use std::collections::BTreeSet;
use std::path::Path;
use clap::CommandFactory;
use crate::app::App;
use crate::config::Config;
use crate::help::Help;
fn read(path: &str) -> String {
let full = Path::new(env!("CARGO_MANIFEST_DIR")).join(path);
std::fs::read_to_string(&full).unwrap_or_else(|e| panic!("{path}: {e}"))
}
fn help_topics() -> Vec<String> {
Help::open("no such topic")
.lines
.iter()
.filter_map(|l| l.trim_start().strip_prefix(":help "))
.map(|rest| rest.split_whitespace().next().unwrap_or("").to_string())
.filter(|t| t.chars().all(|c| c.is_ascii_lowercase()))
.collect()
}
fn help_text() -> String {
std::iter::once(String::new())
.chain(help_topics().into_iter().filter(|t| t != "this"))
.flat_map(|topic| Help::open(&topic).lines)
.collect::<Vec<_>>()
.join("\n")
}
fn all_docs() -> Vec<(String, String)> {
let root = Path::new(env!("CARGO_MANIFEST_DIR"));
let mut out = vec![("README.md".to_string(), read("README.md"))];
let mut dirs = vec![root.join("docs")];
while let Some(dir) = dirs.pop() {
for entry in std::fs::read_dir(&dir).unwrap().flatten() {
let path = entry.path();
if path.is_dir() {
dirs.push(path);
} else if path.extension().is_some_and(|e| e == "md") {
let text = std::fs::read_to_string(&path).unwrap();
let name = path.strip_prefix(root).unwrap_or(&path).display().to_string();
out.push((name, text));
}
}
}
out
}
fn ex_commands(text: &str) -> BTreeSet<String> {
let chars: Vec<char> = text.chars().collect();
let mut out = BTreeSet::new();
for (i, &c) in chars.iter().enumerate() {
if c != ':' || i > 0 && (chars[i - 1].is_alphanumeric() || chars[i - 1] == ':') {
continue;
}
let mut name: String = chars[i + 1..].iter().take_while(|c| c.is_ascii_alphabetic()).collect();
if name.is_empty() {
continue;
}
if chars.get(i + 1 + name.len()) == Some(&'!') {
name.push('!');
}
out.insert(name);
}
out
}
fn set_options(text: &str) -> BTreeSet<String> {
text.match_indices(":set ")
.flat_map(|(i, m)| {
let rest = &text[i + m.len()..];
let end = rest
.find(|c: char| !(c.is_ascii_lowercase() || c == '_' || c == '|' || c == '\\'))
.unwrap_or(rest.len());
rest[..end].split(['|', '\\']).filter(|s| !s.is_empty()).map(str::to_string).collect::<Vec<_>>()
})
.collect()
}
fn run(cmd: &str) -> String {
let mut app = App::new(Config::default(), None, None).unwrap();
app.run_command(cmd);
app.flash.and_then(|f| f.text).unwrap_or_default()
}
#[test]
fn help_and_the_commands_page_name_the_same_commands() {
let help = ex_commands(&help_text());
let docs = ex_commands(&read("docs/reference/commands.md"));
let only_help: Vec<_> = help.difference(&docs).collect();
let only_docs: Vec<_> = docs.difference(&help).collect();
assert!(
only_help.is_empty() && only_docs.is_empty(),
"only in :help: {only_help:?}\nonly in docs/reference/commands.md: {only_docs:?}"
);
}
#[test]
fn every_documented_command_exists() {
let mut named = ex_commands(&help_text());
for (_, text) in all_docs() {
named.extend(ex_commands(&text));
}
let unknown: Vec<_> = named.iter().filter(|c| run(c).starts_with("not a command")).collect();
assert!(unknown.is_empty(), "documented but not commands: {unknown:?}");
}
#[test]
fn every_documented_set_option_exists() {
let mut named = set_options(&help_text());
for (_, text) in all_docs() {
named.extend(set_options(&text));
}
assert!(!named.is_empty(), "the :set parser found nothing");
let unknown: Vec<_> =
named.iter().filter(|o| run(&format!("set {o}")).starts_with("unknown option")).collect();
assert!(unknown.is_empty(), "documented but not :set options: {unknown:?}");
}
fn cli_flags() -> BTreeSet<String> {
let mut flags: BTreeSet<String> = crate::Cli::command()
.get_arguments()
.filter_map(|a| a.get_long())
.map(|l| format!("--{l}"))
.collect();
flags.extend(["--help".to_string(), "--version".to_string()]);
flags
}
fn cli_short_flags() -> BTreeSet<(String, String)> {
let mut flags: BTreeSet<(String, String)> = crate::Cli::command()
.get_arguments()
.filter_map(|a| Some((format!("-{}", a.get_short()?), format!("--{}", a.get_long()?))))
.collect();
flags.insert(("-h".to_string(), "--help".to_string()));
flags.insert(("-V".to_string(), "--version".to_string()));
flags
}
fn shoin_flags(text: &str) -> BTreeSet<String> {
let mut out = BTreeSet::new();
for line in text.lines() {
for (i, _) in line.match_indices("shoin ") {
let rest = &line[i..];
let end = rest.find(['`', '|', ')', '"']).unwrap_or(rest.len());
for word in rest[..end].split_whitespace() {
let word = word.trim_end_matches([',', '.', ';', ':']);
let short = word.len() == 2
&& word.starts_with('-')
&& word[1..].chars().all(|c| c.is_ascii_alphabetic());
if short || word.starts_with("--") && word.len() > 2 {
out.insert(word.split('=').next().unwrap().to_string());
}
}
}
}
out
}
#[test]
fn the_command_line_page_matches_the_flags() {
let flags = cli_flags();
let page = read("docs/reference/cli.md");
let listed: BTreeSet<String> = page
.split(|c: char| !(c.is_ascii_alphanumeric() || c == '-'))
.filter(|w| w.starts_with("--") && w.len() > 2)
.map(str::to_string)
.collect();
let missing: Vec<_> = flags.difference(&listed).collect();
let unknown: Vec<_> = listed.difference(&flags).collect();
assert!(
missing.is_empty() && unknown.is_empty(),
"not in docs/reference/cli.md: {missing:?}\nin cli.md but not a flag: {unknown:?}"
);
}
#[test]
fn the_command_line_page_matches_the_short_flags() {
let flags = cli_short_flags();
let listed: BTreeSet<(String, String)> = read("docs/reference/cli.md")
.lines()
.filter_map(|l| {
let (short, rest) = l.trim_start().split_once(", ")?;
let long = rest.split(|c: char| !(c.is_ascii_alphanumeric() || c == '-')).next()?;
let is_short = short.len() == 2 && short.starts_with('-') && short != "--";
(is_short && long.starts_with("--")).then(|| (short.to_string(), long.to_string()))
})
.collect();
let missing: Vec<_> = flags.difference(&listed).collect();
let unknown: Vec<_> = listed.difference(&flags).collect();
assert!(
missing.is_empty() && unknown.is_empty(),
"not in docs/reference/cli.md: {missing:?}\nin cli.md but not a flag: {unknown:?}"
);
}
#[test]
fn every_documented_flag_exists() {
let mut flags = cli_flags();
flags.extend(cli_short_flags().into_iter().map(|(short, _)| short));
let mut stale = Vec::new();
for (name, text) in all_docs().into_iter().chain([(":help".to_string(), help_text())]) {
for flag in shoin_flags(&text) {
if !flags.contains(&flag) {
stale.push(format!("{name}: {flag}"));
}
}
}
assert!(stale.is_empty(), "flags shoin does not have: {stale:?}");
}
fn leader_keys() -> BTreeSet<String> {
read("shoin/keys.conf")
.lines()
.map(str::trim)
.filter(|l| !l.starts_with('#'))
.filter_map(|l| l.strip_prefix("\"<leader>"))
.filter_map(|l| l.split_once('"').map(|(key, _)| key.to_string()))
.collect()
}
#[test]
fn every_leader_binding_is_documented() {
let keys = leader_keys();
assert!(!keys.is_empty(), "no <leader> bindings found in shoin/keys.conf");
let help = help_text();
let reference = read("docs/reference/keys.md");
let mut missing = Vec::new();
for key in &keys {
let in_help = help.match_indices(&format!("<leader>{key}")).any(|(i, m)| {
help[i + m.len()..].chars().next().is_none_or(char::is_whitespace)
});
if !in_help {
missing.push(format!(":help: <leader>{key}"));
}
if !reference.contains(&format!("`Space {key}`")) {
missing.push(format!("docs/reference/keys.md: Space {key}"));
}
}
assert!(missing.is_empty(), "leader bindings not documented: {missing:?}");
}
fn other_shipped_keys() -> BTreeSet<String> {
read("shoin/keys.conf")
.lines()
.map(str::trim)
.filter(|l| !l.starts_with('#') && !l.starts_with("\"<leader>"))
.filter_map(|l| l.strip_prefix('"')?.split_once('"').map(|(key, _)| key.to_string()))
.map(|key| {
let inner = key.trim_start_matches('<').trim_end_matches('>');
match inner.split_once('-') {
Some(("C", k)) => format!("Ctrl-{k}"),
Some(("M", k)) => format!("Alt-{k}"),
Some(("S", k)) => format!("Shift-{k}"),
_ => inner.to_string(),
}
})
.collect()
}
#[test]
fn every_shipped_binding_is_documented() {
let keys = other_shipped_keys();
assert!(keys.contains("Ctrl-s"), "parsed shoin/keys.conf: {keys:?}");
let help = help_text();
let reference = read("docs/reference/keys.md");
let mut missing = Vec::new();
for key in &keys {
if !reference.contains(&format!("`{key}`")) {
missing.push(format!("docs/reference/keys.md: {key}"));
}
if !help.contains(key.as_str()) {
missing.push(format!(":help: {key}"));
}
}
assert!(missing.is_empty(), "shipped bindings not documented: {missing:?}");
}
#[test]
fn every_documented_help_topic_exists() {
let page = read("docs/reference/commands.md");
let line = page.lines().find(|l| l.contains("`:help [topic]`")).expect("the :help row");
let topics: Vec<&str> = line
.split('`')
.skip(3) .step_by(2)
.collect();
assert!(!topics.is_empty(), "no topics found in: {line}");
for topic in topics {
let first = Help::open(topic).lines.into_iter().next().unwrap_or_default();
assert!(!first.starts_with("No help topic"), ":help {topic} is not a topic");
}
}
#[test]
fn every_help_topic_mentioned_exists() {
let mut texts = all_docs();
texts.push((":help".to_string(), help_text()));
let mut unknown = Vec::new();
for (name, text) in texts {
for (i, m) in text.match_indices(":help ") {
let topic: String =
text[i + m.len()..].chars().take_while(|c| c.is_ascii_alphanumeric()).collect();
if topic.is_empty() {
continue;
}
let first = Help::open(&topic).lines.into_iter().next().unwrap_or_default();
if first.starts_with("No help topic") {
unknown.push(format!("{name}: :help {topic}"));
}
}
}
assert!(unknown.is_empty(), "not :help topics: {unknown:?}");
}