From c28d2aad6cc673dfa2142c993ca3991a669e4d3c Mon Sep 17 00:00:00 2001 From: Sean Cheatham Date: Thu, 3 Sep 2026 21:51:36 -0400 Subject: [PATCH 01/32] Map Random.nextInt through LCG high bits and fail on a non-positive bound so TestRuntime draws stay unbiased and in range. Advance the fake clock on poller idle so sleepers become due without a wall wait. --- crates/runtime/include/scuzz_rt.h | 4 +- crates/runtime/src/random.c | 28 ++++++------- crates/runtime/src/runtime.c | 20 ++++++++++ crates/runtime/src/testrt.c | 10 ++++- crates/runtime/tests/test_io.c | 65 ++++++++++++++++++++++++++++++- docs/guide.md | 4 +- 6 files changed, 111 insertions(+), 20 deletions(-) diff --git a/crates/runtime/include/scuzz_rt.h b/crates/runtime/include/scuzz_rt.h index de353abd..5154f7df 100644 --- a/crates/runtime/include/scuzz_rt.h +++ b/crates/runtime/include/scuzz_rt.h @@ -909,9 +909,9 @@ SzIo *sz_sys_getenv(SzString *key); /* Blessed Clock / Random / Net (impurity boundary) */ SzIo *sz_clock_real_time(void); /* IO[Int] wall epoch ms */ SzIo *sz_clock_monotonic(void); /* IO[Int] monotonic ms */ -int64_t sz_clock_monotonic_ms_sync(void); /* sync read for UI pump dt */ +int64_t sz_clock_monotonic_ms_sync(void); /* sync monotonic ms (scheduler, Net, UI); TestRuntime fake clock */ -SzIo *sz_random_next_int(int64_t bound); /* IO[Int] in [0, bound) */ +SzIo *sz_random_next_int(int64_t bound); /* IO[Int] in [0, bound); bound <= 0 fails */ SzIo *sz_net_http_get(SzString *url); /* IO[String] body; 2xx; 1 MiB; http:// or https:// */ SzIo *sz_net_http_post(SzString *url, SzString *body); diff --git a/crates/runtime/src/random.c b/crates/runtime/src/random.c index 602d3d79..31fc7309 100644 --- a/crates/runtime/src/random.c +++ b/crates/runtime/src/random.c @@ -4,7 +4,8 @@ #include #include -/* Blessed Random — live entropy or TestRuntime seeded LCG. */ +/* Blessed Random. Live seeds once from /dev/urandom then LCG. + * TestRuntime is a seeded LCG. */ static int g_fake = 0; static uint64_t g_state = 1; @@ -50,25 +51,24 @@ static uint64_t next_u64(void) { static void *random_next_thunk(void *env) { int64_t bound = sz_unbox_i64(env); + uint64_t u; + uint64_t lim; int64_t n; sz_timeline_log_cstr("Random.nextInt", ""); - if (bound <= 0) - n = 0; - else { - uint64_t lim = (uint64_t)bound; - uint64_t zone = UINT64_MAX - (UINT64_MAX % lim); - uint64_t u; - do { - u = next_u64(); - } while (u >= zone); - n = (int64_t)(u % lim); - } + lim = (uint64_t)bound; + u = next_u64(); + /* Lemire 2019: map through high bits. LCG bit 0 has period 2. */ + n = (int64_t)(((__uint128_t)u * lim) >> 64); return sz_box_i64(n); } SzIo *sz_random_next_int(int64_t bound) { - void *b = sz_box_i64(bound); - SzIo *io = sz_io_delay(random_next_thunk, b); + void *b; + SzIo *io; + if (bound <= 0) + return sz_io_fail_cstr("Random.nextInt: bound <= 0"); + b = sz_box_i64(bound); + io = sz_io_delay(random_next_thunk, b); sz_release(b); return io; } diff --git a/crates/runtime/src/runtime.c b/crates/runtime/src/runtime.c index 681186a4..0fe9819f 100644 --- a/crates/runtime/src/runtime.c +++ b/crates/runtime/src/runtime.c @@ -3640,6 +3640,26 @@ static int idle_advance(Sched *s) { return 1; continue; } + if (sz_testrt_clock_is_fake()) { + /* Do not poll with a wall timeout. Virtual time does not move. */ + pr = poll(pfds, (nfds_t)npoll, 0); + if (pr < 0 && errno == EINTR) + continue; + now = sz_clock_monotonic_ms_sync(); + if (pr > 0 && wake_pollers(s, pfds, fibs, npoll)) + return 1; + if (wake_sleepers(s, now)) + return 1; + if (pr < 0) + return 0; + if (next < 0) + return 0; + delta = next - now; + if (delta > 0) + sz_testrt_clock_advance(delta); + now = sz_clock_monotonic_ms_sync(); + return wake_sleepers(s, now); + } if (next < 0) timeout_ms = -1; else { diff --git a/crates/runtime/src/testrt.c b/crates/runtime/src/testrt.c index e2bee6ac..c8b997df 100644 --- a/crates/runtime/src/testrt.c +++ b/crates/runtime/src/testrt.c @@ -2571,9 +2571,17 @@ int sz_testrt_sys_is_fake(void) { return g_sys_fake; } /* --- install / reset ----------------------------------------------------- */ void sz_testrt_install(void) { + uint64_t rand_seed = 42; + const char *rs; fault_arm_from_env(); sz_testrt_clock_install(1); - sz_testrt_random_install(42); + rs = getenv("SCUZZ_RAND_SEED"); + if (rs && rs[0]) { + unsigned long long parsed = strtoull(rs, NULL, 10); + if (parsed != 0) + rand_seed = (uint64_t)parsed; + } + sz_testrt_random_install(rand_seed); sz_testrt_fs_install(); sz_testrt_net_install(); sz_testrt_sys_install(); diff --git a/crates/runtime/tests/test_io.c b/crates/runtime/tests/test_io.c index e9981a25..29080eb7 100644 --- a/crates/runtime/tests/test_io.c +++ b/crates/runtime/tests/test_io.c @@ -5495,7 +5495,32 @@ int main(void) { r = sz_io_unsafe_run(sz_clock_real_time()); assert(r.ok); - assert(sz_unbox_i64(r.value) == t1); + assert(sz_unbox_i64(r.value) == sz_testrt_clock_now_ms()); + + /* Parked poller must not freeze fake-clock sleepers. */ + { + int fds[2]; + struct timespec w0, w1; + int64_t ct0, ct1; + long wall_ms; + assert(pipe(fds) == 0); + ct0 = sz_testrt_clock_now_ms(); + clock_gettime(CLOCK_MONOTONIC, &w0); + r = sz_io_unsafe_run(race_drop( + sz_io_poll_readable(fds[0]), + fm_drop(sz_io_sleep_ms(50), after_sleep_tag, + (void *)(intptr_t)50))); + clock_gettime(CLOCK_MONOTONIC, &w1); + assert(r.ok); + assert((intptr_t)r.value == 50); + ct1 = sz_testrt_clock_now_ms(); + assert(ct1 == ct0 + 50); + wall_ms = (long)((w1.tv_sec - w0.tv_sec) * 1000L + + (w1.tv_nsec - w0.tv_nsec) / 1000000L); + assert(wall_ms < 25); + close(fds[0]); + close(fds[1]); + } r = sz_io_unsafe_run(sz_random_next_int(10)); assert(r.ok); @@ -5517,6 +5542,44 @@ int main(void) { assert(saw_hi); } + { + int64_t a[32]; + int64_t b[32]; + int i; + int alt; + sz_testrt_random_install(42); + for (i = 0; i < 32; i++) { + r = sz_io_unsafe_run(sz_random_next_int(2)); + assert(r.ok); + a[i] = sz_unbox_i64(r.value); + assert(a[i] == 0 || a[i] == 1); + sz_release(r.value); + } + alt = 1; + for (i = 1; i < 32; i++) { + if (a[i] == a[i - 1]) + alt = 0; + } + assert(!alt); + sz_testrt_random_install(42); + for (i = 0; i < 32; i++) { + r = sz_io_unsafe_run(sz_random_next_int(2)); + assert(r.ok); + b[i] = sz_unbox_i64(r.value); + sz_release(r.value); + assert(b[i] == a[i]); + } + } + + r = sz_io_unsafe_run(sz_random_next_int(0)); + assert(!r.ok); + assert(r.error && + strstr(sz_string_cstr(r.error->message), "bound <= 0") != NULL); + sz_error_free(r.error); + r = sz_io_unsafe_run(sz_random_next_int(-3)); + assert(!r.ok); + sz_error_free(r.error); + sz_alloc_stats(&base_bytes, &base_count); r = sz_io_unsafe_run(sz_random_next_int(10)); assert(r.ok); diff --git a/docs/guide.md b/docs/guide.md index 32174f7a..cff069a5 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -51,7 +51,7 @@ Console kit: `Sys.args(): IO[List[String]]`, `Sys.readLine(): IO[String]` (EOF - Thin **traits** / `impl` with static dispatch (`p.show()` / `p.getOrElse(0)` — including `impl Get[Int] for Point` and `impl Get[T] for Opt`; see `examples/kernel`) - Thin **generics**: `def id[T](x: T): T = x` monomorphized at call sites (`examples/kernel`); generic enums/records too — `enum Opt[T]:` with `o.getOrElse(0)` / `record Box[T](x: T): def get(): T = self.x` (type methods are indented `def`s after cases or after record `:`, same shape as `impl`). Instantiation inferred from ctor args, the expected type, or `e: T` (`examples/kernel`). - **Type aliases**: `type UserId = Int` / `type BoxList[T] = List[T]`. The checker expands the name in params, returns, and `e: T`. `import Module.UserId` binds the alias. -- Blessed impurity only: `IO.println` / `sleep` / `fail` / `pure` / `race` / `both` / `ensure` / `timeout` / `forever` / `repeatN` / `retryN` / `foreach` / `foreachDiscard` / `when` / `unless`, `.map` / `.flatMap` / `.handleErrorWith` / `.attempt`, `Fiber.fork` / `join` / `interrupt`, `Ref.*` / `Queue.*` / `Deferred.*` (`Ref.of` infers `A`; pin `Queue[Int]` / `Deferred[Int]` with `: IO[Queue[Int]]`; `Ref.update` / `Ref.updateAndGet`), `Resource.make` / `Resource.use` (`Resource[A]` from acquire `IO[A]`; release on success, failure, and cancel), `Stream.emit` / `emits` / `eval` / `concat` / `map` / `evalMap` / `evalTap` / `filter` / `filterNot` / `take` / `takeWhile` / `drop` / `dropWhile` / `find` / `findLast` / `exists` / `forall` / `none` / `range` / `repeatN` / `zip` / `zipWith` / `zipAll` / `zipWithIndex` / `interleave` / `intersperse` / `grouped` / `sliding` / `takeRight` / `dropRight` / `flatten` / `flatMap` / `mapConcat` / `scan` / `fold` / `changes` / `orElse` / `iterate` / `unfold` / `head` / `last` / `count` / `compileToList` / `drain` (`Stream[A]` from emit/emits/eval/range/iterate/unfold; `exists` / `forall` / `none` are `IO[Bool]`; `fold` is `IO[Z]`; `head` / `last` are `IO[A]` and fail when empty; `count` is `IO[Int]`; `zip` / `zipAll` are `Stream[(A, B)]`; `interleave` is `Stream[A]`; `grouped` / `sliding` are `Stream[List[A]]`), `Fs.*`, `Json.parse` / `Json.stringify` (enum `Json`: `Null|Bool|Int|Float|Str|Arr|Obj`; `parse` is `Result[Json]`; `stringify` is `Result[String]`; query: `get` / `keys` / `arr` / `at` / `has` / `pairs` / `is*` / `as*` / `*Or` / `getBool` / `getInt` / `getStr` / `getFloat` / `merge`; write: `set` / `remove` / `append` / `prepend` / `setAt` / `dropAt`; a miss is an empty list; `set` on a non-Obj is a one-key Obj; `append` / `prepend` on a non-Arr is a one-cell Arr), `Sys.args` / `Sys.readLine` / `Sys.read` / `Sys.write` / `Sys.exec` / `Sys.spawn` / `Sys.childWrite` / `Sys.childRead` / `Sys.childClose` / `Sys.alive` / `Sys.kill` / `Sys.getenv`, `Clock.*`, `Random.nextInt` (`IO[Int]` in `[0, bound)`), `Net.httpGet` / `Net.httpPost` / `Net.httpPut` / `Net.httpPatch` / `Net.httpDelete` / `Net.httpHead` / `Net.serveOnce` / `Net.serve` (handler receives `(path, method, body)` and returns the response body as `IO[String]`) / `Net.tcpConnect` / `Net.tcpListen` / `Net.tcpAccept` / `Net.tcpRead` / `Net.tcpWrite` / `Net.tcpClose` / `Net.udpBind` / `Net.udpSend` / `Net.udpRecv` / `Net.udpClose` +- Blessed impurity only: `IO.println` / `sleep` / `fail` / `pure` / `race` / `both` / `ensure` / `timeout` / `forever` / `repeatN` / `retryN` / `foreach` / `foreachDiscard` / `when` / `unless`, `.map` / `.flatMap` / `.handleErrorWith` / `.attempt`, `Fiber.fork` / `join` / `interrupt`, `Ref.*` / `Queue.*` / `Deferred.*` (`Ref.of` infers `A`; pin `Queue[Int]` / `Deferred[Int]` with `: IO[Queue[Int]]`; `Ref.update` / `Ref.updateAndGet`), `Resource.make` / `Resource.use` (`Resource[A]` from acquire `IO[A]`; release on success, failure, and cancel), `Stream.emit` / `emits` / `eval` / `concat` / `map` / `evalMap` / `evalTap` / `filter` / `filterNot` / `take` / `takeWhile` / `drop` / `dropWhile` / `find` / `findLast` / `exists` / `forall` / `none` / `range` / `repeatN` / `zip` / `zipWith` / `zipAll` / `zipWithIndex` / `interleave` / `intersperse` / `grouped` / `sliding` / `takeRight` / `dropRight` / `flatten` / `flatMap` / `mapConcat` / `scan` / `fold` / `changes` / `orElse` / `iterate` / `unfold` / `head` / `last` / `count` / `compileToList` / `drain` (`Stream[A]` from emit/emits/eval/range/iterate/unfold; `exists` / `forall` / `none` are `IO[Bool]`; `fold` is `IO[Z]`; `head` / `last` are `IO[A]` and fail when empty; `count` is `IO[Int]`; `zip` / `zipAll` are `Stream[(A, B)]`; `interleave` is `Stream[A]`; `grouped` / `sliding` are `Stream[List[A]]`), `Fs.*`, `Json.parse` / `Json.stringify` (enum `Json`: `Null|Bool|Int|Float|Str|Arr|Obj`; `parse` is `Result[Json]`; `stringify` is `Result[String]`; query: `get` / `keys` / `arr` / `at` / `has` / `pairs` / `is*` / `as*` / `*Or` / `getBool` / `getInt` / `getStr` / `getFloat` / `merge`; write: `set` / `remove` / `append` / `prepend` / `setAt` / `dropAt`; a miss is an empty list; `set` on a non-Obj is a one-key Obj; `append` / `prepend` on a non-Arr is a one-cell Arr), `Sys.args` / `Sys.readLine` / `Sys.read` / `Sys.write` / `Sys.exec` / `Sys.spawn` / `Sys.childWrite` / `Sys.childRead` / `Sys.childClose` / `Sys.alive` / `Sys.kill` / `Sys.getenv`, `Clock.*`, `Random.nextInt` (`IO[Int]` in `[0, bound)`; bound <= 0 is `IO.fail`), `Net.httpGet` / `Net.httpPost` / `Net.httpPut` / `Net.httpPatch` / `Net.httpDelete` / `Net.httpHead` / `Net.serveOnce` / `Net.serve` (handler receives `(path, method, body)` and returns the response body as `IO[String]`) / `Net.tcpConnect` / `Net.tcpListen` / `Net.tcpAccept` / `Net.tcpRead` / `Net.tcpWrite` / `Net.tcpClose` / `Net.udpBind` / `Net.udpSend` / `Net.udpRecv` / `Net.udpClose` - No raw side effects in View build. Taps may run `IO` through `sz_io_unsafe_run`. The tap drops a leftover owned value or the run result. The product CLI is Scuzz (`examples/cli`). `scuzz --help` and `scuzz --help` list flags and examples. `scuzz check` is the linter (format-verify + typecheck). `scuzz fmt` rewrites. `scuzz watch` rebuilds on source change. It does not reload a running process. `[ui]` `scuzz run --watch` is hot reload: it keeps the process and stamp-reloads the View tree (Signals stay). IO-only `scuzz run --watch` kills and reruns the process on source change. `[ui]` build emits `build/reload.dylib`. On source change, watch recompiles it then stamps so the session `dlopen`s new machine code (`SCUZZ_UI_RELOAD_CODE`). The process rewrites `build/debug.dump` (signal store + a11y including live `View.bindText` + `[taps]` with frames / `[fields]` plus `caret=B sel=A:C` (and `preedit=` when compose is set) / `[editor]` buffer plus `caret=B sel=A:C sx=X sy=Y lines=L` when a `View.editor` is present (plus `diag=` / `tok=` / `inlay=` / `fold=` / `preedit=` when set) / `[scrolls]` inject indices, same format as `scuzz test` goldens; `[last_hit]` after a TAP; `[hover]` after a no-button MOVE; `[last_secondary]` after a button-3 click; `[session]` kind/size/title/focus/lifecycle/pumps; `[splits]` / `[overlays]` when present; `[heap]` alloc stats with kind census, delta, and `[live]` remaining blocks) on dirty pumps so agents can read live UI state, `tap N` without guessing coordinates, and see which TextField `text` / `type` / `key` / `compose` / `caret` / `select` / `backspace` hit (`N* placeholder="live" caret=B sel=A:C`). A `View.editor` dumps under `[editor]` (`N* caret=B sel=A:C sx=X sy=Y lines=L "buffer"`). Newlines stay as `\n`. Diagnostic marks append `diag=P:S`. LSP span counts append `tok=N` / `inlay=N` / `fold=N` when non-zero. Compose appends `preedit="…"` when the preview is non-empty. Append `tap` / `xy` / `text` / `type` / `key` / `compose` / `commit` / `caret` / `select` / `copy` / `cut` / `paste` / `drag` / `hover` / `secondary` / `pump` / `scroll` / `backspace` / `dump` / `reload` / `quit` / `resetpeak` lines to `build/inject.script` to drive the session (rewrite plays the whole file). `dump` rewrites the debug dump now. `reload` rebuilds the View factory. `quit` stops the live session. Desktop quit is window close. `resetpeak` sets peak bytes to live and marks the heap delta. Panic prints remaining `[heap]` and `[live]`, writes `build/debug.dump.panic` when a live dump path is set, then frees remaining live blocks and abort. `text N s` / `type N s` / `backspace N k` / `caret N b` / `select N a c` / `scroll N dy` target dump index N. `key [+shift|+ctrl|+cmd|+alt|+repeat] [text]` uses the starred field or focused editor (`Enter`, `Backspace`, `ArrowLeft`, `a`). `+repeat` is a held-key auto-repeat (same insert / move / delete as a discrete key). `compose ` sets IME preedit (underlined preview; not in the committed buffer). `compose` with no text, or `commit`, inserts the preedit at the caret. `key Escape` cancels preedit. `caret ` sets the starred-field or focused-editor caret byte offset. `caret N b` targets dump index N. `select ` sets the starred-field selection. `copy` / `cut` / `paste` / `paste ` drive the session clipboard. Headless `paste` is first-class. Desktop/Mobile pull the OS pasteboard on paste when present. `drag x1 y1 x2 y2` is pointer-drag select. Live OS keys record as `key`, not `type`. Live OS auto-repeat records `key a+repeat`. Live OS copy/cut/paste and Shift+arrows record those verbs. One-token forms (`text s`, `backspace k`, `scroll 40`) still use the starred field or first Scroll. `text 0` remains payload `"0"`. `xy x y` injects a TAP at a logical point; a miss does not panic. `hover x y` injects a pointer MOVE with no button and shows `View.tooltip`. `secondary N` / `secondary x y` is a button-3 click; it does not fire the primary tap. Live OS hover and right-click record as `hover` / `secondary`. Desktop/Mobile `scuzz run` records live OS clicks and keys to `build/record.script` (not `inject.script`) and writes `build/debug.dump`. Replay with `scuzz run --headless --script build/record.script --dump build/debug.dump`. `--message-format=json` applies to `check` only. That JSON is the editor protocol. @@ -123,7 +123,7 @@ count.scuzz_verify # Timeline => Verdict session claims and Bool drive oracl - `scuzz check` format-verifies `src/` and `*.scuzz_verify` and typechecks live + sim + drivers + verify predicates + `where` + `.require`. A present empty `*.scuzz_verify` fails. A leftover `*.scuzz_intent` file fails. It reports unused imports, unused locals, unused parameters, and unused private defs. It reports unclaimed defs, signals, and controls as info (used names that no claim observes). A name that starts with `_` is kept on purpose. `--message-format=json` is the editor protocol (`check` only). `scuzz lsp` wraps that JSON over stdin/stdout (open buffers overlay disk on didOpen/didChange; didClose uses disk; `workspace/didChangeWatchedFiles` republishes check). Language-server methods use the same parse. One run reports every parse and type diagnostic. Unclaimed reports do not fail `check`. - `scuzz ide [path]` launches the bundled `[ui]` editor with Desktop. `--headless` stays. The CLI finds `SCUZZ_IDE`, `SCUZZ_HOME/ide`, or `examples/editor`. It passes the path through `Sys.args`. There is no `scuzz-ide` binary. - `scuzz fuzz --iterations N` is the verification campaign. It loads `/corpus/*.toml` (sibling of `goldens/`; same shape as `repro.toml`) in sorted-name order and replays those entries before search. It also replays `build/seeds.txt` zero-argument verify oracles (`drive `). A missing or empty `corpus/` is a no-op. Missing seeds is a no-op. `--iterations 0` is corpus-only: replay, then stop. `--minimize-corpus` rewrites stored entries to their shortest forms that keep pass/fail status and declared `sometimes` coverage. No search. No mutation. Use it for a fast inner loop. A failing stored entry is a search failure; the stored file is the repro. A failing seed writes `build/fuzz/repro.toml`. Passing entries seed in-memory keeps. For `N > 0` it splits the rest of the budget into search then mutation. `[ui]` search tries exhaustive event scripts while the next full depth fits, then coverage-guided random. Scripts that hit new `Property.sometimes` names or a new Headless `dump.txt` are kept, replayed once more (dump and timeline must match), and later iters extend those prefixes. Search writes a shrunk failure into `corpus/` under a hash of `schedule_seed` plus events plus a non-zero `fault_seed` (idempotent). Shrink first drops events, then shrinks `drive` args (Int toward the published bound or 0, Bool to `false`, String to `a`, record/enum fields, list length then elements), then shrinks the fault seed, then shrinks PCT `pct_k`. The campaign prints `shrunk events:` with those lines. A simple Int `where` bound publishes as a driver-table token (`noteDrive i>=0`) and clamps draws. Record / enum / list params publish as `Rect(i>=0,i)` / `e:Some(i)|None` / `[i]`. A recursive enum caps nest depth at 3 and publishes a nested `e:N(i)|Add(...)` spec. Search also persists a keep that reaches a `sometimes` name no stored entry reaches (cap: declared-name count). Dump-novelty keeps stay in memory. Review new `corpus/*.toml` files like goldens. After a UI refactor a stored `tap N` can miss (the index is gone). Delete that entry. IO-only keeps schedule seeds that hit new sometimes names and perturbs them. `--replay repro.toml` restores a shrunk event list + optional `schedule_seed` / `pct_d` / `pct_k` + optional fault plan. Oracles are residual `.require` / `Property.check`, panic/`SzError`, dump determinism, leak (heap growth across consecutive idle UI pumps), deadlock (all fibers parked with no timer pending), heap baseline after session teardown, acquire/release pairing, finalizer-on-cancel, leftover parked fibers after quiesce, live/verify differential, and campaign `Property.sometimes` reachability. `Property.classify` counts write to `summary.toml` `[classify]` and do not fail the campaign. Search explores `SCUZZ_FAULT_SEED` like `SCUZZ_SCHED_SEED`. `SCUZZ_SCHED_SEED` is a packed PCT plan. `repro.toml` writes `pct_d` / `pct_k`. Seed `0` is no fault. Iter `0` stays no-fault. After search, mutation compiles live `def` bodies (flip `==`/`/>=/&&/||`, swap `+`/`-` and `*`/`/`, replace `%` with `*`, drop `&&` conjuncts, swap `if` arms, swap `0`↔`1`, replace an ADT construct with a same-arity sibling). Program mode also swaps a tap handler body with a sibling handler and replaces a `Signal.map` transform with the identity. Mutants that do not compile count as killed. A probe that runs longer than 20 s is killed. Residual oracles stay armed. `--oracles` mutates residual `Property.check` / `Property.assert` / `.require` predicates instead. It also negates or drops residual `where` bounds. Each mutant gets an idle TestRuntime probe, then corpus replay. Kill = any probe fails. A surviving mutant that changes a claimed `State` field is a weak claim. A surviving mutant that changes an unclaimed `State` field is a missing claim. Mutants with a bit-identical replayed timeline are inert and unreported. Each survivor prints file:line, enclosing def, mutation label with a source excerpt, the nearest residual oracle (same def, else closest span in the same module, else no observing oracle), and a weak or missing claim. `summary.toml` `[mutate]` records `survivors` (kind and fields) and `inert`. No sites (for example `examples/hello`) exits 0. Do not add external mutators. The command writes `build/fuzz/summary.toml` with coverage (`declared` / `reachability`), `[coverage].unclaimed_varied` (`State` fields that varied and no claim reads), a `[corpus]` table (`entries` / `failures` / `reached` / `promoted`), `[classify]` true/false counts, and mutation (`killed` / `survived` / `inert` / `score` / `survivors`). Unclaimed variation reports to the author. It does not fail the campaign. Missing names split into not reached in this budget vs reached by no stored corpus entry. Default stops at the first search failure. `--no-fail-fast` keeps that `repro.toml`, finishes search, then mutates. `examples/bad-example` must fail: search drives `bump` against a wrong `bump` and prints the shrunk arg (`drive bump 0`). A checked-in `corpus/` entry pins that failure. `examples/bad-fault` must fail: search drives `checkNote` under a fault seed that fails `Fs.write`. `saveNote` swallows that error, so `loadNote` does not match. A checked-in `corpus/` entry pins `drive checkNote a` with `fault_seed = "1"`. Replay without `fault_seed` passes. `examples/bad-adt` must fail: search draws `Rect` values against a wrong `area` (`w + h` vs `w * h`). `Property.classify` records `square` / `wide`. A checked-in `corpus/` entry pins a shrunk `drive area Rect(...)`. `examples/bad-sched` must fail: search drives `checkOrder` under a PCT schedule seed that offers `R` first. A checked-in `corpus/` entry pins `drive checkOrder` with `schedule_seed = "1344"` / `pct_d = 2` / `pct_k = 0`. Replay without `schedule_seed` passes. `scuzz check` and `scuzz test` still pass. A failing search keeps no passing corpus, so idle mutants that change the replayed timeline survive. Mutants with a bit-identical replayed timeline are inert and unreported. `scale` has no oracle. An idle `scale` mutant does not change the timeline, so it is inert. -- Deterministic fakes: `TestRuntime` / `SCUZZ_TESTRT=1` for clock/random/FS/network/console in app binaries. Simulation is hermetic (no live sockets; `Sys.exec` / `Sys.spawn` fail; `Sys.getenv` sealed; `Sys.alive` / `Sys.kill` fake). Fault injection: `SCUZZ_FAULT_SEED` (or `SCUZZ_FAULT_KIND` + `SCUZZ_FAULT_N` + `SCUZZ_FAULT_MODE`) fails the Nth `Fs` / `Net` / `Queue` op, or drops/corrupts a Net stub. Seed `0` / unset is no fault. `scuzz fuzz` writes `fault_seed` and the decoded plan into `repro.toml`. PCT schedule: `SCUZZ_SCHED_SEED` arms priority plus change-points (packed `k=s%8`, `d=2+(s/8)%4`, `rng=s/32`). `SCUZZ_PCT_D` / `SCUZZ_PCT_K` override. Unset keeps FIFO. `scuzz fuzz` writes `schedule_seed` / `pct_d` / `pct_k` into `repro.toml`. Implicit oracles fail a run on leak (heap growth across consecutive idle UI pumps), deadlock (all fibers parked with no timer pending), heap baseline after session teardown, acquire/release pairing, finalizer-on-cancel, leftover parked fibers after quiesce, and a silent live/verify split (a `*.scuzz_sim` overlay is a declared delta). +- Deterministic fakes: `TestRuntime` / `SCUZZ_TESTRT=1` for clock/random/FS/network/console in app binaries. `SCUZZ_RAND_SEED` seeds `Random.nextInt` (unset or `0` keeps 42). Under TestRuntime, Clock.realTime and Clock.monotonic both read the virtual ms counter (start 1). Simulation is hermetic (no live sockets; `Sys.exec` / `Sys.spawn` fail; `Sys.getenv` sealed; `Sys.alive` / `Sys.kill` fake). Fault injection: `SCUZZ_FAULT_SEED` (or `SCUZZ_FAULT_KIND` + `SCUZZ_FAULT_N` + `SCUZZ_FAULT_MODE`) fails the Nth `Fs` / `Net` / `Queue` op, or drops/corrupts a Net stub. Seed `0` / unset is no fault. `scuzz fuzz` writes `fault_seed` and the decoded plan into `repro.toml`. PCT schedule: `SCUZZ_SCHED_SEED` arms priority plus change-points (packed `k=s%8`, `d=2+(s/8)%4`, `rng=s/32`). `SCUZZ_PCT_D` / `SCUZZ_PCT_K` override. Unset keeps FIFO. `scuzz fuzz` writes `schedule_seed` / `pct_d` / `pct_k` into `repro.toml`. Implicit oracles fail a run on leak (heap growth across consecutive idle UI pumps), deadlock (all fibers parked with no timer pending), heap baseline after session teardown, acquire/release pairing, finalizer-on-cancel, leftover parked fibers after quiesce, and a silent live/verify split (a `*.scuzz_sim` overlay is a declared delta). - Put non-determinism behind blessed `IO`. Keep View construction pure. ## Examples to read next From 16f99a561ba68305c047e4b22f8e1e5dafbf8349 Mon Sep 17 00:00:00 2001 From: Sean Cheatham Date: Thu, 3 Sep 2026 22:48:20 -0400 Subject: [PATCH 02/32] Grow inject and dump buffers and escape signal strings so Timeline claims see the full observation. Clear pointer captures on hot reload so inject cannot write a freed View. --- crates/runtime/include/scuzz_rt.h | 3 + crates/runtime/include/scuzz_ui.h | 11 +- crates/runtime/src/rt_util.h | 117 +++++++++++ crates/runtime/src/signal.c | 78 ++++---- crates/runtime/src/testrt.c | 129 +++++++++--- crates/runtime/src/ui.c | 165 +++++++++++---- crates/runtime/src/ui_script.c | 16 +- crates/runtime/src/view.c | 51 +++-- crates/runtime/tests/test_io.c | 16 ++ crates/runtime/tests/test_ui.c | 321 ++++++++++++++++++++++++++++++ docs/guide.md | 2 +- 11 files changed, 775 insertions(+), 134 deletions(-) diff --git a/crates/runtime/include/scuzz_rt.h b/crates/runtime/include/scuzz_rt.h index 5154f7df..9fb3a1f5 100644 --- a/crates/runtime/include/scuzz_rt.h +++ b/crates/runtime/include/scuzz_rt.h @@ -1114,6 +1114,9 @@ void sz_property_session_reset(void); void sz_timeline_set_drive(const char *line); int sz_timeline_replaying(void); int64_t sz_timeline_replay_signal_int(const char *name); +SzString *sz_timeline_replay_signal_str(const char *name); +int64_t sz_timeline_replay_signal_list_len(const char *name); +SzString *sz_timeline_replay_signal_list_at(const char *name, int64_t index); int64_t sz_timeline_len(void *tl); int64_t sz_timeline_signal_int(void *tl, int64_t i, SzString *name); int64_t sz_timeline_signal_list_len(void *tl, int64_t i, SzString *name); diff --git a/crates/runtime/include/scuzz_ui.h b/crates/runtime/include/scuzz_ui.h index 34cef3ce..675b70b9 100644 --- a/crates/runtime/include/scuzz_ui.h +++ b/crates/runtime/include/scuzz_ui.h @@ -126,10 +126,10 @@ void sz_signal_list_set(SzSignalList *s, SzList *v); SzList *sz_signal_list_get(const SzSignalList *s); void sz_signal_list_free(SzSignalList *s); -/* Signal store dump: one "kind[id] = value" line per live signal, in creation - order (fuzz oracle; caller frees SzString). */ +/* Signal store dump: one "kind[id] name = value" line per live signal. + * String values use the editor dump escape dialect. Caller frees SzString. */ SzString *sz_signal_dump(void); -/* Property observation: signal store by creation-order id (TestRuntime / fuzz). */ +/* Publish the for-binder name. Property and Timeline kits read that name. */ void sz_signal_name(const void *sig, const char *name); int64_t sz_property_signal_int(SzString *name); @@ -677,8 +677,9 @@ int sz_ui_session_set_record(SzUiSession *session, const char *path); int sz_ui_session_reload(SzUiSession *session); /* dlopen `path` (copied to a unique sibling so the OS does not keep a stale * image) and set the rebuild factory from exported `sz_ui_reload_rebuild`. - * Signals stay in `rebuild_env`. Does not rebuild until reload/stamp. - * Stamp-watch loads `SCUZZ_UI_RELOAD_CODE` (if set) before rebuild. */ + * Unlink the copy after dlopen. Signals stay in `rebuild_env`. Does not + * rebuild until reload/stamp. Stamp-watch loads `SCUZZ_UI_RELOAD_CODE` (if + * set) before rebuild. */ int sz_ui_session_load_code(SzUiSession *session, const char *path); void sz_ui_unmount(SzUiSession *session); /* Snapshot PNG / structural dump from SCUZZ_SNAPSHOT_PATH / SCUZZ_FUZZ_DUMP. */ diff --git a/crates/runtime/src/rt_util.h b/crates/runtime/src/rt_util.h index d05314bb..844f7d30 100644 --- a/crates/runtime/src/rt_util.h +++ b/crates/runtime/src/rt_util.h @@ -53,4 +53,121 @@ static inline SzString *pack_path(void *env) { return pack ? (SzString *)pack->left : NULL; } +/* Grow a C string buffer. Used by signal dump, a11y dump, and inject read. */ +static inline void sz_dump_append(char **buf, size_t *len, size_t *cap, + const char *s) { + size_t n = strlen(s); + if (*len + n + 1 > *cap) { + size_t ncap = *cap ? *cap : 256; + char *nb; + while (*len + n + 1 > ncap) + ncap *= 2; + nb = (char *)sz_alloc(ncap); + if (*buf) { + memcpy(nb, *buf, *len); + sz_free(*buf); + } + *buf = nb; + *cap = ncap; + } + memcpy(*buf + *len, s, n); + *len += n; + (*buf)[*len] = '\0'; +} + +/* Editor dump dialect: \\ \" \n \r \t. No surrounding quotes. */ +static inline void sz_dump_append_escaped(char **buf, size_t *len, size_t *cap, + const char *s) { + const char *p; + if (!s) + return; + for (p = s; *p; p++) { + unsigned char c = (unsigned char)*p; + if (c == '\\') + sz_dump_append(buf, len, cap, "\\\\"); + else if (c == '"') + sz_dump_append(buf, len, cap, "\\\""); + else if (c == '\n') + sz_dump_append(buf, len, cap, "\\n"); + else if (c == '\r') + sz_dump_append(buf, len, cap, "\\r"); + else if (c == '\t') + sz_dump_append(buf, len, cap, "\\t"); + else { + char t[2]; + t[0] = (char)c; + t[1] = '\0'; + sz_dump_append(buf, len, cap, t); + } + } +} + +static inline unsigned char sz_dump_unescape_char(const char **p) { + const char *s = *p; + unsigned char c; + if (*s == '\\' && s[1]) { + s++; + if (*s == 'n') + c = '\n'; + else if (*s == 'r') + c = '\r'; + else if (*s == 't') + c = '\t'; + else + c = (unsigned char)*s; + s++; + *p = s; + return c; + } + c = (unsigned char)*s; + *p = s + 1; + return c; +} + +/* Unescape a dump/script payload. Caller frees. */ +static inline char *sz_dump_unescape(const char *s) { + char *buf = NULL; + size_t len = 0, cap = 0; + const char *p = s ? s : ""; + while (*p) { + char t[2]; + t[0] = (char)sz_dump_unescape_char(&p); + t[1] = '\0'; + sz_dump_append(&buf, &len, &cap, t); + } + if (!buf) + sz_dump_append(&buf, &len, &cap, ""); + return buf; +} + +/* Parse `"…"` with the dump escape dialect. p points at the opening quote. + * Returns the pointer after the closing quote, or NULL. When out is set, + * writes the unescaped bytes (caller frees). */ +static inline const char *sz_dump_parse_quoted(const char *p, char **out) { + char *buf = NULL; + size_t len = 0, cap = 0; + if (out) + *out = NULL; + if (!p || *p != '"') + return NULL; + p++; + while (*p && *p != '"') { + char t[2]; + t[0] = (char)sz_dump_unescape_char(&p); + t[1] = '\0'; + if (out) + sz_dump_append(&buf, &len, &cap, t); + } + if (*p != '"') { + sz_free(buf); + return NULL; + } + if (out) { + if (!buf) + sz_dump_append(&buf, &len, &cap, ""); + *out = buf; + } + return p + 1; +} + #endif diff --git a/crates/runtime/src/signal.c b/crates/runtime/src/signal.c index f35ba856..a6a70006 100644 --- a/crates/runtime/src/signal.c +++ b/crates/runtime/src/signal.c @@ -54,16 +54,24 @@ static void sig_register(SigKind kind, const void *sig) { g_sig_tail = r; } -/* Publish the author-facing name of a signal (its `for` binder name). */ +/* Publish the author-facing name of a signal (its `for` binder name). + * Last non-empty name wins: a later bind clears the same name on others. */ void sz_signal_name(const void *sig, const char *name) { SigReg *r; + SigReg *mine = NULL; + const char *n = name ? name : ""; for (r = g_sig_head; r; r = r->next) { - if (r->sig == sig) { + if (r->sig == sig) + mine = r; + else if (n[0] && r->name && strcmp(r->name, n) == 0) { sz_free(r->name); - r->name = sz_strdup(name); - return; + r->name = sz_strdup(""); } } + if (!mine) + return; + sz_free(mine->name); + mine->name = sz_strdup(n); } static void sig_unregister(const void *sig) { @@ -108,26 +116,6 @@ static void sig_set_elem_str(const void *sig, int64_t elem_str) { } } -static void dump_append(char **buf, size_t *len, size_t *cap, const char *s) { - size_t n = strlen(s); - if (*len + n + 1 > *cap) { - size_t ncap = *cap ? *cap : 256; - char *nb; - while (*len + n + 1 > ncap) - ncap *= 2; - nb = (char *)sz_alloc(ncap); - if (*buf) { - memcpy(nb, *buf, *len); - sz_free(*buf); - } - *buf = nb; - *cap = ncap; - } - memcpy(*buf + *len, s, n); - *len += n; - (*buf)[*len] = '\0'; -} - SzString *sz_signal_dump(void) { char *buf = NULL; size_t len = 0, cap = 0; @@ -135,7 +123,7 @@ SzString *sz_signal_dump(void) { char tag[256]; SigReg *r; SzString *out; - dump_append(&buf, &len, &cap, ""); + sz_dump_append(&buf, &len, &cap, ""); for (r = g_sig_head; r; r = r->next) { if (r->name && r->name[0]) snprintf(tag, sizeof tag, "%s ", r->name); @@ -145,30 +133,33 @@ SzString *sz_signal_dump(void) { case SIG_INT: snprintf(line, sizeof line, "int[%d] %s= %lld\n", r->id, tag, (long long)sz_signal_int_get((const SzSignalInt *)r->sig)); - dump_append(&buf, &len, &cap, line); + sz_dump_append(&buf, &len, &cap, line); break; case SIG_STR: - snprintf(line, sizeof line, "str[%d] %s= \"%s\"\n", r->id, tag, - sz_signal_str_get((const SzSignalStr *)r->sig)); - dump_append(&buf, &len, &cap, line); + snprintf(line, sizeof line, "str[%d] %s= \"", r->id, tag); + sz_dump_append(&buf, &len, &cap, line); + sz_dump_append_escaped(&buf, &len, &cap, + sz_signal_str_get((const SzSignalStr *)r->sig)); + sz_dump_append(&buf, &len, &cap, "\"\n"); break; case SIG_LIST: { SzList *p = sz_signal_list_get((const SzSignalList *)r->sig); if (!r->elem_str) { snprintf(line, sizeof line, "list[%d] %s= <%lld>\n", r->id, tag, (long long)sz_list_len(p)); - dump_append(&buf, &len, &cap, line); + sz_dump_append(&buf, &len, &cap, line); break; } snprintf(line, sizeof line, "list[%d] %s= [", r->id, tag); - dump_append(&buf, &len, &cap, line); + sz_dump_append(&buf, &len, &cap, line); for (; p; p = p->tail) { const SzString *s = (const SzString *)p->head; - snprintf(line, sizeof line, "\"%s\"%s", s ? sz_string_cstr(s) : "", - p->tail ? ", " : ""); - dump_append(&buf, &len, &cap, line); + sz_dump_append(&buf, &len, &cap, "\""); + sz_dump_append_escaped(&buf, &len, &cap, + s ? sz_string_cstr(s) : ""); + sz_dump_append(&buf, &len, &cap, p->tail ? "\", " : "\""); } - dump_append(&buf, &len, &cap, "]\n"); + sz_dump_append(&buf, &len, &cap, "]\n"); break; } } @@ -188,7 +179,11 @@ int64_t sz_property_signal_int(SzString *name) { } SzString *sz_property_signal_str(SzString *name) { - SigReg *r = sig_find(SIG_STR, name ? sz_string_cstr(name) : ""); + const char *n = name ? sz_string_cstr(name) : ""; + SigReg *r; + if (sz_timeline_replaying()) + return sz_timeline_replay_signal_str(n); + r = sig_find(SIG_STR, n); if (r) return sz_string_from_cstr( sz_signal_str_get((const SzSignalStr *)r->sig)); @@ -196,7 +191,11 @@ SzString *sz_property_signal_str(SzString *name) { } int64_t sz_property_signal_list_len(SzString *name) { - SigReg *r = sig_find(SIG_LIST, name ? sz_string_cstr(name) : ""); + const char *n = name ? sz_string_cstr(name) : ""; + SigReg *r; + if (sz_timeline_replaying()) + return sz_timeline_replay_signal_list_len(n); + r = sig_find(SIG_LIST, n); if (r) return (int64_t)sz_list_len( sz_signal_list_get((const SzSignalList *)r->sig)); @@ -207,9 +206,12 @@ SzString *sz_property_signal_list_at(SzString *name, int64_t index) { SigReg *r; const SzList *p; int64_t i; + const char *n = name ? sz_string_cstr(name) : ""; if (index < 0) return sz_string_from_cstr(""); - r = sig_find(SIG_LIST, name ? sz_string_cstr(name) : ""); + if (sz_timeline_replaying()) + return sz_timeline_replay_signal_list_at(n, index); + r = sig_find(SIG_LIST, n); if (r && r->elem_str) { p = sz_signal_list_get((const SzSignalList *)r->sig); i = 0; diff --git a/crates/runtime/src/testrt.c b/crates/runtime/src/testrt.c index c8b997df..d4184c7c 100644 --- a/crates/runtime/src/testrt.c +++ b/crates/runtime/src/testrt.c @@ -3146,11 +3146,93 @@ int64_t sz_timeline_replay_signal_int(const char *name) { return sep ? (int64_t)atoll(sep + 3) : 0; } +static const char *tl_sig_payload(const char *sep) { + if (!sep || memcmp(sep, " = ", 3) != 0) + return NULL; + return sep + 3; +} + +static SzString *tl_parse_quoted_str(const char *sep) { + const char *p = tl_sig_payload(sep); + char *val = NULL; + SzString *out; + if (!p || *p != '"') + return sz_string_from_cstr(""); + if (!sz_dump_parse_quoted(p, &val) || !val) + return sz_string_from_cstr(""); + out = sz_string_from_cstr(val); + sz_free(val); + return out; +} + +static int64_t tl_count_quoted_list(const char *p) { + int64_t n = 0; + if (!p) + return 0; + while (*p && *p != ']' && *p != '\n') { + while (*p == ' ' || *p == ',') + p++; + if (*p != '"') + break; + p = sz_dump_parse_quoted(p, NULL); + if (!p) + break; + n++; + } + return n; +} + static int64_t tl_parse_signal_int(const char *dump, const char *name) { const char *sep = tl_sig_line(dump, "int", name); return sep ? (int64_t)atoll(sep + 3) : 0; } +SzString *sz_timeline_replay_signal_str(const char *name) { + return tl_parse_quoted_str(tl_sig_line(g_replay_signals, "str", name)); +} + +int64_t sz_timeline_replay_signal_list_len(const char *name) { + const char *sep = tl_sig_line(g_replay_signals, "list", name); + const char *p; + if (!sep) + return 0; + if (memcmp(sep, " = <", 4) == 0) + return (int64_t)atoll(sep + 4); + if (memcmp(sep, " = [", 4) != 0) + return 0; + p = sep + 4; + return tl_count_quoted_list(p); +} + +SzString *sz_timeline_replay_signal_list_at(const char *name, int64_t index) { + const char *sep = tl_sig_line(g_replay_signals, "list", name); + const char *p; + int64_t i = 0; + if (index < 0 || !sep || memcmp(sep, " = [", 4) != 0) + return sz_string_from_cstr(""); + p = sep + 4; + while (*p && *p != ']' && *p != '\n') { + char *val = NULL; + while (*p == ' ' || *p == ',') + p++; + if (*p != '"') + break; + p = sz_dump_parse_quoted(p, &val); + if (!p) { + sz_free(val); + break; + } + if (i == index) { + SzString *out = sz_string_from_cstr(val ? val : ""); + sz_free(val); + return out; + } + sz_free(val); + i++; + } + return sz_string_from_cstr(""); +} + static SzTlState *tl_at(void *tl, int64_t i) { SzTimeline *t = (SzTimeline *)tl; if (!t || i < 0 || i >= t->n) @@ -3169,12 +3251,11 @@ int64_t sz_timeline_signal_int(void *tl, int64_t i, SzString *name) { : 0; } -/* List length from the signals dump: `list[] = ["a", "b"]` holds - * one quoted string per element (no escaping), so quotes pair per element. */ +/* List length from the signals dump: `list[] = ["a", "b"]` + * counts quoted strings with the dump escape dialect. */ static int64_t tl_parse_signal_list_len(const char *dump, const char *name) { const char *sep = tl_sig_line(dump, "list", name); const char *p; - int64_t quotes = 0; if (!sep) return 0; /* Record lists dump the count only: `list[] = `. */ @@ -3183,12 +3264,7 @@ static int64_t tl_parse_signal_list_len(const char *dump, const char *name) { if (memcmp(sep, " = [", 4) != 0) return 0; p = sep + 4; - while (*p && *p != ']' && *p != '\n') { - if (*p == '"') - quotes += 1; - p += 1; - } - return quotes / 2; + return tl_count_quoted_list(p); } int64_t sz_timeline_signal_list_len(void *tl, int64_t i, SzString *name) { @@ -3198,35 +3274,26 @@ int64_t sz_timeline_signal_list_len(void *tl, int64_t i, SzString *name) { : 0; } -/* 1 when the `str[] = ""` line in the state's signals dump - * holds `needle` as a substring, searched from the ` = ` separator so the - * name cannot false-match. */ +/* 1 when the unescaped `str[] = ""` holds `needle`. */ int64_t sz_timeline_signal_str_has(void *tl, int64_t i, SzString *name, SzString *needle) { - const char *p; - const char *end; SzTlState *s = tl_at(tl, i); const char *n = needle ? sz_string_cstr(needle) : ""; + const char *sep; + const char *p; + char *val = NULL; + int64_t hit = 0; if (!s || !s->signals || !n[0]) return 0; - p = tl_sig_line(s->signals, "str", name ? sz_string_cstr(name) : ""); - if (!p) + sep = tl_sig_line(s->signals, "str", name ? sz_string_cstr(name) : ""); + p = tl_sig_payload(sep); + if (!p || *p != '"') return 0; - end = strchr(p, '\n'); - if (!end) - end = p + strlen(p); - { - size_t len = (size_t)(end - p); - size_t m = strlen(n); - size_t k; - if (m > len) - return 0; - for (k = 0; k + m <= len; k++) { - if (memcmp(p + k, n, m) == 0) - return 1; - } - } - return 0; + if (!sz_dump_parse_quoted(p, &val) || !val) + return 0; + hit = strstr(val, n) != NULL ? 1 : 0; + sz_free(val); + return hit; } int64_t sz_timeline_a11y_has(void *tl, int64_t i, SzString *needle) { diff --git a/crates/runtime/src/ui.c b/crates/runtime/src/ui.c index 15aa60ea..4392c4e1 100644 --- a/crates/runtime/src/ui.c +++ b/crates/runtime/src/ui.c @@ -13,6 +13,8 @@ #include #include #include +#include +#include static int want_gpu_presenter(void) { const char *e = getenv("SCUZZ_SKIA"); @@ -170,6 +172,9 @@ struct SzUiSession { static SzUiSession *g_live_session; static char *g_pending_title; +static void host_free(char **p); +static void session_drop_pointer(SzUiSession *session); + static int runtime_kind_ok(SzUiRuntimeKind kind) { return kind == SZ_UI_RUNTIME_HEADLESS || kind == SZ_UI_RUNTIME_DESKTOP || kind == SZ_UI_RUNTIME_MOBILE; @@ -265,6 +270,7 @@ void sz_ui_session_take_root(SzUiSession *session) { int sz_ui_session_replace_root(SzUiSession *session, SzView *root) { if (!session || !root) return 0; + session_drop_pointer(session); if (session->owns_view) sz_view_free(session->root); session->root = root; @@ -273,21 +279,65 @@ int sz_ui_session_replace_root(SzUiSession *session, SzView *root) { return 1; } -enum { SZ_UI_STAMP_CAP = 4096 }; - -static char *stamp_snapshot(const char *path) { +/* Whole-file read for inject playback (prefix-extend). Grows like signal dump. */ +static char *read_file_all(const char *path) { FILE *f; - char buf[SZ_UI_STAMP_CAP]; + char *buf = NULL; + size_t len = 0, cap = 0; + char tmp[4096]; size_t n; if (!path) return sz_strdup(""); f = fopen(path, "rb"); if (!f) return sz_strdup(""); - n = fread(buf, 1, sizeof(buf) - 1, f); + while ((n = fread(tmp, 1, sizeof tmp, f)) > 0) { + if (len + n + 1 > cap) { + size_t ncap = cap ? cap : 256; + char *nb; + while (len + n + 1 > ncap) + ncap *= 2; + nb = (char *)sz_alloc(ncap); + if (buf) { + memcpy(nb, buf, len); + sz_free(buf); + } + buf = nb; + cap = ncap; + } + memcpy(buf + len, tmp, n); + len += n; + } + fclose(f); + if (!buf) + return sz_strdup(""); + buf[len] = '\0'; + return buf; +} + +/* Small watch stamp: length plus FNV-1a of the whole file. */ +static char *watch_stamp(const char *path) { + FILE *f; + unsigned char tmp[4096]; + size_t n, total = 0; + uint32_t h = 2166136261u; + char out[64]; + if (!path) + return sz_strdup("0:0"); + f = fopen(path, "rb"); + if (!f) + return sz_strdup("0:0"); + while ((n = fread(tmp, 1, sizeof tmp, f)) > 0) { + size_t i; + total += n; + for (i = 0; i < n; i++) { + h ^= tmp[i]; + h *= 16777619u; + } + } fclose(f); - buf[n] = '\0'; - return sz_strdup(buf); + snprintf(out, sizeof out, "%zu:%08x", total, h); + return sz_strdup(out); } static int stamp_changed(SzUiSession *session) { @@ -295,7 +345,7 @@ static int stamp_changed(SzUiSession *session) { int changed; if (!session || !session->watch_path) return 0; - now = stamp_snapshot(session->watch_path); + now = watch_stamp(session->watch_path); changed = !session->watch_fp || strcmp(session->watch_fp, now) != 0; if (changed) { sz_free(session->watch_fp); @@ -322,7 +372,7 @@ int sz_ui_session_watch(SzUiSession *session, const char *path) { sz_free(session->watch_path); sz_free(session->watch_fp); session->watch_path = sz_strdup(path); - session->watch_fp = stamp_snapshot(path); + session->watch_fp = watch_stamp(path); return 1; } @@ -344,7 +394,7 @@ int sz_ui_session_set_inject(SzUiSession *session, const char *path) { sz_free(session->inject_path); sz_free(session->inject_fp); session->inject_path = sz_strdup(path); - session->inject_fp = stamp_snapshot(path); + session->inject_fp = read_file_all(path); return 1; } @@ -381,26 +431,30 @@ static void fputs_dump_quoted(FILE *f, const char *s) { } /* Editor dump: keep newlines as \\n so a file buffer stays one node. */ -static void fputs_dump_escaped(FILE *f, const char *s) { +static void fputs_escaped_body(FILE *f, const char *s) { const char *p; - fputc('"', f); - if (s) { - for (p = s; *p; p++) { - unsigned char c = (unsigned char)*p; - if (c == '\\') - fputs("\\\\", f); - else if (c == '"') - fputs("\\\"", f); - else if (c == '\n') - fputs("\\n", f); - else if (c == '\r') - fputs("\\r", f); - else if (c == '\t') - fputs("\\t", f); - else - fputc(*p, f); - } + if (!s) + return; + for (p = s; *p; p++) { + unsigned char c = (unsigned char)*p; + if (c == '\\') + fputs("\\\\", f); + else if (c == '"') + fputs("\\\"", f); + else if (c == '\n') + fputs("\\n", f); + else if (c == '\r') + fputs("\\r", f); + else if (c == '\t') + fputs("\\t", f); + else + fputc(*p, f); } +} + +static void fputs_dump_escaped(FILE *f, const char *s) { + fputc('"', f); + fputs_escaped_body(f, s); fputc('"', f); } @@ -671,16 +725,22 @@ int sz_ui_session_load_code(SzUiSession *session, const char *path) { if (snprintf(staged, sizeof staged, "%s.load-%d", path, session->code_gen) >= (int)sizeof staged) return 0; - if (!copy_file(path, staged)) + if (!copy_file(path, staged)) { + unlink(staged); return 0; + } h = dlopen(staged, RTLD_NOW | RTLD_LOCAL); - if (!h) + if (!h) { + unlink(staged); return 0; + } fn = (SzUiRebuildFn)dlsym(h, "sz_ui_reload_rebuild"); if (!fn) { dlclose(h); + unlink(staged); return 0; } + unlink(staged); session->code_stale = session->code_handle; session->code_handle = h; session->rebuild = fn; @@ -749,11 +809,31 @@ static void host_free(char **p) { *p = NULL; } +static void session_drop_pointer(SzUiSession *session) { + if (!session) + return; + session->pointer_down = 0; + session->pointer_button = 0; + session->pointer_scroll = NULL; + session->pointer_slider = NULL; + session->pointer_field = NULL; + session->hover_seen = 0; + host_free(&session->hover_desc); + session->last_hit_seen = 0; + host_free(&session->last_hit_desc); + session->last_secondary_seen = 0; + host_free(&session->last_secondary_desc); + host_free(&session->record_hover_desc); + if (session->root) + sz_view_clear_hover(session->root); +} + void sz_ui_unmount(SzUiSession *session) { if (!session) return; if (g_live_session == session) g_live_session = NULL; + session_drop_pointer(session); sz_ui_bridge_flush(session); pthread_mutex_destroy(&session->bridge_lock); if (session->cfg.kind == SZ_UI_RUNTIME_DESKTOP) @@ -1128,9 +1208,11 @@ static void record_clipboard_verb(SzUiSession *session, int op) { fputs("copy\n", f); else if (op == 2) fputs("cut\n", f); - else if (session->clipboard && session->clipboard[0]) - fprintf(f, "paste %s\n", session->clipboard); - else + else if (session->clipboard && session->clipboard[0]) { + fputs("paste ", f); + fputs_escaped_body(f, session->clipboard); + fputc('\n', f); + } else fputs("paste\n", f); fclose(f); } @@ -1171,15 +1253,20 @@ static void record_live_event(SzUiSession *session, const SzInputEvent *ev) { if (!clipboard_chord(ev->key, ev->key_mods)) record_key_line(f, ev); } else if (ev->kind == SZ_INPUT_COMPOSE) { - if (ev->text && ev->text[0]) - fprintf(f, "compose %s\n", ev->text); - else + if (ev->text && ev->text[0]) { + fputs("compose ", f); + fputs_escaped_body(f, ev->text); + fputc('\n', f); + } else fputs("commit\n", f); } else if (ev->kind == SZ_INPUT_TEXT_EDIT) { if (!ev->text || !ev->text[0]) fputs("backspace\n", f); - else - fprintf(f, "type %s\n", ev->text); + else { + fputs("type ", f); + fputs_escaped_body(f, ev->text); + fputc('\n', f); + } } else if (ev->kind == SZ_INPUT_POINTER && ev->pointer_phase == SZ_POINTER_MOVE && !session->pointer_down) { SzView *tip; @@ -1279,7 +1366,7 @@ static int take_inject(SzUiSession *session, char **out) { if (!session || !session->inject_path || !out) return 0; *out = NULL; - now = stamp_snapshot(session->inject_path); + now = read_file_all(session->inject_path); if (session->inject_fp && strcmp(session->inject_fp, now) == 0) { sz_free(now); return 0; diff --git a/crates/runtime/src/ui_script.c b/crates/runtime/src/ui_script.c index 0afcce86..939e079f 100644 --- a/crates/runtime/src/ui_script.c +++ b/crates/runtime/src/ui_script.c @@ -1,6 +1,7 @@ #include "ui_script.h" #include "scuzz_rt.h" +#include "rt_util.h" #include #include @@ -506,7 +507,9 @@ static void play_script_line(SzUiSession *session, char *line) { } else if (strncmp(line, "type ", 5) == 0 || strcmp(line, "type") == 0) { int idx; const char *payload = script_field_payload(len > 4 ? line + 5 : "", &idx); - script_type(session, idx, payload); + char *raw = sz_dump_unescape(payload); + script_type(session, idx, raw); + sz_free(raw); } else if (strncmp(line, "key ", 4) == 0 || strcmp(line, "key") == 0) { const char *rest = len > 3 ? line + 4 : ""; char token[96]; @@ -528,9 +531,12 @@ static void play_script_line(SzUiSession *session, char *line) { script_key(session, name, text, mods, repeat); } else if (strncmp(line, "compose ", 8) == 0 || strcmp(line, "compose") == 0) { const char *rest = len > 7 ? line + 8 : ""; + char *raw; while (*rest == ' ') rest++; - script_compose(session, rest); + raw = sz_dump_unescape(rest); + script_compose(session, raw); + sz_free(raw); } else if (strcmp(line, "commit") == 0) { script_compose(session, ""); } else if (strncmp(line, "caret ", 6) == 0 || strcmp(line, "caret") == 0) { @@ -559,10 +565,16 @@ static void play_script_line(SzUiSession *session, char *line) { fprintf(stderr, "scuzz: script cut skipped (no text field)\n"); } else if (strncmp(line, "paste ", 6) == 0 || strcmp(line, "paste") == 0) { const char *payload = NULL; + char *raw = NULL; if (len > 6 && line[5] == ' ' && line[6]) payload = line + 6; + if (payload) { + raw = sz_dump_unescape(payload); + payload = raw; + } if (!sz_ui_session_paste(session, payload)) fprintf(stderr, "scuzz: script paste skipped (no text field)\n"); + sz_free(raw); } else if (strncmp(line, "drag ", 5) == 0) { float x1 = 0.f, y1 = 0.f, x2 = 0.f, y2 = 0.f; if (sscanf(line + 5, "%f %f %f %f", &x1, &y1, &x2, &y2) == 4) diff --git a/crates/runtime/src/view.c b/crates/runtime/src/view.c index f7060d19..d9fa3fe7 100644 --- a/crates/runtime/src/view.c +++ b/crates/runtime/src/view.c @@ -55,7 +55,7 @@ struct SzView { /* View.each: rebuild children from Signal.list at layout (pull). */ SzSignalList *each_sig; - SzList *each_seen; /* last synced list pointer (not owned) */ + SzList *each_seen; /* last synced list (retained; sentinel 1 = never synced) */ SzViewEachFn each_fn; void *each_env; @@ -1090,9 +1090,9 @@ static const char *a11y_role_name(SzA11yRole role) { } } -static void a11y_dump_node(SzView *v, char *buf, size_t cap, size_t *len) { +static void a11y_dump_node(SzView *v, char **buf, size_t *len, size_t *cap) { int i; - if (!v || !buf || !len || !view_is_shown(v)) + if (!v || !buf || !len || !cap || !view_is_shown(v)) return; if (v->kind == SZ_VIEW_EXCLUDE_SEMANTICS) return; @@ -1100,7 +1100,6 @@ static void a11y_dump_node(SzView *v, char *buf, size_t cap, size_t *len) { char line[256]; char live[256]; const char *label = v->a11y_label ? v->a11y_label : ""; - int n; if (v->kind == SZ_VIEW_TEXT && (v->sig_int || v->sig_str)) { resolve_text(v, live, sizeof live); label = live; @@ -1156,13 +1155,9 @@ static void a11y_dump_node(SzView *v, char *buf, size_t cap, size_t *len) { snprintf(live, sizeof live, "%d", view_overlay_open(v) ? 1 : 0); label = live; } - n = snprintf(line, sizeof line, "%s:%s\n", a11y_role_name(v->a11y_role), - label); - if (n > 0 && *len + (size_t)n < cap) { - memcpy(buf + *len, line, (size_t)n); - *len += (size_t)n; - buf[*len] = '\0'; - } + snprintf(line, sizeof line, "%s:%s\n", a11y_role_name(v->a11y_role), + label); + sz_dump_append(buf, len, cap, line); } if (v->kind == SZ_VIEW_MERGE_SEMANTICS) return; @@ -1173,15 +1168,18 @@ static void a11y_dump_node(SzView *v, char *buf, size_t cap, size_t *len) { if (v->kind == SZ_VIEW_OVERLAY && !view_overlay_open(v)) return; for (i = 0; i < v->child_count; i++) - a11y_dump_node(v->children[i], buf, cap, len); + a11y_dump_node(v->children[i], buf, len, cap); } SzString *sz_view_a11y_dump(SzView *root) { - char buf[4096]; - size_t len = 0; - buf[0] = '\0'; - a11y_dump_node(root, buf, sizeof buf, &len); - return sz_string_from_cstr(buf); + char *buf = NULL; + size_t len = 0, cap = 0; + SzString *out; + sz_dump_append(&buf, &len, &cap, ""); + a11y_dump_node(root, &buf, &len, &cap); + out = sz_string_from_cstr(buf); + sz_free(buf); + return out; } SzView *sz_view_column(void) { return view_new(SZ_VIEW_COLUMN); } @@ -1217,6 +1215,20 @@ SzView *sz_view_each_map(SzSignalList *sig, SzViewEachFn fn, void *env) { return v; } +static int each_seen_is_sentinel(SzList *p) { + return p == (SzList *)(uintptr_t)1; +} + +static void each_seen_set(SzView *v, SzList *xs) { + if (!v) + return; + if (v->each_seen && !each_seen_is_sentinel(v->each_seen)) + sz_release(v->each_seen); + if (xs) + sz_retain(xs); + v->each_seen = xs; +} + static void sync_each(SzView *v) { SzList *xs; SzList *p; @@ -1240,7 +1252,7 @@ static void sync_each(SzView *v) { sz_view_add_child(v, sz_view_text("- ")); } } - v->each_seen = xs; + each_seen_set(v, xs); } SzView *sz_view_scroll(SzView *child) { @@ -1523,6 +1535,9 @@ void sz_view_free(SzView *view) { view->tap_env = NULL; sz_release(view->each_env); view->each_env = NULL; + if (view->each_seen && view->each_seen != (SzList *)(uintptr_t)1) + sz_release(view->each_seen); + view->each_seen = NULL; { int u; for (u = 0; u < view->undo_n; u++) diff --git a/crates/runtime/tests/test_io.c b/crates/runtime/tests/test_io.c index 29080eb7..27bb2fab 100644 --- a/crates/runtime/tests/test_io.c +++ b/crates/runtime/tests/test_io.c @@ -10850,6 +10850,22 @@ int main(void) { assert(sz_timeline_a11y_has(tl, 0, needle) == 1); sz_release(needle); sz_timeline_free(tl); + write_text(path, "# timeline v=2 n=1\n--- 0\nlast_hit:\n\ndrive:\n\n" + "signals:\nlist[1] q = [\"a\\\"b\", \"a\\nb\"]\n" + "str[2] draft = \"a\\\"b\"\na11y:\n\n"); + tl = sz_timeline_load(path); + assert(tl); + needle = sz_string_from_cstr("q"); + assert(sz_timeline_signal_list_len(tl, 0, needle) == 2); + sz_release(needle); + needle = sz_string_from_cstr("draft"); + { + SzString *want = sz_string_from_cstr("a\"b"); + assert(sz_timeline_signal_str_has(tl, 0, needle, want) == 1); + sz_release(want); + } + sz_release(needle); + sz_timeline_free(tl); write_text(path, "# timeline v=3 n=0\n"); assert(sz_timeline_load(path) == NULL); write_text(path, "nonsense\n"); diff --git a/crates/runtime/tests/test_ui.c b/crates/runtime/tests/test_ui.c index aa497f65..e28ddf32 100644 --- a/crates/runtime/tests/test_ui.c +++ b/crates/runtime/tests/test_ui.c @@ -11,6 +11,7 @@ #include #include #include +#include int sz_view_paint(SzView *root, SkCanvas *canvas, int width, int height, const SzTheme *theme); @@ -1003,6 +1004,56 @@ static void test_session_inject_script(void) { remove(path); } +static void test_session_inject_grows_past_4k(void) { + SzUiConfig cfg; + SzUiSession *session; + SzView *root, *btn; + SzSignalInt *count; + const char *path = "/tmp/scuzz_ui_inject_4k.script"; + FILE *f; + int i; + + remove(path); + count = sz_signal_int(0); + root = sz_view_column(); + btn = sz_view_button("+", counter_tap, count); + sz_view_add_child(root, btn); + + memset(&cfg, 0, sizeof(cfg)); + cfg.kind = SZ_UI_RUNTIME_HEADLESS; + cfg.width = 200; + cfg.height = 100; + cfg.scale = 1.0; + session = sz_ui_mount(&cfg, root); + assert(session); + sz_ui_session_take_root(session); + assert(sz_ui_session_set_inject(session, path)); + assert(sz_ui_pump_sync(session)); + assert(sz_signal_int_get(count) == 0); + + f = fopen(path, "w"); + assert(f); + fputc('#', f); + for (i = 0; i < 4200; i++) + fputc('x', f); + fputc('\n', f); + fputs("tap 0\n", f); + fclose(f); + assert(sz_ui_pump_sync(session)); + assert(sz_signal_int_get(count) == 1); + + f = fopen(path, "a"); + assert(f); + fputs("tap 0\n", f); + fclose(f); + assert(sz_ui_pump_sync(session)); + assert(sz_signal_int_get(count) == 2); + + sz_ui_unmount(session); + sz_signal_int_free(count); + remove(path); +} + static void test_session_inject_control(void) { SzUiConfig cfg; SzUiSession *session; @@ -1295,6 +1346,10 @@ static void test_session_inject_type(void) { assert(sz_ui_pump_sync(session)); assert(strcmp(sz_signal_str_get(draft), "abc") == 0); + write_stamp(path, "text x\ntype a\\nb\n"); + assert(sz_ui_pump_sync(session)); + assert(strcmp(sz_signal_str_get(draft), "xa\nb") == 0); + sz_ui_unmount(session); sz_signal_str_free(draft); remove(path); @@ -1444,6 +1499,46 @@ static void test_record_live_key(void) { remove(record); } +static void test_record_type_escapes(void) { + SzUiConfig cfg; + SzUiSession *session; + SzView *root, *field; + SzSignalStr *draft; + SzInputEvent ev; + const char *record = "/tmp/scuzz_ui_record_type_esc.script"; + char *body; + + remove(record); + draft = sz_signal_str(""); + root = sz_view_column(); + field = sz_view_text_field(draft, "item"); + sz_view_add_child(root, field); + + memset(&cfg, 0, sizeof(cfg)); + cfg.kind = SZ_UI_RUNTIME_HEADLESS; + cfg.width = 200; + cfg.height = 80; + cfg.scale = 1.0; + session = sz_ui_mount(&cfg, root); + assert(session); + sz_ui_session_take_root(session); + assert(sz_ui_session_set_record(session, record)); + assert(sz_ui_pump_sync(session)); + + memset(&ev, 0, sizeof(ev)); + ev.kind = SZ_INPUT_TEXT_EDIT; + ev.text = "a\nb"; + assert(sz_ui_session_live_inject(session, &ev)); + body = slurp_cstr(record); + assert(strstr(body, "type a\\nb") != NULL); + free(body); + assert(strcmp(sz_signal_str_get(draft), "a\nb") == 0); + + sz_ui_unmount(session); + sz_signal_str_free(draft); + remove(record); +} + static void test_session_inject_key_repeat(void) { SzUiConfig cfg; SzUiSession *session; @@ -12318,6 +12413,49 @@ static void test_slider_pointer_drag(void) { sz_signal_int_free(sig); } +static void test_replace_root_drops_pointer(void) { + SzUiConfig cfg; + SzUiSession *session; + SzView *root1, *root2, *sl; + SzSignalInt *sig; + SzInputEvent ev; + const SzTheme *theme = sz_theme_default(); + SzRect f; + int64_t v0; + + sig = sz_signal_int(0); + sl = sz_view_slider(sig); + root1 = sz_view_column(); + sz_view_add_child(root1, sl); + memset(&cfg, 0, sizeof(cfg)); + cfg.kind = SZ_UI_RUNTIME_HEADLESS; + cfg.width = 200; + cfg.height = 80; + cfg.scale = 1.0; + session = sz_ui_mount(&cfg, root1); + assert(session); + sz_ui_session_take_root(session); + assert(sz_ui_pump_sync(session)); + sz_view_layout(root1, 200.f, 80.f, theme); + f = sz_view_frame(sl); + memset(&ev, 0, sizeof(ev)); + ev.kind = SZ_INPUT_POINTER; + ev.pointer_phase = SZ_POINTER_DOWN; + ev.x = f.x + 4.f; + ev.y = f.y + f.h * 0.5f; + assert(sz_ui_inject_sync(session, &ev)); + v0 = sz_signal_int_get(sig); + root2 = sz_view_column(); + sz_view_add_child(root2, sz_view_text("after")); + assert(sz_ui_session_replace_root(session, root2)); + ev.pointer_phase = SZ_POINTER_MOVE; + ev.x = f.x + f.w * 0.8f; + (void)sz_ui_inject_sync(session, &ev); + assert(sz_signal_int_get(sig) == v0); + sz_ui_unmount(session); + sz_signal_int_free(sig); +} + static void test_slider_live_records_xy(void) { SzUiConfig cfg; SzUiSession *session; @@ -12845,6 +12983,28 @@ static void test_a11y(void) { } } +static void test_a11y_dump_grows_past_4k(void) { + SzView *col; + SzString *dump; + char lab[64]; + int i; + const char *s; + + col = sz_view_column(); + for (i = 0; i < 80; i++) { + snprintf(lab, sizeof lab, "L%02d-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + i); + sz_view_add_child(col, sz_view_button(lab, NULL, NULL)); + } + sz_view_add_child(col, sz_view_button("TAIL", NULL, NULL)); + dump = sz_view_a11y_dump(col); + s = sz_string_cstr(dump); + assert(strlen(s) > 4096); + assert(strstr(s, "button:TAIL") != NULL); + sz_string_free(dump); + sz_view_free(col); +} + static void test_clear_children(void) { SzView *list; SzString *dump; @@ -12893,6 +13053,39 @@ static void test_view_each(void) { sz_signal_list_free(items); } +static void test_view_each_setlist_rebuilds(void) { + SzSignalList *items; + SzView *list; + const SzTheme *theme = sz_theme_default(); + SzList *xs; + SzString *dump; + int i; + + xs = sz_list_cons(sz_string_from_cstr("old"), sz_list_nil()); + items = sz_signal_list(xs); + sz_release(xs); + list = sz_view_each(items); + sz_view_layout(list, 200.f, 120.f, theme); + dump = sz_view_a11y_dump(list); + assert(strstr(sz_string_cstr(dump), "text:- old") != NULL); + sz_string_free(dump); + + xs = sz_list_cons(sz_string_from_cstr("new"), sz_list_nil()); + sz_signal_list_set(items, xs); + sz_release(xs); + for (i = 0; i < 64; i++) { + SzList *t = sz_list_cons(sz_string_from_cstr("churn"), sz_list_nil()); + sz_release(t); + } + sz_view_layout(list, 200.f, 120.f, theme); + dump = sz_view_a11y_dump(list); + assert(strstr(sz_string_cstr(dump), "text:- new") != NULL); + assert(strstr(sz_string_cstr(dump), "text:- old") == NULL); + sz_string_free(dump); + sz_view_free(list); + sz_signal_list_free(items); +} + static SzView *each_map_text(SzString *item, void *env) { (void)env; return sz_view_text(item ? sz_string_cstr(item) : ""); @@ -13186,6 +13379,119 @@ static void test_property_signal_str(void) { sz_signal_str_free(draft); } +static void test_signal_dump_escapes(void) { + SzSignalStr *s; + SzSignalList *items; + SzList *xs; + SzString *dump; + SzString *name; + SzString *got; + const char *d; + char big[2001]; + int i; + + s = sz_signal_str("a\"b"); + sz_signal_name(s, "q"); + dump = sz_signal_dump(); + d = sz_string_cstr(dump); + assert(strstr(d, "a\\\"b") != NULL); + sz_string_free(dump); + + sz_signal_str_set(s, "a\nb"); + dump = sz_signal_dump(); + d = sz_string_cstr(dump); + assert(strstr(d, "a\\nb") != NULL); + sz_string_free(dump); + + for (i = 0; i < 2000; i++) + big[i] = 'x'; + big[2000] = '\0'; + sz_signal_str_set(s, big); + dump = sz_signal_dump(); + d = sz_string_cstr(dump); + assert(strlen(d) > 2000); + assert(strstr(d, big) != NULL); + sz_string_free(dump); + sz_signal_str_free(s); + + xs = sz_list_cons(sz_string_from_cstr("a\"b"), + sz_list_cons(sz_string_from_cstr("a\nb"), sz_list_nil())); + items = sz_signal_list(xs); + sz_signal_name(items, "xs"); + dump = sz_signal_dump(); + d = sz_string_cstr(dump); + assert(strstr(d, "a\\\"b") != NULL); + assert(strstr(d, "a\\nb") != NULL); + name = sz_string_from_cstr("xs"); + assert(sz_property_signal_list_len(name) == 2); + got = sz_property_signal_list_at(name, 0); + assert(strcmp(sz_string_cstr(got), "a\"b") == 0); + sz_string_free(got); + got = sz_property_signal_list_at(name, 1); + assert(strcmp(sz_string_cstr(got), "a\nb") == 0); + sz_string_free(got); + sz_release(name); + sz_string_free(dump); + sz_signal_list_free(items); +} + +static void test_signal_name_last_wins(void) { + SzSignalInt *a; + SzSignalInt *b; + SzString *name; + + a = sz_signal_int(1); + b = sz_signal_int(2); + sz_signal_name(a, "n"); + sz_signal_name(b, "n"); + name = sz_string_from_cstr("n"); + assert(sz_property_signal_int(name) == 2); + sz_release(name); + sz_signal_int_free(a); + sz_signal_int_free(b); +} + +static void test_property_replay_str_list(void) { + SzSignalStr *draft; + SzSignalList *items; + SzList *xs; + SzString *name; + SzString *got; + + setenv("SCUZZ_TESTRT", "1", 1); + sz_property_session_reset(); + draft = sz_signal_str("old"); + sz_signal_name(draft, "draft"); + xs = sz_list_cons(sz_string_from_cstr("a"), + sz_list_cons(sz_string_from_cstr("b"), sz_list_nil())); + items = sz_signal_list(xs); + sz_signal_name(items, "items"); + sz_property_session_step(); + sz_signal_str_set(draft, "new"); + sz_signal_list_set(items, sz_list_nil()); + name = sz_string_from_cstr("draft"); + got = sz_property_signal_str(name); + assert(strcmp(sz_string_cstr(got), "new") == 0); + sz_string_free(got); + sz_timeline_replay_from(0); + got = sz_property_signal_str(name); + assert(strcmp(sz_string_cstr(got), "old") == 0); + sz_string_free(got); + sz_release(name); + name = sz_string_from_cstr("items"); + assert(sz_property_signal_list_len(name) == 2); + got = sz_property_signal_list_at(name, 1); + assert(strcmp(sz_string_cstr(got), "b") == 0); + sz_string_free(got); + sz_timeline_replay_from(-1); + assert(sz_property_signal_list_len(name) == 0); + sz_release(name); + sz_property_session_reset(); + unsetenv("SCUZZ_TESTRT"); + sz_signal_str_free(draft); + sz_signal_list_free(items); +} + static void test_signal_list_spine_collect(void) { SzSignalList *items; SzList *xs; @@ -14428,6 +14734,13 @@ static void test_session_load_code(void) { sz_ui_session_take_root(session); sz_ui_session_set_rebuild(session, NULL, count); assert(sz_ui_session_load_code(session, RELOAD_A)); + { + char staged[128]; + FILE *st; + snprintf(staged, sizeof staged, "%s.load-1", RELOAD_A); + st = fopen(staged, "rb"); + assert(st == NULL); + } assert(sz_ui_session_reload(session)); a11y = sz_view_a11y_dump(sz_ui_session_root(session)); assert(strstr(sz_string_cstr(a11y), "text:A") != NULL); @@ -14541,6 +14854,7 @@ int main(void) { test_record_live_scroll(); test_studio_shaped_xy(); test_session_inject_script(); + test_session_inject_grows_past_4k(); test_session_inject_control(); test_session_dump_now_needs_path(); test_session_inject_scroll(); @@ -14549,6 +14863,7 @@ int main(void) { test_session_inject_key(); test_session_inject_key_utf8_backspace(); test_record_live_key(); + test_record_type_escapes(); test_session_inject_key_repeat(); test_session_inject_compose(); test_record_live_hover_secondary(); @@ -15035,6 +15350,7 @@ int main(void) { test_slider_paint_fill(); test_slider_in_taps_dump(); test_slider_pointer_drag(); + test_replace_root_drops_pointer(); test_slider_live_records_xy(); test_progress_sizes(); test_progress_unbounded_width(); @@ -15060,8 +15376,10 @@ int main(void) { test_button_does_not_wrap(); test_text_blank_line_from_newline(); test_a11y(); + test_a11y_dump_grows_past_4k(); test_clear_children(); test_view_each(); + test_view_each_setlist_rebuilds(); test_view_each_map_text(); test_view_each_map_button(); test_each_expanded_row_in_scroll(); @@ -15071,6 +15389,9 @@ int main(void) { test_property_signal_int(); test_signal_list_record_dump(); test_property_signal_str(); + test_signal_dump_escapes(); + test_signal_name_last_wins(); + test_property_replay_str_list(); test_text_field_edit(); test_view_editor(); test_view_editor_viewport(); diff --git a/docs/guide.md b/docs/guide.md index cff069a5..b2a12c84 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -54,7 +54,7 @@ Console kit: `Sys.args(): IO[List[String]]`, `Sys.readLine(): IO[String]` (EOF - Blessed impurity only: `IO.println` / `sleep` / `fail` / `pure` / `race` / `both` / `ensure` / `timeout` / `forever` / `repeatN` / `retryN` / `foreach` / `foreachDiscard` / `when` / `unless`, `.map` / `.flatMap` / `.handleErrorWith` / `.attempt`, `Fiber.fork` / `join` / `interrupt`, `Ref.*` / `Queue.*` / `Deferred.*` (`Ref.of` infers `A`; pin `Queue[Int]` / `Deferred[Int]` with `: IO[Queue[Int]]`; `Ref.update` / `Ref.updateAndGet`), `Resource.make` / `Resource.use` (`Resource[A]` from acquire `IO[A]`; release on success, failure, and cancel), `Stream.emit` / `emits` / `eval` / `concat` / `map` / `evalMap` / `evalTap` / `filter` / `filterNot` / `take` / `takeWhile` / `drop` / `dropWhile` / `find` / `findLast` / `exists` / `forall` / `none` / `range` / `repeatN` / `zip` / `zipWith` / `zipAll` / `zipWithIndex` / `interleave` / `intersperse` / `grouped` / `sliding` / `takeRight` / `dropRight` / `flatten` / `flatMap` / `mapConcat` / `scan` / `fold` / `changes` / `orElse` / `iterate` / `unfold` / `head` / `last` / `count` / `compileToList` / `drain` (`Stream[A]` from emit/emits/eval/range/iterate/unfold; `exists` / `forall` / `none` are `IO[Bool]`; `fold` is `IO[Z]`; `head` / `last` are `IO[A]` and fail when empty; `count` is `IO[Int]`; `zip` / `zipAll` are `Stream[(A, B)]`; `interleave` is `Stream[A]`; `grouped` / `sliding` are `Stream[List[A]]`), `Fs.*`, `Json.parse` / `Json.stringify` (enum `Json`: `Null|Bool|Int|Float|Str|Arr|Obj`; `parse` is `Result[Json]`; `stringify` is `Result[String]`; query: `get` / `keys` / `arr` / `at` / `has` / `pairs` / `is*` / `as*` / `*Or` / `getBool` / `getInt` / `getStr` / `getFloat` / `merge`; write: `set` / `remove` / `append` / `prepend` / `setAt` / `dropAt`; a miss is an empty list; `set` on a non-Obj is a one-key Obj; `append` / `prepend` on a non-Arr is a one-cell Arr), `Sys.args` / `Sys.readLine` / `Sys.read` / `Sys.write` / `Sys.exec` / `Sys.spawn` / `Sys.childWrite` / `Sys.childRead` / `Sys.childClose` / `Sys.alive` / `Sys.kill` / `Sys.getenv`, `Clock.*`, `Random.nextInt` (`IO[Int]` in `[0, bound)`; bound <= 0 is `IO.fail`), `Net.httpGet` / `Net.httpPost` / `Net.httpPut` / `Net.httpPatch` / `Net.httpDelete` / `Net.httpHead` / `Net.serveOnce` / `Net.serve` (handler receives `(path, method, body)` and returns the response body as `IO[String]`) / `Net.tcpConnect` / `Net.tcpListen` / `Net.tcpAccept` / `Net.tcpRead` / `Net.tcpWrite` / `Net.tcpClose` / `Net.udpBind` / `Net.udpSend` / `Net.udpRecv` / `Net.udpClose` - No raw side effects in View build. Taps may run `IO` through `sz_io_unsafe_run`. The tap drops a leftover owned value or the run result. -The product CLI is Scuzz (`examples/cli`). `scuzz --help` and `scuzz --help` list flags and examples. `scuzz check` is the linter (format-verify + typecheck). `scuzz fmt` rewrites. `scuzz watch` rebuilds on source change. It does not reload a running process. `[ui]` `scuzz run --watch` is hot reload: it keeps the process and stamp-reloads the View tree (Signals stay). IO-only `scuzz run --watch` kills and reruns the process on source change. `[ui]` build emits `build/reload.dylib`. On source change, watch recompiles it then stamps so the session `dlopen`s new machine code (`SCUZZ_UI_RELOAD_CODE`). The process rewrites `build/debug.dump` (signal store + a11y including live `View.bindText` + `[taps]` with frames / `[fields]` plus `caret=B sel=A:C` (and `preedit=` when compose is set) / `[editor]` buffer plus `caret=B sel=A:C sx=X sy=Y lines=L` when a `View.editor` is present (plus `diag=` / `tok=` / `inlay=` / `fold=` / `preedit=` when set) / `[scrolls]` inject indices, same format as `scuzz test` goldens; `[last_hit]` after a TAP; `[hover]` after a no-button MOVE; `[last_secondary]` after a button-3 click; `[session]` kind/size/title/focus/lifecycle/pumps; `[splits]` / `[overlays]` when present; `[heap]` alloc stats with kind census, delta, and `[live]` remaining blocks) on dirty pumps so agents can read live UI state, `tap N` without guessing coordinates, and see which TextField `text` / `type` / `key` / `compose` / `caret` / `select` / `backspace` hit (`N* placeholder="live" caret=B sel=A:C`). A `View.editor` dumps under `[editor]` (`N* caret=B sel=A:C sx=X sy=Y lines=L "buffer"`). Newlines stay as `\n`. Diagnostic marks append `diag=P:S`. LSP span counts append `tok=N` / `inlay=N` / `fold=N` when non-zero. Compose appends `preedit="…"` when the preview is non-empty. Append `tap` / `xy` / `text` / `type` / `key` / `compose` / `commit` / `caret` / `select` / `copy` / `cut` / `paste` / `drag` / `hover` / `secondary` / `pump` / `scroll` / `backspace` / `dump` / `reload` / `quit` / `resetpeak` lines to `build/inject.script` to drive the session (rewrite plays the whole file). `dump` rewrites the debug dump now. `reload` rebuilds the View factory. `quit` stops the live session. Desktop quit is window close. `resetpeak` sets peak bytes to live and marks the heap delta. Panic prints remaining `[heap]` and `[live]`, writes `build/debug.dump.panic` when a live dump path is set, then frees remaining live blocks and abort. `text N s` / `type N s` / `backspace N k` / `caret N b` / `select N a c` / `scroll N dy` target dump index N. `key [+shift|+ctrl|+cmd|+alt|+repeat] [text]` uses the starred field or focused editor (`Enter`, `Backspace`, `ArrowLeft`, `a`). `+repeat` is a held-key auto-repeat (same insert / move / delete as a discrete key). `compose ` sets IME preedit (underlined preview; not in the committed buffer). `compose` with no text, or `commit`, inserts the preedit at the caret. `key Escape` cancels preedit. `caret ` sets the starred-field or focused-editor caret byte offset. `caret N b` targets dump index N. `select ` sets the starred-field selection. `copy` / `cut` / `paste` / `paste ` drive the session clipboard. Headless `paste` is first-class. Desktop/Mobile pull the OS pasteboard on paste when present. `drag x1 y1 x2 y2` is pointer-drag select. Live OS keys record as `key`, not `type`. Live OS auto-repeat records `key a+repeat`. Live OS copy/cut/paste and Shift+arrows record those verbs. One-token forms (`text s`, `backspace k`, `scroll 40`) still use the starred field or first Scroll. `text 0` remains payload `"0"`. `xy x y` injects a TAP at a logical point; a miss does not panic. `hover x y` injects a pointer MOVE with no button and shows `View.tooltip`. `secondary N` / `secondary x y` is a button-3 click; it does not fire the primary tap. Live OS hover and right-click record as `hover` / `secondary`. Desktop/Mobile `scuzz run` records live OS clicks and keys to `build/record.script` (not `inject.script`) and writes `build/debug.dump`. Replay with `scuzz run --headless --script build/record.script --dump build/debug.dump`. `--message-format=json` applies to `check` only. That JSON is the editor protocol. +The product CLI is Scuzz (`examples/cli`). `scuzz --help` and `scuzz --help` list flags and examples. `scuzz check` is the linter (format-verify + typecheck). `scuzz fmt` rewrites. `scuzz watch` rebuilds on source change. It does not reload a running process. `[ui]` `scuzz run --watch` is hot reload: it keeps the process and stamp-reloads the View tree (Signals stay). IO-only `scuzz run --watch` kills and reruns the process on source change. `[ui]` build emits `build/reload.dylib`. On source change, watch recompiles it then stamps so the session `dlopen`s new machine code (`SCUZZ_UI_RELOAD_CODE`). The process rewrites `build/debug.dump` (signal store + a11y including live `View.bindText` + `[taps]` with frames / `[fields]` plus `caret=B sel=A:C` (and `preedit=` when compose is set) / `[editor]` buffer plus `caret=B sel=A:C sx=X sy=Y lines=L` when a `View.editor` is present (plus `diag=` / `tok=` / `inlay=` / `fold=` / `preedit=` when set) / `[scrolls]` inject indices, same format as `scuzz test` goldens; `[last_hit]` after a TAP; `[hover]` after a no-button MOVE; `[last_secondary]` after a button-3 click; `[session]` kind/size/title/focus/lifecycle/pumps; `[splits]` / `[overlays]` when present; `[heap]` alloc stats with kind census, delta, and `[live]` remaining blocks) on dirty pumps so agents can read live UI state, `tap N` without guessing coordinates, and see which TextField `text` / `type` / `key` / `compose` / `caret` / `select` / `backspace` hit (`N* placeholder="live" caret=B sel=A:C`). A `View.editor` dumps under `[editor]` (`N* caret=B sel=A:C sx=X sy=Y lines=L "buffer"`). Newlines stay as `\n`. Signal dump strings and inject `type` / `paste` / `compose` payloads escape backslash, quote, newline, CR, and tab. Diagnostic marks append `diag=P:S`. LSP span counts append `tok=N` / `inlay=N` / `fold=N` when non-zero. Compose appends `preedit="…"` when the preview is non-empty. Append `tap` / `xy` / `text` / `type` / `key` / `compose` / `commit` / `caret` / `select` / `copy` / `cut` / `paste` / `drag` / `hover` / `secondary` / `pump` / `scroll` / `backspace` / `dump` / `reload` / `quit` / `resetpeak` lines to `build/inject.script` to drive the session (rewrite plays the whole file). `dump` rewrites the debug dump now. `reload` rebuilds the View factory. `quit` stops the live session. Desktop quit is window close. `resetpeak` sets peak bytes to live and marks the heap delta. Panic prints remaining `[heap]` and `[live]`, writes `build/debug.dump.panic` when a live dump path is set, then frees remaining live blocks and abort. `text N s` / `type N s` / `backspace N k` / `caret N b` / `select N a c` / `scroll N dy` target dump index N. `key [+shift|+ctrl|+cmd|+alt|+repeat] [text]` uses the starred field or focused editor (`Enter`, `Backspace`, `ArrowLeft`, `a`). `+repeat` is a held-key auto-repeat (same insert / move / delete as a discrete key). `compose ` sets IME preedit (underlined preview; not in the committed buffer). `compose` with no text, or `commit`, inserts the preedit at the caret. `key Escape` cancels preedit. `caret ` sets the starred-field or focused-editor caret byte offset. `caret N b` targets dump index N. `select ` sets the starred-field selection. `copy` / `cut` / `paste` / `paste ` drive the session clipboard. Headless `paste` is first-class. Desktop/Mobile pull the OS pasteboard on paste when present. `drag x1 y1 x2 y2` is pointer-drag select. Live OS keys record as `key`, not `type`. Live OS auto-repeat records `key a+repeat`. Live OS copy/cut/paste and Shift+arrows record those verbs. One-token forms (`text s`, `backspace k`, `scroll 40`) still use the starred field or first Scroll. `text 0` remains payload `"0"`. `xy x y` injects a TAP at a logical point; a miss does not panic. `hover x y` injects a pointer MOVE with no button and shows `View.tooltip`. `secondary N` / `secondary x y` is a button-3 click; it does not fire the primary tap. Live OS hover and right-click record as `hover` / `secondary`. Desktop/Mobile `scuzz run` records live OS clicks and keys to `build/record.script` (not `inject.script`) and writes `build/debug.dump`. Replay with `scuzz run --headless --script build/record.script --dump build/debug.dump`. `--message-format=json` applies to `check` only. That JSON is the editor protocol. ## View + Signal + Ui From 804bd73f85ff7426ecc29a809ab7008fb537269f Mon Sep 17 00:00:00 2001 From: Sean Cheatham Date: Thu, 3 Sep 2026 23:58:57 -0400 Subject: [PATCH 03/32] Make scuzz lsp honest on the wire so editors overlay buffers, read full-sync didChange, get a WorkspaceEdit for rename, and see package-check diagnostics. Advertise only implemented methods and prove the protocol with cli-ok oracles. --- docs/gaps.md | 2 +- docs/guide.md | 4 +- docs/vision.md | 2 +- examples/cli/src/Cli.scuzz | 7 +- examples/cli/src/Help.scuzz | 2 - examples/cli/src/Main.scuzz | 63 +++++++++- examples/compiler/src/Check.scuzz | 32 ++++- examples/compiler/src/Drive.scuzz | 47 ++++++++ examples/compiler/src/Lsp.scuzz | 191 ++++++++++++++++++++++++------ examples/editor/src/Lsp.scuzz | 2 +- examples/editor/src/Lsp.scuzz_sim | 2 +- examples/editor/src/Main.scuzz | 1 + 12 files changed, 302 insertions(+), 53 deletions(-) diff --git a/docs/gaps.md b/docs/gaps.md index 7ac1d9c1..95b38438 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -37,7 +37,7 @@ These gaps keep the distinctive claims kernel-shaped. Close them in this order. 2. **UTF-8 `String`** — In: `Str.*` indexes code points; `Str.byteLen` / `Str.byteSlice` keep bytes for framing; the kernel `utf8Ops` drive oracle proves multibyte ops. Case maps stay ASCII by design. Caret offsets in TextField/editor stay bytes. The editor and toolchain LSP framing uses `Str.byteLen` / `Str.byteSlice`. LLVM `[N x i8]` string sizing uses `Str.byteLen`. -3. **Scuzz spans on panic and LSP** — In: `check` JSON diagnostics use recorded file stem, line, and column. No substring search. No hardcoded file. Product `scuzz lsp` is a stdio JSON-RPC server. It wraps `check`. JSON diagnostics stay the single schema. Goto-def uses `Fun.off`. Rename replaces lexer ident tokens. Hover names the ident under the caret. Completion filters by the caret prefix. Semantic tokens come from the lexer. Panic prints `scuzz panic: Main.scuzz:2:14: ` from that def's file, line, and column. The dogfood IDE consumes that schema. It does not replace it. +3. **Scuzz spans on panic and LSP** — In: `check` JSON diagnostics use recorded file stem, line, and column. No substring search. No hardcoded file. Product `scuzz lsp` is a stdio JSON-RPC server. It wraps `check`. JSON diagnostics stay the single schema. Overlay presence is a list entry. didChange reads full-sync `contentChanges`. Rename returns a WorkspaceEdit. Diagnostics run the check file list with overlays. Goto-def uses `Fun.off`. Rename replaces lexer ident tokens. Hover names the ident under the caret. Completion filters by the caret prefix from kit names and local defs. Semantic tokens come from the lexer. Panic prints `scuzz panic: Main.scuzz:2:14: ` from that def's file, line, and column. The dogfood IDE consumes that schema. It does not replace it. 4. **Typed fail `E`** — In: check encodes `IO[E, A]`. `IO[A]` means `IO[String, A]`. `IO.fail(e)` takes `E`. `handleErrorWith` binds `E`. `flatMap` keeps one `E`. Kits still fail with `String`. Open: the C `SzError` wire is still a string. Do not add `ZIO[R, E, A]`. Do not add user `IO.delay`. diff --git a/docs/guide.md b/docs/guide.md index b2a12c84..c6a88852 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -120,7 +120,7 @@ count.scuzz_verify # Timeline => Verdict session claims and Bool drive oracl - `[ui]` packages: `scuzz test` is Headless **structural** goldens on the **live** graph (signal store + a11y dump + tap/field/scroll indices). PNG optional through `--pixels`. Seed with `scuzz test --update`. `--update` creates `goldens/` when it is missing. Missing `goldens/` or empty dumps fail without `--update`. Seed and compare PNG goldens under the in-tree `sk_sw` backend (`SCUZZ_SKIA=sk_sw`) so pixels stay platform-deterministic. `scuzz test --differential` runs the golden scenarios once per render backend (`skia`, `sk_sw`, `gpu`) and compares the structural dumps across backends; a backend that cannot run on the host skips with a note. `scuzz fuzz` compiles the **verify** graph (sim + residual `.require` / `*.scuzz_verify` + drivers). - IO packages (no `[ui]`): `scuzz test` compiles and runs under `SCUZZ_TESTRT=1`, requiring exit 0 -- `scuzz check` format-verifies `src/` and `*.scuzz_verify` and typechecks live + sim + drivers + verify predicates + `where` + `.require`. A present empty `*.scuzz_verify` fails. A leftover `*.scuzz_intent` file fails. It reports unused imports, unused locals, unused parameters, and unused private defs. It reports unclaimed defs, signals, and controls as info (used names that no claim observes). A name that starts with `_` is kept on purpose. `--message-format=json` is the editor protocol (`check` only). `scuzz lsp` wraps that JSON over stdin/stdout (open buffers overlay disk on didOpen/didChange; didClose uses disk; `workspace/didChangeWatchedFiles` republishes check). Language-server methods use the same parse. One run reports every parse and type diagnostic. Unclaimed reports do not fail `check`. +- `scuzz check` format-verifies `src/` and `*.scuzz_verify` and typechecks live + sim + drivers + verify predicates + `where` + `.require`. A present empty `*.scuzz_verify` fails. A leftover `*.scuzz_intent` file fails. It reports unused imports, unused locals, unused parameters, and unused private defs. It reports unclaimed defs, signals, and controls as info (used names that no claim observes). A name that starts with `_` is kept on purpose. `--message-format=json` is the editor protocol (`check` only). `scuzz lsp` wraps that JSON over stdin/stdout. Open buffers overlay disk on didOpen/didChange. didChange reads full-sync `contentChanges`. didClose uses disk and publishes empty diagnostics for that URI. Language-server methods use the same parse. The server advertises only methods it implements. Rename returns a WorkspaceEdit. Diagnostics run the check file list with overlays. One run reports every parse and type diagnostic. Unclaimed reports do not fail `check`. - `scuzz ide [path]` launches the bundled `[ui]` editor with Desktop. `--headless` stays. The CLI finds `SCUZZ_IDE`, `SCUZZ_HOME/ide`, or `examples/editor`. It passes the path through `Sys.args`. There is no `scuzz-ide` binary. - `scuzz fuzz --iterations N` is the verification campaign. It loads `/corpus/*.toml` (sibling of `goldens/`; same shape as `repro.toml`) in sorted-name order and replays those entries before search. It also replays `build/seeds.txt` zero-argument verify oracles (`drive `). A missing or empty `corpus/` is a no-op. Missing seeds is a no-op. `--iterations 0` is corpus-only: replay, then stop. `--minimize-corpus` rewrites stored entries to their shortest forms that keep pass/fail status and declared `sometimes` coverage. No search. No mutation. Use it for a fast inner loop. A failing stored entry is a search failure; the stored file is the repro. A failing seed writes `build/fuzz/repro.toml`. Passing entries seed in-memory keeps. For `N > 0` it splits the rest of the budget into search then mutation. `[ui]` search tries exhaustive event scripts while the next full depth fits, then coverage-guided random. Scripts that hit new `Property.sometimes` names or a new Headless `dump.txt` are kept, replayed once more (dump and timeline must match), and later iters extend those prefixes. Search writes a shrunk failure into `corpus/` under a hash of `schedule_seed` plus events plus a non-zero `fault_seed` (idempotent). Shrink first drops events, then shrinks `drive` args (Int toward the published bound or 0, Bool to `false`, String to `a`, record/enum fields, list length then elements), then shrinks the fault seed, then shrinks PCT `pct_k`. The campaign prints `shrunk events:` with those lines. A simple Int `where` bound publishes as a driver-table token (`noteDrive i>=0`) and clamps draws. Record / enum / list params publish as `Rect(i>=0,i)` / `e:Some(i)|None` / `[i]`. A recursive enum caps nest depth at 3 and publishes a nested `e:N(i)|Add(...)` spec. Search also persists a keep that reaches a `sometimes` name no stored entry reaches (cap: declared-name count). Dump-novelty keeps stay in memory. Review new `corpus/*.toml` files like goldens. After a UI refactor a stored `tap N` can miss (the index is gone). Delete that entry. IO-only keeps schedule seeds that hit new sometimes names and perturbs them. `--replay repro.toml` restores a shrunk event list + optional `schedule_seed` / `pct_d` / `pct_k` + optional fault plan. Oracles are residual `.require` / `Property.check`, panic/`SzError`, dump determinism, leak (heap growth across consecutive idle UI pumps), deadlock (all fibers parked with no timer pending), heap baseline after session teardown, acquire/release pairing, finalizer-on-cancel, leftover parked fibers after quiesce, live/verify differential, and campaign `Property.sometimes` reachability. `Property.classify` counts write to `summary.toml` `[classify]` and do not fail the campaign. Search explores `SCUZZ_FAULT_SEED` like `SCUZZ_SCHED_SEED`. `SCUZZ_SCHED_SEED` is a packed PCT plan. `repro.toml` writes `pct_d` / `pct_k`. Seed `0` is no fault. Iter `0` stays no-fault. After search, mutation compiles live `def` bodies (flip `==`/`/>=/&&/||`, swap `+`/`-` and `*`/`/`, replace `%` with `*`, drop `&&` conjuncts, swap `if` arms, swap `0`↔`1`, replace an ADT construct with a same-arity sibling). Program mode also swaps a tap handler body with a sibling handler and replaces a `Signal.map` transform with the identity. Mutants that do not compile count as killed. A probe that runs longer than 20 s is killed. Residual oracles stay armed. `--oracles` mutates residual `Property.check` / `Property.assert` / `.require` predicates instead. It also negates or drops residual `where` bounds. Each mutant gets an idle TestRuntime probe, then corpus replay. Kill = any probe fails. A surviving mutant that changes a claimed `State` field is a weak claim. A surviving mutant that changes an unclaimed `State` field is a missing claim. Mutants with a bit-identical replayed timeline are inert and unreported. Each survivor prints file:line, enclosing def, mutation label with a source excerpt, the nearest residual oracle (same def, else closest span in the same module, else no observing oracle), and a weak or missing claim. `summary.toml` `[mutate]` records `survivors` (kind and fields) and `inert`. No sites (for example `examples/hello`) exits 0. Do not add external mutators. The command writes `build/fuzz/summary.toml` with coverage (`declared` / `reachability`), `[coverage].unclaimed_varied` (`State` fields that varied and no claim reads), a `[corpus]` table (`entries` / `failures` / `reached` / `promoted`), `[classify]` true/false counts, and mutation (`killed` / `survived` / `inert` / `score` / `survivors`). Unclaimed variation reports to the author. It does not fail the campaign. Missing names split into not reached in this budget vs reached by no stored corpus entry. Default stops at the first search failure. `--no-fail-fast` keeps that `repro.toml`, finishes search, then mutates. `examples/bad-example` must fail: search drives `bump` against a wrong `bump` and prints the shrunk arg (`drive bump 0`). A checked-in `corpus/` entry pins that failure. `examples/bad-fault` must fail: search drives `checkNote` under a fault seed that fails `Fs.write`. `saveNote` swallows that error, so `loadNote` does not match. A checked-in `corpus/` entry pins `drive checkNote a` with `fault_seed = "1"`. Replay without `fault_seed` passes. `examples/bad-adt` must fail: search draws `Rect` values against a wrong `area` (`w + h` vs `w * h`). `Property.classify` records `square` / `wide`. A checked-in `corpus/` entry pins a shrunk `drive area Rect(...)`. `examples/bad-sched` must fail: search drives `checkOrder` under a PCT schedule seed that offers `R` first. A checked-in `corpus/` entry pins `drive checkOrder` with `schedule_seed = "1344"` / `pct_d = 2` / `pct_k = 0`. Replay without `schedule_seed` passes. `scuzz check` and `scuzz test` still pass. A failing search keeps no passing corpus, so idle mutants that change the replayed timeline survive. Mutants with a bit-identical replayed timeline are inert and unreported. `scale` has no oracle. An idle `scale` mutant does not change the timeline, so it is inert. - Deterministic fakes: `TestRuntime` / `SCUZZ_TESTRT=1` for clock/random/FS/network/console in app binaries. `SCUZZ_RAND_SEED` seeds `Random.nextInt` (unset or `0` keeps 42). Under TestRuntime, Clock.realTime and Clock.monotonic both read the virtual ms counter (start 1). Simulation is hermetic (no live sockets; `Sys.exec` / `Sys.spawn` fail; `Sys.getenv` sealed; `Sys.alive` / `Sys.kill` fake). Fault injection: `SCUZZ_FAULT_SEED` (or `SCUZZ_FAULT_KIND` + `SCUZZ_FAULT_N` + `SCUZZ_FAULT_MODE`) fails the Nth `Fs` / `Net` / `Queue` op, or drops/corrupts a Net stub. Seed `0` / unset is no fault. `scuzz fuzz` writes `fault_seed` and the decoded plan into `repro.toml`. PCT schedule: `SCUZZ_SCHED_SEED` arms priority plus change-points (packed `k=s%8`, `d=2+(s/8)%4`, `rng=s/32`). `SCUZZ_PCT_D` / `SCUZZ_PCT_K` override. Unset keeps FIFO. `scuzz fuzz` writes `schedule_seed` / `pct_d` / `pct_k` into `repro.toml`. Implicit oracles fail a run on leak (heap growth across consecutive idle UI pumps), deadlock (all fibers parked with no timer pending), heap baseline after session teardown, acquire/release pairing, finalizer-on-cancel, leftover parked fibers after quiesce, and a silent live/verify split (a `*.scuzz_sim` overlay is a declared delta). @@ -145,7 +145,7 @@ count.scuzz_verify # Timeline => Verdict session claims and Bool drive oracl | `examples/bad-response` | Known-wrong app vs claim. `afterPlusChanged` expects a11y `text:changed` after a `button:+1` hit; the label stays `count = N`. `scuzz fuzz` taps `+1`, fails the claim, and writes `repro.toml`. `corpus/` pins `tap 0` with `schedule_seed`. `--iterations 0` fails from corpus alone. `scuzz check` and `scuzz test` still pass | | `examples/counter` | Small UI: `Signal.map` + `View.bindText` + layout widgets + button lambda + `Ui.run(_ => view)` factory + path dep on `shared` + in-body `.require` / `Property.sometimes` + `count.scuzz_verify` `Timeline => Verdict` | | `examples/shared` | Library package (`{ path = "..." }`) with helpers + optional `*.scuzz_sim` + `.require` | -| `examples/editor` | Bundled IDE package. `scuzz ide` launches it. Open a project root from `Sys.args`, edit in `View.editor`, save with `Fs.write`. Nested file tree (dir tap expands in place, nested rows indent, tree scrolls), basename tab plus dirty mark, wrapping toolbar, find/replace, completion/hover/palette overlays, Context overlay on a tree-file button-3 (`View.onSecondary`; Open / Delete), output list (hidden when empty), and `Ui.setTitle`. Tree and diagnostic rows wrap in `View.focusGroup`. A tap focuses that list. ArrowUp / ArrowDown move among sibling rows when no overlay is open. Enter / Space activate. Check writes the buffer, runs `scuzz check --message-format=json`, parses JSON, lists diagnostics, and jumps the caret. A diagnostic tap opens that file when the row encodes one. Hover / Complete / Format / Def / Rename / Fix host `scuzz lsp` over `Sys.spawn` pipes. Def can open a definition uri. Fix applies the first `newText` from a code action. A completion tap inserts the label at the editor caret (`Ui.editorCaret`). Fuzz overlays `analyze` and `lspCall` with canned JSON. Headless goldens dump `[editor]`. Search-plus-mutate `scuzz fuzz --iterations N` compiles mutants on the compiler stack. `key s+ctrl` / `f+ctrl` fire labeled toolbar buttons. `key p+shift+ctrl` opens Palette. | +| `examples/editor` | Bundled IDE package. `scuzz ide` launches it. Open a project root from `Sys.args`, edit in `View.editor`, save with `Fs.write`. Nested file tree (dir tap expands in place, nested rows indent, tree scrolls), basename tab plus dirty mark, wrapping toolbar, find/replace, completion/hover/palette overlays, Context overlay on a tree-file button-3 (`View.onSecondary`; Open / Delete), output list (hidden when empty), and `Ui.setTitle`. Tree and diagnostic rows wrap in `View.focusGroup`. A tap focuses that list. ArrowUp / ArrowDown move among sibling rows when no overlay is open. Enter / Space activate. Check writes the buffer, runs `scuzz check --message-format=json`, parses JSON, lists diagnostics, and jumps the caret. A diagnostic tap opens that file when the row encodes one. Hover / Complete / Format / Def / Rename / Fix host `scuzz lsp` over `Sys.spawn` pipes. Def can open a definition uri. Rename applies WorkspaceEdit `changes`. Fix applies the first `newText` from a code action. A completion tap inserts the label at the editor caret (`Ui.editorCaret`). Fuzz overlays `analyze` and `lspCall` with canned JSON. Headless goldens dump `[editor]`. Search-plus-mutate `scuzz fuzz --iterations N` compiles mutants on the compiler stack. `key s+ctrl` / `f+ctrl` fire labeled toolbar buttons. `key p+shift+ctrl` opens Palette. | | `examples/studio` | Desktop stay-open app: `showWhen` pages, `Signal.list` + `View.each`, Done/Add/Del/Rename, `View.radio` / `View.slider` / `View.progress` / `View.switch` / `View.chip` / `View.filterChip` / `View.choiceChip` / `View.actionChip` / `View.inputChip` / `View.listTile` / `View.badge` / `View.card` / `View.divider` / `View.expansionTile` / `View.iconButton` / `View.verticalDivider` / `View.circularProgress` / `View.avatar` / `View.checkboxListTile` / `View.switchListTile` / `View.radioListTile` / `View.segmented` / `View.fab` / `View.outlinedButton` / `View.textButton` / `View.tooltip` / `View.placeholder` / `View.semantics` / `View.mergeSemantics` / `View.inkWell` / `View.visibility` / `View.offstage` / `View.unconstrainedBox` / `View.scrollH` / `View.grid`, Fs load/save, `record` / `trait` / stem modules, `*.scuzz_verify` + drivers / `Property.sometimes`. `scuzz run` opens a window (close the window to quit). `--headless` snapshots. | | `examples/kernel` | Language constructs: enums, `record` + `where` + `.copy`, `trait` / `impl`, generics, generic enum/record, type aliases, stem modules, `private def`, `import` / `as` / `*`, unused names, `Float`, match guards, literal match, or-patterns, as-patterns, list patterns (`[]` / `::` / `[a, b]`), named field patterns, bare constructors (`case None`, `Some(1)`), tuple of 2 through 8 slots, tuple and constructor `for` / lambda unpack, `if` in `for`, `if` without `else` (`Unit` / `IO[Unit]`), `IO.fail` as `IO[A]`, `io.map`, case lambdas (`{ case … }`), `A => B` apply (`f(x)`, `f(x, y)`), named Fun values (`inc = (_ + 1): Int => Int`, `addN`), unary-def eta (`Str.fromInt`, `id`), n-ary-def eta (`add`), cons `h :: t`, named call arguments, default arguments, type ascription, typed lambdas, placeholder lambdas (`_ + 1`), structural `==`, numeric separators, scientific floats, triple-quoted strings, self-tail calls (loop lowering), Builder (linear string kit), recursive `Term` drive (`termDiff`) | | `examples/scale` | Compiler-scale package: about 4k lines across stem modules. A live run fills a String-keyed `Map` of 3048 entries (`mapn:3048`, `hit:0:49`). `scuzz fuzz --iterations 8` samples 3 of 97 live-code sites and finishes in about 36 s | diff --git a/docs/vision.md b/docs/vision.md index 3e702ea7..2d8e0e0e 100644 --- a/docs/vision.md +++ b/docs/vision.md @@ -68,7 +68,7 @@ One CLI. One typer. One formatter. One linter. One testing strategy. No second a - **Watch** rebuilds when sources or `scuzz.toml` change. It does not patch running machine code. `[ui]` `run --watch` is hot reload: it recompiles `build/reload.dylib`, stamps, and swaps the View tree without resetting Signals (see [`guide.md`](guide.md)). IO-only `run --watch` kills and reruns the process on source change. - **Static hygiene** is `scuzz check` (the linter). `scuzz fmt` rewrites. No `lint` subcommand. - **Verification** is built into `scuzz` and the language (properties, sim overlays, deterministic TestRuntime, fuzz search, mutation). Not optional crates or Maven/npm test plugins. A search failure fails `scuzz fuzz`. A mutation survivor does not. A drive-script probe does not run `@main`. -- **JSON diagnostics** (`scuzz check --message-format=json`) are the editor protocol. `check` reports the real file stem, line, and column from recorded token offsets. `scuzz lsp` wraps that, overlays open buffers, and serves language-server methods from the same parse. Goto-def, rename, hover, and completion use that parse. They must land on the right Scuzz span. Unclaimed def, signal, and control reports are info. They do not fail `check`. Do not grow a second typer or schema. A typed agent session schema is later ([`gaps.md`](gaps.md)). `--message-format=json` stays `check` until that schema ships. +- **JSON diagnostics** (`scuzz check --message-format=json`) are the editor protocol. `check` reports the real file stem, line, and column from recorded token offsets. `scuzz lsp` wraps that, overlays open buffers, and serves language-server methods from the same parse. Overlay presence is a list entry. An open empty file stays empty. didChange reads full-sync `contentChanges`. Rename returns a WorkspaceEdit for the document URI. The server advertises only methods it implements (full sync, hover, completion, definition, formatting, rename without prepare, semantic tokens full). Diagnostics run the check file list with overlays through `Check.checkFilesOwn`. Goto-def, rename, hover, and completion use that parse. They must land on the right Scuzz span. Unclaimed def, signal, and control reports are info. They do not fail `check`. Do not grow a second typer or schema. A typed agent session schema is later ([`gaps.md`](gaps.md)). `--message-format=json` stays `check` until that schema ships. - **Dogfood IDE.** A Scuzz `[ui]` package is the in-tree IDE. `scuzz ide` on the one CLI launches that package with Desktop. Headless stays a peer (`scuzz ide --headless`). The app talks to `scuzz check` / `scuzz lsp` / `scuzz fmt` / `scuzz run` / `scuzz fuzz`. It does not reimplement them. `scuzz lsp` stays the protocol for external editors. Do not add Desktop-only editor behavior. Do not ship a second `scuzz-ide` binary. - **`scuzz.toml` is data** — package, path deps, `[ui]`. No plugin DSL. No `build.scuzz` hooks. Unknown keys rejected. Do not add `[plugins]`. - **Fingerprint** (incremental): miss → rebuild. A hit still rebuilds when the out-dir has no `.ll`. The fingerprint includes compiler/runtime identity, native sources, target, clang version, Skia backend, and verify mode. Live (`fingerprint`) and verify (`fingerprint.verify`) share `build/` artifacts. A compile writes its mode file and deletes the sibling so a later switch rebuilds. No `scuzz clean` ritual. diff --git a/examples/cli/src/Cli.scuzz b/examples/cli/src/Cli.scuzz index 24372f75..0cf56654 100644 --- a/examples/cli/src/Cli.scuzz +++ b/examples/cli/src/Cli.scuzz @@ -262,12 +262,7 @@ def parseLsp(args: List[String], i: Int, json: Bool, path: String): Cmd = if (i >= List.len(args)) gateJson(Cmd.Lsp(path), json) else parseLspTok(args, i, json, path, arg(args, i)) def parseLspTok(args: List[String], i: Int, json: Bool, path: String, a: String): Cmd = - if (isHelp(a)) gateJson(Cmd.Help("lsp"), json) else if (a == "--message-format" || flagPref(a, "--message-format")) parseLspMsg(takeVal(args, i, "--message-format"), args, path) else if (isFlag(a)) Cmd.Fail(unexp(a, "scuzz lsp [OPTIONS] [PATH]")) else parseLsp(args, i + 1, json, a) - -def parseLspMsg(p: (String, Int), args: List[String], path: String): Cmd = - p match { - case (v, n) => if (n < 0) Cmd.Fail(needVal("--message-format")) else if (v == "json") parseLsp(args, n, true, path) else if (v == "human") parseLsp(args, n, false, path) else Cmd.Fail(badFmt(v)) - } + if (isHelp(a)) gateJson(Cmd.Help("lsp"), json) else if (isFlag(a)) Cmd.Fail(unexp(a, "scuzz lsp [OPTIONS] [PATH]")) else parseLsp(args, i + 1, json, a) def parseFuzz(args: List[String], i: Int, json: Bool, path: String, iterations: Int, seed: Int, replay: String, oracles: Bool, noFailFast: Bool, minimize: Bool, relate: Bool): Cmd = if (i >= List.len(args)) gateJson(Cmd.Fuzz(path, iterations, seed, replay, oracles, noFailFast, minimize, relate), json) else parseFuzzTok(args, i, json, path, iterations, seed, replay, oracles, noFailFast, minimize, relate, arg(args, i)) diff --git a/examples/cli/src/Help.scuzz b/examples/cli/src/Help.scuzz index ae0157ff..daa03d45 100644 --- a/examples/cli/src/Help.scuzz +++ b/examples/cli/src/Help.scuzz @@ -219,8 +219,6 @@ Arguments: [PATH] [default: .] Options: - --message-format - Diagnostic format: human (default) or json (`scuzz check` only) [default: human] [possible values: human, json] -h, --help Print help diff --git a/examples/cli/src/Main.scuzz b/examples/cli/src/Main.scuzz index f73cd235..b55abd10 100644 --- a/examples/cli/src/Main.scuzz +++ b/examples/cli/src/Main.scuzz @@ -254,8 +254,67 @@ def food(): Int = bar(1) """ +def lspHelpSrc(): String = + """def g(x: Int): Int = + x +""" + +def lspMainCall(): String = + """def f(): Int = + Help.g(1) +""" + +def lspMainTy(): String = + "def f(): Int =\n Help.g(\"x\")\n" + +def lspOpenJ(): String = + "{\"params\":{\"textDocument\":{\"text\":\"abc\"}}}" + +def lspChangeJ(): String = + "{\"params\":{\"textDocument\":{\"text\":\"wrong\"},\"contentChanges\":[{\"text\":\"old\"},{\"text\":\"new\"}]}}" + +def overlaySrcOf(files: List[(String, String)]): String = + overlaySrcHd(List.at(files, 0)) + +def overlaySrcHd(h: (String, String)): String = + h match { + case (_, src) => src + } + +def lspSpanOk(): Bool = + Lsp.identAt(lspSrc(), 4) == "foo" && Lsp.hoverAt(lspSrc(), 0, 4) == "def foo(x: Int): Int" && Lsp.qualIdentAt("IO.println(1)", 3) == "IO.println" && Lsp.hoverAt("IO.println(1)", 0, 3) == "IO.println: IO[Unit]" && Lsp.hoverAt(lspSrc(), 1, 0) == "" && Str.contains(Lsp.defJson("file://x", lspSrc(), 0, 4), "\"character\":4") && Str.contains(Lsp.defJson("file://x", lspSrc(), 0, 4), "\"character\":7") && Str.contains(Lsp.defJson("file://x", lspUseSrc(), 4, 2), "\"character\":4") && Lsp.renameIdents(lspRenameSrc(), "foo", "bar") == lspRenameWant() && Str.contains(Lsp.completeJson(lspSrc(), "fo"), "foo") && Str.contains(Lsp.completeJson("", "IO.prin"), "IO.println") && Lsp.completeJson(lspSrc(), "zzz") == "{\"isIncomplete\":false,\"items\":[]}" && Str.contains(Lsp.tokensJson(lspSrc()), "0,0,3,0,0") && Str.contains(Lsp.frame("é"), "Content-Length: 2") && Lsp.offsetOf("""ab +cd""", 0, 99) == 2 + +def lspWireOpen(): Bool = + Lsp.openSrc(Lsp.parseJson(lspOpenJ())) == "abc" && Lsp.changeSrc(Lsp.parseJson(lspChangeJ())) == "new" + +def lspWireOver(): Bool = + Lsp.overlayHas(Lsp.overlayPut([], "/x", ""), "/x") && Lsp.overlayGet(Lsp.overlayPut([], "/x", ""), "/x") == "" && !Lsp.overlayHas([], "/x") + +def lspWireCaps(): Bool = + Str.contains(Lsp.renameJson("file://x", lspRenameSrc(), 0, 4, "bar"), "\"file://x\"") && Str.contains(Lsp.renameJson("file://x", lspRenameSrc(), 0, 4, "bar"), "bar") && !Str.contains(Lsp.caps(), "prepareProvider") && !Str.contains(Lsp.caps(), "documentSymbol") && !Str.contains(Lsp.caps(), "inlayHint") && !Str.contains(Lsp.caps(), "foldingRange") && !Str.contains(Lsp.caps(), "codeAction") && !Str.contains(Lsp.caps(), "declarationProvider") && !Str.contains(Lsp.caps(), "triggerCharacters") && Str.contains(Lsp.caps(), "utf-8") + +def lspWirePath(): Bool = + Lsp.pathOfUri("file:///tmp/a%20b.scuzz") == "/tmp/a b.scuzz" && Str.contains(Lsp.errorMsg("1", 0 - 32601, "MethodNotFound"), "-32601") && Str.contains(Check.jsonStr("a\tb"), "\\t") && overlaySrcOf(Drive.substOverlay(("Main", "disk") :: Manifest.noPairs(), ("/pkg/src/Main.scuzz", "") :: Manifest.noPairs())) == "" && overlaySrcOf(Drive.substOverlay(("Main", "disk") :: Manifest.noPairs(), ("/pkg/src/Main.scuzz", "open") :: Manifest.noPairs())) == "open" + +def lspWireOk(): Bool = + lspWireOpen() && lspWireOver() && lspWireCaps() && lspWirePath() + +def lspHelpBad(): String = + """def g(): Int = + true +""" + +def lspMainOk(): String = + """def f(): Int = + 1 +""" + +def lspDiagOk(): Bool = + Str.contains(Check.check(lspHelpBad()), "Main.scuzz") && !Str.contains(Check.check(lspHelpBad()), "Help.scuzz") && Str.contains(Check.checkFilesOwn(("Help", lspHelpBad()) :: ("Main", lspMainOk()) :: Manifest.noPairs(), "Help" :: "Main" :: []), "Help.scuzz") && Check.checkFilesOwn(("Main", lspMainCall()) :: ("Help", lspHelpSrc()) :: Manifest.noPairs(), "Main" :: "Help" :: []) == "[]" && Str.contains(Check.checkFilesOwn(("Main", lspMainTy()) :: ("Help", lspHelpSrc()) :: Manifest.noPairs(), "Main" :: "Help" :: []), "type error") + def lspOk(): Bool = - Lsp.identAt(lspSrc(), 4) == "foo" && Lsp.hoverAt(lspSrc(), 0, 4) == "def foo(x: Int): Int" && Lsp.qualIdentAt("IO.println(1)", 3) == "IO.println" && Lsp.hoverAt(lspSrc(), 1, 0) == "" && Str.contains(Lsp.defJson("file://x", lspSrc(), 0, 4), "\"character\":4") && Str.contains(Lsp.defJson("file://x", lspSrc(), 0, 4), "\"character\":7") && Str.contains(Lsp.defJson("file://x", lspUseSrc(), 4, 2), "\"character\":4") && Lsp.renameIdents(lspRenameSrc(), "foo", "bar") == lspRenameWant() && Str.contains(Lsp.completeJson(lspSrc(), "fo"), "foo") && Lsp.completeJson(lspSrc(), "zzz") == "{\"isIncomplete\":false,\"items\":[]}" && Str.contains(Lsp.tokensJson(lspSrc()), "0,0,3,0,0") && Str.contains(Lsp.frame("é"), "Content-Length: 2") + lspSpanOk() && lspWireOk() && lspDiagOk() def srcPanic(): String = """def boom(): Int = @@ -266,7 +325,7 @@ def panicOk(): Bool = Str.contains(Drive.compileSrc(srcPanic()).ir, "sz_panic_push_src") && Str.contains(Drive.compileSrc(srcPanic()).ir, "Main.scuzz:") def allOk(): Bool = - panicOk() && lspOk() && cliDiff(Cli.render(Cli.parse(a1("--help"))), Help.helpRoot()) && cliDiff(Cli.render(Cli.parse(a2("fmt", "--help"))), Help.helpFmt()) && cliDiff(Cli.render(Cli.parse(a2("check", "--help"))), Help.helpCheck()) && cliDiff(Cli.render(Cli.parse(a2("build", "--help"))), Help.helpBuild()) && cliDiff(Cli.render(Cli.parse(a2("run", "--help"))), Help.helpRun()) && cliDiff(Cli.render(Cli.parse(a2("fuzz", "--help"))), Help.helpFuzz()) && cliDiff(Cli.render(Cli.parse(a2("new", "--help"))), Help.helpNew()) && cliDiff(Cli.render(Cli.parse(a1("-V"))), Version.line()) && cliDiff(Cli.show(Cli.parse(a1("fmt"))), "fmt path=. check=false") && cliDiff(Cli.show(Cli.parse(a2("fmt", "--check"))), "fmt path=. check=true") && cliDiff(Cli.show(Cli.parse(a3("fmt", "--check", "examples/hello"))), "fmt path=examples/hello check=true") && cliDiff(Cli.show(Cli.parse(a2("check", "examples/kernel"))), "check path=examples/kernel json=false") && cliDiff(Cli.show(Cli.parse(a2("check", "--message-format=json"))), "check path=. json=true") && cliDiff(Cli.show(Cli.parse(a2("--message-format=json", "check"))), "check path=. json=true") && cliDiff(Cli.show(Cli.parse(a3("build", "--full", "examples/hello"))), "build path=examples/hello out=build full=true verify=false") && cliDiff(Cli.show(Cli.parse(a3("run", "--headless", "examples/studio"))), "run path=examples/studio out=build headless=true watch=false script= dump=") && cliDiff(Cli.show(Cli.parse(a3("fuzz", "--iterations", "16"))), "fuzz path=. iterations=16 seed=42 replay= oracles=false noFailFast=false minimize=false relate=false") && cliDiff(Cli.show(Cli.parse(a4("fuzz", "--iterations", "0", "examples/counter"))), "fuzz path=examples/counter iterations=0 seed=42 replay= oracles=false noFailFast=false minimize=false relate=false") && cliDiff(Cli.show(Cli.parse(a3("new", "myapp", "--ui"))), "new name=myapp path=. ui=true") && cliDiff(Cli.show(Cli.parse(a2("package", "--target=ios"))), "package path=. out=build target=ios") && cliDiff(Cli.show(Cli.parse(a2("ide", "--headless"))), "ide path=. out=build headless=true") && cliDiff(Cli.render(Cli.parse(a1("nope"))), wantUnrec()) && cliDiff(Cli.render(Cli.parse(a2("fmt", "--nope"))), wantUnexp()) && cliDiff(Cli.render(Cli.parse(a2("--message-format=json", "fmt"))), wantJsonOnly()) && cliDiff(Cli.render(Cli.parse(a1("new"))), wantMissName()) && cliDiff(Cli.render(Cli.parse(a2("fuzz", "--iterations"))), wantNeedIter()) && cliDiff(Cli.render(Cli.parse(noArgs())), Help.helpRoot()) && cliDiff(Cli.fmtSrc(srcHi()), wantHi()) && cliIdem(a1("fmt")) && cliIdem(a2("fuzz", "--oracles")) && driveOk() && verifyOk() + panicOk() && lspOk() && cliDiff(Cli.render(Cli.parse(a1("--help"))), Help.helpRoot()) && cliDiff(Cli.render(Cli.parse(a2("fmt", "--help"))), Help.helpFmt()) && cliDiff(Cli.render(Cli.parse(a2("check", "--help"))), Help.helpCheck()) && cliDiff(Cli.render(Cli.parse(a2("build", "--help"))), Help.helpBuild()) && cliDiff(Cli.render(Cli.parse(a2("run", "--help"))), Help.helpRun()) && cliDiff(Cli.render(Cli.parse(a2("fuzz", "--help"))), Help.helpFuzz()) && cliDiff(Cli.render(Cli.parse(a2("new", "--help"))), Help.helpNew()) && cliDiff(Cli.render(Cli.parse(a2("lsp", "--help"))), Help.helpLsp()) && cliDiff(Cli.render(Cli.parse(a1("-V"))), Version.line()) && cliDiff(Cli.show(Cli.parse(a1("fmt"))), "fmt path=. check=false") && cliDiff(Cli.show(Cli.parse(a2("fmt", "--check"))), "fmt path=. check=true") && cliDiff(Cli.show(Cli.parse(a3("fmt", "--check", "examples/hello"))), "fmt path=examples/hello check=true") && cliDiff(Cli.show(Cli.parse(a2("check", "examples/kernel"))), "check path=examples/kernel json=false") && cliDiff(Cli.show(Cli.parse(a2("check", "--message-format=json"))), "check path=. json=true") && cliDiff(Cli.show(Cli.parse(a2("--message-format=json", "check"))), "check path=. json=true") && cliDiff(Cli.show(Cli.parse(a3("build", "--full", "examples/hello"))), "build path=examples/hello out=build full=true verify=false") && cliDiff(Cli.show(Cli.parse(a3("run", "--headless", "examples/studio"))), "run path=examples/studio out=build headless=true watch=false script= dump=") && cliDiff(Cli.show(Cli.parse(a3("fuzz", "--iterations", "16"))), "fuzz path=. iterations=16 seed=42 replay= oracles=false noFailFast=false minimize=false relate=false") && cliDiff(Cli.show(Cli.parse(a4("fuzz", "--iterations", "0", "examples/counter"))), "fuzz path=examples/counter iterations=0 seed=42 replay= oracles=false noFailFast=false minimize=false relate=false") && cliDiff(Cli.show(Cli.parse(a3("new", "myapp", "--ui"))), "new name=myapp path=. ui=true") && cliDiff(Cli.show(Cli.parse(a2("package", "--target=ios"))), "package path=. out=build target=ios") && cliDiff(Cli.show(Cli.parse(a2("ide", "--headless"))), "ide path=. out=build headless=true") && cliDiff(Cli.render(Cli.parse(a1("nope"))), wantUnrec()) && cliDiff(Cli.render(Cli.parse(a2("fmt", "--nope"))), wantUnexp()) && cliDiff(Cli.render(Cli.parse(a2("--message-format=json", "fmt"))), wantJsonOnly()) && cliDiff(Cli.render(Cli.parse(a1("new"))), wantMissName()) && cliDiff(Cli.render(Cli.parse(a2("fuzz", "--iterations"))), wantNeedIter()) && cliDiff(Cli.render(Cli.parse(noArgs())), Help.helpRoot()) && cliDiff(Cli.fmtSrc(srcHi()), wantHi()) && cliIdem(a1("fmt")) && cliIdem(a2("fuzz", "--oracles")) && driveOk() && verifyOk() def go(args: List[String]): IO[Unit] = if (List.isEmpty(args)) IO.println(if (allOk()) "cli-ok" else "cli-bad") else Cli.dispatch(Cli.parse(args)) diff --git a/examples/compiler/src/Check.scuzz b/examples/compiler/src/Check.scuzz index 4f84b39a..7b723b8b 100644 --- a/examples/compiler/src/Check.scuzz +++ b/examples/compiler/src/Check.scuzz @@ -176,6 +176,30 @@ def kitParams3(f: String): List[String] = def kitParams4(f: String): List[String] = if (f == "Timeline.signalInt" || f == "Timeline.signalListLen") "Timeline" :: "Int" :: "String" :: noStr() else if (f == "Timeline.signalStrHas") "Timeline" :: "Int" :: "String" :: "String" :: noStr() else if (f == "Property.signalInt" || f == "Property.signalStr" || f == "Property.signalListLen") "String" :: noStr() else if (f == "Property.signalListAt") "String" :: "Int" :: noStr() else if (f == "Verdict.alwaysHas") "Timeline" :: "String" :: noStr() else if (f == "Verdict.afterHit") "Timeline" :: "String" :: "String" :: noStr() else noStr() +def kitNames(): List[String] = + List.concat(kitNamesA(), List.concat(kitNamesB(), List.concat(kitNamesC(), List.concat(kitNamesD(), List.concat(kitNamesE(), List.concat(kitNamesF(), kitNamesG())))))) + +def kitNamesA(): List[String] = + "Str.fromInt" :: "Str.concat" :: "Str.len" :: "Str.byteLen" :: "Str.byteSlice" :: "Str.fromBool" :: "Str.charAt" :: "Str.slice" :: "Str.toInt" :: "Str.repeat" :: "List.len" :: "List.concat" :: "List.reverse" :: "List.head" :: "List.tail" :: "List.isEmpty" :: "List.cons" :: "List.at" :: "List.join" :: "List.take" :: "List.drop" :: "List.takeRight" :: "List.dropRight" :: "List.init" :: "List.last" :: "List.flatten" :: noStr() + +def kitNamesB(): List[String] = + "Builder.empty" :: "Builder.append" :: "Builder.result" :: "Fs.read" :: "Fs.write" :: "Fs.list" :: "Fs.mkdirs" :: "Sys.args" :: "Sys.getenv" :: "Sys.write" :: "Sys.read" :: "Sys.readLine" :: "Sys.spawn" :: "Sys.alive" :: "Sys.kill" :: "Impurity.runKit" :: "IO.pure" :: "IO.println" :: "Clock.monotonic" :: "Clock.realTime" :: "Map.empty" :: "Map.set" :: "Map.keys" :: "Map.values" :: "Map.size" :: "Map.contains" :: "Map.getOrElse" :: "Map.remove" :: "Map.toList" :: noStr() + +def kitNamesC(): List[String] = + "Set.empty" :: "Set.add" :: "Set.toList" :: "Set.size" :: "Set.contains" :: "Set.remove" :: "Set.union" :: "Set.intersect" :: "Set.diff" :: "Json.keys" :: "Json.get" :: "Json.has" :: "Json.parse" :: "Json.getStr" :: "Json.getBool" :: "Json.getInt" :: "Json.intOr" :: "Json.arr" :: "Json.at" :: "Json.isNull" :: "Json.isObj" :: "Json.isArr" :: "Json.pairs" :: "Json.set" :: "Json.remove" :: "Json.append" :: "Json.prepend" :: "Json.setAt" :: "Json.dropAt" :: "Json.merge" :: "Json.Int" :: "Json.Null" :: "Json.asInt" :: "Json.asBool" :: "Json.asStr" :: "Json.asFloat" :: "Json.Float" :: "Json.floatOr" :: "Json.Obj" :: "Json.Str" :: "Json.Bool" :: "Json.Arr" :: noStr() + +def kitNamesD(): List[String] = + "Str.startsWith" :: "Str.endsWith" :: "Str.contains" :: "Str.eq" :: "Str.isEmpty" :: "Str.nonEmpty" :: "Str.isBlank" :: "Str.lines" :: "Str.trim" :: "Str.indexOf" :: "Str.lastIndexOf" :: "Str.take" :: "Str.drop" :: "Str.takeRight" :: "Str.dropRight" :: "Str.stripPrefix" :: "Str.stripSuffix" :: "Str.split" :: "Fs.join" :: "Fs.dirname" :: "Fs.basename" :: noStr() + +def kitNamesE(): List[String] = + "Fs.exists" :: "Fs.delete" :: "Fs.walk" :: "Fs.canonicalize" :: "Fs.rename" :: "Random.nextInt" :: "Sys.childWrite" :: "Sys.childRead" :: "Sys.childClose" :: noStr() + +def kitNamesF(): List[String] = + "Net.httpGet" :: "Net.httpPost" :: "Net.httpPut" :: "Net.httpPatch" :: "Net.httpDelete" :: "Net.httpHead" :: "Net.serve" :: "Net.serveOnce" :: "Net.tcpConnect" :: "Net.tcpListen" :: "Net.tcpAccept" :: "Net.tcpRead" :: "Net.tcpWrite" :: "Net.tcpClose" :: "Net.udpBind" :: "Net.udpSend" :: "Net.udpRecv" :: "Net.udpClose" :: noStr() + +def kitNamesG(): List[String] = + "Timeline.signalInt" :: "Timeline.signalListLen" :: "Timeline.signalStrHas" :: "Property.signalInt" :: "Property.signalStr" :: "Property.signalListLen" :: "Property.signalListAt" :: "Verdict.alwaysHas" :: "Verdict.afterHit" :: noStr() + def kitRet(f: String): String = if (f == "Timeline.signalInt" || f == "Timeline.signalListLen" || f == "Property.signalInt" || f == "Property.signalListLen") "Int" else if (f == "Timeline.signalStrHas") "Bool" else if (f == "Property.signalStr" || f == "Property.signalListAt") "String" else if (f == "Verdict.alwaysHas" || f == "Verdict.afterHit") "Verdict" else if (f == "Str.len" || f == "Str.byteLen" || f == "List.len" || f == "Str.charAt" || f == "Str.toInt" || f == "Str.indexOf" || f == "Str.lastIndexOf" || f == "Map.size" || f == "Set.size" || f == "Json.getInt" || f == "Json.intOr") "Int" else if (f == "List.head") "Option" else if (f == "List.at" || f == "List.last" || f == "Map.getOrElse") "Any" else if (f == "List.isEmpty" || f == "Map.contains" || f == "Set.contains" || f == "Json.has" || f == "Json.getBool" || f == "Json.isNull" || f == "Json.isObj" || f == "Json.isArr" || f == "Str.startsWith" || f == "Str.endsWith" || f == "Str.contains" || f == "Str.eq" || f == "Str.isEmpty" || f == "Str.nonEmpty" || f == "Str.isBlank") "Bool" else if (f == "List.concat" || f == "List.reverse" || f == "List.tail" || f == "List.cons" || f == "List.take" || f == "List.drop" || f == "List.takeRight" || f == "List.dropRight" || f == "List.init" || f == "List.flatten" || f == "Str.lines" || f == "Str.split") "List" else if (f == "Json.floatOr" || f == "Json.Float" || f == "Json.asFloat") "Float" else if (f == "Map.keys" || f == "Map.values" || f == "Map.toList" || f == "Set.toList" || f == "Json.keys" || f == "Json.arr" || f == "Json.at" || f == "Json.pairs" || f == "Json.asInt" || f == "Json.asBool" || f == "Json.asStr" || f == "Json.asFloat") "List" else if (f == "Builder.empty" || f == "Builder.append") "Builder" else if (f == "Fs.read" || f == "Sys.getenv" || f == "Sys.read" || f == "Sys.readLine" || f == "Fs.canonicalize" || f == "Sys.childRead" || isNetHttp(f) || f == "Net.tcpRead") "IO[String]" else if (f == "Fs.write" || f == "Fs.mkdirs" || f == "Fs.delete" || f == "Fs.rename" || f == "Impurity.runKit" || f == "Sys.write" || f == "Sys.kill" || f == "Sys.childWrite" || f == "Sys.childClose" || f == "IO.println" || isNetServe(f) || f == "Net.tcpWrite" || f == "Net.tcpClose" || f == "Net.udpSend" || f == "Net.udpClose") "IO[Unit]" else if (f == "Fs.list" || f == "Fs.walk") "IO[List[(String, Bool)]]" else if (f == "Sys.args") "IO[List[String]]" else if (f == "Sys.spawn" || f == "Sys.alive" || f == "Fs.exists" || f == "Random.nextInt") "IO[Int]" else if (isSysProc(f)) "IO[(Int, String, String)]" else if (f == "Net.udpRecv") "IO[(String, Int, String)]" else if (f == "Net.tcpConnect" || f == "Net.tcpListen" || f == "Net.tcpAccept" || f == "Net.udpBind") "IO[Any]" else if (f == "IO.pure") "IO[Any]" else if (f == "Clock.monotonic" || f == "Clock.realTime") "IO[Int]" else if (f == "Map.empty" || f == "Map.set" || f == "Map.remove") "Map" else if (f == "Set.empty" || f == "Set.add" || f == "Set.remove" || f == "Set.union" || f == "Set.intersect" || f == "Set.diff") "Set" else if (f == "Json.parse" || f == "Json.get" || f == "Json.set" || f == "Json.remove" || f == "Json.append" || f == "Json.prepend" || f == "Json.setAt" || f == "Json.dropAt" || f == "Json.merge" || f == "Json.Int" || f == "Json.Null" || f == "Json.Float" || f == "Json.Obj" || f == "Json.Str" || f == "Json.Bool" || f == "Json.Arr") "Any" else "Any" @@ -1168,6 +1192,12 @@ def locOf2(p: (Int, Int), src: String, off: Int): (Int, Int, Int, Int) = def locOf3(line: Int, col: Int, n: Int): (Int, Int, Int, Int) = (line, col, line, col + (if (n < 1) 1 else n)) +def hexDigit(n: Int): String = + Str.slice("0123456789abcdef", n, n + 1) + +def hex2(n: Int): String = + Str.concat(hexDigit(n / 16), hexDigit(n % 16)) + def jsonStr(s: String): String = Str.concat("\"", Str.concat(_esc(s, 0, Builder.empty()), "\"")) @@ -1175,7 +1205,7 @@ def _esc(s: String, i: Int, b: Builder): String = if (i >= Str.len(s)) Builder.result(b) else _esc2(s, i, b, Str.charAt(s, i)) def _esc2(s: String, i: Int, b: Builder, c: Int): String = - _esc(s, i + 1, Builder.append(b, if (c == 34) "\\\"" else if (c == 92) "\\\\" else if (c == 10) "\\n" else Str.slice(s, i, i + 1))) + _esc(s, i + 1, Builder.append(b, if (c == 34) "\\\"" else if (c == 92) "\\\\" else if (c == 10) "\\n" else if (c == 13) "\\r" else if (c == 9) "\\t" else if (c < 32) Str.concat("\\u00", hex2(c)) else Str.slice(s, i, i + 1))) def fileOf(stem: String): String = if (stem == "") "Main.scuzz" else Str.concat(stem, ".scuzz") diff --git a/examples/compiler/src/Drive.scuzz b/examples/compiler/src/Drive.scuzz index 5c604ae0..092603d7 100644 --- a/examples/compiler/src/Drive.scuzz +++ b/examples/compiler/src/Drive.scuzz @@ -647,6 +647,53 @@ def checkDirJson(diags: String, files: List[(String, String)]): IO[Unit] = def checkDirHuman(human: String): IO[Unit] = if (human == "scuzz check ok") IO.println(human) else IO.println(human).flatMap(_ => IO.fail("scuzz check")) +def overlayStemHit(overlays: List[(String, String)], stem: String): (Bool, String) = + overlayStemHitGo(overlays, Str.concat(stem, ".scuzz")) + +def overlayStemHitGo(overlays: List[(String, String)], name: String): (Bool, String) = + if (List.isEmpty(overlays)) (false, "") else overlayStemHitHd(List.at(overlays, 0), List.tail(overlays), name) + +def overlayStemHitHd(h: (String, String), rest: List[(String, String)], name: String): (Bool, String) = + h match { + case (path, src) => if (overlayPathHit(path, name)) (true, src) else overlayStemHitGo(rest, name) + } + +def overlayPathHit(path: String, name: String): Bool = + path == name || Str.endsWith(path, Str.concat("/", name)) + +def substOverlay(files: List[(String, String)], overlays: List[(String, String)]): List[(String, String)] = + if (List.isEmpty(files)) Manifest.noPairs() else substOverlayHd(List.at(files, 0), List.tail(files), overlays) + +def substOverlayHd(h: (String, String), rest: List[(String, String)], overlays: List[(String, String)]): List[(String, String)] = + h match { + case (stem, src) => substOverlayPick(stem, src, rest, overlays, overlayStemHit(overlays, stem)) + } + +def substOverlayPick(stem: String, src: String, rest: List[(String, String)], overlays: List[(String, String)], hit: (Bool, String)): List[(String, String)] = + hit match { + case (found, over) => (stem, if (found) over else src) :: substOverlay(rest, overlays) + } + +def checkOverlay(dir: String, overlays: List[(String, String)]): IO[String] = + Fs.read(joinSlash(dir, "scuzz.toml")).flatMap(toml => checkOverlayMan(dir, overlays, Manifest.parse(toml))).handleErrorWith(_ => IO.pure("[]")) + +def checkOverlayMan(dir: String, overlays: List[(String, String)], m: Man): IO[String] = + m match { + case Man(ok, _name, _version, _hasUi, deps, _err) => checkOverlayMan2(dir, overlays, ok, deps) + } + +def checkOverlayMan2(dir: String, overlays: List[(String, String)], ok: Bool, deps: List[(String, String)]): IO[String] = + if (!ok) IO.pure("[]") else Fs.list(joinSlash(dir, "src")).flatMap(ents => checkOverlayListed(dir, overlays, deps, ents)) + +def checkOverlayListed(dir: String, overlays: List[(String, String)], deps: List[(String, String)], ents: List[(String, Bool)]): IO[String] = + readSrcFiles(joinSlash(dir, "src"), ents).flatMap(own => checkOverlayOwn(dir, overlays, deps, own)) + +def checkOverlayOwn(dir: String, overlays: List[(String, String)], deps: List[(String, String)], own: List[(String, String)]): IO[String] = + collectDeps(dir, deps, own).flatMap(files => IO.pure(checkOverlayGot(files, fileStems(own), overlays))) + +def checkOverlayGot(files: List[(String, String)], stems: List[String], overlays: List[(String, String)]): String = + Check.checkFilesOwn(sigFiles(substOverlay(files, overlays), stems), stems) + def testDir(dir: String, outDir: String, update: Bool, pixels: Bool, differential: Bool): IO[Unit] = if (differential) testDiff(dir, outDir) else emitDir(dir, outDir, true).flatMap(_ => testLink(dir, outDir, update, pixels)) diff --git a/examples/compiler/src/Lsp.scuzz b/examples/compiler/src/Lsp.scuzz index bce90965..97c25c23 100644 --- a/examples/compiler/src/Lsp.scuzz +++ b/examples/compiler/src/Lsp.scuzz @@ -79,16 +79,49 @@ def newNameOf(j: Json): String = jStr(jGet(paramsOf(j), "newName")) def pathOfUri(uri: String): String = + pctDecode(stripFileUri(uri)) + +def stripFileUri(uri: String): String = if (Str.startsWith(uri, "file://")) Str.drop(uri, 7) else uri +def latin1(): String = + Str.concat(" !\"#$%&'()*+,-./0123456789:;<=>?@", Str.concat("ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`", "abcdefghijklmnopqrstuvwxyz{|}~")) + +def hexVal(c: Int): Int = + if (c >= 48 && c <= 57) c - 48 else if (c >= 97 && c <= 102) c - 87 else if (c >= 65 && c <= 70) c - 55 else 0 - 1 + +def pctByte(n: Int): String = + if (n >= 32 && n <= 126) Str.slice(latin1(), n - 32, n - 31) else "" + +def pctDecode(s: String): String = + pctGo(s, 0, Builder.empty()) + +def pctGo(s: String, i: Int, b: Builder): String = + if (i >= Str.len(s)) Builder.result(b) else if (Str.charAt(s, i) == 37 && i + 2 < Str.len(s)) pctHex(s, i, b, hexVal(Str.charAt(s, i + 1)), hexVal(Str.charAt(s, i + 2))) else pctGo(s, i + 1, Builder.append(b, Str.slice(s, i, i + 1))) + +def pctHex(s: String, i: Int, b: Builder, hi: Int, lo: Int): String = + if (hi < 0 || lo < 0) pctGo(s, i + 1, Builder.append(b, "%")) else pctPut(s, i, b, hi * 16 + lo) + +def pctPut(s: String, i: Int, b: Builder, n: Int): String = + pctPut2(s, i, b, pctByte(n)) + +def pctPut2(s: String, i: Int, b: Builder, ch: String): String = + if (Str.isEmpty(ch)) pctGo(s, i + 3, Builder.append(b, Str.slice(s, i, i + 3))) else pctGo(s, i + 3, Builder.append(b, ch)) + def uriOfPath(path: String): String = Str.concat("file://", path) def offsetOf(src: String, line: Int, col: Int): Int = offsetGo(src, 0, 0, line, col) +def lineEnd(src: String, i: Int): Int = + if (i >= Str.len(src)) i else if (Str.charAt(src, i) == 10) i else lineEnd(src, i + 1) + +def clampCol(col: Int, n: Int): Int = + if (col < 0) 0 else if (col > n) n else col + def offsetGo(src: String, i: Int, ln: Int, want: Int, col: Int): Int = - if (ln == want) i + col else if (i >= Str.len(src)) Str.len(src) else if (Str.charAt(src, i) == 10) offsetGo(src, i + 1, ln + 1, want, col) else offsetGo(src, i + 1, ln, want, col) + if (ln == want) i + clampCol(col, lineEnd(src, i) - i) else if (i >= Str.len(src)) Str.len(src) else if (Str.charAt(src, i) == 10) offsetGo(src, i + 1, ln + 1, want, col) else offsetGo(src, i + 1, ln, want, col) def isIdentCh(c: Int): Bool = c >= 65 && c <= 90 || c >= 97 && c <= 122 || c >= 48 && c <= 57 || c == 95 @@ -160,7 +193,7 @@ def hoverJson(text: String): String = if (text == "") "null" else Str.concat("{\"contents\":{\"kind\":\"plaintext\",\"value\":", Str.concat(Check.jsonStr(text), "}}")) def completeNames(src: String): List[String] = - completeNames2(Parse.parse(Lexer.lex(src)).defs, "IO.println" :: "Str.concat" :: "List.len" :: "Sys.args" :: "Fs.read" :: []) + completeNames2(Parse.parse(Lexer.lex(src)).defs, Check.kitNames()) def completeNames2(ds: List[Fun], acc: List[String]): List[String] = if (List.isEmpty(ds)) acc else completeNamesHd(List.at(ds, 0), List.tail(ds), acc) @@ -192,10 +225,22 @@ def completeItem(name: String): String = Str.concat("{\"label\":", Str.concat(Check.jsonStr(name), "}")) def defJson(uri: String, src: String, line: Int, col: Int): String = - defJsonName(uri, src, identAt(src, offsetOf(src, line, col))) + defJsonName(uri, src, qualIdentAt(src, offsetOf(src, line, col))) def defJsonName(uri: String, src: String, name: String): String = - defJsonOff(uri, src, name, findFunOff(Parse.parse(Lexer.lex(src)).defs, name)) + defJsonOff(uri, src, name, findFunOffNamed(Parse.parse(Lexer.lex(src)).defs, name)) + +def findFunOffNamed(ds: List[Fun], name: String): Int = + findFunOffNamed2(ds, name, findFunOff(ds, name)) + +def findFunOffNamed2(ds: List[Fun], name: String, off: Int): Int = + if (off >= 0) off else findFunOff(ds, identAfterDot(name)) + +def identAfterDot(name: String): String = + identAfterDotAt(name, Str.lastIndexOf(name, ".")) + +def identAfterDotAt(name: String, i: Int): String = + if (i < 0) name else Str.drop(name, i + 1) def defJsonOff(uri: String, src: String, name: String, off: Int): String = if (name == "" || off < 0) "null" else Str.concat("[{\"uri\":", Str.concat(Check.jsonStr(uri), Str.concat(",\"range\":", Str.concat(rangeOf(src, off), "}]")))) @@ -228,11 +273,14 @@ def lineCount(src: String): Int = def lineCountGo(src: String, i: Int, n: Int): Int = if (i >= Str.len(src)) n else if (Str.charAt(src, i) == 10) lineCountGo(src, i + 1, n + 1) else lineCountGo(src, i + 1, n) -def renameJson(src: String, line: Int, col: Int, nu: String): String = - renameJson2(src, identAt(src, offsetOf(src, line, col)), nu) +def renameJson(uri: String, src: String, line: Int, col: Int, nu: String): String = + renameJson2(uri, src, identAt(src, offsetOf(src, line, col)), nu) -def renameJson2(src: String, name: String, nu: String): String = - if (name == "" || nu == "") "null" else Str.concat("{\"changes\":{},\"newText\":", Str.concat(Check.jsonStr(renameIdents(src, name, nu)), "}")) +def renameJson2(uri: String, src: String, name: String, nu: String): String = + if (name == "" || nu == "") "null" else Str.concat("{\"changes\":{", Str.concat(Check.jsonStr(uri), Str.concat(":", Str.concat(wholeEdit(src, renameIdents(src, name, nu)), "}")))) + +def wholeEdit(src: String, text: String): String = + Str.concat("[{\"range\":{\"start\":{\"line\":0,\"character\":0},\"end\":{\"line\":", Str.concat(Str.fromInt(lineCount(src)), Str.concat(",\"character\":0}},\"newText\":", Str.concat(Check.jsonStr(text), "}]")))) def renameIdents(src: String, name: String, nu: String): String = Builder.result(renameToks(Lexer.lex(src), src, name, nu, 0, Builder.empty())) @@ -254,9 +302,6 @@ def renameTokKind(tok: Tok, rest: List[SpTok], src: String, name: String, nu: St def renameTokIdent(s: String, rest: List[SpTok], src: String, name: String, nu: String, prev: Int, off: Int, b: Builder): Builder = if (s == name) renameToks(rest, src, name, nu, off + Str.len(name), Builder.append(Builder.append(b, Str.slice(src, prev, off)), nu)) else renameToks(rest, src, name, nu, prev, b) -def emptyArr(): String = - "[]" - def tokensJson(src: String): String = Str.concat("{\"data\":[", Str.concat(tokensJoin(tokenInts(Lexer.lex(src), src, 0, 0, [])), "]}")) @@ -304,7 +349,10 @@ def tokensJoinHd(n: Int, rest: List[Int], b: Builder): String = if (List.isEmpty(rest)) Builder.result(Builder.append(b, Str.fromInt(n))) else tokensJoinGo(rest, Builder.append(Builder.append(b, Str.fromInt(n)), ",")) def caps(): String = - "{\"capabilities\":{\"textDocumentSync\":{\"openClose\":true,\"change\":1},\"hoverProvider\":true,\"completionProvider\":{\"triggerCharacters\":[\".\"]},\"definitionProvider\":true,\"declarationProvider\":true,\"documentSymbolProvider\":true,\"documentFormattingProvider\":true,\"renameProvider\":{\"prepareProvider\":true},\"foldingRangeProvider\":true,\"inlayHintProvider\":true,\"semanticTokensProvider\":{\"legend\":{\"tokenTypes\":[\"keyword\",\"function\",\"string\",\"number\"],\"tokenModifiers\":[]},\"full\":true,\"range\":true},\"codeActionProvider\":{\"codeActionKinds\":[\"quickfix\",\"source.formatDocument\"],\"resolveProvider\":true}}}" + "{\"capabilities\":{\"positionEncoding\":\"utf-8\",\"textDocumentSync\":{\"openClose\":true,\"change\":1},\"hoverProvider\":true,\"completionProvider\":true,\"definitionProvider\":true,\"documentFormattingProvider\":true,\"renameProvider\":true,\"semanticTokensProvider\":{\"legend\":{\"tokenTypes\":[\"keyword\",\"function\",\"string\",\"number\"],\"tokenModifiers\":[]},\"full\":true}}}" + +def errorMsg(id: String, code: Int, message: String): String = + Str.concat("{\"jsonrpc\":\"2.0\",\"id\":", Str.concat(id, Str.concat(",\"error\":{\"code\":", Str.concat(Str.fromInt(code), Str.concat(",\"message\":", Str.concat(Check.jsonStr(message), "}}")))))) def resultMsg(id: String, result: String): String = Str.concat("{\"jsonrpc\":\"2.0\",\"id\":", Str.concat(id, Str.concat(",\"result\":", Str.concat(result, "}")))) @@ -334,7 +382,13 @@ def publishDiags(uri: String, raw: String): String = publishDiags2(uri, parseJson(raw)) def publishDiags2(uri: String, j: Json): String = - notifyMsg("textDocument/publishDiagnostics", Str.concat("{\"uri\":", Str.concat(Check.jsonStr(uri), Str.concat(",\"diagnostics\":[", Str.concat(diagsArr(jArr(j)), "]}"))))) + notifyMsg("textDocument/publishDiagnostics", Str.concat("{\"uri\":", Str.concat(Check.jsonStr(uri), Str.concat(",\"diagnostics\":[", Str.concat(diagsArr(diagsKeep(jArr(j), Fs.basename(pathOfUri(uri)))), "]}"))))) + +def diagsKeep(xs: List[Json], file: String): List[Json] = + if (List.isEmpty(xs)) [] else diagsKeepHd(List.at(xs, 0), List.tail(xs), file) + +def diagsKeepHd(h: Json, rest: List[Json], file: String): List[Json] = + if (jStr(jGet(h, "file")) == file) h :: diagsKeep(rest, file) else diagsKeep(rest, file) def overlayGet(open: List[Open], path: String): String = if (List.isEmpty(open)) "" else overlayGetHd(List.at(open, 0), List.tail(open), path) @@ -355,11 +409,24 @@ def overlayDropHd(h: Open, rest: List[Open], path: String): List[Open] = case Open(p, _) => if (p == path) overlayDrop(rest, path) else h :: overlayDrop(rest, path) } -def srcOf(open: List[Open], path: String): IO[String] = - srcOf2(path, overlayGet(open, path)) +def overlayHas(open: List[Open], path: String): Bool = + if (List.isEmpty(open)) false else overlayHasHd(List.at(open, 0), List.tail(open), path) + +def overlayHasHd(h: Open, rest: List[Open], path: String): Bool = + h match { + case Open(p, _) => if (p == path) true else overlayHas(rest, path) + } -def srcOf2(path: String, hit: String): IO[String] = - if (Str.nonEmpty(hit)) IO.pure(hit) else Fs.read(path).handleErrorWith(_ => IO.pure("")) +def overlayPairs(open: List[Open]): List[(String, String)] = + if (List.isEmpty(open)) Manifest.noPairs() else overlayPairsHd(List.at(open, 0), List.tail(open)) + +def overlayPairsHd(h: Open, rest: List[Open]): List[(String, String)] = + h match { + case Open(p, src) => (p, src) :: overlayPairs(rest) + } + +def srcOf(open: List[Open], path: String): IO[String] = + if (overlayHas(open, path)) IO.pure(overlayGet(open, path)) else Fs.read(path).handleErrorWith(_ => IO.pure("")) def handle(root: String, open: List[Open], body: String): IO[List[Open]] = handleJ(root, open, parseJson(body), body) @@ -368,24 +435,55 @@ def handleJ(root: String, open: List[Open], j: Json, _body: String): IO[List[Ope handleM(root, open, j, methodOf(j), idJson(j)) def handleM(root: String, open: List[Open], j: Json, method: String, id: String): IO[List[Open]] = - if (method == "initialize") reply(id, caps()).map(_ => open) else if (method == "shutdown") reply(id, "null").map(_ => open) else if (method == "exit") IO.pure(open) else if (method == "initialized") IO.pure(open) else if (method == "textDocument/didOpen" || method == "textDocument/didChange") didOpen(root, open, j) else if (method == "textDocument/didClose") IO.pure(overlayDrop(open, pathOfUri(uriOf(j)))) else if (method == "textDocument/hover") hover(open, j, id) else if (method == "textDocument/completion") complete(open, j, id) else if (method == "textDocument/definition" || method == "textDocument/declaration" || method == "textDocument/typeDefinition" || method == "textDocument/implementation") definition(open, j, id) else if (method == "textDocument/formatting") format(open, j, id) else if (method == "textDocument/rename") rename(open, j, id) else if (method == "textDocument/semanticTokens/full" || method == "textDocument/semanticTokens/range") tokens(open, j, id) else if (method == "textDocument/inlayHint" || method == "textDocument/foldingRange" || method == "textDocument/codeAction" || method == "textDocument/documentSymbol" || method == "textDocument/references" || method == "textDocument/documentHighlight" || method == "textDocument/selectionRange" || method == "textDocument/codeLens" || method == "textDocument/documentLink") reply(id, emptyArr()).map(_ => open) else if (id == "null") IO.pure(open) else reply(id, "null").map(_ => open) + if (method == "initialize") reply(id, caps()).map(_ => open) else if (method == "shutdown") reply(id, "null").map(_ => open) else if (method == "exit") IO.pure(open) else if (method == "initialized") IO.pure(open) else handleM2(root, open, j, method, id) + +def handleM2(root: String, open: List[Open], j: Json, method: String, id: String): IO[List[Open]] = + if (method == "textDocument/didOpen") didOpen(root, open, j) else if (method == "textDocument/didChange") didChange(root, open, j) else if (method == "textDocument/didClose") didClose(open, j) else handleM3(root, open, j, method, id) + +def handleM3(_root: String, open: List[Open], j: Json, method: String, id: String): IO[List[Open]] = + if (method == "textDocument/hover") hover(open, j, id) else if (method == "textDocument/completion") complete(open, j, id) else if (method == "textDocument/definition") definition(open, j, id) else if (method == "textDocument/formatting") format(open, j, id) else if (method == "textDocument/rename") rename(open, j, id) else if (method == "textDocument/semanticTokens/full") tokens(open, j, id) else handleM4(open, id) + +def handleM4(open: List[Open], id: String): IO[List[Open]] = + if (id == "null") IO.pure(open) else replyErr(id, 0 - 32601, "MethodNotFound").map(_ => open) def reply(id: String, result: String): IO[Unit] = Sys.write(frame(resultMsg(id, result))) +def replyErr(id: String, code: Int, message: String): IO[Unit] = + Sys.write(frame(errorMsg(id, code, message))) + def notify(body: String): IO[Unit] = Sys.write(frame(body)) +def openSrc(j: Json): String = + Json.getStr(jGet(paramsOf(j), "textDocument"), "text", "") + +def changeSrc(j: Json): String = + changeSrcArr(jArr(jGet(paramsOf(j), "contentChanges"))) + +def changeSrcArr(xs: List[Json]): String = + if (List.isEmpty(xs)) "" else changeSrcOne(List.at(xs, List.len(xs) - 1)) + +def changeSrcOne(c: Json): String = + Json.getStr(c, "text", "") + def didOpen(root: String, open: List[Open], j: Json): IO[List[Open]] = - didOpen2(root, open, pathOfUri(uriOf(j)), jStr(jGet(jGet(jGet(paramsOf(j), "textDocument"), "text"), "text")) match { - case s => if (Str.nonEmpty(s)) s else jStr(jGet(jGet(paramsOf(j), "textDocument"), "text")) -}) + didOpen2(root, open, pathOfUri(uriOf(j)), openSrc(j)) + +def didChange(root: String, open: List[Open], j: Json): IO[List[Open]] = + didOpen2(root, open, pathOfUri(uriOf(j)), changeSrc(j)) def didOpen2(root: String, open: List[Open], path: String, src: String): IO[List[Open]] = - didOpen3(root, overlayPut(open, path, src), path, src) + didOpen3(root, overlayPut(open, path, src), path) + +def didOpen3(root: String, open: List[Open], path: String): IO[List[Open]] = + Drive.checkOverlay(root, overlayPairs(open)).handleErrorWith(_ => IO.pure("[]")).flatMap(raw => notify(publishDiags(uriOfPath(path), raw)).map(_ => open)) + +def didClose(open: List[Open], j: Json): IO[List[Open]] = + didClose2(open, uriOf(j)) -def didOpen3(_root: String, open: List[Open], path: String, src: String): IO[List[Open]] = - notify(publishDiags(uriOfPath(path), Check.check(src))).map(_ => open) +def didClose2(open: List[Open], uri: String): IO[List[Open]] = + notify(publishDiags(uri, "[]")).map(_ => overlayDrop(open, pathOfUri(uri))) def hover(open: List[Open], j: Json, id: String): IO[List[Open]] = srcOf(open, pathOfUri(uriOf(j))).flatMap(src => reply(id, hoverJson(hoverAt(src, posLine(j), posCol(j)))).flatMap(_ => IO.pure(open))) @@ -409,7 +507,7 @@ def format(open: List[Open], j: Json, id: String): IO[List[Open]] = srcOf(open, pathOfUri(uriOf(j))).flatMap(src => reply(id, formatJson(src)).flatMap(_ => IO.pure(open))) def rename(open: List[Open], j: Json, id: String): IO[List[Open]] = - srcOf(open, pathOfUri(uriOf(j))).flatMap(src => reply(id, renameJson(src, posLine(j), posCol(j), newNameOf(j))).flatMap(_ => IO.pure(open))) + srcOf(open, pathOfUri(uriOf(j))).flatMap(src => reply(id, renameJson(uriOf(j), src, posLine(j), posCol(j), newNameOf(j))).flatMap(_ => IO.pure(open))) def headerBody(acc: String): Int = headerBodyGo(acc, 0, 0) @@ -420,14 +518,11 @@ def headerBodyGo(acc: String, i: Int, nl: Int): Int = def headerBodyNl(acc: String, i: Int, nl: Int): Int = if (nl == 1) i + 1 else headerBodyGo(acc, i + 1, 1) -def readUntilHeader(acc: String): IO[String] = - if (headerBody(acc) >= 0) IO.pure(acc) else Sys.read(256).flatMap(c => if (Str.isEmpty(c)) IO.pure(acc) else readUntilHeader(Str.concat(acc, c))) - -def fillTo(acc: String, need: Int): IO[String] = - if (Str.byteLen(acc) >= need) IO.pure(acc) else Sys.read(256).flatMap(c => if (Str.isEmpty(c)) IO.pure(acc) else fillTo(Str.concat(acc, c), need)) - def parseLen(head: String): Int = - parseLenGo(head, Str.indexOf(head, "Content-Length:") + 15) + parseLenAt(head, Str.indexOf(head, "Content-Length:")) + +def parseLenAt(head: String, i: Int): Int = + if (i < 0) 0 else parseLenGo(head, i + 15) def parseLenGo(head: String, i: Int): Int = if (i < 0 || i >= Str.len(head)) 0 else if (Str.charAt(head, i) == 32 || Str.charAt(head, i) == 9) parseLenGo(head, i + 1) else parseLenDigits(head, i, i) @@ -435,14 +530,29 @@ def parseLenGo(head: String, i: Int): Int = def parseLenDigits(head: String, a: Int, b: Int): Int = if (b >= Str.len(head) || Str.charAt(head, b) < 48 || Str.charAt(head, b) > 57) Str.toInt(Str.slice(head, a, b), 0) else parseLenDigits(head, a, b + 1) +def readUntilHeader(acc: String): IO[String] = + if (headerBody(acc) >= 0) IO.pure(acc) else Sys.read(256).flatMap(c => readUntilGot(acc, c)) + +def readUntilGot(acc: String, c: String): IO[String] = + if (Str.isEmpty(c)) readUntilEof(acc) else readUntilHeader(Str.concat(acc, c)) + +def readUntilEof(acc: String): IO[String] = + if (Str.isEmpty(acc)) IO.pure(acc) else IO.fail("lsp: truncated header") + +def fillTo(acc: String, need: Int): IO[String] = + if (Str.byteLen(acc) >= need) IO.pure(acc) else Sys.read(256).flatMap(c => if (Str.isEmpty(c)) IO.pure(acc) else fillTo(Str.concat(acc, c), need)) + def readMsg(acc: String): IO[(String, String)] = readUntilHeader(acc).flatMap(got => readMsgGot(got, headerBody(got))) def readMsgGot(acc: String, start: Int): IO[(String, String)] = - if (start < 0) IO.pure(("", "")) else readMsg4(acc, parseLen(acc), start) + if (start < 0) IO.pure(("", "")) else readMsgLen(acc, parseLen(acc), start) + +def readMsgLen(acc: String, len: Int, start: Int): IO[(String, String)] = + if (len < 1) IO.fail("lsp: missing Content-Length") else fillTo(acc, start + len).flatMap(full => readMsgFull(full, start, len)) -def readMsg4(acc: String, len: Int, start: Int): IO[(String, String)] = - fillTo(acc, start + len).map(full => readMsg5(full, start, len)) +def readMsgFull(full: String, start: Int, len: Int): IO[(String, String)] = + if (Str.byteLen(full) < start + len) IO.fail("lsp: truncated body") else IO.pure(readMsg5(full, start, len)) def readMsg5(full: String, start: Int, len: Int): (String, String) = (Str.byteSlice(full, start, start + len), Str.byteSlice(full, start + len, Str.byteLen(full))) @@ -459,7 +569,16 @@ def serveGot(root: String, open: List[Open], body: String, rest: String): IO[Uni if (Str.isEmpty(body)) IO.pure(()) else handle(root, open, body).flatMap(nxt => serveStop(root, nxt, parseJson(body), rest)) def serveStop(root: String, open: List[Open], j: Json, rest: String): IO[Unit] = - if (methodOf(j) == "exit") IO.pure(()) else serve(root, open, rest) + if (methodOf(j) == "exit") IO.pure(()) else serve(rootNext(root, j), open, rest) + +def rootNext(root: String, j: Json): String = + if (methodOf(j) == "initialize") rootFromInit(j, root) else root + +def rootFromInit(j: Json, fallback: String): String = + rootFromInit2(pathOfUri(jStr(jGet(paramsOf(j), "rootUri"))), fallback) + +def rootFromInit2(path: String, fallback: String): String = + if (Str.nonEmpty(path)) path else fallback def run(root: String): IO[Unit] = serve(root, [], "") diff --git a/examples/editor/src/Lsp.scuzz b/examples/editor/src/Lsp.scuzz index b6242996..9022e814 100644 --- a/examples/editor/src/Lsp.scuzz +++ b/examples/editor/src/Lsp.scuzz @@ -107,7 +107,7 @@ def formatText(j: Json, fallback: String): String = } def renameText(j: Json, fallback: String): String = - Check.jsonStr(Check.jsonField(Check.jsonField(j, "result"), "newText")) match { + firstNewText(Check.jsonField(Check.jsonField(j, "result"), "changes")) match { case t => if (Str.isEmpty(t)) fallback else t } diff --git a/examples/editor/src/Lsp.scuzz_sim b/examples/editor/src/Lsp.scuzz_sim index b8b007cc..c0de3c45 100644 --- a/examples/editor/src/Lsp.scuzz_sim +++ b/examples/editor/src/Lsp.scuzz_sim @@ -4,7 +4,7 @@ def lspCall(_root: String, _path: String, _body: String, method: String): IO[Jso case "textDocument/completion" => "{\"result\":{\"items\":[{\"label\":\"foo\"}]}}" case "textDocument/definition" => "{\"result\":[{\"uri\":\"\",\"range\":{\"start\":{\"line\":0,\"character\":0}}}]}" case "textDocument/formatting" => "{\"result\":[]}" - case "textDocument/rename" => "{\"result\":{\"newText\":\"\"}}" + case "textDocument/rename" => "{\"result\":{\"changes\":{\"file://x\":[{\"newText\":\"\"}]}}}" case "textDocument/codeAction" => "{\"result\":[{\"title\":\"fix\",\"edit\":{\"changes\":{\"file://x\":[{\"newText\":\"fixed\"}]}}}]}" case "textDocument/semanticTokens/full" => "{\"result\":{\"data\":[0,0,5,8,0]}}" case "textDocument/inlayHint" => "{\"result\":[{\"position\":{\"line\":0,\"character\":5},\"label\":\"Int\"}]}" diff --git a/examples/editor/src/Main.scuzz b/examples/editor/src/Main.scuzz index 28284c4b..7ea30b94 100644 --- a/examples/editor/src/Main.scuzz +++ b/examples/editor/src/Main.scuzz @@ -100,6 +100,7 @@ bc""", 3)._1 == 1, true) _ = Property.check("ms", Lsp.msgSlice("aabbcc", Str.len("aa"), Str.len("bb")) == "bb", true) _ = Property.check("idm", Check.jsonInt(Check.jsonField(Lsp.takeIfMatch(Lsp.parseJson("{\"id\":2}"), Str.len("xy")), "id")) == Str.len("xy"), true) _ = Property.check("fix", Lsp.fixText(Lsp.parseJson("{\"result\":[{\"title\":\"fix\",\"edit\":{\"changes\":{\"u\":[{\"newText\":\"fixed\"}]}}}]}"), "x") == "fixed", true) + _ = Property.check("rename", Lsp.renameText(Lsp.parseJson("{\"result\":{\"changes\":{\"u\":[{\"newText\":\"renamed\"}]}}}"), "x") == "renamed", true) _ = Property.check("duri", Lsp.defUri(Lsp.parseJson("{\"result\":[{\"uri\":\"file://x\",\"range\":{\"start\":{\"line\":0,\"character\":0}}}]}")) == "file://x", true) _ = Property.check("dobj", Lsp.defUri(Lsp.parseJson("{\"result\":{\"uri\":\"u\"}}")) == "u", true) _ <- Ui.setTitle(Fs.basename(file)) From 4990fa956ce714819d3be8734cbbf71c26d15cdd Mon Sep 17 00:00:00 2001 From: Sean Cheatham Date: Fri, 4 Sep 2026 00:37:00 -0400 Subject: [PATCH 04/32] Inspect List heads by RC kind instead of emit's as_int guess so bound Int lists sort as ints, and reject non-String join / non-Int sum at check or panic instead of casting. --- crates/runtime/include/scuzz_rt.h | 8 ++- crates/runtime/src/impurity.c | 5 +- crates/runtime/src/list.c | 77 +++++++++++++++++++++----- crates/runtime/src/runtime.c | 6 ++ crates/runtime/tests/test_io.c | 92 ++++++++++++++++++------------- docs/guide.md | 2 +- examples/codegen/src/Main.scuzz | 2 +- examples/compiler/src/Check.scuzz | 8 +-- examples/compiler/src/Emit.scuzz | 19 ++----- examples/kernel/src/Main.scuzz | 3 + examples/tyck/src/Main.scuzz | 8 ++- examples/tyck/tyck.scuzz_verify | 3 + 12 files changed, 156 insertions(+), 77 deletions(-) diff --git a/crates/runtime/include/scuzz_rt.h b/crates/runtime/include/scuzz_rt.h index 9fb3a1f5..8072a0c9 100644 --- a/crates/runtime/include/scuzz_rt.h +++ b/crates/runtime/include/scuzz_rt.h @@ -60,6 +60,8 @@ enum { void *sz_rc_alloc(size_t size, uint32_t kind); void sz_retain(void *ptr); void sz_release(void *ptr); +/* RC kind of `ptr`. A non-RC pointer is `SZ_RC_KIND_COUNT`. */ +uint32_t sz_rc_kind(const void *ptr); /* Live heap through sz_alloc/sz_free (user bytes; excludes size header). */ void sz_alloc_stats(size_t *live_bytes, size_t *live_count); /* Sum of RC counts on live RC blocks. Raw sz_alloc blocks add 0. */ @@ -760,7 +762,8 @@ int64_t sz_list_segment_length(SzList *xs, SzListPred pred, void *env, int64_t f int64_t sz_list_is_defined_at(SzList *xs, int64_t index); /* Negative when len < n, 0 when equal, positive when len > n. */ int64_t sz_list_length_compare(SzList *xs, int64_t n); -/* `as_int` 1 orders boxed Int, else String. `want_max` 1 is max. Empty max panics. */ +/* Kind comes from the first non-null head (boxed Int or String). + * `as_int` is unused. Empty max panics. */ SzList *sz_list_sort(SzList *xs, int64_t as_int); SzList *sz_list_sort_by(SzList *xs, SzListMapFn fn, void *env); void *sz_list_max(SzList *xs, int64_t as_int); @@ -776,7 +779,8 @@ int64_t sz_list_sum(SzList *xs); int64_t sz_list_product(SzList *xs); /* Release the spine; heads drop through RC. */ void sz_list_free(SzList *xs); -SzString *sz_list_join(const SzList *xs, const char *sep); +/* Join string cells with `sep`. A null cell is empty. Other heads panic. */ +SzString *sz_list_join(const SzList *xs, const SzString *sep); /* Persistent Map / Set (NULL = empty). key_kind 0 = boxed i64, 1 = String. */ struct SzMap { diff --git a/crates/runtime/src/impurity.c b/crates/runtime/src/impurity.c index 5f077d73..5d7e8fcd 100644 --- a/crates/runtime/src/impurity.c +++ b/crates/runtime/src/impurity.c @@ -48,10 +48,13 @@ static SzIo *do_read_line(void *value, void *env) { static SzIo *after_args(void *value, void *env) { SzList *xs = (SzList *)value; + SzString *sep; SzString *joined; SzIo *io; (void)env; - joined = sz_list_join(xs, ","); + sep = sz_string_from_cstr(","); + joined = sz_list_join(xs, sep); + sz_release(sep); sz_release(xs); io = labeled("args:", joined); sz_release(joined); diff --git a/crates/runtime/src/list.c b/crates/runtime/src/list.c index ab4cc791..e89d719b 100644 --- a/crates/runtime/src/list.c +++ b/crates/runtime/src/list.c @@ -27,6 +27,31 @@ static SzList *sz_list_cons_take(void *head, SzList *tail) { return n; } +/* First non-null head must be boxed Int or String. Empty or all-null panics. */ +static uint32_t list_elem_kind(SzList *xs, const char *msg) { + SzList *p; + uint32_t k; + for (p = xs; p; p = p->tail) { + if (!p->head) + continue; + k = sz_rc_kind(p->head); + if (k == SZ_RC_BOX || k == SZ_RC_STRING) + return k; + sz_panic(msg); + } + sz_panic(msg); +} + +static void list_require_kind(const SzList *xs, uint32_t want, const char *msg) { + const SzList *p; + for (p = xs; p; p = p->tail) { + if (!p->head) + continue; + if (sz_rc_kind(p->head) != want) + sz_panic(msg); + } +} + void *sz_list_head(const SzList *xs) { if (!xs) sz_panic("List.head on empty"); @@ -320,6 +345,7 @@ static SzList *flatten_from_rev(SzList *rev) { SzList *out = NULL; SzList *p; SzList *next; + list_require_kind(rev, SZ_RC_LIST, "List.flatten: not List"); for (p = rev; p; p = p->tail) { next = sz_list_concat((SzList *)p->head, out); sz_release(out); @@ -643,6 +669,7 @@ SzPair *sz_list_unzip(SzList *pairs) { SzList *arev; SzList *brev; SzPair *out; + list_require_kind(pairs, SZ_RC_PAIR, "List.unzip: not pair"); for (p = pairs; p; p = p->tail) { inner = (SzPair *)p->head; if (!inner) @@ -878,9 +905,10 @@ SzMap *sz_list_to_map(SzList *pairs) { SzMap *acc = NULL; SzList *p; int32_t kind = 1; + list_require_kind(pairs, SZ_RC_PAIR, "List.toMap: not pair"); if (pairs && pairs->head) { SzPair *first = (SzPair *)pairs->head; - if (first && first->left) + if (first->left) kind = sz_map_infer_key_kind(first->left); } for (p = pairs; p; p = p->tail) { @@ -1239,8 +1267,12 @@ SzList *sz_list_sort(SzList *xs, int64_t as_int) { SzSortSlot *slots; SzList *p; size_t i; + uint32_t kind; + (void)as_int; if (!xs) return NULL; + kind = list_elem_kind(xs, "List.sort: not Int or String"); + list_require_kind(xs, kind, "List.sort: not Int or String"); slots = (SzSortSlot *)sz_alloc((size_t)n * sizeof(SzSortSlot)); i = 0; for (p = xs; p; p = p->tail) { @@ -1249,7 +1281,8 @@ SzList *sz_list_sort(SzList *xs, int64_t as_int) { slots[i].idx = i; i++; } - return sort_slots(slots, (size_t)n, as_int ? cmp_int_slots : cmp_str_slots); + return sort_slots(slots, (size_t)n, + kind == SZ_RC_BOX ? cmp_int_slots : cmp_str_slots); } SzList *sz_list_sort_by(SzList *xs, SzListMapFn fn, void *env) { @@ -1274,8 +1307,8 @@ SzList *sz_list_sort_by(SzList *xs, SzListMapFn fn, void *env) { return sort_slots(slots, (size_t)n, cmp_key_slots); } -static int cell_ord(void *a, void *b, int64_t as_int) { - if (as_int) { +static int cell_ord(void *a, void *b, uint32_t kind) { + if (kind == SZ_RC_BOX) { int64_t ka = sz_unbox_i64(a); int64_t kb = sz_unbox_i64(b); if (ka < kb) @@ -1291,11 +1324,17 @@ static void *list_extreme(SzList *xs, int64_t as_int, int want_max, const char *empty_msg) { SzList *p; void *best; + uint32_t kind; + const char *bad = + want_max ? "List.max: not Int or String" : "List.min: not Int or String"; + (void)as_int; if (!xs) sz_panic(empty_msg); + kind = list_elem_kind(xs, bad); + list_require_kind(xs, kind, bad); best = xs->head; for (p = xs->tail; p; p = p->tail) { - int c = cell_ord(p->head, best, as_int); + int c = cell_ord(p->head, best, kind); if (want_max ? c > 0 : c < 0) best = p->head; } @@ -1339,6 +1378,7 @@ SzMap *sz_list_group_by(SzList *xs, SzListMapFn fn, void *env, int32_t key_kind) int64_t sz_list_sum(SzList *xs) { uint64_t acc = 0; SzList *p; + list_require_kind(xs, SZ_RC_BOX, "List.sum: not Int"); for (p = xs; p; p = p->tail) acc += (uint64_t)sz_unbox_i64(p->head); return (int64_t)acc; @@ -1347,6 +1387,7 @@ int64_t sz_list_sum(SzList *xs) { int64_t sz_list_product(SzList *xs) { uint64_t acc = 1; SzList *p; + list_require_kind(xs, SZ_RC_BOX, "List.product: not Int"); for (p = xs; p; p = p->tail) acc *= (uint64_t)sz_unbox_i64(p->head); return (int64_t)acc; @@ -1398,12 +1439,22 @@ int sz_list_non_empty(const SzList *xs) { return xs != NULL; } void sz_list_free(SzList *xs) { sz_release(xs); } -SzString *sz_list_join(const SzList *xs, const char *sep) { - if (!sep) - sep = ""; - size_t sep_len = strlen(sep); +SzString *sz_list_join(const SzList *xs, const SzString *sep) { + const char *sep_data = ""; + size_t sep_len = 0; size_t total = 0; size_t count = 0; + char *buf; + size_t off = 0; + size_t i = 0; + SzString *out; + list_require_kind(xs, SZ_RC_STRING, "List.join: not String"); + if (sep) { + if (sz_rc_kind(sep) != SZ_RC_STRING) + sz_panic("List.join: not String"); + sep_data = sep->data ? sep->data : ""; + sep_len = sep->len; + } for (const SzList *p = xs; p; p = p->tail) { SzString *s = (SzString *)p->head; if (s) { @@ -1420,12 +1471,10 @@ SzString *sz_list_join(const SzList *xs, const char *sep) { } if (total == SIZE_MAX) sz_panic("List.join too large"); - char *buf = (char *)sz_alloc(total + 1); - size_t off = 0; - size_t i = 0; + buf = (char *)sz_alloc(total + 1); for (const SzList *p = xs; p; p = p->tail) { if (i > 0 && sep_len) { - memcpy(buf + off, sep, sep_len); + memcpy(buf + off, sep_data, sep_len); off += sep_len; } SzString *s = (SzString *)p->head; @@ -1436,7 +1485,7 @@ SzString *sz_list_join(const SzList *xs, const char *sep) { i++; } buf[off] = '\0'; - SzString *out = sz_string_from_bytes(buf, off); + out = sz_string_from_bytes(buf, off); sz_free(buf); return out; } diff --git a/crates/runtime/src/runtime.c b/crates/runtime/src/runtime.c index 0fe9819f..5004f991 100644 --- a/crates/runtime/src/runtime.c +++ b/crates/runtime/src/runtime.c @@ -459,6 +459,12 @@ void sz_retain(void *ptr) { sz_rc_hdr(ptr)->rc += 1; } +uint32_t sz_rc_kind(const void *ptr) { + if (!sz_is_rc(ptr)) + return SZ_RC_KIND_COUNT; + return sz_rc_hdr(ptr)->kind; +} + void sz_release(void *ptr) { SzRcHdr *h; uint32_t kind; diff --git a/crates/runtime/tests/test_io.c b/crates/runtime/tests/test_io.c index 27bb2fab..f85bd237 100644 --- a/crates/runtime/tests/test_io.c +++ b/crates/runtime/tests/test_io.c @@ -19,6 +19,13 @@ static SzIo *pure_drop(void *value); +static SzString *test_list_join(SzList *xs, const char *sep) { + SzString *s = sz_string_from_cstr(sep ? sep : ""); + SzString *out = sz_list_join(xs, s); + sz_release(s); + return out; +} + static void sleep_us(long us) { struct timespec ts; if (us <= 0) @@ -3980,7 +3987,7 @@ int main(void) { sz_stream_eval(pure_drop(sz_string_from_cstr("c")))); r = sz_io_unsafe_run(sz_stream_compile_to_list(s)); assert(r.ok); - SzString * joined = sz_list_join((SzList *)r.value, ","); + SzString * joined = test_list_join((SzList *)r.value, ","); assert(strcmp(sz_string_cstr(joined), "a!,b!,c") == 0); r = sz_io_unsafe_run(sz_stream_drain(sz_stream_emit(sz_string_from_cstr("d")))); @@ -3993,7 +4000,7 @@ int main(void) { r = sz_io_unsafe_run( sz_stream_compile_to_list(sz_stream_take(sz_stream_emits(xs), 2))); assert(r.ok); - joined = sz_list_join((SzList *)r.value, ","); + joined = test_list_join((SzList *)r.value, ","); assert(strcmp(sz_string_cstr(joined), "a,b") == 0); delay_calls = 0; @@ -4003,7 +4010,7 @@ int main(void) { 1); r = sz_io_unsafe_run(sz_stream_compile_to_list(s)); assert(r.ok); - joined = sz_list_join((SzList *)r.value, ","); + joined = test_list_join((SzList *)r.value, ","); assert(strcmp(sz_string_cstr(joined), "a") == 0); assert(delay_calls == 1); @@ -4014,7 +4021,7 @@ int main(void) { r = sz_io_unsafe_run( sz_stream_compile_to_list(sz_stream_drop(sz_stream_emits(xs), 1))); assert(r.ok); - joined = sz_list_join((SzList *)r.value, ","); + joined = test_list_join((SzList *)r.value, ","); assert(strcmp(sz_string_cstr(joined), "b,c") == 0); delay_calls = 0; @@ -4024,7 +4031,7 @@ int main(void) { 1); r = sz_io_unsafe_run(sz_stream_compile_to_list(s)); assert(r.ok); - joined = sz_list_join((SzList *)r.value, ","); + joined = test_list_join((SzList *)r.value, ","); assert(strcmp(sz_string_cstr(joined), "b") == 0); assert(delay_calls == 2); @@ -4035,7 +4042,7 @@ int main(void) { r = sz_io_unsafe_run(sz_stream_compile_to_list( sz_stream_filter(sz_stream_emits(xs), stream_nonempty, NULL))); assert(r.ok); - joined = sz_list_join((SzList *)r.value, ","); + joined = test_list_join((SzList *)r.value, ","); assert(strcmp(sz_string_cstr(joined), "a,b") == 0); delay_calls = 0; @@ -4047,7 +4054,7 @@ int main(void) { stream_nonempty, NULL); r = sz_io_unsafe_run(sz_stream_compile_to_list(s)); assert(r.ok); - joined = sz_list_join((SzList *)r.value, ","); + joined = test_list_join((SzList *)r.value, ","); assert(strcmp(sz_string_cstr(joined), "a,b") == 0); assert(delay_calls == 3); @@ -4063,7 +4070,7 @@ int main(void) { 1); r = sz_io_unsafe_run(sz_stream_compile_to_list(s)); assert(r.ok); - joined = sz_list_join((SzList *)r.value, ","); + joined = test_list_join((SzList *)r.value, ","); assert(strcmp(sz_string_cstr(joined), "a") == 0); assert(delay_calls == 1); @@ -4073,7 +4080,7 @@ int main(void) { r = sz_io_unsafe_run(sz_stream_compile_to_list( sz_stream_map(sz_stream_emits(xs), stream_bang_sync, NULL))); assert(r.ok); - joined = sz_list_join((SzList *)r.value, ","); + joined = test_list_join((SzList *)r.value, ","); assert(strcmp(sz_string_cstr(joined), "a!,b!") == 0); delay_calls = 0; @@ -4085,7 +4092,7 @@ int main(void) { 1); r = sz_io_unsafe_run(sz_stream_compile_to_list(s)); assert(r.ok); - joined = sz_list_join((SzList *)r.value, ","); + joined = test_list_join((SzList *)r.value, ","); assert(strcmp(sz_string_cstr(joined), "a!") == 0); assert(delay_calls == 1); @@ -4098,7 +4105,7 @@ int main(void) { r = sz_io_unsafe_run(sz_stream_compile_to_list( sz_stream_takewhile(sz_stream_emits(xs), stream_nonempty, NULL))); assert(r.ok); - joined = sz_list_join((SzList *)r.value, ","); + joined = test_list_join((SzList *)r.value, ","); assert(strcmp(sz_string_cstr(joined), "a,b") == 0); delay_calls = 0; @@ -4110,7 +4117,7 @@ int main(void) { stream_nonempty, NULL); r = sz_io_unsafe_run(sz_stream_compile_to_list(s)); assert(r.ok); - joined = sz_list_join((SzList *)r.value, ","); + joined = test_list_join((SzList *)r.value, ","); assert(strcmp(sz_string_cstr(joined), "a") == 0); assert(delay_calls == 2); @@ -4123,7 +4130,7 @@ int main(void) { r = sz_io_unsafe_run(sz_stream_compile_to_list( sz_stream_dropwhile(sz_stream_emits(xs), stream_empty, NULL))); assert(r.ok); - joined = sz_list_join((SzList *)r.value, ","); + joined = test_list_join((SzList *)r.value, ","); assert(strcmp(sz_string_cstr(joined), "a,b") == 0); delay_calls = 0; @@ -4135,7 +4142,7 @@ int main(void) { stream_empty, NULL); r = sz_io_unsafe_run(sz_stream_compile_to_list(s)); assert(r.ok); - joined = sz_list_join((SzList *)r.value, ","); + joined = test_list_join((SzList *)r.value, ","); assert(strcmp(sz_string_cstr(joined), "a") == 0); assert(delay_calls == 3); @@ -4151,7 +4158,7 @@ int main(void) { 1); r = sz_io_unsafe_run(sz_stream_compile_to_list(s)); assert(r.ok); - joined = sz_list_join((SzList *)r.value, ","); + joined = test_list_join((SzList *)r.value, ","); assert(strcmp(sz_string_cstr(joined), "a") == 0); assert(delay_calls == 2); @@ -4162,7 +4169,7 @@ int main(void) { r = sz_io_unsafe_run(sz_stream_compile_to_list( sz_stream_find(sz_stream_emits(xs), stream_nonempty, NULL))); assert(r.ok); - joined = sz_list_join((SzList *)r.value, ","); + joined = test_list_join((SzList *)r.value, ","); assert(strcmp(sz_string_cstr(joined), "a") == 0); xs = sz_list_cons(sz_string_from_cstr(""), @@ -4190,7 +4197,7 @@ int main(void) { stream_nonempty, NULL); r = sz_io_unsafe_run(sz_stream_compile_to_list(s)); assert(r.ok); - joined = sz_list_join((SzList *)r.value, ","); + joined = test_list_join((SzList *)r.value, ","); assert(strcmp(sz_string_cstr(joined), "a") == 0); assert(delay_calls == 2); @@ -4233,7 +4240,7 @@ int main(void) { r = sz_io_unsafe_run(sz_stream_compile_to_list( sz_stream_intersperse(sz_stream_emits(xs), sz_string_from_cstr("|")))); assert(r.ok); - joined = sz_list_join((SzList *)r.value, ","); + joined = test_list_join((SzList *)r.value, ","); assert(strcmp(sz_string_cstr(joined), "a,|,b") == 0); xs = sz_list_cons( @@ -4244,7 +4251,7 @@ int main(void) { sz_stream_grouped(sz_stream_emits(xs), 2))); assert(r.ok); assert(sz_list_len((SzList *)r.value) == 2); - joined = sz_list_join((SzList *)sz_list_head((SzList *)r.value), ""); + joined = test_list_join((SzList *)sz_list_head((SzList *)r.value), ""); assert(strcmp(sz_string_cstr(joined), "ab") == 0); r = sz_io_unsafe_run(sz_stream_compile_to_list(sz_stream_range(3, 6))); @@ -4256,7 +4263,7 @@ int main(void) { r = sz_io_unsafe_run(sz_stream_compile_to_list( sz_stream_repeat_n(sz_stream_emits(xs), 3))); assert(r.ok); - joined = sz_list_join((SzList *)r.value, ","); + joined = test_list_join((SzList *)r.value, ","); assert(strcmp(sz_string_cstr(joined), "x,x,x") == 0); { @@ -4281,7 +4288,7 @@ int main(void) { r = sz_io_unsafe_run(sz_stream_compile_to_list( sz_stream_interleave(sz_stream_emits(as), sz_stream_emits(bs)))); assert(r.ok); - joined = sz_list_join((SzList *)r.value, ","); + joined = test_list_join((SzList *)r.value, ","); assert(strcmp(sz_string_cstr(joined), "a,b,c") == 0); } @@ -4296,7 +4303,7 @@ int main(void) { r = sz_io_unsafe_run(sz_stream_compile_to_list( sz_stream_interleave(sz_stream_emits(as), sz_stream_emits(bs)))); assert(r.ok); - joined = sz_list_join((SzList *)r.value, ","); + joined = test_list_join((SzList *)r.value, ","); assert(strcmp(sz_string_cstr(joined), "a,b,c,d,e") == 0); } @@ -4306,7 +4313,7 @@ int main(void) { r = sz_io_unsafe_run(sz_stream_compile_to_list( sz_stream_flatmap(sz_stream_emits(xs), stream_dup, NULL))); assert(r.ok); - joined = sz_list_join((SzList *)r.value, ","); + joined = test_list_join((SzList *)r.value, ","); assert(strcmp(sz_string_cstr(joined), "a,a,b,b") == 0); { @@ -4318,7 +4325,7 @@ int main(void) { r = sz_io_unsafe_run(sz_stream_compile_to_list( sz_stream_flatten(sz_stream_emits(nested)))); assert(r.ok); - joined = sz_list_join((SzList *)r.value, ","); + joined = test_list_join((SzList *)r.value, ","); assert(strcmp(sz_string_cstr(joined), "a,b,c") == 0); } @@ -4329,7 +4336,7 @@ int main(void) { r = sz_io_unsafe_run(sz_stream_compile_to_list( sz_stream_changes(sz_stream_emits(xs)))); assert(r.ok); - joined = sz_list_join((SzList *)r.value, ","); + joined = test_list_join((SzList *)r.value, ","); assert(strcmp(sz_string_cstr(joined), "a,b") == 0); xs = sz_list_cons( @@ -4339,7 +4346,7 @@ int main(void) { sz_stream_emits(xs), sz_string_from_cstr(""), stream_scan_concat, NULL))); assert(r.ok); - joined = sz_list_join((SzList *)r.value, ","); + joined = test_list_join((SzList *)r.value, ","); assert(strcmp(sz_string_cstr(joined), ",a,ab") == 0); xs = sz_list_cons( @@ -4391,13 +4398,13 @@ int main(void) { r = sz_io_unsafe_run(sz_stream_compile_to_list(sz_stream_interleave( sz_stream_nil(), sz_stream_emit(sz_string_from_cstr("a"))))); assert(r.ok); - joined = sz_list_join((SzList *)r.value, ","); + joined = test_list_join((SzList *)r.value, ","); assert(strcmp(sz_string_cstr(joined), "a") == 0); r = sz_io_unsafe_run(sz_stream_compile_to_list(sz_stream_interleave( sz_stream_emit(sz_string_from_cstr("a")), sz_stream_nil()))); assert(r.ok); - joined = sz_list_join((SzList *)r.value, ","); + joined = test_list_join((SzList *)r.value, ","); assert(strcmp(sz_string_cstr(joined), "a") == 0); r = sz_io_unsafe_run(sz_stream_compile_to_list( @@ -4411,7 +4418,7 @@ int main(void) { sz_list_nil())))), 3))); assert(r.ok); - joined = sz_list_join((SzList *)r.value, ","); + joined = test_list_join((SzList *)r.value, ","); assert(strcmp(sz_string_cstr(joined), "a,b,c") == 0); r = sz_io_unsafe_run(sz_stream_fold(sz_stream_nil(), sz_string_from_cstr("z"), @@ -4439,7 +4446,7 @@ int main(void) { r = sz_io_unsafe_run(sz_stream_compile_to_list( sz_stream_filter_not(sz_stream_emits(xs), stream_empty, NULL))); assert(r.ok); - joined = sz_list_join((SzList *)r.value, ","); + joined = test_list_join((SzList *)r.value, ","); assert(strcmp(sz_string_cstr(joined), "a,b") == 0); xs = sz_list_cons(sz_string_from_cstr("a"), @@ -4447,7 +4454,7 @@ int main(void) { r = sz_io_unsafe_run(sz_stream_compile_to_list( sz_stream_map_concat(sz_stream_emits(xs), stream_list_dup, NULL))); assert(r.ok); - joined = sz_list_join((SzList *)r.value, ","); + joined = test_list_join((SzList *)r.value, ","); assert(strcmp(sz_string_cstr(joined), "a,a,b,b") == 0); { @@ -4460,7 +4467,7 @@ int main(void) { r = sz_io_unsafe_run(sz_stream_compile_to_list(sz_stream_zip_with( sz_stream_emits(as), sz_stream_emits(bs), stream_zip_concat, NULL))); assert(r.ok); - joined = sz_list_join((SzList *)r.value, ","); + joined = test_list_join((SzList *)r.value, ","); assert(strcmp(sz_string_cstr(joined), "a1,b2") == 0); } @@ -4480,14 +4487,14 @@ int main(void) { r = sz_io_unsafe_run(sz_stream_compile_to_list(sz_stream_or_else( sz_stream_nil(), sz_stream_emit(sz_string_from_cstr("z"))))); assert(r.ok); - joined = sz_list_join((SzList *)r.value, ","); + joined = test_list_join((SzList *)r.value, ","); assert(strcmp(sz_string_cstr(joined), "z") == 0); r = sz_io_unsafe_run(sz_stream_compile_to_list(sz_stream_or_else( sz_stream_emit(sz_string_from_cstr("a")), sz_stream_emit(sz_string_from_cstr("z"))))); assert(r.ok); - joined = sz_list_join((SzList *)r.value, ","); + joined = test_list_join((SzList *)r.value, ","); assert(strcmp(sz_string_cstr(joined), "a") == 0); xs = sz_list_cons( @@ -4506,7 +4513,7 @@ int main(void) { r = sz_io_unsafe_run(sz_stream_compile_to_list( sz_stream_take_right(sz_stream_emits(xs), 2))); assert(r.ok); - joined = sz_list_join((SzList *)r.value, ","); + joined = test_list_join((SzList *)r.value, ","); assert(strcmp(sz_string_cstr(joined), "b,c") == 0); xs = sz_list_cons( @@ -4516,7 +4523,7 @@ int main(void) { r = sz_io_unsafe_run(sz_stream_compile_to_list( sz_stream_drop_right(sz_stream_emits(xs), 1))); assert(r.ok); - joined = sz_list_join((SzList *)r.value, ","); + joined = test_list_join((SzList *)r.value, ","); assert(strcmp(sz_string_cstr(joined), "a,b") == 0); xs = sz_list_cons( @@ -4526,7 +4533,7 @@ int main(void) { r = sz_io_unsafe_run(sz_stream_compile_to_list( sz_stream_find_last(sz_stream_emits(xs), stream_nonempty, NULL))); assert(r.ok); - joined = sz_list_join((SzList *)r.value, ","); + joined = test_list_join((SzList *)r.value, ","); assert(strcmp(sz_string_cstr(joined), "b") == 0); tap_n = 0; @@ -4535,7 +4542,7 @@ int main(void) { r = sz_io_unsafe_run(sz_stream_compile_to_list( sz_stream_evaltap(sz_stream_emits(xs), stream_tap_count, NULL))); assert(r.ok); - joined = sz_list_join((SzList *)r.value, ","); + joined = test_list_join((SzList *)r.value, ","); assert(strcmp(sz_string_cstr(joined), "a,b") == 0); assert(tap_n == 2); @@ -5137,7 +5144,7 @@ int main(void) { assert(sz_list_len(xs) == 2); assert(strcmp(sz_string_cstr((SzString *)sz_list_head(xs)), "a") == 0); assert(strcmp(sz_string_cstr((SzString *)sz_list_at(xs, 1)), "b") == 0); - SzString *j = sz_list_join(xs, ","); + SzString *j = test_list_join(xs, ","); assert(strcmp(sz_string_cstr(j), "a,b") == 0); } @@ -9159,8 +9166,15 @@ int main(void) { assert(sz_unbox_i64(sz_list_at(out, 1)) == 2); assert(sz_unbox_i64(sz_list_at(out, 2)) == 3); sz_list_free(out); + out = sz_list_sort(ns, 0); + assert(sz_unbox_i64(sz_list_head(out)) == 1); + assert(sz_unbox_i64(sz_list_at(out, 1)) == 2); + assert(sz_unbox_i64(sz_list_at(out, 2)) == 3); + sz_list_free(out); assert(sz_unbox_i64(sz_list_max(ns, 1)) == 3); assert(sz_unbox_i64(sz_list_min(ns, 1)) == 1); + assert(sz_unbox_i64(sz_list_max(ns, 0)) == 3); + assert(sz_unbox_i64(sz_list_min(ns, 0)) == 1); sz_list_free(ns); sz_release(n1); sz_release(n2); diff --git a/docs/guide.md b/docs/guide.md index c6a88852..16128a8f 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -72,7 +72,7 @@ Build a pure `View` tree. Hold state in `Signal`. Run a session with `Ui.run`: } yield () ``` -Lists: keep a `Signal.list`, render with `View.each(items)` (framework rebuilds `- item` texts at layout). `View.each(items, s => view)` builds one child per element. The element type comes from the signal (`Signal.list(xs)` over `List[T]` binds `T`; record fields like `item.label` work in the body). String lists dump `["a", "b"]`; other element types dump the count as `list[N] = `. `List.filter(xs, pred)` keeps elements for which `pred` is true. `List.map(xs, f)` builds a new list. The filter/map/flatMap/find/findLast/exists/count/takeWhile/dropWhile/forall/filterNot/indexWhere/lastIndexWhere/span/partition/prefixLength/segmentLength/sortBy/maxBy/minBy/groupBy/distinctBy/`IO.foreach`/`IO.foreachDiscard` lambda binds the element type. `Ref.update` / `Ref.updateAndGet` bind the cell. `Map.filter` / `Map.exists` / `Map.forall` / `Map.mapValues` bind the value. `Set.filter` / `Set.exists` / `Set.forall` / `Set.map` bind the key. `List.tabulate(n, f)` binds `Int`. `List.setAt(xs, i, v)` replaces the element at `i`. An index outside the list leaves the list. `List.take(xs, n)` keeps the first `n` elements (`n` <= 0 is empty). `List.drop(xs, n)` skips `n` (`n` <= 0 leaves the list). `List.find(xs, pred)` is a list of the first match, or empty. `List.exists(xs, pred)` is true when any element matches. `List.takeWhile(xs, pred)` keeps a prefix while `pred` is true. `List.dropWhile(xs, pred)` skips that prefix. `List.forall(xs, pred)` is true when every element matches (empty is true). `List.filterNot(xs, pred)` keeps elements for which `pred` is false. `List.count(xs, pred)` is the number of matches. `List.flatMap(xs, f)` concatenates the lists that `f` returns. `List.padTo(xs, n, x)` appends `x` until length `n`. `n` <= len leaves the list. `List.nonEmpty(xs)` is true when the list has a cell. `List.empty()` is Nil. `List.len(xs)` is the cell count. `List.head(xs)` is `Option[T]`. Empty is `None`. `List.at(xs, i)` is the element at `i`. An index outside panics. `List.tail(xs)` drops the first cell. Empty panics. `List.join(xs, sep)` joins string cells. `List.concat(xs, ys)` copies the `xs` spine and shares `ys`. `List.flatten(xss)` concatenates inner lists. `List.takeRight(xs, n)` keeps the last `n` elements (`n` <= 0 is empty). `List.dropRight(xs, n)` drops the last `n` (`n` <= 0 leaves the list). `List.init(xs)` drops the last cell (empty stays empty). `List.last(xs)` is a list of the last element, or empty. `List.getOrElse(xs, i, default)` is the element at `i`, or `default` when `i` is out of range. `List.fill(n, x)` is `n` copies of `x` (`n` <= 0 is empty). `List.range(from, until)` is boxed ints `[from, until)` (empty when `until` <= `from`). `List.tabulate(n, f)` is `f(0)` … `f(n-1)` (`n` <= 0 is empty). `List.intersperse(xs, x)` inserts `x` between cells. Empty or one cell shares. `List.grouped(xs, n)` is chunks of length `n`. The last chunk may be short. `n` <= 0 is empty. `List.sliding(xs, n)` is overlapping windows of length `n`. `n` <= 0 or `n` > len is empty. `List.slice(xs, from, until)` is `[from, until)`. Negative `from` / `until` is 0. `until` <= `from` is empty. `List.indexWhere(xs, pred)` is the first matching index, or `-1`. `List.lastIndexWhere(xs, pred)` is the last matching index, or `-1`. `List.indices(xs)` is boxed ints `[0, len)`. Empty when `xs` is empty. `List.splitAt(xs, n)` is two lists: take then drop, packed as `List[List[T]]`. `List.span(xs, pred)` is takeWhile then dropWhile. `List.partition(xs, pred)` is filter then filterNot. `List.inits(xs)` is prefixes including empty and the full list. `List.tails(xs)` is suffixes including the full list and empty. `List.zip(xs, ys)` is `List[(A, B)]`. A and B may differ. It stops at the shorter list. `List.interleave(xs, ys)` alternates cells from `xs` and `ys`, then appends the leftover. Empty or one list shares. `List.zipAll(xs, ys, x, y)` pads the shorter list with `x` or `y`. `List.unzip(pairs)` is `(List[A], List[B])`. Empty unzip is two empty lists. `List.zipWithIndex(xs)` is `List[(Int, T)]`. `List.foldLeft(xs, z, f)` folds with `f(acc, x)` and returns `z` when `xs` is empty. `List.foldRight(xs, z, f)` folds with `f(x, acc)` from the right. `List.scanLeft(xs, z, f)` is the list of accumulators including `z`. Empty is `[z]`. `List.scanRight(xs, z, f)` scans from the right. `List.reduceLeft(xs, f)` folds from the first cell. Empty panics. `List.reduceRight(xs, f)` folds from the last cell. Empty panics. `List.transpose(xss)` turns rows into columns. It stops at the shortest row. Empty `xss` is empty. `List.contains(xs, x)` is true when a cell equals `x`. Strings and boxed ints compare by value. `List.indexOf(xs, x)` is the first matching index, or `-1`. `List.lastIndexOf(xs, x)` is the last matching index, or `-1`. `List.distinct(xs)` keeps the first cell of each equal value. `List.distinctBy(xs, f)` keeps the first cell of each `Int` or `String` key that `f` returns. Empty stays empty. `List.toMap(pairs)` is `Map[K, V]` from `List[(K, V)]`. Duplicate keys keep the last value. Empty is empty. `List.toSet(xs)` is `Set[T]` from `List[Int]` or `List[String]`. Duplicate cells collapse. Empty is empty. `Map.toList(m)` is `List[(K, V)]` in key order. Empty is empty. `List.diff(xs, ys)` keeps cells of `xs` that are missing from `ys`. `List.intersect(xs, ys)` keeps cells of `xs` that occur in `ys`. `List.startsWith(xs, prefix)` is true when `xs` begins with `prefix`. Empty prefix is true. `List.endsWith(xs, suffix)` is true when `xs` ends with `suffix`. Empty suffix is true. `List.sameElements(xs, ys)` is true when both lists have the same cells in order. `List.patch(xs, from, other, replaced)` replaces `replaced` cells from `from` with `other`. Negative `from` / `replaced` is 0. `from` past the end appends `other`. `List.findLast(xs, pred)` is a list of the last match, or empty. `List.prefixLength(xs, pred)` is the length of the leading prefix where `pred` is true. `List.segmentLength(xs, pred, from)` is that length starting at `from`. Negative `from` is 0. `from` past the end is 0. `List.indexOfSlice(xs, slice)` is the first index of `slice`, or `-1`. Empty slice is 0. `List.lastIndexOfSlice(xs, slice)` is the last such index, or `-1`. Empty slice is the length. `List.isDefinedAt(xs, i)` is true when `i` is a valid index. `List.lengthCompare(xs, n)` is negative when the list is shorter than `n`, 0 when equal, and positive when longer. `List.sort(xs)` orders `Int` or `String` cells. Empty stays empty. Equal cells keep their order. `List.sortBy(xs, f)` orders by the `Int` key that `f` returns. `List.max(xs)` / `List.min(xs)` is the greatest / least `Int` or `String` cell. Empty panics. `List.maxBy(xs, f)` / `List.minBy(xs, f)` picks by that `Int` key. A tie keeps the first cell. `List.groupBy(xs, f)` groups cells by the `Int` or `String` key that `f` returns. Map keys sort. Cells in a group keep their order. Empty is empty. `List.sum(xs)` adds `Int` cells. Empty is 0. `List.product(xs)` multiplies `Int` cells. Empty is 1. `IO.foreach(xs, f)` runs `f` on each cell in order and is `IO[List[U]]`. Empty is an empty list. Failure or cancel stops later cells. `IO.foreachDiscard(xs, f)` is `IO[Unit]`. `IO.when(cond, io)` runs `io` when `cond` is true. Else it is `IO.pure(())`. `IO.unless` inverts the cond. `Ref.of(x)` is `IO[Ref[A]]`. `Ref.update(r, f)` / `Ref.updateAndGet(r, f)` apply `f` to the cell. Pin `Queue[Int]` / `Deferred[Int]` with `: IO[Queue[Int]]`. A missing pin is String. `Map.filter(m, pred)` keeps entries whose value matches `pred`. `Map.mapValues(m, f)` maps each value. `Map.exists(m, pred)` is true when any value matches. `Map.forall(m, pred)` is true when every value matches (empty is true). `Set.filter(s, pred)` keeps keys that match `pred`. `Set.map(s, f)` maps each key to an `Int` or `String` key. Duplicate keys collapse. `Set.exists(s, pred)` / `Set.forall(s, pred)` test keys. `Str.startsWith(s, prefix)` is `true` when `s` begins with `prefix`. `Str.contains(s, needle)` is true when `needle` occurs in `s`. `Str.endsWith(s, suffix)` is true when `s` ends with `suffix`. `Str.toInt(s, default)` parses base-10; junk or overflow uses `default`. `Str.replace(s, old, new)` replaces every non-overlapping `old`. Empty `old` leaves `s`. `Str.split(s, sep)` splits on non-overlapping `sep`. Empty `sep` copies `s` as one cell. `Str.isEmpty(s)` is true when `s` is empty. `Str.nonEmpty(s)` is true when `s` is not empty. `Str.toLower(s)` / `Str.toUpper(s)` map ASCII letters. Other bytes stay. `Str.capitalize(s)` maps the first ASCII letter to upper. Other bytes stay. Empty stays empty. `Str.repeat(s, n)` copies `s` `n` times (`n` <= 0 is empty). `Str.byteLen(s)` is the byte count; `Str.byteSlice(s, start, end)` copies that byte range. Use them for protocol framing (LSP `Content-Length` is bytes). All other `Str.*` index by code point. `Str.stripPrefix(s, prefix)` drops `prefix` when `s` starts with it. Else it copies `s`. `Str.stripSuffix(s, suffix)` drops `suffix` when `s` ends with it. Else it copies `s`. `Str.padLeft(s, n, pad)` / `Str.padRight(s, n, pad)` pad to width `n`. `n` <= len, or empty `pad`, copies `s`. `Str.isBlank(s)` is true when `s` has no bytes or only ASCII space, tab, CR, and LF. `Str.lastIndexOf(s, needle)` is the last start code-point index of `needle`, or `-1`. An empty needle is the length of `s`. `Str.take(s, n)` keeps the first `n` code points (`n` <= 0 is empty). `Str.drop(s, n)` skips `n` (`n` <= 0 copies `s`). `Str.takeRight(s, n)` keeps the last `n` code points. `Str.dropRight(s, n)` drops the last `n`. `Str.reverse(s)` reverses the code points. `Str.len(s)` is the code-point count. `Str.charAt(s, i)` is the code point at `i`, or `-1`. `Str.indexOf(s, needle)` is the first start code-point index, or `-1`. `Str.slice(s, start, end)` copies that code-point range. `Str.lines(s)` splits on CR/LF and drops empty lines. `List.reverse(xs)` copies the spine in reverse. `Str.trim(s)` drops leading and trailing ASCII space, tab, CR, and LF. `View.each` lambdas bind the element type. `Net.serve` lambdas bind `(String, String, String)` (path, method, body). `Stream.*` / `Resource.make` / `Resource.use` bind the payload. `Signal.map` / `List.tabulate` bind Int. The lambda body must return the kit result: `View` (`View.each` / `Ui.run`), `Bool` (filter / find / findLast / exists / takeWhile / dropWhile / forall / indexWhere / lastIndexWhere / span / partition / prefixLength / segmentLength / `Map.filter` / `Map.exists` / `Map.forall` / `Set.filter` / `Set.exists` / `Set.forall`), `Int` (`List.sortBy` / `List.maxBy` / `List.minBy`), `Int` or `String` (`List.groupBy` / `List.distinctBy` / `Set.map`), the mapped element (`List.map` / `List.tabulate` / `Map.mapValues` / `Stream.map`), `List` (`List.flatMap`), `String` (`Signal.map`; Int stringifies), or `IO` (`Resource` / `Net` / `Stream.evalMap` / `IO.foreach` / `IO.foreachDiscard`). `View.wrap(…)` lays out children left to right. A child that does not fit the remaining width starts a new run. Wrap sizes to the runs. `View.grid(n, …)` lays out children in `n` columns (`n` < `1` is one column). A new row starts after `n` shown children. Bounded width uses equal column slots. Height sizes to the rows. `View.scroll(child)` pans on y. Content lays out with unbounded height. Wrap a scroll list in `View.expanded(…)` inside a Column so it fills leftover height. `View.scrollH(child)` pans on x. Content lays out with unbounded width. Height sizes to the child. `scroll N dy` pans that scroll on its axis. In a Row, `View.expanded` takes leftover width. Scroll content is unbounded on the pan axis, so a Row inside a List keeps an intrinsic height. Expanded flex slots are tight. `View.stretch(child)` tightens the cross axis in a Column (width) or Row (height); the main axis stays intrinsic. Column and row do not stretch non-flex children unless wrapped in `View.stretch`. `View.center(child)` fills the max slot and centers the child. `View.align(ax, ay, child)` places the child (`0` start / `1` center / `2` end). `View.stack(…)` overlays children. `View.positioned(x, y, child)` offsets a Stack child. `View.padding(n, child)` insets uniformly. `View.sized(w, h, child)` is a tight slot. `View.minSize(w, h, child)` raises min size (`0` = no floor on that axis). `View.maxSize(w, h, child)` lowers max size (`0` = no cap on that axis). Incoming max still wins when tighter. `View.clip(child)` clips paint to the clip frame. Scroll uses the same clip. Do not add constraint-overflow dumps. `View.opacity(pct, child)` scales paint alpha (`0` = transparent, `100` = opaque). Nested opacity multiplies. `View.maxLines(n, child)` keeps at most `n` wrapped text lines (`0` = no cap). Nested caps take the tighter value. A11y still dumps the full string. Buttons and TextField stay one line. `View.ellipsis(child)` keeps extra lines off the paint. Without a positive `maxLines` it keeps one line. With `maxLines` it paints `...` on the last visible line when more text remains. A11y still dumps the full string. `View.textColor(color, child)` paints `View.text` / `View.bindText` with `color`. Nested `textColor` uses the inner color. Buttons and TextField stay on the theme. `View.gap(n, child)` sets Column/Row/Wrap/Grid/List spacing to `n` px (`0` = none). Nested `gap` uses the inner value. Without `View.gap`, Column/Row/Wrap/Grid/List use the theme gap. `View.fontSize(n, child)` sets `View.text` / `View.bindText` measure and paint size (`n` px, min `1`). Nested `fontSize` uses the inner size. Buttons and TextField stay on the theme font. `View.editor(sig)` paints a multiline buffer on `sig`. Insert and delete at the caret include newline and tab (two spaces). A11y dumps one `editor:editor` node. The dump uses `[editor]`, not `[fields]`. `View.split(frac, start, end)` is a row with a drag handle. `frac` is 0–100. `[splits]` dumps `N frac=F`. `View.overlay(open, child)` fills the parent when `open` is not 0. Compose it on `View.stack`. Escape and a backdrop tap write 0. Keys go to the overlay subtree while it is open. `[overlays]` dumps `N* open=0|1`. `View.focusGroup(child)` sizes to `child`. A tap on a descendant tap target focuses that list. ArrowUp / ArrowDown move among sibling taps when no overlay is open. Enter / Space activate the focused row. An open overlay still takes keys. `[session]` dumps `focus=button: ` sets the starred-field selection. `copy` / `cut` / `paste` / `paste ` drive the session clipboard. Headless `paste` is first-class. Desktop/Mobile pull the OS pasteboard on paste when present. `drag x1 y1 x2 y2` is pointer-drag select. Live OS keys record as `key`, not `type`. Live OS auto-repeat records `key a+repeat`. Live OS copy/cut/paste and Shift+arrows record those verbs. One-token forms (`text s`, `backspace k`, `scroll 40`) still use the starred field or first Scroll. `text 0` remains payload `"0"`. `xy x y` injects a TAP at a logical point; a miss does not panic. `hover x y` injects a pointer MOVE with no button and shows `View.tooltip`. `secondary N` / `secondary x y` is a button-3 click; it does not fire the primary tap. Live OS hover and right-click record as `hover` / `secondary`. Desktop/Mobile `scuzz run` records live OS clicks and keys to `build/record.script` (not `inject.script`) and writes `build/debug.dump`. Replay with `scuzz run --headless --script build/record.script --dump build/debug.dump`. `--message-format=json` applies to `check` only. That JSON is the editor protocol. diff --git a/docs/vision.md b/docs/vision.md index 2d8e0e0e..2eadda8e 100644 --- a/docs/vision.md +++ b/docs/vision.md @@ -119,6 +119,8 @@ No vendored Skia tree. Thin `sk_capi` (measure + draw). **Default UI backend** i One failure channel: `SzError` on `IO[T]`. The fail payload is a `String` message. Direction: typed `E` on `IO` without environment `R`. Do not add `ZIO[R, E, A]`. Blessed kits only. No app-level `IO.delay`. No user FFI. Expand kits for time, regex, and hash after the thesis-critical language gaps close. Cooperative single-threaded fibers are the scheduler for CLI, server, and UI (no OS threads for IO). Park on sleep, empty take, incomplete get, and fd poll. Cancel runs `IO.ensure` / `Resource` finalizers. `Fiber.fork` starts a supervised child. TestRuntime (`SCUZZ_TESTRT=1`) fakes clock, random, FS, net, and console. Simulation is hermetic: no live sockets; HTTP uses stubs and a loopback mailbox; `Sys.exec` / `Sys.spawn` fail; `Sys.getenv` is sealed; `Sys.alive` / `Sys.kill` use a fake process table. Live `Net.serve` binds localhost. Live HTTP client kits take `http://` and `https://` with OpenSSL. Expand `Net` on this HTTP/1.0 stack. Do not expose POSIX sockets. Do not add a second HTTP client. TLS is for `https://` on this kit. Every new `Net` op keeps a TestRuntime fake. Surface catalogs: [`guide.md`](guide.md). Panics abort through `sz_panic`. A panic must print a Scuzz file and line. +`Stream` is one finite pull interpreter. Constructors and transformers are `Stream[A]`. Bind a Stream with `=`. `<-` needs `IO`. Terminals (`compileToList` / `drain` / `head` / `last` / `count` / `fold` / `exists` / `forall` / `none`) are `IO`. `take` pulls until n outputs. `head` and `forall` / `none` / `exists` / `find` stop early. `range` / `iterate` / `unfold` allocate on pull. The unfold cap is 65536 pulled steps. Do not add backpressure, publishers, or a second stream kit. + ### `Ui` vs `View` | Layer | Role | Purity | diff --git a/examples/compiler/src/Check.scuzz b/examples/compiler/src/Check.scuzz index 64afcce4..44e3273d 100644 --- a/examples/compiler/src/Check.scuzz +++ b/examples/compiler/src/Check.scuzz @@ -475,7 +475,10 @@ def anyParams(n: Int): List[String] = if (n <= 0) noStr() else "Any" :: anyParams(n - 1) def openKitRet(f: String): String = - if (openKitBool(f)) "Bool" else if (openKitInt(f) || Str.startsWith(f, "Color.") || Str.startsWith(f, "Theme.")) "Int" else if (f == "Property.sometimes") "Unit" else if (openKitAny(f) || Str.startsWith(f, "Signal.") || Str.startsWith(f, "View.") || Str.startsWith(f, "Property.") || Str.startsWith(f, "Json.")) "Any" else if (Str.startsWith(f, "Map.")) "Map" else if (Str.startsWith(f, "Set.")) "Set" else if (Str.startsWith(f, "IO.") || Str.startsWith(f, "Ui.") || Str.startsWith(f, "Fiber.") || Str.startsWith(f, "Ref.") || Str.startsWith(f, "Queue.") || Str.startsWith(f, "Deferred.") || Str.startsWith(f, "Stream.") || Str.startsWith(f, "Resource.")) "IO[Any]" else if (Str.startsWith(f, "Str.")) "String" else if (Str.startsWith(f, "Float.")) "Float" else if (Str.startsWith(f, "Builder.") || Str.startsWith(f, "Oracle.") || Str.startsWith(f, ".")) "Any" else "Any" + if (openKitBool(f)) "Bool" else if (openKitInt(f) || Str.startsWith(f, "Color.") || Str.startsWith(f, "Theme.")) "Int" else if (f == "Property.sometimes") "Unit" else if (openKitAny(f) || Str.startsWith(f, "Signal.") || Str.startsWith(f, "View.") || Str.startsWith(f, "Property.") || Str.startsWith(f, "Json.")) "Any" else if (Str.startsWith(f, "Map.")) "Map" else if (Str.startsWith(f, "Set.")) "Set" else if (Str.startsWith(f, "Stream.")) streamKitRet(f) else if (Str.startsWith(f, "IO.") || Str.startsWith(f, "Ui.") || Str.startsWith(f, "Fiber.") || Str.startsWith(f, "Ref.") || Str.startsWith(f, "Queue.") || Str.startsWith(f, "Deferred.") || Str.startsWith(f, "Resource.")) "IO[Any]" else if (Str.startsWith(f, "Str.")) "String" else if (Str.startsWith(f, "Float.")) "Float" else if (Str.startsWith(f, "Builder.") || Str.startsWith(f, "Oracle.") || Str.startsWith(f, ".")) "Any" else "Any" + +def streamKitRet(f: String): String = + if (f == "Stream.exists" || f == "Stream.forall" || f == "Stream.none") "IO[Bool]" else if (f == "Stream.count") "IO[Int]" else if (f == "Stream.compileToList" || f == "Stream.drain" || f == "Stream.head" || f == "Stream.last" || f == "Stream.fold") "IO[Any]" else "Stream" def openKitBool(f: String): Bool = f == "List.exists" || f == "List.forall" || f == "List.contains" || f == "List.nonEmpty" || f == "List.isEmpty" @@ -640,7 +643,7 @@ def inferForBind2(d: Bool, name: String, vo: Out, rest: List[Bind], body: Expr, if (hasErr(vo)) vo else if (d) inferForDraw(name, vo.ty, rest, body, env, funs, ens, errTy, span) else inferForGo(rest, body, bindFor(false, name, vo.ty, env, ens), funs, ens, errTy) def inferForDraw(name: String, ty: String, rest: List[Bind], body: Expr, env: List[(String, String)], funs: List[Fun], ens: List[En], errTy: String, span: (String, Int)): Out = - inferForDraw2(name, ty, rest, body, env, funs, ens, unifyErr(errTy, ioErr(ty), span)) + if (Str.startsWith(ty, "Stream")) bad(Str.concat("<- needs IO, got ", ty), span) else inferForDraw2(name, ty, rest, body, env, funs, ens, unifyErr(errTy, ioErr(ty), span)) def inferForDraw2(name: String, ty: String, rest: List[Bind], body: Expr, env: List[(String, String)], funs: List[Fun], ens: List[En], u: Out): Out = if (hasErr(u)) u else inferForGo(rest, body, bindFor(true, name, ty, env, ens), funs, ens, u.ty) From 992e01b99f8b3dd8d23365ecbe5821f911af5de9 Mon Sep 17 00:00:00 2001 From: Sean Cheatham Date: Fri, 4 Sep 2026 05:59:45 -0400 Subject: [PATCH 10/32] Typecheck packages before disk emit so scuzz build cannot write IR for a failing Check. Stamp VERSION, uname, clang, and Skia into the fingerprint, fail closed on bad toml, and keep UI size fields on Man. --- docs/guide.md | 8 +- docs/schemas/scuzz-toml.md | 5 +- docs/vision.md | 6 +- examples/cli/cli.scuzz_verify | 4 +- examples/cli/src/Cli.scuzz | 72 +++-- examples/cli/src/Help.scuzz | 7 +- examples/cli/src/Main.scuzz | 16 +- examples/compiler/src/Drive.scuzz | 411 +++++++++++++++------------ examples/compiler/src/Lsp.scuzz | 17 +- examples/compiler/src/Manifest.scuzz | 129 ++++++++- 10 files changed, 428 insertions(+), 247 deletions(-) diff --git a/docs/guide.md b/docs/guide.md index dc29f372..b856c727 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -74,7 +74,7 @@ Build a pure `View` tree. Hold state in `Signal`. Run a session with `Ui.run`: Lists: keep a `Signal.list`, render with `View.each(items)` (framework rebuilds `- item` texts at layout). `View.each(items, s => view)` builds one child per element. The element type comes from the signal (`Signal.list(xs)` over `List[T]` binds `T`; record fields like `item.label` work in the body). String lists dump `["a", "b"]`; other element types dump the count as `list[N] = `. `List.filter(xs, pred)` keeps elements for which `pred` is true. `List.map(xs, f)` builds a new list. The filter/map/flatMap/find/findLast/exists/count/takeWhile/dropWhile/forall/filterNot/indexWhere/lastIndexWhere/span/partition/prefixLength/segmentLength/sortBy/maxBy/minBy/groupBy/distinctBy/`IO.foreach`/`IO.foreachDiscard` lambda binds the element type. `Ref.update` / `Ref.updateAndGet` bind the cell. `Map.filter` / `Map.exists` / `Map.forall` / `Map.mapValues` bind the value. `Set.filter` / `Set.exists` / `Set.forall` / `Set.map` bind the key. `List.tabulate(n, f)` binds `Int`. `List.setAt(xs, i, v)` replaces the element at `i`. An index outside the list leaves the list. `List.take(xs, n)` keeps the first `n` elements (`n` <= 0 is empty). `List.drop(xs, n)` skips `n` (`n` <= 0 leaves the list). `List.find(xs, pred)` is a list of the first match, or empty. `List.exists(xs, pred)` is true when any element matches. `List.takeWhile(xs, pred)` keeps a prefix while `pred` is true. `List.dropWhile(xs, pred)` skips that prefix. `List.forall(xs, pred)` is true when every element matches (empty is true). `List.filterNot(xs, pred)` keeps elements for which `pred` is false. `List.count(xs, pred)` is the number of matches. `List.flatMap(xs, f)` concatenates the lists that `f` returns. `List.padTo(xs, n, x)` appends `x` until length `n`. `n` <= len leaves the list. `List.nonEmpty(xs)` is true when the list has a cell. `List.empty()` is Nil. `List.len(xs)` is the cell count. `List.head(xs)` is `Option[T]`. Empty is `None`. `List.at(xs, i)` is the element at `i`. An index outside panics. `List.tail(xs)` drops the first cell. Empty panics. `List.join(xs, sep)` joins `List[String]` cells. `List.concat(xs, ys)` copies the `xs` spine and shares `ys`. `List.flatten(xss)` concatenates inner lists. `List.takeRight(xs, n)` keeps the last `n` elements (`n` <= 0 is empty). `List.dropRight(xs, n)` drops the last `n` (`n` <= 0 leaves the list). `List.init(xs)` drops the last cell (empty stays empty). `List.last(xs)` is a list of the last element, or empty. `List.getOrElse(xs, i, default)` is the element at `i`, or `default` when `i` is out of range. `List.fill(n, x)` is `n` copies of `x` (`n` <= 0 is empty). `List.range(from, until)` is boxed ints `[from, until)` (empty when `until` <= `from`). `List.tabulate(n, f)` is `f(0)` … `f(n-1)` (`n` <= 0 is empty). `List.intersperse(xs, x)` inserts `x` between cells. Empty or one cell shares. `List.grouped(xs, n)` is chunks of length `n`. The last chunk may be short. `n` <= 0 is empty. `List.sliding(xs, n)` is overlapping windows of length `n`. `n` <= 0 or `n` > len is empty. `List.slice(xs, from, until)` is `[from, until)`. Negative `from` / `until` is 0. `until` <= `from` is empty. `List.indexWhere(xs, pred)` is the first matching index, or `-1`. `List.lastIndexWhere(xs, pred)` is the last matching index, or `-1`. `List.indices(xs)` is boxed ints `[0, len)`. Empty when `xs` is empty. `List.splitAt(xs, n)` is two lists: take then drop, packed as `List[List[T]]`. `List.span(xs, pred)` is takeWhile then dropWhile. `List.partition(xs, pred)` is filter then filterNot. `List.inits(xs)` is prefixes including empty and the full list. `List.tails(xs)` is suffixes including the full list and empty. `List.zip(xs, ys)` is `List[(A, B)]`. A and B may differ. It stops at the shorter list. `List.interleave(xs, ys)` alternates cells from `xs` and `ys`, then appends the leftover. Empty or one list shares. `List.zipAll(xs, ys, x, y)` pads the shorter list with `x` or `y`. `List.unzip(pairs)` is `(List[A], List[B])`. Empty unzip is two empty lists. `List.zipWithIndex(xs)` is `List[(Int, T)]`. `List.foldLeft(xs, z, f)` folds with `f(acc, x)` and returns `z` when `xs` is empty. `List.foldRight(xs, z, f)` folds with `f(x, acc)` from the right. `List.scanLeft(xs, z, f)` is the list of accumulators including `z`. Empty is `[z]`. `List.scanRight(xs, z, f)` scans from the right. `List.reduceLeft(xs, f)` folds from the first cell. Empty panics. `List.reduceRight(xs, f)` folds from the last cell. Empty panics. `List.transpose(xss)` turns rows into columns. It stops at the shortest row. Empty `xss` is empty. `List.contains(xs, x)` is true when a cell equals `x`. Strings and boxed ints compare by value. `List.indexOf(xs, x)` is the first matching index, or `-1`. `List.lastIndexOf(xs, x)` is the last matching index, or `-1`. `List.distinct(xs)` keeps the first cell of each equal value. `List.distinctBy(xs, f)` keeps the first cell of each `Int` or `String` key that `f` returns. Empty stays empty. `List.toMap(pairs)` is `Map[K, V]` from `List[(K, V)]`. Duplicate keys keep the last value. Empty is empty. `List.toSet(xs)` is `Set[T]` from `List[Int]` or `List[String]`. Duplicate cells collapse. Empty is empty. `Map.toList(m)` is `List[(K, V)]` in key order. Empty is empty. `List.diff(xs, ys)` keeps cells of `xs` that are missing from `ys`. `List.intersect(xs, ys)` keeps cells of `xs` that occur in `ys`. `List.startsWith(xs, prefix)` is true when `xs` begins with `prefix`. Empty prefix is true. `List.endsWith(xs, suffix)` is true when `xs` ends with `suffix`. Empty suffix is true. `List.sameElements(xs, ys)` is true when both lists have the same cells in order. `List.patch(xs, from, other, replaced)` replaces `replaced` cells from `from` with `other`. Negative `from` / `replaced` is 0. `from` past the end appends `other`. `List.findLast(xs, pred)` is a list of the last match, or empty. `List.prefixLength(xs, pred)` is the length of the leading prefix where `pred` is true. `List.segmentLength(xs, pred, from)` is that length starting at `from`. Negative `from` is 0. `from` past the end is 0. `List.indexOfSlice(xs, slice)` is the first index of `slice`, or `-1`. Empty slice is 0. `List.lastIndexOfSlice(xs, slice)` is the last such index, or `-1`. Empty slice is the length. `List.isDefinedAt(xs, i)` is true when `i` is a valid index. `List.lengthCompare(xs, n)` is negative when the list is shorter than `n`, 0 when equal, and positive when longer. `List.sort(xs)` orders `Int` or `String` cells. Empty stays empty. Equal cells keep their order. `List.sortBy(xs, f)` orders by the `Int` key that `f` returns. `List.max(xs)` / `List.min(xs)` is the greatest / least `Int` or `String` cell. Empty panics. `List.maxBy(xs, f)` / `List.minBy(xs, f)` picks by that `Int` key. A tie keeps the first cell. `List.groupBy(xs, f)` groups cells by the `Int` or `String` key that `f` returns. Map keys sort. Cells in a group keep their order. Empty is empty. `List.sum(xs)` adds `Int` cells. Empty is 0. `List.product(xs)` multiplies `Int` cells. Empty is 1. `IO.foreach(xs, f)` runs `f` on each cell in order and is `IO[List[U]]`. Empty is an empty list. Failure or cancel stops later cells. `IO.foreachDiscard(xs, f)` is `IO[Unit]`. `IO.when(cond, io)` runs `io` when `cond` is true. Else it is `IO.pure(())`. `IO.unless` inverts the cond. `Ref.of(x)` is `IO[Ref[A]]`. `Ref.update(r, f)` / `Ref.updateAndGet(r, f)` apply `f` to the cell. Pin `Queue[Int]` / `Deferred[Int]` with `: IO[Queue[Int]]`. A missing pin is String. `Map.filter(m, pred)` keeps entries whose value matches `pred`. `Map.mapValues(m, f)` maps each value. `Map.exists(m, pred)` is true when any value matches. `Map.forall(m, pred)` is true when every value matches (empty is true). `Set.filter(s, pred)` keeps keys that match `pred`. `Set.map(s, f)` maps each key to an `Int` or `String` key. Duplicate keys collapse. `Set.exists(s, pred)` / `Set.forall(s, pred)` test keys. `Str.startsWith(s, prefix)` is `true` when `s` begins with `prefix`. `Str.contains(s, needle)` is true when `needle` occurs in `s`. `Str.endsWith(s, suffix)` is true when `s` ends with `suffix`. `Str.toInt(s, default)` parses base-10; junk or overflow uses `default`. `Str.replace(s, old, new)` replaces every non-overlapping `old`. Empty `old` leaves `s`. `Str.split(s, sep)` splits on non-overlapping `sep`. Empty `sep` copies `s` as one cell. `Str.isEmpty(s)` is true when `s` is empty. `Str.nonEmpty(s)` is true when `s` is not empty. `Str.toLower(s)` / `Str.toUpper(s)` map ASCII letters. Other bytes stay. `Str.capitalize(s)` maps the first ASCII letter to upper. Other bytes stay. Empty stays empty. `Str.repeat(s, n)` copies `s` `n` times (`n` <= 0 is empty). `Str.byteLen(s)` is the byte count; `Str.byteSlice(s, start, end)` copies that byte range. Use them for protocol framing (LSP `Content-Length` is bytes). All other `Str.*` index by code point. `Str.stripPrefix(s, prefix)` drops `prefix` when `s` starts with it. Else it copies `s`. `Str.stripSuffix(s, suffix)` drops `suffix` when `s` ends with it. Else it copies `s`. `Str.padLeft(s, n, pad)` / `Str.padRight(s, n, pad)` pad to width `n`. `n` <= len, or empty `pad`, copies `s`. `Str.isBlank(s)` is true when `s` has no bytes or only ASCII space, tab, CR, and LF. `Str.lastIndexOf(s, needle)` is the last start code-point index of `needle`, or `-1`. An empty needle is the length of `s`. `Str.take(s, n)` keeps the first `n` code points (`n` <= 0 is empty). `Str.drop(s, n)` skips `n` (`n` <= 0 copies `s`). `Str.takeRight(s, n)` keeps the last `n` code points. `Str.dropRight(s, n)` drops the last `n`. `Str.reverse(s)` reverses the code points. `Str.len(s)` is the code-point count. `Str.charAt(s, i)` is the code point at `i`, or `-1`. `Str.indexOf(s, needle)` is the first start code-point index, or `-1`. `Str.slice(s, start, end)` copies that code-point range. `Str.lines(s)` splits on CR/LF and drops empty lines. `List.reverse(xs)` copies the spine in reverse. `Str.trim(s)` drops leading and trailing ASCII space, tab, CR, and LF. `View.each` lambdas bind the element type. `Net.serve` lambdas bind `(String, String, String)` (path, method, body). `Stream.*` / `Resource.make` / `Resource.use` bind the payload. `Signal.map` / `List.tabulate` bind Int. The lambda body must return the kit result: `View` (`View.each` / `Ui.run`), `Bool` (filter / find / findLast / exists / takeWhile / dropWhile / forall / indexWhere / lastIndexWhere / span / partition / prefixLength / segmentLength / `Map.filter` / `Map.exists` / `Map.forall` / `Set.filter` / `Set.exists` / `Set.forall`), `Int` (`List.sortBy` / `List.maxBy` / `List.minBy`), `Int` or `String` (`List.groupBy` / `List.distinctBy` / `Set.map`), the mapped element (`List.map` / `List.tabulate` / `Map.mapValues` / `Stream.map`), `List` (`List.flatMap`), `String` (`Signal.map`; Int stringifies), or `IO` (`Resource` / `Net` / `Stream.evalMap` / `IO.foreach` / `IO.foreachDiscard`). `View.wrap(…)` lays out children left to right. A child that does not fit the remaining width starts a new run. Wrap sizes to the runs. `View.grid(n, …)` lays out children in `n` columns (`n` < `1` is one column). A new row starts after `n` shown children. Bounded width uses equal column slots. Height sizes to the rows. `View.scroll(child)` pans on y. Content lays out with unbounded height. Wrap a scroll list in `View.expanded(…)` inside a Column so it fills leftover height. `View.scrollH(child)` pans on x. Content lays out with unbounded width. Height sizes to the child. `scroll N dy` pans that scroll on its axis. In a Row, `View.expanded` takes leftover width. Scroll content is unbounded on the pan axis, so a Row inside a List keeps an intrinsic height. Expanded flex slots are tight. `View.stretch(child)` tightens the cross axis in a Column (width) or Row (height); the main axis stays intrinsic. Column and row do not stretch non-flex children unless wrapped in `View.stretch`. `View.center(child)` fills the max slot and centers the child. `View.align(ax, ay, child)` places the child (`0` start / `1` center / `2` end). `View.stack(…)` overlays children. `View.positioned(x, y, child)` offsets a Stack child. `View.padding(n, child)` insets uniformly. `View.sized(w, h, child)` is a tight slot. `View.minSize(w, h, child)` raises min size (`0` = no floor on that axis). `View.maxSize(w, h, child)` lowers max size (`0` = no cap on that axis). Incoming max still wins when tighter. `View.clip(child)` clips paint to the clip frame. Scroll uses the same clip. Do not add constraint-overflow dumps. `View.opacity(pct, child)` scales paint alpha (`0` = transparent, `100` = opaque). Nested opacity multiplies. `View.maxLines(n, child)` keeps at most `n` wrapped text lines (`0` = no cap). Nested caps take the tighter value. A11y still dumps the full string. Buttons and TextField stay one line. `View.ellipsis(child)` keeps extra lines off the paint. Without a positive `maxLines` it keeps one line. With `maxLines` it paints `...` on the last visible line when more text remains. A11y still dumps the full string. `View.textColor(color, child)` paints `View.text` / `View.bindText` with `color`. Nested `textColor` uses the inner color. Buttons and TextField stay on the theme. `View.gap(n, child)` sets Column/Row/Wrap/Grid/List spacing to `n` px (`0` = none). Nested `gap` uses the inner value. Without `View.gap`, Column/Row/Wrap/Grid/List use the theme gap. `View.fontSize(n, child)` sets `View.text` / `View.bindText` measure and paint size (`n` px, min `1`). Nested `fontSize` uses the inner size. Buttons and TextField stay on the theme font. `View.editor(sig)` paints a multiline buffer on `sig`. Insert and delete at the caret include newline and tab (two spaces). A11y dumps one `editor:editor` node. The dump uses `[editor]`, not `[fields]`. `View.split(frac, start, end)` is a row with a drag handle. `frac` is 0–100. `[splits]` dumps `N frac=F`. `View.overlay(open, child)` fills the parent when `open` is not 0. Compose it on `View.stack`. Escape and a backdrop tap write 0. Keys go to the overlay subtree while it is open. `[overlays]` dumps `N* open=0|1`. `View.focusGroup(child)` sizes to `child`. A tap on a descendant tap target focuses that list. ArrowUp / ArrowDown move among sibling taps when no overlay is open. Enter / Space activate the focused row. An open overlay still takes keys. `[session]` dumps `focus=button: ` sets the starred-field selection. `copy` / `cut` / `paste` / `paste ` drive the session clipboard. Headless `paste` is first-class. Desktop/Mobile pull the OS pasteboard on paste when present. `drag x1 y1 x2 y2` is pointer-drag select. Live OS keys record as `key`, not `type`. Live OS auto-repeat records `key a+repeat`. Live OS copy/cut/paste and Shift+arrows record those verbs. One-token forms (`text s`, `backspace k`, `scroll 40`) still use the starred field or first Scroll. `text 0` remains payload `"0"`. `xy x y` injects a TAP at a logical point; a miss does not panic. `hover x y` injects a pointer MOVE with no button and shows `View.tooltip`. `secondary N` / `secondary x y` is a button-3 click; it does not fire the primary tap. Live OS hover and right-click record as `hover` / `secondary`. Desktop/Mobile `scuzz run` records live OS clicks and keys to `build/record.script` (not `inject.script`) and writes `build/debug.dump`. Replay with `scuzz run --headless --script build/record.script --dump build/debug.dump`. `--message-format=json` applies to `check` only. That JSON is the editor protocol. @@ -72,7 +72,7 @@ Build a pure `View` tree. Hold state in `Signal`. Run a session with `Ui.run`: } yield () ``` -Lists: keep a `Signal.list`, render with `View.each(items)` (framework rebuilds `- item` texts at layout). `View.each(items, s => view)` builds one child per element. The element type comes from the signal (`Signal.list(xs)` over `List[T]` binds `T`; record fields like `item.label` work in the body). String lists dump `["a", "b"]`; other element types dump the count as `list[N] = `. `List.filter(xs, pred)` keeps elements for which `pred` is true. `List.map(xs, f)` builds a new list. The filter/map/flatMap/find/findLast/exists/count/takeWhile/dropWhile/forall/filterNot/indexWhere/lastIndexWhere/span/partition/prefixLength/segmentLength/sortBy/maxBy/minBy/groupBy/distinctBy/`IO.foreach`/`IO.foreachDiscard` lambda binds the element type. `Ref.update` / `Ref.updateAndGet` bind the cell. `Map.filter` / `Map.exists` / `Map.forall` / `Map.mapValues` bind the value. `Set.filter` / `Set.exists` / `Set.forall` / `Set.map` bind the key. `List.tabulate(n, f)` binds `Int`. `List.setAt(xs, i, v)` replaces the element at `i`. An index outside the list leaves the list. `List.take(xs, n)` keeps the first `n` elements (`n` <= 0 is empty). `List.drop(xs, n)` skips `n` (`n` <= 0 leaves the list). `List.find(xs, pred)` is a list of the first match, or empty. `List.exists(xs, pred)` is true when any element matches. `List.takeWhile(xs, pred)` keeps a prefix while `pred` is true. `List.dropWhile(xs, pred)` skips that prefix. `List.forall(xs, pred)` is true when every element matches (empty is true). `List.filterNot(xs, pred)` keeps elements for which `pred` is false. `List.count(xs, pred)` is the number of matches. `List.flatMap(xs, f)` concatenates the lists that `f` returns. `List.padTo(xs, n, x)` appends `x` until length `n`. `n` <= len leaves the list. `List.nonEmpty(xs)` is true when the list has a cell. `List.empty()` is Nil. `List.len(xs)` is the cell count. `List.head(xs)` is `Option[T]`. Empty is `None`. `List.at(xs, i)` is the element at `i`. An index outside panics. `List.tail(xs)` drops the first cell. Empty panics. `List.join(xs, sep)` joins `List[String]` cells. `List.concat(xs, ys)` copies the `xs` spine and shares `ys`. `List.flatten(xss)` concatenates inner lists. `List.takeRight(xs, n)` keeps the last `n` elements (`n` <= 0 is empty). `List.dropRight(xs, n)` drops the last `n` (`n` <= 0 leaves the list). `List.init(xs)` drops the last cell (empty stays empty). `List.last(xs)` is a list of the last element, or empty. `List.getOrElse(xs, i, default)` is the element at `i`, or `default` when `i` is out of range. `List.fill(n, x)` is `n` copies of `x` (`n` <= 0 is empty). `List.range(from, until)` is boxed ints `[from, until)` (empty when `until` <= `from`). `List.tabulate(n, f)` is `f(0)` … `f(n-1)` (`n` <= 0 is empty). `List.intersperse(xs, x)` inserts `x` between cells. Empty or one cell shares. `List.grouped(xs, n)` is chunks of length `n`. The last chunk may be short. `n` <= 0 is empty. `List.sliding(xs, n)` is overlapping windows of length `n`. `n` <= 0 or `n` > len is empty. `List.slice(xs, from, until)` is `[from, until)`. Negative `from` / `until` is 0. `until` <= `from` is empty. `List.indexWhere(xs, pred)` is the first matching index, or `-1`. `List.lastIndexWhere(xs, pred)` is the last matching index, or `-1`. `List.indices(xs)` is boxed ints `[0, len)`. Empty when `xs` is empty. `List.splitAt(xs, n)` is two lists: take then drop, packed as `List[List[T]]`. `List.span(xs, pred)` is takeWhile then dropWhile. `List.partition(xs, pred)` is filter then filterNot. `List.inits(xs)` is prefixes including empty and the full list. `List.tails(xs)` is suffixes including the full list and empty. `List.zip(xs, ys)` is `List[(A, B)]`. A and B may differ. It stops at the shorter list. `List.interleave(xs, ys)` alternates cells from `xs` and `ys`, then appends the leftover. Empty or one list shares. `List.zipAll(xs, ys, x, y)` pads the shorter list with `x` or `y`. `List.unzip(pairs)` is `(List[A], List[B])`. Empty unzip is two empty lists. `List.zipWithIndex(xs)` is `List[(Int, T)]`. `List.foldLeft(xs, z, f)` folds with `f(acc, x)` and returns `z` when `xs` is empty. `List.foldRight(xs, z, f)` folds with `f(x, acc)` from the right. `List.scanLeft(xs, z, f)` is the list of accumulators including `z`. Empty is `[z]`. `List.scanRight(xs, z, f)` scans from the right. `List.reduceLeft(xs, f)` folds from the first cell. Empty panics. `List.reduceRight(xs, f)` folds from the last cell. Empty panics. `List.transpose(xss)` turns rows into columns. It stops at the shortest row. Empty `xss` is empty. `List.contains(xs, x)` is true when a cell equals `x`. Strings and boxed ints compare by value. `List.indexOf(xs, x)` is the first matching index, or `-1`. `List.lastIndexOf(xs, x)` is the last matching index, or `-1`. `List.distinct(xs)` keeps the first cell of each equal value. `List.distinctBy(xs, f)` keeps the first cell of each `Int` or `String` key that `f` returns. Empty stays empty. `List.toMap(pairs)` is `Map[K, V]` from `List[(K, V)]`. Duplicate keys keep the last value. Empty is empty. `List.toSet(xs)` is `Set[T]` from `List[Int]` or `List[String]`. Duplicate cells collapse. Empty is empty. `Map.toList(m)` is `List[(K, V)]` in key order. Empty is empty. `List.diff(xs, ys)` keeps cells of `xs` that are missing from `ys`. `List.intersect(xs, ys)` keeps cells of `xs` that occur in `ys`. `List.startsWith(xs, prefix)` is true when `xs` begins with `prefix`. Empty prefix is true. `List.endsWith(xs, suffix)` is true when `xs` ends with `suffix`. Empty suffix is true. `List.sameElements(xs, ys)` is true when both lists have the same cells in order. `List.patch(xs, from, other, replaced)` replaces `replaced` cells from `from` with `other`. Negative `from` / `replaced` is 0. `from` past the end appends `other`. `List.findLast(xs, pred)` is a list of the last match, or empty. `List.prefixLength(xs, pred)` is the length of the leading prefix where `pred` is true. `List.segmentLength(xs, pred, from)` is that length starting at `from`. Negative `from` is 0. `from` past the end is 0. `List.indexOfSlice(xs, slice)` is the first index of `slice`, or `-1`. Empty slice is 0. `List.lastIndexOfSlice(xs, slice)` is the last such index, or `-1`. Empty slice is the length. `List.isDefinedAt(xs, i)` is true when `i` is a valid index. `List.lengthCompare(xs, n)` is negative when the list is shorter than `n`, 0 when equal, and positive when longer. `List.sort(xs)` orders `Int` or `String` cells. Empty stays empty. Equal cells keep their order. `List.sortBy(xs, f)` orders by the `Int` key that `f` returns. `List.max(xs)` / `List.min(xs)` is the greatest / least `Int` or `String` cell. Empty panics. `List.maxBy(xs, f)` / `List.minBy(xs, f)` picks by that `Int` key. A tie keeps the first cell. `List.groupBy(xs, f)` groups cells by the `Int` or `String` key that `f` returns. Map keys sort. Cells in a group keep their order. Empty is empty. `List.sum(xs)` adds `Int` cells. Empty is 0. `List.product(xs)` multiplies `Int` cells. Empty is 1. `IO.foreach(xs, f)` runs `f` on each cell in order and is `IO[List[U]]`. Empty is an empty list. Failure or cancel stops later cells. `IO.foreachDiscard(xs, f)` is `IO[Unit]`. `IO.when(cond, io)` runs `io` when `cond` is true. Else it is `IO.pure(())`. `IO.unless` inverts the cond. `Ref.of(x)` is `IO[Ref[A]]`. `Ref.update(r, f)` / `Ref.updateAndGet(r, f)` apply `f` to the cell. Pin `Queue[Int]` / `Deferred[Int]` with `: IO[Queue[Int]]`. A missing pin is String. `Map.filter(m, pred)` keeps entries whose value matches `pred`. `Map.mapValues(m, f)` maps each value. `Map.exists(m, pred)` is true when any value matches. `Map.forall(m, pred)` is true when every value matches (empty is true). `Set.filter(s, pred)` keeps keys that match `pred`. `Set.map(s, f)` maps each key to an `Int` or `String` key. Duplicate keys collapse. `Set.exists(s, pred)` / `Set.forall(s, pred)` test keys. `Str.startsWith(s, prefix)` is `true` when `s` begins with `prefix`. `Str.contains(s, needle)` is true when `needle` occurs in `s`. `Str.endsWith(s, suffix)` is true when `s` ends with `suffix`. `Str.toInt(s, default)` parses base-10; junk or overflow uses `default`. `Str.fromBool(b)` is `"true"` or `"false"`. `Str.replace(s, old, new)` replaces every non-overlapping `old`. Empty `old` leaves `s`. `Str.split(s, sep)` splits on non-overlapping `sep`. Empty `sep` copies `s` as one cell. `Str.isEmpty(s)` is true when `s` is empty. `Str.nonEmpty(s)` is true when `s` is not empty. `Str.toLower(s)` / `Str.toUpper(s)` map ASCII letters. Other bytes stay. `Str.capitalize(s)` maps the first ASCII letter to upper. Other bytes stay. Empty stays empty. `Str.repeat(s, n)` copies `s` `n` times (`n` <= 0 is empty). `Str.byteLen(s)` is the byte count; `Str.byteSlice(s, start, end)` copies that byte range. Use them for protocol framing (LSP `Content-Length` is bytes). All other `Str.*` index by code point. `Str.stripPrefix(s, prefix)` drops `prefix` when `s` starts with it. Else it copies `s`. `Str.stripSuffix(s, suffix)` drops `suffix` when `s` ends with it. Else it copies `s`. `Str.padLeft(s, n, pad)` / `Str.padRight(s, n, pad)` pad to code-point width `n`. Fill cycles complete code points. `n` <= len, or empty `pad`, copies `s`. `Str.isBlank(s)` is true when `s` has no bytes or only ASCII space, tab, CR, and LF. `Str.lastIndexOf(s, needle)` is the last start code-point index of `needle`, or `-1`. An empty needle is the length of `s`. `Str.take(s, n)` keeps the first `n` code points (`n` <= 0 is empty). `Str.drop(s, n)` skips `n` (`n` <= 0 copies `s`). `Str.takeRight(s, n)` keeps the last `n` code points. `Str.dropRight(s, n)` drops the last `n`. `Str.reverse(s)` reverses the code points. `Str.len(s)` is the code-point count. `Str.charAt(s, i)` is the code point at `i`, or `-1`. `Str.indexOf(s, needle)` is the first start code-point index, or `-1`. `Str.slice(s, start, end)` copies that code-point range. `Str.lines(s)` splits on CR/LF and drops empty lines. `List.reverse(xs)` copies the spine in reverse. `Str.trim(s)` drops leading and trailing ASCII space, tab, CR, and LF. `View.each` lambdas bind the element type. `Net.serve` lambdas bind `(String, String, String)` (path, method, body). `Stream.*` / `Resource.make` / `Resource.use` bind the payload. `Signal.map` / `List.tabulate` bind Int. The lambda body must return the kit result: `View` (`View.each` / `Ui.run`), `Bool` (filter / find / findLast / exists / takeWhile / dropWhile / forall / indexWhere / lastIndexWhere / span / partition / prefixLength / segmentLength / `Map.filter` / `Map.exists` / `Map.forall` / `Set.filter` / `Set.exists` / `Set.forall`), `Int` (`List.sortBy` / `List.maxBy` / `List.minBy`), `Int` or `String` (`List.groupBy` / `List.distinctBy` / `Set.map`), the mapped element (`List.map` / `List.tabulate` / `Map.mapValues` / `Stream.map`), `List` (`List.flatMap`), `String` (`Signal.map`; Int stringifies), or `IO` (`Resource` / `Net` / `Stream.evalMap` / `IO.foreach` / `IO.foreachDiscard`). `View.wrap(…)` lays out children left to right. A child that does not fit the remaining width starts a new run. Wrap sizes to the runs. `View.grid(n, …)` lays out children in `n` columns (`n` < `1` is one column). A new row starts after `n` shown children. Bounded width uses equal column slots. Height sizes to the rows. `View.scroll(child)` pans on y. Content lays out with unbounded height. Wrap a scroll list in `View.expanded(…)` inside a Column so it fills leftover height. `View.scrollH(child)` pans on x. Content lays out with unbounded width. Height sizes to the child. `scroll N dy` pans that scroll on its axis. In a Row, `View.expanded` takes leftover width. Scroll content is unbounded on the pan axis, so a Row inside a List keeps an intrinsic height. Expanded flex slots are tight. `View.stretch(child)` tightens the cross axis in a Column (width) or Row (height); the main axis stays intrinsic. Column and row do not stretch non-flex children unless wrapped in `View.stretch`. `View.center(child)` fills the max slot and centers the child. `View.align(ax, ay, child)` places the child (`0` start / `1` center / `2` end). `View.stack(…)` overlays children. `View.positioned(x, y, child)` offsets a Stack child. `View.padding(n, child)` insets uniformly. `View.sized(w, h, child)` is a tight slot. `View.minSize(w, h, child)` raises min size (`0` = no floor on that axis). `View.maxSize(w, h, child)` lowers max size (`0` = no cap on that axis). Incoming max still wins when tighter. `View.clip(child)` clips paint to the clip frame. Scroll uses the same clip. Do not add constraint-overflow dumps. `View.opacity(pct, child)` scales paint alpha (`0` = transparent, `100` = opaque). Nested opacity multiplies. `View.maxLines(n, child)` keeps at most `n` wrapped text lines (`0` = no cap). Nested caps take the tighter value. A11y still dumps the full string. Buttons and TextField stay one line. `View.ellipsis(child)` keeps extra lines off the paint. Without a positive `maxLines` it keeps one line. With `maxLines` it paints `...` on the last visible line when more text remains. A11y still dumps the full string. `View.textColor(color, child)` paints `View.text` / `View.bindText` with `color`. Nested `textColor` uses the inner color. Buttons and TextField stay on the theme. `View.gap(n, child)` sets Column/Row/Wrap/Grid/List spacing to `n` px (`0` = none). Nested `gap` uses the inner value. Without `View.gap`, Column/Row/Wrap/Grid/List use the theme gap. `View.fontSize(n, child)` sets `View.text` / `View.bindText` measure and paint size (`n` px, min `1`). Nested `fontSize` uses the inner size. Buttons and TextField stay on the theme font. `View.editor(sig)` paints a multiline buffer on `sig`. Insert and delete at the caret include newline and tab (two spaces). A11y dumps one `editor:editor` node. The dump uses `[editor]`, not `[fields]`. `View.split(frac, start, end)` is a row with a drag handle. `frac` is 0–100. `[splits]` dumps `N frac=F`. `View.overlay(open, child)` fills the parent when `open` is not 0. Compose it on `View.stack`. Escape and a backdrop tap write 0. Keys go to the overlay subtree while it is open. `[overlays]` dumps `N* open=0|1`. `View.focusGroup(child)` sizes to `child`. A tap on a descendant tap target focuses that list. ArrowUp / ArrowDown move among sibling taps when no overlay is open. Enter / Space activate the focused row. An open overlay still takes keys. `[session]` dumps `focus=button: