Skip to main content

weavatrix_parse/
docs.rs

1//! Structural extraction for Markdown, MDX, `reStructuredText` and `AsciiDoc`.
2//!
3//! Prose has no token structure worth the name: a `"` is a quotation mark, not
4//! a literal, and `//` is part of a URL. So this reads lines directly rather
5//! than through the tokenizer, which is the honest model for the format even
6//! though it is the opposite of what every other extractor here does.
7//!
8//! Two facts are worth having. A heading is a named anchor other documents
9//! link to, and nesting one heading under another is the document's own table
10//! of contents. A link to a path in the repository is a dependency exactly as
11//! an import is - which is what turns a documentation tree into part of the
12//! graph rather than a pile of files beside it.
13
14use crate::facts::{Declaration, DeclarationKind, Facts, Import, Span};
15use crate::syntax::Language;
16
17/// Extracts structural facts from one document.
18#[must_use]
19pub fn extract(source: &str, language: Language) -> Facts {
20    let mut state = Extractor {
21        facts: Facts::default(),
22        headings: Vec::new(),
23        offset: 0,
24    };
25    state.run(source, language);
26    state.facts
27}
28
29/// A heading whose section later headings may nest inside.
30struct Heading {
31    name: String,
32    level: usize,
33}
34
35struct Extractor {
36    facts: Facts,
37    headings: Vec<Heading>,
38    offset: usize,
39}
40
41impl Extractor {
42    fn run(&mut self, source: &str, language: Language) {
43        let lines = source.lines().collect::<Vec<_>>();
44        let mut fenced = false;
45        let mut script = String::new();
46        for (number, line) in lines.iter().enumerate() {
47            let start = self.offset;
48            self.offset += line.len() + 1;
49            let trimmed = line.trim();
50            // A fence hides everything until it closes: code inside a block is
51            // an example, and reading its links would invent dependencies.
52            if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
53                fenced = !fenced;
54                continue;
55            }
56            if fenced {
57                continue;
58            }
59            let span = Span {
60                start,
61                end: start + line.len(),
62                line: u32::try_from(number + 1).unwrap_or(u32::MAX),
63                column: 1,
64                end_line: u32::try_from(number + 1).unwrap_or(u32::MAX),
65                end_column: u32::try_from(line.len() + 1).unwrap_or(u32::MAX),
66            };
67            // MDX holds real JavaScript imports, which the script extractor
68            // already reads correctly; gathering them keeps that one rule.
69            if language == Language::Mdx
70                && (trimmed.starts_with("import ") || trimmed.starts_with("export "))
71            {
72                script.push_str(line);
73                script.push('\n');
74                continue;
75            }
76            self.heading(trimmed, lines.get(number + 1).copied(), language, span);
77            self.targets(line, language, span);
78        }
79        if !script.is_empty() {
80            let inner = crate::script::extract(&script, Language::TypeScript);
81            self.facts.imports.extend(inner.imports);
82        }
83    }
84
85    /// Records a heading and nests it under the last shallower one.
86    fn heading(&mut self, line: &str, next: Option<&str>, language: Language, span: Span) {
87        let Some((level, name)) = read_heading(line, next, language) else {
88            return;
89        };
90        while self
91            .headings
92            .last()
93            .is_some_and(|heading| heading.level >= level)
94        {
95            self.headings.pop();
96        }
97        self.facts.declarations.push(Declaration {
98            name: name.clone(),
99            kind: DeclarationKind::Heading,
100            span,
101            owner: self.headings.last().map(|heading| heading.name.clone()),
102            // Every heading in a document is reachable by anchor.
103            exported: true,
104        });
105        self.headings.push(Heading { name, level });
106    }
107
108    /// Records every path this line points at.
109    fn targets(&mut self, line: &str, language: Language, span: Span) {
110        for target in link_targets(line, language) {
111            if !is_repository_path(&target) {
112                continue;
113            }
114            self.facts.imports.push(Import {
115                specifier: target,
116                span,
117                type_only: false,
118                reexport: false,
119                names: Vec::new(),
120                bindings: Vec::new(),
121            });
122        }
123    }
124}
125
126/// The heading level and text on this line, if it is one.
127fn read_heading(line: &str, next: Option<&str>, language: Language) -> Option<(usize, String)> {
128    let marker = match language {
129        // AsciiDoc writes `== Title`; Markdown writes `## Title`.
130        Language::AsciiDoc => '=',
131        Language::ReStructuredText => {
132            // A heading is text with a rule of repeated punctuation beneath it,
133            // and the character used sets the level by order of first use.
134            let under = next?.trim();
135            if line.is_empty() || under.len() < line.len() {
136                return None;
137            }
138            let mark = under.chars().next()?;
139            if !"=-`:'\"~^_*+#<>".contains(mark) || under.chars().any(|other| other != mark) {
140                return None;
141            }
142            let level = "=-`:'\"~^_*+#<>".find(mark).unwrap_or(0) + 1;
143            return Some((level, line.to_owned()));
144        }
145        _ => '#',
146    };
147    let level = line
148        .chars()
149        .take_while(|character| *character == marker)
150        .count();
151    if level == 0 || level > 6 {
152        return None;
153    }
154    let rest = line[level..].trim();
155    // `#tag` and `=value` are not headings; a heading separates its marker.
156    if rest.is_empty() || !line[level..].starts_with(' ') {
157        return None;
158    }
159    Some((level, rest.to_owned()))
160}
161
162/// Every path this line points at, in whichever way the format writes one.
163fn link_targets(line: &str, language: Language) -> Vec<String> {
164    let mut found = Vec::new();
165    match language {
166        Language::ReStructuredText => {
167            // `.. include:: path`, `.. image:: path`, `.. figure:: path`.
168            let trimmed = line.trim();
169            if let Some(rest) = trimmed.strip_prefix("..")
170                && let Some((directive, argument)) = rest.trim_start().split_once("::")
171                && matches!(
172                    directive.trim(),
173                    "include" | "image" | "figure" | "literalinclude"
174                )
175            {
176                found.push(argument.trim().to_owned());
177            }
178        }
179        Language::AsciiDoc => {
180            // `include::path[]` and `image::path[opts]`.
181            for directive in ["include::", "image::"] {
182                let mut rest = line;
183                while let Some(at) = rest.find(directive) {
184                    let after = &rest[at + directive.len()..];
185                    if let Some(end) = after.find('[') {
186                        found.push(after[..end].trim().to_owned());
187                    }
188                    rest = after;
189                }
190            }
191        }
192        _ => {
193            // `[text](./path)`, `![alt](./image.png)` and the reference form
194            // `[id]: ./path`.
195            let bytes = line.as_bytes();
196            let mut at = 0;
197            while at < bytes.len() {
198                if bytes[at] == b']'
199                    && let Some(rest) = line.get(at + 1..)
200                {
201                    if let Some(inner) = rest.strip_prefix('(')
202                        && let Some(end) = inner.find(')')
203                    {
204                        // A title may follow the path inside the parentheses.
205                        let target = inner[..end].split_whitespace().next().unwrap_or("");
206                        found.push(target.to_owned());
207                    } else if let Some(inner) = rest.strip_prefix(": ") {
208                        found.push(inner.trim().to_owned());
209                    }
210                }
211                at += 1;
212            }
213        }
214    }
215    found
216}
217
218/// Whether a link target names a file in this repository rather than the web.
219fn is_repository_path(target: &str) -> bool {
220    !target.is_empty()
221        && !target.starts_with('#')
222        && !target.starts_with("//")
223        && !target.contains("://")
224        && !target.starts_with("mailto:")
225        && !target.starts_with("tel:")
226        && !target.starts_with('<')
227}
228
229#[cfg(test)]
230mod tests {
231    use super::extract;
232    use crate::facts::DeclarationKind;
233    use crate::syntax::Language;
234
235    fn imports(source: &str, language: Language) -> Vec<String> {
236        extract(source, language)
237            .imports
238            .into_iter()
239            .map(|import| import.specifier)
240            .collect()
241    }
242
243    #[test]
244    fn headings_nest_into_the_documents_own_table_of_contents() {
245        let source = "# Guide\n\
246             \n\
247             ## Install\n\
248             \n\
249             ### From source\n\
250             \n\
251             ## Usage\n\
252             \n\
253             Not a heading: #tag and # \n";
254        let declared = extract(source, Language::Markdown)
255            .declarations
256            .into_iter()
257            .map(|item| (item.name, item.owner))
258            .collect::<Vec<_>>();
259        assert_eq!(
260            declared,
261            [
262                ("Guide".to_owned(), None),
263                ("Install".to_owned(), Some("Guide".to_owned())),
264                ("From source".to_owned(), Some("Install".to_owned())),
265                ("Usage".to_owned(), Some("Guide".to_owned())),
266            ],
267            "a heading nests under the last shallower one"
268        );
269    }
270
271    #[test]
272    fn a_link_to_the_repository_is_a_dependency_and_a_url_is_not() {
273        let source = "See [the guide](./docs/guide.md) and [the API](../api/index.md \"title\").\n\
274             ![diagram](assets/flow.png)\n\
275             [home]: https://example.com\n\
276             [local]: ./other.md\n\
277             Jump to [section](#usage) or mail [us](mailto:x@y.z).\n";
278        assert_eq!(
279            imports(source, Language::Markdown),
280            [
281                "./docs/guide.md",
282                "../api/index.md",
283                "assets/flow.png",
284                "./other.md",
285            ],
286            "an anchor, a URL and a mail address are not files"
287        );
288    }
289
290    #[test]
291    fn a_fenced_block_is_an_example_rather_than_a_dependency() {
292        let source = "Real [link](./real.md).\n\
293             \n\
294             ```markdown\n\
295             [ghost](./ghost.md)\n\
296             ```\n\
297             \n\
298             ~~~\n\
299             [also-ghost](./also.md)\n\
300             ~~~\n";
301        assert_eq!(imports(source, Language::Markdown), ["./real.md"]);
302    }
303
304    #[test]
305    fn mdx_keeps_real_javascript_imports() {
306        let source = "import Chart from './Chart.jsx';\n\
307             import { note } from '../notes';\n\
308             \n\
309             # Report\n\
310             \n\
311             See [details](./details.mdx).\n\
312             \n\
313             <Chart data={note} />\n";
314        assert_eq!(
315            imports(source, Language::Mdx),
316            ["./details.mdx", "./Chart.jsx", "../notes"],
317            "a component import is a dependency, not prose"
318        );
319    }
320
321    #[test]
322    fn restructured_text_headings_and_includes() {
323        let source = "Guide\n\
324             =====\n\
325             \n\
326             .. include:: ../shared/intro.rst\n\
327             \n\
328             Install\n\
329             -------\n\
330             \n\
331             .. image:: assets/logo.png\n";
332        let facts = extract(source, Language::ReStructuredText);
333        assert_eq!(
334            facts
335                .declarations
336                .iter()
337                .map(|item| (item.name.as_str(), item.owner.as_deref()))
338                .collect::<Vec<_>>(),
339            [("Guide", None), ("Install", Some("Guide"))],
340            "the underline character sets the level"
341        );
342        assert_eq!(
343            facts
344                .imports
345                .iter()
346                .map(|import| import.specifier.as_str())
347                .collect::<Vec<_>>(),
348            ["../shared/intro.rst", "assets/logo.png"]
349        );
350    }
351
352    #[test]
353    fn asciidoc_headings_and_includes() {
354        let source = "= Guide\n\
355             \n\
356             include::shared/intro.adoc[]\n\
357             \n\
358             == Install\n\
359             \n\
360             image::assets/logo.png[width=200]\n";
361        let facts = extract(source, Language::AsciiDoc);
362        assert_eq!(
363            facts
364                .declarations
365                .iter()
366                .map(|item| (item.name.as_str(), item.owner.as_deref()))
367                .collect::<Vec<_>>(),
368            [("Guide", None), ("Install", Some("Guide"))]
369        );
370        assert_eq!(
371            facts
372                .imports
373                .iter()
374                .map(|import| import.specifier.as_str())
375                .collect::<Vec<_>>(),
376            ["shared/intro.adoc", "assets/logo.png"]
377        );
378    }
379
380    #[test]
381    fn every_heading_carries_the_kind_the_graph_stores_it_under() {
382        let facts = extract("# Only\n", Language::Markdown);
383        assert_eq!(facts.declarations[0].kind, DeclarationKind::Heading);
384        assert_eq!(facts.declarations[0].span.line, 1);
385    }
386}