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
1621import json
22+ import os
1723import 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
2129from aoc_main import _logging
2230
31+ if TYPE_CHECKING :
32+ from collections .abc import Iterator
33+
2334_logger = _logging .logger
2435
2536PresetType = 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+
32120class 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