Skip to main content

mir_test_utils/
lib.rs

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
9/// Run the full analyzer on an inline PHP string.
10/// Creates a unique temp file, analyzes it, deletes it, and returns all
11/// unsuppressed issues.
12pub 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
26// ---------------------------------------------------------------------------
27// Fixture-based test support
28// ---------------------------------------------------------------------------
29
30/// One expected issue from a `.phpt` fixture's `===expect===` section.
31///
32/// Format: `KindName: snippet`
33pub struct ExpectedIssue {
34    pub kind_name: String,
35    pub snippet: String,
36}
37
38/// Parse a `.phpt` fixture file into `(php_source, expected_issues)`.
39///
40/// Fixture format:
41/// ```text
42/// ===source===
43/// <?php
44/// ...
45/// ===expect===
46/// UndefinedClass: UnknownClass
47/// UndefinedFunction: foo()
48/// ```
49/// An empty `===expect===` section means no issues are expected.
50pub 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
82/// Extract only the source section from a fixture file (used in UPDATE_FIXTURES mode
83/// to avoid parsing potentially stale/old-format expect sections).
84fn 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    // Format: "KindName: snippet"
102    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
116/// Run a `.phpt` fixture file: parse, analyze, and assert the issues match
117/// the `===expect===` section exactly (no missing, no unexpected).
118///
119/// If the environment variable `UPDATE_FIXTURES` is set to `1`, the fixture
120/// file is rewritten with the actual issues instead of asserting.
121///
122/// Called by the auto-generated test functions in `build.rs`.
123pub 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
187/// Rewrite the fixture file's `===expect===` section with the actual issues.
188/// Preserves the `===source===` section unchanged.
189fn 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    // Sort issues by (line, col, kind) for deterministic output.
204    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/// Generate a `#[test]` function that runs a `.phpt` fixture file.
217///
218/// The path is relative to the crate's `tests/fixtures/` directory.
219///
220/// # Example
221/// ```rust,ignore
222/// fixture_test!(new_unknown_class, "undefined_class/new_unknown_class.phpt");
223/// ```
224#[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
238// ---------------------------------------------------------------------------
239// Assertion helpers (used by inline tests)
240// ---------------------------------------------------------------------------
241
242/// Assert that `issues` contains at least one issue with the exact `IssueKind`
243/// at `line` and `col_start`. Panics with the full issue list on failure.
244pub 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
259/// Assert that `issues` contains at least one issue whose `kind.name()` equals
260/// `kind_name`, at `line` and `col_start`. Use this when the exact IssueKind
261/// field values are complex (e.g. type-format strings in InvalidArgument).
262pub 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
277/// Assert that `issues` contains no issue whose `kind.name()` equals `kind_name`.
278/// Panics with the matching issues on failure.
279pub 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}