1use crate::Error;
32
33pub const WIDTH: usize = 72;
35
36fn 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
59fn 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
70pub 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
92pub 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}