diff --git a/README.md b/README.md index 1b0d90d..ee9dcf8 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,81 @@ jsep.addIdentifierChar("@"); jsep.removeIdentifierChar('@'); ``` +### Plugins +jsep supports defining custom hooks for extending or modifying the expression parsing. +All hooks are called with a single argument and return void. +The hook argument provides access to the internal parsing methods of jsep +to allow reuse as needed. + +#### Hook Argument + +```typescript +import { PossibleExpression } from 'jsep'; + +export interface HookScope { + index: number; + expr: string; + exprI: string; + exprICode: () => number; + gobbleSpaces: () => void; + gobbleExpression: () => Expression; + gobbleBinaryOp: () => PossibleExpression; + gobbleBinaryExpression: () => PossibleExpression; + gobbleToken: () => PossibleExpression; + gobbleNumericLiteral: () => PossibleExpression; + gobbleStringLiteral: () => PossibleExpression; + gobbleIdentifier: () => PossibleExpression; + gobbleArguments: (number) => PossibleExpression; + gobbleGroup: () => Expression; + gobbleArray: () => PossibleExpression; + throwError: (string) => void; + nodes?: PossibleExpression[]; + node?: PossibleExpression; +} +``` + +#### Hook Types +* `before-all`: called just before starting all expression parsing +* `after-all`: called just before returning from parsing +* `gobble-expression`: called just before attempting to parse an expression +* `after-expression`: called just after parsing an expression +* `gobble-token`: called just before attempting to parse a token +* `after-token`: called just after parsing a token +* `gobble-spaces`: called when gobbling spaces + +### How to add Hooks +```javascript +// single: +jsep.hooks.add('after-expression', function(env) { + console.log('got expression', JSON.stringify(env.node, null, 2)); +}); +// last argument will add to the top of the array, instead of the bottom by default +jsep.hooks.add('after-all', () => console.log('done'), true); + +// multi: +const myHooks = { + 'before-all': env => console.log(`parsing ${env.expr}`), + 'after-all': env => console.log(`found ${env.nodes.length} nodes`); +}; +jsep.hooks.add(myHooks); +``` + +#### How to add plugins: +```javascript +const jsep = require('jsep'); +const plugins = require('jsep/plugins'); +plugins.forEach(p => p(jsep)); +``` + +#### Optional Plugins: +* `arrowFunction`: Adds arrow-function support, `(a) => x`, `x => x` +* `assignment`: Adds support for assignment expressions +* `ignoreComments`: Adds support for ignoring comments: `// comment` and `/* comment */` +* `new`: Adds support for the `new` operator +* `object`: Adds support for object expressions +* `templateLiteral`: Adds support for template literals, `` `this ${value + `${nested}}` `` +* `ternary`: Built-in by default, adds support for ternary `a ? b : c` expressions + ### License jsep is under the MIT license. See LICENSE file. diff --git a/package.json b/package.json index dfdaa27..5e6504f 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,6 @@ "build:watch": "npx rollup -c --watch", "test": "npx http-server -p 49649 --silent & npx node-qunit-puppeteer http://localhost:49649/test/unit_tests.html", "docco": "npx docco src/jsep.js --css=src/docco.css --output=annotated_source/", - "lint": "npx eslint src/jsep.js" + "lint": "npx eslint src/jsep.js plugins/**/*.js" } } diff --git a/plugins/arrow-function/jsep-arrow-function.js b/plugins/arrow-function/jsep-arrow-function.js new file mode 100644 index 0000000..8fa19a8 --- /dev/null +++ b/plugins/arrow-function/jsep-arrow-function.js @@ -0,0 +1,40 @@ +export default function (jsep) { + if (typeof jsep === 'undefined') { + return; + } + + const EQUAL_CODE = 61; // = + const GTHAN_CODE = 62; // > + const ARROW_EXP = 'ArrowFunctionExpression'; + jsep.addBinaryOp('=>', 0); + + jsep.hooks.add('gobble-expression', function gobbleArrowExpression(env) { + if (env.exprICode(env.index) === EQUAL_CODE) { + // arrow expression: () => expr + env.index++; + if (env.exprICode(env.index) === GTHAN_CODE) { + env.index++; + env.node = { + type: ARROW_EXP, + params: env.node ? [env.node] : null, + body: env.gobbleExpression(), + }; + } + else { + env.throwError('Expected >'); + } + } + }); + + // This is necessary when adding '=' as a binary operator (for assignment) + // Otherwise '>' throws an error for the right-hand side + jsep.hooks.add('after-expression', function fixBinaryArrow(env) { + if (env.node && env.node.operator === '=>') { + env.node = { + type: 'ArrowFunctionExpression', + params: env.node.left ? [env.node.left] : null, + body: env.node.right, + }; + } + }); +}; diff --git a/plugins/assignment/jsep-assignment.js b/plugins/assignment/jsep-assignment.js new file mode 100644 index 0000000..3412a50 --- /dev/null +++ b/plugins/assignment/jsep-assignment.js @@ -0,0 +1,28 @@ +export default function (jsep) { + if (typeof jsep === 'undefined') { + return; + } + + const assignmentOperators = new Set([ + '=', + '*=', + '**=', + '/=', + '%=', + '+=', + '-=', + '<<=', + '>>=', + '>>>=', + '&=', + '^=', + '|=', + ]); + assignmentOperators.forEach(op => jsep.addBinaryOp(op, 0.9)); + + jsep.hooks.add('after-expression', function gobbleAssignment(env) { + if (assignmentOperators.has(env.node.operator)) { + env.node.type = 'AssignmentExpression'; + } + }); +}; diff --git a/plugins/ignore-comments/jsep-ignore-comments.js b/plugins/ignore-comments/jsep-ignore-comments.js new file mode 100644 index 0000000..2b4a76d --- /dev/null +++ b/plugins/ignore-comments/jsep-ignore-comments.js @@ -0,0 +1,36 @@ +export default function (jsep) { + if (typeof jsep === 'undefined') { + return; + } + + const FSLSH_CODE = 47; // / + const ASTSK_CODE = 42; // * + const LF_CODE = 10; + + jsep.hooks.add('gobble-spaces', function gobbleComment(env) { + if (env.exprICode(env.index) === FSLSH_CODE) { + let ch = env.exprICode(env.index + 1); + if (ch === FSLSH_CODE) { + // read to end of line + env.index += 2; + while (ch !== LF_CODE && !isNaN(ch)) { + ch = env.exprICode(++env.index); + } + } + else if (ch === ASTSK_CODE) { + // read to */ or end of input + env.index += 2; + while (!isNaN(ch)) { + ch = env.exprICode(++env.index); + if (ch === ASTSK_CODE) { + ch = env.exprICode(++env.index); + if (ch === FSLSH_CODE) { + env.index += 1; + break; + } + } + } + } + } + }); +}; diff --git a/plugins/index.js b/plugins/index.js new file mode 100644 index 0000000..ecc5b0c --- /dev/null +++ b/plugins/index.js @@ -0,0 +1,24 @@ +import arrowFunction from './arrow-function/jsep-arrow-function.js'; +import assignment from './assignment/jsep-assignment.js'; +import ignoreComments from './ignore-comments/jsep-ignore-comments.js'; +import newExpression from './new/jsep-new.js'; +import object from './object/jsep-object.js'; +import templateLiteral from './template-literal/jsep-template-literal.js'; + +export { + arrowFunction, + assignment, + ignoreComments, + newExpression, + object, + templateLiteral, +}; + +export default [ + arrowFunction, + assignment, + ignoreComments, + newExpression, + object, + templateLiteral, +]; diff --git a/plugins/new/jsep-new.js b/plugins/new/jsep-new.js new file mode 100644 index 0000000..fb82026 --- /dev/null +++ b/plugins/new/jsep-new.js @@ -0,0 +1,18 @@ +export default function (jsep) { + if (typeof jsep === 'undefined') { + return; + } + + jsep.addUnaryOp('new'); + + jsep.hooks.add('after-token', function gobbleNew(env) { + const node = env.node; + if (node && node.operator === 'new') { + if (!node.argument || node.argument.type !== 'CallExpression') { + env.throwError('Expected new function()'); + } + env.node = node.argument; + env.node.type = 'NewExpression'; + } + }); +}; diff --git a/plugins/object/jsep-object.js b/plugins/object/jsep-object.js new file mode 100644 index 0000000..acd4332 --- /dev/null +++ b/plugins/object/jsep-object.js @@ -0,0 +1,66 @@ +export default function (jsep) { + if (typeof jsep === 'undefined') { + return; + } + + const PERIOD_CODE = 46; // '.' + const OCURLY_CODE = 123; // { + const CCURLY_CODE = 125; // } + const OBJECT_EXPRESSION = 'ObjectExpression'; + const PROPERTY = 'Property'; + const SPREAD_ELEMENT = 'SpreadElement'; + jsep.addBinaryOp(':', 0.5); + + const gobbleObject = function (type) { + return function (env) { + if (env.exprICode(env.index) === OCURLY_CODE) { + env.index++; + const args = env.gobbleArguments(CCURLY_CODE); + const properties = args.map((arg) => { + if (arg.type === 'SpreadElement') { + return arg; + } + if (arg.type === 'Identifier') { + return { + type: PROPERTY, + computed: false, + key: arg.name, + shorthand: true, + }; + } + if (arg.type === 'BinaryExpression') { + const computed = arg.left.type === 'ArrayExpression'; + return { + type: PROPERTY, + computed, + key: computed + ? arg.left.elements[0] + : arg.left, + value: arg.right, + shorthand: false, + }; + } + env.throwError('Unexpected object property'); + }); + + env.node = { + type, + properties, + }; + } + }; + }; + jsep.hooks.add('gobble-expression', gobbleObject(OBJECT_EXPRESSION)); + jsep.hooks.add('after-token', gobbleObject(OBJECT_EXPRESSION)); + + jsep.hooks.add('gobble-token', function gobbleSpread(env) { + // check for spread operator: + if ([0, 1, 2].every(i => env.exprICode(env.index + i) === PERIOD_CODE)) { + env.index += 3; + env.node = { + type: SPREAD_ELEMENT, + argument: env.gobbleExpression(), + }; + } + }); +}; diff --git a/plugins/template-literal/jsep-template-literal.js b/plugins/template-literal/jsep-template-literal.js new file mode 100644 index 0000000..fa66e85 --- /dev/null +++ b/plugins/template-literal/jsep-template-literal.js @@ -0,0 +1,94 @@ +export default function (jsep) { + if (typeof jsep === 'undefined') { + return; + } + + const BTICK_CODE = 96; // ` + const CCURLY_CODE = 125; // } + const TAGGED_TEMPLATE_EXPRESSION = 'TaggedTemplateExpression'; + const TEMPLATE_LITERAL = 'TemplateLiteral'; + const TEMPLATE_ELEMENT = 'TemplateElement'; + const IDENTIFIER = 'Identifier'; + + jsep.hooks.add('after-token', function gobbleTaggedTemplateIdentifier(env) { + if (env.node.type === IDENTIFIER && env.exprICode(env.index) === BTICK_CODE) { + env.node = { + type: TAGGED_TEMPLATE_EXPRESSION, + tag: env.node, + quasi: gobbleTemplateLiteral(env), + }; + } + }); + + jsep.hooks.add('gobble-token', gobbleTemplateLiteral); + + function gobbleTemplateLiteral(env) { + if (env.exprICode(env.index) === BTICK_CODE) { + const node = { + type: TEMPLATE_LITERAL, + quasis: [], + expressions: [], + }; + let cooked = ''; + let raw = ''; + let closed = false; + const length = env.expr.length; + const pushQuasi = () => node.quasis.push({ + type: TEMPLATE_ELEMENT, + value: { + raw, + cooked, + }, + tail: closed, + }); + + while (env.index < length) { + let ch = env.exprI(++env.index); + + if (ch === '`') { + env.index += 1; + closed = true; + break; + } + else if (ch === '$' && env.exprI(env.index + 1) === '{') { + env.index += 2; + pushQuasi(); + raw = ''; + cooked = ''; + node.expressions = env.gobbleExpressions(CCURLY_CODE); + if (env.exprICode(env.index) !== CCURLY_CODE) { + env.throwError('unclosed ${'); + } + } + else if (ch === '\\') { + // Check for all of the common escape codes + raw += ch; + ch = env.exprI(env.index++); + raw += ch; + + switch (ch) { + case 'n': cooked += '\n'; break; + case 'r': cooked += '\r'; break; + case 't': cooked += '\t'; break; + case 'b': cooked += '\b'; break; + case 'f': cooked += '\f'; break; + case 'v': cooked += '\x0B'; break; + default : cooked += ch; + } + } + else { + cooked += ch; + raw += ch; + } + } + + if (!closed) { + env.throwError('Unclosed `'); + } + pushQuasi(); + + env.node = node; + return node; + } + } +}; diff --git a/src/jsep.js b/src/jsep.js index 663521d..2bd3c27 100644 --- a/src/jsep.js +++ b/src/jsep.js @@ -136,6 +136,7 @@ let jsep = function(expr) { return charCodeAtFunc.call(expr, i); }; let length = expr.length; + const hooks = jsep.hooks; // Push `index` up to the next non-space character let gobbleSpaces = function() { @@ -144,47 +145,52 @@ let jsep = function(expr) { while (ch === SPACE_CODE || ch === TAB_CODE || ch === LF_CODE || ch === CR_CODE) { ch = exprICode(++index); } + hooks.run('gobble-spaces', hookScope); }; - // The main parsing function. Much of this code is dedicated to ternary expressions - let gobbleExpression = function() { - let test = gobbleBinaryExpression(); - let consequent, alternate; - - gobbleSpaces(); - - if (exprICode(index) === QUMARK_CODE) { - // Ternary expression: test ? consequent : alternate - index++; - consequent = gobbleExpression(); - - if (!consequent) { - throwError('Expected expression', index); - } + let gobbleExpressions = function(untilICode) { + let nodes = [], ch_i, node; - gobbleSpaces(); + while (index < length) { + hooks.run('before-expression', hookScope); - if (exprICode(index) === COLON_CODE) { - index++; - alternate = gobbleExpression(); + ch_i = exprICode(index); - if (!alternate) { - throwError('Expected expression', index); - } - return { - type: CONDITIONAL_EXP, - test, - consequent, - alternate - }; + // Expressions can be separated by semicolons, commas, or just inferred without any + // separators + if (ch_i === SEMCOL_CODE || ch_i === COMMA_CODE) { + index++; // ignore separators } else { - throwError('Expected :', index); + // Try to gobble each expression individually + if (node = gobbleExpression()) { + nodes.push(node); + // If we weren't able to find a binary expression and are out of room, then + // the expression passed in probably has too much + } + else if (index < length) { + if (untilICode && ch_i === untilICode) { + break; + } + throwError('Unexpected "' + exprI(index) + '"', index); + } } } - else { - return test; + + return nodes; + }; + + // The main parsing function. Much of this code is dedicated to ternary expressions + let gobbleExpression = function() { + hookScope.node = false; + hooks.run('gobble-expression', hookScope); + if (!hookScope.node) { + hookScope.node = gobbleBinaryExpression(); } + gobbleSpaces(); + + hooks.run('after-expression', hookScope); + return hookScope.node; }; // Search for the operation portion of the string (e.g. `+`, `===`) @@ -193,7 +199,7 @@ let jsep = function(expr) { // then, return that binary operation let gobbleBinaryOp = function() { gobbleSpaces(); - let biop, to_check = expr.substr(index, max_binop_len); + let to_check = expr.substr(index, max_binop_len); let tc_len = to_check.length; while (tc_len > 0) { @@ -215,7 +221,7 @@ let jsep = function(expr) { // This function is responsible for gobbling an individual expression, // e.g. `1`, `1+2`, `a+(b*2)-Math.sqrt(2)` let gobbleBinaryExpression = function() { - let ch_i, node, biop, prec, stack, biop_info, left, right, i, cur_biop; + let node, biop, prec, stack, biop_info, left, right, i, cur_biop; // First, try to get the leftmost thing // Then, check to see if there's a binary operator operating on that leftmost thing @@ -277,7 +283,6 @@ let jsep = function(expr) { node = createBinaryExpression(stack[i - 1].value, stack[i - 2], node); i -= 2; } - return node; }; @@ -287,6 +292,13 @@ let jsep = function(expr) { let ch, to_check, tc_len, node; gobbleSpaces(); + hookScope.node = null; + hooks.run('gobble-token', hookScope); + if (hookScope.node) { + hooks.run('after-token', hookScope); + return hookScope.node; + } + ch = exprICode(index); if (isDecimalDigit(ch) || ch === PERIOD_CODE) { @@ -314,12 +326,14 @@ let jsep = function(expr) { (index+to_check.length < expr.length && !isIdentifierPart(exprICode(index+to_check.length))) )) { index += tc_len; - return { + hookScope.node = { type: UNARY_EXP, operator: to_check, argument: gobbleToken(), prefix: true }; + hooks.run('after-token', hookScope); + return hookScope.node; } to_check = to_check.substr(0, --tc_len); @@ -334,7 +348,9 @@ let jsep = function(expr) { } if (!node) { - return false; + hookScope.node = false; + hooks.run('after-token', hookScope); + return hookScope.node; } gobbleSpaces(); @@ -384,7 +400,9 @@ let jsep = function(expr) { ch = exprICode(index); } - return node; + hookScope.node = node; + hooks.run('after-token', hookScope); + return hookScope.node; }; // Parse simple numeric literals: `12`, `3.4`, `.5`. Do this by using a string to @@ -600,6 +618,18 @@ let jsep = function(expr) { index++; return node; } + else if (exprICode(index) === COMMA_CODE) { + // arrow function arguments: + index++; + let params = gobbleArguments(CPAREN_CODE); + params.unshift(node); + node = gobbleExpression(); + if (!node) { + throwError('Unclosed (', index); + } + node.params = params; + return node; + } else { throwError('Unclosed (', index); } @@ -617,37 +647,46 @@ let jsep = function(expr) { }; }; - let nodes = [], ch_i, node; + const hookScope = { + get index() { + return index; + }, + set index(v) { + index = v; + }, + expr, + exprI, + exprICode, + gobbleSpaces, + gobbleExpressions, + gobbleExpression, + gobbleBinaryOp, + gobbleBinaryExpression, + gobbleToken, + gobbleNumericLiteral, + gobbleStringLiteral, + gobbleIdentifier, + gobbleArguments, + gobbleGroup, + gobbleArray, + throwError: (e) => throwError(e, index), + // node, + // nodes, + }; - while (index < length) { - ch_i = exprICode(index); - // Expressions can be separated by semicolons, commas, or just inferred without any - // separators - if (ch_i === SEMCOL_CODE || ch_i === COMMA_CODE) { - index++; // ignore separators - } - else { - // Try to gobble each expression individually - if (node = gobbleExpression()) { - nodes.push(node); - // If we weren't able to find a binary expression and are out of room, then - // the expression passed in probably has too much - } - else if (index < length) { - throwError('Unexpected "' + exprI(index) + '"', index); - } - } - } + hooks.run('before-all', hookScope); + hookScope.nodes = gobbleExpressions(); + hooks.run('after-all', hookScope); // If there's only one expression just try returning the expression - if (nodes.length === 1) { - return nodes[0]; + if (hookScope.nodes.length === 1) { + return hookScope.nodes[0]; } else { return { type: COMPOUND, - body: nodes + body: hookScope.nodes, }; } }; @@ -658,6 +697,54 @@ jsep.toString = function() { return 'JavaScript Expression Parser (JSEP) v' + jsep.version; }; +jsep.hooks = { + /** + * Adds the given callback to the list of callbacks for the given hook. + * + * The callback will be invoked when the hook it is registered for is run. + * + * One callback function can be registered to multiple hooks and the same hook multiple times. + * + * @param {string|object} name The name of the hook, or an object of callbacks keyed by name + * @param {HookCallback|boolean} callback The callback function which is given environment variables. + * @param {?boolean} [first=false] Will add the hook to the top of the list (defaults to the bottom) + * @public + */ + add: function(name, callback, first) { + if (typeof arguments[0] != 'string') { + // Multiple hook callbacks, keyed by name + for (let name in arguments[0]) { + this.add(name, arguments[0][name], arguments[1]); + } + } + else { + (Array.isArray(name) ? name : [name]).forEach(function(name) { + this[name] = this[name] || []; + + if (callback) { + this[name][first ? 'unshift' : 'push'](callback); + } + }, this); + } + }, + + /** + * Runs a hook invoking all registered callbacks with the given environment variables. + * + * Callbacks will be invoked synchronously and in the order in which they were registered. + * + * @param {string} name The name of the hook. + * @param {Object} env The environment variables of the hook passed to all callbacks registered. + * @public + */ + run: function(name, env) { + this[name] = this[name] || []; + this[name].forEach(function (callback) { + callback.call(env && env.context ? env.context : env, env); + }); + }, +}; + /** * @method jsep.addUnaryOp * @param {string} op_name The name of the unary op to add @@ -736,7 +823,6 @@ jsep.removeIdentifierChar = function(char) { return this; }; - /** * @method jsep.removeBinaryOp * @param {string} op_name The name of the binary op to remove @@ -783,4 +869,42 @@ jsep.removeAllLiterals = function() { return this; }; +// ternary support as a plugin: +jsep.hooks.add('after-expression', function gobbleTernary(env) { + if (env.exprICode(env.index) === QUMARK_CODE) { + // Ternary expression: test ? consequent : alternate + env.index++; + const test = env.node; + let consequent, alternate; + consequent = env.gobbleExpression(); + if (!consequent) { + env.throwError('Expected expression'); + } + + env.gobbleSpaces(); + if (env.exprICode(env.index) === COLON_CODE) { + env.index++; + alternate = env.gobbleExpression(); + if (!alternate) { + env.throwError('Expected expression'); + } + } + else if (consequent.operator === ':') { + // (object support is enabled) + alternate = consequent.right; + consequent = consequent.left; + } + else { + env.throwError('Expected :'); + } + + env.node = { + type: CONDITIONAL_EXP, + test, + consequent, + alternate, + }; + } +}); + export default jsep; diff --git a/test/tests.js b/test/tests.js index 8cd318f..e6849ff 100644 --- a/test/tests.js +++ b/test/tests.js @@ -1,6 +1,9 @@ import jsep from "../src/jsep.js"; +import plugins from '../plugins/index.js'; (function() { +plugins.forEach(p => p(jsep)); + var binops = { "+" : function(a, b) { return a + b; }, "-" : function(a, b) { return a - b; }, @@ -32,7 +35,7 @@ var filter_props = function(larger, smaller) { var prop_val; for(var prop_name in smaller) { prop_val = smaller[prop_name]; - if(typeof prop_val === 'string' || typeof prop_val === 'number') { + if(typeof prop_val === 'string' || typeof prop_val === 'number' || typeof prop_val === 'boolean' || prop_val === null) { rv[prop_name] = larger[prop_name]; } else { rv[prop_name] = filter_props(larger[prop_name], prop_val); @@ -68,86 +71,491 @@ QUnit.test('Variables', function(assert) { type: "MemberExpression" } }, assert); - test_parser("Δέλτα", {name: "Δέλτα"}, assert); + test_parser("Δέλτα", {name: "Δέλτα"}, assert); }); QUnit.test('Function Calls', function(assert) { - //test_parser("a(b, c(d,e), f)", {}); - test_parser("a b + c", {}, assert); - test_parser("'a'.toString()", {}, assert); - test_parser("[1].length", {}, assert); - test_parser(";", {}, assert); + // test_parser("a(b, c(d,e), f)", {}); + test_parser("a b + c", { + type: 'Compound', + body: [ + { + type: 'Identifier', + name: 'a', + }, + { + type: 'BinaryExpression', + operator: '+', + left: { + type: 'Identifier', + name: 'b', + }, + right: { + type: 'Identifier', + name: 'c', + }, + }, + ], + }, assert); + test_parser("'a'.toString()", { + type: 'CallExpression', + arguments: [], + callee: { + type: 'MemberExpression', + computed: false, + object: { + type: 'Literal', + value: 'a', + raw: '\'a\'', + }, + property: { + type: 'Identifier', + name: 'toString', + }, + }, + }, assert); + test_parser("[1].length", { + type: "MemberExpression", + computed: false, + object: { + type: "ArrayExpression", + elements: [ + { + type: "Literal", + value: 1, + raw: "1" + } + ] + }, + property: { + type: "Identifier", + name: "length" + } + }, assert); + test_parser(";", { + type: "Compound", + body: [] + }, assert); + test_parser("a().b(1)", { + type: "CallExpression", + arguments: [ + { + type: "Literal", + value: 1, + raw: "1" + } + ], + callee: { + type: "MemberExpression", + computed: false, + object: { + type: "CallExpression", + arguments: [], + callee: { + type: "Identifier", + name: "a" + } + }, + property: { + type: "Identifier", + name: "b" + } + } + }, assert); }); -QUnit.test('Arrays', function(assert) { - test_parser("[]", {type: 'ArrayExpression', elements: []}, assert); +QUnit.test('Plugins', function(assert) { + // arrow functions: + test_parser('a.find(() => true)', { + type: "CallExpression", + arguments: [ + { + type: "ArrowFunctionExpression", + params: null, + body: { + type: "Literal", + value: true, + raw: "true" + } + } + ], + callee: { + type: "MemberExpression", + computed: false, + object: { + type: "Identifier", + name: "a" + }, + property: { + type: "Identifier", + name: "find" + } + } + }, assert); - test_parser("[a]", { - type: 'ArrayExpression', - elements: [{type: 'Identifier', name: 'a'}] + test_parser('[1, 2].find(v => v === 2)', { + type: "CallExpression", + arguments: [ + { + type: "ArrowFunctionExpression", + params: [ + { + type: "Identifier", + name: "v" + } + ], + body: { + type: "BinaryExpression", + operator: "===", + left: { + type: "Identifier", + name: "v" + }, + right: { + type: "Literal", + value: 2, + raw: "2" + } + } + } + ], + callee: { + type: "MemberExpression", + computed: false, + object: { + type: "ArrayExpression", + elements: [ + { + type: "Literal", + value: 1, + raw: "1" + }, + { + type: "Literal", + value: 2, + raw: "2" + } + ] + }, + property: { + type: "Identifier", + name: "find" + } + } }, assert); -}); -QUnit.test('Ops', function(assert) { - test_op_expession("1", assert); - test_op_expession("1+2", assert); - test_op_expession("1*2", assert); - test_op_expession("1*(2+3)", assert); - test_op_expession("(1+2)*3", assert); - test_op_expession("(1+2)*3+4-2-5+2/2*3", assert); - test_op_expession("1 + 2- 3* 4 /8", assert); - test_op_expession("\n1\r\n+\n2\n", assert); -}); + test_parser('a.find((val, key) => key === "abc")', { + type: "CallExpression", + arguments: [ + { + type: "ArrowFunctionExpression", + params: [ + { + type: "Identifier", + name: "val" + }, + { + type: "Identifier", + name: "key" + } + ], + body: { + type: "BinaryExpression", + operator: "===", + left: { + type: "Identifier", + name: "key" + }, + right: { + type: "Literal", + value: "abc", + raw: "\"abc\"" + } + } + } + ], + callee: { + type: "MemberExpression", + computed: false, + object: { + type: "Identifier", + name: "a" + }, + property: { + type: "Identifier", + name: "find" + } + } + }, assert); + test_parser("(['a', 'b'].find(v => v === 'b').length > 1 || 2) === true", {}, assert); + test_parser('a.find(val => key === "abc")', {}, assert); + test_parser("a.find(() => []).length > 2", {}, assert); + test_parser('(a || b).find(v => v(1))', {}, assert); -QUnit.test('Custom operators', function(assert) { - jsep.addBinaryOp("^", 10); - test_parser("a^b", {}, assert); + // object expression/literal: + test_parser('({ a: 1, b: 2 })', { + type: "ObjectExpression", + properties: [ + { + type: "Property", + computed: false, + key: { + type: "Identifier", + name: "a" + }, + value: { + type: "Literal", + value: 1, + raw: "1" + }, + shorthand: false + }, + { + type: "Property", + computed: false, + key: { + type: "Identifier", + name: "b" + }, + value: { + type: "Literal", + value: 2, + raw: "2" + }, + shorthand: false + } + ] + }, assert); - jsep.addBinaryOp("×", 9); - test_parser("a×b", { - type: 'BinaryExpression', - left: {name: 'a'}, - right: {name: 'b'} - }, assert); + test_parser('{ [key || key2]: { a: 0 } }', { + type: "ObjectExpression", + properties: [ + { + type: "Property", + computed: true, + key: { + type: "BinaryExpression", + operator: "||", + left: { + type: "Identifier", + name: "key" + }, + right: { + type: "Identifier", + name: "key2" + } + }, + value: { + type: "ObjectExpression", + properties: [ + { + type: "Property", + computed: false, + key: { + type: "Identifier", + name: "a" + }, + value: { + type: "Literal", + value: 0, + raw: "0" + }, + shorthand: false + } + ] + }, + shorthand: false + } + ] + }, assert); - jsep.addBinaryOp("or", 1); - test_parser("oneWord ordering anotherWord", { - type: 'Compound', - body: [ + test_parser('{ a: !1, ...b, c, ...(a || b) }', { + type: "ObjectExpression", + properties: [ { - type: 'Identifier', - name: 'oneWord' + type: "Property", + computed: false, + key: { + type: "Identifier", + name: "a" + }, + value: { + type: "UnaryExpression", + operator: "!", + argument: { + type: "Literal", + value: 1, + raw: "1" + }, + prefix: true + }, + shorthand: false }, { - type: 'Identifier', - name: 'ordering' + type: "SpreadElement", + argument: { + type: "Identifier", + name: "b" + } }, { - type: 'Identifier', - name: 'anotherWord' + type: "Property", + computed: false, + key: "c", + shorthand: true + }, + { + type: "SpreadElement", + argument: { + type: "BinaryExpression", + operator: "||", + left: { + type: "Identifier", + name: "a" + }, + right: { + type: "Identifier", + name: "b" + } + } } ] - }, assert); + }, assert); - jsep.addUnaryOp("#"); - test_parser("#a", { - type: "UnaryExpression", - operator: "#", - argument: {type: "Identifier", name: "a"} + // assignment + test_parser('a = 2', { + type: "AssignmentExpression", + operator: "=", + left: { + type: "Identifier", + name: "a" + }, + right: { + type: "Literal", + value: 2, + raw: "2" + } + }, assert); + test_parser('a += 2', { + type: 'AssignmentExpression', + operator: '+=', }, assert); - jsep.addUnaryOp("not"); - test_parser("not a", { - type: "UnaryExpression", - operator: "not", - argument: {type: "Identifier", name: "a"} + // ignore comments + test_parser('a /* ignore this */ > 1 // ignore this too', { + type: 'BinaryExpression', + operator: '>', + left: { name: 'a' }, + right: { value: 1 }, }, assert); - jsep.addUnaryOp("notes"); - test_parser("notes", { - type: "Identifier", - name: "notes" + // new operator + test_parser('a = new Date(123)', { + type: "AssignmentExpression", + operator: "=", + left: { + type: "Identifier", + name: "a" + }, + right: { + type: "NewExpression", + arguments: [ + { + type: "Literal", + value: 123, + raw: "123" + } + ], + callee: { + type: "Identifier", + name: "Date" + } + } }, assert); + assert.throws(function(){ + jsep("new A"); + }, /new function/i, "detects invalid new"); + + // template literals + test_parser('abc`token ${`nested ${`deeply` + "str"} blah`}`', { + type: "TaggedTemplateExpression", + tag: { + type: "Identifier", + name: "abc" + }, + quasi: { + type: "TemplateLiteral", + quasis: [ + { + type: "TemplateElement", + value: { + raw: "token ", + cooked: "token " + }, + tail: false + }, + { + type: "TemplateElement", + value: { + raw: "", + cooked: "" + }, + tail: true + } + ], + expressions: [ + { + type: "TemplateLiteral", + quasis: [ + { + type: "TemplateElement", + value: { + raw: "nested ", + cooked: "nested " + }, + tail: false + }, + { + type: "TemplateElement", + value: { + raw: " blah", + cooked: " blah" + }, + tail: true + } + ], + expressions: [ + { + type: "BinaryExpression", + operator: "+", + left: { + type: "TemplateLiteral", + quasis: [ + { + type: "TemplateElement", + value: { + raw: "deeply", + cooked: "deeply" + }, + tail: true + } + ], + expressions: [] + }, + right: { + type: "Literal", + value: "str", + raw: "\"str\"" + } + } + ] + } + ] + } + }, assert); + assert.throws(function(){ + jsep("`abc ${ `"); + }, /unclosed/i, "detects unclosed template"); }); QUnit.test('Custom alphanumeric operators', function(assert) { @@ -241,10 +649,22 @@ QUnit.test('Esprima Comparison', function(assert) { }); QUnit.test('Ternary', function(assert) { - var val = jsep('a ? b : c'); - assert.equal(val.type, 'ConditionalExpression'); - val = jsep('a||b ? c : d'); - assert.equal(val.type, 'ConditionalExpression'); + test_parser('a ? b : c', { + type: "ConditionalExpression", + test: { + type: 'Identifier', + name: 'a', + }, + consequent: { + type: 'Identifier', + name: 'b', + }, + alternate: { + name: 'c', + }, + }, assert); + + test_parser('a||b ? c : d', { type: 'ConditionalExpression' }, assert); }); }()); diff --git a/typings/tsd.d.ts b/typings/tsd.d.ts index 0051cbe..f8cbe20 100644 --- a/typings/tsd.d.ts +++ b/typings/tsd.d.ts @@ -1,94 +1,213 @@ declare module 'jsep' { - namespace jsep { - export interface Expression { - type: ExpressionType; - } - - export interface ArrayExpression extends Expression { - type: 'ArrayExpression'; - elements: Expression[]; - } - - export interface BinaryExpression extends Expression { - type: 'BinaryExpression'; - operator: string; - left: Expression; - right: Expression; - } - - export interface CallExpression extends Expression { - type: 'CallExpression'; - arguments: Expression[]; - callee: Expression; - } - - export interface Compound extends Expression { - type: 'Compound'; - body: Expression[]; - } - - export interface ConditionalExpression extends Expression { - type: 'ConditionalExpression'; - test: Expression; - consequent: Expression; - alternate: Expression; - } - - export interface Identifier extends Expression { - type: 'Identifier'; - name: string; - } - - export interface Literal extends Expression { - type: 'Literal'; - value: boolean | number | string; - raw: string; - } - - export interface LogicalExpression extends Expression { - type: 'LogicalExpression'; - operator: string; - left: Expression; - right: Expression; - } - - export interface MemberExpression extends Expression { - type: 'MemberExpression'; - computed: boolean; - object: Expression; - property: Expression; - } - - export interface ThisExpression extends Expression { - type: 'ThisExpression'; - } - - export interface UnaryExpression extends Expression { - type: 'UnaryExpression'; - operator: string; - argument: Expression; - prefix: boolean; - } - - type ExpressionType = 'Compound' | 'Identifier' | 'MemberExpression' | 'Literal' | 'ThisExpression' | 'CallExpression' | 'UnaryExpression' | 'BinaryExpression' | 'LogicalExpression' | 'ConditionalExpression' | 'ArrayExpression'; - - function addBinaryOp(operatorName: string, precedence: number): void; - - function addUnaryOp(operatorName: string): void; - - function removeBinaryOp(operatorName: string): void; - - function removeUnaryOp(operatorName: string): void; - - function addIdentifierChar(identifierName: string): void; - - function removeIdentifierChar(identifierName: string): void; - - const version: string; - } - - function jsep(val: string | jsep.Expression): jsep.Expression; - - export = jsep; + namespace jsep { + export interface Expression { + type: ExpressionType; + } + + export interface ArrayExpression extends Expression { + type: 'ArrayExpression'; + elements: Expression[]; + } + + export interface BinaryExpression extends Expression { + type: 'BinaryExpression'; + operator: string; + left: Expression; + right: Expression; + } + + export interface CallExpression extends Expression { + type: 'CallExpression'; + arguments: Expression[]; + callee: Expression; + } + + export interface Compound extends Expression { + type: 'Compound'; + body: Expression[]; + } + + export interface ConditionalExpression extends Expression { + type: 'ConditionalExpression'; + test: Expression; + consequent: Expression; + alternate: Expression; + } + + export interface Identifier extends Expression { + type: 'Identifier'; + name: string; + } + + export interface Literal extends Expression { + type: 'Literal'; + value: boolean | number | string; + raw: string; + } + + export interface LogicalExpression extends Expression { + type: 'LogicalExpression'; + operator: string; + left: Expression; + right: Expression; + } + + export interface MemberExpression extends Expression { + type: 'MemberExpression'; + computed: boolean; + object: Expression; + property: Expression; + } + + export interface ThisExpression extends Expression { + type: 'ThisExpression'; + } + + export interface UnaryExpression extends Expression { + type: 'UnaryExpression'; + operator: string; + argument: Expression; + prefix: boolean; + } + + // plugin types: + export interface ArrowFunctionExpression extends Expression { + type: 'ArrowFunctionExpression', + params?: Expression[]; + body: Expression; + } + + export interface SpreadElement extends Expression { + type: 'SpreadElement'; + argument: Expression; + } + + export interface AssignmentExpression extends Expression { + type: 'AssignmentExpression', + operator: string; + left: Expression; + right: Expression; + } + + export interface NewExpression extends Expression { + type: 'NewExpression'; + node: Expression; + } + + export interface ObjectExpression extends Expression { + type: 'ObjectExpression'; + properties: Array; + } + + export interface ObjectPatterson extends Expression { + type: 'ObjectPattern'; + properties: Array; + } + + export interface Property extends Expression { + type: 'Property'; + computed: boolean; + key: Expression; + value: Expression; + shorthand: boolean; + } + + export interface TaggedTemplateExpression extends Expression { + type: 'TaggedTemplateExpression'; + tag: Expression; + quasi: TemplateLiteral; + } + + export interface TemplateLiteral extends Expression { + type: 'TemplateLiteral'; + quasis: TemplateElement[]; + expressions: Expression[]; + } + + export interface TemplateElement extends Expression { + type: 'TemplateElement'; + value: { + cooked: string; + raw: string; + } + tail: boolean; + } + + type ExpressionType = 'Compound' + | 'Identifier' + | 'MemberExpression' + | 'Literal' + | 'ThisExpression' + | 'CallExpression' + | 'UnaryExpression' + | 'BinaryExpression' + | 'LogicalExpression' + | 'ConditionalExpression' + | 'ArrayExpression' + | 'ArrowFunctionExpression' + | 'SpreadElement' + | 'AssignmentExpression' + | 'NewExpression' + | 'ObjectExpression' + | 'ObjectPattern' + | 'Property' + | 'TaggedTemplateExpression' + | 'TemplateLiteral' + | 'TemplateElement'; + + export type PossibleExpression = Expression | false; + + export interface HookScope { + index: number; + expr: string; + exprI: string; + exprICode: () => number; + gobbleSpaces: () => void; + gobbleExpressions: () => Expression[]; + gobbleExpression: () => Expression; + gobbleBinaryOp: () => PossibleExpression; + gobbleBinaryExpression: () => PossibleExpression; + gobbleToken: () => PossibleExpression; + gobbleNumericLiteral: () => PossibleExpression; + gobbleStringLiteral: () => PossibleExpression; + gobbleIdentifier: () => PossibleExpression; + gobbleArguments: (number) => PossibleExpression; + gobbleGroup: () => Expression; + gobbleArray: () => PossibleExpression; + throwError: (string) => void; + node?: PossibleExpression; + nodes?: PossibleExpression[]; + } + + export type HookCallback = (env: HookScope) => void; + + function addBinaryOp(operatorName: string, precedence: number): void; + + function addUnaryOp(operatorName: string): void; + + function removeBinaryOp(operatorName: string): void; + + function removeUnaryOp(operatorName: string): void; + + function addIdentifierChar(identifierName: string): void; + + function removeIdentifierChar(identifierName: string): void; + + const hooks: { + 'before-all'?: HookCallback; + 'after-all'?: HookCallback; + 'gobble-expression'?: HookCallback; + 'after-expression'?: HookCallback; + 'gobble-token'?: HookCallback; + 'after-token'?: HookCallback; + 'gobble-spaces'?: HookCallback; + }; + + const version: string; + } + + function jsep(val: string | jsep.Expression): jsep.Expression; + + export = jsep; }