#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ 改名 / 挪目录 + 全库引用自动改写(把"同步引用链接"从手工活变成一条命令)。 背景:本仓库 1800+ 处路径引用(`docs/xxx.md` 反引号引用 + Markdown 链接 + `#include` + `uvprojx`/`CMakeLists.txt` 源文件列表),改名或挪目录后要靠人 逐文件扫改(历史上一次改名波及 10~30 个文件)。本脚本把这件事机械化。 用法: python scripts/rename_ref.py <旧路径> <新路径> # 默认 dry-run,只打印将要改什么 python scripts/rename_ref.py <旧路径> <新路径> --apply # 真正落盘(git mv + 改写引用) python scripts/rename_ref.py <旧路径> <新目录/ # 挪进目录(保留文件名) python scripts/rename_ref.py docs/a/ docs/b/ --apply # 整目录搬迁(前缀改写) 路径一律写**仓库根相对路径**(正斜杠),末尾不带 `/` 也可。 改写覆盖的引用形态: 1. 反引号根相对引用 `docs/xxx/yyy.md` 2. Markdown 链接 [文本](docs/xxx/yyy.md)(根相对) / [文本](../xxx/yyy.md)(文档相对) 3. 图片链接 ![x](images/a.svg) —— 含"文档挪层级后 ../ 前缀要跟着变"这条实证坑 4. C 的 #include #include "yyy.h"(改名时连裸文件名引用一起改) 5. 构建文件 MDK-ARM/*.uvprojx 的 、simulator/CMakeLists.txt 的 ${PROJECT_ROOT}/… 与 ${VAR}/… 源文件列表 6. 任意文本文件里的路径串(.py/.bat/.yaml/.json/.txt/skill 等) 保真约定(见 docs/文档规范.md §2、scripts/README.md「脚本编写约定」): * 按原样回写 BOM 与换行符(LF/CRLF),无改动的文件不写 * 只扫 git 跟踪的文本文件;第三方/生成物目录跳过 * 默认 dry-run;--apply 才落盘,且落盘后自动跑 check_doc_links.py --all 复核 退出码:0 成功;1 有错误(旧路径不存在 / 新路径已存在 / 复核失败);2 用法错误。 """ import argparse import os import pathlib import re import subprocess import sys import urllib.parse REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent # 不扫描的路径片段(第三方 / 生成物 / 本地状态) SKIP_PARTS = ("Lib/", "Middlewares/", "Drivers/STM32F4xx_HAL_Driver/", "Drivers/CMSIS/", "MDK-ARM/objects/", "MDK-ARM/Listings/", "MDK-ARM/RTE/", "MDK-ARM/STM32F407-Demo/", ".venv/", "node_modules/", "__pycache__/", "build/", "obj/", ".workbuddy-ai/", ".mnemon/", "simulator/SDL2/", "docs/芯片手册/") # 生成物:不改写,只在结尾提示重跑生成器(改了也是白改,下次生成会覆盖) GENERATED = ("docs/images/引用关系图.",) # 不扫描的扩展名(二进制 / 生成物) SKIP_EXT = {".o", ".d", ".map", ".axf", ".bin", ".hex", ".elf", ".htm", ".lst", ".png", ".jpg", ".jpeg", ".gif", ".ttf", ".woff", ".dll", ".lib", ".pyc", ".zip", ".gz", ".pdf", ".docx", ".xlsx", ".ico", ".cur"} # 路径 token:至少含一个分隔符;允许 ${VAR} 前缀、中文、点、下划线、短横 CH = r"[\w$.\-{}一-鿿]" TOKEN_RE = re.compile(CH + r"+(?:[/\\]" + CH + r"+)+") VAR_PREFIX_RE = re.compile(r"^(\$\{[^}]+\}/)(.+)$") INCLUDE_RE = re.compile(r'(#\s*include\s*)([<"])([^<>"\n]+)([">])') FILEPATH_RE = re.compile(r"()([^<\n]+)()") BOM = b"\xef\xbb\xbf" def rel(p: pathlib.Path) -> str: """仓库根相对 POSIX 路径。""" try: return p.resolve().relative_to(REPO_ROOT).as_posix() except ValueError: return p.as_posix() def is_skipped(relpath: str) -> bool: for s in SKIP_PARTS: if relpath == s.rstrip("/") or relpath.startswith(s): return True return False def is_generated(relpath: str) -> bool: """生成物(引用关系图等):跳过改写,只提示重跑生成器。""" return any(relpath.startswith(g) for g in GENERATED) def tracked_files(): """git 跟踪 + 未跟踪但未忽略的文件(自动排除生成物与 .gitignore 内容)。""" out = subprocess.run(["git", "ls-files", "-z", "--cached", "--others", "--exclude-standard"], cwd=REPO_ROOT, stdout=subprocess.PIPE, check=True).stdout for name in out.decode("utf-8").split("\0"): if not name: continue name = name.replace("\\", "/") if is_skipped(name): continue if pathlib.Path(name).suffix.lower() in SKIP_EXT: continue p = REPO_ROOT / name if p.is_file(): yield p def read_text(p: pathlib.Path): """返回 (text, bom, ok)。二进制/非 UTF-8 → ok=False。""" data = p.read_bytes() bom = data.startswith(BOM) if bom: data = data[len(BOM):] try: return data.decode("utf-8"), bom, True except UnicodeDecodeError: return None, bom, False def resolve_token(tok: str, file_dir: pathlib.Path, old_abs: pathlib.Path): """把 token 解析成绝对路径;解析不到 old 就返回 (None, "", False)。 返回 (解析到的绝对路径, ${VAR}/ 前缀, 是否按仓库根解析)。 依次尝试:${VAR}/ 前缀剥离 → 相对仓库根 → 相对引用文件目录 → 尾段后缀匹配。 """ var_prefix = "" body = tok m = VAR_PREFIX_RE.match(tok) if m: var_prefix, body = m.group(1), m.group(2) unquoted = urllib.parse.unquote(body) roots = [] if not unquoted.startswith("."): roots.append((REPO_ROOT, True)) roots.append((file_dir, False)) if unquoted.startswith("."): roots.insert(0, (REPO_ROOT, True)) for base, via_root in roots: for b in (unquoted, body): b2 = b.rstrip(".") try: rc = pathlib.Path(os.path.normpath(base / b2)) except (OSError, ValueError): continue if rc == old_abs or str(rc).startswith(str(old_abs) + os.sep): return rc, var_prefix, via_root # ${VAR}/name.ext 之类:变量指向的目录未知,按「尾段后缀」匹配 parts = re.split(r"[/\\]", body) old_parts = list(old_abs.parts) if var_prefix and len(parts) <= 2: if parts[-1] == old_parts[-1] and (len(parts) == 1 or parts[-2] == old_parts[-2]): return old_abs, var_prefix, True return None, "", False def resolve_relative(tok: str, file_dir: pathlib.Path): """按「相对引用文件所在目录」解析,且目标必须真实存在(用于被移动文件的出链)。""" if VAR_PREFIX_RE.match(tok): return None for b in (urllib.parse.unquote(tok), tok): b2 = b.rstrip(".") p = pathlib.Path(os.path.normpath(file_dir / b2)) if p.exists(): return p return None def rel_spelling(tok: str, target: pathlib.Path, base_dir: pathlib.Path) -> str: """保持原分隔符风格,重算相对 base_dir 的写法。""" body = os.path.relpath(target, base_dir).replace("\\", "/") if "\\" in tok: body = body.replace("/", "\\") if "%" in tok: body = urllib.parse.quote(body, safe="/") return body def new_spelling(tok: str, resolved: pathlib.Path, var_prefix: str, via_root: bool, base_dir: pathlib.Path) -> str: """按原写法风格算出新 token(根相对保持根相对,相对保持相对)。""" old_abs = old_abs_global[0] new_abs = new_abs_global[0] if resolved == old_abs: remainder = pathlib.Path(".") else: remainder = resolved.relative_to(old_abs) target = new_abs if remainder == pathlib.Path(".") else new_abs / remainder if var_prefix or via_root: new_body = os.path.relpath(target, REPO_ROOT) else: new_body = os.path.relpath(target, base_dir) if "\\" in tok: new_body = new_body.replace("/", "\\") else: new_body = new_body.replace("\\", "/") if "%" in tok: new_body = urllib.parse.quote(new_body, safe="/") return var_prefix + new_body old_abs_global = [None] new_abs_global = [None] def rewrite_text(text: str, file_path: pathlib.Path, changes: list, lineno_map=True): """改写一个文件里的所有引用;changes 收集 (lineno, old, new)。""" old_abs = old_abs_global[0] new_abs = new_abs_global[0] file_abs = file_path.resolve() # 被移动文件(含整目录搬迁时目录内的文件)自身的相对链接(图片等): # **解析**用旧目录(链接当前指向的位置),**改写**用新目录 # ——docs/文档规范.md §3.2 记的实证坑(迁目录后 images/ 未跟着改 ../) moved = (file_abs == old_abs or str(file_abs).startswith(str(old_abs) + os.sep)) resolve_dir = file_path.parent emit_dir = new_abs.parent if moved else file_path.parent out = [] pos = 0 for m in TOKEN_RE.finditer(text): tok = m.group(0) resolved, var_prefix, via_root = resolve_token(tok, resolve_dir, old_abs) if resolved is None and moved: # 被移动文件自身的**出链**(图片等):目标没变但基准目录变了 → 重算写法 own = resolve_relative(tok, resolve_dir) if own is not None: rep = rel_spelling(tok, own, emit_dir) if rep != tok: lineno = text.count("\n", 0, m.start()) + 1 changes.append((lineno, tok, rep)) out.append(text[pos:m.start()]) out.append(rep) pos = m.end() continue if resolved is None: continue rep = new_spelling(tok, resolved, var_prefix, via_root, emit_dir) if rep == tok: continue lineno = text.count("\n", 0, m.start()) + 1 changes.append((lineno, tok, rep)) out.append(text[pos:m.start()]) out.append(rep) pos = m.end() out.append(text[pos:]) new_text = "".join(out) # 改名时:裸文件名引用(#include "x.h"、x.s) old_name = old_abs.name new_name = new_abs.name if old_name != new_name and old_abs != new_abs: def fix_include(m): inc = m.group(3) if pathlib.PurePosixPath(inc.replace("\\", "/")).name == old_name: base = inc[: len(inc) - len(old_name)] new_inc = base + new_name changes.append((text.count("\n", 0, m.start()) + 1, inc, new_inc)) return m.group(1) + m.group(2) + new_inc + m.group(4) return m.group(0) def fix_filepath(m): fp = m.group(2) if pathlib.PurePosixPath(fp.replace("\\", "/")).name == old_name: base = fp[: len(fp) - len(old_name)] new_fp = base + new_name changes.append((text.count("\n", 0, m.start()) + 1, fp, new_fp)) return m.group(1) + new_fp + m.group(3) return m.group(0) new_text = INCLUDE_RE.sub(fix_include, new_text) if file_path.suffix.lower() in (".uvprojx", ".uvoptx"): new_text = FILEPATH_RE.sub(fix_filepath, new_text) return new_text def main(argv=None): ap = argparse.ArgumentParser( description="改名/挪目录并自动改写全库引用(默认 dry-run)") ap.add_argument("old", help="旧路径(仓库根相对)") ap.add_argument("new", help="新路径(仓库根相对;以 / 结尾表示挪进该目录)") ap.add_argument("--apply", action="store_true", help="真正落盘(默认只预览)") ap.add_argument("--no-git", action="store_true", help="用 os.rename 代替 git mv") ap.add_argument("--no-verify", action="store_true", help="跳过 check_doc_links 复核") args = ap.parse_args(argv) old_rel = args.old.replace("\\", "/").rstrip("/") new_rel = args.new.replace("\\", "/") if new_rel.endswith("/"): new_rel = new_rel + old_rel.rsplit("/", 1)[-1] new_rel = new_rel.rstrip("/") if not new_rel or old_rel == new_rel: print("错误:新旧路径相同或新路径为空") return 2 old_abs = (REPO_ROOT / old_rel).resolve() new_abs = (REPO_ROOT / new_rel).resolve() if not old_abs.exists(): print(f"错误:旧路径不存在:{old_rel}") return 1 if new_abs.exists(): print(f"错误:新路径已存在:{new_rel}") return 1 if is_skipped(old_rel): print(f"错误:{old_rel} 属于第三方/生成物范围,不应由本脚本改写") return 1 old_abs_global[0] = old_abs new_abs_global[0] = new_abs plan = [] hit_generated = False for f in tracked_files(): if is_generated(rel(f)): if resolve_token(old_rel, f.parent, old_abs)[0] is not None: hit_generated = True continue text, bom, ok = read_text(f) if not ok: continue changes = [] new_text = rewrite_text(text, f, changes) if changes: plan.append((f, text, new_text, bom, changes)) n_refs = sum(len(c) for _, _, _, _, c in plan) print(f"计划:{old_rel} → {new_rel}") print(f"受影响文件 {len(plan)} 个,引用改写 {n_refs} 处\n") for f, _, _, _, changes in plan: print(f"── {rel(f)} ({len(changes)} 处)") for lineno, o, n in changes[:12]: print(f" :{lineno} {o} → {n}") if len(changes) > 12: print(f" … 其余 {len(changes) - 12} 处略") print() if not args.apply: print("(dry-run:未做任何修改。确认无误后加 --apply)") return 0 # 1) 先移动文件本身 new_abs.parent.mkdir(parents=True, exist_ok=True) if args.no_git: os.rename(old_abs, new_abs) else: r = subprocess.run(["git", "mv", "--", old_rel, new_rel], cwd=REPO_ROOT, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) if r.returncode != 0: os.rename(old_abs, new_abs) print(f"已移动:{old_rel} → {new_rel}") # 2) 再改写引用(保 BOM / 换行) # 被移动文件本身已换位置 → 内容必须写到**新**路径,否则会在旧路径复活一份 def mapped(f: pathlib.Path) -> pathlib.Path: fa = f.resolve() if fa == old_abs: return new_abs if str(fa).startswith(str(old_abs) + os.sep): return new_abs / fa.relative_to(old_abs) return f for f, _, new_text, bom, _ in plan: target = mapped(f) target.parent.mkdir(parents=True, exist_ok=True) data = new_text.encode("utf-8") if bom: data = BOM + data target.write_bytes(data) print(f"已改写 {len(plan)} 个文件、{n_refs} 处引用") # 2b) 收尾提示(脚本管不到的部分) if hit_generated: print("ℹ 引用关系图是生成物,未改写 → 重跑 & 'tools\\refgraph\\gen_refgraph.bat'") if old_abs.suffix.lower() in (".c", ".h") or new_abs.suffix.lower() in (".c", ".h"): print("ℹ 改了 .c/.h:已同步 uvprojx 与 CMakeLists;若**新增目录**还需把目录加进 " "uvprojx 的 (见 new-source-file skill),随后跑 @build 验证") print("ℹ 正文里的**裸文件名/昵称引用**(如「内存方案 §2.1」「app_main.c/h」)无法机械判定," "已尽量改写,仍建议 grep 一遍旧名:git grep -n '<旧文件名>'") # 3) 复核 if not args.no_verify: r = subprocess.run([sys.executable, str(REPO_ROOT / "scripts" / "check_doc_links.py"), "--all"], cwd=REPO_ROOT) if r.returncode != 0: print("\n⚠️ check_doc_links 未通过——上面列出的断链需人工确认(多为裸文件名引用)") return 1 return 0 if __name__ == "__main__": sys.exit(main())