agent-codinglanguage-mapper 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. agent_codinglanguage_mapper/__init__.py +20 -0
  2. agent_codinglanguage_mapper/__main__.py +4 -0
  3. agent_codinglanguage_mapper/_version.py +2 -0
  4. agent_codinglanguage_mapper/adapters/__init__.py +4 -0
  5. agent_codinglanguage_mapper/adapters/delphi.py +822 -0
  6. agent_codinglanguage_mapper/adapters/delphi_frontend/__init__.py +6 -0
  7. agent_codinglanguage_mapper/adapters/delphi_frontend/comment_builder.py +29 -0
  8. agent_codinglanguage_mapper/adapters/delphi_frontend/consts.py +345 -0
  9. agent_codinglanguage_mapper/adapters/delphi_frontend/grammar.py +460 -0
  10. agent_codinglanguage_mapper/adapters/delphi_frontend/lark_builder.py +2674 -0
  11. agent_codinglanguage_mapper/adapters/delphi_frontend/lark_tokens.py +237 -0
  12. agent_codinglanguage_mapper/adapters/delphi_frontend/lsp_server.py +1832 -0
  13. agent_codinglanguage_mapper/adapters/delphi_frontend/nodes.py +371 -0
  14. agent_codinglanguage_mapper/adapters/delphi_frontend/parser.py +193 -0
  15. agent_codinglanguage_mapper/adapters/delphi_frontend/preprocessor.py +997 -0
  16. agent_codinglanguage_mapper/adapters/delphi_frontend/project_discovery.py +518 -0
  17. agent_codinglanguage_mapper/adapters/delphi_frontend/project_indexer.py +319 -0
  18. agent_codinglanguage_mapper/adapters/delphi_frontend/semantic.py +375 -0
  19. agent_codinglanguage_mapper/adapters/delphi_frontend/semantic_builder.py +1384 -0
  20. agent_codinglanguage_mapper/adapters/delphi_frontend/source_reader.py +17 -0
  21. agent_codinglanguage_mapper/adapters/delphi_frontend/workspace.py +67 -0
  22. agent_codinglanguage_mapper/adapters/tree_sitter.py +1722 -0
  23. agent_codinglanguage_mapper/cli.py +138 -0
  24. agent_codinglanguage_mapper/discovery.py +1328 -0
  25. agent_codinglanguage_mapper/indexer.py +1102 -0
  26. agent_codinglanguage_mapper/integration.py +186 -0
  27. agent_codinglanguage_mapper/lsp_server.py +413 -0
  28. agent_codinglanguage_mapper/lsp_service.py +447 -0
  29. agent_codinglanguage_mapper/mapper.py +596 -0
  30. agent_codinglanguage_mapper/models.py +101 -0
  31. agent_codinglanguage_mapper/protocol.py +152 -0
  32. agent_codinglanguage_mapper/rendering.py +153 -0
  33. agent_codinglanguage_mapper/templates/opencode/plugins/codebase_map.ts +191 -0
  34. agent_codinglanguage_mapper/templates/skill/SKILL.md +52 -0
  35. agent_codinglanguage_mapper/worker.py +29 -0
  36. agent_codinglanguage_mapper/workspace.py +114 -0
  37. agent_codinglanguage_mapper-1.0.0.dist-info/METADATA +383 -0
  38. agent_codinglanguage_mapper-1.0.0.dist-info/RECORD +42 -0
  39. agent_codinglanguage_mapper-1.0.0.dist-info/WHEEL +5 -0
  40. agent_codinglanguage_mapper-1.0.0.dist-info/entry_points.txt +2 -0
  41. agent_codinglanguage_mapper-1.0.0.dist-info/licenses/LICENSE +373 -0
  42. agent_codinglanguage_mapper-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1328 @@
