Skip to main content

cssforge_core/
scanner.rs

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
288/// Extract direct declarations from a rule body, excluding declarations in
289/// nested style rules and at-rules. This is deliberately separate from the
290/// counter above because diagnostics need the property/value text as well.
291pub 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}