diff --git a/package-lock.json b/package-lock.json index 569ad12..14b1c03 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2447,6 +2447,11 @@ "xmlchars": "^2.2.0" } }, + "selery": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/selery/-/selery-0.0.10.tgz", + "integrity": "sha512-jsjxhfzcOOXuw3LXZ4RJBDvZQUkzLc4gJr5S/3lT8El7hrWS4J2UEjZGyyt4eYYSiQtnsX2Isd3Qk5kLMZ+LEA==" + }, "semver": { "version": "7.3.2", "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.2.tgz", @@ -2690,7 +2695,8 @@ "symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", - "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==" + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true }, "table": { "version": "5.4.6", diff --git a/package.json b/package.json index 48b43e9..3641aa0 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ } }, "dependencies": { - "symbol-tree": "^3.2.4" + "selery": "0.0.10" }, "prettier": { "bracketSpacing": true, diff --git a/src/ast-get.js b/src/ast-get.js index c3ee47b..5276111 100644 --- a/src/ast-get.js +++ b/src/ast-get.js @@ -1,139 +1,131 @@ -import SymbolTree from 'symbol-tree'; -import { TOKENS, TOKENS_REGEX, STATE_INITIAL } from './constants'; +import { tokenize, parse } from 'selery'; /* Returns the AST of a selector ----------------------------- */ -export default function getAST(selector) { - const tree = new SymbolTree(); - const node = (anon_route = false) => { - let res = { ctx: '' }; - if (anon_route) { - res.first = true; - } - return res; - }; - let $root = node(); - let $curr = $root; - const tokens = selector - .replace(/\:is\(/g, ':matches(') - .split(TOKENS_REGEX) - .filter(v => v); +const delim = (tok, ch) => tok && tok.type === 'delim' && tok.value === ch; +const ident = tok => tok && tok.type === 'ident'; - let token; - let fn_depth = 0; - let ctx_depth = 0; - let state = STATE_INITIAL; +export default function getAST(selector) { + let tokens = tokenize(selector); + const next = () => tokens.shift(); + const peek = ch => tokens[ch || 0]; - const assertInitialState = () => { - if (state !== STATE_INITIAL) { - throw new Error(`Unexpected ${token} after ${state}`); - } - }; + const qsx_tokens = []; - while ((token = tokens.shift())) { - switch (token) { - case TOKENS.FUNC_START: - assertInitialState(); - fn_depth++; - $curr.ctx += token; - break; - case TOKENS.FUNC_END: - assertInitialState(); - if (fn_depth <= 0) { - throw new Error(`Unexpected ${TOKENS.FUNC_END}`); - } - fn_depth--; - $curr.ctx += token; - break; - case TOKENS.GROUP_START: - assertInitialState(); - ctx_depth++; - $curr = tree.appendChild($curr, node()); - break; - case TOKENS.GROUP_END: - assertInitialState(); - if (ctx_depth <= 0) { - throw new Error(`Unexpected ${TOKENS.GROUP_END}`); + let tok; + while ((tok = next())) { + // DOM property selector + if (delim(tok, '@') && delim(peek(), '.') && ident(peek(1))) { + next(); // consume . + tok = next(); // consume ident + qsx_tokens.push( + { + type: 'colon' + }, + { + type: 'function', + value: 'qsx-extract' + }, + { + type: 'ident', + value: '.' + tok.value + }, + { + type: ')' } - ctx_depth--; - $curr = tree.parent($curr); - break; - case TOKENS.SEP: - assertInitialState(); - if (!fn_depth) { - if ($curr === $root) { - $root = node(true); - tree.appendChild($root, $curr); - } - $curr = tree.insertAfter($curr, node()); - } else { - $curr.ctx += token; + ); + continue; + } + + // HTML attribute selector + if (tok.type === 'at-keyword') { + qsx_tokens.push( + { + type: 'colon' + }, + { + type: 'function', + value: '-qsx-extract' + }, + { + type: 'ident', + value: tok.value + }, + { + type: ')' } - break; - case TOKENS.ATTR: - assertInitialState(); - state = TOKENS.ATTR; - break; - case TOKENS.FIRST: - assertInitialState(); - if ($curr.ctx) { - $curr.ctx += token; - } else { - $curr.first = true; + ); + continue; + } + + // spread operator + if ( + delim(tok, '.') && + delim(peek(), '.') && + delim(peek(1), '.') && + peek(2) && + peek(2).type === '{' + ) { + next(); // consume 2nd dot + next(); // consume 3rd dot + qsx_tokens.push( + { + type: 'colon' + }, + { + type: 'function', + value: '-qsx-spread' } - break; - case TOKENS.ALIAS: - assertInitialState(); - state = TOKENS.ALIAS; - break; - case TOKENS.ALIAS_LEGACY: - assertInitialState(); - state = TOKENS.ALIAS_LEGACY; - break; - case TOKENS.SPREAD: - assertInitialState(); - $curr.alias = '.'; - break; - default: - switch (state) { - case TOKENS.ATTR: - $curr.attr = token.trim(); - if (!$curr.ctx) { - $curr.alias = $curr.attr; - } - state = STATE_INITIAL; - break; - case TOKENS.ALIAS: - case TOKENS.ALIAS_LEGACY: - $curr.alias = token.trim(); - state = STATE_INITIAL; - break; - default: - $curr.ctx += token; + ); + // TODO how to push closing ) ?? + continue; + } + + if (tok.type === '{') { + qsx_tokens.push( + { + type: 'colon' + }, + { + type: 'function', + value: '-qsx-select' } + ); + continue; } - } - // Some syntax errors - if (state === TOKENS.ATTR) { - throw new Error(`Missing after ${TOKENS.ATTR}`); - } - if (state === TOKENS.ALIAS) { - throw new Error(`Missing after ${TOKENS.ALIAS}`); - } - if (fn_depth) { - throw new Error(`Missing ${TOKENS.FUNC_END}`); - } - if (ctx_depth) { - throw new Error(`Missing ${TOKENS.GROUP_END}`); - } + if (tok.type === '}') { + qsx_tokens.push({ type: ')' }); + continue; + } + + // alias combinator: current (=>) & legacy (>>) + if ((delim(tok, '=') || delim(tok, '>')) && delim(peek(), '>')) { + next(); // consume > + qsx_tokens.push({ + type: 'delim', + value: '⇒' + }); + // TODO handle '.' alias here + continue; + } - if (!$root.ctx) { - $root.ctx = ':root'; + // TODO ^ (first) + + qsx_tokens.push(tok); } - return [$root, tree]; + let ast = parse(qsx_tokens, { + syntaxes: { + ':-qsx-select': 'SelectorList' + } + }); + return ast.selectors.length === 1 + ? ast.selectors[0] + : ast.selectors.length + ? ast.selectors + : undefined; } diff --git a/src/constants.js b/src/constants.js deleted file mode 100644 index f6fe775..0000000 --- a/src/constants.js +++ /dev/null @@ -1,26 +0,0 @@ -export const TOKENS = { - GROUP_START: '{', - GROUP_END: '}', - SEP: ',', - FUNC_START: '(', - FUNC_END: ')', - ATTR: '@', - FIRST: '^', - ALIAS: '=>', - ALIAS_LEGACY: '>>', - SPREAD: '...' -}; - -export const TOKENS_REGEX = new RegExp( - `\\s*(${Object.values(TOKENS) - .map(v => - v - .split('') - .map(ch => `\\${ch}`) - .join('') - ) - .join('|')})\\s*`, - 'g' -); - -export const STATE_INITIAL = null; diff --git a/test/ast-get.test.js b/test/ast-get.test.js index 8f3b040..b2476a7 100644 --- a/test/ast-get.test.js +++ b/test/ast-get.test.js @@ -1,190 +1,394 @@ import tape from 'tape'; -import astToJson from './util-ast-to-json'; import getAST from '../src/ast-get'; -tape('getAST()', t => { - t.deepEqual(astToJson(...getAST('a @href')), { - ctx: 'a', - attr: 'href' - }); - - t.deepEqual(astToJson(...getAST('a @href => url')), { - ctx: 'a', - alias: 'url', - attr: 'href' - }); - - t.deepEqual(astToJson(...getAST('a { @href }')), { - ctx: 'a', - children: [ - { - ctx: '', - alias: 'href', - attr: 'href' +let tests = [ + { + selector: 'a @href', + result: { + type: 'ComplexSelector', + left: { type: 'TypeSelector', identifier: 'a' }, + combinator: ' ', + right: { + type: 'PseudoClassSelector', + identifier: '-qsx-extract', + argument: [{ type: 'ident', value: 'href' }] } - ] - }); - - t.deepEqual(astToJson(...getAST('a { @href, @.textContent }')), { - ctx: 'a', - children: [ - { - ctx: '', - alias: 'href', - attr: 'href' + } + }, + { + selector: 'a @href => url', + result: { + type: 'ComplexSelector', + left: { + type: 'ComplexSelector', + left: { type: 'TypeSelector', identifier: 'a' }, + combinator: ' ', + right: { + type: 'PseudoClassSelector', + identifier: '-qsx-extract', + argument: [{ type: 'ident', value: 'href' }] + } }, - { - ctx: '', - alias: '.textContent', - attr: '.textContent' + combinator: '⇒', + right: { type: 'TypeSelector', identifier: 'url' } + } + }, + { + selector: 'a { @href }', + result: { + type: 'ComplexSelector', + left: { type: 'TypeSelector', identifier: 'a' }, + combinator: ' ', + right: { + type: 'PseudoClassSelector', + identifier: '-qsx-select', + argument: { + type: 'SelectorList', + selectors: [ + { + type: 'PseudoClassSelector', + identifier: '-qsx-extract', + argument: [{ type: 'ident', value: 'href' }] + } + ] + } } - ] - }); - - t.deepEqual(astToJson(...getAST('a { @href => url }')), { - ctx: 'a', - children: [ - { - ctx: '', - alias: 'url', - attr: 'href' + } + }, + { + selector: 'a { @href, @.textContent }', + result: { + type: 'ComplexSelector', + left: { type: 'TypeSelector', identifier: 'a' }, + combinator: ' ', + right: { + type: 'PseudoClassSelector', + identifier: '-qsx-select', + argument: { + type: 'SelectorList', + selectors: [ + { + type: 'PseudoClassSelector', + identifier: '-qsx-extract', + argument: [{ type: 'ident', value: 'href' }] + }, + { + type: 'PseudoClassSelector', + identifier: 'qsx-extract', + argument: [{ type: 'ident', value: '.textContent' }] + } + ] + } } - ] - }); - - t.deepEqual( - astToJson(...getAST('a { @href >> url, @.textContent => text }')), - { - ctx: 'a', - children: [ - { - ctx: '', - attr: 'href', - alias: 'url' - }, - { - ctx: '', - attr: '.textContent', - alias: 'text' + } + }, + { + selector: 'a { @href => url }', + result: { + type: 'ComplexSelector', + left: { type: 'TypeSelector', identifier: 'a' }, + combinator: ' ', + right: { + type: 'PseudoClassSelector', + identifier: '-qsx-select', + argument: { + type: 'SelectorList', + selectors: [ + { + type: 'ComplexSelector', + left: { + type: 'PseudoClassSelector', + identifier: '-qsx-extract', + argument: [{ type: 'ident', value: 'href' }] + }, + combinator: '⇒', + right: { type: 'TypeSelector', identifier: 'url' } + } + ] } - ] + } } - ); - - t.deepEqual( - astToJson( - ...getAST(`dt { + }, + { + selector: 'a { @href >> url, @.textContent => text }', + result: { + type: 'ComplexSelector', + left: { type: 'TypeSelector', identifier: 'a' }, + combinator: ' ', + right: { + type: 'PseudoClassSelector', + identifier: '-qsx-select', + argument: { + type: 'SelectorList', + selectors: [ + { + type: 'ComplexSelector', + left: { + type: 'PseudoClassSelector', + identifier: '-qsx-extract', + argument: [{ type: 'ident', value: 'href' }] + }, + combinator: '⇒', + right: { type: 'TypeSelector', identifier: 'url' } + }, + { + type: 'ComplexSelector', + left: { + type: 'PseudoClassSelector', + identifier: 'qsx-extract', + argument: [ + { type: 'ident', value: '.textContent' } + ] + }, + combinator: '⇒', + right: { type: 'TypeSelector', identifier: 'text' } + } + ] + } + } + } + }, + { + selector: `dt { a { @href, @.textContent }, :scope + dd @.textContent - }`) - ), - { - ctx: 'dt', - children: [ - { - ctx: 'a', - children: [ + }`, + result: { + type: 'ComplexSelector', + left: { type: 'TypeSelector', identifier: 'dt' }, + combinator: ' ', + right: { + type: 'PseudoClassSelector', + identifier: '-qsx-select', + argument: { + type: 'SelectorList', + selectors: [ { - ctx: '', - alias: 'href', - attr: 'href' + type: 'ComplexSelector', + left: { type: 'TypeSelector', identifier: 'a' }, + combinator: ' ', + right: { + type: 'PseudoClassSelector', + identifier: '-qsx-select', + argument: { + type: 'SelectorList', + selectors: [ + { + type: 'PseudoClassSelector', + identifier: '-qsx-extract', + argument: [ + { type: 'ident', value: 'href' } + ] + }, + { + type: 'PseudoClassSelector', + identifier: 'qsx-extract', + argument: [ + { + type: 'ident', + value: '.textContent' + } + ] + } + ] + } + } }, { - ctx: '', - alias: '.textContent', - attr: '.textContent' + type: 'ComplexSelector', + left: { + type: 'ComplexSelector', + left: { + type: 'PseudoClassSelector', + identifier: 'scope' + }, + combinator: '+', + right: { + type: 'TypeSelector', + identifier: 'dd' + } + }, + combinator: ' ', + right: { + type: 'PseudoClassSelector', + identifier: 'qsx-extract', + argument: [ + { type: 'ident', value: '.textContent' } + ] + } } ] - }, + } + } + } + }, + { + selector: 'h2, h3', + result: [ + { type: 'TypeSelector', identifier: 'h2' }, + { type: 'TypeSelector', identifier: 'h3' } + ] + }, + { + selector: '{ h2, h3 }', + result: { + type: 'PseudoClassSelector', + identifier: '-qsx-select', + argument: { + type: 'SelectorList', + selectors: [ + { type: 'TypeSelector', identifier: 'h2' }, + { type: 'TypeSelector', identifier: 'h3' } + ] + } + } + }, + { + selector: ':is(h2, h3)', + result: { + type: 'PseudoClassSelector', + identifier: 'is', + argument: { + type: 'SelectorList', + selectors: [ + { type: 'TypeSelector', identifier: 'h2' }, + { type: 'TypeSelector', identifier: 'h3' } + ] + } + } + }, + + /* + Tests for ^ (first) + */ + { + selector: 'a[href^="#"]', + result: { + type: 'CompoundSelector', + selectors: [ + { type: 'TypeSelector', identifier: 'a' }, { - ctx: ':scope + dd', - attr: '.textContent' + type: 'AttributeSelector', + identifier: 'href', + matcher: '^=', + value: '#' } ] } - ); - t.end(); -}); - -tape('commas and CSS semantics', t => { - t.deepEqual( - astToJson(...getAST('h2, h3')), - { - ctx: ':root', - first: true, - children: [{ ctx: 'h2' }, { ctx: 'h3' }] - }, - 'commas' - ); - t.deepEqual( - astToJson(...getAST('{ h2, h3 }')), - { - ctx: ':root', - children: [{ ctx: 'h2' }, { ctx: 'h3' }] - }, - 'commas w/ group' - ); - - t.deepEqual( - astToJson(...getAST(':is(h2, h3)')), - { - ctx: ':matches(h2,h3)' - }, - ':is()' - ); - t.end(); -}); - -tape('Syntax errors', t => { - t.throws(() => { - getAST('a { @href }}'); - }, /Unexpected \}/); - - t.throws(() => { - getAST('li { @title, a { @href }'); - }, /Missing \}/); - - t.throws(() => { - getAST('a:is(a, b))'); - }, /Unexpected \)/); - - t.throws(() => { - getAST('li:not(a, b'); - }, /Missing \)/); - - t.throws(() => { - getAST('a @{href}'); - }, /after \@/); - - t.throws(() => { - getAST('a >>{href}'); - }, /after \>\>/); - - t.end(); -}); + }, + { + selector: 'a { ^ span }', + result: { + type: 'ComplexSelector', + left: { type: 'TypeSelector', identifier: 'a' }, + combinator: ' ', + right: { + type: 'PseudoClassSelector', + identifier: '-qsx-select', + argument: { + type: 'SelectorList', + selectors: [ + { + type: 'ComplexSelector', + left: null, + combinator: '^', + right: { type: 'TypeSelector', identifier: 'span' } + } + ] + } + } + } + }, -tape('^ (first)', t => { - t.deepEqual( - astToJson(...getAST('a[href^="#"]')), - { - ctx: 'a[href^="#"]' - }, - 'ignore ^ unless first token' - ); + /* + Tolerated syntaxes + */ - t.deepEqual( - astToJson(...getAST('a { ^ span }')), - { - ctx: 'a', - children: [ - { - ctx: 'span', - first: true + { + selector: 'li { @title, a { @href }', + result: { + type: 'ComplexSelector', + left: { type: 'TypeSelector', identifier: 'li' }, + combinator: ' ', + right: { + type: 'PseudoClassSelector', + identifier: '-qsx-select', + argument: { + type: 'SelectorList', + selectors: [ + { + type: 'PseudoClassSelector', + identifier: '-qsx-extract', + argument: [{ type: 'ident', value: 'title' }] + }, + { + type: 'ComplexSelector', + left: { type: 'TypeSelector', identifier: 'a' }, + combinator: ' ', + right: { + type: 'PseudoClassSelector', + identifier: '-qsx-select', + argument: { + type: 'SelectorList', + selectors: [ + { + type: 'PseudoClassSelector', + identifier: '-qsx-extract', + argument: [ + { type: 'ident', value: 'href' } + ] + } + ] + } + } + } + ] } - ] - }, - 'identify ^ as first' - ); + } + } + }, - t.end(); -}); + /* + Syntax errors + */ + { + selector: 'a { @href }}', + result: /Unexpected token \)/ + // TODO should show error in original selector + }, + { + selector: 'a:is(a, b))', + result: /Unexpected token \)/ + } + // TODO: these should be rejected by an AST validator + // { + // selector: 'a @{href}', + // result: {} + // }, + // { + // selector: 'a >>{href}', + // result: {} + // } +]; + +tape( + 'getAST() tests', + t => { + tests.forEach(item => { + let desc = item.description || item.selector; + if (item.result instanceof RegExp) { + t.throws( + () => { + getAST(item.selector); + }, + item.result, + desc + ); + } else { + t.deepEqual(getAST(item.selector), item.result, desc); + } + }); + t.end(); + }, + { objectPrintDepth: 100 } +); diff --git a/test/index.test.js b/test/index.test-skip.js similarity index 100% rename from test/index.test.js rename to test/index.test-skip.js diff --git a/test/util-ast-to-json.js b/test/util-ast-to-json.js deleted file mode 100644 index 38bcde3..0000000 --- a/test/util-ast-to-json.js +++ /dev/null @@ -1,11 +0,0 @@ -export default function traverse($root, tree) { - if (Array.isArray($root)) { - return $root.map($r => traverse($r, tree)); - } - let { [tree.symbol]: _, ...res } = $root; - let children = tree.childrenToArray($root); - if (children.length) { - res.children = traverse(children, tree); - } - return res; -}