1use std::path::PathBuf;
2use std::sync::atomic::{AtomicU64, Ordering};
3
4use mir_analyzer::project::ProjectAnalyzer;
5use mir_issues::{Issue, IssueKind};
6
7static COUNTER: AtomicU64 = AtomicU64::new(0);
8
9pub fn check(src: &str) -> Vec<Issue> {
13 let id = COUNTER.fetch_add(1, Ordering::Relaxed);
14 let tmp: PathBuf = std::env::temp_dir().join(format!("mir_test_{}.php", id));
15 std::fs::write(&tmp, src)
16 .unwrap_or_else(|e| panic!("failed to write temp PHP file {}: {}", tmp.display(), e));
17 let result = ProjectAnalyzer::new().analyze(std::slice::from_ref(&tmp));
18 std::fs::remove_file(&tmp).ok();
19 result
20 .issues
21 .into_iter()
22 .filter(|i| !i.suppressed)
23 .collect()
24}
25
26pub struct ExpectedIssue {
34 pub kind_name: String,
35 pub snippet: String,
36}
37
38pub fn parse_phpt(content: &str, path: &str) -> (String, Vec<ExpectedIssue>) {
51 let source_marker = "===source===";
52 let expect_marker = "===expect===";
53
54 let source_pos = content
55 .find(source_marker)
56 .unwrap_or_else(|| panic!("fixture {} missing ===source=== section", path));
57 let expect_pos = content
58 .find(expect_marker)
59 .unwrap_or_else(|| panic!("fixture {} missing ===expect=== section", path));
60
61 assert!(
62 source_pos < expect_pos,
63 "fixture {}: ===source=== must come before ===expect===",
64 path
65 );
66
67 let source = content[source_pos + source_marker.len()..expect_pos]
68 .trim()
69 .to_string();
70 let expect_section = content[expect_pos + expect_marker.len()..].trim();
71
72 let expected: Vec<ExpectedIssue> = expect_section
73 .lines()
74 .map(str::trim)
75 .filter(|l| !l.is_empty() && !l.starts_with('#'))
76 .map(|l| parse_expected_line(l, path))
77 .collect();
78
79 (source, expected)
80}
81
82fn parse_phpt_source_only(content: &str, path: &str) -> String {
85 let source_marker = "===source===";
86 let expect_marker = "===expect===";
87
88 let source_pos = content
89 .find(source_marker)
90 .unwrap_or_else(|| panic!("fixture {} missing ===source=== section", path));
91 let expect_pos = content
92 .find(expect_marker)
93 .unwrap_or_else(|| panic!("fixture {} missing ===expect=== section", path));
94
95 content[source_pos + source_marker.len()..expect_pos]
96 .trim()
97 .to_string()
98}
99
100fn parse_expected_line(line: &str, fixture_path: &str) -> ExpectedIssue {
101 let parts: Vec<&str> = line.splitn(2, ": ").collect();
103 assert_eq!(
104 parts.len(),
105 2,
106 "fixture {}: invalid expect line {:?} — expected \"KindName: snippet\"",
107 fixture_path,
108 line
109 );
110 ExpectedIssue {
111 kind_name: parts[0].trim().to_string(),
112 snippet: parts[1].trim().to_string(),
113 }
114}
115
116pub fn run_fixture(path: &str) {
124 let content = std::fs::read_to_string(path)
125 .unwrap_or_else(|e| panic!("failed to read fixture {}: {}", path, e));
126
127 if std::env::var("UPDATE_FIXTURES").as_deref() == Ok("1") {
128 let source = parse_phpt_source_only(&content, path);
129 let actual = check(&source);
130 rewrite_fixture(path, &content, &actual);
131 return;
132 }
133
134 let (source, expected) = parse_phpt(&content, path);
135 let actual = check(&source);
136
137 let mut failures: Vec<String> = Vec::new();
138
139 for exp in &expected {
140 let found = actual.iter().any(|a| {
141 if a.kind.name() != exp.kind_name {
142 return false;
143 }
144 if exp.snippet == "<no snippet>" {
145 a.snippet.is_none()
146 } else {
147 a.snippet.as_deref() == Some(exp.snippet.as_str())
148 }
149 });
150 if !found {
151 failures.push(format!(" MISSING {}: {}", exp.kind_name, exp.snippet));
152 }
153 }
154
155 for act in &actual {
156 let expected_it = expected.iter().any(|e| {
157 if e.kind_name != act.kind.name() {
158 return false;
159 }
160 if e.snippet == "<no snippet>" {
161 act.snippet.is_none()
162 } else {
163 act.snippet.as_deref() == Some(e.snippet.as_str())
164 }
165 });
166 if !expected_it {
167 let snippet = act.snippet.as_deref().unwrap_or("<no snippet>");
168 failures.push(format!(
169 " UNEXPECTED {}: {} — {}",
170 act.kind.name(),
171 snippet,
172 act.kind.message(),
173 ));
174 }
175 }
176
177 if !failures.is_empty() {
178 panic!(
179 "fixture {} FAILED:\n{}\n\nAll actual issues:\n{}",
180 path,
181 failures.join("\n"),
182 fmt_issues(&actual)
183 );
184 }
185}
186
187fn rewrite_fixture(path: &str, content: &str, actual: &[Issue]) {
190 let source_marker = "===source===";
191 let expect_marker = "===expect===";
192
193 let source_pos = content.find(source_marker).expect("missing ===source===");
194 let expect_pos = content.find(expect_marker).expect("missing ===expect===");
195
196 let source_section = &content[source_pos..expect_pos];
197
198 let mut new_content = String::new();
199 new_content.push_str(source_section);
200 new_content.push_str(expect_marker);
201 new_content.push('\n');
202
203 let mut sorted: Vec<&Issue> = actual.iter().collect();
205 sorted.sort_by_key(|i| (i.location.line, i.location.col_start, i.kind.name()));
206
207 for issue in sorted {
208 let snippet = issue.snippet.as_deref().unwrap_or("<no snippet>");
209 new_content.push_str(&format!("{}: {}\n", issue.kind.name(), snippet));
210 }
211
212 std::fs::write(path, &new_content)
213 .unwrap_or_else(|e| panic!("failed to write fixture {}: {}", path, e));
214}
215
216#[macro_export]
225macro_rules! fixture_test {
226 ($name:ident, $path:expr) => {
227 #[test]
228 fn $name() {
229 mir_test_utils::run_fixture(concat!(
230 env!("CARGO_MANIFEST_DIR"),
231 "/tests/fixtures/",
232 $path
233 ));
234 }
235 };
236}
237
238pub fn assert_issue(issues: &[Issue], kind: IssueKind, line: u32, col_start: u16) {
245 let found = issues
246 .iter()
247 .any(|i| i.kind == kind && i.location.line == line && i.location.col_start == col_start);
248 if !found {
249 panic!(
250 "Expected issue {:?} at line {}, col {}.\nActual issues:\n{}",
251 kind,
252 line,
253 col_start,
254 fmt_issues(issues),
255 );
256 }
257}
258
259pub fn assert_issue_kind(issues: &[Issue], kind_name: &str, line: u32, col_start: u16) {
263 let found = issues.iter().any(|i| {
264 i.kind.name() == kind_name && i.location.line == line && i.location.col_start == col_start
265 });
266 if !found {
267 panic!(
268 "Expected issue {} at line {}, col {}.\nActual issues:\n{}",
269 kind_name,
270 line,
271 col_start,
272 fmt_issues(issues),
273 );
274 }
275}
276
277pub fn assert_no_issue(issues: &[Issue], kind_name: &str) {
280 let found: Vec<_> = issues
281 .iter()
282 .filter(|i| i.kind.name() == kind_name)
283 .collect();
284 if !found.is_empty() {
285 panic!(
286 "Expected no {} issues, but found:\n{}",
287 kind_name,
288 fmt_issues(&found.into_iter().cloned().collect::<Vec<_>>()),
289 );
290 }
291}
292
293fn fmt_issues(issues: &[Issue]) -> String {
294 if issues.is_empty() {
295 return " (none)".to_string();
296 }
297 issues
298 .iter()
299 .map(|i| {
300 let snippet = i.snippet.as_deref().unwrap_or("<no snippet>");
301 format!(" {}: {} — {}", i.kind.name(), snippet, i.kind.message(),)
302 })
303 .collect::<Vec<_>>()
304 .join("\n")
305}