Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions cli/launcher/src/bridges.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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
//==============================================================================
Expand Down Expand Up @@ -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
Expand Down
147 changes: 130 additions & 17 deletions cli/src/Main.eph
Original file line number Diff line number Diff line change
@@ -1,31 +1,144 @@
// SPDX-License-Identifier: PMPL-1.0-or-later
// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
//
// 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))
Loading