forma-dsl docs
Home Editor GitHub

Expressions#

Expressions appear on the right of every attribute, in for sequences, in if conditions, in part labels and inside ${ } splices. The language is dynamically typed: values carry their type, and a mismatch is reported where it is used.


Values#

Type Literals Notes
number 12, 0.5, 1e3 Double precision. Infinities and NaN cannot be written and are rejected where a block reads a number
string "text", "a ${b} c" Double quotes only; see Syntax → Strings
bool true, false
list [1, 2, 3], [[0,0], [1,0]] Heterogeneous and nestable. A list of numbers is what every block means by a vector
object { w = 3, h = 4 }, { w: 3 } = and : are interchangeable. Read with .key
null null Falsy; renders as the empty string in interpolation

There is no separate vector type in the language - a vector is a list of numbers, and blocks say how many components they want. Where a block wants a vector, a single number is accepted as shorthand for every component, so scale { factor = 2 } and scale { factor = [2, 2, 2] } are the same.


Operators#

Listed loosest-binding first. Everything in one row has equal precedence and associates left to right.

Precedence Operators Notes
1 (loosest) cond ? a : b Right-associative
2 || Short-circuits; always yields a bool
3 && Short-circuits; always yields a bool
4 == != Structural equality
5 < <= > >= Both sides must be the same type
6 + -
7 * / %
8 -x !x Prefix
9 (tightest) a.b a[i] f(x) Postfix

Arithmetic#

+, -, * and / are overloaded so that vector maths reads the way it is meant to.

2 + 3                  // 5
[10, 20] + [1, 2]      // [11, 22]     componentwise
[10, 20] - [1, 2]      // [9, 18]      componentwise
[10, 20] * 2           // [20, 40]     scalar broadcast
2 * [10, 20]           // [20, 40]     either order
[10, 20] * [2, 3]      // [20, 60]     componentwise
[10, 20] / 2           // [5, 10]      scalar only
10 % 3                 // 1

What each accepts:

Operator Number ∘ number List ∘ list List ∘ number Number ∘ list Anything with a string
+ sum componentwise ✗ ✗ concatenation
- difference componentwise ✗ ✗ ✗
* product componentwise broadcast broadcast ✗
/ quotient ✗ broadcast ✗ ✗
% remainder ✗ ✗ ✗ ✗

Two lists of different lengths cannot be combined: cannot combine vectors of length 2 and 3.

Division and modulo by zero are errors, not Infinity or NaN - a NaN would otherwise travel all the way into the kernel and surface as a degenerate vertex a long way from its cause.

Concatenation#

+ concatenates when either side is a string. The other side is rendered by the same rules as interpolation:

"part_" + 3            // "part_3"
"size " + [1, 2]       // "size [1, 2]"

Comparison and equality#

== and != compare structurally, so two lists with the same numbers are equal:

[1, 2] == [1, 2]       // true
1 == "1"               // false - different types are never equal

<, <=, > and >= require both sides to be the same type, and compare numbers numerically and strings lexicographically. Mixing them is an error: cannot compare a number with a string.

Logic#

&& and || short-circuit, so the right side is not evaluated when the left decides the answer - count > 0 && list[0] > 1 is safe on an empty list. Both always return a bool, even when given non-bool operands: 1 || false is true, not 1.

!x negates the truthiness of any value. Unary - requires a number: cannot negate a non-number.

Conditional#

color = i % 2 == 0 ? "#3f7fbf" : "#bf6b3f"

Binds looser than everything else, so the condition needs no parentheses. It is right-associative, so a ? b : c ? d : e chains as a ? b : (c ? d : e).


Truthiness#

Conditions - in ? :, &&, ||, ! and if - accept any value.

Falsy Truthy
false true
0 any other number
"" any non-empty string
[] any non-empty list
null any object, including {}

Empty strings and empty lists being falsy is what makes if var.label { ... } and if var.sizes { ... } read the way they are written.


Member access and indexing#

local size = [40, 30, 12]
size.x        // 40
size.y        // 30
size.z        // 12
size[0]       // 40

local opts = { wall = 2, gap = 0.4 }
opts.wall     // 2
opts["wall"]  // 2

.x, .y and .z on a list are sugar for indices 0, 1 and 2 - they are not a vector type. They are read on a list of any length, so [1, 2].z yields nothing rather than raising; the failure surfaces later, wherever that nothing is used.

On an object, a missing key is an error: no attribute "wdith". Reading a property of null is too: cannot read "x" of nothing.

Indexing past the end of a list is not an error; it yields undefined, which then usually fails where it is used. Indexing something that is neither a list, a string nor an object is an error: cannot index this value.


Function calls#

max(sizes)             // variadic, flattens its arguments
pow(2, 10)
sin(30)                // 0.5 - every angle is in degrees

Functions take positional arguments only. Named arguments are an error: pow: functions take positional arguments. A fixed-arity function checks its count: len takes 1 argument(s), got 2.

There are no user-defined functions. To name a piece of geometry, declare a component; to name a value, declare a local.

See the function reference for all thirty of them.


String interpolation#

${ ... } splices any expression into a string.

part "leg_${i}" { ... }
color = "#${hex}"
description = "wall ${var.wall}mm, ${len(sizes)} slots"

How each value renders:

Value Rendered as
number 3, 0.5 - JavaScript's default number formatting
string itself
bool true / false
list [1, 2, 3] - elements joined by , inside brackets, recursively
null the empty string
object {w: 3, h: 4} - each entry rendered by these same rules. Interpolating one is usually a mistake, but it at least names which object

A splice must contain exactly one expression; anything left over is an error: trailing input in ${ }. Errors raised inside a splice are reported at the position of the string that contains it, since the splice has no line of its own.