use std::fs;
use std::path::{Path, PathBuf};
use harn_hostlib::ast::{api, Language, OutlineItem, Symbol};
const UPDATE_GOLDEN_ENV: &str = "HARN_AST_UPDATE_GOLDEN";
#[test]
fn every_language_fixture_matches_its_golden() {
let fixtures_root = manifest_dir().join("tests/fixtures/ast");
let mut fixture_dirs: Vec<PathBuf> = fs::read_dir(&fixtures_root)
.unwrap_or_else(|err| panic!("read fixtures dir {}: {err}", fixtures_root.display()))
.filter_map(|entry| entry.ok())
.filter(|entry| entry.file_type().map(|t| t.is_dir()).unwrap_or(false))
.map(|entry| entry.path())
.collect();
fixture_dirs.sort();
assert!(
!fixture_dirs.is_empty(),
"no language fixtures under {}",
fixtures_root.display()
);
let update = std::env::var(UPDATE_GOLDEN_ENV).is_ok();
let mut failures: Vec<String> = Vec::new();
for dir in &fixture_dirs {
if let Err(message) = run_fixture(dir, update) {
failures.push(format!(
"[{}] {message}",
dir.file_name().unwrap().to_string_lossy()
));
}
}
assert!(
failures.is_empty(),
"{} fixture(s) failed:\n - {}\n\nRe-run with {UPDATE_GOLDEN_ENV}=1 to regenerate goldens.",
failures.len(),
failures.join("\n - "),
);
}
#[test]
fn every_language_has_a_fixture() {
let fixtures_root = manifest_dir().join("tests/fixtures/ast");
let dirs: std::collections::HashSet<String> = fs::read_dir(&fixtures_root)
.unwrap()
.filter_map(|entry| entry.ok())
.filter(|entry| entry.file_type().map(|t| t.is_dir()).unwrap_or(false))
.map(|entry| entry.file_name().to_string_lossy().to_string())
.collect();
let missing: Vec<&str> = Language::all()
.iter()
.map(|l| l.name())
.filter(|name| !dirs.contains(*name))
.collect();
assert!(
missing.is_empty(),
"languages without fixtures: {missing:?} (under {})",
fixtures_root.display()
);
}
fn run_fixture(dir: &Path, update: bool) -> Result<(), String> {
let language_name = dir
.file_name()
.and_then(|s| s.to_str())
.ok_or_else(|| format!("non-UTF8 fixture dir {}", dir.display()))?;
let language = Language::from_name(language_name)
.ok_or_else(|| format!("unknown language `{language_name}`"))?;
let source_path =
find_source(dir).ok_or_else(|| format!("no source.* file under {}", dir.display()))?;
let (_, symbols) = api::symbols(&source_path, Some(language.name()))
.map_err(|err| format!("symbols extraction failed: {err}"))?;
let (_, outline) = api::outline(&source_path, Some(language.name()))
.map_err(|err| format!("outline build failed: {err}"))?;
compare_or_update(
&dir.join("symbols.golden.json"),
&symbols_to_json(&symbols),
update,
)?;
compare_or_update(
&dir.join("outline.golden.json"),
&outline_to_json(&outline),
update,
)?;
Ok(())
}
fn find_source(dir: &Path) -> Option<PathBuf> {
fs::read_dir(dir)
.ok()?
.filter_map(|entry| entry.ok())
.map(|entry| entry.path())
.find(|path| {
path.file_stem()
.and_then(|s| s.to_str())
.map(|name| name == "source")
.unwrap_or(false)
&& path.extension().is_some()
})
}
fn manifest_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
}
fn compare_or_update(path: &Path, actual: &str, update: bool) -> Result<(), String> {
let pretty = format!("{actual}\n");
if update || !path.exists() {
fs::write(path, &pretty).map_err(|err| format!("write {}: {err}", path.display()))?;
return Ok(());
}
let expected_raw =
fs::read_to_string(path).map_err(|err| format!("read {}: {err}", path.display()))?;
let expected = normalize_line_endings(&expected_raw);
if expected != pretty {
let mismatch = first_mismatch(&expected, &pretty);
return Err(format!(
"golden {} does not match (first mismatch: {})\n--- expected\n{expected}\n+++ actual\n{pretty}",
path.display(),
mismatch,
));
}
Ok(())
}
fn normalize_line_endings(text: &str) -> String {
text.replace("\r\n", "\n").replace('\r', "\n")
}
fn first_mismatch(a: &str, b: &str) -> String {
for (line, (la, lb)) in (1..).zip(a.lines().zip(b.lines())) {
if la != lb {
return format!("line {line}: `{la}` vs `{lb}`");
}
}
if a.lines().count() != b.lines().count() {
return format!(
"different line counts: {} vs {}",
a.lines().count(),
b.lines().count()
);
}
"trailing whitespace".into()
}
fn symbols_to_json(symbols: &[Symbol]) -> String {
let mut out = String::from("[\n");
for (i, sym) in symbols.iter().enumerate() {
let comma = if i + 1 == symbols.len() { "" } else { "," };
out.push_str(&format!(
" {{\"name\":{}, \"kind\":\"{}\", \"container\":{}, \"access_level\":{}, \"signature\":{}, \"start_row\":{}, \"start_col\":{}, \"end_row\":{}, \"end_col\":{}}}{comma}\n",
quote(&sym.name),
sym.kind.as_str(),
sym.container
.as_ref()
.map(|s| quote(s))
.unwrap_or_else(|| "null".into()),
sym.access_level
.as_ref()
.map(|s| quote(s))
.unwrap_or_else(|| "null".into()),
quote(&sym.signature),
sym.start_row,
sym.start_col,
sym.end_row,
sym.end_col,
));
}
out.push(']');
out
}
fn outline_to_json(items: &[OutlineItem]) -> String {
let mut out = String::new();
write_outline_items(&mut out, items, 0);
out
}
fn write_outline_items(buf: &mut String, items: &[OutlineItem], depth: usize) {
let indent = " ".repeat(depth);
if items.is_empty() {
buf.push_str(&indent);
buf.push_str("[]");
return;
}
buf.push_str(&indent);
buf.push_str("[\n");
for (i, item) in items.iter().enumerate() {
let inner = " ".repeat(depth + 1);
buf.push_str(&inner);
buf.push_str(&format!(
"{{\"name\":{}, \"kind\":\"{}\", \"access_level\":{}, \"signature\":{}, \"start_row\":{}, \"end_row\":{}, \"children\":",
quote(&item.name),
item.kind.as_str(),
item.access_level
.as_ref()
.map(|s| quote(s))
.unwrap_or_else(|| "null".into()),
quote(&item.signature),
item.start_row,
item.end_row,
));
if item.children.is_empty() {
buf.push_str("[]}");
} else {
buf.push('\n');
write_outline_items(buf, &item.children, depth + 2);
buf.push('\n');
buf.push_str(&inner);
buf.push('}');
}
if i + 1 < items.len() {
buf.push(',');
}
buf.push('\n');
}
buf.push_str(&indent);
buf.push(']');
}
fn quote(s: &str) -> String {
let mut out = String::from("\"");
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
c => out.push(c),
}
}
out.push('"');
out
}