Skip to content

Commit 2344e34

Browse files
Support includes when parsing CMake preset files (#624)
1 parent 5361790 commit 2344e34

2 files changed

Lines changed: 332 additions & 39 deletions

File tree

aoc-main/src/aoc_main/_cmake_presets.py

Lines changed: 157 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -5,21 +5,32 @@
55
66
* looking up a preset by *type* and *name*, with user presets taking precedence
77
over project presets (mirroring CMake's own override order);
8+
* following ``include`` directives so a preset can live in a file pulled in by
9+
the entry-point file, including recursively;
10+
* expanding a minimal set of preset macros in those include paths
11+
(see :func:`_expand_include`);
812
* finding the configure step of a workflow preset;
913
* resolving the ``binaryDir`` of a configure preset, walking ``inherits`` chains
1014
when the field is not set on the preset directly.
1115
12-
Features such as ``include`` and macro expansion (e.g. ``${sourceDir}``) are
13-
intentionally unsupported.
16+
Macros outside that minimal set (e.g. ``${sourceDir}``) and other schema
17+
features are intentionally unsupported; an include path that uses one raises
18+
rather than silently producing a wrong path.
1419
"""
1520

1621
import json
22+
import os
1723
import pathlib
18-
from functools import cached_property
19-
from typing import Any, Literal
24+
import platform
25+
import re
26+
from dataclasses import dataclass
27+
from typing import TYPE_CHECKING, Any, Literal
2028

2129
from aoc_main import _logging
2230

31+
if TYPE_CHECKING:
32+
from collections.abc import Iterator
33+
2334
_logger = _logging.logger
2435

2536
PresetType = Literal["build", "configure", "workflow"]
@@ -29,44 +40,163 @@ class CMakePresetError(Exception):
2940
"""Raised when the preset files cannot satisfy a requested lookup."""
3041

3142

43+
def _host_system_name() -> str:
44+
"""Value CMake exposes as ``${hostSystemName}`` / ``CMAKE_HOST_SYSTEM_NAME``.
45+
46+
``platform.system()`` returns ``Darwin`` / ``Linux`` / ``Windows``, which is
47+
exactly the spelling CMake uses for the host OS name. Kept as a module-level
48+
function so tests can substitute a value without faking the whole platform.
49+
"""
50+
return platform.system()
51+
52+
53+
# Matches the two macro forms this module expands: ``${macro}`` and
54+
# ``$penv{var}`` / ``$env{var}``. ``$penv`` reads the *parent* (process)
55+
# environment; ``$env`` reads preset-defined environment and is unsupported in
56+
# include paths.
57+
_INCLUDE_MACRO_RE = re.compile(
58+
r"\$(?:(?P<scope>p?env)\{(?P<var>[^}]+)\}|\{(?P<macro>[^}]+)\})"
59+
)
60+
61+
62+
def _expand_include(value: str) -> str:
63+
"""Expand the supported preset macros in an ``include`` entry.
64+
65+
The supported set is deliberately small: ``${hostSystemName}`` and
66+
``$penv{NAME}`` (a parent-environment lookup). Any other macro raises
67+
:class:`CMakePresetError` so an unsupported construct fails loudly instead of
68+
resolving to a nonsensical path.
69+
"""
70+
71+
def replace(match: re.Match[str]) -> str:
72+
scope = match.group("scope")
73+
if scope == "penv":
74+
var = match.group("var")
75+
try:
76+
return os.environ[var]
77+
except KeyError:
78+
msg = (
79+
f"Environment variable not set for include macro: {match.group(0)}"
80+
)
81+
raise CMakePresetError(msg) from None
82+
83+
if scope is None and match.group("macro") == "hostSystemName":
84+
return _host_system_name()
85+
86+
msg = f"Unsupported macro in include path: {match.group(0)}"
87+
raise CMakePresetError(msg)
88+
89+
return _INCLUDE_MACRO_RE.sub(replace, value)
90+
91+
92+
@dataclass(frozen=True)
93+
class _PresetFile:
94+
"""One parsed preset file: its absolute path plus the decoded JSON."""
95+
96+
path: pathlib.Path
97+
config: dict[str, Any]
98+
99+
@property
100+
def includes(self) -> list[pathlib.Path]:
101+
"""Absolute paths this file's ``include`` list points at.
102+
103+
Relative entries resolve against this file's directory (CMake's rule),
104+
and macros are expanded along the way.
105+
"""
106+
base = self.path.parent
107+
resolved: list[pathlib.Path] = []
108+
for raw in self.config.get("include", []):
109+
include_path = pathlib.Path(_expand_include(raw))
110+
if not include_path.is_absolute():
111+
include_path = base / include_path
112+
resolved.append(include_path)
113+
return resolved
114+
115+
def presets(self, preset_type: PresetType) -> list[dict[str, Any]]:
116+
result: list[dict[str, Any]] = self.config.get(f"{preset_type}Presets", [])
117+
return result
118+
119+
32120
class CMakePresets:
33121
"""Reads CMake preset files from a directory and answers queries about them.
34122
35123
Pass the directory that contains ``CMakePresets.json`` (and optionally
36124
``CMakeUserPresets.json``). Files are read lazily and cached on first
37125
access, so an instance reflects their contents at that point. A missing
38126
file is treated as containing no presets.
127+
128+
Lookups follow ``include`` directives: the user file implicitly includes the
129+
project file (as CMake does), and any file may pull in further files, which
130+
are searched recursively.
39131
"""
40132

41133
def __init__(self, root_dir: pathlib.Path) -> None:
42134
self._root_dir = root_dir
135+
self._file_cache: dict[pathlib.Path, _PresetFile | None] = {}
136+
137+
def _load_file(self, path: pathlib.Path) -> _PresetFile | None:
138+
"""Load and cache a preset file; ``None`` if it does not exist."""
139+
path = path.absolute()
140+
if path not in self._file_cache:
141+
if path.exists():
142+
self._file_cache[path] = _PresetFile(path, json.loads(path.read_text()))
143+
else:
144+
self._file_cache[path] = None
145+
return self._file_cache[path]
146+
147+
def _walk(
148+
self,
149+
path: pathlib.Path,
150+
*,
151+
visited: set[pathlib.Path],
152+
implicit: tuple[pathlib.Path, ...] = (),
153+
) -> Iterator[_PresetFile]:
154+
"""Yield ``path`` and every file it includes, depth-first.
155+
156+
``visited`` guards against re-yielding a file reached through more than
157+
one include path (a diamond) and against cycles. ``implicit`` lists
158+
files included by CMake convention rather than by an ``include`` entry
159+
(the project file, included by the user file); those may be absent,
160+
whereas an explicit include that is missing is an error.
161+
"""
162+
path = path.absolute()
163+
if path in visited:
164+
return
165+
visited.add(path)
166+
167+
preset_file = self._load_file(path)
168+
if preset_file is None:
169+
return
170+
yield preset_file
171+
172+
for include_path in implicit:
173+
yield from self._walk(include_path, visited=visited)
174+
175+
for include_path in preset_file.includes:
176+
if self._load_file(include_path) is None:
177+
msg = f"Included preset file does not exist: {include_path}"
178+
raise CMakePresetError(msg)
179+
yield from self._walk(include_path, visited=visited)
43180

44-
@cached_property
45-
def _user_presets(self) -> dict[str, Any]:
46-
return self._load(self._root_dir / "CMakeUserPresets.json")
47-
48-
@cached_property
49-
def _project_presets(self) -> dict[str, Any]:
50-
return self._load(self._root_dir / "CMakePresets.json")
181+
def _iter_preset_files(self) -> Iterator[_PresetFile]:
182+
"""Yield every reachable preset file, user presets first.
51183
52-
@staticmethod
53-
def _load(preset_file: pathlib.Path) -> dict[str, Any]:
54-
if not preset_file.exists():
55-
return {}
56-
return json.loads(preset_file.read_text())
184+
CMake has ``CMakeUserPresets.json`` implicitly include
185+
``CMakePresets.json``; that is modelled here as an implicit include so a
186+
preset defined only in the project file is still found when looking up
187+
from the user file.
188+
"""
189+
visited: set[pathlib.Path] = set()
190+
project = (self._root_dir / "CMakePresets.json").absolute()
191+
user = (self._root_dir / "CMakeUserPresets.json").absolute()
192+
yield from self._walk(user, visited=visited, implicit=(project,))
193+
yield from self._walk(project, visited=visited)
57194

58195
def _get_preset(self, preset_type: PresetType, name: str) -> dict[str, Any] | None:
59-
for presets in (self._user_presets, self._project_presets):
60-
preset = next(
61-
(
62-
preset
63-
for preset in presets.get(f"{preset_type}Presets", [])
64-
if preset["name"] == name
65-
),
66-
None,
67-
)
68-
if preset is not None:
69-
return preset
196+
for preset_file in self._iter_preset_files():
197+
for preset in preset_file.presets(preset_type):
198+
if preset["name"] == name:
199+
return preset
70200
return None
71201

72202
def workflow_configure_preset_name(self, workflow_name: str) -> str:

0 commit comments

Comments
 (0)