Skip to main content

hygiene/
versions.rs

1//! The versions check: `hygiene versions [ROOT]` fails when a text names
2//! a version of riff.
3//!
4//! # Design
5//!
6//! A text, a rustdoc line and a page of the book describe the riff of
7//! today. A version name in them ages: it was right on the day of the
8//! release, and wrong on the next major release. So the code, its
9//! tests and the book name no version of riff
10//! (01M5R2XVDN99TE9NEJYY1GJE91, 01M5R2XVGMTB2HC9PRMS85ZWTY).
11//! `just check` runs `hygiene versions`.
12//!
13//! The rule `versions` finds each name of the form `riff N` or
14//! `riff N.M`, also `riff-server N`, in each file of these folders
15//! under ROOT (the default is `.`), but in
16//! `docs/src/requirements-history.md`: a requirement that a new one
17//! superseded in full moved there with its text unchanged, and its
18//! history names versions. `docs/src/requirements.md` holds the rules of
19//! today, so the check reads it.
20//! It finds a name that a line break splits too: `riff` at the end of
21//! a line, and the number at the start of the next one.
22//!
23//! - `crates/*/src`
24//! - `crates/*/tests`
25//! - `docs/src`
26//!
27//! A use that must name a version stays. It is a line in the allow
28//! list `crates/hygiene/versions.allow`:
29//!
30//! ```text
31//! PATH | TEXT | REASON
32//! ```
33//!
34//! - PATH is the path of the file from ROOT.
35//! - TEXT is a part of a line of that file. It holds the version name.
36//!   The line is allowed for each name in TEXT.
37//! - REASON says why the name stays. The one allowed kind: a rule between
38//!   versions, with its real text.
39//!
40//! A line of the list that allows nothing is an error: the list holds
41//! no old entry. A line that starts with `#` is a comment.
42//!
43//! ```mermaid
44//! flowchart TD
45//!     F["each file of crates/*/src, crates/*/tests, docs/src"] --> L{"a line with<br/>riff N or riff N.M?"}
46//!     L -- no --> OK["pass"]
47//!     L -- yes --> A{"an allow line of the file<br/>holds that name?"}
48//!     A -- yes --> OK
49//!     A -- no --> E["error: rewrite the line,<br/>or allow it"]
50//! ```
51//!
52//! ```
53//! use hygiene::versions::{check, parse_allow};
54//!
55//! // The sample is built, so that this file names no version.
56//! let line = format!("riff {} runs on Linux only.", "3.0");
57//! let file = ("crates/a/src/text.rs".to_owned(), format!("{line}\n"));
58//! let errors = check(&[file.clone()], &[]);
59//! assert_eq!(errors.len(), 1);
60//! assert_eq!(errors[0].rule, "versions");
61//! assert!(errors[0].text.starts_with("crates/a/src/text.rs:1: "));
62//!
63//! let (allow, problems) = parse_allow(&format!(
64//!     "crates/a/src/text.rs | riff {} runs | the rule of a release\n",
65//!     "3.0"
66//! ));
67//! assert!(problems.is_empty());
68//! assert!(check(&[file], &allow).is_empty());
69//! ```
70
71use std::io;
72use std::ops::Range;
73use std::path::Path;
74
75use crate::Error;
76
77/// The file of the allow list, from the root.
78pub const ALLOW_FILE: &str = "crates/hygiene/versions.allow";
79
80/// The page that the check skips: the history of the requirements
81/// names versions.
82pub const SKIPPED: &str = "docs/src/requirements-history.md";
83
84/// A line of the allow list.
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub struct Allowed {
87    /// The line of the list, from 1.
88    pub at: usize,
89    /// The path of the file from the root.
90    pub path: String,
91    /// A part of a line of the file, with the version name.
92    pub text: String,
93    /// Why the name stays.
94    pub reason: String,
95}
96
97fn word_byte(b: u8) -> bool {
98    b.is_ascii_alphanumeric() || b == b'_'
99}
100
101/// The version names in `line`, each as the range of its bytes: `riff`
102/// or `riff-server`, a space, and a number `N` or `N.M` with any
103/// number of parts. A sentence dot after the number is not part of it.
104///
105/// ```
106/// use hygiene::versions::names;
107///
108/// // The sample is built, so that this file names no version.
109/// let line = format!("riff {} and riff-server {}.", 3, "4.1.1");
110/// let found: Vec<&str> = names(&line).into_iter().map(|r| &line[r]).collect();
111/// assert_eq!(found, [format!("riff {}", 3), format!("riff-server {}", "4.1.1")]);
112/// assert!(names("riff 3x, riff more, a riff 2nd").is_empty());
113/// ```
114pub fn names(line: &str) -> Vec<Range<usize>> {
115    let bytes = line.as_bytes();
116    let mut found = Vec::new();
117    let mut from = 0;
118    while let Some(offset) = line[from..].find("riff") {
119        let start = from + offset;
120        from = start + 4;
121        if start > 0 && (word_byte(bytes[start - 1]) || bytes[start - 1] == b'-') {
122            continue;
123        }
124        let mut at = start + 4;
125        if line[at..].starts_with("-server") {
126            at += 7;
127        }
128        if bytes.get(at) != Some(&b' ') {
129            continue;
130        }
131        at += 1;
132        let digits = |from: usize| {
133            bytes[from..]
134                .iter()
135                .take_while(|b| b.is_ascii_digit())
136                .count()
137        };
138        let mut n = digits(at);
139        if n == 0 {
140            continue;
141        }
142        at += n;
143        while bytes.get(at) == Some(&b'.') {
144            n = digits(at + 1);
145            if n == 0 {
146                break;
147            }
148            at += 1 + n;
149        }
150        if bytes.get(at).is_some_and(|b| word_byte(*b)) {
151            continue;
152        }
153        found.push(start..at);
154        from = at;
155    }
156    found
157}
158
159/// The lines of the allow list `source`, and the errors in it: a line
160/// that is not `PATH | TEXT | REASON`, or that has an empty part.
161pub fn parse_allow(source: &str) -> (Vec<Allowed>, Vec<Error>) {
162    let mut allow = Vec::new();
163    let mut errors = Vec::new();
164    for (n, line) in source.lines().enumerate() {
165        if line.trim().is_empty() || line.starts_with('#') {
166            continue;
167        }
168        let parts: Vec<&str> = line.splitn(3, " | ").map(str::trim).collect();
169        match parts[..] {
170            [path, text, reason] if ![path, text, reason].contains(&"") => allow.push(Allowed {
171                at: n + 1,
172                path: path.to_owned(),
173                text: text.to_owned(),
174                reason: reason.to_owned(),
175            }),
176            _ => errors.push(Error::new(
177                "versions",
178                format!(
179                    "{ALLOW_FILE}:{}: the line is not `PATH | TEXT | REASON`",
180                    n + 1
181                ),
182            )),
183        }
184    }
185    (allow, errors)
186}
187
188/// True when `allow` has a line for `path` whose text holds the range
189/// `name` of `line`.
190fn allowed(
191    allow: &[Allowed],
192    used: &mut [bool],
193    path: &str,
194    line: &str,
195    name: &Range<usize>,
196) -> bool {
197    let mut covered = false;
198    for (entry, used) in allow.iter().zip(used) {
199        if entry.path != path {
200            continue;
201        }
202        let holds = line
203            .match_indices(&entry.text)
204            .any(|(at, text)| at <= name.start && name.end <= at + text.len());
205        *used |= holds;
206        covered |= holds;
207    }
208    covered
209}
210
211/// A name that a line break splits: `line` ends with `riff` or
212/// `riff-server`, and `next` starts with the number, after the mark of a
213/// comment. It gives the two lines as one, and the range of the name in
214/// it. A line that ends with two spaces and a backslash, in a Rust
215/// string, keeps the two spaces: the name is not split.
216fn split_name(line: &str, next: &str) -> Option<(String, Range<usize>)> {
217    let body = line.trim_end();
218    let body = body.strip_suffix('\\').unwrap_or(body);
219    if body.ends_with("  ") {
220        return None;
221    }
222    let head = body.trim_end();
223    let next = ["///", "//!", "//", ">"]
224        .iter()
225        .find_map(|mark| next.trim_start().strip_prefix(mark))
226        .unwrap_or(next)
227        .trim_start();
228    let joined = format!("{head} {next}");
229    names(&joined)
230        .into_iter()
231        .find(|name| name.start < head.len() && name.end > head.len())
232        .map(|name| (joined, name))
233}
234
235/// The errors of the files `files` (each with its path from the root
236/// and its text) against the list `allow`: one for each line with a
237/// version name that no line of the list allows, and one for each line
238/// of the list that allows no name.
239pub fn check(files: &[(String, String)], allow: &[Allowed]) -> Vec<Error> {
240    let mut used = vec![false; allow.len()];
241    let mut errors = Vec::new();
242    for (path, text) in files {
243        let lines: Vec<&str> = text.lines().collect();
244        for (n, &line) in lines.iter().enumerate() {
245            let mut open: Vec<String> = names(line)
246                .into_iter()
247                .filter(|name| !allowed(allow, &mut used, path, line, name))
248                .map(|name| line[name].to_owned())
249                .collect();
250            let split = lines.get(n + 1).and_then(|next| split_name(line, next));
251            if let Some((joined, name)) = split
252                && !allowed(allow, &mut used, path, &joined, &name)
253            {
254                open.push(joined[name].to_owned());
255            }
256            if !open.is_empty() {
257                errors.push(Error::new(
258                    "versions",
259                    format!(
260                        "{path}:{}: the line names {}. Say what riff does, with no version. \
261                         A name that must stay is a line of {ALLOW_FILE}: {}",
262                        n + 1,
263                        open.iter()
264                            .map(|name| format!("\"{name}\""))
265                            .collect::<Vec<_>>()
266                            .join(", "),
267                        line.trim()
268                    ),
269                ));
270            }
271        }
272    }
273    for (entry, used) in allow.iter().zip(used) {
274        if !used {
275            errors.push(Error::new(
276                "versions",
277                format!(
278                    "{ALLOW_FILE}:{}: no line of {} has a version name in \"{}\". Remove the line.",
279                    entry.at, entry.path, entry.text
280                ),
281            ));
282        }
283    }
284    errors
285}
286
287/// The files that the check reads under `root`, each with its path
288/// from `root`, in path order: each file in `crates/*/src`,
289/// `crates/*/tests` and `docs/src`, but [`SKIPPED`] and a file that is
290/// not UTF-8.
291pub fn scan(root: &Path) -> io::Result<Vec<(String, String)>> {
292    let mut dirs = vec![root.join("docs/src")];
293    if let Ok(crates) = std::fs::read_dir(root.join("crates")) {
294        for krate in crates {
295            let krate = krate?.path();
296            dirs.push(krate.join("src"));
297            dirs.push(krate.join("tests"));
298        }
299    }
300    let mut paths = Vec::new();
301    for dir in dirs {
302        files(&dir, &mut paths)?;
303    }
304    let mut found = Vec::new();
305    for path in paths {
306        let Ok(relative) = path.strip_prefix(root) else {
307            continue;
308        };
309        let name = relative
310            .components()
311            .map(|c| c.as_os_str().to_string_lossy())
312            .collect::<Vec<_>>()
313            .join("/");
314        if name == SKIPPED {
315            continue;
316        }
317        if let Ok(text) = std::fs::read_to_string(&path) {
318            found.push((name, text));
319        }
320    }
321    found.sort();
322    Ok(found)
323}
324
325/// Adds each file in `dir` and its subfolders to `paths`. A folder
326/// that is not there has no file.
327fn files(dir: &Path, paths: &mut Vec<std::path::PathBuf>) -> io::Result<()> {
328    let Ok(entries) = std::fs::read_dir(dir) else {
329        return Ok(());
330    };
331    for entry in entries {
332        let path = entry?.path();
333        if path.is_dir() {
334            files(&path, paths)?;
335        } else {
336            paths.push(path);
337        }
338    }
339    Ok(())
340}
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345
346    /// A name of the form that the check refuses, built so that this
347    /// file names no version.
348    fn riff(version: &str) -> String {
349        format!("riff {version}")
350    }
351
352    fn found(line: &str) -> Vec<String> {
353        names(line)
354            .into_iter()
355            .map(|r| line[r].to_owned())
356            .collect()
357    }
358
359    #[test]
360    fn a_name_has_riff_or_riff_server_a_space_and_a_number() {
361        for version in ["1", "3.0", "4.1.1", "10.20"] {
362            let name = riff(version);
363            assert_eq!(found(&format!("The {name} runs.")), [name.as_str()]);
364            assert_eq!(found(&format!("{name}.")), [name]);
365            let server = format!("riff-server {version}");
366            assert_eq!(found(&format!("({server})")), [server]);
367        }
368    }
369
370    #[test]
371    fn a_number_with_a_letter_after_it_is_not_a_version() {
372        assert_eq!(found(&format!("{}.x line", riff("1"))), [riff("1")]);
373        assert!(found(&format!("{}x", riff("1"))).is_empty());
374        assert!(found(&format!("{}_a", riff("1"))).is_empty());
375        assert!(found("riff 1a, riff  2, riff, riff-core 3, riff-3.md").is_empty());
376        let (a, b) = (riff("3"), riff("3"));
377        assert!(found(&format!("my{a}, the-{b}")).is_empty());
378    }
379
380    #[test]
381    fn a_sentence_dot_is_not_part_of_the_number() {
382        assert_eq!(found(&format!("{}. Next", riff("3"))), [riff("3")]);
383        assert_eq!(found(&format!("{}..", riff("3.0"))), [riff("3.0")]);
384    }
385
386    #[test]
387    fn each_name_of_a_line_counts() {
388        let server = format!("riff-server {}", 1);
389        let line = format!("{} and {} and {server}", riff("1.5"), riff("3"));
390        assert_eq!(found(&line), [riff("1.5"), riff("3"), server]);
391    }
392
393    fn file(text: &str) -> Vec<(String, String)> {
394        vec![("crates/a/src/a.rs".to_owned(), text.to_owned())]
395    }
396
397    #[test]
398    fn a_name_that_a_line_break_splits_fails() {
399        for (first, second) in [
400            ("/// A post of riff", "/// 1.8.1 is not there."),
401            ("//! The plugin of riff", "//!    3 is left."),
402            ("the riff", "   3.0 runs here."),
403            ("> the riff", "> 3.0 runs here."),
404            ("\"runs on riff \\", "     3.0 only\""),
405            ("// riff-server", "// 4.1 is the server."),
406        ] {
407            let text = format!("{first}\n{second}\n");
408            let errors = check(&file(&text), &[]);
409            assert_eq!(errors.len(), 1, "{text:?}: {errors:?}");
410            assert!(
411                errors[0].text.starts_with("crates/a/src/a.rs:1: "),
412                "{errors:?}"
413            );
414        }
415        let text = "the riff\nis here.\nriff\nand 3 more.\n";
416        assert!(check(&file(text), &[]).is_empty());
417        let text = "let a = \"riff  \\\n      4 sessions\";\n";
418        assert!(
419            check(&file(text), &[]).is_empty(),
420            "two spaces are not a split"
421        );
422    }
423
424    #[test]
425    fn a_text_of_the_allow_list_allows_a_name_that_a_line_break_splits() {
426        let text = format!("The plugin of riff\n{} is left.\n", "3");
427        let (allow, _) = parse_allow(&format!(
428            "crates/a/src/a.rs | plugin of {} | the old plugin\n",
429            riff("3")
430        ));
431        assert!(check(&file(&text), &allow).is_empty());
432        let (other, _) = parse_allow(&format!(
433            "crates/a/src/a.rs | file of {} | the old file\n",
434            riff("3")
435        ));
436        assert_eq!(check(&file(&text), &other).len(), 2);
437    }
438
439    #[test]
440    fn a_line_with_a_name_fails_and_names_the_file_and_the_line() {
441        let text = format!("fine\n/// {} runs on Linux only.\n", riff("3.0"));
442        let errors = check(&file(&text), &[]);
443        assert_eq!(errors.len(), 1);
444        assert_eq!(
445            errors[0].to_string(),
446            format!(
447                "versions: crates/a/src/a.rs:2: the line names \"{}\". Say what riff does, with \
448                 no version. A name that must stay is a line of {ALLOW_FILE}: /// {} runs on \
449                 Linux only.",
450                riff("3.0"),
451                riff("3.0")
452            )
453        );
454    }
455
456    #[test]
457    fn an_allowed_text_passes_in_its_file_only() {
458        let line = format!("The plugin of {} stays.", riff("3"));
459        let (allow, problems) = parse_allow(&format!(
460            "crates/a/src/a.rs | plugin of {} | the old plugin\n",
461            riff("3")
462        ));
463        assert!(problems.is_empty());
464        assert!(check(&file(&line), &allow).is_empty());
465        let other = vec![("crates/a/src/b.rs".to_owned(), line)];
466        let rules: Vec<_> = check(&other, &allow)
467            .iter()
468            .map(|e| e.text.clone())
469            .collect();
470        assert_eq!(
471            rules.len(),
472            2,
473            "the name fails, and the allow line is unused: {rules:?}"
474        );
475    }
476
477    #[test]
478    fn an_allowed_text_does_not_allow_another_name_on_its_line() {
479        let line = format!("{} and the plugin of {}.", riff("3.0"), riff("3"));
480        let (allow, _) = parse_allow(&format!(
481            "crates/a/src/a.rs | plugin of {} | the old plugin\n",
482            riff("3")
483        ));
484        let errors = check(&file(&line), &allow);
485        assert_eq!(errors.len(), 1, "{errors:?}");
486        assert!(
487            errors[0].text.contains(&format!("\"{}\"", riff("3.0"))),
488            "{errors:?}"
489        );
490    }
491
492    #[test]
493    fn a_text_allows_each_line_that_holds_it() {
494        let line = format!("The plugin of {}.", riff("3"));
495        let (allow, _) = parse_allow(&format!(
496            "crates/a/src/a.rs | plugin of {} | the old plugin\n",
497            riff("3")
498        ));
499        assert!(check(&file(&format!("{line}\n{line}\n")), &allow).is_empty());
500    }
501
502    #[test]
503    fn a_line_of_the_list_that_allows_nothing_fails() {
504        let (allow, _) = parse_allow("crates/a/src/a.rs | a name | a reason\n");
505        let errors = check(&file("no name here\n"), &allow);
506        assert_eq!(errors.len(), 1);
507        assert_eq!(
508            errors[0].to_string(),
509            format!(
510                "versions: {ALLOW_FILE}:1: no line of crates/a/src/a.rs has a version name in \
511                 \"a name\". Remove the line."
512            )
513        );
514    }
515
516    #[test]
517    fn the_list_has_three_parts_in_each_line() {
518        let source = "# a comment\n\ncrates/a | text | reason\nonly two | parts\n\
519                      crates/a |  | no text\ncrates/a | text | reason | with | pipes\n";
520        let (allow, problems) = parse_allow(source);
521        assert_eq!(allow.len(), 2);
522        assert_eq!(allow[0].at, 3);
523        assert_eq!(allow[1].reason, "reason | with | pipes");
524        let lines: Vec<_> = problems.iter().map(|e| e.text.as_str()).collect();
525        assert_eq!(
526            lines,
527            [
528                format!("{ALLOW_FILE}:4: the line is not `PATH | TEXT | REASON`"),
529                format!("{ALLOW_FILE}:5: the line is not `PATH | TEXT | REASON`"),
530            ]
531        );
532    }
533
534    #[test]
535    fn scan_reads_the_folders_of_the_check_and_skips_the_requirements_history() {
536        let root = tempfile::tempdir().unwrap();
537        for (path, text) in [
538            ("crates/a/src/lib.rs", "a"),
539            ("crates/a/tests/t/b.rs", "b"),
540            ("crates/a/other/c.rs", "not read"),
541            ("docs/src/page.md", "p"),
542            ("docs/src/requirements.md", "r"),
543            ("docs/src/requirements-history.md", "not read"),
544            ("docs/book.toml", "not read"),
545        ] {
546            let path = root.path().join(path);
547            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
548            std::fs::write(path, text).unwrap();
549        }
550        std::fs::write(root.path().join("docs/src/bytes.bin"), [0xff, 0xfe]).unwrap();
551        let paths: Vec<_> = scan(root.path())
552            .unwrap()
553            .into_iter()
554            .map(|f| f.0)
555            .collect();
556        assert_eq!(
557            paths,
558            [
559                "crates/a/src/lib.rs",
560                "crates/a/tests/t/b.rs",
561                "docs/src/page.md",
562                "docs/src/requirements.md"
563            ]
564        );
565    }
566}