Skip to content

Commit a75f7d9

Browse files
authored
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
1 parent e1255ec commit a75f7d9

16 files changed

+328
-20
lines changed

.eslintrc.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@
44
},
55
"globals": {},
66
"rules": {
7+
"quotes": [
8+
1,
9+
"single"
10+
],
711
"no-eval": 2,
812
"no-use-before-define": [
913
2,

lib/less/parser/parser-input.js

Lines changed: 126 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -145,14 +145,15 @@ module.exports = function() {
145145
return tok;
146146
};
147147

148-
parserInput.$quoted = function() {
148+
parserInput.$quoted = function(loc) {
149+
var pos = loc || parserInput.i,
150+
startChar = input.charAt(pos);
149151

150-
var startChar = input.charAt(parserInput.i);
151152
if (startChar !== "'" && startChar !== '"') {
152153
return;
153154
}
154155
var length = input.length,
155-
currentPosition = parserInput.i;
156+
currentPosition = pos;
156157

157158
for (var i = 1; i + currentPosition < length; i++) {
158159
var nextChar = input.charAt(i + currentPosition);
@@ -165,14 +166,134 @@ module.exports = function() {
165166
break;
166167
case startChar:
167168
var str = input.substr(currentPosition, i + 1);
168-
skipWhitespace(i + 1);
169-
return str;
169+
if (!loc && loc !== 0) {
170+
skipWhitespace(i + 1);
171+
return str
172+
}
173+
return [startChar, str];
170174
default:
171175
}
172176
}
173177
return null;
174178
};
175179

180+
/**
181+
* Permissive parsing. Ignores everything except matching {} [] () and quotes
182+
* until matching token (outside of blocks)
183+
*/
184+
parserInput.$parseUntil = function(tok) {
185+
var quote = '',
186+
returnVal = null,
187+
inComment = false,
188+
blockDepth = 0,
189+
blockStack = [],
190+
parseGroups = [],
191+
length = input.length,
192+
startPos = parserInput.i,
193+
lastPos = parserInput.i,
194+
i = parserInput.i,
195+
loop = true,
196+
testChar;
197+
198+
if (typeof tok === 'string') {
199+
testChar = function(char) {
200+
return char === tok;
201+
}
202+
} else {
203+
testChar = function(char) {
204+
return tok.test(char);
205+
}
206+
}
207+
208+
do {
209+
var prevChar, nextChar = input.charAt(i);
210+
if (blockDepth === 0 && testChar(nextChar)) {
211+
returnVal = input.substr(lastPos, i - lastPos);
212+
if (returnVal) {
213+
parseGroups.push(returnVal);
214+
returnVal = parseGroups;
215+
}
216+
else {
217+
returnVal = [' '];
218+
}
219+
skipWhitespace(i - startPos);
220+
loop = false
221+
} else {
222+
if (inComment) {
223+
if (nextChar === "*" &&
224+
input.charAt(i + 1) === "/") {
225+
i++;
226+
blockDepth--;
227+
inComment = false;
228+
}
229+
i++;
230+
continue;
231+
}
232+
switch (nextChar) {
233+
case '\\':
234+
i++;
235+
nextChar = input.charAt(i);
236+
parseGroups.push(input.substr(lastPos, i - lastPos + 1));
237+
lastPos = i + 1;
238+
break;
239+
case "/":
240+
if (input.charAt(i + 1) === "*") {
241+
i++;
242+
console.log(input.substr(lastPos, i - lastPos));
243+
inComment = true;
244+
blockDepth++;
245+
}
246+
break;
247+
case "'":
248+
case '"':
249+
quote = parserInput.$quoted(i);
250+
if (quote) {
251+
parseGroups.push(input.substr(lastPos, i - lastPos), quote);
252+
i += quote[1].length - 1;
253+
lastPos = i + 1;
254+
}
255+
else {
256+
skipWhitespace(i - startPos);
257+
returnVal = nextChar;
258+
loop = false;
259+
}
260+
break;
261+
case "{":
262+
blockStack.push("}");
263+
blockDepth++;
264+
break;
265+
case "(":
266+
blockStack.push(")");
267+
blockDepth++;
268+
break;
269+
case "[":
270+
blockStack.push("]");
271+
blockDepth++;
272+
break;
273+
case "}":
274+
case ")":
275+
case "]":
276+
var expected = blockStack.pop();
277+
if (nextChar === expected) {
278+
blockDepth--;
279+
} else {
280+
// move the parser to the error and return expected
281+
skipWhitespace(i - startPos);
282+
returnVal = expected;
283+
loop = false;
284+
}
285+
}
286+
i++;
287+
if (i > length) {
288+
loop = false;
289+
}
290+
}
291+
prevChar = nextChar;
292+
} while (loop);
293+
294+
return returnVal ? returnVal : null;
295+
}
296+
176297
parserInput.autoCommentAbsorb = true;
177298
parserInput.commentStore = [];
178299
parserInput.finished = false;

lib/less/parser/parser.js

Lines changed: 63 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1269,7 +1269,8 @@ var Parser = function Parser(context, imports, fileInfo) {
12691269
}
12701270
},
12711271
declaration: function () {
1272-
var name, value, startOfRule = parserInput.i, c = parserInput.currentChar(), important, merge, isVariable;
1272+
var name, value, index = parserInput.i,
1273+
c = parserInput.currentChar(), important, merge, isVariable;
12731274

12741275
if (c === '.' || c === '#' || c === '&' || c === ':') { return; }
12751276

@@ -1290,25 +1291,36 @@ var Parser = function Parser(context, imports, fileInfo) {
12901291
// where each item is a tree.Keyword or tree.Variable
12911292
merge = !isVariable && name.length > 1 && name.pop().value;
12921293

1294+
// Custom property values get permissive parsing
1295+
if (name[0].value && name[0].value.slice(0, 2) === '--') {
1296+
value = this.permissiveValue(';');
1297+
}
12931298
// Try to store values as anonymous
12941299
// If we need the value later we'll re-parse it in ruleset.parseValue
1295-
value = this.anonymousValue();
1300+
else {
1301+
value = this.anonymousValue();
1302+
}
12961303
if (value) {
12971304
parserInput.forget();
12981305
// anonymous values absorb the end ';' which is required for them to work
1299-
return new (tree.Declaration)(name, value, false, merge, startOfRule, fileInfo);
1306+
return new (tree.Declaration)(name, value, false, merge, index, fileInfo);
13001307
}
13011308

13021309
if (!value) {
13031310
value = this.value();
13041311
}
13051312

13061313
important = this.important();
1314+
1315+
// As a last resort, let a variable try to be parsed as a permissive value
1316+
if (!value && isVariable) {
1317+
value = this.permissiveValue(';');
1318+
}
13071319
}
13081320

13091321
if (value && this.end()) {
13101322
parserInput.forget();
1311-
return new (tree.Declaration)(name, value, important, merge, startOfRule, fileInfo);
1323+
return new (tree.Declaration)(name, value, important, merge, index, fileInfo);
13121324
}
13131325
else {
13141326
parserInput.restore();
@@ -1324,6 +1336,44 @@ var Parser = function Parser(context, imports, fileInfo) {
13241336
return new(tree.Anonymous)(match[1], index);
13251337
}
13261338
},
1339+
/**
1340+
* Used for custom properties and custom at-rules
1341+
* Parses almost anything inside of {} [] () "" blocks
1342+
* until it reaches outer-most tokens.
1343+
*/
1344+
permissiveValue: function (untilTokens) {
1345+
var i, index = parserInput.i,
1346+
value = parserInput.$parseUntil(untilTokens);
1347+
1348+
if (value) {
1349+
if (typeof value === 'string') {
1350+
error("Expected '" + value + "'", "Parse");
1351+
}
1352+
if (value.length === 1 && value[0] === ' ') {
1353+
return new tree.Anonymous('', index);
1354+
}
1355+
var item, args = [];
1356+
for (i = 0; i < value.length; i++) {
1357+
item = value[i];
1358+
if (Array.isArray(item)) {
1359+
// Treat actual quotes as normal quoted values
1360+
args.push(new tree.Quoted(item[0], item[1], true, index, fileInfo));
1361+
}
1362+
else {
1363+
if (i === value.length - 1) {
1364+
item = item.trim();
1365+
}
1366+
// Treat like quoted values, but replace vars like unquoted expressions
1367+
var quote = new tree.Quoted("'", item, true, index, fileInfo);
1368+
quote.variableRegex = /@([\w-]+)/g;
1369+
quote.propRegex = /\$([\w-]+)/g;
1370+
quote.reparse = true;
1371+
args.push(quote);
1372+
}
1373+
}
1374+
return new tree.Expression(args, true);
1375+
}
1376+
},
13271377

13281378
//
13291379
// An @import atrule
@@ -1595,10 +1645,15 @@ var Parser = function Parser(context, imports, fileInfo) {
15951645
error("expected " + name + " expression");
15961646
}
15971647
} else if (hasUnknown) {
1598-
value = (parserInput.$re(/^[^{;]+/) || '').trim();
1599-
hasBlock = (parserInput.currentChar() == '{');
1600-
if (value) {
1601-
value = new(tree.Anonymous)(value);
1648+
value = this.permissiveValue(/^[{;]/);
1649+
hasBlock = (parserInput.currentChar() === '{');
1650+
if (!value) {
1651+
if (!hasBlock && parserInput.currentChar() !== ';') {
1652+
error(name + " rule is missing block or ending semi-colon");
1653+
}
1654+
}
1655+
else if (!value.value) {
1656+
value = null;
16021657
}
16031658
}
16041659

lib/less/tree/expression.js

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@ var Node = require("./node"),
22
Paren = require("./paren"),
33
Comment = require("./comment");
44

5-
var Expression = function (value) {
5+
var Expression = function (value, noSpacing) {
66
this.value = value;
7+
this.noSpacing = noSpacing;
78
if (!value) {
89
throw new Error("Expression requires an array parameter");
910
}
@@ -23,7 +24,7 @@ Expression.prototype.eval = function (context) {
2324
if (this.value.length > 1) {
2425
returnValue = new Expression(this.value.map(function (e) {
2526
return e.eval(context);
26-
}));
27+
}), this.noSpacing);
2728
} else if (this.value.length === 1) {
2829
if (this.value[0].parens && !this.value[0].parensInOp) {
2930
doubleParen = true;
@@ -43,7 +44,7 @@ Expression.prototype.eval = function (context) {
4344
Expression.prototype.genCSS = function (context, output) {
4445
for (var i = 0; i < this.value.length; i++) {
4546
this.value[i].genCSS(context, output);
46-
if (i + 1 < this.value.length) {
47+
if (!this.noSpacing && i + 1 < this.value.length) {
4748
output.add(" ");
4849
}
4950
}

lib/less/tree/quoted.js

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ var Quoted = function (str, content, escaped, index, currentFileInfo) {
88
this.quote = str.charAt(0);
99
this._index = index;
1010
this._fileInfo = currentFileInfo;
11+
this.variableRegex = /@\{([\w-]+)\}/g;
12+
this.propRegex = /\$\{([\w-]+)\}/g;
1113
};
1214
Quoted.prototype = new Node();
1315
Quoted.prototype.type = "Quoted";
@@ -21,7 +23,7 @@ Quoted.prototype.genCSS = function (context, output) {
2123
}
2224
};
2325
Quoted.prototype.containsVariables = function() {
24-
return this.value.match(/@\{([\w-]+)\}/);
26+
return this.value.match(this.variableRegex);
2527
};
2628
Quoted.prototype.eval = function (context) {
2729
var that = this, value = this.value;
@@ -41,8 +43,8 @@ Quoted.prototype.eval = function (context) {
4143
} while (value !== evaluatedValue);
4244
return evaluatedValue;
4345
}
44-
value = iterativeReplace(value, /@\{([\w-]+)\}/g, variableReplacement);
45-
value = iterativeReplace(value, /\$\{([\w-]+)\}/g, propertyReplacement);
46+
value = iterativeReplace(value, this.variableRegex, variableReplacement);
47+
value = iterativeReplace(value, this.propRegex, propertyReplacement);
4648
return new Quoted(this.quote + value + this.quote, value, this.escaped, this.getIndex(), this.fileInfo());
4749
};
4850
Quoted.prototype.compare = function (other) {

test/css/permissive-parse.css

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
@-moz-document regexp("(\d{0,15})") {
2+
a {
3+
color: red;
4+
}
5+
}
6+
.custom-property {
7+
--this: () => {
8+
basically anything until final semi-colon;
9+
even other stuff; // i\'m serious;
10+
};
11+
--that: () => {
12+
basically anything until final semi-colon;
13+
even other stuff; // i\'m serious;
14+
};
15+
--custom-color: #ff3333;
16+
custom-color: #ff3333;
17+
}
18+
.var {
19+
--fortran: read (*, *, iostat=1) radius, height;
20+
}
21+
@-moz-whatever (foo: "(" bam ")") {
22+
bar: foo;
23+
}
24+
#selector, .bar, foo[attr="blah"] {
25+
bar: value;
26+
}
27+
@media (min-width: 640px) {
28+
.holy-crap {
29+
this: works;
30+
}
31+
}
32+
.test-comment {
33+
--value: ;
34+
--comment-within: ( /* okay?; comment; */ );
35+
--empty: ;
36+
}

test/less-test.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ module.exports = function() {
1414
var oneTestOnly = process.argv[2],
1515
isFinished = false;
1616

17-
var isVerbose = process.env.npm_config_loglevel === 'verbose';
17+
var isVerbose = process.env.npm_config_loglevel !== 'concise';
1818

1919
var normalFolder = 'test/less';
2020
var bomFolder = 'test/less-bom';
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
2+
@unknown url( {
3+
50% {width: 20px;}
4+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
SyntaxError: @unknown rule is missing block or ending semi-colon in {path}at-rules-unmatching-block.less on line 2, column 10:
2+
1
3+
2 @unknown url( {
4+
3 50% {width: 20px;}

0 commit comments

Comments
 (0)