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.5or5.. 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,inArea mm2,mil2Angle deg,radResistance mohm,ohm,kohm,MohmCapacitance pF,nF,uF,mF,FInductance nH,uH,mH,HVoltage, current, power mV,V,kV;uA,mA,A;mW,WFrequency, time Hz,kHz,MHz,GHz;ps,ns,us,ms,s,min,hCharge, 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. (.0603is 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,=,:,,,withor 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 }, whichpour(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 fieldx: 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 + 1is an error (“give the number its unit”);let x = 5stays a plain number, a length isx * 1mmormm(x).Int / Intis aNumber;div(a, b)divides integers.round,floor,ceilround half to even and keep the dimension (a plain number becomes anInt). 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 useself.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 texta, remembered as a name (forpins: a logical pin).m.keyreads 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;
letnames 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,foldand 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,
letnames private.usebinds 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 declareboard; everything of a design type bound in it — or in block instances it binds, directly or inside lists and maps — is the board (seeformat.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 (seeformat.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; anif … then … elsethat does not fit puts eachelseon 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 computedatorrotation, 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 computedat/rotationwith 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 startspcbkit 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. requireis checked against the design as it is;require distance(a, b) <= / >= dandprefer 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,…+/…-); apairof nets with other names is recorded and selected, but not yet coupled by the router.