@tishlang/tish-lint 3.7.1 → 3.9.1

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.
@@ -33,6 +33,26 @@ pub(crate) fn native_recur_guard_enabled() -> bool {
33
33
  std::env::var("TISH_NATIVE_RECUR_GUARD").map(|v| v != "0").unwrap_or(true)
34
34
  }
35
35
 
36
+ /// #682 — MODULE-SCOPE STATE GETS A TOP-LEVEL HOME INSTEAD OF A `run()` STACK SLOT.
37
+ ///
38
+ /// Every module in a program is flattened into one `run()`, so every top-level function's cell and
39
+ /// boxed closure, every capture clone of them, and every module binding is a live local of a single
40
+ /// frame. On a real GBA game that frame reached 27–30 KB against a 32.5 KB IWRAM stack, and it grows
41
+ /// with program size — so the ROM stops booting and no further feature can be added.
42
+ ///
43
+ /// Promoting those bindings to statics (`SingleCore` on GBA, `thread_local!` on the host) takes them
44
+ /// out of the frame, and removes the per-closure `_cell`/`_ref` capture clones with them.
45
+ ///
46
+ /// **Default ON.** `TISH_MODULE_STATICS=0` restores the pre-#682 emission for bisecting;
47
+ /// `TISH_MODULE_STATICS=1` forces it on where the thread gate below would otherwise decline.
48
+ pub(crate) fn module_statics_flag() -> Option<bool> {
49
+ match std::env::var("TISH_MODULE_STATICS").as_deref() {
50
+ Ok("0") => Some(false),
51
+ Ok(_) => Some(true),
52
+ Err(_) => None,
53
+ }
54
+ }
55
+
36
56
  /// How generated Rust is linked (desktop binary vs embedded iOS staticlib vs GBA ROM).
37
57
  #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
38
58
  pub enum NativeEmitMode {
@@ -117,6 +137,7 @@ fn count(): number {
117
137
  for (let i: number = 0; i < 10; i = i + 1) { total = total + i }
118
138
  return total
119
139
  }
140
+ console.log(count())
120
141
  "#;
121
142
  let program = parse(src_native).unwrap();
122
143
  let rust = compile(&program).unwrap();
@@ -124,9 +145,14 @@ fn count(): number {
124
145
  rust.contains("let mut total: f64"),
125
146
  "numeric-only reassignment keeps `total` native f64"
126
147
  );
148
+ // The boxing moved, it did not disappear. With the boxed wrapper elided for a fn every
149
+ // caller reaches directly, the f64 is wrapped at the CALL SITE
150
+ // (`Value::Number(count_native())`) instead of inside a wrapper's return. Either spelling
151
+ // satisfies what this is checking — that a native f64 crosses back to Value at the boundary
152
+ // — so accept both rather than pinning the assertion to one codegen shape.
127
153
  assert!(
128
- rust.contains("Value::Number(total)"),
129
- "f64 total is wrapped back to Value at the return boundary"
154
+ rust.contains("Value::Number(total)") || rust.contains("Value::Number(count_native())"),
155
+ "f64 total is wrapped back to Value at a boundary (wrapper return or call site)"
130
156
  );
131
157
  }
132
158
 
@@ -887,6 +887,13 @@ impl TypeContext {
887
887
  pub fn get_type(&self, name: &str) -> RustType {
888
888
  self.lookup(name).cloned().unwrap_or(RustType::Value)
889
889
  }
890
+
891
+ /// Is `name` bound in a NON-GLOBAL scope (a param or local of the function/block being
892
+ /// emitted)? Used to let locals shadow module-function statics: `let dist: i32` inside a
893
+ /// function must win over a bundled module's `function dist(...)`.
894
+ pub fn is_locally_shadowed(&self, name: &str) -> bool {
895
+ self.scopes.iter().skip(1).any(|scope| scope.contains_key(name))
896
+ }
890
897
  }
891
898
 
892
899
  #[cfg(test)]
