From 995a1a56736e6fb28cb7ad48c4a3753ee2a280a1 Mon Sep 17 00:00:00 2001 From: LESdylan Date: Sun, 18 Oct 2026 04:08:31 +0200 Subject: [PATCH] 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).