Skip to content

SJON is the DSL you don't have to write

Every domain tool eventually grows a little configuration language, and then has to write the lexer, the parser, the formatter, and the error messages to go with it. I wrote that machinery once, so you only have to bring the vocabulary. The result reads the same to the person reviewing the file as to the model that wrote it.

Config files start simple and then they stop being simple. You reach for JSON, and within a month you want a comment saying why the timeout is 250 and not 200. You want 250ms to stay a duration instead of collapsing into a bare number that some later reader has to guess the unit for. You want the file to say ortho and have something tell the author when they type orhto. JSON offers none of that, so the tool grows a language of its own, and that language needs a lexer, a parser, a formatter, spans, and error messages. Every domain tool ends up writing its own copy.

Here is the smallest SJON worth reading. It is also the first thing the tutorial puts in front of you, and it carries this whole page from here.

(camera :ortho :zoom 2)

Read it as a tree:

(camera :ortho :zoom 2)
| | |
| | +-- kvpair: key `:zoom`, value `2`
| +--------- positional flag: the keyword `:ortho`, standing alone
+----------------- head: the symbol `camera`

Three parts, and each one earns its spelling. camera is the head, a bare symbol, and it means nothing at all until a schema says what a camera is. :zoom 2 is a kvpair, because a keyword followed by a value pairs with it. :ortho is a flag, because a keyword followed by another keyword stands alone. That rule is structural rather than lexical, which means the same :ortho token is a flag here and would be a key in (camera :ortho 2). It is the one piece of the syntax that catches people out, so hold on to it.

Every piece of machinery in SJON gets pointed at that camera, and the tutorial grows it a lesson at a time. Here it is at the end, carrying all of them at once.

;; The wide shot. 2 keeps the whole 1920 plate on screen.
(camera :name wide :projection ortho
:zoom 2
:delay 4b
:alpha (smoothstep 0 1 t))

Nothing there is a new kind of thing. It is the same head, the same keywords, the same numbers, with four claims attached:

  • Comments survive. The ;; line is captured as trivia hanging off the form, so a lossless round-trip hands it back byte for byte. It is not a thing the parser throws away and the formatter guesses at.
  • Units stay attached to their number. 4b is four beats, not the number 4. It stays a duration through the printer, through the binary encoding, and into JSON as {"$num": [4, "b"]}. Same for 90deg, 50%, and 250ms.
  • Expressions are opt-in and bounded. (smoothstep 0 1 t) runs only because the host allowed smoothstep and supplied t. There are no imports, no I/O, no mutation, and no recursion to allow.
  • :ortho turned into :projection ortho. I said the pairing rule was structural. This is what that costs and what it buys. The flag was fine while a camera was only ever orthographic; the moment there is a second projection, the projection wants to be a key whose value gets checked, and changing the spelling is the entire edit.

The camera above is still just shapes. What makes :projection ortho mean something, and a misspelling of :zoom mean nothing, is a plugin: one declaration of the forms and keys your application accepts.

scene.sjon
(plugin :name scene :version "1.0.0"
(value-kind :name projection :underlying symbol
:members (member-set :values [ortho perspective]))
(value-kind :name duration :underlying number
:unit (unit-shape :required true :allowed [s ms b]))
(form :name camera
(key :name name :type symbol)
(key :name projection :type projection)
(key :name zoom :type number :optional true)
(key :name delay :type duration :optional true)
(key :name alpha :type number :optional true)))

Notice what that is written in. The schema is SJON, parsed by the same parser, so there is no second grammar to learn and no way for the checker to drift from the description. The duration kind is doing real work: it is why :delay 4b is accepted and :delay 4 is not.

Misspell one key and run the checker. This is the actual output, not a mockup of one:

sjon validate --format=rich scene.sjon
error[unknown_key]: unknown keyword `:zom` in form `camera`
┌─ scene.sjon:16:3
16 │ :zom 2
│ ^^^^
at camera/zom (phase: validation)
note: Did you mean `:zoom`?
help: https://hugodaniel.com/pages/sjon/errors/unknown_key
1 error

Four things ship with every diagnostic: a code, a severity, a source span, and a semantic path saying where in the document you are. The help: line is a page on this site, and there are 130 of them. The codes are appended and never renamed, so a code you match on today keeps meaning the same thing in the next release.

Before you scroll, try predicting one. Given the schema above, what does (camera :name wide :projection isometric :zoom 2) report? The code is not_member, and the message spells out the answer for you: expects `projection`, got `isometric` (allowed: `ortho`, `perspective`).

The honest half. SJON is deliberately small, and some of what it will not do for you is the point rather than a gap waiting to be filled.

It is not a programming language

No imports, no I/O, no mutation, no recursion, and no way for a document to reach out and touch the world. Parsing a SJON file costs what parsing data costs, which is what makes accepting a file you did not write a reasonable thing to do.

