From 106f9041446f0a725a58e04ee1846a36d7b737d5 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sat, 26 Sep 2026 06:21:00 +0800 Subject: [PATCH 01/13] deps-vcpkg: a vcpkg manifest installed as a blocking action through mcpp-deps, and its prefix mapped into the build --- deps/deps.cppm | 195 +++++++++++++ deps/vcpkg.cppm | 284 +++++++++++++++++++ tests/vcpkg-consumer/build.mcpp | 11 + tests/vcpkg-consumer/mcpp.toml | 25 ++ tests/vcpkg-consumer/src/main.cpp | 10 + tests/vcpkg-consumer/vcpkg.json | 6 + tests/vcpkg-workspace/app-a/build.mcpp | 10 + tests/vcpkg-workspace/app-a/mcpp.toml | 15 + tests/vcpkg-workspace/app-a/src/main.cpp | 8 + tests/vcpkg-workspace/app-b/build.mcpp | 10 + tests/vcpkg-workspace/app-b/mcpp.toml | 15 + tests/vcpkg-workspace/app-b/src/main.cpp | 8 + tests/vcpkg-workspace/mcpp.toml | 15 + tests/vcpkg-workspace/vcpkg.json | 6 + tools/deps_main.cpp | 334 +++++++++++++++++++++++ 15 files changed, 952 insertions(+) create mode 100644 deps/deps.cppm create mode 100644 deps/vcpkg.cppm create mode 100644 tests/vcpkg-consumer/build.mcpp create mode 100644 tests/vcpkg-consumer/mcpp.toml create mode 100644 tests/vcpkg-consumer/src/main.cpp create mode 100644 tests/vcpkg-consumer/vcpkg.json create mode 100644 tests/vcpkg-workspace/app-a/build.mcpp create mode 100644 tests/vcpkg-workspace/app-a/mcpp.toml create mode 100644 tests/vcpkg-workspace/app-a/src/main.cpp create mode 100644 tests/vcpkg-workspace/app-b/build.mcpp create mode 100644 tests/vcpkg-workspace/app-b/mcpp.toml create mode 100644 tests/vcpkg-workspace/app-b/src/main.cpp create mode 100644 tests/vcpkg-workspace/mcpp.toml create mode 100644 tests/vcpkg-workspace/vcpkg.json create mode 100644 tools/deps_main.cpp diff --git a/deps/deps.cppm b/deps/deps.cppm new file mode 100644 index 0000000..cf9b8d7 --- /dev/null +++ b/deps/deps.cppm @@ -0,0 +1,195 @@ +// mcpp.deps -- what the `deps-*` members share. +// +// A `deps-*` member answers "where does a library come from": it installs a +// prefix through a program mcpp does not drive (vcpkg, CMake) and maps the +// prefix into the build -- include directories, the link, and the directories +// the program's shared libraries are found in at run time. The two members +// differ in which program installs the prefix; everything else is here. +// +// TWO RULES EVERY MEMBER FOLLOWS, AND WHY THEY ARE THE SAME RULE. +// +// 1. The installation is an ACTION, never work the build program does. +// `mcpp emit build-database` runs build programs to plan (mcpp's +// docs/specs/build-database.md), a build program has a 600-second limit, +// and an installation can take an hour. An action is a ninja edge: it runs +// under `mcpp build` only, as long as it needs, and only when its inputs +// changed. +// +// 2. A missing prefix is a WARNING, not a failure. The first plan of a +// project runs before the action has installed anything, and an editor +// asks for the plan on machines that never built. A build program that +// exits 1 there takes the whole build database with it; one that states +// the paths it WILL use lets the editor resolve every include the moment +// the first build finishes. +// +// This unit imports `mcpp`, so it exists only inside a build program. The +// program that performs the installation is `mcpp-deps` (tools/deps_main.cpp), +// an ordinary executable built from this package. + +export module mcpp.deps; + +import std; +import mcpp; +import mcpp.plugins; + +export namespace mcpp::deps { + +// Prints `message` and records it as a `mcpp::warning`, folded onto one line. +// The engine discards a build program's output when it exits 0, so a note that +// only went to stderr would be invisible on exactly the builds that succeed. +inline void warn(const std::string& message) { + std::cerr << message << '\n'; + std::string folded; + folded.reserve(message.size()); + bool space = false; + for (char c : message) { + if (c == '\n' || c == '\r') { space = true; continue; } + if (space) { + if (c == ' ') continue; + folded += ' '; + space = false; + } + folded += c; + } + mcpp::warning(folded.c_str()); +} + +// The installer program, or empty after saying which line brings it. +inline std::string launcher(std::string_view member, std::string_view feature) { + const std::string tool = mcpp::dep_bin("plugins", "mcpp-deps"); + if (!tool.empty()) return tool; + std::cerr << std::format( + "{0}: the installation runs as a build action, and an action's command is\n" + " a program: `mcpp-deps`, built from this package. Ask for it on the edge\n" + " that brings the member in:\n\n" + " [build-dependencies.mcpp]\n" + " plugins = {{ version = \"{1}\", features = [\"{2}\"], host-module = true,\n" + " tools = [\"mcpp-deps\"] }}\n", + member, mcpp::plugins::version, feature); + return {}; +} + +inline bool is_windows() { return std::string_view(mcpp::target_os()) == "windows"; } +inline bool is_macos() { return std::string_view(mcpp::target_os()) == "macos"; } + +// Relative paths are the package root's, as every other path a member takes. +inline std::filesystem::path absolute_from_root(const std::string& p) { + std::filesystem::path path(p); + if (path.is_relative()) path = std::filesystem::path(mcpp::manifest_dir()) / path; + return path.lexically_normal(); +} + +inline std::string generic(const std::filesystem::path& p) { + return p.lexically_normal().generic_string(); +} + +// The nearest directory at or above `start` that holds `file`. A workspace +// keeps one `vcpkg.json` at its root and its members one level down, so the +// member's own directory is where the search begins, not where it ends. +inline std::filesystem::path find_upward(std::filesystem::path start, std::string_view file) { + std::error_code ec; + for (auto dir = start.lexically_normal(); !dir.empty(); ) { + if (std::filesystem::is_regular_file(dir / file, ec)) return dir; + auto parent = dir.parent_path(); + if (parent == dir) break; + dir = parent; + } + return {}; +} + +// Every regular file under `dir`, for an action's inputs. Version-control and +// build-output directories are skipped: they change on every build and are no +// part of what the installer reads. +inline std::vector files_under(const std::filesystem::path& dir) { + std::vector out; + std::error_code ec; + if (!std::filesystem::is_directory(dir, ec)) return out; + for (auto it = std::filesystem::recursive_directory_iterator( + dir, std::filesystem::directory_options::skip_permission_denied, ec); + it != std::filesystem::recursive_directory_iterator(); it.increment(ec)) { + if (ec) break; + const auto name = it->path().filename().string(); + if (it->is_directory(ec)) { + if (name == ".git" || name == "target" || name == "out" || name == "build" || + name == "Install" || name == "vcpkg_installed") + it.disable_recursion_pending(); + continue; + } + if (it->is_regular_file(ec)) out.push_back(generic(it->path())); + } + std::ranges::sort(out); + return out; +} + +// The file a library name denotes under `lib_dir`. A name that already carries +// an extension is a file name and is taken as written; otherwise the target's +// convention decides: `.lib` on Windows (an import library and a static +// library have the same name there), `lib.so` / `.dylib` for a shared +// library elsewhere, `lib.a` for a static one. +// +// A FULL PATH, NOT `-l`. `-lz` resolves through every search directory +// on the line, so a system `libz` can stand in for the prefix's without a +// word; a path names exactly one file. +inline std::filesystem::path library_file(const std::filesystem::path& lib_dir, + const std::string& name, bool shared) { + std::filesystem::path given(name); + if (given.is_absolute()) return given; + if (given.has_extension()) { + const auto ext = given.extension().string(); + if (ext == ".lib" || ext == ".a" || ext == ".so" || ext == ".dylib" || ext == ".tbd") + return lib_dir / given; + } + if (is_windows()) return lib_dir / (name + ".lib"); + const std::string stem = name.starts_with("lib") ? name : "lib" + name; + if (!shared) return lib_dir / (stem + ".a"); + return lib_dir / (stem + (is_macos() ? ".dylib" : ".so")); +} + +// Links each library by its full path, and says which ones are missing once +// the prefix exists. The path is stated whether or not the file exists yet: +// the link edge runs after the installation, and a line that depended on what +// happened to be on disk at plan time would differ between the first build and +// the second. +inline void link_libraries(const std::filesystem::path& lib_dir, + std::span names, bool shared, + bool prefix_exists, std::string_view member) { + std::vector missing; + std::error_code ec; + for (auto const& name : names) { + const auto file = library_file(lib_dir, name, shared); + mcpp::link_flag(generic(file).c_str()); + if (prefix_exists && !std::filesystem::exists(file, ec)) missing.push_back(generic(file)); + } + if (missing.empty()) return; + std::string present; + for (auto const& e : std::filesystem::directory_iterator(lib_dir, ec)) { + if (!e.is_regular_file(ec)) continue; + const auto ext = e.path().extension().string(); + if (ext == ".lib" || ext == ".a" || ext == ".so" || ext == ".dylib") + present += "\n " + e.path().filename().string(); + } + std::string list; + for (auto const& m : missing) list += "\n " + m; + warn(std::format("{}: {} listed librar{} not found in the installed prefix:{}\n" + " `libraries` names files in {} (a name, or a file name with its " + "extension). Present there:{}", + member, missing.size(), missing.size() == 1 ? "y is" : "ies are", list, + generic(lib_dir), present.empty() ? std::string(" (none)") : present)); +} + +// The directory a prefix's shared libraries are loaded from at run time: +// `mcpp run` searches it, `mcpp pack` collects from it, and off Windows the +// program also carries it as a run path, so it starts outside `mcpp run` the +// way it does inside -- a shared library installed with CMake's default +// `@rpath/` install name is found by nothing else on macOS. +inline void runtime_directory(const std::string& bin, const std::string& lib) { + if (is_windows()) { + mcpp::runtime_library_dir(bin.c_str()); + return; + } + mcpp::runtime_library_dir(lib.c_str()); + const std::string rpath = "-Wl,-rpath," + lib; + mcpp::link_flag(rpath.c_str()); +} + +} // namespace mcpp::deps diff --git a/deps/vcpkg.cppm b/deps/vcpkg.cppm new file mode 100644 index 0000000..01b3470 --- /dev/null +++ b/deps/vcpkg.cppm @@ -0,0 +1,284 @@ +// mcpp.deps.vcpkg -- the libraries a vcpkg manifest names, installed as a build +// action and mapped into the build. +// +// A project that keeps its third-party C and C++ libraries in `vcpkg.json` +// writes one call: +// +// mcpp::deps::vcpkg::options o; +// o.libraries = { "fmt" }; +// return mcpp::deps::vcpkg::use(o) ? 0 : 1; +// +// and three things follow, none of which the project states again: +// +// 1. INSTALLATION IS AN EDGE. One `role = "check"`, `blocking = true` action +// runs `vcpkg install` for the manifest; this package's compile edges wait +// for it. It re-runs when `vcpkg.json`, `vcpkg-configuration.json` or an +// overlay changes, and never under `mcpp emit build-database`. +// 2. THE PREFIX REACHES THE BUILD. `//include` is an +// include directory; each listed library is linked by its full path; the +// directory holding the prefix's shared libraries is a runtime library +// directory (`mcpp::runtime_library_dir`, mcpp 2026.9.27.1+), so `mcpp +// run` finds them and `mcpp pack` carries them. +// 3. THE TOOL IS A PAYLOAD. `xim:vcpkg` is the tool together with the +// scripts released with it (vcpkg-tool's standalone bundle), declared by +// this feature. A `builtin-baseline` manifest resolves through vcpkg's +// git registry into vcpkg's per-user registry cache, so no clone of +// microsoft/vcpkg is made or managed per project. +// +// THE LIBRARY LIST IS EXPLICIT. On a project's first build the build program +// runs before the installation, when vcpkg's own record of what it installed +// does not exist yet; a link line derived from it would differ between the +// first build and the second. The names are the files under `/lib`, +// and a name that does not match one is reported with the files that do. +// +// NOT HERE: vcpkg's classic mode; a second resolver of versions (vcpkg's +// baseline and overrides decide them); modules for the libraries' headers. + +export module mcpp.deps.vcpkg; + +import std; +import mcpp; +import mcpp.plugins; +import mcpp.deps; + +export namespace mcpp::deps::vcpkg { + +struct options { + // The vcpkg triplet. Empty derives it from the target: `x64-windows`, + // `arm64-windows`, `x64-mingw-dynamic`, `x64-linux`, `arm64-linux`, + // `x64-osx`, `arm64-osx`. A custom triplet is named here and found through + // the manifest's `overlay-triplets` like any other. + std::string triplet; + // Library names in link order: `fmt` denotes `lib/fmt.lib` on Windows and + // `lib/libfmt.a` or `lib/libfmt.so` elsewhere; a name with an extension + // (`libzstd.so`) is a file name under `lib/`. + std::vector libraries; + // The directory holding `vcpkg.json`. Empty searches upward from the + // package root, so the members of a workspace find the manifest at its + // root. + std::string manifest_root; + // Where vcpkg installs. Empty is vcpkg's own default, + // `/vcpkg_installed`. + std::string install_root; + // Further overlay-triplet directories, beside the manifest's own. + std::vector overlay_triplets; + // Arguments appended to `vcpkg install` (`--x-feature=…`, `--allow-unsupported`). + std::vector install_args; + // The vcpkg root. Empty is the `xim:vcpkg` payload this feature declares. + std::string vcpkg_root; +}; + +// The installed prefix, stated whether or not it exists yet. +struct prefix { + std::string root; // / + std::string include; // root/include + std::string lib; // root/lib + std::string bin; // root/bin + std::string share; // root/share + std::string triplet; + bool installed = false; // the prefix existed when this program ran + explicit operator bool() const { return !root.empty(); } +}; + +// ─── The triplet ─────────────────────────────────────────────────────────── + +inline std::string default_triplet() { + const std::string os = mcpp::target_os(), arch = mcpp::target_arch(), env = mcpp::target_env(); + const std::string a = arch == "x86_64" ? "x64" + : arch == "aarch64" ? "arm64" + : (arch == "i686" || arch == "x86") ? "x86" : arch; + if (os == "windows") return env == "gnu" ? a + "-mingw-dynamic" : a + "-windows"; + if (os == "macos") return a + "-osx"; + if (os == "linux") return a + "-linux"; + return {}; +} + +// The `overlay-triplets` a `vcpkg-configuration.json` beside the manifest +// names, resolved against the file's directory as vcpkg resolves them. +inline std::vector manifest_overlays(const std::filesystem::path& manifest_root, + std::string_view key) { + std::vector out; + const auto file = manifest_root / "vcpkg-configuration.json"; + std::ifstream in(file, std::ios::binary); + if (!in) return out; + const std::string text{std::istreambuf_iterator(in), {}}; + mcpp::plugins::json::value doc; + if (!mcpp::plugins::json::parse_json(text, doc)) return out; + if (auto const* list = doc.get(key)) { + for (auto const& item : list->items) { + std::filesystem::path p(item.text); + if (p.is_relative()) p = manifest_root / p; + out.push_back(p.lexically_normal()); + } + } + return out; +} + +// Whether the triplet links libraries as shared objects. Read from the +// triplet file itself -- `set(VCPKG_LIBRARY_LINKAGE dynamic)` outside any +// `if()`, which is where a per-port exception lives -- and otherwise from +// vcpkg's convention: dynamic on Windows, static elsewhere, a `-dynamic` +// suffix for the community triplets that say so in their name. +inline bool shared_linkage(const std::string& triplet, + std::span search) { + std::error_code ec; + for (auto const& dir : search) { + const auto file = dir / (triplet + ".cmake"); + if (!std::filesystem::is_regular_file(file, ec)) continue; + mcpp::rerun_if_changed(mcpp::deps::generic(file).c_str()); + std::ifstream in(file); + std::string line; + int depth = 0; + while (std::getline(in, line)) { + // By index: under GCC 16 a range-for over a non-const std::string in + // a module unit fails with "inlining failed in call to always_inline + // ... function body not available" (measured on this file). + std::string s(line.size(), ' '); + for (std::size_t i = 0; i < line.size(); ++i) + s[i] = char(std::tolower(static_cast(line[i]))); + const auto first = s.find_first_not_of(" \t"); + if (first == std::string::npos || s[first] == '#') continue; + s = s.substr(first); + if (s.starts_with("if(") || s.starts_with("if (")) ++depth; + else if (s.starts_with("endif(") || s.starts_with("endif (")) { if (depth) --depth; } + else if (depth == 0 && s.starts_with("set(vcpkg_library_linkage")) { + return s.find("dynamic") != std::string::npos; + } + } + break; + } + if (triplet.ends_with("-dynamic")) return true; + if (triplet.ends_with("-static") || triplet.ends_with("-static-md")) return false; + return mcpp::deps::is_windows(); +} + +// ─── The member ──────────────────────────────────────────────────────────── + +inline prefix use(const options& opt = {}) { + namespace fs = std::filesystem; + constexpr std::string_view who = "mcpp.deps.vcpkg"; + mcpp::fact("mcpp.plugins", std::string(mcpp::plugins::version).c_str()); + + // THE MANIFEST. Its absence is a mistake in the project, not a state of + // the machine, so it is the one refusal here. + fs::path manifestRoot = opt.manifest_root.empty() + ? mcpp::deps::find_upward(mcpp::manifest_dir(), "vcpkg.json") + : mcpp::deps::absolute_from_root(opt.manifest_root); + std::error_code ec; + if (manifestRoot.empty() || !fs::is_regular_file(manifestRoot / "vcpkg.json", ec)) { + std::cerr << std::format( + "{}: no vcpkg.json at or above {}.\n" + " This member installs the libraries a vcpkg manifest names; write one\n" + " (`vcpkg new --application` writes a minimal one), or name its directory\n" + " with options::manifest_root.\n", + who, opt.manifest_root.empty() ? std::string(mcpp::manifest_dir()) : opt.manifest_root); + return {}; + } + + const std::string triplet = opt.triplet.empty() ? default_triplet() : opt.triplet; + if (triplet.empty()) { + std::cerr << std::format("{}: no default vcpkg triplet for the target '{}'; name one with " + "options::triplet.\n", who, std::string(mcpp::target())); + return {}; + } + + const fs::path installRoot = opt.install_root.empty() + ? manifestRoot / "vcpkg_installed" : mcpp::deps::absolute_from_root(opt.install_root); + const fs::path root = installRoot / triplet; + + prefix p; + p.root = mcpp::deps::generic(root); + p.include = mcpp::deps::generic(root / "include"); + p.lib = mcpp::deps::generic(root / "lib"); + p.bin = mcpp::deps::generic(root / "bin"); + p.share = mcpp::deps::generic(root / "share"); + p.triplet = triplet; + // `vcpkg/status` is written by a completed installation, so an install + // root that a failed first run left behind does not count as installed. + const fs::path status = installRoot / "vcpkg" / "status"; + p.installed = fs::is_regular_file(status, ec) && fs::is_directory(root, ec); + mcpp::rerun_if_changed(mcpp::deps::generic(status).c_str()); + + // ── the tool ── + const std::string vcpkgRoot = opt.vcpkg_root.empty() + ? std::string(mcpp::xpkg_dir("xim", "vcpkg")) : mcpp::deps::generic(mcpp::deps::absolute_from_root(opt.vcpkg_root)); + const fs::path exe = vcpkgRoot.empty() ? fs::path() + : fs::path(vcpkgRoot) / (std::string(mcpp::host()).find("windows") != std::string::npos + ? "vcpkg.exe" : "vcpkg"); + + // ── the overlays: the manifest's own, then the project's extras ── + std::vector overlayTriplets = manifest_overlays(manifestRoot, "overlay-triplets"); + for (auto const& d : opt.overlay_triplets) overlayTriplets.push_back(mcpp::deps::absolute_from_root(d)); + const std::vector overlayPorts = manifest_overlays(manifestRoot, "overlay-ports"); + + std::vector tripletSearch = overlayTriplets; + if (!vcpkgRoot.empty()) { + tripletSearch.push_back(fs::path(vcpkgRoot) / "triplets"); + tripletSearch.push_back(fs::path(vcpkgRoot) / "triplets" / "community"); + } + const bool shared = shared_linkage(triplet, tripletSearch); + + // ── the installation, as an edge ── + const fs::path manifestFile = manifestRoot / "vcpkg.json"; + const fs::path configFile = manifestRoot / "vcpkg-configuration.json"; + mcpp::rerun_if_changed(mcpp::deps::generic(configFile).c_str()); + if (exe.empty() || !fs::is_regular_file(exe, ec)) { + mcpp::deps::warn(std::format( + "{}: the vcpkg tool is not installed (xpkg_dir(\"xim\", \"vcpkg\") answered \"{}\"), " + "so this plan installs nothing. The `deps-vcpkg` feature declares `xim:vcpkg`; " + "`mcpp build` provisions it before this program runs.", who, vcpkgRoot)); + } else if (const std::string tool = mcpp::deps::launcher(who, "deps-vcpkg"); tool.empty()) { + return {}; + } else { + const std::string stamp = mcpp::deps::generic( + fs::path(mcpp::out_dir()) / "deps-vcpkg" / (triplet + ".stamp")); + const std::string id = "deps-vcpkg:install:" + triplet; + const std::string desc = "VCPKG install " + triplet; + const std::string exeS = mcpp::deps::generic(exe); + const std::string mRoot = mcpp::deps::generic(manifestRoot); + const std::string iRoot = mcpp::deps::generic(installRoot); + mcpp::action a; + a.id = id.c_str(); + a.role = "check"; + a.blocking = true; + a.description = desc.c_str(); + a.arg(tool.c_str()).arg("vcpkg") + .arg("--vcpkg").arg(exeS.c_str()) + .arg("--root").arg(vcpkgRoot.c_str()) + .arg("--manifest-root").arg(mRoot.c_str()) + .arg("--install-root").arg(iRoot.c_str()) + .arg("--triplet").arg(triplet.c_str()); + // `arg()` and `input()` copy what they are given, so the temporaries + // below need not outlive the call. + for (auto const& d : opt.overlay_triplets) + a.arg("--overlay-triplets").arg(mcpp::deps::generic(mcpp::deps::absolute_from_root(d)).c_str()); + if (!opt.install_args.empty()) { + a.arg("--"); + for (auto const& x : opt.install_args) a.arg(x.c_str()); + } + a.input(tool.c_str()); + a.input(mcpp::deps::generic(manifestFile).c_str()); + if (fs::is_regular_file(configFile, ec)) a.input(mcpp::deps::generic(configFile).c_str()); + // An overlay's files are inputs: a changed patch or triplet is a + // different installation. + for (auto const& d : overlayTriplets) + for (auto const& f : mcpp::deps::files_under(d)) a.input(f.c_str()); + for (auto const& d : overlayPorts) + for (auto const& f : mcpp::deps::files_under(d)) a.input(f.c_str()); + a.output(stamp.c_str()); + a.submit(); + mcpp::rerun_if_changed(stamp.c_str()); + } + + // ── the prefix, into the build ── + mcpp::include_dir(p.include.c_str()); + mcpp::deps::link_libraries(root / "lib", opt.libraries, shared, p.installed, who); + if (shared) mcpp::deps::runtime_directory(p.bin, p.lib); + if (!p.installed) + mcpp::deps::warn(std::format( + "{}: {} is not installed yet; the paths above are where `mcpp build` installs " + "it (triplet {}).", who, p.root, triplet)); + return p; +} + +} // namespace mcpp::deps::vcpkg diff --git a/tests/vcpkg-consumer/build.mcpp b/tests/vcpkg-consumer/build.mcpp new file mode 100644 index 0000000..ac08fc9 --- /dev/null +++ b/tests/vcpkg-consumer/build.mcpp @@ -0,0 +1,11 @@ +// The whole integration: which libraries to link. The installation, the +// include directory and the runtime library directory follow from the manifest. +import std; +import mcpp; +import mcpp.deps.vcpkg; + +int main() { + mcpp::deps::vcpkg::options o; + o.libraries = { "fmt" }; + return mcpp::deps::vcpkg::use(o) ? 0 : 1; +} diff --git a/tests/vcpkg-consumer/mcpp.toml b/tests/vcpkg-consumer/mcpp.toml new file mode 100644 index 0000000..e687885 --- /dev/null +++ b/tests/vcpkg-consumer/mcpp.toml @@ -0,0 +1,25 @@ +# Fixture: a program whose one third-party library comes from a vcpkg manifest. +# +# `vcpkg.json` names `fmt` at the builtin baseline of vcpkg's 2026.07.29 +# release -- the registry commit that states the tool release `xim:vcpkg` +# carries (`scripts/vcpkg-tool-metadata.txt`: 2026-07-27). The triplet is the +# target's default: `x64-windows` (a DLL), `x64-linux` and `arm64-osx` (an +# archive), so one fixture measures both link forms across the matrix. +[package] +name = "vcpkg-consumer" +version = "0.1.0" +description = "Fixture: links a library a vcpkg manifest names" +license = "Apache-2.0" +authors = ["mcpp-community"] + +[language] +standard = "c++23" +modules = true +import_std = true + +[build-dependencies.mcpp] +plugins = { path = "../..", features = ["deps-vcpkg"], host-module = true, tools = ["mcpp-deps"] } + +[targets.vcpkg-consumer] +kind = "bin" +main = "src/main.cpp" diff --git a/tests/vcpkg-consumer/src/main.cpp b/tests/vcpkg-consumer/src/main.cpp new file mode 100644 index 0000000..0be1a59 --- /dev/null +++ b/tests/vcpkg-consumer/src/main.cpp @@ -0,0 +1,10 @@ +// Formats through fmt, so the program links it and -- on Windows, where the +// default triplet builds fmt as a DLL -- loads it at run time. +#include + +#include + +int main() { + std::puts(fmt::format("vcpkg-consumer: fmt {} says {}", FMT_VERSION / 10000, 6 * 7).c_str()); + return 0; +} diff --git a/tests/vcpkg-consumer/vcpkg.json b/tests/vcpkg-consumer/vcpkg.json new file mode 100644 index 0000000..9d40ac4 --- /dev/null +++ b/tests/vcpkg-consumer/vcpkg.json @@ -0,0 +1,6 @@ +{ + "name": "vcpkg-consumer", + "version": "0.1.0", + "dependencies": ["fmt"], + "builtin-baseline": "9e593bb18ea69cc5095e012465dcd675a822ed0d" +} diff --git a/tests/vcpkg-workspace/app-a/build.mcpp b/tests/vcpkg-workspace/app-a/build.mcpp new file mode 100644 index 0000000..954a945 --- /dev/null +++ b/tests/vcpkg-workspace/app-a/build.mcpp @@ -0,0 +1,10 @@ +// The manifest is the workspace root's: `use()` finds it by searching upward. +import std; +import mcpp; +import mcpp.deps.vcpkg; + +int main() { + mcpp::deps::vcpkg::options o; + o.libraries = { "fmt" }; + return mcpp::deps::vcpkg::use(o) ? 0 : 1; +} diff --git a/tests/vcpkg-workspace/app-a/mcpp.toml b/tests/vcpkg-workspace/app-a/mcpp.toml new file mode 100644 index 0000000..a1eb757 --- /dev/null +++ b/tests/vcpkg-workspace/app-a/mcpp.toml @@ -0,0 +1,15 @@ +[package] +name = "app-a" +namespace = "example" +description = "Fixture: workspace member a" + +[language] +modules = true +import_std = true + +[build-dependencies.mcpp] +plugins = { path = "../../..", features = ["deps-vcpkg"], host-module = true, tools = ["mcpp-deps"] } + +[targets.app-a] +kind = "bin" +main = "src/main.cpp" diff --git a/tests/vcpkg-workspace/app-a/src/main.cpp b/tests/vcpkg-workspace/app-a/src/main.cpp new file mode 100644 index 0000000..316e967 --- /dev/null +++ b/tests/vcpkg-workspace/app-a/src/main.cpp @@ -0,0 +1,8 @@ +#include + +#include + +int main() { + std::puts(fmt::format("app-a: fmt {}", FMT_VERSION / 10000).c_str()); + return 0; +} diff --git a/tests/vcpkg-workspace/app-b/build.mcpp b/tests/vcpkg-workspace/app-b/build.mcpp new file mode 100644 index 0000000..954a945 --- /dev/null +++ b/tests/vcpkg-workspace/app-b/build.mcpp @@ -0,0 +1,10 @@ +// The manifest is the workspace root's: `use()` finds it by searching upward. +import std; +import mcpp; +import mcpp.deps.vcpkg; + +int main() { + mcpp::deps::vcpkg::options o; + o.libraries = { "fmt" }; + return mcpp::deps::vcpkg::use(o) ? 0 : 1; +} diff --git a/tests/vcpkg-workspace/app-b/mcpp.toml b/tests/vcpkg-workspace/app-b/mcpp.toml new file mode 100644 index 0000000..84fdae7 --- /dev/null +++ b/tests/vcpkg-workspace/app-b/mcpp.toml @@ -0,0 +1,15 @@ +[package] +name = "app-b" +namespace = "example" +description = "Fixture: workspace member b" + +[language] +modules = true +import_std = true + +[build-dependencies.mcpp] +plugins = { path = "../../..", features = ["deps-vcpkg"], host-module = true, tools = ["mcpp-deps"] } + +[targets.app-b] +kind = "bin" +main = "src/main.cpp" diff --git a/tests/vcpkg-workspace/app-b/src/main.cpp b/tests/vcpkg-workspace/app-b/src/main.cpp new file mode 100644 index 0000000..82056cb --- /dev/null +++ b/tests/vcpkg-workspace/app-b/src/main.cpp @@ -0,0 +1,8 @@ +#include + +#include + +int main() { + std::puts(fmt::format("app-b: fmt {}", FMT_VERSION / 10000).c_str()); + return 0; +} diff --git a/tests/vcpkg-workspace/mcpp.toml b/tests/vcpkg-workspace/mcpp.toml new file mode 100644 index 0000000..0b3a846 --- /dev/null +++ b/tests/vcpkg-workspace/mcpp.toml @@ -0,0 +1,15 @@ +# Fixture: two workspace members that link one vcpkg prefix and depend on +# neither each other nor a common member. +# +# mcpp orders a `blocking` action before the compile edges of the package that +# declares it and of no other, so each member declares the installation +# itself. The two actions run the same `vcpkg install` against the root's one +# manifest; `mcpp-deps` holds a lock on the installation root, so they run one +# after the other and the second finds everything installed. +[workspace] +members = ["app-a", "app-b"] + +[workspace.package] +version = "0.1.0" +standard = "c++23" +license = "Apache-2.0" diff --git a/tests/vcpkg-workspace/vcpkg.json b/tests/vcpkg-workspace/vcpkg.json new file mode 100644 index 0000000..e576298 --- /dev/null +++ b/tests/vcpkg-workspace/vcpkg.json @@ -0,0 +1,6 @@ +{ + "name": "vcpkg-workspace", + "version": "0.1.0", + "dependencies": ["fmt"], + "builtin-baseline": "9e593bb18ea69cc5095e012465dcd675a822ed0d" +} diff --git a/tools/deps_main.cpp b/tools/deps_main.cpp new file mode 100644 index 0000000..3bb512e --- /dev/null +++ b/tools/deps_main.cpp @@ -0,0 +1,334 @@ +// mcpp-deps -- the command of every installation a `deps-*` member declares. +// +// WHY A PROGRAM AND NOT THE INSTALLER ITSELF AS THE ACTION'S COMMAND. +// +// An `mcpp::action` is an argument vector and nothing else: it carries no +// environment and no working directory (mcpp's docs/30, "Declaring work instead +// of doing it"). vcpkg is configured through its environment -- `VCPKG_ROOT` +// names the tool's scripts, `VCPKG_DISABLE_METRICS` keeps a build from +// reporting home -- and an installation has three more needs no argument +// vector can state: +// +// - a LOCK on the installation root, because two workspace members that +// both use one prefix each declare the installation (the engine orders +// `blocking` actions per package), and two installers writing one tree at +// once is a corrupt tree; +// - SHORT scratch directories for vcpkg's build trees, outside the project, +// because a port's build nests deep and Windows still enforces MAX_PATH on +// many of the tools a port runs. +// +// This program sets those up and runs the installer as a child, waiting for it +// and returning its status; the action is a `check`, whose stamp mcpp writes +// when the status is 0. It holds no knowledge of any project: every value +// arrives on its command line from the member that planned the action. +// +// Usage: +// mcpp-deps vcpkg --vcpkg --root --manifest-root +// --install-root --triplet +// [--overlay-triplets ]... [-- ...] +// mcpp-deps cmake --cmake --source --build --prefix +// [--config ] [--generator ] +// [-- ...] +#if defined(_WIN32) +# define WIN32_LEAN_AND_MEAN +# define NOMINMAX +# include +#else +# include +# include +# include +# include +# include +extern char** environ; +#endif +#include + +import std; + +namespace { + +namespace fs = std::filesystem; + +[[noreturn]] void usage(std::string_view why) { + std::cerr << "mcpp-deps: " << why << "\n" + << "usage: mcpp-deps vcpkg --vcpkg --root --manifest-root " + "--install-root --triplet [--overlay-triplets ]... " + "[-- ...]\n" + << " mcpp-deps cmake --cmake --source --build --prefix " + "[--config ] [--generator ] [-- ...]\n"; + std::exit(2); +} + +struct args { + std::map> named; + std::vector rest; // after `--` + + const std::string& one(const std::string& key) const { + auto it = named.find(key); + if (it == named.end() || it->second.empty()) usage("missing --" + key); + return it->second.back(); + } + std::string opt(const std::string& key, std::string fallback = {}) const { + auto it = named.find(key); + return it == named.end() || it->second.empty() ? fallback : it->second.back(); + } + std::vector all(const std::string& key) const { + auto it = named.find(key); + return it == named.end() ? std::vector{} : it->second; + } +}; + +args parse(int argc, char** argv, int from) { + args a; + for (int i = from; i < argc; ++i) { + std::string_view s = argv[i]; + if (s == "--") { + for (++i; i < argc; ++i) a.rest.emplace_back(argv[i]); + break; + } + if (!s.starts_with("--") || i + 1 >= argc) usage(std::format("unexpected `{}`", s)); + a.named[std::string(s.substr(2))].emplace_back(argv[++i]); + } + return a; +} + +std::string env(const char* name) { + const char* v = std::getenv(name); + return v ? std::string(v) : std::string(); +} + +// ── The platform: environment, a child process, a lock ───────────────────── + +#if defined(_WIN32) +std::wstring wide(std::string_view s) { + if (s.empty()) return {}; + const int n = ::MultiByteToWideChar(CP_UTF8, 0, s.data(), int(s.size()), nullptr, 0); + std::wstring w(std::size_t(n), L'\0'); + ::MultiByteToWideChar(CP_UTF8, 0, s.data(), int(s.size()), w.data(), n); + return w; +} + +void set_env(const std::string& name, const std::string& value) { + ::SetEnvironmentVariableW(wide(name).c_str(), value.empty() ? nullptr : wide(value).c_str()); +} + +// The quoting `CommandLineToArgvW` and the C runtime both undo: backslashes +// are literal unless they precede a quote, and then they are doubled. +std::wstring quote(const std::wstring& arg) { + if (!arg.empty() && arg.find_first_of(L" \t\n\v\"") == std::wstring::npos) return arg; + std::wstring out = L"\""; + for (std::size_t i = 0;; ++i) { + std::size_t backslashes = 0; + while (i < arg.size() && arg[i] == L'\\') { ++i; ++backslashes; } + if (i == arg.size()) { out.append(backslashes * 2, L'\\'); break; } + if (arg[i] == L'"') { out.append(backslashes * 2 + 1, L'\\'); out += L'"'; } + else { out.append(backslashes, L'\\'); out += arg[i]; } + } + return out + L"\""; +} + +int run(const std::vector& argv) { + std::wstring line; + for (auto const& a : argv) { + if (!line.empty()) line += L' '; + line += quote(wide(a)); + } + STARTUPINFOW si{}; + si.cb = sizeof si; + PROCESS_INFORMATION pi{}; + if (!::CreateProcessW(nullptr, line.data(), nullptr, nullptr, TRUE, 0, nullptr, nullptr, + &si, &pi)) { + std::cerr << std::format("mcpp-deps: cannot start {} (error {})\n", argv.front(), + ::GetLastError()); + return 127; + } + ::WaitForSingleObject(pi.hProcess, INFINITE); + DWORD code = 1; + ::GetExitCodeProcess(pi.hProcess, &code); + ::CloseHandle(pi.hThread); + ::CloseHandle(pi.hProcess); + return int(code); +} + +struct file_lock { + HANDLE h = INVALID_HANDLE_VALUE; + explicit file_lock(const fs::path& p) { + h = ::CreateFileW(p.wstring().c_str(), GENERIC_READ | GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, nullptr, + OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr); + if (h == INVALID_HANDLE_VALUE) return; + OVERLAPPED o{}; + ::LockFileEx(h, LOCKFILE_EXCLUSIVE_LOCK, 0, MAXDWORD, MAXDWORD, &o); + } + ~file_lock() { + if (h == INVALID_HANDLE_VALUE) return; + OVERLAPPED o{}; + ::UnlockFileEx(h, 0, MAXDWORD, MAXDWORD, &o); + ::CloseHandle(h); + } + bool held() const { return h != INVALID_HANDLE_VALUE; } +}; +#else +void set_env(const std::string& name, const std::string& value) { + if (value.empty()) ::unsetenv(name.c_str()); + else ::setenv(name.c_str(), value.c_str(), 1); +} + +int run(const std::vector& argv) { + std::vector cargv; + for (auto const& a : argv) cargv.push_back(const_cast(a.c_str())); + cargv.push_back(nullptr); + pid_t pid = 0; + if (::posix_spawnp(&pid, cargv[0], nullptr, nullptr, cargv.data(), environ) != 0) { + std::cerr << std::format("mcpp-deps: cannot start {}\n", argv.front()); + return 127; + } + int status = 0; + while (::waitpid(pid, &status, 0) < 0) {} + if (WIFEXITED(status)) return WEXITSTATUS(status); + return 128 + (WIFSIGNALED(status) ? WTERMSIG(status) : 0); +} + +struct file_lock { + int fd = -1; + explicit file_lock(const fs::path& p) { + fd = ::open(p.c_str(), O_RDWR | O_CREAT, 0644); + if (fd >= 0) ::flock(fd, LOCK_EX); + } + ~file_lock() { + if (fd < 0) return; + ::flock(fd, LOCK_UN); + ::close(fd); + } + bool held() const { return fd >= 0; } +}; +#endif + +// FNV-1a: a stable name for a directory derived from a path. Stable across +// runs and compilers, which `std::hash` does not promise. +std::string short_name(std::string_view text) { + std::uint64_t h = 1469598103934665603ull; + for (unsigned char c : text) { h ^= c; h *= 1099511628211ull; } + return std::format("{:08x}", std::uint32_t(h ^ (h >> 32))); +} + +// vcpkg's own per-user directory: where its default binary cache +// (`archives/`) and registry cache (`registries/`) already live (vcpkg's +// "Default binary cache" documentation). Scratch and downloads go beside them, +// so everything vcpkg keeps for a user is in the place vcpkg's own +// documentation sends them to look. +fs::path vcpkg_user_dir() { +#if defined(_WIN32) + if (auto v = env("LOCALAPPDATA"); !v.empty()) return fs::path(v) / "vcpkg"; + if (auto v = env("APPDATA"); !v.empty()) return fs::path(v) / "vcpkg"; +#else + if (auto v = env("XDG_CACHE_HOME"); !v.empty()) return fs::path(v) / "vcpkg"; + if (auto v = env("HOME"); !v.empty()) return fs::path(v) / ".cache" / "vcpkg"; +#endif + return fs::temp_directory_path() / "vcpkg"; +} + +// ── vcpkg ────────────────────────────────────────────────────────────────── + +int vcpkg_install(const args& a) { + const fs::path exe = a.one("vcpkg"); + const fs::path root = a.one("root"); + const fs::path manifestRoot = a.one("manifest-root"); + const fs::path installRoot = a.one("install-root"); + const std::string triplet = a.one("triplet"); + + std::error_code ec; + fs::create_directories(installRoot, ec); + file_lock lock(installRoot / ".mcpp-deps.lock"); + if (!lock.held()) + std::cerr << "mcpp-deps: could not lock " << installRoot.string() + << "; continuing without the lock\n"; + + // THE TOOL'S OWN SCRIPTS, NOT WHATEVER `VCPKG_ROOT` THE SHELL HAS. The + // root this program is given is the standalone bundle published with the + // tool, so the scripts a port calls are the ones this tool was released + // with. An inherited `VCPKG_ROOT` naming a clone at another commit would + // pair this tool with scripts it was not tested against. + set_env("VCPKG_ROOT", root.string()); + set_env("VCPKG_DISABLE_METRICS", "1"); + + const fs::path user = vcpkg_user_dir(); + const fs::path work = user / "mcpp" / short_name(fs::absolute(installRoot).generic_string()); + + std::vector cmd{ + exe.string(), "install", + "--triplet", triplet, + "--x-manifest-root=" + manifestRoot.string(), + "--x-install-root=" + installRoot.string(), + "--x-buildtrees-root=" + (work / "bt").string(), + "--x-packages-root=" + (work / "pk").string(), + "--clean-buildtrees-after-build", + "--clean-packages-after-build", + }; + // Downloads are shared by every project on the machine; a user who has + // moved them already (`VCPKG_DOWNLOADS`) keeps that. + if (env("VCPKG_DOWNLOADS").empty()) + cmd.push_back("--downloads-root=" + (user / "downloads").string()); + for (auto const& d : a.all("overlay-triplets")) cmd.push_back("--overlay-triplets=" + d); + for (auto const& r : a.rest) cmd.push_back(r); + + std::cerr << "mcpp-deps: vcpkg install --triplet " << triplet << " (" + << manifestRoot.string() << " -> " << installRoot.string() << ")\n"; + const int code = run(cmd); + if (code != 0) { + std::cerr << std::format("mcpp-deps: vcpkg install exited {}\n", code); + return code; + } + return 0; +} + +// ── CMake ────────────────────────────────────────────────────────────────── + +int cmake_install(const args& a) { + const fs::path cmake = a.one("cmake"); + const fs::path source = a.one("source"); + const fs::path build = a.one("build"); + const fs::path prefix = a.one("prefix"); + const std::string config = a.opt("config", "Release"); + + std::error_code ec; + fs::create_directories(build, ec); + file_lock lock(build / ".mcpp-deps.lock"); + + // CONFIGURE EVERY TIME, AND LET CMAKE DECIDE WHAT THAT COSTS. A configure + // over an existing cache re-runs only what changed, and the arguments may + // have changed -- which is why this action ran at all. + std::vector configure{ + cmake.string(), "-S", source.string(), "-B", build.string(), + "-DCMAKE_INSTALL_PREFIX=" + prefix.string(), + "-DCMAKE_BUILD_TYPE=" + config, + }; + if (auto g = a.opt("generator"); !g.empty()) { configure.push_back("-G"); configure.push_back(g); } + for (auto const& r : a.rest) configure.push_back(r); + std::cerr << "mcpp-deps: cmake configure " << source.string() << "\n"; + if (int code = run(configure); code != 0) { + std::cerr << std::format("mcpp-deps: cmake configure exited {}\n", code); + return code; + } + std::vector install{ + cmake.string(), "--build", build.string(), "--config", config, + "--target", "install", "--parallel", + }; + std::cerr << "mcpp-deps: cmake build and install -> " << prefix.string() << "\n"; + if (int code = run(install); code != 0) { + std::cerr << std::format("mcpp-deps: cmake --build exited {}\n", code); + return code; + } + return 0; +} + +} // namespace + +int main(int argc, char** argv) { + if (argc < 2) usage("no subcommand"); + const std::string_view sub = argv[1]; + const args a = parse(argc, argv, 2); + if (sub == "vcpkg") return vcpkg_install(a); + if (sub == "cmake") return cmake_install(a); + usage(std::format("unknown subcommand `{}`", sub)); +} From 31b1991d0e54ac0fb26d75453dc89c90984af0e4 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sat, 26 Sep 2026 06:21:00 +0800 Subject: [PATCH 02/13] deps-cmake: a CMake subproject configured, built and installed as one action, and its prefix mapped into the build --- deps/cmake.cppm | 167 +++++++++++++++++++++ tests/cmake-consumer/build.mcpp | 11 ++ tests/cmake-consumer/greet/CMakeLists.txt | 11 ++ tests/cmake-consumer/greet/greet.c | 3 + tests/cmake-consumer/greet/include/greet.h | 8 + tests/cmake-consumer/mcpp.toml | 20 +++ tests/cmake-consumer/src/main.cpp | 8 + 7 files changed, 228 insertions(+) create mode 100644 deps/cmake.cppm create mode 100644 tests/cmake-consumer/build.mcpp create mode 100644 tests/cmake-consumer/greet/CMakeLists.txt create mode 100644 tests/cmake-consumer/greet/greet.c create mode 100644 tests/cmake-consumer/greet/include/greet.h create mode 100644 tests/cmake-consumer/mcpp.toml create mode 100644 tests/cmake-consumer/src/main.cpp diff --git a/deps/cmake.cppm b/deps/cmake.cppm new file mode 100644 index 0000000..cd5458c --- /dev/null +++ b/deps/cmake.cppm @@ -0,0 +1,167 @@ +// mcpp.deps.cmake -- a CMake subproject, built and installed as a build action +// and mapped into the build. +// +// A project that carries a library as a CMake subproject -- a git submodule, a +// vendored directory -- writes: +// +// mcpp::deps::cmake::options o; +// o.source = "3rdParty/widgets"; +// o.cache_args = { "-DWIDGETS_BUILD_EXAMPLES=OFF" }; +// o.libraries = { "widgets" }; +// o.shared = true; +// return mcpp::deps::cmake::use(o) ? 0 : 1; +// +// and the subproject is configured, built and installed into a prefix under +// this package's output directory by ONE `blocking` check action, whose inputs +// are the subproject's files, so an edit to it rebuilds it and nothing else +// does. The prefix is then mapped exactly as `mcpp.deps.vcpkg` maps its own: +// include directory, libraries by full path, the shared-library directory as a +// runtime library directory. +// +// THE COMPILER IS CMAKE'S OWN CHOICE. A subproject is configured the way its +// authors build it -- on Windows, CMake's default generator and the Visual +// Studio toolset it finds -- unless the project passes `-G`, `-DCMAKE_CXX_COMPILER` +// or a toolchain file through `cache_args`. Keeping the C runtime and the C++ +// standard library consistent with the program is the project's decision, as it +// is with any prebuilt library. +// +// `xim:cmake` is declared by this feature; `options::cmake` names another. + +export module mcpp.deps.cmake; + +import std; +import mcpp; +import mcpp.plugins; +import mcpp.deps; + +export namespace mcpp::deps::cmake { + +// Where the subproject installs each kind of file, relative to its prefix. +// A subproject whose `install()` rules nest them (`/Widgets/include`) +// names the nesting here. +struct layout { + std::string include = "include"; + std::string lib = "lib"; + std::string bin = "bin"; +}; + +struct options { + // The subproject's source directory, relative to the package root. + std::string source; + // Names the action and the prefix. Empty takes the source directory's name. + std::string name; + // `-D…`, `-G …` and any other argument for the configure step. + std::vector cache_args; + // Prefixes the subproject's `find_package` searches (`CMAKE_PREFIX_PATH`), + // e.g. `mcpp::rules::qt::root()`. + std::vector prefix_path; + std::string config = "Release"; + layout dirs; + // Library names in link order, as `mcpp.deps.vcpkg` takes them. + std::vector libraries; + // Whether those libraries are shared: decides the file names off Windows + // and whether the prefix's shared-library directory reaches `mcpp run` + // and `mcpp pack`. + bool shared = false; + // The `cmake` executable. Empty is the `xim:cmake` payload. + std::string cmake; +}; + +struct prefix { + std::string root, include, lib, bin; + bool installed = false; + explicit operator bool() const { return !root.empty(); } +}; + +inline std::string cmake_exe(const options& opt) { + namespace fs = std::filesystem; + if (!opt.cmake.empty()) return mcpp::deps::generic(mcpp::deps::absolute_from_root(opt.cmake)); + const std::string dir = mcpp::xpkg_dir("xim", "cmake"); + if (dir.empty()) return {}; + const bool win = std::string(mcpp::host()).find("windows") != std::string::npos; + std::error_code ec; + for (auto const& sub : { fs::path("bin"), fs::path("CMake.app") / "Contents" / "bin" }) { + const auto exe = fs::path(dir) / sub / (win ? "cmake.exe" : "cmake"); + if (fs::is_regular_file(exe, ec)) return mcpp::deps::generic(exe); + } + return {}; +} + +inline prefix use(const options& opt) { + namespace fs = std::filesystem; + constexpr std::string_view who = "mcpp.deps.cmake"; + mcpp::fact("mcpp.plugins", std::string(mcpp::plugins::version).c_str()); + + std::error_code ec; + const fs::path source = mcpp::deps::absolute_from_root(opt.source); + if (opt.source.empty() || !fs::is_regular_file(source / "CMakeLists.txt", ec)) { + std::cerr << std::format( + "{}: options::source must name a directory holding CMakeLists.txt; got '{}'.\n" + " A git submodule that was not checked out is an empty directory: " + "`git submodule update --init`.\n", who, opt.source); + return {}; + } + const std::string name = opt.name.empty() ? source.filename().string() : opt.name; + const fs::path base = fs::path(mcpp::out_dir()) / "deps-cmake" / name; + const fs::path build = base / "build"; + const fs::path root = base / "install"; + + prefix p; + p.root = mcpp::deps::generic(root); + p.include = mcpp::deps::generic(root / opt.dirs.include); + p.lib = mcpp::deps::generic(root / opt.dirs.lib); + p.bin = mcpp::deps::generic(root / opt.dirs.bin); + // `install_manifest.txt` is written by `cmake --install` when it finishes. + p.installed = fs::is_regular_file(build / "install_manifest.txt", ec); + mcpp::rerun_if_changed(mcpp::deps::generic(build / "install_manifest.txt").c_str()); + + const std::string cmake = cmake_exe(opt); + if (cmake.empty()) { + mcpp::deps::warn(std::format( + "{}: no cmake (xpkg_dir(\"xim\", \"cmake\") answered \"{}\"), so this plan builds " + "nothing. The `deps-cmake` feature declares `xim:cmake`; `mcpp build` provisions it " + "before this program runs.", who, std::string(mcpp::xpkg_dir("xim", "cmake")))); + } else if (const std::string tool = mcpp::deps::launcher(who, "deps-cmake"); tool.empty()) { + return {}; + } else { + const std::string stamp = mcpp::deps::generic(base / (name + ".stamp")); + const std::string id = "deps-cmake:" + name; + const std::string desc = "CMAKE " + name; + mcpp::action a; + a.id = id.c_str(); + a.role = "check"; + a.blocking = true; + a.description = desc.c_str(); + a.arg(tool.c_str()).arg("cmake") + .arg("--cmake").arg(cmake.c_str()) + .arg("--source").arg(mcpp::deps::generic(source).c_str()) + .arg("--build").arg(mcpp::deps::generic(build).c_str()) + .arg("--prefix").arg(p.root.c_str()) + .arg("--config").arg(opt.config.c_str()) + .arg("--"); + if (!opt.prefix_path.empty()) { + std::string joined; + for (auto const& d : opt.prefix_path) { + if (!joined.empty()) joined += ';'; + joined += mcpp::deps::generic(mcpp::deps::absolute_from_root(d)); + } + a.arg(("-DCMAKE_PREFIX_PATH=" + joined).c_str()); + } + for (auto const& x : opt.cache_args) a.arg(x.c_str()); + a.input(tool.c_str()); + for (auto const& f : mcpp::deps::files_under(source)) a.input(f.c_str()); + a.output(stamp.c_str()); + a.submit(); + mcpp::rerun_if_changed(stamp.c_str()); + } + + mcpp::include_dir(p.include.c_str()); + mcpp::deps::link_libraries(root / opt.dirs.lib, opt.libraries, opt.shared, p.installed, who); + if (opt.shared) mcpp::deps::runtime_directory(p.bin, p.lib); + if (!p.installed) + mcpp::deps::warn(std::format("{}: {} is not built yet; the paths above are where `mcpp " + "build` installs it.", who, p.root)); + return p; +} + +} // namespace mcpp::deps::cmake diff --git a/tests/cmake-consumer/build.mcpp b/tests/cmake-consumer/build.mcpp new file mode 100644 index 0000000..55cab6f --- /dev/null +++ b/tests/cmake-consumer/build.mcpp @@ -0,0 +1,11 @@ +import std; +import mcpp; +import mcpp.deps.cmake; + +int main() { + mcpp::deps::cmake::options o; + o.source = "greet"; + o.libraries = { "greet" }; + o.shared = true; + return mcpp::deps::cmake::use(o) ? 0 : 1; +} diff --git a/tests/cmake-consumer/greet/CMakeLists.txt b/tests/cmake-consumer/greet/CMakeLists.txt new file mode 100644 index 0000000..3672483 --- /dev/null +++ b/tests/cmake-consumer/greet/CMakeLists.txt @@ -0,0 +1,11 @@ +# A CMake subproject standing in for a vendored library: one shared library, +# installed with the layout `install()` gives it. +cmake_minimum_required(VERSION 3.16) +project(greet LANGUAGES C) + +add_library(greet SHARED greet.c) +target_include_directories(greet PUBLIC $) +set_target_properties(greet PROPERTIES WINDOWS_EXPORT_ALL_SYMBOLS ON) + +install(TARGETS greet RUNTIME DESTINATION bin LIBRARY DESTINATION lib ARCHIVE DESTINATION lib) +install(FILES include/greet.h DESTINATION include) diff --git a/tests/cmake-consumer/greet/greet.c b/tests/cmake-consumer/greet/greet.c new file mode 100644 index 0000000..5a6c8ae --- /dev/null +++ b/tests/cmake-consumer/greet/greet.c @@ -0,0 +1,3 @@ +#include "greet.h" + +int greet_answer(void) { return 42; } diff --git a/tests/cmake-consumer/greet/include/greet.h b/tests/cmake-consumer/greet/include/greet.h new file mode 100644 index 0000000..bdd29e9 --- /dev/null +++ b/tests/cmake-consumer/greet/include/greet.h @@ -0,0 +1,8 @@ +#pragma once +#ifdef __cplusplus +extern "C" { +#endif +int greet_answer(void); +#ifdef __cplusplus +} +#endif diff --git a/tests/cmake-consumer/mcpp.toml b/tests/cmake-consumer/mcpp.toml new file mode 100644 index 0000000..ee9f762 --- /dev/null +++ b/tests/cmake-consumer/mcpp.toml @@ -0,0 +1,20 @@ +# Fixture: a library carried as a CMake subproject (`greet/`), configured, +# built and installed by one action, and linked as a shared library. +[package] +name = "cmake-consumer" +version = "0.1.0" +description = "Fixture: links a shared library a CMake subproject installs" +license = "Apache-2.0" +authors = ["mcpp-community"] + +[language] +standard = "c++23" +modules = true +import_std = true + +[build-dependencies.mcpp] +plugins = { path = "../..", features = ["deps-cmake"], host-module = true, tools = ["mcpp-deps"] } + +[targets.cmake-consumer] +kind = "bin" +main = "src/main.cpp" diff --git a/tests/cmake-consumer/src/main.cpp b/tests/cmake-consumer/src/main.cpp new file mode 100644 index 0000000..71ada59 --- /dev/null +++ b/tests/cmake-consumer/src/main.cpp @@ -0,0 +1,8 @@ +#include + +#include + +int main() { + std::printf("cmake-consumer: greet says %d\n", greet_answer()); + return greet_answer() == 42 ? 0 : 1; +} From 9417bd98031f1d1ddb74ef7eb5c18d97a9ebb325 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sat, 26 Sep 2026 06:21:00 +0800 Subject: [PATCH 03/13] rules-qt: moc, uic, rcc and lrelease as actions, the modules by full path, and the plugins beside the program; rules-qt-xim and rules-qt-xim-addons declare the SDK --- rules/qt.cppm | 603 +++++++++++++++++++++++ tests/qt-consumer/build.mcpp | 11 + tests/qt-consumer/i18n/qt_consumer_de.ts | 11 + tests/qt-consumer/mcpp.toml | 45 ++ tests/qt-consumer/res/app.qrc | 6 + tests/qt-consumer/res/greeting.txt | 1 + tests/qt-consumer/src/counter.h | 23 + tests/qt-consumer/src/main.cpp | 50 ++ tests/qt-widgets-consumer/build.mcpp | 11 + tests/qt-widgets-consumer/mcpp.toml | 41 ++ tests/qt-widgets-consumer/src/main.cpp | 19 + tests/qt-widgets-consumer/ui/form.ui | 20 + 12 files changed, 841 insertions(+) create mode 100644 rules/qt.cppm create mode 100644 tests/qt-consumer/build.mcpp create mode 100644 tests/qt-consumer/i18n/qt_consumer_de.ts create mode 100644 tests/qt-consumer/mcpp.toml create mode 100644 tests/qt-consumer/res/app.qrc create mode 100644 tests/qt-consumer/res/greeting.txt create mode 100644 tests/qt-consumer/src/counter.h create mode 100644 tests/qt-consumer/src/main.cpp create mode 100644 tests/qt-widgets-consumer/build.mcpp create mode 100644 tests/qt-widgets-consumer/mcpp.toml create mode 100644 tests/qt-widgets-consumer/src/main.cpp create mode 100644 tests/qt-widgets-consumer/ui/form.ui diff --git a/rules/qt.cppm b/rules/qt.cppm new file mode 100644 index 0000000..4d35b7e --- /dev/null +++ b/rules/qt.cppm @@ -0,0 +1,603 @@ +// mcpp.rules.qt -- how the files a Qt program is made of become part of it. +// +// A Qt 6 program has four kinds of input no C++ compiler reads: +// +// a header declaring `Q_OBJECT`, `Q_GADGET` or `Q_NAMESPACE` +// -> `moc` -> `moc_.cpp`, compiled +// `
.ui` -> `uic` -> `ui_.h`, included +// `.qrc` -> `rcc` -> `qrc_.cpp`, compiled +// `.ts` -> `lrelease` -> `.qm`, deployed beside the program +// +// and each is one action with declared inputs and outputs, so an edited header +// re-runs one `moc` and an edited translation one `lrelease`. The rule also +// puts the modules on the build -- include directories, the libraries by full +// path, `QT__LIB` -- and places what Qt loads at run time and no +// import table names: its plugins (`platforms/qwindows.dll`, `styles/`, +// `imageformats/`), which is the job `windeployqt` exists for. +// +// WHERE QT COMES FROM IS NOT THIS RULE'S QUESTION. `options::root` names an SDK; +// empty takes the `xim:qt` payload, which `rules-qt-xim` declares, and +// `xim:qt-addons` beside it when `rules-qt-xim-addons` declares that. A project +// using a Qt from elsewhere names `rules-qt` alone and downloads nothing. +// +// THE MODULES' SHARED LIBRARIES ARE A RUNTIME LIBRARY DIRECTORY, not copies: +// `/bin` on Windows and `/lib` elsewhere reach `mcpp run`'s library +// path and `mcpp pack`'s closure through `mcpp::runtime_library_dir` (mcpp +// 2026.9.27.1+). The plugins are copied, because Qt finds them relative to the +// program and no import table names them. +// +// A MISSING SDK IS A WARNING. `mcpp emit build-database` plans a project on +// machines that never built it; the rule says what it could not find and +// declares nothing, so the plan succeeds and the build is where it fails. +// +// `lupdate` REWRITES SOURCES, so it is off unless `translations::update_sources` +// asks for it; then it is a `blocking` check whose stamp `lrelease` waits for, +// the order Qt's Visual Studio integration runs them in. + +module; +#include + +export module mcpp.rules.qt; + +import std; +import mcpp; +import mcpp.plugins; + +export namespace mcpp::rules::qt { + +enum class moc_scan { + project_headers, // every header under the package root that declares a meta-object + listed, // only `options::moc_headers` +}; + +struct translations { + // `.ts` files, beside those the project names in `[build] sources`. + std::vector ts; + // Run `lupdate` over the sources before `lrelease`. It writes into the + // `.ts` files, which are part of the source tree. + bool update_sources = false; + // `lupdate -tr-function-alias` values, e.g. `translate+=appTr`. + std::vector tr_function_alias; + // The files `lupdate` reads. Empty takes the package's C++ files. + std::vector sources; + // Where the `.qm` files are placed, relative to the program. + std::string deploy_to = "translations"; + // Where `lrelease` writes them. Empty is `/qt/translations`; a + // project whose own release step copies them names a directory it knows. + std::string out_dir; +}; + +struct options { + // Qt modules, `Core` / `QtCore` / `Qt6Core` alike, in link order. + std::vector modules = { "Core" }; + // Modules whose private headers the package includes (`QtWidgets/private/…`). + std::vector private_modules; + moc_scan moc = moc_scan::project_headers; + std::vector moc_headers; + // `.ui` and `.qrc` files, beside those named in `[build] sources`. + std::vector forms; + std::vector resources; + translations i18n; + // Plugin directories placed beside the program (`platforms`, `styles`, + // `imageformats`, `tls`, …). `platforms` is what any GUI program needs. + std::vector deploy_plugins = { "platforms" }; + // Windows: `opengl32sw.dll` (Mesa's software OpenGL) and `d3dcompiler_47.dll` + // beside the program, as `windeployqt` places them by default. + bool deploy_software_gl = false; + // The SDK. Empty takes `xim:qt`, then `xim:qt-addons` as a second prefix. + std::string root; + std::vector extra_roots; + std::string out_dir = std::string(mcpp::out_dir()); +}; + +// ─── The SDK ─────────────────────────────────────────────────────────────── + +namespace detail { + +inline std::string generic(const std::filesystem::path& p) { + return p.lexically_normal().generic_string(); +} + +inline bool is_windows() { return std::string_view(mcpp::target_os()) == "windows"; } +inline bool is_macos() { return std::string_view(mcpp::target_os()) == "macos"; } +inline bool host_windows() { return std::string(mcpp::host()).find("windows") != std::string::npos; } + +inline std::filesystem::path absolute_from_root(const std::string& p) { + std::filesystem::path path(p); + if (path.is_relative()) path = std::filesystem::path(mcpp::manifest_dir()) / path; + return path.lexically_normal(); +} + +inline void warn(const std::string& message) { + std::cerr << message << '\n'; + std::string folded; + bool space = false; + for (char c : message) { + if (c == '\n') { space = true; continue; } + if (space) { if (c == ' ') continue; folded += ' '; space = false; } + folded += c; + } + mcpp::warning(folded.c_str()); +} + +// `Core`, `QtCore` and `Qt6Core` name one module. +inline std::string module_name(std::string m) { + if (m.starts_with("Qt6")) m = m.substr(3); + else if (m.starts_with("Qt")) m = m.substr(2); + return m; +} + +// By index, as `mcpp.deps.vcpkg` lowers a triplet line: GCC 16 cannot inline a +// non-const std::string iterator in a module unit. +inline std::string upper(std::string s) { + for (std::size_t i = 0; i < s.size(); ++i) + s[i] = char(std::toupper(static_cast(s[i]))); + return s; +} + +// A Qt tool: `bin/` on Windows, `libexec/` for the code generators elsewhere +// (Qt 6 moved `moc`, `uic` and `rcc` there), `bin/` for the Linguist tools. +inline std::string tool(std::span roots, const char* name) { + std::error_code ec; + const std::string file = std::string(name) + (host_windows() ? ".exe" : ""); + for (auto const& r : roots) + for (auto const* sub : { "bin", "libexec" }) + if (std::filesystem::is_regular_file(r / sub / file, ec)) return generic(r / sub / file); + return {}; +} + +// Files under the package root with one of `exts`, skipping build output and +// version control. Sorted, so the plan does not depend on directory order. +inline std::vector project_files(std::initializer_list exts) { + std::vector out; + const std::filesystem::path root = mcpp::manifest_dir(); + std::error_code ec; + for (auto it = std::filesystem::recursive_directory_iterator( + root, std::filesystem::directory_options::skip_permission_denied, ec); + it != std::filesystem::recursive_directory_iterator(); it.increment(ec)) { + if (ec) break; + const auto name = it->path().filename().string(); + if (it->is_directory(ec)) { + if (name == "target" || name == ".git" || name == "mcpp-generated" || + name == "vcpkg_installed" || name == "node_modules") + it.disable_recursion_pending(); + continue; + } + const auto ext = it->path().extension().string(); + for (auto e : exts) if (ext == e) { out.push_back(it->path()); break; } + } + std::ranges::sort(out); + return out; +} + +inline std::string read_file(const std::filesystem::path& p) { + std::ifstream in(p, std::ios::binary); + return std::string(std::istreambuf_iterator(in), {}); +} + +inline bool declares_meta_object(const std::string& text) { + for (auto m : { "Q_OBJECT", "Q_GADGET", "Q_NAMESPACE" }) { + for (std::size_t at = text.find(m); at != std::string::npos; at = text.find(m, at + 1)) { + const bool left = at == 0 || !(std::isalnum(static_cast(text[at - 1])) || text[at - 1] == '_'); + const std::size_t end = at + std::strlen(m); + // `Q_OBJECT`, `Q_GADGET_EXPORT(…)`, `Q_NAMESPACE_EXPORT(…)`. + const bool right = end >= text.size() || !(std::isalnum(static_cast(text[end]))) + || text.compare(end, 7, "_EXPORT") == 0; + if (left && right) return true; + } + } + return false; +} + +// The device sources the project names, of one extension. +inline std::vector device(std::string_view ext) { + std::vector out; + const std::string all = mcpp::device_sources(); + std::size_t i = 0; + while (i <= all.size()) { + auto nl = all.find('\n', i); + std::string one = all.substr(i, nl == std::string::npos ? std::string::npos : nl - i); + i = nl == std::string::npos ? all.size() + 1 : nl + 1; + while (!one.empty() && (one.back() == ' ' || one.back() == '\r')) one.pop_back(); + if (one.size() > ext.size() && one.ends_with(ext)) out.push_back(one); + } + return out; +} + +// The `` entries of a `.qrc`, resolved against its directory: what `rcc` +// reads, so each is an input of the action that runs it. +inline std::vector qrc_files(const std::filesystem::path& qrc) { + std::vector out; + mcpp::plugins::xml::node doc; + std::string error; + if (!mcpp::plugins::xml::parse(read_file(qrc), doc, error)) return out; + std::function walk = [&](const mcpp::plugins::xml::node& n) { + if (n.name == "file") { + std::string text = mcpp::plugins::xml::trim_copy(n.text); + if (!text.empty()) out.push_back(generic(qrc.parent_path() / text)); + } + for (auto const& c : n.children) walk(c); + }; + walk(doc); + return out; +} + +// The directory `include/Qt/` private headers live in. +inline std::filesystem::path private_dir(const std::filesystem::path& root, const std::string& module) { + std::error_code ec; + const auto base = is_macos() ? root / "lib" / ("Qt" + module + ".framework") / "Headers" + : root / "include" / ("Qt" + module); + for (auto const& e : std::filesystem::directory_iterator(base, ec)) + if (e.is_directory(ec) && !e.path().filename().string().empty() && + std::isdigit(static_cast(e.path().filename().string()[0]))) + return e.path(); + return {}; +} + +} // namespace detail + +// The SDKs this build uses: `options::root` and `extra_roots`, or the xim +// payloads. Empty when none is present. +inline std::vector roots(const options& opt = {}) { + std::vector out; + std::error_code ec; + auto add = [&](const std::string& r) { + if (r.empty()) return; + const auto p = detail::absolute_from_root(r); + if (std::filesystem::is_directory(p, ec)) out.push_back(p); + }; + if (!opt.root.empty()) add(opt.root); + else { + add(mcpp::xpkg_dir("xim", "qt")); + add(mcpp::xpkg_dir("xim", "qt-addons")); + } + for (auto const& r : opt.extra_roots) add(r); + return out; +} + +// The first SDK, for a project that hands it to something else (a CMake +// subproject's `CMAKE_PREFIX_PATH`). Empty when none is present. +inline std::string root(const options& opt = {}) { + auto r = roots(opt); + return r.empty() ? std::string() : detail::generic(r.front()); +} + +// ─── The rule ────────────────────────────────────────────────────────────── + +inline bool compile(options opt = {}) { + namespace fs = std::filesystem; + using detail::generic; + constexpr std::string_view who = "mcpp.rules.qt"; + mcpp::fact("mcpp.plugins", std::string(mcpp::plugins::version).c_str()); + + const auto sdks = roots(opt); + if (sdks.empty()) { + detail::warn(std::format( + "{}: no Qt SDK: options::root is {} and xpkg_dir(\"xim\", \"qt\") answered \"{}\". " + "Nothing Qt-specific is planned. The `rules-qt-xim` feature declares `xim:qt`, " + "which `mcpp build` provisions; a Qt from elsewhere is named with options::root.", + who, opt.root.empty() ? "empty" : "'" + opt.root + "' (no such directory)", + std::string(mcpp::xpkg_dir("xim", "qt")))); + return true; + } + const fs::path main = sdks.front(); + const fs::path gen = fs::path(opt.out_dir) / "qt"; + std::error_code ec; + fs::create_directories(gen, ec); + + // ── the modules ── + std::vector modules; + for (auto const& m : opt.modules) { + const auto name = detail::module_name(m); + if (std::ranges::find(modules, name) == modules.end()) modules.push_back(name); + } + std::vector missing; + for (auto const& r : sdks) { + if (detail::is_macos()) { + const std::string f = "-F" + generic(r / "lib"); + mcpp::cxxflag(f.c_str()); + } else { + mcpp::include_dir(generic(r / "include").c_str()); + } + } + for (auto const& m : modules) { + bool found = false; + for (auto const& r : sdks) { + fs::path lib, headers; + if (detail::is_windows()) { + lib = r / "lib" / ("Qt6" + m + ".lib"); + headers = r / "include" / ("Qt" + m); + } else if (detail::is_macos()) { + lib = r / "lib" / ("Qt" + m + ".framework") / ("Qt" + m); + headers = r / "lib" / ("Qt" + m + ".framework") / "Headers"; + } else { + lib = r / "lib" / ("libQt6" + m + ".so"); + headers = r / "include" / ("Qt" + m); + } + if (!fs::exists(lib, ec)) continue; + mcpp::include_dir(generic(headers).c_str()); + mcpp::link_flag(generic(lib).c_str()); + const std::string def = "QT_" + detail::upper(m) + "_LIB"; + mcpp::define(def.c_str()); + found = true; + break; + } + if (!found) missing.push_back(m); + } + for (auto const& m : opt.private_modules) { + const auto name = detail::module_name(m); + for (auto const& r : sdks) { + const auto dir = detail::private_dir(r, name); + if (dir.empty()) continue; + mcpp::include_dir(generic(dir).c_str()); + mcpp::include_dir(generic(dir / ("Qt" + name)).c_str()); + break; + } + } + if (!missing.empty()) { + std::string list; + for (auto const& m : missing) list += " " + m; + std::cerr << std::format( + "{}: module(s){} not found in {}. The base package carries Core, Gui, Widgets, " + "Network, Svg, Qml, Quick and the other qtbase/qtdeclarative modules; the " + "additional libraries (Multimedia, Charts, WebSockets, …) are `xim:qt-addons`, " + "which the `rules-qt-xim-addons` feature declares.\n", + who, list, generic(main)); + return false; + } + + // ── the compiler and the loader ── + if (std::string_view(mcpp::compiler()) == "msvc") { + // Qt 6 refuses a `__cplusplus` that does not state the standard, which + // is cl.exe's default. + mcpp::cxxflag("/Zc:__cplusplus"); + mcpp::cxxflag("/permissive-"); + } + if (!detail::is_windows() && !detail::is_macos()) { + // Qt's Linux libraries are built with `-reduce-relocations`, and its + // headers refuse position-dependent code: `-fPIC`, as Qt's own CMake + // package requires of every consumer. + mcpp::cxxflag("-fPIC"); + } + // THE LIBRARIES QT'S OWN LINUX BUILD NAMES. Qt's official Linux QtCore + // links the distribution's glib, zstd and zlib and finds them through + // `RUNPATH $ORIGIN` and the host loader's default directories. A program + // mcpp links runs under the ecosystem's glibc loader, which does not search + // the host's directories, and a RUNPATH in the library stops the program's + // own RPATH from applying to the library's dependencies. So the directories + // are stated here: `rules-qt-xim` declares these packages on Linux, and each + // one present is a runtime library directory -- found by `mcpp run` and + // carried by `mcpp pack`. QtGui additionally loads QtDBus and through it + // `libdbus-1.so.3`, which the ecosystem does not publish, so a program + // linking QtGui on Linux is refused by mcpp's runtime closure check. + if (!detail::is_windows() && !detail::is_macos()) { + for (auto const* pkg : { "glib", "zstd", "zlib" }) { + const std::string dir = mcpp::xpkg_dir("xim", pkg); + if (dir.empty()) continue; + for (auto const* sub : { "lib", "lib64" }) + if (fs::is_directory(fs::path(dir) / sub, ec)) + mcpp::runtime_library_dir(generic(fs::path(dir) / sub).c_str()); + } + } + for (auto const& r : sdks) { + const std::string rt = generic(r / (detail::is_windows() ? "bin" : "lib")); + mcpp::runtime_library_dir(rt.c_str()); + if (!detail::is_windows()) { + const std::string rpath = "-Wl,-rpath," + generic(r / "lib"); + mcpp::link_flag(rpath.c_str()); + } + } + + // ── moc ── + const std::string moc = detail::tool(sdks, "moc"); + std::vector headers; + if (opt.moc == moc_scan::listed) { + for (auto const& h : opt.moc_headers) headers.push_back(detail::absolute_from_root(h)); + } else { + for (auto const* g : { "**/*.h", "**/*.hpp", "**/*.hxx" }) mcpp::rerun_if_changed_glob(g); + for (auto const& h : detail::project_files({ ".h", ".hpp", ".hxx" })) { + mcpp::rerun_if_changed(generic(h).c_str()); + if (detail::declares_meta_object(detail::read_file(h))) headers.push_back(h); + } + } + // A source that includes its own `.moc` declares a meta-object in the + // source itself; its moc output is a header that source includes. + std::vector inlineMoc; + if (opt.moc == moc_scan::project_headers) { + for (auto const* g : { "**/*.cpp", "**/*.cc", "**/*.cxx" }) mcpp::rerun_if_changed_glob(g); + for (auto const& c : detail::project_files({ ".cpp", ".cc", ".cxx" })) { + const auto text = detail::read_file(c); + if (text.find("\"" + c.stem().string() + ".moc\"") != std::string::npos && + detail::declares_meta_object(text)) + inlineMoc.push_back(c); + } + } + if ((!headers.empty() || !inlineMoc.empty()) && moc.empty()) { + std::cerr << std::format("{}: `moc` not found under {} (bin/ or libexec/).\n", who, generic(main)); + return false; + } + std::map mocNames; + auto mocOne = [&](const fs::path& in, const std::string& outName) -> bool { + auto [it, fresh] = mocNames.try_emplace(outName, in); + if (!fresh) { + std::cerr << std::format("{}: two files produce `{}`: {} and {}. moc outputs are named " + "after the file's stem; rename one.\n", + who, outName, generic(it->second), generic(in)); + return false; + } + const std::string out = generic(gen / outName); + const std::string dep = out + ".d"; + const std::string src = generic(in); + const std::string id = "qt:moc:" + outName; + const std::string desc = "MOC " + in.filename().string(); + mcpp::action a; + a.id = id.c_str(); + a.role = "source"; + a.description = desc.c_str(); + a.depfile = dep.c_str(); + a.arg(moc.c_str()).arg(src.c_str()).arg("-o").arg(out.c_str()) + .arg("--output-dep-file").arg("--dep-file-path").arg(dep.c_str()) + .input(src.c_str()).output(out.c_str()).submit(); + return true; + }; + for (auto const& h : headers) + if (!mocOne(h, "moc_" + h.stem().string() + ".cpp")) return false; + for (auto const& c : inlineMoc) + if (!mocOne(c, c.stem().string() + ".moc")) return false; + + // ── uic ── + std::vector forms = detail::device(".ui"); + for (auto const& f : opt.forms) forms.push_back(f); + if (!forms.empty()) { + const std::string uic = detail::tool(sdks, "uic"); + if (uic.empty()) { + std::cerr << std::format("{}: `uic` not found under {}.\n", who, generic(main)); + return false; + } + for (auto const& f : forms) { + const std::string in = generic(detail::absolute_from_root(f)); + const std::string out = generic(gen / ("ui_" + fs::path(f).stem().string() + ".h")); + const std::string id = "qt:uic:" + fs::path(f).stem().string(); + const std::string desc = "UIC " + fs::path(f).filename().string(); + mcpp::action a; + a.id = id.c_str(); + a.role = "source"; + a.description = desc.c_str(); + a.arg(uic.c_str()).arg(in.c_str()).arg("-o").arg(out.c_str()) + .input(in.c_str()).output(out.c_str()).submit(); + } + } + if (!forms.empty() || !inlineMoc.empty()) mcpp::include_dir(generic(gen).c_str()); + + // ── rcc ── + std::vector resources = detail::device(".qrc"); + for (auto const& r : opt.resources) resources.push_back(r); + if (!resources.empty()) { + const std::string rcc = detail::tool(sdks, "rcc"); + if (rcc.empty()) { + std::cerr << std::format("{}: `rcc` not found under {}.\n", who, generic(main)); + return false; + } + for (auto const& r : resources) { + const fs::path qrc = detail::absolute_from_root(r); + const std::string stem = qrc.stem().string(); + const std::string in = generic(qrc); + const std::string out = generic(gen / ("qrc_" + stem + ".cpp")); + const std::string id = "qt:rcc:" + stem; + const std::string desc = "RCC " + qrc.filename().string(); + mcpp::rerun_if_changed(in.c_str()); + mcpp::action a; + a.id = id.c_str(); + a.role = "source"; + a.description = desc.c_str(); + a.arg(rcc.c_str()).arg("--name").arg(stem.c_str()).arg(in.c_str()).arg("-o").arg(out.c_str()) + .input(in.c_str()); + for (auto const& f : detail::qrc_files(qrc)) a.input(f.c_str()); + a.output(out.c_str()).submit(); + } + } + + // ── translations ── + std::vector ts = detail::device(".ts"); + for (auto const& t : opt.i18n.ts) ts.push_back(t); + if (!ts.empty()) { + const std::string lrelease = detail::tool(sdks, "lrelease"); + const std::string lupdate = detail::tool(sdks, "lupdate"); + if (lrelease.empty() || (opt.i18n.update_sources && lupdate.empty())) { + std::cerr << std::format("{}: `{}` not found under {}; it is part of qttools.\n", + who, lrelease.empty() ? "lrelease" : "lupdate", generic(main)); + return false; + } + std::vector sources; + if (opt.i18n.update_sources) { + if (!opt.i18n.sources.empty()) { + for (auto const& s : opt.i18n.sources) sources.push_back(generic(detail::absolute_from_root(s))); + } else { + for (auto const* g : { "**/*.cpp", "**/*.h", "**/*.hpp", "**/*.ixx", "**/*.cppm" }) + mcpp::rerun_if_changed_glob(g); + for (auto const& f : detail::project_files({ ".cpp", ".h", ".hpp", ".ixx", ".cppm" })) + sources.push_back(generic(f)); + } + } + for (auto const& t : ts) { + const fs::path file = detail::absolute_from_root(t); + const std::string stem = file.stem().string(); + const std::string in = generic(file); + const fs::path qmDir = opt.i18n.out_dir.empty() ? gen / "translations" + : detail::absolute_from_root(opt.i18n.out_dir); + const std::string qm = generic(qmDir / (stem + ".qm")); + std::string stamp; + if (opt.i18n.update_sources) { + stamp = generic(gen / (stem + ".lupdate.stamp")); + const std::string id = "qt:lupdate:" + stem; + const std::string desc = "LUPDATE " + file.filename().string(); + mcpp::action u; + u.id = id.c_str(); + u.role = "check"; + u.blocking = true; + u.description = desc.c_str(); + u.arg(lupdate.c_str()).arg("-silent").arg("-extensions").arg("cpp,h,hpp,ixx,cppm"); + for (auto const& a : opt.i18n.tr_function_alias) u.arg("-tr-function-alias").arg(a.c_str()); + for (auto const& s : sources) u.arg(s.c_str()).input(s.c_str()); + // The stamp is written by mcpp when lupdate succeeds (a check's + // command need not write its own); `lrelease` takes it as an + // input, so it reads the `.ts` lupdate has rewritten. + u.arg("-ts").arg(in.c_str()).output(stamp.c_str()).submit(); + } + const std::string id = "qt:lrelease:" + stem; + const std::string desc = "LRELEASE " + file.filename().string(); + mcpp::action r; + r.id = id.c_str(); + r.role = "source"; + r.description = desc.c_str(); + r.arg(lrelease.c_str()).arg("-silent").arg(in.c_str()).arg("-qm").arg(qm.c_str()).input(in.c_str()); + if (!stamp.empty()) r.input(stamp.c_str()); + r.output(qm.c_str()).submit(); + mcpp::deploy(qm.c_str(), opt.i18n.deploy_to.c_str()); + } + } + + // ── what Qt loads at run time ── + for (auto const& dir : opt.deploy_plugins) { + bool any = false; + for (auto const& r : sdks) { + const fs::path pdir = r / "plugins" / dir; + if (!fs::is_directory(pdir, ec)) continue; + std::vector files; + for (auto const& e : fs::directory_iterator(pdir, ec)) + if (e.is_regular_file(ec)) files.push_back(e.path()); + std::ranges::sort(files); + for (auto const& f : files) { + const auto ext = f.extension().string(); + const bool lib = detail::is_windows() ? ext == ".dll" + : detail::is_macos() ? ext == ".dylib" : ext == ".so"; + if (!lib) continue; + // The Windows SDK ships each plugin twice, `qwindows.dll` and + // the debug `qwindowsd.dll`; the program links the release + // libraries, so it loads the release plugin. + if (detail::is_windows()) { + const auto stem = f.stem().string(); + if (stem.ends_with("d") && + fs::exists(f.parent_path() / (stem.substr(0, stem.size() - 1) + ".dll"), ec)) + continue; + } + mcpp::deploy(generic(f).c_str(), dir.c_str()); + any = true; + } + } + if (!any) + detail::warn(std::format("{}: no plugin directory `{}` under {}/plugins; nothing placed " + "for it.", who, dir, generic(main))); + } + if (opt.deploy_software_gl && detail::is_windows()) { + for (auto const* f : { "opengl32sw.dll", "d3dcompiler_47.dll" }) + for (auto const& r : sdks) + if (fs::is_regular_file(r / "bin" / f, ec)) { + mcpp::deploy(generic(r / "bin" / f).c_str(), "."); + break; + } + } + return true; +} + +} // namespace mcpp::rules::qt diff --git a/tests/qt-consumer/build.mcpp b/tests/qt-consumer/build.mcpp new file mode 100644 index 0000000..cda072a --- /dev/null +++ b/tests/qt-consumer/build.mcpp @@ -0,0 +1,11 @@ +// A console program: QtCore alone, and no platform plugin to place. +import std; +import mcpp; +import mcpp.rules.qt; + +int main() { + mcpp::rules::qt::options o; + o.modules = { "Core" }; + o.deploy_plugins = {}; + return mcpp::rules::qt::compile(o) ? 0 : 1; +} diff --git a/tests/qt-consumer/i18n/qt_consumer_de.ts b/tests/qt-consumer/i18n/qt_consumer_de.ts new file mode 100644 index 0000000..45ed799 --- /dev/null +++ b/tests/qt-consumer/i18n/qt_consumer_de.ts @@ -0,0 +1,11 @@ + + + + + main + + hello + hallo + + + diff --git a/tests/qt-consumer/mcpp.toml b/tests/qt-consumer/mcpp.toml new file mode 100644 index 0000000..81d3b3c --- /dev/null +++ b/tests/qt-consumer/mcpp.toml @@ -0,0 +1,45 @@ +# Fixture: a Qt 6 program that exercises each of Qt's code generators and its +# Linguist tool through `rules-qt`, with the SDK from `xim:qt`. +# +# A console program on purpose: QtCore needs no display and no platform plugin, +# so the same program is built and RUN on every host in the matrix. What it +# covers: +# +# src/counter.h declares a Q_OBJECT class -> moc +# src/main.cpp includes "main.moc" -> moc, inline +# res/app.qrc names res/greeting.txt -> rcc +# i18n/qt_consumer_de.ts -> lrelease, deployed +# under translations/ +# +# and the program prints what each produced: a signal delivered to a slot, a +# resource read through `:/`, and a string translated by the `.qm` it loaded +# from beside itself. `widgets/` is the second fixture, for Qt Widgets, `.ui` +# and the platform plugin. +[package] +name = "qt-consumer" +version = "0.1.0" +description = "Fixture: a Qt 6 console program built through rules-qt" +license = "Apache-2.0" +authors = ["mcpp-community"] + +[language] +standard = "c++23" +modules = true +import_std = false + +[build-dependencies.mcpp] +plugins = { path = "../..", features = ["rules-qt-xim"], host-module = true } + +[build] +sources = ["src/*.cpp", "res/*.qrc", "i18n/*.ts"] + +# Qt's Linux libraries load the shared libstdc++ (`libstdc++.so.6`), so a +# program that links them uses the toolchain's shared C++ runtime rather than +# embedding one: two C++ runtimes in one process is the hazard mcpp's docs/20 +# describes, and mcpp reports it when the default applies. +[target.x86_64-linux-gnu] +cxx_runtime = "toolchain-coupled" + +[targets.qt-consumer] +kind = "bin" +main = "src/main.cpp" diff --git a/tests/qt-consumer/res/app.qrc b/tests/qt-consumer/res/app.qrc new file mode 100644 index 0000000..be4eeea --- /dev/null +++ b/tests/qt-consumer/res/app.qrc @@ -0,0 +1,6 @@ + + + + greeting.txt + + diff --git a/tests/qt-consumer/res/greeting.txt b/tests/qt-consumer/res/greeting.txt new file mode 100644 index 0000000..75c1fe7 --- /dev/null +++ b/tests/qt-consumer/res/greeting.txt @@ -0,0 +1 @@ +greetings from rcc diff --git a/tests/qt-consumer/src/counter.h b/tests/qt-consumer/src/counter.h new file mode 100644 index 0000000..21986e5 --- /dev/null +++ b/tests/qt-consumer/src/counter.h @@ -0,0 +1,23 @@ +// A meta-object in a header: `moc` reads it and writes `moc_counter.cpp`, +// which the build compiles beside the sources. +#pragma once + +#include + +class Counter : public QObject { + Q_OBJECT +public: + int value() const { return value_; } + +public slots: + void add(int n) { + value_ += n; + emit changed(value_); + } + +signals: + void changed(int value); + +private: + int value_ = 0; +}; diff --git a/tests/qt-consumer/src/main.cpp b/tests/qt-consumer/src/main.cpp new file mode 100644 index 0000000..890ae19 --- /dev/null +++ b/tests/qt-consumer/src/main.cpp @@ -0,0 +1,50 @@ +#include "counter.h" + +#include +#include +#include + +#include + +// A meta-object declared in a source file: `moc` writes `main.moc`, which this +// file includes at its end. +class Relay : public QObject { + Q_OBJECT +public: + int seen = 0; +public slots: + void take(int v) { seen = v; } +}; + +int main(int argc, char** argv) { + QCoreApplication app(argc, argv); + + Counter counter; + Relay relay; + QObject::connect(&counter, &Counter::changed, &relay, &Relay::take); + counter.add(40); + counter.add(2); + + QFile greeting(":/greeting.txt"); + if (!greeting.open(QIODevice::ReadOnly)) { + std::puts("qt-consumer: the resource :/greeting.txt is missing"); + return 1; + } + const QByteArray text = greeting.readAll().trimmed(); + + QTranslator translator; + const QString dir = QCoreApplication::applicationDirPath() + "/translations"; + if (!translator.load("qt_consumer_de", dir)) { + std::printf("qt-consumer: no translations/qt_consumer_de.qm under %s\n", + dir.toLocal8Bit().constData()); + return 1; + } + QCoreApplication::installTranslator(&translator); + const QString hello = QCoreApplication::translate("main", "hello"); + + std::printf("qt-consumer: signal %d, resource '%s', translation '%s', Qt %s\n", + relay.seen, text.constData(), hello.toUtf8().constData(), qVersion()); + return relay.seen == 42 && text == "greetings from rcc" && hello == "hallo" ? 0 : 1; +} + +#include "main.moc" diff --git a/tests/qt-widgets-consumer/build.mcpp b/tests/qt-widgets-consumer/build.mcpp new file mode 100644 index 0000000..1f42260 --- /dev/null +++ b/tests/qt-widgets-consumer/build.mcpp @@ -0,0 +1,11 @@ +import std; +import mcpp; +import mcpp.rules.qt; + +int main() { + mcpp::rules::qt::options o; + o.modules = { "Core", "Gui", "Widgets" }; + // `platforms` is the default; stated because it is what this fixture tests. + o.deploy_plugins = { "platforms" }; + return mcpp::rules::qt::compile(o) ? 0 : 1; +} diff --git a/tests/qt-widgets-consumer/mcpp.toml b/tests/qt-widgets-consumer/mcpp.toml new file mode 100644 index 0000000..e65dc15 --- /dev/null +++ b/tests/qt-widgets-consumer/mcpp.toml @@ -0,0 +1,41 @@ +# Fixture: a Qt Widgets program -- a `.ui` form through `uic`, and the platform +# plugin placed beside the program, which is what `windeployqt` is for and what +# no import table names. +# +# Run under `QT_QPA_PLATFORM=offscreen`: the runners have no display, and the +# offscreen plugin is one of the files `deploy_plugins = { "platforms" }` +# places, so a program that finds it has found the directory. A program that +# does not exits in QGuiApplication's constructor with "Could not find the Qt +# platform plugin", which is the failure this fixture exists to catch. +[package] +name = "qt-widgets-consumer" +version = "0.1.0" +description = "Fixture: a Qt Widgets program with a .ui form, built through rules-qt" +license = "Apache-2.0" +authors = ["mcpp-community"] + +[language] +standard = "c++23" +modules = true +import_std = false + +[build-dependencies.mcpp] +plugins = { path = "../..", features = ["rules-qt-xim"], host-module = true } + +[build] +sources = ["src/*.cpp", "ui/*.ui"] + +# Built and run on Windows by CI; on Linux QtGui needs libdbus, which the +# ecosystem does not publish. The Linux runtime contract below states what a +# Linux build of it will need once it does. +# +# Qt's Linux libraries load the shared libstdc++ (`libstdc++.so.6`), so a +# program that links them uses the toolchain's shared C++ runtime rather than +# embedding one: two C++ runtimes in one process is the hazard mcpp's docs/20 +# describes, and mcpp reports it when the default applies. +[target.x86_64-linux-gnu] +cxx_runtime = "toolchain-coupled" + +[targets.qt-widgets-consumer] +kind = "bin" +main = "src/main.cpp" diff --git a/tests/qt-widgets-consumer/src/main.cpp b/tests/qt-widgets-consumer/src/main.cpp new file mode 100644 index 0000000..0041f63 --- /dev/null +++ b/tests/qt-widgets-consumer/src/main.cpp @@ -0,0 +1,19 @@ +#include "ui_form.h" + +#include +#include +#include + +#include + +int main(int argc, char** argv) { + QApplication app(argc, argv); + QWidget window; + Ui::Form form; + form.setupUi(&window); + window.show(); + const QByteArray text = form.label->text().toUtf8(); + std::printf("qt-widgets-consumer: platform %s, label '%s'\n", + QGuiApplication::platformName().toUtf8().constData(), text.constData()); + return text == "made by uic" ? 0 : 1; +} diff --git a/tests/qt-widgets-consumer/ui/form.ui b/tests/qt-widgets-consumer/ui/form.ui new file mode 100644 index 0000000..54018ea --- /dev/null +++ b/tests/qt-widgets-consumer/ui/form.ui @@ -0,0 +1,20 @@ + + + Form + + + qt-widgets-consumer + + + + + + made by uic + + + + + + + + From 78f5476324e1a439d784b09988bee27af69543fe Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sat, 26 Sep 2026 06:21:00 +0800 Subject: [PATCH 04/13] 0.13.0: the deps family and rules-qt in the manifest; every source-carrying member compiles on every host; CI measures the new members on three platforms --- .github/scripts/check-deps-and-qt.sh | 129 ++++++++++++++++++++++++++ .github/workflows/ci.yml | 131 +++++++++++++++++++++++---- .gitignore | 1 + mcpp.toml | 122 ++++++++++++++++++++++++- src/plugins.cppm | 2 +- tests/all-rules-compile/build.mcpp | 16 +++- tests/all-rules-compile/mcpp.toml | 12 ++- 7 files changed, 389 insertions(+), 24 deletions(-) create mode 100644 .github/scripts/check-deps-and-qt.sh diff --git a/.github/scripts/check-deps-and-qt.sh b/.github/scripts/check-deps-and-qt.sh new file mode 100644 index 0000000..f50e882 --- /dev/null +++ b/.github/scripts/check-deps-and-qt.sh @@ -0,0 +1,129 @@ +#!/usr/bin/env bash +# The criteria for `deps-vcpkg`, `deps-cmake` and `rules-qt`, one function per +# fixture, the same on every host. A CI step names the fixture: +# +# bash .github/scripts/check-deps-and-qt.sh vcpkg-consumer +# +# Each function fails with the reason it failed. Host differences are the +# host's: a DLL is looked for on Windows, where a triplet builds fmt as one, and +# nowhere else. +set -euo pipefail + +: "${MCPP:?MCPP names the mcpp under test}" +ROOT=$(cd "$(dirname "$0")/../.." && pwd) + +fail() { echo "FAIL: $*"; exit 1; } + +is_windows() { case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) return 0 ;; *) return 1 ;; esac; } + +# The stamp an installation action leaves: the file mcpp writes when a `check` +# action's command succeeds. Its modification time is the criterion for "the +# installation did not run again". +stamp_of() { find target -path "*$1*" -name '*.stamp' | head -1; } + +# A second build with nothing changed must not run the installation again. +assert_not_rerun() { + local stamp="$1" + [ -n "$stamp" ] && [ -f "$stamp" ] || fail "no installation stamp under target/" + mkdir -p target/ci + touch -r "$stamp" target/ci/before-second-build + sleep 1 + "$MCPP" build > target/ci/second-build.log 2>&1 || { cat target/ci/second-build.log; fail "the second build failed"; } + [ -z "$(find "$stamp" -newer target/ci/before-second-build)" ] || + fail "the second build re-ran the installation ($stamp is newer)" + echo "ok: a second build with nothing changed did not re-run the installation" +} + +vcpkg_consumer() { + cd "$ROOT/tests/vcpkg-consumer" + rm -rf target vcpkg_installed + # PLANNING NEVER INSTALLS, AND STILL STATES THE PATHS. An editor asks for + # the build database on machines that never built; the build program must + # succeed there and name the include directory the build will fill. + mkdir -p target/ci + "$MCPP" emit build-database --format json > target/ci/db.json 2> target/ci/emit.log || + { cat target/ci/emit.log; fail "emit build-database failed before any installation"; } + grep -q 'vcpkg_installed' target/ci/db.json || fail "the database names no vcpkg_installed include directory" + [ ! -d vcpkg_installed ] || fail "emit build-database installed something" + echo "ok: emit succeeded before the installation and named its include directory" + + "$MCPP" build 2>&1 | tee build.log + "$MCPP" run | tee run.log + grep -qE '^vcpkg-consumer: fmt [0-9]+ says 42$' run.log || fail "the program did not print through fmt" + assert_not_rerun "$(stamp_of deps-vcpkg)" + + if is_windows; then + # The DLL reached the run through the runtime library directory; the + # pack must carry it, which only the same directory can make it do. + ls vcpkg_installed/x64-windows/bin/fmt.dll > /dev/null || fail "x64-windows built no fmt.dll" + "$MCPP" pack --format dir | tee pack.log + find target/dist -iname 'fmt.dll' | grep -q . || fail "the packed tree carries no fmt.dll" + echo "ok: the packed tree carries fmt.dll" + fi +} + +vcpkg_workspace() { + cd "$ROOT/tests/vcpkg-workspace" + rm -rf target app-a/target app-b/target vcpkg_installed + "$MCPP" build 2>&1 | tee build.log + "$MCPP" run -p app-a | tee run-a.log + "$MCPP" run -p app-b | tee run-b.log + grep -qE '^app-a: fmt [0-9]+$' run-a.log || fail "app-a did not run" + grep -qE '^app-b: fmt [0-9]+$' run-b.log || fail "app-b did not run" + # Each member builds into its own target/ and declares its own installation. + [ "$(find . -path '*/target/*' -path '*deps-vcpkg*' -name '*.stamp' | wc -l)" -ge 2 ] || + fail "each member did not declare its own installation" + echo "ok: two members that share no dependency both installed and linked one prefix" +} + +cmake_consumer() { + cd "$ROOT/tests/cmake-consumer" + rm -rf target + "$MCPP" build 2>&1 | tee build.log + "$MCPP" run | tee run.log + grep -q '^cmake-consumer: greet says 42$' run.log || fail "the program did not call the subproject's library" + assert_not_rerun "$(stamp_of deps-cmake)" + touch greet/greet.c + "$MCPP" build > target/ci/third-build.log 2>&1 || { cat target/ci/third-build.log; fail "the rebuild failed"; } + [ -n "$(find "$(stamp_of deps-cmake)" -newer target/ci/before-second-build)" ] || + fail "an edited subproject source did not rebuild the subproject" + echo "ok: an edited subproject source rebuilt the subproject" + # And not again: the stamp moved past the edited file (mcpp 2026.9.27.1). + assert_not_rerun "$(stamp_of deps-cmake)" +} + +qt_consumer() { + cd "$ROOT/tests/qt-consumer" + rm -rf target + "$MCPP" build 2>&1 | tee build.log + "$MCPP" run | tee run.log + grep -qE "^qt-consumer: signal 42, resource 'greetings from rcc', translation 'hallo', Qt 6\." run.log || + fail "moc, rcc or lrelease did not reach the program" + find target -name 'qt_consumer_de.qm' | grep -q . || fail "no .qm was produced" + echo "ok: moc (header and inline), rcc and lrelease reached the program" +} + +qt_widgets_consumer() { + cd "$ROOT/tests/qt-widgets-consumer" + rm -rf target + "$MCPP" build 2>&1 | tee build.log + QT_QPA_PLATFORM=offscreen "$MCPP" run | tee run.log + grep -q "^qt-widgets-consumer: platform offscreen, label 'made by uic'$" run.log || + fail "the platform plugin or the uic form did not reach the program" + if is_windows; then + "$MCPP" pack --format dir | tee pack.log + find target/dist -iname 'Qt6Widgets.dll' | grep -q . || fail "the packed tree carries no Qt6Widgets.dll" + find target/dist -ipath '*platforms/qoffscreen.dll' | grep -q . || + fail "the packed tree carries no platforms/qoffscreen.dll" + echo "ok: the packed tree carries the Qt modules and the platform plugins" + fi +} + +case "${1:-}" in + vcpkg-consumer) vcpkg_consumer ;; + vcpkg-workspace) vcpkg_workspace ;; + cmake-consumer) cmake_consumer ;; + qt-consumer) qt_consumer ;; + qt-widgets-consumer) qt_widgets_consumer ;; + *) echo "usage: $0 vcpkg-consumer|vcpkg-workspace|cmake-consumer|qt-consumer|qt-widgets-consumer"; exit 2 ;; +esac diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a2663fd..ccd8c30 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1444,23 +1444,40 @@ jobs: # dependable -- and a check that behaves differently on one of the # three hosts is the exact class of difference this job exists to # catch, so it must not introduce one. + # THE MEMBERS THAT CARRY SOURCES, READ OUT OF THE MANIFEST. From 0.13.0 a + # member family may hold features that add a payload and no source + # (`rules-qt-xim`, `dist-apk-kotlin`): they compile nothing this fixture + # could miss, and naming them here would download an SDK on every host. + # So the requirement is one-sided -- every source-carrying member of the + # four families is named -- and the other side only checks spelling: + # every name the fixture uses is a feature the package declares. + awk '/^\[features\.[a-z0-9-]+\]/ { cur = $0; sub(/^\[features\./, "", cur); sub(/\]$/, "", cur); next } + /^\[/ { cur = "" } + /^sources[ \t]*=/ && cur ~ /^(rules|tools|dist|deps)-/ { print cur }' mcpp.toml \ + | sort -u > /tmp/members.txt grep -oE '^\[features\.[a-z0-9-]+\]' mcpp.toml \ - | sed 's/^\[features\.//; s/\]$//' | grep -E '^(rules|tools|dist)-' | sort > /tmp/members.txt + | sed 's/^\[features\.//; s/\]$//' | sort > /tmp/declared.txt feats=$(cat /tmp/members.txt) used=$(sed -n '/features = \[/,/\], host-module/p' tests/all-rules-compile/mcpp.toml \ | grep -oE '"[a-z-]+"' | tr -d '"' | sort) [ -n "$feats" ] || { - echo "FAIL: read no features out of mcpp.toml. The extractor above" - echo " expects [features.] sections and found none, so the" - echo " manifest spelling changed -- the package has not stopped" - echo " having features." + echo "FAIL: read no source-carrying members out of mcpp.toml. The" + echo " extractor above expects [features.] sections with a" + echo " sources key and found none, so the manifest spelling changed." grep -n '^\[features' mcpp.toml | head exit 1; } - [ "$feats" = "$used" ] || { - echo "FAIL: the fixture does not name every published feature" - echo " package: $(echo $feats)" - echo " fixture: $(echo $used)" - exit 1; } + for f in $feats; do + echo "$used" | grep -qx "$f" || { + echo "FAIL: the fixture does not name the member $f" + echo " members: $(echo $feats)" + echo " fixture: $(echo $used)" + exit 1; } + done + for f in $used; do + grep -qx "$f" /tmp/declared.txt || { + echo "FAIL: the fixture names $f, which mcpp.toml does not declare" + exit 1; } + done cd tests/all-rules-compile "$MCPP" build "$MCPP" run | tee run.log @@ -1610,6 +1627,34 @@ jobs: done [ "$fail" -eq 0 ] + # ── deps-vcpkg, deps-cmake and rules-qt (0.13.0) ────────────────────── + # + # vcpkg keeps everything it reuses in one per-user directory -- its + # binary cache (`archives/`), its registry cache (`registries/`) and, + # through `mcpp-deps`, its downloads -- so caching that directory is what + # makes the second run of a manifest a restore rather than a compile. + - name: Cache vcpkg's per-user directory + uses: actions/cache@v4 + with: + path: ~/.cache/vcpkg + key: vcpkg-user-${{ runner.os }}-${{ hashFiles('tests/vcpkg-*/vcpkg.json') }} + restore-keys: vcpkg-user-${{ runner.os }}- + + - name: deps-vcpkg installs a manifest as an action, and planning installs nothing + run: bash .github/scripts/check-deps-and-qt.sh vcpkg-consumer + + - name: deps-vcpkg in two workspace members that share no dependency + run: bash .github/scripts/check-deps-and-qt.sh vcpkg-workspace + + - name: deps-cmake builds a CMake subproject as an action and links it + run: bash .github/scripts/check-deps-and-qt.sh cmake-consumer + + # A console program: QtCore, which is what Linux is served for. QtGui + # loads libdbus, which the ecosystem does not publish, so the Widgets + # fixture runs on Windows only. + - name: rules-qt runs moc, rcc and lrelease, with the SDK from xim:qt + run: bash .github/scripts/check-deps-and-qt.sh qt-consumer + # ── THE SAME RULE ON THE OTHER TWO PLATFORMS ──────────────────────────────── # # `rules-spirv` is the only member whose payload this ecosystem publishes for @@ -1780,23 +1825,40 @@ jobs: # through Git Bash, where process substitution is emulated and not # dependable -- and a check that behaves differently on one of the # three hosts is the class of difference this job exists to catch. + # THE MEMBERS THAT CARRY SOURCES, READ OUT OF THE MANIFEST. From 0.13.0 a + # member family may hold features that add a payload and no source + # (`rules-qt-xim`, `dist-apk-kotlin`): they compile nothing this fixture + # could miss, and naming them here would download an SDK on every host. + # So the requirement is one-sided -- every source-carrying member of the + # four families is named -- and the other side only checks spelling: + # every name the fixture uses is a feature the package declares. + awk '/^\[features\.[a-z0-9-]+\]/ { cur = $0; sub(/^\[features\./, "", cur); sub(/\]$/, "", cur); next } + /^\[/ { cur = "" } + /^sources[ \t]*=/ && cur ~ /^(rules|tools|dist|deps)-/ { print cur }' mcpp.toml \ + | sort -u > /tmp/members.txt grep -oE '^\[features\.[a-z0-9-]+\]' mcpp.toml \ - | sed 's/^\[features\.//; s/\]$//' | grep -E '^(rules|tools|dist)-' | sort > /tmp/members.txt + | sed 's/^\[features\.//; s/\]$//' | sort > /tmp/declared.txt feats=$(cat /tmp/members.txt) used=$(sed -n '/features = \[/,/\], host-module/p' tests/all-rules-compile/mcpp.toml \ | grep -oE '"[a-z-]+"' | tr -d '"' | sort) [ -n "$feats" ] || { - echo "FAIL: read no features out of mcpp.toml. The extractor above" - echo " expects [features.] sections and found none, so the" - echo " manifest spelling changed -- the package has not stopped" - echo " having features." + echo "FAIL: read no source-carrying members out of mcpp.toml. The" + echo " extractor above expects [features.] sections with a" + echo " sources key and found none, so the manifest spelling changed." grep -n '^\[features' mcpp.toml | head exit 1; } - [ "$feats" = "$used" ] || { - echo "FAIL: the fixture does not name every published feature" - echo " package: $(echo $feats)" - echo " fixture: $(echo $used)" - exit 1; } + for f in $feats; do + echo "$used" | grep -qx "$f" || { + echo "FAIL: the fixture does not name the member $f" + echo " members: $(echo $feats)" + echo " fixture: $(echo $used)" + exit 1; } + done + for f in $used; do + grep -qx "$f" /tmp/declared.txt || { + echo "FAIL: the fixture names $f, which mcpp.toml does not declare" + exit 1; } + done cd tests/all-rules-compile "$MCPP" build "$MCPP" run | tee run.log @@ -2238,3 +2300,32 @@ jobs: grep -i provisioning prov.log || echo "(no provisioning line at all)" exit 1; } echo "ok: $(grep -m1 'entries declared by dependencies' prov.log)" + + # ── deps-vcpkg, deps-cmake and rules-qt (0.13.0) ────────────────────── + # + # The same criteria as the Linux job, from the same script. On Windows the + # default triplet builds fmt as a DLL and the Qt modules are DLLs, so the + # runtime library directory is measured twice: `mcpp run` finds them, and + # `mcpp pack` carries them. + - name: Cache vcpkg's per-user directory + uses: actions/cache@v4 + with: + path: ${{ runner.os == 'Windows' && '~/AppData/Local/vcpkg' || '~/.cache/vcpkg' }} + key: vcpkg-user-${{ runner.os }}-${{ hashFiles('tests/vcpkg-*/vcpkg.json') }} + restore-keys: vcpkg-user-${{ runner.os }}- + + - name: deps-vcpkg installs a manifest as an action, and planning installs nothing + run: bash .github/scripts/check-deps-and-qt.sh vcpkg-consumer + + - name: deps-vcpkg in two workspace members that share no dependency + run: bash .github/scripts/check-deps-and-qt.sh vcpkg-workspace + + - name: deps-cmake builds a CMake subproject as an action and links it + run: bash .github/scripts/check-deps-and-qt.sh cmake-consumer + + - name: rules-qt runs moc, rcc and lrelease, with the SDK from xim:qt + run: bash .github/scripts/check-deps-and-qt.sh qt-consumer + + - name: rules-qt builds a Widgets program with a .ui form, and it runs offscreen + if: runner.os == 'Windows' + run: bash .github/scripts/check-deps-and-qt.sh qt-widgets-consumer diff --git a/.gitignore b/.gitignore index ed94241..f13f735 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ mcpp.lock .mcpp/ *.log .worktrees/ +vcpkg_installed/ diff --git a/mcpp.toml b/mcpp.toml index ccc6e1a..962c072 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,7 +1,7 @@ [package] name = "plugins" namespace = "mcpp" -version = "0.12.0" +version = "0.13.0" description = "Official mcpp build plugins: rule packages under mcpp.rules.*, build-time utilities under mcpp.tools.*, each member selected by a feature" license = "Apache-2.0" authors = ["mcpp-community"] @@ -133,6 +133,30 @@ implies = ["surface"] rule_module = "mcpp.rules.sycl" device_extensions = [".sycl"] +# Qt's code generators and Linguist tools. `.ui`, `.qrc` and `.ts` files named +# in `[build] sources` reach the rule through `mcpp::device_sources()`; a header +# declaring a meta-object is found by content, since `.h` is a C++ extension the +# engine already owns. `.ts` is also TypeScript's extension, and that costs +# nothing here: the feature is requested by name, and only the files a +# project's own `sources` names are routed. +[features.rules-qt] +sources = ["rules/qt.cppm"] +implies = ["surface"] +rule_module = "mcpp.rules.qt" +device_extensions = [".ui", ".qrc", ".ts"] + +# WHERE QT COMES FROM IS A FEATURE OF ITS OWN, for the reason `dist-apk-kotlin` +# gives: provisioning runs before the build program learns what it compiles, so +# a payload `rules-qt` declared would download an SDK into every project that +# already has one -- a Qt from vcpkg, from a distribution, from an installer. +# `rules-qt-xim` is the one that downloads; `rules-qt-xim-addons` adds the +# additional libraries (Multimedia, Charts, WebSockets, ...) as a second prefix. +[features.rules-qt-xim] +implies = ["rules-qt"] + +[features.rules-qt-xim-addons] +implies = ["rules-qt-xim"] + # NOT device rules: these embed or declare things a project already has, so they # claim no extension and name no rule module. A consumer calls them from its own # `build.mcpp`. @@ -204,6 +228,34 @@ implies = ["dist-apk"] [features.dist-apk-maven] implies = ["dist-apk"] +# ── deps: where a library comes from ───────────────────────────────────── +# +# A FOURTH FAMILY, AND THE PREFIX IS THE POINT AGAIN. A `deps-*` member installs +# a prefix through a program mcpp does not drive -- vcpkg, CMake -- and maps it +# into the build: include directories, the link, the directories the program's +# shared libraries are found in at run time. It compiles none of the project's +# translation units, so it is not a rule; its work cannot happen while the build +# program runs, because an installation outlasts the build program's time limit +# and `mcpp emit build-database` must never trigger one, so it is not a tool; +# and it produces inputs of the link rather than something made from it, so it +# is not a dist member. The installation is a `blocking` check ACTION whose +# command is `mcpp-deps` (below), and the consumer asks for that tool on the +# same edge: `tools = ["mcpp-deps"]`. +# +# `deps` is the unit both members import, as `surface` is for the rest; a +# consumer never names it. +[features.deps] +sources = ["deps/deps.cppm"] +implies = ["surface"] + +[features.deps-vcpkg] +sources = ["deps/vcpkg.cppm"] +implies = ["deps"] + +[features.deps-cmake] +sources = ["deps/cmake.cppm"] +implies = ["deps"] + [features.tools-embed] sources = ["tools/embed.cppm"] implies = ["surface"] @@ -377,6 +429,57 @@ implies = ["surface"] [target.'cfg(accelerator = "ascend")'.feature-xlings.rules-ascendc] "xim:cann-toolkit" = ">=8.5.0" +# ── The environment `deps-vcpkg`, `deps-cmake` and `rules-qt-xim` need ───── +# +# THE INSTALLERS ARE THE HOST'S; THE SDK IS THE TARGET'S. vcpkg and CMake run +# on the machine that builds, so their payloads are declared on the host axis, +# `[feature-xlings.]`. Qt's libraries are linked into the program, so +# its payload is declared on the target axis, as the device toolkits above are. +# +# `xim:vcpkg` IS THE TOOL AND ITS SCRIPTS, NOT A CLONE OF THE REGISTRY. The +# payload is vcpkg-tool's release binary with the standalone bundle published +# beside it (`vcpkg-bundle.json`, `"usegitregistry": true`), so the scripts a +# port calls are the ones that tool was released with, and a `builtin-baseline` +# manifest resolves through vcpkg's git registry into vcpkg's own per-user +# registry cache. A floor: the tool's version is coupled to nothing a project +# pins -- the baseline is. +# +# NOTHING ELSE FOR vcpkg. It fetches the CMake, Ninja and 7-Zip its ports were +# tested with (and a portable git on Windows) into its downloads directory; a +# payload here would replace them with newer ones, and CMake 4 refuses the +# `cmake_minimum_required` of ports that CMake 3 accepted. On Linux and macOS +# vcpkg's documented host prerequisites -- git, curl, zip, unzip, tar and a C +# compiler -- are the host's, as they are for vcpkg itself. +[feature-xlings.deps-vcpkg] +"xim:vcpkg" = ">=2026.7.27" + +# A floor: CMake's version is coupled to the subproject's +# `cmake_minimum_required`, which only rises. +[feature-xlings.deps-cmake] +"xim:cmake" = ">=3.31" + +# AN EXACT VERSION: `moc`'s output is tied to the Qt libraries it is linked +# against, so the SDK is a choice a project may change, not a floor. The +# additional libraries share the base's version, and `xim:qt-addons` depends on +# `xim:qt` at exactly that version. +[target.'cfg(any(windows, linux, macos))'.feature-xlings.rules-qt-xim] +"xim:qt" = "6.11.1" + +# THE LIBRARIES QT'S LINUX QtCore LINKS AND DOES NOT CARRY: glib, zstd and +# zlib, which it expects the distribution to provide. A program mcpp links runs +# under the ecosystem's glibc loader, which does not search the host's library +# directories, so the rule makes each of these a runtime library directory. +# Unversioned: they are ABI-stable C libraries, and the SDK names them by +# SONAME. QtGui needs `libdbus-1.so.3` besides, which the ecosystem does not +# publish; GUI modules on Linux are therefore not served yet. +[target.'cfg(linux)'.feature-xlings.rules-qt-xim] +"xim:glib" = "" +"xim:zstd" = "" +"xim:zlib" = "" + +[target.'cfg(any(windows, linux, macos))'.feature-xlings.rules-qt-xim-addons] +"xim:qt-addons" = "6.11.1" + # ── The environment `dist-appimage` needs ────────────────────────────────── # # ONE PAYLOAD, AND IT CARRIES TWO FILES. `appimagetool` builds the image, and @@ -535,3 +638,20 @@ kind = "lib" [targets.mcpp-embed] kind = "bin" main = "tools/embed_main.cpp" + +# mcpp-deps -- the command of every installation a `deps-*` member declares. +# +# An action carries an argument vector and no environment; vcpkg is configured +# through its environment, and two workspace members installing one prefix at +# once must not write it concurrently. This program sets the environment, takes +# a lock on the installation root, and runs the installer as its child. Built +# from this package for the reason `mcpp-embed` is: the member and the program +# it plans are one decision, and a separately published binary could drift from +# it by a release. +# +# [build-dependencies.mcpp] +# plugins = { version = "0.13.0", features = ["deps-vcpkg"], +# host-module = true, tools = ["mcpp-deps"] } +[targets.mcpp-deps] +kind = "bin" +main = "tools/deps_main.cpp" diff --git a/src/plugins.cppm b/src/plugins.cppm index 5841616..1c5302d 100644 --- a/src/plugins.cppm +++ b/src/plugins.cppm @@ -49,7 +49,7 @@ export namespace mcpp::plugins { // // One package, one version: the number lives in mcpp.toml, and the CI step // `the collection states its own version` compares the two. -inline constexpr std::string_view version = "0.12.0"; +inline constexpr std::string_view version = "0.13.0"; } // namespace mcpp::plugins diff --git a/tests/all-rules-compile/build.mcpp b/tests/all-rules-compile/build.mcpp index f98093b..3688000 100644 --- a/tests/all-rules-compile/build.mcpp +++ b/tests/all-rules-compile/build.mcpp @@ -9,6 +9,7 @@ import mcpp.rules.ascendc; import mcpp.rules.cuda; import mcpp.rules.hip; import mcpp.rules.metal; +import mcpp.rules.qt; import mcpp.rules.slang; import mcpp.rules.spirv; import mcpp.rules.swift; @@ -20,6 +21,9 @@ import mcpp.dist.wix; import mcpp.dist.apple; import mcpp.dist.web; import mcpp.dist.apk; +import mcpp.deps; +import mcpp.deps.vcpkg; +import mcpp.deps.cmake; int main() { // No accelerator is named, so each of these returns true without looking @@ -76,6 +80,16 @@ int main() { auto apkPlan = mcpp::dist::apk::plan_for(); ok = ok && !apkPlan.applies; - std::println("all-rules-compile: every rule and dist module compiled for this host"); + // `rules-qt` without `rules-qt-xim` has no SDK here, so the question that + // plans nothing is where the SDK would be: nowhere. + ok = ok && mcpp::rules::qt::roots({ .root = "no-such-qt-sdk" }).empty(); + // The `deps-*` members install through an action; asked only what they + // derive without one -- the triplet this target defaults to, and the file + // a library name denotes. + ok = ok && !mcpp::deps::vcpkg::default_triplet().empty(); + ok = ok && mcpp::deps::library_file("lib", "fmt", false).filename().string().find("fmt") + != std::string::npos; + + std::println("all-rules-compile: every rule, dist and deps module compiled for this host"); return ok ? 0 : 1; } diff --git a/tests/all-rules-compile/mcpp.toml b/tests/all-rules-compile/mcpp.toml index 1fd2359..4d778fc 100644 --- a/tests/all-rules-compile/mcpp.toml +++ b/tests/all-rules-compile/mcpp.toml @@ -40,11 +40,12 @@ import_std = true # list is one whose host-dependent code is compiled on one platform only. [build-dependencies.mcpp] plugins = { path = "../..", features = [ - "rules-ascendc", "rules-cuda", "rules-hip", "rules-metal", "rules-slang", + "rules-ascendc", "rules-cuda", "rules-hip", "rules-metal", "rules-qt", "rules-slang", "rules-spirv", "rules-swift", "rules-sycl", "tools-embed", "tools-island", "dist-appimage", "dist-wix", "dist-apple", "dist-web", "dist-apk", "dist-apk-kotlin", "dist-apk-maven", + "deps-cmake", "deps-vcpkg", ], host-module = true } # NO `accel`, and that is the whole design: with none, every rule returns @@ -63,6 +64,15 @@ plugins = { path = "../..", features = [ # member whose macOS and Windows compile is never attempted -- which is exactly # the failure the two platform-specific members are here to catch. # +# `rules-qt` is named without `rules-qt-xim`: the rule compiles either way, and +# the SDK -- hundreds of megabytes on every host -- is what the `-xim` feature +# adds and nothing else. The CI step that reads this list requires every member +# that carries SOURCES; a member that only declares a payload is optional here. +# +# `deps-vcpkg` and `deps-cmake` declare their installers on the host axis, so +# this fixture installs `xim:vcpkg` (3 MB) and `xim:cmake` on every host. Their +# modules are what is compiled; neither member is asked to install anything. +# # `dist-apk-kotlin` and `dist-apk-maven` add no source: each implies `dist-apk` # and declares one payload on the `cfg(env = "android")` axis, which a host # build never opens. They are listed because a consumer names them, which is From fd927c4173d9afefe45f14938a962edcc975dbc4 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sat, 26 Sep 2026 06:21:00 +0800 Subject: [PATCH 05/13] 0.13.0: the README states the deps family, deps-vcpkg, deps-cmake and rules-qt; the design record and the plan --- .../2026-09-26-deps-vcpkg-rules-qt-design.md | 630 ++++++++++++++++++ .../2026-09-26-deps-vcpkg-rules-qt-plan.md | 63 ++ README.md | 174 ++++- 3 files changed, 865 insertions(+), 2 deletions(-) create mode 100644 .agents/docs/2026-09-26-deps-vcpkg-rules-qt-design.md create mode 100644 .agents/docs/2026-09-26-deps-vcpkg-rules-qt-plan.md diff --git a/.agents/docs/2026-09-26-deps-vcpkg-rules-qt-design.md b/.agents/docs/2026-09-26-deps-vcpkg-rules-qt-design.md new file mode 100644 index 0000000..fd71056 --- /dev/null +++ b/.agents/docs/2026-09-26-deps-vcpkg-rules-qt-design.md @@ -0,0 +1,630 @@ +# 通用构建插件设计:`deps-vcpkg`、`rules-qt`、`deps-cmake` 及其 xlings 依赖 + +状态:第 4 版(实现版;§0.1 的更正块取代正文中与之冲突的内容),实施中 · 2026-09-26 · 实施计划见 `2026-09-26-deps-vcpkg-rules-qt-plan.md` · +基于 mcpp 2026.9.25.1、mcpp-plugins 0.12.0(`origin/main` dea1f09)、xim-pkgindex `origin/main`、GalTranslPP 上游 +`main` 2c7d6bf(2026-09-25)· 起因:在 GalTranslPP(mcpp 工作区 + vcpkg + Qt 6,Windows/MSVC)上调查 +mcpp-language-server issue 23 时,整理出一批**与该项目无关、任何 mcpp 项目都能复用**的构建能力。§1–§6、§8 只设计这批 +通用能力;§7 用 GalTranslPP 做真实案例,核算"插件都做完以后,一个真实项目迁移要花多少、能省多少"。 + +标注约定:**【已核实】** 有出处(文档章节、索引文件、发布资产、CI 实测、源码计数);**【待验证】** 是设计依赖但尚未 +实测的假设,全部列入 §9 Phase 0。 + +## 0. 摘要与已定决策 + +| 交付物 | 放在哪 | 服务对象 | 状态 | +|---|---|---|---| +| `deps-vcpkg`(新的 `deps-*` 家族) | mcpp-plugins | 任何用 vcpkg manifest 模式的 mcpp 项目 | 新增 | +| `rules-qt`(+ `rules-qt-xim`、`rules-qt-xim-addons`) | mcpp-plugins | 任何 Qt 6 项目(Qt 5 以后再支持) | 新增 | +| `deps-cmake` | mcpp-plugins | 任何自带 CMake 子项目(submodule、vendored)的 mcpp 项目 | 新增,第二批 | +| `mcpp-vcpkg`、`mcpp-cmake` 启动器 | mcpp-plugins `[targets.*]` | 上面两个插件的 action 命令 | 新增 | +| `xim:vcpkg` | xim-pkgindex | **只含 vcpkg 工具本体** | 新增 | +| vcpkg registry 共享克隆 | vcpkg 的每用户目录(与其默认二进制缓存同级) | 所有项目共享,与工具独立 | 由 `mcpp-vcpkg` 管理,见 §3.6 | +| `xim:qt` + `xim:qt-addons` | xim-pkgindex | Qt 6 SDK:官方基础包 + **全部附加模块合为一个包** | 新增,首个版本 6.11.1 | +| `xim:cmake`/`ninja`/`git`/`7zip`/`msvc`/`python`/`wix` | xim-pkgindex | 工具与运行时 | 已有 | +| 引擎侧需求 4 项 | mcpp | 让插件"只描述、不做重活"成立 | 提给上游 | + +已定决策: + +| # | 轮次 | 问题 | 决定 | +|---|---|---|---| +| D1 | 1 | 新开 `deps-*` 家族 | 接受 | +| D2 | 1 | `xim:vcpkg` 的 registry 与体积 | registry 与工具**独立、可复用**:`xim:vcpkg` 只装工具;registry 每台机器一份,所有项目共享(§3.6) | +| D3 | 1 | `xim:qt` 的粒度 | 按预构建包的惯例,即 Qt 官方安装器的分包;首个版本 6.11.1(§6.2) | +| D4 | 1 | Qt 5 | 本期不做,记为后续(§10) | +| D5 | 1 | 7z 运行时 | 按行业惯例:Windows 把官方 `7z.dll` 放在 exe 旁边;Linux/macOS 用发行版 p7zip 的 `7z.so`(§6.3) | +| D6 | 2 | 附加模块能否做成一个包 | **能,建议做成一个包 `xim:qt-addons`**(依据与代价见 §6.2;待确认,§10-1) | +| D7 | 2 | registry 共享克隆放哪 | vcpkg 的每用户目录(§3.6);第 4 版起不再有托管克隆,见 §0.1 | +| D6′ | 3 | D6 确认 | `xim:qt-addons` 为一个包,**自有前缀**(xim 载荷私有),`rules-qt` 同时加入两个前缀 | +| D8 | 3 | 生态通用能力先行 | 引擎需求 2 在 mcpp 中实现(`mcpp::runtime_library_dir`);GalTranslPP 的 D 阶段在 fork 上初步验证 | +| D9 | 3 | 免装 VS | 不是目标;删除 `toolchain::msvc_xim`,`xim:msvc` 由 mcpp 自动匹配 | + +### 0.1 第 4 版更正(实现时的决定,取代正文中冲突的部分) + +1. **registry 不再由插件管理**(取代 §3.6 的托管克隆、按工具检出与文件锁)。vcpkg-tool 每个发布附带 + `vcpkg-standalone-bundle.tar.gz`(3.4 MB),其 `vcpkg-bundle.json` 为 `{"deployment":"OneLiner","usegitregistry":true}`, + 脚本与该工具版本严格对应。`xim:vcpkg` = 工具 + 该 bundle,安装目录即 `VCPKG_ROOT`。【已核实】本机以此为根,对 + GalTranslPP 的 baseline 执行 `vcpkg install --dry-run`,输出 "Fetching registry information from + https://github.com/microsoft/vcpkg",解析出 `fmt 12.2.0`,registry 缓存 137 MB。继承的 `VCPKG_ROOT` 不使用, + `options::vcpkg_root` 可显式指定。 +2. **启动器合为一个 `mcpp-deps`**(取代 `mcpp-vcpkg`、`mcpp-cmake`),子命令 `vcpkg`、`cmake`;消费者写 + `tools = ["mcpp-deps"]`。它设置 `VCPKG_ROOT`、`VCPKG_DISABLE_METRICS`,对安装根加排他锁,把 buildtrees/packages + 放在 `/mcpp/<安装根的 FNV 哈希>/`、downloads 放在 `/downloads`(未设 + `VCPKG_DOWNLOADS` 时)。`check` 动作的 stamp 由 mcpp 在命令成功后写入(docs/30,2026.8.29.1+),启动器不写。 +3. **不声明 cmake/ninja/7zip/git,也不设 `VCPKG_FORCE_SYSTEM_BINARIES`**(取代 §3.4)。vcpkg 自取经测试的 + CMake 3.x、Ninja、7-Zip(Windows 另取便携 git);强制使用 `xim:cmake` 4.x 会让 `cmake_minimum_required` 低于 + 3.5 的 port 失败。`xim:git` 没有 macOS 版本、Linux 只有 x86_64,无法作为跨平台依赖。Linux/macOS 的 git、curl、 + zip、unzip、tar 与 C 编译器按 vcpkg 文档属于主机前提。 +4. **工具链选项 v1 只保留 vcpkg 默认**(取代 §3.3(4) 的三选项表)。自定义工具链经项目自己的 overlay triplet + (`VCPKG_CHAINLOAD_TOOLCHAIN_FILE`)表达,插件不生成。 +5. **运行时目录经引擎通道**(取代 §3.3(3) 的逐 DLL deploy 过渡方案)。mcpp 新增构建程序指令 + `mcpp::runtime_library_dir(dir)`,是 `[runtime] library_dirs` 的构建程序形式:进入 `mcpp run` 的库搜索路径与 + `mcpp pack` 的闭包搜索目录。【已核实】引擎源码:pack 闭包只读 `plan.runtimeLibraryDirs`,`link_search`/`link_flag` + 不进入(`src/pack/pipeline.cppm`、`src/build/plan.cppm`)。插件下界因此为 mcpp 2026.9.27.1;非 Windows 另加 + `-Wl,-rpath`,使程序在 `mcpp run` 之外同样能启动。 +6. **工作区**(§3.3(5)):每个调用 `use()` 的成员各声明一条安装边;【已核实】引擎 `blocking` 只排序本包的编译边 + (`src/build/ninja_backend.cppm`)。并发由启动器的安装根锁串行化,不依赖 vcpkg 自身的锁。 +7. **依赖方的 deploy 会进入可执行文件布局**(关闭 §4.3 的【待验证】):【已核实】`deploy` 与 `link-*` 同属 + LinkIntent,由消费者的 `resolve_runtime_contract` 合并(`src/build/plan.cppm`)。库成员生成的 `.qm` 无需可执行成员重复声明。 +8. **`rules-qt` 的平台细节**:Linux/macOS 上 `moc`/`uic`/`rcc` 在 `libexec/`;macOS 的模块是 framework,以 + `-F/lib` 编译、以 framework 内二进制的完整路径链接;Linux 加 `-fPIC`(Qt 以 `-reduce-relocations` 构建); + Windows 的插件目录跳过 `*d.dll` 调试副本。 +9. **`deps-cmake` 使用 CMake 默认工具链与生成器**(取代 §5 的"生成 toolchain file"):与子项目作者的构建方式一致; + 项目可经 `cache_args` 指定 `-G`、编译器或 toolchain file。 +10. **fixture 规则**:CI 中 `all-rules-compile` 的成员检查由"名单相等"改为"每个带 sources 的成员都被列出,列出的 + 名字都已声明"。只声明载荷的 feature(`rules-qt-xim`、`dist-apk-kotlin`)不再强制列入,避免在三个平台下载 Qt。 + +## 1. 原则 + +1. **只做通用、可复用的**。插件里不出现任何项目知识,项目差异全部走参数。与 `tools/README.md` 的准入一致:"A utility + written for a single consumer belongs in that consumer's `build.mcpp`; a member here is one that more than one + consumer needs." +2. **插件拥有它驱动的包列表**。插件编译期要用的 xim 包由插件在 `[target..feature-xlings.]` 声明, + 项目不重复写;**程序运行时要的东西由项目声明**(mcpp.toml 中 "A RULE OWNS THE LIST OF PACKAGES IT DRIVES" / + "WHAT IS NOT HERE" 两段)。 +3. **构建程序只描述,重活全部是 action**。【已核实】`mcpp emit build-database` 执行构建程序且无"规划模式"信号 + (mcpp `docs/specs/build-database.md` R2.5;`docs/30` 环境契约表中无此变量);构建程序有 600 s 上限 + (`docs/30` "Current limitations");`mcpp::action` 是 ninja 边,只在 `mcpp build` 时执行,不受该上限约束 + (`docs/30` "Declaring work instead of doing it")。 +4. **缺环境不失败**。构建程序遇到缺失的前缀/工具时输出确定的路径并 `mcpp::warning`,真正的失败留给 action 边—— + 这样 IDE(mcppls 通过 emit 取模型)在一台尚未安装依赖的机器上也能拿到可信模型。反例是 GalTranslPP 现状:构建 + 程序缺 Qt 或缺一个 vcpkg `.lib` 就 `return 1`,emit 整体失败(CI 实测 `MCPP_BUILD_DATABASE_PLAN_FAILED`)。 +5. **不写别人的东西**。插件只写 `out_dir`、自己管理的目录(§3.6 的托管克隆)和用户显式要求写的位置;用户已有的 + vcpkg 克隆、项目源码树默认只读(lupdate 见 §4.3)。 +6. **前缀即含义**。沿用 README 的分类并新增一类,见 §2。 + +## 2. 新家族 `deps-*`(D1) + +现有三类各回答一个问题(README "Naming"):`rules-*` 某类翻译单元怎么编译;`tools-*` 构建程序自己要做什么; +`dist-*` 链接产物以什么形式交付。**"一个库从哪里来"是第四个问题**,它: + +- 不编译项目的翻译单元(不是 rule), +- 重活不能在构建程序里做(`tools/README.md`:"A tool … does it while `build.mcpp` runs"——vcpkg 安装恰恰不能), +- 产物是头文件、库和运行时 DLL,被编译和链接消费(不是 dist)。 + +因此新增 `deps-*`:`deps-vcpkg`、`deps-cmake`,以后 `deps-conan`、`deps-prefix` 同属此类。形态沿用 `tools-*`/`dist-*`: +无 `rule_module`/`device_extensions`,消费者写 `host-module = true` 并按名 import。落地时 README 的 "Naming" 表与 +`mcpp.toml` 的分类注释同步加一行。 + +## 3. `deps-vcpkg` + +### 3.1 范围 + +- 做:vcpkg **manifest 模式**(项目根有 `vcpkg.json`);安装作为构建图的一条边;把已安装前缀映射为 include、link、 + 运行时 DLL;向项目暴露前缀路径(数据文件如 `share/opencc` 由项目自己 deploy);二进制缓存;共享 registry(§3.6); + 让 vcpkg 用 xlings 提供的 cmake/ninja/git;可选地让 vcpkg 用 mcpp 解析出的工具链编库。 +- 不做:classic 模式;替代 vcpkg 的版本解析;为头文件生成 C++ 模块接口(仍是项目或 index 包的事)。 + +### 3.2 消费者写法 + +```toml +[build-dependencies.mcpp] +plugins = { version = "0.13.0", features = ["deps-vcpkg"], host-module = true, tools = ["mcpp-vcpkg"] } +``` + +```cpp +// build.mcpp +import std; import mcpp; import mcpp.deps.vcpkg; +int main() { + mcpp::deps::vcpkg::options o; + o.triplet = "x64-windows"; // 自定义 triplet 由 vcpkg-configuration.json 的 overlay 指定 + o.libraries = { "fmt", "spdlog" }; // 显式、可 review;见 3.3(2) + o.toolchain = mcpp::deps::vcpkg::toolchain::vcpkg_default; // 见 3.3(4) + const auto prefix = mcpp::deps::vcpkg::use(o); // 缺前缀时仍返回确定路径并 warning,见原则 4 + // 数据文件由项目按需 deploy,例如 prefix / "share/opencc" + return prefix.empty() ? 1 : 0; // 只有参数错误才为空 +} +``` + +### 3.3 机制 + +**(1) 安装边**:一个 `mcpp::action`,`role = "check"`、`blocking = true`(【已核实】`docs/30` 角色表:"blocking = true +makes the package's compile edges wait for it")。 + +- 命令:`mcpp-vcpkg install --root <项目根> --triplet …`,经 `mcpp::dep_bin("plugins", "mcpp-vcpkg")` 取得(沿用 + `mcpp-embed` 的方式:`[targets.mcpp-vcpkg]` + 消费者 `tools = ["mcpp-vcpkg"]`,【已核实】`src/declare.cppm`)。 +- 为什么要启动器:【已核实】`mcpp::action` 没有设置环境变量的接口(`docs/30` 只有 `rerun-if-env-changed`),而 vcpkg + 需要 `VCPKG_ROOT`(§3.6)、`VCPKG_BINARY_SOURCES`、`VCPKG_FORCE_SYSTEM_BINARIES`、`VCPKG_DISABLE_METRICS` 和指向 + xlings 工具的 `PATH`。启动器是普通程序,设置环境后启动 vcpkg 并等待它结束(期间持有 §3.6 的共享锁),不依赖 shell(与 `dist-web` 用 + `${mcpp.self} stage` 代替 `cp` 的理由相同)。若引擎将来提供 `action.env(...)`(§8 需求 4),启动器仍保留 registry + 管理(§3.6),只是不再承担环境设置。 +- 目录隔离:`--x-buildtrees-root`、`--x-packages-root` 指向项目自己的 `out_dir`,`downloads/` 共享。共享克隆的默认 + buildtrees/packages 在克隆内,两个项目同时编同一个 port 会互相覆盖;按项目隔离后与 vcpkg 自身的锁语义无关, + 一定安全。(GalTranslPP 的 CI 已经这样用 `--x-buildtrees-root`。) +- 输入:`vcpkg.json`、`vcpkg-configuration.json`、overlay ports/triplets 目录(`input()` + `rerun_if_changed_glob`)。 + 输出:stamp。只在这些输入变化时重跑;emit 永不执行;不受 600 s 限制。 +- 二进制缓存:**不覆盖,用 vcpkg 的默认位置**(【已核实】vcpkg 文档 "Default binary cache":Windows + `%LOCALAPPDATA%\vcpkg\archives`,其他 `$XDG_CACHE_HOME/vcpkg/archives` 或 `~/.cache/vcpkg/archives`)。这样用户 + 已有的缓存直接复用,与 D7 的目录选择一致;CI 用 `VCPKG_BINARY_SOURCES` / `VCPKG_DEFAULT_BINARY_CACHE` 覆盖即可。 + +**(2) 映射(构建程序内,只读、保证成功)** + +- `include_dir(//include)`:路径在安装前即可确定。`install_root` 默认 `/vcpkg_installed` + (vcpkg 的默认值,已有项目的目录布局不变)。 +- 链接:以 `options.libraries` 为准,**一律用确定的完整路径** `mcpp::link_flag(/lib/.lib)`(ELF/Mach-O 上 + `lib.a/.so/.dylib`)。链接边排在编译边之后、编译边又等待安装边,所以链接时文件必然已存在,不必在规划期判断 + 存在与否。用完整路径的理由 GalTranslPP 的 `vcpkg_link.hpp` 写过:"An exact file path cannot resolve to a same-named + system library"。之所以要显式列表:首次构建时构建程序先于安装边运行,vcpkg 的 `info/*.list` 尚不存在。 +- 校验:`info/*.list` 存在时,比对列表与实际产物,不一致给 `mcpp::warning`;`rerun_if_changed(stamp)` 让安装完成后 + 下一次构建程序按实际结果重新校验。 +- 缺失:前缀不存在时不 `return 1`,只 warning(原则 4)。IDE 在首次构建前能拿到正确的 include 路径,但头文件尚不存在; + mcppls 可据此提示"先运行一次 `mcpp build`"。 + +**(3) 运行时(动态 triplet)**:DLL 在 `/bin`。`mcpp run`/`mcpp test` 要能找到它们,`mcpp pack` 要把它们 +收进闭包。【待验证】PE 下 pack 的闭包搜索目录是否包含构建程序声明的目录(`docs/10` 只写清了 Android 行:输出目录、 +各包 `[runtime] library_dirs`/`link_library_dirs`、编译器搜索路径)。过渡方案:插件对 `info/*.list` 中的每个 DLL +调 `mcpp::deploy` 放到 exe 旁边(run 与 pack 都成立);根本方案见 §8 需求 2。 + +**(4) 工具链**:三个选项,决定"vcpkg 用什么编译器编 port"。 + +| 选项 | 编译器 | 需要 VS 实例 | port 兼容性 | 用途 | +|---|---|---|---|---| +| `vcpkg_default`(默认) | vcpkg 自己找的 cl | **需要**(vcpkg 用 vswhere 找 VS 并加载 vcvars) | 最好 | 已装 VS 的机器、CI runner | +| `msvc_xim` | `xim:msvc` 的 cl,经 chainload | 不需要 | 与默认相同(同一个编译器) | **免装 VS** | +| `project` | `mcpp::toolchain_dir()`(如 LLVM clang),经 chainload | 不需要 | 个别 port 不支持 clang | 全链路同一编译器 | + +- 【已核实】vcpkg 文档 `VCPKG_LOAD_VCVARS_ENV`:"By default, this is ON for Windows triplets that do not specify + `VCPKG_CHAINLOAD_TOOLCHAIN_FILE` … triplets specifying `VCPKG_CHAINLOAD_TOOLCHAIN_FILE`, this defaults to OFF"。 + 所以**只有 chainload 路线能做到免装 VS**;`xim:msvc` 只解决了 mcpp 自己的编译,vcpkg 默认路线仍要 VS。 +- 后两项由插件在 `out_dir` 生成 overlay triplet 与 chainload 工具链文件。【已核实】vcpkg 在 Windows 上用干净环境编 + port,`INCLUDE`/`LIB` 要经 `VCPKG_ENV_PASSTHROUGH` 传入(文档同页);或者工具链文件直接写 `/I`、`/LIBPATH`。 + 哪种可行【待验证】。 +- **项目自带自定义 triplet 时**(GalTranslPP 的 `gpp-x64-windows-release` 就是):生成的 triplet 先 `include()` 项目 + triplet,再追加 chainload 设置,不替换它;triplet 名随之变为 `<原名>-mcpp-<选项>`,安装目录、映射一律按新名。 +- 同时检查 triplet 的 `VCPKG_CRT_LINKAGE` 与项目运行库(如 `-fms-runtime-lib=dll`)是否一致,不一致则 warning。 + +**(5) 工作区**:`blocking` 的顺序是"per package"(【已核实】`docs/30`),被依赖成员的安装边管不到不依赖它的成员 +(GalTranslPP 的 `Updater` 不依赖 core,却要链接 `bit7z`,§7.1)。默认做法:**每个调用 `use()` 的成员各声明一条 +安装边**,命令相同、stamp 各在自己的 `out_dir`。第一条真正安装,其余的 `vcpkg install` 发现已安装、几秒内结束; +并发时由 vcpkg 对安装目录加的锁串行化【待验证:manifest 模式对 `vcpkg_installed` 的锁】。锁不成立时退回"同一个 +stamp 由每个成员声明为 `input()`、只由一个成员声明为 `output()`";根本方案是 §8 需求 3。 + +### 3.4 插件声明的 xlings 依赖 + +```toml +[target.windows.feature-xlings.deps-vcpkg] +"xim:vcpkg" = ">=2026.7.27" # 只含工具,见 §3.6、§6.1 +"xim:cmake" = ">=3.31" # 【已核实】xim 有 cmake 4.4.2(linux/macosx/windows) +"xim:ninja" = ">=1.12" # 【已核实】ninja 1.12.1 +"xim:git" = ">=2.40" # 【已核实】git 2.51.1(windows)/ 2.53.0(linux);registry 管理要用 +"xim:7zip" = ">=26.02" # 【已核实】7zip 26.02,三平台;vcpkg 解包用 +[target.'cfg(any(linux, macos))'.feature-xlings.deps-vcpkg] +"xim:vcpkg" = ">=2026.7.27" +"xim:cmake" = ">=3.31" +"xim:ninja" = ">=1.12" +"xim:git" = ">=2.40" +``` + +用 floor(`>=`)而不是精确版本:这些工具与项目没有 ABI 耦合(沿用 mcpp.toml 中"THE SHAPE OF EACH DEFAULT IS A +JUDGEMENT ABOUT COUPLING"的判断)。`xim:msvc` 不在这里:只有选了 `toolchain::msvc_xim` 的项目才要,由项目的 +`sysroot = "xim:msvc@…"` 带入。【待验证】`VCPKG_FORCE_SYSTEM_BINARIES=1` 在 Windows 上会让 vcpkg 使用 PATH 中的 +cmake/ninja/7zip,而不是自行下载到 `downloads/tools`。【待验证】Linux 上 vcpkg 还要求 `curl`、`zip`、`unzip`、`tar`、 +`pkg-config`:优先来自 xim(`xim:curl` 已有,其余待查),缺的由主机提供并在文档中写明。 + +### 3.5 测试(fixtures,放入现有 CI 矩阵) + +| fixture | 平台 | 断言 | +|---|---|---| +| `vcpkg-consumer`(fmt,动态 triplet) | windows-2022、ubuntu-24.04 | 构建、运行;`mcpp pack` 的 zip 含 `fmt.dll` | +| `vcpkg-consumer-static` | 同上 | 静态 triplet,无 DLL | +| `vcpkg-consumer-msvc-xim` | windows-2022 | `toolchain::msvc_xim` 下 port 由 `xim:msvc` 的 cl 编译,构建日志中不出现 vcvars | +| `vcpkg-consumer-project-toolchain` | windows-2022 | `toolchain::project` 下 port 由 mcpp 的 clang 编译;自定义 triplet 被 include 而非替换 | +| `vcpkg-emit-safe` | 同上 | 无 `vcpkg_installed` 时 `mcpp emit build-database` 返回 0,include 路径已在库中 | +| `vcpkg-shared-registry` | 同上 | 两个 baseline 不同的项目共用一份托管克隆,第二个项目只 `git fetch` 不重新克隆;两者并发构建不冲突 | +| `vcpkg-user-root` | 同上 | 设置了 `VCPKG_ROOT` 时直接使用,且构建前后该克隆的 `git status`、refs 不变 | +| `vcpkg-workspace` | 同上 | 两个互不依赖的成员都 `use()`,只安装一次,两者都能链接 | +| 二次构建 | 同上 | 输入未变时安装边不重跑 | + +### 3.6 registry:与工具独立、每台机器一份(D2、D7) + +【已核实】vcpkg 官方就把两者分开发布:工具来自 microsoft/vcpkg-tool 的发布资产(2026-07-27:`vcpkg.exe`、 +`vcpkg-arm64.exe`、`vcpkg-glibc`、`vcpkg-glibc-arm64`、`vcpkg-muslc`、`vcpkg-macos`,单文件),registry 是 +microsoft/vcpkg 仓库(GitHub 统计约 111 MB)。因此 **`xim:vcpkg` 只装工具**;registry 由 `mcpp-vcpkg` 按项目的两种 +manifest 写法处理: + +| 项目写法 | vcpkg 的要求 | 本设计的做法 | +|---|---|---| +| `vcpkg-configuration.json` 的 `default-registry` 为 `git` 类型(带 `baseline`) | 工具自己把 registry 取到它的每用户 registries 缓存,所有项目共享 | 什么都不用做(`X_VCPKG_REGISTRIES_CACHE` 可覆盖)【待验证:各平台默认路径】 | +| `vcpkg.json` 的 `builtin-baseline`(GalTranslPP 就是这种) | `VCPKG_ROOT` 必须是含该 baseline 提交的 microsoft/vcpkg 克隆 | 见下 | + +`builtin-baseline` 的 `VCPKG_ROOT` 按顺序选: + +1. **用户已有的克隆**:`VCPKG_ROOT` 已设置、是 microsoft/vcpkg 克隆、且含 baseline 提交(`git cat-file -e`)时直接用, + **只读**(原则 5):不 fetch、不 checkout,buildtrees/packages 已按 §3.3(1) 移出。缺提交时不碰它,落到第 2 项, + 并在 warning 里写明原因。 +2. **托管克隆**(D7):放在 vcpkg 的每用户目录下,与它的默认二进制缓存 `archives/` 同级——Windows + `%LOCALAPPDATA%\vcpkg\mcpp-registry`,其他 `$XDG_CACHE_HOME/vcpkg/mcpp-registry` 或 `~/.cache/vcpkg/mcpp-registry`。 + 第一次完整克隆(不能浅克隆:vcpkg 要从 git 对象读取 baseline 时刻的版本数据库和 port 树);之后缺 baseline 时只 + `git fetch`。目录名带 `mcpp-`,表明它归 mcpp 管,不与用户自己放在这里的东西冲突。它在 cache 目录下,被清理后 + 下次自动重建。 + +**托管克隆检出哪个版本**:port 的版本来自 baseline,但 `scripts/`(port 构建时调用的 CMake 函数)永远来自工作区的 +检出。所以检出必须**同时**满足: + +- 与工具匹配:【已核实】registry 的每个提交都在 `scripts/vcpkg-tool-metadata.txt` 写明所需工具,例如发布标签 + `2026.07.29` 写的是 `VCPKG_TOOL_RELEASE_TAG=2026-07-27`,正是 `xim:vcpkg` 的首个版本。 +- 不旧于项目 baseline:新 port 可能用到旧脚本里没有的函数。 + +做法:检出**上游主干上"所需工具不新于已装 `xim:vcpkg`"的最后一个提交**,即下一次"Release vcpkg-tool"提交的前一个 +(只需遍历改过 `vcpkg-tool-metadata.txt` 的少数提交)。项目 baseline 自身要求的工具比已装的新时报错,写明 baseline、 +它要求的工具、已装工具三个版本,提示升级 `xim:vcpkg`;否则 baseline 一定在这个检出之前,两条都满足。这样**工具版本 +决定脚本版本、baseline 决定 port 版本**,不会因为上游 registry 前进而让所有项目一起失败(第 2 版的问题,见 §11)。 + +不按发布标签检出:【已核实】GalTranslPP 的 baseline `ea1a7396`(2026-08-08)比最新标签 `2026.07.29` 晚 129 个提交, +但要求的工具同样是 2026-07-27。按标签检出会把一个完全兼容的项目判为"baseline 太新"。项目在两个标签之间选 baseline +很常见。 + +- 共用一份克隆的前提:带版本的 builtin registry 从 git 对象读取 baseline 时刻的 port 版本,不同 baseline 的项目 + 可以共用一份检出。【待验证】 +- 并发:`mcpp-vcpkg` 对托管克隆加文件锁——`fetch`/`checkout` 持排他锁,`vcpkg install` 持共享锁,所以正在编的项目 + 不会看到脚本在中途被换掉。 + +## 4. `rules-qt`(+ `rules-qt-xim`、`rules-qt-xim-addons`) + +### 4.1 为什么是一个 rule、为什么通用 + +所有 Qt 6 项目都要:moc(含 `Q_OBJECT`/`Q_GADGET`/`Q_NAMESPACE` 的头文件 → `moc_*.cpp`)、uic(`.ui` → `ui_*.h`)、 +rcc(`.qrc` → `qrc_*.cpp`)、lrelease(`.ts` → `.qm`,可选 lupdate)、链接所用模块、部署运行时。符合 `rules-*` 的定义 +"how one kind of translation unit is compiled by a compiler mcpp does not drive";`rules-swift` 也负责给自身运行时加 +链接搜索目录,是先例(README `rules-swift` 行)。 + +部署是"通用"的关键理由之一:Qt 插件(`platforms/qwindows.dll`、`styles/`、`imageformats/`)由 `QPluginLoader` 在运行时 +加载,**不在 PE 导入表里**,`mcpp pack` 按导入表收集 DLL(【已核实】`docs/10` "Windows (PE)")收不到——这正是 +`windeployqt` 存在的原因,每个 Qt 项目都要解决。 + +### 4.2 声明与写法 + +```toml +[features.rules-qt] +sources = ["rules/qt.cppm"] +implies = ["surface"] +rule_module = "mcpp.rules.qt" +device_extensions = [".ui", ".qrc", ".ts"] + +# Qt 从哪来与规则无关:用 vcpkg 的 qtbase 或本机 Qt 的项目不应下载 SDK。 +# 沿用 dist-apk-kotlin 的理由:"provisioning runs before the build program learns what it will compile"。 +[features.rules-qt-xim] +implies = ["rules-qt"] + +[target.'cfg(any(windows, linux, macos))'.feature-xlings.rules-qt-xim] +"xim:qt" = "6.11.1" # 精确版本:moc 输出与 Qt 库版本耦合;项目可改选其他版本 + +# 用到附加模块(Multimedia、Charts…)的项目再开这一项;只用 qtbase/qtdeclarative 的项目不下载。 +[features.rules-qt-xim-addons] +implies = ["rules-qt-xim"] + +[target.'cfg(any(windows, linux, macos))'.feature-xlings.rules-qt-xim-addons] +"xim:qt-addons" = "6.11.1" # 必须与 xim:qt 同版本,§4.3 校验 +``` + +`.ts` 同时是 TypeScript 的扩展名;不会误判,因为 feature 必须按名激活(mcpp.toml "The feature is still requested BY +NAME"),且只对项目 `sources` 中的文件生效。 + +```cpp +import mcpp.rules.qt; +int main() { + mcpp::rules::qt::options o; + o.modules = { "Core", "Gui", "Widgets", "Network" }; + o.moc = mcpp::rules::qt::moc_scan::project_headers; + o.translations = { .ts = { "i18n/app_en.ts" }, .tr_function_alias = { "translate+=appTr" } }; + o.deploy_plugins = { "platforms", "styles", "imageformats" }; + return mcpp::rules::qt::compile(o) ? 0 : 1; +} +``` + +无 `build.mcpp` 时(零配置):处理 sources 里的 `.ui`/`.qrc`/`.ts`,扫描项目头文件跑 moc,只链接 `Core`,部署 +`platforms`——够跑一个最小程序;其余都要写选项。 + +### 4.3 机制 + +- **Qt 根目录**:`options.root` > `mcpp::xpkg_dir("xim", "qt")`(【已核实】`docs/30` "Finding an `[xlings.workspace]` + payload",且对依赖方声明的包同样有效)> 未找到时 warning(原则 4)。探测 `bin/moc`、`uic`、`rcc`、`lrelease`。 + `xim:qt-addons` 装进同一个前缀(§6.2),规则不需要第二个根目录;模块不在前缀里时报错并提示开 + `rules-qt-xim-addons`。所用附加模块的 `lib/cmake/Qt6/Qt6ConfigVersion.cmake` 与 `lib/cmake/Qt6/Qt6ConfigVersion.cmake` 版本不一致时报错(项目只改了其中一个包的版本)。 +- **moc**:按内容扫描项目头文件(`rerun_if_changed_glob` 声明输入),每个一条 `role = "source"` action,带 depfile + (`moc --output-dep-file`,【待验证】版本下限);`.cpp` 内含 `Q_OBJECT` 时生成 `.moc` 头并 `include_dir(out_dir)`。 +- **uic / rcc**:`role = "source"`;rcc 的依赖用 `rcc --list` 在规划期取得资源清单作为 `input()`,或 depfile【待验证】。 +- **lrelease**:`.ts` → `.qm`,`role = "source"`(产物不参与编译),再 `mcpp::deploy` 到 `translations/`。 +- **lupdate**:会**改写源码树里的 `.ts`**,默认关闭;`o.translations.update_sources = true` 时作为 `role = "check"`、 + `blocking = true` 的 action,lrelease 依赖它的 stamp(GalTranslPP 现行做法,§7.2)。与 emit "never writes into the + project tree"(specs R2.1)的精神一致:只在显式要求的 build 中写。 +- **链接**:`include_dir(/include/Qt)`、`link_search(/lib)`、`link_lib("Qt6")`、 + `QT__LIB` 等定义;编译器族为 msvc/clang-cl 时加 `/Zc:__cplusplus`(Qt 6 对 MSVC 的要求;clang 驱动不需要); + Windows 下 GUI 子系统入口(`Qt6EntryPoint`)随 `windows_subsystem` 处理。`o.private_modules = { "Widgets" }` 加 + `include/Qt/`(ElaWidgetTools 这类库要 `WidgetsPrivate`,§7.2)。 +- **部署**:模块 DLL 由 pack 的导入表闭包收集(前提同 §3.3(3)【待验证】);插件目录用 `mcpp::deploy` 放到 + `platforms/` 等子目录(【已核实】构建程序的 `mcpp::deploy` 见 `docs/30` "Deploying what the program generated", + pack 将其放在 `bin//`,README `dist-apple` 行;清单侧 `[runtime] deploy` 的 `to` 语义见 `docs/04` §2.11)。 + `d3dcompiler_47.dll`、`opengl32sw.dll` 按 windeployqt 的默认行为可选部署(`o.deploy_software_gl`)。 +- **库成员的翻译**:库成员(如 GalTranslPP core)生成的 `.qm` 要进可执行成员的 `translations/`。【待验证】依赖方 + 构建程序的 `mcpp::deploy` 是否随依赖进入可执行文件的 run/pack 布局;不成立时由可执行成员的 `translations` 选项 + 直接列出库的 `.ts`。 + +### 4.4 测试 + +`qt-consumer`(Widgets,含 `Q_OBJECT` 头、`.ui`、`.qrc`、`.ts`),windows-2022 与 ubuntu-24.04:构建、无显示环境下以 +`QT_QPA_PLATFORM=offscreen` 运行;pack 的 zip 含 `platforms/qwindows.dll` 与 `translations/*.qm`;emit 在无 Qt 时返回 0; +`qt-consumer-zero-config` 验证无 `build.mcpp` 的路径;`qt-consumer-workspace` 验证库成员的 `.qm` 进入可执行文件布局; +`qt-consumer-addons` 链接一个附加模块(如 `WebSockets`)。 + +## 5. `deps-cmake`(第二批) + +项目里常见"带一个 CMake 子项目"(submodule/vendored),今天只能靠外部脚本先编好(GalTranslPP 的 ElaWidgetTools +就是:一个 Python 脚本调 CMake,Qt 路径写死在脚本里,§7.1)。插件把 configure / build / install 作为三条 action +(`role = "check"` 链,最后一条 blocking),安装到 `out_dir` 下的前缀,再按 §3.3(2) 的方式映射。 + +```cpp +mcpp::deps::cmake::options o; +o.source = "../3rdParty/ElaWidgetTools"; +o.cache_args = { "-DQT_SDK_DIR=" + qt_root, "-DELAWIDGETTOOLS_BUILD_EXAMPLE=OFF" }; +o.layout = { .include = "ElaWidgetTools/include", .lib = "ElaWidgetTools/lib", .bin = "ElaWidgetTools/bin" }; +o.libraries = { "ElaWidgetTools" }; +mcpp::deps::cmake::use(o); +``` + +- 工具链:生成 CMake toolchain file,编译器取 `mcpp::toolchain_dir()`,与项目一致;也可选 `msvc_xim`(同 §3.3(4))。 +- 依赖前缀:`options.prefix_path` 透传 `CMAKE_PREFIX_PATH`;与 `rules-qt` 组合时传 Qt 根目录(`mcpp::rules::qt::root()`)。 +- 安装布局:默认 `include/`、`lib/`、`bin/`;子项目自有布局时用 `o.layout` 指定(ElaWidgetTools 装在 + `/ElaWidgetTools/{include,lib,bin}`)。 +- 输入:`rerun_if_changed_glob(/**/CMakeLists.txt)` 与源码 glob;子项目源码变化时重编。 +- xlings:`"xim:cmake" = ">=3.31"`、`"xim:ninja" = ">=1.12"`(插件声明)。 +- 同 `deps-vcpkg`:库列表显式声明、链接用完整路径;缺前缀只 warning。 + +## 6. xlings 生态依赖 + +### 6.1 清单 + +| 包 | 状态 | 平台 | 本设计中的用途 | 要做的事 | +|---|---|---|---|---| +| `xim:vcpkg` | **缺** | win(x64、arm64)/ linux(glibc、musl、arm64)/ macos | vcpkg 工具本体 | 新增,只含 vcpkg-tool 单文件(§3.6) | +| `xim:qt` | **缺** | win(msvc2022_64、arm64)、linux、macos | Qt 6 基础包 | 新增,§6.2 | +| `xim:qt-addons` | **缺** | 同上 | Qt 6 全部附加模块 | 新增,§6.2 | +| `xim:cmake` / `ninja` / `git` | 已有 | 三平台 | vcpkg、deps-cmake 的构建工具;registry 管理 | 无 | +| `xim:7zip` | 已有(26.02;上游已发 26.03) | 三平台 | vcpkg 解包;Windows 上兼作 `7z.dll` 来源 | 在包说明中写明 `7z.dll` 的位置,§6.3 | +| `xim:msvc` + `windows-sdk` | 已有 | windows | 固定 MSVC 工具集;`toolchain::msvc_xim` 的编译器 | 无;项目写 `[target.x86_64-windows-msvc] sysroot = "xim:msvc@14.44.35207"`(mcpp 2026.9.24.1) | +| `xim:python` | 已有 | 三平台 | 嵌入 Python(pybind11 场景) | 【已核实】Windows 为 python-build-standalone `install_only`,有 3.13.12 与 3.12.13;是否含 `include/` 与 `libs/python3X.lib` 【待验证】 | +| `xim:wix` | 已有 | windows | `dist-wix` | 无 | + +按原则 2:`xim:qt`/`xim:qt-addons` 由 `rules-qt-xim*` 声明、构建工具由 `deps-*` 声明;`7z.dll`、Python 运行时这类 +"程序运行时要的"由项目在 `[xlings.workspace]` 声明(【已核实】`docs/23` 的 `when` 取值为 `build`/`run`/`dev`;构建和 +运行都要的不写 `when`)。 + +### 6.2 `xim:qt` 与 `xim:qt-addons`(D3、D6) + +- **`xim:qt`** = 官方基础包 `qt.qt6..`。【已核实】6.11.1 / win64_msvc2022_64 的基础包含 8 个归档:qtbase、 + qtsvg、qtdeclarative、qtdoc、qttools、qttranslations、d3dcompiler_47、opengl32sw,合计压缩 245 MB、解压 2.26 GB。 + xim 安装除 `qtdoc` 以外的全部归档(文档对构建无用)。qtbase 已含 Core/Gui/Widgets/Network 以及 `qjpeg`/`qgif`/`qico` + 图像插件,大多数桌面程序只需要这一个包。 +- **`xim:qt-addons`** = 全部附加模块 `qt.qt6..addons.*.` 合为**一个包**,装进 `xim:qt` 的同一个前缀, + 依赖同版本的 `xim:qt`。 + +**为什么能做成一个包**(回答第 2 轮问题 1): + +| 依据 | 数据 | +|---|---| +| 官方安装器本来就有这个分组 | 【已核实】`Updates.xml` 里有父节点 `qt.qt6.6111.addons`("Additional Libraries"),勾它即装全部附加模块 | +| 体积可接受 | 【已核实】34 个附加模块(不含调试符号)合计压缩 **178 MB**、解压 **1.76 GB**,比基础包还小 | +| 不影响链接 | 规则只链接 `o.modules` 列出的模块(§4.3),装了不等于用了 | +| 省掉一个开放问题 | 不再需要 34 个包名,也就不需要 xim 维护者确认 `qt-` 的命名 | + +**代价**:只要一个小模块(如 WebSockets 解压 1.8 MB)也得下载 178 MB;其中约 90% 来自 7 个大模块(Multimedia 479 MB、 +Quick3DPhysics 445 MB、Quick3D 273 MB、LanguageServer 141 MB、gRPC 88 MB、ActiveQt 78 MB、Qt3D 73 MB,均为解压后)。 +可接受,因为只有开了 `rules-qt-xim-addons` 的项目才下载,且 xlings 按版本缓存、全机共享。若以后某个模块单独太大, +再把它拆出去,不影响现有写法。 + +**许可**:附加模块中相当一部分只以 GPLv3 或商业许可提供(`Updates.xml` 对 addons 节点的说明:"Most of the additional +libraries are available under commercial licenses from The Qt Company, or under GPL v3")。安装不产生义务,链接才会; +由于规则只链接显式列出的模块,项目不会误用。包说明里写明这一点,并列出 LGPL 与 GPL-only 模块。 + +- 调试符号包(`debug_information`、`debug_info`)不收(基础包的调试符号解压 4.05 GB)。 +- **首个版本 6.11.1**(GalTranslPP 的要求),平台先做 win64_msvc2022_64,再补 linux、macos、arm64。 +- 【已核实】下载方式:6.11 起在线仓库按架构分目录(`qt6_6111/qt6_6111_msvc2022_64/Updates.xml`),aqtinstall 3.3.0 不认; + 每个归档以安装前缀为根(`bin/`、`include/`、`lib/`)。本设计的验证 CI 已按此方式装成功。 +- 许可:xim 只是从 Qt 官方仓库下载;包说明注明 Qt 的 LGPL 义务(动态链接、允许替换)。 + +### 6.3 7z 运行时(D5) + +【已核实】7-Zip 官方发布两种给程序用的库:完整安装包里的 `7z.dll`(全部格式,含 RAR 解压)和 "7-Zip Extra" +(`7z2603-extra.7z`)里的 `7za.dll`/`7zxa.dll`(不含 RAR)。行业惯例(bit7z 等封装库的用法): + +- **Windows**:把 `7z.dll`(需要 RAR 时)或 `7za.dll` 放在 exe 旁边随程序发布。`xim:7zip` 的 Windows 安装本来就是完整 + 安装包展开,【已核实】`7z.dll` 就在 `xpkg_dir("xim", "7zip")` 下,所以项目一行 `mcpp::deploy` 即可,不再把 DLL 提交 + 进仓库。 +- **Linux / macOS**:【已核实】官方只发 `7zz` 程序,没有共享库;惯例是用发行版 p7zip 的 `7z.so`,由主机提供,不在 + xim 范围内。 +- 许可:`7z.dll` 为 LGPL,RAR 解压部分带 unRAR 限制;包说明里注明。 +- `xim:7zip` 要做的只有一件事:在包说明里写明 Windows 安装目录中 `7z.dll` 的位置是约定(不是偶然),这样项目可以依赖它。 + +## 7. 真实案例:GalTranslPP 迁移的成本与收益 + +本节回答:"§3–§6 都做完以后,一个真实项目要花多少才能用上,能省下什么。"数据全部来自 GalTranslPP 上游 `main` +2c7d6bf 的源码计数、它的 `how-to-build.md`、子模块源码和本设计的验证 CI(Sunrisepeak/GalTranslPP PR #1)。 + +### 7.1 现状(【已核实】源码计数) + +工作区 5 个成员:`GalTranslPP`(core,库)、`GPPVersion`(库)、`GPPCLI`、`GPPGUI`、`Updater`(可执行)。依赖关系: +CLI、GUI 依赖 core;**Updater 不依赖 core,但自己链接 `bit7z`**。 + +| 部分 | 规模 | 做什么 | +|---|---|---| +| `mcpp-build-scripts/*.hpp` | 4 个头文件 383 行 | Qt 路径与模块(51)、vcpkg 链接(19)、lupdate/lrelease(62)、moc/rcc 与发布布局(251) | +| `mcpp-build-scripts/qt-root.txt` | 1 行 | **提交进仓库的本机路径** `D:/Qt/6.11.1/msvc2022_64` | +| `mcpp-build-scripts/runtime-stage` | 232 行(工具成员) | 解析 PE 导入表,把运行时 DLL 复制进 `Release/` 各目录 | +| `Release.py` | 47 行 | 复制 opencc 数据、BaseConfig、SampleProject、`7z.dll` 到三个发布目录 | +| 5 个 `mcpp.toml` + 根 | 240 行 | 其中 4 处写死 `../vcpkg_installed/gpp-x64-windows-release/include`,2 处写死 ElaWidgetTools 安装路径 | +| 4 个 `build.mcpp` | 79 行 | 调用上面的头文件 | +| `3rdParty/7z.dll` | 1.9 MB | 提交进仓库的二进制 | +| 子模块 ElaWidgetTools | CMake 项目 | 用自带的 `build.py` 编;Qt 路径 `D:/Qt/6.11.1/msvc2022_64` **写死在脚本里**;需要 `WidgetsPrivate` | +| 子模块 pybind11(fork) | 附带 Python 头文件与 `python312.lib` | 与仓库内 `Python-3.12.10-embed-amd64.zip` 配套;代码里写死 `BaseConfig/Python-3.12.10-embed-amd64` | +| vcpkg | 22 个 port,自定义 triplet `gpp-x64-windows-release`,2 个 overlay port(mecab、proxy) | `builtin-baseline` 写法 | +| Qt 用量 | 45 个含 `Q_OBJECT` 的头文件(全在 GPPGUI)、1 个 `.qrc`、4 个 `.ts`、0 个 `.ui` | 模块:Core、Gui、Widgets、Network;无附加模块 | + +新开发者的准备工作(`how-to-build.md`,111 行): + +- 手动安装 8 样东西:git、xlings、mcpp、CMake、Python、VS Build Tools、vcpkg(克隆 + bootstrap + 加 PATH)、Qt(在线 + 安装器,**要注册 Qt 账号**)。 +- 手动改 2 个文件里的本机路径:ElaWidgetTools 的 `build.py`、`qt-root.txt`。 +- 按顺序手动执行 8 条命令:`build.py`、`vcpkg install --triplet …`、`mcpp build` ×2、解压 Python embed 包、 + `Release.py`、`windeployqt` ×2。 + +CI(验证 workflow):Qt 靠一段 25 行的内联 Python 从 Qt 仓库下载;vcpkg 靠克隆 + bootstrap + install 加 4 个缓存步骤; +【已核实】vcpkg 冷安装 51.6 分钟。 + +### 7.2 映射:每一块由什么替代 + +| 现状 | 替代 | 依赖哪个交付物 / 前提 | +|---|---|---| +| `qt_config.hpp` + `qt-root.txt` | `rules-qt-xim`:根目录来自 `xpkg_dir` | `xim:qt`、`rules-qt` | +| `vcpkg_link.hpp` + 4 处写死的 include 路径 | `deps-vcpkg`:core 的 `libraries` 列 20 个库,Updater 列 2 个 | `deps-vcpkg`;Updater 各自一条安装边(§3.3(5)) | +| `mcpp_translations.hpp` | `rules-qt` 的 `translations`,`update_sources = true`、`tr_function_alias = {"translate+=gppTr"}` | 保持现有"构建时更新 `.ts`"的行为 | +| `mcpp_actions.hpp` 的 moc/rcc(59 行)与 Qt 检查 | `rules-qt` | 无 | +| core 的 `.qm` 复制到 CLI/GUI | `rules-qt` 库成员翻译 | §4.3【待验证】 | +| `windeployqt` ×2(手动) | `rules-qt` 的 `deploy_plugins` | pack 闭包【待验证】,否则插件逐个 deploy | +| `3rdParty/7z.dll` + Release.py 中的复制 | `[xlings.workspace] "xim:7zip"` + 一行 `mcpp::deploy` | §6.3 | +| Release.py 中 opencc 数据 | `use()` 返回的前缀 + `deploy(prefix / "share/opencc")` | §3.2 | +| Release.py 中 BaseConfig、SampleProject | 项目 `build.mcpp` 里的 deploy;GUICORE 要排除 4 项 | deploy 是否支持排除模式【待验证】,否则保留项目现有的 `copy` 助手 | +| ElaWidgetTools `build.py` + 写死的 Qt 路径 | `deps-cmake`,`QT_SDK_DIR` 取 `rules::qt::root()`,布局用 `o.layout` | `deps-cmake`(Phase 3) | +| vcpkg 克隆 + bootstrap | `xim:vcpkg` + 托管克隆(§3.6) | 无 | +| VS Build Tools | mcpp 侧 `sysroot = "xim:msvc@…"`;vcpkg 侧**只有** `toolchain::msvc_xim` 能免装 VS | §3.3(4)【待验证】chainload 细节 | +| CMake、Python(开发者手装) | CMake 由插件声明;Python 只剩 embed 包解压(可改成一条用 `xim:7zip` 的 action) | 无 | +| **不替代**:`runtime-stage` + 发布布局代码 | 保留。`Release/GPPCLI`、`GPPGUI`、`GUICORE`、私有镜像目录是这个项目的发布约定,不是通用能力 | 只有改为从 `mcpp pack` 产物派生布局,或引擎提供 §8 需求 2,`runtime-stage` 才能删 | +| **不替代**:pybind11 fork 附带的 Python | 保留。换成 `xim:python` 会让头文件(3.12.13)与运行时 embed 包(3.12.10)的补丁版本不一致,而版本号写死在代码里 | 若要换,先把 embed 包也改由 xim 提供,属项目自己的决定 | + +### 7.3 迁移成本(插件发布之后,GalTranslPP 一侧,一人) + +| 阶段 | 内容 | 人日 | 前提 | +|---|---|---|---| +| A | 5 个 `mcpp.toml` 去掉写死路径、加插件;4 个 `build.mcpp` 改写;删 `qt_config`/`vcpkg_link`/`mcpp_translations`、moc/rcc 部分和 `qt-root.txt`;CI 去掉 Qt 下载与 vcpkg bootstrap;新旧 `Release/` 目录逐文件对比 | 1.5–2 | Phase 1、2 | +| B | Release.py、`7z.dll`、`windeployqt`、Python embed 包解压并入构建(deploy 与一条用 `xim:7zip` 的 action);更新 `how-to-build.md` | 1–1.5 | 同上 | +| C | ElaWidgetTools 改用 `deps-cmake` | 1 | Phase 3 | +| D(可选) | 发布改为从 `mcpp pack` 派生,删 `runtime-stage`;vcpkg 改 `toolchain::msvc_xim` 实现免装 VS | 1–2 | §8 需求 2 或 pack 方案;§3.3(4) 验证通过 | +| **合计** | A–C 必做 | **3.5–4.5** | D 另计 1–2 | + +对比:通用插件本身 27–37 人日(§9),GalTranslPP 只占其中 3.5–4.5。第二个及以后的项目不再分摊插件开发,只付 +自己的 A–C。 + +### 7.4 收益(A–C 完成后) + +| 指标 | 现在 | 迁移后 | +|---|---|---| +| 手动安装的工具 | 8 样(含需注册账号的 Qt 安装器) | 2 样(git、xlings)+ VS Build Tools;做完 D 后为 2 样 | +| 提交进仓库的本机路径 | 2 处(`qt-root.txt`、ElaWidgetTools `build.py`) | 0 | +| 手动执行的命令 | 8 条,有顺序要求 | `mcpp build` ×2(或一次工作区构建) | +| 项目自写的构建辅助代码 | 662 行(383 + 232 + 47) | 约 410 行:删 207 行(Qt/vcpkg/翻译/moc/rcc)和 Release.py 47 行,发布布局 ~175 行与 `runtime-stage` 232 行仍在;做完 D 后约 130 行 | +| 提交进仓库的二进制 | `7z.dll` 1.9 MB | 0 | +| IDE(mcppls) | 缺 Qt 或 vcpkg 时 emit 失败,退回兜底模型(issue 23 的起因之一) | emit 总是成功(原则 4),首次构建前 include 路径已正确 | +| CI 冷构建 | vcpkg 51.6 分钟 | **不变**:port 还是那 22 个、编译器还是 cl;省的是 bootstrap 与脚本维护,不是编译时间 | +| CI 热构建 | 两级缓存(`vcpkg_installed` + 二进制缓存)靠 workflow 手写 | 一级:缓存 vcpkg 默认的 `archives/` 即可 | + +### 7.5 这个案例暴露出的设计缺口(已并入本版) + +1. Updater 不依赖 core 却要 vcpkg 库 → §3.3(5) 改为每个成员各自声明安装边。 +2. 自定义 triplet + chainload → §3.3(4) 改为 include 项目 triplet 而非替换。 +3. "免装 VS"在 vcpkg 侧需要 chainload → §3.3(4) 新增 `toolchain::msvc_xim`。 +4. 数据文件在 vcpkg 前缀里(opencc)→ §3.2 `use()` 返回前缀。 +5. 现行项目依赖"构建时 lupdate" → §4.3 该选项的行为照搬现行实现(blocking check + stamp)。 +6. CMake 子项目有非标准安装布局、需要 Qt 私有头 → §5 `o.layout`、§4.3 `private_modules`。 +7. 发布布局与 embed Python 是项目约定 → §7.2 明确不替代,第 2 版"可删除约 600 行"的说法过高。 +8. baseline 落在两个 registry 发布标签之间(2026-08-08,晚于标签 129 个提交)→ §3.6 按提交而非标签选检出。 + +## 8. 引擎侧需求(提给 mcpp) + +| # | 需求 | 为什么 | 没有它时的退路 | +|---|---|---|---| +| 1 | 构建程序可感知"规划模式"(或 emit 时构建程序失败只降级) | 原则 3/4 目前靠插件自律;任何项目的构建程序出错都会让 IDE 退回兜底模型 | 插件自律:缺失只 warning | +| 2 | 构建程序声明运行时搜索目录(run 的 PATH、pack 的 PE 闭包),且项目能在 build 时取得闭包结果 | vcpkg/Qt 的 DLL 在各自 `bin/`;GalTranslPP 的 `runtime-stage` 就是在自己补这一块 | 逐个 `mcpp::deploy`;项目保留自己的闭包工具 | +| 3 | blocking action 对依赖方也生效,或工作区级 setup action | 多成员工作区共用一次安装 | 每个成员各一条安装边(§3.3(5)) | +| 4 | `mcpp::action` 设置环境变量 | 外部工具普遍靠环境变量配置 | 插件自带启动器(本设计采用) | + +另:`[workspace.profile.*]` 继承与本插件无关,但同一消费者会遇到(【已核实】`docs/07` §4.1、specs §9 只允许三类继承; +GalTranslPP 5 个成员重复写了同样的 `[profile.release]`/`[profile.fast-release]`)。 + +## 9. 计划与工作量(一人估算,含 fixtures 与 CI,Windows 优先) + +| 阶段 | 内容 | 人日 | +|---|---|---| +| Phase 0 验证 | 本文全部【待验证】项(汇总见 §11) | 2.5–3.5 | +| Phase 1 | `xim:vcpkg`、`xim:qt`(Windows)、`deps-vcpkg` + `mcpp-vcpkg`(registry 选择、按工具检出、锁、目录隔离、三种工具链)+ fixtures | 9–12 | +| Phase 2 | `rules-qt` + fixtures;`xim:qt-addons`;`xim:qt*` Linux/macOS | 8–10 | +| Phase 3 | `deps-cmake` + `mcpp-cmake` + fixtures | 3.5–5.5 | +| 消费者验证 | GalTranslPP 迁移 A–C(§7.3,fork 分支 + 其 CI) | 3.5–4.5 | +| **合计** | | **27–37**(第 2 版 24–33;增加的部分见 §11) | + +**验证场已就绪**:Sunrisepeak/GalTranslPP PR #1 的 workflow 已在 windows-2025 上搭好 xlings、mcpp 2026.9.25.1、 +LLVM 22.1.8、Qt 6.11.1(直接读 Qt 仓库)、vcpkg(`gpp-x64-windows-release`,22 个 port);【已核实】vcpkg 冷安装 +51.6 分钟,结果已缓存。 + +## 10. 仍开放的问题与后续 + +1. ~~D6~~、~~D 阶段~~、~~免装 VS~~:第 3 轮已定(D6′、D8、D9)。 +2. **后续**:Qt 5 支持(moc/uic/rcc 参数差异小,包名与部署规则不同);`deps-conan`、`deps-prefix`;`xim:qt*` 其余平台 + 与版本;工作区级 profile 继承(§8 另注)。 + +已关闭:registry 共享克隆的位置(D7);`xim:qt-` 命名(随 D6 消失)。 + +## 11. 自我 review 记录 + +### 第 3 版相对第 2 版 + +改动(按影响排序): + +1. **托管克隆的检出版本**(§3.6):第 2 版让克隆跟随上游最新、再比较工具版本。上游 registry 一旦要求比 `xim:vcpkg` + 更新的工具,**所有项目会同时失败**,直到 xim 跟进。改为检出"主干上与已装工具匹配的最后一个提交"。本版起草时 + 曾写成"与工具匹配的最新发布标签",用 GalTranslPP 的 baseline 实测(比最新标签晚 129 个提交、工具要求相同)后改掉。 +2. **用户已有的 `VCPKG_ROOT` 改为只读**(§3.6、原则 5):第 2 版会对它 `git fetch`,等于改写用户的仓库。 +3. **按项目隔离 buildtrees/packages**(§3.3(1)):第 2 版没提,共享克隆时两个项目同时编同一个 port 会互相覆盖。 +4. **二进制缓存改用 vcpkg 默认位置**(§3.3(1)):第 2 版放在 mcpp 缓存目录,与 D7 的"复用用户已有的 vcpkg"矛盾。 + 另外第 2 版"放默认数据目录就能复用用户已有 vcpkg"的理由不成立:复用靠的是识别 `VCPKG_ROOT`,不是目录位置; + 目录位置带来的实际好处是与默认二进制缓存同级。 +5. **"免装 VS"的真实条件**(§3.3(4)):第 2 版把"工具链一致"当作纯可选项,并在 §6.1 暗示 `xim:msvc` 就能免装 VS。 + 已核实 vcpkg 默认路线必须找到 VS 实例,只有 chainload 能绕开;新增 `toolchain::msvc_xim`。 +6. **工作区默认做法**(§3.3(5)):由"共享 stamp 技巧"改为"每个成员各一条安装边",更简单,且 GalTranslPP 的 + Updater 正好需要。 +7. **§7 重写**:第 2 版的 GalTranslPP 样例写了 `xim:python = 3.12.13`(与 embed 包 3.12.10 不一致)、称可删除约 600 行 + (`runtime-stage` 和发布布局其实删不掉)、完全没提 ElaWidgetTools(`deps-cmake` 对这个项目不是可有可无)。 +8. 附加模块合为一个包(D6),§0、§4.2、§4.3、§6.1、§6.2 同步;新增 `rules-qt-xim-addons`。 +9. 小项:自定义 triplet 包装;`use()` 返回前缀;`private_modules`;库成员翻译;rcc 依赖;fixtures 增加 4 个。 + +工作量 +3~4 人日:Phase 0 多 4 个验证项;Phase 1 多按工具检出、目录隔离与两种 chainload;Phase 3 多布局选项; +消费者验证按 §7.3 的逐阶段估算上调。 + +残余风险(按影响排序): + +1. **PE 闭包是否搜索插件声明的目录**(§3.3(3)、§4.3):影响两个插件的部署路径;退路是逐个 `deploy`,功能不受影响。 +2. **chainload 在 Windows 上传递 MSVC 环境**(§3.3(4)):影响"免装 VS"。不成立时仍可用 `vcpkg_default`,只是要装 VS。 +3. **vcpkg manifest 模式对安装目录的锁**(§3.3(5)):影响多成员并发;退路是共享 stamp。 +4. **依赖方 deploy 是否进入可执行文件布局**(§4.3):影响库成员翻译;退路是可执行成员直接列 `.ts`。 +5. **极旧 baseline 与当前检出的脚本不兼容**(§3.6):报错写明 baseline 与脚本版本,由项目升级 baseline 或固定旧的 `xim:vcpkg`。 +6. **lupdate 默认关闭**会让已习惯"构建时自动更新 `.ts`"的项目多一行配置;有意的取舍(不写源码树)。 + +### 第 2 版相对第 1 版(保留) + +链接一律用完整路径(首次构建规划期文件必然不存在,条件判断会让两次构建的链接行不同);补 Linux 主机依赖与 +`xim:git`;新增 registry 分离;`.ts` 不会误判;零配置行为;`/Zc:__cplusplus`;软件 GL 可选部署;修正 `when`; +按 D3、D5 重写 §6.2、§6.3。 + +### 【待验证】汇总(Phase 0) + +PE 闭包目录;vcpkg manifest 模式对安装目录的锁;chainload 下 `INCLUDE`/`LIB` 的传递(`VCPKG_ENV_PASSTHROUGH` 或 +工具链文件直写);`VCPKG_FORCE_SYSTEM_BINARIES` 在 Windows 上的效果;Linux 主机依赖在 xim 中的覆盖;chainload clang +的 port 兼容性;registries 缓存各平台默认路径;共享克隆读旧 baseline;依赖方 deploy 进入可执行文件布局;deploy 的排除 +模式;`xim:python` 的开发文件;moc/rcc 的 depfile 版本下限。 diff --git a/.agents/docs/2026-09-26-deps-vcpkg-rules-qt-plan.md b/.agents/docs/2026-09-26-deps-vcpkg-rules-qt-plan.md new file mode 100644 index 0000000..2ebbac3 --- /dev/null +++ b/.agents/docs/2026-09-26-deps-vcpkg-rules-qt-plan.md @@ -0,0 +1,63 @@ +# 实施计划:`deps-vcpkg`、`rules-qt`、`deps-cmake` 的跨仓库交付 + +状态:执行中 · 2026-09-26 · 设计见 `2026-09-26-deps-vcpkg-rules-qt-design.md`(第 4 版)。 + +## 1. 第 3 轮决定(设计第 4 版据此修改) + +| # | 决定 | 对设计的影响 | +|---|---|---| +| D6 | 附加模块合为一个包 | 确认 `xim:qt-addons`;按 xim 的载荷私有规则,它有**自己的前缀**,不写入 `xim:qt` 的目录 | +| D8 | 先做 mcpp / xlings 生态的通用能力;GalTranslPP 的 D 阶段只在 fork 上做初步验证 | 引擎需求 2 在 mcpp 中实现:构建程序声明运行时库目录 | +| D9 | 免装 VS 不是目标 | 删除 `toolchain::msvc_xim`;`xim:msvc` 由 mcpp 自动匹配,插件不声明 | + +另有一项由实测带来的简化:vcpkg-tool 每个发布附带 `vcpkg-standalone-bundle.tar.gz`(3.4 MB,脚本与该工具 +版本严格对应,`vcpkg-bundle.json` 含 `"usegitregistry": true`)。以它为 `VCPKG_ROOT` 时,`builtin-baseline` +清单经 git registry 解析,registry 落在 vcpkg 的每用户 registries 缓存。已在本机实测:对 GalTranslPP 的 +baseline `ea1a7396` 执行 `vcpkg install --dry-run` 解析出 `fmt 12.2.0`,缓存 137 MB。因此设计第 3 版 §3.6 的托管克隆、 +按工具检出与文件锁全部取消。 + +## 2. 仓库与交付物 + +| 仓库 | 单个 PR 的内容 | 发布 | +|---|---|---| +| mcpp-community/mcpp | `mcpp::runtime_library_dir()`:构建程序形式的 `[runtime] library_dirs`(`mcpp run` 的库搜索路径、`mcpp pack` 的闭包搜索目录);docs/30 中英文;e2e | 引擎发布 `2026.9.27.1`(release.yml 自动镜像并开 xim-pkgindex 索引 PR) | +| openxlings/xim-pkgindex | `xim:vcpkg` 2026.7.27(工具 + standalone bundle)、`xim:qt` 6.11.1、`xim:qt-addons` 6.11.1;`xim:7zip` 说明 `7z.dll` 位置;测试 | 合入即发布索引;`xlings-res/vcpkg` 资源用 `gh` 与 `gtc` 双端上传 | +| mcpp-community/mcpp-plugins | `0.13.0`:`deps-vcpkg` + `mcpp-vcpkg`、`rules-qt` + `rules-qt-xim` + `rules-qt-xim-addons`、`deps-cmake` + `mcpp-cmake`;fixtures;CI 引擎版本;README | tag `v0.13.0`,GitHub release,`gtc` 上传 `mcpp-res/mcpp-plugins` | +| mcpp-community/mcpp-index | 登记 `mcpp:plugins 0.13.0` | 合入即发布 | +| Sunrisepeak/GalTranslPP(fork) | 临时 PR 2:迁移 A–C,D 阶段初步验证 | 不合入 | + +## 3. 依赖关系 + +``` +T1 引擎 runtime_library_dir ──► T1r 引擎发布 2026.9.27.1 ──┐ +T2 xim 包(vcpkg/qt/qt-addons)──► T2r 索引合入 ────────────┼──► T3c 插件 CI 全绿 ──► T3r 插件发布 0.13.0 ──► T4 mcpp-index ──► T5 GalTranslPP PR 2 +T3 插件实现与本地测试(用 T1 的本地构建)─────────────────────┘ +``` + +- T1、T2、T3 并行:T3 在本机用 T1 的源码构建验证 Linux 路径;Windows 与 macOS 路径只能由 CI 验证。 +- T3 的 CI 依赖 T1r(`MCPP_VERSION` 指向新引擎)与 T2r(索引里有 `xim:vcpkg`、`xim:qt`)。 +- T5 用索引中的 `mcpp:plugins 0.13.0`,不用路径依赖,以验证用户实际得到的东西。 + +## 4. 多角度检查项(实施与 review 共用) + +| 角度 | 检查项 | +|---|---| +| 架构 | `deps-*` 家族只描述、重活在 action;插件拥有它驱动的 xim 包;运行时目录经引擎通道而非逐文件复制 | +| 稳定性 | emit 在未安装依赖时返回 0;安装边只在输入变化时重跑;各项目的 buildtrees/packages 隔离;vcpkg 自身对安装目录加锁 | +| 优雅简洁 | 零配置可用;registry 管理交给 vcpkg;一个选项结构 + 一个入口函数 | +| 用户体验 | 所有拒绝都给出下一步;缺依赖时 warning 写明 `mcpp build` 会安装它 | +| 兼容性 | 旧引擎给出明确的版本下界提示;已有 `vcpkg_installed/` 布局不变;自定义 triplet 与 overlay 照常 | +| 跨平台 | Windows x86_64 / Linux x86_64 / macOS:`deps-vcpkg` 三平台 fixture;`rules-qt` Windows + Linux(macOS 视 Qt 框架布局而定) | +| 一致性 | 选项、警告格式、`mcpp::fact` 版本记录与既有成员一致;README 成员表同格式 | +| 无感升级 | 插件次版本号前移;不改变已有成员的行为;引擎新增指令,旧程序不受影响 | +| 测试覆盖 | 每个功能一个正向 fixture + 一个拒绝/边界断言;`all-rules-compile` 覆盖新模块在三平台编译 | + +## 5. 进度 + +| 任务 | 状态 | +|---|---| +| T0 设计第 4 版、本计划 | 进行中 | +| T1 引擎 | 未开始 | +| T2 xim 包 | Qt 归档 sha256 计算中 | +| T3 插件 | 未开始 | +| T4 / T5 | 未开始 | diff --git a/README.md b/README.md index d71756f..bd5465b 100644 --- a/README.md +++ b/README.md @@ -37,15 +37,17 @@ int main() { | rules | `mcpp.rules.` | how one kind of translation unit is compiled by a compiler mcpp does not drive: the spelling of its flags, the probe of its toolkit, the actions it submits | | tools | `mcpp.tools.` | a build-time utility independent of any compiler; see `tools/README.md` | | dist | `mcpp.dist.` | what comes out of the link, and in what form a user installs it: an `.msi`, an AppImage, a signed `.app` | +| deps | `mcpp.deps.` | where a library comes from: a prefix a program mcpp does not drive installs (vcpkg, CMake), mapped into the build | | identity | `mcpp.plugins` | the lib root, compiled before every member; it states the collection's version | -The three families answer three different questions, and the prefix is which +The four families answer four different questions, and the prefix is which one a member answers: ``` rules-* how is this translation unit compiled tools-* what does the build program need to do itself dist-* what comes out of the link, and in what form a user installs it +deps-* where does a library come from ``` A `dist-*` member fits neither of the first two definitions: it does not @@ -55,6 +57,13 @@ with `mcpp pack --format ` (mcpp 2026.9.11.1+). The prefix matters because the taxonomy is load-bearing -- a consumer reading `rules-wix` would expect a compiler it does not drive and a translation unit, and there is neither. +A `deps-*` member compiles none of the project's translation units and does not +do its work while the build program runs: the installation is a `blocking` +check action, which the package's compile edges wait for, and whose command is +`mcpp-deps`, a program built from this package. The build program only states +the prefix -- include directory, libraries by full path, runtime library +directory -- which it can do before anything is installed. + The `mcpp.` prefix is reserved for this package: mcpp warns when a module under it is declared by a package outside the `mcpp` namespace. `mcpp.build.*` is the engine's own module family and is not used here. @@ -67,6 +76,7 @@ engine's own module family and is not used here. | `rules-cuda` | `mcpp.rules.cuda` | 2026.9.6.6 | `[build] accel = "cuda…"`, a constrained glob for `*.cu`; the clang route with an LLVM toolchain, the nvcc route with a GCC one | | `rules-hip` | `mcpp.rules.hip` | 2026.9.6.6 | `[build] accel = "hip, cuda12.9+{sm_89}"`, a constrained glob for `*.hip`. On the NVIDIA platform HIP is a header layer over the CUDA runtime, so the compiler is the project's own clang and there is no ROCm on the machine | | `rules-metal` | `mcpp.rules.metal` | 2026.9.8.1 | the Metal toolchain of the macOS host's Xcode, located rather than installed: Xcode is not redistributable, so no payload is declared. `.metal` sources the project names on a macOS or iOS row become one `xcrun --sdk metal` action per shader (`-MMD`, so an edited `#include` recompiles the shaders that include it) and one `xcrun --sdk metallib` action per library, placed beside the program with `mcpp::deploy` under `metallib/`, which `dist-apple` maps into the bundle's resources. `compile(shaders)` compiles one source several times with definitions of its own, one library per `shader`; `options::library` links every shader into one library (`default` is the one `newDefaultLibrary` finds). Before planning anything the rule asks `xcrun --sdk --show-sdk-path` and `--find metal` / `--find metallib`, and refuses naming the command that answered nothing, because a missing SDK and a missing compiler have different remedies (Xcode 26 installs the Metal toolchain as a separate component). A shader on any other row is refused naming the row. CI compiles the fixture on `macos-15` and checks each library's magic, and that a header edit recompiles only the shaders that include it | +| `rules-qt` | `mcpp.rules.qt` | 2026.9.27.1 | a Qt 6 SDK: `rules-qt-xim` declares `xim:qt` 6.11.1 on the target axis, `rules-qt-xim-addons` adds `xim:qt-addons` (the additional libraries) as a second prefix, and `options::root` names an SDK from elsewhere. From 0.13.0. `moc` for every header under the package root that declares `Q_OBJECT`, `Q_GADGET` or `Q_NAMESPACE` and for a source that includes its own `.moc`; `uic` for `.ui`, `rcc` for `.qrc`, `lrelease` for `.ts` (named in `[build] sources` or in the options), each a `role = "source"` action with declared inputs. The modules are linked by full path, the SDK's library directory is a runtime library directory, and the plugin directories `deploy_plugins` names are placed beside the program. See [`deps-vcpkg`, `deps-cmake` and `rules-qt`](#deps-vcpkg-deps-cmake-and-rules-qt) | | `rules-slang` | `mcpp.rules.slang` | 2026.9.7.1 | `[build] accel = "vulkan1.2"`, a constrained glob for `*.slang`. Slang is a different language from GLSL rather than a second driver for it -- its own module system, generics, and targets beyond SPIR-V -- so it is a rule of its own. `.slang` is **not** in the engine's device-source table: this feature declares `device_extensions = [".slang"]` and `rule_module = "mcpp.rules.slang"`, and the engine routes it from there. That is the criterion for the whole arrangement -- a new device language costs no engine release. Since 0.7.0 it has the same `options::storage` axis as `rules-spirv` (header / object / sidecar), `options::extra_args` for the arguments the rule has no field for, and `options::per_file` for what one shader gets that the others do not -- a project with a `-fvk-use-gl-layout` and one shader needing `-emit-spirv-via-glsl` writes both without leaving one `compile()` call | | `rules-spirv` | `mcpp.rules.spirv` | 2026.9.6.6 | `[build] accel = "vulkan1.2"`, a constrained glob for the shader stages; compiles each shader through a `role = "source"` action and states which of the two compilers produced it | | `rules-swift` | `mcpp.rules.swift` | 2026.9.8.1 | the Swift compiler of the macOS host's Xcode or Command Line Tools, located rather than installed, as `rules-metal` locates its toolchain. From 0.12.0. The `.swift` sources a project names on a macOS or iOS row compile as one module named after the package: one whole-module `xcrun --sdk swiftc -wmo -emit-object -target ` action whose role is `object`, so the object joins every image of the package, and one `swiftc -typecheck -emit-objc-header-path` action whose role is `source`, whose directory `mcpp::include_dir` adds, so the package's C and C++ sources include `-Swift.h`. `options::bridging_header` names a C header Swift sees without an import. The link receives the toolchain's `usr/lib/swift/` and the SDK's `usr/lib/swift` as search directories and `/usr/lib/swift` as a run path through `mcpp::link_flag`. Before planning anything the rule asks `xcrun --sdk --show-sdk-path` and `--find swiftc`, and refuses naming the command that answered nothing; a Swift source on any other row is refused naming the row. Not supported: a Swift `import` of another package's module, and another package's C++ including this package's generated header, which both need an engine channel that publishes a package's interface directory to its dependents; and SwiftPM dependencies. CI builds `tests/swift-consumer` on `macos-15` -- a C++ program calling a `@_cdecl` Swift function that calls back into C -- and runs it | @@ -78,6 +88,8 @@ engine's own module family and is not used here. | `dist-apple` | `mcpp.dist.apple` | 2026.9.14.2 (0.10.0); 2026.9.11.2 (macOS) and 2026.9.12.3 (iOS) before it | the base macOS install (`ditto`, `codesign`, `hdiutil`), and `xim:macapp-run` for `mcpp run` on macOS, which this feature declares with `when = "run"`. macOS: `Contents/`-shaped, as always. iOS (`aarch64-ios-sim`, `aarch64-ios`): a flat bundle at the same call site -- no separate feature, no separate module -- with `MinimumOSVersion` from `mcpp::min_platform_version()` (#622 A11), `CFBundleSupportedPlatforms` read from `env == "sim"`, `UIDeviceFamily`, `LSRequiresIPhoneOS`, and a directory of flat PNGs listed under `CFBundleIcons` in place of macOS's single `.icns` file. Signing is skipped on the simulator row (`options::identity` is ignored, with a `mcpp::warning` naming why), and the device row signs only with an identity. The iOS row is measured end to end on `macos-15`: a real `mcpp build`, `mcpp pack --format app` and `mcpp run` against `aarch64-ios-sim`, through `xim:apple-simulator-tools`' `simctl-run`. **The macOS floor is one release higher than its siblings** and the reason is not this member: under 2026.9.11.1 `mcpp pack` staged before dispatching and let a staging failure fail the command, so on a Mach-O program -- which the built-in closure walk refuses, because it uses `LD_TRACE_LOADED_OBJECTS` and dyld answers that by running the program -- every dispatched format was unreachable, including one that reads no staged tree. 2026.9.11.2 makes staging a service to the provider. From 0.9.2 the staged tree's deployed files (`bin//...`, which the engine stages for a Mach-O program before the closure walk since the release for mcpp#630) land at the bundle's resource destination -- `Contents/Resources//...` on macOS, the bundle root on iOS -- and the launcher alone goes to the executable directory, so `CFBundleExecutable` names a file that is where it says. The iOS fixture declares `llvm.libcxx` and `llvm.compiler-rt-builtins` under `cfg(os = "ios")`, which is what an application that imports `std` on those rows declares. From 0.10.0, with mcpp 2026.9.14.2: the dylibs the engine stages beside a Mach-O program, which the stage manifest's `needs` lines name, go to `Contents/Frameworks/` (`Frameworks/` on iOS) and not to the resources; the program is linked with the rpath that finds them there (`@executable_path/../Frameworks`, `@executable_path/Frameworks` on iOS) through `mcpp::link_flag`, so no file is edited after the link; a macOS bundle without `options::identity` is signed ad hoc, frameworks first and the bundle second, which `codesign --verify --deep --strict` requires of a bundle that carries a framework; an incomplete closure is a `mcpp::warning` naming the unresolved libraries; every refusal is a `mcpp::warning` as well, because the engine discards a build program's output when it exits 0. On macOS the member supplies the runner named `app` (`macapp-run`), so `mcpp run --format app` runs the bundle's executable in the foreground and returns its status with no runner in the manifest; a manifest runner of that name wins. `--format dmg` stages the bundle beside an `Applications` link and writes a UDZO image with `hdiutil create` (`options::volume_name`, `options::dmg`); it is refused on iOS. An engine below 2026.9.14.2 stages no `needs` lines, so the bundle carries no framework, anchors the rpath to the package directory, and hands the bundle directory to the kernel under `mcpp run --format app` unless `--runner app` is typed. CI measures the bundle on `macos-15`: the load command, the signature, the program with and without its framework (exit 7, then "Library not loaded"), `mcpp run --format app` with and without `--runner app`, and `hdiutil verify` and an attached image. From 0.11.0: a project's own Info.plist entries (`options::info_plist`), an iOS device bundle's provisioning profile (`options::provisioning_profile`), and `devicectl-run` (`xim:apple-device-tools`) as the device row's runner named `app` -- see [`dist-apple`: a project's Info.plist, and an iOS device](#dist-apple-a-projects-infoplist-and-an-ios-device). From 0.12.0 `options::omit_keys` leaves out a key the member only defaults -- see [`dist-apple`: a project's Info.plist, and an iOS device](#dist-apple-a-projects-infoplist-and-an-ios-device). From 0.12.0 a package in the resolved graph contributes Info.plist entries through `[package.metadata.dist-apple]`, applied before the application's own | | `dist-web` | `mcpp.dist.web` | 2026.9.13.1, the release that carries `${mcpp.self}` and `mcpp stage`'s argument shape as an engine contract (`stage --verify content --output `) -- what lets this member's copy run on every host mcpp does, Windows included, in place of the `cp` this member used through 0.8.0 | nothing beyond mcpp: `wasm32-emscripten` only. Copies `${mcpp.stage_dir}/bin/` -- the `.js` launcher, the implicit `.wasm`, the `.data` when present, and every `mcpp::deploy`'d file, all of which #622 A5 and A4 already stage there -- to `/web/`, dropping the `bin/` prefix a browser has no use for, and writes an `index.html` rendered from a project template or a built-in default that loads the script with a plain `