Skip to main content

hygiene/
book.rs

1//! The book check: `hygiene book [DIR]` builds the book and fails when
2//! it has a missing include or anchor, or when the build changes a
3//! tracked file.
4//!
5//! # Design
6//!
7//! mdbook does not fail on a bad include. For a missing file it prints
8//! an `ERROR` line, leaves the include line in the text, and exits 0.
9//! For a missing anchor it prints nothing and renders an empty code
10//! block. So `hygiene book` runs `mdbook build`, then checks the log and
11//! each page (01M3T5MG7VVYG4A8YBM75FGSMB):
12//!
13//! | Rule | Finds |
14//! |---|---|
15//! | `mdbook` | An `ERROR` line in the log of mdbook. |
16//! | `include` | An include line, `{{#...}}`, that mdbook left in the text of a page, outside a code block. |
17//! | `empty-code` | A code block with no text. A missing anchor gives one. |
18//! | `tracked` | A tracked file that the build changed. |
19//!
20//! An example of an include in a code block of the book is escaped, as
21//! `\{{#include file.rs:name}}`. mdbook shows it as text in the code
22//! block. The `include` rule skips each code block, so it skips the
23//! example. mdbook also leaves a missing file of a code block as text in
24//! the block, so only the `ERROR` line shows that one.
25//!
26//! ```
27//! let page = "<p>See {{#include gone.rs}}.</p>\n<pre><code></code></pre>";
28//! let rules: Vec<_> = hygiene::book::check_page("a.html", page)
29//!     .into_iter()
30//!     .map(|e| e.rule)
31//!     .collect();
32//! assert_eq!(rules, ["include", "empty-code"]);
33//!
34//! let example = "<pre><code class=\"language-text\">{{#include a.rs:x}}\n</code></pre>";
35//! assert!(hygiene::book::check_page("a.html", example).is_empty());
36//! ```
37//!
38//! # The theme and the tracked files
39//!
40//! The theme of the book, `DIR/gruvbox`, is not in git. `hygiene book`
41//! installs it with `mdbook-gruvbox install DIR` when `book.toml` names
42//! it ([`uses_theme`]) and the directory is missing. A new version of
43//! that tool also writes `book.toml`. `book.toml` is the decision of
44//! the repository, so `hygiene book` puts its bytes back after the
45//! install (01M3W5YW0172EVF2JA8T7WW392).
46//!
47//! The build must not change a tracked file: a session can commit the
48//! change with its item by mistake. `hygiene book` reads the tracked
49//! files that differ from `HEAD` before the install and after the
50//! build ([`Tracked`]), and [`check_tracked`] compares the two. So a
51//! file that a person changed before the build passes, and a file that
52//! the build changed fails. A directory outside a git repository has no
53//! tracked file.
54//!
55//! ```
56//! use hygiene::book::{check_tracked, Tracked};
57//!
58//! let before = Tracked::from([("src/a.rs".to_owned(), "1".to_owned())]);
59//! let mut after = before.clone();
60//! assert!(check_tracked(&before, &after).is_empty());
61//!
62//! after.insert("docs/book.toml".to_owned(), "2".to_owned());
63//! let errors = check_tracked(&before, &after);
64//! assert_eq!(errors.len(), 1);
65//! assert_eq!(errors[0].rule, "tracked");
66//! assert!(errors[0].text.starts_with("docs/book.toml: "));
67//! ```
68
69use std::collections::BTreeMap;
70
71use crate::Error;
72
73/// Regions of a page that the `include` rule skips: code, and the
74/// scripts and styles of the theme.
75const SKIPS: [(&str, &str); 4] = [
76    ("<pre", "</pre>"),
77    ("<code", "</code>"),
78    ("<script", "</script>"),
79    ("<style", "</style>"),
80];
81
82/// The tracked files that differ from `HEAD`: the path of each file from
83/// the top of the repository, and a mark of its content. Two marks are
84/// equal when the content is the same.
85pub type Tracked = BTreeMap<String, String>;
86
87/// True when `book_toml`, the text of a `book.toml`, names a file of the
88/// theme directory `gruvbox`.
89///
90/// ```
91/// assert!(hygiene::book::uses_theme("additional-js = [\"gruvbox/gruvbox.js\"]"));
92/// assert!(!hygiene::book::uses_theme("[book]\ntitle = \"t\"\n"));
93/// ```
94pub fn uses_theme(book_toml: &str) -> bool {
95    book_toml.contains("gruvbox/")
96}
97
98/// A `tracked` error for each tracked file that differs between
99/// `before` and `after`: a file that only one of them has, or a file
100/// with two different marks.
101pub fn check_tracked(before: &Tracked, after: &Tracked) -> Vec<Error> {
102    let names: std::collections::BTreeSet<&String> = before.keys().chain(after.keys()).collect();
103    names
104        .into_iter()
105        .filter(|name| before.get(*name) != after.get(*name))
106        .map(|name| {
107            Error::new(
108                "tracked",
109                format!(
110                    "{name}: the build changed this tracked file. Find the step that writes it."
111                ),
112            )
113        })
114        .collect()
115}
116
117/// An `mdbook` error for each `ERROR` line of the log of mdbook.
118///
119/// ```
120/// let log = " INFO Book building has started\n\
121///            ERROR Error updating \"{{#include gone.rs}}\", Could not read file\n\
122///             WARN Caused By: No such file or directory (os error 2)\n";
123/// let errors = hygiene::book::check_log(log);
124/// assert_eq!(errors.len(), 1);
125/// assert_eq!(errors[0].rule, "mdbook");
126/// assert!(errors[0].text.starts_with("ERROR Error updating"));
127/// ```
128pub fn check_log(log: &str) -> Vec<Error> {
129    log.lines()
130        .map(|line| strip_ansi(line.trim()))
131        .filter(|line| line.starts_with("ERROR"))
132        .map(|line| Error::new("mdbook", line))
133        .collect()
134}
135
136/// The `include` and `empty-code` errors of one page of the book.
137/// `name` names the page in each error, with the line.
138pub fn check_page(name: &str, html: &str) -> Vec<Error> {
139    let mut errors = Vec::new();
140    let mut at = 0;
141    loop {
142        let rest = &html[at..];
143        let include = rest.find("{{#");
144        let skip = SKIPS
145            .iter()
146            .filter_map(|&(open, close)| tag_start(rest, open).map(|i| (i, open, close)))
147            .min_by_key(|&(i, ..)| i);
148        match (include, skip) {
149            (Some(i), skip) if skip.is_none_or(|(s, ..)| i < s) => {
150                let text = rest[i..].lines().next().unwrap_or_default();
151                errors.push(Error::new(
152                    "include",
153                    format!(
154                        "{name}:{}: mdbook left an include line in the text: {}",
155                        line_of(html, at + i),
156                        text.trim()
157                    ),
158                ));
159                at += i + 3;
160            }
161            (_, Some((s, open, close))) => {
162                let end = rest[s..].find(close).map_or(rest.len(), |e| s + e);
163                if open == "<pre" && is_empty(&rest[s..end]) {
164                    errors.push(Error::new(
165                        "empty-code",
166                        format!(
167                            "{name}:{}: a code block has no text. Check the anchor of its include.",
168                            line_of(html, at + s)
169                        ),
170                    ));
171                }
172                at += (end + close.len()).min(rest.len());
173            }
174            _ => return errors,
175        }
176    }
177}
178
179/// The start of the first tag `open` in `text`: `open` and then `>` or
180/// a space, so that `<pre` does not match `<preview`.
181fn tag_start(text: &str, open: &str) -> Option<usize> {
182    text.match_indices(open)
183        .find(|&(i, _)| {
184            text[i + open.len()..]
185                .chars()
186                .next()
187                .is_some_and(|c| c == '>' || c.is_whitespace())
188        })
189        .map(|(i, _)| i)
190}
191
192/// True when a `<pre>` block, from `<pre` to before `</pre>`, has no
193/// text. The hidden lines of a Rust block (`<span class="boring">`) and
194/// the tags do not count.
195fn is_empty(block: &str) -> bool {
196    let Some(open_end) = block.find('>') else {
197        return true;
198    };
199    let mut body = block[open_end + 1..].to_owned();
200    while let Some(s) = body.find("<span class=\"boring\">") {
201        let e = body[s..].find("</span>").map_or(body.len(), |e| s + e + 7);
202        body.replace_range(s..e, "");
203    }
204    let mut in_tag = false;
205    !body.chars().any(|c| match c {
206        '<' => {
207            in_tag = true;
208            false
209        }
210        '>' => {
211            in_tag = false;
212            false
213        }
214        c => !in_tag && !c.is_whitespace(),
215    })
216}
217
218/// The line number of byte `at` in `text`, from 1.
219fn line_of(text: &str, at: usize) -> usize {
220    text[..at].matches('\n').count() + 1
221}
222
223/// `line` with no ANSI color codes.
224fn strip_ansi(line: &str) -> String {
225    let mut out = String::with_capacity(line.len());
226    let mut chars = line.chars();
227    while let Some(c) = chars.next() {
228        if c == '\u{1b}' {
229            for c in chars.by_ref() {
230                if c.is_ascii_alphabetic() {
231                    break;
232                }
233            }
234        } else {
235            out.push(c);
236        }
237    }
238    out
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244
245    fn rules(html: &str) -> Vec<&'static str> {
246        check_page("a.html", html)
247            .into_iter()
248            .map(|e| e.rule)
249            .collect()
250    }
251
252    fn tracked(files: &[(&str, &str)]) -> Tracked {
253        files
254            .iter()
255            .map(|&(name, mark)| (name.to_owned(), mark.to_owned()))
256            .collect()
257    }
258
259    fn names(errors: &[Error]) -> Vec<&str> {
260        errors
261            .iter()
262            .map(|e| e.text.split(':').next().unwrap())
263            .collect()
264    }
265
266    #[test]
267    fn a_file_that_the_build_does_not_change_passes() {
268        let before = tracked(&[("a.rs", "1"), ("docs/book.toml", "2")]);
269        assert!(check_tracked(&before, &before.clone()).is_empty());
270        assert!(check_tracked(&Tracked::new(), &Tracked::new()).is_empty());
271    }
272
273    #[test]
274    fn a_file_that_the_build_changes_fails() {
275        let before = tracked(&[("a.rs", "1")]);
276        let after = tracked(&[("a.rs", "1"), ("docs/book.toml", "2")]);
277        let errors = check_tracked(&before, &after);
278        assert_eq!(names(&errors), ["docs/book.toml"]);
279        assert_eq!(errors[0].rule, "tracked");
280    }
281
282    #[test]
283    fn a_changed_file_that_the_build_changes_again_fails() {
284        let before = tracked(&[("a.rs", "1"), ("b.rs", "1")]);
285        let after = tracked(&[("a.rs", "1"), ("b.rs", "2")]);
286        assert_eq!(names(&check_tracked(&before, &after)), ["b.rs"]);
287    }
288
289    #[test]
290    fn a_changed_file_that_the_build_puts_back_fails() {
291        let before = tracked(&[("a.rs", "1"), ("b.rs", "1")]);
292        let after = tracked(&[("b.rs", "1"), ("c.rs", "1")]);
293        assert_eq!(names(&check_tracked(&before, &after)), ["a.rs", "c.rs"]);
294    }
295
296    #[test]
297    fn only_a_book_toml_that_names_a_theme_file_uses_the_theme() {
298        assert!(uses_theme(
299            "additional-css = [\"gruvbox/css/variables.css\"]\n"
300        ));
301        assert!(!uses_theme("default-theme = \"gruvbox\"\n"));
302    }
303
304    #[test]
305    fn an_include_in_the_text_fails_with_its_line() {
306        let errors = check_page(
307            "a.html",
308            "<p>one</p>\n<p>Text {{#include gone.rs}} here.</p>",
309        );
310        assert_eq!(errors.len(), 1);
311        assert_eq!(errors[0].rule, "include");
312        assert!(
313            errors[0].text.starts_with("a.html:2: "),
314            "{}",
315            errors[0].text
316        );
317        assert!(errors[0].text.contains("{{#include gone.rs}}"));
318    }
319
320    #[test]
321    fn each_include_in_the_text_fails() {
322        assert_eq!(
323            rules("<p>{{#include a.rs}}</p><p>{{#rustdoc_include b.rs}}</p>"),
324            ["include", "include"]
325        );
326    }
327
328    #[test]
329    fn an_include_in_code_passes() {
330        assert!(
331            rules("<pre><code class=\"language-text\">{{#include a.rs:x}}\n</code></pre>")
332                .is_empty()
333        );
334        assert!(rules("<p>Use <code>{{#include a.rs}}</code>.</p>").is_empty());
335    }
336
337    #[test]
338    fn an_include_after_code_fails() {
339        assert_eq!(
340            rules("<pre><code>x</code></pre><p>{{#include a.rs}}</p>"),
341            ["include"]
342        );
343    }
344
345    #[test]
346    fn a_script_or_a_style_is_skipped() {
347        assert!(rules("<script>let a = \"{{#x}}\";</script><style>a{}</style>").is_empty());
348    }
349
350    #[test]
351    fn an_empty_rust_block_fails() {
352        let html = "<pre class=\"playground\"><code class=\"language-rust\"><span class=\"boring\">#![allow(unused)]\n\
353                    </span><span class=\"boring\">fn main() {\n</span>\n<span class=\"boring\">}</span></code></pre>";
354        assert_eq!(rules(html), ["empty-code"]);
355    }
356
357    #[test]
358    fn an_empty_text_block_fails() {
359        assert_eq!(
360            rules("<pre><code class=\"language-text\">\n</code></pre>"),
361            ["empty-code"]
362        );
363        assert_eq!(rules("<pre class=\"mermaid\"></pre>"), ["empty-code"]);
364    }
365
366    #[test]
367    fn a_block_with_text_passes() {
368        let html = "<pre class=\"playground\"><code class=\"language-rust\"><span class=\"boring\">fn main() {\n</span>let a = 1;\n<span class=\"boring\">}</span></code></pre>";
369        assert!(rules(html).is_empty());
370        assert!(rules("<pre class=\"mermaid\">flowchart LR\n a --> b</pre>").is_empty());
371    }
372
373    #[test]
374    fn a_tag_that_starts_like_pre_is_not_code() {
375        assert_eq!(rules("<preview>{{#include a.rs}}</preview>"), ["include"]);
376    }
377
378    #[test]
379    fn an_open_block_with_no_end_stops_the_scan() {
380        assert!(rules("<pre><code>x").is_empty());
381    }
382
383    #[test]
384    fn the_log_check_reads_colored_lines() {
385        let log = "\u{1b}[31mERROR\u{1b}[0m Error updating \"{{#include a.rs}}\"\n INFO done\n";
386        let errors = check_log(log);
387        assert_eq!(errors.len(), 1);
388        assert_eq!(errors[0].text, "ERROR Error updating \"{{#include a.rs}}\"");
389        assert!(check_log(" INFO Book building has started\n WARN Caused By: x\n").is_empty());
390    }
391}