@tishlang/tish-lint 3.0.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/tish-lint CHANGED
Binary file
@@ -77,3 +77,34 @@ fn scope_exports_tish_merges_as_source() {
77
77
  fn scope_plain_main_merges_as_source() {
78
78
  assert_merged_as_source(&setup(r#","main":"index.tish""#));
79
79
  }
80
+
81
+ /// The other half of the #38 split: a `@scope/pkg` that opts in via `tish.crate` (the tish-apple
82
+ /// hosts, published as `@tishlang/tish-macos` / `@tishlang/tish-ios`) routes to the NATIVE path —
83
+ /// lowered to `NativeModuleLoad`, not merged as source.
84
+ #[test]
85
+ fn scope_tish_crate_routes_native() {
86
+ let dir = setup(r#","tish":{"module":true,"crate":"test-greet","export":"greet_object"}"#);
87
+ let root = dir.path();
88
+ let modules = resolve_project(&root.join("main.tish"), Some(root)).expect("resolve");
89
+ let merged = merge_modules(modules).expect("merge");
90
+ let native_scope = merged.program.statements.iter().any(|s| {
91
+ matches!(
92
+ s,
93
+ Statement::VarDecl { init: Some(Expr::NativeModuleLoad { spec, .. }), .. }
94
+ if spec.as_ref() == "@test/greet"
95
+ )
96
+ });
97
+ assert!(
98
+ native_scope,
99
+ "@scope package with tish.crate must lower to NativeModuleLoad (native Rust-crate path)"
100
+ );
101
+ let has_greet = merged
102
+ .program
103
+ .statements
104
+ .iter()
105
+ .any(|s| matches!(s, Statement::FunDecl { name, .. } if name.as_ref() == "greet"));
106
+ assert!(
107
+ !has_greet,
108
+ "@scope package with tish.crate must NOT be merged as Tish source"
109
+ );
110
+ }
@@ -3,10 +3,11 @@
3
3
  //! Provides workspace discovery, path resolution, and Cargo build orchestration
4
4
  //! used by tishlang_wasm, tishlang_cranelift, tishlang_native, and the tish CLI.
5
5
 
6
+ use std::collections::BTreeSet;
6
7
  use std::fs;
7
8
  use std::path::{Path, PathBuf};
8
9
  use std::process::Command;
9
- use std::sync::Mutex;
10
+ use std::sync::{Mutex, OnceLock};
10
11
 
11
12
  /// Serialize nested `cargo build` calls that share the workspace `target/` dir (tests + `tish build`).
12
13
  static NESTED_CARGO_MUTEX: Mutex<()> = Mutex::new(());
@@ -19,6 +20,56 @@ fn mold_available() -> bool {
19
20
  .unwrap_or(false)
20
21
  }
21
22
 
23
+ /// The `target_feature="..."` values in `rustc --print cfg` output.
24
+ fn parse_target_features(print_cfg_stdout: &str) -> BTreeSet<String> {
25
+ print_cfg_stdout
26
+ .lines()
27
+ .filter_map(|l| l.trim().strip_prefix("target_feature=\""))
28
+ .filter_map(|l| l.strip_suffix('"'))
29
+ .map(str::to_owned)
30
+ .collect()
31
+ }
32
+
33
+ /// Host target features rustc reports, optionally under one extra `-C` flag.
34
+ fn rustc_target_features(codegen_flag: Option<&str>) -> Option<BTreeSet<String>> {
35
+ let rustc = std::env::var("RUSTC").unwrap_or_else(|_| "rustc".to_string());
36
+ let mut cmd = Command::new(rustc);
37
+ cmd.args(["--print", "cfg"]);
38
+ if let Some(flag) = codegen_flag {
39
+ cmd.args(["-C", flag]);
40
+ }
41
+ let out = cmd.output().ok()?;
42
+ if !out.status.success() {
43
+ return None;
44
+ }
45
+ Some(parse_target_features(&String::from_utf8_lossy(&out.stdout)))
46
+ }
47
+
48
+ /// True when `-C target-cpu=native` is worth passing on this host.
49
+ ///
50
+ /// `native` asks LLVM to identify the host core. On hosts it cannot identify — notably
51
+ /// the virtualized Apple Silicon behind GitHub Actions' macOS runners — it answers
52
+ /// `generic`, which is *narrower* than the target's own default CPU. On
53
+ /// aarch64-apple-darwin that silently drops `aes` and `sha2`, and crates that assert
54
+ /// those are statically present then fail to compile at all; `ring` 0.17 trips
55
+ /// `assertion failed: CAPS_STATIC == MIN_STATIC_FEATURES` in const-eval.
56
+ ///
57
+ /// So this compares feature sets rather than trusting `native` blindly: keep the flag
58
+ /// only when it is a strict win (every default feature survives). If rustc cannot be
59
+ /// queried, skip the flag — the target default is always correct, just less tuned.
60
+ fn native_target_cpu_is_safe() -> bool {
61
+ static SAFE: OnceLock<bool> = OnceLock::new();
62
+ *SAFE.get_or_init(|| {
63
+ match (
64
+ rustc_target_features(None),
65
+ rustc_target_features(Some("target-cpu=native")),
66
+ ) {
67
+ (Some(default), Some(native)) => default.is_subset(&native),
68
+ _ => false,
69
+ }
70
+ })
71
+ }
72
+
22
73
  /// True if `root` looks like the Tish language repo (has `crates/tish_runtime`).
23
74
  ///
24
75
  /// Used so we do not treat unrelated workspaces (e.g. a parent `zectre-platform` repo) as Tish
@@ -347,7 +398,13 @@ pub fn cargo_target_name(stem: &str) -> String {
347
398
  // A leading digit is prefixed with `_`; an empty result falls back to a fixed default.
348
399
  let mut name: String = stem
349
400
  .chars()
350
- .map(|c| if c.is_ascii_alphanumeric() || c == '_' { c } else { '_' })
401
+ .map(|c| {
402
+ if c.is_ascii_alphanumeric() || c == '_' {
403
+ c
404
+ } else {
405
+ '_'
406
+ }
407
+ })
351
408
  .collect();
352
409
  if name.is_empty() {
353
410
  return "tish_output".to_string();
@@ -371,6 +428,10 @@ pub fn create_build_dir(prefix: &str, out_name: &str) -> Result<PathBuf, String>
371
428
  }
372
429
 
373
430
  /// Run cargo build in the given directory.
431
+ ///
432
+ /// `-C target-cpu=native` is added only for host builds, only when the caller has not
433
+ /// already pinned a `target-cpu` in `RUSTFLAGS`, and only when `native` does not lose
434
+ /// target features against the target default (see `native_target_cpu_is_safe`).
374
435
  /// If `cross_target` is Some, passes `--target` and skips `-C target-cpu=native`.
375
436
  pub fn run_cargo_build(
376
437
  build_dir: &Path,
@@ -395,10 +456,14 @@ pub fn run_cargo_build(
395
456
  merged_rustflags = format!("{} -C link-arg=-fuse-ld=mold", merged_rustflags.trim());
396
457
  merged_rustflags = merged_rustflags.trim().to_string();
397
458
  }
398
- } else if merged_rustflags.is_empty() {
399
- merged_rustflags = "-C target-cpu=native".to_string();
400
- } else if !merged_rustflags.contains("target-cpu") {
401
- merged_rustflags = format!("{} -C target-cpu=native", merged_rustflags);
459
+ } else if !merged_rustflags.contains("target-cpu") && native_target_cpu_is_safe() {
460
+ // Caller did not pin a CPU and `native` resolves to something at least as
461
+ // capable as the target default — see `native_target_cpu_is_safe`.
462
+ merged_rustflags = if merged_rustflags.is_empty() {
463
+ "-C target-cpu=native".to_string()
464
+ } else {
465
+ format!("{} -C target-cpu=native", merged_rustflags)
466
+ };
402
467
  }
403
468
 
404
469
  let mut cmd = Command::new("cargo");
@@ -438,6 +503,66 @@ pub fn run_cargo_build(
438
503
  mod protoc_tests {
439
504
  use super::*;
440
505
 
506
+ /// Trimmed `rustc --print cfg --target aarch64-apple-darwin`.
507
+ const APPLE_DARWIN_DEFAULT_CFG: &str = r#"
508
+ debug_assertions
509
+ panic="unwind"
510
+ target_arch="aarch64"
511
+ target_feature="aes"
512
+ target_feature="neon"
513
+ target_feature="sha2"
514
+ target_feature="sha3"
515
+ target_os="macos"
516
+ "#;
517
+
518
+ #[test]
519
+ fn parses_target_features_from_print_cfg() {
520
+ let features = parse_target_features(APPLE_DARWIN_DEFAULT_CFG);
521
+ assert_eq!(
522
+ features.iter().map(String::as_str).collect::<Vec<_>>(),
523
+ ["aes", "neon", "sha2", "sha3"]
524
+ );
525
+ // Non-`target_feature` keys never leak in, and neither do the quotes.
526
+ assert!(!features.contains("aarch64"));
527
+ assert!(!features.contains("\"aes\""));
528
+ }
529
+
530
+ #[test]
531
+ fn native_cpu_rejected_when_it_drops_target_features() {
532
+ // What GitHub's virtualized Apple Silicon reports: `native` -> `generic`, which
533
+ // keeps only `neon`. Pinning it would break `ring`'s static feature assertions.
534
+ let default = parse_target_features(APPLE_DARWIN_DEFAULT_CFG);
535
+ let generic = parse_target_features("target_feature=\"neon\"\n");
536
+ assert!(!default.is_subset(&generic));
537
+ }
538
+
539
+ #[test]
540
+ fn native_cpu_accepted_when_it_only_adds() {
541
+ let default = parse_target_features(APPLE_DARWIN_DEFAULT_CFG);
542
+ let apple_m1 = parse_target_features(
543
+ "target_feature=\"aes\"\ntarget_feature=\"neon\"\n\
544
+ target_feature=\"sha2\"\ntarget_feature=\"sha3\"\ntarget_feature=\"lse\"\n",
545
+ );
546
+ assert!(default.is_subset(&apple_m1));
547
+ // Identical sets count as safe too — `native` matching the default is a no-op.
548
+ assert!(default.is_subset(&default));
549
+ }
550
+
551
+ #[test]
552
+ fn live_rustc_probe_still_parses() {
553
+ // Guards the parse against a `rustc --print cfg` format change: every target Tish
554
+ // builds on has at least one baseline target feature (x86_64 sse2, aarch64 neon).
555
+ // If this ever comes back empty the probe would silently disable target-cpu=native
556
+ // everywhere rather than only on hosts LLVM cannot identify.
557
+ let Some(features) = rustc_target_features(None) else {
558
+ return; // no rustc on PATH — nothing to assert
559
+ };
560
+ assert!(
561
+ !features.is_empty(),
562
+ "no target_feature in rustc --print cfg"
563
+ );
564
+ }
565
+
441
566
  #[test]
442
567
  fn cargo_target_name_replaces_hyphens() {
443
568
  assert_eq!(cargo_target_name("hello-ios"), "hello_ios");
@@ -1018,6 +1018,9 @@ pub(crate) struct Codegen {
1018
1018
  features: std::collections::HashSet<String>,
1019
1019
  /// spec -> native init strategy (legacy adapter object vs generated `generated_native` wrapper)
1020
1020
  native_module_init: std::collections::HashMap<String, crate::resolve::NativeModuleInit>,
1021
+ /// spec -> the `run()`-local holding that module's namespace object, bound ONCE in the preamble.
1022
+ /// Building it is O(exports) — see `emit_native_namespace_preamble`.
1023
+ native_ns_locals: std::collections::HashMap<String, String>,
1021
1024
  /// Typed native imports (`declare fun` + a native import): tish name -> its typed signature.
1022
1025
  /// A matching call emits a DIRECT `<crate>::name_typed(..)` instead of a boxed `value_call`.
1023
1026
  extern_fns: std::collections::HashMap<String, ExternSig>,
@@ -1292,6 +1295,7 @@ impl Codegen {
1292
1295
  string_charset: String::new(),
1293
1296
  features,
1294
1297
  native_module_init,
1298
+ native_ns_locals: std::collections::HashMap::new(),
1295
1299
  extern_fns: std::collections::HashMap::new(),
1296
1300
  user_fn_returns: std::collections::HashMap::new(),
1297
1301
  async_context_stack: Vec::new(),
@@ -2044,6 +2048,48 @@ impl Codegen {
2044
2048
  }
2045
2049
  }
2046
2050
 
2051
+ /// The Rust expression that CONSTRUCTS a native module's namespace object. O(exports): it inserts
2052
+ /// every function the crate exports into a fresh map. Call it once per module, not once per import.
2053
+ fn native_module_ns_ctor(init: &crate::resolve::NativeModuleInit) -> String {
2054
+ match init {
2055
+ crate::resolve::NativeModuleInit::Legacy {
2056
+ crate_name,
2057
+ export_fn,
2058
+ } => format!("{}::{}()", crate_name, export_fn),
2059
+ crate::resolve::NativeModuleInit::Generated { export_fn, .. } => {
2060
+ format!("crate::generated_native::{}()", export_fn)
2061
+ }
2062
+ }
2063
+ }
2064
+
2065
+ /// Bind every native module's namespace object to a `run()`-local, ONCE, before any import reads
2066
+ /// from one.
2067
+ ///
2068
+ /// Each named import lowers to "get this key out of the module's namespace". Building that
2069
+ /// namespace is O(exports) — a fresh `ObjectMap` with an `Arc<str>` key and a native closure per
2070
+ /// exported function — so doing it at every import site is O(imports x exports): importing 68
2071
+ /// names from a crate that exports 68 cost 4,624 allocations to bind 68 functions. On a GBA that
2072
+ /// was measured at 3 seconds of black screen (`tish-gba/examples/bench-boot`), paid before the
2073
+ /// program's first statement; `packages/engine.tish` there imports 117 symbols across two crates
2074
+ /// and cost 4.3s. Hoisting makes it O(imports + exports).
2075
+ ///
2076
+ /// Emitted in sorted order so the generated Rust is byte-identical across builds (the map is a
2077
+ /// `HashMap`, whose iteration order is not).
2078
+ fn emit_native_namespace_preamble(&mut self) {
2079
+ let mut specs: Vec<String> = self.native_module_init.keys().cloned().collect();
2080
+ specs.sort();
2081
+ for (i, spec) in specs.iter().enumerate() {
2082
+ // Index rather than a sanitized spec: `cargo:a-b` and `cargo:a_b` sanitize to the same
2083
+ // identifier, and silently binding one module's namespace under another's name would be a
2084
+ // very quiet bug.
2085
+ let local = format!("__tish_native_ns_{}", i);
2086
+ let ctor = Self::native_module_ns_ctor(&self.native_module_init[spec]);
2087
+ // A module can be resolved but have every one of its imports shadowed or unused.
2088
+ self.writeln(&format!("#[allow(unused_variables)] let {} = {};", local, ctor));
2089
+ self.native_ns_locals.insert(spec.clone(), local);
2090
+ }
2091
+ }
2092
+
2047
2093
  /// Map native module spec to Rust init expression using resolved package.json modules.
2048
2094
  /// For built-in modules (tish:fs, tish:http, tish:process), use builtin_native_module_rust_init.
2049
2095
  fn native_module_rust_init(&self, spec: &str, export_name: &str) -> Option<String> {
@@ -2053,19 +2099,18 @@ impl Codegen {
2053
2099
  self.native_module_init.get(spec).map(|init| {
2054
2100
  // Native modules return a namespace object (like an ES module).
2055
2101
  // Named imports extract the field from that namespace: `import { foo } from "pkg"` → `ns.foo`.
2056
- let init_expr = match init {
2057
- crate::resolve::NativeModuleInit::Legacy {
2058
- crate_name,
2059
- export_fn,
2060
- } => format!("{}::{}()", crate_name, export_fn),
2061
- crate::resolve::NativeModuleInit::Generated { export_fn, .. } => {
2062
- format!("crate::generated_native::{}()", export_fn)
2063
- }
2064
- };
2065
- format!(
2066
- "{{ let _ns = {}; match _ns {{ Value::Object(ref _o) => _o.borrow().strings.get({:?}).cloned().unwrap_or(Value::Null), _ => Value::Null }} }}",
2067
- init_expr, export_name
2068
- )
2102
+ // Read it from the hoisted local when there is one; the inline constructor is the fallback
2103
+ // for anything emitted outside `run()`'s preamble.
2104
+ match self.native_ns_locals.get(spec) {
2105
+ Some(local) => format!(
2106
+ "{{ match &{} {{ Value::Object(ref _o) => _o.borrow().strings.get({:?}).cloned().unwrap_or(Value::Null), _ => Value::Null }} }}",
2107
+ local, export_name
2108
+ ),
2109
+ None => format!(
2110
+ "{{ let _ns = {}; match _ns {{ Value::Object(ref _o) => _o.borrow().strings.get({:?}).cloned().unwrap_or(Value::Null), _ => Value::Null }} }}",
2111
+ Self::native_module_ns_ctor(init), export_name
2112
+ ),
2113
+ }
2069
2114
  })
2070
2115
  }
2071
2116
 
@@ -2098,6 +2143,7 @@ impl Codegen {
2098
2143
  "realpath" | "realpathSync" => Some("Value::native(|args: &[Value]| tishlang_runtime::fs_ext::realpath(args))"),
2099
2144
  "readlink" | "readlinkSync" => Some("Value::native(|args: &[Value]| tishlang_runtime::fs_ext::readlink(args))"),
2100
2145
  "truncate" | "truncateSync" => Some("Value::native(|args: &[Value]| tishlang_runtime::fs_ext::truncate(args))"),
2146
+ "chmod" | "chmodSync" => Some("Value::native(|args: &[Value]| tishlang_runtime::fs_ext::chmod(args))"),
2101
2147
  "mkdtemp" | "mkdtempSync" => Some("Value::native(|args: &[Value]| tishlang_runtime::fs_ext::mkdtemp(args))"),
2102
2148
  "cp" | "cpSync" => Some("Value::native(|args: &[Value]| tishlang_runtime::fs_ext::cp(args))"),
2103
2149
  "access" | "accessSync" => Some("Value::native(|args: &[Value]| tishlang_runtime::fs_ext::access(args))"),
@@ -2121,6 +2167,7 @@ impl Codegen {
2121
2167
  "realpath" => Some("Value::native(|args: &[Value]| tishlang_runtime::fs_ext::realpath_promise(args))"),
2122
2168
  "readlink" => Some("Value::native(|args: &[Value]| tishlang_runtime::fs_ext::readlink_promise(args))"),
2123
2169
  "truncate" => Some("Value::native(|args: &[Value]| tishlang_runtime::fs_ext::truncate_promise(args))"),
2170
+ "chmod" => Some("Value::native(|args: &[Value]| tishlang_runtime::fs_ext::chmod_promise(args))"),
2124
2171
  "mkdtemp" => Some("Value::native(|args: &[Value]| tishlang_runtime::fs_ext::mkdtemp_promise(args))"),
2125
2172
  "cp" => Some("Value::native(|args: &[Value]| tishlang_runtime::fs_ext::cp_promise(args))"),
2126
2173
  "access" => Some("Value::native(|args: &[Value]| tishlang_runtime::fs_ext::access_promise(args))"),
@@ -2155,9 +2202,20 @@ impl Codegen {
2155
2202
  "execFile" => Some("Value::native(|args: &[Value]| tish_process_exec_file(args))"),
2156
2203
  "execCapture" => Some("Value::native(|args: &[Value]| tish_process_exec_capture(args))"),
2157
2204
  "execFileCapture" => Some("Value::native(|args: &[Value]| tish_process_exec_file_capture(args))"),
2205
+ "spawn" => Some("Value::native(|args: &[Value]| tishlang_runtime::process_spawn(args))"),
2206
+ "readStdout" => Some("Value::native(|args: &[Value]| tishlang_runtime::process_read_stdout(args))"),
2207
+ "readStderr" => Some("Value::native(|args: &[Value]| tishlang_runtime::process_read_stderr(args))"),
2208
+ "writeStdin" => Some("Value::native(|args: &[Value]| tishlang_runtime::process_write_stdin(args))"),
2209
+ "closeStdin" => Some("Value::native(|args: &[Value]| tishlang_runtime::process_close_stdin(args))"),
2210
+ "wait" => Some("Value::native(|args: &[Value]| tishlang_runtime::process_wait(args))"),
2211
+ "kill" => Some("Value::native(|args: &[Value]| tishlang_runtime::process_kill(args))"),
2212
+ "pid" => Some("Value::native(|args: &[Value]| tishlang_runtime::process_pid(args))"),
2158
2213
  "argv" => Some("Value::Array(VmRef::new(std::env::args().map(|s| Value::String(s.into())).collect()))"),
2159
2214
  "env" => Some("Value::object(std::env::vars().map(|(k,v)| (Arc::from(k.as_str()), Value::String(v.into()))).collect())"),
2160
- "process" => Some("{ let mut m = ObjectMap::default(); m.insert(Arc::from(\"exit\"), Value::native(|args: &[Value]| tish_process_exit(args))); m.insert(Arc::from(\"cwd\"), Value::native(|args: &[Value]| tish_process_cwd(args))); m.insert(Arc::from(\"exec\"), Value::native(|args: &[Value]| tish_process_exec(args))); m.insert(Arc::from(\"execFile\"), Value::native(|args: &[Value]| tish_process_exec_file(args))); m.insert(Arc::from(\"execCapture\"), Value::native(|args: &[Value]| tish_process_exec_capture(args))); m.insert(Arc::from(\"execFileCapture\"), Value::native(|args: &[Value]| tish_process_exec_file_capture(args))); m.insert(Arc::from(\"argv\"), Value::Array(VmRef::new(std::env::args().map(|s| Value::String(s.into())).collect()))); m.insert(Arc::from(\"env\"), Value::object(std::env::vars().map(|(k,v)| (Arc::from(k.as_str()), Value::String(v.into()))).collect::<ObjectMap>())); Value::object(m) }"),
2215
+ // Node-style platform string so `process.platform` reads identically on the JS
2216
+ // target (Node's real global) and native (this): macos->darwin, windows->win32.
2217
+ "platform" => Some("Value::String(match std::env::consts::OS { \"macos\" => \"darwin\", \"windows\" => \"win32\", o => o }.into())"),
2218
+ "process" => Some("{ let mut m = ObjectMap::default(); m.insert(Arc::from(\"exit\"), Value::native(|args: &[Value]| tish_process_exit(args))); m.insert(Arc::from(\"cwd\"), Value::native(|args: &[Value]| tish_process_cwd(args))); m.insert(Arc::from(\"exec\"), Value::native(|args: &[Value]| tish_process_exec(args))); m.insert(Arc::from(\"execFile\"), Value::native(|args: &[Value]| tish_process_exec_file(args))); m.insert(Arc::from(\"execCapture\"), Value::native(|args: &[Value]| tish_process_exec_capture(args))); m.insert(Arc::from(\"execFileCapture\"), Value::native(|args: &[Value]| tish_process_exec_file_capture(args))); m.insert(Arc::from(\"argv\"), Value::Array(VmRef::new(std::env::args().map(|s| Value::String(s.into())).collect()))); m.insert(Arc::from(\"env\"), Value::object(std::env::vars().map(|(k,v)| (Arc::from(k.as_str()), Value::String(v.into()))).collect::<ObjectMap>())); m.insert(Arc::from(\"platform\"), Value::String(match std::env::consts::OS { \"macos\" => \"darwin\", \"windows\" => \"win32\", o => o }.into())); Value::object(m) }"),
2161
2219
  _ => None,
2162
2220
  },
2163
2221
  "tish:ws" if self.has_feature("ws") => match export_name {
@@ -2187,6 +2245,37 @@ impl Codegen {
2187
2245
  "pid" => Some("Value::native(|args: &[Value]| tishlang_runtime::pty_pid(args))"),
2188
2246
  _ => None,
2189
2247
  },
2248
+ "tish:net" if self.has_feature("net") => match export_name {
2249
+ "connect" => Some("Value::native(|args: &[Value]| tishlang_runtime::net_connect(args))"),
2250
+ "read" => Some("Value::native(|args: &[Value]| tishlang_runtime::net_read(args))"),
2251
+ "write" => Some("Value::native(|args: &[Value]| tishlang_runtime::net_write(args))"),
2252
+ "close" => Some("Value::native(|args: &[Value]| tishlang_runtime::net_close(args))"),
2253
+ "listen" => Some("Value::native(|args: &[Value]| tishlang_runtime::net_listen(args))"),
2254
+ "accept" => Some("Value::native(|args: &[Value]| tishlang_runtime::net_accept(args))"),
2255
+ "probe" => Some("Value::native(|args: &[Value]| tishlang_runtime::net_probe(args))"),
2256
+ "sleep" => Some("Value::native(|args: &[Value]| tishlang_runtime::net_sleep(args))"),
2257
+ _ => None,
2258
+ },
2259
+ "tish:encoding" if self.has_feature("encoding") => match export_name {
2260
+ "base64Encode" => Some("Value::native(|args: &[Value]| tishlang_runtime::base64_encode(args))"),
2261
+ "base64Decode" => Some("Value::native(|args: &[Value]| tishlang_runtime::base64_decode(args))"),
2262
+ "base64UrlEncode" => Some("Value::native(|args: &[Value]| tishlang_runtime::base64_url_encode(args))"),
2263
+ "base64UrlDecode" => Some("Value::native(|args: &[Value]| tishlang_runtime::base64_url_decode(args))"),
2264
+ "utf8Decode" => Some("Value::native(|args: &[Value]| tishlang_runtime::utf8_decode(args))"),
2265
+ "utf8Encode" => Some("Value::native(|args: &[Value]| tishlang_runtime::utf8_encode(args))"),
2266
+ _ => None,
2267
+ },
2268
+ "tish:crypto" if self.has_feature("crypto") => match export_name {
2269
+ "sha256" => Some("Value::native(|args: &[Value]| tishlang_runtime::sha256(args))"),
2270
+ "sha256Hex" => Some("Value::native(|args: &[Value]| tishlang_runtime::sha256_hex(args))"),
2271
+ "randomBytes" => Some("Value::native(|args: &[Value]| tishlang_runtime::random_bytes(args))"),
2272
+ _ => None,
2273
+ },
2274
+ "tish:zip" if self.has_feature("zip") => match export_name {
2275
+ "extract" => Some("Value::native(|args: &[Value]| tishlang_runtime::zip_extract(args))"),
2276
+ "entries" => Some("Value::native(|args: &[Value]| tishlang_runtime::zip_entries(args))"),
2277
+ _ => None,
2278
+ },
2190
2279
  _ => return None,
2191
2280
  };
2192
2281
  init.map(String::from)
@@ -2196,7 +2285,8 @@ impl Codegen {
2196
2285
  if self.features.contains("full") {
2197
2286
  matches!(
2198
2287
  name,
2199
- "http" | "timers" | "fs" | "process" | "regex" | "ws" | "tty" | "pty"
2288
+ "http" | "timers" | "fs" | "process" | "regex" | "ws" | "tty" | "pty" | "net"
2289
+ | "encoding" | "crypto" | "zip"
2200
2290
  )
2201
2291
  } else {
2202
2292
  self.features.contains(name)
@@ -3088,6 +3178,9 @@ impl Codegen {
3088
3178
  }
3089
3179
  self.indent += 1;
3090
3180
 
3181
+ // Before anything else: one namespace object per native module, shared by all of its imports.
3182
+ self.emit_native_namespace_preamble();
3183
+
3091
3184
  // Initialize builtins
3092
3185
  self.writeln("let mut console = Value::object(ObjectMap::from([");
3093
3186
  self.indent += 1;
@@ -3331,6 +3424,9 @@ impl Codegen {
3331
3424
  self.indent -= 1;
3332
3425
  self.writeln("}");
3333
3426
  self.writeln("p.insert(Arc::from(\"env\"), Value::object(env_obj));");
3427
+ // Node-style platform string (macos->darwin, windows->win32) so bare `process.platform`
3428
+ // matches the JS target's Node global and the named `tish:process` export.
3429
+ self.writeln("p.insert(Arc::from(\"platform\"), Value::String(match std::env::consts::OS { \"macos\" => \"darwin\", \"windows\" => \"win32\", o => o }.into()));");
3334
3430
  self.writeln("p");
3335
3431
  self.indent -= 1;
3336
3432
  self.writeln("});");
@@ -3612,9 +3708,17 @@ impl Codegen {
3612
3708
  let escaped = Self::escape_ident(name.as_ref());
3613
3709
  return Ok(format!("{} = ({}) as i32", escaped, int_code));
3614
3710
  }
3711
+ let (val_code, val_ty) = self.emit_typed_expr(value)?;
3712
+ // An RHS that is ALREADY an integer stores straight into the register. `d = d + 1`
3713
+ // reaches here — it is arithmetic, not a bitwise chain, so `emit_int32_operand`
3714
+ // declines it — and boxing that i32 into a `Value::Number` only to run ToInt32 back
3715
+ // out of it was the whole cost of incrementing an annotated counter.
3716
+ let escaped_int = Self::escape_ident(name.as_ref());
3717
+ if val_ty.is_integer_scalar() {
3718
+ return Ok(format!("{} = ({}) as i32", escaped_int, val_code));
3719
+ }
3615
3720
  // Defensive: gate guarantees `Some`, but if a future RHS shape slips through,
3616
3721
  // fall back to a sound f64-narrowed store rather than miscompiling.
3617
- let (val_code, val_ty) = self.emit_typed_expr(value)?;
3618
3722
  let v = if val_ty.is_native() {
3619
3723
  val_ty.to_value_expr(&val_code)
3620
3724
  } else {
@@ -14083,6 +14187,22 @@ impl Codegen {
14083
14187
  /// native `Num<i32,8>` arithmetic instead of boxing the literal side through `f64`. Uses the
14084
14188
  /// same truncating `(n * 256) as i32` conversion as the dynamic `fixed`↔`Value` boundary, so
14085
14189
  /// the constant is bit-identical to the boxed path.
14190
+ /// A numeric literal that is EXACTLY a whole number inside `i32`'s range, as an `i32`. Used to keep
14191
+ /// `x > 0` / `i < 100` in integer registers when the other side is already an integer — a fraction
14192
+ /// or an out-of-range magnitude returns `None` and stays on the `f64` path, where it is correct.
14193
+ fn int_literal_of(e: &Expr) -> Option<i32> {
14194
+ if let Expr::Literal {
14195
+ value: Literal::Number(n),
14196
+ ..
14197
+ } = e
14198
+ {
14199
+ if n.fract() == 0.0 && *n >= i32::MIN as f64 && *n <= i32::MAX as f64 {
14200
+ return Some(*n as i32);
14201
+ }
14202
+ }
14203
+ None
14204
+ }
14205
+
14086
14206
  fn fixed_literal_of(e: &Expr) -> Option<String> {
14087
14207
  if let Expr::Literal {
14088
14208
  value: Literal::Number(n),
@@ -23161,6 +23281,83 @@ impl Codegen {
23161
23281
  // A narrow int storage read (`u8` HP, `i16` tile x) promotes the same way — read
23162
23282
  // as its exact `f64` value so arithmetic keeps JS Number semantics; the truncating
23163
23283
  // store back to the field happens at the assignment site.
23284
+ // …but when BOTH sides are already integers, that detour is pure loss on a CPU with no
23285
+ // FPU. On ARM7TDMI every f64 op is a soft-float library CALL, so `i + 1` on a `: i32`
23286
+ // counter costs two int→float conversions, a call, and a float→int store. It is why
23287
+ // annotating a loop counter `: i32` measured SLOWER than leaving it a plain number
23288
+ // (examples/bench-ai) — the opposite of what an annotation is for.
23289
+ //
23290
+ // Keeping the pair in integer registers is not an approximation: every i32 is exact in
23291
+ // f64, so `+`, `-` and the relational operators give bit-identical answers in either
23292
+ // domain. Only the operators where the two domains genuinely DISAGREE stay on floats:
23293
+ // `/` JS division is real division — `5 / 2` is 2.5, not 2
23294
+ // `%` integer `%` panics on a zero divisor where JS yields NaN
23295
+ // `*` a product can leave i32's range while staying exact in f64, and the bitwise
23296
+ // lowering below deliberately relies on that headroom (`h * 16777619`)
23297
+ // Operands widen to `i32` first so mixed widths (a `u8` HP against an `i16` tile
23298
+ // coordinate) still agree with JS Number semantics. `u32` is excluded because it is the
23299
+ // one width that does not fit — reading 3 billion `as i32` would flip its sign.
23300
+ // The adds wrap rather than panic, which matches both JS (`to_int32` is modulo 2³²) and
23301
+ // the truncating `as` store every integer target already performs.
23302
+ let fits_i32 = |t: &RustType| {
23303
+ matches!(
23304
+ t,
23305
+ RustType::I32
23306
+ | RustType::I8
23307
+ | RustType::U8
23308
+ | RustType::I16
23309
+ | RustType::U16
23310
+ )
23311
+ };
23312
+ // An integer against a whole-number LITERAL is the shape most of this actually is —
23313
+ // `x > 0`, `i < 100`, `0 - dir`. The literal types as `f64`, which would drag the
23314
+ // integer side out of its register for a soft-float compare over two values that are
23315
+ // both plainly integers. Fold it into the integer domain instead. Only exact whole
23316
+ // numbers inside i32's range qualify, so `x < 2.5` and `x < 3e9` still go to floats,
23317
+ // where they belong.
23318
+ let (l, lt, r, rt) = if fits_i32(&lt) && rt == RustType::F64 {
23319
+ match Self::int_literal_of(right) {
23320
+ Some(v) => (l, lt, format!("{}i32", v), RustType::I32),
23321
+ None => (l, lt, r, rt),
23322
+ }
23323
+ } else if fits_i32(&rt) && lt == RustType::F64 {
23324
+ match Self::int_literal_of(left) {
23325
+ Some(v) => (format!("{}i32", v), RustType::I32, r, rt),
23326
+ None => (l, lt, r, rt),
23327
+ }
23328
+ } else {
23329
+ (l, lt, r, rt)
23330
+ };
23331
+ if fits_i32(&lt) && fits_i32(&rt) {
23332
+ let li = format!("(({}) as i32)", l);
23333
+ let ri = format!("(({}) as i32)", r);
23334
+ match op {
23335
+ BinOp::Add => {
23336
+ return Ok((
23337
+ format!("({}).wrapping_add({})", li, ri),
23338
+ RustType::I32,
23339
+ ))
23340
+ }
23341
+ BinOp::Sub => {
23342
+ return Ok((
23343
+ format!("({}).wrapping_sub({})", li, ri),
23344
+ RustType::I32,
23345
+ ))
23346
+ }
23347
+ BinOp::Lt => return Ok((format!("({} < {})", li, ri), RustType::Bool)),
23348
+ BinOp::Le => return Ok((format!("({} <= {})", li, ri), RustType::Bool)),
23349
+ BinOp::Gt => return Ok((format!("({} > {})", li, ri), RustType::Bool)),
23350
+ BinOp::Ge => return Ok((format!("({} >= {})", li, ri), RustType::Bool)),
23351
+ BinOp::StrictEq => {
23352
+ return Ok((format!("({} == {})", li, ri), RustType::Bool))
23353
+ }
23354
+ BinOp::StrictNe => {
23355
+ return Ok((format!("({} != {})", li, ri), RustType::Bool))
23356
+ }
23357
+ _ => {}
23358
+ }
23359
+ }
23360
+
23164
23361
  let (l, lt) = if lt.is_integer_scalar() {
23165
23362
  (format!("(({}) as f64)", l), RustType::F64)
23166
23363
  } else {
@@ -77,6 +77,10 @@ const BUILTIN_ALIASES: &[(&str, &str)] = &[
77
77
  ("ws", "tish:ws"),
78
78
  ("tty", "tish:tty"),
79
79
  ("pty", "tish:pty"),
80
+ ("net", "tish:net"),
81
+ ("encoding", "tish:encoding"),
82
+ ("crypto", "tish:crypto"),
83
+ ("zip", "tish:zip"),
80
84
  ];
81
85
 
82
86
  /// Normalize built-in spec to canonical form. Handles the Node `node:` prefix
@@ -106,9 +110,14 @@ pub fn is_builtin_native_spec(spec: &str) -> bool {
106
110
  | "tish:ws"
107
111
  | "tish:tty"
108
112
  | "tish:pty"
113
+ | "tish:net"
114
+ | "tish:encoding"
115
+ | "tish:crypto"
116
+ | "tish:zip"
109
117
  ) || matches!(
110
118
  spec,
111
- "fs" | "fs/promises" | "http" | "timers" | "process" | "ws" | "tty" | "pty"
119
+ "fs" | "fs/promises" | "http" | "timers" | "process" | "ws" | "tty" | "pty" | "net"
120
+ | "encoding" | "crypto" | "zip"
112
121
  )
113
122
  }
114
123
 
@@ -1029,7 +1038,8 @@ pub fn is_native_import(spec: &str) -> bool {
1029
1038
  || spec.starts_with("ffi:")
1030
1039
  || matches!(
1031
1040
  spec,
1032
- "fs" | "fs/promises" | "http" | "timers" | "process" | "ws" | "tty"
1041
+ "fs" | "fs/promises" | "http" | "timers" | "process" | "ws" | "tty" | "net" | "encoding"
1042
+ | "crypto" | "zip"
1033
1043
  )
1034
1044
  }
1035
1045
 
@@ -12,9 +12,9 @@
12
12
  //! `PropMap` on a cache miss). Phase 1b will attach the ordered key list to each shape so objects can
13
13
  //! drop per-object key storage entirely (the butterfly representation).
14
14
 
15
- use crate::compat::{AHashMap, Arc, OnceLock, RwLock};
16
- #[cfg(feature = "portable")]
17
- use alloc::{vec, vec::Vec};
15
+ use crate::compat::Arc;
16
+ #[cfg(not(feature = "portable"))]
17
+ use crate::compat::{AHashMap, OnceLock, RwLock};
18
18
 
19
19
  /// Identity of an object's ordered key-set.
20
20
  pub type ShapeId = u32;
@@ -31,15 +31,18 @@ pub const DICT_SHAPE: ShapeId = u32::MAX;
31
31
  /// Edges are keyed by an `ahash` map, not the default SipHash one: `transition` is on the hot path of
32
32
  /// every object construction (and `JSON.parse` — profiled as ~16% of parse, almost all SipHash), and
33
33
  /// ahash is ~2–3× faster on short string keys. Random-seeded per process, so no HashDoS regression.
34
+ #[cfg(not(feature = "portable"))]
34
35
  #[derive(Default)]
35
36
  struct ShapeNode {
36
37
  transitions: AHashMap<Arc<str>, ShapeId>,
37
38
  }
38
39
 
40
+ #[cfg(not(feature = "portable"))]
39
41
  struct Registry {
40
42
  nodes: Vec<ShapeNode>,
41
43
  }
42
44
 
45
+ #[cfg(not(feature = "portable"))]
43
46
  fn registry() -> &'static RwLock<Registry> {
44
47
  static REG: OnceLock<RwLock<Registry>> = OnceLock::new();
45
48
  REG.get_or_init(|| {
@@ -56,6 +59,28 @@ fn registry() -> &'static RwLock<Registry> {
56
59
  /// object with the same construction path reuses it. Cheap on the hot path (a read-lock + one hashmap
57
60
  /// lookup once the edge exists). A `DICT_SHAPE` input (or shape-space exhaustion) stays `DICT_SHAPE`.
58
61
  pub fn transition(from: ShapeId, key: &Arc<str>) -> ShapeId {
62
+ // On `portable` (the embedded/GBA link) nothing ever READS a shape id: the only consumers are
63
+ // the bytecode VM (`tish_vm`) and the std runtime's inline caches (`tish_runtime`), and neither
64
+ // is linked there — the GBA facade's `get_prop` goes straight to `PropMap`. Maintaining the
65
+ // registry anyway cost a lock plus an ahash lookup on every new-key insert AND — since this
66
+ // file has no eviction of any kind — a permanent `ShapeNode` plus an `Arc::clone` of the key
67
+ // for every distinct construction path, for the life of the program, on a 136 KB heap.
68
+ // Opt out via the sentinel the file already defines for exactly this case: `DICT_SHAPE` never
69
+ // matches an inline cache, so any future reader degrades to the slow path rather than trusting
70
+ // a slot index that was never recorded.
71
+ #[cfg(feature = "portable")]
72
+ {
73
+ let _ = (from, key);
74
+ DICT_SHAPE
75
+ }
76
+ #[cfg(not(feature = "portable"))]
77
+ {
78
+ transition_tracked(from, key)
79
+ }
80
+ }
81
+
82
+ #[cfg(not(feature = "portable"))]
83
+ fn transition_tracked(from: ShapeId, key: &Arc<str>) -> ShapeId {
59
84
  if from == DICT_SHAPE {
60
85
  return DICT_SHAPE;
61
86
  }