From 8fc603c5b3f705e5e7c24ac6cd70ba80587b8323 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Wed, 9 Sep 2026 00:02:04 +0800 Subject: [PATCH] 0.5.0: an island's entry points arrive in the module's own namespace `docs/42` states one rule for both lanes -- the module name and the namespace are one identifier path -- and the shader lane follows it while this generator did not. Every entry point was emitted at global scope, so `import app.kernels` bought a file name and nothing else, and a project with device code in several directories got no help from the names it reached that code through. The names now follow the rule. A root's directories extend the namespace exactly as a payload tree's do, and the C++ side reaches `app::kernels::image::blur` through one import and one flat header. A namespace over a flat symbol is a lookup alias. Measured with clang++ (DPC++ 7.1.0), -std=c++23: two modules re-exporting one `extern "C"` name into two namespaces produce two spellings of one entity, `&a::f == &b::f`. The shader lane does not have this problem because it composes its own symbols; an island's symbol is written by its author and this generator only reads it. So the namespaces are admissible only together with a refusal: one name declared twice in one root is a collision, named with both files. A name then exists in exactly one namespace and cannot lie about what a call resolves to. The API says roots rather than files. `options::roots` names the directories implementations live under and `options::layout_root` names the one whose structure decides where entry points live. A file list's common ancestor moves when a file is added, and a consumer's qualified name would move with it; a root taken from `mcpp::device_sources()` would vanish under `--no-accel` and the namespace would come from the fallback tree instead. Every other root only has to define the names, so a fallback may be one flat file and may be reorganised without renaming anything a consumer wrote. Two drafts were discarded and are recorded in the design: requiring every implementation to sit at the same relative directory, which refused an ordinary flat fallback; and taking the deepest directory among them, which let a refactor of a tree nobody consumes rename what every consumer writes. `options::strip_prefix` emits a short spelling beside the authored name, which stays canonical: an island's symbol is global to the whole program, so an entry point carries a prefix the namespace then repeats. A `constexpr` function pointer costs the artifact nothing -- the pair is one symbol, asserted with nm. `common_base_dir` and `namespace_of` had been written in `rules/spirv.cppm` and again in `rules/slang.cppm`. They move to `mcpp::plugins::names` with the identifier sanitiser, so a directory named `default` or `2d` gets one answer rather than two that agree by inspection. Also: a scan that finds no marked entry point is an error naming the roots rather than a module exporting nothing; the walk is ordered, so the generated files are a function of the tree and not of the filesystem's enumeration; and each root is registered as a glob, so adding a file re-runs the program. The floor does not move. This changes what the package generates, not what it asks the engine for. --- .github/workflows/ci.yml | 267 ++++++-- README.md | 114 +++- mcpp.toml | 2 +- rules/slang.cppm | 41 +- rules/spirv.cppm | 55 +- src/plugins.cppm | 225 ++++--- tests/island-interface/build.mcpp | 43 +- tests/island-interface/mcpp.toml | 7 +- tests/island-interface/src/app.cppm | 26 +- tests/island-interface/src/cpu/ops.c | 34 ++ tests/island-interface/src/cpu/saxpy.c | 30 - .../src/kernels/image/scale.c | 19 + tests/island-interface/src/kernels/saxpy.c | 17 +- tools/island.cppm | 569 ++++++++++++++---- 14 files changed, 1006 insertions(+), 443 deletions(-) create mode 100644 tests/island-interface/src/cpu/ops.c delete mode 100644 tests/island-interface/src/cpu/saxpy.c create mode 100644 tests/island-interface/src/kernels/image/scale.c diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ba57cdf..182b31e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -239,7 +239,13 @@ jobs: # The island here is a `.c` rather than a `.cu`: the generator does not # know what compiler produced the object, and a fixture needing a vendor # toolkit could only run where that toolkit is published. - - name: island interfaces are generated, not written + # + # THE FIXTURE'S SHAPE IS ITSELF A CRITERION. `src/kernels` is the layout + # root and holds a subdirectory; `src/cpu` implements the same two entry + # points in ONE FLAT FILE. A generator that derived a namespace from every + # root would give `island_scale` two of them, and one that required the + # trees to have equal shapes would refuse this layout outright. + - name: island interfaces are generated, namespaced, and not written working-directory: tests/island-interface run: | "$MCPP" build @@ -250,12 +256,45 @@ jobs: test ! -f include/island_interface.kernels.h d=target/.build-mcpp/out/island grep -q 'extern "C" {' "$d/island_interface.kernels.h" - grep -q '^export using ::saxpy_device;' "$d/island_interface.kernels.cppm" - grep -q '^export using ::scale_device;' "$d/island_interface.kernels.cppm" + # THE MODULE NAME AND THE NAMESPACE ARE ONE IDENTIFIER PATH, which is + # the rule docs/42 states for both lanes and the one this generator + # did not follow before 0.5.0: it put every entry point at global + # scope, so `import app.kernels` bought a file name and nothing else. + grep -q '^export namespace island_interface::kernels {' \ + "$d/island_interface.kernels.cppm" + # A directory below the layout root extends it. `image/scale.c` is + # what puts this block in the file, and a generator that ignored + # directories would still pass every other check here. + grep -q '^export namespace island_interface::kernels::image {' \ + "$d/island_interface.kernels.cppm" + grep -q '^using ::island_saxpy;' "$d/island_interface.kernels.cppm" + grep -q '^using ::island_scale;' "$d/island_interface.kernels.cppm" + # TWO, not four. Both roots implement the same two entry points, and a + # merge that appended instead would declare each one twice -- which + # the module rejects as a redefinition and the header silently + # accepts, so the count catches it in both surfaces. + n=$(grep -c '^using ::' "$d/island_interface.kernels.cppm") + [ "$n" = 2 ] || { echo "FAIL: the module re-exports $n names; the two roots declare 2" + cat "$d/island_interface.kernels.cppm"; exit 1; } + # THE SHORT NAME, AND THE ONE IT IS A SPELLING OF. An island's symbol + # is global to the whole program, so an entry point carries a prefix + # the namespace then repeats. Both spellings are emitted and the + # authored one stays canonical. + grep -q '^inline constexpr auto saxpy = island_saxpy;' \ + "$d/island_interface.kernels.cppm" + grep -q '^inline constexpr auto scale = island_scale;' \ + "$d/island_interface.kernels.cppm" + # THE HEADER IS FLAT. It is read by a C compiler, which has no + # namespace to read, and by no other means could the device half + # compile against it. + if grep -q 'namespace' "$d/island_interface.kernels.h"; then + echo "FAIL: the generated header carries a namespace" + grep -n namespace "$d/island_interface.kernels.h"; exit 1 + fi # The module re-exports NAMES: a second copy of a signature is the # thing this generator exists to remove, so its appearance here is a # regression even though it would compile. - if grep -q 'int saxpy_device(' "$d/island_interface.kernels.cppm"; then + if grep -q 'int island_saxpy(' "$d/island_interface.kernels.cppm"; then echo "FAIL: the module restated a signature instead of re-exporting a name" exit 1 fi @@ -263,7 +302,8 @@ jobs: # island reads the generated header through the compiler's # forced-include flag -- so a project using this generator has no # header in its source tree and no line naming one. - if grep -rn '#include' src/main.cpp src/app.cppm src/kernels/saxpy.c src/cpu/saxpy.c; then + if grep -rn '#include' src/main.cpp src/app.cppm src/kernels/saxpy.c \ + src/kernels/image/scale.c src/cpu/ops.c; then echo "FAIL: a source names an include; the generator exists to remove it" exit 1 fi @@ -279,15 +319,11 @@ jobs: # criterion then reports the file for saying what it does. Comment # lines are stripped first, which is the difference between asking # "does this file mention the name" and "does this file use it". - if sed 's://.*::' src/main.cpp | grep -q 'saxpy_device\|scale_device'; then + if sed 's://.*::' src/main.cpp | grep -q 'island_saxpy\|island_scale'; then echo "FAIL: the consumer names a boundary symbol; the seam exists to hide it" - sed 's://.*::' src/main.cpp | grep -n 'saxpy_device\|scale_device' + sed 's://.*::' src/main.cpp | grep -n 'island_saxpy\|island_scale' exit 1 fi - # AND THE CHECK THAT INCLUDE USED TO DO IS STILL THERE. The compiler - # sees the declarations, so a definition whose signature drifted from - # the generated header fails where it was written rather than at the - # link. # THE MECHANISM, NOT THE WORD. `grep 'include'` matched this build # program's own comments and its `mcpp::include_dir` line, so it # would have passed with the forced include removed -- a criterion @@ -298,7 +334,7 @@ jobs: # a static helper and an unmarked external function; a scan that # exported whatever the file contained would still pass every check # above, because both generated files would merely be larger. - for name in internal_device unexported_helper; do + for name in internal_device unexported_helper host_only_helper; do if grep -q "$name" "$d/island_interface.kernels.h" \ || grep -q "$name" "$d/island_interface.kernels.cppm"; then echo "FAIL: $name is not marked and reached the generated boundary" @@ -310,71 +346,216 @@ jobs: grep -q '^#define MCPP_EXPORT_C$' "$d/island_interface.kernels.h" # A signature that wrapped across lines survived: the scan matches # parentheses rather than reading lines. - grep -q 'int scale_device(float a, float\* out, unsigned n);' \ + grep -q 'int island_scale(float a, float\* out, unsigned n);' \ "$d/island_interface.kernels.h" echo "ok: one declaration, two generated artefacts, and only what was marked" - # THE SEAM'S OTHER HALF, AND THE DISAGREEMENT THAT NOTHING ELSE CATCHES. + # ONE ENTITY, TWO SPELLINGS, AND NO SECOND SYMBOL. + # + # The short name is a `constexpr` function pointer, so it costs the + # artifact nothing and `nm` shows the authored name alone. A generator + # that emitted a forwarding FUNCTION instead would pass every check above + # and put a second symbol in every artifact. + - name: the short name is a spelling, not a second function + working-directory: tests/island-interface + run: | + bin=$(find target -name island-interface -type f | head -1) + test -n "$bin" || { echo "FAIL: no artifact"; exit 1; } + n=$(nm "$bin" | grep -c ' island_saxpy$') + [ "$n" = 1 ] || { echo "FAIL: $n definitions of island_saxpy"; exit 1; } + if nm "$bin" | grep -qE ' (T|t|W) saxpy$'; then + echo "FAIL: the short name became a symbol of its own" + nm "$bin" | grep -E ' saxpy$'; exit 1 + fi + echo "ok: one symbol for the pair" + + # THE SEAM'S OTHER HALF, AND THE NAME THAT DOES NOT MOVE WITH IT. # # A seam has two implementations of one `extern "C"` boundary -- a device # island and a host fallback -- and exactly one of them is in any link. - # `build.mcpp` hands `scan` both files unconditionally, because both exist - # on disk in either build and the manifest decides which is compiled. A - # build program that asked `mcpp::accel()` instead would carry a second - # copy of a decision the manifest already states. + # `build.mcpp` names both roots unconditionally, because both exist on + # disk in either build and the manifest decides which is compiled. # - # The CPU leg is not a variant of the step above. It is the build in which - # the device half is ABSENT, so a generator that read only the file it was - # given first would produce an empty boundary and the C++ side would fail - # on an unresolved name. - - name: the same boundary, generated from the half the build selected + # The criterion is stronger than "the CPU leg builds": the generated + # module must be BYTE-IDENTICAL to the device leg's. The consumer's + # qualified name is what would otherwise differ between two builds of one + # project, and it is the failure a namespace derived from every root + # produces. + - name: the same boundary and the same names, from the half the build selected working-directory: tests/island-interface run: | + d=target/.build-mcpp/out/island + cp "$d/island_interface.kernels.cppm" /tmp/device-leg.cppm rm -rf target "$MCPP" build --no-accel > cpu.log 2>&1 || { echo "FAIL: the CPU leg does not build"; tail -25 cpu.log; exit 1; } "$MCPP" run --no-accel | tee cpurun.log grep -q '^out\[0\]=6 out\[1\]=12 out\[2\]=18 out\[3\]=24' cpurun.log grep -q '^all ok' cpurun.log - # ONE set of declarations, not two. Both files mark the same two entry - # points; a merge that appended would declare each twice, which the - # module rejects as a redefinition and the header silently accepts. + diff /tmp/device-leg.cppm "$d/island_interface.kernels.cppm" || { + echo "FAIL: the qualified names differ between the two legs"; exit 1; } + echo "ok: the CPU leg reaches the same qualified names" + + # THE SHAPE COMES FROM ONE STATED ROOT. + # + # Moving the flat fallback under a directory of its own must rename + # nothing. The rejected alternative -- take the DEEPEST directory among an + # entry point's implementations -- passes every other check in this file + # and fails this one: `src/cpu/deep/ops.c` would put both entry points in + # `::deep`, so a refactor of a tree nobody consumes would rename what + # every consumer writes. + - name: reorganising a root that does not supply the shape renames nothing + working-directory: tests/island-interface + run: | d=target/.build-mcpp/out/island - n=$(grep -c '^export using ::' "$d/island_interface.kernels.cppm") - [ "$n" = 2 ] || { - echo "FAIL: the module re-exports $n names; the two halves declare 2" - cat "$d/island_interface.kernels.cppm"; exit 1; } - echo "ok: the CPU leg reaches the same generated boundary" + cp "$d/island_interface.kernels.cppm" /tmp/before-move.cppm + mkdir -p src/cpu/deep && mv src/cpu/ops.c src/cpu/deep/ops.c + rm -rf target + "$MCPP" build --no-accel > moved.log 2>&1 || { + echo "FAIL: the build broke when a fallback moved"; tail -20 moved.log + mv src/cpu/deep/ops.c src/cpu/ops.c; rmdir src/cpu/deep; exit 1; } + rc=0 + diff /tmp/before-move.cppm "$d/island_interface.kernels.cppm" || rc=1 + mv src/cpu/deep/ops.c src/cpu/ops.c; rmdir src/cpu/deep + [ "$rc" = 0 ] || { echo "FAIL: a non-layout root changed a consumer's names"; exit 1; } + rm -f moved.log + echo "ok: the layout root alone decides where an entry point lives" + + # ADDING A FILE HAS TO RE-RUN THE GENERATOR. + # + # Declared inputs are hashed CONTENTS, so a new file changes none of them + # and a program that registered only the files it read would never see the + # new entry point -- the consumer's failure would be an unresolved name in + # a module that silently did not grow. The glob's fingerprint is the + # sorted set of matching paths, which is the question being asked. + - name: a new island file reaches the boundary with nothing else touched + working-directory: tests/island-interface + run: | + d=target/.build-mcpp/out/island + rm -rf target && "$MCPP" build > base.log 2>&1 + before=$(grep -c '^using ::' "$d/island_interface.kernels.cppm") + mkdir -p src/kernels/audio + printf 'MCPP_EXPORT_C\nint island_fft(float* out, unsigned n) { (void)out; (void)n; return 0; }\n' \ + > src/kernels/audio/fft.c + "$MCPP" build > added.log 2>&1 || { + echo "FAIL: the build broke after a file was added"; tail -20 added.log + rm -rf src/kernels/audio; exit 1; } + rc=0 + grep -q '^export namespace island_interface::kernels::audio {' \ + "$d/island_interface.kernels.cppm" || rc=1 + after=$(grep -c '^using ::' "$d/island_interface.kernels.cppm") + rm -rf src/kernels/audio base.log added.log + [ "$rc" = 0 ] && [ "$after" = $((before + 1)) ] || { + echo "FAIL: adding a file left the boundary at $before entry points"; exit 1; } + echo "ok: the glob makes which files are here an input" + + # ONE NAME TWICE IN ONE ROOT IS A COLLISION, NOT A SEAM. + # + # C language linkage does not mangle, so two entry points with one name + # are one symbol whatever namespace each appears in -- measured: two + # modules re-exporting one name into two namespaces give `&a::f == &b::f`. + # A namespace is a lookup alias over a flat symbol, and this refusal is + # what keeps it from promising an isolation the linker does not provide. + - name: two files in one root declaring one name are refused + working-directory: tests/island-interface + run: | + printf 'MCPP_EXPORT_C\nint island_saxpy(float a, const float* x, const float* y, float* out, unsigned n) {\n (void)a; (void)x; (void)y; (void)out; (void)n; return 1;\n}\n' \ + > src/kernels/dup.c + rm -rf target + set +e + "$MCPP" build > dup.log 2>&1 + rc=$? + set -e + rm -f src/kernels/dup.c + [ "$rc" != 0 ] || { echo "FAIL: the collision was accepted"; tail -20 dup.log; exit 1; } + grep -q 'is declared twice in the root' dup.log || { + echo "FAIL: refused, but not for this reason"; tail -20 dup.log; exit 1; } + # Both file names, and the way out: a diagnostic that only says "no" + # leaves the reader to guess that two roots are how a seam is spelled. + grep -q 'src/kernels/dup.c' dup.log + grep -q 'src/kernels/saxpy.c' dup.log + grep -q 'in two roots' dup.log + rm -f dup.log + echo "ok: refused, naming both files and the arrangement that is not a collision" + + # A SHORT NAME IS A SECOND SPELLING OF ONE ENTRY POINT, NOT A SHARED ONE. + # + # Stripping a prefix can land on a name that is already taken. The + # generated module would then declare one name twice, and the error would + # name a generated file rather than the two entry points that produced it. + - name: a short name that collides with an authored one is refused + working-directory: tests/island-interface + run: | + printf 'MCPP_EXPORT_C\nint saxpy(float a, float* out, unsigned n) { (void)a; (void)out; (void)n; return 1; }\n' \ + > src/kernels/collide.c + rm -rf target + set +e + "$MCPP" build > collide.log 2>&1 + rc=$? + set -e + rm -f src/kernels/collide.c + [ "$rc" != 0 ] || { echo "FAIL: the short-name collision was accepted" + tail -20 collide.log; exit 1; } + grep -q 'both reach' collide.log || { + echo "FAIL: refused, but not for this reason"; tail -20 collide.log; exit 1; } + rm -f collide.log + echo "ok: refused, naming both entry points and the prefix" + + # A ROOT THAT YIELDS NOTHING IS AN ERROR, NOT AN EMPTY MODULE. + # + # A misspelled root or a marker that never arrived would otherwise produce + # a module exporting nothing, and the failure would surface as an + # unresolved name in a consumer three files away. + - name: roots holding no marker are refused, naming the roots + working-directory: tests/island-interface + run: | + cp build.mcpp /tmp/build.mcpp.bak + sed -i 's|opt.produced_by = "the island-interface fixture";|&\n opt.marker = "MCPP_NOT_THE_MARKER";|' build.mcpp + grep -q 'MCPP_NOT_THE_MARKER' build.mcpp || { + echo "FAIL: the fixture was not perturbed; this step would assert nothing" + cp /tmp/build.mcpp.bak build.mcpp; exit 1; } + rm -rf target + set +e + "$MCPP" build > empty.log 2>&1 + rc=$? + set -e + cp /tmp/build.mcpp.bak build.mcpp + [ "$rc" != 0 ] || { echo "FAIL: an empty scan was accepted"; tail -20 empty.log; exit 1; } + grep -q 'no entry point marked' empty.log || { + echo "FAIL: refused, but not for this reason"; tail -20 empty.log; exit 1; } + grep -q 'src/kernels' empty.log + rm -f empty.log + echo "ok: refused, naming the marker and the roots" - # AND THE REVERSE LEG, WHICH IS THE POINT OF SCANNING BOTH. + # AND THE REVERSE LEG, WHICH IS THE POINT OF READING EVERY ROOT. # - # C language linkage does not mangle and the two halves are never in one - # link, so two declarations of one name that disagree produce a clean + # C language linkage does not mangle and two implementations are never in + # one link, so two declarations of one name that disagree produce a clean # build and an artifact that reads its arguments by whichever signature it # happened to be compiled with. `scan` is the only place both texts exist # at once, so it is the only place that can refuse. - - name: two halves that disagree are refused where both texts exist + - name: two implementations that disagree are refused where both texts exist working-directory: tests/island-interface run: | - cp src/cpu/saxpy.c /tmp/cpu_saxpy.bak - sed -i 's/^int scale_device(float a, float\* out, unsigned n) {/int scale_device(float a, float* out, double n) {/' \ - src/cpu/saxpy.c - grep -q 'double n' src/cpu/saxpy.c || { + cp src/cpu/ops.c /tmp/cpu_ops.bak + sed -i 's/^int island_scale(float a, float\* out, unsigned n) {/int island_scale(float a, float* out, double n) {/' \ + src/cpu/ops.c + grep -q 'double n' src/cpu/ops.c || { echo "FAIL: the fixture was not perturbed; this step would assert nothing" - cp /tmp/cpu_saxpy.bak src/cpu/saxpy.c; exit 1; } + cp /tmp/cpu_ops.bak src/cpu/ops.c; exit 1; } rm -rf target set +e "$MCPP" build > neg.log 2>&1 rc=$? set -e - cp /tmp/cpu_saxpy.bak src/cpu/saxpy.c + cp /tmp/cpu_ops.bak src/cpu/ops.c [ "$rc" != 0 ] || { echo "FAIL: the disagreement was accepted"; tail -20 neg.log; exit 1; } grep -q 'declare it differently' neg.log || { echo "FAIL: refused, but not for this reason"; tail -20 neg.log; exit 1; } # Both file names, because a diagnostic naming one leaves the reader # to find the other. - grep -q 'src/kernels/saxpy.c' neg.log - grep -q 'src/cpu/saxpy.c' neg.log + grep -q 'src/kernels/image/scale.c' neg.log + grep -q 'src/cpu/ops.c' neg.log echo "ok: refused, naming both definitions and both signatures" # And it builds again once they agree, so this step cannot leave a # fixture that refuses everything. diff --git a/README.md b/README.md index 87fa346..474333b 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ imports each one from `build.mcpp` under the module name the member declares. ```toml [build-dependencies.mcpp] -plugins = { version = "0.3.0", features = ["rules-spirv"], host-module = true } +plugins = { version = "0.5.0", features = ["rules-spirv"], host-module = true } ``` `[build-dependencies]`, not `[dependencies]`. The two keys answer separate @@ -61,7 +61,7 @@ A project names the rule and nothing else: ```toml [build-dependencies.mcpp] -plugins = { version = "0.3.0", features = ["rules-cuda"], host-module = true } +plugins = { version = "0.5.0", features = ["rules-cuda"], host-module = true } ``` The payloads each rule drives are declared **here**, under the feature that @@ -142,6 +142,9 @@ nothing enforcing it, which is the fragility the engine fix removes. A file renamed for a reason nobody can see is a defect waiting for the rename that looks harmless. +0.5.0 does not move it. Naming an island's entry points is a change to what this +package generates, not to what it asks the engine for. + The previous shared floor was 2026.9.7.1, the release that reads `device_extensions` and `rule_module`, reports `[language] modules` and the package's own name to a build program, writes the build program a declared rule @@ -348,10 +351,13 @@ int saxpy_device(float a, const float* x, const float* y, float* out, unsigned n ```cpp // build.mcpp mcpp::tools::island::options opt; -opt.module_name = "myapp.kernels"; -opt.out_dir = std::string(mcpp::out_dir()) + "/island"; +opt.module_name = "myapp.kernels"; +opt.out_dir = std::string(mcpp::out_dir()) + "/island"; +opt.roots = { root + "/src/backends/cuda", root + "/src/backends/cpu" }; +opt.layout_root = root + "/src/backends/cuda"; // default: roots.front() +opt.strip_prefix = "myapp_"; // optional short spelling -const auto entries = mcpp::tools::island::scan(islands, opt); +const auto entries = mcpp::tools::island::scan(opt); const auto out = mcpp::tools::island::emit(*entries, opt); mcpp::include_dir(out->include_dir.c_str()); mcpp::generated(out->interface_file.c_str()); @@ -361,6 +367,52 @@ Two files come out of that one marked declaration: the `extern "C"` header the device translation unit includes, guards and `__cplusplus` dance included, and the module the C++ side imports. +**The names arrive in the module's own namespace (0.5.0).** `docs/42` states one +rule for both lanes -- the module name and the namespace are one identifier path +-- and this generator did not follow it: every entry point was at global scope, +so `import myapp.kernels` bought a file name and nothing else. It follows it +now, and a directory below the layout root extends the path exactly as a payload +tree's does: + +``` +src/backends/cuda/image/blur.cu myapp_blur -> myapp::kernels::image::myapp_blur +src/backends/cuda/saxpy.cu myapp_saxpy -> myapp::kernels::myapp_saxpy +``` + +**A root is a tree, and one of them supplies the shape.** `options::roots` names +the directories implementations live under; `options::layout_root` names the one +whose directory structure decides where entry points live, and defaults to the +first. Every other root only has to define the names, so a fallback tree may be +one flat file or six directories and may be reorganised without renaming +anything a consumer wrote. This is a naming role and not a rank: every root +compiles, links, and is equally a backend. + +A file list would not do: its common ancestor moves when a file is added, and a +consumer's qualified name would move with it. Roots also must not come from +`mcpp::device_sources()`, which `accel` narrows to nothing under `--no-accel` -- +the device tree would vanish and the namespace would come from the fallback. + +`scan` registers every file it reads as a declared input and each root as a +glob, so **adding** a file re-runs the program. Declared inputs are hashed +contents, and a new file changes none of them. + +**A short spelling, when the prefix repeats the namespace.** An island's symbol +is global to the whole program, so an entry point carries a package prefix +whether or not it sits in a namespace. `options::strip_prefix` emits a second +spelling beside the first: + +```cpp +export namespace myapp::kernels::image { +using ::myapp_blur; // the authored name; this is the symbol +inline constexpr auto blur = myapp_blur; // the short name, for the call site +} +``` + +Both are exported and the authored one stays canonical -- it is what `nm`, a +link error, a profiler and `dlsym` show. A `constexpr` function pointer costs +the artifact nothing: the pair is one symbol. A short name that collides with +another entry point's name in the same namespace is refused, naming both. + The C++ side is usually a **seam module** of the project rather than a consumer directly: `app.cppm` imports the generated module and turns pointers and a count back into spans, and it is the one place a backend can be exchanged. That means @@ -369,10 +421,11 @@ build directory during the same build; the ordering comes from the scan seeing the import, and nothing has to be declared for it. **Three layers, and each overrides the one above.** `scan` reads the marked -declarations out of the island, which puts the signature beside the definition; -`emit` takes a list directly, for entry points a scan cannot see; and a project -that wants neither writes its own header and its own module wrapper. The default -is the one that keeps the signature in one place. +declarations out of the roots, which puts the signature beside the definition; +`island::declared` builds an entry from a declaration the scan cannot see, for +`emit` to take directly; and a project that wants neither writes its own header +and its own module wrapper. The default is the one that keeps the signature in +one place. **The marker selects.** An island has internal functions, and a generator that exported whatever the file contained would make the boundary an accident of the @@ -425,29 +478,30 @@ where it was written rather than at the link: src/kernels/saxpy.c:22:5: error: conflicting types for 'scale_device' ``` -**A seam has two halves, and `scan` is where they are compared.** A device -island and a host fallback implement one `extern "C"` boundary, and exactly one -of them is in any link. A build program hands `scan` both, unconditionally -- -both files exist on disk in either build, and which one is compiled is the -manifest's decision rather than a condition the build program repeats: +**Two checks, and they answer different questions.** -```cpp -const std::vector islands{ - std::string(mcpp::manifest_dir()) + "/src/kernels/saxpy.cu", - std::string(mcpp::manifest_dir()) + "/src/cpu/saxpy.cpp", -}; -``` +One name declared twice in ONE root is a collision. C language linkage does not +mangle, so those are one symbol, and a namespace that appeared to separate them +would promise an isolation the linker does not provide -- measured: two modules +re-exporting one `extern "C"` name into two namespaces give `&a::f == &b::f`. +It is refused, naming both files and saying that two implementations of one +entry point belong in two roots. That refusal is what makes the namespaces +honest: a name exists in exactly one of them. -Entries are merged by name, so the two halves produce one set of declarations. -Two definitions of one name that declare it **differently** are refused, naming -both files and both signatures: +One name in SEVERAL roots is one entry point implemented several times, which is +the ordinary shape of a seam -- a device island and a host fallback, exactly one +of them in any link. Every root is read unconditionally, because all of them +exist on disk in either build and which one is compiled is the manifest's +decision rather than a condition the build program repeats. The declarations +must then agree verbatim, and two that declare it **differently** are refused, +naming both files and both signatures: ``` -mcpp.tools.island: two definitions of `scale_device` declare it differently. - src/kernels/saxpy.c - int scale_device(float a, float* out, unsigned n) - src/cpu/saxpy.c - int scale_device(float a, float* out, double n) +mcpp.tools.island: two definitions of `island_scale` declare it differently. + src/kernels/image/scale.c + int island_scale(float a, float* out, unsigned n) + src/cpu/ops.c + int island_scale(float a, float* out, double n) C language linkage does not mangle, so these never meet at the link: whichever one is in the artifact reads its arguments by its own signature. ``` @@ -458,6 +512,10 @@ build with disagreeing halves is clean and the artifact reads its arguments by whichever signature it was compiled with. `scan` is the only point at which both texts exist at once. +**A root that yields nothing is an error.** A misspelled path or a marker that +never arrived would otherwise produce a module exporting nothing, and the +failure would surface as an unresolved name in a consumer three files away. + **The declaration still exists once.** Without this, a project writes it twice -- in a header, and again wherever the C++ side reaches it. C language linkage does not mangle, so two copies that disagree are one symbol: the link is clean and diff --git a/mcpp.toml b/mcpp.toml index fa62ca2..9005ef3 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,7 +1,7 @@ [package] name = "plugins" namespace = "mcpp" -version = "0.4.0" +version = "0.5.0" description = "Official mcpp build plugins: rule packages under mcpp.rules.*, build-time utilities under mcpp.tools.*, each member selected by a feature" license = "Apache-2.0" authors = ["mcpp-community"] diff --git a/rules/slang.cppm b/rules/slang.cppm index 094bc3d..0695e28 100644 --- a/rules/slang.cppm +++ b/rules/slang.cppm @@ -240,41 +240,12 @@ inline std::vector device_shaders() { // ─── Naming ──────────────────────────────────────────────────────────────── -// `shaders/post/tone.slang` under a base of `shaders` -> `{"post"}`. The same -// derivation `mcpp.rules.spirv` uses, because a project with both must not have -// to learn two. -inline std::string common_base_dir(std::span shaders) { - std::vector prefix; - bool first = true; - for (auto const& src : shaders) { - std::vector segs; - for (auto const& part : std::filesystem::path(src).parent_path()) - if (auto s = part.string(); !s.empty() && s != ".") segs.push_back(s); - if (first) { prefix = std::move(segs); first = false; continue; } - std::size_t keep = 0; - while (keep < prefix.size() && keep < segs.size() && prefix[keep] == segs[keep]) ++keep; - prefix.resize(keep); - } - std::string out; - for (auto const& s : prefix) { if (!out.empty()) out += '/'; out += s; } - return out; -} - -inline std::vector namespace_of(std::string_view src, std::string_view base) { - std::vector out; - auto dir = std::filesystem::path(src).parent_path().string(); - if (!base.empty() && dir.size() >= base.size() && dir.compare(0, base.size(), base) == 0) - dir.erase(0, base.size()); - for (auto const& part : std::filesystem::path(dir)) { - auto s = part.string(); - if (s.empty() || s == "." || s == "/") continue; - // Through the lib root, which is the one place that knows a segment - // may not be a keyword: `shaders/default/` is an ordinary directory - // name and `namespace default {` is not a namespace. - out.push_back(mcpp::plugins::surface::identifier(s, "dir")); - } - return out; -} +// The path-to-namespace derivations come from the lib root. They were written +// here and again in the other rule that has a namespaced surface; one function +// is what makes a directory named `default` get one answer rather than two that +// agree by inspection. +using mcpp::plugins::names::common_base_dir; +using mcpp::plugins::names::namespace_of; // The array the embedded output declares. The namespace path is part of it for // the reason `mcpp.rules.spirv` records: one translation unit includes every diff --git a/rules/spirv.cppm b/rules/spirv.cppm index 1f8e145..b8d7c5a 100644 --- a/rules/spirv.cppm +++ b/rules/spirv.cppm @@ -440,55 +440,12 @@ inline std::string symbol_of(std::string_view stem, std::string_view stage) { return symbol_of(std::span{}, stem, stage); } -// THE BASE DIRECTORY IS DERIVED, NOT ASKED FOR. -// -// A shader's namespace comes from where it sits relative to the tree the -// project globbed, so something has to say where that tree starts. Asking the -// project would put a second spelling of the glob in the manifest, and the two -// would disagree the first time a glob moved. The shallowest directory every -// shader shares is the same answer without the second spelling: for -// `shaders/*.comp` it is `shaders` and every namespace is empty; for -// `shaders/a/x.comp` and `shaders/b/y.comp` it is still `shaders`, and the two -// land in `::a` and `::b`. -// -// A single shader has no common prefix with anything, so its own directory is -// the base and its namespace is empty -- which is the same answer the general -// case gives once a second shader appears beside it. -inline std::string common_base_dir(std::span shaders) { - std::vector prefix; - bool first = true; - for (auto const& src : shaders) { - std::vector segs; - for (auto const& part : std::filesystem::path(src).parent_path()) - if (auto s = part.string(); !s.empty() && s != ".") segs.push_back(s); - if (first) { prefix = std::move(segs); first = false; continue; } - std::size_t keep = 0; - while (keep < prefix.size() && keep < segs.size() && prefix[keep] == segs[keep]) ++keep; - prefix.resize(keep); - } - std::string out; - for (auto const& s : prefix) { if (!out.empty()) out += '/'; out += s; } - return out; -} - -// The namespace segments a shader sits in, below the group's own: the path from -// the base directory to the shader, sanitised one segment at a time. `..` cannot -// appear, because the base is a prefix of every shader by construction. -inline std::vector namespace_of(std::string_view src, std::string_view base) { - std::vector out; - auto dir = std::filesystem::path(src).parent_path().string(); - if (!base.empty() && dir.size() >= base.size() && dir.compare(0, base.size(), base) == 0) - dir.erase(0, base.size()); - for (auto const& part : std::filesystem::path(dir)) { - auto s = part.string(); - if (s.empty() || s == "." || s == "/") continue; - // Through the lib root, which is the one place that knows a segment - // may not be a keyword: `shaders/default/` is an ordinary directory - // name and `namespace default {` is not a namespace. - out.push_back(mcpp::plugins::surface::identifier(s, "dir")); - } - return out; -} +// The path-to-namespace derivations come from the lib root. They were written +// here and again in the other rule that has a namespaced surface; one function +// is what makes a directory named `default` get one answer rather than two that +// agree by inspection. +using mcpp::plugins::names::common_base_dir; +using mcpp::plugins::names::namespace_of; // NEWLINE-SEPARATED, not `;`. A path may contain a semicolon and cannot // contain a newline, which is why the engine chose it — and why a splitter diff --git a/src/plugins.cppm b/src/plugins.cppm index 7da9170..5f96b9c 100644 --- a/src/plugins.cppm +++ b/src/plugins.cppm @@ -49,10 +49,149 @@ export namespace mcpp::plugins { // // One package, one version: the number lives in mcpp.toml, and the CI step // `the collection states its own version` compares the two. -inline constexpr std::string_view version = "0.4.0"; +inline constexpr std::string_view version = "0.5.0"; } // namespace mcpp::plugins +// mcpp::plugins::names -- the derivations that turn a path into a C++ name. +// +// THESE ARE SHARED BECAUSE THEY WERE COPIED. `common_base_dir` and +// `namespace_of` were written in `rules/spirv.cppm` and written again in +// `rules/slang.cppm`, and `mcpp.tools.island` is the third caller needing the +// same answers. Two copies that agree today are still two copies: a directory +// named `default` or `2d` has to get ONE answer, and one function is how that +// is guaranteed rather than two files that happen to say the same thing. +export namespace mcpp::plugins::names { + +// A GENERATED NAME THE C++ COMPILER WILL ACCEPT. +// +// Three transformations, and the third is the one every hand-rolled copy of +// this function was missing. Non-identifier characters become `_`; a leading +// digit gets a `_` in front; and a result that is a KEYWORD gets a trailing `_`. +// +// The keyword case is not hypothetical. The first two rules accept `default`, +// `template`, `operator`, `private` and `union` unchanged -- they are valid +// identifiers to a character filter and reserved to the compiler -- and +// `shaders/default/` is an ordinary name for a shader directory. What it +// produced was `namespace default {` in a generated file, and an error naming a +// line its author never wrote. +// +// TRAILING `_`, not a prefix: `_default` is reserved at namespace scope +// (a leading underscore in the global namespace), and prefixing would trade one +// reserved name for another. +// +// The list is the keywords of the standard this collection targets. A word that +// is contextual rather than reserved (`final`, `override`, `import`, `module`) +// is a legal identifier and is left alone. +inline bool is_cxx_keyword(std::string_view w) { + static constexpr std::string_view kWords[] = { + "alignas", "alignof", "and", "and_eq", "asm", "auto", "bitand", "bitor", + "bool", "break", "case", "catch", "char", "char8_t", "char16_t", + "char32_t", "class", "compl", "concept", "const", "consteval", + "constexpr", "constinit", "const_cast", "continue", "co_await", + "co_return", "co_yield", "decltype", "default", "delete", "do", "double", + "dynamic_cast", "else", "enum", "explicit", "export", "extern", "false", + "float", "for", "friend", "goto", "if", "inline", "int", "long", + "mutable", "namespace", "new", "noexcept", "not", "not_eq", "nullptr", + "operator", "or", "or_eq", "private", "protected", "public", "register", + "reinterpret_cast", "requires", "return", "short", "signed", "sizeof", + "static", "static_assert", "static_cast", "struct", "switch", + "template", "this", "thread_local", "throw", "true", "try", "typedef", + "typeid", "typename", "union", "unsigned", "using", "virtual", "void", + "volatile", "wchar_t", "while", "xor", "xor_eq", + }; + for (auto k : kWords) if (k == w) return true; + return false; +} + +// `fallback` is used when the input sanitises to nothing, which a file named +// only in punctuation does. +// +// INDEXED RATHER THAN A RANGE-FOR over the string, for the reason recorded in +// `mcpp.tools.island`: iterating a `std::string` inside an exported inline +// function makes GCC 16 instantiate its iterator in this BMI, and a consumer's +// build program then fails to compile on `always_inline` in a header naming +// neither this file nor this loop. +inline std::string identifier(std::string_view raw, std::string_view fallback) { + std::string s; + for (std::size_t i = 0; i < raw.size(); ++i) { + const char c = raw[i]; + s += (std::isalnum(static_cast(c)) || c == '_') ? c : '_'; + } + if (s.empty()) s = std::string(fallback); + if (!s.empty() && std::isdigit(static_cast(s.front()))) + s.insert(s.begin(), '_'); + if (is_cxx_keyword(s)) s += '_'; + return s; +} + + +// ---- internals ------------------------------------------------------------- + +inline std::vector split_module_name(std::string_view name) { + std::vector out; + for (std::size_t i = 0; i <= name.size();) { + auto dot = name.find('.', i); + auto one = dot == std::string_view::npos ? name.substr(i) : name.substr(i, dot - i); + if (!one.empty()) out.emplace_back(one); + if (dot == std::string_view::npos) break; + i = dot + 1; + } + return out; +} + +// THE BASE DIRECTORY IS DERIVED, NOT ASKED FOR. +// +// A payload's namespace comes from where it sits relative to the tree the +// project globbed, so something has to say where that tree starts. Asking the +// project would put a second spelling of the glob in the manifest, and the two +// would disagree the first time a glob moved. The shallowest directory every +// path shares is the same answer without the second spelling: for +// `shaders/*.comp` it is `shaders` and every namespace is empty; for +// `shaders/a/x.comp` and `shaders/b/y.comp` it is still `shaders`, and the two +// land in `::a` and `::b`. +// +// A single path has no common prefix with anything, so its own directory is the +// base and its namespace is empty -- which is the same answer the general case +// gives once a second file appears beside it. +inline std::string common_base_dir(std::span paths) { + std::vector prefix; + bool first = true; + for (auto const& src : paths) { + std::vector segs; + for (auto const& part : std::filesystem::path(src).parent_path()) + if (auto s = part.string(); !s.empty() && s != ".") segs.push_back(s); + if (first) { prefix = std::move(segs); first = false; continue; } + std::size_t keep = 0; + while (keep < prefix.size() && keep < segs.size() && prefix[keep] == segs[keep]) ++keep; + prefix.resize(keep); + } + std::string out; + for (auto const& s : prefix) { if (!out.empty()) out += '/'; out += s; } + return out; +} + +// The namespace segments a file sits in, below the group's own: the path from +// the base directory to the file, sanitised one segment at a time. `..` cannot +// appear, because the base is a prefix of every path by construction. +inline std::vector namespace_of(std::string_view src, std::string_view base) { + std::vector out; + auto dir = std::filesystem::path(src).parent_path().string(); + if (!base.empty() && dir.size() >= base.size() && dir.compare(0, base.size(), base) == 0) + dir.erase(0, base.size()); + for (auto const& part : std::filesystem::path(dir)) { + auto s = part.string(); + if (s.empty() || s == "." || s == "/") continue; + // `shaders/default/` is an ordinary directory name and + // `namespace default {` is not a namespace. + out.push_back(identifier(s, "dir")); + } + return out; +} + +} // namespace mcpp::plugins::names + + // mcpp.plugins.surface -- the interface a consumer names for an embedded payload. // // WHY THIS IS SHARED RATHER THAN PER-RULE. @@ -116,6 +255,14 @@ inline constexpr std::string_view version = "0.4.0"; // and every consumer reaches the same array through the accessor. export namespace mcpp::plugins::surface { +// The name derivations live in `mcpp::plugins::names`. They are reachable under +// this namespace as well, because every rule in this collection already spells +// them this way -- one definition under two spellings is not two definitions. +using names::identifier; +using names::split_module_name; +using names::common_base_dir; +using names::namespace_of; + // How a consumer names the payloads. // // `module_` is the default where the project builds C++ modules, and @@ -287,20 +434,6 @@ struct emitted { std::string include_dir; }; -// ---- internals ------------------------------------------------------------- - -inline std::vector split_module_name(std::string_view name) { - std::vector out; - for (std::size_t i = 0; i <= name.size();) { - auto dot = name.find('.', i); - auto one = dot == std::string_view::npos ? name.substr(i) : name.substr(i, dot - i); - if (!one.empty()) out.emplace_back(one); - if (dot == std::string_view::npos) break; - i = dot + 1; - } - return out; -} - // The linker symbol an accessor carries. It is derived from the module name and // the payload's full namespace path rather than from the identifier alone, // because two groups in one link unit -- a project with shaders and with @@ -319,68 +452,6 @@ inline std::string accessor_base(const options& opt, const item& it) { return s; } -// A GENERATED NAME THE C++ COMPILER WILL ACCEPT. -// -// Three transformations, and the third is the one every hand-rolled copy of -// this function was missing. Non-identifier characters become `_`; a leading -// digit gets a `_` in front; and a result that is a KEYWORD gets a trailing `_`. -// -// The keyword case is not hypothetical. The first two rules accept `default`, -// `template`, `operator`, `private` and `union` unchanged -- they are valid -// identifiers to a character filter and reserved to the compiler -- and -// `shaders/default/` is an ordinary name for a shader directory. What it -// produced was `namespace default {` in a generated file, and an error naming a -// line its author never wrote. -// -// TRAILING `_`, not a prefix: `_default` is reserved at namespace scope -// (a leading underscore in the global namespace), and prefixing would trade one -// reserved name for another. -// -// The list is the keywords of the standard this collection targets. A word that -// is contextual rather than reserved (`final`, `override`, `import`, `module`) -// is a legal identifier and is left alone. -inline bool is_cxx_keyword(std::string_view w) { - static constexpr std::string_view kWords[] = { - "alignas", "alignof", "and", "and_eq", "asm", "auto", "bitand", "bitor", - "bool", "break", "case", "catch", "char", "char8_t", "char16_t", - "char32_t", "class", "compl", "concept", "const", "consteval", - "constexpr", "constinit", "const_cast", "continue", "co_await", - "co_return", "co_yield", "decltype", "default", "delete", "do", "double", - "dynamic_cast", "else", "enum", "explicit", "export", "extern", "false", - "float", "for", "friend", "goto", "if", "inline", "int", "long", - "mutable", "namespace", "new", "noexcept", "not", "not_eq", "nullptr", - "operator", "or", "or_eq", "private", "protected", "public", "register", - "reinterpret_cast", "requires", "return", "short", "signed", "sizeof", - "static", "static_assert", "static_cast", "struct", "switch", - "template", "this", "thread_local", "throw", "true", "try", "typedef", - "typeid", "typename", "union", "unsigned", "using", "virtual", "void", - "volatile", "wchar_t", "while", "xor", "xor_eq", - }; - for (auto k : kWords) if (k == w) return true; - return false; -} - -// `fallback` is used when the input sanitises to nothing, which a file named -// only in punctuation does. -// -// INDEXED RATHER THAN A RANGE-FOR over the string, for the reason recorded in -// `mcpp.tools.island`: iterating a `std::string` inside an exported inline -// function makes GCC 16 instantiate its iterator in this BMI, and a consumer's -// build program then fails to compile on `always_inline` in a header naming -// neither this file nor this loop. -inline std::string identifier(std::string_view raw, std::string_view fallback) { - std::string s; - for (std::size_t i = 0; i < raw.size(); ++i) { - const char c = raw[i]; - s += (std::isalnum(static_cast(c)) || c == '_') ? c : '_'; - } - if (s.empty()) s = std::string(fallback); - if (!s.empty() && std::isdigit(static_cast(s.front()))) - s.insert(s.begin(), '_'); - if (is_cxx_keyword(s)) s += '_'; - return s; -} - inline const char* element_type(element e) { return e == element::word32 ? "unsigned int" : "unsigned char"; } diff --git a/tests/island-interface/build.mcpp b/tests/island-interface/build.mcpp index 1466d49..0daa739 100644 --- a/tests/island-interface/build.mcpp +++ b/tests/island-interface/build.mcpp @@ -16,33 +16,36 @@ import mcpp.tools.island; // linkage does not mangle, so two copies that disagree are one symbol: the link // is clean and each side reads the arguments by its own ABI. int main() { - // BOTH HALVES, UNCONDITIONALLY. Which one the build compiles is the - // manifest's decision and this program does not repeat it: the declarations - // are the same either way, and `scan` merges them by entry name. A build - // program that asked `mcpp::accel()` here would be a second copy of a - // decision the manifest already states -- and the copy that goes stale. - const std::vector islands{ - std::string(mcpp::manifest_dir()) + "/src/kernels/saxpy.c", - std::string(mcpp::manifest_dir()) + "/src/cpu/saxpy.c", - }; - for (auto const& f : islands) mcpp::rerun_if_changed(f.c_str()); + const std::string root = std::string(mcpp::manifest_dir()); mcpp::tools::island::options opt; opt.module_name = "island_interface.kernels"; opt.out_dir = std::string(mcpp::out_dir()) + "/island"; opt.produced_by = "the island-interface fixture"; - const auto entries = mcpp::tools::island::scan(islands, opt); + // TWO ROOTS, UNCONDITIONALLY, AND NEITHER DEPENDS ON THE ACCELERATOR. + // + // Which one the build compiles is the manifest's decision and this program + // does not repeat it: the declarations are the same either way. Roots taken + // from `mcpp::device_sources()` would be narrowed by `accel` and the device + // tree would vanish under `--no-accel`, so an entry point's namespace would + // then come from the fallback tree -- a consumer's qualified name would + // differ between two builds of one project. + opt.roots = { root + "/src/kernels", root + "/src/cpu" }; + opt.layout_root = root + "/src/kernels"; + + // The short name beside the authored one. An island's symbol is global to + // the whole program, so `island_saxpy` carries a prefix the namespace then + // repeats; `island_interface::kernels::saxpy` is the same entity. + opt.strip_prefix = "island_"; + + // THE COUNT IS ASSERTED BY CI, NOT HERE. Both roots implement the same two + // entry points, and a merge that appended instead would declare each one + // twice -- which the module rejects as a redefinition and the header + // silently accepts. Counting here would also stop every negative case this + // fixture is perturbed into before it reached the check being tested. + const auto entries = mcpp::tools::island::scan(opt); if (!entries) return 1; - // TWO, not four. Both files mark the same two entry points, and a merge - // that appended instead would declare each one twice -- which the module - // rejects as a redefinition and the header does not, so the count is the - // criterion that catches it in both surfaces. - if (entries->size() != 2) { - std::cerr << std::format("expected two marked entry points, found {}\n", - entries->size()); - return 1; - } const auto out = mcpp::tools::island::emit(*entries, opt); if (!out) return 1; diff --git a/tests/island-interface/mcpp.toml b/tests/island-interface/mcpp.toml index c41d526..a9b5bc9 100644 --- a/tests/island-interface/mcpp.toml +++ b/tests/island-interface/mcpp.toml @@ -15,6 +15,9 @@ # `extern "C"` block and the `__cplusplus` dance are not written by hand; # - the module the C++ side imports is generated too, and re-exports the entry # points by name rather than restating their signatures; +# - the names arrive in the boundary module's own namespace, extended by the +# directories of the root that supplies the shape; +# - a root that does not supply the shape may be one flat file; # - the consumer never includes the header. [package] name = "island-interface" @@ -42,10 +45,10 @@ accel = "vulkan1.2" sources = ["src/*.cppm", "src/*.cpp"] [target.'cfg(accelerator = "vulkan")'.build] -sources = ["src/kernels/*.c"] +sources = ["src/kernels/**/*.c"] [target.'cfg(not(accelerator = "vulkan"))'.build] -sources = ["src/cpu/*.c"] +sources = ["src/cpu/**/*.c"] [targets.island-interface] kind = "bin" diff --git a/tests/island-interface/src/app.cppm b/tests/island-interface/src/app.cppm index 5052ac9..8d621d6 100644 --- a/tests/island-interface/src/app.cppm +++ b/tests/island-interface/src/app.cppm @@ -1,16 +1,15 @@ // The seam: a module of this project that imports the GENERATED module. // -// This is the shape every example under `examples/09-heterogeneous` has, and it -// is the one the fixture was missing. `src/main.cpp` is not a module unit, so -// its `import` said nothing about the case that matters here -- a module -// interface written by this project importing a module interface written into -// the build directory during the same build. The two have to be ordered, and -// the ordering comes from the scan seeing the import rather than from anything -// this file declares. +// This is the shape every example under `examples/09-heterogeneous` has. It is +// also where the boundary stops being C. Above this line callers pass spans; +// below it, pointers and a count, which is the one shape every device API +// agrees on. // -// It is also where the boundary stops being C. Above this line callers pass -// spans; below it, pointers and a count, which is the one shape every device -// API agrees on. +// The generated names arrive in a namespace that is the boundary module's own +// path -- `island_interface::kernels` -- and a directory below the layout root +// extends it, which is why `scale` is reached through `::image`. `saxpy` and +// `scale` are the short spellings of `island_saxpy` and `island_scale`: the +// authored names are the symbols and are exported too. export module island_interface.app; import std; @@ -22,14 +21,15 @@ std::optional> saxpy(float a, std::span x, std::span y) { if (x.size() != y.size()) return std::nullopt; std::vector out(x.size()); - if (saxpy_device(a, x.data(), y.data(), out.data(), - static_cast(x.size())) != 0) + if (kernels::saxpy(a, x.data(), y.data(), out.data(), + static_cast(x.size())) != 0) return std::nullopt; return out; } bool scale(float a, std::span v) { - return scale_device(a, v.data(), static_cast(v.size())) == 0; + return kernels::image::island_scale(a, v.data(), + static_cast(v.size())) == 0; } } // namespace island_interface diff --git a/tests/island-interface/src/cpu/ops.c b/tests/island-interface/src/cpu/ops.c new file mode 100644 index 0000000..2013e4f --- /dev/null +++ b/tests/island-interface/src/cpu/ops.c @@ -0,0 +1,34 @@ +/* The host half of the same boundary, and the demonstration that a root which + * does not supply the shape may be organised however it likes. + * + * ONE FLAT FILE against a layout root organised by subject. Both entry points + * are implemented here, and neither takes its namespace from this file's + * directory: the shape comes from `src/kernels`, so `island_scale` stays in + * `::image` even though nothing here is under a directory of that name. Moving + * this file deeper changes no name a consumer wrote. + * + * The signatures here and in the layout root are the same declarations, and + * that is a property nothing else in the toolchain checks: C language linkage + * does not mangle, exactly one of these implementations is in any link, and two + * that disagreed would each read the arguments its own way. `scan` is the one + * place where both texts exist at once, and it refuses a disagreement there. + * + * No include here either. The generated boundary header arrives through the + * compiler's forced-include flag, the same way it reaches the device half. */ + +MCPP_EXPORT_C +int island_saxpy(float a, const float* x, const float* y, float* out, unsigned n) { + for (unsigned i = 0; i < n; ++i) out[i] = a * x[i] + y[i]; + return 0; +} + +MCPP_EXPORT_C +int island_scale(float a, float* out, unsigned n) { + for (unsigned i = 0; i < n; ++i) out[i] = a * out[i]; + return 0; +} + +/* The host half has its own internals, and they must not reach the boundary + * any more than the device half's do. */ +static int host_only_helper(int x) { return x - 1; } +int internal_device(int x) { return host_only_helper(x); } diff --git a/tests/island-interface/src/cpu/saxpy.c b/tests/island-interface/src/cpu/saxpy.c deleted file mode 100644 index 13a07e8..0000000 --- a/tests/island-interface/src/cpu/saxpy.c +++ /dev/null @@ -1,30 +0,0 @@ -/* The host half of the same boundary. - * - * The signatures here and in `../kernels/saxpy.c` are the same declarations, - * and that is a property nothing else in the toolchain checks: C language - * linkage does not mangle, exactly one of these files is in any link, and two - * that disagreed would each read the arguments its own way. - * - * `mcpp::tools::island::scan` is handed both, so it is the one place where both - * texts exist at once -- and it refuses a disagreement there, with both file - * names, instead of leaving it to the run. - * - * No include here either. The generated boundary header arrives through the - * compiler's forced-include flag, the same way it reaches the device half. */ - -MCPP_EXPORT_C -int saxpy_device(float a, const float* x, const float* y, float* out, unsigned n) { - for (unsigned i = 0; i < n; ++i) out[i] = a * x[i] + y[i]; - return 0; -} - -MCPP_EXPORT_C -int scale_device(float a, float* out, unsigned n) { - for (unsigned i = 0; i < n; ++i) out[i] = a * out[i]; - return 0; -} - -/* The host half has its own internals, and they must not reach the boundary - * any more than the device half's do. */ -static int host_only_helper(int x) { return x - 1; } -int internal_device(int x) { return host_only_helper(x); } diff --git a/tests/island-interface/src/kernels/image/scale.c b/tests/island-interface/src/kernels/image/scale.c new file mode 100644 index 0000000..74e0d96 --- /dev/null +++ b/tests/island-interface/src/kernels/image/scale.c @@ -0,0 +1,19 @@ +/* A second island, one directory deeper. + * + * The directory is what puts this entry point in + * `island_interface::kernels::image` while the file above it is in + * `island_interface::kernels`. Nothing about the file name reaches the name: + * an island holds zero, one or many marked entry points and each carries its + * own. + * + * The signature deliberately wraps across lines: one that did not fit on a + * single line is the shape a line-oriented scan gets wrong, and the generator + * matches parentheses rather than reading lines. */ + +MCPP_EXPORT_C +int island_scale(float a, + float* out, + unsigned n) { + for (unsigned i = 0; i < n; ++i) out[i] = a * out[i]; + return 0; +} diff --git a/tests/island-interface/src/kernels/saxpy.c b/tests/island-interface/src/kernels/saxpy.c index fe55b0a..90fbb1e 100644 --- a/tests/island-interface/src/kernels/saxpy.c +++ b/tests/island-interface/src/kernels/saxpy.c @@ -1,4 +1,4 @@ -/* The island. No include, and no header in this project at all. +/* The island, and the root that supplies the shape. * * The signatures live HERE, beside the definitions, and exist once. The marker * is what the generator finds them by, and the generated boundary header @@ -8,24 +8,15 @@ * That header still declares these functions, so a definition whose signature * drifted from its declaration fails here rather than at the link. * - * `scale_device` deliberately wraps across lines: a signature that did not fit - * on one is the shape a line-oriented scan gets wrong, and the generator - * matches parentheses rather than reading lines. */ + * This file sits directly under the layout root, so its entry point carries no + * namespace segment: `island_interface::kernels::island_saxpy`. */ MCPP_EXPORT_C -int saxpy_device(float a, const float* x, const float* y, float* out, unsigned n) { +int island_saxpy(float a, const float* x, const float* y, float* out, unsigned n) { for (unsigned i = 0; i < n; ++i) out[i] = a * x[i] + y[i]; return 0; } -MCPP_EXPORT_C -int scale_device(float a, - float* out, - unsigned n) { - for (unsigned i = 0; i < n; ++i) out[i] = a * out[i]; - return 0; -} - /* Not marked, so it must not appear in the header or the module: an island has * internal functions, and exporting them all would make the boundary whatever * the file happened to contain. */ diff --git a/tools/island.cppm b/tools/island.cppm index 5af24ca..bf4f2a2 100644 --- a/tools/island.cppm +++ b/tools/island.cppm @@ -15,9 +15,9 @@ export module mcpp.tools.island; import std; import mcpp; -// For `write_if_different`, which the lib root owns because every generator in -// this package needs the same "do not touch a file whose content is unchanged" -// rule. +// For `write_if_different` and for the path-to-namespace derivations, which the +// lib root owns because the shader lane and this one must answer a directory +// named `default` the same way. import mcpp.plugins; @@ -31,10 +31,9 @@ import mcpp.plugins; // design decision no generator makes well. That interface stays hand-written. // // What is mechanical is everything AROUND the entry points: an include guard, -// an `extern "C"` block, the `__cplusplus` dance, and a module wrapper whose -// only content is a global module fragment and a re-export. Ten lines of -// boilerplate per one line of content, written the same way in every project -// that has an island. That is what this generates. +// an `extern "C"` block, the `__cplusplus` dance, the namespace the C++ side +// reaches them through, and a module wrapper. Ten lines of boilerplate per one +// line of content, written the same way in every project that has an island. // // THE DECLARATION STILL EXISTS ONCE. It moves from a hand-written header into // the build program, and both artefacts are produced from it -- the header the @@ -52,6 +51,24 @@ import mcpp.plugins; // entry point and links against an implementation compiled by a DIFFERENT // driver, which is the arrangement a device island actually has. // +// THE NAMES ARE IN A NAMESPACE, AND THE NAMESPACE IS THE MODULE'S PATH. +// +// `docs/42` states one rule for both lanes: the module name and the namespace +// are one identifier path. The shader lane obeys it; this generator did not, +// and put every entry point at global scope, so `import app.kernels` bought a +// file name and nothing else. It obeys it now: a root's directories extend the +// namespace exactly as a payload tree's do. +// +// A NAMESPACE OVER A FLAT SYMBOL IS A LOOKUP ALIAS, AND THE CHECK IS WHAT MAKES +// IT HONEST. Measured 2026-09-08 with clang++ (DPC++ 7.1.0), `-std=c++23`: two +// modules re-exporting one `extern "C"` name into two namespaces produce two +// spellings of ONE entity -- `&a::f == &b::f`. The shader lane does not have +// this problem because it composes its own symbols and can put the path in +// them; an island's symbol is written by its author and this generator only +// reads it. So `scan` refuses two entry points with one name in one root: a +// name then exists in exactly one namespace, and the namespace cannot lie about +// what a call resolves to. +// // IT IS OPTIONAL, AND A PROJECT THAT WRITES ITS OWN HEADER KEEPS IT. Nothing // here is required to have an island; it removes boilerplate from projects that // want it removed. @@ -59,7 +76,8 @@ export namespace mcpp::tools::island { struct options { // The module the C++ side imports: `myapp.kernels`. The header is named - // after it too, so one name places both files. + // after it too, so one name places both files, and it is also the namespace + // the entry points arrive in: `myapp::kernels::…`. std::string module_name; // Where they are written. The caller puts this on the include path so the // device translation unit can include the header. @@ -69,6 +87,54 @@ struct options { // False emits the header alone, for a project that wants the boilerplate // removed but keeps a hand-written seam that includes rather than imports. bool emit_module = true; + + // WHERE THE ISLANDS ARE, AND WHY THIS IS A TREE RATHER THAN A FILE LIST. + // + // A root is a directory that holds implementations. Its internal structure + // is what extends the namespace, so it is DECLARED rather than inferred + // from the set of files handed in -- a file list's common ancestor moves + // when a file is added, and a consumer's qualified name would move with it. + // + // Several roots mean one entry point implemented several times: a device + // island and a host fallback are two roots, and exactly one of them is in + // any link. A single file is also a legal root, for a project that keeps + // one implementation beside another in one directory. + // + // THEY MUST NOT DEPEND ON THE ACCELERATOR. `mcpp::device_sources()` is + // narrowed by `accel` and is empty under `--no-accel`; roots taken from it + // would lose the device tree in a CPU-only build, and the entry point's + // namespace would then come from the fallback tree instead. Roots are + // directories on disk, and `accel` decides only what is compiled. + std::vector roots; + + // The root whose directory structure says WHERE entry points live. Every + // other root supplies implementations and its structure is never read for + // naming, so a fallback tree may be one flat file or six directories and + // may be reorganised without renaming anything a consumer wrote. + // + // This is a naming role and not a rank: every root compiles, links and is + // equally a backend. Empty means `roots.front()`. + // + // An entry point the layout root does not declare -- a kernel only one + // backend has -- takes the namespace of the first root that does. + std::string layout_root; + + // The files a root is read for. Headers are deliberately absent: a project + // that declares its entry points in a `.cuh` and defines them in a `.cu` + // would otherwise hand the uniqueness check two files for one name and be + // refused for a layout that is correct. + std::vector extensions; + + // Non-empty emits a second spelling beside each entry point that carries + // it: `inline constexpr auto blur = opkit_blur;`. + // + // An island's symbol is global to the whole program, so an entry point + // carries a package prefix whether or not it sits in a namespace, and the + // namespace then repeats what the prefix already said. The authored name is + // always emitted and stays canonical -- it is what `nm`, a link error, a + // profiler and `dlsym` show. This is a convenience at the call site. + std::string strip_prefix; + // The marker `scan()` looks for. It is defined as nothing by the generated // header, so the island includes that header and then writes the marker in // front of each entry point it exports. @@ -93,6 +159,15 @@ struct options { std::string marker = "MCPP_EXPORT_C"; }; +// One marked entry point. `decl` is what the header will contain, copied +// verbatim from the island; `name_space` is where the C++ side reaches it. +struct entry { + std::string decl; + std::string name; + std::vector name_space; + std::string origin; // the file it was first seen in +}; + struct emitted { std::string header_file; // the generated boundary header std::string interface_file; // the `.cppm`; empty when `emit_module` is false @@ -100,6 +175,16 @@ struct emitted { std::string module_name; // empty when `emit_module` is false }; +// The extensions a root is read for when `options::extensions` is empty. Every +// language a device compiler consumes that produces an OBJECT, plus the C and +// C++ a host implementation of the same boundary is written in. A shading +// language is absent: a `.comp` is data by the time it reaches a link and +// carries no `extern "C"` entry point. +inline std::vector default_extensions() { + return {".c", ".cc", ".cpp", ".cxx", ".cu", ".hip", ".sycl", + ".asc", ".cce", ".cl", ".metal"}; +} + // The flags that make a compiler read the generated header before the island's // first line, so the island writes neither an include nor anything else. // @@ -141,12 +226,142 @@ inline std::string entry_name(std::string_view decl) { return std::string(decl.substr(begin, end - begin)); } +// An entry point the scan cannot see -- generated by something else, or behind +// a macro this does not expand. The project states the declaration and where it +// belongs, and everything downstream is identical. +inline entry declared(std::string decl, std::vector name_space = {}) { + entry e; + e.name = entry_name(decl); + e.decl = std::move(decl); + e.name_space = std::move(name_space); + e.origin = "declared in the build program"; + return e; +} + +// ─── the scan ────────────────────────────────────────────────────────────── + +namespace detail { + +// Runs of whitespace collapsed, so a wrapped signature reads as one +// declaration and two halves that wrote it differently still compare equal. +// +// INDEXED RATHER THAN A RANGE-FOR, AND THAT IS NOT A STYLE CHOICE. +// +// `for (char c : decl)` over a `std::string` inside an exported inline function +// makes GCC 16 instantiate `std::string::iterator` in this BMI, and the +// consumer's build program then fails to compile with +// +// error: inlining failed in call to 'always_inline' +// __normal_iterator>::operator*(): +// function body not available +// +// in ``, naming neither this file nor this loop. Indexing +// touches no iterator type and compiles. +inline std::string flatten(std::string_view decl) { + std::string flat; + bool space = false; + for (std::size_t k = 0; k < decl.size(); ++k) { + const char c = decl[k]; + if (c == '\n' || c == '\t' || c == '\r' || c == ' ') { + if (!flat.empty()) space = true; + } else { + if (space) flat += ' '; + space = false; + flat += c; + } + } + return flat; +} + +inline bool has_extension(const std::filesystem::path& p, + std::span exts) { + const auto e = p.extension().string(); + for (auto const& want : exts) if (e == want) return true; + return false; +} + +// The files of one root, sorted. THE ORDER IS PART OF THE CONTRACT: the walk +// order of a directory is the filesystem's, so without the sort the generated +// files differ between two runs that changed nothing, `write_if_different` +// rewrites them, the force-included header's timestamp moves and every island +// translation unit rebuilds. +inline bool files_under(const std::string& root, std::span exts, + std::vector& out, std::string& base) { + std::error_code ec; + const std::filesystem::path p(root); + if (std::filesystem::is_regular_file(p, ec)) { + base = p.parent_path().string(); + out.push_back(p.string()); + return true; + } + if (!std::filesystem::is_directory(p, ec)) return false; + base = p.string(); + for (std::filesystem::recursive_directory_iterator it(p, ec), end; it != end; + it.increment(ec)) { + if (ec) return false; + if (!it->is_regular_file(ec)) continue; + if (has_extension(it->path(), exts)) out.push_back(it->path().string()); + } + std::sort(out.begin(), out.end()); + return true; +} + +// The declarations one file marks, in source order. +inline bool marked_in(const std::string& src, const std::string& marker, + std::vector& decls) { + std::ifstream in(src); + if (!in) { + std::cerr << std::format("mcpp.tools.island: cannot read {}\n", src); + return false; + } + std::string text((std::istreambuf_iterator(in)), + std::istreambuf_iterator()); + for (std::size_t at = text.find(marker); at != std::string::npos; + at = text.find(marker, at + 1)) { + // The marker's own definition in the generated header is not an entry + // point. Skipped by requiring a `(` before the next `;` or `{`, which a + // `#define` line does not have. + std::size_t i = at + marker.size(); + int depth = 0; + bool sawOpen = false; + std::size_t end = std::string::npos; + for (; i < text.size(); ++i) { + const char c = text[i]; + if (c == '(') { ++depth; sawOpen = true; } + else if (c == ')') { + if (--depth == 0) { end = i; break; } + } else if (!sawOpen && (c == ';' || c == '{' || c == '\n')) { + if (c == '\n') continue; // a signature may wrap + break; // `;` or `{` with no `(` + } + } + if (end == std::string::npos) { + if (!sawOpen) continue; // a `#define` of the marker + std::cerr << std::format( + "mcpp.tools.island: {} carries `{}` whose parameter list does not " + "close.\n A marked entry point is one declaration, and the generator " + "copies it verbatim.\n", src, marker); + return false; + } + auto flat = flatten(text.substr(at + marker.size(), + end + 1 - (at + marker.size()))); + if (!flat.empty()) decls.push_back(std::move(flat)); + } + return true; +} + +inline std::string joined(std::span segs) { + std::string s; + for (auto const& one : segs) { if (!s.empty()) s += "::"; s += one; } + return s; +} + +} // namespace detail + // THE ENTRY POINTS, TAKEN FROM WHERE THEY ARE DEFINED. // -// `emit` takes a list of declarations, which is exact and requires the project -// to write each signature in its build program. This reads them out of the -// island instead, so the signature lives beside the definition and exists once -// -- which is the arrangement a reader expects and the one that cannot drift. +// The signature lives beside the definition and exists once, which is the +// arrangement a reader expects and the one that cannot drift. // // IT IS NOT A C PARSER, AND DOES NOT NEED TO BE. From the marker it copies // verbatim up to the parenthesis that closes the parameter list, matching @@ -155,126 +370,151 @@ inline std::string entry_name(std::string_view decl) { // a declaration -- a macro, a qualifier, a multi-line signature -- travels // through unexamined. // -// A marker with no `(` after it is refused rather than skipped: a marked entry -// point that produced no declaration would leave the island defining a function -// nothing declares, and the consumer's failure would be an unresolved name in a -// different file. -// -// SEVERAL SOURCES MAY DEFINE THE SAME ENTRY POINT, AND THEY MUST AGREE. +// TWO REFUSALS, AND THEY ANSWER DIFFERENT QUESTIONS. // -// That is the ordinary shape of a seam: a device island and a host fallback -// define one boundary, and exactly one of them is in any given link. A build -// program hands both to this function and gets one set of declarations back, -// so it does not have to ask which build it is in. +// One name twice in ONE root is a collision: C language linkage does not +// mangle, so those are one symbol, and a namespace that appeared to separate +// them would be a lookup alias promising an isolation the linker does not +// provide. Refused, naming both files. // -// Two definitions of one name whose declarations differ are REFUSED here, -// naming both files. Nothing else in the toolchain catches that: C language -// linkage does not mangle, so the two never meet at the link, and whichever -// one is present reads its arguments by its own idea of the signature. This is -// the only point at which both texts exist at once. -inline std::optional> -scan(std::span sources, const options& opt) { - std::vector entries; - // Where each entry was found, for the disagreement diagnostic. Parallel to - // `entries`, which is the return value and cannot carry it. - std::vector origin; - for (auto const& src : sources) { - std::ifstream in(src); - if (!in) { - std::cerr << std::format("mcpp.tools.island: cannot read {}\n", src); +// One name in SEVERAL roots is one entry point implemented several times -- +// the ordinary shape of a seam, where exactly one implementation is in any +// link. The declarations must then agree verbatim, and this is the only place +// in the toolchain where both texts exist at once: the two never meet at the +// link, so nothing else can compare them. +inline std::optional> scan(const options& opt) { + if (opt.roots.empty()) { + std::cerr << "mcpp.tools.island: options::roots is empty; there is nothing " + "to scan.\n A root is the directory an island's sources live " + "under.\n"; + return std::nullopt; + } + const std::string layout = opt.layout_root.empty() ? opt.roots.front() + : opt.layout_root; + if (std::find(opt.roots.begin(), opt.roots.end(), layout) == opt.roots.end()) { + std::cerr << std::format( + "mcpp.tools.island: layout_root `{}` is not one of the roots.\n" + " The root that supplies the shape has to be a root.\n", layout); + return std::nullopt; + } + const auto exts = opt.extensions.empty() ? default_extensions() : opt.extensions; + + struct record { + entry e; + std::size_t root = 0; + bool from_layout = false; + }; + std::vector found; + auto find_by_name = [&](std::string_view n) -> record* { + for (auto& r : found) if (r.e.name == n) return &r; + return nullptr; + }; + + for (std::size_t ri = 0; ri < opt.roots.size(); ++ri) { + const auto& root = opt.roots[ri]; + std::vector files; + std::string base; + if (!detail::files_under(root, exts, files, base)) { + std::cerr << std::format( + "mcpp.tools.island: `{}` is neither a directory nor a file.\n" + " Roots are where an island's sources live, on disk.\n", root); return std::nullopt; } - std::string text((std::istreambuf_iterator(in)), - std::istreambuf_iterator()); - for (std::size_t at = text.find(opt.marker); at != std::string::npos; - at = text.find(opt.marker, at + 1)) { - // The marker's own definition in the generated header is not an - // entry point. Skipped by requiring a `(` before the next `;` or - // `{`, which a `#define` line does not have. - std::size_t i = at + opt.marker.size(); - int depth = 0; - bool sawOpen = false; - std::size_t end = std::string::npos; - for (; i < text.size(); ++i) { - const char c = text[i]; - if (c == '(') { ++depth; sawOpen = true; } - else if (c == ')') { - if (--depth == 0) { end = i; break; } - } else if (!sawOpen && (c == ';' || c == '{' || c == '\n')) { - if (c == '\n') continue; // a signature may wrap - break; // `;` or `{` with no `(` + for (auto const& f : files) { + mcpp::rerun_if_changed(f.c_str()); + std::vector decls; + if (!detail::marked_in(f, opt.marker, decls)) return std::nullopt; + const auto ns = mcpp::plugins::names::namespace_of(f, base); + for (auto& d : decls) { + const auto name = entry_name(d); + if (name.empty()) { + std::cerr << std::format( + "mcpp.tools.island: cannot find an entry point name in `{}`\n" + " in {}.\n", d, f); + return std::nullopt; } - } - if (end == std::string::npos) { - if (!sawOpen) continue; // a `#define` of the marker - std::cerr << std::format( - "mcpp.tools.island: {} carries `{}` whose parameter list does not " - "close.\n A marked entry point is one declaration, and the generator " - "copies it verbatim.\n", src, opt.marker); - return std::nullopt; - } - auto decl = text.substr(at + opt.marker.size(), - end + 1 - (at + opt.marker.size())); - // Collapse the runs of whitespace a wrapped signature carries, so - // the header reads as one declaration per line. - // INDEXED RATHER THAN A RANGE-FOR, AND THAT IS NOT A STYLE CHOICE. - // - // `for (char c : decl)` over a `std::string` inside an exported - // inline function makes GCC 16 instantiate `std::string::iterator` - // in this BMI, and the consumer's build program then fails to - // compile with - // - // error: inlining failed in call to 'always_inline' - // __normal_iterator>::operator*(): - // function body not available - // - // in ``, naming neither this file nor this - // loop. Indexing touches no iterator type and compiles. - std::string flat; - bool space = false; - for (std::size_t k = 0; k < decl.size(); ++k) { - const char c = decl[k]; - if (c == '\n' || c == '\t' || c == '\r' || c == ' ') { - if (!flat.empty()) space = true; - } else { - if (space) flat += ' '; - space = false; - flat += c; + if (auto* seen = find_by_name(name)) { + if (seen->root == ri) { + std::cerr << std::format( + "mcpp.tools.island: `{}` is declared twice in the root `{}`.\n" + " {}\n {}\n" + " C language linkage does not mangle, so these are one " + "symbol and\n a namespace would not separate them. If they " + "are two implementations of\n one entry point, they belong " + "in two roots.\n", + name, root, seen->e.origin, f); + return std::nullopt; + } + if (seen->e.decl != d) { + std::cerr << std::format( + "mcpp.tools.island: two definitions of `{}` declare it " + "differently.\n" + " {}\n {}\n" + " {}\n {}\n" + " C language linkage does not mangle, so these never meet " + "at the link:\n whichever one is in the artifact reads its " + "arguments by its own signature.\n", + name, seen->e.origin, seen->e.decl, f, d); + return std::nullopt; + } + // The shape comes from one stated root. A second + // implementation adds nothing to where the entry point + // lives, unless the layout root is the one adding it. + if (!seen->from_layout && root == layout) { + seen->e.name_space = ns; + seen->from_layout = true; + } + continue; } + record r; + r.e.decl = d; + r.e.name = name; + r.e.name_space = ns; + r.e.origin = f; + r.root = ri; + r.from_layout = (root == layout); + found.push_back(std::move(r)); } - if (flat.empty()) continue; - - // MERGED BY ENTRY NAME. A second definition of a name already seen - // is either the same declaration -- the seam's two halves agreeing, - // which is the expected case -- or a disagreement that has to stop - // the build here. - const auto name = entry_name(flat); - std::size_t seen = entries.size(); - for (std::size_t k = 0; k < entries.size(); ++k) - if (entry_name(entries[k]) == name) { seen = k; break; } - - if (seen == entries.size()) { - entries.push_back(std::move(flat)); - origin.push_back(src); - continue; - } - if (entries[seen] == flat) continue; // both halves agree - - std::cerr << std::format( - "mcpp.tools.island: two definitions of `{}` declare it differently.\n" - " {}\n {}\n" - " {}\n {}\n" - " C language linkage does not mangle, so these never meet at the " - "link:\n whichever one is in the artifact reads its arguments by its " - "own signature.\n", - name, origin[seen], entries[seen], src, flat); - return std::nullopt; } + // ADDING A FILE HAS TO RE-RUN THIS PROGRAM. Declared inputs are hashed + // contents, so a new file changes none of them; the glob's fingerprint + // is the sorted set of matching paths, which is exactly the question + // "which files are here". The pattern is relative to the manifest + // directory, so a root outside it registers its files and nothing else. + const auto rel = std::filesystem::path(base).lexically_relative( + std::filesystem::path(mcpp::manifest_dir())); + const auto reltext = rel.generic_string(); + if (!reltext.empty() && !reltext.starts_with("..")) { + for (auto const& e : exts) + mcpp::rerun_if_changed_glob((reltext + "/**/*" + e).c_str()); + } + } + + if (found.empty()) { + std::string where; + for (auto const& r : opt.roots) { if (!where.empty()) where += ", "; where += r; } + std::cerr << std::format( + "mcpp.tools.island: no entry point marked `{}` under {}.\n" + " A root that yields nothing is a misspelled path or a marker that " + "never arrived,\n and an empty module fails later and less clearly.\n", + opt.marker, where); + return std::nullopt; } - return entries; + + std::vector out; + out.reserve(found.size()); + for (auto& r : found) out.push_back(std::move(r.e)); + // Grouped and stable: the generated files are a function of the tree. + std::sort(out.begin(), out.end(), [](const entry& a, const entry& b) { + const auto an = detail::joined(a.name_space), bn = detail::joined(b.name_space); + return an == bn ? a.name < b.name : an < bn; + }); + return out; } -inline std::optional emit(std::span entries, +// ─── the emission ────────────────────────────────────────────────────────── + +inline std::optional emit(std::span entries, const options& opt) { if (entries.empty()) return emitted{}; if (opt.module_name.empty() || opt.out_dir.empty()) { @@ -285,31 +525,46 @@ inline std::optional emit(std::span entries, : opt.produced_by; const auto dir = std::filesystem::path(opt.out_dir); - // A name the generator could not find is refused rather than skipped: a - // declaration that produced no re-export would compile, and the consumer's - // failure would be an unresolved name three files away. - std::vector names; + const auto modSegs = mcpp::plugins::names::split_module_name(opt.module_name); + if (modSegs.empty()) { + std::cerr << std::format("mcpp.tools.island: `{}` is not a usable module name\n", + opt.module_name); + return std::nullopt; + } + for (auto const& seg : modSegs) { + if (mcpp::plugins::names::identifier(seg, "x") != seg) { + std::cerr << std::format( + "mcpp.tools.island: `{}` is not a usable module name: the segment `{}` " + "is not a C++ identifier.\n Each segment becomes a namespace.\n", + opt.module_name, seg); + return std::nullopt; + } + } + for (auto const& e : entries) { - auto n = entry_name(e); - if (n.empty()) { + if (e.name.empty()) { std::cerr << std::format( "mcpp.tools.island: cannot find an entry point name in `{}`.\n" " Each entry is a C declaration, e.g.\n" - " \"int saxpy_device(float a, const float* x, unsigned n)\"\n", e); + " \"int saxpy_device(float a, const float* x, unsigned n)\"\n", e.decl); return std::nullopt; } - names.push_back(std::move(n)); } emitted out; const auto guard = [&] { std::string g = "MCPP_ISLAND_"; - for (char c : opt.module_name) + for (std::size_t i = 0; i < opt.module_name.size(); ++i) { + const char c = opt.module_name[i]; g += std::isalnum(static_cast(c)) ? static_cast(std::toupper(static_cast(c))) : '_'; + } return g + "_H"; }(); + // THE HEADER IS FLAT AND HAS NO NAMESPACES, and that is not an omission. It + // is read by a C or a device compiler, and neither has a namespace to read. + // The module is a view onto it. std::string h; h += std::format("// Generated by mcpp.tools.island for {0}. Do not edit.\n" "//\n" @@ -323,7 +578,7 @@ inline std::optional emit(std::span entries, "#ifndef {2}\n#define {2}\n#endif\n" "#ifdef __cplusplus\nextern \"C\" {{\n#endif\n\n", by, guard, opt.marker); - for (auto const& e : entries) h += e + ";\n"; + for (auto const& e : entries) h += e.decl + ";\n"; h += "\n#ifdef __cplusplus\n}\n#endif\n#endif\n"; out.header_file = (dir / (opt.module_name + ".h")).string(); @@ -345,7 +600,57 @@ inline std::optional emit(std::span entries, "export module {2};\n\n", by, std::filesystem::path(out.header_file).filename().string(), opt.module_name); - for (auto const& n : names) m += std::format("export using ::{};\n", n); + + // One block per namespace. `entries` arrives grouped, and a caller that + // built the list itself gets the same grouping from this loop as long as it + // kept equal namespaces adjacent. + std::vector shortNames; // per entry, empty when there is none + shortNames.resize(entries.size()); + if (!opt.strip_prefix.empty()) { + for (std::size_t i = 0; i < entries.size(); ++i) { + if (!entries[i].name.starts_with(opt.strip_prefix)) continue; + auto s = mcpp::plugins::names::identifier( + entries[i].name.substr(opt.strip_prefix.size()), "entry"); + if (s.empty() || s == entries[i].name) continue; + shortNames[i] = std::move(s); + } + for (std::size_t i = 0; i < entries.size(); ++i) { + if (shortNames[i].empty()) continue; + for (std::size_t j = 0; j < entries.size(); ++j) { + if (i == j) continue; + const bool sameNs = entries[i].name_space == entries[j].name_space; + if (!sameNs) continue; + if (shortNames[i] == shortNames[j] || shortNames[i] == entries[j].name) { + std::cerr << std::format( + "mcpp.tools.island: `{}` and `{}` both reach `{}` once `{}` is " + "stripped.\n {}\n {}\n A short name is a second spelling of " + "one entry point, not a shared one.\n", + entries[i].name, entries[j].name, shortNames[i], + opt.strip_prefix, entries[i].origin, entries[j].origin); + return std::nullopt; + } + } + } + } + + std::string openNs; + bool open = false; + for (std::size_t i = 0; i < entries.size(); ++i) { + std::vector full = modSegs; + for (auto const& seg : entries[i].name_space) full.push_back(seg); + const auto path = detail::joined(full); + if (!open || path != openNs) { + if (open) m += "}\n\n"; + m += std::format("export namespace {} {{\n", path); + openNs = path; + open = true; + } + m += std::format("using ::{};\n", entries[i].name); + if (!shortNames[i].empty()) + m += std::format("inline constexpr auto {} = {};\n", + shortNames[i], entries[i].name); + } + if (open) m += "}\n"; out.interface_file = (dir / (opt.module_name + ".cppm")).string(); out.module_name = opt.module_name;