UNPKG

@pawel-up/jexl

Version:

Javascript Expression Language: Powerful context-based expression parser and evaluator

550 lines 24 kB
import Lexer from './Lexer.js'; import Parser from './parser/Parser.js'; export var ValidationSeverity; (function (ValidationSeverity) { ValidationSeverity["ERROR"] = "error"; ValidationSeverity["WARNING"] = "warning"; ValidationSeverity["INFO"] = "info"; })(ValidationSeverity || (ValidationSeverity = {})); export class Validator { _grammar; _lexer; constructor(grammar) { this._grammar = grammar; this._lexer = new Lexer(grammar); } validate(expression, context, options = {}) { const issues = []; const opts = this._getDefaultOptions(options); 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); } const trimmedExpression = expression.trim(); if (trimmedExpression.length === 0) { issues.push({ severity: ValidationSeverity.ERROR, message: 'Expression cannot be empty or whitespace only', code: 'INVALID_INPUT', }); return this._createResult(issues); } 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 { this._validateLexical(trimmedExpression, issues); if (this._hasErrors(issues)) { return this._createResult(issues); } const ast = this._validateSyntax(trimmedExpression, issues); if (this._hasErrors(issues) || !ast) { return this._createResult(issues); } this._validateSemantics(ast, trimmedExpression, issues, opts); if (!opts.allowUndefinedContext && context !== undefined) { this._validateContext(ast, context, trimmedExpression, issues, opts); } 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); } } isValid(expression) { try { const result = this.validate(expression, {}, { allowUndefinedContext: true, includeWarnings: false, includeInfo: false, }); return result.valid; } catch { return false; } } getFirstError(expression) { const result = this.validate(expression, {}, { allowUndefinedContext: true, includeWarnings: false, includeInfo: false, }); return result.errors[0] || null; } _validateLexical(expression, issues) { try { const tokens = this._lexer.tokenize(expression); for (let i = 0; i < tokens.length; i++) { const token = tokens[i]; const nextToken = tokens[i + 1]; 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', }); } } } _validateSyntax(expression, issues) { try { const parser = new Parser(this._grammar); const tokens = this._lexer.tokenize(expression); parser.addTokens(tokens); const ast = parser.complete(); return ast; } catch (error) { const originalMessage = error instanceof Error ? error.message : 'Parse error'; const message = `Syntax error: ${originalMessage}`; let position; let token; 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; } } _validateSemantics(ast, expression, issues, options) { this._validateASTNode(ast, expression, issues, options, 0); } _validateASTNode(node, expression, issues, options, depth) { if (!node) return; 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; } switch (node.type) { case 'FunctionCall': this._validateFunction(node, expression, issues, options); break; case 'BinaryExpression': this._validateBinaryExpression(node, expression, issues); break; case 'UnaryExpression': this._validateUnaryExpression(node, expression, issues); break; } switch (node.type) { case 'BinaryExpression': const binaryNode = node; this._validateASTNode(binaryNode.left, expression, issues, options, depth + 1); this._validateASTNode(binaryNode.right, expression, issues, options, depth + 1); break; case 'UnaryExpression': const unaryNode = node; this._validateASTNode(unaryNode.right, expression, issues, options, depth + 1); break; case 'ConditionalExpression': const conditionalNode = node; 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; this._validateASTNode(filterNode.subject, expression, issues, options, depth + 1); this._validateASTNode(filterNode.expr, expression, issues, options, depth + 1); break; case 'Identifier': const identifierNode = node; if (identifierNode.from) { this._validateASTNode(identifierNode.from, expression, issues, options, depth + 1); } break; case 'FunctionCall': const functionNode = node; if (functionNode.args) { functionNode.args.forEach((arg) => { this._validateASTNode(arg, expression, issues, options, depth + 1); }); } break; case 'ArrayLiteral': const arrayNode = node; arrayNode.value.forEach((item) => { this._validateASTNode(item, expression, issues, options, depth + 1); }); break; case 'ObjectLiteral': const objectNode = node; Object.values(objectNode.value).forEach((value) => { this._validateASTNode(value, expression, issues, options, depth + 1); }); break; } } _validateFunction(node, _expression, issues, options) { 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', }); } } } _validateBinaryExpression(node, _expression, issues) { 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', }); } } _validateUnaryExpression(node, _expression, issues) { 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', }); } } _validateContext(ast, context, expression, issues, options) { this._validateContextPath(ast, context, context, expression, issues, options); } _validateContextPath(node, localContext, globalContext, expression, issues, options) { if (!node) return undefined; switch (node.type) { case 'Identifier': const idNode = node; if (idNode.from) { const parentContext = this._validateContextPath(idNode.from, localContext, globalContext, expression, issues, options); if (parentContext === undefined) { 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[idNode.value]; } if (idNode.relative) { if (!idNode.value) { return localContext; } 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[idNode.value]; } if (typeof globalContext === 'object' && globalContext !== null && idNode.value in globalContext) { return globalContext[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; return arrayNode.value.map((item) => this._validateContextPath(item, localContext, globalContext, expression, issues, options)); case 'ObjectLiteral': const objectNode = node; const newObj = {}; 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; this._validateContextPath(binaryNode.left, localContext, globalContext, expression, issues, options); this._validateContextPath(binaryNode.right, localContext, globalContext, expression, issues, options); return undefined; case 'UnaryExpression': const unaryNode = node; this._validateContextPath(unaryNode.right, localContext, globalContext, expression, issues, options); return undefined; case 'ConditionalExpression': const conditionalNode = node; 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; case 'FilterExpression': const filterNode = node; 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; } const relativeContext = subjectContext.length > 0 ? subjectContext[0] : {}; this._validateContextPath(filterNode.expr, relativeContext, globalContext, expression, issues, options); } else { this._validateContextPath(filterNode.expr, localContext, globalContext, expression, issues, options); } return undefined; case 'FunctionCall': const functionNode = node; if (functionNode.args) { functionNode.args.forEach((arg) => this._validateContextPath(arg, localContext, globalContext, expression, issues, options)); } return undefined; } return undefined; } _performWarningAnalysis(ast, _expression, issues) { 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', }); } } _performInfoAnalysis(ast, _expression, issues) { const nodeCount = this._countNodes(ast); issues.push({ severity: ValidationSeverity.INFO, message: `Expression contains ${nodeCount} nodes`, code: 'EXPRESSION_STATS', }); } _getDefaultOptions(options) { return { allowUndefinedContext: true, includeInfo: false, includeWarnings: true, maxDepth: 20, maxLength: 1000, customFunctions: [], customTransforms: [], ...options, }; } _createResult(issues, ast) { 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, }; } _hasErrors(issues) { return issues.some((issue) => issue.severity === ValidationSeverity.ERROR); } _findTokenPosition(expression, tokenRaw) { return expression.indexOf(tokenRaw.trim()); } _getLineColumn(expression, position) { const lines = expression.substring(0, position).split('\n'); return { line: lines.length, column: lines[lines.length - 1].length + 1, }; } _countNodes(node) { if (!node) return 0; let count = 1; switch (node.type) { case 'BinaryExpression': const binaryNode = node; count += this._countNodes(binaryNode.left); count += this._countNodes(binaryNode.right); break; case 'UnaryExpression': const unaryNode = node; count += this._countNodes(unaryNode.right); break; case 'ConditionalExpression': const conditionalNode = node; count += this._countNodes(conditionalNode.test); count += this._countNodes(conditionalNode.consequent); count += this._countNodes(conditionalNode.alternate); break; case 'FilterExpression': const filterNode = node; count += this._countNodes(filterNode.subject); count += this._countNodes(filterNode.expr); break; case 'Identifier': const identifierNode = node; if (identifierNode.from) { count += this._countNodes(identifierNode.from); } break; case 'FunctionCall': const functionNode = node; if (functionNode.args) { count += functionNode.args.reduce((sum, arg) => sum + this._countNodes(arg), 0); } break; case 'ArrayLiteral': const arrayNode = node; count += arrayNode.value.reduce((sum, item) => sum + this._countNodes(item), 0); break; case 'ObjectLiteral': const objectNode = node; count += Object.values(objectNode.value).reduce((sum, value) => sum + this._countNodes(value), 0); break; } return count; } } export default Validator; //# sourceMappingURL=Validator.js.map