Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions incs/prompt.h
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
24 changes: 11 additions & 13 deletions share/rc.d/40-prompt-switch.hsh
Original file line number Diff line number Diff line change
Expand Up @@ -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:<hostname>)`, 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.
#
Expand All @@ -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
Expand Down
7 changes: 6 additions & 1 deletion src/expander/expand_param_xform.c
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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));
}

Expand Down
61 changes: 61 additions & 0 deletions src/infrastructure/prompt_expand.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/* ************************************************************************** */
/* */
/* ::: :::::::: */
/* prompt_expand.c :+: :+: :+: */
/* +:+ +:+ +:+ */
/* By: marvin <marvin@student.42.fr> +#+ +:+ +#+ */
/* +#+#+#+#+#+ +#+ */
/* 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 <stdlib.h>

/* ${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);
}
155 changes: 155 additions & 0 deletions tests/prompt_expand_p_test.py
Original file line number Diff line number Diff line change
@@ -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())
15 changes: 8 additions & 7 deletions tests/prompt_themes_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
49 changes: 49 additions & 0 deletions tests/scripts/61_prompt_expand_P.sh
Original file line number Diff line number Diff line change
@@ -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"
5 changes: 5 additions & 0 deletions wiki/interactive.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading