From a39fd27530f112cd7b22fc8736c8a66995956ec2 Mon Sep 17 00:00:00 2001 From: nandi <78769380+codegod100@users.noreply.github.com> Date: Sun, 9 Aug 2026 00:09:29 -0700 Subject: [PATCH] Drive desktop slash parsing through a Gleam Wasm guest. Compose-box commands are parsed in Gleam with host string externals; the Rust parser remains the Android/test oracle and fallback. Co-authored-by: Cursor --- .gitignore | 2 + android/src/lib.rs | 2 +- android/src/slash.rs | 74 ++-- host/Cargo.lock | 499 ++++++++++++++++++++++++- host/Cargo.toml | 6 + host/build.rs | 125 +++++++ host/gleam/slash/README.md | 18 + host/gleam/slash/gleam.toml | 4 + host/gleam/slash/manifest.toml | 12 + host/gleam/slash/src/gleam_slash.gleam | 157 ++++++++ host/src/gleam_slash.rs | 233 ++++++++++++ host/src/gleam_string.rs | 124 ++++++ host/src/main.rs | 101 +++++ justfile | 12 + 14 files changed, 1341 insertions(+), 28 deletions(-) create mode 100644 host/build.rs create mode 100644 host/gleam/slash/README.md create mode 100644 host/gleam/slash/gleam.toml create mode 100644 host/gleam/slash/manifest.toml create mode 100644 host/gleam/slash/src/gleam_slash.gleam create mode 100644 host/src/gleam_slash.rs create mode 100644 host/src/gleam_string.rs diff --git a/.gitignore b/.gitignore index c9d71fb..2f1e7f5 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ result result-* .buildkite/.issue-poll-state +/host/gleam/slash/build/ +/host/target/ diff --git a/android/src/lib.rs b/android/src/lib.rs index 8e686a3..985a349 100644 --- a/android/src/lib.rs +++ b/android/src/lib.rs @@ -10,7 +10,7 @@ mod net; mod policy; mod preview; mod reconnect; -mod slash; +pub mod slash; mod state; mod ui; diff --git a/android/src/slash.rs b/android/src/slash.rs index 817c47c..beb4c60 100644 --- a/android/src/slash.rs +++ b/android/src/slash.rs @@ -1,4 +1,9 @@ //! Compose-box slash commands — mirrors freeq-android `SlashCommandParser`. +//! +//! Desktop host may install a Gleam Wasm implementation via [`install_parse_hook`]; +//! Android and unit tests keep the native Rust parser. + +use std::sync::OnceLock; /// A parsed slash-command from the compose box. #[derive(Debug, Clone, PartialEq, Eq)] @@ -15,8 +20,32 @@ pub enum SlashCommand { Empty, } +type ParseHook = Box Result + Send + Sync>; + +static PARSE_HOOK: OnceLock = OnceLock::new(); + +/// Install a Gleam (or other) parser used by [`parse`] when present. +pub fn install_parse_hook(hook: ParseHook) { + let _ = PARSE_HOOK.set(hook); +} + /// Parse a compose-box string that starts with `/`. +/// +/// Prefers the installed hook (desktop Gleam guest); falls back to [`parse_native`]. pub fn parse(input: &str) -> SlashCommand { + if let Some(hook) = PARSE_HOOK.get() { + match hook(input) { + Ok(cmd) => return cmd, + Err(err) => { + log::warn!("slash parse hook failed ({err}); using native parser"); + } + } + } + parse_native(input) +} + +/// Native Rust parser (oracle for tests / Android / hook fallback). +pub fn parse_native(input: &str) -> SlashCommand { let without_slash = input.strip_prefix('/').unwrap_or(input); let (cmd, arg) = match without_slash.split_once(' ') { Some((c, rest)) => (c, Some(rest).filter(|s| !s.is_empty())), @@ -42,9 +71,7 @@ pub fn parse(input: &str) -> SlashCommand { return SlashCommand::Empty; }; match arg.split_once(' ') { - Some((target, text)) - if !target.is_empty() && !text.is_empty() => - { + Some((target, text)) if !target.is_empty() && !text.is_empty() => { SlashCommand::Msg { target: target.to_string(), text: text.to_string(), @@ -66,40 +93,43 @@ mod tests { #[test] fn parses_join_with_channel() { - assert_eq!(parse("/join #freeq"), SlashCommand::Join("#freeq".into())); + assert_eq!( + parse_native("/join #freeq"), + SlashCommand::Join("#freeq".into()) + ); } #[test] fn join_with_no_arg_is_empty() { - assert_eq!(parse("/join"), SlashCommand::Empty); - assert_eq!(parse("/join "), SlashCommand::Empty); + assert_eq!(parse_native("/join"), SlashCommand::Empty); + assert_eq!(parse_native("/join "), SlashCommand::Empty); } #[test] fn part_and_leave_both_resolve_to_part_active() { - assert_eq!(parse("/part"), SlashCommand::PartActive); - assert_eq!(parse("/leave"), SlashCommand::PartActive); + assert_eq!(parse_native("/part"), SlashCommand::PartActive); + assert_eq!(parse_native("/leave"), SlashCommand::PartActive); // Arg is ignored — dispatch uses the active channel. - assert_eq!(parse("/part #ignored"), SlashCommand::PartActive); + assert_eq!(parse_native("/part #ignored"), SlashCommand::PartActive); } #[test] fn parses_nick_change() { assert_eq!( - parse("/nick newhandle"), + parse_native("/nick newhandle"), SlashCommand::Nick("newhandle".into()) ); } #[test] fn nick_with_no_arg_is_empty() { - assert_eq!(parse("/nick"), SlashCommand::Empty); + assert_eq!(parse_native("/nick"), SlashCommand::Empty); } #[test] fn parses_me_action() { assert_eq!( - parse("/me waves hello"), + parse_native("/me waves hello"), SlashCommand::Me("waves hello".into()) ); } @@ -107,7 +137,7 @@ mod tests { #[test] fn parses_msg_with_target_and_text() { assert_eq!( - parse("/msg alice hi there"), + parse_native("/msg alice hi there"), SlashCommand::Msg { target: "alice".into(), text: "hi there".into(), @@ -117,14 +147,14 @@ mod tests { #[test] fn msg_without_text_is_empty() { - assert_eq!(parse("/msg alice"), SlashCommand::Empty); - assert_eq!(parse("/msg"), SlashCommand::Empty); + assert_eq!(parse_native("/msg alice"), SlashCommand::Empty); + assert_eq!(parse_native("/msg"), SlashCommand::Empty); } #[test] fn parses_topic() { assert_eq!( - parse("/topic the new topic"), + parse_native("/topic the new topic"), SlashCommand::Topic("the new topic".into()) ); } @@ -132,30 +162,30 @@ mod tests { #[test] fn unknown_command_falls_through_to_raw() { assert_eq!( - parse("/invite alice #freeq"), + parse_native("/invite alice #freeq"), SlashCommand::Raw("invite alice #freeq".into()) ); assert_eq!( - parse("/whois alice"), + parse_native("/whois alice"), SlashCommand::Raw("whois alice".into()) ); } #[test] fn command_lookup_is_case_insensitive() { - assert_eq!(parse("/JOIN #x"), SlashCommand::Join("#x".into())); - assert_eq!(parse("/Join #x"), SlashCommand::Join("#x".into())); + assert_eq!(parse_native("/JOIN #x"), SlashCommand::Join("#x".into())); + assert_eq!(parse_native("/Join #x"), SlashCommand::Join("#x".into())); } #[test] fn empty_or_lone_slash_input_is_empty() { - assert_eq!(parse("/"), SlashCommand::Empty); + assert_eq!(parse_native("/"), SlashCommand::Empty); } #[test] fn preserves_trailing_arguments_with_spaces() { assert_eq!( - parse("/me does the weird thing"), + parse_native("/me does the weird thing"), SlashCommand::Me("does the weird thing".into()) ); } diff --git a/host/Cargo.lock b/host/Cargo.lock index 12cc0fb..4a3e9f6 100644 --- a/host/Cargo.lock +++ b/host/Cargo.lock @@ -24,7 +24,7 @@ version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" dependencies = [ - "gimli", + "gimli 0.32.3", ] [[package]] @@ -235,6 +235,15 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "ar_archive_writer" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73cd58deff2140a0a8eae87e417bd01db68a33e148aa93d1e8cd837e55e312b6" +dependencies = [ + "object 0.39.1", +] + [[package]] name = "arbitrary" version = "1.4.2" @@ -522,7 +531,7 @@ dependencies = [ "cfg-if", "libc", "miniz_oxide", - "object", + "object 0.37.3", "rustc-demangle", "windows-link", ] @@ -560,7 +569,7 @@ dependencies = [ "bitflags 2.13.1", "cexpr", "clang-sys", - "itertools", + "itertools 0.13.0", "log", "prettyplease", "proc-macro2", @@ -581,7 +590,7 @@ dependencies = [ "bitflags 2.13.1", "cexpr", "clang-sys", - "itertools", + "itertools 0.13.0", "proc-macro2", "quote", "regex", @@ -708,6 +717,9 @@ name = "bumpalo" version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +dependencies = [ + "allocator-api2", +] [[package]] name = "bytemuck" @@ -1179,7 +1191,7 @@ dependencies = [ "jni 0.21.1", "js-sys", "libc", - "mach2", + "mach2 0.5.0", "ndk", "ndk-context", "num-derive", @@ -1216,6 +1228,113 @@ dependencies = [ "libc", ] +[[package]] +name = "cranelift-bforest" +version = "0.116.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e15d04a0ce86cb36ead88ad68cf693ffd6cda47052b9e0ac114bc47fd9cd23c4" +dependencies = [ + "cranelift-entity", +] + +[[package]] +name = "cranelift-bitset" +version = "0.116.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c6e3969a7ce267259ce244b7867c5d3bc9e65b0a87e81039588dfdeaede9f34" +dependencies = [ + "serde", + "serde_derive", +] + +[[package]] +name = "cranelift-codegen" +version = "0.116.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c22032c4cb42558371cf516bb47f26cdad1819d3475c133e93c49f50ebf304e" +dependencies = [ + "bumpalo", + "cranelift-bforest", + "cranelift-bitset", + "cranelift-codegen-meta", + "cranelift-codegen-shared", + "cranelift-control", + "cranelift-entity", + "cranelift-isle", + "gimli 0.31.1", + "hashbrown 0.14.5", + "log", + "regalloc2", + "rustc-hash 2.1.3", + "serde", + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cranelift-codegen-meta" +version = "0.116.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c904bc71c61b27fc57827f4a1379f29de64fe95653b620a3db77d59655eee0b8" +dependencies = [ + "cranelift-codegen-shared", +] + +[[package]] +name = "cranelift-codegen-shared" +version = "0.116.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40180f5497572f644ce88c255480981ae2ec1d7bb4d8e0c0136a13b87a2f2ceb" + +[[package]] +name = "cranelift-control" +version = "0.116.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d132c6d0bd8a489563472afc171759da0707804a65ece7ceb15a8c6d7dd5ef" +dependencies = [ + "arbitrary", +] + +[[package]] +name = "cranelift-entity" +version = "0.116.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b2d0d9618275474fbf679dd018ac6e009acbd6ae6850f6a67be33fb3b00b323" +dependencies = [ + "cranelift-bitset", + "serde", + "serde_derive", +] + +[[package]] +name = "cranelift-frontend" +version = "0.116.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fac41e16729107393174b0c9e3730fb072866100e1e64e80a1a963b2e484d57" +dependencies = [ + "cranelift-codegen", + "log", + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cranelift-isle" +version = "0.116.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ca20d576e5070044d0a72a9effc2deacf4d6aa650403189d8ea50126483944d" + +[[package]] +name = "cranelift-native" +version = "0.116.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dee82f3f1f2c4cba9177f1cc5e350fe98764379bcd29340caa7b01f85076c7" +dependencies = [ + "cranelift-codegen", + "libc", + "target-lexicon", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -2149,6 +2268,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365" +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + [[package]] name = "fast-interleave" version = "0.1.3" @@ -2599,6 +2724,17 @@ dependencies = [ "weezl", ] +[[package]] +name = "gimli" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" +dependencies = [ + "fallible-iterator", + "indexmap 2.14.0", + "stable_deref_trait", +] + [[package]] name = "gimli" version = "0.32.3" @@ -2829,6 +2965,15 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -2836,6 +2981,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "foldhash 0.1.5", + "serde", ] [[package]] @@ -3201,6 +3347,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "ident_case" version = "1.0.1" @@ -3649,6 +3801,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.13.0" @@ -3832,6 +3993,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "leb128" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c83bff1d572d6b9aeef67ddfc8448e4a3737909cb28e81f97c791b9018703e52" + [[package]] name = "libc" version = "0.2.189" @@ -3967,6 +4134,15 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3d25b0e0b648a86960ac23b7ad4abb9717601dec6f66c165f5b037f3f03065f" +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + [[package]] name = "mach2" version = "0.5.0" @@ -4000,6 +4176,15 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "memfd" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad38eb12aea514a0466ea40a80fd8cc83637065948eb4a426e4aa46261175227" +dependencies = [ + "rustix 1.1.4", +] + [[package]] name = "memmap2" version = "0.9.11" @@ -5117,6 +5302,18 @@ dependencies = [ "objc2-foundation 0.2.2", ] +[[package]] +name = "object" +version = "0.36.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" +dependencies = [ + "crc32fast", + "hashbrown 0.15.5", + "indexmap 2.14.0", + "memchr", +] + [[package]] name = "object" version = "0.37.3" @@ -5126,6 +5323,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "object" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b" +dependencies = [ + "memchr", +] + [[package]] name = "oid-registry" version = "0.8.1" @@ -5724,6 +5930,28 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" +[[package]] +name = "psm" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dcd034599e63b970727f70d79e02d62390a4a84f7c6b827c27c46d5ac3fa622" +dependencies = [ + "ar_archive_writer", + "cc", +] + +[[package]] +name = "pulley-interpreter" +version = "29.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62d95f8575df49a2708398182f49a888cf9dc30210fb1fd2df87c889edcee75d" +dependencies = [ + "cranelift-bitset", + "log", + "sptr", + "wasmtime-math", +] + [[package]] name = "pxfm" version = "0.1.30" @@ -6034,6 +6262,20 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "regalloc2" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc06e6b318142614e4a48bc725abbf08ff166694835c43c9dae5a9009704639a" +dependencies = [ + "allocator-api2", + "bumpalo", + "hashbrown 0.15.5", + "log", + "rustc-hash 2.1.3", + "smallvec", +] + [[package]] name = "regex" version = "1.13.1" @@ -6932,6 +7174,7 @@ dependencies = [ "cpal", "eframe", "sleek", + "wasmtime", ] [[package]] @@ -6948,6 +7191,9 @@ name = "smallvec" version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +dependencies = [ + "serde", +] [[package]] name = "smithay-client-toolkit" @@ -7165,6 +7411,12 @@ dependencies = [ "der 0.8.0", ] +[[package]] +name = "sptr" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b9b39299b249ad65f3b7e96443bad61c02ca5cd3589f46cb6d610a0fd6c0d6a" + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -8223,6 +8475,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.221.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc8444fe4920de80a4fe5ab564fff2ae58b6b73166b89751f8c6c93509da32e5" +dependencies = [ + "leb128", + "wasmparser", +] + [[package]] name = "wasm-streams" version = "0.5.0" @@ -8236,6 +8498,215 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wasmparser" +version = "0.221.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d06bfa36ab3ac2be0dee563380147a5b81ba10dd8885d7fbbc9eb574be67d185" +dependencies = [ + "bitflags 2.13.1", + "hashbrown 0.15.5", + "indexmap 2.14.0", + "semver", + "serde", +] + +[[package]] +name = "wasmprinter" +version = "0.221.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7343c42a97f2926c7819ff81b64012092ae954c5d83ddd30c9fcdefd97d0b283" +dependencies = [ + "anyhow", + "termcolor", + "wasmparser", +] + +[[package]] +name = "wasmtime" +version = "29.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11976a250672556d1c4c04c6d5d7656ac9192ac9edc42a4587d6c21460010e69" +dependencies = [ + "anyhow", + "bitflags 2.13.1", + "bumpalo", + "cc", + "cfg-if", + "hashbrown 0.14.5", + "indexmap 2.14.0", + "libc", + "log", + "mach2 0.4.3", + "memfd", + "object 0.36.7", + "once_cell", + "paste", + "postcard", + "psm", + "pulley-interpreter", + "rustix 0.38.44", + "serde", + "serde_derive", + "smallvec", + "sptr", + "target-lexicon", + "wasmparser", + "wasmtime-asm-macros", + "wasmtime-component-macro", + "wasmtime-cranelift", + "wasmtime-environ", + "wasmtime-fiber", + "wasmtime-jit-icache-coherence", + "wasmtime-math", + "wasmtime-slab", + "wasmtime-versioned-export-macros", + "windows-sys 0.59.0", +] + +[[package]] +name = "wasmtime-asm-macros" +version = "29.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f178b0d125201fbe9f75beaf849bd3e511891f9e45ba216a5b620802ccf64f2" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "wasmtime-component-macro" +version = "29.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d74de6592ed945d0a602f71243982a304d5d02f1e501b638addf57f42d57dfaf" +dependencies = [ + "anyhow", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasmtime-component-util", + "wasmtime-wit-bindgen", + "wit-parser", +] + +[[package]] +name = "wasmtime-component-util" +version = "29.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707dc7b3c112ab5a366b30cfe2fb5b2f8e6a0f682f16df96a5ec582bfe6f056e" + +[[package]] +name = "wasmtime-cranelift" +version = "29.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366be722674d4bf153290fbcbc4d7d16895cc82fb3e869f8d550ff768f9e9e87" +dependencies = [ + "anyhow", + "cfg-if", + "cranelift-codegen", + "cranelift-control", + "cranelift-entity", + "cranelift-frontend", + "cranelift-native", + "gimli 0.31.1", + "itertools 0.12.1", + "log", + "object 0.36.7", + "smallvec", + "target-lexicon", + "thiserror 1.0.69", + "wasmparser", + "wasmtime-environ", + "wasmtime-versioned-export-macros", +] + +[[package]] +name = "wasmtime-environ" +version = "29.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdadc1af7097347aa276a4f008929810f726b5b46946971c660b6d421e9994ad" +dependencies = [ + "anyhow", + "cranelift-bitset", + "cranelift-entity", + "gimli 0.31.1", + "indexmap 2.14.0", + "log", + "object 0.36.7", + "postcard", + "serde", + "serde_derive", + "smallvec", + "target-lexicon", + "wasm-encoder", + "wasmparser", + "wasmprinter", +] + +[[package]] +name = "wasmtime-fiber" +version = "29.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccba90d4119f081bca91190485650730a617be1fff5228f8c4757ce133d21117" +dependencies = [ + "anyhow", + "cc", + "cfg-if", + "rustix 0.38.44", + "wasmtime-asm-macros", + "wasmtime-versioned-export-macros", + "windows-sys 0.59.0", +] + +[[package]] +name = "wasmtime-jit-icache-coherence" +version = "29.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec5e8552e01692e6c2e5293171704fed8abdec79d1a6995a0870ab190e5747d1" +dependencies = [ + "anyhow", + "cfg-if", + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "wasmtime-math" +version = "29.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29210ec2aa25e00f4d54605cedaf080f39ec01a872c5bd520ad04c67af1dde17" +dependencies = [ + "libm", +] + +[[package]] +name = "wasmtime-slab" +version = "29.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcb5821a96fa04ac14bc7b158bb3d5cd7729a053db5a74dad396cd513a5e5ccf" + +[[package]] +name = "wasmtime-versioned-export-macros" +version = "29.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86ff86db216dc0240462de40c8290887a613dddf9685508eb39479037ba97b5b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "wasmtime-wit-bindgen" +version = "29.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8358319c2dd1e4db79e3c1c5d3a5af84956615343f9f89f4e4996a36816e06e6" +dependencies = [ + "anyhow", + "heck", + "indexmap 2.14.0", + "wit-parser", +] + [[package]] name = "wasmtimer" version = "0.4.3" @@ -9229,6 +9700,24 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "wit-parser" +version = "0.221.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "896112579ed56b4a538b07a3d16e562d101ff6265c46b515ce0c701eef16b2ac" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.14.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + [[package]] name = "wl-clipboard-rs" version = "0.9.3" diff --git a/host/Cargo.toml b/host/Cargo.toml index f92c263..ae23b21 100644 --- a/host/Cargo.toml +++ b/host/Cargo.toml @@ -5,6 +5,7 @@ edition = "2021" description = "Sleek — desktop window host" license = "MIT" publish = false +build = "build.rs" [workspace] @@ -31,6 +32,11 @@ eframe = { version = "0.31", default-features = false, features = [ "wayland", "x11", ] } +# Gleam slash guest (compose-box parser) via wasmtime. +wasmtime = { version = "29", default-features = false, features = [ + "cranelift", + "runtime", +] } # Same patches as android/ (host is its own workspace root). [patch.crates-io] diff --git a/host/build.rs b/host/build.rs new file mode 100644 index 0000000..a831010 --- /dev/null +++ b/host/build.rs @@ -0,0 +1,125 @@ +//! Compile Gleam Wasm guests into OUT_DIR for `include_bytes!`. + +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +fn main() { + let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()); + let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap()); + let gleam = find_gleam(&manifest_dir); + eprintln!("sleek-host build.rs: gleam → {}", gleam.display()); + println!("cargo:rerun-if-env-changed=GLEAM"); + + build_guest( + &gleam, + &manifest_dir, + &out_dir, + "gleam_slash", + "gleam/slash", + "src/gleam_slash.gleam", + ); +} + +fn build_guest( + gleam: &Path, + manifest_dir: &Path, + out_dir: &Path, + package: &str, + guest_rel: &str, + src_rel: &str, +) { + let guest_dir = manifest_dir.join(guest_rel); + let guest_src = guest_dir.join(src_rel); + + println!("cargo:rerun-if-changed={}", guest_src.display()); + println!( + "cargo:rerun-if-changed={}", + guest_dir.join("gleam.toml").display() + ); + + let status = Command::new(gleam) + .current_dir(&guest_dir) + .arg("build") + .status() + .unwrap_or_else(|err| { + panic!( + "failed to spawn gleam at {}: {err}\n\ + Set GLEAM to a wasm-capable gleam binary \ + (https://tangled.org/nandi.uk/gleam , branch wasm).", + gleam.display() + ) + }); + + if !status.success() { + panic!( + "`gleam build` failed in {} (status {status}).\n\ + Need Gleam with target = \"wasm\" support:\n\ + https://tangled.org/nandi.uk/gleam (branch wasm)\n\ + cargo build -p gleam && export GLEAM=$PWD/target/debug/gleam\n\ + Current candidate: {}", + guest_dir.display(), + gleam.display() + ); + } + + let wasm_src = guest_dir + .join("build/dev/wasm") + .join(package) + .join(format!("{package}.wasm")); + if !wasm_src.is_file() { + panic!( + "gleam build succeeded but {} was not created", + wasm_src.display() + ); + } + + let wasm_dst = out_dir.join(format!("{package}.wasm")); + fs::copy(&wasm_src, &wasm_dst).unwrap_or_else(|err| { + panic!( + "copy {} → {}: {err}", + wasm_src.display(), + wasm_dst.display() + ); + }); + + let inspect = manifest_dir.join("target").join(format!("{package}.wasm")); + if let Some(parent) = inspect.parent() { + let _ = fs::create_dir_all(parent); + } + let _ = fs::copy(&wasm_src, &inspect); + + eprintln!( + "sleek-host build.rs: {package} ready ({} bytes) → {}", + fs::metadata(&wasm_dst).map(|m| m.len()).unwrap_or(0), + wasm_dst.display() + ); +} + +fn find_gleam(manifest_dir: &Path) -> PathBuf { + if let Ok(path) = env::var("GLEAM") { + let p = PathBuf::from(path); + if p.is_file() { + return p; + } + panic!("GLEAM={p:?} is not a file"); + } + + let mut candidates = vec![ + manifest_dir.join("../../gleam/target/debug/gleam"), + manifest_dir.join("../../gleam/target/release/gleam"), + ]; + if let Some(home) = env::var_os("HOME") { + let home = PathBuf::from(home); + candidates.push(home.join("code/gleam/target/debug/gleam")); + candidates.push(home.join("code/gleam/target/release/gleam")); + } + for candidate in candidates { + if candidate.is_file() { + return candidate.canonicalize().unwrap_or(candidate); + } + } + + PathBuf::from("gleam") +} diff --git a/host/gleam/slash/README.md b/host/gleam/slash/README.md new file mode 100644 index 0000000..3d29724 --- /dev/null +++ b/host/gleam/slash/README.md @@ -0,0 +1,18 @@ +# gleam_slash + +Compose-box slash parser as a Gleam → Wasm guest for the sleek desktop host. + +## Layout + +- `parse(input) -> Slash` — custom type decoded by `host/src/gleam_slash.rs` +- String ops (`length`, `byte_at`, `slice`, `lowercase`) are `@external(wasm, "sleek", …)` + provided by the wasmtime linker (no Gleam stdlib on the Wasm target yet) + +## Smoke + +```bash +nix develop --command just gleam-slash +# or: cargo run --manifest-path host/Cargo.toml -- --gleam-slash-only +``` + +Compares Gleam output to `sleek::slash::parse_native` for the same cases. diff --git a/host/gleam/slash/gleam.toml b/host/gleam/slash/gleam.toml new file mode 100644 index 0000000..90cba94 --- /dev/null +++ b/host/gleam/slash/gleam.toml @@ -0,0 +1,4 @@ +name = "gleam_slash" +version = "0.1.0" +description = "Compose-box slash parser (Gleam → Wasm) for sleek desktop host" +target = "wasm" diff --git a/host/gleam/slash/manifest.toml b/host/gleam/slash/manifest.toml new file mode 100644 index 0000000..6e7ebce --- /dev/null +++ b/host/gleam/slash/manifest.toml @@ -0,0 +1,12 @@ +# Do not manually edit this file, it is managed by Gleam. +# +# This file locks the dependency versions used, to make your build +# deterministic and to prevent unexpected versions from being included +# in your application. +# +# You should check this file into your source control repository. + +packages = [ +] + +[requirements] diff --git a/host/gleam/slash/src/gleam_slash.gleam b/host/gleam/slash/src/gleam_slash.gleam new file mode 100644 index 0000000..b292243 --- /dev/null +++ b/host/gleam/slash/src/gleam_slash.gleam @@ -0,0 +1,157 @@ +//// Slash-command parser owned by Gleam; string primitives via `@external(wasm)`. +//// +//// Export: `gleam_slash__parse(String) -> Slash` (i32 struct ptr). +//// Host decodes the custom type from linear memory (tag @0, string fields @4…). +//// +//// Wasm MIR limits we work around: +//// - no `case` on tuples (`RuntimeCheck::Tuple` todo) +//// - no `case` on string values (lowers to indirect call) +//// - no first-class / indirect function calls + +pub type Slash { + Empty + PartActive + Join(String) + Nick(String) + Me(String) + Topic(String) + Raw(String) + Msg(String, String) +} + +type Arg { + NoArg + Arg(String) +} + +type Split { + Split(String, Arg) +} + +@external(wasm, "sleek", "length") +fn length(s: String) -> Int + +@external(wasm, "sleek", "byte_at") +fn byte_at(s: String, i: Int) -> Int + +@external(wasm, "sleek", "slice") +fn slice(s: String, start: Int, end: Int) -> String + +@external(wasm, "sleek", "lowercase") +fn lowercase(s: String) -> String + +/// Parse a compose-box string that starts with `/` (slash optional). +pub fn parse(input: String) -> Slash { + let stripped = drop_slash(input) + case split_once(stripped) { + Split(cmd_raw, arg) -> { + let cmd = lowercase(cmd_raw) + case length(cmd) { + 0 -> Empty + _ -> dispatch(cmd, arg, stripped) + } + } + } +} + +fn dispatch(cmd: String, arg: Arg, stripped: String) -> Slash { + case cmd == "join" { + True -> one_arg(arg, 0) + False -> + case cmd == "part" { + True -> PartActive + False -> + case cmd == "leave" { + True -> PartActive + False -> + case cmd == "nick" { + True -> one_arg(arg, 1) + False -> + case cmd == "me" { + True -> one_arg(arg, 2) + False -> + case cmd == "topic" { + True -> one_arg(arg, 3) + False -> + case cmd == "msg" { + True -> parse_msg(arg) + False -> Raw(stripped) + } + } + } + } + } + } + } +} + +/// `which`: 0=Join, 1=Nick, 2=Me, 3=Topic — avoids passing constructors as fns. +fn one_arg(arg: Arg, which: Int) -> Slash { + case arg { + NoArg -> Empty + Arg(a) -> + case which { + 0 -> Join(a) + 1 -> Nick(a) + 2 -> Me(a) + _ -> Topic(a) + } + } +} + +fn parse_msg(arg: Arg) -> Slash { + case arg { + NoArg -> Empty + Arg(a) -> + case split_once(a) { + Split(_, NoArg) -> Empty + Split(target, Arg(text)) -> + case length(target) > 0 { + False -> Empty + True -> + case length(text) > 0 { + True -> Msg(target, text) + False -> Empty + } + } + } + } +} + +fn drop_slash(s: String) -> String { + case length(s) { + 0 -> s + _ -> + case byte_at(s, 0) { + // '/' + 47 -> slice(s, 1, length(s)) + _ -> s + } + } +} + +fn split_once(s: String) -> Split { + let n = length(s) + case find_space(s, 0, n) { + -1 -> Split(s, NoArg) + i -> { + let rest = slice(s, i + 1, n) + case length(rest) { + 0 -> Split(slice(s, 0, i), NoArg) + _ -> Split(slice(s, 0, i), Arg(rest)) + } + } + } +} + +fn find_space(s: String, i: Int, n: Int) -> Int { + case i < n { + False -> -1 + True -> + case byte_at(s, i) { + // ' ' + 32 -> i + _ -> find_space(s, i + 1, n) + } + } +} diff --git a/host/src/gleam_slash.rs b/host/src/gleam_slash.rs new file mode 100644 index 0000000..2f9f48c --- /dev/null +++ b/host/src/gleam_slash.rs @@ -0,0 +1,233 @@ +//! Gleam Wasm slash parser: host string externals + custom-type decode. + +use std::sync::{Mutex, OnceLock}; + +use wasmtime::{Caller, Engine, Linker, Memory, Module, Store, TypedFunc}; + +use crate::gleam_string::{self, HostStringArena}; + +const SLASH_WASM: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/gleam_slash.wasm")); + +/// Mirrors `sleek::slash::SlashCommand` without depending on the android crate +/// layout from build scripts. Converted at the host boundary. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SlashCommand { + Join(String), + PartActive, + Nick(String), + Me(String), + Msg { target: String, text: String }, + Topic(String), + Raw(String), + Empty, +} + +struct HostState { + arena: HostStringArena, +} + +struct SlashSession { + store: Store, + memory: Memory, + parse: TypedFunc, +} + +static SESSION: OnceLock> = OnceLock::new(); + +fn session() -> Result<&'static Mutex, String> { + if let Some(s) = SESSION.get() { + return Ok(s); + } + let loaded = SlashSession::load()?; + let _ = SESSION.set(Mutex::new(loaded)); + SESSION + .get() + .ok_or_else(|| "gleam slash session missing after init".into()) +} + +impl SlashSession { + fn load() -> Result { + let engine = Engine::default(); + let module = Module::new(&engine, SLASH_WASM) + .map_err(|e| format!("parse gleam_slash.wasm: {e}"))?; + + let mut linker = Linker::new(&engine); + register_string_externals(&mut linker)?; + + let mut store = Store::new( + &engine, + HostState { + arena: HostStringArena::new(), + }, + ); + let instance = linker + .instantiate(&mut store, &module) + .map_err(|e| format!("instantiate gleam_slash.wasm: {e}"))?; + + let memory = instance + .get_memory(&mut store, "memory") + .ok_or_else(|| "gleam_slash.wasm missing exported memory".to_string())?; + let parse = instance + .get_typed_func(&mut store, "gleam_slash__parse") + .map_err(|e| format!("export gleam_slash__parse: {e}"))?; + + Ok(Self { + store, + memory, + parse, + }) + } + + fn call_parse(&mut self, in_ptr: i32) -> Result { + self.parse + .call(&mut self.store, in_ptr) + .map_err(|e| format!("call gleam_slash__parse: {e}")) + } +} + +fn register_string_externals(linker: &mut Linker) -> Result<(), String> { + linker + .func_wrap( + "sleek", + "length", + |mut caller: Caller<'_, HostState>, s: i32| -> i64 { + match memory_of(&mut caller).and_then(|m| gleam_string::read(&m, &caller, s)) { + Ok(text) => text.len() as i64, + Err(_) => 0, + } + }, + ) + .map_err(|e| format!("link sleek.length: {e}"))?; + + linker + .func_wrap( + "sleek", + "byte_at", + |mut caller: Caller<'_, HostState>, s: i32, i: i64| -> i64 { + match memory_of(&mut caller).and_then(|m| gleam_string::read(&m, &caller, s)) { + Ok(text) => { + let i = i as usize; + text.as_bytes().get(i).copied().unwrap_or(0) as i64 + } + Err(_) => 0, + } + }, + ) + .map_err(|e| format!("link sleek.byte_at: {e}"))?; + + linker + .func_wrap( + "sleek", + "slice", + |mut caller: Caller<'_, HostState>, s: i32, start: i64, end: i64| -> i32 { + let Ok(memory) = memory_of(&mut caller) else { + return 0; + }; + let Ok(text) = gleam_string::read(&memory, &caller, s) else { + return 0; + }; + let len = text.len() as i64; + let start = start.clamp(0, len) as usize; + let end = end.clamp(start as i64, len) as usize; + let piece = text[start..end].to_string(); + write_host_string(&mut caller, memory, &piece) + }, + ) + .map_err(|e| format!("link sleek.slice: {e}"))?; + + linker + .func_wrap( + "sleek", + "lowercase", + |mut caller: Caller<'_, HostState>, s: i32| -> i32 { + let Ok(memory) = memory_of(&mut caller) else { + return 0; + }; + let Ok(text) = gleam_string::read(&memory, &caller, s) else { + return 0; + }; + let lower = text.to_ascii_lowercase(); + write_host_string(&mut caller, memory, &lower) + }, + ) + .map_err(|e| format!("link sleek.lowercase: {e}"))?; + + Ok(()) +} + +fn write_host_string(caller: &mut Caller<'_, HostState>, memory: Memory, value: &str) -> i32 { + let mut arena = std::mem::take(&mut caller.data_mut().arena); + let ptr = gleam_string::write(&mut arena, &memory, &mut *caller, value).unwrap_or(0); + caller.data_mut().arena = arena; + ptr +} + +fn memory_of(caller: &mut Caller<'_, HostState>) -> Result { + caller + .get_export("memory") + .and_then(|e| e.into_memory()) + .ok_or_else(|| "missing memory export during sleek string external".into()) +} + +/// Parse `input` via the Gleam Wasm guest. +pub fn parse(input: &str) -> Result { + let mutex = session()?; + let mut s = mutex + .lock() + .map_err(|_| "gleam slash session lock poisoned".to_string())?; + + let memory = s.memory; + let mut arena = std::mem::take(&mut s.store.data_mut().arena); + let in_ptr = gleam_string::write(&mut arena, &memory, &mut s.store, input); + s.store.data_mut().arena = arena; + let in_ptr = in_ptr?; + + let out_ptr = s.call_parse(in_ptr)?; + decode_slash(&s.memory, &s.store, out_ptr) +} + +/// Gleam custom type layout: `tag: u32 @0`, fields as `i32` slots at `4 + i*4`. +/// +/// Declaration order in `gleam_slash.gleam`: +/// Empty, PartActive, Join, Nick, Me, Topic, Raw, Msg. +fn decode_slash( + memory: &Memory, + store: &Store, + ptr: i32, +) -> Result { + if ptr <= 0 { + return Err(format!("slash result ptr {ptr} invalid")); + } + let ptr = ptr as usize; + let data = memory.data(store); + if ptr + 4 > data.len() { + return Err("slash result tag out of bounds".into()); + } + let tag = u32::from_le_bytes(data[ptr..ptr + 4].try_into().unwrap()); + let field = |index: usize| -> Result { + let off = ptr + 4 + index * 4; + if off + 4 > data.len() { + return Err(format!("slash field {index} out of bounds")); + } + Ok(i32::from_le_bytes(data[off..off + 4].try_into().unwrap())) + }; + let read_str = |index: usize| -> Result { + let sp = field(index)?; + gleam_string::read(memory, store, sp) + }; + + match tag { + 0 => Ok(SlashCommand::Empty), + 1 => Ok(SlashCommand::PartActive), + 2 => Ok(SlashCommand::Join(read_str(0)?)), + 3 => Ok(SlashCommand::Nick(read_str(0)?)), + 4 => Ok(SlashCommand::Me(read_str(0)?)), + 5 => Ok(SlashCommand::Topic(read_str(0)?)), + 6 => Ok(SlashCommand::Raw(read_str(0)?)), + 7 => Ok(SlashCommand::Msg { + target: read_str(0)?, + text: read_str(1)?, + }), + other => Err(format!("unknown slash tag {other}")), + } +} diff --git a/host/src/gleam_string.rs b/host/src/gleam_string.rs new file mode 100644 index 0000000..d9f6043 --- /dev/null +++ b/host/src/gleam_string.rs @@ -0,0 +1,124 @@ +//! Gleam Wasm string ABI helpers for wasmtime hosts. +//! +//! Layout (see gleam `compiler-core/src/wasm.rs`): +//! +//! ```text +//! String = i32 pointer → { len: u32 LE, data: [u8; len] } in linear `memory` +//! ``` +//! +//! Host→guest writes use a **host arena** in freshly grown high pages so they +//! do not collide with the guest bump allocator (`$gleam_heap_ptr`). + +use wasmtime::{AsContext, AsContextMut, Memory, StoreContext, StoreContextMut}; + +const PAGE: u64 = 65_536; + +/// Next free offset in the host-owned high arena (`0` = not yet placed). +#[derive(Debug, Default, Clone, Copy)] +pub struct HostStringArena { + next: u32, +} + +impl HostStringArena { + pub fn new() -> Self { + Self { next: 0 } + } +} + +/// Read a Gleam `String` from guest linear memory. +pub fn read(memory: &Memory, store: impl AsContext, ptr: i32) -> Result { + read_ctx(memory, store.as_context(), ptr) +} + +pub fn read_ctx( + memory: &Memory, + store: StoreContext<'_, T>, + ptr: i32, +) -> Result { + if ptr < 0 { + return Err(format!("gleam string ptr {ptr} is negative")); + } + let ptr = ptr as usize; + let data = memory.data(store); + if ptr.checked_add(4).is_none_or(|end| end > data.len()) { + return Err(format!( + "gleam string ptr {ptr} out of bounds for len header (mem {})", + data.len() + )); + } + let len = u32::from_le_bytes(data[ptr..ptr + 4].try_into().unwrap()) as usize; + let data_start = ptr + 4; + let data_end = data_start + .checked_add(len) + .ok_or_else(|| format!("gleam string len {len} overflows"))?; + if data_end > data.len() { + return Err(format!( + "gleam string ptr {ptr} len {len} out of bounds (mem {})", + data.len() + )); + } + let bytes = &data[data_start..data_end]; + String::from_utf8(bytes.to_vec()).map_err(|e| format!("gleam string utf-8: {e}")) +} + +/// Write `value` into a host-owned high arena and return the Gleam string pointer. +pub fn write( + arena: &mut HostStringArena, + memory: &Memory, + mut store: impl AsContextMut, + value: &str, +) -> Result { + write_ctx(arena, memory, store.as_context_mut(), value) +} + +pub fn write_ctx( + arena: &mut HostStringArena, + memory: &Memory, + mut store: StoreContextMut<'_, T>, + value: &str, +) -> Result { + let bytes = value.as_bytes(); + let need = 4u64 + .checked_add(bytes.len() as u64) + .ok_or_else(|| "gleam string too large".to_string())?; + let need_aligned = (need + 3) & !3; + + if arena.next == 0 { + let old_size = memory.data_size(&mut store) as u64; + let start = (old_size + 3) & !3; + let end = start + .checked_add(need_aligned) + .ok_or_else(|| "gleam string arena overflow".to_string())?; + ensure_pages(memory, &mut store, end)?; + arena.next = start as u32; + } + + let start = arena.next as u64; + let end = start + .checked_add(need_aligned) + .ok_or_else(|| "gleam string arena overflow".to_string())?; + ensure_pages(memory, &mut store, end)?; + + let start_usize = start as usize; + let data = memory.data_mut(&mut store); + data[start_usize..start_usize + 4].copy_from_slice(&(bytes.len() as u32).to_le_bytes()); + data[start_usize + 4..start_usize + 4 + bytes.len()].copy_from_slice(bytes); + + arena.next = end as u32; + Ok(start as i32) +} + +fn ensure_pages( + memory: &Memory, + store: &mut StoreContextMut<'_, T>, + end_byte: u64, +) -> Result<(), String> { + let need_pages = end_byte.div_ceil(PAGE); + let cur_pages = memory.size(&mut *store); + if need_pages > cur_pages { + memory + .grow(&mut *store, need_pages - cur_pages) + .map_err(|e| format!("memory.grow({}): {e}", need_pages - cur_pages))?; + } + Ok(()) +} diff --git a/host/src/main.rs b/host/src/main.rs index 35c0e6c..11832d8 100644 --- a/host/src/main.rs +++ b/host/src/main.rs @@ -1,5 +1,106 @@ //! Desktop host: `just host` or `cargo run --manifest-path host/Cargo.toml` +//! +//! Builds / loads the Gleam Wasm slash guest (`just gleam-slash` / `--gleam-slash-only`). + +mod gleam_slash; +mod gleam_string; fn main() -> eframe::Result { + let slash_only = std::env::args().any(|a| a == "--gleam-slash-only"); + + match smoke_gleam_slash() { + Ok(summary) => { + println!("gleam slash smoke → {summary}"); + if slash_only { + return Ok(()); + } + } + Err(err) => { + eprintln!("gleam slash smoke failed: {err}"); + if slash_only { + std::process::exit(1); + } + } + } + + install_gleam_slash_hook(); sleek::run_desktop() } + +fn install_gleam_slash_hook() { + sleek::slash::install_parse_hook(Box::new(|input: &str| { + gleam_slash::parse(input).map(into_sleek_slash) + })); +} + +fn into_sleek_slash(cmd: gleam_slash::SlashCommand) -> sleek::slash::SlashCommand { + use gleam_slash::SlashCommand as G; + use sleek::slash::SlashCommand as S; + match cmd { + G::Join(c) => S::Join(c), + G::PartActive => S::PartActive, + G::Nick(n) => S::Nick(n), + G::Me(a) => S::Me(a), + G::Msg { target, text } => S::Msg { target, text }, + G::Topic(t) => S::Topic(t), + G::Raw(r) => S::Raw(r), + G::Empty => S::Empty, + } +} + +fn smoke_gleam_slash() -> Result { + let cases: &[(&str, gleam_slash::SlashCommand)] = &[ + ("/join #freeq", gleam_slash::SlashCommand::Join("#freeq".into())), + ("/join", gleam_slash::SlashCommand::Empty), + ("/part", gleam_slash::SlashCommand::PartActive), + ("/leave", gleam_slash::SlashCommand::PartActive), + ( + "/nick newhandle", + gleam_slash::SlashCommand::Nick("newhandle".into()), + ), + ( + "/me waves hello", + gleam_slash::SlashCommand::Me("waves hello".into()), + ), + ( + "/msg alice hi there", + gleam_slash::SlashCommand::Msg { + target: "alice".into(), + text: "hi there".into(), + }, + ), + ("/msg alice", gleam_slash::SlashCommand::Empty), + ( + "/topic the new topic", + gleam_slash::SlashCommand::Topic("the new topic".into()), + ), + ( + "/invite alice #freeq", + gleam_slash::SlashCommand::Raw("invite alice #freeq".into()), + ), + ("/JOIN #x", gleam_slash::SlashCommand::Join("#x".into())), + ("/", gleam_slash::SlashCommand::Empty), + ( + "/me does the weird thing", + gleam_slash::SlashCommand::Me("does the weird thing".into()), + ), + ]; + + for &(input, ref expected) in cases { + let native = sleek::slash::parse_native(input); + let gleam = gleam_slash::parse(input)?; + let gleam_as_sleek = into_sleek_slash(gleam.clone()); + if gleam_as_sleek != native { + return Err(format!( + "{input:?}: gleam={gleam_as_sleek:?} native={native:?}" + )); + } + if &gleam != expected { + return Err(format!( + "{input:?}: gleam got {gleam:?}, expected {expected:?}" + )); + } + } + + Ok(format!("{} cases match native oracle", cases.len())) +} diff --git a/justfile b/justfile index 5cbdf24..eb941aa 100644 --- a/justfile +++ b/justfile @@ -52,6 +52,18 @@ host *args: fi cargo run --manifest-path host/Cargo.toml {{args}} +# Gleam Wasm slash parser smoke (matches native Rust oracle; no GUI) +gleam-slash: + #!/usr/bin/env bash + set -euo pipefail + if [[ -n "${SLEEK_LD_LIBRARY_PATH:-}" ]]; then + export LD_LIBRARY_PATH="${SLEEK_LD_LIBRARY_PATH}${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" + fi + if [[ -z "${GLEAM:-}" && -x "${HOME}/code/gleam/target/debug/gleam" ]]; then + export GLEAM="${HOME}/code/gleam/target/debug/gleam" + fi + cargo run --manifest-path host/Cargo.toml -- --gleam-slash-only + # Check / build the Android package as a library (desktop target) lib: cargo build --manifest-path android/Cargo.toml --lib -- 2.51.2