diff --git a/examples/115-post-link-callback.sx b/examples/115-post-link-callback.sx new file mode 100644 index 0000000..7f19a96 --- /dev/null +++ b/examples/115-post-link-callback.sx @@ -0,0 +1,26 @@ +#import "modules/std.sx"; +#import "modules/compiler.sx"; + +// Post-link callback registration. The compiler invokes `post_link` +// after `target.link()` returns (sx build). Under `sx run` (JIT) the +// callback is registered but never invoked because there's no link +// phase — so the only thing this example prints under the test +// runner is `runtime main`. The post-link path is exercised via +// `sx build` separately. + +puts :: (s: [:0]u8) -> s32 #foreign libc; + +post_link :: () -> bool { + puts("[post-link] callback fired"); + true; +} + +configure :: () { + opts := build_options(); + opts.set_post_link_callback(post_link); +} +#run configure(); + +main :: () { + print("runtime main\n"); +} diff --git a/examples/116-fs-roundtrip.sx b/examples/116-fs-roundtrip.sx new file mode 100644 index 0000000..e42b959 --- /dev/null +++ b/examples/116-fs-roundtrip.sx @@ -0,0 +1,43 @@ +#import "modules/std.sx"; +#import "modules/fs.sx"; + +// fs.sx smoke test: every primitive the bundling phase needs. +// Creates a temp tree, writes/reads/copies/renames/chmod/deletes +// through it, then exercises basename/dirname. + +main :: () { + if !create_dir_all("/tmp/sx_fs_test/a/b/c") { print("FAIL mkdir_all\n"); return; } + if !exists("/tmp/sx_fs_test/a/b/c") { print("FAIL exists after mkdir_all\n"); return; } + + if !write_file("/tmp/sx_fs_test/hello.txt", "hello fs") { print("FAIL write_file\n"); return; } + if r := read_file("/tmp/sx_fs_test/hello.txt") { + if r.len != 8 { print("FAIL read length: got {}\n", r.len); return; } + print("read: {}\n", r); + } else { + print("FAIL read_file\n"); + return; + } + + if !copy_file("/tmp/sx_fs_test/hello.txt", "/tmp/sx_fs_test/hello.copy") { print("FAIL copy\n"); return; } + if !exists("/tmp/sx_fs_test/hello.copy") { print("FAIL exists after copy\n"); return; } + + if !move("/tmp/sx_fs_test/hello.copy", "/tmp/sx_fs_test/renamed.txt") { print("FAIL rename\n"); return; } + if exists("/tmp/sx_fs_test/hello.copy") { print("FAIL old still exists after rename\n"); return; } + if !exists("/tmp/sx_fs_test/renamed.txt") { print("FAIL new missing after rename\n"); return; } + + if !set_mode("/tmp/sx_fs_test/hello.txt", 493) { print("FAIL chmod\n"); return; } + + if !delete_file("/tmp/sx_fs_test/hello.txt") { print("FAIL delete_file\n"); return; } + if !delete_file("/tmp/sx_fs_test/renamed.txt") { print("FAIL delete renamed\n"); return; } + if !delete_dir("/tmp/sx_fs_test/a/b/c") { print("FAIL delete c\n"); return; } + if !delete_dir("/tmp/sx_fs_test/a/b") { print("FAIL delete b\n"); return; } + if !delete_dir("/tmp/sx_fs_test/a") { print("FAIL delete a\n"); return; } + if !delete_dir("/tmp/sx_fs_test") { print("FAIL delete root\n"); return; } + + print("basename(/a/b/c.txt) = {}\n", basename("/a/b/c.txt")); + print("basename(foo) = {}\n", basename("foo")); + print("dirname(/a/b/c.txt) = {}\n", dirname("/a/b/c.txt")); + print("dirname(foo) = {}\n", dirname("foo")); + + print("ok\n"); +} diff --git a/examples/117-process-roundtrip.sx b/examples/117-process-roundtrip.sx new file mode 100644 index 0000000..583170f --- /dev/null +++ b/examples/117-process-roundtrip.sx @@ -0,0 +1,47 @@ +#import "modules/std.sx"; +#import "modules/process.sx"; + +// process.sx smoke test: run + env + find_executable, with +// success-path and failure-path coverage. +// +// The PATH-startswith check is stable across machines (PATH always +// begins with an absolute path); `ls` is guaranteed in /bin on every +// POSIX host this targets. + +main :: () { + if r := run("echo hello world") { + print("exit={}, stdout={}", r.exit_code, r.stdout); + } else { + print("FAIL run echo\n"); + return; + } + + if r := run("false") { + if r.exit_code == 0 { print("FAIL: false should not exit 0\n"); return; } + print("false exit={}\n", r.exit_code); + } + + if n := env("SX_DEFINITELY_UNSET_VAR") { + print("FAIL: unset var returned: {}\n", n); + return; + } + print("unset var: null (ok)\n"); + + if w := find_executable("ls") { + // /bin/ls on macOS, /usr/bin/ls on Linux. Either is fine — + // we only assert the result is non-empty and absolute. + if w.len < 2 { print("FAIL: ls path too short\n"); return; } + if w[0] != 47 { print("FAIL: ls path not absolute\n"); return; } + print("ls is absolute (ok)\n"); + } else { + print("FAIL find ls\n"); return; + } + + if w := find_executable("sx_definitely_no_such_command_12345") { + print("FAIL: bogus exec returned: {}\n", w); + return; + } + print("missing exec: null (ok)\n"); + + print("ok\n"); +} diff --git a/examples/118-macos-bundle.sx b/examples/118-macos-bundle.sx new file mode 100644 index 0000000..5dcd92b --- /dev/null +++ b/examples/118-macos-bundle.sx @@ -0,0 +1,18 @@ +#import "modules/std.sx"; +#import "modules/compiler.sx"; +#import "modules/platform/bundle.sx"; + +// Register the sx-side `.app` bundler. Under `sx build` on macOS, the +// post-link callback runs and writes a real `.app` next to the +// binary. Under `sx run` (JIT) the callback is registered but never +// fires — so the test runner only sees `runtime main`. + +configure :: () { + opts := build_options(); + opts.set_bundle_path("HelloApp.app"); + opts.set_bundle_id("co.example.hello"); + opts.set_post_link_callback(bundle_main); +} +#run configure(); + +main :: () { print("runtime main\n"); } diff --git a/examples/119-interp-cast-ptr-cmp.sx b/examples/119-interp-cast-ptr-cmp.sx new file mode 100644 index 0000000..e8fcdf1 --- /dev/null +++ b/examples/119-interp-cast-ptr-cmp.sx @@ -0,0 +1,32 @@ +// `cast(T) val` inside a conditional, evaluated by the IR interpreter +// during a post-link callback. The `cast` syntax lowers the type arg +// (`s64`) as a `placeholder` IR op; the interpreter treats placeholders +// as undef so the comparison runs through unchanged. + +#import "modules/std.sx"; +#import "modules/compiler.sx"; + +libc :: #library "c"; +popen :: (cmd: [:0]u8, mode: [:0]u8) -> *void #foreign libc; +puts :: (s: [:0]u8) -> s32 #foreign libc; + +R :: struct { x: s32; } + +bug :: (cmd: [:0]u8) -> ?R { + f := popen(cmd, "r"); + if cast(s64) f == 0 { return null; } + R.{ x = 1 }; +} + +post_link :: () -> bool { + if r := bug("echo hi") { puts("ok"); } else { puts("null"); } + true; +} + +configure :: () { + opts := build_options(); + opts.set_post_link_callback(post_link); +} +#run configure(); + +main :: () { print("rt\n"); } diff --git a/examples/120-interp-variadic-any.sx b/examples/120-interp-variadic-any.sx new file mode 100644 index 0000000..ad4c70d --- /dev/null +++ b/examples/120-interp-variadic-any.sx @@ -0,0 +1,29 @@ +// IR interpreter — variadic `..Any` indexing inside post-link callback. +// +// `format(fmt, args: ..Any)` lowers to `any_to_string(args[i])` calls. +// The interpreter must be able to read every element of the packed +// `[N x Any]` slice from within a `#run`/post-link callback, not just +// the first two — and not just via JIT. + +#import "modules/std.sx"; +#import "modules/compiler.sx"; + +puts :: (s: [:0]u8) -> s32 #foreign libc; + +cb :: () -> bool { + a := format("{}", "x"); + puts("1-arg ok"); + b := format("{} {}", "x", "y"); + puts("2-arg ok"); + c := format("{} {} {}", "x", "y", "z"); + puts("3-arg ok"); + true; +} + +configure :: () { + opts := build_options(); + opts.set_post_link_callback(cb); +} +#run configure(); + +main :: () { print("rt\n"); } diff --git a/examples/121-ios-sim-bundle.sx b/examples/121-ios-sim-bundle.sx new file mode 100644 index 0000000..702db77 --- /dev/null +++ b/examples/121-ios-sim-bundle.sx @@ -0,0 +1,20 @@ +#import "modules/std.sx"; +#import "modules/compiler.sx"; +#import "modules/platform/bundle.sx"; + +// Cross-compile regression for the iOS-simulator branch of +// `platform.bundle`. On a host with the iPhoneSimulator SDK installed, +// `sx build --target ios-sim` writes a `.app` with the iOS-shaped +// Info.plist (UIDeviceFamily, LSRequiresIPhoneOS, +// UIApplicationSceneManifest, DTPlatformName=iPhoneSimulator). Ad-hoc +// codesign; no provisioning embed needed for the simulator. + +configure :: () { + opts := build_options(); + opts.set_bundle_path("IosSimApp.app"); + opts.set_bundle_id("co.example.iossim"); + opts.set_post_link_callback(bundle_main); +} +#run configure(); + +main :: () { print("ios-sim runtime main\n"); } diff --git a/examples/122-ios-device-bundle.sx b/examples/122-ios-device-bundle.sx new file mode 100644 index 0000000..080f56d --- /dev/null +++ b/examples/122-ios-device-bundle.sx @@ -0,0 +1,54 @@ +// iOS *device* end-to-end exercise for `platform.bundle`. Distinct from +// 121-ios-sim-bundle.sx because the device path adds three steps that +// don't run on the simulator: provisioning profile embed, entitlements +// extraction (`security cms` + `plutil` pipeline resolving the +// wildcard `.*` → `.`), and codesign with +// `--entitlements`. +// +// Build: +// sx build --target ios examples/122-ios-device-bundle.sx -o /tmp/SxDeviceProbe +// +// Install + launch (requires the device UDID to be on the profile): +// xcrun devicectl device install app --device /tmp/SxDeviceProbe.app +// xcrun devicectl device process launch --device co.swipelab.sxprobe +// +// The bundle id (`co.swipelab.sxprobe`) and codesign identity below +// match the test team's wildcard `SwipeS32DevProfile.mobileprovision`. +// Update the three set_* values to your own identity / profile / id to +// re-exercise on a different developer account. + +#import "modules/std.sx"; +#import "modules/std/uikit.sx"; +#import "modules/compiler.sx"; +#import "modules/platform/bundle.sx"; + +configure :: () { + opts := build_options(); + opts.set_bundle_path("/tmp/SxDeviceProbe.app"); + opts.set_bundle_id("co.swipelab.sxprobe"); + opts.set_codesign_identity("Apple Development: Alexandru Agrapine (DC8VVHJ9W4)"); + opts.set_provisioning_profile("/Users/agra/Downloads/SwipeS32DevProfile.mobileprovision"); + opts.set_post_link_callback(bundle_main); +} +#run configure(); + +// IMP for application:didFinishLaunchingWithOptions: +// Obj-C: -(BOOL)application:(UIApplication *)app didFinishLaunchingWithOptions:(NSDictionary *)opts +// Type encoding: "c@:@@" -- BOOL (signed char), self, _cmd, id, id +did_finish_launching :: (self: *void, _cmd: *void, app: *void, opts: *void) -> u8 callconv(.c) { + NSLog(ns_string("[sx-device-probe] launched\n".ptr)); + return 1; // YES +} + +main :: () -> s32 { + UIResponder := objc_getClass("UIResponder".ptr); + SxAppDelegate := objc_allocateClassPair(UIResponder, "SxAppDelegate".ptr, 0); + + sel := sel_registerName("application:didFinishLaunchingWithOptions:".ptr); + class_addMethod(SxAppDelegate, sel, xx did_finish_launching, "c@:@@".ptr); + + objc_registerClassPair(SxAppDelegate); + + // UIApplicationMain blocks driving iOS's run loop. + return UIApplicationMain(0, xx 0, xx 0, ns_string("SxAppDelegate".ptr)); +} diff --git a/examples/123-inline-if-import-in-body.sx b/examples/123-inline-if-import-in-body.sx new file mode 100644 index 0000000..4ef6166 --- /dev/null +++ b/examples/123-inline-if-import-in-body.sx @@ -0,0 +1,20 @@ +// Regression: `#import` inside the body of a top-level +// `inline if OS == .X { ... }` block. The imports.zig flatten pass +// (issue-0042) lifts these to the top level before resolution; the +// parser arm in `parseStmt` that accepts them was missing on macOS / +// iOS / linux until this commit, so chess's +// `inline if OS == .android { #import "modules/platform/android.sx"; }` +// pattern broke parse on every non-Android target. +// +// The body here also carries a global decl to mirror chess's shape — +// the prior bug was specifically about hash_import inside an inline-if +// body, not the global decl alongside it. + +#import "modules/std.sx"; + +inline if OS == .android { + #import "modules/std.sx"; + g_android_only : s32 = 0; +} + +main :: () { print("ok\n"); } diff --git a/library/modules/compiler.sx b/library/modules/compiler.sx index 1209cb3..25b911e 100644 --- a/library/modules/compiler.sx +++ b/library/modules/compiler.sx @@ -5,11 +5,71 @@ OS : OperatingSystem = .unknown; ARCH : Architecture = .unknown; POINTER_SIZE : s64 = 8; -BuildOptions :: struct { - add_link_flag :: (self: BuildOptions, flag: [:0]u8) #compiler; - add_framework :: (self: BuildOptions, name: [:0]u8) #compiler; - set_output_path :: (self: BuildOptions, path: [:0]u8) #compiler; - set_wasm_shell :: (self: BuildOptions, path: [:0]u8) #compiler; +BuildOptions :: struct #compiler { + add_link_flag :: (self: BuildOptions, flag: [:0]u8); + add_framework :: (self: BuildOptions, name: [:0]u8); + set_output_path :: (self: BuildOptions, path: [:0]u8); + set_wasm_shell :: (self: BuildOptions, path: [:0]u8); + + // Register a directory of runtime assets to bundle alongside the + // binary. `src` is the path on disk (relative to the CWD at build + // time); `dest` is the relative location inside the bundle / APK. + // Apple .app: copied to `//`. Android APK (Week 7): + // zipped under `/` at the APK root. Idiomatic chess form is + // `opts.add_asset_dir("assets", "assets")`. + add_asset_dir :: (self: BuildOptions, src: [:0]u8, dest: [:0]u8); + asset_dir_count :: (self: BuildOptions) -> s64; + asset_dir_src_at :: (self: BuildOptions, i: s64) -> string; + asset_dir_dest_at :: (self: BuildOptions, i: s64) -> string; + + // Post-link callback. Registers a sx function the compiler will + // invoke after `target.link()` returns. Used by the sx-side + // bundler (`platform.bundle.bundle_main`) and by user programs + // that want custom post-build steps. Return `false` to fail the build. + set_post_link_callback :: (self: BuildOptions, cb: () -> bool); + + // Name-based alternative to `set_post_link_callback`. The + // compiler resolves `.bundle_main` after linking. + set_post_link_module :: (self: BuildOptions, module_name: [:0]u8); + + // Path of the freshly-linked binary, only meaningful while a + // post-link callback is running. Returns "" before linking. + binary_path :: (self: BuildOptions) -> string; + + // Apple `.app` / Android `.apk` bundling parameters. Accessors + // return "" when unset so the bundler can use a single string + // type. macOS bundling needs `bundle_path` and `bundle_id`; + // codesign / provisioning are iOS-device-only. + set_bundle_path :: (self: BuildOptions, path: [:0]u8); + set_bundle_id :: (self: BuildOptions, id: [:0]u8); + set_codesign_identity :: (self: BuildOptions, identity: [:0]u8); + set_provisioning_profile :: (self: BuildOptions, path: [:0]u8); + + bundle_path :: (self: BuildOptions) -> string; + bundle_id :: (self: BuildOptions) -> string; + codesign_identity :: (self: BuildOptions) -> string; + provisioning_profile :: (self: BuildOptions) -> string; + + // Target accessors. Empty triple before linking; predicates mirror + // TargetConfig.is{MacOS,IOS,IOSDevice,IOSSimulator}() on the Zig + // side. Used by the sx bundler to switch Info.plist shape and + // codesigning ceremony per platform. + target_triple :: (self: BuildOptions) -> string; + is_macos :: (self: BuildOptions) -> bool; + is_ios :: (self: BuildOptions) -> bool; + is_ios_device :: (self: BuildOptions) -> bool; + is_ios_simulator :: (self: BuildOptions) -> bool; + + // Framework list accessors. The bundler walks `framework_count() * + // framework_at(i)` to find each `-framework` name and recursively + // copies its `.framework` directory from one of + // `framework_path_at(0..framework_path_count())` into + // `/Frameworks/`. Slice returns aren't natively expressible + // through the compiler-hook bridge yet, hence the indexed form. + framework_count :: (self: BuildOptions) -> s64; + framework_at :: (self: BuildOptions, i: s64) -> string; + framework_path_count :: (self: BuildOptions) -> s64; + framework_path_at :: (self: BuildOptions, i: s64) -> string; } build_options :: () -> BuildOptions #compiler; diff --git a/library/modules/fs.sx b/library/modules/fs.sx new file mode 100644 index 0000000..3aae877 --- /dev/null +++ b/library/modules/fs.sx @@ -0,0 +1,268 @@ +#import "std.sx"; + +// ===================================================================== +// fs.sx — file system stdlib (POSIX backend, macOS values). +// +// Allocation contract: every returned `string` or slice is allocated +// from `context.allocator`. Callers are responsible for releasing it +// (typically via an arena reset). +// +// Handle ownership: `File` is a small value-typed handle wrapping the +// POSIX file descriptor. Methods are provided for read/write/close; +// the value is invalid (fd == -1) after `close()`. +// +// Scope (Phase 1A): file I/O + directory creation/deletion + path +// helpers needed for `.app` bundling. Recursive walkers, `stat`, and +// the full path module land in subsequent phases. +// ===================================================================== + +libc :: #library "c"; + +// ── Low-level libc bindings ───────────────────────────────────────── +// These declare the actual libc symbols and must use the libc names +// verbatim (no prefix), so they live at module top-level. The public +// API below wraps them. Users should not call these directly. +// +// macOS `open` is variadic in C (`int open(const char*, int, ...)`); +// declared with `args: ..s32` so the mode is passed via the C +// variadic tail. Without that, the mode arg goes to the wrong +// register on arm64 and the file ends up with mode 0. + +open :: (path: [:0]u8, flags: s32, args: ..s32) -> s32 #foreign libc; +close :: (fd: s32) -> s32 #foreign libc; +read :: (fd: s32, buf: [*]u8, count: usize) -> isize #foreign libc; +write :: (fd: s32, buf: [*]u8, count: usize) -> isize #foreign libc; +lseek :: (fd: s32, offset: s64, whence: s32) -> s64 #foreign libc; +unlink :: (path: [:0]u8) -> s32 #foreign libc; +rmdir :: (path: [:0]u8) -> s32 #foreign libc; +mkdir :: (path: [:0]u8, mode: u32) -> s32 #foreign libc; +access :: (path: [:0]u8, mode: s32) -> s32 #foreign libc; +chmod :: (path: [:0]u8, mode: u32) -> s32 #foreign libc; +rename :: (oldp: [:0]u8, newp: [:0]u8) -> s32 #foreign libc; + +// macOS POSIX constants. Linux values differ; split into platform- +// conditional includes when we gain a Linux host. +O_RDONLY :s32: 0x0000; +O_WRONLY :s32: 0x0001; +O_RDWR :s32: 0x0002; +O_APPEND :s32: 0x0008; +O_CREAT :s32: 0x0200; +O_TRUNC :s32: 0x0400; + +SEEK_SET :s32: 0; +SEEK_CUR :s32: 1; +SEEK_END :s32: 2; + +F_OK :s32: 0; + +// ── Public types ───────────────────────────────────────────────────── + +OpenMode :: enum { + read; // O_RDONLY + write; // O_WRONLY | O_CREAT | O_TRUNC + append; // O_WRONLY | O_CREAT | O_APPEND + read_write; // O_RDWR +} + +SeekFrom :: enum { set; current; end; } + +File :: struct { + fd: s32 = -1; + + is_valid :: (self: *File) -> bool { self.fd >= 0; } + + close :: (self: *File) -> bool { + if self.fd < 0 { return false; } + rc := close(self.fd); + self.fd = -1; + rc == 0; + } + + read :: (self: *File, buf: string) -> s64 { + if self.fd < 0 { return -1; } + n := read(self.fd, buf.ptr, xx buf.len); + cast(s64) n; + } + + write :: (self: *File, data: string) -> s64 { + if self.fd < 0 { return -1; } + n := write(self.fd, data.ptr, xx data.len); + cast(s64) n; + } + + seek :: (self: *File, offset: s64, whence: SeekFrom) -> s64 { + if self.fd < 0 { return -1; } + w := SEEK_SET; + if whence == .current { w = SEEK_CUR; } + if whence == .end { w = SEEK_END; } + lseek(self.fd, offset, w); + } +} + +// ── High-level file API ───────────────────────────────────────────── +// Named `open_file` (not `open`) so they don't shadow libc's `open` +// symbol; the latter is needed for `#foreign libc` to resolve. Same +// idea for `delete_file`/`delete_dir` vs libc's `unlink`/`rmdir`, +// `set_mode` vs libc's `chmod`, etc. + +mode_to_flags :: (m: OpenMode) -> s32 { + if m == .read { return O_RDONLY; } + if m == .write { return O_WRONLY | O_CREAT | O_TRUNC; } + if m == .append { return O_WRONLY | O_CREAT | O_APPEND; } + if m == .read_write { return O_RDWR; } + O_RDONLY; +} + +open_file :: (path: [:0]u8, mode: OpenMode) -> ?File { + fd := open(path, mode_to_flags(mode), 420); // 0o644 = 420 + if fd < 0 { return null; } + File.{ fd = fd }; +} + +// One-shot read: opens, slurps the whole file into a fresh buffer, +// closes. Returns null on any failure. Uses libc directly (not File +// methods) so it remains callable from the post-link IR interpreter, +// which doesn't yet handle `*Self` method dispatch on locally- +// unwrapped optionals. +read_file :: (path: [:0]u8) -> ?string { + fd := open(path, O_RDONLY, 0); + if fd < 0 { return null; } + size := lseek(fd, 0, SEEK_END); + if size < 0 { close(fd); return null; } + lseek(fd, 0, SEEK_SET); + buf := cstring(size); + n := read(fd, buf.ptr, xx size); + close(fd); + if cast(s64) n != size { return null; } + buf; +} + +// One-shot write: creates / truncates and writes the whole buffer. +write_file :: (path: [:0]u8, data: string) -> bool { + fd := open(path, O_WRONLY | O_CREAT | O_TRUNC, 420); // 0o644 + if fd < 0 { return false; } + n := write(fd, data.ptr, xx data.len); + close(fd); + cast(s64) n == cast(s64) data.len; +} + +append_file :: (path: [:0]u8, data: string) -> bool { + fd := open(path, O_WRONLY | O_CREAT | O_APPEND, 420); + if fd < 0 { return false; } + n := write(fd, data.ptr, xx data.len); + close(fd); + cast(s64) n == cast(s64) data.len; +} + +// ── Single-syscall ops ─────────────────────────────────────────────── + +exists :: (path: [:0]u8) -> bool { + access(path, F_OK) == 0; +} + +delete_file :: (path: [:0]u8) -> bool { + unlink(path) == 0; +} + +delete_dir :: (path: [:0]u8) -> bool { + rmdir(path) == 0; +} + +create_dir :: (path: [:0]u8) -> bool { + mkdir(path, 493) == 0; // 0o755 = 493 +} + +set_mode :: (path: [:0]u8, mode: u32) -> bool { + chmod(path, mode) == 0; +} + +move :: (oldp: [:0]u8, newp: [:0]u8) -> bool { + rename(oldp, newp) == 0; +} + +// Recursive mkdir -p. Walks the path and creates each missing +// segment. Treats existing directories as success. +create_dir_all :: (path: [:0]u8) -> bool { + if path.len == 0 { return true; } + if exists(path) { return true; } + last := path.len - 1; + while last > 0 { + if path[last] == 47 { break; } + last -= 1; + } + if last > 0 { + parent := cstring(last); + memcpy(parent.ptr, path.ptr, last); + if !create_dir_all(parent) { return false; } + } + create_dir(path); +} + +// Copy a file by streaming through a 64KB buffer. Uses libc directly +// (not File methods) — same interpreter-compat reason as read_file. +// No metadata is preserved beyond what `open` creates (mode 0644). +// Caller is responsible for setting executable bits with `set_mode`. +copy_file :: (src: [:0]u8, dst: [:0]u8) -> bool { + src_fd := open(src, O_RDONLY, 0); + if src_fd < 0 { return false; } + dst_fd := open(dst, O_WRONLY | O_CREAT | O_TRUNC, 420); + if dst_fd < 0 { + close(src_fd); + return false; + } + ok := true; + buf := cstring(65536); + loop := true; + while loop { + n := read(src_fd, buf.ptr, 65536); + if n < 0 { ok = false; loop = false; } + if n == 0 { loop = false; } + if n > 0 { + w := write(dst_fd, buf.ptr, xx n); + if w != cast(isize) n { ok = false; loop = false; } + } + } + close(src_fd); + close(dst_fd); + ok; +} + +// ── Path helpers ───────────────────────────────────────────────────── +// `path_join` is in std.sx (used widely beyond fs). These are the +// fs-adjacent helpers — basename/dirname operate purely on text. + +basename :: (p: string) -> string { + if p.len == 0 { return ""; } + last := p.len - 1; + while last > 0 { + if p[last] != 47 { break; } + last -= 1; + } + end := last + 1; + while last > 0 { + if p[last - 1] == 47 { return substr(p, last, end - last); } + last -= 1; + } + substr(p, 0, end); +} + +dirname :: (p: string) -> string { + if p.len == 0 { return ""; } + last := p.len - 1; + while last > 0 { + if p[last] != 47 { break; } + last -= 1; + } + while last > 0 { + if p[last] == 47 { + while last > 0 { + if p[last - 1] != 47 { break; } + last -= 1; + } + return substr(p, 0, last); + } + last -= 1; + } + if p[0] == 47 { return "/"; } + "."; +} diff --git a/library/modules/platform/bundle.sx b/library/modules/platform/bundle.sx new file mode 100644 index 0000000..9f82d2a --- /dev/null +++ b/library/modules/platform/bundle.sx @@ -0,0 +1,519 @@ +#import "../std.sx"; +#import "../compiler.sx"; +#import "../fs.sx"; +#import "../process.sx"; + +// ===================================================================== +// platform.bundle — sx-side Apple `.app` bundler. +// +// Covers all three Apple targets from a single `bundle_main` entry: +// macOS, iOS simulator, iOS device. Per-platform branching is keyed +// off `BuildOptions.is_macos()` / `is_ios_simulator()` / `is_ios_device()` +// so the bundle layout, Info.plist shape, framework embedding, +// provisioning, entitlements, and codesigning ceremony all match what +// the Zig `createBundle` used to produce. +// +// Wiring: users opt in by registering `bundle_main` as the post-link +// callback in their own `#run` block. Example: +// +// #run { +// opts := build_options(); +// opts.set_bundle_path("MyApp.app"); +// opts.set_bundle_id("co.example.myapp"); +// opts.set_post_link_callback(platform.bundle.bundle_main); +// } +// ===================================================================== + +bundle_main :: () -> bool { + opts := build_options(); + binary := opts.binary_path(); + bundle := opts.bundle_path(); + bid := opts.bundle_id(); + + if bundle.len == 0 { + // No bundle requested — nothing to do. Build succeeded. + return true; + } + if bid.len == 0 { + out("error: bundle requires bundle_id (set via set_bundle_id() or --bundle-id)\n"); + return false; + } + if binary.len == 0 { + out("error: bundle: empty binary_path (compiler bug)\n"); + return false; + } + + // Device builds without a real identity will be rejected by the + // device, so fail fast with a clear hint — matches what the legacy + // Zig path did at the top of createBundle. + if opts.is_ios_device() { + if opts.codesign_identity().len == 0 { + out("error: --target ios requires --codesign-identity (e.g. \"Apple Development: ...\") and --provisioning-profile \n"); + return false; + } + } + + bundle_z := str_to_cstr(bundle); + + // Clean previous bundle. `rm -rf` via shell until fs.sx grows + // `delete_dir_all`. + rm_cmd := concat("rm -rf ", bundle); + rm_z := str_to_cstr(rm_cmd); + if r := run(rm_z) { + if r.exit_code != 0 { + out("error: bundle: failed to clean "); + out(bundle); + out("\n"); + return false; + } + } + if !create_dir_all(bundle_z) { + out("error: bundle: cannot create dir "); + out(bundle); + out("\n"); + return false; + } + + // Copy the linked binary into the bundle as ``. Flat + // layout (binary + Info.plist at bundle root) matches the legacy + // Zig path for every Apple target — the canonical macOS + // `Contents/MacOS/` layout is a follow-up. + exe_name := basename(binary); + binary_z := str_to_cstr(binary); + exe_dest := concat(bundle, "/"); + exe_dest = concat(exe_dest, exe_name); + exe_dest_z := str_to_cstr(exe_dest); + if !copy_file(binary_z, exe_dest_z) { + out("error: bundle: copy binary failed\n"); + return false; + } + set_mode(exe_dest_z, 493); // 0o755 = preserve executable bit + + // Write Info.plist. Per-target shape — iOS needs UIDeviceFamily + + // UIApplicationSceneManifest + DTPlatformName, macOS doesn't. + plist := build_info_plist(opts, exe_name, bid); + plist_path := concat(bundle, "/Info.plist"); + plist_path_z := str_to_cstr(plist_path); + if !write_file(plist_path_z, plist) { + out("error: bundle: write Info.plist failed\n"); + return false; + } + + // Embed the provisioning profile if supplied. Required for device + // installs; harmless (and usually omitted) elsewhere. + profile := opts.provisioning_profile(); + if profile.len > 0 { + if !embed_provisioning_profile(profile, bundle) { + return false; + } + } + + // Copy any user-registered asset directories into the bundle. + // Apple .app puts them at `//`. Android (Week 7) will + // zip them into the APK at the same relative path. Recursive copy + // shells out to `cp -R` until fs.sx grows `list_dir`. + asset_count := opts.asset_dir_count(); + j : s64 = 0; + while j < asset_count { + src := opts.asset_dir_src_at(j); + dest := opts.asset_dir_dest_at(j); + if !copy_asset_dir(src, dest, bundle) { + out("error: bundle: failed to copy asset dir '"); + out(src); + out("'\n"); + return false; + } + j += 1; + } + + // iOS apps load dynamic frameworks from + // `.app/Frameworks/.framework/` via the + // `@executable_path/Frameworks` rpath set at link time. Recursive + // copy lives in `embed_framework` until fs.sx grows `list_dir`. + if opts.is_ios() { + fw_count := opts.framework_count(); + if fw_count > 0 { + fw_dir := concat(bundle, "/Frameworks"); + fw_dir_z := str_to_cstr(fw_dir); + if !create_dir_all(fw_dir_z) { + out("error: bundle: cannot create Frameworks dir\n"); + return false; + } + i : s64 = 0; + while i < fw_count { + fw_name := opts.framework_at(i); + if !embed_framework(opts, fw_name, fw_dir) { + // embed_framework emits its own diagnostic; on + // failure we print a warning (matching the legacy + // Zig path) and continue — the link may still have + // resolved the framework against the SDK. + out("warning: framework '"); + out(fw_name); + out("' not embedded; runtime load may fail\n"); + } + i += 1; + } + } + } + + // Codesign. Device builds need real identity + extracted + // entitlements; sim/macOS default to ad-hoc ("-"). + identity := opts.codesign_identity(); + if identity.len == 0 { identity = "-"; } + ent_path := ""; + if opts.is_ios_device() { + if profile.len > 0 { + if e := extract_entitlements(profile, bid) { + ent_path = e; + } else { + out("error: bundle: failed to extract entitlements from provisioning profile\n"); + return false; + } + } + } + if !codesign(bundle, identity, ent_path) { + return false; + } + + out("bundled: "); + out(bundle); + out("\n"); + true; +} + +// ── Helpers ────────────────────────────────────────────────────────── + +// Copy a sx string (slice) into a freshly-allocated null-terminated +// buffer for libc / `[:0]u8` callees. Allocated from +// `context.allocator` like the rest of the bundling stage. +str_to_cstr :: (s: string) -> [:0]u8 { + buf := cstring(s.len); + memcpy(buf.ptr, s.ptr, s.len); + buf; +} + +// Minimum iOS version baked into the Info.plist — matches what the +// Zig path emitted for years. Lift to a setter when a real consumer +// needs a higher floor. +IOS_MIN_OS : string : "14.0"; + +// Build the Info.plist body for the current target. iOS-shaped plists +// carry the keys the iOS launcher needs (UIDeviceFamily, +// LSRequiresIPhoneOS, UIApplicationSceneManifest, DTPlatformName, +// MinimumOSVersion); macOS doesn't need any of those. +build_info_plist :: (opts: BuildOptions, exe_name: string, bundle_id: string) -> string { + if opts.is_ios() { + platform_key := if opts.is_ios_simulator() then "iPhoneSimulator" else "iPhoneOS"; + return format(#string PLIST + + + + + CFBundleIdentifier + {} + CFBundleName + {} + CFBundleExecutable + {} + CFBundlePackageType + APPL + CFBundleVersion + 1 + CFBundleShortVersionString + 0.1 + MinimumOSVersion + {} + UIDeviceFamily + + 1 + + LSRequiresIPhoneOS + + UILaunchScreen + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + SxSceneDelegate + + + + + DTPlatformName + {} + + +PLIST, xml_escape(bundle_id), xml_escape(exe_name), xml_escape(exe_name), IOS_MIN_OS, platform_key); + } + + // macOS (and any non-iOS Apple target) — the minimal plist both + // launchers will accept. + format(#string PLIST + + + + + CFBundleIdentifier + {} + CFBundleName + {} + CFBundleExecutable + {} + CFBundlePackageType + APPL + CFBundleVersion + 1 + CFBundleShortVersionString + 0.1 + + +PLIST, xml_escape(bundle_id), xml_escape(exe_name), xml_escape(exe_name)); +} + +// Read a `.mobileprovision` and write it to +// `/embedded.mobileprovision`. iOS device installer rejects +// the app without this file when a profile-bound identity is used. +embed_provisioning_profile :: (profile: string, bundle: string) -> bool { + profile_z := str_to_cstr(profile); + if data := read_file(profile_z) { + dest := concat(bundle, "/embedded.mobileprovision"); + dest_z := str_to_cstr(dest); + if !write_file(dest_z, data) { + out("error: bundle: failed to write embedded.mobileprovision\n"); + return false; + } + return true; + } + out("error: bundle: cannot read provisioning profile: "); + out(profile); + out("\n"); + false; +} + +// Recursive-copy `` (relative to the build CWD) into +// `//`. Creates intermediate dirs as needed. Returns +// true if `src_dir` doesn't exist (callers can register optional +// asset trees without failing the build). Shells out to `cp -R` +// because fs.sx Phase 1A doesn't expose `list_dir` / `walk` yet. +copy_asset_dir :: (src: string, dest: string, bundle: string) -> bool { + src_z := str_to_cstr(src); + if !exists(src_z) { + // Treating missing src as "nothing to do" lets a project + // register `add_asset_dir("assets", "assets")` unconditionally + // and only ship assets when the dir is present. + return true; + } + dest_full := concat(bundle, "/"); + dest_full = concat(dest_full, dest); + // Parent of dest_full must exist for `cp -R src dest_full` to + // place src as dest_full's contents. We pre-create dest_full so cp + // works in "copy src contents into existing dir" mode by appending + // a trailing `/` to src. + dest_full_z := str_to_cstr(dest_full); + if !create_dir_all(dest_full_z) { + out("error: bundle: cannot create asset dest '"); + out(dest_full); + out("'\n"); + return false; + } + // `cp -R src/. dest/` copies the contents of src into dest. The + // `.` is critical: `cp -R src/ dest/` on macOS BSD cp places src + // *inside* dest as `dest/src/`, which is the wrong shape. + cmd := concat("cp -R \"", src); + cmd = concat(cmd, "/.\" \""); + cmd = concat(cmd, dest_full); + cmd = concat(cmd, "\" 2>&1"); + cmd_z := str_to_cstr(cmd); + if r := run(cmd_z) { + if r.exit_code != 0 { + out("error: cp -R failed:\n"); + out(r.stdout); + return false; + } + return true; + } + out("error: cp -R spawn failed\n"); + false; +} + +// Recursive-copy `.framework` from one of the user's `-F` search +// paths into ``. Walks the framework paths in order; first +// hit wins. Falls back to a `cp -R` subprocess because fs.sx Phase 1A +// doesn't expose `list_dir` / `walk` yet. +embed_framework :: (opts: BuildOptions, name: string, dest_dir: string) -> bool { + subdir := concat(name, ".framework"); + path_count := opts.framework_path_count(); + i : s64 = 0; + while i < path_count { + base := opts.framework_path_at(i); + candidate := concat(base, "/"); + candidate = concat(candidate, subdir); + candidate_z := str_to_cstr(candidate); + if exists(candidate_z) { + dest := concat(dest_dir, "/"); + dest = concat(dest, subdir); + // Shell-quoting is conservative — paths may contain + // spaces (e.g. user's home dir on macOS). Wrap each path + // in double quotes; we trust them not to contain `"`. + cmd := concat("cp -R \"", candidate); + cmd = concat(cmd, "\" \""); + cmd = concat(cmd, dest); + cmd = concat(cmd, "\""); + cmd_z := str_to_cstr(cmd); + if r := run(cmd_z) { + if r.exit_code != 0 { + out("error: cp -R "); + out(candidate); + out(" -> "); + out(dest); + out(" failed\n"); + return false; + } + return true; + } + out("error: cp -R failed to spawn\n"); + return false; + } + i += 1; + } + false; +} + +// Extract entitlements XML from a `.mobileprovision` and resolve the +// `application-identifier` wildcard (`.*`) to the concrete +// bundle ID. Required for iOS device installs — without this +// substitution the device installer rejects the app with +// `MIInstallerErrorDomain error 13` / `0xe8008015`. +// Writes the resolved entitlements to `.sx-tmp/entitlements.plist` +// and returns that path on success. +extract_entitlements :: (profile: string, bundle_id: string) -> ?string { + sx_tmp := str_to_cstr(".sx-tmp"); + create_dir_all(sx_tmp); + + profile_plist := ".sx-tmp/profile.plist"; + ent_path := ".sx-tmp/entitlements.plist"; + + // 1. security cms -D -i -o profile.plist + cmd1 := concat("security cms -D -i \"", profile); + cmd1 = concat(cmd1, "\" -o \""); + cmd1 = concat(cmd1, profile_plist); + cmd1 = concat(cmd1, "\" 2>&1"); + cmd1_z := str_to_cstr(cmd1); + if r := run(cmd1_z) { + if r.exit_code != 0 { + out("error: failed to decode provisioning profile:\n"); + out(r.stdout); + return null; + } + } else { + out("error: security cms spawn failed\n"); + return null; + } + + // 2. plutil -extract Entitlements xml1 -o entitlements.plist profile.plist + cmd2 := concat("plutil -extract Entitlements xml1 -o \"", ent_path); + cmd2 = concat(cmd2, "\" \""); + cmd2 = concat(cmd2, profile_plist); + cmd2 = concat(cmd2, "\" 2>&1"); + cmd2_z := str_to_cstr(cmd2); + if r := run(cmd2_z) { + if r.exit_code != 0 { + out("error: failed to extract entitlements:\n"); + out(r.stdout); + return null; + } + } else { + out("error: plutil extract spawn failed\n"); + return null; + } + + // 3. Read the team identifier from + // `ApplicationIdentifierPrefix.0`. Using + // `com.apple.developer.team-identifier` would confuse plutil — + // dots in plutil paths are interpreted as path separators. + cmd3 := concat("plutil -extract ApplicationIdentifierPrefix.0 raw -o - \"", profile_plist); + cmd3 = concat(cmd3, "\""); + cmd3_z := str_to_cstr(cmd3); + team := ""; + if r := run(cmd3_z) { + if r.exit_code != 0 { + out("error: profile missing ApplicationIdentifierPrefix:\n"); + out(r.stdout); + return null; + } + team = r.stdout; + // Strip trailing whitespace. + while team.len > 0 { + last := team[team.len - 1]; + if last == 10 { team = substr(team, 0, team.len - 1); } + else if last == 13 { team = substr(team, 0, team.len - 1); } + else if last == 32 { team = substr(team, 0, team.len - 1); } + else if last == 9 { team = substr(team, 0, team.len - 1); } + else { break; } + } + } else { + out("error: plutil ApplicationIdentifierPrefix spawn failed\n"); + return null; + } + if team.len == 0 { + out("error: provisioning profile has empty ApplicationIdentifierPrefix\n"); + return null; + } + + // 4. plutil -replace application-identifier -string "." entitlements.plist + resolved_app_id := concat(team, "."); + resolved_app_id = concat(resolved_app_id, bundle_id); + cmd4 := concat("plutil -replace application-identifier -string \"", resolved_app_id); + cmd4 = concat(cmd4, "\" \""); + cmd4 = concat(cmd4, ent_path); + cmd4 = concat(cmd4, "\" 2>&1"); + cmd4_z := str_to_cstr(cmd4); + if r := run(cmd4_z) { + if r.exit_code != 0 { + out("error: failed to resolve application-identifier:\n"); + out(r.stdout); + return null; + } + } else { + out("error: plutil replace spawn failed\n"); + return null; + } + + ent_path; +} + +// Codesign the bundle. Empty `ent_path` means no `--entitlements` +// flag (macOS / iOS-sim / ad-hoc). Folds stderr into stdout so a +// failing run hands the user a useful diagnostic. +codesign :: (bundle: string, identity: string, ent_path: string) -> bool { + cmd := concat("codesign --force --sign \"", identity); + cmd = concat(cmd, "\" --timestamp=none"); + if ent_path.len > 0 { + cmd = concat(cmd, " --entitlements \""); + cmd = concat(cmd, ent_path); + cmd = concat(cmd, "\""); + } + cmd = concat(cmd, " \""); + cmd = concat(cmd, bundle); + cmd = concat(cmd, "\" 2>&1"); + cmd_z := str_to_cstr(cmd); + if r := run(cmd_z) { + if r.exit_code != 0 { + out("error: codesign failed:\n"); + out(r.stdout); + return false; + } + return true; + } + out("error: codesign spawn failed\n"); + false; +} diff --git a/library/modules/process.sx b/library/modules/process.sx new file mode 100644 index 0000000..05fa72f --- /dev/null +++ b/library/modules/process.sx @@ -0,0 +1,118 @@ +#import "std.sx"; + +// ===================================================================== +// process.sx — subprocess + environment stdlib (POSIX backend). +// +// Scope (Phase 1A): one entry point `run(cmd)` that shells out to +// /bin/sh, captures stdout, returns exit code + stdout. Plus +// `env(name)` / `find_executable(name)`. The bundler uses these to +// invoke `codesign`, `plutil`, `security`, `aapt2`, `javac`, `d8`, +// `keytool`, `apksigner`. +// +// Roadmap: phase 1B replaces `popen` with `posix_spawn` + pipes so +// we can capture stderr separately and pass argv without shell +// quoting. Until then, callers responsible for quoting + use 2>&1 +// to fold stderr into the captured stream. +// ===================================================================== + +libc :: #library "c"; + +// ── Low-level libc bindings ───────────────────────────────────────── + +popen :: (cmd: [:0]u8, mode: [:0]u8) -> *void #foreign libc; +pclose :: (stream: *void) -> s32 #foreign libc; +fread :: (ptr: [*]u8, size: usize, nmemb: usize, stream: *void) -> usize #foreign libc; +feof :: (stream: *void) -> s32 #foreign libc; +getenv :: (name: [:0]u8) -> *u8 #foreign libc; +strlen :: (s: *u8) -> usize #foreign libc; +system :: (cmd: [:0]u8) -> s32 #foreign libc; + +// ── Public types ───────────────────────────────────────────────────── + +ProcessResult :: struct { + /// Exit code as reported by `WEXITSTATUS(status)`. 0 = success. + /// Note: doesn't distinguish "killed by signal" from "exited + /// non-zero"; phase 1B will return a tagged union. + exit_code: s32; + stdout: string; +} + +// ── Public API ─────────────────────────────────────────────────────── + +// Run a shell command, capture stdout, wait for exit. Returns null if +// the shell itself couldn't be spawned. A non-zero exit_code with +// valid stdout means the command ran and exited non-zero — distinct +// from spawn failure. +// +// `cmd` is interpreted by /bin/sh — callers MUST quote arguments +// containing spaces or shell metacharacters. To capture stderr along +// with stdout, append " 2>&1" to the command. +run :: (cmd: [:0]u8) -> ?ProcessResult { + f := popen(cmd, "r"); + if cast(s64) f == 0 { return null; } + + out := ""; + buf := cstring(4096); + loop := true; + while loop { + n := fread(buf.ptr, 1, 4096, f); + if n == 0 { loop = false; } + if n > 0 { + chunk : string = ---; + chunk.ptr = buf.ptr; + chunk.len = cast(s64) n; + out = concat(out, chunk); + } + } + raw_status := pclose(f); + if raw_status < 0 { return null; } + // POSIX wait(2) status encoding: low byte = signal (if signaled), + // next byte = exit code (if normally exited). For our MVP we just + // surface the exit-code byte; the signal case is folded into the + // non-zero return. + exit_code := (raw_status >> 8) & 0xFF; + if exit_code == 0 { + if (raw_status & 0x7F) != 0 { + // Killed by signal — surface as a non-zero exit. + exit_code = 128 + (raw_status & 0x7F); + } + } + ProcessResult.{ exit_code = exit_code, stdout = out }; +} + +// Read an environment variable. Returns null if unset; an empty +// string if set to "". +env :: (name: [:0]u8) -> ?string { + p := getenv(name); + addr : s64 = xx p; + if addr == 0 { return null; } + n := strlen(p); + if n == 0 { return ""; } + buf := cstring(cast(s64) n); + memcpy(buf.ptr, xx p, cast(s64) n); + buf; +} + +// Locate an executable by walking `$PATH`. Returns the absolute path +// to the first hit, or null if not found anywhere. Uses `command -v` +// under the shell; cheap and matches what the bundler ultimately +// shells out to anyway. +find_executable :: (name: [:0]u8) -> ?string { + // Compose `command -v ` — name is assumed shell-safe + // (executable names like `codesign`, `javac`, `aapt2`). + cmd := concat("command -v ", name); + // Need null-terminated for popen. + cmd_z := cstring(cmd.len); + memcpy(cmd_z.ptr, cmd.ptr, cmd.len); + if r := run(cmd_z) { + if r.exit_code != 0 { return null; } + // Strip the trailing newline that `command -v` emits. + out := r.stdout; + if out.len > 0 { + if out[out.len - 1] == 10 { out = substr(out, 0, out.len - 1); } + } + if out.len == 0 { return null; } + return out; + } + null; +} diff --git a/library/modules/std.sx b/library/modules/std.sx index a9efd44..bbda085 100644 --- a/library/modules/std.sx +++ b/library/modules/std.sx @@ -152,6 +152,67 @@ substr :: (s: string, start: s64, len: s64) -> string { buf; } +// Replace XML special characters with their entity references. Used +// when emitting Info.plist / AndroidManifest content from sx values +// that may contain user-supplied text (bundle id, app name, etc). +xml_escape :: (s: string) -> string { + result := ""; + i := 0; + seg_start := 0; + while i < s.len { + c := s[i]; + // 38='&', 60='<', 62='>', 34='"', 39='\'' + ent := ""; + if c == 38 { ent = "&"; } + if c == 60 { ent = "<"; } + if c == 62 { ent = ">"; } + if c == 34 { ent = """; } + if c == 39 { ent = "'"; } + if ent.len > 0 { + if i > seg_start { + result = concat(result, substr(s, seg_start, i - seg_start)); + } + result = concat(result, ent); + seg_start = i + 1; + } + i += 1; + } + if seg_start < s.len { + result = concat(result, substr(s, seg_start, s.len - seg_start)); + } + result; +} + +// Join path components with the POSIX separator ('/'). Skips empty +// components and collapses duplicate separators at component +// boundaries. Used for bundle paths where Apple .app and Android APK +// both expect POSIX-style paths. +path_join :: (parts: ..string) -> string { + result := ""; + i := 0; + while i < parts.len { + p := parts[i]; + if p.len > 0 { + if result.len > 0 { + tail := result[result.len - 1]; + head := p[0]; + if tail == 47 { + if head == 47 { + p = substr(p, 1, p.len - 1); + } + } else { + if head != 47 { + result = concat(result, "/"); + } + } + } + result = concat(result, p); + } + i += 1; + } + result; +} + struct_to_string :: (s: $T) -> string { result := concat(type_name(T), "{"); i := 0; diff --git a/src/ast.zig b/src/ast.zig index 72c98d6..36b94a3 100644 --- a/src/ast.zig +++ b/src/ast.zig @@ -125,6 +125,9 @@ pub const Param = struct { type_expr: *Node, is_variadic: bool = false, is_comptime: bool = false, + /// Optional default value expression. When the caller omits this + /// parameter, lowering substitutes this expression in its place. + default_expr: ?*Node = null, }; pub const Block = struct { diff --git a/src/core.zig b/src/core.zig index 3488006..9dc5a6d 100644 --- a/src/core.zig +++ b/src/core.zig @@ -29,6 +29,10 @@ pub const Compilation = struct { import_graph: std.StringHashMap(std.StringHashMap(void)), sema_result: ?sema.SemaResult = null, ir_emitter: ?ir.LLVMEmitter = null, + /// Lowered IR module, kept alive past `generateCode` so post-link + /// callbacks can re-enter the interpreter to invoke sx functions + /// (e.g. `platform.bundle.bundle_main` after `target.link`). + ir_module: ?*ir.Module = null, /// C sources requested by the lowering pass (not in the user's AST). /// E.g. the JNI env TL runtime when `#jni_env` is used. Merged with /// AST sources in `collectCImportSources`. @@ -55,6 +59,10 @@ pub const Compilation = struct { pub fn deinit(self: *Compilation) void { if (self.ir_emitter) |*e| e.deinit(); + if (self.ir_module) |m| { + m.deinit(); + self.allocator.destroy(m); + } self.diagnostics.deinit(); } @@ -124,12 +132,43 @@ pub const Compilation = struct { ir_mod_ptr.* = try self.lowerToIR(); var emitter = ir.LLVMEmitter.init(self.allocator, ir_mod_ptr, "sx_module", self.target_config); emitter.emit(); - // IR module is no longer needed after LLVM IR has been generated - ir_mod_ptr.deinit(); - self.allocator.destroy(ir_mod_ptr); + // Keep the IR module alive past LLVM emission so post-link + // callbacks can re-enter the interpreter via `invokeByName`. + self.ir_module = ir_mod_ptr; self.ir_emitter = emitter; } + /// Re-enter the IR interpreter after `generateCode` (and after linking, + /// if applicable) to invoke a named sx function. Used for the post-link + /// bundling callback. Returns the function's return value, or null if the + /// name doesn't resolve to a function in the lowered module. + pub fn invokeByName(self: *Compilation, name: []const u8, args: []const ir.Value) !?ir.Value { + const mod = self.ir_module orelse return null; + var found_id: ?ir.FuncId = null; + for (mod.functions.items, 0..) |func, i| { + const fname = mod.types.getString(func.name); + if (std.mem.eql(u8, fname, name)) { + found_id = ir.FuncId.fromIndex(@intCast(i)); + break; + } + } + const fid = found_id orelse return null; + return try self.invokeByFuncId(fid, args); + } + + /// Re-enter the IR interpreter and call a previously-resolved function + /// id. Companion to `invokeByName` — used when the FuncId was captured + /// at `#run` time (e.g. by `set_post_link_callback`) and we want to + /// invoke it later without name lookup. + pub fn invokeByFuncId(self: *Compilation, id: ir.FuncId, args: []const ir.Value) !ir.Value { + const mod = self.ir_module orelse return error.NoIRModule; + var interp = ir.Interpreter.init(mod, self.allocator); + defer interp.deinit(); + if (self.ir_emitter) |*e| interp.build_config = &e.build_config; + ir.Interpreter.last_bail_op = null; + return try interp.call(id, args); + } + /// Get link flags accumulated from #run build blocks. pub fn getBuildLinkFlags(self: *Compilation) []const []const u8 { if (self.ir_emitter) |*e| return e.build_config.link_flags.items; @@ -154,6 +193,20 @@ pub const Compilation = struct { return null; } + /// Get the post-link callback function id (set via + /// `BuildOptions.set_post_link_callback(fn)`), if any. + pub fn getPostLinkCallback(self: *Compilation) ?ir.FuncId { + if (self.ir_emitter) |*e| return e.build_config.post_link_callback_fn; + return null; + } + + /// Get the post-link module name (set via + /// `BuildOptions.set_post_link_module("name")`), if any. + pub fn getPostLinkModule(self: *Compilation) ?[]const u8 { + if (self.ir_emitter) |*e| return e.build_config.post_link_module; + return null; + } + /// Collect C import source info — both from user-written `#import c { ... }` /// blocks in the AST AND from lowering-time auto-injections (currently: /// the JNI env TL runtime when `#jni_env` / `#jni_call`-with-omitted-env diff --git a/src/ir/compiler_hooks.zig b/src/ir/compiler_hooks.zig index 4b63ff6..b99c274 100644 --- a/src/ir/compiler_hooks.zig +++ b/src/ir/compiler_hooks.zig @@ -3,19 +3,71 @@ const Allocator = std.mem.Allocator; const interp_mod = @import("interp.zig"); const Value = interp_mod.Value; const Interpreter = interp_mod.Interpreter; +const inst = @import("inst.zig"); +const FuncId = inst.FuncId; // ── BuildConfig ───────────────────────────────────────────────────────── // Mutable build configuration accumulated by #run blocks via #compiler methods. +/// `(src_dir, dest_in_bundle)` pair recorded by +/// `BuildOptions.add_asset_dir(src, dest)`. The sx bundler walks the +/// list and recursively copies each `src` directory into the bundle +/// at the relative `dest` path (e.g. `("assets", "assets")` copies +/// `./assets/` to `/assets/`). Android's Week-7 APK path will +/// zip the same pairs into the unaligned APK. +pub const AssetDir = struct { + src: []const u8, + dest: []const u8, +}; + pub const BuildConfig = struct { link_flags: std.ArrayList([]const u8) = .empty, frameworks: std.ArrayList([]const u8) = .empty, + asset_dirs: std.ArrayList(AssetDir) = .empty, output_path: ?[]const u8 = null, wasm_shell_path: ?[]const u8 = null, + /// Post-link callback registered via + /// `BuildOptions.set_post_link_callback(fn)`. When set, the + /// compiler re-enters the IR interpreter after `target.link()` + /// and invokes this function with no args. A `false` return is + /// treated as a build failure. + post_link_callback_fn: ?FuncId = null, + /// Alternative to `post_link_callback_fn`: the qualified name of + /// a module whose `bundle_main` function should be called + /// post-link. + post_link_module: ?[]const u8 = null, + + /// Path of the freshly-linked binary, populated by `main.zig` + /// right before the post-link callback runs. The sx-side bundler + /// reads this via `binary_path()` to know what file to wrap. + binary_path: ?[]const u8 = null, + + // Apple `.app` / Android `.apk` bundling parameters. Set either + // by the sx-side `BuildOptions.set_bundle_*` methods (preferred) + // or by main.zig from CLI flags (transitional fallback). The sx + // bundler reads them via the matching accessor methods. + bundle_path: ?[]const u8 = null, + bundle_id: ?[]const u8 = null, + codesign_identity: ?[]const u8 = null, + provisioning_profile: ?[]const u8 = null, + + /// Target triple as supplied to `--target` (canonicalized). + /// Populated by main.zig before the post-link callback runs so the + /// sx bundler can switch on iOS vs. macOS vs. simulator. + target_triple: ?[]const u8 = null, + + /// Frameworks the binary links against (`-framework` names) and + /// the search paths to look them up in (`-F` directories), forwarded + /// from the link step so the sx bundler can embed them into + /// `/Frameworks/`. + target_frameworks: []const []const u8 = &.{}, + target_framework_paths: []const []const u8 = &.{}, + pub fn deinit(self: *BuildConfig, alloc: Allocator) void { self.link_flags.deinit(alloc); self.frameworks.deinit(alloc); + self.asset_dirs.deinit(alloc); } }; @@ -55,8 +107,36 @@ pub const Registry = struct { self.hooks.put("build_options", &hookBuildOptions) catch {}; self.hooks.put("BuildOptions.add_link_flag", &hookAddLinkFlag) catch {}; self.hooks.put("BuildOptions.add_framework", &hookAddFramework) catch {}; + self.hooks.put("BuildOptions.add_asset_dir", &hookAddAssetDir) catch {}; + self.hooks.put("BuildOptions.asset_dir_count", &hookAssetDirCount) catch {}; + self.hooks.put("BuildOptions.asset_dir_src_at", &hookAssetDirSrcAt) catch {}; + self.hooks.put("BuildOptions.asset_dir_dest_at", &hookAssetDirDestAt) catch {}; self.hooks.put("BuildOptions.set_output_path", &hookSetOutputPath) catch {}; self.hooks.put("BuildOptions.set_wasm_shell", &hookSetWasmShell) catch {}; + self.hooks.put("BuildOptions.set_post_link_callback", &hookSetPostLinkCallback) catch {}; + self.hooks.put("BuildOptions.set_post_link_module", &hookSetPostLinkModule) catch {}; + self.hooks.put("BuildOptions.binary_path", &hookGetBinaryPath) catch {}; + // Bundling setters + self.hooks.put("BuildOptions.set_bundle_path", &hookSetBundlePath) catch {}; + self.hooks.put("BuildOptions.set_bundle_id", &hookSetBundleId) catch {}; + self.hooks.put("BuildOptions.set_codesign_identity", &hookSetCodesignIdentity) catch {}; + self.hooks.put("BuildOptions.set_provisioning_profile", &hookSetProvisioningProfile) catch {}; + // Bundling accessors + self.hooks.put("BuildOptions.bundle_path", &hookGetBundlePath) catch {}; + self.hooks.put("BuildOptions.bundle_id", &hookGetBundleId) catch {}; + self.hooks.put("BuildOptions.codesign_identity", &hookGetCodesignIdentity) catch {}; + self.hooks.put("BuildOptions.provisioning_profile", &hookGetProvisioningProfile) catch {}; + // Target accessors — mirror TargetConfig.is{MacOS,IOS,IOSDevice,IOSSimulator}() + self.hooks.put("BuildOptions.target_triple", &hookGetTargetTriple) catch {}; + self.hooks.put("BuildOptions.is_macos", &hookIsMacOS) catch {}; + self.hooks.put("BuildOptions.is_ios", &hookIsIOS) catch {}; + self.hooks.put("BuildOptions.is_ios_device", &hookIsIOSDevice) catch {}; + self.hooks.put("BuildOptions.is_ios_simulator", &hookIsIOSSimulator) catch {}; + // Framework list accessors (for `.app/Frameworks/` embedding) + self.hooks.put("BuildOptions.framework_count", &hookFrameworkCount) catch {}; + self.hooks.put("BuildOptions.framework_at", &hookFrameworkAt) catch {}; + self.hooks.put("BuildOptions.framework_path_count", &hookFrameworkPathCount) catch {}; + self.hooks.put("BuildOptions.framework_path_at", &hookFrameworkPathAt) catch {}; } }; @@ -105,6 +185,40 @@ fn hookAddFramework( return .void_val; } +fn hookAddAssetDir( + interp: *const Interpreter, + args: []const Value, + bc: *BuildConfig, + alloc: Allocator, +) HookError!Value { + // args: [self (BuildOptions value), src_path, dest_path_in_bundle] + if (args.len < 3) return .void_val; + const src = args[1].asString(interp) orelse return error.TypeError; + const dest = args[2].asString(interp) orelse return error.TypeError; + const src_dup = alloc.dupe(u8, src) catch return error.CannotEvalComptime; + const dest_dup = alloc.dupe(u8, dest) catch return error.CannotEvalComptime; + bc.asset_dirs.append(alloc, .{ .src = src_dup, .dest = dest_dup }) catch return error.CannotEvalComptime; + return .void_val; +} + +fn hookAssetDirCount(_: *const Interpreter, _: []const Value, bc: *BuildConfig, _: Allocator) HookError!Value { + return Value{ .int = @intCast(bc.asset_dirs.items.len) }; +} + +fn hookAssetDirSrcAt(_: *const Interpreter, args: []const Value, bc: *BuildConfig, _: Allocator) HookError!Value { + if (args.len < 2) return Value{ .string = "" }; + const idx = args[1].asInt() orelse return error.TypeError; + if (idx < 0 or @as(usize, @intCast(idx)) >= bc.asset_dirs.items.len) return Value{ .string = "" }; + return Value{ .string = bc.asset_dirs.items[@intCast(idx)].src }; +} + +fn hookAssetDirDestAt(_: *const Interpreter, args: []const Value, bc: *BuildConfig, _: Allocator) HookError!Value { + if (args.len < 2) return Value{ .string = "" }; + const idx = args[1].asInt() orelse return error.TypeError; + if (idx < 0 or @as(usize, @intCast(idx)) >= bc.asset_dirs.items.len) return Value{ .string = "" }; + return Value{ .string = bc.asset_dirs.items[@intCast(idx)].dest }; +} + fn hookSetOutputPath( interp: *const Interpreter, args: []const Value, @@ -134,3 +248,199 @@ fn hookSetWasmShell( } return .void_val; } + +fn hookSetPostLinkCallback( + _: *const Interpreter, + args: []const Value, + bc: *BuildConfig, + _: Allocator, +) HookError!Value { + // args: [self (BuildOptions value), fn_value]. We accept a function + // value (.func_ref) and stash the FuncId so `main.zig` can re-enter + // the interpreter after linking. + if (args.len < 2) return .void_val; + switch (args[1]) { + .func_ref => |id| bc.post_link_callback_fn = id, + else => return error.TypeError, + } + return .void_val; +} + +fn hookSetPostLinkModule( + interp: *const Interpreter, + args: []const Value, + bc: *BuildConfig, + alloc: Allocator, +) HookError!Value { + if (args.len < 2) return .void_val; + if (args[1].asString(interp)) |s| { + bc.post_link_module = alloc.dupe(u8, s) catch return error.CannotEvalComptime; + } + return .void_val; +} + +/// Read the linked-binary path that main.zig populated right before +/// invoking the post-link callback. Returns the fat-string aggregate +/// the interpreter normally hands out for sx `string` values. +fn hookGetBinaryPath( + interp: *const Interpreter, + _: []const Value, + bc: *BuildConfig, + _: Allocator, +) HookError!Value { + _ = interp; + const path = bc.binary_path orelse ""; + return Value{ .string = path }; +} + +// ── Bundling setters & accessors ───────────────────────────────────── +// Same pattern as set_output_path: take a string arg, dupe into the +// long-lived allocator, store on BuildConfig. The companion accessor +// reads back the same field; empty string when unset. + +fn hookSetBundlePath( + interp: *const Interpreter, + args: []const Value, + bc: *BuildConfig, + alloc: Allocator, +) HookError!Value { + if (args.len < 2) return .void_val; + if (args[1].asString(interp)) |s| { + bc.bundle_path = alloc.dupe(u8, s) catch return error.CannotEvalComptime; + } + return .void_val; +} + +fn hookSetBundleId( + interp: *const Interpreter, + args: []const Value, + bc: *BuildConfig, + alloc: Allocator, +) HookError!Value { + if (args.len < 2) return .void_val; + if (args[1].asString(interp)) |s| { + bc.bundle_id = alloc.dupe(u8, s) catch return error.CannotEvalComptime; + } + return .void_val; +} + +fn hookSetCodesignIdentity( + interp: *const Interpreter, + args: []const Value, + bc: *BuildConfig, + alloc: Allocator, +) HookError!Value { + if (args.len < 2) return .void_val; + if (args[1].asString(interp)) |s| { + bc.codesign_identity = alloc.dupe(u8, s) catch return error.CannotEvalComptime; + } + return .void_val; +} + +fn hookSetProvisioningProfile( + interp: *const Interpreter, + args: []const Value, + bc: *BuildConfig, + alloc: Allocator, +) HookError!Value { + if (args.len < 2) return .void_val; + if (args[1].asString(interp)) |s| { + bc.provisioning_profile = alloc.dupe(u8, s) catch return error.CannotEvalComptime; + } + return .void_val; +} + +fn hookGetBundlePath(_: *const Interpreter, _: []const Value, bc: *BuildConfig, _: Allocator) HookError!Value { + return Value{ .string = bc.bundle_path orelse "" }; +} + +fn hookGetBundleId(_: *const Interpreter, _: []const Value, bc: *BuildConfig, _: Allocator) HookError!Value { + return Value{ .string = bc.bundle_id orelse "" }; +} + +fn hookGetCodesignIdentity(_: *const Interpreter, _: []const Value, bc: *BuildConfig, _: Allocator) HookError!Value { + return Value{ .string = bc.codesign_identity orelse "" }; +} + +fn hookGetProvisioningProfile(_: *const Interpreter, _: []const Value, bc: *BuildConfig, _: Allocator) HookError!Value { + return Value{ .string = bc.provisioning_profile orelse "" }; +} + +// ── Target accessors ────────────────────────────────────────────────── +// These look at the target_triple that main.zig populates and answer +// the same questions TargetConfig's helpers do for Zig callers. + +fn tripleContains(triple: ?[]const u8, needle: []const u8) bool { + const t = triple orelse return false; + return std.mem.indexOf(u8, t, needle) != null; +} + +fn isIOSTriple(triple: ?[]const u8) bool { + return tripleContains(triple, "apple-ios"); +} + +fn hookGetTargetTriple(_: *const Interpreter, _: []const Value, bc: *BuildConfig, _: Allocator) HookError!Value { + return Value{ .string = bc.target_triple orelse "" }; +} + +fn hookIsMacOS(_: *const Interpreter, _: []const Value, bc: *BuildConfig, _: Allocator) HookError!Value { + if (isIOSTriple(bc.target_triple)) return Value{ .boolean = false }; + const t = bc.target_triple orelse ""; + const is_mac = std.mem.indexOf(u8, t, "apple-macosx") != null or + std.mem.indexOf(u8, t, "apple-macos") != null or + std.mem.indexOf(u8, t, "apple-darwin") != null; + return Value{ .boolean = is_mac }; +} + +fn hookIsIOS(_: *const Interpreter, _: []const Value, bc: *BuildConfig, _: Allocator) HookError!Value { + return Value{ .boolean = isIOSTriple(bc.target_triple) }; +} + +fn hookIsIOSDevice(_: *const Interpreter, _: []const Value, bc: *BuildConfig, _: Allocator) HookError!Value { + const ios = isIOSTriple(bc.target_triple); + const sim = tripleContains(bc.target_triple, "simulator"); + return Value{ .boolean = ios and !sim }; +} + +fn hookIsIOSSimulator(_: *const Interpreter, _: []const Value, bc: *BuildConfig, _: Allocator) HookError!Value { + const ios = isIOSTriple(bc.target_triple); + const sim = tripleContains(bc.target_triple, "simulator"); + return Value{ .boolean = ios and sim }; +} + +// ── Framework list accessors ────────────────────────────────────────── +// The Apple .app bundler in `library/modules/platform/bundle.sx` walks +// the framework list to recursively copy each `.framework` +// directory from the user's -F search paths into `/Frameworks/`. +// Slice-of-string returns aren't natively expressible as a Value, so we +// expose count + indexed lookups instead. + +fn intValue(n: i64) Value { + return Value{ .int = n }; +} + +fn hookFrameworkCount(_: *const Interpreter, _: []const Value, bc: *BuildConfig, _: Allocator) HookError!Value { + return intValue(@intCast(bc.target_frameworks.len)); +} + +fn hookFrameworkAt(_: *const Interpreter, args: []const Value, bc: *BuildConfig, _: Allocator) HookError!Value { + if (args.len < 2) return Value{ .string = "" }; + const idx_i64 = args[1].asInt() orelse return error.TypeError; + if (idx_i64 < 0 or @as(usize, @intCast(idx_i64)) >= bc.target_frameworks.len) { + return Value{ .string = "" }; + } + return Value{ .string = bc.target_frameworks[@intCast(idx_i64)] }; +} + +fn hookFrameworkPathCount(_: *const Interpreter, _: []const Value, bc: *BuildConfig, _: Allocator) HookError!Value { + return intValue(@intCast(bc.target_framework_paths.len)); +} + +fn hookFrameworkPathAt(_: *const Interpreter, args: []const Value, bc: *BuildConfig, _: Allocator) HookError!Value { + if (args.len < 2) return Value{ .string = "" }; + const idx_i64 = args[1].asInt() orelse return error.TypeError; + if (idx_i64 < 0 or @as(usize, @intCast(idx_i64)) >= bc.target_framework_paths.len) { + return Value{ .string = "" }; + } + return Value{ .string = bc.target_framework_paths[@intCast(idx_i64)] }; +} diff --git a/src/ir/host_ffi.zig b/src/ir/host_ffi.zig new file mode 100644 index 0000000..2be92ef --- /dev/null +++ b/src/ir/host_ffi.zig @@ -0,0 +1,155 @@ +//! Host FFI dispatch for the IR interpreter. +//! +//! When the interpreter encounters a call to an extern function during `#run` +//! (or post-link interpretation), it has no body to walk. This module +//! `dlsym`s the symbol from the host's already-loaded dylibs (libc, libSystem, +//! kernel32, whatever the OS provides) and calls it via an arity-switched +//! cdecl function pointer. +//! +//! Limits: +//! * Up to 8 cdecl-passed arguments. Beyond that, return error. +//! * All arguments are marshalled to / from `usize`. Pointer-sized integers, +//! pointers, null-terminated strings, and booleans are supported; floats, +//! aggregates passed by value, and varargs are not. +//! * Return value can be void, integer (i64), pointer (usize), or boolean. + +const std = @import("std"); + +const RTLD_DEFAULT: ?*anyopaque = @ptrFromInt(@as(usize, @bitCast(@as(isize, -2)))); + +extern "c" fn dlsym(handle: ?*anyopaque, name: [*:0]const u8) ?*anyopaque; + +/// Look up an extern symbol in the host's loaded image. Returns null if not +/// found. +pub fn lookupSymbol(allocator: std.mem.Allocator, name: []const u8) !?*anyopaque { + const name_z = try allocator.allocSentinel(u8, name.len, 0); + defer allocator.free(name_z); + @memcpy(name_z[0..name.len], name); + return dlsym(RTLD_DEFAULT, name_z.ptr); +} + +/// Call a cdecl symbol that returns `i64`. Args are pre-marshalled to `usize`. +pub fn callIntRet(symbol: *anyopaque, args: []const usize) !i64 { + return switch (args.len) { + 0 => @as(*const fn () callconv(.c) i64, @ptrCast(@alignCast(symbol)))(), + 1 => @as(*const fn (usize) callconv(.c) i64, @ptrCast(@alignCast(symbol)))(args[0]), + 2 => @as(*const fn (usize, usize) callconv(.c) i64, @ptrCast(@alignCast(symbol)))(args[0], args[1]), + 3 => @as(*const fn (usize, usize, usize) callconv(.c) i64, @ptrCast(@alignCast(symbol)))(args[0], args[1], args[2]), + 4 => @as(*const fn (usize, usize, usize, usize) callconv(.c) i64, @ptrCast(@alignCast(symbol)))(args[0], args[1], args[2], args[3]), + 5 => @as(*const fn (usize, usize, usize, usize, usize) callconv(.c) i64, @ptrCast(@alignCast(symbol)))(args[0], args[1], args[2], args[3], args[4]), + 6 => @as(*const fn (usize, usize, usize, usize, usize, usize) callconv(.c) i64, @ptrCast(@alignCast(symbol)))(args[0], args[1], args[2], args[3], args[4], args[5]), + 7 => @as(*const fn (usize, usize, usize, usize, usize, usize, usize) callconv(.c) i64, @ptrCast(@alignCast(symbol)))(args[0], args[1], args[2], args[3], args[4], args[5], args[6]), + 8 => @as(*const fn (usize, usize, usize, usize, usize, usize, usize, usize) callconv(.c) i64, @ptrCast(@alignCast(symbol)))(args[0], args[1], args[2], args[3], args[4], args[5], args[6], args[7]), + else => return error.TooManyArgs, + }; +} + +/// Call a cdecl symbol that returns a pointer (or any pointer-sized value). +pub fn callPtrRet(symbol: *anyopaque, args: []const usize) !usize { + return switch (args.len) { + 0 => @as(*const fn () callconv(.c) usize, @ptrCast(@alignCast(symbol)))(), + 1 => @as(*const fn (usize) callconv(.c) usize, @ptrCast(@alignCast(symbol)))(args[0]), + 2 => @as(*const fn (usize, usize) callconv(.c) usize, @ptrCast(@alignCast(symbol)))(args[0], args[1]), + 3 => @as(*const fn (usize, usize, usize) callconv(.c) usize, @ptrCast(@alignCast(symbol)))(args[0], args[1], args[2]), + 4 => @as(*const fn (usize, usize, usize, usize) callconv(.c) usize, @ptrCast(@alignCast(symbol)))(args[0], args[1], args[2], args[3]), + 5 => @as(*const fn (usize, usize, usize, usize, usize) callconv(.c) usize, @ptrCast(@alignCast(symbol)))(args[0], args[1], args[2], args[3], args[4]), + 6 => @as(*const fn (usize, usize, usize, usize, usize, usize) callconv(.c) usize, @ptrCast(@alignCast(symbol)))(args[0], args[1], args[2], args[3], args[4], args[5]), + 7 => @as(*const fn (usize, usize, usize, usize, usize, usize, usize) callconv(.c) usize, @ptrCast(@alignCast(symbol)))(args[0], args[1], args[2], args[3], args[4], args[5], args[6]), + 8 => @as(*const fn (usize, usize, usize, usize, usize, usize, usize, usize) callconv(.c) usize, @ptrCast(@alignCast(symbol)))(args[0], args[1], args[2], args[3], args[4], args[5], args[6], args[7]), + else => return error.TooManyArgs, + }; +} + +/// Call a cdecl symbol with void return. +pub fn callVoidRet(symbol: *anyopaque, args: []const usize) !void { + switch (args.len) { + 0 => @as(*const fn () callconv(.c) void, @ptrCast(@alignCast(symbol)))(), + 1 => @as(*const fn (usize) callconv(.c) void, @ptrCast(@alignCast(symbol)))(args[0]), + 2 => @as(*const fn (usize, usize) callconv(.c) void, @ptrCast(@alignCast(symbol)))(args[0], args[1]), + 3 => @as(*const fn (usize, usize, usize) callconv(.c) void, @ptrCast(@alignCast(symbol)))(args[0], args[1], args[2]), + 4 => @as(*const fn (usize, usize, usize, usize) callconv(.c) void, @ptrCast(@alignCast(symbol)))(args[0], args[1], args[2], args[3]), + 5 => @as(*const fn (usize, usize, usize, usize, usize) callconv(.c) void, @ptrCast(@alignCast(symbol)))(args[0], args[1], args[2], args[3], args[4]), + 6 => @as(*const fn (usize, usize, usize, usize, usize, usize) callconv(.c) void, @ptrCast(@alignCast(symbol)))(args[0], args[1], args[2], args[3], args[4], args[5]), + 7 => @as(*const fn (usize, usize, usize, usize, usize, usize, usize) callconv(.c) void, @ptrCast(@alignCast(symbol)))(args[0], args[1], args[2], args[3], args[4], args[5], args[6]), + 8 => @as(*const fn (usize, usize, usize, usize, usize, usize, usize, usize) callconv(.c) void, @ptrCast(@alignCast(symbol)))(args[0], args[1], args[2], args[3], args[4], args[5], args[6], args[7]), + else => return error.TooManyArgs, + } +} + +// ── Variadic cdecl dispatch ───────────────────────────────────────── +// For foreign functions declared with `args: ..T` (C-variadic, e.g. +// libc `open(path, flags, ...)`). The trailing args must be passed +// through the C-variadic ABI — arm64 places them on the stack rather +// than in argument registers. Calling a variadic function as if it +// were fixed-arity puts the mode/etc. in the wrong register and the +// callee reads garbage. +// +// The dispatch is keyed by (fixed_count, total_args). `fixed_count` +// is the number of declared (non-variadic) params; trailing +// `total_args - fixed_count` slots are the variadic tail. The Zig +// function-pointer types use `...` so the compiler emits +// proper-variadic calls. + +pub fn callIntRetVar(symbol: *anyopaque, fixed: usize, args: []const usize) !i64 { + if (args.len < fixed) return error.TooFewArgs; + // Special-case the shapes we actually use today; extend as + // needed. fixed_count > total is impossible. + return switch (fixed) { + 2 => switch (args.len) { + 2 => @as(*const fn (usize, usize, ...) callconv(.c) i64, @ptrCast(@alignCast(symbol)))(args[0], args[1]), + 3 => @as(*const fn (usize, usize, ...) callconv(.c) i64, @ptrCast(@alignCast(symbol)))(args[0], args[1], args[2]), + 4 => @as(*const fn (usize, usize, ...) callconv(.c) i64, @ptrCast(@alignCast(symbol)))(args[0], args[1], args[2], args[3]), + 5 => @as(*const fn (usize, usize, ...) callconv(.c) i64, @ptrCast(@alignCast(symbol)))(args[0], args[1], args[2], args[3], args[4]), + else => return error.TooManyArgs, + }, + 1 => switch (args.len) { + 1 => @as(*const fn (usize, ...) callconv(.c) i64, @ptrCast(@alignCast(symbol)))(args[0]), + 2 => @as(*const fn (usize, ...) callconv(.c) i64, @ptrCast(@alignCast(symbol)))(args[0], args[1]), + 3 => @as(*const fn (usize, ...) callconv(.c) i64, @ptrCast(@alignCast(symbol)))(args[0], args[1], args[2]), + 4 => @as(*const fn (usize, ...) callconv(.c) i64, @ptrCast(@alignCast(symbol)))(args[0], args[1], args[2], args[3]), + 5 => @as(*const fn (usize, ...) callconv(.c) i64, @ptrCast(@alignCast(symbol)))(args[0], args[1], args[2], args[3], args[4]), + else => return error.TooManyArgs, + }, + else => return error.UnsupportedVariadicArity, + }; +} + +pub fn callPtrRetVar(symbol: *anyopaque, fixed: usize, args: []const usize) !usize { + if (args.len < fixed) return error.TooFewArgs; + return switch (fixed) { + 2 => switch (args.len) { + 2 => @as(*const fn (usize, usize, ...) callconv(.c) usize, @ptrCast(@alignCast(symbol)))(args[0], args[1]), + 3 => @as(*const fn (usize, usize, ...) callconv(.c) usize, @ptrCast(@alignCast(symbol)))(args[0], args[1], args[2]), + 4 => @as(*const fn (usize, usize, ...) callconv(.c) usize, @ptrCast(@alignCast(symbol)))(args[0], args[1], args[2], args[3]), + else => return error.TooManyArgs, + }, + 1 => switch (args.len) { + 1 => @as(*const fn (usize, ...) callconv(.c) usize, @ptrCast(@alignCast(symbol)))(args[0]), + 2 => @as(*const fn (usize, ...) callconv(.c) usize, @ptrCast(@alignCast(symbol)))(args[0], args[1]), + 3 => @as(*const fn (usize, ...) callconv(.c) usize, @ptrCast(@alignCast(symbol)))(args[0], args[1], args[2]), + 4 => @as(*const fn (usize, ...) callconv(.c) usize, @ptrCast(@alignCast(symbol)))(args[0], args[1], args[2], args[3]), + else => return error.TooManyArgs, + }, + else => return error.UnsupportedVariadicArity, + }; +} + +pub fn callVoidRetVar(symbol: *anyopaque, fixed: usize, args: []const usize) !void { + if (args.len < fixed) return error.TooFewArgs; + switch (fixed) { + 2 => switch (args.len) { + 2 => @as(*const fn (usize, usize, ...) callconv(.c) void, @ptrCast(@alignCast(symbol)))(args[0], args[1]), + 3 => @as(*const fn (usize, usize, ...) callconv(.c) void, @ptrCast(@alignCast(symbol)))(args[0], args[1], args[2]), + 4 => @as(*const fn (usize, usize, ...) callconv(.c) void, @ptrCast(@alignCast(symbol)))(args[0], args[1], args[2], args[3]), + else => return error.TooManyArgs, + }, + 1 => switch (args.len) { + 1 => @as(*const fn (usize, ...) callconv(.c) void, @ptrCast(@alignCast(symbol)))(args[0]), + 2 => @as(*const fn (usize, ...) callconv(.c) void, @ptrCast(@alignCast(symbol)))(args[0], args[1]), + 3 => @as(*const fn (usize, ...) callconv(.c) void, @ptrCast(@alignCast(symbol)))(args[0], args[1], args[2]), + 4 => @as(*const fn (usize, ...) callconv(.c) void, @ptrCast(@alignCast(symbol)))(args[0], args[1], args[2], args[3]), + else => return error.TooManyArgs, + }, + else => return error.UnsupportedVariadicArity, + } +} diff --git a/src/ir/interp.zig b/src/ir/interp.zig index 2e89eb5..3334c27 100644 --- a/src/ir/interp.zig +++ b/src/ir/interp.zig @@ -108,6 +108,7 @@ pub const InterpError = error{ const compiler_hooks = @import("compiler_hooks.zig"); pub const BuildConfig = compiler_hooks.BuildConfig; +const host_ffi = @import("host_ffi.zig"); // ── Interpreter ───────────────────────────────────────────────────────── @@ -130,6 +131,14 @@ pub const Interpreter = struct { // Compiler hook registry for #compiler methods hooks: compiler_hooks.Registry, + // First op tag that bailed with InterpError, captured the first + // time the interpreter unwinds so callers can surface "op=foo at + // :" alongside the bare error name. Static so it + // survives Interpreter teardown (lifetime: program global). + pub var last_bail_op: ?[]const u8 = null; + pub var last_bail_file: ?[]const u8 = null; + pub var last_bail_offset: u32 = 0; + pub fn init(module: *const Module, alloc: Allocator) Interpreter { var hooks = compiler_hooks.Registry.init(alloc); hooks.registerDefaults(); @@ -217,6 +226,121 @@ pub const Interpreter = struct { return .undef; } + /// Marshal a single sx Value into a `usize` slot for a cdecl host call. + /// Strings are made null-terminated; pointer-like values pass their + /// underlying address. The returned `usize` is only valid for the + /// duration of this call — caller-allocated buffers are tracked in + /// `tmp` so they get freed once the call returns. + fn marshalForeignArg(self: *Interpreter, v: Value, tmp: *std.ArrayList([]u8)) !usize { + return switch (v) { + .int => |i| @bitCast(i), + .boolean => |b| @intFromBool(b), + .null_val => 0, + .heap_ptr => |hp| blk: { + const mem = self.heapSlice(hp) orelse return error.TypeError; + break :blk @intFromPtr(mem.ptr) + hp.offset; + }, + .string => |s| blk: { + const buf = try self.alloc.alloc(u8, s.len + 1); + @memcpy(buf[0..s.len], s); + buf[s.len] = 0; + tmp.append(self.alloc, buf) catch return error.TypeError; + break :blk @intFromPtr(buf.ptr); + }, + .aggregate => |fields| blk: { + // Fat string pointer: { ptr, len }. Pass the raw bytes + // null-terminated so libc string APIs work. + if (fields.len == 2) { + const len: usize = @intCast(fields[1].asInt() orelse return error.TypeError); + switch (fields[0]) { + .heap_ptr => |hp| { + const mem = self.heapSlice(hp) orelse return error.TypeError; + const start = hp.offset; + const slice = mem[start .. start + len]; + const buf = try self.alloc.alloc(u8, len + 1); + @memcpy(buf[0..len], slice); + buf[len] = 0; + tmp.append(self.alloc, buf) catch return error.TypeError; + break :blk @intFromPtr(buf.ptr); + }, + .string => |s| { + const slice = if (len <= s.len) s[0..len] else s; + const buf = try self.alloc.alloc(u8, slice.len + 1); + @memcpy(buf[0..slice.len], slice); + buf[slice.len] = 0; + tmp.append(self.alloc, buf) catch return error.TypeError; + break :blk @intFromPtr(buf.ptr); + }, + else => return error.TypeError, + } + } + return error.TypeError; + }, + else => error.TypeError, + }; + } + + fn callForeign(self: *Interpreter, func: *const inst_mod.Function, args: []const Value) InterpError!Value { + const name = self.module.types.getString(func.name); + const symbol = (host_ffi.lookupSymbol(self.alloc, name) catch return error.CannotEvalComptime) orelse { + return error.CannotEvalComptime; + }; + + var packed_args: [8]usize = undefined; + if (args.len > packed_args.len) return error.CannotEvalComptime; + + var tmp = std.ArrayList([]u8).empty; + defer { + for (tmp.items) |buf| self.alloc.free(buf); + tmp.deinit(self.alloc); + } + for (args, 0..) |a, i| { + packed_args[i] = self.marshalForeignArg(a, &tmp) catch return error.TypeError; + } + const argv = packed_args[0..args.len]; + + // Variadic foreign functions (declared `args: ..T`) must be + // dispatched through C-variadic trampolines so the trailing + // args land in the right place per the target's variadic + // ABI. The fixed-arity trampolines would put them in arg + // registers, and the callee would read garbage from the + // stack. + const fixed = func.params.len; + const variadic = func.is_variadic and args.len > fixed; + + const ret = func.ret; + if (ret == .void) { + if (variadic) { + host_ffi.callVoidRetVar(symbol, fixed, argv) catch return error.CannotEvalComptime; + } else { + host_ffi.callVoidRet(symbol, argv) catch return error.CannotEvalComptime; + } + return .void_val; + } + if (ret == .s8 or ret == .s16 or ret == .s32 or ret == .s64 or + ret == .u8 or ret == .u16 or ret == .u32 or ret == .u64 or + ret == .usize or ret == .isize) + { + const r = if (variadic) + host_ffi.callIntRetVar(symbol, fixed, argv) catch return error.CannotEvalComptime + else + host_ffi.callIntRet(symbol, argv) catch return error.CannotEvalComptime; + return Value{ .int = r }; + } + if (ret == .bool) { + const r = if (variadic) + host_ffi.callIntRetVar(symbol, fixed, argv) catch return error.CannotEvalComptime + else + host_ffi.callIntRet(symbol, argv) catch return error.CannotEvalComptime; + return Value{ .boolean = r != 0 }; + } + const r = if (variadic) + host_ffi.callPtrRetVar(symbol, fixed, argv) catch return error.CannotEvalComptime + else + host_ffi.callPtrRet(symbol, argv) catch return error.CannotEvalComptime; + return Value{ .int = @bitCast(@as(u64, r)) }; + } + pub fn call(self: *Interpreter, func_id: FuncId, args: []const Value) InterpError!Value { if (self.call_depth >= self.max_call_depth) return error.StackOverflow; self.call_depth += 1; @@ -224,7 +348,10 @@ pub const Interpreter = struct { const func = self.module.getFunction(func_id); if (func.is_extern or func.blocks.items.len == 0) { - return error.CannotEvalComptime; + // Dispatch to host libc via dlsym. Lets `#run` (and the + // post-link bundler) call ordinary foreign symbols like + // `puts`, `getenv`, `posix_spawn`, etc. + return self.callForeign(func, args); } // Compute total refs: params + all instructions across all blocks @@ -269,6 +396,11 @@ pub const Interpreter = struct { } const result = self.execInst(instruction, &frame, ¤t_block, &block_args) catch |err| { + if (last_bail_op == null) { + last_bail_op = @tagName(instruction.op); + last_bail_file = func.source_file; + last_bail_offset = instruction.span.start; + } return err; }; switch (result) { @@ -982,8 +1114,14 @@ pub const Interpreter = struct { } }, + // Type-as-value sentinel emitted for the type arg of + // `cast(T) val`. Result is never read (the cast lowering + // consumes the type from the AST, not the IR Ref), so an + // undef value is sufficient — matches the LLVM emitter. + .placeholder => return .{ .value = .undef }, + // ── Not yet evaluable at comptime ────────────────── - .call_closure, .protocol_call_dynamic, .protocol_erase, .closure_create, .context_load, .context_store, .context_save, .context_restore, .union_get, .union_gep, .vec_splat, .vec_extract, .vec_insert, .placeholder => { + .call_closure, .protocol_call_dynamic, .protocol_erase, .closure_create, .context_load, .context_store, .context_save, .context_restore, .union_get, .union_gep, .vec_splat, .vec_extract, .vec_insert => { return error.CannotEvalComptime; }, } @@ -1187,14 +1325,13 @@ pub const Interpreter = struct { const parent = frame.loadSlot(parent_slot); switch (parent) { .aggregate => |parent_fields| { - if (field_idx < parent_fields.len) { - // Clone the aggregate and update the field - const new_fields = self.alloc.alloc(Value, parent_fields.len) catch return false; - @memcpy(new_fields, parent_fields); - new_fields[field_idx] = new_val; - frame.storeSlot(parent_slot, .{ .aggregate = new_fields }); - return true; - } + const new_len = @max(field_idx + 1, parent_fields.len); + const new_fields = self.alloc.alloc(Value, new_len) catch return false; + @memcpy(new_fields[0..parent_fields.len], parent_fields); + for (new_fields[parent_fields.len..]) |*f| f.* = .undef; + new_fields[field_idx] = new_val; + frame.storeSlot(parent_slot, .{ .aggregate = new_fields }); + return true; }, .undef => { // Initialize a new aggregate from undef diff --git a/src/ir/lower.zig b/src/ir/lower.zig index 8bc03e2..017f677 100644 --- a/src/ir/lower.zig +++ b/src/ir/lower.zig @@ -4381,7 +4381,12 @@ pub const Lowering = struct { // ── Calls ─────────────────────────────────────────────────────── - fn lowerCall(self: *Lowering, c: *const ast.Call) Ref { + fn lowerCall(self: *Lowering, c_in: *const ast.Call) Ref { + // Expand default parameter values for bare identifier callees: + // when the caller omits trailing positional args, fill them in + // from the callee's `param: T = expr` declarations. + var c = c_in; + if (self.expandCallDefaults(c)) |expanded| c = expanded; // Check reflection builtins first (before lowering args — some args are type names, not values) if (c.callee.data == .identifier) { if (self.tryLowerReflectionCall(c.callee.data.identifier.name, c)) |ref| return ref; @@ -4991,7 +4996,11 @@ pub const Lowering = struct { // Generic #compiler method dispatch if (self.fn_ast_map.get(qualified)) |method_fd| { if (method_fd.body.data == .compiler_expr) { - return self.builder.compilerCall(qualified, method_args.items, .void); + const ret_ty = if (method_fd.return_type) |rt| + type_bridge.resolveAstType(rt, &self.module.types) + else + .void; + return self.builder.compilerCall(qualified, method_args.items, ret_ty); } } @@ -7436,6 +7445,39 @@ pub const Lowering = struct { return false; } + /// When a bare-identifier call omits trailing positional args and the + /// callee's signature provides defaults for them, return a fresh Call + /// node with the defaults filled in. Returns null when no expansion is + /// needed (callee unknown, all args provided, or no defaults available). + fn expandCallDefaults(self: *Lowering, c: *const ast.Call) ?*ast.Call { + if (c.callee.data != .identifier) return null; + const id_name = c.callee.data.identifier.name; + const eff_name = blk: { + const scoped = if (self.scope) |scope| scope.lookupFn(id_name) orelse id_name else id_name; + if (self.ufcs_alias_map.get(id_name)) |target| { + break :blk if (self.scope) |scope| scope.lookupFn(target) orelse target else target; + } + break :blk scoped; + }; + const fd = self.fn_ast_map.get(eff_name) orelse return null; + if (c.args.len >= fd.params.len) return null; + var end: usize = c.args.len; + while (end < fd.params.len) : (end += 1) { + if (fd.params[end].default_expr == null) break; + } + if (end == c.args.len) return null; + + var new_args = self.alloc.alloc(*ast.Node, end) catch return null; + for (c.args, 0..) |arg, i| new_args[i] = arg; + var i: usize = c.args.len; + while (i < end) : (i += 1) { + new_args[i] = fd.params[i].default_expr.?; + } + const new_call = self.alloc.create(ast.Call) catch return null; + new_call.* = .{ .callee = c.callee, .args = new_args }; + return new_call; + } + /// Resolve parameter types for a call expression (for target_type context). /// Returns empty slice if the function can't be resolved. fn resolveCallParamTypes(self: *Lowering, c: *const ast.Call) []const TypeId { diff --git a/src/main.zig b/src/main.zig index 913f4a4..d4720b6 100644 --- a/src/main.zig +++ b/src/main.zig @@ -401,6 +401,25 @@ fn deriveOutputName(input_path: []const u8) []const u8 { } +/// Format the "interpreter bailed during X" message, attaching the IR op +/// and the source location (line:col) when the interpreter captured them. +fn printInterpBailDiag(comp: *const sx.core.Compilation, label: []const u8, err: anyerror) void { + const op = sx.ir.Interpreter.last_bail_op orelse { + std.debug.print("error: {s} failed: {s}\n", .{ label, @errorName(err) }); + return; + }; + if (sx.ir.Interpreter.last_bail_file) |file| { + if (comp.import_sources.get(file)) |source| { + const loc = sx.errors.SourceLoc.compute(source, sx.ir.Interpreter.last_bail_offset); + std.debug.print("error: {s} failed: {s} (op={s}) at {s}:{d}:{d}\n", .{ label, @errorName(err), op, file, loc.line, loc.col }); + return; + } + std.debug.print("error: {s} failed: {s} (op={s}) at {s}:+{d}\n", .{ label, @errorName(err), op, file, sx.ir.Interpreter.last_bail_offset }); + return; + } + std.debug.print("error: {s} failed: {s} (op={s})\n", .{ label, @errorName(err), op }); +} + fn readSource(allocator: std.mem.Allocator, io: std.Io, input_path: []const u8) ![:0]const u8 { const source_bytes = std.Io.Dir.readFileAlloc(.cwd(), io, input_path, allocator, .limited(10 * 1024 * 1024)) catch |err| { std.debug.print("error: cannot read '{s}': {}\n", .{ input_path, err }); @@ -601,14 +620,6 @@ fn compileWithTimer(allocator: std.mem.Allocator, io: std.Io, input_path: []cons }; timer.record("link"); - // Wrap into a .app bundle if requested (iOS/macOS). - if (merged_config.bundle_path) |bp| { - timer.mark(); - sx.target.createBundle(allocator, io, final_output, merged_config, fws) catch std.process.exit(1); - timer.record("bundle"); - std.debug.print("bundled: {s}\n", .{bp}); - } - // Wrap into an .apk if requested (Android). if (merged_config.apk_path) |ap| { timer.mark(); @@ -617,6 +628,77 @@ fn compileWithTimer(allocator: std.mem.Allocator, io: std.Io, input_path: []cons std.debug.print("apk: {s}\n", .{ap}); } + // Make the linked binary's path + bundling config visible to the + // post-link callback via `BuildOptions.binary_path()`, + // `BuildOptions.bundle_path()`, etc. CLI flags + // (`--bundle Foo.app`, `--bundle-id`, ...) feed in here so the sx + // bundler doesn't need a separate code path. + if (comp.ir_emitter) |*e| { + e.build_config.binary_path = final_output; + if (e.build_config.bundle_path == null) e.build_config.bundle_path = merged_config.bundle_path; + if (e.build_config.bundle_id == null) e.build_config.bundle_id = merged_config.bundle_id; + if (e.build_config.codesign_identity == null) e.build_config.codesign_identity = merged_config.codesign_identity; + if (e.build_config.provisioning_profile == null) e.build_config.provisioning_profile = merged_config.provisioning_profile; + // Target triple + framework lists drive the sx bundler's per-platform + // branching (iOS device vs simulator vs macOS) and `Frameworks/` + // embedding. Slice fields point into the long-lived target_config / + // CLI argv buffers, which outlive the post-link callback. + if (merged_config.triple) |t| e.build_config.target_triple = std.mem.span(t); + e.build_config.target_frameworks = fws; + e.build_config.target_framework_paths = merged_config.framework_paths; + } + + // CLI `--bundle ` migration shim. The legacy Zig bundler + // path (target.createBundle) has been retired; the equivalent + // logic now lives in `library/modules/platform/bundle.sx`. If the + // user passed `--bundle` on the command line but did NOT register + // a post-link callback themselves, point the resolver at + // `platform.bundle.bundle_main`. The lookup is best-effort: if the + // source doesn't `#import "modules/platform/bundle.sx"`, + // `invokeByName` returns null and the existing "not found" branch + // prints a clear migration message. + if (comp.ir_emitter) |*e| { + if (e.build_config.bundle_path != null and + e.build_config.post_link_callback_fn == null and + e.build_config.post_link_module == null) + { + e.build_config.post_link_module = "platform.bundle"; + } + } + + // Post-link callback: if the user registered one via + // `BuildOptions.set_post_link_callback(fn)` or + // `set_post_link_module("name")`, re-enter the IR interpreter and + // invoke that sx function now. A `false` return fails the build. + if (comp.getPostLinkCallback()) |fid| { + const ret = comp.invokeByFuncId(fid, &.{}) catch |err| { + printInterpBailDiag(&comp, "post-link callback", err); + return error.CompileError; + }; + if (ret.asBool() == false) { + std.debug.print("error: post-link callback returned false\n", .{}); + return error.CompileError; + } + } else if (comp.getPostLinkModule()) |mod_name| { + const qualified = try std.fmt.allocPrint(allocator, "{s}.bundle_main", .{mod_name}); + defer allocator.free(qualified); + const ret_opt = comp.invokeByName(qualified, &.{}) catch |err| { + const label = try std.fmt.allocPrint(allocator, "post-link module '{s}.bundle_main'", .{mod_name}); + defer allocator.free(label); + printInterpBailDiag(&comp, label, err); + return error.CompileError; + }; + if (ret_opt) |ret| { + if (ret.asBool() == false) { + std.debug.print("error: post-link module '{s}.bundle_main' returned false\n", .{mod_name}); + return error.CompileError; + } + } else { + std.debug.print("error: post-link module '{s}.bundle_main' not found\n", .{mod_name}); + return error.CompileError; + } + } + // Post-process wasm HTML: inject content hash for cache busting if (merged_config.isEmscripten() and std.mem.endsWith(u8, final_output, ".html")) { sx.target.postProcessWasmHtml(allocator, io, final_output); diff --git a/src/parser.zig b/src/parser.zig index f563510..b531da2 100644 --- a/src/parser.zig +++ b/src/parser.zig @@ -18,6 +18,11 @@ pub const Parser = struct { diagnostics: ?*errors.DiagnosticList = null, /// Type param names from enclosing generic struct (set while parsing methods) struct_type_params: []const []const u8 = &.{}, + /// When true (set while parsing methods inside `struct #compiler { ... }`), + /// a missing function body (just `name :: (params);`) is synthesized as + /// a `.compiler_expr` body so the per-method `#compiler` suffix can be + /// omitted. + struct_default_compiler: bool = false, pub fn init(allocator: std.mem.Allocator, source: [:0]const u8) Parser { var lexer = Lexer.init(source); @@ -760,6 +765,15 @@ pub const Parser = struct { fn parseStructDecl(self: *Parser, name: []const u8, start_pos: u32) anyerror!*Node { self.advance(); // skip 'struct' + // Optional `#compiler` attribute: all methods inside this struct are + // implicitly compiler hooks (no per-method `#compiler` suffix needed). + // Mirrors `protocol #inline { ... }` shape. + var is_compiler_struct = false; + if (self.current.tag == .hash_compiler) { + is_compiler_struct = true; + self.advance(); + } + // Optional type params: struct($N: u32, $T: Type) { ... } var type_params = std.ArrayList(ast.StructTypeParam).empty; if (self.current.tag == .l_paren) { @@ -805,6 +819,13 @@ pub const Parser = struct { self.struct_type_params = tp_names.items; defer self.struct_type_params = saved_struct_type_params; + // Propagate the struct-level `#compiler` flag to nested method + // parsing so a bodyless `name :: (params);` synthesizes a + // `.compiler_expr` body. + const saved_struct_default_compiler = self.struct_default_compiler; + self.struct_default_compiler = is_compiler_struct; + defer self.struct_default_compiler = saved_struct_default_compiler; + var field_names = std.ArrayList([]const u8).empty; var field_types = std.ArrayList(*Node).empty; var field_defaults = std.ArrayList(?*Node).empty; @@ -1486,7 +1507,14 @@ pub const Parser = struct { is_comptime_param = true; } } - try params.append(self.allocator, .{ .name = param_name, .name_span = param_name_span, .type_expr = param_type, .is_variadic = is_variadic, .is_comptime = is_comptime_param }); + // Optional default value: `param: T = expr`. Stored on the Param + // node; lowering fills it in for callers that omit this positional arg. + var default_expr: ?*Node = null; + if (self.current.tag == .equal) { + self.advance(); // consume '=' + default_expr = try self.parseExpr(); + } + try params.append(self.allocator, .{ .name = param_name, .name_span = param_name_span, .type_expr = param_type, .is_variadic = is_variadic, .is_comptime = is_comptime_param, .default_expr = default_expr }); } for (params.items, 0..) |param, i| { if (param.is_variadic and i != params.items.len - 1) { @@ -1570,6 +1598,12 @@ pub const Parser = struct { self.advance(); try self.expect(.semicolon); break :blk try self.createNode(ci_start, .{ .compiler_expr = {} }); + } else if (self.struct_default_compiler and self.current.tag == .semicolon) blk: { + // Inside `struct #compiler { ... }`: a bodyless method is + // implicitly a `#compiler` hook. + const ci_start = self.current.loc.start; + self.advance(); + break :blk try self.createNode(ci_start, .{ .compiler_expr = {} }); } else if (self.current.tag == .hash_foreign) blk: { const fi_start = self.current.loc.start; self.advance(); @@ -1741,6 +1775,36 @@ pub const Parser = struct { return try self.createNode(start, .{ .insert_expr = .{ .expr = inner } }); } + // `#import "path";` / `#framework "Name";` inside a block body. + // Only meaningful inside an `inline if OS == ... { ... }` arm — + // the imports.zig flatten pass (issue-0042) surfaces those + // declarations to the top level before resolution. Anywhere else + // these nodes survive into lowering and produce a clear error. + if (self.current.tag == .hash_import) { + const start = self.current.loc.start; + self.advance(); + if (self.current.tag != .string_literal) { + return self.fail("expected string path after '#import'"); + } + const raw = self.tokenSlice(self.current); + const path = raw[1 .. raw.len - 1]; + self.advance(); + try self.expect(.semicolon); + return try self.createNode(start, .{ .import_decl = .{ .path = path, .name = null } }); + } + if (self.current.tag == .hash_framework) { + const start = self.current.loc.start; + self.advance(); + if (self.current.tag != .string_literal) { + return self.fail("expected string after '#framework'"); + } + const raw = self.tokenSlice(self.current); + const fw_name = raw[1 .. raw.len - 1]; + self.advance(); + try self.expect(.semicolon); + return try self.createNode(start, .{ .framework_decl = .{ .name = fw_name } }); + } + // inline if — compile-time conditional if (self.current.tag == .kw_inline) { if (self.peekNext() == .kw_if) { @@ -2777,6 +2841,10 @@ pub const Parser = struct { fn isFunctionDef(self: *Parser) bool { const tag = self.peekPastParens() orelse return false; + // Inside `struct #compiler { ... }`, a bodyless method declaration + // ends with `;` directly after the param list — recognise it as a + // function def (not a constant) so it goes through parseFnDecl. + if (self.struct_default_compiler and tag == .semicolon) return true; return tag == .l_brace or tag == .arrow or tag == .hash_builtin or tag == .hash_compiler or tag == .hash_foreign or tag == .fat_arrow or tag == .kw_callconv; } diff --git a/src/target.zig b/src/target.zig index 08ab1b7..9c9e9d6 100644 --- a/src/target.zig +++ b/src/target.zig @@ -926,243 +926,10 @@ pub fn link(allocator: std.mem.Allocator, io: std.Io, output_obj: []const u8, ex if (result.exited != 0) return error.LinkError; } -/// Move `binary_path` into a freshly-created `` directory, -/// write a minimal Info.plist, and ad-hoc codesign for simulator runs. -/// The executable inside the bundle is named after the basename of -/// `binary_path` (also used as CFBundleExecutable). -pub fn createBundle(allocator: std.mem.Allocator, io: std.Io, binary_path: []const u8, target_config: TargetConfig, frameworks: []const []const u8) !void { - const bundle_path = target_config.bundle_path orelse return error.NoBundlePath; - const bundle_id = target_config.bundle_id orelse { - std.debug.print("error: --bundle requires --bundle-id (e.g. co.swipelab.app)\n", .{}); - return error.MissingBundleId; - }; - - // Device builds without a real identity will be rejected by the device, - // so fail fast with a clear hint. - if (target_config.isIOSDevice() and target_config.codesign_identity == null) { - std.debug.print("error: --target ios requires --codesign-identity (e.g. \"Apple Development: ...\") and --provisioning-profile \n", .{}); - return error.MissingCodesignIdentity; - } - - const cwd = std.Io.Dir.cwd(); - cwd.deleteTree(io, bundle_path) catch {}; - try cwd.createDirPath(io, bundle_path); - - const exe_name = std.fs.path.basename(binary_path); - const exe_dest = try std.fs.path.join(allocator, &.{ bundle_path, exe_name }); - cwd.rename(binary_path, cwd, exe_dest, io) catch return error.BundleMoveFailed; - - // Info.plist - const plist = try buildInfoPlist(allocator, exe_name, bundle_id, target_config); - const plist_path = try std.fs.path.join(allocator, &.{ bundle_path, "Info.plist" }); - try cwd.writeFile(io, .{ .sub_path = plist_path, .data = plist }); - - // Embed provisioning profile if supplied. Required for device installs. - if (target_config.provisioning_profile) |pp| { - const profile_data = std.Io.Dir.readFileAlloc(.cwd(), io, pp, allocator, .limited(1 * 1024 * 1024)) catch { - std.debug.print("error: cannot read provisioning profile: {s}\n", .{pp}); - return error.ProvisioningProfileNotFound; - }; - const embedded_path = try std.fs.path.join(allocator, &.{ bundle_path, "embedded.mobileprovision" }); - try cwd.writeFile(io, .{ .sub_path = embedded_path, .data = profile_data }); - } - - // Embed any dynamic frameworks the binary links against. iOS apps load - // frameworks from `.app/Frameworks/.framework/` via - // the `@executable_path/Frameworks` rpath we set at link time. For each - // framework, look it up in `framework_paths` and copy the bundle in. - if (target_config.isIOS() and frameworks.len > 0) { - const fw_dir = try std.fs.path.join(allocator, &.{ bundle_path, "Frameworks" }); - try cwd.createDirPath(io, fw_dir); - for (frameworks) |fw| { - try embedFramework(allocator, io, fw, target_config.framework_paths, fw_dir); - } - } - - // Codesign: real identity for device, ad-hoc otherwise. - const identity: []const u8 = target_config.codesign_identity orelse "-"; - const ent_path: ?[]const u8 = if (target_config.entitlements_path) |e| e else blk: { - if (target_config.provisioning_profile) |pp| { - break :blk try extractEntitlements(allocator, io, pp, bundle_id); - } - break :blk null; - }; - try codesign(allocator, io, bundle_path, identity, ent_path); -} - -/// Find `.framework` in one of `framework_paths` and copy it into -/// `/.framework`. Shells out to `cp -R` because Zig's std -/// doesn't expose a recursive-copy primitive on `Io.Dir` yet. -fn embedFramework(allocator: std.mem.Allocator, io: std.Io, name: []const u8, framework_paths: []const []const u8, dest_dir: []const u8) !void { - const cwd = std.Io.Dir.cwd(); - const subdir = try std.fmt.allocPrint(allocator, "{s}.framework", .{name}); - for (framework_paths) |fp| { - const candidate = try std.fs.path.join(allocator, &.{ fp, subdir }); - if (cwd.openDir(io, candidate, .{})) |d| { - d.close(io); - const dest = try std.fs.path.join(allocator, &.{ dest_dir, subdir }); - const r = std.process.run(allocator, io, .{ - .argv = &.{ "cp", "-R", candidate, dest }, - }) catch return error.FrameworkCopyFailed; - defer allocator.free(r.stdout); - defer allocator.free(r.stderr); - if (r.term != .exited or r.term.exited != 0) { - std.debug.print("error: cp -R {s} -> {s} failed: {s}\n", .{ candidate, dest, r.stderr }); - return error.FrameworkCopyFailed; - } - return; - } else |_| {} - } - std.debug.print("warning: framework '{s}' not found in any -F path; runtime load will fail\n", .{name}); -} - -/// Extract entitlements XML from a `.mobileprovision` and resolve the -/// `application-identifier` wildcard (`.*`) to the concrete bundle ID -/// (`.`). Without this substitution the device installer -/// rejects the app with `MIInstallerErrorDomain error 13` / -/// `0xe8008015 (A valid provisioning profile ... was not found)`. -/// Writes the resolved entitlements to `.sx-tmp/entitlements.plist`. -fn extractEntitlements(allocator: std.mem.Allocator, io: std.Io, profile_path: []const u8, bundle_id: []const u8) ![]const u8 { - const cwd = std.Io.Dir.cwd(); - cwd.createDirPath(io, ".sx-tmp") catch {}; - - const profile_plist_path = ".sx-tmp/profile.plist"; - const ent_path = ".sx-tmp/entitlements.plist"; - - // 1. security cms -D -i -o profile.plist (decode CMS to plist) - const r1 = std.process.run(allocator, io, .{ - .argv = &.{ "security", "cms", "-D", "-i", profile_path, "-o", profile_plist_path }, - }) catch return error.SecurityCommandFailed; - defer allocator.free(r1.stdout); - defer allocator.free(r1.stderr); - if (r1.term != .exited or r1.term.exited != 0) { - std.debug.print("error: failed to decode provisioning profile: {s}\n", .{r1.stderr}); - return error.SecurityCommandFailed; - } - - // 2. plutil -extract Entitlements xml1 -o entitlements.plist profile.plist - const r2 = std.process.run(allocator, io, .{ - .argv = &.{ "plutil", "-extract", "Entitlements", "xml1", "-o", ent_path, profile_plist_path }, - }) catch return error.PlutilCommandFailed; - defer allocator.free(r2.stdout); - defer allocator.free(r2.stderr); - if (r2.term != .exited or r2.term.exited != 0) { - std.debug.print("error: failed to extract entitlements: {s}\n", .{r2.stderr}); - return error.PlutilCommandFailed; - } - - // 3. Read the team identifier so we can resolve the wildcard. The profile - // stores it as `ApplicationIdentifierPrefix.0` (an array). We use that - // path because `com.apple.developer.team-identifier` would confuse - // plutil — dots in plutil paths are interpreted as path separators. - const r3 = std.process.run(allocator, io, .{ - .argv = &.{ "plutil", "-extract", "ApplicationIdentifierPrefix.0", "raw", "-o", "-", profile_plist_path }, - }) catch return error.PlutilCommandFailed; - defer allocator.free(r3.stdout); - defer allocator.free(r3.stderr); - if (r3.term != .exited or r3.term.exited != 0) { - std.debug.print("error: profile missing ApplicationIdentifierPrefix: {s}\n", .{r3.stderr}); - return error.PlutilCommandFailed; - } - const team = std.mem.trimEnd(u8, r3.stdout, " \t\r\n"); - const resolved_app_id = try std.fmt.allocPrint(allocator, "{s}.{s}", .{ team, bundle_id }); - defer allocator.free(resolved_app_id); - - // 4. plutil -replace application-identifier -string "." entitlements.plist - const r4 = std.process.run(allocator, io, .{ - .argv = &.{ "plutil", "-replace", "application-identifier", "-string", resolved_app_id, ent_path }, - }) catch return error.PlutilCommandFailed; - defer allocator.free(r4.stdout); - defer allocator.free(r4.stderr); - if (r4.term != .exited or r4.term.exited != 0) { - std.debug.print("error: failed to resolve application-identifier: {s}\n", .{r4.stderr}); - return error.PlutilCommandFailed; - } - - return try allocator.dupe(u8, ent_path); -} - -fn buildInfoPlist(allocator: std.mem.Allocator, exe_name: []const u8, bundle_id: []const u8, target_config: TargetConfig) ![]const u8 { - const min_os: []const u8 = "14.0"; - const is_sim = target_config.isIOSSimulator(); - const platform_key: []const u8 = if (is_sim) "iPhoneSimulator" else "iPhoneOS"; - // UIApplicationSceneManifest opts the app into the iOS 13+ scene-based - // lifecycle. Without it, iOS 26 boots the app in `[rb-legacy]` mode and - // the CAMetalLayer never reaches the compositor. With the manifest - // declared (no UISceneDelegate listed), iOS auto-connects an implicit - // scene that our SxAppDelegate can find via `[app connectedScenes]`. - return std.fmt.allocPrint(allocator, - \\ - \\ - \\ - \\ - \\ CFBundleIdentifier - \\ {s} - \\ CFBundleName - \\ {s} - \\ CFBundleExecutable - \\ {s} - \\ CFBundlePackageType - \\ APPL - \\ CFBundleVersion - \\ 1 - \\ CFBundleShortVersionString - \\ 0.1 - \\ MinimumOSVersion - \\ {s} - \\ UIDeviceFamily - \\ - \\ 1 - \\ - \\ LSRequiresIPhoneOS - \\ - \\ UILaunchScreen - \\ - \\ UIApplicationSceneManifest - \\ - \\ UIApplicationSupportsMultipleScenes - \\ - \\ UISceneConfigurations - \\ - \\ UIWindowSceneSessionRoleApplication - \\ - \\ - \\ UISceneConfigurationName - \\ Default Configuration - \\ UISceneDelegateClassName - \\ SxSceneDelegate - \\ - \\ - \\ - \\ - \\ DTPlatformName - \\ {s} - \\ - \\ - \\ - , .{ bundle_id, exe_name, exe_name, min_os, platform_key }); -} - -fn codesign(allocator: std.mem.Allocator, io: std.Io, bundle_path: []const u8, identity: []const u8, entitlements: ?[]const u8) !void { - var argv = std.ArrayList([]const u8).empty; - defer argv.deinit(allocator); - try argv.appendSlice(allocator, &.{ "codesign", "--force", "--sign", identity, "--timestamp=none" }); - if (entitlements) |ep| { - try argv.appendSlice(allocator, &.{ "--entitlements", ep }); - } - try argv.append(allocator, bundle_path); - - const r = std.process.run(allocator, io, .{ .argv = argv.items }) catch |e| { - std.debug.print("error: failed to run codesign: {}\n", .{e}); - return error.CodesignFailed; - }; - defer allocator.free(r.stdout); - defer allocator.free(r.stderr); - if (r.term != .exited or r.term.exited != 0) { - std.debug.print("codesign failed: {s}\n", .{r.stderr}); - return error.CodesignFailed; - } -} +// Apple .app bundling (createBundle, embedFramework, extractEntitlements, +// buildInfoPlist, codesign) has moved to +// `library/modules/platform/bundle.sx`. `src/main.zig` invokes it +// post-link via the BuildOptions callback registered from sx code. /// After emcc produces HTML output, inject cache-busting hashes into the /// generated