Skip to content

Commit 2a8cf1f

Browse files
authored
Support new CSS if() (#472)
* Add `if` to `cssWideFunctions` * Add (failing) tests for builtin functions * Implement `if` parsing in CSS * Support old `if` syntax for SCSS * Add more tests for other `if` syntax * Add (failing) tests for `sass()` in `if()` * Properly parse `sass()` in `if()` * Add `resyncStopToken`s for better error experience
1 parent 54c68ce commit 2a8cf1f

File tree

6 files changed

+237
-4
lines changed

6 files changed

+237
-4
lines changed

src/languageFacts/builtinData.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,8 @@ export const cssWideKeywords: { [name: string]: string } = {
5757

5858
export const cssWideFunctions: { [name: string]: string } = {
5959
'var()': 'Evaluates the value of a custom variable.',
60-
'calc()': 'Evaluates an mathematical expression. The following operators can be used: + - * /.'
60+
'calc()': 'Evaluates an mathematical expression. The following operators can be used: + - * /.',
61+
'if()': 'Evaluates a conditional expression.'
6162
};
6263

6364
export const imageFunctions: { [name: string]: string } = {

src/parser/cssErrors.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,4 +52,5 @@ export const ParseError = {
5252
IdentifierOrWildcardExpected: new CSSIssueType('css-idorwildcardexpected', l10n.t("identifier or wildcard expected")),
5353
WildcardExpected: new CSSIssueType('css-wildcardexpected', l10n.t("wildcard expected")),
5454
IdentifierOrVariableExpected: new CSSIssueType('css-idorvarexpected', l10n.t("identifier or variable expected")),
55+
IfConditionExpected: new CSSIssueType('css-ifconditionexpected', l10n.t("if condition expected")),
5556
};

src/parser/cssParser.ts

Lines changed: 143 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1157,6 +1157,61 @@ export class Parser {
11571157
return this.finish(node);
11581158
}
11591159

1160+
public _parseBooleanExpression(parseTest: () => nodes.Node | null): nodes.Node | null {
1161+
// <boolean-expr[ <test> ]> = not <boolean-expr-group> | <boolean-expr-group>
1162+
// [ [ and <boolean-expr-group> ]*
1163+
// | [ or <boolean-expr-group> ]* ]
1164+
1165+
const node = this.create(nodes.Node);
1166+
1167+
if (this.acceptIdent('not')) {
1168+
if (!node.addChild(this._parseBooleanExpressionGroup(parseTest))) {
1169+
return null;
1170+
}
1171+
} else {
1172+
if (!node.addChild(this._parseBooleanExpressionGroup(parseTest))) {
1173+
return null;
1174+
}
1175+
if (this.peekIdent('and')) {
1176+
while (this.acceptIdent('and')) {
1177+
if (!node.addChild(this._parseBooleanExpressionGroup(parseTest))) {
1178+
return null;
1179+
}
1180+
}
1181+
} else if (this.peekIdent('or')) {
1182+
while (this.acceptIdent('or')) {
1183+
if (!node.addChild(this._parseBooleanExpressionGroup(parseTest))) {
1184+
return null;
1185+
}
1186+
}
1187+
}
1188+
}
1189+
return this.finish(node);
1190+
}
1191+
1192+
public _parseBooleanExpressionGroup(parseTest: () => nodes.Node | null) {
1193+
// <boolean-expr-group> = <test> | ( <boolean-expr[ <test> ]> ) | <general-enclosed>
1194+
1195+
const node = this.create(nodes.Node);
1196+
const pos = this.mark();
1197+
1198+
if (this.accept(TokenType.ParenthesisL)) {
1199+
if (node.addChild(this._parseBooleanExpression(parseTest))) {
1200+
if (!this.accept(TokenType.ParenthesisR)) {
1201+
return this.finish(node, ParseError.RightParenthesisExpected, [], [TokenType.CurlyL]);
1202+
}
1203+
return this.finish(node);
1204+
}
1205+
this.restoreAtMark(pos);
1206+
}
1207+
1208+
if (!node.addChild(parseTest())) {
1209+
return null;
1210+
};
1211+
1212+
return this.finish(node);
1213+
}
1214+
11601215
public _parseMediaCondition(): nodes.Node | null {
11611216
// <media-condition> = <media-not> | <media-and> | <media-or> | <media-in-parens>
11621217
// <media-not> = not <media-in-parens>
@@ -2071,6 +2126,13 @@ export class Parser {
20712126
const pos = this.mark();
20722127
const node = this.create(nodes.Function);
20732128

2129+
let parseArgument = this._parseFunctionArgument.bind(this);
2130+
let separator = TokenType.Comma;
2131+
if (this.peekIdent("if")) {
2132+
parseArgument = this._parseIfBranch.bind(this);
2133+
separator = TokenType.SemiColon;
2134+
}
2135+
20742136
if (!node.setIdentifier(this._parseFunctionIdentifier())) {
20752137
return null;
20762138
}
@@ -2080,12 +2142,12 @@ export class Parser {
20802142
return null;
20812143
}
20822144

2083-
if (node.getArguments().addChild(this._parseFunctionArgument())) {
2084-
while (this.accept(TokenType.Comma)) {
2145+
if (node.getArguments().addChild(parseArgument())) {
2146+
while (this.accept(separator)) {
20852147
if (this.peek(TokenType.ParenthesisR)) {
20862148
break;
20872149
}
2088-
if (!node.getArguments().addChild(this._parseFunctionArgument())) {
2150+
if (!node.getArguments().addChild(parseArgument())) {
20892151
this.markError(node, ParseError.ExpressionExpected);
20902152
}
20912153
}
@@ -2126,6 +2188,84 @@ export class Parser {
21262188
return null;
21272189
}
21282190

2191+
public _parseIfBranch() {
2192+
// <if-branch> = <if-condition> : <declaration-value>?
2193+
2194+
const node = this.create(nodes.Node);
2195+
if (!node.addChild(this._parseIfCondition())) {
2196+
return this.finish(node, ParseError.IfConditionExpected, [], [TokenType.SemiColon]);
2197+
}
2198+
if (!this.accept(TokenType.Colon)) {
2199+
return this.finish(node, ParseError.ColonExpected, [], [TokenType.SemiColon]);
2200+
}
2201+
node.addChild(this._parseExpr());
2202+
return this.finish(node);
2203+
}
2204+
2205+
public _parseIfCondition(): nodes.Node | null {
2206+
// <if-condition> = <boolean-expr[ <if-test> ]> | else
2207+
2208+
const node = this.create(nodes.Node);
2209+
2210+
if (this.peekIdent("else")) {
2211+
node.addChild(this._parseIdent());
2212+
return this.finish(node);
2213+
}
2214+
2215+
return this._parseBooleanExpression(this._parseIfTest.bind(this));
2216+
}
2217+
2218+
public _parseIfTest(): nodes.Node | null {
2219+
// <if-test> =
2220+
// supports( [ <ident> : <declaration-value> ] | <supports-condition> ) |
2221+
// media( <media-feature> | <media-condition> ) |
2222+
// style( <style-query> )
2223+
2224+
const node = this.create(nodes.Node);
2225+
2226+
if (this.acceptIdent('supports')) {
2227+
if (this.hasWhitespace() || !this.accept(TokenType.ParenthesisL)) {
2228+
return this.finish(node, ParseError.LeftParenthesisExpected, [], [TokenType.Colon]);
2229+
}
2230+
node.addChild(this._tryToParseDeclaration() || this._parseSupportsCondition());
2231+
if (!this.accept(TokenType.ParenthesisR)) {
2232+
return this.finish(node, ParseError.RightParenthesisExpected, [], [TokenType.Colon]);
2233+
}
2234+
return this.finish(node);
2235+
}
2236+
2237+
if (this.acceptIdent('media')) {
2238+
if (this.hasWhitespace() || !this.accept(TokenType.ParenthesisL)) {
2239+
return this.finish(node, ParseError.LeftParenthesisExpected, [], [TokenType.Colon]);
2240+
}
2241+
const pos = this.mark();
2242+
const condition = this._parseMediaCondition();
2243+
if (condition && !condition.isErroneous()) {
2244+
node.addChild(condition);
2245+
} else {
2246+
this.restoreAtMark(pos);
2247+
node.addChild(this._parseMediaFeature());
2248+
}
2249+
if (!this.accept(TokenType.ParenthesisR)) {
2250+
return this.finish(node, ParseError.RightParenthesisExpected, [], [TokenType.Colon]);
2251+
}
2252+
return this.finish(node);
2253+
}
2254+
2255+
if (this.acceptIdent('style')) {
2256+
if (this.hasWhitespace() || !this.accept(TokenType.ParenthesisL)) {
2257+
return this.finish(node, ParseError.LeftParenthesisExpected, [], [TokenType.Colon]);
2258+
}
2259+
node.addChild(this._parseStyleQuery());
2260+
if (!this.accept(TokenType.ParenthesisR)) {
2261+
return this.finish(node, ParseError.RightParenthesisExpected, [], [TokenType.Colon]);
2262+
}
2263+
return this.finish(node);
2264+
}
2265+
2266+
return null;
2267+
}
2268+
21292269
public _parseHexColor(): nodes.Node | null {
21302270
if (this.peekRegExp(TokenType.Hash, /^#([A-Fa-f0-9]{3}|[A-Fa-f0-9]{4}|[A-Fa-f0-9]{6}|[A-Fa-f0-9]{8})$/g)) {
21312271
const node = this.create(nodes.HexColorValue);

src/parser/scssParser.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -715,6 +715,74 @@ export class SCSSParser extends cssParser.Parser {
715715
return this._tryParseKeyframeSelector() || this._parseRuleSetDeclaration();
716716
}
717717

718+
public _parseIfTest(): nodes.Node | null {
719+
const node = this.create(nodes.Node);
720+
721+
if (this.acceptIdent('sass')) {
722+
if (this.hasWhitespace() || !this.accept(TokenType.ParenthesisL)) {
723+
return this.finish(node, ParseError.LeftParenthesisExpected, [], [TokenType.CurlyL]);
724+
}
725+
node.addChild(this._parseExpr());
726+
if (!this.accept(TokenType.ParenthesisR)) {
727+
return this.finish(node, ParseError.RightParenthesisExpected, [], [TokenType.CurlyL]);
728+
}
729+
return this.finish(node);
730+
}
731+
732+
return super._parseIfTest();
733+
}
734+
735+
public _parseFunction(): nodes.Function | null {
736+
737+
const pos = this.mark();
738+
const node = this.create(nodes.Function);
739+
740+
let isIf = this.peekIdent('if');
741+
742+
if (!node.setIdentifier(this._parseFunctionIdentifier())) {
743+
return null;
744+
}
745+
746+
if (this.hasWhitespace() || !this.accept(TokenType.ParenthesisL)) {
747+
this.restoreAtMark(pos);
748+
return null;
749+
}
750+
751+
let firstArgument: nodes.Node | null;
752+
let parseArgument = this._parseFunctionArgument.bind(this);
753+
let separator = TokenType.Comma;
754+
if (!isIf) {
755+
firstArgument = this._parseFunctionArgument();
756+
} else {
757+
const pos = this.mark();
758+
firstArgument = this._parseIfBranch();
759+
if (firstArgument && !firstArgument.isErroneous()) {
760+
parseArgument = this._parseIfBranch.bind(this);
761+
separator = TokenType.SemiColon;
762+
} else {
763+
this.restoreAtMark(pos);
764+
firstArgument = this._parseFunctionArgument();
765+
}
766+
}
767+
768+
if (node.getArguments().addChild(firstArgument)) {
769+
while (this.accept(separator)) {
770+
if (this.peek(TokenType.ParenthesisR)) {
771+
break;
772+
}
773+
if (!node.getArguments().addChild(parseArgument())) {
774+
this.markError(node, ParseError.ExpressionExpected);
775+
}
776+
}
777+
}
778+
779+
if (!this.accept(TokenType.ParenthesisR)) {
780+
return <nodes.Function>this.finish(node, ParseError.RightParenthesisExpected);
781+
}
782+
return <nodes.Function>this.finish(node);
783+
}
784+
785+
718786
public _parseFunctionArgument(): nodes.Node | null {
719787
// [variableName ':'] expression | variableName '...'
720788
const node = <nodes.FunctionArgument>this.create(nodes.FunctionArgument);

src/test/css/parser.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -621,6 +621,22 @@ suite('CSS - Parser', () => {
621621
assertFunction('let(--variable1, let(--variable2))', parser, parser._parseFunction.bind(parser));
622622
assertFunction('fun(value1, value2)', parser, parser._parseFunction.bind(parser));
623623
assertFunction('fun(value1,)', parser, parser._parseFunction.bind(parser));
624+
625+
// Builtin functions
626+
// var
627+
assertFunction('var(--some-variable)', parser, parser._parseFunction.bind(parser));
628+
// calc
629+
assertFunction('calc(10px + 1rem)', parser, parser._parseFunction.bind(parser));
630+
// if
631+
assertFunction('if(media(print): black; else: white;)', parser, parser._parseFunction.bind(parser));
632+
assertFunction('if(media(print): ; else: ;)', parser, parser._parseFunction.bind(parser));
633+
assertFunction('if(media(print): black; else: white)', parser, parser._parseFunction.bind(parser));
634+
assertFunction('if(style(--some-var: true): black)', parser, parser._parseFunction.bind(parser));
635+
// TODO: once https://github.com/microsoft/vscode-css-languageservice/pull/473 is merged, this should also work:
636+
// assertFunction('if(style(--some-var): black)', parser, parser._parseFunction.bind(parser));
637+
assertFunction('if(else: white)', parser, parser._parseFunction.bind(parser));
638+
assertError('if()', parser, parser._parseFunction.bind(parser), ParseError.IfConditionExpected);
639+
assertError('if(invalid: black;)', parser, parser._parseFunction.bind(parser), ParseError.IfConditionExpected);
624640
});
625641

626642
test('test token prio', function () {

src/test/scss/parser.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -669,4 +669,11 @@ suite('SCSS - Parser', () => {
669669
assertNode('@font-face { unicode-range: U+0021-007F, u+1f49C, U+4??, U+??????; }', parser, parser._parseFontFace.bind(parser));
670670
assertError('@font-face { font-style: normal font-stretch: normal; }', parser, parser._parseFontFace.bind(parser), ParseError.SemiColonExpected);
671671
});
672+
673+
test('if function', function() {
674+
const parser = new SCSSParser();
675+
assertNode('if(true, black, white)', parser, parser._parseFunction.bind(parser));
676+
assertNode('if(sass(true): black; else: white;)', parser, parser._parseFunction.bind(parser));
677+
assertNode('if(sass($value == \'default\'): flex-gutter(); else: $value;)', parser, parser._parseFunction.bind(parser));
678+
})
672679
});

0 commit comments

Comments
 (0)