@tishlang/tish-format 3.7.0 → 3.8.6

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.
@@ -117,6 +117,7 @@ fn count(): number {
117
117
  for (let i: number = 0; i < 10; i = i + 1) { total = total + i }
118
118
  return total
119
119
  }
120
+ console.log(count())
120
121
  "#;
121
122
  let program = parse(src_native).unwrap();
122
123
  let rust = compile(&program).unwrap();
@@ -124,9 +125,14 @@ fn count(): number {
124
125
  rust.contains("let mut total: f64"),
125
126
  "numeric-only reassignment keeps `total` native f64"
126
127
  );
128
+ // The boxing moved, it did not disappear. With the boxed wrapper elided for a fn every
129
+ // caller reaches directly, the f64 is wrapped at the CALL SITE
130
+ // (`Value::Number(count_native())`) instead of inside a wrapper's return. Either spelling
131
+ // satisfies what this is checking — that a native f64 crosses back to Value at the boundary
132
+ // — so accept both rather than pinning the assertion to one codegen shape.
127
133
  assert!(
128
- rust.contains("Value::Number(total)"),
129
- "f64 total is wrapped back to Value at the return boundary"
134
+ rust.contains("Value::Number(total)") || rust.contains("Value::Number(count_native())"),
135
+ "f64 total is wrapped back to Value at a boundary (wrapper return or call site)"
130
136
  );
131
137
  }
132
138
 
@@ -0,0 +1,41 @@
1
+ //! #655 — the stack-overflow guard must actually be emitted on the GBA target.
2
+ //!
3
+ //! The recursion guard was gated OFF for `NativeEmitMode::Gba` (the no_std runtime had no
4
+ //! stack-pressure probe), and the boxed guard's floor fell back to "never trips". GBA is the one
5
+ //! target where that is unrecoverable: 32 KB of IWRAM, no MMU, no guard page — an unguarded
6
+ //! overflow grows down into agb's live data (the audio mixer's buffers among them) and surfaces
7
+ //! frames later as an illegal opcode at an address that names nothing.
8
+ //!
9
+ //! `tishlang_runtime_gba` now derives a real floor from the link map (`__iwram_end`), so both the
10
+ //! typed and boxed guards are emitted here exactly as they are on the host.
11
+
12
+ use std::path::PathBuf;
13
+
14
+ use tishlang_compile::{compile_project_full_emit, NativeEmitMode};
15
+
16
+ fn gba_rust(fixture: &str) -> String {
17
+ let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(fixture);
18
+ compile_project_full_emit(&path, path.parent(), &[], true, NativeEmitMode::Gba, None)
19
+ .expect("compile for gba")
20
+ .0
21
+ }
22
+
23
+ /// A self-recursive typed fn opens with the stack check and bails through the NaN sentinel.
24
+ #[test]
25
+ fn gba_typed_recursion_emits_stack_guard() {
26
+ let rust = gba_rust("../../tests/perf/recursion_fib.tish");
27
+ let copy0 = rust
28
+ .split("fn fib_native(")
29
+ .nth(1)
30
+ .expect("fib_native must be lowered on gba")
31
+ .split("\nfn ")
32
+ .next()
33
+ .unwrap()
34
+ .to_string();
35
+ assert!(
36
+ copy0.contains("tishlang_runtime::stack_low()")
37
+ && copy0.contains("tishlang_runtime::recursion_tripped_f64()"),
38
+ "gba typed recursion must open with the stack check — without it an overflow silently \
39
+ overwrites IWRAM instead of raising a catchable RangeError:\n{copy0}"
40
+ );
41
+ }
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "tishlang_fmt"
3
- version = "3.7.0"
3
+ version = "3.8.6"
4
4
  edition = "2021"
5
5
  description = "Opinionated formatter for Tish source (parse → pretty-print)"
6
6
  license-file = { workspace = true }
@@ -350,15 +350,107 @@ fn read_facade_agb_version(facade_path: &Path) -> Option<String> {
350
350
  None
351
351
  }
