1use std::path::PathBuf;
7use std::sync::atomic::{AtomicU64, Ordering};
8
9use crate::project::ProjectAnalyzer;
10use mir_issues::{Issue, IssueKind};
11
12static COUNTER: AtomicU64 = AtomicU64::new(0);
13
14pub fn check(src: &str) -> Vec<Issue> {
18 check_with_opts(src, false)
19}
20
21pub fn check_dead_code(src: &str) -> Vec<Issue> {
24 check_with_opts(src, true)
25}
26
27fn check_with_opts(src: &str, find_dead_code: bool) -> Vec<Issue> {
28 let id = COUNTER.fetch_add(1, Ordering::Relaxed);
29 let tmp: PathBuf = std::env::temp_dir().join(format!("mir_test_{}.php", id));
30 std::fs::write(&tmp, src)
31 .unwrap_or_else(|e| panic!("failed to write temp PHP file {}: {}", tmp.display(), e));
32 let mut analyzer = ProjectAnalyzer::new();
33 analyzer.find_dead_code = find_dead_code;
34 let result = analyzer.analyze(std::slice::from_ref(&tmp));
35 let tmp_str = tmp.to_string_lossy().into_owned();
36 std::fs::remove_file(&tmp).ok();
37 result
38 .issues
39 .into_iter()
40 .filter(|i| !i.suppressed)
41 .filter(|i| !find_dead_code || i.location.file.as_ref() == tmp_str.as_str())
46 .collect()
47}
48
49pub struct ExpectedIssue {
57 pub kind_name: String,
58 pub snippet: String,
59}
60
61pub fn parse_phpt(content: &str, path: &str) -> (String, Vec<ExpectedIssue>) {
74 let source_marker = "===source===";
75 let expect_marker = "===expect===";
76
77 let source_pos = content
78 .find(source_marker)
79 .unwrap_or_else(|| panic!("fixture {} missing ===source=== section", path));
80 let expect_pos = content
81 .find(expect_marker)
82 .unwrap_or_else(|| panic!("fixture {} missing ===expect=== section", path));
83
84 assert!(
85 source_pos < expect_pos,
86 "fixture {}: ===source=== must come before ===expect===",
87 path
88 );
89
90 let source = content[source_pos + source_marker.len()..expect_pos]
91 .trim()
92 .to_string();
93 let expect_section = content[expect_pos + expect_marker.len()..].trim();
94
95 let expected: Vec<ExpectedIssue> = expect_section
96 .lines()
97 .map(str::trim)
98 .filter(|l| !l.is_empty() && !l.starts_with('#'))
99 .map(|l| parse_expected_line(l, path))
100 .collect();
101
102 (source, expected)
103}
104
105fn parse_phpt_source_only(content: &str, path: &str) -> String {
108 let source_marker = "===source===";
109 let expect_marker = "===expect===";
110
111 let source_pos = content
112 .find(source_marker)
113 .unwrap_or_else(|| panic!("fixture {} missing ===source=== section", path));
114 let expect_pos = content
115 .find(expect_marker)
116 .unwrap_or_else(|| panic!("fixture {} missing ===expect=== section", path));
117
118 content[source_pos + source_marker.len()..expect_pos]
119 .trim()
120 .to_string()
121}
122
123fn parse_expected_line(line: &str, fixture_path: &str) -> ExpectedIssue {
124 let parts: Vec<&str> = line.splitn(2, ": ").collect();
126 assert_eq!(
127 parts.len(),
128 2,
129 "fixture {}: invalid expect line {:?} — expected \"KindName: snippet\"",
130 fixture_path,
131 line
132 );
133 ExpectedIssue {
134 kind_name: parts[0].trim().to_string(),
135 snippet: parts[1].trim().to_string(),
136 }
137}
138
139pub fn run_fixture(path: &str) {
147 run_fixture_with_opts(path, false);
148}
149
150pub fn run_fixture_dead_code(path: &str) {
153 run_fixture_with_opts(path, true);
154}
155
156fn run_fixture_with_opts(path: &str, find_dead_code: bool) {
157 let content = std::fs::read_to_string(path)
158 .unwrap_or_else(|e| panic!("failed to read fixture {}: {}", path, e));
159
160 if std::env::var("UPDATE_FIXTURES").as_deref() == Ok("1") {
161 let source = parse_phpt_source_only(&content, path);
162 let actual = check_with_opts(&source, find_dead_code);
163 rewrite_fixture(path, &content, &actual);
164 return;
165 }
166
167 let (source, expected) = parse_phpt(&content, path);
168 let actual = check_with_opts(&source, find_dead_code);
169
170 let mut failures: Vec<String> = Vec::new();
171
172 for exp in &expected {
173 let found = actual.iter().any(|a| {
174 if a.kind.name() != exp.kind_name {
175 return false;
176 }
177 if exp.snippet == "<no snippet>" {
178 a.snippet.is_none()
179 } else {
180 a.snippet.as_deref() == Some(exp.snippet.as_str())
181 }
182 });
183 if !found {
184 failures.push(format!(" MISSING {}: {}", exp.kind_name, exp.snippet));
185 }
186 }
187
188 for act in &actual {
189 let expected_it = expected.iter().any(|e| {
190 if e.kind_name != act.kind.name() {
191 return false;
192 }
193 if e.snippet == "<no snippet>" {
194 act.snippet.is_none()
195 } else {
196 act.snippet.as_deref() == Some(e.snippet.as_str())
197 }
198 });
199 if !expected_it {
200 let snippet = act.snippet.as_deref().unwrap_or("<no snippet>");
201 failures.push(format!(
202 " UNEXPECTED {}: {} — {}",
203 act.kind.name(),
204 snippet,
205 act.kind.message(),
206 ));
207 }
208 }
209
210 if !failures.is_empty() {
211 panic!(
212 "fixture {} FAILED:\n{}\n\nAll actual issues:\n{}",
213 path,
214 failures.join("\n"),
215 fmt_issues(&actual)
216 );
217 }
218}
219
220fn rewrite_fixture(path: &str, content: &str, actual: &[Issue]) {
223 let source_marker = "===source===";
224 let expect_marker = "===expect===";
225
226 let source_pos = content.find(source_marker).expect("missing ===source===");
227 let expect_pos = content.find(expect_marker).expect("missing ===expect===");
228
229 let source_section = &content[source_pos..expect_pos];
230
231 let mut new_content = String::new();
232 new_content.push_str(source_section);
233 new_content.push_str(expect_marker);
234 new_content.push('\n');
235
236 let mut sorted: Vec<&Issue> = actual.iter().collect();
238 sorted.sort_by_key(|i| (i.location.line, i.location.col_start, i.kind.name()));
239
240 for issue in sorted {
241 let snippet = issue.snippet.as_deref().unwrap_or("<no snippet>");
242 new_content.push_str(&format!("{}: {}\n", issue.kind.name(), snippet));
243 }
244
245 std::fs::write(path, &new_content)
246 .unwrap_or_else(|e| panic!("failed to write fixture {}: {}", path, e));
247}
248
249pub fn assert_issue(issues: &[Issue], kind: IssueKind, line: u32, col_start: u16) {
256 let found = issues
257 .iter()
258 .any(|i| i.kind == kind && i.location.line == line && i.location.col_start == col_start);
259 if !found {
260 panic!(
261 "Expected issue {:?} at line {}, col {}.\nActual issues:\n{}",
262 kind,
263 line,
264 col_start,
265 fmt_issues(issues),
266 );
267 }
268}
269
270pub fn assert_issue_kind(issues: &[Issue], kind_name: &str, line: u32, col_start: u16) {
274 let found = issues.iter().any(|i| {
275 i.kind.name() == kind_name && i.location.line == line && i.location.col_start == col_start
276 });
277 if !found {
278 panic!(
279 "Expected issue {} at line {}, col {}.\nActual issues:\n{}",
280 kind_name,
281 line,
282 col_start,
283 fmt_issues(issues),
284 );
285 }
286}
287
288pub fn assert_no_issue(issues: &[Issue], kind_name: &str) {
291 let found: Vec<_> = issues
292 .iter()
293 .filter(|i| i.kind.name() == kind_name)
294 .collect();
295 if !found.is_empty() {
296 panic!(
297 "Expected no {} issues, but found:\n{}",
298 kind_name,
299 fmt_issues(&found.into_iter().cloned().collect::<Vec<_>>()),
300 );
301 }
302}
303
304fn fmt_issues(issues: &[Issue]) -> String {
305 if issues.is_empty() {
306 return " (none)".to_string();
307 }
308 issues
309 .iter()
310 .map(|i| {
311 let snippet = i.snippet.as_deref().unwrap_or("<no snippet>");
312 format!(" {}: {} — {}", i.kind.name(), snippet, i.kind.message(),)
313 })
314 .collect::<Vec<_>>()
315 .join("\n")
316}