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 count_ascii_case_insensitive_outside_comments(source: &str, needle: &str) -> usize {
289 let lower_needle = needle.to_ascii_lowercase();
290 let bytes = source.as_bytes();
291 let mut i = 0usize;
292 let mut quote: Option<u8> = None;
293 let mut escaped = false;
294 let mut count = 0usize;
295
296 while i < bytes.len() {
297 let b = bytes[i];
298 if let Some(q) = quote {
299 if escaped {
300 escaped = false;
301 } else if b == b'\\' {
302 escaped = true;
303 } else if b == q {
304 quote = None;
305 }
306 i += 1;
307 continue;
308 }
309 if b == b'/' && i + 1 < bytes.len() && bytes[i + 1] == b'*' {
310 i = skip_comment(source, i, bytes.len());
311 continue;
312 }
313 if b == b'\'' || b == b'"' {
314 quote = Some(b);
315 i += 1;
316 continue;
317 }
318 if i + lower_needle.len() <= bytes.len() {
319 let needle = lower_needle.as_bytes();
320 let matches = bytes[i..i + needle.len()]
321 .iter()
322 .zip(needle.iter())
323 .all(|(a, b)| a.to_ascii_lowercase() == *b);
324 if matches {
325 count += 1;
326 i += needle.len();
327 continue;
328 }
329 }
330 i += 1;
331 }
332 count
333}