Skip to content

Prompt renderer, extglob and shopt: 8 findings from building a 30-theme config on 2.10.2 #134

Description

@LESdylan

Everything below was found while rebuilding hellishrc_plugins against 2.10.2 — a config framework with a 30-theme prompt engine, so it leans hard on the prompt renderer, the hook arrays and shopt. Each item was reproduced in a real pty (not just -c) before being written down, and each says what it cost downstream.

hellish, version 2.10.2 (hellish-linux-x86_64)
Ubuntu 22.04.4, xterm-256color, COLORTERM=truecolor, LANG=en_US.UTF-8
user-mode install: ~/.local/bin/hellish

The prompt work in 2.9/2.10 is genuinely good — % and \ in one PS1, the self-spacing badges, HELLISH_*_FUNCS, right prompts. Most of what follows is the sharp edges around that.


1. print -rP and the prompt renderer disagree about variable content

The most consequential one, because it makes prompt preview lie.

The prompt renderer does not re-scan what a variable expanded to. print -rP does. So the same theme renders two different ways depending on which path you go through.

$ V='50%off'; print -rP "[${V}]"
[50ff]                                   # %o was eaten as a prompt escape

$ V='100%done'; print -rP "[${V}]"
[100/tmp/…/scratchpadone]                # %d expanded to the cwd

versus the real prompt, in a pty:

PS1='[${V}] '   with V='50%off'    →  [50%off]        # correct, left alone
PS1='[${V}] '   with V='%F{46}x%f' →  [%F{46}x%f]     # inert, not colour
PS1='[${V}] '   with V='x\g y'     →  [x\g y]         # inert, not the branch

Both behaviours are defensible; having both is not. share/hellish/rc.d/40-prompt-switch.hsh ships prompt preview built on print -rP, and it already carries a comment explaining that it doubles % "because print -P always enters through the zsh frontend" — that comment is documenting this bug rather than a design.

Why it matters: any prompt whose content comes from a variable — which is every prompt with a computed segment, since $( ) is unavailable (§2) — previews as something you will never actually see. A branch called feature/50%-done or a directory called 100%done is enough.

Suggested: make print -rP treat post-expansion text as literal, the way the renderer does. Failing that, document the split loudly, because the natural assumption is that the preview renderer is the renderer.

Worked around by escaping every segment on the way into a preview and restoring it after — _hx_preview_escape in lib/prompt.hsh.


2. $( ) is never expanded in a prompt, and nothing says so

PS1='[$(echo LIVE)] '   →   [$(echo LIVE)]      # literal, forever
PS1='[${VAR}] '         →   expands every render
PS1='[$((6*7))] '       →   [42]

hellishrc.example says "Live $VAR / ${VAR} expansion happens at every render, and so does $? and the other single-character specials" — true, and it never mentions command substitution, so the reader is left to infer it from an omission. Every other shell expands $( ) in PS1, so the inference goes the wrong way.

This is not a small limitation: it is the difference between "a theme is a string" and "a theme needs a hook, a variable, and an invalidation story". The whole segment architecture in my engine exists because of it.

Suggested: either expand it (behind setopt PROMPT_SUBST, as zsh does), or state the exclusion explicitly next to the $VAR line. A prompt that silently prints $(git branch) looks like a broken config, not an unsupported feature.


3. shopt -s extglob is accepted, reports on, and then fails to parse

$ hellish -c 'shopt -s extglob; shopt extglob'
extglob             	on

