Skip to content

Commit 5bbefa0

Browse files
committed
fix: watch parent-directory glob inputs in build programs
1 parent 6e3cb97 commit 5bbefa0

8 files changed

Lines changed: 239 additions & 51 deletions

File tree

‎docs/30-build-mcpp.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -560,7 +560,10 @@ int main() {
560560
```
561561

562562
The pattern is relative to the manifest directory and uses the same `*` / `**`
563-
grammar as `sources = [...]`. Its fingerprint is the **sorted set of matching
563+
grammar as `sources = [...]`. A literal directory prefix can leave the package:
564+
`../inputs/**/*.in` watches a sibling directory, including its creation after
565+
the first build. Output and `.git` directories remain excluded, and directory
566+
symlinks are not followed. Its fingerprint is the **sorted set of matching
564567
paths** and nothing else:
565568

566569
- **not contents** — a file whose bytes matter is an ordinary

‎docs/zh/30-build-mcpp.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -481,7 +481,9 @@ int main() {
481481
}
482482
```
483483

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

487489
- **不含内容** —— 字节内容重要的文件本来就该用 `rerun_if_changed` 声明,那条

‎modules/buildmcpp/src/directives.cppm‎

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -936,33 +936,49 @@ std::string glob_fingerprint(const std::filesystem::path& root,
936936
namespace fs = std::filesystem;
937937
std::vector<std::string> hits;
938938
std::error_code ec;
939+
const auto empty = mcpp::toolchain::hash_string("");
940+
const auto prefix = mcpp::modgraph::glob_literal_prefix(pattern);
941+
// Starting at the literal prefix must not bypass the exclusions applied
942+
// during traversal, or target/** and symlink/subdir/** would enter them.
943+
auto start = root;
944+
for (const auto& component : prefix) {
945+
if (component == ".git" || (!outputDirName.empty()
946+
&& component == mcpp::modgraph::native_path_from_generic(outputDirName)))
947+
return empty;
948+
start /= component;
949+
if (fs::is_symlink(start, ec)) return empty;
950+
}
951+
start = start.lexically_normal();
952+
ec.clear();
939953
// skip_permission_denied only: symlinked directories are NOT followed, the
940954
// same rule the source scan uses, so a self-referential link cannot make
941955
// this walk diverge.
942956
fs::recursive_directory_iterator it(
943-
root, fs::directory_options::skip_permission_denied, ec);
944-
if (ec) return {};
957+
start, fs::directory_options::skip_permission_denied, ec);
958+
// A missing input directory is the same empty set as an existing directory
959+
// containing no matches. Its first matching file will invalidate the key.
960+
if (ec) return empty;
945961
for (; it != fs::recursive_directory_iterator(); it.increment(ec)) {
946962
if (ec) break;
947963
const auto& p = it->path();
948964
std::error_code dec;
949965
if (it->is_directory(dec)) {
950-
auto name = p.filename().string();
951-
if (name == ".git" || (!outputDirName.empty() && name == outputDirName)) {
966+
const auto name = p.filename();
967+
if (name == ".git" || (!outputDirName.empty()
968+
&& name == mcpp::modgraph::native_path_from_generic(outputDirName))) {
952969
it.disable_recursion_pending();
953970
continue;
954971
}
955972
if (it->is_symlink(dec)) it.disable_recursion_pending();
956973
continue;
957974
}
958975
if (!mcpp::modgraph::path_matches_glob(p, root, pattern)) continue;
959-
std::string rel;
960-
try {
961-
rel = p.lexically_relative(root).generic_string();
962-
} catch (const std::exception&) {
963-
continue; // unspellable name — see path_matches_glob
976+
auto rel = mcpp::modgraph::try_narrow(p.lexically_relative(root));
977+
if (!rel) {
978+
mcpp::modgraph::note_unnarrowable_path(p);
979+
continue;
964980
}
965-
hits.push_back(std::move(rel));
981+
hits.push_back(std::move(*rel));
966982
}
967983
std::ranges::sort(hits);
968984
std::string joined;
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
#include <gtest/gtest.h>
2+
3+
import std;
4+
import mcpp.build.directives;
5+
import mcpp.toolchain.fingerprint;
6+
7+
namespace dirs = mcpp::build::directives;
8+
9+
namespace {
10+
11+
struct GlobInputs : testing::Test {
12+
std::filesystem::path tree;
13+
std::filesystem::path root;
14+
15+
void SetUp() override {
16+
tree = std::filesystem::temp_directory_path() / "mcpp_parent_glob_inputs";
17+
std::filesystem::remove_all(tree);
18+
root = tree / "workspace" / "app";
19+
std::filesystem::create_directories(root);
20+
}
21+
22+
void TearDown() override {
23+
std::error_code ec;
24+
std::filesystem::remove_all(tree, ec);
25+
}
26+
27+
void write(const std::filesystem::path& relative, std::string_view content = "x") {
28+
const auto path = root / relative;
29+
std::filesystem::create_directories(path.parent_path());
30+
std::ofstream out(path);
31+
out << content;
32+
}
33+
34+
std::string fingerprint(std::string_view pattern) {
35+
return dirs::glob_fingerprint(root, pattern, "target");
36+
}
37+
};
38+
39+
} // namespace
40+
41+
TEST_F(GlobInputs, ParentDirectoryMembershipInvalidatesTheFingerprint) {
42+
write("../inputs/a.in");
43+
const auto one = fingerprint("../inputs/**/*.in");
44+
EXPECT_NE(one, mcpp::toolchain::hash_string(""));
45+
46+
write("../inputs/nested/b.in");
47+
EXPECT_NE(fingerprint("../inputs/**/*.in"), one);
48+
std::filesystem::remove(root / "../inputs/nested/b.in");
49+
EXPECT_EQ(fingerprint("../inputs/**/*.in"), one);
50+
51+
write("../inputs/a.in", "different contents");
52+
write("../inputs/ignored.txt");
53+
write("../unrelated/c.in");
54+
EXPECT_EQ(fingerprint("../inputs/**/*.in"), one);
55+
}
56+
57+
TEST_F(GlobInputs, MultipleParentLevelsAndExactPathsAreWatched) {
58+
const auto before = fingerprint("../../inputs/*.in");
59+
write("../../inputs/a.in");
60+
EXPECT_NE(fingerprint("../../inputs/*.in"), before);
61+
EXPECT_NE(fingerprint("../../inputs/a.in"), before);
62+
std::filesystem::remove(root / "../../inputs/a.in");
63+
EXPECT_EQ(fingerprint("../../inputs/*.in"), before);
64+
}
65+
66+
TEST_F(GlobInputs, MissingLiteralDirectoryDoesNotHideLaterFiles) {
67+
const auto before = fingerprint("../missing/**/*.in");
68+
write("../missing/ignored.txt");
69+
EXPECT_EQ(fingerprint("../missing/**/*.in"), before);
70+
write("../missing/nested/a.in");
71+
EXPECT_NE(fingerprint("../missing/**/*.in"), before);
72+
}
73+
74+
TEST_F(GlobInputs, LiteralPrefixDoesNotBypassExcludedDirectories) {
75+
const auto before = fingerprint("**");
76+
write("target/generated.in");
77+
write(".git/index.in");
78+
write("../inputs/target/generated.in");
79+
write("../inputs/.git/index.in");
80+
EXPECT_EQ(fingerprint("**"), before);
81+
for (const auto pattern : {"target/**", ".git/**", "../inputs/target/**",
82+
"../inputs/.git/**", "../inputs/**"})
83+
EXPECT_EQ(fingerprint(pattern), mcpp::toolchain::hash_string("")) << pattern;
84+
}
85+
86+
TEST_F(GlobInputs, ParentPatternsRetainPathSetSemantics) {
87+
write("../inputs/a.in");
88+
const auto before = fingerprint("../inputs/**");
89+
write("../inputs/a.in", "longer content");
90+
std::filesystem::last_write_time(root / "../inputs/a.in",
91+
std::filesystem::file_time_type::clock::now() + std::chrono::hours(1));
92+
EXPECT_EQ(fingerprint("../inputs/**"), before);
93+
write("../inputs/nested/b.in");
94+
EXPECT_NE(fingerprint("../inputs/**"), before);
95+
}
96+
97+
TEST_F(GlobInputs, DirectorySymlinksAreNotFollowedThroughTheLiteralPrefix) {
98+
write("../real/nested/a.in");
99+
std::error_code ec;
100+
std::filesystem::create_directory_symlink(tree / "workspace" / "real",
101+
root / "../link", ec);
102+
if (ec) GTEST_SKIP() << "Directory symlinks are unavailable: " << ec.message();
103+
EXPECT_EQ(fingerprint("../link/**/*.in"), mcpp::toolchain::hash_string(""));
104+
EXPECT_EQ(fingerprint("../link/nested/*.in"), mcpp::toolchain::hash_string(""));
105+
const auto before = fingerprint("../**/*.in");
106+
write("../real/nested/b.in");
107+
EXPECT_NE(fingerprint("../**/*.in"), before);
108+
}

‎modules/manifest/src/glob.cppm‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,18 @@ std::filesystem::path native_path_from_generic(std::string_view s) {
3434
return p;
3535
}
3636

37+
// The complete literal directory prefix before the first wildcard. Both the
38+
// source scanner and rerun-glob fingerprints use it to choose their walk root,
39+
// including sibling directories such as ../inputs/**/*.in.
40+
std::filesystem::path glob_literal_prefix(std::string_view glob) {
41+
if (!glob.empty() && glob.front() == '!') glob.remove_prefix(1);
42+
const auto wildcard = glob.find_first_of("*?{[");
43+
const auto literal = glob.substr(0, wildcard);
44+
const auto slash = literal.find_last_of('/');
45+
if (slash == std::string_view::npos) return {};
46+
return native_path_from_generic(literal.substr(0, slash));
47+
}
48+
3749
// Whether `s` is well-formed UTF-8: no stray continuation byte, no truncated or
3850
// overlong sequence, no surrogate, nothing above U+10FFFF.
3951
bool is_valid_utf8(std::string_view s);

‎src/build/test_targets.cppm‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export module mcpp.build.test_targets;
44

55
import std;
66
import mcpp.manifest;
7+
import mcpp.modgraph.glob;
78
import mcpp.modgraph.scanner;
89
import mcpp.project;
910

‎src/modgraph/scanner.cppm‎

Lines changed: 0 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -40,13 +40,6 @@ std::vector<std::filesystem::path> expand_glob(const std::filesystem::path& root
4040
std::vector<std::filesystem::path> expand_dir_glob(const std::filesystem::path& root,
4141
std::string_view glob);
4242

43-
// mcpp#225 (test-exposed): pure literal (non-wildcard) directory-prefix
44-
// derivation used to bound expand_glob/expand_dir_glob's walk start point.
45-
// Exported (rather than kept file-local) solely so unit tests can assert its
46-
// behavior directly and deterministically — see its definition below for
47-
// the full contract.
48-
std::filesystem::path glob_literal_prefix(std::string_view glob);
49-
5043
// mcpp#228: desugar brace alternation `{a,b}` into a cartesian product of
5144
// plain globs, e.g. "a/{x,y}/**" -> ["a/x/**", "a/y/**"]. A glob with no `{`
5245
// 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,
458451

459452
} // namespace
460453

461-
// mcpp#225: the literal (non-wildcard) directory prefix of a glob, e.g.
462-
// "src/**/*.cppm" -> "src", "tests/**/*.cpp" -> "tests", "*/include" -> ""
463-
// (wildcard already in the first segment). Used to bound the walk's START
464-
// point in expand_glob/expand_dir_glob instead of always walking from root
465-
// and filtering lexically afterward — path_matches_glob still does the full
466-
// lexical match, so this only narrows WHERE the iterator begins, never
467-
// which files can match. Truncates back to the last complete '/' so a
468-
// partial segment (e.g. "src/pre*fix" -> "src/pre") is never mistaken for a
469-
// real directory name. Conservative about '{' (and '?'/'['): brace-expansion
470-
// globs are desugared into multiple plain globs before ever reaching here
471-
// (mcpp#225 cluster E follow-up), so any of those chars seen here is just a
472-
// segment boundary, never something to interpret. Declared in the exported
473-
// namespace above (not kept file-local) purely so unit tests can assert its
474-
// behavior directly, deterministically, and independently of filesystem
475-
// enumeration order — see Scanner.GlobLiteralPrefixDerivation.
476-
std::filesystem::path glob_literal_prefix(std::string_view glob) {
477-
// Every current caller already strips a leading `!` (exclusion globs)
478-
// before calling expand_glob/expand_dir_glob, but strip it here too —
479-
// defense in depth, and a literal '!' is never a real path component.
480-
if (!glob.empty() && glob.front() == '!') glob.remove_prefix(1);
481-
auto wildcard = glob.find_first_of("*?{[");
482-
std::string_view literal = wildcard == std::string_view::npos
483-
? glob : glob.substr(0, wildcard);
484-
auto slash = literal.find_last_of('/');
485-
if (slash == std::string_view::npos) return {};
486-
// Native separators, not the raw generic form: MSVC keeps the input's
487-
// `/` verbatim, and `root / p` plus the directory walk then propagate a
488-
// MIXED `root\generated/modules` into every downstream path — which is
489-
// what `compile_commands.json`'s `file` field showed on Windows for
490-
// multi-segment globs. See mcpp::modgraph::native_path_from_generic.
491-
return native_path_from_generic(literal.substr(0, slash));
492-
}
493454

494455
// mcpp#228: `{a,b}` alternation, recursively. Finds the first top-level `{`,
495456
// its MATCHING `}` (brace-depth tracked, so a nested group's inner braces
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
#!/usr/bin/env bash
2+
# requires:
3+
# A sibling input directory must invalidate both build.mcpp's cache and the
4+
# project fast path. The generated executable is the observable result.
5+
set -euo pipefail
6+
source "$(dirname "$0")/_host_path.sh"
7+
8+
TMP=$(mktemp -d)
9+
trap 'rm -rf "$TMP"' EXIT
10+
MCPP="${MCPP:-mcpp}"
11+
REGISTRY_HOST=$(host_path "${MCPP_HOME:-$HOME/.mcpp}/registry")
12+
export MCPP_HOME="$TMP/mcpp-home"
13+
mkdir -p "$MCPP_HOME" "$TMP/ws/app/src" "$TMP/logs"
14+
cat > "$MCPP_HOME/config.toml" <<EOF
15+
[xlings]
16+
home = "$REGISTRY_HOST"
17+
EOF
18+
19+
cd "$TMP/ws/app"
20+
cat > mcpp.toml <<'EOF'
21+
[package]
22+
name = "parent-glob"
23+
version = "0.1.0"
24+
25+
[targets.parent-glob]
26+
kind = "bin"
27+
main = "src/main.cpp"
28+
EOF
29+
cat > src/main.cpp <<'EOF'
30+
#include <cstdio>
31+
int count_inputs();
32+
int main() { std::printf("COUNT=%d\n", count_inputs()); }
33+
EOF
34+
cat > build.mcpp <<'EOF'
35+
import std;
36+
import mcpp;
37+
int main() {
38+
namespace fs = std::filesystem;
39+
const fs::path inputs = fs::path(mcpp::manifest_dir()) / "../inputs";
40+
mcpp::rerun_if_changed_glob("../inputs/**/*.in");
41+
int count = 0;
42+
std::error_code ec;
43+
for (const auto& entry : fs::recursive_directory_iterator(inputs, ec))
44+
if (entry.is_regular_file() && entry.path().extension() == ".in") ++count;
45+
const std::string output = std::string(mcpp::out_dir()) + "/count.cpp";
46+
{ std::ofstream out(output); out << "int count_inputs() { return " << count << "; }\n"; }
47+
mcpp::generated(output.c_str());
48+
return 0;
49+
}
50+
EOF
51+
52+
fail() { echo "FAIL: $1"; cat "$TMP/logs"/*.log; exit 1; }
53+
build_count() {
54+
local step=$1 expected=$2
55+
"$MCPP" build > "$TMP/logs/$step.log" 2>&1 || fail "build $step failed"
56+
local output
57+
output=$("$MCPP" run 2>&1 | grep '^COUNT=' | tail -1)
58+
[[ "$output" == "COUNT=$expected" ]] || fail "$step expected COUNT=$expected, got $output"
59+
}
60+
61+
# The literal prefix initially does not exist. Creating the sibling directory
62+
# and its first input must rerun without touching a source or manifest.
63+
build_count missing 0
64+
mkdir -p ../inputs
65+
printf 'a\n' > ../inputs/a.in
66+
build_count appeared 1
67+
mkdir -p ../inputs/nested
68+
printf 'b\n' > ../inputs/nested/b.in
69+
build_count added 2
70+
rm ../inputs/nested/b.in
71+
build_count removed 1
72+
73+
# Glob inputs track membership only. A content edit, a nonmatching file, and
74+
# an unchanged build must preserve the fast path instead of always rerunning.
75+
printf 'different content\n' > ../inputs/a.in
76+
printf 'not an input\n' > ../inputs/ignored.txt
77+
build_count content 1
78+
build_count unchanged 1
79+
for step in content unchanged; do
80+
if grep -qE '^ *build\.mcpp .* ran [0-9]' "$TMP/logs/$step.log"; then
81+
fail "$step reran the build program despite unchanged membership"
82+
fi
83+
done
84+
85+
echo "PASS: parent-directory glob inputs invalidate builds on membership changes"

0 commit comments

Comments
 (0)