From the .wadi data model to the .wdl grammar
How the Wadi Design Language (.wdl) grammar relates to the Wadi data model
(.wadi JSON), step by step: what is generated, what is hand-written, the field
types, and the composition rules.
The headline, stated plainly first: the grammar is not auto-generated from the
.wadi JSON. It is a hand-written Langium grammar, designed against the JSON's
shape, with one shared declarative layer (fields) that keeps the JSON schema, the
docs, the forms, and the generic DSL path in sync. The sections below trace the whole
chain against the real files.
The two artifacts, and where truth lives
.wadiis the data-model instance. Its shape is defined by the ZodHouseConfigschema ineditor/src/schema/houseConfig.ts. This is the source of truth for what a house is..wdlis the authored source. Its shape is defined by the Langium grammar inwadi-dsl/src/language/wadi.langium. It is ergonomic syntax for producing a.wadi.- The bridge is the compiler
wadi-dsl/src/generator/toHouseConfig.ts(.wdlAST to.wadiJSON) and its inverse decompilerwadi-dsl/src/generator/fromHouseConfig.ts.
The flow:
.wdl --parse--> AST --compile--> .wadi JSON --validate--> Zod
--resolve--> numbers --> render
The grammar exists to emit ASTs that map cleanly onto the JSON.
Step 1 — The data model: the .wadi JSON as a Zod schema
A .wadi is a HouseConfig: a top-level object with units, site,
houseDefaults, variables, points, grids, configurator, layers,
components, and floors. Each floor holds an objects array, and every object is
one branch of a discriminated union on type
(editor/src/schema/houseConfig.ts):
export const object = z.discriminatedUnion("type", [ room, wall, pillar, beam, … ]);
export const objectSchema = z.union([object, registeredObjectFallback]);
The union has two halves: hand-written branches (room, wall, roof, and so on) and
generated branches imported from schema/generated/objects.generated.ts. That
generated file is where the fields layer feeds the JSON schema.
Step 2 — The fields spine: one declaration, many projections
This is the part that literally starts from a single declaration and fans out. A
primitive declares its shape once as FieldSpec[]. Example,
editor/src/schema/fields/beam.ts:
export const beamFields: FieldSpec[] = [
{ name: "name", kind: "text", required: false, doc: "Label" },
{ name: "x", kind: "coord", doc: "Top-left X", unit: "project units" },
{ name: "width", kind: "extent", doc: "X extent", unit: "project units" },
{ name: "height", kind: "extent", required: false, doc: "Vertical thickness" },
{ name: "z_offset", kind: "coord", required: false },
];
The domain-neutral engine in kernel/fieldSchema.ts projects each field onto
four surfaces:
- Zod source (
fieldsToZodSource). A codegen script,editor/scripts/gen-primitives.mjs, writes it intoobjects.generated.ts, which houseConfig imports into the union. This is howfieldsbecomes the JSON validator and thez.inferTypeScript type. - Docs (
fieldsToDocRows), producing the generateddata-model.md. - Form control (
fieldToFormControl), producing the property-editor widget. - DSL descriptor (via
wadi-dsl/src/generator/descriptors.ts), driving the generic grammar path (Step 6).
One declaration, and the schema/docs/form/DSL cannot drift, because they are all
derived from it. A parity test asserts fieldsToZod equals the generated schema.
The registry of which primitives are generated this way is
editor/src/schema/fields/index.ts (PRIMITIVE_FIELD_DECLS).
Field types (the kind presets)
Field types are two-tier (kernel/fieldSchema.ts):
- Tier 1 (closed): atoms
number | string | boolean | literal; combinatorsunion,list,optional; constraintspositive | nonneg | int | min | max | pattern. This is the only part that is engine code. - Tier 2 (open, data): named presets composed from Tier 1:
kind |
resolves to | used for |
|---|---|---|
coord |
number | x, y, z_offset |
extent |
number > 0 | width, length, height |
nonneg |
number ≥ 0 | thickness |
int |
integer | step counts |
text |
string | name, label |
flag |
boolean or number | enabled-like switches |
enum |
literal union (values from the spec) | direction, roof_type |
A FieldSpec carries name, kind, required (default true; false makes it
.optional()), values (for enum), plus doc-only doc/unit/label. Adding a new
field type is data (a new preset), not an engine change.
Step 3 — The grammar: two tiers
The grammar is deliberately split by a banner into two tiers
(wadi-dsl/src/language/wadi.langium):
- Parametric core (domain-neutral):
Var,Point,Grid/GridLine, theExprformula sublanguage,Configurator,TemplateMeta, and theRawJSON escape. Nothing here mentions a house. To formalize a different domain you keep this tier verbatim. - Wadi vocabulary (the domain):
Units,Site,Defaults,Floor,Room,Wall,Pillar,Beam,Roof, and the rest. This is the only tier you rewrite to retarget the method.
Step 4 — Composition rules (how the grammar nests)
The entry rule mirrors the JSON's containment:
Model: (Import | AssetDecl | Var | Point | Grid | Configurator | LayerDecl | ComponentDef)*
('house' ID '{' … (… | Floor)* '}')?
Two composition facts fall out of this:
- A
.wdlfile is a module. The same top-level declarations are legal both outside and insidehouse { … }, and they merge into the same arrays. A file with nohouseblock is a pure asset/component library (the unit of reuse and import). - Containment matches the JSON:
housetofloorstoFloortoFloorObject*. AFloorcontains aFloorObject, which is the grammar's mirror of the object union:
FloorObject: Room | Wall | Pillar | Beam | FloorSlab | Plinth | Ground
| Staircase | SpiralStaircase | KitchenPlatform | Item | GlbModel
| Component | Roof | Raw | ObjectDecl;
Deeper nesting is itself compositional:
- Room to walls to openings.
Roomoptionally containsRoomWallandRoomItem; aRoomWalloptionally containsOpening(door/window). Soroom … { wall north east { window … } item … }nests three levels. - Components.
ComponentDefis a mini-house in local coordinates;Component(use Comp at (…)) stamps it onto a floor.use target=[ComponentDef:QualifiedName]is a real Langium cross-reference, resolved by the Wadi scope provider across imported-module documents (in-fileCompor namespacedns.Comp). Asset references for furniture (item f."bed_double") work the same way. - Grid.
GridcontainsGridLines (1 @ 60 thick 9 role structural), a first-class parametric scaffold that rooms and pillars reference by name.
Step 5 — The Expr sublanguage and the shared Common tail
Two grammar features cut across every primitive:
Expris a small expression sublanguage:+ - * /, unary minus, the pure functionsmin/max/clamp/round/floor/ceil/abs, and dotted references (bay,main.x1,House.W). It is a faithful port of the runtime evaluator ineditor/src/param/formula.ts. Any numeric field that can be parametric is typed= Exprin the grammar. At compile time eachExpris serialized back to a"= …"string (or a bare number when it is a literal); see Step 7.fragment Commonis the optional attribute tail (z_offset,enabled,layer) that every object carries. It is a Langium fragment, so it inlines into each rule with no wrapper AST node, and it mirrors the common tail the field engine appends to every generated Zod object (COMMON_SOURCE_LINESinkernel/fieldSchema.ts).
Step 6 — Bespoke rules vs the generic ObjectDecl
Every shipped object type has an ergonomic, keyword-led rule (for example
Room, Wall, SpiralStaircase): at (x, y) placement plus named clauses, tuned to
read well. These are sugar.
Underneath sits one generic rule, ObjectDecl, the last branch of FloorObject:
ObjectDecl:
type=QualifiedName name=NameRef?
('(' args+=Value (',' args+=Value)* ')')?
('{' fields+=FieldAssign* '}')?
Common;
It parses any primitive by name; the semantics (positional-arg order, valid field
names) come from the descriptor manifest (descriptors.ts), which reads the very same
fields from Step 2. The consequence: a new primitive can be added and used from the
DSL with zero grammar edits and no langium generate. Keyword-led rules win when
they match; a non-keyword type name falls through to ObjectDecl.
One lexer detail makes this work: field-name keywords (radius, direction,
total_height, and so on) are made soft by the token builder
(wadi-token-builder.ts), so they lex as ID and match FieldKey without being
enumerated. Only a key that collides with a hard leader keyword (at, size, an
object type) needs quoting ("height" 8).
Step 7 — Compile (.wdl to .wadi) and decompile
compileDsl(wdl) (toHouseConfig.ts) does three things: parse with Langium,
link cross-references (use targets, asset refs), then map the AST onto the
HouseConfig JSON. The parametric layer is preserved rather than evaluated:
// exprToValue: a literal number stays a number; anything else becomes a formula string
exprToValue(e) => isNum(e) ? e.value : `= ${exprToFormula(e)}`
So width (House.W / 2) compiles to "formulas": { "width": "= House.W / 2" } with a
numeric placeholder, exactly the shape the Zod schema expects. Folding those formulas
to final numbers is a separate step, resolveParametric (editor/src/param/),
run by the app, the MCP server, and check.sh. Keeping compile and resolve separate
is what lets the editor show a partial model when a formula fails, while the checkers
still flag it.
The decompiler fromHouseConfig.ts (emitWdl) is the inverse: it turns a
.wadi back into an editable .wdl, re-lifting "= …" strings into Expr syntax and
reconstructing the parametric layer (variables, points, grids, components, configurator).
A round-trip test asserts compile(emit(cfg)) deep-equals cfg.
So in what sense does it "start from the WADI JSON"?
Two senses, and it helps to keep them separate:
- Conceptually / by design: the JSON (the Zod
HouseConfig) is the target. The grammar is authored so its AST maps onto that JSON, and the compiler is the explicit mapping. Change the JSON's meaning and you change the grammar and compiler to match. - Mechanically, for one path: the
fieldslayer is generated-from-once. A primitive'sfieldsproduce the JSON schema (via codegen), the docs, the form, and the generic DSL descriptor together. For a generic (ObjectDecl) primitive, the DSL surface really is derived from the same declaration as the JSON schema. For the keyword-sugared primitives, the bespoke grammar rule is hand-written, but its meaning still bottoms out in the samefields.
That two-tier split (a neutral parametric core plus a domain vocabulary, over a
declarative field engine) is the reusable method; see documentation/06-the-method.md
and documentation/05-extending-the-dsl.md.
File map
| Concern | File |
|---|---|
| Data model (source of truth) | editor/src/schema/houseConfig.ts |
| Per-primitive field declarations | editor/src/schema/fields/*.ts (+ index.ts) |
| Field engine (kinds, projections) | kernel/fieldSchema.ts |
| Codegen: fields to Zod | editor/scripts/gen-primitives.mjs to schema/generated/objects.generated.ts |
| Grammar | wadi-dsl/src/language/wadi.langium |
| Generic-path descriptors | wadi-dsl/src/generator/descriptors.ts |
| Formula runtime (grammar mirror) | editor/src/param/formula.ts |
Compiler (.wdl to .wadi) |
wadi-dsl/src/generator/toHouseConfig.ts |
Decompiler (.wadi to .wdl) |
wadi-dsl/src/generator/fromHouseConfig.ts |