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
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}