From 0ea8f7079a23046eaf5583890ae5291cc71e8a99 Mon Sep 17 00:00:00 2001 From: Joichiro Hayashi Date: Sun, 3 May 2026 20:23:17 +0900 Subject: [PATCH 1/6] =?UTF-8?q?fix(parser):=20track=20SCSS=20#{=E2=80=A6}?= =?UTF-8?q?=20interpolation=20depth=20in=20scan=5Fvalue=5Fend?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `scan_value_end` previously treated any `}` at paren depth 0 as a value terminator, so `--color: #{$brand};` was cut to `#{$brand` and the following `}` of the interpolation was mistaken for the rule's closing brace, throwing brace tracking off. Track `#{ … }` as a balanced group alongside parens, and gate the `;`, comment, and missing-semicolon-recovery branches on `interp_depth <= 0` so SCSS interpolations parse cleanly inside any value. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- crates/css-var-kit/src/parser/css.rs | 67 ++++++++++++++++++++++++++-- 1 file changed, 64 insertions(+), 3 deletions(-) diff --git a/crates/css-var-kit/src/parser/css.rs b/crates/css-var-kit/src/parser/css.rs index b44d992..982d9d2 100644 --- a/crates/css-var-kit/src/parser/css.rs +++ b/crates/css-var-kit/src/parser/css.rs @@ -266,6 +266,7 @@ impl<'a> Scanner<'a> { fn scan_value_end(&mut self) -> usize { let mut paren_depth = 0i32; + let mut interp_depth = 0i32; while !self.is_eof() { match self.bytes[self.pos] { @@ -281,7 +282,17 @@ impl<'a> Scanner<'a> { paren_depth -= 1; self.advance(1); } - b';' if paren_depth <= 0 => { + b'#' if self.peek_at(1) == Some(b'{') => { + // SCSS interpolation — treat `#{ … }` as a balanced group + // so the inner `}` doesn't terminate the value. + interp_depth += 1; + self.advance(2); + } + b'}' if interp_depth > 0 => { + interp_depth -= 1; + self.advance(1); + } + b';' if paren_depth <= 0 && interp_depth <= 0 => { let end = self.pos; self.advance(1); // skip ';' return end; @@ -290,7 +301,7 @@ impl<'a> Scanner<'a> { // Don't consume '}', let the main loop handle brace_depth return self.pos; } - b'/' if self.peek_at(1) == Some(b'*') && paren_depth <= 0 => { + b'/' if self.peek_at(1) == Some(b'*') && paren_depth <= 0 && interp_depth <= 0 => { // Comment starts — value ends here let end = self.pos; // Skip the comment @@ -314,7 +325,7 @@ impl<'a> Scanner<'a> { } return end; } - b'\n' | b'\r' if paren_depth <= 0 => { + b'\n' | b'\r' if paren_depth <= 0 && interp_depth <= 0 => { // Check if the next non-whitespace looks like a new property let mut skip = 1; // For \r\n, skip both bytes @@ -768,6 +779,56 @@ mod tests { ); } + #[test] + fn scss_interpolation_in_value() { + let css = ":root {\n --color: #{$brand};\n --size: 16px;\n}"; + let result = test_parse(css); + assert_eq!(result.properties.len(), 2); + assert_eq!(result.properties[0].ident.raw.as_str(), "--color"); + assert_eq!(result.properties[0].value.raw.as_str(), "#{$brand}"); + assert_eq!(result.properties[1].ident.raw.as_str(), "--size"); + assert_eq!(result.properties[1].value.raw.as_str(), "16px"); + } + + #[test] + fn scss_interpolation_with_semicolon_inside() { + // Semicolons inside `#{ … }` must not terminate the value. + let css = ":root { --x: #{ map-get($m, 'a'); $y } px; --z: 1; }"; + let result = test_parse(css); + assert_eq!(result.properties.len(), 2); + assert_eq!(result.properties[0].ident.raw.as_str(), "--x"); + assert_eq!( + result.properties[0].value.raw.as_str(), + "#{ map-get($m, 'a'); $y } px" + ); + assert_eq!(result.properties[1].ident.raw.as_str(), "--z"); + } + + #[test] + fn scss_interpolation_no_terminator_break() { + // The closing `}` of an interpolation must not terminate the value + // and must not pop the surrounding rule's brace depth. + let css = ".a {\n --x: #{$a}px;\n --y: 2;\n}"; + let result = test_parse(css); + assert_eq!(result.properties.len(), 2); + assert_eq!(result.properties[0].value.raw.as_str(), "#{$a}px"); + assert_eq!(result.properties[1].value.raw.as_str(), "2"); + } + + #[test] + fn scss_interpolation_with_newline_inside() { + // A newline inside `#{ … }` must not trigger the missing-semicolon + // recovery path. + let css = ":root {\n --x: #{\n $a\n };\n --y: 2;\n}"; + let result = test_parse(css); + assert_eq!(result.properties.len(), 2); + assert_eq!( + result.properties[0].value.raw.as_str(), + "#{\n $a\n }" + ); + assert_eq!(result.properties[1].value.raw.as_str(), "2"); + } + #[test] fn value_position_tracking() { let css = ":root {\n --color: red;\n}"; From 64648a49c402dfa3084cf78cc61a46d9426b43fb Mon Sep 17 00:00:00 2001 From: Joichiro Hayashi Date: Mon, 4 May 2026 13:53:04 +0900 Subject: [PATCH 2/6] test(parser): cover SCSS at-rule descent Verify that the universal at-rule descent (@mixin, @if/@else, @each, @function) correctly collects definitions and var() references from SCSS-only at-rule bodies, and that statement-form @return inside a @function body doesn't pollute the property list. @each uses #{$name} interpolation and depends on the preceding scan_value_end interpolation-depth tracking. Co-Authored-By: Claude Opus 4.7 --- crates/css-var-kit/src/parser/css.rs | 42 ++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/crates/css-var-kit/src/parser/css.rs b/crates/css-var-kit/src/parser/css.rs index 982d9d2..8ba8f64 100644 --- a/crates/css-var-kit/src/parser/css.rs +++ b/crates/css-var-kit/src/parser/css.rs @@ -1157,6 +1157,48 @@ mod tests { assert_eq!(result.properties[1].value.raw.as_str(), "var(--margin)"); } + #[test] + fn scss_at_mixin_body_collects_definitions() { + let css = "@mixin theme($name) {\n :root { --color: $name; }\n}"; + let result = test_parse(css); + assert_eq!(result.properties.len(), 1); + assert_eq!(result.properties[0].ident.raw.as_str(), "--color"); + assert_eq!(result.properties[0].value.raw.as_str(), "$name"); + } + + #[test] + fn scss_at_if_else_collects_both_branches() { + let css = "@if $dark {\n :root { --bg: black; }\n}\n@else {\n :root { --bg: white; }\n}"; + let result = test_parse(css); + assert_eq!(result.properties.len(), 2); + assert_eq!(result.properties[0].ident.raw.as_str(), "--bg"); + assert_eq!(result.properties[0].value.raw.as_str(), "black"); + assert_eq!(result.properties[1].ident.raw.as_str(), "--bg"); + assert_eq!(result.properties[1].value.raw.as_str(), "white"); + } + + #[test] + fn scss_at_each_collects_definitions() { + let css = "@each $name in $colors {\n .text-#{$name} { color: var(--color-#{$name}); }\n}"; + let result = test_parse(css); + assert_eq!(result.properties.len(), 1); + assert_eq!(result.properties[0].ident.raw.as_str(), "color"); + assert_eq!( + result.properties[0].value.raw.as_str(), + "var(--color-#{$name})" + ); + } + + #[test] + fn scss_at_function_with_return_does_not_pollute() { + // SCSS @function bodies are full of `@return …;` statement-form at-rules. + // Descending must not invent any properties from them. + let css = "@function double($n) {\n @return $n * 2;\n}\n:root { --x: 1; }"; + let result = test_parse(css); + assert_eq!(result.properties.len(), 1); + assert_eq!(result.properties[0].ident.raw.as_str(), "--x"); + } + #[test] fn at_media_top_level_collects_inner_defs() { let css = "@media (prefers-color-scheme: dark) {\n :root { --color: white; }\n}"; From 01c99b153c781d4d435da0ef00c137dd15d1f3d2 Mon Sep 17 00:00:00 2001 From: Joichiro Hayashi Date: Tue, 5 May 2026 09:24:36 +0900 Subject: [PATCH 3/6] fix(parser): skip block comments inside SCSS interpolation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `scan_value_end` gated the `/* … */` skip on `interp_depth <= 0`, so a block comment inside `#{ … }` was advanced byte-by-byte. A `}` inside the comment then matched the interpolation-close arm, popping interp_depth one step early — the real interpolation closer was treated as the rule's `}` and trailing properties were silently dropped. Add a dedicated `b'/' if peek == b'*' && interp_depth > 0` arm that consumes the comment wholesale, leaving interp tracking intact. Co-Authored-By: Claude Opus 4.7 --- crates/css-var-kit/src/parser/css.rs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/crates/css-var-kit/src/parser/css.rs b/crates/css-var-kit/src/parser/css.rs index 8ba8f64..e146c97 100644 --- a/crates/css-var-kit/src/parser/css.rs +++ b/crates/css-var-kit/src/parser/css.rs @@ -325,6 +325,12 @@ impl<'a> Scanner<'a> { } return end; } + b'/' if self.peek_at(1) == Some(b'*') && interp_depth > 0 => { + // Inside `#{ … }` a block comment must be skipped wholesale, + // otherwise a `}` inside it would be mistaken for the + // interpolation closer and pop interp_depth prematurely. + self.skip_comment(); + } b'\n' | b'\r' if paren_depth <= 0 && interp_depth <= 0 => { // Check if the next non-whitespace looks like a new property let mut skip = 1; @@ -829,6 +835,20 @@ mod tests { assert_eq!(result.properties[1].value.raw.as_str(), "2"); } + #[test] + fn scss_interpolation_with_block_comment_brace() { + // A `}` inside a block comment within `#{ … }` must be invisible to + // interp-depth tracking; otherwise the surrounding rule's brace + // tracking gets thrown off and trailing properties are dropped. + let css = ":root { --x: #{ /* } */ $a }; --y: 1; }"; + let result = test_parse(css); + assert_eq!(result.properties.len(), 2); + assert_eq!(result.properties[0].ident.raw.as_str(), "--x"); + assert_eq!(result.properties[0].value.raw.as_str(), "#{ /* } */ $a }"); + assert_eq!(result.properties[1].ident.raw.as_str(), "--y"); + assert_eq!(result.properties[1].value.raw.as_str(), "1"); + } + #[test] fn value_position_tracking() { let css = ":root {\n --color: red;\n}"; From c0fccf28bb5e82208708034e26171ef1565d06f7 Mon Sep 17 00:00:00 2001 From: Joichiro Hayashi Date: Tue, 5 May 2026 09:30:42 +0900 Subject: [PATCH 4/6] feat(parser): recognize SCSS // line comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The parser previously advanced through `//` byte-by-byte, so a `;` or `}` inside a SCSS line comment terminated the surrounding value or popped brace_depth, silently dropping later definitions. Add `Scanner::skip_line_comment` and call it in the four scan contexts: the main loop, at-rule prelude, `@property` body walker, and `scan_value_end`. In `scan_value_end` outside parens/interp the comment terminates the value (mirroring `/* … */`); inside interpolation it is skipped so a `}` in the comment doesn't pop interp_depth; inside parens the bytes are kept verbatim so protocol-relative URLs like `url(//cdn.example.com/x.png)` parse unchanged. The at-rule prelude tracks paren_depth for the same reason — `@import url(//cdn.example.com/x.css);` must still work. cvk-ignore is intentionally not honored on `//` comments; pairing it with line comments is a separate enhancement. Co-Authored-By: Claude Opus 4.7 --- crates/css-var-kit/src/parser/css.rs | 137 +++++++++++++++++++++++++++ 1 file changed, 137 insertions(+) diff --git a/crates/css-var-kit/src/parser/css.rs b/crates/css-var-kit/src/parser/css.rs index e146c97..03b581b 100644 --- a/crates/css-var-kit/src/parser/css.rs +++ b/crates/css-var-kit/src/parser/css.rs @@ -118,6 +118,13 @@ impl<'a> Scanner<'a> { } } + fn skip_line_comment(&mut self) { + self.advance(2); // skip // + while !self.is_eof() && !matches!(self.bytes[self.pos], b'\n' | b'\r') { + self.advance(1); + } + } + fn scan_comment(&mut self, css: &'a OwnedStr) -> OwnedStr { self.advance(2); // skip /* let content_start = self.pos; @@ -143,10 +150,24 @@ impl<'a> Scanner<'a> { } fn skip_at_rule_prelude(&mut self) { + let mut paren_depth = 0i32; while !self.is_eof() { match self.bytes[self.pos] { b'"' | b'\'' => self.skip_string_literal(), b'/' if self.peek_at(1) == Some(b'*') => self.skip_comment(), + // `//` inside parens may be a protocol-relative `url(//cdn/…)`, + // so only treat it as a SCSS line comment outside parens. + b'/' if self.peek_at(1) == Some(b'/') && paren_depth <= 0 => { + self.skip_line_comment(); + } + b'(' => { + paren_depth += 1; + self.advance(1); + } + b')' => { + paren_depth -= 1; + self.advance(1); + } b';' => { self.advance(1); return; @@ -210,6 +231,7 @@ impl<'a> Scanner<'a> { match self.bytes[self.pos] { b'"' | b'\'' => self.skip_string_literal(), b'/' if self.peek_at(1) == Some(b'*') => self.skip_comment(), + b'/' if self.peek_at(1) == Some(b'/') => self.skip_line_comment(), b'{' => { depth += 1; self.advance(1); @@ -331,6 +353,18 @@ impl<'a> Scanner<'a> { // interpolation closer and pop interp_depth prematurely. self.skip_comment(); } + b'/' if self.peek_at(1) == Some(b'/') && paren_depth <= 0 && interp_depth <= 0 => { + // SCSS line comment outside parens/interp — terminates value. + let end = self.pos; + self.skip_line_comment(); + return end; + } + b'/' if self.peek_at(1) == Some(b'/') && interp_depth > 0 => { + // Inside `#{ … }` a `}` in the comment would otherwise pop + // interp_depth; skip to EOL instead. (Inside parens we keep + // bytes as-is so `url(//cdn/…)` survives.) + self.skip_line_comment(); + } b'\n' | b'\r' if paren_depth <= 0 && interp_depth <= 0 => { // Check if the next non-whitespace looks like a new property let mut skip = 1; @@ -425,6 +459,12 @@ fn parse_impl( pending_ignores.push(content); } } + // SCSS line comments — skip to EOL so a `;` or `}` inside doesn't + // break parsing. cvk-ignore is intentionally not honored here: + // pairing it with `//` is a separate enhancement. + b'/' if s.peek_at(1) == Some(b'/') => { + s.skip_line_comment(); + } b'@' => { let ignore_comments = std::mem::take(&mut pending_ignores); let name = s.read_at_rule_name(); @@ -835,6 +875,103 @@ mod tests { assert_eq!(result.properties[1].value.raw.as_str(), "2"); } + #[test] + fn scss_line_comment_in_value() { + let css = ":root { --x: 16px // comment\n --y: 1; }"; + let result = test_parse(css); + assert_eq!(result.properties.len(), 2); + assert_eq!(result.properties[0].value.raw.as_str(), "16px"); + assert_eq!(result.properties[1].value.raw.as_str(), "1"); + } + + #[test] + fn scss_line_comment_with_semicolon_inside() { + // A `;` inside `// …` must not terminate the value early. + let css = ":root { --x: 16px // ; trap\n --y: 1; }"; + let result = test_parse(css); + assert_eq!(result.properties.len(), 2); + assert_eq!(result.properties[0].value.raw.as_str(), "16px"); + assert_eq!(result.properties[1].value.raw.as_str(), "1"); + } + + #[test] + fn scss_line_comment_with_closebrace_inside() { + // A `}` inside `// …` must not pop the rule's brace_depth. + let css = ":root { --x: 16px // } trap\n --y: 1; }"; + let result = test_parse(css); + assert_eq!(result.properties.len(), 2); + assert_eq!(result.properties[0].value.raw.as_str(), "16px"); + assert_eq!(result.properties[1].value.raw.as_str(), "1"); + } + + #[test] + fn scss_line_comment_at_top_level() { + let css = "// hint\n:root { --x: 1; }"; + let result = test_parse(css); + assert_eq!(result.properties.len(), 1); + assert_eq!(result.properties[0].ident.raw.as_str(), "--x"); + } + + #[test] + fn scss_line_comment_with_brace_at_top_level() { + // A `}` inside a top-level `// …` must not affect brace_depth. + let css = "// } trap\n:root { --x: 1; }"; + let result = test_parse(css); + assert_eq!(result.properties.len(), 1); + assert_eq!(result.properties[0].ident.raw.as_str(), "--x"); + } + + #[test] + fn scss_line_comment_inside_interp() { + // `//` inside `#{ … }` is skipped without terminating the value, so a + // `}` in the comment doesn't pop interp_depth. + let css = ":root { --x: #{ // } trap\n $a }; --y: 1; }"; + let result = test_parse(css); + assert_eq!(result.properties.len(), 2); + assert_eq!(result.properties[1].ident.raw.as_str(), "--y"); + assert_eq!(result.properties[1].value.raw.as_str(), "1"); + } + + #[test] + fn scss_line_comment_in_at_rule_prelude() { + let css = "@media // legacy ; {\n (min-width: 1px) { :root { --x: 1; } }"; + let result = test_parse(css); + assert_eq!(result.properties.len(), 1); + assert_eq!(result.properties[0].ident.raw.as_str(), "--x"); + } + + #[test] + fn scss_line_comment_in_at_property_body() { + // `}` inside `// …` between declarations of an `@property` body must + // not close the body early. + let css = "@property --x { initial-value: red; // } trap\n }"; + let result = test_parse(css); + assert_eq!(result.properties.len(), 1); + assert_eq!(result.properties[0].ident.raw.as_str(), "--x"); + assert_eq!(result.properties[0].value.raw.as_str(), "red"); + } + + #[test] + fn scss_protocol_relative_url_in_value_not_line_comment() { + // `//` inside `url(…)` is a protocol-relative URL, not a comment. + let css = ":root { --bg: url(//cdn.example.com/x.png); --y: 1; }"; + let result = test_parse(css); + assert_eq!(result.properties.len(), 2); + assert_eq!( + result.properties[0].value.raw.as_str(), + "url(//cdn.example.com/x.png)" + ); + assert_eq!(result.properties[1].value.raw.as_str(), "1"); + } + + #[test] + fn scss_protocol_relative_url_in_at_rule_prelude_not_line_comment() { + let css = "@import url(//cdn.example.com/x.css);\n:root { --x: 1; }"; + let result = test_parse(css); + assert_eq!(result.properties.len(), 1); + assert_eq!(result.properties[0].ident.raw.as_str(), "--x"); + } + #[test] fn scss_interpolation_with_block_comment_brace() { // A `}` inside a block comment within `#{ … }` must be invisible to From 21a0684f28e9fed0598702a326d2d07e6a7d5a78 Mon Sep 17 00:00:00 2001 From: Joichiro Hayashi Date: Tue, 5 May 2026 09:44:22 +0900 Subject: [PATCH 5/6] feat(parser): gate SCSS syntax extensions on the .scss extension MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit always treated `//` as a line comment, which silently truncates valid CSS like `--link: https://example.com/path` because the custom-property value is allowed to hold raw URLs. Branch the behavior on file extension instead: the parser now tracks a `scss` flag derived from the file path and only honors `//` line comments when the source is `.scss`. `#{ … }` interpolation tracking and the universal at-rule body descent are unaffected — neither construct is meaningful in CSS, so applying them everywhere is harmless and keeps the implementation simple. Add `test_parse_scss` for SCSS-mode tests, switch the `scss_line_comment_*` and `scss_protocol_relative_url_*` tests onto it, and pin the CSS-mode behavior with two regressions: `--link: https://…` keeps its full value and a literal `//` in a value stays as bytes. Drop the long-unused `initial_brace_depth` parameter on `parse_impl` — both call sites passed `0` and dropping it keeps the signature within clippy's argument-count limit after adding `scss`. Co-Authored-By: Claude Opus 4.7 --- crates/css-var-kit/src/parser/css.rs | 105 ++++++++++++++++++++------- 1 file changed, 80 insertions(+), 25 deletions(-) diff --git a/crates/css-var-kit/src/parser/css.rs b/crates/css-var-kit/src/parser/css.rs index 03b581b..44f281d 100644 --- a/crates/css-var-kit/src/parser/css.rs +++ b/crates/css-var-kit/src/parser/css.rs @@ -9,6 +9,7 @@ struct Scanner<'a> { pos: usize, line: u32, col: u32, + scss: bool, } struct AtPropertyParts { @@ -23,12 +24,18 @@ struct AtPropertyParts { } impl<'a> Scanner<'a> { - fn new_with_offset(css: &'a OwnedStr, line_offset: u32, column_offset: u32) -> Self { + fn new_with_offset( + css: &'a OwnedStr, + line_offset: u32, + column_offset: u32, + scss: bool, + ) -> Self { Self { bytes: css.as_bytes(), pos: 0, line: line_offset, col: column_offset, + scss, } } @@ -155,9 +162,10 @@ impl<'a> Scanner<'a> { match self.bytes[self.pos] { b'"' | b'\'' => self.skip_string_literal(), b'/' if self.peek_at(1) == Some(b'*') => self.skip_comment(), - // `//` inside parens may be a protocol-relative `url(//cdn/…)`, - // so only treat it as a SCSS line comment outside parens. - b'/' if self.peek_at(1) == Some(b'/') && paren_depth <= 0 => { + // SCSS only: `//` outside parens is a line comment. Inside + // parens it may be a protocol-relative `url(//cdn/…)`, so we + // leave the bytes as-is. + b'/' if self.scss && self.peek_at(1) == Some(b'/') && paren_depth <= 0 => { self.skip_line_comment(); } b'(' => { @@ -231,7 +239,9 @@ impl<'a> Scanner<'a> { match self.bytes[self.pos] { b'"' | b'\'' => self.skip_string_literal(), b'/' if self.peek_at(1) == Some(b'*') => self.skip_comment(), - b'/' if self.peek_at(1) == Some(b'/') => self.skip_line_comment(), + b'/' if self.scss && self.peek_at(1) == Some(b'/') => { + self.skip_line_comment(); + } b'{' => { depth += 1; self.advance(1); @@ -353,13 +363,17 @@ impl<'a> Scanner<'a> { // interpolation closer and pop interp_depth prematurely. self.skip_comment(); } - b'/' if self.peek_at(1) == Some(b'/') && paren_depth <= 0 && interp_depth <= 0 => { + b'/' if self.scss + && self.peek_at(1) == Some(b'/') + && paren_depth <= 0 + && interp_depth <= 0 => + { // SCSS line comment outside parens/interp — terminates value. let end = self.pos; self.skip_line_comment(); return end; } - b'/' if self.peek_at(1) == Some(b'/') && interp_depth > 0 => { + b'/' if self.scss && self.peek_at(1) == Some(b'/') && interp_depth > 0 => { // Inside `#{ … }` a `}` in the comment would otherwise pop // interp_depth; skip to EOL instead. (Inside parens we keep // bytes as-is so `url(//cdn/…)` survives.) @@ -398,7 +412,7 @@ impl<'a> Scanner<'a> { } pub fn parse(css: &OwnedStr, file_path: &Rc) -> ParseResult { - parse_impl(css, css, file_path, 0, 0, 0, 0) + parse_impl(css, css, file_path, 0, 0, 0, is_scss_path(file_path)) } /// Used when parsing `