From f6cdb0cb5c81047d442eae6c2767443cad7482a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 10:47:57 +0000 Subject: [PATCH] feat(prompt): ${var@P} renders through the PS1 renderer; prompt preview uses it $ V='100%done'; PS1='[${V}] ' prompt: [100%done] prompt preview: [100/home/me/projone] # %d read as an escape Section 1 of issue #134: `prompt preview` drew prompts that never appear. It rendered a PS1 theme with `print -rP "${PS1//%/%%}"`. print -P is zsh's, and like zsh's it substitutes a variable first and then reads the value for `%` escapes. A PS1 does not; hellish follows bash's order there. So a segment holding `100%done`, `50%off` or `\w` previewed as something else. The doubling also hid the bilingual PS1's own `%~` from the preview. print -P is right to be zsh's. What was missing is a way to render a PS1 exactly as the prompt does, and bash has one: ${var@P}. hellish parsed it and returned the value unchanged (a documented v1 scope-out). prompt_expand_p (prompt_expand.c) implements it as the PS1 rule itself: - zsh_to_ps1 in the mode the dialect bit selects, then ps1_render, the same pair prompt_normal uses for PS1; - under `set -o zsh`, where PS1 is PROMPT, a value's `%` is an escape again, as in the live prompt; - ps1_render, not ps1_animated: an @P from a hook or inside a PS1 must not rebuild the live prompt's animation cells; - \[ \] become nothing outside a line editor, as in bash; HELLISH_DBG_PROMPT_MARKS keeps them, the window print -P already gives the width tests. prompt_preview draws a PS1 theme with "${PS1@P}". PROMPT themes stay on print -rP, which is the live PROMPT's renderer. Tests: - tests/scripts/61_prompt_expand_P.sh, graded against bash --posix, covers: - the escapes both shells have, and \[ \]; - \nnn and \D{}; - $VAR, ${...} and $((...)), with values holding \w, %d and $HOME left as they are; - unset and empty values, positionals, a function's arguments; - quoting and assignment contexts. develop prints the values raw; this branch matches bash. - tests/prompt_expand_p_test.py, at a real prompt, in-process and forked readers, checks that: - the live PS1 and ${PS1@P} are the same bytes, and stay so after the variable changes; - the same holds under set -o zsh, where the value IS read again; - `prompt preview` prints the live prompt for a PS1 theme. develop fails 8 of its 14 checks. Its preview line is the issue's bug verbatim: [100/tmp/...one. - tests/prompt_themes_test.py renders PS1 themes the way the preview now does. All checks pass. Refs #134 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RAmeHfJNm7XjYbMNrQqvkG --- incs/prompt.h | 1 + share/rc.d/40-prompt-switch.hsh | 24 ++--- src/expander/expand_param_xform.c | 7 +- src/infrastructure/prompt_expand.c | 61 +++++++++++ tests/prompt_expand_p_test.py | 155 ++++++++++++++++++++++++++++ tests/prompt_themes_test.py | 15 +-- tests/scripts/61_prompt_expand_P.sh | 49 +++++++++ wiki/interactive.md | 5 + 8 files changed, 296 insertions(+), 21 deletions(-) create mode 100644 src/infrastructure/prompt_expand.c create mode 100644 tests/prompt_expand_p_test.py create mode 100644 tests/scripts/61_prompt_expand_P.sh diff --git a/incs/prompt.h b/incs/prompt.h index 967f970e..14a75a13 100644 --- a/incs/prompt.h +++ b/incs/prompt.h @@ -119,6 +119,7 @@ int rl_read_inproc(t_shell *state, char *prompt); char *rl_editor_enter(t_shell *state, char *prompt); void rl_editor_exit(t_shell *state); t_string prompt_normal(t_shell *state); +char *prompt_expand_p(t_shell *state, const char *fmt); char *prompt_more_input(t_shell *state, struct s_parser *parser); char *prompt_ps2(t_shell *state, const char *fallback); void buff_readline_init(t_rl *ret); diff --git a/share/rc.d/40-prompt-switch.hsh b/share/rc.d/40-prompt-switch.hsh index 75fe66cb..8e398569 100644 --- a/share/rc.d/40-prompt-switch.hsh +++ b/share/rc.d/40-prompt-switch.hsh @@ -49,22 +49,20 @@ prompt_list() { # Render every theme, then put back the one that was active. # -# `print -rP` is the renderer, not a reimplementation of one: it runs the -# same code the prompt itself does, so a preview cannot disagree with what -# you get after switching. It handles both spellings -- the % escapes of a -# PROMPT theme and the backslash escapes of a PS1 one -- because the zsh -# frontend rewrites into the backslash language and they share the engine. +# Each theme goes through the renderer its own prompt uses, never a +# reimplementation, so a preview cannot disagree with what you get after +# switching. A PROMPT theme is zsh's `%` language, which `print -rP` +# renders exactly as the prompt does. A PS1 theme is `${PS1@P}`: bash's +# prompt-expansion operator, which runs the PS1 rule itself -- both escape +# languages, and a variable's value left as it is. That last part is why +# this is not `print -rP` too: print -P scans what a variable expanded to +# for escapes again, as zsh's does, and the PS1 renderer does not, so a +# PS1 segment holding `100%done` or `\w` previewed as a prompt that never +# appears (issue #134, section 1). # # The \001/\002 pair is stripped: those are the zero-width markers `\[` and # `\]` become, invisible to a line editor and line noise on a terminal. # -# A PS1 theme has its percent signs doubled first. `print -P` always enters -# through the zsh frontend, and a bare `%` there is an escape -- so a PS1 -# containing `$(date +%H:%M)` previewed as `$(date +%H:)`, because -# %M is zsh for the full host name. Doubling makes each one literal, which -# is what the backslash renderer would have done with it anyway. Without -# this the preview quietly lies about any prompt containing a percent. -# # A two-row theme takes two rows here. Flattening it would show a prompt # that does not exist. # @@ -82,7 +80,7 @@ prompt_preview() { if [ -n "${PROMPT:-}" ]; then print -rP "$PROMPT" | tr -d '\001\002' else - print -rP "${PS1//%/%%}" | tr -d '\001\002' + printf '%s\n' "${PS1@P}" | tr -d '\001\002' fi done if [ -n "$was" ]; then diff --git a/src/expander/expand_param_xform.c b/src/expander/expand_param_xform.c index 7a4b8e4c..50e34c46 100644 --- a/src/expander/expand_param_xform.c +++ b/src/expander/expand_param_xform.c @@ -13,12 +13,15 @@ #include "expander_private.h" #include "env.h" #include "helpers.h" +#include "prompt.h" /* ${var@OP} parameter transformations (bash 5): @Q single-quote the value so it can be re-read by the shell @U uppercase @L lowercase @u uppercase-first @A an assignment statement that would recreate the variable - The rarer @E/@P/@a/@K are documented v1 scope-outs. + @P the value rendered as a prompt string, by the PS1 renderer + (prompt_expand_p) -- so ${PS1@P} is the prompt itself + The rarer @E/@a/@K are documented v1 scope-outs. @Q is sq_quote (src/helpers/sq_quote.c), shared with the completion dispatcher, which quotes the word under the cursor for the same reason: @@ -68,6 +71,8 @@ char *expand_xform(t_shell *state, const char *s, int name_len, char op) return (xform_case(val, op)); if (op == 'A') return (xform_assign(s, name_len, val)); + if (op == 'P') + return (prompt_expand_p(state, val)); return (ft_strdup(val)); } diff --git a/src/infrastructure/prompt_expand.c b/src/infrastructure/prompt_expand.c new file mode 100644 index 00000000..49532e31 --- /dev/null +++ b/src/infrastructure/prompt_expand.c @@ -0,0 +1,61 @@ +/* ************************************************************************** */ +/* */ +/* ::: :::::::: */ +/* prompt_expand.c :+: :+: :+: */ +/* +:+ +:+ +:+ */ +/* By: marvin +#+ +:+ +#+ */ +/* +#+#+#+#+#+ +#+ */ +/* Created: 2026/09/26 00:30:00 by marvin #+# #+# */ +/* Updated: 2026/09/26 00:30:00 by marvin ### ########.fr */ +/* */ +/* ************************************************************************** */ + +#include "prompt_private.h" +#include "sh_input.h" +#include + +/* ${var@P}: the value of var rendered as a prompt string, bash's way to + ask the shell what a prompt looks like (issue #134, section 1). + + It is the PS1 rule and nothing else: the one prompt_normal applies to + PS1 -- zsh_to_ps1 in the mode the dialect bit selects, then + ps1_render. So `${PS1@P}` is what the prompt shows, including what it + does NOT do: a variable's value is not scanned again for escapes, in + either language (V='100%done' stays 100%done, V='\w' stays \w), which + is also bash's order. print -P is zsh's and does scan again, as zsh's + does; a preview of a PS1 theme built on it drew prompts that never + appear. + + ps1_render, not ps1_animated: the animation cells belong to the live + prompt, and an @P inside a PS1 or a precmd hook must not rebuild them + from another string. + + \[ and \] become readline's zero-width markers, \001 and \002. Without + a line editor bash drops them, so they are stripped here unless the + read is interactive -- or HELLISH_DBG_PROMPT_MARKS asks for them, the + window print -P gives the width tests too. Returns a new string. */ +char *prompt_expand_p(t_shell *state, const char *fmt) +{ + t_string conv; + t_string out; + char *s; + size_t i; + size_t j; + + conv = zsh_to_ps1(state, fmt, zsh_mode(state)); + out = ps1_render(state, (char *)conv.ctx); + xfree(conv.ctx); + s = (char *)out.ctx; + if (state->metinp == INP_RL || getenv("HELLISH_DBG_PROMPT_MARKS")) + return (s); + i = 0; + j = 0; + while (s[i]) + { + if (s[i] != '\001' && s[i] != '\002') + s[j++] = s[i]; + i++; + } + s[j] = '\0'; + return (s); +} diff --git a/tests/prompt_expand_p_test.py b/tests/prompt_expand_p_test.py new file mode 100644 index 00000000..ce78c05c --- /dev/null +++ b/tests/prompt_expand_p_test.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +"""${PS1@P} is the prompt the shell actually shows (issue #134, section 1). + +`prompt preview` rendered PS1 themes with `print -rP`. print -P is zsh's +and, as in zsh, scans what a variable expanded to for escapes again; the +PS1 renderer does not (bash's order). So a theme segment holding +`100%done` previewed as `100/home/...one`, a prompt that never appears. +${var@P}, bash's prompt-expansion operator, now runs the PS1 rule itself +and the preview uses it. + +What this pins, at a real prompt: + * the live PS1 and ${PS1@P} are the same bytes, in hellish's bilingual + PS1 (a `%~` escape and a `\\W` escape side by side) and under + `set -o zsh`, where PS1 is zsh's PROMPT and a variable's `%` IS read + again -- @P follows the mode the prompt follows; + * a variable's value is left as it is in the bilingual PS1; + * `prompt preview` prints that same line for a PS1 theme. + +The part of @P hellish shares with bash is graded against bash in +tests/scripts/61_prompt_expand_P.sh. + +Cells: the in-process reader, and HELLISH_RL_FORK=1. + +Usage: python3 prompt_expand_p_test.py [/path/to/hellish] +""" +import os +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.join(HERE, "helpers")) +from ptyexpect import Tty # noqa: E402 + +ROOT = os.path.dirname(HERE) +SHELL = os.path.abspath(sys.argv[1] if len(sys.argv) > 1 + else os.path.join(ROOT, "build", "bin", "hellish")) +SWITCH = os.path.join(ROOT, "share", "rc.d", "40-prompt-switch.hsh") +FAILS = [] +T = 8.0 +ROOT_USER = os.geteuid() == 0 + +# The value is what a theme segment gets from a hook: text with a percent +# and a backslash in it. +VALUE = r"100%done \w %d" +RC = ("V='%s'\n" % VALUE + + "PS1='[${V}|%~|\\W|$((6*7))]\\$ '\n") +# HOME is the pty's temp dir and the shell starts in it: %~ and \W are ~. +LIVE = ("[%s|~|~|42]%s " % (VALUE, "#" if ROOT_USER else "$")).encode() + + +def check(name, ok, detail=""): + print(("ok " if ok else "FAIL ") + name + ("" if ok else " " + detail)) + if not ok: + FAILS.append(name) + + +def at_p(t, var): + """Print ${var@P} between markers; return it, or None on a timeout.""" + t.send("printf '<%%s>\\n' \"${%s@P}\"\r" % var) + mark = len(t.out) + if not t.expect(b">\r\n", T): + return None + got = t.since(mark) + start = got.rfind(b"\n<") + return got[start + 2:-2] if start >= 0 else None + + +def live_prompt(t, suffix): + """Run a command and return the prompt printed after it, through + `suffix` (the prompt's known last bytes). The marker is typed as + MA''RK so that its echo cannot be mistaken for its output.""" + t.send("echo MA''RK\r") + if not t.expect(b"\nMARK\r\n", T): + return None + mark = t.pos + if not t.expect(suffix, T): + return None + return t.out[mark:t.pos] + + +def bilingual_cell(cell, env): + t = Tty(SHELL, rc=RC, env=env) + try: + ok = t.expect(LIVE, T) + check("%s/live-prompt-leaves-the-value" % cell, ok, + "tail=%r" % t.out[-160:]) + got = at_p(t, "PS1") + check("%s/PS1@P-is-the-live-prompt" % cell, got == LIVE, + "got=%r want=%r" % (got, LIVE)) + # A later change to the variable shows in both, the same way. + t.send("V='50%off \\e'\r") + want = LIVE.replace(VALUE.encode(), b"50%off \\e") + live = live_prompt(t, want[-4:]) + check("%s/live-prompt-follows-the-variable" % cell, live == want, + "live=%r want=%r" % (live, want)) + got = at_p(t, "PS1") + check("%s/PS1@P-follows-the-variable" % cell, got == want, + "got=%r want=%r" % (got, want)) + finally: + t.close() + + +def zsh_cell(cell, env): + """Under set -o zsh, PS1 is zsh's PROMPT: the value is substituted + first and its `%` IS an escape (zsh's PROMPT_SUBST order). @P follows + the prompt there too.""" + rc = "V='50%~x'\nset -o zsh\nPS1='[${V}]%# '\n" + t = Tty(SHELL, rc=rc, env=env) + try: + suffix = b"]# " if ROOT_USER else b"]% " + live = live_prompt(t, suffix) + check("%s/zsh-live-prompt-reads-the-value" % cell, + live == b"[50~x" + suffix, + "live=%r" % live) + got = at_p(t, "PS1") + check("%s/zsh-PS1@P-is-the-live-prompt" % cell, got == live, + "got=%r live=%r" % (got, live)) + finally: + t.close() + + +def preview_cell(cell, env): + """prompt preview prints, for a PS1 theme, the line the prompt shows + once the theme is active.""" + t = Tty(SHELL, rc=RC, env=env) + tdir = os.path.join(t.home, "themes") + os.mkdir(tdir) + with open(os.path.join(tdir, "pct.hsh"), "w") as f: + f.write("PS1='[${V}|%~|\\W|$((6*7))]\\$ '\n") + try: + t.expect(LIVE, T) + t.send("HELLISH_THEMES=%s; . %s; prompt preview; echo DO''NE\r" + % (tdir, SWITCH)) + mark = len(t.out) + ok = t.expect(b"\nDONE\r\n", T) + out = t.since(mark) + line = b"\npct " + LIVE + b"\n" + check("%s/preview-is-the-live-prompt" % cell, ok and line in out, + "out=%r want=%r" % (out[-200:], line)) + finally: + t.close() + + +def main(): + if not os.path.exists(SHELL): + print("no shell at", SHELL) + return 1 + for cell, env in (("inproc", {}), ("fork", {"HELLISH_RL_FORK": "1"})): + bilingual_cell(cell, env) + zsh_cell(cell, env) + preview_cell(cell, env) + print("\n%d failed" % len(FAILS) if FAILS else "\nall passed") + return 1 if FAILS else 0 + + +sys.exit(main()) diff --git a/tests/prompt_themes_test.py b/tests/prompt_themes_test.py index d2414d42..114dda9f 100644 --- a/tests/prompt_themes_test.py +++ b/tests/prompt_themes_test.py @@ -123,16 +123,17 @@ def visible_width(s): def render(name, env=None): - """Render one theme the way the shell does. `print -rP` runs the real - engine, so this cannot drift from what a user sees; the percent-doubling - is the same one the switcher's preview does, and for the same reason -- - print -P enters through the zsh frontend, where a bare % is an escape.""" + """Render one theme the way the shell does, and the way the switcher's + preview does: a PROMPT theme through `print -rP`, a PS1 theme through + `${PS1@P}`, which is the PS1 rule itself (a variable's value is not + read again for escapes; print -P's zsh order would), so this cannot + drift from what a user sees.""" script = (". %s\n" % SWITCH + "prompt %s >/dev/null 2>&1\n" % name + 'if [ -n "${PROMPT:-}" ]; then print -rP "$PROMPT"; ' - + 'else print -rP "${PS1//%/%%}"; fi\n') - # print -P strips the \001/\002 width guards by default, because zsh's - # print -P emits none (measured; the parity suite pins it). This test + + "else printf '%s\\n' \"${PS1@P}\"; fi\n") + # print -P and, outside a line editor, @P strip the \001/\002 width + # guards, as zsh's print -P and bash's @P do (measured). This test # exists to SEE those bytes, so it asks for them by name. e = {"HELLISH_THEMES": THEMES, "HELLISH_DBG_PROMPT_MARKS": "1"} if env: diff --git a/tests/scripts/61_prompt_expand_P.sh b/tests/scripts/61_prompt_expand_P.sh new file mode 100644 index 00000000..3092dd48 --- /dev/null +++ b/tests/scripts/61_prompt_expand_P.sh @@ -0,0 +1,49 @@ +# ${var@P}: the value rendered as a prompt string (bash 4.4). This is the +# part hellish and bash agree on byte for byte -- the escapes both have, +# parameter and arithmetic expansion, and that what a variable expanded +# to is NOT read again for escapes. hellish's own additions (the zsh `%` +# escapes of its bilingual PS1, \A, \g) and `${PS1@P}` being exactly the +# live prompt are pinned at a real prompt, tests/prompt_expand_p_test.py. +d=$(mktemp -d) +cd "$d" || exit 1 +mkdir leaf && cd leaf || exit 1 +HOME=$d + +show() { for a; do printf '<%s>\n' "$a"; done; } + +P='\w \W \$ \\ \n.' +show "${P@P}" +P='\u' Q='\h' R='\H' +[ "${P@P}" = "$(id -un)" ] && echo "\\u is the user" +[ "${Q@P}" = "$(hostname | cut -d. -f1)" ] && echo "\\h is the host" +[ "${R@P}" = "$(hostname)" ] && echo "\\H is the full host" + +# \[ \] are readline's markers; with no line editor they are nothing. +P='a\[b\]c\e[0m' +show "${P@P}" | od -An -c | tr -s ' ' + +# \nnn octal, \D{fmt}. +P='\101\060 \D{%Y}' +[ "${P@P}" = "A0 $(date +%Y)" ] && echo "octal and \\D" + +# Expansions run after the escapes, and what they produce stays as it is. +V='100%done \w %d \$HOME $HOME' +P='[${V}] [$V] [$((6 * 7))] [${V%% *}] [${nosuch-unset}]' +show "${P@P}" + +# An empty and an unset variable both render as nothing. +E= +show "${E@P}" "${nosuch@P}" + +# Positionals and a function's arguments. +set -- '\W' '$((1+1))' +show "${1@P}" "${2@P}" +f() { show "${1@P}"; } +f 'in \W' + +# In double quotes, in an assignment, as part of a word. +P='\W' +x=${P@P} +show "$x" "pre${P@P}post" + +cd / && rm -rf "$d" diff --git a/wiki/interactive.md b/wiki/interactive.md index cea4db95..ceb65304 100644 --- a/wiki/interactive.md +++ b/wiki/interactive.md @@ -118,6 +118,11 @@ Bash's escape set is implemented — `\u \h \H \w \W \t \d \D{fmt} \T \@ \! \# \ \n \e \a \$ \\ \[ \] \nnn` — plus hellish's own: `\g` git branch, `\S` failure badge, `\p` duration, `\J` jobs, `\U` pending update, `\B` the built-in prompt, `\I` the file being sourced. +`${PS1@P}` prints what PS1 renders to. It is bash's prompt-expansion operator, and here it runs +the PS1 renderer itself, both escape languages included, so it cannot disagree with the prompt: +`prompt preview` draws PS1 themes with it. (`print -P` is zsh's, and like zsh's it reads a +variable's value for `%` escapes again; a PS1 does not.) + **`\A` is the one deliberate divergence.** In bash it is the 24-hour clock; in hellish it is the animation frame, and it shipped first. `\D{%H:%M}` gives you bash's meaning. A bash PS1 pasted in with `\A` shows the glyph, or nothing while the animation is off, which is the default: the idle