1use crate::facts::{Declaration, DeclarationKind, Facts, Import, Reference, ReferenceKind, Span};
14use crate::syntax::Language;
15use crate::token::{Mode, Token, TokenKind, Tokenizer};
16
17#[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
33const AT_IMPORTS: &[&str] = &["import", "use", "forward"];
35
36struct Extractor<'source, 'tokens> {
37 source: &'source str,
38 tokens: &'tokens [Token],
39 facts: Facts,
40 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 if let Some(open) = self.selector_block(index) {
94 return open;
95 }
96 index + 1
97 }
98
99 fn at_rule(&mut self, index: usize) -> Option<usize> {
101 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 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 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 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 fn selector_at(&self, index: usize, parent: &str) -> Option<(String, usize)> {
187 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 if name.starts_with(|character: char| character.is_ascii_digit()) {
209 return None;
210 }
211 Some((format!("{marker}{name}"), index + 2))
212 }
213}
214
215pub(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 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 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}