SELSimple Expression Language
GitHub

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, so IF(c, (a; b), d) needs no special parsing — the parenthesised sequence is just another operand. (; binds looser than ,, so those inner parentheses are required; see grammar.md.)
  • There is no return, no break, no continue, 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 — TRUE or FALSE. 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 as NULL. In host boundaries, from_native(None) / fromNative(null) produces it, and to_native() yields None / 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_SCALAR

This 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_KEY

The 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.00

4.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.6666666667

Division 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..3

5.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:

sel
"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:

  1. Thread-first default: x .> f(...) prepends x as the first argument to f.
  2. Bare identifier: x .> f is equivalent to x .> f(), desugaring to f(x).
  3. Placeholder _: If the number of arguments given to f is already at least f's minimum arity and one of the top-level arguments is a bare identifier _, that placeholder is replaced by x rather than prepending x.
  4. Postfix binding: .> binds at the postfix level (tightest binding power alongside indexing [ ]), so x .> f() > 0 parses as (f(x)) > 0, and a .> f() AND b .> g() parses as f(a) AND g(b) without requiring extra parentheses.
  5. Compile-time validation: The right-hand side must resolve to a valid registered function name or call; otherwise E_UNKNOWN_FUNC, E_ARITY, or E_SYNTAX is 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 NULL key is E_NULL; a boolean, binary, list or record key is E_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: _K is 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 _K is 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.

  • \b and \B. A word boundary is defined in terms of the engine's notion of a word character, and the two engines disagree — PHP's u modifier 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, \S inside 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 the D modifier. Python's re and cl-ppcre behave like PCRE here and have no such modifier, so both hosts instead lower ^ and $ to \A and \Z after 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:

  1. Compile with u and dotall in both hosts (us in JS, usD in PHP), and expand the class escapes as above. u gives code point matching in both; the expansion is what makes \d, \w and \s mean the same thing, since PHP's u also enables UCP and JS's does not.
  2. Report code point offsets. preg_* returns byte offsets and JS returns UTF-16 unit offsets; neither is what SEL reports.
  3. Splice replacements manually from match offsets. Do not hand the replacement string to preg_replace or String.replace. SEL replacement syntax is $0–$9 for the whole match and captures, and $$ for a literal $; every other character is literal. This avoids PCRE's \1 and 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 predicates

fromNative/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 caller

The 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_FUNC at 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. name is 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. min and max are whole numbers with 0 <= 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 fn runs (§7.1). fn receives 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 new Value. It must not modify an argument. A SelError it 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 what fn computes, 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 caveat host-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 an fn that 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.

View this page's Markdown on GitHub