1use crate::facts::{Declaration, DeclarationKind, Facts, Import, Span};
15use crate::syntax::Language;
16
17#[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
29struct 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 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 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 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 exported: true,
104 });
105 self.headings.push(Heading { name, level });
106 }
107
108 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
126fn read_heading(line: &str, next: Option<&str>, language: Language) -> Option<(usize, String)> {
128 let marker = match language {
129 Language::AsciiDoc => '=',
131 Language::ReStructuredText => {
132 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 if rest.is_empty() || !line[level..].starts_with(' ') {
157 return None;
158 }
159 Some((level, rest.to_owned()))
160}
161
162fn link_targets(line: &str, language: Language) -> Vec<String> {
164 let mut found = Vec::new();
165 match language {
166 Language::ReStructuredText => {
167 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 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 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 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
218fn 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 \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}