SEL — Simple Expression Language
Version 0.1 (draft). This document is normative. Where an implementation and
this document disagree, the implementation is wrong. Where this document and
conformance/ disagree, that is a bug in one of them and must be resolved here
first.
1. Nature of the language
SEL has no statements. A program is one expression. Everything that looks like control flow is a function call.
Functions do not receive values. They receive the caller's AST nodes, and
decide for themselves whether to evaluate each one, when, and how many times.
IF evaluates its first argument, then exactly one of the other two. ALL
evaluates its second argument once per element of its first. Neither is a
keyword; both are ordinary entries in the function table.
Two consequences shape the whole grammar:
;and,are ordinary binary operators, not syntax. A block and an argument list are the same kind of tree, soIF(c, (a; b), d)needs no special parsing — the parenthesised sequence is just another operand. (;binds looser than,, so those inner parentheses are required; seegrammar.md.)- There is no
return, nobreak, nocontinue, no loop, and no user-defined function. Every expression yields a value; the last one yields the program's.
SEL is designed for one job: validating data identically on a PHP backend and a JS frontend. It prefers failing loudly over coercing quietly.
2. Source text
Source is UTF-8. Invalid UTF-8 in source is E_UTF8 at the offending byte.
2.1 Comments
# begins a comment that runs to the end of the line. # inside a string
literal is not a comment.
2.2 Whitespace
Space (U+0020), tab (U+0009), CR (U+000D) and LF (U+000A) separate tokens and are otherwise insignificant. No other character is whitespace.
2.3 Identifiers
identifier = (letter | "_") { letter | digit | "_" }
letter = "A".."Z" | "a".."z"
digit = "0".."9"Identifiers are ASCII only and case-insensitive. They are canonicalised
to upper case internally, so total, Total and TOTAL are one name. This
applies to variables, function names and aggregate binders alike.
TRUE, FALSE, NULL, AND, OR, NOT, XOR, EQL, IN, BAND, BOR, BXOR are
reserved and may not be used as variable names (E_RESERVED).
2.4 Number literals
number = digit { digit } [ "." digit { digit } ]No sign (use unary -), no exponent, no leading ., no trailing ..
007 is valid and canonicalises to 7. See §4.
2.5 Text literals
Two forms.
Quoted, "…" — supports escapes and interpolation.
| Escape | Means |
|---|---|
\\ |
\ |
\" |
" |
\n \t \r |
LF, TAB, CR |
\{ \} |
literal brace |
\u{H…} |
code point, 1–6 hex digits |
Any other \x is E_ESCAPE. \u{…} above U+10FFFF or in the surrogate range
D800–DFFF is E_RANGE. An unterminated literal is E_UNTERMINATED.
Raw, '…' — no escapes, no interpolation. '' inside means one '.
Everything else is literal. This form exists for regex patterns:
'^\d{3}-\d{2}$' needs no backslash doubling.
2.6 Interpolation
Inside a quoted literal, { … } contains a full SEL expression. The lexer
rewrites the literal into a concatenation before parsing proper:
"total: {A + 1}." ==> "total: " & (A + 1) & "."This is a lexer pass, not a runtime feature; the resulting tree contains no trace
of it. Braces nest, and braces inside a string literal within the expression do
not terminate it. An unterminated { is E_UNTERMINATED. {} with nothing in
it is E_SYNTAX.
Interpolation lowers to & and obeys §5.2 exactly — there is no separate rule.
Leading and trailing segments are kept even when empty, so "{A}" becomes
"" & A & "". That costs two no-op concatenations and buys uniformity: an
interpolation always applies scalar context and always rejects BOOL, and a
literal interpolating a BIN value yields BIN just as & would.
3. Values
Every value is one node:
Value {
kind : NONE | TEXT | BIN | BOOL
scalar : characters | bytes | boolean | (absent when NONE)
children : ordered map from text key to Value
}A value may have a scalar, children, both, or neither. Nothing in the language branches on "is this a list" — see §3.2.
3.1 Kinds
- TEXT — a sequence of Unicode code points.
- BIN — a sequence of bytes, 0–255. Not text; not printable by assumption.
- BOOL —
TRUEorFALSE. A distinct kind, not a string and not a number. This is what makes strictness cheap: every truth test is a kind check. - NONE — no scalar of its own. A list built by
,is NONE with children. A NONE with no scalar and no children represents NULL (the explicit absence of a value). In source code it is written asNULL. In host boundaries,from_native(None)/fromNative(null)produces it, andto_native()yieldsNone/null.
TEXT and BIN interconvert through UTF-8 and nothing else. There is no
encoding parameter anywhere in the language. Converting BIN to TEXT decodes
UTF-8 and raises E_UTF8 if the bytes are not valid UTF-8 (including
overlong forms, surrogates encoded as CESU-8, and code points above U+10FFFF).
Converting TEXT to BIN always succeeds.
3.2 Scalar context
asScalar(v):
if v.kind is not NONE -> v.scalar
else if v has children -> asScalar(first child in insertion order)
else if v is NULL -> E_NULL
else -> E_NO_SCALARThis is why A[1] = 3 makes A == 3 true, and why returning several values is
free: the caller reads R for the first and R[2], R[3] for the rest.
3.3 Keys and ordering
Keys are text. Children keep insertion order; re-assigning an existing key
keeps its original position. Order is observable through INDEXES, JOIN,
MAP, and the conformance dump, so it is normative.
Indexing uses the index expression's scalar verbatim as the key, with no
numeric normalisation: A[1] reads key "1", and A[1.0] reads key "1.0",
which is a different key. Lists built by , use keys "1", "2", … so integer
indexing works as expected.
Reading a missing key is E_NO_KEY. Reading an undefined variable is
E_UNDEF_VAR — never an empty string. Use HAS(x, key) to test.
Assigning to A[k] creates A and any intermediate levels if absent.
3.4 Identity
Evaluating an expression yields a value, not a snapshot of one. A variable reference yields the value that variable holds — the same value — so a mutation performed by a later sub-expression is visible through a reference obtained earlier:
A = 1; A[A["k"] = "k"] ==> t"k"The index expression created the key, and the pending read finds it. The same rule covers an aggregate binder, which names the element rather than a copy of it, and the compound assignment forms, which hold their target across the evaluation of the right-hand side.
Assignment is the only operation that copies. = deep-copies its
right-hand side (§5.7), which is what stops two variables from sharing
structure, and , (§5.9) and the aggregates copy what they collect for the same
reason. Nothing else copies — an implementation that copies anywhere else will
disagree with this section, and an implementation that copies nowhere will
disagree with §5.7.
Rebinding a variable does not affect a value already yielded. A = … makes
the name refer to a different value; a reference obtained before it still refers
to the old one:
A = (1, 2); A[(A = (A, 9)); "3"] ==> !E_NO_KEYThe index expression rebound A to a three-element list, but the pending read
still holds the two-element value A had when it was evaluated, and that has no
key "3". Contrast with the first example, where the index expression mutated
the value rather than replacing it.
This is the read-side counterpart of the argument in §5.7: a value that has been detached from the tree is not a value anything should still be reading through, and a mutation nothing can observe is as bad as a write nothing can read.
4. Numbers
There are no floating-point numbers in SEL. A number is a TEXT value whose content matches:
-? digit {digit} [ "." digit {digit} ]Arithmetic is exact decimal, computed on digit strings. No implicit trimming:
" 2" is E_NOT_NUM; call TRIM first. BOOL and BIN are never numbers
(E_NOT_NUM).
4.1 Canonical form
Leading zeros in the integer part are removed, leaving at least one digit. Trailing zeros in the fraction are kept — scale is part of the value. A zero value never carries a minus sign.
007 -> 7
2.50 -> 2.50 (scale 2, not "2.5")
-0.00 -> 0.004.2 Scale of results
Let sa, sb be the operand scales.
| Op | Result scale |
|---|---|
+ - |
max(sa, sb) |
* |
sa + sb |
% |
max(sa, sb) |
/ |
see §4.3 |
So 2.50 + 2.50 is 5.00, and 1.5 * 1.5 is 2.25. Money keeps its cents.
4.3 Division
DIV_SCALE is 10.
Long division runs to at most DIV_SCALE fractional digits. If the remainder
reaches zero at or before that point, the exact quotient is returned at its
minimal scale. Otherwise the result is rounded to exactly DIV_SCALE
fractional digits, half away from zero.
4 / 2 -> 2
10 / 4 -> 2.5
1 / 8 -> 0.125
1 / 3 -> 0.3333333333
2 / 3 -> 0.6666666667Division or modulo by zero is E_DIV_ZERO.
% is the remainder of truncated division and takes the sign of the dividend:
5 % 3 is 2, -5 % 3 is -2, 5.5 % 2 is 1.5.
4.4 Rounding
Every rounding in SEL — ROUND, and the inexact case of / — is half away
from zero. ROUND(2.5, 0) is 3; ROUND(-2.5, 0) is -3.
4.5 Comparison
==, !=, <, <=, >, >= compare numerically after aligning scales, so
"5.00" == "5" is TRUE. The $ family compares text, so "5.00" $== "5" is
FALSE. Both are true statements about the same pair of values, which is the
point of having two families.
5. Operators
Tightest binding first. Same-row operators associate as marked.
| # | Operators | Assoc | Notes |
|---|---|---|---|
| 1 | x[k] f(…) ( ) x .> f(…) x .> f |
left | indexing, call, grouping, forward pipeline |
| 2 | -x |
prefix | numeric negation |
| 3 | * / % |
left | |
| 4 | + - |
left | |
| 5 | & |
left | concatenation |
| 6 | BAND |
left | BIN, equal length |
| 7 | BXOR |
left | BIN, equal length |
| 8 | BOR |
left | BIN, equal length |
| 9 | ?? ??? |
right | null-coalescing (??), vacuous-coalescing (???), short-circuit |
| 10 | == != < <= > >= $== $!= $< $<= $> $>= EQL IN |
none | |
| 11 | NOT x |
prefix | |
| 12 | AND |
left | short-circuit |
| 13 | XOR |
left | |
| 14 | OR |
left | short-circuit |
| 15 | = += -= *= /= %= &= |
right | |
| 16 | , |
left | list build / argument separator |
| 17 | ; |
left | sequence |
Two deliberate choices:
Comparisons are non-associative. a < b < c is E_SYNTAX at parse time
rather than a confusing E_NOT_BOOL at runtime.
NOT binds looser than comparison. NOT a == b means NOT (a == b), which
is what it reads like. This differs from C-family !.
5.1 Arithmetic — + - * / % and unary -
Both operands must be numbers (§4). An operand that is NULL raises E_NULL.
Anything else is E_NOT_NUM.
5.2 Concatenation — &
Operands must be TEXT, BIN, or numbers. An operand that is NULL raises E_NULL.
If both are TEXT the result is TEXT; if either is BIN the result is BIN, with
TEXT operands encoded as UTF-8. BOOL is E_NOT_TEXT.
5.3 Text comparison — $== $!= $< $<= $> $>=
Both operands are taken as bytes (TEXT via UTF-8) and compared bytewise. An
operand that is NULL raises E_NULL. For valid UTF-8 this is code-point order.
It is specified as byte order because JS strings compare in UTF-16 order
natively, which disagrees above U+FFFF; an implementation must not use its
host's native comparison.
5.4 Deep comparison — EQL, IN
a EQL b is TRUE when both have the same kind, equal scalars (BIN compared
bytewise, TEXT bytewise, numbers not normalised — EQL is structural), and
children with the same keys in the same order, pairwise EQL. Two values
that are NULL are EQL to each other. NULL is not EQL to any scalar or
non-null value.
x IN list is TRUE when some child of list is EQL to x. If list has no
children it is compared directly against x. NULL IN list is TRUE if any
element of list is NULL.
Every identity in the language is this one: DISTINCT/DEDUPE, BUCKET's
group key (§7.3) and an index key (§3.3) tell 1, 1.0 and 1.00 apart as
EQL does. To compare numbers by value there, compare their canonical forms:
CANON(x) (§7.6) is the one spelling every equal number shares.
5.5 Coalescing — ?? and ???
a ?? b evaluates a. If a evaluates to NULL, or if evaluating a fails
with E_NO_KEY or E_UNDEF_VAR, b is evaluated and yielded. Otherwise a is
yielded and b is not evaluated.
a ??? b evaluates a. If a evaluates to NULL, an empty string "", a
whitespace-only string, an empty list (a childless NONE), or fails with
E_NO_KEY or E_UNDEF_VAR, b is evaluated and yielded. Otherwise a is
yielded and b is not evaluated.
Both operators short-circuit: if a is non-vacuous, b is never evaluated.
5.6 Logic — AND OR NOT XOR
Operands must be BOOL (E_NOT_BOOL). There is no truthiness: IF(name, …) is an
error, not a shortcut. Write IF(name $!= "", …).
AND and OR short-circuit: FALSE AND (1/0) is FALSE, not an error.
XOR evaluates both.
5.7 Bitwise — BAND BOR BXOR
Both operands must be BIN of equal length (E_NOT_BIN, E_LEN_MISMATCH).
The result is BIN of that length. These operate on byte strings, not integers.
5.8 Assignment
The target must be an identifier, optionally followed by index operations
(A, A[1], A["x"][2]). Anything else is E_BAD_ASSIGN at parse time.
= copies by value: the assigned value, including all children, is deep
copied. Two variables never share structure.
The target is resolved before the right-hand side is evaluated: the base
variable and every intermediate level are created first, and each index
expression is evaluated once, left to right. So A[COUNT(A)] = 1 sees the A
that resolving the target just created.
The value is then stored at that path, in the tree as it exists once the right-hand side has been evaluated. This matters only when the right-hand side or a later index expression replaces a level the walk already passed through:
A[1] = (A = 2); A ==> t"2"{"1"=t"2"}The alternative — keeping hold of the container object found during the walk and writing into it — discards the assignment silently whenever that object has since been detached from the tree, and a write that nothing can ever read is a worse answer than a visible one. §3.4 states the read-side half of the same rule.
The compound forms += -= *= /= %= &= read the target, apply the matching binary
operator, and store back. The target must already exist.
An assignment evaluates to the value assigned.
5.8 Sequence — ;
Evaluates left then right, yields the right. A trailing ; is permitted and
yields the value before it.
5.9 List building — ,
Produces a NONE value with children keyed "1", "2", … Each operand
contributes: a value with children and no scalar of its own contributes each
of its children's values in order; anything else contributes itself. Keys of
contributed children are not preserved — the result is always renumbered
from 1.
This is what makes the append idiom work:
A = ("a", "b");
A = (A, "c"); # A is now three elements, keys 1..35.10 Forward pipeline — .>
The forward pipeline operator passes an expression into a function invocation on its right-hand side. It is desugared entirely at compile time:
"hello" .> UPPER # desugars to UPPER("hello")
(1, 2, 3, 4) .> FILTER(_ % 2 == 0) # desugars to FILTER((1, 2, 3, 4), _ % 2 == 0)
"hello world" .> LEFT(5) # desugars to LEFT("hello world", 5)
"," .> JOIN((1, 2, 3), _) # desugars to JOIN((1, 2, 3), ",")Rules:
- Thread-first default:
x .> f(...)prependsxas the first argument tof. - Bare identifier:
x .> fis equivalent tox .> f(), desugaring tof(x). - Placeholder
_: If the number of arguments given tofis already at leastf's minimum arity and one of the top-level arguments is a bare identifier_, that placeholder is replaced byxrather than prependingx. - Postfix binding:
.>binds at the postfix level (tightest binding power alongside indexing[ ]), sox .> f() > 0parses as(f(x)) > 0, anda .> f() AND b .> g()parses asf(a) AND g(b)without requiring extra parentheses. - Compile-time validation: The right-hand side must resolve to a valid registered function name or call; otherwise
E_UNKNOWN_FUNC,E_ARITY, orE_SYNTAXis reported at compile time.
6. Evaluation
6.1 Context
One Value is the root context. Variables are its direct children. Host code
builds and reads it with the same API the interpreter uses — there is no second
representation of state.
There is no lexical scoping and no call stack of frames. The single exception is aggregate binders (§7.3), which push one name for the duration of one element.
6.2 Order
Evaluation is strictly left to right wherever both operands are evaluated. The
only operators that skip evaluation are AND and OR. The only functions that
skip or repeat evaluation are IF and the aggregates.
6.3 Errors
Evaluation stops at the first failure. An error carries a stable code, a human message, and the position of the node that actually failed — no caller wraps it, re-messages it, or adds a stack. The innermost failure is what the host sees.
Codes are listed in errors.md. Conformance tests assert on code and position
only, never on message text, so messages remain free to change and to be
translated.
6.4 Limits
Parser nesting depth and evaluation depth are capped (implementation-defined,
at least 200) and exceeding either is E_DEPTH. This is a denial-of-service
guard, not a language feature.
What each construct costs is part of the contract, not an implementation
detail. A parenthesis, a call's parentheses and an index bracket each cost two
levels — the construct's own and the sequence inside it — while a prefix
operator and an assignment cost one. Those numbers are what
conformance/10-limits.selt pins, at exact columns, and they are what makes
E_DEPTH land in the same place on every host. The index bracket is the one
that drifted: it recurses from outside the rule that counts, so four hosts
charged it one level for five stack frames until it was counted separately.
Every construct that can nest is counted, including prefix operators. A
chain of NOT or unary - recurses in the parser without passing through a
parenthesis, a call or an index, so it is easy to leave out of the count — and
leaving it out is not a cosmetic bug. Uncounted, - repeated about twenty
thousand times raised a host-level RangeError on the JS host and segfaulted
the C++ one, which is precisely the failure this cap exists to prevent. The
error is reported at the operator that crossed the limit, not at the start of
the chain.
dependencies() is capped by the evaluation depth and raises the same
E_DEPTH at the same node. It walks the tree without evaluating it, so it is
neither of the two depths above and was left uncounted in every host; A+A+A…
repeated about fifty thousand times then reached each host's own stack —
RangeError on JS, RecursionError on Python, an exhausted control stack on
Lisp, and a segfault on C++ and PHP. That a program's dependencies cannot be
computed exactly when the program could not have been evaluated is the reason
the two share a limit rather than each having one.
A value's nesting is capped by the same number, and exceeding it is
E_DEPTH. Every host walks a value recursively to copy it, to compare it with
EQL, to dump it, and to convert it to and from native data. A chain of index
brackets in an assignment target is walked iteratively — A[1][2][3] is a
chain of index nodes, not a nesting of them — so neither of the two depths above
ever saw it, and the value it built could be nested past what those walks
survive. Uncounted, the hosts disagreed about where: an uncaught
RecursionError on Python at about a thousand levels, an uncaught RangeError
on JS at about four thousand, a segfault on C++ at about sixty thousand, while
PHP and Lisp still answered. The error is reported at the assignment target.
A value can also be nested past the cap through a host's own API, where there is no source and nothing to report a position against: building a value from the leaf up, nothing knows how deep it will end up, so the cap is enforced by the operations that walk it rather than by the one that adds a child. Such a value can be held; it cannot be copied, compared, dumped or converted.
A depth counts nesting in the source; a walk of the tree counts nodes. The
two are the same number for ((((1)))) and wildly different for 1+1+1+…,
which nests nothing and yet builds a tree as deep as it is long. A cap on the
first does not bound the second, and any walk of a tree — evaluating it,
analysing it, copying it, or freeing it — needs its own count or it will find
the host's stack instead. That is the general rule the three caps are instances
of, and every one of them was found the same way.
The evaluator is the depth authority. Whether a program exceeds the
evaluation depth is decided by evaluating it as written, and nothing that runs
before the evaluator may change that answer. An optimiser must neither raise
E_DEPTH itself — it would report it for a branch the evaluator never visits,
IF(TRUE, 7, <a chain 201 deep>) — nor make it disappear: a chain of 201
additions is E_DEPTH at its innermost node even though every one of them
folds, and so is one of 199 behind an assignment, which costs a level of its
own. A tree that reaches the cap is left as written and evaluated as written;
conformance/10-limits.selt pins both sides of the boundary for a chain the
optimiser could fold.
Three arguments name a size rather than a value, and a large one asks for more work or more memory than any host has. Each is capped, and exceeding the cap is an ordinary SEL error rather than a host failure:
| Argument | Cap | Beyond it |
|---|---|---|
ROUND(x, n) scale |
1 000 000 | E_RANGE |
POWER(x, n) exponent |
100 000 | E_RANGE |
a regex quantifier bound, as in a{n} or a{n,m} |
65 535 | E_REGEX_SYNTAX |
The quantifier cap is PCRE2's own hard limit rather than a number of SEL's choosing: above 65 535 PCRE refuses to compile the pattern at all, so no cap above it could be honoured on a PHP host.
These caps exist because without them each host fails in its own way, and one
of them fails quietly: ROUND(1.5, 4294967296) exhausted memory
on two hosts and raised a host-level RangeError on a third, while
POWER(10, 4294967299) returned 1000 in JS — a confident wrong answer, caused
by a shift that silently truncates the exponent to 32 bits. A rule that asks for
a million-digit scale is a mistake in the rule; the language should say so in the
same vocabulary as every other mistake.
An argument cap is not a value cap. The three caps above bound arguments
that name a size; they say nothing about how big the number that comes out may
be. POWER's exponent is capped, but its base is not, so nesting one call
inside another multiplies the exponents and steps straight over the cap:
POWER(POWER(10, 20), 100000) is 2 000 001 digits, and every host produced it.
One more level of nesting and a host does not report E_RANGE, it exhausts its
memory. So the size of a value is capped too:
| Value | Cap | Beyond it |
|---|---|---|
| integer digits of a number | 1 000 000 | E_RANGE |
| fractional digits of a number | 1 000 000 | E_RANGE |
Two independent caps rather than one budget shared between them, because
ROUND(99.5, 1000000) is 1 000 002 digits and legal under the scale cap above:
a single budget of 1 000 001 would have shrunk what this section already allows.
Set where they are, the caps refuse nothing that the argument caps permit.
Both are on the rendered size, which is what a host has to hold and what LEN
counts — not on the stored digit string. The distinction is not academic:
POWER(POWER(0.1, 20), 100000) stores the single digit 1 with a scale of
2 000 000, because leading zeros are not stored, so a cap on the digit string
would not notice it at all.
The cap is checked wherever a number is built, not where it is rendered.
POWER is repeated squaring over multiplication, so an over-large result is
refused at an intermediate step and the value that would exhaust memory is never
allocated.
A numeral too long to hold is E_RANGE wherever it appears — as a literal, as
the result of arithmetic, or as text that arithmetic reads. It is not
E_NOT_NUM: every character of it is a digit, and "not a number" would be
false. ISNUM is a probe and answers rather than raising, so it is FALSE for
such a value: ISNUM(x) is true exactly when x can be used as a number.
7. Functions
7.1 Calling
NAME(a, b, c) — the parenthesised expression is a , tree, flattened into an
argument vector. NAME() has zero arguments. Names are case-insensitive.
An unknown name is E_UNKNOWN_FUNC at parse time, not run time.
Each function declares a minimum and maximum arity, checked by the framework
before the body runs, so no function body counts its own arguments (E_ARITY).
A function is declared strict or lazy. A strict function's arguments are
all evaluated, left to right, before the body runs. A lazy function receives the
argument nodes and evaluates what it chooses. Only IF and the aggregates are
lazy. An application's own functions (§8.1) are always strict.
7.2 Control
| Signature | Meaning |
|---|---|
IF(cond, then) |
cond must be BOOL. Evaluates and yields then if TRUE; yields TEXT "" if FALSE. |
IF(cond, then, else) |
Evaluates and yields exactly one branch. |
COND(c1, r1, …, default) |
Flat multi-branch selection. See below. |
ABORT(message) |
Always fails with E_ABORT and the given message. |
COND takes condition/result pairs followed by a mandatory default. It evaluates
conditions in order, stops at the first TRUE, and evaluates only that result —
identical in every respect to the nested IF ladder it replaces. Each condition
must be BOOL.
COND(SCORE >= 90, "A",
SCORE >= 80, "B",
SCORE >= 70, "C",
"F")The argument count must be odd (E_ARITY otherwise, at compile time).
IF can safely let its two-argument form default to "" because there is one
branch and nothing to mis-pair. COND cannot: with an even count, a single
miscounted comma shifts every condition/result pair by one and the rule still
compiles. Requiring the default makes that a compile-time error rather than a
wrong answer at run time. Write "" explicitly when you mean nothing.
COND adds no grammar and no new node type — , is already an ordinary operator
and the parser already hands functions a flattened argument vector.
7.3 Aggregates
The reason no loop is needed. Each evaluates its body argument once per child of its first argument, in insertion order.
| Signature | Yields |
|---|---|
ALL(list, body) |
BOOL — TRUE if body is TRUE for every element. Short-circuits on the first FALSE. Empty list yields TRUE. |
ANY(list, body) |
BOOL — TRUE if body is TRUE for any element. Short-circuits. Empty list yields FALSE. |
MAP(list, body) |
list of each body result, renumbered from "1". |
FILTER(list, body) |
the elements for which body is TRUE, keys preserved. |
SUM(list, body) |
the exact sum of each body result. Empty list yields 0. |
JOIN(list, sep) |
TEXT — strict, not an aggregate body; concatenates each element's scalar with sep between. |
SORT(list [, body]) |
list sorted ascending; body optional (defaults to element itself). |
SORT_DESC(list [, body]) |
list sorted descending; body optional (defaults to element itself). |
SORT_BY(list, [binder,] key [, dir]) |
list sorted by evaluated key; optional dir ("ASC" or "DESC", default "ASC"). |
BUCKET(list, [binder,] key) |
the elements grouped by evaluated key: a record whose keys are the group keys, in order of first appearance, each holding the list of its members (renumbered from "1"). |
BUCKET(list, [binder,] key, proj) |
one proj result per group, as a list; within proj, the binder is the group's member list and _K its key. |
Keys are part of the value. FILTER is the one step that keeps its input's
keys; every other aggregate, and every list function of §7.4, renumbers from
"1". A pipeline's keys are those its steps produce in the order written, and
a _K read after a step sees that step's keys: MAP(…) .> FILTER(…) keeps the
MAP's numbering for the rows it keeps, so an implementation that filters
before it maps must not let that show — not in the answer's keys, and not in a
later _K.
Bucket keys. The two spellings group differently, because only one of them has to make a record key out of the group key:
- In the two-argument spelling the group key is an index key (§3.3): the
key's scalar, verbatim, and only text or a number will do. A
NULLkey isE_NULL; a boolean, binary, list or record key isE_NOT_TEXT— the errors indexing gives — reported at the key expression. The result is a record, and.> MAP(proj)over it is the same value as the three-argument spelling for every key this spelling accepts:_Kis the key's text either way. - In the three-argument spelling the group key is compared by identity
(§3.4) and may be any value — a list of several fields groups by all of them
— and
_Kis that value.
An SQL backend must preserve this identity independently of a column's default collation: case folding or ignoring trailing spaces must not merge distinct text keys. Projection through an intermediate relation does not relax this requirement; when identity cannot be proved, grouping must remain local.
Within a body, _ is bound to the element and _K to its key.
A three-argument form replaces _ with a name of your choosing:
ALL(items, ITEM, ITEM["qty"] > 0)The second argument must be a bare identifier node, which the function checks
by inspecting the AST it was handed — E_EXPECT_SYMBOL otherwise. _K is still
available. Binders shadow any variable of the same name for the duration of the
body and are removed afterwards; nested aggregates shadow independently.
If the first argument has no children, it is treated as a one-element list
containing itself when it has a scalar (consistent with §3.2), and as an
empty list when it is NONE. The second case is what FILTER returns when
nothing matched, so ALL(FILTER(…), …) is TRUE rather than a scalar-context
failure.
7.4 Structure
| Signature | Yields |
|---|---|
COUNT(x) |
number of children |
INDEXES(x) |
list of the keys, in order |
HAS(x, key) |
BOOL |
LIST(v1, v2, …) |
list of values without flattening nested lists/records |
RECORD(k1, v1, k2, v2, …) |
record from key-value pairs; even argument count required (E_ARITY otherwise); keys are case-sensitive, repeated keys keep their first insertion position and last value; all arguments still evaluate left-to-right, including overwritten values |
TAKE(list, n) |
first n elements as a list (n >= 0, E_RANGE if negative, E_NOT_INT if non-integer) |
DROP(list, n) |
list after dropping first n elements (n >= 0, E_RANGE if negative, E_NOT_INT if non-integer) |
SELECT_COLS(rel, c1, c2, …) |
list of records with only specified column keys preserved |
DISTINCT(list) |
list of unique elements preserving order of first occurrence via EQL |
DEDUPE(list) |
the same as DISTINCT: the relational spelling of the one operation |
LINK(left, right, pred), LINK(left, right, L, R, pred) |
inner join: one joined row (below) per pair of a left element and a right element for which pred is TRUE, in left order then right order; within pred, _1 (or L) is the left element and _2 (or R) the right; any other argument count is a compile-time E_ARITY |
LINK_LEFT(…) |
the same, plus one row per left element that matched nothing, whose right side is empty |
TOP(list, [binder,] [body,] n), TOP_DESC(…) |
the first n of SORT(list, [binder,] [body]) / SORT_DESC(…), as one step |
TOP_BY(list, [binder,] key, n [, dir]) |
the first n of SORT_BY(list, [binder,] key [, dir]), as one step |
Slices compose in pipeline order. A DROP after a TAKE removes elements
from that bounded result; it cannot restore elements excluded by the TAKE.
For example, LIST(1, 2, 3) .> TAKE(2) .> DROP(1) contains only 2, and
LIST(1, 2, 3) .> TAKE(1) .> DROP(2) is empty.
A count of zero still evaluates the list. TAKE(list, 0), DROP, and the
TOP family with n of 0 evaluate list before answering the empty list,
so TOP(LST, 0) with LST unbound is E_UNDEF_VAR, not (); an optimiser
that fuses a sort and a TAKE into a TOP must keep that.
Joined rows. A joined row is a record whose keys are, in order of first
occurrence: the nested records the left element already carried (the rows an
earlier LINK in the same pipeline bound), the left binders, the right
binders, then the promoted fields — the left element's scalar fields whose
names, compared ASCII-case-insensitively, do not occur in the right element,
followed by the right element's non-NULL scalar fields whose names do not
occur in the left. This is decided for each pair from its own two elements, in
each element's own field order: elements of one side need not share a shape,
and no row takes its keys from another. A nested record is a field whose
value is a record with at least one field; every other field — text, a
number, BOOL, BIN, NULL, a list — is a scalar field. The left binders are _1, the name given in the
five-argument form, and — when the argument is a bare name, or a pipeline whose
source is one — that name and its ASCII lowercase; the right binders are _2
and likewise. A binder holds the element as pred saw it, which for a named
argument is the element extended with the name and its lowercase as keys
holding the element (the lowercase only where the element has no such key,
and neither when the element already has a field of the name itself, which it
is then bound as), and for an argument with no name (a literal, or any
other expression) is the bare element — _1 and _2 are never added as
keys. Each key appears once, where it first occurred: a binder
key holds the row this LINK bound even when the left element carried a
nested record of the same name from an earlier one (the earlier _1, or a
relation joined twice), and every other key holds its first value. An
unmatched LINK_LEFT row holds under each right binder a record shaped like
the right elements whose every field is NULL — shaped like the first right
element as the right binder holds it (with its name keys); with no right
elements, a record of just those name keys, or NULL when the right argument
has no name — and promotes nothing from the right; that record is the right
element its left fields are compared with.
How a LINK evaluates. The pairs are the left elements in order, each with
every right element in order. A NULL element, or one with no fields, is an
element like any other, so a pred that indexes it fails as it would anywhere
(E_NO_KEY). When pred is one comparison (== or $==) whose two sides read
one binder each, the right side's expression is evaluated once per right
element first, then the left side's once per left element, and pairs are
matched by those values as the comparison would compare them (a NULL on
either side matches nothing) — so an error in the right side's expression is
reported before one in the left's. Any other pred is evaluated once per pair,
left element outer, right element inner. With no right elements pred is never
evaluated: an unmatched LINK_LEFT row costs no evaluation of it, and a LINK
over an empty side is the empty list whatever pred would have done.
A FILTER after a LINK is evaluated as written. An implementation may
test a conjunct of the FILTER's predicate against one side's elements before
the join, to join fewer rows, but the program's value is that of evaluating the
joined rows in order and the predicate left to right, AND short-circuiting
as §5 says: a conjunct tested early that would raise keeps the element for the
FILTER to decide, so no error is reported that the predicate as written would
not have reached on that row, and none is missed — which also means a conjunct
is tested early only when every conjunct before it is tested there too, or
cannot raise on any row of the join: an earlier conjunct left for the join
might otherwise raise on a row the early test would have dropped. A
comparison (§5.3, or == and its kin) between literals and fields cannot
raise when every such field is carried by every row of the one side that
carries it at all, with the kind the operator takes — text, or a number —
and that side is not the null-extended side of a LINK_LEFT. ORDERS .> LINK(C, …) .> FILTER(_["status"] $== "A" AND _["orders"]["amount"] > 2) over an order whose
status is "B" and whose amount is text answers the rows it would answer had
the join been assigned to a variable first; the same predicate with its
conjuncts swapped is E_NOT_NUM at the amount, in either form.
Nothing else an early test does can be seen either. The FILTER's result
keeps the keys the joined rows had (§7.3), not the positions of the rows a
smaller join would have made. Every join still computes every key it would
have computed: an element dropped early still raises in a key expression it
cannot evaluate, so does a join above it, and a side emptied early does not
spare the other side's keys. And a member the predicate reads is the joined
row's: under explicit binders, LINK(C, L, R, …), the right element is
_["R"] and _["C"] is no member, and _["A"]["cid"] reads the element
A carries, never a field of the same name that the rows of a join below
carry.
7.5 Text
Positions are 1-based, and 0 means "not found". Lengths and positions count
code points, never bytes or UTF-16 units. This differs from Aster, which is
0-based; one base for everything is worth the divergence.
| Signature | Yields |
|---|---|
LEN(x) |
code point count |
LEFT(x, n) / RIGHT(x, n) |
leading / trailing n code points; fewer if shorter |
SUBSTR(x, start [, len]) |
from start (1-based) for len code points, or to the end |
FIND(needle, hay [, from]) |
1-based position, or 0 |
REPLACE(needle, repl, hay) |
all occurrences, left to right, non-overlapping |
SPLIT(x, sep) |
list; empty sep is E_BAD_ARG |
TRIM(x) / LTRIM(x) / RTRIM(x) |
strips space, tab, CR, LF |
UPPER(x) / LOWER(x) |
ASCII only — see below |
BACKWARDS(x) |
code points reversed |
REPEAT(x, n) |
n copies, n >= 0 |
PADL(x, n, fill) / PADR(x, n, fill) |
pad to n code points; no truncation if longer |
CHAR(n) |
the code point n as TEXT |
CODE(x) |
code point of the first character |
UPPER and LOWER map only A–Z ↔ a–z and leave every other code
point untouched. PHP's strtoupper is byte- and locale-based while JS's
toUpperCase applies full Unicode case mapping; there is no way to make those
agree without shipping a case table, and guessing would break the invariant
silently rather than loudly.
7.6 Numbers
| Signature | Yields |
|---|---|
ABS(x) SIGN(x) |
SIGN is -1, 0 or 1 |
CEIL(x) FLOOR(x) TRUNC(x) |
scale 0 |
ROUND(x, n) |
scale exactly n, n >= 0, half away from zero |
MIN(a, b, …) MAX(a, b, …) |
at least one argument |
POWER(x, n) |
n a non-negative integer; result scale scale(x) * n |
CANON(x) |
the canonical form of the number x: §4.1's, with the fraction's trailing zeros and then a bare point removed |
ISNUM(x) |
BOOL — whether x parses as a number |
CANON gives every number one spelling per value: CANON(1.50) is 1.5,
CANON(2.000) is 2, CANON(100) is 100, CANON("007.50") is 7.5 and
CANON(-0.00) is 0. Scale is part of a number (§4.1) and so of its identity
(§5.4); CANON is how a rule compares numbers by value in the places that
compare by identity — LIST(1.0, 1) .> MAP(CANON(_)) .> DEDUPE() has one
element. Its argument is read as every numeric argument is: text that is not a
number (" 2", "1.", ".5", "1e3") is E_NOT_NUM, BOOL and BIN are
E_NOT_NUM, NULL is E_NULL. The result is an ordinary number and sorts as one.
SQRT, LOG and RANDOM do not exist: the first two have no exact decimal
result, and the third would make the conformance suite meaningless.
7.7 Binary
| Signature | Yields |
|---|---|
BLEN(x) |
byte length |
TO_UTF8(x) |
BIN — the UTF-8 bytes of TEXT x |
FROM_UTF8(x) |
TEXT — decodes BIN x, E_UTF8 if invalid |
TO_HEX(x) |
TEXT — lower-case hex of BIN x |
FROM_HEX(x) |
BIN — even-length hex, either case; E_BAD_ARG otherwise |
ENCODE_BASE64(x) |
TEXT — standard alphabet, always padded |
DECODE_BASE64(x) |
BIN — padding required, E_BAD_ARG on any invalid character |
CRC32(x) |
TEXT — CRC-32/ISO-HDLC as 8 lower-case hex digits |
BTL(x) |
list of byte values 0–255 |
LTB(list) |
BIN from a list of byte values; E_RANGE outside 0–255 |
Functions taking BIN accept TEXT and encode it as UTF-8 first.
7.8 Regular expressions
SEL accepts a subset of syntax every host's regex engine agrees on, checked
at compile time. There are four of them behind five hosts — PCRE in PHP,
ECMAScript in JS and (through SRELL) in C++, Python's re, and cl-ppcre in Lisp
— and the subset is the intersection. Anything outside the subset is E_REGEX_SYNTAX with the offset of
the offending character — a clear failure instead of a silent divergence between
backend and frontend.
| Signature | Yields |
|---|---|
RMATCH(pat, subj [, flags]) |
BOOL |
RFIND(pat, subj [, flags]) |
1-based code point position of the first match, or 0 |
RREPLACE(pat, repl, subj [, flags]) |
TEXT, all matches replaced |
RGROUPS(pat, subj [, flags]) |
list: whole match at "1", capture n at "n+1"; empty list if no match |
Allowed: literal characters, . ^ $, character classes […] with ranges
and negation, the escapes \d \D \w \W \s \S, the control escapes \n \r \t \f,
escaped metacharacters, quantifiers * + ? {n} {n,} {n,m} and their lazy ?
forms, groups ( ), non-capturing groups (?: ), and alternation |.
Rejected: POSIX classes [[:alpha:]], \p{…}, backreferences, lookahead and
lookbehind, atomic groups, possessive quantifiers, inline modifiers (?i),
\A \z \Z \G \K, conditionals, recursion, and the three below.
\band\B. A word boundary is defined in terms of the engine's notion of a word character, and the two engines disagree — PHP'sumodifier enables PCRE2's UCP, soéis a word character there and is not in ECMAScript. There is no rewrite that fixes this. Write the boundary explicitly, e.g.(^|[^0-9A-Za-z_]).\v. In PCRE it means "any vertical whitespace"; in ECMAScript it means U+000B. Same spelling, different language.\D,\W,\Sinside a character class, which cannot be expanded (below). Negate the whole class instead. A leading]is likewise rejected — PCRE reads[]as a literal bracket and ECMAScript as an empty class — so write\].
\d, \w and \s are rewritten, not passed through. Every host expands
them into explicit ASCII classes before compiling:
| Escape | Becomes |
|---|---|
\d / \D |
[0-9] / [^0-9] |
\w / \W |
[0-9A-Za-z_] / [^0-9A-Za-z_] |
\s / \S |
[ \t\n\r\f\x0b] / [^ \t\n\r\f\x0b] |
Inside a character class the bracketed form is dropped, so [\d.] becomes
[0-9.]. This makes the definitions structural rather than dependent on a
library flag: without it, \d matches Arabic-Indic digits under PHP and not
under JS.
Flags: i and nothing else. i is rejected with E_BAD_ARG on a pattern
containing non-ASCII literals, because case folding is the one area the two
engines cannot be made to agree.
m and s are deliberately not offered. JS treats \r, U+2028 and U+2029 as
line terminators and PCRE treats only \n as one, so every construct whose
meaning depends on where a line ends — ., ^, $ under m — would differ
between backend and frontend. Instead:
- Dotall is permanently on.
.means "any code point", in both hosts. Write[^\n]when you mean "not a newline"; that is portable and says what it means. ^and$anchor only to the ends of the subject. PCRE's$otherwise also matches before a trailing newline, so PHP must additionally compile with theDmodifier. Python'sreand cl-ppcre behave like PCRE here and have no such modifier, so both hosts instead lower^and$to\Aand\Zafter validating the pattern — the same rule reached by rewriting rather than by a flag.
Four requirements on implementations, without which the hosts diverge. Each is written for the two engines SEL started with; the three added since each needed its own spelling of the same rule, and the third requirement below is where they differ most:
- Compile with
uand dotall in both hosts (usin JS,usDin PHP), and expand the class escapes as above.ugives code point matching in both; the expansion is what makes\d,\wand\smean the same thing, since PHP'sualso enables UCP and JS's does not. - Report code point offsets.
preg_*returns byte offsets and JS returns UTF-16 unit offsets; neither is what SEL reports. - Splice replacements manually from match offsets. Do not hand the
replacement string to
preg_replaceorString.replace. SEL replacement syntax is$0–$9for the whole match and captures, and$$for a literal$; every other character is literal. This avoids PCRE's\1and JS's$&,$'and$`.
A capture that did not participate in the match yields TEXT "".
7.9 Safety and Null
| Signature | Yields |
|---|---|
IS_NULL(x) |
BOOL — TRUE if x is NULL, FALSE otherwise. Never raises. |
IS_NOT_NULL(x) |
BOOL — TRUE if x is not NULL, FALSE otherwise. |
COALESCE(a, b, …) |
first non-null argument, or NULL if all are null. Evaluates arguments lazily. |
GET(target, key [, default]) |
reads target[key]; yields default (or NULL) if absent or target is NULL. |
PATH(target, path_str [, default]) |
walks dot-separated child keys in memory; yields default (or NULL) if any key is absent. |
IS_BLANK(x) |
BOOL — TRUE if x is NULL, empty text "", whitespace-only text, or an empty list. |
IS_PRESENT(x) |
BOOL — inverse of IS_BLANK(x). |
8. Host interface
Every implementation exposes the same shape:
Sel.compile(source) -> Program # throws on syntax error
Program.run(context) -> Value
Program.dependencies() -> array of variable names, upper case
Program.ast -> the parse tree
Value.text/bin/num/bool/list/null
Value.fromNative/toNative # where the host has native data
Value.isNone/isText/isBin/isBool/isNull # kind predicatesfromNative/toNative convert between a host's own maps and lists and a
Value. C++ has neither, and deliberately: it has no native map or list to
convert from — Value.list and Value.set are how a C++ program builds one,
and a conversion from std::map<std::string, std::variant<…>> would be inventing
a native type rather than accepting one. The other four hosts have an obvious
candidate and all four have it.
Program.ast is the parse tree, and it is public because the SEL→SQL layer is
the second thing that walks it. It is the one part of this list whose shape is
not specified here: spec/grammar.md names the productions, and a host's own
tree is its business.
Constructors validate at the boundary. Value.text raises E_UTF8 on input
that is not valid UTF-8, and Value.num canonicalises its argument (§4.1) and
raises E_NOT_NUM when it is not a number. Host code is the one place bad data
can enter, so it is the place to reject it: a Value.num("x") that quietly
produced a non-numeric TEXT would fail later, somewhere else, with a position
pointing at an innocent expression.
Branching on kind uses the predicates. The kind values are a string in JS
and Python, a class constant in PHP, an enum in C++ and a keyword in Lisp, so
only a predicate can be written the same way in all five. The constants remain
available in each host for code that would rather switch than branch. Method
names follow each host's convention — isText in JS and PHP, is_text in C++
and Python, value-text-p in Lisp — and tools/check-api.sh runs the same
numbered probes through every binding to keep the answers identical.
dependencies() returns every variable the program reads, determined statically
without evaluating it. This is possible only because SEL has no dynamic symbol
operator, and it is how a frontend knows which inputs should re-trigger which
rule.
8.1 Host functions
An application may add functions of its own to the table:
Sel.registerFunction(name, min, max, fn) # before compiling a callerThe spelling follows each host's convention: registerFunction in JS and
PHP (Sel::registerFunction), register_function in Python and C++
(sel.register_function, sel::register_function), register-function in
Lisp.
- Registration precedes compilation. An unknown name is
E_UNKNOWN_FUNCat parse time (§7.1), so a function must be registered before any program that calls it is compiled. A compiled program keeps the function it was compiled against. - A host function adds to the language; it never changes it.
nameis an identifier (§2.3) that starts with a letter, matched case-insensitively like every function name. The name of a builtin (spec/builtins.json) and a reserved word are refused. Registering a host function's name again replaces it. - Arity is declared, not counted.
minandmaxare whole numbers with0 <= min <= max, checked at compile time (E_ARITY) like any builtin's. - A host function is strict. Every argument is evaluated once, left to
right, before
fnruns (§7.1).fnreceives the argument accessor — the count, each argument's value, and the typed readers (text, boolean, whole number, non-negative whole number) that raise the usual error at that argument's position — and returns a newValue. It must not modify an argument. ASelErrorit raises propagates as raised; any other exception is the host's own and propagates unchanged. - It is invisible to the analyses.
dependencies()treats a call to it like a call to any strict builtin. - It has a SQL spelling only if the application gives it one. The
application may register, per dialect, a SQL expression for its function
(
sql/MAP.md§4.7) — a call to a database function, a stored function, an inline expression. Translation then renders a call to it like any mapped builtin, and the hybrid planner may push the steps that call it into the database. The application asserts that the spelling computes whatfncomputes, for every argument a program can pass; SEL checks the shape — the arity, the declared argument kinds, a scalar result — and cannot check the meaning, so every fragment that uses such a spelling carries the caveathost-function, and strict translation refuses it. Without a spelling for the dialect, translation refuses a program that calls the function (E_SQL_UNSUPPORTED) and the planner keeps those steps in memory. - A bad registration is a programming error, raised as the host's own
argument error rather than as a
SelError: a malformed or reserved name, a builtin's name, an arity outside the bounds above, or anfnthat is not callable.
9. Relationship to Aster
SEL takes Aster's calling convention — arguments passed as AST, evaluated at the
callee's discretion — its ;/,-as-operators grammar, its string interpolation
pass, its key-value variables with scalar-context-takes-first, and its split
between numeric and text operator families.
It does not take Aster's loops, DEFUN/LAMBDA, LOCALS/WITH, dynamic
symbols, XML/XPath/XSLT, JSON, GZIP/ZIP, QSORT, RANDOM, \ indexing, ~=,
NAND/NOR, |AND/|OR, 'X'/' ' booleans, or its floating-point numbers.
It is not a port and is not compatible. Known divergences that will bite someone
porting a rule by eye: 1-based string positions, strict BOOL instead of 'X',
short-circuiting AND/OR by default, and exact decimal instead of float.