diff --git a/cli/launcher/src/bridges.zig b/cli/launcher/src/bridges.zig index a25c92a..c9595d9 100644 --- a/cli/launcher/src/bridges.zig +++ b/cli/launcher/src/bridges.zig @@ -109,6 +109,21 @@ fn writeCStringToGuest(env: *launcher.HostEnv, src: ?[*:0]const u8, buf_ptr: i32 return @intCast(slice.len); } +/// Resolve an Ephapax String handle (single i32) to a host byte slice. +/// String layout in linear memory (from ephapax-wasm gen_string_new): +/// handle = i32 pointer to an 8-byte header +/// header[0..4]= data pointer (i32) +/// header[4..8]= length (i32, little-endian) +/// The data itself lives elsewhere in linear memory; this returns a +/// borrowed slice over it that remains valid until the guest mutates +/// or frees the string. +fn ephStringSlice(env: *launcher.HostEnv, handle: i32) ?[]u8 { + const header = launcher.guestSlice(env, handle, 8) orelse return null; + const data_ptr = std.mem.readInt(i32, header[0..4], .little); + const data_len = std.mem.readInt(i32, header[4..8], .little); + return launcher.guestSlice(env, data_ptr, data_len); +} + inline fn argI32(args: [*c]const c.wasmtime_val_t, idx: usize) i32 { return args[idx].of.i32; } @@ -527,6 +542,54 @@ fn bCapToken(env_raw: ?*anyopaque, _: ?*c.wasmtime_caller_t, args: [*c]const c.w return null; } +//============================================================================== +// Ephapax-String-aware helpers (resolve guest String handle via memory) +//============================================================================== + +/// env::say_string(string_handle: i32) -> () +/// Print an Ephapax String to stderr. The argument is a single i32 — +/// the Ephapax String handle — which we walk through guest memory to +/// reach the actual bytes. Mirrors the baseline env::print_string but +/// takes the high-level String type so .eph code can call it with a +/// literal: `say_string("hello")`. +fn bSayString(env_raw: ?*anyopaque, _: ?*c.wasmtime_caller_t, args: [*c]const c.wasmtime_val_t, _: usize, _: [*c]c.wasmtime_val_t, _: usize) callconv(.c) ?*c.wasm_trap_t { + const env: *launcher.HostEnv = @alignCast(@ptrCast(env_raw orelse return null)); + const slice = ephStringSlice(env, argI32(args, 0)) orelse return null; + std.debug.print("{s}", .{slice}); + return null; +} + +/// env::argv_eq_string(idx: i32, literal: String) -> i32 +/// Returns 1 if argv[idx] equals the literal byte-for-byte, 0 otherwise. +/// Unblocks subcommand-name dispatch from .eph: rather than match by +/// argv_count, the guest can now do +/// if argv_eq_string(1, "dev") == 1 then runDev(...) else ... +fn bArgvEqString(env_raw: ?*anyopaque, _: ?*c.wasmtime_caller_t, args: [*c]const c.wasmtime_val_t, _: usize, results: [*c]c.wasmtime_val_t, _: usize) callconv(.c) ?*c.wasm_trap_t { + const env: *launcher.HostEnv = @alignCast(@ptrCast(env_raw orelse return null)); + const idx = argI32(args, 0); + if (idx < 0 or idx >= env.argv.len) { + retI32(results, 0); + return null; + } + const literal = ephStringSlice(env, argI32(args, 1)) orelse { + retI32(results, 0); + return null; + }; + const arg = env.argv[@intCast(idx)]; + retI32(results, if (std.mem.eql(u8, arg, literal)) 1 else 0); + return null; +} + +/// env::i64_is_zero(n: i64) -> i32 +/// Returns 1 if n == 0, 0 otherwise. Works around v2-grammar Ephapax's +/// lack of i64 literals (`some_i64 == 0` won't typecheck because the +/// literal 0 is always i32). Lets the guest check cap_token results, +/// opaque-handle nullability, etc. +fn bI64IsZero(_: ?*anyopaque, _: ?*c.wasmtime_caller_t, args: [*c]const c.wasmtime_val_t, _: usize, results: [*c]c.wasmtime_val_t, _: usize) callconv(.c) ?*c.wasm_trap_t { + retI32(results, if (argI64(args, 0) == 0) 1 else 0); + return null; +} + //============================================================================== // Imports table — registered into the wasmtime linker by main.zig //============================================================================== @@ -579,6 +642,11 @@ pub const Imports = [_]launcher.ImportSpec{ // Capability tokens .{ .name = "cap_token", .params = &.{I32}, .results = &.{I64}, .callback = &bCapToken }, + + // Ephapax-String-aware helpers + .{ .name = "say_string", .params = &.{I32}, .results = &.{}, .callback = &bSayString }, + .{ .name = "argv_eq_string", .params = &.{ I32, I32 }, .results = &.{I32}, .callback = &bArgvEqString }, + .{ .name = "i64_is_zero", .params = &.{I64}, .results = &.{I32}, .callback = &bI64IsZero }, }; /// Eager-grant the baseline capability tokens at launcher startup. The diff --git a/cli/src/Main.eph b/cli/src/Main.eph index acf1dea..30d7358 100644 --- a/cli/src/Main.eph +++ b/cli/src/Main.eph @@ -1,31 +1,144 @@ // SPDX-License-Identifier: PMPL-1.0-or-later // Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) // -// Gossamer CLI — Phase 14a.5c placeholder. +// Gossamer CLI — typed-wasm entry point loaded by gossamer-launcher. // -// This is the wasm entry point the gossamer-launcher loads at runtime -// once the launcher is renamed to `gossamer` (which #15 does). For now -// it's a placeholder that proves the build pipeline: +// Phase #15 step 2 (extended with the string-bridge follow-up). +// Demonstrates that v2-grammar Ephapax can drive real CLI dispatch +// through the launcher's host imports, INCLUDING subcommand-name +// matching against argv[0] (argv visible to the guest, after the +// launcher trims its own binary and the wasm path). // -// ephapax compile cli/src/Main.eph -o cli.wasm -// gossamer-launcher cli.wasm +// The subcommand bodies still print status integers rather than +// actually opening webviews — that next step needs more libgossamer +// bridges wired through (gossamer_run blocks the wasm thread which +// requires careful integration with the GTK event loop and is its +// own design question). What this module proves end-to-end: // -// On run it calls argv_count() and prints the integer back via the -// baseline env::print_i32 host import. The real CLI surface — dev / -// build / bundle / run / init / info — lands in #15 once the v2 -// grammar supports enough of the patterns the existing cli/src/main.zig -// needs (string slicing, conf-handle lifetimes, etc). -// -// Imports below are deliberately a subset of what the launcher provides -// (5 baseline + 29 libgossamer bridges from 14a.5b). The full set is in -// cli/launcher/src/bridges.zig — adding new ones here is just a matter -// of declaring the matching extern "env" fn. +// 1. argv[0] subcommand name matching via env::argv_eq_string, +// passing Ephapax String literals across the FFI boundary. +// 2. Sum-type pattern matching across 7 distinct branches. +// 3. Side-effecting host calls (say_string, print_i32, FFI into +// libgossamer's groove discovery). +// 4. Capability-token liveness via env::cap_token + env::i64_is_zero +// (works around v2 grammar's lack of i64 literal comparison). module GossamerCli +// ── Host imports ───────────────────────────────────────────────────────── +// +// String-aware helpers (say_string, argv_eq_string, i64_is_zero) come +// from the launcher follow-up to 14a.5b. Everything else from the +// original 14a.5a/5b surface. + extern "env" { + // Baseline + diagnostics fn print_i32(n: I32): Unit + fn say_string(s: String): Unit + + // argv fn argv_count(): I32 + fn argv_eq_string(idx: I32, literal: String): I32 + + // Capabilities + fn cap_token(kind: I32): I64 + fn i64_is_zero(n: I64): I32 + + // libgossamer + fn gossamer_groove_discover(): I32 } -fn main(): Unit = print_i32(argv_count()) +// ── Subcommand classification ─────────────────────────────────────────── + +pub data Subcommand = + | NoArg + | Version + | Info + | Dev + | Build + | Run + | Bundle + | Init + | Unknown + +// argv visible to the guest: position 0 is the user-supplied subcommand +// (launcher strips its own argv[0] and the wasm path before handing +// guest_argv to the wasm). If no subcommand at all, classify as NoArg. +fn classify(): Subcommand = + if argv_count() == 0 then NoArg + else if argv_eq_string(0, "version") == 1 then Version + else if argv_eq_string(0, "info") == 1 then Info + else if argv_eq_string(0, "dev") == 1 then Dev + else if argv_eq_string(0, "build") == 1 then Build + else if argv_eq_string(0, "run") == 1 then Run + else if argv_eq_string(0, "bundle") == 1 then Bundle + else if argv_eq_string(0, "init") == 1 then Init + else Unknown + +// ── Per-subcommand status code (stable contract for harness) ───────────── + +fn dispatchCode(s: Subcommand): I32 = + match s of + | NoArg => 100 + | Version => 110 + | Info => 120 + | Dev => 200 + | Build => 300 + | Run => 400 + | Bundle => 500 + | Init => 600 + | Unknown => 900 + end + +// ── Per-subcommand banner (calls into launcher's say_string) ───────────── +// +// Subcommand bodies are still stubs at this stage — they print a +// recognisable banner so a script harness can confirm dispatch reached +// the right arm. Real cmdDev / cmdBuild / cmdRun bodies follow once +// the gossamer_create_ex / gossamer_run integration with the wasm +// event-loop story is settled. + +fn announce(s: Subcommand): Unit = + match s of + | NoArg => say_string("gossamer: no subcommand (run `gossamer info` for help)") + | Version => say_string("gossamer version (typed-wasm guest)") + | Info => say_string("gossamer info: typed-wasm guest reporting in") + | Dev => say_string("gossamer dev: would start webview + watcher (stub)") + | Build => say_string("gossamer build: would run beforeBuildCommand (stub)") + | Run => say_string("gossamer run: would load built frontend in webview (stub)") + | Bundle => say_string("gossamer bundle: would assemble deb package (stub)") + | Init => say_string("gossamer init: would create gossamer.conf.json (stub)") + | Unknown => say_string("gossamer: unknown subcommand") + end + +// ── Liveness signals ───────────────────────────────────────────────────── + +fn grooveCount(): I32 = gossamer_groove_discover() + +// FileSystem capability (kind=0) liveness — uses env::i64_is_zero to +// sidestep v2-grammar's lack of an i64 literal. Returns 1 (granted) +// or 0 (missing). +fn capsLive(): I32 = + let fs_token : I64 = cap_token(0) + if i64_is_zero(fs_token) == 1 then 0 else 1 + +// ── Composite status code ──────────────────────────────────────────────── +// +// Three digits encoded as: +// dispatch / 10 + (grooves clamped 0..9) * 10 + caps +// e.g. argv=["dev"] with 2 grooves and caps OK -> 221 +// argv=[] no grooves no caps -> 100 + +fn statusCode(s: Subcommand): I32 = + let d : I32 = dispatchCode(s) + let g_raw : I32 = grooveCount() + let g_clamp : I32 = if g_raw > 9 then 9 else g_raw + let c : I32 = capsLive() + d + (g_clamp * 10) + c + +// ── Entry point ────────────────────────────────────────────────────────── + +fn main(): Unit = + let s : Subcommand = classify() + let _ : Unit = announce(s) + print_i32(statusCode(s))