1use std::io;
72use std::ops::Range;
73use std::path::Path;
74
75use crate::Error;
76
77pub const ALLOW_FILE: &str = "crates/hygiene/versions.allow";
79
80pub const SKIPPED: &str = "docs/src/requirements-history.md";
83
84#[derive(Debug, Clone, PartialEq, Eq)]
86pub struct Allowed {
87 pub at: usize,
89 pub path: String,
91 pub text: String,
93 pub reason: String,
95}
96
97fn word_byte(b: u8) -> bool {
98 b.is_ascii_alphanumeric() || b == b'_'
99}
100
101pub 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
159pub 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
188fn 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
211fn 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
235pub 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
287pub 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
325fn 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 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}