1
+ from __future__ import annotations
2
+
3
+ import configparser
4
+ import hashlib
5
+ import json
6
+ import os
7
+ import re
8
+ import shlex
9
+ import sys
10
+ import xml.etree.ElementTree as element_tree
11
+ from dataclasses import dataclass, replace
12
+ from pathlib import Path
13
+ from typing import Iterable, Iterator
14
+
15
+ import pathspec
16
+
17
+ if sys.version_info >= (3, 11):
18
+ import tomllib
19
+ else: # pragma: no cover - Python 3.10 dependency fallback
20
+ import tomli as tomllib
21
+
22
+ from .adapters import adapters_for
23
+ from .models import Language, Problem, Project
24
+ from .workspace import stable_id
25
+
26
+ _IGNORED = {
27
+ ".git", ".hg", ".mypy_cache", ".pytest_cache", ".svn", ".tox", "__pycache__", "build", "dist", "node_modules",
28
+ "target", ".venv", "venv",
29
+ }
30
+ _MARKERS = {
31
+ "pyproject.toml": (Language.PYTHON, "python"), "setup.cfg": (Language.PYTHON, "python"),
32
+ "setup.py": (Language.PYTHON, "python"), "Cargo.toml": (Language.RUST, "rust"),
33
+ ".sln": (Language.CSHARP, "csharp-solution"), ".csproj": (Language.CSHARP, "csharp"),
34
+ ".dpr": (Language.DELPHI, "delphi"), ".dpk": (Language.DELPHI, "delphi-package"),
35
+ ".dproj": (Language.DELPHI, "delphi"),
36
+ ".groupproj": (Language.DELPHI, "delphi-group"), "compile_commands.json": (Language.CPP, "compile-commands"),
37
+ "CMakeLists.txt": (Language.CPP, "cmake"), ".vcxproj": (Language.CPP, "visual-cpp"),
38
+ }
39
+ _VARIABLE = re.compile(
40
+ r"\$\([^)]*\)|\$\{[^}]*\}|\$ENV\{[^}]*\}|\$(?![({<])[A-Za-z_][A-Za-z0-9_]*|%\([^)]*\)|%[^%]+%"
41
+ )
42
+ _GENERATOR_EXPRESSION = re.compile(r"\$<[^>]*>")
43
+ _CMAKE_CALL = re.compile(r"\b([A-Za-z_][A-Za-z0-9_]*)\s*\(")
44
+ _CMAKE_INCLUDE_MARKERS = {"AFTER", "BEFORE", "INTERFACE", "PRIVATE", "PUBLIC", "SYSTEM"}
45
+ _CMAKE_BRACKET_COMMENT_START = re.compile(r"#\[(?P<equals>=*)\[")
46
+ _CMAKE_ESCAPED_SEMICOLON = "\0"
47
+ _CMAKE_LIBRARY_KINDS = {"SHARED", "MODULE"}
48
+ _CMAKE_TARGET_MARKERS = {
49
+ "ALIAS", "EXCLUDE_FROM_ALL", "GLOBAL", "IMPORTED", "INTERFACE", "MODULE", "OBJECT", "SHARED", "STATIC", "UNKNOWN",
50
+ }
51
+ _DELPHI_IN = re.compile(r"\b(?:in|include)\s*['\"]([^'\"]+)['\"]", re.IGNORECASE)
52
+ _SOLUTION_PROJECT = re.compile(
53
+ r'^Project\("[^"]*"\)\s*=\s*"[^"]*",\s*"([^"]+)"',
54
+ re.MULTILINE,
55
+ )
56
+ _NAMED_PROJECT_SUFFIXES = {".csproj", ".dpk", ".dpr", ".dproj", ".groupproj", ".sln", ".vcxproj"}
57
+ _PROJECT_OWNERSHIP_PRIORITY = {
58
+ ".csproj": 3,
59
+ ".dpr": 3,
60
+ ".dpk": 3,
61
+ ".vcxproj": 3,
62
+ ".dproj": 2,
63
+ ".groupproj": 1,
64
+ ".sln": 1,
65
+ }
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class DiscoveredFile:
70
+ path: Path
71
+ language: Language
72
+ project_id: str
73
+ include_paths: tuple[Path, ...] = ()
74
+ output_names: tuple[str, ...] = ()
75
+
76
+ @property
77
+ def name(self) -> str:
78
+ return self.path.name
79
+
80
+
81
+ @dataclass(frozen=True)
82
+ class DiscoveryResult:
83
+ projects: tuple[Project, ...]
84
+ files: tuple[DiscoveredFile, ...]
85
+ problems: tuple[Problem, ...] = ()
86
+
87
+
88
+ @dataclass(frozen=True)
89
+ class _Definition:
90
+ root: Path
91
+ language: Language
92
+ kind: str
93
+ metadata_path: Path
94
+ source_roots: tuple[Path, ...] = ()
95
+ source_files: tuple[Path, ...] = ()
96
+ include_paths: tuple[Path, ...] = ()
97
+ translation_unit_include_paths: tuple[tuple[Path, tuple[Path, ...]], ...] = ()
98
+ translation_unit_output_names: tuple[tuple[Path, tuple[str, ...]], ...] = ()
99
+
100
+
101
+ @dataclass(frozen=True)
102
+ class _MetadataPath:
103
+ value: str
104
+ base: Path
105
+
106
+
107
+ @dataclass(frozen=True)
108
+ class _CompileCommand:
109
+ source_file: str
110
+ include_paths: tuple[_MetadataPath, ...]
111
+
112
+
113
+ @dataclass
114
+ class _CMakeConditionalFrame:
115
+ parent_active: bool
116
+ parent_possible: bool
117
+ branch_taken: bool
118
+ uncertain: bool
119
+ active: bool
120
+ possible: bool
121
+
122
+
123
+ def discover_workspace(root: str | Path, languages: Iterable[Language] | None = None) -> DiscoveryResult:
124
+ workspace = Path(root).expanduser().resolve()
125
+ allowed = set(languages) if languages is not None else set(Language)
126
+ paths = tuple(_walk(workspace))
127
+ definitions, problems = _definitions(workspace, paths)
128
+ projects: dict[str, Project] = {}
129
+ for definition in definitions:
130
+ if (
131
+ definition.language in allowed
132
+ and definition.metadata_path.suffix.casefold() in _NAMED_PROJECT_SUFFIXES
133
+ ):
134
+ project = _project_from_definition(definition, definitions)
135
+ projects[project.project_id] = _merge_project(projects.get(project.project_id), project)
136
+ files: list[DiscoveredFile] = []
137
+ for path in paths:
138
+ marker = _MARKERS.get(path.name) or _MARKERS.get(path.suffix.casefold())
139
+ if marker and path.suffix.casefold() not in {".dpk", ".dpr"}:
140
+ continue
141
+ detected = [language for language in _languages_for(path) if language in allowed]
142
+ if not detected:
143
+ continue
144
+ if _excluded_by_layout(path, definitions, detected[0]):
145
+ continue
146
+ project, ownership_problem = _owner(path, definitions, detected[0], workspace)
147
+ if ownership_problem is not None:
148
+ problems.append(ownership_problem)
149
+ project = _merge_project(projects.get(project.project_id), project)
150
+ projects[project.project_id] = project
151
+ files.append(
152
+ DiscoveredFile(
153
+ path,
154
+ detected[0],
155
+ project.project_id,
156
+ _source_include_paths(path, definitions, detected[0], project),
157
+ _source_output_names(path, definitions, detected[0], project),
158
+ )
159
+ )
160
+ return DiscoveryResult(
161
+ tuple(sorted(projects.values(), key=lambda item: (item.root, item.name, item.project_id))),
162
+ tuple(files),
163
+ tuple(problems),
164
+ )
165
+
166
+
167
+ def workspace_inventory(root: str | Path) -> tuple[tuple[str, int, int, str], ...]:
168
+ workspace = Path(root).expanduser().resolve()
169
+ tracked: list[tuple[str, int, int, str]] = []
170
+ for path in _walk(workspace):
171
+ relative = path.relative_to(workspace).as_posix()
172
+ if path.name != ".gitignore" and not (
173
+ _MARKERS.get(path.name) or _MARKERS.get(path.suffix.casefold()) or _languages_for(path)
174
+ ):
175
+ continue
176
+ try:
177
+ stat = path.stat()
178
+ digest = (
179
+ ""
180
+ if _languages_for(path)
181
+ else hashlib.sha256(path.read_bytes()).hexdigest()
182
+ )
183
+ except OSError:
184
+ continue
185
+ tracked.append((relative, stat.st_mtime_ns, stat.st_size, digest))
186
+ return tuple(sorted(tracked))
187
+
188
+
189
+ def workspace_directory_inventory(root: str | Path) -> tuple[tuple[str, int], ...]:
190
+ """Capture included directory mtimes without following directory symlinks."""
191
+ workspace = Path(root).expanduser().resolve()
192
+ tracked: list[tuple[str, int]] = []
193
+ pending: list[tuple[Path, tuple[tuple[Path, pathspec.GitIgnoreSpec], ...]]] = [
194
+ (workspace, ())
195
+ ]
196
+ while pending:
197
+ directory, inherited_rules = pending.pop()
198
+ try:
199
+ stat = directory.stat()
200
+ entries = sorted(directory.iterdir(), reverse=True)
201
+ except OSError:
202
+ continue
203
+ relative = directory.relative_to(workspace).as_posix()
204
+ tracked.append((relative or ".", stat.st_mtime_ns))
205
+ local_ignore = _gitignore(directory)
206
+ rules = inherited_rules + (((directory, local_ignore),) if local_ignore is not None else ())
207
+ for path in entries:
208
+ path_relative = path.relative_to(workspace)
209
+ if any(part in _IGNORED for part in path_relative.parts):
210
+ continue
211
+ if (
212
+ path.is_dir()
213
+ and not path.is_symlink()
214
+ and not _ignored_by_rules(path, rules, directory=True)
215
+ ):
216
+ pending.append((path, rules))
217
+ return tuple(sorted(tracked))
218
+
219
+
220
+ def _walk(root: Path) -> Iterator[Path]:
221
+ """Yield included regular files without following directory symlinks."""
222
+ pending: list[tuple[Path, tuple[tuple[Path, pathspec.GitIgnoreSpec], ...]]] = [(root, ())]
223
+ while pending:
224
+ directory, inherited_rules = pending.pop()
225
+ try:
226
+ local_ignore = _gitignore(directory)
227
+ entries = sorted(directory.iterdir(), reverse=True)
228
+ except OSError:
229
+ continue
230
+ rules = inherited_rules + (((directory, local_ignore),) if local_ignore is not None else ())
231
+ for path in entries:
232
+ relative = path.relative_to(root)
233
+ if any(part in _IGNORED for part in relative.parts):
234
+ continue
235
+ if path.is_dir():
236
+ if not path.is_symlink() and not _ignored_by_rules(path, rules, directory=True):
237
+ pending.append((path, rules))
238
+ continue
239
+ if _included(root, path, rules):
240
+ yield path
241
+
242
+
243
+ def _included(
244
+ root: Path,
245
+ path: Path,
246
+ rules: tuple[tuple[Path, pathspec.GitIgnoreSpec], ...],
247
+ ) -> bool:
248
+ if not path.is_file():
249
+ return False
250
+ try:
251
+ path.resolve().relative_to(root)
252
+ except ValueError:
253
+ return False
254
+ if path.name == ".gitignore":
255
+ return True
256
+ return not _ignored_by_rules(path, rules)
257
+
258
+
259
+ def _ignored_by_rules(
260
+ path: Path,
261
+ rules: tuple[tuple[Path, pathspec.GitIgnoreSpec], ...],
262
+ *,
263
+ directory: bool = False,
264
+ ) -> bool:
265
+ ignored = False
266
+ for base, rule in rules:
267
+ relative = path.relative_to(base).as_posix()
268
+ if directory:
269
+ relative += "/"
270
+ decision = rule.check_file(relative).include
271
+ if decision is not None:
272
+ ignored = decision
273
+ return ignored
274
+
275
+
276
+ def _gitignore(root: Path) -> pathspec.GitIgnoreSpec | None:
277
+ path = root / ".gitignore"
278
+ return pathspec.GitIgnoreSpec.from_lines(path.read_text(errors="replace").splitlines()) if path.is_file() else None
279
+
280
+
281
+ def _definitions(root: Path, paths: tuple[Path, ...]) -> tuple[list[_Definition], list[Problem]]:
282
+ definitions: list[_Definition] = []
283
+ problems: list[Problem] = []
284
+ for path in paths:
285
+ marker = _MARKERS.get(path.name) or _MARKERS.get(path.suffix.casefold())
286
+ if marker is None:
287
+ continue
288
+ language, kind = marker
289
+ definition, reported = _parse_definition(root, path, language, kind)
290
+ definitions.append(definition)
291
+ problems.extend(reported)
292
+ return definitions, problems
293
+
294
+
295
+ def _parse_definition(workspace: Path, path: Path, language: Language, kind: str) -> tuple[_Definition, list[Problem]]:
296
+ root = path.parent
297
+ roots: list[str] = []
298
+ source_files: list[str] = []
299
+ include_paths: list[_MetadataPath] = []
300
+ compile_commands: list[_CompileCommand] = []
301
+ output_mappings: list[tuple[str, str]] = []
302
+ metadata_problems: list[Problem] = []
303
+ if path.name == "pyproject.toml":
304
+ roots = _pyproject_roots(path)
305
+ elif path.name == "setup.cfg":
306
+ roots = _setup_cfg_roots(path)
307
+ elif path.name == "Cargo.toml":
308
+ roots, source_files, output_mappings = _cargo_sources(path)
309
+ elif path.suffix.casefold() == ".sln":
310
+ source_files, metadata_problems = _solution_sources(workspace, path)
311
+ elif path.suffix.casefold() in {".csproj", ".vcxproj"}:
312
+ source_files = _xml_includes(path, {"Compile", "ClCompile", "ClInclude"})
313
+ if path.suffix.casefold() == ".vcxproj":
314
+ include_paths = _vcxproj_include_paths(path)
315
+ elif path.suffix.casefold() in {".dpk", ".dpr", ".dproj", ".groupproj"}:
316
+ roots, source_files = _delphi_sources(path)
317
+ if path.suffix.casefold() in {".dpk", ".dpr"}:
318
+ source_files.insert(0, path.name)
319
+ elif path.name == "compile_commands.json":
320
+ compile_commands = _compile_commands(path)
321
+ elif path.name == "CMakeLists.txt":
322
+ source_files, include_paths, output_mappings, uncertain_conditions = _cmake_metadata(path)
323
+ metadata_problems.extend(
324
+ _cmake_condition_problem(condition, path) for condition in uncertain_conditions
325
+ )
326
+ definition_roots, root_problems = _resolve_roots(workspace, root, language, path, roots)
327
+ translation_unit_includes: list[tuple[Path, tuple[Path, ...]]] = []
328
+ if compile_commands:
329
+ definition_files, translation_unit_includes, compile_problems = _resolve_compile_commands(
330
+ workspace,
331
+ language,
332
+ path,
333
+ compile_commands,
334
+ )
335
+ definition_includes: list[Path] = []
336
+ file_problems = compile_problems
337
+ include_problems: list[Problem] = []
338
+ else:
339
+ definition_files, file_problems = _resolve_files(workspace, root, language, path, source_files)
340
+ definition_includes, include_problems = _resolve_include_paths(workspace, language, path, include_paths)
341
+ output_names_by_file: dict[Path, list[str]] = {}
342
+ for output_name, source_file in output_mappings:
343
+ if _has_unresolved_static_value(output_name):
344
+ metadata_problems.append(_output_problem(output_name, language, path))
345
+ continue
346
+ expanded = _expand_project_variables(source_file, root)
347
+ if _has_unresolved_static_value(expanded):
348
+ continue
349
+ candidate = Path(expanded)
350
+ candidate = (candidate if candidate.is_absolute() else root / candidate).resolve()
351
+ if candidate not in definition_files and not any(
352
+ candidate.is_relative_to(source_root) for source_root in definition_roots
353
+ ):
354
+ continue
355
+ names = output_names_by_file.setdefault(candidate, [])
356
+ if output_name not in names:
357
+ names.append(output_name)
358
+ return (
359
+ _Definition(
360
+ root,
361
+ language,
362
+ kind,
363
+ path,
364
+ tuple(definition_roots),
365
+ tuple(definition_files),
366
+ tuple(definition_includes),
367
+ tuple(translation_unit_includes),
368
+ tuple((source_file, tuple(names)) for source_file, names in output_names_by_file.items()),
369
+ ),
370
+ root_problems + file_problems + include_problems + metadata_problems,
371
+ )
372
+
373
+
374
+ def _pyproject_roots(path: Path) -> list[str]:
375
+ try:
376
+ data = tomllib.loads(path.read_text(errors="replace"))
377
+ except (OSError, ValueError):
378
+ return []
379
+ tool = data.get("tool", {})
380
+ setuptools = tool.get("setuptools", {}) if isinstance(tool, dict) else {}
381
+ result: list[str] = []
382
+ package_dir = setuptools.get("package-dir", {}) if isinstance(setuptools, dict) else {}
383
+ if isinstance(package_dir, dict):
384
+ result.extend(value for value in package_dir.values() if isinstance(value, str))
385
+ packages = setuptools.get("packages", {}) if isinstance(setuptools, dict) else {}
386
+ find = packages.get("find", {}) if isinstance(packages, dict) else {}
387
+ if isinstance(find, dict):
388
+ result.extend(value for value in find.get("where", []) if isinstance(value, str))
389
+ poetry = tool.get("poetry", {}) if isinstance(tool, dict) else {}
390
+ if isinstance(poetry, dict):
391
+ result.extend(item["from"] for item in poetry.get("packages", []) if isinstance(item, dict) and isinstance(item.get("from"), str))
392
+ return result
393
+
394
+
395
+ def _setup_cfg_roots(path: Path) -> list[str]:
396
+ parser = configparser.ConfigParser()
397
+ try:
398
+ parser.read_string(path.read_text(errors="replace"))
399
+ except (configparser.Error, OSError):
400
+ return []
401
+ result: list[str] = []
402
+ if parser.has_option("options", "package_dir"):
403
+ result.extend(line.split("=", 1)[1].strip() for line in parser.get("options", "package_dir").splitlines() if "=" in line)
404
+ if parser.has_option("options.packages.find", "where"):
405
+ result.extend(value.strip() for value in parser.get("options.packages.find", "where").splitlines() if value.strip())
406
+ return result
407
+
408
+
409
+ def _cargo_sources(path: Path) -> tuple[list[str], list[str], list[tuple[str, str]]]:
410
+ try:
411
+ data = tomllib.loads(path.read_text(errors="replace"))
412
+ except (OSError, ValueError):
413
+ return [], [], []
414
+ package = data.get("package")
415
+ if not isinstance(package, dict):
416
+ return [], [], []
417
+ files: list[str] = []
418
+ output_mappings: list[tuple[str, str]] = []
419
+ lib = data.get("lib", {})
420
+ if isinstance(lib, dict):
421
+ library_path = lib.get("path")
422
+ if isinstance(library_path, str):
423
+ files.append(library_path)
424
+ else:
425
+ library_path = "src/lib.rs"
426
+ crate_types = lib.get("crate-type", [])
427
+ if isinstance(crate_types, str):
428
+ crate_types = [crate_types]
429
+ if (
430
+ isinstance(crate_types, list)
431
+ and any(item in {"cdylib", "dylib"} for item in crate_types)
432
+ and isinstance(package.get("name"), str)
433
+ ):
434
+ library_name = lib.get("name", package["name"])
435
+ if isinstance(library_name, str):
436
+ output_mappings.append((library_name.replace("-", "_"), library_path))
437
+ bins = data.get("bin", [])
438
+ if isinstance(bins, list):
439
+ files.extend(
440
+ item["path"]
441
+ for item in bins
442
+ if isinstance(item, dict) and isinstance(item.get("path"), str)
443
+ )
444
+ sources = ["src"] if (path.parent / "src").is_dir() or not files else []
445
+ return sources, files, output_mappings
446
+
447
+
448
+ def _solution_sources(workspace: Path, path: Path) -> tuple[list[str], list[Problem]]:
449
+ try:
450
+ project_files = _SOLUTION_PROJECT.findall(path.read_text(errors="replace"))
451
+ except OSError:
452
+ return [], []
453
+ sources: list[str] = []
454
+ problems: list[Problem] = []
455
+ for project_file in project_files:
456
+ expanded = _expand_project_variables(project_file, path.parent)
457
+ if _has_unresolved_static_value(expanded):
458
+ problems.append(_project_file_problem("unresolved-project-file", project_file, path))
459
+ continue
460
+ candidate = Path(expanded)
461
+ candidate = candidate if candidate.is_absolute() else path.parent / candidate
462
+ candidate = candidate.resolve()
463
+ try:
464
+ candidate.relative_to(workspace)
465
+ except ValueError:
466
+ problems.append(_project_file_problem("workspace-escaping-project-file", project_file, path))
467
+ continue
468
+ if candidate.suffix.casefold() != ".csproj":
469
+ continue
470
+ if not candidate.is_file():
471
+ problems.append(_project_file_problem("missing-project-file", project_file, path))
472
+ continue
473
+ for source_file in _xml_includes(candidate, {"Compile"}):
474
+ expanded_source = _expand_project_variables(source_file, candidate.parent)
475
+ if _has_unresolved_static_value(expanded_source):
476
+ problems.append(_problem("unresolved-source-file", source_file, Language.CSHARP, candidate))
477
+ continue
478
+ source_path = Path(expanded_source)
479
+ source_path = source_path if source_path.is_absolute() else candidate.parent / source_path
480
+ normalized = str(source_path.resolve())
481
+ if normalized not in sources:
482
+ sources.append(normalized)
483
+ return sources, problems
484
+
485
+
486
+ def _xml_includes(path: Path, names: set[str]) -> list[str]:
487
+ try:
488
+ tree = element_tree.parse(path)
489
+ except (OSError, element_tree.ParseError):
490
+ return []
491
+ return [element.attrib["Include"] for element in tree.iter() if element.tag.split("}")[-1] in names and "Include" in element.attrib]
492
+
493
+
494
+ def _vcxproj_include_paths(path: Path) -> list[_MetadataPath]:
495
+ try:
496
+ tree = element_tree.parse(path)
497
+ except (OSError, element_tree.ParseError):
498
+ return []
499
+ result: list[_MetadataPath] = []
500
+ for element in tree.iter():
501
+ if element.tag.split("}")[-1] != "AdditionalIncludeDirectories" or not element.text:
502
+ continue
503
+ result.extend(
504
+ _MetadataPath(value.strip(), path.parent)
505
+ for value in element.text.split(";")
506
+ if value.strip()
507
+ )
508
+ return result
509
+
510
+
511
+ def _delphi_sources(path: Path) -> tuple[list[str], list[str]]:
512
+ text = path.read_text(errors="replace")
513
+ source_files = (
514
+ _delphi_project_sources(text)
515
+ if path.suffix.casefold() in {".dpk", ".dpr"}
516
+ else _DELPHI_IN.findall(text)
517
+ )
518
+ roots: list[str] = []
519
+ if path.suffix.casefold() in {".dproj", ".groupproj"}:
520
+ try:
521
+ tree = element_tree.fromstring(text)
522
+ except element_tree.ParseError:
523
+ return roots, source_files
524
+ for element in tree.iter():
525
+ if element.tag.split("}")[-1] in {"DCC_UnitSearchPath", "DCC_IncludePath", "DCC_ObjPath", "DCC_SourcePath"} and element.text:
526
+ roots.extend(part.strip() for part in element.text.split(";") if part.strip())
527
+ return roots, source_files
528
+
529
+
530
+ def _delphi_project_sources(text: str) -> list[str]:
531
+ sources: list[str] = []
532
+ position = 0
533
+ while position < len(text):
534
+ if text.startswith("//", position):
535
+ newline = text.find("\n", position + 2)
536
+ position = len(text) if newline < 0 else newline + 1
537
+ continue
538
+ if text.startswith("(*", position):
539
+ closing = text.find("*)", position + 2)
540
+ position = len(text) if closing < 0 else closing + 2
541
+ continue
542
+ if text[position] == "{":
543
+ closing = text.find("}", position + 1)
544
+ position = len(text) if closing < 0 else closing + 1
545
+ continue
546
+ if text[position] in {"'", '"'}:
547
+ _, position = _delphi_quoted_value(text, position)
548
+ continue
549
+ if text[position].isalpha() or text[position] == "_":
550
+ end = position + 1
551
+ while end < len(text) and (text[end].isalnum() or text[end] == "_"):
552
+ end += 1
553
+ keyword = text[position:end].casefold()
554
+ value_start = end
555
+ while value_start < len(text) and text[value_start].isspace():
556
+ value_start += 1
557
+ if keyword in {"in", "include"} and value_start < len(text) and text[value_start] in {"'", '"'}:
558
+ value, position = _delphi_quoted_value(text, value_start)
559
+ if value:
560
+ sources.append(value)
561
+ continue
562
+ position = end
563
+ continue
564
+ position += 1
565
+ return sources
566
+
567
+
568
+ def _delphi_quoted_value(text: str, position: int) -> tuple[str, int]:
569
+ quote = text[position]
570
+ value: list[str] = []
571
+ position += 1
572
+ while position < len(text):
573
+ character = text[position]
574
+ if character != quote:
575
+ value.append(character)
576
+ position += 1
577
+ continue
578
+ if position + 1 < len(text) and text[position + 1] == quote:
579
+ value.append(quote)
580
+ position += 2
581
+ continue
582
+ return "".join(value), position + 1
583
+ return "".join(value), position
584
+
585
+
586
+ def _compile_commands(path: Path) -> list[_CompileCommand]:
587
+ try:
588
+ data = json.loads(path.read_text(errors="replace"))
589
+ except (OSError, ValueError):
590
+ return []
591
+ if not isinstance(data, list):
592
+ return []
593
+ commands: list[_CompileCommand] = []
594
+ for item in data:
595
+ if not isinstance(item, dict):
596
+ continue
597
+ directory_value = item.get("directory", str(path.parent))
598
+ directory = Path(directory_value) if isinstance(directory_value, str) else path.parent
599
+ directory = directory if directory.is_absolute() else path.parent / directory
600
+ source_file = item.get("file")
601
+ if not isinstance(source_file, str):
602
+ continue
603
+ source_path = Path(source_file)
604
+ source_path = source_path if source_path.is_absolute() else directory / source_path
605
+ commands.append(
606
+ _CompileCommand(
607
+ str(source_path),
608
+ tuple(_MetadataPath(value, directory) for value in _compile_include_paths(item)),
609
+ )
610
+ )
611
+ return commands
612
+
613
+
614
+ def _compile_include_paths(
615
+ item: dict[str, object],
616
+ *,
617
+ windows: bool | None = None,
618
+ ) -> list[str]:
619
+ raw_arguments = item.get("arguments")
620
+ if isinstance(raw_arguments, list) and all(isinstance(value, str) for value in raw_arguments):
621
+ arguments = list(raw_arguments)
622
+ else:
623
+ command = item.get("command")
624
+ if not isinstance(command, str):
625
+ return []
626
+ try:
627
+ arguments = _split_command(command, windows=windows)
628
+ except ValueError:
629
+ return []
630
+ result: list[str] = []
631
+ index = 0
632
+ while index < len(arguments):
633
+ argument = _unquote_argument(arguments[index])
634
+ if argument in {"-I", "-isystem", "/I"}:
635
+ index += 1
636
+ if index < len(arguments):
637
+ value = _unquote_argument(arguments[index])
638
+ if value:
639
+ result.append(value)
640
+ elif argument.startswith("-isystem") and len(argument) > len("-isystem"):
641
+ value = _unquote_argument(argument[len("-isystem"):])
642
+ if value:
643
+ result.append(value)
644
+ elif argument.startswith("-I") and len(argument) > 2:
645
+ value = _unquote_argument(argument[2:])
646
+ if value:
647
+ result.append(value)
648
+ elif argument.startswith("/I") and len(argument) > 2:
649
+ value = _unquote_argument(argument[2:])
650
+ if value:
651
+ result.append(value)
652
+ index += 1
653
+ return result
654
+
655
+
656
+ def _split_command(command: str, *, windows: bool | None = None) -> list[str]:
657
+ use_windows_rules = os.name == "nt" if windows is None else windows
658
+ arguments = shlex.split(command, posix=not use_windows_rules)
659
+ if not use_windows_rules:
660
+ return arguments
661
+ result: list[str] = []
662
+ pending: list[str] = []
663
+ quoted = False
664
+ for argument in arguments:
665
+ pending.append(argument)
666
+ if argument.count('"') % 2:
667
+ quoted = not quoted
668
+ if not quoted:
669
+ result.append(" ".join(pending))
670
+ pending = []
671
+ result.extend(pending)
672
+ return result
673
+
674
+
675
+ def _unquote_argument(value: str) -> str:
676
+ value = value.strip()
677
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}:
678
+ return value[1:-1]
679
+ return value
680
+
681
+
682
+ def _cmake_metadata(
683
+ path: Path,
684
+ ) -> tuple[list[str], list[_MetadataPath], list[tuple[str, str]], list[str]]:
685
+ sources: list[str] = []
686
+ include_paths: list[_MetadataPath] = []
687
+ loadable_targets: list[tuple[str, list[str]]] = []
688
+ output_names: dict[str, str] = {}
689
+ uncertain_conditions: list[str] = []
690
+ conditional_frames: list[_CMakeConditionalFrame] = []
691
+ opaque_blocks: list[str] = []
692
+ opaque_openers = {
693
+ "foreach": "endforeach",
694
+ "function": "endfunction",
695
+ "macro": "endmacro",
696
+ "while": "endwhile",
697
+ }
698
+ for name, arguments in _cmake_calls(path.read_text(errors="replace")):
699
+ try:
700
+ tokens = shlex.split(
701
+ arguments.replace(r"\;", _CMAKE_ESCAPED_SEMICOLON),
702
+ comments=False,
703
+ posix=True,
704
+ )
705
+ except ValueError:
706
+ continue
707
+ if opaque_blocks:
708
+ if name in opaque_openers:
709
+ opaque_blocks.append(opaque_openers[name])
710
+ elif name == opaque_blocks[-1]:
711
+ opaque_blocks.pop()
712
+ continue
713
+ if name in opaque_openers:
714
+ opaque_blocks.append(opaque_openers[name])
715
+ continue
716
+ if name == "if":
717
+ parent_active = all(frame.active for frame in conditional_frames)
718
+ parent_possible = all(frame.possible for frame in conditional_frames)
719
+ condition = _cmake_static_condition(tokens)
720
+ if parent_possible and condition is None and arguments.strip() not in uncertain_conditions:
721
+ uncertain_conditions.append(arguments.strip())
722
+ conditional_frames.append(
723
+ _CMakeConditionalFrame(
724
+ parent_active,
725
+ parent_possible,
726
+ condition is True,
727
+ condition is None,
728
+ parent_active and condition is True,
729
+ parent_possible and condition is not False,
730
+ )
731
+ )
732
+ continue
733
+ if name == "elseif":
734
+ if conditional_frames:
735
+ frame = conditional_frames[-1]
736
+ condition = _cmake_static_condition(tokens)
737
+ if (
738
+ frame.parent_possible
739
+ and not frame.branch_taken
740
+ and condition is None
741
+ and arguments.strip() not in uncertain_conditions
742
+ ):
743
+ uncertain_conditions.append(arguments.strip())
744
+ frame.active = (
745
+ frame.parent_active
746
+ and not frame.branch_taken
747
+ and not frame.uncertain
748
+ and condition is True
749
+ )
750
+ frame.possible = (
751
+ frame.parent_possible
752
+ and not frame.branch_taken
753
+ and condition is not False
754
+ )
755
+ frame.branch_taken = frame.branch_taken or condition is True
756
+ frame.uncertain = frame.uncertain or condition is None
757
+ continue
758
+ if name == "else":
759
+ if conditional_frames:
760
+ frame = conditional_frames[-1]
761
+ frame.active = frame.parent_active and not frame.branch_taken and not frame.uncertain
762
+ frame.possible = frame.parent_possible and not frame.branch_taken
763
+ frame.branch_taken = True
764
+ continue
765
+ if name == "endif":
766
+ if conditional_frames:
767
+ conditional_frames.pop()
768
+ continue
769
+ possible = all(frame.possible for frame in conditional_frames)
770
+ if not possible:
771
+ continue
772
+ active = all(frame.active for frame in conditional_frames)
773
+ if name in {"add_executable", "add_library"}:
774
+ output_name = tokens[0].replace(_CMAKE_ESCAPED_SEMICOLON, ";") if tokens else ""
775
+ target_kind = next(
776
+ (
777
+ item.upper()
778
+ for item in tokens[1:]
779
+ if item.upper() in _CMAKE_TARGET_MARKERS
780
+ ),
781
+ "",
782
+ )
783
+ target_sources = [
784
+ item
785
+ for item in _cmake_list_items(tokens[1:])
786
+ if item.upper() not in _CMAKE_TARGET_MARKERS
787
+ and (
788
+ _has_unresolved_static_value(item)
789
+ or Path(item).suffix.casefold()
790
+ in {".c", ".cc", ".cpp", ".cxx", ".h", ".hpp"}
791
+ )
792
+ ]
793
+ sources.extend(target_sources)
794
+ if active and name == "add_library" and target_kind in _CMAKE_LIBRARY_KINDS:
795
+ loadable_targets.append((output_name, target_sources))
796
+ elif name == "set_target_properties":
797
+ properties_index = next(
798
+ (index for index, item in enumerate(tokens) if item.upper() == "PROPERTIES"),
799
+ -1,
800
+ )
801
+ if properties_index > 0:
802
+ targets = tokens[:properties_index]
803
+ properties = tokens[properties_index + 1 :]
804
+ for index in range(0, len(properties) - 1, 2):
805
+ if properties[index].upper() == "OUTPUT_NAME":
806
+ for target in targets:
807
+ output_names[target] = (
808
+ properties[index + 1]
809
+ if active
810
+ else "${OUTPUT_NAME_DEPENDS_ON_CMAKE_CONDITION}"
811
+ )
812
+ elif name == "set_property":
813
+ property_index = next(
814
+ (index for index, item in enumerate(tokens) if item.upper() == "PROPERTY"),
815
+ -1,
816
+ )
817
+ if (
818
+ tokens
819
+ and tokens[0].upper() == "TARGET"
820
+ and property_index > 1
821
+ and len(tokens) > property_index + 2
822
+ and tokens[property_index + 1].upper() == "OUTPUT_NAME"
823
+ ):
824
+ targets = [
825
+ target
826
+ for target in tokens[1:property_index]
827
+ if target.upper() not in {"APPEND", "APPEND_STRING"}
828
+ ]
829
+ modifiers = {item.upper() for item in tokens[1:property_index]}
830
+ values = tokens[property_index + 2 :]
831
+ for target in targets:
832
+ if not active:
833
+ output_names[target] = "${OUTPUT_NAME_DEPENDS_ON_CMAKE_CONDITION}"
834
+ elif "APPEND_STRING" in modifiers:
835
+ output_names[target] = output_names.get(target, "") + "".join(values)
836
+ elif "APPEND" in modifiers:
837
+ output_names[target] = "${OUTPUT_NAME_APPEND_REQUIRES_CMAKE}"
838
+ else:
839
+ output_names[target] = values[0]
840
+ elif name == "include_directories":
841
+ if not active:
842
+ continue
843
+ include_paths.extend(
844
+ _MetadataPath(item, path.parent)
845
+ for item in _cmake_list_items(tokens)
846
+ if item not in _CMAKE_INCLUDE_MARKERS
847
+ )
848
+ elif name == "target_include_directories":
849
+ if not active:
850
+ continue
851
+ include_paths.extend(
852
+ _MetadataPath(item, path.parent)
853
+ for item in _cmake_list_items(tokens[1:])
854
+ if item not in _CMAKE_INCLUDE_MARKERS
855
+ )
856
+ output_mappings = [
857
+ (output_names.get(target, target), source)
858
+ for target, target_sources in loadable_targets
859
+ if target
860
+ for source in target_sources
861
+ ]
862
+ return sources, include_paths, output_mappings, uncertain_conditions
863
+
864
+
865
+ def _cmake_static_condition(tokens: list[str]) -> bool | None:
866
+ if len(tokens) == 2 and tokens[0].upper() == "NOT":
867
+ nested = _cmake_static_condition(tokens[1:])
868
+ return None if nested is None else not nested
869
+ if len(tokens) != 1:
870
+ return None
871
+ value = tokens[0].strip().upper()
872
+ if not value or value in {"0", "OFF", "NO", "FALSE", "N", "IGNORE", "NOTFOUND"}:
873
+ return False
874
+ if value.endswith("-NOTFOUND"):
875
+ return False
876
+ if value in {"1", "ON", "YES", "TRUE", "Y"}:
877
+ return True
878
+ try:
879
+ return float(value) != 0
880
+ except ValueError:
881
+ return None
882
+
883
+
884
+ def _cmake_list_items(tokens: Iterable[str]) -> Iterator[str]:
885
+ for token in tokens:
886
+ yield from (
887
+ item.replace(_CMAKE_ESCAPED_SEMICOLON, ";")
888
+ for item in token.split(";")
889
+ if item
890
+ )
891
+
892
+
893
+ def _cmake_calls(text: str) -> Iterator[tuple[str, str]]:
894
+ text = _strip_cmake_comments(text)
895
+ position = 0
896
+ while match := _CMAKE_CALL.search(text, position):
897
+ depth = 1
898
+ quote = ""
899
+ escaped = False
900
+ cursor = match.end()
901
+ while cursor < len(text) and depth:
902
+ character = text[cursor]
903
+ if escaped:
904
+ escaped = False
905
+ elif character == "\\":
906
+ escaped = True
907
+ elif quote:
908
+ if character == quote:
909
+ quote = ""
910
+ elif character in {'"', "'"}:
911
+ quote = character
912
+ elif character == "(":
913
+ depth += 1
914
+ elif character == ")":
915
+ depth -= 1
916
+ cursor += 1
917
+ if depth:
918
+ return
919
+ yield match.group(1).casefold(), text[match.end():cursor - 1]
920
+ position = cursor
921
+
922
+
923
+ def _strip_cmake_comments(text: str) -> str:
924
+ result: list[str] = []
925
+ quote = ""
926
+ escaped = False
927
+ position = 0
928
+ while position < len(text):
929
+ character = text[position]
930
+ if escaped:
931
+ escaped = False
932
+ result.append(character)
933
+ elif character == "\\":
934
+ escaped = True
935
+ result.append(character)
936
+ elif quote:
937
+ if character == quote:
938
+ quote = ""
939
+ result.append(character)
940
+ elif character in {'"', "'"}:
941
+ quote = character
942
+ result.append(character)
943
+ elif character == "#":
944
+ bracket = _CMAKE_BRACKET_COMMENT_START.match(text, position)
945
+ if bracket is not None:
946
+ closing = f"]{bracket.group('equals')}]"
947
+ closing_start = text.find(closing, bracket.end())
948
+ comment_end = len(text) if closing_start < 0 else closing_start + len(closing)
949
+ result.extend("\n" * text[position:comment_end].count("\n"))
950
+ position = comment_end
951
+ continue
952
+ newline = text.find("\n", position)
953
+ position = len(text) if newline < 0 else newline
954
+ continue
955
+ else:
956
+ result.append(character)
957
+ position += 1
958
+ return "".join(result)
959
+
960
+
961
+ def _resolve_roots(workspace: Path, base: Path, language: Language, path: Path, roots: list[str]) -> tuple[list[Path], list[Problem]]:
962
+ resolved: list[Path] = []
963
+ problems: list[Problem] = []
964
+ for item in roots:
965
+ expanded = _expand_project_variables(item, base)
966
+ if _has_unresolved_static_value(expanded):
967
+ problems.append(_problem("unresolved-source-root", item, language, path))
968
+ continue
969
+ candidate = (base / expanded).resolve()
970
+ try:
971
+ candidate.relative_to(workspace)
972
+ except ValueError:
973
+ problems.append(_problem("workspace-escaping-source-root", item, language, path))
974
+ continue
975
+ if not candidate.is_dir():
976
+ problems.append(_problem("missing-source-root", item, language, path))
977
+ continue
978
+ resolved.append(candidate)
979
+ return resolved, problems
980
+
981
+
982
+ def _resolve_files(workspace: Path, base: Path, language: Language, path: Path, files: list[str]) -> tuple[list[Path], list[Problem]]:
983
+ resolved: list[Path] = []
984
+ problems: list[Problem] = []
985
+ for item in files:
986
+ expanded = _expand_project_variables(item, base)
987
+ if _has_unresolved_static_value(expanded):
988
+ problems.append(_problem("unresolved-source-file", item, language, path))
989
+ continue
990
+ candidate = Path(expanded)
991
+ candidate = candidate if candidate.is_absolute() else base / candidate
992
+ candidate = candidate.resolve()
993
+ try:
994
+ candidate.relative_to(workspace)
995
+ except ValueError:
996
+ problems.append(_problem("workspace-escaping-source-file", item, language, path))
997
+ continue
998
+ if not candidate.is_file():
999
+ problems.append(_problem("missing-source-file", item, language, path))
1000
+ continue
1001
+ resolved.append(candidate)
1002
+ return resolved, problems
1003
+
1004
+
1005
+ def _resolve_compile_commands(
1006
+ workspace: Path,
1007
+ language: Language,
1008
+ path: Path,
1009
+ commands: list[_CompileCommand],
1010
+ ) -> tuple[list[Path], list[tuple[Path, tuple[Path, ...]]], list[Problem]]:
1011
+ resolved_files: list[Path] = []
1012
+ translation_unit_includes: list[tuple[Path, tuple[Path, ...]]] = []
1013
+ problems: list[Problem] = []
1014
+ seen_files: set[Path] = set()
1015
+ mapped_files: set[Path] = set()
1016
+ reported_include_problems: set[tuple[str, str]] = set()
1017
+ for command in commands:
1018
+ command_files, file_problems = _resolve_files(
1019
+ workspace,
1020
+ path.parent,
1021
+ language,
1022
+ path,
1023
+ [command.source_file],
1024
+ )
1025
+ command_includes, include_problems = _resolve_include_paths(
1026
+ workspace,
1027
+ language,
1028
+ path,
1029
+ list(command.include_paths),
1030
+ reported_include_problems,
1031
+ )
1032
+ problems.extend(file_problems)
1033
+ problems.extend(include_problems)
1034
+ if not command_files:
1035
+ continue
1036
+ source_file = command_files[0]
1037
+ if source_file not in seen_files:
1038
+ seen_files.add(source_file)
1039
+ resolved_files.append(source_file)
1040
+ if source_file not in mapped_files:
1041
+ mapped_files.add(source_file)
1042
+ translation_unit_includes.append((source_file, tuple(command_includes)))
1043
+ return resolved_files, translation_unit_includes, problems
1044
+
1045
+
1046
+ def _resolve_include_paths(
1047
+ workspace: Path,
1048
+ language: Language,
1049
+ path: Path,
1050
+ include_paths: list[_MetadataPath],
1051
+ reported_problems: set[tuple[str, str]] | None = None,
1052
+ ) -> tuple[list[Path], list[Problem]]:
1053
+ resolved: list[Path] = []
1054
+ problems: list[Problem] = []
1055
+ seen: set[Path] = set()
1056
+ seen_problems = reported_problems if reported_problems is not None else set()
1057
+
1058
+ def report(code: str, identity: str, include_path: _MetadataPath) -> None:
1059
+ key = (code, identity)
1060
+ if key in seen_problems:
1061
+ return
1062
+ seen_problems.add(key)
1063
+ problems.append(_include_problem(code, include_path.value, language, path))
1064
+
1065
+ for include_path in include_paths:
1066
+ expanded = _expand_project_variables(include_path.value, include_path.base)
1067
+ expanded_base = _expand_project_variables(str(include_path.base), path.parent)
1068
+ if _has_unresolved_static_value(expanded) or _has_unresolved_static_value(expanded_base):
1069
+ report("unresolved-include-path", f"{expanded_base}\0{expanded}", include_path)
1070
+ continue
1071
+ candidate = Path(expanded)
1072
+ candidate = candidate if candidate.is_absolute() else Path(expanded_base) / candidate
1073
+ candidate = candidate.resolve()
1074
+ try:
1075
+ candidate.relative_to(workspace)
1076
+ except ValueError:
1077
+ report("workspace-escaping-include-path", str(candidate), include_path)
1078
+ continue
1079
+ if not candidate.is_dir():
1080
+ report("missing-include-path", str(candidate), include_path)
1081
+ continue
1082
+ if candidate not in seen:
1083
+ seen.add(candidate)
1084
+ resolved.append(candidate)
1085
+ return resolved, problems
1086
+
1087
+
1088
+ def _has_unresolved_static_value(value: str) -> bool:
1089
+ return _VARIABLE.search(value) is not None or _GENERATOR_EXPRESSION.search(value) is not None
1090
+
1091
+
1092
+ def _expand_project_variables(value: str, base: Path) -> str:
1093
+ directory = f"{base}/"
1094
+ replacements = {
1095
+ "PROJECTDIR": directory,
1096
+ "PROJECT_DIR": directory,
1097
+ "MSBUILDPROJECTDIRECTORY": directory,
1098
+ "MSBUILDTHISFILEDIRECTORY": directory,
1099
+ }
1100
+
1101
+ def replace_variable(match: re.Match[str]) -> str:
1102
+ token = match.group(0)
1103
+ if not token.startswith("$("):
1104
+ return token
1105
+ return replacements.get(token[2:-1].upper(), token)
1106
+
1107
+ return _VARIABLE.sub(replace_variable, value).replace("\\", "/")
1108
+
1109
+
1110
+ def _problem(code: str, item: str, language: Language, path: Path) -> Problem:
1111
+ return Problem(code, f"Could not resolve static metadata entry: {item}", language=language, metadata={"source_root": item, "metadata_path": str(path)})
1112
+
1113
+
1114
+ def _include_problem(code: str, item: str, language: Language, path: Path) -> Problem:
1115
+ return Problem(
1116
+ code,
1117
+ f"Could not resolve static include path: {item}",
1118
+ language=language,
1119
+ metadata={"include_path": item, "metadata_path": str(path)},
1120
+ )
1121
+
1122
+
1123
+ def _output_problem(item: str, language: Language, path: Path) -> Problem:
1124
+ return Problem(
1125
+ "unresolved-output-name",
1126
+ f"Could not resolve static output name: {item}",
1127
+ language=language,
1128
+ metadata={"output_name": item, "metadata_path": str(path)},
1129
+ )
1130
+
1131
+
1132
+ def _cmake_condition_problem(condition: str, path: Path) -> Problem:
1133
+ return Problem(
1134
+ "unresolved-cmake-condition",
1135
+ f"Could not statically resolve CMake condition: {condition}",
1136
+ language=Language.CPP,
1137
+ metadata={"condition": condition, "metadata_path": str(path)},
1138
+ )
1139
+
1140
+
1141
+ def _project_file_problem(code: str, item: str, path: Path) -> Problem:
1142
+ return Problem(
1143
+ code,
1144
+ f"Could not resolve static solution project entry: {item}",
1145
+ language=Language.CSHARP,
1146
+ metadata={"project_file": item, "metadata_path": str(path)},
1147
+ )
1148
+
1149
+
1150
+ def _languages_for(path: Path) -> tuple[Language, ...]:
1151
+ detected = adapters_for(path)
1152
+ return (Language.DELPHI,) if path.suffix.casefold() in {".pas", ".dpr", ".dpk"} else detected
1153
+
1154
+
1155
+ def _owner(
1156
+ path: Path,
1157
+ definitions: list[_Definition],
1158
+ language: Language,
1159
+ root: Path,
1160
+ ) -> tuple[Project, Problem | None]:
1161
+ candidates = [item for item in definitions if item.language is language and _owns(item, path)]
1162
+ if not candidates:
1163
+ return _synthetic_project(root, language), None
1164
+ best_rank = max(_ownership_rank(item, path) for item in candidates)
1165
+ winners = [item for item in candidates if _ownership_rank(item, path) == best_rank]
1166
+ winner_ids = {_definition_project_id(item, definitions) for item in winners}
1167
+ if len(winner_ids) > 1:
1168
+ metadata_paths = sorted(str(item.metadata_path) for item in winners)
1169
+ return (
1170
+ _synthetic_project(root, language),
1171
+ Problem(
1172
+ "ambiguous-project-ownership",
1173
+ f"Multiple project files can own {path}: {', '.join(metadata_paths)}",
1174
+ "warning",
1175
+ language,
1176
+ metadata={"path": str(path.relative_to(root)), "project_files": metadata_paths},
1177
+ ),
1178
+ )
1179
+ return _project_from_definition(winners[0], definitions), None
1180
+
1181
+
1182
+ def _synthetic_project(root: Path, language: Language) -> Project:
1183
+ return Project(
1184
+ stable_id("project", str(root), "synthetic"),
1185
+ root.name,
1186
+ str(root),
1187
+ (language,),
1188
+ "synthetic",
1189
+ )
1190
+
1191
+
1192
+ def _project_from_definition(definition: _Definition, definitions: list[_Definition]) -> Project:
1193
+ project_id = _definition_project_id(definition, definitions)
1194
+ canonical = _canonical_project_definition(definition, definitions)
1195
+ include_paths = tuple(
1196
+ dict.fromkeys(
1197
+ str(include_path)
1198
+ for candidate in definitions
1199
+ if candidate.language is definition.language
1200
+ and _definition_project_id(candidate, definitions) == project_id
1201
+ for include_path in candidate.include_paths
1202
+ )
1203
+ )
1204
+ return Project(
1205
+ project_id,
1206
+ _definition_project_name(definition),
1207
+ str(definition.root),
1208
+ (definition.language,),
1209
+ canonical.kind,
1210
+ include_paths,
1211
+ )
1212
+
1213
+
1214
+ def _merge_project(previous: Project | None, project: Project) -> Project:
1215
+ if previous is None:
1216
+ return project
1217
+ selected_languages = {*previous.languages, *project.languages}
1218
+ return replace(
1219
+ previous,
1220
+ languages=tuple(language for language in Language if language in selected_languages),
1221
+ include_paths=tuple(dict.fromkeys((*previous.include_paths, *project.include_paths))),
1222
+ )
1223
+
1224
+
1225
+ def _source_include_paths(
1226
+ path: Path,
1227
+ definitions: list[_Definition],
1228
+ language: Language,
1229
+ project: Project,
1230
+ ) -> tuple[Path, ...]:
1231
+ project_root = Path(project.root)
1232
+ candidates = [
1233
+ item
1234
+ for item in definitions
1235
+ if item.language is language
1236
+ and item.root == project_root
1237
+ and _definition_project_id(item, definitions) == project.project_id
1238
+ and _owns(item, path)
1239
+ ]
1240
+ for candidate in candidates:
1241
+ for source_file, include_paths in candidate.translation_unit_include_paths:
1242
+ if source_file == path:
1243
+ return include_paths
1244
+ return tuple(Path(include_path) for include_path in project.include_paths)
1245
+
1246
+
1247
+ def _source_output_names(
1248
+ path: Path,
1249
+ definitions: list[_Definition],
1250
+ language: Language,
1251
+ project: Project,
1252
+ ) -> tuple[str, ...]:
1253
+ project_root = Path(project.root)
1254
+ names: list[str] = []
1255
+ for candidate in definitions:
1256
+ if (
1257
+ candidate.language is not language
1258
+ or candidate.root != project_root
1259
+ or _definition_project_id(candidate, definitions) != project.project_id
1260
+ or not _owns(candidate, path)
1261
+ ):
1262
+ continue
1263
+ for source_file, output_names in candidate.translation_unit_output_names:
1264
+ if source_file == path:
1265
+ names.extend(name for name in output_names if name not in names)
1266
+ return tuple(names)
1267
+
1268
+
1269
+ def _excluded_by_layout(path: Path, definitions: list[_Definition], language: Language) -> bool:
1270
+ scoped = [item for item in definitions if item.language is language and (item.source_roots or item.source_files)]
1271
+ return any(path.is_relative_to(item.root) for item in scoped) and not any(_owns(item, path) for item in scoped)
1272
+
1273
+
1274
+ def _owns(definition: _Definition, path: Path) -> bool:
1275
+ if path in definition.source_files:
1276
+ return True
1277
+ roots = definition.source_roots
1278
+ if roots:
1279
+ return any(path.is_relative_to(root) for root in roots)
1280
+ return not definition.source_files and path.is_relative_to(definition.root)
1281
+
1282
+
1283
+ def _ownership_rank(definition: _Definition, path: Path) -> tuple[int, int, int, int, int]:
1284
+ matching_root_depth = max(
1285
+ (len(root.parts) for root in definition.source_roots if path.is_relative_to(root)),
1286
+ default=-1,
1287
+ )
1288
+ return (
1289
+ int(path in definition.source_files),
1290
+ int(definition.metadata_path.stem.casefold() == path.stem.casefold()),
1291
+ _PROJECT_OWNERSHIP_PRIORITY.get(definition.metadata_path.suffix.casefold(), 0),
1292
+ matching_root_depth,
1293
+ len(definition.root.parts),
1294
+ )
1295
+
1296
+
1297
+ def _definition_project_id(definition: _Definition, definitions: list[_Definition]) -> str:
1298
+ canonical = _canonical_project_definition(definition, definitions)
1299
+ if canonical.metadata_path.suffix.casefold() in _NAMED_PROJECT_SUFFIXES:
1300
+ return stable_id("project", str(canonical.metadata_path.with_suffix("")), canonical.kind)
1301
+ return stable_id("project", str(definition.root), definition.language.value)
1302
+
1303
+
1304
+ def _canonical_project_definition(
1305
+ definition: _Definition,
1306
+ definitions: list[_Definition],
1307
+ ) -> _Definition:
1308
+ if definition.metadata_path.suffix.casefold() != ".dproj":
1309
+ return definition
1310
+ project_path = definition.metadata_path.with_suffix("")
1311
+ companions = [
1312
+ candidate
1313
+ for candidate in definitions
1314
+ if candidate.metadata_path.with_suffix("") == project_path
1315
+ and candidate.metadata_path.suffix.casefold() in {".dpk", ".dpr"}
1316
+ ]
1317
+ if not companions:
1318
+ return definition
1319
+ return max(
1320
+ companions,
1321
+ key=lambda candidate: (candidate.metadata_path.suffix.casefold() == ".dpr", candidate.kind),
1322
+ )
1323
+
1324
+
1325
+ def _definition_project_name(definition: _Definition) -> str:
1326
+ if definition.metadata_path.suffix.casefold() in _NAMED_PROJECT_SUFFIXES:
1327
+ return definition.metadata_path.stem
1328
+ return definition.root.name