$ hellish -c 'shopt -s extglob; case ab in @(ab|cd)) echo MATCH;; esac'
hellish: line 1: syntax error near unexpected token `('     # exit 2

This is worse than an unimplemented option, for one specific reason: the failure is at parse time, so the guard everyone writes does not work.

if shopt -q extglob; then       # true
    case "$x" in @(a|b)) ... ;; esac   # the whole FILE fails to parse
fi

You cannot feature-detect your way out of it from inside the file — the branch never runs because the file never parses. The only safe probe is out-of-process (hellish -c '…'), which is what my capability probe ended up doing.

Suggested: make shopt -s extglob fail and report off until the lexer supports it. An option that lies about being on is strictly worse than one that refuses.

The other glob knobs are fine now, and worth noting as fixed: dotglob, nocaseglob, globstar and nullglob (arrays included) all behave correctly on 2.10.2, where they were accepted no-ops on 2.7.6.


4. PS2 is silently ignored

$ PS2='CUSTOM2> '
$ echo one \
> two            # still '> ', not CUSTOM2>

PS2 is settable, readable, and has no effect. hellishrc.example and several shipped themes set it, which suggests it is expected to work. Either honour it or drop it from the examples, so nobody else spends time wondering why their continuation prompt never changes.


5. COLUMNS and LINES are never set

$ shopt -s checkwinsize; pretty on resize-aware; /bin/true
$ echo "[${COLUMNS:-unset}] [${LINES:-unset}]"
[unset] [unset]

checkwinsize is on by default and pretty on resize-aware is advertised as "track the terminal size after every command" — so the shell clearly knows the size. It just never exports it, where bash maintains both in interactive shells.

Cost: anything that needs the terminal width has to fork tput cols. In a prompt that is a process per render, which is exactly the thing a fast prompt cannot afford. My full-width-rule theme has to declare itself expensive and cache the answer for the life of the shell, so it gets the wrong width after a resize.

Suggested: set COLUMNS/LINES when checkwinsize/resize-aware is on. The tracking already exists; this is publishing it.


6. Missing variables: EPOCHREALTIME, SRANDOM

$ echo "[${EPOCHSECONDS}] [${EPOCHREALTIME:-unset}] [${SRANDOM:-unset}]"
[1789416704] [unset] [unset]

EPOCHSECONDS is there, EPOCHREALTIME is not. For prompt timing, one-second resolution is not useful — date +%s%N is a fork, which is the cost you were trying to avoid by having EPOCHSECONDS at all. SRANDOM is minor by comparison.


7. No bind, so key bindings cannot come from a config

$ hellish -c 'bind -p'
hellish: bind: command not found

set -o emacs / set -o vi work and are the only line-editing control an rc has. With complete landed in 2.10, bind is the one remaining hole where a config cannot reach the line editor at all — no history-prefix search on Up, no completion-ignore-case, no rebinding C-w.

Not a bug, but it is the highest-value missing builtin from a configuration author's point of view, and worth a line in the docs saying it is unavailable rather than leaving people to discover it.


8. Docs and examples that no longer match the binary

Small things, but each one costs a reader real time because they read as authoritative.

  • USER_DOC.md has no prompt section. Everything about the prompt — the bilingual PS1, the badges, the hook arrays — lives in hellishrc.example comments and wiki/interactive.md. grep -n '^#+ ' USER_DOC.md for prompt/PS1/theme returns nothing. The example file is doing the job of the manual.
  • wiki/interactive.md says prompt lists 29 themes. prompt is not a builtin; it is a shell function seeded into ~/.config/hellish/rc.d/40-prompt-switch.hsh. On a machine where that seed did not run, prompt is command not found and the wiki looks wrong.
  • \A shadows bash's 24-hour clock escape. Documented, and the right call given it shipped first — but a bash prompt pasted in gets an animation frame where it expected 14:30, with no warning at the point of use.
  • The animation warning deserves the docs, not just a comment. hellishrc.example says \A "has been reported to corrupt the prompt on some terminals … not reproducible on demand". That is important and currently only visible to someone reading the example file.

What works well, for balance

Worth saying, since a bug report is a biased sample. All verified on 2.10.2, all fixed since 2.7.6:

complete / compgen (with shopt -s progcomp) · printf %q · printf '\e' · declare -F and alias -p · subscripted array writes from a function defined in a sourced file · local -n / declare -n namerefs · [ -v VAR ] · read -n N · globstar, dotglob, nocaseglob, nullglob in arrays · process substitution <( ) · dirs as a builtin · mapfile / readarray -d · FUNCNAME with a real call stack · BASH_SOURCE · trap DEBUG

And specifically in the prompt: RPROMPT right-aligns correctly and is erased as the line grows; %F{n} %K{n} %B %~ %N<…< %(?.a.b) %(!.a.b) all match zsh; $? is correctly preserved across HELLISH_PRECMD_FUNCS, so a status badge shows your command's result and not the hook's:

PS1='[rc=%?]\S> '  with a precmd hook installed
    false  →  [rc=1] ✘1>      # correct

That last one is easy to get wrong and you got it right.


Priority, if it helps

1 §1 preview/renderer split makes the shipped prompt preview lie
2 §3 extglob reporting on cannot be guarded against from inside a file
3 §2 $( ) undocumented changes how every dynamic prompt must be built
4 §5 COLUMNS unset forces a fork per prompt for width
5 §4 PS2 ignored silent, and the shipped examples set it

Happy to open these as separate issues, supply the pty harness, or test patches — the harness that produced every transcript above is a ~40-line pty.fork() driver and I can attach it if useful.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions