返回 CodeWhale
move-modules.py
根目录 / scripts / split / move-modules.py
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
161 lines PYTHON