Skip to content

Commit 4509fc6

Browse files
feat(css): add support for SCSS binary expressions in lexer and parser (#9343)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent 70c2d4e commit 4509fc6

15 files changed

Lines changed: 1430 additions & 160 deletions

File tree

crates/biome_css_formatter/tests/specs/css/scss/declaration/nested-properties-complex-value.scss.snap

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ Trailing newline: true
3737

3838
```scss
3939
.theme {
40-
spacing: (1px +2px) * 3 {
40+
spacing: (1px + 2px) * 3 {
4141
size: 1px;
4242
}
4343

crates/biome_css_formatter/tests/specs/css/scss/declaration/page-at-rule.scss.snap

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
---
22
source: crates/biome_formatter_test/src/snapshot_builder.rs
3+
assertion_line: 212
34
info: css/scss/declaration/page-at-rule.scss
45
---
56

@@ -57,7 +58,7 @@ Trailing newline: true
5758
```scss
5859
@page :left {
5960
$padding: 12px !default;
60-
$scale: 1 +2 * 3;
61+
$scale: 1 + 2 * 3;
6162
padding: $padding * $scale;
6263
6364
font: {
@@ -66,13 +67,13 @@ Trailing newline: true
6667
}
6768
6869
$map: (
69-
($padding +1): foo,
70-
(1 +2): bar,
70+
($padding + 1): foo,
71+
(1 + 2): bar,
7172
);
7273
7374
@top-left {
7475
$margin: 4px !global;
75-
margin: ($margin +2) / 2;
76+
margin: ($margin + 2) / 2;
7677
border: 1px solid $color {
7778
width: 1px;
7879
color: $color;

crates/biome_css_formatter/tests/specs/css/scss/expression/edge-cases.scss.snap

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
---
22
source: crates/biome_formatter_test/src/snapshot_builder.rs
3+
assertion_line: 212
34
info: css/scss/expression/edge-cases.scss
45
---
56

@@ -70,7 +71,7 @@ $paren-map: (
7071
a: 1,
7172
b: 2,
7273
);
73-
$paren-expr: (1 +2 * 3);
74+
$paren-expr: (1 + 2 * 3);
7475
$nested-list: 1 2, 3 4, 5;
7576
$kw-args: foo($a: 1, $b: 2, $c...);
7677
.b {

crates/biome_css_formatter/tests/specs/css/scss/expression/formatting.scss.snap

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
---
22
source: crates/biome_formatter_test/src/snapshot_builder.rs
3+
assertion_line: 212
34
info: css/scss/expression/formatting.scss
45
---
56

@@ -98,15 +99,15 @@ $map: (
9899
);
99100
$list: 1, 2, 3;
100101
101-
$bin: 1 +2;
102+
$bin: 1 + 2;
102103
$unary1: -1;
103104
104105
$unary2: not $flag;
105106
$kw: foo($a: 1, $b: 2);
106107
107108
$varargs: foo($args...);
108109
109-
$paren: (1 +2);
110+
$paren: (1 + 2);
110111
$qualified: module.$var;
111112
112113
$parent: &;
@@ -118,7 +119,7 @@ $list-space: 1 2 3;
118119
119120
$list-mixed: 1 2, 3 4;
120121
121-
$paren-nested: ((1 +2) * 3);
122+
$paren-nested: ((1 + 2) * 3);
122123
123124
$comparison: $a <= $b and $c >= $d;
124125
@@ -129,7 +130,7 @@ $complex-args: foo($a: (1 2, 3 4), $b: map-get($map, key));
129130
130131
$interpolation: "#{$a}-#{$b}";
131132
$calc: calc(100% - $gutter * 2);
132-
$math: 1 +2 * 3% 4 / 5;
133+
$math: 1 + 2 * 3% 4 / 5;
133134
$map-nested: (
134135
a: (
135136
b: 1,

crates/biome_css_formatter/tests/specs/css/scss/expression/precedence.scss.snap

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ source: crates/biome_formatter_test/src/snapshot_builder.rs
33
assertion_line: 212
44
info: css/scss/expression/precedence.scss
55
---
6+
67
# Input
78

89
```scss
@@ -156,8 +157,8 @@ $expr15: $a != ($b + $c * $d);
156157
157158
$expr16: $a + $b / $c - $d * $e + $f;
158159
159-
$expr17: 1 +2 * 3 -4 / 5;
160-
$expr18: -1 +2;
160+
$expr17: 1 + 2 * 3 - 4 / 5;
161+
$expr18: -1 + 2;
161162
$expr19: ($a + $b) * ($c + $d) / ($e - $f);
162163
163164
$expr20: $a == $b and $c == $d or $e;
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
$number-plus-tight:1+2;
2+
$number-plus-left-space:1 +2;
3+
$number-plus-right-space:1+ 2;
4+
$number-plus-spaced:1 + 2;
5+
6+
$number-minus-tight:3-2;
7+
$number-minus-left-space:3 -2;
8+
$number-minus-right-space:3- 2;
9+
$number-minus-spaced:3 - 2;
10+
11+
$dimension-plus-tight:10+10px;
12+
$dimension-minus-tight:10-10px;
13+
14+
$percentage-plus-tight:10+10%;
15+
$percentage-minus-tight:10-10%;
16+
17+
$unary-plus:+2;
18+
$unary-minus:-2;
19+
$binary-plus-unary-plus:1 + +2;
20+
$binary-plus-unary-minus:1 + -3;
21+
$binary-plus-unary-plus-literal:1 + +5;
22+
$binary-minus-unary-minus:1 - -2;
23+
24+
$page-like-scale:1 +2 * 3;
25+
$page-like-map:(
26+
(1 +2):foo,
27+
($gap +1):bar,
28+
);
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
---
2+
source: crates/biome_formatter_test/src/snapshot_builder.rs
3+
info: css/scss/expression/signed-numeric-operator.scss
4+
---
5+
6+
# Input
7+
8+
```scss
9+
$number-plus-tight:1+2;
10+
$number-plus-left-space:1 +2;
11+
$number-plus-right-space:1+ 2;
12+
$number-plus-spaced:1 + 2;
13+
14+
$number-minus-tight:3-2;
15+
$number-minus-left-space:3 -2;
16+
$number-minus-right-space:3- 2;
17+
$number-minus-spaced:3 - 2;
18+
19+
$dimension-plus-tight:10+10px;
20+
$dimension-minus-tight:10-10px;
21+
22+
$percentage-plus-tight:10+10%;
23+
$percentage-minus-tight:10-10%;
24+
25+
$unary-plus:+2;
26+
$unary-minus:-2;
27+
$binary-plus-unary-plus:1 + +2;
28+
$binary-plus-unary-minus:1 + -3;
29+
$binary-plus-unary-plus-literal:1 + +5;
30+
$binary-minus-unary-minus:1 - -2;
31+
32+
$page-like-scale:1 +2 * 3;
33+
$page-like-map:(
34+
(1 +2):foo,
35+
($gap +1):bar,
36+
);
37+
38+
```
39+
40+
41+
=============================
42+
43+
# Outputs
44+
45+
## Output 1
46+
47+
-----
48+
Indent style: Tab
49+
Indent width: 2
50+
Line ending: LF
51+
Line width: 80
52+
Quote style: Double Quotes
53+
Trailing newline: true
54+
-----
55+
56+
```scss
57+
$number-plus-tight: 1 + 2;
58+
$number-plus-left-space: 1 + 2;
59+
$number-plus-right-space: 1 + 2;
60+
$number-plus-spaced: 1 + 2;
61+
62+
$number-minus-tight: 3 - 2;
63+
$number-minus-left-space: 3 - 2;
64+
$number-minus-right-space: 3 - 2;
65+
$number-minus-spaced: 3 - 2;
66+
67+
$dimension-plus-tight: 10 + 10px;
68+
$dimension-minus-tight: 10 - 10px;
69+
70+
$percentage-plus-tight: 10 + 10%;
71+
$percentage-minus-tight: 10 - 10%;
72+
73+
$unary-plus: +2;
74+
$unary-minus: -2;
75+
$binary-plus-unary-plus: 1 + +2;
76+
$binary-plus-unary-minus: 1 + -3;
77+
$binary-plus-unary-plus-literal: 1 + +5;
78+
$binary-minus-unary-minus: 1 - -2;
79+
80+
$page-like-scale: 1 + 2 * 3;
81+
$page-like-map: (
82+
(1 + 2): foo,
83+
($gap + 1): bar,
84+
);
85+
86+
```

crates/biome_css_parser/src/lexer/mod.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ pub enum CssReLexContext {
6363
Regular,
6464
/// See [CssLexContext::UnicodeRange]
6565
UnicodeRange,
66+
/// Re-lexes `+` and `-` as standalone tokens for SCSS expression parsing.
67+
ScssExpression,
6668
}
6769

6870
/// An extremely fast, lookup table based, lossless CSS lexer
@@ -1502,6 +1504,7 @@ impl<'src> ReLexer<'src> for CssLexer<'src> {
15021504
Some(current) => match context {
15031505
CssReLexContext::Regular => self.consume_token(current),
15041506
CssReLexContext::UnicodeRange => self.consume_unicode_range_token(current),
1507+
CssReLexContext::ScssExpression => self.consume_scss_expression_token(current),
15051508
},
15061509
None => EOF,
15071510
};
@@ -1517,6 +1520,16 @@ impl<'src> ReLexer<'src> for CssLexer<'src> {
15171520
}
15181521
}
15191522

1523+
impl CssLexer<'_> {
1524+
fn consume_scss_expression_token(&mut self, current: u8) -> CssSyntaxKind {
1525+
match current {
1526+
b'+' => self.consume_byte(T![+]),
1527+
b'-' => self.consume_byte(T![-]),
1528+
_ => self.consume_token(current),
1529+
}
1530+
}
1531+
}
1532+
15201533
impl<'src> LexerWithCheckpoint<'src> for CssLexer<'src> {
15211534
fn checkpoint(&self) -> LexerCheckpoint<Self::Kind> {
15221535
LexerCheckpoint {

crates/biome_css_parser/src/syntax/property/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -380,7 +380,7 @@ pub(crate) fn parse_generic_component_value(p: &mut CssParser) -> ParsedSyntax {
380380

381381
const GENERIC_DELIMITER_SET: TokenSet<CssSyntaxKind> = token_set![T![,], T![/]];
382382
#[inline]
383-
fn is_at_generic_delimiter(p: &mut CssParser) -> bool {
383+
pub(crate) fn is_at_generic_delimiter(p: &mut CssParser) -> bool {
384384
p.at_ts(GENERIC_DELIMITER_SET)
385385
}
386386

crates/biome_css_parser/src/syntax/scss/expression/mod.rs

Lines changed: 46 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
1+
use crate::lexer::CssReLexContext;
12
use crate::parser::CssParser;
23
use crate::syntax::parse_error::{
34
expected_component_value, expected_scss_expression, scss_ellipsis_not_allowed,
45
};
5-
use crate::syntax::property::parse_generic_component_value;
6+
use crate::syntax::property::{is_at_generic_delimiter, parse_generic_component_value};
67
use crate::syntax::scss::{is_at_scss_identifier, parse_scss_identifier};
8+
use crate::syntax::value::dimension::is_at_any_dimension;
79
use biome_css_syntax::CssSyntaxKind::{
8-
CSS_BOGUS_PROPERTY_VALUE, EOF, SCSS_ARBITRARY_ARGUMENT, SCSS_BINARY_EXPRESSION,
9-
SCSS_EXPRESSION, SCSS_EXPRESSION_ITEM_LIST, SCSS_KEYWORD_ARGUMENT, SCSS_LIST_EXPRESSION,
10-
SCSS_LIST_EXPRESSION_ELEMENT, SCSS_LIST_EXPRESSION_ELEMENT_LIST, SCSS_MAP_EXPRESSION,
11-
SCSS_MAP_EXPRESSION_PAIR, SCSS_MAP_EXPRESSION_PAIR_LIST, SCSS_PARENTHESIZED_EXPRESSION,
12-
SCSS_UNARY_EXPRESSION,
10+
CSS_BOGUS_PROPERTY_VALUE, CSS_NUMBER_LITERAL, EOF, SCSS_ARBITRARY_ARGUMENT,
11+
SCSS_BINARY_EXPRESSION, SCSS_EXPRESSION, SCSS_EXPRESSION_ITEM_LIST, SCSS_KEYWORD_ARGUMENT,
12+
SCSS_LIST_EXPRESSION, SCSS_LIST_EXPRESSION_ELEMENT, SCSS_LIST_EXPRESSION_ELEMENT_LIST,
13+
SCSS_MAP_EXPRESSION, SCSS_MAP_EXPRESSION_PAIR, SCSS_MAP_EXPRESSION_PAIR_LIST,
14+
SCSS_PARENTHESIZED_EXPRESSION, SCSS_UNARY_EXPRESSION,
1315
};
1416
use biome_css_syntax::{CssSyntaxKind, T};
1517
use biome_parser::parse_recovery::ParseRecoveryTokenSet;
@@ -40,7 +42,6 @@ const SCSS_MAP_EXPRESSION_KEY_END_TOKEN_SET: TokenSet<CssSyntaxKind> =
4042
const SCSS_MAP_EXPRESSION_VALUE_END_TOKEN_SET: TokenSet<CssSyntaxKind> = token_set![T![,], T![')']];
4143
const SCSS_LIST_EXPRESSION_ELEMENT_END_TOKEN_SET: TokenSet<CssSyntaxKind> =
4244
token_set![T![,], T![')']];
43-
4445
pub(crate) const END_OF_SCSS_EXPRESSION_TOKEN_SET: TokenSet<CssSyntaxKind> =
4546
token_set![T![,], T![')'], T![;], T!['}']];
4647

@@ -158,27 +159,28 @@ fn parse_scss_expression_item(p: &mut CssParser, options: ScssExpressionOptions)
158159
return parse_scss_keyword_argument(p, options);
159160
}
160161

161-
let expression = parse_scss_binary_expression(p, 0);
162+
if is_at_generic_delimiter(p) {
163+
return parse_generic_component_value(p);
164+
}
165+
166+
let expression = parse_scss_binary_expression(p, 0).or_else(|| {
167+
if p.at(T![...]) {
168+
report_and_bump_scss_ellipsis(p);
169+
}
170+
171+
Absent
172+
});
162173
let expression = match expression {
163174
Present(expression) => expression,
164-
Absent => {
165-
if p.at(T![...]) {
166-
let range = p.cur_range();
167-
p.error(scss_ellipsis_not_allowed(p, range));
168-
p.bump(T![...]);
169-
}
170-
return Absent;
171-
}
175+
Absent => return Absent,
172176
};
173177

174178
if !p.at(T![...]) {
175179
return Present(expression);
176180
}
177181

178182
if !options.allows_ellipsis {
179-
let range = p.cur_range();
180-
p.error(scss_ellipsis_not_allowed(p, range));
181-
p.bump(T![...]);
183+
report_and_bump_scss_ellipsis(p);
182184
return Present(expression);
183185
}
184186

@@ -187,6 +189,13 @@ fn parse_scss_expression_item(p: &mut CssParser, options: ScssExpressionOptions)
187189
Present(m.complete(p, SCSS_ARBITRARY_ARGUMENT))
188190
}
189191

192+
#[inline]
193+
fn report_and_bump_scss_ellipsis(p: &mut CssParser) {
194+
let range = p.cur_range();
195+
p.error(scss_ellipsis_not_allowed(p, range));
196+
p.bump(T![...]);
197+
}
198+
190199
#[inline]
191200
fn is_at_scss_keyword_argument(p: &mut CssParser, options: ScssExpressionOptions) -> bool {
192201
options.allows_keyword_arguments
@@ -266,11 +275,29 @@ fn parse_scss_primary_expression(p: &mut CssParser) -> ParsedSyntax {
266275
}
267276
}
268277

278+
/// Re-lexes signed numeric tokens in SCSS expression context.
279+
///
280+
/// If the current token is `CSS_NUMBER_LITERAL` or any dimension starting with `+` or `-`,
281+
/// this mutates parser state via `CssParser::re_lex(CssReLexContext::ScssExpression)`.
282+
#[inline]
283+
fn re_lex_signed_numeric_as_scss_operator(p: &mut CssParser) {
284+
if !(p.at(CSS_NUMBER_LITERAL) || is_at_any_dimension(p)) {
285+
return;
286+
}
287+
288+
let text = p.cur_text();
289+
if matches!(text.as_bytes().first(), Some(b'+' | b'-')) {
290+
p.re_lex(CssReLexContext::ScssExpression);
291+
}
292+
}
293+
269294
/// Returns the precedence level for the current SCSS binary operator token.
270295
///
271296
/// Docs: https://sass-lang.com/documentation/operators/#order-of-operations
272297
#[inline]
273298
fn scss_binary_precedence(p: &mut CssParser) -> Option<u8> {
299+
re_lex_signed_numeric_as_scss_operator(p);
300+
274301
if !p.at_ts(SCSS_BINARY_OPERATOR_TOKEN_SET) {
275302
return None;
276303
}

0 commit comments

Comments
 (0)