@pawel-up/jexl
Version:
Javascript Expression Language: Powerful context-based expression parser and evaluator
1,051 lines (960 loc) • 34 kB
text/typescript
/* eslint-disable no-case-declarations */
import type {
Grammar,
ASTNode,
FunctionCallNode,
BinaryExpressionNode,
UnaryExpressionNode,
ConditionalExpressionNode,
FilterExpressionNode,
IdentifierNode,
ArrayLiteralNode,
ObjectLiteralNode,
} from './grammar.js'
import Lexer from './Lexer.js'
import Parser from './parser/Parser.js'
/**
* Severity levels for validation issues.
*/
export enum ValidationSeverity {
/** Critical errors that prevent expression compilation/evaluation */
ERROR = 'error',
/** Potential issues that might cause runtime problems */
WARNING = 'warning',
/** Informational messages about expression structure */
INFO = 'info',
}
/**
* Represents a validation issue found in a Jexl expression.
*/
export interface ValidationIssue {
/** Severity level of the issue */
severity: ValidationSeverity
/** Human-readable description of the issue */
message: string
/** Character position where the issue starts (0-based) */
startPosition?: number
/** Character position where the issue ends (0-based) */
endPosition?: number
/** Line number where the issue occurs (1-based) */
line?: number
/** Column number where the issue occurs (1-based) */
column?: number
/** The problematic token or text segment */
token?: string
/** Error code for programmatic handling */
code?: string
}
/**
* Configuration options for expression validation.
*/
export interface ValidationOptions {
/**
* If true, assumes context variables are not yet available and treats
* undefined identifiers as valid. Useful for validating expressions
* before runtime context is known.
*/
allowUndefinedContext?: boolean
/**
* If true, provides additional informational messages about
* expression structure and optimization opportunities.
*/
includeInfo?: boolean
/**
* If true, provides warnings about potential runtime issues
* like accessing properties on possibly undefined values.
*/
includeWarnings?: boolean
/**
* Maximum expression depth to prevent stack overflow.
* Expressions deeper than this will generate warnings.
*/
maxDepth?: number
/**
* Maximum expression length to prevent performance issues.
* Expressions longer than this will generate warnings.
*/
maxLength?: number
/**
* Custom functions that should be considered available during validation.
* Useful when validating expressions that will use runtime-defined functions.
*/
customFunctions?: string[]
/**
* Custom transforms that should be considered available during validation.
* Useful when validating expressions that will use runtime-defined transforms.
*/
customTransforms?: string[]
}
/**
* Result of expression validation containing all found issues.
*/
export interface ValidationResult {
/** Whether the expression is valid (no errors) */
valid: boolean
/** All validation issues found, sorted by position */
issues: ValidationIssue[]
/** Only error-level issues */
errors: ValidationIssue[]
/** Only warning-level issues */
warnings: ValidationIssue[]
/** Only info-level issues */
info: ValidationIssue[]
/** The compiled AST if compilation was successful */
ast?: ASTNode
}
/**
* Jexl Expression Validator
*
* Provides comprehensive validation of Jexl expressions, including syntax checking,
* semantic analysis, and optional context validation. Can differentiate between
* critical errors, warnings, and informational messages.
*
* The validator can operate in two modes:
* 1. **Strict mode**: Validates that all identifiers exist in provided context
* 2. **Lenient mode**: Assumes context will be available at runtime
*
* ## Automatic Expression Trimming
*
* The validator automatically trims leading and trailing whitespace from expressions
* before validation. This provides a better user experience since whitespace has no
* semantic meaning in Jexl expressions.
*
* - `"user.name"` and `" user.name "` are treated identically
* - Internal whitespace is preserved: `"a + b"` stays `"a + b"`
* - Empty or whitespace-only expressions are still detected as invalid
*
* @example
* ```typescript
* const validator = new Validator(jexl.grammar)
*
* // Basic validation
* const result = validator.validate('user.name | upper')
* if (!result.valid) {
* console.log('Errors:', result.errors)
* }
*
* // Trimming behavior - these are all equivalent
* validator.validate('user.name') // valid
* validator.validate(' user.name ') // valid (trimmed)
* validator.validate('\t user.name\n') // valid (trimmed)
* validator.validate(' ') // invalid (empty after trim)
*
* // Validation with context checking
* const strictResult = validator.validate(
* 'user.age >= 18 ? "adult" : "minor"',
* { user: { name: 'John' } }, // missing 'age' property
* { allowUndefinedContext: false }
* )
*
* // Lenient validation (useful during development)
* const lenientResult = validator.validate(
* 'future.feature | someTransform',
* {},
* {
* allowUndefinedContext: true,
* customTransforms: ['someTransform']
* }
* )
* ```
*
* ## Validation Categories
*
* ### Errors (prevent execution)
* - Syntax errors (invalid tokens, mismatched brackets)
* - Unknown operators, functions, or transforms
* - Invalid expression structure
*
* ### Warnings (potential runtime issues)
* - Accessing properties on undefined values
* - Unused context variables
* - Performance concerns (deep nesting, long expressions)
*
* ### Info (optimization opportunities)
* - Expressions that could be simplified
* - Suggestions for better performance
* - Usage statistics
*/
export class Validator {
private _grammar: Grammar
private _lexer: Lexer
/**
* Creates a new Validator instance.
*
* @param grammar The Jexl grammar configuration to use for validation
*
* @example
* ```typescript
* import { Jexl } from '@pawel-up/jexl'
* import { Validator } from '@pawel-up/jexl'
*
* const jexl = new Jexl()
* const validator = new Validator(jexl.grammar)
* ```
*/
constructor(grammar: Grammar) {
this._grammar = grammar
this._lexer = new Lexer(grammar)
}
/**
* Validates a Jexl expression and returns detailed validation results.
*
* @param expression The Jexl expression string to validate
* @param context Optional context object for strict validation
* @param options Validation configuration options
* @returns Comprehensive validation results
*
* @example
* ```typescript
* // Basic syntax validation
* const result = validator.validate('user.name | upper')
*
* // Strict validation with context
* const strictResult = validator.validate(
* 'user.profile.email',
* { user: { name: 'John' } }, // missing profile
* { allowUndefinedContext: false }
* )
*
* // Development mode validation
* const devResult = validator.validate(
* 'items | filter("active") | map("name")',
* {},
* {
* allowUndefinedContext: true,
* includeInfo: true,
* includeWarnings: true
* }
* )
*
* // Check results
* if (!result.valid) {
* result.errors.forEach(error => {
* console.log(`Error at ${error.line}:${error.column}: ${error.message}`)
* })
* }
*
* if (result.warnings.length > 0) {
* console.log('Warnings:', result.warnings.map(w => w.message))
* }
* ```
*/
validate(expression: string, context?: Record<string, unknown>, options: ValidationOptions = {}): ValidationResult {
const issues: ValidationIssue[] = []
const opts = this._getDefaultOptions(options)
// Basic input validation
if (!expression || typeof expression !== 'string') {
issues.push({
severity: ValidationSeverity.ERROR,
message: 'Expression must be a non-empty string',
code: 'INVALID_INPUT',
})
return this._createResult(issues)
}
// Trim whitespace from the expression since it has no semantic meaning
const trimmedExpression = expression.trim()
// Check for empty expressions after trimming
if (trimmedExpression.length === 0) {
issues.push({
severity: ValidationSeverity.ERROR,
message: 'Expression cannot be empty or whitespace only',
code: 'INVALID_INPUT',
})
return this._createResult(issues)
}
// Length validation (check original length for user feedback, but use trimmed for processing)
if (opts.maxLength && expression.length > opts.maxLength) {
issues.push({
severity: ValidationSeverity.WARNING,
message: `Expression length (${expression.length}) exceeds recommended maximum (${opts.maxLength})`,
code: 'EXPRESSION_TOO_LONG',
})
}
try {
// Step 1: Lexical analysis
this._validateLexical(trimmedExpression, issues)
if (this._hasErrors(issues)) {
return this._createResult(issues)
}
// Step 2: Syntax analysis
const ast = this._validateSyntax(trimmedExpression, issues)
if (this._hasErrors(issues) || !ast) {
return this._createResult(issues)
}
// Step 3: Semantic analysis
this._validateSemantics(ast, trimmedExpression, issues, opts)
// Step 4: Context validation (if not in lenient mode)
if (!opts.allowUndefinedContext && context !== undefined) {
this._validateContext(ast, context, trimmedExpression, issues, opts)
}
// Step 5: Additional analysis
if (opts.includeWarnings) {
this._performWarningAnalysis(ast, trimmedExpression, issues)
}
if (opts.includeInfo) {
this._performInfoAnalysis(ast, trimmedExpression, issues)
}
return this._createResult(issues, ast)
} catch (error) {
issues.push({
severity: ValidationSeverity.ERROR,
message: `Validation failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
code: 'VALIDATION_ERROR',
})
return this._createResult(issues)
}
}
/**
* Quick validation check that only returns whether the expression is syntactically valid.
* Useful for real-time validation in editors where detailed analysis isn't needed.
*
* @param expression The expression to validate
* @returns True if the expression has valid syntax
*
* @example
* ```typescript
* // Quick syntax check
* if (validator.isValid('user.name | upper')) {
* console.log('Expression syntax is valid')
* }
*
* // Use in form validation
* const isExpressionValid = validator.isValid(userInput)
* setFieldError(isExpressionValid ? null : 'Invalid expression syntax')
* ```
*/
isValid(expression: string): boolean {
try {
const result = this.validate(
expression,
{},
{
allowUndefinedContext: true,
includeWarnings: false,
includeInfo: false,
}
)
return result.valid
} catch {
return false
}
}
/**
* Validates expression syntax and returns the first error found, or null if valid.
* Useful for providing immediate feedback in development tools.
*
* @param expression The expression to validate
* @returns The first error found, or null if valid
*
* @example
* ```typescript
* const error = validator.getFirstError('user.name |')
* if (error) {
* console.log(`Syntax error: ${error.message}`)
* if (error.line && error.column) {
* console.log(`at line ${error.line}, column ${error.column}`)
* }
* }
* ```
*/
getFirstError(expression: string): ValidationIssue | null {
const result = this.validate(
expression,
{},
{
allowUndefinedContext: true,
includeWarnings: false,
includeInfo: false,
}
)
return result.errors[0] || null
}
/**
* Performs lexical validation by tokenizing the expression.
* @private
*/
private _validateLexical(expression: string, issues: ValidationIssue[]): void {
try {
const tokens = this._lexer.tokenize(expression)
// Check for invalid token sequences
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i]
const nextToken = tokens[i + 1]
// Check for consecutive operators
if (token.type === 'binaryOp' && nextToken?.type === 'binaryOp') {
const position = this._findTokenPosition(expression, token.raw)
issues.push({
severity: ValidationSeverity.ERROR,
message: `Consecutive operators: '${token.value}' followed by '${nextToken.value}'`,
startPosition: position,
endPosition: position + token.raw.length,
token: token.raw,
code: 'CONSECUTIVE_OPERATORS',
...this._getLineColumn(expression, position),
})
}
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Lexical analysis failed'
const match = message.match(/Invalid expression token: (.+)/)
if (match) {
const invalidToken = match[1]
const position = expression.indexOf(invalidToken)
issues.push({
severity: ValidationSeverity.ERROR,
message: `Invalid token: '${invalidToken}'`,
startPosition: position >= 0 ? position : undefined,
endPosition: position >= 0 ? position + invalidToken.length : undefined,
token: invalidToken,
code: 'INVALID_TOKEN',
...this._getLineColumn(expression, position >= 0 ? position : 0),
})
} else {
issues.push({
severity: ValidationSeverity.ERROR,
message,
code: 'LEXICAL_ERROR',
})
}
}
}
/**
* Performs syntax validation by parsing the expression into an AST.
* @private
*/
private _validateSyntax(expression: string, issues: ValidationIssue[]): ASTNode | null {
try {
const parser = new Parser(this._grammar)
const tokens = this._lexer.tokenize(expression)
parser.addTokens(tokens)
const ast = parser.complete()
return ast as ASTNode
} catch (error) {
const originalMessage = error instanceof Error ? error.message : 'Parse error'
const message = `Syntax error: ${originalMessage}`
// Try to extract position information from parser errors
let position: number | undefined
let token: string | undefined
const unexpectedMatch = message.match(/Unexpected token (.+)/)
if (unexpectedMatch) {
token = unexpectedMatch[1]
position = expression.indexOf(token)
}
issues.push({
severity: ValidationSeverity.ERROR,
message,
startPosition: position,
endPosition: position !== undefined && token ? position + token.length : undefined,
token,
code: 'SYNTAX_ERROR',
...this._getLineColumn(expression, position || 0),
})
return null
}
}
/**
* Performs semantic validation of the AST.
* @private
*/
private _validateSemantics(
ast: ASTNode,
expression: string,
issues: ValidationIssue[],
options: Required<ValidationOptions>
): void {
this._validateASTNode(ast, expression, issues, options, 0)
}
/**
* Recursively validates an AST node and its children.
* @private
*/
private _validateASTNode(
node: ASTNode,
expression: string,
issues: ValidationIssue[],
options: Required<ValidationOptions>,
depth: number
): void {
if (!node) return
// Check depth limits
if (options.maxDepth && depth > options.maxDepth) {
issues.push({
severity: ValidationSeverity.WARNING,
message: `Expression depth (${depth}) exceeds recommended maximum (${options.maxDepth})`,
code: 'EXPRESSION_TOO_DEEP',
})
return
}
// Validate based on node type
switch (node.type) {
case 'FunctionCall':
this._validateFunction(node as FunctionCallNode, expression, issues, options)
break
case 'BinaryExpression':
this._validateBinaryExpression(node as BinaryExpressionNode, expression, issues)
break
case 'UnaryExpression':
this._validateUnaryExpression(node as UnaryExpressionNode, expression, issues)
break
}
// Recursively validate children based on node type
switch (node.type) {
case 'BinaryExpression':
const binaryNode = node as BinaryExpressionNode
this._validateASTNode(binaryNode.left, expression, issues, options, depth + 1)
this._validateASTNode(binaryNode.right, expression, issues, options, depth + 1)
break
case 'UnaryExpression':
const unaryNode = node as UnaryExpressionNode
this._validateASTNode(unaryNode.right, expression, issues, options, depth + 1)
break
case 'ConditionalExpression':
const conditionalNode = node as ConditionalExpressionNode
this._validateASTNode(conditionalNode.test, expression, issues, options, depth + 1)
this._validateASTNode(conditionalNode.consequent, expression, issues, options, depth + 1)
this._validateASTNode(conditionalNode.alternate, expression, issues, options, depth + 1)
break
case 'FilterExpression':
const filterNode = node as FilterExpressionNode
this._validateASTNode(filterNode.subject, expression, issues, options, depth + 1)
this._validateASTNode(filterNode.expr, expression, issues, options, depth + 1)
break
case 'Identifier':
const identifierNode = node as IdentifierNode
if (identifierNode.from) {
this._validateASTNode(identifierNode.from, expression, issues, options, depth + 1)
}
break
case 'FunctionCall':
const functionNode = node as FunctionCallNode
if (functionNode.args) {
functionNode.args.forEach((arg) => {
this._validateASTNode(arg, expression, issues, options, depth + 1)
})
}
break
case 'ArrayLiteral':
const arrayNode = node as ArrayLiteralNode
arrayNode.value.forEach((item) => {
this._validateASTNode(item, expression, issues, options, depth + 1)
})
break
case 'ObjectLiteral':
const objectNode = node as ObjectLiteralNode
Object.values(objectNode.value).forEach((value) => {
this._validateASTNode(value, expression, issues, options, depth + 1)
})
break
}
}
/**
* Validates function calls.
* @private
*/
private _validateFunction(
node: FunctionCallNode,
_expression: string,
issues: ValidationIssue[],
options: Required<ValidationOptions>
): void {
const funcName = node.name
if (!funcName) return
const isBuiltIn = this._grammar.functions[funcName] !== undefined
const isCustom = options.customFunctions.includes(funcName)
if (!isBuiltIn && !isCustom && node.pool === 'functions') {
issues.push({
severity: ValidationSeverity.ERROR,
message: `Unknown function: '${funcName}'`,
token: funcName,
code: 'UNKNOWN_FUNCTION',
})
}
if (!isBuiltIn && !isCustom && node.pool === 'transforms') {
const isBuiltInTransform = this._grammar.transforms[funcName] !== undefined
const isCustomTransform = options.customTransforms.includes(funcName)
if (!isBuiltInTransform && !isCustomTransform) {
issues.push({
severity: ValidationSeverity.ERROR,
message: `Unknown transform: '${funcName}'`,
token: funcName,
code: 'UNKNOWN_TRANSFORM',
})
}
}
}
/**
* Validates binary expressions.
* @private
*/
private _validateBinaryExpression(node: BinaryExpressionNode, _expression: string, issues: ValidationIssue[]): void {
const operator = node.operator
if (!operator) return
const isBuiltIn = this._grammar.elements[operator] !== undefined
if (!isBuiltIn) {
issues.push({
severity: ValidationSeverity.ERROR,
message: `Unknown binary operator: '${operator}'`,
token: operator,
code: 'UNKNOWN_OPERATOR',
})
}
}
/**
* Validates unary expressions.
* @private
*/
private _validateUnaryExpression(node: UnaryExpressionNode, _expression: string, issues: ValidationIssue[]): void {
const operator = node.operator
if (!operator) return
const isBuiltIn = this._grammar.elements[operator] !== undefined
if (!isBuiltIn) {
issues.push({
severity: ValidationSeverity.ERROR,
message: `Unknown unary operator: '${operator}'`,
token: operator,
code: 'UNKNOWN_OPERATOR',
})
}
}
private _validateContext(
ast: ASTNode,
context: Record<string, unknown>,
expression: string,
issues: ValidationIssue[],
options: Required<ValidationOptions>
): void {
this._validateContextPath(ast, context, context, expression, issues, options)
}
/**
* Recursively validates an AST node's context path.
* @param node The AST node to validate
* @param localContext The current context for this node
* @param globalContext The top-level context
* @param expression The full expression string
* @param issues The array of issues to populate
* @param options Validation options
* @returns The resolved value from the context if the path is valid and static, otherwise undefined.
* @private
*/
private _validateContextPath(
node: ASTNode,
localContext: unknown,
globalContext: unknown,
expression: string,
issues: ValidationIssue[],
options: Required<ValidationOptions>
): unknown {
if (!node) return undefined
switch (node.type) {
case 'Identifier':
const idNode = node as IdentifierNode
if (idNode.from) {
const parentContext = this._validateContextPath(
idNode.from,
localContext,
globalContext,
expression,
issues,
options
)
if (parentContext === undefined) {
// Cannot resolve parent, or error already reported.
return undefined
}
if (parentContext === null) {
issues.push({
severity: ValidationSeverity.WARNING,
message: `Cannot access property '${idNode.value}' of null`,
token: idNode.value,
code: 'PROPERTY_ACCESS_ON_NULL',
})
return undefined
}
if (typeof parentContext !== 'object') {
issues.push({
severity: ValidationSeverity.WARNING,
message: `Cannot access property '${idNode.value}' on non-object value of type ${typeof parentContext}`,
token: idNode.value,
code: 'PROPERTY_ACCESS_ON_NON_OBJECT',
})
return undefined
}
if (!(idNode.value in parentContext)) {
issues.push({
severity: ValidationSeverity.WARNING,
message: `Property '${idNode.value}' not found on object`,
token: idNode.value,
code: 'UNDEFINED_PROPERTY',
})
return undefined
}
return (parentContext as Record<string, unknown>)[idNode.value]
}
if (idNode.relative) {
// This is for relative identifiers like '.' or '.foo'
if (!idNode.value) {
// This is for '.' which refers to the context element itself
return localContext
}
// This is for '.foo' which is a property on the context element
const parentContext = localContext
if (parentContext === null) {
issues.push({
severity: ValidationSeverity.WARNING,
message: `Cannot access property '${idNode.value}' of null`,
token: idNode.value,
code: 'PROPERTY_ACCESS_ON_NULL',
})
return undefined
}
if (typeof parentContext !== 'object') {
issues.push({
severity: ValidationSeverity.WARNING,
message: `Cannot access property '${idNode.value}' on non-object value of type ${typeof parentContext}`,
token: idNode.value,
code: 'PROPERTY_ACCESS_ON_NON_OBJECT',
})
return undefined
}
if (!(idNode.value in parentContext)) {
issues.push({
severity: ValidationSeverity.WARNING,
message: `Property '${idNode.value}' not found on relative context object`,
token: idNode.value,
code: 'UNDEFINED_PROPERTY',
})
return undefined
}
return (parentContext as Record<string, unknown>)[idNode.value]
}
if (typeof globalContext === 'object' && globalContext !== null && idNode.value in globalContext) {
return (globalContext as Record<string, unknown>)[idNode.value]
}
issues.push({
severity: ValidationSeverity.WARNING,
message: `Identifier '${idNode.value}' not found in context`,
token: idNode.value,
code: 'UNDEFINED_IDENTIFIER',
})
return undefined
case 'Literal':
return node.value
case 'ArrayLiteral':
const arrayNode = node as ArrayLiteralNode
return arrayNode.value.map((item) =>
this._validateContextPath(item, localContext, globalContext, expression, issues, options)
)
case 'ObjectLiteral':
const objectNode = node as ObjectLiteralNode
const newObj: Record<string, unknown> = {}
for (const key in objectNode.value) {
newObj[key] = this._validateContextPath(
objectNode.value[key],
localContext,
globalContext,
expression,
issues,
options
)
}
return newObj
case 'BinaryExpression':
const binaryNode = node as BinaryExpressionNode
this._validateContextPath(binaryNode.left, localContext, globalContext, expression, issues, options)
this._validateContextPath(binaryNode.right, localContext, globalContext, expression, issues, options)
return undefined // Result of binary expression is dynamic
case 'UnaryExpression':
const unaryNode = node as UnaryExpressionNode
this._validateContextPath(unaryNode.right, localContext, globalContext, expression, issues, options)
return undefined // Result of unary expression is dynamic
case 'ConditionalExpression':
const conditionalNode = node as ConditionalExpressionNode
this._validateContextPath(conditionalNode.test, localContext, globalContext, expression, issues, options)
this._validateContextPath(conditionalNode.consequent, localContext, globalContext, expression, issues, options)
this._validateContextPath(conditionalNode.alternate, localContext, globalContext, expression, issues, options)
return undefined // Result of conditional is dynamic
case 'FilterExpression':
const filterNode = node as FilterExpressionNode
const subjectContext = this._validateContextPath(
filterNode.subject,
localContext,
globalContext,
expression,
issues,
options
)
if (subjectContext === undefined) return undefined
if (filterNode.relative) {
if (!Array.isArray(subjectContext)) {
if (subjectContext !== undefined) {
issues.push({
severity: ValidationSeverity.WARNING,
message: 'Relative filter expression used on non-array value',
code: 'FILTER_ON_NON_ARRAY',
})
}
return undefined
}
// For relative filters, the context for the inner expression is an element of the array.
// We can't know which one, so we use the first element if available, or an empty object as a placeholder.
const relativeContext = subjectContext.length > 0 ? subjectContext[0] : {}
this._validateContextPath(filterNode.expr, relativeContext, globalContext, expression, issues, options)
} else {
// For static filters, the inner expression is evaluated against the main context.
this._validateContextPath(filterNode.expr, localContext, globalContext, expression, issues, options)
}
return undefined // Result of a filter is dynamic
case 'FunctionCall':
const functionNode = node as FunctionCallNode
if (functionNode.args) {
functionNode.args.forEach((arg) =>
this._validateContextPath(arg, localContext, globalContext, expression, issues, options)
)
}
return undefined // Result of a function call is dynamic
}
return undefined
}
/**
* Performs additional warning analysis.
* @private
*/
private _performWarningAnalysis(ast: ASTNode, _expression: string, issues: ValidationIssue[]): void {
// Add warnings for complex expressions, potential optimizations, etc.
const nodeCount = this._countNodes(ast)
if (nodeCount > 50) {
issues.push({
severity: ValidationSeverity.WARNING,
message: `Complex expression with ${nodeCount} nodes may impact performance`,
code: 'COMPLEX_EXPRESSION',
})
}
}
/**
* Performs informational analysis.
* @private
*/
private _performInfoAnalysis(ast: ASTNode, _expression: string, issues: ValidationIssue[]): void {
const nodeCount = this._countNodes(ast)
issues.push({
severity: ValidationSeverity.INFO,
message: `Expression contains ${nodeCount} nodes`,
code: 'EXPRESSION_STATS',
})
}
/**
* Gets default validation options.
* @private
*/
private _getDefaultOptions(options: ValidationOptions): Required<ValidationOptions> {
return {
allowUndefinedContext: true,
includeInfo: false,
includeWarnings: true,
maxDepth: 20,
maxLength: 1000,
customFunctions: [],
customTransforms: [],
...options,
}
}
/**
* Creates a validation result object.
* @private
*/
private _createResult(issues: ValidationIssue[], ast?: ASTNode): ValidationResult {
const errors = issues.filter((i) => i.severity === ValidationSeverity.ERROR)
const warnings = issues.filter((i) => i.severity === ValidationSeverity.WARNING)
const info = issues.filter((i) => i.severity === ValidationSeverity.INFO)
return {
valid: errors.length === 0,
issues: issues.sort((a, b) => (a.startPosition || 0) - (b.startPosition || 0)),
errors,
warnings,
info,
ast,
}
}
/**
* Checks if there are any error-level issues.
* @private
*/
private _hasErrors(issues: ValidationIssue[]): boolean {
return issues.some((issue) => issue.severity === ValidationSeverity.ERROR)
}
/**
* Finds the position of a token in the expression.
* @private
*/
private _findTokenPosition(expression: string, tokenRaw: string): number {
// This is a simplified implementation - in practice, you'd want to track
// positions during tokenization for more accuracy
return expression.indexOf(tokenRaw.trim())
}
/**
* Gets line and column information for a character position.
* @private
*/
private _getLineColumn(expression: string, position: number): { line: number; column: number } {
const lines = expression.substring(0, position).split('\n')
return {
line: lines.length,
column: lines[lines.length - 1].length + 1,
}
}
/**
* Counts the total number of nodes in an AST.
* @private
*/
private _countNodes(node: ASTNode): number {
if (!node) return 0
let count = 1
switch (node.type) {
case 'BinaryExpression':
const binaryNode = node as BinaryExpressionNode
count += this._countNodes(binaryNode.left)
count += this._countNodes(binaryNode.right)
break
case 'UnaryExpression':
const unaryNode = node as UnaryExpressionNode
count += this._countNodes(unaryNode.right)
break
case 'ConditionalExpression':
const conditionalNode = node as ConditionalExpressionNode
count += this._countNodes(conditionalNode.test)
count += this._countNodes(conditionalNode.consequent)
count += this._countNodes(conditionalNode.alternate)
break
case 'FilterExpression':
const filterNode = node as FilterExpressionNode
count += this._countNodes(filterNode.subject)
count += this._countNodes(filterNode.expr)
break
case 'Identifier':
const identifierNode = node as IdentifierNode
if (identifierNode.from) {
count += this._countNodes(identifierNode.from)
}
break
case 'FunctionCall':
const functionNode = node as FunctionCallNode
if (functionNode.args) {
count += functionNode.args.reduce((sum: number, arg: ASTNode) => sum + this._countNodes(arg), 0)
}
break
case 'ArrayLiteral':
const arrayNode = node as ArrayLiteralNode
count += arrayNode.value.reduce((sum: number, item: ASTNode) => sum + this._countNodes(item), 0)
break
case 'ObjectLiteral':
const objectNode = node as ObjectLiteralNode
count += Object.values(objectNode.value).reduce(
(sum: number, value: ASTNode) => sum + this._countNodes(value),
0
)
break
}
return count
}
}
export default Validator