Skip to main content

weavatrix_parse/
markup.rs

1//! Structural extraction for HTML and single-file components.
2//!
3//! HTML contributes two kinds of edge. A `<link href>`, `<script src>` or
4//! `<img src>` names another file, which is an ordinary dependency. A `class`
5//! or `id` attribute names a CSS selector, which resolves to whichever
6//! stylesheet declares it - an edge that exists only once both sides are
7//! parsed, and the reason a document and its stylesheet belong in one graph.
8//!
9//! Attributes are read from the token stream, so a tag written inside a
10//! comment contributes nothing, and a `<` inside an attribute value does not
11//! open a tag.
12
13use crate::facts::{Facts, Import, Span};
14use crate::style::selector_use;
15use crate::syntax::Language;
16use crate::token::{Mode, Token, TokenKind, Tokenizer};
17
18/// Extracts structural facts from one document.
19#[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
36/// Elements whose text is a dependency rather than prose.
37///
38/// XML project files name their dependencies in element content rather than in
39/// attributes: a Maven module lists `<module>ui</module>`, and an artifact is
40/// split across `<groupId>` and `<artifactId>`.
41fn names_a_file_in_text(tag: &str) -> bool {
42    matches!(tag, "module" | "include" | "xi:include" | "systemid")
43}
44
45/// Which attribute names a file, given the tag it is written on.
46///
47/// `href` on an anchor is a link to a page rather than a dependency of this
48/// one, so the tag decides, not the attribute alone.
49fn names_a_file(language: Language, tag: &str, attribute: &str) -> bool {
50    if language == Language::Xml {
51        // A project file points at another project or package by attribute:
52        // `<ProjectReference Include="../Lib/Lib.csproj">`, `<xi:include
53        // href="shared.xml">`, `<xsd:import schemaLocation="types.xsd">`.
54        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    /// The tag whose attributes are being read.
76    tag: String,
77    /// Where the text of an element whose content names a file begins.
78    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        // `<tag` opens an element and names the tag the following attributes
119        // belong to; `</` and `>` end one.
120        if self.punct(index, "<") {
121            // An element whose content names a file ends its text here.
122            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            // A script or style element holds another language, and its
132            // contents are the whole point of a single-file component: a Vue
133            // or Svelte file keeps its imports there and nowhere else.
134            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    /// Records the text of an element whose content is a path.
153    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        // A path has no spaces in it; prose does.
165        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    /// Extracts the body of a `<script>` or `<style>` element with the
179    /// extractor for the language it holds, and moves the facts into this
180    /// file's coordinates.
181    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        // The body runs to the `<` that opens the closing tag.
187        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            // Everything a component writes in a style block is at least SCSS,
207            // and reading plain CSS with SCSS rules costs only accepting `//`.
208            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    /// Moves facts from a fragment's coordinates into the document's.
224    fn absorb(&mut self, mut inner: Facts, offset: usize, line: u32, column: u32) {
225        let shift = |span: &mut Span| {
226            // Only the fragment's first line shares a line with the document,
227            // so only it needs the column moved.
228            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    /// `name="value"`, `name='value'` or `name=value`.
254    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        // A namespace prefix does not change what the attribute means:
259        // `xlink:href` names a file exactly as `href` does.
260        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        // Owned before any push, because the borrow of the token text and the
268        // borrow of the fact list are both of `self`.
269        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                // A srcset lists several candidates with descriptors.
287                for candidate in value.split(',') {
288                    let path = candidate.split_whitespace().next().unwrap_or("");
289                    // A data URI or an external URL is not a file in this tree.
290                    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        // Claiming `.vue` and `.svelte` while reading only tag attributes was
367        // worse than not claiming them: the file became a graph node with no
368        // dependencies at all, which reads as "this component imports
369        // nothing" rather than as "unsupported".
370        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}