Cortex 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 delegated to the
MathJSON symbol profile, 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
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 be a valid MathJSON 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 MathJSON symbol profile. 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 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 → ( [(expression)#,] )
index-clause → [ (expression)#, ]
postfix-expression → primary (call-clause | index-clause | !)*
expression → primary | prefix-expression | infix-expression | postfix-expression
prefix-expression → (- | !) expression
infix-expression → expression operator expression
parameter → symbol [: type]
parameters → ( [(parameter)#,] )
declaration → (let | const) symbol
[: type] [= expression] |
symbol : type [= expression]
function-definition → symbol parameters
[-> type] = expression |
function symbol parameters [-> type] block
while-statement → while expression block
for-statement → for symbol in expression block
statement → declaration | function-definition | while-statement | for-statement | expression
statement-separator → ; | linebreak
shebang → #! (unicode-char)* (linebreak | _eof)
cortex → ([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 well-formed multi-statement program wraps its statements in ["Block", …]; a
program consisting of a single statement is returned unwrapped (no Block
wrapper):
a
2
["Block", "a", 2]
; is interchangeable with a linebreak as a separator:
a; 2
["Block", "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]
Calls and indexing
A call is a symbol (or another primary) immediately followed — with no whitespace — by a parenthesized, comma-separated argument list:
f(x, y) // ["f", "x", "y"]
f() // ["f"]
If the callee is not a bare symbol (for example, a parenthesized expression
or the result of another call), the call lowers to Apply:
(getF())(x) // ["Apply", ["getF"], "x"]
(a + b)(2+1) // ["Apply", ["Add", "a", "b"], ["Add", 2, 1]]
Indexing is a primary immediately followed — with no whitespace — by a
bracketed index expression, and lowers to At. Indexing is 1-based,
matching the engine convention (xs[1] is the first element):
xs[i] // ["At", "xs", "i"]
f(x)[0] // ["At", ["f", "x"], 0]
In both cases the ( or [ must directly abut the callee/indexed
expression: whitespace before it means the parenthesized/bracketed form is a
separate primary (a parenthesized expression or a list literal), not a
call/index — the same whitespace-sensitivity that governs operators.
Collections, tuples, and dictionaries
- List:
[a, b]→["List", "a", "b"];[]→["List"]. - Set:
{a, b}→["Set", "a", "b"];{}→["Set"]. - Tuple:
(a, b)→["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 (["Function", body]). - Dictionary:
{k -> v}→["Dictionary", ["KeyValuePair", {str: "k"}, "v"]]; an unquoted key becomes a string key. The empty dictionary is spelled{->}(not{}, which is the empty set) and lowers to["Dictionary"].
{ … } 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 (the engine's Block) — a
statement sequence whose value is its last statement — while a bare { … }
stays a set/dictionary. See Blocks.
{ one -> 1, two -> 2 }
["Dictionary",
["KeyValuePair", {"str": "one"}, 1],
["KeyValuePair", {"str": "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. Sequence is available only as an explicit call: Sequence(1, 2, 3) → ["Sequence", 1, 2, 3].
Round-trip and serialization normalizations
serializeCortex and parseCortex are inverses over the MathJSON the grammar
can produce, up to a small set of documented normalizations.
parseCortex(serializeCortex(e)) is structurally equal to e after
applying:
- Number formatting —
2,{num: "2"}and"2"are the same number; the serializer emits a single canonical spelling (with_digit grouping), which re-parses to a{num}object. Negateof a literal —["Negate", 3]serializes to-3and["Negate", -1]to1; both re-parse as a signednumliteral rather than aNegatenode (the sign is folded into the number).Rational→Divide—["Rational", 1, 2]serializes to1 / 2. There is no rational literal in the grammar, so it re-parses as["Divide", 1, 2].- Invisible multiply — a binary
["Multiply", {num}, {sym}]serializes to the juxtaposed form2x(only when the two abut and re-lex unambiguously as a number followed by a symbol). All other products — n-ary, number×group (2(x+1)), group×group — stay explicit*, because(x+y)(3+4)would otherwise re-parse asApply, notMultiply. - Associativity — the left-associative operators
(
Add/Subtract/Multiply/Divide/And/Or) re-parse into left-nested binary trees; a flat n-ary form and its left-nested spelling are the same expression.
Comments are not preserved by a round-trip — see Comments.
If and Match have dedicated expression spellings. Other MathJSON heads that
do not have a special surface form serialize as ordinary function calls.
Relationship to the loose math parser
Cortex is a programming-language syntax. The Compute Engine also ships a
loose math parser (ce.parse(src, { canonical: false })) that reads
LaTeX/ASCII-math notation. The two share a few surface forms but are not the
same language, and they overlap only partially:
| Source | Cortex parseCortex | Loose ce.parse (non-canonical) | Agree? |
|---|---|---|---|
[1, 2, 3] | ["List", 1, 2, 3] | ["List", 1, 2, 3] | ✅ same |
x^2 | ["Power", "x", 2] | ["Power", "x", 2] | ✅ same |
2**3 | ["Power", 2, 3] | math-parser artifact (** is not an operator) | ❌ diverge |
a |> b | ["Pipe", "a", "b"] | ["Pipe", "a", "b"] | ✅ same |
f(x, y) | ["f", "x", "y"] (call) | ["InvisibleOperator", "f", ["Delimiter", …]] | ❌ diverge |
sin | "sin" (a symbol) | ["InvisibleOperator", "s", "i", "n"] | ❌ diverge |
2x | ["Multiply", 2, "x"] | ["InvisibleOperator", 2, "x"] | ❌ diverge |
The remaining divergences are intentional: in Cortex a juxtaposed name is a
single identifier (sin is one symbol, not s·i·n), f(x, y) is a function
call, and ** is exponentiation. The two parsers do agree that |> produces
Pipe. Do not rely on them agreeing except on the rows marked same.