from __future__ import annotations
import re
import sys
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
LOCALES = REPO / "locales"
LLMAN_SDD_COPY = REPO / "crates" / "llman-sdd" / "locales" / "app.yml"
CODE_DIRS = ("src", "crates", "tests")
SECTION_RE = re.compile(r"^(\s*)([A-Za-z0-9_-]+):\s*$")
LEAF_RE = re.compile(r'^(\s*)([A-Za-z0-9_-]+):\s*(?:"(.*)"\s*|\S.*)$')
COMMENT_RE = re.compile(r"^\s*#")
LOCALES_SUPPORTED = {"en", "zh-Hans"}
def parse_locale_tree(path: Path) -> dict:
root: dict = {}
stack: list[tuple[int, dict]] = [(-1, root)]
for raw in path.read_text(encoding="utf-8").splitlines():
if not raw.strip() or COMMENT_RE.match(raw):
continue
leaf = LEAF_RE.match(raw)
section = SECTION_RE.match(raw)
if not section and not leaf:
continue
indent = len((leaf or section).group(1))
while stack and indent <= stack[-1][0]:
stack.pop()
parent = stack[-1][1]
if section:
node: dict = {}
parent[section.group(2)] = node
stack.append((indent, node))
else:
parent[leaf.group(2)] = leaf.group(3) or ""
return root
def collect_key_paths(node: dict, prefix: str = "") -> set[str]:
keys: set[str] = set()
for name, value in node.items():
path = f"{prefix}.{name}" if prefix else name
if isinstance(value, dict):
if set(value.keys()) <= LOCALES_SUPPORTED and value:
keys.add(path)
else:
keys |= collect_key_paths(value, path)
return keys
def collect_used_key_literals() -> set[str]:
used: set[str] = set()
pattern = re.compile(r'\bt!\(\s*"([^"]+)"')
for code_dir in CODE_DIRS:
for file in (REPO / code_dir).rglob("*.rs"):
for match in pattern.finditer(file.read_text(encoding="utf-8")):
used.add(match.group(1))
return used
def key_referenced_in_code(key: str) -> bool:
for code_dir in CODE_DIRS:
for file in (REPO / code_dir).rglob("*.rs"):
if key in file.read_text(encoding="utf-8"):
return True
return False
def strip_dead_keys(path: Path, dead: set[str]) -> int:
lines = path.read_text(encoding="utf-8").splitlines(keepends=True)
out: list[str] = []
stack: list[tuple[int, str]] = [(-1, "")]
skipping = False
skip_indent = 0
removed = 0
for raw in lines:
stripped = raw.strip()
if not stripped or stripped.startswith("#"):
if not skipping:
out.append(raw)
continue
leaf = LEAF_RE.match(raw)
section = SECTION_RE.match(raw)
if not leaf and not section:
out.append(raw)
continue
indent = len((leaf or section).group(1))
key = (leaf or section).group(2)
while stack and indent <= stack[-1][0]:
stack.pop()
prefix = ".".join(name for _, name in stack[1:])
full = f"{prefix}.{key}" if prefix else key
if skipping:
if indent > skip_indent:
continue
skipping = False
if section and full in dead:
skipping = True
skip_indent = indent
removed += 1
continue
out.append(raw)
if section:
stack.append((indent, key))
if removed:
path.write_text("".join(out), encoding="utf-8")
return removed
def main() -> int:
if LLMAN_SDD_COPY.is_file() and LLMAN_SDD_COPY.read_bytes() != (LOCALES / "app.yml").read_bytes():
print(f"ERROR: {LLMAN_SDD_COPY} out of sync with {LOCALES / 'app.yml'}")
print(" run: cp locales/app.yml crates/llman-sdd/locales/app.yml")
return 1
if "--fix" in sys.argv:
defined_before = collect_key_paths(parse_locale_tree(LOCALES / "app.yml"))
dead = {key for key in defined_before if not key_referenced_in_code(key)}
removed = strip_dead_keys(LOCALES / "app.yml", dead)
print(f"removed {removed} dead key blocks from {LOCALES / 'app.yml'}")
return 0
defined = collect_key_paths(parse_locale_tree(LOCALES / "app.yml"))
used = collect_used_key_literals()
dead = sorted(key for key in defined if not key_referenced_in_code(key))
missing = sorted(used - defined)
for key in dead:
print(f"dead i18n key (defined in app.yml, unused in code): {key}")
for key in missing:
print(f"missing i18n key (used in code, not in app.yml): {key}")
print(f"{len(defined)} defined · {len(used & defined)} used · "
f"{len(dead)} dead · {len(missing)} missing")
return 1 if dead or missing else 0
if __name__ == "__main__":
sys.exit(main())