352
352
 
353
- /// Build a Game Boy Advance ROM from generated `#![no_std]` Rust.
353
+ /// Cargo `[profile.*]` blocks for the nested GBA crate.
354
354
  ///
355
- /// Emits an agb-style cargo project (nightly + build-std + `thumbv4t-none-eabi` +
356
- /// `gba.ld`) that links the `tishlang_runtime_gba` facade under the `tishlang_runtime`
357
- /// name (the `package =` rename), builds it, then runs `agb-gbafix` on the ELF.
355
+ /// #581 — the default used to be `lto = "fat"` + `codegen-units = 1`, which dominates wall-clock on
356
+ /// the ~20k-line `run()` a real game generates, and every `tish build --target gba` paid it. Thin
357
+ /// LTO across 8 CGUs keeps most of the size/speed win and lets rustc parallelize; fat LTO stays one
358
+ /// env var away for a ship build.
358
359
  ///
359
- /// Runs its own `cargo build` (NOT `run_cargo_build`) so the `.cargo/config.toml`
360
- /// rustflags (`-Tgba.ld`, `-Ctarget-cpu=arm7tdmi`) apply — `run_cargo_build` sets a
361
- /// `RUSTFLAGS` env that would shadow them.
360
+ /// Env knobs (checked here, not by cargo):
361
+ /// - `TISH_FAST_NATIVE_BUILD=1` — iteration profile: no LTO, opt-level 1, many codegen-units.
362
+ /// Same flag as desktop native; use this for day-to-day GBA work.
363
+ /// - `TISH_GBA_FAT_LTO=1` — old ship profile: fat LTO + single CGU (slow; smallest/fastest ROM).
364
+ /// - default — thin LTO, opt-level 3, 8 CGUs, no debuginfo. Much faster than fat LTO while still
365
+ /// producing a solid ROM.
366
+ /// - `TISH_GBA_DEBUG=1` — keep debuginfo in the release profile (slower link; for mgba backtraces).
367
+ fn gba_cargo_profiles_toml() -> String {
368
+ let debug = if std::env::var("TISH_GBA_DEBUG").as_deref() == Ok("1") {
369
+ "true"
370
+ } else {
371
+ "false"
372
+ };
373
+ if std::env::var("TISH_FAST_NATIVE_BUILD").as_deref() == Ok("1") {
374
+ return format!(
375
+ r#"[profile.dev]
376
+ opt-level = 1
377
+ debug = {debug}
378
+
379
+ [profile.dev.build-override]
380
+ opt-level = 1
381
+
382
+ [profile.release]
383
+ opt-level = 1
384
+ lto = false
385
+ codegen-units = 16
386
+ incremental = true
387
+ debug = {debug}
388
+ panic = "abort"
389
+
390
+ [profile.release.build-override]
391
+ opt-level = 1
392
+ "#
393
+ );
394
+ }
395
+ // ⚠️ THIN LTO IS OPT-IN, AND THE REASON IS STACK, NOT SPEED. Less inlining means bigger frames,
396
+ // and a GBA has 32 KB of IWRAM for the whole stack. Measured on tish-gba's examples/ffta, same
397
+ // source, cold build dir:
398
+ //
399
+ // profile run() factory combined vs 32,512 B usable stack
400
+ // fat 23,604 + 7,452 = 31,056 fits, 1,456 B spare
401
+ // thin 25,436 + 9,068 = 34,504 OVER by 1,992 B
402
+ //
403
+ // And it does not trap. The deepest SP under thin is 0x02FFF838; the GBA mirrors EWRAM every
404
+ // 256 KB, so that aliases 0x0203F838 — the top of the heap. The stack writes into allocated
405
+ // memory and the ROM keeps running, which is far worse to diagnose than a crash.
406
+ //
407
+ // The build-time win thin was introduced for is real but example-dependent: on a small ROM
408
+ // (examples/ffta-hud) it was 70s vs 86s with a 37 KB smaller output and byte-identical frames;
409
+ // on ffta itself it was SLOWER, 148s vs 137s. So it is offered, not defaulted.
410
+ if std::env::var("TISH_GBA_THIN_LTO").as_deref() == Ok("1") {
411
+ return format!(
412
+ r#"[profile.dev]
413
+ opt-level = 3
414
+ debug = {debug}
415
+
416
+ [profile.dev.build-override]
417
+ opt-level = 3
418
+
419
+ [profile.release]
420
+ opt-level = 3
421
+ lto = "thin"
422
+ codegen-units = 8
423
+ debug = {debug}
424
+ panic = "abort"
425
+
426
+ [profile.release.build-override]
427
+ opt-level = 3
428
+ "#
429
+ );
430
+ }
431
+ // Default: fat LTO, one codegen unit — the smallest frames, which is what fits in IWRAM.
432
+ // `TISH_GBA_FAT_LTO=1` is still accepted and is now a no-op, so existing scripts keep working.
433
+ format!(
434
+ r#"[profile.dev]
435
+ opt-level = 3
436
+ debug = {debug}
437
+
438
+ [profile.dev.build-override]
439
+ opt-level = 3
440
+
441
+ [profile.release]
442
+ opt-level = 3
443
+ lto = "fat"
444
+ codegen-units = 1
445
+ debug = {debug}
446
+ panic = "abort"
447
+
448
+ [profile.release.build-override]
449
+ opt-level = 3
450
+ "#
451
+ )
452
+ }
453
+
362
454
  /// Read `version = "…"` from the `[package]` section of a crate's `Cargo.toml`.