@@ -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
+ }
@@ -0,0 +1,189 @@
1
+ //! #682 — module-scope state must have a top-level home, not a `run()` stack slot.
2
+ //!
3
+ //! Every module of a program is flattened into one `run()`, so every top-level function's cell,
4
+ //! its boxed closure, and one `_ref` clone per capturing sibling are live locals of a single frame.
5
+ //! On a real GBA game that frame reached 27–30 KB against a 32.5 KB IWRAM stack, and it grows with
6
+ //! program size — the ROM stops booting and no further feature can be added.
7
+
8
+ use std::path::PathBuf;
9
+
10
+ use tishlang_compile::{compile_project_full_emit, NativeEmitMode};
11
+
12
+ fn fixture(dir: &str, files: &[(&str, &str)]) -> PathBuf {
13
+ let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
14
+ .join("../../target")
15
+ .join(dir);
16
+ let _ = std::fs::remove_dir_all(&root);
17
+ std::fs::create_dir_all(root.join("pkg")).unwrap();
18
+ for (rel, body) in files {
19
+ std::fs::write(root.join(rel), body).unwrap();
20
+ }
21
+ root.join("main.tish")
22
+ }
23
+
24
+ const MOD: &str = "\
25
+ export let hits: i32 = 0
26
+ export let bag = { hits: 0, tag: \"b\" }
27
+ export function tally(a: i32): i32 { hits = hits + a; bag = { hits: bag.hits + 1, tag: bag.tag }; return hits }
28
+ export function twice(a: i32): i32 { return tally(a) + tally(a) }
29
+ ";
30
+
31
+ const ENTRY: &str = "\
32
+ import { tally, twice, hits, bag } from './pkg/m.tish'
33
+ function main() { console.log(twice(2) + tally(1) + hits + bag.hits) }
34
+ ";
35
+
36
+ fn compile_in(dir: &str, mode: NativeEmitMode) -> String {
37
+ let entry = fixture(dir, &[("main.tish", ENTRY), ("pkg/m.tish", MOD)]);
38
+ compile_project_full_emit(&entry, entry.parent(), &[], true, mode, None)
39
+ .expect("compile")
40
+ .0
41
+ }
42
+
43
+ fn run_body(rust: &str) -> String {
44
+ rust.split("fn run()").nth(1).expect("run()").to_string()
45
+ }
46
+
47
+ /// GBA has no `thread_local!` (no_std), so module fns land in `SingleCore` statics — the same split
48
+ /// #594 made for the numeric globals, and for the same reason: `with()` mirrors `LocalKey::with`,
49
+ /// so the read sites are written once and work on both targets.
50
+ #[test]
51
+ fn gba_module_fns_live_in_singlecore_statics() {
52
+ let rust = compile_in("regr682_gba", NativeEmitMode::Gba);
53
+ assert!(
54
+ rust.contains(
55
+ "static __TISH_GF_twice: tishlang_runtime::SingleCore<core::cell::RefCell<Value>> = \
56
+ tishlang_runtime::SingleCore::new(core::cell::RefCell::new(Value::Null));"
57
+ ),
58
+ "expected a SingleCore static for `twice`:\n{rust}"
59
+ );
60
+ let body = run_body(&rust);
61
+ assert!(
62
+ body.contains("__TISH_GF_twice.with(|c| *c.borrow_mut() = {"),
63
+ "the closure must be built INTO the static, not into a `run()` local:\n{body}"
64
+ );
65
+ assert!(
66
+ !body.contains("let twice_cell"),
67
+ "a promoted fn must not also keep a `run()` cell — that is the slot #682 is about:\n{body}"
68
+ );
69
+ }
70
+
71
+ /// The host storage is `thread_local!`, one macro invocation per static: the macro recurses once
72
+ /// per item and a real program has hundreds of module fns, which blows rustc's default
73
+ /// `recursion_limit = 128` if they share one block.
74
+ #[test]
75
+ fn host_module_fns_live_in_thread_local_statics() {
76
+ let rust = compile_in("regr682_host", NativeEmitMode::DesktopBin);
77
+ assert!(
78
+ rust.contains(
79
+ "thread_local! { static __TISH_GF_tally: std::cell::RefCell<Value> = \
80
+ const { std::cell::RefCell::new(Value::Null) }; }"
81
+ ),
82
+ "expected a one-per-static thread_local for `tally`:\n{rust}"
83
+ );
84
+ assert!(
85
+ !run_body(&rust).contains("let tally_cell"),
86
+ "promoted fns keep no `run()` cell:\n{}",
87
+ run_body(&rust)
88
+ );
89
+ }
90
+
91
+ /// A sibling call inside a promoted closure reads the static instead of cloning a `_ref` in — that
92
+ /// clone is a per-closure slot in the SAME frame, so removing it is half the point.
93
+ #[test]
94
+ fn a_sibling_call_reads_the_static_instead_of_capturing_a_ref() {
95
+ for mode in [NativeEmitMode::Gba, NativeEmitMode::DesktopBin] {
96
+ let body = run_body(&compile_in(
97
+ match mode {
98
+ NativeEmitMode::Gba => "regr682_sib_gba",
99
+ _ => "regr682_sib_host",
100
+ },
101
+ mode,
102
+ ));
103
+ assert!(
104
+ body.contains("__TISH_GF_tally.with(|c| (*c.borrow()).clone())"),
105
+ "`twice` must reach `tally` through the static ({mode:?}):\n{body}"
106
+ );
107
+ assert!(
108
+ !body.contains("let tally_ref"),
109
+ "no `_ref` capture clone should survive ({mode:?}):\n{body}"
110
+ );
111
+ }
112
+ }
113
+
114
+ /// A module DATA binding that closures capture moves its CELL into a static. The static holds the
115
+ /// `VmRef`, not the value, so every read/write site is unchanged — what goes away is the `_cell`
116
+ /// clone each enclosing scope had to leave in its frame to thread the cell down.
117
+ #[test]
118
+ fn a_captured_module_binding_takes_its_cell_from_a_static() {
119
+ for (dir, mode) in [
120
+ ("regr682_var_gba", NativeEmitMode::Gba),
121
+ ("regr682_var_host", NativeEmitMode::DesktopBin),
122
+ ] {
123
+ let rust = compile_in(dir, mode);
124
+ let handle = "__TISH_GV_bag.with(|c| c.get_or_init(|| VmRef::new(Value::Null)).clone())";
125
+ assert!(
126
+ rust.contains(handle),
127
+ "`bag`'s cell must come from its static ({mode:?}):\n{rust}"
128
+ );
129
+ let body = run_body(&rust);
130
+ assert!(
131
+ !body.contains("let bag_cell"),
132
+ "no `_cell` clone should be threaded through the frame ({mode:?}):\n{body}"
133
+ );
134
+ assert!(
135
+ body.contains("*bag.borrow_mut()"),
136
+ "the write path is unchanged — it still sees a `VmRef` local ({mode:?}):\n{body}"
137
+ );
138
+ }
139
+ }
140
+
141
+ /// A binding's TYPE is not an eligibility criterion: the static and its accessor are emitted after
142
+ /// `run()`, from the type recorded when the `let` was emitted, so a natively-typed module binding
143
+ /// keeps its native representation and still gets a static — no boxing, no per-read `Value` clone.
144
+ #[test]
145
+ fn a_typed_module_binding_gets_a_typed_static() {
146
+ let rust = compile_in("regr682_typed", NativeEmitMode::Gba);
147
+ assert!(
148
+ rust.contains("VmRef<i32>"),
149
+ "`hits: i32` must keep its native cell type:\n{rust}"
150
+ );
151
+ assert!(
152
+ rust.contains("static __TISH_GV_hits: tishlang_runtime::SingleCore<core::cell::OnceCell<VmRef<i32>>>"),
153
+ "…in a typed static:\n{rust}"
154
+ );
155
+ assert!(
156
+ !run_body(&rust).contains("let hits_cell"),
157
+ "and nothing threads a cell through the frame any more:\n{}",
158
+ run_body(&rust)
159
+ );
160
+ }
161
+
162
+ /// The builtin preamble is built in its own frame. `console`, `Math`, `JSON`, the nine TypedArray
163
+ /// constructors and the rest are ~40 bindings whose `ObjectMap::from([ … ])` pair arrays are large
164
+ /// stack temporaries — 4,192 B of a hello-world's `run()` frame, 13% of the GBA's whole stack,
165
+ /// before a line of the program runs.
166
+ #[test]
167
+ fn the_builtin_preamble_is_built_out_of_line() {
168
+ for (dir, mode) in [
169
+ ("regr682_pre_gba", NativeEmitMode::Gba),
170
+ ("regr682_pre_host", NativeEmitMode::DesktopBin),
171
+ ] {
172
+ let rust = compile_in(dir, mode);
173
+ assert!(
174
+ rust.contains(
175
+ "#[inline(never)] fn __tish_no_inline<T>(f: impl FnOnce() -> T) -> T { f() }"
176
+ ),
177
+ "the shim must be emitted ({mode:?}):\n{rust}"
178
+ );
179
+ let body = run_body(&rust);
180
+ assert!(
181
+ body.contains("= __tish_no_inline(|| {"),
182
+ "the preamble must be built through it ({mode:?}):\n{body}"
183
+ );
184
+ assert!(
185
+ body.contains("console") && body.contains("Math"),
186
+ "…and still bind the builtins ({mode:?}):\n{body}"
187
+ );
188
+ }
189
+ }
@@ -0,0 +1,53 @@
1
+ //! #682 — `TISH_MODULE_STATICS=0` restores the pre-change emission, so a regression can be
2
+ //! bisected without a revert.
3
+ //!
4
+ //! Its own test binary because it sets a process-wide env var: sharing a binary with the other
5
+ //! #682 tests would let it flip their answers mid-run.
6
+
7
+ use std::path::PathBuf;
8
+
9
+ use tishlang_compile::{compile_project_full_emit, NativeEmitMode};
10
+
11
+ #[test]
12
+ fn the_kill_switch_restores_run_locals() {
13
+ let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../target/regr682_off");
14
+ let _ = std::fs::remove_dir_all(&root);
15
+ std::fs::create_dir_all(root.join("pkg")).unwrap();
16
+ std::fs::write(
17
+ root.join("pkg/m.tish"),
18
+ "export let hits: i32 = 0\n\
19
+ export function tally(a: i32): i32 { hits = hits + a; return hits }\n\
20
+ export function twice(a: i32): i32 { return tally(a) + tally(a) }\n",
21
+ )
22
+ .unwrap();
23
+ std::fs::write(
24
+ root.join("main.tish"),
25
+ "import { tally, twice, hits } from './pkg/m.tish'\n\
26
+ function main() { console.log(twice(2) + tally(1) + hits) }\n",
27
+ )
28
+ .unwrap();
29
+
30
+ std::env::set_var("TISH_MODULE_STATICS", "0");
31
+ let entry = root.join("main.tish");
32
+ let rust = compile_project_full_emit(
33
+ &entry,
34
+ entry.parent(),
35
+ &[],
36
+ true,
37
+ NativeEmitMode::Gba,
38
+ None,
39
+ )
40
+ .expect("compile")
41
+ .0;
42
+ std::env::remove_var("TISH_MODULE_STATICS");
43
+
44
+ assert!(
45
+ !rust.contains("static __TISH_GF_"),
46
+ "the kill switch must emit no module-fn statics:\n{rust}"
47
+ );
48
+ let body = rust.split("fn run()").nth(1).expect("run()");
49
+ assert!(
50
+ body.contains("let twice_cell"),
51
+ "…and must restore the `run()` cell:\n{body}"
52
+ );
53
+ }
@@ -0,0 +1,104 @@
1
+ //! #684 — a boxed closure that WRITES an outer module binding must have that binding's cell in
2
+ //! scope.
3
+ //!
4
+ //! The write path emits `*name.borrow_mut() = …` whenever `refcell_wrapped_vars` — a whole-program
5
+ //! prepass — says the binding is a capture cell. The binding it refers to comes from
6
+ //! `outer_vars_stack`, which is built as statements are emitted. When a closure is created ABOVE
7
+ //! the `let`, the two disagree and nothing is in scope:
8
+ //!
9
+ //! ```text
10
+ //! error[E0425]: cannot find value `deadCol` in this scope
11
+ //! ```
12
+ //!
13
+ //! #629's hoist does not reach this shape — it only moves a literal declaration referenced from a
14
+ //! top-level `function`, not one reached through a `let` holding an arrow. The cell is now declared
15
+ //! up front instead, and the `let` assigns into it.
16
+
17
+ use std::path::PathBuf;
18
+
19
+ use tishlang_compile::{compile_project_full_emit, NativeEmitMode};
20
+
21
+ const FIXTURE: &str = "../../tests/regression/closure_writes_module_let_684.tish";
22
+
23
+ /// #682's module statics give the same binding a home that is in scope everywhere, which fixes
24
+ /// this shape too. The pre-declaration below is what covers it everywhere the statics are OFF —
25
+ /// the kill switch, `RustLib`, a program that can run tish on another thread, and any binding a
26
+ /// local elsewhere shadows — so that is the mode these assertions pin. This whole file is its own
27
+ /// test binary, so the env var cannot leak into another test's compile.
28
+ fn rust_for(mode: NativeEmitMode) -> String {
29
+ std::env::set_var("TISH_MODULE_STATICS", "0");
30
+ let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(FIXTURE);
31
+ compile_project_full_emit(&path, path.parent(), &[], true, mode, None)
32
+ .expect("compile")
33
+ .0
34
+ }
35
+
36
+ fn run_body(rust: &str) -> String {
37
+ rust.split("fn run()")
38
+ .nth(1)
39
+ .expect("generated crate must have a run()")
40
+ .to_string()
41
+ }
42
+
43
+ /// The cell is declared before every statement, and the `let` assigns into it rather than
44
+ /// shadowing it with a second `VmRef`.
45
+ #[test]
46
+ fn module_let_written_from_a_closure_above_it_gets_its_cell_first() {
47
+ for mode in [NativeEmitMode::Gba, NativeEmitMode::DesktopBin] {
48
+ let body = run_body(&rust_for(mode));
49
+ let decl = body
50
+ .find("let deadCol: VmRef<Value> = VmRef::new(Value::Null);")
51
+ .unwrap_or_else(|| {
52
+ panic!("deadCol's cell must be pre-declared for {mode:?}; got:\n{body}")
53
+ });
54
+ let write = body
55
+ .find("*deadCol.borrow_mut()")
56
+ .unwrap_or_else(|| panic!("expected a cell write for {mode:?}; got:\n{body}"));
57
+ assert!(
58
+ decl < write,
59
+ "the cell must be declared before the first `*deadCol.borrow_mut()` — that ordering IS \
60
+ the bug ({mode:?}):\n{body}"
61
+ );
62
+ assert!(
63
+ !body.contains("let deadCol = VmRef::new("),
64
+ "a second `let deadCol = VmRef::new(..)` would leave the closures above it writing a \
65
+ cell nothing reads ({mode:?}):\n{body}"
66
+ );
67
+ }
68
+ }
69
+
70
+ /// A binding with no closure above its declaration keeps the ordinary `let x = VmRef::new(init)`
71
+ /// form — the pre-declaration is the residue path, not the default.
72
+ #[test]
73
+ fn a_binding_with_no_forward_capture_is_untouched() {
74
+ let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../target/regr684_plain");
75
+ std::fs::create_dir_all(&dir).unwrap();
76
+ let src = dir.join("main.tish");
77
+ std::fs::write(
78
+ &src,
79
+ "export let deadCol: i32 = -1\n\
80
+ function bump(c: i32) { deadCol = c }\n\
81
+ function main() { bump(3); console.log(deadCol) }\n",
82
+ )
83
+ .unwrap();
84
+ std::env::set_var("TISH_MODULE_STATICS", "0");
85
+ let rust = compile_project_full_emit(
86
+ &src,
87
+ Some(dir.as_path()),
88
+ &[],
89
+ true,
90
+ NativeEmitMode::Gba,
91
+ None,
92
+ )
93
+ .expect("compile")
94
+ .0;
95
+ let body = run_body(&rust);
96
+ assert!(
97
+ body.contains("let deadCol = VmRef::new("),
98
+ "no forward capture here, so the plain declaration must survive:\n{body}"
99
+ );
100
+ assert!(
101
+ !body.contains("let deadCol: VmRef<Value> = VmRef::new(Value::Null);"),
102
+ "pre-declaration must not fire without a forward capture:\n{body}"
103
+ );
104
+ }
@@ -0,0 +1,35 @@
1
+ //! #684 with #682's module statics ON (the default): the binding has a home that is in scope
2
+ //! everywhere, so the closure above its `let` has nothing to capture and nothing to get wrong.
3
+ //!
4
+ //! Its own test binary because its sibling file pins the statics-OFF emission with a process-wide
5
+ //! env var.
6
+
7
+ use std::path::PathBuf;
8
+
9
+ use tishlang_compile::{compile_project_full_emit, NativeEmitMode};
10
+
11
+ #[test]
12
+ fn the_write_resolves_through_the_static() {
13
+ let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
14
+ .join("../../tests/regression/closure_writes_module_let_684.tish");
15
+ for mode in [NativeEmitMode::Gba, NativeEmitMode::DesktopBin] {
16
+ let rust = compile_project_full_emit(&path, path.parent(), &[], true, mode, None)
17
+ .expect("compile")
18
+ .0;
19
+ assert!(
20
+ rust.contains("fn __tish_gv_deadCol()"),
21
+ "`deadCol` should have a static home ({mode:?}):\n{rust}"
22
+ );
23
+ let body = rust.split("fn run()").nth(1).expect("run()");
24
+ let handle = body
25
+ .find("let deadCol = __tish_gv_deadCol();")
26
+ .unwrap_or_else(|| panic!("expected the handle binding ({mode:?}):\n{body}"));
27
+ let write = body
28
+ .find("*deadCol.borrow_mut()")
29
+ .unwrap_or_else(|| panic!("expected the cell write ({mode:?}):\n{body}"));
30
+ assert!(
31
+ handle < write,
32
+ "the handle must be bound before the first write — that ordering IS #684 ({mode:?}):\n{body}"
33
+ );
34
+ }
35
+ }
@@ -0,0 +1,106 @@
1
+ //! Two name-resolution shadowing bugs found via the chuggie-engine examples.
2
+ //!
3
+ //! 1. A LOCAL shadows a same-named MODULE FUNCTION from another bundled module. earshot declared
4
+ //! `let dist: i32` while packages/sfx.tish had a non-exported `function dist(...)`; in
5
+ //! call-argument position the argument lowered to `__TISH_GF_dist.with(...)` — the function
6
+ //! cell — instead of the local, and the callee panicked "expected number" at runtime.
7
+ //!
8
+ //! 2. A USER FUNCTION shadows a BUILTIN of the same name. pong-link's `function serve(toRight)`
9
+ //! (serve is the HTTP builtin) compiled to sibling capture preludes `let serve = serve.clone();`
10
+ //! with no `serve` binding in scope — E0425 at rustc time.
11
+
12
+ use std::path::PathBuf;
13
+
14
+ use tishlang_compile::{compile_project_full_emit, NativeEmitMode};
15
+
16
+ fn fixture(dir: &str, files: &[(&str, &str)]) -> PathBuf {
17
+ let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
18
+ .join("../../target")
19
+ .join(dir);
20
+ let _ = std::fs::remove_dir_all(&root);
21
+ std::fs::create_dir_all(root.join("pkg")).unwrap();
22
+ for (rel, body) in files {
23
+ std::fs::write(root.join(rel), body).unwrap();
24
+ }
25
+ root.join("main.tish")
26
+ }
27
+
28
+ fn compile(dir: &str, files: &[(&str, &str)], mode: NativeEmitMode) -> String {
29
+ let entry = fixture(dir, files);
30
+ compile_project_full_emit(&entry, entry.parent(), &[], true, mode, None)
31
+ .expect("compile")
32
+ .0
33
+ }
34
+
35
+ // ── 1. local shadows a module-internal function of another module ────────────────────────────────
36
+
37
+ const SFX_MOD: &str = "\
38
+ function dist(dx: i32, dy: i32): i32 { return dx + dy }
39
+ export function volumeAt(d: i32): i32 { return 256 - dist(d, 0) }
40
+ ";
41
+
42
+ const SHADOW_ENTRY: &str = "\
43
+ import { volumeAt } from './pkg/sfx.tish'
44
+ function paint() {
45
+ let dist: i32 = 96
46
+ console.log(\"vol \" + volumeAt(dist))
47
+ }
48
+ paint()
49
+ ";
50
+
51
+ #[test]
52
+ fn local_shadows_module_fn_in_argument_position() {
53
+ for (mode, dir) in [
54
+ (NativeEmitMode::Gba, "regr_shadow_dist_gba"),
55
+ (NativeEmitMode::DesktopBin, "regr_shadow_dist_host"),
56
+ ] {
57
+ let rust = compile(
58
+ dir,
59
+ &[("main.tish", SHADOW_ENTRY), ("pkg/sfx.tish", SFX_MOD)],
60
+ mode,
61
+ );
62
+ // paint()'s call must pass the LOCAL, never the module function's cell. The cell read
63
+ // is fine inside volumeAt itself (calling its sibling), so scope the assertion to the
64
+ // argument list of the volumeAt call.
65
+ let call = rust
66
+ .split("__TISH_GF_volumeAt.with(|c| (*c.borrow()).clone())")
67
+ .nth(1)
68
+ .or_else(|| rust.split("volumeAt").nth(1))
69
+ .expect("volumeAt call site");
70
+ let args = &call[..call.len().min(400)];
71
+ assert!(
72
+ !args.contains("__TISH_GF_dist"),
73
+ "{mode:?}: volumeAt's argument resolved to the module fn cell instead of the local:\n{args}"
74
+ );
75
+ }
76
+ }
77
+
78
+ // ── 2. user function shadows a builtin name ──────────────────────────────────────────────────────
79
+
80
+ const SERVE_ENTRY: &str = "\
81
+ function serve(toRight: i32): i32 { return toRight + 1 }
82
+ function resetMatch(): i32 { return serve(1) }
83
+ console.log(\"r \" + resetMatch())
84
+ ";
85
+
86
+ #[test]
87
+ fn user_fn_shadows_builtin_name() {
88
+ for (mode, dir) in [
89
+ (NativeEmitMode::Gba, "regr_shadow_serve_gba"),
90
+ (NativeEmitMode::DesktopBin, "regr_shadow_serve_host"),
91
+ ] {
92
+ let rust = compile(dir, &[("main.tish", SERVE_ENTRY)], mode);
93
+ // The bug emitted `let serve = serve.clone();` with no such binding in scope. If a
94
+ // capture prelude names `serve`, a definition for it must exist (a GF cell or a local
95
+ // `let serve =` initialisation), or rustc dies with E0425.
96
+ if rust.contains("let serve = serve.clone();") {
97
+ assert!(
98
+ rust.contains("__TISH_GF_serve")
99
+ || rust.contains("let serve = Value::native")
100
+ || rust.contains("let serve = {"),
101
+ "{mode:?}: capture prelude references `serve` but nothing defines it (E0425):\n\
102
+ the user declaration must shadow the builtin"
103
+ );
104
+ }
105
+ }
106
+ }
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "tishlang_lint"
3
- version = "3.7.1"
3
+ version = "3.9.1"
4
4
  edition = "2021"
5
5
  description = "AST-based linter for Tish"
6
6
  license-file = { workspace = true }