use std::collections::HashMap;
use std::path::{Path, PathBuf};
const SOURCE_EXTS: &[&str] = &[
"rs", "py", "pyi", "js", "jsx", "ts", "tsx", "mjs", "cjs", "go", "c", "h", "cc", "cpp", "cxx",
"hpp", "hh", "java", "kt", "kts", "rb", "php", "cs", "swift", "scala", "sh", "bash", "lua",
"ml", "mli", "ex", "exs", "dart", "chimera",
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FaultSite {
pub path: PathBuf,
pub line: u32,
pub hits: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Language {
Rust,
Python,
JavaScript,
TypeScript,
Go,
Java,
Gradle,
Ruby,
Chimera,
}
pub fn detect_language(root: &Path) -> Option<Language> {
let has = |name: &str| root.join(name).exists();
if has("Cargo.toml") {
return Some(Language::Rust);
}
if has("go.mod") {
return Some(Language::Go);
}
if has("pyproject.toml") || has("setup.py") || has("setup.cfg") || has("requirements.txt") {
return Some(Language::Python);
}
if has("tsconfig.json") {
return Some(Language::TypeScript);
}
if has("package.json") {
return Some(Language::JavaScript);
}
if has("pom.xml") {
return Some(Language::Java);
}
if has("build.gradle") || has("build.gradle.kts") {
return Some(Language::Gradle);
}
if has("Gemfile") {
return Some(Language::Ruby);
}
if has("chimera.toml") || has_chimera_source(root) {
return Some(Language::Chimera);
}
None
}
fn has_chimera_source(root: &Path) -> bool {
let Ok(entries) = std::fs::read_dir(root) else {
return false;
};
entries.flatten().any(|e| {
e.path()
.extension()
.and_then(|x| x.to_str())
.map(|x| x.eq_ignore_ascii_case("chimera"))
.unwrap_or(false)
})
}
pub fn default_verify(language: Language) -> (String, Vec<String>) {
let (prog, args): (&str, &[&str]) = match language {
Language::Rust => ("cargo", &["test", "--quiet"]),
Language::Python => ("pytest", &["-q"]),
Language::JavaScript | Language::TypeScript => ("npm", &["test", "--silent"]),
Language::Go => ("go", &["test", "./..."]),
Language::Java => ("mvn", &["-q", "test"]),
Language::Gradle => ("gradle", &["test", "--quiet"]),
Language::Ruby => ("rake", &["test"]),
Language::Chimera => ("axiom", &["chimera", "check"]),
};
(prog.to_string(), args.iter().map(|s| s.to_string()).collect())
}
fn has_source_ext(path: &str) -> bool {
let lower = path.to_ascii_lowercase();
match lower.rsplit_once('.') {
Some((_, ext)) => SOURCE_EXTS.contains(&ext),
None => false,
}
}
fn parse_python_frame(line: &str) -> Option<(PathBuf, u32)> {
let l = line.trim_start();
let rest = l.strip_prefix("File \"")?;
let end = rest.find('"')?;
let path = &rest[..end];
if path.is_empty() {
return None;
}
let after = &rest[end..];
let idx = after.find("line ")?;
let digits: String = after[idx + 5..]
.chars()
.take_while(|c| c.is_ascii_digit())
.collect();
let line_no = digits.parse::<u32>().ok()?;
Some((PathBuf::from(path), line_no))
}
fn parse_inline_token(tok: &str) -> Option<(PathBuf, u32)> {
let t = tok.trim_matches(|c| {
matches!(
c,
'(' | ')' | '[' | ']' | '{' | '}' | '\'' | '"' | ',' | ';' | '`'
)
});
if t.is_empty() {
return None;
}
if let Some(open) = t.find('(') {
let path_part = &t[..open];
if has_source_ext(path_part) {
let digits: String = t[open + 1..]
.chars()
.take_while(|c| c.is_ascii_digit())
.collect();
if let Ok(line) = digits.parse::<u32>() {
return Some((PathBuf::from(path_part), line));
}
}
}
let mut parts = t.split(':');
let path_part = parts.next()?;
if has_source_ext(path_part) {
let num_tok = parts.next()?;
let digits: String = num_tok.chars().take_while(|c| c.is_ascii_digit()).collect();
if let Ok(line) = digits.parse::<u32>() {
return Some((PathBuf::from(path_part), line));
}
}
None
}
pub fn locate_sites(trace: &str) -> Vec<FaultSite> {
let mut order: Vec<PathBuf> = Vec::new();
let mut info: HashMap<PathBuf, (u32, u32)> = HashMap::new();
let mut record = |path: PathBuf, line: u32| {
let entry = info.entry(path.clone()).or_insert_with(|| {
order.push(path.clone());
(line, 0)
});
entry.1 = entry.1.saturating_add(1);
};
for raw in trace.lines() {
if let Some((p, l)) = parse_python_frame(raw) {
record(p, l);
continue; }
for tok in raw.split_whitespace() {
if let Some((p, l)) = parse_inline_token(tok) {
record(p, l);
}
}
}
let mut sites: Vec<FaultSite> = order
.into_iter()
.map(|path| {
let (line, hits) = info[&path];
FaultSite { path, line, hits }
})
.collect();
sites.sort_by_key(|s| std::cmp::Reverse(s.hits));
sites
}
fn resolve_under_root(path: &Path, root: &Path) -> Option<PathBuf> {
let candidate = if path.is_absolute() {
path.to_path_buf()
} else {
root.join(path)
};
if !candidate.is_file() {
return None;
}
let root_canon = root.canonicalize().ok()?;
let cand_canon = candidate.canonicalize().ok()?;
if cand_canon.starts_with(&root_canon) {
Some(cand_canon)
} else {
None
}
}
pub fn best_source(trace: &str, root: &Path) -> Option<PathBuf> {
candidate_sources(trace, root).into_iter().next()
}
pub fn candidate_sources(trace: &str, root: &Path) -> Vec<PathBuf> {
let mut out: Vec<PathBuf> = Vec::new();
for site in locate_sites(trace) {
if let Some(p) = resolve_under_root(&site.path, root) {
if !out.contains(&p) {
out.push(p);
}
}
}
out
}
pub fn contained_path(path: &Path, root: &Path) -> Option<PathBuf> {
let root_canon = root.canonicalize().ok()?;
let joined = if path.is_absolute() {
path.to_path_buf()
} else {
root.join(path)
};
if let Ok(canon) = joined.canonicalize() {
return canon.starts_with(&root_canon).then_some(canon);
}
let parent = joined.parent()?;
let file_name = joined.file_name()?;
let parent_canon = parent.canonicalize().ok()?;
parent_canon
.starts_with(&root_canon)
.then(|| parent_canon.join(file_name))
}
pub fn line_hints_for(trace: &str, target: &Path) -> Vec<u32> {
let name = match target.file_name() {
Some(n) => n,
None => return Vec::new(),
};
let mut order: Vec<u32> = Vec::new();
let mut freq: HashMap<u32, u32> = HashMap::new();
let mut consider = |p: &Path, line: u32| {
if p.file_name() == Some(name) {
let e = freq.entry(line).or_insert_with(|| {
order.push(line);
0
});
*e = e.saturating_add(1);
}
};
for raw in trace.lines() {
if let Some((p, l)) = parse_python_frame(raw) {
consider(&p, l);
continue;
}
for tok in raw.split_whitespace() {
if let Some((p, l)) = parse_inline_token(tok) {
consider(&p, l);
}
}
}
order.sort_by(|a, b| freq[b].cmp(&freq[a]));
order
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_rust_compiler_and_panic_frames() {
let trace = "\
error[E0308]: mismatched types
--> src/widget.rs:42:18
|
thread 'main' panicked at src/widget.rs:50:9:
assertion failed";
let sites = locate_sites(trace);
assert_eq!(sites[0].path, PathBuf::from("src/widget.rs"));
assert_eq!(sites[0].hits, 2, "two references to the same file");
assert_eq!(sites[0].line, 42, "first-seen line retained");
}
#[test]
fn parses_python_traceback() {
let trace = "\
Traceback (most recent call last):
File \"/app/pkg/runner.py\", line 17, in <module>
main()
File \"/app/pkg/core.py\", line 88, in main
ValueError: boom";
let sites = locate_sites(trace);
let paths: Vec<_> = sites.iter().map(|s| s.path.clone()).collect();
assert!(paths.contains(&PathBuf::from("/app/pkg/runner.py")));
assert!(paths.contains(&PathBuf::from("/app/pkg/core.py")));
assert_eq!(sites.iter().find(|s| s.path == PathBuf::from("/app/pkg/core.py")).unwrap().line, 88);
}
#[test]
fn parses_js_stack_and_ts_diagnostic() {
let js = " at Object.<anonymous> (/srv/app/index.js:23:11)";
let ts = "src/server.ts(120,5): error TS2322: Type mismatch";
let go = "./internal/db/conn.go:31:2: undefined: Foo";
assert_eq!(locate_sites(js)[0].path, PathBuf::from("/srv/app/index.js"));
assert_eq!(locate_sites(js)[0].line, 23);
assert_eq!(locate_sites(ts)[0].path, PathBuf::from("src/server.ts"));
assert_eq!(locate_sites(ts)[0].line, 120);
assert_eq!(locate_sites(go)[0].path, PathBuf::from("./internal/db/conn.go"));
assert_eq!(locate_sites(go)[0].line, 31);
}
#[test]
fn ranks_most_referenced_file_first() {
let trace = "\
a.rs:1:1: note
b.py:2:1: error
b.py:9:1: error
b.py:14:1: error";
let sites = locate_sites(trace);
assert_eq!(sites[0].path, PathBuf::from("b.py"), "3 hits outrank 1");
assert_eq!(sites[0].hits, 3);
}
#[test]
fn ignores_non_source_and_prose() {
let trace = "Compiling thing v0.1.0\nFinished in 3:14 minutes\nsee README.md:1 for details";
assert!(locate_sites(trace).is_empty());
}
#[test]
fn best_source_returns_only_existing_files() {
let dir = std::env::temp_dir().join(format!("axiom_floc_{}", std::process::id()));
let sub = dir.join("src");
std::fs::create_dir_all(&sub).unwrap();
std::fs::write(sub.join("real.rs"), "fn main() {}").unwrap();
let trace = "\
src/ghost.rs:3:1: error
src/ghost.rs:4:1: error
src/real.rs:9:2: error";
let found = best_source(trace, &dir);
assert_eq!(found, Some(sub.join("real.rs").canonicalize().unwrap()));
assert_eq!(best_source("src/ghost.rs:1:1: error", &dir), None);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn best_source_rejects_existing_files_outside_the_root() {
let base = std::env::temp_dir().join(format!("axiom_floc_esc_{}", std::process::id()));
let project = base.join("project");
let outside = base.join("vendor");
std::fs::create_dir_all(project.join("src")).unwrap();
std::fs::create_dir_all(&outside).unwrap();
std::fs::write(project.join("src/app.rs"), "fn main() {}").unwrap();
let dep = outside.join("dep.rs");
std::fs::write(&dep, "// dependency").unwrap();
let trace = format!(
"thread panicked at {dep}:10:5\n{dep}:11:1: note\nsrc/app.rs:3:2: error",
dep = dep.display()
);
let found = best_source(&trace, &project);
assert_eq!(found, Some(project.join("src/app.rs").canonicalize().unwrap()));
let only_dep = format!("{}:10:5: boom", dep.display());
assert_eq!(best_source(&only_dep, &project), None);
assert_eq!(best_source("../vendor/dep.rs:1:1: x", &project), None);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn contained_path_allows_in_root_and_rejects_escapes() {
let dir = std::env::temp_dir().join(format!("axiom_floc_contain_{}", std::process::id()));
std::fs::create_dir_all(dir.join("src")).unwrap();
std::fs::write(dir.join("src/lib.rs"), "fn main() {}").unwrap();
let got = contained_path(Path::new("src/lib.rs"), &dir).unwrap();
assert_eq!(got, dir.join("src/lib.rs").canonicalize().unwrap());
assert!(contained_path(Path::new("src/new.rs"), &dir).is_some());
assert_eq!(contained_path(Path::new("/etc/passwd"), &dir), None);
assert_eq!(contained_path(Path::new("../escape.rs"), &dir), None);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn candidate_sources_lists_all_in_root_files_ranked_and_deduped() {
let dir = std::env::temp_dir().join(format!("axiom_floc_cands_{}", std::process::id()));
std::fs::create_dir_all(dir.join("src")).unwrap();
std::fs::write(dir.join("src/a.rs"), "fn a() {}").unwrap();
std::fs::write(dir.join("src/b.rs"), "fn b() {}").unwrap();
let trace = "\
src/ghost.rs:1:1: error
src/b.rs:2:1: error
src/b.rs:9:1: error
src/a.rs:3:1: error";
let cands = candidate_sources(trace, &dir);
let b = dir.join("src/b.rs").canonicalize().unwrap();
let a = dir.join("src/a.rs").canonicalize().unwrap();
assert_eq!(cands, vec![b.clone(), a], "ranked, in-root only, deduped");
assert_eq!(best_source(trace, &dir), Some(b));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn detect_language_reads_marker_files() {
let dir = std::env::temp_dir().join(format!("axiom_floc_lang_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("go.mod"), "module x").unwrap();
assert_eq!(detect_language(&dir), Some(Language::Go));
let (prog, args) = default_verify(Language::Go);
assert_eq!(prog, "go");
assert_eq!(args, vec!["test", "./..."]);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn detect_language_recognizes_chimera_projects() {
let dir = std::env::temp_dir().join(format!("axiom_floc_chimera_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("main.chimera"), "emit \"hi\"").unwrap();
assert_eq!(detect_language(&dir), Some(Language::Chimera));
let (prog, args) = default_verify(Language::Chimera);
assert_eq!(prog, "axiom");
assert_eq!(args, vec!["chimera", "check"]);
let _ = std::fs::remove_dir_all(&dir);
let dir2 = std::env::temp_dir().join(format!("axiom_floc_chimtoml_{}", std::process::id()));
std::fs::create_dir_all(&dir2).unwrap();
std::fs::write(dir2.join("chimera.toml"), "name = \"x\"").unwrap();
assert_eq!(detect_language(&dir2), Some(Language::Chimera));
let _ = std::fs::remove_dir_all(&dir2);
}
#[test]
fn line_hints_rank_and_filter_to_the_target_file() {
let trace = "\
src/widget.rs:42:18: error
src/widget.rs:42:18: error
src/widget.rs:50:9: error
src/unrelated.rs:3:1: error";
let hints = line_hints_for(trace, Path::new("/abs/proj/src/widget.rs"));
assert_eq!(hints, vec![42, 50], "line 42 (2 hits) ranks before line 50 (1)");
assert!(line_hints_for(trace, Path::new("src/ghost.rs")).is_empty());
}
#[test]
fn gradle_and_maven_select_their_own_build_tool() {
let gdir = std::env::temp_dir().join(format!("axiom_floc_gradle_{}", std::process::id()));
std::fs::create_dir_all(&gdir).unwrap();
std::fs::write(gdir.join("build.gradle.kts"), "plugins {}").unwrap();
assert_eq!(detect_language(&gdir), Some(Language::Gradle));
assert_eq!(default_verify(Language::Gradle).0, "gradle");
let _ = std::fs::remove_dir_all(&gdir);
let mdir = std::env::temp_dir().join(format!("axiom_floc_maven_{}", std::process::id()));
std::fs::create_dir_all(&mdir).unwrap();
std::fs::write(mdir.join("pom.xml"), "<project/>").unwrap();
assert_eq!(detect_language(&mdir), Some(Language::Java));
assert_eq!(default_verify(Language::Java).0, "mvn");
let _ = std::fs::remove_dir_all(&mdir);
}
}