Expression Plugins
Enhance the syntax and evaluation capabilities of expressions using custom plugins.
Note
This guide assumes familiarity with LyteNyte Grid’s expressions. For more details, refer to the Expressions Overview guide.
A basic understanding of lexers, parsers, and abstract syntax trees is recommended to get the most out of creating expression plugins.
Plugin Pipeline
Every expression passes through four sequential stages:
Source string ↓ scan (tokenization)Token[] ↓ parseASTNode ↓ optimizeASTNode (simplified) ↓ evaluatevalueA plugin can hook into any stage of the expression lifecycle. All hooks are optional. Implement only the ones your plugin requires:
scanHook: For tokenizing the raw string.parseHook: For building the Abstract Syntax Tree (AST).optimizeHook: For transforming or simplifying the AST.evaluateHook: For computing the final result.
Plugin Interface
interface Plugin { name: string;
// Stage 1: Tokenization scan?: (source: string, pos: number) => { type: string; value: string; end: number } | null;
// Stage 2: Parsing parsePrefix?: (ctx: ParserContext) => ASTNode | null; parseInfix?: (ctx: ParserContext, left: ASTNode, minPrec: number) => ASTNode | null; parsePostfix?: (ctx: ParserContext, node: ASTNode) => ASTNode | null; parseUnary?: (ctx: ParserContext, parseNext: (ctx: ParserContext) => ASTNode) => ASTNode | null; infixPrecedence?: (ctx: ParserContext) => number | undefined;
// Stage 3: Optimization optimize?: (node: ASTNode, optimize: (n: ASTNode) => ASTNode) => ASTNode | null;
// Stage 4: Evaluation evaluate?: ( node: ASTNode, context: Record<string, unknown>, evaluate: (node: ASTNode, context: Record<string, unknown>) => unknown, ) => { value: unknown } | null;}Return null from any hook to signal “not handled by this plugin.” The evaluator then tries
the next plugin in the array. Return a non-null value to claim the node and stop the chain.
| Hook | Stage | Use When… |
|---|---|---|
scan | Tokenization | You need a new token type (e.g. #date literals or @variable prefixes). |
parsePrefix | Parsing | You need a new expression form that starts a term (literals, prefix operators). |
parseInfix | Parsing | You need a binary operator that appears between two terms. |
infixPrecedence | Parsing | You define an infix operator and need to declare its precedence. |
parsePostfix | Parsing | You need a postfix form that follows a completed term (member access, calls). |
parseUnary | Parsing | You need a unary wrapper around the next term (rarely needed outside parsePrefix). |
optimize | Optimization | You want to simplify or constant-fold custom AST nodes before evaluation. |
evaluate | Evaluation | You need to compute a value from a custom (or existing) AST node type. |
Scan Hook
scan(source, pos) runs at each character position the tokenizer does not recognize.
The scan hook must return a token descriptor or null:
scan: (source: string, pos: number) => { type: string; // token type name, used by parse hooks value: string; // raw matched text end: number; // index of the first character AFTER the token} | nullThe end field is a source position, not a length. For a token starting at pos with text
"#2024-01-15" (11 characters), end is pos + 11.
Parse Hooks
All parse hooks receive a ParserContext that represents the current position in the token
stream. Use these helpers to interact with it:
import { current, advance, expect } from "@1771technologies/lytenyte-pro/expressions";
current(ctx) // returns the current tokenadvance(ctx) // consumes the current token and advancesexpect(ctx, type, value?) // asserts and consumes the current tokenA typical parsePrefix hook checks the current token, consumes it, and returns an AST node:
parsePrefix: (ctx) => { const tok = current(ctx); if (tok.type !== "MyTokenType") return null; advance(ctx); return { type: "MyNode", value: tok.value, start: tok.start, end: tok.end };},Infix Precedence
parseInfix handles binary operators. The parser uses precedence climbing and only calls
parseInfix when the operator’s precedence is at least minPrec. Always declare precedence
in infixPrecedence and check it before consuming tokens:
infixPrecedence: (ctx) => { return current(ctx).value === "~>" ? 8 : undefined;},
parseInfix: (ctx, left, minPrec) => { if (current(ctx).value !== "~>") return null; const prec = 8; if (prec < minPrec) return null; advance(ctx); const right = parseExpression(ctx, prec + 1); // left-associative return { type: "MyInfixNode", left, right, start: left.start, end: right.end };},Use prec - 1 instead of prec + 1 for right-associative operators (such as **).
Optimize Hook
optimize(node, optimize) receives a node and a recursive optimizer function. Call the recursive
function on child nodes before simplifying the parent:
optimize: (node, opt) => { if (node.type !== "MyNode") return null; const child = opt((node as any).child); if (child.type === "NumberLiteral") { return { type: "NumberLiteral", value: (child as any).value * 2, start: node.start, end: node.end }; } return { ...node, child };},Return null to defer handling to the next plugin. Return a node (even unchanged) to take control
of optimization for that node type.
Evaluate Hook
evaluate(node, context, evaluate) receives the current AST node, the evaluation context, and
a recursive evaluator function. Call the recursive function to evaluate child nodes:
evaluate: (node, context, evalFn) => { if (node.type !== "MyNode") return null; const childValue = evalFn((node as any).child, context); return { value: doSomethingWith(childValue) };},The evaluate hook returns an object ({ value: ... }) to distinguish a valid
result (including undefined) from a null “not handled” signal.
The context argument is the same object passed to evaluator.run. Pass it unchanged unless
your plugin intentionally introduces new bindings.
Plugin Examples
Evaluation Hook: Resolving Column Identifiers
createResolvedIdentifierPlugin uses only the evaluate hook. It intercepts Identifier nodes
that match a registered list and calls them as functions. This allows users to write Gender
while the evaluator resolves the value from the current row.
The default identifier lookup reads context[name]. This plugin treats the value as a function
and calls it with the row argument:
function createResolvedIdentifierPlugin(options: { identifiers: string[]; args: string[] }): Plugin { const identifierSet = new Set(options.identifiers); const argKeys = options.args;
return { name: "resolved-identifier", evaluate: (node, context) => { if (node.type !== "Identifier") return null; if (!identifierSet.has((node as any).name)) return null;
const fn = context[(node as any).name]; if (typeof fn !== "function") { throw new Error(`Resolved identifier "${(node as any).name}" is not a function in context`); }
const args = argKeys.map((key) => context[key]); return { value: fn(...args) }; }, };}See the Expression Filters guide for a full example.
Parse + Evaluate: Adding Boolean Literals
booleansPlugin uses parsePrefix to convert tokens into AST nodes and evaluate to produce
runtime values.
The tokenizer already recognizes true, false, null, and undefined. This plugin converts
them into AST nodes and evaluates them:
const booleansPlugin: Plugin = { name: "booleans", parsePrefix: (ctx) => { const tok = current(ctx); if (tok.type === "Boolean") { advance(ctx); return { type: "BooleanLiteral", value: tok.value === "true", start: tok.start, end: tok.end }; } if (tok.type === "Null") { advance(ctx); return { type: "NullLiteral", value: null, start: tok.start, end: tok.end }; } if (tok.type === "Undefined") { advance(ctx); return { type: "UndefinedLiteral", value: undefined, start: tok.start, end: tok.end }; } return null; }, evaluate: (node) => { if (node.type === "BooleanLiteral") return { value: (node as any).value }; if (node.type === "NullLiteral") return { value: null }; if (node.type === "UndefinedLiteral") return { value: undefined }; return null; },};Scan + Parse + Evaluate: A Custom Token Type
When you need syntax the tokenizer does not recognize, add a scan hook. This plugin introduces
a #YYYY-MM-DD date literal:
const dateLiteralPlugin: Plugin = { name: "date-literal",
scan: (source, pos) => { if (source[pos] !== "#") return null; const match = /^#(\d{4}-\d{2}-\d{2})/.exec(source.slice(pos)); if (!match) return null; return { type: "DateLiteral", value: match[1], end: pos + match[0].length }; },
parsePrefix: (ctx) => { const tok = current(ctx); if (tok.type !== "DateLiteral") return null; advance(ctx); return { type: "DateLiteralNode", isoValue: tok.value, start: tok.start, end: tok.end }; },
evaluate: (node) => { if (node.type !== "DateLiteralNode") return null; return { value: new Date((node as any).isoValue) }; },};Infix Operator: A Custom Binary Operator
parseInfix and infixPrecedence define binary operators. This example adds ** exponentiation:
const exponentiationPlugin: Plugin = { name: "exponentiation",
infixPrecedence: (ctx) => { return current(ctx).value === "**" ? 70 : undefined; },
parseInfix: (ctx, left, minPrec) => { if (current(ctx).value !== "**") return null; const prec = 70; if (prec < minPrec) return null; advance(ctx); const right = parseExpression(ctx, prec - 1); // right-associative return { type: "ExponentiationExpr", left, right, start: left.start, end: right.end }; },
evaluate: (node, context, evalFn) => { if (node.type !== "ExponentiationExpr") return null; const base = evalFn((node as any).left, context) as number; const exp = evalFn((node as any).right, context) as number; return { value: Math.pow(base, exp) }; },};Composing Plugins
Pass plugins as an array to Evaluator. The evaluator tries them in order and uses the first
non-null result for each node.
Place custom plugins after standardPlugins. Standard plugins handle core node types, and placing
custom plugins after them avoids unintended overrides:
const evaluator = new Evaluator([ ...standardPlugins, dateLiteralPlugin, exponentiationPlugin, createResolvedIdentifierPlugin({ args: ["row"], identifiers: columnNames }),]);Plugins that intentionally override existing behavior (such as createResolvedIdentifierPlugin)
must also appear after standardPlugins.
Next Steps
- Expressions Overview: Learn how to build domain-specific expressions.
- Expression Filters: Define complex logical conditions to filter grid rows.
- Expression Fields: Compute cell values from user-defined expressions.
