@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.
- package/Cargo.toml +3 -0
- package/bin/tish +0 -0
- package/crates/tish/Cargo.toml +1 -1
- package/crates/tish/tests/integration_test.rs +80 -0
- package/crates/tish_ast/src/ast.rs +10 -0
- package/crates/tish_builtins/src/array.rs +529 -56
- package/crates/tish_builtins/src/collections.rs +114 -40
- package/crates/tish_builtins/src/string.rs +95 -8
- package/crates/tish_bytecode/src/chunk.rs +7 -0
- package/crates/tish_bytecode/src/compiler.rs +560 -64
- package/crates/tish_bytecode/src/lib.rs +1 -1
- package/crates/tish_bytecode/src/opcode.rs +154 -2
- package/crates/tish_bytecode/src/serialize.rs +2 -0
- package/crates/tish_bytecode/tests/append_local_string_builder.rs +63 -0
- package/crates/tish_bytecode/tests/math_unary_intrinsic.rs +47 -0
- package/crates/tish_compile/src/codegen.rs +17736 -5269
- package/crates/tish_compile/src/infer.rs +1707 -190
- package/crates/tish_compile/src/lib.rs +29 -4
- package/crates/tish_compile/src/resolve.rs +66 -5
- package/crates/tish_compile/src/types.rs +224 -28
- package/crates/tish_compile/tests/dump_codegen.rs +21 -0
- package/crates/tish_compile/tests/perf_codegen_169_173.rs +8 -17
- package/crates/tish_compile/tests/perf_codegen_173_part3.rs +61 -0
- package/crates/tish_compile/tests/perf_codegen_174.rs +160 -0
- package/crates/tish_compile/tests/perf_codegen_175.rs +190 -0
- package/crates/tish_compile/tests/perf_codegen_176.rs +60 -0
- package/crates/tish_compile/tests/perf_codegen_177.rs +167 -0
- package/crates/tish_compile/tests/perf_codegen_178.rs +114 -0
- package/crates/tish_compile/tests/perf_codegen_178_rec.rs +124 -0
- package/crates/tish_compile/tests/perf_codegen_181.rs +36 -0
- package/crates/tish_compile/tests/perf_codegen_320.rs +211 -0
- package/crates/tish_compile/tests/perf_codegen_module_const_forof.rs +40 -0
- package/crates/tish_compile_js/src/codegen.rs +33 -0
- package/crates/tish_compiler_wasm/src/resolve_virtual.rs +100 -2
- package/crates/tish_core/Cargo.toml +4 -0
- package/crates/tish_core/src/json.rs +104 -5
- package/crates/tish_core/src/lib.rs +174 -0
- package/crates/tish_core/src/shape.rs +4 -2
- package/crates/tish_core/src/value.rs +565 -35
- package/crates/tish_core/src/vmref.rs +14 -0
- package/crates/tish_eval/src/eval.rs +675 -76
- package/crates/tish_eval/src/natives.rs +19 -0
- package/crates/tish_eval/src/value.rs +76 -21
- package/crates/tish_ffi/src/lib.rs +11 -1
- package/crates/tish_ffi/tests/double_free.rs +35 -0
- package/crates/tish_fmt/src/lib.rs +75 -1
- package/crates/tish_lexer/src/lib.rs +76 -0
- package/crates/tish_lexer/src/token.rs +4 -0
- package/crates/tish_lint/src/lib.rs +126 -0
- package/crates/tish_lsp/README.md +2 -1
- package/crates/tish_lsp/src/main.rs +378 -28
- package/crates/tish_native/src/build.rs +41 -0
- package/crates/tish_parser/Cargo.toml +4 -0
- package/crates/tish_parser/src/lib.rs +27 -0
- package/crates/tish_parser/src/parser.rs +302 -20
- package/crates/tish_resolve/src/lib.rs +9 -0
- package/crates/tish_runtime/Cargo.toml +5 -0
- package/crates/tish_runtime/src/http.rs +28 -10
- package/crates/tish_runtime/src/http_fetch.rs +150 -1
- package/crates/tish_runtime/src/http_prefork.rs +72 -11
- package/crates/tish_runtime/src/lib.rs +523 -42
- package/crates/tish_runtime/src/timers.rs +49 -2
- package/crates/tish_ui/src/jsx.rs +3 -0
- package/crates/tish_vm/src/jit.rs +2514 -117
- package/crates/tish_vm/src/vm.rs +1227 -182
- package/package.json +1 -1
- package/platform/darwin-arm64/tish +0 -0
- package/platform/darwin-x64/tish +0 -0
- package/platform/linux-arm64/tish +0 -0
- package/platform/linux-x64/tish +0 -0
- 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
|
-
//
|
|
92
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
349
|
-
//
|
|
350
|
-
// (
|
|
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!(
|
|
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
|
-
//
|
|
407
|
-
//
|
|
408
|
-
//
|
|
409
|
-
//
|
|
410
|
-
//
|
|
411
|
-
|
|
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
|
-
"
|
|
425
|
-
|
|
426
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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" | "
|
|
594
|
+
"type" | "ref" | "fn" | "match" | "move" | "mod" | "use"
|
|
468
595
|
| "where" | "loop" | "yield" | "async" | "await" | "dyn" | "impl" | "trait" | "in"
|
|
469
|
-
| "as" | "box" | "
|
|
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
|
-
//
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
}
|