mirror of
https://github.com/qdrant/qdrant.git
synced 2026-09-21 13:37:46 -05:00
Add tools/callgraph: interactive call-graph reports via rust-analyzer (#10422)
* Add tools/callgraph: interactive call-graph reports via rust-analyzer Generates a self-contained HTML report for one function: pan/zoom graphviz graph of callers and callees, per-node docs and source snippets, exact call sites with context, GitHub/editor links. - rust-analyzer call hierarchy over LSP gives resolved (not textual) edges; trait declarations and impls are bridged via goto-declaration / goto-implementation so dispatch through a trait doesn't dead-end the walk - test code excluded by running rust-analyzer with cfg(test) disabled, plus path filters for tests/, benches/, examples/ targets - no dependencies beyond rust-analyzer and graphviz on PATH Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RgxVoa6hfvgB7r6FVbcGmg * Add screenshot to tools/callgraph README Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RgxVoa6hfvgB7r6FVbcGmg --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
7a26b7117c
commit
c6d8c8f347
@@ -0,0 +1,40 @@
|
||||
# callgraph
|
||||
|
||||
Interactive call-graph reports for one function of the Qdrant workspace,
|
||||
viewable in a browser: pan/zoom graph, per-node docs and source snippets,
|
||||
exact call sites, GitHub/editor links.
|
||||
|
||||

|
||||
|
||||
```bash
|
||||
tools/callgraph/callgraph.py read_bytes_async # by name
|
||||
tools/callgraph/callgraph.py universal_io::traits::read_bytes_async # :: segments disambiguate
|
||||
tools/callgraph/callgraph.py lib/common/common/src/universal_io/traits/read.rs:113
|
||||
```
|
||||
|
||||
Prints a `file://…/target/callgraph/<fn>.html` link when done. Requires
|
||||
`rust-analyzer` and graphviz `dot` on `PATH`; no Python dependencies.
|
||||
|
||||
## How it works
|
||||
|
||||
- rust-analyzer's call hierarchy over LSP provides resolved (not textual)
|
||||
caller/callee edges; both directions are collected in one run.
|
||||
- Trait declarations and their impls are bridged via goto-implementation /
|
||||
goto-declaration, so a walk doesn't dead-end when a call dispatches through
|
||||
a trait (dashed edges in the graph).
|
||||
- Test code is excluded for real: rust-analyzer runs with `cfg(test)` off,
|
||||
and `tests/`, `benches/`, `examples/` targets are filtered by path.
|
||||
- Layout by graphviz at generation time; the report itself is one
|
||||
self-contained HTML file with no external resources.
|
||||
|
||||
## Options
|
||||
|
||||
- `--depth N` — call hops from the root (default 4)
|
||||
- `--max-nodes N` — per-view node cap (default 250)
|
||||
- `--out PATH` — output HTML path
|
||||
|
||||
## Performance note
|
||||
|
||||
Each run cold-starts rust-analyzer, which re-indexes the workspace (~3 min).
|
||||
If runs become frequent, the upgrade path is a `--serve` mode that keeps one
|
||||
rust-analyzer instance alive between reports.
|
||||
Executable
+489
@@ -0,0 +1,489 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Interactive call-graph reports for the Qdrant workspace.
|
||||
|
||||
Drives rust-analyzer's call hierarchy to build caller/callee graphs for one
|
||||
function, bridges trait declarations <-> implementations (so dispatch through
|
||||
a trait doesn't dead-end the walk), lays the graphs out with graphviz, and
|
||||
writes a self-contained interactive HTML report: pan/zoom graph, per-node
|
||||
source snippets and docs, exact call sites, GitHub/editor links.
|
||||
|
||||
Usage:
|
||||
tools/callgraph/callgraph.py <function-name>
|
||||
tools/callgraph/callgraph.py <module::path::function>
|
||||
tools/callgraph/callgraph.py <path/to/file.rs>:<line>
|
||||
|
||||
Test code is excluded: rust-analyzer runs with cfg(test) disabled, and the
|
||||
tests/, benches/, examples/ cargo targets are filtered by path.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
TOOL_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT = os.path.dirname(os.path.dirname(TOOL_DIR))
|
||||
EXCLUDE_PATH_PARTS = ("/tests/", "/benches/", "/examples/", "/target/", "/edge/publish/")
|
||||
SNIPPET_MAX_LINES = 80
|
||||
PALETTE = [
|
||||
"#dbeafe", "#dcfce7", "#fef3c7", "#fce7f3", "#e0e7ff",
|
||||
"#ccfbf1", "#fee2e2", "#f3e8ff", "#ede9d5", "#f1f5f9",
|
||||
]
|
||||
|
||||
|
||||
def log(msg):
|
||||
print(msg, file=sys.stderr)
|
||||
|
||||
|
||||
class Lsp:
|
||||
def __init__(self):
|
||||
self.proc = subprocess.Popen(
|
||||
["rust-analyzer"],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
self.next_id = 0
|
||||
self.quiescent = False
|
||||
|
||||
def send(self, msg):
|
||||
data = json.dumps(msg).encode()
|
||||
self.proc.stdin.write(b"Content-Length: %d\r\n\r\n%s" % (len(data), data))
|
||||
self.proc.stdin.flush()
|
||||
|
||||
def read_msg(self):
|
||||
length = None
|
||||
while True:
|
||||
line = self.proc.stdout.readline()
|
||||
if not line:
|
||||
sys.exit("rust-analyzer exited unexpectedly")
|
||||
if line.startswith(b"Content-Length:"):
|
||||
length = int(line.split(b":")[1])
|
||||
if line == b"\r\n":
|
||||
break
|
||||
return json.loads(self.proc.stdout.read(length))
|
||||
|
||||
def handle(self, msg):
|
||||
"""React to server-initiated traffic; return True if it was consumed."""
|
||||
if msg.get("method") == "experimental/serverStatus":
|
||||
self.quiescent = msg["params"].get("quiescent", False)
|
||||
return True
|
||||
if "id" in msg and "method" in msg: # server request: give an empty answer
|
||||
if msg["method"] == "workspace/configuration":
|
||||
result = [None] * len(msg["params"]["items"])
|
||||
else:
|
||||
result = None
|
||||
self.send({"jsonrpc": "2.0", "id": msg["id"], "result": result})
|
||||
return True
|
||||
return "method" in msg # other notifications
|
||||
|
||||
def request(self, method, params, default=None):
|
||||
self.next_id += 1
|
||||
rid = self.next_id
|
||||
self.send({"jsonrpc": "2.0", "id": rid, "method": method, "params": params})
|
||||
while True:
|
||||
msg = self.read_msg()
|
||||
if self.handle(msg):
|
||||
continue
|
||||
if msg.get("id") == rid:
|
||||
if "error" in msg:
|
||||
return default
|
||||
return msg["result"] if msg["result"] is not None else default
|
||||
|
||||
def notify(self, method, params):
|
||||
self.send({"jsonrpc": "2.0", "method": method, "params": params})
|
||||
|
||||
def start(self):
|
||||
self.request(
|
||||
"initialize",
|
||||
{
|
||||
"processId": os.getpid(),
|
||||
"rootUri": "file://" + ROOT,
|
||||
"capabilities": {
|
||||
"textDocument": {"hover": {"contentFormat": ["markdown"]}},
|
||||
"experimental": {"serverStatusNotification": True},
|
||||
},
|
||||
# analyze without cfg(test): test modules become inactive code,
|
||||
# so they can never appear in the call hierarchy
|
||||
"initializationOptions": {"cfg": {"setTest": False}},
|
||||
"clientInfo": {"name": "qdrant-callgraph"},
|
||||
},
|
||||
)
|
||||
self.notify("initialized", {})
|
||||
|
||||
def wait_quiescent(self, timeout=900):
|
||||
log("waiting for rust-analyzer to index the workspace (~3 min cold)...")
|
||||
deadline = time.time() + timeout
|
||||
while not self.quiescent:
|
||||
if time.time() > deadline:
|
||||
sys.exit("timed out waiting for rust-analyzer indexing")
|
||||
self.handle(self.read_msg())
|
||||
log("indexed.")
|
||||
|
||||
|
||||
def find_function(target):
|
||||
"""Locate `fn name` by name, optionally qualified with :: module hints."""
|
||||
*hints, name = target.split("::")
|
||||
out = subprocess.run(
|
||||
["grep", "-rn", "--include=*.rs", "-E", r"\bfn %s\b" % re.escape(name), "lib", "src"],
|
||||
cwd=ROOT, capture_output=True, text=True,
|
||||
).stdout.splitlines()
|
||||
hits = []
|
||||
for line in out:
|
||||
path, lineno, text = line.split(":", 2)
|
||||
if any(p in "/" + path + "/" for p in EXCLUDE_PATH_PARTS):
|
||||
continue
|
||||
score = sum(1 for h in hints if h in path.split("/"))
|
||||
hits.append((score, path, int(lineno), text))
|
||||
if not hits:
|
||||
sys.exit(f"no definition of `fn {name}` found")
|
||||
best = max(score for score, *_ in hits)
|
||||
hits = [h for h in hits if h[0] == best]
|
||||
if len(hits) > 1:
|
||||
log(f"`{target}` is ambiguous, use FILE:LINE:")
|
||||
for _, path, lineno, text in hits:
|
||||
log(f" {path}:{lineno} {text.strip()}")
|
||||
sys.exit(1)
|
||||
_, path, lineno, text = hits[0]
|
||||
return os.path.join(ROOT, path), lineno - 1, text.index(name, text.index("fn "))
|
||||
|
||||
|
||||
def position_in_file(spec):
|
||||
path, lineno = spec.rsplit(":", 1)
|
||||
path = os.path.abspath(path)
|
||||
lineno = int(lineno) - 1
|
||||
line = open(path).read().splitlines()[lineno]
|
||||
m = re.search(r"\bfn\s+(\w+)", line)
|
||||
if not m:
|
||||
sys.exit(f"no `fn` on line {lineno + 1} of {path}")
|
||||
return path, lineno, m.start(1)
|
||||
|
||||
|
||||
def keep(path):
|
||||
# cfg(test) code is already invisible (cfg.setTest=false); what remains to
|
||||
# exclude are the directory-defined cargo targets and generated code.
|
||||
return path.startswith(ROOT) and not any(p in path for p in EXCLUDE_PATH_PARTS)
|
||||
|
||||
|
||||
def norm_locations(resp):
|
||||
"""Normalize a goto-style response (Location | Location[] | LocationLink[])."""
|
||||
if resp is None:
|
||||
return []
|
||||
if isinstance(resp, dict):
|
||||
resp = [resp]
|
||||
out = []
|
||||
for loc in resp:
|
||||
if "targetUri" in loc:
|
||||
out.append((loc["targetUri"], loc["targetSelectionRange"]["start"]))
|
||||
else:
|
||||
out.append((loc["uri"], loc["range"]["start"]))
|
||||
return out
|
||||
|
||||
|
||||
class Collector:
|
||||
def __init__(self, lsp):
|
||||
self.lsp = lsp
|
||||
self.nodes = {} # id -> {item, ...}
|
||||
self.ids = {} # (uri, line, name) -> id
|
||||
self.file_cache = {}
|
||||
|
||||
def node_id(self, item):
|
||||
key = (item["uri"], item["selectionRange"]["start"]["line"], item["name"])
|
||||
if key not in self.ids:
|
||||
self.ids[key] = f"n{len(self.ids)}"
|
||||
self.nodes[self.ids[key]] = {"item": item}
|
||||
return self.ids[key]
|
||||
|
||||
def prepare(self, uri, pos):
|
||||
items = self.lsp.request(
|
||||
"textDocument/prepareCallHierarchy",
|
||||
{"textDocument": {"uri": uri}, "position": pos},
|
||||
default=[],
|
||||
)
|
||||
return items[0] if items else None
|
||||
|
||||
def lines(self, path):
|
||||
if path not in self.file_cache:
|
||||
try:
|
||||
self.file_cache[path] = open(path).read().splitlines()
|
||||
except OSError:
|
||||
self.file_cache[path] = []
|
||||
return self.file_cache[path]
|
||||
|
||||
def goto(self, item, method):
|
||||
"""Resolve a goto-style request into call-hierarchy items (self excluded)."""
|
||||
uri, pos = item["uri"], item["selectionRange"]["start"]
|
||||
peers = []
|
||||
for loc_uri, loc_pos in norm_locations(
|
||||
self.lsp.request(method, {"textDocument": {"uri": uri}, "position": pos})
|
||||
):
|
||||
if loc_uri == uri and loc_pos["line"] == pos["line"]:
|
||||
continue
|
||||
peer = self.prepare(loc_uri, loc_pos)
|
||||
if peer:
|
||||
peers.append(peer)
|
||||
return peers
|
||||
|
||||
def bridge(self, item, callers, is_root):
|
||||
"""Trait decl <-> impl links relevant to the walk direction.
|
||||
|
||||
Note: goto-implementation answers from impl sites too (listing all
|
||||
sibling impls), so it must only be asked on genuine declarations —
|
||||
sibling impls are runtime alternatives, not part of this call graph.
|
||||
Returns [(decl_item, impl_item, item_to_enqueue)].
|
||||
"""
|
||||
decls = self.goto(item, "textDocument/declaration")
|
||||
links = []
|
||||
if callers:
|
||||
# calls dispatched through the trait reach this impl: walk the decl up
|
||||
for decl in decls:
|
||||
links.append((decl, item, decl))
|
||||
if is_root and not decls:
|
||||
# the root is a decl: direct calls to any impl are usages too
|
||||
for impl in self.goto(item, "textDocument/implementation"):
|
||||
links.append((item, impl, impl))
|
||||
elif not decls:
|
||||
# a call lands on a decl: the runtime target is one of its impls
|
||||
for impl in self.goto(item, "textDocument/implementation"):
|
||||
links.append((item, impl, impl))
|
||||
return links
|
||||
|
||||
def collect_view(self, root_item, direction, depth, max_nodes):
|
||||
callers = direction == "callers"
|
||||
method = "callHierarchy/incomingCalls" if callers else "callHierarchy/outgoingCalls"
|
||||
peer_key = "from" if callers else "to"
|
||||
root_id = self.node_id(root_item)
|
||||
edges = {} # (caller_id, callee_id, kind) -> [ranges in caller's file]
|
||||
in_view = {root_id}
|
||||
truncated = set()
|
||||
expanded = set()
|
||||
queue = [(root_item, 0)]
|
||||
while queue:
|
||||
item, d = queue.pop(0)
|
||||
iid = self.node_id(item)
|
||||
if iid in expanded:
|
||||
continue
|
||||
expanded.add(iid)
|
||||
if d >= depth or len(in_view) >= max_nodes:
|
||||
truncated.add(iid)
|
||||
continue
|
||||
for call in self.lsp.request(method, {"item": item}, default=[]):
|
||||
peer = call[peer_key]
|
||||
if peer["kind"] not in (6, 12): # 6 = Method, 12 = Function
|
||||
continue
|
||||
if not keep(peer["uri"].removeprefix("file://")):
|
||||
continue
|
||||
pid = self.node_id(peer)
|
||||
edge = (pid, iid, "call") if callers else (iid, pid, "call")
|
||||
edges.setdefault(edge, []).extend(call.get("fromRanges", []))
|
||||
in_view.add(pid)
|
||||
queue.append((peer, d + 1))
|
||||
for decl, impl, enqueue in self.bridge(item, callers, iid == root_id):
|
||||
if not (keep(decl["uri"].removeprefix("file://")) and keep(impl["uri"].removeprefix("file://"))):
|
||||
continue
|
||||
edges.setdefault((self.node_id(decl), self.node_id(impl), "impl"), [])
|
||||
in_view.update((self.node_id(decl), self.node_id(impl)))
|
||||
# a bridge hop is free: it is the same logical function
|
||||
queue.append((enqueue, d))
|
||||
log(f" {direction}: {len(in_view)} nodes, {len(edges)} edges")
|
||||
return {"edges": edges, "in_view": in_view, "truncated": truncated}
|
||||
|
||||
def enrich(self, node_id):
|
||||
node = self.nodes[node_id]
|
||||
item = node["item"]
|
||||
path = item["uri"].removeprefix("file://")
|
||||
rel = os.path.relpath(path, ROOT)
|
||||
start = item["range"]["start"]["line"]
|
||||
end = item["range"]["end"]["line"]
|
||||
lines = self.lines(path)[start : end + 1]
|
||||
clipped = len(lines) > SNIPPET_MAX_LINES
|
||||
hover = self.lsp.request(
|
||||
"textDocument/hover",
|
||||
{"textDocument": {"uri": item["uri"]}, "position": item["selectionRange"]["start"]},
|
||||
default={},
|
||||
)
|
||||
contents = hover.get("contents", "")
|
||||
if isinstance(contents, dict):
|
||||
contents = contents.get("value", "")
|
||||
elif isinstance(contents, list):
|
||||
contents = "\n\n".join(c if isinstance(c, str) else c.get("value", "") for c in contents)
|
||||
node.update(
|
||||
name=item["name"],
|
||||
path=rel,
|
||||
line=item["selectionRange"]["start"]["line"] + 1,
|
||||
crate=crate_of(rel),
|
||||
detail=item.get("detail", ""),
|
||||
hover=contents,
|
||||
snippet={"start": start + 1, "lines": lines[:SNIPPET_MAX_LINES], "clipped": clipped},
|
||||
)
|
||||
|
||||
def call_sites(self, caller_id, ranges):
|
||||
path = self.nodes[caller_id]["item"]["uri"].removeprefix("file://")
|
||||
lines = self.lines(path)
|
||||
sites, seen = [], set()
|
||||
for rng in ranges:
|
||||
line = rng["start"]["line"]
|
||||
if line in seen:
|
||||
continue
|
||||
seen.add(line)
|
||||
lo, hi = max(0, line - 2), min(len(lines), line + 3)
|
||||
sites.append({
|
||||
"line": line + 1,
|
||||
"context_start": lo + 1,
|
||||
"lines": lines[lo:hi],
|
||||
})
|
||||
sites.sort(key=lambda s: s["line"])
|
||||
return sites
|
||||
|
||||
|
||||
def crate_of(rel):
|
||||
if "/src/" in rel:
|
||||
return rel.split("/src/")[0].split("/")[-1]
|
||||
return "qdrant"
|
||||
|
||||
|
||||
def short_path(rel):
|
||||
tail = rel.split("/src/")[-1]
|
||||
parts = tail.split("/")
|
||||
return "/".join(parts[-2:]) if len(parts) > 1 else tail
|
||||
|
||||
|
||||
def render_dot(nodes, view, root_id, crate_colors):
|
||||
lines = [
|
||||
"digraph callgraph {",
|
||||
' rankdir=LR; splines=true; ranksep=0.7; nodesep=0.25; pad=0.3;',
|
||||
' node [shape=box, style="rounded,filled", fontname="Helvetica", fontsize=11,'
|
||||
' margin="0.15,0.08", color="#00000033"];',
|
||||
' edge [color="#64748b", arrowsize=0.7];',
|
||||
]
|
||||
for nid in sorted(view["in_view"], key=lambda n: int(n[1:])):
|
||||
node = nodes[nid]
|
||||
name = node["name"].replace("\\", "\\\\").replace('"', '\\"')
|
||||
where = short_path(node["path"]).replace("\\", "\\\\").replace('"', '\\"')
|
||||
extra = ', penwidth=2.2, color="#b45309"' if nid == root_id else ""
|
||||
lines.append(
|
||||
f' {nid} [id="{nid}", label="{name}\\n{where}", fillcolor="{crate_colors[node["crate"]]}"{extra}];'
|
||||
)
|
||||
for i, (a, b, kind) in enumerate(sorted(view["edges"])):
|
||||
attrs = f'id="E{i}_{kind}"'
|
||||
if kind == "impl":
|
||||
attrs += ', style=dashed, color="#94a3b8", arrowhead=empty'
|
||||
lines.append(f" {a} -> {b} [{attrs}];")
|
||||
lines.append("}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def layout_svg(dot_source):
|
||||
svg = subprocess.run(
|
||||
["dot", "-Tsvg"], input=dot_source.encode(), capture_output=True, check=True
|
||||
).stdout.decode()
|
||||
return svg[svg.index("<svg") :]
|
||||
|
||||
|
||||
def git(*args):
|
||||
return subprocess.run(["git", *args], cwd=ROOT, capture_output=True, text=True).stdout.strip()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("target", help="function name, module::path::function, or file.rs:line")
|
||||
parser.add_argument("--depth", type=int, default=4, help="call hops from the root (default 4)")
|
||||
parser.add_argument("--max-nodes", type=int, default=250, help="node cap per view (default 250)")
|
||||
parser.add_argument("--out", help="output HTML path (default target/callgraph/<fn>.html)")
|
||||
args = parser.parse_args()
|
||||
|
||||
if re.search(r"\.rs:\d+$", args.target):
|
||||
path, line, col = position_in_file(args.target)
|
||||
else:
|
||||
path, line, col = find_function(args.target)
|
||||
|
||||
lsp = Lsp()
|
||||
lsp.start()
|
||||
uri = "file://" + path
|
||||
lsp.notify(
|
||||
"textDocument/didOpen",
|
||||
{"textDocument": {"uri": uri, "languageId": "rust", "version": 0, "text": open(path).read()}},
|
||||
)
|
||||
lsp.wait_quiescent()
|
||||
|
||||
collector = Collector(lsp)
|
||||
root = None
|
||||
for _ in range(5): # rust-analyzer can need a beat right after quiescence
|
||||
root = collector.prepare(uri, {"line": line, "character": col})
|
||||
if root:
|
||||
break
|
||||
time.sleep(1)
|
||||
if not root:
|
||||
sys.exit("rust-analyzer found no call-hierarchy item at that position")
|
||||
root_id = collector.node_id(root)
|
||||
|
||||
log("collecting graphs...")
|
||||
views = {
|
||||
"callers": collector.collect_view(root, "callers", args.depth, args.max_nodes),
|
||||
"callees": collector.collect_view(root, "callees", args.depth, args.max_nodes),
|
||||
}
|
||||
|
||||
log("enriching nodes (docs, snippets, call sites)...")
|
||||
all_ids = views["callers"]["in_view"] | views["callees"]["in_view"]
|
||||
for nid in all_ids:
|
||||
collector.enrich(nid)
|
||||
|
||||
crates = sorted({collector.nodes[n]["crate"] for n in all_ids})
|
||||
crate_colors = {c: PALETTE[i % len(PALETTE)] for i, c in enumerate(crates)}
|
||||
|
||||
out_views = {}
|
||||
for name, view in views.items():
|
||||
edges_json = []
|
||||
sites_json = {}
|
||||
for (a, b, kind), ranges in sorted(view["edges"].items()):
|
||||
edges_json.append([a, b, kind])
|
||||
if kind == "call" and ranges:
|
||||
sites_json[f"{a}>{b}"] = collector.call_sites(a, ranges)
|
||||
out_views[name] = {
|
||||
"svg": layout_svg(render_dot(collector.nodes, view, root_id, crate_colors)),
|
||||
"edges": edges_json,
|
||||
"sites": sites_json,
|
||||
"truncated": sorted(view["truncated"] & view["in_view"]),
|
||||
"nodeCount": len(view["in_view"]),
|
||||
}
|
||||
|
||||
remote = git("remote", "get-url", "origin")
|
||||
m = re.search(r"github\.com[:/](.+?)(?:\.git)?$", remote)
|
||||
data = {
|
||||
"meta": {
|
||||
"root": root["name"],
|
||||
"rootId": root_id,
|
||||
"target": f"{collector.nodes[root_id]['path']}:{collector.nodes[root_id]['line']}",
|
||||
"commit": git("rev-parse", "HEAD"),
|
||||
"commitShort": git("rev-parse", "--short", "HEAD"),
|
||||
"branch": git("rev-parse", "--abbrev-ref", "HEAD"),
|
||||
"github": f"https://github.com/{m.group(1)}" if m else "",
|
||||
"repoRoot": ROOT,
|
||||
"depth": args.depth,
|
||||
"maxNodes": args.max_nodes,
|
||||
"date": time.strftime("%Y-%m-%d %H:%M"),
|
||||
"crateColors": crate_colors,
|
||||
},
|
||||
"nodes": {
|
||||
nid: {k: v for k, v in collector.nodes[nid].items() if k != "item"}
|
||||
for nid in all_ids
|
||||
},
|
||||
"views": out_views,
|
||||
}
|
||||
|
||||
out_path = args.out or os.path.join(ROOT, "target", "callgraph", f"{root['name']}.html")
|
||||
os.makedirs(os.path.dirname(out_path), exist_ok=True)
|
||||
template = open(os.path.join(TOOL_DIR, "template.html")).read()
|
||||
payload = json.dumps(data, ensure_ascii=False).replace("</", "<\\/")
|
||||
open(out_path, "w").write(template.replace("__DATA__", payload, 1))
|
||||
log(f"callers: {out_views['callers']['nodeCount']} nodes, "
|
||||
f"callees: {out_views['callees']['nodeCount']} nodes")
|
||||
print(f"file://{out_path}")
|
||||
lsp.proc.kill()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 273 KiB |
@@ -0,0 +1,448 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>call graph</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f8fafc; --panel: #ffffff; --border: #e2e8f0; --text: #0f172a;
|
||||
--dim: #64748b; --accent: #2563eb; --root-ring: #b45309;
|
||||
--code-bg: #f1f5f9; --hl-bg: #fef3c7;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; font: 14px/1.45 system-ui, sans-serif; color: var(--text); background: var(--bg);
|
||||
height: 100vh; display: flex; flex-direction: column; overflow: hidden; }
|
||||
code, pre { font-family: ui-monospace, "JetBrains Mono", Menlo, monospace; font-size: 12px; }
|
||||
|
||||
header { display: flex; align-items: center; gap: 16px; padding: 10px 16px;
|
||||
background: var(--panel); border-bottom: 1px solid var(--border); flex-wrap: wrap; }
|
||||
header h1 { font-size: 16px; margin: 0; font-weight: 650; }
|
||||
header h1 code { font-size: 14px; background: var(--code-bg); padding: 2px 6px; border-radius: 4px; }
|
||||
.meta { color: var(--dim); font-size: 12px; }
|
||||
.meta a { color: var(--dim); }
|
||||
.toggle { display: flex; border: 1px solid var(--border); border-radius: 6px; overflow: hidden; }
|
||||
.toggle button { border: 0; background: var(--panel); padding: 6px 14px; cursor: pointer; font: inherit; color: var(--dim); }
|
||||
.toggle button.active { background: var(--accent); color: #fff; }
|
||||
#search { margin-left: auto; padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px;
|
||||
font: inherit; width: 220px; background: var(--panel); }
|
||||
|
||||
main { flex: 1; display: flex; min-height: 0; }
|
||||
#graph-pane { flex: 1; position: relative; min-width: 0; }
|
||||
#svg-host { position: absolute; inset: 0; cursor: grab; user-select: none; -webkit-user-select: none; }
|
||||
#svg-host.dragging { cursor: grabbing; }
|
||||
#svg-host svg { width: 100%; height: 100%; display: block; }
|
||||
|
||||
#zoom { position: absolute; top: 12px; left: 12px; display: flex; flex-direction: column; gap: 4px; z-index: 2; }
|
||||
#zoom button { width: 30px; height: 30px; border: 1px solid var(--border); border-radius: 6px;
|
||||
background: var(--panel); cursor: pointer; font-size: 15px; color: var(--text); }
|
||||
#legend { position: absolute; bottom: 12px; left: 12px; background: #ffffffe6; border: 1px solid var(--border);
|
||||
border-radius: 8px; padding: 8px 12px; font-size: 12px; display: flex; gap: 12px; flex-wrap: wrap;
|
||||
max-width: 70%; z-index: 2; }
|
||||
.chip { display: inline-flex; align-items: center; gap: 5px; color: var(--dim); }
|
||||
.chip i { width: 12px; height: 12px; border-radius: 3px; border: 1px solid #00000022; }
|
||||
.chip svg { overflow: visible; }
|
||||
|
||||
/* graph highlighting */
|
||||
.node { cursor: pointer; }
|
||||
.node text { pointer-events: none; }
|
||||
.node.sel :is(path, polygon, ellipse) { stroke: var(--accent); stroke-width: 2.5px; }
|
||||
.node.trunc :is(path, polygon, ellipse) { stroke-dasharray: 5 3; }
|
||||
svg.focus :is(.node, .edge):not(.sel):not(.nbr):not(.in-e):not(.out-e) { opacity: 0.22; }
|
||||
.edge.in-e :is(path) { stroke: #d97706; stroke-width: 2px; }
|
||||
.edge.in-e :is(polygon) { fill: #d97706; stroke: #d97706; }
|
||||
.edge.out-e :is(path) { stroke: var(--accent); stroke-width: 2px; }
|
||||
.edge.out-e :is(polygon) { fill: var(--accent); stroke: var(--accent); }
|
||||
.search-miss { opacity: 0.15; }
|
||||
|
||||
/* details panel */
|
||||
#resizer { flex: 0 0 6px; cursor: col-resize; background: transparent; border-left: 1px solid var(--border); }
|
||||
#resizer:hover, #resizer.active { background: var(--accent); border-left-color: var(--accent); }
|
||||
#panel { width: 420px; min-width: 280px; flex-shrink: 0; background: var(--panel);
|
||||
overflow-y: auto; padding: 16px; }
|
||||
#panel .empty { color: var(--dim); margin-top: 40px; text-align: center; }
|
||||
#panel h2 { font-size: 15px; margin: 0 0 2px; word-break: break-all; }
|
||||
#panel h3 { font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--dim); margin: 18px 0 6px; }
|
||||
.badge { display: inline-block; font-size: 11px; padding: 1px 7px; border-radius: 10px;
|
||||
border: 1px solid #00000022; margin-right: 4px; }
|
||||
.badge.warn { background: var(--hl-bg); border-color: #d9770655; color: #92400e; }
|
||||
.links { margin: 8px 0 0; font-size: 12px; display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
|
||||
.links a { color: var(--accent); text-decoration: none; }
|
||||
.links a:hover { text-decoration: underline; }
|
||||
.links button { border: 0; background: none; color: var(--accent); cursor: pointer; font: inherit; font-size: 12px; padding: 0; }
|
||||
.path { color: var(--dim); font-size: 12px; word-break: break-all; }
|
||||
|
||||
pre.code { background: var(--code-bg); border: 1px solid var(--border); border-radius: 6px;
|
||||
padding: 6px 0; overflow-x: auto; margin: 6px 0; line-height: 1.55; }
|
||||
pre.code.plain { padding: 6px 10px; }
|
||||
pre.code .cl { display: block; width: max-content; min-width: 100%; padding-right: 10px; }
|
||||
pre.code .ln { position: sticky; left: 0; display: inline-block; min-width: 4ch; text-align: right;
|
||||
padding: 0 8px 0 6px; margin-right: 8px; color: #94a3b8; user-select: none;
|
||||
background: var(--code-bg); border-right: 1px solid var(--border); }
|
||||
pre.code .cl.hl-line { background: var(--hl-bg); }
|
||||
pre.code .cl.hl-line .ln { background: var(--hl-bg); color: #92400e; border-right-color: #d9770680; }
|
||||
.k { color: #7c3aed; } .s { color: #16a34a; } .c { color: #94a3b8; font-style: italic; } .n { color: #d97706; }
|
||||
.docs p { margin: 6px 0; } .docs hr { border: 0; border-top: 1px solid var(--border); margin: 8px 0; }
|
||||
.docs code { background: var(--code-bg); padding: 1px 4px; border-radius: 3px; }
|
||||
|
||||
.rel { border: 1px solid var(--border); border-radius: 6px; margin: 6px 0; overflow: hidden; }
|
||||
.rel > summary, .rel > .rel-head { padding: 6px 10px; cursor: pointer; display: flex; gap: 8px; align-items: baseline; background: #fbfcfe; }
|
||||
.rel .fname { font-family: ui-monospace, monospace; font-size: 12.5px; font-weight: 600; }
|
||||
.rel .where { color: var(--dim); font-size: 11.5px; margin-left: auto; white-space: nowrap; }
|
||||
.rel .body { padding: 4px 10px 8px; border-top: 1px solid var(--border); }
|
||||
.site { margin: 8px 0; }
|
||||
.site .site-head { font-size: 11.5px; color: var(--dim); margin-bottom: 2px; }
|
||||
.site .site-head a { color: var(--accent); text-decoration: none; }
|
||||
details.src summary { cursor: pointer; color: var(--dim); font-size: 12px; margin: 14px 0 4px; }
|
||||
.goto { color: var(--accent); cursor: pointer; font-size: 12px; text-decoration: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1><code id="h-root"></code> <span class="meta">call graph</span></h1>
|
||||
<div class="toggle">
|
||||
<button id="btn-callers">Callers</button>
|
||||
<button id="btn-callees">Callees</button>
|
||||
</div>
|
||||
<span class="meta" id="h-meta"></span>
|
||||
<input id="search" type="search" placeholder="filter nodes… (Enter selects)">
|
||||
</header>
|
||||
<main>
|
||||
<div id="graph-pane">
|
||||
<div id="zoom">
|
||||
<button id="z-in" title="zoom in">+</button>
|
||||
<button id="z-out" title="zoom out">−</button>
|
||||
<button id="z-fit" title="fit">⤢</button>
|
||||
</div>
|
||||
<div id="svg-host"></div>
|
||||
<div id="legend"></div>
|
||||
</div>
|
||||
<div id="resizer" title="drag to resize, double-click to reset"></div>
|
||||
<aside id="panel"><div class="empty">Click a node to inspect it.</div></aside>
|
||||
</main>
|
||||
<script>
|
||||
const DATA = __DATA__;
|
||||
const M = DATA.meta, NODES = DATA.nodes;
|
||||
let view = "callers", selected = null, vb = null, vb0 = null, svg = null;
|
||||
let userFocused = false; // fade the rest of the graph only after an explicit click
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const esc = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
|
||||
/* ---------- tiny rust highlighter ---------- */
|
||||
const RUST_TOKENS = new RegExp(
|
||||
'(\\/\\/[^\\n]*)|("(?:[^"\\\\]|\\\\.)*")|(\\b\\d[\\d_]*(?:\\.\\d+)?\\b)|' +
|
||||
"(\\b(?:pub|fn|let|mut|const|static|impl|trait|struct|enum|match|if|else|for|while|loop|return|" +
|
||||
"use|mod|where|async|await|move|dyn|ref|self|Self|super|crate|in|as|break|continue|unsafe|type)\\b)", "g");
|
||||
function hlRust(src) {
|
||||
let out = "", last = 0, m;
|
||||
RUST_TOKENS.lastIndex = 0;
|
||||
while ((m = RUST_TOKENS.exec(src))) {
|
||||
out += esc(src.slice(last, m.index));
|
||||
const cls = m[1] ? "c" : m[2] ? "s" : m[3] ? "n" : "k";
|
||||
out += `<span class="${cls}">${esc(m[0])}</span>`;
|
||||
last = m.index + m[0].length;
|
||||
}
|
||||
return out + esc(src.slice(last));
|
||||
}
|
||||
function codeBlock(lines, startLine, hlLine) {
|
||||
const nonEmpty = lines.filter((l) => l.trim());
|
||||
const indent = nonEmpty.length ? Math.min(...nonEmpty.map((l) => l.match(/^[ \t]*/)[0].length)) : 0;
|
||||
const body = lines.map((l, i) => {
|
||||
const no = startLine + i;
|
||||
const cls = no === hlLine ? "cl hl-line" : "cl";
|
||||
return `<span class="${cls}"><span class="ln">${no}</span>${hlRust(l.slice(indent))}</span>`;
|
||||
}).join("");
|
||||
return `<pre class="code">${body}</pre>`;
|
||||
}
|
||||
/* hover markdown: alternate text / ```fenced``` segments */
|
||||
function renderDocs(md) {
|
||||
if (!md) return "";
|
||||
const parts = md.split(/```(?:rust[^\n]*)?\n?/);
|
||||
return '<div class="docs">' + parts.map((p, i) => {
|
||||
if (i % 2 === 1) return `<pre class="code plain">${hlRust(p.replace(/\n$/, ""))}</pre>`;
|
||||
return p.trim().split(/\n{2,}/).filter(Boolean).map((para) => {
|
||||
if (/^-{3,}$/.test(para.trim())) return "<hr>";
|
||||
if (/^Implements notable traits/.test(para.trim())) return ""; // rust-analyzer noise
|
||||
const text = esc(para)
|
||||
.replace(/\[([^\]]+)\]\([^)\s]+\)/g, "$1") // docs.rs links from RA are dead for workspace crates
|
||||
.replace(/`([^`]+)`/g, "<code>$1</code>");
|
||||
return `<p>${text}</p>`;
|
||||
}).join("");
|
||||
}).join("") + "</div>";
|
||||
}
|
||||
|
||||
/* ---------- links ---------- */
|
||||
const ghLink = (path, line) => M.github ? `${M.github}/blob/${M.commit}/${path}#L${line}` : null;
|
||||
const edLink = (path, line) => `vscode://file${M.repoRoot}/${path}:${line}`;
|
||||
function linksRow(path, line) {
|
||||
const gh = ghLink(path, line);
|
||||
return `<div class="links">
|
||||
${gh ? `<a href="${gh}" target="_blank">GitHub ↗</a>` : ""}
|
||||
<a href="${edLink(path, line)}">editor</a>
|
||||
<button data-copy="${esc(path)}:${line}">copy path</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/* ---------- graph rendering ---------- */
|
||||
function edgeSvgId(i, kind) { return `E${i}_${kind}`; }
|
||||
|
||||
function show(name) {
|
||||
view = name;
|
||||
const v = DATA.views[view];
|
||||
$("svg-host").innerHTML = v.svg;
|
||||
svg = $("svg-host").querySelector("svg");
|
||||
svg.removeAttribute("width"); svg.removeAttribute("height");
|
||||
const [x, y, w, h] = svg.getAttribute("viewBox").split(" ").map(Number);
|
||||
vb0 = { x, y, w, h };
|
||||
fit();
|
||||
// node tooltips + truncation marks
|
||||
for (const [nid, n] of Object.entries(NODES)) {
|
||||
const el = svg.getElementById(nid);
|
||||
if (!el) continue;
|
||||
el.querySelector("title")?.remove();
|
||||
const t = document.createElementNS("http://www.w3.org/2000/svg", "title");
|
||||
t.textContent = `${n.name} — ${n.path}:${n.line}` + (v.truncated.includes(nid) ? " (not expanded)" : "");
|
||||
el.appendChild(t);
|
||||
if (v.truncated.includes(nid)) el.classList.add("trunc");
|
||||
}
|
||||
$("btn-callers").classList.toggle("active", view === "callers");
|
||||
$("btn-callees").classList.toggle("active", view === "callees");
|
||||
applySelection();
|
||||
applySearch();
|
||||
}
|
||||
|
||||
function edgesOf(nid) {
|
||||
const v = DATA.views[view];
|
||||
const res = { callers: [], callees: [], impls: [], decl: [] };
|
||||
v.edges.forEach(([a, b, kind], i) => {
|
||||
if (kind === "call") {
|
||||
if (b === nid) res.callers.push({ peer: a, i, sites: v.sites[`${a}>${b}`] || [] });
|
||||
if (a === nid) res.callees.push({ peer: b, i, sites: v.sites[`${a}>${b}`] || [] });
|
||||
} else {
|
||||
if (a === nid) res.impls.push({ peer: b, i });
|
||||
if (b === nid) res.decl.push({ peer: a, i });
|
||||
}
|
||||
});
|
||||
return res;
|
||||
}
|
||||
|
||||
function applySelection() {
|
||||
if (!svg) return;
|
||||
svg.classList.toggle("focus", !!selected && userFocused);
|
||||
svg.querySelectorAll(".sel, .nbr, .in-e, .out-e").forEach((el) =>
|
||||
el.classList.remove("sel", "nbr", "in-e", "out-e"));
|
||||
if (!selected) return;
|
||||
const el = svg.getElementById(selected);
|
||||
if (el) el.classList.add("sel");
|
||||
const rel = edgesOf(selected);
|
||||
const v = DATA.views[view];
|
||||
for (const { peer, i } of [...rel.callers, ...rel.decl])
|
||||
mark(peer, i, v.edges[i][2], "in-e");
|
||||
for (const { peer, i } of [...rel.callees, ...rel.impls])
|
||||
mark(peer, i, v.edges[i][2], "out-e");
|
||||
function mark(peer, i, kind, cls) {
|
||||
svg.getElementById(peer)?.classList.add("nbr");
|
||||
svg.getElementById(edgeSvgId(i, kind))?.classList.add(cls);
|
||||
}
|
||||
}
|
||||
|
||||
function select(nid, centerIt, focus = true) {
|
||||
selected = nid;
|
||||
userFocused = focus && !!nid;
|
||||
applySelection();
|
||||
renderPanel();
|
||||
if (centerIt && nid) center(nid);
|
||||
}
|
||||
|
||||
/* ---------- details panel ---------- */
|
||||
function nodeRef(nid) {
|
||||
const n = NODES[nid];
|
||||
return `<a class="goto" data-goto="${nid}">${esc(n.name)}</a> <span class="path">${esc(n.path)}:${n.line}</span>`;
|
||||
}
|
||||
function relEntry({ peer, i, sites }, siteOwnerIsPeer) {
|
||||
const n = NODES[peer];
|
||||
const siteHtml = (sites || []).map((s) => {
|
||||
const owner = siteOwnerIsPeer ? NODES[peer] : NODES[selected];
|
||||
const gh = ghLink(owner.path, s.line);
|
||||
return `<div class="site">
|
||||
<div class="site-head" title="${esc(owner.path)}:${s.line}">line ${s.line}${gh ? ` — <a href="${gh}" target="_blank">GitHub ↗</a>` : ""}</div>
|
||||
${codeBlock(s.lines, s.context_start, s.line)}
|
||||
</div>`;
|
||||
}).join("");
|
||||
const head = `<span class="fname"><a class="goto" data-goto="${peer}">${esc(n.name)}</a></span>
|
||||
<span class="where">${esc(shortPath(n.path))}:${n.line}</span>`;
|
||||
if (!siteHtml) return `<div class="rel"><div class="rel-head">${head}</div></div>`;
|
||||
return `<details class="rel"><summary>${head}</summary><div class="body">${siteHtml}</div></details>`;
|
||||
}
|
||||
function shortPath(p) {
|
||||
const parts = p.split("/src/").pop().split("/");
|
||||
return parts.slice(-2).join("/");
|
||||
}
|
||||
function renderPanel() {
|
||||
const panel = $("panel");
|
||||
if (!selected) { panel.innerHTML = '<div class="empty">Click a node to inspect it.</div>'; return; }
|
||||
const n = NODES[selected];
|
||||
const v = DATA.views[view];
|
||||
const rel = edgesOf(selected);
|
||||
const badges =
|
||||
(selected === M.rootId ? '<span class="badge" style="background:#fde68a">root</span>' : "") +
|
||||
`<span class="badge" style="background:${M.crateColors[n.crate]}">${esc(n.crate)}</span>` +
|
||||
(v.truncated.includes(selected) ? '<span class="badge warn">not expanded (limit)</span>' : "");
|
||||
let html = `<h2>${esc(n.name)}</h2>
|
||||
<div>${badges}</div>
|
||||
<div class="path" style="margin-top:6px">${esc(n.path)}:${n.line}</div>
|
||||
${linksRow(n.path, n.line)}
|
||||
${renderDocs(n.hover)}`;
|
||||
if (rel.decl.length)
|
||||
html += `<h3>Declared in trait</h3>` + rel.decl.map((r) => `<div class="rel"><div class="rel-head">${nodeRef(r.peer)}</div></div>`).join("");
|
||||
if (rel.impls.length)
|
||||
html += `<h3>Implementations (${rel.impls.length})</h3>` + rel.impls.map((r) => `<div class="rel"><div class="rel-head">${nodeRef(r.peer)}</div></div>`).join("");
|
||||
if (rel.callers.length)
|
||||
html += `<h3>Called by (${rel.callers.length})</h3>` + rel.callers.map((r) => relEntry(r, true)).join("");
|
||||
if (rel.callees.length)
|
||||
html += `<h3>Calls (${rel.callees.length})</h3>` + rel.callees.map((r) => relEntry(r, false)).join("");
|
||||
const snip = n.snippet;
|
||||
html += `<details class="src"><summary>Source (${snip.lines.length}${snip.clipped ? "+ (clipped)" : ""} lines)</summary>
|
||||
${codeBlock(snip.lines, snip.start, -1)}</details>`;
|
||||
panel.innerHTML = html;
|
||||
panel.scrollTop = 0;
|
||||
}
|
||||
|
||||
/* ---------- pan / zoom ---------- */
|
||||
function setVb() { svg.setAttribute("viewBox", `${vb.x} ${vb.y} ${vb.w} ${vb.h}`); }
|
||||
function fit() {
|
||||
vb = { ...vb0 };
|
||||
// don't blow small graphs up: cap initial scale at ~1.3x
|
||||
const host = $("svg-host").getBoundingClientRect();
|
||||
const scale = Math.min(host.width / vb.w, host.height / vb.h);
|
||||
if (scale > 1.3) {
|
||||
const w = host.width / 1.3, h = host.height / 1.3;
|
||||
vb = { x: vb.x - (w - vb.w) / 2, y: vb.y - (h - vb.h) / 2, w, h };
|
||||
}
|
||||
setVb();
|
||||
}
|
||||
function zoom(factor, cx, cy) {
|
||||
const host = $("svg-host").getBoundingClientRect();
|
||||
cx = cx === undefined ? host.width / 2 : cx; cy = cy === undefined ? host.height / 2 : cy;
|
||||
const px = vb.x + (cx / host.width) * vb.w, py = vb.y + (cy / host.height) * vb.h;
|
||||
vb.w /= factor; vb.h /= factor;
|
||||
vb.x = px - (cx / host.width) * vb.w; vb.y = py - (cy / host.height) * vb.h;
|
||||
setVb();
|
||||
}
|
||||
function center(nid) {
|
||||
const el = svg.getElementById(nid);
|
||||
if (!el) return;
|
||||
const r = el.getBoundingClientRect(), host = $("svg-host").getBoundingClientRect();
|
||||
const dx = (r.left + r.width / 2 - (host.left + host.width / 2)) * (vb.w / host.width);
|
||||
const dy = (r.top + r.height / 2 - (host.top + host.height / 2)) * (vb.h / host.height);
|
||||
vb.x += dx; vb.y += dy;
|
||||
setVb();
|
||||
}
|
||||
$("svg-host").addEventListener("wheel", (e) => {
|
||||
e.preventDefault();
|
||||
const host = $("svg-host").getBoundingClientRect();
|
||||
zoom(Math.pow(1.0015, -e.deltaY), e.clientX - host.left, e.clientY - host.top);
|
||||
}, { passive: false });
|
||||
let drag = null;
|
||||
$("svg-host").addEventListener("pointerdown", (e) => {
|
||||
e.preventDefault();
|
||||
drag = { x: e.clientX, y: e.clientY, moved: false };
|
||||
$("svg-host").classList.add("dragging");
|
||||
$("svg-host").setPointerCapture(e.pointerId);
|
||||
});
|
||||
$("svg-host").addEventListener("pointermove", (e) => {
|
||||
if (!drag) return;
|
||||
const host = $("svg-host").getBoundingClientRect();
|
||||
const dx = (e.clientX - drag.x) * (vb.w / host.width), dy = (e.clientY - drag.y) * (vb.h / host.height);
|
||||
if (Math.abs(e.clientX - drag.x) + Math.abs(e.clientY - drag.y) > 3) drag.moved = true;
|
||||
vb.x -= dx; vb.y -= dy;
|
||||
drag.x = e.clientX; drag.y = e.clientY;
|
||||
setVb();
|
||||
});
|
||||
$("svg-host").addEventListener("pointerup", (e) => {
|
||||
const wasDrag = drag?.moved;
|
||||
drag = null;
|
||||
$("svg-host").classList.remove("dragging");
|
||||
if (wasDrag) return;
|
||||
// pointer capture retargets e.target to the host div: hit-test by coordinates
|
||||
const nodeEl = document.elementFromPoint(e.clientX, e.clientY)?.closest("g.node");
|
||||
select(nodeEl ? nodeEl.id : null, false);
|
||||
});
|
||||
|
||||
/* ---------- search ---------- */
|
||||
function applySearch() {
|
||||
const q = $("search").value.trim().toLowerCase();
|
||||
if (!svg) return;
|
||||
for (const nid of Object.keys(NODES)) {
|
||||
const el = svg.getElementById(nid);
|
||||
if (!el) continue;
|
||||
const hit = !q || NODES[nid].name.toLowerCase().includes(q) || NODES[nid].path.toLowerCase().includes(q);
|
||||
el.classList.toggle("search-miss", !hit);
|
||||
}
|
||||
}
|
||||
$("search").addEventListener("input", applySearch);
|
||||
$("search").addEventListener("keydown", (e) => {
|
||||
if (e.key !== "Enter") return;
|
||||
const q = $("search").value.trim().toLowerCase();
|
||||
const hit = Object.keys(NODES).find((nid) =>
|
||||
svg.getElementById(nid) && NODES[nid].name.toLowerCase().includes(q));
|
||||
if (hit) select(hit, true);
|
||||
});
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape") { $("search").value = ""; applySearch(); select(null, false); }
|
||||
});
|
||||
|
||||
/* ---------- global click handlers (goto / copy) ---------- */
|
||||
document.addEventListener("click", (e) => {
|
||||
const goto = e.target.closest("[data-goto]");
|
||||
if (goto) { e.preventDefault(); select(goto.dataset.goto, true); return; }
|
||||
const copy = e.target.closest("[data-copy]");
|
||||
if (copy) { navigator.clipboard?.writeText(copy.dataset.copy); copy.textContent = "copied!"; setTimeout(() => (copy.textContent = "copy path"), 1200); }
|
||||
});
|
||||
|
||||
/* ---------- panel resize ---------- */
|
||||
function setPanelWidth(w) {
|
||||
w = Math.max(280, Math.min(w, window.innerWidth - 400));
|
||||
$("panel").style.width = w + "px";
|
||||
try { localStorage.setItem("cg-panel-w", w); } catch {}
|
||||
}
|
||||
$("resizer").addEventListener("pointerdown", (e) => {
|
||||
e.preventDefault();
|
||||
$("resizer").classList.add("active");
|
||||
$("resizer").setPointerCapture(e.pointerId);
|
||||
const move = (ev) => setPanelWidth(window.innerWidth - ev.clientX);
|
||||
$("resizer").addEventListener("pointermove", move);
|
||||
$("resizer").addEventListener("pointerup", () => {
|
||||
$("resizer").classList.remove("active");
|
||||
$("resizer").removeEventListener("pointermove", move);
|
||||
}, { once: true });
|
||||
});
|
||||
$("resizer").addEventListener("dblclick", () => setPanelWidth(420));
|
||||
try {
|
||||
const saved = parseInt(localStorage.getItem("cg-panel-w"));
|
||||
if (saved) setPanelWidth(saved);
|
||||
} catch {}
|
||||
|
||||
/* ---------- boot ---------- */
|
||||
document.title = `${M.root} — call graph`;
|
||||
$("h-root").textContent = M.root;
|
||||
$("h-meta").innerHTML = `${esc(M.target)} · depth ${M.depth} · ` +
|
||||
(M.github ? `<a href="${M.github}/commit/${M.commit}" target="_blank">${esc(M.branch)}@${esc(M.commitShort)}</a>` : esc(M.commitShort)) +
|
||||
` · ${esc(M.date)}`;
|
||||
$("btn-callers").textContent = `Callers (${DATA.views.callers.nodeCount})`;
|
||||
$("btn-callees").textContent = `Callees (${DATA.views.callees.nodeCount})`;
|
||||
$("btn-callers").onclick = () => show("callers");
|
||||
$("btn-callees").onclick = () => show("callees");
|
||||
$("z-in").onclick = () => zoom(1.4);
|
||||
$("z-out").onclick = () => zoom(1 / 1.4);
|
||||
$("z-fit").onclick = fit;
|
||||
$("legend").innerHTML =
|
||||
Object.entries(M.crateColors).map(([c, col]) => `<span class="chip"><i style="background:${col}"></i>${esc(c)}</span>`).join("") +
|
||||
`<span class="chip"><svg width="26" height="10"><line x1="0" y1="5" x2="26" y2="5" stroke="#94a3b8" stroke-dasharray="4 3" stroke-width="1.5"/></svg>trait ↔ impl</span>` +
|
||||
`<span class="chip"><i style="border:1.5px dashed #64748b; background:none"></i>not expanded</span>`;
|
||||
show("callers");
|
||||
select(M.rootId, false, false);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user