Expressif.Syntax provides the Tree-sitter parser for the Expressif expression language, together with bindings for supported programming languages.
The parser defines the concrete syntax of Expressif independently from its runtime implementations. It is intended to provide a common syntax foundation for the C#, Python and TypeScript implementations of Expressif, as well as editor tooling and language-server support.
About | Repository structure | Development | Releases
Continuous integration builds:
Expressif.Syntax separates the syntax of the Expressif language from its runtime semantics.
The Tree-sitter grammar parses source text into a syntax tree while preserving syntactic constructs such as shorthands. Language-specific bindings can then translate this tree into the semantic representation expected by an Expressif implementation.
For example:
.foo
is represented syntactically as a field-access construct, while:
field(foo)
is represented as a regular function call. Both can later be bound to the same semantic operation:
field(foo)
This separation allows the same parser to support:
- Expressif for C#
- Expressif for Python
- Expressif for TypeScript
- language servers
- syntax highlighting and other editor tooling
Expressif recognizes dedicated scalar syntax for booleans, null, and the all-dimension marker:
#true
#false
#null
#all
#all is represented explicitly as an all_literal node (and as
AllLiteralSyntax in the C# binding), distinct from #null and the quoted
string "#all". The syntax tree preserves its authored text and source span;
binding it to an all-dimension runtime value remains the responsibility of an
Expressif runtime.
Expressif distinguishes record fields from elements of ordered values:
.name named field of the current record
.0 positional field of the current record
^.name named field of the current expression root
^.0 positional field of the current expression root
^^.name named field of the enclosing expression root
$0 first element of the current tuple or array
$1 second element of the current tuple or array
$-1 last element of the current tuple or array
$-2 second-to-last element of the current tuple or array
^$1 second element of the current expression input
^^$1 second element of the enclosing expression input
^^^$1 second element two expression scopes outward
Record access always uses . for navigation. A leading ^ changes the root
from the current pipeline value to the current expression input; each additional
^ moves outward by one enclosing expression. It does not change how fields are
selected. Access can be chained for nested records, for example
.customer.address, ^.customer.0, or ^^.customer.0. The former bracket forms
[name] and [0] are replaced by ^.name and ^.0 respectively.
Element positions with $n are zero-based from the beginning. Preferred $-n
counts from the end using one-based positions; $-0 is invalid. The parser
represents both tuple and array access with the same tuple_projection node; downstream binders decide whether the
runtime value supports positional access and how invalid or out-of-range access
is handled.
Legacy $^n notation is obsolete/deprecated but remains supported with its
unchanged zero-based offsets from the end. Migrate $^0 to $-1 (last element)
and $^1 to $-2 (second-to-last). No sunset date or removal version is set;
any future removal requires a separate decision and advance migration notice.
TupleProjectionSyntax.Index preserves the written integer without normalization:
$-1 has index 1, whereas the equivalent $^0 has index 0. Both have
Direction = FromEnd. Consumers must inspect the preserved Text to distinguish
the notation and its index base; direction and index alone are insufficient.
Serialization must preserve that distinction. Unqualified references have no
Root, root depth 0, and empty typed Children; punctuation remains in Text.
Leading carets on tuple projections follow the same root-depth convention as
record access: ^^^^$1 and ^^^^.name both have root depth 4. Any positive
number of carets is supported. TupleProjectionSyntax.Root preserves the prefix
as an ExpressionRootSyntax, including its $ delimiter, text, and source span;
RootDepth counts the carets (zero for unqualified projections). Direction and
Index remain independent properties. The root is included in Children.
^$1 selects from an expression input; $-1 counts from the end of the current
value. Combined forms such as ^^$^1 and ^^$-1, and whitespace inside
the shorthand are invalid. Pipeline stages and grouping do not create scopes;
nested expression invocations do. Resolving those scopes belongs to downstream
evaluation, while the syntax tree preserves the authored reference directly.
@_ denotes the current pipeline object as a single value. Spread syntax is
orthogonal: ... spreads the current object implicitly, while ...expression
spreads an explicit expression.
@_ current object as one value
... spread the current object (shorthand for ...@_)
...@_ explicitly spread the current object
...@args spread the variable @args
...args spread the result of the zero-argument function args
The distinction applies consistently to function arguments, arrays, and named record fields:
array(@args) variable as one positional argument
array(...@args) variable spread into the function argument list
{1, @_, 3} current object as one array element
{1, ..., 3} current object spread into an array
{1, ...@args, 3} variable @args spread into an array
{foo := @_} current object as a normal field value
{foo := ...} current object spread into the field
{foo := ...@args} variable @args spread into the field
The syntax tree preserves whether a spread operand was implicit or explicitly authored. Parsing records the intent to spread but does not expand or validate the runtime value.
A record can carry an authored tag for downstream semantic interpretation:
SortTable{headers := {...}, rows := {...}}
The parser exposes this as a tagged_record_literal containing the tag and the
existing record_literal structure. It preserves the tag exactly and does not
resolve or validate it. Empty tagged records use SortTable{:} because {} is
the existing empty-array spelling.
.
├── grammar.js
├── tree-sitter.json
├── package.json
├── src/
│ ├── parser.c
│ ├── grammar.json
│ └── node-types.json
├── bindings/
│ ├── csharp/
│ ├── python/
│ └── typescript/
├── queries/
│ └── highlights.scm
└── test/
grammar.js is the source definition of the Expressif grammar.
The files under src/ are generated by Tree-sitter and are committed to source control so consumers do not need the Tree-sitter CLI to build the parser.
Language-specific integration is located under bindings/.
A named or anonymous binding must follow a pipe and a preceding expression in the same pipeline. It binds the output of that preceding expression and preserves a dedicated body:
Tuple(10, 20) | extend(30) | myTuple :> (@myTuple | $0 | subtract(@myTuple | $1) | multiply(@myTuple | $2))
record(name := "Alice", age := 30) | :> record(n := .name, a := .age)
Tuple(10, 20) | extend(30) | (current, minus, factor) :> (@current | subtract(@minus) | multiply(@factor))
Bindings also work inside an argument's own pipeline:
apply(trim | input :> (@input | upper))
adjacent($1 | current :> (@current | multiply(2)))
apply(expression := trim | input :> @input | upper)
A positional binding may also begin an open expression. In that form, its tuple receiver consumes the current input supplied when the open expression is invoked:
adjacent((previous, current) :> @current | subtract(@previous))
apply(expression := (a, b) :> @a | add(@b))
In the second example, the preceding $1 supplies the value being bound.
Implicit input supplied by a function satisfies the placement rule only for a
leading positional binding. Leading named and anonymous forms remain invalid,
including under named arguments or extra grouping parentheses:
apply(input :> @input)
apply(:> $0 | add($1))
map(input :> @input)
apply(expression := input :> @input)
apply((input :> @input))
Bare name :> body and :> body are also invalid without a preceding pipeline
expression. A bare (a, b) :> body is an open expression and is valid. Ordinary
expression arguments and callable shorthands such as apply(trim | upper) and
adjacent(subtract) remain valid. No function names are special-cased by the
placement rule.
Without parentheses, the body consumes the remaining pipeline through its enclosing expression or argument boundary. Parentheses delimit the body explicitly; a following pipe continues the outer pipeline:
10 | input :> @input | add(1) | multiply(2)
10 | input :> (@input | add(1)) | multiply(2)
10 | :> (@_ | add(1)) | multiply(2)
10|:>(@_ | add(1))|multiply(2)
The last two examples have the same syntax structure: | :> and |:> are
equivalent. The :> token itself must remain contiguous; : > is invalid.
Whitespace and comments may surround the tokens. A body is required and may start
with a variable, literal, tuple/field selector, or ordinary function. Grouping
parentheses do not themselves introduce another binding or runtime scope.
InputBindingExpressionSyntax : ExpressionSyntax is an ordered pipeline stage.
Its optional BindingPatternSyntax Binding is a BindingNameSyntax for a whole
input name, a PositionalBindingPatternSyntax for destructuring, or null for an
anonymous binding. RootExpressionSyntax Body preserves either an open or closed
root. A grouped body retains an OpenExpressionSyntax wrapper containing one
ParenthesizedExpressionSyntax; its Expression property preserves the actual
inner open/closed root, including a variable or literal source and its pipeline.
Consumers must handle both root kinds rather than requiring an open function-only
body. In particular, a grouped variable-led body is syntactically valid; rejection
of its closed root by a runtime binder must be corrected in that consumer.
These names replace InputBoundExpressionSyntax and the former declaration base
InputBindingSyntax. The CST uses input_binding_expression with binding and
body fields. Managed children are the binding (when present) followed by the body.
Punctuation remains in the CST and original text; every node preserves source spans.
Binding names use [A-Za-z][A-Za-z0-9]*. References use existing @name syntax;
bare names remain function calls. Positional patterns expose ordered
IReadOnlyList<BindingNameSyntax> Names with individual spans, distinct from tuple
or pair literals. Lists require at least two names; empty/single-name lists,
trailing commas, rest patterns, and nested patterns are rejected. Duplicate names
are retained for semantic diagnostics.
The Expressif runtime owns capture, lexical resolution, shadowing, scope restoration,
input types, component ordering, exact destructuring arity, and evaluation results.
Scoped tuple access (^$1, ^^$1) stays distinct from from-end access ($-1, or legacy $^0).
This correction supports Expressif #980–#982 and its runtime integration PR #988.
The corrected package is published by the main-branch release workflow after merge.
Install the dependencies:
npm installGenerate the parser:
npx tree-sitter generateRun the grammar tests:
npx tree-sitter testThe grammar should remain independent from the Expressif function catalogue. Parsing determines the syntactic structure of an expression; resolution of functions, predicates, accumulators and their accepted arguments belongs to the language-specific semantic binding layer.
For every push to main, CI builds, tests, and collects the distributable artifacts. After every validation job succeeds, a patch-zero version is published from those same collected artifacts to the corresponding vX.Y.0 GitHub release and NuGet.org. Other versions complete validation without publishing artifacts.
Each GitHub release contains the distributable artifacts collected by CI after package validation:
- the C# NuGet package
- the native parser source archive
Only the C# package is currently published to an external registry. NuGet publication uses GitHub OIDC trusted publishing to obtain a short-lived API key, so no long-lived NuGet API key is stored in the repository. Configure the trusted publishing policy on NuGet.org with these values:
- Repository Owner:
Seddryck - Repository:
Expressif.Syntax - Workflow File:
ci.yml - Environment: leave blank
The policy's NuGet user must be Seddryck, matching the NuGet/login step in the workflow.
- Expressif — C# implementation and reference project
- Expressif documentation — language documentation
f~ and ~f bind a tuple to a function name. The postfix form represents
bind("f"); the prefix form represents rotate | bind("f"), with the default
rotation offset of -1. For T(a, b, c), these correspond to a | f(b, c) and
c | f(a, b). Prefix binding rotates the tuple; it does not reverse it and is
not restricted to binary functions.
T(120, 135) | subtract~ // runtime result: -15
T(120, 135) | ~subtract // runtime result: 15
extend(~subtract)
The tilde must touch the function name: ~ subtract, subtract ~, and comments
between the name and tilde are invalid. Names follow ordinary function-name
syntax (ASCII letters and hyphen-separated letter segments); unknown names are
preserved for downstream semantic validation. Whitespace and comments around
the complete shorthand are allowed. Use (~subtract) or (subtract~) for
grouping. Existing prefixes wrap the complete shorthand, as in !~subtract
and *subtract~; ~!subtract and ~*subtract are invalid. A tilde cannot bind a
call or grouped expression: ~subtract(), subtract()~, subtract~(1),
~(subtract), and (subtract)~ are invalid. Bare, repeated, or combined tildes
such as ~, ~~subtract, subtract~~, and ~subtract~ are also invalid.
The CST preserves tuple_binding_shorthand with a named function_name child
in the name field and anonymous ~ in the tilde field, in authored order.
The C# TupleBindingShorthandSyntax is an ExpressionSyntax exposing Name,
Direction (Prefix or Postfix), NameSpan, and TildeSpan, as well as exact
Text and full Span. Its typed Children is empty, consistent with function
name handling in FunctionCallSyntax. No runtime calls are synthesized.