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,932 @@
1
+ from __future__ import annotations
2
+
3
+ import errno
4
+ import os
5
+ import re
6
+ import xml.etree.ElementTree as ET
7
+ from collections.abc import Iterable, Iterator
8
+ from dataclasses import dataclass, field
9
+ from pathlib import Path
10
+
11
+ from .progress import ProgressCallback, ProgressEvent
12
+ from .project_config import (
13
+ ProjectConfigError,
14
+ ProjectPathConfig,
15
+ load_project_path_config,
16
+ )
17
+ from .source_reader import read_source_text
18
+
19
+ SOURCE_EXTENSIONS = (".pas", ".dpr", ".dpk", ".inc")
20
+ PROJECT_EXTENSIONS = (".dpr", ".dpk")
21
+ SKIP_DIRS = {
22
+ ".git",
23
+ ".hg",
24
+ ".svn",
25
+ ".venv",
26
+ "venv",
27
+ "env",
28
+ "__pycache__",
29
+ ".pytest_cache",
30
+ ".ruff_cache",
31
+ ".mypy_cache",
32
+ "build",
33
+ "dist",
34
+ ".worktrees",
35
+ "node_modules",
36
+ }
37
+ _RAD_STUDIO_ALWAYS_SKIP_DIRS = {"__history", "__recovery"}
38
+ _RAD_STUDIO_PROJECT_OUTPUT_DIRS = {"bin", "obj", "win32", "win64"}
39
+ _DELPHI_PROJECT_MARKER_EXTENSIONS = {".dpr", ".dproj", ".dpk"}
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class DiscoveryProblem:
44
+ kind: str
45
+ message: str
46
+ origin: str
47
+
48
+
49
+ @dataclass
50
+ class DelphiProjectDiscovery:
51
+ root: str
52
+ project_files: list[str] = field(default_factory=list)
53
+ config_files: list[str] = field(default_factory=list)
54
+ search_paths: list[str] = field(default_factory=list)
55
+ include_paths: list[str] = field(default_factory=list)
56
+ defines: list[str] = field(default_factory=list)
57
+ source_files: list[str] = field(default_factory=list)
58
+ unit_paths: dict[str, list[str]] = field(default_factory=dict)
59
+ problems: list[DiscoveryProblem] = field(default_factory=list)
60
+ search_path_origins: dict[str, list[str]] = field(default_factory=dict)
61
+ include_path_origins: dict[str, list[str]] = field(default_factory=dict)
62
+ define_origins: dict[str, list[str]] = field(default_factory=dict)
63
+ project_config: ProjectPathConfig | None = None
64
+
65
+
66
+ _DPR_UNIT_RE = re.compile(
67
+ r"\b(?P<name>[A-Za-z_][A-Za-z0-9_.]*)\b\s*(?:in\s*['\"](?P<path>[^'\"]+)['\"])?",
68
+ re.IGNORECASE,
69
+ )
70
+ _DPR_CLAUSE_RE = re.compile(r"\b(?:uses|contains)\b(?P<body>.*?);", re.IGNORECASE | re.DOTALL)
71
+ _CFG_TOKEN_RE = re.compile(r"(?P<option>-[UID])(?P<value>.+)", re.IGNORECASE)
72
+ _MACRO_RE = re.compile(r"\$\(([A-Za-z_][A-Za-z0-9_]*)\)")
73
+ _WINDOWS_ABSOLUTE_RE = re.compile(r"^[A-Za-z]:[\\/]")
74
+
75
+
76
+ def discover_delphi_project(
77
+ root: str | os.PathLike[str],
78
+ *,
79
+ project_file: str | os.PathLike[str] | None = None,
80
+ main_projects_only: bool = False,
81
+ include_paths: Iterable[str | os.PathLike[str]] = (),
82
+ search_paths: Iterable[str | os.PathLike[str]] = (),
83
+ defines: Iterable[str] = (),
84
+ scan_workspace_sources: bool = True,
85
+ on_progress: ProgressCallback | None = None,
86
+ ) -> DelphiProjectDiscovery:
87
+ root_path = Path(root).expanduser().resolve()
88
+ project_config = load_project_path_config(root_path)
89
+ project_path = Path(project_file).expanduser() if project_file is not None else None
90
+ if project_path is not None:
91
+ if not project_path.is_absolute():
92
+ project_path = root_path / project_path
93
+ project_path = project_path.resolve()
94
+ if (
95
+ project_config is not None
96
+ and project_config.excludes_workspace_path(project_path)
97
+ ):
98
+ raise ProjectConfigError(
99
+ f"{project_config.source}: explicit project {project_path} is "
100
+ "excluded by workspace.exclude."
101
+ )
102
+ discovery = DelphiProjectDiscovery(
103
+ root=str(root_path),
104
+ project_config=project_config,
105
+ )
106
+ _emit_progress(on_progress, "discovery", str(root_path), 0, 0, None, "project discovery started")
107
+
108
+ seen_search: dict[str, str] = {}
109
+ seen_include: dict[str, str] = {}
110
+ seen_defines: set[str] = set()
111
+ seen_projects: set[str] = set()
112
+ seen_configs: set[str] = set()
113
+ project_configs: dict[str, tuple[Path, ...]] = {}
114
+ if project_config is not None:
115
+ config_key = str(project_config.source).casefold()
116
+ seen_configs.add(config_key)
117
+ discovery.config_files.append(str(project_config.source))
118
+
119
+ def add_path(
120
+ target: list[str],
121
+ canonical_by_key: dict[str, str],
122
+ origins: dict[str, list[str]],
123
+ path: Path | str,
124
+ *,
125
+ base: Path,
126
+ origin: str,
127
+ ) -> None:
128
+ _add_resolved_path(
129
+ target,
130
+ canonical_by_key,
131
+ origins,
132
+ str(path),
133
+ base=base,
134
+ origin=origin,
135
+ discovery=discovery,
136
+ )
137
+
138
+ def add_define(raw: str, *, origin: str = "manual define") -> None:
139
+ for item in _split_list(raw):
140
+ define = item.strip()
141
+ if not define:
142
+ continue
143
+ if _MACRO_RE.search(define):
144
+ discovery.problems.append(
145
+ DiscoveryProblem("unresolved_macro", f"Could not resolve {define} in define list", origin)
146
+ )
147
+ continue
148
+ key = define.casefold()
149
+ if key not in seen_defines:
150
+ seen_defines.add(key)
151
+ discovery.defines.append(define)
152
+ exposed_define = next(item for item in discovery.defines if item.casefold() == key)
153
+ _record_origin(discovery.define_origins, exposed_define, origin)
154
+
155
+ for value in search_paths:
156
+ add_path(
157
+ discovery.search_paths,
158
+ seen_search,
159
+ discovery.search_path_origins,
160
+ Path(value),
161
+ base=root_path,
162
+ origin="manual search path",
163
+ )
164
+ for value in include_paths:
165
+ add_path(
166
+ discovery.include_paths,
167
+ seen_include,
168
+ discovery.include_path_origins,
169
+ Path(value),
170
+ base=root_path,
171
+ origin="manual include path",
172
+ )
173
+ for value in defines:
174
+ add_define(value)
175
+
176
+ explicit_dproj: Path | None = None
177
+ if (
178
+ project_path is not None
179
+ and project_path.suffix.casefold() == ".dproj"
180
+ ):
181
+ explicit_dproj = project_path
182
+ project_path = _project_entry_from_dproj(
183
+ explicit_dproj,
184
+ discovery,
185
+ )
186
+ if (
187
+ project_path is not None
188
+ and project_config is not None
189
+ and project_config.excludes_workspace_path(project_path)
190
+ ):
191
+ raise ProjectConfigError(
192
+ f"{project_config.source}: explicit project {project_path} is "
193
+ "excluded by workspace.exclude."
194
+ )
195
+ candidates = (
196
+ []
197
+ if explicit_dproj is not None and project_path is None
198
+ else _project_candidates(
199
+ root_path,
200
+ project_path,
201
+ main_projects_only=main_projects_only,
202
+ project_config=project_config,
203
+ project_configs=project_configs,
204
+ )
205
+ )
206
+ for project in candidates:
207
+ key = str(project).casefold()
208
+ if key not in seen_projects:
209
+ seen_projects.add(key)
210
+ discovery.project_files.append(str(project))
211
+ if project.suffix.casefold() in PROJECT_EXTENSIONS:
212
+ _read_dpr_paths(
213
+ project,
214
+ discovery,
215
+ discovery.search_paths,
216
+ seen_search,
217
+ discovery.search_path_origins,
218
+ )
219
+ dproj_candidates = [
220
+ *project_configs.get(str(project).casefold(), ()),
221
+ project.with_suffix(".dproj"),
222
+ ]
223
+ if explicit_dproj is not None:
224
+ dproj_candidates.insert(0, explicit_dproj)
225
+ for dproj in dproj_candidates:
226
+ config_key = str(dproj).casefold()
227
+ if config_key in seen_configs or not dproj.exists():
228
+ continue
229
+ _read_dproj(
230
+ dproj,
231
+ discovery,
232
+ discovery.search_paths,
233
+ seen_search,
234
+ discovery.search_path_origins,
235
+ discovery.include_paths,
236
+ seen_include,
237
+ discovery.include_path_origins,
238
+ add_define,
239
+ )
240
+ seen_configs.add(config_key)
241
+ discovery.config_files.append(str(dproj))
242
+ for cfg in (project.with_suffix(".cfg"), project.with_suffix(".dof")):
243
+ if cfg.exists():
244
+ _read_cfg(
245
+ cfg,
246
+ discovery,
247
+ discovery.search_paths,
248
+ seen_search,
249
+ discovery.search_path_origins,
250
+ discovery.include_paths,
251
+ seen_include,
252
+ discovery.include_path_origins,
253
+ add_define,
254
+ )
255
+ key = str(cfg).casefold()
256
+ if key not in seen_configs:
257
+ seen_configs.add(key)
258
+ discovery.config_files.append(str(cfg))
259
+
260
+ if scan_workspace_sources:
261
+ populate_workspace_sources(discovery, on_progress=on_progress)
262
+
263
+ _emit_progress(
264
+ on_progress,
265
+ "inventory",
266
+ str(root_path),
267
+ len(discovery.source_files),
268
+ 0,
269
+ len(discovery.source_files),
270
+ "project discovery complete",
271
+ )
272
+
273
+ return discovery
274
+
275
+
276
+ def populate_workspace_sources(
277
+ discovery: DelphiProjectDiscovery,
278
+ *,
279
+ on_progress: ProgressCallback | None = None,
280
+ ) -> DelphiProjectDiscovery:
281
+ root_path = Path(discovery.root).expanduser().resolve()
282
+ seen_sources = {source.casefold() for source in discovery.source_files}
283
+ seen_search = {path.casefold(): path for path in discovery.search_paths}
284
+ seen_include = {path.casefold(): path for path in discovery.include_paths}
285
+
286
+ _scan_sources(root_path, discovery, seen_sources, on_progress=on_progress)
287
+ total_sources = len(discovery.source_files)
288
+ _emit_progress(
289
+ on_progress,
290
+ "inventory",
291
+ str(root_path),
292
+ total_sources,
293
+ 0,
294
+ total_sources,
295
+ "workspace source inventory complete",
296
+ )
297
+ for source in discovery.source_files:
298
+ path = Path(source)
299
+ unit_key = path.stem.casefold()
300
+ unit_paths = discovery.unit_paths.setdefault(unit_key, [])
301
+ if source not in unit_paths:
302
+ unit_paths.append(source)
303
+ if path.suffix.casefold() in {".pas", ".dpr", ".dpk"}:
304
+ _add_resolved_path(
305
+ discovery.search_paths,
306
+ seen_search,
307
+ discovery.search_path_origins,
308
+ str(path.parent),
309
+ base=root_path,
310
+ origin="workspace source scan",
311
+ discovery=discovery,
312
+ )
313
+ elif path.suffix.casefold() == ".inc":
314
+ _add_resolved_path(
315
+ discovery.include_paths,
316
+ seen_include,
317
+ discovery.include_path_origins,
318
+ str(path.parent),
319
+ base=root_path,
320
+ origin="workspace include scan",
321
+ discovery=discovery,
322
+ )
323
+
324
+ return discovery
325
+
326
+
327
+ def discover_workspace_sources(
328
+ root: str | os.PathLike[str],
329
+ *,
330
+ on_progress: ProgressCallback | None = None,
331
+ ) -> DelphiProjectDiscovery:
332
+ root_path = Path(root).expanduser().resolve()
333
+ project_config = load_project_path_config(root_path)
334
+ discovery = DelphiProjectDiscovery(
335
+ root=str(root_path),
336
+ project_config=project_config,
337
+ )
338
+ if project_config is not None:
339
+ discovery.config_files.append(str(project_config.source))
340
+ _emit_progress(on_progress, "discovery", str(root_path), 0, 0, None, "workspace discovery started")
341
+ populate_workspace_sources(discovery, on_progress=on_progress)
342
+ _emit_progress(
343
+ on_progress,
344
+ "complete",
345
+ str(root_path),
346
+ len(discovery.source_files),
347
+ len(discovery.source_files),
348
+ len(discovery.source_files),
349
+ "workspace discovery complete",
350
+ )
351
+ return discovery
352
+
353
+
354
+ def _emit_progress(
355
+ callback: ProgressCallback | None,
356
+ phase: str,
357
+ path: str,
358
+ files_discovered: int,
359
+ files_completed: int,
360
+ files_total: int | None,
361
+ detail: str,
362
+ ) -> None:
363
+ if callback is not None:
364
+ callback(
365
+ ProgressEvent(
366
+ phase=phase,
367
+ language="delphi",
368
+ path=path,
369
+ files_discovered=files_discovered,
370
+ files_completed=files_completed,
371
+ files_total=files_total,
372
+ lines_processed=0,
373
+ symbols_discovered=0,
374
+ cached_files=0,
375
+ detail=detail,
376
+ )
377
+ )
378
+
379
+
380
+ def _project_candidates(
381
+ root: Path,
382
+ explicit: Path | None,
383
+ *,
384
+ main_projects_only: bool = False,
385
+ project_config: ProjectPathConfig | None = None,
386
+ project_configs: dict[str, tuple[Path, ...]] | None = None,
387
+ ) -> list[Path]:
388
+ if explicit is not None:
389
+ return [explicit]
390
+ candidates: list[Path] = []
391
+ aliases: dict[str, list[Path]] = {}
392
+ configs_by_entry: dict[str, list[Path]] = {}
393
+ for ext in PROJECT_EXTENSIONS:
394
+ candidates.extend(_walk_sources(root, f"*{ext}", project_config))
395
+ for candidate in candidates:
396
+ key = str(candidate).casefold()
397
+ aliases.setdefault(key, []).append(candidate)
398
+ companion = candidate.with_suffix(".dproj")
399
+ if companion.is_file():
400
+ aliases[key].append(companion)
401
+ configs_by_entry.setdefault(key, []).append(companion)
402
+ for dproj in _walk_sources(root, "*.dproj", project_config):
403
+ main = _main_source_from_dproj(dproj)
404
+ if main is not None:
405
+ entry = (dproj.parent / main).resolve()
406
+ key = str(entry).casefold()
407
+ aliases.setdefault(key, []).extend((entry, dproj))
408
+ configs_by_entry.setdefault(key, []).append(dproj)
409
+ candidates.append(entry)
410
+ candidates = sorted(
411
+ {
412
+ candidate
413
+ for candidate in candidates
414
+ if candidate.exists()
415
+ and candidate.is_file()
416
+ and candidate.is_relative_to(root)
417
+ and (
418
+ project_config is None
419
+ or not project_config.excludes_workspace_path(candidate)
420
+ )
421
+ },
422
+ key=lambda path: str(path).casefold(),
423
+ )
424
+ if project_config is not None and project_config.has_filters:
425
+ candidates = [
426
+ candidate
427
+ for candidate in candidates
428
+ if project_config.selects(
429
+ *aliases.get(
430
+ str(candidate).casefold(),
431
+ (candidate, candidate.with_suffix(".dproj")),
432
+ )
433
+ )
434
+ ]
435
+ if not candidates:
436
+ raise ProjectConfigError(
437
+ f"{project_config.source}: configured [projects] filters matched "
438
+ "no Delphi projects."
439
+ )
440
+ _set_project_configs(project_configs, configs_by_entry, candidates)
441
+ return candidates
442
+ if not main_projects_only or not candidates:
443
+ _set_project_configs(project_configs, configs_by_entry, candidates)
444
+ return candidates
445
+
446
+ primary = [
447
+ candidate
448
+ for candidate in candidates
449
+ if not _is_auxiliary_project(candidate, root)
450
+ ]
451
+ configured = [
452
+ candidate
453
+ for candidate in primary
454
+ if candidate.with_suffix(".dproj").is_file()
455
+ ]
456
+ root_entries = [
457
+ candidate
458
+ for candidate in primary
459
+ if candidate.parent == root
460
+ ]
461
+ scoped = root_entries or configured
462
+ if not scoped:
463
+ return []
464
+ minimum_depth = min(
465
+ len(candidate.parent.relative_to(root).parts)
466
+ for candidate in scoped
467
+ )
468
+ selected = [
469
+ candidate
470
+ for candidate in scoped
471
+ if len(candidate.parent.relative_to(root).parts) == minimum_depth
472
+ ]
473
+ _set_project_configs(project_configs, configs_by_entry, selected)
474
+ return selected
475
+
476
+
477
+ def _set_project_configs(
478
+ target: dict[str, tuple[Path, ...]] | None,
479
+ configs_by_entry: dict[str, list[Path]],
480
+ selected: list[Path],
481
+ ) -> None:
482
+ if target is None:
483
+ return
484
+ for candidate in selected:
485
+ key = str(candidate).casefold()
486
+ configs = sorted(
487
+ set(configs_by_entry.get(key, ())),
488
+ key=lambda path: str(path).casefold(),
489
+ )
490
+ if configs:
491
+ target[key] = tuple(configs)
492
+
493
+
494
+ _AUXILIARY_PROJECT_PARTS = {
495
+ "benchmark",
496
+ "benchmarks",
497
+ "demo",
498
+ "demos",
499
+ "ex",
500
+ "example",
501
+ "examples",
502
+ "sample",
503
+ "samples",
504
+ "test",
505
+ "tests",
506
+ "testing",
507
+ "thirdparty",
508
+ "third-party",
509
+ "vendor",
510
+ "vendors",
511
+ }
512
+
513
+
514
+ def _is_auxiliary_project(path: Path, root: Path) -> bool:
515
+ relative = path.relative_to(root)
516
+ directory_parts = {part.casefold() for part in relative.parent.parts}
517
+ if directory_parts & _AUXILIARY_PROJECT_PARTS:
518
+ return True
519
+ stem = path.stem.casefold()
520
+ return (
521
+ "example" in stem
522
+ or "sample" in stem
523
+ or stem.startswith("test")
524
+ or stem.endswith("tests")
525
+ )
526
+
527
+
528
+ def _read_dpr_paths(
529
+ project: Path,
530
+ discovery: DelphiProjectDiscovery,
531
+ search_paths: list[str],
532
+ seen_search: dict[str, str],
533
+ search_path_origins: dict[str, list[str]],
534
+ ) -> None:
535
+ try:
536
+ text = read_source_text(project)
537
+ except (OSError, UnicodeError) as exc:
538
+ discovery.problems.append(DiscoveryProblem("cant_read_project", str(exc), str(project)))
539
+ return
540
+ for clause in _DPR_CLAUSE_RE.finditer(text):
541
+ body = clause.group("body")
542
+ for match in _DPR_UNIT_RE.finditer(body):
543
+ unit_path = match.group("path")
544
+ if not unit_path:
545
+ continue
546
+ resolved = _resolve_project_path(unit_path, base=project.parent, origin=str(project), discovery=discovery)
547
+ if resolved is None:
548
+ continue
549
+ _add_resolved_path(
550
+ search_paths,
551
+ seen_search,
552
+ search_path_origins,
553
+ str(resolved.parent),
554
+ base=project.parent,
555
+ origin=str(project),
556
+ discovery=discovery,
557
+ )
558
+
559
+
560
+ def _read_dproj(
561
+ path: Path,
562
+ discovery: DelphiProjectDiscovery,
563
+ search_paths: list[str],
564
+ seen_search: dict[str, str],
565
+ search_path_origins: dict[str, list[str]],
566
+ include_paths: list[str],
567
+ seen_include: dict[str, str],
568
+ include_path_origins: dict[str, list[str]],
569
+ add_define,
570
+ ) -> None:
571
+ try:
572
+ root = ET.parse(path).getroot()
573
+ except (ET.ParseError, OSError) as exc:
574
+ discovery.problems.append(DiscoveryProblem("cant_read_dproj", str(exc), str(path)))
575
+ return
576
+
577
+ for element in root.iter():
578
+ name = _xml_local_name(element.tag)
579
+ text = (element.text or "").strip()
580
+ if name in {"DCC_UnitSearchPath", "UnitSearchPath"} and text:
581
+ for item in _split_list(text):
582
+ _add_resolved_path(
583
+ search_paths,
584
+ seen_search,
585
+ search_path_origins,
586
+ item,
587
+ base=path.parent,
588
+ origin=str(path),
589
+ discovery=discovery,
590
+ )
591
+ elif name in {"DCC_IncludePath", "IncludePath"} and text:
592
+ for item in _split_list(text):
593
+ _add_resolved_path(
594
+ include_paths,
595
+ seen_include,
596
+ include_path_origins,
597
+ item,
598
+ base=path.parent,
599
+ origin=str(path),
600
+ discovery=discovery,
601
+ )
602
+ elif name in {"DCC_Define", "DefineConstants"} and text:
603
+ add_define(text, origin=str(path))
604
+
605
+ if name == "DCCReference":
606
+ include = element.attrib.get("Include") or element.attrib.get("include")
607
+ if include:
608
+ resolved = _resolve_project_path(include, base=path.parent, origin=str(path), discovery=discovery)
609
+ if resolved is not None:
610
+ _add_resolved_path(
611
+ search_paths,
612
+ seen_search,
613
+ search_path_origins,
614
+ str(resolved.parent),
615
+ base=path.parent,
616
+ origin=str(path),
617
+ discovery=discovery,
618
+ )
619
+
620
+
621
+ def _read_cfg(
622
+ path: Path,
623
+ discovery: DelphiProjectDiscovery,
624
+ search_paths: list[str],
625
+ seen_search: dict[str, str],
626
+ search_path_origins: dict[str, list[str]],
627
+ include_paths: list[str],
628
+ seen_include: dict[str, str],
629
+ include_path_origins: dict[str, list[str]],
630
+ add_define,
631
+ ) -> None:
632
+ try:
633
+ lines = read_source_text(path).splitlines()
634
+ except (OSError, UnicodeError) as exc:
635
+ discovery.problems.append(DiscoveryProblem("cant_read_config", str(exc), str(path)))
636
+ return
637
+ for line in lines:
638
+ stripped = line.strip()
639
+ if not stripped or stripped.startswith("#"):
640
+ continue
641
+ match = _CFG_TOKEN_RE.match(stripped)
642
+ if match is None:
643
+ continue
644
+ option = match.group("option").casefold()
645
+ value = match.group("value")
646
+ if option == "-u":
647
+ for item in _split_list(value):
648
+ _add_resolved_path(
649
+ search_paths,
650
+ seen_search,
651
+ search_path_origins,
652
+ item,
653
+ base=path.parent,
654
+ origin=str(path),
655
+ discovery=discovery,
656
+ )
657
+ elif option == "-i":
658
+ for item in _split_list(value):
659
+ _add_resolved_path(
660
+ include_paths,
661
+ seen_include,
662
+ include_path_origins,
663
+ item,
664
+ base=path.parent,
665
+ origin=str(path),
666
+ discovery=discovery,
667
+ )
668
+ elif option == "-d":
669
+ add_define(value, origin=str(path))
670
+
671
+
672
+ def _main_source_from_dproj(path: Path) -> str | None:
673
+ try:
674
+ root = ET.parse(path).getroot()
675
+ except (ET.ParseError, OSError):
676
+ return None
677
+ for element in root.iter():
678
+ if _xml_local_name(element.tag) == "MainSource" and element.text:
679
+ return element.text.strip()
680
+ return None
681
+
682
+
683
+ def _project_entry_from_dproj(
684
+ path: Path,
685
+ discovery: DelphiProjectDiscovery,
686
+ ) -> Path | None:
687
+ try:
688
+ root = ET.parse(path).getroot()
689
+ except ET.ParseError as exc:
690
+ discovery.problems.append(
691
+ DiscoveryProblem(
692
+ "cant_read_project",
693
+ f"Could not parse Delphi project file: {exc}",
694
+ str(path),
695
+ )
696
+ )
697
+ return None
698
+ except OSError as exc:
699
+ discovery.problems.append(
700
+ DiscoveryProblem(
701
+ "cant_read_project",
702
+ f"Could not read Delphi project file: {exc}",
703
+ str(path),
704
+ )
705
+ )
706
+ return None
707
+
708
+ main_source = next(
709
+ (
710
+ (element.text or "").strip()
711
+ for element in root.iter()
712
+ if _xml_local_name(element.tag) == "MainSource"
713
+ and (element.text or "").strip()
714
+ ),
715
+ "",
716
+ )
717
+ if not main_source:
718
+ discovery.problems.append(
719
+ DiscoveryProblem(
720
+ "cant_read_project",
721
+ "Delphi project file does not define MainSource.",
722
+ str(path),
723
+ )
724
+ )
725
+ return None
726
+
727
+ entry = (path.parent / main_source).resolve()
728
+ if entry.suffix.casefold() not in PROJECT_EXTENSIONS:
729
+ discovery.problems.append(
730
+ DiscoveryProblem(
731
+ "cant_read_project",
732
+ "MainSource must reference a .dpr or .dpk entry file.",
733
+ str(path),
734
+ )
735
+ )
736
+ return None
737
+ if not entry.is_file():
738
+ discovery.problems.append(
739
+ DiscoveryProblem(
740
+ "cant_read_project",
741
+ f"MainSource does not exist: {entry}",
742
+ str(path),
743
+ )
744
+ )
745
+ return None
746
+ return entry
747
+
748
+
749
+ def _scan_sources(
750
+ root: Path,
751
+ discovery: DelphiProjectDiscovery,
752
+ seen_sources: set[str],
753
+ *,
754
+ on_progress: ProgressCallback | None = None,
755
+ ) -> None:
756
+ for path in _walk_files(root, discovery.project_config):
757
+ if path.suffix.casefold() not in SOURCE_EXTENSIONS:
758
+ continue
759
+ resolved = path.resolve()
760
+ key = str(resolved).casefold()
761
+ if key in seen_sources:
762
+ continue
763
+ seen_sources.add(key)
764
+ discovery.source_files.append(str(resolved))
765
+ _emit_progress(
766
+ on_progress,
767
+ "discovery",
768
+ str(resolved),
769
+ len(discovery.source_files),
770
+ 0,
771
+ None,
772
+ "source file discovered",
773
+ )
774
+ discovery.source_files.sort(key=lambda item: (item.casefold(), item))
775
+
776
+
777
+ def _walk_sources(
778
+ root: Path,
779
+ pattern: str,
780
+ project_config: ProjectPathConfig | None = None,
781
+ ) -> list[Path]:
782
+ results: list[Path] = []
783
+ for path in _walk_files(root, project_config):
784
+ if path.match(pattern):
785
+ results.append(path.resolve())
786
+ return results
787
+
788
+
789
+ def _walk_files(
790
+ root: Path,
791
+ project_config: ProjectPathConfig | None = None,
792
+ ) -> Iterator[Path]:
793
+ def handle_error(error: OSError) -> None:
794
+ if _is_path_too_long(error):
795
+ return
796
+ raise error
797
+
798
+ for directory, directory_names, file_names in os.walk(
799
+ root,
800
+ followlinks=False,
801
+ onerror=handle_error,
802
+ ):
803
+ current = Path(directory)
804
+ directory_names[:] = sorted(
805
+ name
806
+ for name in directory_names
807
+ if not _skip_walk_directory(name, file_names)
808
+ and (
809
+ project_config is None
810
+ or not project_config.excludes_workspace_path(current / name)
811
+ )
812
+ )
813
+ for name in sorted(file_names):
814
+ path = current / name
815
+ if (
816
+ project_config is not None
817
+ and project_config.excludes_workspace_path(path)
818
+ ):
819
+ continue
820
+ try:
821
+ if path.is_file():
822
+ yield path
823
+ except OSError as error:
824
+ if _is_path_too_long(error):
825
+ continue
826
+ raise
827
+
828
+
829
+ def _skip_walk_directory(name: str, sibling_file_names: list[str]) -> bool:
830
+ if name in SKIP_DIRS:
831
+ return True
832
+ normalized = name.casefold()
833
+ if normalized in _RAD_STUDIO_ALWAYS_SKIP_DIRS:
834
+ return True
835
+ if normalized not in _RAD_STUDIO_PROJECT_OUTPUT_DIRS:
836
+ return False
837
+ return any(
838
+ Path(file_name).suffix.casefold() in _DELPHI_PROJECT_MARKER_EXTENSIONS
839
+ for file_name in sibling_file_names
840
+ )
841
+
842
+
843
+ def _is_path_too_long(error: OSError) -> bool:
844
+ return getattr(error, "winerror", None) == 206 or error.errno == errno.ENAMETOOLONG
845
+
846
+
847
+ def _add_resolved_path(
848
+ target: list[str],
849
+ canonical_by_key: dict[str, str],
850
+ origins: dict[str, list[str]],
851
+ value: str,
852
+ *,
853
+ base: Path,
854
+ origin: str,
855
+ discovery: DelphiProjectDiscovery,
856
+ ) -> None:
857
+ resolved = _resolve_project_path(value, base=base, origin=origin, discovery=discovery)
858
+ if resolved is None:
859
+ return
860
+ if (
861
+ discovery.project_config is not None
862
+ and discovery.project_config.excludes_workspace_path(resolved)
863
+ ):
864
+ return
865
+ key = str(resolved).casefold()
866
+ exposed_path = canonical_by_key.get(key)
867
+ if exposed_path is None:
868
+ exposed_path = str(resolved)
869
+ canonical_by_key[key] = exposed_path
870
+ target.append(exposed_path)
871
+ _record_origin(origins, exposed_path, origin)
872
+
873
+
874
+ def _record_origin(origins: dict[str, list[str]], key: str, origin: str) -> None:
875
+ entries = origins.setdefault(key, [])
876
+ if origin not in entries:
877
+ entries.append(origin)
878
+
879
+
880
+ def _resolve_project_path(
881
+ raw: str,
882
+ *,
883
+ base: Path,
884
+ origin: str,
885
+ discovery: DelphiProjectDiscovery,
886
+ ) -> Path | None:
887
+ cleaned = raw.strip().strip('"').strip("'")
888
+ if not cleaned:
889
+ return None
890
+ if os.name != "nt" and _WINDOWS_ABSOLUTE_RE.match(cleaned):
891
+ discovery.problems.append(
892
+ DiscoveryProblem("external_path", f"Skipping non-local Windows absolute path: {raw}", origin)
893
+ )
894
+ return None
895
+ normalized = cleaned.replace("\\", os.sep)
896
+ macros = _MACRO_RE.findall(normalized)
897
+ replacements = {
898
+ "PROJECTDIR": str(base),
899
+ "PROJECT_DIR": str(base),
900
+ "MSBUILDPROJECTDIRECTORY": str(base),
901
+ "MSBUILDTHISFILEDIRECTORY": str(base),
902
+ }
903
+ for macro in macros:
904
+ value = replacements.get(macro.upper())
905
+ if value is None:
906
+ discovery.problems.append(
907
+ DiscoveryProblem("unresolved_macro", f"Could not resolve $({macro}) in {raw}", origin)
908
+ )
909
+ return None
910
+ normalized = normalized.replace(f"$({macro})", value)
911
+ normalized = os.path.expandvars(normalized)
912
+ path = Path(normalized)
913
+ if not path.is_absolute():
914
+ path = base / path
915
+ return path.resolve()
916
+
917
+
918
+ def _split_list(raw: str) -> list[str]:
919
+ return [item.strip() for item in raw.replace("\n", ";").split(";") if item.strip()]
920
+
921
+
922
+ def _xml_local_name(tag: str) -> str:
923
+ return tag.rsplit("}", 1)[-1]
924
+
925
+
926
+ __all__ = [
927
+ "DelphiProjectDiscovery",
928
+ "DiscoveryProblem",
929
+ "discover_delphi_project",
930
+ "discover_workspace_sources",
931
+ "populate_workspace_sources",
932
+ ]