1use std::collections::BTreeMap;
70
71use crate::Error;
72
73const SKIPS: [(&str, &str); 4] = [
76 ("<pre", "</pre>"),
77 ("<code", "</code>"),
78 ("<script", "</script>"),
79 ("<style", "</style>"),
80];
81
82pub type Tracked = BTreeMap<String, String>;
86
87pub fn uses_theme(book_toml: &str) -> bool {
95 book_toml.contains("gruvbox/")
96}
97
98pub 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
117pub 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
136pub 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
179fn 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
192fn 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
218fn line_of(text: &str, at: usize) -> usize {
220 text[..at].matches('\n').count() + 1
221}
222
223fn 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}