@tishlang/tish 2.12.0 → 2.16.13

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 (71) hide show
  1. package/Cargo.toml +3 -0
  2. package/bin/tish +0 -0
  3. package/crates/tish/Cargo.toml +1 -1
  4. package/crates/tish/tests/integration_test.rs +80 -0
  5. package/crates/tish_ast/src/ast.rs +10 -0
  6. package/crates/tish_builtins/src/array.rs +529 -56
  7. package/crates/tish_builtins/src/collections.rs +114 -40
  8. package/crates/tish_builtins/src/string.rs +95 -8
  9. package/crates/tish_bytecode/src/chunk.rs +7 -0
  10. package/crates/tish_bytecode/src/compiler.rs +560 -64
  11. package/crates/tish_bytecode/src/lib.rs +1 -1
  12. package/crates/tish_bytecode/src/opcode.rs +154 -2
  13. package/crates/tish_bytecode/src/serialize.rs +2 -0
  14. package/crates/tish_bytecode/tests/append_local_string_builder.rs +63 -0
  15. package/crates/tish_bytecode/tests/math_unary_intrinsic.rs +47 -0
  16. package/crates/tish_compile/src/codegen.rs +17736 -5269
  17. package/crates/tish_compile/src/infer.rs +1707 -190
  18. package/crates/tish_compile/src/lib.rs +29 -4
  19. package/crates/tish_compile/src/resolve.rs +66 -5
  20. package/crates/tish_compile/src/types.rs +224 -28
  21. package/crates/tish_compile/tests/dump_codegen.rs +21 -0
  22. package/crates/tish_compile/tests/perf_codegen_169_173.rs +8 -17
  23. package/crates/tish_compile/tests/perf_codegen_173_part3.rs +61 -0
  24. package/crates/tish_compile/tests/perf_codegen_174.rs +160 -0
  25. package/crates/tish_compile/tests/perf_codegen_175.rs +190 -0
  26. package/crates/tish_compile/tests/perf_codegen_176.rs +60 -0
  27. package/crates/tish_compile/tests/perf_codegen_177.rs +167 -0
  28. package/crates/tish_compile/tests/perf_codegen_178.rs +114 -0
  29. package/crates/tish_compile/tests/perf_codegen_178_rec.rs +124 -0
  30. package/crates/tish_compile/tests/perf_codegen_181.rs +36 -0
  31. package/crates/tish_compile/tests/perf_codegen_320.rs +211 -0
  32. package/crates/tish_compile/tests/perf_codegen_module_const_forof.rs +40 -0
  33. package/crates/tish_compile_js/src/codegen.rs +33 -0
  34. package/crates/tish_compiler_wasm/src/resolve_virtual.rs +100 -2
  35. package/crates/tish_core/Cargo.toml +4 -0
  36. package/crates/tish_core/src/json.rs +104 -5
  37. package/crates/tish_core/src/lib.rs +174 -0
  38. package/crates/tish_core/src/shape.rs +4 -2
  39. package/crates/tish_core/src/value.rs +565 -35
  40. package/crates/tish_core/src/vmref.rs +14 -0
  41. package/crates/tish_eval/src/eval.rs +675 -76
  42. package/crates/tish_eval/src/natives.rs +19 -0
  43. package/crates/tish_eval/src/value.rs +76 -21
  44. package/crates/tish_ffi/src/lib.rs +11 -1
  45. package/crates/tish_ffi/tests/double_free.rs +35 -0
  46. package/crates/tish_fmt/src/lib.rs +75 -1
  47. package/crates/tish_lexer/src/lib.rs +76 -0
  48. package/crates/tish_lexer/src/token.rs +4 -0
  49. package/crates/tish_lint/src/lib.rs +126 -0
  50. package/crates/tish_lsp/README.md +2 -1
  51. package/crates/tish_lsp/src/main.rs +378 -28
  52. package/crates/tish_native/src/build.rs +41 -0
  53. package/crates/tish_parser/Cargo.toml +4 -0
  54. package/crates/tish_parser/src/lib.rs +27 -0
  55. package/crates/tish_parser/src/parser.rs +302 -20
  56. package/crates/tish_resolve/src/lib.rs +9 -0
  57. package/crates/tish_runtime/Cargo.toml +5 -0
  58. package/crates/tish_runtime/src/http.rs +28 -10
  59. package/crates/tish_runtime/src/http_fetch.rs +150 -1
  60. package/crates/tish_runtime/src/http_prefork.rs +72 -11
  61. package/crates/tish_runtime/src/lib.rs +523 -42
  62. package/crates/tish_runtime/src/timers.rs +49 -2
  63. package/crates/tish_ui/src/jsx.rs +3 -0
  64. package/crates/tish_vm/src/jit.rs +2514 -117
  65. package/crates/tish_vm/src/vm.rs +1227 -182
  66. package/package.json +1 -1
  67. package/platform/darwin-arm64/tish +0 -0
  68. package/platform/darwin-x64/tish +0 -0
  69. package/platform/linux-arm64/tish +0 -0
  70. package/platform/linux-x64/tish +0 -0
  71. package/platform/win32-x64/tish.exe +0 -0
