Skip to content

Commit c13d868

Browse files
jo16ohclaude
andcommitted
feat(parser): collect definitions inside @media and other conditional group rules (#38)
Treat @media, @supports, @container, @layer, @scope, and @starting-style as transparent wrappers: consume only the prelude and let the main loop descend into the body so inner property declarations are collected. Other at-rules (@Property, @import, @font-face, …) keep being skipped whole. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
1 parent d662fdf commit c13d868

1 file changed

Lines changed: 122 additions & 23 deletions

File tree

  • crates/css-var-kit/src/parser

crates/css-var-kit/src/parser/css.rs

Lines changed: 122 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -122,9 +122,16 @@ impl<'a> Scanner<'a> {
122122
css.map(|s| s[content_start..self.pos].trim())
123123
}
124124

125-
fn skip_at_rule(&mut self) {
125+
fn read_at_rule_name(&mut self) -> &'a str {
126126
self.advance(1); // skip '@'
127-
// Skip until ';' (statement) or matched '{...}' (block)
127+
let start = self.pos;
128+
while !self.is_eof() && is_ident_char(self.bytes[self.pos]) {
129+
self.advance(1);
130+
}
131+
std::str::from_utf8(&self.bytes[start..self.pos]).unwrap_or("")
132+
}
133+
134+
fn skip_at_rule_prelude(&mut self) {
128135
while !self.is_eof() {
129136
match self.bytes[self.pos] {
130137
b'"' | b'\'' => self.skip_string_literal(),
@@ -133,26 +140,29 @@ impl<'a> Scanner<'a> {
133140
self.advance(1);
134141
return;
135142
}
143+
b'{' => return,
144+
_ => self.advance(1),
145+
}
146+
}
147+
}
148+
149+
fn skip_at_rule_body(&mut self) {
150+
if self.is_eof() || self.bytes[self.pos] != b'{' {
151+
return;
152+
}
153+
self.advance(1);
154+
let mut depth = 1i32;
155+
while !self.is_eof() && depth > 0 {
156+
match self.bytes[self.pos] {
157+
b'"' | b'\'' => self.skip_string_literal(),
158+
b'/' if self.peek_at(1) == Some(b'*') => self.skip_comment(),
136159
b'{' => {
137-
// Skip the block including nested braces
160+
depth += 1;
161+
self.advance(1);
162+
}
163+
b'}' => {
164+
depth -= 1;
138165
self.advance(1);
139-
let mut depth = 1i32;
140-
while !self.is_eof() && depth > 0 {
141-
match self.bytes[self.pos] {
142-
b'"' | b'\'' => self.skip_string_literal(),
143-
b'/' if self.peek_at(1) == Some(b'*') => self.skip_comment(),
144-
b'{' => {
145-
depth += 1;
146-
self.advance(1);
147-
}
148-
b'}' => {
149-
depth -= 1;
150-
self.advance(1);
151-
}
152-
_ => self.advance(1),
153-
}
154-
}
155-
return;
156166
}
157167
_ => self.advance(1),
158168
}
@@ -303,10 +313,13 @@ fn parse_impl(
303313
pending_ignores.push(content);
304314
}
305315
}
306-
// Skip @-rules at top level (e.g. @property, @import, @charset)
307-
b'@' if brace_depth == initial_brace_depth => {
316+
b'@' => {
308317
pending_ignores.clear();
309-
s.skip_at_rule();
318+
let name = s.read_at_rule_name();
319+
s.skip_at_rule_prelude();
320+
if !is_transparent_at_rule(name) {
321+
s.skip_at_rule_body();
322+
}
310323
}
311324
b'{' => {
312325
pending_ignores.clear();
@@ -394,6 +407,18 @@ fn parse_impl(
394407
}
395408
}
396409

410+
fn is_transparent_at_rule(name: &str) -> bool {
411+
const TRANSPARENT: &[&str] = &[
412+
"media",
413+
"supports",
414+
"container",
415+
"layer",
416+
"scope",
417+
"starting-style",
418+
];
419+
TRANSPARENT.iter().any(|t| name.eq_ignore_ascii_case(t))
420+
}
421+
397422
fn is_ident_start(b: u8) -> bool {
398423
b.is_ascii_alphabetic() || b == b'-' || b == b'_' || b == b'\\' || b >= 0x80
399424
}
@@ -829,6 +854,80 @@ mod tests {
829854
assert_eq!(result.properties[0].ident.raw.as_str(), "color");
830855
}
831856

857+
#[test]
858+
fn at_media_top_level_collects_inner_defs() {
859+
let css = "@media (prefers-color-scheme: dark) {\n :root { --color: white; }\n}";
860+
let result = test_parse(css);
861+
assert_eq!(result.properties.len(), 1);
862+
assert_eq!(result.properties[0].ident.raw.as_str(), "--color");
863+
assert_eq!(result.properties[0].value.raw.as_str(), "white");
864+
}
865+
866+
#[test]
867+
fn at_media_with_bare_declarations() {
868+
let css = "@media (max-width: 600px) {\n --pad: 8px;\n}";
869+
let result = test_parse(css);
870+
assert_eq!(result.properties.len(), 1);
871+
assert_eq!(result.properties[0].ident.raw.as_str(), "--pad");
872+
assert_eq!(result.properties[0].value.raw.as_str(), "8px");
873+
}
874+
875+
#[test]
876+
fn at_media_nested_in_supports() {
877+
let css = "@supports (color: oklch(0 0 0)) {\n @media (prefers-color-scheme: dark) {\n :root { --x: 1; }\n }\n}";
878+
let result = test_parse(css);
879+
assert_eq!(result.properties.len(), 1);
880+
assert_eq!(result.properties[0].ident.raw.as_str(), "--x");
881+
assert_eq!(result.properties[0].value.raw.as_str(), "1");
882+
}
883+
884+
#[test]
885+
fn at_layer_block_form() {
886+
let css = "@layer theme {\n :root { --x: 1; }\n}";
887+
let result = test_parse(css);
888+
assert_eq!(result.properties.len(), 1);
889+
assert_eq!(result.properties[0].ident.raw.as_str(), "--x");
890+
}
891+
892+
#[test]
893+
fn at_layer_statement_form() {
894+
let css = "@layer foo, bar;\n:root { --y: 2; }";
895+
let result = test_parse(css);
896+
assert_eq!(result.properties.len(), 1);
897+
assert_eq!(result.properties[0].ident.raw.as_str(), "--y");
898+
assert_eq!(result.properties[0].value.raw.as_str(), "2");
899+
}
900+
901+
#[test]
902+
fn at_media_inside_selector() {
903+
let css = ":root {\n --base: 0;\n @media (prefers-color-scheme: dark) {\n --base: 1;\n }\n}";
904+
let result = test_parse(css);
905+
let names: Vec<&str> = result
906+
.properties
907+
.iter()
908+
.map(|p| p.ident.raw.as_str())
909+
.collect();
910+
assert_eq!(names, vec!["--base", "--base"]);
911+
assert_eq!(result.properties[0].value.raw.as_str(), "0");
912+
assert_eq!(result.properties[1].value.raw.as_str(), "1");
913+
}
914+
915+
#[test]
916+
fn at_media_uppercase() {
917+
let css = "@MEDIA (prefers-color-scheme: dark) {\n :root { --x: 1; }\n}";
918+
let result = test_parse(css);
919+
assert_eq!(result.properties.len(), 1);
920+
assert_eq!(result.properties[0].ident.raw.as_str(), "--x");
921+
}
922+
923+
#[test]
924+
fn at_media_with_string_in_prelude() {
925+
let css = "@media (min-width: 100px) and (foo: \"with } brace\") {\n :root { --x: 1; }\n}";
926+
let result = test_parse(css);
927+
assert_eq!(result.properties.len(), 1);
928+
assert_eq!(result.properties[0].ident.raw.as_str(), "--x");
929+
}
930+
832931
#[test]
833932
fn unterminated_comment_consumes_all_bytes() {
834933
let str = OwnedStr::from("/* {");

0 commit comments

Comments
 (0)