diff --git a/README.md b/README.md index a3ee334..6cc0c18 100644 --- a/README.md +++ b/README.md @@ -22,5 +22,6 @@ The following selectors are supported: * [matches-any](http://dev.w3.org/csswg/selectors4/#matches): `:matches([attr] > :first-child, :last-child)` * [subject indicator](http://dev.w3.org/csswg/selectors4/#subject): `!IfStatement > [name="foo"]` * class of AST node: `:statement`, `:expression`, `:declaration`, `:function`, or `:pattern` +* [root](https://drafts.csswg.org/selectors-4/#root-pseudo) and [scope](https://drafts.csswg.org/selectors-4/#scope-pseudo): `:root`, `:scope` [![Build Status](https://travis-ci.org/estools/esquery.png?branch=master)](https://travis-ci.org/estools/esquery) diff --git a/esquery.js b/esquery.js index d4d9fea..7ea3dce 100644 --- a/esquery.js +++ b/esquery.js @@ -47,7 +47,7 @@ /** * Given a `node` and its ancestors, determine if `node` is matched by `selector`. */ - function matches(node, selector, ancestry) { + function matches(node, selector, ancestry, scope) { var path, ancestor, i, l, p; if (!selector) { return true; } if (!node) { return false; } @@ -67,31 +67,32 @@ case 'matches': for (i = 0, l = selector.selectors.length; i < l; ++i) { - if (matches(node, selector.selectors[i], ancestry)) { return true; } + if (matches(node, selector.selectors[i], ancestry, scope)) { return true; } } return false; case 'compound': for (i = 0, l = selector.selectors.length; i < l; ++i) { - if (!matches(node, selector.selectors[i], ancestry)) { return false; } + if (!matches(node, selector.selectors[i], ancestry, scope)) { return false; } } return true; case 'not': for (i = 0, l = selector.selectors.length; i < l; ++i) { - if (matches(node, selector.selectors[i], ancestry)) { return false; } + if (matches(node, selector.selectors[i], ancestry, scope)) { return false; } } return true; case 'has': - var a, collector = []; + var a, collector = [], parent = ancestry[0]; for (i = 0, l = selector.selectors.length; i < l; ++i) { - a = []; - estraverse.traverse(node, { - enter: function (node, parent) { - if (parent != null) { a.unshift(parent); } - if (matches(node, selector.selectors[i], a)) { - collector.push(node); + a = ancestry.slice(parent ? 1 : 0); + estraverse.traverse(parent || node, { + enter: function (child, parent) { + if (parent == null) { return; } + a.unshift(parent); + if (matches(child, selector.selectors[i], a, node)) { + collector.push(child); } }, leave: function () { a.shift(); } @@ -100,15 +101,15 @@ 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, scope)) { + return matches(ancestry[0], selector.left, ancestry.slice(1), scope); } return false; case 'descendant': - if (matches(node, selector.right, ancestry)) { + if (matches(node, selector.right, ancestry, scope)) { for (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), scope)) { return true; } } @@ -140,27 +141,27 @@ } case 'sibling': - return matches(node, selector.right, ancestry) && - sibling(node, selector.left, ancestry, LEFT_SIDE) || + return matches(node, selector.right, ancestry, scope) && + sibling(node, selector.left, ancestry, LEFT_SIDE, scope) || selector.left.subject && - matches(node, selector.left, ancestry) && - sibling(node, selector.right, ancestry, RIGHT_SIDE); + matches(node, selector.left, ancestry, scope) && + sibling(node, selector.right, ancestry, RIGHT_SIDE, scope); case 'adjacent': - return matches(node, selector.right, ancestry) && - adjacent(node, selector.left, ancestry, LEFT_SIDE) || + return matches(node, selector.right, ancestry, scope) && + adjacent(node, selector.left, ancestry, LEFT_SIDE, scope) || selector.right.subject && - matches(node, selector.left, ancestry) && - adjacent(node, selector.right, ancestry, RIGHT_SIDE); + matches(node, selector.left, ancestry, scope) && + adjacent(node, selector.right, ancestry, RIGHT_SIDE, scope); case 'nth-child': - return matches(node, selector.right, ancestry) && + return matches(node, selector.right, ancestry, scope) && nthChild(node, ancestry, function (length) { return selector.index.value - 1; }); case 'nth-last-child': - return matches(node, selector.right, ancestry) && + return matches(node, selector.right, ancestry, scope) && nthChild(node, ancestry, function (length) { return length - selector.index.value; }); @@ -185,6 +186,12 @@ node.type === 'ArrowFunctionExpression'; } throw new Error('Unknown class name: ' + selector.name); + + case 'scope': + return scope ? node === scope : ancestry.length === 0; + + case 'root': + return ancestry.length === 0; } throw new Error('Unknown selector type: ' + selector.type); @@ -193,7 +200,7 @@ /* * Determines if the given node has a sibling that matches the given selector. */ - function sibling(node, selector, ancestry, side) { + function sibling(node, selector, ancestry, side, scope) { var parent = ancestry[0], listProp, startIndex, keys, i, l, k, lowerBound, upperBound; if (!parent) { return false; } keys = estraverse.VisitorKeys[parent.type]; @@ -210,7 +217,7 @@ upperBound = listProp.length; } for (k = lowerBound; k < upperBound; ++k) { - if (matches(listProp[k], selector, ancestry)) { + if (matches(listProp[k], selector, ancestry, scope)) { return true; } } @@ -222,7 +229,7 @@ /* * Determines if the given node has an asjacent sibling that matches the given selector. */ - function adjacent(node, selector, ancestry, side) { + function adjacent(node, selector, ancestry, side, scope) { var parent = ancestry[0], listProp, keys, i, l, idx; if (!parent) { return false; } keys = estraverse.VisitorKeys[parent.type]; @@ -231,10 +238,10 @@ if (isArray(listProp)) { 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 && matches(listProp[idx - 1], selector, ancestry, scope)) { return true; } - if (side === RIGHT_SIDE && idx < listProp.length - 1 && matches(listProp[idx + 1], selector, ancestry)) { + if (side === RIGHT_SIDE && idx < listProp.length - 1 && matches(listProp[idx + 1], selector, ancestry, scope)) { return true; } } @@ -274,6 +281,81 @@ return results; } + /* + * Clones a selector AST + */ + function clone(selector) { + if (typeof selector !== 'object' || selector instanceof RegExp) { + return selector; + } + var clonedSelector = selector instanceof Array ? [] : {}; + for (var key in selector) { + if (!selector.hasOwnProperty(key)) { continue; } + clonedSelector[key] = clone(selector[key]); + } + return clonedSelector; + } + + /* + * Transforms this query so that subject indicators are replaced with :has selectors + */ + function transform(selector) { + var root = { }, + current = selector, + previous = root, + subjects = [ ]; + + if (!selector) { return selector; } + + while ('left' in current && 'right' in current) { + if (current.right.subject) { + previous.type = 'scope'; + subjects.push([ clone(current), clone(root) ]); + } + previous.type = current.type; + previous.right = current.right; + previous = previous.left = { } + current = current.left; + } + if (current.subject) { + previous.type = 'scope'; + subjects.push([ clone(current), clone(root) ]); + } + if (subjects.length === 0) { + return selector; + } else { + var matches = { + type: 'matches', + selectors: subjects.map(function (subject) { + var result = subject[0], has = { + type: 'has', + selectors: [ subject[1] ] + }; + if ('right' in subject[0]) { + delete result.right.subject; + result.right = { + type: 'compound', + selectors: result.right.type === 'compound' + ? result.right.selectors.concat(has) + : [ result.right, has ] + }; + } else { + delete result.subject; + result = { + type: 'compound', + selectors: result.type === 'compound' + ? result.selectors.concat(has) + : [ result, has ] + }; + } + return result; + }) + }; + return matches.selectors.length === 1 + ? matches.selectors[0] : matches; + } + } + /** * From a JS AST and a selector AST, collect all JS AST nodes that match the selector. */ @@ -308,7 +390,7 @@ * Parse a selector string and return its AST. */ function parse(selector) { - return parser.parse(selector); + return transform(parser.parse(selector)); } /** diff --git a/grammar.pegjs b/grammar.pegjs index 34e1c59..798bb2c 100644 --- a/grammar.pegjs +++ b/grammar.pegjs @@ -33,6 +33,10 @@ selectors = s:selector ss:(_ "," _ selector)* { return [s].concat(ss.map(function (s) { return s[3]; })); } +relativeSelectors = s:relativeSelector ss:(_ "," _ relativeSelector)* { + return [s].concat(ss.map(function (s) { return s[3]; })); +} + selector = a:sequence ops:(binaryOp sequence)* { return ops.reduce(function (memo, rhs) { @@ -40,6 +44,16 @@ selector }, a); } +relativeSelector + = a:sequence? ops:(binaryOp sequence)* { + return ops.reduce(function (memo, rhs) { + return { type: rhs[0], left: memo, right: rhs[1] }; + }, + !a || a.type === 'scope' + ? { type: 'scope' } + : { type: 'descendant', left: { type: 'scope' }, right: a }); + } + sequence = subject:"!"? as:atom+ { var b = as.length === 1 ? as[0] : { type: 'compound', selectors: as }; @@ -50,6 +64,7 @@ sequence atom = wildcard / identifier / attr / field / negation / matches / has / firstChild / lastChild / nthChild / nthLastChild / class + / root / scope wildcard = a:"*" { return { type: 'wildcard', value: a }; } identifier = "#"? i:identifierName { return { type: 'identifier', value: i }; } @@ -88,7 +103,7 @@ field = "." i:identifierName is:("." identifierName)* { negation = ":not(" _ ss:selectors _ ")" { return { type: 'not', selectors: ss }; } matches = ":matches(" _ ss:selectors _ ")" { return { type: 'matches', selectors: ss }; } -has = ":has(" _ ss:selectors _ ")" { return { type: 'has', selectors: ss }; } +has = ":has(" _ ss:relativeSelectors _ ")" { return { type: 'has', selectors: ss }; } firstChild = ":first-child" { return nth(1); } lastChild = ":last-child" { return nthLast(1); } @@ -99,3 +114,6 @@ nthLastChild = ":nth-last-child(" _ n:[0-9]+ _ ")" { return nthLast(parseInt(n.j class = ":" c:("statement"i / "expression"i / "declaration"i / "function"i / "pattern"i) { return { type: 'class', name: c }; } + +root = ":root" { return { type: 'root' }; } +scope = ":scope" { return { type: 'scope' }; } diff --git a/parser.js b/parser.js index 5e250f7..b5718b6 100644 --- a/parser.js +++ b/parser.js @@ -42,7 +42,9 @@ var result = (function(){ "identifierName": parse_identifierName, "binaryOp": parse_binaryOp, "selectors": parse_selectors, + "relativeSelectors": parse_relativeSelectors, "selector": parse_selector, + "relativeSelector": parse_relativeSelector, "sequence": parse_sequence, "atom": parse_atom, "wildcard": parse_wildcard, @@ -65,7 +67,9 @@ var result = (function(){ "lastChild": parse_lastChild, "nthChild": parse_nthChild, "nthLastChild": parse_nthLastChild, - "class": parse_class + "class": parse_class, + "root": parse_root, + "scope": parse_scope }; if (startRule !== undefined) { @@ -542,6 +546,119 @@ var result = (function(){ return result0; } + function parse_relativeSelectors() { + var cacheKey = "relativeSelectors@" + pos; + var cachedResult = cache[cacheKey]; + if (cachedResult) { + pos = cachedResult.nextPos; + return cachedResult.result; + } + + var result0, result1, result2, result3, result4, result5; + var pos0, pos1, pos2; + + pos0 = pos; + pos1 = pos; + result0 = parse_relativeSelector(); + if (result0 !== null) { + result1 = []; + pos2 = pos; + result2 = parse__(); + if (result2 !== null) { + if (input.charCodeAt(pos) === 44) { + result3 = ","; + pos++; + } else { + result3 = null; + if (reportFailures === 0) { + matchFailed("\",\""); + } + } + if (result3 !== null) { + result4 = parse__(); + if (result4 !== null) { + result5 = parse_relativeSelector(); + if (result5 !== null) { + result2 = [result2, result3, result4, result5]; + } else { + result2 = null; + pos = pos2; + } + } else { + result2 = null; + pos = pos2; + } + } else { + result2 = null; + pos = pos2; + } + } else { + result2 = null; + pos = pos2; + } + while (result2 !== null) { + result1.push(result2); + pos2 = pos; + result2 = parse__(); + if (result2 !== null) { + if (input.charCodeAt(pos) === 44) { + result3 = ","; + pos++; + } else { + result3 = null; + if (reportFailures === 0) { + matchFailed("\",\""); + } + } + if (result3 !== null) { + result4 = parse__(); + if (result4 !== null) { + result5 = parse_relativeSelector(); + if (result5 !== null) { + result2 = [result2, result3, result4, result5]; + } else { + result2 = null; + pos = pos2; + } + } else { + result2 = null; + pos = pos2; + } + } else { + result2 = null; + pos = pos2; + } + } else { + result2 = null; + pos = pos2; + } + } + if (result1 !== null) { + result0 = [result0, result1]; + } else { + result0 = null; + pos = pos1; + } + } else { + result0 = null; + pos = pos1; + } + if (result0 !== null) { + result0 = (function(offset, s, ss) { + return [s].concat(ss.map(function (s) { return s[3]; })); + })(pos0, result0[0], result0[1]); + } + if (result0 === null) { + pos = pos0; + } + + cache[cacheKey] = { + nextPos: pos, + result: result0 + }; + return result0; + } + function parse_selector() { var cacheKey = "selector@" + pos; var cachedResult = cache[cacheKey]; @@ -617,6 +734,85 @@ var result = (function(){ return result0; } + function parse_relativeSelector() { + var cacheKey = "relativeSelector@" + pos; + var cachedResult = cache[cacheKey]; + if (cachedResult) { + pos = cachedResult.nextPos; + return cachedResult.result; + } + + var result0, result1, result2, result3; + var pos0, pos1, pos2; + + pos0 = pos; + pos1 = pos; + result0 = parse_sequence(); + result0 = result0 !== null ? result0 : ""; + if (result0 !== null) { + result1 = []; + pos2 = pos; + result2 = parse_binaryOp(); + if (result2 !== null) { + result3 = parse_sequence(); + if (result3 !== null) { + result2 = [result2, result3]; + } else { + result2 = null; + pos = pos2; + } + } else { + result2 = null; + pos = pos2; + } + while (result2 !== null) { + result1.push(result2); + pos2 = pos; + result2 = parse_binaryOp(); + if (result2 !== null) { + result3 = parse_sequence(); + if (result3 !== null) { + result2 = [result2, result3]; + } else { + result2 = null; + pos = pos2; + } + } else { + result2 = null; + pos = pos2; + } + } + if (result1 !== null) { + result0 = [result0, result1]; + } else { + result0 = null; + pos = pos1; + } + } else { + result0 = null; + pos = pos1; + } + if (result0 !== null) { + result0 = (function(offset, a, ops) { + return ops.reduce(function (memo, rhs) { + return { type: rhs[0], left: memo, right: rhs[1] }; + }, + !a || a.type === 'scope' + ? { type: 'scope' } + : { type: 'descendant', left: { type: 'scope' }, right: a }); + })(pos0, result0[0], result0[1]); + } + if (result0 === null) { + pos = pos0; + } + + cache[cacheKey] = { + nextPos: pos, + result: result0 + }; + return result0; + } + function parse_sequence() { var cacheKey = "sequence@" + pos; var cachedResult = cache[cacheKey]; @@ -712,6 +908,12 @@ var result = (function(){ result0 = parse_nthLastChild(); if (result0 === null) { result0 = parse_class(); + if (result0 === null) { + result0 = parse_root(); + if (result0 === null) { + result0 = parse_scope(); + } + } } } } @@ -2102,7 +2304,7 @@ var result = (function(){ if (result0 !== null) { result1 = parse__(); if (result1 !== null) { - result2 = parse_selectors(); + result2 = parse_relativeSelectors(); if (result2 !== null) { result3 = parse__(); if (result3 !== null) { @@ -2521,6 +2723,76 @@ var result = (function(){ return result0; } + function parse_root() { + var cacheKey = "root@" + pos; + var cachedResult = cache[cacheKey]; + if (cachedResult) { + pos = cachedResult.nextPos; + return cachedResult.result; + } + + var result0; + var pos0; + + pos0 = pos; + if (input.substr(pos, 5) === ":root") { + result0 = ":root"; + pos += 5; + } else { + result0 = null; + if (reportFailures === 0) { + matchFailed("\":root\""); + } + } + if (result0 !== null) { + result0 = (function(offset) { return { type: 'root' }; })(pos0); + } + if (result0 === null) { + pos = pos0; + } + + cache[cacheKey] = { + nextPos: pos, + result: result0 + }; + return result0; + } + + function parse_scope() { + var cacheKey = "scope@" + pos; + var cachedResult = cache[cacheKey]; + if (cachedResult) { + pos = cachedResult.nextPos; + return cachedResult.result; + } + + var result0; + var pos0; + + pos0 = pos; + if (input.substr(pos, 6) === ":scope") { + result0 = ":scope"; + pos += 6; + } else { + result0 = null; + if (reportFailures === 0) { + matchFailed("\":scope\""); + } + } + if (result0 !== null) { + result0 = (function(offset) { return { type: 'scope' }; })(pos0); + } + if (result0 === null) { + pos = pos0; + } + + cache[cacheKey] = { + nextPos: pos, + result: result0 + }; + return result0; + } + function cleanupExpected(expected) { expected.sort(); diff --git a/tests/fixtures/nestedFunctionsWithReturns.js b/tests/fixtures/nestedFunctionsWithReturns.js new file mode 100644 index 0000000..f4bcc14 --- /dev/null +++ b/tests/fixtures/nestedFunctionsWithReturns.js @@ -0,0 +1,12 @@ +define(["esprima"], function (esprima) { + + return esprima.parse( + "const x = function() {\n" + + " function y() {\n" + + " return 'bar';\n" + + " }\n" + + " return 'foo';\n" + + "};\n" + ); + +}); diff --git a/tests/queryRoot.js b/tests/queryRoot.js new file mode 100644 index 0000000..49fada5 --- /dev/null +++ b/tests/queryRoot.js @@ -0,0 +1,26 @@ + +define([ + "esquery", + "jstestr/assert", + "jstestr/test", + "./fixtures/nestedFunctions", + "./fixtures/nestedFunctionsWithReturns", +], function (esquery, assert, test, nestedFunctions, nestedFunctionsWithReturns) { + + test.defineSuite("root selector", { + + "select only first level function": function () { + var matches = esquery(nestedFunctions, ":root > FunctionDeclaration"); + assert.isSame(1, matches.length); + assert.isSame(matches[0].id.name, "foo") + }, + + "select only first level return": function () { + var firstBlock = esquery(nestedFunctionsWithReturns, "BlockStatement")[0]; + var matches = esquery(firstBlock, ":scope > ReturnStatement"); + assert.isSame(1, matches.length); + assert.isSame(matches[0].argument.value, "foo") + }, + + }); +}); diff --git a/tests/queryScope.js b/tests/queryScope.js new file mode 100644 index 0000000..b1a8905 --- /dev/null +++ b/tests/queryScope.js @@ -0,0 +1,26 @@ + +define([ + "esquery", + "jstestr/assert", + "jstestr/test", + "./fixtures/nestedFunctions", + "./fixtures/nestedFunctionsWithReturns", +], function (esquery, assert, test, nestedFunctions, nestedFunctionsWithReturns) { + + test.defineSuite("scope selector", { + + "select only first level function": function () { + var matches = esquery(nestedFunctions, ":scope > FunctionDeclaration"); + assert.isSame(1, matches.length); + assert.isSame(matches[0].id.name, "foo") + }, + + "select only the first level return of the first block": function () { + var firstBlock = esquery(nestedFunctionsWithReturns, "BlockStatement")[0]; + var matches = esquery(firstBlock, ":scope > ReturnStatement"); + assert.isSame(1, matches.length); + assert.isSame(matches[0].argument.value, "foo") + }, + + }); +}); \ No newline at end of file diff --git a/tests/querySubject.js b/tests/querySubject.js index 8082a63..adf36e2 100644 --- a/tests/querySubject.js +++ b/tests/querySubject.js @@ -156,6 +156,12 @@ define([ bigArray.body[0].expression.elements[8] ], matches); assert.isSame(3, matches.length); + }, + + "nested descendant subject": function () { + var matches = esquery(nestedFunctions, "!:function :function AssignmentExpression"); + assert.contains([ nestedFunctions.body[0] ], matches); + assert.isSame(1, matches.length); } }); });