It is not a wire format

JSON is still the best thing to send between systems, and SJON bridges to it cleanly. SJON is what you reach for one step earlier: the source file somebody is actually editing, where comments, units, and source order still matter.

It does not know your domain

The package stops at shared document machinery: parser, validator, printer, expression evaluator, editor reducer, JSON bridge, binary reader. What camera means, and what your program does with one, stays yours.

The same source text parses identically from Zig, JavaScript, or Rust, so your editor and your runtime never disagree about a file. Only the loading differs.

const sjon = @import("sjon");
var tree = try sjon.parse(gpa, source);
defer tree.deinit();
const text = try sjon.print(gpa, tree, .{ .mode = .canonical });
defer text.deinit();

Those three agree because they are the same code. The Zig core compiles to WebAssembly, and the Node and Rust hosts drive that module; a fourth host is a native TypeScript port kept honest against the others. On every build a conformance corpus replays across all four, which is the only reason I am willing to say “identically” out loud. Two groups of cases are skipped on the WebAssembly hosts, and each one is named with its reason beside the skip list rather than quietly dropped.

The parser runs once and builds a single structure-of-arrays tree. Validation, printing, JSON, binary encoding, structural edits, and expression evaluation all read from that one tree.

source.sjonLexerParserAst.TreePrinterValidatorExprJSONBinaryEdit
Lexer and parser on the way in, one Ast.Tree in the middle, six consumers reading it.

That is not an implementation detail you can skip past, because it is what holds the rest of this page together. The ;; comment on the camera is trivia the parser hung on that form, which is why the lossless printer can put it back exactly where it was and a structural edit cannot strand it. The :zom typo had exactly one span, which is why the checker, the squiggle in your editor, and the replacement an agent applies all point at the same four bytes. Two tools cannot disagree about a document when there is only one copy of it.

A growing share of configuration is not typed by a person any more. It is generated by a model and reviewed by one, and SJON is built for that author too. Hand it the schema, let it write the document, and the validator answers with something it can act on without a human in the loop. Point a model at llms.txt to prime it on the whole language in a couple of thousand tokens.

The schema is the prompt

The schema above is not a description of the format, it is the format. Paste it into the prompt and the model has your domain’s grammar, and it cannot drift from the checker, because the checker reads the same bytes.

The error is the instruction

unknown_key at camera/zom, span 16:3, replacement :zoom. That is not a hint for a human squinting at a terminal. It is an edit an agent applies and re-validates, and then the loop terminates because the codes are finite.

Generated SJON is read, never executed

The sandbox is the same one your own authors write in. A document a model wrote cannot do anything a document you wrote could not do, which is a much shorter security review than the alternative.

Fifteen lessons, all built on the camera you have already met. It starts with that one form and grows it: atoms, units, expressions, schemas, cross-references, and finally reading a diagnostic and repairing the file it came from. One small file at a time, nothing to install.

If you only want the shape of the language, Orientation through Comments and strings are enough and you can stop there with a clear conscience. Safe expressions and Bindings and control flow are where the arithmetic shows up and I have to justify calling it safe. From Reading plugin schemas on, you are in the schema half, the half that makes the diagnostics good.

  1. 01OrientationWhat SJON is, what it isn’t, how it differs from JSON.
  2. 02Your first documentForms, keywords, vectors, and the canonical printer.
  3. 03Atoms and intentSymbols, strings, numbers, booleans, nil. When to reach for which.
  4. 04Numbers, units, vectorsUnit suffixes, vector literals, when units are mandatory.
  5. 05Forms and keyword pairingHow keys parse, how positionals work, how to read a (form …).
  6. 06Comments and stringsLossless comments, escape rules, raw strings.
  7. 07Safe expressionsParens that compute. The closed expression vocabulary.
  8. 08Bindings and control flow(let …), (if …), (cond …). Bounded, deterministic, no recursion.
  9. 09Reading plugin schemasForms, keys, required/optional, defaults, positional policy, open forms, schema export.
  10. 10Discriminated and exclusive formsOne head with variant shapes; exclusive groups; multi-key bundles.
  11. 11Value kinds: shapes, vectors, units, bounds, representationUnderlying shapes, vector shapes (fixed and variable length), unit shapes, numeric bounds, and representation tags.
  12. 12Value kinds: strings, members, heads, unions, slot-local formsString bounds, member sets, head sets, unions, slot-local forms, and the diagnostic cheat sheet.
  13. 13Cross-referencesDocument-spanning name lookups; acyclic constraints.
  14. 14Diagnostics-driven repairThe stable diagnostic codes as a repair workflow. Read, repair, repeat.
  15. 15Style, portability, capstoneCanonical formatting, manifest portability, a capstone exercise.