mir_analyzer/
test_utils.rs1use 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 let id = COUNTER.fetch_add(1, Ordering::Relaxed);
19 let tmp: PathBuf = std::env::temp_dir().join(format!("mir_test_{}.php", id));
20 std::fs::write(&tmp, src)
21 .unwrap_or_else(|e| panic!("failed to write temp PHP file {}: {}", tmp.display(), e));
22 let result = ProjectAnalyzer::new().analyze(std::slice::from_ref(&tmp));
23 std::fs::remove_file(&tmp).ok();
24 result
25 .issues
26 .into_iter()
27 .filter(|i| !i.suppressed)
28 .collect()
29}
30
31pub struct ExpectedIssue {
39 pub kind_name: String,
40 pub snippet: String,
41}
42
43pub fn parse_phpt(content: &str, path: &str) -> (String, Vec<ExpectedIssue>) {
56 let source_marker = "===source===";
57 let expect_marker = "===expect===";
58
59 let source_pos = content
60 .find(source_marker)
61 .unwrap_or_else(|| panic!("fixture {} missing ===source=== section", path));
62 let expect_pos = content
63 .find(expect_marker)
64 .unwrap_or_else(|| panic!("fixture {} missing ===expect=== section", path));
65
66 assert!(
67 source_pos < expect_pos,
68 "fixture {}: ===source=== must come before ===expect===",
69 path
70 );
71
72 let source = content[source_pos + source_marker.len()..expect_pos]
73 .trim()
74 .to_string();
75 let expect_section = content[expect_pos + expect_marker.len()..].trim();
76
77 let expected: Vec<ExpectedIssue> = expect_section
78 .lines()
79 .map(str::trim)
80 .filter(|l| !l.is_empty() && !l.starts_with('#'))
81 .map(|l| parse_expected_line(l, path))
82 .collect();
83
84 (source, expected)
85}
86
87fn parse_phpt_source_only(content: &str, path: &str) -> String {
90 let source_marker = "===source===";
91 let expect_marker = "===expect===";
92
93 let source_pos = content
94 .find(source_marker)
95 .unwrap_or_else(|| panic!("fixture {} missing ===source=== section", path));
96 let expect_pos = content
97 .find(expect_marker)
98 .unwrap_or_else(|| panic!("fixture {} missing ===expect=== section", path));
99
100 content[source_pos + source_marker.len()..expect_pos]
101 .trim()
102 .to_string()
103}
104
105fn parse_expected_line(line: &str, fixture_path: &str) -> ExpectedIssue {
106 let parts: Vec<&str> = line.splitn(2, ": ").collect();
108 assert_eq!(
109 parts.len(),
110 2,
111 "fixture {}: invalid expect line {:?} — expected \"KindName: snippet\"",
112 fixture_path,
113 line
114 );
115 ExpectedIssue {
116 kind_name: parts[0].trim().to_string(),
117 snippet: parts[1].trim().to_string(),
118 }
119}
120
121pub fn run_fixture(path: &str) {
129 let content = std::fs::read_to_string(path)
130 .unwrap_or_else(|e| panic!("failed to read fixture {}: {}", path, e));
131
132 if std::env::var("UPDATE_FIXTURES").as_deref() == Ok("1") {
133 let source = parse_phpt_source_only(&content, path);
134 let actual = check(&source);
135 rewrite_fixture(path, &content, &actual);
136 return;
137 }
138
139 let (source, expected) = parse_phpt(&content, path);
140 let actual = check(&source);
141
142 let mut failures: Vec<String> = Vec::new();
143
144 for exp in &expected {
145 let found = actual.iter().any(|a| {
146 if a.kind.name() != exp.kind_name {
147 return false;
148 }
149 if exp.snippet == "<no snippet>" {
150 a.snippet.is_none()
151 } else {
152 a.snippet.as_deref() == Some(exp.snippet.as_str())
153 }
154 });
155 if !found {
156 failures.push(format!(" MISSING {}: {}", exp.kind_name, exp.snippet));
157 }
158 }
159
160 for act in &actual {
161 let expected_it = expected.iter().any(|e| {
162 if e.kind_name != act.kind.name() {
163 return false;
164 }
165 if e.snippet == "<no snippet>" {
166 act.snippet.is_none()
167 } else {
168 act.snippet.as_deref() == Some(e.snippet.as_str())
169 }
170 });
171 if !expected_it {
172 let snippet = act.snippet.as_deref().unwrap_or("<no snippet>");
173 failures.push(format!(
174 " UNEXPECTED {}: {} — {}",
175 act.kind.name(),
176 snippet,
177 act.kind.message(),
178 ));
179 }
180 }
181
182 if !failures.is_empty() {
183 panic!(
184 "fixture {} FAILED:\n{}\n\nAll actual issues:\n{}",
185 path,
186 failures.join("\n"),
187 fmt_issues(&actual)
188 );
189 }
190}
191
192fn rewrite_fixture(path: &str, content: &str, actual: &[Issue]) {
195 let source_marker = "===source===";
196 let expect_marker = "===expect===";
197
198 let source_pos = content.find(source_marker).expect("missing ===source===");
199 let expect_pos = content.find(expect_marker).expect("missing ===expect===");
200
201 let source_section = &content[source_pos..expect_pos];
202
203 let mut new_content = String::new();
204 new_content.push_str(source_section);
205 new_content.push_str(expect_marker);
206 new_content.push('\n');
207
208 let mut sorted: Vec<&Issue> = actual.iter().collect();
210 sorted.sort_by_key(|i| (i.location.line, i.location.col_start, i.kind.name()));
211
212 for issue in sorted {
213 let snippet = issue.snippet.as_deref().unwrap_or("<no snippet>");
214 new_content.push_str(&format!("{}: {}\n", issue.kind.name(), snippet));
215 }
216
217 std::fs::write(path, &new_content)
218 .unwrap_or_else(|e| panic!("failed to write fixture {}: {}", path, e));
219}
220
221pub fn assert_issue(issues: &[Issue], kind: IssueKind, line: u32, col_start: u16) {
228 let found = issues
229 .iter()
230 .any(|i| i.kind == kind && i.location.line == line && i.location.col_start == col_start);
231 if !found {
232 panic!(
233 "Expected issue {:?} at line {}, col {}.\nActual issues:\n{}",
234 kind,
235 line,
236 col_start,
237 fmt_issues(issues),
238 );
239 }
240}
241
242pub fn assert_issue_kind(issues: &[Issue], kind_name: &str, line: u32, col_start: u16) {
246 let found = issues.iter().any(|i| {
247 i.kind.name() == kind_name && i.location.line == line && i.location.col_start == col_start
248 });
249 if !found {
250 panic!(
251 "Expected issue {} at line {}, col {}.\nActual issues:\n{}",
252 kind_name,
253 line,
254 col_start,
255 fmt_issues(issues),
256 );
257 }
258}
259
260pub fn assert_no_issue(issues: &[Issue], kind_name: &str) {
263 let found: Vec<_> = issues
264 .iter()
265 .filter(|i| i.kind.name() == kind_name)
266 .collect();
267 if !found.is_empty() {
268 panic!(
269 "Expected no {} issues, but found:\n{}",
270 kind_name,
271 fmt_issues(&found.into_iter().cloned().collect::<Vec<_>>()),
272 );
273 }
274}
275
276fn fmt_issues(issues: &[Issue]) -> String {
277 if issues.is_empty() {
278 return " (none)".to_string();
279 }
280 issues
281 .iter()
282 .map(|i| {
283 let snippet = i.snippet.as_deref().unwrap_or("<no snippet>");
284 format!(" {}: {} — {}", i.kind.name(), snippet, i.kind.message(),)
285 })
286 .collect::<Vec<_>>()
287 .join("\n")
288}