Skip to content

Commit 4f96693

Browse files
jo16ohclaude
andcommitted
fix(searcher): skip strings, urls, and comments when detecting variable references
`VariableUsages::matches` previously did `value.contains("var(")` and a naive substring scan for `--ident`, both of which fired on content that sits inside string literals or `url(...)` tokens (e.g. `content: "var(--x)"`, `url("file--name.png")`). It also matched `--ident` glued to the tail of a preceding identifier (`font--name`). Replace it with a single state-aware scanner that walks the value once, skipping `"…"`/`'…'` strings, `/* … */` comments, and `url(…)` token contents, and only fires on `var(`/`--ident` at a real token boundary. Also accepts `Var(` / `URL(` case-insensitively to match CSS rules. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
1 parent c13d868 commit 4f96693

1 file changed

Lines changed: 136 additions & 9 deletions

File tree

crates/css-var-kit/src/searcher/conditions/variable_usages.rs

Lines changed: 136 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,92 @@ pub struct VariableUsages;
55

66
impl SearchCondition for VariableUsages {
77
fn matches(&self, prop: &Property) -> bool {
8-
prop.value.raw.contains("var(") || has_dashed_ident(prop.value.raw.as_ref())
8+
contains_variable_reference(prop.value.raw.as_ref())
99
}
1010
}
1111

12-
fn has_dashed_ident(value: &str) -> bool {
12+
fn contains_variable_reference(value: &str) -> bool {
1313
let bytes = value.as_bytes();
14-
(0..bytes.len().saturating_sub(2)).any(|i| {
15-
bytes[i] == b'-'
16-
&& bytes[i + 1] == b'-'
17-
&& (bytes[i + 2].is_ascii_alphanumeric()
18-
|| bytes[i + 2] == b'_'
19-
|| bytes[i + 2] == b'-')
20-
})
14+
let len = bytes.len();
15+
let mut i = 0;
16+
while i < len {
17+
match bytes[i] {
18+
b'"' | b'\'' => i = end_of_string(bytes, i),
19+
b'/' if i + 1 < len && bytes[i + 1] == b'*' => i = end_of_comment(bytes, i),
20+
b'u' | b'U' if at_token_boundary(bytes, i) && matches_func_open(bytes, i, b"url") => {
21+
i = end_of_url(bytes, i + 4);
22+
}
23+
b'v' | b'V' if at_token_boundary(bytes, i) && matches_func_open(bytes, i, b"var") => {
24+
return true;
25+
}
26+
b'-' if at_token_boundary(bytes, i)
27+
&& i + 2 < len
28+
&& bytes[i + 1] == b'-'
29+
&& is_ident_continue(bytes[i + 2]) =>
30+
{
31+
return true;
32+
}
33+
_ => i += 1,
34+
}
35+
}
36+
false
37+
}
38+
39+
fn at_token_boundary(bytes: &[u8], i: usize) -> bool {
40+
i == 0 || !is_ident_continue(bytes[i - 1])
41+
}
42+
43+
fn is_ident_continue(b: u8) -> bool {
44+
b.is_ascii_alphanumeric() || b == b'_' || b == b'-' || b >= 0x80
45+
}
46+
47+
fn matches_func_open(bytes: &[u8], i: usize, name: &[u8]) -> bool {
48+
let end = i + name.len();
49+
end < bytes.len() && bytes[i..end].eq_ignore_ascii_case(name) && bytes[end] == b'('
50+
}
51+
52+
fn end_of_string(bytes: &[u8], start: usize) -> usize {
53+
let quote = bytes[start];
54+
let mut i = start + 1;
55+
while i < bytes.len() {
56+
match bytes[i] {
57+
b'\\' if i + 1 < bytes.len() => i += 2,
58+
b if b == quote => return i + 1,
59+
_ => i += 1,
60+
}
61+
}
62+
bytes.len()
63+
}
64+
65+
fn end_of_comment(bytes: &[u8], start: usize) -> usize {
66+
let mut i = start + 2;
67+
while i + 1 < bytes.len() {
68+
if bytes[i] == b'*' && bytes[i + 1] == b'/' {
69+
return i + 2;
70+
}
71+
i += 1;
72+
}
73+
bytes.len()
74+
}
75+
76+
fn end_of_url(bytes: &[u8], start: usize) -> usize {
77+
let mut i = start;
78+
let mut paren = 1i32;
79+
while i < bytes.len() && paren > 0 {
80+
match bytes[i] {
81+
b'"' | b'\'' => i = end_of_string(bytes, i),
82+
b'(' => {
83+
paren += 1;
84+
i += 1;
85+
}
86+
b')' => {
87+
paren -= 1;
88+
i += 1;
89+
}
90+
_ => i += 1,
91+
}
92+
}
93+
i
2194
}
2295

2396
#[cfg(test)]
@@ -62,4 +135,58 @@ mod tests {
62135
fn rejects_bare_double_dash() {
63136
assert!(!matches_value("--"));
64137
}
138+
139+
#[test]
140+
fn rejects_dashed_ident_inside_double_quoted_string() {
141+
assert!(!matches_value("\"--not-a-var\""));
142+
}
143+
144+
#[test]
145+
fn rejects_dashed_ident_inside_single_quoted_string() {
146+
assert!(!matches_value("'--not-a-var'"));
147+
}
148+
149+
#[test]
150+
fn rejects_var_call_inside_string() {
151+
assert!(!matches_value("\"var(--x)\""));
152+
}
153+
154+
#[test]
155+
fn rejects_dashed_ident_inside_quoted_url() {
156+
assert!(!matches_value("url(\"file--name.png\")"));
157+
}
158+
159+
#[test]
160+
fn rejects_dashed_ident_inside_unquoted_url() {
161+
assert!(!matches_value("url(file--name.png)"));
162+
}
163+
164+
#[test]
165+
fn rejects_var_call_inside_unquoted_url() {
166+
assert!(!matches_value("url(var(--x))"));
167+
}
168+
169+
#[test]
170+
fn rejects_dashed_ident_glued_to_preceding_ident() {
171+
// `font--name` is a single identifier-like token, not a `--name` reference.
172+
assert!(!matches_value("font--name"));
173+
}
174+
175+
#[test]
176+
fn matches_var_call_uppercase() {
177+
assert!(matches_value("VAR(--x)"));
178+
assert!(matches_value("Var(--x)"));
179+
}
180+
181+
#[test]
182+
fn matches_real_var_alongside_string_with_dashes() {
183+
assert!(matches_value("\"--decoy\" var(--real)"));
184+
}
185+
186+
#[test]
187+
fn rejects_dashed_ident_inside_block_comment() {
188+
// Comments don't normally survive into the value (parser strips them),
189+
// but be defensive.
190+
assert!(!matches_value("/* --decoy */ red"));
191+
}
65192
}

0 commit comments

Comments
 (0)