diff --git a/aoc-main/src/aoc_main/_cmake_presets.py b/aoc-main/src/aoc_main/_cmake_presets.py index 98d819b..f7760a3 100644 --- a/aoc-main/src/aoc_main/_cmake_presets.py +++ b/aoc-main/src/aoc_main/_cmake_presets.py @@ -5,21 +5,32 @@ * looking up a preset by *type* and *name*, with user presets taking precedence over project presets (mirroring CMake's own override order); +* following ``include`` directives so a preset can live in a file pulled in by + the entry-point file, including recursively; +* expanding a minimal set of preset macros in those include paths + (see :func:`_expand_include`); * finding the configure step of a workflow preset; * resolving the ``binaryDir`` of a configure preset, walking ``inherits`` chains when the field is not set on the preset directly. -Features such as ``include`` and macro expansion (e.g. ``${sourceDir}``) are -intentionally unsupported. +Macros outside that minimal set (e.g. ``${sourceDir}``) and other schema +features are intentionally unsupported; an include path that uses one raises +rather than silently producing a wrong path. """ import json +import os import pathlib -from functools import cached_property -from typing import Any, Literal +import platform +import re +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Literal from aoc_main import _logging +if TYPE_CHECKING: + from collections.abc import Iterator + _logger = _logging.logger PresetType = Literal["build", "configure", "workflow"] @@ -29,6 +40,83 @@ class CMakePresetError(Exception): """Raised when the preset files cannot satisfy a requested lookup.""" +def _host_system_name() -> str: + """Value CMake exposes as ``${hostSystemName}`` / ``CMAKE_HOST_SYSTEM_NAME``. + + ``platform.system()`` returns ``Darwin`` / ``Linux`` / ``Windows``, which is + exactly the spelling CMake uses for the host OS name. Kept as a module-level + function so tests can substitute a value without faking the whole platform. + """ + return platform.system() + + +# Matches the two macro forms this module expands: ``${macro}`` and +# ``$penv{var}`` / ``$env{var}``. ``$penv`` reads the *parent* (process) +# environment; ``$env`` reads preset-defined environment and is unsupported in +# include paths. +_INCLUDE_MACRO_RE = re.compile( + r"\$(?:(?Pp?env)\{(?P[^}]+)\}|\{(?P[^}]+)\})" +) + + +def _expand_include(value: str) -> str: + """Expand the supported preset macros in an ``include`` entry. + + The supported set is deliberately small: ``${hostSystemName}`` and + ``$penv{NAME}`` (a parent-environment lookup). Any other macro raises + :class:`CMakePresetError` so an unsupported construct fails loudly instead of + resolving to a nonsensical path. + """ + + def replace(match: re.Match[str]) -> str: + scope = match.group("scope") + if scope == "penv": + var = match.group("var") + try: + return os.environ[var] + except KeyError: + msg = ( + f"Environment variable not set for include macro: {match.group(0)}" + ) + raise CMakePresetError(msg) from None + + if scope is None and match.group("macro") == "hostSystemName": + return _host_system_name() + + msg = f"Unsupported macro in include path: {match.group(0)}" + raise CMakePresetError(msg) + + return _INCLUDE_MACRO_RE.sub(replace, value) + + +@dataclass(frozen=True) +class _PresetFile: + """One parsed preset file: its absolute path plus the decoded JSON.""" + + path: pathlib.Path + config: dict[str, Any] + + @property + def includes(self) -> list[pathlib.Path]: + """Absolute paths this file's ``include`` list points at. + + Relative entries resolve against this file's directory (CMake's rule), + and macros are expanded along the way. + """ + base = self.path.parent + resolved: list[pathlib.Path] = [] + for raw in self.config.get("include", []): + include_path = pathlib.Path(_expand_include(raw)) + if not include_path.is_absolute(): + include_path = base / include_path + resolved.append(include_path) + return resolved + + def presets(self, preset_type: PresetType) -> list[dict[str, Any]]: + result: list[dict[str, Any]] = self.config.get(f"{preset_type}Presets", []) + return result + + class CMakePresets: """Reads CMake preset files from a directory and answers queries about them. @@ -36,37 +124,79 @@ class CMakePresets: ``CMakeUserPresets.json``). Files are read lazily and cached on first access, so an instance reflects their contents at that point. A missing file is treated as containing no presets. + + Lookups follow ``include`` directives: the user file implicitly includes the + project file (as CMake does), and any file may pull in further files, which + are searched recursively. """ def __init__(self, root_dir: pathlib.Path) -> None: self._root_dir = root_dir + self._file_cache: dict[pathlib.Path, _PresetFile | None] = {} + + def _load_file(self, path: pathlib.Path) -> _PresetFile | None: + """Load and cache a preset file; ``None`` if it does not exist.""" + path = path.absolute() + if path not in self._file_cache: + if path.exists(): + self._file_cache[path] = _PresetFile(path, json.loads(path.read_text())) + else: + self._file_cache[path] = None + return self._file_cache[path] + + def _walk( + self, + path: pathlib.Path, + *, + visited: set[pathlib.Path], + implicit: tuple[pathlib.Path, ...] = (), + ) -> Iterator[_PresetFile]: + """Yield ``path`` and every file it includes, depth-first. + + ``visited`` guards against re-yielding a file reached through more than + one include path (a diamond) and against cycles. ``implicit`` lists + files included by CMake convention rather than by an ``include`` entry + (the project file, included by the user file); those may be absent, + whereas an explicit include that is missing is an error. + """ + path = path.absolute() + if path in visited: + return + visited.add(path) + + preset_file = self._load_file(path) + if preset_file is None: + return + yield preset_file + + for include_path in implicit: + yield from self._walk(include_path, visited=visited) + + for include_path in preset_file.includes: + if self._load_file(include_path) is None: + msg = f"Included preset file does not exist: {include_path}" + raise CMakePresetError(msg) + yield from self._walk(include_path, visited=visited) - @cached_property - def _user_presets(self) -> dict[str, Any]: - return self._load(self._root_dir / "CMakeUserPresets.json") - - @cached_property - def _project_presets(self) -> dict[str, Any]: - return self._load(self._root_dir / "CMakePresets.json") + def _iter_preset_files(self) -> Iterator[_PresetFile]: + """Yield every reachable preset file, user presets first. - @staticmethod - def _load(preset_file: pathlib.Path) -> dict[str, Any]: - if not preset_file.exists(): - return {} - return json.loads(preset_file.read_text()) + CMake has ``CMakeUserPresets.json`` implicitly include + ``CMakePresets.json``; that is modelled here as an implicit include so a + preset defined only in the project file is still found when looking up + from the user file. + """ + visited: set[pathlib.Path] = set() + project = (self._root_dir / "CMakePresets.json").absolute() + user = (self._root_dir / "CMakeUserPresets.json").absolute() + yield from self._walk(user, visited=visited, implicit=(project,)) + yield from self._walk(project, visited=visited) def _get_preset(self, preset_type: PresetType, name: str) -> dict[str, Any] | None: - for presets in (self._user_presets, self._project_presets): - preset = next( - ( - preset - for preset in presets.get(f"{preset_type}Presets", []) - if preset["name"] == name - ), - None, - ) - if preset is not None: - return preset + for preset_file in self._iter_preset_files(): + for preset in preset_file.presets(preset_type): + if preset["name"] == name: + return preset return None def workflow_configure_preset_name(self, workflow_name: str) -> str: diff --git a/aoc-main/tests/test_cmake_presets.py b/aoc-main/tests/test_cmake_presets.py index 8a9fc36..1e84864 100644 --- a/aoc-main/tests/test_cmake_presets.py +++ b/aoc-main/tests/test_cmake_presets.py @@ -5,20 +5,21 @@ from aoc_main._cmake_presets import CMakePresetError, CMakePresets +# The two filenames CMake recognises by convention; other names are only reached +# through an `include`. Spelling them as constants keeps call-sites readable and +# lets include lists refer to the same value. +_PROJECT_PRESETS = "CMakePresets.json" +_USER_PRESETS = "CMakeUserPresets.json" -def _write_presets( - root: pathlib.Path, - presets: dict[str, object], - *, - user: bool = False, -) -> None: - name = "CMakeUserPresets.json" if user else "CMakePresets.json" + +def _write_presets(root: pathlib.Path, name: str, presets: dict[str, object]) -> None: (root / name).write_text(json.dumps(presets)) def test_binary_dir_set_on_preset(tmp_path: pathlib.Path) -> None: _write_presets( tmp_path, + _PROJECT_PRESETS, {"configurePresets": [{"name": "aoc", "binaryDir": "build/aoc"}]}, ) assert CMakePresets(tmp_path).binary_dir("aoc") == pathlib.Path("build/aoc") @@ -27,6 +28,7 @@ def test_binary_dir_set_on_preset(tmp_path: pathlib.Path) -> None: def test_binary_dir_inherited_from_parent(tmp_path: pathlib.Path) -> None: _write_presets( tmp_path, + _PROJECT_PRESETS, { "configurePresets": [ {"name": "aoc", "inherits": ["base"]}, @@ -40,6 +42,7 @@ def test_binary_dir_inherited_from_parent(tmp_path: pathlib.Path) -> None: def test_binary_dir_on_preset_wins_over_inherited(tmp_path: pathlib.Path) -> None: _write_presets( tmp_path, + _PROJECT_PRESETS, { "configurePresets": [ {"name": "aoc", "binaryDir": "build/aoc", "inherits": ["base"]}, @@ -55,6 +58,7 @@ def test_binary_dir_inheritance_is_breadth_first(tmp_path: pathlib.Path) -> None # over a value reachable only through the second parent's own grandparent. _write_presets( tmp_path, + _PROJECT_PRESETS, { "configurePresets": [ {"name": "aoc", "inherits": ["first", "second"]}, @@ -72,12 +76,13 @@ def test_presets_are_read_from_both_files(tmp_path: pathlib.Path) -> None: # and user files, so both files are consulted but never collide by name. _write_presets( tmp_path, + _PROJECT_PRESETS, {"configurePresets": [{"name": "project", "binaryDir": "build/project"}]}, ) _write_presets( tmp_path, + _USER_PRESETS, {"configurePresets": [{"name": "user", "binaryDir": "build/user"}]}, - user=True, ) presets = CMakePresets(tmp_path) assert presets.binary_dir("project") == pathlib.Path("build/project") @@ -87,18 +92,19 @@ def test_presets_are_read_from_both_files(tmp_path: pathlib.Path) -> None: def test_user_preset_can_inherit_from_project_preset(tmp_path: pathlib.Path) -> None: _write_presets( tmp_path, + _PROJECT_PRESETS, {"configurePresets": [{"name": "base", "binaryDir": "build/base"}]}, ) _write_presets( tmp_path, + _USER_PRESETS, {"configurePresets": [{"name": "aoc", "inherits": ["base"]}]}, - user=True, ) assert CMakePresets(tmp_path).binary_dir("aoc") == pathlib.Path("build/base") def test_binary_dir_unknown_preset_raises(tmp_path: pathlib.Path) -> None: - _write_presets(tmp_path, {"configurePresets": []}) + _write_presets(tmp_path, _PROJECT_PRESETS, {"configurePresets": []}) with pytest.raises(CMakePresetError, match="Configure preset not found: missing"): CMakePresets(tmp_path).binary_dir("missing") @@ -108,6 +114,7 @@ def test_binary_dir_missing_through_inheritance_raises( ) -> None: _write_presets( tmp_path, + _PROJECT_PRESETS, { "configurePresets": [ {"name": "aoc", "inherits": ["base"]}, @@ -127,6 +134,7 @@ def test_missing_preset_files_are_treated_as_empty(tmp_path: pathlib.Path) -> No def test_workflow_configure_preset_name(tmp_path: pathlib.Path) -> None: _write_presets( tmp_path, + _PROJECT_PRESETS, { "workflowPresets": [ { @@ -145,18 +153,18 @@ def test_workflow_configure_preset_name(tmp_path: pathlib.Path) -> None: def test_workflow_lookup_uses_user_presets(tmp_path: pathlib.Path) -> None: _write_presets( tmp_path, + _USER_PRESETS, { "workflowPresets": [ {"name": "ci", "steps": [{"type": "configure", "name": "user-aoc"}]} ] }, - user=True, ) assert CMakePresets(tmp_path).workflow_configure_preset_name("ci") == "user-aoc" def test_workflow_unknown_raises(tmp_path: pathlib.Path) -> None: - _write_presets(tmp_path, {"workflowPresets": []}) + _write_presets(tmp_path, _PROJECT_PRESETS, {"workflowPresets": []}) with pytest.raises( CMakePresetError, match="Requested workflow preset not found: ci" ): @@ -166,6 +174,7 @@ def test_workflow_unknown_raises(tmp_path: pathlib.Path) -> None: def test_workflow_without_configure_step_raises(tmp_path: pathlib.Path) -> None: _write_presets( tmp_path, + _PROJECT_PRESETS, { "workflowPresets": [ {"name": "ci", "steps": [{"type": "build", "name": "aoc"}]} @@ -179,7 +188,161 @@ def test_workflow_without_configure_step_raises(tmp_path: pathlib.Path) -> None: def test_workflow_with_malformed_steps_raises(tmp_path: pathlib.Path) -> None: _write_presets( tmp_path, + _PROJECT_PRESETS, {"workflowPresets": [{"name": "ci", "steps": [{"type": "configure"}]}]}, ) with pytest.raises(CMakePresetError, match="Invalid CMake Preset json"): CMakePresets(tmp_path).workflow_configure_preset_name("ci") + + +def test_include_pulls_in_preset_from_another_file(tmp_path: pathlib.Path) -> None: + _write_presets(tmp_path, _USER_PRESETS, {"include": ["extra.json"]}) + _write_presets( + tmp_path, + "extra.json", + {"configurePresets": [{"name": "aoc", "binaryDir": "build/aoc"}]}, + ) + assert CMakePresets(tmp_path).binary_dir("aoc") == pathlib.Path("build/aoc") + + +def test_include_is_recursive(tmp_path: pathlib.Path) -> None: + _write_presets(tmp_path, _USER_PRESETS, {"include": ["mid.json"]}) + _write_presets(tmp_path, "mid.json", {"include": ["leaf.json"]}) + _write_presets( + tmp_path, + "leaf.json", + {"configurePresets": [{"name": "aoc", "binaryDir": "build/aoc"}]}, + ) + assert CMakePresets(tmp_path).binary_dir("aoc") == pathlib.Path("build/aoc") + + +def test_inherits_resolves_across_included_file(tmp_path: pathlib.Path) -> None: + # A preset can inherit a parent that lives in an included file. + _write_presets( + tmp_path, + _USER_PRESETS, + { + "include": ["base.json"], + "configurePresets": [{"name": "aoc", "inherits": ["base"]}], + }, + ) + _write_presets( + tmp_path, + "base.json", + {"configurePresets": [{"name": "base", "binaryDir": "build/base"}]}, + ) + assert CMakePresets(tmp_path).binary_dir("aoc") == pathlib.Path("build/base") + + +def test_included_paths_resolve_relative_to_including_file( + tmp_path: pathlib.Path, +) -> None: + # `mid.json` lives in a subdirectory and its own include must resolve against + # that subdirectory, not the project root. + sub = tmp_path / "sub" + sub.mkdir() + _write_presets(tmp_path, _USER_PRESETS, {"include": ["sub/mid.json"]}) + _write_presets(sub, "mid.json", {"include": ["leaf.json"]}) + _write_presets( + sub, + "leaf.json", + {"configurePresets": [{"name": "aoc", "binaryDir": "build/aoc"}]}, + ) + assert CMakePresets(tmp_path).binary_dir("aoc") == pathlib.Path("build/aoc") + + +def test_project_presets_referenced_from_included_file(tmp_path: pathlib.Path) -> None: + # Only the user file implicitly includes CMakePresets.json; a file deeper in + # the include graph must include it explicitly to see its presets. + _write_presets(tmp_path, _USER_PRESETS, {"include": ["base.json"]}) + _write_presets( + tmp_path, + "base.json", + { + "include": [_PROJECT_PRESETS], + "configurePresets": [{"name": "aoc", "inherits": ["project-base"]}], + }, + ) + _write_presets( + tmp_path, + _PROJECT_PRESETS, + {"configurePresets": [{"name": "project-base", "binaryDir": "build/project"}]}, + ) + assert CMakePresets(tmp_path).binary_dir("aoc") == pathlib.Path("build/project") + + +def test_missing_explicit_include_raises(tmp_path: pathlib.Path) -> None: + _write_presets(tmp_path, _USER_PRESETS, {"include": ["does-not-exist.json"]}) + with pytest.raises(CMakePresetError, match="Included preset file does not exist"): + CMakePresets(tmp_path).binary_dir("aoc") + + +def test_diamond_include_is_resolved_once(tmp_path: pathlib.Path) -> None: + # `left` and `right` both include `shared`; the shared file must still be + # traversable (the visited-set must not double-process or fail on it). + _write_presets(tmp_path, _USER_PRESETS, {"include": ["left.json", "right.json"]}) + _write_presets(tmp_path, "left.json", {"include": ["shared.json"]}) + _write_presets(tmp_path, "right.json", {"include": ["shared.json"]}) + _write_presets( + tmp_path, + "shared.json", + {"configurePresets": [{"name": "aoc", "binaryDir": "build/aoc"}]}, + ) + assert CMakePresets(tmp_path).binary_dir("aoc") == pathlib.Path("build/aoc") + + +def test_include_expands_host_system_name( + tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr("aoc_main._cmake_presets._host_system_name", lambda: "Linux") + _write_presets( + tmp_path, + _USER_PRESETS, + {"include": ["CMakeUserPresets-${hostSystemName}.json"]}, + ) + _write_presets( + tmp_path, + "CMakeUserPresets-Linux.json", + {"configurePresets": [{"name": "aoc", "binaryDir": "build/linux"}]}, + ) + assert CMakePresets(tmp_path).binary_dir("aoc") == pathlib.Path("build/linux") + + +def test_include_expands_penv( + tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HOST_ARCH", "arm64") + _write_presets( + tmp_path, + _USER_PRESETS, + {"include": ["CMakeUserPresets-$penv{HOST_ARCH}.json"]}, + ) + _write_presets( + tmp_path, + "CMakeUserPresets-arm64.json", + {"configurePresets": [{"name": "aoc", "binaryDir": "build/arm64"}]}, + ) + assert CMakePresets(tmp_path).binary_dir("aoc") == pathlib.Path("build/arm64") + + +def test_include_unset_penv_raises( + tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.delenv("HOST_ARCH", raising=False) + _write_presets( + tmp_path, + _USER_PRESETS, + {"include": ["CMakeUserPresets-$penv{HOST_ARCH}.json"]}, + ) + with pytest.raises(CMakePresetError, match="Environment variable not set"): + CMakePresets(tmp_path).binary_dir("aoc") + + +def test_include_unsupported_macro_raises(tmp_path: pathlib.Path) -> None: + _write_presets( + tmp_path, + _USER_PRESETS, + {"include": ["${sourceDir}/extra.json"]}, + ) + with pytest.raises(CMakePresetError, match="Unsupported macro in include path"): + CMakePresets(tmp_path).binary_dir("aoc")