Skip to main content

mir_analyzer/
test_utils.rs

1//! Test utilities for fixture-based testing.
2//!
3//! Provides helpers to run `.phpt` fixture files against the analyzer
4//! and compare actual vs expected issues.
5
6use 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
14/// Run the full analyzer on an inline PHP string.
15/// Creates a unique temp file, analyzes it, deletes it, and returns all
16/// unsuppressed issues.
17pub fn check(src: &str) -> Vec<Issue> {
18    check_with_opts(src, false)
19}
20
21/// Like [`check`] but also runs the dead-code detector
22/// (`UnusedMethod`, `UnusedProperty`).
23pub 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        // When dead-code analysis is enabled the analyzer walks the entire
42        // codebase (including PHP stubs).  Filter to issues originating from
43        // the test file only so that stub-side false positives don't pollute
44        // the fixture output.
45        .filter(|i| !find_dead_code || i.location.file.as_ref() == tmp_str.as_str())
46        .collect()
47}
48
49// ---------------------------------------------------------------------------
50// Fixture-based test support
51// ---------------------------------------------------------------------------
52
53/// One expected issue from a `.phpt` fixture's `===expect===` section.
54///
55/// Format: `KindName: snippet`
56pub struct ExpectedIssue {
57    pub kind_name: String,
58    pub snippet: String,
59}
60
61/// Parse a `.phpt` fixture file into `(php_source, expected_issues)`.
62///
63/// Fixture format:
64/// ```text
65/// ===source===
66/// <?php
67/// ...
68/// ===expect===
69/// UndefinedClass: UnknownClass
70/// UndefinedFunction: foo()
71/// ```
72/// An empty `===expect===` section means no issues are expected.
73pub 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
105/// Extract only the source section from a fixture file (used in UPDATE_FIXTURES mode
106/// to avoid parsing potentially stale/old-format expect sections).
107fn 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    // Format: "KindName: snippet"
125    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
139/// Run a `.phpt` fixture file: parse, analyze, and assert the issues match
140/// the `===expect===` section exactly (no missing, no unexpected).
141///
142/// If the environment variable `UPDATE_FIXTURES` is set to `1`, the fixture
143/// file is rewritten with the actual issues instead of asserting.
144///
145/// Called by the auto-generated test functions in `build.rs`.
146pub fn run_fixture(path: &str) {
147    run_fixture_with_opts(path, false);
148}
149
150/// Like [`run_fixture`] but also enables the dead-code detector for issue kinds
151/// such as `UnusedMethod` and `UnusedProperty` that require it.
152pub 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
220/// Rewrite the fixture file's `===expect===` section with the actual issues.
221/// Preserves the `===source===` section unchanged.
222fn 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    // Sort issues by (line, col, kind) for deterministic output.
237    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
249// ---------------------------------------------------------------------------
250// Assertion helpers (used by inline tests)
251// ---------------------------------------------------------------------------
252
253/// Assert that `issues` contains at least one issue with the exact `IssueKind`
254/// at `line` and `col_start`. Panics with the full issue list on failure.
255pub 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
270/// Assert that `issues` contains at least one issue whose `kind.name()` equals
271/// `kind_name`, at `line` and `col_start`. Use this when the exact IssueKind
272/// field values are complex (e.g. type-format strings in InvalidArgument).
273pub 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
288/// Assert that `issues` contains no issue whose `kind.name()` equals `kind_name`.
289/// Panics with the matching issues on failure.
290pub 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}