1use crate::facts::{Facts, Import, Span};
14use crate::style::selector_use;
15use crate::syntax::Language;
16use crate::token::{Mode, Token, TokenKind, Tokenizer};
17
18#[must_use]
20pub fn extract(source: &str, language: Language) -> Facts {
21 let tokens = Tokenizer::new(source, language)
22 .mode(Mode::Lite)
23 .collect::<Vec<_>>();
24 let mut state = Extractor {
25 source,
26 tokens: &tokens,
27 language,
28 facts: Facts::default(),
29 tag: String::new(),
30 text_start: None,
31 };
32 state.run();
33 state.facts
34}
35
36fn names_a_file_in_text(tag: &str) -> bool {
42 matches!(tag, "module" | "include" | "xi:include" | "systemid")
43}
44
45fn names_a_file(language: Language, tag: &str, attribute: &str) -> bool {
50 if language == Language::Xml {
51 return matches!(
55 attribute,
56 "include" | "href" | "src" | "schemalocation" | "location" | "file" | "path"
57 );
58 }
59 match attribute {
60 "href" => matches!(tag, "link" | "use"),
61 "src" => matches!(
62 tag,
63 "script" | "img" | "iframe" | "audio" | "video" | "source" | "embed" | "track"
64 ),
65 "srcset" | "data-src" => true,
66 _ => false,
67 }
68}
69
70struct Extractor<'source, 'tokens> {
71 source: &'source str,
72 tokens: &'tokens [Token],
73 language: Language,
74 facts: Facts,
75 tag: String,
77 text_start: Option<(usize, String)>,
79}
80
81impl Extractor<'_, '_> {
82 fn run(&mut self) {
83 let mut index = 0;
84 while index < self.tokens.len() {
85 index = self.step(index);
86 }
87 }
88
89 fn text(&self, index: usize) -> &str {
90 self.tokens
91 .get(index)
92 .map_or("", |token| token.text(self.source))
93 }
94
95 fn kind(&self, index: usize) -> Option<TokenKind> {
96 self.tokens.get(index).map(|token| token.kind)
97 }
98
99 fn punct(&self, index: usize, mark: &str) -> bool {
100 self.kind(index) == Some(TokenKind::Punctuation) && self.text(index) == mark
101 }
102
103 fn span(&self, start: usize, end: usize) -> Span {
104 let last_index = self.tokens.len().saturating_sub(1);
105 let first = &self.tokens[start.min(last_index)];
106 let last = &self.tokens[end.min(last_index)];
107 Span {
108 start: first.start,
109 end: last.end,
110 line: first.line,
111 column: first.column,
112 end_line: last.line,
113 end_column: last.column,
114 }
115 }
116
117 fn step(&mut self, index: usize) -> usize {
118 if self.punct(index, "<") {
121 self.close_text(index);
123 if self.kind(index + 1) == Some(TokenKind::Identifier) {
124 self.tag = self.text(index + 1).to_ascii_lowercase();
125 return index + 2;
126 }
127 self.tag.clear();
128 return index + 1;
129 }
130 if self.punct(index, ">") {
131 if matches!(self.tag.as_str(), "script" | "style") {
135 let embedded = self.tag.clone();
136 self.tag.clear();
137 return self.embedded(index, &embedded);
138 }
139 if self.language == Language::Xml && names_a_file_in_text(&self.tag) {
140 let tag = self.tag.clone();
141 self.text_start = self.tokens.get(index + 1).map(|token| (token.start, tag));
142 }
143 self.tag.clear();
144 return index + 1;
145 }
146 if self.tag.is_empty() {
147 return index + 1;
148 }
149 self.attribute(index)
150 }
151
152 fn close_text(&mut self, index: usize) {
154 let Some((start, _)) = self.text_start.take() else {
155 return;
156 };
157 let Some(end) = self.tokens.get(index).map(|token| token.start) else {
158 return;
159 };
160 if end <= start {
161 return;
162 }
163 let text = self.source[start..end].trim();
164 if text.is_empty() || text.contains(char::is_whitespace) {
166 return;
167 }
168 self.facts.imports.push(Import {
169 specifier: text.to_owned(),
170 span: self.span(index, index),
171 type_only: false,
172 reexport: false,
173 names: Vec::new(),
174 bindings: Vec::new(),
175 });
176 }
177
178 fn embedded(&mut self, close_of_open_tag: usize, tag: &str) -> usize {
182 let start_token = close_of_open_tag + 1;
183 let Some(start) = self.tokens.get(start_token).map(|token| token.start) else {
184 return close_of_open_tag + 1;
185 };
186 let mut end_token = start_token;
188 while end_token < self.tokens.len() {
189 if self.punct(end_token, "<")
190 && self.punct(end_token + 1, "/")
191 && self.text(end_token + 2).eq_ignore_ascii_case(tag)
192 {
193 break;
194 }
195 end_token += 1;
196 }
197 let end = self
198 .tokens
199 .get(end_token)
200 .map_or(self.source.len(), |token| token.start);
201 if end <= start {
202 return end_token.max(close_of_open_tag + 1);
203 }
204 let body = &self.source[start..end];
205 let language = if tag == "style" {
206 Language::Scss
209 } else {
210 Language::TypeScript
211 };
212 let inner = if tag == "style" {
213 crate::style::extract(body, language)
214 } else {
215 crate::script::extract(body, language)
216 };
217 let line = self.tokens[start_token].line;
218 let column = self.tokens[start_token].column;
219 self.absorb(inner, start, line, column);
220 end_token
221 }
222
223 fn absorb(&mut self, mut inner: Facts, offset: usize, line: u32, column: u32) {
225 let shift = |span: &mut Span| {
226 if span.line == 1 {
229 span.column += column - 1;
230 }
231 if span.end_line == 1 {
232 span.end_column += column - 1;
233 }
234 span.start += offset;
235 span.end += offset;
236 span.line += line - 1;
237 span.end_line += line - 1;
238 };
239 for item in &mut inner.declarations {
240 shift(&mut item.span);
241 }
242 for item in &mut inner.imports {
243 shift(&mut item.span);
244 }
245 for item in &mut inner.references {
246 shift(&mut item.span);
247 }
248 self.facts.declarations.append(&mut inner.declarations);
249 self.facts.imports.append(&mut inner.imports);
250 self.facts.references.append(&mut inner.references);
251 }
252
253 fn attribute(&mut self, index: usize) -> usize {
255 if self.kind(index) != Some(TokenKind::Identifier) || !self.punct(index + 1, "=") {
256 return index + 1;
257 }
258 let written = self.text(index).to_ascii_lowercase();
261 let name = written
262 .rsplit_once(':')
263 .map_or(written.as_str(), |(_, local)| local)
264 .to_owned();
265 let value_index = index + 2;
266 let raw = self.text(value_index);
267 let value = match self.kind(value_index) {
270 Some(TokenKind::String) => raw.trim_matches(['"', '\'']).to_owned(),
271 Some(TokenKind::Identifier | TokenKind::Number) => raw.to_owned(),
272 _ => return index + 2,
273 };
274 if value.is_empty() {
275 return value_index + 1;
276 }
277 let span = self.span(index, value_index);
278 match name.as_str() {
279 "class" => {
280 for class in value.split_whitespace() {
281 selector_use(&mut self.facts, format!(".{class}"), span);
282 }
283 }
284 "id" => selector_use(&mut self.facts, format!("#{value}"), span),
285 _ if names_a_file(self.language, &self.tag, &name) => {
286 for candidate in value.split(',') {
288 let path = candidate.split_whitespace().next().unwrap_or("");
289 if path.is_empty() || path.contains(':') || path.starts_with("//") {
291 continue;
292 }
293 self.facts.imports.push(Import {
294 specifier: path.to_owned(),
295 span,
296 type_only: false,
297 reexport: false,
298 names: Vec::new(),
299 bindings: Vec::new(),
300 });
301 }
302 }
303 _ => {}
304 }
305 value_index + 1
306 }
307}
308
309#[cfg(test)]
310mod tests {
311 use super::extract;
312 use crate::facts::ReferenceKind;
313 use crate::syntax::Language;
314
315 fn imports(source: &str) -> Vec<String> {
316 extract(source, Language::Html)
317 .imports
318 .into_iter()
319 .map(|import| import.specifier)
320 .collect()
321 }
322
323 fn uses(source: &str) -> Vec<String> {
324 extract(source, Language::Html)
325 .references
326 .into_iter()
327 .filter(|reference| reference.kind == ReferenceKind::Uses)
328 .map(|reference| reference.name)
329 .collect()
330 }
331
332 #[test]
333 fn a_document_depends_on_the_files_it_pulls_in() {
334 let source = "<html>\n\
335 <head>\n\
336 <link rel=\"stylesheet\" href=\"./styles/app.css\">\n\
337 <script src=\"/js/main.js\"></script>\n\
338 </head>\n\
339 <body>\n\
340 <img src=\"assets/logo.png\" alt=\"logo\">\n\
341 <a href=\"/about\">about</a>\n\
342 <script src=\"https://cdn.example.com/x.js\"></script>\n\
343 </body>\n\
344 </html>\n";
345 assert_eq!(
346 imports(source),
347 ["./styles/app.css", "/js/main.js", "assets/logo.png"],
348 "an anchor is navigation and a CDN script is not a file in this tree"
349 );
350 }
351
352 #[test]
353 fn class_and_id_attributes_use_the_selectors_a_stylesheet_declares() {
354 let source = "<div class=\"panel panel--wide\" id=\"root\">\n\
355 <span class=\"badge\">x</span>\n\
356 </div>\n";
357 assert_eq!(
358 uses(source),
359 [".panel", ".panel--wide", "#root", ".badge"],
360 "a class attribute names one selector per word"
361 );
362 }
363
364 #[test]
365 fn a_single_file_component_keeps_its_imports_in_its_script_block() {
366 let source = "<template>\n\
371 \x20 <div class=\"card\"><Child /></div>\n\
372 </template>\n\
373 <script>\n\
374 import Child from './Child.vue';\n\
375 import { useStore } from '../store';\n\
376 function mounted() { useStore(); }\n\
377 </script>\n\
378 <style scoped>\n\
379 .card { color: red; }\n\
380 </style>\n";
381 let facts = extract(source, Language::Html);
382 assert_eq!(
383 facts
384 .imports
385 .iter()
386 .map(|import| import.specifier.as_str())
387 .collect::<Vec<_>>(),
388 ["./Child.vue", "../store"]
389 );
390 let mounted = facts
391 .declarations
392 .iter()
393 .find(|item| item.name == "mounted")
394 .expect("the script block declares a function");
395 assert_eq!(
396 mounted.span.line, 7,
397 "a fact from an embedded block must carry the document's line"
398 );
399 assert!(
400 facts.declarations.iter().any(|item| item.name == ".card"),
401 "the style block declares a selector"
402 );
403 assert!(
404 facts
405 .references
406 .iter()
407 .any(|reference| reference.name == ".card"),
408 "and the template uses it"
409 );
410 }
411
412 #[test]
413 fn a_project_file_names_the_projects_and_packages_it_references() {
414 let source = "<Project Sdk=\"Microsoft.NET.Sdk\">\n\
415 \x20 <ItemGroup>\n\
416 \x20 <ProjectReference Include=\"../Core/Core.csproj\" />\n\
417 \x20 <PackageReference Include=\"Serilog\" Version=\"3.1.0\" />\n\
418 \x20 </ItemGroup>\n\
419 \x20 <Import Project=\"build/common.props\" />\n\
420 </Project>\n";
421 assert_eq!(
422 extract(source, Language::Xml)
423 .imports
424 .into_iter()
425 .map(|import| import.specifier)
426 .collect::<Vec<_>>(),
427 ["../Core/Core.csproj", "Serilog"],
428 "an Include names a dependency whether it is a path or a package"
429 );
430 }
431
432 #[test]
433 fn a_maven_module_is_named_by_its_element_text() {
434 let source = "<project>\n\
435 \x20 <modules>\n\
436 \x20 <module>ui</module>\n\
437 \x20 <module>service</module>\n\
438 \x20 </modules>\n\
439 \x20 <name>My Project Name</name>\n\
440 </project>\n";
441 assert_eq!(
442 extract(source, Language::Xml)
443 .imports
444 .into_iter()
445 .map(|import| import.specifier)
446 .collect::<Vec<_>>(),
447 ["ui", "service"],
448 "prose with spaces in it is not a path"
449 );
450 }
451
452 #[test]
453 fn a_tag_written_inside_a_comment_contributes_nothing() {
454 let source = "<!-- <script src=\"./ghost.js\"></script> -->\n\
455 <script src=\"./real.js\"></script>\n";
456 assert_eq!(imports(source), ["./real.js"]);
457 }
458
459 #[test]
460 fn an_angle_bracket_inside_a_value_does_not_open_a_tag() {
461 let source = "<div data-tpl=\"<b>bold</b>\" class=\"kept\"></div>\n";
462 assert_eq!(
463 uses(source),
464 [".kept"],
465 "the attribute value is one string token, not markup"
466 );
467 }
468
469 #[test]
470 fn a_srcset_names_every_candidate_without_its_descriptor() {
471 assert_eq!(
472 imports("<img srcset=\"small.png 480w, large.png 1080w\" src=\"fallback.png\">\n"),
473 ["small.png", "large.png", "fallback.png"]
474 );
475 }
476
477 #[test]
478 fn hyphenated_and_namespaced_attributes_are_read_as_one_name() {
479 let source = "<use xlink:href=\"./sprite.svg#icon\"></use>\n\
480 <div data-count=\"3\" aria-label=\"n\" class=\"c\"></div>\n";
481 assert_eq!(imports(source), ["./sprite.svg#icon"]);
482 assert_eq!(uses(source), [".c"]);
483 }
484}