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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 40 additions & 8 deletions src/build/compile_commands.cppm
Original file line number Diff line number Diff line change
Expand Up @@ -142,16 +142,34 @@ std::vector<std::string> split_flags(std::string_view s) {

namespace {

// NATIVE separators for every path this emitter SPELLS ITSELF. Each ingestion
// point (manifest globs, include_dirs, build.mcpp directives) is normalized at
// the source, but this is the last line for the fields the CDB schema defines
// — a path that slips through with a mixed `root\a/b` spelling (MSVC keeps
// input `/` verbatim) breaks CLion, and "all ingestion points are covered" is
// not a claim that can be proven once and stay true.
//
// It is NOT a whole-argv guarantee: the flag strings (split_flags(f.cxx), the
// package cflags/cxxflags) pass through untouched, because normalizing an
// arbitrary flag payload is unsafe — `-DPATH="/etc/x"` holds real slashes.
// Those channels are normalized where they are ingested instead.
// make_preferred() is a no-op on POSIX.
std::string native_string(const std::filesystem::path& p) {
auto n = p;
n.make_preferred();
return n.string();
}

std::vector<std::string> local_include_args(const CompileUnit& cu) {
std::vector<std::string> args;
args.reserve(cu.localIncludeDirs.size());
for (auto const& inc : cu.localIncludeDirs) {
args.push_back("-I" + inc.string());
args.push_back("-I" + native_string(inc));
}
// #249: after-dirs keep their -idirafter spelling in the compile DB so
// tooling (clangd) reproduces the compiler's search order.
for (auto const& inc : cu.localIncludeDirsAfter) {
args.push_back("-idirafter" + inc.string());
args.push_back("-idirafter" + native_string(inc));
}
return args;
}
Expand Down Expand Up @@ -186,7 +204,7 @@ std::string emit_compile_commands(const BuildPlan& plan, const CompileFlags& fla
: isCSource ? flags.cc
: flags.cxx;

auto output_path = (plan.outputDir / cu.object).string();
auto output_path = native_string(plan.outputDir / cu.object);

// Build arguments array.
nlohmann::json args = nlohmann::json::array();
Expand All @@ -198,13 +216,13 @@ std::string emit_compile_commands(const BuildPlan& plan, const CompileFlags& fla
for (auto& f : package_flag_args(cu, isCSource))
args.push_back(std::move(f));
args.push_back("-c");
args.push_back(cu.source.string());
args.push_back(native_string(cu.source));
args.push_back("-o");
args.push_back(output_path);

nlohmann::json entry;
entry["directory"] = plan.projectRoot.string();
entry["file"] = cu.source.string();
entry["directory"] = native_string(plan.projectRoot);
entry["file"] = native_string(cu.source);
entry["arguments"] = std::move(args);
entry["output"] = output_path;

Expand All @@ -222,11 +240,25 @@ std::string merge_compile_commands(
if (freshJ.is_discarded() || !freshJ.is_array())
return std::string(fresh);

// Dedup key = the file's PATH, spelled the way a fresh plan spells it
// (native separators). A prior CDB written before the mixed-separator
// fix (#390) carries `root\generated/modules\x.cppm` entries that are
// the SAME file as the fresh `root\generated\modules\x.cppm` — a literal
// string comparison would keep both and the user's upgrade would not
// visibly fix anything. Normalizing makes the merge self-healing: the
// stale mixed entry is skipped on the first `mcpp build` after upgrade.
// fileExists still probes the raw spelling — Windows accepts both.
auto norm_key = [](std::string_view f) {
auto p = std::filesystem::path(std::string(f)).lexically_normal();
p.make_preferred();
return p.string();
};

// Files the current plan already covers — those entries are authoritative.
std::set<std::string> freshFiles;
for (auto const& e : freshJ) {
if (e.contains("file") && e["file"].is_string())
freshFiles.insert(e["file"].get<std::string>());
freshFiles.insert(norm_key(e["file"].get<std::string>()));
}

// Keep fresh order, then append still-valid prior entries the plan doesn't
Expand All @@ -238,7 +270,7 @@ std::string merge_compile_commands(
for (auto const& e : existingJ) {
if (!e.contains("file") || !e["file"].is_string()) continue;
auto f = e["file"].get<std::string>();
if (freshFiles.contains(f)) continue; // fresh wins
if (freshFiles.contains(norm_key(f))) continue; // fresh wins
if (!fileExists(std::filesystem::path(f))) continue; // pruned
merged.push_back(e);
}
Expand Down
5 changes: 4 additions & 1 deletion src/build/directives.cppm
Original file line number Diff line number Diff line change
Expand Up @@ -366,7 +366,10 @@ const Def* find_by_tag(std::string_view tag) {
}

std::string abs_against(const fs::path& base, std::string_view p) {
fs::path pp(p);
// Native spelling (see mcpp::modgraph::native_path_from_generic): a
// directive path like `generated/modules/x` would otherwise stay mixed
// on MSVC and leak into include flags / the CDB.
fs::path pp = mcpp::modgraph::native_path_from_generic(p);
if (pp.is_relative()) pp = base / pp;
return pp.lexically_normal().string();
}
Expand Down
37 changes: 27 additions & 10 deletions src/build/flags.cppm
Original file line number Diff line number Diff line change
Expand Up @@ -315,10 +315,24 @@ CompileFlags compute_flags(const BuildPlan& plan) {
// ninja-$-escape and shell-quote per token (#234) so an include dir
// whose name contains a space can't silently split into two shell words
// once ninja hands the resolved command line to the shell.
// The one place this file turns a manifest include entry into a path.
// make_preferred: a multi-segment TOML entry like `generated/inc` keeps
// its `/` on MSVC, and the bare `projectRoot / inc` join would be MIXED —
// reaching both the ninja command line and the CDB's arguments (via
// f.cxx → split_flags). Same rule as every other manifest-path ingestion
// point (#390); no-op on POSIX. ONE lambda because the same join is needed
// four times in this function — {include_dirs, include_dirs_after} × {the
// C/C++ token list, the NASM one} — and re-deriving it per site is how the
// two channels drifted apart in the first place.
auto abs_native = [&](const std::filesystem::path& inc) {
auto p = inc.has_root_path() ? inc : (plan.projectRoot / inc);
p.make_preferred();
return p;
};

std::vector<std::string> includeTokens;
for (auto& inc : plan.manifest.buildConfig.includeDirs) {
std::filesystem::path p = inc.has_root_path() ? inc : (plan.projectRoot / inc);
includeTokens.push_back(include_token(d, p));
includeTokens.push_back(include_token(d, abs_native(inc)));
}
// #249: `[build] include_dirs_after` — searched AFTER the toolchain's
// system dirs via -idirafter (gcc+clang), so entries can't shadow
Expand All @@ -327,10 +341,8 @@ CompileFlags compute_flags(const BuildPlan& plan) {
// (documented degradation; clang-MSVC uses the gnu dialect).
const bool msvcInclude = d.includePrefix == std::string_view("/I");
for (auto& inc : plan.manifest.buildConfig.includeDirsAfter) {
std::filesystem::path ip(inc);
std::filesystem::path p = ip.has_root_path() ? ip : (plan.projectRoot / ip);
includeTokens.push_back(
include_token(d, p, msvcInclude ? "/I" : "-idirafter"));
include_token(d, abs_native(inc), msvcInclude ? "/I" : "-idirafter"));
}
std::string include_flags;
for (auto& t : includeTokens) {
Expand Down Expand Up @@ -529,17 +541,22 @@ CompileFlags compute_flags(const BuildPlan& plan) {
// re-spelt with -I regardless of dialect (nasm ≥2.14 inserts a missing
// path separator itself); DWARF debug info exists on ELF only.
if (!plan.nasmPath.empty()) {
// Same abs_native join as the C/C++ channel above — one decision, one
// implementation. Two knock-on effects, both wanted: the entry is now
// spelt with native separators (#390), and the "already rooted?" test
// becomes has_root_path() instead of is_absolute(), so a root-relative
// `/x` entry is left alone here exactly as it is for the C/C++ include
// list. The two predicates only differ on Windows, and only for that
// spelling — where NASM disagreeing with the compiler about the SAME
// `include_dirs` key was the bug, not the feature.
std::string nasm_includes;
for (auto& inc : plan.manifest.buildConfig.includeDirs) {
auto abs = inc.is_absolute() ? inc : (plan.projectRoot / inc);
nasm_includes += " -I" + escape_path(abs);
nasm_includes += " -I" + escape_path(abs_native(inc));
}
// #249: nasm has no system header dirs to defer to — after-dirs
// degrade to plain -I appended at the end.
for (auto& inc : plan.manifest.buildConfig.includeDirsAfter) {
std::filesystem::path ip(inc);
auto abs = ip.is_absolute() ? ip : (plan.projectRoot / ip);
nasm_includes += " -I" + escape_path(abs);
nasm_includes += " -I" + escape_path(abs_native(inc));
}
std::string nasm_debug;
if (prof.debug && plan.nasmFormat.starts_with("elf"))
Expand Down
36 changes: 33 additions & 3 deletions src/build/plan.cppm
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,14 @@ make_plan(const mcpp::manifest::Manifest& manifest,
// simply makes those units uncacheable.
const std::vector<std::filesystem::path>& storeRoots = {});

// Expand one manifest `include_dirs` entry against the project root — the
// #249 consistency join + the expand_dir_glob the dep path uses. Exported
// (like modgraph's glob_literal_prefix) so unit tests can assert its
// native-separator contract directly; see the definition below.
std::vector<std::filesystem::path>
expand_manifest_include_entry(const std::filesystem::path& root,
const std::filesystem::path& inc);

} // namespace mcpp::build

namespace mcpp::build {
Expand Down Expand Up @@ -368,22 +376,42 @@ std::vector<std::string> shared_library_link_flags(
return flags;
}

} // namespace

// #249 consistency fix: expand include_dirs entries with the same
// `expand_dir_glob` the dep path (prepare.cppm) uses, so a main-manifest
// `include_dirs = ["*/include"]` glob works identically here. For a literal
// (wildcard-free) entry expand_dir_glob only returns EXISTING directories,
// whereas this helper historically joined unconditionally — keep the plain
// join as a fallback so an -I for a dir created later (e.g. by a build
// step) isn't silently dropped.
//
// Deliberately OUTSIDE the anonymous namespace: it is exported for its unit
// test (like modgraph's glob_literal_prefix), and the two
// local_include_dirs_*_for_manifest consumers below ride along so a single
// namespace split serves the whole trio.
std::vector<std::filesystem::path>
expand_manifest_include_entry(const std::filesystem::path& root,
const std::filesystem::path& inc)
{
if (inc.is_absolute()) return { inc };
if (inc.is_absolute()) {
// A TOML value like `C:/SDL2/include` keeps its `/` on MSVC — make
// it native so the CDB's -I (via local_include_args) is uniform.
auto n = inc;
n.make_preferred();
return { std::move(n) };
}
const auto glob = inc.generic_string();
auto expanded = mcpp::modgraph::expand_dir_glob(root, glob);
if (expanded.empty() && glob.find('*') == std::string::npos)
expanded.push_back(root / inc);
if (expanded.empty() && glob.find('*') == std::string::npos) {
// Same native-spelling rule for the bare join (see above): `root / p`
// with a multi-segment `generated/inc` is MIXED on MSVC, and this
// fallback exists precisely for dirs like `generated/` that a later
// build step creates — the #390 shape.
auto joined = root / inc;
joined.make_preferred();
expanded.push_back(std::move(joined));
}
return expanded;
}

Expand Down Expand Up @@ -412,6 +440,8 @@ local_include_dirs_after_for_manifest(const std::filesystem::path& root,
return dirs;
}

namespace {

void append_unique_path(std::vector<std::filesystem::path>& out,
std::filesystem::path path)
{
Expand Down
13 changes: 11 additions & 2 deletions src/build/prepare.cppm
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import mcpp.platform.axis;
import mcpp.libs.json;
import mcpp.log;
import mcpp.manifest;
import mcpp.modgraph.glob;
import mcpp.modgraph.graph;
import mcpp.modgraph.scanner;
import mcpp.modgraph.validate;
Expand Down Expand Up @@ -2996,7 +2997,13 @@ prepare_build(bool print_fingerprint,
std::vector<std::filesystem::path> dirs;
for (auto const& inc : manifest.buildConfig.includeDirs) {
if (inc.is_absolute()) {
appendUniquePath(dirs, inc);
// Native spelling: a TOML `C:/SDL2/include` stays mixed on
// MSVC and leaks into the CDB's -I otherwise. Direct
// make_preferred — no generic_string round trip, which can
// throw for names the ANSI codepage cannot spell (mcpp#230).
auto n = inc;
n.make_preferred();
appendUniquePath(dirs, std::move(n));
continue;
}
for (auto& dir : mcpp::modgraph::expand_dir_glob(
Expand All @@ -3016,7 +3023,9 @@ prepare_build(bool print_fingerprint,
std::vector<std::filesystem::path> dirs;
for (auto const& inc : manifest.buildConfig.includeDirsAfter) {
if (inc.is_absolute()) {
appendUniquePath(dirs, inc);
auto n = inc;
n.make_preferred();
appendUniquePath(dirs, std::move(n));
continue;
}
for (auto& dir : mcpp::modgraph::expand_dir_glob(
Expand Down
20 changes: 20 additions & 0 deletions src/modgraph/glob.cppm
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,26 @@ import std;

export namespace mcpp::modgraph {

// Convert a manifest-style path or glob prefix (always spelled with the
// generic `/` separator) to the platform's native spelling.
//
// MSVC's std::filesystem::path preserves the separators of the string it
// was constructed from instead of normalizing them, so wrapping a raw
// `generated/modules` in a path and joining it with `root / p` yields the
// MIXED `C:\...\generated/modules` — and the directory-walk children built
// on top of that stay mixed. `.string()` then carries the mixed form into
// `compile_commands.json` (its `file` / `-c` fields), which CLion refuses
// to parse. Ninja never notices because it renders everything via
// generic_string(); the CDB is the first `.string()` consumer.
//
// POSIX is untouched (`make_preferred()` is a no-op there, and it is also
// safe for already-native Windows input, which never contains `/`).
std::filesystem::path native_path_from_generic(std::string_view s) {
std::filesystem::path p(s);
p.make_preferred();
return p;
}

// Does `candidate` match `glob`, interpreted relative to `root`?
//
// Supports "**" (any number of directory levels) and "*" (within one segment).
Expand Down
47 changes: 40 additions & 7 deletions src/modgraph/scanner.cppm
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,12 @@ std::filesystem::path glob_literal_prefix(std::string_view glob) {
? glob : glob.substr(0, wildcard);
auto slash = literal.find_last_of('/');
if (slash == std::string_view::npos) return {};
return std::filesystem::path(literal.substr(0, slash));
// Native separators, not the raw generic form: MSVC keeps the input's
// `/` verbatim, and `root / p` plus the directory walk then propagate a
// MIXED `root\generated/modules` into every downstream path — which is
// what `compile_commands.json`'s `file` field showed on Windows for
// multi-segment globs. See mcpp::modgraph::native_path_from_generic.
return native_path_from_generic(literal.substr(0, slash));
}

// mcpp#228: `{a,b}` alternation, recursively. Finds the first top-level `{`,
Expand Down Expand Up @@ -442,7 +447,9 @@ std::vector<std::filesystem::path> expand_dir_glob(const std::filesystem::path&
// expand_glob) — include_dirs entries are meant to name one literal
// directory each; a caller wanting alternatives lists multiple entries.
if (glob.find('*') == std::string_view::npos) {
auto p = root / std::filesystem::path(glob);
// Native spelling (see native_path_from_generic — a raw `a/b` would
// come back mixed from .string() on MSVC).
auto p = root / native_path_from_generic(glob);
if (std::filesystem::is_directory(p, ec)) out.push_back(p);
return out;
}
Expand Down Expand Up @@ -494,10 +501,24 @@ namespace {

// has_root_path: leave absolute AND root-relative ("/x" on Windows)
// spellings alone — only genuinely root-less paths are project-relative.
// Both branches normalize to NATIVE separators: a `-Ithird_party/inc` cxxflag
// would otherwise come back as `C:\proj\third_party/inc` on MSVC (path keeps
// the input `/` verbatim) and reach the CDB's arguments via packageCxxflags.
std::string rewrite_rel_copy(const std::string& p, const std::filesystem::path& root) {
std::filesystem::path fp(p);
if (fp.has_root_path()) return p;
return (root / fp).string();
if (fp.has_root_path()) {
// Nothing to re-spell → hand back the ORIGINAL bytes rather than
// round-tripping them through path's narrow conversion, which throws
// std::system_error for names the ANSI codepage cannot express
// (mcpp#230 — see path_matches_glob). A rooted path with no '/' is
// already native on both platform families.
if (p.find('/') == std::string::npos) return p;
fp.make_preferred();
return fp.string();
}
auto joined = root / fp;
joined.make_preferred();
return joined.string();
}

void rewrite_rel(std::string& p, const std::filesystem::path& root) {
Expand Down Expand Up @@ -682,7 +703,14 @@ local_include_dirs_for(const std::filesystem::path& root,
std::vector<std::filesystem::path> dirs;
for (auto const& inc : manifest.buildConfig.includeDirs) {
if (inc.is_absolute()) {
dirs.push_back(inc);
// A TOML value like `C:/SDL2/include` keeps its `/` on MSVC —
// normalize so the CDB's -I comes out native (mixed separators
// break CLion). Direct make_preferred, no generic_string round
// trip: the narrow conversion can throw for names the ANSI
// codepage cannot spell (mcpp#230).
auto n = inc;
n.make_preferred();
dirs.push_back(std::move(n));
continue;
}
for (auto& d : expand_dir_glob(root, inc.generic_string())) {
Expand All @@ -701,7 +729,9 @@ local_include_dirs_after_for(const std::filesystem::path& root,
std::vector<std::filesystem::path> dirs;
for (auto const& inc : manifest.buildConfig.includeDirsAfter) {
if (inc.is_absolute()) {
dirs.push_back(inc);
auto n = inc;
n.make_preferred();
dirs.push_back(std::move(n));
continue;
}
for (auto& d : expand_dir_glob(root, inc.generic_string())) {
Expand Down Expand Up @@ -738,7 +768,10 @@ void scan_one_into(ScanResult& result,
// Literal absolute entry — e.g. a dependency build.mcpp's OUT_DIR
// generated source, which lives OUTSIDE the (possibly read-only)
// package root. No glob expansion; taken as-is when it exists.
if (std::filesystem::path gp(g); gp.is_absolute()) {
// Native spelling: a raw `C:/abs/x.cppm` would stay mixed on MSVC
// (see native_path_from_generic) and leak into the CDB.
auto gp = native_path_from_generic(g);
if (gp.is_absolute()) {
std::error_code aec;
if (std::filesystem::is_regular_file(gp, aec)) all_files.insert(gp);
continue;
Expand Down
Loading
Loading