Files
fish-config/docs/verify-manual.py
T
rootiest f0de5378fe docs(manual): keep backticks out of verbatim blocks and off line breaks
Two shapes reach a reader as literal punctuation rather than markup:

A backtick inside a four-space block. The block is verbatim in every
renderer -- pandoc sets it monospace, prettify() fences it for the site
-- so the backtick is a character on the page. Twelve such lines are
cleaned; each sat at the start of its column, so the alignment of the
C5 capture table and the component summaries is unchanged.

A span split over a line break. Markdown pairs it happily, but
config-help pairs backticks one line at a time, so `fish-deps\nupdate`
showed both halves. The sentence is reflowed.

Both are now enforced, the first by test_concat_section_five_stays_
verbatim and the second by test_concat_code_spans_never_straddle_a_line.
2026-08-31 22:02:28 -04:00

1502 lines
58 KiB
Python

#!/usr/bin/env python3
# Copyright (C) 2026 Rootiest
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Verification checks for the docs/manual SSOT pipeline."""
import importlib.util
import re
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
# convention (matching verify-manual.py), which means it
# cannot satisfy a plain `import build_manual` on its own — Python's import
# statement never treats a hyphen as an underscore. Load it explicitly under
# the name the tests expect and register it in sys.modules; every later
# `import build_manual` (including the one inside test_concat_roundtrips_
# original below) then finds the cached module instead of touching the path
# finder.
_build_manual_path = Path(__file__).parent / "build-manual.py"
_spec = importlib.util.spec_from_file_location("build_manual", _build_manual_path)
_build_manual = importlib.util.module_from_spec(_spec)
sys.modules["build_manual"] = _build_manual
_spec.loader.exec_module(_build_manual)
def test_parse_roundtrip():
fm = {"title": "Git", "sidebar": {"order": 4}, "helpKeywords": ["git", "gi"]}
body = "## gitig\n\nManages ignore files."
text = mt.serialize(fm, body)
with tempfile.TemporaryDirectory() as d:
p = Path(d) / "t.md"
p.write_text(text)
got_fm, got_body = mt.parse(p)
assert got_fm == fm, f"frontmatter mismatch: {got_fm!r}"
assert got_body == body, f"body mismatch: {got_body!r}"
def test_parse_no_frontmatter():
with tempfile.TemporaryDirectory() as d:
p = Path(d) / "t.md"
p.write_text("# Plain\n\ntext\n")
fm, body = mt.parse(p)
assert fm == {}, f"expected empty frontmatter, got {fm!r}"
assert body == "# Plain\n\ntext", f"body mismatch: {body!r}"
def test_shift_headings():
body = "## a\n\ntext\n\n### b"
assert mt.shift_headings(body, 1) == "### a\n\ntext\n\n#### b"
def test_shift_headings_skips_code_fences():
body = "## a\n\n```\n# not a heading\n```\n\n## b"
got = mt.shift_headings(body, 1)
assert "# not a heading" in got, "code fence content was modified"
assert got.startswith("### a"), f"heading not shifted: {got[:10]!r}"
def test_walk_orders_by_sidebar_order_then_filename():
with tempfile.TemporaryDirectory() as d:
root = Path(d)
(root / "b.md").write_text(mt.serialize({"title": "B", "sidebar": {"order": 1}}, ""))
(root / "a.md").write_text(mt.serialize({"title": "A", "sidebar": {"order": 2}}, ""))
(root / "c.md").write_text(mt.serialize({"title": "C"}, ""))
got = [p.name for p, _ in mt.walk(root)]
assert got == ["b.md", "a.md", "c.md"], f"wrong order: {got}"
def test_walk_nests_directory_after_its_index():
with tempfile.TemporaryDirectory() as d:
root = Path(d)
(root / "01-first.md").write_text(mt.serialize({"title": "First"}, ""))
sub = root / "02-group"
sub.mkdir()
(sub / "index.md").write_text(mt.serialize({"title": "Group"}, ""))
(sub / "01-child.md").write_text(mt.serialize({"title": "Child"}, ""))
(root / "03-last.md").write_text(mt.serialize({"title": "Last"}, ""))
got = [(p.name, depth) for p, depth in mt.walk(root)]
expected = [
("01-first.md", 0),
("index.md", 0),
("01-child.md", 1),
("03-last.md", 0),
]
assert got == expected, f"wrong nesting: {got}"
def test_parse_roundtrip_body_with_leading_blank_line():
fm = {"title": "Test"}
body = "\nContent starts after blank line."
text = mt.serialize(fm, body)
with tempfile.TemporaryDirectory() as d:
p = Path(d) / "t.md"
p.write_text(text)
got_fm, got_body = mt.parse(p)
assert got_fm == fm, f"frontmatter mismatch: {got_fm!r}"
assert got_body == body, f"body mismatch: expected {body!r}, got {got_body!r}"
def test_manual_tree_exists():
root = Path(__file__).parent / "manual"
assert root.is_dir(), "docs/manual/ not generated"
assert (root / "index.md").exists(), "docs/manual/index.md missing"
fn = root / "05-functions"
assert fn.is_dir(), "docs/manual/05-functions/ missing"
cats = sorted(p.name for p in fn.glob("*.md") if p.name != "index.md")
assert len(cats) == 14, f"expected 14 function categories, got {len(cats)}: {cats}"
def test_function_stubs_carry_no_entries():
"""Category files are stubs: entries come from functions/*.fish headers.
An authored `##` entry here would be a second copy of a function's
documentation — exactly the duplication the header-SSOT migration
removed — and the generator would emit its own entry alongside it.
"""
root = Path(__file__).parent / "manual" / "05-functions"
for path in root.glob("*.md"):
if path.name == "index.md":
continue
_, body = mt.parse(path)
stray = [ln for ln in body.split("\n") if ln.startswith(("## ", "### "))]
assert not stray, f"{path.name} has authored entries: {stray}"
def _parsed_functions() -> dict[str, dict[str, list[str]]]:
return mt.parse_functions(Path(__file__).parent.parent / "functions")
def _parsed_components() -> dict[str, list[str]]:
import generate_component_registry as gcr
return gcr.collect_components()
_C0_TAGS = {"always/on", "always/off"}
_TAXONOMY_FILES = {
"aliases": "01-c1-command-shadows.md",
"autoexec": "02-c2-startup-side-effects.md",
"overrides": "03-c3-key-and-environment-overrides.md",
"integrations": "04-c4-terminal-and-tool-integration.md",
"logging": "05-c5-logging-and-capture.md",
"greeting": "06-c6-greeting-and-first-run-ui.md",
}
def _load_taxonomy() -> dict[str, set[str]]:
"""{category: {sub-category slugs}}, parsed from `## <slug>` headings
in each category's docs/manual/08-components-reference/ file."""
ref_root = Path(__file__).parent / "manual" / "08-components-reference"
taxonomy: dict[str, set[str]] = {}
for category, filename in _TAXONOMY_FILES.items():
_, body = mt.parse(ref_root / filename)
taxonomy[category] = {
m.group(1)
for ln in body.split("\n")
if (m := re.match(r"^## ([a-z][a-z0-9-]*)$", ln))
}
return taxonomy
def test_every_categorised_function_produces_one_entry():
import build_manual
functions = _parsed_functions()
entries = build_manual.build_entries(functions)
got = [name for names in entries.values() for name, _ in names]
assert sorted(got) == sorted(functions), (
f"entry/function mismatch: "
f"{sorted(set(functions) ^ set(got))}"
)
assert len(got) == len(set(got)), "a function produced more than one entry"
def test_entries_carry_the_required_sections():
missing = []
for name, fn in _parsed_functions().items():
absent = [s for s in ("SYNOPSIS", "DESCRIPTION", "EXAMPLE") if not fn.get(s)]
if absent:
missing.append(f"{name}: {', '.join(absent)}")
assert not missing, "headers missing required sections:\n " + "\n ".join(missing)
def test_every_category_resolves_to_a_stub():
root = Path(__file__).parent / "manual" / "05-functions"
stubs = {p.stem for p in root.glob("*.md") if p.name != "index.md"}
used = {}
for name, fn in _parsed_functions().items():
used.setdefault(" ".join(fn.get("CATEGORY", [])).strip(), []).append(name)
unknown = {c: v for c, v in used.items() if c not in stubs}
assert not unknown, f"# CATEGORY values with no stub: {unknown}"
empty = sorted(stubs - set(used))
assert not empty, f"category stubs generating zero entries: {empty}"
def test_dependencies_resolve():
"""Every declared # DEPENDENCIES name must be a real function or binary.
Catches typos, and catches stale entries when a dependency is renamed
or deleted. External binaries are accepted when some file in the tree
guards them with `type -q`, which is this repo's convention.
"""
repo = Path(__file__).parent.parent
functions = _parsed_functions()
known = {p.stem for p in (repo / "functions").glob("*.fish")} | set(functions)
for path in list(repo.glob("conf.d/*.fish")) + list((repo / "functions").glob("*.fish")):
known |= set(re.findall(r"type -q\s+(\S+)", path.read_text(encoding="utf-8")))
dangling = []
for name, fn in functions.items():
for dep in (d for d in re.split(r"[,\s]+", " ".join(fn.get("DEPENDENCIES", []))) if d):
if dep not in known:
dangling.append(f"{name} -> {dep}")
assert not dangling, "unresolvable # DEPENDENCIES:\n " + "\n ".join(dangling)
def test_every_component_resolves_to_a_taxonomy_entry():
"""Every non-C0 # COMPONENT tag must resolve to a documented sub-category."""
taxonomy = _load_taxonomy()
unknown = []
for identity, raw_lines in _parsed_components().items():
for site, tag in mt.parse_component_lines(raw_lines):
if tag in _C0_TAGS:
continue
if "/" not in tag:
unknown.append(f"{identity}: malformed tag {tag!r}")
continue
category, subcat = tag.split("/", 1)
if category not in taxonomy or subcat not in taxonomy[category]:
unknown.append(f"{identity}: {tag}")
assert not unknown, "# COMPONENT tags with no taxonomy entry:\n " + "\n ".join(unknown)
def warn_c0_tags_never_combine_with_contradiction():
"""Warn -- never fail -- on always/on + always/off contradictions this
repo's own generator would warn about -- this is a direct repo-content
check, independent of running the generator, so CI surfaces them even
if someone forgets to regenerate. Warnings are non-fatal (spec §4.5);
this exists to print them prominently in CI output."""
from generate_component_registry import build_registry
_, warnings = build_registry(_parsed_components())
for w in warnings:
print(f" WARN {w}")
def warn_public_functions_without_category():
"""Warn — never fail — on a public function carrying no `# CATEGORY`.
Bundled plugin and prompt internals will always lack one, so this
cannot be a hard failure; a genuinely new user-facing function going
undocumented still needs to be visible in CI output.
"""
repo = Path(__file__).parent.parent
documented = set(_parsed_functions())
orphans = sorted(
p.stem
for p in (repo / "functions").glob("*.fish")
if not p.stem.startswith("_")
and p.stem not in documented
and "# SYNOPSIS" in p.read_text(encoding="utf-8")
)
if orphans:
print(f" WARN {len(orphans)} documented function(s) lack # CATEGORY:")
print(" " + ", ".join(orphans))
# The guard's own supporting infrastructure: these files' bodies (function
# signature, SYNOPSIS/EXAMPLE prose) legitimately contain the literal text
# "__fish_config_op_enabled" without being a *caller* of the guard, so they
# are permanently exempt from warn_functions_without_component's substring
# check below.
_GUARD_INFRA_FILES = {
"__fish_config_op_enabled.fish",
"__fish_config_op_cascade.fish",
"__fish_config_op_registry_lookup.fish",
}
def warn_functions_without_component():
"""Warn -- never fail -- on a documented function calling the
opinionated guard but carrying no `# COMPONENT` section.
Mirrors warn_public_functions_without_category: a function that never
opted into the header convention at all (no # SYNOPSIS) is silently
out of scope, matching spec §4.5's fail-open tiering.
"""
repo = Path(__file__).parent.parent
components = _parsed_components()
orphans = []
for p in list((repo / "functions").glob("*.fish")) + list((repo / "conf.d").glob("*.fish")):
if p.name in _GUARD_INFRA_FILES:
continue
text = p.read_text(encoding="utf-8")
if "__fish_config_op_enabled" not in text or "# SYNOPSIS" not in text:
continue
if p.stem not in components:
orphans.append(str(p.relative_to(repo)))
if orphans:
print(f" WARN {len(orphans)} function(s) call the opinionated guard but lack # COMPONENT:")
print(" " + ", ".join(sorted(orphans)))
def warn_unused_taxonomy_entries():
"""Warn -- never fail -- on a documented sub-category with zero tagged functions."""
taxonomy = _load_taxonomy()
used: dict[str, set[str]] = {c: set() for c in taxonomy}
for raw_lines in _parsed_components().values():
for _site, tag in mt.parse_component_lines(raw_lines):
if tag in _C0_TAGS or "/" not in tag:
continue
category, subcat = tag.split("/", 1)
if category in used:
used[category].add(subcat)
unused = [
f"{category}/{subcat}"
for category, subcats in taxonomy.items()
for subcat in sorted(subcats - used[category])
]
if unused:
print(f" WARN {len(unused)} taxonomy entr{'y has' if len(unused) == 1 else 'ies have'} zero tagged functions:")
print(" " + ", ".join(unused))
def _without_section_5(text: str) -> str:
"""Drop `# 5. FUNCTIONS REFERENCE` through the start of section 6.
Section 5 is generated from `functions/*.fish` headers, so it
legitimately differs from the pre-migration snapshot. The round-trip
guard covers the authored sections either side of it.
"""
start = text.find("\n# 5. ")
if start == -1:
return text
end = text.find("\n# 6. ", start)
return text[:start] + (text[end:] if end != -1 else "")
def _normalise(text: str) -> str:
"""Collapse whitespace so only content differences survive."""
lines = [ln.rstrip() for ln in text.strip().split("\n")]
return "\n".join(ln for ln in lines if ln != "")
def test_concat_roundtrips_original():
"""The concat of manual/ must reproduce the original fish-config.md exactly.
Section 5 is excluded: it is generated from `functions/*.fish` headers
and so legitimately differs from the snapshot. This test guarded the
*format* migration; the header-SSOT change is a *content* migration,
covered instead by the structural checks above.
Prefers docs/fish-config.md.orig (a snapshot of the pre-migration file)
when present. Once that snapshot is deleted post-migration,
docs/fish-config.md IS the concat output regenerated in Step 5, so
falling back to it turns this into an idempotency regression check
instead of going red for a missing file.
The pass/fail decision is an exact (raw-text) comparison, not a
whitespace-normalised one. Blank lines are load-bearing for pandoc
(`blank_before_header` is on by default): losing them merges paragraphs
and stops headings being headings, so a test that tolerated blank-line
or line-joining drift would stay green while the man page silently
broke. `_normalise` is used only afterwards, to build a readable diff.
"""
import build_manual
docs = Path(__file__).parent
original = docs / "fish-config.md.orig"
label = "original"
if not original.exists():
original = docs / "fish-config.md"
label = "fish-config.md"
got = _without_section_5(build_manual.build_concat(docs / "manual"))
want = _without_section_5(original.read_text())
if got != want:
norm_got = _normalise(got)
norm_want = _normalise(want)
if norm_got == norm_want:
raise AssertionError(
"concat differs from original only in whitespace/blank lines "
"(exact comparison failed, normalised comparison passed) — "
"blank lines are load-bearing for pandoc, this is a real regression"
)
import difflib
diff = list(
difflib.unified_diff(
norm_want.split("\n"), norm_got.split("\n"), label, "concat", lineterm="", n=1
)
)[:40]
raise AssertionError("concat differs from original:\n" + "\n".join(diff))
def test_every_index_keyword_resolves():
"""Every keyword in fish-config.index must match a heading in the concat."""
import build_manual
docs = Path(__file__).parent
index = docs / "fish-config.index"
if not index.exists():
print(" SKIP test_every_index_keyword_resolves (no index file)")
return
concat = build_manual.build_concat(docs / "manual")
headings = {ln.strip() for ln in concat.split("\n") if ln.startswith("#")}
missing = []
for line in index.read_text().splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
keyword, heading = line.split("=", 1)
if heading.strip() not in headings:
missing.append(f"{keyword.strip()} -> {heading.strip()}")
assert not missing, "unresolvable index keywords:\n " + "\n ".join(missing)
def test_site_build_produces_function_pages():
import tempfile
import build_manual
docs = Path(__file__).parent
with tempfile.TemporaryDirectory() as d:
out = Path(d)
sidebar = build_manual.build_site(docs / "manual", out)
pages = list(out.rglob("*.md*"))
assert (out / "index.md").exists() or (out / "index.mdx").exists(), "landing page missing"
fn_pages = [p for p in pages if "reference" in p.parts and p.name != "index.mdx"]
assert len(fn_pages) > 80, f"expected >80 function pages, got {len(fn_pages)}"
assert not (out / "00-name.md").exists(), "site:false page was published"
assert sidebar, "sidebar structure is empty"
for page in pages:
fm, _ = mt.parse(page)
assert "manTitle" not in fm, f"{page.name} leaked manTitle into site output"
assert "title" in fm, f"{page.name} has no title"
def test_prettify_splits_an_entry_block():
"""A man-page-style entry becomes fenced code, prose, and a table."""
import build_manual
body = "\n".join(
[
" Synopsis: rm [-e | args...]",
" Safe rm wrapper routing to trash:",
"",
" (no args) List current trash contents",
" -e/--empty Empty the trash",
"",
" Falls back to /usr/bin/rm when trash is unavailable.",
"",
" Example:",
" rm file.txt # moves to trash",
" rm -e # empty trash",
]
)
out = build_manual.prettify(body, "rm")
assert '```fish title="Usage"\nrm [-e | args...]\n```' in out, (
"synopsis was not fenced as fish with a Usage title"
)
assert '```fish title="Examples"\nrm file.txt' in out, (
"examples were not fenced as fish with an Examples title"
)
assert out.count("```") == 4, f"expected exactly two fences, got:\n{out}"
assert "\nSafe rm wrapper routing to trash:" in out, "description stayed indented"
assert "| `(no args)` | List current trash contents |" in out, (
"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 (or lost its path code span)"
def test_as_table_converts_option_blocks():
"""A labelled, column-aligned block becomes a table; wrapped rows fold in."""
import build_manual
out = build_manual._as_table(
[
"Options:",
" -a/--aggressive Also removes node_modules, logs,",
" and IDE dirs",
" -d/--dry-run Print what would be removed",
"Pass neither to run interactively.",
]
)
assert out is not None, "a plain option table was rejected"
assert out.splitlines()[0] == "Options:", "the label line was dropped"
assert out.splitlines()[-1] == "Pass neither to run interactively.", (
"the trailing sentence was dropped"
)
assert (
"| `-a/--aggressive` | Also removes node_modules, logs, and IDE dirs |" in out
), "a wrapped description did not fold into the row above"
def test_as_table_escapes_pipes():
"""`|` splits table cells even inside a code span, so it must be escaped."""
import build_manual
out = build_manual._as_table(
[" -r/-R Recurse into it", " -e|-E Empty it"]
)
assert out is not None and r"`-e\|-E`" in out, f"pipe was not escaped:\n{out}"
def test_as_table_rejects_non_tables():
"""Returning None is always safe, so every ambiguous shape must return it."""
import build_manual
cases = {
"single row": [" -f/--force Force-delete unmerged branches too"],
"numbered list": [
" 1. git+cargo source build (fish shell itself)",
" 2. cargo (Rust tools — gets latest crate version)",
],
"misaligned rows": [
" -e/--empty Empty the trash",
" -S/--secure Permanently delete (single space, not a column)",
],
"synopsis continuation": [
" auto-pull add [PATH]",
" auto-pull status",
],
"unlabelled head": [
"Routes to the best tool by context.",
" --disk force duf",
" --dir force dust",
],
"live markdown in prose column": [
" add Register <PATH>'s git root",
" remove Unregister by basename",
],
}
for label, para in cases.items():
assert build_manual._as_table(para) is None, f"{label} was wrongly tabled"
def test_as_ruled_table_converts_header_rule_rows():
"""A header + dashed-rule + rows block (Component Reference style) tables."""
import build_manual
out = build_manual._as_ruled_table(
[
"Component Requires",
"─────────────────────────────────────────",
"spwin Kitty or WezTerm",
"hist fzf + wl-copy (Wayland clipboard)",
]
)
assert out is not None, "a header+rule+rows block was rejected"
assert out.splitlines()[0] == "| Component | Requires |"
assert "| `spwin` | Kitty or WezTerm |" in out
def test_as_ruled_table_converts_per_column_dash_rule():
"""RST-style rule (one dash run per column, gapped like the header) tables too."""
import build_manual
out = build_manual._as_ruled_table(
[
"Variable Disables",
"------------------------------ ------------------------------------",
"__fish_config_op_aliases Command shadows and flag injection",
"__fish_config_op_autoexec Startup side-effects",
]
)
assert out is not None, "a per-column dashed rule was rejected"
assert out.splitlines()[0] == "| Variable | Disables |"
assert "| `__fish_config_op_aliases` | Command shadows and flag injection |" in out
def test_as_ruled_table_folds_wrapped_continuations():
"""A row that splits into one cell continues the previous row's last column."""
import build_manual
out = build_manual._as_ruled_table(
[
"Component What it captures",
"─────────────────────────────────────────",
"Scrollback capture Terminal output saved to:",
" ~/.terminal_history/scrollback.log",
"tmux pane capture Streamed via pipe-pane",
]
)
assert out is not None
assert (
"| `Scrollback capture` | Terminal output saved to: ~/.terminal_history/scrollback.log |"
in out
), "a wrapped continuation line did not fold into the row above"
def test_as_ruled_table_code_spans_angle_brackets_and_braces():
"""<placeholder> / brace-glob cells get backtick-protected, not rejected."""
import build_manual
out = build_manual._as_ruled_table(
[
"Component What it captures",
"─────────────────────────────────────────",
"tmux pane capture saved to tmux_<session>-w<win>.log",
"Autopair auto-close to (), [], {}",
]
)
assert out is not None, "angle brackets/braces caused the table to be rejected"
assert "`saved to tmux_<session>-w<win>.log`" in out
assert "`auto-close to (), [], {}`" in out
def test_as_ruled_table_rejects_ambiguous_columns():
"""A row with fewer delimited columns than the header is a source bug, not a guess."""
import build_manual
out = build_manual._as_ruled_table(
[
"Command Active behavior Disabled fallback",
"─────────────────────────────────────────────────────────",
"ls eza -l -a --icons system ls",
"du duf (disk overview) system du",
]
)
assert out is None, "an under-delimited row should fall back to a code block"
def test_prettify_leaves_reference_tables_alone():
"""Column-aligned blocks too small for a real table are data, not shell,
and must not get shell syntax highlighting — but they still need SOME
fence, since indentation alone doesn't survive MDX (see
test_prettify_fallback_fences_instead_of_indenting)."""
import build_manual
table = " XDG_CONFIG_HOME ~/.config\n XDG_CACHE_HOME ~/.cache"
out = build_manual.prettify(table)
assert "```fish" not in out, f"a reference table got shell-highlighted:\n{out}"
assert "```text" in out, f"a reference table lost its fence:\n{out}"
binds = " n / nv / neovim nvim\n e edit"
out = build_manual.prettify(binds)
assert "```fish" not in out, f"an abbreviation table got shell-highlighted:\n{out}"
assert "```text" in out, f"an abbreviation table lost its fence:\n{out}"
shell = " set -U __fish_user_dots_path /path/to/dots"
assert "```fish" in build_manual.prettify(shell), "a shell block was not fenced"
def test_prettify_fallback_fences_instead_of_indenting():
"""The catch-all fallback must emit a fenced block, not bare indentation.
MDX (used for any page that also carries an <Aside> or <FileTree>) has
no indented-code-block syntax: a plain 4-space-indented block silently
renders as flowed paragraph text there, collapsing every line break. A
fenced block is the only fallback shape that's safe in both MDX and
plain Markdown.
"""
import build_manual
para = ["✘ 1 Fri Jun 12 00:51:21 2026 ← failed"]
out = build_manual._render_para(para, None, False)
assert out == "```text\n" + para[0] + "\n```", f"unexpected fallback output:\n{out}"
def test_prettify_titles_paths_and_commented_examples():
"""A bare file path or a leading '# in x.fish' comment become a title."""
import build_manual
path = " $__fish_user_dots_path/local.fish"
assert '```fish title="local.fish"\n$__fish_user_dots_path/local.fish\n```' in (
build_manual.prettify(path)
), "a bare file path was not titled"
commented = "\n".join(
[" # in local.fish", " set -gx SCROLLBACK_HISTORY_MAX_FILES 200"]
)
out = build_manual.prettify(commented)
assert '```fish title="local.fish"\nset -gx SCROLLBACK_HISTORY_MAX_FILES 200\n```' in out, (
f"a filename comment was not promoted to the fence title:\n{out}"
)
def test_as_aside_converts_a_single_line_label():
"""A `LABEL: text` line becomes a titled <Aside> with the label's type."""
import build_manual
out = build_manual._as_aside(["TIP: Use `-h` on any function for its help text."])
assert out == (
'<Aside type="tip" title="Tip">\n'
"Use `-h` on any function for its help text.\n"
"</Aside>"
), f"unexpected aside output:\n{out}"
def test_as_aside_converts_a_label_and_list():
"""A label line immediately followed by a list becomes one aside body."""
import build_manual
out = build_manual._as_aside(
[
"NOTE:",
" - Command shadows react immediately.",
" - conf.d-level components take effect in new shells.",
]
)
assert out == (
'<Aside type="note" title="Note">\n'
" - Command shadows react immediately.\n"
" - conf.d-level components take effect in new shells.\n"
"</Aside>"
), f"unexpected aside output:\n{out}"
def test_as_aside_adds_icon_only_where_types_collide():
"""WARNING and CAUTION share the `caution` type; only WARNING gets an icon."""
import build_manual
warning = build_manual._as_aside(["WARNING: Deletes files permanently."])
caution = build_manual._as_aside(["CAUTION: Slow on large trees."])
assert 'icon="warning"' in warning, f"WARNING lost its icon override:\n{warning}"
assert "icon=" not in caution, f"CAUTION should use its type's default icon:\n{caution}"
def test_as_aside_rejects_non_label_paragraphs():
"""Only the closed set of 7 labels triggers an aside; other WORD: prose does not."""
import build_manual
cases = {
"Example prefix": ["Example:", "rm file.txt"],
"sentence with a colon": ["Options: pass one of the flags below."],
"lowercase label": ["note: this should not become an aside"],
}
for label, para in cases.items():
assert build_manual._as_aside(para) is None, f"{label} was wrongly asided"
def test_prettify_flat_paragraphs_unchanged_when_not_labeled():
"""Restructuring prettify()'s loop to buffer flat lines must not alter output for ordinary prose."""
import build_manual
body = "\n".join(
[
"## Heading",
"",
"First line of a paragraph",
"continued on a second line.",
"",
"A second paragraph.",
]
)
assert build_manual.prettify(body) == body, "flat prose was altered by the aside buffering"
def test_prettify_converts_a_flat_note_paragraph_to_an_aside():
"""A `NOTE:` paragraph surrounded by ordinary prose becomes an <Aside>, prose is untouched."""
import build_manual
body = "\n".join(
[
"## Heading",
"",
"Ordinary prose before.",
"",
"NOTE: Something worth calling out.",
"",
"Ordinary prose after.",
]
)
out = build_manual.prettify(body)
assert "## Heading" in out
assert "Ordinary prose before." in out
assert "Ordinary prose after." in out
assert (
'<Aside type="note" title="Note">\nSomething worth calling out.\n</Aside>' in out
), f"note paragraph was not converted:\n{out}"
def test_as_file_tree_converts_a_box_drawing_tree():
"""A root path plus ├──/└── branches becomes a Starlight <FileTree>."""
import build_manual
para = [
"$__fish_user_dots_path/",
"├── secrets.fish API keys, tokens, passwords, personal identifiers",
"└── local.fish Machine-specific paths, env vars, and sourcing secrets",
]
out = build_manual._as_file_tree(para)
assert out == (
"<FileTree>\n"
"- $__fish_user_dots_path/\n"
" - secrets.fish API keys, tokens, passwords, personal identifiers\n"
" - local.fish Machine-specific paths, env vars, and sourcing secrets\n"
"</FileTree>"
), f"unexpected file tree output:\n{out}"
def test_as_file_tree_rejects_non_trees():
"""Returning None is always safe for anything that isn't a root+branches shape."""
import build_manual
cases = {
"no root slash": ["$__fish_user_dots_path", "├── secrets.fish"],
"single line": ["$__fish_user_dots_path/"],
"non-branch second line": ["$__fish_user_dots_path/", "secrets.fish"],
}
for label, para in cases.items():
assert build_manual._as_file_tree(para) is None, f"{label} was wrongly treed"
def test_as_file_tree_accepts_deeper_trees():
"""A tree with indented branch lines becomes a Starlight <FileTree> with nested markdown lists."""
import build_manual
para = [
"~/proj/",
"├── src/",
"│ └── main.fish",
"└── README.md",
]
out = build_manual._as_file_tree(para)
assert out == (
"<FileTree>\n"
"- ~/proj/\n"
" - src/\n"
" - main.fish\n"
" - README.md\n"
"</FileTree>"
), f"unexpected file tree output:\n{out}"
def test_as_file_tree_expands_functions_and_completions():
"""The functions/ and completions/ branches list real directory contents."""
import build_manual
with tempfile.TemporaryDirectory() as d:
root = Path(d)
functions_dir = root / "functions"
functions_dir.mkdir()
(functions_dir / "zeta.fish").write_text("")
(functions_dir / "Alpha.fish").write_text("")
completions_dir = root / "completions"
completions_dir.mkdir()
(completions_dir / "bd.fish").write_text("")
original = dict(build_manual.EXPANDABLE_TREE_DIRS)
build_manual.EXPANDABLE_TREE_DIRS["functions/"] = functions_dir
build_manual.EXPANDABLE_TREE_DIRS["completions/"] = completions_dir
try:
para = [
"~/.config/fish/",
"├── functions/ Custom functions, one per file",
"└── completions/ Tab completion scripts",
]
out = build_manual._as_file_tree(para)
finally:
build_manual.EXPANDABLE_TREE_DIRS.clear()
build_manual.EXPANDABLE_TREE_DIRS.update(original)
assert out == (
"<FileTree>\n"
"- ~/.config/fish/\n"
" - functions/ Custom functions, one per file\n"
" - Alpha.fish\n"
" - zeta.fish\n"
" - completions/ Tab completion scripts\n"
" - bd.fish\n"
"</FileTree>"
), f"unexpected file tree output:\n{out}"
def test_as_file_tree_leaves_unrelated_branches_unexpanded():
"""Only the mapped directory names get expanded; everything else is untouched."""
import build_manual
para = [
"$__fish_user_dots_path/",
"├── secrets.fish API keys, tokens, passwords, personal identifiers",
"└── local.fish Machine-specific paths, env vars, and sourcing secrets",
]
out = build_manual._as_file_tree(para)
assert "secrets.fish" in out and "local.fish" in out
assert len(out.splitlines()) == 5, f"unexpected expansion of unrelated branches:\n{out}"
def test_customization_notes_render_as_aside():
"""The real 07-customization NOTE paragraph converts to one intact <Aside>."""
import build_manual
path = Path(__file__).parent / "manual" / "07-customization.md"
_, body = mt.parse(path)
out = build_manual.prettify(body)
assert out.count('<Aside type="note" title="Note">') >= 1, f"expected at least one Note aside:\n{out}"
aside = out.split('<Aside type="note" title="Note">')[1].split("</Aside>")[0]
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 "- On CachyOS, the distro fish config's own aliases" in aside
def test_site_promotes_pages_with_asides_or_filetrees_to_mdx():
"""A page whose rendered content contains <Aside> or <FileTree> is written as .mdx."""
import build_manual
docs = Path(__file__).parent
with tempfile.TemporaryDirectory() as d:
out = Path(d)
build_manual.build_site(docs / "manual", out)
assert (out / "11-personalization.mdx").exists(), "FileTree page was not promoted to .mdx"
assert not (out / "11-personalization.md").exists(), "old .md sibling was left behind"
assert (out / "13-viewing-this-manual.mdx").exists(), "Aside page (NOTE) was not promoted to .mdx"
assert (out / "07-customization.mdx").exists(), "Aside page (rewritten NOTE) was not promoted to .mdx"
assert (out / "02-path-setup.mdx").exists(), "Aside page (NOTE) was not promoted to .mdx"
assert (out / "03-key-bindings.md").exists(), "plain page was wrongly promoted to .mdx"
assert not (out / "03-key-bindings.mdx").exists(), "plain page should stay .md"
text = (out / "11-personalization.mdx").read_text()
assert "FileTree" in text.split("from '@astrojs/starlight/components';")[0]
text2 = (out / "13-viewing-this-manual.mdx").read_text()
assert "Aside" in text2.split("from '@astrojs/starlight/components';")[0]
def test_prettify_is_site_only():
"""The SSOT keeps the indented form the man-page pipeline depends on."""
import build_manual
manual = Path(__file__).parent / "manual"
forbidden = ("```", "<Aside", "<FileTree", ":::")
for path in manual.rglob("*.md"):
text = path.read_text()
for marker in forbidden:
assert marker not in text, (
f"{path.name} contains {marker!r}: prettify must run at "
"site-build time, never be written back to the SSOT"
)
def test_sidebar_has_no_duplicate_functions_entry():
"""The functions group must not also list itself as one of its children."""
import build_manual
docs = Path(__file__).parent
with tempfile.TemporaryDirectory() as d:
sidebar = build_manual.build_site(docs / "manual", Path(d))
groups = [e for e in sidebar if "items" in e]
group = next((g for g in groups if g["label"] == "Functions Reference"), None)
assert group is not None, "expected to find 'Functions Reference' sidebar group"
labels = [item["label"] for item in group["items"]]
assert group["label"] not in labels[1:], (
f"'{group['label']}' is repeated inside its own group: {labels}"
)
assert labels[0] == "Categories", f"group should lead with Categories, got {labels[0]}"
assert not any(
"autogenerate" in item for item in group["items"]
), "categories should be listed explicitly, not autogenerated from slugs"
assert len(labels) == 15, f"expected Overview + 14 categories, got {len(labels)}"
def test_exit_status_never_describes_stdout_content():
"""EXIT STATUS documents $status; stdout/printed content belongs in RETURNS.
Regression guard for the RETURNS -> EXIT STATUS split: a row like
"0 Patterns appended, or the requested content printed" re-introduces
the exact ambiguity (exit code vs. printed output) the split exists to
remove. A `--stdout`-style flag name is not itself an offense — only
"stdout" used as a bare word (not part of a flag) counts.
"""
offenders = []
for name, fn in _parsed_functions().items():
for line in fn.get("EXIT STATUS", []):
if re.search(r"(?<!-)\bstdout\b|\bprinted\b", line, re.IGNORECASE):
offenders.append(f"{name}: {line.strip()}")
assert not offenders, "stdout/printed language leaked into EXIT STATUS:\n " + "\n ".join(
offenders
)
def test_returns_renders_after_exit_status():
"""When a function has both, Exit Status: must render before Returns:."""
import build_manual
fn = {
"SYNOPSIS": ["thing"],
"DESCRIPTION": ["Does a thing."],
"EXIT STATUS": ["0 Always"],
"RETURNS": ["The thing, printed to stdout"],
"EXAMPLE": ["thing"],
}
out = build_manual.render_entry(fn, [])
exit_pos = out.find("Exit Status:")
returns_pos = out.find("Returns:")
assert exit_pos != -1 and returns_pos != -1, f"missing a section head:\n{out}"
assert exit_pos < returns_pos, f"Returns: rendered before Exit Status::\n{out}"
def test_site_avoids_reserved_dir():
"""No output directory may collide with a Cloudflare Pages reserved name.
Pages treats a top-level `functions/` directory in the deploy output as
Pages Functions (server-side handlers) and drops it from the static-asset
upload. The build succeeds, wrangler reports success, and every page under
it 404s in production while working perfectly under `astro preview` — so
only a check on the emitted tree catches it.
"""
import build_manual
reserved = {"functions", "_worker.js", "_routes.json"}
docs = Path(__file__).parent
with tempfile.TemporaryDirectory() as d:
out = Path(d)
build_manual.build_site(docs / "manual", out)
clashes = {p.name for p in out.iterdir()} & reserved
assert not clashes, (
f"site output has Cloudflare Pages reserved name(s): {sorted(clashes)} "
"— these are silently excluded from the deployed assets"
)
def test_parse_component_lines_default_and_named_sites():
lines = [
"aliases/filesystem",
"site exit-plain: overrides/key-bindings",
"site logging-guard: logging/terminal-capture",
"",
" ",
]
got = mt.parse_component_lines(lines)
assert got == [
("", "aliases/filesystem"),
("exit-plain", "overrides/key-bindings"),
("logging-guard", "logging/terminal-capture"),
], f"unexpected parse: {got}"
def test_parse_components_includes_underscore_prefixed_and_uncategorised():
"""Unlike parse_functions, parse_components has no # CATEGORY gate and
no underscore exclusion -- every guarded identity must be visible."""
with tempfile.TemporaryDirectory() as d:
root = Path(d)
(root / "__private_helper.fish").write_text(
"# COMPONENT\n"
"# logging/terminal-capture\n"
"function __private_helper\n"
"end\n"
)
(root / "no_category.fish").write_text(
"# COMPONENT\n"
"# aliases/filesystem\n"
"#\n"
"# SYNOPSIS\n"
"# no_category\n"
"function no_category\n"
"end\n"
)
got = mt.parse_components(root)
assert got["__private_helper"] == ["logging/terminal-capture"]
assert got["no_category"] == ["aliases/filesystem"]
def test_parse_components_resolves_multi_header_file_to_function_name():
with tempfile.TemporaryDirectory() as d:
root = Path(d)
(root / "multi.fish").write_text(
"# COMPONENT\n"
"# aliases/filesystem\n"
"function first_fn\n"
"end\n"
"\n"
"# COMPONENT\n"
"# aliases/network\n"
"function second_fn\n"
"end\n"
)
got = mt.parse_components(root)
assert got == {
"first_fn": ["aliases/filesystem"],
"second_fn": ["aliases/network"],
}, f"unexpected resolution: {got}"
def test_parse_component_file_single_file():
with tempfile.TemporaryDirectory() as d:
path = Path(d) / "config.fish"
path.write_text(
"# COMPONENT\n"
"# site greeting-block: greeting/greeting-message\n"
)
got = mt.parse_component_file(path)
assert got == {"config": ["site greeting-block: greeting/greeting-message"]}
def test_build_registry_strips_on_off_contradiction_with_warning():
import generate_component_registry as gcr
components = {"contradictory_fn": ["always/on", "always/off", "aliases/filesystem"]}
registry, warnings = gcr.build_registry(components)
assert registry["contradictory_fn:"] == ["aliases/filesystem"], (
f"the non-contradictory tag should survive: {registry}"
)
assert len(warnings) == 1 and "contradictory_fn" in warnings[0]
def test_build_registry_drops_empty_effective_tag_sets():
import generate_component_registry as gcr
components = {"only_contradictory": ["always/on", "always/off"]}
registry, warnings = gcr.build_registry(components)
assert "only_contradictory:" not in registry, (
"a site stripped down to nothing must produce no registry entry "
"(fail-open: absence of an entry already means always/on at guard time)"
)
assert len(warnings) == 1
def test_build_registry_keeps_sites_independent():
import generate_component_registry as gcr
components = {
"smart_exit": [
"site exit-plain: overrides/key-bindings",
"site logging-guard: logging/terminal-capture",
]
}
registry, warnings = gcr.build_registry(components)
assert registry["smart_exit:exit-plain"] == ["overrides/key-bindings"]
assert registry["smart_exit:logging-guard"] == ["logging/terminal-capture"]
assert not warnings
def test_collect_components_merges_identity_collisions_across_sources():
"""functions/auto-pull.fish and conf.d/auto-pull.fish both self-identify
as "auto-pull" at runtime -- the guard only ever has the bare
status current-function/basename string to look up with -- so
collect_components must concatenate their raw COMPONENT lines
rather than letting conf.d's entry silently overwrite functions'."""
import generate_component_registry as gcr
with tempfile.TemporaryDirectory() as d:
root = Path(d)
(root / "functions").mkdir()
(root / "conf.d").mkdir()
(root / "functions" / "auto-pull.fish").write_text(
"# COMPONENT\n"
"# autoexec/sync\n"
"function auto-pull\n"
"end\n"
)
(root / "conf.d" / "auto-pull.fish").write_text(
"# COMPONENT\n"
"# autoexec/sync\n"
)
(root / "config.fish").write_text("")
orig_repo = gcr.REPO
try:
gcr.REPO = root
got = gcr.collect_components()
finally:
gcr.REPO = orig_repo
assert got["auto-pull"] == ["autoexec/sync", "autoexec/sync"], (
f"both sources' tags should survive the merge, not overwrite: {got}"
)
def test_render_registry_is_valid_fish_and_round_trips():
"""Sourcing render()'s output must leave the two arrays in the exact
shape __fish_config_op_registry_lookup expects -- checked via the real
lookup helper (functions/__fish_config_op_registry_lookup.fish, Task
2) rather than re-parsing the generated text by hand.
Written to a real temp file rather than piped through `source
/dev/stdin`: fish 3.7 (Ubuntu 24.04's packaged version, used in CI)
rejects `/dev/stdin` with "is not a file" when it's backed by a pipe,
even though fish 4.8 accepts it -- sourcing an actual file on disk is
the portable form across fish versions.
"""
import subprocess
import tempfile
import generate_component_registry as gcr
registry = {
"rm:": ["aliases/filesystem"],
"smart_exit:exit-plain": ["overrides/key-bindings"],
}
text = gcr.render(registry)
repo = Path(__file__).parent.parent
with tempfile.TemporaryDirectory() as d:
registry_path = Path(d) / "registry.fish"
registry_path.write_text(text)
proc = subprocess.run(
[
"fish", "-c",
f"source {repo}/functions/__fish_config_op_registry_lookup.fish; "
f"source {registry_path}; "
"__fish_config_op_registry_lookup rm ''; echo status=$status",
],
capture_output=True,
text=True,
)
assert "aliases/filesystem" in proc.stdout, f"unexpected output: {proc.stdout!r} {proc.stderr!r}"
assert "status=0" in proc.stdout, f"lookup did not report found: {proc.stdout!r}"
def test_build_manual_regenerates_registry_before_building():
"""docs/build-manual.py must regenerate the registry as a pre-step."""
import build_manual
assert hasattr(build_manual, "generate_component_registry"), (
"build-manual.py must import generate_component_registry so its "
"main() can be called as a pre-step before --site/--concat run"
)
def test_committed_registry_matches_headers():
"""The committed conf.d/__fish_config_op_registry.fish must match what
generate_component_registry.py would produce right now from the current
`# COMPONENT` headers -- otherwise CI has nothing catching drift."""
import generate_component_registry as gcr
registry, _ = gcr.build_registry(gcr.collect_components())
assert gcr.render(registry) == gcr.OUTPUT.read_text(), (
"conf.d/__fish_config_op_registry.fish is stale — run "
"__fish_config_op_registry_rebuild"
)
# ---------------------------------------------------------------------------
# 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",
"",
"<Aside type=\"note\" title=\"Note\">",
"See --verbose.",
"</Aside>",
]
)
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("<Aside"), "component markup was rewritten"
assert got[9] == "See `--verbose`.", "aside body was not processed"
def test_codespans_leaves_file_tree_bodies_alone():
"""<FileTree> list items are filenames the component renders itself."""
body = "<FileTree>\n- ~/.config/fish/\n - config.fish\n</FileTree>"
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_leaves_indented_code_blocks_alone():
"""A four-space block is code, whatever it happens to contain.
The concat keeps the indented form pandoc wants, so unlike the site
this pass meets real indented blocks -- the table of contents among
them, which is nothing but a list of command names.
"""
body = "\n".join(
[
"Pick a viewer:",
"",
" 1. ov + bat section navigation",
" 2. less plain text with --jump",
"",
"Then run config-help.",
]
)
got = _spans(body).split("\n")
assert got[2] == " 1. ov + bat section navigation", got[2]
assert got[3] == " 2. less plain text with --jump", got[3]
assert got[5] == "Then run `config-help`.", "prose after the block was skipped"
def test_codespans_reach_the_man_page_pipeline():
"""Prose is marked identically wherever it is rendered.
`build_concat` runs the same pass `build_site` does, so a token the
site typesets as code is typeset as code in the man page and
`config-help` too, instead of only where the SSOT hand-wrote a
backtick.
"""
import build_manual
manual = Path(__file__).parent / "manual"
text = build_manual.build_concat(manual)
assert "`local.fish`" in text, "prose code spans never reached the concat"
assert "`tmux`" in text, "a vocabulary command was not wrapped in the concat"
def test_concat_code_spans_never_straddle_a_line():
"""`config-help` pairs backticks one line at a time.
Its `string replace` filters run per line, so a span split across a
line break -- ``run `fish-deps\\nupdate` `` -- leaves an unpaired
backtick the pager then shows literally. Markdown is happy to wrap
one, so nothing else catches this.
"""
import build_manual
text = build_manual.build_concat(Path(__file__).parent / "manual")
odd = [
(n, line)
for n, line in enumerate(text.split("\n"), 1)
if line.count("`") % 2
]
assert not odd, f"unpaired backtick, span wraps a line: {odd[:3]}"
def test_concat_section_five_stays_verbatim():
"""Section 5's entries are indented blocks, not prose.
They are generated from the `functions/*.fish` headers and pandoc sets
them verbatim, so a backtick there would be a literal character on the
page rather than markup.
"""
import build_manual
text = build_manual.build_concat(Path(__file__).parent / "manual")
body = text.split("\n# 5. ", 1)[1].split("\n# 6. ", 1)[0]
offenders = [
line for line in body.split("\n") if line.startswith(" ") and "`" in line
]
assert not offenders, f"backticks inside verbatim entries: {offenders[:3]}"
TESTS = [v for k, v in sorted(globals().items()) if k.startswith("test_")]
def main() -> int:
failed = 0
for t in TESTS:
try:
t()
print(f" PASS {t.__name__}")
except AssertionError as e:
print(f" FAIL {t.__name__}: {e}", file=sys.stderr)
failed += 1
warn_public_functions_without_category()
warn_functions_without_component()
warn_unused_taxonomy_entries()
warn_c0_tags_never_combine_with_contradiction()
print(f"\n{len(TESTS) - failed}/{len(TESTS)} passed")
return 1 if failed else 0
if __name__ == "__main__":
sys.path.insert(0, str(Path(__file__).parent))
raise SystemExit(main())