From b5ac9ab63dd35d13cf660373c280c8fe9458beb7 Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Tue, 9 Jun 2026 17:13:52 -0500 Subject: [PATCH] feat: target-specific Host ABI adapters and diagnostics --- crates/core/src/ir/lowerer.rs | 22 +-- crates/core/src/stdlib.rs | 8 +- crates/core/src/wasm.rs | 129 ++++++++++++++++-- crates/core/src/wasm/helpers.rs | 31 +++++ .../architecture/wasm_backend_and_runtime.md | 25 ++-- .../specs/14_stdlib_and_host_interop.md | 63 +++++++-- .../tasks/14_stdlib_and_host_interop.md | 10 +- 7 files changed, 230 insertions(+), 58 deletions(-) diff --git a/crates/core/src/ir/lowerer.rs b/crates/core/src/ir/lowerer.rs index cceb985..cb8d644 100644 --- a/crates/core/src/ir/lowerer.rs +++ b/crates/core/src/ir/lowerer.rs @@ -1,15 +1,13 @@ use std::collections::HashMap; -use super::bit_slices::{ast_bit_array_literal, bit_array_literal, bit_string_pattern_segments}; use super::*; -use crate::{ - ast::{self, Declaration as AstDeclaration, Expression as AstExpression, Pattern, Statement}, - diagnostic::{Diagnostic, DiagnosticCode, Diagnostics, Label}, - labels::{FunctionLabelMap, call_argument_order, function_label_map, use_callback_placement}, - resolve::ReferenceTarget, - stdlib::{MemberStrategy, StdlibRegistry}, - types::{ConstructorInfo, TypedModule}, -}; +use crate::ast::{self, Declaration as AstDeclaration, Expression as AstExpression, Pattern, Statement}; +use crate::diagnostic::{Diagnostic, DiagnosticCode, Diagnostics, Label}; +use crate::labels::{FunctionLabelMap, call_argument_order, function_label_map, use_callback_placement}; +use crate::resolve::ReferenceTarget; +use crate::stdlib::{MemberStrategy, STDLIB_IO_HOST_MODULE, StdlibRegistry}; +use crate::types::{ConstructorInfo, TypedModule}; +use bit_slices::{ast_bit_array_literal, bit_array_literal, bit_string_pattern_segments}; pub(super) fn lower(module: TypedModule) -> Result { Lowerer::new(module).lower() @@ -1169,7 +1167,7 @@ impl Lowerer { return_type: *return_type, abi: call_abi( &type_, - CallBoundary::HostImport { module: "env".into(), name: member.name.into() }, + CallBoundary::HostImport { module: STDLIB_IO_HOST_MODULE.into(), name: member.name.into() }, ), body: Block { instructions: Vec::new(), @@ -1248,7 +1246,9 @@ fn stdlib_lowered_name(module: &str, member: &str) -> String { fn stdlib_boundary(strategy: MemberStrategy, member: &str) -> CallBoundary { match strategy { - MemberStrategy::HostImport => CallBoundary::HostImport { module: "env".into(), name: member.into() }, + MemberStrategy::HostImport => { + CallBoundary::HostImport { module: STDLIB_IO_HOST_MODULE.into(), name: member.into() } + } _ => CallBoundary::Internal, } } diff --git a/crates/core/src/stdlib.rs b/crates/core/src/stdlib.rs index 1819593..3a2fd70 100644 --- a/crates/core/src/stdlib.rs +++ b/crates/core/src/stdlib.rs @@ -1,9 +1,9 @@ use std::collections::HashMap; -use crate::{ - source::{SourceFileId, Span}, - types::{ConstructorInfo, FieldInfo, ModuleInterface, Type, TypeDeclaration}, -}; +use crate::source::{SourceFileId, Span}; +use crate::types::{ConstructorInfo, FieldInfo, ModuleInterface, Type, TypeDeclaration}; + +pub const STDLIB_IO_HOST_MODULE: &str = "__regulus_stdlib_io"; const STDLIB_SPAN: Span = Span { file_id: SourceFileId(u32::MAX), start: 0, end: 0 }; diff --git a/crates/core/src/wasm.rs b/crates/core/src/wasm.rs index 9f23bc6..23341e5 100644 --- a/crates/core/src/wasm.rs +++ b/crates/core/src/wasm.rs @@ -4,7 +4,9 @@ use std::{collections::HashMap, fmt::Write}; use crate::diagnostic::{Diagnostic, DiagnosticCode, Diagnostics, Label}; use crate::ir::{self, ExpressionKind, Instruction}; -use crate::{ClosureConstants, ast::LiteralKind, runtime, target::CompileTarget, types::Type}; +use crate::{ + ClosureConstants, ast::LiteralKind, runtime, stdlib::STDLIB_IO_HOST_MODULE, target::CompileTarget, types::Type, +}; /// WebAssembly output from the backend. #[derive(Debug, Clone, PartialEq, Eq)] @@ -44,6 +46,14 @@ impl From for WasmTarget { } impl WasmTarget { + fn name(self) -> &'static str { + match self { + Self::Wasmtime => "wasmtime", + Self::Browser => "browser", + Self::Wasi => "wasi", + } + } + fn host_module(self) -> &'static str { match self { Self::Wasmtime => "env", @@ -178,6 +188,12 @@ struct Emitter { options: EmitOptions, } +#[derive(Debug, Clone, PartialEq, Eq)] +struct ConcreteHostImport { + module: String, + name: String, +} + impl Emitter { fn constant(&mut self, constant: &ir::Constant) { if let ir::ConstantValue::Literal(ir::Literal { kind: LiteralKind::String, source }) = &constant.value { @@ -185,11 +201,39 @@ impl Emitter { } } + fn concrete_import(&mut self, function: &ir::Function) -> Option { + match &function.abi.boundary { + ir::CallBoundary::HostImport { module, name } if module == STDLIB_IO_HOST_MODULE => { + self.stdlib_io_import(name, function.span) + } + ir::CallBoundary::HostImport { module, name } if module == self.options.target.host_module() => { + Some(ConcreteHostImport { module: module.clone(), name: name.clone() }) + } + ir::CallBoundary::HostImport { module, .. } => { + self.unsupported_target_import(module, function.span, &function.name); + None + } + ir::CallBoundary::ModuleImport { module } => { + Some(ConcreteHostImport { module: module.clone(), name: function.name.clone() }) + } + ir::CallBoundary::Internal | ir::CallBoundary::ModuleExport => None, + } + } + + fn stdlib_io_import(&mut self, name: &str, span: crate::source::Span) -> Option { + match self.options.target { + WasmTarget::Wasmtime => Some(ConcreteHostImport { module: "env".into(), name: name.into() }), + WasmTarget::Browser => Some(ConcreteHostImport { module: "browser".into(), name: name.into() }), + WasmTarget::Wasi => { + self.unsupported_stdlib_host_call("gleam/io", name, span); + None + } + } + } + fn import_function(&mut self, function: &ir::Function) { - let import = match &function.abi.boundary { - ir::CallBoundary::HostImport { module, name } => (module.as_str(), name.as_str()), - ir::CallBoundary::ModuleImport { module } => (module.as_str(), function.name.as_str()), - ir::CallBoundary::Internal | ir::CallBoundary::ModuleExport => return, + let Some(import) = self.concrete_import(function) else { + return; }; if !self.validate_host_abi(function) { return; @@ -198,7 +242,7 @@ impl Emitter { write!( self.imports, " (import \"{}\" \"{}\" (func ${}", - import.0, import.1, function.name + import.module, import.name, function.name ) .expect("write WAT"); for param in &function.params { @@ -1311,15 +1355,8 @@ impl Emitter { } fn validate_host_abi(&mut self, function: &ir::Function) -> bool { - match &function.abi.boundary { - ir::CallBoundary::Internal => return true, - ir::CallBoundary::HostImport { module, .. } if module != self.options.target.host_module() => { - self.unsupported_target_import(module, function.span, &function.name); - return false; - } - ir::CallBoundary::ModuleExport - | ir::CallBoundary::ModuleImport { .. } - | ir::CallBoundary::HostImport { .. } => {} + if matches!(function.abi.boundary, ir::CallBoundary::Internal) { + return true; } let mut supported = true; @@ -1350,6 +1387,20 @@ impl Emitter { ); } + fn unsupported_stdlib_host_call(&mut self, module: &str, member: &str, span: crate::source::Span) { + self.diagnostics.push( + Diagnostic::new( + DiagnosticCode::WasmError, + format!( + "stdlib host call `{module}.{member}` is not supported for target `{}`", + self.options.target.name() + ), + ) + .with_label(Label::primary(span, "unsupported host call for this target")) + .with_note("supported targets for `gleam/io` host calls are `wasmtime` and `browser`"), + ); + } + fn unsupported_abi_type(&mut self, type_: &Type, span: crate::source::Span, function: &str) { self.diagnostics.push( Diagnostic::new( @@ -1436,6 +1487,7 @@ impl RuntimePrelude { self.lines(&managed_value_helpers); self.lines(helpers::EQUALITY_AND_ORDERING_HELPERS); self.lines(helpers::DEBUG_HELPERS); + self.lines(helpers::HOST_ADAPTER_HELPERS); } fn lines(&mut self, block: &str) { @@ -1861,6 +1913,14 @@ mod tests { emit(&ir).expect("emit wasm") } + fn compile_wasm_target(source: &str, target: CompileTarget) -> Result { + crate::compile_source_with_options( + SourceFile::new(SourceFileId(0), source), + crate::CompileOptions { target }, + ) + .map(|output| output.wasm) + } + fn int_expr(source: &str, span: Span) -> ir::Expression { ir::Expression { type_: Type::Int, @@ -1953,6 +2013,8 @@ mod tests { assert!(wasm.wat.contains("(memory (export \"memory\") 1)")); assert!(wasm.wat.contains("(func $__alloc")); + assert!(wasm.wat.contains("(export \"__regulus_string_len\")")); + assert!(wasm.wat.contains("(export \"__regulus_value_tag\")")); assert!(wasm.wat.contains("(func $greeting (export \"greeting\") (result i32)")); assert!(wasm.wat.contains(&format!( "i32.const {}", @@ -2672,6 +2734,43 @@ pub fn main() { main.call(&mut store, ()).expect("call main"); } + #[test] + fn selects_browser_stdlib_io_host_imports() { + let wasm = compile_wasm_target( + r#"import gleam/io + +pub fn main() { io.println("hi") } +"#, + CompileTarget::Browser, + ) + .expect("compile browser io"); + + assert!( + wasm.wat + .contains("(import \"browser\" \"println\" (func $__stdlib_gleam_io_println"), + "{}", + wasm.wat + ); + } + + #[test] + fn reports_unsupported_wasi_stdlib_io_host_imports() { + let errors = compile_wasm_target( + r#"import gleam/io + +pub fn main() { io.println("hi") } +"#, + CompileTarget::Wasi, + ) + .expect_err("wasi io should be unsupported"); + + assert!(errors.iter().any(|diagnostic| { + diagnostic + .message + .contains("stdlib host call `gleam/io.println` is not supported for target `wasi`") + })); + } + #[test] fn runs_initial_stdlib_intrinsics() { let wasm = compile_wasm( diff --git a/crates/core/src/wasm/helpers.rs b/crates/core/src/wasm/helpers.rs index a62a60a..84a1e3d 100644 --- a/crates/core/src/wasm/helpers.rs +++ b/crates/core/src/wasm/helpers.rs @@ -1323,6 +1323,37 @@ pub const DEBUG_HELPERS: &str = r#" ) "#; +pub const HOST_ADAPTER_HELPERS: &str = r#" + (func $__regulus_string_len (export "__regulus_string_len") (param $ptr i32) (result i32) + local.get $ptr + call $__string_len + ) + (func $__regulus_string_data (export "__regulus_string_data") (param $ptr i32) (result i32) + local.get $ptr + call $__string_data + ) + (func $__regulus_value_tag (export "__regulus_value_tag") (param $ptr i32) (result i32) + local.get $ptr + call $__debug_tag + ) + (func $__regulus_value_size (export "__regulus_value_size") (param $ptr i32) (result i32) + local.get $ptr + call $__debug_size + ) + (func $__regulus_value_field_i64 (export "__regulus_value_field_i64") + (param $ptr i32) (param $index i32) (result i64) + local.get $ptr + local.get $index + call $__debug_payload_i64 + ) + (func $__regulus_value_field_i32 (export "__regulus_value_field_i32") + (param $ptr i32) (param $index i32) (result i32) + local.get $ptr + local.get $index + call $__debug_payload_i32 + ) +"#; + pub const MANAGED_VALUE_HELPERS: &str = r#" (func $__list_cons (param $head i64) (param $tail i32) (result i32) (local $ptr i32) diff --git a/docs/src/internal/development/architecture/wasm_backend_and_runtime.md b/docs/src/internal/development/architecture/wasm_backend_and_runtime.md index 39169e6..2e089e4 100644 --- a/docs/src/internal/development/architecture/wasm_backend_and_runtime.md +++ b/docs/src/internal/development/architecture/wasm_backend_and_runtime.md @@ -72,8 +72,8 @@ Gleam language. These areas still need design or implementation before every accepted source program can execute with full Gleam semantics: - explicit arena reset points beyond instance reset -- target-specific browser and WASI adapters -- standard library and dependency-backed imports +- broader WASI adapters beyond current unsupported stdlib diagnostics +- standard library and dependency-backed imports beyond Group 1 ## ABI rules @@ -82,13 +82,15 @@ Internal calls pass scalars as raw WebAssembly values and managed values as validated before emission. The backend supports explicit target selection for Wasmtime, browser, and WASI-oriented modules. -Host imports must use the target's host module name: +Host imports must use the target's host module name. Compiler-provided stdlib +host shims use logical IR modules and are mapped to concrete target imports by +the backend. -| Target | Host module | -| -------- | ------------------------ | -| Wasmtime | `env` | -| Browser | `browser` | -| WASI | `wasi_snapshot_preview1` | +| Target | General host module | `gleam/io` imports | +| -------- | ------------------------ | ---------------------------------- | +| Wasmtime | `env` | `env.print`, `env.println` | +| Browser | `browser` | `browser.print`, `browser.println` | +| WASI | `wasi_snapshot_preview1` | unsupported diagnostic | The current raw ABI supports: @@ -102,9 +104,10 @@ The current raw ABI supports: Managed values can be exported directly for low-level Wasmtime tests. String exports with no parameters also get `__data` and `__len` adapter exports so host -code can read the string payload without duplicating the call shape. Safer -browser or user-facing APIs should use adapters that read or write memory using -the documented object layout. +code can read the string payload without duplicating the call shape. Runtime +modules also export `__regulus_string_len`, `__regulus_string_data`, +`__regulus_value_tag`, `__regulus_value_size`, and raw field readers for host +adapters that need to inspect managed values. Unsupported target/import/type combinations produce `WasmError` diagnostics before WAT assembly. diff --git a/docs/src/internal/specs/14_stdlib_and_host_interop.md b/docs/src/internal/specs/14_stdlib_and_host_interop.md index 0b2b230..6522fdb 100644 --- a/docs/src/internal/specs/14_stdlib_and_host_interop.md +++ b/docs/src/internal/specs/14_stdlib_and_host_interop.md @@ -37,15 +37,15 @@ The group contains: Initial implementations should cover: -| Module | Initial support | -| --- | --- | -| `gleam/io` | `println`, `print`, maybe `debug` | -| `gleam/int` | `to_string`, later `parse` | -| `gleam/string` | `append`, `concat`, `length`, `is_empty` | -| `gleam/list` | `length`, `reverse`, later `map` and `fold` | -| `gleam/result` | `Result`, `Ok`, `Error`, maybe `map` | -| `gleam/option` | `Option`, `Some`, `None`, maybe `map` | -| `gleam/order` | `Order`, `Lt`, `Eq`, `Gt` | +| Module | Initial support | +| -------------- | ------------------------------------------- | +| `gleam/io` | `println`, `print`, maybe `debug` | +| `gleam/int` | `to_string`, later `parse` | +| `gleam/string` | `append`, `concat`, `length`, `is_empty` | +| `gleam/list` | `length`, `reverse`, later `map` and `fold` | +| `gleam/result` | `Result`, `Ok`, `Error`, maybe `map` | +| `gleam/option` | `Option`, `Some`, `None`, maybe `map` | +| `gleam/order` | `Order`, `Lt`, `Eq`, `Gt` | Each implemented member should declare one lowering strategy: intrinsic, host import, compiled Gleam source, or adapter around a host import. @@ -87,9 +87,48 @@ The host ABI should define how values cross the WASM boundary: - memory ownership - arbitrary managed-value import/export wrappers -Browser and WASI adapters should be concrete, not just target names. Each target -should document required imports, exports, memory access rules, and unsupported -combinations. +Concrete host ABI for Group 1 uses raw WASM values plus runtime adapters: + +| Gleam shape | WASM shape | Host rule | +| -------------- | ---------- | ---------------------------------- | +| `Int` | `i64` | signed 64-bit integer | +| `Float` | `f64` | IEEE-754 double | +| `Bool` | `i32` | `0` false, non-zero true | +| `Nil` | no result | unit value | +| managed values | `i32` | borrowed pointer into guest memory | + +Managed values include strings, bit arrays, lists, tuples, records, custom +values, functions, errors, and panics. The guest runtime owns these values. +Hosts may read them during and after a call while the instance is alive, but +must not mutate object memory or retain pointers across instance reset or any +future arena reset. + +The runtime exports adapter helpers for low-level hosts: + +| Export | Purpose | +| --------------------------------------- | ------------------------------------ | +| `memory` | guest linear memory | +| `__regulus_string_len(ptr)` | byte length for a string pointer | +| `__regulus_string_data(ptr)` | data address for a string pointer | +| `__regulus_value_tag(ptr)` | runtime object tag, `0` for nil/null | +| `__regulus_value_size(ptr)` | runtime object size/arity/bit length | +| `__regulus_value_field_i64(ptr, index)` | raw managed field slot | +| `__regulus_value_field_i32(ptr, index)` | raw field slot narrowed to `i32` | + +Compiler code receives host-provided managed values as borrowed `i32` pointers. +The host must only pass pointers to values allocated in the same guest memory +or adapter functions that explicitly document another ownership rule. + +Current stdlib host imports are: + +| Gleam member | Wasmtime import | Browser import | WASI | +| ------------------ | ------------------ | ---------------------- | ----------- | +| `gleam/io.print` | `env.print(ptr)` | `browser.print(ptr)` | unsupported | +| `gleam/io.println` | `env.println(ptr)` | `browser.println(ptr)` | unsupported | + +WASI `gleam/io` is deliberately unsupported until a concrete `fd_write` +adapter is added. Unsupported host calls, target combinations, and ABI shapes +produce source-spanned diagnostics before WAT assembly. ## Diagnostics diff --git a/docs/src/internal/tasks/14_stdlib_and_host_interop.md b/docs/src/internal/tasks/14_stdlib_and_host_interop.md index cf15624..6bd9731 100644 --- a/docs/src/internal/tasks/14_stdlib_and_host_interop.md +++ b/docs/src/internal/tasks/14_stdlib_and_host_interop.md @@ -40,14 +40,14 @@ Support useful standard library modules and host calls. ### Host ABI -- [ ] Define concrete host imports and adapters for Wasmtime, browser, and WASI +- [x] Define concrete host imports and adapters for Wasmtime, browser, and WASI targets where supported. -- [ ] Define ABI rules for scalar values, strings, bit arrays, lists, tuples, +- [x] Define ABI rules for scalar values, strings, bit arrays, lists, tuples, records, custom types, functions, errors, and panics. -- [ ] Define ownership rules for managed values crossing the host boundary. -- [ ] Define how host code reads compiler memory and how compiler code receives +- [x] Define ownership rules for managed values crossing the host boundary. +- [x] Define how host code reads compiler memory and how compiler code receives host-provided managed values. -- [ ] Add rich managed-value wrappers or adapter functions for ABI shapes that +- [x] Add rich managed-value wrappers or adapter functions for ABI shapes that do not map directly to raw WASM parameters and results. ### Intrinsics and host calls -- 2.51.2