@tishlang/tish-lsp 2.39.0 → 3.0.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.
Files changed (89) hide show
  1. package/Cargo.toml +4 -1
  2. package/bin/tish-lsp +0 -0
  3. package/crates/tish/src/cli_help.rs +28 -0
  4. package/crates/tish/src/main.rs +41 -5
  5. package/crates/tish/tests/fixtures/cargo_example_project/crates/demo-shim/src/lib.rs +21 -0
  6. package/crates/tish/tests/fixtures/cargo_example_project/crates/demo-shim/tish.d.tish +4 -0
  7. package/crates/tish/tests/fixtures/cargo_example_project/src/main.tish +5 -1
  8. package/crates/tish/tests/regr_typed_externs.rs +59 -0
  9. package/crates/tish/tests/scope_merge.rs +79 -0
  10. package/crates/tish_build_utils/src/lib.rs +27 -1
  11. package/crates/tish_builtins/Cargo.toml +14 -6
  12. package/crates/tish_builtins/src/array.rs +130 -54
  13. package/crates/tish_builtins/src/collections.rs +9 -5
  14. package/crates/tish_builtins/src/construct.rs +7 -1
  15. package/crates/tish_builtins/src/date.rs +15 -1
  16. package/crates/tish_builtins/src/globals.rs +65 -3
  17. package/crates/tish_builtins/src/helpers.rs +7 -3
  18. package/crates/tish_builtins/src/iterator.rs +5 -1
  19. package/crates/tish_builtins/src/lib.rs +3 -0
  20. package/crates/tish_builtins/src/math.rs +14 -0
  21. package/crates/tish_builtins/src/number.rs +72 -6
  22. package/crates/tish_builtins/src/object.rs +6 -1
  23. package/crates/tish_builtins/src/string.rs +13 -2
  24. package/crates/tish_builtins/src/symbol.rs +17 -17
  25. package/crates/tish_builtins/src/typedarrays.rs +9 -2
  26. package/crates/tish_bytecode/src/compiler.rs +76 -2
  27. package/crates/tish_bytecode/src/lib.rs +1 -1
  28. package/crates/tish_bytecode/src/opcode.rs +77 -2
  29. package/crates/tish_compile/src/codegen.rs +2154 -225
  30. package/crates/tish_compile/src/infer.rs +37 -41
  31. package/crates/tish_compile/src/lib.rs +13 -1
  32. package/crates/tish_compile/src/platform_resolve.rs +361 -0
  33. package/crates/tish_compile/src/resolve.rs +195 -11
  34. package/crates/tish_compile/src/schemes.rs +333 -0
  35. package/crates/tish_compile/src/types.rs +205 -34
  36. package/crates/tish_compile/tests/perf_codegen_320.rs +2 -2
  37. package/crates/tish_compile/tests/perf_codegen_module_const_forof.rs +3 -1
  38. package/crates/tish_compile/tests/platform_resolve_cli.rs +164 -0
  39. package/crates/tish_compile/tests/regr_556_param_name_collision.rs +40 -0
  40. package/crates/tish_compile/tests/regr_558_struct_array_write.rs +65 -0
  41. package/crates/tish_compile/tests/regr_558b_struct_field_read.rs +21 -0
  42. package/crates/tish_compile/tests/regr_558c_wrapped_struct_array.rs +64 -0
  43. package/crates/tish_compile/tests/regr_558d_native_vec_mutators.rs +156 -0
  44. package/crates/tish_compile/tests/regr_562_wrapped_struct_array_push.rs +50 -0
  45. package/crates/tish_compile/tests/regr_fixed_native_coercions.rs +69 -0
  46. package/crates/tish_compile/tests/regr_gba_numerics_gated.rs +58 -0
  47. package/crates/tish_compile/tests/regr_gba_struct_reflection.rs +106 -0
  48. package/crates/tish_compile/tests/regression/name_collision_lib.tish +6 -0
  49. package/crates/tish_compile/tests/regression/name_collision_main.tish +13 -0
  50. package/crates/tish_compile/tests/regression/struct_array_write.tish +22 -0
  51. package/crates/tish_compile/tests/regression/struct_field_return_read.tish +10 -0
  52. package/crates/tish_compiler_wasm/Cargo.toml +1 -0
  53. package/crates/tish_compiler_wasm/src/resolve_virtual.rs +56 -6
  54. package/crates/tish_core/Cargo.toml +39 -4
  55. package/crates/tish_core/src/compat.rs +506 -0
  56. package/crates/tish_core/src/console_style.rs +23 -1
  57. package/crates/tish_core/src/json.rs +42 -10
  58. package/crates/tish_core/src/lib.rs +121 -25
  59. package/crates/tish_core/src/macros.rs +1 -1
  60. package/crates/tish_core/src/shape.rs +4 -2
  61. package/crates/tish_core/src/uri.rs +8 -1
  62. package/crates/tish_core/src/value.rs +305 -54
  63. package/crates/tish_core/src/vmref.rs +17 -5
  64. package/crates/tish_eval/Cargo.toml +2 -0
  65. package/crates/tish_eval/src/eval.rs +90 -3
  66. package/crates/tish_eval/src/value.rs +8 -0
  67. package/crates/tish_eval/src/value_convert.rs +316 -21
  68. package/crates/tish_lsp/Cargo.toml +3 -1
  69. package/crates/tish_lsp/src/import_goto.rs +41 -14
  70. package/crates/tish_lsp/src/main.rs +18 -0
  71. package/crates/tish_native/src/build.rs +246 -0
  72. package/crates/tish_native/src/config.rs +20 -0
  73. package/crates/tish_native/src/lib.rs +16 -6
  74. package/crates/tish_parser/src/lib.rs +28 -0
  75. package/crates/tish_parser/src/parser.rs +25 -4
  76. package/crates/tish_runtime/src/lib.rs +55 -9
  77. package/crates/tish_runtime_gba/Cargo.lock +837 -0
  78. package/crates/tish_runtime_gba/Cargo.toml +16 -0
  79. package/crates/tish_runtime_gba/src/gba.rs +211 -0
  80. package/crates/tish_runtime_gba/src/lib.rs +804 -0
  81. package/crates/tish_vm/src/jit.rs +1446 -164
  82. package/crates/tish_vm/src/vm.rs +583 -173
  83. package/justfile +9 -0
  84. package/package.json +1 -1
  85. package/platform/darwin-arm64/tish-lsp +0 -0
  86. package/platform/darwin-x64/tish-lsp +0 -0
  87. package/platform/linux-arm64/tish-lsp +0 -0
  88. package/platform/linux-x64/tish-lsp +0 -0
  89. package/platform/win32-x64/tish-lsp.exe +0 -0
