From 0d793cb8f89dbaea4ec508d6fc2209d9b3c4ba38 Mon Sep 17 00:00:00 2001 From: ejshafran Date: Tue, 27 Jan 2026 14:08:43 +0200 Subject: [PATCH 1/8] Add `if` to `cssWideFunctions` --- src/languageFacts/builtinData.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/languageFacts/builtinData.ts b/src/languageFacts/builtinData.ts index 4750bc3d..dd8611a2 100644 --- a/src/languageFacts/builtinData.ts +++ b/src/languageFacts/builtinData.ts @@ -57,7 +57,8 @@ export const cssWideKeywords: { [name: string]: string } = { export const cssWideFunctions: { [name: string]: string } = { 'var()': 'Evaluates the value of a custom variable.', - 'calc()': 'Evaluates an mathematical expression. The following operators can be used: + - * /.' + 'calc()': 'Evaluates an mathematical expression. The following operators can be used: + - * /.', + 'if()': 'Evaluates a conditional expression.' }; export const imageFunctions: { [name: string]: string } = { From b086388e02ba40522f8e8561b976d07993279d0a Mon Sep 17 00:00:00 2001 From: ejshafran Date: Tue, 27 Jan 2026 14:09:00 +0200 Subject: [PATCH 2/8] Add (failing) tests for builtin functions --- src/test/css/parser.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/test/css/parser.test.ts b/src/test/css/parser.test.ts index 53a241a9..53ac822d 100644 --- a/src/test/css/parser.test.ts +++ b/src/test/css/parser.test.ts @@ -611,6 +611,14 @@ suite('CSS - Parser', () => { assertFunction('let(--variable1, let(--variable2))', parser, parser._parseFunction.bind(parser)); assertFunction('fun(value1, value2)', parser, parser._parseFunction.bind(parser)); assertFunction('fun(value1,)', parser, parser._parseFunction.bind(parser)); + + // Builtin functions + // var + assertFunction('var(--some-variable)', parser, parser._parseFunction.bind(parser)); + // calc + assertFunction('calc(10px + 1rem)', parser, parser._parseFunction.bind(parser)); + // if + assertFunction('if(media(print): black; else: white;)', parser, parser._parseFunction.bind(parser)); }); test('test token prio', function () { From 00872ed1b488557bf438c4233eb27d6ff0c07059 Mon Sep 17 00:00:00 2001 From: ejshafran Date: Tue, 27 Jan 2026 14:18:22 +0200 Subject: [PATCH 3/8] Implement `if` parsing in CSS --- src/parser/cssErrors.ts | 1 + src/parser/cssParser.ts | 146 +++++++++++++++++++++++++++++++++++- src/test/css/parser.test.ts | 2 + 3 files changed, 146 insertions(+), 3 deletions(-) diff --git a/src/parser/cssErrors.ts b/src/parser/cssErrors.ts index 07ae5ecb..690b2491 100644 --- a/src/parser/cssErrors.ts +++ b/src/parser/cssErrors.ts @@ -52,4 +52,5 @@ export const ParseError = { IdentifierOrWildcardExpected: new CSSIssueType('css-idorwildcardexpected', l10n.t("identifier or wildcard expected")), WildcardExpected: new CSSIssueType('css-wildcardexpected', l10n.t("wildcard expected")), IdentifierOrVariableExpected: new CSSIssueType('css-idorvarexpected', l10n.t("identifier or variable expected")), + IfConditionExpected: new CSSIssueType('css-ifconditionexpected', l10n.t("if condition expected")), }; diff --git a/src/parser/cssParser.ts b/src/parser/cssParser.ts index 588f24e4..a65d42ba 100644 --- a/src/parser/cssParser.ts +++ b/src/parser/cssParser.ts @@ -1154,6 +1154,61 @@ export class Parser { return this.finish(node); } + public _parseBooleanExpression(parseTest: () => nodes.Node | null): nodes.Node | null { + // ]> = not | + // [ [ and ]* + // | [ or ]* ] + + const node = this.create(nodes.Node); + + if (this.acceptIdent('not')) { + if (!node.addChild(this._parseBooleanExpressionGroup(parseTest))) { + return null; + } + } else { + if (!node.addChild(this._parseBooleanExpressionGroup(parseTest))) { + return null; + } + if (this.peekIdent('and')) { + while (this.acceptIdent('and')) { + if (!node.addChild(this._parseBooleanExpressionGroup(parseTest))) { + return null; + } + } + } else if (this.peekIdent('or')) { + while (this.acceptIdent('or')) { + if (!node.addChild(this._parseBooleanExpressionGroup(parseTest))) { + return null; + } + } + } + } + return this.finish(node); + } + + public _parseBooleanExpressionGroup(parseTest: () => nodes.Node | null) { + // = | ( ]> ) | + + const node = this.create(nodes.Node); + const pos = this.mark(); + + if (this.accept(TokenType.ParenthesisL)) { + if (node.addChild(this._parseBooleanExpression(parseTest))) { + if (!this.accept(TokenType.ParenthesisR)) { + return this.finish(node, ParseError.RightParenthesisExpected, [], [TokenType.CurlyL]); + } + return this.finish(node); + } + this.restoreAtMark(pos); + } + + if (!node.addChild(parseTest())) { + return null; + }; + + return this.finish(node); + } + public _parseMediaCondition(): nodes.Node | null { // = | | | // = not @@ -2045,6 +2100,13 @@ export class Parser { const pos = this.mark(); const node = this.create(nodes.Function); + let parseArgument = this._parseFunctionArgument.bind(this); + let separator = TokenType.Comma; + if (this.peekIdent("if")) { + parseArgument = this._parseIfBranch.bind(this); + separator = TokenType.SemiColon; + } + if (!node.setIdentifier(this._parseFunctionIdentifier())) { return null; } @@ -2054,12 +2116,12 @@ export class Parser { return null; } - if (node.getArguments().addChild(this._parseFunctionArgument())) { - while (this.accept(TokenType.Comma)) { + if (node.getArguments().addChild(parseArgument())) { + while (this.accept(separator)) { if (this.peek(TokenType.ParenthesisR)) { break; } - if (!node.getArguments().addChild(this._parseFunctionArgument())) { + if (!node.getArguments().addChild(parseArgument())) { this.markError(node, ParseError.ExpressionExpected); } } @@ -2100,6 +2162,84 @@ export class Parser { return null; } + public _parseIfBranch() { + // = : ? + + const node = this.create(nodes.Node); + if (!node.addChild(this._parseIfCondition())) { + return this.finish(node, ParseError.IfConditionExpected); + } + if (!this.accept(TokenType.Colon)) { + return this.finish(node, ParseError.ColonExpected); + } + node.addChild(this._parseExpr()); + return this.finish(node); + } + + public _parseIfCondition(): nodes.Node | null { + // = ]> | else + + const node = this.create(nodes.Node); + + if (this.peekIdent("else")) { + node.addChild(this._parseIdent()); + return this.finish(node); + } + + return this._parseBooleanExpression(this._parseIfTest.bind(this)); + } + + public _parseIfTest(): nodes.Node | null { + // = + // supports( [ : ] | ) | + // media( | ) | + // style( ) + + const node = this.create(nodes.Node); + + if (this.acceptIdent('supports')) { + if (this.hasWhitespace() || !this.accept(TokenType.ParenthesisL)) { + return this.finish(node, ParseError.LeftParenthesisExpected, [], [TokenType.CurlyL]); + } + node.addChild(this._tryToParseDeclaration() || this._parseSupportsCondition()); + if (!this.accept(TokenType.ParenthesisR)) { + return this.finish(node, ParseError.RightParenthesisExpected, [], [TokenType.CurlyL]); + } + return this.finish(node); + } + + if (this.acceptIdent('media')) { + if (this.hasWhitespace() || !this.accept(TokenType.ParenthesisL)) { + return this.finish(node, ParseError.LeftParenthesisExpected, [], [TokenType.CurlyL]); + } + const pos = this.mark(); + const condition = this._parseMediaCondition(); + if (condition && !condition.isErroneous()) { + node.addChild(condition); + } else { + this.restoreAtMark(pos); + node.addChild(this._parseMediaFeature()); + } + if (!this.accept(TokenType.ParenthesisR)) { + return this.finish(node, ParseError.RightParenthesisExpected, [], [TokenType.CurlyL]); + } + return this.finish(node); + } + + if (this.acceptIdent('style')) { + if (this.hasWhitespace() || !this.accept(TokenType.ParenthesisL)) { + return this.finish(node, ParseError.LeftParenthesisExpected, [], [TokenType.CurlyL]); + } + node.addChild(this._parseStyleQuery()); + if (!this.accept(TokenType.ParenthesisR)) { + return this.finish(node, ParseError.RightParenthesisExpected, [], [TokenType.CurlyL]); + } + return this.finish(node); + } + + return null; + } + public _parseHexColor(): nodes.Node | null { 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)) { const node = this.create(nodes.HexColorValue); diff --git a/src/test/css/parser.test.ts b/src/test/css/parser.test.ts index 53ac822d..4ad28150 100644 --- a/src/test/css/parser.test.ts +++ b/src/test/css/parser.test.ts @@ -619,6 +619,8 @@ suite('CSS - Parser', () => { assertFunction('calc(10px + 1rem)', parser, parser._parseFunction.bind(parser)); // if assertFunction('if(media(print): black; else: white;)', parser, parser._parseFunction.bind(parser)); + assertError('if()', parser, parser._parseFunction.bind(parser), ParseError.IfConditionExpected); + assertError('if(invalid: black;)', parser, parser._parseFunction.bind(parser), ParseError.IfConditionExpected); }); test('test token prio', function () { From 0611fb76d61c4015e642965248641558c862fe86 Mon Sep 17 00:00:00 2001 From: ejshafran Date: Tue, 27 Jan 2026 14:28:58 +0200 Subject: [PATCH 4/8] Support old `if` syntax for SCSS --- src/parser/scssParser.ts | 51 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/src/parser/scssParser.ts b/src/parser/scssParser.ts index ea9d7f99..9b89196b 100644 --- a/src/parser/scssParser.ts +++ b/src/parser/scssParser.ts @@ -715,6 +715,57 @@ export class SCSSParser extends cssParser.Parser { return this._tryParseKeyframeSelector() || this._parseRuleSetDeclaration(); } + public _parseFunction(): nodes.Function | null { + + const pos = this.mark(); + const node = this.create(nodes.Function); + + let isIf = this.peekIdent('if'); + + if (!node.setIdentifier(this._parseFunctionIdentifier())) { + return null; + } + + if (this.hasWhitespace() || !this.accept(TokenType.ParenthesisL)) { + this.restoreAtMark(pos); + return null; + } + + let firstArgument: nodes.Node | null; + let parseArgument = this._parseFunctionArgument.bind(this); + let separator = TokenType.Comma; + if (!isIf) { + firstArgument = this._parseFunctionArgument(); + } else { + const pos = this.mark(); + firstArgument = this._parseIfBranch(); + if (firstArgument && !firstArgument.isErroneous()) { + parseArgument = this._parseIfBranch.bind(this); + separator = TokenType.SemiColon; + } else { + this.restoreAtMark(pos); + firstArgument = this._parseFunctionArgument(); + } + } + + if (node.getArguments().addChild(firstArgument)) { + while (this.accept(separator)) { + if (this.peek(TokenType.ParenthesisR)) { + break; + } + if (!node.getArguments().addChild(parseArgument())) { + this.markError(node, ParseError.ExpressionExpected); + } + } + } + + if (!this.accept(TokenType.ParenthesisR)) { + return this.finish(node, ParseError.RightParenthesisExpected); + } + return this.finish(node); + } + + public _parseFunctionArgument(): nodes.Node | null { // [variableName ':'] expression | variableName '...' const node = this.create(nodes.FunctionArgument); From 9ffb8764633a98a1c2ada5916ad4a988c1a9a12c Mon Sep 17 00:00:00 2001 From: ejshafran Date: Tue, 27 Jan 2026 16:14:15 +0200 Subject: [PATCH 5/8] Add more tests for other `if` syntax --- src/test/css/parser.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/test/css/parser.test.ts b/src/test/css/parser.test.ts index 4ad28150..a08090de 100644 --- a/src/test/css/parser.test.ts +++ b/src/test/css/parser.test.ts @@ -619,6 +619,12 @@ suite('CSS - Parser', () => { assertFunction('calc(10px + 1rem)', parser, parser._parseFunction.bind(parser)); // if assertFunction('if(media(print): black; else: white;)', parser, parser._parseFunction.bind(parser)); + assertFunction('if(media(print): ; else: ;)', parser, parser._parseFunction.bind(parser)); + assertFunction('if(media(print): black; else: white)', parser, parser._parseFunction.bind(parser)); + assertFunction('if(style(--some-var: true): black)', parser, parser._parseFunction.bind(parser)); + // TODO: once https://github.com/microsoft/vscode-css-languageservice/pull/473 is merged, this should also work: + // assertFunction('if(style(--some-var): black)', parser, parser._parseFunction.bind(parser)); + assertFunction('if(else: white)', parser, parser._parseFunction.bind(parser)); assertError('if()', parser, parser._parseFunction.bind(parser), ParseError.IfConditionExpected); assertError('if(invalid: black;)', parser, parser._parseFunction.bind(parser), ParseError.IfConditionExpected); }); From e477a68429c3f9b472280e736fe1420a835c53cb Mon Sep 17 00:00:00 2001 From: ejshafran Date: Tue, 27 Jan 2026 16:15:36 +0200 Subject: [PATCH 6/8] Add (failing) tests for `sass()` in `if()` --- src/test/scss/parser.test.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/test/scss/parser.test.ts b/src/test/scss/parser.test.ts index 5f0d11c4..c453e317 100644 --- a/src/test/scss/parser.test.ts +++ b/src/test/scss/parser.test.ts @@ -669,4 +669,11 @@ suite('SCSS - Parser', () => { assertNode('@font-face { unicode-range: U+0021-007F, u+1f49C, U+4??, U+??????; }', parser, parser._parseFontFace.bind(parser)); assertError('@font-face { font-style: normal font-stretch: normal; }', parser, parser._parseFontFace.bind(parser), ParseError.SemiColonExpected); }); + + test('if function', function() { + const parser = new SCSSParser(); + assertNode('if(true, black, white)', parser, parser._parseFunction.bind(parser)); + assertNode('if(sass(true): black; else: white;)', parser, parser._parseFunction.bind(parser)); + assertNode('if(sass($value == \'default\'): flex-gutter(); else: $value;)', parser, parser._parseFunction.bind(parser)); + }) }); From 14fa4b5c0ee7d47285f663a100a0767b73f1e5a6 Mon Sep 17 00:00:00 2001 From: ejshafran Date: Tue, 27 Jan 2026 16:15:52 +0200 Subject: [PATCH 7/8] Properly parse `sass()` in `if()` --- src/parser/scssParser.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/parser/scssParser.ts b/src/parser/scssParser.ts index 9b89196b..ad3eddfe 100644 --- a/src/parser/scssParser.ts +++ b/src/parser/scssParser.ts @@ -715,6 +715,23 @@ export class SCSSParser extends cssParser.Parser { return this._tryParseKeyframeSelector() || this._parseRuleSetDeclaration(); } + public _parseIfTest(): nodes.Node | null { + const node = this.create(nodes.Node); + + if (this.acceptIdent('sass')) { + if (this.hasWhitespace() || !this.accept(TokenType.ParenthesisL)) { + return this.finish(node, ParseError.LeftParenthesisExpected, [], [TokenType.CurlyL]); + } + node.addChild(this._parseExpr()); + if (!this.accept(TokenType.ParenthesisR)) { + return this.finish(node, ParseError.RightParenthesisExpected, [], [TokenType.CurlyL]); + } + return this.finish(node); + } + + return super._parseIfTest(); + } + public _parseFunction(): nodes.Function | null { const pos = this.mark(); From 84c4137f20a6a40a4d3750d79852afd14c572bdd Mon Sep 17 00:00:00 2001 From: ejshafran Date: Tue, 27 Jan 2026 17:57:27 +0200 Subject: [PATCH 8/8] Add `resyncStopToken`s for better error experience --- src/parser/cssParser.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/parser/cssParser.ts b/src/parser/cssParser.ts index a65d42ba..43f17481 100644 --- a/src/parser/cssParser.ts +++ b/src/parser/cssParser.ts @@ -2167,10 +2167,10 @@ export class Parser { const node = this.create(nodes.Node); if (!node.addChild(this._parseIfCondition())) { - return this.finish(node, ParseError.IfConditionExpected); + return this.finish(node, ParseError.IfConditionExpected, [], [TokenType.SemiColon]); } if (!this.accept(TokenType.Colon)) { - return this.finish(node, ParseError.ColonExpected); + return this.finish(node, ParseError.ColonExpected, [], [TokenType.SemiColon]); } node.addChild(this._parseExpr()); return this.finish(node); @@ -2199,18 +2199,18 @@ export class Parser { if (this.acceptIdent('supports')) { if (this.hasWhitespace() || !this.accept(TokenType.ParenthesisL)) { - return this.finish(node, ParseError.LeftParenthesisExpected, [], [TokenType.CurlyL]); + return this.finish(node, ParseError.LeftParenthesisExpected, [], [TokenType.Colon]); } node.addChild(this._tryToParseDeclaration() || this._parseSupportsCondition()); if (!this.accept(TokenType.ParenthesisR)) { - return this.finish(node, ParseError.RightParenthesisExpected, [], [TokenType.CurlyL]); + return this.finish(node, ParseError.RightParenthesisExpected, [], [TokenType.Colon]); } return this.finish(node); } if (this.acceptIdent('media')) { if (this.hasWhitespace() || !this.accept(TokenType.ParenthesisL)) { - return this.finish(node, ParseError.LeftParenthesisExpected, [], [TokenType.CurlyL]); + return this.finish(node, ParseError.LeftParenthesisExpected, [], [TokenType.Colon]); } const pos = this.mark(); const condition = this._parseMediaCondition(); @@ -2221,18 +2221,18 @@ export class Parser { node.addChild(this._parseMediaFeature()); } if (!this.accept(TokenType.ParenthesisR)) { - return this.finish(node, ParseError.RightParenthesisExpected, [], [TokenType.CurlyL]); + return this.finish(node, ParseError.RightParenthesisExpected, [], [TokenType.Colon]); } return this.finish(node); } if (this.acceptIdent('style')) { if (this.hasWhitespace() || !this.accept(TokenType.ParenthesisL)) { - return this.finish(node, ParseError.LeftParenthesisExpected, [], [TokenType.CurlyL]); + return this.finish(node, ParseError.LeftParenthesisExpected, [], [TokenType.Colon]); } node.addChild(this._parseStyleQuery()); if (!this.accept(TokenType.ParenthesisR)) { - return this.finish(node, ParseError.RightParenthesisExpected, [], [TokenType.CurlyL]); + return this.finish(node, ParseError.RightParenthesisExpected, [], [TokenType.Colon]); } return this.finish(node); }