@tishlang/tish-format 3.3.0 → 3.3.3

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.
@@ -310,8 +310,14 @@ console.log(R.length)
310
310
  #[test]
311
311
  fn a_module_const_array_is_reachable_from_a_lowered_fn_on_gba() {
312
312
  let rust = compile_gba("const_array_gba", CONST_ARRAY_READ);
313
+ // Element type is deliberately NOT pinned here — this test is about REACHABILITY (#594), and an
314
+ // all-integer table is now expected to hoist as `[i32; 8]` on GBA. The representation itself is
315
+ // pinned by `an_integral_module_const_array_is_emitted_as_i32_on_gba`.
313
316
  assert!(
314
- rust.contains("const G_TABLE: [f64; 8]") || rust.contains("const G_table: [f64; 8]"),
317
+ rust.contains("const G_TABLE: [i32; 8]")
318
+ || rust.contains("const G_TABLE: [f64; 8]")
319
+ || rust.contains("const G_table: [i32; 8]")
320
+ || rust.contains("const G_table: [f64; 8]"),
315
321
  "a module const numeric array should get a top-level `const` on GBA (#594):\n{}",
316
322
  rust.lines().filter(|l| l.contains("TABLE")).take(4).collect::<Vec<_>>().join("\n")
317
323
  );
@@ -485,3 +491,401 @@ console.log(R.length)
485
491
  offenders.join("\n ")
486
492
  );
487
493
  }
