@@ -472,10 +472,124 @@ pub(crate) fn compile_big_stack(src: &str) -> Result<Vec<u8>, localharness::rust
472472 . expect ( "rustlite compile thread panicked" )
473473}
474474
475+ /// Parse the wasm import section (id 2) and return every `host::<module>::<func>`
476+ /// the cartridge binds — its exact platform-call surface (its "tool schemas" in
477+ /// cartridge-author terms) — sorted + deduped. Imports are emitted as
478+ /// `host_<module>` (codegen), so the `host_` prefix is stripped. Conservative:
479+ /// malformed / truncated bytes yield an empty Vec, never a panic.
480+ pub ( crate ) fn cartridge_host_calls ( wasm : & [ u8 ] ) -> Vec < String > {
481+ fn leb ( b : & [ u8 ] , i : & mut usize ) -> Option < u64 > {
482+ let ( mut result, mut shift) = ( 0u64 , 0u32 ) ;
483+ loop {
484+ let byte = * b. get ( * i) ?;
485+ * i += 1 ;
486+ result |= ( ( byte & 0x7f ) as u64 ) << shift;
487+ if byte & 0x80 == 0 {
488+ return Some ( result) ;
489+ }
490+ shift += 7 ;
491+ if shift >= 64 {
492+ return None ;
493+ }
494+ }
495+ }
496+ // Skip a wasm `limits` (flag byte, min, optional max) — table/mem imports.
497+ fn skip_limits ( b : & [ u8 ] , i : & mut usize ) -> Option < ( ) > {
498+ let flag = leb ( b, i) ?;
499+ leb ( b, i) ?; // min
500+ if flag & 0x01 != 0 {
501+ leb ( b, i) ?; // max
502+ }
503+ Some ( ( ) )
504+ }
505+ fn parse ( wasm : & [ u8 ] ) -> Option < Vec < String > > {
506+ if wasm. len ( ) < 8 || & wasm[ 0 ..4 ] != b"\0 asm" {
507+ return None ;
508+ }
509+ let mut calls: Vec < String > = Vec :: new ( ) ;
510+ let mut i = 8 ; // skip magic + version
511+ while i < wasm. len ( ) {
512+ let id = wasm[ i] ;
513+ i += 1 ;
514+ let size = leb ( wasm, & mut i) ?;
515+ let section_end = i. checked_add ( size as usize ) ?;
516+ if section_end > wasm. len ( ) {
517+ return None ;
518+ }
519+ if id == 2 {
520+ let mut j = i;
521+ let count = leb ( wasm, & mut j) ?;
522+ for _ in 0 ..count {
523+ let mod_len = leb ( wasm, & mut j) ? as usize ;
524+ let module = wasm. get ( j..) ?. get ( ..mod_len) ?;
525+ j += mod_len;
526+ let field_len = leb ( wasm, & mut j) ? as usize ;
527+ let field = wasm. get ( j..) ?. get ( ..field_len) ?;
528+ j += field_len;
529+ let kind = * wasm. get ( j) ?;
530+ j += 1 ;
531+ match kind {
532+ 0x00 => {
533+ leb ( wasm, & mut j) ?; // func: type index
534+ if let Some ( m) = module. strip_prefix ( b"host_" ) {
535+ if let ( Ok ( m) , Ok ( f) ) =
536+ ( std:: str:: from_utf8 ( m) , std:: str:: from_utf8 ( field) )
537+ {
538+ calls. push ( format ! ( "host::{m}::{f}" ) ) ;
539+ }
540+ }
541+ }
542+ 0x01 => {
543+ j += 1 ; // table: elem type
544+ skip_limits ( wasm, & mut j) ?;
545+ }
546+ 0x02 => skip_limits ( wasm, & mut j) ?, // mem
547+ 0x03 => j += 2 , // global: valtype + mut
548+ _ => return None ,
549+ }
550+ }
551+ }
552+ i = section_end;
553+ }
554+ calls. sort ( ) ;
555+ calls. dedup ( ) ;
556+ Some ( calls)
557+ }
558+ parse ( wasm) . unwrap_or_default ( )
559+ }
560+
561+ /// Parse `compile <source.rl> [out.wasm] [--out <file>] [--host-calls|--schemas]`.
562+ /// A bare 2nd positional is the out path (back-compat); `--out` is the flag form.
563+ /// `--host-calls` (telemetry #52 alias `--schemas`) dumps the cartridge's
564+ /// host-call surface. Pure/testable. Returns `(source, out, host_calls)`.
565+ pub ( crate ) fn parse_compile_args ( rest : & [ String ] ) -> Result < ( String , Option < String > , bool ) , String > {
566+ const USAGE : & str =
567+ "usage: localharness compile <source.rl> [out.wasm] [--out <file>] [--host-calls]" ;
568+ let ( mut source, mut out, mut host_calls) = ( None , None , false ) ;
569+ let mut i = 0 ;
570+ while i < rest. len ( ) {
571+ match rest[ i] . as_str ( ) {
572+ "--host-calls" | "--schemas" => host_calls = true ,
573+ "--out" => {
574+ i += 1 ;
575+ // A following flag is a missing value, not the out path.
576+ out = Some ( rest. get ( i) . filter ( |s| !s. starts_with ( "--" ) ) . ok_or ( USAGE ) ?. clone ( ) ) ;
577+ }
578+ s if s. starts_with ( "--" ) => return Err ( USAGE . to_string ( ) ) ,
579+ s if source. is_none ( ) => source = Some ( s. to_string ( ) ) ,
580+ s if out. is_none ( ) => out = Some ( s. to_string ( ) ) ,
581+ _ => return Err ( USAGE . to_string ( ) ) ,
582+ }
583+ i += 1 ;
584+ }
585+ Ok ( ( source. ok_or ( USAGE ) ?, out, host_calls) )
586+ }
587+
475588/// Compile-check a rustlite cartridge locally and report its size — NO on-chain
476589/// write. Lets an author iterate before spending a sponsored publish. With
477- /// `out_path`, also writes the compiled `.wasm` (handy for local validation).
478- pub ( crate ) fn compile_check ( source_path : & str , out_path : Option < & str > ) -> i32 {
590+ /// `out_path`, also writes the compiled `.wasm`. With `host_calls`, dumps the
591+ /// `host::<module>::<func>` platform-call surface the cartridge binds.
592+ pub ( crate ) fn compile_check ( source_path : & str , out_path : Option < & str > , host_calls : bool ) -> i32 {
479593 let src = match read_file_clean ( source_path) {
480594 Ok ( s) => s,
481595 Err ( e) => {
@@ -493,6 +607,19 @@ pub(crate) fn compile_check(source_path: &str, out_path: Option<&str>) -> i32 {
493607 }
494608 println ! ( " wrote {out}" ) ;
495609 }
610+ // Dump BEFORE the entry/cap gates: an entry-less or over-cap cartridge
611+ // is exactly the broken case an author wants to introspect (telemetry #52).
612+ if host_calls {
613+ let calls = cartridge_host_calls ( & wasm) ;
614+ if calls. is_empty ( ) {
615+ println ! ( " host-calls: none (binds no host:: platform calls)" ) ;
616+ } else {
617+ println ! ( " host-calls ({}):" , calls. len( ) ) ;
618+ for c in & calls {
619+ println ! ( " {c}" ) ;
620+ }
621+ }
622+ }
496623 if !cartridge_has_entry ( & wasm) {
497624 eprintln ! (
498625 " ✗ no `frame` or `render` export — the loader has no entry to \
@@ -511,6 +638,12 @@ pub(crate) fn compile_check(source_path: &str, out_path: Option<&str>) -> i32 {
511638 " fits the {APPSTORE_PUBLISH_CAP}-byte publish cap ({} bytes to spare)" ,
512639 APPSTORE_PUBLISH_CAP - wasm. len( )
513640 ) ;
641+ // No native wasm host exists — the host_* imports are browser closures.
642+ // Point authors at the real exec surface instead of faking a headless run.
643+ println ! (
644+ " run it live via the in-browser run_cartridge tool, or open \
645+ https://<name>.localharness.xyz after publish"
646+ ) ;
514647 0
515648 }
516649 Err ( e) => {
@@ -1125,6 +1258,79 @@ mod tests {
11251258 assert ! ( !cartridge_has_entry( b"\0 asm\x01 \0 \0 \0 \x07 \xff " ) ) ; // bogus section size
11261259 }
11271260
1261+ #[ test]
1262+ fn cartridge_host_calls_lists_bound_platform_calls ( ) {
1263+ // The dump is the cartridge's OWN host-call surface, sorted + deduped.
1264+ let wasm = localharness:: rustlite:: compile (
1265+ "fn frame(t: i32) { host::display::clear(0); host::display::present(); }" ,
1266+ )
1267+ . unwrap ( ) ;
1268+ assert_eq ! (
1269+ cartridge_host_calls( & wasm) ,
1270+ vec![
1271+ "host::display::clear" . to_string( ) ,
1272+ "host::display::present" . to_string( )
1273+ ]
1274+ ) ;
1275+ }
1276+
1277+ #[ test]
1278+ fn cartridge_host_calls_spans_modules_and_strips_host_prefix ( ) {
1279+ // A second module (net) proves the `host_`-prefix strip is per-import.
1280+ let wasm = localharness:: rustlite:: compile (
1281+ "fn frame(t: i32) { host::display::present(); let h: i32 = host::net::open(0); }" ,
1282+ )
1283+ . unwrap ( ) ;
1284+ let calls = cartridge_host_calls ( & wasm) ;
1285+ assert ! ( calls. contains( & "host::net::open" . to_string( ) ) , "got: {calls:?}" ) ;
1286+ assert ! ( calls. contains( & "host::display::present" . to_string( ) ) , "got: {calls:?}" ) ;
1287+ }
1288+
1289+ #[ test]
1290+ fn cartridge_host_calls_dedups_repeated_binds ( ) {
1291+ // The same call in two branches yields a single import → one entry.
1292+ let wasm = localharness:: rustlite:: compile (
1293+ "fn frame(t: i32) { if t > 0 { host::display::present(); } else { host::display::present(); } }" ,
1294+ )
1295+ . unwrap ( ) ;
1296+ assert_eq ! ( cartridge_host_calls( & wasm) , vec![ "host::display::present" . to_string( ) ] ) ;
1297+ }
1298+
1299+ #[ test]
1300+ fn cartridge_host_calls_robust_to_garbage ( ) {
1301+ // Malformed / truncated bytes never panic and report no calls.
1302+ assert ! ( cartridge_host_calls( b"" ) . is_empty( ) ) ;
1303+ assert ! ( cartridge_host_calls( b"\0 asm" ) . is_empty( ) ) ; // header only
1304+ assert ! ( cartridge_host_calls( b"\0 asm\x01 \0 \0 \0 \x02 \xff " ) . is_empty( ) ) ; // bogus section size
1305+ }
1306+
1307+ #[ test]
1308+ fn parse_compile_args_flags_and_aliases ( ) {
1309+ // `--host-calls` and its `--schemas` alias resolve to the same bool.
1310+ let hc = parse_compile_args ( & args_of ( & [ "app.rl" , "--host-calls" ] ) ) . unwrap ( ) ;
1311+ let sc = parse_compile_args ( & args_of ( & [ "app.rl" , "--schemas" ] ) ) . unwrap ( ) ;
1312+ assert_eq ! ( hc, ( "app.rl" . to_string( ) , None , true ) ) ;
1313+ assert_eq ! ( sc, ( "app.rl" . to_string( ) , None , true ) ) ;
1314+ // `--out <path>` resolves the out path; a bare 2nd positional still works.
1315+ assert_eq ! (
1316+ parse_compile_args( & args_of( & [ "app.rl" , "--out" , "o.wasm" ] ) ) . unwrap( ) ,
1317+ ( "app.rl" . to_string( ) , Some ( "o.wasm" . to_string( ) ) , false )
1318+ ) ;
1319+ assert_eq ! (
1320+ parse_compile_args( & args_of( & [ "app.rl" , "o.wasm" ] ) ) . unwrap( ) ,
1321+ ( "app.rl" . to_string( ) , Some ( "o.wasm" . to_string( ) ) , false )
1322+ ) ;
1323+ // Missing source or a dangling `--out` is an error, not a panic.
1324+ assert ! ( parse_compile_args( & args_of( & [ "--host-calls" ] ) ) . is_err( ) ) ;
1325+ assert ! ( parse_compile_args( & args_of( & [ "app.rl" , "--out" ] ) ) . is_err( ) ) ;
1326+ // `--out` must not swallow a following flag as its value.
1327+ assert ! ( parse_compile_args( & args_of( & [ "app.rl" , "--out" , "--host-calls" ] ) ) . is_err( ) ) ;
1328+ }
1329+
1330+ fn args_of ( parts : & [ & str ] ) -> Vec < String > {
1331+ parts. iter ( ) . map ( |s| s. to_string ( ) ) . collect ( )
1332+ }
1333+
11281334 #[ test]
11291335 fn read_file_clean_maps_not_found_without_leaking_os_error ( ) {
11301336 // Closes on-chain QA finding #1: "os error 2" must not reach the user.
0 commit comments