@tishlang/tish 1.13.2 → 2.0.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.
Files changed (106) hide show
  1. package/Cargo.toml +2 -0
  2. package/bin/tish +0 -0
  3. package/crates/js_to_tish/src/transform/expr.rs +1 -0
  4. package/crates/tish/Cargo.toml +11 -3
  5. package/crates/tish/build.rs +21 -0
  6. package/crates/tish/src/cli_help.rs +15 -4
  7. package/crates/tish/src/main.rs +93 -21
  8. package/crates/tish/src/repl_completion.rs +0 -1
  9. package/crates/tish/tests/error_source_location.rs +36 -0
  10. package/crates/tish/tests/fixtures/runtime_error_location.tish +5 -0
  11. package/crates/tish/tests/fixtures/trycatch_runtime_errors.tish +15 -0
  12. package/crates/tish/tests/fixtures/tty_capability.tish +9 -0
  13. package/crates/tish/tests/integration_test.rs +402 -91
  14. package/crates/tish/tests/trycatch_runtime_errors.rs +45 -0
  15. package/crates/tish/tests/tty_capability.rs +43 -0
  16. package/crates/tish_ast/src/ast.rs +37 -8
  17. package/crates/tish_builtins/Cargo.toml +2 -0
  18. package/crates/tish_builtins/src/array.rs +375 -13
  19. package/crates/tish_builtins/src/collections.rs +481 -0
  20. package/crates/tish_builtins/src/construct.rs +59 -19
  21. package/crates/tish_builtins/src/date.rs +538 -0
  22. package/crates/tish_builtins/src/globals.rs +86 -6
  23. package/crates/tish_builtins/src/iterator.rs +129 -0
  24. package/crates/tish_builtins/src/lib.rs +5 -0
  25. package/crates/tish_builtins/src/number.rs +96 -0
  26. package/crates/tish_builtins/src/object.rs +2 -2
  27. package/crates/tish_builtins/src/string.rs +19 -20
  28. package/crates/tish_builtins/src/symbol.rs +1 -1
  29. package/crates/tish_builtins/src/typedarrays.rs +298 -0
  30. package/crates/tish_bytecode/src/chunk.rs +69 -1
  31. package/crates/tish_bytecode/src/compiler.rs +933 -89
  32. package/crates/tish_bytecode/src/encoding.rs +2 -0
  33. package/crates/tish_bytecode/src/lib.rs +2 -1
  34. package/crates/tish_bytecode/src/opcode.rs +47 -4
  35. package/crates/tish_bytecode/src/serialize.rs +31 -1
  36. package/crates/tish_compile/Cargo.toml +1 -0
  37. package/crates/tish_compile/src/check.rs +774 -0
  38. package/crates/tish_compile/src/codegen.rs +2334 -349
  39. package/crates/tish_compile/src/infer.rs +1395 -6
  40. package/crates/tish_compile/src/lib.rs +50 -8
  41. package/crates/tish_compile/src/resolve.rs +584 -21
  42. package/crates/tish_compile/src/types.rs +106 -2
  43. package/crates/tish_compile_js/src/codegen.rs +67 -0
  44. package/crates/tish_compile_js/src/tests_jsx.rs +64 -0
  45. package/crates/tish_core/Cargo.toml +7 -1
  46. package/crates/tish_core/src/console_style.rs +11 -1
  47. package/crates/tish_core/src/json.rs +81 -38
  48. package/crates/tish_core/src/lib.rs +3 -0
  49. package/crates/tish_core/src/shape.rs +85 -0
  50. package/crates/tish_core/src/value.rs +679 -25
  51. package/crates/tish_core/src/vmref.rs +13 -8
  52. package/crates/tish_cranelift/src/link.rs +17 -4
  53. package/crates/tish_cranelift_runtime/Cargo.toml +1 -0
  54. package/crates/tish_eval/Cargo.toml +6 -0
  55. package/crates/tish_eval/src/eval.rs +665 -117
  56. package/crates/tish_eval/src/http.rs +4 -1
  57. package/crates/tish_eval/src/natives.rs +165 -13
  58. package/crates/tish_eval/src/value.rs +31 -13
  59. package/crates/tish_eval/src/value_convert.rs +10 -4
  60. package/crates/tish_ffi/Cargo.toml +26 -0
  61. package/crates/tish_ffi/src/lib.rs +518 -0
  62. package/crates/tish_ffi/tests/fixtures/testmod/Cargo.toml +18 -0
  63. package/crates/tish_ffi/tests/fixtures/testmod/src/lib.rs +46 -0
  64. package/crates/tish_ffi/tests/loader.rs +65 -0
  65. package/crates/tish_fmt/src/lib.rs +61 -5
  66. package/crates/tish_lexer/src/lib.rs +397 -9
  67. package/crates/tish_lexer/src/token.rs +7 -0
  68. package/crates/tish_lint/src/lib.rs +2 -10
  69. package/crates/tish_lsp/src/import_goto.rs +2 -0
  70. package/crates/tish_lsp/src/main.rs +439 -26
  71. package/crates/tish_native/src/build.rs +55 -1
  72. package/crates/tish_opt/src/lib.rs +126 -23
  73. package/crates/tish_parser/src/lib.rs +55 -1
  74. package/crates/tish_parser/src/parser.rs +456 -34
  75. package/crates/tish_pg/src/lib.rs +3 -3
  76. package/crates/tish_resolve/src/lib.rs +99 -59
  77. package/crates/tish_runtime/Cargo.toml +4 -0
  78. package/crates/tish_runtime/src/http.rs +66 -17
  79. package/crates/tish_runtime/src/http_fetch.rs +29 -8
  80. package/crates/tish_runtime/src/http_hyper.rs +25 -2
  81. package/crates/tish_runtime/src/lib.rs +299 -44
  82. package/crates/tish_runtime/src/promise.rs +328 -18
  83. package/crates/tish_runtime/src/timers.rs +13 -7
  84. package/crates/tish_runtime/src/tty.rs +226 -0
  85. package/crates/tish_runtime/src/ws.rs +35 -18
  86. package/crates/tish_runtime/tests/fetch_readable_stream.rs +2 -2
  87. package/crates/tish_ui/src/jsx.rs +10 -0
  88. package/crates/tish_ui/src/runtime/hooks.rs +19 -15
  89. package/crates/tish_ui/src/runtime/mod.rs +15 -12
  90. package/crates/tish_vm/Cargo.toml +14 -1
  91. package/crates/tish_vm/src/jit.rs +1050 -0
  92. package/crates/tish_vm/src/lib.rs +2 -0
  93. package/crates/tish_vm/src/vm.rs +1546 -202
  94. package/crates/tish_vm/tests/concurrent_shared_state.rs +140 -0
  95. package/crates/tish_wasm/src/lib.rs +6 -2
  96. package/crates/tish_wasm_runtime/src/gpu.rs +17 -1
  97. package/crates/tishlang_cargo_bindgen/src/classify.rs +1 -3
  98. package/crates/tishlang_cargo_bindgen/src/lib.rs +2 -2
  99. package/crates/tishlang_cargo_bindgen/src/metadata.rs +1 -1
  100. package/justfile +8 -0
  101. package/package.json +1 -1
  102. package/platform/darwin-arm64/tish +0 -0
  103. package/platform/darwin-x64/tish +0 -0
  104. package/platform/linux-arm64/tish +0 -0
  105. package/platform/linux-x64/tish +0 -0
  106. package/platform/win32-x64/tish.exe +0 -0
