Skip to content

Commit

Permalink
Fixes #3147 #2715 (#3213)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
matthew-dean authored Jun 22, 2018
1 parent e1255ec commit a75f7d9
Show file tree
Hide file tree
Showing 16 changed files with 328 additions and 20 deletions.
4 changes: 4 additions & 0 deletions .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@
},
"globals": {},
"rules": {
"quotes": [
1,
"single"
],
"no-eval": 2,
"no-use-before-define": [
2,
Expand Down
131 changes: 126 additions & 5 deletions lib/less/parser/parser-input.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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;
Expand Down
71 changes: 63 additions & 8 deletions lib/less/parser/parser.js
Original file line number Diff line number Diff line change
Expand Up @@ -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; }

Expand All @@ -1290,25 +1291,36 @@ 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) {
value = this.value();
}

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();
Expand All @@ -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
Expand Down Expand Up @@ -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;
}
}

Expand Down
7 changes: 4 additions & 3 deletions lib/less/tree/expression.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
Expand All @@ -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;
Expand All @@ -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(" ");
}
}
Expand Down
8 changes: 5 additions & 3 deletions lib/less/tree/quoted.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand All @@ -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) {
Expand Down
36 changes: 36 additions & 0 deletions test/css/permissive-parse.css
Original file line number Diff line number Diff line change
@@ -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: ;
}
2 changes: 1 addition & 1 deletion test/less-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
4 changes: 4 additions & 0 deletions test/less/errors/at-rules-unmatching-block.less
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@

@unknown url( {
50% {width: 20px;}
}
4 changes: 4 additions & 0 deletions test/less/errors/at-rules-unmatching-block.txt
Original file line number Diff line number Diff line change
@@ -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;}
Loading

0 comments on commit a75f7d9

Please sign in to comment.