@tishlang/tish-lsp 3.3.3 → 3.7.0
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 +1 -0
- package/bin/tish-lsp +0 -0
- package/crates/tish/Cargo.toml +4 -1
- package/crates/tish/src/cli_help.rs +84 -0
- package/crates/tish/src/main.rs +93 -1
- package/crates/tish/tests/scope_merge.rs +90 -0
- package/crates/tish/tests/test_runner.rs +673 -0
- package/crates/tish_bytecode/src/compiler.rs +25 -4
- package/crates/tish_compile/src/check.rs +178 -0
- package/crates/tish_compile/src/codegen.rs +383 -63
- package/crates/tish_compile/src/lib.rs +33 -1
- package/crates/tish_compile/src/resolve.rs +121 -19
- package/crates/tish_compile/src/types.rs +35 -1
- package/crates/tish_compile/tests/call_arity_641.rs +104 -0
- package/crates/tish_core/src/lib.rs +103 -11
- package/crates/tish_eval/src/eval.rs +16 -0
- package/crates/tish_eval/src/value_convert.rs +10 -0
- package/crates/tish_lsp/Cargo.toml +1 -1
- package/crates/tish_test/Cargo.toml +47 -0
- package/crates/tish_test/src/assert.rs +435 -0
- package/crates/tish_test/src/config.rs +85 -0
- package/crates/tish_test/src/coverage.rs +237 -0
- package/crates/tish_test/src/deep_equal.rs +261 -0
- package/crates/tish_test/src/discovery.rs +70 -0
- package/crates/tish_test/src/expect.rs +977 -0
- package/crates/tish_test/src/instrument.rs +457 -0
- package/crates/tish_test/src/isolation.rs +14 -0
- package/crates/tish_test/src/lib.rs +22 -0
- package/crates/tish_test/src/load.rs +152 -0
- package/crates/tish_test/src/mocks.rs +270 -0
- package/crates/tish_test/src/module.rs +318 -0
- package/crates/tish_test/src/registry.rs +235 -0
- package/crates/tish_test/src/report.rs +312 -0
- package/crates/tish_test/src/runner.rs +797 -0
- package/crates/tish_test/src/snapshots.rs +197 -0
- package/crates/tish_vm/src/vm.rs +17 -14
- package/package.json +1 -1
- package/platform/darwin-arm64/tish-lsp +0 -0
- package/platform/darwin-x64/tish-lsp +0 -0
- package/platform/linux-arm64/tish-lsp +0 -0
- package/platform/linux-x64/tish-lsp +0 -0
- package/platform/win32-x64/tish-lsp.exe +0 -0
|
@@ -12,7 +12,7 @@ mod types;
|
|
|
12
12
|
|
|
13
13
|
pub use schemes::{set_active as set_scheme_registry, SchemeRegistry};
|
|
14
14
|
|
|
15
|
-
pub use check::{check_program, TypeDiagnostic};
|
|
15
|
+
pub use check::{check_call_arity, check_program, TypeDiagnostic};
|
|
16
16
|
|
|
17
17
|
/// The native typed-codegen optimizations — numeric param inference, struct/aggregate inference,
|
|
18
18
|
/// native (monomorphic) free fns, native-vec params, recursive-struct arena lowering, fused/native
|
|
@@ -291,4 +291,36 @@ mod monomorphization_tests {
|
|
|
291
291
|
assert!(rust.contains("value: f64"), "expected native f64 field; got:\n{}",
|
|
292
292
|
rust.lines().filter(|l| l.contains("struct") || l.contains("value")).take(6).collect::<Vec<_>>().join("\n"));
|
|
293
293
|
}
|
|
294
|
+
|
|
295
|
+
/// #614: a module-level binding demoted to boxed `Value` must NOT drag down a function-local
|
|
296
|
+
/// that merely shares its name. Both lowerings have to appear in one program — under the old
|
|
297
|
+
/// bare-name keying that was impossible, since one name got one verdict bundle-wide.
|
|
298
|
+
#[test]
|
|
299
|
+
fn module_demotion_does_not_box_a_same_named_local() {
|
|
300
|
+
let src = r#"
|
|
301
|
+
let i = 0
|
|
302
|
+
i = i + "x"
|
|
303
|
+
function modern(n: number) {
|
|
304
|
+
let i = 0
|
|
305
|
+
let t = 0
|
|
306
|
+
while (i < n) { t = t + i * 2; i = i + 1 }
|
|
307
|
+
return t
|
|
308
|
+
}
|
|
309
|
+
console.log(i)
|
|
310
|
+
console.log(modern(5))
|
|
311
|
+
"#;
|
|
312
|
+
let rust = compile(&parse(src).unwrap()).unwrap();
|
|
313
|
+
// The module `i` really can hold a String (`i + "x"`), so it must stay boxed.
|
|
314
|
+
assert!(
|
|
315
|
+
rust.contains("let mut i = Value::Number(0_f64)"),
|
|
316
|
+
"module-level `i` must stay boxed; got:\n{}",
|
|
317
|
+
rust.lines().filter(|l| l.contains("let mut i")).collect::<Vec<_>>().join("\n")
|
|
318
|
+
);
|
|
319
|
+
// `modern`'s `i` is a different variable, provably numeric, and must lower natively.
|
|
320
|
+
assert!(
|
|
321
|
+
rust.contains("let mut i: f64 = 0_f64"),
|
|
322
|
+
"fn-local `i` must stay native f64; got:\n{}",
|
|
323
|
+
rust.lines().filter(|l| l.contains("let mut i")).collect::<Vec<_>>().join("\n")
|
|
324
|
+
);
|
|
325
|
+
}
|
|
294
326
|
}
|
|
@@ -81,13 +81,22 @@ const BUILTIN_ALIASES: &[(&str, &str)] = &[
|
|
|
81
81
|
("encoding", "tish:encoding"),
|
|
82
82
|
("crypto", "tish:crypto"),
|
|
83
83
|
("zip", "tish:zip"),
|
|
84
|
+
("test", "tish:test"),
|
|
85
|
+
("assert", "tish:assert"),
|
|
86
|
+
("assert/strict", "tish:assert"),
|
|
84
87
|
];
|
|
85
88
|
|
|
86
89
|
/// Normalize built-in spec to canonical form. Handles the Node `node:` prefix
|
|
87
|
-
/// (`node:fs` -> `tish:fs`, `node:fs/promises` -> `tish:fs/promises
|
|
90
|
+
/// (`node:fs` -> `tish:fs`, `node:fs/promises` -> `tish:fs/promises`,
|
|
91
|
+
/// `node:test` -> `tish:test`, `node:assert` / `node:assert/strict` -> `tish:assert`)
|
|
92
|
+
/// and bare aliases.
|
|
88
93
|
pub fn normalize_builtin_spec(spec: &str) -> Option<String> {
|
|
89
94
|
// Strip a leading `node:` so `node:fs` resolves the same as `fs`.
|
|
90
95
|
let spec = spec.strip_prefix("node:").unwrap_or(spec);
|
|
96
|
+
// `node:assert/strict` (and bare `assert/strict`) → `tish:assert`.
|
|
97
|
+
if spec == "assert/strict" {
|
|
98
|
+
return Some("tish:assert".to_string());
|
|
99
|
+
}
|
|
91
100
|
if spec.starts_with("tish:") {
|
|
92
101
|
return Some(spec.to_string());
|
|
93
102
|
}
|
|
@@ -97,7 +106,7 @@ pub fn normalize_builtin_spec(spec: &str) -> Option<String> {
|
|
|
97
106
|
.map(|(_, canonical)| (*canonical).to_string())
|
|
98
107
|
}
|
|
99
108
|
|
|
100
|
-
/// Built-in modules that come from tishlang_runtime, not from package.json.
|
|
109
|
+
/// Built-in modules that come from tishlang_runtime / tishlang_test, not from package.json.
|
|
101
110
|
pub fn is_builtin_native_spec(spec: &str) -> bool {
|
|
102
111
|
let spec = spec.strip_prefix("node:").unwrap_or(spec);
|
|
103
112
|
matches!(
|
|
@@ -114,10 +123,12 @@ pub fn is_builtin_native_spec(spec: &str) -> bool {
|
|
|
114
123
|
| "tish:encoding"
|
|
115
124
|
| "tish:crypto"
|
|
116
125
|
| "tish:zip"
|
|
126
|
+
| "tish:test"
|
|
127
|
+
| "tish:assert"
|
|
117
128
|
) || matches!(
|
|
118
129
|
spec,
|
|
119
130
|
"fs" | "fs/promises" | "http" | "timers" | "process" | "ws" | "tty" | "pty" | "net"
|
|
120
|
-
| "encoding" | "crypto" | "zip"
|
|
131
|
+
| "encoding" | "crypto" | "zip" | "test" | "assert" | "assert/strict"
|
|
121
132
|
)
|
|
122
133
|
}
|
|
123
134
|
|
|
@@ -1031,7 +1042,7 @@ fn load_module_recursive(
|
|
|
1031
1042
|
/// resolution for `import … from "@scope/pkg"` is interop, and the native-vs-merged axis is the one
|
|
1032
1043
|
/// piece tish adds on top, keyed off the native markers rather than mere presence of `tish.module`.
|
|
1033
1044
|
pub fn is_native_import(spec: &str) -> bool {
|
|
1034
|
-
// A leading `node:` (node:fs, node:fs/promises) resolves
|
|
1045
|
+
// A leading `node:` (node:fs, node:fs/promises, node:assert/strict) resolves like the bare form.
|
|
1035
1046
|
let spec = spec.strip_prefix("node:").unwrap_or(spec);
|
|
1036
1047
|
spec.starts_with("tish:")
|
|
1037
1048
|
|| spec.starts_with("cargo:")
|
|
@@ -1039,7 +1050,7 @@ pub fn is_native_import(spec: &str) -> bool {
|
|
|
1039
1050
|
|| matches!(
|
|
1040
1051
|
spec,
|
|
1041
1052
|
"fs" | "fs/promises" | "http" | "timers" | "process" | "ws" | "tty" | "net" | "encoding"
|
|
1042
|
-
| "crypto" | "zip"
|
|
1053
|
+
| "crypto" | "zip" | "test" | "assert" | "assert/strict"
|
|
1043
1054
|
)
|
|
1044
1055
|
}
|
|
1045
1056
|
|
|
@@ -1231,6 +1242,29 @@ fn resolve_import_path(
|
|
|
1231
1242
|
spec
|
|
1232
1243
|
));
|
|
1233
1244
|
}
|
|
1245
|
+
// #618: build-time package-variant selection. `package.json` may remap an import specifier to
|
|
1246
|
+
// another module, which is tish's equivalent of a cargo feature swapping one implementation for
|
|
1247
|
+
// another:
|
|
1248
|
+
//
|
|
1249
|
+
// "tish": { "packageOverrides": { "./drop_tables": "./drop_tables_pocket" } }
|
|
1250
|
+
//
|
|
1251
|
+
// Applied to the SPECIFIER before any resolution, so it works for relative paths and package
|
|
1252
|
+
// names alike, and the replacement is then resolved by the ordinary rules (relative to the
|
|
1253
|
+
// importing file, as the original would have been). Matching is exact on the written
|
|
1254
|
+
// specifier — no prefix or glob matching, so an override cannot silently capture an import the
|
|
1255
|
+
// author did not mean to redirect.
|
|
1256
|
+
//
|
|
1257
|
+
// A remapped specifier is NOT re-checked against the override table: one substitution per
|
|
1258
|
+
// import, so a table that maps A->B and B->A terminates instead of looping.
|
|
1259
|
+
let overridden = {
|
|
1260
|
+
let cfg = read_project_tish_config(project_root);
|
|
1261
|
+
cfg.get("packageOverrides")
|
|
1262
|
+
.and_then(|o| o.as_object())
|
|
1263
|
+
.and_then(|o| o.get(spec))
|
|
1264
|
+
.and_then(|v| v.as_str())
|
|
1265
|
+
.map(|s| s.to_string())
|
|
1266
|
+
};
|
|
1267
|
+
let spec: &str = overridden.as_deref().unwrap_or(spec);
|
|
1234
1268
|
if !spec.starts_with("./") && !spec.starts_with("../") {
|
|
1235
1269
|
if let Some(path) = resolve_bare_spec(spec, from_dir, project_root) {
|
|
1236
1270
|
return Ok(path);
|
|
@@ -1445,10 +1479,26 @@ fn collect_destructure_names(pattern: &DestructPattern, out: &mut HashSet<String
|
|
|
1445
1479
|
|
|
1446
1480
|
/// Rename each module's non-exported top-level bindings whose name also occurs as a top-level
|
|
1447
1481
|
/// name in another module, isolating module-private declarations (#97).
|
|
1448
|
-
|
|
1482
|
+
///
|
|
1483
|
+
/// #587: this ALSO covers colliding EXPORTED names in non-entry modules, returning
|
|
1484
|
+
/// `renamed symbol -> original export name` per module so the export table can still be keyed by the
|
|
1485
|
+
/// name importers ask for. The original carve-out ("exported names stay stable so imports keep
|
|
1486
|
+
/// resolving") was stricter than necessary: `module_exports` is already an INDIRECTION from export
|
|
1487
|
+
/// name to source symbol, so the symbol is free to move as long as the key does not.
|
|
1488
|
+
///
|
|
1489
|
+
/// Leaving exported collisions alone was not merely a missed optimisation — it silently produced a
|
|
1490
|
+
/// wrong program. Two modules exporting `greet`, flattened into one scope, emit two
|
|
1491
|
+
/// `function greet` declarations; JS function declarations HOIST, so the second overwrote the first
|
|
1492
|
+
/// before `const _greet = greet` ran, and a barrel that re-wrapped its own import recursed until the
|
|
1493
|
+
/// stack blew.
|
|
1494
|
+
///
|
|
1495
|
+
/// The ENTRY module is deliberately excluded: its exports are the bundle's public surface, so
|
|
1496
|
+
/// renaming them would change the API the caller sees.
|
|
1497
|
+
fn isolate_private_top_level_bindings(modules: &mut [ResolvedModule]) -> Vec<HashMap<String, String>> {
|
|
1449
1498
|
let n = modules.len();
|
|
1499
|
+
let mut export_renames: Vec<HashMap<String, String>> = vec![HashMap::new(); n];
|
|
1450
1500
|
if n < 2 {
|
|
1451
|
-
return; // a single module cannot collide with another
|
|
1501
|
+
return export_renames; // a single module cannot collide with another
|
|
1452
1502
|
}
|
|
1453
1503
|
let mut decls: Vec<HashSet<String>> = vec![HashSet::new(); n];
|
|
1454
1504
|
let mut exported: Vec<HashSet<String>> = vec![HashSet::new(); n];
|
|
@@ -1471,13 +1521,35 @@ fn isolate_private_top_level_bindings(modules: &mut [ResolvedModule]) {
|
|
|
1471
1521
|
*count.entry(name.as_str()).or_insert(0) += 1;
|
|
1472
1522
|
}
|
|
1473
1523
|
}
|
|
1524
|
+
// #587: for EXPORTED names the question is different, and `occupancy` answers the wrong one.
|
|
1525
|
+
// It counts import bindings, so a dep's exported `greet` looks like it collides with the
|
|
1526
|
+
// importer's binding of that same `greet` — but those are one thing, not two, and renaming on
|
|
1527
|
+
// that basis breaks nothing yet churns every ordinary import. A real collision is another module
|
|
1528
|
+
// DECLARING the name, so exported renames key off declarations only.
|
|
1529
|
+
let mut decl_count: HashMap<&str, usize> = HashMap::new();
|
|
1530
|
+
for d in &decls {
|
|
1531
|
+
for name in d {
|
|
1532
|
+
*decl_count.entry(name.as_str()).or_insert(0) += 1;
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1474
1535
|
for (i, m) in modules.iter_mut().enumerate() {
|
|
1536
|
+
let is_entry = i + 1 == n; // the entry module is last — its exports are the public surface
|
|
1475
1537
|
let mut renames: HashMap<String, Arc<str>> = HashMap::new();
|
|
1476
1538
|
for name in &decls[i] {
|
|
1477
|
-
if
|
|
1478
|
-
continue; //
|
|
1539
|
+
if count.get(name.as_str()).copied().unwrap_or(0) <= 1 {
|
|
1540
|
+
continue; // no collision — leave it exactly as it is (zero blast radius)
|
|
1479
1541
|
}
|
|
1480
|
-
if
|
|
1542
|
+
if exported[i].contains(name) {
|
|
1543
|
+
// #587: an exported collision in a NON-ENTRY module is renamed too, and the new
|
|
1544
|
+
// symbol is recorded so `module_exports` can still answer to the original name.
|
|
1545
|
+
// Gated on DECLARATION count — see the note above.
|
|
1546
|
+
if is_entry || decl_count.get(name.as_str()).copied().unwrap_or(0) <= 1 {
|
|
1547
|
+
continue;
|
|
1548
|
+
}
|
|
1549
|
+
let renamed = format!("{name}__m{i}");
|
|
1550
|
+
export_renames[i].insert(renamed.clone(), name.clone());
|
|
1551
|
+
renames.insert(name.clone(), Arc::from(renamed));
|
|
1552
|
+
} else {
|
|
1481
1553
|
renames.insert(name.clone(), Arc::from(format!("{name}__m{i}")));
|
|
1482
1554
|
}
|
|
1483
1555
|
}
|
|
@@ -1491,6 +1563,7 @@ fn isolate_private_top_level_bindings(modules: &mut [ResolvedModule]) {
|
|
|
1491
1563
|
rewrite_stmt_scope(stmt, &mut active, true);
|
|
1492
1564
|
}
|
|
1493
1565
|
}
|
|
1566
|
+
export_renames
|
|
1494
1567
|
}
|
|
1495
1568
|
|
|
1496
1569
|
/// Apply the rename for a *declared* name. At module top level the binding is the canonical
|
|
@@ -1896,7 +1969,7 @@ pub fn merge_modules(mut modules: Vec<ResolvedModule>) -> Result<MergedProgram,
|
|
|
1896
1969
|
}
|
|
1897
1970
|
|
|
1898
1971
|
// #97: isolate module-private top-level bindings before they are flattened together.
|
|
1899
|
-
isolate_private_top_level_bindings(&mut modules);
|
|
1972
|
+
let export_renames = isolate_private_top_level_bindings(&mut modules);
|
|
1900
1973
|
|
|
1901
1974
|
let path_to_idx: HashMap<PathBuf, usize> = modules
|
|
1902
1975
|
.iter()
|
|
@@ -1916,7 +1989,14 @@ pub fn merge_modules(mut modules: Vec<ResolvedModule>) -> Result<MergedProgram,
|
|
|
1916
1989
|
}
|
|
1917
1990
|
_ => continue,
|
|
1918
1991
|
};
|
|
1919
|
-
|
|
1992
|
+
// #587: if this symbol was renamed for an exported collision, the export
|
|
1993
|
+
// table must still answer to the name importers ask for — key by the
|
|
1994
|
+
// ORIGINAL, point at the renamed symbol.
|
|
1995
|
+
let key = export_renames[idx]
|
|
1996
|
+
.get(&name)
|
|
1997
|
+
.cloned()
|
|
1998
|
+
.unwrap_or_else(|| name.clone());
|
|
1999
|
+
module_exports[idx].insert(key, name);
|
|
1920
2000
|
}
|
|
1921
2001
|
ExportDeclaration::Default(_) => {
|
|
1922
2002
|
let default_name = format!("__default_{}", idx);
|
|
@@ -2041,13 +2121,35 @@ pub fn merge_modules(mut modules: Vec<ResolvedModule>) -> Result<MergedProgram,
|
|
|
2041
2121
|
from.as_ref()
|
|
2042
2122
|
));
|
|
2043
2123
|
}
|
|
2044
|
-
ImportSpecifier::Default { name,
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2124
|
+
ImportSpecifier::Default { name, name_span } => {
|
|
2125
|
+
// Only `tish:assert` (and aliases) expose a callable default.
|
|
2126
|
+
// Other natives still require named imports.
|
|
2127
|
+
if canonical_spec != "tish:assert" {
|
|
2128
|
+
return Err(format!(
|
|
2129
|
+
"Default import '{}' not supported for native module '{}'. Use named import, e.g. import {{ egui }} from '{}'",
|
|
2130
|
+
name.as_ref(),
|
|
2131
|
+
from.as_ref(),
|
|
2132
|
+
from.as_ref()
|
|
2133
|
+
));
|
|
2134
|
+
}
|
|
2135
|
+
let init = Expr::NativeModuleLoad {
|
|
2136
|
+
spec: Arc::from(canonical_spec.clone()),
|
|
2137
|
+
export_name: Arc::from("default"),
|
|
2138
|
+
span: *span,
|
|
2139
|
+
};
|
|
2140
|
+
merge_push(
|
|
2141
|
+
&mut statements,
|
|
2142
|
+
&mut statement_sources,
|
|
2143
|
+
Statement::VarDecl {
|
|
2144
|
+
name: name.clone(),
|
|
2145
|
+
name_span: *name_span,
|
|
2146
|
+
mutable: false,
|
|
2147
|
+
type_ann: None,
|
|
2148
|
+
init: Some(init),
|
|
2149
|
+
span: *span,
|
|
2150
|
+
},
|
|
2151
|
+
src_path.clone(),
|
|
2152
|
+
);
|
|
2051
2153
|
}
|
|
2052
2154
|
}
|
|
2053
2155
|
}
|
|
@@ -327,6 +327,27 @@ impl RustType {
|
|
|
327
327
|
| BinOp::StrictNe => Some(RustType::Bool),
|
|
328
328
|
_ => None,
|
|
329
329
|
}
|
|
330
|
+
} else if (lhs == &RustType::Fixed && rhs == &RustType::F64)
|
|
331
|
+
|| (lhs == &RustType::F64 && rhs == &RustType::Fixed)
|
|
332
|
+
{
|
|
333
|
+
// #606: a MIXED `fixed`/`f64` operand pair. Previously this fell through to `None`, so
|
|
334
|
+
// the whole expression dropped to the boxed `ops::*` path — and on an FPU-less
|
|
335
|
+
// ARM7TDMI that path also routes through SOFT-FLOAT f64, which is the expensive part.
|
|
336
|
+
//
|
|
337
|
+
// The f64 side is lifted to `Fixed` at the operator, with the SAME truncating semantics
|
|
338
|
+
// the literal fold already applies (`Fixed::from_raw((x * 256.0) as i32)`), so a mixed
|
|
339
|
+
// expression now agrees with the equivalent all-literal one instead of disagreeing with
|
|
340
|
+
// it. `%`/`**`/bitwise stay boxed, exactly as they do for `Fixed × Fixed`.
|
|
341
|
+
match op {
|
|
342
|
+
BinOp::Add | BinOp::Sub | BinOp::Mul | BinOp::Div => Some(RustType::Fixed),
|
|
343
|
+
BinOp::Lt
|
|
344
|
+
| BinOp::Le
|
|
345
|
+
| BinOp::Gt
|
|
346
|
+
| BinOp::Ge
|
|
347
|
+
| BinOp::StrictEq
|
|
348
|
+
| BinOp::StrictNe => Some(RustType::Bool),
|
|
349
|
+
_ => None,
|
|
350
|
+
}
|
|
330
351
|
} else if lhs == &RustType::Fixed && rhs == &RustType::Fixed {
|
|
331
352
|
// agb `Num<i32,8>` overloads `+ - * /` (Mul/Div apply the Q24.8 shift
|
|
332
353
|
// correction) and `PartialOrd`/`PartialEq`, so these lower to native
|
|
@@ -512,8 +533,21 @@ impl RustType {
|
|
|
512
533
|
"match &{} {{ Value::String(s) => s.to_string(), _ => panic!(\"expected string\") }}",
|
|
513
534
|
value_expr
|
|
514
535
|
),
|
|
536
|
+
// #610: A NUMBER REACHING A BOOL BOUNDARY MUST NOT SHIP A PANIC. This arm accepted only
|
|
537
|
+
// `Value::Bool`, so passing `1` where a typed extern — or any bool-typed boundary —
|
|
538
|
+
// declares `bool` compiled clean, emitted no coercion, and panicked on the first call.
|
|
539
|
+
// The failure is latent until that exact line runs, which is how tish-gba's space-goo
|
|
540
|
+
// shipped it.
|
|
541
|
+
//
|
|
542
|
+
// Numbers coerce as `n != 0`. This does NOT reintroduce loose equality: `bool === 1` is
|
|
543
|
+
// still false, because that is a question about the COMPARISON operator. This is a
|
|
544
|
+
// boundary CONVERSION — the signature has already said `bool`, the way an `as` cast
|
|
545
|
+
// would — and a total conversion there beats a guaranteed abort.
|
|
546
|
+
//
|
|
547
|
+
// Anything genuinely unconvertible still panics, so a string or object reaching a bool
|
|
548
|
+
// parameter stays exactly as loud as before.
|
|
515
549
|
RustType::Bool => format!(
|
|
516
|
-
"match &{} {{ Value::Bool(b) => *b, _ => panic!(\"expected boolean\") }}",
|
|
550
|
+
"match &{} {{ Value::Bool(b) => *b, Value::Number(n) => *n != 0.0, _ => panic!(\"expected boolean\") }}",
|
|
517
551
|
value_expr
|
|
518
552
|
),
|
|
519
553
|
RustType::Unit => "()".to_string(),
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
//! #641 — call arity. A call with too few arguments used to compile, bind the missing parameter to
|
|
2
|
+
//! `Value::Null`, and then either panic at runtime (boxed callee, typed params), fail in rustc
|
|
3
|
+
//! (direct native callee), or silently yield null/NaN (interpreter) — depending on which lowering
|
|
4
|
+
//! the CALLEE happened to get. These pin the rule and, just as importantly, the non-rule: omitting
|
|
5
|
+
//! an argument bound to an UNTYPED parameter stays legal, because shipping packages rely on it.
|
|
6
|
+
|
|
7
|
+
use tishlang_compile::check_call_arity;
|
|
8
|
+
use tishlang_parser::parse;
|
|
9
|
+
|
|
10
|
+
fn diags(src: &str) -> Vec<String> {
|
|
11
|
+
let p = parse(src).expect("parse");
|
|
12
|
+
check_call_arity(&p).into_iter().map(|d| d.message).collect()
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
#[test]
|
|
16
|
+
fn omitting_a_typed_parameter_is_an_error() {
|
|
17
|
+
let d = diags(
|
|
18
|
+
r#"
|
|
19
|
+
function paintTyped(slot: i32, rv: i32): i32 { return slot + rv }
|
|
20
|
+
paintTyped(1)
|
|
21
|
+
"#,
|
|
22
|
+
);
|
|
23
|
+
assert_eq!(d.len(), 1, "expected exactly one diagnostic, got {:?}", d);
|
|
24
|
+
assert!(d[0].contains("paintTyped"), "names the callee: {}", d[0]);
|
|
25
|
+
assert!(d[0].contains("needs 2"), "states the requirement: {}", d[0]);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
#[test]
|
|
29
|
+
fn omitting_an_untyped_parameter_is_allowed() {
|
|
30
|
+
// The established idiom: `packages/shop.tish` has six of these, `packages/ui.tish` one.
|
|
31
|
+
// Enforcing arity here would break shipping code that is behaving exactly as intended.
|
|
32
|
+
let d = diags(
|
|
33
|
+
r#"
|
|
34
|
+
function renderTab(defer, stream) { return 1 }
|
|
35
|
+
renderTab()
|
|
36
|
+
renderTab(1)
|
|
37
|
+
renderTab(1, 1)
|
|
38
|
+
"#,
|
|
39
|
+
);
|
|
40
|
+
assert!(d.is_empty(), "untyped params must stay optional, got {:?}", d);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
#[test]
|
|
44
|
+
fn a_defaulted_parameter_imposes_no_minimum() {
|
|
45
|
+
// The default is what fills the slot, so nothing arrives as null.
|
|
46
|
+
let d = diags(
|
|
47
|
+
r#"
|
|
48
|
+
function f(a: i32, b: i32 = 7): i32 { return a + b }
|
|
49
|
+
f(1)
|
|
50
|
+
"#,
|
|
51
|
+
);
|
|
52
|
+
assert!(d.is_empty(), "a default supplies the argument, got {:?}", d);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
#[test]
|
|
56
|
+
fn a_typed_parameter_after_untyped_ones_still_forces_its_position() {
|
|
57
|
+
// A call cannot skip positions to reach `c`, so the minimum is 3 even though 0 and 1 are
|
|
58
|
+
// untyped — the minimum is one past the LAST typed parameter, not a count of typed ones.
|
|
59
|
+
let d = diags(
|
|
60
|
+
r#"
|
|
61
|
+
function g(a, b, c: i32): i32 { return c }
|
|
62
|
+
g(1, 2)
|
|
63
|
+
"#,
|
|
64
|
+
);
|
|
65
|
+
assert_eq!(d.len(), 1, "expected one diagnostic, got {:?}", d);
|
|
66
|
+
assert!(d[0].contains("needs 3"), "minimum is positional: {}", d[0]);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
#[test]
|
|
70
|
+
fn a_spread_argument_is_not_second_guessed() {
|
|
71
|
+
// The count is unknowable at compile time; saying nothing beats guessing wrong.
|
|
72
|
+
let d = diags(
|
|
73
|
+
r#"
|
|
74
|
+
function h(a: i32, b: i32): i32 { return a + b }
|
|
75
|
+
let xs = [1, 2]
|
|
76
|
+
h(...xs)
|
|
77
|
+
"#,
|
|
78
|
+
);
|
|
79
|
+
assert!(d.is_empty(), "spread must not be flagged, got {:?}", d);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
#[test]
|
|
83
|
+
fn a_nested_call_is_still_seen() {
|
|
84
|
+
// The reported shape. `for_each_stmt_expr` hands out only a statement's ROOT expression, so
|
|
85
|
+
// without full sub-expression walking this exact case — the one in the issue — is missed.
|
|
86
|
+
let d = diags(
|
|
87
|
+
r#"
|
|
88
|
+
function paintTyped(slot: i32, rv: i32): i32 { return slot + rv }
|
|
89
|
+
console.log("v = " + paintTyped(1))
|
|
90
|
+
"#,
|
|
91
|
+
);
|
|
92
|
+
assert_eq!(d.len(), 1, "nested call must be checked, got {:?}", d);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
#[test]
|
|
96
|
+
fn a_correct_call_is_silent() {
|
|
97
|
+
let d = diags(
|
|
98
|
+
r#"
|
|
99
|
+
function paintTyped(slot: i32, rv: i32): i32 { return slot + rv }
|
|
100
|
+
paintTyped(1, 2)
|
|
101
|
+
"#,
|
|
102
|
+
);
|
|
103
|
+
assert!(d.is_empty(), "correct arity must not warn, got {:?}", d);
|
|
104
|
+
}
|
|
@@ -109,17 +109,17 @@ pub fn process_argv() -> Vec<String> {
|
|
|
109
109
|
}
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
112
|
+
// #303 — a thrown JS value parked while it unwinds across a boundary that can't carry a `Result`.
|
|
113
|
+
//
|
|
114
|
+
// The native value-fn ABI is `Fn(&[Value]) -> Value`, and `Callable::call` likewise returns a bare
|
|
115
|
+
// `Value`, so a `throw` crossing such a boundary can't ride a `Result`. It is parked here and picked
|
|
116
|
+
// up at the next checkpoint: native codegen checks it after each call; the VM checks it after each
|
|
117
|
+
// `Callable::call`; and the shared array builtins (`forEach`/`map`/`sort`/…) check it between
|
|
118
|
+
// elements so they stop iterating promptly instead of running the callback for the rest of the
|
|
119
|
+
// array. The slot lives in `tishlang_core` (rather than `tishlang_runtime`) so `tishlang_builtins`
|
|
120
|
+
// can poll it without a `builtins -> runtime` dependency cycle; `tishlang_runtime` and `tishlang_vm`
|
|
121
|
+
// share this one slot. First-throw-wins; drained exactly once by [`take_pending_throw`] at the frame
|
|
122
|
+
// that converts it back into a `Result`.
|
|
123
123
|
#[cfg(not(feature = "portable"))]
|
|
124
124
|
thread_local! {
|
|
125
125
|
static PENDING_THROW: core::cell::RefCell<Option<Value>> = const { core::cell::RefCell::new(None) };
|
|
@@ -151,6 +151,98 @@ pub fn take_pending_throw() -> Option<Value> {
|
|
|
151
151
|
PENDING_THROW.with(|c| c.borrow_mut().take())
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
// Cooperative execution deadline (`tish test --timeout`).
|
|
156
|
+
//
|
|
157
|
+
// Without this a runaway loop is uninterruptible: the runner can only notice a blown
|
|
158
|
+
// deadline *after* the body returns, which never happens. Backends poll
|
|
159
|
+
// [`execution_deadline_exceeded`] on loop back-edges and abort with a catchable error.
|
|
160
|
+
//
|
|
161
|
+
// Disarmed is the default and costs one relaxed load — `tish run` never arms it. Armed,
|
|
162
|
+
// the clock is read once every `DEADLINE_POLL_MASK + 1` back-edges so a hot loop is not
|
|
163
|
+
// paying a `now()` per iteration.
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
#[cfg(not(feature = "portable"))]
|
|
167
|
+
mod deadline {
|
|
168
|
+
use core::sync::atomic::{AtomicU64, Ordering};
|
|
169
|
+
use std::sync::LazyLock;
|
|
170
|
+
use std::time::Instant;
|
|
171
|
+
|
|
172
|
+
/// Nanoseconds since [`START`] at which execution must stop. 0 = disarmed.
|
|
173
|
+
static DEADLINE_NS: AtomicU64 = AtomicU64::new(0);
|
|
174
|
+
static START: LazyLock<Instant> = LazyLock::new(Instant::now);
|
|
175
|
+
|
|
176
|
+
const DEADLINE_POLL_MASK: u32 = 0x3FF;
|
|
177
|
+
|
|
178
|
+
thread_local! {
|
|
179
|
+
static POLL_TICK: core::cell::Cell<u32> = const { core::cell::Cell::new(0) };
|
|
180
|
+
static TRIPPED: core::cell::Cell<bool> = const { core::cell::Cell::new(false) };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/// Arm the deadline `millis` from now, or disarm with `None`. Clears any previous trip.
|
|
184
|
+
pub fn set_execution_deadline(millis: Option<u64>) {
|
|
185
|
+
TRIPPED.with(|t| t.set(false));
|
|
186
|
+
POLL_TICK.with(|t| t.set(0));
|
|
187
|
+
match millis {
|
|
188
|
+
Some(ms) => {
|
|
189
|
+
let at = START.elapsed().as_nanos() as u64 + ms.saturating_mul(1_000_000);
|
|
190
|
+
// 0 means "disarmed"; nudge an exact-zero deadline to 1ns.
|
|
191
|
+
DEADLINE_NS.store(at.max(1), Ordering::Relaxed);
|
|
192
|
+
}
|
|
193
|
+
None => DEADLINE_NS.store(0, Ordering::Relaxed),
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/// Has the armed deadline passed? Cheap and false when disarmed.
|
|
198
|
+
#[inline]
|
|
199
|
+
pub fn execution_deadline_exceeded() -> bool {
|
|
200
|
+
let at = DEADLINE_NS.load(Ordering::Relaxed);
|
|
201
|
+
if at == 0 {
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
if TRIPPED.with(|t| t.get()) {
|
|
205
|
+
return true;
|
|
206
|
+
}
|
|
207
|
+
let tick = POLL_TICK.with(|t| {
|
|
208
|
+
let n = t.get().wrapping_add(1);
|
|
209
|
+
t.set(n);
|
|
210
|
+
n
|
|
211
|
+
});
|
|
212
|
+
if tick & DEADLINE_POLL_MASK != 0 {
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
if START.elapsed().as_nanos() as u64 >= at {
|
|
216
|
+
TRIPPED.with(|t| t.set(true));
|
|
217
|
+
return true;
|
|
218
|
+
}
|
|
219
|
+
false
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/// Did this thread abort because the deadline tripped?
|
|
223
|
+
pub fn execution_deadline_tripped() -> bool {
|
|
224
|
+
TRIPPED.with(|t| t.get())
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Portable / single-core builds (GBA and friends) have no wall clock to poll.
|
|
229
|
+
#[cfg(feature = "portable")]
|
|
230
|
+
mod deadline {
|
|
231
|
+
pub fn set_execution_deadline(_millis: Option<u64>) {}
|
|
232
|
+
#[inline]
|
|
233
|
+
pub fn execution_deadline_exceeded() -> bool {
|
|
234
|
+
false
|
|
235
|
+
}
|
|
236
|
+
pub fn execution_deadline_tripped() -> bool {
|
|
237
|
+
false
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
pub use deadline::{execution_deadline_exceeded, execution_deadline_tripped, set_execution_deadline};
|
|
242
|
+
|
|
243
|
+
/// Message backends raise when [`execution_deadline_exceeded`] trips.
|
|
244
|
+
pub const DEADLINE_ERROR: &str = "Test exceeded its timeout and was aborted";
|
|
245
|
+
|
|
154
246
|
// Current user-function call depth for the bytecode VM. The VM's recursive path builds a fresh
|
|
155
247
|
// `Vm` per call (so no shared struct field can accumulate) and its `Callable::call` signature is
|
|
156
248
|
// fixed — a thread-local is the VM's equivalent of the interpreter's shared `Rc<Cell>` counter.
|
|
@@ -639,6 +639,7 @@ impl Evaluator {
|
|
|
639
639
|
}
|
|
640
640
|
Statement::While { cond, body, .. } => {
|
|
641
641
|
loop {
|
|
642
|
+
deadline_check()?;
|
|
642
643
|
if !self.eval_expr(cond)?.is_truthy() {
|
|
643
644
|
break;
|
|
644
645
|
}
|
|
@@ -778,6 +779,10 @@ impl Evaluator {
|
|
|
778
779
|
};
|
|
779
780
|
let mut ret = Ok(Value::Null);
|
|
780
781
|
loop {
|
|
782
|
+
if let Err(e) = deadline_check() {
|
|
783
|
+
ret = Err(e);
|
|
784
|
+
break;
|
|
785
|
+
}
|
|
781
786
|
self.scope = Rc::clone(&loop_env);
|
|
782
787
|
let cond_ok = match cond.as_ref() {
|
|
783
788
|
Some(c) => match self.eval_expr(c) {
|
|
@@ -909,6 +914,7 @@ impl Evaluator {
|
|
|
909
914
|
}
|
|
910
915
|
Statement::DoWhile { body, cond, .. } => {
|
|
911
916
|
loop {
|
|
917
|
+
deadline_check()?;
|
|
912
918
|
match self.eval_statement(body) {
|
|
913
919
|
Ok(_) => {}
|
|
914
920
|
Err(EvalError::Break) => break,
|
|
@@ -5389,6 +5395,16 @@ impl Evaluator {
|
|
|
5389
5395
|
}
|
|
5390
5396
|
}
|
|
5391
5397
|
|
|
5398
|
+
/// Cooperative timeout poll for loop back-edges (`tish test --timeout`). Disarmed on every
|
|
5399
|
+
/// other path, where it costs one relaxed atomic load.
|
|
5400
|
+
#[inline]
|
|
5401
|
+
fn deadline_check() -> Result<(), EvalError> {
|
|
5402
|
+
if tishlang_core::execution_deadline_exceeded() {
|
|
5403
|
+
return Err(EvalError::Error(tishlang_core::DEADLINE_ERROR.to_string()));
|
|
5404
|
+
}
|
|
5405
|
+
Ok(())
|
|
5406
|
+
}
|
|
5407
|
+
|
|
5392
5408
|
#[derive(Debug)]
|
|
5393
5409
|
enum EvalError {
|
|
5394
5410
|
Return(Value),
|
|
@@ -187,6 +187,16 @@ pub fn eval_to_core(v: &Value) -> Result<CoreValue, String> {
|
|
|
187
187
|
}
|
|
188
188
|
Value::Symbol(s) => Ok(CoreValue::Symbol(Arc::clone(s))),
|
|
189
189
|
Value::Opaque(o) => Ok(CoreValue::Opaque(Arc::clone(o))),
|
|
190
|
+
// A core function that came IN through `core_to_eval` round-trips back out unchanged.
|
|
191
|
+
Value::CoreFn(f) => Ok(CoreValue::Function(f.clone())),
|
|
192
|
+
// Interpreter closures carry an AST body plus a scope that only the `Evaluator` can
|
|
193
|
+
// resolve, and they do not outlive it. Say so instead of printing a discriminant.
|
|
194
|
+
Value::Function { .. } | Value::Native(_) => Err(
|
|
195
|
+
"Cannot pass an interpreter function to a native module: interpreter closures are \
|
|
196
|
+
bound to the evaluator and cannot be invoked from native code. Use the default \
|
|
197
|
+
(VM) backend."
|
|
198
|
+
.to_string(),
|
|
199
|
+
),
|
|
190
200
|
_ => Err(format!(
|
|
191
201
|
"Cannot pass {:?} to native function (unsupported type)",
|
|
192
202
|
std::mem::discriminant(v)
|