From b754709f0291afabf2f81d5b224343a694938647 Mon Sep 17 00:00:00 2001 From: Rootiest Date: Mon, 31 Aug 2026 20:04:19 -0400 Subject: [PATCH] docs(site): add inline code spans to generated Starlight pages Function doc-headers are authored as plain text -- `config-help`, `funcsave` and anyone opening the `.fish` file read them as-is -- so they carry no backticks. The site inherited that and rendered `-a/--all` and `__fish_config_op_aliases` as ordinary prose. docs/codespans.py adds the spans at render time, as the last step of prettify(), so only the site sees them; build_concat() (man page, config-help) is byte-for-byte unchanged. Recognised shapes: flags and flag pairs, `$vars`, SCREAMING_SNAKE env vars, snake_case identifiers, paths and filenames, key chords, command shadow chains (`ls->eza`), runs of tool names, whole command lines in a table column of command lines, and known command names -- drawn from the `_fdc_*` catalog in functions/_fish_deps_catalog.fish, the functions/ listing, and a standard-command list, minus the names that also read as English. Fenced blocks, existing code spans, headings, link targets, URLs, component markup and bodies are passed through untouched, and every rule bails out rather than guess. --- CONTRIBUTING.md | 7 + docs/build-manual.py | 15 +- docs/codespans.py | 534 ++++++++++++++++++++++++++++++++++++++++++ docs/site/README.md | 26 ++ docs/verify-manual.py | 181 +++++++++++++- 5 files changed, 758 insertions(+), 5 deletions(-) create mode 100644 docs/codespans.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8fbc9d1..cb0ad6d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -239,6 +239,13 @@ python3 docs/verify-manual.py CI runs the same verification and regenerates the site/man page — a broken manual won't get published, but running it locally saves a round trip. +Write doc-headers as plain text — no backticks. `-a/--all`, +`__fish_config_op_aliases` and `~/.config/fish/config.fish` are typed +bare, because the header is also read as-is by `config-help` and by +anyone opening the file. `docs/codespans.py` adds the inline code spans +the docs site wants when it renders, so the SSOT never carries them; see +`docs/site/README.md` for which shapes it recognises. + ## Testing ```fish diff --git a/docs/build-manual.py b/docs/build-manual.py index e2ab213..e74802b 100644 --- a/docs/build-manual.py +++ b/docs/build-manual.py @@ -8,12 +8,14 @@ """ import argparse +import functools import json import re import shutil import sys from pathlib import Path +import codespans import manualtools as mt import generate_component_registry @@ -484,12 +486,21 @@ def _as_aside(para: list[str]) -> str | None: return f"" +@functools.lru_cache(maxsize=1) +def _code_vocabulary() -> codespans.Vocabulary: + """The command names codespans may wrap, read from the repo once.""" + return codespans.vocabulary(DOCS.parent) + + def prettify(body: str, entry_name: str | None = None) -> str: """Rewrite a body's indented code blocks and labeled asides for the website. Site-only: the man page and `config-help` keep reading the untouched SSOT, where the indented form and the `LABEL:` text are exactly what - pandoc/`config-help` want. + pandoc/`config-help` want. The same applies to the inline code spans + added last: `-a/--all` and `__fish_config_op_aliases` are authored bare + so the `functions/*.fish` headers stay readable as plain text, and the + backticks the site wants are put on here rather than in the SSOT. """ out: list[str] = [] block: list[str] = [] @@ -528,7 +539,7 @@ def prettify(body: str, entry_name: str | None = None) -> str: while block and not block[-1].strip(): block.pop() out.append(_prettify_block(block, entry_name)) - return "\n".join(out) + return codespans.add_code_spans("\n".join(out), _code_vocabulary()) ENTRY_HEADS = { diff --git a/docs/codespans.py b/docs/codespans.py new file mode 100644 index 0000000..a14d58a --- /dev/null +++ b/docs/codespans.py @@ -0,0 +1,534 @@ +#!/usr/bin/env python3 +# Copyright (C) 2026 Rootiest +# SPDX-License-Identifier: AGPL-3.0-or-later +"""Wrap code-shaped tokens in inline code spans for the Starlight site. + +Section 5 is generated from the `functions/*.fish` comment headers, which +are read as plain text by `config-help`, by `funcsave`, and by anyone +opening the source file. Backticks there would be noise, so the headers +are authored without them -- and the site inherited that, rendering +`-a/--all` and `__fish_config_op_aliases` as ordinary prose. + +This module closes that gap at render time: it walks the markdown a page +is about to be written as, finds the tokens whose shape only ever means +"code" (flags, `$vars`, snake_case identifiers, paths, key chords, known +command names) and wraps each one in a code span. The SSOT is never +touched, so the man page and `config-help` keep the plain-text form. + +Everything here is conservative by construction: leaving a token alone is +always safe and wrapping the wrong one is not, so every rule bails out the +moment it is unsure. The regions that must never be rewritten -- fenced +blocks, existing code spans, link targets, URLs, JSX attributes, +`` bodies, headings -- are recognised first and passed through. +""" + +import functools +import re +from pathlib import Path + +FENCE_RE = re.compile(r"^\s*(```|~~~)") + +# --------------------------------------------------------------------------- +# Vocabulary +# --------------------------------------------------------------------------- + +# Commands a reader expects to see typeset as code. This is the *wide* list: +# it decides that a table column holds command lines (see _command_columns), +# where position already proves the name is a command. Wrapping a bare +# mention in running prose is gated on the strict tier below. +STANDARD_COMMANDS = frozenset( + """ + apk apt awk basename bash bat bg bind brew builtin cargo cat cd chmod + chown chsh cmp column cp curl cut date dd delta df diff dig dirname dnf + docker dpkg du echo emacs emerge env eza exec exit export fastfetch fd + fdisk fg fgrep file find fisher flatpak fzf gh git grep gzip head help + hexdump host hostname id ifconfig install ip jq jobs journalctl kill + killall kitten kitty last less ln locale ls lsblk lsd lsof make man + micro mkdir more mount mpv mv nano nc neofetch neovim netstat nix nl + nohup npm nproc nvim od open openssl pacman paru paste pgrep ping pip + pip3 pkill pr printf ps pwd python python3 readlink realpath rg rm rmdir + rpm rsync scp sed seq sh shutdown sleep snap sort source ssh stat + strings su sudo sync systemctl tac tail tar tee test time tldr tmux + touch tr trash tree type udisksctl umount uname uniq unzip uv vdir vi + vim vlc wait wc wezterm wget which who whoami wl-copy wl-paste xargs + xbps-install xclip xdg-open xsel yay yum yt-dlp zellij zip zoxide zsh + zypper + abbr alias and argparse begin block break case command complete contains + continue count else emit end eval false for function funcsave functions + history if math not or random read return set set_color status string + switch true while + """.split() +) + +# Names that also read as ordinary English (or as this manual's own prose) +# often enough that a bare mention is not evidence of a command. They still +# take part in command-line and list detection, where position disambiguates +# -- they just never get wrapped on their own. +AMBIGUOUS_COMMANDS = frozenset( + """ + abbr alias all and at basename bat begin bg bind block branch break case + cat cd cheat cleanup clone column command complete contains continue copy + count cut date dd df dir dirname do docker du duf dust echo edit else emit + end env eval exec exit export false fc fg file find fish for free function + functions git go head help hist history host hostname id if in install ip + jobs join key kill last less link list ln lock locale log logs look ls make + man math micro more mount mv next no not note od open or ov p page paste + pkg poke ports pr ps pwd random read real replay return rm run screen sed + search seq set sh show sleep sort source spark split stat status string + strings su switch sync tab tac tail tar tee test time top touch tr trash + tree true type uniq upgrade view vi wait watch wc which while who write + yes zip + builtin fast function vdir + """.split() +) + +# Extensions that make a bare `name.ext` token unambiguously a filename. +PATH_EXTENSIONS = ( + "fish md mdx json jsonc toml yml yaml py sh bash zsh lua conf cfg ini " + "txt log list service socket desktop css scss ts js astro nix rasi 1" +).split() + +# English function words. A candidate command line containing one is prose. +STOPWORDS = frozenset( + """ + a an the this that these those it its is are was were be been being am + to of in into on at by for from with without within about across after + before during over under again then than so such as and or but nor if + when while where which who whom whose why how all any both each few more + most other some only own same too very can will just should now via per + also either neither every no not + """.split() +) + +_CATALOG_ARRAY_RE = re.compile( + r"set\s+-g\s+_fdc_(?:bins|cargo|pm)\s+((?:[^\n]*\\\n)*[^\n]*)" +) + + +def dependency_names(repo: Path) -> set[str]: + """Every tool name in the `fish-deps` catalog (`_fdc_*` arrays). + + `functions/_fish_deps_catalog.fish` is this repo's dependency database; + reading it here means a tool added there starts rendering as code with + no second list to keep in sync. + """ + path = repo / "functions" / "_fish_deps_catalog.fish" + if not path.exists(): + return set() + names: set[str] = set() + for m in _CATALOG_ARRAY_RE.finditer(path.read_text(encoding="utf-8")): + for token in m.group(1).replace("\\\n", " ").split(): + token = token.strip("\"'") + if token and re.fullmatch(r"[\w.@+-]+", token): + names.add(token) + return names + + +def function_names(repo: Path) -> set[str]: + """Public function names, from the `functions/` directory listing. + + Underscore-prefixed internals are skipped only because the snake_case + rule already covers them, and covers them everywhere -- including the + ones that have no file of their own. + """ + directory = repo / "functions" + if not directory.is_dir(): + return set() + return {p.stem for p in directory.glob("*.fish") if not p.stem.startswith("_")} + + +class Vocabulary: + """The command names the rules recognise, in two tiers. + + `full` is every name we know of, used where position already proves a + token is a command (a command-line table cell, an arrow chain, a + comma-separated run). `strict` is the subset safe to wrap on sight in + running prose: `zoxide` yes, `find` no. + """ + + __slots__ = ("full", "strict") + + def __init__(self, names: set[str]): + # `and`, `or`, `not`, `if` … are fish builtins, but as vocabulary + # entries they turn every conjunction into a command name and break + # list and command-line detection. They are never worth wrapping. + self.full = frozenset(names) - STOPWORDS + self.strict = frozenset( + n + for n in names + if n not in AMBIGUOUS_COMMANDS + and (len(n) >= 3 or any(c.isdigit() for c in n)) + ) + + def __eq__(self, other): + return ( + isinstance(other, Vocabulary) + and self.full == other.full + and self.strict == other.strict + ) + + def __hash__(self): + return hash((self.full, self.strict)) + + +def vocabulary(repo: Path) -> Vocabulary: + """Build the command vocabulary from the repo plus the standard list.""" + return Vocabulary( + set(STANDARD_COMMANDS) | dependency_names(repo) | function_names(repo) + ) + + +EMPTY_VOCABULARY = Vocabulary(set()) + + +# --------------------------------------------------------------------------- +# Token grammar +# --------------------------------------------------------------------------- + +# A token may not start inside a word, a path, a code span, a history +# expansion, or a hyphenated compound: `` `zoxide` ``-backed must not see +# `-backed` as a flag, `and/or` must not see `/or` as a path, and `!-N` must +# not see `-N` as one either. +BEFORE = r"(?``+.+?``+|`[^`\n]*`)" + r"|(?P\[[^\]\n]*\]\([^)\n]*\))" + r"|(?P[A-Za-z][\w+.-]*://\S+|[\w.-]+@[\w.-]+(?::\S+)?)" + r"|(?P\n]*?/?>)" +) + +ARROW = r"(?:->|→|=>)" +# Shortest comma run that reads as a list of tools rather than as prose. +MIN_RUN_NAMES = 3 +# `, and` must be tried before a bare `,` so the conjunction is a separator +# and not an item. +RUN_SPLIT = r",?\s+(?:and|or)\s+|,\s*" +RUN_SPLIT_RE = re.compile(RUN_SPLIT) +# A command name inside a chain is followed by `->`, so the usual "no +# trailing hyphen" guard has to make room for exactly that. +_CMD_END = r"(?!\w)(?!-(?!>))" + + +def _alternation(names) -> str: + """Regex alternation over names, longest first so `rg` can't beat `rga`.""" + if not names: + return r"(?!)" + return "|".join(re.escape(n) for n in sorted(names, key=lambda s: (-len(s), s))) + + +def _atom(vocab: Vocabulary) -> str: + cmd = rf"(?:{_alternation(vocab.strict)})(?![\w-])" + return rf"(?:{KEYBIND}|{VAR}|{PATH}|{FLAG}|{ENVVAR}|{IDENT}|{cmd})" + + +@functools.lru_cache(maxsize=4) +def _scanner(vocab: Vocabulary) -> re.Pattern: + """The single pass over a line: protected regions plus wrappable tokens.""" + full = rf"(?:{_alternation(vocab.full)})" + chain_link = rf"(?:{full}{_CMD_END}|{VAR})" + name = rf"{full}(?![\w-])" + return re.compile( + PROTECTED + # `ls->eza, cat->bat`: a shadow chain. Position makes even an + # ambiguous name unmistakably a command here. + + rf"|(?P{BEFORE}{chain_link}(?:\s*{ARROW}\s*{chain_link})+{AFTER})" + # `cargo, starship, uv, zoxide`: a run of nothing but tool names. + + rf"|(?P{BEFORE}{name}(?:,\s*{name})+" + + rf"(?:,?\s+(?:and|or)\s+{name})?{AFTER})" + # `-a/--all`: slash-joined atoms, each wrapped on its own. + + rf"|(?P{BEFORE}{_atom(vocab)}(?:/{_atom(vocab)})*{AFTER})" + ) + + +@functools.lru_cache(maxsize=4) +def _atom_re(vocab: Vocabulary) -> re.Pattern: + return re.compile(_atom(vocab)) + + +# --------------------------------------------------------------------------- +# Table cells that are whole command lines +# --------------------------------------------------------------------------- + +# The abbreviation tables' second column is an expansion, not a sentence: +# `sudo -s`, `cd ../..`, `journalctl -p 3 -xb`. Wrapping only the flag would +# leave a bare `sudo` in front of a code span; the cell wants to be one span. +# +# Whether a column holds command lines is decided for the column as a whole +# -- one cell is far too little evidence, as `zoxide frecency-based +# navigation` (prose, in a column of prose) and `docker context ls` (a +# command, in a column of commands) open identically. +CELL_TOKEN_RE = re.compile(r"^[\w$~./=:;@+*?%'\"-]+$") +CELL_NAME_RE = re.compile(r"^[a-z][\w.+-]*$") +CELL_OPERATORS = frozenset((r"\|", "|", "&&", "||", ">", ">>", "<", ";")) +MAX_CELL_TOKENS = 8 +COMMAND_COLUMN_RATIO = 0.7 +MIN_COMMAND_COLUMN_ROWS = 3 + + +def _cell_tokens(cell: str) -> list[str] | None: + """Tokenise a cell that could be a command line, or None if it can't be.""" + text = cell.strip() + if not text or any(c in text for c in "`<([)]"): + return None + tokens = text.split() + if not (1 <= len(tokens) <= MAX_CELL_TOKENS): + return None + for token in tokens: + if token in CELL_OPERATORS: + continue + if not CELL_TOKEN_RE.match(token): + return None + if token[:1].isupper() or token.lower() in STOPWORDS: + return None + return tokens + + +def _is_command_cell(cell: str, vocab: Vocabulary) -> bool: + """True when a cell in a command column really is one command line.""" + tokens = _cell_tokens(cell) + if tokens is None: + return False + return tokens[0] in vocab.full or bool(CELL_NAME_RE.match(tokens[0])) + + +def _opens_with_command(cell: str, vocab: Vocabulary) -> bool: + """The per-cell evidence the column vote is counted from.""" + tokens = _cell_tokens(cell) + return tokens is not None and tokens[0] in vocab.full + + +# --------------------------------------------------------------------------- +# Line classification +# --------------------------------------------------------------------------- + +HEADING_RE = re.compile(r"^\s{0,3}#{1,6}\s") +TABLE_ROW_RE = re.compile(r"^\s*\|.*\|\s*$") +TABLE_RULE_RE = re.compile(r"^\s*\|[\s:|-]+\|\s*$") +IMPORT_RE = re.compile(r"^\s*import\s") +FILE_TREE_OPEN = " bool: + """True for a line that must be passed through untouched. + + Headings are excluded because Starlight derives anchors -- and this + pipeline derives `LinkCard` hrefs -- from their text. A line opening + with `<` is component markup, whose attributes are JSX, not markdown. + """ + stripped = line.strip() + return bool( + not stripped + or HEADING_RE.match(line) + or IMPORT_RE.match(line) + or stripped.startswith("<") + or TABLE_RULE_RE.match(line) + ) + + +def _row_cells(line: str) -> list[str]: + return CELL_SPLIT_RE.split(line) + + +def _command_columns(rows: list[str], vocab: Vocabulary) -> set[int]: + """Which column indices of one table hold command lines rather than prose.""" + votes: dict[int, list[int]] = {} + for line in rows: + if TABLE_RULE_RE.match(line): + continue + for index, cell in enumerate(_row_cells(line)): + if not cell.strip() or "`" in cell: + continue + votes.setdefault(index, []).append(_opens_with_command(cell, vocab)) + return { + index + for index, seen in votes.items() + if len(seen) >= MIN_COMMAND_COLUMN_ROWS + and sum(seen) / len(seen) >= COMMAND_COLUMN_RATIO + } + + +# --------------------------------------------------------------------------- +# The pass +# --------------------------------------------------------------------------- + +# Spans this pass creates are marked, not back-ticked, until the very end: +# adjacent ones are merged (`eza` `-l` `-a` -> `eza -l -a`), and only spans +# this pass created may take part in that. +MARK = "\x01" +MERGE_RE = re.compile(rf"{MARK} {MARK}") + + +def _mark(text: str) -> str: + return f"{MARK}{text}{MARK}" + + +def _wrap_atoms(text: str, atom_re: re.Pattern) -> str: + """Mark each atom of a slash-joined group, keeping the separators. + + Rescanning the group rather than capturing during the first match keeps + the grammar readable; the round-trip check makes that shortcut safe -- + if the rescan disagrees with the original match, nothing is changed. + """ + wrapped = atom_re.sub(lambda m: _mark(m.group(0)), text) + if wrapped.replace(MARK, "") != text: + return text + return wrapped + + +def _wrap_split(text: str, separator: str) -> str: + """Mark each item of a separated run, keeping the separators.""" + parts = re.split(rf"({separator})", text) + return "".join(p if i % 2 else _mark(p) for i, p in enumerate(parts)) + + +def _transform(text: str, scanner: re.Pattern, atom_re: re.Pattern, vocab: Vocabulary) -> str: + def repl(m: re.Match) -> str: + group = m.lastgroup + if group == "chain": + return _wrap_split(m.group(0), rf"\s*{ARROW}\s*") + if group == "run": + # A long run anchored by at least one unambiguous tool name is + # a list of commands; two names, one of them a word like + # `function`, is a sentence. + names = RUN_SPLIT_RE.split(m.group(0)) + if len(names) < MIN_RUN_NAMES or not any( + n in vocab.strict for n in names + ): + # Not a list after all -- hand the text back to the + # ordinary token rules rather than swallowing it. + return _wrap_atoms(m.group(0), atom_re) + return _wrap_split(m.group(0), RUN_SPLIT) + if group == "group": + return _wrap_atoms(m.group(0), atom_re) + return m.group(0) + + return scanner.sub(repl, text) + + +def _transform_line( + line: str, + scanner: re.Pattern, + atom_re: re.Pattern, + vocab: Vocabulary, + command_columns: set[int], +) -> str: + if not command_columns: + return _transform(line, scanner, atom_re, vocab) + + out = [] + for index, cell in enumerate(_row_cells(line)): + if index in command_columns and _is_command_cell(cell, vocab): + body = cell.strip() + lead = cell[: len(cell) - len(cell.lstrip())] + trail = cell[len(cell.rstrip()) :] + out.append(f"{lead}{_mark(body)}{trail}") + else: + out.append(_transform(cell, scanner, atom_re, vocab)) + return "|".join(out) + + +def _finish(line: str) -> str: + """Merge abutting new spans, then turn the marks into backticks.""" + return MERGE_RE.sub(" ", line).replace(MARK, "`") + + +def add_code_spans(text: str, vocab: Vocabulary = EMPTY_VOCABULARY) -> str: + """Wrap code-shaped tokens in `text` in inline code spans. + + `text` is a rendered page body (no frontmatter). Fenced blocks, + `` bodies, headings, component markup, existing code spans, + link targets and URLs are left exactly as they are. + """ + scanner = _scanner(vocab) + atom_re = _atom_re(vocab) + + lines = text.split("\n") + eligible = [False] * len(lines) + in_fence = False + in_tree = False + for i, line in enumerate(lines): + if FENCE_RE.match(line): + in_fence = not in_fence + continue + if in_fence: + continue + if FILE_TREE_OPEN in line: + in_tree = True + if in_tree: + if FILE_TREE_CLOSE in line: + in_tree = False + continue + eligible[i] = not _skip_line(line) + + # Command columns are a property of a whole table, so the contiguous + # runs of table rows are resolved before any line is rewritten. + columns: list[set[int]] = [set() for _ in lines] + start = None + for i, line in enumerate(lines + [""]): + is_row = i < len(lines) and eligible[i] and TABLE_ROW_RE.match(line) + if is_row and start is None: + start = i + elif not is_row and start is not None: + found = _command_columns(lines[start:i], vocab) + for j in range(start, i): + columns[j] = found + start = None + + return "\n".join( + _finish(_transform_line(line, scanner, atom_re, vocab, columns[i])) + if eligible[i] + else line + for i, line in enumerate(lines) + ) diff --git a/docs/site/README.md b/docs/site/README.md index 350bbc1..a7d25c6 100644 --- a/docs/site/README.md +++ b/docs/site/README.md @@ -20,6 +20,32 @@ python3 docs/build-manual.py --site `docs/verify-manual.py` validates both sources before you build; run it first if you've touched a header or a manual page. +## Inline code spans + +Function headers are read as plain text (by `config-help`, by `funcsave`, +by anyone opening the `.fish` file), so they're authored without backticks +— `-a/--all`, not `` `-a`/`--all` ``. `docs/codespans.py` puts the +backticks on at render time, as the last step of `prettify()`, so only the +site sees them. + +It recognises flags, `$vars`, `SCREAMING_SNAKE` env vars, snake_case +identifiers (`__fish_config_op_aliases`, `fish_greeting`), paths and +filenames, key chords (`Ctrl-R`), shadow chains (`ls->eza`), runs of tool +names (`btop, dust, duf, …`), whole command lines in a table column of +command lines, and command names it knows — the `_fdc_*` catalog in +`functions/_fish_deps_catalog.fish`, the `functions/` directory listing, +and a standard-command list in the module. + +Names that also read as English (`find`, `top`, `screen`) are listed in +`AMBIGUOUS_COMMANDS` and are never wrapped on sight; they still count +where position already proves they're a command. Add to that list rather +than removing a rule if a wrap ever reads wrong. + +Fenced blocks, existing code spans, headings, link targets, URLs, +component markup, and `` bodies are never touched. Leaving a +token alone is always the safe outcome, so every rule bails out when it +isn't sure. + ## llms.txt The [`starlight-llms-txt`](https://www.npmjs.com/package/starlight-llms-txt) diff --git a/docs/verify-manual.py b/docs/verify-manual.py index 7873a6f..a5db59f 100644 --- a/docs/verify-manual.py +++ b/docs/verify-manual.py @@ -9,6 +9,7 @@ import sys import tempfile from pathlib import Path +import codespans import manualtools as mt # docs/build-manual.py follows this repo's hyphenated CLI-script naming @@ -470,8 +471,8 @@ def test_prettify_splits_an_entry_block(): "option table was not converted to a markdown table" ) assert ( - "\nFalls back to /usr/bin/rm when trash is unavailable." in out - ), "trailing prose stayed indented" + "\nFalls back to `/usr/bin/rm` when trash is unavailable." in out + ), "trailing prose stayed indented (or lost its path code span)" def test_as_table_converts_option_blocks(): @@ -895,7 +896,10 @@ def test_customization_notes_render_as_aside(): assert aside.count(" - ") == 4, f"expected exactly 4 bullets inside the aside:\n{aside}" assert "- Command shadows (rm, cat, ls, ...) react immediately" in aside assert "- With aliases disabled, rm falls back to bare `command rm`" in aside - assert "- Disabled integration commands (spwin, tab, split, hist, logs, upgrade)" in aside + assert ( + "- Disabled integration commands " + "(`spwin`, `tab`, `split`, `hist`, `logs`, `upgrade`)" in aside + ) assert "- On CachyOS, the distro fish config's own aliases" in aside @@ -1235,6 +1239,177 @@ def test_committed_registry_matches_headers(): ) + +# --------------------------------------------------------------------------- +# codespans: inline code spans added at site-render time +# --------------------------------------------------------------------------- + +_REPO = Path(__file__).parent.parent + + +def _spans(text: str) -> str: + return codespans.add_code_spans(text, codespans.vocabulary(_REPO)) + + +def test_codespans_wraps_each_half_of_a_flag_pair(): + """`-a/--all` is the manual's usual way of naming a flag and its alias.""" + got = _spans("Use -a/--all to include both, or -s/--stdout to print.") + assert got == "Use `-a`/`--all` to include both, or `-s`/`--stdout` to print.", got + + +def test_codespans_wraps_override_variables_and_snake_case(): + got = _spans("Disabled via __fish_config_op_aliases; see _fdc_bins and fish_greeting.") + assert got == ( + "Disabled via `__fish_config_op_aliases`; see `_fdc_bins` and `fish_greeting`." + ), got + + +def test_codespans_wraps_paths_vars_env_and_key_chords(): + cases = { + "Sourced from ~/.config/fish/config.fish.": ( + "Sourced from `~/.config/fish/config.fish`." + ), + "honoring $XDG_CONFIG_HOME/aichat/roles/cli.md.": ( + "honoring `$XDG_CONFIG_HOME/aichat/roles/cli.md`." + ), + "Launches with NO_TMUX=1 set.": "Launches with `NO_TMUX=1` set.", + "end the session with Ctrl-D or Ctrl+Alt+F.": ( + "end the session with `Ctrl-D` or `Ctrl+Alt+F`." + ), + } + for source, want in cases.items(): + assert _spans(source) == want, f"{source!r} -> {_spans(source)!r}" + + +def test_codespans_leaves_existing_spans_and_fences_alone(): + body = "\n".join( + [ + "Already `--wrapped` here.", + "", + "```fish", + "rm -e --empty ~/.config/fish", + "```", + "", + "## --not-a-flag-heading", + "", + "", + ] + ) + got = _spans(body).split("\n") + assert got[0] == "Already `--wrapped` here.", got[0] + assert got[3] == "rm -e --empty ~/.config/fish", "a fenced line was rewritten" + assert got[6] == "## --not-a-flag-heading", "a heading was rewritten" + assert got[8].startswith(" list items are filenames the component renders itself.""" + body = "\n- ~/.config/fish/\n - config.fish\n" + assert _spans(body) == body + + +def test_codespans_leaves_links_and_urls_alone(): + cases = [ + "See [`fish-deps`](/reference/dependency-management/fish-deps/) for more.", + "Clone from ssh://git@host/owner/repo.git today.", + "Docs live at https://fish.rootiest.fyi/07-customization/ online.", + ] + for source in cases: + assert _spans(source) == source, f"{source!r} -> {_spans(source)!r}" + + +def test_codespans_ignores_prose_that_only_looks_like_code(): + """Every one of these has bitten a looser version of the pattern set.""" + cases = [ + "the registry -- not the cache -- is rebuilt", + "the everyday filesystem-inspection and -modification shadows", + "expand !^, !*, !-N and !?str? abbreviations", + "grep/cp/mv/wget flag injection", + "resolved and/or rejected", + "a _really_ important caveat", + "the TCP and AGPL acronyms", + ] + for source in cases: + assert _spans(source) == source, f"{source!r} -> {_spans(source)!r}" + + +def test_codespans_wraps_a_command_shadow_chain(): + got = _spans("Falls back through ov -> bat -> man -> less -> cat.") + assert got == "Falls back through `ov` -> `bat` -> `man` -> `less` -> `cat`.", got + + +def test_codespans_wraps_long_runs_of_tool_names_only(): + """Two names, one of them an English word, is a sentence -- not a list.""" + got = _spans("Supports paru, yay, pacman, apt, dnf, zypper, yum, brew, and pkg.") + assert got == ( + "Supports `paru`, `yay`, `pacman`, `apt`, `dnf`, `zypper`, `yum`, " + "`brew`, and `pkg`." + ), got + got = _spans("the custom rm function, trashy, or trash-cli") + assert got == "the custom rm function, `trashy`, or trash-cli", got + + +def test_codespans_wraps_whole_cells_only_in_command_columns(): + """A column of expansions becomes code; a column of prose stays prose.""" + commands = "\n".join( + [ + "| Abbreviation | Description |", + "|---|---|", + "| `..` | cd .. |", + "| `jctl` | journalctl -p 3 -xb |", + "| `sudu` | sudo -s |", + "| `kt` | kitty (Kitty only) |", + ] + ).split("\n") + got = _spans("\n".join(commands)).split("\n") + assert got[2] == "| `..` | `cd ..` |", got[2] + assert got[3] == "| `jctl` | `journalctl -p 3 -xb` |", got[3] + assert got[4] == "| `sudu` | `sudo -s` |", got[4] + assert got[5] == "| `kt` | `kitty` (Kitty only) |", got[5] + + prose = "\n".join( + [ + "| Command | Active behavior |", + "|---|---|", + "| `cd` | zoxide frecency-based navigation |", + "| `top` | btop resource monitor |", + "| `mkdir` | verbose path-tree display on creation |", + "| `history` | timestamps prepended to every entry |", + ] + ) + got = _spans(prose).split("\n") + assert got[2] == "| `cd` | `zoxide` frecency-based navigation |", got[2] + assert got[3] == "| `top` | `btop` resource monitor |", got[3] + + +def test_codespans_merges_abutting_new_spans(): + """One command line reads as one span, not as a row of adjacent ones.""" + got = _spans("| `ls` | eza -l -a --icons --hyperlink | system ls |") + assert got == "| `ls` | `eza -l -a --icons --hyperlink` | system ls |", got + + +def test_codespans_vocabulary_comes_from_the_deps_catalog(): + """A tool added to _fish_deps_catalog.fish needs no second list.""" + names = codespans.dependency_names(_REPO) + assert {"fzf", "zoxide", "prettyping"} <= names, sorted(names)[:20] + vocab = codespans.vocabulary(_REPO) + assert "zoxide" in vocab.strict, "an unambiguous tool name should be wrappable" + assert "find" not in vocab.strict, "an English word must not be wrappable on sight" + assert "find" in vocab.full, "…but it still counts as a command-line opener" + + +def test_codespans_is_site_only(): + """The concat (man page, config-help) keeps the plain-text form.""" + import build_manual + + text = build_manual.build_concat(Path(__file__).parent / "manual") + assert "-r/--resume" in text, "sanity: the bare flag-pair form is what's authored" + assert "`-r`/`--resume`" not in text, "code spans leaked into the man-page pipeline" + + TESTS = [v for k, v in sorted(globals().items()) if k.startswith("test_")]