Skip to content

Commit 84da143

Browse files
jo16ohclaude
andcommitted
feat(parser): collect @Property rules as variable definitions (#38)
Phase 2 of #38: synthesise one Property per @Property rule, with ident pointing at the prelude --name and value pointing at the initial-value declaration (or empty if absent). Custom properties declared via @Property now show up in LSP completion / hover / go-to-definition / rename, and don't trip no-undefined-variable-use when referenced. Other at-rules continue to be skipped. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
1 parent 4f96693 commit 84da143

1 file changed

Lines changed: 246 additions & 11 deletions

File tree

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

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

Lines changed: 246 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,17 @@ struct Scanner<'a> {
1111
col: u32,
1212
}
1313

14+
struct AtPropertyParts {
15+
name_start: usize,
16+
name_end: usize,
17+
name_line: u32,
18+
name_col: u32,
19+
value_start: usize,
20+
value_end: usize,
21+
value_line: u32,
22+
value_col: u32,
23+
}
24+
1425
impl<'a> Scanner<'a> {
1526
fn new_with_offset(css: &'a OwnedStr, line_offset: u32, column_offset: u32) -> Self {
1627
Self {
@@ -169,6 +180,110 @@ impl<'a> Scanner<'a> {
169180
}
170181
}
171182

183+
fn skip_inline_and_newlines(&mut self) {
184+
while !self.is_eof() && matches!(self.bytes[self.pos], b' ' | b'\t' | b'\n' | b'\r') {
185+
self.advance(1);
186+
}
187+
}
188+
189+
/// Scans an `@property --name { … initial-value: V; … }` rule.
190+
/// The leading `@property` token has already been consumed.
191+
/// Returns prelude name + `initial-value` spans (the value span is empty
192+
/// when no `initial-value` declaration is present). The closing `}` is
193+
/// consumed. Returns `None` only when the prelude lacks an identifier or
194+
/// the body never opens.
195+
fn scan_at_property_rule(&mut self) -> Option<AtPropertyParts> {
196+
self.skip_inline_and_newlines();
197+
198+
let name_start = self.pos;
199+
let name_line = self.line;
200+
let name_col = self.col;
201+
while !self.is_eof() {
202+
if self.bytes[self.pos] == b'\\' {
203+
self.skip_escape();
204+
} else if is_ident_char(self.bytes[self.pos]) {
205+
self.advance(1);
206+
} else {
207+
break;
208+
}
209+
}
210+
let name_end = self.pos;
211+
if name_start == name_end {
212+
self.skip_at_rule_prelude();
213+
self.skip_at_rule_body();
214+
return None;
215+
}
216+
217+
self.skip_at_rule_prelude();
218+
if self.is_eof() || self.bytes[self.pos] != b'{' {
219+
return None;
220+
}
221+
self.advance(1); // consume '{'
222+
223+
let mut value_start = self.pos;
224+
let mut value_end = self.pos;
225+
let mut value_line = self.line;
226+
let mut value_col = self.col;
227+
228+
let mut depth = 1i32;
229+
while !self.is_eof() && depth > 0 {
230+
match self.bytes[self.pos] {
231+
b'"' | b'\'' => self.skip_string_literal(),
232+
b'/' if self.peek_at(1) == Some(b'*') => self.skip_comment(),
233+
b'{' => {
234+
depth += 1;
235+
self.advance(1);
236+
}
237+
b'}' => {
238+
depth -= 1;
239+
self.advance(1);
240+
}
241+
_ if depth == 1 && is_ident_start(self.bytes[self.pos]) => {
242+
let prop_name_start = self.pos;
243+
while !self.is_eof() {
244+
if self.bytes[self.pos] == b'\\' {
245+
self.skip_escape();
246+
} else if is_ident_char(self.bytes[self.pos]) {
247+
self.advance(1);
248+
} else {
249+
break;
250+
}
251+
}
252+
let prop_name_end = self.pos;
253+
self.skip_whitespace();
254+
if !self.is_eof() && self.bytes[self.pos] == b':' {
255+
self.advance(1);
256+
self.skip_whitespace();
257+
let line = self.line;
258+
let col = self.col;
259+
let start = self.pos;
260+
let end = self.scan_value_end();
261+
let prop_name = &self.bytes[prop_name_start..prop_name_end];
262+
if prop_name.eq_ignore_ascii_case(b"initial-value") {
263+
// Last `initial-value` wins, matching CSS cascade.
264+
value_start = start;
265+
value_end = end;
266+
value_line = line;
267+
value_col = col;
268+
}
269+
}
270+
}
271+
_ => self.advance(1),
272+
}
273+
}
274+
275+
Some(AtPropertyParts {
276+
name_start,
277+
name_end,
278+
name_line,
279+
name_col,
280+
value_start,
281+
value_end,
282+
value_line,
283+
value_col,
284+
})
285+
}
286+
172287
fn scan_value_end(&mut self) -> usize {
173288
let mut paren_depth = 0i32;
174289

@@ -314,11 +429,36 @@ fn parse_impl(
314429
}
315430
}
316431
b'@' => {
317-
pending_ignores.clear();
432+
let ignore_comments = std::mem::take(&mut pending_ignores);
318433
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();
434+
if name.eq_ignore_ascii_case("property") {
435+
if let Some(parts) = s.scan_at_property_rule() {
436+
let raw_name = css.slice(parts.name_start..parts.name_end);
437+
let raw_value = css.map(|s| s[parts.value_start..parts.value_end].trim());
438+
properties.push(Property {
439+
file_path: file_path.clone(),
440+
source: source.clone(),
441+
ident: PropertyIdent::new(
442+
raw_name,
443+
parts.name_start + byte_offset,
444+
parts.name_line,
445+
parts.name_col,
446+
),
447+
value: PropertyValue {
448+
raw: raw_value,
449+
offset: parts.value_start + byte_offset,
450+
line: parts.value_line,
451+
column: parts.value_col,
452+
},
453+
ignore_comments,
454+
token_list: OnceCell::new(),
455+
});
456+
}
457+
} else {
458+
s.skip_at_rule_prelude();
459+
if !is_transparent_at_rule(name) {
460+
s.skip_at_rule_body();
461+
}
322462
}
323463
}
324464
b'{' => {
@@ -829,21 +969,116 @@ mod tests {
829969
}
830970

831971
#[test]
832-
fn at_property_rule_skipped() {
972+
fn at_property_rule_collects_definition() {
833973
let css = "@property --my-color {\n syntax: \"<color>\";\n inherits: false;\n initial-value: red;\n}\n.a { color: var(--my-color); }";
834974
let result = test_parse(css);
835-
assert_eq!(result.properties.len(), 1);
836-
assert_eq!(result.properties[0].ident.raw.as_str(), "color");
837-
assert_eq!(result.properties[0].value.raw.as_str(), "var(--my-color)");
975+
assert_eq!(result.properties.len(), 2);
976+
assert_eq!(result.properties[0].ident.raw.as_str(), "--my-color");
977+
assert_eq!(result.properties[0].value.raw.as_str(), "red");
978+
assert_eq!(result.properties[1].ident.raw.as_str(), "color");
979+
assert_eq!(result.properties[1].value.raw.as_str(), "var(--my-color)");
838980
}
839981

840982
#[test]
841-
fn at_property_between_selectors() {
983+
fn at_property_between_selectors_collects_definition() {
842984
let css = ".before { margin: 0; }\n@property --x {\n syntax: \"*\";\n inherits: true;\n}\n.after { padding: 0; }";
843985
let result = test_parse(css);
844-
assert_eq!(result.properties.len(), 2);
986+
assert_eq!(result.properties.len(), 3);
845987
assert_eq!(result.properties[0].ident.raw.as_str(), "margin");
846-
assert_eq!(result.properties[1].ident.raw.as_str(), "padding");
988+
assert_eq!(result.properties[1].ident.raw.as_str(), "--x");
989+
assert_eq!(result.properties[1].value.raw.as_str(), "");
990+
assert_eq!(result.properties[2].ident.raw.as_str(), "padding");
991+
}
992+
993+
#[test]
994+
fn at_property_extracts_initial_value() {
995+
let css = "@property --logo-color {\n syntax: \"<color>\";\n inherits: false;\n initial-value: #c0ffee;\n}";
996+
let result = test_parse(css);
997+
assert_eq!(result.properties.len(), 1);
998+
let p = &result.properties[0];
999+
assert_eq!(p.ident.raw.as_str(), "--logo-color");
1000+
assert_eq!(p.value.raw.as_str(), "#c0ffee");
1001+
// name on line 0, after "@property " (10 cols)
1002+
assert_eq!(p.ident.line, 0);
1003+
assert_eq!(p.ident.column, 10);
1004+
// value on line 3, after " initial-value: " (17 cols)
1005+
assert_eq!(p.value.line, 3);
1006+
assert_eq!(p.value.column, 17);
1007+
}
1008+
1009+
#[test]
1010+
fn at_property_without_initial_value() {
1011+
let css = "@property --x {\n syntax: \"*\";\n inherits: true;\n}";
1012+
let result = test_parse(css);
1013+
assert_eq!(result.properties.len(), 1);
1014+
assert_eq!(result.properties[0].ident.raw.as_str(), "--x");
1015+
assert_eq!(result.properties[0].value.raw.as_str(), "");
1016+
}
1017+
1018+
#[test]
1019+
fn at_property_initial_value_with_var() {
1020+
let css = "@property --x {\n syntax: \"<color>\";\n initial-value: var(--base, red);\n}";
1021+
let result = test_parse(css);
1022+
assert_eq!(result.properties.len(), 1);
1023+
assert_eq!(result.properties[0].ident.raw.as_str(), "--x");
1024+
assert_eq!(result.properties[0].value.raw.as_str(), "var(--base, red)");
1025+
}
1026+
1027+
#[test]
1028+
fn at_property_with_string_in_syntax() {
1029+
let css =
1030+
"@property --x {\n syntax: \"<length> | <percentage>\";\n initial-value: 8px;\n}";
1031+
let result = test_parse(css);
1032+
assert_eq!(result.properties.len(), 1);
1033+
assert_eq!(result.properties[0].ident.raw.as_str(), "--x");
1034+
assert_eq!(result.properties[0].value.raw.as_str(), "8px");
1035+
}
1036+
1037+
#[test]
1038+
fn at_property_invalid_name() {
1039+
let css = "@property foo {\n initial-value: red;\n}\n.a { color: red; }";
1040+
let result = test_parse(css);
1041+
assert_eq!(result.properties.len(), 2);
1042+
assert_eq!(result.properties[0].ident.raw.as_str(), "foo");
1043+
assert_eq!(result.properties[0].value.raw.as_str(), "red");
1044+
assert_eq!(result.properties[1].ident.raw.as_str(), "color");
1045+
}
1046+
1047+
#[test]
1048+
fn at_property_last_initial_value_wins() {
1049+
let css = "@property --x {\n initial-value: red;\n initial-value: blue;\n}";
1050+
let result = test_parse(css);
1051+
assert_eq!(result.properties.len(), 1);
1052+
assert_eq!(result.properties[0].value.raw.as_str(), "blue");
1053+
}
1054+
1055+
#[test]
1056+
fn at_property_cvk_ignore_propagates() {
1057+
let css = "/* cvk-ignore */\n@property --x {\n initial-value: red;\n}";
1058+
let result = test_parse(css);
1059+
assert_eq!(result.properties.len(), 1);
1060+
assert_eq!(
1061+
result.properties[0].ignore_comments,
1062+
map_owned_str(vec!["cvk-ignore"])
1063+
);
1064+
}
1065+
1066+
#[test]
1067+
fn at_property_inside_media() {
1068+
let css = "@media (prefers-color-scheme: dark) {\n @property --x {\n initial-value: white;\n }\n}";
1069+
let result = test_parse(css);
1070+
assert_eq!(result.properties.len(), 1);
1071+
assert_eq!(result.properties[0].ident.raw.as_str(), "--x");
1072+
assert_eq!(result.properties[0].value.raw.as_str(), "white");
1073+
}
1074+
1075+
#[test]
1076+
fn at_property_uppercase() {
1077+
let css = "@PROPERTY --x {\n initial-value: red;\n}";
1078+
let result = test_parse(css);
1079+
assert_eq!(result.properties.len(), 1);
1080+
assert_eq!(result.properties[0].ident.raw.as_str(), "--x");
1081+
assert_eq!(result.properties[0].value.raw.as_str(), "red");
8471082
}
8481083

8491084
#[test]

0 commit comments

Comments
 (0)