This page is generated from
docs/language-reference.md— edit it there.
ArchLang Language Reference (v1.3)
ArchLang is a small declarative language that compiles to a professional SVG floor plan. It is explicit and parametric: you give every element exact coordinates and sizes in millimetres, so the same source always renders the same drawing, and changing one number changes exactly one thing.
It is also a small, pure scripting language — values, control flow, functions, arrays, and string interpolation — but it stays expand-time and deterministic: every loop, conditional, and function call is evaluated while the drawing is built (there is no runtime, no I/O, no clock), so the same source always produces byte-identical output.
The output is professional CAD: layers, line weights, line types, wall poché hatches by material, openings that void their wall, dimensions, a north arrow, scale bar, and a title block — exportable to SVG, DXF, PDF, or PNG. Rooms can be placed absolutely or relative to one another (right-of / below / …), classified by what they're for (uses bedroom), and furnished with fixtures that draw real plan symbols — placed by coordinate or snapped against a wall. Plans can import components from other modules, select named themes, and be formatted with arch fmt.
Beyond rendering, ArchLang reads back what you wrote: arch describe returns the rooms, areas, adjacencies, and a modelled access graph (what connects to what, and how far each room is from the entrance); arch lint flags habitability problems against advisory profiles. Both are pure, text-only, and image-free — see Analysis: describe & lint. This reference covers the language through v1.3.
- Unit: millimetres (integers recommended).
- Coordinate system: origin top-left, +x right, +y down (matches SVG).
- Comments:
#to end of line. - Strings: double-quoted;
\",\\,\nescapes supported, plus{…}interpolation (see Strings & interpolation).
A program is a single plan block:
plan "My Home" {
<statements…>
}Plan-level settings
| Statement | Meaning | Default |
|---|---|---|
units mm | Measurement unit (only mm in v0.1). | mm |
grid <n> | Snap module in mm. All coordinates round to the nearest multiple. 0 disables. | 0 |
scale 1:50 | Printed scale, shown in the title block. | none |
north up|down|left|right|<deg> | North direction for the north arrow. | up |
dims auto [overall|rooms|all] | Auto-draw dimension strings without hand-placing each dim: overall (the bounding extents), rooms (each room's width + height), or all (both; the default when no scope is given). | off |
Values & expressions
Expressions appear anywhere a value is expected (coordinates, sizes, widths, thickness, offsets, labels). A value is one of:
| Type | Examples |
|---|---|
| number (unitless mm) | 3000, 12.5, WALL + 300 |
| boolean | true, false, a < b |
| string | "Bed", "Studio {i}" |
| array | [1, 2, 3], 0..n (a range) |
| function | let area(w, h) = w * h |
room at (0, 0) size (3000) x (3000 - 500)
furniture bed at (WALL + 300, 300) size 1500x2000Where a number is specifically required (a coordinate, a size, …), a non-number value is a type error with a clear diagnostic — it never crashes the compile.
Operators
Lowest-to-highest precedence (use parentheses to override):
| Group | Operators |
|---|---|
| logical or | || |
| logical and | && |
| equality | == != |
| comparison | < > <= >= |
| range | a..b |
| additive | + - |
| multiplicative | * / % |
| unary | -x +x !x |
| postfix | arr[i] f(args) |
&&/||short-circuit (the right side is skipped when the result is already known).==/!=compare values of any type (different types are never equal; arrays compare deeply); the ordering operators require numbers.- Numbers are non-negative literals; write
-xfor negation. Division / modulo by zero is a compile error. - Sizes accept either the
WxHliteral (4000x3000) or<expr> x <expr>((2000+W) x H). The barexseparates width and height.
Arrays & ranges
let widths = [3000, 3500, 4000]
let n = widths[1] # indexing (0-based; out-of-range is an error)
for i in 0..3 { … } # 0..3 is the array [0, 1, 2] (half-open)Conditional expression
if is also an expression that yields a value (the else is required):
let w = if compact { 2400 } else { 3000 }Strings & interpolation
A string may embed { <expr> }; each hole is evaluated and converted to text:
room at (x, 0) size W x H label "Studio {i + 1}"
dim (0,0)->(L,0) offset 700 text "{L / 1000} m"- Literal braces are written
\{and\}. - Interpolated text is escaped at output, so labels are always XSS-safe.
Bindings — let
Bind a name to a value with let; later statements can use it:
let WALL = 200
let W = 4000
let H = W - 1000
room at (0, 0) size W x H- Evaluated top to bottom; a name must be defined before it is used (no forward references).
- Re-defining a name in the same scope is an error. An inner scope (a component body or a control-flow block) may shadow an outer name.
- Unknown names produce a
did you mean …?hint.
Reassignment. Once a name is bound, name = <expr> updates it (this is how a while loop makes progress — see Control flow). Assigning a name that was never let-bound is an error.
let i = 0
i = i + 1 # reassigns the existing bindingFunctions
let NAME(params) = <expr> defines a pure value-function (a closure over the names visible where it is defined):
let area(w, h) = w * h
let scaled(x) = x * GRID # captures the outer `GRID`
room at (0, 0) size area(40, 30) x 100- A function may call itself; recursion is bounded (deep recursion is reported, not a crash).
- Calling with the wrong number of arguments is an error.
- This is distinct from
component, which emits elements rather than returning a value.
Components
Define a reusable, parameterised sub-plan with component, then instantiate it by name. A component body may contain elements, lets, and calls to earlier components (composition).
component bath(x, y) {
room at (x, y) size 2000x2000 label "Bath"
door at (x + 1000, y) width 700 wall exterior
}
bath(0, 0)
bath(3000, 0)- Scope: a component body sees its parameters, its own
lets, and the plan-levellets (plan scope is global) — but not the caller's locals. - Auto-assigned ids stay unique across instantiations (the whole drawing is numbered per kind), so two
bath(...)calls yieldroom_1/room_2, etc. - Infinite recursion is bounded and reported as an error.
See examples/parametric.arch for a worked example using all of these.
Control flow
for, if, and while expand into the element stream while the drawing is built — there is no runtime. Each block is its own scope.
for i in 0..COUNT {
let x = i * W
room at (x, 0) size W x H label "Unit {i + 1}"
}
if rooms > 1 {
wall partition thickness 100 { (W, 0) (W, H) }
} else {
furniture sofa at (300, 300) size 2000x900
}
let i = 0
while i < COUNT {
column at (i * 600, 0) size 300x300
i = i + 1 # progress (see Reassignment)
}for x in <array|range>bindsxfor each item, in order.if <cond> { … } [else { … }]expands one branch; the condition must be a boolean.while <cond> { … }repeats until the condition is false; it is capped at 10,000 iterations (a runaway loop is reported, not hung).
Built-in functions
A frozen set of pure helpers is always in scope (a let of the same name shadows one):
| Function | Result |
|---|---|
min(a, b, …) / max(a, b, …) | smallest / largest number |
abs(x) | absolute value |
sqrt(x) | square root (negative input is an error) |
floor(x) / ceil(x) / round(x) | rounding |
len(x) | length of an array or string |
str(x) | value rendered as a string |
column at (max(0, x - GAP), 0) size 300x300
room at (0,0) size 1000x1000 label "Room {floor(area / 1000000)} m²"Set rules
set <kind>(attr: value, …) overrides the default for subsequent elements of that kind, scoped to the enclosing block. An attribute the element states explicitly always wins.
set door(swing: out) # later doors swing out…
door at (1000, 0) width 800 # → out
door at (3000, 0) width 800 swing in # explicit → inCurrently door supports swing (in/out) and hinge (left/right).
Elements
Wall
wall <kind> thickness <mm> [material <name> [scale <n>] [angle <deg>]] { (x,y) (x,y) … [close] }
wall id=<id> <kind> thickness <mm> [material <name> …] { … }A polyline of ≥2 points, drawn with the given thickness and a poché hatch. close connects the last point back to the first (use for exterior shells). <kind> is a free label (e.g. exterior, partition).
Orthogonal walls are boolean-unioned so corners and T-junctions render as one clean outline with no internal seams. Angled walls render seamlessly too when the optional clipper2-wasm geometry engine is installed; otherwise they fall back to a per-segment outline.
Materials select the hatch pattern: poche (default), concrete, brick, insulation, tile, none. An unknown material warns and uses the default. Hatches are data-driven: the SVG emits a tiled <pattern> and the DXF a real HATCH entity. Optionally tune the hatch with scale <n> (tile-size multiplier, default 1) and angle <deg> (extra rotation, default 0):
wall exterior thickness 250 material brick { … }
wall exterior thickness 250 material brick scale 1.5 angle 30 { … }Room
room [id=<id>] at (x,y) size <w>x<h> [label "<text>"] [uses <kind>…]
room [id=<id>] <right-of|left-of|below|above> <ref> [align <edge>] [gap <mm>] size <w>x<h> [label "<text>"] [uses <kind>…]A rectangle. The compiler prints the label and the computed area (m²). Rooms describe space; walls are drawn separately.
Room purpose — uses (v1.3). Tag a room with one or more space kinds so the analysis layer knows what it is without guessing from the label:
room id=r_living at (0,0) size 4000x6000 label "Living / Kitchen" uses living kitchen
room id=r_bath at (4000,4400) size 3000x1600 label "Bath" uses bathThe kinds are living, kitchen, dining, bedroom, bath, wc, hall, circulation, storage, utility, office, and entry. This is authored intent: it overrides the conservative label/id regex that describe and lint fall back to when uses is absent (so a room labelled "Master Suite" can still be tagged uses bedroom). The tags drive lint rules like bedrooms need a window and wet rooms need fixtures, and appear in describe().rooms[].uses — see Analysis.
Relational placement (v1.0). Instead of an absolute at (x,y), a room may be positioned relative to another room with right-of / left-of / below / above. The compiler resolves the absolute corner by pure arithmetic in dependency order (a topological pass over the references) — it is deterministic sugar over absolute coordinates, not an optimizer. The absolute path is the default and is unchanged.
<ref>is theidof another room.align <edge>lines up the cross-axis edges: horizontal placement usestop|middle|bottom, vertical placement usesleft|center|right(default: the leading edge —topfor horizontal,leftfor vertical).gap <mm>is the spacing along the placement axis (default0).
room id=living at (0,0) size 5000x4000 label "Living"
room id=kitchen right-of living align top gap 0 size 3000x4000 label "Kitchen"
room id=bed below living align left gap 0 size 5000x3500 label "Bedroom"A reference cycle reports E_LAYOUT_CYCLE; an unknown reference reports E_LAYOUT_REF. See the dedicated guide page for the placement arithmetic.
Door
door [id=<id>] at (x,y) width <mm> [wall <ref>] [hinge left|right] [swing in|out]Drawn as an opening in the host wall plus a leaf and a quarter-circle swing arc. wall <ref> pins the door to a wall by id or kind; otherwise the nearest wall hosts it. hinge is relative to the wall's direction. Defaults: hinge left, swing in.
Window
window [id=<id>] at (x,y) width <mm> [wall <ref>]An opening with the standard double-line glazing symbol.
Opening (v1.3)
opening [id=<id>] at (x,y) width <mm> [wall <ref>]A cased, leaf-less gap — it voids the wall like a door does, but draws no leaf and no swing arc and no glazing. Use it where two spaces flow into one another without a door: a living room into a hall, an open-plan kitchen, a wide cased passage. Like a door, an opening connects two spaces in the access graph — but because there is no leaf to subtract, its clear width equals its nominal width (a door loses ~60 mm to the leaf and stop).
opening id=o_living at (4000,3700) width 900 wall partition # living ↔ hall, no doorFurniture
furniture <kind> [id=<id>] at (x,y) size <w>x<h> [label "<text>"] [rotate 0|90|180|270] [in <room>]
furniture <kind> [id=<id>] against wall <ref> [segment <n>] [offset <mm>] [side left|right] size <along>x<depth> [label "<text>"] [in <room>]A schematic labelled rectangle (bed, sofa, desk…). Known plumbing & kitchen fixture kinds draw a real plan symbol instead of an empty box and ignore any label: wc/toilet, basin, shower, bathtub, kitchen_sink/sink, counter, fridge, and stove/hob/cooktop. Any other kind falls back to the labelled rectangle.
A piece can be placed two ways: absolutely with at (x,y) (optionally turned with rotate), or snapped against wall <ref> so its back sits on the wall and its rotation is derived for you. in <room> records which room owns the piece (used by the lint rules). The full placement rules, the fixture symbol catalogue, and the fixture-aware lint checks live on the dedicated Furniture & fixtures page. Standard fixtures are also importable components at typical residential sizes:
import "lib/fixtures.arch": wc, basin, shower
wc(6200, 4600)Dimension
dim (x1,y1)->(x2,y2) [offset <mm>] [text "<override>"]A dimension line offset perpendicular from the measured segment, with tick marks and a label. Without text, the measured length (mm) is shown.
Column
column [id=<id>] at (x,y) size <w>x<h>A solid structural column (filled square). Useful for grids of columns in larger plans.
Title block
title {
project "<name>"
drawn_by "<name>"
date "<date>"
}Rendered as a title block in the lower-right corner (with scale if set).
Theming
A theme { … } directive overrides colours, line weight, and font. Resolution order (later wins): built-in defaults → the theme directive → CompileOptions.theme (programmatic).
theme {
background: "#1e2127"
wall: "#e8e8e8" # wall outline
wallFill: "#3a3f4b" # poché base
wallHatch: "#5a6172" # poché lines
room: "#272b33"
roomLabel: "#f0f0f0"
dim: "#6cb6ff"
annotation: "#cfd3da"
font: "Georgia, serif"
lineWeight: 1.3 # multiplier on all stroke widths
}Friendly keys (wall, room, furniture, wallFill, wallHatch, door, window, background) alias the canonical theme fields; you can also use the canonical names (wallStroke, roomFill, …). Unknown keys warn and are ignored. Colours are strings, lineWeight is a number, font is a CSS font-family. Programmatic overrides use the canonical field names:
compile(src, { theme: { wallStroke: "#0000ff", lineWeight: 0.5 } });See examples/themed.arch.
Analysis: describe & lint
ArchLang doesn't just draw a plan — it can read it back as facts. Two pure functions (also surfaced as arch describe / arch lint) turn source into machine-readable, image-free output:
describe(source)→ a semantic summary: every room with itsuses, area, bounding box andadjacentrooms; what each door, window, and opening connects; the furniture; and a modelled access graph (entrances, per-room reachability, door-hop depth from the entrance, and the clear-width bottleneck on the way in).lint(source)→ advisoryW_*warnings about habitability (a room with no way in, a windowless bedroom, a too-small room, a door leaf sweeping onto a fixture, a wet room reached only through a bedroom…). Pick a ruleset with--profile:arch lint plan.arch --profile residential-basic # default: ≥700 mm doors, ≥4 m² rooms arch lint plan.arch --profile accessibility-advisory # stricter: ≥850 mm doors, ≥5 m² rooms, swing clearanceProfiles are advisory soundness checks, never a building-code guarantee. The programmatic form is
lint(src, { profile }); the names come fromLINT_PROFILES(seesrc/lint.ts).
These are deliberately facts and advice, not an auto-arranger — ArchLang never moves your geometry behind your back (see ADR 0005). The full output shapes, the access graph, and the complete rule list are documented on the Analysis: describe & lint page; every code is in the error catalog.
Compilation result
compile(source, opts?) returns:
{
svg: string;
errors: CompileError[]; // derived from diagnostics (severity "error")
warnings: CompileWarning[]; // derived from diagnostics (severity "warning")
diagnostics: Diagnostic[]; // every problem, with byte-offset spans
ast?: PlanNode;
scene?: Scene; // backend-neutral drawing (for DXF/PDF/PNG)
}errorsare fatal; when present,svgis"". Each carriesmessageand (when known)line/col.warningsare advisory (e.g. door does not lie on any wall, rooms overlap) and do not block rendering.errors/warningsare projections ofdiagnostics— kept for back-compat.sceneis the backend-neutral {@link Scene} IR — the geometry computed once and shared by every backend.
Output formats
The default compile() path is zero-dependency and emits SVG. Other backends are pure serializers of the same scene:
| Format | API | CLI | Dependency |
|---|---|---|---|
| SVG | compile().svg | arch compile p.arch | none (default) |
| DXF | toDxf(scene) | arch compile p.arch -f dxf | none (zero-dep) |
toPdf(scene) | arch compile p.arch -f pdf | optional pdfkit (vector, text selectable) | |
| PNG | renderPng(scene) | arch compile p.arch -f png | optional @resvg/resvg-js (deterministic raster) |
The optional dependencies are lazily import()ed, so the core never requires them and a default install emits SVG and DXF with nothing extra. The PNG backend rasterizes the SVG with a bundled font (no system fonts), so output is byte-identical across machines.
Diagnostics
The compiler never throws on bad source: it recovers from syntax errors and reports all problems in a single pass. Each is a Diagnostic:
interface Span { start: number; end: number; } // byte offsets into source
type Severity = "error" | "warning";
interface Diagnostic {
severity: Severity;
message: string;
span?: Span; // source location, when known
code?: string; // stable machine code, e.g. "E_ROOM_SIZE"
hints?: string[]; // optional "did you mean …?" suggestions
}formatDiagnostic(source, d) (also exported) renders a caret-framed snippet:
error[E_ROOM_SIZE]: room "bed" must have a positive size
--> 1:27
|
1 | room id=bed at (0,0) size 0x4000
| ^^^^^^
= help: did you mean 3000x4000?offsetToLineCol(source, offset) converts a byte offset to a 1-based { line, col }. The arch CLI prints these frames for every diagnostic.
Options: width (px for the <svg>; height derived from aspect ratio) and noCache (bypass the memoization cache).
Worked example
See examples/studio.arch and examples/two-bed.arch, or try the playground.
Architecture (for contributors)
The compiler is a pipeline: lex → parse → resolve(AST → IR) → render. Every element type (wall, room, door, …) is a single self-contained module in src/elements/ implementing a common ElementDef (parse / resolve / render); parse, resolve, and render all iterate the registry rather than a hard-coded switch. resolve() (in src/ir.ts) is the single place semantics live — grid-snap, id assignment, opening-hosting, and checks — and it produces a new immutable IR (the AST is never mutated). render() consumes the IR only, which keeps it backend-ready.
To add an element type: write one src/elements/<name>.ts exporting an ElementDef, then add one register() line in src/elements/index.ts. No edits to the parser, resolver, or renderer cores are needed — column is the worked example.