From 3c0e69d9d2c99fbb6677b9f78e19d2dbec3b4e23 Mon Sep 17 00:00:00 2001 From: LESdylan Date: Sun, 18 Oct 2026 03:28:34 +0200 Subject: [PATCH 1/6] fix(build): make re no longer stops on a file NFS keeps for a running shell On NFS (the 42 machines' sgoinfre) a deleted file that a process still has open stays behind as .nfsXXXX until that process exits, and removing it fails with "Device or resource busy". With any shell started from build/bin still running, fclean's final `rm -rf build` hit exactly that file, so `make re` stopped before compiling anything and left no binary. fclean now accepts a build tree reduced to such files: it says which one it kept and succeeds; the rebuild is unaffected, since the file has a name of its own. Anything else that cannot be removed is retried and fails the target as before. tests/fclean_nfs_test.py cuts the recipe out of the Makefile and runs it next to the repo: with only a held-open .nfs file fclean succeeds and names it; with an unwritable directory it still fails. Off NFS no .nfs file appears and the test reports SKIP. Verified on sgoinfre: `make re` exit 2 before, exit 0 after, with a shell from build/bin running. --- Makefile | 17 ++++++- tests/fclean_nfs_test.py | 101 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 tests/fclean_nfs_test.py diff --git a/Makefile b/Makefile index bc76c02b..14d42467 100755 --- a/Makefile +++ b/Makefile @@ -488,6 +488,14 @@ clean: ## Remove object files, keep the binary # default set above does not, so a plain `make fclean` used to reach libft with # SAFE unset -- landing in its SAFE!=1 branch, running the LTO capability probe # and printing its warning just to delete files. +# +# The final `rm -rf build` cannot finish on NFS (the 42 machines' sgoinfre) +# while a shell started from build/bin is still running: NFS keeps a deleted +# file that is still open as build/bin/.nfsXXXX until its last user exits, and +# refuses to remove it ("Device or resource busy"). That made `make re` stop +# here with nothing rebuilt. Such a file is not ours to remove and does not +# block the rebuild, so fclean succeeds when only those remain; anything else +# left behind is retried and fails the target as before. fclean: clean ## Remove objects, the binary and libft's build trees @printf " \033[1;33m⚠\033[0m \033[1;37mRemoving binary\033[0m" >&2 @rm -f $(BIN_DIR)/$(BAPTIZE_SHELL) @@ -496,7 +504,14 @@ fclean: clean ## Remove objects, the binary and libft's build trees @$(MAKE) -C vendor/libft fclean SAFE=$(SAFE) BUILD_DIR=build-$(SAFE_TAG) @rm -rf vendor/libft/build-ft vendor/libft/build-libc vendor/libft/build @printf "\r\033[K \033[1;32m✓\033[0m \033[37mlibft cleaned\033[0m\n" >&2 - @rm -rf build + @if ! rm -rf build 2>/dev/null; then \ + if find build ! -type d ! -name '.nfs*' | grep -q .; then \ + rm -rf build; \ + else \ + printf " \033[1;33m⚠\033[0m \033[37mkept %s: still open in a running process (NFS)\033[0m\n" \ + "$$(find build -name '.nfs*' | xargs)" >&2; \ + fi; \ + fi @printf "\n" >&2 # Run fclean THEN all, strictly in order. As plain prerequisites diff --git a/tests/fclean_nfs_test.py b/tests/fclean_nfs_test.py new file mode 100644 index 00000000..e2d9de9f --- /dev/null +++ b/tests/fclean_nfs_test.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +"""`make fclean` must not fail on a file NFS keeps for a running process. + +On NFS, deleting a file that a process still has open leaves it behind as +.nfsXXXX until that process exits, and removing it fails with "Device or +resource busy". With a shell from build/bin still running, `make re` used to +stop in fclean on exactly that file, with nothing rebuilt. + +This runs the Makefile's own cleanup recipe (cut out of the Makefile, so the +test cannot drift from it) in a scratch directory next to the repo: + - only a held-open .nfs file left: fclean succeeds and says what it kept; + - a real leftover (an unwritable directory): fclean still fails. +Where the repo is not on NFS no .nfs file ever appears; that is a SKIP, not a +pass. argv[1] (the shell, as for every tests/*.py) is not used. +""" +import os +import shutil +import subprocess +import sys +import tempfile +import time + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +FAILS = [] + + +def check(name, ok, detail=""): + print(("ok " if ok else "FAIL ") + name) + if not ok: + FAILS.append(name) + print(" " + detail) + + +def recipe(): + """The cleanup block of the fclean target, from `@if ! rm -rf build` to + its closing `fi`.""" + out, inside = [], False + with open(os.path.join(ROOT, "Makefile")) as f: + for line in f: + if line.startswith("\t@if ! rm -rf build"): + inside = True + if inside: + out.append(line) + if line == "\tfi\n": + return "".join(out) + return None + + +def fclean(d): + return subprocess.run(["make", "-s", "-C", d, "fclean"], + capture_output=True, text=True, timeout=30) + + +def main(): + body = recipe() + check("recipe/found-in-Makefile", body is not None) + if body is None: + return 1 + d = tempfile.mkdtemp(prefix=".fclean-nfs-", dir=ROOT) + holder = None + try: + with open(os.path.join(d, "Makefile"), "w") as f: + f.write("fclean:\n" + body) + os.makedirs(os.path.join(d, "build", "bin")) + held = os.path.join(d, "build", "bin", "held") + with open(held, "w") as f: + f.write("x") + holder = subprocess.Popen(["sh", "-c", "exec 3<\"$1\"; exec sleep 30", + "sh", held]) + time.sleep(0.3) + os.unlink(held) + if not any(n.startswith(".nfs") + for n in os.listdir(os.path.join(d, "build", "bin"))): + print("SKIP not on NFS: no .nfs file is left for an open file") + return 0 + r = fclean(d) + check("nfs-only/fclean-succeeds", r.returncode == 0, r.stderr) + check("nfs-only/says-what-it-kept", "kept build/bin/.nfs" in r.stderr, + r.stderr) + locked = os.path.join(d, "build", "locked") + os.makedirs(locked) + open(os.path.join(locked, "f"), "w").close() + os.chmod(locked, 0o555) + r = fclean(d) + os.chmod(locked, 0o755) + check("real-leftover/fclean-still-fails", r.returncode != 0, r.stderr) + finally: + if holder: + holder.kill() + holder.wait() + # NFS drops the .nfs file only once the holder is gone; retry. + for _ in range(20): + shutil.rmtree(d, ignore_errors=True) + if not os.path.exists(d): + break + time.sleep(0.25) + print("%d failed" % len(FAILS)) + return 1 if FAILS else 0 + + +sys.exit(main()) From 79fa12d6f07f2566fa12dbff73a3369e45211779 Mon Sep 17 00:00:00 2001 From: LESdylan Date: Sun, 18 Oct 2026 03:36:19 +0200 Subject: [PATCH 2/6] fix(editor): free the two undo lists readline 8.1 drops on the floor ASan reported a leak at exit after an ordinary editing session: 32 bytes plus the typed text, allocated by readline's rl_add_undo. Two paths in readline 8.1 (Ubuntu 22.04's, what the 42 machines link) lose an undo list once per line, and the in-process reader made them the shell's: - Saved line. Walking the history from a typed line with previous-history (Ctrl-P, or Up when prefix search is off) saves that line, with its undo list as data, so Down can bring it back. Ended on the recalled entry (Enter, ^C, ^G), the saved copy outlives the read and the next readline() frees it without its data. hist_rl_reclaim_saved() now runs before each read and takes the list back with public calls only: rl_maybe_save_line() so there is a saved line (unsave rings the bell otherwise), rl_maybe_unsave_line() to move its data into rl_undo_list, rl_free_undo_list() to free it, which also clears any history entry still pointing at it. - Aborted search. Alt-N / Alt-P (stock non-incremental search) reads its string with an undo list of its own; aborting restores the saved line over rl_undo_list. rl_nsearch_reclaim() frees that list in the reader when the byte readline is about to get ends the search: ^G, ^C, Rubout or ^H on an empty string, a failed read. A readline that frees it itself finds NULL. Marked Caveat: the abort keys mirror 8.1's search.c. Both predate #136/#140 (same report on 134ac5af); prefix search made the first one the everyday path on a non-PIE build, where Up was plain previous-history. tests/rl_undo_leak_test.py runs 10 leaking key sequences, each in a fresh shell with detect_leaks=1, plus 3 checks that the keys still work (Up then Down restores the typed line, Alt-P finds, ^G restores). Before: 11 failed; after: 0. SKIP on a binary built without ASan. --- incs/history.h | 1 + src/infrastructure/history_rl.c | 25 ++++++++ src/infrastructure/rl_private.h | 1 + src/platform/posix/rl_getc.c | 4 +- src/platform/posix/rl_inproc.c | 1 + src/platform/posix/rl_nsearch.c | 39 ++++++++++++ tests/rl_undo_leak_test.py | 103 ++++++++++++++++++++++++++++++++ 7 files changed, 172 insertions(+), 2 deletions(-) create mode 100644 src/platform/posix/rl_nsearch.c create mode 100644 tests/rl_undo_leak_test.py diff --git a/incs/history.h b/incs/history.h index 9b342821..790b8a92 100644 --- a/incs/history.h +++ b/incs/history.h @@ -54,6 +54,7 @@ void hist_rehome(t_shell *state); keeps on an edited entry (history_rl.c). Never call the raw ones. */ void hist_rl_drop(int idx); void hist_rl_clear(void); +void hist_rl_reclaim_saved(void); void init_history(t_shell *state); void free_hist(t_shell *state); void parse_history_file(t_shell *state); diff --git a/src/infrastructure/history_rl.c b/src/infrastructure/history_rl.c index d95afc94..32cdcb54 100644 --- a/src/infrastructure/history_rl.c +++ b/src/infrastructure/history_rl.c @@ -61,6 +61,31 @@ void hist_rl_drop(int idx) undo_list_free((UNDO_LIST *)free_history_entry(e)); } +/* Before each read: free the undo list readline parked with its saved +** copy of the typed line. +** +** Walking the history from a typed line (previous-history: Ctrl-P, or Up +** with prefix search off) makes readline save that line, with its undo +** list as the entry's data, so Down can bring it back. When the line ends +** elsewhere -- Enter or ^C on a recalled entry -- the saved copy outlives +** the read, and the next readline() frees it without the undo list: +** readline 8.1 (Ubuntu 22.04's) leaked the typed text's undo records once +** per such line, which ASan reported at exit. +** rl_maybe_unsave_line() is the public way to take that list back into +** rl_undo_list, and rl_free_undo_list() frees it as readline does, also +** dropping any history entry still pointing at it. Unsave rings the bell +** when nothing is saved, so the save before it makes sure something is. +** Both rewrite rl_line_buffer, which is harmless here: the read that +** follows starts from an empty line. */ +void hist_rl_reclaim_saved(void) +{ + if (!rl_line_buffer) + return ; + rl_maybe_save_line(); + rl_maybe_unsave_line(); + rl_free_undo_list(); +} + /* clear_history(), freeing the undo lists it cannot. */ void hist_rl_clear(void) { diff --git a/src/infrastructure/rl_private.h b/src/infrastructure/rl_private.h index b3f31bbd..ccece657 100644 --- a/src/infrastructure/rl_private.h +++ b/src/infrastructure/rl_private.h @@ -165,6 +165,7 @@ int rl_shell_leave(t_shell *state, t_rl_bracket *b); void rl_prerow_arm(t_string *rows); FILE *rl_out_open(void); int rl_getc_hook(FILE *stream); +int rl_nsearch_reclaim(int c); int rl_idle_timeout(void); int rl_idle_fd(void); void rl_idle_event(int r); diff --git a/src/platform/posix/rl_getc.c b/src/platform/posix/rl_getc.c index 2271cba4..18e4e886 100644 --- a/src/platform/posix/rl_getc.c +++ b/src/platform/posix/rl_getc.c @@ -128,7 +128,7 @@ int rl_getc_hook(FILE *stream) { rl_take_signals(); if (rl_should_abort()) - return (rl_abort_value()); + return (rl_nsearch_reclaim(rl_abort_value())); rl_block(&old); r = -1; if (!rl_pending_signal() && !rl_should_abort()) @@ -141,5 +141,5 @@ int rl_getc_hook(FILE *stream) } if (r == '\r' || r == '\n') rl_suggest_unpaint(); - return (r); + return (rl_nsearch_reclaim(r)); } diff --git a/src/platform/posix/rl_inproc.c b/src/platform/posix/rl_inproc.c index 40e0487e..3d181303 100644 --- a/src/platform/posix/rl_inproc.c +++ b/src/platform/posix/rl_inproc.c @@ -58,6 +58,7 @@ int rl_read_inproc(t_shell *state, char *prompt) char *row; char *line; + hist_rl_reclaim_saved(); row = rl_editor_enter(state, prompt); line = readline(row); rl_editor_exit(state); diff --git a/src/platform/posix/rl_nsearch.c b/src/platform/posix/rl_nsearch.c new file mode 100644 index 00000000..8969f610 --- /dev/null +++ b/src/platform/posix/rl_nsearch.c @@ -0,0 +1,39 @@ +/* ************************************************************************** */ +/* */ +/* ::: :::::::: */ +/* rl_nsearch.c :+: :+: :+: */ +/* +:+ +:+ +:+ */ +/* By: dlesieur +#+ +:+ +#+ */ +/* +#+#+#+#+#+ +#+ */ +/* Created: 2026/10/18 03:40:00 by dlesieur #+# #+# */ +/* Updated: 2026/10/18 03:40:00 by dlesieur ### ########.fr */ +/* */ +/* ************************************************************************** */ + +#include "rl_private.h" + +/* The byte (or EOF / READERR) the reader is about to hand readline, after +** freeing the search string's undo list when that byte ends a +** non-incremental search (Alt-N / Alt-P, stock) without searching. +** +** The search reads its string into the line buffer with an undo list of +** its own: it saves the line, sets rl_undo_list to NULL and inserts. On +** Enter it frees that list before searching; on abort it puts the saved +** line back, overwriting rl_undo_list, and readline 8.1 (Ubuntu 22.04's) +** loses the list -- a leak per aborted search, reported by ASan at exit. +** Freeing it here, before readline sees the key, leaves nothing to lose, +** and a readline that frees it itself finds NULL. +** +** Caveat: the abort keys are readline 8.1's (search.c, +** _rl_nsearch_dispatch): ^G, ^C, Rubout or ^H on an empty string, and a +** failed read. A key a later readline adds to that list leaks again; a +** key it drops loses only the undo history of the search string. */ +int rl_nsearch_reclaim(int c) +{ + if (!RL_ISSTATE(RL_STATE_NSEARCH)) + return (c); + if (c == '\a' || c == 3 || c < 0 + || ((c == '\b' || c == 127) && rl_point == 0)) + rl_free_undo_list(); + return (c); +} diff --git a/tests/rl_undo_leak_test.py b/tests/rl_undo_leak_test.py new file mode 100644 index 00000000..e86eac28 --- /dev/null +++ b/tests/rl_undo_leak_test.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +"""No undo list leaks out of readline's history walk or its aborted search. + +Two places where readline 8.1 (Ubuntu 22.04's) drops an undo list it owns, +each once per line, reported by LeakSanitizer when the shell exits: + + saved line type, then walk the history with previous-history (Ctrl-P, or + Up with prefix search off) and end the line on the recalled + entry: Enter, ^C or ^G. The next read freed readline's saved + copy of the typed line, but not its undo list. + Fixed by hist_rl_reclaim_saved() (history_rl.c). + search Alt-P / Alt-N (stock non-incremental search), type a string, + abort with ^G or Rubout on an empty string: readline restored + the line over the search string's undo list. + Fixed by rl_nsearch_reclaim() (rl_nsearch.c). + +Each case is a fresh shell (in-process reader: the forked one discards its +heap with the child) run with detect_leaks=1; it must exit without a +report. The `works/` cases check the keys still do their job. On a binary +built without ASan nothing can be measured: SKIP, not pass. +""" +import os +import sys +import time + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), + "helpers")) +from ptyexpect import Tty # noqa: E402 + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +SHELL = os.path.abspath(sys.argv[1] if len(sys.argv) > 1 else + os.path.join(ROOT, "build", "bin", "hellish")) +FAILS = [] +UP, DOWN = "\x1b[A", "\x1b[B" +ALT_P, ALT_N = "\x1bp", "\x1bn" +HISTORY = ["echo alpha", "echo beta"] + + +def check(name, ok, detail=""): + print(("ok " if ok else "FAIL ") + name) + if not ok: + FAILS.append(name) + print(" " + detail) + + +def session(name, steps, want=None): + """A shell with HISTORY run, then each key group of `steps` sent and + given time to land, then exit. No leak report allowed; `want`, when + given, must be printed by the line the keys ran.""" + t = Tty(SHELL, rc="PS1='P$ '\nHISTFILE=/dev/null\n", + env={"ASAN_OPTIONS": "detect_leaks=1", + "HELLISH_HISTORY_PREFIX": "0"}) + ok = t.expect(b"P$ ") + for c in HISTORY: + t.send(c + "\r") + ok = ok and t.expect(b"P$ ") + mark = len(t.out) + for keys in steps: + t.send(keys) + t._read(0.25) + if want is not None: + ok = ok and t.expect(b"\n" + want.encode() + b"\r\n") + t.send("\x03") + ok = ok and t.expect(b"P$ ") + t.send("exit\r") + # LeakSanitizer reports as the shell exits: read until the pty closes, + # so the report is in t.out before anything is decided. + end = time.time() + 10 + while t._read(0.2) and time.time() < end: + pass + st = t.close() + leak = b"LeakSanitizer" in t.out + check(name, ok and not leak and os.WIFEXITED(st), + repr(t.since(mark)[-400:])) + + +def main(): + with open(SHELL, "rb") as f: + if b"AddressSanitizer" not in f.read(): + print("SKIP %s has no ASan: leaks cannot be seen" % SHELL) + return 0 + session("saved/up-then-enter", ["echo", UP, "\r"]) + session("saved/up-then-ctrl-c", ["echo", UP, "\x03"]) + session("saved/up-then-ctrl-g", ["ec", UP, "\x07"]) + session("saved/ctrl-p-then-enter", ["echo", "\x10", "\r"]) + session("saved/ctrl-p-walk-back-and-forth", + ["echo", "\x10", "\x10", "\x0e", "\x10", "\r"]) + session("search/alt-p-abort-ctrl-g", [ALT_P, "ech", "\x07"]) + session("search/alt-n-abort-ctrl-g", [ALT_N, "ech", "\x07"]) + session("search/alt-p-rubout-past-empty", [ALT_P, "e", "\x7f", "\x7f"]) + session("search/typed-line-then-abort", ["ls", ALT_P, "ech", "\x07"]) + session("search/typed-then-alt-p-enter", ["ech", ALT_P, "\r"]) + session("works/up-down-brings-the-typed-line-back", + ["echo typed", UP, DOWN, "\r"], want="typed") + session("works/alt-p-search-runs-the-match", + [ALT_P, "alph", "\r", "\r"], want="alpha") + session("works/aborted-search-restores-the-line", + ["echo kept", ALT_P, "x", "\x07", "\r"], want="kept") + print("%d failed" % len(FAILS)) + return 1 if FAILS else 0 + + +sys.exit(main()) From d1f76bab0ca32799590e2644c6393965fc826f32 Mon Sep 17 00:00:00 2001 From: LESdylan Date: Sun, 18 Oct 2026 03:36:34 +0200 Subject: [PATCH 3/6] fix(editor): prefix search and suggestion keys on non-PIE builds On the 42 machines Up stayed plain history and Right / End / ^F / Alt-F / Alt-N never touched the ghost: none of #136's or #140's keys were installed. rl_slot_arm() decides whether a slot still holds readline's stock function by comparing pointers, and took the stock ones by address (&rl_get_previous_history, ...). cc there is clang 12, which links non-PIE, where such an address is the executable's PLT stub; Ubuntu's libreadline is linked -Bsymbolic-functions and keeps its own internal address in the keymaps. The two never compared equal, every slot read as a user binding, and slot_user kept it that way for the session. gcc (CI) and the static release build link PIE, so neither showed it. rl_slot_funcs() now asks readline for its functions by name (rl_named_function), the pointer its keymaps and funmap hold, for the stock side and for the history-search pair that replaces Up / Down. The latter also matters for readline's own rl_last_func check: a search bound by stub address would restart on every Up instead of walking back. Verified on a non-PIE debug build (file: "LSB executable"), both readers: history_prefix_search_test 57 failed -> 148 ok / 1 failed, the one being bind -p against the oracle's bundled readline 8.3 (fails on develop too); autosuggest_test 86 failed -> 346 ok / 0 failed. A PIE build passes before and after, so CI cannot see this without a non-PIE job. --- src/platform/posix/rl_slot_tab.c | 37 +++++++++++++++++++++++++------- 1 file changed, 29 insertions(+), 8 deletions(-) diff --git a/src/platform/posix/rl_slot_tab.c b/src/platform/posix/rl_slot_tab.c index 1df3c412..07860270 100644 --- a/src/platform/posix/rl_slot_tab.c +++ b/src/platform/posix/rl_slot_tab.c @@ -68,22 +68,43 @@ t_slot rl_slot_extra(int j) return (s); } +/* Readline's own functions, by the name readline knows them under: + 0-6 the stock function of each SLOT_*, 7-8 the history-search pair + that replaces Up / Down. + Looked up by name, never by address. In a non-PIE executable (clang + before 15 builds those by default, 42's machines included) + `&rl_forward_char` is the executable's PLT stub, while a libreadline + linked -Bsymbolic-functions (Debian, Ubuntu) keeps its internal address + in the keymaps. The two never compare equal, so every slot read as the + user's own binding and no feature key was installed: Up stayed plain + history and Right / Alt-F / Alt-N never touched the ghost. A search + bound by stub address would also miss readline's rl_last_func check, + so a second Up would restart the search instead of walking back. */ +static rl_command_func_t *rl_slot_readline(int i) +{ + static const char *const names[] = {"previous-history", + "next-history", "forward-char", "end-of-line", "forward-word", + "non-incremental-forward-search-history", + "non-incremental-reverse-search-history", + "history-search-backward", "history-search-forward"}; + + return (rl_named_function(names[i])); +} + /* The stock function of a slot, and what replaces it while its feature is on. In vi insert mode the Alt keys have no stock function (ESC leaves insert mode), so there the slot is unbound while off. */ void rl_slot_funcs(const t_slot *s, rl_command_func_t **stock, rl_command_func_t **repl) { - static rl_command_func_t *const stk[] = {rl_get_previous_history, - rl_get_next_history, rl_forward_char, rl_end_of_line, - rl_forward_word, rl_noninc_forward_search, - rl_noninc_reverse_search}; - static rl_command_func_t *const rep[] = {rl_history_search_backward, - rl_history_search_forward, rl_suggest_accept, rl_suggest_accept, - rl_suggest_word, rl_suggest_older, rl_suggest_newer}; + static rl_command_func_t *const rep[] = {NULL, NULL, rl_suggest_accept, + rl_suggest_accept, rl_suggest_word, rl_suggest_older, + rl_suggest_newer}; - *stock = stk[s->fn]; + *stock = rl_slot_readline(s->fn); *repl = rep[s->fn]; + if (s->fn <= SLOT_NEXT) + *repl = rl_slot_readline(SLOT_NEWER + 1 + s->fn); if (s->vi && s->fn >= SLOT_WORD) *stock = NULL; } From cd5b1f283f6fced7863d29c4bfa8f9682aaffce7 Mon Sep 17 00:00:00 2001 From: LESdylan Date: Sun, 18 Oct 2026 03:37:30 +0200 Subject: [PATCH 4/6] fix(zsh): `which` is a builtin in zsh mode, so Esc Esc stops erroring oh-my-zsh's sudo widget runs `builtin which -a "$cmd"` on every Esc Esc once $EDITOR is set, and hellish had no `which` builtin: each toggle printed "builtin: which: not a shell builtin" over the prompt, and an editor reached only through that check (`vim f` with EDITOR set to a second vim on $PATH) became `sudo vim f` instead of `sudo -e f`. `which [-ap] name...` is zsh's (whence -c): alias, reserved word, function, builtin, then the command; -a lists every match, -p paths only; "NAME not found" on stdout and status 1, as zsh. Diffed against zsh 5.8.1 on 20 invocations: identical apart from the error prefix. It resolves in zsh mode only. `which` is also /usr/bin/which, and a bash script's `which`, `type which` and `command -v which` must keep meaning that one, so it sits in a separate table that builtin_func() consults only under zsh_mode(). builtin_func() now takes the shell state for that; every caller already had it, and the compiler found them all. declare -f's body trimming moves to func_text_trimmed() so `which` prints a function with the same text. Caveats in the code: functions print as written rather than deparsed; the reserved words are the POSIX set. tests/omz_sudo_widget_test.py adds the second-vim case and fails on "not a shell builtin" anywhere: 2 failed before, 11 ok after. Golden suite 5363/5363; zsh_builtins, zle and plugin_corpus tests pass. wiki/builtins regenerated (it also picks up `bind`, which was missing). --- incs/executor.h | 187 +++++++++++++------------ incs/ft_builtins.h | 4 +- src/builtins/builtin_builtin.c | 2 +- src/builtins/builtin_cmd2.c | 8 +- src/builtins/builtin_declare4.c | 25 ++-- src/builtins/builtin_type.c | 6 +- src/builtins/builtin_type3.c | 4 +- src/builtins/builtin_zsh_which.c | 67 +++++++++ src/builtins/builtin_zsh_which2.c | 113 +++++++++++++++ src/builtins/builtins_private.h | 8 +- src/builtins/hash_builtins_dispatch.c | 14 +- src/builtins/hash_builtins_zsh.c | 19 +++ src/builtins/help_data2.c | 3 + src/execution/execute_builtin.c | 3 +- src/execution/execute_simple_command.c | 2 +- src/execution/find_cmd_path2.c | 2 +- src/execution/run_utils2.c | 2 +- src/expander/cmdsub_inplace.c | 2 +- src/expander/expand_zsh_which.c | 2 +- tests/omz_sudo_widget_test.py | 14 +- wiki/builtins/index.md | 5 +- 21 files changed, 368 insertions(+), 124 deletions(-) create mode 100644 src/builtins/builtin_zsh_which.c create mode 100644 src/builtins/builtin_zsh_which2.c diff --git a/incs/executor.h b/incs/executor.h index 886ce875..3b9a54f5 100644 --- a/incs/executor.h +++ b/incs/executor.h @@ -1,94 +1,95 @@ -/* ************************************************************************** */ -/* */ -/* ::: :::::::: */ -/* executor.h :+: :+: :+: */ -/* +:+ +:+ +:+ */ -/* By: marvin +#+ +:+ +#+ */ -/* +#+#+#+#+#+ +#+ */ -/* Created: 2026/01/10 02:38:39 by marvin #+# #+# */ -/* Updated: 2026/01/10 02:38:39 by marvin ### ########.fr */ -/* */ -/* ************************************************************************** */ - -/* Executor public API: turning an AST into side-effects. - The main entry is execute_top_level() which walks the tree returned by - the parser. Every execute_* function returns t_execution_state so the - exit code and any ctrl-c signal propagate up cleanly. */ - -#ifndef EXECUTOR_H -# define EXECUTOR_H - -# include "public/executor_types.h" -# include "alias.h" - -/* forward declarations to avoid heavy includes */ -typedef struct s_shell t_shell; -typedef struct s_ast_node t_ast_node; - -/* An expanded, ready-to-run simple command: pre-command assignments, - the argv array, and the resolved executable path. pooled=true means - argv.ctx comes from argv_pool (don't free it, just reset the vec). */ -typedef struct executable_cmd_s -{ - t_vec_env pre_assigns; /* NAME=VALUE pairs before the command */ - t_vec argv; /* NULL-terminated argv word list */ - char *fname; /* resolved path (heap) or NULL for builtins */ - bool pooled; /* true if argv was borrowed from argv_pool */ -} t_executable_cmd; - -/* argv backing pool (free_utils2.c): borrow on expand, return on teardown. */ -void argv_pool_acquire(t_shell *state, t_executable_cmd *cmd); -void argv_pool_release(t_shell *state, t_executable_cmd *cmd); -void free_argv_pool(t_shell *state); - -/* Execution context for one node in the pipeline: which fds to hook up, - which AST node to execute, and whether this command runs in the parent - process (builtins, function calls) and is allowed to modify state. */ -typedef struct executable_node_s -{ - int infd; /* read end of pipe from previous stage */ - int outfd; /* write end of pipe to next stage */ - int next_infd; /* saved next read end (pipeline setup) */ - t_ast_node *node; /* the AST node being executed */ - t_vec_int redirs; /* indices into state->redirects */ - bool modify_parent_ctx; /* true for builtins that modify state */ -} t_executable_node; - -void execute_top_level(t_shell *state); -int exec_string(t_shell *state, char *str); -int exec_file_string(t_shell *state, char *str, - const char *src); -t_execution_state execute_pipeline(t_shell *state, - t_executable_node *exe); -void set_up_redirection(t_shell *state, - t_executable_node *exe); -t_execution_state execute_simple_command(t_shell *state, - t_executable_node *exe); -void forward_exit_status(t_execution_state res); -t_execution_state execute_command(t_shell *state, - t_executable_node *exe); -t_execution_state execute_tree_node(t_shell *state, - t_executable_node *exe); -t_execution_state execute_simple_list(t_shell *state, - t_executable_node *exe); -t_string word_to_string(t_ast_node node); -t_string word_to_hrdoc_string(t_ast_node node); -void set_cmd_status(t_shell *state, t_execution_state res); -t_execution_state res_status(int status); -t_execution_state res_pid(int pid); -void fire_debug_trap(t_shell *state); -void fire_err_trap(t_shell *state, int code); -void fire_return_trap(t_shell *state, int code); -void trap_save_reset(t_shell *state, char **save); -void pseudo_traps_quiet(t_shell *state); -void trap_restore(t_shell *state, char **save); -void exe_res_set_status(t_shell *st, t_execution_state *res); -int find_cmd_path(t_shell *state, char *cmd_name, - char **path_of_exe); -int exec_external_argv(t_shell *state, t_vec *args); -int run_external_sync(t_shell *state, t_vec *args); -int expand_simple_command(t_shell *state, t_ast_node *node, - t_executable_cmd *ret, t_vec_int *redirects); -int (*builtin_func(char *name))(t_shell *state, t_vec argv); - +/* ************************************************************************** */ +/* */ +/* ::: :::::::: */ +/* executor.h :+: :+: :+: */ +/* +:+ +:+ +:+ */ +/* By: marvin +#+ +:+ +#+ */ +/* +#+#+#+#+#+ +#+ */ +/* Created: 2026/01/10 02:38:39 by marvin #+# #+# */ +/* Updated: 2026/01/10 02:38:39 by marvin ### ########.fr */ +/* */ +/* ************************************************************************** */ + +/* Executor public API: turning an AST into side-effects. + The main entry is execute_top_level() which walks the tree returned by + the parser. Every execute_* function returns t_execution_state so the + exit code and any ctrl-c signal propagate up cleanly. */ + +#ifndef EXECUTOR_H +# define EXECUTOR_H + +# include "public/executor_types.h" +# include "alias.h" + +/* forward declarations to avoid heavy includes */ +typedef struct s_shell t_shell; +typedef struct s_ast_node t_ast_node; + +/* An expanded, ready-to-run simple command: pre-command assignments, + the argv array, and the resolved executable path. pooled=true means + argv.ctx comes from argv_pool (don't free it, just reset the vec). */ +typedef struct executable_cmd_s +{ + t_vec_env pre_assigns; /* NAME=VALUE pairs before the command */ + t_vec argv; /* NULL-terminated argv word list */ + char *fname; /* resolved path (heap) or NULL for builtins */ + bool pooled; /* true if argv was borrowed from argv_pool */ +} t_executable_cmd; + +/* argv backing pool (free_utils2.c): borrow on expand, return on teardown. */ +void argv_pool_acquire(t_shell *state, t_executable_cmd *cmd); +void argv_pool_release(t_shell *state, t_executable_cmd *cmd); +void free_argv_pool(t_shell *state); + +/* Execution context for one node in the pipeline: which fds to hook up, + which AST node to execute, and whether this command runs in the parent + process (builtins, function calls) and is allowed to modify state. */ +typedef struct executable_node_s +{ + int infd; /* read end of pipe from previous stage */ + int outfd; /* write end of pipe to next stage */ + int next_infd; /* saved next read end (pipeline setup) */ + t_ast_node *node; /* the AST node being executed */ + t_vec_int redirs; /* indices into state->redirects */ + bool modify_parent_ctx; /* true for builtins that modify state */ +} t_executable_node; + +void execute_top_level(t_shell *state); +int exec_string(t_shell *state, char *str); +int exec_file_string(t_shell *state, char *str, + const char *src); +t_execution_state execute_pipeline(t_shell *state, + t_executable_node *exe); +void set_up_redirection(t_shell *state, + t_executable_node *exe); +t_execution_state execute_simple_command(t_shell *state, + t_executable_node *exe); +void forward_exit_status(t_execution_state res); +t_execution_state execute_command(t_shell *state, + t_executable_node *exe); +t_execution_state execute_tree_node(t_shell *state, + t_executable_node *exe); +t_execution_state execute_simple_list(t_shell *state, + t_executable_node *exe); +t_string word_to_string(t_ast_node node); +t_string word_to_hrdoc_string(t_ast_node node); +void set_cmd_status(t_shell *state, t_execution_state res); +t_execution_state res_status(int status); +t_execution_state res_pid(int pid); +void fire_debug_trap(t_shell *state); +void fire_err_trap(t_shell *state, int code); +void fire_return_trap(t_shell *state, int code); +void trap_save_reset(t_shell *state, char **save); +void pseudo_traps_quiet(t_shell *state); +void trap_restore(t_shell *state, char **save); +void exe_res_set_status(t_shell *st, t_execution_state *res); +int find_cmd_path(t_shell *state, char *cmd_name, + char **path_of_exe); +int exec_external_argv(t_shell *state, t_vec *args); +int run_external_sync(t_shell *state, t_vec *args); +int expand_simple_command(t_shell *state, t_ast_node *node, + t_executable_cmd *ret, t_vec_int *redirects); +int (*builtin_func(t_shell *state, char *name))(t_shell *s, + t_vec argv); + #endif \ No newline at end of file diff --git a/incs/ft_builtins.h b/incs/ft_builtins.h index 34b364be..778e2a1e 100644 --- a/incs/ft_builtins.h +++ b/incs/ft_builtins.h @@ -148,7 +148,9 @@ bool zopt_apply(t_shell *state, const char *n, bool on); int builtin_let(t_shell *state, t_vec argv); int builtin_kill(t_shell *state, t_vec argv); int builtin_printf(t_shell *state, t_vec argv); -int (*builtin_func(char *name))(t_shell *state, +int (*builtin_func(t_shell *state, char *name))(t_shell *s, t_vec argv); +int (*zsh_builtin_func(char *name))(t_shell *s, t_vec argv); +int builtin_which(t_shell *state, t_vec argv); #endif diff --git a/src/builtins/builtin_builtin.c b/src/builtins/builtin_builtin.c index ecef49fb..04c932b7 100644 --- a/src/builtins/builtin_builtin.c +++ b/src/builtins/builtin_builtin.c @@ -29,7 +29,7 @@ int builtin_builtin(t_shell *state, t_vec argv) if (argv.len < 2) return (0); - f = builtin_func(((char **)argv.ctx)[1]); + f = builtin_func(state, ((char **)argv.ctx)[1]); if (!f) { ft_eprintf("%s: builtin: %s: not a shell builtin\n", diff --git a/src/builtins/builtin_cmd2.c b/src/builtins/builtin_cmd2.c index b3cbad20..5c33e9b8 100644 --- a/src/builtins/builtin_cmd2.c +++ b/src/builtins/builtin_cmd2.c @@ -58,7 +58,8 @@ static int command_v(t_shell *state, char *name) if (alias_get(&state->aliases, name)) return (ft_printf("alias "), alias_print_one(&state->aliases, name)); - if (type_is_keyword(name) || builtin_func(name) || func_lookup(state, name)) + if (type_is_keyword(name) || builtin_func(state, name) + || func_lookup(state, name)) return (ft_printf("%s\n", name), 0); if (ft_strchr(name, '/')) { @@ -120,8 +121,9 @@ static int command_run(t_shell *state, t_vec argv, size_t start) i = start; while (i < argv.len) vec_push(&sub, &av[i++]); - if (builtin_func(av[start])) - return (rc = builtin_func(av[start])(state, sub), xfree(sub.ctx), rc); + if (builtin_func(state, av[start])) + return (rc = builtin_func(state, av[start])(state, sub), + xfree(sub.ctx), rc); rc = run_external_sync(state, &sub); return (xfree(sub.ctx), rc); } diff --git a/src/builtins/builtin_declare4.c b/src/builtins/builtin_declare4.c index 08df4d7e..f389fd8c 100644 --- a/src/builtins/builtin_declare4.c +++ b/src/builtins/builtin_declare4.c @@ -25,8 +25,6 @@ `} 2>/dev/null`, so the closing brace is not added a second time. */ static void print_body(t_shell *state, t_shell_func *fn) { - const char *t; - size_t n; char *body; if (!fn->text) @@ -35,13 +33,7 @@ static void print_body(t_shell *state, t_shell_func *fn) "(defined by eval?)\n", state->ctx, fn->name); return ; } - t = fn->text; - while (*t == ' ' || *t == '\t' || *t == '\n') - t++; - n = ft_strlen(t); - while (n > 0 && (t[n - 1] == ' ' || t[n - 1] == '\t' || t[n - 1] == '\n')) - n--; - body = ft_strndup(t, n); + body = func_text_trimmed(fn->text); if (fn->body.node_type == AST_COMMAND) ft_printf("%s ()\n{\n%s\n", fn->name, body); else @@ -76,6 +68,21 @@ static int bodies_of(t_shell *state, t_vec argv, size_t i) return (missing); } +/* A function's source text without the blank space around it, as a new + string. Shared by `declare -f` and zsh's `which`, which print the same + body under different headers. */ +char *func_text_trimmed(const char *t) +{ + size_t n; + + while (*t == ' ' || *t == '\t' || *t == '\n') + t++; + n = ft_strlen(t); + while (n > 0 && (t[n - 1] == ' ' || t[n - 1] == '\t' || t[n - 1] == '\n')) + n--; + return (ft_strndup(t, n)); +} + int declare_functions(t_shell *state, t_vec argv, size_t i, bool bodies) { if (i < argv.len && ft_strcmp(((char **)argv.ctx)[i], "--") == 0) diff --git a/src/builtins/builtin_type.c b/src/builtins/builtin_type.c index 1b0c8dd6..851d3e02 100644 --- a/src/builtins/builtin_type.c +++ b/src/builtins/builtin_type.c @@ -16,9 +16,9 @@ /* Check via the dispatch hash table — no need to maintain a separate list. The dispatch table IS the authoritative set of builtins. */ -int type_is_builtin(const char *name) +int type_is_builtin(t_shell *state, const char *name) { - return (builtin_func((char *)name) != NULL); + return (builtin_func(state, (char *)name) != NULL); } /* POSIX reserved words (plus the common function/{/}). */ @@ -100,7 +100,7 @@ int type_one(t_shell *state, const char *name) return (ft_printf("%s is a shell keyword\n", name), 0); if (func_lookup(state, (char *)name)) return (ft_printf("%s is a function\n", name), 0); - if (type_is_builtin(name)) + if (type_is_builtin(state, name)) return (ft_printf("%s is a shell builtin\n", name), 0); return (type_one_path(state, name)); } diff --git a/src/builtins/builtin_type3.c b/src/builtins/builtin_type3.c index c53610ac..427597de 100644 --- a/src/builtins/builtin_type3.c +++ b/src/builtins/builtin_type3.c @@ -28,7 +28,7 @@ int type_t_one(t_shell *state, const char *name) return (ft_printf("keyword\n"), 0); if (func_lookup(state, (char *)name)) return (ft_printf("function\n"), 0); - if (type_is_builtin(name)) + if (type_is_builtin(state, name)) return (ft_printf("builtin\n"), 0); if (cmd_hash_lookup(&state->cmd_cache, name)) return (ft_printf("file\n"), 0); @@ -47,7 +47,7 @@ int type_p_one(t_shell *state, const char *name, int force) char *path; if (!force && (type_is_keyword(name) || func_lookup(state, (char *)name) - || type_is_builtin(name))) + || type_is_builtin(state, name))) return (0); if (ft_strchr(name, '/') && access(name, X_OK) == 0) return (ft_printf("%s\n", name), 0); diff --git a/src/builtins/builtin_zsh_which.c b/src/builtins/builtin_zsh_which.c new file mode 100644 index 00000000..38abc667 --- /dev/null +++ b/src/builtins/builtin_zsh_which.c @@ -0,0 +1,67 @@ +/* ************************************************************************** */ +/* */ +/* ::: :::::::: */ +/* builtin_zsh_which.c :+: :+: :+: */ +/* +:+ +:+ +:+ */ +/* By: dlesieur +#+ +:+ +#+ */ +/* +#+#+#+#+#+ +#+ */ +/* Created: 2026/10/18 03:00:00 by dlesieur #+# #+# */ +/* Updated: 2026/10/18 03:00:00 by dlesieur ### ########.fr */ +/* */ +/* ************************************************************************** */ + +#include "builtins_private.h" + +/* zsh's `which` (its `whence -c`), a builtin in zsh mode only -- see + zsh_builtin_func() in hash_builtins_zsh.c for why the gate. Plugins call + it as `builtin which`: oh-my-zsh's sudo widget runs `builtin which -a` + on every Esc Esc, and without it each toggle printed "not a shell + builtin" over the prompt. + -a lists every match instead of the first, -p skips aliases, reserved + words, functions and builtins; any other letter is zsh's "bad option", + status 1. A name nothing answers to prints "NAME not found" on stdout + and makes the status 1, as zsh does; no operand at all is status 1. */ +static int which_opts(t_shell *state, t_vec argv, size_t *i, int *flags) +{ + char *a; + size_t k; + + while (*i < argv.len) + { + a = ((char **)argv.ctx)[*i]; + if (a[0] != '-' || !a[1]) + break ; + (*i)++; + if (!ft_strcmp(a, "--")) + break ; + k = 0; + while (a[++k]) + { + if (a[k] == 'a') + *flags |= WHICH_ALL; + else if (a[k] == 'p') + *flags |= WHICH_PATH; + else + return (ft_eprintf("%s: which: bad option: -%c\n", + state->ctx, a[k]), 1); + } + } + return (0); +} + +int builtin_which(t_shell *state, t_vec argv) +{ + size_t i; + int flags; + int rc; + + i = 1; + flags = 0; + if (which_opts(state, argv, &i, &flags)) + return (1); + rc = (i >= argv.len); + while (i < argv.len) + if (which_one(state, ((char **)argv.ctx)[i++], flags)) + rc = 1; + return (rc); +} diff --git a/src/builtins/builtin_zsh_which2.c b/src/builtins/builtin_zsh_which2.c new file mode 100644 index 00000000..c5320b50 --- /dev/null +++ b/src/builtins/builtin_zsh_which2.c @@ -0,0 +1,113 @@ +/* ************************************************************************** */ +/* */ +/* ::: :::::::: */ +/* builtin_zsh_which2.c :+: :+: :+: */ +/* +:+ +:+ +:+ */ +/* By: dlesieur +#+ +:+ +#+ */ +/* +#+#+#+#+#+ +#+ */ +/* Created: 2026/10/18 03:00:00 by dlesieur #+# #+# */ +/* Updated: 2026/10/18 03:00:00 by dlesieur ### ########.fr */ +/* */ +/* ************************************************************************** */ + +#include "builtins_private.h" +#include "sh_alias.h" +#include "cmd_hash.h" + +/* zsh's `which`: the per-name lookup, in zsh's order -- alias, reserved + word, function, builtin, then the command itself. builtin_zsh_which.c + holds the option parsing. + + Caveat: zsh prints a function by deparsing it, one command per line; + this prints the body's source text as written, so `f() { a; b; }` comes + out as "\ta; b;" where zsh prints "\ta\n\tb". A one-command body matches + byte for byte. */ +static int which_function(t_shell_func *fn) +{ + char *body; + size_t n; + + if (fn->text) + body = func_text_trimmed(fn->text); + else + body = ft_strdup(""); + if (!body) + return (1); + n = ft_strlen(body); + while (n > 0 && body[n - 1] == ';') + body[--n] = '\0'; + ft_printf("%s () {\n\t%s\n}\n", fn->name, body); + xfree(body); + return (1); +} + +/* Caveat: the reserved words are hellish's POSIX set (type_is_keyword); + zsh 5.x also reserves [[, function, time, repeat, coproc, foreach and + the typeset family, so `which [[` says "shell built-in command" here + where zsh says "shell reserved word". */ +static int which_shell(t_shell *state, char *name, bool all) +{ + char *val; + t_shell_func *fn; + int hits; + + hits = 0; + val = alias_get(&state->aliases, name); + if (val) + hits += (ft_printf("%s: aliased to %s\n", name, val), 1); + if ((all || !hits) && type_is_keyword(name)) + hits += (ft_printf("%s: shell reserved word\n", name), 1); + fn = func_lookup(state, name); + if ((all || !hits) && fn) + hits += which_function(fn); + if ((all || !hits) && builtin_func(state, name)) + hits += (ft_printf("%s: shell built-in command\n", name), 1); + return (hits); +} + +/* The command: a name with a slash is itself when executable; otherwise + the hashed path, then $PATH. -a asks exe_path() one directory at a time, + so every match goes through the same is-a-file, is-executable test the + executor uses and is listed in $PATH order. */ +static int which_paths(t_shell *state, char *name, bool all) +{ + char *dir[2]; + char *path; + int denied; + int hits; + size_t i; + + if (ft_strchr(name, '/')) + return (access(name, X_OK) == 0 && ft_printf("%s\n", name) >= 0); + path = cmd_hash_lookup(&state->cmd_cache, name); + if (path && !all) + return (ft_printf("%s\n", path), 1); + path_cache_sync(state); + hits = 0; + i = 0; + dir[1] = NULL; + while (*name && state->path_dirs && state->path_dirs[i] && (all || !hits)) + { + dir[0] = state->path_dirs[i++]; + path = exe_path(dir, name, &denied); + if (path) + hits += (ft_printf("%s\n", path), xfree(path), 1); + } + return (hits); +} + +/* Returns 0 when something answered to `name`, 1 after "not found". */ +int which_one(t_shell *state, char *name, int flags) +{ + int hits; + + hits = 0; + if (!(flags & WHICH_PATH)) + hits = which_shell(state, name, (flags & WHICH_ALL) != 0); + if (!hits || (flags & WHICH_ALL)) + hits += which_paths(state, name, (flags & WHICH_ALL) != 0); + if (hits) + return (0); + ft_printf("%s not found\n", name); + return (1); +} diff --git a/src/builtins/builtins_private.h b/src/builtins/builtins_private.h index e34d8f1e..ae2e9b4c 100644 --- a/src/builtins/builtins_private.h +++ b/src/builtins/builtins_private.h @@ -337,6 +337,12 @@ int parse_redir_len(const char *arg); bool redir_needs_next(const char *arg); char *exe_path(char **path_dirs, char *exe_name, int *perm_denied); char *exe_path_preferred(char **path_dirs, char *exe_name, bool posix); +void path_cache_sync(t_shell *state); +char *func_text_trimmed(const char *t); +# define WHICH_ALL 1 +# define WHICH_PATH 2 + +int which_one(t_shell *state, char *name, int flags); char *command_v_lookup(t_shell *state, const char *name); int list_traps(t_shell *state); @@ -398,7 +404,7 @@ void gopt_init(t_shell *state, t_vec argv, t_getopts *g); int one_option(t_shell *state, t_vec argv, t_getopts *g, char *cur); int type_one_path(t_shell *state, const char *name); int type_one(t_shell *state, const char *name); -int type_is_builtin(const char *name); +int type_is_builtin(t_shell *state, const char *name); int type_is_keyword(const char *name); int type_find_in_path(t_shell *state, const char *name, char **out); int type_dispatch(t_shell *state, const char *name, char mode); diff --git a/src/builtins/hash_builtins_dispatch.c b/src/builtins/hash_builtins_dispatch.c index 76c7378f..3ffa299c 100644 --- a/src/builtins/hash_builtins_dispatch.c +++ b/src/builtins/hash_builtins_dispatch.c @@ -112,12 +112,20 @@ static void init_builtin_hash(t_hash *h) (the `!h.ctx` guard) and then reused for every subsequent lookup — so the cost is one hash + one pointer dereference in the steady state. Returns NULL when `name` is not a builtin, so callers can branch on that to decide - whether to fork a child process. */ -int (*builtin_func(char *name))(t_shell *state, t_vec argv) + whether to fork a child process. + `state` is what lets a name be a builtin in one dialect only: in zsh + mode the zsh-only table (hash_builtins_zsh.c) answers what this one does + not. Every caller goes through here, so `which`, `builtin which`, + `command which` and `type which` cannot disagree about it. */ +int (*builtin_func(t_shell *state, char *name))(t_shell *s, t_vec argv) { static t_hash h = {0}; + t_builtin_fn f; if (!h.ctx) init_builtin_hash(&h); - return ((t_builtin_fn)hash_get(&h, name)); + f = (t_builtin_fn)hash_get(&h, name); + if (!f && zsh_mode(state)) + f = zsh_builtin_func(name); + return (f); } diff --git a/src/builtins/hash_builtins_zsh.c b/src/builtins/hash_builtins_zsh.c index 9991ab2c..e973f71d 100644 --- a/src/builtins/hash_builtins_zsh.c +++ b/src/builtins/hash_builtins_zsh.c @@ -48,3 +48,22 @@ void fill_builtin_hash5(t_hash *h) hash_set(h, "compopt", (void *)builtin_compopt); hash_set(h, "bind", (void *)builtin_bind); } + +/* The one exception to the rule above: names that are ALSO an external + command. `which` is /usr/bin/which to a bash script -- `type which`, + `command -v which` and its output format all depend on that -- and a + zsh builtin to a zsh plugin (oh-my-zsh's sudo widget runs + `builtin which -a`). An unconditional registration would change what + the bash script runs, so these resolve only in zsh mode, through + builtin_func(). */ +int (*zsh_builtin_func(char *name))(t_shell *s, t_vec argv) +{ + static t_hash h = {0}; + + if (!h.ctx) + { + hash_init(&h, 4); + hash_set(&h, "which", (void *)builtin_which); + } + return ((t_builtin_fn)hash_get(&h, name)); +} diff --git a/src/builtins/help_data2.c b/src/builtins/help_data2.c index 11aee64f..1a5d0f6b 100644 --- a/src/builtins/help_data2.c +++ b/src/builtins/help_data2.c @@ -179,6 +179,9 @@ static const t_help g_help_2[] = { {"print", "zsh", "print [-nrlP] [--] [arg ...]", "zsh's echo: escapes on by default, -r turns them off"}, +{"which", "zsh", + "which [-ap] name ...", + "zsh mode only: what runs for name; -a every match, -p paths only"}, {"autoload", "zsh", "autoload [-Uz] name ...", "define a function from a file on $fpath"}, diff --git a/src/execution/execute_builtin.c b/src/execution/execute_builtin.c index 06f5f002..da65fc94 100644 --- a/src/execution/execute_builtin.c +++ b/src/execution/execute_builtin.c @@ -88,7 +88,8 @@ t_execution_state execute_builtin_cmd_fg(t_shell *state, need = prep_redir(state, exe, bak, persist); update_underscore_var(state, cmd); saves = apply_temp_assigns(state, &cmd->pre_assigns); - status = builtin_func(((char **)(cmd->argv.ctx))[0])(state, cmd->argv); + status = builtin_func(state, ((char **)(cmd->argv.ctx))[0])(state, + cmd->argv); restore_temp_assigns(state, &saves); if (need) restore_backup_fds(bak, persist); diff --git a/src/execution/execute_simple_command.c b/src/execution/execute_simple_command.c index 6f3f63a7..178d4420 100644 --- a/src/execution/execute_simple_command.c +++ b/src/execution/execute_simple_command.c @@ -112,7 +112,7 @@ static t_execution_state dispatch_cmd(t_shell *state, if (state->functions.len && func_lookup(state, argv0) && exe->modify_parent_ctx) return (handle_func_call(state, cmd, exe, func_lookup(state, argv0))); - if (builtin_func(argv0) && exe->modify_parent_ctx) + if (builtin_func(state, argv0) && exe->modify_parent_ctx) return (execute_builtin_cmd_fg(state, cmd, exe)); prehash_external(state, argv0); return (execute_cmd_bg(state, exe, cmd)); diff --git a/src/execution/find_cmd_path2.c b/src/execution/find_cmd_path2.c index d39de644..db31739a 100644 --- a/src/execution/find_cmd_path2.c +++ b/src/execution/find_cmd_path2.c @@ -74,7 +74,7 @@ void prehash_external(t_shell *state, char *argv0) char *path; int denied; - if (!argv0 || ft_strchr(argv0, '/') || builtin_func(argv0) + if (!argv0 || ft_strchr(argv0, '/') || builtin_func(state, argv0) || func_lookup(state, argv0)) return ; path_cache_sync(state); diff --git a/src/execution/run_utils2.c b/src/execution/run_utils2.c index 7aa65799..ecc39706 100644 --- a/src/execution/run_utils2.c +++ b/src/execution/run_utils2.c @@ -28,7 +28,7 @@ int run_builtin_or_continue(t_shell *state, t_vec *args) fn = func_lookup(state, ((char **)args->ctx)[0]); if (fn) exit(execute_func_call(state, fn, args).status); - bf = builtin_func(((char **)args->ctx)[0]); + bf = builtin_func(state, ((char **)args->ctx)[0]); if (bf) exit(bf(state, *args)); return (0); diff --git a/src/expander/cmdsub_inplace.c b/src/expander/cmdsub_inplace.c index f4ff5661..2564ffb2 100644 --- a/src/expander/cmdsub_inplace.c +++ b/src/expander/cmdsub_inplace.c @@ -33,7 +33,7 @@ static bool cs_word_opaque(t_shell *state, const char *s, int len) if (!w) return (true); bad = (ft_strchr(w, '$') != NULL || ft_strchr(w, '=') != NULL - || func_lookup(state, w) != NULL || builtin_func(w) != NULL); + || func_lookup(state, w) != NULL || builtin_func(state, w) != NULL); xfree(w); return (bad); } diff --git a/src/expander/expand_zsh_which.c b/src/expander/expand_zsh_which.c index 731cb7c0..58243bbf 100644 --- a/src/expander/expand_zsh_which.c +++ b/src/expander/expand_zsh_which.c @@ -31,7 +31,7 @@ char *zp_which(t_shell *state, char *name) if (!name || !*name) return (NULL); - if (builtin_func(name) || func_lookup(state, name)) + if (builtin_func(state, name) || func_lookup(state, name)) return (ft_strdup(name)); if (ft_strchr(name, '/')) { diff --git a/tests/omz_sudo_widget_test.py b/tests/omz_sudo_widget_test.py index 8ef9898e..f9af2648 100644 --- a/tests/omz_sudo_widget_test.py +++ b/tests/omz_sudo_widget_test.py @@ -106,7 +106,9 @@ def main(): print("SKIP omz sudo plugin: no cached copy and no network") return 0 rc = "PS1='P$ '\nEDITOR=vim\nexport EDITOR\n. %s\n" % path - t = Tty(SHELL, rc=rc, path=fakes() + ":" + os.environ.get("PATH", "")) + first, second = fakes(), fakes() + t = Tty(SHELL, rc=rc, + path=first + ":" + second + ":" + os.environ.get("PATH", "")) check("start/prompt", t.expect(b"P$ "), repr(t.out[-300:])) esc = b"\x1b\x1b" line(t, ["echo a1", esc, "\r"], b"FAKESUDO[echo a1]", @@ -125,8 +127,18 @@ def main(): t.expect(b"a6\r\nP$ ") line(t, [esc, "\r"], b"FAKESUDO[echo a6]", "empty-line/recalls-the-previous-command") + # $EDITOR is the SECOND vim on $PATH: neither `vim` nor ${vim:c} (the + # first one) equals it, so only the widget's `builtin which -a vim | + # grep -Fx` can tell it is the editor. That builtin used to be missing, + # and every Esc Esc printed "builtin: which: not a shell builtin". + t.send("EDITOR=%s/vim\r" % second) + t.expect(b"P$ ") + line(t, ["vim /tmp/f7", esc, "\r"], b"FAKESUDO[-e /tmp/f7]", + "editor/second-on-PATH-found-by-builtin-which") check("no-bad-substitution", b"bad substitution" not in t.out, repr(t.out[-400:])) + check("no-missing-builtin", b"not a shell builtin" not in t.out, + repr(t.out[-400:])) st = t.close() check("exit/not-signalled", not os.WIFSIGNALED(st)) print("%d failed" % len(FAILS)) diff --git a/wiki/builtins/index.md b/wiki/builtins/index.md index 2568fefb..9ba562fa 100644 --- a/wiki/builtins/index.md +++ b/wiki/builtins/index.md @@ -6,7 +6,7 @@ > (tools/gen_builtins_md.py), never edit it by hand. The help > table itself is test-enforced against the dispatch table, so > neither this page nor `help` can drift from what actually -> runs. 72 builtins; anything else on `$PATH` works as usual — +> runs. 73 builtins; anything else on `$PATH` works as usual — > `type NAME` says which is which. ## navigation @@ -156,6 +156,9 @@ **`.`** — `. filename [arguments]`
read and run a file in the current shell +**`bind`** — `bind [-lpsvPSVX] [-m keymap] [-f file] [-q name] [-u name] [-r keyseq] [-x keyseq:command] [keyseq:function | readline-line]` +
set key bindings and readline variables, or list them + **`compgen`** — `compgen [-abcdfkv] [-A action] [-W wordlist] [word]`
print the completions the shell would offer for word From 995a1a56736e6fb28cb7ad48c4a3753ee2a280a1 Mon Sep 17 00:00:00 2001 From: LESdylan Date: Sun, 18 Oct 2026 04:08:31 +0200 Subject: [PATCH 5/6] feat(banner): show what is new in 3.2, readable at 80 columns The welcome panel's "Did you know?" block becomes "What's new in 3.2": one row per new feature, written as the keys that reach it, then the switch for inline hints. The link to wiki/whats-new.md (new, and in the site nav) sits under the logo. The left column is a fixed 43 columns, so the URL shows in full at any width. Every right-hand row now fits in the 32 cells the right column has on an 80-column terminal. The 3.1 rows, and two "Getting started" rows, were written for about 40 and were cut with an ellipsis there. Under LANG=C, the identity line printed a UTF-8 middle dot that header_width() cannot decode, so it stopped counting and the frame went out of line. In that locale it is now a dash. HELLISH_HEADER_REV goes to 4, so users who saw the 3.1 panel see this one. banner_update_test.py had the revision hard-coded as 3; it now reads it from incs/version.h. tests/banner_news_test.py renders the panel at 80 and 120 columns, in UTF-8 and in C. It checks four things: - every framed row is the terminal's width; - no news row is clipped; - the guide link shows in full; - the guide is in mkdocs.yml's nav and names every key the banner shows. On the 3.1.3 build it fails 7 checks, the LANG=C frame among them. Lengthening one row makes it fail at 80 columns only. Also: the autosuggestions entry on interactive.md's roadmap is now marked shipped. --- incs/version.h | 2 +- mkdocs.yml | 1 + src/infrastructure/banner.c | 33 ++++- src/infrastructure/banner2.c | 106 ++++++++-------- tests/banner_news_test.py | 138 ++++++++++++++++++++ tests/banner_update_test.py | 25 +++- wiki/interactive.md | 15 +-- wiki/whats-new.md | 237 +++++++++++++++++++++++++++++++++++ 8 files changed, 482 insertions(+), 75 deletions(-) create mode 100755 tests/banner_news_test.py create mode 100644 wiki/whats-new.md diff --git a/incs/version.h b/incs/version.h index 2936f99a..e1d03eff 100644 --- a/incs/version.h +++ b/incs/version.h @@ -30,6 +30,6 @@ /* Bumped whenever the welcome header itself changes what it says. The header is shown once a day, but a new revision is new information, so it is shown again immediately rather than waiting for tomorrow. */ -# define HELLISH_HEADER_REV 3 +# define HELLISH_HEADER_REV 4 #endif diff --git a/mkdocs.yml b/mkdocs.yml index 6de4fb5b..b264d002 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -55,6 +55,7 @@ markdown_extensions: nav: - Home: index.md + - What's new in 3.2: whats-new.md - What it is + Install: product.md - The manual: manual.md - Builtins reference: diff --git a/src/infrastructure/banner.c b/src/infrastructure/banner.c index 59a828ce..dc8148a6 100644 --- a/src/infrastructure/banner.c +++ b/src/infrastructure/banner.c @@ -55,8 +55,25 @@ static void build_cwd(char *buf, size_t n) ft_strlcpy(buf, cwd, n); } -/* The left column: a greeting, the 42 logo mascot, the hellish identity and the - current directory. HP_LOGO lines are centred and tinted with logo_color. */ +/* Where the release's features are explained in full, under the logo. The + left column is a fixed 43 columns wide, so the link fits there at any + terminal width; the right column has 32 at 80. Plain text, not an OSC 8 + hyperlink: header_width() skips CSI escapes only, and would count the + hidden target as visible columns. */ +static const char *guide_link(void) +{ + if (header_use_unicode()) + return ("\033[38;5;209m\xe2\x86\x92\033[0m " + "\033[4;38;5;81munivers42.github.io/hellish/whats-new\033[0m"); + return ("\033[38;5;209m->\033[0m " + "\033[4;38;5;81munivers42.github.io/hellish/whats-new\033[0m"); +} + +/* The left column: a greeting, the 42 logo mascot, the hellish identity, the + current directory and the link to the release guide. HP_LOGO lines are + centred and tinted with logo_color. Under an ASCII terminal the identity's + middle dot is a dash: header_width() cannot decode it there and stops + counting, which pushed the frame out of line. */ static void build_left(const char **l) { static char welcome[128]; @@ -75,9 +92,15 @@ static void build_left(const char **l) while (g_logo[++i]) l[i + 2] = g_logo[i]; l[i + 2] = ""; - l[i + 3] = "hellish " HELLISH_VERSION " \xc2\xb7 a POSIX shell with bite"; + l[i + 3] = "hellish " HELLISH_VERSION " - a POSIX shell with bite"; + if (header_use_unicode()) + l[i + 3] = "hellish " HELLISH_VERSION + " \xc2\xb7 a POSIX shell with bite"; l[i + 4] = cwd; - l[i + 5] = NULL; + l[i + 5] = ""; + l[i + 6] = "\033[38;5;245mthe 3.2 guide, with examples\033[0m"; + l[i + 7] = guide_link(); + l[i + 8] = NULL; } /* Print the welcome panel on interactive (tty) startup, when it has @@ -92,7 +115,7 @@ static void build_left(const char **l) HELLISH_BANNER. */ void show_welcome(t_shell *state) { - const char *l[16]; + const char *l[20]; const char *r[24]; t_panel p; diff --git a/src/infrastructure/banner2.c b/src/infrastructure/banner2.c index 2711d3a0..ece2aad6 100644 --- a/src/infrastructure/banner2.c +++ b/src/infrastructure/banner2.c @@ -13,70 +13,68 @@ #include "banner_private.h" #include "shell.h" -/* A rotating-feel showcase of real things this shell can do that a plain sh - can't — a brag line plus two copy-pasteable tricks. Each is fronted by a - coloured badge (g_chip) and carries its own inline colours: the command in - warm code-yellow, the gloss in muted grey. All verified to work. */ -static const char *g_chip[] = { - "\033[1;48;5;209;38;5;236m HOT \033[0m", - "\033[1;48;5;141;38;5;236m PRO \033[0m", - "\033[1;48;5;73;38;5;236m TIP \033[0m" -}; - -static const char *g_news[] = { - "\033[38;5;245moutpaces \033[1;38;5;209mbash --posix\033[0m" - "\033[38;5;245m on compute-heavy scripts\033[0m", - "\033[38;5;223mdiff <(a) <(b)\033[0m" - "\033[38;5;245m compare two outputs, no temp files\033[0m", - "\033[38;5;223mcp report{,.bak}\033[0m" - "\033[38;5;245m duplicate a name in one word\033[0m", - NULL -}; - -/* The block heading — an invitation to learn a trick, in cool cyan. */ -static void news_head(char *dst) +/* One row of "What's new in 3.2": the keys that reach a new feature, in + code-yellow, then what it does, in muted grey -- the shape of "Getting + started", so each can be tried before the prompt even appears. Every + row fits the 32 columns the right column has on an 80-column terminal. + Keys are padded to ten columns by hand (ft_snprintf pads bytes; an arrow + is three bytes in one column); the ASCII keys are for terminals where + header_use_unicode() says no. A NULL key ends a group: the always-on + features, then the hint keys, which need HELLISH_AUTOSUGGEST. + wiki/whats-new.md is the long form of this table and must stay in step + with it; next release, replace both and bump HELLISH_HEADER_REV. + Returns 0 at the end of a group. */ +static int news_row(char *dst, int n) { - ft_snprintf(dst, 160, "\033[1;38;5;81mDid you know?\033[0m"); -} + static const char *utf8[] = {"\xe2\x86\x91 \xe2\x86\x93 ", + "make ", "bind -x ", "bindkey ", "${var@P} ", "$SRANDOM ", + NULL, "\xe2\x86\x92 ^F End ", "Alt-F ", "Alt-N/P ", NULL}; + static const char *ascii[] = {"Up Down ", "make ", "bind -x ", + "bindkey ", "${var@P} ", "$SRANDOM ", NULL, "Right ^F ", + "Alt-F ", "Alt-N/P ", NULL}; + static const char *what[] = {"history by prefix", "Makefile targets", + "key runs a command", "zsh binds now work", "render as a prompt", + "and $EPOCHREALTIME", NULL, "take the dim hint", "take one word", + "older/newer hint"}; + const char **keys; -/* One showcase line: an indented badge, then the self-coloured tip. */ -static void news_item(char *dst, const char *chip, const char *text) -{ - ft_snprintf(dst, 160, " %s %s\033[0m", chip, text); + keys = ascii; + if (header_use_unicode()) + keys = utf8; + if (!keys[n]) + return (0); + ft_snprintf(dst, 192, " \033[38;5;223m%s\033[0m " + "\033[38;5;245m%s\033[0m", keys[n], what[n]); + return (1); } -/* The showcase block: the heading, one badged tip per feature, then a salmon - pointer onward to the docs. Backing strings are caller-owned. */ -static void news_block(const char **r, int *i, char nw[][160]) +/* The news block: a salmon NEW badge on a cyan title, the always-on rows, + then the switch for hints and the keys that take them. `k` walks the + table across its group break; `m` the caller-owned backing rows. */ +static void news_block(const char **r, int *i, char nw[][192]) { - const char *arw; - int n; + int k; + int m; - news_head(nw[0]); - r[(*i)++] = nw[0]; + r[(*i)++] = "\033[1;48;5;209;38;5;236m NEW \033[0m " + "\033[1;38;5;81mWhat's new in 3.2\033[0m"; r[(*i)++] = ""; - n = -1; - while (g_news[++n]) - { - news_item(nw[n + 1], g_chip[n], g_news[n]); - r[(*i)++] = nw[n + 1]; - } - arw = "->"; - if (header_use_unicode()) - arw = "\xe2\x86\x92"; - ft_snprintf(nw[4], 160, " \033[38;5;173m%s\033[0m " - "\033[38;5;245mmore in docs/, or run \033[0m\033[38;5;223mhelp\033[0m", - arw); + k = 0; + m = 0; + while (news_row(nw[m], k++)) + r[(*i)++] = nw[m++]; r[(*i)++] = ""; - r[(*i)++] = nw[4]; + r[(*i)++] = " \033[38;5;223mexport HELLISH_AUTOSUGGEST=1\033[0m"; + while (news_row(nw[m], k++)) + r[(*i)++] = nw[m++]; } -/* The right column: getting-started tips, the "Did you know?" showcase, then a - live version/update status footer. Backing strings are static (shown once). -*/ +/* The right column: getting-started tips, what is new in this release, + then a live version/update status footer. Backing strings are static + (shown once). */ void build_right(const char **r) { - static char nw[5][160]; + static char nw[9][192]; static char status[192]; int i; @@ -84,8 +82,8 @@ void build_right(const char **r) r[i++] = HP_HEAD "Getting started"; r[i++] = ""; r[i++] = " help commands & options"; - r[i++] = " update check for a newer release"; - r[i++] = " type NAME how any name resolves"; + r[i++] = " update check for updates"; + r[i++] = " type NAME what a name runs"; r[i++] = HP_RULE; news_block(r, &i, nw); r[i++] = HP_RULE; diff --git a/tests/banner_news_test.py b/tests/banner_news_test.py new file mode 100755 index 00000000..302bdbdc --- /dev/null +++ b/tests/banner_news_test.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +"""The welcome banner's "What's new" block reads whole, and its link lands. + +The block is there to be read before the first prompt, so a clipped row is a +defect. At 80 columns, the narrowest terminal the panel is laid out for, the +right column has 32 cells; a row longer than that is cut with an ellipsis +and loses its meaning. The 3.1 rows were written for about 40 and were cut. +Under an ASCII terminal (LANG=C) every framed row must still be exactly the +terminal's width: a UTF-8 byte there cannot be measured, and the identity +line's middle dot pushed the frame out of line. + +The banner links to wiki/whats-new.md. That page must exist and be in the +docs site's nav (mkdocs.yml), or the printed URL is a 404, and it must name +every key the banner shows, so the two cannot drift apart. + +Usage: python3 banner_news_test.py /path/to/hellish +""" +import fcntl +import os +import pty +import re +import select +import struct +import sys +import tempfile +import termios +import time + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +SHELL = os.path.abspath(sys.argv[1] if len(sys.argv) > 1 else + os.path.join(ROOT, "build", "bin", "hellish")) +FAILS = [] +ESC = re.compile(r"\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(\x07|\x1b\\)") +LINK = "univers42.github.io/hellish/whats-new" + + +def check(name, ok, detail=""): + print(("ok " if ok else "FAIL ") + name) + if not ok: + FAILS.append(name) + print(" " + detail) + + +def banner(cols, lang): + """The banner as text rows, drawn on a `cols`-wide pty in a fresh HOME + (so the cwd row is a short `~`).""" + home = tempfile.mkdtemp(prefix="banner_news_") + env = {"HOME": home, "PATH": os.environ["PATH"], "TERM": "xterm-256color", + "LANG": lang, "LC_ALL": lang, "XDG_CACHE_HOME": home, + "HELLISH_BANNER": "1", "HELLISH_NO_UPDATE_CHECK": "1", + "ASAN_OPTIONS": "detect_leaks=0"} + pid, fd = pty.fork() + if pid == 0: + os.chdir(home) + os.environ.clear() + os.environ.update(env) + os.execv(SHELL, [SHELL]) + os._exit(127) + fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", 50, cols, 0, 0)) + out, quiet, end = b"", time.time() + 1.0, time.time() + 15 + while time.time() < min(quiet, end): + r, _, _ = select.select([fd], [], [], 0.05) + if r: + try: + out += os.read(fd, 65536) + except OSError: + break + quiet = time.time() + 1.0 + os.kill(pid, 9) + os.waitpid(pid, 0) + text = ESC.sub("", out.decode("utf-8", errors="replace")) + return [ln.strip("\r") for ln in text.split("\n")] + + +def news_cells(rows, bar): + """The right-hand cells from the "What's new" heading to the next rule.""" + cells, inside = [], False + for row in rows: + parts = row.split(bar) + if len(parts) != 4: + continue + cell = parts[2] + if "What's new in" in cell: + inside = True + elif inside and set(cell.strip()) <= set("─-"): + if cell.strip(): + break + if inside: + cells.append(cell) + return cells + + +def panel(name, cols, lang, bar, clip): + rows = banner(cols, lang) + framed = [r for r in rows if r.startswith(bar) and r.endswith(bar)] + check(name + "/banner-drawn", len(framed) > 10, repr(rows[:6])) + bad = [r for r in framed if len(r) != cols] + check(name + "/every-row-is-%d-wide" % cols, not bad, repr(bad[:2])) + cells = news_cells(rows, bar) + check(name + "/news-block-found", len(cells) > 5, repr(cells)) + cut = [c for c in cells if c.rstrip().endswith(clip)] + check(name + "/no-news-row-clipped", not cut, repr(cut)) + check(name + "/guide-link-whole", any(LINK in r for r in framed), + repr([r for r in framed if "univers42" in r])) + return cells + + +def doc(cells): + path = os.path.join(ROOT, "wiki", "whats-new.md") + check("doc/whats-new-exists", os.path.isfile(path), path) + if not os.path.isfile(path): + return + with open(path, encoding="utf-8") as f: + page = f.read() + with open(os.path.join(ROOT, "mkdocs.yml"), encoding="utf-8") as f: + nav = f.read() + check("doc/in-the-site-nav", re.search(r":\s*whats-new\.md\s*$", nav, + re.M) is not None) + # A key row is " KEY10 what": one cell margin, two of indent, then + # the key padded to ten columns. The switch row is a command instead. + for c in cells: + text = c[3:].rstrip() + if not text or "What's new in" in c: + continue + key = text if text.startswith("export ") else c[3:13].rstrip() + check("doc/names " + key, key in page, "not in " + path) + + +def main(): + cells = panel("utf8-80", 80, "C.UTF-8", "│", "…") + panel("utf8-120", 120, "C.UTF-8", "│", "…") + panel("ascii-80", 80, "C", "|", "~") + doc(cells) + print("%d failed" % len(FAILS)) + return 1 if FAILS else 0 + + +sys.exit(main()) diff --git a/tests/banner_update_test.py b/tests/banner_update_test.py index 5066b91c..f1e60b26 100755 --- a/tests/banner_update_test.py +++ b/tests/banner_update_test.py @@ -67,6 +67,18 @@ def version(): RUNNING = version() +def header_rev(): + """HELLISH_HEADER_REV from incs/version.h: a seeded state must carry the + banner revision the binary was built with to count as already shown.""" + root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + with open(os.path.join(root, "incs", "version.h")) as f: + m = re.search(r"#\s*define\s+HELLISH_HEADER_REV\s+(\d+)", f.read()) + return int(m.group(1)) + + +HEADER_REV = header_rev() + + def bump(v, part=1): """A version strictly newer than `v`, for seeding a pending update.""" n = [int(x) for x in v.split(".")[:3]] @@ -161,7 +173,7 @@ def main(): c = tmp() try: # Fresh cache would show the banner anyway, so mark it shown first. - seed(c, header_shown=int(time.time()), header_rev=3, + seed(c, header_shown=int(time.time()), header_rev=HEADER_REV, header_ver=RUNNING) out, _ = session(c, {"HELLISH_BANNER": "1"}) check("HELLISH_BANNER=1 forces the banner", "Welcome back" in out, @@ -184,7 +196,7 @@ def main(): try: seed(c, latest=newer, checked=int(time.time()), notified=int(time.time()), header_shown=int(time.time()), - header_rev=3, header_ver=RUNNING) + header_rev=HEADER_REV, header_ver=RUNNING) out, _ = session(c) check("a pending update re-shows the banner", "Welcome back" in out, @@ -199,7 +211,8 @@ def main(): c = tmp() try: seed(c, latest=newer, checked=int(time.time()), - header_shown=int(time.time()), header_rev=3, header_ver=RUNNING) + header_shown=int(time.time()), header_rev=HEADER_REV, + header_ver=RUNNING) out, _ = session(c) check("first session announces it", "Welcome back" in out) check("the announcement is recorded", @@ -212,7 +225,7 @@ def main(): # A newer release still gets announced. newest = bump(RUNNING, part=0) seed(c, latest=newest, checked=int(time.time()), - header_shown=int(time.time()), header_rev=3, + header_shown=int(time.time()), header_rev=HEADER_REV, header_ver=RUNNING, announced=newer) out, _ = session(c) check("a newer release is announced again", @@ -226,7 +239,7 @@ def main(): try: seed(c, latest=newer, checked=int(time.time()), notified=int(time.time()), header_shown=int(time.time()), - header_rev=3, header_ver=RUNNING, announced=newer) + header_rev=HEADER_REV, header_ver=RUNNING, announced=newer) for n in (1, 2): out, _ = session(c) check("session %d: the prompt carries the update badge" % n, @@ -234,7 +247,7 @@ def main(): "no badge: %r" % out[-300:]) # And it disappears once the running shell IS the latest. seed(c, latest=RUNNING, checked=int(time.time()), - header_shown=int(time.time()), header_rev=3, + header_shown=int(time.time()), header_rev=HEADER_REV, header_ver=RUNNING, announced=RUNNING) out, _ = session(c) check("no badge once the shell is up to date", diff --git a/wiki/interactive.md b/wiki/interactive.md index 0748097e..0a73a670 100644 --- a/wiki/interactive.md +++ b/wiki/interactive.md @@ -169,16 +169,13 @@ $ git commit -m "fix: close [[ ]] on metachar-adjacent ]]" ``` *(commands green, flags cyan, strings yellow — illustrative)* -### Autosuggestions 🚧 *(Week 1)* +### Autosuggestions ✅ *(3.2)* -fish-style "ghost text": as you type, the longest matching command from history is shown dimmed -after the cursor; **→ / End** accepts it. Reuses the loaded history and the same redisplay path -as highlighting. - -```text -$ make OPT=1 all ← typed -$ make OPT=1 all && ./build/bin/hellish ← suggested (dim), press → to accept -``` +Shipped in 3.2, opt-in with `export HELLISH_AUTOSUGGEST=1`. As you type, the newest history line +that starts with the typed text is shown dimmed after the cursor. **→ / End / Ctrl-F** take it, +**Alt-F** one word of it, and **Alt-N / Alt-P** show other matches. In the same release, Up and +Down search the history by the text before the cursor. See +[What's new in 3.2](whats-new.md#inline-hints). ### Completion polish 🚧 *(Week 1, time-boxed)* diff --git a/wiki/whats-new.md b/wiki/whats-new.md new file mode 100644 index 00000000..6add2881 --- /dev/null +++ b/wiki/whats-new.md @@ -0,0 +1,237 @@ +# What's new in 3.2 + +hellish 3.2 is about the prompt. The up arrow finds what you started typing. +A dim hint from your history can complete the line, and you take it with one +key. `make ` knows your targets, and `bind` puts a shell command on a +key. This page shows how to use each one. Every example on it was run on a +3.2 build. + +!!! tip "Get it" + Already running hellish? Type `update --now`. Otherwise install it: + + ```sh + curl -fsSL https://raw.githubusercontent.com/Univers42/hellish/main/install.sh | sh + ``` + +## At a glance + +This is the table from the welcome banner, explained. + +| Keys | What it does | Needs | +|---|---|---| +| `↑ ↓` | [history by prefix](#history-that-matches-what-you-typed) | on by default | +| `make ` | [Makefile targets](#completing-make-targets) | | +| `bind -x` | [key runs a command](#key-bindings-with-bind) | | +| `bindkey` | [zsh binds now work](#bindkey-in-zsh-configs) | zsh mode | +| `${var@P}` | [render as a prompt](#rendering-text-like-the-prompt) | | +| `$SRANDOM` | [and `$EPOCHREALTIME`](#two-new-variables) | | +| `→ ^F End` | [take the dim hint](#inline-hints) | `HELLISH_AUTOSUGGEST=1` | +| `Alt-F` | [take one word](#inline-hints) | `HELLISH_AUTOSUGGEST=1` | +| `Alt-N/P` | [older/newer hint](#inline-hints) | `HELLISH_AUTOSUGGEST=1` | + +## History that matches what you typed + +Type the start of a command, then press ↑. Up now steps only +through history lines that begin with the text before the cursor, and +↓ steps back toward the newest one: + +```text +$ git -> git push the newest line starting with "git" +$ git -> git status the one before it +``` + +On an empty line, Up and Down walk the whole history as before. + +- It is on by default. `HELLISH_HISTORY_PREFIX=0` (or `off`) brings back + plain Up/Down. The variable is read at every prompt, so you can change it + in the middle of a session. +- Configs that already asked for this now get it. That covers bash's + `bind '"\e[A": history-search-backward'`, zsh's + `bindkey '^[[A' history-beginning-search-backward`, and oh-my-zsh's + `up-line-or-beginning-search`. Before 3.2, each of them left the arrow + dead. +- A key you bound yourself keeps your binding. + +## Inline hints + +Hints are off until you ask for them. Add this line to `~/.hellishrc`, or +type it at the prompt to try it: + +```sh +export HELLISH_AUTOSUGGEST=1 +``` + +From then on, when you type the start of a line you ran before, the rest of +it appears dimmed after the cursor. The hint is the newest history line that +starts with what you typed. It is not part of your line until you take it: + +```text +$ echo hello ran earlier +$ ech|o hello you typed "ech", the cursor is at |, "o hello" is dim +``` + +| Key | What it does | +|---|---| +| →, End or Ctrl-F | take the whole hint | +| Alt-F or Ctrl-→ | take its next word | +| Alt-N / Alt-P | show an older / a newer match instead | +| Enter | run only what you typed | + +- With no hint on screen, these keys keep their usual job: Right moves the + cursor, Alt-F moves a word, and Alt-N / Alt-P are readline's history + search. +- A *word* is the hint's leading blanks plus the text up to the next space + or tab. +- Cycling skips duplicates. It stops at either end with a bell instead of + wrapping around. Any edit starts again from the newest match. +- Hints also work in vi insert mode. +- A history line that holds a newline or a control character is never + offered. A terminal that cannot move the cursor (`TERM` unset or `dumb`) + never shows a hint. +- `HELLISH_SUGGEST_MAX=N` sets how many of the newest history lines are + searched (default 1000). +- In zsh mode, widgets see the hint as `POSTDISPLAY`, and can set their own. + +## Completing make targets + +Tab after `make` offers the targets of the Makefile in the +current directory. With this Makefile: + +```make +.PHONY: all clean fclean re +NAME = demo +all: $(NAME) +$(NAME): main.o + cc -o $@ $^ +%.o: %.c + cc -c $< +clean: + rm -f *.o +fclean: clean + rm -f $(NAME) +re: fclean all +``` + +```text +$ make +all clean fclean re +$ make fc +$ make fclean +``` + +The variable, the pattern rule, `$(NAME)` and `.PHONY` are not targets you +would type, so they are not offered. + +- The shell reads the file itself; `make` is never run. It reads the first + of `GNUmakefile`, `makefile` and `Makefile` (the order GNU make uses). +- It also works for `gmake` and `/usr/bin/make`, and after `sudo`, + `env X=1`, `&&` or `(`. +- `include` lines are followed one level deep. If you change only an + included file, the list is not refreshed: touch the Makefile. +- With no Makefile, Tab completes file names as usual. + +The full rules are in the manual's +[make targets](manual.md#make-targets) section. + +## Key bindings with bind + +hellish now has bash's `bind` builtin, so the lines you would put in +`~/.inputrc` work in `~/.hellishrc`: + +```sh +bind '"\e[A": history-search-backward' # a key -> a readline command +bind 'set completion-ignore-case on' # a readline setting +bind -x '"\C-t": date' # a key -> a shell command +``` + +`bind -x` runs the command the way bash does. It can read and change the +line you are editing through `READLINE_LINE` and `READLINE_POINT` (the +cursor). This key puts `sudo` in front of the line and leaves the cursor at +the end: + +```sh +bind -x '"\C-xs": READLINE_LINE="sudo $READLINE_LINE"; READLINE_POINT=${#READLINE_LINE}' +``` + +```text +$ apt update +$ sudo apt update +``` + +To see what is bound: `bind -p` lists key bindings, `bind -v` the settings, +`bind -X` the `-x` commands and `bind -l` every command name. `help bind` +shows every option. + +## bindkey in zsh configs + +In zsh mode (`set -o zsh`, `emulate zsh`, or a sourced `.zsh` file), +`bindkey` now understands zsh's key notation (`^[[A`, `^R`, `^[b`, `\E`) +and reaches the line editor's own widgets. These two lines from a zsh +config now do what they say: + +```zsh +bindkey '^[[A' history-beginning-search-backward +bindkey '^[[B' history-beginning-search-forward +``` + +## Rendering text like the prompt + +`${var@P}` expands a value the way the prompt does. It uses the same +renderer as `PS1`, so the result always matches your prompt: + +```text +$ USER=ada; PS1='\u in \W \$ '; cd /usr/share +$ echo "${PS1@P}" +ada in share $ +``` + +Both escape languages work, bash's `\u \w` and zsh's `%n %~`. `prompt +preview` uses it to draw the themes. + +## Two new variables + +```text +$ echo $SRANDOM +2414656789 +$ echo $EPOCHREALTIME +1792288722.298309 +``` + +`$SRANDOM` is a 32-bit random number, a new one each time you read it. +`$EPOCHREALTIME` is the time in seconds since the epoch, with microseconds. +Both come from bash: `$EPOCHREALTIME` from 5.0, `$SRANDOM` from 5.1. + +## Fixed + +At the prompt: + +- On some builds, the hint keys did nothing and Up did not search by prefix: + the keys kept their stock behaviour. They now work on every build. +- Two small memory leaks in line editing are fixed. One came from walking + history with Up or Ctrl-P and then pressing Enter or + Ctrl-C; the other from cancelling an Alt-P search. +- In zsh mode, pressing Esc twice (oh-my-zsh's `sudo` plugin) + printed `builtin: which: not a shell builtin`. `which [-ap] name` now + exists in zsh mode. +- `PS2` is the prompt of every continuation line. +- A Ctrl-C at the wrong moment no longer eats the next key. + +In scripts: + +- `exec nosuch` no longer crashes the shell. +- A runtime error names the line it happened on, and a command that cannot + run says why, in bash's words and with bash's exit status. +- `read` assigns its variables at end of input, and never a readonly one. +- A quoted `"$@"` element is never expanded as a glob pattern. +- `export X` before `X` is set keeps the export. +- Heredocs work inside function bodies, `eval` and `source`. + +Installing and building: + +- The `curl … | sh` installer prints the complete `PATH` instruction + ([#164](https://github.com/Univers42/hellish/issues/164)). +- `make re` no longer stops on NFS when a hellish from `build/` is still + running. + +Every change since 3.1.3: +[v3.1.3...v3.2.0](https://github.com/Univers42/hellish/compare/v3.1.3...v3.2.0). From bc277208e0a6207c5b183418f39682a56e952e70 Mon Sep 17 00:00:00 2001 From: LESdylan Date: Sun, 18 Oct 2026 04:09:50 +0200 Subject: [PATCH 6/6] =?UTF-8?q?release:=203.2.0=20=E2=80=94=20Up=20finds?= =?UTF-8?q?=20what=20you=20typed,=20a=20hint=20is=20one=20key=20away,=20an?= =?UTF-8?q?d=20bind?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prefix history search, opt-in inline hints, make , bind / bind -x, bindkey's zsh notation, ${var@P}, $SRANDOM and $EPOCHREALTIME. Also the fixes that made the hint and prefix keys work on non-PIE builds, two readline undo-list leaks, zsh-mode which, and make re on NFS. RELEASE.md has the summary; wiki/whats-new.md, which the banner links to, has the long form. --- RELEASE.md | 61 ++++++++++++++++++++++++++++++++++++++++++++++-- incs/version.h | 2 +- npm/package.json | 2 +- 3 files changed, 61 insertions(+), 4 deletions(-) diff --git a/RELEASE.md b/RELEASE.md index aaefb59f..2eea7adc 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -3,8 +3,65 @@ > 🔥 **hellish** is a fast, almost‑POSIX shell written from scratch in C — > hackable, observable, and pleasant to live in. -This file is what the welcome banner points you at. It tracks what's new and -shows you how to drive the shell. +This file tracks what's new in every release and shows you how to drive the +shell. The welcome banner links to the guide for the latest one: +[What's new](https://univers42.github.io/hellish/whats-new/). + +--- + +## v3.2.0 — *Up finds what you typed, a hint is one key away, and `bind`* + +**The guide: [What's new in 3.2](https://univers42.github.io/hellish/whats-new/)**. +It covers every feature: the keys that reach it, and an example run on +3.2. The welcome banner shows the same list once after you update. + +- **Up and Down search by what you typed.** `git` steps through the + history lines that start with `git`; on an empty line Up walks the whole + history. On by default; `HELLISH_HISTORY_PREFIX=0` turns it off. The + configs that ask for it now work too: bash's + `bind '"\e[A": history-search-backward'` and zsh's + `bindkey '^[[A' history-beginning-search-backward` (#136). +- **Inline hints**, opt-in with `export HELLISH_AUTOSUGGEST=1`. The newest + matching history line shows dim after the cursor. Right / End / Ctrl-F + take all of it, Alt-F / Ctrl-Right take one word, and Alt-N / Alt-P + show other matches (#140). +- **`make `** completes the targets of the Makefile in the current + directory. The shell reads the file itself; make never runs (#140). +- **`bind`**, bash's builtin: key bindings and readline settings from + `~/.hellishrc`. `bind -x` runs a shell command on a key, and the command + can edit the line through `READLINE_LINE` / `READLINE_POINT`. +- **`bindkey`** in zsh mode reads zsh's `^X` notation and reaches the line + editor's own widgets. +- **`${var@P}`** renders a value through the PS1 renderer. **`$SRANDOM`** + and **`$EPOCHREALTIME`** are new variables. + +Fixed on the way to the release: + +- **The hint and prefix keys did nothing on some builds.** The shell found + readline's commands by comparing function addresses. On a non-PIE binary + linked against a readline built with `-Bsymbolic-functions` (Ubuntu's), + one function has two addresses, so nothing matched and every key kept + its stock meaning. The shell now asks readline for each command by name + (`rl_named_function`). +- **Two leaks in readline 8.1's line editing**, one undo list per line: + - after walking the history with Up or Ctrl-P; + - after cancelling an Alt-P search. + + LeakSanitizer reported both. `tests/rl_undo_leak_test.py` covers ten + ways to reach them. +- **`which` in zsh mode.** Esc Esc (oh-my-zsh's `sudo` plugin) printed + `builtin: which: not a shell builtin`. zsh's `which [-ap]` now exists in + zsh mode. +- **`make re` on NFS** no longer stops in `fclean` on the `.nfsXXXX` file + a running `build/bin/hellish` keeps open. +- **The welcome banner** fits an 80-column terminal, and its frame stays + aligned under `LANG=C`. + +About two dozen smaller fixes landed as well, among them PS2 on every +continuation line, `exec nosuch` no longer reading freed memory, `read` +at end of input, and heredocs in functions, `eval` and `source`. The guide +lists the ones you can see; the compare view has all of them: +[v3.1.3...v3.2.0](https://github.com/Univers42/hellish/compare/v3.1.3...v3.2.0). --- diff --git a/incs/version.h b/incs/version.h index e1d03eff..9a6bf8a5 100644 --- a/incs/version.h +++ b/incs/version.h @@ -15,7 +15,7 @@ /* The single source of truth for the running shell's version. Bumped in step with the git tag / GitHub release / npm package / docker image. */ -# define HELLISH_VERSION "3.1.3" +# define HELLISH_VERSION "3.2.0" /* Where releases live; used by the `update` builtin and the daily check. */ # define HELLISH_REPO "Univers42/hellish" diff --git a/npm/package.json b/npm/package.json index ac20e7b4..7421118f 100644 --- a/npm/package.json +++ b/npm/package.json @@ -1,6 +1,6 @@ { "name": "hellish-shell", - "version": "3.1.3", + "version": "3.2.0", "description": "hellish — a fast, POSIX-compliant shell, with horns", "bin": { "hellish": "bin/hellish.js"