@@ -0,0 +1,64 @@
1
+ //! tishlang/tish#558 — a captured (refcell-wrapped) native struct `Vec` must mutate NATIVELY, not fall
2
+ //! to the boxed path. When a struct array is closure-captured-and-mutated it is wrapped in a `VmRef`;
3
+ //! the native fast paths used to bail on that (`!refcell_wrapped_vars`), so `arr[i].field = v` and
4
+ //! `arr.push(x)` fell to the generic boxed path, which cloned+`from_struct`-boxed the WHOLE Vec into a
5
+ //! throwaway `Value::Array` and ran `set_prop`/`array_push` on the discarded copy — O(n) per op, the
6
+ //! write lost, and (for a `Vec<struct>`) an E0282 on the boxing closure since the skipped native op
7
+ //! never pinned the element type. These assert the native `borrow_mut` forms are emitted instead.
8
+
9
+ use std::path::PathBuf;
10
+
11
+ use tishlang_compile::compile_project_full;
12
+
13
+ fn emit(src: &str, name: &str) -> String {
14
+ let path = PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join(name);
15
+ std::fs::write(&path, src).unwrap();
16
+ compile_project_full(&path, path.parent(), &[], true).unwrap().0
17
+ }
18
+
19
+ // `arr` is captured AND mutated (field-assign) by `update`, so it is refcell-wrapped.
20
+ const WRAPPED_ASSIGN: &str = "\
21
+ interface E { x: number; y: number }
22
+ let arr: E[] = []
23
+ arr.push({ x: 0, y: 0 })
24
+ function update() { arr[0].x = 200 }
25
+ update()
26
+ console.log(arr[0].x)
27
+ ";
28
+
29
+ #[test]
30
+ fn wrapped_struct_element_field_assign_is_a_native_store() {
31
+ let rust = emit(WRAPPED_ASSIGN, "wrapped_assign.tish");
32
+ // Native element field store through the shared cell — not a `set_prop` on a boxed copy.
33
+ assert!(
34
+ rust.contains("borrow_mut())[_i]") || rust.contains(".borrow_mut())[") ,
35
+ "#558: wrapped `arr[i].field = v` must store natively through `(*arr.borrow_mut())[i]`.\n{rust}"
36
+ );
37
+ assert!(
38
+ !rust.contains("set_prop(&(tishlang_runtime::get_index(&Value::Array(VmRef::new"),
39
+ "#558: wrapped struct assign must NOT box the whole Vec and set_prop a throwaway copy.\n{rust}"
40
+ );
41
+ }
42
+
43
+ // `a` is captured AND mutated (push) by `f`, so it is refcell-wrapped.
44
+ const WRAPPED_PUSH: &str = "\
45
+ interface E { x: number; y: number }
46
+ let a: E[] = []
47
+ function f() { a.push({ x: 1, y: 2 }) }
48
+ f()
49
+ console.log(a[0].x)
50
+ ";
51
+
52
+ #[test]
53
+ fn wrapped_struct_push_is_a_native_push() {
54
+ let rust = emit(WRAPPED_PUSH, "wrapped_push.tish");
55
+ // Native push through the shared cell — not a boxed `array_push` on a whole-Vec copy.
56
+ assert!(
57
+ rust.contains("borrow_mut()).push("),
58
+ "#558: wrapped `a.push(struct)` must push natively through `(*a.borrow_mut()).push(..)`.\n{rust}"
59
+ );
60
+ assert!(
61
+ !rust.contains("array_push(&Value::Array(VmRef::new"),
62
+ "#558: wrapped struct push must NOT box the whole Vec into a throwaway array_push.\n{rust}"
63
+ );
64
+ }
@@ -0,0 +1,156 @@
1
+ //! tishlang/tish#558 — in-place native `Vec` mutators (`pop`/`shift`/`unshift`/`reverse`/`sort`) must
2
+ //! mutate the REAL Vec, both for a plain local and for a closure-captured (refcell-wrapped) one. The
3
+ //! boxed `array_*` fallback operates on a THROWAWAY boxed copy of the Vec (cloning + `from_struct`-boxing
4
+ //! every element into a `Value::Array`), so the mutation is silently discarded (O(n), and an E0282 on
5
+ //! the boxing closure for a `Vec<struct>`). These assert the native forms are emitted — plain (`a.pop()`,
6
+ //! `a.remove(0)`, `a.insert(0, …)`, `a.reverse()`, `a.sort_by(…)`) and captured (`(*a.borrow_mut())…`) —
7
+ //! and that the throwaway-boxing fallback does NOT appear.
8
+ //!
9
+ //! End-to-end runtime parity (interp/vm == native) is exercised by the fixtures under tests/core via
10
+ //! `test_mvp_programs_native`; these codegen-string checks pin the lowering itself.
11
+
12
+ use std::path::PathBuf;
13
+
14
+ use tishlang_compile::compile_project_full;
15
+
16
+ fn emit(body: &str, name: &str) -> String {
17
+ let src = format!("interface E {{ x: number; y: number }}\n{}", body);
18
+ let path = PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join(name);
19
+ std::fs::write(&path, src).unwrap();
20
+ compile_project_full(&path, path.parent(), &[], true).unwrap().0
21
+ }
22
+
23
+ // A plain local `a` mutated at top level (not captured) — native, not wrapped.
24
+ const PLAIN: &str = "\
25
+ let a: E[] = []
26
+ a.push({ x: 1, y: 2 })
27
+ a.push({ x: 3, y: 4 })
28
+ let p = a.pop()
29
+ let s = a.shift()
30
+ a.unshift({ x: 9, y: 9 })
31
+ a.reverse()
32
+ a.sort((u, v) => u.x - v.x)
33
+ console.log(a.length)
34
+ ";
35
+
36
+ #[test]
37
+ fn plain_native_vec_mutators_are_native() {
38
+ let r = emit(PLAIN, "plain_mut.tish");
39
+ assert!(r.contains("a.pop()"), "pop must be native `a.pop()`\n{r}");
40
+ assert!(r.contains("a.remove(0)"), "shift must be native `a.remove(0)`\n{r}");
41
+ assert!(r.contains("a.insert(0,"), "unshift must be native `a.insert(0, …)`\n{r}");
42
+ assert!(r.contains("a.reverse()"), "reverse must be native `a.reverse()`\n{r}");
43
+ assert!(r.contains("a.sort_by("), "sort must be native `a.sort_by(…)`\n{r}");
44
+ // None of the mutators may fall to the throwaway whole-Vec boxing fallback.
45
+ for boxed in ["array_pop(&Value::Array(VmRef::new", "array_shift", "array_unshift", "array_reverse"] {
46
+ assert!(!r.contains(boxed), "mutator must not box the whole Vec (`{boxed}`)\n{r}");
47
+ }
48
+ }
49
+
50
+ // `a` is captured AND mutated inside a closure → refcell-wrapped. Each mutator must go through the cell.
51
+ fn wrapped(mutator: &str, name: &str) -> String {
52
+ emit(
53
+ &format!(
54
+ "let a: E[] = []\na.push({{ x: 1, y: 2 }})\na.push({{ x: 3, y: 4 }})\nfunction f() {{ {mutator} }}\nf()\nconsole.log(a.length)\n"
55
+ ),
56
+ name,
57
+ )
58
+ }
59
+
60
+ #[test]
61
+ fn wrapped_pop_is_native_through_cell() {
62
+ let r = wrapped("a.pop()", "w_pop.tish");
63
+ assert!(r.contains(".borrow_mut()).pop()"), "wrapped pop must be `(*a.borrow_mut()).pop()`\n{r}");
64
+ assert!(!r.contains("array_pop(&Value::Array(VmRef::new"), "wrapped pop must not box the whole Vec\n{r}");
65
+ }
66
+
67
+ #[test]
68
+ fn wrapped_shift_is_native_through_cell() {
69
+ let r = wrapped("a.shift()", "w_shift.tish");
70
+ assert!(r.contains(".borrow_mut()).remove(0)"), "wrapped shift must be `(*a.borrow_mut()).remove(0)`\n{r}");
71
+ }
72
+
73
+ #[test]
74
+ fn wrapped_unshift_is_native_through_cell() {
75
+ let r = wrapped("a.unshift({ x: 9, y: 9 })", "w_unshift.tish");
76
+ assert!(r.contains(".borrow_mut()).insert(0,"), "wrapped unshift must be `(*a.borrow_mut()).insert(0, …)`\n{r}");
77
+ }
78
+
79
+ #[test]
80
+ fn wrapped_reverse_is_native_through_cell() {
81
+ let r = wrapped("a.reverse()", "w_reverse.tish");
82
+ assert!(r.contains(".borrow_mut()).reverse()"), "wrapped reverse must be `(*a.borrow_mut()).reverse()`\n{r}");
83
+ }
84
+
85
+ #[test]
86
+ fn wrapped_sort_is_native_through_cell() {
87
+ let r = wrapped("a.sort((u, v) => u.x - v.x)", "w_sort.tish");
88
+ assert!(
89
+ r.contains(".borrow_mut()).sort_by(") || r.contains(".borrow_mut() = "),
90
+ "wrapped sort must mutate through the cell (`(*a.borrow_mut()).sort_by` or a write-back)\n{r}"
91
+ );
92
+ }
93
+
94
+ // ── splice / fill / copyWithin (native emitters, plain + captured) ──
95
+
96
+ const SFC_PLAIN: &str = "\
97
+ let a: E[] = []
98
+ a.push({ x: 1, y: 2 })
99
+ a.push({ x: 3, y: 4 })
100
+ let removed = a.splice(0, 1, { x: 9, y: 9 })
101
+ a.fill({ x: 0, y: 0 }, 0, 1)
102
+ a.copyWithin(0, 1)
103
+ console.log(a.length + \"\" + removed.length)
104
+ ";
105
+
106
+ #[test]
107
+ fn plain_splice_fill_copywithin_are_native() {
108
+ let r = emit(SFC_PLAIN, "sfc_plain.tish");
109
+ assert!(r.contains(".splice(__start"), "splice must be native `Vec::splice`\n{r}");
110
+ assert!(r.contains("__v.clone()"), "fill must write elements natively\n{r}");
111
+ assert!(r.contains(".to_vec()"), "copyWithin must copy via a native temp\n{r}");
112
+ for boxed in ["array_splice", "array_fill", "array_copy_within", "copy_within as"] {
113
+ assert!(!r.contains(boxed), "must not fall to the boxed `{boxed}` on a throwaway copy\n{r}");
114
+ }
115
+ }
116
+
117
+ #[test]
118
+ fn wrapped_splice_is_native_through_guard() {
119
+ // `a` captured + spliced in a closure → wrapped; splice uses a `borrow_mut` guard.
120
+ let r = wrapped("a.splice(0, 1)", "w_splice.tish");
121
+ assert!(
122
+ r.contains(".borrow_mut()") && r.contains("__g.splice("),
123
+ "wrapped splice must run on a `borrow_mut` guard (`__g.splice(..)`)\n{r}"
124
+ );
125
+ assert!(!r.contains("array_splice"), "wrapped splice must not box the whole Vec\n{r}");
126
+ }
127
+
128
+ // ── captured (wrapped) array READS must NOT box the whole Vec (tish#558 read side) ──
129
+
130
+ #[test]
131
+ fn wrapped_element_field_read_is_native_not_whole_array_box() {
132
+ // `arr` captured + mutated in a closure → wrapped; reading `arr[0].x` must index the borrowed
133
+ // Vec directly, NOT clone+box the entire Vec into a `Value::Array` just to read one field.
134
+ let src = "let arr: E[] = []\narr.push({ x: 1, y: 2 })\nfunction f() { arr[0].x = 9; return arr[0].x }\nconsole.log(f())\n";
135
+ let path = std::path::PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join("w_read.tish");
136
+ std::fs::write(&path, format!("interface E {{ x: number; y: number }}\n{src}")).unwrap();
137
+ let r = compile_project_full(&path, path.parent(), &[], true).unwrap().0;
138
+ assert!(
139
+ r.contains("let __bg = arr.borrow();") && r.contains("(*__bg)["),
140
+ "wrapped `arr[i].field` read must index the borrowed Vec natively via a scoped guard (#567)\n{r}"
141
+ );
142
+ assert!(
143
+ !r.contains("iter().cloned().map(|v| tishlang_runtime::Value::from_struct"),
144
+ "wrapped read must NOT clone+box the whole Vec into a Value::Array (O(n)/read)\n{r}"
145
+ );
146
+ }
147
+
148
+ #[test]
149
+ fn push_returns_new_length_not_null() {
150
+ // `push` returns the new length (JS / tish interp), a plain `.len()` read — not `Value::Null`.
151
+ let r = emit("let a: E[] = []\nlet n = a.push({ x: 1, y: 2 })\nconsole.log(n)\n", "push_ret.tish");
152
+ assert!(
153
+ r.contains(".len() as f64"),
154
+ "push must return the new length (`.len() as f64`), not Null\n{r}"
155
+ );
156
+ }
@@ -0,0 +1,50 @@
1
+ //! tishlang/tish#562 — `.push()` (and `.length`) on a captured (refcell-wrapped) typed struct array
2
+ //! must lower to NATIVE ops, not box the whole `Vec` into a `Value::Array` via an UNTYPED `map(|v| …)`
3
+ //! closure. The boxed path both wasted O(n) per op AND failed to compile with `error[E0282]: type
4
+ //! annotations needed` (the untyped closure param, since the skipped native push never pinned the Vec's
5
+ //! element type). Reproduced on the GBA emit path (the `i32` struct field is native only there).
6
+
7
+ use std::path::PathBuf;
8
+
9
+ use tishlang_compile::{compile_project_full_emit, NativeEmitMode};
10
+
11
+ fn gba_emit(src: &str, name: &str) -> String {
12
+ let path = PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join(name);
13
+ std::fs::write(&path, src).unwrap();
14
+ compile_project_full_emit(&path, path.parent(), &[], true, NativeEmitMode::Gba, None)
15
+ .unwrap()
16
+ .0
17
+ }
18
+
19
+ // The #562 repro shape: a typed struct array with an i32 field, captured + pushed in a closure (which
20
+ // is what refcell-wraps it, matching the issue's `(*enemyStates.borrow())` generated code).
21
+ const SRC: &str = "\
22
+ type EnemyState = { timer: i32 }
23
+ let enemyStates: EnemyState[] = []
24
+ function update() { enemyStates.push({ timer: 0 }) }
25
+ update()
26
+ console.log(enemyStates.length)
27
+ ";
28
+
29
+ #[test]
30
+ fn wrapped_struct_push_and_length_do_not_box_the_whole_vec() {
31
+ let rust = gba_emit(SRC, "regr562.tish");
32
+ // No whole-Vec boxing (the E0282-prone untyped `map(|v| …)` closure) for either push or length.
33
+ assert!(
34
+ !rust.contains("iter().cloned().map(|v|"),
35
+ "#562: wrapped struct push/length must NOT box the whole Vec via an untyped `map(|v| …)`\n{rust}"
36
+ );
37
+ assert!(
38
+ !rust.contains("array_push(&Value::Array(VmRef::new"),
39
+ "#562: push must not fall to the boxed `array_push` on a throwaway Value::Array copy\n{rust}"
40
+ );
41
+ // Native push through the shared cell, and a native `len()` for `.length`.
42
+ assert!(
43
+ rust.contains(".borrow_mut()).push("),
44
+ "#562: push must be native `(*enemyStates.borrow_mut()).push(..)`\n{rust}"
45
+ );
46
+ assert!(
47
+ rust.contains("enemyStates.borrow(); __bg.len()"),
48
+ "#562: `.length` must be a native scoped `{{ let __bg = enemyStates.borrow(); __bg.len() }}` (#567), not a whole-Vec box\n{rust}"
49
+ );
50
+ }
@@ -0,0 +1,69 @@
1
+ //! GBA `fixed` native coercions (the "gba perf" numeric lowering). These paths lift a native
2
+ //! integer/f64 into `tishlang_runtime::Fixed` (agb `Num<i32,8>`, raw = value << 8) WITHOUT a boxed
3
+ //! `Value` round-trip, so `fixed` arithmetic stays native:
4
+ //! * `let v: fixed = <int/float literal>` — literal → Fixed
5
+ //! * `let d: fixed = t * speed` (t is a runtime i32) — runtime integer LIFTED to Fixed before `Fixed * Fixed`
6
+ //! * `nx = <int literal>` (reassign a fixed local) — RHS lifted to Fixed (the E0308 reassignment fix)
7
+ //!
8
+ //! All three are GBA-only (`fixed`/`i32` are native scalars only under `--target gba`). Off-GBA the
9
+ //! `fixed` annotation falls back to a boxed `Value`, so none of the native `Fixed` code appears —
10
+ //! asserted below so a leak into the desktop build (where `tishlang_runtime::Fixed` does not exist)
11
+ //! would fail the test rather than the downstream Rust compile.
12
+
13
+ use std::path::PathBuf;
14
+
15
+ use tishlang_compile::{compile_project_full, compile_project_full_emit, NativeEmitMode};
16
+
17
+ const SRC: &str = "\
18
+ let speed: fixed = 2
19
+ let t: i32 = 5
20
+ let d: fixed = t * speed
21
+ let nx: fixed = 0
22
+ nx = 3
23
+ console.log(d)
24
+ console.log(nx)
25
+ ";
26
+
27
+ fn write_src() -> PathBuf {
28
+ let dir = PathBuf::from(env!("CARGO_TARGET_TMPDIR"));
29
+ let path = dir.join("fixed_native_coercions.tish");
30
+ std::fs::write(&path, SRC).unwrap();
31
+ path
32
+ }
33
+
34
+ #[test]
35
+ fn gba_lifts_int_and_float_into_native_fixed() {
36
+ let path = write_src();
37
+ let rust = compile_project_full_emit(&path, path.parent(), &[], true, NativeEmitMode::Gba, None)
38
+ .unwrap()
39
+ .0;
40
+
41
+ // `let speed: fixed = 2` — the int literal lifts to Fixed (× 256 via from_raw), not a boxed number.
42
+ assert!(
43
+ rust.contains("let mut speed: tishlang_runtime::Fixed = tishlang_runtime::Fixed::from_raw("),
44
+ "`let speed: fixed = 2` must lower to a native Fixed::from_raw literal.\n{rust}"
45
+ );
46
+ // `t * speed` (runtime i32 × fixed) — the integer operand `t` is LIFTED to Fixed (raw = value * 256)
47
+ // so the whole op stays `Fixed * Fixed`, instead of dropping `t` to f64 and boxing to `ops::*`.
48
+ assert!(
49
+ rust.contains("as i32) * 256) * speed"),
50
+ "`t * speed` must lift the runtime integer `t` to Fixed and stay native Fixed*Fixed.\n{rust}"
51
+ );
52
+ // `nx = 3` — reassigning a fixed local lifts the RHS to Fixed (the E0308 reassignment fix), not f64.
53
+ assert!(
54
+ rust.contains("nx = tishlang_runtime::Fixed::from_raw("),
55
+ "reassigning a `fixed` local must lift the RHS to Fixed (not leave it f64).\n{rust}"
56
+ );
57
+ }
58
+
59
+ #[test]
60
+ fn off_gba_fixed_stays_boxed_no_native_fixed_type() {
61
+ let path = write_src();
62
+ // Default (desktop) emit: `fixed` has no host type, so every one of the above must stay boxed —
63
+ // NO `tishlang_runtime::Fixed` anywhere (it would not compile off-GBA).
64
+ let rust = compile_project_full(&path, path.parent(), &[], true).unwrap().0;
65
+ assert!(
66
+ !rust.contains("tishlang_runtime::Fixed"),
67
+ "`fixed` (incl. the `t * speed` lift and `nx =` reassign) must stay boxed off-GBA.\n{rust}"
68
+ );
69
+ }
@@ -0,0 +1,58 @@
1
+ //! Regression: `fixed` and the narrow integer widths (`i8/u8/i16/u16/u32`) are the GBA typed-scalar
2
+ //! vocabulary and must lower to native scalars ONLY for `--target gba`. On the default native build
3
+ //! they must fall back to boxed `Value` — otherwise `fixed` references `tishlang_runtime::Fixed`,
4
+ //! which does not exist off-GBA (a hard compile error), and narrow widths truncate on store,
5
+ //! diverging from the interpreter and breaking the typed == boxed == interpreter guarantee.
6
+ use std::path::PathBuf;
7
+ use tishlang_compile::{compile_project_full, compile_project_full_emit, NativeEmitMode};
8
+
9
+ fn fixture(rel: &str) -> PathBuf {
10
+ let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
11
+ manifest.join("../..").join(rel).canonicalize().unwrap()
12
+ }
13
+
14
+ fn compile(rel: &str) -> String {
15
+ let path = fixture(rel);
16
+ // compile_project_full uses the default DesktopBin emit mode (i.e. NOT --target gba).
17
+ compile_project_full(&path, path.parent(), &[], true).unwrap().0
18
+ }
19
+
20
+ #[test]
21
+ fn narrow_int_and_fixed_do_not_lower_to_native_off_gba() {
22
+ let rust = compile("tests/regression/gba_numerics_gated.tish");
23
+ // `fixed` must not emit the GBA-only native type (absent off-GBA → would not compile).
24
+ assert!(
25
+ !rust.contains("tishlang_runtime::Fixed"),
26
+ "`: fixed` must fall back to boxed Value on a non-GBA build (found the native Fixed type):\n{}",
27
+ rust
28
+ );
29
+ // The narrow-width fields must not become native storage types (which would truncate).
30
+ assert!(
31
+ !rust.contains(": u8") && !rust.contains(": i16") && !rust.contains("as u8"),
32
+ "narrow-int annotations must stay boxed Value off-GBA (found native narrow storage):\n{}",
33
+ rust
34
+ );
35
+ }
36
+
37
+ #[test]
38
+ fn gba_numeric_profile_does_not_leak_to_a_later_desktop_compile() {
39
+ // The numeric profile is a thread-local. A GBA compile turns it on; a subsequent non-GBA
40
+ // compile on the SAME thread (as happens in an LSP / a `cargo test` worker) must NOT inherit it
41
+ // — otherwise `: fixed`/narrow/`i32` would lower natively off-GBA and diverge. This orders both
42
+ // compiles on one thread and checks the desktop one is clean.
43
+ let path = fixture("tests/regression/gba_numerics_gated.tish");
44
+ let gba = compile_project_full_emit(&path, path.parent(), &[], true, NativeEmitMode::Gba, None)
45
+ .unwrap()
46
+ .0;
47
+ assert!(
48
+ gba.contains("tishlang_runtime::Fixed"),
49
+ "sanity: GBA mode DOES lower `: fixed` to the native Fixed type"
50
+ );
51
+ // Same thread, right after the GBA compile: a default (DesktopBin) compile must reset the profile.
52
+ let desktop = compile_project_full(&path, path.parent(), &[], true).unwrap().0;
53
+ assert!(
54
+ !desktop.contains("tishlang_runtime::Fixed"),
55
+ "a desktop compile after a GBA compile leaked the GBA numeric profile (found native Fixed):\n{}",
56
+ desktop
57
+ );
58
+ }
@@ -0,0 +1,106 @@
1
+ //! GBA static-type-aware struct reflection. On `--target gba`, `Object.keys/values/entries(s)` and
2
+ //! object spread `{ ...s }` over a statically-known struct (`RustType::Named`) lower to NATIVE reads of
3
+ //! the `TishStruct_*` Rust struct — the struct is never boxed to a `Value::Struct` and no intermediate
4
+ //! hashmap `Value::Object` is built. Only the RESULT (an array / the spread object) is a `Value`.
5
+ //!
6
+ //! DESKTOP is deliberately untouched: reflection there keeps the boxed path (a struct materialises to a
7
+ //! real `Value::Object`, already correct), and struct spread boxes exactly as before — so the hot
8
+ //! `object_spread` bench keeps its clone-shape path. Asserted below so a future change that leaks the
9
+ //! GBA native lowering onto desktop (bench regression) fails here.
10
+
11
+ use std::path::PathBuf;
12
+
13
+ use tishlang_compile::{compile_project_full, compile_project_full_emit, NativeEmitMode};
14
+
15
+ fn emit(src: &str, name: &str, gba: bool) -> String {
16
+ let path = PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join(name);
17
+ std::fs::write(&path, src).unwrap();
18
+ if gba {
19
+ compile_project_full_emit(&path, path.parent(), &[], true, NativeEmitMode::Gba, None)
20
+ .unwrap()
21
+ .0
22
+ } else {
23
+ compile_project_full(&path, path.parent(), &[], true).unwrap().0
24
+ }
25
+ }
26
+
27
+ const KEYS_SRC: &str = "\
28
+ interface P { x: number; y: number }
29
+ let p: P = { x: 1, y: 2 }
30
+ console.log(Object.keys(p).join(\",\"))
31
+ ";
32
+
33
+ #[test]
34
+ fn gba_object_keys_on_struct_is_native_field_names() {
35
+ let rust = emit(KEYS_SRC, "gba_keys.tish", true);
36
+ // Field names emitted as a native array literal — no boxed `tish_object_keys` call on the struct.
37
+ assert!(
38
+ rust.contains("Value::String(\"x\".into()), Value::String(\"y\".into())"),
39
+ "GBA `Object.keys(struct)` must emit the field names natively (no struct box / hashmap).\n{rust}"
40
+ );
41
+ }
42
+
43
+ #[test]
44
+ fn desktop_object_keys_stays_boxed_untouched() {
45
+ let rust = emit(KEYS_SRC, "desk_keys.tish", false);
46
+ assert!(
47
+ !rust.contains("Value::String(\"x\".into()), Value::String(\"y\".into())"),
48
+ "desktop reflection must stay on the boxed path (GBA-gated native lowering leaked to desktop).\n{rust}"
49
+ );
50
+ }
51
+
52
+ const SPREAD_SRC: &str = "\
53
+ interface P { x: number; y: number }
54
+ let base: P = { x: 1, y: 2 }
55
+ let merged = { ...base, z: 3 }
56
+ console.log(JSON.stringify(merged))
57
+ ";
58
+
59
+ #[test]
60
+ fn gba_struct_spread_inserts_fields_natively() {
61
+ let rust = emit(SPREAD_SRC, "gba_spread.tish", true);
62
+ // Native per-field inserts from a borrowed struct — not the boxed `object_spread_props` helper
63
+ // (that stays the fallback for a struct hidden behind a `Value`) and not a hashmap materialisation.
64
+ assert!(
65
+ rust.contains("let _s = &(") && rust.contains("_obj.insert("),
66
+ "GBA `{{ ...struct }}` must insert the struct's fields natively.\n{rust}"
67
+ );
68
+ }
69
+
70
+ #[test]
71
+ fn desktop_struct_spread_stays_boxed_untouched() {
72
+ let rust = emit(SPREAD_SRC, "desk_spread.tish", false);
73
+ // Desktop must keep the inline `if let Value::Object` clone-shape path — the native `let _s = &(`
74
+ // insert form must NOT appear (that would mean the GBA lowering leaked onto the object_spread bench).
75
+ assert!(
76
+ !rust.contains("let _s = &("),
77
+ "desktop struct spread must stay boxed/inline (bench-safe); native inserts leaked.\n{rust}"
78
+ );
79
+ }
80
+
81
+ const IN_SRC: &str = "\
82
+ interface P { x: number; y: number }
83
+ let p: P = { x: 1, y: 2 }
84
+ console.log(\"x\" in p)
85
+ console.log(\"z\" in p)
86
+ ";
87
+
88
+ #[test]
89
+ fn gba_in_on_struct_folds_to_native_bool() {
90
+ let rust = emit(IN_SRC, "gba_in.tish", true);
91
+ // A string-literal key `in` a known struct folds to a compile-time bool — no boxed `tish_in_operator`
92
+ // on the struct, no `Value::Struct` box.
93
+ assert!(
94
+ rust.contains("Value::Bool(true)") && rust.contains("Value::Bool(false)"),
95
+ "GBA `\"x\" in struct` must fold to a compile-time bool.\n{rust}"
96
+ );
97
+ }
98
+
99
+ #[test]
100
+ fn desktop_in_on_struct_stays_boxed_untouched() {
101
+ let rust = emit(IN_SRC, "desk_in.tish", false);
102
+ assert!(
103
+ rust.contains("tish_in_operator"),
104
+ "desktop `in` must stay on the boxed runtime operator (GBA native lowering leaked).\n{rust}"
105
+ );
106
+ }
@@ -0,0 +1,6 @@
1
+ // An exported helper whose PARAMETER name (`scene`) collides with a top-level mutable var in the
2
+ // importer. The reference to the parameter inside the body must resolve to the parameter, not the
3
+ // importer's VmRef cell of the same name (tishlang/tish#556).
4
+ export function loadScene(scene) {
5
+ return scene(1)
6
+ }
@@ -0,0 +1,13 @@
1
+ import { loadScene } from './name_collision_lib'
2
+
3
+ // A top-level var named `scene`, CAPTURED + mutated by a closure ⇒ it becomes a shared `VmRef` cell.
4
+ // This is the shape the real game hit: mutable module state whose name matches an imported helper's
5
+ // parameter (tishlang/tish#556).
6
+ let scene = 0
7
+ function bump() { scene = scene + 1 }
8
+ bump()
9
+
10
+ function build(n) { return n + 100 }
11
+
12
+ console.log(loadScene(build)) // expect 101 — the param `scene` inside loadScene, not the cell
13
+ console.log(scene) // expect 1
@@ -0,0 +1,22 @@
1
+ // tishlang/tish#558 — typed `Vec<struct>` element field writes must be NATIVE stores into the Vec,
2
+ // not a `set_prop` on a throwaway `Value::object_from_pairs([field.clone()...])` copy (which discards
3
+ // the write). Covers a module-level typed struct array and a local one; both read the field back so a
4
+ // discarded write is observable.
5
+
6
+ type P = { x: i32, y: i32 }
7
+
8
+ // (1) module-level typed struct array
9
+ let arr: P[] = []
10
+ arr.push({ x: 10, y: 0 })
11
+ arr[0].x = 200
12
+
13
+ // (2) local typed struct array inside a function
14
+ function localWrite() {
15
+ let es: P[] = []
16
+ es.push({ x: 1, y: 2 })
17
+ es[0].x = 99
18
+ console.log(es[0].x) // 99 if the write took effect, 1 if discarded
19
+ }
20
+
21
+ console.log(arr[0].x) // 200 if the write took effect, 10 if discarded
22
+ localWrite()
@@ -0,0 +1,10 @@
1
+ // tishlang/tish#558 residual — reading a struct field from a Vec element in a value/return position
2
+ // must CLONE a non-Copy field, not move it out of the borrowed element (E0507). (A Copy field like an
3
+ // on-GBA `i32` reads by value fine; off-GBA a `: i32` field is a boxed `Value`, which is non-Copy.)
4
+ type P = { x: i32 }
5
+ function firstX(): i32 {
6
+ let es: P[] = []
7
+ es.push({ x: 7 })
8
+ return es[0].x
9
+ }
10
+ console.log(firstX())
@@ -16,6 +16,7 @@ wasm-bindgen = "0.2"
16
16
 
17
17
  tishlang_ast = { path = "../tish_ast", version = ">=0.1" }
18
18
  tishlang_bytecode = { path = "../tish_bytecode", version = ">=0.1" }
19
+ tishlang_compile = { path = "../tish_compile", version = ">=0.1" }
19
20
  tishlang_compile_js = { path = "../tish_compile_js", version = ">=0.1" }
20
21
  tishlang_opt = { path = "../tish_opt", version = ">=0.1" }
21
22
  tishlang_parser = { path = "../tish_parser", version = ">=0.1" }
@@ -76,7 +76,8 @@ fn parent_dir(path: &str) -> &str {
76
76
  }
77
77
  }
78
78
 
79
- /// Resolve import spec to a key for the files map. Tries .tish extension if missing.
79
+ /// Resolve import spec to a key for the files map.
80
+ /// Uses the same platform/surface cascade as `tish resolve-id` / disk resolve (`TISH_PLATFORM` / `TISH_SURFACE`).
80
81
  fn resolve_import_to_key(
81
82
  spec: &str,
82
83
  from_dir: &str,
@@ -86,14 +87,29 @@ fn resolve_import_to_key(
86
87
  if files.contains_key(&normalized) {
87
88
  return Ok(normalized);
88
89
  }
89
- if !normalized.ends_with(".tish") && !normalized.contains('.') {
90
- let with_ext = format!("{}.tish", normalized);
91
- if files.contains_key(&with_ext) {
92
- return Ok(with_ext);
90
+
91
+ let stem = if normalized.ends_with(".tish") {
92
+ &normalized[..normalized.len() - 5]
93
+ } else if !normalized.contains('.') {
94
+ normalized.as_str()
95
+ } else {
96
+ return Err(format!(
97
+ "Cannot resolve import '{}' from {}: file not in virtual file map",
98
+ spec, from_dir
99
+ ));
100
+ };
101
+
102
+ // Match CLI: honor env when set (playground can set TISH_PLATFORM / TISH_SURFACE).
103
+ let _ = tishlang_compile::apply_resolve_env(None, None);
104
+ let ctx = tishlang_compile::resolve_context();
105
+ for key in tishlang_compile::platform_virtual_keys(stem, ctx) {
106
+ if files.contains_key(&key) {
107
+ return Ok(key);
93
108
  }
94
109
  }
110
+
95
111
  Err(format!(
96
- "Cannot resolve import '{}' from {}: file not in virtual file map",
112
+ "Cannot resolve import '{}' from {}: file not in virtual file map (platform cascade)",
97
113
  spec, from_dir
98
114
  ))
99
115
  }
@@ -584,4 +600,38 @@ mod tests {
584
600
  "import of re-exported `plus` should bind to dep's `add`"
585
601
  );
586
602
  }
603
+
604
+ #[test]
605
+ fn resolve_virtual_honors_platform_cascade() {
606
+ use tishlang_compile::{set_resolve_context, Platform, ResolveContext, Surface};
607
+ set_resolve_context(ResolveContext {
608
+ platform: Platform::Macos,
609
+ surface: Surface::Native,
610
+ });
611
+ let mut files = HashMap::new();
612
+ files.insert(
613
+ "Button.tish".to_string(),
614
+ "export fn Button() {}".to_string(),
615
+ );
616
+ files.insert(
617
+ "Button.macos.tish".to_string(),
618
+ "export fn Button() {}".to_string(),
619
+ );
620
+ files.insert(
621
+ "main.tish".to_string(),
622
+ "import { Button } from \"./Button\"\nButton()".to_string(),
623
+ );
624
+ let modules = resolve_virtual("main.tish", &files).unwrap();
625
+ // macos/native → the .macos variant wins; the base is never loaded.
626
+ assert!(
627
+ modules.iter().any(|m| m.path == "Button.macos.tish"),
628
+ "platform cascade should resolve Button.macos.tish"
629
+ );
630
+ assert!(
631
+ !modules.iter().any(|m| m.path == "Button.tish"),
632
+ "base Button.tish must not resolve when the macos variant exists"
633
+ );
634
+ // Reset the process-wide context so it doesn't leak into sibling tests.
635
+ set_resolve_context(ResolveContext::default());
636
+ }
587
637
  }