* 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
This commit is contained in:
Matthew Dean
2018-06-21 23:44:38 -07:00
committed by GitHub
parent e1255ec2b7
commit a75f7d9664
16 changed files with 328 additions and 20 deletions

View File

@@ -4,6 +4,10 @@
},
"globals": {},
"rules": {
"quotes": [
1,
"single"
],
"no-eval": 2,
"no-use-before-define": [
2,

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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(" ");
}
}

View File

@@ -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) {

View File

@@ -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: ;
}

View File

@@ -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';

View File

@@ -0,0 +1,4 @@
@unknown url( {
50% {width: 20px;}
}

View File

@@ -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;}

View File

@@ -0,0 +1,6 @@
.custom {
--custom: ({
this;
is-unmatched: [
})
}

View File

@@ -0,0 +1,4 @@
ParseError: Expected ']' in {path}custom-property-unmatched-block-1.less on line 5, column 3:
4 is-unmatched: [
5 })
6 }

View File

@@ -0,0 +1,6 @@
.custom {
--custom: {{
this;
is-unmatched: [
}}
}

View File

@@ -0,0 +1,4 @@
ParseError: Expected ']' in {path}custom-property-unmatched-block-2.less on line 5, column 3:
4 is-unmatched: [
5 }}
6 }

View File

@@ -0,0 +1,6 @@
.custom {
--custom: {{
this;
is-unmatched: "
}}
}

View File

@@ -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 }}

View File

@@ -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: ;
}