Tags: tishlang/tish
Tags
perf(native): set-optimistic param-numeric fixpoint (#172) — fannkuch… … 4.06x → 2.51x (#281) M4 param inference required every use of a param to be syntactically numeric, but bailed on the common copy-then-compare shape `let r = n; …; r === n`: `r` wasn't seeded numeric (only literal inits were), so `r === n` couldn't prove `n` — and `n` → `r` → `count` stayed boxed (the fannkuch keystone left after #170). - `collect_numeric_locals` is now a monotone fixpoint: seed literal inits, then also add `let x = y` where `y` is already numeric (copy-chains propagate). - `pi_stmt` runs a per-param OPTIMISTIC fixpoint: assume the candidate param numeric, propagate its copies (`let r = n` → `r` numeric), then VERIFY every use of the param (`nus_stmt`) AND every copy-descendant (a new conservative `lns_*` local-numeric verifier) is numeric-safe. A genuinely non-numeric use — string concat, object store, member access, bare call-arg — still bails, so `fn label(x){ return "v=" + x }` keeps `x` dynamic. `lns_*` defaults to bail (`_ => !mentions`), so an unhandled form can't launder a wrong type. Soundness rests on M4's existing contract (caller passes a number for any inferred param; non-number → NaN-coercion at the boxed edge), widened monotonically and gated by the copy-descendant verifier. fannkuch 580ms → 359ms (4.06x → 2.51x vs node; cumulative 8.2x → 2.51x with #170) — `n`/`r` → f64, `count` → Vec<f64>, the outer `count[r-1]=r` loop native, `r === n` → `r == n`. Still FAIL (does the same work as node now, just less optimized). Validated: all 21 gauntlet checksums hold (typed==boxed==node, fannkuch 7319600038), no regression; cargo test --workspace green incl. the cross-backend mutation.tish/objects.tish differential; +2 param_infer tests (copy-then-compare infers number; copy-then-string-concat stays dynamic).
perf(native): persist proven array-element types onto the node (#170,… … fannkuch 2x) (#280) `si_block` (the type-aware second inference pass) re-derives an unannotated local's type once array/struct types are known — e.g. `let k = perm[0]` / `let temp = perm[i]` after `perm: number[]` is inferred — but it only updated the inference `ctx`; the `VarDecl` node kept `type_ann: None`. Codegen reads the NODE, not `ctx`, so the local stayed a boxed `Value` (with `ops::sub` / type checks per inner-loop iteration) despite being provably `f64`. Persist a proven `number` type back onto the node in that second pass. Soundness: only writes when the node was unannotated and the init proves `number` (not string/etc), and the codegen demote-gate (`collect_demoted_numeric_locals`) re-boxes any such local whose later reassignment can escape `number` — so it can never miscompile. This is roadmap #170. fannkuch 1184ms → 580ms (8.2x → 4.06x vs node) — the inner reversal loop (`k`/`k2`/`temp`) is now native f64. Also improved object_sum (0.67x→0.33x), nsieve (0.82x→0.69x), matmul (1.0x→0.82x): the write-back helps any `let x = typedArray[i]`. Not yet a flip — the outer `count[r-1]=r` loop is still boxed via the param-`n` cascade (a separate keystone fix, FIX 2). Validated: all 21 gauntlet checksums hold (typed==boxed==node), no regression; cargo test --workspace 370 passed; +2 infer tests.
feat(wasm): --target wasm-gpu — build the WebGPU runtime + start(chun… …k, host) loader (#277) (#279) `tish build --target wasm` hardcoded `--features browser` and a `run(chunk)` loader, so the reflection-based WebGPU bridge that already ships in tishlang_wasm_runtime/src/gpu.rs (behind `--features gpu`, with a `start(chunk, host)` entry that installs the GPU FFI globals and drives an rAF loop) was built-but-unreachable from the CLI — a WebGPU project had to hand-roll its own `cargo build --features gpu` + custom loader. Add `--target wasm-gpu`: same pipeline as `wasm`, but builds tishlang_wasm_runtime with `--features gpu` and emits an HTML loader that bootstraps WebGPU (requestAdapter → device, canvas → context.configure with the preferred format) into a `host` env object { gpu, adapter, device, queue, context, format, canvas, assets } and calls `start(chunk, host)`. The loader is a working, editable default — its `buildHost()` is the documented host-env contract (issue option 2), so apps customize the canvas/assets without reverse-engineering gpu.rs. Plain `--target wasm` is byte-for-byte unchanged. Threads a `gpu: bool` through compile_to_wasm / compile_program_to_wasm / emit_wasm_from_chunk; extracts the loader into a pure `loader_html(stem, chunk_b64, gpu)` with unit tests asserting the gpu loader uses `start`+WebGPU bootstrap and the plain loader uses `run`. Help + unknown-target error updated. The `gpu` feature builds clean on wasm32-unknown-unknown.
perf(native): i32-register loop accumulators — fnv_hash 2.5x faster (… …to node parity) (#278) A `number` local proven by the #174 int-range lattice to always hold an integer, and written ONLY by bitwise/shift reassignments inside a following for-loop, is now kept in an `i32` register across the loop instead of round-tripping `f64`↔`i32` (`to_int32`, with its is_finite branch) on every op. Bun/JSC-style: a proven-integer hash accumulator lives in an integer register, with a single mandatory f64 excursion for the one multiply that exceeds 2^53 (matching V8's round-then-ToUint32). fnv_hash: 277ms → 108ms vs node ~106ms — a 2.5x win landing at parity (it does the same f64 multiply V8 does, so parity is the honest ceiling; the strict typed<=node verdict flaps ±1-2ms run-to-run at the tie). Mechanism (crates/tish_compile): - types.rs: new `RustType::I32` (the JS ToInt32 signed bit-pattern view); only the codegen loop-var lowering produces it, never `from_annotation`. Boxes as `Value::Number(x as f64)`. - codegen.rs: `collect_i32_loop_vars` detects the pattern under a STRICT all-or-nothing gate — lattice-proven integer (a), not refcell-captured (b), every loop reassignment a fully int32-lowerable bitwise chain (c), a forward signedness pass admitting numeric reads only at int32-valued points (d, since `^/&/|/<</>>` yield signed int32 but `>>>` yields uint32), and no writer outside the loop. Bails to the existing f64 path otherwise (purely additive). `emit_int32_operand` reads an I32 leaf as the raw register (no `to_int32`); a non-bitwise binop operand coerces `(h as f64)`. A bound-proven leaf (`|x| < 2^62`, finite — proven by a conservative `f64_abs_bound`) drops the is_finite guard via an inline `to_int_unchecked::<i64>`. Among the 21 gauntlet benchmarks only fnv's hash accumulator qualifies (mandelbrot/fannkuch/ queens loop counters use `+`/`++`, not bitwise, so they're untouched). Validated: full gauntlet all 21 hold typed==boxed==node (fnv checksum 87326928, no regression on the other 20); cargo test --workspace 368 passed.
fix(interp): JSON.parse delegates to the shared parser — string-aware… … nesting + key order (#276) The interpreter had its own hand-rolled JSON.parse (eval.rs json_parse_str/_object/_array/_one/ _string). json_parse_one located the end of a nested object/array value by depth-counting `{}`/`[]` brackets WITHOUT skipping string contents, so any nested value whose string held a bracket — e.g. `JSON.parse('{"a":{"s":"}"},"b":2}')` — mis-sliced and the entire parse failed to `null`, diverging from Node and the VM/native/cranelift/wasi backends (which use tishlang_core::json_parse). It was also O(n^2) (re-scan + re-parse per nested value) and only decoded a subset of escapes (errored on \u, \/, \b, \f). Replace the whole chain with a single delegation to tishlang_core::json_parse + core_to_eval — one spec-correct, string-aware, insertion-ordered source of truth shared with every other backend. core_to_eval is total for all JSON-producible values, and invalid input still yields null (prior behavior). Net -180 lines. Locked in by tests/core/parity_json_key_order (flat/descending/many/nested key order, bracket-in- string values, unicode escapes, JSON.stringify of the parsed value), verified identical across interp/vm/rust/cranelift/wasi/node.
perf(json): ~2x faster JSON parse/stringify; fix number-format + key-… …order parity (#180) (#275) json_roundtrip went 317ms -> 159ms (2.31x -> 1.18x vs node), entirely in the shared tish_core JSON path (so all backends benefit; codegen flags are irrelevant here). The verified-sound changes: - Number stringify: integer fast-path (|n| < 2^53 -> i64 via itoa) with js_number_to_string for the rest. Empirically byte-identical to node JSON.stringify (incl -0 -> "0", 1e+21, 1e-7) — Rust's `{}` Display, used before, diverges. Added js_number_to_string_into so the number formatter appends with no per-number String alloc. - parse_string: escape-free fast-path byte-scans to the closing quote and slices the input in one allocation instead of decoding char-by-char (the common case for keys/values). - parse_object: build the insertion-ordered PropMap directly. Drops a throwaway AHashMap + re-collect per object AND fixes a real bug — the AHashMap intermediate scrambled JSON.parse key order non-deterministically (its RandomState reseeds per process). - Per-call key interning: repeated object keys (the shape of an array of records) share one Arc<str> instead of re-allocating ~2M times over the run. - parse_number: integer fast-path (<=15 digits -> i64, exact in f64), preserving -0.0. Correctness fix (beyond perf): JSON.stringify of -0/1e21/1e-7 and JSON.parse key order now match node on interp/vm/native/cranelift/js. The interpreter's separate JSON path was aligned to the shared write_json_number helper. Locked in by the new tests/core/parity_json probe. Does not fully flip (still 1.18x): the residual is allocation-bound (per-object Rc + per-string Arc in the parsed tree), which needs a structural change (arena / Value-repr), out of scope here.
fix: -0 ToString is "0" across all backends; inspect keeps "-0" (#247) ( #274) `(-0).toString()`, `String(-0)`, `"" + (-0)`, and `${-0}` returned "-0" on interp/vm/native/cranelift, diverging from the JS target (Node), where ECMAScript Number::toString drops negative zero's sign. The console *inspect* form correctly keeps "-0" (`console.log(-0)` → `-0`), so the two paths are now split rather than sharing one formatter: - tish_core js_number_to_string → "0" for ±0 (the spec ToString), with the sign preserved only in Value::to_display_string (inspect); to_js_string gets an explicit Number arm so ToString never inherits the inspect form. - tish_eval Value mirrors the split (Display = inspect "-0", to_js_string = "0"). - tish_opt keeps its duplicated js_number_to_string byte-for-byte in sync so a constant-folded `"" + (-0)` matches the runtime ("0"). Locks the behavior into the parity_builtins probe (last open #247 row) + regen the CI bundle. interp/vm/native/cranelift/js all agree with Node.
fix(parser): function span ends at its body's closing brace (#158 fol… …low-up) (#273) Companion to the block-span fix: `parse_fun_decl` computed the declaration's end as `max(peek().start, body.span().end)`. With the body block span now correct (ends at `}`, #158), the `max` with `peek().start` — the NEXT token after the function — only ever overran the function span onto the following line, inflating its folding range and document-symbol range. Use the body's end directly; a function now spans `fn`/`async` … `}` exactly. Span-metadata only (no execution change). New parser test pins both the function span and its body block span to the `}` line. All parser/resolver/ lsp/lint/fmt tests pass.
fix(parser): block span ends at its closing brace, not the next token (… …#158) (#272) `parse_block` advanced past the closing `}`/`Dedent`, then computed the block end from `self.peek()` — which by then pointed at the NEXT token. So a block's span overran onto the following statement. The visible symptom: completion on the line right after a function's `}` offered the function's params and inner locals (`collect_block_locals` saw the cursor as still inside the overrun body span), inserting names that don't resolve. Fix at the source: capture the closing `}`/`Dedent` token's end BEFORE advancing and use it as the block end (falling back to the last inner statement's end for an unterminated block at EOF). A block now spans exactly `{`..`}`, which is also correct for every other span consumer (folding ranges, document symbols, hover) — all were getting a too-large range. Metadata-only: execution is unchanged (verified interp+vm), and the full cross-backend integration suite, parser/resolver/lint/lsp/fmt/vm/eval unit tests all pass. New resolver test asserts completion no longer leaks a function's params/locals onto the line after its closing brace.
feat(lint): add --deny-warnings to gate CI on lint findings (#154) (#271 ) The standalone `tish-lint` could never exit non-zero on an actual lint finding: both rules emit Severity::Warning and the exit code was gated only on error_count (which only parse errors reach), so CI couldn't gate on lint warnings without scraping output. Add `--deny-warnings`: when set, warnings count toward the non-zero exit alongside errors. Default behavior is unchanged (warnings print, exit 0). Verified: clean file exits 0; a duplicate-key file exits 0 by default and 1 with --deny-warnings.
PreviousNext