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
2 changes: 1 addition & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

<p align="center">
Expand Down
5 changes: 4 additions & 1 deletion docs/30-build-mcpp.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion docs/zh/30-build-mcpp.md
Original file line number Diff line number Diff line change
Expand Up @@ -481,7 +481,9 @@ int main() {
}
```

模式相对 manifest 目录,`*` / `**` 的文法与 `sources = [...]` 完全一致。它的指纹
模式相对 manifest 目录,`*` / `**` 的文法与 `sources = [...]` 完全一致。固定目录
前缀可以指向包外:`../inputs/**/*.in` 监视同级目录,包括首次构建后才创建该目录
的情况。构建输出目录与 `.git` 仍被排除,也不跟随目录符号链接。它的指纹
是**排序后的匹配路径集合**,不含其他任何东西:

- **不含内容** —— 字节内容重要的文件本来就该用 `rerun_if_changed` 声明,那条
Expand Down
36 changes: 26 additions & 10 deletions modules/buildmcpp/src/directives.cppm
Original file line number Diff line number Diff line change
Expand Up @@ -936,33 +936,49 @@ std::string glob_fingerprint(const std::filesystem::path& root,
namespace fs = std::filesystem;
std::vector<std::string> 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;
}
if (it->is_symlink(dec)) it.disable_recursion_pending();
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;
Expand Down
108 changes: 108 additions & 0 deletions modules/buildmcpp/tests/test_glob_inputs.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
#include <gtest/gtest.h>

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);
}
12 changes: 12 additions & 0 deletions modules/manifest/src/glob.cppm
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions src/build/test_targets.cppm
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
39 changes: 0 additions & 39 deletions src/modgraph/scanner.cppm
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,6 @@ std::vector<std::filesystem::path> expand_glob(const std::filesystem::path& root
std::vector<std::filesystem::path> 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
Expand Down Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions tests/e2e/00_fixture_path_hygiene.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
85 changes: 85 additions & 0 deletions tests/e2e/882_parent_directory_glob_inputs.sh
Original file line number Diff line number Diff line change
@@ -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" <<EOF
[xlings]
home = "$REGISTRY_HOST"
EOF

cd "$TMP/ws/app"
cat > 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 <cstdio>
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"
Loading
Loading