diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6aa0facedc..8d813e1191 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,11 +15,6 @@ on: branches: - develop - # pull_request_target runs repo-owned checks (e.g., Danger comments) on the base ref with the base repo token; - # never executes PR code. - pull_request_target: - branches: - - develop concurrency: group: ${{format('{0}:{1}', github.repository, github.ref)}} @@ -27,7 +22,6 @@ concurrency: jobs: cpp-matrix: - if: github.event_name != 'pull_request_target' runs-on: ubuntu-24.04 container: image: ubuntu:24.04 @@ -133,7 +127,6 @@ jobs: node .github/releases-matrix.js build: - if: github.event_name != 'pull_request_target' needs: cpp-matrix strategy: @@ -430,8 +423,8 @@ jobs: git-tag: ${{ matrix.llvm-hash }} download-dir: ../third-party/llvm-project patches: | - ./third-party/llvm/CMakePresets.json - ./third-party/llvm/CMakeUserPresets.json + ./third-party/patches/llvm/llvm/CMakePresets.json + ./third-party/patches/llvm/llvm/CMakeUserPresets.json build-dir: ${sourceDir}/build preset: ${{ matrix.llvm-build-preset }} build-type: ${{ matrix.build-type }} @@ -467,8 +460,8 @@ jobs: source-dir: ../third-party/duktape url: https://github.com/svaarala/duktape/releases/download/v2.7.0/duktape-2.7.0.tar.xz patches: | - ./third-party/duktape/CMakeLists.txt - ./third-party/duktape/duktapeConfig.cmake.in + ./third-party/patches/duktape/CMakeLists.txt + ./third-party/patches/duktape/duktapeConfig.cmake.in build-dir: ${sourceDir}/build cc: ${{ steps.setup-cpp.outputs.cc }} cxx: ${{ steps.setup-cpp.outputs.cxx }} @@ -487,8 +480,8 @@ jobs: source-dir: ../third-party/lua url: https://github.com/lua/lua/archive/refs/tags/v5.4.8.tar.gz patches: | - ./third-party/lua/CMakeLists.txt - ./third-party/lua/LuaConfig.cmake.in + ./third-party/patches/lua/CMakeLists.txt + ./third-party/patches/lua/LuaConfig.cmake.in build-dir: ${sourceDir}/build cc: ${{ steps.setup-cpp.outputs.cc }} cxx: ${{ steps.setup-cpp.outputs.cxx }} @@ -910,6 +903,17 @@ jobs: npx gulp lint npx gulp + - name: Ensure all refs for Antora + if: github.event_name == 'push' && (contains(fromJSON('["master", "develop"]'), github.ref_name) || startsWith(github.ref, 'refs/tags/')) && runner.os == 'Linux' + run: | + set -euo pipefail + # Make sure Antora sees every branch and tag from the upstream repo, + # regardless of who triggered the workflow. + git remote set-url origin https://github.com/cppalliance/mrdocs.git + git fetch --prune --prune-tags origin \ + '+refs/heads/*:refs/remotes/origin/*' \ + '+refs/tags/*:refs/tags/*' + - name: Generate Remote Documentation working-directory: docs run: | @@ -1308,33 +1312,3 @@ jobs: llvm_dir="/var/www/mrdox.com/llvm+clang" chmod 755 ${{ matrix.llvm-archive-filename }} scp -o StrictHostKeyChecking=no $(pwd)/${{ matrix.llvm-archive-filename }} ubuntu@dev-websites.cpp.al:$llvm_dir/ - - repo-checks: - name: Repo checks - # Run under pull_request_target so we can use the base-repo token to comment on forked PRs - # without executing forked code in this job. Declared after the matrix job so the matrix stays first in the UI. - if: github.event_name == 'pull_request_target' - runs-on: ubuntu-24.04 - permissions: - contents: read - pull-requests: write - issues: write - statuses: write - steps: - - name: Checkout base revision - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 20 - - - name: Install repo-check tools - run: npm --prefix util/danger ci - - - name: Repo checks (Danger) - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: npx --prefix util/danger danger ci --dangerfile util/danger/dangerfile.ts diff --git a/.github/workflows/pr-target-checks.yml b/.github/workflows/pr-target-checks.yml new file mode 100644 index 0000000000..2ff2370e89 --- /dev/null +++ b/.github/workflows/pr-target-checks.yml @@ -0,0 +1,39 @@ +name: PR Target Checks + +on: + # Uses base-repo token and never executes PR code. + pull_request_target: + branches: + - develop + +concurrency: + group: ${{ format('{0}:{1}:{2}', github.repository, github.ref, github.event_name) }} + cancel-in-progress: true + +jobs: + repo-checks: + name: Base-token checks + runs-on: ubuntu-24.04 + permissions: + contents: read + pull-requests: write + issues: write + statuses: write + steps: + - name: Checkout base revision + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install repo-check tools + run: npm --prefix util/danger ci + + - name: Repo checks (Danger) + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: npx --prefix util/danger danger ci --dangerfile util/danger/dangerfile.ts diff --git a/bootstrap.py b/bootstrap.py index 27f02f156e..357f2115ee 100644 --- a/bootstrap.py +++ b/bootstrap.py @@ -14,6 +14,7 @@ import sys import platform import shutil +import math from dataclasses import dataclass, field import dataclasses import urllib.request @@ -22,7 +23,9 @@ import shlex import re import zipfile +from pathlib import Path from functools import lru_cache +from typing import Optional, Dict, Any, List, Iterable, Set @lru_cache(maxsize=1) @@ -36,6 +39,237 @@ def running_from_mrdocs_source_dir(): return cwd == script_dir +class TextUI: + """ + Lightweight console formatting helper that keeps default output plain + but can emit color/emoji when available or explicitly enabled. + """ + + COLOR = { + "reset": "\033[0m", + "info": "\033[97m", # bright white + "warn": "\033[93m", # yellow + "error": "\033[1;91m", # bold red + "ok": "\033[1;92m", # bold green + "section": "\033[1;96m", # bold cyan + "subsection": "\033[96m", # cyan + "command": "\033[95m", # magenta + "dim": "\033[2m", + } + EMOJI = { + "info": "", + "warn": "⚠️ ", + "error": "⛔ ", + "ok": "✅ ", + "section": "", + "command": "💻 ", + } + + def __init__(self, enable_color: bool = False, enable_emoji: bool = False): + force_color = os.environ.get("BOOTSTRAP_FORCE_COLOR") or os.environ.get("CLICOLOR_FORCE") + force_emoji = os.environ.get("BOOTSTRAP_FORCE_EMOJI") + self.color_enabled = bool(enable_color and (force_color or self._supports_color())) + self.emoji_enabled = bool(enable_emoji and (force_emoji or self._supports_emoji())) + # keep displayed paths compact; we shorten aggressively past this length + self.max_path = 50 + self.base_path: Optional[str] = None + self.base_token: str = "." + + @staticmethod + def _supports_color() -> bool: + if os.environ.get("NO_COLOR") or os.environ.get("BOOTSTRAP_PLAIN"): + return False + return sys.stdout.isatty() + + @staticmethod + def _supports_emoji() -> bool: + if os.environ.get("BOOTSTRAP_PLAIN"): + return False + return True + + def _fmt(self, text: str, kind: str, icon: Optional[str] = None) -> str: + prefix = "" + if self.emoji_enabled: + prefix = icon if icon is not None else self.EMOJI.get(kind, "") + if not self.color_enabled: + return f"{prefix}{text}" + color = self.COLOR.get(kind, "") + reset = self.COLOR["reset"] + return f"{color}{prefix}{text}{reset}" + + def info(self, msg: str, icon: Optional[str] = None): + print(self._fmt(msg, "info", icon)) + + def warn(self, msg: str, icon: Optional[str] = None): + print(self._fmt(msg, "warn", icon)) + + def error(self, msg: str, icon: Optional[str] = None): + print(self._fmt(msg, "error", icon)) + + def error_block(self, header: str, tips: Optional[List[str]] = None): + print(self._fmt(f"!! {header}", "error")) + if tips: + for tip in tips: + print(self._fmt(f" • {tip}", "warn")) + + def ok(self, msg: str, icon: Optional[str] = None): + print(self._fmt(msg, "ok", icon)) + + def section(self, title: str, icon: Optional[str] = None): + prefix = (icon + " ") if (self.emoji_enabled and icon) else "" + line = "━" * 60 + print() + print(self._fmt(line, "section", "")) + print(self._fmt(f"{prefix}{title}", "section", "")) + print(self._fmt(line, "section", "")) + + def command(self, cmd: str, icon: Optional[str] = None): + print(self._fmt(cmd, "command", icon)) + + def subsection(self, title: str, icon: Optional[str] = None): + prefix = (icon + " ") if (self.emoji_enabled and icon) else "" + banner = f" {prefix}{title}" + print() # blank line for breathing room + print(self._fmt(banner, "subsection", "")) + # underline matches text length (indent + title) plus a small cushion + underline_len = max(15, len(banner.strip()) + 4) + print(self._fmt("-" * underline_len, "subsection", "")) + + def shorten_path(self, path: str) -> str: + if not path: + return path + try: + if os.path.abspath(path) == os.path.abspath(os.getcwd()): + return "." + except Exception: + pass + if not (path.startswith(self.base_token) or path.startswith("$MRDOCS")): + path = os.path.abspath(path) + if self.base_path and path.startswith(self.base_path): + suffix = path[len(self.base_path):] + if suffix.startswith(os.sep): + suffix = suffix[1:] + path = f"{self.base_token}" + (f"/{suffix}" if suffix else "") + home = os.path.expanduser("~") + if path.startswith(home): + path = path.replace(home, "~", 1) + if len(path) <= self.max_path: + return path + parts = path.split(os.sep) + if len(parts) <= 4: + return path + return os.sep.join(parts[:2]) + os.sep + "..." + os.sep + os.sep.join(parts[-2:]) + + @staticmethod + def _shorten_middle(text: str, max_len: int) -> str: + if len(text) <= max_len: + return text + take = max_len // 2 - 2 + return text[:take] + "..." + text[-take:] + + def set_base_path(self, path: Optional[str], token: str = "."): + if path: + self.base_path = os.path.abspath(path) + self.base_token = token + + def maybe_shorten(self, value: str) -> str: + """ + Shorten likely-path values but leave URLs and simple tokens intact. + """ + if not isinstance(value, str): + return value + lowered = value.lower() + if lowered.startswith("http://") or lowered.startswith("https://"): + return value + if value.startswith(self.base_token) or value.startswith("$MRDOCS") or value.startswith("~"): + return self._shorten_middle(value, self.max_path) + is_pathish = (os.sep in value) or value.startswith("~") or value.startswith(".") or value.startswith("/") + # Prefer replacing the MrDocs source prefix with a short token for path-like strings + if is_pathish: + try: + if self.base_path: + abs_val = value if value.startswith(self.base_token) or value.startswith("$MRDOCS") else os.path.abspath(value) + if abs_val.startswith(self.base_path): + rel = abs_val[len(self.base_path):] + if rel.startswith(os.sep): + rel = rel[1:] + replaced = self.base_token + (f"/{rel}" if rel else "") + return self._shorten_middle(replaced, self.max_path) + except Exception: + pass + if is_pathish: + return self.shorten_path(value) + return value + + def kv(self, key: str, value: str, key_width: int = 18): + key_fmt = key.rjust(key_width) + display_value = self.maybe_shorten(value) if isinstance(value, str) else value + print(self._fmt(f"{key_fmt}: ", "dim") + self._fmt(display_value, "info")) + + def kv_block(self, title: Optional[str], items: List[tuple], icon: Optional[str] = None, indent: int = 2): + """ + Print an aligned key-value block with optional header. + """ + if title: + self.section(title, icon=icon) + if not items: + return + key_width = max(len(k) for k, _ in items) + 2 + pad = " " * indent + for k, v in items: + key_fmt = k.rjust(key_width) + display_value = self.maybe_shorten(v) if isinstance(v, str) else v + line = f"{pad}{key_fmt}: " + if self.color_enabled: + line = f"{self.COLOR['dim']}{line}{self.COLOR['reset']}" + print(line + self._fmt(str(display_value), "info")) + + def checklist(self, title: str, items): + if title: + self.section(title) + for label, done in items: + mark = "✓" if done else "✗" + style = "ok" if done else "warn" + print(self._fmt(f" {mark} {label}", style)) + + def step(self, current: int, total: int, title: str): + prefix = f"[{current}/{total}] " + print(self._fmt(f"{prefix}{title}", "subsection")) + + +# default UI; may be replaced once options are parsed +ui = TextUI() + + +@dataclass +class RecipeSource: + type: str + url: str + branch: Optional[str] = None + tag: Optional[str] = None + commit: Optional[str] = None + ref: Optional[str] = None + depth: Optional[int] = None + submodules: bool = False + + +@dataclass +class Recipe: + name: str + version: str + source: RecipeSource + dependencies: List[str] + source_dir: str + build_dir: str + install_dir: str + build_type: str + source_subdir: Optional[str] = None + build: List[Dict[str, Any]] = field(default_factory=list) + tags: List[str] = field(default_factory=list) + package_root_var: Optional[str] = None + install_scope: str = "per-preset" # "per-preset" (default) or "global" + + @dataclass class InstallOptions: """ @@ -66,58 +300,23 @@ class InstallOptions: ninja_path: str = '' # MrDocs - mrdocs_src_dir: str = field( - default_factory=lambda: os.getcwd() if running_from_mrdocs_source_dir() else os.path.join(os.getcwd(), - "mrdocs")) - mrdocs_build_type: str = "Release" - mrdocs_repo: str = "https://github.com/cppalliance/mrdocs" - mrdocs_branch: str = "develop" - mrdocs_use_user_presets: bool = True - mrdocs_preset_name: str = "-<\"-\":if(cc)><\"-\":if(sanitizer)>" - mrdocs_build_dir: str = "/build/-<\"-\":if(cc)><\"-\":if(sanitizer)><\"-\":if(sanitizer)>" - mrdocs_build_tests: bool = True - mrdocs_system_install: bool = field(default_factory=lambda: not running_from_mrdocs_source_dir()) - mrdocs_install_dir: str = field( - default_factory=lambda: "/install/-<\"-\":if(cc)><\"-\":if(sanitizer)>" if running_from_mrdocs_source_dir() else "") - mrdocs_run_tests: bool = False - - # Third-party dependencies - third_party_src_dir: str = "/build/third-party" - - # Duktape - duktape_src_dir: str = "/duktape" - duktape_url: str = "https://github.com/svaarala/duktape/releases/download/v2.7.0/duktape-2.7.0.tar.xz" - duktape_build_type: str = "" - duktape_build_dir: str = "/build/<\"-\":if(cc)><\"-\":if(sanitizer)>" - duktape_install_dir: str = "/install/<\"-\":if(cc)><\"-\":if(sanitizer)>" - - # Lua - lua_src_dir: str = "/lua" - lua_url: str = "https://github.com/lua/lua/archive/refs/tags/v5.4.8.tar.gz" - lua_build_type: str = "" - lua_build_dir: str = "/build/<\"-\":if(cc)><\"-\":if(sanitizer)>" - lua_install_dir: str = "/install/<\"-\":if(cc)><\"-\":if(sanitizer)>" - - # LLVM - llvm_src_dir: str = "/llvm-project" - llvm_build_type: str = "" - llvm_build_dir: str = "/build/<\"-\":if(cc)><\"-\":if(sanitizer)>" - llvm_install_dir: str = "/install/<\"-\":if(cc)><\"-\":if(sanitizer)>" - llvm_repo: str = "https://github.com/llvm/llvm-project.git" - llvm_commit: str = "dc4cef81d47c7bc4a3c4d58fbacf8a6359683fae" - - # Libxml2 - libxml2_src_dir: str = "/libxml2" - libxml2_build_type: str = "Release" # purposefully does not depend on mrdocs-build-type because we only need the executable - libxml2_build_dir: str = "/build/<\"-\":if(cc)>" - libxml2_install_dir: str = "/install/<\"-\":if(cc)>" - libxml2_repo: str = "https://github.com/GNOME/libxml2" - libxml2_branch: str = "v2.12.6" + source_dir: str = field(default_factory=lambda: os.path.dirname(os.path.abspath(__file__))) + build_type: str = "Release" + preset: str = "-<\"-\":if(cc)><\"-\":if(sanitizer)>" + build_dir: str = "/build/-<\"-\":if(cc)><\"-\":if(sanitizer)><\"-\":if(sanitizer)>" + build_tests: bool = True + system_install: bool = field(default_factory=lambda: not running_from_mrdocs_source_dir()) + install_dir: str = field( + default_factory=lambda: "/install/-<\"-\":if(cc)><\"-\":if(sanitizer)>" if running_from_mrdocs_source_dir() else "") + run_tests: bool = False + + # Third-party dependencies root and recipes + third_party_src_dir: str = "/build/third-party" # Information to create run configurations generate_run_configs: bool = field(default_factory=lambda: running_from_mrdocs_source_dir()) - jetbrains_run_config_dir: str = "/.run" - boost_src_dir: str = "/../boost" + jetbrains_run_config_dir: str = "/.run" + boost_src_dir: str = "/../boost" generate_clion_run_configs: bool = True generate_vscode_run_configs: bool = field(default_factory=lambda: os.name != "nt") generate_vs_run_configs: bool = field(default_factory=lambda: os.name == "nt") @@ -130,64 +329,15 @@ class InstallOptions: refresh_all: bool = False force_rebuild: bool = False remove_build_dir: bool = True - - -# Constant for option descriptions -INSTALL_OPTION_DESCRIPTIONS = { - "cc": "Path to the C compiler executable. Leave empty for default.", - "cxx": "Path to the C++ compiler executable. Leave empty for default.", - "sanitizer": "Sanitizer to use for the build. Leave empty for no sanitizer. (ASan, UBSan, MSan, TSan)", - "git_path": "Path to the git executable, if not in system PATH.", - "cmake_path": "Path to the cmake executable, if not in system PATH.", - "python_path": "Path to the python executable, if not in system PATH.", - "java_path": "Path to the java executable, if not in system PATH.", - "ninja_path": "Path to the ninja executable. Leave empty to look for ninja in PATH or to download it automatically.", - "mrdocs_src_dir": "MrDocs source directory.", - "mrdocs_repo": "URL of the MrDocs repository to clone.", - "mrdocs_branch": "Branch or tag of the MrDocs repository to use.", - "mrdocs_build_type": "CMake build type for MrDocs (Release, Debug, RelWithDebInfo, MinRelSize).", - "mrdocs_use_user_presets": "Whether to use CMake User Presets for building MrDocs.", - "mrdocs_preset_name": "Name of the CMake User Preset to use for MrDocs.", - "mrdocs_build_dir": "Directory where MrDocs will be built.", - "mrdocs_build_tests": "Whether to build tests for MrDocs.", - "mrdocs_install_dir": "Directory where MrDocs will be installed.", - "mrdocs_system_install": "Whether to install MrDocs to the system directories instead of a custom install directory.", - "mrdocs_run_tests": "Whether to run tests after building MrDocs.", - "third_party_src_dir": "Directory for all third-party source dependencies.", - "duktape_src_dir": "Directory for the Duktape source code.", - "duktape_url": "Download URL for the Duktape source archive.", - "duktape_build_type": "CMake build type for Duktape. (Release, Debug, RelWithDebInfo, MinRelSize)", - "duktape_build_dir": "Directory where Duktape will be built.", - "duktape_install_dir": "Directory where Duktape will be installed.", - "lua_src_dir": "Directory for the Lua source code.", - "lua_url": "Download URL for the Lua source archive.", - "lua_build_type": "Build type for Lua. (Release, Debug)", - "lua_build_dir": "Directory where Lua will be built.", - "lua_install_dir": "Directory where Lua will be installed.", - "libxml2_src_dir": "Directory for the libxml2 source code.", - "libxml2_build_type": "CMake build type for libxml2: Release always recommended. (Release, Debug, RelWithDebInfo, MinRelSize)", - "libxml2_build_dir": "Directory where libxml2 will be built.", - "libxml2_install_dir": "Directory where libxml2 will be installed.", - "libxml2_repo": "URL of the libxml2 repository to clone.", - "libxml2_branch": "Branch or tag of libxml2 to use.", - "llvm_src_dir": "Directory for the LLVM project source code.", - "llvm_build_type": "CMake build type for LLVM. (Release, Debug, RelWithDebInfo, MinRelSize)", - "llvm_build_dir": "Directory where LLVM will be built.", - "llvm_install_dir": "Directory where LLVM will be installed.", - "llvm_repo": "URL of the LLVM project repository to clone.", - "llvm_commit": "Specific commit hash of LLVM to checkout.", - "generate_run_configs": "Whether to generate run configurations for IDEs.", - "jetbrains_run_config_dir": "Directory where JetBrains run configurations will be stored.", - "boost_src_dir": "Directory where the source files of the Boost libraries are located.", - "generate_clion_run_configs": "Whether to generate run configurations for CLion.", - "generate_vscode_run_configs": "Whether to generate run configurations for Visual Studio Code.", - "generate_vs_run_configs": "Whether to generate run configurations for Visual Studio.", - "generate_pretty_printer_configs": "Whether to generate pretty printer configurations for debuggers.", - "non_interactive": "Whether to use all default options without interactive prompts.", - "refresh_all": "Call the command to refresh dependencies for all configurations", - "force_rebuild": "Whether to force a rebuild of all dependencies, even if they are already built.", - "remove_build_dir": "Whether to remove the build directory of dependencies after installation.", -} + plain_ui: bool = False + verbose: bool = False + debug: bool = False + dry_run: bool = False + list_recipes: bool = False + recipe_filter: str = "" + skip_build: bool = False + clean: bool = False + force: bool = False class MrDocsInstaller: @@ -202,6 +352,8 @@ def __init__(self, cmd_line_args=None): self.cmd_line_args = cmd_line_args or dict() self.default_options = InstallOptions() self.options = InstallOptions() + self.package_roots: Dict[str, str] = {} + self.recipe_info: Dict[str, Recipe] = {} for field in dataclasses.fields(self.options): if field.type == str: setattr(self.options, field.name, '') @@ -209,11 +361,42 @@ def __init__(self, cmd_line_args=None): setattr(self.options, field.name, None) else: raise TypeError(f"Unsupported type {field.type} for field {field.name} in InstallOptions.") + # Seed critical defaults for path expansion before prompting + self.options.source_dir = self.default_options.source_dir + self.options.third_party_src_dir = self.default_options.third_party_src_dir + self.recipes_dir = os.path.join(self.options.source_dir, "third-party", "recipes") + self.patches_dir = os.path.join(self.options.source_dir, "third-party", "patches") self.options.non_interactive = self.cmd_line_args.get("non_interactive", False) self.options.refresh_all = self.cmd_line_args.get("refresh_all", False) self.prompted_options = set() self.compiler_info = {} - self.env = None + self.env = os.environ.copy() + # Disable pkg-config to avoid CMake regex issues with paths containing '+' (e.g., C++). + # Find modules will still use CMAKE_PREFIX_PATH and hints. + self.env["PKG_CONFIG"] = "false" + # Seed options from command-line for all fields we already know + for field in dataclasses.fields(self.options): + name = field.name + if name in self.cmd_line_args and self.cmd_line_args[name] is not None: + setattr(self.options, name, self.cmd_line_args[name]) + plain_ui_flag = bool(self.cmd_line_args.get("plain_ui", False)) + self.ui = TextUI(enable_color=not plain_ui_flag, enable_emoji=not plain_ui_flag) + # allow UI shortening to replace MrDocs source dir with a compact token + self.ui.set_base_path(self.options.source_dir) + global ui + ui = self.ui + self.package_roots: Dict[str, str] = {} + self.recipe_info: Dict[str, Recipe] = {} + + def _load_json_file(self, path: str) -> Optional[Dict[str, Any]]: + if not os.path.exists(path): + return None + try: + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + except Exception as exc: + self.ui.warn(f"Failed to read {path}: {exc}") + return None def prompt_string(self, prompt, default): """ @@ -223,6 +406,7 @@ def prompt_string(self, prompt, default): :param default: The default value to use if the user does not provide input. :return: """ + indent = " " if self.options.non_interactive and default is not None: return default prompt = prompt.strip() @@ -231,12 +415,19 @@ def prompt_string(self, prompt, default): prompt = prompt.strip() BLUE = "\033[94m" RESET = "\033[0m" + display_default = default + if isinstance(default, str): + try: + display_default = self.ui.maybe_shorten(default) + except Exception: + display_default = default + if self.supports_ansi(): prompt = f"{BLUE}{prompt}{RESET}" - if default: - prompt += f" ({default})" + if display_default not in (None, ""): + prompt += f" ({display_default})" prompt += f": " - inp = input(prompt) + inp = input(indent + prompt) result = inp.strip() or default return result @@ -248,6 +439,7 @@ def prompt_boolean(self, prompt, default=None): :param default: The default value to return if the user does not provide input. :return: bool: True if the user answers yes, False otherwise. """ + indent = " " if self.options.non_interactive and default is not None: return default prompt = prompt.strip() @@ -259,7 +451,7 @@ def prompt_boolean(self, prompt, default=None): if self.supports_ansi(): prompt = f"{BLUE}{prompt}{RESET}" while True: - answer = input(f"{prompt} ({'y/n' if default is None else 'yes' if default else 'no'}): ").strip().lower() + answer = input(f"{indent}{prompt} ({'y/n' if default is None else 'yes' if default else 'no'}): ").strip().lower() if answer in ('y', 'yes'): return True elif answer in ('n', 'no'): @@ -270,7 +462,7 @@ def prompt_boolean(self, prompt, default=None): else: print("Please answer 'y or 'n'.") - def prompt_option(self, name, force_prompt=False): + def prompt_option(self, name, prompt_text, force_prompt=False): """ Prompts the user for a configuration option based on its name. @@ -281,6 +473,7 @@ def prompt_option(self, name, force_prompt=False): or from user input. :param name: The name of the option to prompt for. + :param prompt_text: The text to display when prompting. :return: The value of the option after prompting the user. """ name = name.replace("-", "_") @@ -353,17 +546,15 @@ def repl(match): default_value = os.path.abspath(default_value) setattr(self.default_options, name, default_value) - # If it's non-interactive, use the default value directly + # If it's non-interactive, display and use the value directly if self.options.non_interactive: + display = self.ui.maybe_shorten(default_value) if isinstance(default_value, str) else default_value + self.ui.info(f"{prompt_text}: {display}") setattr(self.options, name, default_value) self.prompted_options.add(name) return default_value - # Generate prompt to ask for value - if name in INSTALL_OPTION_DESCRIPTIONS: - prompt = INSTALL_OPTION_DESCRIPTIONS[name] - else: - raise ValueError(f"Missing description for option '{name}' in INSTALL_OPTION_DESCRIPTIONS.") + prompt = prompt_text # Prompt the user for the option value depending on the type if isinstance(getattr(self.default_options, name), bool): value = self.prompt_boolean(prompt, default_value) @@ -375,27 +566,31 @@ def repl(match): self.prompted_options.add(name) return value - def reprompt_option(self, name): - return self.prompt_option(name, force_prompt=True) + def reprompt_option(self, name, prompt_text): + return self.prompt_option(name, prompt_text, force_prompt=True) def prompt_build_type_option(self, name): - value = self.prompt_option(name) - valid_build_types = ["Debug", "Release", "RelWithDebInfo", "MinSizeRel", "OptimizedDebug"] + value = self.prompt_option(name, "Build type") + valid_build_types = ["Debug", "Release", "RelWithDebInfo", "MinSizeRel", "OptimizedDebug", "DebugFast"] for t in valid_build_types: - if t.lower() == value.lower(): + if t.lower().replace("-", "") == value.lower().replace("-", ""): + if t == "DebugFast": + value = "DebugFast" setattr(self.options, name, t) return value print(f"Invalid build type '{value}'. Must be one of: {', '.join(valid_build_types)}.") - value = self.reprompt_option(name) + value = self.reprompt_option(name, "Build type") for t in valid_build_types: - if t.lower() == value.lower(): + if t.lower().replace("-", "") == value.lower().replace("-", ""): + if t == "DebugFast": + value = "DebugFast" setattr(self.options, name, t) return value print(f"Invalid build type '{value}'. Must be one of: {', '.join(valid_build_types)}.") raise ValueError(f"Invalid build type '{value}'. Must be one of: {', '.join(valid_build_types)}.") def prompt_sanitizer_option(self, name): - value = self.prompt_option(name) + value = self.prompt_option(name, "Sanitizer (asan/ubsan/msan/tsan/none)") if not value: value = '' return value @@ -405,7 +600,7 @@ def prompt_sanitizer_option(self, name): setattr(self.options, name, t) return value print(f"Invalid sanitizer '{value}'. Must be one of: {', '.join(valid_sanitizers)}.") - value = self.reprompt_option(name) + value = self.reprompt_option(name, "Sanitizer (asan/ubsan/msan/tsan/none)") for t in valid_sanitizers: if t.lower() == value.lower(): setattr(self.options, name, t) @@ -414,31 +609,130 @@ def prompt_sanitizer_option(self, name): raise ValueError(f"Invalid sanitizer '{value}'. Must be one of: {', '.join(valid_sanitizers)}.") def supports_ansi(self): - if os.name == "posix": - return True - if os.name == "nt": - # Windows 10+ with VT enabled, or running in Windows Terminal - return "WT_SESSION" in os.environ or sys.stdout.isatty() - return False + return bool(self.ui.color_enabled) - def run_cmd(self, cmd, cwd=None): + def run_cmd(self, cmd, cwd=None, tail=False): """ - Runs a shell command in the specified directory, printing the command in blue if supported. + Runs a shell command in the specified directory. + When tail=True, only the last line of live output is shown (npm-style), + while the full output is buffered and displayed only on failure. """ - BLUE = "\033[94m" - RESET = "\033[0m" if cwd is None: cwd = os.getcwd() - color = BLUE if self.supports_ansi() else "" - reset = RESET if self.supports_ansi() else "" + display_cwd = self.ui.shorten_path(cwd) if cwd else os.getcwd() if isinstance(cmd, list): cmd_str = ' '.join(shlex.quote(arg) for arg in cmd) - print(f"{color}{cwd}> {cmd_str}{reset}") else: - print(f"{color}{cwd}> {cmd}{reset}") - r = subprocess.run(cmd, shell=isinstance(cmd, str), check=True, cwd=cwd, env=self.env) - if r.returncode != 0: - raise RuntimeError(f"Command '{cmd}' failed with return code {r.returncode}.") + cmd_str = cmd + # Always show the command with cwd for transparency + self.ui.command(f"{display_cwd}> {cmd_str}", icon="💻") + if self.options.dry_run: + self.ui.info("dry-run: command not executed") + return + # Favor parallel builds unless user already set it + effective_env = (self.env or os.environ).copy() + if "CMAKE_BUILD_PARALLEL_LEVEL" not in effective_env: + try: + effective_env["CMAKE_BUILD_PARALLEL_LEVEL"] = str(max(1, os.cpu_count() or 1)) + except Exception: + effective_env["CMAKE_BUILD_PARALLEL_LEVEL"] = "4" + if not tail: + try: + r = subprocess.run(cmd, shell=isinstance(cmd, str), check=True, cwd=cwd, env=effective_env) + except subprocess.CalledProcessError as exc: + if self.options.debug: + raise + tips = [ + f"Working dir: {self.ui.shorten_path(cwd)}", + ] + if not self.options.verbose: + tips.append("Re-run with --verbose for full output") + self.ui.error_block(f"Command failed: {exc}", tips) + raise RuntimeError(f"Command '{cmd}' failed. Re-run with --debug for traceback.") from None + if r.returncode != 0: + raise RuntimeError(f"Command '{cmd}' failed with return code {r.returncode}.") + return + + # tail == True: stream output but only show the last line live + output_lines: List[str] = [] + try: + proc = subprocess.Popen( + cmd, + shell=isinstance(cmd, str), + cwd=cwd, + env=effective_env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + universal_newlines=True, + ) + except Exception as exc: # noqa: BLE001 + raise RuntimeError(f"Failed to launch command '{cmd}': {exc}") from None + + try: + term_width = shutil.get_terminal_size(fallback=(80, 24)).columns or 80 + prev_height = 0 + if proc.stdout: + for line in proc.stdout: + line = line.rstrip("\r\n") + output_lines.append(line + "\n") + # compute how many terminal rows this line will wrap to + visible = line + height = max(1, math.ceil(len(visible) / term_width)) + # move cursor up to start of previous render and clear those rows + if prev_height: + sys.stdout.write(f"\x1b[{prev_height}F") + for _ in range(prev_height): + sys.stdout.write("\x1b[2K\x1b[1E") + sys.stdout.write(f"\x1b[{prev_height}F") + # render current line (letting terminal wrap naturally) + sys.stdout.write("\x1b[2K" + line + "\n") + sys.stdout.flush() + prev_height = height + proc.wait() + finally: + if proc.stdout: + proc.stdout.close() + + if output_lines: + # Ensure cursor ends on a clean line after the last render + sys.stdout.write("\x1b[2K") + sys.stdout.flush() + + if proc.returncode != 0: + # On failure, show the full buffered output + if not self.options.verbose: + self.ui.error_block( + f"Command failed: {cmd}", + ["Working dir: " + self.ui.shorten_path(cwd or os.getcwd())], + ) + print("".join(output_lines), end="") + raise RuntimeError(f"Command '{cmd}' failed with return code {proc.returncode}.") + + def ensure_dir(self, path, exist_ok=True): + if self.options.dry_run: + self.ui.info(f"dry-run: would create directory {path}") + return + os.makedirs(path, exist_ok=exist_ok) + + def remove_dir(self, path): + if not os.path.exists(path): + return + if self.options.dry_run: + self.ui.info(f"dry-run: would remove directory {path}") + return + shutil.rmtree(path, ignore_errors=True) + + def write_text(self, path, content, encoding="utf-8"): + if self.options.dry_run: + self.ui.info(f"dry-run: would write file {path}") + return + parent = os.path.dirname(path) + if parent: + os.makedirs(parent, exist_ok=True) + with open(path, "w", encoding=encoding) as f: + f.write(content) def clone_repo(self, repo, dest, branch=None, depth=None): """ @@ -464,12 +758,17 @@ def download_file(self, url, dest): :param dest: The destination file path where the file will be saved. :return: None """ + if self.options.dry_run: + self.ui.info(f" 📥 dry-run: would download {self.ui.maybe_shorten(url)} -> {self.ui.shorten_path(dest)}") + return if os.path.exists(dest): - print(f"File {dest} already exists. Skipping download.") + self.ui.info(f"File {self.ui.shorten_path(dest)} already exists. Skipping download.") return # Ensure the destination directory exists - os.makedirs(os.path.dirname(dest), exist_ok=True) - print(f"Downloading {url} to {dest}") + self.ensure_dir(os.path.dirname(dest)) + self.ui.info(f" 📥 Downloading") + self.ui.kv("🌐 url", self.ui.maybe_shorten(url), key_width=9) + self.ui.kv("📁 dest", self.ui.shorten_path(dest), key_width=9) urllib.request.urlretrieve(url, dest) def is_windows(self): @@ -494,27 +793,27 @@ def is_macos(self): return os.name == "posix" and sys.platform.startswith("darwin") def cmake_workflow(self, src_dir, build_type, build_dir, install_dir, extra_args=None, cc_flags=None, - cxx_flags=None, force_rebuild=False, remove_build_dir=True): + cxx_flags=None, force_rebuild=False, remove_build_dir=True, allow_skip=True): """ Configures and builds a CMake project. """ # Check if we can skip the build - if self.is_non_empty_dir(install_dir): - if force_rebuild or self.prompt_option("force_rebuild"): + if allow_skip and self.is_non_empty_dir(install_dir): + if force_rebuild or self.prompt_option("force_rebuild", "Force rebuild deps"): print(f"Force rebuild requested. Removing existing install directory {install_dir}.") - shutil.rmtree(install_dir, ignore_errors=True) + self.remove_dir(install_dir) if remove_build_dir and self.is_non_empty_dir(build_dir): print(f"Removing existing build directory {build_dir}.") - shutil.rmtree(build_dir, ignore_errors=True) + self.remove_dir(build_dir) else: print(f"Install directory {install_dir} already exists and is not empty. Skipping build.") return if remove_build_dir and force_rebuild and self.is_non_empty_dir(build_dir): - shutil.rmtree(build_dir, ignore_errors=True) + self.remove_dir(build_dir) if self.is_non_empty_dir(install_dir): - shutil.rmtree(install_dir, ignore_errors=True) + self.remove_dir(install_dir) # Adjust any potential CMake flags from extra_args if cc_flags is None: @@ -568,10 +867,10 @@ def cmake_workflow(self, src_dir, build_type, build_dir, install_dir, extra_args config_args.extend(["-DGit_ROOT=" + os.path.dirname(self.options.git_path)]) # Maybe adjust build type based on the options for the main project - if not self.is_abi_compatible(self.options.mrdocs_build_type, build_type): + if not self.is_abi_compatible(self.options.build_type, build_type): print( - f"Warning: The build type '{build_type}' is not ABI compatible with the MrDocs build type '{self.options.mrdocs_build_type}'.") - if self.options.mrdocs_build_type.lower() == "debug": + f"Warning: The build type '{build_type}' is not ABI compatible with the MrDocs build type '{self.options.build_type}'.") + if self.options.build_type.lower() in ("debug", "debugfast", "debug-fast"): # User asked for Release dependency, so we do the best we can and change it to # an optimized debug build. print("Changing build type to 'OptimizedDebug' for ABI compatibility.") @@ -580,8 +879,8 @@ def cmake_workflow(self, src_dir, build_type, build_dir, install_dir, extra_args # User asked for a Debug dependency with Release build type for MrDocs. # The dependency should just copy the release type here. Other options wouldn't make sense # because we can't even debug it. - print(f"Changing build type to '{self.options.mrdocs_build_type}' for ABI compatibility.") - build_type = self.options.mrdocs_build_type + print(f"Changing build type to '{self.options.build_type}' for ABI compatibility.") + build_type = self.options.build_type # "OptimizedDebug" is not a valid build type. We interpret it as a special case # where the build type is Debug and optimizations are enabled. @@ -604,65 +903,39 @@ def cmake_workflow(self, src_dir, build_type, build_dir, install_dir, extra_args else: raise TypeError(f"extra_args must be a list, got {type(extra_args)}.") - if self.is_homebrew_clang(): - homebrew_clang_root = os.path.dirname(os.path.dirname(self.options.cxx)) - ar_path = os.path.join(homebrew_clang_root, "bin", "llvm-ar") - if self.is_executable(ar_path): - for cxx_flag_var in [ - "CMAKE_AR", - "CMAKE_CXX_COMPILER_AR", - "CMAKE_C_COMPILER_AR" - ]: - config_args.append(f"-D{cxx_flag_var}={ar_path}") - runlib_path = os.path.join(homebrew_clang_root, "bin", "llvm-ranlib") - if self.is_executable(runlib_path): - config_args.append(f"-DCMAKE_RANLIB={runlib_path}") - ld_path = os.path.join(homebrew_clang_root, "bin", "ld.lld") - if self.is_executable(ld_path): - config_args.append(f"-DCMAKE_C_COMPILER_LINKER={ld_path}") - config_args.append(f"-DCMAKE_CXX_COMPILER_LINKER={ld_path}") - else: - ld_path = '/opt/homebrew/bin/ld.lld' - if self.is_executable(ld_path): - config_args.append(f"-DCMAKE_C_COMPILER_LINKER={ld_path}") - config_args.append(f"-DCMAKE_CXX_COMPILER_LINKER={ld_path}") - libcxx_include = os.path.join(homebrew_clang_root, "include", "c++", "v1") - libcxx_lib = os.path.join(homebrew_clang_root, "lib", "c++") - libunwind = os.path.join(homebrew_clang_root, "lib", "unwind") - if os.path.exists(libcxx_include) and os.path.exists(libcxx_lib) and os.path.exists(libunwind): - cxx_flags += f' -stdlib=libc++ -I{libcxx_include}' - ld_flags = f'-L{libcxx_lib} -L{libunwind} -lunwind' - if self.options.sanitizer: - ld_flags += f' -fsanitize={self.sanitizer_flag_name(self.options.sanitizer)}' - for cxx_linker_flag_var in [ - "CMAKE_EXE_LINKER_FLAGS", - "CMAKE_SHARED_LINKER_FLAGS", - "CMAKE_MODULE_LINKER_FLAGS" - ]: - config_args.append(f"-D{cxx_linker_flag_var}={ld_flags}") + cc_flags, cxx_flags = self._inject_clang_toolchain_flags(config_args, cc_flags, cxx_flags) if cc_flags: config_args.append(f"-DCMAKE_C_FLAGS={cc_flags.strip()}") if cxx_flags: config_args.append(f"-DCMAKE_CXX_FLAGS={cxx_flags.strip()}") - if not self.is_non_empty_dir(build_dir) or force_rebuild: - self.run_cmd(config_args) - - build_args = [self.options.cmake_path, "--build", build_dir, "--config", cmake_build_type] - num_cores = os.cpu_count() or 1 - max_safe_parallel = 4 # Ideally 4GB per job - build_args.extend(["--parallel", str(min(num_cores, max_safe_parallel))]) - self.run_cmd(build_args) + cache_file = os.path.join(build_dir, "CMakeCache.txt") + # Decide expected build file based on generator (default to Ninja if available) + expected_build_file = os.path.join(build_dir, "build.ninja") + gen = "ninja" if self.options.ninja_path else self.compiler_info.get("CMAKE_GENERATOR", "").lower() + if "ninja" not in gen: + expected_build_file = os.path.join(build_dir, "Makefile") + needs_configure = force_rebuild or not (os.path.isfile(cache_file) and os.path.exists(expected_build_file)) + if needs_configure: + # Configure step can be verbose; show last line live + self.run_cmd(config_args, tail=True) + + # Always build; CMake will noop if nothing to do. + build_args = [self.options.cmake_path, "--build", build_dir, "--config", cmake_build_type] + # Use all available cores unless caller overrides via env/flags + parallel_level = max(1, os.cpu_count() or 1) + build_args.extend(["--parallel", str(parallel_level)]) + self.run_cmd(build_args, tail=True) install_args = [self.options.cmake_path, "--install", build_dir] if install_dir: install_args.extend(["--prefix", install_dir]) if cmake_build_type: install_args.extend(["--config", cmake_build_type]) - self.run_cmd(install_args) - if remove_build_dir and self.prompt_option('remove_build_dir'): + self.run_cmd(install_args, tail=True) + if remove_build_dir and self.prompt_option('remove_build_dir', 'Remove dep build dir'): print(f"Installation complete. Removing build directory {build_dir}.") - shutil.rmtree(build_dir, ignore_errors=True) + self.remove_dir(build_dir) def is_executable(self, path): if not os.path.exists(path): @@ -814,13 +1087,13 @@ def check_tool(self, tool): if not default_value: default_value = tool setattr(self.default_options, f"{tool}_path", default_value) - tool_path = self.prompt_option(f"{tool}_path") + tool_path = self.prompt_option(f"{tool}_path", tool) if not self.is_executable(tool_path): raise FileNotFoundError(f"{tool} executable not found at {tool_path}.") def check_compilers(self): for option in ["cc", "cxx"]: - self.prompt_option(option) + self.prompt_option(option, option.replace("_", " ")) if getattr(self.options, option): if not os.path.isabs(getattr(self.options, option)): exec = shutil.which(getattr(self.options, option)) @@ -836,28 +1109,15 @@ def check_tools(self): for tool in tools: self.check_tool(tool) - def setup_mrdocs_src_dir(self): - self.prompt_option("mrdocs_src_dir") - if not os.path.isabs(self.options.mrdocs_src_dir): - self.options.mrdocs_src_dir = os.path.abspath(self.options.mrdocs_src_dir) - if not os.path.exists(self.options.mrdocs_src_dir): - if not self.prompt_boolean( - f"Source directory '{self.options.mrdocs_src_dir}' does not exist. Create and clone MrDocs there?", - True): - print("Installation aborted by user.") - exit(1) - self.prompt_option("mrdocs_branch") - self.prompt_option("mrdocs_repo") - self.clone_repo(self.options.mrdocs_repo, self.options.mrdocs_src_dir, branch=self.options.mrdocs_branch) - else: - if not os.path.isdir(self.options.mrdocs_src_dir): - raise NotADirectoryError( - f"Specified mrdocs_src_dir '{self.options.mrdocs_src_dir}' is not a directory.") - + def setup_source_dir(self): + # Source dir is fixed to the repository containing this script; no prompts. + self.options.source_dir = os.path.dirname(os.path.abspath(__file__)) + if not os.path.isdir(self.options.source_dir): + raise NotADirectoryError(f"Source dir '{self.options.source_dir}' is not a directory.") # MrDocs build type - self.prompt_build_type_option("mrdocs_build_type") + self.prompt_build_type_option("build_type") self.prompt_sanitizer_option("sanitizer") - if self.prompt_option("mrdocs_build_tests"): + if self.prompt_option("build_tests", "Build tests"): self.check_tool("java") def is_inside_mrdocs_dir(self, path): @@ -866,15 +1126,15 @@ def is_inside_mrdocs_dir(self, path): :param path: The path to check. :return: bool: True if the path is inside the MrDocs source directory, False otherwise. """ - return os.path.commonpath([self.options.mrdocs_src_dir, path]) == self.options.mrdocs_src_dir + return os.path.commonpath([self.options.source_dir, path]) == self.options.source_dir - def prompt_dependency_path_option(self, name): + def prompt_dependency_path_option(self, name, prompt_text): """ Prompts the user for a dependency path option, ensuring it is not inside the MrDocs source directory. :param name: The name of the option to prompt for. :return: The value of the option after prompting the user. """ - self.prompt_option(name) + self.prompt_option(name, prompt_text) value = getattr(self.options, name) value = os.path.abspath(value) setattr(self.options, name, value) @@ -884,8 +1144,8 @@ def prompt_dependency_path_option(self, name): return value def setup_third_party_dir(self): - self.prompt_dependency_path_option("third_party_src_dir") - os.makedirs(self.options.third_party_src_dir, exist_ok=True) + self.prompt_dependency_path_option("third_party_src_dir", "3rd-party root (src/build/install)") + self.ensure_dir(self.options.third_party_src_dir) @lru_cache(maxsize=1) def probe_compilers(self): @@ -898,9 +1158,12 @@ def probe_compilers(self): variables.append("CMAKE_GENERATOR") probe_dir = os.path.join(self.options.third_party_src_dir, "cmake-probe") + if self.options.dry_run: + self.ui.info("dry-run: would probe compilers via CMake") + return if os.path.exists(probe_dir): - shutil.rmtree(probe_dir) - os.makedirs(probe_dir, exist_ok=True) + self.remove_dir(probe_dir) + self.ensure_dir(probe_dir) # Create minimal CMakeLists.txt cmake_lists = [ @@ -909,8 +1172,7 @@ def probe_compilers(self): ] for var in variables: cmake_lists.append(f'message(STATUS "{var}=${{{var}}}")') - with open(os.path.join(probe_dir, "CMakeLists.txt"), "w") as f: - f.write("\n".join(cmake_lists)) + self.write_text(os.path.join(probe_dir, "CMakeLists.txt"), "\n".join(cmake_lists)) # Build command cmd = [self.options.cmake_path, "-S", probe_dir] @@ -939,13 +1201,518 @@ def probe_compilers(self): self.compiler_info = values # Clean up probe directory - shutil.rmtree(probe_dir) + self.remove_dir(probe_dir) # Print default C++ compiler path print( f"Default C++ compiler: {self.compiler_info.get('CMAKE_CXX_COMPILER_ID', 'unknown')} ({self.compiler_info.get('CMAKE_CXX_COMPILER', 'unknown')})") + + # -------------------------- + # Recipe-driven dependencies + # -------------------------- + def build_archive_url(self, url, ref): + """ + For GitHub URLs, return an archive download URL for a commit or tag. + """ + if "github.com" not in url or not ref: + return None + # strip .git and trailing slash + clean = url + if clean.endswith(".git"): + clean = clean[:-4] + clean = clean.rstrip("/") + parts = clean.split("github.com/", 1)[1].split("/") + if len(parts) < 2: + return None + owner, repo = parts[0], parts[1] + return f"https://github.com/{owner}/{repo}/archive/{ref}.zip" + + def extract_zip_flatten(self, zip_path, dest_dir): + if self.options.dry_run: + self.ui.info(f"dry-run: would extract {zip_path} into {dest_dir}") + return + with zipfile.ZipFile(zip_path, 'r') as zf: + infos = zf.infolist() + # determine top-level prefix + prefix = None + for info in infos: + name = info.filename + if name.endswith("/"): + continue + parts = name.split("/", 1) + if len(parts) == 2: + prefix = parts[0] + "/" + break + if prefix is None: + prefix = "" + for info in infos: + name = info.filename + if name.endswith("/"): + continue + rel = name[len(prefix):] if name.startswith(prefix) else name + target_path = os.path.join(dest_dir, rel) + target_dir = os.path.dirname(target_path) + self.ensure_dir(target_dir) + with zf.open(info, 'r') as src, open(target_path, 'wb') as dst: + shutil.copyfileobj(src, dst) + + def extract_tar_flatten(self, tar_path, dest_dir): + if self.options.dry_run: + self.ui.info(f"dry-run: would extract {tar_path} into {dest_dir}") + return + mode = "r:*" + with tarfile.open(tar_path, mode) as tf: + # determine top-level prefix + prefix = None + for member in tf.getmembers(): + parts = member.name.split("/", 1) + if len(parts) == 2: + prefix = parts[0] + "/" + break + if prefix is None: + prefix = "" + for member in tf.getmembers(): + if member.isdir(): + continue + rel = member.name[len(prefix):] if member.name.startswith(prefix) else member.name + target_path = os.path.join(dest_dir, rel) + self.ensure_dir(os.path.dirname(target_path)) + with tf.extractfile(member) as src, open(target_path, "wb") as dst: + shutil.copyfileobj(src, dst) + + def recipe_stamp_path(self, recipe: Recipe): + return os.path.join(recipe.install_dir, ".bootstrap-stamp.json") + + def is_recipe_up_to_date(self, recipe: Recipe, resolved_ref: str): + stamp_path = self.recipe_stamp_path(recipe) + if not os.path.exists(stamp_path): + return False + try: + data = json.loads(open(stamp_path, "r", encoding="utf-8").read()) + except Exception: + return False + return data.get("version") == recipe.version and data.get("ref") == resolved_ref + + def write_recipe_stamp(self, recipe: Recipe, resolved_ref: str): + if self.options.dry_run: + self.ui.info(f"dry-run: would write stamp for {recipe.name} at {self.recipe_stamp_path(recipe)}") + return + payload = { + "name": recipe.name, + "version": recipe.version, + "ref": resolved_ref, + } + self.ensure_dir(recipe.install_dir) + self.write_text(self.recipe_stamp_path(recipe), json.dumps(payload, indent=2)) + + def fetch_recipe_source(self, recipe: Recipe): + src = recipe.source + dest = recipe.source_dir + archive_url = None + resolved_ref = src.commit or src.tag or src.branch or src.ref or "" + + if self.options.clean and os.path.exists(dest): + self.remove_dir(dest) + if not self.options.force and self.is_recipe_up_to_date(recipe, resolved_ref): + self.ui.ok(f"[{recipe.name}] already up to date ({resolved_ref or 'HEAD'}).") + return resolved_ref + # If source already exists and we're not forcing or cleaning, skip re-download + if os.path.isdir(dest) and not self.options.clean and not self.options.force: + self.ui.info(f"{recipe.name}: source already present at {self.ui.shorten_path(dest)}; skipping download.") + return resolved_ref or "HEAD" + + if src.type == "git": + archive_url = self.build_archive_url(src.url, src.commit or src.tag or src.ref) + elif src.type in ("archive", "http", "zip"): + archive_url = src.url + + if archive_url: + filename = os.path.basename(archive_url.split("?")[0]) + tmp_archive = os.path.join(self.options.source_dir, "build", "third-party", "source", filename) + self.download_file(archive_url, tmp_archive) + if not self.options.dry_run and os.path.exists(dest): + self.remove_dir(dest) + self.ensure_dir(dest) + if not self.options.dry_run: + if archive_url.endswith(".zip"): + self.extract_zip_flatten(tmp_archive, dest) + else: + self.extract_tar_flatten(tmp_archive, dest) + os.remove(tmp_archive) + else: + # fallback to git + depth = ["--depth", str(src.depth)] if src.depth else [] + if not os.path.exists(dest): + self.ensure_dir(os.path.dirname(dest)) + clone_cmd = [self.options.git_path or "git", "clone", src.url, dest, *depth] + if src.branch and not src.commit: + clone_cmd.extend(["--branch", src.branch]) + self.run_cmd(clone_cmd) + if resolved_ref: + self.run_cmd([self.options.git_path or "git", "fetch", "--tags"], cwd=dest) + self.run_cmd([self.options.git_path or "git", "checkout", resolved_ref], cwd=dest) + else: + self.run_cmd([self.options.git_path or "git", "pull", "--ff-only"], cwd=dest) + + return resolved_ref or "HEAD" + + def apply_recipe_patches(self, recipe: Recipe): + patch_root = os.path.join(self.patches_dir, recipe.name) + if not os.path.isdir(patch_root): + return + entries = sorted(os.listdir(patch_root)) + for entry in entries: + path = os.path.join(patch_root, entry) + if entry.endswith(".patch"): + self.ui.info(f"Applying patch {path}") + self.run_cmd(["patch", "-p1", "-i", path], cwd=recipe.source_dir) + else: + target = os.path.join(recipe.source_dir, entry) + if os.path.isdir(path): + if self.options.dry_run: + self.ui.info(f"dry-run: would copy directory {path} -> {target}") + else: + shutil.copytree(path, target, dirs_exist_ok=True) + else: + if self.options.dry_run: + self.ui.info(f"dry-run: would copy file {path} -> {target}") + else: + self.ensure_dir(os.path.dirname(target)) + shutil.copy(path, target) + + def _expand_path(self, template: str, build_type: str): + if not template: + return template + mrdocs = self.options.source_dir + third = self.options.third_party_src_dir + build_lower = build_type.lower() if build_type else "" + repl = { + "${source_dir}": mrdocs, + "${third_party_src_dir}": third, + "${build_type}": build_type, + "${build_type_lower}": build_lower, + } + out = template + for k, v in repl.items(): + out = out.replace(k, v) + if not os.path.isabs(out): + out = os.path.normpath(os.path.join(mrdocs, out)) + return out + + def load_recipe_files(self) -> List[Recipe]: + recipes_dir = self.recipes_dir + patches_dir = self.patches_dir + if not os.path.isdir(recipes_dir): + return [] + # For debug-fast, dependencies reuse release (or optimized debug on Windows) builds/presets. + dep_build_type = "OptimizedDebug" if self.is_windows() else "Release" + dep_preset = self.options.preset + if self.options.build_type.lower() in ("debugfast", "debug-fast"): + if "debug-fast" in dep_preset: + dep_preset = dep_preset.replace("debug-fast", dep_build_type.lower()) + elif "debugfast" in dep_preset: + dep_preset = dep_preset.replace("debugfast", dep_build_type.lower()) + recipes: List[Recipe] = [] + for path in sorted(os.listdir(recipes_dir)): + if not path.endswith(".json"): + continue + full = os.path.join(recipes_dir, path) + try: + data = json.load(open(full, "r", encoding="utf-8")) + except Exception as exc: + self.ui.warn(f"Skipping recipe {path}: {exc}") + continue + src = data.get("source", {}) + recipe = Recipe( + name=data.get("name") or os.path.splitext(path)[0], + version=str(data.get("version", "")), + source=RecipeSource( + type=src.get("type", "git"), + url=src.get("url", ""), + branch=src.get("branch"), + tag=src.get("tag"), + commit=src.get("commit"), + ref=src.get("ref"), + depth=src.get("depth"), + submodules=bool(src.get("submodules", False)), + ), + dependencies=data.get("dependencies", []), + source_dir=data.get("source_dir", ""), + build_dir=data.get("build_dir", ""), + install_dir=data.get("install_dir", ""), + build_type=data.get("build_type", "Release"), + build=data.get("build", []), + tags=data.get("tags", []), + package_root_var=data.get("package_root_var"), + install_scope=data.get("install_scope", "per-preset"), + ) + placeholders = self._recipe_placeholders(recipe) + + # Apply placeholders to source reference fields + recipe.source.url = self._apply_placeholders(recipe.source.url, placeholders) + recipe.source.branch = self._apply_placeholders(recipe.source.branch, placeholders) + recipe.source.tag = self._apply_placeholders(recipe.source.tag, placeholders) + recipe.source.commit = self._apply_placeholders(recipe.source.commit, placeholders) + recipe.source.ref = self._apply_placeholders(recipe.source.ref, placeholders) + + # Paths are controlled by bootstrap, not the recipe file. + tp_root = os.path.join(self.options.source_dir, "build", "third-party") + preset = dep_preset + if recipe.install_scope == "global": + recipe.source_dir = os.path.join(tp_root, "source", recipe.name) + recipe.build_dir = os.path.join(tp_root, "build", recipe.name) + recipe.install_dir = os.path.join(tp_root, "install", recipe.name) + else: + recipe.source_dir = os.path.join(tp_root, "source", recipe.name) + recipe.build_dir = os.path.join(tp_root, "build", preset, recipe.name) + recipe.install_dir = os.path.join(tp_root, "install", preset, recipe.name) + recipes.append(recipe) + return recipes + + def _topo_sort_recipes(self, recipes: List[Recipe]) -> List[Recipe]: + by_name = {r.name: r for r in recipes} + visited: Dict[str, bool] = {} + order: List[Recipe] = [] + + def visit(name, stack): + state = visited.get(name) + if state is True: + return + if state is False: + raise RuntimeError(f"Dependency cycle: {' -> '.join(stack + [name])}") + visited[name] = False + stack.append(name) + for dep in by_name[name].dependencies: + if dep not in by_name: + raise RuntimeError(f"Missing dependency recipe '{dep}' needed by '{name}'") + visit(dep, stack) + visited[name] = True + stack.pop() + order.append(by_name[name]) + + for n in by_name: + if visited.get(n) is not True: + visit(n, []) + return order + + def _recipe_placeholders(self, recipe: Recipe) -> Dict[str, str]: + host_suffix = "windows" if self.is_windows() else "unix" + return { + "BOOTSTRAP_BUILD_TYPE": recipe.build_type, + "BOOTSTRAP_BUILD_TYPE_LOWER": recipe.build_type.lower(), + "BOOTSTRAP_CONFIGURE_PRESET": self.options.preset, + "BOOTSTRAP_CC": self.options.cc or "", + "BOOTSTRAP_CXX": self.options.cxx or "", + "BOOTSTRAP_PROJECT_BUILD_DIR": self.options.build_dir, + "BOOTSTRAP_PROJECT_INSTALL_DIR": self.options.install_dir, + "BOOTSTRAP_HOST_PRESET_SUFFIX": host_suffix, + "build_type": recipe.build_type, + "build_type_lower": recipe.build_type.lower(), + } + + def _apply_placeholders(self, value: Any, placeholders: Dict[str, str]) -> Any: + if isinstance(value, str): + for k, v in placeholders.items(): + value = value.replace("${" + k + "}", v) + return value + if isinstance(value, list): + return [self._apply_placeholders(v, placeholders) for v in value] + if isinstance(value, dict): + return {self._apply_placeholders(k, placeholders): self._apply_placeholders(v, placeholders) for k, v in value.items()} + return value + + def _run_cmake_recipe_step(self, recipe: Recipe, step: Dict[str, Any]): + cmake_exe = shutil.which("cmake") + if not cmake_exe: + raise RuntimeError("cmake executable not found in PATH.") + placeholders = self._recipe_placeholders(recipe) + opts = self._apply_placeholders(step.get("options", []), placeholders) + build_dir = self._expand_path(step.get("build_dir", recipe.build_dir), recipe.build_type) + source_dir = self._expand_path(step.get("source_dir", recipe.source_dir), recipe.build_type) + source_subdir = step.get("source_subdir") + if source_subdir: + source_dir = os.path.join(source_dir, self._apply_placeholders(source_subdir, placeholders)) + generator = step.get("generator") + config = self._apply_placeholders(step.get("config", recipe.build_type), placeholders) + targets = self._apply_placeholders(step.get("targets", []), placeholders) + install_flag = step.get("install", True) + + # Optional sanitizer-specific options declared in the recipe + san_map = step.get("sanitizers", {}) + if self.options.sanitizer: + san = self.options.sanitizer.lower() + if san_map: + extra = san_map.get(san) + if extra is None: + raise ValueError(f"Unknown sanitizer '{self.options.sanitizer}' for recipe '{recipe.name}'.") + extra_opts = self._apply_placeholders(extra, placeholders) + if isinstance(extra_opts, list): + opts.extend(extra_opts) + else: + opts.append(extra_opts) + else: + # Fallback: apply typical compiler sanitizer flags (data-driven by compiler/sanitizer) + if self.is_windows(): + msvc_flags = { + "asan": "/fsanitize=address", + } + flag = msvc_flags.get(san) + else: + posix_flags = { + "asan": "-fsanitize=address", + "ubsan": "-fsanitize=undefined", + "msan": "-fsanitize=memory", + "tsan": "-fsanitize=thread", + } + flag = posix_flags.get(san) + + if flag: + # Initialize build/link flags; use *_FLAGS_INIT to avoid clobbering cache if present + opts.extend([ + f"-DCMAKE_C_FLAGS_INIT={flag}", + f"-DCMAKE_CXX_FLAGS_INIT={flag}", + f"-DCMAKE_EXE_LINKER_FLAGS_INIT={flag}", + f"-DCMAKE_SHARED_LINKER_FLAGS_INIT={flag}", + ]) + + self.ensure_dir(build_dir) + cfg_cmd = [cmake_exe, "-S", source_dir, "-B", build_dir] + if generator: + cfg_cmd.extend(["-G", generator]) + cfg_cmd.append(f"-DCMAKE_BUILD_TYPE={config}") + cfg_cmd.append(f"-DCMAKE_INSTALL_PREFIX={recipe.install_dir}") + if self.options.cc: + cfg_cmd.append(f"-DCMAKE_C_COMPILER={self.options.cc}") + if self.options.cxx: + cfg_cmd.append(f"-DCMAKE_CXX_COMPILER={self.options.cxx}") + cfg_cmd.extend(opts) + # Configure step can be chatty; use tail view + self.run_cmd(cfg_cmd, tail=True) + + build_cmd = [cmake_exe, "--build", build_dir] + if config: + build_cmd.extend(["--config", config]) + if targets: + build_cmd.extend(["--target", *targets]) + # Use available cores unless caller specified parallelism via env/flags + if "--parallel" not in build_cmd: + try: + parallel_level = max(1, os.cpu_count() or 1) + build_cmd.extend(["--parallel", str(parallel_level)]) + except Exception: + pass + if self.options.force: + build_cmd.extend(["--clean-first"]) + self.run_cmd(build_cmd, tail=True) + + if install_flag: + inst_cmd = [cmake_exe, "--install", build_dir] + if config: + inst_cmd.extend(["--config", config]) + self.run_cmd(inst_cmd, tail=True) + + def _run_command_recipe_step(self, recipe: Recipe, step: Dict[str, Any]): + placeholders = self._recipe_placeholders(recipe) + command = self._apply_placeholders(step.get("command", []), placeholders) + cwd = step.get("cwd") + if cwd: + cwd = self._expand_path(self._apply_placeholders(cwd, placeholders), recipe.build_type) + env = step.get("env") + if env: + env = {k: self._apply_placeholders(v, placeholders) for k, v in env.items()} + env.update(self.env or {}) + self.run_cmd(command, cwd=cwd) + + def build_recipe(self, recipe: Recipe): + for raw_step in (recipe.build or []): + step_type = raw_step.get("type", "").lower() + if step_type == "cmake": + self._run_cmake_recipe_step(recipe, raw_step) + elif step_type == "command": + self._run_command_recipe_step(recipe, raw_step) + else: + raise RuntimeError(f"Unsupported build step type '{step_type}' in recipe '{recipe.name}'") + + def install_recipes(self): + recipe_list = self.load_recipe_files() + if not recipe_list: + raise RuntimeError(f"No recipes found in {self.recipes_dir}. Add recipe JSON files to proceed.") + + if self.options.recipe_filter: + wanted = {name.strip().lower() for name in self.options.recipe_filter.split(",") if name.strip()} + recipe_list = [r for r in recipe_list if r.name.lower() in wanted] + + ordered = self._topo_sort_recipes(recipe_list) + + def detect_root_var(recipe: Recipe) -> Optional[str]: + # Prefer an inferred name from installed *Config.cmake (matches actual package case) + cfg_name = None + for dirpath, _, filenames in os.walk(recipe.install_dir): + for fn in filenames: + if fn.endswith("Config.cmake"): + cfg_name = fn[:-len("Config.cmake")] + break + if cfg_name: + break + if cfg_name: + return f"{cfg_name}_ROOT" + # Fallback to recipe hint if no config found + if recipe.package_root_var: + return recipe.package_root_var + return None + + for recipe in ordered: + resolved_ref = self.fetch_recipe_source(recipe) + self.apply_recipe_patches(recipe) + # Track recipe metadata + self.recipe_info[recipe.name] = recipe + root_var = detect_root_var(recipe) + if root_var: + self.package_roots[root_var] = recipe.install_dir + if self.options.skip_build: + continue + if self.is_recipe_up_to_date(recipe, resolved_ref) and not self.options.force: + self.ui.ok(f"[{recipe.name}] up to date; skipping build.") + continue + self.build_recipe(recipe) + self.write_recipe_stamp(recipe, resolved_ref) + root_var = detect_root_var(recipe) + if root_var: + self.package_roots[root_var] = recipe.install_dir + print(f"Default C++ build system: {self.compiler_info.get('CMAKE_GENERATOR', 'unknown')}") + def show_preset_summary(self): + """Display key details of the selected CMake user preset.""" + path = os.path.join(self.options.source_dir, "CMakeUserPresets.json") + try: + data = json.load(open(path, "r", encoding="utf-8")) + except Exception as exc: + self.ui.warn(f"Could not read {self.ui.shorten_path(path)}: {exc}") + return + preset = None + for p in data.get("configurePresets", []): + if p.get("name") == self.options.preset: + preset = p + break + if not preset: + self.ui.warn(f"Preset '{self.options.preset}' not found in {self.ui.shorten_path(path)}") + return + cache = preset.get("cacheVariables", {}) + roots = {k: v for k, v in cache.items() if k.endswith("_ROOT")} + summary = [ + ("Preset file", self.ui.shorten_path(path)), + ("Preset name", preset.get("name", "")), + ("Generator", preset.get("generator", "")), + ("Binary dir", preset.get("binaryDir", "")), + ] + if roots: + for k, v in sorted(roots.items()): + summary.append((k, v)) + if "CMAKE_MAKE_PROGRAM" in cache: + summary.append(("CMAKE_MAKE_PROGRAM", cache["CMAKE_MAKE_PROGRAM"])) + self.ui.kv_block(None, summary, indent=4) + @lru_cache(maxsize=1) def probe_msvc_dev_env(self): if not self.is_windows(): @@ -1001,22 +1768,61 @@ def probe_msvc_dev_env(self): self.env[key] = value print("MSVC development environment variables extracted successfully.") - @lru_cache(maxsize=1) - def is_homebrew_clang(self): + def _inject_clang_toolchain_flags(self, config_args: List[str], cc_flags: str, cxx_flags: str): + """ + For clang/LLVM toolchains, prefer colocated binutils/linker/libc++ if available. + Works for Homebrew or any LLVM install that keeps tools together. + """ self.probe_compilers() - if not self.is_macos(): - return False - if not self.compiler_info: - return False - if self.compiler_info["CMAKE_CXX_COMPILER_ID"].lower() != "clang": - return False - out = subprocess.run([self.options.cxx, "--version"], capture_output=True, text=True) - version = out.stdout.strip() - return "Homebrew clang" in version + compiler_id = self.compiler_info.get("CMAKE_CXX_COMPILER_ID", "").lower() + if compiler_id not in ("clang", "appleclang"): + return cc_flags, cxx_flags + + cxx_path = self.options.cxx or self.compiler_info.get("CMAKE_CXX_COMPILER", "") + if not cxx_path: + return cc_flags, cxx_flags + + tool_root = os.path.abspath(os.path.join(os.path.dirname(cxx_path), os.pardir)) + bin_dir = os.path.join(tool_root, "bin") + + def maybe_append(flag_var, tool_name): + tool_path = os.path.join(bin_dir, tool_name) + if self.is_executable(tool_path): + config_args.append(f"-D{flag_var}={tool_path}") + + for var, tool in [ + ("CMAKE_AR", "llvm-ar"), + ("CMAKE_CXX_COMPILER_AR", "llvm-ar"), + ("CMAKE_C_COMPILER_AR", "llvm-ar"), + ("CMAKE_RANLIB", "llvm-ranlib"), + ]: + maybe_append(var, tool) + + for linker in ["ld.lld", "lld"]: + ld_path = os.path.join(bin_dir, linker) + if self.is_executable(ld_path): + config_args.append(f"-DCMAKE_C_COMPILER_LINKER={ld_path}") + config_args.append(f"-DCMAKE_CXX_COMPILER_LINKER={ld_path}") + break + + libcxx_include = os.path.join(tool_root, "include", "c++", "v1") + libcxx_lib = os.path.join(tool_root, "lib", "c++") + libunwind = os.path.join(tool_root, "lib", "unwind") + if os.path.exists(libcxx_include) and os.path.exists(libcxx_lib): + cxx_flags += f" -stdlib=libc++ -I{libcxx_include}" + ld_flags = f"-L{libcxx_lib}" + if os.path.exists(libunwind): + ld_flags += f" -L{libunwind} -lunwind" + if self.options.sanitizer: + ld_flags += f" -fsanitize={self.sanitizer_flag_name(self.options.sanitizer)}" + for var in ["CMAKE_EXE_LINKER_FLAGS", "CMAKE_SHARED_LINKER_FLAGS", "CMAKE_MODULE_LINKER_FLAGS"]: + config_args.append(f"-D{var}={ld_flags}") + + return cc_flags, cxx_flags def install_ninja(self): # 1. Check if the user has set a ninja_path option - if self.prompt_option("ninja_path"): + if self.prompt_option("ninja_path", "ninja"): if not os.path.isabs(self.options.ninja_path): self.options.ninja_path = self.find_tool(self.options.ninja_path) if not self.is_executable(self.options.ninja_path): @@ -1026,17 +1832,26 @@ def install_ninja(self): # 2. If ninja_path is not set, but does the user have it available in PATH? ninja_path = self.find_tool("ninja") if ninja_path: - print(f"Ninja found in PATH at {ninja_path}. Using it.") + self.ui.info(f"Ninja found in PATH at {ninja_path}. Using it.") self.options.ninja_path = ninja_path return # 3. Ninja path isn't set and not available in PATH, so we download it - destination_dir = self.options.third_party_src_dir - ninja_dir = os.path.join(destination_dir, "ninja") + tp_root = os.path.join(self.options.source_dir, "build", "third-party") + source_dir = os.path.join(tp_root, "source", "ninja") + install_dir = os.path.join(tp_root, "install", self.options.preset, "ninja") + self.ensure_dir(source_dir) + self.ensure_dir(install_dir) + ninja_dir = install_dir exe_name = 'ninja.exe' if platform.system().lower() == 'windows' else 'ninja' ninja_path = os.path.join(ninja_dir, exe_name) if os.path.exists(ninja_path) and self.is_executable(ninja_path): - print(f"Ninja already exists at {ninja_path}. Using it.") + try: + rel = os.path.relpath(ninja_path, self.options.source_dir) + display_path = "./" + rel if not rel.startswith("..") else ninja_path + except Exception: + display_path = ninja_path + self.ui.ok(f"[ninja] already available at {display_path}; reusing.") self.options.ninja_path = ninja_path return @@ -1058,8 +1873,13 @@ def install_ninja(self): else: return + destination_dir = source_dir # 3b. Find the download URL for the latest Ninja release asset api_url = 'https://api.github.com/repos/ninja-build/ninja/releases/latest' + if self.options.dry_run: + self.options.ninja_path = ninja_path + self.ui.info(f"dry-run: would fetch {api_url} and download {asset_name} -> {destination_dir}") + return with urllib.request.urlopen(api_url) as resp: data = json.load(resp) release_assets = data.get('assets', []) @@ -1074,13 +1894,13 @@ def install_ninja(self): # 3c. Download the asset to the third-party source directory tmpzip = os.path.join(destination_dir, asset_name) - os.makedirs(destination_dir, exist_ok=True) + self.ensure_dir(destination_dir) print(f'Downloading {asset_name} …') urllib.request.urlretrieve(download_url, tmpzip) # 3d. Extract the downloaded zip file into the ninja dir print('Extracting…') - os.makedirs(ninja_dir, exist_ok=True) + self.ensure_dir(ninja_dir) with zipfile.ZipFile(tmpzip, 'r') as z: z.extractall(ninja_dir) os.remove(tmpzip) @@ -1111,248 +1931,28 @@ def is_abi_compatible(self, build_type_a, build_type_b): if not self.is_windows(): return True # On Windows, Debug and Release builds are not ABI compatible - build_type_a_is_debug = build_type_a.lower() == "debug" - build_type_b_is_debug = build_type_b.lower() == "debug" + def _is_debug(bt): + return bt.lower() in ("debug", "debugfast", "debug-fast", "optimizeddebug") + build_type_a_is_debug = _is_debug(build_type_a) + build_type_b_is_debug = _is_debug(build_type_b) return build_type_a_is_debug == build_type_b_is_debug - def install_duktape(self): - self.prompt_dependency_path_option("duktape_src_dir") - if not os.path.exists(self.options.duktape_src_dir): - self.prompt_option("duktape_url") - archive_filename = os.path.basename(self.options.duktape_url) - archive_path = os.path.join(self.options.third_party_src_dir, archive_filename) - self.download_file(self.options.duktape_url, archive_path) - with tarfile.open(archive_path, "r:xz") as tar: - top_level = tar.getnames()[0].split('/')[0] - for member in tar.getmembers(): - # Remove the top-level directory from the path - member_path = os.path.relpath(member.name, top_level) - if member_path == '.': - continue - member.name = member_path - tar.extract(member, path=self.options.duktape_src_dir) - os.remove(archive_path) - duktape_patches = os.path.join(self.options.mrdocs_src_dir, 'third-party', 'duktape') - if os.path.exists(duktape_patches): - for patch_file in os.listdir(duktape_patches): - patch_path = os.path.join(duktape_patches, patch_file) - shutil.copy(patch_path, self.options.duktape_src_dir) - duk_config_path = os.path.join(self.options.duktape_src_dir, "src", "duk_config.h") - if os.path.exists(duk_config_path): - with open(duk_config_path, "r") as f: - content = f.read() - new_content = content.replace("#define DUK_F_DLL_BUILD", "#undef DUK_F_DLL_BUILD") - if new_content != content: - with open(duk_config_path, "w") as f: - f.write(new_content) - else: - print(f"Warning: {duk_config_path} does not exist. Skipping patch.") - self.prompt_build_type_option("duktape_build_type") - if not self.is_abi_compatible(self.options.mrdocs_build_type, self.options.duktape_build_type): - if self.options.mrdocs_build_type.lower() == "debug": - # User asked for Release dependency, so we do the best we can and change it to - # an optimized debug build. - self.options.duktape_build_type = "OptimizedDebug" - else: - # User asked for a Debug dependency with Release build type for MrDocs. - # The dependency should just copy the release type here. Other options wouldn't make sense - # because we can't even debug it. - self.options.duktape_build_type = self.options.mrdocs_build_type - self.prompt_dependency_path_option("duktape_build_dir") - self.prompt_dependency_path_option("duktape_install_dir") - extra_args = [] - if self.options.sanitizer: - flag_name = self.sanitizer_flag_name(self.options.sanitizer) - for arg in ["CMAKE_C_FLAGS", "CMAKE_CXX_FLAGS"]: - extra_args.append( - f"-D{arg}=-fsanitize={flag_name} -fno-sanitize-recover={flag_name} -fno-omit-frame-pointer") - - self.cmake_workflow( - self.options.duktape_src_dir, - self.options.duktape_build_type, - self.options.duktape_build_dir, - self.options.duktape_install_dir, - extra_args) - - def install_lua(self): - # Resolve paths/values - self.prompt_dependency_path_option("lua_src_dir") - if not os.path.exists(self.options.lua_src_dir): - self.prompt_option("lua_url") - os.makedirs(self.options.lua_src_dir, exist_ok=True) - archive_filename = os.path.basename(self.options.lua_url) - archive_path = os.path.join(self.options.third_party_src_dir, archive_filename) - self.download_file(self.options.lua_url, archive_path) - - # Extract lua-5.4.8.tar.gz, flatten top-level dir into lua_src_dir - mode = "r:gz" if archive_filename.endswith(".gz") else "r:*" - with tarfile.open(archive_path, mode) as tar: - top_level = tar.getmembers()[0].name.split('/')[0] - for member in tar.getmembers(): - rel = os.path.relpath(member.name, top_level) - if rel == '.' or rel.startswith('..'): - continue - member.name = rel - tar.extract(member, path=self.options.lua_src_dir) - os.remove(archive_path) - - # Copy our tiny CMake patch files (like we do for Duktape) - lua_patches = os.path.join(self.options.mrdocs_src_dir, 'third-party', 'lua') - if os.path.exists(lua_patches): - for fname in os.listdir(lua_patches): - src = os.path.join(lua_patches, fname) - dst = os.path.join(self.options.lua_src_dir, fname) - shutil.copy(src, dst) - - # Lua’s own tree puts sources under src/; our CMakeLists handles that. - self.prompt_build_type_option("lua_build_type") - # align ABI expectations like we do for Duktape: - if not self.is_abi_compatible(self.options.mrdocs_build_type, self.options.lua_build_type): - if self.options.mrdocs_build_type.lower() == "debug": - self.options.lua_build_type = "OptimizedDebug" - else: - self.options.lua_build_type = self.options.mrdocs_build_type - - self.prompt_dependency_path_option("lua_build_dir") - self.prompt_dependency_path_option("lua_install_dir") - - extra_args = [] - if self.options.sanitizer: - flag = self.sanitizer_flag_name(self.options.sanitizer) - for arg in ("CMAKE_C_FLAGS", "CMAKE_CXX_FLAGS"): - extra_args.append(f"-D{arg}=-fsanitize={flag} -fno-sanitize-recover={flag} -fno-omit-frame-pointer") - - # Standard cmake_workflow like Duktape - self.cmake_workflow( - self.options.lua_src_dir, - self.options.lua_build_type, - self.options.lua_build_dir, - self.options.lua_install_dir, - extra_args - ) - - def install_libxml2(self): - self.prompt_dependency_path_option("libxml2_src_dir") - if not os.path.exists(self.options.libxml2_src_dir): - self.prompt_option("libxml2_repo") - self.prompt_option("libxml2_branch") - self.clone_repo(self.options.libxml2_repo, self.options.libxml2_src_dir, branch=self.options.libxml2_branch, - depth=1) - self.prompt_build_type_option("libxml2_build_type") - self.prompt_dependency_path_option("libxml2_build_dir") - self.prompt_dependency_path_option("libxml2_install_dir") - extra_args = [ - "-DBUILD_SHARED_LIBS=OFF", - "-DLIBXML2_WITH_PROGRAMS=ON", - "-DLIBXML2_WITH_FTP=OFF", - "-DLIBXML2_WITH_HTTP=OFF", - "-DLIBXML2_WITH_ICONV=OFF", - "-DLIBXML2_WITH_LEGACY=OFF", - "-DLIBXML2_WITH_LZMA=OFF", - "-DLIBXML2_WITH_ZLIB=OFF", - "-DLIBXML2_WITH_ICU=OFF", - "-DLIBXML2_WITH_TESTS=OFF", - "-DLIBXML2_WITH_HTML=ON", - "-DLIBXML2_WITH_C14N=ON", - "-DLIBXML2_WITH_CATALOG=ON", - "-DLIBXML2_WITH_DEBUG=ON", - "-DLIBXML2_WITH_ISO8859X=ON", - "-DLIBXML2_WITH_MEM_DEBUG=OFF", - "-DLIBXML2_WITH_MODULES=ON", - "-DLIBXML2_WITH_OUTPUT=ON", - "-DLIBXML2_WITH_PATTERN=ON", - "-DLIBXML2_WITH_PUSH=ON", - "-DLIBXML2_WITH_PYTHON=OFF", - "-DLIBXML2_WITH_READER=ON", - "-DLIBXML2_WITH_REGEXPS=ON", - "-DLIBXML2_WITH_SAX1=ON", - "-DLIBXML2_WITH_SCHEMAS=ON", - "-DLIBXML2_WITH_SCHEMATRON=ON", - "-DLIBXML2_WITH_THREADS=ON", - "-DLIBXML2_WITH_THREAD_ALLOC=OFF", - "-DLIBXML2_WITH_TREE=ON", - "-DLIBXML2_WITH_VALID=ON", - "-DLIBXML2_WITH_WRITER=ON", - "-DLIBXML2_WITH_XINCLUDE=ON", - "-DLIBXML2_WITH_XPATH=ON", - "-DLIBXML2_WITH_XPTR=ON" - ] - self.cmake_workflow(self.options.libxml2_src_dir, self.options.libxml2_build_type, - self.options.libxml2_build_dir, self.options.libxml2_install_dir, extra_args) - - def install_llvm(self): - self.prompt_dependency_path_option("llvm_src_dir") - if not os.path.exists(self.options.llvm_src_dir): - self.prompt_option("llvm_repo") - self.prompt_option("llvm_commit") - os.makedirs(self.options.llvm_src_dir, exist_ok=True) - self.run_cmd([self.options.git_path, "init"], self.options.llvm_src_dir) - self.run_cmd([self.options.git_path, "remote", "add", "origin", self.options.llvm_repo], - self.options.llvm_src_dir) - self.run_cmd([self.options.git_path, "fetch", "--depth", "1", "origin", self.options.llvm_commit], - self.options.llvm_src_dir) - self.run_cmd([self.options.git_path, "checkout", "FETCH_HEAD"], self.options.llvm_src_dir) - - llvm_subproject_dir = os.path.join(self.options.llvm_src_dir, "llvm") - llvm_patches = os.path.join(self.options.mrdocs_src_dir, 'third-party', 'llvm') - if os.path.exists(llvm_patches): - for patch_file in os.listdir(llvm_patches): - patch_path = os.path.join(llvm_patches, patch_file) - shutil.copy(patch_path, llvm_subproject_dir) - self.prompt_build_type_option("llvm_build_type") - # Same logic as for Duktape - if not self.is_abi_compatible(self.options.mrdocs_build_type, self.options.llvm_build_type): - if self.options.mrdocs_build_type.lower() == "debug": - self.options.llvm_build_type = "OptimizedDebug" - else: - self.options.llvm_build_type = self.options.mrdocs_build_type - self.prompt_dependency_path_option("llvm_build_dir") - self.prompt_dependency_path_option("llvm_install_dir") - cmake_preset = f"{self.options.llvm_build_type.lower()}-win" if self.is_windows() else f"{self.options.llvm_build_type.lower()}-unix" - cmake_extra_args = [f"--preset={cmake_preset}"] - if self.options.sanitizer: - if self.options.sanitizer.lower() == "asan": - cmake_extra_args.append("-DLLVM_USE_SANITIZER=Address") - elif self.options.sanitizer.lower() == "ubsan": - cmake_extra_args.append("-DLLVM_USE_SANITIZER=Undefined") - elif self.options.sanitizer.lower() == "msan": - cmake_extra_args.append("-DLLVM_USE_SANITIZER=Memory") - elif self.options.sanitizer.lower() == "tsan": - cmake_extra_args.append("-DLLVM_USE_SANITIZER=Thread") - else: - raise ValueError(f"Unknown LLVM sanitizer '{self.options.sanitizer}'.") - self.cmake_workflow( - llvm_subproject_dir, - self.options.llvm_build_type, - self.options.llvm_build_dir, - self.options.llvm_install_dir, - cmake_extra_args) def create_cmake_presets(self): - # Ask the user if they want to create CMake User presets referencing the installed dependencies - # Otherwise, we skip this step and pass the directories as command line arguments to the CMake build command - if not self.prompt_option("mrdocs_use_user_presets"): - print("Skipping CMake User presets creation as per user preference.") - return - - # If they choose to create presets, we either generate or update the CMakeUserPresets.json file - user_presets_path = os.path.join(self.options.mrdocs_src_dir, "CMakeUserPresets.json") - if not os.path.exists(user_presets_path): - user_presets_example_path = os.path.join(self.options.mrdocs_src_dir, "CMakeUserPresets.json.example") - if not os.path.exists(user_presets_example_path): - raise FileNotFoundError(f"Cannot find CMakeUserPresets.json.example in {self.options.mrdocs_src_dir}.") - shutil.copy(user_presets_example_path, user_presets_path) - - # Now that we know the file exists, we can read it and update the paths - # Read the file as json - with open(user_presets_path, "r") as f: - user_presets = json.load(f) + # Generate or update CMakeUserPresets.json directly + user_presets_path = os.path.join(self.options.source_dir, "CMakeUserPresets.json") + if os.path.exists(user_presets_path): + with open(user_presets_path, "r") as f: + user_presets = json.load(f) + else: + user_presets = { + "version": 6, + "cmakeMinimumRequired": {"major": 3, "minor": 21, "patch": 0}, + "configurePresets": [] + } # Come up with a nice user preset name - is_debug_fast = self.options.mrdocs_build_type.lower() == "debug" and self.options.llvm_build_type != self.options.mrdocs_build_type - if is_debug_fast: - self.default_options.mrdocs_preset_name += "-fast" - self.prompt_option("mrdocs_preset_name") + self.prompt_option("preset", "CMake preset") # Upsert the preset in the "configurePresets" array of objects # If preset with the same name already exists, we update it @@ -1369,15 +1969,16 @@ def create_cmake_presets(self): # Preset inherits from the parent preset based on the build type parent_preset_name = "debug" - if self.options.mrdocs_build_type.lower() != "debug": + bt_lower = self.options.build_type.lower() + if bt_lower not in ("debug", "debugfast", "debug-fast"): parent_preset_name = "release" - if self.options.mrdocs_build_type.lower() == "relwithdebinfo": + if bt_lower == "relwithdebinfo": parent_preset_name = "relwithdebinfo" # Nice display name for the preset - display_name = f"{self.options.mrdocs_build_type}" - if self.options.mrdocs_build_type.lower() == "debug" and self.options.llvm_build_type != self.options.mrdocs_build_type: - display_name += " with Optimized Dependencies" + display_name = f"{self.options.build_type}" + if bt_lower in ("debugfast", "debug-fast"): + display_name = "Debug (fast)" display_name += f" ({OSDisplayName}" if self.options.cc: display_name += f": {os.path.basename(self.options.cc)}" @@ -1392,28 +1993,41 @@ def create_cmake_presets(self): elif "CMAKE_GENERATOR" in self.compiler_info: generator = self.compiler_info["CMAKE_GENERATOR"] + main_cmake_build_type = "Debug" if self.options.build_type.lower() in ("debugfast", "debug-fast") else self.options.build_type + cache_vars = { + "CMAKE_BUILD_TYPE": main_cmake_build_type, + "MRDOCS_BUILD_DOCS": False, + "MRDOCS_GENERATE_REFERENCE": False, + "MRDOCS_GENERATE_ANTORA_REFERENCE": False + } + # Rebuild package roots strictly from recipe metadata for this run + self.package_roots = {} + for rec in self.recipe_info.values(): + if rec.package_root_var: + self.package_roots[rec.package_root_var] = rec.install_dir + + # Only set explicit *_ROOT cache variables; avoid CMAKE_PREFIX_PATH to prevent regex issues + dedup_roots: Dict[str, Tuple[str, str]] = {} + for var, path in self.package_roots.items(): + k = var.lower() + # Prefer lowercase variable name if both forms exist + if k not in dedup_roots or var.islower(): + dedup_roots[k] = (var, path) + # rewrite package_roots to the deduped, preferred casing + self.package_roots = {var: path for (_, (var, path)) in dedup_roots.items()} + for var, path in self.package_roots.items(): + cache_vars[var] = path + # Ensure no stray prefix path sneaks in + cache_vars.pop("CMAKE_PREFIX_PATH", None) + new_preset = { - "name": self.options.mrdocs_preset_name, + "name": self.options.preset, "generator": generator, "displayName": display_name, - "description": f"Preset for building MrDocs in {self.options.mrdocs_build_type} mode with the {os.path.basename(self.options.cc) if self.options.cc else 'default'} compiler in {OSDisplayName}.", + "description": f"Preset for building MrDocs in {self.options.build_type} mode with the {os.path.basename(self.options.cc) if self.options.cc else 'default'} compiler in {OSDisplayName}.", "inherits": parent_preset_name, "binaryDir": "${sourceDir}/build/${presetName}", - "cacheVariables": { - "CMAKE_BUILD_TYPE": self.options.mrdocs_build_type, - "LLVM_ROOT": self.options.llvm_install_dir, - "Clang_ROOT": self.options.llvm_install_dir, - "duktape_ROOT": self.options.duktape_install_dir, - "Duktape_ROOT": self.options.duktape_install_dir, - "libxml2_ROOT": self.options.libxml2_install_dir, - "LibXml2_ROOT": self.options.libxml2_install_dir, - "LUA_ROOT": self.options.lua_install_dir, - "Lua_ROOT": self.options.lua_install_dir, - "lua_ROOT": self.options.lua_install_dir, - "MRDOCS_BUILD_DOCS": False, - "MRDOCS_GENERATE_REFERENCE": False, - "MRDOCS_GENERATE_ANTORA_REFERENCE": False - }, + "cacheVariables": cache_vars, "warnings": { "unusedCli": False }, @@ -1433,6 +2047,7 @@ def create_cmake_presets(self): new_preset["cacheVariables"]["CMAKE_CXX_COMPILER"] = self.options.cxx if self.options.ninja_path: new_preset["cacheVariables"]["CMAKE_MAKE_PROGRAM"] = self.options.ninja_path + new_preset["generator"] = "Ninja" cc_flags = '' cxx_flags = '' @@ -1441,43 +2056,12 @@ def create_cmake_presets(self): cc_flags = f"-fsanitize={flag_name} -fno-sanitize-recover={flag_name} -fno-omit-frame-pointer" cxx_flags = f"-fsanitize={flag_name} -fno-sanitize-recover={flag_name} -fno-omit-frame-pointer" - if self.is_homebrew_clang(): - homebrew_clang_root = os.path.dirname(os.path.dirname(self.options.cxx)) - ar_path = os.path.join(homebrew_clang_root, "bin", "llvm-ar") - if self.is_executable(ar_path): - for cxx_flag_var in [ - "CMAKE_AR", - "CMAKE_CXX_COMPILER_AR", - "CMAKE_C_COMPILER_AR" - ]: - new_preset["cacheVariables"][cxx_flag_var] = ar_path - runlib_path = os.path.join(homebrew_clang_root, "bin", "llvm-ranlib") - if self.is_executable(runlib_path): - new_preset["cacheVariables"]['CMAKE_RANLIB'] = runlib_path - ld_path = os.path.join(homebrew_clang_root, "bin", "ld.lld") - if self.is_executable(ld_path): - new_preset["cacheVariables"]['CMAKE_C_COMPILER_LINKER'] = ld_path - new_preset["cacheVariables"]['CMAKE_CXX_COMPILER_LINKER'] = ld_path - else: - ld_path = '/opt/homebrew/bin/ld.lld' - if self.is_executable(ld_path): - new_preset["cacheVariables"]['CMAKE_C_COMPILER_LINKER'] = ld_path - new_preset["cacheVariables"]['CMAKE_CXX_COMPILER_LINKER'] = ld_path - libcxx_include = os.path.join(homebrew_clang_root, "include", "c++", "v1") - libcxx_lib = os.path.join(homebrew_clang_root, "lib", "c++") - libunwind = os.path.join(homebrew_clang_root, "lib", "unwind") - if os.path.exists(libcxx_include) and os.path.exists(libcxx_lib) and os.path.exists(libunwind): - cxx_flags += f' -stdlib=libc++ -I{libcxx_include}' - ld_flags = f'-L{libcxx_lib} -L{libunwind} -lunwind' - if self.options.sanitizer: - ld_flags += f' -fsanitize={self.sanitizer_flag_name(self.options.sanitizer)}' - for cxx_linker_flag_var in [ - "CMAKE_EXE_LINKER_FLAGS", - "CMAKE_SHARED_LINKER_FLAGS", - "CMAKE_MODULE_LINKER_FLAGS" - ]: - new_preset["cacheVariables"][cxx_linker_flag_var] = ld_flags - + cache_config_args: List[str] = [] + cc_flags, cxx_flags = self._inject_clang_toolchain_flags(cache_config_args, cc_flags, cxx_flags) + for arg in cache_config_args: + key, value = arg.split("=", 1) + key = key.replace("-D", "", 1) + new_preset["cacheVariables"][key] = value if cc_flags: new_preset["cacheVariables"]['CMAKE_C_FLAGS'] = cc_flags.strip() if cxx_flags: @@ -1486,7 +2070,7 @@ def create_cmake_presets(self): # if build type is debug and compiler is clang (default macos or explicitly clang), # add "CMAKE_CXX_FLAGS": "-D_LIBCPP_HARDENING_MODE=_LIBCPP_HARDENING_MODE_EXTENSIVE" # or append it to existing CMAKE_CXX_FLAGS - if self.options.mrdocs_build_type.lower() == "debug": + if self.options.build_type.lower() == "debug": is_clang = False if self.options.cxx and "clang" in os.path.basename(self.options.cxx).lower(): is_clang = True @@ -1515,31 +2099,36 @@ def create_cmake_presets(self): } } - # Update cache variables path prefixes with their relative equivalents - mrdocs_src_dir_parent = os.path.dirname(self.options.mrdocs_src_dir) - if mrdocs_src_dir_parent == self.options.mrdocs_src_dir: - mrdocs_src_dir_parent = '' + # Update cache variables path prefixes with their relative equivalents (semicolon-safe) + source_dir_parent = os.path.dirname(self.options.source_dir) + if source_dir_parent == self.options.source_dir: + source_dir_parent = '' home_dir = os.path.expanduser("~") - for key, value in new_preset["cacheVariables"].items(): - if not isinstance(value, str): - continue - # Replace mrdocs-src-dir with ${sourceDir} - if self.options.mrdocs_src_dir and value.startswith(self.options.mrdocs_src_dir): - new_value = "${sourceDir}" + value[len(self.options.mrdocs_src_dir):] - new_preset["cacheVariables"][key] = new_value - # Replace mrdocs-src-dir parent with ${sourceParentDir} - elif mrdocs_src_dir_parent and value.startswith(mrdocs_src_dir_parent): - new_value = "${sourceParentDir}" + value[len(mrdocs_src_dir_parent):] - new_preset["cacheVariables"][key] = new_value - # Replace $HOME with $env{HOME} - elif home_dir and value.startswith(home_dir): - new_value = "$env{HOME}" + value[len(home_dir):] - new_preset["cacheVariables"][key] = new_value + + def normalize_value(val: str) -> str: + if not isinstance(val, str): + return val + parts = val.split(";") + out_parts = [] + for part in parts: + p = part + if self.options.source_dir and p.startswith(self.options.source_dir): + p = "${sourceDir}" + p[len(self.options.source_dir):] + elif source_dir_parent and p.startswith(source_dir_parent): + p = "${sourceParentDir}" + p[len(source_dir_parent):] + elif home_dir and p.startswith(home_dir): + p = "$env{HOME}" + p[len(home_dir):] + out_parts.append(p) + return ";".join(out_parts) + + for key, value in list(new_preset["cacheVariables"].items()): + if isinstance(value, str): + new_preset["cacheVariables"][key] = normalize_value(value) # Upsert preset preset_exists = False for preset in user_presets.get("configurePresets", []): - if preset.get("name") == self.options.mrdocs_preset_name: + if preset.get("name") == self.options.preset: preset_exists = True # Update the existing preset preset.update(new_preset) @@ -1549,8 +2138,7 @@ def create_cmake_presets(self): user_presets.setdefault("configurePresets", []).append(new_preset) # Write the updated presets back to the file - with open(user_presets_path, "w") as f: - json.dump(user_presets, f, indent=4) + self.write_text(user_presets_path, json.dumps(user_presets, indent=4)) def _git_symlink_entries(self, repo_dir): """ @@ -1596,13 +2184,20 @@ def _make_symlink_or_fallback(self, file_path, intended_target, repo_dir): Falls back to hardlink/copy on Windows if symlinks aren’t permitted. Returns: 'symlink' | 'hardlink' | 'copy' """ + if self.options.dry_run: + self.ui.info(f"dry-run: would ensure symlink {file_path} -> {intended_target}") + return "dry-run" + parent = os.path.dirname(file_path) if parent and not os.path.isdir(parent): - os.makedirs(parent, exist_ok=True) + self.ensure_dir(parent) # Remove existing non-symlink file if os.path.exists(file_path) and not os.path.islink(file_path): - os.remove(file_path) + if self.options.dry_run: + self.ui.info(f"dry-run: would remove file {file_path}") + else: + os.remove(file_path) # Git stores POSIX-style link text; translate to native separators for the OS call native_target = intended_target.replace("/", os.sep) @@ -1615,9 +2210,15 @@ def _make_symlink_or_fallback(self, file_path, intended_target, repo_dir): try: # On Windows, target_is_directory must be correct for directory links if os.name == "nt": - os.symlink(native_target, file_path, target_is_directory=target_is_dir) + if self.options.dry_run: + self.ui.info(f"dry-run: would create symlink {file_path} -> {native_target}") + else: + os.symlink(native_target, file_path, target_is_directory=target_is_dir) else: - os.symlink(native_target, file_path) + if self.options.dry_run: + self.ui.info(f"dry-run: would create symlink {file_path} -> {native_target}") + else: + os.symlink(native_target, file_path) return "symlink" except (NotImplementedError, OSError, PermissionError): pass @@ -1625,19 +2226,24 @@ def _make_symlink_or_fallback(self, file_path, intended_target, repo_dir): # Fallback: hardlink (files only, same volume) try: if os.path.isfile(resolved_target): - os.link(resolved_target, file_path) + if self.options.dry_run: + self.ui.info(f"dry-run: would create hardlink {file_path} -> {resolved_target}") + else: + os.link(resolved_target, file_path) return "hardlink" except OSError: pass # Last resort: copy the file contents if it exists if os.path.isfile(resolved_target): - shutil.copyfile(resolved_target, file_path) + if self.options.dry_run: + self.ui.info(f"dry-run: would copy {resolved_target} -> {file_path}") + else: + shutil.copyfile(resolved_target, file_path) return "copy" # If the target doesn’t exist in WT, write the intended link text so state is explicit - with open(file_path, "w", encoding="utf-8") as f: - f.write(intended_target) + self.write_text(file_path, intended_target, encoding="utf-8") return "copy" def _is_git_repo(self, repo_dir): @@ -1704,54 +2310,26 @@ def check_git_symlinks(self, repo_dir): ) def install_mrdocs(self): - self.check_git_symlinks(self.options.mrdocs_src_dir) - - if not self.options.mrdocs_use_user_presets: - self.prompt_option("mrdocs_build_dir") - else: - self.options.mrdocs_build_dir = os.path.join(self.options.mrdocs_src_dir, "build", - self.options.mrdocs_preset_name) - self.default_options.mrdocs_build_dir = self.options.mrdocs_build_dir + self.check_git_symlinks(self.options.source_dir) - if not self.prompt_option("mrdocs_system_install"): - if self.options.mrdocs_use_user_presets: - self.default_options.mrdocs_install_dir = os.path.join(self.options.mrdocs_src_dir, "install", - self.options.mrdocs_preset_name) - self.prompt_option("mrdocs_install_dir") + # build_dir/install_dir already collected; ensure they are set relative to preset if empty + if not self.options.build_dir: + self.options.build_dir = os.path.join(self.options.source_dir, "build", self.options.preset) + if not self.options.system_install and not self.options.install_dir: + self.options.install_dir = os.path.join(self.options.source_dir, "install", self.options.preset) extra_args = [] - if not self.options.mrdocs_system_install and self.options.mrdocs_install_dir: - extra_args.extend(["-D", f"CMAKE_INSTALL_PREFIX={self.options.mrdocs_install_dir}"]) + if not self.options.system_install and self.options.install_dir: + extra_args.extend(["-D", f"CMAKE_INSTALL_PREFIX={self.options.install_dir}"]) - if self.options.mrdocs_use_user_presets: - extra_args.append(f"--preset={self.options.mrdocs_preset_name}") - else: - # If not using user presets, we pass the directories as command line arguments - extra_args.extend([ - "-D", f"LLVM_ROOT={self.options.llvm_install_dir}", - "-D", f"Clang_ROOT={self.options.llvm_install_dir}", - "-D", f"duktape_ROOT={self.options.duktape_install_dir}", - "-D", f"Duktape_ROOT={self.options.duktape_install_dir}", - ]) - if self.options.mrdocs_build_tests: - extra_args.extend([ - "-D", f"libxml2_ROOT={self.options.libxml2_install_dir}", - "-D", f"LibXml2_ROOT={self.options.libxml2_install_dir}" - ]) - extra_args.extend(["-D", "MRDOCS_BUILD_TESTS=ON"]) - extra_args.extend(["-DMRDOCS_BUILD_DOCS=OFF", "-DMRDOCS_GENERATE_REFERENCE=OFF", - "-DMRDOCS_GENERATE_ANTORA_REFERENCE=OFF"]) - - if self.options.sanitizer: - flag_name = self.sanitizer_flag_name(self.options.sanitizer) - for arg in ["CMAKE_C_FLAGS", "CMAKE_CXX_FLAGS"]: - extra_args.append( - f"-D{arg}=-fsanitize={flag_name} -fno-sanitize-recover={flag_name} -fno-omit-frame-pointer") + extra_args.append(f"--preset={self.options.preset}") - self.cmake_workflow(self.options.mrdocs_src_dir, self.options.mrdocs_build_type, self.options.mrdocs_build_dir, - self.options.mrdocs_install_dir, extra_args, force_rebuild=False, remove_build_dir=False) + main_build_type = "Debug" if self.options.build_type.lower() in ("debugfast", "debug-fast") else self.options.build_type + self.cmake_workflow(self.options.source_dir, main_build_type, self.options.build_dir, + self.options.install_dir, extra_args, force_rebuild=False, + remove_build_dir=False, allow_skip=False) - if self.options.mrdocs_build_dir and self.prompt_option("mrdocs_run_tests"): + if self.options.build_dir and self.prompt_option("run_tests", "Run tests after build"): # Look for ctest path relative to the cmake path ctest_path = os.path.join(os.path.dirname(self.options.cmake_path), "ctest") if self.is_windows(): @@ -1759,36 +2337,27 @@ def install_mrdocs(self): if not os.path.exists(ctest_path): raise FileNotFoundError( f"ctest executable not found at {ctest_path}. Please ensure CMake is installed correctly.") - test_args = [ctest_path, "--test-dir", self.options.mrdocs_build_dir, "--output-on-failure", "--progress", + test_args = [ctest_path, "--test-dir", self.options.build_dir, "--output-on-failure", "--progress", "--no-tests=error", "--output-on-failure", "--parallel", str(os.cpu_count() or 1)] self.run_cmd(test_args) - YELLOW = "\033[93m" - RESET = "\033[0m" - if self.supports_ansi(): - print(f"{YELLOW}MrDocs has been successfully installed in {self.options.mrdocs_install_dir}.{RESET}") - else: - print(f"\nMrDocs has been successfully installed in {self.options.mrdocs_install_dir}.\n") + self.ui.ok(f"MrDocs has been successfully installed in {self.options.install_dir}.") @lru_cache(maxsize=1) - def find_latest_clang_include_dir(self): - parent = os.path.join(self.options.llvm_install_dir, "lib", "clang") - subdirs = [d for d in os.listdir(parent) if os.path.isdir(os.path.join(parent, d))] - numeric_subdirs = [d for d in subdirs if d.isdigit()] - if not numeric_subdirs: - raise RuntimeError(f"No numeric directories found in {parent}") - latest_numeric_subdir = max(numeric_subdirs, key=lambda d: int(d)) - - return os.path.join(parent, latest_numeric_subdir, "include") + def libxml2_root_dir(self): + for key, path in self.package_roots.items(): + if "libxml2" in key.lower(): + return path + return None def generate_clion_run_configs(self, configs): import xml.etree.ElementTree as ET # Generate CLion run configurations for MrDocs - # For each configuration, we create an XML file in /.run + # For each configuration, we create an XML file in /.run # named .run.xml - run_dir = os.path.join(self.options.mrdocs_src_dir, ".run") - os.makedirs(run_dir, exist_ok=True) + run_dir = os.path.join(self.options.source_dir, ".run") + self.ensure_dir(run_dir) for config in configs: config_name = config["name"] run_config_path = os.path.join(run_dir, f"{config_name}.run.xml") @@ -1807,7 +2376,7 @@ def generate_clion_run_configs(self, configs): "PASS_PARENT_ENVS_2": "true", "PROJECT_NAME": "MrDocs", "TARGET_NAME": config["target"], - "CONFIG_NAME": self.options.mrdocs_preset_name or "debug", + "CONFIG_NAME": self.options.preset or "debug", "RUN_TARGET_PROJECT_NAME": "MrDocs", "RUN_TARGET_NAME": config["target"] } @@ -1841,7 +2410,7 @@ def generate_clion_run_configs(self, configs): envs = ET.SubElement(clion_config, "envs") ET.SubElement(envs, "env", name="PYTHONUNBUFFERED", value="1") ET.SubElement(clion_config, "option", name="SDK_HOME", value="") - if 'cwd' in config and config["cwd"] != self.options.mrdocs_src_dir: + if 'cwd' in config and config["cwd"] != self.options.source_dir: ET.SubElement(clion_config, "option", name="WORKING_DIRECTORY", value=config["cwd"]) else: ET.SubElement(clion_config, "option", name="WORKING_DIRECTORY", value="$PROJECT_DIR$") @@ -1872,7 +2441,7 @@ def generate_clion_run_configs(self, configs): ET.SubElement(clion_config, "option", name="SCRIPT_PATH", value=config["script"]) ET.SubElement(clion_config, "option", name="SCRIPT_OPTIONS", value="") ET.SubElement(clion_config, "option", name="INDEPENDENT_SCRIPT_WORKING_DIRECTORY", value="true") - if 'cwd' in config and config["cwd"] != self.options.mrdocs_src_dir: + if 'cwd' in config and config["cwd"] != self.options.source_dir: ET.SubElement(clion_config, "option", name="SCRIPT_WORKING_DIRECTORY", value=config["cwd"]) else: ET.SubElement(clion_config, "option", name="SCRIPT_WORKING_DIRECTORY", value="$PROJECT_DIR$") @@ -1932,7 +2501,7 @@ def generate_clion_run_configs(self, configs): ET.SubElement(clion_config, "option", name="SCRIPT_PATH", value=config["script"]) ET.SubElement(clion_config, "option", name="SCRIPT_OPTIONS", value="") ET.SubElement(clion_config, "option", name="INDEPENDENT_SCRIPT_WORKING_DIRECTORY", value="true") - if 'cwd' in config and config["cwd"] != self.options.mrdocs_src_dir: + if 'cwd' in config and config["cwd"] != self.options.source_dir: ET.SubElement(clion_config, "option", name="SCRIPT_WORKING_DIRECTORY", value=config["cwd"]) else: ET.SubElement(clion_config, "option", name="SCRIPT_WORKING_DIRECTORY", value="$PROJECT_DIR$") @@ -1945,15 +2514,18 @@ def generate_clion_run_configs(self, configs): ET.SubElement(clion_config, "method", v="2") tree = ET.ElementTree(root) - tree.write(run_config_path, encoding="utf-8", xml_declaration=False) + if self.options.dry_run: + self.ui.info(f"dry-run: would write CLion run configuration {run_config_path}") + else: + tree.write(run_config_path, encoding="utf-8", xml_declaration=False) def generate_visual_studio_run_configs(self, configs): # https://learn.microsoft.com/en-us/visualstudio/ide/customize-build-and-debug-tasks-in-visual-studio?view=vs-2022 # https://learn.microsoft.com/en-us/cpp/build/launch-vs-schema-reference-cpp?view=msvc-170 # https://learn.microsoft.com/en-us/cpp/build/tasks-vs-json-schema-reference-cpp?view=msvc-170 # Visual Studio launch configs are stored in .vs/launch.vs.json - vs_dir = os.path.join(self.options.mrdocs_src_dir, ".vs") - os.makedirs(vs_dir, exist_ok=True) + vs_dir = os.path.join(self.options.source_dir, ".vs") + self.ensure_dir(vs_dir) launch_path = os.path.join(vs_dir, "launch.vs.json") tasks_path = os.path.join(vs_dir, "tasks.vs.json") @@ -1986,10 +2558,10 @@ def vs_config_type(config): return "default" def rel_to_mrdocs_dir(script_path): - is_subdir_of_mrdocs_src_dir = script_path.replace('\\', '/').rstrip('/').startswith( - self.options.mrdocs_src_dir.replace('\\', '/').rstrip('/')) - if is_subdir_of_mrdocs_src_dir: - return os.path.relpath(script_path, self.options.mrdocs_src_dir) + is_subdir_of_source_dir = script_path.replace('\\', '/').rstrip('/').startswith( + self.options.source_dir.replace('\\', '/').rstrip('/')) + if is_subdir_of_source_dir: + return os.path.relpath(script_path, self.options.source_dir) return script_path def vs_config_project(config): @@ -2067,20 +2639,18 @@ def vs_config_project_target(config): # Write back all configs launch_data["configurations"] = list(vs_configs_by_name.values()) - with open(launch_path, "w") as f: - json.dump(launch_data, f, indent=4) + self.write_text(launch_path, json.dumps(launch_data, indent=4)) tasks_data["tasks"] = list(vs_tasks_by_name.values()) - with open(tasks_path, "w") as f: - json.dump(tasks_data, f, indent=4) + self.write_text(tasks_path, json.dumps(tasks_data, indent=4)) def generate_vscode_run_configs(self, configs): - if not self.prompt_option("generate_run_configs"): + if not self.prompt_option("generate_run_configs", "Generate run configs"): return # Visual Studio launch configs are stored in .vs/launch.vs.json - vscode_dir = os.path.join(self.options.mrdocs_src_dir, ".vscode") - os.makedirs(vscode_dir, exist_ok=True) + vscode_dir = os.path.join(self.options.source_dir, ".vscode") + self.ensure_dir(vscode_dir) launch_path = os.path.join(vscode_dir, "launch.json") tasks_path = os.path.join(vscode_dir, "tasks.json") @@ -2105,17 +2675,17 @@ def generate_vscode_run_configs(self, configs): def replace_with_placeholders(new_config): for key, value in new_config.items(): if isinstance(value, str): - new_config[key] = value.replace(self.options.mrdocs_src_dir, "${workspaceFolder}") + new_config[key] = value.replace(self.options.source_dir, "${workspaceFolder}") elif isinstance(value, list): for i in range(len(value)): if isinstance(value[i], str): - value[i] = value[i].replace(self.options.mrdocs_src_dir, "${workspaceFolder}") + value[i] = value[i].replace(self.options.source_dir, "${workspaceFolder}") elif isinstance(value, dict): for sub_key, sub_value in value.items(): if isinstance(sub_value, str): - value[sub_key] = sub_value.replace(self.options.mrdocs_src_dir, "${workspaceFolder}") + value[sub_key] = sub_value.replace(self.options.source_dir, "${workspaceFolder}") - bootstrap_refresh_config_name = self.options.mrdocs_preset_name or self.options.mrdocs_build_type or "debug" + bootstrap_refresh_config_name = self.options.preset or self.options.build_type or "debug" for config in configs: is_python_script = 'script' in config and config['script'].endswith('.py') is_js_script = 'script' in config and config['script'].endswith('.js') @@ -2127,7 +2697,7 @@ def replace_with_placeholders(new_config): "request": "launch", "program": config.get("script", "") or config.get("target", ""), "args": config["args"].copy(), - "cwd": config.get('cwd', self.options.mrdocs_build_dir) + "cwd": config.get('cwd', self.options.build_dir) } if 'target' in config: @@ -2137,7 +2707,7 @@ def replace_with_placeholders(new_config): if 'program' in config: new_cfg["program"] = config["program"] else: - new_cfg["program"] = os.path.join(self.options.mrdocs_build_dir, config["target"]) + new_cfg["program"] = os.path.join(self.options.build_dir, config["target"]) new_cfg["environment"] = [] new_cfg["stopAtEntry"] = False new_cfg["externalConsole"] = False @@ -2188,7 +2758,7 @@ def replace_with_placeholders(new_config): "Only Python (.py) and JavaScript (.js) scripts are supported." ) - # Any property that begins with the value of mrdocs_src_dir is replaced with ${workspaceFolder} + # Any property that begins with the value of source_dir is replaced with ${workspaceFolder} replace_with_placeholders(new_cfg) # Replace or add @@ -2209,10 +2779,10 @@ def to_task_args(config): "options": {}, "problemMatcher": [], } - if 'cwd' in config and config["cwd"] != self.options.mrdocs_src_dir: + if 'cwd' in config and config["cwd"] != self.options.source_dir: new_task["options"]["cwd"] = config["cwd"] - # Any property that begins with the value of mrdocs_src_dir is replaced with ${workspaceFolder} + # Any property that begins with the value of source_dir is replaced with ${workspaceFolder} replace_with_placeholders(new_task) # Replace or add @@ -2222,10 +2792,10 @@ def to_task_args(config): cmake_config_args = [ "-S", "${workspaceFolder}" ] - if self.options.mrdocs_preset_name: - cmake_config_args.extend(["--preset", self.options.mrdocs_preset_name]) + if self.options.preset: + cmake_config_args.extend(["--preset", self.options.preset]) else: - cmake_config_args.extend(["-B", self.options.mrdocs_build_dir]) + cmake_config_args.extend(["-B", self.options.build_dir]) if self.options.ninja_path: cmake_config_args.extend(["-G", "Ninja"]) cmake_config_task = { @@ -2246,7 +2816,7 @@ def to_task_args(config): unique_targets.add(config['target']) for target in unique_targets: build_args = [ - "--build", self.options.mrdocs_build_dir, + "--build", self.options.build_dir, "--target", target ] cmake_build_task = { @@ -2266,136 +2836,88 @@ def to_task_args(config): # Write back all configs launch_data["configurations"] = list(vs_configs_by_name.values()) - with open(launch_path, "w") as f: - json.dump(launch_data, f, indent=4) + self.write_text(launch_path, json.dumps(launch_data, indent=4)) tasks_data["tasks"] = list(vs_tasks_by_name.values()) - with open(tasks_path, "w") as f: - json.dump(tasks_data, f, indent=4) + self.write_text(tasks_path, json.dumps(tasks_data, indent=4)) def generate_run_configs(self): - # Configurations using MrDocs executable - configs = [{ - "name": "MrDocs Version", - "target": "mrdocs", - "program": os.path.join(self.options.mrdocs_build_dir, "mrdocs"), - "args": ["--version"] - }, { - "name": "MrDocs Help", - "target": "mrdocs", - "program": os.path.join(self.options.mrdocs_build_dir, "mrdocs"), - "args": ["--help"] - }] - - # Configuration to run unit tests - if self.options.mrdocs_build_tests: - configs.append({ - "name": "MrDocs Unit Tests", - "target": "mrdocs-test", - "program": os.path.join(self.options.mrdocs_build_dir, "mrdocs-test"), - "args": [ - '--unit=true' - ] - }) - - # Configurations to Update/Test/Create test fixtures - for verb in ["update", "test", "create"]: - for generator in ["adoc", "html", "xml"]: - configs.append({ - "name": f"MrDocs {verb.title()} Test Fixtures ({generator.upper()})", - "target": "mrdocs-test", - "program": os.path.join(self.options.mrdocs_build_dir, "mrdocs-test"), - "folder": "MrDocs Test Fixtures", - "args": [ - os.path.join(self.options.mrdocs_src_dir, 'test-files', 'golden-tests'), - '--unit=false', - f'--action={verb}', - f'--generator={generator}', - f'--addons={os.path.join(self.options.mrdocs_src_dir, "share", "mrdocs", "addons")}', - f'--stdlib-includes={os.path.join(self.options.llvm_install_dir, "include", "c++", "v1")}', - f'--stdlib-includes={self.find_latest_clang_include_dir()}', - f'--libc-includes={os.path.join(self.options.mrdocs_src_dir, "share", "mrdocs", "headers", "libc-stubs")}', - '--log-level=warn' - ] - }) - - num_cores = os.cpu_count() or 1 - self.prompt_option("boost_src_dir") - if self.options.boost_src_dir and os.path.exists(self.options.boost_src_dir): - boost_libs = os.path.join(self.options.boost_src_dir, 'libs') - if os.path.exists(boost_libs): - for lib in os.listdir(boost_libs): - mrdocs_config = os.path.join(boost_libs, lib, 'doc', 'mrdocs.yml') - if os.path.exists(mrdocs_config): - print(f"Generating run configuration for Boost library '{lib}'") - configs.append({ - "name": f"Boost.{lib.title()} Documentation", - "target": "mrdocs", - "folder": "Boost Documentation", - "program": os.path.join(self.options.mrdocs_build_dir, "mrdocs"), - "args": [ - '../CMakeLists.txt', - f'--config={os.path.join(self.options.boost_src_dir, "libs", lib, "doc", "mrdocs.yml")}', - f'--output={os.path.join(self.options.boost_src_dir, "libs", lib, "doc", "modules", "reference", "pages")}', - f'--generator=adoc', - f'--addons={os.path.join(self.options.mrdocs_src_dir, "share", "mrdocs", "addons")}', - f'--stdlib-includes={os.path.join(self.options.llvm_install_dir, "include", "c++", "v1")}', - f'--stdlib-includes={self.find_latest_clang_include_dir()}', - f'--libc-includes={os.path.join(self.options.mrdocs_src_dir, "share", "mrdocs", "headers", "libc-stubs")}', - f'--tagfile=reference.tag.xml', - '--multipage=true', - f'--concurrency={num_cores}', - '--log-level=debug' - ] - }) - else: - print( - f"Warning: Boost source directory '{self.options.boost_src_dir}' does not contain 'libs' directory. Skipping Boost documentation target generation.") - - # Target to generate the documentation for MrDocs itself - configs.append({ - "name": f"MrDocs Self-Reference", - "target": "mrdocs", - "program": os.path.join(self.options.mrdocs_build_dir, "mrdocs"), - "args": [ - '../CMakeLists.txt', - f'--config={os.path.join(self.options.mrdocs_src_dir, "docs", "mrdocs.yml")}', - f'--output={os.path.join(self.options.mrdocs_src_dir, "docs", "modules", "reference", "pages")}', - f'--generator=adoc', - f'--addons={os.path.join(self.options.mrdocs_src_dir, "share", "mrdocs", "addons")}', - f'--stdlib-includes={os.path.join(self.options.llvm_install_dir, "include", "c++", "v1")}', - f'--stdlib-includes={self.find_latest_clang_include_dir()}', - f'--libc-includes={os.path.join(self.options.mrdocs_src_dir, "share", "mrdocs", "headers", "libc-stubs")}', - f'--tagfile=reference.tag.xml', - '--multipage=true', - f'--concurrency={num_cores}', - '--log-level=debug' - ], - "env": { - "LLVM_ROOT": self.options.llvm_install_dir, - "Clang_ROOT": self.options.llvm_install_dir, - "duktape_ROOT": self.options.duktape_install_dir, - "Duktape_ROOT": self.options.duktape_install_dir, - "libxml2_ROOT": self.options.libxml2_install_dir, - "LibXml2_ROOT": self.options.libxml2_install_dir - } - }) + if self.options.dry_run: + self.ui.info("dry-run: skipping IDE run configuration generation") + return - # bootstrap.py targets - configs.append({ - "name": f"MrDocs Bootstrap Help", - "script": os.path.join(self.options.mrdocs_src_dir, "bootstrap.py"), - "args": ["--help"], - "cwd": self.options.mrdocs_src_dir - }) + var_pattern = re.compile(r"\$(\w+)|\${([^}]+)}") + + def expand_with(s: str, mapping: Dict[str, Any]) -> str: + def repl(m): + key = m.group(1) or m.group(2) + return str(mapping.get(key, m.group(0))) + return var_pattern.sub(repl, s) + + def format_values(obj, tokens): + if isinstance(obj, str): + return expand_with(obj, tokens) + if isinstance(obj, list): + return [format_values(x, tokens) for x in obj] + if isinstance(obj, dict): + return {k: format_values(v, tokens) for k, v in obj.items()} + return obj + + defaults_path = os.path.join(self.options.source_dir, "share", "run_configs.json") + defaults = self._load_json_file(defaults_path) or {} + + configs: List[Dict[str, Any]] = defaults.get("configs", []) + + if not configs: + raise RuntimeError("No run configurations found in share/run_configs.json; add configs to proceed.") + + tokens = { + "build_dir": self.options.build_dir, + "source_dir": self.options.source_dir, + "install_dir": self.options.install_dir, + "docs_script_ext": "bat" if self.is_windows() else "sh", + "num_cores": os.cpu_count() or 1, + } + configs = [format_values(cfg, tokens) for cfg in configs] + filtered = [] + for cfg in configs: + req = cfg.get("requires", []) + include = True + if "build_tests" in req and not self.options.build_tests: + include = False + if "java" in req and not self.options.java_path: + include = False + if include: + cfg.pop("requires", None) + filtered.append(cfg) + configs = filtered + + # Append dynamic configs that must be computed (bootstrap helpers, boost docs, schema lint) + configs.extend(self._dynamic_run_configs()) + + target_vscode = bool(defaults.get("vscode", True)) + target_clion = bool(defaults.get("clion", True)) + target_vs = bool(defaults.get("vs", True)) + + if target_clion and self.prompt_option("generate_clion_run_configs", "CLion"): + self.ui.info("Generating CLion run configurations...") + self.generate_clion_run_configs(configs) + if target_vscode and self.prompt_option("generate_vscode_run_configs", "VS Code"): + self.ui.info("Generating Visual Studio Code run configurations...") + self.generate_vscode_run_configs(configs) + if target_vs and self.prompt_option("generate_vs_run_configs", "Visual Studio"): + self.ui.info("Generating Visual Studio run configurations...") + self.generate_visual_studio_run_configs(configs) - bootstrap_args = [] + def _dynamic_run_configs(self) -> List[Dict[str, Any]]: + configs: List[Dict[str, Any]] = [] + # Bootstrap helper targets + bootstrap_args: List[str] = [] for field in dataclasses.fields(InstallOptions): value = getattr(self.options, field.name) default_value = getattr(self.default_options, field.name, None) - if value is not None and (value != default_value or field.name == 'mrdocs_build_type'): - if field.name == 'non_interactive': - # Skip non_interactive as it is handled separately, + if value is not None and (value != default_value or field.name == "build_type"): + if field.name == "non_interactive": continue if field.type is bool: if value: @@ -2403,272 +2925,384 @@ def generate_run_configs(self): else: bootstrap_args.append(f"--no-{field.name.replace('_', '-')}") elif field.type is str: - if value != '': + if value != "": bootstrap_args.append(f"--{field.name.replace('_', '-')}") bootstrap_args.append(value) else: raise TypeError(f"Unsupported type {field.type} for field '{field.name}' in InstallOptions.") - bootstrap_refresh_config_name = self.options.mrdocs_preset_name or self.options.mrdocs_build_type or "debug" - configs.append({ - "name": f"MrDocs Bootstrap Update ({bootstrap_refresh_config_name})", - "script": os.path.join(self.options.mrdocs_src_dir, "bootstrap.py"), - "folder": "MrDocs Bootstrap Update", - "args": bootstrap_args, - "cwd": self.options.mrdocs_src_dir - }) - bootstrap_refresh_args = bootstrap_args.copy() - bootstrap_refresh_args.append("--non-interactive") - configs.append({ - "name": f"MrDocs Bootstrap Refresh ({bootstrap_refresh_config_name})", - "script": os.path.join(self.options.mrdocs_src_dir, "bootstrap.py"), - "folder": "MrDocs Bootstrap Refresh", - "args": bootstrap_refresh_args, - "cwd": self.options.mrdocs_src_dir - }) - configs.append({ - "name": f"MrDocs Bootstrap Refresh All", - "script": os.path.join(self.options.mrdocs_src_dir, "bootstrap.py"), - "folder": "MrDocs Bootstrap Refresh", - "args": ["--refresh-all"], - "cwd": self.options.mrdocs_src_dir - }) - - # Targets for the pre-build steps - configs.append({ - "name": f"MrDocs Generate Config Info ({bootstrap_refresh_config_name})", - "script": os.path.join(self.options.mrdocs_src_dir, 'util', 'generate-config-info.py'), - "folder": "MrDocs Generate Config Info", - "args": [os.path.join(self.options.mrdocs_src_dir, 'src', 'lib', 'ConfigOptions.json'), - os.path.join(self.options.mrdocs_build_dir)], - "cwd": self.options.mrdocs_src_dir - }) - configs.append({ - "name": f"MrDocs Generate Config Info (docs)", - "script": os.path.join(self.options.mrdocs_src_dir, 'util', 'generate-config-info.py'), - "folder": "MrDocs Generate Config Info", - "args": [os.path.join(self.options.mrdocs_src_dir, 'src', 'lib', 'ConfigOptions.json'), - os.path.join(self.options.mrdocs_src_dir, 'docs', 'config-headers')], - "cwd": self.options.mrdocs_src_dir - }) - configs.append({ - "name": f"MrDocs Generate YAML Schema", - "script": os.path.join(self.options.mrdocs_src_dir, 'util', 'generate-yaml-schema.py'), - "args": [], - "cwd": self.options.mrdocs_src_dir - }) - configs.append({ - "name": f"MrDocs Reformat Source Files", - "script": os.path.join(self.options.mrdocs_src_dir, 'util', 'reformat.py'), - "args": [], - "cwd": self.options.mrdocs_src_dir - }) - - # Documentation generation targets - mrdocs_docs_dir = os.path.join(self.options.mrdocs_src_dir, "docs") - mrdocs_docs_ui_dir = os.path.join(mrdocs_docs_dir, "ui") - mrdocs_docs_script_ext = "bat" if self.is_windows() else "sh" - configs.append({ - "name": "MrDocs Build Local Docs", - "script": os.path.join(mrdocs_docs_dir, f"build_local_docs.{mrdocs_docs_script_ext}"), - "args": [], - "cwd": mrdocs_docs_dir, - "env": { - "MRDOCS_ROOT": self.options.mrdocs_install_dir - } - }) - configs.append({ - "name": "MrDocs Build Docs", - "script": os.path.join(mrdocs_docs_dir, f"build_docs.{mrdocs_docs_script_ext}"), - "args": [], - "cwd": mrdocs_docs_dir, - "env": { - "MRDOCS_ROOT": self.options.mrdocs_install_dir - } - }) - configs.append({ - "name": "MrDocs Build UI Bundle", - "script": os.path.join(mrdocs_docs_ui_dir, f"build.{mrdocs_docs_script_ext}"), - "args": [], - "cwd": mrdocs_docs_ui_dir - }) - - # Remove bad test files - test_files_dir = os.path.join(self.options.mrdocs_src_dir, "test-files", "golden-tests") - configs.append({ - "name": "MrDocs Remove Bad Test Files", - "script": os.path.join(test_files_dir, f"remove_bad_files.{mrdocs_docs_script_ext}"), - "args": [], - "cwd": test_files_dir - }) - - # Render landing page - mrdocs_website_dir = os.path.join(mrdocs_docs_dir, "website") - configs.append({ - "name": f"MrDocs Render Landing Page ({bootstrap_refresh_config_name})", - "script": os.path.join(mrdocs_website_dir, "render.js"), - "folder": "MrDocs Render Landing Page", - "args": [], - "cwd": mrdocs_website_dir, - "env": { - "NODE_ENV": "production", - "MRDOCS_ROOT": self.options.mrdocs_install_dir - } - }) - configs.append({ - "name": f"MrDocs Clean Install Website Dependencies", - "script": "npm", - "args": ["ci"], - "cwd": mrdocs_website_dir - }) - configs.append({ - "name": f"MrDocs Install Website Dependencies", - "script": "npm", - "args": ["install"], - "cwd": mrdocs_website_dir - }) - - # XML schema tests + + bootstrap_refresh_config_name = self.options.preset or self.options.build_type or "debug" + configs.extend([ + {"name": "MrDocs Bootstrap Help", "script": os.path.join(self.options.source_dir, "bootstrap.py"), "args": ["--help"], "cwd": self.options.source_dir}, + {"name": f"MrDocs Bootstrap Update ({bootstrap_refresh_config_name})", "script": os.path.join(self.options.source_dir, "bootstrap.py"), "folder": "MrDocs Bootstrap Update", "args": bootstrap_args, "cwd": self.options.source_dir}, + {"name": f"MrDocs Bootstrap Refresh ({bootstrap_refresh_config_name})", "script": os.path.join(self.options.source_dir, "bootstrap.py"), "folder": "MrDocs Bootstrap Refresh", "args": bootstrap_args + ["--non-interactive"], "cwd": self.options.source_dir}, + {"name": "MrDocs Bootstrap Refresh All", "script": os.path.join(self.options.source_dir, "bootstrap.py"), "folder": "MrDocs Bootstrap Refresh", "args": ["--refresh-all"], "cwd": self.options.source_dir}, + {"name": f"MrDocs Generate Config Info ({bootstrap_refresh_config_name})", "script": os.path.join(self.options.source_dir, "util", "generate-config-info.py"), "folder": "MrDocs Generate Config Info", "args": [os.path.join(self.options.source_dir, "src", "lib", "ConfigOptions.json"), os.path.join(self.options.build_dir)], "cwd": self.options.source_dir}, + {"name": "MrDocs Generate Config Info (docs)", "script": os.path.join(self.options.source_dir, "util", "generate-config-info.py"), "folder": "MrDocs Generate Config Info", "args": [os.path.join(self.options.source_dir, "src", "lib", "ConfigOptions.json"), os.path.join(self.options.source_dir, "docs", "config-headers")], "cwd": self.options.source_dir}, + {"name": "MrDocs Generate YAML Schema", "script": os.path.join(self.options.source_dir, "util", "generate-yaml-schema.py"), "args": [], "cwd": self.options.source_dir}, + {"name": "MrDocs Reformat Source Files", "script": os.path.join(self.options.source_dir, "util", "reformat.py"), "args": [], "cwd": self.options.source_dir}, + ]) + + # Boost documentation targets (dynamic scan) + self.prompt_option("boost_src_dir", "Boost source") + num_cores = os.cpu_count() or 1 + if self.options.boost_src_dir and os.path.exists(self.options.boost_src_dir): + boost_libs = os.path.join(self.options.boost_src_dir, "libs") + if os.path.exists(boost_libs): + for lib in os.listdir(boost_libs): + mrdocs_config = os.path.join(boost_libs, lib, "doc", "mrdocs.yml") + if os.path.exists(mrdocs_config): + configs.append({ + "name": f"Boost.{lib.title()} Documentation", + "target": "mrdocs", + "folder": "Boost Documentation", + "program": os.path.join(self.options.build_dir, "mrdocs"), + "args": [ + "../CMakeLists.txt", + f"--config={mrdocs_config}", + f"--output={os.path.join(self.options.boost_src_dir, 'libs', lib, 'doc', 'modules', 'reference', 'pages')}", + "--generator=adoc", + f"--addons={os.path.join(self.options.source_dir, 'share', 'mrdocs', 'addons')}", + f"--libc-includes={os.path.join(self.options.source_dir, 'share', 'mrdocs', 'headers', 'libc-stubs')}", + "--tagfile=reference.tag.xml", + "--multipage=true", + f"--concurrency={num_cores}", + "--log-level=debug", + ], + }) + + # XML / RelaxNG tasks requiring Java and libxml2 if self.options.java_path: configs.append({ "name": "MrDocs Generate RelaxNG Schema", "script": self.options.java_path, "args": [ "-jar", - os.path.join(self.options.mrdocs_src_dir, "util", "trang.jar"), - os.path.join(self.options.mrdocs_src_dir, "mrdocs.rnc"), - os.path.join(self.options.mrdocs_build_dir, "mrdocs.rng") + os.path.join(self.options.source_dir, "util", "trang.jar"), + os.path.join(self.options.source_dir, "mrdocs.rnc"), + os.path.join(self.options.build_dir, "mrdocs.rng"), ], - "cwd": self.options.mrdocs_src_dir + "cwd": self.options.source_dir, }) + libxml2_root = self.libxml2_root_dir() + if libxml2_root: + libxml2_xmllint_executable = os.path.join(libxml2_root, "bin", "xmllint") + xml_sources_dir = os.path.join(self.options.source_dir, "test-files", "golden-tests") + if self.is_windows(): + xml_sources = [] + for root, _, files in os.walk(xml_sources_dir): + for file in files: + if file.endswith(".xml") and not file.endswith(".bad.xml"): + xml_sources.append(os.path.join(root, file)) + configs.append({ + "name": "MrDocs XML Lint with RelaxNG Schema", + "script": libxml2_xmllint_executable, + "args": [ + "--dropdtd", + "--noout", + "--relaxng", + os.path.join(self.options.build_dir, "mrdocs.rng"), + *xml_sources, + ], + "cwd": self.options.source_dir, + }) + else: + configs.append({ + "name": "MrDocs XML Lint with RelaxNG Schema", + "script": "find", + "args": [ + xml_sources_dir, + "-type", + "f", + "-name", + "*.xml", + "!", + "-name", + "*.bad.xml", + "-exec", + libxml2_xmllint_executable, + "--dropdtd", + "--noout", + "--relaxng", + os.path.join(self.options.build_dir, "mrdocs.rng"), + "{}", + "+", + ], + "cwd": self.options.source_dir, + }) + return configs - libxml2_xmllint_executable = os.path.join(self.options.libxml2_install_dir, "bin", "xmllint") - xml_sources_dir = os.path.join(self.options.mrdocs_src_dir, "test-files", "golden-tests") + def generate_pretty_printer_configs(self): + config_path = os.path.join(self.options.source_dir, "share", "pretty_printers.json") + overrides = self._load_json_file(config_path) or {} - if self.is_windows(): - xml_sources = [] - for root, _, files in os.walk(xml_sources_dir): - for file in files: - if file.endswith(".xml") and not file.endswith(".bad.xml"): - xml_sources.append(os.path.join(root, file)) - new_config = { - "name": "MrDocs XML Lint with RelaxNG Schema", - "script": libxml2_xmllint_executable, - "args": [ - "--dropdtd", - "--noout", - "--relaxng", - os.path.join(self.options.mrdocs_build_dir, "mrdocs.rng") - ], - "cwd": self.options.mrdocs_src_dir - } - new_config["args"] += xml_sources - configs.append(new_config) + if self.options.dry_run: + if overrides: + self.ui.info("dry-run: would generate debugger pretty printer configuration from share/pretty_printers.json") else: - configs.append({ - "name": "MrDocs XML Lint with RelaxNG Schema", - "script": "find", - "args": [ - xml_sources_dir, - "-type", "f", - "-name", "*.xml", - "!", "-name", "*.bad.xml", - "-exec", libxml2_xmllint_executable, - "--dropdtd", "--noout", - "--relaxng", os.path.join(self.options.mrdocs_build_dir, "mrdocs.rng"), - "{}", "+" - ], - "cwd": self.options.mrdocs_src_dir - }) - - if self.prompt_option("generate_clion_run_configs"): - print("Generating CLion run configurations for MrDocs...") - self.generate_clion_run_configs(configs) - if self.prompt_option("generate_vscode_run_configs"): - print("Generating Visual Studio Code run configurations for MrDocs...") - self.generate_vscode_run_configs(configs) - if self.prompt_option("generate_vs_run_configs"): - print("Generating Visual Studio run configurations for MrDocs...") - self.generate_visual_studio_run_configs(configs) + self.ui.info("dry-run: skipping debugger pretty printer generation (no config found)") + return - def generate_pretty_printer_configs(self): - # Generate a .lldbinit file (if it doesn't exist) for LLDB pretty printers - lldbinit_path = os.path.join(self.options.mrdocs_src_dir, ".lldbinit") - if not os.path.exists(lldbinit_path): - home_lldbinit_path = os.path.join(os.path.expanduser("~"), ".lldbinit") - lldbinit_enabled = False - if os.path.exists(home_lldbinit_path): - with open(home_lldbinit_path, "r") as f: - home_lldbinit_content = f.read() - if "settings set target.load-cwd-lldbinit true" in home_lldbinit_content: - lldbinit_enabled = True - # The content of the file should be: - # # echo 'settings set target.load-cwd-lldbinit true' >> ~/.lldbinit - # command script import /Users/alandefreitas/Documents/Code/C++/mrdocs/build/third-party/llvm-project/llvm/utils/lldbDataFormatters.py - lldbinit_content = f"# LLDB pretty printers for MrDocs\n" - lldbinit_content += f"# Generated by bootstrap.py\n" - lldbinit_content += f"# \n" - if not lldbinit_enabled: - lldbinit_content += f"# To enable this file, also add this to your ~/.lldbinit file:\n" - lldbinit_content += f"# settings set target.load-cwd-lldbinit true\n" - lldbinit_content += f"# \n" - lldbinit_content += f"# Or run the following bash command:\n" - lldbinit_content += f"# echo 'settings set target.load-cwd-lldbinit true' >> ~/.lldbinit\n" - lldbinit_content += f"# \n" - lldbinit_content += f"command script import {os.path.join(self.options.llvm_src_dir, 'llvm', 'utils', 'lldbDataFormatters.py').replace(os.sep, '/')}\n" - lldbinit_content += f"command script import {os.path.join(self.options.mrdocs_src_dir, 'share', 'lldb', 'mrdocs_formatters.py').replace(os.sep, '/')}\n" - lldbinit_content += f"command script import {os.path.join(self.options.mrdocs_src_dir, 'share', 'lldb', 'clang_ast_formatters.py').replace(os.sep, '/')}\n" - with open(lldbinit_path, "w") as f: - f.write(lldbinit_content) - print(f"Generated LLDB pretty printer configuration at '{lldbinit_path}'") + if not overrides: + self.ui.info("No debugger pretty printer configuration found in share/pretty_printers.json; skipping generation.") + return + + project_label = overrides.get("project", "MrDocs") + + def _resolve_paths(paths): + resolved = [] + for p in paths: + resolved.append(p if os.path.isabs(p) else os.path.abspath(os.path.join(self.options.source_dir, p))) + return resolved + + lldb_scripts = _resolve_paths(overrides.get("lldb", [])) + gdb_scripts = _resolve_paths(overrides.get("gdb", [])) + + if not lldb_scripts and not gdb_scripts: + self.ui.info("No debugger pretty printer scripts listed in local/pretty_printers.json; skipping generation.") + return + + lldbinit_path = os.path.join(self.options.source_dir, ".lldbinit") + if lldb_scripts: + if os.path.exists(lldbinit_path): + self.ui.info(f"LLDB pretty printer configuration already exists at '{lldbinit_path}', skipping generation.") + else: + lldb_lines = [ + f"# LLDB pretty printers for {project_label}", + "# Generated by bootstrap.py", + "# Enable LLDB to load this file with: echo 'settings set target.load-cwd-lldbinit true' >> ~/.lldbinit", + ] + for script in lldb_scripts: + lldb_lines.append(f"command script import {script.replace(os.sep, '/')}") + self.write_text(lldbinit_path, "\n".join(lldb_lines) + "\n") + self.ui.ok(f"Generated LLDB pretty printer configuration at '{lldbinit_path}'") else: - print(f"LLDB pretty printer configuration already exists at '{lldbinit_path}', skipping generation.") - - # Do the same logic for GDB pretty printers, generating a .gdbinit file - # The pretty printer is at: .../third-party/llvm-project/llvm/utils/gdb-scripts/prettyprinters.py - gdbinit_path = os.path.join(self.options.mrdocs_src_dir, ".gdbinit") - if not os.path.exists(gdbinit_path): - gdbinit_content = f"# GDB pretty printers for MrDocs\n" - gdbinit_content += f"# Generated by bootstrap.py\n" - gdbinit_content += f"# \n" - gdbinit_content += f"python\n" - gdbinit_content += f"import sys\n" - gdbinit_content += f"sys.path.insert(0, '{os.path.join(self.options.llvm_src_dir, 'llvm', 'utils', 'gdb-scripts', 'prettyprinters.py').replace(os.sep, '/')}')\n" - gdbinit_content += f"from prettyprinters import register_pretty_printers\n" - gdbinit_content += f"register_pretty_printers(gdb)\n" - gdbinit_content += f"end\n" - with open(gdbinit_path, "w") as f: - f.write(gdbinit_content) - print(f"Generated GDB pretty printer configuration at '{gdbinit_path}'") + self.ui.info("No LLDB pretty printer scripts provided; skipping LLDB configuration.") + + gdbinit_path = os.path.join(self.options.source_dir, ".gdbinit") + if gdb_scripts: + if os.path.exists(gdbinit_path): + self.ui.info(f"GDB pretty printer configuration already exists at '{gdbinit_path}', skipping generation.") + else: + gdb_lines = [ + f"# GDB pretty printers for {project_label}", + "# Generated by bootstrap.py", + "python", + "import sys", + ] + for script in gdb_scripts: + script_dir = os.path.dirname(script) + gdb_lines.append(f"sys.path.insert(0, '{script_dir.replace(os.sep, '/')}')") + gdb_lines.extend([ + "try:", + f" import {Path(script).stem} as _bootstrap_pretty", + " _bootstrap_pretty.register_pretty_printers(gdb)", + "except Exception as exc:", + " print('warning: failed to register pretty printers:', exc)", + ]) + gdb_lines.append("end") + self.write_text(gdbinit_path, "\n".join(gdb_lines) + "\n") + self.ui.ok(f"Generated GDB pretty printer configuration at '{gdbinit_path}'") else: - print(f"GDB pretty printer configuration already exists at '{gdbinit_path}', skipping generation.") + self.ui.info("No GDB pretty printer scripts provided; skipping GDB configuration.") + + def validate_cli_compatibility(self): + """ + Smoke-test a handful of legacy/expected CLI invocations to ensure parsing still works. + """ + parser = _build_arg_parser() + samples = [ + [], + ["--no-generate-run-configs"], + ["--dry-run"], + ["--skip-build"], + ["--clean"], + ["--force"], + ["--plain-ui"], + ["--recipe", "sample"], + ] + for argv in samples: + try: + parser.parse_args(argv) + except SystemExit as exc: + raise RuntimeError(f"Legacy CLI invocation {' '.join(argv) or ''} failed: exit {exc.code}") from exc + except Exception as exc: + raise RuntimeError(f"Legacy CLI invocation {' '.join(argv) or ''} failed: {exc}") from exc + self.ui.ok("CLI backward compatibility parse check passed.") + + def collect_user_inputs(self): + """ + Phase 1: ask all questions up front to mirror bootstrap-other's two-phase flow. + This keeps prompts grouped before any work begins. + """ + self.ui.section("MrDocs Bootstrap", icon="🚀") + + # Seed tool path defaults so prompts (or non-interactive runs) don't get empty values + for tool in ["git", "cmake", "python", "java", "ninja"]: + found = self.find_tool(tool) + if found: + setattr(self.default_options, f"{tool}_path", found) + + # Toolchain early so later steps don't re-prompt + self.ui.subsection("Toolchain", icon="🧰") + self.prompt_option("cc", "C compiler") + self.prompt_option("cxx", "C++ compiler") + self.prompt_option("sanitizer", "Sanitizer (asan/ubsan/msan/tsan/none)") + self.prompt_option("git_path", "git") + self.prompt_option("cmake_path", "cmake") + self.prompt_option("python_path", "python") + self.prompt_option("java_path", "java") + self.prompt_option("ninja_path", "ninja") + + # Layout / presets + self.ui.subsection("Source & build", icon="📂") + self.prompt_option("build_type", "Build type") + self.prompt_option("preset", "CMake preset") + self.prompt_option("build_dir", "Build dir") + self.prompt_option("system_install", "Install to system dirs") + self.prompt_option("install_dir", "Install dir") + self.prompt_option("third_party_src_dir", "3rd-party root (src/build/install)") + + # Testing toggles + self.ui.subsection("Testing", icon="🧪") + self.prompt_option("build_tests", "Build tests") + self.prompt_option("run_tests", "Run tests after build") + self.prompt_option("boost_src_dir", "Boost source") + + # IDE / debugger choices + self.ui.subsection("Run configs & debuggers", icon="💻") + self.prompt_option("generate_run_configs", "Generate run configs") + self.prompt_option("generate_clion_run_configs", "CLion") + self.prompt_option("generate_vscode_run_configs", "VS Code") + self.prompt_option("generate_vs_run_configs", "Visual Studio") + self.prompt_option("generate_pretty_printer_configs", "Pretty printers") + + # Housekeeping toggles + self.ui.subsection("Maintenance", icon="🧹") + self.prompt_option("force_rebuild", "Force rebuild deps") + self.prompt_option("remove_build_dir", "Remove dep build dir") + def install_all(self): + # Gather inputs first (two-phase flow) + self.collect_user_inputs() + + # compute total steps dynamically based on toggles + total_steps = 4 + if self.options.generate_run_configs: + total_steps += 1 + if self.options.generate_pretty_printer_configs: + total_steps += 1 + current_step = 1 + + if self.options.list_recipes: + recipes = self.load_recipe_files() + if not recipes: + self.ui.warn(f"No recipes found in {self.recipes_dir}") + else: + self.ui.section("Available recipes", icon="📦") + for r in recipes: + tags = f" [{', '.join(r.tags)}]" if r.tags else "" + self.ui.info(f"- {r.name}{tags} (version {r.version})") + return + if self.options.skip_build: + self.ui.info("Skip-build requested; build and install steps will be skipped after initial checks.") + + self.ui.section("Toolchain and environment checks", icon="🧰") + self.ui.info("Checking compilers, environment, and required tools...") self.check_compilers() self.probe_msvc_dev_env() self.check_tools() - self.setup_mrdocs_src_dir() + self.ui.subsection("Toolchain summary", icon="🧾") + toolchain = [ + ("C compiler", self.options.cc or self.compiler_info.get("CMAKE_C_COMPILER", "auto")), + ("C++ compiler", self.options.cxx or self.compiler_info.get("CMAKE_CXX_COMPILER", "auto")), + ("git", self.options.git_path), + ("cmake", self.options.cmake_path), + ("python", self.options.python_path), + ] + self.ui.kv_block(None, toolchain, indent=4) + self.ui.info("Toolchain ready.") + + current_step += 1 + self.ui.section("Source and build layout", icon="📂") + self.setup_source_dir() self.setup_third_party_dir() self.probe_compilers() + # Ensure preset name is resolved early + self.prompt_option("build_type", "Build type") + self.prompt_option("preset", "CMake preset") + self.ensure_dir(self.options.third_party_src_dir) + + # Summary block + summary = [ + ("Build type", self.options.build_type), + ("Preset", self.options.preset), + ("Build dir", self.ui.shorten_path(self.options.build_dir)), + ("Install dir", self.ui.shorten_path(self.options.install_dir)), + ("3rd-party root", self.ui.shorten_path(self.options.third_party_src_dir)), + ] + self.ui.subsection("Configuration summary", icon="📋") + self.ui.kv_block(None, summary, indent=4) + + current_step += 1 + self.ui.section("Third-party dependencies", icon="📦") + # Ninja is treated like any other dependency now + self.ui.subsection("ninja", icon="📜") self.install_ninja() - self.install_duktape() - self.install_lua() - self.install_llvm() - if self.prompt_option("mrdocs_build_tests"): - self.install_libxml2() + # Recipes bundled into the same section + recipe_list = self.load_recipe_files() + if recipe_list: + for recipe in self._topo_sort_recipes(recipe_list): + self.ui.subsection(f"{recipe.name}", icon="📜") + resolved_ref = self.fetch_recipe_source(recipe) + self.apply_recipe_patches(recipe) + self.recipe_info[recipe.name] = recipe + if self.options.skip_build: + continue + if self.is_recipe_up_to_date(recipe, resolved_ref) and not self.options.force: + self.ui.ok(f"[{recipe.name}] up to date; skipping build.") + continue + self.build_recipe(recipe) + self.write_recipe_stamp(recipe, resolved_ref) + if recipe.package_root_var: + self.package_roots[recipe.package_root_var] = recipe.install_dir + else: + raise RuntimeError(f"No recipes found in {self.recipes_dir}. Add recipe JSON files to proceed.") + + current_step += 1 + self.ui.section("MrDocs build", icon="⚙️") + self.ui.subsection("CMake presets") self.create_cmake_presets() + self.show_preset_summary() + self.ui.subsection("Build and install MrDocs") self.install_mrdocs() - if self.prompt_option("generate_run_configs"): - self.generate_run_configs() - if self.prompt_option("generate_pretty_printer_configs"): - self.generate_pretty_printer_configs() + if self.prompt_option("generate_run_configs", "Generate run configs"): + current_step += 1 + self.ui.section("IDE run configurations", icon="💻") + self.generate_run_configs() + if self.prompt_option("generate_pretty_printer_configs", "Pretty printers"): + current_step += 1 + self.ui.section("Debugger pretty printers", icon="🐞") + self.generate_pretty_printer_configs() + + # Success footer + generator = "Ninja" if self.options.ninja_path else self.compiler_info.get("CMAKE_GENERATOR", "unknown") + footer = [ + ("Preset", self.options.preset), + ("Build dir", self.ui.shorten_path(self.options.build_dir)), + ("Install dir", self.ui.shorten_path(self.options.install_dir)), + ("Generator", generator), + ] + self.ui.kv_block("Bootstrap complete", footer, icon="✔️", indent=2) def refresh_all(self): # 1. Read all configurations in .vscode/launch.json current_python_interpreter_path = sys.executable this_script_path = os.path.abspath(__file__) - mrdocs_src_dir = os.path.dirname(this_script_path) - vscode_launch_path = os.path.join(mrdocs_src_dir, ".vscode", "launch.json") - vs_launch_path = os.path.join(mrdocs_src_dir, ".vs", "launch.vs.json") + source_dir = os.path.dirname(this_script_path) + vscode_launch_path = os.path.join(source_dir, ".vscode", "launch.json") + vs_launch_path = os.path.join(source_dir, ".vs", "launch.vs.json") use_vscode = os.path.exists(vscode_launch_path) use_vs = os.path.exists(vs_launch_path) if not use_vscode and not use_vs: @@ -2696,7 +3330,7 @@ def refresh_all(self): for config in bootstrap_refresh_configs: config_name = config['name'] if use_vscode: - args = [arg.replace("${workspaceFolder}", mrdocs_src_dir) for arg in config.get("args", [])] + args = [arg.replace("${workspaceFolder}", source_dir) for arg in config.get("args", [])] else: args = shlex.split(config.get("scriptArguments", "")) @@ -2706,24 +3340,27 @@ def refresh_all(self): subprocess.run([current_python_interpreter_path, this_script_path] + args, check=True) -def get_command_line_args(): - """ - Parses command line arguments and returns them as a dictionary. - - Every field in the InstallOptions dataclass is converted to a - valid command line argument description. - - :return: dict: Dictionary of command line arguments. - """ +def _build_arg_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( - description="Download and install MrDocs from source.", + description="Bootstrap MrDocs using recipe-driven third-party deps, presets, and IDE/debugger configs.", formatter_class=argparse.RawTextHelpFormatter ) + skip_cli = {"source_dir"} # internal-only; not configurable via CLI + # Preferred flag names for key options (we intentionally drop the old long names). + custom_flags: Dict[str, List[str]] = { + "build_type": ["--build-type"], + "preset": ["--preset"], + "build_dir": ["--build-dir"], + "install_dir": ["--install-dir"], + "third_party_src_dir": ["--third-party-root"], + "non_interactive": ["-y", "--yes"], + } + for field in dataclasses.fields(InstallOptions): - arg_name = f"--{field.name.replace('_', '-')}" - help_text = INSTALL_OPTION_DESCRIPTIONS.get(field.name) - if help_text is None: - raise ValueError(f"Missing description for option '{field.name}' in INSTALL_OPTION_DESCRIPTIONS.") + if field.name in skip_cli: + continue + flag_names = custom_flags.get(field.name, [f"--{field.name.replace('_', '-')}"]) + help_text = field.name.replace("_", " ") if field.default is not dataclasses.MISSING and field.default is not None: if isinstance(field.default, str) and field.default: help_text += f" (default: '{field.default}')" @@ -2734,14 +3371,34 @@ def get_command_line_args(): else: help_text += f" (default: {field.default})" if field.type is bool: - parser.add_argument(arg_name, dest=field.name, action='store_true', help=help_text, default=None) - parser.add_argument(f"--no-{field.name.replace('_', '-')}", dest=field.name, action='store_false', - help=f'Set {arg_name} to false', default=None) + if field.name == "non_interactive": + parser.add_argument(*flag_names, dest=field.name, action='store_true', help=help_text, default=None) + else: + primary = flag_names[0] + parser.add_argument(*flag_names, dest=field.name, action='store_true', help=help_text, default=None) + # Provide a no- form for toggling off + no_flag = primary.replace("--", "--no-", 1) if primary.startswith("--") else f"--no-{field.name}" + parser.add_argument(no_flag, dest=field.name, action='store_false', + help=f"Set {primary} to false", default=None) elif field.type is str: - parser.add_argument(arg_name, type=field.type, help=help_text, default=None) + parser.add_argument(*flag_names, type=field.type, dest=field.name, help=help_text, default=None) else: raise TypeError(f"Unsupported type {field.type} for field '{field.name}' in InstallOptions.") - return {k: v for k, v in vars(parser.parse_args()).items() if v is not None} + return parser + + +def get_command_line_args(argv=None): + """ + Parses command line arguments and returns them as a dictionary. + + Every field in the InstallOptions dataclass is converted to a + valid command line argument description. + + :return: dict: Dictionary of command line arguments. + """ + parser = _build_arg_parser() + parsed = vars(parser.parse_args(argv)) + return {k: v for k, v in parsed.items() if v is not None} def main(): @@ -2754,4 +3411,12 @@ def main(): if __name__ == "__main__": - main() + try: + main() + except KeyboardInterrupt: + # Graceful exit when the user hits Ctrl+C/Cmd+C during a prompt + try: + ui.ok("🛑 Aborted by user.") + except Exception: + print("Aborted by user.") + sys.exit(130) diff --git a/docs/modules/ROOT/pages/install.adoc b/docs/modules/ROOT/pages/install.adoc index 8db83d0d80..bdd0db77dc 100644 --- a/docs/modules/ROOT/pages/install.adoc +++ b/docs/modules/ROOT/pages/install.adoc @@ -22,29 +22,6 @@ cd mrdocs python bootstrap.py ---- -Or if you just want to install Mr.Docs without cloning the repository, you can run the script directly from the web: - -[tabs] -==== -Windows PowerShell:: -+ --- -[source,powershell] ----- -python -c "$(Invoke-WebRequest -Uri 'https://raw.githubusercontent.com/cppalliance/mrdocs/refs/heads/develop/bootstrap.py' -UseBasicParsing | Select-Object -ExpandProperty Content)" ----- --- - -Unix Variants:: -+ --- -[source,bash] ----- -python -c "$(curl -fsSL https://raw.githubusercontent.com/cppalliance/mrdocs/refs/heads/develop/bootstrap.py)" ----- --- -==== - This method automates the download, configuration, and build steps for Mr.Docs and all required third-party libraries. It is especially useful for developers and for users who prefer a streamlined, interactive setup or need to install Mr.Docs in custom environments. @@ -63,30 +40,6 @@ is handy for developers to refresh dependencies: python bootstrap.py --non-interactive --mrdocs-build-type=Release --third-party-src-dir=/path/to/dependencies ---- -In the default case, the script will download the source code to the current directory -and install Mr.Docs system-wide. - -[tabs] -==== -Windows PowerShell:: -+ --- -[source,powershell] ----- -python -c "$(Invoke-WebRequest -Uri 'https://raw.githubusercontent.com/cppalliance/mrdocs/refs/heads/develop/bootstrap.py' -UseBasicParsing)" --non-interactive ----- --- - -Unix Variants:: -+ --- -[source,bash] ----- -python -c "$(curl -fsSL https://raw.githubusercontent.com/cppalliance/mrdocs/refs/heads/develop/bootstrap.py)" --non-interactive ----- --- -==== - The script handles tool checks, repository cloning, patching, and CMake configuration, reducing manual intervention and potential errors. This approach is recommended for developers, advanced users, or those integrating Mr.Docs diff --git a/share/run_configs.json b/share/run_configs.json new file mode 100644 index 0000000000..d95068f9a7 --- /dev/null +++ b/share/run_configs.json @@ -0,0 +1,144 @@ +{ + "vscode": true, + "clion": true, + "vs": true, + "configs": [ + { + "name": "MrDocs Version", + "target": "mrdocs", + "program": "${mrdocs_build_dir}/mrdocs", + "args": ["--version"] + }, + { + "name": "MrDocs Help", + "target": "mrdocs", + "program": "${mrdocs_build_dir}/mrdocs", + "args": ["--help"] + }, + { + "name": "MrDocs Unit Tests", + "target": "mrdocs-test", + "program": "${mrdocs_build_dir}/mrdocs-test", + "args": ["--unit=true"], + "requires": ["build_tests"] + }, + { + "name": "MrDocs Update Test Fixtures (ADOC)", + "target": "mrdocs-test", + "folder": "MrDocs Test Fixtures", + "program": "${mrdocs_build_dir}/mrdocs-test", + "args": [ + "${mrdocs_src_dir}/test-files/golden-tests", + "--unit=false", + "--action=update", + "--generator=adoc", + "--addons=${mrdocs_src_dir}/share/mrdocs/addons", + "--libc-includes=${mrdocs_src_dir}/share/mrdocs/headers/libc-stubs", + "--log-level=warn" + ], + "requires": ["build_tests"] + }, + { + "name": "MrDocs Test Test Fixtures (ADOC)", + "target": "mrdocs-test", + "folder": "MrDocs Test Fixtures", + "program": "${mrdocs_build_dir}/mrdocs-test", + "args": [ + "${mrdocs_src_dir}/test-files/golden-tests", + "--unit=false", + "--action=test", + "--generator=adoc", + "--addons=${mrdocs_src_dir}/share/mrdocs/addons", + "--libc-includes=${mrdocs_src_dir}/share/mrdocs/headers/libc-stubs", + "--log-level=warn" + ], + "requires": ["build_tests"] + }, + { + "name": "MrDocs Create Test Fixtures (ADOC)", + "target": "mrdocs-test", + "folder": "MrDocs Test Fixtures", + "program": "${mrdocs_build_dir}/mrdocs-test", + "args": [ + "${mrdocs_src_dir}/test-files/golden-tests", + "--unit=false", + "--action=create", + "--generator=adoc", + "--addons=${mrdocs_src_dir}/share/mrdocs/addons", + "--libc-includes=${mrdocs_src_dir}/share/mrdocs/headers/libc-stubs", + "--log-level=warn" + ], + "requires": ["build_tests"] + }, + { + "name": "MrDocs Self-Reference", + "target": "mrdocs", + "program": "${mrdocs_build_dir}/mrdocs", + "args": [ + "../CMakeLists.txt", + "--config=${mrdocs_src_dir}/docs/mrdocs.yml", + "--output=${mrdocs_src_dir}/docs/modules/reference/pages", + "--generator=adoc", + "--addons=${mrdocs_src_dir}/share/mrdocs/addons", + "--libc-includes=${mrdocs_src_dir}/share/mrdocs/headers/libc-stubs", + "--tagfile=reference.tag.xml", + "--multipage=true", + "--concurrency=${num_cores}", + "--log-level=debug" + ] + }, + { + "name": "MrDocs Build Local Docs", + "script": "${mrdocs_src_dir}/docs/build_local_docs.${docs_script_ext}", + "args": [], + "cwd": "${mrdocs_src_dir}/docs", + "env": { + "MRDOCS_ROOT": "${mrdocs_install_dir}" + } + }, + { + "name": "MrDocs Build Docs", + "script": "${mrdocs_src_dir}/docs/build_docs.${docs_script_ext}", + "args": [], + "cwd": "${mrdocs_src_dir}/docs", + "env": { + "MRDOCS_ROOT": "${mrdocs_install_dir}" + } + }, + { + "name": "MrDocs Build UI Bundle", + "script": "${mrdocs_src_dir}/docs/ui/build.${docs_script_ext}", + "args": [], + "cwd": "${mrdocs_src_dir}/docs/ui" + }, + { + "name": "MrDocs Remove Bad Test Files", + "script": "${mrdocs_src_dir}/test-files/golden-tests/remove_bad_files.${docs_script_ext}", + "args": [], + "cwd": "${mrdocs_src_dir}/test-files/golden-tests" + }, + { + "name": "MrDocs Render Landing Page", + "script": "${mrdocs_src_dir}/docs/website/render.js", + "folder": "MrDocs Render Landing Page", + "args": [], + "cwd": "${mrdocs_src_dir}/docs/website", + "env": { + "NODE_ENV": "production", + "MRDOCS_ROOT": "${mrdocs_install_dir}" + } + }, + { + "name": "MrDocs Clean Install Website Dependencies", + "script": "npm", + "args": ["ci"], + "cwd": "${mrdocs_src_dir}/docs/website" + }, + { + "name": "MrDocs Install Website Dependencies", + "script": "npm", + "args": ["install"], + "cwd": "${mrdocs_src_dir}/docs/website" + } + ] +} diff --git a/src/lib/AST/ASTVisitor.cpp b/src/lib/AST/ASTVisitor.cpp index 5df1fa2e2a..e37ea62bb1 100644 --- a/src/lib/AST/ASTVisitor.cpp +++ b/src/lib/AST/ASTVisitor.cpp @@ -753,10 +753,7 @@ populate( { for (clang::FriendDecl const* FD : D->friends()) { - // Check if the friend is a fundamental type - // Declaring a fundamental type like `int` as a friend of a - // class or struct does not have any practical effect. Thus, - // it's not considered part of the public API. + // Skip meaningless builtin friend types if (clang::TypeSourceInfo const* TSI = FD->getFriendType()) { clang::Type const* T = TSI->getType().getTypePtrOrNull(); @@ -1216,9 +1213,13 @@ populate( } else if (clang::NamedDecl const* ND = D->getFriendDecl()) { - // ND can be a function or a class + // ND can be a function or a class; converge to the semantic owner + // (primary template or canonical decl) before traversing so friend + // graphs built from many instantiations collapse to a single node. + clang::Decl const* Target = canonicalFriendTarget(ND); + MRDOCS_CHECK_OR(Target); ScopeExitRestore s(mode_, Dependency); - if (Symbol const* SI = traverse(dyn_cast(ND))) + if (Symbol const* SI = findOrTraverse(Target)) { I.id = SI->id; } diff --git a/src/lib/AST/ClangHelpers.cpp b/src/lib/AST/ClangHelpers.cpp index f973ed25bc..0b72ce1dd4 100644 --- a/src/lib/AST/ClangHelpers.cpp +++ b/src/lib/AST/ClangHelpers.cpp @@ -87,6 +87,45 @@ SubstituteConstraintExpressionWithoutSatisfaction( return SubstConstr.get(); } +clang::Decl const* +canonicalFriendTarget(clang::NamedDecl const* ND) +{ + if (!ND) + return nullptr; + + if (auto const* CTSD = llvm::dyn_cast(ND)) + { + if (auto* T = CTSD->getSpecializedTemplate()) + return T->getTemplatedDecl()->getCanonicalDecl(); + } + + if (auto const* CRD = llvm::dyn_cast(ND)) + { + if (auto* CTD = CRD->getDescribedClassTemplate()) + return CTD->getTemplatedDecl()->getCanonicalDecl(); + } + + if (auto const* VTS = llvm::dyn_cast(ND)) + { + if (auto* VT = VTS->getSpecializedTemplate()) + return VT->getTemplatedDecl()->getCanonicalDecl(); + } + + if (auto const* VTD = llvm::dyn_cast(ND)) + return VTD->getTemplatedDecl()->getCanonicalDecl(); + + if (auto const* FD = llvm::dyn_cast(ND)) + { + if (auto const* PT = FD->getPrimaryTemplate()) + return PT->getTemplatedDecl()->getCanonicalDecl(); + } + + if (auto const* FTD = llvm::dyn_cast(ND)) + return FTD->getTemplatedDecl()->getCanonicalDecl(); + + return ND->getCanonicalDecl(); +} + clang::AccessSpecifier getAccess(clang::Decl const* D) { diff --git a/src/lib/AST/ClangHelpers.hpp b/src/lib/AST/ClangHelpers.hpp index e9a0ff2a5a..4a1edafb95 100644 --- a/src/lib/AST/ClangHelpers.hpp +++ b/src/lib/AST/ClangHelpers.hpp @@ -50,6 +50,15 @@ SubstituteConstraintExpressionWithoutSatisfaction( const clang::Sema::TemplateCompareNewDeclInfo &DeclInfo, const clang::Expr *ConstrExpr); +/** Collapse a friend target (function/class/template) to its canonical owner. + + This resolves friend declarations that name template specializations or + redeclarations to the primary/canonical declaration so traversal and + symbol IDs converge on a single entity. +*/ +clang::Decl const* +canonicalFriendTarget(clang::NamedDecl const* ND); + /** Determine the MrDocs Info type for a Clang DeclType This trait associates a Clang Decl type with the corresponding diff --git a/test-files/golden-tests/regression/1119.adoc b/test-files/golden-tests/config/include-symbols/using-external-base-docs.adoc similarity index 100% rename from test-files/golden-tests/regression/1119.adoc rename to test-files/golden-tests/config/include-symbols/using-external-base-docs.adoc diff --git a/test-files/golden-tests/config/include-symbols/using-external-base-docs.cpp b/test-files/golden-tests/config/include-symbols/using-external-base-docs.cpp new file mode 100644 index 0000000000..c972158553 --- /dev/null +++ b/test-files/golden-tests/config/include-symbols/using-external-base-docs.cpp @@ -0,0 +1,17 @@ +// Regression test for #1119: inheriting from an excluded/external base class +// must not emit missing-documentation diagnostics for the base members. +// The YAML config for this fixture only includes an unrelated namespace, so +// `ns::Foo` is external; Baz inherits it and should not trigger diagnostics. +namespace ns { +struct Foo { + /// bar + int + bar(); +}; +} // namespace ns + +/// project namespace +namespace mrdocs { +/// Baz +struct Baz : ns::Foo {}; +} // namespace mrdocs diff --git a/test-files/golden-tests/config/include-symbols/using-external-base-docs.html b/test-files/golden-tests/config/include-symbols/using-external-base-docs.html new file mode 100644 index 0000000000..e583553dba --- /dev/null +++ b/test-files/golden-tests/config/include-symbols/using-external-base-docs.html @@ -0,0 +1,22 @@ + + +Reference + + + +
+

