From f10b2ebc02dad6fb51c44899ef774be2a1c574f1 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Fri, 17 Jul 2026 18:08:10 +0800 Subject: [PATCH] =?UTF-8?q?feat(manifest):=20=E5=A3=B0=E6=98=8E=E5=BC=8F?= =?UTF-8?q?=E6=B8=85=E5=8D=95=E8=83=BD=E5=8A=9B=20=E2=80=94=20features.sou?= =?UTF-8?q?rces=20/=20generated=5Ffiles=20/=20cfg=20=E6=9D=A1=E4=BB=B6=20s?= =?UTF-8?q?ources=20/=20per-glob=20flags?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 设计: .agents/docs/2026-07-17-asm-sources-and-general-build-capabilities-design.md (PR-B) - G5: mcpp.toml [features] 表形式增 sources 键 → featureSources(与 xpkg 逐字对等);顺修 gate 语义真缺口:drop 只按字符串删 glob,默认 glob 仍罩住 被门控文件——未激活 feature 的 glob 现在变成 `!` 排除,gate 真正生效 (build 模式;test 模式保持 0.0.94 的解耦决策不变,e2e 100/79 回归全过) - G6: mcpp.toml [generated_files] 节 → generatedFiles(路径越根解析期报错); 根 manifest 补 materialize 调用(此前只对依赖做);自研 TOML 解析器补 """/''' 多行字符串(TOML 1.0:首换行修剪、行尾反斜杠、闭合粘连引号) - G1b: ConditionalConfig 增 sources,[target.'cfg(...)'.build] sources 按解析 后 target 求值 APPEND(含 modules.sources 镜像);cfg 合并提炼为 merge_conditional_build 并补依赖侧求值(此前 conditionalConfigs 对依赖 完全不求值,descriptor 写了也静默无效);xpkg 增 target_cfg 键 - G4: [build] flags = [{ glob, cflags/cxxflags/asmflags/defines }] 有序数组 (TOML 表是 sorted map 丢声明序,数组形式才能承载"顺序即语义");xpkg 增 对称 flags 键;scanner 命中附加(声明序 append,后者胜)、零命中 warning、 SourceUnit/CompileUnit 增 packageAsmflags 通道接 PR-A 的 unit_asmflags; 全序列折入 canonical_compile_flags 指纹 - G8b 已定位并修复:用户 cflags/cxxflags 里的相对 -I 按 ninja cwd(outputDir) 解析而非项目根(.cpp/.cppm 同病)——scanner 单点绝对化 + plan 入口单元同步 - 单测:toml/xpkg 对称性契约测试、多行字符串、per-glob 顺序语义、相对 -I; e2e 106(featureSources 四象限)/107(generated_files)/108(cfg sources, host-aware)/109(per-glob flags 顺序覆盖+零命中告警);全量 110 过, 3 失败均既有环境性(22/54/62);docs/05 中英同步(含 PR-A 汇编段补中文) --- docs/05-mcpp-toml.md | 52 ++++- docs/zh/05-mcpp-toml.md | 52 ++++- src/build/ninja_backend.cppm | 4 +- src/build/plan.cppm | 6 + src/build/prepare.cppm | 78 +++++++- src/libs/toml.cppm | 61 +++++- src/manifest/toml.cppm | 82 ++++++++ src/manifest/types.cppm | 19 ++ src/manifest/xpkg.cppm | 127 ++++++++++++ src/modgraph/graph.cppm | 1 + src/modgraph/scanner.cppm | 62 ++++++ tests/e2e/106_feature_gated_sources_toml.sh | 72 +++++++ tests/e2e/107_generated_files_toml.sh | 58 ++++++ tests/e2e/108_cfg_conditional_sources.sh | 57 ++++++ tests/e2e/109_per_glob_flags.sh | 60 ++++++ tests/unit/test_manifest.cpp | 202 ++++++++++++++++++++ tests/unit/test_modgraph.cpp | 64 +++++++ tests/unit/test_toml.cpp | 32 ++++ 18 files changed, 1074 insertions(+), 15 deletions(-) create mode 100755 tests/e2e/106_feature_gated_sources_toml.sh create mode 100755 tests/e2e/107_generated_files_toml.sh create mode 100755 tests/e2e/108_cfg_conditional_sources.sh create mode 100755 tests/e2e/109_per_glob_flags.sh diff --git a/docs/05-mcpp-toml.md b/docs/05-mcpp-toml.md index 67e5e84e..f339f682 100644 --- a/docs/05-mcpp-toml.md +++ b/docs/05-mcpp-toml.md @@ -188,6 +188,46 @@ sources = [ ] ``` +**Per-glob flags** (mcpp 0.0.95+): `[build] flags` is an ordered array of +inline tables attaching extra compile flags to exactly the sources a glob +matches — the tool for SIMD dispatch TUs and vendored-code warning isolation: + +```toml +[build] +flags = [ + { glob = "third_party/**", cflags = ["-w"], cxxflags = ["-w"] }, + { glob = "src/simd/**/*.avx2.cpp", cxxflags = ["-mavx2"], defines = ["HAVE_AVX2"] }, + { glob = "src/x86/**/*.asm", asmflags = ["-DPREFIX"] }, +] +``` + +Keys per entry: `glob` (package-root-relative, required) plus `cflags` / +`cxxflags` / `asmflags` / `defines` (no `ldflags` — linking has no per-TU +scope). Declaration order is application order: a later entry's flags land +later on the command line, so with GNU "last flag wins" a narrower glob +placed after a broader one overrides it. All matching entries apply. These +are private build flags — they never propagate to consumers. A glob that +matches no source file prints a warning (a typo'd glob must not silently do +nothing). + +**Generated files** (mcpp 0.0.95+): `[generated_files]` maps a relative path +to file contents (TOML multiline strings supported). Entries are written +into the project tree before source globs expand — the same mechanism index +descriptors use to synthesize module wrappers — and the content enters the +fingerprint, so editing it rebuilds: + +```toml +[generated_files] +"src/gen/wrap.cppm" = """ +module; +#include +export module wrap; +""" +``` + +Paths must stay inside the project root (`..` / absolute paths are parse +errors). + **Assembly sources** (mcpp 0.0.95+): `.S`/`.s` (GAS — preprocessed by the C driver, covers ARM and AT&T-syntax x86) and `.asm` (NASM — Intel-syntax x86) are first-class sources: default-globbed, fingerprinted, built incrementally @@ -319,7 +359,9 @@ there is no ambiguity. Use the bare form for a single OS/family; use `cfg(...)` when you need arch/env conditions or combinators. - **Keys**: `dependencies` / `dev-dependencies` / `build-dependencies`, and - `build` with `cflags` / `cxxflags` / `ldflags`. + `build` with `cflags` / `cxxflags` / `ldflags` / `sources` (mcpp 0.0.95+ — + conditional source globs, e.g. gating `src/x86/**/*.asm` behind + `cfg(arch = "x86_64")`; `!`-exclusion globs work here too). - **Evaluated against the resolved target** — the `--target` triple for a cross build, otherwise the host. So a native Linux build never even *downloads* a `[target.windows]` dependency. @@ -352,8 +394,12 @@ extra = [] #### Table form — a feature that contributes more than implied features A `[features]` entry may be written as a **table** instead of an array, letting the -feature carry package-owned preprocessor `defines` and/or capability `requires` / -`provides` (see §2.8.1) alongside its implied features: +feature carry package-owned preprocessor `defines`, feature-gated source globs +(`sources`, mcpp 0.0.95+ — the globs leave the default build and compile only when +the feature is active, exactly like an index descriptor's `features..sources`; +the highest-frequency shape for vendored libraries: *feature = a source set + a +define*), and/or capability `requires` / `provides` (see §2.8.1) alongside its +implied features: ```toml [features] diff --git a/docs/zh/05-mcpp-toml.md b/docs/zh/05-mcpp-toml.md index fd72613d..31fd131b 100644 --- a/docs/zh/05-mcpp-toml.md +++ b/docs/zh/05-mcpp-toml.md @@ -175,6 +175,49 @@ sources = [ ] ``` +**per-glob 旗标**(mcpp 0.0.95+):`[build] flags` 是**有序**的内联表数组,把额外 +编译旗标只附加到 glob 命中的源文件——SIMD 多档 dispatch TU 与三方代码告警隔离的 +正解: + +```toml +[build] +flags = [ + { glob = "third_party/**", cflags = ["-w"], cxxflags = ["-w"] }, + { glob = "src/simd/**/*.avx2.cpp", cxxflags = ["-mavx2"], defines = ["HAVE_AVX2"] }, + { glob = "src/x86/**/*.asm", asmflags = ["-DPREFIX"] }, +] +``` + +每条目键:`glob`(相对包根,必填)+ `cflags` / `cxxflags` / `asmflags` / +`defines`(没有 `ldflags`——链接没有 per-TU 作用域)。声明顺序即应用顺序:靠后 +条目的旗标排在命令行更后,配合 GNU "后旗标胜",窄 glob 放在宽 glob 之后即可覆盖。 +所有命中条目都生效;这些是私有构建旗标,不会传播给消费者。glob 零命中会打印 +warning(打错的 glob 不允许静默无效)。 + +**生成文件**(mcpp 0.0.95+):`[generated_files]` 把相对路径映射到文件内容(支持 +TOML 多行字符串)。条目在源 glob 展开之前写入工程树——与 index 描述符合成模块 +包装文件是同一机制——内容进指纹,改内容即重建: + +```toml +[generated_files] +"src/gen/wrap.cppm" = """ +module; +#include +export module wrap; +""" +``` + +路径必须留在工程根之内(`..` / 绝对路径是解析错误)。 + +**汇编源**(mcpp 0.0.95+):`.S`/`.s`(GAS——由 C 驱动器预处理,覆盖 ARM 与 +AT&T 语法 x86)和 `.asm`(NASM——Intel 语法 x86)是一等源文件:默认 glob 收录、 +进指纹、增量并行构建、像任何对象一样链接。NASM 的输出格式由目标三元组推导 +(`elf64`/`win64`/`macho64`/...——交叉构建零特判);`nasm` 仅在存在 `.asm` 单元时 +惰性解析:先 `PATH`,再 mcpp 沙箱,再 `xlings install nasm`;找不到 ≥2.16 的 +nasm 则**硬失败**(汇编绝不静默跳过)。限制:`.asm` 仅限 x86 目标(其他目标硬 +报错——用条件 sources 门控)、MSVC 工具链不支持 `.S`、`.asm` 即 NASM 语法 +(MASM 源请用 `!` 排除)。 + ### 2.4 `[lib]` — 库根模块约定 ```toml @@ -290,7 +333,9 @@ cxxflags = ["-march=x86-64-v2"] OS/家族用裸形式;需要 arch/env 条件或组合子时用 `cfg(...)`。 - **可放的键**:`dependencies` / `dev-dependencies` / `build-dependencies`,以及 - `build` 下的 `cflags` / `cxxflags` / `ldflags`。 + `build` 下的 `cflags` / `cxxflags` / `ldflags` / `sources`(mcpp 0.0.95+—— + 条件源 glob,如把 `src/x86/**/*.asm` 门控在 `cfg(arch = "x86_64")` 之后; + `!` 排除 glob 同样可用)。 - **按解析后的目标求值**——交叉构建时是 `--target` 三元组,否则是 host。所以原生 Linux 构建**根本不会下载** `[target.windows]` 的依赖。 - **优先级**:精确三元组表压过 `cfg`/别名表;多个命中的谓词表,其旗标会拼接。 @@ -317,7 +362,10 @@ extra = [] #### 表形式 —— 让 feature 贡献的不止是隐含 feature `[features]` 的条目除了写成数组,还可写成**表**,从而让该 feature 在隐含 feature -之外,携带包自有的预处理 `defines`,以及 capability 的 `requires` / `provides` +之外,携带包自有的预处理 `defines`、feature 门控的源 glob(`sources`,mcpp +0.0.95+——列出的 glob 离开默认构建,仅当 feature 激活时才编译,与 index 描述符的 +`features..sources` 完全对等;这正是 vendored 大库最高频的形态:*feature = +一组源文件 + 一个 define*),以及 capability 的 `requires` / `provides` (见 §2.8.1): ```toml diff --git a/src/build/ninja_backend.cppm b/src/build/ninja_backend.cppm index 94e20bd2..36573ed5 100644 --- a/src/build/ninja_backend.cppm +++ b/src/build/ninja_backend.cppm @@ -170,13 +170,15 @@ bool is_scan_exempt(const std::filesystem::path& src) { // Per-unit flags an assembler can take: the -D/-U/-I subset of the unit's C // flags (feature defines land there). NASM shares the GNU -D/-U/-I spelling // (and ≥2.14 inserts a missing -I path separator itself), so one filter -// serves both asm rules. +// serves both asm rules. Explicit per-glob asmflags (G4) append after the +// filtered subset — author-directed flags win. std::vector asm_unit_flags(const CompileUnit& cu) { std::vector out; for (auto& f : cu.packageCflags) { if (f.starts_with("-D") || f.starts_with("-U") || f.starts_with("-I")) out.push_back(f); } + out.insert(out.end(), cu.packageAsmflags.begin(), cu.packageAsmflags.end()); return out; } diff --git a/src/build/plan.cppm b/src/build/plan.cppm index 9f9a08c3..cef187cc 100644 --- a/src/build/plan.cppm +++ b/src/build/plan.cppm @@ -24,6 +24,7 @@ struct CompileUnit { std::vector localIncludeDirs; std::vector packageCflags; std::vector packageCxxflags; + std::vector packageAsmflags; // per-glob asmflags (G4) std::optional providesModule; // logical name, if .cppm export std::vector imports; // logical names imported // Unit came from a scan_overrides declaration — plan-vs-ddi @@ -445,6 +446,7 @@ BuildPlan make_plan(const mcpp::manifest::Manifest& manifest, cu.localIncludeDirs = u.localIncludeDirs; cu.packageCflags = u.packageCflags; cu.packageCxxflags = u.packageCxxflags; + cu.packageAsmflags = u.packageAsmflags; const auto fname = object_filename_for(u.path, objExt); if (basenameCount[fname] > 1) { // Use / as prefix to handle @@ -700,6 +702,10 @@ BuildPlan make_plan(const mcpp::manifest::Manifest& manifest, main_cu.packageCflags = manifest.buildConfig.cflags; main_cu.packageCxxflags = manifest.buildConfig.cxxflags; } + // Root-relative -I flags → absolute (G8b), mirroring the scanner's + // treatment of every scanned unit. + mcpp::modgraph::absolutize_include_flags(projectRoot, main_cu.packageCflags); + mcpp::modgraph::absolutize_include_flags(projectRoot, main_cu.packageCxxflags); // We didn't scan main.cpp earlier (it's not in scanner output unless globbed in). // Best-effort: scan its imports here. diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index 50ec3447..cd888fd2 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -225,6 +225,15 @@ std::string canonical_compile_flags(const mcpp::manifest::Manifest& m) { s += " ldflag:"; s += flag; } + // Per-glob flags (G4): full ordered serialization — glob + every list — + // so editing any entry (or reordering) re-fingerprints the output dir. + for (auto const& gf : m.buildConfig.globFlags) { + s += " globflags:"; s += gf.glob; + for (auto const& f : gf.cflags) { s += " gc:"; s += f; } + for (auto const& f : gf.cxxflags) { s += " gxx:"; s += f; } + for (auto const& f : gf.asmflags) { s += " gas:"; s += f; } + for (auto const& f : gf.defines) { s += " gd:"; s += f; } + } return s; } @@ -327,6 +336,31 @@ materialize_generated_files(const std::filesystem::path& root, return {}; } +// L1 cfg merge for ONE manifest (root or dependency): append the matching +// conditional cflags/cxxflags/ldflags and sources (G1b) to its buildConfig. +// Sources also update the legacy modules.sources mirror — the scanner walks +// that. Conditional dependency maps are root-only and handled at the root +// call site; a dependency's conditional configs otherwise evaluate the same +// way (descriptor `target_cfg` must not be silently inert). +void merge_conditional_build(mcpp::manifest::Manifest& m, + const cfgpred::Ctx& ctx, + std::string_view targetTriple) +{ + for (auto const& cc : m.conditionalConfigs) { + if (!cfgpred::matches(cc.predicate, ctx, targetTriple)) continue; + m.buildConfig.cflags.insert(m.buildConfig.cflags.end(), + cc.cflags.begin(), cc.cflags.end()); + m.buildConfig.cxxflags.insert(m.buildConfig.cxxflags.end(), + cc.cxxflags.begin(), cc.cxxflags.end()); + m.buildConfig.ldflags.insert(m.buildConfig.ldflags.end(), + cc.ldflags.begin(), cc.ldflags.end()); + for (auto const& s : cc.sources) { + m.buildConfig.sources.push_back(s); + m.modules.sources.push_back(s); + } + } +} + bool is_std_module(std::string_view name) { return name == "std" || name == "std.compat"; } @@ -717,18 +751,14 @@ prepare_build(bool print_fingerprint, // flags append to buildConfig, mirroring the [profile] merge above. if (!m->conditionalConfigs.empty()) { auto cc_ctx = cfgpred::context_for(overrides.target_triple); + merge_conditional_build(*m, cc_ctx, overrides.target_triple); for (auto const& cc : m->conditionalConfigs) { if (!cfgpred::matches(cc.predicate, cc_ctx, overrides.target_triple)) continue; - m->buildConfig.cflags.insert(m->buildConfig.cflags.end(), - cc.cflags.begin(), cc.cflags.end()); - m->buildConfig.cxxflags.insert(m->buildConfig.cxxflags.end(), - cc.cxxflags.begin(), cc.cxxflags.end()); - m->buildConfig.ldflags.insert(m->buildConfig.ldflags.end(), - cc.ldflags.begin(), cc.ldflags.end()); // Conditional dependencies (Phase 1b): merge into the manifest maps // before dependency resolution so they resolve like any dep. insert() // keeps an existing unconditional entry (no silent override). + // Root-only — a dependency's own conditional deps are out of scope. m->dependencies.insert(cc.dependencies.begin(), cc.dependencies.end()); m->devDependencies.insert(cc.devDependencies.begin(), cc.devDependencies.end()); m->buildDependencies.insert(cc.buildDependencies.begin(), cc.buildDependencies.end()); @@ -962,6 +992,16 @@ prepare_build(bool print_fingerprint, // when its source/inputs/env change. Leaf-only: it cannot gate the top-level // dependency graph. Skipped under a cross --target (host program, host run). // See .agents/docs/2026-06-30-l3-build-mcpp-implementation-design.md. + // Root [generated_files]: materialize before build.mcpp and the modgraph + // scan so synthesized sources are globbed like any on-disk file. (The + // per-dependency call sits in the dep resolution loop below; the root + // manifest needs its own.) + if (!m->buildConfig.generatedFiles.empty()) { + if (auto r = materialize_generated_files(*root, *m); !r) { + return std::unexpected(r.error()); + } + } + if (auto bp = mcpp::build::run_build_program( *m, *root, explicit_compiler, *tc, m->cppStandard.canonical, /*isCross=*/!overrides.target_triple.empty()); @@ -1563,6 +1603,15 @@ prepare_build(bool print_fingerprint, "dependency '{}': {}", depName, r.error())); } + // Dependency-side L1 cfg merge (flags + sources): a descriptor's + // `target_cfg` / a dep mcpp.toml's [target.'cfg(...)'.build] must + // evaluate here too — before its globs expand. + if (!manifest->conditionalConfigs.empty()) { + merge_conditional_build(*manifest, + cfgpred::context_for(overrides.target_triple), + overrides.target_triple); + } + return std::pair{effRoot, std::move(*manifest)}; }; @@ -2502,14 +2551,27 @@ prepare_build(bool print_fingerprint, auto& bc = pkg.manifest.buildConfig; if (!bc.featureSources.empty()) { if (!includeDevDeps) { - std::set gated; + // glob → owned by at least one ACTIVE feature? + std::set activeNow(active.begin(), active.end()); + std::map gated; for (auto& [f, globs] : bc.featureSources) - for (auto& g : globs) gated.insert(g); + for (auto& g : globs) + gated[g] = gated[g] || activeNow.contains(f); auto drop = [&](std::vector& v) { std::erase_if(v, [&](const std::string& s) { return gated.contains(s); }); }; drop(bc.sources); drop(pkg.manifest.modules.sources); + // Dropping the glob STRING is not enough: files it matches + // may still be covered by a broader base glob (the default + // src/** — the mcpp.toml G5 case). An inactive gate becomes + // a `!` exclusion so the gate actually gates; active gates + // are re-added below. + for (auto& [g, isActive] : gated) { + if (isActive || g.starts_with("!")) continue; + bc.sources.push_back("!" + g); + pkg.manifest.modules.sources.push_back("!" + g); + } } std::set activeSet(active.begin(), active.end()); auto add = [](std::vector& v, const std::string& g) { diff --git a/src/libs/toml.cppm b/src/libs/toml.cppm index 62142705..24855d3c 100644 --- a/src/libs/toml.cppm +++ b/src/libs/toml.cppm @@ -6,7 +6,7 @@ // - Integers // - Arrays of strings: key = ["a", "b"] // - Inline tables (rudimentary): key = { version = "..." } -// Comments: # to end of line. No multiline strings, no datetime. +// Comments: # to end of line. Multiline strings ("""/''') supported; no datetime. export module mcpp.libs.toml; @@ -152,11 +152,70 @@ inline bool is_bare_key_char(char c) { || (c >= '0' && c <= '9') || c == '_' || c == '-'; } +// """multi-line basic""" / '''multi-line literal''' strings (TOML 1.0). +// Entered from read_string once it has seen the triple quote. A newline +// immediately after the opening delimiter is trimmed (spec); basic form +// supports the same escapes as single-line plus the line-ending backslash +// (strips the newline and following whitespace). +inline std::expected read_multiline_string(Lexer& L, char quote) { + for (int i = 0; i < 3; ++i) L.advance(); // consume opening delim + if (L.peek() == '\r') L.advance(); + if (L.peek() == '\n') L.advance(); // trim first newline + std::string out; + while (!L.eof()) { + if (L.peek() == quote && L.peek(1) == quote && L.peek(2) == quote) { + // TOML allows 1–2 extra quotes glued to the closing delimiter. + std::size_t extra = 0; + while (extra < 2 && L.peek(3 + extra) == quote) ++extra; + for (std::size_t i = 0; i < extra; ++i) out.push_back(quote); + for (std::size_t i = 0; i < 3 + extra; ++i) L.advance(); + return out; + } + char c = L.peek(); + if (c == '\\' && quote == '"') { + char nxt = L.peek(1); + if (nxt == '\n' || nxt == '\r' + || ((nxt == ' ' || nxt == '\t') && [&] { // ws-only tail → line-ending backslash + std::size_t o = 1; + while (L.peek(o) == ' ' || L.peek(o) == '\t') ++o; + return L.peek(o) == '\n' || L.peek(o) == '\r'; + }())) { + L.advance(); // backslash + while (!L.eof() && (L.peek() == ' ' || L.peek() == '\t' + || L.peek() == '\r' || L.peek() == '\n')) + L.advance(); + continue; + } + L.advance(); + char esc = L.peek(); + switch (esc) { + case 'n': out.push_back('\n'); break; + case 't': out.push_back('\t'); break; + case 'r': out.push_back('\r'); break; + case '"': out.push_back('"'); break; + case '\\': out.push_back('\\'); break; + case '0': out.push_back('\0'); break; + default: + return std::unexpected(ParseError{ + std::format("invalid escape '\\{}'", esc), L.position()}); + } + L.advance(); + continue; + } + out.push_back(c); + L.advance(); + } + return std::unexpected(ParseError{"unterminated multi-line string", L.position()}); +} + inline std::expected read_string(Lexer& L) { char quote = L.peek(); if (quote != '"' && quote != '\'') { return std::unexpected(ParseError{"expected string literal", L.position()}); } + if (L.peek(1) == quote && L.peek(2) == quote) { + return read_multiline_string(L, quote); + } L.advance(); std::string out; while (!L.eof() && L.peek() != quote) { diff --git a/src/manifest/toml.cppm b/src/manifest/toml.cppm index 4d546dc9..0d776c4b 100644 --- a/src/manifest/toml.cppm +++ b/src/manifest/toml.cppm @@ -172,6 +172,13 @@ std::expected parse_string(std::string_view content, std::vector defs; read_str_array(ft, "defines", defs); if (!defs.empty()) m.buildConfig.featureDefines[fname] = std::move(defs); + // Feature-gated source globs — same semantics as the index + // descriptor's `sources` key (one data model, two grammars): + // listed globs leave the default build and compile only when + // the feature is active. + std::vector fsrcs; + read_str_array(ft, "sources", fsrcs); + if (!fsrcs.empty()) m.buildConfig.featureSources[fname] = std::move(fsrcs); std::vector reqs, provs; read_str_array(ft, "requires", reqs); read_str_array(ft, "provides", provs); @@ -191,6 +198,35 @@ std::expected parse_string(std::string_view content, if (cval.is_string()) m.capabilityPins[cap] = cval.as_string(); } + // [generated_files] — "relative/path" = "file contents" (multiline + // strings supported). Same mechanism as the index descriptor's + // generated_files key: materialized into the package root before glob + // expansion, content folded into the package fingerprint. Paths are + // validated again at materialize time; checking here gives the error a + // manifest location. + if (auto* gf = doc->get_table("generated_files"); gf && !gf->empty()) { + for (auto& [rel, val] : *gf) { + if (!val.is_string()) { + return std::unexpected(error(origin, std::format( + "[generated_files].\"{}\" must be a string (file contents)", rel))); + } + std::filesystem::path p(rel); + // has_root_path, not is_absolute: on Windows "/x" is root-relative + // (not absolute) yet still escapes the project root. + bool escapes = rel.empty() || p.has_root_path(); + // const&: libc++'s path iterator dereferences to a temporary + // path (libstdc++ hands out a reference) — auto& won't bind. + for (auto const& part : p.lexically_normal()) + if (part == "..") { escapes = true; break; } + if (escapes) { + return std::unexpected(error(origin, std::format( + "[generated_files] path '{}' must be relative and stay " + "inside the project root", rel))); + } + m.buildConfig.generatedFiles.emplace(std::move(p), val.as_string()); + } + } + // [scan_overrides.""] — author-asserted scan results (see // manifest:types ScanOverride). provides/imports are string arrays. if (auto* so_table = doc->get_table("scan_overrides"); @@ -648,6 +684,50 @@ std::expected parse_string(std::string_view content, if (auto v = doc->get_string_array("build.dialect_cxxflags")) m.buildConfig.dialectCxxflags = *v; if (auto v = doc->get_string_array("build.ldflags")) m.buildConfig.ldflags = *v; + // [build] flags = [{ glob = "...", cflags/cxxflags/asmflags/defines }] + // Per-glob compile flags (G4). An ARRAY of inline tables — TOML tables + // are sorted maps, so only the array form can carry declaration order, + // and order is the override semantics (later entries win via "last flag + // wins"). Unknown keys are errors: closed grammar. + if (auto* fv = doc->get("build.flags")) { + if (!fv->is_array()) { + return std::unexpected(error(origin, + "[build].flags must be an array of inline tables: " + "flags = [{ glob = \"...\", cxxflags = [...] }, ...]")); + } + for (auto& ev : fv->as_array()) { + if (!ev.is_table()) { + return std::unexpected(error(origin, + "[build].flags entries must be inline tables with a `glob` key")); + } + auto& et = ev.as_table(); + GlobFlags gf; + for (auto& [k, v] : et) { + auto read_list = [&](std::vector& out) -> bool { + if (!v.is_array()) return false; + for (auto& s : v.as_array()) + if (s.is_string()) out.push_back(s.as_string()); + return true; + }; + bool ok = false; + if (k == "glob") { ok = v.is_string(); if (ok) gf.glob = v.as_string(); } + else if (k == "cflags") ok = read_list(gf.cflags); + else if (k == "cxxflags") ok = read_list(gf.cxxflags); + else if (k == "asmflags") ok = read_list(gf.asmflags); + else if (k == "defines") ok = read_list(gf.defines); + if (!ok) { + return std::unexpected(error(origin, std::format( + "[build].flags: invalid key '{}' (expected glob = \"...\" " + "plus cflags/cxxflags/asmflags/defines arrays)", k))); + } + } + if (gf.glob.empty()) { + return std::unexpected(error(origin, + "[build].flags entry is missing its `glob` key")); + } + m.buildConfig.globFlags.push_back(std::move(gf)); + } + } if (auto v = doc->get_string("build.c_standard")) m.buildConfig.cStandard = *v; if (auto v = doc->get_string("build.target")) m.buildConfig.target = *v; if (auto v = doc->get_string("build.default-profile")) m.buildConfig.defaultProfile = *v; @@ -760,6 +840,7 @@ std::expected parse_string(std::string_view content, read_list("cflags", cc.cflags); read_list("cxxflags", cc.cxxflags); read_list("ldflags", cc.ldflags); + read_list("sources", cc.sources); } // [target..{dependencies,dev-dependencies,build-dependencies}] // parsed via the shared table-based loader (same selectors/namespaces @@ -775,6 +856,7 @@ std::expected parse_string(std::string_view content, if (auto r = read_deps("dev-dependencies", cc.devDependencies); !r) return std::unexpected(r.error()); if (auto r = read_deps("build-dependencies", cc.buildDependencies); !r) return std::unexpected(r.error()); if (!cc.cflags.empty() || !cc.cxxflags.empty() || !cc.ldflags.empty() + || !cc.sources.empty() || !cc.dependencies.empty() || !cc.devDependencies.empty() || !cc.buildDependencies.empty()) m.conditionalConfigs.push_back(std::move(cc)); diff --git a/src/manifest/types.cppm b/src/manifest/types.cppm index 32ce31c7..19ac18fc 100644 --- a/src/manifest/types.cppm +++ b/src/manifest/types.cppm @@ -117,6 +117,19 @@ struct Toolchain { } }; +// `[build] flags = [{ glob = "...", ... }]` — per-glob compile flags (G4). +// A VECTOR, not a map: declaration order is application order (a later +// entry's flags land later on the command line, so GNU "last flag wins" +// gives it precedence over an earlier, broader glob). Private build flags — +// they never enter usage requirements / never propagate to consumers. +struct GlobFlags { + std::string glob; // matched against package-root-relative paths + std::vector cflags; // C units (.c/.m) + std::vector cxxflags; // C++ units (.cpp/.cc/.cxx/.cppm) + std::vector asmflags; // assembly units (.S/.s via cc, .asm via nasm) + std::vector defines; // desugars to -D on every matched unit kind +}; + // `[build]` section — tunables for the build backend. // // M5.0: now also carries `sources` (moved from [modules]) and `include_dirs` @@ -140,6 +153,7 @@ struct BuildConfig { std::map> featureDefines; std::vector includeDirs; // relative to package root std::map generatedFiles; // Form B package-owned support files + std::vector globFlags; // [build] flags = [...] (ordered) bool staticStdlib = true; // "" (default = dynamic), "static", "dynamic" — chosen at resolve // time from --static / --target / [target.].linkage. Wired @@ -247,6 +261,11 @@ struct ConditionalConfig { std::vector cflags; std::vector cxxflags; std::vector ldflags; + // Conditional source globs (G1b): appended to [build].sources when the + // predicate matches the resolved target — the declarative gate for + // arch-specific code (x86 .asm on x86 targets only). `!`-exclusion + // globs work here too (the scanner handles positive+negative sets). + std::vector sources; // Conditional dependencies (Phase 1b): merged into the corresponding // manifest maps in prepare_build when the predicate matches the resolved // target — before dependency resolution, so they resolve like any dep. diff --git a/src/manifest/xpkg.cppm b/src/manifest/xpkg.cppm index bdd0eb93..d810e579 100644 --- a/src/manifest/xpkg.cppm +++ b/src/manifest/xpkg.cppm @@ -861,6 +861,133 @@ synthesize_from_xpkg_lua(std::string_view luaContent, } cur.consume('}'); } + else if (key == "flags") { + // `{ { glob = "...", cflags = {...}, cxxflags = {...}, + // asmflags = {...}, defines = {...} }, ... }` — per-glob + // compile flags (G4), same ordered data model as mcpp.toml's + // [build] flags array. Lua array-of-tables preserves order. + if (!cur.consume('{')) { + return std::unexpected(ManifestError{ + "expected '{' after `flags =`", m.sourcePath, 0, 0}); + } + cur.skip_ws_and_comments(); + while (!cur.eof() && cur.peek() != '}') { + if (!cur.consume('{')) { + return std::unexpected(ManifestError{ + "expected '{' opening a `flags` entry", m.sourcePath, 0, 0}); + } + GlobFlags gf; + cur.skip_ws_and_comments(); + while (!cur.eof() && cur.peek() != '}') { + auto sub = cur.read_key(); + if (sub.empty()) break; + if (!cur.consume('=')) { + return std::unexpected(ManifestError{ + "expected '=' in `flags` entry", m.sourcePath, 0, 0}); + } + cur.skip_ws_and_comments(); + if (sub == "glob") { + gf.glob = cur.read_string(); + cur.skip_ws_and_comments(); + continue; + } + std::vector* dst = + sub == "cflags" ? &gf.cflags + : sub == "cxxflags" ? &gf.cxxflags + : sub == "asmflags" ? &gf.asmflags + : sub == "defines" ? &gf.defines + : nullptr; + if (!dst) { + return std::unexpected(ManifestError{ + std::format("unknown flags key '{}' (expected glob/" + "cflags/cxxflags/asmflags/defines)", sub), + m.sourcePath, 0, 0}); + } + if (!cur.consume('{')) { + return std::unexpected(ManifestError{ + std::format("expected '{{' after flags entry key '{}'", sub), + m.sourcePath, 0, 0}); + } + cur.skip_ws_and_comments(); + while (!cur.eof() && cur.peek() != '}') { + auto s = cur.read_string(); + if (!s.empty()) dst->push_back(std::move(s)); + cur.skip_ws_and_comments(); + } + cur.consume('}'); + cur.skip_ws_and_comments(); + } + cur.consume('}'); + if (gf.glob.empty()) { + return std::unexpected(ManifestError{ + "`flags` entry is missing its `glob` key", m.sourcePath, 0, 0}); + } + m.buildConfig.globFlags.push_back(std::move(gf)); + cur.skip_ws_and_comments(); + } + cur.consume('}'); + } + else if (key == "target_cfg") { + // `{ ["cfg(...)"] = { cflags = {...}, cxxflags = {...}, + // ldflags = {...}, sources = {...} }, ... }` — deferred + // platform-conditional build config (L1), the same data model as + // mcpp.toml's [target.'cfg(...)'.build]. Evaluated against the + // RESOLVED target in prepare_build (root and dependency alike), + // so arch-gated sources (x86 .asm) work under cross builds. + if (!cur.consume('{')) { + return std::unexpected(ManifestError{ + "expected '{' after `target_cfg =`", m.sourcePath, 0, 0}); + } + cur.skip_ws_and_comments(); + while (!cur.eof() && cur.peek() != '}') { + auto pred = cur.read_key(); + if (pred.empty()) break; + if (!cur.consume('=') || !cur.consume('{')) { + return std::unexpected(ManifestError{ + std::format("expected `= {{` after target_cfg entry '{}'", pred), + m.sourcePath, 0, 0}); + } + ConditionalConfig cc; + cc.predicate = pred; + cur.skip_ws_and_comments(); + while (!cur.eof() && cur.peek() != '}') { + auto sub = cur.read_key(); + if (sub.empty()) break; + std::vector* dst = + sub == "cflags" ? &cc.cflags + : sub == "cxxflags" ? &cc.cxxflags + : sub == "ldflags" ? &cc.ldflags + : sub == "sources" ? &cc.sources + : nullptr; + if (!dst) { + return std::unexpected(ManifestError{ + std::format("unknown target_cfg key '{}' (expected " + "cflags/cxxflags/ldflags/sources)", sub), + m.sourcePath, 0, 0}); + } + if (!cur.consume('=') || !cur.consume('{')) { + return std::unexpected(ManifestError{ + std::format("expected `= {{` after target_cfg '{}'.{}", + pred, sub), + m.sourcePath, 0, 0}); + } + cur.skip_ws_and_comments(); + while (!cur.eof() && cur.peek() != '}') { + auto s = cur.read_string(); + if (!s.empty()) dst->push_back(std::move(s)); + cur.skip_ws_and_comments(); + } + cur.consume('}'); + cur.skip_ws_and_comments(); + } + cur.consume('}'); + if (!cc.cflags.empty() || !cc.cxxflags.empty() + || !cc.ldflags.empty() || !cc.sources.empty()) + m.conditionalConfigs.push_back(std::move(cc)); + cur.skip_ws_and_comments(); + } + cur.consume('}'); + } else if (key == "targets") { // `{ ["name"] = { kind = "lib" }, ... }` if (!cur.consume('{')) { diff --git a/src/modgraph/graph.cppm b/src/modgraph/graph.cppm index 9f9fefa7..20ebf5cc 100644 --- a/src/modgraph/graph.cppm +++ b/src/modgraph/graph.cppm @@ -18,6 +18,7 @@ struct SourceUnit { std::vector localIncludeDirs; std::vector packageCflags; std::vector packageCxxflags; + std::vector packageAsmflags; // per-glob asmflags (G4) std::optional provides; std::vector requires_; bool isModuleInterface = false; // .cppm with export module diff --git a/src/modgraph/scanner.cppm b/src/modgraph/scanner.cppm index f6dc0d4a..a63fc063 100644 --- a/src/modgraph/scanner.cppm +++ b/src/modgraph/scanner.cppm @@ -51,6 +51,14 @@ struct ScanResult { ScanResult scan_package(const std::filesystem::path& root, const mcpp::manifest::Manifest& manifest); +// Absolutize relative `-I` compile flags against the package root +// (G8b). A manifest's relative -I means root-relative, but ninja runs +// commands with cwd = the output dir, so a verbatim relative flag resolves +// against the wrong base. Called at every point where per-unit flag vectors +// are attached (the scanner here; plan.cppm for a target's entry unit). +void absolutize_include_flags(const std::filesystem::path& root, + std::vector& flags); + enum class DependencyVisibility { Private, Public, @@ -302,6 +310,20 @@ std::vector expand_dir_glob(const std::filesystem::path& return out; } +void absolutize_include_flags(const std::filesystem::path& root, + std::vector& flags) +{ + for (auto& f : flags) { + if (f.size() > 2 && f.starts_with("-I")) { + std::filesystem::path p(f.substr(2)); + // has_root_path: leave absolute AND root-relative ("/x" on + // Windows) spellings alone — only genuinely root-less paths are + // project-relative. + if (!p.has_root_path()) f = "-I" + (root / p).string(); + } + } +} + std::expected scan_file(const std::filesystem::path& file, const std::string& packageName) { @@ -505,6 +527,31 @@ void scan_one_into(ScanResult& result, for (auto const& [glob, ov] : manifest.modules.scanOverrides) overrideHits[glob] = 0; + // [build].flags per-glob entries (G4): append each matching entry's + // flags to the unit IN DECLARATION ORDER (later entries land later on + // the command line — "last flag wins" precedence). `defines` desugar to + // -D on every unit kind. Zero-hit globs warn after the walk (a typo'd + // glob silently doing nothing is the classic trap) — warn, not error: + // a cfg-gated source set can legitimately leave a glob empty on some + // targets. + std::vector globFlagHits(manifest.buildConfig.globFlags.size(), 0); + auto apply_glob_flags = [&](SourceUnit& u) { + for (std::size_t i = 0; i < manifest.buildConfig.globFlags.size(); ++i) { + auto const& gf = manifest.buildConfig.globFlags[i]; + if (!path_matches_glob(u.path, root, gf.glob)) continue; + ++globFlagHits[i]; + // defines reach asm units too — via the -D subset the backend + // filters out of packageCflags (no third copy needed here). + for (auto const& d : gf.defines) { + u.packageCflags.push_back("-D" + d); + u.packageCxxflags.push_back("-D" + d); + } + for (auto const& f : gf.cflags) u.packageCflags.push_back(f); + for (auto const& f : gf.cxxflags) u.packageCxxflags.push_back(f); + for (auto const& f : gf.asmflags) u.packageAsmflags.push_back(f); + } + }; + for (auto const& f : all_files) { const mcpp::manifest::ScanOverride* ov = nullptr; for (auto const& [glob, o] : manifest.modules.scanOverrides) { @@ -534,6 +581,10 @@ void scan_one_into(ScanResult& result, u.localIncludeDirs = localIncludeDirs; u.packageCflags = packageCflags; u.packageCxxflags = packageCxxflags; + apply_glob_flags(u); + absolutize_include_flags(root, u.packageCflags); + absolutize_include_flags(root, u.packageCxxflags); + absolutize_include_flags(root, u.packageAsmflags); result.graph.units.push_back(std::move(u)); continue; } @@ -545,6 +596,10 @@ void scan_one_into(ScanResult& result, r->localIncludeDirs = localIncludeDirs; r->packageCflags = packageCflags; r->packageCxxflags = packageCxxflags; + apply_glob_flags(*r); + absolutize_include_flags(root, r->packageCflags); + absolutize_include_flags(root, r->packageCxxflags); + absolutize_include_flags(root, r->packageAsmflags); result.graph.units.push_back(std::move(*r)); } @@ -555,6 +610,13 @@ void scan_one_into(ScanResult& result, "(typo, or the glob is not covered by `sources`)", glob)}); } } + for (std::size_t i = 0; i < globFlagHits.size(); ++i) { + if (globFlagHits[i] == 0) { + result.warnings.push_back(ScanError{root, 0, std::format( + "[build].flags glob '{}' matched no source file", + manifest.buildConfig.globFlags[i].glob)}); + } + } } // Phase 2: producerOf + edges over already-collected units. diff --git a/tests/e2e/106_feature_gated_sources_toml.sh b/tests/e2e/106_feature_gated_sources_toml.sh new file mode 100755 index 00000000..2dab88ec --- /dev/null +++ b/tests/e2e/106_feature_gated_sources_toml.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# requires: gcc +# G5: mcpp.toml [features] `sources` key — feature-gated source globs, at +# parity with index descriptors. Four quadrants: feature off/on × build/test +# (the 0.0.94 lesson: feature-source behavior MUST match on both paths). +set -e + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT + +cd "$TMP" +"$MCPP" new featsrc > /dev/null +cd featsrc + +cat > src/extra.cpp <<'EOF' +extern "C" int extra_answer() { return 42; } +EOF + +cat > src/main.cpp <<'EOF' +import std; +extern "C" int extra_answer(); +int main() { +#ifdef HAS_EXTRA + std::println("extra = {}", extra_answer()); +#else + std::println("extra = none"); +#endif + return 0; +} +EOF + +cat > mcpp.toml <<'EOF' +[package] +name = "featsrc" +version = "0.1.0" + +[features] +default = [] +extra = { sources = ["src/extra.cpp"], defines = ["HAS_EXTRA"] } +EOF + +# Quadrant 1: feature OFF, build — extra.cpp must NOT compile (main has no +# reference so the link succeeds; the object must be absent). +"$MCPP" build > build_off.log 2>&1 || { cat build_off.log; echo "build (off) failed"; exit 1; } +find target -name 'extra.o' | grep -q . && { echo "extra.cpp compiled with feature off"; exit 1; } || true +out="$("$MCPP" run 2>&1 | tail -1)" +[[ "$out" == "extra = none" ]] || { echo "unexpected (off): $out"; exit 1; } + +# Quadrant 2: feature ON, build (`run` has no --features; exec the binary). +"$MCPP" build --features extra > build_on.log 2>&1 || { cat build_on.log; echo "build (on) failed"; exit 1; } +# Feature defines change the fingerprint → a second output dir; take the +# newest binary. +bin="$(ls -t $(find target -name 'featsrc' -type f) | head -1)" +[[ -n "$bin" ]] || { echo "binary not found"; exit 1; } +out="$("./$bin" | tail -1)" +[[ "$out" == "extra = 42" ]] || { echo "unexpected (on): $out"; exit 1; } + +# Quadrants 3+4: test path, feature off then on. The test binary references +# the gated symbol only when the feature is active. +mkdir -p tests +cat > tests/test_featsrc.cpp <<'EOF' +#ifdef HAS_EXTRA +extern "C" int extra_answer(); +int main() { return extra_answer() == 42 ? 0 : 1; } +#else +int main() { return 0; } +#endif +EOF +"$MCPP" test > test_off.log 2>&1 || { cat test_off.log; echo "mcpp test (off) failed"; exit 1; } +"$MCPP" test --features extra > test_on.log 2>&1 || { cat test_on.log; echo "mcpp test (on) failed"; exit 1; } + +echo "OK" diff --git a/tests/e2e/107_generated_files_toml.sh b/tests/e2e/107_generated_files_toml.sh new file mode 100755 index 00000000..ef8f961c --- /dev/null +++ b/tests/e2e/107_generated_files_toml.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# requires: gcc +# G6: mcpp.toml [generated_files] — path = "contents" entries materialize +# into the project root before the scan (multiline TOML strings), enter the +# fingerprint (content change → rebuild), and escaping paths hard-error. +set -e + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT + +cd "$TMP" +"$MCPP" new genf > /dev/null +cd genf +rm -f src/main.cpp + +cat > mcpp.toml <<'EOF' +[package] +name = "genf" +version = "0.1.0" + +[generated_files] +"src/gen/answer.cpp" = """ +extern "C" int gen_answer() { return 41; } +""" +EOF + +cat > src/main.cpp <<'EOF' +import std; +extern "C" int gen_answer(); +int main() { + std::println("gen = {}", gen_answer()); + return 0; +} +EOF + +"$MCPP" build > build.log 2>&1 || { cat build.log; echo "build failed"; exit 1; } +[[ -f src/gen/answer.cpp ]] || { echo "generated file not materialized"; exit 1; } +out="$("$MCPP" run 2>&1 | tail -1)" +[[ "$out" == "gen = 41" ]] || { echo "unexpected output: $out"; exit 1; } + +# Content change re-materializes and rebuilds (content is in the fingerprint). +sed -i.bak 's/return 41/return 42/' mcpp.toml +"$MCPP" build > rebuild.log 2>&1 || { cat rebuild.log; echo "rebuild failed"; exit 1; } +out="$("$MCPP" run 2>&1 | tail -1)" +[[ "$out" == "gen = 42" ]] || { echo "stale after content change: $out"; exit 1; } + +# Escaping path must be rejected at parse time. +cat > mcpp.toml <<'EOF' +[package] +name = "genf" +version = "0.1.0" +[generated_files] +"../evil.cpp" = "int x;" +EOF +"$MCPP" build > escape.log 2>&1 && { echo "escaping path was accepted"; exit 1; } +grep -qi "generated_files" escape.log || { cat escape.log; echo "wrong error"; exit 1; } + +echo "OK" diff --git a/tests/e2e/108_cfg_conditional_sources.sh b/tests/e2e/108_cfg_conditional_sources.sh new file mode 100755 index 00000000..ed256719 --- /dev/null +++ b/tests/e2e/108_cfg_conditional_sources.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# requires: gcc +# G1b: [target.'cfg(...)'.build] sources — target-conditional source globs, +# evaluated against the RESOLVED target. Host-aware: asserts the host-arch +# gated file is compiled and the other-arch file is not. +set -e + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT + +HOST_ARCH="$(uname -m)" +case "$HOST_ARCH" in + x86_64) THIS=x86_64; OTHER=aarch64 ;; + aarch64|arm64) THIS=aarch64; OTHER=x86_64 ;; + *) echo "SKIP: unmapped arch $HOST_ARCH"; exit 0 ;; +esac + +cd "$TMP" +"$MCPP" new condsrc > /dev/null +cd condsrc + +mkdir -p arch +cat > arch/this.cpp <<'EOF' +extern "C" int arch_answer() { return 7; } +EOF +cat > arch/other.cpp <<'EOF' +extern "C" int arch_answer() { return 8; } // would double-define if both compiled +EOF + +cat > src/main.cpp <<'EOF' +import std; +extern "C" int arch_answer(); +int main() { + std::println("arch = {}", arch_answer()); + return 0; +} +EOF + +cat > mcpp.toml < build.log 2>&1 || { cat build.log; echo "build failed"; exit 1; } +out="$("$MCPP" run 2>&1 | tail -1)" +[[ "$out" == "arch = 7" ]] || { echo "unexpected output: $out"; exit 1; } +# The other-arch file must not have been compiled. +find target -name 'other.o' | grep -q . && { echo "other-arch source compiled"; exit 1; } || true + +echo "OK" diff --git a/tests/e2e/109_per_glob_flags.sh b/tests/e2e/109_per_glob_flags.sh new file mode 100755 index 00000000..d7118d24 --- /dev/null +++ b/tests/e2e/109_per_glob_flags.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# requires: gcc +# G4: [build] flags = [{ glob = "...", ... }] — per-glob compile flags. +# Matched TUs get the flags (probed via -D), unmatched TUs don't, later +# entries override earlier ones ("last flag wins"), zero-hit globs warn. +set -e + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT + +cd "$TMP" +"$MCPP" new globf > /dev/null +cd globf + +cat > src/tagged.cpp <<'EOF' +#ifndef TAG +#error "per-glob define did not reach tagged.cpp" +#endif +extern "C" int tag_value() { return TAG; } +EOF + +cat > src/plain.cpp <<'EOF' +#ifdef TAG +#error "per-glob define leaked into plain.cpp" +#endif +extern "C" int plain_ok() { return 1; } +EOF + +cat > src/main.cpp <<'EOF' +import std; +extern "C" int tag_value(); +extern "C" int plain_ok(); +int main() { + std::println("tag = {}", tag_value() + plain_ok()); + return 0; +} +EOF + +# Two overlapping globs: the later, more specific entry must win (-DTAG=2 +# appended after -DTAG=1; GNU last-wins → 2). Plus a zero-hit glob → warning. +cat > mcpp.toml <<'EOF' +[package] +name = "globf" +version = "0.1.0" + +[build] +flags = [ + { glob = "src/tag*.cpp", cxxflags = ["-DTAG=1"] }, + { glob = "src/tagged.cpp", cxxflags = ["-DTAG=2"] }, + { glob = "src/never/**", cxxflags = ["-DNOPE"] }, +] +EOF + +"$MCPP" build > build.log 2>&1 || { cat build.log; echo "build failed"; exit 1; } +grep -q "matched no source file" build.log || { + cat build.log; echo "missing zero-hit glob warning"; exit 1; } +out="$("$MCPP" run 2>&1 | tail -1)" +[[ "$out" == "tag = 3" ]] || { echo "unexpected output: $out (want later-entry override → 2+1)"; exit 1; } + +echo "OK" diff --git a/tests/unit/test_manifest.cpp b/tests/unit/test_manifest.cpp index 16b3e50d..83b653e9 100644 --- a/tests/unit/test_manifest.cpp +++ b/tests/unit/test_manifest.cpp @@ -1529,3 +1529,205 @@ main = "src/main.cpp" EXPECT_EQ(ov.provides, std::vector{"fmt"}); EXPECT_EQ(ov.imports, std::vector{"std"}); } + +// ── declarative source-set parity: mcpp.toml ↔ xpkg descriptor ─────────────── +// One data model, two grammars (manifest.cppm): what a descriptor can express +// through `features..sources` / `generated_files`, mcpp.toml must express +// identically. This test locks the symmetry as a contract. + +TEST(Manifest, FeatureSourcesParseFromToml) { + constexpr auto src = R"( +[package] +name = "sym" +version = "1.0.0" +[features] +default = [] +compiled = { sources = ["src/impl/**"], defines = ["SYM_COMPILED"] } +)"; + auto m = mcpp::manifest::parse_string(src); + ASSERT_TRUE(m.has_value()) << m.error().format(); + ASSERT_TRUE(m->buildConfig.featureSources.contains("compiled")); + EXPECT_EQ(m->buildConfig.featureSources.at("compiled"), + (std::vector{"src/impl/**"})); + ASSERT_TRUE(m->buildConfig.featureDefines.contains("compiled")); +} + +TEST(Manifest, GeneratedFilesParseFromToml) { + constexpr auto src = R"( +[package] +name = "genf" +version = "1.0.0" + +[generated_files] +"src/gen/wrap.cppm" = """ +module; +export module wrap; +""" +)"; + auto m = mcpp::manifest::parse_string(src); + ASSERT_TRUE(m.has_value()) << m.error().format(); + ASSERT_EQ(m->buildConfig.generatedFiles.size(), 1u); + auto it = m->buildConfig.generatedFiles.find( + std::filesystem::path("src/gen/wrap.cppm")); + ASSERT_NE(it, m->buildConfig.generatedFiles.end()); + EXPECT_EQ(it->second, "module;\nexport module wrap;\n"); +} + +TEST(Manifest, GeneratedFilesRejectEscapingPaths) { + for (auto path : { "\"../evil.cpp\"", "\"/abs/evil.cpp\"", "\"a/../../evil.cpp\"" }) { + auto src = std::format(R"( +[package] +name = "genf" +version = "1.0.0" +[generated_files] +{} = "x" +)", path); + auto m = mcpp::manifest::parse_string(src); + EXPECT_FALSE(m.has_value()) << "path accepted: " << path; + } +} + +TEST(Manifest, TomlAndXpkgFeatureSourcesAreSymmetric) { + constexpr auto toml = R"( +[package] +name = "sym" +version = "1.0.0" +[features] +compiled = { sources = ["src/impl/**"], defines = ["SYM_COMPILED"] } +[generated_files] +"src/gen/wrap.cppm" = "module;\nexport module wrap;\n" +)"; + constexpr auto lua = R"( +package = { name = "sym" } +mcpp = { + sources = { "src/**/*.cpp" }, + features = { + compiled = { sources = { "src/impl/**" }, defines = { "SYM_COMPILED" } }, + }, + generated_files = { + ["src/gen/wrap.cppm"] = "module;\nexport module wrap;\n", + }, +} +)"; + auto mt = mcpp::manifest::parse_string(toml); + ASSERT_TRUE(mt.has_value()) << mt.error().format(); + auto mx = mcpp::manifest::synthesize_from_xpkg_lua(lua, "sym", "1.0.0"); + ASSERT_TRUE(mx.has_value()) << mx.error().format(); + + EXPECT_EQ(mt->buildConfig.featureSources, mx->buildConfig.featureSources); + EXPECT_EQ(mt->buildConfig.featureDefines, mx->buildConfig.featureDefines); + EXPECT_EQ(mt->buildConfig.generatedFiles, mx->buildConfig.generatedFiles); +} + +TEST(Manifest, CfgConditionalSourcesParseFromToml) { + constexpr auto src = R"( +[package] +name = "condsrc" +version = "1.0.0" + +[target.'cfg(arch = "x86_64")'.build] +sources = ["src/x86/**/*.asm", "!src/x86/legacy/**"] +cxxflags = ["-DHAVE_X86=1"] +)"; + auto m = mcpp::manifest::parse_string(src); + ASSERT_TRUE(m.has_value()) << m.error().format(); + ASSERT_EQ(m->conditionalConfigs.size(), 1u); + auto& cc = m->conditionalConfigs[0]; + EXPECT_EQ(cc.predicate, "cfg(arch = \"x86_64\")"); + EXPECT_EQ(cc.sources, (std::vector{ + "src/x86/**/*.asm", "!src/x86/legacy/**"})); + EXPECT_EQ(cc.cxxflags, (std::vector{"-DHAVE_X86=1"})); +} + +TEST(Manifest, TargetCfgParsesFromXpkgSymmetrically) { + constexpr auto lua = R"( +package = { name = "condsrc" } +mcpp = { + sources = { "src/**/*.c" }, + target_cfg = { + ['cfg(arch = "x86_64")'] = { + sources = { "src/x86/**/*.asm" }, + cxxflags = { "-DHAVE_X86=1" }, + }, + ['cfg(windows)'] = { + ldflags = { "-lws2_32" }, + }, + }, +} +)"; + auto mx = mcpp::manifest::synthesize_from_xpkg_lua(lua, "condsrc", "1.0.0"); + ASSERT_TRUE(mx.has_value()) << mx.error().format(); + ASSERT_EQ(mx->conditionalConfigs.size(), 2u); + EXPECT_EQ(mx->conditionalConfigs[0].predicate, "cfg(arch = \"x86_64\")"); + EXPECT_EQ(mx->conditionalConfigs[0].sources, + (std::vector{"src/x86/**/*.asm"})); + EXPECT_EQ(mx->conditionalConfigs[1].predicate, "cfg(windows)"); + EXPECT_EQ(mx->conditionalConfigs[1].ldflags, + (std::vector{"-lws2_32"})); +} + +TEST(Manifest, PerGlobFlagsParseOrderedFromToml) { + constexpr auto src = R"( +[package] +name = "globf" +version = "1.0.0" + +[build] +flags = [ + { glob = "third_party/**", cflags = ["-w"], cxxflags = ["-w"] }, + { glob = "src/simd/**/*.avx2.cpp", cxxflags = ["-mavx2"], defines = ["HAVE_AVX2"] }, + { glob = "src/x86/**/*.asm", asmflags = ["-DPIC"] }, +] +)"; + auto m = mcpp::manifest::parse_string(src); + ASSERT_TRUE(m.has_value()) << m.error().format(); + auto& gfs = m->buildConfig.globFlags; + ASSERT_EQ(gfs.size(), 3u); + // Declaration order IS the data (override semantics ride on it). + EXPECT_EQ(gfs[0].glob, "third_party/**"); + EXPECT_EQ(gfs[0].cxxflags, (std::vector{"-w"})); + EXPECT_EQ(gfs[1].glob, "src/simd/**/*.avx2.cpp"); + EXPECT_EQ(gfs[1].defines, (std::vector{"HAVE_AVX2"})); + EXPECT_EQ(gfs[2].asmflags, (std::vector{"-DPIC"})); +} + +TEST(Manifest, PerGlobFlagsRejectUnknownKeysAndMissingGlob) { + auto bad1 = mcpp::manifest::parse_string(R"( +[package] +name = "globf" +version = "1.0.0" +[build] +flags = [{ glob = "src/**", ldflags = ["-lfoo"] }] +)"); + EXPECT_FALSE(bad1.has_value()); // ldflags is not per-TU — closed grammar + + auto bad2 = mcpp::manifest::parse_string(R"( +[package] +name = "globf" +version = "1.0.0" +[build] +flags = [{ cxxflags = ["-w"] }] +)"); + EXPECT_FALSE(bad2.has_value()); // missing glob +} + +TEST(Manifest, PerGlobFlagsParseFromXpkgSymmetrically) { + constexpr auto lua = R"( +package = { name = "globf" } +mcpp = { + sources = { "src/**/*.cpp" }, + flags = { + { glob = "third_party/**", cxxflags = { "-w" } }, + { glob = "src/simd/**", cxxflags = { "-mavx2" }, defines = { "HAVE_AVX2" } }, + }, +} +)"; + auto mx = mcpp::manifest::synthesize_from_xpkg_lua(lua, "globf", "1.0.0"); + ASSERT_TRUE(mx.has_value()) << mx.error().format(); + ASSERT_EQ(mx->buildConfig.globFlags.size(), 2u); + EXPECT_EQ(mx->buildConfig.globFlags[0].glob, "third_party/**"); + EXPECT_EQ(mx->buildConfig.globFlags[1].cxxflags, + (std::vector{"-mavx2"})); + EXPECT_EQ(mx->buildConfig.globFlags[1].defines, + (std::vector{"HAVE_AVX2"})); +} diff --git a/tests/unit/test_modgraph.cpp b/tests/unit/test_modgraph.cpp index b5ac1e79..6fbbb23e 100644 --- a/tests/unit/test_modgraph.cpp +++ b/tests/unit/test_modgraph.cpp @@ -456,3 +456,67 @@ TEST(IsForbiddenTopModule, KnownNames) { EXPECT_TRUE(is_forbidden_top_module("util.x")); EXPECT_FALSE(is_forbidden_top_module("myorg.foo")); } + +// G4: per-glob flags attach to exactly the matched units, in declaration +// order; a zero-hit glob warns (not errors — cfg-gated source sets may +// legitimately leave a glob empty on some targets). +TEST(Scanner, PerGlobFlagsAttachToMatchedUnits) { + auto dir = make_tempdir("mcpp-scanner-globflags"); + write(dir / "src" / "hot.cpp", "int hot() { return 1; }\n"); + write(dir / "third_party" / "noisy.cpp", "int noisy() { return 2; }\n"); + + mcpp::manifest::Manifest m; + m.package.name = "globpkg"; + m.modules.sources = { "src/**/*.cpp", "third_party/**/*.cpp" }; + m.buildConfig.globFlags.push_back( + { .glob = "third_party/**", .cxxflags = {"-w"} }); + m.buildConfig.globFlags.push_back( + { .glob = "src/**", .cxxflags = {"-mavx2"}, .defines = {"HOT"} }); + m.buildConfig.globFlags.push_back( + { .glob = "nothing/**", .cxxflags = {"-Wnever"} }); + + auto res = scan_package(dir, m); + ASSERT_TRUE(res.errors.empty()); + ASSERT_EQ(res.graph.units.size(), 2u); + for (auto& u : res.graph.units) { + if (u.path.filename() == "noisy.cpp") { + EXPECT_EQ(u.packageCxxflags, (std::vector{"-w"})); + } else { + // defines land before the entry's own flag lists + EXPECT_EQ(u.packageCxxflags, + (std::vector{"-DHOT", "-mavx2"})); + } + } + // The unmatched glob warns. + bool warned = false; + for (auto& w : res.warnings) + if (w.message.find("nothing/**") != std::string::npos) warned = true; + EXPECT_TRUE(warned); + + std::filesystem::remove_all(dir); +} + +// G8b: relative -I flags are root-relative in the manifest but ninja runs +// with cwd = output dir — the scanner absolutizes them on every unit. +TEST(Scanner, RelativeIncludeFlagsAbsolutized) { + auto dir = make_tempdir("mcpp-scanner-relinc"); + write(dir / "src" / "a.cpp", "int a();\n"); + + mcpp::manifest::Manifest m; + m.package.name = "relinc"; + m.modules.sources = {"src/*.cpp"}; + + // via package flags + auto res = [&] { + mcpp::manifest::Manifest mm = m; + mm.buildConfig.cxxflags = {"-Iinc", "-I/abs/path", "-DKEEP"}; + return scan_package(dir, mm); + }(); + ASSERT_EQ(res.graph.units.size(), 1u); + auto& fl = res.graph.units[0].packageCxxflags; + EXPECT_EQ(fl[0], "-I" + (dir / "inc").string()); + EXPECT_EQ(fl[1], "-I/abs/path"); // absolute stays + EXPECT_EQ(fl[2], "-DKEEP"); // non-include untouched + + std::filesystem::remove_all(dir); +} diff --git a/tests/unit/test_toml.cpp b/tests/unit/test_toml.cpp index b3c3a2b6..a18c6713 100644 --- a/tests/unit/test_toml.cpp +++ b/tests/unit/test_toml.cpp @@ -81,3 +81,35 @@ TEST(Toml, EscapeStringHelper) { EXPECT_EQ(t::escape_string("a\"b"), "\"a\\\"b\""); EXPECT_EQ(t::escape_string("a\\b"), "\"a\\\\b\""); } + +TEST(Toml, MultilineBasicString) { + auto d = t::parse("body = \"\"\"\nline1\nline2 \"quoted\"\n\"\"\"\nafter = 1"); + ASSERT_TRUE(d.has_value()) << d.error().message; + // First newline after the opening delimiter is trimmed (TOML 1.0). + EXPECT_EQ(d->get_string("body").value_or(""), "line1\nline2 \"quoted\"\n"); + EXPECT_EQ(d->get_int("after").value_or(-1), 1); +} + +TEST(Toml, MultilineLiteralString) { + auto d = t::parse("raw = '''\nno \\escapes here\n'''"); + ASSERT_TRUE(d.has_value()) << d.error().message; + EXPECT_EQ(d->get_string("raw").value_or(""), "no \\escapes here\n"); +} + +TEST(Toml, MultilineLineEndingBackslash) { + auto d = t::parse("s = \"\"\"a\\\n b\"\"\""); + ASSERT_TRUE(d.has_value()) << d.error().message; + EXPECT_EQ(d->get_string("s").value_or(""), "ab"); +} + +TEST(Toml, MultilineUnterminatedIsError) { + auto d = t::parse("s = \"\"\"never closed"); + EXPECT_FALSE(d.has_value()); +} + +TEST(Toml, EmptyStringStillParses) { + auto d = t::parse("e = \"\"\nx = 2"); + ASSERT_TRUE(d.has_value()) << d.error().message; + EXPECT_EQ(d->get_string("e").value_or("?"), ""); + EXPECT_EQ(d->get_int("x").value_or(-1), 2); +}