@@ -16,6 +16,7 @@ const RUNTIME_CARGO_FEATURES: &[&str] = &[
16
16
  "process",
17
17
  "regex",
18
18
  "ws",
19
+ "tty",
19
20
  ];
20
21
 
21
22
  /// Map CLI/compile features to flags passed to `tishlang_runtime` in the temp crate's Cargo.toml.
@@ -71,6 +72,32 @@ fn inject_generated_native_mod(rust_code: &str) -> String {
71
72
  }
72
73
  }
73
74
 
75
+ /// Whether to embed mimalloc as the `#[global_allocator]` of rust-AOT BINARY output. tish workloads
76
+ /// are allocation-bound (a sampling profile of object/array code spends most time in malloc/free — see
77
+ /// `docs/perf.md`); mimalloc gives ~20% on object/array/bundle code, the same lever as the `tish` CLI's
78
+ /// own `fast-alloc` and the reason JSC ships bmalloc. Default ON; `TISH_NATIVE_FAST_ALLOC=0` opts out
79
+ /// (e.g. a target whose C toolchain can't build mimalloc). Callers also skip it for staticlib output (a
80
+ /// library does not own the final program's allocator) and cross builds (avoid cross-compiling C).
81
+ fn fast_alloc_enabled() -> bool {
82
+ std::env::var("TISH_NATIVE_FAST_ALLOC")
83
+ .map(|v| v != "0")
84
+ .unwrap_or(true)
85
+ }
86
+
87
+ /// Insert a mimalloc `#[global_allocator]` into the generated crate root, after the leading
88
+ /// `#![allow(...)]` inner attribute (mirrors [`inject_generated_native_mod`]; an inner attribute must
89
+ /// precede any item, and the codegen emits exactly one — `#![allow(unused, non_snake_case)]`).
90
+ fn inject_global_allocator(rust_code: &str) -> String {
91
+ const STMT: &str =
92
+ "#[global_allocator]\nstatic TISH_GLOBAL_ALLOC: mimalloc::MiMalloc = mimalloc::MiMalloc;\n\n";
93
+ if let Some(pos) = rust_code.find("\n\n") {
94
+ let (a, b) = rust_code.split_at(pos + 2);
95
+ format!("{a}{STMT}{b}")
96
+ } else {
97
+ format!("{rust_code}\n\n{STMT}")
98
+ }
99
+ }
100
+
74
101
  pub(crate) fn rust_code_needs_tokio(rust_code: &str) -> bool {
75
102
  rust_code.contains("#[tokio::main]") || rust_code.contains("tokio::runtime::Runtime")
76
103
  }
