diff --git a/README.zh-CN.md b/README.zh-CN.md index 5c0fdb1f4..e44c3ae79 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -8,11 +8,11 @@ [![C++23](https://img.shields.io/badge/C%2B%2B-23-blue.svg)](https://en.cppreference.com/w/cpp/23) [![Module](https://img.shields.io/badge/module-ok-green.svg)](https://en.cppreference.com/w/cpp/language/modules) [![License](https://img.shields.io/badge/license-Apache_2.0-blue.svg)](LICENSE) +[![ci](https://github.com/mcpp-community/mcpp/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/mcpp-community/mcpp/actions/workflows/ci.yml) | [文档](docs/zh/) · [快速开始](docs/zh/01-getting-started.md) · [mcpp.toml 指南](docs/zh/04-mcpp-toml.md) · [示例项目](docs/zh/03-examples.md) · [工具链管理](docs/zh/20-toolchains.md) | |:---:| | [包索引 mcpp-index](https://mcpplibs.github.io/mcpp-index/) · [模块化库 mcpplibs](https://github.com/mcpplibs) · [社区论坛](https://forum.d2learn.org/category/20) · [Issues](https://github.com/mcpp-community/mcpp/issues) · [Releases](https://github.com/mcpp-community/mcpp/releases) | -| [![ci](https://github.com/mcpp-community/mcpp/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/mcpp-community/mcpp/actions/workflows/ci.yml) | | 支持的插件 · [mcpp-language-server(mcppls)](https://github.com/Sunrisepeak/mcpp-language-server) —— C++20/23 模块语言服务器,面向 VS Code、Zed、CLion、Neovim、AI Agent(MCP)与 CI |

diff --git a/docs/30-build-mcpp.md b/docs/30-build-mcpp.md index d571e5c98..10a925383 100644 --- a/docs/30-build-mcpp.md +++ b/docs/30-build-mcpp.md @@ -560,7 +560,10 @@ int main() { ``` The pattern is relative to the manifest directory and uses the same `*` / `**` -grammar as `sources = [...]`. Its fingerprint is the **sorted set of matching +grammar as `sources = [...]`. A literal directory prefix can leave the package: +`../inputs/**/*.in` watches a sibling directory, including its creation after +the first build. Output and `.git` directories remain excluded, and directory +symlinks are not followed. Its fingerprint is the **sorted set of matching paths** and nothing else: - **not contents** — a file whose bytes matter is an ordinary diff --git a/docs/zh/30-build-mcpp.md b/docs/zh/30-build-mcpp.md index 8751c6f58..5f1897b1e 100644 --- a/docs/zh/30-build-mcpp.md +++ b/docs/zh/30-build-mcpp.md @@ -481,7 +481,9 @@ int main() { } ``` -模式相对 manifest 目录,`*` / `**` 的文法与 `sources = [...]` 完全一致。它的指纹 +模式相对 manifest 目录,`*` / `**` 的文法与 `sources = [...]` 完全一致。固定目录 +前缀可以指向包外:`../inputs/**/*.in` 监视同级目录,包括首次构建后才创建该目录 +的情况。构建输出目录与 `.git` 仍被排除,也不跟随目录符号链接。它的指纹 是**排序后的匹配路径集合**,不含其他任何东西: - **不含内容** —— 字节内容重要的文件本来就该用 `rerun_if_changed` 声明,那条 diff --git a/modules/buildmcpp/src/directives.cppm b/modules/buildmcpp/src/directives.cppm index 279dc3f1e..0743440a2 100644 --- a/modules/buildmcpp/src/directives.cppm +++ b/modules/buildmcpp/src/directives.cppm @@ -936,19 +936,36 @@ std::string glob_fingerprint(const std::filesystem::path& root, namespace fs = std::filesystem; std::vector hits; std::error_code ec; + const auto empty = mcpp::toolchain::hash_string(""); + const auto prefix = mcpp::modgraph::glob_literal_prefix(pattern); + // Starting at the literal prefix must not bypass the exclusions applied + // during traversal, or target/** and symlink/subdir/** would enter them. + auto start = root; + for (const auto& component : prefix) { + if (component == ".git" || (!outputDirName.empty() + && component == mcpp::modgraph::native_path_from_generic(outputDirName))) + return empty; + start /= component; + if (fs::is_symlink(start, ec)) return empty; + } + start = start.lexically_normal(); + ec.clear(); // skip_permission_denied only: symlinked directories are NOT followed, the // same rule the source scan uses, so a self-referential link cannot make // this walk diverge. fs::recursive_directory_iterator it( - root, fs::directory_options::skip_permission_denied, ec); - if (ec) return {}; + start, fs::directory_options::skip_permission_denied, ec); + // A missing input directory is the same empty set as an existing directory + // containing no matches. Its first matching file will invalidate the key. + if (ec) return empty; for (; it != fs::recursive_directory_iterator(); it.increment(ec)) { if (ec) break; const auto& p = it->path(); std::error_code dec; if (it->is_directory(dec)) { - auto name = p.filename().string(); - if (name == ".git" || (!outputDirName.empty() && name == outputDirName)) { + const auto name = p.filename(); + if (name == ".git" || (!outputDirName.empty() + && name == mcpp::modgraph::native_path_from_generic(outputDirName))) { it.disable_recursion_pending(); continue; } @@ -956,13 +973,12 @@ std::string glob_fingerprint(const std::filesystem::path& root, continue; } if (!mcpp::modgraph::path_matches_glob(p, root, pattern)) continue; - std::string rel; - try { - rel = p.lexically_relative(root).generic_string(); - } catch (const std::exception&) { - continue; // unspellable name — see path_matches_glob + auto rel = mcpp::modgraph::try_narrow(p.lexically_relative(root)); + if (!rel) { + mcpp::modgraph::note_unnarrowable_path(p); + continue; } - hits.push_back(std::move(rel)); + hits.push_back(std::move(*rel)); } std::ranges::sort(hits); std::string joined; diff --git a/modules/buildmcpp/tests/test_glob_inputs.cpp b/modules/buildmcpp/tests/test_glob_inputs.cpp new file mode 100644 index 000000000..cec501204 --- /dev/null +++ b/modules/buildmcpp/tests/test_glob_inputs.cpp @@ -0,0 +1,108 @@ +#include + +import std; +import mcpp.build.directives; +import mcpp.toolchain.fingerprint; + +namespace dirs = mcpp::build::directives; + +namespace { + +struct GlobInputs : testing::Test { + std::filesystem::path tree; + std::filesystem::path root; + + void SetUp() override { + tree = std::filesystem::temp_directory_path() / "mcpp_parent_glob_inputs"; + std::filesystem::remove_all(tree); + root = tree / "workspace" / "app"; + std::filesystem::create_directories(root); + } + + void TearDown() override { + std::error_code ec; + std::filesystem::remove_all(tree, ec); + } + + void write(const std::filesystem::path& relative, std::string_view content = "x") { + const auto path = root / relative; + std::filesystem::create_directories(path.parent_path()); + std::ofstream out(path); + out << content; + } + + std::string fingerprint(std::string_view pattern) { + return dirs::glob_fingerprint(root, pattern, "target"); + } +}; + +} // namespace + +TEST_F(GlobInputs, ParentDirectoryMembershipInvalidatesTheFingerprint) { + write("../inputs/a.in"); + const auto one = fingerprint("../inputs/**/*.in"); + EXPECT_NE(one, mcpp::toolchain::hash_string("")); + + write("../inputs/nested/b.in"); + EXPECT_NE(fingerprint("../inputs/**/*.in"), one); + std::filesystem::remove(root / "../inputs/nested/b.in"); + EXPECT_EQ(fingerprint("../inputs/**/*.in"), one); + + write("../inputs/a.in", "different contents"); + write("../inputs/ignored.txt"); + write("../unrelated/c.in"); + EXPECT_EQ(fingerprint("../inputs/**/*.in"), one); +} + +TEST_F(GlobInputs, MultipleParentLevelsAndExactPathsAreWatched) { + const auto before = fingerprint("../../inputs/*.in"); + write("../../inputs/a.in"); + EXPECT_NE(fingerprint("../../inputs/*.in"), before); + EXPECT_NE(fingerprint("../../inputs/a.in"), before); + std::filesystem::remove(root / "../../inputs/a.in"); + EXPECT_EQ(fingerprint("../../inputs/*.in"), before); +} + +TEST_F(GlobInputs, MissingLiteralDirectoryDoesNotHideLaterFiles) { + const auto before = fingerprint("../missing/**/*.in"); + write("../missing/ignored.txt"); + EXPECT_EQ(fingerprint("../missing/**/*.in"), before); + write("../missing/nested/a.in"); + EXPECT_NE(fingerprint("../missing/**/*.in"), before); +} + +TEST_F(GlobInputs, LiteralPrefixDoesNotBypassExcludedDirectories) { + const auto before = fingerprint("**"); + write("target/generated.in"); + write(".git/index.in"); + write("../inputs/target/generated.in"); + write("../inputs/.git/index.in"); + EXPECT_EQ(fingerprint("**"), before); + for (const auto pattern : {"target/**", ".git/**", "../inputs/target/**", + "../inputs/.git/**", "../inputs/**"}) + EXPECT_EQ(fingerprint(pattern), mcpp::toolchain::hash_string("")) << pattern; +} + +TEST_F(GlobInputs, ParentPatternsRetainPathSetSemantics) { + write("../inputs/a.in"); + const auto before = fingerprint("../inputs/**"); + write("../inputs/a.in", "longer content"); + std::filesystem::last_write_time(root / "../inputs/a.in", + std::filesystem::file_time_type::clock::now() + std::chrono::hours(1)); + EXPECT_EQ(fingerprint("../inputs/**"), before); + write("../inputs/nested/b.in"); + EXPECT_NE(fingerprint("../inputs/**"), before); +} + +TEST_F(GlobInputs, DirectorySymlinksAreNotFollowedThroughTheLiteralPrefix) { + write("../real/nested/a.in"); + std::error_code ec; + std::filesystem::create_directory_symlink(tree / "workspace" / "real", + root / "../link", ec); + if (ec) GTEST_SKIP() << "Directory symlinks are unavailable: " << ec.message(); + EXPECT_EQ(fingerprint("../link/**/*.in"), mcpp::toolchain::hash_string("")); + EXPECT_EQ(fingerprint("../link/nested/*.in"), mcpp::toolchain::hash_string("")); + const auto before = fingerprint("../**/*.in"); + write("../real/nested/b.in"); + EXPECT_NE(fingerprint("../**/*.in"), before); +} diff --git a/modules/manifest/src/glob.cppm b/modules/manifest/src/glob.cppm index 031a9dc7c..a99f9e0b5 100644 --- a/modules/manifest/src/glob.cppm +++ b/modules/manifest/src/glob.cppm @@ -34,6 +34,18 @@ std::filesystem::path native_path_from_generic(std::string_view s) { return p; } +// The complete literal directory prefix before the first wildcard. Both the +// source scanner and rerun-glob fingerprints use it to choose their walk root, +// including sibling directories such as ../inputs/**/*.in. +std::filesystem::path glob_literal_prefix(std::string_view glob) { + if (!glob.empty() && glob.front() == '!') glob.remove_prefix(1); + const auto wildcard = glob.find_first_of("*?{["); + const auto literal = glob.substr(0, wildcard); + const auto slash = literal.find_last_of('/'); + if (slash == std::string_view::npos) return {}; + return native_path_from_generic(literal.substr(0, slash)); +} + // Whether `s` is well-formed UTF-8: no stray continuation byte, no truncated or // overlong sequence, no surrogate, nothing above U+10FFFF. bool is_valid_utf8(std::string_view s); diff --git a/src/build/test_targets.cppm b/src/build/test_targets.cppm index b0808ed08..85fa28e38 100644 --- a/src/build/test_targets.cppm +++ b/src/build/test_targets.cppm @@ -4,6 +4,7 @@ export module mcpp.build.test_targets; import std; import mcpp.manifest; +import mcpp.modgraph.glob; import mcpp.modgraph.scanner; import mcpp.project; diff --git a/src/modgraph/scanner.cppm b/src/modgraph/scanner.cppm index 275f68633..731fd1425 100644 --- a/src/modgraph/scanner.cppm +++ b/src/modgraph/scanner.cppm @@ -40,13 +40,6 @@ std::vector expand_glob(const std::filesystem::path& root std::vector expand_dir_glob(const std::filesystem::path& root, std::string_view glob); -// mcpp#225 (test-exposed): pure literal (non-wildcard) directory-prefix -// derivation used to bound expand_glob/expand_dir_glob's walk start point. -// Exported (rather than kept file-local) solely so unit tests can assert its -// behavior directly and deterministically — see its definition below for -// the full contract. -std::filesystem::path glob_literal_prefix(std::string_view glob); - // mcpp#228: desugar brace alternation `{a,b}` into a cartesian product of // plain globs, e.g. "a/{x,y}/**" -> ["a/x/**", "a/y/**"]. A glob with no `{` // returns itself unchanged (the common case). Multiple and nested groups are @@ -458,38 +451,6 @@ bool is_excluded_walk_dir(const std::filesystem::path& dir, } // namespace -// mcpp#225: the literal (non-wildcard) directory prefix of a glob, e.g. -// "src/**/*.cppm" -> "src", "tests/**/*.cpp" -> "tests", "*/include" -> "" -// (wildcard already in the first segment). Used to bound the walk's START -// point in expand_glob/expand_dir_glob instead of always walking from root -// and filtering lexically afterward — path_matches_glob still does the full -// lexical match, so this only narrows WHERE the iterator begins, never -// which files can match. Truncates back to the last complete '/' so a -// partial segment (e.g. "src/pre*fix" -> "src/pre") is never mistaken for a -// real directory name. Conservative about '{' (and '?'/'['): brace-expansion -// globs are desugared into multiple plain globs before ever reaching here -// (mcpp#225 cluster E follow-up), so any of those chars seen here is just a -// segment boundary, never something to interpret. Declared in the exported -// namespace above (not kept file-local) purely so unit tests can assert its -// behavior directly, deterministically, and independently of filesystem -// enumeration order — see Scanner.GlobLiteralPrefixDerivation. -std::filesystem::path glob_literal_prefix(std::string_view glob) { - // Every current caller already strips a leading `!` (exclusion globs) - // before calling expand_glob/expand_dir_glob, but strip it here too — - // defense in depth, and a literal '!' is never a real path component. - if (!glob.empty() && glob.front() == '!') glob.remove_prefix(1); - auto wildcard = glob.find_first_of("*?{["); - std::string_view literal = wildcard == std::string_view::npos - ? glob : glob.substr(0, wildcard); - auto slash = literal.find_last_of('/'); - if (slash == std::string_view::npos) return {}; - // 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 `{`, // its MATCHING `}` (brace-depth tracked, so a nested group's inner braces diff --git a/tests/e2e/00_fixture_path_hygiene.sh b/tests/e2e/00_fixture_path_hygiene.sh index 1795f2f5d..d1351b084 100755 --- a/tests/e2e/00_fixture_path_hygiene.sh +++ b/tests/e2e/00_fixture_path_hygiene.sh @@ -25,6 +25,20 @@ set -euo pipefail HERE="$(cd "$(dirname "$0")" && pwd)" +source "$HERE/_host_path.sh" + +# CI passes MCPP_HOME in native Windows spelling, not just Git Bash spelling. +# Both must produce paths safe to interpolate into TOML basic strings. +case "$(uname -s)" in + MINGW* | MSYS* | CYGWIN*) + [[ "$(host_path 'C:\Users\runner admin\.mcpp/registry')" == 'C:/Users/runner admin/.mcpp/registry' ]] || { + echo "FAIL: host_path did not normalize a native Windows absolute path" + exit 1 + } + [[ "$(host_path 'C:/Users/runner admin/.mcpp/registry')" == 'C:/Users/runner admin/.mcpp/registry' ]] || exit 1 + ;; +esac +[[ "$(host_path '../relative directory')" == '../relative directory' ]] || exit 1 # A TOML `path` key: either at the start of a line, or inside an inline table # (`{ path = "..." }`). Anchoring this way is what keeps shell assignments such diff --git a/tests/e2e/882_parent_directory_glob_inputs.sh b/tests/e2e/882_parent_directory_glob_inputs.sh new file mode 100755 index 000000000..057820ecb --- /dev/null +++ b/tests/e2e/882_parent_directory_glob_inputs.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# requires: +# A sibling input directory must invalidate both build.mcpp's cache and the +# project fast path. The generated executable is the observable result. +set -euo pipefail +source "$(dirname "$0")/_host_path.sh" + +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT +MCPP="${MCPP:-mcpp}" +REGISTRY_HOST=$(host_path "${MCPP_HOME:-$HOME/.mcpp}/registry") +export MCPP_HOME="$TMP/mcpp-home" +mkdir -p "$MCPP_HOME" "$TMP/ws/app/src" "$TMP/logs" +cat > "$MCPP_HOME/config.toml" < mcpp.toml <<'EOF' +[package] +name = "parent-glob" +version = "0.1.0" + +[targets.parent-glob] +kind = "bin" +main = "src/main.cpp" +EOF +cat > src/main.cpp <<'EOF' +#include +int count_inputs(); +int main() { std::printf("COUNT=%d\n", count_inputs()); } +EOF +cat > build.mcpp <<'EOF' +import std; +import mcpp; +int main() { + namespace fs = std::filesystem; + const fs::path inputs = fs::path(mcpp::manifest_dir()) / "../inputs"; + mcpp::rerun_if_changed_glob("../inputs/**/*.in"); + int count = 0; + std::error_code ec; + for (const auto& entry : fs::recursive_directory_iterator(inputs, ec)) + if (entry.is_regular_file() && entry.path().extension() == ".in") ++count; + const std::string output = std::string(mcpp::out_dir()) + "/count.cpp"; + { std::ofstream out(output); out << "int count_inputs() { return " << count << "; }\n"; } + mcpp::generated(output.c_str()); + return 0; +} +EOF + +fail() { echo "FAIL: $1"; cat "$TMP/logs"/*.log; exit 1; } +build_count() { + local step=$1 expected=$2 + "$MCPP" build > "$TMP/logs/$step.log" 2>&1 || fail "build $step failed" + local output + output=$("$MCPP" run 2>&1 | grep '^COUNT=' | tail -1) + [[ "$output" == "COUNT=$expected" ]] || fail "$step expected COUNT=$expected, got $output" +} + +# The literal prefix initially does not exist. Creating the sibling directory +# and its first input must rerun without touching a source or manifest. +build_count missing 0 +mkdir -p ../inputs +printf 'a\n' > ../inputs/a.in +build_count appeared 1 +mkdir -p ../inputs/nested +printf 'b\n' > ../inputs/nested/b.in +build_count added 2 +rm ../inputs/nested/b.in +build_count removed 1 + +# Glob inputs track membership only. A content edit, a nonmatching file, and +# an unchanged build must preserve the fast path instead of always rerunning. +printf 'different content\n' > ../inputs/a.in +printf 'not an input\n' > ../inputs/ignored.txt +build_count content 1 +build_count unchanged 1 +for step in content unchanged; do + if grep -qE '^ *build\.mcpp .* ran [0-9]' "$TMP/logs/$step.log"; then + fail "$step reran the build program despite unchanged membership" + fi +done + +echo "PASS: parent-directory glob inputs invalidate builds on membership changes" diff --git a/tests/e2e/_host_path.sh b/tests/e2e/_host_path.sh index 165cb8657..aff28632e 100644 --- a/tests/e2e/_host_path.sh +++ b/tests/e2e/_host_path.sh @@ -36,8 +36,9 @@ host_path() { # agree and Windows accepts `/` as a separator. Passing one to `cygpath -m` # would silently ANCHOR it at the current directory — which is not where the # manifest being written lives — so relative input is returned untouched. + # MCPP_HOME can arrive from CI as C:\Users\...; it is absolute too. case "$1" in - /*) ;; + /* | [A-Za-z]:[\\/]*) ;; *) printf '%s' "$1"; return 0 ;; esac case "$(uname -s)" in