diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000000..4e25a2dbae --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,19 @@ +{ + "extends": "eslint:recommended", + "env": { + "browser": false, + "node": true, + "es2020": true, + "mocha": true + }, + "parserOptions": { + "ecmaVersion": 2022, + "sourceType": "module" + }, + "rules": { + "no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }], + "no-prototype-builtins": "off", + "no-undef": "warn", + "no-constant-condition": ["error", { "checkLoops": false }] + } +} diff --git a/src/expression/error.js b/src/expression/error.js new file mode 100644 index 0000000000..88aa8c2d88 --- /dev/null +++ b/src/expression/error.js @@ -0,0 +1,40 @@ +/** + * Shortcut for getting the current col value (one based) + * Returns the column (position) where the last state.token starts + * @param {Object} state + * @return {number} + * @private + */ +export function col (state) { + return state.index - state.token.length + 1 +} + +/** + * Create a syntax error with the message: + * 'Syntax error in part "" (char )' + * @param {Object} state + * @param {string} message + * @return {SyntaxError} instantiated error + * @private + */ +export function createSyntaxError (state, message) { + const c = col(state) + const error = new SyntaxError(message + ' (char ' + c + ')') + error.char = c + return error +} + +/** + * Create an error with the message: + * ' (char )' + * @param {Object} state + * @param {string} message + * @return {Error} instantiated error + * @private + */ +export function createError (state, message) { + const c = col(state) + const error = new Error(message + ' (char ' + c + ')') // Changed to regular Error as per original type, can be SyntaxError if preferred + error.char = c + return error +} diff --git a/src/expression/lexer.js b/src/expression/lexer.js new file mode 100644 index 0000000000..14fb0509a6 --- /dev/null +++ b/src/expression/lexer.js @@ -0,0 +1,461 @@ +// token types enumeration +export const TOKENTYPE = { + NULL: 0, + DELIMITER: 1, + NUMBER: 2, + SYMBOL: 3, + UNKNOWN: 4 +} + +// map with all delimiters +export const DELIMITERS = { + ',': true, + '(': true, + ')': true, + '[': true, + ']': true, + '{': true, + '}': true, + '"': true, + "'": true, + ';': true, + + '+': true, + '-': true, + '*': true, + '.*': true, + '/': true, + './': true, + '%': true, + '^': true, + '.^': true, + '~': true, + '!': true, + '&': true, + '|': true, + '^|': true, + '=': true, + ':': true, + '?': true, + + '==': true, + '!=': true, + '<': true, + '>': true, + '<=': true, + '>=': true, + + '<<': true, + '>>': true, + '>>>': true +} + +// map with all named delimiters +export const NAMED_DELIMITERS = { + mod: true, + to: true, + in: true, + and: true, + xor: true, + or: true, + not: true +} + +export const CONSTANTS = { + true: true, + false: false, + null: null, + undefined +} + +export const NUMERIC_CONSTANTS = [ + 'NaN', + 'Infinity' +] + +export const ESCAPE_CHARACTERS = { + '"': '"', + "'": "'", + '\\': '\\', + '/': '/', + b: '\b', + f: '\f', + n: '\n', + r: '\r', + t: '\t' + // note that \u is handled separately in parseStringToken() +} + +/** + * View upto `length` characters of the expression starting at the current character. + * + * @param {Object} state + * @param {number} [length=1] Number of characters to view + * @returns {string} + * @private + */ +export function currentString (state, length) { + return state.expression.substr(state.index, length) +} + +/** + * View the current character. Returns '' if end of expression is reached. + * + * @param {Object} state + * @returns {string} + * @private + */ +export function currentCharacter (state) { + return currentString(state, 1) +} + +/** + * Get the next character from the expression. + * The character is stored into the char c. If the end of the expression is + * reached, the function puts an empty string in c. + * @private + */ +export function next (state) { + state.index++ +} + +/** + * Preview the previous character from the expression. + * @return {string} cNext + * @private + */ +export function prevCharacter (state) { + return state.expression.charAt(state.index - 1) +} + +/** + * Preview the next character from the expression. + * @return {string} cNext + * @private + */ +export function nextCharacter (state) { + return state.expression.charAt(state.index + 1) +} + +/** + * Get next token in the current string expr. + * The token and token type are available as token and tokenType + * @private + */ +export function getToken (state) { + state.tokenType = TOKENTYPE.NULL + state.token = '' + state.comment = '' + + // Skip comments and whitespace + while (true) { + const char = currentCharacter(state) + if (isWhitespace(char, state.nestingLevel)) { + next(state) + continue + } + if (char === '#') { + let currentLineComment = '' + while (currentCharacter(state) !== '\n' && currentCharacter(state) !== '') { + currentLineComment += currentCharacter(state) + next(state) + } + state.comment = currentLineComment // Store the last encountered comment line + + // If comment ended with a newline and we are not nested, consume it as part of comment skipping + if (currentCharacter(state) === '\n' && state.nestingLevel === 0) { + next(state) + } + continue // Restart loop to check for more comments/whitespace + } + break // No whitespace, no comment, proceed to tokenization + } + + // check for end of expression + if (currentCharacter(state) === '') { + // token is still empty + state.tokenType = TOKENTYPE.DELIMITER + return + } + + // check for new line character + if (currentCharacter(state) === '\n' && !state.nestingLevel) { + state.tokenType = TOKENTYPE.DELIMITER + state.token = currentCharacter(state) + next(state) + return + } + + const c1 = currentCharacter(state) + const c2 = currentString(state, 2) + const c3 = currentString(state, 3) + if (c3.length === 3 && DELIMITERS[c3]) { + state.tokenType = TOKENTYPE.DELIMITER + state.token = c3 + next(state) + next(state) + next(state) + return + } + + // check for delimiters consisting of 2 characters + if (c2.length === 2 && DELIMITERS[c2]) { + state.tokenType = TOKENTYPE.DELIMITER + state.token = c2 + next(state) + next(state) + return + } + + // check for delimiters consisting of 1 character + if (DELIMITERS[c1]) { + state.tokenType = TOKENTYPE.DELIMITER + state.token = c1 + next(state) + return + } + + // check for a number + if (isDigitDot(c1)) { + state.tokenType = TOKENTYPE.NUMBER + + // check for binary, octal, or hex + const c2 = currentString(state, 2) + if (c2 === '0b' || c2 === '0o' || c2 === '0x') { + state.token += currentCharacter(state) + next(state) + state.token += currentCharacter(state) + next(state) + while (isHexDigit(currentCharacter(state))) { + state.token += currentCharacter(state) + next(state) + } + if (currentCharacter(state) === '.') { + // this number has a radix point + state.token += '.' + next(state) + // get the digits after the radix + while (isHexDigit(currentCharacter(state))) { + state.token += currentCharacter(state) + next(state) + } + } else if (currentCharacter(state) === 'i') { + // this number has a word size suffix + state.token += 'i' + next(state) + // get the word size + while (isDigit(currentCharacter(state))) { + state.token += currentCharacter(state) + next(state) + } + } + return + } + + // get number, can have a single dot + if (currentCharacter(state) === '.') { + state.token += currentCharacter(state) + next(state) + + if (!isDigit(currentCharacter(state))) { + // this is no number, it is just a dot (can be dot notation) + state.tokenType = TOKENTYPE.DELIMITER + return + } + } else { + while (isDigit(currentCharacter(state))) { + state.token += currentCharacter(state) + next(state) + } + if (isDecimalMark(currentCharacter(state), nextCharacter(state))) { + state.token += currentCharacter(state) + next(state) + } + } + + while (isDigit(currentCharacter(state))) { + state.token += currentCharacter(state) + next(state) + } + // check for exponential notation like "2.3e-4", "1.23e50" or "2e+4" + if (currentCharacter(state) === 'E' || currentCharacter(state) === 'e') { + if (isDigit(nextCharacter(state)) || nextCharacter(state) === '-' || nextCharacter(state) === '+') { + state.token += currentCharacter(state) + next(state) + + if (currentCharacter(state) === '+' || currentCharacter(state) === '-') { + state.token += currentCharacter(state) + next(state) + } + // Scientific notation MUST be followed by an exponent + if (!isDigit(currentCharacter(state))) { + throw new Error('Digit expected, got "' + currentCharacter(state) + '"') + } + + while (isDigit(currentCharacter(state))) { + state.token += currentCharacter(state) + next(state) + } + + if (isDecimalMark(currentCharacter(state), nextCharacter(state))) { + throw new Error('Digit expected, got "' + currentCharacter(state) + '"') + } + } else if (nextCharacter(state) === '.') { + // like '1.2e.' + next(state) // consume 'e' + // currentCharacter(state) is now '.' + throw new Error('Digit expected, got "' + currentCharacter(state) + '"') + } else { + // like '1.2e' or '1.2eA' + // The 'e' or 'E' is part of the number, but it's not valid scientific notation + throw new Error('Digit expected after exponent, got "' + nextCharacter(state) + '"') + } + } + + return + } + + // check for variables, functions, named operators + if (isAlpha(currentCharacter(state), prevCharacter(state), nextCharacter(state))) { + while (isAlpha(currentCharacter(state), prevCharacter(state), nextCharacter(state)) || isDigit(currentCharacter(state))) { + state.token += currentCharacter(state) + next(state) + } + + if (NAMED_DELIMITERS[state.token]) { // Check against NAMED_DELIMITERS directly + state.tokenType = TOKENTYPE.DELIMITER + } else { + state.tokenType = TOKENTYPE.SYMBOL + } + + return + } + + // something unknown is found, wrong characters -> a syntax error + state.tokenType = TOKENTYPE.UNKNOWN + while (currentCharacter(state) !== '') { + state.token += currentCharacter(state) + next(state) + } + throw new Error('Syntax error in part "' + state.token + '"') +} + +/** + * Get next token and skip newline tokens + */ +export function getTokenSkipNewline (state) { + do { + getToken(state) + } + while (state.token === '\n') // eslint-disable-line no-unmodified-loop-condition +} + +/** + * Checks whether the current character `c` is a valid alpha character: + * + * - A latin letter (upper or lower case) Ascii: a-z, A-Z + * - An underscore Ascii: _ + * - A dollar sign Ascii: $ + * - A latin letter with accents Unicode: \u00C0 - \u02AF + * - A greek letter Unicode: \u0370 - \u03FF + * - A mathematical alphanumeric symbol Unicode: \u{1D400} - \u{1D7FF} excluding invalid code points + * + * The previous and next characters are needed to determine whether + * this character is part of a unicode surrogate pair. + * + * @param {string} c Current character in the expression + * @param {string} cPrev Previous character + * @param {string} cNext Next character + * @return {boolean} + */ +export function isAlpha (c, cPrev, cNext) { + return isValidLatinOrGreek(c) || + isValidMathSymbol(c, cNext) || + isValidMathSymbol(cPrev, c) +} + +/** + * Test whether a character is a valid latin, greek, or letter-like character + * @param {string} c + * @return {boolean} + */ +export function isValidLatinOrGreek (c) { + return /^[a-zA-Z_$\u00C0-\u02AF\u0370-\u03FF\u2100-\u214F]$/.test(c) +} + +/** + * Test whether two given 16 bit characters form a surrogate pair of a + * unicode math symbol. + * + * https://unicode-table.com/en/ + * https://www.wikiwand.com/en/Mathematical_operators_and_symbols_in_Unicode + * + * Note: In ES6 will be unicode aware: + * https://stackoverflow.com/questions/280712/javascript-unicode-regexes + * https://mathiasbynens.be/notes/es6-unicode-regex + * + * @param {string} high + * @param {string} low + * @return {boolean} + */ +export function isValidMathSymbol (high, low) { + return /^[\uD835]$/.test(high) && + /^[\uDC00-\uDFFF]$/.test(low) && + /^[^\uDC55\uDC9D\uDCA0\uDCA1\uDCA3\uDCA4\uDCA7\uDCA8\uDCAD\uDCBA\uDCBC\uDCC4\uDD06\uDD0B\uDD0C\uDD15\uDD1D\uDD3A\uDD3F\uDD45\uDD47-\uDD49\uDD51\uDEA6\uDEA7\uDFCC\uDFCD]$/.test(low) +} + +/** + * Check whether given character c is a white space character: space, tab, or enter + * @param {string} c + * @param {number} nestingLevel + * @return {boolean} + */ +export function isWhitespace (c, nestingLevel) { + // TODO: also take '\r' carriage return as newline? Or does that give problems on mac? + return c === ' ' || c === '\t' || (c === '\n' && nestingLevel > 0) +} + +/** + * Test whether the character c is a decimal mark (dot). + * This is the case when it's not the start of a delimiter '.*', './', or '.^' + * @param {string} c + * @param {string} cNext + * @return {boolean} + */ +export function isDecimalMark (c, cNext) { + return c === '.' && cNext !== '/' && cNext !== '*' && cNext !== '^' +} + +/** + * checks if the given char c is a digit or dot + * @param {string} c a string with one character + * @return {boolean} + */ +export function isDigitDot (c) { + return ((c >= '0' && c <= '9') || c === '.') +} + +/** + * checks if the given char c is a digit + * @param {string} c a string with one character + * @return {boolean} + */ +export function isDigit (c) { + return (c >= '0' && c <= '9') +} + +/** + * checks if the given char c is a hex digit + * @param {string} c a string with one character + * @return {boolean} + */ +export function isHexDigit (c) { + return ((c >= '0' && c <= '9') || + (c >= 'a' && c <= 'f') || + (c >= 'A' && c <= 'F')) +} + +// The createSyntaxError function is not part of this file yet. +// The createSyntaxError function has been moved to error.js +// It will be imported from there in a later refactoring step +// when parse.js is updated to use the new module structure. diff --git a/src/expression/node/ConstantNode.js b/src/expression/node/ConstantNode.js index 7ab4443a22..706b5ec35e 100644 --- a/src/expression/node/ConstantNode.js +++ b/src/expression/node/ConstantNode.js @@ -44,7 +44,7 @@ export const createConstantNode = /* #__PURE__ */ factory(name, dependencies, ({ * @return {function} Returns a function which can be called like: * evalNode(scope: Object, args: Object, context: *) */ - _compile (math, argNames) { + _compile (_math, _argNames) { const value = this.value return function evalConstantNode () { @@ -56,7 +56,7 @@ export const createConstantNode = /* #__PURE__ */ factory(name, dependencies, ({ * Execute a callback for each of the child nodes of this node * @param {function(child: Node, path: string, parent: Node)} callback */ - forEach (callback) { + forEach (_callback) { // nothing to do, we don't have any children } @@ -66,7 +66,7 @@ export const createConstantNode = /* #__PURE__ */ factory(name, dependencies, ({ * @param {function(child: Node, path: string, parent: Node) : Node} callback * @returns {ConstantNode} Returns a clone of the node */ - map (callback) { + map (_callback) { return this.clone() } diff --git a/src/expression/node/IndexNode.js b/src/expression/node/IndexNode.js index 8cd0fa2d93..d5ad03dcff 100644 --- a/src/expression/node/IndexNode.js +++ b/src/expression/node/IndexNode.js @@ -168,7 +168,7 @@ export const createIndexNode = /* #__PURE__ */ factory(name, dependencies, ({ No * @param {Object} options * @return {string} str */ - _toString (options) { + _toString (_options) { // format the parameters like "[1, 0:5]" return this.dotNotation ? ('.' + this.getObjectProperty()) @@ -204,7 +204,7 @@ export const createIndexNode = /* #__PURE__ */ factory(name, dependencies, ({ No * @param {Object} options * @return {string} str */ - _toHTML (options) { + _toHTML (_options) { // format the parameters like "[1, 0:5]" const dimensions = [] for (let i = 0; i < this.dimensions.length; i++) { diff --git a/src/expression/node/Node.js b/src/expression/node/Node.js index 06c67abc3b..28ca31f935 100644 --- a/src/expression/node/Node.js +++ b/src/expression/node/Node.js @@ -72,7 +72,7 @@ export const createNode = /* #__PURE__ */ factory(name, dependencies, ({ mathWit * @return {function} Returns a function which can be called like: * evalNode(scope: Object, args: Object, context: *) */ - _compile (math, argNames) { + _compile (_math, _argNames) { throw new Error('Method _compile must be implemented by type ' + this.type) } @@ -80,7 +80,7 @@ export const createNode = /* #__PURE__ */ factory(name, dependencies, ({ mathWit * Execute a callback for each of the child nodes of this node * @param {function(child: Node, path: string, parent: Node)} callback */ - forEach (callback) { + forEach (_callback) { // must be implemented by each of the Node implementations throw new Error('Cannot run forEach on a Node interface') } @@ -91,7 +91,7 @@ export const createNode = /* #__PURE__ */ factory(name, dependencies, ({ mathWit * @param {function(child: Node, path: string, parent: Node): Node} callback * @returns {OperatorNode} Returns a transformed copy of the node */ - map (callback) { + map (_callback) { // must be implemented by each of the Node implementations throw new Error('Cannot run map on a Node interface') } @@ -341,7 +341,7 @@ export const createNode = /* #__PURE__ */ factory(name, dependencies, ({ mathWit * @param {Object} [options] * @throws {Error} */ - _toTex (options) { + _toTex (_options) { // must be implemented by each of the Node implementations throw new Error('_toTex not implemented for ' + this.type) } diff --git a/src/expression/node/RelationalNode.js b/src/expression/node/RelationalNode.js index 5663ad71f6..c6fb664152 100644 --- a/src/expression/node/RelationalNode.js +++ b/src/expression/node/RelationalNode.js @@ -123,7 +123,9 @@ export const createRelationalNode = /* #__PURE__ */ factory(name, dependencies, const precedence = getPrecedence(this, parenthesis, options && options.implicit) - const paramStrings = this.params.map(function (p, index) { + const paramStrings = this.params.map(function (p, _index) { + const paramStrings = this.params.map(function (p, _index) { + const paramStrings = this.params.map(function (p, _index) { const paramPrecedence = getPrecedence(p, parenthesis, options && options.implicit) return (parenthesis === 'all' || diff --git a/src/expression/node/SymbolNode.js b/src/expression/node/SymbolNode.js index 36391c751e..5ed6dadced 100644 --- a/src/expression/node/SymbolNode.js +++ b/src/expression/node/SymbolNode.js @@ -61,11 +61,11 @@ export const createSymbolNode = /* #__PURE__ */ factory(name, dependencies, ({ m // this is a FunctionAssignment argument // (like an x when inside the expression of a function // assignment `f(x) = ...`) - return function (scope, args, context) { + return function (scope, args, _context) { return getSafeProperty(args, name) } } else if (name in math) { - return function (scope, args, context) { + return function (scope, _args, _context) { return scope.has(name) ? scope.get(name) : getSafeProperty(math, name) @@ -73,7 +73,7 @@ export const createSymbolNode = /* #__PURE__ */ factory(name, dependencies, ({ m } else { const isUnit = isValuelessUnit(name) - return function (scope, args, context) { + return function (scope, _args, _context) { return scope.has(name) ? scope.get(name) : isUnit @@ -87,7 +87,7 @@ export const createSymbolNode = /* #__PURE__ */ factory(name, dependencies, ({ m * Execute a callback for each of the child nodes of this node * @param {function(child: Node, path: string, parent: Node)} callback */ - forEach (callback) { + forEach (_callback) { // nothing to do, we don't have any children } @@ -97,7 +97,7 @@ export const createSymbolNode = /* #__PURE__ */ factory(name, dependencies, ({ m * @param {function(child: Node, path: string, parent: Node) : Node} callback * @returns {SymbolNode} Returns a clone of the node */ - map (callback) { + map (_callback) { return this.clone() } @@ -123,7 +123,7 @@ export const createSymbolNode = /* #__PURE__ */ factory(name, dependencies, ({ m * @return {string} str * @override */ - _toString (options) { + _toString (_options) { return this.name } @@ -133,7 +133,7 @@ export const createSymbolNode = /* #__PURE__ */ factory(name, dependencies, ({ m * @return {string} str * @override */ - _toHTML (options) { + _toHTML (_options) { const name = escape(this.name) if (name === 'true' || name === 'false') { @@ -184,7 +184,7 @@ export const createSymbolNode = /* #__PURE__ */ factory(name, dependencies, ({ m * @return {string} str * @override */ - _toTex (options) { + _toTex (_options) { let isUnit = false if ((typeof math[this.name] === 'undefined') && isValuelessUnit(this.name)) { diff --git a/src/expression/nodeParsers.js b/src/expression/nodeParsers.js new file mode 100644 index 0000000000..fa7a4fc7b5 --- /dev/null +++ b/src/expression/nodeParsers.js @@ -0,0 +1,1119 @@ +import { TOKENTYPE, NAMED_DELIMITERS, CONSTANTS, NUMERIC_CONSTANTS, ESCAPE_CHARACTERS, getToken, getTokenSkipNewline, currentCharacter, next, currentString } from './lexer.js' +import { initialState, openParams, closeParams } from './parserState.js' +import { createSyntaxError, createError } from './error.js' +import { isAccessorNode, isConstantNode, isFunctionNode, isOperatorNode, isSymbolNode, rule2Node } from '../utils/is.js' +import { hasOwnProperty } from '../utils/object.js' +import { safeNumberType } from '../utils/number.js' + +// These will be injected by the factory function createParse +let numeric, config +let AccessorNode, ArrayNode, AssignmentNode, BlockNode, ConditionalNode, ConstantNode, FunctionAssignmentNode, FunctionNode, IndexNode, ObjectNode, OperatorNode, ParenthesisNode, RangeNode, RelationalNode, SymbolNode + +export function setDependencies (dependencies) { + numeric = dependencies.numeric + config = dependencies.config + AccessorNode = dependencies.AccessorNode + ArrayNode = dependencies.ArrayNode + AssignmentNode = dependencies.AssignmentNode + BlockNode = dependencies.BlockNode + ConditionalNode = dependencies.ConditionalNode + ConstantNode = dependencies.ConstantNode + FunctionAssignmentNode = dependencies.FunctionAssignmentNode + FunctionNode = dependencies.FunctionNode + IndexNode = dependencies.IndexNode + ObjectNode = dependencies.ObjectNode + OperatorNode = dependencies.OperatorNode + ParenthesisNode = dependencies.ParenthesisNode + RangeNode = dependencies.RangeNode + RelationalNode = dependencies.RelationalNode + SymbolNode = dependencies.SymbolNode +} + +/** + * Start of the parse levels below, in order of precedence + * @return {Node} node + * @private + */ +export function parseStart (expression, extraNodes) { + const state = initialState() + Object.assign(state, { expression, extraNodes }) + try { + getToken(state) + } catch (err) { + throw createSyntaxError(state, err.message) + } + + const node = parseBlock(state) + + if (state.token !== '') { + if (state.tokenType === TOKENTYPE.DELIMITER) { + throw createError(state, 'Unexpected operator ' + state.token) + } else { + throw createSyntaxError(state, 'Unexpected part "' + state.token + '"') + } + } + + return node +} + +/** + * Parse a block with expressions. Expressions can be separated by a newline + * character '\n', or by a semicolon ';'. In case of a semicolon, no output + * of the preceding line is returned. + * @return {Node} node + * @private + */ +function parseBlock (state) { + let node + const blocks = [] + let visible + + if (state.token !== '' && state.token !== '\n' && state.token !== ';') { + node = parseAssignment(state) + if (state.comment) { + node.comment = state.comment + } + } + + while (state.token === '\n' || state.token === ';') { + if (blocks.length === 0 && node) { + visible = (state.token !== ';') + blocks.push({ node, visible }) + } + + try { + getToken(state) + } catch (err) { + throw createSyntaxError(state, err.message) + } + if (state.token !== '\n' && state.token !== ';' && state.token !== '') { + node = parseAssignment(state) + if (state.comment) { + node.comment = state.comment + } + + visible = (state.token !== ';') + blocks.push({ node, visible }) + } + } + + if (blocks.length > 0) { + return new BlockNode(blocks) + } else { + if (!node) { + node = new ConstantNode(undefined) + if (state.comment) { + node.comment = state.comment + } + } + return node + } +} + +/** + * Assignment of a function or variable, + * - can be a variable like 'a=2.3' + * - or a updating an existing variable like 'matrix(2,3:5)=[6,7,8]' + * - defining a function like 'f(x) = x^2' + * @return {Node} node + * @private + */ +function parseAssignment (state) { + let name, args, value, valid + const node = parseConditional(state) + + if (state.token === '=') { + if (isSymbolNode(node)) { + name = node.name + try { + getTokenSkipNewline(state) + } catch (err) { + throw createSyntaxError(state, err.message) + } + value = parseAssignment(state) + return new AssignmentNode(new SymbolNode(name), value) + } else if (isAccessorNode(node)) { + try { + getTokenSkipNewline(state) + } catch (err) { + throw createSyntaxError(state, err.message) + } + value = parseAssignment(state) + return new AssignmentNode(node.object, node.index, value) + } else if (isFunctionNode(node) && isSymbolNode(node.fn)) { + valid = true + args = [] + name = node.name + node.args.forEach(function (arg, index) { + if (isSymbolNode(arg)) { + args[index] = arg.name + } else { + valid = false + } + }) + if (valid) { + try { + getTokenSkipNewline(state) + } catch (err) { + throw createSyntaxError(state, err.message) + } + value = parseAssignment(state) + return new FunctionAssignmentNode(name, args, value) + } + } + throw createSyntaxError(state, 'Invalid left hand side of assignment operator =') + } + return node +} + +/** + * conditional operation + * condition ? truePart : falsePart + * Note: conditional operator is right-associative + * @return {Node} node + * @private + */ +function parseConditional (state) { + let node = parseLogicalOr(state) + while (state.token === '?') { + const prev = state.conditionalLevel + state.conditionalLevel = state.nestingLevel + try { + getTokenSkipNewline(state) + } catch (err) { + throw createSyntaxError(state, err.message) + } + const condition = node + const trueExpr = parseAssignment(state) + if (state.token !== ':') throw createSyntaxError(state, 'False part of conditional expression expected') + state.conditionalLevel = null + try { + getTokenSkipNewline(state) + } catch (err) { + throw createSyntaxError(state, err.message) + } + const falseExpr = parseAssignment(state) + node = new ConditionalNode(condition, trueExpr, falseExpr) + state.conditionalLevel = prev + } + return node +} + +/** + * logical or, 'x or y' + * @return {Node} node + * @private + */ +function parseLogicalOr (state) { + let node = parseLogicalXor(state) + while (state.token === 'or') { + try { + getTokenSkipNewline(state) + } catch (err) { + throw createSyntaxError(state, err.message) + } + node = new OperatorNode('or', 'or', [node, parseLogicalXor(state)]) + } + return node +} + +/** + * logical exclusive or, 'x xor y' + * @return {Node} node + * @private + */ +function parseLogicalXor (state) { + let node = parseLogicalAnd(state) + while (state.token === 'xor') { + try { + getTokenSkipNewline(state) + } catch (err) { + throw createSyntaxError(state, err.message) + } + node = new OperatorNode('xor', 'xor', [node, parseLogicalAnd(state)]) + } + return node +} + +/** + * logical and, 'x and y' + * @return {Node} node + * @private + */ +function parseLogicalAnd (state) { + let node = parseBitwiseOr(state) + while (state.token === 'and') { + try { + getTokenSkipNewline(state) + } catch (err) { + throw createSyntaxError(state, err.message) + } + node = new OperatorNode('and', 'and', [node, parseBitwiseOr(state)]) + } + return node +} + +/** + * bitwise or, 'x | y' + * @return {Node} node + * @private + */ +function parseBitwiseOr (state) { + let node = parseBitwiseXor(state) + while (state.token === '|') { + try { + getTokenSkipNewline(state) + } catch (err) { + throw createSyntaxError(state, err.message) + } + node = new OperatorNode('|', 'bitOr', [node, parseBitwiseXor(state)]) + } + return node +} + +/** + * bitwise exclusive or (xor), 'x ^| y' + * @return {Node} node + * @private + */ +function parseBitwiseXor (state) { + let node = parseBitwiseAnd(state) + while (state.token === '^|') { + try { + getTokenSkipNewline(state) + } catch (err) { + throw createSyntaxError(state, err.message) + } + node = new OperatorNode('^|', 'bitXor', [node, parseBitwiseAnd(state)]) + } + return node +} + +/** + * bitwise and, 'x & y' + * @return {Node} node + * @private + */ +function parseBitwiseAnd (state) { + let node = parseRelational(state) + while (state.token === '&') { + try { + getTokenSkipNewline(state) + } catch (err) { + throw createSyntaxError(state, err.message) + } + node = new OperatorNode('&', 'bitAnd', [node, parseRelational(state)]) + } + return node +} + +/** + * Parse a chained conditional, like 'a > b >= c' + * @return {Node} node + */ +function parseRelational (state) { + const params = [parseShift(state)] + const conditionals = [] + const operators = { + '==': 'equal', + '!=': 'unequal', + '<': 'smaller', + '>': 'larger', + '<=': 'smallerEq', + '>=': 'largerEq' + } + while (hasOwnProperty(operators, state.token)) { + const cond = { name: state.token, fn: operators[state.token] } + conditionals.push(cond) + try { + getTokenSkipNewline(state) + } catch (err) { + throw createSyntaxError(state, err.message) + } + params.push(parseShift(state)) + } + if (params.length === 1) { + return params[0] + } else if (params.length === 2) { + return new OperatorNode(conditionals[0].name, conditionals[0].fn, params) + } else { + return new RelationalNode(conditionals.map(c => c.fn), params) + } +} + +/** + * Bitwise left shift, bitwise right arithmetic shift, bitwise right logical shift + * @return {Node} node + * @private + */ +function parseShift (state) { + let node, name, fn, params + node = parseConversion(state) + const operators = { + '<<': 'leftShift', + '>>': 'rightArithShift', + '>>>': 'rightLogShift' + } + while (hasOwnProperty(operators, state.token)) { + name = state.token + fn = operators[name] + try { + getTokenSkipNewline(state) + } catch (err) { + throw createSyntaxError(state, err.message) + } + params = [node, parseConversion(state)] + node = new OperatorNode(name, fn, params) + } + return node +} + +/** + * conversion operators 'to' and 'in' + * @return {Node} node + * @private + */ +function parseConversion (state) { + let node, name, fn, params + node = parseRange(state) + const operators = { + to: 'to', + in: 'to' // alias of 'to' + } + while (hasOwnProperty(operators, state.token)) { + name = state.token + fn = operators[name] + try { + getTokenSkipNewline(state) + } catch (err) { + throw createSyntaxError(state, err.message) + } + if (name === 'in' && state.token === '') { + node = new OperatorNode('*', 'multiply', [node, new SymbolNode('in')], true) + } else { + params = [node, parseRange(state)] + node = new OperatorNode(name, fn, params) + } + } + return node +} + +/** + * parse range, "start:end", "start:step:end", ":", "start:", ":end", etc + * @return {Node} node + * @private + */ +function parseRange (state) { + let node + const params = [] + if (state.token === ':') { + node = new ConstantNode(1) + } else { + node = parseAddSubtract(state) + } + if (state.token === ':' && (state.conditionalLevel !== state.nestingLevel)) { + params.push(node) + while (state.token === ':' && params.length < 3) { + try { + getTokenSkipNewline(state) + } catch (err) { + throw createSyntaxError(state, err.message) + } + if (state.token === ')' || state.token === ']' || state.token === ',' || state.token === '') { + params.push(new SymbolNode('end')) + } else { + params.push(parseAddSubtract(state)) + } + } + if (params.length === 3) { + node = new RangeNode(params[0], params[2], params[1]) + } else { + node = new RangeNode(params[0], params[1]) + } + } + return node +} + +/** + * add or subtract + * @return {Node} node + * @private + */ +function parseAddSubtract (state) { + let node, name, fn, params + node = parseMultiplyDivideModulusPercentage(state) + const operators = { + '+': 'add', + '-': 'subtract' + } + while (hasOwnProperty(operators, state.token)) { + name = state.token + fn = operators[name] + try { + getTokenSkipNewline(state) + } catch (err) { + throw createSyntaxError(state, err.message) + } + const rightNode = parseMultiplyDivideModulusPercentage(state) + if (rightNode.isPercentage) { // Note: isPercentage is a property not on all nodes + params = [node, new OperatorNode('*', 'multiply', [node, rightNode])] + } else { + params = [node, rightNode] + } + node = new OperatorNode(name, fn, params) + } + return node +} + +/** + * multiply, divide, modulus, percentage + * @return {Node} node + * @private + */ +function parseMultiplyDivideModulusPercentage (state) { + let node, last, name, fn + node = parseImplicitMultiplication(state) + last = node + const operators = { + '*': 'multiply', + '.*': 'dotMultiply', + '/': 'divide', + './': 'dotDivide', + '%': 'mod', + mod: 'mod' + } + while (true) { + if (hasOwnProperty(operators, state.token)) { + name = state.token + fn = operators[name] + try { + getTokenSkipNewline(state) + } catch (err) { + throw createSyntaxError(state, err.message) + } + if (name === '%' && state.tokenType === TOKENTYPE.DELIMITER && state.token !== '(') { + if (state.token !== '' && operators[state.token]) { + const left = new OperatorNode('/', 'divide', [node, new ConstantNode(100)], false, true) + name = state.token + fn = operators[name] + try { + getTokenSkipNewline(state) + } catch (err) { + throw createSyntaxError(state, err.message) + } + last = parseImplicitMultiplication(state) + node = new OperatorNode(name, fn, [left, last]) + } else { + node = new OperatorNode('/', 'divide', [node, new ConstantNode(100)], false, true) + } + } else { + last = parseImplicitMultiplication(state) + node = new OperatorNode(name, fn, [node, last]) + } + } else { + break + } + } + return node +} + +/** + * implicit multiplication + * @return {Node} node + * @private + */ +function parseImplicitMultiplication (state) { + let node, last + node = parseRule2(state) + last = node + while (true) { + if ((state.tokenType === TOKENTYPE.SYMBOL) || + (state.token === 'in' && isConstantNode(node)) || + (state.token === 'in' && isOperatorNode(node) && node.fn === 'unaryMinus' && isConstantNode(node.args[0])) || + (state.tokenType === TOKENTYPE.NUMBER && + !isConstantNode(last) && + (!isOperatorNode(last) || last.op === '!')) || + (state.token === '(')) { + last = parseRule2(state) + node = new OperatorNode('*', 'multiply', [node, last], true) + } else { + break + } + } + return node +} + +/** + * Infamous "rule 2" + * @return {Node} node + * @private + */ +function parseRule2 (state) { + let node = parseUnary(state) + let last = node + const tokenStates = [] + while (true) { + if (state.token === '/' && rule2Node(last)) { + tokenStates.push(Object.assign({}, state)) + try { + getTokenSkipNewline(state) + } catch (err) { + throw createSyntaxError(state, err.message) + } + if (state.tokenType === TOKENTYPE.NUMBER) { + tokenStates.push(Object.assign({}, state)) + try { + getTokenSkipNewline(state) + } catch (err) { + throw createSyntaxError(state, err.message) + } + if (state.tokenType === TOKENTYPE.SYMBOL || state.token === '(' || state.token === 'in') { + Object.assign(state, tokenStates.pop()) + tokenStates.pop() + last = parseUnary(state) + node = new OperatorNode('/', 'divide', [node, last]) + } else { + tokenStates.pop() + Object.assign(state, tokenStates.pop()) + break + } + } else { + Object.assign(state, tokenStates.pop()) + break + } + } else { + break + } + } + return node +} + +/** + * Unary plus and minus, and logical and bitwise not + * @return {Node} node + * @private + */ +function parseUnary (state) { + let name, params, fn + const operators = { + '-': 'unaryMinus', + '+': 'unaryPlus', + '~': 'bitNot', + not: 'not' + } + if (hasOwnProperty(operators, state.token)) { + fn = operators[state.token] + name = state.token + try { + getTokenSkipNewline(state) + } catch (err) { + throw createSyntaxError(state, err.message) + } + params = [parseUnary(state)] + return new OperatorNode(name, fn, params) + } + return parsePow(state) +} + +/** + * power + * Note: power operator is right associative + * @return {Node} node + * @private + */ +function parsePow (state) { + let node, name, fn, params + node = parseLeftHandOperators(state) + if (state.token === '^' || state.token === '.^') { + name = state.token + fn = (name === '^') ? 'pow' : 'dotPow' + try { + getTokenSkipNewline(state) + } catch (err) { + throw createSyntaxError(state, err.message) + } + params = [node, parseUnary(state)] + node = new OperatorNode(name, fn, params) + } + return node +} + +/** + * Left hand operators: factorial x!, ctranspose x' + * @return {Node} node + * @private + */ +function parseLeftHandOperators (state) { + let node, name, fn, params + node = parseCustomNodes(state) + const operators = { + '!': 'factorial', + '\'': 'ctranspose' + } + while (hasOwnProperty(operators, state.token)) { + name = state.token + fn = operators[name] + try { + getToken(state) + } catch (err) { + throw createSyntaxError(state, err.message) + } + params = [node] + node = new OperatorNode(name, fn, params) + node = parseAccessors(state, node) + } + return node +} + +/** + * Parse a custom node handler. + * @return {Node} node + * @private + */ +function parseCustomNodes (state) { + let params = [] + if (state.tokenType === TOKENTYPE.SYMBOL && hasOwnProperty(state.extraNodes, state.token)) { + const CustomNode = state.extraNodes[state.token] + try { + getToken(state) + } catch (err) { + throw createSyntaxError(state, err.message) + } + if (state.token === '(') { + params = [] + openParams(state) + try { + getToken(state) + } catch (err) { + throw createSyntaxError(state, err.message) + } + if (state.token !== ')') { + params.push(parseAssignment(state)) + while (state.token === ',') { + try { + getToken(state) + } catch (err) { + throw createSyntaxError(state, err.message) + } + params.push(parseAssignment(state)) + } + } + if (state.token !== ')') { + throw createSyntaxError(state, 'Parenthesis ) expected') + } + closeParams(state) + try { + getToken(state) + } catch (err) { + throw createSyntaxError(state, err.message) + } + } + return new CustomNode(params) + } + return parseSymbol(state) +} + +/** + * parse symbols: functions, variables, constants, units + * @return {Node} node + * @private + */ +function parseSymbol (state) { + let node, name + if (state.tokenType === TOKENTYPE.SYMBOL || + (state.tokenType === TOKENTYPE.DELIMITER && state.token in NAMED_DELIMITERS)) { + name = state.token + try { + getToken(state) + } catch (err) { + throw createSyntaxError(state, err.message) + } + if (hasOwnProperty(CONSTANTS, name)) { + node = new ConstantNode(CONSTANTS[name]) + } else if (NUMERIC_CONSTANTS.includes(name)) { + node = new ConstantNode(numeric(name, 'number')) + } else { + node = new SymbolNode(name) + } + node = parseAccessors(state, node) + return node + } + return parseString(state) +} + +/** + * parse accessors: + * - function invocation in round brackets (...), for example sqrt(2) + * - index enclosed in square brackets [...], for example A[2,3] + * - dot notation for properties, like foo.bar + * @param {Node} node Node on which to apply the parameters. + * @param {string[]} [types] Filter the types of notations + * @return {Node} node + * @private + */ +function parseAccessors (state, node, types) { + let params + while ((state.token === '(' || state.token === '[' || state.token === '.') && + (!types || types.includes(state.token))) { + params = [] + if (state.token === '(') { + if (isSymbolNode(node) || isAccessorNode(node)) { + openParams(state) + try { + getToken(state) + } catch (err) { + throw createSyntaxError(state, err.message) + } + if (state.token !== ')') { + params.push(parseAssignment(state)) + while (state.token === ',') { + try { + getToken(state) + } catch (err) { + throw createSyntaxError(state, err.message) + } + params.push(parseAssignment(state)) + } + } + if (state.token !== ')') { + throw createSyntaxError(state, 'Parenthesis ) expected') + } + closeParams(state) + try { + getToken(state) + } catch (err) { + throw createSyntaxError(state, err.message) + } + node = new FunctionNode(node, params) + } else { + return node + } + } else if (state.token === '[') { + openParams(state) + try { + getToken(state) + } catch (err) { + throw createSyntaxError(state, err.message) + } + if (state.token !== ']') { + params.push(parseAssignment(state)) + while (state.token === ',') { + try { + getToken(state) + } catch (err) { + throw createSyntaxError(state, err.message) + } + params.push(parseAssignment(state)) + } + } + if (state.token !== ']') { + throw createSyntaxError(state, 'Parenthesis ] expected') + } + closeParams(state) + try { + getToken(state) + } catch (err) { + throw createSyntaxError(state, err.message) + } + node = new AccessorNode(node, new IndexNode(params)) + } else { // state.token === '.' + try { + getToken(state) + } catch (err) { + throw createSyntaxError(state, err.message) + } + const isPropertyName = state.tokenType === TOKENTYPE.SYMBOL || + (state.tokenType === TOKENTYPE.DELIMITER && state.token in NAMED_DELIMITERS) + if (!isPropertyName) { + throw createSyntaxError(state, 'Property name expected after dot') + } + params.push(new ConstantNode(state.token)) + try { + getToken(state) + } catch (err) { + throw createSyntaxError(state, err.message) + } + const dotNotation = true + node = new AccessorNode(node, new IndexNode(params, dotNotation)) + } + } + return node +} + +/** + * Parse a single or double quoted string. + * @return {Node} node + * @private + */ +function parseString (state) { + let node, str + if (state.token === '"' || state.token === "'") { + str = parseStringToken(state, state.token) + node = new ConstantNode(str) + node = parseAccessors(state, node) + return node + } + return parseMatrix(state) +} + +/** + * Parse a string surrounded by single or double quotes + * @param {"'" | "\""} quote + * @return {string} + */ +function parseStringToken (state, quote) { + let str = '' + while (currentCharacter(state) !== '' && currentCharacter(state) !== quote) { + if (currentCharacter(state) === '\\') { + next(state) + const char = currentCharacter(state) + const escapeChar = ESCAPE_CHARACTERS[char] + if (escapeChar !== undefined) { + str += escapeChar + state.index += 1 // Note: next() was already called for the backslash + } else if (char === 'u') { + const unicode = currentString(state, 5).substring(1) // expression.slice(index + 1, index + 5) + if (/^[0-9A-Fa-f]{4}$/.test(unicode)) { + str += String.fromCharCode(parseInt(unicode, 16)) + state.index += 5 // +1 for 'u' and +4 for hex digits + } else { + throw createSyntaxError(state, `Invalid unicode character \\u${unicode}`) + } + } else { + throw createSyntaxError(state, `Bad escape character \\${char}`) + } + } else { + str += currentCharacter(state) + next(state) + } + } + try { + getToken(state) // consume quote + } catch (err) { + throw createSyntaxError(state, err.message) + } + if (state.token !== quote) { + throw createSyntaxError(state, `End of string ${quote} expected`) + } + try { + getToken(state) // consume next token + } catch (err) { + throw createSyntaxError(state, err.message) + } + return str +} + +/** + * parse the matrix + * @return {Node} node + * @private + */ +function parseMatrix (state) { + let array, params, rows, cols + if (state.token === '[') { + openParams(state) + try { + getToken(state) + } catch (err) { + throw createSyntaxError(state, err.message) + } + if (state.token !== ']') { + const row = parseRow(state) + if (state.token === ';') { + rows = 1 + params = [row] + while (state.token === ';') { + try { + getToken(state) + } catch (err) { + throw createSyntaxError(state, err.message) + } + if (state.token !== ']') { // check for empty row + params[rows] = parseRow(state) + rows++ + } + } + if (state.token !== ']') { + throw createSyntaxError(state, 'End of matrix ] expected') + } + closeParams(state) + try { + getToken(state) + } catch (err) { + throw createSyntaxError(state, err.message) + } + cols = params[0].items.length + for (let r = 1; r < rows; r++) { + if (params[r].items.length !== cols) { + throw createError(state, 'Column dimensions mismatch (' + params[r].items.length + ' !== ' + cols + ')') + } + } + array = new ArrayNode(params) + } else { + if (state.token !== ']') { + throw createSyntaxError(state, 'End of matrix ] expected') + } + closeParams(state) + try { + getToken(state) + } catch (err) { + throw createSyntaxError(state, err.message) + } + array = row + } + } else { + closeParams(state) + try { + getToken(state) + } catch (err) { + throw createSyntaxError(state, err.message) + } + array = new ArrayNode([]) + } + return parseAccessors(state, array) + } + return parseObject(state) +} + +/** + * Parse a single comma-separated row from a matrix, like 'a, b, c' + * @return {ArrayNode} node + */ +function parseRow (state) { + const params = [parseAssignment(state)] + let len = 1 + while (state.token === ',') { + try { + getToken(state) + } catch (err) { + throw createSyntaxError(state, err.message) + } + if (state.token !== ']' && state.token !== ';') { // check for empty item + params[len] = parseAssignment(state) + len++ + } + } + return new ArrayNode(params) +} + +/** + * parse an object, enclosed in angle brackets{...}, for example {value: 2} + * @return {Node} node + * @private + */ +function parseObject (state) { + if (state.token === '{') { + openParams(state) + let key + const properties = {} + do { + try { + getToken(state) + } catch (err) { + throw createSyntaxError(state, err.message) + } + if (state.token !== '}') { + if (state.token === '"' || state.token === "'") { + key = parseStringToken(state, state.token) + } else if (state.tokenType === TOKENTYPE.SYMBOL || (state.tokenType === TOKENTYPE.DELIMITER && state.token in NAMED_DELIMITERS)) { + key = state.token + try { + getToken(state) + } catch (err) { + throw createSyntaxError(state, err.message) + } + } else { + throw createSyntaxError(state, 'Symbol or string expected as object key') + } + if (state.token !== ':') { + throw createSyntaxError(state, 'Colon : expected after object key') + } + try { + getToken(state) + } catch (err) { + throw createSyntaxError(state, err.message) + } + properties[key] = parseAssignment(state) + } + } + while (state.token === ',') + if (state.token !== '}') { + throw createSyntaxError(state, 'Comma , or bracket } expected after object value') + } + closeParams(state) + try { + getToken(state) + } catch (err) { + throw createSyntaxError(state, err.message) + } + let node = new ObjectNode(properties) + node = parseAccessors(state, node) + return node + } + return parseNumber(state) +} + +/** + * parse a number + * @return {Node} node + * @private + */ +function parseNumber (state) { + let numberStr + if (state.tokenType === TOKENTYPE.NUMBER) { + numberStr = state.token + try { + getToken(state) + } catch (err) { + throw createSyntaxError(state, err.message) + } + const numericType = safeNumberType(numberStr, config) + const value = numeric(numberStr, numericType) + return new ConstantNode(value) + } + return parseParentheses(state) +} + +/** + * parentheses + * @return {Node} node + * @private + */ +function parseParentheses (state) { + let node + if (state.token === '(') { + openParams(state) + try { + getToken(state) + } catch (err) { + throw createSyntaxError(state, err.message) + } + node = parseAssignment(state) + if (state.token !== ')') { + throw createSyntaxError(state, 'Parenthesis ) expected') + } + closeParams(state) + try { + getToken(state) + } catch (err) { + throw createSyntaxError(state, err.message) + } + node = new ParenthesisNode(node) + node = parseAccessors(state, node) + return node + } + return parseEnd(state) +} + +/** + * Evaluated when the expression is not yet ended but expected to end + * @return {Node} res + * @private + */ +function parseEnd (state) { + if (state.token === '') { + throw createSyntaxError(state, 'Unexpected end of expression') + } else { + throw createSyntaxError(state, 'Value expected') + } +} diff --git a/src/expression/parse.js b/src/expression/parse.js index 32faf04344..e9fea0418d 100644 --- a/src/expression/parse.js +++ b/src/expression/parse.js @@ -1,8 +1,7 @@ import { factory } from '../utils/factory.js' -import { isAccessorNode, isConstantNode, isFunctionNode, isOperatorNode, isSymbolNode, rule2Node } from '../utils/is.js' import { deepMap } from '../utils/collection.js' -import { safeNumberType } from '../utils/number.js' -import { hasOwnProperty } from '../utils/object.js' +import { parseStart, setDependencies as setNodeParsersDependencies } from './nodeParsers.js' +import { isAlpha, isValidLatinOrGreek, isValidMathSymbol, isWhitespace, isDecimalMark, isDigitDot, isDigit, isHexDigit } from './lexer.js' const name = 'parse' const dependencies = [ @@ -24,6 +23,8 @@ const dependencies = [ 'RangeNode', 'RelationalNode', 'SymbolNode' + // No longer directly needed: safeNumberType, hasOwnProperty, isAccessorNode etc. + // Those are used within nodeParsers.js which imports them directly. ] export const createParse = /* #__PURE__ */ factory(name, dependencies, ({ @@ -45,7 +46,29 @@ export const createParse = /* #__PURE__ */ factory(name, dependencies, ({ RangeNode, RelationalNode, SymbolNode + // Note: other utils like is.js, object.js, number.js are imported directly in nodeParsers.js }) => { + // Pass dependencies to the nodeParsers module + setNodeParsersDependencies({ + numeric, + config, + AccessorNode, + ArrayNode, + AssignmentNode, + BlockNode, + ConditionalNode, + ConstantNode, + FunctionAssignmentNode, + FunctionNode, + IndexNode, + ObjectNode, + OperatorNode, + ParenthesisNode, + RangeNode, + RelationalNode, + SymbolNode + }) + /** * Parse an expression. Returns a node tree, which can be evaluated by * invoking node.evaluate(). @@ -94,7 +117,6 @@ export const createParse = /* #__PURE__ */ factory(name, dependencies, ({ }, 'string, Object': function (expression, options) { const extraNodes = options.nodes !== undefined ? options.nodes : {} - return parseStart(expression, extraNodes) }, 'Array | Matrix, Object': parseMultiple @@ -106,1665 +128,19 @@ export const createParse = /* #__PURE__ */ factory(name, dependencies, ({ // parse an array or matrix with expressions return deepMap(expressions, function (elem) { if (typeof elem !== 'string') throw new TypeError('String expected') - return parseStart(elem, extraNodes) }) } - // token types enumeration - const TOKENTYPE = { - NULL: 0, - DELIMITER: 1, - NUMBER: 2, - SYMBOL: 3, - UNKNOWN: 4 - } - - // map with all delimiters - const DELIMITERS = { - ',': true, - '(': true, - ')': true, - '[': true, - ']': true, - '{': true, - '}': true, - '"': true, - '\'': true, - ';': true, - - '+': true, - '-': true, - '*': true, - '.*': true, - '/': true, - './': true, - '%': true, - '^': true, - '.^': true, - '~': true, - '!': true, - '&': true, - '|': true, - '^|': true, - '=': true, - ':': true, - '?': true, - - '==': true, - '!=': true, - '<': true, - '>': true, - '<=': true, - '>=': true, - - '<<': true, - '>>': true, - '>>>': true - } - - // map with all named delimiters - const NAMED_DELIMITERS = { - mod: true, - to: true, - in: true, - and: true, - xor: true, - or: true, - not: true - } - - const CONSTANTS = { - true: true, - false: false, - null: null, - undefined - } - - const NUMERIC_CONSTANTS = [ - 'NaN', - 'Infinity' - ] - - const ESCAPE_CHARACTERS = { - '"': '"', - "'": "'", - '\\': '\\', - '/': '/', - b: '\b', - f: '\f', - n: '\n', - r: '\r', - t: '\t' - // note that \u is handled separately in parseStringToken() - } - - function initialState () { - return { - extraNodes: {}, // current extra nodes, must be careful not to mutate - expression: '', // current expression - comment: '', // last parsed comment - index: 0, // current index in expr - token: '', // current token - tokenType: TOKENTYPE.NULL, // type of the token - nestingLevel: 0, // level of nesting inside parameters, used to ignore newline characters - conditionalLevel: null // when a conditional is being parsed, the level of the conditional is stored here - } - } - - /** - * View upto `length` characters of the expression starting at the current character. - * - * @param {Object} state - * @param {number} [length=1] Number of characters to view - * @returns {string} - * @private - */ - function currentString (state, length) { - return state.expression.substr(state.index, length) - } - - /** - * View the current character. Returns '' if end of expression is reached. - * - * @param {Object} state - * @returns {string} - * @private - */ - function currentCharacter (state) { - return currentString(state, 1) - } - - /** - * Get the next character from the expression. - * The character is stored into the char c. If the end of the expression is - * reached, the function puts an empty string in c. - * @private - */ - function next (state) { - state.index++ - } - - /** - * Preview the previous character from the expression. - * @return {string} cNext - * @private - */ - function prevCharacter (state) { - return state.expression.charAt(state.index - 1) - } - - /** - * Preview the next character from the expression. - * @return {string} cNext - * @private - */ - function nextCharacter (state) { - return state.expression.charAt(state.index + 1) - } - - /** - * Get next token in the current string expr. - * The token and token type are available as token and tokenType - * @private - */ - function getToken (state) { - state.tokenType = TOKENTYPE.NULL - state.token = '' - state.comment = '' - - // skip over ignored characters: - while (true) { - // comments: - if (currentCharacter(state) === '#') { - while (currentCharacter(state) !== '\n' && - currentCharacter(state) !== '') { - state.comment += currentCharacter(state) - next(state) - } - } - // whitespace: space, tab, and newline when inside parameters - if (parse.isWhitespace(currentCharacter(state), state.nestingLevel)) { - next(state) - } else { - break - } - } - - // check for end of expression - if (currentCharacter(state) === '') { - // token is still empty - state.tokenType = TOKENTYPE.DELIMITER - return - } - - // check for new line character - if (currentCharacter(state) === '\n' && !state.nestingLevel) { - state.tokenType = TOKENTYPE.DELIMITER - state.token = currentCharacter(state) - next(state) - return - } - - const c1 = currentCharacter(state) - const c2 = currentString(state, 2) - const c3 = currentString(state, 3) - if (c3.length === 3 && DELIMITERS[c3]) { - state.tokenType = TOKENTYPE.DELIMITER - state.token = c3 - next(state) - next(state) - next(state) - return - } - - // check for delimiters consisting of 2 characters - if (c2.length === 2 && DELIMITERS[c2]) { - state.tokenType = TOKENTYPE.DELIMITER - state.token = c2 - next(state) - next(state) - return - } - - // check for delimiters consisting of 1 character - if (DELIMITERS[c1]) { - state.tokenType = TOKENTYPE.DELIMITER - state.token = c1 - next(state) - return - } - - // check for a number - if (parse.isDigitDot(c1)) { - state.tokenType = TOKENTYPE.NUMBER - - // check for binary, octal, or hex - const c2 = currentString(state, 2) - if (c2 === '0b' || c2 === '0o' || c2 === '0x') { - state.token += currentCharacter(state) - next(state) - state.token += currentCharacter(state) - next(state) - while (parse.isHexDigit(currentCharacter(state))) { - state.token += currentCharacter(state) - next(state) - } - if (currentCharacter(state) === '.') { - // this number has a radix point - state.token += '.' - next(state) - // get the digits after the radix - while (parse.isHexDigit(currentCharacter(state))) { - state.token += currentCharacter(state) - next(state) - } - } else if (currentCharacter(state) === 'i') { - // this number has a word size suffix - state.token += 'i' - next(state) - // get the word size - while (parse.isDigit(currentCharacter(state))) { - state.token += currentCharacter(state) - next(state) - } - } - return - } - - // get number, can have a single dot - if (currentCharacter(state) === '.') { - state.token += currentCharacter(state) - next(state) - - if (!parse.isDigit(currentCharacter(state))) { - // this is no number, it is just a dot (can be dot notation) - state.tokenType = TOKENTYPE.DELIMITER - return - } - } else { - while (parse.isDigit(currentCharacter(state))) { - state.token += currentCharacter(state) - next(state) - } - if (parse.isDecimalMark(currentCharacter(state), nextCharacter(state))) { - state.token += currentCharacter(state) - next(state) - } - } - - while (parse.isDigit(currentCharacter(state))) { - state.token += currentCharacter(state) - next(state) - } - // check for exponential notation like "2.3e-4", "1.23e50" or "2e+4" - if (currentCharacter(state) === 'E' || currentCharacter(state) === 'e') { - if (parse.isDigit(nextCharacter(state)) || nextCharacter(state) === '-' || nextCharacter(state) === '+') { - state.token += currentCharacter(state) - next(state) - - if (currentCharacter(state) === '+' || currentCharacter(state) === '-') { - state.token += currentCharacter(state) - next(state) - } - // Scientific notation MUST be followed by an exponent - if (!parse.isDigit(currentCharacter(state))) { - throw createSyntaxError(state, 'Digit expected, got "' + currentCharacter(state) + '"') - } - - while (parse.isDigit(currentCharacter(state))) { - state.token += currentCharacter(state) - next(state) - } - - if (parse.isDecimalMark(currentCharacter(state), nextCharacter(state))) { - throw createSyntaxError(state, 'Digit expected, got "' + currentCharacter(state) + '"') - } - } else if (nextCharacter(state) === '.') { - next(state) - throw createSyntaxError(state, 'Digit expected, got "' + currentCharacter(state) + '"') - } - } - - return - } - - // check for variables, functions, named operators - if (parse.isAlpha(currentCharacter(state), prevCharacter(state), nextCharacter(state))) { - while (parse.isAlpha(currentCharacter(state), prevCharacter(state), nextCharacter(state)) || parse.isDigit(currentCharacter(state))) { - state.token += currentCharacter(state) - next(state) - } - - if (hasOwnProperty(NAMED_DELIMITERS, state.token)) { - state.tokenType = TOKENTYPE.DELIMITER - } else { - state.tokenType = TOKENTYPE.SYMBOL - } - - return - } - - // something unknown is found, wrong characters -> a syntax error - state.tokenType = TOKENTYPE.UNKNOWN - while (currentCharacter(state) !== '') { - state.token += currentCharacter(state) - next(state) - } - throw createSyntaxError(state, 'Syntax error in part "' + state.token + '"') - } - - /** - * Get next token and skip newline tokens - */ - function getTokenSkipNewline (state) { - do { - getToken(state) - } - while (state.token === '\n') // eslint-disable-line no-unmodified-loop-condition - } - - /** - * Open parameters. - * New line characters will be ignored until closeParams(state) is called - */ - function openParams (state) { - state.nestingLevel++ - } - - /** - * Close parameters. - * New line characters will no longer be ignored - */ - function closeParams (state) { - state.nestingLevel-- - } - - /** - * Checks whether the current character `c` is a valid alpha character: - * - * - A latin letter (upper or lower case) Ascii: a-z, A-Z - * - An underscore Ascii: _ - * - A dollar sign Ascii: $ - * - A latin letter with accents Unicode: \u00C0 - \u02AF - * - A greek letter Unicode: \u0370 - \u03FF - * - A mathematical alphanumeric symbol Unicode: \u{1D400} - \u{1D7FF} excluding invalid code points - * - * The previous and next characters are needed to determine whether - * this character is part of a unicode surrogate pair. - * - * @param {string} c Current character in the expression - * @param {string} cPrev Previous character - * @param {string} cNext Next character - * @return {boolean} - */ - parse.isAlpha = function isAlpha (c, cPrev, cNext) { - return parse.isValidLatinOrGreek(c) || - parse.isValidMathSymbol(c, cNext) || - parse.isValidMathSymbol(cPrev, c) - } - - /** - * Test whether a character is a valid latin, greek, or letter-like character - * @param {string} c - * @return {boolean} - */ - parse.isValidLatinOrGreek = function isValidLatinOrGreek (c) { - return /^[a-zA-Z_$\u00C0-\u02AF\u0370-\u03FF\u2100-\u214F]$/.test(c) - } - - /** - * Test whether two given 16 bit characters form a surrogate pair of a - * unicode math symbol. - * - * https://unicode-table.com/en/ - * https://www.wikiwand.com/en/Mathematical_operators_and_symbols_in_Unicode - * - * Note: In ES6 will be unicode aware: - * https://stackoverflow.com/questions/280712/javascript-unicode-regexes - * https://mathiasbynens.be/notes/es6-unicode-regex - * - * @param {string} high - * @param {string} low - * @return {boolean} - */ - parse.isValidMathSymbol = function isValidMathSymbol (high, low) { - return /^[\uD835]$/.test(high) && - /^[\uDC00-\uDFFF]$/.test(low) && - /^[^\uDC55\uDC9D\uDCA0\uDCA1\uDCA3\uDCA4\uDCA7\uDCA8\uDCAD\uDCBA\uDCBC\uDCC4\uDD06\uDD0B\uDD0C\uDD15\uDD1D\uDD3A\uDD3F\uDD45\uDD47-\uDD49\uDD51\uDEA6\uDEA7\uDFCC\uDFCD]$/.test(low) - } - - /** - * Check whether given character c is a white space character: space, tab, or enter - * @param {string} c - * @param {number} nestingLevel - * @return {boolean} - */ - parse.isWhitespace = function isWhitespace (c, nestingLevel) { - // TODO: also take '\r' carriage return as newline? Or does that give problems on mac? - return c === ' ' || c === '\t' || (c === '\n' && nestingLevel > 0) - } - - /** - * Test whether the character c is a decimal mark (dot). - * This is the case when it's not the start of a delimiter '.*', './', or '.^' - * @param {string} c - * @param {string} cNext - * @return {boolean} - */ - parse.isDecimalMark = function isDecimalMark (c, cNext) { - return c === '.' && cNext !== '/' && cNext !== '*' && cNext !== '^' - } - - /** - * checks if the given char c is a digit or dot - * @param {string} c a string with one character - * @return {boolean} - */ - parse.isDigitDot = function isDigitDot (c) { - return ((c >= '0' && c <= '9') || c === '.') - } - - /** - * checks if the given char c is a digit - * @param {string} c a string with one character - * @return {boolean} - */ - parse.isDigit = function isDigit (c) { - return (c >= '0' && c <= '9') - } - - /** - * checks if the given char c is a hex digit - * @param {string} c a string with one character - * @return {boolean} - */ - parse.isHexDigit = function isHexDigit (c) { - return ((c >= '0' && c <= '9') || - (c >= 'a' && c <= 'f') || - (c >= 'A' && c <= 'F')) - } - - /** - * Start of the parse levels below, in order of precedence - * @return {Node} node - * @private - */ - function parseStart (expression, extraNodes) { - const state = initialState() - Object.assign(state, { expression, extraNodes }) - getToken(state) - - const node = parseBlock(state) - - // check for garbage at the end of the expression - // an expression ends with a empty character '' and tokenType DELIMITER - if (state.token !== '') { - if (state.tokenType === TOKENTYPE.DELIMITER) { - // user entered a not existing operator like "//" - - // TODO: give hints for aliases, for example with "<>" give as hint " did you mean !== ?" - throw createError(state, 'Unexpected operator ' + state.token) - } else { - throw createSyntaxError(state, 'Unexpected part "' + state.token + '"') - } - } - - return node - } - - /** - * Parse a block with expressions. Expressions can be separated by a newline - * character '\n', or by a semicolon ';'. In case of a semicolon, no output - * of the preceding line is returned. - * @return {Node} node - * @private - */ - function parseBlock (state) { - let node - const blocks = [] - let visible - - if (state.token !== '' && state.token !== '\n' && state.token !== ';') { - node = parseAssignment(state) - if (state.comment) { - node.comment = state.comment - } - } - - // TODO: simplify this loop - while (state.token === '\n' || state.token === ';') { // eslint-disable-line no-unmodified-loop-condition - if (blocks.length === 0 && node) { - visible = (state.token !== ';') - blocks.push({ node, visible }) - } - - getToken(state) - if (state.token !== '\n' && state.token !== ';' && state.token !== '') { - node = parseAssignment(state) - if (state.comment) { - node.comment = state.comment - } - - visible = (state.token !== ';') - blocks.push({ node, visible }) - } - } - - if (blocks.length > 0) { - return new BlockNode(blocks) - } else { - if (!node) { - node = new ConstantNode(undefined) - if (state.comment) { - node.comment = state.comment - } - } - - return node - } - } - - /** - * Assignment of a function or variable, - * - can be a variable like 'a=2.3' - * - or a updating an existing variable like 'matrix(2,3:5)=[6,7,8]' - * - defining a function like 'f(x) = x^2' - * @return {Node} node - * @private - */ - function parseAssignment (state) { - let name, args, value, valid - - const node = parseConditional(state) - - if (state.token === '=') { - if (isSymbolNode(node)) { - // parse a variable assignment like 'a = 2/3' - name = node.name - getTokenSkipNewline(state) - value = parseAssignment(state) - return new AssignmentNode(new SymbolNode(name), value) - } else if (isAccessorNode(node)) { - // parse a matrix subset assignment like 'A[1,2] = 4' - getTokenSkipNewline(state) - value = parseAssignment(state) - return new AssignmentNode(node.object, node.index, value) - } else if (isFunctionNode(node) && isSymbolNode(node.fn)) { - // parse function assignment like 'f(x) = x^2' - valid = true - args = [] - - name = node.name - node.args.forEach(function (arg, index) { - if (isSymbolNode(arg)) { - args[index] = arg.name - } else { - valid = false - } - }) - - if (valid) { - getTokenSkipNewline(state) - value = parseAssignment(state) - return new FunctionAssignmentNode(name, args, value) - } - } - - throw createSyntaxError(state, 'Invalid left hand side of assignment operator =') - } - - return node - } - - /** - * conditional operation - * - * condition ? truePart : falsePart - * - * Note: conditional operator is right-associative - * - * @return {Node} node - * @private - */ - function parseConditional (state) { - let node = parseLogicalOr(state) - - while (state.token === '?') { // eslint-disable-line no-unmodified-loop-condition - // set a conditional level, the range operator will be ignored as long - // as conditionalLevel === state.nestingLevel. - const prev = state.conditionalLevel - state.conditionalLevel = state.nestingLevel - getTokenSkipNewline(state) - - const condition = node - const trueExpr = parseAssignment(state) - - if (state.token !== ':') throw createSyntaxError(state, 'False part of conditional expression expected') - - state.conditionalLevel = null - getTokenSkipNewline(state) - - const falseExpr = parseAssignment(state) // Note: check for conditional operator again, right associativity - - node = new ConditionalNode(condition, trueExpr, falseExpr) - - // restore the previous conditional level - state.conditionalLevel = prev - } - - return node - } - - /** - * logical or, 'x or y' - * @return {Node} node - * @private - */ - function parseLogicalOr (state) { - let node = parseLogicalXor(state) - - while (state.token === 'or') { // eslint-disable-line no-unmodified-loop-condition - getTokenSkipNewline(state) - node = new OperatorNode('or', 'or', [node, parseLogicalXor(state)]) - } - - return node - } - - /** - * logical exclusive or, 'x xor y' - * @return {Node} node - * @private - */ - function parseLogicalXor (state) { - let node = parseLogicalAnd(state) - - while (state.token === 'xor') { // eslint-disable-line no-unmodified-loop-condition - getTokenSkipNewline(state) - node = new OperatorNode('xor', 'xor', [node, parseLogicalAnd(state)]) - } - - return node - } - - /** - * logical and, 'x and y' - * @return {Node} node - * @private - */ - function parseLogicalAnd (state) { - let node = parseBitwiseOr(state) - - while (state.token === 'and') { // eslint-disable-line no-unmodified-loop-condition - getTokenSkipNewline(state) - node = new OperatorNode('and', 'and', [node, parseBitwiseOr(state)]) - } - - return node - } - - /** - * bitwise or, 'x | y' - * @return {Node} node - * @private - */ - function parseBitwiseOr (state) { - let node = parseBitwiseXor(state) - - while (state.token === '|') { // eslint-disable-line no-unmodified-loop-condition - getTokenSkipNewline(state) - node = new OperatorNode('|', 'bitOr', [node, parseBitwiseXor(state)]) - } - - return node - } - - /** - * bitwise exclusive or (xor), 'x ^| y' - * @return {Node} node - * @private - */ - function parseBitwiseXor (state) { - let node = parseBitwiseAnd(state) - - while (state.token === '^|') { // eslint-disable-line no-unmodified-loop-condition - getTokenSkipNewline(state) - node = new OperatorNode('^|', 'bitXor', [node, parseBitwiseAnd(state)]) - } - - return node - } - - /** - * bitwise and, 'x & y' - * @return {Node} node - * @private - */ - function parseBitwiseAnd (state) { - let node = parseRelational(state) - - while (state.token === '&') { // eslint-disable-line no-unmodified-loop-condition - getTokenSkipNewline(state) - node = new OperatorNode('&', 'bitAnd', [node, parseRelational(state)]) - } - - return node - } - - /** - * Parse a chained conditional, like 'a > b >= c' - * @return {Node} node - */ - function parseRelational (state) { - const params = [parseShift(state)] - const conditionals = [] - - const operators = { - '==': 'equal', - '!=': 'unequal', - '<': 'smaller', - '>': 'larger', - '<=': 'smallerEq', - '>=': 'largerEq' - } - - while (hasOwnProperty(operators, state.token)) { // eslint-disable-line no-unmodified-loop-condition - const cond = { name: state.token, fn: operators[state.token] } - conditionals.push(cond) - getTokenSkipNewline(state) - params.push(parseShift(state)) - } - - if (params.length === 1) { - return params[0] - } else if (params.length === 2) { - return new OperatorNode(conditionals[0].name, conditionals[0].fn, params) - } else { - return new RelationalNode(conditionals.map(c => c.fn), params) - } - } - - /** - * Bitwise left shift, bitwise right arithmetic shift, bitwise right logical shift - * @return {Node} node - * @private - */ - function parseShift (state) { - let node, name, fn, params - - node = parseConversion(state) - - const operators = { - '<<': 'leftShift', - '>>': 'rightArithShift', - '>>>': 'rightLogShift' - } - - while (hasOwnProperty(operators, state.token)) { - name = state.token - fn = operators[name] - - getTokenSkipNewline(state) - params = [node, parseConversion(state)] - node = new OperatorNode(name, fn, params) - } - - return node - } - - /** - * conversion operators 'to' and 'in' - * @return {Node} node - * @private - */ - function parseConversion (state) { - let node, name, fn, params - - node = parseRange(state) - - const operators = { - to: 'to', - in: 'to' // alias of 'to' - } - - while (hasOwnProperty(operators, state.token)) { - name = state.token - fn = operators[name] - - getTokenSkipNewline(state) - - if (name === 'in' && state.token === '') { - // end of expression -> this is the unit 'in' ('inch') - node = new OperatorNode('*', 'multiply', [node, new SymbolNode('in')], true) - } else { - // operator 'a to b' or 'a in b' - params = [node, parseRange(state)] - node = new OperatorNode(name, fn, params) - } - } - - return node - } - - /** - * parse range, "start:end", "start:step:end", ":", "start:", ":end", etc - * @return {Node} node - * @private - */ - function parseRange (state) { - let node - const params = [] - - if (state.token === ':') { - // implicit start=1 (one-based) - node = new ConstantNode(1) - } else { - // explicit start - node = parseAddSubtract(state) - } - - if (state.token === ':' && (state.conditionalLevel !== state.nestingLevel)) { - // we ignore the range operator when a conditional operator is being processed on the same level - params.push(node) - - // parse step and end - while (state.token === ':' && params.length < 3) { // eslint-disable-line no-unmodified-loop-condition - getTokenSkipNewline(state) - - if (state.token === ')' || state.token === ']' || state.token === ',' || state.token === '') { - // implicit end - params.push(new SymbolNode('end')) - } else { - // explicit end - params.push(parseAddSubtract(state)) - } - } - - if (params.length === 3) { - // params = [start, step, end] - node = new RangeNode(params[0], params[2], params[1]) // start, end, step - } else { // length === 2 - // params = [start, end] - node = new RangeNode(params[0], params[1]) // start, end - } - } - - return node - } - - /** - * add or subtract - * @return {Node} node - * @private - */ - function parseAddSubtract (state) { - let node, name, fn, params - - node = parseMultiplyDivideModulusPercentage(state) - - const operators = { - '+': 'add', - '-': 'subtract' - } - while (hasOwnProperty(operators, state.token)) { - name = state.token - fn = operators[name] - - getTokenSkipNewline(state) - const rightNode = parseMultiplyDivideModulusPercentage(state) - if (rightNode.isPercentage) { - params = [node, new OperatorNode('*', 'multiply', [node, rightNode])] - } else { - params = [node, rightNode] - } - node = new OperatorNode(name, fn, params) - } - - return node - } - - /** - * multiply, divide, modulus, percentage - * @return {Node} node - * @private - */ - function parseMultiplyDivideModulusPercentage (state) { - let node, last, name, fn - - node = parseImplicitMultiplication(state) - last = node - - const operators = { - '*': 'multiply', - '.*': 'dotMultiply', - '/': 'divide', - './': 'dotDivide', - '%': 'mod', - mod: 'mod' - } - - while (true) { - if (hasOwnProperty(operators, state.token)) { - // explicit operators - name = state.token - fn = operators[name] - - getTokenSkipNewline(state) - - if (name === '%' && state.tokenType === TOKENTYPE.DELIMITER && state.token !== '(') { - // If the expression contains only %, then treat that as /100 - if (state.token !== '' && operators[state.token]) { - const left = new OperatorNode('/', 'divide', [node, new ConstantNode(100)], false, true) - name = state.token - fn = operators[name] - getTokenSkipNewline(state) - last = parseImplicitMultiplication(state) - - node = new OperatorNode(name, fn, [left, last]) - } else { node = new OperatorNode('/', 'divide', [node, new ConstantNode(100)], false, true) } - // return node - } else { - last = parseImplicitMultiplication(state) - node = new OperatorNode(name, fn, [node, last]) - } - } else { - break - } - } - - return node - } - - /** - * implicit multiplication - * @return {Node} node - * @private - */ - function parseImplicitMultiplication (state) { - let node, last - - node = parseRule2(state) - last = node - - while (true) { - if ((state.tokenType === TOKENTYPE.SYMBOL) || - (state.token === 'in' && isConstantNode(node)) || - (state.token === 'in' && isOperatorNode(node) && node.fn === 'unaryMinus' && isConstantNode(node.args[0])) || - (state.tokenType === TOKENTYPE.NUMBER && - !isConstantNode(last) && - (!isOperatorNode(last) || last.op === '!')) || - (state.token === '(')) { - // parse implicit multiplication - // - // symbol: implicit multiplication like '2a', '(2+3)a', 'a b' - // number: implicit multiplication like '(2+3)2' - // parenthesis: implicit multiplication like '2(3+4)', '(3+4)(1+2)' - last = parseRule2(state) - node = new OperatorNode('*', 'multiply', [node, last], true /* implicit */) - } else { - break - } - } - - return node - } - - /** - * Infamous "rule 2" as described in https://github.com/josdejong/mathjs/issues/792#issuecomment-361065370 - * And as amended in https://github.com/josdejong/mathjs/issues/2370#issuecomment-1054052164 - * Explicit division gets higher precedence than implicit multiplication - * when the division matches this pattern: - * [unaryPrefixOp]?[number] / [number] [symbol] - * @return {Node} node - * @private - */ - function parseRule2 (state) { - let node = parseUnary(state) - let last = node - const tokenStates = [] - - while (true) { - // Match the "number /" part of the pattern "number / number symbol" - if (state.token === '/' && rule2Node(last)) { - // Look ahead to see if the next token is a number - tokenStates.push(Object.assign({}, state)) - getTokenSkipNewline(state) - - // Match the "number / number" part of the pattern - if (state.tokenType === TOKENTYPE.NUMBER) { - // Look ahead again - tokenStates.push(Object.assign({}, state)) - getTokenSkipNewline(state) - - // Match the "symbol" part of the pattern, or a left parenthesis - if (state.tokenType === TOKENTYPE.SYMBOL || state.token === '(' || state.token === 'in') { - // We've matched the pattern "number / number symbol". - // Rewind once and build the "number / number" node; the symbol will be consumed later - Object.assign(state, tokenStates.pop()) - tokenStates.pop() - last = parseUnary(state) - node = new OperatorNode('/', 'divide', [node, last]) - } else { - // Not a match, so rewind - tokenStates.pop() - Object.assign(state, tokenStates.pop()) - break - } - } else { - // Not a match, so rewind - Object.assign(state, tokenStates.pop()) - break - } - } else { - break - } - } - - return node - } - - /** - * Unary plus and minus, and logical and bitwise not - * @return {Node} node - * @private - */ - function parseUnary (state) { - let name, params, fn - const operators = { - '-': 'unaryMinus', - '+': 'unaryPlus', - '~': 'bitNot', - not: 'not' - } - - if (hasOwnProperty(operators, state.token)) { - fn = operators[state.token] - name = state.token - - getTokenSkipNewline(state) - params = [parseUnary(state)] - - return new OperatorNode(name, fn, params) - } - - return parsePow(state) - } - - /** - * power - * Note: power operator is right associative - * @return {Node} node - * @private - */ - function parsePow (state) { - let node, name, fn, params - - node = parseLeftHandOperators(state) - - if (state.token === '^' || state.token === '.^') { - name = state.token - fn = (name === '^') ? 'pow' : 'dotPow' - - getTokenSkipNewline(state) - params = [node, parseUnary(state)] // Go back to unary, we can have '2^-3' - node = new OperatorNode(name, fn, params) - } - - return node - } - - /** - * Left hand operators: factorial x!, ctranspose x' - * @return {Node} node - * @private - */ - function parseLeftHandOperators (state) { - let node, name, fn, params - - node = parseCustomNodes(state) - - const operators = { - '!': 'factorial', - '\'': 'ctranspose' - } - - while (hasOwnProperty(operators, state.token)) { - name = state.token - fn = operators[name] - - getToken(state) - params = [node] - - node = new OperatorNode(name, fn, params) - node = parseAccessors(state, node) - } - - return node - } - - /** - * Parse a custom node handler. A node handler can be used to process - * nodes in a custom way, for example for handling a plot. - * - * A handler must be passed as second argument of the parse function. - * - must extend math.Node - * - must contain a function _compile(defs: Object) : string - * - must contain a function find(filter: Object) : Node[] - * - must contain a function toString() : string - * - the constructor is called with a single argument containing all parameters - * - * For example: - * - * nodes = { - * 'plot': PlotHandler - * } - * - * The constructor of the handler is called as: - * - * node = new PlotHandler(params) - * - * The handler will be invoked when evaluating an expression like: - * - * node = math.parse('plot(sin(x), x)', nodes) - * - * @return {Node} node - * @private - */ - function parseCustomNodes (state) { - let params = [] - - if (state.tokenType === TOKENTYPE.SYMBOL && hasOwnProperty(state.extraNodes, state.token)) { - const CustomNode = state.extraNodes[state.token] - - getToken(state) - - // parse parameters - if (state.token === '(') { - params = [] - - openParams(state) - getToken(state) - - if (state.token !== ')') { - params.push(parseAssignment(state)) - - // parse a list with parameters - while (state.token === ',') { // eslint-disable-line no-unmodified-loop-condition - getToken(state) - params.push(parseAssignment(state)) - } - } - - if (state.token !== ')') { - throw createSyntaxError(state, 'Parenthesis ) expected') - } - closeParams(state) - getToken(state) - } - - // create a new custom node - // noinspection JSValidateTypes - return new CustomNode(params) - } - - return parseSymbol(state) - } - - /** - * parse symbols: functions, variables, constants, units - * @return {Node} node - * @private - */ - function parseSymbol (state) { - let node, name - - if (state.tokenType === TOKENTYPE.SYMBOL || - (state.tokenType === TOKENTYPE.DELIMITER && state.token in NAMED_DELIMITERS)) { - name = state.token - - getToken(state) - - if (hasOwnProperty(CONSTANTS, name)) { // true, false, null, ... - node = new ConstantNode(CONSTANTS[name]) - } else if (NUMERIC_CONSTANTS.includes(name)) { // NaN, Infinity - node = new ConstantNode(numeric(name, 'number')) - } else { - node = new SymbolNode(name) - } - - // parse function parameters and matrix index - node = parseAccessors(state, node) - return node - } - - return parseString(state) - } - - /** - * parse accessors: - * - function invocation in round brackets (...), for example sqrt(2) - * - index enclosed in square brackets [...], for example A[2,3] - * - dot notation for properties, like foo.bar - * @param {Object} state - * @param {Node} node Node on which to apply the parameters. If there - * are no parameters in the expression, the node - * itself is returned - * @param {string[]} [types] Filter the types of notations - * can be ['(', '[', '.'] - * @return {Node} node - * @private - */ - function parseAccessors (state, node, types) { - let params - - while ((state.token === '(' || state.token === '[' || state.token === '.') && - (!types || types.includes(state.token))) { // eslint-disable-line no-unmodified-loop-condition - params = [] - - if (state.token === '(') { - if (isSymbolNode(node) || isAccessorNode(node)) { - // function invocation like fn(2, 3) or obj.fn(2, 3) - openParams(state) - getToken(state) - - if (state.token !== ')') { - params.push(parseAssignment(state)) - - // parse a list with parameters - while (state.token === ',') { // eslint-disable-line no-unmodified-loop-condition - getToken(state) - params.push(parseAssignment(state)) - } - } - - if (state.token !== ')') { - throw createSyntaxError(state, 'Parenthesis ) expected') - } - closeParams(state) - getToken(state) - - node = new FunctionNode(node, params) - } else { - // implicit multiplication like (2+3)(4+5) or sqrt(2)(1+2) - // don't parse it here but let it be handled by parseImplicitMultiplication - // with correct precedence - return node - } - } else if (state.token === '[') { - // index notation like variable[2, 3] - openParams(state) - getToken(state) - - if (state.token !== ']') { - params.push(parseAssignment(state)) - - // parse a list with parameters - while (state.token === ',') { // eslint-disable-line no-unmodified-loop-condition - getToken(state) - params.push(parseAssignment(state)) - } - } - - if (state.token !== ']') { - throw createSyntaxError(state, 'Parenthesis ] expected') - } - closeParams(state) - getToken(state) - - node = new AccessorNode(node, new IndexNode(params)) - } else { - // dot notation like variable.prop - getToken(state) - - const isPropertyName = state.tokenType === TOKENTYPE.SYMBOL || - (state.tokenType === TOKENTYPE.DELIMITER && state.token in NAMED_DELIMITERS) - if (!isPropertyName) { - throw createSyntaxError(state, 'Property name expected after dot') - } - - params.push(new ConstantNode(state.token)) - getToken(state) - - const dotNotation = true - node = new AccessorNode(node, new IndexNode(params, dotNotation)) - } - } - - return node - } - - /** - * Parse a single or double quoted string. - * @return {Node} node - * @private - */ - function parseString (state) { - let node, str - - if (state.token === '"' || state.token === "'") { - str = parseStringToken(state, state.token) - - // create constant - node = new ConstantNode(str) - - // parse index parameters - node = parseAccessors(state, node) - - return node - } - - return parseMatrix(state) - } - - /** - * Parse a string surrounded by single or double quotes - * @param {Object} state - * @param {"'" | "\""} quote - * @return {string} - */ - function parseStringToken (state, quote) { - let str = '' - - while (currentCharacter(state) !== '' && currentCharacter(state) !== quote) { - if (currentCharacter(state) === '\\') { - next(state) - - const char = currentCharacter(state) - const escapeChar = ESCAPE_CHARACTERS[char] - if (escapeChar !== undefined) { - // an escaped control character like \" or \n - str += escapeChar - state.index += 1 - } else if (char === 'u') { - // escaped unicode character - const unicode = state.expression.slice(state.index + 1, state.index + 5) - if (/^[0-9A-Fa-f]{4}$/.test(unicode)) { // test whether the string holds four hexadecimal values - str += String.fromCharCode(parseInt(unicode, 16)) - state.index += 5 - } else { - throw createSyntaxError(state, `Invalid unicode character \\u${unicode}`) - } - } else { - throw createSyntaxError(state, `Bad escape character \\${char}`) - } - } else { - // any regular character - str += currentCharacter(state) - next(state) - } - } - - getToken(state) - if (state.token !== quote) { - throw createSyntaxError(state, `End of string ${quote} expected`) - } - getToken(state) - - return str - } - - /** - * parse the matrix - * @return {Node} node - * @private - */ - function parseMatrix (state) { - let array, params, rows, cols - - if (state.token === '[') { - // matrix [...] - openParams(state) - getToken(state) - - if (state.token !== ']') { - // this is a non-empty matrix - const row = parseRow(state) - - if (state.token === ';') { - // 2 dimensional array - rows = 1 - params = [row] - - // the rows of the matrix are separated by dot-comma's - while (state.token === ';') { // eslint-disable-line no-unmodified-loop-condition - getToken(state) - - if (state.token !== ']') { - params[rows] = parseRow(state) - rows++ - } - } - - if (state.token !== ']') { - throw createSyntaxError(state, 'End of matrix ] expected') - } - closeParams(state) - getToken(state) - - // check if the number of columns matches in all rows - cols = params[0].items.length - for (let r = 1; r < rows; r++) { - if (params[r].items.length !== cols) { - throw createError(state, 'Column dimensions mismatch ' + - '(' + params[r].items.length + ' !== ' + cols + ')') - } - } - - array = new ArrayNode(params) - } else { - // 1 dimensional vector - if (state.token !== ']') { - throw createSyntaxError(state, 'End of matrix ] expected') - } - closeParams(state) - getToken(state) - - array = row - } - } else { - // this is an empty matrix "[ ]" - closeParams(state) - getToken(state) - array = new ArrayNode([]) - } - - return parseAccessors(state, array) - } - - return parseObject(state) - } - - /** - * Parse a single comma-separated row from a matrix, like 'a, b, c' - * @return {ArrayNode} node - */ - function parseRow (state) { - const params = [parseAssignment(state)] - let len = 1 - - while (state.token === ',') { // eslint-disable-line no-unmodified-loop-condition - getToken(state) - - // parse expression - if (state.token !== ']' && state.token !== ';') { - params[len] = parseAssignment(state) - len++ - } - } - - return new ArrayNode(params) - } - - /** - * parse an object, enclosed in angle brackets{...}, for example {value: 2} - * @return {Node} node - * @private - */ - function parseObject (state) { - if (state.token === '{') { - openParams(state) - let key - - const properties = {} - do { - getToken(state) - - if (state.token !== '}') { - // parse key - if (state.token === '"' || state.token === "'") { - key = parseStringToken(state, state.token) - } else if (state.tokenType === TOKENTYPE.SYMBOL || (state.tokenType === TOKENTYPE.DELIMITER && state.token in NAMED_DELIMITERS)) { - key = state.token - getToken(state) - } else { - throw createSyntaxError(state, 'Symbol or string expected as object key') - } - - // parse key/value separator - if (state.token !== ':') { - throw createSyntaxError(state, 'Colon : expected after object key') - } - getToken(state) - - // parse key - properties[key] = parseAssignment(state) - } - } - while (state.token === ',') // eslint-disable-line no-unmodified-loop-condition - - if (state.token !== '}') { - throw createSyntaxError(state, 'Comma , or bracket } expected after object value') - } - closeParams(state) - getToken(state) - - let node = new ObjectNode(properties) - - // parse index parameters - node = parseAccessors(state, node) - - return node - } - - return parseNumber(state) - } - - /** - * parse a number - * @return {Node} node - * @private - */ - function parseNumber (state) { - let numberStr - - if (state.tokenType === TOKENTYPE.NUMBER) { - // this is a number - numberStr = state.token - getToken(state) - - const numericType = safeNumberType(numberStr, config) - const value = numeric(numberStr, numericType) - - return new ConstantNode(value) - } - - return parseParentheses(state) - } - - /** - * parentheses - * @return {Node} node - * @private - */ - function parseParentheses (state) { - let node - - // check if it is a parenthesized expression - if (state.token === '(') { - // parentheses (...) - openParams(state) - getToken(state) - - node = parseAssignment(state) // start again - - if (state.token !== ')') { - throw createSyntaxError(state, 'Parenthesis ) expected') - } - closeParams(state) - getToken(state) - - node = new ParenthesisNode(node) - node = parseAccessors(state, node) - return node - } - - return parseEnd(state) - } - - /** - * Evaluated when the expression is not yet ended but expected to end - * @return {Node} res - * @private - */ - function parseEnd (state) { - if (state.token === '') { - // syntax error or unexpected end of expression - throw createSyntaxError(state, 'Unexpected end of expression') - } else { - throw createSyntaxError(state, 'Value expected') - } - } - - /** - * Shortcut for getting the current row value (one based) - * Returns the line of the currently handled expression - * @private - */ - /* TODO: implement keeping track on the row number - function row () { - return null - } - */ - - /** - * Shortcut for getting the current col value (one based) - * Returns the column (position) where the last state.token starts - * @private - */ - function col (state) { - return state.index - state.token.length + 1 - } - - /** - * Create an error - * @param {Object} state - * @param {string} message - * @return {SyntaxError} instantiated error - * @private - */ - function createSyntaxError (state, message) { - const c = col(state) - const error = new SyntaxError(message + ' (char ' + c + ')') - error.char = c - - return error - } - - /** - * Create an error - * @param {Object} state - * @param {string} message - * @return {Error} instantiated error - * @private - */ - function createError (state, message) { - const c = col(state) - const error = new SyntaxError(message + ' (char ' + c + ')') - error.char = c - - return error - } + // Attach helper functions to the parse function + parse.isAlpha = isAlpha + parse.isValidLatinOrGreek = isValidLatinOrGreek + parse.isValidMathSymbol = isValidMathSymbol + parse.isWhitespace = isWhitespace + parse.isDecimalMark = isDecimalMark + parse.isDigitDot = isDigitDot + parse.isDigit = isDigit + parse.isHexDigit = isHexDigit // Now that we can parse, automatically convert strings to Nodes by parsing typed.addConversion({ from: 'string', to: 'Node', convert: parse }) diff --git a/src/expression/parserState.js b/src/expression/parserState.js new file mode 100644 index 0000000000..82a188a578 --- /dev/null +++ b/src/expression/parserState.js @@ -0,0 +1,30 @@ +import { TOKENTYPE } from '../expression/lexer.js' + +export function initialState () { + return { + extraNodes: {}, // current extra nodes, must be careful not to mutate + expression: '', // current expression + comment: '', // last parsed comment + index: 0, // current index in expr + token: '', // current token + tokenType: TOKENTYPE.NULL, // type of the token + nestingLevel: 0, // level of nesting inside parameters, used to ignore newline characters + conditionalLevel: null // when a conditional is being parsed, the level of the conditional is stored here + } +} + +/** + * Open parameters. + * New line characters will be ignored until closeParams(state) is called + */ +export function openParams (state) { + state.nestingLevel++ +} + +/** + * Close parameters. + * New line characters will no longer be ignored + */ +export function closeParams (state) { + state.nestingLevel-- +} diff --git a/test/unit-tests/expression/error.test.js b/test/unit-tests/expression/error.test.js new file mode 100644 index 0000000000..0e9e779932 --- /dev/null +++ b/test/unit-tests/expression/error.test.js @@ -0,0 +1,71 @@ +import assert from 'assert' +import { + col, + createSyntaxError, + createError +} from '../../../src/expression/error.js' + +describe('error.js', () => { + describe('col', () => { + it('should calculate the correct column number', () => { + const state1 = { index: 10, token: 'abc' } // 10 - 3 + 1 = 8 + assert.strictEqual(col(state1), 8) + + const state2 = { index: 5, token: 'xy' } // 5 - 2 + 1 = 4 + assert.strictEqual(col(state2), 4) + + const state3 = { index: 0, token: '' } // 0 - 0 + 1 = 1 + assert.strictEqual(col(state3), 1) + + const state4 = { index: 7, token: 'longtok' } // 7 - 7 + 1 = 1 + assert.strictEqual(col(state4), 1) + }) + }) + + describe('createSyntaxError', () => { + it('should create a SyntaxError with correct message and properties', () => { + const state = { index: 10, token: 'test' } // col = 10 - 4 + 1 = 7 + const message = 'Something went wrong' + const error = createSyntaxError(state, message) + + assert.ok(error instanceof SyntaxError) + assert.strictEqual(error.message, 'Something went wrong (char 7)') + assert.strictEqual(error.char, 7) + }) + + it('should handle empty token string for column calculation', () => { + const state = { index: 5, token: '' } // col = 5 - 0 + 1 = 6 + const message = 'Empty token issue' + const error = createSyntaxError(state, message) + + assert.ok(error instanceof SyntaxError) + assert.strictEqual(error.message, 'Empty token issue (char 6)') + assert.strictEqual(error.char, 6) + }) + }) + + describe('createError', () => { + it('should create an Error (currently SyntaxError) with correct message and properties', () => { + const state = { index: 12, token: 'operator' } // col = 12 - 8 + 1 = 5 + const message = 'Invalid operator' + const error = createError(state, message) + + // The current implementation of createError in error.js returns an Error. + assert.ok(error instanceof Error) + assert.ok(!(error instanceof SyntaxError)) // Ensure it's not a SyntaxError + assert.strictEqual(error.message, 'Invalid operator (char 5)') + assert.strictEqual(error.char, 5) + }) + + it('should handle different message for createError', () => { + const state = { index: 3, token: 'a' } // col = 3 - 1 + 1 = 3 + const message = 'Custom error' + const error = createError(state, message) + + assert.ok(error instanceof Error) + assert.ok(!(error instanceof SyntaxError)) // Ensure it's not a SyntaxError + assert.strictEqual(error.message, 'Custom error (char 3)') + assert.strictEqual(error.char, 3) + }) + }) +}) diff --git a/test/unit-tests/expression/lexer.test.js b/test/unit-tests/expression/lexer.test.js new file mode 100644 index 0000000000..cf10f7bfa8 --- /dev/null +++ b/test/unit-tests/expression/lexer.test.js @@ -0,0 +1,324 @@ +import assert from 'assert' +import { + TOKENTYPE, + DELIMITERS, + NAMED_DELIMITERS, + CONSTANTS, + NUMERIC_CONSTANTS, + ESCAPE_CHARACTERS, + getToken, + getTokenSkipNewline, + isAlpha, + isValidLatinOrGreek, + isValidMathSymbol, + isWhitespace, + isDecimalMark, + isDigitDot, + isDigit, + isHexDigit, + currentCharacter, + next, + currentString +} from '../../../src/expression/lexer.js' + +describe('lexer.js', () => { + const createInitialState = (expression, index = 0, nestingLevel = 0) => ({ + expression, + index, + token: '', + tokenType: TOKENTYPE.NULL, + nestingLevel, + comment: '' + }) + + describe('getToken and getTokenSkipNewline', () => { + it('should tokenize a simple number', () => { + const state = createInitialState('123') + getToken(state) + assert.strictEqual(state.token, '123') + assert.strictEqual(state.tokenType, TOKENTYPE.NUMBER) + }) + + it('should tokenize a number with decimal', () => { + const state = createInitialState('3.14') + getToken(state) + assert.strictEqual(state.token, '3.14') + assert.strictEqual(state.tokenType, TOKENTYPE.NUMBER) + }) + + it('should tokenize a symbol', () => { + const state = createInitialState('x') + getToken(state) + assert.strictEqual(state.token, 'x') + assert.strictEqual(state.tokenType, TOKENTYPE.SYMBOL) + }) + + it('should tokenize an operator', () => { + const state = createInitialState('+') + getToken(state) + assert.strictEqual(state.token, '+') + assert.strictEqual(state.tokenType, TOKENTYPE.DELIMITER) + assert.ok(DELIMITERS[state.token]) + }) + + it('should tokenize a two-character operator', () => { + const state = createInitialState('>=') + getToken(state) + assert.strictEqual(state.token, '>=') + assert.strictEqual(state.tokenType, TOKENTYPE.DELIMITER) + }) + + it('should tokenize a three-character operator', () => { + const state = createInitialState('>>>') + getToken(state) + assert.strictEqual(state.token, '>>>') + assert.strictEqual(state.tokenType, TOKENTYPE.DELIMITER) + }) + + it('should skip whitespace', () => { + const state = createInitialState(' 123') + getToken(state) + assert.strictEqual(state.token, '123') + assert.strictEqual(state.tokenType, TOKENTYPE.NUMBER) + }) + + it('should skip comments', () => { + const state = createInitialState('# this is a comment\n42') + getToken(state) + assert.strictEqual(state.token, '42') + assert.strictEqual(state.tokenType, TOKENTYPE.NUMBER) + assert.strictEqual(state.comment, '# this is a comment') + }) + + it('should handle newline as a delimiter when not nested', () => { + const state = createInitialState('\n', 0, 0) + getToken(state) + assert.strictEqual(state.token, '\n') + assert.strictEqual(state.tokenType, TOKENTYPE.DELIMITER) + }) + + it('should skip newline when nested', () => { + const state = createInitialState('\n abc', 0, 1) // nestingLevel = 1 + getToken(state) + assert.strictEqual(state.token, 'abc') + assert.strictEqual(state.tokenType, TOKENTYPE.SYMBOL) + }) + + it('getTokenSkipNewline should skip newlines', () => { + const state = createInitialState('\n\n xyz') + getTokenSkipNewline(state) + assert.strictEqual(state.token, 'xyz') + assert.strictEqual(state.tokenType, TOKENTYPE.SYMBOL) + }) + + it('should tokenize named delimiters', () => { + const state = createInitialState('mod') + getToken(state) + assert.strictEqual(state.token, 'mod') + assert.strictEqual(state.tokenType, TOKENTYPE.DELIMITER) + assert.ok(NAMED_DELIMITERS[state.token]) + }) + + it('should tokenize hex numbers', () => { + const state = createInitialState('0x1A') + getToken(state) + assert.strictEqual(state.token, '0x1A') + assert.strictEqual(state.tokenType, TOKENTYPE.NUMBER) + }) + + it('should tokenize octal numbers', () => { + const state = createInitialState('0o72') + getToken(state) + assert.strictEqual(state.token, '0o72') + assert.strictEqual(state.tokenType, TOKENTYPE.NUMBER) + }) + + it('should tokenize binary numbers', () => { + const state = createInitialState('0b101') + getToken(state) + assert.strictEqual(state.token, '0b101') + assert.strictEqual(state.tokenType, TOKENTYPE.NUMBER) + }) + + it('should tokenize numbers with exponent', () => { + let state = createInitialState('1.2e3') + getToken(state) + assert.strictEqual(state.token, '1.2e3') + assert.strictEqual(state.tokenType, TOKENTYPE.NUMBER) + + state = createInitialState('0.5E-2') + getToken(state) + assert.strictEqual(state.token, '0.5E-2') + assert.strictEqual(state.tokenType, TOKENTYPE.NUMBER) + + state = createInitialState('4e+4') + getToken(state) + assert.strictEqual(state.token, '4e+4') + assert.strictEqual(state.tokenType, TOKENTYPE.NUMBER) + }) + + it('should throw Error for invalid exponent', () => { + assert.throws(() => getToken(createInitialState('1.2e')), Error, /Digit expected/) + assert.throws(() => getToken(createInitialState('1.2eA')), Error, /Digit expected/) + assert.throws(() => getToken(createInitialState('1.2e.')), Error, /Digit expected/) + }) + + it('should throw Error for unknown character', () => { + assert.throws(() => getToken(createInitialState('@')), Error, /Syntax error in part "@"/) + }) + + it('should identify end of expression', () => { + const state = createInitialState('') + getToken(state) + assert.strictEqual(state.token, '') + assert.strictEqual(state.tokenType, TOKENTYPE.DELIMITER) + }) + + it('should tokenize a dot as delimiter if not part of a number', () => { + const state = createInitialState('.') + getToken(state) + assert.strictEqual(state.token, '.') + assert.strictEqual(state.tokenType, TOKENTYPE.DELIMITER) + }) + }) + + describe('Character Classification Functions', () => { + it('isAlpha', () => { + assert.ok(isAlpha('a', '', '')) + assert.ok(isAlpha('Z', '', '')) + assert.ok(isAlpha('_', '', '')) + assert.ok(isAlpha('$', '', '')) + assert.ok(isAlpha('\u00C0', '', '')) // Latin A with grave + assert.ok(isAlpha('\u03B1', '', '')) // Greek alpha + assert.ok(isAlpha('\uD835', '', '\uDC00')) // Math symbol (surrogate pair start) + assert.ok(isAlpha('\uDC00', '\uD835', '')) // Math symbol (surrogate pair end) + assert.strictEqual(isAlpha('1', '', ''), false) + assert.strictEqual(isAlpha('.', '', ''), false) + assert.strictEqual(isAlpha(' ', '', ''), false) + }) + + it('isValidLatinOrGreek', () => { + assert.ok(isValidLatinOrGreek('a')) + assert.ok(isValidLatinOrGreek('Z')) + assert.ok(isValidLatinOrGreek('_')) + assert.ok(isValidLatinOrGreek('$')) + assert.ok(isValidLatinOrGreek('\u00C0')) + assert.ok(isValidLatinOrGreek('\u02AF')) + assert.ok(isValidLatinOrGreek('\u0370')) + assert.ok(isValidLatinOrGreek('\u03FF')) + assert.ok(isValidLatinOrGreek('\u2100')) // Letterlike symbols block start + assert.ok(isValidLatinOrGreek('\u214F')) // Letterlike symbols block end + assert.strictEqual(isValidLatinOrGreek('1'), false) + assert.strictEqual(isValidLatinOrGreek('\uD835'), false) // Surrogate pair char + }) + + it('isValidMathSymbol', () => { + assert.ok(isValidMathSymbol('\uD835', '\uDC00')) // Valid start of math symbol block + assert.ok(isValidMathSymbol('\uD835', '\uDFFF')) // Valid end of math symbol block + assert.strictEqual(isValidMathSymbol('\uD835', '\uDC55'), false) // Excluded char + assert.strictEqual(isValidMathSymbol('a', 'b'), false) + assert.strictEqual(isValidMathSymbol('\uD835', ''), false) + }) + + it('isWhitespace', () => { + assert.ok(isWhitespace(' ', 0)) + assert.ok(isWhitespace('\t', 0)) + assert.ok(isWhitespace('\n', 1)) // Newline is whitespace if nestingLevel > 0 + assert.strictEqual(isWhitespace('\n', 0), false) // Newline is NOT plain whitespace if nestingLevel == 0 + assert.strictEqual(isWhitespace('a', 0), false) + }) + + it('isDecimalMark', () => { + assert.ok(isDecimalMark('.', '1')) + assert.strictEqual(isDecimalMark('.', '*'), false) + assert.strictEqual(isDecimalMark('.', '/'), false) + assert.strictEqual(isDecimalMark('.', '^'), false) + assert.strictEqual(isDecimalMark(',', '1'), false) + }) + + it('isDigitDot', () => { + assert.ok(isDigitDot('1')) + assert.ok(isDigitDot('.')) + assert.strictEqual(isDigitDot('a'), false) + }) + + it('isDigit', () => { + assert.ok(isDigit('0')) + assert.ok(isDigit('9')) + assert.strictEqual(isDigit('.'), false) + assert.strictEqual(isDigit('a'), false) + }) + + it('isHexDigit', () => { + assert.ok(isHexDigit('0')) + assert.ok(isHexDigit('9')) + assert.ok(isHexDigit('a')) + assert.ok(isHexDigit('f')) + assert.ok(isHexDigit('A')) + assert.ok(isHexDigit('F')) + assert.strictEqual(isHexDigit('g'), false) + assert.strictEqual(isHexDigit('G'), false) + assert.strictEqual(isHexDigit('.'), false) + }) + }) + + describe('Constants', () => { + it('TOKENTYPE should have correct values', () => { + assert.strictEqual(TOKENTYPE.NULL, 0) + assert.strictEqual(TOKENTYPE.DELIMITER, 1) + assert.strictEqual(TOKENTYPE.NUMBER, 2) + assert.strictEqual(TOKENTYPE.SYMBOL, 3) + assert.strictEqual(TOKENTYPE.UNKNOWN, 4) + }) + + it('DELIMITERS should contain common delimiters', () => { + assert.ok(DELIMITERS[',']) + assert.ok(DELIMITERS['(']) + assert.ok(DELIMITERS['+']) + assert.ok(DELIMITERS['==']) + }) + + it('NAMED_DELIMITERS should contain named operators', () => { + assert.ok(NAMED_DELIMITERS.mod) + assert.ok(NAMED_DELIMITERS.to) + assert.ok(NAMED_DELIMITERS.in) + assert.ok(NAMED_DELIMITERS.and) + }) + + it('ESCAPE_CHARACTERS should contain common escapes', () => { + assert.strictEqual(ESCAPE_CHARACTERS['"'], '"') + assert.strictEqual(ESCAPE_CHARACTERS.n, '\n') + assert.strictEqual(ESCAPE_CHARACTERS.t, '\t') + }) + + it('CONSTANTS should be defined', () => { + assert.strictEqual(CONSTANTS.true, true) + assert.strictEqual(CONSTANTS.false, false) + assert.strictEqual(CONSTANTS.null, null) + assert.strictEqual(CONSTANTS.undefined, undefined) + }) + + it('NUMERIC_CONSTANTS should be defined', () => { + assert.ok(NUMERIC_CONSTANTS.includes('NaN')) + assert.ok(NUMERIC_CONSTANTS.includes('Infinity')) + }) + }) + + // Minimal tests for currentCharacter, next, currentString as they are simple helpers + describe('State Helper Functions (Minimal)', () => { + it('currentCharacter should return current char', () => { + const state = createInitialState('abc') + assert.strictEqual(currentCharacter(state), 'a') + }) + it('next should advance index', () => { + const state = createInitialState('abc') + next(state) + assert.strictEqual(state.index, 1) + assert.strictEqual(currentCharacter(state), 'b') + }) + it('currentString should return substring', () => { + const state = createInitialState('hello') + assert.strictEqual(currentString(state, 2), 'he') + }) + }) +}) diff --git a/test/unit-tests/expression/node/ConstantNode.test.js b/test/unit-tests/expression/node/ConstantNode.test.js index 68d74020bf..c661bf256d 100644 --- a/test/unit-tests/expression/node/ConstantNode.test.js +++ b/test/unit-tests/expression/node/ConstantNode.test.js @@ -221,7 +221,7 @@ describe('ConstantNode', function () { it('should LaTeX a ConstantNode with custom toTex', function () { // Also checks if the custom functions get passed on to the children - const customFunction = function (node, options) { + const customFunction = function (node, _options) { if (node.type === 'ConstantNode') { return 'const\\left(' + node.value + '\\right)' } diff --git a/test/unit-tests/expression/node/FunctionAssignmentNode.test.js b/test/unit-tests/expression/node/FunctionAssignmentNode.test.js index f2347e35e4..b4e841a30f 100644 --- a/test/unit-tests/expression/node/FunctionAssignmentNode.test.js +++ b/test/unit-tests/expression/node/FunctionAssignmentNode.test.js @@ -179,7 +179,7 @@ describe('FunctionAssignmentNode', function () { }) it('should pass function arguments in scope to functions with rawArgs and transform', function () { - const outputScope = function (x) { + const outputScope = function (_x) { return 'should not occur' } outputScope.transform = function (args, math, scope) { diff --git a/test/unit-tests/expression/node/FunctionNode.test.js b/test/unit-tests/expression/node/FunctionNode.test.js index bc6c38426c..acc3fe1014 100644 --- a/test/unit-tests/expression/node/FunctionNode.test.js +++ b/test/unit-tests/expression/node/FunctionNode.test.js @@ -107,12 +107,12 @@ describe('FunctionNode', function () { it('should compile a FunctionNode with a raw function', function () { const mymath = math.create() - function myFunction (args, _math, _scope) { - assert.strictEqual(args.length, 2) - assert(args[0] instanceof mymath.Node) - assert(args[1] instanceof mymath.Node) + function myFunction (_args, _math, _scope) { + assert.strictEqual(_args.length, 2) + assert(_args[0] instanceof mymath.Node) + assert(_args[1] instanceof mymath.Node) assert.deepStrictEqual(toObject(_scope), scope) - return 'myFunction(' + args.join(', ') + ')' + return 'myFunction(' + _args.join(', ') + ')' } myFunction.rawArgs = true mymath.import({ myFunction }) @@ -472,10 +472,10 @@ describe('FunctionNode', function () { it('should stringify a FunctionNode with custom toString for a single function', function () { // Also checks if the custom functions get passed on to the children const customFunction = { - add: function (node, options) { - return node.args[0].toString(options) + + add: function (node, _options) { + return node.args[0].toString(_options) + ' ' + node.name + ' ' + - node.args[1].toString(options) + node.args[1].toString(_options) } } diff --git a/test/unit-tests/expression/node/IndexNode.test.js b/test/unit-tests/expression/node/IndexNode.test.js index bb866f91e4..d25d0cc0b3 100644 --- a/test/unit-tests/expression/node/IndexNode.test.js +++ b/test/unit-tests/expression/node/IndexNode.test.js @@ -99,7 +99,7 @@ describe('IndexNode', function () { it('should copy dotNotation property when mapping an IndexNode', function () { const b = new ConstantNode('objprop') const n = new IndexNode([b], true) - const f = n.map(function (node, path, parent) { + const f = n.map(function (node, _path, _parent) { return node }) diff --git a/test/unit-tests/expression/node/Node.test.js b/test/unit-tests/expression/node/Node.test.js index 2f6fe1d4c6..cbf4c18249 100644 --- a/test/unit-tests/expression/node/Node.test.js +++ b/test/unit-tests/expression/node/Node.test.js @@ -175,9 +175,9 @@ describe('Node', function () { }) it('should ignore custom toString if it returns nothing', function () { - const callback1 = function (node, callback) {} + const callback1 = function (_node, _callback) {} const callback2 = { - bla: function (node, callbacks) {} + bla: function (_node, _callbacks) {} } const mymath = math.create() mymath.Node.prototype._toString = function () { @@ -192,9 +192,9 @@ describe('Node', function () { }) it('should ignore custom toTex if it returns nothing', function () { - const callback1 = function (node, callback) {} + const callback1 = function (_node, _callback) {} const callback2 = { - bla: function (node, callbacks) {} + bla: function (_node, _callbacks) {} } const mymath = math.create() mymath.Node.prototype._toTex = function () { diff --git a/test/unit-tests/expression/node/ObjectNode.test.js b/test/unit-tests/expression/node/ObjectNode.test.js index f827182f08..59cba1480f 100644 --- a/test/unit-tests/expression/node/ObjectNode.test.js +++ b/test/unit-tests/expression/node/ObjectNode.test.js @@ -243,7 +243,7 @@ describe('ObjectNode', function () { }) it('should stringify an ObjectNode with custom toString', function () { - const customFunction = function (node, options) { + const customFunction = function (node, _options) { if (node.type === 'ConstantNode') { return 'const(' + node.value + ', ' + math.typeOf(node.value) + ')' } @@ -257,7 +257,7 @@ describe('ObjectNode', function () { }) it('should stringify an ObjectNode with custom toHTML', function () { - const customFunction = function (node, options) { + const customFunction = function (node, _options) { if (node.type === 'ConstantNode') { return 'const(' + node.value + ', ' + math.typeOf(node.value) + ')' } @@ -304,7 +304,7 @@ describe('ObjectNode', function () { }) it('should LaTeX an ObjectNode with custom toTex', function () { - const customFunction = function (node, options) { + const customFunction = function (node, _options) { if (node.type === 'ConstantNode') { return 'const\\left(' + node.value + ', ' + math.typeOf(node.value) + '\\right)' } diff --git a/test/unit-tests/expression/node/ParenthesisNode.test.js b/test/unit-tests/expression/node/ParenthesisNode.test.js index f3dd26c3d4..66421ac945 100644 --- a/test/unit-tests/expression/node/ParenthesisNode.test.js +++ b/test/unit-tests/expression/node/ParenthesisNode.test.js @@ -69,7 +69,7 @@ describe('ParenthesisNode', function () { let count = 0 - const c = b.map(function (node, path, _parent) { + const c = b.map(function (node, _path, _parent) { count++ assert.strictEqual(node.type, 'ConstantNode') assert.strictEqual(node.value, 1) diff --git a/test/unit-tests/expression/nodeParsers.test.js b/test/unit-tests/expression/nodeParsers.test.js new file mode 100644 index 0000000000..c7ca48dc42 --- /dev/null +++ b/test/unit-tests/expression/nodeParsers.test.js @@ -0,0 +1,190 @@ +import assert from 'assert' +import { parseStart, setDependencies } from '../../../src/expression/nodeParsers.js' +import { initialState, openParams, closeParams } from '../../../src/expression/parserState.js' +import { TOKENTYPE } from '../../../src/expression/lexer.js' +import * as isFunctions from '../../../src/utils/is.js' +import { hasOwnProperty } from '../../../src/utils/object.js' +import { safeNumberType } from '../../../src/utils/number.js' +import { createSyntaxError, createError } from '../../../src/expression/error.js' + +// Mock Node Constructors +const mockNodes = { + AccessorNode: function (...args) { this.name = 'AccessorNode'; this.args = args; }, + ArrayNode: function (...args) { this.name = 'ArrayNode'; this.args = args; }, + AssignmentNode: function (...args) { this.name = 'AssignmentNode'; this.args = args; }, + BlockNode: function (...args) { this.name = 'BlockNode'; this.args = args; }, + ConditionalNode: function (...args) { this.name = 'ConditionalNode'; this.args = args; }, + ConstantNode: function (...args) { this.name = 'ConstantNode'; this.args = args; }, + FunctionAssignmentNode: function (...args) { this.name = 'FunctionAssignmentNode'; this.args = args; }, + FunctionNode: function (...args) { this.name = 'FunctionNode'; this.args = args; }, + IndexNode: function (...args) { this.name = 'IndexNode'; this.args = args; }, + ObjectNode: function (...args) { this.name = 'ObjectNode'; this.args = args; }, + OperatorNode: function (...args) { this.name = 'OperatorNode'; this.args = args; }, + ParenthesisNode: function (...args) { this.name = 'ParenthesisNode'; this.args = args; }, + RangeNode: function (...args) { this.name = 'RangeNode'; this.args = args; }, + RelationalNode: function (...args) { this.name = 'RelationalNode'; this.args = args; }, + SymbolNode: function (...args) { this.name = 'SymbolNode'; this.args = args; } +} + +Object.values(mockNodes).forEach(MockNode => { + MockNode.prototype.isNode = true + if (MockNode.name === 'ConstantNode') MockNode.prototype.isConstantNode = true + if (MockNode.name === 'SymbolNode') MockNode.prototype.isSymbolNode = true + if (MockNode.name === 'OperatorNode') MockNode.prototype.isOperatorNode = true + if (MockNode.name === 'ParenthesisNode') MockNode.prototype.isParenthesisNode = true + // Add other is*Node properties to prototypes if a test fails due to it. + // For example, for parseAssignment testing isAccessorNode: + if (MockNode.name === 'AccessorNode') MockNode.prototype.isAccessorNode = true; + +}) + +// Mock numeric and config +const mockNumeric = (val) => parseFloat(val) // Simple mock +const mockConfig = { number: 'number' } // Simple mock + +// Mock Lexer +let tokenQueue = [] +const mockLexer = { + getToken: (state) => { + if (tokenQueue.length === 0) { + // Default to end-of-expression token if queue is empty and not explicitly set + state.token = '' + state.tokenType = TOKENTYPE.DELIMITER + return + } + const nextToken = tokenQueue.shift() + state.token = nextToken.token + state.tokenType = nextToken.tokenType + state.index += (nextToken.token || '').length // Simplistic index update + }, + getTokenSkipNewline: (state) => { // Simplified: assumes no newlines to skip for these tests + mockLexer.getToken(state) + } +} + +describe('nodeParsers.js', () => { + beforeEach(() => { + tokenQueue = [] // Reset token queue for each test + + // Call setDependencies with all required mocks and real functions + setDependencies({ + // Mocked dependencies + ...mockNodes, + numeric: mockNumeric, + config: mockConfig, + getToken: mockLexer.getToken, + getTokenSkipNewline: mockLexer.getTokenSkipNewline, + + // Real utility functions + ...isFunctions, + hasOwnProperty, + safeNumberType, + + // Real error functions + createSyntaxError, + createError, + + // Real parserState functions + initialState, + openParams, + closeParams, + + // Constants from lexer (simplified where possible) + TOKENTYPE, + DELIMITERS: { // Minimal set for basic tests, expand as needed + '': true, // End of expression + '+': true, + '-': true, + '*': true, + '/': true, + '(': true, + ')': true, + '[': true, + ']': true, + ',': true, + '.': true, + ';': true, + ':': true, + '=': true + }, + NAMED_DELIMITERS: { 'mod': true, 'to': true, 'in': true, 'and': true, 'xor': true, 'or': true, 'not': true }, + CONSTANTS: { true: true, false: false, null: null, undefined }, // Using global undefined + NUMERIC_CONSTANTS: ['NaN', 'Infinity'], + ESCAPE_CHARACTERS: { '\\"': '"', '\\\\': '\\', '\\n': '\n' } // Minimal + }) + }) + + it('should parse a simple number (testing parseNumber indirectly)', () => { + tokenQueue = [ + { token: '123', tokenType: TOKENTYPE.NUMBER }, + { token: '', tokenType: TOKENTYPE.DELIMITER } // End of expression + ] + const result = parseStart('123', {}) + assert.ok(result instanceof mockNodes.ConstantNode, 'Result should be a ConstantNode') + assert.deepStrictEqual(result.args, [123]) + }) + + it('should parse a symbol (testing parseSymbol indirectly)', () => { + tokenQueue = [ + { token: 'x', tokenType: TOKENTYPE.SYMBOL }, + { token: '', tokenType: TOKENTYPE.DELIMITER } + ] + const result = parseStart('x', {}) + assert.ok(result instanceof mockNodes.SymbolNode, 'Result should be a SymbolNode') + assert.deepStrictEqual(result.args, ['x']) + }) + + it('should parse "true" as a constant (testing parseSymbol indirectly)', () => { + tokenQueue = [ + { token: 'true', tokenType: TOKENTYPE.SYMBOL }, // Lexer identifies it as symbol first + { token: '', tokenType: TOKENTYPE.DELIMITER } + ] + const result = parseStart('true', {}) + assert.ok(result instanceof mockNodes.ConstantNode, 'Result should be a ConstantNode for "true"') + assert.deepStrictEqual(result.args, [true]) + }) + + it('should parse unary minus (testing parseUnary indirectly)', () => { + tokenQueue = [ + { token: '-', tokenType: TOKENTYPE.DELIMITER }, + { token: '5', tokenType: TOKENTYPE.NUMBER }, + { token: '', tokenType: TOKENTYPE.DELIMITER } + ] + const result = parseStart('-5', {}) + assert.ok(result instanceof mockNodes.OperatorNode, 'Result should be an OperatorNode') + assert.strictEqual(result.args[0], '-') + assert.strictEqual(result.args[1], 'unaryMinus') + assert.ok(result.args[2][0] instanceof mockNodes.ConstantNode) + assert.deepStrictEqual(result.args[2][0].args, [5]) + }) + + it('should parse addition (testing parseAddSubtract indirectly)', () => { + tokenQueue = [ + { token: '1', tokenType: TOKENTYPE.NUMBER }, + { token: '+', tokenType: TOKENTYPE.DELIMITER }, + { token: '2', tokenType: TOKENTYPE.NUMBER }, + { token: '', tokenType: TOKENTYPE.DELIMITER } + ] + const result = parseStart('1+2', {}) + assert.ok(result instanceof mockNodes.OperatorNode, 'Result should be an OperatorNode for addition') + assert.strictEqual(result.args[0], '+') + assert.strictEqual(result.args[1], 'add') + assert.ok(result.args[2][0] instanceof mockNodes.ConstantNode) + assert.deepStrictEqual(result.args[2][0].args, [1]) + assert.ok(result.args[2][1] instanceof mockNodes.ConstantNode) + assert.deepStrictEqual(result.args[2][1].args, [2]) + }) + + it('should parse parentheses (testing parseParentheses indirectly)', () => { + tokenQueue = [ + { token: '(', tokenType: TOKENTYPE.DELIMITER }, + { token: '1', tokenType: TOKENTYPE.NUMBER }, + { token: ')', tokenType: TOKENTYPE.DELIMITER }, + { token: '', tokenType: TOKENTYPE.DELIMITER } + ] + const result = parseStart('(1)', {}) + assert.ok(result instanceof mockNodes.ParenthesisNode, 'Result should be a ParenthesisNode') + assert.ok(result.args[0] instanceof mockNodes.ConstantNode) + assert.deepStrictEqual(result.args[0].args, [1]) + }) +}) diff --git a/test/unit-tests/expression/parse.test.js b/test/unit-tests/expression/parse.test.js index 92a1871b5f..0701264278 100644 --- a/test/unit-tests/expression/parse.test.js +++ b/test/unit-tests/expression/parse.test.js @@ -2433,16 +2433,16 @@ describe('parse', function () { CustomNode.prototype.toString = function () { return 'CustomNode' } - CustomNode.prototype._compile = function (math, argNames) { + CustomNode.prototype._compile = function (_math, _argNames) { const strArgs = [] this.args.forEach(function (arg) { strArgs.push(arg.toString()) }) - return function (scope, args, context) { + return function (_scope, _args, _context) { return 'CustomNode(' + strArgs.join(', ') + ')' } } - CustomNode.prototype.forEach = function (callback) { + CustomNode.prototype.forEach = function (_callback) { // we don't have childs } @@ -2507,7 +2507,7 @@ describe('parse', function () { const originalIsAlpha = math.parse.isAlpha // override isAlpha with one accepting $ characters too - math.parse.isAlpha = function (c, cPrev, cNext) { + math.parse.isAlpha = function (c, _cPrev, _cNext) { return /^[a-zA-Z_$]$/.test(c) } @@ -2525,7 +2525,9 @@ describe('parse', function () { try { mathClone.evaluate('f(x)=1;config({clone:f})') - } catch (err) { } + } catch (err) { + // Expected: do nothing, the test is to ensure the evaluate call doesn't crash. + } assert.strictEqual(mathClone.evaluate('2'), 2) }) diff --git a/test/unit-tests/expression/parserState.test.js b/test/unit-tests/expression/parserState.test.js new file mode 100644 index 0000000000..2384c5b20a --- /dev/null +++ b/test/unit-tests/expression/parserState.test.js @@ -0,0 +1,67 @@ +import assert from 'assert' +import { + initialState, + openParams, + closeParams +} from '../../../src/expression/parserState.js' +import { TOKENTYPE } from '../../../src/expression/lexer.js' + +describe('parserState.js', () => { + describe('initialState', () => { + it('should return a state object with correct default values', () => { + const state = initialState() + assert.deepStrictEqual(state.extraNodes, {}) + assert.strictEqual(state.expression, '') + assert.strictEqual(state.comment, '') + assert.strictEqual(state.index, 0) + assert.strictEqual(state.token, '') + assert.strictEqual(state.tokenType, TOKENTYPE.NULL) + assert.strictEqual(state.nestingLevel, 0) + assert.strictEqual(state.conditionalLevel, null) + }) + }) + + describe('openParams', () => { + it('should increment nestingLevel by 1', () => { + const state = { nestingLevel: 0 } + openParams(state) + assert.strictEqual(state.nestingLevel, 1) + }) + + it('should increment nestingLevel multiple times', () => { + const state = { nestingLevel: 1 } + openParams(state) + assert.strictEqual(state.nestingLevel, 2) + openParams(state) + assert.strictEqual(state.nestingLevel, 3) + }) + }) + + describe('closeParams', () => { + it('should decrement nestingLevel by 1', () => { + const state = { nestingLevel: 2 } + closeParams(state) + assert.strictEqual(state.nestingLevel, 1) + }) + + it('should decrement nestingLevel multiple times', () => { + const state = { nestingLevel: 3 } + closeParams(state) + assert.strictEqual(state.nestingLevel, 2) + closeParams(state) + assert.strictEqual(state.nestingLevel, 1) + }) + + it('should handle decrementing to zero', () => { + const state = { nestingLevel: 1 } + closeParams(state) + assert.strictEqual(state.nestingLevel, 0) + }) + + it('should handle decrementing below zero (though not typical in parsing logic)', () => { + const state = { nestingLevel: 0 } + closeParams(state) + assert.strictEqual(state.nestingLevel, -1) // Behavior is to just decrement + }) + }) +})