494
+
495
+ // ── #48/#52-follow-up: the module-const-array hoist was blind to closures ─────────────────────────
496
+ //
497
+ // `collect_module_const_f64_arrays` hoists a module-level numeric array to a top-level
498
+ // `const G_X: [f64; N]` so a LOWERED fn can reach it (that reachability is the whole point of #594).
499
+ // Admission is supposed to reject any array that is written, or used as a value/member, because a
500
+ // `const` is neither mutable nor addressable. The admission walker checks all of that — but its
501
+ // expression arm had no `ArrowFunction` case, so every use inside a CLOSURE fell into `_ => {}` and
502
+ // was invisible. The sibling scalar pass (`walk_expr_for_global_disqualifiers`) has always had that
503
+ // arm; this walker is a copy that lost it.
504
+ //
505
+ // Net effect once the pass was enabled on GBA: an array that a closure writes got hoisted anyway.
506
+ // The read side renamed to `G_X`, the write side kept the boxed local, and the boxed local is
507
+ // declared *after* the closure — so rustc reports `cannot find value` on a name the game never
508
+ // misspelled. Reported against card-gba (queensblood/triad/gwent) and reproduced independently by
509
+ // tish-gba `examples/hyrule` (`rangLive`, weapons.tish).
510
+ //
511
+ // The trigger needs all three ingredients: a module numeric array, a typed top-level fn that reads
512
+ // it (that is what nominates it for hoisting), and a write from inside a closure.
513
+
514
+ /// The reported repro, reduced. Before the fix this emitted `const G_A: [f64; 3]` plus a write
515
+ /// through a bare `A`, and rustc rejected it with E0425.
516
+ const CLOSURE_WRITE: &str = r#"
517
+ let A: i32[] = [1, 2, 3]
518
+
519
+ function readA(i: i32): i32 { return A[i] }
520
+
521
+ export function run(): i32 {
522
+ let bump = () => { A[0] = A[0] + 1 }
523
+ bump()
524
+ return readA(0)
525
+ }
526
+ "#;
527
+
528
+ #[test]
529
+ fn module_array_written_in_closure_is_not_hoisted_to_const() {
530
+ let rust = compile_gba("closure_write", CLOSURE_WRITE);
531
+ // The array is written, so it must stay a runtime binding: no const hoist at all. Renaming the
532
+ // write site would not be enough — a `const` cannot be assigned through.
533
+ assert!(
534
+ !rust.contains("const G_A"),
535
+ "a module array written from a closure must not be hoisted to a const:\n{}",
536
+ rust
537
+ );
538
+ // And the generated program must not reference a hoisted name it never declared.
539
+ assert!(
540
+ !rust.contains("G_A["),
541
+ "no read may be renamed to a hoist that does not exist:\n{}",
542
+ rust
543
+ );
544
+ }
545
+
546
+ /// The second missed case from the same report: `.length` lowers to `.len()`, which is a member use
547
+ /// the walker already rejects — it was just unreachable inside a closure. A hoist here emitted
548
+ /// `A.len()` against a name not in scope.
549
+ const CLOSURE_LENGTH: &str = r#"
550
+ let A: i32[] = [1, 2, 3]
551
+
552
+ function readA(i: i32): i32 { return A[i] }
553
+
554
+ export function run(): i32 {
555
+ let count = () => { return A.length }
556
+ return readA(0) + count()
557
+ }
558
+ "#;
559
+
560
+ #[test]
561
+ fn module_array_member_used_in_closure_is_not_hoisted_to_const() {
562
+ let rust = compile_gba("closure_length", CLOSURE_LENGTH);
563
+ assert!(
564
+ !rust.contains("const G_A"),
565
+ "a module array whose `.length` a closure reads must not be hoisted:\n{}",
566
+ rust
567
+ );
568
+ }
569
+
570
+ /// The win must survive: a read-only array indexed from a closure is still a legal hoist, because
571
+ /// the Index arm deliberately does not count `X[i]` as a disqualifying use of `X`. Without this the
572
+ /// fix would be a blanket "any closure mention disables hoisting" and would give back #594.
573
+ const CLOSURE_READ_ONLY: &str = r#"
574
+ let A: i32[] = [1, 2, 3]
575
+
576
+ function readA(i: i32): i32 { return A[i] }
577
+
578
+ export function run(): i32 {
579
+ let pick = () => { return A[1] }
580
+ return readA(0) + pick()
581
+ }
582
+ "#;
583
+
584
+ #[test]
585
+ fn read_only_module_array_still_hoists_when_a_closure_indexes_it() {
586
+ let rust = compile_gba("closure_read_only", CLOSURE_READ_ONLY);
587
+ assert!(
588
+ rust.contains("G_A"),
589
+ "a read-only module array must still hoist — that reachability is #594:\n{}",
590
+ rust
591
+ );
592
+ }
593
+
594
+ // ── GBA representation: the hoist must not carry the host's f64 onto a CPU with no FPU ───────────
595
+ //
596
+ // ARM7TDMI has no hardware float. Hoisting `let A: i32[] = [1,2,3]` to `const G_A: [f64; 3]` cost
597
+ // 8 bytes per element instead of 4, an `i32 -> f64 -> usize` round trip on every index, and an
598
+ // `f64::NAN` out-of-range sentinel — while DISCARDING the element type the source had already
599
+ // declared. These pin the integer representation.
600
+
601
+ #[test]
602
+ fn an_integral_module_const_array_is_emitted_as_i32_on_gba() {
603
+ let rust = compile_gba("repr_int", CLOSURE_READ_ONLY);
604
+ assert!(
605
+ rust.contains("const G_A: [i32; 3]"),
606
+ "an all-integer module const array must hoist as [i32; N], not [f64; N]:\n{}",
607
+ rust
608
+ );
609
+ assert!(
610
+ !rust.contains("const G_A: [f64; 3]"),
611
+ "the f64 representation must be gone:\n{}",
612
+ rust
613
+ );
614
+ }
615
+
616
+ /// Non-integral values still need f64 — the narrowing is value-driven, not blanket.
617
+ const FRACTIONAL: &str = r#"
618
+ let P: number[] = [0.25, 0.5, 0.25]
619
+
620
+ function readP(i: i32): number { return P[i] }
621
+
622
+ export function run(): number {
623
+ let pick = () => { return P[1] }
624
+ return readP(0) + pick()
625
+ }
626
+ "#;
627
+
628
+ #[test]
629
+ fn a_fractional_module_const_array_stays_f64() {
630
+ let rust = compile_gba("repr_frac", FRACTIONAL);
631
+ assert!(
632
+ rust.contains("const G_P: [f64; 3]"),
633
+ "a fractional array must keep f64 — narrowing it would change its values:\n{}",
634
+ rust
635
+ );
636
+ }
637
+
638
+ /// The out-of-range read still has to answer NaN. There is no `i32` meaning "absent", so the
639
+ /// bounds-checked form widens rather than inventing a sentinel that would read as a real element.
640
+ #[test]
641
+ fn an_unproven_index_still_answers_nan() {
642
+ let rust = compile_gba("repr_int", CLOSURE_READ_ONLY);
643
+ if rust.contains("f64::NAN") {
644
+ assert!(
645
+ rust.contains("as f64"),
646
+ "an i32-backed out-of-range read must widen before yielding NaN:\n{}",
647
+ rust
648
+ );
649
+ }
650
+ }
651
+
652
+ /// The SECOND hole, found only because the first fix left queensblood at 4 errors instead of 12: the
653
+ /// statement walker visited loop and branch BODIES but not their CONDITIONS. `.length` is a member
654
+ /// use the walker already rejects — it was simply never reached in `while (i < X.length)`, which is
655
+ /// how card-gba's `SB_FORCE`/`SB_PRE`/`TN_REWARD` (exported `i32[]` in qb-catalog.tish, consumed by
656
+ /// qb-engine.tish:567 and main.tish:297) stayed eligible and hoisted.
657
+ const COND_LENGTH: &str = r#"
658
+ let A: i32[] = [1, 2, 3]
659
+
660
+ function readA(i: i32): i32 { return A[i] }
661
+
662
+ export function run(): i32 {
663
+ let i: i32 = 0
664
+ let n: i32 = 0
665
+ while (i < A.length) { n = n + readA(i); i = i + 1 }
666
+ return n
667
+ }
668
+ "#;
669
+
670
+ #[test]
671
+ fn a_use_in_a_loop_condition_disqualifies_the_hoist() {
672
+ let rust = compile_gba("cond_length", COND_LENGTH);
673
+ assert!(
674
+ !rust.contains("const G_A"),
675
+ "`.length` in a while-condition is a member use and must block the hoist:\n{}",
676
+ rust
677
+ );
678
+ }
679
+
680
+ /// Same shape, in an `if` condition — that arm had the identical omission.
681
+ const IF_COND: &str = r#"
682
+ let A: i32[] = [1, 2, 3]
683
+
684
+ function readA(i: i32): i32 { return A[i] }
685
+
686
+ export function run(): i32 {
687
+ if (A.length > 2) { return readA(0) }
688
+ return 0
689
+ }
690
+ "#;
691
+
692
+ #[test]
693
+ fn a_use_in_an_if_condition_disqualifies_the_hoist() {
694
+ let rust = compile_gba("if_cond", IF_COND);
695
+ assert!(
696
+ !rust.contains("const G_A"),
697
+ "`.length` in an if-condition must block the hoist:\n{}",
698
+ rust
699
+ );
700
+ }
701
+
702
+ // ── #594 residual, reported from tish-gba examples/hyrule ─────────────────────────────────────────
703
+ //
704
+ // Reported shape: a fn with NO PARAMS and NO RETURN that WRITES a module array, and IS CALLED. On
705
+ // 3.3.0 that emitted the new `vm_read(&CELL)` module-scope capability without bringing the cell into
706
+ // scope. Two facts from the report narrow it and are both encoded below:
707
+ // - the identical shape with NO callers compiles, because it is dead-code eliminated, so a repro
708
+ // MUST contain a call site;
709
+ // - inlining the body at the call site is a complete workaround.
710
+ //
711
+ // This is the `rangDone()` shape in hyrule's weapons.tish (`export function rangDone() { rangLive[0] = 0 }`),
712
+ // which is the same `rangLive` E0425 that started this investigation.
713
+
714
+ /// No params, no return, writes a module array, and — critically — is called.
715
+ const WRITER_WITH_CALLER: &str = r#"
716
+ let A: i32[] = [0, 0, 0]
717
+
718
+ function readA(i: i32): i32 { return A[i] }
719
+
720
+ function clearA() { A[0] = 0 }
721
+
722
+ export function run(): i32 {
723
+ clearA()
724
+ return readA(0)
725
+ }
726
+ "#;
727
+
728
+ #[test]
729
+ fn a_called_no_param_no_return_module_array_writer_compiles() {
730
+ let rust = compile_gba("writer_with_caller", WRITER_WITH_CALLER);
731
+ // The array is written, so it must NOT have been hoisted…
732
+ assert!(
733
+ !rust.contains("const G_A"),
734
+ "a written module array must not be hoisted:\n{}",
735
+ rust
736
+ );
737
+ // …and no lowered free fn may reference the boxed cell, which lives in `run()`. A free `fn`
738
+ // mentioning `A` is precisely the E0425 the report describes.
739
+ for (i, line) in rust.lines().enumerate() {
740
+ let t = line.trim_start();
741
+ if t.starts_with("fn ") || t.starts_with("pub fn ") {
742
+ assert!(
743
+ !t.contains("clearA") || t.contains("_cell"),
744
+ "clearA must not be emitted as a free fn that cannot see module scope (line {}): {}",
745
+ i + 1,
746
+ t
747
+ );
748
+ }
749
+ }
750
+ }
751
+
752
+ // ── #600: `let s: string = strArr[i]` moves out of the Vec ────────────────────────────────────────
753
+ const STR_ELEM_BIND: &str = r#"
754
+ let NAMES: string[] = ["alpha", "beta"]
755
+
756
+ export function show(i: i32): string {
757
+ let name: string = NAMES[i]
758
+ return name
759
+ }
760
+ "#;
761
+
762
+ #[test]
763
+ fn binding_a_string_element_clones_instead_of_moving() {
764
+ let rust = compile_gba("str_elem_bind", STR_ELEM_BIND);
765
+ let bad = rust.lines().any(|l| {
766
+ let t = l.trim();
767
+ t.contains("= NAMES[") && !t.contains(".clone()") && !t.contains("&NAMES[")
768
+ });
769
+ assert!(
770
+ !bad,
771
+ "a string element read must clone — a bare index is a move out of the Vec (E0507):\n{}",
772
+ rust.lines().filter(|l| l.contains("NAMES[")).take(4).collect::<Vec<_>>().join("\n")
773
+ );
774
+ }
775
+
776
+ // ── #599: a written module-level `let x: i32` ────────────────────────────────────────────────────
777
+ const MODULE_SCALAR_WRITE: &str = r#"
778
+ let counter: i32 = 0
779
+
780
+ function bump() { counter = counter + 1 }
781
+
782
+ export function run(): i32 {
783
+ bump()
784
+ return counter
785
+ }
786
+ "#;
787
+
788
+ #[test]
789
+ fn a_written_module_scalar_reads_and_writes_through_the_same_cell() {
790
+ let rust = compile_gba("module_scalar_write", MODULE_SCALAR_WRITE);
791
+ // If reads go through a cell, writes must too. A bare `counter = <i32>` against a VmRef<i32>
792
+ // is E0308 — the two halves disagreeing is the bug.
793
+ for line in rust.lines() {
794
+ let t = line.trim();
795
+ if t.starts_with("counter = ") && !t.contains("vm_write") {
796
+ panic!(
797
+ "write must match the read's representation (#599):\n{}",
798
+ rust.lines().filter(|l| l.contains("counter")).take(6).collect::<Vec<_>>().join("\n")
799
+ );
800
+ }
801
+ }
802
+ }
803
+
804
+ // ── #597: a typed array passed to a function is COPIED, not aliased ───────────────────────────────
805
+ //
806
+ // `let reqs: i32[] = []` lowers to `Vec<i32>`, but a call site marshals a FRESH `Value::Array` from
807
+ // its elements. The callee's pushes land in the copy and the caller's array stays empty — silently,
808
+ // with no error at compile time or run time. An UNTYPED array passes the same `VmRef` and aliases
809
+ // correctly, so adding an annotation changes program behaviour rather than just representation.
810
+ //
811
+ // The fix cannot live at the call site: building a `Value::Array` out of a `Vec<i32>` is a copy by
812
+ // construction. An array that escapes into a boxed call has to stay boxed.
813
+ const ARRAY_ESCAPES: &str = r#"
814
+ function fill(out) {
815
+ out.push(1)
816
+ }
817
+
818
+ export function run(): i32 {
819
+ let reqs: i32[] = []
820
+ fill(reqs)
821
+ return reqs.length
822
+ }
823
+ "#;
824
+
825
+ #[test]
826
+ fn a_typed_array_passed_to_a_function_is_not_copied() {
827
+ let rust = compile_gba("array_escapes", ARRAY_ESCAPES);
828
+ let copied = rust.lines().find(|l| {
829
+ l.contains("Value::Array(VmRef::new(") && l.contains("reqs") && l.contains(".iter()")
830
+ });
831
+ assert!(
832
+ copied.is_none(),
833
+ "a typed array passed to a fn was marshalled as a fresh copy — the callee's writes are \
834
+ silently lost (#597):\n {}",
835
+ copied.unwrap_or("")
836
+ );
837
+ }
838
+
839
+ // ── #629: module bindings are emitted in SOURCE ORDER — a fn referencing a LATER binding breaks ───
840
+ //
841
+ // The variable is ORDER, nothing else. Module-level bindings are emitted into `run()` where they
842
+ // appear, but the function values that capture them are emitted at their own source position, so a
843
+ // function declared ABOVE its binding captures a name that is not in scope yet:
844
+ //
845
+ // let rangDone = { Value::native(move |args| { … vm_read(&rangLive) … }) }; // use
846
+ // let rangLive = VmRef::new(vec![(0_f64) as i32]); // declaration
847
+ //
848
+ // This is legal tish — the function is not CALLED before the binding is initialised.
849
+ //
850
+ // The fixture pinned in 57d6aa1be for this got it wrong: it declared `let A` FIRST, so it passed
851
+ // while the bug was live and proved nothing. Moving the declaration is the whole difference, which
852
+ // is why the earlier reduction "reproduced" on neither compiler. Hits readers and writers alike.
853
+ const FORWARD_REF: &str = r#"
854
+ function readA(i: i32): i32 { return A[i] }
855
+ function clearA() { A[0] = 0 }
856
+
857
+ let A: i32[] = [0, 0, 0]
858
+
859
+ export function run(): i32 {
860
+ clearA()
861
+ return readA(0)
862
+ }
863
+ "#;
864
+
865
+ #[test]
866
+ fn a_fn_may_reference_a_module_binding_declared_after_it() {
867
+ let rust = compile_gba("forward_ref", FORWARD_REF);
868
+ // Find where `A` is DECLARED and where it is first USED. A use above the declaration is the bug.
869
+ let decl = rust
870
+ .lines()
871
+ .position(|l| {
872
+ let t = l.trim();
873
+ t.starts_with("let A = ") || t.starts_with("let A:") || t.starts_with("let mut A")
874
+ });
875
+ let first_use = rust.lines().position(|l| {
876
+ let t = l.trim();
877
+ (t.contains("vm_read(&A)") || t.contains("(&A)") || t.contains("A.borrow"))
878
+ && !t.starts_with("let A")
879
+ });
880
+ if let (Some(d), Some(u)) = (decl, first_use) {
881
+ assert!(
882
+ u > d,
883
+ "#629: `A` is used at line {} but only declared at line {} — use before declaration \
884
+ (E0425). Module bindings must be emitted ahead of the function values that capture \
885
+ them.\n{}",
886
+ u + 1,
887
+ d + 1,
888
+ rust.lines().skip(d.saturating_sub(6)).take(12).collect::<Vec<_>>().join("\n")
889
+ );
890
+ }
891
+ }
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "tishlang_fmt"
3
- version = "3.3.0"
3
+ version = "3.3.3"
4
4
  edition = "2021"
5
5
  description = "Opinionated formatter for Tish source (parse → pretty-print)"
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.3.0",
3
+ "version": "3.3.3",
4
4
  "description": "Tish code formatter",
5
5
  "license": "PIF",
6
6
  "repository": {
Binary file
Binary file
Binary file
Binary file
Binary file