Epsil Syntax
Notation
In the grammar below, the following notation is used:
- An arrow (→) marks grammar productions and can be read as "can consist of"
- Syntactic categories are written in lowercase italic (newline) on both sides of a production rule.
- Placeholders for recursive syntactic categories are indicated by ···.
- Literal words and punctuation are indicated in bold (+) or as a Unicode codepoint (U+00A0) or as a Unicode codepoint range (U+2000-U+200A).
- Alternatives are indicated by a vertical bar (|)
- Optional elements are indicated in square brackets
- Elements that can repeat 1 or more times are indicated by a trailing plus sign
- Elements that can repeat 0 or more times are indicated by a trailing star sign
- Elements that can repeat 0 or more times, separated by a another element are indicated with a trailing hash sign, followed by the separator. If no separator is provided, the comma (,) is implied.
Grammar overview
The productions below describe the source forms accepted by the current
parser. The Unicode identifier rules are described under
Symbols, and the type following a : or return
arrow is parsed using the
Compute Engine type language. Detailed
match patterns are documented under
Control Flow.
quoted-text-item → U+0000-U+0009 U+000B-U+000C U+000E-U+0021 U+0023-U+2027 U+202A-U+D7FF | U+E000-U+10FFFF
linebreak → (U+000A [U+000D]) | U+000D | U+2028 | U+2029
unicode-char → quoted-text-item | linebreak | U+0022
pattern-syntax → U+0021-U+002F | U+003A-U+0040 | U+005b-U+005E | U+0060 | U+007b-U+007e | U+00A1-U+00A7 | U+00A9 | U+00AB-U+00AC | U+00AE | U+00B0-U+00B1 | U+00B6 | U+00BB | U+00BF | U+00D7 | U+00F7 | U+2010-U+203E | U+2041-U+2053 | U+2190-U+2775 | U+2794-U+27EF | U+3001-U+3003 | U+3008-U+3020 | U+3030 | U+FD3E | U+FD3F | U+FE45 | U+FE46
inline-space → U+0009 | U+0020
pattern-whitespace → inline-space | U+000A | U+000B | U+000C | U+000D | U+0085 | U+200E | U+200F | U+2028 | U+2029
whitespace → pattern-whitespace | U+0000 | U+00A0 | U+1680 | U+180E | U+2000-U+200A | U+202f | U+205f | U+3000
line-comment → // (unicode-char)* linebreak)
block-comment → /* (((unicode-char)* linebreak)) | block-comment)
*/
digit → U+0030-U+0039 | U+FF10-U+FF19
hex-digit → digit | U+0041-U+0046 | U+0061-U+0066 | U+FF21-FF26 | U+FF41-U+FF46
binary-digit → U+0030 | U+0031 | U+FF10 | U+FF11
numerical-constant → NaN | Infinity | +Infinity |
-Infinity | oo | +oo | -oo
(oo is an input alias for Infinity; the serializer always emits the
canonical Infinity spelling.)
base-10-exponent → (e | E) [sign](digit)+
base-2-exponent → (p | P) [sign](digit)+
exponent → base-10-exponent | base-2-exponent
binary-number → 0b (binary-digit)+ [. (binary-digit)+
][exponent]
hexadecimal-number → 0x (hex-digit)+ [. (hex-digit)+
][base-2-exponent]
decimal-number → (digit)+ [. (digit)+ ][exponent]
The digit runs of a number literal may contain _ grouping separators
(1_000, 0xFF_FF); an underscore is ignored and never begins or ends a
run. A hexadecimal-number takes only a base-2-exponent because e and
E are hexadecimal digits, so they cannot double as an exponent marker.
sign → + | -
signed-number → numerical-constant | ([sign] (binary-number | hexadecimal-number | decimal-number))
symbol → verbatim-symbol | inline-symbol
verbatim-symbol → ` symbol-start (symbol-continue)*
`
The content of a verbatim-symbol is taken literally: no escape sequences
are applied, and it must still be a valid symbol name. The form exists to
write symbols whose name is a reserved word, e.g. `while`.
inline-symbol → symbol-start (symbol-continue)*
symbol-start and symbol-continue follow the Unicode profile described under Symbols. Reserved words are not accepted as inline-symbol; use the verbatim form.
escape-expression → \( expression )
single-line-string → " (escape-sequence | escape-expression |
quoted-text-item)* "
multiline-string → """ multiline-string-line """
extended-string → (#)+ " (unicode-char)* " (#)+
The number of trailing # must match the number of leading # that
opened the literal (#"…"#, ##"…"##, …). No escape sequences are applied
inside an extended string, so it can hold " and \ literally.
string → single-line-string | multiline-string | extended-string
String escapes, interpolation, multiline indentation and continuation are specified in Literals.
parenthesized → ( expression )
list → [ [(expression)#,] ]
set → { [(expression)#,] }
dictionary → { [(key-value-pair)#,] } | {->}
key-value-pair → expression -> expression
block → { [(statement)#statement-separator] }
do-block → do block
latex-island → $ (unicode-char | \$)* $
pragma → #line | #column | #url | #filename |
#date | #time | pragma-call
pragma-call → (#env | #navigator | #warning |
#error) ( [(expression)#,] )
if-expression → if (expression | let pattern =
expression) block [else (block | if-expression)]
match-expression → match expression { match-case+ }
primary → signed-number | symbol | string | pragma | latex-island | parenthesized | list | set | dictionary | do-block | if-expression | match-expression
call-clause → ( [(argument)#,] )
argument → [...] expression
index-clause → [ (expression)#, ]
field-clause → . symbol
— the . must abut the base; not after a number
literal
postfix-expression → primary (call-clause | index-clause |
field-clause | !)*
expression → primary | prefix-expression | infix-expression | postfix-expression
prefix-expression → (- | !) expression
infix-expression → expression operator expression
literal-parameter → signed-number | string | true | false
— a string literal parameter cannot contain interpolation
parameter → symbol [: type] | literal-parameter
parameters → ( [(parameter)#,] )
effect-label → console | entropy | environment |
fs_read | fs_write | network | random |
scope | state | time
effect-specifier → pure | any | (effect-label)+
— labels are space-separated; duplicates are rejected;
pure and any cannot be combined with another word
declaration → (let | const) symbol
[: type] [= expression] |
(let | const) tuple-pattern = expression |
symbol : type [= expression]
tuple-pattern → ( (symbol | tuple-pattern)#, )
— at least two elements; _ skips a position
math-function-signature → -> type |
effect-specifier -> type
type-parameter → symbol [: type]
— the bound must be a ground type (it may not mention
another type parameter)
type-parameter-clause → < (type-parameter)#, >
— at least one parameter (<> is rejected); duplicate
names are rejected; the names scope over the definition's HEAD only (its
parameters, effect specifier, and return type), not over its body
function-definition → [hold] symbol parameters
[math-function-signature] = expression |
[hold] function symbol [type-parameter-clause] parameters
[effect-specifier] [-> type] block
— the <…> clause is claimed only by the
function form: f<T>(x) = x is genuinely ambiguous with a relational
expression, so the math form does not take it; the hold prefix
(a contextual keyword — hold is an ordinary identifier elsewhere) makes a
definition whose arguments are bound unevaluated, see
Hold functions; it does not combine
with a type-parameter clause or a literal parameter. A parameter of a hold
definition may be marked bind (hold mySum(body, bind i, n)): it
receives a symbol, the bound variable. The effect-specifier slot also
accepts the algebraic words commutative, associative,
idempotent, involution (definition attributes, not effects). A
doc comment (/// lines or /** … */) immediately before a definition is
its description
type-declaration → type alias symbol
[type-parameter-clause] = type |
type symbol [type-parameter-clause] = type
— both forms take a clause (a variance marker such
as out T is legal only on the bare, nominal, form). The clause names scope
over the definition only, and each must be used in it. Types are global, so
a type-declaration is only valid at the top level of a program — inside a
block or function body it is the type-declaration-not-top-level error
while-statement → while (expression | let pattern =
expression) block
for-statement → for symbol in expression block
statement → declaration | type-declaration | function-definition | while-statement | for-statement | expression
statement-separator → ; | linebreak
shebang → #! (unicode-char)* (linebreak | _eof)
epsil → ([shebang] (statement)#statement-separator [eof])
The Pratt (precedence-climbing) grammar for _infix-expression_,
_prefix-expression_, and _postfix-expression_ — the operator set, its
precedence, and its associativity — is documented as a table in
Operators rather than spelled out production by
production; the whitespace rule described there (an infix operator has
whitespace on both sides or neither; a prefix operator has no whitespace after
it, and a postfix operator none before it) is part of this grammar, not a
separate lexical concern.
Statements and sequencing
A program is a sequence of statements separated by a linebreak or a ;. Two
expressions on the same line with no separator between them is not a
silent sequence — it is a diagnostic:
1 2
Error: unexpected-symbol "2"
A multi-statement program is a sequence, evaluated in order, whose value is the
value of its last statement. ; is interchangeable with a linebreak as a
separator, so these two programs are identical:
a
2
a; 2
Primary expressions
A primary is the leaf of the expression grammar — the thing an operator or a call/index applies to. The primary forms are:
- a number:
2,3.14,0x1F,0b101 - a symbol:
x,Add - a verbatim symbol:
`while` - a string:
"hello" - a pragma:
#env("HOME") - a parenthesized expression:
(2 + 3) - a list:
[1, 2, 3] - a set:
{1, 2, 3} - a dictionary:
{one -> 1, two -> 2} - a
do { … }block expression:do { let t = 3; t + 1 } - a
$…$LaTeX island:$\frac{1}{2}$— see LaTeX Islands - a function call:
f(x, y) - an index expression:
xs[i] - a field access:
p.x
Calls, indexing and field access
A call is a symbol (or another primary) immediately followed — with no whitespace — by a parenthesized, comma-separated argument list:
f(x, y)
f()
An argument may be prefixed with ... to spread a tuple's elements into the
call's arguments (... is also valid in list, set, and dictionary
literals, where it splices non-tuple collections — see
Spread):
f(...p)
f(1, ...p)
The callee does not have to be a bare symbol. A parenthesized expression, or the result of another call, can be called too:
(getF())(x)
(a + b)(2+1)
Named arguments
An argument can be passed by the name of the parameter it is for, written
name: value. Named arguments may be given in any order, and may follow
positional arguments — but never precede them:
function interest(principal: number, rate: number) -> number {
principal * rate
}
interest(principal: 1000, rate: 0.05) // ➔ 50
interest(rate: 0.05, principal: 1000) // ➔ 50 — order-free
interest(1000, rate: 0.05) // ➔ 50 — positional prefix is fine
interest(rate: 0.05, 1000) // ✘ positional after named
The names checked are the ones the callee's declaration carries — a
function definition's parameters, a
named function-type annotation,
an annotated lambda (including one assigned to a name,
f := (x: number, y: string) => x + 3 then f(y: "ok", x: 1)), or a
protocol member's requirement (both the bare call
compare(other: y, self: x) and the qualified
Comparable.compare(other: y, self: x), which dispatch on self
wherever it is written). An inline lambda applied directly reads its
names from the expression itself — ((x: number) => x + 1)(x: 5) is
6, and unannotated parameters work there too,
((x, y) => x - y)(y: 2, x: 10) is 8.
A parameter without a declared name is positional-only, and a callee
whose parameter names the engine cannot read cannot take named
arguments at all: a forward reference (a call before the statement
that pins the callee's signature), a value typed only as function,
or an unannotated lambda reached through a binding —
h := (x, y) => … then h(x: 1, y: 2) declines, because type
inference drops the parameter names; annotate the parameters to call
it by name. A misspelled name gets a "did you mean" pointing at the
closest declared one.
A call that names any argument is a complete call: optional
parameters may simply be omitted, but a missing required parameter is an
error — a named call never turns into a partial application — and a
variadic tail cannot be filled (nor can ... spread arguments mix with
names). Partial application and spreads remain available through purely
positional calls.
When a function has several clauses or overloads, a named argument is
also a branch selector: a clause that does not declare the written
name is never chosen, even if the argument's value would have selected
it. With clauses (z: 0), (o: 1) and (n: integer), the call
f(n: 0) runs the general n clause with the argument 0, while
f(0) runs the z: 0 base clause. Among the clauses that do declare
the written names, selection works exactly as for a positional call. If
the surviving overloads read the same names in different orders and
nothing else tells them apart, the call is an error asking you to be
explicit — call it positionally.
Note the disambiguations: f(a := 1) passes the assignment a := 1
as an ordinary argument (the token is :=, not :), and each
diagnostic these rules produce has an extended explanation under
epsil doc <code> (e.g. epsil doc argument-name-unknown).
Indexing is a primary immediately followed — with no whitespace — by a
bracketed index expression. Indexing is 1-based (xs[1] is the first
element):
xs[i]
f(x)[0]
Field access is a primary immediately followed — with no whitespace — by a
. and a symbol. Chains associate left, and a field value can be called like
any other computed callee:
p.x
a.b.c
p.x(2)
A number literal never takes a field: the lexer folds a trailing dot into
the number, so 2.x is the multiplication 2. * x, and 1..5 stays a
range. See Types for what
p.x means on values of declared types, records and dictionaries.
In all three cases the (, [ or . must directly abut the
callee/indexed expression: whitespace before it means the form is a
separate primary (or, for ., a diagnosed stray token), not a
call/index/field — the same whitespace-sensitivity that governs operators.
Collections, tuples, and dictionaries
- List:
[a, b];[]is the empty list. - Set:
{a, b};{}is the empty set. - Tuple:
(a, b). A single parenthesized element,(a), is just the parenthesized expressiona, not a one-element tuple;()is a diagnostic (expression-expected) — there is no empty tuple — except immediately before a mapsto arrow, where() => expris a zero-parameter lambda. - Dictionary:
{k -> v}; an unquoted key becomes a string key. The empty dictionary is spelled{->}, not{}(which is the empty set).
{ … } is disambiguated by looking at the first element once it has been
parsed: if it is followed by a top-level ->, the whole { … } is a
dictionary and every subsequent element must also be a key -> value pair;
otherwise { … } is a set.
A { in expression position is therefore always a collection literal (set
or dictionary); to open a statement block in expression position, prefix it
with do. do { … } is a block expression — a statement sequence whose value
is its last statement — while a bare { … } stays a set/dictionary. See
Blocks.
{ one -> 1, two -> 2 }
Trailing commas are allowed in every collection form (lists, sets, tuples, dictionaries, and call/index argument lists) — friendly to notebook editing and diffs:
[1, 2, 3,] // same as [1, 2, 3]
A bare, top-level comma-separated sequence with no enclosing delimiter (for
example 1, 2, 3 on its own) is not a sequence literal — it is a
diagnostic. A sequence is written only as an explicit call, Sequence(1, 2, 3).
Round-trips
Reading a program and writing it back out reproduces its meaning, but not
necessarily its spelling. A few forms have one canonical rendering: numbers get
a single spelling (with _ digit grouping), a division is always written with
an explicit /, 2x keeps its juxtaposed form where that re-reads
unambiguously (but 2(x+1) and (x+y)(3+4) keep an explicit *, since a
juxtaposed group would read as a call), and is is written as in — the two
spell the same membership test.
Comments are not preserved by a round-trip — see Comments. For the exact list of normalizations, see Round-trip and serialization normalizations.
Relationship to the loose math parser
Epsil is a programming-language syntax. The Compute Engine also ships a
loose math parser that reads LaTeX/ASCII-math notation. The two share a few
surface forms but are not the same language: in Epsil a juxtaposed name is
a single identifier (sin is one symbol, not s·i·n), f(x, y) is a function
call rather than a product, and ** is exponentiation. Do not assume a snippet
means the same thing to both. See
Relationship to the loose math parser
for a form-by-form comparison.