jm — Odin for scripts
A collection of small packages and one runner that make Odin comfortable for
the scripts Python and bash usually get. Everything builds on core:. The
only system library is libcurl through vendor:curl; SQLite and the wasm3
interpreter are vendored and linked statically, so a script that uses either
still installs nothing.
#!/usr/bin/env odin-run
package main
import "core:fmt"
import "jm:prelude"
import "jm:sh"
import "jm:path"
must :: prelude.must
die :: prelude.die
main :: proc() {
context = prelude.init()
branch := must(sh.out("git rev-parse --abbrev-ref HEAD"))
for f in must(path.walk(".")) {
fmt.println(branch, f)
}
}
chmod +x and run it. The first run compiles; later runs start the cached
binary.
Packages
| Package | Job |
|---|---|
prelude |
must, die, env, args; arena or debug allocator; logfmt log file plus deaths.log audit trail |
sh |
out, lines, ok, run, capture through the shell; exec, exec_run with argv; which, quote, error |
http |
get, post, post_json, get_json, download, request over libcurl |
path |
expand, join, mkdirs, read, read_lines, write, append_file, list, walk, temp_dir, same, per-user app dirs |
timefmt |
strftime format, local, parse; iso, stamp, date, duration |
debug |
guard-byte debug allocator (origin: sonar, which now imports this copy) |
flow |
width, each, manage: lock-free worker pools where each worker owns one state slot and the caller merges afterwards |
tar |
read, extract: git archive output without a tar program |
sqlite3 |
open, exec, exec_args, query/next, prepare, transact over a statically linked SQLite |
wasm |
open, load, find, call, link, run: WebAssembly through a statically linked wasm3 |
ui |
immediate-mode UI: ops → flatten → draw and hit lists; layout, theme, widgets, a Probe that clicks and types without a window |
ui/render |
executes a ui.Frame on Blend2D (vendored binding in ui/blend2d), shapes text with it, and snapshots a ui proc straight to a PNG |
ui/sdl |
the SDL3 window and event loop for a ui app; run_host runs the same window against a subprocess instead of a local ui proc |
ui/ipc |
length-prefixed frames over a pipe, and spawning a child process wired up for exactly that — the transport under ui/sdl’s host/subprocess split |
ui/child |
the subprocess half of that split: owns the Model, the ui proc, Router and Layout, and speaks ui’s wire format over its own stdin/stdout |
ui/diagram |
titled, accent-bordered groups of chips and arrows (solid or dashed) for an architecture diagram, over plain ui calls |
ui/material |
Material 3 Expressive on ui: the colour scheme, type scale, shape, motion springs and state tokens generated from the m3e-kit into ui/material/tokens, Material Symbols icons as paths, and the components (buttons, text fields, selection controls, chips, cards, lists, navigation, app bars, tabs), each able to paint any spec state on demand |
ui/testutil |
count_ops: assertions a ui package’s own tests and a downstream package’s tests both want, without an import cycle |
pg_query |
parse, split, is_utility, fingerprint, normalize: PostgreSQL’s own SQL parser, statically linked, with node types generated from its schema |
pq |
connect, exec, escape_literal, escape_identifier, identity: a PostgreSQL client over the system libpq, the one dynamically linked library |
pq/testdb |
a throwaway PostgreSQL server on a Unix socket, for tests |
fuzz |
property fuzzing: an entropy Source, generators, format-agnostic damage, shrinking, a corpus, a per-case deadline |
sqlite3/fuzz |
the jm:sqlite3 suite for jm:fuzz |
tar/fuzz |
the jm:tar suite for jm:fuzz |
wasm/fuzz |
the jm:wasm suite for jm:fuzz, with a small Wasm encoder to build cases from |
pg_query/fuzz |
the jm:pg_query suite for jm:fuzz, with a SQL generator to build cases from |
pq/fuzz |
the jm:pq suite for jm:fuzz, against the pq/testdb server |
ui/render/fuzz |
the jm:ui/render suite for jm:fuzz: composed frames checked against whole renders |
tools/odin-run is the runner. Every package reads on its own; the doc
comment at the top of each file is the reference.
Conventions
- Scripts never free.
prelude.initputs a growing arena incontext.allocator; the exit reclaims it.ODIN_SCRIPT_DEBUG=1swaps in the debug allocator, which reports overflow, double free and write after free at exit. - Anything that can fail returns
(value, ok)or(value, os.Error)somust(...)wraps it.diewrites the message and the call site to the script log, to stderr, and to one shareddeaths.log. preludenever writes to stdout. Stdout is the script’s data channel.- Logs:
<log dir>/odin/<name>/<name>.log, rotated to.1at 8 MiB, and<log dir>/odin/deaths.log. The log dir is~/.local/stateon Linux,~/Library/Logson macOS and%LOCALAPPDATA%on Windows.ODIN_LOG=debugechoes everything to stderr; the default echoes warning and above. - Platform splits live in
_unix.odin/_windows.odinfiles.just checktype-checks every package forlinux_amd64,darwin_arm64andwindows_amd64from one machine.
Runner
odin-run script.odin [args...] compile if stale, then run
odin-run -clean drop the cache
Cache key: script bytes and path, every .odin in the collection, the odin
binary, target and flags. ODIN_RUN_FLAGS="-debug" builds unoptimised;
ODIN_RUN_VERBOSE=1 prints the build line. On Windows the shebang does
nothing; call odin-run script.odin or associate .odin with it. When
vendor/curl/lib/libcurl.dll exists under the Odin root it is copied beside
the cached binary.
Recipes
just build debug odin-run just release optimised odin-run
just test all package tests just check 3-target type-check
just install odin-run -> ~/.local/bin (BINDIR overrides)
just sqlite compile the vendored SQLite just wasm compile wasm3
just pg_query compile the vendored libpg_query
just pg_query-gen regenerate pg_query/nodes.odin from the vendored schema
just blend2d compile Blend2D into ui/blend2d/lib (BLEND2D_SRC overrides)
just kitchen open the jm:ui demo just kitchen-dump its first frame as text
just kitchen-png render it to build/kitchen.png
just material-kitchen build and open the hot-reloaded M3 kitchen
just material-png page=Chips render one M3 kitchen page to build/
just material-tokens regenerate ui/material/tokens from the m3e-kit (M3E_KIT)
just material-shapes regenerate ui/material/shape_data.odin from the m3e-kit's morphs
just bench-ui ms per frame for layout and the Blend2D executor
just hot-counter-child build the subprocess `just test`'s own host/child test spawns
just example run examples/hello.odin just fuzz 30s of fuzzing
just clean drop build/ and the four archives
just install bakes this checkout’s path into the runner as the jm
collection root; ODIN_RUN_COLLECTION overrides it.
SQLite
sqlite3/vendor/ holds the SQLite 3.53.4 amalgamation (sqlite3.c and
sqlite3.h, source id bf7c7f30031888f4e796e429ab3978879485813aaca6f641c7b33e4e09459bcc),
taken from sqlite.org and verified against the SHA3-256 that page publishes.
SQLite is public domain, so vendoring it carries no licence obligation.
just sqlite compiles it once into sqlite3/lib/sqlite3.a, which is
gitignored and rebuilt when the amalgamation changes. foreign import
resolves that archive relative to the package directory. odin check never
opens a foreign import, so just check still type-checks all three targets on
one machine with no archive built.
The compile options are sqlite.org’s recommended set, with three deliberate departures, all of them in the justfile:
SQLITE_THREADSAFE=1, not the recommended0.jm:flowexists, and a connection per worker has to be safe.SQLITE_OMIT_AUTOINITis not set, though it is recommended. With it, any call made beforesqlite3_initializeis a segfault rather than an error.SQLITE_ENABLE_FTS5is added, for a full-text index, andSQLITE_OMIT_LOAD_EXTENSIONkeeps the link from needing libdl.
One trap is worth knowing even though the package handles it: the bytes behind
a text or blob column are freed on the next step, and SQLite reuses its own
pool rather than returning them to libc, so reading a stale pointer yields the
next row’s data instead of crashing. AddressSanitizer cannot see it. That is
why text and blob clone into the allocator the query was given.
WebAssembly
wasm/vendor/ holds wasm3 0.9.1 (commit c036c43), the interpreter’s
source/ tree minus the two files this build does not compile: the uvwasi
backend, which needs libuv, and the meta-WASI one, which is for running wasm3
inside wasm3. wasm3 is MIT, and wasm/vendor/LICENSE is its copy.
just wasm compiles it once into wasm/lib/wasm3.a, which is gitignored and
rebuilt when any vendored source changes. As with SQLite, foreign import
resolves that archive relative to the package directory, and odin check
never opens it, so just check still type-checks all three targets on one
machine with no archive built.
The build takes wasm3’s defaults, which already have bytecode validation and
gas metering on, and adds one option: d_m3HasWASI, without which run has
no _start to call. That is an interpreter, not a JIT — several times slower
than Wasmtime on arithmetic, and in exchange it is 550 KB of portable C with
nothing to install and no executable pages to allocate at runtime.
One limit is worth knowing before reaching for jm:flow: wasm3 is not
thread-safe, and not merely per runtime. Eight threads, each with its own
environment, runtime and copy of a two-instruction module, still produce
spurious traps — a stack overflow, an out of bounds access — roughly five
times in sixteen hundred calls. That was reproduced in C against this archive,
with no Odin involved, so it is wasm3’s own state and not the binding’s.
jm:wasm is therefore a one-thread package; interrupt is the only call
meant to cross a thread, because it does nothing but set a flag. just test
runs this package’s tests with the test runner on a single thread for the
same reason.
wasm/fuzz is the suite: six properties over generated modules, damaged ones
and bytes that were never a module, with a small Wasm encoder so a case needs
no toolchain. just fuzz "wasm -for=1m" runs it; the section below records
what it found.
Speed
just bench times jm:wasm against the four workloads in
tools/wasm-bench/workloads, compiled from the C beside them by
just bench-build. Each exports run(i32) -> i32: the argument scales the
work linearly and the result is a checksum, so the same module can be run
through another engine and checked. The harness reports the load, the first
call — which is where wasm3 compiles the body — an empty call, and the
workload, and subtracts run(2n) - run(n) so that only what scales with the
work is left. On this machine:
module load first call work
fib 14.5µs 2.8µs 138ns 219µs per unit
mandel 10.5µs 7.4µs 118ns 481µs
memsum 10.8µs 5.3µs 106ns 2.3ms
sortbench 9.9µs 7.9µs 102ns 9.5ms
A module is instantiated in about ten microseconds and a call into it costs
around a hundred nanoseconds, which is what an embedded interpreter is for.
The work is another matter. The same four modules, at a fixed size — run(4096),
run(2048), run(512) and run(128) — through every engine that could be
made to run them, each one a command that loads the module, calls run once
and exits. Milliseconds of work, fastest of three, lowest first; first run
is what the whole process costs at run(1):
| engine | fib | mandel | memsum | sort | first run |
|---|---|---|---|---|---|
| wasmtime 49.0.1, Cranelift JIT | 84 | 178 | 52 | 158 | 2.9ms |
| Node 26.8.1, V8 JIT | 93 | 316 | 80 | 309 | 15.6ms |
| wazero 1.12.0, Go compiler | 125 | 193 | 130 | 380 | 2.0ms |
| WAMR 2.4.5, default (JIT) | 160 | 370 | 71 | 301 | 18.7ms |
| jm:wasm (wasm3 0.9.1) | 820 | 897 | 1200 | 1140 | 1.0ms |
| wasm3 0.9.1, its own CLI | 920 | 937 | 1367 | 1236 | 1.1ms |
WAMR 2.4.5, --interp |
2643 | 6528 | 2806 | 6437 | 20.2ms |
Three things are worth taking from it. The bindings cost nothing: the same modules through wasm3’s own CLI come out a few percent slower, so what is measured above is the interpreter, not the crossing. Against a JIT the interpreter is five to ten times slower on arithmetic and twenty times on memory traffic, which is the price of the 550 KB of portable C and the one millisecond to first execution — the fastest start of anything measured here, where a JIT pays two to twenty milliseconds before it runs a thing. And among interpreters wasm3 holds up: WAMR’s classic interpreter, the other embedded standard, is three to seven times slower than it on the same modules. WAMR’s default mode is not an interpreter at all, which is what makes it look fast in the fourth row.
Every engine returned the same checksum for every workload, which is what
makes the columns comparable. The figures are the fastest of three runs on an
otherwise idle machine and repeat to within about fifteen percent, so the
orders of magnitude are the point and the last digit is not. “The bindings
cost nothing” is the same measurement against a C program driving
wasm/lib/wasm3.a directly: 800ms against 923 on fib, 1166 against 1153 on
memsum — the two are inside each other’s noise.
PostgreSQL
pg_query/vendor/ holds libpg_query at commit 6e764b79 of the
17-latest branch: PostgreSQL’s own gram.y and everything it needs, lifted
out of the server source tree, carrying the 17.7 grammar. Vendored is the
src/ tree, protobuf/pg_query.pb-c.{c,h}, the two third-party directories
under vendor/ and srcdata/, which is the schema the node types are
generated from; left out are upstream’s tests, generator scripts and the
optional C++ protobuf path. libpg_query is BSD-3-Clause, and
pg_query/vendor/LICENSE is its copy.
just pg_query compiles it once into pg_query/lib/pg_query.a — 86
translation units, about twenty seconds, 5 MB — which is gitignored and
rebuilt when any vendored source changes. As with SQLite and wasm3,
foreign import resolves that archive relative to the package directory and
odin check never opens it, so just check still type-checks all three
targets on one machine with no archive built.
The flags are upstream’s Makefile exactly, less its -g and at -O2 rather
than -O3, and they are in the justfile. Two of them are correctness rather
than taste: the PostgreSQL sources are written against -fno-strict-aliasing
and -fwrapv and miscompile without them.
The protobuf objects are compiled although this binding only wants the JSON
API, which is not for want of trying: pg_query_parse.c defines
pg_query_parse_protobuf beside pg_query_parse, so the object that holds
the one entry point we call also references the protobuf writer, and an
archive without it fails to link. Measured, then written down in the recipe.
Typed nodes
A parse tree comes back as the JSON libpg_query wrote and as typed Odin
nodes, and the nodes are generated rather than written by hand.
vendor/srcdata/ is libpg_query’s own schema — the input it generates its Go,
Ruby and Python bindings from — and pg_query/gen turns the four sections
that describe parse nodes into pg_query/nodes.odin: 267 structs, 63
enumerated types and a tag-dispatched decoder. just pg_query-gen
regenerates it; the file is checked in, so nothing normally runs the
generator.
267 rather than the 474 in nodetypes.json, and the difference is not a
subset taken for convenience. struct_defs.json describes nodes in sixteen
sections; four of them — nodes/parsenodes, nodes/primnodes, nodes/value
and nodes/pg_list — are what a parse tree can contain, and they hold 267
structs between them. The rest describe planner and executor nodes, which
exist only in a tree the server has already analysed and which
pg_query_parse never emits. The fuzz suite is what says so rather than this
paragraph: an unknown tag fails a parse, and a run that meets one fails.
Typing them is the point rather than a convenience. Dropping the schema would
not remove it, only make it implicit: a field PostgreSQL renames in its next
major would stop decoding silently, and a caller asking “does this statement
write?” would be told no because the field it looked for was absent rather
than because the statement was harmless — a failure that fails open.
Generated from the schema, the same rename fails to compile, and
schema_conforms in the tests fails before that: it loads the vendored
struct_defs.json at compile time and holds every node and field the Odin
side names against it.
The decoder dispatches on the tag rather than trying variants in order. Every
node arrives as a single-key object — {"UpdateStmt": {…}}, {"BoolExpr": {…}} — so a union matched structurally would match whichever variant was
declared first and hand back the wrong node, silently. A tag this build has no
struct for fails the parse, for the same reason a missing field would: a gate
must not be handed a tree with a hole in it.
Fields are plain and left at their zero value when absent, because
libpg_query omits anything false, zero or empty; "inh":true is written and
"inh":false is not. Two shapes are written by hand in the C rather than
generated from the schema — A_Const, whose value arrives under ival,
fval, boolval, sval or bsval, and the bare RawStmt at the top level
— and both are special-cased in decode.odin and named in the generator.
Two traps, both handled, both worth knowing:
- Every
char *in a result dies at itspg_query_free_*, which releases a whole memory context. A pointer held past that reads memory the next parse reuses — thesqlite3_column_texttrap in another dialect — so everything the package hands back is cloned first. pg_query_exitis the one entry point deliberately left unwrapped.pg_query_initregisters a pthread destructor over the same top memory context, so a thread that calls it and then ends frees that context twice and the process aborts in glibc. One worker thread, one parse, no concurrency needed; reproduced in C against this archive. Let the thread end and the destructor does it.
Parsing itself is thread-safe, unlike wasm3: the memory contexts are
__thread, and eight threads over the same statements agree on every tree
through 144,000 parses in C and 9,600 in pg_query_test.odin.
Statements must be valid UTF-8, and the package refuses one that is not with
the offset of the first bad byte. That is not tidiness — see what the fuzzer
found, below. normalize’s output is for showing a human and not for
re-parsing, for a reason recorded there too.
pg_query/fuzz is the suite: eight properties over generated SQL, damaged
SQL and bytes that were never SQL, with a SQL generator so a case needs no
fixtures. just fuzz "pg_query -for=1m" runs it.
libpq
jm:pq talks to a PostgreSQL server over libpq, and it is the one C
library in the collection that is not vendored: it links the system’s
libpq.so.5 dynamically, and a built script needs it at runtime. Every other
binding here vendors its C because that C builds with a plain cc and nothing
else. libpq does not — it is a slice of the PostgreSQL tree with its own
configure step — and it brings OpenSSL and GSSAPI with it for TLS and
Kerberos, which a machine should keep patched on its own schedule rather than
have frozen into every script. What makes the exception safe is libpq’s
record: libpq.so.5 has kept its ABI since 2006 and is on every machine with
a PostgreSQL client, psql included. Built and tested against libpq 18.6;
Windows links libpq.lib and is untested, like every Windows build here.
So there is no just recipe for it and nothing under pq/lib. foreign import "system:pq" is resolved by the linker, and odin check never opens a
foreign import, so just check type-checks all three targets on a machine
without libpq installed. Only building and testing need it.
Tests need a server, and bring their own. jm:pq/testdb runs initdb
into a fresh directory under the temp location and pg_ctl start on it, once
per process: a Unix socket in that directory, listen_addresses='' so nothing
listens on TCP, trust authentication behind a socket only this user can
reach. It clears every PG* variable — a shell exporting PGHOSTADDR or
PGSERVICE for a real database would otherwise send a test there — and sets
PGHOST, PGPORT, PGUSER and PGDATABASE to the throwaway, which is also
what pq.connect("") reads. Bringing it up takes about half a second. It
leaves a shell loop behind that waits for the test process to be gone, however
it went, and then stops the server and deletes the directory; an @(fini)
would miss an os.exit, a failed assertion and a sanitizer abort. No Docker,
and no server needs to exist beforehand: on this machine initdb and pg_ctl
come with the postgresql package. Where they are not on PATH, every test
that needs a server logs why and skips, and jm-fuzz pq says so and runs no
cases, so the other suites keep running without PostgreSQL.
pq/fuzz is the suite: six properties against that server, each case on its
own connection as an unprivileged role with a statement_timeout.
just fuzz "pq -for=1m" runs it.
Fuzzing
jm:fuzz runs properties against generated input and tells you the smallest
case that broke one. A property is handed a subject and a Source, draws
whatever input it wants, and says whether the promise held. The package does
the rest: seeding, budgets, an arena per case, shrinking, the corpus, and a
deadline on a case that will not finish.
just fuzz 30 seconds of every suite
just fuzz "tar -for=5m" one suite, longer
just fuzz "sqlite3 -seed=12345" replay a reported seed exactly
just fuzz "-corpus=DIR" keep failures somewhere else
just fuzz-isolate five minutes, a child process per case
just fuzz-asan the same under AddressSanitizer
Each suite keeps its regressions beside its source, in <suite>/corpus, and
replays them before generating anything. just test runs a few hundred cases
of each suite on fixed seeds and replays those corpora, so a bug found once by
fuzzing stays found without anyone running the fuzzer again.
A case’s randomness is a finite byte string, and every generator draws from it. That one decision is what the rest rests on: a case is a pure function of its bytes, so it replays exactly, it can be written to disk as a regression, and it can be shrunk by simplifying the bytes and running it again. Generators are written so a zero byte asks for the simplest value they can give, which is what shrinking converges on.
Writing a suite means naming a subject, how to make and unmake one, how to cancel work in flight, and the promises:
properties := []fuzz.Property(Sandbox){{"no_escape", no_escape}}
suite :: proc() -> fuzz.Suite(Sandbox) {
return fuzz.Suite(Sandbox) {
name = "tar", setup = open, teardown = shut, properties = properties,
}
}
Three things in it were each put there by something that went wrong:
- Shrinking, because a failure arrived as an 80-byte blob when one byte was enough. A found case shrinks to its simplest form before it is reported.
- A corpus, because a bug found once should be a test from then on. With
-corpus, the case about to run is also written out before it runs, so a property that takes the process down — a panic, a failed bounds check, anything the harness cannot catch — leaves the input that did it on disk. That is how thejm:tarcrash below was captured. - A deadline, because nothing in SQLite bounds how long a statement runs
and a damaged recursive CTE returns rows for ever. In this process a suite
has to supply
cancelfor that to work;-isolatelifts the restriction, below.
Isolation
-isolate runs each case in a child process. The child is the same binary,
re-run with the case named in its environment, so a program that calls
fuzz.run is its own child with no extra wiring.
It costs a spawn per case. Measured with
just fuzz "tar -for=5s -no-corpus" against the same run with -isolate,
this machine does about 16,200 cases a second in process and 1,070 isolated
— fifteen times slower — which is why it is off by default. What it buys:
- A crash is a result, not the end of the run. A panic or a failed bounds
check comes back as
Crashed, carrying whatever the child wrote to stderr, and the next case starts from a clean process. - The deadline works for any subject. A child is killed whether or not it
cooperates, so a suite over a parser with no interruption point —
jm:tar, whose loop has nothing to check — gets a deadline for free. Nocancelneeded. - Shrinking still applies, so a crash shrinks to its smallest case like any other failure.
Use it for unattended runs. In process is the right default for the quick
pass that just test and a 30-second just fuzz do.
One trap the sqlite3 suite exists to catch: its properties read every row
before comparing any of them. Comparing inside the loop passes even when a
column read hands back SQLite’s memory instead of a copy, because the bytes
have not been reused yet. Deleting the clone from sqlite3.text leaves the
example-based tests green and fails one case in three here.
What it found
Wiring jm:tar up as the second suite turned up three ways to crash the
parser on a malformed archive, all now fixed, all with regression tests in
tar/tar_test.odin:
- A size field in the base-256 form can name a number larger than an
intholds. The shift wrapped, the size came back negative, andreadsliced the archive backwards. - A pax record whose claimed length did not reach past its own length field
made
readslice backwards too."1 "was enough. - Guarding the size field was not enough on its own:
readthen adds the header’s offset to it, and a size ofmax(int)made that wrap. The check has to be a subtraction. This one is alsotar/fuzz/corpus’s first entry: the shape needs a dozen specific bytes, so the corpus rather than the fuzzer is what keeps it tested.
jm:wasm was the third suite, and its bounds property found the same bug in
a different package on its first run. wasm.bytes is what stands between a
guest’s chosen pointer and the rest of the process; it checked the range by
adding ptr + size, so a size near the top of int wrapped the sum back down
into the memory, the check passed, and the slice that followed had a negative
length. wasm.bytes(mod, 8, max(int)) was enough. It subtracts now — what is
left of the memory after ptr, which cannot wrap — and wasm_test.odin keeps
the case. Two packages, written days apart, got the same arithmetic wrong in
the same place; a property that draws pointers at the edges finds it in
seconds, and no example-based test here had.
jm:pg_query was the fourth suite, and it found two things in its first
runs. error_sane bounded cursorpos by the length of the statement; a
statement cut short faults at one past the end, which is how PostgreSQL
says “at end of input”. That one was the property being wrong, and it is
written down in the package now rather than left to be rediscovered.
The second was real. split_covers took the process down on raw bytes after
ten thousand cases, inside core:encoding/json. libpg_query sets the scanner’s
encoding to UTF-8 but does not validate its input against it, so
SELECT '<0xff><0xfe>' FROM t parses and those bytes are copied into the parse
tree verbatim — making the tree JSON that is not UTF-8. Decoding that walks
unquote_string off the end of its buffer, because an invalid byte decodes as
one byte and re-encodes as the three of U+FFFD. jm:pg_query now refuses a
statement that is not UTF-8 before the parser sees it, which is what a
PostgreSQL server with a UTF-8 database does anyway.
A third, under the sanitizer, is upstream’s rather than ours and is written
down rather than fixed: normalize substitutes a parameter over the literal’s
recorded extent without checking that a token boundary survives. A leading
minus belongs to the constant, so SELECT-1 comes back as SELECT$1 — one
identifier, not a statement. normalize_reparses skips that shape and names
it; pg_query_test.odin keeps the case, and the package doc tells a caller
not to re-parse what normalize writes.
jm:pq was the fifth suite. Its first run failed error_sane on every COPY:
the binding refused COPY … TO STDOUT with no SQLSTATE, the mark of a
refusal that never reached the server — but by then the server had started
the COPY. It now carries 0A000, feature_not_supported, which is both true
and something a caller branching on SQLSTATE can match. Nothing else turned
up in 100,000 cases under the sanitizer.
The sanitizer taught something about itself while the tests were written.
values_outlive_the_connection closes the connection and then reads every
string the binding handed back, so that a string still pointing into a freed
PGresult traps. With the clone deliberately removed it passed anyway: the
comparison runs in base:runtime, which is not instrumented, so ASan never
saw the read. The test now walks every byte in its own code first, and with
the clone removed it fails with a heap-use-after-free, as it should.
UI
jm:ui is an immediate-mode user interface whose frame is data. A ui proc
records scene ops, flatten turns them into a draw list and a hit list, and
ui/render executes the draw list on Blend2D while the router hit-tests the
hit list. Every stage is an array with a text dump, so a frame can be
asserted on, serialized (encode) for a renderer in another process, or
driven by ui.Probe with no window at all:
build/debug/ui-kitchen -dump the scene as text
build/debug/ui-kitchen -click Save -names click by tag, list what is on screen
build/debug/ui-kitchen -click name -type Ada -png out.png
Widgets nest through containers with no per-child boilerplate:
col := ui.column(gtx, gap = 8); defer ui.end(&col)
ui.label(gtx, "Name")
ui.text_field(gtx, &m.name)
if ui.button(gtx, "Save") { save(m) }
A zero field in a style struct takes the theme’s value. Clipping is exact for
any shape under any affine: Blend2D clips only to rectangles, so a path or
rotated clip renders through an A8 mask. The Blend2D binding is copied from
odin-blend2d; just blend2d builds its archive from that checkout’s source
(BLEND2D_SRC), and anything linking it needs -lstdc++.
examples/ui-kitchen is the demo, just kitchen opens it.
That “serialized for a renderer in another process” is ui/sdl.run_host:
a host owns the window and renders, a subprocess (ui/child) owns the
Model, the ui proc, Router and Layout, and the two talk ui/wire’s
Input/Reply over ui/ipc’s pipes. Host_App.watch names a pointer file a
builder republishes on every successful build (tools/hot-watch is one);
the host re-reads it and respawns the child on a change, never
overwriting a running executable in place, which Windows refuses.
examples/hot-counter (a two-binary click counter),
examples/hot-architecture (a live-editable diagram of this very
pipeline) and examples/hot-button (every M3 button kind — filled,
tonal, outlined, text, elevated, icon, FAB and its sizes, extended FAB,
segmented, split) and examples/material-kitchen (every ui/material
component, a page each, in all its spec states) are the demos; just hot-architecture and just hot-button build their own host and print
the two commands that run them, and just material-kitchen builds and
opens its own. tools/hot-watch
takes extra directories to watch after the pointer file, so a child
rebuilds when the ui package it imports is edited too.
Checking a change without a window: -dump prints the scene as text,
free of vision tokens and enough for most bugs; -png renders it to a
PNG when the question is actually about pixels; tools/img-diff (over
ui/render’s diff_files) turns two PNGs into a list of changed regions
as text, so confirming an edit changed what it should needs looking at
neither.