363
455
  fn read_crate_version(crate_dir: &Path) -> Option<String> {
364
456
  let toml = fs::read_to_string(crate_dir.join("Cargo.toml")).ok()?;
@@ -475,6 +567,17 @@ tishlang_runtime = {{ path = {runtime:?}{version}{features} }}
475
567
  Ok(())
476
568
  }
477
569
 
570
+ /// Build a Game Boy Advance ROM from generated `#![no_std]` Rust.
571
+ ///
572
+ /// Emits an agb-style cargo project (nightly + build-std + `thumbv4t-none-eabi` +
573
+ /// `gba.ld`) that links the `tishlang_runtime_gba` facade under the `tishlang_runtime`
574
+ /// name (the `package =` rename), builds it, then runs `agb-gbafix` on the ELF.
575
+ ///
576
+ /// Runs its own `cargo build` (NOT `run_cargo_build`) so the `.cargo/config.toml`
577
+ /// rustflags (`-Tgba.ld`, `-Ctarget-cpu=arm7tdmi`) apply — `run_cargo_build` sets a
578
+ /// `RUSTFLAGS` env that would shadow them.
579
+ ///
580
+ /// Profiles come from [`gba_cargo_profiles_toml`].
478
581
  fn build_gba_rom(
479
582
  rust_code: &str,
480
583
  native_modules: Vec<ResolvedNativeModule>,
@@ -543,6 +646,7 @@ fn build_gba_rom(
543
646
  }
544
647
 
545
648
  let facade = facade_path.display().to_string().replace('\\', "/");
649
+ let profiles = gba_cargo_profiles_toml();
546
650
  let cargo_toml = format!(
547
651
  r#"[package]
548
652
  name = "tish_output"
@@ -561,28 +665,29 @@ path = "src/main.rs"
561
665
  tishlang_runtime = {{ package = "tishlang_runtime_gba", path = {facade:?} }}
562
666
  agb = "{agb_version}"
563
667
  {more_deps}
564
- [profile.dev]
565
- opt-level = 3
566
- debug = true
567
-
568
- [profile.dev.build-override]
569
- opt-level = 3
570
-
571
- [profile.release]
572
- opt-level = 3
573
- lto = "fat"
574
- debug = true
575
-
576
- [profile.release.build-override]
577
- opt-level = 3
668
+ {profiles}
578
669
  "#,
579
670
  );
580
671
  fs::write(build_dir.join("Cargo.toml"), cargo_toml)
581
672
  .map_err(|e| format!("Cannot write Cargo.toml: {}", e))?;
582
673
 
674
+ // Inherit the project's toolchain pin when it has one.
675
+ //
676
+ // rustup honours the `rust-toolchain.toml` NEAREST the directory cargo runs in, and this
677
+ // generated file sits in `.tish/gba/<name>/` — so it wins over the pin in the project's own
678
+ // checkout. Writing a bare `channel = "nightly"` here therefore made every GBA build float: the
679
+ // compiler could change under an unchanged checkout, and differ from CI or another machine, with
680
+ // nothing in the source to show it. Inheriting the project's channel makes the checkout the
681
+ // single source of truth; projects without a pin still get `nightly` exactly as before.
682
+ //
683
+ // Only the channel is inherited. `components` stays `rust-src` because that is what build-std
684
+ // needs — copying a project's `clippy`/`rustfmt` entries in would fail the ROM build whenever
685
+ // the pinned toolchain lacks a component the ROM never uses.
686
+ let channel =
687
+ find_project_toolchain_channel(&base).unwrap_or_else(|| "nightly".to_string());
583
688
  fs::write(
584
689
  build_dir.join("rust-toolchain.toml"),
585
- "[toolchain]\nchannel = \"nightly\"\ncomponents = [\"rust-src\"]\n",
690
+ format!("[toolchain]\nchannel = \"{}\"\ncomponents = [\"rust-src\"]\n", channel),
586
691
  )
587
692
  .map_err(|e| format!("Cannot write rust-toolchain.toml: {}", e))?;
588
693
 
@@ -694,6 +799,45 @@ runner = ["mgba-qt", "-C", "logToStdout=1", "-C", "logLevel.gba.debug=127"]
694
799
  Ok(())
695
800
  }
