From 8a17e764ceac755e58c9ad5916b1758d8098536f Mon Sep 17 00:00:00 2001 From: yosuke ota Date: Sat, 20 Jun 2020 15:57:11 +0900 Subject: [PATCH 1/4] Add support for custom visitor keys. --- esquery.js | 135 ++++++++++++++++++++++++---------- tests/fixtures/customNodes.js | 32 ++++++++ tests/matches.js | 83 +++++++++++++++++++++ tests/queryPseudoChild.js | 103 ++++++++++++++++++++++++++ tests/querySubject.js | 45 ++++++++++++ 5 files changed, 358 insertions(+), 40 deletions(-) create mode 100644 tests/fixtures/customNodes.js diff --git a/esquery.js b/esquery.js index 3355755..1a63e5e 100644 --- a/esquery.js +++ b/esquery.js @@ -64,17 +64,29 @@ function inPath(node, ancestor, path) { } } +/** + * @callback TraverseOptionFallback + * @param {external:AST} node The given node. + * @returns {string[]} An array of visitor keys for the given node. + */ +/** + * @typedef {object} ESQueryOptions + * @property { { [nodeType: string]: string[] } } [visitorKeys] By passing `visitorKeys` mapping, we can extend the properties of the nodes that traverse the node. + * @property {TraverseOptionFallback} [fallback] By passing `fallback` option, we can control the properties of traversing nodes when encountering unknown nodes. + */ + /** * Given a `node` and its ancestors, determine if `node` is matched * by `selector`. * @param {?external:AST} node * @param {?SelectorAST} selector * @param {external:AST[]} [ancestry=[]] + * @param {ESQueryOptions} [options] * @throws {Error} Unknowns (operator, class name, selector type, or * selector value type) * @returns {boolean} */ -function matches(node, selector, ancestry) { +function matches(node, selector, ancestry, options) { if (!selector) { return true; } if (!node) { return false; } if (!ancestry) { ancestry = []; } @@ -94,19 +106,19 @@ function matches(node, selector, ancestry) { } case 'matches': for (const sel of selector.selectors) { - if (matches(node, sel, ancestry)) { return true; } + if (matches(node, sel, ancestry, options)) { return true; } } return false; case 'compound': for (const sel of selector.selectors) { - if (!matches(node, sel, ancestry)) { return false; } + if (!matches(node, sel, ancestry, options)) { return false; } } return true; case 'not': for (const sel of selector.selectors) { - if (matches(node, sel, ancestry)) { return false; } + if (matches(node, sel, ancestry, options)) { return false; } } return true; @@ -117,27 +129,28 @@ function matches(node, selector, ancestry) { estraverse.traverse(node, { enter (node, parent) { if (parent != null) { a.unshift(parent); } - if (matches(node, sel, a)) { + if (matches(node, sel, a, options)) { collector.push(node); } }, leave () { a.shift(); }, - fallback: 'iteration' + keys: options && options.visitorKeys, + fallback: options && options.fallback || 'iteration' }); } return collector.length !== 0; } case 'child': - if (matches(node, selector.right, ancestry)) { - return matches(ancestry[0], selector.left, ancestry.slice(1)); + if (matches(node, selector.right, ancestry, options)) { + return matches(ancestry[0], selector.left, ancestry.slice(1), options); } return false; case 'descendant': - if (matches(node, selector.right, ancestry)) { + if (matches(node, selector.right, ancestry, options)) { for (let i = 0, l = ancestry.length; i < l; ++i) { - if (matches(ancestry[i], selector.left, ancestry.slice(i + 1))) { + if (matches(ancestry[i], selector.left, ancestry.slice(i + 1), options)) { return true; } } @@ -171,29 +184,29 @@ function matches(node, selector, ancestry) { throw new Error(`Unknown operator: ${selector.operator}`); } case 'sibling': - return matches(node, selector.right, ancestry) && - sibling(node, selector.left, ancestry, LEFT_SIDE) || + return matches(node, selector.right, ancestry, options) && + sibling(node, selector.left, ancestry, LEFT_SIDE, options) || selector.left.subject && - matches(node, selector.left, ancestry) && - sibling(node, selector.right, ancestry, RIGHT_SIDE); + matches(node, selector.left, ancestry, options) && + sibling(node, selector.right, ancestry, RIGHT_SIDE, options); case 'adjacent': - return matches(node, selector.right, ancestry) && - adjacent(node, selector.left, ancestry, LEFT_SIDE) || + return matches(node, selector.right, ancestry, options) && + adjacent(node, selector.left, ancestry, LEFT_SIDE, options) || selector.right.subject && - matches(node, selector.left, ancestry) && - adjacent(node, selector.right, ancestry, RIGHT_SIDE); + matches(node, selector.left, ancestry, options) && + adjacent(node, selector.right, ancestry, RIGHT_SIDE, options); case 'nth-child': - return matches(node, selector.right, ancestry) && + return matches(node, selector.right, ancestry, options) && nthChild(node, ancestry, function () { return selector.index.value - 1; - }); + }, options); case 'nth-last-child': - return matches(node, selector.right, ancestry) && + return matches(node, selector.right, ancestry, options) && nthChild(node, ancestry, function (length) { return length - selector.index.value; - }); + }, options); case 'class': switch(selector.name.toLowerCase()){ @@ -224,6 +237,41 @@ function matches(node, selector, ancestry) { throw new Error(`Unknown selector type: ${selector.type}`); } +/** + * Get visitor keys of a given node. + * @param {external:AST} node node The AST node to get keys. + * @param {ESQueryOptions|undefined} options + * @returns {string[]} Visitor keys of the node. + */ +function getVisitorKeys(node, options) { + const nodeType = node.type; + let candidates; + if (options && options.visitorKeys && options.visitorKeys[nodeType]) { + candidates = options.visitorKeys[nodeType]; + } else { + candidates = estraverse.VisitorKeys[nodeType]; + } + if(!candidates) { + if (options && typeof options.fallback === 'function') { + candidates = options.fallback(node); + } else { + // 'iteration' fallback + candidates = Object.keys(node); + } + } + return candidates; +} + + +/** + * Check whether the given value is an ASTNode or not. + * @param {any} node The value to check. + * @returns {boolean} `true` if the value is an ASTNode. + */ +function isNode(node) { + return node !== null && typeof node === 'object' && typeof node.type === 'string'; +} + /** * Determines if the given node has a sibling that matches the * given selector. @@ -231,12 +279,13 @@ function matches(node, selector, ancestry) { * @param {SelectorSequenceAST} selector * @param {external:AST[]} ancestry * @param {Side} side + * @param {ESQueryOptions|undefined} options * @returns {boolean} */ -function sibling(node, selector, ancestry, side) { +function sibling(node, selector, ancestry, side, options) { const [parent] = ancestry; if (!parent) { return false; } - const keys = estraverse.VisitorKeys[parent.type]; + const keys = getVisitorKeys(parent, options); for (const key of keys) { const listProp = parent[key]; if (Array.isArray(listProp)) { @@ -251,7 +300,7 @@ function sibling(node, selector, ancestry, side) { upperBound = listProp.length; } for (let k = lowerBound; k < upperBound; ++k) { - if (matches(listProp[k], selector, ancestry)) { + if (isNode(listProp[k]) && matches(listProp[k], selector, ancestry, options)) { return true; } } @@ -267,21 +316,22 @@ function sibling(node, selector, ancestry, side) { * @param {SelectorSequenceAST} selector * @param {external:AST[]} ancestry * @param {Side} side + * @param {ESQueryOptions|undefined} options * @returns {boolean} */ -function adjacent(node, selector, ancestry, side) { +function adjacent(node, selector, ancestry, side, options) { const [parent] = ancestry; if (!parent) { return false; } - const keys = estraverse.VisitorKeys[parent.type]; + const keys = getVisitorKeys(parent, options); for (const key of keys) { const listProp = parent[key]; if (Array.isArray(listProp)) { const idx = listProp.indexOf(node); if (idx < 0) { continue; } - if (side === LEFT_SIDE && idx > 0 && matches(listProp[idx - 1], selector, ancestry)) { + if (side === LEFT_SIDE && idx > 0 && isNode(listProp[idx - 1]) && matches(listProp[idx - 1], selector, ancestry, options)) { return true; } - if (side === RIGHT_SIDE && idx < listProp.length - 1 && matches(listProp[idx + 1], selector, ancestry)) { + if (side === RIGHT_SIDE && idx < listProp.length - 1 && isNode(listProp[idx + 1]) && matches(listProp[idx + 1], selector, ancestry, options)) { return true; } } @@ -301,12 +351,13 @@ function adjacent(node, selector, ancestry, side) { * @param {external:AST} node * @param {external:AST[]} ancestry * @param {IndexFunction} idxFn + * @param {ESQueryOptions|undefined} options * @returns {boolean} */ -function nthChild(node, ancestry, idxFn) { +function nthChild(node, ancestry, idxFn, options) { const [parent] = ancestry; if (!parent) { return false; } - const keys = estraverse.VisitorKeys[parent.type]; + const keys = getVisitorKeys(parent, options); for (const key of keys) { const listProp = parent[key]; if (Array.isArray(listProp)) { @@ -347,24 +398,25 @@ function subjects(selector, ancestor) { * @param {external:AST} ast * @param {?SelectorAST} selector * @param {TraverseVisitor} visitor + * @param {ESQueryOptions} [options] * @returns {external:AST[]} */ -function traverse(ast, selector, visitor) { +function traverse(ast, selector, visitor, options) { if (!selector) { return; } const ancestry = []; const altSubjects = subjects(selector); estraverse.traverse(ast, { enter (node, parent) { if (parent != null) { ancestry.unshift(parent); } - if (matches(node, selector, ancestry)) { + if (matches(node, selector, ancestry, options)) { if (altSubjects.length) { for (let i = 0, l = altSubjects.length; i < l; ++i) { - if (matches(node, altSubjects[i], ancestry)) { + if (matches(node, altSubjects[i], ancestry, options)) { visitor(node, parent, ancestry); } for (let k = 0, m = ancestry.length; k < m; ++k) { const succeedingAncestry = ancestry.slice(k + 1); - if (matches(ancestry[k], altSubjects[i], succeedingAncestry)) { + if (matches(ancestry[k], altSubjects[i], succeedingAncestry, options)) { visitor(ancestry[k], parent, succeedingAncestry); } } @@ -375,7 +427,8 @@ function traverse(ast, selector, visitor) { } }, leave () { ancestry.shift(); }, - fallback: 'iteration' + keys: options && options.visitorKeys, + fallback: options && options.fallback || 'iteration' }); } @@ -385,13 +438,14 @@ function traverse(ast, selector, visitor) { * match the selector. * @param {external:AST} ast * @param {?SelectorAST} selector + * @param {ESQueryOptions} [options] * @returns {external:AST[]} */ -function match(ast, selector) { +function match(ast, selector, options) { const results = []; traverse(ast, selector, function (node) { results.push(node); - }); + }, options); return results; } @@ -408,10 +462,11 @@ function parse(selector) { * Query the code AST using the selector string. * @param {external:AST} ast * @param {string} selector + * @param {ESQueryOptions} [options] * @returns {external:AST[]} */ -function query(ast, selector) { - return match(ast, parse(selector)); +function query(ast, selector, options) { + return match(ast, parse(selector), options); } query.parse = parse; diff --git a/tests/fixtures/customNodes.js b/tests/fixtures/customNodes.js new file mode 100644 index 0000000..8ad8a37 --- /dev/null +++ b/tests/fixtures/customNodes.js @@ -0,0 +1,32 @@ +export default { + type: 'CustomRoot', + list: [ + { + type: 'CustomChild', + name: 'one', + sublist: [{ type: 'CustomGrandChild' }], + }, + { + type: 'CustomChild', + name: 'two', + sublist: [], + }, + { + type: 'CustomChild', + name: 'three', + sublist: [ + { type: 'CustomGrandChild' }, + { type: 'CustomGrandChild' }, + ], + }, + { + type: 'CustomChild', + name: 'four', + sublist: [ + { type: 'CustomGrandChild' }, + { type: 'CustomGrandChild' }, + { type: 'CustomGrandChild' }, + ], + }, + ], +}; diff --git a/tests/matches.js b/tests/matches.js index 026ea3b..33d0cb8 100644 --- a/tests/matches.js +++ b/tests/matches.js @@ -2,6 +2,7 @@ import esquery from '../esquery.js'; import forLoop from './fixtures/forLoop.js'; import simpleProgram from './fixtures/simpleProgram.js'; import conditional from './fixtures/conditional.js'; +import customNodes from './fixtures/customNodes.js'; describe('matches', function () { it('falsey node', function () { @@ -134,3 +135,85 @@ describe('matches', function () { }); }); }); + +describe('matches with custom AST and custom visitor keys', function () { + it('adjacent/sibling', function () { + const options = { + visitorKeys: { + CustomRoot: ['list'], + CustomChild: ['sublist'], + CustomGrandChild: [] + } + }; + let selector = esquery.parse('CustomChild + CustomChild'); + assert.doesNotThrow(() => { + esquery.matches( + customNodes.list[1], + selector, + [customNodes], + options + ); + }); + + selector = esquery.parse('CustomChild ~ CustomChild'); + assert.doesNotThrow(() => { + esquery.matches( + customNodes.list[1], + selector, + [customNodes], + options + ); + }); + }); +}); + +describe('matches with custom AST and fallback option', function () { + it('adjacent/sibling', function () { + const options = { + fallback (node) { + return node.type === 'CustomRoot' ? ['list'] : node.type === 'CustomChild' ? ['sublist'] : []; + } + }; + let selector = esquery.parse('CustomChild + CustomChild'); + assert.doesNotThrow(() => { + esquery.matches( + customNodes.list[1], + selector, + [customNodes], + options + ); + }); + + selector = esquery.parse('CustomChild ~ CustomChild'); + assert.doesNotThrow(() => { + esquery.matches( + customNodes.list[1], + selector, + [customNodes], + options + ); + }); + }); +}); + +describe('matches with custom AST and default fallback', function () { + it('adjacent/sibling', function () { + let selector = esquery.parse('CustomChild + CustomChild'); + assert.doesNotThrow(() => { + esquery.matches( + customNodes.list[1], + selector, + [customNodes], + ); + }); + + selector = esquery.parse('CustomChild ~ CustomChild'); + assert.doesNotThrow(() => { + esquery.matches( + customNodes.list[1], + selector, + [customNodes], + ); + }); + }); +}); diff --git a/tests/queryPseudoChild.js b/tests/queryPseudoChild.js index e77ca41..f7cc7b3 100644 --- a/tests/queryPseudoChild.js +++ b/tests/queryPseudoChild.js @@ -4,6 +4,7 @@ import conditionalLong from './fixtures/conditionalLong.js'; import forLoop from './fixtures/forLoop.js'; import simpleFunction from './fixtures/simpleFunction.js'; import simpleProgram from './fixtures/simpleProgram.js'; +import customNodes from './fixtures/customNodes.js'; describe('Pseudo *-child query', function () { @@ -155,3 +156,105 @@ describe('Pseudo *-child query', function () { ]); }); }); + +describe('Pseudo *-child query with custom ast', function () { + const visitorKeys = { + CustomRoot: ['list'], + CustomChild: ['sublist'], + CustomGrandChild: [] + }; + + it('conditional first child', function () { + const matches = esquery(customNodes, ':first-child', { visitorKeys }); + assert.includeMembers(matches, [ + customNodes.list[0], + customNodes.list[0].sublist[0], + customNodes.list[2].sublist[0], + customNodes.list[3].sublist[0], + ]); + }); + + it('conditional first child with fallback', function () { + const matches = esquery(customNodes, ':first-child', { + fallback (node) { + return node.type === 'CustomRoot' ? ['list'] : node.type === 'CustomChild' ? ['sublist'] : []; + } + }); + assert.includeMembers(matches, [ + customNodes.list[0], + customNodes.list[0].sublist[0], + customNodes.list[2].sublist[0], + customNodes.list[3].sublist[0], + ]); + }); + + it('conditional first child with default fallback', function () { + const matches = esquery(customNodes, ':first-child'); + assert.includeMembers(matches, [ + customNodes.list[0], + customNodes.list[0].sublist[0], + customNodes.list[2].sublist[0], + customNodes.list[3].sublist[0], + ]); + }); + + it('conditional last child', function () { + const matches = esquery(customNodes, ':last-child', { visitorKeys }); + assert.includeMembers(matches, [ + customNodes.list[3], + customNodes.list[0].sublist[0], + customNodes.list[2].sublist[1], + customNodes.list[3].sublist[2], + ]); + }); + + it('conditional nth child', function () { + let matches = esquery(customNodes, ':nth-child(2)', { visitorKeys }); + assert.includeMembers(matches, [ + customNodes.list[1], + customNodes.list[2].sublist[1], + customNodes.list[3].sublist[1], + ]); + + matches = esquery(customNodes, ':nth-last-child(2)', { visitorKeys }); + assert.includeMembers(matches, [ + customNodes.list[2], + customNodes.list[2].sublist[0], + customNodes.list[3].sublist[1], + ]); + }); + + it('conditional nth child combination', function () { + let matches = esquery(customNodes, ':matches(:nth-child(2), :nth-last-child(2))', { visitorKeys }); + assert.includeMembers(matches, [ + customNodes.list[1], + customNodes.list[2], + customNodes.list[2].sublist[0], + customNodes.list[2].sublist[1], + customNodes.list[3].sublist[1], + ]); + + matches = esquery(customNodes, ':not(:nth-child(2)):nth-last-child(2)', { visitorKeys }); + assert.includeMembers(matches, [ + customNodes.list[2], + customNodes.list[2].sublist[0], + ]); + + + matches = esquery(customNodes, ':nth-last-child(2) > :nth-child(2)', { visitorKeys }); + assert.includeMembers(matches, [ + customNodes.list[2].sublist[1], + ]); + + matches = esquery(customNodes, ':nth-last-child(2) :nth-child(2)', { visitorKeys }); + assert.includeMembers(matches, [ + customNodes.list[2].sublist[1], + ]); + + matches = esquery(customNodes, '*:has(:nth-child(2))', { visitorKeys }); + assert.includeMembers(matches, [ + customNodes.list[2], + customNodes.list[3], + ]); + }); +}); diff --git a/tests/querySubject.js b/tests/querySubject.js index 8f611fd..dc3c2ba 100644 --- a/tests/querySubject.js +++ b/tests/querySubject.js @@ -6,6 +6,7 @@ import simpleProgram from './fixtures/simpleProgram.js'; import nestedFunctions from './fixtures/nestedFunctions.js'; import bigArray from './fixtures/bigArray.js'; +import customNodes from './fixtures/customNodes.js'; describe('Query subject', function () { @@ -155,3 +156,47 @@ describe('Query subject', function () { assert.equal(3, matches.length); }); }); + +describe('Query subject with custom ast', function () { + const visitorKeys = { + CustomRoot: ['list'], + CustomChild: ['sublist'], + CustomGrandChild: [] + }; + + it('sibling', function () { + const matches = esquery(customNodes, 'CustomChild[name=two] ~ CustomChild', { visitorKeys }); + assert.includeMembers(matches, [ + customNodes.list[2], + customNodes.list[3], + ]); + }); + + + it('sibling with fallback', function () { + const matches = esquery(customNodes, 'CustomChild[name=two] ~ CustomChild', { + fallback (node) { + return node.type === 'CustomRoot' ? ['list'] : node.type === 'CustomChild' ? ['sublist'] : []; + } + }); + assert.includeMembers(matches, [ + customNodes.list[2], + customNodes.list[3], + ]); + }); + + it('sibling with default fallback', function () { + const matches = esquery(customNodes, 'CustomChild[name=two] ~ CustomChild'); + assert.includeMembers(matches, [ + customNodes.list[2], + customNodes.list[3], + ]); + }); + + it('adjacent', function () { + const matches = esquery(customNodes, 'CustomChild[name=two] + CustomChild', { visitorKeys }); + assert.includeMembers(matches, [ + customNodes.list[2], + ]); + }); +}); From d6845d696dd7d64d91b3280bb9fe797c9d8a3838 Mon Sep 17 00:00:00 2001 From: yosuke ota Date: Tue, 2 Feb 2021 12:06:11 +0900 Subject: [PATCH 2/4] Fix for getVisitorKeys --- esquery.js | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/esquery.js b/esquery.js index 1a63e5e..1c0d0a2 100644 --- a/esquery.js +++ b/esquery.js @@ -239,27 +239,25 @@ function matches(node, selector, ancestry, options) { /** * Get visitor keys of a given node. - * @param {external:AST} node node The AST node to get keys. + * @param {external:AST} node The AST node to get keys. * @param {ESQueryOptions|undefined} options * @returns {string[]} Visitor keys of the node. */ function getVisitorKeys(node, options) { const nodeType = node.type; - let candidates; if (options && options.visitorKeys && options.visitorKeys[nodeType]) { - candidates = options.visitorKeys[nodeType]; - } else { - candidates = estraverse.VisitorKeys[nodeType]; + return options.visitorKeys[nodeType]; } - if(!candidates) { - if (options && typeof options.fallback === 'function') { - candidates = options.fallback(node); - } else { - // 'iteration' fallback - candidates = Object.keys(node); - } + if(estraverse.VisitorKeys[nodeType]) { + return estraverse.VisitorKeys[nodeType]; } - return candidates; + if (options && typeof options.fallback === 'function') { + return options.fallback(node); + } + // 'iteration' fallback + return Object.keys(node).filter(function (key) { + return key !== 'type'; + }); } From 6bb44eeb88dbf921161f5c8898e3c7c95b88be0f Mon Sep 17 00:00:00 2001 From: yosuke ota Date: Tue, 2 Feb 2021 12:09:04 +0900 Subject: [PATCH 3/4] format --- esquery.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esquery.js b/esquery.js index 1c0d0a2..b451ad6 100644 --- a/esquery.js +++ b/esquery.js @@ -248,7 +248,7 @@ function getVisitorKeys(node, options) { if (options && options.visitorKeys && options.visitorKeys[nodeType]) { return options.visitorKeys[nodeType]; } - if(estraverse.VisitorKeys[nodeType]) { + if (estraverse.VisitorKeys[nodeType]) { return estraverse.VisitorKeys[nodeType]; } if (options && typeof options.fallback === 'function') { From 6d2cbd39f0ee6288df83ab31052c4a623f617805 Mon Sep 17 00:00:00 2001 From: Michael Ficarra Date: Tue, 2 Feb 2021 11:30:33 -0800 Subject: [PATCH 4/4] empty commit to run new CI