Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
184 changes: 157 additions & 27 deletions aoc-main/src/aoc_main/_cmake_presets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand All @@ -29,44 +40,163 @@ 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"\$(?:(?P<scope>p?env)\{(?P<var>[^}]+)\}|\{(?P<macro>[^}]+)\})"
)


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
Comment thread
nikobockerman marked this conversation as 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.

Pass the directory that contains ``CMakePresets.json`` (and optionally
``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)
Comment thread
nikobockerman marked this conversation as resolved.

@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:
Expand Down
Loading
Loading