"""RON validation — ported from the bash `validate-ron` (D-263). The old script was three languages deep: bash dispatching on a flag, a Python heredoc doing collision detection, and `cargo run` for schema validation. The heredoc is the reason this is a rewrite rather than a move — logic embedded in a shell string cannot be imported, cannot be tested, and cannot be read by anything that indexes Python. What stayed shell-shaped is the one genuine OS interaction: running the Rust validator. That goes through `core.process.run`, which is the sanctioned exec. """ from __future__ import annotations import re from pathlib import Path from tooling.core import config, console, process from tooling.core.errors import ReachError SCHEMAS = ("zone", "zone_type", "culture") def name_collisions(directory: Path) -> int: """Report names shared between culture name pools. Returns an exit code. A name appearing in two cultures' pools makes generated NPCs ambiguous about where they are from, which is invisible until someone notices two cultures producing the same surnames. """ if not directory.is_dir(): raise ReachError( f"directory not found: {directory}", fix="pass a directory containing culture-*.ron files", ) files = sorted(directory.glob("culture-*.ron")) if not files: console.event(f"No culture-*.ron files found in: {directory}") return 0 given: dict[str, set[str]] = {} family: dict[str, set[str]] = {} for path in files: text = path.read_text(encoding="utf-8") match = re.search(r'\bid\s*:\s*"([^"]+)"', text) culture = match.group(1) if match else path.name given[culture] = set(_names(text, "given_names")) family[culture] = set(_names(text, "family_names")) collisions = False for field, pools in (("given_names", given), ("family_names", family)): for name, cultures in sorted(_shared(pools).items()): collisions = True console.event( f'COLLISION {field}: "{name}" in {", ".join(sorted(cultures))}', level="error", ) if collisions: return 1 console.verdict( f"OK: no name collisions across {len(given)} culture(s): " f"{', '.join(sorted(given))}" ) return 0 def ron_file(path: Path, schema: str) -> int: """Validate one .ron file against a Rust struct schema.""" if schema not in SCHEMAS: raise ReachError( f"unknown schema {schema!r}", fix=f"choose one of: {', '.join(SCHEMAS)}", exit_code=2, ) if not path.is_file(): # Checked before resolving, because realpath on a missing file gives an # error about the path rather than about the file — the old script made # the same distinction and it is worth keeping. raise ReachError( f"file not found: {path}", fix="check the path, or pass a directory to `reach validate name-collisions`", ) result = process.run( ["cargo", "run", "--quiet", "--bin", "validate_ron", "--", str(path.resolve()), schema], cwd=config.path("server"), check=False, capture=False, missing_fix="install Rust — make setup-rust", ) return result.returncode def _names(text: str, field: str) -> list[str]: """Quoted strings from a named RON array field, comments stripped.""" match = re.search(rf"\b{re.escape(field)}\s*:\s*\[([^\]]*)\]", text, re.DOTALL) if not match: return [] block = re.sub(r"//[^\n]*", "", match.group(1)) return re.findall(r'"([^"]+)"', block) def _shared(pools: dict[str, set[str]]) -> dict[str, list[str]]: """name -> the cultures claiming it, for names claimed more than once.""" owners: dict[str, list[str]] = {} for culture, names in pools.items(): for name in names: owners.setdefault(name, []).append(culture) return {name: cultures for name, cultures in owners.items() if len(cultures) > 1}