1use std::ops::Range;
2
3#[derive(Debug, Clone, PartialEq, Eq)]
4pub enum NodeKind {
5 Style,
6 AtBlock { name: String, params: String },
7 AtStatement { name: String },
8}
9
10#[derive(Debug, Clone)]
11pub struct SourceNode {
12 pub kind: NodeKind,
13 pub start: usize,
14 pub end: usize,
15 pub prelude_range: Range<usize>,
16 pub body_range: Option<Range<usize>>,
17}
18
19impl SourceNode {
20 pub fn prelude<'a>(&self, source: &'a str) -> &'a str {
21 source[self.prelude_range.clone()].trim()
22 }
23
24 pub fn body<'a>(&self, source: &'a str) -> Option<&'a str> {
25 self.body_range.as_ref().map(|range| &source[range.clone()])
26 }
27}
28
29pub fn scan_nodes(source: &str, range: Range<usize>) -> Vec<SourceNode> {
30 let bytes = source.as_bytes();
31 let mut nodes = Vec::new();
32 let mut i = range.start;
33
34 while i < range.end {
35 i = skip_trivia(source, i, range.end);
36 if i >= range.end || bytes[i] == b'}' {
37 break;
38 }
39
40 let start = i;
41 let mut quote: Option<u8> = None;
42 let mut escaped = false;
43 let mut paren_depth = 0usize;
44 let mut bracket_depth = 0usize;
45 let mut consumed = false;
46
47 while i < range.end {
48 let b = bytes[i];
49
50 if let Some(q) = quote {
51 if escaped {
52 escaped = false;
53 } else if b == b'\\' {
54 escaped = true;
55 } else if b == q {
56 quote = None;
57 }
58 i += 1;
59 continue;
60 }
61
62 if b == b'/' && i + 1 < range.end && bytes[i + 1] == b'*' {
63 i = skip_comment(source, i, range.end);
64 continue;
65 }
66
67 match b {
68 b'\'' | b'"' => {
69 quote = Some(b);
70 i += 1;
71 }
72 b'(' => {
73 paren_depth += 1;
74 i += 1;
75 }
76 b')' => {
77 paren_depth = paren_depth.saturating_sub(1);
78 i += 1;
79 }
80 b'[' => {
81 bracket_depth += 1;
82 i += 1;
83 }
84 b']' => {
85 bracket_depth = bracket_depth.saturating_sub(1);
86 i += 1;
87 }
88 b'{' if paren_depth == 0 && bracket_depth == 0 => {
89 let open = i;
90 if let Some(close) = find_matching_brace(source, open, range.end) {
91 let prelude = &source[start..open];
92 let (kind, prelude_start) = classify_block(prelude, start);
93 nodes.push(SourceNode {
94 kind,
95 start,
96 end: close + 1,
97 prelude_range: prelude_start..open,
98 body_range: Some(open + 1..close),
99 });
100 i = close + 1;
101 } else {
102 i = range.end;
103 }
104 consumed = true;
105 break;
106 }
107 b';' if paren_depth == 0 && bracket_depth == 0 => {
108 let raw = source[start..i].trim();
109 if raw.starts_with('@') {
110 let name = at_rule_name(raw);
111 nodes.push(SourceNode {
112 kind: NodeKind::AtStatement { name },
113 start,
114 end: i + 1,
115 prelude_range: start..i,
116 body_range: None,
117 });
118 }
119 i += 1;
120 consumed = true;
121 break;
122 }
123 b'}' if paren_depth == 0 && bracket_depth == 0 => {
124 consumed = true;
125 break;
126 }
127 _ => i += 1,
128 }
129 }
130
131 if !consumed {
132 break;
133 }
134 }
135
136 nodes
137}
138
139fn classify_block(prelude: &str, absolute_start: usize) -> (NodeKind, usize) {
140 let leading = prelude.len() - prelude.trim_start().len();
141 let trimmed = prelude.trim();
142 let prelude_start = absolute_start + leading;
143 if trimmed.starts_with('@') {
144 let name = at_rule_name(trimmed);
145 let after_name = &trimmed[1 + name.len()..];
146 let params = after_name.trim().to_string();
147 (NodeKind::AtBlock { name, params }, prelude_start)
148 } else {
149 (NodeKind::Style, prelude_start)
150 }
151}
152
153fn at_rule_name(raw: &str) -> String {
154 raw.trim_start_matches('@')
155 .split(|c: char| c.is_ascii_whitespace() || c == '(' || c == ';' || c == '{')
156 .next()
157 .unwrap_or_default()
158 .to_ascii_lowercase()
159}
160
161pub fn skip_trivia(source: &str, mut i: usize, end: usize) -> usize {
162 let bytes = source.as_bytes();
163 loop {
164 while i < end && bytes[i].is_ascii_whitespace() {
165 i += 1;
166 }
167 if i + 1 < end && bytes[i] == b'/' && bytes[i + 1] == b'*' {
168 i = skip_comment(source, i, end);
169 continue;
170 }
171 return i;
172 }
173}
174
175pub fn is_whitespace_only(source: &str, range: Range<usize>) -> bool {
176 source[range].bytes().all(|b| b.is_ascii_whitespace())
177}
178
179fn skip_comment(source: &str, i: usize, end: usize) -> usize {
180 let bytes = source.as_bytes();
181 let mut p = i + 2;
182 while p + 1 < end {
183 if bytes[p] == b'*' && bytes[p + 1] == b'/' {
184 return p + 2;
185 }
186 p += 1;
187 }
188 end
189}
190
191fn find_matching_brace(source: &str, open: usize, end: usize) -> Option<usize> {
192 let bytes = source.as_bytes();
193 let mut depth = 1usize;
194 let mut i = open + 1;
195 let mut quote: Option<u8> = None;
196 let mut escaped = false;
197
198 while i < end {
199 let b = bytes[i];
200
201 if let Some(q) = quote {
202 if escaped {
203 escaped = false;
204 } else if b == b'\\' {
205 escaped = true;
206 } else if b == q {
207 quote = None;
208 }
209 i += 1;
210 continue;
211 }
212
213 if b == b'/' && i + 1 < end && bytes[i + 1] == b'*' {
214 i = skip_comment(source, i, end);
215 continue;
216 }
217
218 match b {
219 b'\'' | b'"' => quote = Some(b),
220 b'{' => depth += 1,
221 b'}' => {
222 depth -= 1;
223 if depth == 0 {
224 return Some(i);
225 }
226 }
227 _ => {}
228 }
229 i += 1;
230 }
231
232 None
233}
234
235pub fn count_top_level_declarations(body: &str) -> usize {
236 let bytes = body.as_bytes();
237 let mut i = 0usize;
238 let mut braces = 0usize;
239 let mut parens = 0usize;
240 let mut brackets = 0usize;
241 let mut quote: Option<u8> = None;
242 let mut escaped = false;
243 let mut count = 0usize;
244 let mut segment_has_colon = false;
245
246 while i < bytes.len() {
247 let b = bytes[i];
248 if let Some(q) = quote {
249 if escaped {
250 escaped = false;
251 } else if b == b'\\' {
252 escaped = true;
253 } else if b == q {
254 quote = None;
255 }
256 i += 1;
257 continue;
258 }
259 if b == b'/' && i + 1 < bytes.len() && bytes[i + 1] == b'*' {
260 i = skip_comment(body, i, bytes.len());
261 continue;
262 }
263 match b {
264 b'\'' | b'"' => quote = Some(b),
265 b'{' => braces += 1,
266 b'}' => braces = braces.saturating_sub(1),
267 b'(' => parens += 1,
268 b')' => parens = parens.saturating_sub(1),
269 b'[' => brackets += 1,
270 b']' => brackets = brackets.saturating_sub(1),
271 b':' if braces == 0 && parens == 0 && brackets == 0 => segment_has_colon = true,
272 b';' if braces == 0 && parens == 0 && brackets == 0 => {
273 if segment_has_colon {
274 count += 1;
275 }
276 segment_has_colon = false;
277 }
278 _ => {}
279 }
280 i += 1;
281 }
282 if segment_has_colon {
283 count += 1;
284 }
285 count
286}
287
288pub fn top_level_declarations(body: &str) -> Vec<(String, String)> {
292 let bytes = body.as_bytes();
293 let mut declarations = Vec::new();
294 let mut i = 0usize;
295 let mut segment_start = 0usize;
296 let mut braces = 0usize;
297 let mut parens = 0usize;
298 let mut brackets = 0usize;
299 let mut quote: Option<u8> = None;
300 let mut escaped = false;
301 let mut segment_has_colon = false;
302
303 while i < bytes.len() {
304 let b = bytes[i];
305 if let Some(q) = quote {
306 if escaped {
307 escaped = false;
308 } else if b == b'\\' {
309 escaped = true;
310 } else if b == q {
311 quote = None;
312 }
313 i += 1;
314 continue;
315 }
316 if b == b'/' && i + 1 < bytes.len() && bytes[i + 1] == b'*' {
317 i = skip_comment(body, i, bytes.len());
318 continue;
319 }
320 match b {
321 b'\'' | b'"' => quote = Some(b),
322 b'(' => parens += 1,
323 b')' => parens = parens.saturating_sub(1),
324 b'[' => brackets += 1,
325 b']' => brackets = brackets.saturating_sub(1),
326 b':' if braces == 0 && parens == 0 && brackets == 0 => segment_has_colon = true,
327 b'{' if parens == 0 && brackets == 0 => {
328 if braces == 0 {
329 segment_start = i + 1;
330 segment_has_colon = false;
331 }
332 braces += 1;
333 }
334 b'}' if parens == 0 && brackets == 0 => {
335 braces = braces.saturating_sub(1);
336 if braces == 0 {
337 segment_start = i + 1;
338 segment_has_colon = false;
339 }
340 }
341 b';' if braces == 0 && parens == 0 && brackets == 0 => {
342 if segment_has_colon {
343 let raw = body[segment_start..i].trim();
344 if let Some((property, value)) = raw.split_once(':') {
345 declarations.push((property.trim().to_string(), value.trim().to_string()));
346 }
347 }
348 segment_start = i + 1;
349 segment_has_colon = false;
350 }
351 _ => {}
352 }
353 i += 1;
354 }
355
356 if braces == 0 && segment_has_colon {
357 let raw = body[segment_start..].trim();
358 if let Some((property, value)) = raw.split_once(':') {
359 declarations.push((property.trim().to_string(), value.trim().to_string()));
360 }
361 }
362
363 declarations
364}
365
366pub fn count_ascii_case_insensitive_outside_comments(source: &str, needle: &str) -> usize {
367 let lower_needle = needle.to_ascii_lowercase();
368 let bytes = source.as_bytes();
369 let mut i = 0usize;
370 let mut quote: Option<u8> = None;
371 let mut escaped = false;
372 let mut count = 0usize;
373
374 while i < bytes.len() {
375 let b = bytes[i];
376 if let Some(q) = quote {
377 if escaped {
378 escaped = false;
379 } else if b == b'\\' {
380 escaped = true;
381 } else if b == q {
382 quote = None;
383 }
384 i += 1;
385 continue;
386 }
387 if b == b'/' && i + 1 < bytes.len() && bytes[i + 1] == b'*' {
388 i = skip_comment(source, i, bytes.len());
389 continue;
390 }
391 if b == b'\'' || b == b'"' {
392 quote = Some(b);
393 i += 1;
394 continue;
395 }
396 if i + lower_needle.len() <= bytes.len() {
397 let needle = lower_needle.as_bytes();
398 let matches = bytes[i..i + needle.len()]
399 .iter()
400 .zip(needle.iter())
401 .all(|(a, b)| a.to_ascii_lowercase() == *b);
402 if matches {
403 count += 1;
404 i += needle.len();
405 continue;
406 }
407 }
408 i += 1;
409 }
410 count
411}