Skip to main content

sphinx_ultra/
parser.rs

1//! The build-path parser front end.
2//!
3//! Since M2 wave 3, RST goes through the docutils-fidelity parser in
4//! [`crate::rst`] (sphinx mode): `Parser::parse` builds the [`Document`]
5//! the pipeline consumes — title, toc (docutils-normalized anchors),
6//! toctree entries with per-entry lines, directive/role records for the
7//! validation system, and explicit-target labels for nitpicky mode — all
8//! derived from the doctree at parse time and serializable for the
9//! incremental cache. The M1 line-scanner is gone. Markdown stays on the
10//! wave-6 (MyST) TODO path.
11
12use anyhow::Result;
13use log::debug;
14use pulldown_cmark::{Event, Parser as MarkdownParser, Tag};
15use std::collections::BTreeSet;
16use std::path::Path;
17use std::sync::Arc;
18
19use crate::config::BuildConfig;
20use crate::doctree::{kinds, Doctree, Node, Span};
21use crate::document::{
22    Document, DocumentContent, LabelRecord, MarkdownContent, MarkdownNode, RstContent, TocEntry,
23};
24use crate::rst;
25use crate::utils;
26
27pub struct Parser {
28    /// `exclude_patterns` from the build configuration, which the `toctree`
29    /// directive consults to tell an excluded target from a nonexisting one
30    /// (sphinx `TocTree.parse_content` reads `self.config.exclude_patterns`).
31    exclude_patterns: Vec<String>,
32    /// The object-signature / py-domain configuration the read phase
33    /// consumes (see [`crate::rst::ParseOptions::py`]), projected out of the
34    /// build configuration once instead of per document.
35    py: crate::py::PySigConfig,
36    /// The project source directory (see
37    /// [`crate::rst::ParseOptions::srcdir`]): the build path sets it via
38    /// [`Parser::with_srcdir`] so the `include` directive resolves paths
39    /// sphinx-style; a bare `Parser::new` keeps standalone behavior.
40    srcdir: Option<std::path::PathBuf>,
41    /// `source_encoding` (see [`crate::rst::ParseOptions::source_encoding`]).
42    source_encoding: String,
43}
44
45/// Everything one source file's parse produces: the pipeline's [`Document`]
46/// and the doctree it was derived from. The build's read phase keeps both —
47/// the doctree is what the environment layer (tocs, titles, domains) reads,
48/// and what gets persisted per document.
49pub struct ParsedFile {
50    pub document: Document,
51    pub doctree: Doctree,
52}
53
54impl Parser {
55    pub fn new(config: &BuildConfig) -> Result<Self> {
56        Ok(Self {
57            exclude_patterns: config.exclude_patterns.clone(),
58            py: crate::py::PySigConfig::from(config),
59            srcdir: None,
60            source_encoding: config.source_encoding.clone(),
61        })
62    }
63
64    /// Attach the project source directory (sphinx `env.srcdir`), enabling
65    /// the `include` directive's sphinx-mode path resolution and its
66    /// `included`/`dependencies` recording.
67    pub fn with_srcdir(mut self, srcdir: std::path::PathBuf) -> Self {
68        self.srcdir = Some(srcdir);
69        self
70    }
71
72    pub fn parse(&self, file_path: &Path, content: &str, docname: &str) -> Result<Document> {
73        Ok(self.parse_full(file_path, content, docname, None)?.document)
74    }
75
76    /// Parse one source file, keeping the doctree.
77    ///
78    /// `found_docs` is the project's full docname set (sphinx
79    /// `env.found_docs`), which the `toctree` directive resolves its entries
80    /// against; `None` parses without an environment (see
81    /// [`crate::rst::ParseOptions::found_docs`]).
82    pub fn parse_full(
83        &self,
84        file_path: &Path,
85        content: &str,
86        docname: &str,
87        found_docs: Option<Arc<BTreeSet<String>>>,
88    ) -> Result<ParsedFile> {
89        let output_path = self.get_output_path(file_path)?;
90        let mut document = Document::new(file_path.to_path_buf(), output_path);
91
92        // Set source modification time
93        document.source_mtime = utils::get_file_mtime(file_path)?;
94
95        let extension = file_path
96            .extension()
97            .and_then(|ext| ext.to_str())
98            .unwrap_or("");
99
100        let doctree = match extension {
101            "rst" => self.parse_rst_into(content, file_path, docname, found_docs, &mut document),
102            "md" => {
103                document.content = self.parse_markdown(content)?;
104                document.title = "Untitled".to_string();
105                empty_doctree()
106            }
107            _ => {
108                document.content = DocumentContent::PlainText(content.to_string());
109                document.title = "Untitled".to_string();
110                empty_doctree()
111            }
112        };
113
114        debug!(
115            "Parsed document: {} ({} chars)",
116            file_path.display(),
117            content.len()
118        );
119
120        Ok(ParsedFile { document, doctree })
121    }
122
123    /// Returns the parsed doctree so callers (tests included) can inspect
124    /// docname-carrying attrs (`pending_xref[refdoc]`, toctree `parent`)
125    /// that don't otherwise survive onto `Document`.
126    fn parse_rst_into(
127        &self,
128        content: &str,
129        file_path: &Path,
130        docname: &str,
131        found_docs: Option<Arc<BTreeSet<String>>>,
132        document: &mut Document,
133    ) -> Doctree {
134        let output = rst::parse_rst_full(
135            content,
136            &rst::ParseOptions {
137                source_path: file_path.display().to_string(),
138                sphinx: true,
139                docname: docname.to_string(),
140                found_docs,
141                exclude_patterns: self.exclude_patterns.clone(),
142                py: self.py.clone(),
143                srcdir: self.srcdir.clone(),
144                source_encoding: self.source_encoding.clone(),
145            },
146        );
147        {
148            let root = &output.doctree.root;
149
150            // Title: the first section title (the M1 scanner's "Untitled"
151            // fallback preserved).
152            document.title = first_section_title(root).unwrap_or_else(|| "Untitled".to_string());
153
154            // Flat TOC; the builder's stack walk nests by level. Anchors are
155            // the sections' docutils ids (make_id) — a verified Sphinx-parity
156            // improvement over the M1 lowercase/space-hyphen slugs. Line
157            // numbers come from the spans' stamped provenance.
158            let mut toc = Vec::new();
159            collect_toc(root, 1, &mut toc);
160            document.toc = toc;
161
162            // Explicit targets for nitpicky label resolution.
163            let mut labels = Vec::new();
164            collect_labels(root, &mut labels);
165            document.labels = labels;
166        }
167
168        document.toctrees = output.toctrees;
169        document.directive_records = output.directive_records;
170        document.role_records = output.role_records;
171        document.registry = output.registry;
172
173        document.content = DocumentContent::RestructuredText(RstContent {
174            raw: content.to_string(),
175            ast: Vec::new(),
176            directives: Vec::new(),
177        });
178
179        output.doctree
180    }
181
182    fn parse_markdown(&self, content: &str) -> Result<DocumentContent> {
183        let mut nodes = Vec::new();
184        let parser = MarkdownParser::new(content);
185        let current_line = 1;
186
187        for event in parser {
188            match event {
189                Event::Start(Tag::Heading { .. }) => {
190                    // We'll handle this in the text event
191                }
192                Event::End(_) => {
193                    // Handle end tags generically
194                }
195                Event::Start(Tag::Paragraph) => {
196                    // Start of paragraph
197                }
198                Event::Start(Tag::CodeBlock(_)) => {
199                    // Start of code block
200                }
201                Event::Text(text) => {
202                    // Handle text content based on context
203                    nodes.push(MarkdownNode::Paragraph {
204                        content: text.to_string(),
205                        line: current_line,
206                    });
207                }
208                Event::Code(_code) => {
209                    // Inline code
210                }
211                _ => {
212                    // Handle other events as needed
213                }
214            }
215        }
216
217        Ok(DocumentContent::Markdown(MarkdownContent {
218            raw: content.to_string(),
219            ast: nodes,
220            front_matter: None, // TODO: Parse YAML front matter
221        }))
222    }
223
224    fn get_output_path(&self, source_path: &Path) -> Result<std::path::PathBuf> {
225        let mut output_path = source_path.to_path_buf();
226        output_path.set_extension("html");
227        Ok(output_path)
228    }
229}
230
231/// The doctree of a source file this crate has no real parser for yet
232/// (Markdown, plain text): an empty `document`. It is deliberately empty
233/// rather than a guess — the environment layer reading it will report no
234/// sections, no toc entries and no toctrees, which is exactly what this
235/// crate currently knows about such a file.
236fn empty_doctree() -> Doctree {
237    Doctree {
238        root: Node::elem(kinds::DOCUMENT, Span::ZERO),
239        sources: vec!["<document>".to_string()],
240    }
241}
242
243fn first_section_title(root: &Node) -> Option<String> {
244    for child in &root.children {
245        if child.kind == kinds::SECTION {
246            for c in &child.children {
247                if c.kind == kinds::TITLE {
248                    return Some(c.astext());
249                }
250            }
251        }
252    }
253    None
254}
255
256fn collect_toc(node: &Node, level: usize, out: &mut Vec<TocEntry>) {
257    for child in &node.children {
258        if child.kind != kinds::SECTION {
259            continue;
260        }
261        if let Some(title) = child.children.iter().find(|c| c.kind == kinds::TITLE) {
262            out.push(TocEntry {
263                title: title.astext(),
264                level,
265                anchor: child.attrs.ids.first().cloned().unwrap_or_default(),
266                line_number: title.span.line as usize,
267                children: Vec::new(),
268            });
269        }
270        collect_toc(child, level + 1, out);
271    }
272}
273
274fn collect_labels(node: &Node, out: &mut Vec<LabelRecord>) {
275    for child in &node.children {
276        if child.kind == kinds::TARGET && !child.attrs.names.is_empty() {
277            for name in &child.attrs.names {
278                out.push(LabelRecord {
279                    name: name.clone(),
280                    line: child.span.line as usize,
281                });
282            }
283        }
284        collect_labels(child, out);
285    }
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291    use crate::config::BuildConfig;
292    use crate::doctree::AttrValue;
293
294    fn parse_doc(content: &str) -> Document {
295        let parser = Parser::new(&BuildConfig::default()).unwrap();
296        // Bypass mtime lookup by parsing through the internals.
297        let mut document = Document::new("test.rst".into(), "test.html".into());
298        parser.parse_rst_into(content, Path::new("test.rst"), "index", None, &mut document);
299        document
300    }
301
302    /// First node of the given kind, depth-first.
303    fn find_by_kind<'a>(node: &'a Node, kind: &str) -> Option<&'a Node> {
304        if node.kind == kind {
305            return Some(node);
306        }
307        node.children.iter().find_map(|c| find_by_kind(c, kind))
308    }
309
310    #[test]
311    fn hyphenated_directive_is_recorded() {
312        let doc = parse_doc(".. code-block:: python\n\n   x = 1\n");
313        assert_eq!(doc.directive_records.len(), 1);
314        assert_eq!(doc.directive_records[0].name, "code-block");
315        assert_eq!(doc.directive_records[0].arguments, vec!["python"]);
316        assert_eq!(doc.directive_records[0].content, "x = 1");
317        assert_eq!(doc.directive_records[0].line, 1);
318    }
319
320    #[test]
321    fn domain_directive_is_recorded() {
322        let doc = parse_doc(".. py:function:: foo(x)\n\n   Does foo.\n");
323        assert_eq!(doc.directive_records.len(), 1);
324        assert_eq!(doc.directive_records[0].name, "py:function");
325    }
326
327    #[test]
328    fn tab_indented_directive_content_does_not_panic() {
329        let doc = parse_doc(".. note::\n\n\tshort\n");
330        assert_eq!(doc.directive_records.len(), 1);
331        assert_eq!(doc.directive_records[0].content, "short");
332    }
333
334    #[test]
335    fn title_and_toc_from_sections() {
336        let doc = parse_doc("Title\n=====\n\nBody.\n\nSub\n---\n\nMore.\n");
337        assert_eq!(doc.title, "Title");
338        assert_eq!(doc.toc.len(), 2);
339        assert_eq!(doc.toc[0].level, 1);
340        assert_eq!(doc.toc[0].anchor, "title");
341        assert_eq!(doc.toc[1].level, 2);
342        assert_eq!(doc.toc[1].title, "Sub");
343        assert_eq!(doc.toc[1].line_number, 6);
344    }
345
346    #[test]
347    fn toctree_entries_recorded_with_lines() {
348        let doc = parse_doc(
349            "Title\n=====\n\n.. toctree::\n   :maxdepth: 2\n   :glob:\n\n   installation\n   Linked <other>\n",
350        );
351        assert_eq!(doc.toctrees.len(), 1);
352        let t = &doc.toctrees[0];
353        assert!(t.glob);
354        assert_eq!(t.entries.len(), 2);
355        assert_eq!(t.entries[0].target, "installation");
356        assert_eq!(t.entries[0].line, 8);
357        assert_eq!(t.entries[1].title.as_deref(), Some("Linked"));
358        assert_eq!(t.entries[1].target, "other");
359    }
360
361    #[test]
362    fn labels_and_roles_recorded() {
363        let doc =
364            parse_doc(".. _setup-label:\n\nSee :ref:`setup-label` and :doc:`installation`.\n");
365        assert_eq!(doc.labels.len(), 1);
366        assert_eq!(doc.labels[0].name, "setup-label");
367        assert_eq!(doc.labels[0].line, 1);
368        let names: Vec<&str> = doc.role_records.iter().map(|r| r.name.as_str()).collect();
369        assert_eq!(names, vec!["ref", "doc"]);
370        assert_eq!(doc.role_records[0].target, "setup-label");
371        assert_eq!(doc.role_records[0].line, 3);
372    }
373
374    /// Nested docs (e.g. `guide/install.rst`) must carry their real
375    /// root-relative docname into `pending_xref[refdoc]` and the toctree
376    /// `parent` attr — not the bare file stem (`install`).
377    #[test]
378    fn real_docname_threads_into_refdoc_and_toctree_parent() {
379        let parser = Parser::new(&BuildConfig::default()).unwrap();
380        let mut document = Document::new("guide/install.rst".into(), "guide/install.html".into());
381        let doctree = parser.parse_rst_into(
382            ".. toctree::\n\n   other\n\nSee :doc:`other`.\n",
383            Path::new("guide/install.rst"),
384            "guide/install",
385            None,
386            &mut document,
387        );
388
389        let toctree = find_by_kind(&doctree.root, "toctree").expect("toctree node");
390        assert_eq!(
391            toctree.get("parent"),
392            Some(&AttrValue::Str("guide/install".to_string()))
393        );
394
395        let xref = find_by_kind(&doctree.root, "pending_xref").expect("pending_xref node");
396        assert_eq!(
397            xref.get("refdoc"),
398            Some(&AttrValue::Str("guide/install".to_string()))
399        );
400    }
401
402    /// The id registry's nameids table (name -> (id, explicit)) must
403    /// survive parse_document_full as `ParseOutput.registry` — wave 4's
404    /// std-domain label harvest needs it after the registry itself drops.
405    #[test]
406    fn registry_export_carries_nameids_with_explicitness() {
407        let output = rst::parse_rst_full(
408            "Section\n=======\n\n.. _tgt:\n\nBody.\n",
409            &rst::ParseOptions {
410                source_path: "<snippet>".to_string(),
411                sphinx: true,
412                docname: "index".to_string(),
413                exclude_patterns: Vec::new(),
414                py: Default::default(),
415                srcdir: None,
416                found_docs: None,
417                ..Default::default()
418            },
419        );
420
421        let tgt = output
422            .registry
423            .nameids
424            .iter()
425            .find(|(name, _, _)| name == "tgt")
426            .expect("tgt registered");
427        assert_eq!(tgt, &("tgt".to_string(), Some("tgt".to_string()), true));
428
429        let section = output
430            .registry
431            .nameids
432            .iter()
433            .find(|(name, _, _)| name == "section")
434            .expect("section registered");
435        assert_eq!(
436            section,
437            &("section".to_string(), Some("section".to_string()), false)
438        );
439    }
440}