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,860 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import os
6
+ import sys
7
+ import unicodedata
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+
11
+ from .agent_protocol import AgentProtocolError, Focus, make_target_id
12
+ from .consts import AttributeName, SyntaxNodeType
13
+ from .lsp_server import multiline_string_block_end, outline_source
14
+ from .project_config import ProjectPathConfig
15
+ from .project_discovery import (
16
+ SKIP_DIRS,
17
+ SOURCE_EXTENSIONS,
18
+ DelphiProjectDiscovery,
19
+ discover_delphi_project,
20
+ discover_workspace_sources,
21
+ populate_workspace_sources,
22
+ )
23
+ from .project_indexer import (
24
+ IncludeFileInfo,
25
+ ProjectIndexer,
26
+ ProjectIndexResult,
27
+ UnitInfo,
28
+ )
29
+
30
+ _PROJECT_SNAPSHOT_EXTENSIONS = (".pas", ".dpr", ".dpk", ".dproj", ".cfg", ".dof")
31
+ _UNIT_SNAPSHOT_EXTENSIONS = (".pas", ".dpr", ".dpk")
32
+ _RELATIVE_INCLUDE_EXTENSIONS = (".inc",)
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class AgentProject:
37
+ project_id: str
38
+ name: str
39
+ path: str
40
+ kind: str
41
+
42
+ def to_mapping(self) -> dict[str, str]:
43
+ return {
44
+ "project_id": self.project_id,
45
+ "name": self.name,
46
+ "path": self.path,
47
+ "kind": self.kind,
48
+ }
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class AgentUnit:
53
+ unit_id: str
54
+ name: str
55
+ path: str
56
+ has_error: bool
57
+
58
+ def to_mapping(self) -> dict[str, str | bool]:
59
+ return {
60
+ "unit_id": self.unit_id,
61
+ "name": self.name,
62
+ "path": self.path,
63
+ "has_error": self.has_error,
64
+ }
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class _ProjectCache:
69
+ result: ProjectIndexResult
70
+ fingerprint: str
71
+
72
+
73
+ @dataclass
74
+ class _DirectorySnapshotSpec:
75
+ path: Path
76
+ immediate_extensions: set[str]
77
+ recursive_extensions: set[str]
78
+
79
+
80
+ class AgentWorkspace:
81
+ def __init__(
82
+ self,
83
+ root: Path,
84
+ discovery: DelphiProjectDiscovery,
85
+ projects: tuple[AgentProject, ...],
86
+ project_paths: dict[str, Path | None],
87
+ ) -> None:
88
+ self._root = root
89
+ self._discovery = discovery
90
+ self._projects = projects
91
+ self._project_paths = project_paths
92
+ self._active_project_id = ""
93
+ self._focus = Focus()
94
+ self._active_discovery: DelphiProjectDiscovery | None = None
95
+ self._active_result: ProjectIndexResult | None = None
96
+ self._units: tuple[AgentUnit, ...] = ()
97
+ self._include_files: tuple[dict[str, str], ...] = ()
98
+ self._project_cache: dict[str, _ProjectCache] = {}
99
+ self._current_revision = ""
100
+
101
+ @classmethod
102
+ def open(
103
+ cls,
104
+ root: str | Path,
105
+ project_file: str | Path | None = None,
106
+ ) -> AgentWorkspace:
107
+ root_path = Path(root).expanduser().resolve()
108
+ resolved_project_file: Path | None = None
109
+ if project_file is not None:
110
+ resolved_project_file = Path(project_file).expanduser()
111
+ if not resolved_project_file.is_absolute():
112
+ resolved_project_file = root_path / resolved_project_file
113
+ resolved_project_file = resolved_project_file.resolve()
114
+ discovery = discover_delphi_project(
115
+ root_path,
116
+ project_file=resolved_project_file,
117
+ scan_workspace_sources=False,
118
+ )
119
+ projects: list[AgentProject] = []
120
+ project_paths: dict[str, Path | None] = {}
121
+ default_project_id = ""
122
+ if (
123
+ resolved_project_file is None
124
+ and len(discovery.project_files) > 1
125
+ ):
126
+ default_project_id = make_target_id("project", "", "workspace")
127
+ projects.append(
128
+ AgentProject(
129
+ project_id=default_project_id,
130
+ name="Workspace",
131
+ path=".",
132
+ kind="workspace",
133
+ )
134
+ )
135
+ project_paths[default_project_id] = None
136
+ if discovery.project_files:
137
+ for value in discovery.project_files:
138
+ path = Path(value)
139
+ relative_path = _display_path(path, root_path)
140
+ name = path.stem
141
+ project_id = make_target_id("project", relative_path, name)
142
+ project = AgentProject(
143
+ project_id=project_id,
144
+ name=name,
145
+ path=relative_path,
146
+ kind=_project_kind(path),
147
+ )
148
+ projects.append(project)
149
+ project_paths[project_id] = path
150
+ else:
151
+ populate_workspace_sources(discovery)
152
+ project_id = make_target_id("project", "", "workspace")
153
+ projects.append(
154
+ AgentProject(
155
+ project_id=project_id,
156
+ name="Workspace",
157
+ path=".",
158
+ kind="workspace",
159
+ )
160
+ )
161
+ project_paths[project_id] = None
162
+
163
+ workspace = cls(root_path, discovery, tuple(projects), project_paths)
164
+ if default_project_id:
165
+ workspace.select_project(default_project_id)
166
+ elif len(projects) == 1:
167
+ workspace.select_project(projects[0].project_id)
168
+ return workspace
169
+
170
+ @property
171
+ def projects(self) -> tuple[AgentProject, ...]:
172
+ return self._projects
173
+
174
+ @property
175
+ def root(self) -> Path:
176
+ return self._root
177
+
178
+ @property
179
+ def search_paths(self) -> tuple[str, ...]:
180
+ discovery = self._active_discovery or self._discovery
181
+ return tuple(discovery.search_paths)
182
+
183
+ @property
184
+ def include_paths(self) -> tuple[str, ...]:
185
+ discovery = self._active_discovery or self._discovery
186
+ return tuple(discovery.include_paths)
187
+
188
+ @property
189
+ def defines(self) -> tuple[str, ...]:
190
+ discovery = self._active_discovery or self._discovery
191
+ return tuple(discovery.defines)
192
+
193
+ @property
194
+ def project_config(self) -> ProjectPathConfig | None:
195
+ discovery = self._active_discovery or self._discovery
196
+ return discovery.project_config
197
+
198
+ @property
199
+ def active_project(self) -> AgentProject | None:
200
+ return next(
201
+ (project for project in self._projects if project.project_id == self._active_project_id),
202
+ None,
203
+ )
204
+
205
+ @property
206
+ def active_project_id(self) -> str:
207
+ return self._active_project_id
208
+
209
+ def cache_roots(self) -> tuple[object, ...]:
210
+ return (self._project_cache, self._active_result)
211
+
212
+ @property
213
+ def estimated_cache_bytes(self) -> int:
214
+ retained = 512
215
+ seen_results: set[int] = set()
216
+ for cached in self._project_cache.values():
217
+ result = cached.result
218
+ if id(result) in seen_results:
219
+ continue
220
+ seen_results.add(id(result))
221
+ retained += _estimate_project_result_bytes(result)
222
+ if self._active_result is not None and id(self._active_result) not in seen_results:
223
+ retained += _estimate_project_result_bytes(self._active_result)
224
+ return retained
225
+
226
+ def evict_recomputable_caches(self) -> None:
227
+ self._project_cache.clear()
228
+ self._active_result = None
229
+
230
+ @property
231
+ def focus(self) -> Focus:
232
+ return self._focus
233
+
234
+ @property
235
+ def units(self) -> tuple[AgentUnit, ...]:
236
+ return self._units
237
+
238
+ @property
239
+ def include_files(self) -> tuple[dict[str, str], ...]:
240
+ return tuple(dict(item) for item in self._include_files)
241
+
242
+ def include_source_path(self, display_path: str) -> Path:
243
+ if self._active_result is not None:
244
+ for include in self._active_result.include_files:
245
+ source_path = Path(include.path).expanduser().resolve()
246
+ if _display_path(source_path, self._root) == display_path:
247
+ return source_path
248
+ path = Path(display_path).expanduser()
249
+ if not path.is_absolute():
250
+ path = self._root / path
251
+ return path.resolve()
252
+
253
+ def include_display_path(self, display_path: str) -> str:
254
+ source_path = self.include_source_path(display_path)
255
+ try:
256
+ return source_path.relative_to(self._root).as_posix()
257
+ except ValueError:
258
+ return f"@external/include/{_stable_path_component(source_path.name)}"
259
+
260
+ @property
261
+ def search_path_entries(self) -> tuple[dict[str, object], ...]:
262
+ discovery = self._active_discovery or self._discovery
263
+ return self._path_entries(discovery.search_paths, discovery.search_path_origins)
264
+
265
+ @property
266
+ def include_path_entries(self) -> tuple[dict[str, object], ...]:
267
+ discovery = self._active_discovery or self._discovery
268
+ return self._path_entries(discovery.include_paths, discovery.include_path_origins)
269
+
270
+ @property
271
+ def define_entries(self) -> tuple[dict[str, object], ...]:
272
+ discovery = self._active_discovery or self._discovery
273
+ return tuple(
274
+ {
275
+ "define": define,
276
+ "origins": [self._display_origin(origin) for origin in discovery.define_origins.get(define, [])],
277
+ }
278
+ for define in discovery.defines
279
+ )
280
+
281
+ @property
282
+ def problems(self) -> tuple[dict[str, str], ...]:
283
+ entries: list[dict[str, str]] = []
284
+ seen_discovery: set[tuple[str, str, str]] = set()
285
+ discovery = self._active_discovery or self._discovery
286
+ for problem in discovery.problems:
287
+ key = (problem.kind, problem.message, problem.origin)
288
+ if key in seen_discovery:
289
+ continue
290
+ seen_discovery.add(key)
291
+ entries.append(
292
+ {
293
+ "kind": problem.kind,
294
+ "message": problem.message,
295
+ "origin": self._display_origin(problem.origin),
296
+ }
297
+ )
298
+
299
+ active_project = self.active_project
300
+ if active_project is not None and self._active_result is not None:
301
+ seen_project: set[tuple[str, str, str]] = set()
302
+ for problem in self._active_result.problems:
303
+ key = (problem.problem_type.value, problem.file_name, problem.description)
304
+ if key in seen_project:
305
+ continue
306
+ seen_project.add(key)
307
+ entries.append(
308
+ {
309
+ "kind": problem.problem_type.value,
310
+ "message": problem.description,
311
+ "origin": active_project.path,
312
+ "path": self._display_problem_path(problem.file_name),
313
+ }
314
+ )
315
+ return tuple(entries)
316
+
317
+ @property
318
+ def workspace_revision(self) -> str:
319
+ return self.refresh_revision()
320
+
321
+ @property
322
+ def current_revision(self) -> str:
323
+ if not self._current_revision:
324
+ return self.refresh_revision()
325
+ return self._current_revision
326
+
327
+ def refresh_revision(self) -> str:
328
+ discovery = self._active_discovery or self._discovery
329
+ result = self._active_result
330
+ if self._active_project_id:
331
+ project_path = self._project_paths[self._active_project_id]
332
+ if project_path is None:
333
+ discovery = discover_workspace_sources(self._root)
334
+ result = _catalog_workspace_sources(discovery)
335
+ else:
336
+ discovery = discover_delphi_project(
337
+ self._root,
338
+ project_file=project_path,
339
+ scan_workspace_sources=False,
340
+ )
341
+ fingerprint = _selection_fingerprint(discovery, result, root=self._root)
342
+ self._current_revision = f"workspace_v2_{fingerprint}"
343
+ return self._current_revision
344
+
345
+ def select_project(self, project_id: str) -> None:
346
+ self._select_project_with_revision(project_id)
347
+
348
+ def _select_project_with_revision(self, project_id: str) -> str:
349
+ if project_id not in self._project_paths:
350
+ raise AgentProtocolError("project_not_found", f"Project not found: {project_id}.")
351
+ project_path = self._project_paths[project_id]
352
+ cached = self._project_cache.get(project_id)
353
+ if project_path is None:
354
+ discovery = (
355
+ self._discovery
356
+ if cached is None and self._discovery.source_files
357
+ else discover_workspace_sources(self._root)
358
+ )
359
+ else:
360
+ discovery = discover_delphi_project(
361
+ self._root,
362
+ project_file=project_path,
363
+ scan_workspace_sources=False,
364
+ )
365
+ if cached is not None:
366
+ fingerprint = _selection_fingerprint(discovery, cached.result, root=self._root)
367
+ if fingerprint == cached.fingerprint:
368
+ self._activate_project(project_id, discovery, cached.result)
369
+ self._current_revision = f"workspace_v2_{fingerprint}"
370
+ return self._current_revision
371
+
372
+ if project_path is None:
373
+ result = _catalog_workspace_sources(discovery)
374
+ else:
375
+ indexer = ProjectIndexer(
376
+ search_paths=discovery.search_paths,
377
+ include_paths=discovery.include_paths,
378
+ defines=discovery.defines,
379
+ source_transform=_outline_agent_source,
380
+ project_config=discovery.project_config,
381
+ )
382
+ result = indexer.index(str(project_path))
383
+ fingerprint = _selection_fingerprint(discovery, result, root=self._root)
384
+ self._project_cache[project_id] = _ProjectCache(
385
+ result=result,
386
+ fingerprint=fingerprint,
387
+ )
388
+ self._activate_project(project_id, discovery, result)
389
+ self._current_revision = f"workspace_v2_{fingerprint}"
390
+ return self._current_revision
391
+
392
+ def _activate_project(
393
+ self,
394
+ project_id: str,
395
+ discovery: DelphiProjectDiscovery,
396
+ result: ProjectIndexResult,
397
+ ) -> None:
398
+ units = []
399
+ for item in result.parsed_units:
400
+ relative_path = _display_path(Path(item.path), self._root)
401
+ units.append(
402
+ AgentUnit(
403
+ unit_id=make_target_id("unit", relative_path, item.name),
404
+ name=item.name,
405
+ path=relative_path,
406
+ has_error=item.has_error,
407
+ )
408
+ )
409
+ units.sort(key=lambda item: (item.name.casefold(), item.path.casefold(), item.name, item.path))
410
+ include_files = tuple(
411
+ {
412
+ "name": item.name,
413
+ "path": _display_path(Path(item.path), self._root),
414
+ }
415
+ for item in sorted(
416
+ result.include_files,
417
+ key=lambda item: (item.name.casefold(), item.path.casefold(), item.name, item.path),
418
+ )
419
+ )
420
+
421
+ self._active_discovery = discovery
422
+ self._active_result = result
423
+ if self._project_paths[project_id] is None:
424
+ self._discovery = discovery
425
+ self._units = tuple(units)
426
+ self._include_files = include_files
427
+ self._active_project_id = project_id
428
+ self._focus = Focus(project_id=project_id, unit_id="", target_id="")
429
+
430
+ def _path_entries(
431
+ self,
432
+ paths: list[str],
433
+ origins: dict[str, list[str]],
434
+ ) -> tuple[dict[str, object], ...]:
435
+ return tuple(
436
+ {
437
+ "path": _display_path(Path(path), self._root),
438
+ "origins": [self._display_origin(origin) for origin in origins.get(path, [])],
439
+ }
440
+ for path in paths
441
+ )
442
+
443
+ def _display_origin(self, origin: str) -> str:
444
+ path = Path(origin).expanduser()
445
+ if path.is_absolute():
446
+ return _display_path(path, self._root)
447
+ return origin.replace("\\", "/")
448
+
449
+ def _display_problem_path(self, value: str) -> str:
450
+ path = Path(value).expanduser()
451
+ if path.is_absolute():
452
+ return _display_path(path, self._root)
453
+ return value.replace("\\", "/")
454
+
455
+
456
+ def _display_path(path: Path, root: Path) -> str:
457
+ resolved = path.expanduser().resolve()
458
+ try:
459
+ return resolved.relative_to(root).as_posix()
460
+ except ValueError:
461
+ return resolved.as_posix()
462
+
463
+
464
+ def unit_source_path(root: Path, unit: AgentUnit) -> Path:
465
+ path = Path(unit.path)
466
+ if not path.is_absolute():
467
+ path = root / path
468
+ return path.expanduser().resolve()
469
+
470
+
471
+ def unit_display_path(root: Path, unit: AgentUnit) -> str:
472
+ source_path = unit_source_path(root, unit)
473
+ try:
474
+ return source_path.relative_to(root).as_posix()
475
+ except ValueError:
476
+ unit_component = _stable_path_component(unit.name or source_path.stem)
477
+ file_component = _stable_path_component(source_path.name)
478
+ return f"@external/{unit_component}/{file_component}"
479
+
480
+
481
+ def unit_target_id(root: Path, unit: AgentUnit) -> str:
482
+ return make_target_id("unit", unit_display_path(root, unit), unit.name)
483
+
484
+
485
+ def _stable_path_component(value: str) -> str:
486
+ normalized = unicodedata.normalize("NFC", value).replace("\\", "_").replace("/", "_")
487
+ return normalized or "unknown"
488
+
489
+
490
+ def _outline_agent_source(text: str) -> str:
491
+ return _compact_outline_whitespace(outline_source(text))
492
+
493
+
494
+ def _compact_outline_whitespace(text: str) -> str:
495
+ parts: list[str] = []
496
+ i = 0
497
+ n = len(text)
498
+ while i < n:
499
+ if text[i].isspace():
500
+ start = i
501
+ while i < n and text[i].isspace():
502
+ i += 1
503
+ if parts and i < n and not parts[-1][-1].isspace():
504
+ parts.append(_collapsed_whitespace(text, start, i))
505
+ continue
506
+
507
+ start = i
508
+ if text[i] == "'":
509
+ block_end = multiline_string_block_end(text, i)
510
+ i = block_end if block_end is not None else _quoted_string_end(text, i)
511
+ elif text.startswith("//", i):
512
+ i = _line_comment_end(text, i)
513
+ elif text[i] == "{":
514
+ close = text.find("}", i + 1)
515
+ i = n if close < 0 else close + 1
516
+ elif text.startswith("(*", i):
517
+ close = text.find("*)", i + 2)
518
+ i = n if close < 0 else close + 2
519
+ else:
520
+ i += 1
521
+ while i < n:
522
+ if text[i].isspace() or text[i] in {"'", "{"}:
523
+ break
524
+ if text.startswith("//", i) or text.startswith("(*", i):
525
+ break
526
+ i += 1
527
+ parts.append(text[start:i])
528
+ return "".join(parts)
529
+
530
+
531
+ def _collapsed_whitespace(text: str, start: int, end: int) -> str:
532
+ for index in range(start, end):
533
+ if text[index] == "\r":
534
+ if index + 1 < end and text[index + 1] == "\n":
535
+ return "\r\n"
536
+ return "\r"
537
+ if text[index] == "\n":
538
+ return "\n"
539
+ return " "
540
+
541
+
542
+ def _quoted_string_end(text: str, start: int) -> int:
543
+ i = start + 1
544
+ while i < len(text):
545
+ if text[i] != "'":
546
+ i += 1
547
+ continue
548
+ if i + 1 < len(text) and text[i + 1] == "'":
549
+ i += 2
550
+ continue
551
+ return i + 1
552
+ return len(text)
553
+
554
+
555
+ def _line_comment_end(text: str, start: int) -> int:
556
+ i = start + 2
557
+ while i < len(text) and text[i] not in {"\r", "\n"}:
558
+ i += 1
559
+ if i < len(text) and text[i] == "\r" and i + 1 < len(text) and text[i + 1] == "\n":
560
+ return i + 2
561
+ if i < len(text):
562
+ return i + 1
563
+ return i
564
+
565
+
566
+ def _project_kind(path: Path) -> str:
567
+ if path.suffix.casefold() == ".dpr":
568
+ return "program"
569
+ if path.suffix.casefold() == ".dpk":
570
+ return "package"
571
+ return "project"
572
+
573
+
574
+ def _catalog_workspace_sources(discovery: DelphiProjectDiscovery) -> ProjectIndexResult:
575
+ parsed_units: list[UnitInfo] = []
576
+ include_files: list[IncludeFileInfo] = []
577
+ for source in discovery.source_files:
578
+ path = Path(source)
579
+ if path.suffix.casefold() in {".pas", ".dpr", ".dpk"}:
580
+ parsed_units.append(UnitInfo(name=path.stem, path=source, syntax_tree=None))
581
+ elif path.suffix.casefold() == ".inc":
582
+ include_files.append(IncludeFileInfo(name=path.name, path=source))
583
+
584
+ return ProjectIndexResult(
585
+ parsed_units=sorted(
586
+ parsed_units,
587
+ key=lambda item: (item.name.casefold(), item.path.casefold(), item.name, item.path),
588
+ ),
589
+ include_files=sorted(
590
+ include_files,
591
+ key=lambda item: (item.name.casefold(), item.path.casefold(), item.name, item.path),
592
+ ),
593
+ problems=[],
594
+ not_found_units=[],
595
+ )
596
+
597
+
598
+ def _selection_fingerprint(
599
+ discovery: DelphiProjectDiscovery,
600
+ result: ProjectIndexResult | None,
601
+ *,
602
+ root: Path,
603
+ ) -> str:
604
+ state = _selection_state(discovery, result, root=root)
605
+ encoded = json.dumps(
606
+ state,
607
+ ensure_ascii=False,
608
+ sort_keys=True,
609
+ separators=(",", ":"),
610
+ ).encode("utf-8")
611
+ return hashlib.sha256(encoded).hexdigest()
612
+
613
+
614
+ def _estimate_project_result_bytes(result: ProjectIndexResult) -> int:
615
+ retained = 2048
616
+ retained += sum(
617
+ 768 + sys.getsizeof(unit.name) + sys.getsizeof(unit.path)
618
+ for unit in result.parsed_units
619
+ )
620
+ retained += sum(
621
+ 384 + sys.getsizeof(include.name) + sys.getsizeof(include.path)
622
+ for include in result.include_files
623
+ )
624
+ retained += sum(
625
+ 512
626
+ + sys.getsizeof(problem.file_name)
627
+ + sys.getsizeof(problem.description)
628
+ for problem in result.problems
629
+ )
630
+ retained += sum(sys.getsizeof(name) + 96 for name in result.not_found_units)
631
+ return retained
632
+
633
+
634
+ def _selection_state(
635
+ discovery: DelphiProjectDiscovery,
636
+ result: ProjectIndexResult | None,
637
+ *,
638
+ root: Path,
639
+ ) -> dict[str, object]:
640
+ return {
641
+ "files": _file_records(_selection_paths(discovery, result), root=root),
642
+ "search_paths": [_display_path(Path(path), root) for path in discovery.search_paths],
643
+ "include_paths": [_display_path(Path(path), root) for path in discovery.include_paths],
644
+ "defines": sorted(discovery.defines, key=lambda item: (item.casefold(), item)),
645
+ "directories": _directory_snapshots(
646
+ _selection_snapshot_specs(discovery, result),
647
+ root=root,
648
+ project_config=discovery.project_config,
649
+ ),
650
+ }
651
+
652
+
653
+ def _selection_paths(
654
+ discovery: DelphiProjectDiscovery,
655
+ result: ProjectIndexResult | None,
656
+ ) -> set[str]:
657
+ paths = {
658
+ *discovery.project_files,
659
+ *discovery.config_files,
660
+ *discovery.source_files,
661
+ }
662
+ if result is not None:
663
+ paths.update(unit.path for unit in result.parsed_units)
664
+ paths.update(include.path for include in result.include_files)
665
+ paths.update(_explicit_dependency_paths(result, discovery.project_config))
666
+ if discovery.project_config is None:
667
+ return paths
668
+ return {
669
+ path
670
+ for path in paths
671
+ if not discovery.project_config.excludes_workspace_path(path)
672
+ }
673
+
674
+
675
+ def _explicit_dependency_paths(
676
+ result: ProjectIndexResult,
677
+ project_config: ProjectPathConfig | None,
678
+ ) -> set[str]:
679
+ paths: dict[str, str] = {}
680
+ for unit in sorted(
681
+ result.parsed_units,
682
+ key=lambda item: (item.path.casefold(), item.path, item.name.casefold(), item.name),
683
+ ):
684
+ if unit.syntax_tree is None:
685
+ continue
686
+ declaring_folder = Path(unit.path).expanduser().resolve().parent
687
+ pending = [unit.syntax_tree]
688
+ while pending:
689
+ node = pending.pop()
690
+ if node.typ == SyntaxNodeType.ntUnit:
691
+ raw_path = node.get_attribute(AttributeName.anPath).strip()
692
+ if raw_path:
693
+ candidate = Path(raw_path.replace("\\", "/"))
694
+ if not candidate.is_absolute():
695
+ candidate = declaring_folder / candidate
696
+ resolved = str(candidate.resolve())
697
+ if (
698
+ project_config is None
699
+ or not project_config.excludes_workspace_path(resolved)
700
+ ):
701
+ paths.setdefault(resolved.casefold(), resolved)
702
+ pending.extend(reversed(node.child_nodes))
703
+ return set(paths.values())
704
+
705
+
706
+ def _selection_snapshot_specs(
707
+ discovery: DelphiProjectDiscovery,
708
+ result: ProjectIndexResult | None,
709
+ ) -> list[_DirectorySnapshotSpec]:
710
+ specs: dict[str, _DirectorySnapshotSpec] = {}
711
+
712
+ def add(
713
+ path: Path,
714
+ *,
715
+ immediate: tuple[str, ...] = (),
716
+ recursive: tuple[str, ...] = (),
717
+ ) -> None:
718
+ resolved = path.expanduser().resolve()
719
+ if (
720
+ discovery.project_config is not None
721
+ and discovery.project_config.excludes_workspace_path(resolved)
722
+ ):
723
+ return
724
+ key = str(resolved).casefold()
725
+ spec = specs.setdefault(
726
+ key,
727
+ _DirectorySnapshotSpec(
728
+ path=resolved,
729
+ immediate_extensions=set(),
730
+ recursive_extensions=set(),
731
+ ),
732
+ )
733
+ spec.immediate_extensions.update(immediate)
734
+ spec.recursive_extensions.update(recursive)
735
+
736
+ for project in discovery.project_files:
737
+ add(
738
+ Path(project).parent,
739
+ immediate=_PROJECT_SNAPSHOT_EXTENSIONS,
740
+ recursive=_RELATIVE_INCLUDE_EXTENSIONS,
741
+ )
742
+ for path in discovery.search_paths:
743
+ add(
744
+ Path(path),
745
+ immediate=_UNIT_SNAPSHOT_EXTENSIONS,
746
+ recursive=_RELATIVE_INCLUDE_EXTENSIONS,
747
+ )
748
+ for path in discovery.include_paths:
749
+ add(Path(path), recursive=SOURCE_EXTENSIONS)
750
+ if result is not None:
751
+ for unit in result.parsed_units:
752
+ add(
753
+ Path(unit.path).parent,
754
+ immediate=_UNIT_SNAPSHOT_EXTENSIONS,
755
+ recursive=_RELATIVE_INCLUDE_EXTENSIONS,
756
+ )
757
+ for include in result.include_files:
758
+ add(
759
+ Path(include.path).parent,
760
+ immediate=_UNIT_SNAPSHOT_EXTENSIONS,
761
+ recursive=_RELATIVE_INCLUDE_EXTENSIONS,
762
+ )
763
+ return sorted(specs.values(), key=lambda item: (str(item.path).casefold(), str(item.path)))
764
+
765
+
766
+ def _directory_snapshots(
767
+ specs: list[_DirectorySnapshotSpec],
768
+ *,
769
+ root: Path,
770
+ project_config: ProjectPathConfig | None,
771
+ ) -> list[dict[str, object]]:
772
+ snapshots: list[dict[str, object]] = []
773
+ for spec in specs:
774
+ directory = spec.path
775
+ entries: set[str] = set()
776
+ readable = True
777
+ try:
778
+ if spec.immediate_extensions:
779
+ for candidate in directory.iterdir():
780
+ if (
781
+ (
782
+ project_config is None
783
+ or not project_config.excludes_workspace_path(candidate)
784
+ )
785
+ and candidate.suffix.casefold() in spec.immediate_extensions
786
+ and candidate.is_file()
787
+ ):
788
+ entries.add(str(candidate.resolve()))
789
+ if spec.recursive_extensions:
790
+ if project_config is None:
791
+ for candidate in directory.rglob("*"):
792
+ if any(part in SKIP_DIRS for part in candidate.parts):
793
+ continue
794
+ if (
795
+ candidate.suffix.casefold() in spec.recursive_extensions
796
+ and candidate.is_file()
797
+ ):
798
+ entries.add(str(candidate.resolve()))
799
+ else:
800
+ for current_name, directory_names, file_names in os.walk(
801
+ directory,
802
+ followlinks=False,
803
+ ):
804
+ current = Path(current_name)
805
+ directory_names[:] = [
806
+ name
807
+ for name in directory_names
808
+ if name not in SKIP_DIRS
809
+ and not project_config.excludes_workspace_path(
810
+ current / name
811
+ )
812
+ ]
813
+ for name in file_names:
814
+ candidate = current / name
815
+ if (
816
+ candidate.suffix.casefold()
817
+ in spec.recursive_extensions
818
+ and not project_config.excludes_workspace_path(candidate)
819
+ and candidate.is_file()
820
+ ):
821
+ entries.add(str(candidate.resolve()))
822
+ except OSError:
823
+ readable = False
824
+ snapshots.append(
825
+ {
826
+ "path": _display_path(directory, root),
827
+ "is_directory": directory.is_dir(),
828
+ "readable": readable,
829
+ "entries": _file_records(entries, root=root),
830
+ }
831
+ )
832
+ return snapshots
833
+
834
+
835
+ def _file_records(
836
+ paths: set[str],
837
+ *,
838
+ root: Path,
839
+ ) -> list[tuple[str, int | None, int | None]]:
840
+ records: list[tuple[str, int | None, int | None]] = []
841
+ for value in sorted(paths, key=lambda item: (item.casefold(), item)):
842
+ path = Path(value).expanduser().resolve()
843
+ exposed_path = _display_path(path, root)
844
+ try:
845
+ stat = path.stat()
846
+ except OSError:
847
+ records.append((exposed_path, None, None))
848
+ else:
849
+ records.append((exposed_path, stat.st_size, stat.st_mtime_ns))
850
+ return records
851
+
852
+
853
+ __all__ = [
854
+ "AgentProject",
855
+ "AgentUnit",
856
+ "AgentWorkspace",
857
+ "unit_display_path",
858
+ "unit_source_path",
859
+ "unit_target_id",
860
+ ]