Files
STM32F4-Base/tools/refgraph/gen_refgraph.py
T

659 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
gen_refgraph.py — STM32F4-Base 项目文件引用关系图生成器
=====================================================================
功能:
扫描仓库内全部 C 源文件(.c/.h),解析 #include 指令,按 Keil 工程
(MDK-ARM/STM32F407-Demo.uvprojx) 与 LVGL 模拟器 (simulator/CMakeLists.txt)
的包含路径将引用解析到具体文件,构建文件级引用关系图,输出 Graphviz
DOT / SVG / PNG 与结构化 JSON 数据。
产物(默认输出到 docs/images/,随脚本一并提交,项目变化后重跑更新):
引用关系图.dot / .svg / .png —— 全量文件级引用图(一手指方+第三方)
引用关系图_模块.dot / .svg —— 模块级概览图(目录/库聚合)
引用关系图.json —— 结构化数据(节点/边/未解析引用)
用法(仓库根目录):
& '.venv\\Scripts\\python.exe' tools/refgraph/gen_refgraph.py
或双击/运行 tools/refgraph/gen_refgraph.bat
选项:
--root PATH 仓库根目录(默认:脚本上两级目录)
--outdir PATH 产物目录(默认 <root>/docs/images)
--views a,b 要生成的视图,可选 full / module(默认两者)
--edge-scope S project = 仅第一方代码发起的引用边(默认,图最清晰);
all = 含第三方内部引用边(完整但密集,淡显)
--no-render 只生成 .dot/.json,不调用 Graphviz 渲染
--quiet 只输出关键行
引用解析规则(与编译器搜索顺序一致):
1. "..." 引用优先按所在文件目录解析,再按包含路径顺序查找;
2. <...> 引用按包含路径顺序查找;
3. 仍解析不到 → 归入“外部/系统头文件”(如 stdio.h/stdint.h),
明细见 JSON 的 unresolved 字段。
注:刻意不做“基名全库兜底”——仓库内存在同名系统头文件
(rapidjson 自带 msinttypes/stdint.h)时会把 <stdint.h> 误连到
库内部文件产生假边;编译器的系统目录不在仓库内,统一归外部。
说明:
- 纯标准库实现,无第三方依赖;渲染依赖 Graphviz(dot 可执行文件),
未安装时自动跳过渲染(仍产出 .dot,可用任意 Graphviz 渲染)。
- 文本层解析(不预编译):#if 0 内的引用、MCU/模拟器双端平台差异
引用均按“文件存在引用”计入,完整呈现项目级引用面。
- 第一方代码 = App/、Inc/、Src/、test/、simulator/(SDL2 与 build 除外)、
Drivers/BSP/(NET/lftpd 除外,其为移植的第三方组件);
其余(Lib/、Middlewares/、Drivers/CMSIS、HAL、lftpd、SDL2)= 第三方。
维护:
simulator/CMakeLists.txt 的 include_directories 若变化,请同步下方
SIM_INCLUDE_DIRS;Keil 工程包含路径变化则自动跟随(解析自 uvprojx)。
"""
from __future__ import annotations
import argparse
import datetime as _dt
import json
import os
import re
import shutil
import subprocess
import sys
# ---------------------------------------------------------------------------
# 常量:第一方判定 / 排除目录 / 模块与库划分
# ---------------------------------------------------------------------------
# 文件索引时整体跳过的目录(按相对路径前缀,'/' 分隔)
EXCLUDE_PREFIXES = ("MDK-ARM", "docs", "simulator/build")
# 按目录名跳过(任意层级)
EXCLUDE_DIRNAMES = (".git", ".venv", "node_modules")
# simulator/CMakeLists.txt 的 include_directories(相对仓库根;保持同步)
SIM_INCLUDE_DIRS = (
"simulator",
"simulator/sim",
"Lib/lvgl",
"Lib/lvgl/src",
"Drivers/BSP/UI",
"Drivers/BSP/NET",
"Drivers/BSP/NETCFG",
"App",
"App/log",
"Drivers/BSP",
"simulator/SDL2/SDL2-2.32.10/include",
)
# 模块级视图:第一方模块(长前缀优先匹配)
FP_MODULES = (
("App/log", "App/log"),
("App/task", "App/task"),
("App/util", "App/util"),
("App", "App"),
("test", "test"),
("simulator", "simulator"),
("Drivers/BSP/CH395F", "BSP/CH395F"),
("Drivers/BSP/GD5F2GQ5UE", "BSP/GD5F2GQ5UE"),
("Drivers/BSP/NETCFG", "BSP/NETCFG"),
("Drivers/BSP/NET", "BSP/NET"),
("Drivers/BSP/RS485", "BSP/RS485"),
("Drivers/BSP/SD2506", "BSP/SD2506"),
("Drivers/BSP/TPAFE5160", "BSP/TPAFE5160"),
("Drivers/BSP/UI", "BSP/UI"),
("Drivers/BSP", "BSP(公共)"),
# Src/ 与 Inc/ 为 CubeMX 配对生成(main.c↔main.h 等),模块级合并
("Src", "Src/Inc (CubeMX)"),
("Inc", "Src/Inc (CubeMX)"),
)
# 模块级视图:第三方库(长前缀优先匹配)
TP_LIBS = (
("Lib/lvgl", "LVGL"),
("Middlewares/Third_Party/FreeRTOS", "FreeRTOS"),
("Drivers/CMSIS", "CMSIS"),
("Drivers/STM32F4xx_HAL_Driver", "HAL"),
("Lib/FatFs", "FatFs"),
("Lib/dhara", "dhara"),
("Drivers/BSP/NET/lftpd", "lftpd"),
("simulator/SDL2", "SDL2"),
)
EXT_NODE = "外部/系统头文件"
# 颜色方案
C_FP, C_FP_BORDER = "#dbeafe", "#1d4ed8"
C_TP, C_TP_BORDER = "#f3f4f6", "#9ca3af"
C_EXT, C_EXT_BORDER = "#fef3c7", "#d97706"
E_FPFP, E_FPFP_W = "#2563eb", 1.0
E_FPTP, E_FPTP_W = "#94a3b8", 0.7
E_TPTP, E_TPTP_W = "#e2e8f0", 0.5
E_EXT, E_EXT_W = "#f59e0b", 0.6
# ---------------------------------------------------------------------------
# 读取 / 编码
# ---------------------------------------------------------------------------
def read_text(path: str) -> str:
"""按项目约定读文本:UTF-8(含 BOM)→ GBK → Latin-1 兜底。"""
with open(path, "rb") as f:
data = f.read()
if data.startswith(b"\xef\xbb\xbf"):
data = data[3:]
for enc in ("utf-8", "gbk", "latin-1"):
try:
return data.decode(enc)
except UnicodeDecodeError:
continue
return data.decode("utf-8", errors="replace") # 理论不可达
# ---------------------------------------------------------------------------
# 工程信息:包含路径 / 文件索引 / 分类
# ---------------------------------------------------------------------------
def classify(rel: str) -> str:
"""rel 为 '/' 分隔相对路径;返回 'fp'(第一方) 或 'tp'(第三方)。"""
if rel.startswith(("App/", "Inc/", "Src/", "test/")):
return "fp"
if rel.startswith("simulator/"):
if rel.startswith(("simulator/SDL2/", "simulator/build/")):
return "tp"
return "fp"
if rel.startswith("Drivers/BSP/"):
if rel.startswith("Drivers/BSP/NET/lftpd/"):
return "tp"
return "fp"
return "tp"
def load_include_dirs(root: str) -> list[str]:
"""合并 Keil 工程(uvprojx)与模拟器的包含目录,按序去重。"""
dirs: list[str] = []
seen: set[str] = set()
def add(p: str) -> None:
ap = os.path.normpath(p)
if ap not in seen and os.path.isdir(ap):
seen.add(ap)
dirs.append(ap)
uvprojx = os.path.join(root, "MDK-ARM", "STM32F407-Demo.uvprojx")
if os.path.isfile(uvprojx):
text = read_text(uvprojx)
for m in re.finditer(r"<IncludePath>(.*?)</IncludePath>", text, re.S):
for part in m.group(1).replace("\\", "/").split(";"):
part = part.strip()
if part:
add(os.path.join(root, "MDK-ARM", part))
for part in SIM_INCLUDE_DIRS:
add(os.path.join(root, *part.split("/")))
return dirs
def build_index(root: str) -> list[tuple[str, str]]:
"""收集仓库内全部 .c/.h 文件 → [(绝对路径, 相对路径)]。"""
out: list[tuple[str, str]] = []
for dirpath, dirnames, filenames in os.walk(root):
rel_dir = os.path.relpath(dirpath, root).replace(os.sep, "/")
if rel_dir == ".":
rel_dir = ""
if any(seg in EXCLUDE_DIRNAMES for seg in rel_dir.split("/")):
dirnames[:] = []
continue
if any(rel_dir == p or rel_dir.startswith(p + "/") for p in EXCLUDE_PREFIXES):
dirnames[:] = []
continue
for fn in sorted(filenames):
if fn.lower().endswith((".c", ".h")):
out.append((os.path.join(dirpath, fn),
f"{rel_dir}/{fn}" if rel_dir else fn))
return out
class Resolver:
"""#include 名称 → 仓库内文件 的解析器(顺序与编译器一致)。
仅按“文件目录 + 包含路径”解析(编译器实际使用的搜索顺序)。
刻意不做“基名全库兜底”:仓库内恰好存在同名的系统头文件
(如 rapidjson 自带的 msinttypes/stdint.h)时会把 <stdint.h>
之类误连到库内部文件,产生虚假引用边。解析不到的 include
一律归入“外部/系统头文件”,与编译器的系统目录行为一致。
"""
def __init__(self, root: str, files: list[tuple[str, str]], inc_dirs: list[str]):
self.root = root
self.inc_dirs = inc_dirs
# (包含目录, 相对名小写) → 绝对路径
self.by_increl: dict[tuple[str, str], str] = {}
for d in inc_dirs:
dl = d.lower()
for ap, _ in files:
apl = ap.lower()
if apl.startswith(dl + os.sep):
rel = ap[len(d):].lstrip(os.sep).replace(os.sep, "/")
key = (d, rel.lower())
self.by_increl.setdefault(key, ap)
def resolve(self, name: str, from_file: str, quoted: bool) -> str | None:
name = name.strip()
if not name:
return None
fname = name.replace("/", os.sep)
# 1) 所在文件目录("..." 标准行为;<...> 亦允许相对,如 "../x.h")
if os.path.isabs(name):
return None
cand = os.path.normpath(os.path.join(os.path.dirname(from_file), fname))
if os.path.isfile(cand):
return cand
# 2) 包含路径顺序查找(精确相对名;Windows 不区分大小写)
for d in self.inc_dirs:
cand = os.path.normpath(os.path.join(d, fname))
if os.path.isfile(cand):
return cand
hit = self.by_increl.get((d, name.lower()))
if hit:
return hit
# 3) 仓库根
cand = os.path.normpath(os.path.join(self.root, fname))
if os.path.isfile(cand):
return cand
return None
# ---------------------------------------------------------------------------
# #include 解析
# ---------------------------------------------------------------------------
INC_RE = re.compile(r'^\s*#\s*include\s*(?:"(?P<q>[^"\r\n]*)"|<(?P<a>[^>\r\n]+)>)')
def parse_includes(path: str) -> list[tuple[str, bool, int]]:
"""返回 [(include名, 是否引号式, 行号)];处理行尾反斜杠续行。"""
text = read_text(path)
lines = text.splitlines()
n = len(lines)
out: list[tuple[str, bool, int]] = []
i = 0
while i < n:
line = lines[i]
if line.lstrip()[:1] != "#":
i += 1
continue
m = INC_RE.match(line)
if not m:
i += 1
continue
# 续行拼接后再匹配
logical, j = line, i
while logical.rstrip().endswith("\\") and j + 1 < n:
logical = logical.rstrip()[:-1] + lines[j + 1]
j += 1
m = INC_RE.match(logical)
if m:
quoted = m.group("q") is not None
name = (m.group("q") if quoted else m.group("a") or "").strip()
if name:
out.append((name, quoted, i + 1))
i = j + 1
return out
# ---------------------------------------------------------------------------
# DOT 输出
# ---------------------------------------------------------------------------
def dot_escape(s: str) -> str:
return s.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
def file_module(rel: str) -> str:
for prefix, label in FP_MODULES:
if rel == prefix or rel.startswith(prefix + "/"):
return label
return "其他(第一方)"
def file_lib(rel: str) -> str:
for prefix, label in TP_LIBS:
if rel == prefix or rel.startswith(prefix + "/"):
return label
return "其他(第三方)"
class Graph:
def __init__(self) -> None:
self.nodes: dict[str, dict] = {} # rel -> {cls, label, ...}
self.edges: dict[tuple[str, str], dict] = {} # (from,to) -> {kind,...}
self.edge_list: list[dict] = [] # JSON 用(含 include 明细)
def add_node(self, rel: str, cls: str) -> None:
if rel not in self.nodes:
self.nodes[rel] = {"path": rel, "class": cls}
def add_edge(self, src: str, dst: str, kind: str, include: str | None = None) -> None:
key = (src, dst)
if key not in self.edges:
self.edges[key] = {"kind": kind}
self.edge_list.append({"from": src, "to": dst, "kind": kind, "include": include})
# ---- 全量文件级视图 ----
def emit_dot_full(self, title: str, edge_scope: str) -> str:
# project 模式:只显示第一方文件发起的边(第一方 → 任意),
# 隐藏第三方内部引用网(tp→tp / tp→外部),节点随之收敛。
fp_nodes = {r for r, nd in self.nodes.items() if nd["class"] == "fp"}
if edge_scope == "project":
vis_edges = {k: v for k, v in self.edges.items() if k[0] in fp_nodes}
vis_nodes = set(fp_nodes) | {d for (_, d) in vis_edges}
else:
vis_edges, vis_nodes = self.edges, set(self.nodes)
L: list[str] = []
L.append("digraph refgraph_full {")
L.append(
' graph [rankdir=LR, bgcolor="white", labelloc=t, '
f'label="{dot_escape(title)}", fontname="Microsoft YaHei", fontsize=18];'
)
L.append(' node [fontname="Microsoft YaHei", fontsize=10, shape=box, style="rounded,filled"];')
L.append(" edge [fontname=\"Microsoft YaHei\", fontsize=8];")
for rel in sorted(vis_nodes):
nd = self.nodes[rel]
cls = nd["class"]
if cls == "fp":
fill, border = C_FP, C_FP_BORDER
elif cls == "ext":
fill, border = C_EXT, C_EXT_BORDER
L.append(f' "{dot_escape(EXT_NODE)}" [shape=ellipse, fillcolor="{fill}", color="{border}", fontsize=12];')
continue
else:
fill, border = C_TP, C_TP_BORDER
L.append(f' "{dot_escape(rel)}" [fillcolor="{fill}", color="{border}"];')
# 边
for (src, dst), ed in sorted(vis_edges.items()):
kind = ed["kind"]
if kind == "fp-fp":
spec = f'color="{E_FPFP}", penwidth={E_FPFP_W}'
elif kind == "fp-tp":
spec = f'color="{E_FPTP}", penwidth={E_FPTP_W}'
elif kind == "fp-ext":
spec = f'color="{E_EXT}", penwidth={E_EXT_W}, style=dashed'
elif kind == "tp-tp":
spec = f'color="{E_TPTP}", penwidth={E_TPTP_W}, style=dotted'
else: # tp-ext
spec = f'color="{E_EXT}", penwidth={E_EXT_W * 0.7}, style=dashed'
dst_q = dot_escape(EXT_NODE) if dst == EXT_NODE else dot_escape(dst)
L.append(f' "{dot_escape(src)}" -> "{dst_q}" [{spec}];')
L.append("}")
return "\n".join(L) + "\n"
# ---- 模块级视图 ----
def emit_dot_module(self, title: str) -> str:
mod_edges: dict[tuple[str, str], dict] = {}
mod_nodes: dict[str, str] = {} # label -> cls
for rel, nd in self.nodes.items():
if nd["class"] == "fp":
mod_nodes.setdefault(file_module(rel), "fp")
elif nd["class"] == "tp":
mod_nodes.setdefault(file_lib(rel), "tp")
if EXT_NODE in self.nodes:
mod_nodes[EXT_NODE] = "ext"
for (src, dst), ed in self.edges.items():
if dst == EXT_NODE:
msrc = file_module(src) if self.nodes[src]["class"] == "fp" else file_lib(src)
key = (msrc, EXT_NODE)
else:
sc = self.nodes[src]["class"]
msrc = file_module(src) if sc == "fp" else file_lib(src)
mc = self.nodes[dst]["class"]
mdst = file_module(dst) if mc == "fp" else file_lib(dst)
key = (msrc, mdst)
if key[0] == key[1]:
continue
e = mod_edges.setdefault(key, {"n": 0, "kind": ed["kind"]})
e["n"] += 1
L: list[str] = []
L.append("digraph refgraph_module {")
L.append(
' graph [rankdir=LR, bgcolor="white", labelloc=t, '
f'label="{dot_escape(title)}", fontname="Microsoft YaHei", fontsize=18];'
)
L.append(' node [fontname="Microsoft YaHei", fontsize=12, shape=box, style="rounded,filled"];')
L.append(" edge [fontname=\"Microsoft YaHei\", fontsize=10, fontcolor=\"#334155\"];")
for label, cls in sorted(mod_nodes.items()):
if cls == "fp":
fill, border = C_FP, C_FP_BORDER
elif cls == "ext":
fill, border = C_EXT, C_EXT_BORDER
L.append(f' "{dot_escape(label)}" [shape=ellipse, fillcolor="{fill}", color="{border}"];')
continue
else:
fill, border = C_TP, C_TP_BORDER
L.append(f' "{dot_escape(label)}" [fillcolor="{fill}", color="{border}"];')
for (s, d), e in sorted(mod_edges.items()):
strong = e["kind"] in ("fp-fp", "fp-tp")
color = "#2563eb" if strong else "#94a3b8"
w = 1.2 if strong else 0.7
lbl = f"×{e['n']}"
ds = dot_escape(EXT_NODE) if d == EXT_NODE else dot_escape(d)
L.append(f' "{dot_escape(s)}" -> "{ds}" [color="{color}", penwidth={w}, label="{dot_escape(lbl)}"];')
L.append("}")
return "\n".join(L) + "\n"
# ---------------------------------------------------------------------------
# Graphviz 渲染
# ---------------------------------------------------------------------------
def find_dot() -> str | None:
"""定位 Graphviz dot:DTU_GRAPHVIZ_DOT(显式)→ PATH。
显式变量已设置则严格使用(无效立即报错,不静默回退);未设置时只查
PATH,未找到返回 None 并降级为"只生成 .dot、不渲染"(保持既有语义)。
见 docs/环境与部署/本地工具路径环境变量方案.md §3。
"""
explicit = os.environ.get("DTU_GRAPHVIZ_DOT", "").strip()
if explicit:
if not os.path.isfile(explicit):
sys.exit("ERROR: DTU_GRAPHVIZ_DOT 已设置但不存在: {}(清除该变量可改用 PATH 查找)".format(explicit))
return explicit
return shutil.which("dot")
def render(dot: str, dotfile: str, outfiles: dict[str, str]) -> bool:
"""outfiles: {格式: 输出路径},如 {'svg': ..., 'png': ...}"""
ok = True
for fmt, outp in outfiles.items():
argv = [dot, "-T" + fmt]
if fmt == "png":
argv += ["-Gdpi=130"]
argv += [dotfile, "-o", outp]
r = subprocess.run(argv, capture_output=True, text=True)
if r.returncode != 0:
ok = False
print(f"[refgraph] 渲染失败 {fmt}: {r.stderr.strip()[:300]}", file=sys.stderr)
return ok
# ---------------------------------------------------------------------------
# 主流程
# ---------------------------------------------------------------------------
def build(root: str) -> tuple[Graph, dict]:
files = build_index(root)
inc_dirs = load_include_dirs(root)
resolver = Resolver(root, files, inc_dirs)
g = Graph()
unresolved: dict[str, list[str]] = {}
stats = {
"files_indexed": len(files),
"includes_parsed": 0,
"resolved": 0,
"unresolved": 0,
"nodes_fp": 0,
"nodes_tp": 0,
"ext_names": 0,
}
ext_names: set[str] = set()
# 先登记全部第一方文件为节点(含无引用的孤立文件)
for ap, rel in files:
if classify(rel) == "fp":
g.add_node(rel, "fp")
stats["nodes_fp"] += 1
for ap, rel in files:
src_cls = classify(rel)
for name, quoted, lineno in parse_includes(ap):
stats["includes_parsed"] += 1
target = resolver.resolve(name, ap, quoted)
if target:
# 相对路径
trel = os.path.relpath(target, root).replace(os.sep, "/")
tcls = classify(trel)
g.add_node(rel, src_cls)
g.add_node(trel, tcls)
kind = ("fp-fp" if (src_cls == "fp" and tcls == "fp")
else "fp-tp" if (src_cls == "fp" and tcls == "tp")
else "tp-tp")
g.add_edge(rel, trel, kind, name)
stats["resolved"] += 1
else:
stats["unresolved"] += 1
ext_names.add(name)
g.add_node(rel, src_cls)
g.add_node(EXT_NODE, "ext")
g.add_edge(rel, EXT_NODE, "fp-ext" if src_cls == "fp" else "tp-ext", name)
unresolved.setdefault(rel, []).append(name)
# 节点计数修正(tp 节点 = 实际出现的第三方文件节点)
stats["nodes_tp"] = sum(1 for nd in g.nodes.values()
if nd["class"] == "tp") if EXT_NODE not in g.nodes else \
sum(1 for r, nd in g.nodes.items() if nd["class"] == "tp")
stats["ext_names"] = len(ext_names)
stats["include_dirs"] = [os.path.relpath(d, root).replace(os.sep, "/") for d in inc_dirs]
meta = {
"unresolved_by_file": {k: sorted(set(v)) for k, v in sorted(unresolved.items())},
"ext_names_sorted": sorted(ext_names),
}
return g, {**stats, **meta}
def main() -> int:
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
here = os.path.dirname(os.path.abspath(__file__))
ap = argparse.ArgumentParser(description="STM32F4-Base 文件引用关系图生成器")
ap.add_argument("--root", default=os.path.dirname(os.path.dirname(here)),
help="仓库根目录(默认:脚本上两级目录)")
ap.add_argument("--outdir", default=None, help="产物目录(默认 <root>/docs/images)")
ap.add_argument("--views", default="full,module", help="full / module,逗号分隔")
ap.add_argument("--edge-scope", choices=("project", "all"), default="project",
help="project=仅第一方发起的引用边(默认);all=含第三方内部引用边")
ap.add_argument("--no-render", action="store_true", help="不调用 Graphviz 渲染")
ap.add_argument("--quiet", action="store_true", help="精简输出")
args = ap.parse_args()
root = os.path.abspath(args.root)
outdir = os.path.abspath(args.outdir or os.path.join(root, "docs", "images"))
os.makedirs(outdir, exist_ok=True)
views = [v.strip() for v in args.views.split(",") if v.strip()]
g, stats = build(root)
title = ("STM32F4-Base 项目文件引用关系图(项目代码视图)"
if args.edge_scope == "project"
else "STM32F4-Base 项目文件引用关系图(全量视图)")
written: list[str] = []
if "full" in views:
dot_full = g.emit_dot_full(title, args.edge_scope)
pf = os.path.join(outdir, "引用关系图.dot")
with open(pf, "w", encoding="utf-8", newline="\n") as f:
f.write(dot_full)
written.append(pf)
if "module" in views:
dm = g.emit_dot_module("STM32F4-Base 模块引用概览")
pm = os.path.join(outdir, "引用关系图_模块.dot")
with open(pm, "w", encoding="utf-8", newline="\n") as f:
f.write(dm)
written.append(pm)
# JSON 数据(末尾补单个换行:与 pre-commit end-of-file-fixer 口径一致,
# 避免每次重生成都触发该钩子的"首次修复"抖动,模式同 make_font.py)
pj = os.path.join(outdir, "引用关系图.json")
payload = {
"title": title,
"nodes": sorted(g.nodes.values(), key=lambda x: x["path"]),
"edges": g.edge_list,
"unresolved": stats["unresolved_by_file"],
"stats": {k: v for k, v in stats.items()
if k not in ("unresolved_by_file", "ext_names_sorted", "include_dirs")},
"include_dirs": stats["include_dirs"],
}
with open(pj, "w", encoding="utf-8", newline="\n") as f:
json.dump(payload, f, ensure_ascii=False, indent=1)
f.write("\n")
written.append(pj)
# 渲染
if not args.no_render:
dot = find_dot()
if dot is None:
print("[refgraph] 未找到 Graphviz(dot),跳过渲染;.dot 已生成,"
"安装 Graphviz 后重跑即可出图。", file=sys.stderr)
else:
if "full" in views:
render(dot, written[0] if written and written[0].endswith(".dot") else
os.path.join(outdir, "引用关系图.dot"),
{"svg": os.path.join(outdir, "引用关系图.svg"),
"png": os.path.join(outdir, "引用关系图.png")})
if "module" in views:
pm = os.path.join(outdir, "引用关系图_模块.dot")
render(dot, pm, {"svg": os.path.join(outdir, "引用关系图_模块.svg")})
# 报告
n_edges = len(g.edges)
k = lambda s: print(s) if not args.quiet else None
k("=" * 62)
k("STM32F4-Base 文件引用关系图 生成报告")
k(f" 生成时间 : {_dt.datetime.now():%Y-%m-%d %H:%M:%S}")
k(f" 索引文件 : {stats['files_indexed']} 个 .c/.h")
k(f" 解析 include : {stats['includes_parsed']} 条(解析成功 {stats['resolved']},"
f"外部/系统 {stats['unresolved']})")
k(f" 节点 : 第一方 {stats['nodes_fp']} + 第三方 {stats['nodes_tp']}"
+ (f" + 外部聚合 1" if EXT_NODE in g.nodes else ""))
k(f" 边(去重) : {n_edges} 条(含边明细 {len(g.edge_list)} 条)")
k(f" 外部头文件 : {stats['ext_names']} 种,如 "
+ ", ".join(stats["ext_names_sorted"][:8]))
k("-" * 62)
k(" 产物:")
for w in written:
k(f" {os.path.relpath(w, root)}")
if not args.no_render:
for name in ("引用关系图.svg", "引用关系图.png", "引用关系图_模块.svg"):
p = os.path.join(outdir, name)
if os.path.isfile(p):
k(f" {os.path.relpath(p, root)} ({os.path.getsize(p)//1024} KB)")
k("=" * 62)
# 被引用最多的第一方文件 Top10(概览用)
ref_cnt: dict[str, int] = {}
for (s, d) in g.edges:
if g.nodes.get(d, {}).get("class") == "fp":
ref_cnt[d] = ref_cnt.get(d, 0) + 1
top = sorted(ref_cnt.items(), key=lambda x: -x[1])[:10]
if top and not args.quiet:
k(" 被引用最多的第一方文件(Top10):")
for rel, c in top:
k(f" {c:4d} {rel}")
k("=" * 62)
return 0
if __name__ == "__main__":
raise SystemExit(main())