Skip to content

Commit 6740b6e

Browse files
committed
surface: a generated name may not be a C++ keyword
Six sites across four files ran the same character filter -- letters, digits and `_` survive, everything else becomes `_`, a leading digit gets a `_` -- and none of them asked whether the result is reserved. `default`, `template`, `operator`, `private` and `union` are valid identifiers to that filter, and `shaders/default/` is an ordinary name for a shader directory. Measured: renaming the fixture's `shaders/a` to `shaders/default` makes the generator write namespace default { and the build fails at `expected identifier before 'default'`, inside a generated file, on a line the author of that directory has never opened. `surface::identifier` does all three transformations in one place, and a reserved result gets a TRAILING underscore -- a leading one is itself reserved at namespace scope, so prefixing would trade one reserved name for another. Namespace segments in `rules-spirv` and `rules-slang` go through it, as does `tools-embed`'s accessor name. The accessor is sanitised where it is EMITTED rather than in each producer. A rule that builds `item::identifier` from a file stem cannot know it has produced `my-shader_comp` or `default` until it reaches the one line that writes the function's name. `accessor_base` and the `_spv` symbols are left alone: both carry fixed affixes and cannot come out reserved, and routing them would rename symbols already published. The fixture could not see any of this: `a` and `b` are ordinary identifiers, so both filters produce the same file. It gains `shaders/default/`, and CI asserts the generated interface contains `namespace default_ {`.
1 parent f20bb11 commit 6740b6e

8 files changed

Lines changed: 118 additions & 24 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,17 @@ jobs:
115115
"$MCPP" run | tee run.log
116116
grep -q '^a/scale.comp: magic=07230203' run.log
117117
grep -q '^b/scale.comp: magic=07230203' run.log
118+
grep -q '^default/scale.comp: magic=07230203' run.log
118119
grep -q '^all ok' run.log
120+
# A DIRECTORY NAMED AFTER A C++ KEYWORD. `a` and `b` are ordinary
121+
# identifiers and cannot tell a name filter that knows the keywords
122+
# from one that does not; without the guard this is
123+
# `namespace default {` and the generated file does not parse.
124+
grep -q '^namespace default_ {' \
125+
target/.build-mcpp/out/spirv/shader_app.shaders.cppm \
126+
|| { echo "FAIL: the keyword directory did not get its trailing underscore"
127+
grep -n namespace target/.build-mcpp/out/spirv/shader_app.shaders.cppm
128+
exit 1; }
119129
# The point of the surface: a consumer names no generated file.
120130
if grep -rn 'scale_comp\.h\|\.inc"' src/; then
121131
echo "FAIL: a consumer source names a generated file"

‎rules/slang.cppm‎

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -267,11 +267,10 @@ inline std::vector<std::string> namespace_of(std::string_view src, std::string_v
267267
for (auto const& part : std::filesystem::path(dir)) {
268268
auto s = part.string();
269269
if (s.empty() || s == "." || s == "/") continue;
270-
std::string seg;
271-
for (char c : s)
272-
seg += (std::isalnum(static_cast<unsigned char>(c)) || c == '_') ? c : '_';
273-
if (std::isdigit(static_cast<unsigned char>(seg.front()))) seg.insert(seg.begin(), '_');
274-
out.push_back(std::move(seg));
270+
// Through the lib root, which is the one place that knows a segment
271+
// may not be a keyword: `shaders/default/` is an ordinary directory
272+
// name and `namespace default {` is not a namespace.
273+
out.push_back(mcpp::plugins::surface::identifier(s, "dir"));
275274
}
276275
return out;
277276
}

‎rules/spirv.cppm‎

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -477,11 +477,10 @@ inline std::vector<std::string> namespace_of(std::string_view src, std::string_v
477477
for (auto const& part : std::filesystem::path(dir)) {
478478
auto s = part.string();
479479
if (s.empty() || s == "." || s == "/") continue;
480-
std::string seg;
481-
for (char c : s)
482-
seg += (std::isalnum(static_cast<unsigned char>(c)) || c == '_') ? c : '_';
483-
if (std::isdigit(static_cast<unsigned char>(seg.front()))) seg.insert(seg.begin(), '_');
484-
out.push_back(std::move(seg));
480+
// Through the lib root, which is the one place that knows a segment
481+
// may not be a keyword: `shaders/default/` is an ordinary directory
482+
// name and `namespace default {` is not a namespace.
483+
out.push_back(mcpp::plugins::surface::identifier(s, "dir"));
485484
}
486485
return out;
487486
}

