PEL operators and coercion
The exact typed semantics of PEL operators, verified against crates/pgdl/src/pel/eval.rs (evaluator) and parse.rs (grammar/precedence). This is the portable contract: a re-implementation in another runtime must reproduce these rules exactly. The frozen behavioural vectors live in phony-core/conformance/vectors.json — treat that file as normative.
For the readable guide, see Expressions (PEL); for the callable functions, see PEL functions.
Numeric values
Several rules turn on whether a value is numeric. A value is numeric if it is:
- a JSON number, or
- a string that trims to a finite decimal —
" 12 "is12.
Everything else is non-numeric: "12abc", "", "nan", "inf" (parsing is all-or-nothing; non-finite is rejected), and every boolean, null, array, and object. This definition is shared by arithmetic, equality, and ordering.
Arithmetic — + - * / % and unary -
Both operands must be numeric, otherwise the expression is a BadParams error. There is no silent string-to-zero coercion (unlike the arithmetic functions, which coerce). A whole-number result is narrowed to an integer.
| Expression | Result |
|---|---|
2 + 3 | 5 |
'2' + '3' | 5 (numeric strings) |
'a' + 'b' | error — use concat() to join strings |
10 / 4 | 2.5 |
x / 0 | error — divide by zero |
x % 0 | error — modulo by zero |
-'5' | -5 |
-'a' | error — unary - needs a numeric operand |
Equality — == / !=
Typed: no cross-type stringly equality. != is the negation of ==.
| left \ right | null | number / numeric-string | string | bool | array / object |
|---|---|---|---|---|---|
null | true | false | false | false | false |
| number | false | numeric == | numeric if numeric-string, else false | false | false |
| string | false | numeric if numeric-string, else false | scalar == | false | false |
| bool | false | false | false | value == | false |
| array / object | false | false | false | false | structural == |
Consequences:
| Expression | Result |
|---|---|
null == null | true |
null == '' | false |
1 == '1' | true |
'10' == '10.0' | true (both numeric) |
true == 1 | false |
true == 'true' | false |
'a' == 'a' | true |
[1,2] == [1,2] | true (structural) |
Ordering — < <= > >=
Two numerics compare numerically; two strings compare lexicographically by Unicode scalar value. Any other mix is a BadParams error — there is no surprising cross-type ordering.
| Expression | Result |
|---|---|
2 < 10 | true |
'a' < 'b' | true |
'10' < '9' | false (both numeric strings → numeric compare) |
5 < 'a' | error — cannot compare number and non-numeric string |
Logical — && \|\| !
Operate on truthiness and short-circuit. && and || return a boolean.
Falsy values: null, 0, "", [], {}, false. Everything else is truthy.
| Value | Truthy? |
|---|---|
null | no |
false / true | as-is |
0 / any non-zero number | no / yes |
"" / non-empty string | no / yes |
[] / non-empty array | no / yes |
{} / non-empty object | no / yes |
Precedence
Lower rows bind tighter (verified against binding_power in parse.rs). All binary operators are left-associative; ! and unary - are prefix and bind tighter than every binary operator.
| Precedence | Operators |
|---|---|
| 1 (loosest) | || |
| 2 | && |
| 3 | == != |
| 4 | < > <= >= |
| 5 | + - |
| 6 | * / % |
| 7 (tightest) | unary ! unary - |
So 1 + 2 * 3 parses as 1 + (2 * 3) and a || b && c as a || (b && c). Parenthesize to override.
Literals and references
- Numbers: decimal digits with an optional
.(e.g.3.14). - Strings: single- or double-quoted.
- Booleans / null:
true,false,null. - Lists:
[a, b, c]. - Objects:
{ key: value, … }(keys are identifiers or strings). - References: a bare name, optionally dotted to index object properties (
country.code). A name resolves against the generator's local bindings (its params andletresults) or the boundvalue; indexing a non-object is an error. There is no table, row, or sibling-field scope. - Calls:
name(arg, …).
String escapes
Inside a string literal the lexer recognizes:
| Escape | Result |
|---|---|
\n | newline |
\t | tab |
\\ | backslash |
\" \' | the quote character |
\x (any other) | the character x literally (so \d is d) |
= alone is a parse error (use ==); a lone & or \| is a parse error (use && / ||).
Templates vs. expressions
PEL has two surfaces:
- A template is a string with
… placeholder …segments. It is scanned into literal text, inline-generator nodes, and expression nodes. A placeholder whose trimmed body starts with an inline keyword (number,pattern,date,datetime,time,uuid,ulid,nanoid) is an inline generator; any other body is an expression. In template text,\{,\}, and\\escape to literal braces / backslash; an unterminated placeholder is an error. - An expression is a bare PEL expression (no surrounding text), evaluated with its native type preserved.
Where a value is authored as a string (composition let params, output leaves), the rule is:
- a string that is exactly one whole placeholder is evaluated as an expression and keeps its native type (number, boolean, array, …);
- a string with embedded placeholders renders to text;
- a string with no placeholders passes through unchanged.
The following fenced examples use the placeholder braces literally:
"{{ 1 + 2 }}" → 3 (whole expression: native number)
"total: {{ 1 + 2 }}" → "total: 3" (embedded: rendered text)
"plain" → "plain" (no placeholder: passthrough)Rendering a value into text uses string coercion: integral numbers print without a decimal point, null prints as empty, arrays/objects print as JSON.