@@ -96,6 +123,7 @@ pub fn build_via_cargo(
96
123
  )
97
124
  }
98
125
 
126
+ #[allow(clippy::too_many_arguments)] // orthogonal cargo build inputs; bundling would just relocate the same fields
99
127
  pub fn build_via_cargo_with_config(
100
128
  rust_code: &str,
101
129
  native_modules: Vec<ResolvedNativeModule>,
@@ -140,7 +168,7 @@ pub fn build_via_cargo_with_config(
140
168
  .collect();
141
169
 
142
170
  let mut more_deps = String::new();
143
- more_deps.push_str(&tokio_dep);
171
+ more_deps.push_str(tokio_dep);
144
172
  if !native_deps.is_empty() {
145
173
  more_deps.push_str(&format!("\n{}", native_deps));
146
174
  }
@@ -154,6 +182,21 @@ pub fn build_via_cargo_with_config(
154
182
  rust_code.to_string()
155
183
  };
156
184
 
185
+ // mimalloc as the program's global allocator — binary output only (a staticlib does not own the
186
+ // allocator), native only (don't cross-compile mimalloc's C). Adds one cached dep + a global_alloc
187
+ // statement; semantically transparent. `TISH_NATIVE_FAST_ALLOC=0` opts out.
188
+ let use_fast_alloc = fast_alloc_enabled()
189
+ && build_config.artifact != NativeArtifact::StaticLib
190
+ && build_config.cargo_target.is_none();
191
+ if use_fast_alloc {
192
+ more_deps.push_str("\nmimalloc = \"0.1\"\n");
193
+ }
194
+ let rust_main = if use_fast_alloc {
195
+ inject_global_allocator(&rust_main)
196
+ } else {
197
+ rust_main
198
+ };
199
+
157
200
  let tish_ui_path = std::path::Path::new(&runtime_path)
158
201
  .parent()
159
202
  .ok_or_else(|| "invalid tishlang_runtime path (no parent)".to_string())?
@@ -258,6 +301,7 @@ tishlang_runtime = {{ path = {:?}{} }}
258
301
  ///
259
302
  /// `bins` order must match `outputs`: each `(stem, rust_code, generated_native_rs)` pairs with
260
303
  /// `outputs[i].0` (entry path — used only for validation) and `outputs[i].1` (final binary path).
304
+ #[allow(clippy::too_many_arguments)] // orthogonal batch-build inputs (bins/outputs/modules/flags)
261
305
  pub(crate) fn build_many_via_cargo(
262
306
  bins: Vec<(String, String, Option<String>)>,
263
307
  native_modules: Vec<ResolvedNativeModule>,
@@ -322,6 +366,11 @@ pub(crate) fn build_many_via_cargo(
322
366
  if !extra_dependencies_toml.trim().is_empty() {
323
367
  more_deps.push_str(&format!("\n{}", extra_dependencies_toml));
324
368
  }
369
+ // mimalloc global allocator for every binary in the batch (all are executables, always native here).
370
+ let use_fast_alloc = fast_alloc_enabled();
371
+ if use_fast_alloc {
372
+ more_deps.push_str("\nmimalloc = \"0.1\"\n");
373
+ }
325
374
 
326
375
  let tish_ui_path = std::path::Path::new(&runtime_path)
327
376
  .parent()
@@ -346,6 +395,11 @@ pub(crate) fn build_many_via_cargo(
346
395
  } else {
347
396
  rust_code.clone()
348
397
  };
398
+ let rust_main = if use_fast_alloc {
399
+ inject_global_allocator(&rust_main)
400
+ } else {
401
+ rust_main
402
+ };
349
403
 
350
404
  fs::write(bin_dir.join("main.rs"), rust_main)
351
405
  .map_err(|e| format!("write main.rs for {}: {}", stem, e))?;
@@ -23,6 +23,10 @@ fn optimize_statement(stmt: &Statement) -> Statement {
23
23
  span: *span,
24
24
  }
25
25
  }
26
+ Statement::Multi { statements, span } => Statement::Multi {
27
+ statements: statements.iter().map(optimize_statement).collect(),
28
+ span: *span,
29
+ },
26
30
  Statement::VarDecl {
27
31
  name,
28
32
  name_span,
@@ -444,6 +448,10 @@ fn optimize_expr(expr: &Expr) -> Expr {
444
448
  operand: Box::new(optimize_expr(operand)),
445
449
  span: *span,
446
450
  },
451
+ Expr::Delete { target, span } => Expr::Delete {
452
+ target: Box::new(optimize_expr(target)),
453
+ span: *span,
454
+ },
447
455
  Expr::PostfixInc { .. }
448
456
  | Expr::PostfixDec { .. }
449
457
  | Expr::PrefixInc { .. }
@@ -554,19 +562,70 @@ fn literal_strict_eq(a: &Literal, b: &Literal) -> bool {
554
562
  }
555
563
  }
556
564
 
565
+ /// JS `Number.prototype.toString` (radix 10). **Kept byte-for-byte in sync with
566
+ /// `tishlang_core::js_number_to_string`** so a constant-folded `"" + n` here matches the
567
+ /// runtime conversion there; `tish_opt` deliberately does not depend on `tish_core` (it is a
568
+ /// lean AST pass), hence the small duplication of this fixed-spec algorithm. See that function
569
+ /// for the full commentary.
570
+ fn js_number_to_string(value: f64) -> String {
571
+ if value.is_nan() {
572
+ return "NaN".to_string();
573
+ }
574
+ if value == f64::INFINITY {
575
+ return "Infinity".to_string();
576
+ }
577
+ if value == f64::NEG_INFINITY {
578
+ return "-Infinity".to_string();
579
+ }
580
+ if value == 0.0 {
581
+ return if value.is_sign_negative() { "-0" } else { "0" }.to_string();
582
+ }
583
+ let negative = value < 0.0;
584
+ let sci = format!("{:e}", value.abs());
585
+ let (mantissa, exp_str) = sci
586
+ .split_once('e')
587
+ .expect("LowerExp formatting always contains 'e'");
588
+ let exp: i32 = exp_str
589
+ .parse()
590
+ .expect("LowerExp exponent is a valid integer");
591
+ let digits: String = mantissa.chars().filter(|&c| c != '.').collect();
592
+ let k = digits.len() as i32;
593
+ let point = exp + 1;
594
+
595
+ let mut out = String::new();
596
+ if negative {
597
+ out.push('-');
598
+ }
599
+ if k <= point && point <= 21 {
600
+ out.push_str(&digits);
601
+ out.push_str(&"0".repeat((point - k) as usize));
602
+ } else if 0 < point && point <= 21 {
603
+ out.push_str(&digits[..point as usize]);
604
+ out.push('.');
605
+ out.push_str(&digits[point as usize..]);
606
+ } else if -6 < point && point <= 0 {
607
+ out.push_str("0.");
608
+ out.push_str(&"0".repeat((-point) as usize));
609
+ out.push_str(&digits);
610
+ } else {
611
+ let e = point - 1;
612
+ out.push_str(&digits[..1]);
613
+ if k > 1 {
614
+ out.push('.');
615
+ out.push_str(&digits[1..]);
616
+ }
617
+ out.push('e');
618
+ out.push(if e >= 0 { '+' } else { '-' });
619
+ out.push_str(&e.abs().to_string());
620
+ }
621
+ out
622
+ }
623
+
557
624
  fn literal_to_display_string(lit: &Literal) -> String {
558
625
  match lit {
559
- Literal::Number(n) => {
560
- if n.is_nan() {
561
- "NaN".to_string()
562
- } else if *n == f64::INFINITY {
563
- "Infinity".to_string()
564
- } else if *n == f64::NEG_INFINITY {
565
- "-Infinity".to_string()
566
- } else {
567
- n.to_string()
568
- }
569
- }
626
+ // Must match the runtime exactly so constant-folded `"" + n` agrees with the
627
+ // unfolded path (see `js_number_to_string` below).
628
+ Literal::Number(n) => js_number_to_string(*n),
570
629
  Literal::String(s) => s.to_string(),
571
630
  Literal::Bool(b) => b.to_string(),
572
631
  Literal::Null => "null".to_string(),
@@ -708,6 +767,41 @@ fn try_algebraic_simplify(
708
767
  None
709
768
  }
710
769
 
770
+ // JS ToInt32/ToUint32 for the constant folder. NaN/±Infinity → 0 (`f64 as i64` saturates, so a
771
+ // folded `(1e308 * 10) | 0` would otherwise give -1 while the runtime gives 0). `tish_opt` has no
772
+ // `tish_core` dep, so these mirror `tishlang_core::to_int32`/`to_uint32` (kept in sync, pure spec).
773
+ #[inline]
774
+ fn fold_to_int32(x: f64) -> i32 {
775
+ if x.is_finite() {
776
+ x as i64 as i32
777
+ } else {
778
+ 0
779
+ }
780
+ }
781
+ #[inline]
782
+ fn fold_to_uint32(x: f64) -> u32 {
783
+ if x.is_finite() {
784
+ x as i64 as u32
785
+ } else {
786
+ 0
787
+ }
788
+ }
789
+
790
+ /// Constant-fold a relational comparison (`<` `<=` `>` `>=`). Two string literals
791
+ /// compare lexicographically; otherwise the numeric coercions `ln`/`rn` are used.
792
+ /// `pred` maps the `Ordering` to a bool; a NaN-involved numeric comparison has no
793
+ /// ordering and is `false` — matching the VM/interp/native runtime exactly.
794
+ fn fold_relational<F>(left: &Literal, right: &Literal, ln: f64, rn: f64, pred: F) -> bool
795
+ where
796
+ F: FnOnce(std::cmp::Ordering) -> bool,
797
+ {
798
+ let ord = match (left, right) {
799
+ (Literal::String(a), Literal::String(b)) => Some(a.as_ref().cmp(b.as_ref())),
800
+ _ => ln.partial_cmp(&rn),
801
+ };
802
+ ord.map(pred).unwrap_or(false)
803
+ }
804
+
711
805
  fn try_fold_binop(left: &Literal, op: BinOp, right: &Literal) -> Option<Literal> {
712
806
  use BinOp::*;
713
807
  let ln = literal_as_number(left);
@@ -729,24 +823,33 @@ fn try_fold_binop(left: &Literal, op: BinOp, right: &Literal) -> Option<Literal>
729
823
  }
730
824
  Sub => Literal::Number(ln - rn),
731
825
  Mul => Literal::Number(ln * rn),
732
- Div => Literal::Number(if rn == 0.0 { f64::NAN } else { ln / rn }),
733
- Mod => Literal::Number(if rn == 0.0 { f64::NAN } else { ln % rn }),
826
+ // IEEE division/remainder, matching JS + the VM's `eval_binop` + interp + rust-AOT:
827
+ // `5/0` Infinity, `-5/0` → -Infinity, `0/0` NaN, `5%0` NaN. The former
828
+ // `if rn == 0.0 { NaN }` folded `5/0` to NaN at compile time, diverging from every runtime
829
+ // path (which all produce Infinity) — a constant-fold-vs-runtime inconsistency.
830
+ Div => Literal::Number(ln / rn),
831
+ Mod => Literal::Number(ln % rn),
734
832
  Pow => Literal::Number(ln.powf(rn)),
735
833
  Eq => Literal::Bool(literal_strict_eq(left, right)),
736
834
  Ne => Literal::Bool(!literal_strict_eq(left, right)),
737
835
  StrictEq => Literal::Bool(literal_strict_eq(left, right)),
738
836
  StrictNe => Literal::Bool(!literal_strict_eq(left, right)),
739
- Lt => Literal::Bool(ln < rn),
740
- Le => Literal::Bool(ln <= rn),
741
- Gt => Literal::Bool(ln > rn),
742
- Ge => Literal::Bool(ln >= rn),
837
+ // Relational ops fold lexicographically when BOTH operands are string
838
+ // literals (JS semantics — must match the VM/interp/native runtime), else
839
+ // numerically. A NaN-involved numeric comparison is always false.
840
+ Lt => Literal::Bool(fold_relational(left, right, ln, rn, |o| o.is_lt())),
841
+ Le => Literal::Bool(fold_relational(left, right, ln, rn, |o| o.is_le())),
842
+ Gt => Literal::Bool(fold_relational(left, right, ln, rn, |o| o.is_gt())),
843
+ Ge => Literal::Bool(fold_relational(left, right, ln, rn, |o| o.is_ge())),
743
844
  And => Literal::Bool(literal_is_truthy(left) && literal_is_truthy(right)),
744
845
  Or => Literal::Bool(literal_is_truthy(left) || literal_is_truthy(right)),
745
- BitAnd => Literal::Number((ln as i32 & rn as i32) as f64),
746
- BitOr => Literal::Number((ln as i32 | rn as i32) as f64),
747
- BitXor => Literal::Number((ln as i32 ^ rn as i32) as f64),
748
- Shl => Literal::Number(((ln as i32) << (rn as i32)) as f64),
749
- Shr => Literal::Number(((ln as i32) >> (rn as i32)) as f64),
846
+ // ToInt32/ToUint32 (modulo 2³², NaN/±Infinity 0), matching the VM/interp exactly.
847
+ BitAnd => Literal::Number((fold_to_int32(ln) & fold_to_int32(rn)) as f64),
848
+ BitOr => Literal::Number((fold_to_int32(ln) | fold_to_int32(rn)) as f64),
849
+ BitXor => Literal::Number((fold_to_int32(ln) ^ fold_to_int32(rn)) as f64),
850
+ Shl => Literal::Number(fold_to_int32(ln).wrapping_shl(fold_to_uint32(rn)) as f64),
851
+ Shr => Literal::Number(fold_to_int32(ln).wrapping_shr(fold_to_uint32(rn)) as f64),
852
+ UShr => Literal::Number(fold_to_uint32(ln).wrapping_shr(fold_to_uint32(rn)) as f64),
750
853
  In => return None, // Requires object/array on right
751
854
  };
752
855
  Some(result)
@@ -936,7 +1039,7 @@ fn try_fold_unary(op: UnaryOp, operand: &Literal) -> Option<Literal> {
936
1039
  Not => Literal::Bool(!literal_is_truthy(operand)),
937
1040
  Neg => Literal::Number(-literal_as_number(operand)),
938
1041
  Pos => Literal::Number(literal_as_number(operand)),
939
- BitNot => Literal::Number(!(literal_as_number(operand) as i32) as f64),
1042
+ BitNot => Literal::Number(!fold_to_int32(literal_as_number(operand)) as f64),
940
1043
  Void => Literal::Null,
941
1044
  };
942
1045
  Some(result)
@@ -6,9 +6,22 @@ use parser::Parser;
6
6
 
7
7
  use tishlang_ast::Program;
8
8
  use tishlang_lexer::Lexer;
9
+ pub use tishlang_lexer::LexerOptions;
9
10
 
11
+ /// Parse `source`, reading lexer options from the environment (e.g. `TISH_IGNORE_INDENT=1`
12
+ /// to ignore indentation syntax). Every backend funnels through here, so the env toggle
13
+ /// reaches run/build/dump-ast/fmt/lint/lsp uniformly.
10
14
  pub fn parse(source: &str) -> Result<Program, String> {
11
- let lexer = Lexer::new(source);
15
+ parse_with_options(source, LexerOptions::from_env())
16
+ }
17
+
18
+ /// Parse with explicit lexer options, bypassing the environment.
19
+ ///
20
+ /// With `LexerOptions { ignore_indent: true }`, indentation is treated as ordinary
21
+ /// whitespace and blocks must be brace-delimited — useful for debugging how nested
22
+ /// blocks transpile, since fully brace-delimited code parses identically either way.
23
+ pub fn parse_with_options(source: &str, options: LexerOptions) -> Result<Program, String> {
24
+ let lexer = Lexer::with_options(source, options);
12
25
  let tokens: Result<Vec<_>, _> = lexer.collect();
13
26
  let tokens = tokens?;
14
27
  let mut parser = Parser::new(&tokens);
@@ -329,4 +342,45 @@ mod tests {
329
342
  assert!(matches!(stmts[1], Statement::VarDecl { .. }));
330
343
  assert!(matches!(stmts[2], Statement::If { .. }));
331
344
  }
345
+
346
+ #[test]
347
+ fn ignore_indent_parses_brace_blocks_identically() {
348
+ // Fully brace-delimited code: braces are authoritative, indentation is decoration.
349
+ // Ignoring indentation must therefore produce an identical AST.
350
+ let src = "fn f() {\n let a = 1\n if (a) {\n let b = 2\n g(b)\n }\n}\n";
351
+ let normal = parse(src).expect("parse (indentation significant)");
352
+ let ignored = parse_with_options(src, LexerOptions { ignore_indent: true })
353
+ .expect("parse (indentation ignored)");
354
+ assert_eq!(
355
+ format!("{normal:#?}"),
356
+ format!("{ignored:#?}"),
357
+ "brace-delimited code must parse identically with indentation ignored"
358
+ );
359
+ }
360
+
361
+ #[test]
362
+ fn ignore_indent_drops_indentation_induced_block() {
363
+ // A leading-indented line makes the lexer open an indent level, so the parser wraps
364
+ // `a()` in a `Block` — the kind of stray, indentation-driven nesting that can give
365
+ // transpiled JS the wrong lexical scope. Ignoring indentation removes that wrapper.
366
+ let src = " a()\nb()\n";
367
+
368
+ let normal = parse(src).expect("parse normal");
369
+ assert!(
370
+ matches!(normal.statements.first(), Some(Statement::Block { .. })),
371
+ "indentation should wrap a() in a Block, got: {:?}",
372
+ normal.statements
373
+ );
374
+
375
+ let ignored = parse_with_options(src, LexerOptions { ignore_indent: true })
376
+ .expect("parse ignored");
377
+ assert!(
378
+ ignored
379
+ .statements
380
+ .iter()
381
+ .all(|s| matches!(s, Statement::ExprStmt { .. })),
382
+ "with indentation ignored, both calls are flat expression statements, got: {:?}",
383
+ ignored.statements
384
+ );
385
+ }
332
386
  }