@@ -10,6 +10,25 @@ mod types;
10
10
 
11
11
  pub use check::{check_program, TypeDiagnostic};
12
12
 
13
+ /// The native typed-codegen optimizations — numeric param inference, struct/aggregate inference,
14
+ /// native (monomorphic) free fns, native-vec params, recursive-struct arena lowering, fused/native
15
+ /// higher-order fns, and native `number[]` params — are **ON BY DEFAULT**. There are no per-pass
16
+ /// flags anymore: that per-flag gating caused repeated "did I set all of them?" drift between the
17
+ /// gauntlet, manual builds, and CI. The single escape hatch `TISH_NATIVE_OPT=0` turns the whole
18
+ /// stack off — used only by the gauntlet's boxed A/B baseline and to bisect a suspected miscompile.
19
+ pub(crate) fn native_opts_enabled() -> bool {
20
+ std::env::var("TISH_NATIVE_OPT").map(|v| v != "0").unwrap_or(true)
21
+ }
22
+
23
+ /// #381 — the native recursion guard: rotation copies for self/mutually-recursive typed fns plus a
24
+ /// depth guard at boxed user-fn closure entry, so unbounded recursion raises a catchable
25
+ /// `RangeError` instead of overflowing the stack (an uncatchable process abort). **Default ON**;
26
+ /// `TISH_NATIVE_RECUR_GUARD=0` turns it off — mirrors the VM JIT tier's `TISH_JIT_RECUR_GUARD`
27
+ /// (for bisection and code-size-sensitive builds; unbounded recursion then aborts again, as before).
28
+ pub(crate) fn native_recur_guard_enabled() -> bool {
29
+ std::env::var("TISH_NATIVE_RECUR_GUARD").map(|v| v != "0").unwrap_or(true)
30
+ }
31
+
13
32
  /// How generated Rust is linked (desktop binary vs embedded iOS staticlib).
14
33
  #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
