graphlens-rust 0.5.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.
@@ -0,0 +1,6 @@
1
+ """Rust language adapter for graphlens."""
2
+
3
+ from graphlens_rust._adapter import RustAdapter
4
+ from graphlens_rust._resolver import RustAnalyzerResolver, RustResolver
5
+
6
+ __all__ = ["RustAdapter", "RustAnalyzerResolver", "RustResolver"]
@@ -0,0 +1,513 @@
1
+ """RustAdapter — orchestrates structural analysis of Rust crates."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from functools import partial
7
+ from pathlib import Path
8
+ from typing import TYPE_CHECKING
9
+
10
+ from graphlens import (
11
+ RESOLVER_STATUS_KEY,
12
+ AdapterError,
13
+ BoundaryRef,
14
+ GraphLens,
15
+ LanguageAdapter,
16
+ Node,
17
+ NodeKind,
18
+ Relation,
19
+ RelationKind,
20
+ ResolverStatus,
21
+ make_boundary_id,
22
+ )
23
+ from graphlens.utils import SpanIndex, make_node_id
24
+ from graphlens.utils.roots import filter_nested_root_files
25
+
26
+ from graphlens_rust._boundary import (
27
+ RUST_DEFAULT_BOUNDARY_EXTRACTORS,
28
+ RustBoundaryExtractor,
29
+ )
30
+ from graphlens_rust._deps import (
31
+ RUST_DEFAULT_DEP_PARSERS,
32
+ classify_rust_import,
33
+ read_crate_name,
34
+ )
35
+ from graphlens_rust._project_detector import find_rust_roots, is_rust_project
36
+ from graphlens_rust._resolver import RustResolver
37
+ from graphlens_rust._visitor import (
38
+ RustFileContext,
39
+ RustStructureExtractor,
40
+ parse_rust,
41
+ )
42
+
43
+ if TYPE_CHECKING:
44
+ from graphlens.contracts import DependencyFileParser, SymbolResolver
45
+ from tree_sitter import Node as TSNode
46
+
47
+ from graphlens_rust._visitor import OccurrenceRef
48
+
49
+ # Occurrence role -> the edge kind the resolution pass emits for it.
50
+ _ROLE_TO_KIND = {"call": RelationKind.CALLS}
51
+
52
+ logger = logging.getLogger("graphlens_rust")
53
+
54
+
55
+ class RustAdapter(LanguageAdapter):
56
+ """Language adapter for Rust crates (structure + imports)."""
57
+
58
+ def __init__(
59
+ self,
60
+ dep_parsers: list[DependencyFileParser] | None = None,
61
+ resolver: SymbolResolver | None = None,
62
+ boundary_extractors: list[RustBoundaryExtractor] | None = None,
63
+ ) -> None:
64
+ """Initialise with optional custom dep parsers and resolver."""
65
+ self._dep_parsers = (
66
+ dep_parsers
67
+ if dep_parsers is not None
68
+ else RUST_DEFAULT_DEP_PARSERS
69
+ )
70
+ self._resolver = (
71
+ resolver if resolver is not None else RustResolver()
72
+ )
73
+ self._boundary_extractors = (
74
+ boundary_extractors
75
+ if boundary_extractors is not None
76
+ else RUST_DEFAULT_BOUNDARY_EXTRACTORS
77
+ )
78
+
79
+ def language(self) -> str:
80
+ return "rust"
81
+
82
+ def file_extensions(self) -> set[str]:
83
+ return {".rs"}
84
+
85
+ def can_handle(self, project_root: str | Path) -> bool:
86
+ return is_rust_project(Path(project_root))
87
+
88
+ def analyze(
89
+ self,
90
+ project_root: str | Path,
91
+ files: list[Path] | None = None,
92
+ *,
93
+ strict: bool = False,
94
+ ) -> GraphLens:
95
+ project_root = Path(project_root).resolve()
96
+ graph = GraphLens()
97
+ statuses: list[ResolverStatus] = []
98
+
99
+ if files is not None:
100
+ _analyze_root(
101
+ graph,
102
+ project_root,
103
+ project_root,
104
+ files,
105
+ self._dep_parsers,
106
+ self._resolver,
107
+ self._boundary_extractors,
108
+ )
109
+ statuses.append(self._resolver.status())
110
+ else:
111
+ roots = find_rust_roots(project_root)
112
+ for crate_root in roots:
113
+ root_files = filter_nested_root_files(
114
+ self.collect_files(crate_root), crate_root, roots
115
+ )
116
+ _analyze_root(
117
+ graph,
118
+ project_root,
119
+ crate_root,
120
+ root_files,
121
+ self._dep_parsers,
122
+ self._resolver,
123
+ self._boundary_extractors,
124
+ )
125
+ statuses.append(self._resolver.status())
126
+
127
+ status = ResolverStatus.combine(statuses)
128
+ graph.metadata[RESOLVER_STATUS_KEY] = status.value
129
+ if strict and status is not ResolverStatus.OK:
130
+ msg = (
131
+ f"Rust resolver status is '{status.value}'; refusing to "
132
+ "return a degraded graph in strict mode"
133
+ )
134
+ raise AdapterError(msg)
135
+ return graph
136
+
137
+
138
+ def _module_qname(file: Path, crate_root: Path, crate_name: str) -> str:
139
+ try:
140
+ rel = file.relative_to(crate_root / "src")
141
+ except ValueError:
142
+ try:
143
+ rel = file.relative_to(crate_root)
144
+ except ValueError:
145
+ return crate_name
146
+ parts = list(rel.with_suffix("").parts)
147
+ if parts and parts[-1] in ("lib", "main", "mod"):
148
+ parts = parts[:-1]
149
+ return "::".join([crate_name, *parts]) if parts else crate_name
150
+
151
+
152
+ def _analyze_root( # noqa: PLR0913
153
+ graph: GraphLens,
154
+ project_root: Path,
155
+ crate_root: Path,
156
+ files: list[Path],
157
+ dep_parsers: list[DependencyFileParser],
158
+ resolver: SymbolResolver,
159
+ boundary_extractors: list[RustBoundaryExtractor],
160
+ ) -> None:
161
+ """Analyse one Rust crate root and populate ``graph`` in place."""
162
+ crate_name = read_crate_name(crate_root) or crate_root.name
163
+
164
+ required: set[str] = set()
165
+ for parser in dep_parsers:
166
+ if parser.can_parse(crate_root):
167
+ required |= parser.parse(crate_root)
168
+ classify = partial(
169
+ classify_rust_import, crate_name=crate_name, deps=required
170
+ )
171
+
172
+ project_id = make_node_id(
173
+ crate_name, crate_name, NodeKind.PROJECT.value
174
+ )
175
+ if project_id not in graph.nodes:
176
+ graph.add_node(
177
+ Node(
178
+ id=project_id,
179
+ kind=NodeKind.PROJECT,
180
+ qualified_name=crate_name,
181
+ name=crate_name,
182
+ )
183
+ )
184
+
185
+ modules: dict[str, str] = {}
186
+ parsed_files: list[tuple[str, str, TSNode]] = []
187
+ occurrences: list[tuple[str, OccurrenceRef]] = []
188
+ internal_imports: list[tuple[str, str, str]] = []
189
+ for file in files:
190
+ module_qname = _module_qname(file, crate_root, crate_name)
191
+ module_id = _ensure_module(
192
+ graph, crate_name, module_qname, project_id, modules
193
+ )
194
+ file_id = _ensure_file(
195
+ graph, crate_name, project_root, crate_root, file, module_id
196
+ )
197
+ try:
198
+ source = file.read_bytes()
199
+ except OSError as exc:
200
+ logger.warning("Cannot read %s: %s — skipping", file, exc)
201
+ continue
202
+ file_rel = graph.nodes[file_id].qualified_name
203
+ ctx = RustFileContext(
204
+ project_name=crate_name,
205
+ module_qname=module_qname,
206
+ file_id=file_id,
207
+ file_rel=file_rel,
208
+ )
209
+ root = parse_rust(source).root_node
210
+ extractor = RustStructureExtractor(graph, ctx, classify)
211
+ extractor.extract(root)
212
+ parsed_files.append((file_rel, file_id, root))
213
+ occurrences.extend(
214
+ (str(file), occ) for occ in extractor.occurrences
215
+ )
216
+ internal_imports.extend(extractor.internal_imports)
217
+
218
+ # Bind internal imports to the MODULE node they reference (now that every
219
+ # module exists), falling back to an EXTERNAL_SYMBOL when none matches.
220
+ _resolve_internal_imports(graph, crate_name, internal_imports, modules)
221
+
222
+ # Resolution pass: bind occurrences to real nodes or EXTERNAL_SYMBOL.
223
+ span_index = SpanIndex.from_graph(graph)
224
+ resolver.prepare(crate_root, files)
225
+ _resolve_occurrences(
226
+ graph, crate_name, project_root, resolver, span_index, occurrences
227
+ )
228
+
229
+ _extract_boundaries(graph, parsed_files, boundary_extractors)
230
+
231
+
232
+ def _module_candidates(
233
+ import_path: str, crate_name: str, module_qname: str
234
+ ) -> list[str]:
235
+ """
236
+ Return MODULE qnames an internal ``use`` path may resolve to.
237
+
238
+ Translates the path root to the crate-rooted module namespace
239
+ (``crate`` -> crate name, ``self`` -> current module, ``super`` ->
240
+ parent), then offers the full path (the path *is* a module) and its
241
+ parent (the final segment is an imported item, not a module).
242
+ """
243
+ segs = [s.strip() for s in import_path.split("::") if s.strip()]
244
+ if not segs:
245
+ return []
246
+ head = segs[0]
247
+ if head == "crate":
248
+ base, rest = [crate_name], segs[1:]
249
+ elif head == "self":
250
+ base, rest = module_qname.split("::"), segs[1:]
251
+ elif head == "super":
252
+ parts = module_qname.split("::")
253
+ supers = 0
254
+ for seg in segs:
255
+ if seg != "super":
256
+ break
257
+ supers += 1
258
+ if supers >= len(parts):
259
+ return []
260
+ base, rest = parts[: len(parts) - supers], segs[supers:]
261
+ elif crate_name and head.replace("-", "_") == crate_name.replace("-", "_"):
262
+ base, rest = [crate_name], segs[1:]
263
+ else: # pragma: no cover - classifier only marks the above as internal
264
+ return []
265
+ full = [*base, *rest]
266
+ candidates = ["::".join(full)] if full else []
267
+ if len(full) > 1:
268
+ candidates.append("::".join(full[:-1]))
269
+ return candidates
270
+
271
+
272
+ def _resolve_internal_imports(
273
+ graph: GraphLens,
274
+ crate_name: str,
275
+ internal_imports: list[tuple[str, str, str]],
276
+ modules: dict[str, str],
277
+ ) -> None:
278
+ """
279
+ Resolve each ``internal`` import to its MODULE node (per CLAUDE.md §9).
280
+
281
+ Imports whose module was not analyzed fall back to an EXTERNAL_SYMBOL so
282
+ the ``RESOLVES_TO`` edge is never missing.
283
+ """
284
+ for imp_id, import_path, module_qname in internal_imports:
285
+ target_id: str | None = None
286
+ for cand in _module_candidates(import_path, crate_name, module_qname):
287
+ target_id = modules.get(cand)
288
+ if target_id is not None:
289
+ break
290
+ if target_id is None:
291
+ target_id = _ensure_external_symbol(
292
+ graph, crate_name, import_path, "internal"
293
+ )
294
+ graph.add_relation(
295
+ Relation(imp_id, target_id, RelationKind.RESOLVES_TO)
296
+ )
297
+
298
+
299
+ def _ensure_external_symbol(
300
+ graph: GraphLens, project_name: str, qname: str, origin: str
301
+ ) -> str:
302
+ """Return the id of an EXTERNAL_SYMBOL node, creating it if needed."""
303
+ sym_id = make_node_id(
304
+ project_name, qname, NodeKind.EXTERNAL_SYMBOL.value
305
+ )
306
+ if sym_id not in graph.nodes:
307
+ graph.add_node(
308
+ Node(
309
+ id=sym_id,
310
+ kind=NodeKind.EXTERNAL_SYMBOL,
311
+ qualified_name=qname,
312
+ name=qname.rsplit("::", maxsplit=1)[-1],
313
+ metadata={"origin": origin},
314
+ )
315
+ )
316
+ return sym_id
317
+
318
+
319
+ def _relative_to(file_path: Path, project_root: Path) -> str:
320
+ """
321
+ Map an absolute resolver path to the graph's project-relative form.
322
+
323
+ rust-analyzer returns absolute paths, but graph nodes store file paths
324
+ relative to ``project_root``; reconcile them so SpanIndex lookups hit.
325
+ Falls back to the absolute string for paths outside the project (which
326
+ then resolve to an EXTERNAL_SYMBOL).
327
+ """
328
+ try:
329
+ return str(file_path.resolve().relative_to(project_root))
330
+ except (ValueError, OSError):
331
+ return str(file_path)
332
+
333
+
334
+ def _resolve_occurrences( # noqa: PLR0913
335
+ graph: GraphLens,
336
+ project_name: str,
337
+ project_root: Path,
338
+ resolver: SymbolResolver,
339
+ span_index: SpanIndex,
340
+ occurrences: list[tuple[str, OccurrenceRef]],
341
+ ) -> None:
342
+ """Resolve each occurrence to a definition node and emit its edge."""
343
+ for abs_path, occ in occurrences:
344
+ rel_kind = _ROLE_TO_KIND[occ.role]
345
+ ref = resolver.definition_at(Path(abs_path), occ.line, occ.col)
346
+ if ref is None:
347
+ continue
348
+ target_id: str | None = None
349
+ if ref.origin == "internal" and ref.file_path is not None:
350
+ target_id = span_index.at(
351
+ _relative_to(ref.file_path, project_root),
352
+ ref.line,
353
+ ref.col,
354
+ )
355
+ if target_id is None:
356
+ fallback_qname = (
357
+ ref.full_name
358
+ if ref.full_name
359
+ else f"{occ.role}@{occ.line}:{occ.col}"
360
+ )
361
+ target_id = _ensure_external_symbol(
362
+ graph, project_name, fallback_qname, ref.origin
363
+ )
364
+ graph.add_relation(
365
+ Relation(
366
+ source_id=occ.enclosing_id,
367
+ target_id=target_id,
368
+ kind=rel_kind,
369
+ metadata={"span": occ.span},
370
+ )
371
+ )
372
+
373
+
374
+ def _extract_boundaries(
375
+ graph: GraphLens,
376
+ parsed_files: list[tuple[str, str, TSNode]],
377
+ extractors: list[RustBoundaryExtractor],
378
+ ) -> None:
379
+ """Run boundary extractors and emit BOUNDARY nodes + EXPOSES/CONSUMES."""
380
+ if not extractors:
381
+ return
382
+ enclosers: dict[str, list[Node]] = {}
383
+ for node in graph.nodes.values():
384
+ if (
385
+ node.kind in (NodeKind.FUNCTION, NodeKind.METHOD)
386
+ and node.span is not None
387
+ and node.file_path is not None
388
+ ):
389
+ enclosers.setdefault(node.file_path, []).append(node)
390
+
391
+ for file_rel, file_id, root in parsed_files:
392
+ candidates = enclosers.get(file_rel, [])
393
+ for extractor in extractors:
394
+ for ref in extractor.extract(root):
395
+ enclosing_id = (
396
+ _innermost_enclosing(candidates, ref.line, ref.col)
397
+ or file_id
398
+ )
399
+ _add_boundary(graph, enclosing_id, ref)
400
+
401
+
402
+ def _innermost_enclosing(
403
+ candidates: list[Node], line: int, col: int
404
+ ) -> str | None:
405
+ """Return the id of the deepest function/method containing (line, col)."""
406
+ best_id: str | None = None
407
+ best_start: tuple[int, int] | None = None
408
+ for node in candidates:
409
+ span = node.span
410
+ if span is None:
411
+ continue # pragma: no cover - filtered before insertion
412
+ start = (span.start_line, span.start_col)
413
+ end = (span.end_line, span.end_col)
414
+ if start <= (line, col) <= end and (
415
+ best_start is None or start >= best_start
416
+ ):
417
+ best_id = node.id
418
+ best_start = start
419
+ return best_id
420
+
421
+
422
+ def _add_boundary(
423
+ graph: GraphLens, enclosing_id: str, ref: BoundaryRef
424
+ ) -> None:
425
+ boundary_id = make_boundary_id(ref.mechanism, ref.key)
426
+ if boundary_id not in graph.nodes:
427
+ graph.add_node(
428
+ Node(
429
+ id=boundary_id,
430
+ kind=NodeKind.BOUNDARY,
431
+ qualified_name=f"{ref.mechanism}:{ref.key}",
432
+ name=ref.key,
433
+ metadata={"mechanism": ref.mechanism, "key": ref.key},
434
+ )
435
+ )
436
+ kind = (
437
+ RelationKind.EXPOSES
438
+ if ref.role == "server"
439
+ else RelationKind.CONSUMES
440
+ )
441
+ metadata: dict[str, object] = {
442
+ "mechanism": ref.mechanism,
443
+ "key": ref.key,
444
+ "confidence": ref.confidence,
445
+ "role": ref.role,
446
+ "line": ref.line,
447
+ "col": ref.col,
448
+ }
449
+ metadata.update(ref.detail)
450
+ graph.add_relation(
451
+ Relation(
452
+ source_id=enclosing_id,
453
+ target_id=boundary_id,
454
+ kind=kind,
455
+ metadata=metadata,
456
+ )
457
+ )
458
+
459
+
460
+ def _ensure_module(
461
+ graph: GraphLens,
462
+ crate_name: str,
463
+ module_qname: str,
464
+ project_id: str,
465
+ modules: dict[str, str],
466
+ ) -> str:
467
+ if module_qname in modules:
468
+ return modules[module_qname]
469
+ module_id = make_node_id(
470
+ crate_name, module_qname, NodeKind.MODULE.value
471
+ )
472
+ graph.add_node(
473
+ Node(
474
+ id=module_id,
475
+ kind=NodeKind.MODULE,
476
+ qualified_name=module_qname,
477
+ name=module_qname.rsplit("::", maxsplit=1)[-1],
478
+ )
479
+ )
480
+ graph.add_relation(
481
+ Relation(project_id, module_id, RelationKind.CONTAINS)
482
+ )
483
+ modules[module_qname] = module_id
484
+ return module_id
485
+
486
+
487
+ def _ensure_file( # noqa: PLR0913
488
+ graph: GraphLens,
489
+ crate_name: str,
490
+ project_root: Path,
491
+ crate_root: Path,
492
+ file: Path,
493
+ module_id: str,
494
+ ) -> str:
495
+ try:
496
+ file_rel = str(file.relative_to(project_root))
497
+ except ValueError:
498
+ file_rel = str(file.relative_to(crate_root))
499
+ file_id = make_node_id(crate_name, file_rel, NodeKind.FILE.value)
500
+ if file_id not in graph.nodes:
501
+ graph.add_node(
502
+ Node(
503
+ id=file_id,
504
+ kind=NodeKind.FILE,
505
+ qualified_name=file_rel,
506
+ name=file.name,
507
+ file_path=file_rel,
508
+ )
509
+ )
510
+ graph.add_relation(
511
+ Relation(module_id, file_id, RelationKind.CONTAINS)
512
+ )
513
+ return file_id