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_")]