696
801
 
802
+ /// Find the toolchain channel the project pinned, searching `start` and its ancestors.
803
+ ///
804
+ /// Mirrors rustup's own lookup: nearest ancestor wins, and both the TOML form and the legacy
805
+ /// one-line `rust-toolchain` file are accepted. Returns `None` when no pin exists anywhere above the
806
+ /// project, which is what keeps unpinned projects on plain `nightly`.
807
+ ///
808
+ /// Deliberately a line scan rather than a TOML parse: this runs on every build, needs no new
809
+ /// dependency, and a pin is always a single `channel = "…"` line in practice. Comment lines are
810
+ /// skipped so prose mentioning a channel cannot be mistaken for the pin.
811
+ fn find_project_toolchain_channel(start: &Path) -> Option<String> {
812
+ for dir in start.ancestors() {
813
+ for name in ["rust-toolchain.toml", "rust-toolchain"] {
814
+ let Ok(text) = fs::read_to_string(dir.join(name)) else {
815
+ continue;
816
+ };
817
+ for line in text.lines() {
818
+ let line = line.trim();
819
+ if line.starts_with('#') {
820
+ continue;
821
+ }
822
+ if let Some(rest) = line.strip_prefix("channel") {
823
+ if let Some(value) = rest.trim_start().strip_prefix('=') {
824
+ let value = value.trim().trim_matches('"').trim_matches('\'');
825
+ if !value.is_empty() {
826
+ return Some(value.to_string());
827
+ }
828
+ }
829
+ }
830
+ }
831
+ // Legacy form: the entire file is the toolchain name (no table, no assignment).
832
+ let bare = text.trim();
833
+ if !bare.is_empty() && !bare.contains('[') && !bare.contains('=') {
834
+ return Some(bare.to_string());
835
+ }
836
+ }
837
+ }
838
+ None
839
+ }
840
+
697
841
  /// Remove a finished build's per-PID source directory (#384). Called only on SUCCESS, after the
698
842
  /// binary is copied out — the dir is then disposable (it holds only the generated crate source;
699
843
  /// compiled artifacts live in the shared workspace `target/`). Left in place on any earlier error so a
