The PCBKit language

PCBKit is the language a board’s design is written in: a small declarative language with physical quantities (0.25mm, 100nF, 3.3V), typed references (nets, pins, layers), parts, parametric blocks and explicit constraints. A design says what the board is — its circuit and its intent; the tools place, route, check and export it. Where a project’s files are and what lives in each is the board format; this page is the language.

Everything here also holds for the standard library, which is written in PCBKit: every type, field, block and footprint is documented in its source, and lang.search / lang.describe (or go to definition in an editor) show them.

1. A file

pcbkit 1                  first line (comments may come before it): edition 1 of the language

net GND                   declarations and bindings, separated by line breaks or `;`
net V3 named "+3V3"

board = Board {
  name = "decoupling"
  outline = rect(30mm, 20mm)
}

part C100n = cap(100nF, package = "0603")

C1 = C100n {
  at = (20mm, 10mm)
  rotation = 90deg
  pins { a: V3, b: GND }
}

The edition fixes the syntax, the base types, units and the semantics of the core. Library versions and the version of generated files (Artifact { schema = 1 }) are independent of it.

2. Lexicon

  • Names: an ASCII letter or _, then letters, digits, _. Unicode belongs in strings.

  • Numbers: 0, 42, 1_000, 2.5, 1e-3. No leading zeros (007), no .5 or 5.. Integers (Int) and decimals (Number) are different types.

  • Quantities: a number with its unit attached: 100nF, 0.127mm, 90deg, 5pct. An unknown suffix is an error (2pi: multiply instead, 2 * pi). Units:

    Dimension Units
    Length nm, um, mm, cm, m, mil, in
    Area mm2, mil2
    Angle deg, rad
    Resistance mohm, ohm, kohm, Mohm
    Capacitance pF, nF, uF, mF, F
    Inductance nH, uH, mH, H
    Voltage, current, power mV, V, kV; uA, mA, A; mW, W
    Frequency, time Hz, kHz, MHz, GHz; ps, ns, us, ms, s, min, h
    Charge, energy uAh, mAh, Ah; mWh, Wh (a battery’s capacity)
    Ratio a plain number, or pct (5pct = 0.05)
  • Strings: "…" with \n, \t, \", \\, \u{…} and interpolation \(expression); multi-line """…"""; raw #"…"# (no escapes; more # for strings containing "#). A string is text: it never becomes a net or a layer.

  • Enum values: a dot and a name in a value position: .solid, .bottom. (.0603 is an error: package codes are strings, "0603".)

  • Comments: // …, documentation /// … (before a declaration or a field; editors show it), nested /* … */.

  • Keywords: pcbkit use as let fn type enum net named bus pair part block interface rule port for in if then else assert warn require prefer connect override true false null and or not with. Declaration words are names elsewhere: x.net, connect = .solid, copper(net = GND).

  • Separators: members end at a line break or ; (or ,). Inside (…) and […] line breaks do not end anything; after a binary operator, =, :, ,, with or an opening bracket the expression continues on the next line.

3. Members

Member Example Where
binding x = expression module, block, body
field body pins { a: VCC }, vias { minDrill = 0.2mm } body
contribution nets += [GND] body (adds to a list)
entry a: VCC, "1": GND, 4: SCL, [i]: value body (a map)
local value let w = 2mm anywhere; not exported
function fn spacing(n: Int): Length = n * 1.27mm module, block
type type Point3 { x: Length, y: Length, z: Length = 0mm } module
interface interface I2C { sda: Endpoint, scl: Endpoint } module
enum enum Mode { fast, slow } module
nets net A, net A, B, net V3 named "+3V3", net CLK { layers = [Cu.top] } module, block
bus bus D[8] (D[0] … D[7], exported D0 … D7) module, block
pair pair USB = (USB_DP, USB_DN) module, block
part part R10k = res(10kohm, package = "0402") module, block
block block Channel(input: Net) { … } module
rule rule Usb { select = …; limits { … } } module, block
port port i2c = bus block
generators for (i in 1..=4) { … }, for ((i, x) in enumerate(xs)) { … }, if (c) { … } else { … } body
checks assert(cond, "message"), warn(cond, "message") anywhere
constraints require distance(C1, U1) <= 6mm, prefer near(C1, U1, scale = 3mm) module, block
connection connect(A, B) module, block
override override channels[3].R.at = (0mm, 2mm) module
annotation @entity before a declaration module

Bare expressions are not members: foo(1) alone is an error. name: Type is not a field declaration in a body (: always starts an entry); types are declared in let, parameters and type bodies.

4. Expressions

From the weakest to the strongest binding:

Level Operators
1 ?? a ?? b: a, or b when a is null (evaluated only then)
2 or Bools (short-circuit) or selectors
3 and
4 not, ! the operand includes comparisons: not a == b is not (a == b)
5 == != < <= > >= in do not chain: a < b < c is an error
6 .., ..= integer ranges, 1..4 (4 excluded), 1..=4; range(a, b, step) for others
7 +/- a tolerance: 3.9mm +/- 0.1mm; a ratio after an electrical quantity is relative: 10kohm +/- 1pct
8 + - numbers of one dimension; points; strings and lists (+ joins)
9 * / % dimensions multiply and divide: Length * Length is Area
10 unary - +
11 call f(a, b = 1), index x[i], field x.f, configuration F { … }, x with { … }

Primary: literals, names, .enum, (a), tuples (x, y), lists [a, b], objects { … }, lambdas fn(x) -> x * 2, conditionals if c then a else b, self (in a type’s field defaults). Arguments: positional first, then named.

  • F { … } builds from a factory: a type (Board { … }), a preset type (Pour with { net = GND }, which pour(net, layers) returns), a part or a footprint (a component), a block or a block call (an instance). The body must start on the line of the factory.
  • value with { … } copies a record (or a map) with the fields it names changed; on a type it gives a preset type.
  • Field bodies: x { … } configures field x: a record field is built or changed field by field, a map field gets its entries added or replaced; a list field is replaced (=) or extended (+=). Each field is set once per body.

5. Values and types

Bool, Int, Number, quantities (Length, Area, Angle, Time, Frequency, Current, Voltage, Resistance, Capacitance, Inductance, Power, Charge, Energy), Tolerance<Length> (nominal, min, max, tolerance), String, enum values (Symbol, or a declared enum), List<T>, Map<K, V>, tuples (Tuple<A, B>; Point is (Length, Length); .x, .y, .z), records of declared types, Net, Pin, Endpoint (a net or a pin), Layer, Selector, functions (Fn), Any, T? (or null), Or<A, B> (either).

  • Dimensions are checked: 1mm + 1 is an error (“give the number its unit”); let x = 5 stays a plain number, a length is x * 1mm or mm(x). Int / Int is a Number; div(a, b) divides integers. round, floor, ceil round half to even and keep the dimension (a plain number becomes an Int). Results are finite: division by zero is an error.
  • Typed records: a type lists its fields with their types and defaults (type Pad { number: String; at: Point = (0mm, 0mm); paste: Bool = self.kind == .smd; … }). Building a record checks every field: an unknown field is an error with a suggestion, a missing required one is named, values are coerced to the field’s type (an anonymous { … } becomes the field’s record type). Defaults may use self.field.
  • Identity: nets, pins, components and block instances are entities: equal only to themselves. Types marked @entity (net classes, pours, keepouts, rules, limits) get the path of where they are declared (channels[3]/R), which names and refdes come from.
  • Maps keep their order; keys are strings, integers, enum values, nets or layers. A key written as a name (a: VCC) is the text a, remembered as a name (for pins: a logical pin). m.key reads an entry whose key is that name.

6. Evaluation

  • A module’s names (and a block body’s) are evaluated lazily, in dependency order, each once; a value that depends on itself is an error that shows the chain. Bodies are evaluated in order; let names are visible after they are declared.
  • Functions are pure. Edition 1 has no recursion (a function calling itself, directly or through others, is an error); generators, map, filter, fold and friends do the repetition. Evaluation is bounded (steps, list sizes).
  • No I/O, time or environment: modules come from the project (use "parts/x.pcbkit" as x, relative to the importing file, never outside the project) and from the standard library.
  • Modules: top-level names are public, let names private. use binds a module to a name: x.Sensor. Components of another module become part of a design only when the design binds them.
  • The design: the entry module (.pcbkit/design.pcbkit) may declare board; everything of a design type bound in it — or in block instances it binds, directly or inside lists and maps — is the board (see format.md), over the project’s KiCad board. Nets of the entry module, of the instances, and every net a pin or pour uses, are its nets.
  • Errors are collected: every independent mistake of a run is reported at once, each with a code, the file, line and column, and related places (the first declaration of a duplicate). A mistake found inside the standard library is reported where the project calls it.

7. Blocks

block SensorChannel(bus: I2C, vcc: Net, gnd: Net) {
  port i2c = bus
  U = Sensor { at = (0mm, 0mm); pins { VCC: vcc, GND: gnd, SDA: bus.sda, SCL: bus.scl } }
  C = cap(100nF, package = "0402") { at = (0mm, 3mm); pins { a: vcc, b: gnd } }
}

left = SensorChannel(I2C { sda = SDA; scl = SCL }, V3, GND) { at = (10mm, 10mm) }

A block call checks its arguments; the body ({ at; rotation; side }) places the instance. Inside, names are local and positions are in the block’s frame. Members of an instance: left.U, left.i2c (ports), left.parts (its components). Local nets are exported as left/net. Names declared in a block body are fixed: a part that is there only sometimes is D = if esd then diode(…) { … } else null, repeated ones go in a map.

A parameter may have a documentation comment (/// The output voltage. before voltage: Voltage = 3.3V), as may a binding of the body: editors show them, and the standard library’s blocks (std/blocks/: Ldo, Buck, UsbC2, Crystal, I2cPullups, …; docs/blocks.md) list them as their parameters and computed values. A port is any value; typed ports use the electrical types (port output = io.powerOut(3.3V, net = vout)) and the bus interfaces I2C, Uart, Spi, Usb, Swd (port bus = I2C { sda = sda; scl = scl }).

8. Standard library

All of it is PCBKit (crates/pcbkit-lang/std/), loaded as one prelude visible in every module:

File Contents
geometry.pcbkit Box, box, centered, rect, rectangle, polygon, translated, Region, Piece; built in: circle, distance, near
silk.pcbkit silkscreen: Line, Arc, Circle, Polygon, Text, Justify, VJustify, refText
footprint.pcbkit Footprint, Pad, Shape and shape.round/rect/roundRect/oval, PadPour, Graphic, FootprintKeepout, SwapGroup, Package, Fillets, Model3d, jedecRows, lattice, ballGrid
ipc.pcbkit IPC-7351B: Density, Goals, Tolerances, ipc (fillet goal tables), Land, land, smdShape
lib.pcbkit chipResistor, chipCapacitor, chipLed, rows, soic, tssop, sot23, lqfp, qfn, pinHeader, pinSocket, socketRow, moduleSocket, mountingHole, testPad, testPadRect, testPointTht, catalog
parts.pcbkit Part (with pinTypes, pinType, marking), Spec (what an orderable part must meet) with Category, Dielectric, LedColor, Marking (what a body carries, over the marking derived from the part) with BandColor, Component, Freedom, Swap/swap, PinChoice/pinChoice, Placement, Pair, device, pinAliases, pinFor, padsOf, padFor, connectByRole (a part’s pins by role under its own names), cap, res, led, header, hole, testPoint
electrical.pcbkit PinType, Pull, Electrical, io (io.powerIn(…), io.powerOut(…), io.input(…), io.input(pull = .up), …), the bus interfaces I2C, Uart, Spi, Usb, Swd, eSeries, preferred (the nearest E6/E12/E24 value: preferred(17.4pF) is 18pF), trackWidthFor (IPC-2221), diffPairWidth (IPC-2141 microstrip)
calc.pcbkit calculations, each a body of named quantities (the calc.* commands show them): ledVoltage, ledCurrent, astable555, astable555For (R2 for a period), batteryLife (a duty-cycled load), currentSense, rcNetwork, trackFor (IPC-2221 width, resistance, drop)
devices.pcbkit footprints sod (sodNames), sod123, sma, powerInductor, chipInductor, chipFuse, crystalSmd, tactSwitch (tactSwitches), addressableLedFootprint (addressableLeds), usbCReceptacle, twoTerminal; parts inductor, ferrite, fuse, diode, crystal, button, addressableLed, mosfet, usbC, esdArray, raspberryPiHeader (raspberryPiPins) (with typed pins)
bodies.pcbkit 3D bodies of whole parts as solids, each in its own frame (their markings are the parts’, painted by the renderer): body.keyswitch, body.stabilizer, body.axial, body.diode, body.resistor, body.module, body.jack, body.coax, body.rotary, body.tactile, body.part, body.led, body.ledTht, body.connector, body.header, body.dsub, body.toroid, body.pushbutton, body.standing, body.onLegs, body.chip, body.standoff, …; frustum, KeySwitch
blocks/*.pcbkit circuit blocks: Ldo, Buck, ReversePolarity, InputFuse, UsbCPower, UsbC2, UsbUart, Crystal, SwdHeader, McuSupport, EspSupport, I2cPullups, LedIndicator, Button, Divider, LevelShifter, TestPoints, UartHeader, QwiicPort, AddressableLeds, MountingHoles, RaspberryPiHat (with raspberryPiHatOutline); parts ldoSot23, buckSot23, usbUartBridge, qwiicConnector (catalogue: docs/blocks.md)
board.pcbkit Board, Fabrication (and its enums Material, MaskColor, SilkColor, Finish, ViaCovering, OrderMark, Coverlay, Stiffener), LayerSpec/signal/power/mixed, NetClass, Glob/glob, NetProps, Pour/pour, Smoothing, Keepout, RuleArea, Rules, ViaRules, HoleRules, MaskRules, SilkRules, Tier, Routing, KicadRule, KicadData, Interop, Artifact
rules.pcbkit Limit/limit, Rule, selectors: copper, insideArea, intersectsArea, ofComponent, routePath, pairsWithin, onB, allOf, anyOf

Built-in functions: sqrt exp ln pow abs floor ceil round min max sin cos tan asin acos atan atan2 hypot clean roundTo div mm deg tolerance len range enumerate zip zipExact map filter flatMap fold any all sum concat flatten reverse take drop first last indexOf distinct sort repeat keys values entries get join str padStart replace split upper lower startsWith endsWith commonPrefix typeName layer circle courtyardOf clearOfPads jedecRowNames kicad kicadQuote selectorText entityName refOf distance nearness check fail (near is in the library: place.pcbkit), and the values pi, Cu (Cu.top, Cu.bottom, Cu.inner[n]) and Silk (Silk.top, Silk.bottom: the silkscreen layers).

The footprint library matches the golden catalog to 1 nm (crates/pcbkit-lang/tests/std.rs compares it with fixtures/footprints/catalog.json).

9. Data files

Files the tools keep beside the design (.pcbkit/comments.pcbkit, .pcbkit/parts.pcbkit) use the data subset of PCBKit: the header, name = value members and literal values only (numbers with units, strings without interpolation, enum values, lists, tuples, { key: value } maps and Type { field = value } records), no imports, functions or generators. Each records its kind and schema version:

pcbkit 1
artifact = Artifact { kind = .comments; schema = 1 }
comments = [
  Comment { id = 1; author = "user"; on = "comp:C4"; text = "Closer to U1, please" },
]

Strings here are serialized IDs of the schema ("F.Cu", net names), not references of the language. Every data file is still a valid module, which editors check.

10. Tools

  • pcbkit check / show / route / … evaluate the project (see format.md).
  • pcbkit fmt [files] [--check]: the canonical layout, strict and unambiguous like Prettier, with no options. The output depends only on the syntax tree, the comments and where the source had blank lines between members — never on how the source was broken into lines (a test relayouts every file of the repository two ways and requires the same result). Formatting twice changes nothing, every token and comment is kept. The rules:
    • width 100, indent 2 spaces, no trailing whitespace, one newline at the end;
    • a list, tuple, argument or parameter list, enum or nested body (pins { a: VCC, b: GND }) is one line if it fits and holds no comment; otherwise one element per line, each ending in , (map entries too; fields have no separator), the closing bracket on its own line;
    • the body of a declaration (C1 = cap(…) { … }, fn f() = T { … }, rule, net, block, for, if, type bodies) breaks whenever it has two or more members;
    • a call whose last argument is a lambda, a body or a list keeps the other arguments on its line and breaks only the last one when that fits;
    • a chain of one operator that does not fit breaks after every operator, the operands one level deeper; .. and ..= never break; an if … then … else that does not fit puts each else on its own line, and moves to the line after = when that helps;
    • one space around binary operators, =, +=, ->, ?? and after , : ;; none inside () and [], around .., before ( [ . of calls, indexes and fields, or after - !;
    • at most one blank line between members, only where the source had one; none after { or before }; a comment at the end of a line stays there (and breaks its group), others go on their own lines before what they precede; literals print as written.
  • pcbkit lsp: the language server — diagnostics of the whole project as you type, semantic highlighting, outline, go to definition (into the standard library too), find references and rename (across the project’s files: uses, alias.name, override name.…, named arguments of a parameter), hover with documentation and types, completion of names, fields and enum values, signature help, inlay hints (lengths and angles in mm and degrees, parameter names of positional arguments, the value of a computed at or rotation, the nets of a bus of computed size), code actions (the fix a “did you mean” diagnostic suggests, inline a literal-valued name, replace a computed at/rotation with its value, override a value a block or loop shares for one component), formatting.
  • editors/vscode: the VS Code extension (TextMate grammar for highlighting without the server, language configuration, the client that starts pcbkit lsp).

11. Grammar

Structural EBNF (tokens as in §2; NL a line break that separates members):

file           = header SEP [ members ] EOF ;
header         = "pcbkit" INT ;
SEP            = NL | ";" ;
members        = item { ( SEP | "," ) item } [ SEP | "," ] ;
item           = { annotation } statement ;
annotation     = "@" path [ "(" arguments ")" ] ;
statement      = use | let | function | typeDecl | enumDecl | interfaceDecl
               | netDecl | busDecl | pairDecl | partDecl | blockDecl | ruleDecl | portDecl
               | forMembers | ifMembers | check | require | prefer | connect | override
               | binding | contribution | fieldBody | entry ;
use            = "use" STRING "as" IDENT ;
let            = "let" IDENT [ ":" type ] "=" expression ;
function       = "fn" IDENT "(" parameters ")" [ ":" type ] "=" expression ;
typeDecl       = "type" IDENT [ "<" names ">" ] typeBody ;
interfaceDecl  = "interface" IDENT typeBody ;
enumDecl       = "enum" IDENT "{" names "}" ;
typeBody       = "{" [ typeField { ( SEP | "," ) typeField } ] "}" ;
typeField      = fieldName ":" type [ "=" expression ] ;
netDecl        = "net" IDENT { "," IDENT } [ "named" STRING ] [ body ] ;
busDecl        = "bus" IDENT "[" expression "]" [ body ] ;
pairDecl       = "pair" IDENT "=" "(" expression "," expression ")" [ body ] ;
partDecl       = "part" IDENT "=" expression ;
blockDecl      = "block" IDENT "(" parameters ")" body ;
ruleDecl       = "rule" IDENT body ;
portDecl       = "port" IDENT "=" expression ;
forMembers     = "for" "(" pattern "in" expression ")" body ;
ifMembers      = "if" "(" expression ")" body [ "else" ( ifMembers | body ) ] ;
check          = ( "assert" | "warn" ) "(" arguments ")" ;
require        = "require" expression ;
prefer         = "prefer" expression ;
connect        = "connect" "(" arguments ")" ;
override       = "override" address "=" expression ;
binding        = address "=" expression ;
contribution   = address "+=" expression ;
fieldBody      = address body ;
entry          = key ":" expression ;
key            = fieldName | STRING | INT | "[" expression "]" ;
address        = fieldName { "." fieldName | "[" expression "]" } ;
body           = "{" [ members ] "}" ;
parameters     = [ parameter { "," parameter } [ "," ] ] ;
parameter      = fieldName [ ":" type ] [ "=" expression ] ;
arguments      = [ argument { "," argument } [ "," ] ] ;
argument       = [ fieldName "=" ] expression ;
pattern        = IDENT | "(" IDENT "," IDENT ")" ;
type           = path [ "<" type { "," type } ">" ] [ "?" ] ;
path           = IDENT { "." fieldName } ;
names          = IDENT { ( "," | NL ) IDENT } ;

expression     = (* Pratt, §4 *) ;
primary        = literal | IDENT | "self" | "." fieldName | "(" expression ")"
               | "(" expression "," [ expression { "," expression } ] [ "," ] ")"
               | "[" [ expression { "," expression } [ "," ] ] "]"
               | body | "fn" "(" parameters ")" "->" expression
               | "if" expression "then" expression "else" expression ;
postfix        = "(" arguments ")" | "[" expression "]" | "." fieldName | body | "with" body ;

named and a body are allowed on a net of one name. A keyword followed by =, +=, :, . or { at the start of a member is a field name. The parser reads the common prefix of an address, then chooses by the next token (=, +=, {, :); it does not consult the library.

12. Choices and limits of this implementation

  • Lengths are kept in millimetres, angles in degrees, as f64; values are rounded to 1 nm where the model is built (as KiCad does). Decimal literals are exact up to that rounding.
  • require is checked against the design as it is; require distance(a, b) <= / >= d and prefer near(a, b, scale) also bound and steer the placement optimiser, other forms do not. Relative placements (at = U1.at + …) are computed, not kept as a constraint for the optimiser.
  • Authored rules are lowered to the model’s ordered rules so that their limits intersect (§ rules in format.md); imported KiCad rules keep KiCad’s order semantics.
  • Differential pairs are recognised by the router and checks by name (…P/…N, …+/…-); a pair of nets with other names is recorded and selected, but not yet coupled by the router.