‎src/plugins.cppm‎

Lines changed: 68 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,68 @@ inline std::string accessor_base(const options& opt, const item& it) {
267267
return s;
268268
}
269269

270+
// A GENERATED NAME THE C++ COMPILER WILL ACCEPT.
271+
//
272+
// Three transformations, and the third is the one every hand-rolled copy of
273+
// this function was missing. Non-identifier characters become `_`; a leading
274+
// digit gets a `_` in front; and a result that is a KEYWORD gets a trailing `_`.
275+
//
276+
// The keyword case is not hypothetical. The first two rules accept `default`,
277+
// `template`, `operator`, `private` and `union` unchanged -- they are valid
278+
// identifiers to a character filter and reserved to the compiler -- and
279+
// `shaders/default/` is an ordinary name for a shader directory. What it
280+
// produced was `namespace default {` in a generated file, and an error naming a
281+
// line its author never wrote.
282+
//
283+
// TRAILING `_`, not a prefix: `_default` is reserved at namespace scope
284+
// (a leading underscore in the global namespace), and prefixing would trade one
285+
// reserved name for another.
286+
//
287+
// The list is the keywords of the standard this collection targets. A word that
288+
// is contextual rather than reserved (`final`, `override`, `import`, `module`)
289+
// is a legal identifier and is left alone.
290+
inline bool is_cxx_keyword(std::string_view w) {
291+
static constexpr std::string_view kWords[] = {
292+
"alignas", "alignof", "and", "and_eq", "asm", "auto", "bitand", "bitor",
293+
"bool", "break", "case", "catch", "char", "char8_t", "char16_t",
294+
"char32_t", "class", "compl", "concept", "const", "consteval",
295+
"constexpr", "constinit", "const_cast", "continue", "co_await",
296+
"co_return", "co_yield", "decltype", "default", "delete", "do", "double",
297+
"dynamic_cast", "else", "enum", "explicit", "export", "extern", "false",
298+
"float", "for", "friend", "goto", "if", "inline", "int", "long",
299+
"mutable", "namespace", "new", "noexcept", "not", "not_eq", "nullptr",
300+
"operator", "or", "or_eq", "private", "protected", "public", "register",
301+
"reinterpret_cast", "requires", "return", "short", "signed", "sizeof",
302+
"static", "static_assert", "static_cast", "struct", "switch",
303+
"template", "this", "thread_local", "throw", "true", "try", "typedef",
304+
"typeid", "typename", "union", "unsigned", "using", "virtual", "void",
305+
"volatile", "wchar_t", "while", "xor", "xor_eq",
306+
};
307+
for (auto k : kWords) if (k == w) return true;
308+
return false;
309+
}
310+
311+
// `fallback` is used when the input sanitises to nothing, which a file named
312+
// only in punctuation does.
313+
//
314+
// INDEXED RATHER THAN A RANGE-FOR over the string, for the reason recorded in
315+
// `mcpp.tools.island`: iterating a `std::string` inside an exported inline
316+
// function makes GCC 16 instantiate its iterator in this BMI, and a consumer's
317+
// build program then fails to compile on `always_inline` in a header naming
318+
// neither this file nor this loop.
319+
inline std::string identifier(std::string_view raw, std::string_view fallback) {
320+
std::string s;
321+
for (std::size_t i = 0; i < raw.size(); ++i) {
322+
const char c = raw[i];
323+
s += (std::isalnum(static_cast<unsigned char>(c)) || c == '_') ? c : '_';
324+
}
325+
if (s.empty()) s = std::string(fallback);
326+
if (!s.empty() && std::isdigit(static_cast<unsigned char>(s.front())))
327+
s.insert(s.begin(), '_');
328+
if (is_cxx_keyword(s)) s += '_';
329+
return s;
330+
}
331+
270332
inline const char* element_type(element e) {
271333
return e == element::word32 ? "unsigned int" : "unsigned char";
272334
}
@@ -354,8 +416,12 @@ inline std::string declarations(std::span<const item> items, const options& opt)
354416
for (auto const& it : items) {
355417
reopen(it.name_space);
356418
const auto base = accessor_base(opt, it);
419+
// THROUGH `identifier`, HERE RATHER THAN IN EACH PRODUCER. This is the
420+
// one line that turns `item::identifier` into something a compiler
421+
// parses, and a rule that builds the field from a file stem cannot know
422+
// it has produced `my-shader_comp` or `default` until it gets here.
357423
s += std::format("inline payload {}() {{ return {{ {}_data(), {}_size() }}; }}\n",
358-
it.identifier, base, base);
424+
identifier(it.identifier, "payload"), base, base);
359425
}
360426
reopen({});
361427
return s;
@@ -646,12 +712,7 @@ inline std::string module_root_from_package() {
646712
std::string leaf{mcpp::package_name()};
647713
if (leaf.empty())
648714
leaf = std::filesystem::path(mcpp::manifest_dir()).filename().string();
649-
std::string s;
650-
for (char c : leaf)
651-
s += (std::isalnum(static_cast<unsigned char>(c)) || c == '_') ? c : '_';
652-
if (s.empty()) s = "app";
653-
if (std::isdigit(static_cast<unsigned char>(s.front()))) s.insert(s.begin(), '_');
654-
return s;
715+
return identifier(leaf, "app");
655716
}
656717

