Skip to main content

hygiene/
wrap.rs

1//! The wrap check: `hygiene wrap [DIR]` fails when a prose line of a
2//! Markdown file in DIR is longer than 72 characters.
3//!
4//! # Design
5//!
6//! Each page of the book and the requirements wraps at [`WIDTH`]
7//! (01M3MNT28NXA9VRST119QR8AQK). `just wrap` runs `hygiene wrap
8//! docs/src`, and `just ci` runs it for each diff, also for a diff of
9//! only text (01M3WNN836EFG7GJQKZRTSK5FT). So the check is not a test
10//! of a crate: a change of the book needs no build of riff.
11//!
12//! The rule `wrap` finds only a line that a wrap can make shorter. It
13//! skips:
14//!
15//! - a line in a code block, a row of a table, and a heading;
16//! - a line with one word after its list marker. A link and a code
17//!   span are each one word, also with the text that follows them with
18//!   no space.
19//!
20//! ```
21//! let prose = "word ".repeat(15);
22//! let errors = hygiene::wrap::check("a.md", &prose);
23//! assert_eq!(errors.len(), 1);
24//! assert_eq!(errors[0].rule, "wrap");
25//! assert!(errors[0].text.starts_with("a.md:1: the line has 75 characters"));
26//!
27//! let link = format!("- [{}](page.md#part).", "a b ".repeat(20));
28//! assert!(hygiene::wrap::check("a.md", &link).is_empty());
29//! ```
30
31use crate::Error;
32
33/// The wrap width of the book.
34pub const WIDTH: usize = 72;
35
36/// The words of `line` that a wrap can put on a new line. A link and a
37/// code span are each one word, also with the text that follows them
38/// with no space.
39fn words(line: &str) -> usize {
40    let mut count = 0;
41    let mut rest = line.trim();
42    while !rest.is_empty() {
43        let end = if rest.starts_with('[') {
44            rest.find("](")
45                .and_then(|i| rest[i..].find(')').map(|j| i + j + 1))
46        } else if let Some(code) = rest.strip_prefix('`') {
47            code.find('`').map(|i| i + 2)
48        } else {
49            None
50        }
51        .unwrap_or(0);
52        let end = end + rest[end..].find(' ').unwrap_or(rest.len() - end);
53        count += 1;
54        rest = rest[end..].trim_start();
55    }
56    count
57}
58
59/// The text of `line` after its list marker, `- ` or `1. `.
60fn item_text(line: &str) -> &str {
61    if let Some(rest) = line.strip_prefix("- ") {
62        return rest;
63    }
64    match line.split_once(". ") {
65        Some((n, rest)) if !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()) => rest,
66        _ => line,
67    }
68}
69
70/// The lines of `text` that are over [`WIDTH`] and that a wrap can
71/// make shorter: prose outside a code block, a table and a heading.
72/// Each line comes with its number, from 1.
73pub fn long_lines(text: &str) -> Vec<(usize, &str)> {
74    let mut code = false;
75    let mut long = Vec::new();
76    for (n, line) in text.lines().enumerate() {
77        let trimmed = line.trim();
78        if trimmed.starts_with("```") {
79            code = !code;
80            continue;
81        }
82        if code || trimmed.starts_with('|') || trimmed.starts_with('#') {
83            continue;
84        }
85        if line.chars().count() > WIDTH && words(item_text(trimmed)) > 1 {
86            long.push((n + 1, line));
87        }
88    }
89    long
90}
91
92/// The errors of the Markdown file `name` with the content `text`: one
93/// for each line of [`long_lines`].
94pub fn check(name: &str, text: &str) -> Vec<Error> {
95    long_lines(text)
96        .into_iter()
97        .map(|(n, line)| {
98            Error::new(
99                "wrap",
100                format!(
101                    "{name}:{n}: the line has {} characters. Wrap it at {WIDTH}: {line}",
102                    line.chars().count()
103                ),
104            )
105        })
106        .collect()
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112
113    #[test]
114    fn a_link_and_a_code_span_are_one_word() {
115        assert_eq!(words("[Start a Riff](start-a-riff.md#you-need)."), 1);
116        assert_eq!(words("`riff://USER@HOST/-?session=ID`."), 1);
117        assert_eq!(words("see [Join a Riff](join-a-riff.md) now"), 3);
118    }
119
120    #[test]
121    fn only_prose_that_a_wrap_can_break_is_long() {
122        let over = "word ".repeat(15);
123        let link = format!("[{}](page.md)", "a b ".repeat(20));
124        let text = format!("{over}\n```sh\n{over}\n```\n| {over} |\n# {over}\n{link}\n1. {link}\n");
125        assert_eq!(long_lines(&text), vec![(1, over.as_str())]);
126    }
127
128    #[test]
129    fn a_line_of_72_characters_passes_and_73_fails() {
130        let at = format!("{} b", "a".repeat(70));
131        assert_eq!(at.chars().count(), WIDTH);
132        assert!(check("a.md", &at).is_empty());
133        let over = format!("{} b", "a".repeat(71));
134        let errors = check("a.md", &format!("ok\n{over}\n"));
135        assert_eq!(errors.len(), 1);
136        assert_eq!(
137            errors[0].to_string(),
138            format!("wrap: a.md:2: the line has 73 characters. Wrap it at 72: {over}")
139        );
140    }
141
142    #[test]
143    fn the_width_counts_characters_not_bytes() {
144        let line = format!("{} é", "é".repeat(70));
145        assert!(line.len() > WIDTH);
146        assert!(check("a.md", &line).is_empty());
147    }
148}