use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use crate::project::ProjectAnalyzer;
use mir_issues::{Issue, IssueKind};
static COUNTER: AtomicU64 = AtomicU64::new(0);
pub fn check(src: &str) -> Vec<Issue> {
let id = COUNTER.fetch_add(1, Ordering::Relaxed);
let tmp: PathBuf = std::env::temp_dir().join(format!("mir_test_{}.php", id));
std::fs::write(&tmp, src)
.unwrap_or_else(|e| panic!("failed to write temp PHP file {}: {}", tmp.display(), e));
let result = ProjectAnalyzer::new().analyze(std::slice::from_ref(&tmp));
std::fs::remove_file(&tmp).ok();
result
.issues
.into_iter()
.filter(|i| !i.suppressed)
.collect()
}
pub struct ExpectedIssue {
pub kind_name: String,
pub snippet: String,
}
pub fn parse_phpt(content: &str, path: &str) -> (String, Vec<ExpectedIssue>) {
let source_marker = "===source===";
let expect_marker = "===expect===";
let source_pos = content
.find(source_marker)
.unwrap_or_else(|| panic!("fixture {} missing ===source=== section", path));
let expect_pos = content
.find(expect_marker)
.unwrap_or_else(|| panic!("fixture {} missing ===expect=== section", path));
assert!(
source_pos < expect_pos,
"fixture {}: ===source=== must come before ===expect===",
path
);
let source = content[source_pos + source_marker.len()..expect_pos]
.trim()
.to_string();
let expect_section = content[expect_pos + expect_marker.len()..].trim();
let expected: Vec<ExpectedIssue> = expect_section
.lines()
.map(str::trim)
.filter(|l| !l.is_empty() && !l.starts_with('#'))
.map(|l| parse_expected_line(l, path))
.collect();
(source, expected)
}
fn parse_phpt_source_only(content: &str, path: &str) -> String {
let source_marker = "===source===";
let expect_marker = "===expect===";
let source_pos = content
.find(source_marker)
.unwrap_or_else(|| panic!("fixture {} missing ===source=== section", path));
let expect_pos = content
.find(expect_marker)
.unwrap_or_else(|| panic!("fixture {} missing ===expect=== section", path));
content[source_pos + source_marker.len()..expect_pos]
.trim()
.to_string()
}
fn parse_expected_line(line: &str, fixture_path: &str) -> ExpectedIssue {
let parts: Vec<&str> = line.splitn(2, ": ").collect();
assert_eq!(
parts.len(),
2,
"fixture {}: invalid expect line {:?} — expected \"KindName: snippet\"",
fixture_path,
line
);
ExpectedIssue {
kind_name: parts[0].trim().to_string(),
snippet: parts[1].trim().to_string(),
}
}
pub fn run_fixture(path: &str) {
let content = std::fs::read_to_string(path)
.unwrap_or_else(|e| panic!("failed to read fixture {}: {}", path, e));
if std::env::var("UPDATE_FIXTURES").as_deref() == Ok("1") {
let source = parse_phpt_source_only(&content, path);
let actual = check(&source);
rewrite_fixture(path, &content, &actual);
return;
}
let (source, expected) = parse_phpt(&content, path);
let actual = check(&source);
let mut failures: Vec<String> = Vec::new();
for exp in &expected {
let found = actual.iter().any(|a| {
if a.kind.name() != exp.kind_name {
return false;
}
if exp.snippet == "<no snippet>" {
a.snippet.is_none()
} else {
a.snippet.as_deref() == Some(exp.snippet.as_str())
}
});
if !found {
failures.push(format!(" MISSING {}: {}", exp.kind_name, exp.snippet));
}
}
for act in &actual {
let expected_it = expected.iter().any(|e| {
if e.kind_name != act.kind.name() {
return false;
}
if e.snippet == "<no snippet>" {
act.snippet.is_none()
} else {
act.snippet.as_deref() == Some(e.snippet.as_str())
}
});
if !expected_it {
let snippet = act.snippet.as_deref().unwrap_or("<no snippet>");
failures.push(format!(
" UNEXPECTED {}: {} — {}",
act.kind.name(),
snippet,
act.kind.message(),
));
}
}
if !failures.is_empty() {
panic!(
"fixture {} FAILED:\n{}\n\nAll actual issues:\n{}",
path,
failures.join("\n"),
fmt_issues(&actual)
);
}
}
fn rewrite_fixture(path: &str, content: &str, actual: &[Issue]) {
let source_marker = "===source===";
let expect_marker = "===expect===";
let source_pos = content.find(source_marker).expect("missing ===source===");
let expect_pos = content.find(expect_marker).expect("missing ===expect===");
let source_section = &content[source_pos..expect_pos];
let mut new_content = String::new();
new_content.push_str(source_section);
new_content.push_str(expect_marker);
new_content.push('\n');
let mut sorted: Vec<&Issue> = actual.iter().collect();
sorted.sort_by_key(|i| (i.location.line, i.location.col_start, i.kind.name()));
for issue in sorted {
let snippet = issue.snippet.as_deref().unwrap_or("<no snippet>");
new_content.push_str(&format!("{}: {}\n", issue.kind.name(), snippet));
}
std::fs::write(path, &new_content)
.unwrap_or_else(|e| panic!("failed to write fixture {}: {}", path, e));
}
pub fn assert_issue(issues: &[Issue], kind: IssueKind, line: u32, col_start: u16) {
let found = issues
.iter()
.any(|i| i.kind == kind && i.location.line == line && i.location.col_start == col_start);
if !found {
panic!(
"Expected issue {:?} at line {}, col {}.\nActual issues:\n{}",
kind,
line,
col_start,
fmt_issues(issues),
);
}
}
pub fn assert_issue_kind(issues: &[Issue], kind_name: &str, line: u32, col_start: u16) {
let found = issues.iter().any(|i| {
i.kind.name() == kind_name && i.location.line == line && i.location.col_start == col_start
});
if !found {
panic!(
"Expected issue {} at line {}, col {}.\nActual issues:\n{}",
kind_name,
line,
col_start,
fmt_issues(issues),
);
}
}
pub fn assert_no_issue(issues: &[Issue], kind_name: &str) {
let found: Vec<_> = issues
.iter()
.filter(|i| i.kind.name() == kind_name)
.collect();
if !found.is_empty() {
panic!(
"Expected no {} issues, but found:\n{}",
kind_name,
fmt_issues(&found.into_iter().cloned().collect::<Vec<_>>()),
);
}
}
fn fmt_issues(issues: &[Issue]) -> String {
if issues.is_empty() {
return " (none)".to_string();
}
issues
.iter()
.map(|i| {
let snippet = i.snippet.as_deref().unwrap_or("<no snippet>");
format!(" {}: {} — {}", i.kind.name(), snippet, i.kind.message(),)
})
.collect::<Vec<_>>()
.join("\n")
}