| 1 | #!/usr/bin/env python3 |
| 2 | """Move top-level TUI modules into crates/runtime (the runtime/TUI split). |
| 3 | |
| 4 | For each named module this: |
| 5 | |
| 6 | 1. `git mv`s `crates/tui/src/<m>.rs` and/or `crates/tui/src/<m>/` into |
| 7 | `crates/runtime/src/`; |
| 8 | 2. deletes its `mod <m>;` / `pub mod <m>;` line from the TUI `lib.rs` and adds |
| 9 | `pub mod <m>;` to the runtime `lib.rs` (sorted); |
| 10 | 3. adds `<m>` to the TUI's single path-alias block |
| 11 | `use codewhale_runtime::{...};`, so every `crate::<m>::...` path in the TUI |
| 12 | keeps resolving without a content change (the alias is deleted at the end |
| 13 | of the split by rewriting those paths); |
| 14 | 4. sweeps every string occurrence of the old path across the repository |
| 15 | (`crates/tui/src/<m>` and `tui/src/<m>`), found with `git grep`. |
| 16 | CHANGELOG files are history and are left alone. |
| 17 | |
| 18 | It changes no item visibility; `scripts/split/widen.py` does that, driven by |
| 19 | the compiler. |
| 20 | |
| 21 | Usage: |
| 22 | python3 scripts/split/move-modules.py <module> [<module> ...] |
| 23 | python3 scripts/split/move-modules.py --dry-run <module> ... |
| 24 | """ |
| 25 | |
| 26 | from __future__ import annotations |
| 27 | |
| 28 | import argparse |
| 29 | import re |
| 30 | import subprocess |
| 31 | import sys |
| 32 | from pathlib import Path |
| 33 | |
| 34 | REPO_ROOT = Path(__file__).resolve().parents[2] |
| 35 | TUI_SRC = REPO_ROOT / "crates" / "tui" / "src" |
| 36 | RUNTIME_SRC = REPO_ROOT / "crates" / "runtime" / "src" |
| 37 | TUI_LIB = TUI_SRC / "lib.rs" |
| 38 | RUNTIME_LIB = RUNTIME_SRC / "lib.rs" |
| 39 | ALIAS_START = "use codewhale_runtime::{" |
| 40 | ALIAS_MARKER = "// Runtime split path alias" |
| 41 | |
| 42 | |
| 43 | def git(*args: str, check: bool = True) -> str: |
| 44 | return subprocess.run( |
| 45 | ["git", *args], cwd=REPO_ROOT, capture_output=True, text=True, check=check |
| 46 | ).stdout |
| 47 | |
| 48 | |
| 49 | def module_paths(module: str) -> list[Path]: |
| 50 | paths = [p for p in (TUI_SRC / f"{module}.rs", TUI_SRC / module) if p.exists()] |
| 51 | if not paths: |
| 52 | raise SystemExit(f"move-modules: no crates/tui/src/{module}.rs or {module}/") |
| 53 | return paths |
| 54 | |
| 55 | |
| 56 | def remove_mod_line(text: str, module: str) -> tuple[str, bool]: |
| 57 | pattern = re.compile(rf"^(?:pub(?:\([^)]*\))?\s+)?mod\s+{re.escape(module)}\s*;\n", re.M) |
| 58 | match = pattern.search(text) |
| 59 | if not match: |
| 60 | return text, False |
| 61 | # Refuse to drop a mod line that carries attributes (cfg, path): that |
| 62 | # module needs a human decision, not a mechanical move. |
| 63 | before = text[: match.start()].rstrip("\n").rsplit("\n", 1)[-1].strip() |
| 64 | if before.startswith("#["): |
| 65 | raise SystemExit(f"move-modules: `mod {module};` carries an attribute ({before}); move it by hand") |
| 66 | return text[: match.start()] + text[match.end() :], True |
| 67 | |
| 68 | |
| 69 | def add_runtime_mod(text: str, module: str) -> str: |
| 70 | line = f"pub mod {module};\n" |
| 71 | if line in text: |
| 72 | return text |
| 73 | lines = text.splitlines(keepends=True) |
| 74 | mods = [i for i, l in enumerate(lines) if re.match(r"pub mod \w+;\n", l)] |
| 75 | if not mods: |
| 76 | return text.rstrip("\n") + "\n\n" + line |
| 77 | insert = mods[-1] + 1 |
| 78 | for i in mods: |
| 79 | if lines[i] > line: |
| 80 | insert = i |
| 81 | break |
| 82 | lines.insert(insert, line) |
| 83 | return "".join(lines) |
| 84 | |
| 85 | |
| 86 | def add_alias(text: str, module: str) -> str: |
| 87 | start = text.find(ALIAS_START) |
| 88 | if start < 0: |
| 89 | raise SystemExit( |
| 90 | "move-modules: the TUI lib.rs has no `use codewhale_runtime::{...};` alias block" |
| 91 | ) |
| 92 | end = text.index("};", start) |
| 93 | names = {n.strip() for n in text[start + len(ALIAS_START) : end].split(",") if n.strip()} |
| 94 | names.add(module) |
| 95 | body = "".join(f" {n},\n" for n in sorted(names)) |
| 96 | return text[:start] + ALIAS_START + "\n" + body + text[end:] |
| 97 | |
| 98 | |
| 99 | def sweep_paths(module: str, dry_run: bool) -> list[str]: |
| 100 | patterns = [ |
| 101 | (f"crates/tui/src/{module}", f"crates/runtime/src/{module}"), |
| 102 | (f"tui/src/{module}", f"runtime/src/{module}"), |
| 103 | ] |
| 104 | touched: list[str] = [] |
| 105 | for old, new in patterns: |
| 106 | # The module as a whole path segment: `elapsed.rs`, `elapsed/`, |
| 107 | # `elapsed::`, never `elapsed_time.rs`; `tui/src/x` only when it is not |
| 108 | # already the tail of `crates/tui/src/x`. |
| 109 | regex = re.compile( |
| 110 | rf"(?<![A-Za-z0-9_/]){re.escape(old)}(?![A-Za-z0-9_])" |
| 111 | if not old.startswith("crates/") |
| 112 | else rf"{re.escape(old)}(?![A-Za-z0-9_])" |
| 113 | ) |
| 114 | for rel in git("grep", "-l", "-F", old, check=False).split(): |
| 115 | if Path(rel).name.startswith("CHANGELOG"): |
| 116 | continue |
| 117 | path = REPO_ROOT / rel |
| 118 | text = path.read_text(encoding="utf-8") |
| 119 | updated = regex.sub(new, text) |
| 120 | if updated != text: |
| 121 | touched.append(rel) |
| 122 | if not dry_run: |
| 123 | path.write_text(updated, encoding="utf-8") |
| 124 | return sorted(set(touched)) |
| 125 | |
| 126 | |
| 127 | def main(argv: list[str] | None = None) -> int: |
| 128 | parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) |
| 129 | parser.add_argument("modules", nargs="+") |
| 130 | parser.add_argument("--dry-run", action="store_true") |
| 131 | args = parser.parse_args(argv) |
| 132 | |
| 133 | if not RUNTIME_LIB.is_file(): |
| 134 | raise SystemExit("move-modules: crates/runtime/src/lib.rs does not exist yet") |
| 135 | tui_lib = TUI_LIB.read_text(encoding="utf-8") |
| 136 | runtime_lib = RUNTIME_LIB.read_text(encoding="utf-8") |
| 137 | for module in args.modules: |
| 138 | paths = module_paths(module) |
| 139 | tui_lib, found = remove_mod_line(tui_lib, module) |
| 140 | if not found: |
| 141 | raise SystemExit(f"move-modules: no `mod {module};` line in crates/tui/src/lib.rs") |
| 142 | tui_lib = add_alias(tui_lib, module) |
| 143 | runtime_lib = add_runtime_mod(runtime_lib, module) |
| 144 | if not args.dry_run: |
| 145 | for path in paths: |
| 146 | git("mv", str(path.relative_to(REPO_ROOT)), str((RUNTIME_SRC / path.name).relative_to(REPO_ROOT))) |
| 147 | if not args.dry_run: |
| 148 | TUI_LIB.write_text(tui_lib, encoding="utf-8") |
| 149 | RUNTIME_LIB.write_text(runtime_lib, encoding="utf-8") |
| 150 | swept: set[str] = set() |
| 151 | for module in args.modules: |
| 152 | swept.update(sweep_paths(module, args.dry_run)) |
| 153 | for rel in sorted(swept): |
| 154 | print(f"path sweep: {rel}") |
| 155 | print(f"moved {len(args.modules)} module(s){' (dry run)' if args.dry_run else ''}") |
| 156 | return 0 |
| 157 | |
| 158 | |
| 159 | if __name__ == "__main__": |
| 160 | sys.exit(main()) |
| 161 |