python-delphi-lsp 3.6.0__cp310-abi3-win_amd64.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.
Files changed (56) hide show
  1. delphi_lsp/__init__.py +184 -0
  2. delphi_lsp/_version.py +1 -0
  3. delphi_lsp/agent_cache.py +1574 -0
  4. delphi_lsp/agent_cli.py +902 -0
  5. delphi_lsp/agent_context.py +3907 -0
  6. delphi_lsp/agent_cpg.py +349 -0
  7. delphi_lsp/agent_cpg_builder.py +666 -0
  8. delphi_lsp/agent_layers.py +1410 -0
  9. delphi_lsp/agent_metrics.py +526 -0
  10. delphi_lsp/agent_protocol.py +445 -0
  11. delphi_lsp/agent_relations.py +1881 -0
  12. delphi_lsp/agent_templates.py +614 -0
  13. delphi_lsp/agent_wiki.py +2853 -0
  14. delphi_lsp/agent_workspace.py +860 -0
  15. delphi_lsp/ast_serialize.py +126 -0
  16. delphi_lsp/binary.py +250 -0
  17. delphi_lsp/comment_builder.py +28 -0
  18. delphi_lsp/consts.py +345 -0
  19. delphi_lsp/delphi_lsp_native.pyd +0 -0
  20. delphi_lsp/delphiast_lexer.py +399 -0
  21. delphi_lsp/delphiast_parser.py +2189 -0
  22. delphi_lsp/delphiast_tokens.py +41 -0
  23. delphi_lsp/grammar.py +459 -0
  24. delphi_lsp/incremental.py +1139 -0
  25. delphi_lsp/lark_builder.py +2684 -0
  26. delphi_lsp/lark_tokens.py +236 -0
  27. delphi_lsp/lexical_scanner.py +30 -0
  28. delphi_lsp/lsp_server.py +6698 -0
  29. delphi_lsp/metrics.py +1452 -0
  30. delphi_lsp/native_bridge.py +300 -0
  31. delphi_lsp/navigation_cache.py +189 -0
  32. delphi_lsp/nodes.py +462 -0
  33. delphi_lsp/parallel_outline.py +662 -0
  34. delphi_lsp/parse_artifacts.py +969 -0
  35. delphi_lsp/parser.py +342 -0
  36. delphi_lsp/parser_backend.py +139 -0
  37. delphi_lsp/preprocessor.py +1122 -0
  38. delphi_lsp/progress.py +26 -0
  39. delphi_lsp/project_config.py +285 -0
  40. delphi_lsp/project_discovery.py +932 -0
  41. delphi_lsp/project_indexer.py +470 -0
  42. delphi_lsp/py.typed +1 -0
  43. delphi_lsp/response_cache.py +465 -0
  44. delphi_lsp/semantic.py +394 -0
  45. delphi_lsp/semantic_builder.py +2302 -0
  46. delphi_lsp/source_reader.py +17 -0
  47. delphi_lsp/wiki_external_sort.py +392 -0
  48. delphi_lsp/wiki_memory.py +548 -0
  49. delphi_lsp/workspace.py +83 -0
  50. delphi_lsp/writer.py +73 -0
  51. python_delphi_lsp-3.6.0.dist-info/METADATA +766 -0
  52. python_delphi_lsp-3.6.0.dist-info/RECORD +56 -0
  53. python_delphi_lsp-3.6.0.dist-info/WHEEL +4 -0
  54. python_delphi_lsp-3.6.0.dist-info/entry_points.txt +3 -0
  55. python_delphi_lsp-3.6.0.dist-info/licenses/LICENSE +373 -0
  56. python_delphi_lsp-3.6.0.dist-info/sboms/python-delphi-lsp.cyclonedx.json +770 -0
