rust-analyzer-db 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
rust_analyzer/graph.py ADDED
@@ -0,0 +1,297 @@
1
+ """Builds a call graph (execution-flow graph) from the data stored in the
2
+ RustCodeDB and renders it as Graphviz DOT/SVG/PNG or an interactive HTML
3
+ page (vis-network).
4
+ """
5
+
6
+ import hashlib
7
+ import json
8
+ import re
9
+ import shutil
10
+ import subprocess
11
+ from collections import deque
12
+ from typing import Optional, Set, Tuple, List, Dict
13
+
14
+ from .db import RustCodeDB
15
+
16
+ NODE_COLORS = {
17
+ "function": "#4C72B0",
18
+ "method": "#55A868",
19
+ "external": "#B0B0B0",
20
+ }
21
+
22
+ MAX_LABEL_LEN = 60
23
+
24
+
25
+ def _clean(s: Optional[str]) -> str:
26
+ """Collapse whitespace/newlines and truncate. Anything downstream (DOT
27
+ labels, HTML/JSON) treats the result as plain text to be escaped there."""
28
+ if not s:
29
+ return ""
30
+ s = re.sub(r"\s+", " ", s).strip()
31
+ if len(s) > MAX_LABEL_LEN:
32
+ s = s[:MAX_LABEL_LEN - 1] + "\u2026"
33
+ return s
34
+
35
+
36
+ def _safe_id(*parts: Optional[str]) -> str:
37
+ """Deterministic, DOT/HTML-safe node id built from arbitrary text."""
38
+ key = "\x1f".join(p or "" for p in parts)
39
+ return "n" + hashlib.md5(key.encode("utf-8", errors="ignore")).hexdigest()[:12]
40
+
41
+
42
+ def _item_node_id(item_id: int) -> str:
43
+ return f"item{item_id}"
44
+
45
+
46
+ def _external_node_id(name: str, receiver: Optional[str], is_method_call: bool) -> str:
47
+ # Method-call receivers are usually arbitrary local variable names
48
+ # (x.clone(), y.clone(), self.clone()...) so collapse purely by method
49
+ # name, or every unique variable would become its own node. Scoped
50
+ # calls (Type::method / module::func) have bounded, meaningful
51
+ # receivers, so keep those distinct.
52
+ if is_method_call:
53
+ return _safe_id("ext_method", name)
54
+ return _safe_id("ext_fn", receiver, name)
55
+
56
+
57
+ def _node_label(name: str, target: Optional[str]) -> str:
58
+ name = _clean(name)
59
+ target = _clean(target) if target else None
60
+ return f"{target}::{name}" if target else name
61
+
62
+
63
+ class CallGraph:
64
+ def __init__(self):
65
+ self.nodes: Dict[str, dict] = {} # node_id -> {label, kind, item_id}
66
+ self.edges: Set[Tuple[str, str]] = set()
67
+
68
+ def add_node(self, node_id: str, label: str, kind: str, item_id: Optional[int]):
69
+ if node_id not in self.nodes:
70
+ self.nodes[node_id] = {"label": label, "kind": kind, "item_id": item_id}
71
+
72
+ def add_edge(self, src: str, dst: str):
73
+ if src != dst:
74
+ self.edges.add((src, dst))
75
+
76
+
77
+ def build_whole_graph(db: RustCodeDB, include_unresolved: bool = False,
78
+ kinds: Optional[Set[str]] = None) -> CallGraph:
79
+ g = CallGraph()
80
+ for row in db.all_call_edges():
81
+ if not include_unresolved and row["callee_id"] is None:
82
+ continue
83
+ caller_kind = row["caller_kind"]
84
+ if kinds and caller_kind not in kinds:
85
+ continue
86
+ src_id = _item_node_id(row["caller_id"])
87
+ g.add_node(src_id, _node_label(row["caller_name"], row["caller_target"]), caller_kind, row["caller_id"])
88
+
89
+ if row["callee_id"] is not None:
90
+ dst_kind = row["callee_kind"]
91
+ dst_id = _item_node_id(row["callee_id"])
92
+ g.add_node(dst_id, _node_label(row["callee_name"], row["callee_target"]), dst_kind, row["callee_id"])
93
+ else:
94
+ dst_id = _external_node_id(row["callee_name"], row["receiver"], bool(row["is_method_call"]))
95
+ g.add_node(dst_id, _node_label(row["callee_name"], row["receiver"] if not row["is_method_call"] else None),
96
+ "external", None)
97
+ g.add_edge(src_id, dst_id)
98
+ return g
99
+
100
+
101
+ def build_subgraph(db: RustCodeDB, root_item_ids: List[int], depth: int = 2,
102
+ direction: str = "both", include_unresolved: bool = True) -> CallGraph:
103
+ """BFS out from the given root item id(s) following callee edges
104
+ ('callees'), caller edges ('callers'), or both, up to `depth` hops."""
105
+ g = CallGraph()
106
+
107
+ def ensure_item_node(item_id: int):
108
+ row = db.get_item(item_id)
109
+ if row is None:
110
+ return
111
+ node_id = _item_node_id(item_id)
112
+ g.add_node(node_id, _node_label(row["name"], row["target"]), row["kind"], item_id)
113
+
114
+ visited = set()
115
+ queue = deque((rid, 0) for rid in root_item_ids)
116
+ for rid in root_item_ids:
117
+ ensure_item_node(rid)
118
+ visited.add(rid)
119
+
120
+ while queue:
121
+ item_id, d = queue.popleft()
122
+ if d >= depth:
123
+ continue
124
+ row = db.get_item(item_id)
125
+ if row is None:
126
+ continue
127
+ src_id = _item_node_id(item_id)
128
+
129
+ if direction in ("callees", "both"):
130
+ for call in db.get_calls_from(item_id):
131
+ if call["callee_id"] is not None:
132
+ ensure_item_node(call["callee_id"])
133
+ dst_id = _item_node_id(call["callee_id"])
134
+ g.add_edge(src_id, dst_id)
135
+ if call["callee_id"] not in visited:
136
+ visited.add(call["callee_id"])
137
+ queue.append((call["callee_id"], d + 1))
138
+ elif include_unresolved:
139
+ is_method = bool(call["is_method_call"])
140
+ dst_id = _external_node_id(call["callee_name"], call["receiver"], is_method)
141
+ g.add_node(dst_id, _node_label(call["callee_name"], call["receiver"] if not is_method else None),
142
+ "external", None)
143
+ g.add_edge(src_id, dst_id)
144
+
145
+ if direction in ("callers", "both"):
146
+ for call in db.get_calls_to(item_id):
147
+ ensure_item_node(call["caller_id"])
148
+ caller_node_id = _item_node_id(call["caller_id"])
149
+ g.add_edge(caller_node_id, src_id)
150
+ if call["caller_id"] not in visited:
151
+ visited.add(call["caller_id"])
152
+ queue.append((call["caller_id"], d + 1))
153
+
154
+ return g
155
+
156
+
157
+ def _dot_escape(s: str) -> str:
158
+ return s.replace("\\", "\\\\").replace('"', '\\"')
159
+
160
+
161
+ def to_dot(g: CallGraph, title: str = "Call Graph") -> str:
162
+ lines = [
163
+ "digraph callgraph {",
164
+ f' label="{_dot_escape(title)}";',
165
+ " labelloc=t;",
166
+ " fontsize=18;",
167
+ " rankdir=LR;",
168
+ " node [shape=box, style=\"rounded,filled\", fontname=\"Helvetica\", fontsize=10];",
169
+ " edge [color=\"#888888\", arrowsize=0.7];",
170
+ ]
171
+ for node_id, meta in g.nodes.items():
172
+ color = NODE_COLORS.get(meta["kind"], "#CCCCCC")
173
+ label = _dot_escape(meta["label"]) or "?"
174
+ shape = "ellipse" if meta["kind"] == "external" else "box"
175
+ lines.append(f' "{node_id}" [label="{label}", fillcolor="{color}", shape={shape}, fontcolor="white"];')
176
+ for src, dst in sorted(g.edges):
177
+ lines.append(f' "{src}" -> "{dst}";')
178
+ lines.append("}")
179
+ return "\n".join(lines)
180
+
181
+
182
+ def render_dot(dot_str: str, out_path: str, fmt: str) -> bool:
183
+ """Render a DOT string to `fmt` (svg/png/pdf) via the `dot` binary.
184
+ Returns True on success, False if graphviz isn't installed."""
185
+ if shutil.which("dot") is None:
186
+ return False
187
+ proc = subprocess.run(
188
+ ["dot", f"-T{fmt}", "-o", out_path],
189
+ input=dot_str.encode("utf-8"),
190
+ capture_output=True,
191
+ )
192
+ if proc.returncode != 0:
193
+ raise RuntimeError(proc.stderr.decode("utf-8", errors="replace"))
194
+ return True
195
+
196
+
197
+ VIS_HTML_TEMPLATE = """<!DOCTYPE html>
198
+ <html>
199
+ <head>
200
+ <meta charset="utf-8">
201
+ <title>{title}</title>
202
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/vis-network/9.1.9/vis-network.min.js"
203
+ onerror="document.getElementById('graph').innerHTML =
204
+ '<div style=\\'color:#eee;padding:24px;font-family:sans-serif\\'>' +
205
+ 'Could not load vis-network from the CDN (cdnjs.cloudflare.com). ' +
206
+ 'This page needs internet access in your browser to draw the graph. ' +
207
+ 'Use <code>--format svg</code> or <code>--format png</code> instead if you are offline.</div>'"></script>
208
+ <style>
209
+ html, body {{ margin:0; height:100%; font-family: Helvetica, Arial, sans-serif; background:#1e1e1e; }}
210
+ #header {{ padding: 10px 16px; color: #eee; background:#252526; border-bottom: 1px solid #333; }}
211
+ #graph {{ width: 100%; height: calc(100% - 52px); background:#1e1e1e; }}
212
+ .legend span {{ display:inline-block; margin-right:16px; }}
213
+ .dot {{ display:inline-block; width:10px; height:10px; border-radius:50%; margin-right:4px; }}
214
+ #status {{ color:#999; font-size:12px; margin-left:12px; }}
215
+ </style>
216
+ </head>
217
+ <body>
218
+ <div id="header">
219
+ <strong>{title}</strong>
220
+ <span id="status">{node_count} nodes, {edge_count} edges</span>
221
+ <div class="legend" style="margin-top:4px; font-size:12px;">
222
+ <span><span class="dot" style="background:#4C72B0;"></span>function</span>
223
+ <span><span class="dot" style="background:#55A868;"></span>method</span>
224
+ <span><span class="dot" style="background:#B0B0B0;"></span>external/unresolved</span>
225
+ </div>
226
+ </div>
227
+ <div id="graph"></div>
228
+ <script>
229
+ try {{
230
+ const nodesData = {nodes_json};
231
+ const edgesData = {edges_json};
232
+ const nodes = new vis.DataSet(nodesData);
233
+ const edges = new vis.DataSet(edgesData);
234
+ const container = document.getElementById('graph');
235
+ const data = {{ nodes, edges }};
236
+ const nodeCount = nodesData.length;
237
+
238
+ // Large graphs: use a cheaper solver and stop physics as soon as it
239
+ // stabilizes (or after a bounded number of iterations) so the view
240
+ // doesn't sit spinning forever on a big project.
241
+ const big = nodeCount > 400;
242
+ const options = {{
243
+ nodes: {{ shape: 'box', margin: 8, font: {{ color: '#fff', size: 13 }}, borderWidth: 0 }},
244
+ edges: {{ arrows: 'to', color: {{ color: '#666', highlight: '#fff' }}, smooth: {{ type: 'dynamic' }} }},
245
+ physics: {{
246
+ solver: big ? 'barnesHut' : 'forceAtlas2Based',
247
+ stabilization: {{ enabled: true, iterations: big ? 150 : 250, fit: true }}
248
+ }},
249
+ interaction: {{ hover: true, tooltipDelay: 100 }}
250
+ }};
251
+
252
+ const network = new vis.Network(container, data, options);
253
+ const statusEl = document.getElementById('status');
254
+
255
+ network.once('stabilizationIterationsDone', function () {{
256
+ network.setOptions({{ physics: false }});
257
+ network.fit();
258
+ statusEl.textContent = nodeCount + ' nodes, ' + edgesData.length + ' edges (layout complete, physics off)';
259
+ }});
260
+
261
+ // Safety net: even if stabilization never fully completes on a huge
262
+ // graph, force it to stop spinning and fit the view after a timeout.
263
+ setTimeout(function () {{
264
+ network.setOptions({{ physics: false }});
265
+ network.fit();
266
+ }}, 8000);
267
+ }} catch (err) {{
268
+ document.getElementById('graph').innerHTML =
269
+ '<div style="color:#f66;padding:24px;font-family:monospace;white-space:pre-wrap">' +
270
+ 'Error rendering graph: ' + err + '</div>';
271
+ console.error(err);
272
+ }}
273
+ </script>
274
+ </body>
275
+ </html>
276
+ """
277
+
278
+
279
+ def to_html(g: CallGraph, title: str = "Call Graph") -> str:
280
+ nodes = [
281
+ {
282
+ "id": nid,
283
+ "label": meta["label"] or "?",
284
+ "color": NODE_COLORS.get(meta["kind"], "#CCCCCC"),
285
+ "shape": "ellipse" if meta["kind"] == "external" else "box",
286
+ "title": f"{meta['kind']}: {meta['label']}",
287
+ }
288
+ for nid, meta in g.nodes.items()
289
+ ]
290
+ edges = [{"from": s, "to": d} for s, d in sorted(g.edges)]
291
+ return VIS_HTML_TEMPLATE.format(
292
+ title=title,
293
+ node_count=len(nodes),
294
+ edge_count=len(edges),
295
+ nodes_json=json.dumps(nodes),
296
+ edges_json=json.dumps(edges),
297
+ )
@@ -0,0 +1,52 @@
1
+ """Logging configuration for rust-analyzer-db."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import sys
7
+
8
+ _configured = False
9
+
10
+
11
+ class _SafeStreamHandler(logging.StreamHandler):
12
+ """StreamHandler that silently drops writes if the stream is closed."""
13
+
14
+ def emit(self, record: logging.LogRecord) -> None:
15
+ try:
16
+ if self.stream.closed:
17
+ return
18
+ super().emit(record)
19
+ except ValueError:
20
+ pass
21
+
22
+
23
+ def setup_logging(*, verbose: bool = False, json_output: bool = False) -> None:
24
+ """Configure the package-wide logger.
25
+
26
+ Args:
27
+ verbose: Enable DEBUG level output.
28
+ json_output: Format logs as single-line key=value pairs.
29
+ """
30
+ global _configured # noqa: PLW0603
31
+ if _configured:
32
+ return
33
+ _configured = True
34
+
35
+ level = logging.DEBUG if verbose else logging.INFO
36
+
37
+ if json_output:
38
+ fmt = '{"level":"%(levelname)s","logger":"%(name)s","msg":"%(message)s"}'
39
+ else:
40
+ fmt = "%(levelname)s: %(message)s"
41
+
42
+ handler = _SafeStreamHandler(sys.stderr)
43
+ handler.setFormatter(logging.Formatter(fmt))
44
+
45
+ root = logging.getLogger("rust_analyzer")
46
+ root.setLevel(level)
47
+ root.addHandler(handler)
48
+
49
+
50
+ def get_logger(name: str) -> logging.Logger:
51
+ """Get a child logger under the ``rust_analyzer`` namespace."""
52
+ return logging.getLogger(f"rust_analyzer.{name}")