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    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
31// ---------------------------------------------------------------------------
32// Fixture-based test support
33// ---------------------------------------------------------------------------
34
35/// One expected issue from a `.phpt` fixture's `===expect===` section.
36///
37/// Format: `KindName: snippet`
38pub struct ExpectedIssue {
39    pub kind_name: String,
40    pub snippet: String,
41}
42
43/// Parse a `.phpt` fixture file into `(php_source, expected_issues)`.
44///
45/// Fixture format:
46/// ```text
47/// ===source===
48/// <?php
49/// ...
50/// ===expect===
51/// UndefinedClass: UnknownClass
52/// UndefinedFunction: foo()
53/// ```
54/// An empty `===expect===` section means no issues are expected.
55pub 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
87/// Extract only the source section from a fixture file (used in UPDATE_FIXTURES mode
88/// to avoid parsing potentially stale/old-format expect sections).
89fn 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    // Format: "KindName: snippet"
107    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
121/// Run a `.phpt` fixture file: parse, analyze, and assert the issues match
122/// the `===expect===` section exactly (no missing, no unexpected).
123///
124/// If the environment variable `UPDATE_FIXTURES` is set to `1`, the fixture
125/// file is rewritten with the actual issues instead of asserting.
126///
127/// Called by the auto-generated test functions in `build.rs`.
128pub 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
192/// Rewrite the fixture file's `===expect===` section with the actual issues.
193/// Preserves the `===source===` section unchanged.
194fn 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    // Sort issues by (line, col, kind) for deterministic output.
209    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
221// ---------------------------------------------------------------------------
222// Assertion helpers (used by inline tests)
223// ---------------------------------------------------------------------------
224
225/// Assert that `issues` contains at least one issue with the exact `IssueKind`
226/// at `line` and `col_start`. Panics with the full issue list on failure.
227pub 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
242/// Assert that `issues` contains at least one issue whose `kind.name()` equals
243/// `kind_name`, at `line` and `col_start`. Use this when the exact IssueKind
244/// field values are complex (e.g. type-format strings in InvalidArgument).
245pub 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
260/// Assert that `issues` contains no issue whose `kind.name()` equals `kind_name`.
261/// Panics with the matching issues on failure.
262pub 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}