657718
} // namespace mcpp::plugins::surface

‎tests/spirv-module-consumer/mcpp.toml‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@
66
# header anywhere in its own sources;
77
# - two shaders with one stem in two directories are no longer a collision:
88
# `shaders/a/scale.comp` and `shaders/b/scale.comp` land in
9-
# `spirv_module_consumer::shaders::a` and `::b`;
9+
# `shader_app::shaders::a` and `::b`;
10+
# - a directory whose name is a C++ KEYWORD is reached as `default_`. `a` and
11+
# `b` are ordinary identifiers and cannot tell a name filter that knows the
12+
# keywords from one that does not; `shaders/default/` can, and without the
13+
# guard the generator writes `namespace default {`;
1014
# - the module name and the namespace are the same identifier path, derived
1115
# from the package with nothing declared for it.
1216
#
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
#version 450
2+
3+
// The device side of the same computation `examples/09-cuda-kernel` runs on
4+
// CUDA: out = a*x + y. One storage buffer holds all three vectors so the host
5+
// side needs one allocation and one descriptor.
6+
layout(local_size_x = 64) in;
7+
8+
layout(std430, binding = 0) buffer Data { float v[]; };
9+
layout(push_constant) uniform Push { float a; uint n; } push;
10+
11+
void main() {
12+
const uint i = gl_GlobalInvocationID.x;
13+
if (i >= push.n) return;
14+
v[2u * push.n + i] = push.a * v[i] + v[push.n + i];
15+
}

‎tests/spirv-module-consumer/src/main.cpp‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,14 @@ int main() {
2828
bool ok = true;
2929
ok &= check("a/scale.comp", shaders::a::scale_comp());
3030
ok &= check("b/scale.comp", shaders::b::scale_comp());
31+
// A DIRECTORY NAMED AFTER A C++ KEYWORD, reached as `default_`.
32+
//
33+
// `a` and `b` are ordinary identifiers, so neither could tell a name filter
34+
// that knows the keywords from one that does not. `shaders/default/` is an
35+
// ordinary name for a shader directory and `namespace default {` is not a
36+
// namespace: without the trailing underscore the generator writes a file
37+
// that does not parse, and the error names a line nobody wrote.
38+
ok &= check("default/scale.comp", shaders::default_::scale_comp());
3139

3240
// The two shaders are the same source in two directories, so they must
3341
// produce identical modules -- and they must be two distinct objects, not

‎tools/embed.cppm‎

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -89,13 +89,11 @@ struct options {
8989

9090
// ---- internals -------------------------------------------------------------
9191

92+
// The accessor's own name, so a file called `default.bin` must not produce
93+
// `default()`. The lib root owns that decision; this is the one caller that
94+
// needs it here.
9295
inline std::string sanitise(std::string_view stem) {
93-
std::string s;
94-
for (char c : stem)
95-
s += (std::isalnum(static_cast<unsigned char>(c)) || c == '_') ? c : '_';
96-
if (s.empty()) s = "data";
97-
if (std::isdigit(static_cast<unsigned char>(s.front()))) s.insert(s.begin(), '_');
98-
return s;
96+
return mcpp::plugins::surface::identifier(stem, "data");
9997
}
10098

10199
inline std::string default_dir() {

0 commit comments

Comments
 (0)