@pawel-up/jexl
Version:
Javascript Expression Language: Powerful context-based expression parser and evaluator
1,126 lines (1,067 loc) • 35.9 kB
text/typescript
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { Grammar } from '../grammar.js'
import { states } from './states.js'
/**
* Represents a token in the parsing process, which can be either a simple token from the lexer
* or a complex AST node being constructed during parsing.
*
* @example Simple token from lexer
* ```typescript
* const token: Token = {
* type: 'identifier',
* value: 'username',
* raw: 'username'
* }
* ```
*
* @example Complex AST node during parsing
* ```typescript
* const binaryExpr: Token = {
* type: 'BinaryExpression',
* operator: '+',
* left: { type: 'Identifier', value: 'a' },
* right: { type: 'Literal', value: 5 }
* }
* ```
*/
export interface Token {
type: string
raw?: string
right?: Token
_parent?: Token
args?: Token[]
value?: unknown | unknown[]
operator?: string
// Additional properties for specific node types
expr?: Token
subject?: Token
relative?: boolean
from?: Token
name?: string
pool?: string
left?: Token
test?: Token
consequent?: Token
alternate?: Token
}
/**
* The Parser is a state machine that converts tokens from the {@link Lexer} into an Abstract Syntax Tree (AST).
*
* The Parser processes tokens sequentially and builds a tree structure that represents the logical
* structure of the expression. It handles operator precedence, nested expressions, function calls,
* object/array literals, and complex filtering operations.
*
* @example Basic parsing workflow
* ```typescript
* const grammar = getGrammar()
* const lexer = new Lexer(grammar)
* const parser = new Parser(grammar)
*
* // Parse simple expression: "age + 5"
* const tokens = lexer.tokenize("age + 5")
* parser.addTokens(tokens)
* const ast = parser.complete()
* // Returns: { type: 'BinaryExpression', operator: '+', left: {...}, right: {...} }
* ```
*
* @example Parsing complex expressions
* ```typescript
* // Parse: "users[.age > 18].name|upper"
* const tokens = lexer.tokenize("users[.age > 18].name|upper")
* parser.addTokens(tokens)
* const ast = parser.complete()
* // Returns complex AST with FilterExpression, Identifier, and FunctionCall nodes
* ```
*
* @example Sub expression parsing
* ```typescript
* // Used internally for parsing nested expressions like function arguments
* const subParser = new Parser(grammar, "parentExpr", { ')': 'argEnd' })
* // Parses until ')' token and returns stop state 'argEnd'
* ```
*/
export default class Parser {
/** The grammar object containing language rules, operators, and functions */
_grammar: Grammar
/** Current state of the parser state machine (e.g., 'expectOperand', 'expectBinaryOp') */
_state: string
/** Root node of the AST being constructed */
_tree: Token | null
/** Current expression string being parsed (used for error messages) */
_exprStr: string
/** Flag indicating if the expression contains relative identifiers (starting with '.') */
_relative: boolean
/** Map of token types to stop states for sub expression parsing */
_stopMap: Record<string, unknown>
/** Sub parser instance for handling nested expressions */
_subParser: any
/** Flag indicating if this parser should stop when encountering a stop token */
_parentStop: any
/** Current position in the AST where new nodes are added */
_cursor?: Token
/** Flag indicating if the next identifier should encapsulate the current cursor */
_nextIdentEncapsulate?: boolean
/** Flag indicating if the next identifier should be relative */
_nextIdentRelative?: boolean
/** Currently queued object key waiting for a value */
_curObjKey?: string
/**
* Creates a new Parser instance for building Abstract Syntax Trees from token streams.
*
* @param grammar - Grammar object containing language rules and operators
* @param prefix - String prefix for error messages (useful for sub expressions)
* @param stopMap - Map of token types to stop states for sub expression parsing
*
* @example Basic parser creation
* ```typescript
* const grammar = getGrammar()
* const parser = new Parser(grammar)
* ```
*
* @example Sub expression parser with stop conditions
* ```typescript
* // Parser that stops when encountering ')' or ',' tokens
* const subParser = new Parser(grammar, "func(", {
* ')': 'functionEnd',
* ',': 'nextArg'
* })
* ```
*
* @example Error message prefix
* ```typescript
* // For better error messages in nested contexts
* const parser = new Parser(grammar, "users[.age > ")
* // Error messages will include the prefix for context
* ```
*/
constructor(grammar: Grammar, prefix?: string, stopMap: Record<string, unknown> = {}) {
this._grammar = grammar
this._state = 'expectOperand'
this._tree = null
this._exprStr = prefix || ''
this._relative = false
this._stopMap = stopMap
}
/**
* Processes a single token and advances the parser state machine.
*
* This is the core method that drives the parsing process. It examines the current parser state,
* determines if the token is valid in that state, and either processes it directly or delegates
* to a sub parser for nested expressions.
*
* @param token - Token to process (from lexer or as part of AST construction)
* @returns false if parsing should continue, or stop state value if a stop condition was met
*
* @example Processing simple tokens
* ```typescript
* const parser = new Parser(grammar)
*
* // Process identifier token
* const result1 = parser.addToken({ type: 'identifier', value: 'age', raw: 'age' })
* // Returns false (continue parsing)
*
* // Process operator token
* const result2 = parser.addToken({ type: 'binaryOp', value: '+', raw: '+' })
* // Returns false (continue parsing)
*
* // Process literal token
* const result3 = parser.addToken({ type: 'literal', value: 5, raw: '5' })
* // Returns false (continue parsing)
* ```
*
* @example Sub expression with stop conditions
* ```typescript
* // Parser configured to stop on ')' token
* const parser = new Parser(grammar, "", { ')': 'endGroup' })
* parser.addToken({ type: 'identifier', value: 'x', raw: 'x' })
* const result = parser.addToken({ type: 'closeParen', raw: ')' })
* // Returns 'endGroup' (stop state reached)
* ```
*
* @throws {Error} When parser is already complete
* @throws {Error} When unexpected token type is encountered
* @throws {Error} When parser state is invalid
*/
addToken(token: Token): boolean | unknown {
if (this._state === 'complete') {
throw new Error('Cannot add a new token to a completed Parser')
}
const state = states[this._state]
if (!state) {
throw new Error(`Invalid parser state: ${this._state}`)
}
const startExpr = this._exprStr
this._exprStr += token.raw
if (state.subHandler) {
if (!this._subParser) {
this._startSubExpression(startExpr)
}
const stopState = this._subParser.addToken(token)
if (stopState) {
this._endSubExpression()
if (this._parentStop) return stopState
this._state = stopState
}
} else if (state.tokenTypes && state.tokenTypes[token.type]) {
const typeOpts = state.tokenTypes[token.type]
if (!typeOpts) {
throw new Error(`No type options for token ${token.type}`)
}
// Use internal handler methods instead of external handlers
if (typeOpts.handler) {
const handlerMethod = this._getTokenHandlerMethod(typeOpts.handler)
if (handlerMethod) {
handlerMethod(token)
}
} else {
// Map token types to internal handler methods
const handlerMethod = this._getHandlerMethod(token.type)
if (handlerMethod) {
handlerMethod(token)
}
}
if (typeOpts.toState) {
this._state = typeOpts.toState
}
} else if (this._stopMap[token.type]) {
return this._stopMap[token.type]
} else {
throw new Error(`Token ${token.raw} (${token.type}) unexpected in expression: ${this._exprStr}`)
}
return false
}
/**
* Processes an array of tokens sequentially using {@link addToken}.
*
* This is a convenience method for processing multiple tokens at once, typically
* the entire token stream from a lexer.
*
* @param tokens - Array of tokens to process sequentially
*
* @example Processing token stream from lexer
* ```typescript
* const lexer = new Lexer(grammar)
* const parser = new Parser(grammar)
*
* const tokens = lexer.tokenize("user.name + ' - ' + user.email")
* parser.addTokens(tokens)
* const ast = parser.complete()
* // Fully parsed AST ready for evaluation
* ```
*
* @example Manual token array
* ```typescript
* const tokens = [
* { type: 'identifier', value: 'age', raw: 'age' },
* { type: 'binaryOp', value: '>', raw: '>' },
* { type: 'literal', value: 18, raw: '18' }
* ]
* parser.addTokens(tokens)
* // Creates AST for "age > 18"
* ```
*/
addTokens(tokens: Token[]) {
tokens.forEach(this.addToken, this)
}
/**
* Finalizes parsing and returns the completed Abstract Syntax Tree.
*
* This method should be called after all tokens have been processed. It verifies that
* the parser is in a valid end state and returns the root of the constructed AST.
*
* @returns The root AST node, or null if no tokens were processed
*
* @example Completing simple expression
* ```typescript
* const parser = new Parser(grammar)
* parser.addTokens(lexer.tokenize("price * quantity"))
* const ast = parser.complete()
* // Returns:
* // {
* // type: 'BinaryExpression',
* // operator: '*',
* // left: { type: 'Identifier', value: 'price' },
* // right: { type: 'Identifier', value: 'quantity' }
* // }
* ```
*
* @example Completing complex expression
* ```typescript
* parser.addTokens(lexer.tokenize("users[.age > 21 && .active].name"))
* const ast = parser.complete()
* // Returns complex AST with FilterExpression containing nested BinaryExpression
* ```
*
* @example Empty expression
* ```typescript
* const parser = new Parser(grammar)
* const ast = parser.complete()
* // Returns null (no tokens processed)
* ```
*
* @throws {Error} When parser is not in a valid completion state (incomplete expression)
*/
complete(): Token | null {
const currentState = states[this._state]
if (this._cursor && (!currentState || !currentState.completable)) {
throw new Error(`Unexpected end of expression: ${this._exprStr}`)
}
if (this._subParser) {
this._endSubExpression()
}
this._state = 'complete'
return this._cursor ? this._tree : null
}
/**
* Indicates whether the expression contains relative path identifiers.
*
* Relative identifiers start with '.' and are used in filter expressions to reference
* properties of the current context item (e.g., '.age' in 'users[.age > 18]').
*
* @returns true if relative identifiers are present, false otherwise
*
* @example Expression with relative identifiers
* ```typescript
* const parser = new Parser(grammar)
* parser.addTokens(lexer.tokenize("users[.age > 18 && .active]"))
* parser.complete()
* const hasRelative = parser.isRelative()
* // Returns true (contains '.age' and '.active')
* ```
*
* @example Expression without relative identifiers
* ```typescript
* parser.addTokens(lexer.tokenize("user.name + user.email"))
* parser.complete()
* const hasRelative = parser.isRelative()
* // Returns false (no relative identifiers)
* ```
*
* @example Used in sub expression parsing
* ```typescript
* // When parsing filter expressions, this helps determine the filter type
* const filterParser = new Parser(grammar, "", stopMap)
* filterParser.addTokens(filterTokens)
* const filterAst = filterParser.complete()
* if (filterParser.isRelative()) {
* // Use relative filtering (each array element as context)
* } else {
* // Use static filtering (property access or boolean check)
* }
* ```
*/
isRelative(): boolean {
return this._relative
}
/**
* Ends a sub expression by completing the subParser and passing its result
* to the subHandler configured in the current state.
* @private
*/
_endSubExpression() {
const currentState = states[this._state]
if (!currentState || !currentState.subHandler) {
throw new Error(`Invalid state for ending sub expression: ${this._state}`)
}
const subHandlerName = currentState.subHandler
const handlerMethod = this._getSubHandlerMethod(subHandlerName)
if (handlerMethod) {
handlerMethod(this._subParser.complete())
}
this._subParser = null
}
/**
* Places a new AST node at the current cursor position and advances the cursor.
*
* This is the primary method for adding nodes to the AST. It handles both root node
* creation and linking nodes into the tree structure.
*
* @param node - AST node to place at the cursor
*
* @example First node (root)
* ```typescript
* // When tree is empty, node becomes the root
* this._placeAtCursor({ type: 'Identifier', value: 'user' })
* // Result: _tree = { type: 'Identifier', value: 'user' }
* // _cursor points to this node
* ```
*
* @example Subsequent nodes
* ```typescript
* // When cursor exists, node becomes the 'right' child
* this._placeAtCursor({ type: 'Literal', value: 5 })
* // Result: previous node's 'right' property points to new node
* // _cursor advances to new node
* ```
*
* @example Building binary expression
* ```typescript
* // For "a + b":
* // 1. Place identifier 'a' (becomes root)
* // 2. Binary operator '+' restructures tree
* // 3. Place identifier 'b' (becomes right child of '+')
* ```
*
* @private
*/
_placeAtCursor(node: Token): void {
if (!this._cursor) {
this._tree = node
} else {
this._cursor.right = node
this._setParent(node, this._cursor)
}
this._cursor = node
}
/**
* Places a node before the current cursor position, effectively replacing the cursor node.
*
* This method is used when a node needs to "wrap" or "contain" the current cursor node,
* such as when creating filter expressions or function calls.
*
* @param node - AST node that should contain the current cursor node
*
* @example Creating filter expression
* ```typescript
* // Current cursor points to 'users' identifier
* // Create filter that wraps it:
* this._placeBeforeCursor({
* type: 'FilterExpression',
* subject: this._cursor, // 'users' becomes the subject
* expr: filterAst,
* relative: true
* })
* // Result: FilterExpression becomes new cursor, containing 'users'
* ```
*
* @example Converting identifier to function call
* ```typescript
* // Current cursor points to 'max' identifier
* // Convert to function call:
* this._placeBeforeCursor({
* type: 'FunctionCall',
* name: this._cursor.value, // 'max'
* args: [],
* pool: 'functions'
* })
* // Result: FunctionCall replaces identifier, ready for arguments
* ```
*
* @private
*/
_placeBeforeCursor(node: Token): void {
this._cursor = this._cursor?._parent
this._placeAtCursor(node)
}
/**
* Sets the parent of a node by creating a non-enumerable _parent property
* that points to the supplied parent argument.
* @param node A node of the AST on which to set a new
* parent
* @param parent An existing node of the AST to serve as the
* parent of the new node
* @private
*/
_setParent(node: Token, parent: Token): void {
Object.defineProperty(node, '_parent', {
value: parent,
writable: true,
})
}
/**
* Prepares the Parser to accept a sub expression by (re)instantiating the
* subParser.
* @param {string} [exprStr] The expression string to prefix to the new Parser
* @private
*/
_startSubExpression(exprStr?: string): void {
let endStates = (states as any)[this._state].endStates
if (!endStates) {
this._parentStop = true
endStates = this._stopMap
}
this._subParser = new Parser(this._grammar, exprStr, endStates)
}
/**
* Handles a sub expression that's used to define a transform argument's value.
* @param ast The sub expression tree
*/
private argVal(this: Parser, ast?: Token) {
if (ast) {
this._cursor?.args?.push(ast)
}
}
/**
* Handles new array literals by adding them as a new node in the AST,
* initialized with an empty array.
*/
private arrayStart() {
this._placeAtCursor({
type: 'ArrayLiteral',
value: [],
})
}
/**
* Handles a sub expression representing an element of an array literal.
* @param ast The sub expression tree
*/
private arrayVal(ast: Token): void {
const { _cursor } = this
if (ast && _cursor && Array.isArray(_cursor.value)) {
_cursor.value.push(ast)
}
}
/**
* Handles binary operator tokens and manages operator precedence.
*
* Binary operators like +, -, *, /, ==, etc. require special handling to ensure correct
* operator precedence. This method restructures the AST to maintain proper order of operations.
*
* @param token - Binary operator token with operator value
*
* @example Operator precedence handling
* ```typescript
* // For expression "2 + 3 * 4"
* // First processes: 2, then +, then 3, then *
* // When * is encountered, it has higher precedence than +
* // So the tree gets restructured to ensure 3*4 is evaluated first
* //
* // Before * token: After * token:
* // + +
* // / \ / \
* // 2 3 2 *
* // / \
* // 3 (next)
* ```
*
* @example Left-to-right evaluation for same precedence
* ```typescript
* // For expression "10 - 5 + 2"
* // Both - and + have same precedence, so left-to-right evaluation
* // Final AST: ((10 - 5) + 2)
* ```
*/
private binaryOp(token: Token): void {
const precedence = (this._grammar.elements[token.value as string] as any)?.precedence || 0
let parent = this._cursor?._parent
while (parent && parent.operator && (this._grammar.elements[parent.operator] as any)?.precedence >= precedence) {
this._cursor = parent
parent = parent._parent
}
const node: Token = {
type: 'BinaryExpression',
operator: token.value as string,
left: this._cursor,
}
if (this._cursor) {
this._setParent(this._cursor, node)
}
this._cursor = parent
this._placeAtCursor(node)
}
/**
* Handles dot (.) tokens for property access and relative identifier setup.
*
* The dot operator is used for property access (user.name) and to indicate relative
* identifiers in filters (.age). This method sets up state flags that control how
* the next identifier will be processed.
*
* @example Property access chain
* ```typescript
* // For "user.profile.avatar"
* // Each dot sets up the next identifier to be chained from the previous
* // Creates: { type: 'Identifier', value: 'avatar', from: { ... } }
* ```
*
* @example Relative identifier in filter
* ```typescript
* // For "users[.age > 18]"
* // The dot before 'age' marks it as relative to the current filter context
* // Creates: { type: 'Identifier', value: 'age', relative: true }
* ```
*
* @example Standalone relative identifier for transforms
* ```typescript
* // For ".|transform" - the dot is standalone and becomes a relative identifier
* // Creates: { type: 'Identifier', value: '.', relative: true }
* ```
*
* @example Mixed access patterns
* ```typescript
* // For "data.items[.value > threshold].name"
* // First dot: normal property access (data.items)
* // Second dot: relative identifier (.value)
* // Third dot: property access on filtered result (.name)
* ```
*/
private dot(): void {
this._nextIdentEncapsulate = Boolean(
this._cursor &&
this._cursor.type !== 'UnaryExpression' &&
(this._cursor.type !== 'BinaryExpression' || (this._cursor.type === 'BinaryExpression' && this._cursor.right))
)
this._nextIdentRelative = !this._cursor || (this._cursor && !this._nextIdentEncapsulate)
if (this._nextIdentRelative) {
this._relative = true
}
}
/**
* Handles completed filter sub expressions for array filtering or property access.
*
* Filter expressions use square bracket notation like [expression]. They can be either
* relative (filtering arrays where each element becomes context) or static (property access).
*
* @param ast - The completed sub expression AST for the filter
*
* @example Relative filter (array filtering)
* ```typescript
* // For "users[.age > 18]"
* // Creates FilterExpression with:
* // - subject: 'users' identifier
* // - expr: binary expression '.age > 18'
* // - relative: true (because subParser.isRelative() returns true)
* ```
*
* @example Static filter (property access)
* ```typescript
* // For "config['api' + 'Key']"
* // Creates FilterExpression with:
* // - subject: 'config' identifier
* // - expr: binary expression '"api" + "Key"'
* // - relative: false (no relative identifiers in expression)
* ```
*
* @example Dynamic array indexing
* ```typescript
* // For "items[currentIndex + 1]"
* // Creates FilterExpression for computed array access
* ```
*/
private filter(ast: Token): void {
this._placeBeforeCursor({
type: 'FilterExpression',
expr: ast,
relative: this._subParser.isRelative(),
subject: this._cursor,
})
}
/**
* Handles identifier tokens when used to indicate the name of a function to
* be called.
*/
private functionCall(): void {
// Check if the current cursor is already a FunctionCall with pool 'transforms'
// This happens when we have namespace transforms with arguments like String.repeat(3)
if (this._cursor && this._cursor.type === 'FunctionCall' && this._cursor.pool === 'transforms') {
// Don't create a new FunctionCall, just continue with the existing transform
return
}
const functionName = this._buildFullIdentifierPath(this._cursor || null)
this._placeBeforeCursor({
type: 'FunctionCall',
name: functionName,
args: [],
pool: 'functions',
})
}
/**
* Builds the full namespace path for an identifier by traversing the 'from' chain.
* This supports namespace functions like 'My.sayHi' by building the complete name.
*
* @param node - The identifier node to build the path for
* @returns The full namespace path as a string
*
* @example
* // For identifier chain: My.sayHi
* // Returns: "My.sayHi"
*
* @example
* // For simple identifier: sayHi
* // Returns: "sayHi"
*/
private _buildFullIdentifierPath(node: Token | null): string {
if (!node || node.type !== 'Identifier') {
return (node?.value as string) || ''
}
const parts: string[] = []
let current: Token | null = node
// Walk up the 'from' chain to collect all parts
while (current && current.type === 'Identifier') {
parts.unshift(current.value as string)
current = current.from || null
}
return parts.join('.')
}
/**
* Handles identifier tokens by creating Identifier AST nodes.
*
* Identifiers represent variable names, property names, or function names. Their placement
* in the AST depends on context - they can be standalone, part of a property chain, or
* relative to a filter context.
*
* @param token - Identifier token with the name value
*
* @example Simple identifier
* ```typescript
* // For "username"
* // Creates: { type: 'Identifier', value: 'username' }
* ```
*
* @example Property access
* ```typescript
* // For "user.name" - when processing 'name' after dot
* // Creates: { type: 'Identifier', value: 'name', from: userIdentifier }
* ```
*
* @example Relative identifier in filter
* ```typescript
* // For ".age" in filter context
* // Creates: { type: 'Identifier', value: 'age', relative: true }
* ```
*
* @example Function name
* ```typescript
* // For "max(" - identifier before parentheses
* // Later converted to FunctionCall node by functionCall handler
* ```
*/
private identifier(token: Token): void {
const node: Token = {
type: 'Identifier',
value: token.value,
}
// Special handling for namespace transforms
// If we're creating an identifier that has 'from' pointing to a FunctionCall with pool 'transforms',
// this indicates a namespace transform like 'String.upper'
if (
this._nextIdentEncapsulate &&
this._cursor &&
this._cursor.type === 'FunctionCall' &&
this._cursor.pool === 'transforms'
) {
// Rebuild the transform with the full namespace path
const namespaceParts: string[] = []
namespaceParts.push(this._cursor.name as string) // e.g., "String"
namespaceParts.push(token.value as string) // e.g., "upper"
const namespacedTransformName = namespaceParts.join('.')
// Update the existing transform's name instead of creating a new identifier
this._cursor.name = namespacedTransformName
this._nextIdentEncapsulate = false
return
}
if (this._nextIdentEncapsulate) {
node.from = this._cursor
this._placeBeforeCursor(node)
this._nextIdentEncapsulate = false
} else {
if (this._nextIdentRelative) {
node.relative = true
this._nextIdentRelative = false
}
this._placeAtCursor(node)
}
}
/**
* Handles literal value tokens (strings, numbers, booleans, null).
*
* Literals represent constant values in expressions. They are the simplest AST nodes
* and are always leaf nodes (no children).
*
* @param token - Literal token containing the parsed value
*
* @example String literal
* ```typescript
* // For '"Hello World"'
* // Creates: { type: 'Literal', value: 'Hello World' }
* ```
*
* @example Number literal
* ```typescript
* // For '42.5'
* // Creates: { type: 'Literal', value: 42.5 }
* ```
*
* @example Boolean literal
* ```typescript
* // For 'true'
* // Creates: { type: 'Literal', value: true }
* ```
*
* @example Null literal
* ```typescript
* // For 'null'
* // Creates: { type: 'Literal', value: null }
* ```
*/
private literal(token: Token): void {
this._placeAtCursor({
type: 'Literal',
value: token.value,
})
}
/**
* Queues a new object literal key to be written once a value is collected.
* @param token A token object
*/
private objKey(token: Token): void {
this._curObjKey = token.value as string
}
/**
* Handles new object literals by adding them as a new node in the AST,
* initialized with an empty object.
*/
private objStart(): void {
this._placeAtCursor({
type: 'ObjectLiteral',
value: {},
})
}
/**
* Handles an object value by adding its AST to the queued key on the object
* literal node currently at the cursor.
* @param ast The sub expression tree
*/
private objVal(ast: Token): void {
if (this._cursor && this._curObjKey) {
;(this._cursor.value as Record<string, Token>)[this._curObjKey] = ast
}
}
/**
* Handles traditional sub expressions, delineated with the groupStart and
* groupEnd elements.
* @param ast The sub expression tree
*/
private subExpression(ast: Token): void {
this._placeAtCursor(ast)
}
/**
* Handles a completed alternate sub expression of a ternary operator.
* @param ast The sub expression tree
*/
private ternaryEnd(ast: Token): void {
if (this._cursor) {
this._cursor.alternate = ast
}
}
/**
* Handles a completed consequent sub expression of a ternary operator.
* @param ast The sub expression tree
*/
private ternaryMid(ast: Token): void {
if (this._cursor) {
this._cursor.consequent = ast
}
}
/**
* Initiates a ternary conditional expression by wrapping the current AST as the test condition.
*
* Ternary expressions have the form: test ? consequent : alternate
* This method is called when the '?' token is encountered.
*
* @example Standard ternary
* ```typescript
* // For "age >= 18 ? 'adult' : 'minor'"
* // When '?' is encountered, wraps existing "age >= 18" as test:
* // {
* // type: 'ConditionalExpression',
* // test: { type: 'BinaryExpression', ... }, // age >= 18
* // consequent: undefined, // will be set by ternaryMid
* // alternate: undefined // will be set by ternaryEnd
* // }
* ```
*
* @example Elvis operator (missing consequent)
* ```typescript
* // For "nickname ?: 'Anonymous'"
* // Similar structure but consequent will remain undefined
* // Evaluator will use test result as consequent
* ```
*/
private ternaryStart(): void {
this._tree = {
type: 'ConditionalExpression',
test: this._tree || undefined,
}
this._cursor = this._tree
}
/**
* Converts an identifier token into a transform function call.
*
* Transform functions are applied using the pipe operator (|). This method converts
* the identifier following a pipe into a FunctionCall node in the transforms pool.
* Supports namespace transforms like 'String.upper' or 'Utils.format'.
*
* @param token - Identifier token with the transform function name
*
* @example Simple transform
* ```typescript
* // For "name|upper"
* // When processing 'upper' after pipe:
* // Creates: {
* // type: 'FunctionCall',
* // name: 'upper',
* // args: [nameIdentifier], // current cursor becomes first argument
* // pool: 'transforms'
* // }
* ```
*
* @example Namespace transform
* ```typescript
* // For "name|String.upper"
* // When processing 'String.upper' after pipe:
* // Creates: {
* // type: 'FunctionCall',
* // name: 'String.upper',
* // args: [nameIdentifier],
* // pool: 'transforms'
* // }
* ```
*
* @example Transform with arguments
* ```typescript
* // For "value|Utils.multiply(2, 3)"
* // Creates FunctionCall node, args will be populated by argVal handler:
* // {
* // type: 'FunctionCall',
* // name: 'Utils.multiply',
* // args: [valueIdentifier, 2, 3],
* // pool: 'transforms'
* // }
* ```
*
* @example Chained transforms
* ```typescript
* // For "name|String.lower|String.trim"
* // Each transform becomes a FunctionCall wrapping the previous result
* ```
*/
private transform(token: Token): void {
// For transforms, the token contains the transform name
// We need to build the full namespace path if this is part of a namespace transform
const transformName = token.value as string
this._placeBeforeCursor({
type: 'FunctionCall',
name: transformName,
args: this._cursor ? [this._cursor] : [],
pool: 'transforms',
})
}
/**
* Handles token of type 'unaryOp', indicating that the operation has only
* one input: a right side.
* @param token A token object
*/
private unaryOp(token: Token): void {
this._placeAtCursor({
type: 'UnaryExpression',
operator: token.value as string,
})
}
/**
* Maps token types to their corresponding handler methods
* @param tokenType The type of token to handle
* @returns The handler method for this token type
* @private
*/
private _getHandlerMethod(tokenType: string): ((token: Token) => void) | undefined {
switch (tokenType) {
case 'binaryOp':
return this.binaryOp.bind(this)
case 'dot':
return () => this.dot()
case 'identifier':
return this.identifier.bind(this)
case 'literal':
return this.literal.bind(this)
case 'unaryOp':
return this.unaryOp.bind(this)
case 'pipe':
return () => this.pipe()
default:
return undefined
}
}
/**
* Handles pipe (|) tokens for transform operations.
*
* Special handling for cases where a pipe follows a dot in traverse state,
* indicating a standalone relative identifier for transforms like .|transform.
*/
private pipe(): void {
// If we're in traverse state and encounter a pipe, it means we have a standalone dot
// that should become a relative identifier for the transform
if (this._state === 'traverse') {
// Create a standalone relative identifier
this._placeAtCursor({
type: 'Identifier',
value: '.',
relative: true,
})
this._relative = true
}
}
/**
* Maps token handler names to their corresponding handler methods
* @param handlerName The name of the token handler to handle
* @returns The handler method for this token handler name
* @private
*/
private _getTokenHandlerMethod(handlerName: string): ((token: Token) => void) | undefined {
switch (handlerName) {
case 'arrayStart':
return () => this.arrayStart()
case 'functionCall':
return () => this.functionCall()
case 'objKey':
return this.objKey.bind(this)
case 'objStart':
return () => this.objStart()
case 'ternaryStart':
return () => this.ternaryStart()
case 'transform':
return this.transform.bind(this)
default:
return undefined
}
}
/**
* Maps subHandler names to their corresponding handler methods
* @param handlerName The name of the subHandler to handle
* @returns The handler method for this subHandler name
* @private
*/
private _getSubHandlerMethod(handlerName?: string): ((ast: Token) => void) | undefined {
switch (handlerName) {
case 'argVal':
return this.argVal.bind(this)
case 'arrayVal':
return this.arrayVal.bind(this)
case 'filter':
return this.filter.bind(this)
case 'objVal':
return this.objVal.bind(this)
case 'subExpression':
return this.subExpression.bind(this)
case 'ternaryEnd':
return this.ternaryEnd.bind(this)
case 'ternaryMid':
return this.ternaryMid.bind(this)
default:
return undefined
}
}
// ===== Private Handler Methods =====
//
// The following methods handle specific token types and AST node construction.
// They are called by the state machine based on current state and token type.
//
// Handler methods fall into several categories:
// 1. Token handlers: Process individual tokens (identifier, literal, binaryOp, etc.)
// 2. Structure handlers: Handle compound structures (arrays, objects, functions)
// 3. Sub expression handlers: Process completed sub expressions (filter, argVal, etc.)
// 4. State transition handlers: Manage parser state changes (dot, ternaryStart, etc.)
//
// The parser uses a cursor-based approach where _cursor points to the current
// position in the AST where new nodes should be added or where modifications
// should be made.
}