@@ -0,0 +1,1881 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import unicodedata
5
+ from collections.abc import Callable, Iterable, Mapping, Sequence
6
+ from dataclasses import dataclass
7
+ from pathlib import Path, PurePath, PurePosixPath, PureWindowsPath
8
+ from typing import TypeVar
9
+
10
+ from .agent_protocol import AgentProtocolError
11
+ from .agent_workspace import AgentWorkspace
12
+ from .consts import AttributeName, SyntaxNodeType
13
+ from .nodes import SyntaxNode
14
+ from .parser import DelphiParser
15
+ from .parser_backend import ParserMode
16
+ from .preprocessor import PreprocessedSource
17
+ from .project_config import workspace_include_loader
18
+ from .project_indexer import ProjectIndexer, ProjectProblem
19
+ from .semantic import (
20
+ GenericInstanceTypeRef,
21
+ NamedTypeRef,
22
+ ProcTypeRef,
23
+ ReferenceKind,
24
+ Scope,
25
+ ScopeKind,
26
+ SourceRange,
27
+ Symbol,
28
+ SymbolIndex,
29
+ SymbolKind,
30
+ TypeRef,
31
+ )
32
+ from .source_reader import read_source_text
33
+ from .workspace import WorkspaceSemanticResult, build_workspace_semantics_from_roots
34
+
35
+ _ROUTINE_KINDS = frozenset(
36
+ {
37
+ SymbolKind.METHOD.value,
38
+ SymbolKind.FUNCTION.value,
39
+ SymbolKind.PROCEDURE.value,
40
+ SymbolKind.CONSTRUCTOR.value,
41
+ SymbolKind.DESTRUCTOR.value,
42
+ }
43
+ )
44
+ _TYPE_KINDS = frozenset(
45
+ {
46
+ SymbolKind.CLASS.value,
47
+ SymbolKind.RECORD.value,
48
+ SymbolKind.INTERFACE.value,
49
+ }
50
+ )
51
+ _GroupKey = tuple[str, str, str, str]
52
+ _AdjacencyValue = TypeVar("_AdjacencyValue")
53
+ _MAX_RELATION_PROBLEM_SUMMARIES = 50
54
+ _MAX_REFERENCE_CANDIDATE_FILES = 64
55
+ _REFERENCE_SEMANTIC_BATCH_SIZE = 8
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class RelationTarget:
60
+ target_id: str
61
+ source_path: str
62
+ path: str
63
+ unit_id: str
64
+ unit_name: str
65
+ name: str
66
+ qualified_name: str
67
+ kind: str
68
+ signature: str
69
+ line: int
70
+ column: int
71
+ card: Mapping[str, object]
72
+ visibility: str = ""
73
+ type_name: str = ""
74
+ owner: str = ""
75
+
76
+
77
+ @dataclass(frozen=True)
78
+ class _ReferenceRecord:
79
+ target_group: _GroupKey
80
+ owner_group: _GroupKey | None
81
+ kind: ReferenceKind
82
+ evidence: Mapping[str, object]
83
+ referencing_symbol: Mapping[str, object] | None
84
+
85
+
86
+ @dataclass(frozen=True)
87
+ class _TypeEdge:
88
+ source_group: _GroupKey
89
+ target_group: _GroupKey
90
+ evidence: Mapping[str, object]
91
+
92
+
93
+ @dataclass(frozen=True)
94
+ class _UnitEdge:
95
+ source_group: _GroupKey
96
+ target_group: _GroupKey
97
+ evidence: Mapping[str, object]
98
+
99
+
100
+ class ProjectRelationIndex:
101
+ """Lazy deep semantic graph for one selected project revision."""
102
+
103
+ def __init__(
104
+ self,
105
+ workspace: AgentWorkspace,
106
+ project_id: str,
107
+ revision: str,
108
+ targets: Sequence[RelationTarget],
109
+ *,
110
+ targets_complete: bool = True,
111
+ unit_targets_complete: bool = True,
112
+ ) -> None:
113
+ self.project_id = project_id
114
+ self.revision = revision
115
+ self.targets_complete = targets_complete
116
+ self.unit_targets_complete = unit_targets_complete
117
+ self._workspace = workspace
118
+ self._root = workspace.root
119
+ self._unit_name_counts: dict[str, int] = {}
120
+ for unit in workspace.units:
121
+ normalized_name = _normalized(unit.name)
122
+ self._unit_name_counts[normalized_name] = (
123
+ self._unit_name_counts.get(normalized_name, 0) + 1
124
+ )
125
+ self._targets = {target.target_id: target for target in targets}
126
+ duplicate_target_units = {
127
+ _normalized(target.unit_name)
128
+ for target in targets
129
+ if self._unit_name_counts.get(_normalized(target.unit_name), 0) > 1
130
+ }
131
+ self._project_memberships = (
132
+ _workspace_project_memberships(workspace)
133
+ if duplicate_target_units
134
+ else ()
135
+ )
136
+ self._target_groups = {
137
+ target.target_id: _target_group(target)
138
+ for target in targets
139
+ }
140
+ self._groups: dict[_GroupKey, list[RelationTarget]] = {}
141
+ for target in targets:
142
+ self._groups.setdefault(_target_group(target), []).append(target)
143
+ for group in self._groups.values():
144
+ group.sort(key=_target_sort_key)
145
+
146
+ self._path_display: dict[str, str] = {}
147
+ self._exact_targets: dict[tuple[str, int, int, str, str], list[RelationTarget]] = {}
148
+ self._position_targets: dict[tuple[str, int, int, str], list[RelationTarget]] = {}
149
+ self._line_targets: dict[tuple[str, int, str, str], list[RelationTarget]] = {}
150
+ self._identity_targets: dict[tuple[str, str, str], list[RelationTarget]] = {}
151
+ for target in targets:
152
+ path_key = _path_key(target.source_path)
153
+ self._path_display.setdefault(path_key, target.path)
154
+ exact = (path_key, target.line, target.column, target.kind, _normalized(target.name))
155
+ self._exact_targets.setdefault(exact, []).append(target)
156
+ position = (path_key, target.line, target.column, target.kind)
157
+ self._position_targets.setdefault(position, []).append(target)
158
+ line = (path_key, target.line, target.kind, _normalized(target.name))
159
+ self._line_targets.setdefault(line, []).append(target)
160
+ identity = (
161
+ _normalized(target.qualified_name),
162
+ target.kind,
163
+ _target_signature_identity(target),
164
+ )
165
+ self._identity_targets.setdefault(identity, []).append(target)
166
+
167
+ self._roots: dict[str, SyntaxNode] = {}
168
+ self._problems: list[dict[str, object]] = []
169
+ self._semantics: WorkspaceSemanticResult | None = None
170
+ self._semantic_unit_paths: dict[int, str] = {}
171
+ self._symbol_targets: dict[int, RelationTarget | None] = {}
172
+ self._references: list[_ReferenceRecord] = []
173
+ self._unit_edges: list[_UnitEdge] = []
174
+ self._inherits: list[_TypeEdge] = []
175
+ self._implements: list[_TypeEdge] = []
176
+ self._unresolved_references = 0
177
+ self._ambiguous_references = 0
178
+ self._deep_graph_ready = False
179
+ self._reference_groups_loaded: set[_GroupKey] = set()
180
+ self._reference_groups_complete: dict[_GroupKey, bool] = {}
181
+ self._unit_sources_loaded: set[_GroupKey] = set()
182
+ self._index_graph()
183
+
184
+ @property
185
+ def estimated_cache_bytes(self) -> int:
186
+ adjacency_maps = (
187
+ self._references_by_target,
188
+ self._calls_by_target,
189
+ self._calls_by_owner,
190
+ self._unit_edges_by_source,
191
+ self._unit_edges_by_target,
192
+ self._inherits_by_source,
193
+ self._implements_by_source,
194
+ )
195
+ adjacency_groups = sum(len(groups) for groups in adjacency_maps)
196
+ adjacency_slots = sum(
197
+ len(values)
198
+ for groups in adjacency_maps
199
+ for values in groups.values()
200
+ )
201
+ edge_bytes = (
202
+ len(self._references) * 384
203
+ + len(self._unit_edges) * 320
204
+ + (len(self._inherits) + len(self._implements)) * 288
205
+ )
206
+ return (
207
+ 4096
208
+ + len(self._targets) * 512
209
+ + edge_bytes
210
+ + adjacency_groups * 192
211
+ + adjacency_slots * 8
212
+ + len(self._problems) * 512
213
+ )
214
+
215
+ def has_target(self, target_id: str) -> bool:
216
+ return target_id in self._targets
217
+
218
+ def trace(
219
+ self,
220
+ target_id: str,
221
+ relation: str,
222
+ *,
223
+ cancel_check: Callable[[], None] | None = None,
224
+ max_relations: int = 12,
225
+ ) -> list[dict[str, object]]:
226
+ target = self._targets.get(target_id)
227
+ if target is None:
228
+ raise AgentProtocolError("target_not_found", f"Target not found: {target_id}.")
229
+ self._validate_applicability(target, relation)
230
+ group = self._target_groups[target_id]
231
+ active_project = self._workspace.active_project
232
+ lightweight_units = (
233
+ active_project is not None
234
+ and active_project.kind == "workspace"
235
+ )
236
+ if relation == "references" and (
237
+ len(self._workspace.units) >= 256
238
+ or (
239
+ target.kind != SymbolKind.UNIT.value
240
+ and self._unit_name_counts.get(
241
+ _normalized(target.unit_name),
242
+ 0,
243
+ ) > 1
244
+ )
245
+ ):
246
+ self._ensure_reference_graph(
247
+ group,
248
+ target,
249
+ cancel_check=cancel_check,
250
+ max_relations=max_relations,
251
+ )
252
+ elif relation == "uses" and lightweight_units:
253
+ self._ensure_unit_graph((group,))
254
+ elif relation == "used_by" and lightweight_units:
255
+ self._ensure_unit_graph(
256
+ tuple(
257
+ candidate_group
258
+ for candidate_group, candidates in self._groups.items()
259
+ if candidates and candidates[0].kind == SymbolKind.UNIT.value
260
+ )
261
+ )
262
+ else:
263
+ self._ensure_deep_graph(cancel_check=cancel_check)
264
+
265
+ if relation == "references":
266
+ items = [
267
+ self._relation_item(
268
+ relation,
269
+ record.owner_group or record.target_group,
270
+ record.evidence,
271
+ referencing_symbol=record.referencing_symbol,
272
+ )
273
+ for record in self._references_by_target.get(group, ())
274
+ ]
275
+ if target.kind == SymbolKind.UNIT.value:
276
+ items.extend(
277
+ self._relation_item(relation, edge.source_group, edge.evidence)
278
+ for edge in self._unit_edges_by_target.get(group, ())
279
+ )
280
+ elif relation == "callers":
281
+ items = [
282
+ self._relation_item(relation, record.owner_group, record.evidence)
283
+ for record in self._calls_by_target.get(group, ())
284
+ if record.owner_group is not None
285
+ ]
286
+ elif relation == "callees":
287
+ items = [
288
+ self._relation_item(relation, record.target_group, record.evidence)
289
+ for record in self._calls_by_owner.get(group, ())
290
+ ]
291
+ elif relation == "uses":
292
+ items = [
293
+ self._relation_item(relation, edge.target_group, edge.evidence)
294
+ for edge in self._unit_edges_by_source.get(group, ())
295
+ ]
296
+ elif relation == "used_by":
297
+ items = [
298
+ self._relation_item(relation, edge.source_group, edge.evidence)
299
+ for edge in self._unit_edges_by_target.get(group, ())
300
+ ]
301
+ elif relation == "inherits":
302
+ items = [
303
+ self._relation_item(relation, edge.target_group, edge.evidence)
304
+ for edge in self._inherits_by_source.get(group, ())
305
+ ]
306
+ elif relation == "implements":
307
+ items = [
308
+ self._relation_item(relation, edge.target_group, edge.evidence)
309
+ for edge in self._implements_by_source.get(group, ())
310
+ ]
311
+ else: # AgentRequest validation normally rejects this first.
312
+ raise AgentProtocolError("invalid_relation", f"Unsupported relation value: {relation!r}.")
313
+
314
+ compact_items = [item for item in items if item is not None]
315
+ relations = _dedupe_mappings(compact_items)
316
+ relations.sort(key=_relation_sort_key)
317
+ problem_summaries, problem_count = _summarize_relation_problems(
318
+ self._problems,
319
+ limit=_MAX_RELATION_PROBLEM_SUMMARIES,
320
+ )
321
+ result = list(relations)
322
+ result.append(
323
+ self._metadata(
324
+ relation,
325
+ problem_summaries=problem_summaries,
326
+ problem_count=problem_count,
327
+ scan_complete=(
328
+ self._deep_graph_ready
329
+ or self._reference_groups_complete.get(group, False)
330
+ if relation == "references"
331
+ else True
332
+ ),
333
+ )
334
+ )
335
+ return result
336
+
337
+ def _ensure_unit_graph(self, groups: Sequence[_GroupKey]) -> None:
338
+ if self._deep_graph_ready:
339
+ return
340
+ for group in groups:
341
+ if group in self._unit_sources_loaded:
342
+ continue
343
+ self._unit_sources_loaded.add(group)
344
+ candidates = self._groups.get(group)
345
+ if not candidates:
346
+ continue
347
+ source = candidates[0]
348
+ try:
349
+ text = read_source_text(Path(source.source_path))
350
+ parsed = DelphiParser(
351
+ include_paths=self._workspace.include_paths,
352
+ defines=self._workspace.defines,
353
+ include_loader=workspace_include_loader(
354
+ self._workspace.project_config,
355
+ self._workspace.include_paths,
356
+ ),
357
+ mode=(
358
+ ParserMode.TOLERANT
359
+ if len(self._workspace.units) >= 256
360
+ else ParserMode.STRICT
361
+ ),
362
+ ).parse(
363
+ text,
364
+ source.source_path,
365
+ build_semantic=False,
366
+ )
367
+ except Exception as error:
368
+ self._problems.append(
369
+ {
370
+ "item_type": "relation_problem",
371
+ "kind": "cant_parse_file",
372
+ "message": _safe_problem_message(
373
+ f"{type(error).__name__}: {error}",
374
+ self._root,
375
+ (
376
+ source.source_path,
377
+ getattr(error, "filename", None),
378
+ getattr(error, "filename2", None),
379
+ ),
380
+ ),
381
+ "path": source.path,
382
+ }
383
+ )
384
+ continue
385
+ if parsed.problems:
386
+ first = parsed.problems[0]
387
+ self._problems.append(
388
+ {
389
+ "item_type": "relation_problem",
390
+ "kind": "partial_parse",
391
+ "message": (
392
+ f"Partial DelphiAST parse at {first.line}:{first.column}: "
393
+ f"{first.message}"
394
+ ),
395
+ "path": source.path,
396
+ }
397
+ )
398
+ self._add_lightweight_unit_edges(
399
+ source,
400
+ parsed.root,
401
+ parsed.preprocessed,
402
+ )
403
+ self._index_graph()
404
+ self._problems = _dedupe_mappings(self._problems)
405
+
406
+ def _add_lightweight_unit_edges(
407
+ self,
408
+ source: RelationTarget,
409
+ root: SyntaxNode,
410
+ preprocessed: PreprocessedSource,
411
+ ) -> None:
412
+ source_group = _target_group(source)
413
+ unit_targets: dict[str, list[RelationTarget]] = {}
414
+ for target in self._targets.values():
415
+ if target.kind != SymbolKind.UNIT.value:
416
+ continue
417
+ unit_targets.setdefault(
418
+ _normalized(target.unit_name or target.name),
419
+ [],
420
+ ).append(target)
421
+ pending = [root]
422
+ while pending:
423
+ node = pending.pop()
424
+ pending.extend(reversed(node.child_nodes))
425
+ if node.typ not in {
426
+ SyntaxNodeType.ntUses,
427
+ SyntaxNodeType.ntContains,
428
+ SyntaxNodeType.ntRequires,
429
+ }:
430
+ continue
431
+ for child in node.child_nodes:
432
+ if child.typ != SyntaxNodeType.ntUnit:
433
+ continue
434
+ name = child.get_attribute(AttributeName.anName).strip()
435
+ candidates = unit_targets.get(_normalized(name), ())
436
+ resolved_target = self._lightweight_unit_target(source, candidates)
437
+ if resolved_target is None:
438
+ self._unresolved_references += 1
439
+ self._problems.append(
440
+ {
441
+ "item_type": "relation_problem",
442
+ "kind": "unresolved_unit",
443
+ "message": f"Unit not found: {name}",
444
+ "path": source.path,
445
+ "line": child.line,
446
+ "column": child.col,
447
+ }
448
+ )
449
+ continue
450
+ mapped_file, mapped_line, mapped_column = (
451
+ preprocessed.map_position(child.line, child.col)
452
+ )
453
+ evidence = {
454
+ "path": (
455
+ self._display_path(mapped_file)
456
+ if mapped_file and mapped_line > 0
457
+ else source.path
458
+ ),
459
+ "line": mapped_line if mapped_line > 0 else child.line,
460
+ "column": (
461
+ mapped_column if mapped_column > 0 else child.col
462
+ ),
463
+ "kind": (
464
+ "contains"
465
+ if node.typ == SyntaxNodeType.ntContains
466
+ else "uses"
467
+ ),
468
+ }
469
+ self._unit_edges.append(
470
+ _UnitEdge(
471
+ source_group=source_group,
472
+ target_group=_target_group(resolved_target),
473
+ evidence=evidence,
474
+ )
475
+ )
476
+
477
+ def _lightweight_unit_target(
478
+ self,
479
+ source: RelationTarget,
480
+ candidates: Sequence[RelationTarget],
481
+ ) -> RelationTarget | None:
482
+ if len(candidates) == 1:
483
+ return candidates[0]
484
+ source_path = _path_key(source.source_path)
485
+ local = [
486
+ candidate
487
+ for candidate in candidates
488
+ if any(
489
+ source_path in membership
490
+ and _path_key(candidate.source_path) in membership
491
+ for membership in self._project_memberships
492
+ )
493
+ ]
494
+ local_groups = {_target_group(candidate) for candidate in local}
495
+ if len(local_groups) == 1:
496
+ return min(local, key=_target_sort_key)
497
+ source_parent = _path_key(Path(source.source_path).parent)
498
+ same_directory = [
499
+ candidate
500
+ for candidate in candidates
501
+ if _path_key(Path(candidate.source_path).parent) == source_parent
502
+ ]
503
+ same_directory_groups = {
504
+ _target_group(candidate) for candidate in same_directory
505
+ }
506
+ if len(same_directory_groups) == 1:
507
+ return min(same_directory, key=_target_sort_key)
508
+ return None
509
+
510
+ def _ensure_reference_graph(
511
+ self,
512
+ group: _GroupKey,
513
+ target: RelationTarget,
514
+ *,
515
+ cancel_check: Callable[[], None] | None,
516
+ max_relations: int,
517
+ ) -> None:
518
+ if self._deep_graph_ready or group in self._reference_groups_loaded:
519
+ return
520
+ scan_complete = self._scan_reference_sources(
521
+ group,
522
+ target,
523
+ cancel_check=cancel_check,
524
+ max_relations=max_relations,
525
+ )
526
+ self._reference_groups_loaded.add(group)
527
+ self._reference_groups_complete[group] = scan_complete
528
+ self._index_graph()
529
+ self._problems = _dedupe_mappings(self._problems)
530
+
531
+ def _scan_reference_sources(
532
+ self,
533
+ group: _GroupKey,
534
+ target: RelationTarget,
535
+ *,
536
+ cancel_check: Callable[[], None] | None,
537
+ max_relations: int,
538
+ ) -> bool:
539
+ parser = DelphiParser(
540
+ include_paths=self._workspace.include_paths,
541
+ defines=self._workspace.defines,
542
+ include_loader=workspace_include_loader(
543
+ self._workspace.project_config,
544
+ self._workspace.include_paths,
545
+ ),
546
+ mode=(
547
+ ParserMode.TOLERANT
548
+ if len(self._workspace.units) >= 256
549
+ else ParserMode.STRICT
550
+ ),
551
+ legacy_fallback_max_chars=0,
552
+ )
553
+ target_path = Path(target.source_path).expanduser().resolve()
554
+ bounded = len(self._workspace.units) >= 256
555
+
556
+ def relation_budget_reached() -> bool:
557
+ return (
558
+ bounded
559
+ and len(self._references_by_target.get(group, ()))
560
+ >= max_relations
561
+ )
562
+
563
+ target_parse_path = target_path
564
+ target_parsed = None
565
+ target_is_unit = False
566
+ owner_candidates: list[Path] = []
567
+ for unit in self._workspace.units:
568
+ source_path = Path(unit.path)
569
+ if not source_path.is_absolute():
570
+ source_path = self._workspace.root / source_path
571
+ source_path = source_path.expanduser().resolve()
572
+ if source_path == target_path:
573
+ target_is_unit = True
574
+ break
575
+ if _normalized(unit.name) == _normalized(target.unit_name):
576
+ owner_candidates.append(source_path)
577
+ if not target_is_unit:
578
+ target_path_key = _path_key(target_path)
579
+ for owner_path in owner_candidates:
580
+ try:
581
+ owner_text = read_source_text(owner_path)
582
+ owner_parsed = parser.parse(
583
+ owner_text,
584
+ str(owner_path),
585
+ build_semantic=False,
586
+ )
587
+ except Exception as error: # noqa: BLE001
588
+ self._add_load_problem(owner_path, error)
589
+ continue
590
+ if any(
591
+ _path_key(entry.file_name) == target_path_key
592
+ for entry in owner_parsed.preprocessed.source_map
593
+ ):
594
+ target_parse_path = owner_path
595
+ target_parsed = owner_parsed
596
+ break
597
+ if target_parsed is None:
598
+ try:
599
+ target_text = read_source_text(target_path)
600
+ target_parsed = parser.parse(
601
+ target_text,
602
+ str(target_path),
603
+ build_semantic=False,
604
+ )
605
+ except Exception as error: # noqa: BLE001
606
+ self._add_load_problem(target_path, error)
607
+ return True
608
+
609
+ def add_semantics(
610
+ candidates: Sequence[tuple[Path, SyntaxNode, PreprocessedSource]],
611
+ ) -> None:
612
+ roots = {str(target_parse_path): target_parsed.root}
613
+ sources = {
614
+ str(target_parse_path): target_parsed.preprocessed,
615
+ }
616
+ for source_path, root, preprocessed in candidates:
617
+ roots[str(source_path)] = root
618
+ sources[str(source_path)] = preprocessed
619
+ semantics = build_workspace_semantics_from_roots(
620
+ roots,
621
+ preprocessed_sources=sources,
622
+ )
623
+ self._semantics = semantics
624
+ self._roots = roots
625
+ # Semantic symbols belong to this short-lived candidate batch.
626
+ # Object ids may be reused after the previous batch is released.
627
+ self._symbol_targets.clear()
628
+ self._build_graph(
629
+ semantics,
630
+ cancel_check=cancel_check,
631
+ )
632
+ self._add_semantic_problems(semantics)
633
+ self._index_graph()
634
+
635
+ needle = target.name.casefold()
636
+ unit_needle = target.unit_name.casefold()
637
+ legacy_candidates_parsed = 0
638
+ exact_extras_parsed = 0
639
+ scan_complete = True
640
+ candidate_batch: list[tuple[Path, SyntaxNode, PreprocessedSource]] = []
641
+ semantics_built = False
642
+
643
+ def flush_candidates() -> bool:
644
+ nonlocal semantics_built
645
+ add_semantics(candidate_batch)
646
+ candidate_batch.clear()
647
+ semantics_built = True
648
+ return relation_budget_reached()
649
+
650
+ for unit in self._workspace.units:
651
+ if cancel_check is not None:
652
+ cancel_check()
653
+ source_path = Path(unit.path)
654
+ if not source_path.is_absolute():
655
+ source_path = self._workspace.root / source_path
656
+ source_path = source_path.expanduser().resolve()
657
+ if source_path == target_path:
658
+ continue
659
+ if _normalized(unit.name) == _normalized(target.unit_name):
660
+ continue
661
+ try:
662
+ text = read_source_text(source_path)
663
+ except Exception as error: # noqa: BLE001
664
+ self._add_load_problem(source_path, error)
665
+ continue
666
+ folded_text = text.casefold()
667
+ if needle not in folded_text or unit_needle not in folded_text:
668
+ continue
669
+ legacy_limit_reached = (
670
+ legacy_candidates_parsed >= _MAX_REFERENCE_CANDIDATE_FILES
671
+ )
672
+ if legacy_limit_reached:
673
+ scan_complete = False
674
+ exact_candidate = _text_contains_reference_token(
675
+ text,
676
+ needle,
677
+ ) and _text_contains_reference_token(text, unit_needle)
678
+ if legacy_limit_reached and (
679
+ not exact_candidate
680
+ or exact_extras_parsed >= _MAX_REFERENCE_CANDIDATE_FILES
681
+ ):
682
+ continue
683
+ if cancel_check is not None:
684
+ cancel_check()
685
+ try:
686
+ parsed = parser.parse(
687
+ text,
688
+ str(source_path),
689
+ build_semantic=False,
690
+ )
691
+ except Exception as error: # noqa: BLE001
692
+ self._add_load_problem(source_path, error)
693
+ continue
694
+ if legacy_limit_reached:
695
+ exact_extras_parsed += 1
696
+ else:
697
+ legacy_candidates_parsed += 1
698
+ if cancel_check is not None:
699
+ cancel_check()
700
+ candidate_batch.append(
701
+ (source_path, parsed.root, parsed.preprocessed)
702
+ )
703
+ if (
704
+ len(candidate_batch) >= _REFERENCE_SEMANTIC_BATCH_SIZE
705
+ and flush_candidates()
706
+ ):
707
+ return False
708
+ budget_reached = (
709
+ (candidate_batch or not semantics_built)
710
+ and flush_candidates()
711
+ )
712
+ return scan_complete and not budget_reached
713
+
714
+ def _add_load_problem(
715
+ self,
716
+ source_path: Path,
717
+ error: Exception,
718
+ ) -> None:
719
+ self._problems.append(
720
+ {
721
+ "item_type": "relation_problem",
722
+ "kind": "cant_parse_file",
723
+ "message": _safe_problem_message(
724
+ f"{type(error).__name__}: {error}",
725
+ self._root,
726
+ (
727
+ source_path,
728
+ getattr(error, "filename", None),
729
+ getattr(error, "filename2", None),
730
+ ),
731
+ ),
732
+ "path": _display_project_path(
733
+ source_path,
734
+ self._root,
735
+ ),
736
+ }
737
+ )
738
+
739
+ def _ensure_deep_graph(
740
+ self,
741
+ *,
742
+ cancel_check: Callable[[], None] | None = None,
743
+ ) -> None:
744
+ if self._deep_graph_ready:
745
+ return
746
+ if cancel_check is None:
747
+ roots, preprocessed_sources, load_problems = _load_project_roots(
748
+ self._workspace,
749
+ )
750
+ else:
751
+ roots, preprocessed_sources, load_problems = _load_project_roots(
752
+ self._workspace,
753
+ cancel_check=cancel_check,
754
+ )
755
+ self._roots = roots
756
+ self._problems = list(load_problems)
757
+ self._semantics = (
758
+ build_workspace_semantics_from_roots(
759
+ roots,
760
+ preprocessed_sources=preprocessed_sources,
761
+ )
762
+ if roots
763
+ else None
764
+ )
765
+ self._symbol_targets.clear()
766
+ self._references.clear()
767
+ self._unit_edges.clear()
768
+ self._inherits.clear()
769
+ self._implements.clear()
770
+ self._unresolved_references = 0
771
+ self._ambiguous_references = 0
772
+ if self._semantics is not None:
773
+ self._build_graph(
774
+ self._semantics,
775
+ cancel_check=cancel_check,
776
+ )
777
+ self._add_contains_edges(self._semantics)
778
+ self._add_semantic_problems(self._semantics)
779
+ self._deep_graph_ready = True
780
+ self._index_graph()
781
+ self._problems = _dedupe_mappings(self._problems)
782
+
783
+ def _index_graph(self) -> None:
784
+ references_by_target: dict[_GroupKey, list[_ReferenceRecord]] = {}
785
+ calls_by_target: dict[_GroupKey, list[_ReferenceRecord]] = {}
786
+ calls_by_owner: dict[_GroupKey, list[_ReferenceRecord]] = {}
787
+ for record in self._references:
788
+ references_by_target.setdefault(record.target_group, []).append(record)
789
+ if record.kind != ReferenceKind.CALL:
790
+ continue
791
+ calls_by_target.setdefault(record.target_group, []).append(record)
792
+ if record.owner_group is not None:
793
+ calls_by_owner.setdefault(record.owner_group, []).append(record)
794
+ self._references_by_target = _freeze_adjacency(references_by_target)
795
+ self._calls_by_target = _freeze_adjacency(calls_by_target)
796
+ self._calls_by_owner = _freeze_adjacency(calls_by_owner)
797
+
798
+ unit_edges_by_source: dict[_GroupKey, list[_UnitEdge]] = {}
799
+ unit_edges_by_target: dict[_GroupKey, list[_UnitEdge]] = {}
800
+ for edge in self._unit_edges:
801
+ unit_edges_by_source.setdefault(edge.source_group, []).append(edge)
802
+ unit_edges_by_target.setdefault(edge.target_group, []).append(edge)
803
+ self._unit_edges_by_source = _freeze_adjacency(unit_edges_by_source)
804
+ self._unit_edges_by_target = _freeze_adjacency(unit_edges_by_target)
805
+
806
+ inherits_by_source: dict[_GroupKey, list[_TypeEdge]] = {}
807
+ for edge in self._inherits:
808
+ inherits_by_source.setdefault(edge.source_group, []).append(edge)
809
+ self._inherits_by_source = _freeze_adjacency(inherits_by_source)
810
+
811
+ implements_by_source: dict[_GroupKey, list[_TypeEdge]] = {}
812
+ for edge in self._implements:
813
+ implements_by_source.setdefault(edge.source_group, []).append(edge)
814
+ self._implements_by_source = _freeze_adjacency(implements_by_source)
815
+
816
+ def _build_graph(
817
+ self,
818
+ semantics: WorkspaceSemanticResult,
819
+ *,
820
+ cancel_check: Callable[[], None] | None = None,
821
+ ) -> None:
822
+ self._semantic_unit_paths = {
823
+ id(model.unit_scope): _path_key(file_name)
824
+ for file_name, model in semantics.models.items()
825
+ }
826
+ for model in semantics.models.values():
827
+ if cancel_check is not None:
828
+ cancel_check()
829
+ for reference in model.references:
830
+ resolved = reference.resolved
831
+ fallback_resolved = False
832
+ if resolved is None and reference.kind == ReferenceKind.VALUE:
833
+ resolved = _resolve_declaring_type_field(
834
+ reference.name,
835
+ reference.scope,
836
+ reference.ref_range,
837
+ )
838
+ fallback_resolved = resolved is not None
839
+ if resolved is None:
840
+ self._unresolved_references += 1
841
+ continue
842
+ resolved_matches_unit = self._reference_matches_resolved_unit(
843
+ reference.ref_range,
844
+ resolved,
845
+ )
846
+ if resolved_matches_unit:
847
+ candidate_groups = (
848
+ self._exact_candidate_groups(resolved)
849
+ if fallback_resolved
850
+ else self._resolved_candidate_groups(resolved)
851
+ )
852
+ else:
853
+ candidate_groups = self._project_local_candidate_groups(
854
+ reference.ref_range,
855
+ resolved,
856
+ )
857
+ if len(candidate_groups) != 1:
858
+ if len(candidate_groups) > 1:
859
+ self._ambiguous_references += 1
860
+ elif resolved.kind == SymbolKind.TYPE_PARAMETER:
861
+ continue
862
+ else:
863
+ self._unresolved_references += 1
864
+ continue
865
+ target_group = next(iter(candidate_groups))
866
+ owner = self._scope_target(
867
+ reference.scope,
868
+ call_owner=reference.kind == ReferenceKind.CALL,
869
+ )
870
+ owner_group = _target_group(owner) if owner is not None else None
871
+ evidence = self._evidence(reference.ref_range, reference.kind.value)
872
+ record = _ReferenceRecord(
873
+ target_group=target_group,
874
+ owner_group=owner_group,
875
+ kind=reference.kind,
876
+ evidence=evidence,
877
+ referencing_symbol=self._referencing_symbol(reference.scope),
878
+ )
879
+ self._references.append(record)
880
+ if reference.kind == ReferenceKind.UNIT and owner_group is not None:
881
+ self._unit_edges.append(
882
+ _UnitEdge(
883
+ source_group=owner_group,
884
+ target_group=target_group,
885
+ evidence=evidence,
886
+ )
887
+ )
888
+
889
+ seen_symbols: set[int] = set()
890
+ for model in semantics.models.values():
891
+ if cancel_check is not None:
892
+ cancel_check()
893
+ for symbol in _symbols_in_scope(model.unit_scope):
894
+ if (
895
+ id(symbol) in seen_symbols
896
+ or symbol.kind
897
+ not in {SymbolKind.CLASS, SymbolKind.INTERFACE, SymbolKind.RECORD}
898
+ or not symbol.base_types
899
+ ):
900
+ continue
901
+ seen_symbols.add(id(symbol))
902
+ source_target = self._target_for_symbol(symbol)
903
+ if source_target is None:
904
+ continue
905
+ source_group = _target_group(source_target)
906
+ for base_ref in symbol.base_types:
907
+ base_symbol = _resolve_type_symbol(base_ref, symbol.scope, semantics.index)
908
+ base_target = self._target_for_symbol(base_symbol) if base_symbol is not None else None
909
+ if base_target is None:
910
+ self._unresolved_references += 1
911
+ continue
912
+ target_group = _target_group(base_target)
913
+ evidence = self._evidence(symbol.decl_range, "base_type")
914
+ relation = _classify_type_relation(symbol.kind, base_symbol.kind)
915
+ if relation == "inherits":
916
+ self._inherits.append(_TypeEdge(source_group, target_group, evidence))
917
+ elif relation == "implements":
918
+ self._implements.append(_TypeEdge(source_group, target_group, evidence))
919
+
920
+ def _reference_matches_resolved_unit(
921
+ self,
922
+ reference_range: SourceRange,
923
+ resolved: Symbol,
924
+ ) -> bool:
925
+ unit_name = _symbol_unit_name(resolved)
926
+ if self._unit_name_counts.get(_normalized(unit_name), 0) <= 1:
927
+ return True
928
+ if _path_key(reference_range.file_name) == _path_key(
929
+ resolved.decl_range.file_name
930
+ ):
931
+ return True
932
+ reference_path = _path_key(reference_range.file_name)
933
+ declaration_path = _path_key(resolved.decl_range.file_name)
934
+ return any(
935
+ reference_path in membership
936
+ and declaration_path in membership
937
+ for membership in self._project_memberships
938
+ )
939
+
940
+ def _add_contains_edges(self, semantics: WorkspaceSemanticResult) -> None:
941
+ unit_targets = {
942
+ _normalized(target.unit_name or target.name): target
943
+ for target in self._targets.values()
944
+ if target.kind == SymbolKind.UNIT.value
945
+ }
946
+ for file_name, root in self._roots.items():
947
+ contains = root.find_node(SyntaxNodeType.ntContains)
948
+ if contains is None:
949
+ continue
950
+ model = semantics.models.get(file_name)
951
+ source_target = self._target_for_symbol(model.unit_scope.owner) if model is not None else None
952
+ if source_target is None:
953
+ continue
954
+ source_group = _target_group(source_target)
955
+ for child in contains.child_nodes:
956
+ if child.typ != SyntaxNodeType.ntUnit:
957
+ continue
958
+ name = child.get_attribute(AttributeName.anName)
959
+ target = unit_targets.get(_normalized(name))
960
+ if target is None:
961
+ self._unresolved_references += 1
962
+ continue
963
+ evidence = self._evidence(
964
+ SourceRange(
965
+ file_name=child.file_name or file_name,
966
+ start_line=child.line,
967
+ start_col=child.col,
968
+ end_line=child.line,
969
+ end_col=child.col,
970
+ ),
971
+ "contains",
972
+ )
973
+ self._unit_edges.append(_UnitEdge(source_group, _target_group(target), evidence))
974
+
975
+ def _add_semantic_problems(self, semantics: WorkspaceSemanticResult) -> None:
976
+ for model in semantics.models.values():
977
+ for problem in model.problems:
978
+ self._problems.append(
979
+ {
980
+ "item_type": "relation_problem",
981
+ "kind": "semantic",
982
+ "message": problem.message,
983
+ "path": self._display_path(problem.range.file_name),
984
+ "line": problem.range.start_line,
985
+ "column": problem.range.start_col,
986
+ }
987
+ )
988
+
989
+ def _resolved_candidate_groups(self, symbol: Symbol) -> set[_GroupKey]:
990
+ groups: set[_GroupKey] = set()
991
+ for candidate in (symbol, *symbol.overloads):
992
+ target = self._target_for_symbol(candidate)
993
+ if target is not None:
994
+ groups.add(_target_group(target))
995
+ return groups
996
+
997
+ def _project_local_candidate_groups(
998
+ self,
999
+ reference_range: SourceRange,
1000
+ symbol: Symbol,
1001
+ ) -> set[_GroupKey]:
1002
+ identity = (
1003
+ _normalized(_semantic_qualified_name(symbol)),
1004
+ symbol.kind.value,
1005
+ _symbol_signature_identity(symbol),
1006
+ )
1007
+ reference_path = _path_key(reference_range.file_name)
1008
+ candidates = self._identity_targets.get(identity, ())
1009
+ project_local = {
1010
+ _target_group(candidate)
1011
+ for candidate in candidates
1012
+ if any(
1013
+ reference_path in membership
1014
+ and _path_key(candidate.source_path) in membership
1015
+ for membership in self._project_memberships
1016
+ )
1017
+ }
1018
+ if len(project_local) == 1:
1019
+ return project_local
1020
+ reference_parent = _path_key(Path(reference_range.file_name).parent)
1021
+ same_directory = {
1022
+ _target_group(candidate)
1023
+ for candidate in candidates
1024
+ if _path_key(Path(candidate.source_path).parent) == reference_parent
1025
+ }
1026
+ if len(same_directory) == 1:
1027
+ return same_directory
1028
+ return project_local
1029
+
1030
+ def _exact_candidate_groups(self, symbol: Symbol) -> set[_GroupKey]:
1031
+ groups: set[_GroupKey] = set()
1032
+ for candidate in (symbol, *symbol.overloads):
1033
+ exact = (
1034
+ _path_key(candidate.decl_range.file_name),
1035
+ candidate.decl_range.start_line,
1036
+ candidate.decl_range.start_col,
1037
+ candidate.kind.value,
1038
+ _normalized(candidate.name),
1039
+ )
1040
+ groups.update(
1041
+ _target_group(target)
1042
+ for target in self._exact_targets.get(exact, ())
1043
+ )
1044
+ return groups
1045
+
1046
+ def _target_for_symbol(self, symbol: Symbol | None) -> RelationTarget | None:
1047
+ if symbol is None:
1048
+ return None
1049
+ cached = self._symbol_targets.get(id(symbol), ...)
1050
+ if cached is not ...:
1051
+ return cached
1052
+ path_key = _path_key(symbol.decl_range.file_name)
1053
+ exact = (
1054
+ path_key,
1055
+ symbol.decl_range.start_line,
1056
+ symbol.decl_range.start_col,
1057
+ symbol.kind.value,
1058
+ _normalized(symbol.name),
1059
+ )
1060
+ candidates = list(self._exact_targets.get(exact, ()))
1061
+ if not candidates:
1062
+ position = (
1063
+ path_key,
1064
+ symbol.decl_range.start_line,
1065
+ symbol.decl_range.start_col,
1066
+ symbol.kind.value,
1067
+ )
1068
+ candidates = list(self._position_targets.get(position, ()))
1069
+ if not candidates:
1070
+ line = (
1071
+ path_key,
1072
+ symbol.decl_range.start_line,
1073
+ symbol.kind.value,
1074
+ _normalized(symbol.name),
1075
+ )
1076
+ candidates = list(self._line_targets.get(line, ()))
1077
+ if not candidates:
1078
+ qualified_name = _semantic_qualified_name(symbol)
1079
+ candidates = list(
1080
+ self._identity_targets.get(
1081
+ (
1082
+ _normalized(qualified_name),
1083
+ symbol.kind.value,
1084
+ _symbol_signature_identity(symbol),
1085
+ ),
1086
+ (),
1087
+ )
1088
+ )
1089
+ if self._unit_name_counts.get(
1090
+ _normalized(_symbol_unit_name(symbol)),
1091
+ 0,
1092
+ ) > 1:
1093
+ semantic_unit_path = self._semantic_unit_paths.get(
1094
+ id(_symbol_unit_scope(symbol)),
1095
+ "",
1096
+ )
1097
+ provenance_path = semantic_unit_path or path_key
1098
+ candidates = [
1099
+ candidate
1100
+ for candidate in candidates
1101
+ if _path_key(candidate.source_path) == provenance_path
1102
+ ]
1103
+ target = min(candidates, key=_target_sort_key) if candidates else None
1104
+ self._symbol_targets[id(symbol)] = target
1105
+ return target
1106
+
1107
+ def _scope_target(self, scope: Scope, *, call_owner: bool = False) -> RelationTarget | None:
1108
+ current: Scope | None = scope
1109
+ while current is not None:
1110
+ if current.kind == ScopeKind.ROUTINE:
1111
+ target = self._target_for_symbol(current.owner)
1112
+ if target is not None:
1113
+ return target
1114
+ if call_owner:
1115
+ return None
1116
+ elif current.kind == ScopeKind.UNIT:
1117
+ if call_owner:
1118
+ return None
1119
+ target = self._target_for_symbol(current.owner)
1120
+ if target is not None:
1121
+ return target
1122
+ current = current.parent
1123
+ return None
1124
+
1125
+ def _relation_item(
1126
+ self,
1127
+ relation: str,
1128
+ group: _GroupKey | None,
1129
+ evidence: Mapping[str, object],
1130
+ *,
1131
+ referencing_symbol: Mapping[str, object] | None = None,
1132
+ ) -> dict[str, object] | None:
1133
+ if group is None:
1134
+ return None
1135
+ candidates = self._groups.get(group)
1136
+ if not candidates:
1137
+ return None
1138
+ target = candidates[0]
1139
+ card = (
1140
+ dict(target.card)
1141
+ if target.card
1142
+ else {
1143
+ "target_id": target.target_id,
1144
+ "unit_id": target.unit_id,
1145
+ "name": target.name,
1146
+ "qualified_name": target.qualified_name,
1147
+ "kind": target.kind,
1148
+ "path": target.path,
1149
+ "line": target.line,
1150
+ "column": target.column,
1151
+ "visibility": target.visibility,
1152
+ "owner": target.owner,
1153
+ "type": target.type_name,
1154
+ }
1155
+ )
1156
+ item: dict[str, object] = {
1157
+ "item_type": "relation",
1158
+ "relation": relation,
1159
+ **card,
1160
+ "evidence": dict(evidence),
1161
+ }
1162
+ if referencing_symbol is not None:
1163
+ item["referencing_symbol"] = dict(referencing_symbol)
1164
+ return item
1165
+
1166
+ def _referencing_symbol(
1167
+ self,
1168
+ scope: Scope,
1169
+ ) -> dict[str, object] | None:
1170
+ current: Scope | None = scope
1171
+ while current is not None:
1172
+ symbol = current.owner
1173
+ if symbol is not None:
1174
+ source_range = symbol.name_range
1175
+ return {
1176
+ "name": symbol.name,
1177
+ "qualified_name": _semantic_qualified_name(symbol),
1178
+ "kind": symbol.kind.value,
1179
+ "path": self._display_path(source_range.file_name),
1180
+ "line": source_range.start_line,
1181
+ "column": source_range.start_col,
1182
+ }
1183
+ current = current.parent
1184
+ return None
1185
+
1186
+ def _evidence(self, source_range: SourceRange, kind: str) -> dict[str, object]:
1187
+ return {
1188
+ "path": self._display_path(source_range.file_name),
1189
+ "line": source_range.start_line,
1190
+ "column": source_range.start_col,
1191
+ "kind": kind,
1192
+ }
1193
+
1194
+ def _display_path(self, file_name: str) -> str:
1195
+ key = _path_key(file_name)
1196
+ known = self._path_display.get(key)
1197
+ if known is not None:
1198
+ return known
1199
+ path = Path(file_name).expanduser()
1200
+ if path.is_absolute():
1201
+ try:
1202
+ return path.resolve().relative_to(self._root).as_posix()
1203
+ except ValueError:
1204
+ return f"@external/relation/{_stable_component(path.name)}"
1205
+ return file_name.replace("\\", "/")
1206
+
1207
+ def _metadata(
1208
+ self,
1209
+ relation: str,
1210
+ *,
1211
+ problem_summaries: Sequence[Mapping[str, object]],
1212
+ problem_count: int,
1213
+ scan_complete: bool,
1214
+ ) -> dict[str, object]:
1215
+ limitations = {
1216
+ "references": ["Only resolved semantic references are reported; text matches are excluded."],
1217
+ "callers": [
1218
+ "CALL edges cover explicit parsed calls only.",
1219
+ "Bare parameterless, overload, virtual, and procedural dispatch can be incomplete.",
1220
+ ],
1221
+ "callees": [
1222
+ "CALL edges cover explicit parsed calls only.",
1223
+ "Bare parameterless, overload, virtual, and procedural dispatch can be incomplete.",
1224
+ ],
1225
+ "uses": ["Includes resolved uses clauses and project/package contains clauses."],
1226
+ "used_by": ["Includes resolved uses clauses and project/package contains clauses."],
1227
+ "inherits": ["Reports resolved class-to-class and interface-to-interface base types."],
1228
+ "implements": [
1229
+ "Reports class/record base types resolved to interfaces; property delegation is not modeled."
1230
+ ],
1231
+ }
1232
+ return {
1233
+ "item_type": "relation_metadata",
1234
+ "relation": relation,
1235
+ "completeness": "sound_partial",
1236
+ "unresolved_references": self._unresolved_references,
1237
+ "ambiguous_references": self._ambiguous_references,
1238
+ "deep_problem_count": problem_count,
1239
+ "problems": [dict(problem) for problem in problem_summaries],
1240
+ "problems_truncated": problem_count > len(problem_summaries),
1241
+ "scan_complete": scan_complete,
1242
+ "limitations": limitations[relation],
1243
+ }
1244
+
1245
+ @staticmethod
1246
+ def _validate_applicability(target: RelationTarget, relation: str) -> None:
1247
+ applicable = True
1248
+ if relation in {"callers", "callees"}:
1249
+ applicable = target.kind in _ROUTINE_KINDS
1250
+ elif relation in {"uses", "used_by"}:
1251
+ applicable = target.kind == SymbolKind.UNIT.value
1252
+ elif relation == "inherits":
1253
+ applicable = target.kind in {SymbolKind.CLASS.value, SymbolKind.INTERFACE.value}
1254
+ elif relation == "implements":
1255
+ applicable = target.kind in {SymbolKind.CLASS.value, SymbolKind.RECORD.value}
1256
+ if not applicable:
1257
+ raise AgentProtocolError(
1258
+ "relation_not_applicable",
1259
+ f"Relation {relation!r} does not apply to {target.kind} target {target.target_id}.",
1260
+ )
1261
+
1262
+
1263
+ def _load_project_roots(
1264
+ workspace: AgentWorkspace,
1265
+ *,
1266
+ cancel_check: Callable[[], None] | None = None,
1267
+ ) -> tuple[
1268
+ dict[str, SyntaxNode],
1269
+ dict[str, PreprocessedSource],
1270
+ list[dict[str, object]],
1271
+ ]:
1272
+ active_project = workspace.active_project
1273
+ if active_project is None:
1274
+ raise AgentProtocolError("project_required", "Select a project before tracing relations.")
1275
+ parser_mode = (
1276
+ ParserMode.TOLERANT
1277
+ if len(workspace.units) >= 256
1278
+ else ParserMode.STRICT
1279
+ )
1280
+ if active_project.kind != "workspace":
1281
+ if cancel_check is not None:
1282
+ cancel_check()
1283
+ project_path = Path(active_project.path)
1284
+ if not project_path.is_absolute():
1285
+ project_path = workspace.root / project_path
1286
+ indexer = ProjectIndexer(
1287
+ search_paths=workspace.search_paths,
1288
+ include_paths=workspace.include_paths,
1289
+ defines=workspace.defines,
1290
+ mode=parser_mode,
1291
+ project_config=workspace.project_config,
1292
+ )
1293
+ result = indexer.index(str(project_path.resolve()))
1294
+ if cancel_check is not None:
1295
+ cancel_check()
1296
+ roots = {
1297
+ str(Path(unit.path).expanduser().resolve()): unit.syntax_tree
1298
+ for unit in result.parsed_units
1299
+ if unit.syntax_tree is not None
1300
+ }
1301
+ preprocessed_sources = {
1302
+ str(Path(unit.path).expanduser().resolve()): unit.preprocessed
1303
+ for unit in result.parsed_units
1304
+ if unit.syntax_tree is not None and unit.preprocessed is not None
1305
+ }
1306
+ problems = [_project_problem_item(problem, workspace.root) for problem in result.problems]
1307
+ return roots, preprocessed_sources, problems
1308
+
1309
+ parser = DelphiParser(
1310
+ include_paths=workspace.include_paths,
1311
+ defines=workspace.defines,
1312
+ include_loader=workspace_include_loader(
1313
+ workspace.project_config,
1314
+ workspace.include_paths,
1315
+ ),
1316
+ mode=parser_mode,
1317
+ legacy_fallback_max_chars=0,
1318
+ )
1319
+ roots: dict[str, SyntaxNode] = {}
1320
+ preprocessed_sources: dict[str, PreprocessedSource] = {}
1321
+ problems: list[dict[str, object]] = []
1322
+ for unit in workspace.units:
1323
+ if cancel_check is not None:
1324
+ cancel_check()
1325
+ source_path = Path(unit.path)
1326
+ if not source_path.is_absolute():
1327
+ source_path = workspace.root / source_path
1328
+ source_path = source_path.expanduser().resolve()
1329
+ try:
1330
+ text = read_source_text(source_path)
1331
+ parsed = parser.parse(
1332
+ text,
1333
+ str(source_path),
1334
+ build_semantic=False,
1335
+ )
1336
+ roots[str(source_path)] = parsed.root
1337
+ preprocessed_sources[str(source_path)] = parsed.preprocessed
1338
+ if parsed.problems:
1339
+ first = parsed.problems[0]
1340
+ problems.append(
1341
+ {
1342
+ "item_type": "relation_problem",
1343
+ "kind": "partial_parse",
1344
+ "message": (
1345
+ f"Partial DelphiAST parse at {first.line}:{first.column}: "
1346
+ f"{first.message}"
1347
+ ),
1348
+ "path": _display_project_path(source_path, workspace.root),
1349
+ }
1350
+ )
1351
+ except Exception as exc:
1352
+ problems.append(
1353
+ {
1354
+ "item_type": "relation_problem",
1355
+ "kind": "cant_parse_file",
1356
+ "message": _safe_problem_message(
1357
+ f"{type(exc).__name__}: {exc}",
1358
+ workspace.root,
1359
+ (
1360
+ source_path,
1361
+ getattr(exc, "filename", None),
1362
+ getattr(exc, "filename2", None),
1363
+ ),
1364
+ ),
1365
+ "path": _display_project_path(source_path, workspace.root),
1366
+ }
1367
+ )
1368
+ return roots, preprocessed_sources, problems
1369
+
1370
+
1371
+ def _project_problem_item(problem: ProjectProblem, root: Path) -> dict[str, object]:
1372
+ return {
1373
+ "item_type": "relation_problem",
1374
+ "kind": problem.problem_type.value,
1375
+ "message": _safe_problem_message(problem.description, root, (problem.file_name,)),
1376
+ "path": _display_project_path(problem.file_name, root),
1377
+ }
1378
+
1379
+
1380
+ def _safe_problem_message(
1381
+ message: str,
1382
+ root: Path,
1383
+ known_paths: Iterable[str | PurePath | None],
1384
+ ) -> str:
1385
+ replacements: dict[str, str] = {}
1386
+ for value in known_paths:
1387
+ if value is None:
1388
+ continue
1389
+ original = str(value)
1390
+ pattern = _separator_agnostic_absolute_path_pattern(original)
1391
+ if pattern is None:
1392
+ continue
1393
+ safe_path = _display_project_path(original, root)
1394
+ replacements[pattern] = safe_path
1395
+ for pattern in sorted(replacements, key=len, reverse=True):
1396
+ replacement = replacements[pattern]
1397
+ message = re.sub(pattern, lambda _match: replacement, message)
1398
+ return message
1399
+
1400
+
1401
+ def _display_project_path(path: str | PurePath, root: Path) -> str:
1402
+ path_text = str(path)
1403
+ if not _is_cross_platform_absolute(path_text):
1404
+ return Path(path_text).as_posix()
1405
+ host_path = Path(path_text)
1406
+ if host_path.is_absolute():
1407
+ resolved = host_path.expanduser().resolve()
1408
+ relative = _portable_relative_to_root(resolved, root)
1409
+ if relative is not None:
1410
+ return relative
1411
+ return f"@external/relation/{_stable_component(resolved.name)}"
1412
+ relative = _portable_relative_to_root(path_text, root)
1413
+ if relative is not None:
1414
+ return relative
1415
+ return f"@external/relation/{_stable_component(_portable_name(path_text))}"
1416
+
1417
+
1418
+ def _is_cross_platform_absolute(path: str | PurePath) -> bool:
1419
+ text = str(path)
1420
+ return PurePosixPath(text).is_absolute() or PureWindowsPath(text).is_absolute()
1421
+
1422
+
1423
+ def _separator_agnostic_absolute_path_pattern(path: str | PurePath) -> str | None:
1424
+ text = str(path)
1425
+ windows_path = PureWindowsPath(text)
1426
+ if windows_path.is_absolute():
1427
+ canonical = windows_path.as_posix()
1428
+ case_insensitive = True
1429
+ else:
1430
+ posix_path = PurePosixPath(text)
1431
+ if not posix_path.is_absolute():
1432
+ return None
1433
+ canonical = posix_path.as_posix()
1434
+ case_insensitive = False
1435
+ pattern = r"[\\/]+".join(re.escape(component) for component in canonical.split("/"))
1436
+ return f"(?i:{pattern})" if case_insensitive else pattern
1437
+
1438
+
1439
+ def _portable_relative_to_root(path: str | PurePath, root: Path) -> str | None:
1440
+ text = str(path)
1441
+ posix_path = PurePosixPath(text)
1442
+ posix_root = PurePosixPath(str(root))
1443
+ if posix_path.is_absolute() and posix_root.is_absolute():
1444
+ try:
1445
+ return posix_path.relative_to(posix_root).as_posix()
1446
+ except ValueError:
1447
+ pass
1448
+
1449
+ windows_path = PureWindowsPath(text)
1450
+ windows_root = PureWindowsPath(str(root))
1451
+ if windows_path.is_absolute() and windows_root.is_absolute():
1452
+ try:
1453
+ return windows_path.relative_to(windows_root).as_posix()
1454
+ except ValueError:
1455
+ pass
1456
+ return None
1457
+
1458
+
1459
+ def _portable_name(path: str | PurePath) -> str:
1460
+ return PurePosixPath(str(path).replace("\\", "/")).name or "unknown"
1461
+
1462
+
1463
+ def _resolve_type_symbol(type_ref: TypeRef, scope: Scope, index: SymbolIndex) -> Symbol | None:
1464
+ if isinstance(type_ref, GenericInstanceTypeRef):
1465
+ return _resolve_type_symbol(type_ref.base, scope, index)
1466
+ if not isinstance(type_ref, NamedTypeRef):
1467
+ return None
1468
+ if type_ref.unit_name:
1469
+ unit_scope = index.lookup_unit(type_ref.unit_name)
1470
+ if unit_scope is not None:
1471
+ symbols = unit_scope.lookup_local(type_ref.name)
1472
+ return symbols[0] if symbols else None
1473
+ if "." in type_ref.name:
1474
+ symbols = index.resolve_qualified(type_ref.name)
1475
+ if symbols:
1476
+ return symbols[0]
1477
+ symbols = scope.resolve(type_ref.name)
1478
+ return symbols[0] if symbols else None
1479
+
1480
+
1481
+ def _resolve_declaring_type_field(
1482
+ name: str,
1483
+ scope: Scope,
1484
+ reference_range: SourceRange,
1485
+ ) -> Symbol | None:
1486
+ """Resolve an unqualified field used by a qualified method implementation."""
1487
+ if name.startswith("&"):
1488
+ return None
1489
+ parts = name.split(".")
1490
+ explicit_self = len(parts) > 1 and parts[0].casefold() == "self"
1491
+ if not explicit_self or len(parts) != 2:
1492
+ return None
1493
+ if _has_escaped_reference_prefix(reference_range, name):
1494
+ return None
1495
+ field_name = parts[1]
1496
+ current: Scope | None = scope
1497
+ while current is not None:
1498
+ owner = current.owner
1499
+ if (
1500
+ current.kind == ScopeKind.ROUTINE
1501
+ and owner is not None
1502
+ and "." in owner.name
1503
+ ):
1504
+ type_name = owner.name.rsplit(".", 1)[0]
1505
+ unit_scope = current
1506
+ while unit_scope.parent is not None:
1507
+ unit_scope = unit_scope.parent
1508
+ type_symbols = [
1509
+ candidate
1510
+ for candidate in unit_scope.lookup_local(type_name)
1511
+ if candidate.kind in {
1512
+ SymbolKind.CLASS,
1513
+ SymbolKind.RECORD,
1514
+ SymbolKind.TYPE,
1515
+ }
1516
+ and candidate.member_scope is not None
1517
+ ]
1518
+ fields = {
1519
+ id(candidate): candidate
1520
+ for type_symbol in type_symbols
1521
+ for candidate in type_symbol.member_scope.lookup_local(field_name)
1522
+ if candidate.kind == SymbolKind.FIELD
1523
+ }
1524
+ return next(iter(fields.values())) if len(fields) == 1 else None
1525
+ current = current.parent
1526
+ return None
1527
+
1528
+
1529
+ def _has_escaped_reference_prefix(
1530
+ reference_range: SourceRange,
1531
+ name: str,
1532
+ ) -> bool:
1533
+ root_name = name.split(".", 1)[0]
1534
+ if reference_range.start_col <= 1 or not root_name:
1535
+ return False
1536
+ try:
1537
+ lines = read_source_text(Path(reference_range.file_name)).splitlines()
1538
+ except (OSError, UnicodeError):
1539
+ return False
1540
+ if not 1 <= reference_range.start_line <= len(lines):
1541
+ return False
1542
+ line = lines[reference_range.start_line - 1]
1543
+ position = reference_range.start_col - 1
1544
+ window_start = max(0, position - len(root_name) - 1)
1545
+ window_end = min(len(line), position + len(root_name) + 1)
1546
+ escaped_root = re.compile(
1547
+ rf"&{re.escape(root_name)}\b",
1548
+ re.IGNORECASE,
1549
+ )
1550
+ return escaped_root.search(line, window_start, window_end) is not None
1551
+
1552
+
1553
+ def _classify_type_relation(source_kind: SymbolKind, target_kind: SymbolKind) -> str | None:
1554
+ if (
1555
+ source_kind == SymbolKind.CLASS
1556
+ and target_kind == SymbolKind.CLASS
1557
+ ) or (
1558
+ source_kind == SymbolKind.INTERFACE
1559
+ and target_kind == SymbolKind.INTERFACE
1560
+ ):
1561
+ return "inherits"
1562
+ if source_kind in {SymbolKind.CLASS, SymbolKind.RECORD} and target_kind == SymbolKind.INTERFACE:
1563
+ return "implements"
1564
+ return None
1565
+
1566
+
1567
+ def _symbols_in_scope(scope: Scope) -> Iterable[Symbol]:
1568
+ seen_scopes: set[int] = set()
1569
+
1570
+ def visit(current: Scope) -> Iterable[Symbol]:
1571
+ if id(current) in seen_scopes:
1572
+ return
1573
+ seen_scopes.add(id(current))
1574
+ for symbols in current.symbols.values():
1575
+ for symbol in symbols:
1576
+ yield symbol
1577
+ if symbol.member_scope is not None:
1578
+ yield from visit(symbol.member_scope)
1579
+
1580
+ yield from visit(scope)
1581
+
1582
+
1583
+ def _workspace_project_memberships(
1584
+ workspace: AgentWorkspace,
1585
+ ) -> tuple[frozenset[str], ...]:
1586
+ parser = DelphiParser(
1587
+ include_paths=workspace.include_paths,
1588
+ defines=workspace.defines,
1589
+ include_loader=workspace_include_loader(
1590
+ workspace.project_config,
1591
+ workspace.include_paths,
1592
+ ),
1593
+ mode=ParserMode.TOLERANT,
1594
+ legacy_fallback_max_chars=0,
1595
+ )
1596
+ unit_paths_by_name: dict[str, list[Path]] = {}
1597
+ for unit in workspace.units:
1598
+ unit_path = Path(unit.path)
1599
+ if not unit_path.is_absolute():
1600
+ unit_path = workspace.root / unit_path
1601
+ unit_paths_by_name.setdefault(_normalized(unit.name), []).append(
1602
+ unit_path.expanduser().resolve()
1603
+ )
1604
+ memberships: list[frozenset[str]] = []
1605
+ for project in workspace.projects:
1606
+ if project.kind == "workspace":
1607
+ continue
1608
+ project_path = Path(project.path)
1609
+ if not project_path.is_absolute():
1610
+ project_path = workspace.root / project_path
1611
+ project_path = project_path.expanduser().resolve()
1612
+ try:
1613
+ text = read_source_text(project_path)
1614
+ root = parser.parse(
1615
+ text,
1616
+ str(project_path),
1617
+ build_semantic=False,
1618
+ ).root
1619
+ except Exception: # noqa: BLE001, S112
1620
+ continue
1621
+ members = {_path_key(project_path)}
1622
+ pending = [root]
1623
+ while pending:
1624
+ node = pending.pop()
1625
+ pending.extend(reversed(node.child_nodes))
1626
+ if node.typ != SyntaxNodeType.ntUnit:
1627
+ continue
1628
+ declared_path = node.get_attribute(AttributeName.anPath).strip()
1629
+ if not declared_path:
1630
+ unit_name = node.get_attribute(AttributeName.anName).strip()
1631
+ local_candidates = [
1632
+ candidate
1633
+ for candidate in unit_paths_by_name.get(
1634
+ _normalized(unit_name),
1635
+ (),
1636
+ )
1637
+ if candidate.parent == project_path.parent
1638
+ ]
1639
+ if len(local_candidates) == 1:
1640
+ members.add(_path_key(str(local_candidates[0])))
1641
+ continue
1642
+ member_path = Path(declared_path.replace("\\", "/"))
1643
+ if not member_path.is_absolute():
1644
+ member_path = project_path.parent / member_path
1645
+ members.add(_path_key(str(member_path.expanduser().resolve())))
1646
+ if len(members) > 1:
1647
+ memberships.append(frozenset(members))
1648
+ return tuple(memberships)
1649
+
1650
+
1651
+ def _symbol_unit_scope(symbol: Symbol) -> Scope:
1652
+ scope = symbol.scope
1653
+ while scope.parent is not None:
1654
+ scope = scope.parent
1655
+ return scope
1656
+
1657
+
1658
+ def _symbol_unit_name(symbol: Symbol) -> str:
1659
+ return _symbol_unit_scope(symbol).name
1660
+
1661
+
1662
+ def _semantic_qualified_name(symbol: Symbol) -> str:
1663
+ scope = symbol.scope
1664
+ unit_scope = scope
1665
+ while unit_scope.parent is not None:
1666
+ unit_scope = unit_scope.parent
1667
+ unit_name = unit_scope.name
1668
+ if symbol.kind == SymbolKind.UNIT:
1669
+ return unit_name
1670
+ if scope.kind == ScopeKind.TYPE and scope.owner is not None:
1671
+ return f"{unit_name}.{scope.owner.name}.{symbol.name}"
1672
+ return f"{unit_name}.{symbol.name}"
1673
+
1674
+
1675
+ def _target_group(target: RelationTarget) -> _GroupKey:
1676
+ return (
1677
+ _path_key(target.source_path),
1678
+ _normalized(target.qualified_name),
1679
+ target.kind,
1680
+ _target_signature_identity(target),
1681
+ )
1682
+
1683
+
1684
+ def _target_signature_identity(target: RelationTarget) -> str:
1685
+ if target.kind not in _ROUTINE_KINDS:
1686
+ return ""
1687
+ return _canonical_signature_identity(target.signature)
1688
+
1689
+
1690
+ def _symbol_signature_identity(symbol: Symbol) -> str:
1691
+ if symbol.kind.value not in _ROUTINE_KINDS or not isinstance(symbol.type_ref, ProcTypeRef):
1692
+ return ""
1693
+ parameter_symbols = []
1694
+ if symbol.member_scope is not None:
1695
+ parameter_symbols = sorted(
1696
+ (
1697
+ candidate
1698
+ for symbols in symbol.member_scope.symbols.values()
1699
+ for candidate in symbols
1700
+ if candidate.kind == SymbolKind.PARAMETER
1701
+ ),
1702
+ key=lambda candidate: (
1703
+ candidate.decl_range.start_line,
1704
+ candidate.decl_range.start_col,
1705
+ candidate.name.casefold(),
1706
+ ),
1707
+ )
1708
+ normalized_params: list[str] = []
1709
+ for index, param in enumerate(symbol.type_ref.params):
1710
+ modifier = ""
1711
+ if index < len(parameter_symbols):
1712
+ modifier = _normalized(parameter_symbols[index].attributes.get("modifier", ""))
1713
+ type_name = _normalized(param.display_name()).replace(" ", "")
1714
+ normalized_params.append(f"{modifier}:{type_name}")
1715
+ params = ",".join(normalized_params)
1716
+ signature = f"({params})"
1717
+ if symbol.type_ref.return_type is not None:
1718
+ result = _normalized(symbol.type_ref.return_type.display_name()).replace(" ", "")
1719
+ signature = f"{signature}:{result}"
1720
+ calling_convention = _normalized(symbol.attributes.get("callingconvention", ""))
1721
+ if calling_convention:
1722
+ signature = f"{signature}|cc:{calling_convention}"
1723
+ return _canonical_signature_identity(signature)
1724
+
1725
+
1726
+ def _canonical_signature_identity(signature: str) -> str:
1727
+ def split_parameters(content: str) -> list[str]:
1728
+ parameters: list[str] = []
1729
+ start = 0
1730
+ depth = 0
1731
+ for index, char in enumerate(content):
1732
+ if char == "(":
1733
+ depth += 1
1734
+ elif char == ")":
1735
+ depth -= 1
1736
+ elif char in ";," and depth == 0:
1737
+ parameters.append(content[start:index])
1738
+ start = index + 1
1739
+ parameters.append(content[start:])
1740
+ return parameters
1741
+
1742
+ def canonicalize(content: str) -> str:
1743
+ parts: list[str] = []
1744
+ index = 0
1745
+ while index < len(content):
1746
+ if content[index] != "(":
1747
+ parts.append(content[index])
1748
+ index += 1
1749
+ continue
1750
+ depth = 1
1751
+ end = index + 1
1752
+ while end < len(content) and depth:
1753
+ if content[end] == "(":
1754
+ depth += 1
1755
+ elif content[end] == ")":
1756
+ depth -= 1
1757
+ end += 1
1758
+ if depth:
1759
+ parts.append(content[index:])
1760
+ break
1761
+ parameters = split_parameters(content[index + 1 : end - 1])
1762
+ expanded: list[str] = []
1763
+ for parameter in parameters:
1764
+ match = re.fullmatch(r"\s*((?:var|const|out)\s*)?#(\d+):(.*)", parameter, re.DOTALL)
1765
+ if match is None:
1766
+ expanded.append(canonicalize(parameter))
1767
+ continue
1768
+ modifier, count, parameter_type = match.groups()
1769
+ expanded.extend(
1770
+ f"{modifier or ''}:{canonicalize(parameter_type)}"
1771
+ for _ in range(int(count))
1772
+ )
1773
+ parts.append(f"({','.join(expanded)})")
1774
+ index = end
1775
+ return "".join(parts)
1776
+
1777
+ return _normalized(canonicalize(signature).replace(" ", ""))
1778
+
1779
+
1780
+ def _target_sort_key(target: RelationTarget) -> tuple[object, ...]:
1781
+ return (
1782
+ target.path.casefold(),
1783
+ target.line,
1784
+ target.column,
1785
+ target.qualified_name.casefold(),
1786
+ target.signature.casefold(),
1787
+ target.target_id,
1788
+ )
1789
+
1790
+
1791
+ def _relation_sort_key(item: Mapping[str, object]) -> tuple[object, ...]:
1792
+ evidence = item.get("evidence")
1793
+ evidence_mapping = evidence if isinstance(evidence, Mapping) else {}
1794
+ return (
1795
+ str(item.get("qualified_name", "")).casefold(),
1796
+ str(item.get("path", "")).casefold(),
1797
+ int(item.get("line", 0)),
1798
+ int(item.get("column", 0)),
1799
+ str(evidence_mapping.get("path", "")).casefold(),
1800
+ int(evidence_mapping.get("line", 0)),
1801
+ int(evidence_mapping.get("column", 0)),
1802
+ str(evidence_mapping.get("kind", "")),
1803
+ str(item.get("target_id", "")),
1804
+ )
1805
+
1806
+
1807
+ def _freeze_adjacency(
1808
+ groups: Mapping[_GroupKey, list[_AdjacencyValue]],
1809
+ ) -> dict[_GroupKey, tuple[_AdjacencyValue, ...]]:
1810
+ return {key: tuple(values) for key, values in groups.items()}
1811
+
1812
+
1813
+ def _dedupe_mappings(items: Iterable[Mapping[str, object]]) -> list[dict[str, object]]:
1814
+ result: list[dict[str, object]] = []
1815
+ seen: set[str] = set()
1816
+ for item in items:
1817
+ mapping = dict(item)
1818
+ key = repr(_freeze(mapping))
1819
+ if key in seen:
1820
+ continue
1821
+ seen.add(key)
1822
+ result.append(mapping)
1823
+ return result
1824
+
1825
+
1826
+ def _summarize_relation_problems(
1827
+ problems: Iterable[Mapping[str, object]],
1828
+ *,
1829
+ limit: int,
1830
+ ) -> tuple[list[dict[str, object]], int]:
1831
+ summaries: list[dict[str, object]] = []
1832
+ seen: set[tuple[str, str, str]] = set()
1833
+ for problem in problems:
1834
+ mapping = dict(problem)
1835
+ key = (
1836
+ str(mapping.get("path", mapping.get("file", ""))),
1837
+ str(mapping.get("kind", "")),
1838
+ str(mapping.get("message", "")),
1839
+ )
1840
+ if key in seen:
1841
+ continue
1842
+ seen.add(key)
1843
+ if len(summaries) < limit:
1844
+ summaries.append(mapping)
1845
+ return summaries, len(seen)
1846
+
1847
+
1848
+ def _freeze(value: object) -> object:
1849
+ if isinstance(value, Mapping):
1850
+ return tuple(sorted((str(key), _freeze(item)) for key, item in value.items()))
1851
+ if isinstance(value, (list, tuple)):
1852
+ return tuple(_freeze(item) for item in value)
1853
+ return value
1854
+
1855
+
1856
+ def _path_key(value: str | Path) -> str:
1857
+ return str(Path(value).expanduser().resolve()).casefold()
1858
+
1859
+
1860
+ def _normalized(value: str) -> str:
1861
+ return unicodedata.normalize("NFC", value).casefold()
1862
+
1863
+
1864
+ def _text_contains_reference_token(text: str, value: str) -> bool:
1865
+ needle = _normalized(value)
1866
+ normalized_text = _normalized(text) if not needle.isascii() else text.casefold()
1867
+ if not needle:
1868
+ return False
1869
+ if re.fullmatch(r"[^\W\d]\w*(?:\.[^\W\d]\w*)*", needle) is None:
1870
+ return needle in normalized_text
1871
+ return re.search(
1872
+ rf"(?<!\w){re.escape(needle)}(?!\w)",
1873
+ normalized_text,
1874
+ ) is not None
1875
+
1876
+
1877
+ def _stable_component(value: str) -> str:
1878
+ return unicodedata.normalize("NFC", value).replace("\\", "_").replace("/", "_") or "unknown"
1879
+
1880
+
1881
+ __all__ = ["ProjectRelationIndex", "RelationTarget"]