use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use super::tag_is_canonical;
const NON_CANONICAL: &[(&str, &str)] = &[
("integer", "int"),
("boolean", "bool"),
("record", "dict"),
("object", "dict"),
("array", "list"),
("number", "int or a float"),
];
fn type_claim_pattern() -> regex::Regex {
let nouns = NON_CANONICAL
.iter()
.map(|(bad, _)| *bad)
.collect::<Vec<_>>()
.join("|");
regex::Regex::new(&format!(
r"(?:must be|must contain|expected|requires)(?: an?| the)?(?: [a-z][a-z-]*)? ({nouns})\b"
))
.expect("type-claim pattern compiles")
}
const OPT_OUT: &str = "not-a-harn-type:";
fn stdlib_root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("src/stdlib")
}
fn visit(dir: &Path, root: &Path, claim: ®ex::Regex, found: &mut Vec<String>) {
for entry in std::fs::read_dir(dir).expect("read stdlib dir") {
let path = entry.expect("stdlib dir entry").path();
if path.is_dir() {
visit(&path, root, claim, found);
continue;
}
if path.extension().and_then(|extension| extension.to_str()) != Some("rs") {
continue;
}
if path
.parent()
.and_then(Path::file_name)
.and_then(|name| name.to_str())
== Some("args")
{
continue;
}
let source = std::fs::read_to_string(&path).expect("read stdlib source");
let lines: Vec<&str> = source.lines().collect();
for (number, line) in lines.iter().enumerate() {
if line.trim_start().starts_with("//") {
continue;
}
let opted_out = (number.saturating_sub(2)..=number)
.filter_map(|index| lines.get(index))
.any(|line| line.contains(OPT_OUT));
if opted_out {
continue;
}
let prior = number
.checked_sub(1)
.and_then(|index| lines.get(index))
.filter(|line| !line.trim_start().starts_with("//"))
.map(|line| line.trim_end())
.unwrap_or_default();
let offset = if prior.is_empty() { 0 } else { prior.len() + 1 };
let context = format!("{prior} {line}");
for capture in claim.captures_iter(context.trim_start()) {
let noun = capture.get(1).expect("group 1 is the noun");
if noun.start() + context.len() - context.trim_start().len() < offset {
continue;
}
let fix = NON_CANONICAL
.iter()
.find(|(bad, _)| *bad == noun.as_str())
.map(|(_, fix)| *fix)
.expect("captured noun comes from NON_CANONICAL");
let relative = path.strip_prefix(root).unwrap_or(&path).display();
found.push(format!(
"{relative}:{}: `{}` -> `{fix}`",
number + 1,
noun.as_str()
));
}
}
}
}
#[test]
fn stdlib_diagnostics_name_types_the_runtime_has() {
let root = stdlib_root();
let mut found = Vec::new();
visit(&root, &root, &type_claim_pattern(), &mut found);
found.sort();
assert!(
found.is_empty(),
"stdlib diagnostics name types `type_of` never returns. Prefer building the \
message with `Args`/`Options` so the vocabulary is structural; otherwise use \
the canonical spelling, or mark the line `// {OPT_OUT} <reason>` when it \
describes something other than a Harn value.\n {}",
found.join("\n ")
);
}
#[test]
fn recommended_spellings_are_canonical() {
let recommended: BTreeSet<&str> = NON_CANONICAL
.iter()
.flat_map(|(_, fix)| fix.split(" or a "))
.collect();
for word in recommended {
assert!(
tag_is_canonical(word),
"recommended spelling `{word}` is not a runtime type tag"
);
}
}
#[test]
fn the_pattern_matches_claims_and_not_identifiers() {
let claim = type_claim_pattern();
for asserted in [
"value must be an integer",
"ahead must be a positive integer",
"`indent` requires an integer width",
"expected an integer millisecond timestamp",
"entries must contain an array",
] {
assert!(claim.is_match(asserted), "should flag: {asserted}");
}
for innocent in [
"\"trust.record: expected decision dict\"",
"if record.attempt != expected {",
"/// the record crosses the boundary",
"let integer_like = 3;",
] {
assert!(!claim.is_match(innocent), "should not flag: {innocent}");
}
}