Skip to main content

weavatrix_parse/
style.rs

1//! Structural extraction for CSS, SCSS, Sass and Less.
2//!
3//! A stylesheet is a reference graph rather than a call graph: what matters is
4//! which selectors a file declares, because that is what an HTML `class` or
5//! `id` attribute resolves to, and which other stylesheets it pulls in.
6//!
7//! Selectors are read from the token stream rather than by matching lines,
8//! which is what makes nesting work. In SCSS a rule written inside another
9//! rule is a real selector, and a `&` prefix concatenates it onto its parent -
10//! so `.card { &__title { } }` declares `.card__title`, a name that appears
11//! nowhere in the source as written.
12
13use crate::facts::{Declaration, DeclarationKind, Facts, Import, Reference, ReferenceKind, Span};
14use crate::syntax::Language;
15use crate::token::{Mode, Token, TokenKind, Tokenizer};
16
17/// Extracts structural facts from one stylesheet.
18#[must_use]
19pub fn extract(source: &str, language: Language) -> Facts {
20    let tokens = Tokenizer::new(source, language)
21        .mode(Mode::Lite)
22        .collect::<Vec<_>>();
23    let mut state = Extractor {
24        source,
25        tokens: &tokens,
26        facts: Facts::default(),
27        nesting: Vec::new(),
28    };
29    state.run();
30    state.facts
31}
32
33/// At-rules that name another stylesheet.
34const AT_IMPORTS: &[&str] = &["import", "use", "forward"];
35
36struct Extractor<'source, 'tokens> {
37    source: &'source str,
38    tokens: &'tokens [Token],
39    facts: Facts,
40    /// Selector prefixes of the enclosing rules, one per open brace.
41    nesting: Vec<String>,
42}
43
44impl Extractor<'_, '_> {
45    fn run(&mut self) {
46        let mut index = 0;
47        while index < self.tokens.len() {
48            index = self.step(index);
49        }
50    }
51
52    fn text(&self, index: usize) -> &str {
53        self.tokens
54            .get(index)
55            .map_or("", |token| token.text(self.source))
56    }
57
58    fn kind(&self, index: usize) -> Option<TokenKind> {
59        self.tokens.get(index).map(|token| token.kind)
60    }
61
62    fn punct(&self, index: usize, mark: &str) -> bool {
63        self.kind(index) == Some(TokenKind::Punctuation) && self.text(index) == mark
64    }
65
66    fn span(&self, start: usize, end: usize) -> Span {
67        let last_index = self.tokens.len().saturating_sub(1);
68        let first = &self.tokens[start.min(last_index)];
69        let last = &self.tokens[end.min(last_index)];
70        Span {
71            start: first.start,
72            end: last.end,
73            line: first.line,
74            column: first.column,
75            end_line: last.line,
76            end_column: last.column,
77        }
78    }
79
80    fn step(&mut self, index: usize) -> usize {
81        if self.punct(index, "}") {
82            self.nesting.pop();
83            return index + 1;
84        }
85        if (self.punct(index, "@") || self.text(index).starts_with('@'))
86            && let Some(next) = self.at_rule(index)
87        {
88            return next;
89        }
90        // A selector list runs up to the brace that opens its block. Anything
91        // else at this level is a property declaration, which ends at a
92        // semicolon and declares nothing.
93        if let Some(open) = self.selector_block(index) {
94            return open;
95        }
96        index + 1
97    }
98
99    /// `@import "x.css"`, `@use "sass:math"`, `@forward "./theme"`.
100    fn at_rule(&mut self, index: usize) -> Option<usize> {
101        // The tokenizer gives `@` separately in CSS and joined in SCSS, where
102        // `@` is an identifier character, so both shapes are accepted.
103        let (keyword, mut cursor) = if self.punct(index, "@") {
104            (self.text(index + 1).to_owned(), index + 2)
105        } else {
106            (
107                self.text(index).trim_start_matches('@').to_owned(),
108                index + 1,
109            )
110        };
111        if !AT_IMPORTS
112            .iter()
113            .any(|word| word.eq_ignore_ascii_case(&keyword))
114        {
115            return None;
116        }
117        let limit = (cursor + 64).min(self.tokens.len());
118        let mut found = false;
119        while cursor < limit && !self.punct(cursor, ";") && !self.punct(cursor, "{") {
120            if self.kind(cursor) == Some(TokenKind::String) {
121                let specifier = self.text(cursor).trim_matches(['"', '\'']).to_owned();
122                if !specifier.is_empty() {
123                    self.facts.imports.push(Import {
124                        specifier,
125                        span: self.span(index, cursor),
126                        type_only: false,
127                        reexport: keyword.eq_ignore_ascii_case("forward"),
128                        names: Vec::new(),
129                        bindings: Vec::new(),
130                    });
131                    found = true;
132                }
133            }
134            cursor += 1;
135        }
136        found.then_some(cursor)
137    }
138
139    /// Reads a selector list ending at `{`, records every class and id it
140    /// names, and pushes the nesting prefix for the block it opens.
141    fn selector_block(&mut self, start: usize) -> Option<usize> {
142        let limit = (start + 256).min(self.tokens.len());
143        let mut cursor = start;
144        while cursor < limit {
145            if self.punct(cursor, "{") {
146                break;
147            }
148            // A property declaration or a closing brace means this was never a
149            // selector list.
150            if self.punct(cursor, ";") || self.punct(cursor, "}") {
151                return None;
152            }
153            cursor += 1;
154        }
155        if cursor >= limit || !self.punct(cursor, "{") {
156            return None;
157        }
158        let parent = self.nesting.last().cloned().unwrap_or_default();
159        let mut last_selector = String::new();
160        let mut scan = start;
161        while scan < cursor {
162            if let Some((name, after)) = self.selector_at(scan, &parent) {
163                self.facts.declarations.push(Declaration {
164                    name: name.clone(),
165                    kind: DeclarationKind::Selector,
166                    span: self.span(scan, after.saturating_sub(1)),
167                    owner: (!parent.is_empty()).then(|| parent.clone()),
168                    // A stylesheet has no private selectors.
169                    exported: true,
170                });
171                last_selector = name;
172                scan = after;
173                continue;
174            }
175            scan += 1;
176        }
177        self.nesting.push(if last_selector.is_empty() {
178            parent
179        } else {
180            last_selector
181        });
182        Some(cursor + 1)
183    }
184
185    /// A `.class`, `#id`, or SCSS `&`-joined continuation starting at `index`.
186    fn selector_at(&self, index: usize, parent: &str) -> Option<(String, usize)> {
187        // `&__title` and `&--wide` extend the enclosing selector rather than
188        // naming a new one, which is the form a line-based scanner cannot see.
189        if self.punct(index, "&") {
190            let suffix = self.text(index + 1);
191            if self.kind(index + 1) == Some(TokenKind::Identifier) && !parent.is_empty() {
192                return Some((format!("{parent}{suffix}"), index + 2));
193            }
194            return None;
195        }
196        let marker = if self.punct(index, ".") {
197            '.'
198        } else if self.punct(index, "#") {
199            '#'
200        } else {
201            return None;
202        };
203        if self.kind(index + 1) != Some(TokenKind::Identifier) {
204            return None;
205        }
206        let name = self.text(index + 1);
207        // A decimal such as `.5em` is not a class, and neither is a colour.
208        if name.starts_with(|character: char| character.is_ascii_digit()) {
209            return None;
210        }
211        Some((format!("{marker}{name}"), index + 2))
212    }
213}
214
215/// Records that a document uses a selector, which is what an HTML `class` or
216/// `id` attribute does. Kept here so the HTML extractor and this one agree on
217/// how a selector is named.
218pub(crate) fn selector_use(facts: &mut Facts, name: String, span: Span) {
219    facts.references.push(Reference {
220        name,
221        kind: ReferenceKind::Uses,
222        receiver: None,
223        span,
224        owner: None,
225        string_arguments: Vec::new(),
226        name_arguments: Vec::new(),
227    });
228}
229
230#[cfg(test)]
231mod tests {
232    use super::extract;
233    use crate::facts::DeclarationKind;
234    use crate::syntax::Language;
235
236    fn declared(source: &str, language: Language) -> Vec<String> {
237        extract(source, language)
238            .declarations
239            .into_iter()
240            .map(|item| item.name)
241            .collect()
242    }
243
244    #[test]
245    fn class_and_id_selectors_are_declarations() {
246        let source = ".panel { color: red; }\n\
247             #header, .nav .item { margin: 0; }\n\
248             a:hover { color: blue; }\n";
249        assert_eq!(
250            declared(source, Language::Css),
251            [".panel", "#header", ".nav", ".item"],
252            "a pseudo-class on a bare element declares no selector"
253        );
254    }
255
256    #[test]
257    fn nested_scss_selectors_are_resolved_to_the_names_they_produce() {
258        // The JS engine's own note admits this case is under-captured there,
259        // because it has no SCSS grammar and reads CSS as flat rules.
260        let source = ".card {\n\
261             \x20 color: red;\n\
262             \x20 &__title { font-weight: bold; }\n\
263             \x20 &--wide { width: 100%; }\n\
264             \x20 .inner { padding: 0; }\n\
265             }\n";
266        let names = declared(source, Language::Scss);
267        assert!(names.contains(&".card".to_owned()), "got {names:?}");
268        assert!(
269            names.contains(&".card__title".to_owned()),
270            "an ampersand joins the child onto its parent, got {names:?}"
271        );
272        assert!(names.contains(&".card--wide".to_owned()), "got {names:?}");
273        assert!(names.contains(&".inner".to_owned()), "got {names:?}");
274    }
275
276    #[test]
277    fn stylesheets_name_the_stylesheets_they_pull_in() {
278        let source = "@import \"./base.css\";\n\
279             @use \"sass:math\";\n\
280             @forward \"./theme\";\n\
281             .x { background: url(\"./bg.png\"); }\n";
282        let imports = extract(source, Language::Scss)
283            .imports
284            .into_iter()
285            .map(|import| (import.specifier, import.reexport))
286            .collect::<Vec<_>>();
287        assert_eq!(
288            imports,
289            [
290                ("./base.css".to_owned(), false),
291                ("sass:math".to_owned(), false),
292                ("./theme".to_owned(), true),
293            ],
294            "a forward re-exports, and a url() inside a property is not an import"
295        );
296    }
297
298    #[test]
299    fn a_comment_declares_no_selector_and_a_decimal_is_not_a_class() {
300        let source = "/* .ghost { } */\n\
301             .real { margin: .5em; padding: 0 .25rem; }\n";
302        assert_eq!(declared(source, Language::Css), [".real"]);
303    }
304
305    #[test]
306    fn a_double_slash_is_a_comment_in_scss_and_not_in_css() {
307        let scss = "// .ghost { }\n.real { }\n";
308        assert_eq!(declared(scss, Language::Scss), [".real"]);
309        // In plain CSS `//` is not a comment, so the selector after it on the
310        // same line is still read rather than silently dropped.
311        assert_eq!(
312            extract("// x\n.real { }\n", Language::Css)
313                .declarations
314                .len(),
315            1
316        );
317    }
318
319    #[test]
320    fn selectors_carry_the_kind_the_graph_stores_them_under() {
321        let facts = extract(".only { }\n", Language::Css);
322        assert_eq!(facts.declarations[0].kind, DeclarationKind::Selector);
323    }
324}