15
34
  pub enum NativeEmitMode {
@@ -88,8 +107,10 @@ fn count(): number {
88
107
 
89
108
  #[test]
90
109
  fn loop_var_decl_clone_outer_var() {
91
- // With inference, outerVar = 42 gets inferred as f64. f64 is Copy, so no clone is
92
- // needed direct assignment is correct. The test verifies compilation succeeds.
110
+ // outerVar = 42 is inferred as f64. Per #313, a top-level numeric used ONLY at top level
111
+ // (never inside a function/closure body) is a native `run()`-local `let mut _: f64`, NOT a
112
+ // `thread_local Cell<f64>` — the Cell is reserved for globals a function reads across calls
113
+ // (e.g. fasta's `seed`). The read `let x = outerVar` loads it as a Copy f64.
93
114
  let src = r#"
94
115
  let outerVar = 42
95
116
  for (let i = 0; i < 5; i = i + 1) {
@@ -98,10 +119,14 @@ for (let i = 0; i < 5; i = i + 1) {
98
119
  "#;
99
120
  let program = parse(src).unwrap();
100
121
  let rust = compile(&program).unwrap();
101
- // outerVar and x are f64 (inferred) Copy assignment, no .clone() needed.
122
+ // outerVar is a native f64 local (no TLS Cell, since no function references it); x reads it.
102
123
  assert!(
103
124
  rust.contains("let mut outerVar: f64"),
104
- "expected outerVar: f64"
125
+ "expected outerVar as a native f64 local (#313)"
126
+ );
127
+ assert!(
128
+ !rust.contains("G_OUTERVAR"),
129
+ "outerVar must NOT be a thread_local Cell global — it is top-level-only (#313)"
105
130
  );
106
131
  assert!(rust.contains("let mut x: f64"), "expected x: f64");
107
132
  }
@@ -854,7 +854,7 @@ pub fn resolve_project_from_stdin(
854
854
  let from_dir = stdin_path.parent().unwrap_or_else(|| Path::new("."));
855
855
 
856
856
  for stmt in &program.statements {
857
- if let Statement::Import { from, .. } = stmt {
857
+ if let Some(from) = stmt_module_dep(stmt) {
858
858
  if is_native_import(from.as_ref()) {
859
859
  continue;
860
860
  }
@@ -883,6 +883,20 @@ pub fn resolve_project_from_stdin(
883
883
  .collect())
884
884
  }
885
885
 
886
+ /// #305 — the module path a statement depends on: an `import ... from "p"` or a re-export
887
+ /// `export ... from "p"`. Used by dependency discovery + cycle detection so a module reached ONLY
888
+ /// through a re-export is still loaded. None for non-module statements.
889
+ fn stmt_module_dep(stmt: &Statement) -> Option<&std::sync::Arc<str>> {
890
+ match stmt {
891
+ Statement::Import { from, .. } => Some(from),
892
+ Statement::Export { declaration, .. } => match declaration.as_ref() {
893
+ ExportDeclaration::ReExport { from, .. } => Some(from),
894
+ _ => None,
895
+ },
896
+ _ => None,
897
+ }
898
+ }
899
+
886
900
  fn load_module_recursive(
887
901
  module_path: &Path,
888
902
  project_root: &Path,
@@ -907,7 +921,7 @@ fn load_module_recursive(
907
921
  // Collect imports and load dependencies first (skip native imports)
908
922
  let dir = canonical.parent().unwrap_or(Path::new("."));
909
923
  for stmt in &program.statements {
910
- if let Statement::Import { from, .. } = stmt {
924
+ if let Some(from) = stmt_module_dep(stmt) {
911
925
  if is_native_import(from.as_ref()) {
912
926
  continue; // Native imports don't load files
913
927
  }
@@ -1113,7 +1127,7 @@ fn has_cycle_from(
1113
1127
  visiting: &mut HashSet<usize>,
1114
1128
  ) -> Result<bool, String> {
1115
1129
  for stmt in &program.statements {
1116
- if let Statement::Import { from, .. } = stmt {
1130
+ if let Some(from) = stmt_module_dep(stmt) {
1117
1131
  if is_native_import(from.as_ref()) {
1118
1132
  continue;
1119
1133
  }
@@ -1374,7 +1388,11 @@ fn shadow_params(
1374
1388
  }
1375
1389
 
1376
1390
  /// Scope-aware statement rewriter for [`isolate_private_top_level_bindings`].
1377
- fn rewrite_stmt_scope(
1391
+ /// Scope-aware statement rewriter: rename declared bindings and their free references
1392
+ /// according to `active` (name → new Arc). Exposed `pub(crate)` so the #179 factory inliner
1393
+ /// (infer.rs) can reuse this exhaustive, resolution-tested renamer for alpha-renaming a
1394
+ /// spliced factory body instead of hand-rolling a completeness-sensitive walker.
1395
+ pub(crate) fn rewrite_stmt_scope(
1378
1396
  stmt: &mut Statement,
1379
1397
  active: &mut HashMap<String, Arc<str>>,
1380
1398
  top_level: bool,
@@ -1522,6 +1540,9 @@ fn rewrite_stmt_scope(
1522
1540
  // body can still reference module-private names that were renamed.
1523
1541
  ExportDeclaration::Named(inner) => rewrite_stmt_scope(inner, active, top_level),
1524
1542
  ExportDeclaration::Default(e) => rewrite_expr_scope(e, active),
1543
+ // #305: a re-export has no inner statement/expr to scope-rewrite; names are resolved by
1544
+ // the export-table merge against the dep's (already-rewritten) bindings.
1545
+ ExportDeclaration::ReExport { .. } => {}
1525
1546
  },
1526
1547
  Statement::Import { .. }
1527
1548
  | Statement::TypeAlias { .. }
@@ -1533,7 +1554,8 @@ fn rewrite_stmt_scope(
1533
1554
  /// Scope-aware expression rewriter: rename every free reference to a name in `active`.
1534
1555
  /// Expressions never declare module-level bindings, so `active` is read-only here; arrow
1535
1556
  /// functions clone it for their own (shadowed) parameter scope.
1536
- fn rewrite_expr_scope(expr: &mut Expr, active: &HashMap<String, Arc<str>>) {
1557
+ /// `pub(crate)` so the #179 factory inliner can rename a spliced return expression.
1558
+ pub(crate) fn rewrite_expr_scope(expr: &mut Expr, active: &HashMap<String, Arc<str>>) {
1537
1559
  let rename = |name: &mut Arc<str>| {
1538
1560
  if let Some(renamed) = active.get(name.as_ref()) {
1539
1561
  *name = Arc::clone(renamed);
@@ -1701,6 +1723,41 @@ pub fn merge_modules(mut modules: Vec<ResolvedModule>) -> Result<MergedProgram,
1701
1723
  let default_name = format!("__default_{}", idx);
1702
1724
  module_exports[idx].insert("default".to_string(), default_name);
1703
1725
  }
1726
+ // #305: re-export — map each re-exported name to the DEP's binding (the dep is
1727
+ // earlier in load order, so its export table is already built). `export *` copies
1728
+ // every dep export (without overriding an explicit one); `export { a as b }` maps
1729
+ // b -> dep's binding for a. Downstream imports then resolve straight to the dep.
1730
+ ExportDeclaration::ReExport {
1731
+ specifiers,
1732
+ all,
1733
+ from,
1734
+ ..
1735
+ } => {
1736
+ let dir = module.path.parent().unwrap_or_else(|| Path::new("."));
1737
+ let dep = resolve_import_path(from.as_ref(), dir, Path::new("."))
1738
+ .ok()
1739
+ .map(|p| p.canonicalize().unwrap_or(p))
1740
+ .and_then(|p| path_to_idx.get(&p).copied())
1741
+ .map(|dep_idx| module_exports[dep_idx].clone());
1742
+ if let Some(dep) = dep {
1743
+ if *all {
1744
+ for (k, v) in &dep {
1745
+ module_exports[idx]
1746
+ .entry(k.clone())
1747
+ .or_insert_with(|| v.clone());
1748
+ }
1749
+ }
1750
+ for spec in specifiers {
1751
+ if let ImportSpecifier::Named { name, alias, .. } = spec {
1752
+ if let Some(binding) = dep.get(name.as_ref()) {
1753
+ let export_name =
1754
+ alias.as_deref().unwrap_or(name.as_ref()).to_string();
1755
+ module_exports[idx].insert(export_name, binding.clone());
1756
+ }
1757
+ }
1758
+ }
1759
+ }
1760
+ }
1704
1761
  }
1705
1762
  }
1706
1763
  }
@@ -1885,6 +1942,10 @@ pub fn merge_modules(mut modules: Vec<ResolvedModule>) -> Result<MergedProgram,
1885
1942
  src_path.clone(),
1886
1943
  );
1887
1944
  }
1945
+ // #305: re-export emits no code — `module_exports` already maps the re-exported
1946
+ // names to the dep's bindings (in scope in the flattened bundle), so downstream
1947
+ // imports resolve directly. Nothing to push here.
1948
+ ExportDeclaration::ReExport { .. } => {}
1888
1949
  },
1889
1950
  _ => merge_push(
1890
1951
  &mut statements,
@@ -29,6 +29,10 @@ pub enum RustType {
29
29
  Vec(Box<RustType>),
30
30
  /// Option<T> for nullable types (T | null)
31
31
  Option(Box<RustType>),
32
+ /// Box<T> — heap indirection required to make recursive structs finite-sized.
33
+ /// Only the recursive-struct native pass (#178) produces this, for child fields
34
+ /// like `Option<Box<TishRec_Node>>`; never from `from_annotation`.
35
+ Boxed(Box<RustType>),
32
36
  /// Inline object shape — used during inference / annotation lowering
33
37
  /// before a `Named` alias has been registered. Once a corresponding
34
38
  /// `type Foo = { ... }` declaration is found in the program, occurrences
@@ -49,6 +53,15 @@ pub enum RustType {
49
53
  },
50
54
  /// Tuple `(T0, T1, …)` for `[T0, T1]` tuple types — a native Rust tuple.
51
55
  Tuple(Vec<RustType>),
56
+ /// #179 Stage B: a closed set of 2..=8 distinct primitive-record shapes read at one site — the
57
+ /// "megamorphic" array-of-heterogeneous-objects pattern. Emitted as a generated Rust enum
58
+ /// `TishUnion_<name> { V0(TishStruct_<v0>), … }` (one variant per shape); a `.field` read present
59
+ /// in EVERY variant lowers to a `match` with a direct field load per arm (no hash, no IC). `name`
60
+ /// is the union alias; each `variants` entry is `(per-variant struct alias, its fields)`.
61
+ ShapeUnion {
62
+ name: Arc<str>,
63
+ variants: Vec<(Arc<str>, Vec<(Arc<str>, RustType)>)>,
64
+ },
52
65
  }
53
66
 
54
67
  impl RustType {
@@ -95,6 +108,12 @@ impl RustType {
95
108
  RustType::Vec(Box::new(Self::from_annotation_with_aliases(elem, aliases)))
96
109
  }
97
110
  TypeAnnotation::Object(fields) => {
111
+ // Security #379: a field key that is not a valid Rust identifier must not drive a
112
+ // native struct (it would be interpolated into generated Rust). Keep the whole object
113
+ // on the boxed `Value` path — always correct, just unspecialized.
114
+ if fields.iter().any(|(k, _)| !is_struct_field_safe(k)) {
115
+ return RustType::Value;
116
+ }
98
117
  let typed_fields: Vec<_> = fields
99
118
  .iter()
100
119
  .map(|(k, v)| (k.clone(), Self::from_annotation_with_aliases(v, aliases)))
@@ -234,6 +253,7 @@ impl RustType {
234
253
  RustType::Unit => "()".to_string(),
235
254
  RustType::Vec(inner) => format!("Vec<{}>", inner.to_rust_type_str()),
236
255
  RustType::Option(inner) => format!("Option<{}>", inner.to_rust_type_str()),
256
+ RustType::Boxed(inner) => format!("Box<{}>", inner.to_rust_type_str()),
237
257
  RustType::Object(_) => {
238
258
  // Anonymous inline shapes don't have a Rust struct; fall
239
259
  // back to the dynamic Value path.
@@ -249,6 +269,7 @@ impl RustType {
249
269
  )
250
270
  }
251
271
  RustType::Tuple(elems) => tuple_text(&elems.iter().map(|e| e.to_rust_type_str()).collect::<Vec<_>>()),
272
+ RustType::ShapeUnion { name, .. } => shape_union_enum_ident(name),
252
273
  }
253
274
  }
254
275
 
@@ -263,6 +284,7 @@ impl RustType {
263
284
  RustType::Unit => "()".to_string(),
264
285
  RustType::Vec(_) => "Vec::new()".to_string(),
265
286
  RustType::Option(_) => "None".to_string(),
287
+ RustType::Boxed(inner) => format!("Box::new({})", inner.default_value()),
266
288
  RustType::Object(_) => "Value::Null".to_string(),
267
289
  RustType::Named { fields, .. } => {
268
290
  // Build a literal struct with each field at its own default,
@@ -285,6 +307,22 @@ impl RustType {
285
307
  RustType::Tuple(elems) => {
286
308
  tuple_text(&elems.iter().map(|e| e.default_value()).collect::<Vec<_>>())
287
309
  }
310
+ RustType::ShapeUnion { name, variants } => {
311
+ // Default = the first variant with each of its fields defaulted.
312
+ let (v0_alias, v0_fields) = &variants[0];
313
+ let init = v0_fields
314
+ .iter()
315
+ .map(|(k, t)| format!("{}: {}", field_ident(k), t.default_value()))
316
+ .collect::<Vec<_>>()
317
+ .join(", ");
318
+ format!(
319
+ "{}::{}({} {{ {} }})",
320
+ shape_union_enum_ident(name),
321
+ shape_union_variant_ident(0),
322
+ named_struct_ident(v0_alias),
323
+ init
324
+ )
325
+ }
288
326
  }
289
327
  }
290
328
 
@@ -344,20 +382,34 @@ impl RustType {
344
382
  )
345
383
  }
346
384
  RustType::Named { name, fields } => {
347
- // Each field is fetched out of the Value::Object via
348
- // `get_prop` and converted to its native type. Falls back
349
- // to the field's `default_value()` if the field is absent
350
- // (rare usually these come from JSON or PG).
385
+ // Each field is fetched out of the Value::Object via `get_prop` and converted to its
386
+ // native type. The source Value is bound ONCE to `_src` so a non-trivial `value_expr`
387
+ // (an object literal, a call, …) is evaluated a single time instead of being textually
388
+ // re-inlined per field (which would re-allocate the whole object N times). Missing
389
+ // fields fall back to `default_value()` (rare — usually these come from JSON or PG).
351
390
  let field_assigns = fields
352
391
  .iter()
353
392
  .map(|(k, ty)| {
354
- let fetch =
355
- format!("tishlang_runtime::get_prop(&{}, {:?})", value_expr, k.as_ref());
393
+ let fetch = format!("tishlang_runtime::get_prop(&_src, {:?})", k.as_ref());
356
394
  format!("{}: {}", field_ident(k), ty.from_value_expr(&fetch))
357
395
  })
358
396
  .collect::<Vec<_>>()
359
397
  .join(", ");
360
- format!("{} {{ {} }}", named_struct_ident(name), field_assigns)
398
+ format!(
399
+ "{{ let _src = {}; {} {{ {} }} }}",
400
+ value_expr,
401
+ named_struct_ident(name),
402
+ field_assigns
403
+ )
404
+ }
405
+ RustType::ShapeUnion { .. } => {
406
+ // #179 Stage B: converting a boxed Value INTO a ShapeUnion is a boundary the safety
407
+ // walk forbids (a typed union element never crosses to/from boxed), so codegen must
408
+ // never emit this. Present for match-completeness; fails loud if gating is violated.
409
+ format!(
410
+ "{{ let _ = {}; unreachable!(\"ShapeUnion from Value is a forbidden boundary\") }}",
411
+ value_expr
412
+ )
361
413
  }
362
414
  _ => value_expr.to_string(), // Fallback
363
415
  }
@@ -403,12 +455,15 @@ impl RustType {
403
455
  )
404
456
  }
405
457
  RustType::Named { fields, .. } => {
406
- // Walk fields, build an ObjectMap, wrap in Value::Object.
407
- // The boundary is paid only when crossing into untyped
408
- // Tish (JSON.stringify, calling a Value::Function, etc.);
409
- // direct Rust-to-Rust paths between two Named values stay
410
- // as plain struct moves.
411
- let inserts = fields
458
+ // Build the boxed Value with an ORDERED PropMap via `object_from_pairs` (no
459
+ // intermediate `AHashMap`), so key order == field-declaration order == JS
460
+ // insertion order. The old `ObjectMap::default()` (`AHashMap`) + insert path
461
+ // scrambled key order NON-DETERMINISTICALLY per run (ahash seed) a shipped
462
+ // `JSON.stringify` / `Object.keys` / `for..in` divergence from node whenever a
463
+ // native struct crosses back into untyped Tish. This boundary is paid only on
464
+ // that crossing (JSON.stringify, calling a Value::Function, etc.); direct
465
+ // Rust-to-Rust paths between two Named values stay as plain struct moves.
466
+ let pairs = fields
412
467
  .iter()
413
468
  .map(|(k, ty)| {
414
469
  let access = format!("{}.{}", native_expr, field_ident(k));
@@ -420,22 +475,35 @@ impl RustType {
420
475
  } else {
421
476
  ty.to_value_expr(&access)
422
477
  };
478
+ format!("(::std::sync::Arc::from({:?}), {})", k.as_ref(), v_expr)
479
+ })
480
+ .collect::<Vec<_>>()
481
+ .join(", ");
482
+ // `object_from_pairs::<N>` builds the `PropMap` in one ordered pass (N = field
483
+ // count, a codegen-time constant) — no AHashMap, so key order is preserved.
484
+ format!("Value::object_from_pairs([{}])", pairs)
485
+ }
486
+ RustType::ShapeUnion { name, variants } => {
487
+ // union → boxed Value::Object: one match arm per variant, delegating to that
488
+ // variant's Named glue (ordered `object_from_pairs`, preserving JS key order).
489
+ let arms = variants
490
+ .iter()
491
+ .enumerate()
492
+ .map(|(i, (alias, fields))| {
493
+ let named = RustType::Named {
494
+ name: alias.clone(),
495
+ fields: fields.clone(),
496
+ };
423
497
  format!(
424
- "_om.insert(::std::sync::Arc::from({:?}), {});",
425
- k.as_ref(),
426
- v_expr
498
+ "{}::{}(__u) => {}",
499
+ shape_union_enum_ident(name),
500
+ shape_union_variant_ident(i),
501
+ named.to_value_expr("__u")
427
502
  )
428
503
  })
429
504
  .collect::<Vec<_>>()
430
- .join(" ");
431
- // `Value::object` wraps the `ObjectMap` in an `ObjectData` (what `Value::Object`
432
- // actually holds) — emitting `Value::Object(VmRef::new(_om))` directly is a type
433
- // error (`ObjectMap` != `ObjectData`); only surfaced once a whole struct is boxed
434
- // into a `Value`, e.g. passing it to a function.
435
- format!(
436
- "{{ let mut _om = ObjectMap::default(); {} Value::object(_om) }}",
437
- inserts
438
- )
505
+ .join(", ");
506
+ format!("(match &{} {{ {} }})", native_expr, arms)
439
507
  }
440
508
  _ => native_expr.to_string(), // Fallback
441
509
  }
@@ -457,16 +525,75 @@ pub fn named_struct_ident(tish_name: &str) -> String {
457
525
  format!("TishStruct_{}", tish_name)
458
526
  }
459
527
 
528
+ /// #179 Stage B: map a shape-union alias to the generated Rust enum identifier.
529
+ pub fn shape_union_enum_ident(tish_name: &str) -> String {
530
+ format!("TishUnion_{}", tish_name)
531
+ }
532
+
533
+ /// #179 Stage B: the Rust variant identifier for the `idx`-th shape of a union (`V0`, `V1`, …).
534
+ pub fn shape_union_variant_ident(idx: usize) -> String {
535
+ format!("V{}", idx)
536
+ }
537
+
538
+ /// Keywords that cannot be written as a raw identifier (`r#self` etc. are a hard Rust error), so an
539
+ /// object key equal to one of these must NOT drive struct/shape inference — it stays boxed.
540
+ const NON_RAWABLE_KEYWORDS: &[&str] = &["self", "Self", "super", "crate"];
541
+
542
+ /// True iff `key` can be safely emitted as a generated Rust struct field name via [`field_ident`]:
543
+ /// a plain ASCII identifier (`^[A-Za-z_][A-Za-z0-9_]*$`), excluding the bare wildcard `_` and the
544
+ /// four non-rawable keywords. Any other key — one containing spaces/punctuation, empty, non-ASCII,
545
+ /// etc. — is REJECTED so the object it belongs to falls back to the boxed `Value` path instead of
546
+ /// being lowered to a native struct.
547
+ ///
548
+ /// This is the front-line guard for the native-codegen key-injection class (security #379): an
549
+ /// object literal key is arbitrary tish/JS text, and interpolating it verbatim into generated Rust
550
+ /// (`pub {key}: {ty},`) is arbitrary-code injection. Inference/annotation lowering call this to keep
551
+ /// unsafe-keyed objects on the always-correct boxed path; [`field_ident`] additionally sanitizes as a
552
+ /// last-resort choke point so no path can ever emit a non-identifier verbatim.
553
+ pub fn is_struct_field_safe(key: &str) -> bool {
554
+ if key == "_" || NON_RAWABLE_KEYWORDS.contains(&key) {
555
+ return false;
556
+ }
557
+ let mut chars = key.chars();
558
+ match chars.next() {
559
+ Some(c) if c == '_' || c.is_ascii_alphabetic() => {}
560
+ _ => return false,
561
+ }
562
+ chars.all(|c| c == '_' || c.is_ascii_alphanumeric())
563
+ }
564
+
565
+ /// FNV-1a 64-bit — a tiny, dependency-free hash used only to synthesize a deterministic safe field
566
+ /// identifier for an (unexpected) non-identifier key. Deterministic so a struct's field definition,
567
+ /// its constructor, its accessors, and its serializer all agree on the same name.
568
+ fn fnv1a_64(s: &str) -> u64 {
569
+ let mut h: u64 = 0xcbf2_9ce4_8422_2325;
570
+ for b in s.as_bytes() {
571
+ h ^= *b as u64;
572
+ h = h.wrapping_mul(0x0000_0100_0000_01b3);
573
+ }
574
+ h
575
+ }
576
+
460
577
  /// Map a Tish field name (`randomNumber`) to a valid Rust identifier
461
578
  /// (kept identical here — non-snake-case is allowed via
462
579
  /// `#[allow(non_snake_case)]` on the struct, so JS-style camelCase keys
463
580
  /// stay readable in the generated source).
581
+ ///
582
+ /// A key that is not a valid Rust identifier is NEVER emitted verbatim (that would be code injection,
583
+ /// security #379): inference/annotation lowering should already have kept such an object boxed via
584
+ /// [`is_struct_field_safe`], so this is a defense-in-depth choke point — an unsafe key is replaced by
585
+ /// a deterministic `__tish_field_<hash>` name. The worst case if a producer is missed is thus a wrong
586
+ /// field name (a compile error / correctness bug), never injected Rust.
464
587
  pub fn field_ident(tish_name: &str) -> String {
465
- // Reserve Rust keywords that would otherwise conflict.
588
+ if !is_struct_field_safe(tish_name) {
589
+ return format!("__tish_field_{:016x}", fnv1a_64(tish_name));
590
+ }
591
+ // Reserve Rust keywords that would otherwise conflict. (The non-rawable keywords are already
592
+ // excluded by `is_struct_field_safe` above, so every keyword reaching here has a valid `r#` form.)
466
593
  match tish_name {
467
- "type" | "ref" | "fn" | "match" | "move" | "mod" | "self" | "Self" | "super" | "use"
594
+ "type" | "ref" | "fn" | "match" | "move" | "mod" | "use"
468
595
  | "where" | "loop" | "yield" | "async" | "await" | "dyn" | "impl" | "trait" | "in"
469
- | "as" | "box" | "crate" | "const" | "extern" | "let" | "mut" | "pub" | "static"
596
+ | "as" | "box" | "const" | "extern" | "let" | "mut" | "pub" | "static"
470
597
  | "unsafe" | "abstract" | "become" | "do" | "final" | "macro" | "override" | "priv"
471
598
  | "typeof" | "unsized" | "virtual" => format!("r#{}", tish_name),
472
599
  _ => tish_name.to_string(),
@@ -571,6 +698,75 @@ mod tests {
571
698
  );
572
699
  }
573
700
 
701
+ // ---- security #379: object-literal key injection into generated Rust ----
702
+
703
+ #[test]
704
+ fn is_struct_field_safe_accepts_plain_and_camel_and_keywords() {
705
+ for k in ["x", "y", "randomNumber", "_priv", "a1", "TishAnon_0", "type", "fn", "match"] {
706
+ assert!(is_struct_field_safe(k), "{k:?} should be a safe field");
707
+ }
708
+ }
709
+
710
+ #[test]
711
+ fn is_struct_field_safe_rejects_injection_and_nonrawable() {
712
+ for k in [
713
+ // the #379 injection shapes: anything with punctuation/space/braces
714
+ "x: i32 } pub fn pwned() {} struct Z { pub y",
715
+ "a, b",
716
+ "0abc",
717
+ "has space",
718
+ "unicodé",
719
+ "",
720
+ // non-rawable keywords would become invalid `r#self` etc.
721
+ "self", "Self", "super", "crate",
722
+ // bare wildcard is not a legal field name
723
+ "_",
724
+ ] {
725
+ assert!(!is_struct_field_safe(k), "{k:?} must NOT be a safe field");
726
+ }
727
+ }
728
+
729
+ #[test]
730
+ fn field_ident_never_emits_non_identifier_for_unsafe_key() {
731
+ // The core RCE guarantee: whatever key comes in, field_ident() out is ALWAYS a legal Rust
732
+ // identifier (ASCII ident chars, or a leading `r#`) — never verbatim injectable text.
733
+ let malicious = "x: i32 } pub fn PWNED() -> i32 { 1337 } struct S { pub y";
734
+ let out = field_ident(malicious);
735
+ assert!(out.starts_with("__tish_field_"), "unsafe key must be sanitized, got {out:?}");
736
+ let body = out.trim_start_matches("r#");
737
+ assert!(
738
+ body.chars().enumerate().all(|(i, c)| {
739
+ if i == 0 { c == '_' || c.is_ascii_alphabetic() } else { c == '_' || c.is_ascii_alphanumeric() }
740
+ }),
741
+ "field_ident output {out:?} is not a legal Rust identifier"
742
+ );
743
+ // deterministic: the def, the accessor, and the serializer must agree.
744
+ assert_eq!(field_ident(malicious), field_ident(malicious));
745
+ }
746
+
747
+ #[test]
748
+ fn field_ident_preserves_legit_and_raws_keywords() {
749
+ assert_eq!(field_ident("randomNumber"), "randomNumber");
750
+ assert_eq!(field_ident("x"), "x");
751
+ assert_eq!(field_ident("type"), "r#type");
752
+ // a non-rawable keyword is sanitized, NOT emitted as the invalid `r#self`.
753
+ assert!(field_ident("self").starts_with("__tish_field_"));
754
+ }
755
+
756
+ #[test]
757
+ fn annotation_object_with_unsafe_key_stays_boxed() {
758
+ // A `type` alias whose object shape has a non-identifier key must fall back to boxed Value,
759
+ // never a native struct with an injected field name.
760
+ let obj = TypeAnnotation::Object(vec![
761
+ ("safe".into(), TypeAnnotation::Simple("number".into(), tishlang_ast::Span::default())),
762
+ (
763
+ "evil } fn pwned() {".into(),
764
+ TypeAnnotation::Simple("number".into(), tishlang_ast::Span::default()),
765
+ ),
766
+ ]);
767
+ assert_eq!(RustType::from_annotation(&obj), RustType::Value);
768
+ }
769
+
574
770
  #[test]
575
771
  fn test_nullable_type() {
576
772
  let nullable = TypeAnnotation::Union(vec![
@@ -0,0 +1,21 @@
1
+ //! Dump perf fixtures to target/dump_*.rs for inspection.
2
+ use std::path::PathBuf;
3
+ use tishlang_compile::compile_project_full;
4
+
5
+ fn compile(rel: &str) -> String {
6
+ let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
7
+ let path = manifest.join("../..").join(rel).canonicalize().unwrap();
8
+ let (rust, _, _, _) = compile_project_full(&path, path.parent(), &[], true).unwrap();
9
+ rust
10
+ }
11
+
12
+ #[test]
13
+ fn dump_perf_fixtures() {
14
+ let out = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../target");
15
+ std::fs::write(out.join("dump_mandelbrot.rs"), compile("tests/perf/mandelbrot.tish")).unwrap();
16
+ std::fs::write(out.join("dump_fasta.rs"), compile("tests/perf/fasta.tish")).unwrap();
17
+ std::fs::write(out.join("dump_fannkuch.rs"), compile("tests/perf/fannkuch.tish")).unwrap();
18
+ std::fs::write(out.join("dump_fnv_hash.rs"), compile("tests/perf/fnv_hash.tish")).unwrap();
19
+ std::fs::write(out.join("dump_spectral_norm.rs"), compile("tests/perf/spectral_norm.tish")).unwrap();
20
+ std::fs::write(out.join("dump_queens.rs"), compile("tests/perf/queens.tish")).unwrap();
21
+ }
@@ -11,17 +11,6 @@ use tishlang_parser::parse;
11
11
 
12
12
  /// Enable every dark-shipped typed-native flag, matching `scripts/run_perf_gauntlet.sh`.
13
13
  fn enable_typed_flags() {
14
- for k in [
15
- "TISH_PARAM_NATIVE",
16
- "TISH_PARAM_INFER",
17
- "TISH_NATIVE_FN",
18
- "TISH_STRUCT_INFER",
19
- "TISH_FUSED_HOF",
20
- "TISH_NATIVE_HOF",
21
- "TISH_AGGREGATE_INFER",
22
- ] {
23
- std::env::set_var(k, "1");
24
- }
25
14
  }
26
15
 
27
16
  fn compile_typed(src: &str) -> String {
@@ -130,10 +119,12 @@ fn issue_173_adversarial_cases_do_not_fuse() {
130
119
  !rust.contains("repeat((i * 2"),
131
120
  "non-constant push arg must not be fused into a repeat-fill"
132
121
  );
133
- // Exactly the three sound fills are emitted (8, n, 3) no extra fusions crept in.
134
- let fusions = rust.matches("std::iter::repeat").count();
135
- assert_eq!(
136
- fusions, 3,
137
- "exactly 3 fill loops should fuse (boolFill=8, sieve=n, oobGrow=3), found {fusions}"
138
- );
122
+ // The three canonical fills DO fuse (boolFill=8, sieve=n, oobGrow=3); the adversarial cases keep
123
+ // their push loops. Assert each sound fill is present rather than a brittle exact total: with the
124
+ // typing stack on by default, a fn that has a native `_nv` form (e.g. `numFillSieve`) may emit its
125
+ // fill in both the native and the boxed closure form, so the same fill can legitimately appear
126
+ // more than once.
127
+ assert!(rust.contains("repeat(true).take((8"), "boolFill (8) should fuse");
128
+ assert!(rust.contains("repeat(1_f64).take((n"), "sieve (n) should fuse");
129
+ assert!(rust.contains("repeat(1_f64).take((3"), "oobGrow (3) should fuse");
139
130
  }