Skip to main content

sphinx_ultra/env/
dependencies.rs

1//! `env.dependencies[docname]` — the files a document is built from besides
2//! its own source, and the reason a build re-reads a document whose source
3//! never changed.
4//!
5//! Port of the `note_dependency` half of Sphinx's asset collectors
6//! (`environment/collectors/asset.py:26-104`, `ImageCollector.process_doc`)
7//! plus the path arithmetic it relies on,
8//! `BuildEnvironment.relfn2path` (`environment/__init__.py:378-400`).
9//! [`crate::env::BuildEnvironment::get_outdated_files`] is the consumer: a
10//! dependency that is missing, or newer than the time the document was
11//! read, makes the document outdated.
12//!
13//! **Scope.** Sphinx notes a dependency for every file-inserting construct:
14//! images, `figure`, `literalinclude`, `include`, `download`, `docutils.conf`
15//! and the gettext catalogs. This crate covers images (`figure` builds one)
16//! by walking the doctree here, and — since wave 4.5 — `include`, whose
17//! targets arrive as parse-time records
18//! ([`crate::rst::RegistryExport::dependencies`], the counterpart of
19//! docutils' `settings.record_dependencies` harvested by sphinx's
20//! `DependenciesCollector`). The remaining producers are a matter of
21//! calling [`note`] or extending the record stream from wherever their
22//! nodes are collected.
23//!
24//! Deliberate omissions, each of which only costs a *warning*, never a
25//! wrong rebuild decision:
26//!
27//! * `image file not readable: %s` — Sphinx stats the image at read time
28//!   and warns; here a missing image simply makes the document outdated on
29//!   every build (the honest consequence of "a dependency is missing"), and
30//!   nothing is said about it.
31//! * `candidates` — Sphinx expands `foo.*` into per-mimetype candidates and
32//!   notes each. Nothing in this crate resolves candidates, so a `*` uri is
33//!   noted as no dependency at all rather than as one file that does not
34//!   exist and would peg the document to a re-read forever.
35
36use std::collections::BTreeSet;
37use std::path::{Path, PathBuf};
38
39use crate::doctree::{kinds, AttrValue, Doctree, Node};
40use crate::utils::relfn2path;
41
42use super::BuildEnvironment;
43
44/// Record every file `docname`'s doctree pulls in, replacing whatever the
45/// previous read of that document left behind.
46///
47/// `parse_dependencies` are the parse-recorded srcdir-relative paths
48/// (`include` targets); the doctree walk contributes the image uris.
49///
50/// Mirrors Sphinx's collector contract: the entry is dropped entirely when
51/// the document depends on nothing, so `dependencies` holds only documents
52/// that have dependencies (Sphinx's `defaultdict(set)` behaves the same way
53/// after `clear_doc`).
54pub fn process_doc(
55    env: &mut BuildEnvironment,
56    docname: &str,
57    doctree: &Doctree,
58    srcdir: &Path,
59    parse_dependencies: &[String],
60) {
61    let mut paths = BTreeSet::new();
62    collect(&doctree.root, docname, srcdir, &mut paths);
63    for dependency in parse_dependencies {
64        // Recorded srcdir-relative (a leading `..` walks outside, like
65        // sphinx's `srcdir / relpath` for an out-of-tree include).
66        paths.insert(srcdir.join(dependency));
67    }
68    if paths.is_empty() {
69        env.dependencies.remove(docname);
70    } else {
71        env.dependencies.insert(docname.to_string(), paths);
72    }
73}
74
75/// `env.note_dependency(filename)`: the absolute path a `uri` seen in
76/// `docname` refers to, or `None` when the uri names no local file.
77///
78/// Skipped, exactly as `ImageCollector.process_doc` skips them: `data:`
79/// URIs (the image is inline), anything with a scheme (`http://…`), and
80/// candidate patterns (see the module note).
81pub fn note(uri: &str, docname: &str, srcdir: &Path) -> Option<PathBuf> {
82    if uri.starts_with("data:") || uri.contains("://") || uri.contains('*') || uri.is_empty() {
83        return None;
84    }
85    Some(relfn2path(uri, docname, srcdir))
86}
87
88/// Walk the doctree for every node that names a file. `figure` needs no
89/// case of its own: it holds the `image` node this matches.
90fn collect(node: &Node, docname: &str, srcdir: &Path, out: &mut BTreeSet<PathBuf>) {
91    if node.kind == kinds::IMAGE {
92        if let Some(AttrValue::Str(uri)) = node.get("uri") {
93            if let Some(path) = note(uri, docname, srcdir) {
94                out.insert(path);
95            }
96        }
97    }
98    for child in &node.children {
99        collect(child, docname, srcdir, out);
100    }
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106    use crate::rst;
107
108    fn deps(docname: &str, source: &str) -> Vec<PathBuf> {
109        let doctree = rst::parse_rst(source, &rst::ParseOptions::default());
110        let mut env = BuildEnvironment::default();
111        process_doc(&mut env, docname, &doctree, Path::new("/src"), &[]);
112        env.dependencies
113            .get(docname)
114            .map(|set| set.iter().cloned().collect())
115            .unwrap_or_default()
116    }
117
118    /// Parse-recorded include dependencies fold in beside the image walk,
119    /// srcdir-joined.
120    #[test]
121    fn parse_recorded_dependencies_join_the_image_walk() {
122        let doctree = rst::parse_rst(".. image:: pic.png\n", &rst::ParseOptions::default());
123        let mut env = BuildEnvironment::default();
124        process_doc(
125            &mut env,
126            "index",
127            &doctree,
128            Path::new("/src"),
129            &["part.rst".to_string(), "data/snippet.txt".to_string()],
130        );
131        let paths: Vec<PathBuf> = env.dependencies["index"].iter().cloned().collect();
132        assert_eq!(
133            paths,
134            vec![
135                PathBuf::from("/src/data/snippet.txt"),
136                PathBuf::from("/src/part.rst"),
137                PathBuf::from("/src/pic.png"),
138            ]
139        );
140    }
141
142    #[test]
143    fn an_image_uri_resolves_against_the_documents_own_directory() {
144        assert_eq!(
145            deps("chapters/intro", ".. image:: pic.png\n"),
146            vec![PathBuf::from("/src/chapters/pic.png")]
147        );
148        assert_eq!(
149            deps("index", ".. image:: img/pic.png\n"),
150            vec![PathBuf::from("/src/img/pic.png")]
151        );
152    }
153
154    #[test]
155    fn a_rooted_uri_resolves_against_the_source_directory() {
156        assert_eq!(
157            deps("chapters/intro", ".. image:: /img/pic.png\n"),
158            vec![PathBuf::from("/src/img/pic.png")]
159        );
160    }
161
162    #[test]
163    fn dot_segments_are_normalized_away() {
164        assert_eq!(
165            deps("chapters/intro", ".. image:: ../img/./pic.png\n"),
166            vec![PathBuf::from("/src/img/pic.png")]
167        );
168    }
169
170    #[test]
171    fn a_figures_image_is_a_dependency_too() {
172        assert_eq!(
173            deps("index", ".. figure:: pic.png\n\n   A caption.\n"),
174            vec![PathBuf::from("/src/pic.png")]
175        );
176    }
177
178    #[test]
179    fn remote_inline_and_candidate_uris_are_not_local_files() {
180        assert!(deps("index", ".. image:: https://example.com/pic.png\n").is_empty());
181        assert!(deps("index", ".. image:: data:image/png;base64,iVBOR\n").is_empty());
182        assert!(
183            deps("index", ".. image:: pic.*\n").is_empty(),
184            "a candidate pattern names no single file; noting it would peg \
185             the document to a re-read on every build"
186        );
187    }
188
189    #[test]
190    fn a_document_without_files_has_no_entry_at_all() {
191        let doctree = rst::parse_rst("Title\n=====\n", &rst::ParseOptions::default());
192        let mut env = BuildEnvironment::default();
193        env.dependencies
194            .insert("index".to_string(), BTreeSet::from([PathBuf::from("/old")]));
195
196        process_doc(&mut env, "index", &doctree, Path::new("/src"), &[]);
197
198        assert!(
199            !env.dependencies.contains_key("index"),
200            "a re-read that finds no dependencies must drop the stale ones"
201        );
202    }
203
204    #[test]
205    fn several_images_all_count_once() {
206        assert_eq!(
207            deps(
208                "index",
209                ".. image:: a.png\n\n.. image:: b.png\n\n.. image:: a.png\n"
210            ),
211            vec![PathBuf::from("/src/a.png"), PathBuf::from("/src/b.png")]
212        );
213    }
214}