diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e225b25..040e6093 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,25 @@ version number before tagging the release. ## [current] +### Fixed + +- **A binary-package `import` in a non-entry module (a wrapper/adapter) did not + resolve.** The binary-import prepass (`prepare_binary_imports`) scanned only + the entry file's `import` lines, so an `import ` inside a module the + entry pulls in transitively was never discovered, its interface stub never + synthesized, and the build failed with `unresolved import ''`. Only a + binary import written directly in the entry file worked. The prepass now walks + the full source-import graph (recursing into each imported source module, with + a visited-set for cycles) and synthesizes a stub for every binary-package + import it finds — so a wrapper module importing a binary package is as ordinary + as one importing another source module. The walk follows **dotted** package + imports too (`import a.b.c` → recurse into `a/b/c.ae`), not only flat ones, so + a binary import nested inside a dotted-path module (`import + harness.components.phone.validate`) is discovered — dotted package imports are + exactly what the `modules = "."` root export exists to support. POSIX-only, + same as the rest of the binary-import path. (Reported by the datastar-aether / + libphonenumber-ae line: `main → phone component → validate → phonenumber_ae`.) + ## [0.694.0] ### Added diff --git a/tests/integration/binary_import/app_transitive.ae b/tests/integration/binary_import/app_transitive.ae new file mode 100644 index 00000000..a9934303 --- /dev/null +++ b/tests/integration/binary_import/app_transitive.ae @@ -0,0 +1,13 @@ +// Entry file for the transitive binary-import case. It imports ONLY the source +// wrapper `wrap`; the `import gizmo` (a binary package) sits inside wrap.ae, not +// here. Before the transitive-prepass fix the binary-import prepass scanned only +// this entry file, never discovered wrap's `import gizmo`, and the build failed +// with "unresolved import 'gizmo'". The prepass now walks into wrap.ae. + +import wrap + +extern println(s: string) + +main() { + println(wrap.wrapped_greet("world")) +} diff --git a/tests/integration/binary_import/test_binary_import.sh b/tests/integration/binary_import/test_binary_import.sh index da495711..70983b4b 100755 --- a/tests/integration/binary_import/test_binary_import.sh +++ b/tests/integration/binary_import/test_binary_import.sh @@ -23,7 +23,8 @@ esac WORK="$(mktemp -d)" trap 'rm -rf "$WORK" || true' EXIT -cp "$SCRIPT_DIR/gizmo.ae" "$SCRIPT_DIR/app.ae" "$WORK/" +cp "$SCRIPT_DIR/gizmo.ae" "$SCRIPT_DIR/app.ae" \ + "$SCRIPT_DIR/wrap.ae" "$SCRIPT_DIR/app_transitive.ae" "$WORK/" cd "$WORK" fail() { echo " [FAIL] $1"; exit 1; } @@ -54,4 +55,38 @@ OUT2="$(./app 2>&1)" || fail "built binary failed to run" echo "$OUT2" | grep -q "hi world" || { echo "$OUT2"; fail "built binary: greet missing"; } echo "$OUT2" | grep -q "intro" || { echo "$OUT2"; fail "built binary: builder missing"; } -echo " [PASS] binary_import: function export + builder DSL consumed from a precompiled .so (ae run + ae build)" +# 4. TRANSITIVE binary import: the `import gizmo` lives in a NON-ENTRY module +# (wrap.ae), and the entry (app_transitive.ae) imports only `wrap`. The +# binary-import prepass must walk the whole import graph, not just the entry +# file, to discover it — otherwise `import gizmo` is unresolved. This is the +# adapter/wrapper pattern (a project wraps the engine and imports the wrapper +# everywhere). gizmo.ae is already removed above, so `gizmo` resolves to the +# .so; wrap.ae is a source module the prepass must recurse into. +OUT3="$(AETHER_HOME="$ROOT" "$AE" run app_transitive.ae 2>run_t.log)" || { + echo "--- transitive run log:"; cat run_t.log + fail "transitive binary import: prepass did not walk into wrap.ae (import gizmo unresolved)"; } +echo "$OUT3" | grep -q "hi world" \ + || { echo "$OUT3"; fail "transitive binary import: wrap.wrapped_greet did not reach gizmo.greet"; } + +# And it also links into a standalone binary through the wrapper. +if ! AETHER_HOME="$ROOT" "$AE" build app_transitive.ae -o app_t >build_t.log 2>&1; then + echo "--- transitive build log:"; cat build_t.log; fail "ae build app_transitive.ae" +fi +echo "$(./app_t 2>&1)" | grep -q "hi world" || fail "built transitive binary: greet missing" + +# 5. TRANSITIVE through a DOTTED package import. The wrapper is reached as +# `import pkg.dotwrap` (a dotted package path), not a flat name. The graph +# walk must convert `pkg.dotwrap` -> `pkg/dotwrap.ae` and recurse, or the +# `import gizmo` inside it is never discovered — dotted package imports +# (what `modules = "."` exists to support) are idiomatic, and a real +# consumer's chain looks like `import harness.components.phone.validate`. +mkdir -p pkg +printf 'import gizmo\nexports(dgreet)\ndgreet(name: string) -> string { return gizmo.greet(name) }\n' > pkg/dotwrap.ae +printf 'import pkg.dotwrap\nextern println(s: string)\nmain() { println(dotwrap.dgreet("world")) }\n' > app_dotted.ae +OUT4="$(AETHER_HOME="$ROOT" "$AE" run app_dotted.ae 2>run_d.log)" || { + echo "--- dotted run log:"; cat run_d.log + fail "dotted transitive binary import: prepass did not follow 'import pkg.dotwrap' (import gizmo unresolved)"; } +echo "$OUT4" | grep -q "hi world" \ + || { echo "$OUT4"; fail "dotted transitive binary import: dotwrap.dgreet did not reach gizmo.greet"; } + +echo " [PASS] binary_import: direct + transitive (flat + DOTTED wrapper) binary import, ae run + ae build" diff --git a/tests/integration/binary_import/wrap.ae b/tests/integration/binary_import/wrap.ae new file mode 100644 index 00000000..01b9f6ac --- /dev/null +++ b/tests/integration/binary_import/wrap.ae @@ -0,0 +1,12 @@ +// A NON-ENTRY wrapper/adapter module that imports the binary package `gizmo`. +// The transitive-prepass test drives `app_transitive.ae -> wrap -> gizmo(.so)`: +// the binary import lives here, not in the entry file, so it is only discovered +// if the binary-import prepass walks the whole import graph (not just the entry). + +import gizmo + +exports(wrapped_greet) + +wrapped_greet(name: string) -> string { + return gizmo.greet(name) +} diff --git a/tools/ae.c b/tools/ae.c index f1e966c8..788f0900 100644 --- a/tools/ae.c +++ b/tools/ae.c @@ -3567,20 +3567,28 @@ static int ae_generate_binimport_stub(const char* so_path, FILE* out) { // True if a *source* module named `mod` resolves on the current search // path (CWD, src/, and each --lib dir). Mirrors the compiler's local // resolver closely enough to decide "source vs binary" for a bare import. -static int ae_source_module_exists(const char* mod) { - char p[1200]; +// Resolve source module `mod` to its file path (`/.ae` or +// `//module.ae`), probing `.`, `src`, then every --lib/dependency +// dir — the same order source-import resolution uses. Writes the path into +// `out` and returns 1 if found, 0 otherwise. +static int ae_source_module_path(const char* mod, char* out, size_t outcap) { const char* bases[] = { ".", "src" }; for (size_t b = 0; b < sizeof(bases)/sizeof(bases[0]); b++) { - snprintf(p, sizeof(p), "%s/%s.ae", bases[b], mod); if (path_exists(p)) return 1; - snprintf(p, sizeof(p), "%s/%s/module.ae", bases[b], mod); if (path_exists(p)) return 1; + snprintf(out, outcap, "%s/%s.ae", bases[b], mod); if (path_exists(out)) return 1; + snprintf(out, outcap, "%s/%s/module.ae", bases[b], mod); if (path_exists(out)) return 1; } for (int i = 0; i < tc.lib_dir_count; i++) { - snprintf(p, sizeof(p), "%s/%s.ae", tc.lib_dirs[i], mod); if (path_exists(p)) return 1; - snprintf(p, sizeof(p), "%s/%s/module.ae", tc.lib_dirs[i], mod); if (path_exists(p)) return 1; + snprintf(out, outcap, "%s/%s.ae", tc.lib_dirs[i], mod); if (path_exists(out)) return 1; + snprintf(out, outcap, "%s/%s/module.ae", tc.lib_dirs[i], mod); if (path_exists(out)) return 1; } return 0; } +static int ae_source_module_exists(const char* mod) { + char p[1200]; + return ae_source_module_path(mod, p, sizeof(p)); +} + // Locate a binary artifact for module `mod` (libMOD.so / MOD.so / // libMOD.dylib / MOD.dylib) on the search path. Both extensions are // tried on every POSIX platform: a shared object is identified by its @@ -3627,11 +3635,74 @@ static void ae_abspath(const char* path, char* out, size_t outcap) { // each into a shared temp dir (prepended to the module search path), and // record the artifact on the link line. Best-effort: any failure leaves // the build to proceed (and fail later) as an all-source build would. -static void prepare_binary_imports(const char* main_file) { - FILE* f = fopen(main_file, "r"); - if (!f) return; +// Synthesize the interface stub for a binary-package module `mod` whose +// artifact is at `so_path`, make it resolvable, and record the .so + rpath on +// the link line. `stubdir` is the shared temp dir (created lazily on first use, +// so an all-source build makes none). Returns 0 on success, -1 on a hard error. +static int ae_emit_binimport_stub(const char* mod, const char* so_path, + char* stubdir, size_t stubdir_cap) { + if (!stubdir[0]) { + snprintf(stubdir, stubdir_cap, "/tmp/ae-binimport-XXXXXX"); + if (!mkdtemp(stubdir)) { stubdir[0] = '\0'; return -1; } + } + char stub_path[512]; + snprintf(stub_path, sizeof(stub_path), "%s/%s.ae", stubdir, mod); + FILE* sf = fopen(stub_path, "w"); + if (!sf) return 0; /* best-effort: leave the build to fail later */ + int rc = ae_generate_binimport_stub(so_path, sf); + fclose(sf); + if (rc != 0) { remove(stub_path); return 0; } + + // Make the stub resolvable and link the artifact (absolute path + + // rpath so the produced binary finds it at run time). The host's + // -rdynamic + static libaether satisfy the .so's runtime symbols. + tc_lib_dir_append_one(stubdir); + char abs_so[1200], dir[1200]; + ae_abspath(so_path, abs_so, sizeof(abs_so)); + snprintf(dir, sizeof(dir), "%s", abs_so); + char* slash = strrchr(dir, '/'); + if (slash) *slash = '\0'; + // Emit the rpath UNQUOTED — `-Wl,-rpath,`, parallel to the + // unquoted `-L%s` this file already uses. Quoting it + // (`-Wl,-rpath,""`) leaked literal quote characters into the + // recorded rpath on macOS (`dyld: tried '"/.../tmp"/lib.dylib'`), + // so the dylib — whose install name `ae build --emit=lib` rewrites + // to `@rpath/` on macOS — was never found at run time. + // Module/lib/temp dirs don't contain spaces, same assumption -L + // relies on. The .so itself stays quoted (it's a plain input file + // and links fine on both platforms). + size_t off = strlen(g_binimport_link); + snprintf(g_binimport_link + off, sizeof(g_binimport_link) - off, + " \"%s\" -Wl,-rpath,%s", abs_so, dir); + if (tc.verbose) { + fprintf(stderr, "ae: binary import '%s' -> %s (stub %s)\n", + mod, abs_so, stub_path); + } + return 0; +} - char stubdir[256] = ""; +// Scan one `.ae` file's `import` lines for binary-package imports, recursing +// into the SOURCE modules it imports so an `import ` in a non-entry +// module (a wrapper/adapter) is discovered too — the prepass must see the whole +// import graph, not just the entry file. `visited` holds the resolved file +// paths already scanned (dedupe + cycle-break). `stubdir` is the shared stub +// dir, created lazily by ae_emit_binimport_stub. +#define AE_BINIMPORT_MAX_FILES 512 +static void ae_scan_binary_imports(const char* file, char* stubdir, + size_t stubdir_cap, + char (*visited)[1200], int* nvisited) { + // Mark this file visited (by its path as given; the entry uses the passed + // spelling, recursions use the resolved path — both are stable enough to + // break cycles and avoid rescanning the same module twice). + for (int i = 0; i < *nvisited; i++) { + if (strcmp(visited[i], file) == 0) return; + } + if (*nvisited >= AE_BINIMPORT_MAX_FILES) return; /* graph too large; stop */ + snprintf(visited[*nvisited], 1200, "%s", file); + (*nvisited)++; + + FILE* f = fopen(file, "r"); + if (!f) return; char line[1024]; while (fgets(line, sizeof(line), f)) { const char* p = line; @@ -3639,60 +3710,68 @@ static void prepare_binary_imports(const char* main_file) { if (strncmp(p, "import", 6) != 0 || (p[6] != ' ' && p[6] != '\t')) continue; p += 6; while (*p == ' ' || *p == '\t') p++; - // Module token: identifier chars only. A '.' means std./contrib./ - // dotted path — never a bare binary import, skip. - char mod[128]; + // Read the whole module token, INCLUDING dots: a bare name (`validate`) + // OR a dotted package path (`harness.components.phone.validate`). We + // must follow dotted source imports too, or a binary import nested + // inside a dotted-path module is never discovered (a wrapper reached as + // `import foo.validate`). The dotted NAME is still never a binary-import + // candidate itself — a binary import is always a bare name — so only the + // recursion into its file changes. + char mod[256]; size_t mi = 0; - while (*p && (isalnum((unsigned char)*p) || *p == '_') && mi < sizeof(mod) - 1) { + int dotted = 0; + while (*p && (isalnum((unsigned char)*p) || *p == '_' || *p == '.') + && mi < sizeof(mod) - 1) { + if (*p == '.') dotted = 1; mod[mi++] = *p++; } mod[mi] = '\0'; - if (mi == 0 || *p == '.') continue; - if (ae_source_module_exists(mod)) continue; + if (mi == 0) continue; + + char src_path[1200]; + if (dotted) { + // A dotted package import: resolve `a.b.c` -> `a/b/c` and recurse + // into that source file (a/b/c.ae or a/b/c/module.ae) so a binary + // import inside it is seen. A dotted name is never a binary import, + // so if it does not resolve to a source file there is nothing to do. + char slashed[256]; + size_t si = 0; + for (size_t k = 0; k < mi && si < sizeof(slashed) - 1; k++) { + slashed[si++] = (mod[k] == '.') ? '/' : mod[k]; + } + slashed[si] = '\0'; + if (ae_source_module_path(slashed, src_path, sizeof(src_path))) { + ae_scan_binary_imports(src_path, stubdir, stubdir_cap, visited, nvisited); + } + continue; + } + + // A flat source module: recurse into its file so a binary import nested + // inside it (a wrapper importing the binary package) is discovered. + if (ae_source_module_path(mod, src_path, sizeof(src_path))) { + ae_scan_binary_imports(src_path, stubdir, stubdir_cap, visited, nvisited); + continue; + } + // Not a source module — a binary-package import if a .so is on the path. char so_path[1200]; if (!ae_find_binimport_so(mod, so_path, sizeof(so_path))) continue; - - if (!stubdir[0]) { - snprintf(stubdir, sizeof(stubdir), "/tmp/ae-binimport-XXXXXX"); - if (!mkdtemp(stubdir)) { stubdir[0] = '\0'; break; } - } - char stub_path[512]; - snprintf(stub_path, sizeof(stub_path), "%s/%s.ae", stubdir, mod); - FILE* sf = fopen(stub_path, "w"); - if (!sf) continue; - int rc = ae_generate_binimport_stub(so_path, sf); - fclose(sf); - if (rc != 0) { remove(stub_path); continue; } - - // Make the stub resolvable and link the artifact (absolute path + - // rpath so the produced binary finds it at run time). The host's - // -rdynamic + static libaether satisfy the .so's runtime symbols. - tc_lib_dir_append_one(stubdir); - char abs_so[1200], dir[1200]; - ae_abspath(so_path, abs_so, sizeof(abs_so)); - snprintf(dir, sizeof(dir), "%s", abs_so); - char* slash = strrchr(dir, '/'); - if (slash) *slash = '\0'; - // Emit the rpath UNQUOTED — `-Wl,-rpath,`, parallel to the - // unquoted `-L%s` this file already uses. Quoting it - // (`-Wl,-rpath,""`) leaked literal quote characters into the - // recorded rpath on macOS (`dyld: tried '"/.../tmp"/lib.dylib'`), - // so the dylib — whose install name `ae build --emit=lib` rewrites - // to `@rpath/` on macOS — was never found at run time. - // Module/lib/temp dirs don't contain spaces, same assumption -L - // relies on. The .so itself stays quoted (it's a plain input file - // and links fine on both platforms). - size_t off = strlen(g_binimport_link); - snprintf(g_binimport_link + off, sizeof(g_binimport_link) - off, - " \"%s\" -Wl,-rpath,%s", abs_so, dir); - if (tc.verbose) { - fprintf(stderr, "ae: binary import '%s' -> %s (stub %s)\n", - mod, abs_so, stub_path); - } + if (ae_emit_binimport_stub(mod, so_path, stubdir, stubdir_cap) != 0) break; } fclose(f); } + +static void prepare_binary_imports(const char* main_file) { + char stubdir[256] = ""; + // Visited-set of file paths, heap-allocated (512 * 1200 B is too large for + // the stack). Best-effort: if allocation fails, fall back to scanning only + // the entry file, the pre-transitive behaviour. + char (*visited)[1200] = malloc((size_t)AE_BINIMPORT_MAX_FILES * 1200); + if (!visited) { return; } + int nvisited = 0; + ae_scan_binary_imports(main_file, stubdir, sizeof(stubdir), visited, &nvisited); + free(visited); +} #else static void prepare_binary_imports(const char* main_file) { (void)main_file; } #endif