#![allow(dead_code)]
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use std::process::Command;
use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd};
const MDBOOK_OPTIONS: Options = Options::ENABLE_TABLES
.union(Options::ENABLE_FOOTNOTES)
.union(Options::ENABLE_STRIKETHROUGH)
.union(Options::ENABLE_TASKLISTS)
.union(Options::ENABLE_HEADING_ATTRIBUTES);
pub struct Page {
pub name: String,
pub text: String,
}
pub fn page(name: &str) -> Page {
let path: PathBuf = [env!("CARGO_MANIFEST_DIR"), "..", "..", "docs", "book", name]
.iter()
.collect();
let text = std::fs::read_to_string(&path)
.unwrap_or_else(|error| panic!("read {}: {error}", path.display()));
Page {
name: name.to_string(),
text,
}
}
fn line_of(text: &str, offset: usize) -> usize {
text[..offset].matches('\n').count() + 1
}
pub struct Fence {
pub info: String,
pub body: String,
pub line: usize,
}
pub fn fences(page: &Page) -> Vec<Fence> {
let mut out = Vec::new();
let mut open: Option<Fence> = None;
for (event, range) in Parser::new_ext(&page.text, MDBOOK_OPTIONS).into_offset_iter() {
match event {
Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(info))) => {
open = Some(Fence {
info: info.into_string(),
body: String::new(),
line: line_of(&page.text, range.start),
});
}
Event::Text(text) => {
if let Some(fence) = open.as_mut() {
fence.body.push_str(&text);
}
}
Event::End(TagEnd::CodeBlock) => out.extend(open.take()),
_ => {}
}
}
out
}
pub struct Table {
pub line: usize,
pub header: Vec<String>,
pub rows: Vec<Vec<String>>,
}
pub fn tables(page: &Page) -> Vec<Table> {
let mut out = Vec::new();
let mut current: Option<Table> = None;
let mut row: Vec<String> = Vec::new();
let mut cell: Option<String> = None;
for (event, range) in Parser::new_ext(&page.text, MDBOOK_OPTIONS).into_offset_iter() {
match event {
Event::Start(Tag::Table(_)) => {
current = Some(Table {
line: line_of(&page.text, range.start),
header: Vec::new(),
rows: Vec::new(),
});
}
Event::Start(Tag::TableHead) => {
row.clear();
}
Event::End(TagEnd::TableHead) => {
if let Some(table) = current.as_mut() {
table.header = std::mem::take(&mut row);
}
}
Event::Start(Tag::TableRow) => row.clear(),
Event::End(TagEnd::TableRow) => {
if let Some(table) = current.as_mut() {
table.rows.push(std::mem::take(&mut row));
}
}
Event::Start(Tag::TableCell) => cell = Some(String::new()),
Event::End(TagEnd::TableCell) => row.extend(cell.take()),
Event::Text(text) | Event::Code(text) => {
if let Some(cell) = cell.as_mut() {
cell.push_str(&text);
}
}
Event::End(TagEnd::Table) => out.extend(current.take()),
_ => {}
}
}
out
}
pub struct Transcript {
pub line: usize,
pub argv: Vec<String>,
pub expected: String,
}
pub fn help_transcripts(page: &Page) -> Vec<Transcript> {
let fences = fences(page);
let mut out = Vec::new();
for pair in fences.windows(2) {
let (command, output) = (&pair[0], &pair[1]);
if command.info != "sh" || output.info != "text" {
continue;
}
let lines: Vec<&str> = command.body.lines().collect();
if lines.len() != 1 || !lines[0].ends_with(" --help") {
continue;
}
out.push(Transcript {
line: output.line,
argv: lines[0].split_whitespace().map(str::to_string).collect(),
expected: output.body.clone(),
});
}
out
}
pub fn run(exe: &Path, args: &[&str]) -> (i32, String, String) {
let output = Command::new(exe)
.args(args)
.env("NO_COLOR", "1")
.env_remove("CLICOLOR_FORCE")
.env_remove("CLICOLOR")
.output()
.unwrap_or_else(|error| panic!("run {} {args:?}: {error}", exe.display()));
(
output.status.code().unwrap_or(-1),
String::from_utf8_lossy(&output.stdout).into_owned(),
String::from_utf8_lossy(&output.stderr).into_owned(),
)
}
pub fn transcript_failures(page: &Page, exe: &Path, program: &str) -> Vec<String> {
let mut failures = Vec::new();
for transcript in help_transcripts(page) {
if transcript.argv[0] != program {
continue;
}
let args: Vec<&str> = transcript.argv[1..].iter().map(String::as_str).collect();
let (code, stdout, _) = run(exe, &args);
let command = transcript.argv.join(" ");
if code != 0 {
failures.push(format!(
"{}:{}: `{command}` exited {code}, the book shows its help",
page.name, transcript.line
));
continue;
}
let book: Vec<&str> = transcript.expected.trim_end().lines().collect();
let real: Vec<&str> = stdout.trim_end().lines().collect();
for index in 0..book.len().max(real.len()) {
let (b, r) = (book.get(index), real.get(index));
if b != r {
failures.push(format!(
"{}:{}: `{command}` differs at output line {}\n book: {}\n binary: {}",
page.name,
transcript.line + 1 + index,
index + 1,
b.map_or("<no such line>", |s| s),
r.map_or("<no such line>", |s| s),
));
}
}
}
failures
}
pub fn listed_commands(help: &str) -> Vec<String> {
let mut out = Vec::new();
let mut in_section = false;
for line in help.lines() {
if line == "Commands:" {
in_section = true;
} else if in_section {
if !line.starts_with(" ") {
break;
}
if !line.starts_with(" ")
&& let Some(name) = line.split_whitespace().next()
&& name != "help"
{
out.push(name.to_string());
}
}
}
out
}
pub fn real_commands(exe: &Path) -> BTreeSet<String> {
fn walk(exe: &Path, prefix: &[String], out: &mut BTreeSet<String>) {
let mut args: Vec<&str> = prefix.iter().map(String::as_str).collect();
args.push("--help");
let (_, stdout, _) = run(exe, &args);
for name in listed_commands(&stdout) {
let mut path = prefix.to_vec();
path.push(name);
out.insert(path.join(" "));
walk(exe, &path, out);
}
}
let mut out = BTreeSet::new();
walk(exe, &[], &mut out);
out
}
pub fn coverage_failures(page: &Page, exe: &Path, program: &str) -> Vec<String> {
let documented: BTreeSet<String> = help_transcripts(page)
.iter()
.filter(|t| t.argv[0] == program)
.map(|t| t.argv[1..t.argv.len() - 1].join(" "))
.collect();
let mut real = real_commands(exe);
real.insert(String::new());
let mut failures = Vec::new();
for command in real.difference(&documented) {
failures.push(format!(
"{}: `{program} {command} --help` has no transcript in the book",
page.name
));
}
for command in documented.difference(&real) {
failures.push(format!(
"{}: the book has a transcript for `{program} {command}`, which does not exist",
page.name
));
}
failures
}
pub fn exit_table_commands(page: &Page, program: &str) -> (usize, BTreeSet<String>) {
let table = tables(page)
.into_iter()
.find(|t| t.header.first().is_some_and(|h| h == "Command"))
.expect("the exit-code table (first column `Command`) is missing");
let mut out = BTreeSet::new();
for row in &table.rows {
for token in row[0].split(" / ") {
let words: Vec<&str> = token.split_whitespace().collect();
if words.first() == Some(&program) {
out.insert(words[1..].join(" "));
}
}
}
(table.line, out)
}
pub fn exit_table_failures(page: &Page, exe: &Path, program: &str, exempt: &[&str]) -> Vec<String> {
let (line, documented) = exit_table_commands(page, program);
let real: BTreeSet<String> = real_commands(exe)
.into_iter()
.filter(|c| !exempt.contains(&c.as_str()))
.collect();
let mut failures = Vec::new();
for command in real.difference(&documented) {
failures.push(format!(
"{}:{line}: the exit-code table has no row for `{program} {command}`",
page.name
));
}
for command in documented.difference(&real) {
failures.push(format!(
"{}:{line}: the exit-code table names `{program} {command}`, which does not exist or is exempt",
page.name
));
}
failures
}
pub fn emit_values(build_help: &str) -> BTreeSet<String> {
build_help
.lines()
.filter_map(|line| line.trim_start().strip_prefix("- "))
.filter_map(|rest| rest.split_once(':'))
.map(|(name, _)| name.to_string())
.collect()
}
pub fn emit_table_failures(page: &Page, exe: &Path) -> Vec<String> {
let (_, help, _) = run(exe, &["build", "--help"]);
let real = emit_values(&help);
assert!(!real.is_empty(), "no emit values found in `build --help`");
let table = tables(page)
.into_iter()
.find(|t| t.header.first().is_some_and(|h| h == "--emit"))
.unwrap_or_else(|| panic!("{}: the `--emit` table is missing", page.name));
let documented: BTreeSet<String> = table.rows.iter().map(|r| r[0].clone()).collect();
let mut failures = Vec::new();
for value in real.difference(&documented) {
failures.push(format!(
"{}:{}: the `--emit` table has no row for `{value}`",
page.name, table.line
));
}
for value in documented.difference(&real) {
failures.push(format!(
"{}:{}: the `--emit` table names `{value}`, which is not an emit value",
page.name, table.line
));
}
let words = [
"zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten",
"eleven", "twelve", "thirteen",
];
let count = words.get(real.len()).copied().unwrap_or("?");
let wanted = format!("{count} emit targets exist");
if !page.text.to_lowercase().contains(&wanted) {
failures.push(format!(
"{}: the prose does not say \"{wanted}\" ({} emit values exist)",
page.name,
real.len()
));
}
failures
}
pub fn assert_no_failures(failures: Vec<String>) {
assert!(
failures.is_empty(),
"the book drifted from the binary ({} finding(s)):\n{}",
failures.len(),
failures.join("\n")
);
}
fn long_flags(text: &str) -> BTreeSet<String> {
let mut out = BTreeSet::new();
let bytes: Vec<char> = text.chars().collect();
let mut i = 0;
while i + 2 < bytes.len() {
let boundary = i == 0 || !(bytes[i - 1].is_alphanumeric() || bytes[i - 1] == '-');
if boundary && bytes[i] == '-' && bytes[i + 1] == '-' && bytes[i + 2].is_alphabetic() {
let mut j = i + 2;
while j < bytes.len() && (bytes[j].is_alphanumeric() || bytes[j] == '-') {
j += 1;
}
out.insert(
bytes[i..j]
.iter()
.collect::<String>()
.trim_end_matches('-')
.to_string(),
);
i = j;
} else {
i += 1;
}
}
out
}
fn real_flags(exe: &Path) -> BTreeSet<String> {
let mut commands = real_commands(exe);
commands.insert(String::new());
let mut out = BTreeSet::new();
for command in commands {
let mut args: Vec<&str> = command.split_whitespace().collect();
args.push("--help");
out.extend(long_flags(&run(exe, &args).1));
}
out
}
pub type Foreign<'a> = &'a [(&'a str, &'a str)];
fn is_exempt(foreign: Foreign, line: &str, flag: &str) -> bool {
foreign
.iter()
.any(|(context, name)| *name == flag && line.contains(context))
}
fn command_flags(
page: &Page,
exe: &Path,
own: &str,
program: &str,
command: &str,
) -> BTreeSet<String> {
if program == own {
let mut args: Vec<&str> = command.split_whitespace().collect();
args.push("--help");
return long_flags(&run(exe, &args).1);
}
help_transcripts(page)
.iter()
.filter(|t| t.argv[0] == program && t.argv[1..t.argv.len() - 1].join(" ") == command)
.flat_map(|t| long_flags(&t.expected))
.collect()
}
pub fn prose_flag_failures(
page: &Page,
exe: &Path,
own: &str,
other: &str,
foreign: Foreign,
) -> Vec<String> {
let mut known = real_flags(exe);
let mut commands: BTreeSet<(String, String)> = real_commands(exe)
.into_iter()
.map(|c| (own.to_string(), c))
.collect();
commands.insert((own.to_string(), String::new()));
for transcript in help_transcripts(page) {
if transcript.argv[0] == other {
known.extend(long_flags(&transcript.expected));
commands.insert((
other.to_string(),
transcript.argv[1..transcript.argv.len() - 1].join(" "),
));
}
}
let mut failures = Vec::new();
for fence in fences(page).iter().filter(|f| f.info == "sh") {
for (index, text) in fence.body.lines().enumerate() {
let line = fence.line + 1 + index;
let split = text
.replace("&&", "\n")
.replace("||", "\n")
.replace(['|', ';'], "\n");
for command in split.lines().map(str::trim) {
let mut words = command.split_whitespace();
let Some(first) = words.next() else { continue };
let program = first.rsplit('/').next().unwrap_or(first);
let is_ours = program == own || program == other;
let mut path = String::new();
for word in words.take_while(|w| !w.starts_with('-')) {
let next = if path.is_empty() {
word.to_string()
} else {
format!("{path} {word}")
};
if !commands.contains(&(program.to_string(), next.clone())) {
break;
}
path = next;
}
let accepted = if is_ours {
command_flags(page, exe, own, program, &path)
} else {
BTreeSet::new()
};
for flag in long_flags(command) {
if accepted.contains(&flag) || is_exempt(foreign, command, &flag) {
continue;
}
let name = format!("{program} {path}").trim_end().to_string();
failures.push(format!(
"{}:{line}: `{flag}` is used in a `sh` fence on `{name}`, which does not accept it",
page.name
));
}
}
}
}
let other_program = other;
for (event, range) in Parser::new_ext(&page.text, MDBOOK_OPTIONS).into_offset_iter() {
if let Event::Code(code) = event {
for flag in long_flags(&code) {
let line = line_of(&page.text, range.start);
let text = page.text.lines().nth(line - 1).unwrap_or("");
if !known.contains(&flag) && !is_exempt(foreign, text, &flag) {
failures.push(format!(
"{}:{line}: `{flag}` is named in the book, but no command of the binary or of `{other_program}` accepts it",
page.name
));
}
}
}
}
failures
}
pub fn version_failures(page: &Page, exe: &Path, program: &str) -> Vec<String> {
let command = format!("{program} --version");
let fences = fences(page);
let Some(pair) = fences
.windows(2)
.find(|p| p[0].info == "sh" && p[0].body.trim() == command && p[1].info == "text")
else {
return vec![format!("{}: no `{command}` transcript", page.name)];
};
let (_, stdout, _) = run(exe, &["--version"]);
let masked: Vec<String> = stdout
.split_whitespace()
.map(|word| {
if word.starts_with(|c: char| c.is_ascii_digit()) {
"X.Y.Z".to_string()
} else {
word.to_string()
}
})
.collect();
let real = masked.join(" ");
let book = pair[1].body.trim();
if real == book {
Vec::new()
} else {
vec![format!(
"{}:{}: `{command}` prints `{real}` (version masked), the book shows `{book}`",
page.name, pair[1].line
)]
}
}