diff options
Diffstat (limited to 'scripts')
| -rw-r--r-- | scripts/config_reference_check.py | 319 | ||||
| -rw-r--r-- | scripts/test_config_reference_check.py | 245 | ||||
| -rw-r--r-- | scripts/test_preview.py | 5 |
3 files changed, 569 insertions, 0 deletions
diff --git a/scripts/config_reference_check.py b/scripts/config_reference_check.py new file mode 100644 index 00000000..f9aa5e72 --- /dev/null +++ b/scripts/config_reference_check.py @@ -0,0 +1,319 @@ +"""Check the website config reference against the Rust config model. + +Walks the serde structs in src/config/*.rs starting from the root `Config` +struct, builds the set of canonical dotted TOML key paths, and compares it +against the keys documented in the preview config reference. Compatibility +aliases are intentionally documented on their canonical entries rather than +as separate rows. + +The comparison checks key names and serde-derived enum values, so failures +name exact missing, stale, duplicated, or value-drifted entries. + +Open-ended surfaces (arrays of tables such as [[keys.command]]) are not +enumerable per-key and are skipped; they are listed in SKIPPED_SUBTREES so +the skip stays explicit. +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from dataclasses import dataclass, field +from pathlib import Path + + +DEFAULT_MODEL_ROOT = Path("src/config") +DEFAULT_REFERENCE = Path("docs/next/website/src/data/config-reference.json") +ROOT_STRUCT = "Config" + +# Dotted key prefixes that are open-ended (user-defined tables/arrays) and +# therefore not enumerable in a flat reference table. +SKIPPED_SUBTREES = ("keys.command",) + +FIELD_RE = re.compile(r"^\s*pub ([a-z_][a-z0-9_]*):\s*(.+?),?\s*$") +STRUCT_RE = re.compile(r"^\s*pub(?:\(crate\))? struct ([A-Za-z0-9_]+)\s*\{\s*$") +ENUM_RE = re.compile(r"^\s*pub(?:\(crate\))? enum ([A-Za-z0-9_]+)\s*\{\s*$") +VARIANT_RE = re.compile(r"^\s*([A-Z][A-Za-z0-9_]*)\s*(?:\(.*\))?\s*,?\s*$") +RENAME_ALL_RE = re.compile(r'rename_all\s*=\s*"([^"]+)"') +RENAME_RE = re.compile(r'rename\s*=\s*"([^"]+)"') + + +@dataclass +class StructField: + name: str + rust_type: str + doc: str + + +@dataclass +class Model: + structs: dict[str, list[StructField]] = field(default_factory=dict) + enums: dict[str, list[str]] = field(default_factory=dict) + + +def apply_rename_all(name: str, style: str | None) -> str: + if style is None: + return name + if style == "lowercase": + return name.lower() + if style == "snake_case": + return re.sub(r"(?<!^)([A-Z])", r"_\1", name).lower() + if style == "kebab-case": + return re.sub(r"(?<!^)([A-Z])", r"-\1", name).lower() + raise ValueError(f"unsupported serde rename_all style: {style}") + + +def parse_model(paths: list[Path]) -> Model: + model = Model() + for path in paths: + parse_file(path.read_text(encoding="utf-8"), model) + return model + + +def parse_file(text: str, model: Model) -> None: + lines = text.splitlines() + index = 0 + pending_attrs: list[str] = [] + + while index < len(lines): + line = lines[index] + stripped = line.strip() + + if stripped.startswith("#["): + pending_attrs.append(stripped) + index += 1 + continue + + struct_match = STRUCT_RE.match(line) + enum_match = ENUM_RE.match(line) + if struct_match: + rename_all = find_rename_all(pending_attrs) + index = parse_struct_body(lines, index + 1, struct_match.group(1), rename_all, model) + pending_attrs = [] + continue + if enum_match: + rename_all = find_rename_all(pending_attrs) + untagged = any("untagged" in attr for attr in pending_attrs if attr.startswith("#[serde")) + index = parse_enum_body(lines, index + 1, enum_match.group(1), rename_all, model) + if untagged: + model.enums.pop(enum_match.group(1), None) + pending_attrs = [] + continue + + if not stripped.startswith("///"): + pending_attrs = [] + index += 1 + + +def find_rename_all(attrs: list[str]) -> str | None: + for attr in attrs: + if not attr.startswith("#[serde"): + continue + match = RENAME_ALL_RE.search(attr) + if match: + return match.group(1) + return None + + +def parse_struct_body( + lines: list[str], start: int, name: str, rename_all: str | None, model: Model +) -> int: + fields: list[StructField] = [] + doc_lines: list[str] = [] + field_attrs: list[str] = [] + index = start + + while index < len(lines): + stripped = lines[index].strip() + if stripped == "}": + index += 1 + break + + if stripped.startswith("///"): + doc_lines.append(stripped.lstrip("/").strip()) + index += 1 + continue + if stripped.startswith("#["): + field_attrs.append(stripped) + index += 1 + continue + + match = FIELD_RE.match(lines[index]) + if match: + field_name = serde_field_name(match.group(1), field_attrs, rename_all) + if not is_skipped_field(field_attrs): + fields.append( + StructField( + name=field_name, + rust_type=match.group(2).strip(), + doc=" ".join(doc_lines), + ) + ) + doc_lines = [] + field_attrs = [] + index += 1 + + model.structs[name] = fields + return index + + +def serde_field_name(name: str, attrs: list[str], rename_all: str | None) -> str: + for attr in attrs: + if attr.startswith("#[serde"): + match = RENAME_RE.search(attr) + if match and "rename_all" not in attr: + return match.group(1) + return apply_rename_all(name, rename_all) if rename_all else name + + +def is_skipped_field(attrs: list[str]) -> bool: + for attr in attrs: + if not attr.startswith("#[serde"): + continue + if re.search(r"\bskip\b|\bskip_deserializing\b", attr): + return True + return False + + +def parse_enum_body( + lines: list[str], start: int, name: str, rename_all: str | None, model: Model +) -> int: + variants: list[str] = [] + index = start + depth = 0 + + while index < len(lines): + stripped = lines[index].strip() + if depth == 0 and stripped == "}": + index += 1 + break + + depth += stripped.count("{") - stripped.count("}") + if depth == 0 and not stripped.startswith(("#[", "///")): + match = VARIANT_RE.match(stripped) + if match: + variants.append(apply_rename_all(match.group(1), rename_all or "lowercase")) + index += 1 + + model.enums[name] = variants + return index + + +def strip_wrappers(rust_type: str) -> tuple[str, bool]: + """Return the innermost type name and whether it was wrapped in Vec.""" + current = rust_type + is_vec = False + while True: + match = re.fullmatch(r"(Option|Vec|Box)<(.+)>", current) + if not match: + break + if match.group(1) == "Vec": + is_vec = True + current = match.group(2) + # std::collections::BTreeSet<...> and friends stay as-is; they are leaves. + return current.strip(), is_vec + + +def collect_keys(model: Model) -> set[str]: + return {entry["key"] for entry in collect_entries(model)} + + +def collect_entries(model: Model, struct_name: str = ROOT_STRUCT, prefix: str = "") -> list[dict]: + """Dotted keys with type/doc details; --emit prints these for bootstrapping.""" + if struct_name not in model.structs: + raise KeyError(f"struct {struct_name} not found in config model") + + entries: list[dict] = [] + for struct_field in model.structs[struct_name]: + dotted = f"{prefix}{struct_field.name}" + inner, is_vec = strip_wrappers(struct_field.rust_type) + if inner in model.structs: + if dotted in SKIPPED_SUBTREES: + continue + if is_vec: + raise ValueError( + f"{dotted} is an open-ended array of tables; add it to " + "SKIPPED_SUBTREES and document it in prose" + ) + entries.extend(collect_entries(model, inner, f"{dotted}.")) + else: + entry = {"key": dotted, "rust_type": struct_field.rust_type, "doc": struct_field.doc} + if inner in model.enums: + entry["values"] = model.enums[inner] + entries.append(entry) + return entries + + +def reference_entries(reference_path: Path) -> tuple[dict[str, dict], list[str]]: + data = json.loads(reference_path.read_text(encoding="utf-8")) + entries: dict[str, dict] = {} + errors: list[str] = [] + for section in data["sections"]: + for entry in section["keys"]: + key = entry["key"] + if key in entries: + errors.append(f"{key}: duplicated in {reference_path}") + continue + entries[key] = entry + return entries, errors + + +def check(model_root: Path, reference_path: Path) -> list[str]: + model = parse_model(sorted(model_root.glob("*.rs"))) + code_entries = {entry["key"]: entry for entry in collect_entries(model)} + doc_entries, errors = reference_entries(reference_path) + code_keys = set(code_entries) + doc_keys = set(doc_entries) + + for missing in sorted(code_keys - doc_keys): + errors.append(f"{missing}: in src/config but missing from {reference_path}") + for stale in sorted(doc_keys - code_keys): + errors.append(f"{stale}: in {reference_path} but not in src/config") + for key in sorted(code_keys & doc_keys): + expected_values = code_entries[key].get("values") + if expected_values is None: + continue + documented_values = doc_entries[key].get("values") + if documented_values != expected_values: + errors.append( + f"{key}: allowed values in {reference_path} are " + f"{documented_values!r}; expected {expected_values!r} from src/config" + ) + return errors + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Check website config reference keys against src/config structs." + ) + parser.add_argument("--model-root", default=DEFAULT_MODEL_ROOT, type=Path) + parser.add_argument("--reference", default=DEFAULT_REFERENCE, type=Path) + parser.add_argument( + "--emit", + action="store_true", + help="Print extracted keys with types and doc comments as JSON and exit.", + ) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(sys.argv[1:] if argv is None else argv) + + if args.emit: + model = parse_model(sorted(args.model_root.glob("*.rs"))) + print(json.dumps(collect_entries(model), indent=2)) + return 0 + + errors = check(args.model_root, args.reference) + if errors: + print("error: config reference is out of sync with src/config", file=sys.stderr) + for error in errors: + print(f"- {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/test_config_reference_check.py b/scripts/test_config_reference_check.py new file mode 100644 index 00000000..317aae8a --- /dev/null +++ b/scripts/test_config_reference_check.py @@ -0,0 +1,245 @@ +from __future__ import annotations + +import json +import tempfile +import unittest +from pathlib import Path + +from scripts.config_reference_check import ( + Model, + StructField, + check, + collect_entries, + collect_keys, + parse_file, + parse_model, +) + + +SAMPLE_MODEL = """ +#[derive(Debug, Deserialize)] +#[serde(default)] +pub struct Config { + pub onboarding: Option<bool>, + pub ui: UiConfig, + pub keys: KeysConfig, +} + +#[derive(Debug, Deserialize)] +#[serde(default)] +pub struct UiConfig { + /// Sidebar width in columns. Default: 26. + pub sidebar_width: u16, + /// Host cursor policy. Default: auto. + pub host_cursor: HostCursorModeConfig, + #[serde(rename = "accent_color")] + pub accent: String, + #[serde(skip)] + pub internal_cache: usize, +} + +#[derive(Debug, Deserialize)] +#[serde(default)] +pub struct KeysConfig { + /// Prefix key. Default: "ctrl+b". + pub prefix: String, + pub zoom: BindingConfig, + /// Prefix-mode custom command bindings. + pub command: Vec<CommandKeybindConfig>, + pub(crate) user_fields: BTreeSet<&'static str>, +} + +#[derive(Debug, Deserialize)] +pub struct CommandKeybindConfig { + pub key: String, + pub command: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum HostCursorModeConfig { + Auto, + NativeCursor, + Drawn, +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +pub enum BindingConfig { + One(String), + Many(Vec<String>), +} +""" + + +def sample_model() -> Model: + model = Model() + parse_file(SAMPLE_MODEL, model) + return model + + +class CollectKeysTests(unittest.TestCase): + def test_walks_nested_structs_into_dotted_keys(self) -> None: + keys = collect_keys(sample_model()) + + self.assertIn("onboarding", keys) + self.assertIn("ui.sidebar_width", keys) + self.assertIn("keys.prefix", keys) + self.assertIn("keys.zoom", keys) + + def test_serde_rename_wins_over_field_name(self) -> None: + keys = collect_keys(sample_model()) + + self.assertIn("ui.accent_color", keys) + self.assertNotIn("ui.accent", keys) + + def test_skips_serde_skip_and_private_fields(self) -> None: + keys = collect_keys(sample_model()) + + self.assertNotIn("ui.internal_cache", keys) + self.assertNotIn("keys.user_fields", keys) + + def test_skips_listed_vec_of_struct_subtrees(self) -> None: + keys = collect_keys(sample_model()) + + self.assertNotIn("keys.command", keys) + self.assertNotIn("keys.command.key", keys) + + def test_unlisted_vec_of_struct_subtree_is_an_error(self) -> None: + model = sample_model() + parse_file( + "pub struct ExtraConfig {\n" + " pub items: Vec<CommandKeybindConfig>,\n" + "}\n", + model, + ) + model.structs["Config"].append( + StructField(name="extra", rust_type="ExtraConfig", doc="") + ) + + with self.assertRaises(ValueError) as raised: + collect_keys(model) + + self.assertIn("extra.items", str(raised.exception)) + self.assertIn("SKIPPED_SUBTREES", str(raised.exception)) + + def test_enum_values_respect_rename_all_and_untagged_enums_have_none(self) -> None: + entries = {entry["key"]: entry for entry in collect_entries(sample_model())} + + self.assertEqual( + entries["ui.host_cursor"]["values"], ["auto", "native-cursor", "drawn"] + ) + self.assertNotIn("values", entries["keys.zoom"]) + + +class CheckTests(unittest.TestCase): + def run_check( + self, + documented_keys: list[str], + *, + value_overrides: dict[str, list[str]] | None = None, + ) -> list[str]: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + model_root = root / "config" + model_root.mkdir() + (model_root / "model.rs").write_text(SAMPLE_MODEL, encoding="utf-8") + + model_entries = {entry["key"]: entry for entry in collect_entries(sample_model())} + entries = [] + for key in documented_keys: + entry = {"key": key} + if key in model_entries and "values" in model_entries[key]: + entry["values"] = model_entries[key]["values"] + if value_overrides and key in value_overrides: + entry["values"] = value_overrides[key] + entries.append(entry) + + reference = root / "config-reference.json" + reference.write_text( + json.dumps( + { + "sections": [ + { + "id": "all", + "title": "All", + "keys": entries, + } + ] + } + ), + encoding="utf-8", + ) + return check(model_root, reference) + + def all_keys(self) -> list[str]: + return sorted(collect_keys(sample_model())) + + def test_in_sync_reference_passes(self) -> None: + self.assertEqual(self.run_check(self.all_keys()), []) + + def test_missing_key_is_named(self) -> None: + documented = [key for key in self.all_keys() if key != "ui.sidebar_width"] + + errors = self.run_check(documented) + + self.assertEqual(len(errors), 1) + self.assertIn("ui.sidebar_width", errors[0]) + self.assertIn("missing", errors[0]) + + def test_stale_key_is_named(self) -> None: + errors = self.run_check(self.all_keys() + ["ui.removed_option"]) + + self.assertEqual(len(errors), 1) + self.assertIn("ui.removed_option", errors[0]) + self.assertIn("not in src/config", errors[0]) + + def test_swapped_key_fails_despite_equal_count(self) -> None: + documented = [ + "ui.renamed_option" if key == "ui.sidebar_width" else key + for key in self.all_keys() + ] + + errors = self.run_check(documented) + + self.assertEqual(len(errors), 2) + + def test_duplicate_key_is_rejected(self) -> None: + errors = self.run_check(self.all_keys() + ["ui.sidebar_width"]) + + self.assertEqual(len(errors), 1) + self.assertIn("duplicated", errors[0]) + + def test_changed_enum_values_are_rejected(self) -> None: + errors = self.run_check( + self.all_keys(), + value_overrides={"ui.host_cursor": ["auto", "native"]}, + ) + + self.assertEqual(len(errors), 1) + self.assertIn("ui.host_cursor", errors[0]) + self.assertIn("allowed values", errors[0]) + + +class RealModelTests(unittest.TestCase): + def test_real_config_model_parses_and_yields_keys(self) -> None: + model = parse_model(sorted(Path("src/config").glob("*.rs"))) + keys = collect_keys(model) + + self.assertGreater(len(keys), 100) + self.assertIn("keys.prefix", keys) + self.assertIn("ui.sound.agents.claude", keys) + self.assertNotIn("keys.command", keys) + + def test_preview_reference_matches_real_config_model(self) -> None: + self.assertEqual( + check( + Path("src/config"), + Path("docs/next/website/src/data/config-reference.json"), + ), + [], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test_preview.py b/scripts/test_preview.py index db80b1a7..87bd52eb 100644 --- a/scripts/test_preview.py +++ b/scripts/test_preview.py @@ -141,6 +141,9 @@ class PreviewNotesTests(unittest.TestCase): title: Install Herdr --- +import ConfigReference from '../../components/ConfigReference.astro'; +import LocaleWidget from '../../../components/LocaleWidget.astro'; + [Install](/docs/install/) file: ../../../public/assets/logo.svg """ @@ -151,6 +154,8 @@ file: ../../../public/assets/logo.svg ) self.assertIn("[Install](/docs/preview/install/)", output) self.assertIn("file: ../../../../public/assets/logo.svg", output) + self.assertIn("from '../../../components/ConfigReference.astro'", output) + self.assertIn("from '../../../../components/LocaleWidget.astro'", output) self.assertIn("Preview docs describe unreleased preview builds", output) |
