#!/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, indented code 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, indented code 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 if line.startswith(INDENTED_CODE): 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) )