UNPKG

@akala/core

Version:
913 lines (813 loc) 31.7 kB
import type { Expressions, ParameterExpression, StrictExpressions, TypedExpression } from './expressions/index.js'; import { UnaryOperator } from './expressions/unary-operator.js'; import { BinaryOperator } from './expressions/binary-operator.js'; import { TernaryOperator } from './expressions/ternary-operator.js'; import { ExpressionType } from './expressions/expression-type.js'; import { Expression } from './expressions/expression.js'; import { NewExpression } from './expressions/new-expression.js'; import { ConstantExpression } from './expressions/constant-expression.js'; import { MemberExpression } from './expressions/member-expression.js'; import { UnaryExpression } from './expressions/unary-expression.js'; import { BinaryExpression } from './expressions/binary-expression.js'; import { TernaryExpression } from './expressions/ternary-expression.js'; import { CallExpression } from './expressions/call-expression.js'; import identity from '../formatters/identity.js'; import negate from '../formatters/negate.js'; import booleanize from '../formatters/booleanize.js'; import type { ExpressionVisitor } from './expressions/visitors/expression-visitor.js'; import type { Formatter, FormatterFactory } from '../formatters/common.js'; import { formatters } from '../formatters/index.js'; import { escapeRegExp } from '../reflect.js'; import { AssignmentOperator } from './expressions/assignment-operator.js'; import { AssignmentExpression } from './expressions/assignment-expression.js'; import ErrorWithStatus, { HttpStatusCode } from '../errorWithStatus.js'; const jsonKeyRegex = /\s*(?:(?:"([^"]+)")|(?:'([^']+)')|(?:([a-zA-Z0-9_$]+)) *):\s*/; export interface Cursor { offset: number; freeze(): Cursor; } export class StringCursor implements Cursor { /** * Gets the length of the string being parsed. */ get length(): number { return this.string.length }; /** * Gets the current character at the cursor position. */ get char(): string { return this.string[this._offset]; }; /** * Gets whether the cursor has reached the end of the file. */ get eof(): boolean { return this._offset >= this.string.length; }; /** * Creates a new StringCursor instance. * @param string - The string to parse. */ constructor(public readonly string: string) { } /** * Gets the current line number based on the cursor position. * @returns The line number. */ public getLineNo(): number { let n = 0; for (let i = 0; i <= this.offset; i++) { if (this.string[i] === '\n') n++; } return n; } public getLine(): string { const nextLine = this.string.indexOf('\n', this.offset); if (nextLine == -1) return this.string.substring(this._offset - this.getColumn()); else return this.string.substring(this._offset - this.getColumn(), nextLine); } /** * Gets the current column position within the current line. * @returns The column number (0-based). */ public getColumn(): number { for (let i = this.offset; i >= 0; i--) { if (this.string[i] == '\n') return this.offset - i; } } /** * Gets a human-readable representation of the current cursor position. * @returns A string in the format "line:column". */ public getReadableOffset(): string { return this.getLineNo() + ':' + this.getColumn(); } private _offset: number = 0; /** * Gets the current cursor offset in the string. */ get offset(): number { return this._offset; }; /** * Sets the cursor offset in the string. * @throws Error if the offset is beyond the string length. */ set offset(value: number) { this._offset = value; if (this._offset > this.string.length) throw new Error('Cursor cannot go beyond the string limit'); }; /** * Creates a copy of the current cursor state. * @returns A new StringCursor with the same position and content. */ public freeze() { const c = new StringCursor(this.string); c._offset = this._offset; return c; } /** * Executes a regular expression at the current cursor position. * @param regex - The regular expression to execute. * @returns The regex match result or null if no match at current position. */ public exec(regex: RegExp) { if (!regex.global) regex = new RegExp(regex, 'g' + regex.flags); regex.lastIndex = this._offset; const result = regex.exec(this.string); if (result) { if (result.index != this._offset) return null; this.offset += result[0].length; } return result; } /** * Reads a specific string at the current cursor position. * @param s - The string to read. * @returns true if the string was found and consumed, false otherwise. */ public read(s: string) { if (this.string.length < this._offset + s.length) return false; for (let i = 0; i < s.length; i++) { if (s.charCodeAt(i) !== this.string.charCodeAt(this._offset + i)) return false; } this._offset += s.length; return true; } /** * Reads a specific string, skipping whitespace before and after. * @param s - The string to read. * @returns true if the string was found and consumed, false otherwise. */ public trimRead(s: string) { this.skipWhitespace(); if (this.read(s)) { this.skipWhitespace(); return true; } return false; } /** * Reads a specific string, skipping whitespace before the string. * @param s - The string to read. * @returns true if the string was found and consumed, false otherwise. */ public trimStartRead(s: string) { this.skipWhitespace(); return this.read(s); } /** * Reads a specific string, skipping whitespace after the string. * @param s - The string to read. * @returns true if the string was found and consumed, false otherwise. */ public trimEndRead(s: string) { if (this.read(s)) { this.skipWhitespace(); return true; } return false; } /** * Skips any whitespace characters at the current cursor position. * @returns The skipped whitespace string or undefined if none found. */ public skipWhitespace() { return this.exec(/\s+/)?.[0]; } } export type ParsedOneOf = ParsedObject | ParsedArray | ParsedString | ParsedBoolean | ParsedNumber; /** * @deprecated Please use ObservableObject.setValue instead which more versatile * Gets the setter function for a given expression and root object. * @param {string} expression - The expression to evaluate. * @param {T} root - The root object. * @returns {{ expression: string, target: T, set: (value: unknown) => void } | null} The setter function or null if not found. */ export function getSetter<T = unknown>(expression: string, root: T): { expression: string, target: T, set: (value: unknown) => void } | null { let target = root; const parts = expression.split('.'); while (parts.length > 1 && typeof (target) != 'undefined') { target = this.eval(parts[0], target); parts.shift(); } if (typeof (target) == 'undefined') return null; return { expression: parts[0], target: target, set: function (value) { target[parts[0]] = value } }; } /** * Parses a binary operator from a string. * @param {string} op - The operator string. * @returns {BinaryOperator} The parsed binary operator. */ function parseBinaryOperator(op: string): BinaryOperator { if (op in BinaryOperator) return op as BinaryOperator; return BinaryOperator.Unknown; } function parseAssignmentOperator(op: string): AssignmentOperator { if (op in AssignmentOperator) return op as AssignmentOperator; return AssignmentOperator.Unknown; } /** * Parses a ternary operator from a string. * @param {string} op - The operator string. * @returns {TernaryOperator} The parsed ternary operator. */ function parseTernaryOperator(op: string): TernaryOperator { switch (op) { case '?': return TernaryOperator.Question; default: return TernaryOperator.Unknown; } } /** * Represents a format expression. * @extends Expression */ export class FormatExpression<TOutput> extends Expression { constructor(public readonly lhs: Expressions | (TypedExpression<unknown>), public readonly formatter: new (...args: unknown[]) => Formatter<TOutput>, public readonly settings: Expressions) { super(); } get type(): ExpressionType.Format { return ExpressionType.Format; } accept(visitor: ExpressionVisitor): TypedExpression<TOutput> { return visitor.visitFormat(this); } } /** * Represents a parsed object. * @extends NewExpression */ export class ParsedObject<T extends object = object> extends NewExpression<T> { constructor(...init: MemberExpression<T, keyof T, T[keyof T]>[]) { super(...init); } } /** * Represents a parsed array. * @extends NewExpression */ export class ParsedArray extends NewExpression<unknown[]> { constructor(...init: MemberExpression<unknown[], number, unknown>[]) { super(...init); this.newType = '[' } } /** * Represents a parsed string. * @extends ConstantExpression */ export class ParsedString extends ConstantExpression<string> { constructor(value: string) { super(value); } public toString(): string { return this.value; } } /** * Represents a parsed number. * @extends ConstantExpression */ export class ParsedNumber extends ConstantExpression<number> { constructor(value: string) { super(Number(value)); } } /** * Represents a parsed boolean. * @extends ConstantExpression */ export class ParsedBoolean extends ConstantExpression<boolean> { constructor(value: string | boolean) { super(Boolean(value)); } } /** * Represents a parser. */ export class Parser { public static readonly parameterLess: Parser = new Parser(); private parameters: Record<string, ParameterExpression<unknown>>; constructor(...parameters: ParameterExpression<unknown>[]) { if (parameters) { this.parameters = {} parameters.forEach(param => { this.parameters[param.name] = param }); } } /** * Parses an expression. * @param {string} expression - The expression to parse. * @param {boolean} [parseFormatter=true] - Whether to parse formatters. * @param {() => void} [reset] - The reset function. * @returns {Expressions} The parsed expression. */ public parse(expression: string, parseFormatter?: boolean, reset?: () => void): Expressions { expression = expression.trim(); const cursor = new StringCursor(expression); const result = this.parseAny(cursor, (typeof parseFormatter !== 'boolean') || parseFormatter, reset); if (cursor.offset < cursor.length) throw new Error(`invalid character ${cursor.char} at ${cursor.offset}`); return result; } /** * Parses any expression. * @param {string} expression - The expression to parse. * @param {boolean} parseFormatter - Whether to parse formatters. * @param {() => void} [reset] - The reset function. * @param {StringCursor} cursor - The cursor tracking the current position. * @returns {Expressions} The parsed expression. */ public parseAny(expression: StringCursor, parseFormatter: boolean, reset?: () => void): Expressions { switch (expression.char) { case '{': return this.parseObject(expression, parseFormatter); case '[': return this.parseArray(expression, parseFormatter, reset); case '"': case "'": return this.parseString(expression, expression.char, parseFormatter); case '0': case '1': case '2': case '3': case '4': case '5': case '6': case '7': case '8': case '9': case '.': return this.parseNumber(expression, parseFormatter); default: return this.parseEval(expression, parseFormatter, reset); } } /** * Parses a number expression. * @param {string} expression - The expression to parse. * @param {boolean} parseFormatter - Whether to parse formatters. * @param {StringCursor} cursor - The cursor tracking the current position. * @returns {Expressions} The parsed number expression. */ public parseNumber(expression: StringCursor, parseFormatter: boolean) { const match = expression.exec(/\d+(?:\.\d+)?/); if (!match) throw new Error('Invalid number at position ' + expression.offset); const result = new ParsedNumber(match[0]); return this.tryParseOperator(expression, result, parseFormatter); } /** * Parses a boolean expression. * @param {string} expression - The expression to parse. * @returns {ParsedBoolean} The parsed boolean expression. */ public parseBoolean(expression: StringCursor): ParsedBoolean | FormatExpression<boolean> { let formatter = identity as FormatterFactory<boolean> & { instance: Formatter<boolean> }; if (expression.char == '!') { formatter = negate; expression.offset++; } if (expression.char == '!') { formatter = booleanize; expression.offset++; } const boolMatch = expression.exec(/(?:true|false|undefined|null)/); if (boolMatch) { const result = new ParsedBoolean(boolMatch[0]); if (formatter !== identity) { return this.tryParseOperator(expression, new ParsedBoolean(formatter.instance.format(result.value)), true); } return this.tryParseOperator(expression, result, true); } else if (formatter !== identity) { return new FormatExpression(this.parseAny(expression, true), formatter, null); } return null; } /** * Parses an evaluation expression. * @param {string} expression - The expression to parse. * @param {boolean} parseFormatter - Whether to parse formatters. * @param {() => void} [reset] - The reset function. * @param {StringCursor} cursor - The cursor tracking the current position. * @returns {Expressions} The parsed evaluation expression. */ public parseEval(expression: StringCursor, parseFormatter: boolean, reset?: () => void) { const b = this.parseBoolean(expression); if (b) return b; return this.parseFunction(expression, parseFormatter, reset); } /** * Parses a function expression. * @param {string} expression - The expression to parse. * @param {boolean} parseFormatter - Whether to parse formatters. * @param {() => void} [reset] - The reset function. * @returns {Expressions} The parsed function expression. */ public parseFunction(expression: StringCursor, parseFormatter: boolean, reset?: () => void): BinaryExpression { let operator: UnaryOperator; while (expression.char === '!') { if (expression.char === '!') { operator = UnaryOperator.Not; expression.offset++; } if (expression[0] === '!') { operator = UnaryOperator.NotNot; expression.offset++; } } let item = expression.exec(/[\w$]*\??/)[0]; const optional = item.endsWith('?') if (optional) item = item.substring(0, item.length - 1); if (item) { let result: Expressions; if (this.parameters) // eslint-disable-next-line @typescript-eslint/no-explicit-any result = new MemberExpression(this.parameters[''] as TypedExpression<any>, new ParsedString(item), optional) as TypedExpression<any> else result = new MemberExpression(null, new ParsedString(item), optional) if (typeof operator != 'undefined') { result = new UnaryExpression(result, operator); } return this.tryParseOperator(expression, result, parseFormatter, reset); } return this.tryParseOperator(expression, null, parseFormatter, reset); } /** * Parses a formatter expression. * @param {string} expression - The expression to parse. * @param {Expressions} lhs - The left-hand side expression. * @param {() => void} reset - The reset function. * @returns {Expressions} The parsed formatter expression. */ public parseFormatter(expression: StringCursor, lhs: Expressions, reset: () => void): Expressions { const item = expression.exec(/\s*([\w.$]+)\s*/); reset?.(); let settings: Expressions; if (expression.char === ':') { expression.offset++; settings = this.parseAny(expression, false); } const result = new FormatExpression(lhs, formatters.resolve<FormatterFactory<unknown>>(item[1]), settings); return this.tryParseOperator(expression, result, true, reset); } /** * Tries to parse an operator expression. * @param {string} expression - The expression to parse. * @param {Expressions} lhs - The left-hand side expression. * @param {boolean} parseFormatter - Whether to parse formatters. * @param {() => void} [reset] - The reset function. * @param {StringCursor} cursor - The cursor tracking the current position. * @returns {Expressions} The parsed operator expression. */ public tryParseOperator(expression: StringCursor, lhs: Expressions, parseFormatter: boolean, reset?: () => void) { const operator = expression.exec(/\s*([<>=!+\-/*&|?.#[(]+)\s*/); if (operator) { let rhs: Expressions; const oldParameters = this.parameters; let binaryOperator = parseBinaryOperator(operator[1]); let assignmentOperator = parseAssignmentOperator(operator[1]); const ternaryOperator = parseTernaryOperator(operator[1]); let group = 0; switch (operator[1]) { case '#': if (!parseFormatter) { expression.offset -= operator[0].length; return lhs; } return this.parseFormatter(expression, lhs, reset); case '(': case '.(': reset?.(); if (lhs) { expression.offset--; return this.parseFunctionCall(expression, lhs, parseFormatter); } else { lhs = this.parseAny(expression, parseFormatter, reset); expression.offset++; return this.tryParseOperator(expression, lhs, parseFormatter, reset); } case '[': { rhs = this.parseAny(expression, parseFormatter, reset); // eslint-disable-next-line @typescript-eslint/no-explicit-any const member = new MemberExpression(lhs as TypedExpression<any>, rhs as TypedExpression<any>, false); expression.offset++; // Skip closing bracket return this.tryParseOperator(expression, member, parseFormatter, reset); } case '?.': case '.': { this.parameters = { ...this.parameters, '': lhs as ParameterExpression<unknown> }; const selfReset = (() => { this.parameters = oldParameters }); rhs = this.parseAny(expression, parseFormatter, reset || selfReset); selfReset(); return rhs; } case '?': { const second = this.parseAny(expression, parseFormatter, reset); const operator2 = expression.exec(/\s*(:)\s*/); if (!operator2) throw new Error('Invalid ternary operator'); const third = this.parseAny(expression, parseFormatter, reset); const ternary = new TernaryExpression(lhs, ternaryOperator, second, third); return ternary; } default: { reset?.(); if (ternaryOperator == TernaryOperator.Unknown && assignmentOperator == AssignmentOperator.Unknown && binaryOperator == BinaryOperator.Unknown) { while (operator[1][operator[1].length - 1] == '(') { group++; operator[1] = operator[1].substring(0, operator[1].length - 1); } binaryOperator = parseBinaryOperator(operator[1]); assignmentOperator = parseAssignmentOperator(operator[1]); if (assignmentOperator == AssignmentOperator.Unknown && binaryOperator == BinaryOperator.Unknown) { throw new ErrorWithStatus(HttpStatusCode.BadRequest, `Invalid expression at offset ${expression.offset} (${expression.char})`) } } rhs = this.parseAny(expression, parseFormatter); expression.offset += group; if (binaryOperator !== BinaryOperator.Unknown) { const binary = new BinaryExpression(lhs, binaryOperator, rhs); if (group == 0) return BinaryExpression.applyPrecedence(binary); return this.tryParseOperator(expression, binary, parseFormatter, reset); } if (assignmentOperator !== AssignmentOperator.Unknown) return new AssignmentExpression(lhs, assignmentOperator, rhs); } } } return lhs; } /** * Parses a function call expression. * @param {string} expression - The expression to parse. * @param {Expressions} lhs - The left-hand side expression. * @param {boolean} parseFormatter - Whether to parse formatters. * @param {StringCursor} cursor - The cursor tracking the current position. * @returns {Expressions} The parsed function call expression. */ parseFunctionCall(expression: StringCursor, lhs: Expressions, parseFormatter: boolean) { const results: (StrictExpressions)[] = []; const optional = expression.offset > 1 && expression.string[expression.offset - 2] == '?'; this.parseCSV(expression, () => { const item = this.parseAny(expression, parseFormatter); results.push(item as StrictExpressions); return item; }, ')'); if (lhs?.type == ExpressionType.MemberExpression) return this.tryParseOperator( expression, new CallExpression( // eslint-disable-next-line @typescript-eslint/no-explicit-any lhs.source as Expressions & TypedExpression<any>, lhs.member, results, optional ), parseFormatter ); return this.tryParseOperator( expression, new CallExpression( lhs as Expressions & TypedExpression<unknown>, null, results, optional ), parseFormatter, ); } /** * Parses an array expression. * @param {string} expression - The expression to parse. * @param {boolean} parseFormatter - Whether to parse formatters. * @param {StringCursor} cursor - The cursor tracking the current position. * @param {() => void} [reset] - The reset function. * @returns {Expressions} The parsed array expression. */ public parseArray(expression: StringCursor, parseFormatter: boolean, reset?: () => void) { const results: Expressions[] = []; this.parseCSV(expression, () => { const item = this.parseAny(expression, true); results.push(item); return item; }, ']'); //eslint-disable-next-line @typescript-eslint/no-explicit-any return this.tryParseOperator(expression, new ParsedArray(...results.map((v, i) => new MemberExpression<any, number, any>(v as TypedExpression<any>, new ParsedNumber(i.toString()), false))), parseFormatter, reset); } /** * Parses a string expression. * @param {string} expression - The expression to parse. * @param {string} start - The starting character of the string. * @param {boolean} parseFormatter - Whether to parse formatters. * @param {StringCursor} cursor - The cursor tracking the current position. * @returns {Expressions} The parsed string expression. */ public parseString(expression: StringCursor, start: '"' | "'", parseFormatter: boolean) { start = escapeRegExp(start) as '"' | "'"; const evaluatedRegex = expression.exec(new RegExp(start + "((?:[^\\" + start + "]|\\.)*)" + start)); if (!evaluatedRegex) throw new Error('Invalid string at position ' + expression.offset); const result = evaluatedRegex[1]; const parsedString = new ParsedString(result); return this.tryParseOperator(expression, parsedString, parseFormatter); } /** * Operates on two values using a binary operator. * @param {BinaryOperator} operator - The binary operator. * @param {unknown} [left] - The left-hand side value. * @param {unknown} [right] - The right-hand side value. * @returns {unknown} The result of the operation. */ public static operate(operator: BinaryOperator, left?: unknown, right?: unknown) { switch (operator) { case BinaryOperator.Equal: return left == right; case BinaryOperator.StrictEqual: return left === right; case BinaryOperator.LessThan: return left < right; case BinaryOperator.LessThanOrEqual: return left <= right; case BinaryOperator.GreaterThan: return left > right; case BinaryOperator.GreaterThanOrEqual: return left >= right; case BinaryOperator.NotEqual: return left != right; case BinaryOperator.StrictNotEqual: return left !== right; case BinaryOperator.Plus: return (left as number) + (right as number); case BinaryOperator.Minus: return (left as number) - (right as number); case BinaryOperator.Div: return (left as number) / (right as number); case BinaryOperator.Times: return (left as number) * (right as number); case BinaryOperator.Or: return left || right; case BinaryOperator.And: return left && right; case BinaryOperator.Dot: if (right instanceof Function) return right(left); return left[right as keyof typeof left]; default: throw new Error('invalid operator' + operator); } } /** * Parses a CSV expression. * @param {string} expression - The expression to parse. * @param {(expression: string) => Expressions} parseItem - The function to parse each item. * @param {string} end - The ending character of the CSV. * @param {StringCursor} cursor - The cursor tracking the current position. * @returns {number} The length of the parsed CSV expression. */ public parseCSV(expression: StringCursor, parseItem: () => Expressions, end: string): number { const startOffset = expression.offset; expression.offset++; // Skip opening character for (; expression.offset < expression.length && expression.char !== end; expression.offset++) { // Skip whitespace while ((expression.char === ' ' || expression.char == '\n' || expression.char == '\t') && expression.offset < expression.length) expression.offset++; if (expression.offset >= expression.length) break; if (expression.char === end) { expression.offset++; break; } parseItem(); // Remove unused assignment // Skip whitespace while (expression.char === ' ' || expression.char == '\n' || expression.char == '\t') expression.offset++; // Check for comma if (expression.char === ',') continue; // If no comma, must be end character if (expression.char === end) break; throw new Error(`Expected comma or ${end} at position ${expression.offset}, but found ${expression.char}`); } expression.offset++; return expression.offset - startOffset; } /** * Parses an object expression. * @param {string} expression - The expression to parse. * @param {boolean} parseFormatter - Whether to parse formatters. * @param {StringCursor} cursor - The cursor tracking the current position. * @returns {Expressions} The parsed object expression. */ public parseObject(expression: StringCursor, parseFormatter: boolean) { const parsedObject: { key: string, value: Expressions }[] = []; this.parseCSV(expression, () => { const keyMatch = expression.exec(jsonKeyRegex); const key = keyMatch[1] || keyMatch[2] || keyMatch[3]; const item = this.parseAny(expression, true); parsedObject.push({ key, value: item }); // console.log(expression); //console.log(length); return item; }, '}'); //eslint-disable-next-line @typescript-eslint/no-explicit-any return this.tryParseOperator(expression, new ParsedObject(...parsedObject.map(v => new MemberExpression<any, string, any>(v.value as TypedExpression<any>, new ParsedString(v.key), false))), parseFormatter) } }