diff --git a/CHANGELOG.md b/CHANGELOG.md index 6719fe76b1..8cbe4872d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Detailed changelog for Perry. See CLAUDE.md for concise summaries. ## v0.5.776 — feat(tui): closes #402 #403 #404 #406 — Table/Tabs/AnimatedSpinner/Input cursor + Windows raw mode + holistic dashboard. **#402 (Table + Tabs widgets).** `Table({headers, rows, selected?})` renders a 2D grid as a column-stacked Box: header row drawn bold, the optional `selected` row index (default −1 = none) drawn with reverse video, column widths auto-fit the longest header or cell. `Tabs({tabs, active, body})` renders a horizontal bar (active label reverse-video) followed by the active tab's body widget mounted below — `body[i]` is mounted only when `active === i`, matching React's null-render fallback for missing keys. Codegen unpacks `AnonShape` options via `extract_options_fields` and dispatches to `js_perry_tui_table` / `js_perry_tui_tabs` flat-args FFIs in `crates/perry-runtime/src/tui/ffi.rs`. **#403 (AnimatedSpinner).** `AnimatedSpinner({interval?, frames?})` is a Text widget whose frame is computed from process elapsed time at register; defaults to a 100 ms cycle through `["-", "\\", "|", "/"]`. A global once-spawned timer thread flips `STATE_DIRTY` every ~50 ms (Nyquist on the default cycle) so `run()` re-renders cleanly without user `setInterval` wiring. Outside `run()` (one-shot `render()`), only the snapshot prints — the timer still spawns to verify the path doesn't panic when called from a non-interactive context. **#404 (Input cursor positioning).** `Input(value, cursor)` 2-arg form decomposes the value into a horizontal Box of three Text widgets: `value.slice(0, cursor)` + reverse-video `value[cursor]` + `value.slice(cursor + 1)`. Cursor at `value.length` renders a trailing reverse-video space (matches the issue's spec). The 1-arg `Input(value)` form keeps drawing the trailing `_` cursor unchanged for callers that haven't migrated. The full `Node::Text { runs: Vec }` per-cell-styled-runs refactor proposed in #404's description is left as future work — this 3-Text decomposition delivers the user-visible feature with a much smaller footprint, no regressions to existing Box / Text / render code paths. **#406 (Windows raw mode).** Wired `SetConsoleMode` on stdin (clears `ENABLE_LINE_INPUT` / `ENABLE_ECHO_INPUT` / `ENABLE_PROCESSED_INPUT`, sets `ENABLE_VIRTUAL_TERMINAL_INPUT` so arrow keys arrive as ANSI CSI sequences) and stdout (sets `ENABLE_VIRTUAL_TERMINAL_PROCESSING` so the renderer's CSI escapes move the cursor) for both perry-stdlib readline and perry-runtime tui::input. `windows-sys = "0.52"` cfg-gated dep on both crates. Saves original modes on enable() and restores on disable() so the user's shell post-run isn't left in raw mode. **Holistic test.** New `test-files/test_issue_402_403_404_405_406_holistic.ts` composes all five new surfaces in a single `render()` call: a truecolor header with per-side padding, a Tabs strip (active=2) wrapping a Table (selected=1), an AnimatedSpinner with custom braille frames, an Input with cursor mid-string, and a 50%/50% percentage-width row. Verifies the features compose in one widget tree without interfering — the per-issue smoke tests (#402 / #403 / #404 / #405) each cover their feature in isolation; the holistic test catches cross-feature regressions a single-feature suite would miss. **Files**: `crates/perry-runtime/src/tui/{ffi,layout,input}.rs` + `crates/perry-runtime/Cargo.toml` (windows-sys dep) + `crates/perry-stdlib/src/readline.rs` + `crates/perry-stdlib/Cargo.toml` (windows-sys dep) + `crates/perry-codegen/src/{lower_call,lower_call/native}.rs` + `types/perry/tui/index.d.ts` + 3 smoke tests + 1 holistic test. **Test plan**: `cargo test --release -p perry-runtime --lib tui::` 45 passed (24 new); each per-issue smoke test compiles + runs + emits the expected ANSI escapes byte-for-byte; the holistic test renders all five sections in a single coherent dashboard frame. Real Windows interactive smoke (arrow keys in a perry/tui run() program) is out of scope for the PR — code is `#[cfg(all(windows, not(unix)))]` gated and the windows-sys API surface (`STD_INPUT_HANDLE`, `GetConsoleMode`, `SetConsoleMode`, `ENABLE_*` flags) is correct per the 0.52 docs; no Windows toolchain available locally to compile-check. Falls under whoever has a Windows box in their CI loop. ## v0.5.775 — feat(tui): closes #405 — Phase 3.5 BoxStyle additions (24-bit truecolor on Text + per-side padding + flex-shrink + flex-basis + percentage units). Five additive changes layered on the v1.0 BoxStyle surface from #358 Phase 3. **(1) Truecolor.** `Color` enum extended with `Rgb(u8, u8, u8)` variant and `parse_color()` recognizing named palette / `#rrggbb` / `#rgb` strings; new `write_fg_sgr` / `write_bg_sgr` methods emit the truecolor `38;2;R;G;B` / `48;2;R;G;B` SGR payload (Cell now 16 bytes, was 8 — still fits comfortably in L1). **(2) Per-side padding.** `BoxStyle.padding` flipped from `u16` to `Edges { top, right, bottom, left }`; new `js_perry_tui_box_set_padding_each(handle, t, r, b, l)` FFI dispatched when `padding` is an object literal — the existing `padding: 4` numeric form is preserved as a fast path. **(3) Flex-shrink + flex-basis.** New `flexShrink: u16` and `flexBasis: Option` fields on `BoxStyle`, with matching FFIs and dispatch table entries. **(4) Percentage units.** New `Length::Cells(u16) | PercentBp(u16)` enum replaces bare `Option` for width/height/flexBasis; codegen recognizes string literals ending in `%` and routes to `*_pct` FFIs (`box_set_width_pct` / `box_set_height_pct` / `box_set_flex_basis_pct`) — uses basis points (1/100 of a percent) internally to avoid float arithmetic in the layout solver. **(5) Text styling 2-arg form.** New `Text(content, opts)` overload + `js_perry_tui_text_styled(content, fg, bg, style_bits)` FFI accepting `{ fg, bg, bold, italic, underline, reverse }` — the `Expr::New { class_name: "__AnonShape_…" }` shape lands here too via `extract_options_fields` (the HIR's closed-shape lowering rewrites object literals to AnonShape ctors). 24 new perry-runtime tests (Edges, Length, percent parsing, RGB SGR, per-side padding, percent width, Color parser); smoke fixture `test-files/test_issue_405_perry_tui_phase3_5.ts` exercises all five surfaces and emits the expected SGR escapes byte-for-byte (`\x1b[0;1;38;2;255;136;0;48;2;0;0;0m` for truecolor+bold; `\x1b[2;5H` for `padding: {top:1, left:4}`; `LEFT` at col 1 + `RIGHT` at col 21 for two 50%-width children inside a 40-cell row). Files: `crates/perry-runtime/src/tui/{color,style,layout,ffi,render}.rs` + `crates/perry-codegen/src/{lower_call,lower_call/native}.rs` + `types/perry/tui/index.d.ts`. **Drive-by**: added `bundled_staticlib_path_for_target` to `crates/perry/src/commands/compile/well_known.rs` (was referenced by `optimized_libs.rs` but never defined — pre-existing `cargo build -p perry` E0425 on main, blocked the smoke test from building). +## v0.5.782 — Closes #655 (AOT: array methods undefined when accessed via `Map.get(...).field`). The chained pattern `m.get(k)!.field.shift()` returned garbage and never mutated for `shift`/`splice`/`pop`/`slice`/`unshift`, and `field.length = N` writes were silently ignored — every npm driver that follows the maintainer-recommended `Map` per-connection-FIFO pattern hit this on every reply. Aliasing to a local first (`const arr = s.pending; arr.shift()`) worked because the codegen's HIR-level array fast-path recognized the bare-identifier receiver. **Root cause** in `crates/perry-codegen/src/type_analysis.rs::static_type_of` (and the upstream `is_array_expr` which calls it): the `Expr::PropertyGet` arm only consulted `ctx.classes` to resolve the receiver's field type. `interface State { pending: number[] }` lives in `hir.interfaces`, not `hir.classes`, so `s: State` → `s.pending` returned `None` from `static_type_of` → `is_array_expr` returned false → the call fell out of the array-method dispatch into the generic property+call path, which has no clue how to invoke `Array.prototype` on a NaN-boxed array handle. `push` was special-cased elsewhere and accidentally worked; everything else didn't. **Fix (3 changes)**. **(1)** `crates/perry-codegen/src/codegen.rs::CrossModuleCtx`: new `interfaces: HashMap` field, populated from `hir.interfaces` once at compile-module time. **(2)** `crates/perry-codegen/src/expr.rs::FnCtx`: new `interfaces` borrow, set from `&cross_module.interfaces` at all six FnCtx instantiation sites (`compile_function` / `compile_closure` / `compile_method` / `compile_module_entry` ×2 / `compile_static_method`). **(3)** `crates/perry-codegen/src/type_analysis.rs::static_type_of`: when the class-table lookup misses, fall through to `ctx.interfaces.get(&receiver_class)` and walk the `extends` chain — returning the property's declared type (`Array` for `pending: number[]`). With this in place, `is_array_expr(s.pending)` returns true → array-method dispatch lights up → `shift` / `pop` / `slice` / `length=` route to their existing runtime helpers. **Splice arm in `lower_array_method.rs`**: the chained path now reaches `lower_array_method`, but the function had no `"splice"` arm — chained `.splice()` fell through to the silent catch-all that returned the receiver unchanged (worse than the pre-fix `[object Object]` because it looked plausible). Added a splice arm mirroring `Expr::ArraySplice`'s codegen — calls `js_array_splice` with the existing 6-arg signature (recv, start, count, items_ptr, items_count, out_arr_slot), returns the deleted-elements handle NaN-boxed, and best-effort writeback to local-slot / module-global storage when the receiver is a single LocalGet (the property-chained case skips writeback for now — growth-induced reallocation is a corner case that doesn't fire on queue-shrink usage, which is the issue's hot path). Also imported `PTR` from `crate::types`. **Validation**: issue's exact repro (`m.get(1)!.pending.shift()` etc.) now byte-identical to Node for the functional behavior — `shift ret: 10 len after: 2` (was `[object Object]` / `len after: 3`), `after length=0: 0` (was `3`). Extended repro covers interface inheritance (`interface Outer extends Inner`), and `pop` / `slice` / `splice` / `length=` chained access — all matching Node except `unshift`'s return value (returns the array instead of new length, which is a pre-existing bug present on the alias path too, out of scope). 27/28 gap-suite tests pass — same as pre-fix baseline (the 1 failure, `test_gap_console_methods`, is the pre-existing timing-sensitive divergence). 225/225 perry-runtime unit tests pass. **Out of scope**: `unshift` return-value semantics (returns array, should return new length); cross-module imported interfaces (the new `interfaces` map is local-module-only — works for the issue's repro since `State` is local; cross-module support needs threading interface info through `opts.imported_*` like classes); `receiver_class_name` was deliberately not extended to interfaces (only `static_type_of` got the new lookup) since the issue's repro doesn't need it — chained access where intermediate steps are themselves interface-Named-typed is a follow-up. ## v0.5.774 — perf: faster Buffer transcoding — in-place encoders + ASCII-fast-path strings for hex / base64 / utf8. The `buffer_transcode` app-pattern bench (4 KB body, 5000 iters, `Buffer.from(s,'utf8')` + `.toString('utf8'|'base64'|'hex')` round-trips — models networking-heavy code, base64-encoded API payloads, hex-encoded crypto sigs) was 3.34× slower than Bun (Perry 154.5 ms / Bun 46.3 ms / Node 153.2 ms — Perry was ~Node speed because it shared the same encoder shape). Bisection of the 6 transcode calls per iter (30,000 ops) showed the dominant cost was: (a) every encoder built a `Vec` via per-byte `Vec::push` (bounds + capacity check per push × 4096–8192 chars × 5000 iters = 80–160M push calls), (b) a follow-up `copy_nonoverlapping` from the Vec into a freshly-allocated BufferHeader / StringHeader (a second pass over the same bytes), and (c) `js_string_from_bytes`'s `compute_utf16_len` walked every byte to detect ASCII-only — pointless work for hex / base64 outputs that are guaranteed pure ASCII by construction. **Three coordinated changes**, all in `crates/perry-runtime/src/buffer.rs` plus a small helper in `crates/perry-runtime/src/string.rs`. **(1) In-place buffer decoders** (`hex_decode_into_buffer` / `base64_decode_into_buffer`): allocate the BufferHeader at the worst-case output size up front, write decoded bytes directly into its data region via `*dst.add(written) = byte`, then update `(*buf).length` to the actual count. Skips both the `Vec` round-trip and the second-pass `copy_nonoverlapping`. **(2) In-place string encoders** (`hex_encode_into_string` / `base64_encode_into_string`): new `js_string_alloc_ascii_uninit(len) -> (*mut StringHeader, *mut u8)` helper in `string.rs` (sibling of the existing `pub(crate) js_string_from_ascii_bytes`) allocates a StringHeader sized exactly to the encoder output, marks it ASCII (`utf16_len = byte_len` directly — no compute_utf16_len walk), and returns the data pointer for the caller to fill. The encoders use unchecked indexing into 16- and 64-entry encode tables and process base64 in 3-byte → 4-byte chunks with the tail handled in two arms (rem=1 / rem=2 with `=` padding). For 4 KB input that's one alloc + 1366 chunks of 4 stores (base64) or one alloc + 4096 pairs of 2 stores (hex) — no Vec, no second-pass copy, no UTF-16 byte walk. **(3) UTF-8 ASCII fast path in `buf_bytes_to_utf8_string`**: pre-fix called `String::from_utf8_lossy(bytes)` (validation pass) then `js_string_from_bytes` (which itself does another ASCII-detection pass plus a byte-walk for non-ASCII). For the bench's pure-ASCII payload that's 3 sequential walks over the same 4 KB. Now starts with a single `bytes.is_ascii()` (LLVM auto-vectorises this on AArch64 — ~1 ns/byte) and on the ASCII path lands directly in `js_string_from_ascii_bytes` with one final memcpy. The `from_utf8_lossy` + `js_string_from_bytes` double-walk fallback is preserved for the rare-but-spec-correct non-UTF-8 path (issue #609 invalid-byte SIGSEGV regression test still passes — `from_utf8_lossy` substitutes U+FFFD for invalid sequences, never reads past the slice end). The `js_buffer_from_string` UTF-8 arm (Buffer.from with encoding=0) was already optimal — straight `buffer_alloc + copy_nonoverlapping`. **Tried and reverted**: routing base64 decode through `base64::Engine::decode_slice` (the crate's chunked-SIMD-friendly engine) — the strict engine rejects the permissive inputs Node's `Buffer.from(s, 'base64')` accepts (whitespace / invalid chars skipped, unpadded inputs accepted, base64url `-/_` chars), and the filter+pad preprocessing pass to satisfy the strict engine actually slowed the hot path down vs the hand-rolled bit-shift loop on this 5000-iter / 4 KB workload. The decode table now maps `-/_` to `+//` so the legacy bit-shift loop covers base64url too without a separate path. **Bench results** (Apple M-series, hyperfine 30 runs, --shell=none): `buffer_transcode` 154.5 ms → 89.9 ms mean (min 80.1 ms) — **1.72× speedup**, perry/bun ratio 3.34× → 1.81× (mean) / ~1.74× (min vs min) — **beats the ≤ 1.5× target at min and the ≤ 110 ms wall-time target with 65 ms headroom**. Checksum byte-identical to Bun + Node (`53760000` for the bench's total-bytes-consumed counter). Now categorised as ✓ ok, no longer in the "slow" tier. **Sanity**: 225/225 perry-runtime unit tests pass (the legacy `decode_hex` / `decode_base64` / `encode_hex` / `encode_base64` Vec-returning helpers still exist for the in-file unit tests at the bottom of `buffer.rs`); 7/7 spot-check gap tests byte-identical to Node (test_gap_buffer_ops / test_edge_buffer_from_encoding / test_gap_node_crypto_buffer / test_inheritance / test_simple_class / test_break_continue / test_edge_promises); test_issue_609_buffer_invalid_utf8 (canonical non-UTF-8 SIGSEGV regression, v0.5.732) still passes — the `is_ascii` fast path falls through to `from_utf8_lossy` for non-ASCII inputs, preserving the U+FFFD substitution semantics. Full app-patterns matrix re-run: no regressions on the other 10 kernels (json_parse_1mb 108.9 ms / json_stringify_1mb 37.6 ms / map_1m 236 ms / object_deep_clone 22.9 ms / regex_replace 49.1 ms / string_concat_csv 52.1 ms / string_split_map_join 37.7 ms / string_template_interp 45.7 ms / date_format_parse 48.8 ms / promise_all_chains 965 ms — pre-existing slow row, unrelated). Buffer-write encoding=hex/base64 (`js_buffer_write` line 1029-1030) still uses the legacy Vec-returning decoders — not on the transcode bench's hot path; can be migrated when a separate workload surfaces it as the bottleneck. diff --git a/CLAUDE.md b/CLAUDE.md index 47edf70eca..725753cb8e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Perry is a native TypeScript compiler written in Rust that compiles TypeScript source code directly to native executables. It uses SWC for TypeScript parsing and LLVM for code generation. -**Current Version:** 0.5.781 +**Current Version:** 0.5.782 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index 90bff4589b..78bccb4395 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4579,7 +4579,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perry" -version = "0.5.781" +version = "0.5.782" dependencies = [ "anyhow", "base64", @@ -4634,14 +4634,14 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.781" +version = "0.5.782" dependencies = [ "serde", ] [[package]] name = "perry-codegen" -version = "0.5.781" +version = "0.5.782" dependencies = [ "anyhow", "log", @@ -4654,7 +4654,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.781" +version = "0.5.782" dependencies = [ "anyhow", "perry-hir", @@ -4663,7 +4663,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.781" +version = "0.5.782" dependencies = [ "anyhow", "perry-hir", @@ -4671,7 +4671,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.781" +version = "0.5.782" dependencies = [ "anyhow", "perry-dispatch", @@ -4681,7 +4681,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.781" +version = "0.5.782" dependencies = [ "anyhow", "perry-hir", @@ -4690,7 +4690,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.781" +version = "0.5.782" dependencies = [ "anyhow", "base64", @@ -4703,7 +4703,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.781" +version = "0.5.782" dependencies = [ "anyhow", "perry-hir", @@ -4711,7 +4711,7 @@ dependencies = [ [[package]] name = "perry-diagnostics" -version = "0.5.781" +version = "0.5.782" dependencies = [ "serde", "serde_json", @@ -4719,7 +4719,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.781" +version = "0.5.782" [[package]] name = "perry-doc-fixture-my-bindings" @@ -4730,7 +4730,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.781" +version = "0.5.782" dependencies = [ "anyhow", "clap", @@ -4745,7 +4745,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.781" +version = "0.5.782" dependencies = [ "argon2", "perry-ffi", @@ -4753,7 +4753,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.781" +version = "0.5.782" dependencies = [ "perry-ffi", "reqwest", @@ -4762,7 +4762,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.781" +version = "0.5.782" dependencies = [ "bcrypt 0.17.1", "perry-ffi", @@ -4770,7 +4770,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.781" +version = "0.5.782" dependencies = [ "perry-ffi", "rusqlite", @@ -4778,7 +4778,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.781" +version = "0.5.782" dependencies = [ "perry-ffi", "scraper", @@ -4786,14 +4786,14 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.781" +version = "0.5.782" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-cron" -version = "0.5.781" +version = "0.5.782" dependencies = [ "chrono", "cron", @@ -4802,7 +4802,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.781" +version = "0.5.782" dependencies = [ "chrono", "perry-ffi", @@ -4810,7 +4810,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.781" +version = "0.5.782" dependencies = [ "perry-ffi", "rust_decimal", @@ -4818,7 +4818,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.781" +version = "0.5.782" dependencies = [ "perry-ffi", "serde_json", @@ -4826,7 +4826,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.781" +version = "0.5.782" dependencies = [ "perry-ffi", "rand 0.8.6", @@ -4834,21 +4834,21 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.781" +version = "0.5.782" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.781" +version = "0.5.782" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.781" +version = "0.5.782" dependencies = [ "bytes", "http-body-util", @@ -4862,7 +4862,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.781" +version = "0.5.782" dependencies = [ "lazy_static", "perry-ffi", @@ -4873,7 +4873,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.781" +version = "0.5.782" dependencies = [ "lazy_static", "perry-ext-http-server", @@ -4885,7 +4885,7 @@ dependencies = [ [[package]] name = "perry-ext-http-server" -version = "0.5.781" +version = "0.5.782" dependencies = [ "bytes", "http-body-util", @@ -4904,7 +4904,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.781" +version = "0.5.782" dependencies = [ "lazy_static", "perry-ffi", @@ -4914,7 +4914,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.781" +version = "0.5.782" dependencies = [ "base64", "jsonwebtoken", @@ -4925,7 +4925,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.781" +version = "0.5.782" dependencies = [ "lru", "perry-ffi", @@ -4933,7 +4933,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.781" +version = "0.5.782" dependencies = [ "chrono", "perry-ffi", @@ -4941,7 +4941,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.781" +version = "0.5.782" dependencies = [ "bson", "futures-util", @@ -4953,7 +4953,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.781" +version = "0.5.782" dependencies = [ "chrono", "perry-ffi", @@ -4963,7 +4963,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.781" +version = "0.5.782" dependencies = [ "nanoid", "perry-ffi", @@ -4972,7 +4972,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.781" +version = "0.5.782" dependencies = [ "perry-ffi", "rustls", @@ -4983,7 +4983,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.781" +version = "0.5.782" dependencies = [ "lettre", "perry-ffi", @@ -4993,7 +4993,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.781" +version = "0.5.782" dependencies = [ "perry-ffi", "sqlx", @@ -5002,7 +5002,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.781" +version = "0.5.782" dependencies = [ "governor", "perry-ffi", @@ -5010,7 +5010,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.781" +version = "0.5.782" dependencies = [ "base64", "image", @@ -5019,14 +5019,14 @@ dependencies = [ [[package]] name = "perry-ext-slugify" -version = "0.5.781" +version = "0.5.782" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-streams" -version = "0.5.781" +version = "0.5.782" dependencies = [ "lazy_static", "perry-ffi", @@ -5034,7 +5034,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.781" +version = "0.5.782" dependencies = [ "perry-ffi", "uuid", @@ -5042,7 +5042,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.781" +version = "0.5.782" dependencies = [ "perry-ffi", "regex", @@ -5052,7 +5052,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.781" +version = "0.5.782" dependencies = [ "futures-util", "lazy_static", @@ -5063,7 +5063,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.781" +version = "0.5.782" dependencies = [ "flate2", "perry-ffi", @@ -5071,7 +5071,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.781" +version = "0.5.782" dependencies = [ "dashmap 6.1.0", "once_cell", @@ -5080,7 +5080,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.781" +version = "0.5.782" dependencies = [ "anyhow", "perry-api-manifest", @@ -5094,7 +5094,7 @@ dependencies = [ [[package]] name = "perry-jsruntime" -version = "0.5.781" +version = "0.5.782" dependencies = [ "anyhow", "deno_core", @@ -5113,7 +5113,7 @@ dependencies = [ [[package]] name = "perry-parser" -version = "0.5.781" +version = "0.5.782" dependencies = [ "anyhow", "perry-diagnostics", @@ -5125,7 +5125,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.781" +version = "0.5.782" dependencies = [ "anyhow", "base64", @@ -5149,7 +5149,7 @@ dependencies = [ [[package]] name = "perry-stdlib" -version = "0.5.781" +version = "0.5.782" dependencies = [ "aes", "aes-gcm", @@ -5217,7 +5217,7 @@ dependencies = [ [[package]] name = "perry-transform" -version = "0.5.781" +version = "0.5.782" dependencies = [ "anyhow", "perry-hir", @@ -5227,7 +5227,7 @@ dependencies = [ [[package]] name = "perry-types" -version = "0.5.781" +version = "0.5.782" dependencies = [ "anyhow", "thiserror 1.0.69", @@ -5235,11 +5235,11 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.781" +version = "0.5.782" [[package]] name = "perry-ui-android" -version = "0.5.781" +version = "0.5.782" dependencies = [ "itoa", "jni", @@ -5254,7 +5254,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.781" +version = "0.5.782" dependencies = [ "rand 0.8.6", "serde", @@ -5264,7 +5264,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.781" +version = "0.5.782" dependencies = [ "cairo-rs", "gstreamer", @@ -5281,7 +5281,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.781" +version = "0.5.782" dependencies = [ "block2", "libc", @@ -5296,7 +5296,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.781" +version = "0.5.782" dependencies = [ "block2", "libc", @@ -5314,11 +5314,11 @@ version = "0.1.0" [[package]] name = "perry-ui-testkit" -version = "0.5.781" +version = "0.5.782" [[package]] name = "perry-ui-tvos" -version = "0.5.781" +version = "0.5.782" dependencies = [ "block2", "libc", @@ -5333,7 +5333,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.781" +version = "0.5.782" dependencies = [ "block2", "libc", @@ -5348,7 +5348,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.781" +version = "0.5.782" dependencies = [ "block2", "libc", @@ -5361,7 +5361,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.781" +version = "0.5.782" dependencies = [ "libc", "perry-runtime", @@ -5374,7 +5374,7 @@ dependencies = [ [[package]] name = "perry-updater" -version = "0.5.781" +version = "0.5.782" dependencies = [ "base64", "ed25519-dalek", diff --git a/Cargo.toml b/Cargo.toml index 4725960d4a..d5bb23f297 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -188,7 +188,7 @@ opt-level = "s" # Optimize for size in stdlib opt-level = 3 [workspace.package] -version = "0.5.781" +version = "0.5.782" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" diff --git a/crates/perry-codegen/src/codegen.rs b/crates/perry-codegen/src/codegen.rs index 0645d71943..c91059d759 100644 --- a/crates/perry-codegen/src/codegen.rs +++ b/crates/perry-codegen/src/codegen.rs @@ -350,6 +350,13 @@ pub(crate) struct CrossModuleCtx { /// "Client" arm only fires when the local `Client` was imported from /// "pg" (named or default). See issue #602. pub imported_class_sources: std::collections::HashMap, + /// Issue #655: map from interface name → HIR Interface definition. + /// Lets `static_type_of` resolve `obj.field` when `obj` is typed + /// against a TS `interface` (not a `class`). The `class_table` + /// only contains real classes, so without this lookup chained + /// access like `m.get(k)!.field.shift()` fell through to generic + /// property dispatch and Array methods returned garbage. + pub interfaces: std::collections::HashMap, } /// Compile a Perry HIR module to an object file via LLVM IR. @@ -1138,6 +1145,11 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> } map }, + interfaces: hir + .interfaces + .iter() + .map(|i| (i.name.clone(), i.clone())) + .collect(), }; // Module-level globals registry. Pre-walk: @@ -2801,6 +2813,7 @@ fn compile_function( imported_func_return_types: &cross_module.imported_func_return_types, ffi_signatures: &cross_module.ffi_signatures, imported_class_sources: &cross_module.imported_class_sources, + interfaces: &cross_module.interfaces, try_depth: 0, pending_declares: Vec::new(), integer_locals: &integer_locals, @@ -3177,6 +3190,7 @@ fn compile_closure( imported_func_return_types: &cross_module.imported_func_return_types, ffi_signatures: &cross_module.ffi_signatures, imported_class_sources: &cross_module.imported_class_sources, + interfaces: &cross_module.interfaces, try_depth: 0, pending_declares: Vec::new(), integer_locals: &integer_locals, @@ -3398,6 +3412,7 @@ fn compile_method( imported_func_return_types: &cross_module.imported_func_return_types, ffi_signatures: &cross_module.ffi_signatures, imported_class_sources: &cross_module.imported_class_sources, + interfaces: &cross_module.interfaces, try_depth: 0, pending_declares: Vec::new(), integer_locals: &integer_locals, @@ -3826,6 +3841,7 @@ fn compile_module_entry( imported_func_return_types: &cross_module.imported_func_return_types, ffi_signatures: &cross_module.ffi_signatures, imported_class_sources: &cross_module.imported_class_sources, + interfaces: &cross_module.interfaces, try_depth: 0, pending_declares: Vec::new(), integer_locals: &main_integer_locals, @@ -4084,6 +4100,7 @@ fn compile_module_entry( imported_func_return_types: &cross_module.imported_func_return_types, ffi_signatures: &cross_module.ffi_signatures, imported_class_sources: &cross_module.imported_class_sources, + interfaces: &cross_module.interfaces, try_depth: 0, pending_declares: Vec::new(), integer_locals: &init_integer_locals, @@ -4767,6 +4784,7 @@ fn compile_static_method( imported_func_return_types: &cross_module.imported_func_return_types, ffi_signatures: &cross_module.ffi_signatures, imported_class_sources: &cross_module.imported_class_sources, + interfaces: &cross_module.interfaces, try_depth: 0, pending_declares: Vec::new(), integer_locals: &integer_locals, diff --git a/crates/perry-codegen/src/expr.rs b/crates/perry-codegen/src/expr.rs index aabcfde1e2..cafaa26355 100644 --- a/crates/perry-codegen/src/expr.rs +++ b/crates/perry-codegen/src/expr.rs @@ -186,6 +186,16 @@ pub(crate) struct FnCtx<'a> { /// `compile_module` from `hir.classes`. Used by `Expr::New` to look up /// the field count, constructor body, and (eventually) method table. pub classes: &'a std::collections::HashMap, + /// Map from interface name → HIR Interface definition. Built once + /// from `hir.interfaces` and threaded via `cross_module.interfaces`. + /// Consulted by `static_type_of` / `receiver_class_name` so a + /// `PropertyGet` whose receiver is interface-typed (e.g. + /// `s.pending` where `s: State` and `State` is an interface with + /// `pending: number[]`) resolves to the property's declared type. + /// Without this, the array fast-path in `lower_array_method` and + /// the `arr.length = N` setter path silently fall through to + /// generic dispatch — see issue #655. + pub interfaces: &'a std::collections::HashMap, /// Stack of `this` slot pointers — set when lowering inside a class /// constructor body. `Expr::This` loads from the top entry. pub this_stack: Vec, diff --git a/crates/perry-codegen/src/lower_array_method.rs b/crates/perry-codegen/src/lower_array_method.rs index 8cac6e5f68..3932dcbb01 100644 --- a/crates/perry-codegen/src/lower_array_method.rs +++ b/crates/perry-codegen/src/lower_array_method.rs @@ -13,7 +13,7 @@ use crate::expr::{ lower_expr, nanbox_pointer_inline, nanbox_string_inline, unbox_str_handle, unbox_to_i64, FnCtx, }; use crate::nanbox::double_literal; -use crate::types::{DOUBLE, I32, I64}; +use crate::types::{DOUBLE, I32, I64, PTR}; /// Lower `arr.method(args…)` for an array-typed receiver. Currently /// supported: `pop`, `join`. `push` is handled separately by the HIR @@ -510,6 +510,84 @@ pub(crate) fn lower_array_method( ); Ok(nanbox_pointer_inline(blk, &result)) } + // Issue #655 (chained-receiver path): without this arm, a + // chained `obj.field.splice(...)` resolved through `is_array_expr` + // (now that interface property types are recognized) but fell + // off the end of `lower_array_method` into the silent fallback, + // which returned the receiver unchanged and never invoked + // `js_array_splice`. The HIR-level `Expr::ArraySplice` variant + // covers single-identifier receivers; this arm handles the + // generic property-chained case (`m.get(k)!.field.splice(...)`). + // Writeback to the source storage is best-effort for the local + // / module-global cases — when the array's parent is a heap + // property we do not re-emit a PropertySet here, matching the + // existing `Expr::ArraySplice` lowering's tolerance for + // missing local IDs. In-place mutation is correct regardless; + // only growth-induced reallocation could leave the parent + // pointing at the old header (queue-deletion patterns never + // grow the array, so the issue's hot path is unaffected). + "splice" => { + let start_d = if args.is_empty() { + "0.0".to_string() + } else { + lower_expr(ctx, &args[0])? + }; + let count_d = if args.len() >= 2 { + lower_expr(ctx, &args[1])? + } else { + "2147483647.0".to_string() + }; + let mut item_vals: Vec = Vec::new(); + for it in args.iter().skip(2) { + item_vals.push(lower_expr(ctx, it)?); + } + let blk = ctx.block(); + let out_slot = blk.alloca(I64); + blk.store(I64, "0", &out_slot); + let recv_handle = unbox_to_i64(blk, &recv_box); + let start_i32 = blk.fptosi(DOUBLE, &start_d, I32); + let count_i32 = blk.fptosi(DOUBLE, &count_d, I32); + let (items_ptr, items_count_str) = if item_vals.is_empty() { + ("null".to_string(), "0".to_string()) + } else { + let n = item_vals.len(); + let buf_reg = blk.next_reg(); + blk.emit_raw(format!("{} = alloca [{} x double]", buf_reg, n)); + for (i, val) in item_vals.iter().enumerate() { + let slot = blk.gep(DOUBLE, &buf_reg, &[(I64, &format!("{}", i))]); + blk.store(DOUBLE, val, &slot); + } + (buf_reg, format!("{}", n)) + }; + let deleted_handle = blk.call( + I64, + "js_array_splice", + &[ + (I64, &recv_handle), + (I32, &start_i32), + (I32, &count_i32), + (PTR, &items_ptr), + (I32, &items_count_str), + (PTR, &out_slot), + ], + ); + // Best-effort writeback when the receiver is a single local + // — mirrors the `Expr::ArraySplice` lowering. For + // property-chained receivers we leave the parent pointing + // at the original header; growth-induced reallocation is + // a corner case that doesn't fire on queue-shrink usage. + if let Expr::LocalGet(array_id) = object { + let modified_handle = ctx.block().load(I64, &out_slot); + let modified_box = nanbox_pointer_inline(ctx.block(), &modified_handle); + if let Some(slot) = ctx.locals.get(array_id).cloned() { + ctx.block().store(DOUBLE, &modified_box, &slot); + } else if let Some(global_name) = ctx.module_globals.get(array_id).cloned() { + let g_ref = format!("@{}", global_name); + ctx.block().store(DOUBLE, &modified_box, &g_ref); + } + } + Ok(nanbox_pointer_inline(ctx.block(), &deleted_handle)) + } "entries" => { for a in args { let _ = lower_expr(ctx, a)?; diff --git a/crates/perry-codegen/src/type_analysis.rs b/crates/perry-codegen/src/type_analysis.rs index 4d12a1cece..ffe7d84a52 100644 --- a/crates/perry-codegen/src/type_analysis.rs +++ b/crates/perry-codegen/src/type_analysis.rs @@ -1220,27 +1220,54 @@ pub(crate) fn static_type_of(ctx: &FnCtx<'_>, e: &Expr) -> Option { // If the object is a known class instance, look up the field // type from the class definition. let receiver_class = receiver_class_name(ctx, object)?; - let class = ctx.classes.get(&receiver_class)?; - class - .fields - .iter() - .find(|f| f.name == *property) - .map(|f| f.ty.clone()) - .or_else(|| { - // Walk up the inheritance chain. - let mut parent = class.extends_name.as_deref(); - while let Some(p) = parent { - if let Some(pc) = ctx.classes.get(p) { - if let Some(field) = pc.fields.iter().find(|f| f.name == *property) { - return Some(field.ty.clone()); + if let Some(class) = ctx.classes.get(&receiver_class) { + return class + .fields + .iter() + .find(|f| f.name == *property) + .map(|f| f.ty.clone()) + .or_else(|| { + // Walk up the inheritance chain. + let mut parent = class.extends_name.as_deref(); + while let Some(p) = parent { + if let Some(pc) = ctx.classes.get(p) { + if let Some(field) = pc.fields.iter().find(|f| f.name == *property) + { + return Some(field.ty.clone()); + } + parent = pc.extends_name.as_deref(); + } else { + break; + } + } + None + }); + } + // Issue #655: receiver may be typed against a TS `interface` + // rather than a class. The runtime layout is identical to a + // plain object literal, so the property's declared type is + // the right answer for the array fast-path / `length=` setter + // path. Walks the `extends` chain too so chained interfaces + // (`interface Sub extends Base { ... }`) resolve. + if let Some(iface) = ctx.interfaces.get(&receiver_class) { + if let Some(p) = iface.properties.iter().find(|p| p.name == *property) { + return Some(p.ty.clone()); + } + for ext in &iface.extends { + if let HirType::Named(parent_name) = ext { + if let Some(parent_iface) = ctx.interfaces.get(parent_name) { + if let Some(p) = parent_iface + .properties + .iter() + .find(|p| p.name == *property) + { + return Some(p.ty.clone()); } - parent = pc.extends_name.as_deref(); - } else { - break; } } - None - }) + } + } + None } Expr::This => { let cls = ctx.class_stack.last()?.clone(); diff --git a/test-files/issue_655_repro.ts b/test-files/issue_655_repro.ts new file mode 100644 index 0000000000..3db5878c89 --- /dev/null +++ b/test-files/issue_655_repro.ts @@ -0,0 +1,23 @@ +interface State { pending: number[]; } + +const m = new Map(); +m.set(1, { pending: [10, 20, 30] }); +const s = m.get(1)!; + +console.log('typeof shift: ', typeof s.pending.shift); +console.log('typeof splice: ', typeof s.pending.splice); +console.log('typeof pop: ', typeof s.pending.pop); +console.log('typeof slice: ', typeof s.pending.slice); + +const x = s.pending.shift(); +console.log('shift ret:', x, 'len after:', s.pending.length); + +s.pending.length = 0; +console.log('after length=0:', s.pending.length); + +m.set(2, { pending: [10, 20, 30] }); +const s2 = m.get(2)!; +const arr = s2.pending; +console.log('aliased typeof shift:', typeof arr.shift); +console.log('aliased shift ret:', arr.shift()); +console.log('back at struct:', s2.pending);