@@ -871,6 +1015,44 @@ tishlang_runtime = {{ path = {:?}{} }}
871
1015
  mod tests {
872
1016
  use super::runtime_features_for_cargo;
873
1017
 
1018
+ /// The generated ROM crate must inherit the project's pin, or GBA builds float against whatever
1019
+ /// nightly the machine last downloaded (see `find_project_toolchain_channel`).
1020
+ #[test]
1021
+ fn project_toolchain_channel_is_inherited_from_nearest_pin() {
1022
+ use super::find_project_toolchain_channel;
1023
+ use std::fs;
1024
+ use std::path::PathBuf;
1025
+ // Scratch under the workspace target/ (never /tmp), unique per case.
1026
+ let base: PathBuf =
1027
+ PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../target/tmp_toolchain_test");
1028
+ let nested = base.join("examples").join("game");
1029
+ let _ = fs::remove_dir_all(&base);
1030
+ fs::create_dir_all(&nested).unwrap();
1031
+
1032
+ // No pin anywhere below the workspace: caller falls back to plain `nightly`.
1033
+ assert_eq!(find_project_toolchain_channel(&nested), None);
1034
+
1035
+ // A pin several levels up is found, and prose in comments is not mistaken for it.
1036
+ fs::write(
1037
+ base.join("rust-toolchain.toml"),
1038
+ "# do not use a bare channel = \"nightly\"\n[toolchain]\nchannel = \"nightly-2026-07-22\"\ncomponents = [\"rust-src\"]\n",
1039
+ )
1040
+ .unwrap();
1041
+ assert_eq!(
1042
+ find_project_toolchain_channel(&nested).as_deref(),
1043
+ Some("nightly-2026-07-22")
1044
+ );
1045
+
1046
+ // Nearest ancestor wins, matching rustup's own lookup.
1047
+ fs::write(nested.join("rust-toolchain"), "nightly-2026-01-01\n").unwrap();
1048
+ assert_eq!(
1049
+ find_project_toolchain_channel(&nested).as_deref(),
1050
+ Some("nightly-2026-01-01")
1051
+ );
1052
+
1053
+ let _ = fs::remove_dir_all(&base);
1054
+ }
1055
+
874
1056
  /// #384: `cleanup_build_dir` removes a finished build's dir by default, and preserves it under
875
1057
  /// `TISH_KEEP_BUILD_DIR=1`.
876
1058
  #[test]
@@ -915,4 +1097,87 @@ mod tests {
915
1097
  assert_eq!(f.len(), super::RUNTIME_CARGO_FEATURES.len());
916
1098
  assert_eq!(f.iter().filter(|x| *x == "http").count(), 1);
917
1099
  }
1100
+
1101
+ // #581 — the GBA ROM profile policy. The default used to be fat LTO + 1 CGU, so every
1102
+ // `tish build --target gba` paid a full fat-LTO link over the ~20k-line `run()` a real game
1103
+ // generates. These assert the policy rather than the wall-clock (which is machine-dependent):
1104
+ // thin by default, fat still reachable, and a genuinely fast iteration profile.
1105
+ //
1106
+ // Serialized because they mutate process-wide env; `gba_cargo_profiles_toml` reads it directly.
1107
+ #[test]
1108
+ fn gba_profiles_follow_581_policy() {
1109
+ use std::sync::Mutex;
1110
+ static ENV_LOCK: Mutex<()> = Mutex::new(());
1111
+ let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1112
+
1113
+ let restore = |k: &str, v: Option<String>| match v {
1114
+ Some(v) => std::env::set_var(k, v),
1115
+ None => std::env::remove_var(k),
1116
+ };
1117
+ let keys = [
1118
+ "TISH_GBA_FAT_LTO",
1119
+ "TISH_GBA_THIN_LTO",
1120
+ "TISH_FAST_NATIVE_BUILD",
1121
+ "TISH_GBA_DEBUG",
1122
+ ];
1123
+ let saved: Vec<_> = keys.iter().map(|k| (*k, std::env::var(k).ok())).collect();
1124
+ for k in keys {
1125
+ std::env::remove_var(k);
1126
+ }
1127
+
1128
+ // ⚠️ FAT IS THE DEFAULT, AND THE CONSTRAINT IS STACK, NOT LINK TIME. #581 briefly made thin
1129
+ // the default; it costs ~3.4 KB of stack frame on a real ROM (measured on tish-gba's
1130
+ // examples/ffta: 31,056 B combined under fat vs 34,504 under thin, against 32,512 usable),
1131
+ // and the overflow does not trap — it aliases into EWRAM through the GBA's 256 KB mirror and
1132
+ // corrupts the heap silently. See docs/gba-target.md.
1133
+ let default = super::gba_cargo_profiles_toml();
1134
+ assert!(
1135
+ default.contains(r#"lto = "fat""#) && default.contains("codegen-units = 1"),
1136
+ "default ROM profile must be fat LTO + 1 CGU — smallest stack frames:\n{default}"
1137
+ );
1138
+ assert!(
1139
+ !default.contains(r#"lto = "thin""#),
1140
+ "thin LTO must be opt-in via TISH_GBA_THIN_LTO, not the default:\n{default}"
1141
+ );
1142
+ assert!(
1143
+ default.contains("debug = false"),
1144
+ "release debuginfo must be opt-in via TISH_GBA_DEBUG (#581)"
1145
+ );
1146
+
1147
+ // Legacy: scripts that set TISH_GBA_FAT_LTO=1 still get fat (now a no-op, not an error).
1148
+ std::env::set_var("TISH_GBA_FAT_LTO", "1");
1149
+ let ship = super::gba_cargo_profiles_toml();
1150
+ assert!(
1151
+ ship.contains(r#"lto = "fat""#) && ship.contains("codegen-units = 1"),
1152
+ "TISH_GBA_FAT_LTO=1 must keep working as a no-op:\n{ship}"
1153
+ );
1154
+ std::env::remove_var("TISH_GBA_FAT_LTO");
1155
+
1156
+ // The opt-in that #581 was really about: faster iteration where stack headroom allows.
1157
+ std::env::set_var("TISH_GBA_THIN_LTO", "1");
1158
+ let thin = super::gba_cargo_profiles_toml();
1159
+ assert!(
1160
+ thin.contains(r#"lto = "thin""#) && thin.contains("codegen-units = 8"),
1161
+ "TISH_GBA_THIN_LTO=1 must select thin LTO across 8 CGUs:\n{thin}"
1162
+ );
1163
+ std::env::remove_var("TISH_GBA_THIN_LTO");
1164
+
1165
+ std::env::set_var("TISH_FAST_NATIVE_BUILD", "1");
1166
+ let fast = super::gba_cargo_profiles_toml();
1167
+ assert!(
1168
+ fast.contains("lto = false") && fast.contains("opt-level = 1"),
1169
+ "TISH_FAST_NATIVE_BUILD=1 is the iteration profile — no LTO, opt-level 1 (#581):\n{fast}"
1170
+ );
1171
+ std::env::remove_var("TISH_FAST_NATIVE_BUILD");
1172
+
1173
+ std::env::set_var("TISH_GBA_DEBUG", "1");
1174
+ assert!(
1175
+ super::gba_cargo_profiles_toml().contains("debug = true"),
1176
+ "TISH_GBA_DEBUG=1 must keep release debuginfo (#581)"
1177
+ );
1178
+
1179
+ for (k, v) in saved {
1180
+ restore(k, v);
1181
+ }
1182
+ }
918
1183
  }
@@ -30,13 +30,62 @@ pub use tishlang_core::{
30
30
  has_pending_throw, set_pending_throw, stack_overflow_error, take_pending_throw, CallDepthGuard,
31
31
  };
32
32
 
33
- /// Enter a boxed user-fn call frame or trip the recursion guard. On GBA there is
34
- /// no stack-pressure probe (no `std`), so this is just the counted-depth guard.
33
+ unsafe extern "C" {
34
+ /// End of the `.iwram` output section — provided by agb's `gba.ld`. IWRAM runs
35
+ /// 0x0300_0000..0x0300_8000; agb's code/data occupies the bottom and the user
36
+ /// stack grows DOWN from 0x0300_7F00 toward this address. Anything below it is
37
+ /// live agb data (the audio mixer's buffers among them), so this is the real
38
+ /// bottom of the stack.
39
+ static __iwram_end: u8;
40
+ }
41
+
42
+ /// Headroom kept above [`__iwram_end`] (#655): must cover the frames emitted between
43
+ /// two guard checks plus the bail path that parks the `RangeError`. Boxed `Value`
44
+ /// frames on GBA run a few hundred bytes each, and the total stack is under 32 KB,
45
+ /// so this is deliberately small relative to the host's 256 KB margin.
46
+ const GBA_STACK_MARGIN: usize = 2 * 1024;
47
+
48
+ /// #655 — is the GBA stack nearly exhausted? The host probe (`stacker::remaining_stack`)
49
+ /// needs `std` and reports no bounds here, so its `None → floor 1` fallback made the
50
+ /// guard dead code on the one target where overflow is unrecoverable: there is no MMU
51
+ /// and no guard page, so the stack silently grows down into agb's IWRAM data and the
52
+ /// corruption surfaces frames later as an illegal opcode somewhere unrelated.
53
+ ///
54
+ /// The bounds ARE known statically here — the linker hands us the IWRAM extent — so
55
+ /// this is a link-time constant plus a pointer compare, about the cost of the
56
+ /// thread-local read it replaces on the host.
57
+ #[inline]
58
+ pub fn stack_low() -> bool {
59
+ let anchor = 0u8;
60
+ let sp = &anchor as *const u8 as usize;
61
+ let floor = (&raw const __iwram_end) as usize + GBA_STACK_MARGIN;
62
+ sp < floor
63
+ }
64
+
65
+ /// Enter a boxed user-fn call frame, or trip the recursion guard. Trips on EITHER the
66
+ /// counted ceiling (`tishlang_core`) or real stack pressure ([`stack_low`]) — on a
67
+ /// 32 KB IWRAM stack the counted default is far out of reach, so pressure is the
68
+ /// trigger that actually fires. On trip: parks the catchable `RangeError` and returns
69
+ /// `None`, exactly as on the host.
35
70
  #[inline]
36
71
  pub fn enter_call_guarded() -> Option<CallDepthGuard> {
72
+ if stack_low() {
73
+ set_pending_throw(stack_overflow_error());
74
+ return None;
75
+ }
37
76
  tishlang_core::enter_call_guarded()
38
77
  }
39
78
 
79
+ /// #655 — bail path for a tripped typed-fn recursion guard (GBA mirror of the host's
80
+ /// `recursion_tripped_f64`): park the catchable `RangeError` and unwind the numeric
81
+ /// frame with NaN until the first `Value` frame's pending-throw checkpoint raises it.
82
+ #[cold]
83
+ #[inline(never)]
84
+ pub fn recursion_tripped_f64() -> f64 {
85
+ set_pending_throw(stack_overflow_error());
86
+ f64::NAN
87
+ }
88
+
40
89
  // ── Error type for throw/return non-local control flow ───────────────────────
41
90
  /// Mirrors `tishlang_runtime::TishError` (the host runtime defines its own; kept
42
91
  /// in lock-step). `run()` in generated code returns `Result<(), Box<dyn Error>>`.
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "tishlang_test"
3
- version = "0.0.0-dev"
3
+ version = "0.1.0"
4
4
  edition = "2021"
5
5
  description = "Native tish test runner, expect, and Node-compatible assert"
6
6
  license-file = { workspace = true }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tishlang/tish-format",
3
- "version": "3.7.0",
3
+ "version": "3.8.6",
4
4
  "description": "Tish code formatter",
5
5
  "license": "PIF",
6
6
  "repository": {
Binary file
Binary file
Binary file
Binary file
Binary file