graphlens-rust 0.5.0__tar.gz
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.
- graphlens_rust-0.5.0/PKG-INFO +8 -0
- graphlens_rust-0.5.0/pyproject.toml +38 -0
- graphlens_rust-0.5.0/src/graphlens_rust/__init__.py +6 -0
- graphlens_rust-0.5.0/src/graphlens_rust/_adapter.py +513 -0
- graphlens_rust-0.5.0/src/graphlens_rust/_boundary.py +299 -0
- graphlens_rust-0.5.0/src/graphlens_rust/_deps.py +85 -0
- graphlens_rust-0.5.0/src/graphlens_rust/_project_detector.py +40 -0
- graphlens_rust-0.5.0/src/graphlens_rust/_queries.py +26 -0
- graphlens_rust-0.5.0/src/graphlens_rust/_resolver.py +445 -0
- graphlens_rust-0.5.0/src/graphlens_rust/_visitor.py +327 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "graphlens-rust"
|
|
3
|
+
version = "0.5.0"
|
|
4
|
+
description = "Rust language adapter for graphlens"
|
|
5
|
+
requires-python = ">=3.13"
|
|
6
|
+
dependencies = [
|
|
7
|
+
"graphlens",
|
|
8
|
+
"tree-sitter>=0.24",
|
|
9
|
+
"tree-sitter-rust>=0.23",
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
[build-system]
|
|
13
|
+
requires = ["uv_build>=0.9.18,<0.12.0"]
|
|
14
|
+
build-backend = "uv_build"
|
|
15
|
+
|
|
16
|
+
[tool.uv.sources]
|
|
17
|
+
graphlens = { workspace = true }
|
|
18
|
+
|
|
19
|
+
[project.entry-points."graphlens.adapters"]
|
|
20
|
+
rust = "graphlens_rust:RustAdapter"
|
|
21
|
+
|
|
22
|
+
[tool.bandit]
|
|
23
|
+
skips = ["B101", "B404", "B603"]
|
|
24
|
+
|
|
25
|
+
[tool.pytest.ini_options]
|
|
26
|
+
testpaths = ["tests"]
|
|
27
|
+
|
|
28
|
+
[tool.coverage.run]
|
|
29
|
+
source = ["graphlens_rust"]
|
|
30
|
+
|
|
31
|
+
[tool.coverage.report]
|
|
32
|
+
fail_under = 100
|
|
33
|
+
show_missing = true
|
|
34
|
+
exclude_lines = [
|
|
35
|
+
"pragma: no cover",
|
|
36
|
+
"if TYPE_CHECKING:",
|
|
37
|
+
"\\.\\.\\.",
|
|
38
|
+
]
|
|
@@ -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
|