Syntax
How a SEL program is written, and what its values are. The operators have
a page of their own, and so do the functions.
spec/SPEC.md is the normative text; this page is for people writing rules.
Every line below of the form EXPRESSION => RESULT is executed by all five
implementations on every commit (tools/check-docs.sh). A result is written the
way the command-line tool prints it: text as it is, TRUE/FALSE, bin: and
hex for bytes, a tree dump for a structure, and !E_CODE for an error.
- A program is one expression
- Comments and whitespace
- Names
- Values and kinds
- Numbers
- Text
- Lists and records
- Booleans
- NULL
- Variables
- Binders:
_,_Kand named ones - Errors
- The grammar
A program is one expression
There are no statements. What looks like control flow is a function call:
IF(2 > 1, "yes", "no") => yesIF is not a keyword. It is an entry in the function table, like LEN. What
makes it work is that a function receives the caller's syntax tree, not
values, and decides for itself what to evaluate:
IF(1 > 0, "safe", 1 / 0) => safeThe division never happens: the argument was never evaluated, because IF
chose not to.
; and , are ordinary operators. ; evaluates both sides and yields the
right one; , builds a list. A block and an argument list are the same kind of
thing, and the last expression is the program's result:
A = 1; A + 1 => 2Comments and whitespace
# starts a comment that runs to the end of the line, except inside a text
literal. Spaces, tabs, carriage returns and newlines separate tokens and mean
nothing else, so a rule can be laid out however reads best:
"#1" & "a" # the rest of this line is a comment => #1aNames
Variables, functions and binders are ASCII identifiers —
[A-Za-z_][A-Za-z0-9_]* — and case-insensitive: total, Total and TOTAL
are one name.
total = 5; TOTAL + Total => 10TRUE, FALSE, NULL, AND, OR, NOT, XOR, EQL, IN, BAND, BOR
and BXOR are reserved:
IN = 1 => !E_RESERVEDA name that is not a variable in the context and was not assigned is an error, never an empty string:
FOO + 1 => !E_UNDEF_VARValues and kinds
A value has a kind and, independently, may have children — an ordered key→value map. There are four kinds:
| Kind | What it is |
|---|---|
TEXT |
a sequence of Unicode code points. Numbers are TEXT. |
BIN |
a sequence of bytes; not text, not assumed printable |
BOOL |
TRUE or FALSE — a kind of its own, not a string |
NONE |
no scalar of its own. A plain list is NONE with children; NULL is a NONE with neither |
TEXT and BIN convert only through UTF-8, and only when you ask:
TO_UTF8("ż") => bin:c5bc
FROM_UTF8(FROM_HEX("c5bc")) => ż
LEN(FROM_HEX("41")) => !E_NOT_TEXTText functions never decode bytes behind your back.
Numbers
There is no floating point. A number is TEXT that reads as
-?digits[.digits], and arithmetic on it is exact decimal. A literal is written
the same way:
0.10 + 0.20 == 0.30 => TRUE
19.99 * 3 => 59.97Scale is part of the value, so money keeps its cents:
2.50 + 2.50 => 5.00
1.5 * 1.5 => 2.25A leading zero goes and a trailing fraction zero stays; zero is never negative:
007 => 7
-0.00 => 0.00Text from outside is a number only if it is spelled like one — there is no implicit trimming:
" 2" + 1 => !E_NOT_NUM
TRIM(" 2") + 1 => 3
ISNUM(" 2") => FALSEHow the operators treat numbers — division's scale, rounding, remainders — is in Operators.
Text
Two literal forms. Quoted text takes escapes and interpolation:
"a\tb" => a b
"\u{17C}" => ż
"a\{not interpolated\}" => a{not interpolated}The escapes are \\ \" \n \t \r \{ \} and \u{HEX}; anything else is an
error:
"\q" => !E_ESCAPERaw text in single quotes takes none — no escapes, no interpolation, ''
for a quote. It is the form for regular expressions:
'^\d{3}$' => ^\d{3}$
'it''s' => it'sInterpolation
{…} inside quoted text holds any expression:
A = 3; "value {A} here" => value 3 here
A = 3; "{A + 1}" => 4
P = 19.99; Q = 3; "{ROUND(P * Q, 2)} PLN" => 59.97 PLNThe lexer rewrites the literal into a chain of & before parsing, so it costs
nothing at run time and obeys exactly what & obeys — a boolean is refused
rather than guessed at:
"{1 == 1}" => !E_NOT_TEXTPositions count code points, from 1
0 means "not found". Nothing counts bytes or UTF-16 units:
LEN("Zażółć") => 6
BLEN("Zażółć") => 10
LEN("👍a") => 2
LEFT("👍ab", 1) => 👍
FIND("ż", "Zażółć") => 3
FIND("q", "abc") => 0Text compares in UTF-8 byte order, the same on every host, and UPPER/LOWER
touch A–Z only (why).
Lists and records
A variable holding several values is an ordered key→value map. , builds a
list keyed from "1":
(3, 2, 1) => -{"1"=t"3", "2"=t"2", "3"=t"1"}
JOIN(("a", "b", "c"), "-") => a-b-cLIST builds one without flattening, and RECORD builds one with keys of your
choosing:
LIST(1, LIST(2, 3)) .> COUNT => 2
RECORD("sku", "AB-1", "qty", 3)["qty"] => 3An index is used verbatim as a key, so 1 and 1.0 are different keys:
A["x"] = 1; A["x"] => 1
A[1] = "a"; HAS(A, "1.0") => FALSE
A = (1, 2); A[3] => !E_NO_KEYAn operand of , that is a list contributes its values, which is what makes
appending work:
A = ("a", "b"); A = (A, "c"); JOIN(A, "-") => a-b-cScalar context
Where a single value is wanted, a list gives its first value, recursively. Returning several values costs nothing, and nothing in the language asks "is this a list":
A[1] = 3; A == 3 => TRUE
A = (7, 8); A + 0 => 7
R[1] = "ok"; R[2] = "why"; R & "/" & R[2] => ok/whyCOUNT counts children, so a scalar has none; LEN counts characters:
COUNT(("a", "b")) => 2
COUNT("abc") => 0
INDEXES(("a", "b")) => -{"1"=t"1", "2"=t"2"}Booleans
TRUE and FALSE are a kind of their own, and there is no truthiness.
Comparisons produce booleans, and IF, AND, OR, NOT and XOR accept
nothing else:
IF(1, "a", "b") => !E_NOT_BOOL
"x" AND TRUE => !E_NOT_BOOLSay what you mean instead:
NAME = ""; IF(NAME $== "", "missing", "ok") => missingThis removes the single most common source of "works on the server, not in the browser" in validation code.
NULL
NULL is an absence, distinct from empty text and from zero, and it never turns
into either on its own. Arithmetic, comparison and text functions refuse it:
NULL + 1 => !E_NULL
UPPER(NULL) => !E_NULL
NULL == 0 => !E_NULLIt is handled on purpose, with the coalescing operators and the null-safety functions:
NULL ?? "default" => default
" " ??? "fallback" => fallback
IS_NULL(NULL) => TRUE
GET(NULL, "k", "def") => def
COALESCE(NULL, "a", "b") => aVariables
A program reads variables from the context the host passes in, and may assign its own. Assignment is an operator that yields the value assigned:
A = 2 => 2
A = 1; A += 2; A *= 3; A => 9Assignment copies, all the way down — two variables never share structure:
A = (1, 2); B = A; B[1] = 9; A[1] => 1An indexed assignment creates what it needs, and lands where it says it lands, in whatever the variable is after the right-hand side ran:
A[COUNT(A)] = 1; A => -{"0"=t"1"}
A[1] = (A = 2); A => t"2"{"1"=t"2"}The context is changed in place, which is how a rule hands values back to the host (Using SEL).
Binders: _, _K and named ones
A function that walks a list — ALL, MAP, FILTER, SUM, SORT_BY,
BUCKET and the rest — evaluates its body once per element, with _ bound to
the element and _K to its key:
M["a"] = 1; M["b"] = 2; JOIN(MAP(M, _K & "=" & _), " ") => a=1 b=2A three-argument form names the binder instead, which is how an outer element stays reachable from an inner body:
R[1] = (1, 2); R[2] = (3, 4); ALL(R, ROW, ALL(ROW, _ > 0)) => TRUEThe name must be a bare identifier, and a binder cannot be assigned — it lives for one element:
ALL((1, 2), 1, _ > 0) => !E_EXPECT_SYMBOL
ALL((1, 2), (_ = 5; TRUE)) => !E_BAD_ASSIGNErrors
Evaluation stops at the first failure. The error carries a stable code, a message, and the position of the node that actually failed — never the caller's. Match on the code; the message is human text and may change.
Caught before the rule ever runs:
| Code | When |
|---|---|
E_SYNTAX |
it does not parse — including a chained comparison |
E_UNTERMINATED, E_ESCAPE |
a bad text literal |
E_RESERVED |
a reserved word used as a variable |
E_BAD_ASSIGN |
assignment to something that is not a name |
E_UNKNOWN_FUNC |
no such function |
E_ARITY |
wrong argument count |
E_REGEX_SYNTAX |
a pattern outside the portable subset |
E_DEPTH |
nested too deeply |
At run time:
| Code | When |
|---|---|
E_UNDEF_VAR, E_NO_KEY, E_NO_SCALAR |
something is not there |
E_NULL |
an operation that does not accept NULL got one |
E_NOT_NUM, E_NOT_TEXT, E_NOT_BIN, E_NOT_BOOL, E_NOT_INT |
the wrong kind |
E_EXPECT_SYMBOL |
a binder that is not a bare name |
E_DIV_ZERO, E_UTF8, E_RANGE, E_BAD_ARG, E_LEN_MISMATCH |
a bad value |
E_ABORT |
the rule called ABORT |
E_ABORT is the one a rule raises on purpose, and the one worth showing a user.
The full catalogue, with the limits that raise E_DEPTH and E_RANGE, is
the limits reference; the normative list is
spec/errors.md.
The grammar
In one screen, tightest binding first — the Operators page has the table with every operator:
program = sequence
sequence = list { ";" list }
list = assignment { "," assignment }
term = ... (assignment, logic, comparison, concatenation, arithmetic)
postfix = primary { "[" sequence "]" | ".>" call-or-name }
primary = number | 'raw' | "quoted" | name | name "(" [sequence] ")" | "(" sequence ")"spec/grammar.md is the complete, normative grammar.