the json gauntlet
the fastest way to find out whether a language is real is to make it earn a living. so we ported the job description of go's most-used package — encoding/json — into kanso: a complete decoder and encoder, escape handling including \uXXXX, failure positions, canonical output. it exists, it passes sixteen tests under kanso test, and every judgment call made along the way is recorded here in three honesty tiers: things the exercise proved, executive calls awaiting ratification, and friction we refuse to pretend we didn't feel.
tier 01what the gauntlet proved
dispatch is a parser's native language. a recursive-descent parser is one long "what character am i looking at?" — and kanso's literal-dispatch overloads are that question. the tokenizer's decision tables read like tables:
fn value_for "\"" cs p
parse_string cs (p + 1)
fn value_for "[" cs p
parse_array cs (p + 1)
fn value_for "t" cs p
word cs p "true" true
fn value_for none _ p
fail p "unexpected end of input"
auto-propagation deleted the error plumbing. all of it. the parser contains not one line of "check if the last step failed." a failure born anywhere — bad escape, invalid number, truncated input — rides the return values past every continuation function (whose constructor-pattern arms simply don't match it) and surfaces from decode with its position intact. the happy path is the only path anyone wrote. this was the design's biggest bet, and it paid in full.
nothing-wasted caught real noise. the compiler rejected seventeen dispatch arms for naming parameters they never used, forcing _ discards that now document, in the signature, exactly what each arm consumes. annoying for ninety seconds; correct forever.
end-of-input is not a special case. at past the end returns none, which propagates like any failure and gets caught by explicit none arms exactly where the grammar cares. eof handling cost zero new concepts.
tier 02executive calls — ratify or reverse
each of these is implemented, tested, and reversible. defaults chosen by the house rule: the right thing, by default, with nothing to configure.
1. kanso test. a test is a zero-argument fn test_* returning true. no framework, no assertion dsl — == on values is the assertion, because structural equality is already total. kanso run requires main; check and test don't (a library is valid kanso without an entry point).
2. map literals. ["a": 1 "b": 2], empty map [:]. keys are literals only (dynamic maps are built with put), and literal keys must appear sorted, without duplicates — a formatting error otherwise, consistent with fields and declarations. iteration order is always sorted-key order, so encoding is canonical for free.
3. entries m yields entry records (fields key, value; the name is reserved). map traversal dogfoods records and constructor patterns instead of inventing tuples.
4. numeric strictness. int + float64 is an error, not a coercion; convert with to_float. floats render as 1.0, never 1; float division by zero is err, same as int. JSON numbers decode as int when written integral, float64 otherwise.
5. JSON null is json_null, not none. the honest reason: none is propagation-hostile as data — construct a record with it and the record never gets built, because propagation eats it. that's correct behavior for absence-as-failure and wrong for null-as-value, so null gets a marker type. this points at a real gavel: kanso may want zero-field types (type null with no body) — today a type requires at least one field, so the marker carries a dummy bool. it's the one visibly inelegant thing in the library.
6. the allowed-error / defect split is a word, not a sigil. ruby marks the raising variant with !; kanso would have to double every api to do that. instead, must converts any allowed failure into a defect — two lines of ordinary overloads, composing with every function ever written. parse errors from user input stay handleable; must (decode config) declares "this failing is a bug," and the defect rides the rails to the root reporter. still owed: the endpoint rule treating defect as auto-reported rather than must-be-handled.
7. small additions the work demanded: push (list accumulation), chars/char_code/from_code (the minimal unicode bridge), join, slice, string escapes \t and \r, and type-postfix brackets lexing tight (json[]) while list arguments stay spaced (f [1 2]). all prelude candidates for the import gavel.
tier 03friction — where a developer would sigh
no short-circuit and/or. we wrote both as a two-arm overload and it works, but eager evaluation means it can't guard (both (p > 0) (expensive p) runs both). candidate gavel: lazy and/or words with the same thunk mechanics if already uses.
no negative literals, no modulo. -1 is unwritable (only 0 - 1), and hex4 computes remainders by subtract-multiply. both feel like missing table stakes; both interact with the operator gavel that's already queued.
alphabetical order scatters cohesion. the sixteen tests sort into the middle of the implementation, and helper families stay adjacent only because we named them into adjacency (str_char, str_chars, str_escape...). developers will name-game the ordering rule; that's a signal. modules will absorb most of it — tests want to be a sibling file — but the rule deserves a second look with this evidence in hand.
lambdas can't destructure. encoding map entries needed a named encode_entry (entry k v) where a pattern lambda would have been one line. queued with the destructuring family.
positions blur where none propagates far. most eof arms report exact positions, but a failure that rides many frames before conversion loses locality. the fine-grained-failure story (typeset-based propagation beyond err/none) is the real fix.
the library lives at github.com/ClayShentrup/kanso-json, and runs in kanso's ci on every push — the language now has a second heartbeat.