Skip to main content

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-charquoted-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-whitespaceinline-space | U+000A | U+000B | U+000C | U+000D | U+0085 | U+200E | U+200F | U+2028 | U+2029

whitespacepattern-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-digitdigit | 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-constantNaN | 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)+

exponentbase-10-exponent | base-2-exponent

binary-number0b (binary-digit)+ [. (binary-digit)+ ][exponent]

hexadecimal-number0x (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-numbernumerical-constant | ([sign] (binary-number | hexadecimal-number | decimal-number))

symbolverbatim-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-symbolsymbol-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.

stringsingle-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-pairexpression -> expression

block{ [(statement)#statement-separator] }

do-blockdo block

latex-island$ (unicode-char | \$)* $

pragma#line | #column | #url | #filename | #date | #time | pragma-call

pragma-call → (#env | #navigator | #warning | #error) ( [(expression)#,] )

if-expressionif (expression | let pattern = expression) block [else (block | if-expression)]

match-expressionmatch expression { match-case+ }

primarysigned-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-expressionprimary (call-clause | index-clause | field-clause | !)*

expressionprimary | prefix-expression | infix-expression | postfix-expression

prefix-expression → (- | !) expression

infix-expressionexpression operator expression

literal-parametersigned-number | string | true | false     — a string literal parameter cannot contain interpolation

parametersymbol [: type] | literal-parameter

parameters( [(parameter)#,] )

effect-labelconsole | entropy | environment | fs_read | fs_write | network | random | scope | state | time

effect-specifierpure | 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-parametersymbol [: 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-declarationtype 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-statementwhile (expression | let pattern = expression) block

for-statementfor symbol in expression block

statementdeclaration | 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 expression a, not a one-element tuple; () is a diagnostic (expression-expected) — there is no empty tuple — except immediately before a mapsto arrow, where () => expr is 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.