Reference

+
+
+

+Global Namespace# +

+
+
+ +
+ + + \ No newline at end of file diff --git a/test-files/golden-tests/regression/1119.xml b/test-files/golden-tests/config/include-symbols/using-external-base-docs.xml similarity index 100% rename from test-files/golden-tests/regression/1119.xml rename to test-files/golden-tests/config/include-symbols/using-external-base-docs.xml diff --git a/test-files/golden-tests/regression/1119.yml b/test-files/golden-tests/config/include-symbols/using-external-base-docs.yml similarity index 100% rename from test-files/golden-tests/regression/1119.yml rename to test-files/golden-tests/config/include-symbols/using-external-base-docs.yml diff --git a/test-files/golden-tests/regression/1119.cpp b/test-files/golden-tests/regression/1119.cpp deleted file mode 100644 index e195b8fb7f..0000000000 --- a/test-files/golden-tests/regression/1119.cpp +++ /dev/null @@ -1,13 +0,0 @@ -namespace ns { -struct Foo { - /// bar - int - bar(); -}; -} // namespace ns - -/// project namespace -namespace mrdocs { -/// Baz -struct Baz : ns::Foo {}; -} // namespace mrdocs diff --git a/test-files/golden-tests/regression/1119.html b/test-files/golden-tests/regression/1119.html deleted file mode 100644 index 135e79d40d..0000000000 --- a/test-files/golden-tests/regression/1119.html +++ /dev/null @@ -1,19 +0,0 @@ - - -Reference - - -
-

Reference

-
-
-

-
-
- -
-
-

Created with MrDocs

-
- - \ No newline at end of file diff --git a/test-files/golden-tests/symbols/function/noreturn.xml b/test-files/golden-tests/symbols/function/noreturn.xml index 18536d1211..1b3a27246b 100644 --- a/test-files/golden-tests/symbols/function/noreturn.xml +++ b/test-files/golden-tests/symbols/function/noreturn.xml @@ -19,7 +19,6 @@ - diff --git a/test-files/golden-tests/symbols/record/friend-duplicate.xml b/test-files/golden-tests/symbols/record/friend-duplicate.xml index fb92651f0d..7682d5e854 100644 --- a/test-files/golden-tests/symbols/record/friend-duplicate.xml +++ b/test-files/golden-tests/symbols/record/friend-duplicate.xml @@ -15,8 +15,6 @@ - - diff --git a/test-files/golden-tests/symbols/record/friend-excluded.xml b/test-files/golden-tests/symbols/record/friend-excluded.xml index 9463bb2224..f027798eea 100644 --- a/test-files/golden-tests/symbols/record/friend-excluded.xml +++ b/test-files/golden-tests/symbols/record/friend-excluded.xml @@ -21,7 +21,6 @@ - diff --git a/test-files/golden-tests/symbols/record/friend-fn-member.xml b/test-files/golden-tests/symbols/record/friend-fn-member.xml index 2a1d89869e..718372a24e 100644 --- a/test-files/golden-tests/symbols/record/friend-fn-member.xml +++ b/test-files/golden-tests/symbols/record/friend-fn-member.xml @@ -18,7 +18,6 @@ - Default constructor @@ -27,7 +26,6 @@ - Destructor @@ -36,7 +34,6 @@ - diff --git a/test-files/golden-tests/symbols/record/friend-fn-multi-2nd.xml b/test-files/golden-tests/symbols/record/friend-fn-multi-2nd.xml index 05979e1b78..73b136043a 100644 --- a/test-files/golden-tests/symbols/record/friend-fn-multi-2nd.xml +++ b/test-files/golden-tests/symbols/record/friend-fn-multi-2nd.xml @@ -16,7 +16,6 @@ - diff --git a/test-files/golden-tests/symbols/record/friend-fn-multi-free.xml b/test-files/golden-tests/symbols/record/friend-fn-multi-free.xml index 17373780b8..a83fbae30a 100644 --- a/test-files/golden-tests/symbols/record/friend-fn-multi-free.xml +++ b/test-files/golden-tests/symbols/record/friend-fn-multi-free.xml @@ -16,7 +16,6 @@ - diff --git a/test-files/golden-tests/symbols/record/friend-fn-multi.xml b/test-files/golden-tests/symbols/record/friend-fn-multi.xml index 27b82123a2..c2cf50bb9b 100644 --- a/test-files/golden-tests/symbols/record/friend-fn-multi.xml +++ b/test-files/golden-tests/symbols/record/friend-fn-multi.xml @@ -16,7 +16,6 @@ - diff --git a/test-files/golden-tests/symbols/record/friend-recursive.adoc b/test-files/golden-tests/symbols/record/friend-recursive.adoc new file mode 100644 index 0000000000..c782cc2303 --- /dev/null +++ b/test-files/golden-tests/symbols/record/friend-recursive.adoc @@ -0,0 +1,50 @@ += Reference +:mrdocs: + +[#index] +== Global namespace + +=== Namespaces + +[cols=1] +|=== +| Name +| link:#repro[`repro`] +|=== + +[#repro] +== repro + +=== Types + +[cols=1] +|=== +| Name +| link:#repro-FriendLoop[`FriendLoop`] +|=== + +[#repro-FriendLoop] +== link:#repro[repro]::FriendLoop + +=== Synopsis + +Declared in `<friend‐recursive.cpp>` + +[source,cpp,subs="verbatim,replacements,macros,-callouts"] +---- +template<class T> +struct FriendLoop; +---- + +=== Friends + +[cols="1,4"] +|=== +|Name|Description + +| `repro::FriendLoop` +| +|=== + + +[.small]#Created with https://www.mrdocs.com[MrDocs]# diff --git a/test-files/golden-tests/symbols/record/friend-recursive.cpp b/test-files/golden-tests/symbols/record/friend-recursive.cpp new file mode 100644 index 0000000000..293b63ed80 --- /dev/null +++ b/test-files/golden-tests/symbols/record/friend-recursive.cpp @@ -0,0 +1,14 @@ +// Minimal repro for issue #1117: recursive friend declarations in templates +// previously caused unbounded traversal and a crash in TagType printing. + +namespace repro +{ +template +struct FriendLoop +{ + // Each instantiation friends every other instantiation, creating a dense + // friend graph that used to trigger recursive traversal. + template + friend struct FriendLoop; +}; +} // namespace repro diff --git a/test-files/golden-tests/symbols/record/friend-recursive.html b/test-files/golden-tests/symbols/record/friend-recursive.html new file mode 100644 index 0000000000..6d4b4c729b --- /dev/null +++ b/test-files/golden-tests/symbols/record/friend-recursive.html @@ -0,0 +1,93 @@ + + +Reference + + + +
+

Reference

+
+
+

+Global Namespace# +

+
+

+Namespaces

+ + + + + + + + + + +
Name
repro
+ +
+
+
+

+repro# +

+
+

+Types

+ + + + + + + + + + +
Name
FriendLoop
+ +
+
+
+

+repro::FriendLoop# +

+
+
+

+Synopsis

+
+Declared in <friend-recursive.cpp>
+
template<class T>
+struct FriendLoop;
+
+ + +
+

+Friends

+ + + + + + + + + + + + +|=== + +
NameDescription
FriendLoop
+
+
+ +
+ + + \ No newline at end of file diff --git a/test-files/golden-tests/symbols/record/friend-recursive.xml b/test-files/golden-tests/symbols/record/friend-recursive.xml new file mode 100644 index 0000000000..fdf8c649d2 --- /dev/null +++ b/test-files/golden-tests/symbols/record/friend-recursive.xml @@ -0,0 +1,17 @@ + + + + + + + + diff --git a/test-files/golden-tests/regression/1057.adoc b/test-files/golden-tests/symbols/using/using-alias-template-dependent.adoc similarity index 77% rename from test-files/golden-tests/regression/1057.adoc rename to test-files/golden-tests/symbols/using/using-alias-template-dependent.adoc index 6aa15593b2..0c6c6c692b 100644 --- a/test-files/golden-tests/regression/1057.adoc +++ b/test-files/golden-tests/symbols/using/using-alias-template-dependent.adoc @@ -21,7 +21,7 @@ === Synopsis -Declared in `<1057.cpp>` +Declared in `<using‐alias‐template‐dependent.cpp>` [source,cpp,subs="verbatim,replacements,macros,-callouts"] ---- @@ -36,7 +36,7 @@ using _UniqAssignable = int; === Synopsis -Declared in `<1057.cpp>` +Declared in `<using‐alias‐template‐dependent.cpp>` [source,cpp,subs="verbatim,replacements,macros,-callouts"] ---- @@ -52,7 +52,7 @@ using _UniqCompatible = _Res; === Synopsis -Declared in `<1057.cpp>` +Declared in `<using‐alias‐template‐dependent.cpp>` [source,cpp,subs="verbatim,replacements,macros,-callouts"] ---- @@ -75,7 +75,7 @@ struct enable_if; === Synopsis -Declared in `<1057.cpp>` +Declared in `<using‐alias‐template‐dependent.cpp>` [source,cpp,subs="verbatim,replacements,macros,-callouts"] ---- @@ -87,7 +87,7 @@ using type = T; === Synopsis -Declared in `<1057.cpp>` +Declared in `<using‐alias‐template‐dependent.cpp>` [source,cpp,subs="verbatim,replacements,macros,-callouts"] ---- @@ -100,7 +100,7 @@ struct link:#enable_if-03[enable_if]<false, T>; === Synopsis -Declared in `<1057.cpp>` +Declared in `<using‐alias‐template‐dependent.cpp>` [source,cpp,subs="verbatim,replacements,macros,-callouts"] ---- @@ -121,7 +121,7 @@ struct is_match; === Synopsis -Declared in `<1057.cpp>` +Declared in `<using‐alias‐template‐dependent.cpp>` [source,cpp,subs="verbatim,replacements,macros,-callouts"] ---- diff --git a/test-files/golden-tests/regression/1057.cpp b/test-files/golden-tests/symbols/using/using-alias-template-dependent.cpp similarity index 73% rename from test-files/golden-tests/regression/1057.cpp rename to test-files/golden-tests/symbols/using/using-alias-template-dependent.cpp index 3953f87c56..9dd1727c77 100644 --- a/test-files/golden-tests/regression/1057.cpp +++ b/test-files/golden-tests/symbols/using/using-alias-template-dependent.cpp @@ -1,4 +1,7 @@ +// Regression test for issue #1057 (see PR #1076): +// ensure alias templates with dependent enable_if patterns +// (std::unique_ptr-like) are documented correctly. template struct enable_if { using type = T; diff --git a/test-files/golden-tests/regression/1057.html b/test-files/golden-tests/symbols/using/using-alias-template-dependent.html similarity index 87% rename from test-files/golden-tests/regression/1057.html rename to test-files/golden-tests/symbols/using/using-alias-template-dependent.html index 728101bc68..d19c6e8f27 100644 --- a/test-files/golden-tests/regression/1057.html +++ b/test-files/golden-tests/symbols/using/using-alias-template-dependent.html @@ -41,7 +41,7 @@

Synopsis

-Declared in <1057.cpp>
+Declared in <using-alias-template-dependent.cpp>
template<
     typename _Yp,
     typename _Del>
@@ -58,7 +58,7 @@ 

Synopsis

-Declared in <1057.cpp>
+Declared in <using-alias-template-dependent.cpp>
template<
     typename _Yp,
     typename _Del,
@@ -76,7 +76,7 @@ 

Synopsis

-Declared in <1057.cpp>
+Declared in <using-alias-template-dependent.cpp>
template<
     bool C,
     typename T>
@@ -109,7 +109,7 @@ 

Synopsis

-Declared in <1057.cpp>
+Declared in <using-alias-template-dependent.cpp>
using type = T;
@@ -123,7 +123,7 @@

Synopsis

-Declared in <1057.cpp>
+Declared in <using-alias-template-dependent.cpp>
template<typename T>
 struct enable_if<false, T>;
@@ -140,7 +140,7 @@

Synopsis

-Declared in <1057.cpp>
+Declared in <using-alias-template-dependent.cpp>
template<typename T>
 struct is_match;
@@ -171,7 +171,7 @@

Synopsis

-Declared in <1057.cpp>
+Declared in <using-alias-template-dependent.cpp>
enum Unnamed enum;

diff --git a/test-files/golden-tests/regression/1057.xml b/test-files/golden-tests/symbols/using/using-alias-template-dependent.xml similarity index 62% rename from test-files/golden-tests/regression/1057.xml rename to test-files/golden-tests/symbols/using/using-alias-template-dependent.xml index 1be4e5cddc..1e880dd173 100644 --- a/test-files/golden-tests/regression/1057.xml +++ b/test-files/golden-tests/symbols/using/using-alias-template-dependent.xml @@ -6,7 +6,7 @@ - + @@ -15,7 +15,7 @@ - + @@ -23,9 +23,9 @@ - + - + @@ -35,17 +35,17 @@ - +