diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..1b4258a --- /dev/null +++ b/.gitmodules @@ -0,0 +1,4 @@ +[submodule "integration/stylelint"] + path = integration/stylelint + url = https://github.com/chriseppstein/stylelint.git + branch = selector-parser-4.0 diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..58d03f9 --- /dev/null +++ b/.npmignore @@ -0,0 +1 @@ +integration/*/ diff --git a/.travis.yml b/.travis.yml index 0a0c34b..cb4efd3 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,11 +3,20 @@ sudo: false language: node_js matrix: include: + - node_js: '10' + env: INTEGRATION=false - node_js: '8' - - node_js: '7' + env: INTEGRATION=true - node_js: '6' - - node_js: '5' - - node_js: '4' - + env: INTEGRATION=false +install: + - npm install -g npm@latest + - npm ci +script: + - npm test + - ./integration_test.sh +cache: + directories: + - ~/.npm after_success: - './node_modules/.bin/nyc report --reporter=text-lcov | ./node_modules/.bin/coveralls' diff --git a/API.md b/API.md index 99bbab0..3d8d2fd 100644 --- a/API.md +++ b/API.md @@ -78,6 +78,18 @@ Arguments: * `props (object)`: The new node's properties. +Notes: +* **Descendant Combinators** The value of descendant combinators created by the + parser always just a single space (`" "`). For descendant selectors with no + comments, additional space is now stored in `node.spaces.before`. Depending + on the location of comments, additional spaces may be stored in + `node.raws.spaces.before`, `node.raws.spaces.after`, or `node.raws.value`. +* **Named Combinators** Although, nonstandard and unlikely to ever become a standard, + named combinators like `/deep/` and `/for/` are parsed as combinators. The + `node.value` is name after being unescaped and normalized as lowercase. The + original value for the combinator name is stored in `node.raws.value`. + + ### `parser.comment([props])` Creates a new comment. @@ -275,6 +287,17 @@ String(cloned); // => #search ``` +### `node.isAtPosition(line, column)` + +Return a `boolean` indicating whether this node includes the character at the +position of the given line and column. Returns `undefined` if the nodes lack +sufficient source metadata to determine the position. + +Arguments: + +* `line`: 1-index based line number relative to the start of the selector. +* `column`: 1-index based column number relative to the start of the selector. + ### `node.spaces` Extra whitespaces around the node will be moved into `node.spaces.before` and @@ -285,15 +308,13 @@ no semantic meaning: h1 , h2 {} ``` -However, *combinating* spaces will form a `combinator` node: +For descendent selectors, the value is always a single space. ```css h1 h2 {} ``` -A `combinator` node may only have the `spaces` property set if the combinator -value is a non-whitespace character, such as `+`, `~` or `>`. Otherwise, the -combinator value will contain all of the spaces between selectors. +Additional whitespace is found in either the `node.spaces.before` and `node.spaces.after` depending on the presence of comments or other whitespace characters. If the actual whitespace does not start or end with a single space, the node's raw value is set to the actual space(s) found in the source. ### `node.source` @@ -369,6 +390,19 @@ Arguments: * `index`: The index of the node to return. +### `container.atPosition(line, column)` + +Returns the node at the source position `index`. + +```js +selector.at(0) === selector.first; +selector.at(0) === selector.nodes[0]; +``` + +Arguments: + +* `index`: The index of the node to return. + ### `container.index(node)` Return the index of the node within its container. @@ -551,7 +585,7 @@ support parsing of legacy CSS hacks. ## Selector nodes -A selector node represents a single compound selector. For example, this +A selector node represents a single complex selector. For example, this selector string `h1 h2 h3, [href] > p`, is represented as two selector nodes. It has no special functionality of its own. diff --git a/CHANGELOG.md b/CHANGELOG.md index ec6a300..655122b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,235 @@ +# 5.0.0-rc.0 + +This release has **BREAKING CHANGES** that were required to fix regressions +in 4.0.0 and to make the Combinator Node API consistent for all combinator +types. Please read carefully. + +## Summary of Changes + +* The way a descendent combinator that isn't a single space character (E.g. `.a .b`) is stored in the AST has changed. +* Named Combinators (E.g. `.a /for/ .b`) are now properly parsed as a combinator. +* It is now possible to look up a node based on the source location of a character in that node and to query nodes if they contain some character. +* Several bug fixes that caused the parser to hang and run out of memory when a `/` was encountered have been fixed. +* The minimum supported version of Node is now `v6.0.0`. + +### Changes to the Descendent Combinator + +In prior releases, the value of a descendant combinator with multiple spaces included all the spaces. + +* `.a .b`: Extra spaces are now stored as space before. + - Old & Busted: + - `combinator.value === " "` + - New hotness: + - `combinator.value === " " && combinator.spaces.before === " "` +* `.a /*comment*/.b`: A comment at the end of the combinator causes extra space to become after space. + - Old & Busted: + - `combinator.value === " "` + - `combinator.raws.value === " /*comment/"` + - New hotness: + - `combinator.value === " "` + - `combinator.spaces.after === " "` + - `combinator.raws.spaces.after === " /*comment*/"` +* `.a.b`: whitespace that doesn't start or end with a single space character is stored as a raw value. + - Old & Busted: + - `combinator.value === "\n"` + - `combinator.raws.value === undefined` + - New hotness: + - `combinator.value === " "` + - `combinator.raws.value === "\n"` + +### Support for "Named Combinators" + +Although, nonstandard and unlikely to ever become a standard, combinators like `/deep/` and `/for/` are now properly supported. + +Because they've been taken off the standardization track, there is no spec-official name for combinators of the form `//`. However, I talked to [Tab Atkins](https://twitter.com/tabatkins) and we agreed to call them "named combinators" so now they are called that. + +Before this release such named combinators were parsed without intention and generated three nodes of type `"tag"` where the first and last nodes had a value of `"/"`. + +* `.a /for/ .b` is parsed as a combinator. + - Old & Busted: + - `root.nodes[0].nodes[1].type === "tag"` + - `root.nodes[0].nodes[1].value === "/"` + - New hotness: + - `root.nodes[0].nodes[1].type === "combinator"` + - `root.nodes[0].nodes[1].value === "/for/"` +* `.a /F\6fR/ .b` escapes are handled and uppercase is normalized. + - Old & Busted: + - `root.nodes[0].nodes[2].type === "tag"` + - `root.nodes[0].nodes[2].value === "F\\6fR"` + - New hotness: + - `root.nodes[0].nodes[1].type === "combinator"` + - `root.nodes[0].nodes[1].value === "/for/"` + - `root.nodes[0].nodes[1].raws.value === "/F\\6fR/"` + +### Source position checks and lookups + +A new API was added to look up a node based on the source location. + +```js +const selectorParser = require("postcss-selector-parser"); +// You can find the most specific node for any given character +let combinator = selectorParser.astSync(".a > .b").atPosition(1,4); +combinator.toString() === " > "; +// You can check if a node includes a specific character +// Whitespace surrounding the node that is owned by that node +// is included in the check. +[2,3,4,5,6].map(column => combinator.isAtPosition(1, column)); +// => [false, true, true, true, false] +``` + +# 4.0.0 + +This release has **BREAKING CHANGES** that were required to fix bugs regarding values with escape sequences. Please read carefully. + +* **Identifiers with escapes** - CSS escape sequences are now hidden from the public API by default. + The normal value of a node like a class name or ID, or an aspect of a node such as attribute + selector's value, is unescaped. Escapes representing Non-ascii characters are unescaped into + unicode characters. For example: `bu\tton, .\31 00, #i\2764\FE0Fu, [attr="value is \"quoted\""]` + will parse respectively to the values `button`, `100`, `iā¤ļøu`, `value is "quoted"`. + The original escape sequences for these values can be found in the corresponding property name + in `node.raws`. Where possible, deprecation warnings were added, but the nature + of escape handling makes it impossible to detect what is escaped or not. Our expectation is + that most users are neither expecting nor handling escape sequences in their use of this library, + and so for them, this is a bug fix. Users who are taking care to handle escapes correctly can + now update their code to remove the escape handling and let us do it for them. + +* **Mutating values with escapes** - When you make an update to a node property that has escape handling + The value is assumed to be unescaped, and any special characters are escaped automatically and + the corresponding `raws` value is immediately updated. This can result in changes to the original + escape format. Where the exact value of the escape sequence is important there are methods that + allow both values to be set in conjunction. There are a number of new convenience methods for + manipulating values that involve escapes, especially for attributes values where the quote mark + is involved. See https://github.com/postcss/postcss-selector-parser/pull/133 for an extensive + write-up on these changes. + + +**Upgrade/API Example** + +In `3.x` there was no unescape handling and internal consistency of several properties was the caller's job to maintain. It was very easy for the developer +to create a CSS file that did not parse correctly when some types of values +were in use. + +```js +const selectorParser = require("postcss-selector-parser"); +let attr = selectorParser.attribute({attribute: "id", operator: "=", value: "a-value"}); +attr.value; // => "a-value" +attr.toString(); // => [id=a-value] +// Add quotes to an attribute's value. +// All these values have to be set by the caller to be consistent: +// no internal consistency is maintained. +attr.raws.unquoted = attr.value +attr.value = "'" + attr.value + "'"; +attr.value; // => "'a-value'" +attr.quoted = true; +attr.toString(); // => "[id='a-value']" +``` + +In `4.0` there is a convenient API for setting and mutating values +that may need escaping. Especially for attributes. + +```js +const selectorParser = require("postcss-selector-parser"); + +// The constructor requires you specify the exact escape sequence +let className = selectorParser.className({value: "illegal class name", raws: {value: "illegal\\ class\\ name"}}); +className.toString(); // => '.illegal\\ class\\ name' + +// So it's better to set the value as a property +className = selectorParser.className(); +// Most properties that deal with identifiers work like this +className.value = "escape for me"; +className.value; // => 'escape for me' +className.toString(); // => '.escape\\ for\\ me' + +// emoji and all non-ascii are escaped to ensure it works in every css file. +className.value = "šŸ˜±šŸ¦„šŸ˜"; +className.value; // => 'šŸ˜±šŸ¦„šŸ˜' +className.toString(); // => '.\\1F631\\1F984\\1F60D' + +// you can control the escape sequence if you want, or do bad bad things +className.setPropertyAndEscape('value', 'xxxx', 'yyyy'); +className.value; // => "xxxx" +className.toString(); // => ".yyyy" + +// Pass a value directly through to the css output without escaping it. +className.setPropertyWithoutEscape('value', '$REPLACE_ME$'); +className.value; // => "$REPLACE_ME$" +className.toString(); // => ".$REPLACE_ME$" + +// The biggest changes are to the Attribute class +// passing quoteMark explicitly is required to avoid a deprecation warning. +let attr = selectorParser.attribute({attribute: "id", operator: "=", value: "a-value", quoteMark: null}); +attr.toString(); // => "[id=a-value]" +// Get the value with quotes on it and any necessary escapes. +// This is the same as reading attr.value in 3.x. +attr.getQuotedValue(); // => "a-value"; +attr.quoteMark; // => null + +// Add quotes to an attribute's value. +attr.quoteMark = "'"; // This is all that's required. +attr.toString(); // => "[id='a-value']" +attr.quoted; // => true +// The value is still the same, only the quotes have changed. +attr.value; // => a-value +attr.getQuotedValue(); // => "'a-value'"; + +// deprecated assignment, no warning because there's no escapes +attr.value = "new-value"; +// no quote mark is needed so it is removed +attr.getQuotedValue(); // => "new-value"; + +// deprecated assignment, +attr.value = "\"a 'single quoted' value\""; +// > (node:27859) DeprecationWarning: Assigning an attribute a value containing characters that might need to be escaped is deprecated. Call attribute.setValue() instead. +attr.getQuotedValue(); // => '"a \'single quoted\' value"'; +// quote mark inferred from first and last characters. +attr.quoteMark; // => '"' + +// setValue takes options to make manipulating the value simple. +attr.setValue('foo', {smart: true}); +// foo doesn't require any escapes or quotes. +attr.toString(); // => '[id=foo]' +attr.quoteMark; // => null + +// An explicit quote mark can be specified +attr.setValue('foo', {quoteMark: '"'}); +attr.toString(); // => '[id="foo"]' + +// preserves quote mark by default +attr.setValue('bar'); +attr.toString(); // => '[id="bar"]' +attr.quoteMark = null; +attr.toString(); // => '[id=bar]' + +// with no arguments, it preserves quote mark even when it's not a great idea +attr.setValue('a value \n that should be quoted'); +attr.toString(); // => '[id=a\\ value\\ \\A\\ that\\ should\\ be\\ quoted]' + +// smart preservation with a specified default +attr.setValue('a value \n that should be quoted', {smart: true, preferCurrentQuoteMark: true, quoteMark: "'"}); +// => "[id='a value \\A that should be quoted']" +attr.quoteMark = '"'; +// => '[id="a value \\A that should be quoted"]' + +// this keeps double quotes because it wants to quote the value and the existing value has double quotes. +attr.setValue('this should be quoted', {smart: true, preferCurrentQuoteMark: true, quoteMark: "'"}); +// => '[id="this should be quoted"]' + +// picks single quotes because the value has double quotes +attr.setValue('a "double quoted" value', {smart: true, preferCurrentQuoteMark: true, quoteMark: "'"}); +// => "[id='a "double quoted" value']" + +// setPropertyAndEscape lets you do anything you want. Even things that are a bad idea and illegal. +attr.setPropertyAndEscape('value', 'xxxx', 'the password is 42'); +attr.value; // => "xxxx" +attr.toString(); // => "[id=the password is 42]" + +// Pass a value directly through to the css output without escaping it. +attr.setPropertyWithoutEscape('value', '$REPLACEMENT$'); +attr.value; // => "$REPLACEMENT$" +attr.toString(); // => "[id=$REPLACEMENT$]" +``` + # 3.1.2 * Fix: Removed dot-prop dependency since it's no longer written in es5. diff --git a/integration/stylelint b/integration/stylelint new file mode 160000 index 0000000..923878c --- /dev/null +++ b/integration/stylelint @@ -0,0 +1 @@ +Subproject commit 923878cfec814570392101c232942d63a80b2af0 diff --git a/integration_test.sh b/integration_test.sh new file mode 100755 index 0000000..9a9a5ce --- /dev/null +++ b/integration_test.sh @@ -0,0 +1,12 @@ +#!/bin/bash +if [[ $INTEGRATION == "false" ]]; then + exit 0; +fi +git submodule update --init --recursive +npm link +cd integration/stylelint +npm link postcss-selector-parser +npm install +NODE_VERSION=`node -e "console.log(process.version.replace(/v(\d).*/,function(m){return m[1]}))"` +CI="tests $NODE_VERSION" +npm run jest -- --maxWorkers=2 --testPathIgnorePatterns lib/__tests__/standalone-cache.test.js || exit $? diff --git a/package-lock.json b/package-lock.json index 93bdb94..95d6ed2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "postcss-selector-parser", - "version": "4.0.0-rc.1", + "version": "5.0.0-rc.0", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 980c06a..dbac6f2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "postcss-selector-parser", - "version": "4.0.0", + "version": "5.0.0-rc.0", "devDependencies": { "ava": "^0.24.0", "babel-cli": "^6.4.0", @@ -34,7 +34,7 @@ ], "scripts": { "pretest": "eslint src", - "prepublish": "del-cli dist && BABEL_ENV=publish babel src --out-dir dist --ignore /__tests__/", + "prepare": "del-cli dist && BABEL_ENV=publish babel src --out-dir dist --ignore /__tests__/", "lintfix": "eslint --fix src", "report": "nyc report --reporter=html", "test": "nyc ava src/__tests__/*.js", @@ -47,7 +47,7 @@ }, "license": "MIT", "engines": { - "node": ">=4" + "node": ">=6" }, "homepage": "https://github.com/postcss/postcss-selector-parser", "contributors": [ diff --git a/postcss-selector-parser.d.ts b/postcss-selector-parser.d.ts index bfd4341..52bdb77 100644 --- a/postcss-selector-parser.d.ts +++ b/postcss-selector-parser.d.ts @@ -164,6 +164,13 @@ declare namespace parser { next(): Node; prev(): Node; clone(opts: {[override: string]:any}): Node; + /** + * Return whether this node includes the character at the position of the given line and column. + * Returns undefined if the nodes lack sufficient source metadata to determine the position. + * @param line 1-index based line number relative to the start of the selector. + * @param column 1-index based column number relative to the start of the selector. + */ + isAtPosition(line: number, column: number): boolean | undefined; /** * Some non-standard syntax doesn't follow normal escaping rules for css, * this allows the escaped value to be specified directly, allowing illegal characters to be @@ -201,6 +208,20 @@ declare namespace parser { append(selector: Selector): Container; prepend(selector: Selector): Container; at(index: number): Node; + /** + * Return the most specific node at the line and column number given. + * The source location is based on the original parsed location, locations aren't + * updated as selector nodes are mutated. + * + * Note that this location is relative to the location of the first character + * of the selector, and not the location of the selector in the overall document + * when used in conjunction with postcss. + * + * If not found, returns undefined. + * @param line The line number of the node to find. (1-based index) + * @param col The column number of the node to find. (1-based index) + */ + atPosition(line: number, column: number): Node; index(child: Node): number; readonly first: Node; readonly last: Node; @@ -259,6 +280,7 @@ declare namespace parser { * a postcss Rule node, a better error message is raised. */ error(message: string, options?: ErrorOptions): Error; + nodeAt(line: number, column: number): Node } function root(opts: ContainerOptions): Root; function isRoot(node: any): node is Root; diff --git a/src/__tests__/combinators.js b/src/__tests__/combinators.js index 5fa1e79..3a55002 100644 --- a/src/__tests__/combinators.js +++ b/src/__tests__/combinators.js @@ -1,8 +1,10 @@ +import {COMBINATOR} from '../selectors/types'; import {test} from './util/helpers'; test('multiple combinating spaces', 'h1 h2', (t, tree) => { t.deepEqual(tree.nodes[0].nodes[0].value, 'h1'); - t.deepEqual(tree.nodes[0].nodes[1].value, ' '); + t.deepEqual(tree.nodes[0].nodes[1].value, ' '); + t.deepEqual(tree.nodes[0].nodes[1].toString(), ' '); t.deepEqual(tree.nodes[0].nodes[2].value, 'h2'); }); @@ -68,6 +70,41 @@ test('adjacent sibling combinator (5)', 'h1~h2~h3', (t, tree) => { t.deepEqual(tree.nodes[0].nodes[4].value, 'h3'); }); +test('piercing combinator', '.a >>> .b', (t, tree) => { + t.deepEqual(tree.nodes[0].nodes[0].value, 'a'); + t.deepEqual(tree.nodes[0].nodes[1].spaces.before, ' '); + t.deepEqual(tree.nodes[0].nodes[1].value, '>>>'); + t.deepEqual(tree.nodes[0].nodes[1].spaces.after, ' '); + t.deepEqual(tree.nodes[0].nodes[2].value, 'b'); +}); + +test('named combinators', 'a /deep/ b', (t, tree) => { + let nodes = tree.nodes[0].nodes; + t.deepEqual(nodes[0].value, 'a'); + t.deepEqual(nodes[1].type, COMBINATOR); + t.deepEqual(nodes[1].toString(), ' /deep/ '); + t.deepEqual(nodes[1].value, '/deep/'); + t.deepEqual(nodes[2].value, 'b'); +}); + +test('named combinators with escapes', 'a /dee\\p/ b', (t, tree) => { + let nodes = tree.nodes[0].nodes; + t.deepEqual(nodes[0].value, 'a'); + t.deepEqual(nodes[1].type, COMBINATOR); + t.deepEqual(nodes[1].toString(), ' /dee\\p/ '); + t.deepEqual(nodes[1].value, '/deep/'); + t.deepEqual(nodes[2].value, 'b'); +}); + +test('named combinators with escapes and uppercase', 'a /DeE\\p/ b', (t, tree) => { + let nodes = tree.nodes[0].nodes; + t.deepEqual(nodes[0].value, 'a'); + t.deepEqual(nodes[1].type, COMBINATOR); + t.deepEqual(nodes[1].toString(), ' /DeE\\p/ '); + t.deepEqual(nodes[1].value, '/deep/'); + t.deepEqual(nodes[2].value, 'b'); +}); + test('multiple combinators', 'h1~h2>h3', (t, tree) => { t.deepEqual(tree.nodes[0].nodes[1].value, '~', 'should have a combinator'); t.deepEqual(tree.nodes[0].nodes[3].value, '>', 'should have a combinator'); @@ -91,6 +128,12 @@ test('ending in comment has no trailing combinator', ".bar /* comment 3 */", (t, let nodeTypes = tree.nodes[0].map(n => n.type); t.deepEqual(nodeTypes, ["class"]); }); +test('The combinating space is not a space character', ".bar\n.baz", (t, tree) => { + let nodeTypes = tree.nodes[0].map(n => n.type); + t.deepEqual(nodeTypes, ["class", "combinator", "class"]); + t.deepEqual(tree.nodes[0].nodes[1].value, ' ', 'should have a combinator'); + t.deepEqual(tree.nodes[0].nodes[1].raws.value, '\n', 'should have a raw combinator value'); +}); test('with spaces and a comment has only one combinator', ".bar /* comment 3 */ > .foo", (t, tree) => { let nodeTypes = tree.nodes[0].map(n => n.type); t.deepEqual(nodeTypes, ["class", "combinator", "class"]); diff --git a/src/__tests__/comments.js b/src/__tests__/comments.js index 1d21cde..f60ec41 100644 --- a/src/__tests__/comments.js +++ b/src/__tests__/comments.js @@ -5,6 +5,24 @@ test('comments', '/*test comment*/h2', (t, tree) => { t.deepEqual(tree.nodes[0].nodes[1].value, 'h2'); }); +test('comments', '.a /*test comment*/label', (t, tree) => { + t.deepEqual(tree.nodes[0].nodes[0].type, 'class'); + t.deepEqual(tree.nodes[0].nodes[1].type, 'combinator'); + t.deepEqual(tree.nodes[0].nodes[1].value, ' '); + t.deepEqual(tree.nodes[0].nodes[1].spaces.after, ' '); + t.deepEqual(tree.nodes[0].nodes[1].rawSpaceAfter, ' /*test comment*/'); + t.deepEqual(tree.nodes[0].nodes[2].type, 'tag'); +}); + +test('comments', '.a /*test comment*/ label', (t, tree) => { + t.deepEqual(tree.nodes[0].nodes[0].type, 'class'); + t.deepEqual(tree.nodes[0].nodes[1].type, 'combinator'); + t.deepEqual(tree.nodes[0].nodes[1].value, ' '); + t.deepEqual(tree.nodes[0].nodes[1].spaces.before, ' '); + t.deepEqual(tree.nodes[0].nodes[1].rawSpaceBefore, ' /*test comment*/ '); + t.deepEqual(tree.nodes[0].nodes[2].type, 'tag'); +}); + test('multiple comments and other things', 'h1/*test*/h2/*test*/.test/*test*/', (t, tree) => { t.deepEqual(tree.nodes[0].nodes[0].type, 'tag', 'should have a tag'); t.deepEqual(tree.nodes[0].nodes[1].type, 'comment', 'should have a comment'); diff --git a/src/__tests__/container.js b/src/__tests__/container.js index 5823f4a..e41c68d 100644 --- a/src/__tests__/container.js +++ b/src/__tests__/container.js @@ -349,3 +349,55 @@ test('container#insertAfter (during iteration)', (t) => { }); t.deepEqual(out, 'h1[class], h2[class], h3[class]'); }); + +test('Container#atPosition first pseudo', (t) => { + parse(':not(.foo),\n#foo > :matches(ol, ul)', (root) => { + let node = root.atPosition(1, 1); + t.deepEqual(node.type, "pseudo"); + t.deepEqual(node.toString(), ":not(.foo)"); + }); +}); + +test('Container#atPosition class in pseudo', (t) => { + parse(':not(.foo),\n#foo > :matches(ol, ul)', (root) => { + let node = root.atPosition(1, 6); + t.deepEqual(node.type, "class"); + t.deepEqual(node.toString(), ".foo"); + }); +}); + +test('Container#atPosition id in second selector', (t) => { + parse(':not(.foo),\n#foo > :matches(ol, ul)', (root) => { + let node = root.atPosition(2, 1); + t.deepEqual(node.type, "id"); + t.deepEqual(node.toString(), "\n#foo"); + }); +}); + +test('Container#atPosition combinator in second selector', (t) => { + parse(':not(.foo),\n#foo > :matches(ol, ul)', (root) => { + let node = root.atPosition(2, 6); + t.deepEqual(node.type, "combinator"); + t.deepEqual(node.toString(), " > "); + + let nodeSpace = root.atPosition(2, 5); + t.deepEqual(nodeSpace.type, "selector"); + t.deepEqual(nodeSpace.toString(), "\n#foo > :matches(ol, ul)"); + }); +}); + +test('Container#atPosition tag in second selector pseudo', (t) => { + parse(':not(.foo),\n#foo > :matches(ol, ul)', (root) => { + let node = root.atPosition(2, 17); + t.deepEqual(node.type, "tag"); + t.deepEqual(node.toString(), "ol"); + }); +}); + +test('Container#atPosition comma in second selector pseudo', (t) => { + parse(':not(.foo),\n#foo > :matches(ol, ul)', (root) => { + let node = root.atPosition(2, 19); + t.deepEqual(node.type, "pseudo"); + t.deepEqual(node.toString(), ":matches(ol, ul)"); + }); +}); diff --git a/src/__tests__/exceptions.js b/src/__tests__/exceptions.js index 2779f1d..df4b704 100644 --- a/src/__tests__/exceptions.js +++ b/src/__tests__/exceptions.js @@ -22,3 +22,4 @@ throws('bad syntax', '-moz-osx-font-smoothing: grayscale'); throws('missing backslash for semicolon', '.;'); throws('missing backslash for semicolon', '.\;'); +throws('unexpected / foo', '-Option\/root', "Unexpected '/'. Escaping special characters with \\ may help."); diff --git a/src/__tests__/lossy.js b/src/__tests__/lossy.js index c044d5b..9852940 100644 --- a/src/__tests__/lossy.js +++ b/src/__tests__/lossy.js @@ -75,7 +75,7 @@ ava('pseudo - extra whitespace', testLossy, 'a:not( h2 )', 'a:not(h2)'); ava('comments - comment inside descendant selector', testLossy, "div /* wtf */.foo", "div /* wtf */.foo"); ava('comments - comment inside complex selector', testLossy, "div /* wtf */ > .foo", "div/* wtf */>.foo"); -ava('comments - comment inside compound selector with space', testLossy, "div /* wtf */ .foo", "div /* wtf */ .foo"); +ava('comments - comment inside compound selector with space', testLossy, "div /* wtf */ .foo", "div /* wtf */.foo"); ava('@words - space before', testLossy, ' @media', '@media'); ava('@words - space after', testLossy, '@media ', '@media'); ava('@words - maintains space between', testLossy, '@media (min-width: 700px) and (orientation: landscape)', '@media (min-width: 700px) and (orientation: landscape)'); diff --git a/src/__tests__/node.js b/src/__tests__/node.js index 724e402..91eddea 100644 --- a/src/__tests__/node.js +++ b/src/__tests__/node.js @@ -80,3 +80,43 @@ test('Node#setPropertyWithoutEscape without existing raws', (t) => { }); t.deepEqual(out, '.w+t+f'); }); + +test('Node#isAtPosition', (t) => { + parse(':not(.foo),\n#foo > :matches(ol, ul)', (root) => { + t.deepEqual(root.isAtPosition(1, 1), true); + t.deepEqual(root.isAtPosition(1, 10), true); + t.deepEqual(root.isAtPosition(2, 23), true); + t.deepEqual(root.isAtPosition(2, 24), false); + let selector = root.first; + t.deepEqual(selector.isAtPosition(1, 1), true); + t.deepEqual(selector.isAtPosition(1, 10), true); + t.deepEqual(selector.isAtPosition(1, 11), false); + let pseudoNot = selector.first; + t.deepEqual(pseudoNot.isAtPosition(1, 1), true); + t.deepEqual(pseudoNot.isAtPosition(1, 7), true); + t.deepEqual(pseudoNot.isAtPosition(1, 10), true); + t.deepEqual(pseudoNot.isAtPosition(1, 11), false); + let notSelector = pseudoNot.first; + t.deepEqual(notSelector.isAtPosition(1, 1), false); + t.deepEqual(notSelector.isAtPosition(1, 4), false); + t.deepEqual(notSelector.isAtPosition(1, 5), true); + t.deepEqual(notSelector.isAtPosition(1, 6), true); + t.deepEqual(notSelector.isAtPosition(1, 9), true); + t.deepEqual(notSelector.isAtPosition(1, 10), true); + t.deepEqual(notSelector.isAtPosition(1, 11), false); + let notClass = notSelector.first; + t.deepEqual(notClass.isAtPosition(1, 5), false); + t.deepEqual(notClass.isAtPosition(1, 6), true); + t.deepEqual(notClass.isAtPosition(1, 9), true); + t.deepEqual(notClass.isAtPosition(1, 10), false); + let secondSel = root.at(1); + t.deepEqual(secondSel.isAtPosition(1, 11), false); + t.deepEqual(secondSel.isAtPosition(2, 1), true); + t.deepEqual(secondSel.isAtPosition(2, 23), true); + t.deepEqual(secondSel.isAtPosition(2, 24), false); + let combinator = secondSel.at(1); + t.deepEqual(combinator.isAtPosition(2, 5), false); + t.deepEqual(combinator.isAtPosition(2, 6), true); + t.deepEqual(combinator.isAtPosition(2, 7), false); + }); +}); diff --git a/src/__tests__/sourceIndex.js b/src/__tests__/sourceIndex.js index 073b610..1fa24ac 100644 --- a/src/__tests__/sourceIndex.js +++ b/src/__tests__/sourceIndex.js @@ -6,16 +6,16 @@ test('universal selector', '*', (t, tree) => { t.deepEqual(tree.nodes[0].nodes[0].sourceIndex, 0); }); -test('lobotomized owl selector', '* + *', (t, tree) => { - t.deepEqual(tree.nodes[0].nodes[0].source.start.column, 1); - t.deepEqual(tree.nodes[0].nodes[0].source.end.column, 1); - t.deepEqual(tree.nodes[0].nodes[0].sourceIndex, 0); - t.deepEqual(tree.nodes[0].nodes[1].source.start.column, 3); - t.deepEqual(tree.nodes[0].nodes[1].source.end.column, 3); - t.deepEqual(tree.nodes[0].nodes[1].sourceIndex, 2); - t.deepEqual(tree.nodes[0].nodes[2].source.start.column, 5); - t.deepEqual(tree.nodes[0].nodes[2].source.end.column, 5); - t.deepEqual(tree.nodes[0].nodes[2].sourceIndex, 4); +test('lobotomized owl selector', ' * + * ', (t, tree) => { + t.deepEqual(tree.nodes[0].nodes[0].source.start.column, 2); + t.deepEqual(tree.nodes[0].nodes[0].source.end.column, 2); + t.deepEqual(tree.nodes[0].nodes[0].sourceIndex, 1); + t.deepEqual(tree.nodes[0].nodes[1].source.start.column, 4); + t.deepEqual(tree.nodes[0].nodes[1].source.end.column, 4); + t.deepEqual(tree.nodes[0].nodes[1].sourceIndex, 3); + t.deepEqual(tree.nodes[0].nodes[2].source.start.column, 6); + t.deepEqual(tree.nodes[0].nodes[2].source.end.column, 6); + t.deepEqual(tree.nodes[0].nodes[2].sourceIndex, 5); }); test('comment', '/**\n * Hello!\n */', (t, tree) => { @@ -184,7 +184,7 @@ test('combinators surrounded by superfluous spaces', 'div > h1 ~ span a', t.deepEqual(tree.nodes[0].nodes[5].source.start.line, 1, "' ' start line"); t.deepEqual(tree.nodes[0].nodes[5].source.start.column, 21, "' ' start column"); - t.deepEqual(tree.nodes[0].nodes[5].source.end.column, 21, "' ' end column"); + t.deepEqual(tree.nodes[0].nodes[5].source.end.column, 23, "' ' end column"); t.deepEqual(tree.nodes[0].nodes[5].sourceIndex, 20, "' ' sourceIndex"); }); diff --git a/src/__tests__/stripComments.js b/src/__tests__/stripComments.js new file mode 100644 index 0000000..fa982fb --- /dev/null +++ b/src/__tests__/stripComments.js @@ -0,0 +1,12 @@ +import ava from "ava"; +import stripComments from "../../src/util/stripComments"; + +ava("stripComments()", (t) => { + t.deepEqual(stripComments("aaa/**/bbb"), "aaabbb"); + t.deepEqual(stripComments("aaa/*bbb"), "aaa"); + t.deepEqual(stripComments("aaa/*xxx*/bbb"), "aaabbb"); + t.deepEqual(stripComments("aaa/*/xxx/*/bbb"), "aaabbb"); + t.deepEqual(stripComments("aaa/*x*/bbb/**/"), "aaabbb"); + t.deepEqual(stripComments("/**/aaa/*x*/bbb/**/"), "aaabbb"); + t.deepEqual(stripComments("/**/"), ""); +}); diff --git a/src/parser.js b/src/parser.js index 0b31d45..3d69550 100644 --- a/src/parser.js +++ b/src/parser.js @@ -34,6 +34,21 @@ const WHITESPACE_EQUIV_TOKENS = { [tokens.comment]: true, }; +function tokenStart (token) { + return { + line: token[TOKEN.START_LINE], + column: token[TOKEN.START_COL], + }; +} + +function tokenEnd (token) { + return { + line: token[TOKEN.END_LINE], + column: token[TOKEN.END_COL], + }; +} + + function getSource (startLine, startColumn, endLine, endColumn) { return { start: { @@ -56,6 +71,18 @@ function getTokenSource (token) { ); } +function getTokenSourceSpan (startToken, endToken) { + if (!startToken) { + return undefined; + } + return getSource( + startToken[TOKEN.START_LINE], + startToken[TOKEN.START_COL], + endToken[TOKEN.END_LINE], + endToken[TOKEN.END_COL] + ); +} + function unescapeProp (node, prop) { let value = node[prop]; if (typeof value !== "string") { @@ -71,32 +98,11 @@ function unescapeProp (node, prop) { return node; } -function convertWhitespaceNodesToSpace (nodes) { - let space = ""; - let rawSpace = ""; - nodes.forEach(n => { - space += n.spaces.before + n.spaces.after; - rawSpace += n.toString(); - }); - if (rawSpace === space) { - rawSpace = undefined; - } - let result = {space, rawSpace}; - return result; -} - export default class Parser { constructor (rule, options = {}) { this.rule = rule; this.options = Object.assign({lossy: false, safe: false}, options); this.position = 0; - this.root = new Root(); - this.root.errorGenerator = this._errorGenerator(); - - - const selector = new Selector(); - this.root.append(selector); - this.current = selector; this.css = typeof this.rule === 'string' ? this.rule : this.rule.selector; @@ -106,6 +112,16 @@ export default class Parser { safe: this.options.safe, }); + let rootSource = getTokenSourceSpan(this.tokens[0], this.tokens[this.tokens.length - 1]); + this.root = new Root({source: rootSource}); + this.root.errorGenerator = this._errorGenerator(); + + + const selector = new Selector({source: {start: {line: 1, column: 1}}}); + this.root.append(selector); + this.current = selector; + + this.loop(); } @@ -423,6 +439,58 @@ export default class Parser { return nodes; } + /** + * + * @param {*} nodes + */ + convertWhitespaceNodesToSpace (nodes, requiredSpace = false) { + let space = ""; + let rawSpace = ""; + nodes.forEach(n => { + let spaceBefore = this.lossySpace(n.spaces.before, requiredSpace); + let rawSpaceBefore = this.lossySpace(n.rawSpaceBefore, requiredSpace); + space += spaceBefore + this.lossySpace(n.spaces.after, requiredSpace && spaceBefore.length === 0); + rawSpace += spaceBefore + n.value + this.lossySpace(n.rawSpaceAfter, requiredSpace && rawSpaceBefore.length === 0); + }); + if (rawSpace === space) { + rawSpace = undefined; + } + let result = {space, rawSpace}; + return result; + } + + isNamedCombinator (position = this.position) { + return this.tokens[position + 0] && this.tokens[position + 0][TOKEN.TYPE] === tokens.slash && + this.tokens[position + 1] && this.tokens[position + 1][TOKEN.TYPE] === tokens.word && + this.tokens[position + 2] && this.tokens[position + 2][TOKEN.TYPE] === tokens.slash; + + } + namedCombinator () { + if (this.isNamedCombinator()) { + let nameRaw = this.content(this.tokens[this.position + 1]); + let name = unesc(nameRaw).toLowerCase(); + let raws = {}; + if (name !== nameRaw) { + raws.value = `/${nameRaw}/`; + } + let node = new Combinator({ + value: `/${name}/`, + source: getSource( + this.currToken[TOKEN.START_LINE], + this.currToken[TOKEN.START_COL], + this.tokens[this.position + 2][TOKEN.END_LINE], + this.tokens[this.position + 2][TOKEN.END_COL], + ), + sourceIndex: this.currToken[TOKEN.START_POS], + raws, + }); + this.position = this.position + 3; + return node; + } else { + this.unexpected(); + } + } + combinator () { if (this.content() === '|') { return this.namespace(); @@ -435,7 +503,7 @@ export default class Parser { if (nodes.length > 0) { let last = this.current.last; if (last) { - let {space, rawSpace} = convertWhitespaceNodesToSpace(nodes); + let {space, rawSpace} = this.convertWhitespaceNodesToSpace(nodes); if (rawSpace !== undefined) { last.rawSpaceAfter += rawSpace; } @@ -447,40 +515,65 @@ export default class Parser { return; } - let spaceBeforeCombinator = undefined; - if (nextSigTokenPos > this.position && this.tokens[nextSigTokenPos][TOKEN.TYPE] === tokens.combinator) { - spaceBeforeCombinator = convertWhitespaceNodesToSpace(this.parseWhitespaceEquivalentTokens(nextSigTokenPos)); + let firstToken = this.currToken; + let spaceOrDescendantSelectorNodes = undefined; + if (nextSigTokenPos > this.position) { + spaceOrDescendantSelectorNodes = this.parseWhitespaceEquivalentTokens(nextSigTokenPos); } - const current = this.currToken; - const node = new Combinator({ - value: '', - source: getTokenSource(current), - sourceIndex: current[TOKEN.START_POS], - }); - while ( this.position < this.tokens.length && this.currToken && - (this.currToken[TOKEN.TYPE] === tokens.space || - this.currToken[TOKEN.TYPE] === tokens.combinator)) { - const content = this.content(); - if (this.nextToken && this.nextToken[TOKEN.TYPE] === tokens.combinator) { - node.spaces.before = this.optionalSpace(content); - node.source = getTokenSource(this.nextToken); - node.sourceIndex = this.nextToken[TOKEN.START_POS]; - } else if (this.prevToken && this.prevToken[TOKEN.TYPE] === tokens.combinator) { - node.spaces.after = this.optionalSpace(content); - } else if (this.currToken[TOKEN.TYPE] === tokens.combinator) { - node.value = content; - } else if (this.currToken[TOKEN.TYPE] === tokens.space) { - node.value = this.requiredSpace(content); - } - this.position ++; + let node; + if (this.isNamedCombinator()) { + node = this.namedCombinator(); + } else if (this.currToken[TOKEN.TYPE] === tokens.combinator) { + node = new Combinator({ + value: this.content(), + source: getTokenSource(this.currToken), + sourceIndex: this.currToken[TOKEN.START_POS], + }); + this.position++; + } else if (WHITESPACE_TOKENS[this.currToken[TOKEN.TYPE]]) { + // pass + } else if (!spaceOrDescendantSelectorNodes) { + this.unexpected(); } - if (spaceBeforeCombinator) { - if (spaceBeforeCombinator.rawSpace !== undefined) { - node.rawSpaceBefore = spaceBeforeCombinator.rawSpace + node.rawSpaceBefore; + + if (node) { + if (spaceOrDescendantSelectorNodes) { + let {space, rawSpace} = this.convertWhitespaceNodesToSpace(spaceOrDescendantSelectorNodes); + node.spaces.before = space; + node.rawSpaceBefore = rawSpace; } - node.spaces.before = spaceBeforeCombinator.space + node.spaces.before; + } else { + // descendant combinator + let {space, rawSpace} = this.convertWhitespaceNodesToSpace(spaceOrDescendantSelectorNodes, true); + if (!rawSpace) { + rawSpace = space; + } + let spaces = {}; + let raws = {spaces: {}}; + if (space.endsWith(' ') && rawSpace.endsWith(' ')) { + spaces.before = space.slice(0, space.length - 1); + raws.spaces.before = rawSpace.slice(0, rawSpace.length - 1); + } else if (space.startsWith(' ') && rawSpace.startsWith(' ')) { + spaces.after = space.slice(1); + raws.spaces.after = rawSpace.slice(1); + } else { + raws.value = rawSpace; + } + node = new Combinator({ + value: ' ', + source: getTokenSourceSpan(firstToken, this.tokens[this.position - 1]), + sourceIndex: firstToken[TOKEN.START_POS], + spaces, + raws, + }); + } + + if (this.currToken[TOKEN.TYPE] === tokens.space) { + node.spaces.after = this.optionalSpace(this.content()); + this.position++; } + return this.newNode(node); } @@ -490,7 +583,8 @@ export default class Parser { this.position ++; return; } - const selector = new Selector(); + this.current._inferEndPosition(); + const selector = new Selector({source: {start: tokenStart(this.tokens[this.position + 1])}}); this.current.parent.append(selector); this.current = selector; this.position ++; @@ -524,6 +618,10 @@ export default class Parser { return this.expected('opening square bracket', this.currToken[TOKEN.START_POS]); } + unexpected () { + return this.error(`Unexpected '${this.content()}'. Escaping special characters with \\ may help.`, this.currToken[TOKEN.START_POS]); + } + namespace () { const before = this.prevToken && this.content(this.prevToken) || true; if (this.nextToken[TOKEN.TYPE] === tokens.word) { @@ -554,25 +652,25 @@ export default class Parser { parentheses () { let last = this.current.last; - let balanced = 1; + let unbalanced = 1; this.position ++; if (last && last.type === types.PSEUDO) { - const selector = new Selector(); + const selector = new Selector({source: {start: tokenStart(this.tokens[this.position - 1])}}); const cache = this.current; last.append(selector); this.current = selector; - while (this.position < this.tokens.length && balanced) { + while (this.position < this.tokens.length && unbalanced) { if (this.currToken[TOKEN.TYPE] === tokens.openParenthesis) { - balanced ++; + unbalanced ++; } if (this.currToken[TOKEN.TYPE] === tokens.closeParenthesis) { - balanced --; + unbalanced --; } - if (balanced) { + if (unbalanced) { this.parse(); } else { - selector.parent.source.end.line = this.currToken[3]; - selector.parent.source.end.column = this.currToken[4]; + this.current.source.end = tokenEnd(this.currToken); + this.current.parent.source.end = tokenEnd(this.currToken); this.position ++; } } @@ -583,12 +681,12 @@ export default class Parser { let parenStart = this.currToken; let parenValue = "("; let parenEnd; - while (this.position < this.tokens.length && balanced) { + while (this.position < this.tokens.length && unbalanced) { if (this.currToken[TOKEN.TYPE] === tokens.openParenthesis) { - balanced ++; + unbalanced ++; } if (this.currToken[TOKEN.TYPE] === tokens.closeParenthesis) { - balanced --; + unbalanced --; } parenEnd = this.currToken; parenValue += this.parseParenthesisToken(this.currToken); @@ -609,7 +707,7 @@ export default class Parser { })); } } - if (balanced) { + if (unbalanced) { return this.expected('closing parenthesis', this.currToken[TOKEN.START_POS]); } } @@ -629,12 +727,7 @@ export default class Parser { pseudoStr += first; this.newNode(new Pseudo({ value: pseudoStr, - source: getSource( - startingToken[1], - startingToken[2], - this.currToken[3], - this.currToken[4] - ), + source: getTokenSourceSpan(startingToken, this.currToken), sourceIndex: startingToken[TOKEN.START_POS], })); if ( @@ -784,6 +877,7 @@ export default class Parser { while (this.position < this.tokens.length) { this.parse(true); } + this.current._inferEndPosition(); return this.root; } @@ -824,6 +918,7 @@ export default class Parser { case tokens.ampersand: this.nesting(); break; + case tokens.slash: case tokens.combinator: this.combinator(); break; @@ -835,6 +930,8 @@ export default class Parser { this.missingSquareBracket(); case tokens.semicolon: this.missingBackslash(); + default: + this.unexpected(); } } @@ -868,6 +965,14 @@ export default class Parser { return this.options.lossy ? '' : space; } + lossySpace (space, required) { + if (this.options.lossy) { + return required ? ' ' : ''; + } else { + return space; + } + } + parseParenthesisToken (token) { const content = this.content(token); if (token[TOKEN.TYPE] === tokens.space) { diff --git a/src/selectors/container.js b/src/selectors/container.js index a249f0e..ae213df 100644 --- a/src/selectors/container.js +++ b/src/selectors/container.js @@ -108,6 +108,52 @@ export default class Container extends Node { return this; } + _findChildAtPosition (line, col) { + let found = undefined; + this.each(node => { + if (node.atPosition) { + let foundChild = node.atPosition(line, col); + if (foundChild) { + found = foundChild; + return false; + } + } else if (node.isAtPosition(line, col)) { + found = node; + return false; + } + }); + return found; + } + + /** + * Return the most specific node at the line and column number given. + * The source location is based on the original parsed location, locations aren't + * updated as selector nodes are mutated. + * + * Note that this location is relative to the location of the first character + * of the selector, and not the location of the selector in the overall document + * when used in conjunction with postcss. + * + * If not found, returns undefined. + * @param {number} line The line number of the node to find. (1-based index) + * @param {number} col The column number of the node to find. (1-based index) + */ + atPosition (line, col) { + if (this.isAtPosition(line, col)) { + return this._findChildAtPosition(line, col) || this; + } else { + return undefined; + } + } + + _inferEndPosition () { + if (this.last && this.last.source && this.last.source.end) { + this.source = this.source || {}; + this.source.end = this.source.end || {}; + Object.assign(this.source.end, this.last.source.end); + } + } + each (callback) { if (!this.lastEach) { this.lastEach = 0; diff --git a/src/selectors/node.js b/src/selectors/node.js index 2f40fc5..15de38f 100644 --- a/src/selectors/node.js +++ b/src/selectors/node.js @@ -123,6 +123,30 @@ export default class Node { } } + /** + * + * @param {number} line The number (starting with 1) + * @param {number} column The column number (starting with 1) + */ + isAtPosition (line, column) { + if (this.source && this.source.start && this.source.end) { + if (this.source.start.line > line) { + return false; + } + if (this.source.end.line < line) { + return false; + } + if (this.source.start.line === line && this.source.start.column > column) { + return false; + } + if (this.source.end.line === line && this.source.end.column < column) { + return false; + } + return true; + } + return undefined; + } + stringifyProperty (name) { return (this.raws && this.raws[name]) || this[name]; } diff --git a/src/tokenize.js b/src/tokenize.js index 0d769b9..ca1da46 100644 --- a/src/tokenize.js +++ b/src/tokenize.js @@ -167,7 +167,7 @@ export default function tokenize (input) { tokenType = t.space; endLine = line; - endColumn = start - offset; + endColumn = next - offset - 1; end = next; break; @@ -258,6 +258,12 @@ export default function tokenize (input) { line = nextLine; endLine = nextLine; endColumn = next - nextOffset; + } else if (code === t.slash) { + next = start; + tokenType = code; + endLine = line; + endColumn = start - offset; + end = next + 1; } else { next = consumeWord(css, start); tokenType = t.word; diff --git a/src/util/index.js b/src/util/index.js index b1e989d..a0a1579 100644 --- a/src/util/index.js +++ b/src/util/index.js @@ -1,3 +1,4 @@ export {default as unesc} from './unesc'; export {default as getProp} from './getProp'; export {default as ensureObject} from './ensureObject'; +export {default as stripComments} from './stripComments'; diff --git a/src/util/stripComments.js b/src/util/stripComments.js new file mode 100644 index 0000000..fb956df --- /dev/null +++ b/src/util/stripComments.js @@ -0,0 +1,16 @@ +export default function stripComments (str) { + let s = ""; + let commentStart = str.indexOf("/*"); + let lastEnd = 0; + while (commentStart >= 0) { + s = s + str.slice(lastEnd, commentStart); + let commentEnd = str.indexOf("*/", commentStart + 2); + if (commentEnd < 0) { + return s; + } + lastEnd = commentEnd + 2; + commentStart = str.indexOf("/*", lastEnd); + } + s = s + str.slice(lastEnd); + return s; +}