From a75f7d9664225a2b576492c2e9a79495164ad759 Mon Sep 17 00:00:00 2001 From: Matthew Dean Date: Thu, 21 Jun 2018 23:44:38 -0700 Subject: [PATCH] Fixes #3147 #2715 (#3213) * Adds permissive parsing for at-rules and custom properties * Added error tests for permissive parsing * Change custom property value to quoted-like value * Allow interpolation in unknown at-rules * Allows variables to fallback to permissive parsing * Allow escaping of blocks --- .eslintrc.json | 4 + lib/less/parser/parser-input.js | 131 +++++++++++++++++- lib/less/parser/parser.js | 71 ++++++++-- lib/less/tree/expression.js | 7 +- lib/less/tree/quoted.js | 8 +- test/css/permissive-parse.css | 36 +++++ test/less-test.js | 2 +- .../errors/at-rules-unmatching-block.less | 4 + .../less/errors/at-rules-unmatching-block.txt | 4 + .../custom-property-unmatched-block-1.less | 6 + .../custom-property-unmatched-block-1.txt | 4 + .../custom-property-unmatched-block-2.less | 6 + .../custom-property-unmatched-block-2.txt | 4 + .../custom-property-unmatched-block-3.less | 6 + .../custom-property-unmatched-block-3.txt | 4 + test/less/permissive-parse.less | 51 +++++++ 16 files changed, 328 insertions(+), 20 deletions(-) create mode 100644 test/css/permissive-parse.css create mode 100644 test/less/errors/at-rules-unmatching-block.less create mode 100644 test/less/errors/at-rules-unmatching-block.txt create mode 100644 test/less/errors/custom-property-unmatched-block-1.less create mode 100644 test/less/errors/custom-property-unmatched-block-1.txt create mode 100644 test/less/errors/custom-property-unmatched-block-2.less create mode 100644 test/less/errors/custom-property-unmatched-block-2.txt create mode 100644 test/less/errors/custom-property-unmatched-block-3.less create mode 100644 test/less/errors/custom-property-unmatched-block-3.txt create mode 100644 test/less/permissive-parse.less diff --git a/.eslintrc.json b/.eslintrc.json index 9ddce209..754c3845 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -4,6 +4,10 @@ }, "globals": {}, "rules": { + "quotes": [ + 1, + "single" + ], "no-eval": 2, "no-use-before-define": [ 2, diff --git a/lib/less/parser/parser-input.js b/lib/less/parser/parser-input.js index 2394c9c3..bf99ef78 100644 --- a/lib/less/parser/parser-input.js +++ b/lib/less/parser/parser-input.js @@ -145,14 +145,15 @@ module.exports = function() { return tok; }; - parserInput.$quoted = function() { + parserInput.$quoted = function(loc) { + var pos = loc || parserInput.i, + startChar = input.charAt(pos); - var startChar = input.charAt(parserInput.i); if (startChar !== "'" && startChar !== '"') { return; } var length = input.length, - currentPosition = parserInput.i; + currentPosition = pos; for (var i = 1; i + currentPosition < length; i++) { var nextChar = input.charAt(i + currentPosition); @@ -165,14 +166,134 @@ module.exports = function() { break; case startChar: var str = input.substr(currentPosition, i + 1); - skipWhitespace(i + 1); - return str; + if (!loc && loc !== 0) { + skipWhitespace(i + 1); + return str + } + return [startChar, str]; default: } } return null; }; + /** + * Permissive parsing. Ignores everything except matching {} [] () and quotes + * until matching token (outside of blocks) + */ + parserInput.$parseUntil = function(tok) { + var quote = '', + returnVal = null, + inComment = false, + blockDepth = 0, + blockStack = [], + parseGroups = [], + length = input.length, + startPos = parserInput.i, + lastPos = parserInput.i, + i = parserInput.i, + loop = true, + testChar; + + if (typeof tok === 'string') { + testChar = function(char) { + return char === tok; + } + } else { + testChar = function(char) { + return tok.test(char); + } + } + + do { + var prevChar, nextChar = input.charAt(i); + if (blockDepth === 0 && testChar(nextChar)) { + returnVal = input.substr(lastPos, i - lastPos); + if (returnVal) { + parseGroups.push(returnVal); + returnVal = parseGroups; + } + else { + returnVal = [' ']; + } + skipWhitespace(i - startPos); + loop = false + } else { + if (inComment) { + if (nextChar === "*" && + input.charAt(i + 1) === "/") { + i++; + blockDepth--; + inComment = false; + } + i++; + continue; + } + switch (nextChar) { + case '\\': + i++; + nextChar = input.charAt(i); + parseGroups.push(input.substr(lastPos, i - lastPos + 1)); + lastPos = i + 1; + break; + case "/": + if (input.charAt(i + 1) === "*") { + i++; + console.log(input.substr(lastPos, i - lastPos)); + inComment = true; + blockDepth++; + } + break; + case "'": + case '"': + quote = parserInput.$quoted(i); + if (quote) { + parseGroups.push(input.substr(lastPos, i - lastPos), quote); + i += quote[1].length - 1; + lastPos = i + 1; + } + else { + skipWhitespace(i - startPos); + returnVal = nextChar; + loop = false; + } + break; + case "{": + blockStack.push("}"); + blockDepth++; + break; + case "(": + blockStack.push(")"); + blockDepth++; + break; + case "[": + blockStack.push("]"); + blockDepth++; + break; + case "}": + case ")": + case "]": + var expected = blockStack.pop(); + if (nextChar === expected) { + blockDepth--; + } else { + // move the parser to the error and return expected + skipWhitespace(i - startPos); + returnVal = expected; + loop = false; + } + } + i++; + if (i > length) { + loop = false; + } + } + prevChar = nextChar; + } while (loop); + + return returnVal ? returnVal : null; + } + parserInput.autoCommentAbsorb = true; parserInput.commentStore = []; parserInput.finished = false; diff --git a/lib/less/parser/parser.js b/lib/less/parser/parser.js index ef8357ce..2b31eb7d 100644 --- a/lib/less/parser/parser.js +++ b/lib/less/parser/parser.js @@ -1269,7 +1269,8 @@ var Parser = function Parser(context, imports, fileInfo) { } }, declaration: function () { - var name, value, startOfRule = parserInput.i, c = parserInput.currentChar(), important, merge, isVariable; + var name, value, index = parserInput.i, + c = parserInput.currentChar(), important, merge, isVariable; if (c === '.' || c === '#' || c === '&' || c === ':') { return; } @@ -1290,13 +1291,19 @@ var Parser = function Parser(context, imports, fileInfo) { // where each item is a tree.Keyword or tree.Variable merge = !isVariable && name.length > 1 && name.pop().value; + // Custom property values get permissive parsing + if (name[0].value && name[0].value.slice(0, 2) === '--') { + value = this.permissiveValue(';'); + } // Try to store values as anonymous // If we need the value later we'll re-parse it in ruleset.parseValue - value = this.anonymousValue(); + else { + value = this.anonymousValue(); + } if (value) { parserInput.forget(); // anonymous values absorb the end ';' which is required for them to work - return new (tree.Declaration)(name, value, false, merge, startOfRule, fileInfo); + return new (tree.Declaration)(name, value, false, merge, index, fileInfo); } if (!value) { @@ -1304,11 +1311,16 @@ var Parser = function Parser(context, imports, fileInfo) { } important = this.important(); + + // As a last resort, let a variable try to be parsed as a permissive value + if (!value && isVariable) { + value = this.permissiveValue(';'); + } } if (value && this.end()) { parserInput.forget(); - return new (tree.Declaration)(name, value, important, merge, startOfRule, fileInfo); + return new (tree.Declaration)(name, value, important, merge, index, fileInfo); } else { parserInput.restore(); @@ -1324,6 +1336,44 @@ var Parser = function Parser(context, imports, fileInfo) { return new(tree.Anonymous)(match[1], index); } }, + /** + * Used for custom properties and custom at-rules + * Parses almost anything inside of {} [] () "" blocks + * until it reaches outer-most tokens. + */ + permissiveValue: function (untilTokens) { + var i, index = parserInput.i, + value = parserInput.$parseUntil(untilTokens); + + if (value) { + if (typeof value === 'string') { + error("Expected '" + value + "'", "Parse"); + } + if (value.length === 1 && value[0] === ' ') { + return new tree.Anonymous('', index); + } + var item, args = []; + for (i = 0; i < value.length; i++) { + item = value[i]; + if (Array.isArray(item)) { + // Treat actual quotes as normal quoted values + args.push(new tree.Quoted(item[0], item[1], true, index, fileInfo)); + } + else { + if (i === value.length - 1) { + item = item.trim(); + } + // Treat like quoted values, but replace vars like unquoted expressions + var quote = new tree.Quoted("'", item, true, index, fileInfo); + quote.variableRegex = /@([\w-]+)/g; + quote.propRegex = /\$([\w-]+)/g; + quote.reparse = true; + args.push(quote); + } + } + return new tree.Expression(args, true); + } + }, // // An @import atrule @@ -1595,10 +1645,15 @@ var Parser = function Parser(context, imports, fileInfo) { error("expected " + name + " expression"); } } else if (hasUnknown) { - value = (parserInput.$re(/^[^{;]+/) || '').trim(); - hasBlock = (parserInput.currentChar() == '{'); - if (value) { - value = new(tree.Anonymous)(value); + value = this.permissiveValue(/^[{;]/); + hasBlock = (parserInput.currentChar() === '{'); + if (!value) { + if (!hasBlock && parserInput.currentChar() !== ';') { + error(name + " rule is missing block or ending semi-colon"); + } + } + else if (!value.value) { + value = null; } } diff --git a/lib/less/tree/expression.js b/lib/less/tree/expression.js index 4f939a6f..257c93e3 100644 --- a/lib/less/tree/expression.js +++ b/lib/less/tree/expression.js @@ -2,8 +2,9 @@ var Node = require("./node"), Paren = require("./paren"), Comment = require("./comment"); -var Expression = function (value) { +var Expression = function (value, noSpacing) { this.value = value; + this.noSpacing = noSpacing; if (!value) { throw new Error("Expression requires an array parameter"); } @@ -23,7 +24,7 @@ Expression.prototype.eval = function (context) { if (this.value.length > 1) { returnValue = new Expression(this.value.map(function (e) { return e.eval(context); - })); + }), this.noSpacing); } else if (this.value.length === 1) { if (this.value[0].parens && !this.value[0].parensInOp) { doubleParen = true; @@ -43,7 +44,7 @@ Expression.prototype.eval = function (context) { Expression.prototype.genCSS = function (context, output) { for (var i = 0; i < this.value.length; i++) { this.value[i].genCSS(context, output); - if (i + 1 < this.value.length) { + if (!this.noSpacing && i + 1 < this.value.length) { output.add(" "); } } diff --git a/lib/less/tree/quoted.js b/lib/less/tree/quoted.js index dd41f8ce..e1ef8dce 100644 --- a/lib/less/tree/quoted.js +++ b/lib/less/tree/quoted.js @@ -8,6 +8,8 @@ var Quoted = function (str, content, escaped, index, currentFileInfo) { this.quote = str.charAt(0); this._index = index; this._fileInfo = currentFileInfo; + this.variableRegex = /@\{([\w-]+)\}/g; + this.propRegex = /\$\{([\w-]+)\}/g; }; Quoted.prototype = new Node(); Quoted.prototype.type = "Quoted"; @@ -21,7 +23,7 @@ Quoted.prototype.genCSS = function (context, output) { } }; Quoted.prototype.containsVariables = function() { - return this.value.match(/@\{([\w-]+)\}/); + return this.value.match(this.variableRegex); }; Quoted.prototype.eval = function (context) { var that = this, value = this.value; @@ -41,8 +43,8 @@ Quoted.prototype.eval = function (context) { } while (value !== evaluatedValue); return evaluatedValue; } - value = iterativeReplace(value, /@\{([\w-]+)\}/g, variableReplacement); - value = iterativeReplace(value, /\$\{([\w-]+)\}/g, propertyReplacement); + value = iterativeReplace(value, this.variableRegex, variableReplacement); + value = iterativeReplace(value, this.propRegex, propertyReplacement); return new Quoted(this.quote + value + this.quote, value, this.escaped, this.getIndex(), this.fileInfo()); }; Quoted.prototype.compare = function (other) { diff --git a/test/css/permissive-parse.css b/test/css/permissive-parse.css new file mode 100644 index 00000000..1ed0773d --- /dev/null +++ b/test/css/permissive-parse.css @@ -0,0 +1,36 @@ +@-moz-document regexp("(\d{0,15})") { + a { + color: red; + } +} +.custom-property { + --this: () => { + basically anything until final semi-colon; + even other stuff; // i\'m serious; + }; + --that: () => { + basically anything until final semi-colon; + even other stuff; // i\'m serious; + }; + --custom-color: #ff3333; + custom-color: #ff3333; +} +.var { + --fortran: read (*, *, iostat=1) radius, height; +} +@-moz-whatever (foo: "(" bam ")") { + bar: foo; +} +#selector, .bar, foo[attr="blah"] { + bar: value; +} +@media (min-width: 640px) { + .holy-crap { + this: works; + } +} +.test-comment { + --value: ; + --comment-within: ( /* okay?; comment; */ ); + --empty: ; +} diff --git a/test/less-test.js b/test/less-test.js index 9ab051d7..34f4c214 100644 --- a/test/less-test.js +++ b/test/less-test.js @@ -14,7 +14,7 @@ module.exports = function() { var oneTestOnly = process.argv[2], isFinished = false; - var isVerbose = process.env.npm_config_loglevel === 'verbose'; + var isVerbose = process.env.npm_config_loglevel !== 'concise'; var normalFolder = 'test/less'; var bomFolder = 'test/less-bom'; diff --git a/test/less/errors/at-rules-unmatching-block.less b/test/less/errors/at-rules-unmatching-block.less new file mode 100644 index 00000000..fc4b9c50 --- /dev/null +++ b/test/less/errors/at-rules-unmatching-block.less @@ -0,0 +1,4 @@ + +@unknown url( { + 50% {width: 20px;} +} diff --git a/test/less/errors/at-rules-unmatching-block.txt b/test/less/errors/at-rules-unmatching-block.txt new file mode 100644 index 00000000..5070aaf5 --- /dev/null +++ b/test/less/errors/at-rules-unmatching-block.txt @@ -0,0 +1,4 @@ +SyntaxError: @unknown rule is missing block or ending semi-colon in {path}at-rules-unmatching-block.less on line 2, column 10: +1 +2 @unknown url( { +3 50% {width: 20px;} diff --git a/test/less/errors/custom-property-unmatched-block-1.less b/test/less/errors/custom-property-unmatched-block-1.less new file mode 100644 index 00000000..1b847edb --- /dev/null +++ b/test/less/errors/custom-property-unmatched-block-1.less @@ -0,0 +1,6 @@ +.custom { + --custom: ({ + this; + is-unmatched: [ + }) +} \ No newline at end of file diff --git a/test/less/errors/custom-property-unmatched-block-1.txt b/test/less/errors/custom-property-unmatched-block-1.txt new file mode 100644 index 00000000..a1d96dad --- /dev/null +++ b/test/less/errors/custom-property-unmatched-block-1.txt @@ -0,0 +1,4 @@ +ParseError: Expected ']' in {path}custom-property-unmatched-block-1.less on line 5, column 3: +4 is-unmatched: [ +5 }) +6 } diff --git a/test/less/errors/custom-property-unmatched-block-2.less b/test/less/errors/custom-property-unmatched-block-2.less new file mode 100644 index 00000000..626676a0 --- /dev/null +++ b/test/less/errors/custom-property-unmatched-block-2.less @@ -0,0 +1,6 @@ +.custom { + --custom: {{ + this; + is-unmatched: [ + }} +} \ No newline at end of file diff --git a/test/less/errors/custom-property-unmatched-block-2.txt b/test/less/errors/custom-property-unmatched-block-2.txt new file mode 100644 index 00000000..65507fab --- /dev/null +++ b/test/less/errors/custom-property-unmatched-block-2.txt @@ -0,0 +1,4 @@ +ParseError: Expected ']' in {path}custom-property-unmatched-block-2.less on line 5, column 3: +4 is-unmatched: [ +5 }} +6 } diff --git a/test/less/errors/custom-property-unmatched-block-3.less b/test/less/errors/custom-property-unmatched-block-3.less new file mode 100644 index 00000000..535be86a --- /dev/null +++ b/test/less/errors/custom-property-unmatched-block-3.less @@ -0,0 +1,6 @@ +.custom { + --custom: {{ + this; + is-unmatched: " + }} +} \ No newline at end of file diff --git a/test/less/errors/custom-property-unmatched-block-3.txt b/test/less/errors/custom-property-unmatched-block-3.txt new file mode 100644 index 00000000..bf596b17 --- /dev/null +++ b/test/less/errors/custom-property-unmatched-block-3.txt @@ -0,0 +1,4 @@ +ParseError: Expected '"' in {path}custom-property-unmatched-block-3.less on line 4, column 19: +3 this; +4 is-unmatched: " +5 }} diff --git a/test/less/permissive-parse.less b/test/less/permissive-parse.less new file mode 100644 index 00000000..f7066035 --- /dev/null +++ b/test/less/permissive-parse.less @@ -0,0 +1,51 @@ +@function-name: regexp; +@d-value: 15; +@-moz-document @function-name("(\d{0,@{d-value}})") { + a { + color: red; + } +} + +.custom-property { + --this: () => { + basically anything until final semi-colon; + even other stuff; // i\'m serious; + }; + @this: () => { + basically anything until final semi-colon; + even other stuff; // i\'m serious; + }; + --that: @this; + @red: lighten(red, 10%); + --custom-color: @red; + custom-color: $--custom-color; +} + +@iostat: 1; +.var { + --fortran: read (*, *, iostat=@iostat) radius, height; +} + +@boom-boom: bam; +@-moz-whatever (foo: "(" @boom-boom ")") { + bar: foo; +} + +@selectorList: #selector, .bar, foo[attr="blah"]; +@{selectorList} { + bar: value; +} + +@size: 640px; +@tablet: (min-width: @size); +@media @tablet { + .holy-crap { + this: works; + } +} +// @todo - fix comment absorption after property +.test-comment { + --value: /* { ; } */; + --comment-within: ( /* okay?; comment; */ ); + --empty: ; +} \ No newline at end of file