Skip to main content

mir_analyzer/
test_utils.rs

1//! Test utilities for fixture-based testing.
2//!
3//! # Fixture formats
4//!
5//! **Single-file** (`===file===`, appears exactly once):
6//! ```text
7//! ===file===
8//! <?php
9//! ...
10//! ===expect===
11//! UndefinedMethod: Method Foo::bar() does not exist
12//! ```
13//!
14//! **Multi-file** (`===file:name===`, one or more):
15//! ```text
16//! ===file:Base.php===
17//! <?php
18//! class Base { ... }
19//! ===file:Child.php===
20//! <?php
21//! class Child extends Base { ... }
22//! ===expect===
23//! Child.php: UndefinedMethod: Method Child::bar() does not exist
24//! ```
25//!
26//! **With config** (optional `===config===` section, must appear before file sections):
27//! ```text
28//! ===config===
29//! php_version=8.1
30//! find_dead_code=true
31//! ===file===
32//! <?php
33//! ...
34//! ===expect===
35//! ...
36//! ```
37//!
38//! **With Composer/PSR-4**:
39//! ```text
40//! ===file:composer.json===
41//! {"autoload":{"psr-4":{"App\\":"src/"}}}
42//! ===file:src/Base.php===
43//! <?php
44//! namespace App;
45//! class Base { ... }
46//! ===file:Child.php===
47//! <?php
48//! class Child extends \App\Base { ... }
49//! ===expect===
50//! Child.php: UndefinedMethod: Method Child::bar() does not exist
51//! ```
52//!
53//! # Validation rules
54//!
55//! - `===file===` (bare, no name) must appear **at most once** per fixture.
56//! - `===file===` and `===file:name===` cannot appear in the same fixture.
57//! - A fixture with no file section at all fails immediately.
58//! - `===config===` must appear **at most once** per fixture.
59//! - Every key in `===config===` must be a recognised key (`php_version`,
60//!   `find_dead_code`); unknown keys fail the test.
61//! - `php_version` is parsed via [`PhpVersion::from_str`] (same parser as the
62//!   real CLI config); invalid values fail the test.
63//! - `find_dead_code` accepts only the literals `true` or `false`.
64//!
65//! # Expect format
66//!
67//! Single-file fixtures use `KindName: message`.
68//! Multi-file fixtures use `FileName.php: KindName: message`.
69//!
70//! Set `UPDATE_FIXTURES=1` to rewrite the expect section with actual output.
71
72use std::collections::HashSet;
73use std::path::{Path, PathBuf};
74use std::sync::atomic::{AtomicU64, Ordering};
75use std::sync::Arc;
76
77use crate::{project::ProjectAnalyzer, PhpVersion};
78use mir_issues::{Issue, IssueKind};
79
80static COUNTER: AtomicU64 = AtomicU64::new(0);
81
82// ---------------------------------------------------------------------------
83// Fixture configuration
84// ---------------------------------------------------------------------------
85
86#[derive(Default)]
87struct FixtureConfig {
88    php_version: Option<PhpVersion>,
89    find_dead_code: bool,
90}
91
92// ---------------------------------------------------------------------------
93// Public inline-analysis API
94// ---------------------------------------------------------------------------
95
96/// Run the full analyzer on an inline PHP string and return all unsuppressed issues.
97pub fn check(src: &str) -> Vec<Issue> {
98    run_analyzer(&[("test.php", src)], &FixtureConfig::default())
99}
100
101/// Analyze a set of named PHP files together, returning all unsuppressed issues.
102///
103/// Each entry is `(filename, php_source)`. Files are written to a unique temp
104/// directory, analyzed together, then cleaned up.
105///
106/// If a `"composer.json"` entry is included, a `Psr4Map` is built from it.
107/// Files under PSR-4-mapped directories are left for lazy discovery and are
108/// **not** passed to `analyze()` explicitly.
109pub fn check_files(files: &[(&str, &str)]) -> Vec<Issue> {
110    run_analyzer(files, &FixtureConfig::default())
111}
112
113// ---------------------------------------------------------------------------
114// Fixture data types
115// ---------------------------------------------------------------------------
116
117/// One expected issue from a `.phpt` fixture's `===expect===` section.
118pub(crate) struct ExpectedIssue {
119    pub file: Option<String>,
120    pub kind_name: String,
121    pub message: String,
122}
123
124/// Parsed representation of a `.phpt` fixture.
125pub(crate) struct ParsedFixture {
126    /// `(filename, content)` pairs — always at least one entry.
127    pub files: Vec<(String, String)>,
128    pub expected: Vec<ExpectedIssue>,
129    pub is_multi: bool,
130    config: FixtureConfig,
131}
132
133// ---------------------------------------------------------------------------
134// Fixture parsing
135// ---------------------------------------------------------------------------
136
137const BARE_FILE: &str = "===file===";
138const FILE_PREFIX: &str = "===file:";
139const CONFIG_MARKER: &str = "===config===";
140const EXPECT_MARKER: &str = "===expect===";
141
142/// Parse a `.phpt` fixture file.
143pub(crate) fn parse_phpt(content: &str, path: &str) -> ParsedFixture {
144    // --- Locate expect (required, exactly once) ---
145    let expect_count = count_occurrences(content, EXPECT_MARKER);
146    assert_eq!(
147        expect_count, 1,
148        "fixture {path}: ===expect=== must appear exactly once, found {expect_count} times"
149    );
150    let expect_pos = content.find(EXPECT_MARKER).unwrap();
151    let header_region = &content[..expect_pos];
152    let expect_content = content[expect_pos + EXPECT_MARKER.len()..].trim();
153
154    // --- Validate config section ---
155    let config_count = count_occurrences(header_region, CONFIG_MARKER);
156    assert!(
157        config_count <= 1,
158        "fixture {path}: ===config=== must appear at most once, found {config_count} times"
159    );
160
161    // --- Count and validate file markers ---
162    // Config must appear before any file marker so its text is never silently
163    // included in the PHP source of the first file.
164    if config_count == 1 {
165        if let (Some(cfg_pos), Some(first_file_pos)) = (
166            header_region.find(CONFIG_MARKER),
167            header_region.find("===file"),
168        ) {
169            assert!(
170                cfg_pos < first_file_pos,
171                "fixture {path}: ===config=== must appear before the first ===file=== / ===file:name=== marker"
172            );
173        }
174    }
175
176    // ---
177    let bare_count = count_occurrences(header_region, BARE_FILE);
178    // FILE_PREFIX ("===file:") won't match BARE_FILE ("===file===") since after
179    // "file" one has ':' and the other '='.
180    let named_count = count_occurrences(header_region, FILE_PREFIX);
181
182    assert!(
183        !(bare_count > 0 && named_count > 0),
184        "fixture {path}: cannot mix ===file=== and ===file:name=== markers in the same fixture"
185    );
186    assert!(
187        bare_count > 0 || named_count > 0,
188        "fixture {path}: no ===file=== or ===file:name=== section found"
189    );
190    assert!(
191        bare_count <= 1,
192        "fixture {path}: ===file=== must appear at most once, found {bare_count} times"
193    );
194
195    let is_multi = named_count > 0;
196
197    // --- Extract file content(s) ---
198    let files = if is_multi {
199        extract_named_files(header_region, path)
200    } else {
201        let bare_pos = header_region.find(BARE_FILE).unwrap();
202        let src = header_region[bare_pos + BARE_FILE.len()..]
203            .trim()
204            .to_string();
205        vec![("test.php".to_string(), src)]
206    };
207
208    // --- Parse config section ---
209    let config = if config_count == 1 {
210        let cfg_pos = header_region.find(CONFIG_MARKER).unwrap();
211        let after_cfg = cfg_pos + CONFIG_MARKER.len();
212        // Config body ends at the first ===file marker (bare or named).
213        let cfg_end = header_region[after_cfg..]
214            .find("===file")
215            .map(|r| after_cfg + r)
216            .unwrap_or(header_region.len());
217        let cfg_text = header_region[after_cfg..cfg_end].trim();
218        parse_config_section(cfg_text, path)
219    } else {
220        FixtureConfig::default()
221    };
222
223    // --- Parse expect lines ---
224    let expected = expect_content
225        .lines()
226        .map(str::trim)
227        .filter(|l| !l.is_empty() && !l.starts_with('#'))
228        .map(|l| {
229            if is_multi {
230                parse_multi_expect_line(l, path)
231            } else {
232                parse_single_expect_line(l, path)
233            }
234        })
235        .collect();
236
237    ParsedFixture {
238        files,
239        expected,
240        is_multi,
241        config,
242    }
243}
244
245fn parse_config_section(text: &str, path: &str) -> FixtureConfig {
246    let mut config = FixtureConfig::default();
247    for raw_line in text.lines() {
248        let line = raw_line.trim();
249        if line.is_empty() {
250            continue;
251        }
252        let (key, value) = line.split_once('=').unwrap_or_else(|| {
253            panic!("fixture {path}: invalid config line {line:?} — expected key=value")
254        });
255        match key.trim() {
256            "php_version" => {
257                let v = value.trim().parse::<PhpVersion>().unwrap_or_else(|e| {
258                    panic!("fixture {path}: invalid php_version: {e}")
259                });
260                config.php_version = Some(v);
261            }
262            "find_dead_code" => {
263                config.find_dead_code = match value.trim() {
264                    "true" => true,
265                    "false" => false,
266                    other => panic!(
267                        "fixture {path}: find_dead_code must be `true` or `false`, got {other:?}"
268                    ),
269                };
270            }
271            other => panic!(
272                "fixture {path}: unknown config key {other:?} — valid keys: php_version, find_dead_code"
273            ),
274        }
275    }
276    config
277}
278
279fn extract_named_files(region: &str, path: &str) -> Vec<(String, String)> {
280    let mut files = Vec::new();
281    let mut search_from = 0;
282
283    while let Some(marker_rel) = region[search_from..].find(FILE_PREFIX) {
284        let marker_abs = search_from + marker_rel;
285        let after_prefix = marker_abs + FILE_PREFIX.len();
286
287        let close_rel = region[after_prefix..]
288            .find("===")
289            .unwrap_or_else(|| panic!("fixture {path}: unclosed ===file: marker"));
290
291        let file_name = region[after_prefix..after_prefix + close_rel].to_string();
292        let content_start = after_prefix + close_rel + "===".len();
293
294        let content_end = region[content_start..]
295            .find(FILE_PREFIX)
296            .map(|r| content_start + r)
297            .unwrap_or(region.len());
298
299        let file_content = region[content_start..content_end].trim().to_string();
300        files.push((file_name, file_content));
301        search_from = content_end;
302    }
303
304    files
305}
306
307fn parse_single_expect_line(line: &str, path: &str) -> ExpectedIssue {
308    let parts: Vec<&str> = line.splitn(2, ": ").collect();
309    assert_eq!(
310        parts.len(),
311        2,
312        "fixture {path}: invalid expect line {line:?} — expected \"KindName: message\""
313    );
314    ExpectedIssue {
315        file: None,
316        kind_name: parts[0].trim().to_string(),
317        message: parts[1].trim().to_string(),
318    }
319}
320
321fn parse_multi_expect_line(line: &str, path: &str) -> ExpectedIssue {
322    let parts: Vec<&str> = line.splitn(3, ": ").collect();
323    assert_eq!(
324        parts.len(),
325        3,
326        "fixture {path}: invalid multi-file expect line {line:?} — expected \"FileName.php: KindName: message\""
327    );
328    ExpectedIssue {
329        file: Some(parts[0].trim().to_string()),
330        kind_name: parts[1].trim().to_string(),
331        message: parts[2].trim().to_string(),
332    }
333}
334
335fn count_occurrences(haystack: &str, needle: &str) -> usize {
336    let mut count = 0;
337    let mut start = 0;
338    while let Some(pos) = haystack[start..].find(needle) {
339        count += 1;
340        start += pos + needle.len();
341    }
342    count
343}
344
345// ---------------------------------------------------------------------------
346// Fixture runner
347// ---------------------------------------------------------------------------
348
349/// Run a `.phpt` fixture file and assert issues match the `===expect===` section.
350///
351/// Set `UPDATE_FIXTURES=1` to rewrite the expect section with actual output.
352pub fn run_fixture(path: &str) {
353    let content = std::fs::read_to_string(path)
354        .unwrap_or_else(|e| panic!("failed to read fixture {path}: {e}"));
355
356    let fixture = parse_phpt(&content, path);
357    let file_refs: Vec<(&str, &str)> = fixture
358        .files
359        .iter()
360        .map(|(n, s)| (n.as_str(), s.as_str()))
361        .collect();
362    let actual = run_analyzer(&file_refs, &fixture.config);
363
364    if std::env::var("UPDATE_FIXTURES").as_deref() == Ok("1") {
365        rewrite_fixture(path, &content, &actual, fixture.is_multi);
366        return;
367    }
368
369    assert_fixture(path, &fixture, &actual);
370}
371
372// ---------------------------------------------------------------------------
373// Core analyzer runner
374// ---------------------------------------------------------------------------
375
376fn run_analyzer(files: &[(&str, &str)], config: &FixtureConfig) -> Vec<Issue> {
377    let id = COUNTER.fetch_add(1, Ordering::Relaxed);
378    let tmp_dir = std::env::temp_dir().join(format!("mir_fixture_{id}"));
379    std::fs::create_dir_all(&tmp_dir)
380        .unwrap_or_else(|e| panic!("failed to create temp dir {}: {e}", tmp_dir.display()));
381
382    let paths: Vec<PathBuf> = files
383        .iter()
384        .map(|(name, src)| {
385            let path = tmp_dir.join(name);
386            if let Some(parent) = path.parent() {
387                std::fs::create_dir_all(parent)
388                    .unwrap_or_else(|e| panic!("failed to create dir for {name}: {e}"));
389            }
390            std::fs::write(&path, src).unwrap_or_else(|e| panic!("failed to write {name}: {e}"));
391            path
392        })
393        .collect();
394
395    let tmp_dir_str = tmp_dir.to_string_lossy().into_owned();
396
397    let mut analyzer = ProjectAnalyzer::new();
398    analyzer.find_dead_code = config.find_dead_code;
399    if let Some(version) = config.php_version {
400        analyzer = analyzer.with_php_version(version);
401    }
402
403    let has_composer = files.iter().any(|(name, _)| *name == "composer.json");
404    let explicit_paths: Vec<PathBuf> = if has_composer {
405        match crate::composer::Psr4Map::from_composer(&tmp_dir) {
406            Ok(psr4) => {
407                let psr4 = Arc::new(psr4);
408                let psr4_files: HashSet<PathBuf> = psr4.project_files().into_iter().collect();
409                let explicit: Vec<PathBuf> = paths
410                    .iter()
411                    .filter(|p| p.extension().map(|e| e == "php").unwrap_or(false))
412                    .filter(|p| !psr4_files.contains(*p))
413                    .cloned()
414                    .collect();
415                analyzer.psr4 = Some(psr4);
416                explicit
417            }
418            Err(_) => php_files_only(&paths),
419        }
420    } else {
421        php_files_only(&paths)
422    };
423
424    let result = analyzer.analyze(&explicit_paths);
425    std::fs::remove_dir_all(&tmp_dir).ok();
426
427    result
428        .issues
429        .into_iter()
430        .filter(|i| !i.suppressed)
431        // When dead-code analysis is enabled the analyzer walks the entire
432        // codebase including stubs. Filter to issues from the temp directory
433        // only so stub-side false positives don't pollute fixture output.
434        .filter(|i| {
435            !config.find_dead_code || i.location.file.as_ref().starts_with(tmp_dir_str.as_str())
436        })
437        .collect()
438}
439
440fn php_files_only(paths: &[PathBuf]) -> Vec<PathBuf> {
441    paths
442        .iter()
443        .filter(|p| p.extension().map(|e| e == "php").unwrap_or(false))
444        .cloned()
445        .collect()
446}
447
448// ---------------------------------------------------------------------------
449// Fixture assertion
450// ---------------------------------------------------------------------------
451
452fn assert_fixture(path: &str, fixture: &ParsedFixture, actual: &[Issue]) {
453    let mut failures: Vec<String> = Vec::new();
454
455    for exp in &fixture.expected {
456        if !actual.iter().any(|a| issue_matches(a, exp)) {
457            failures.push(format!(
458                "  MISSING  {}",
459                fmt_expected(exp, fixture.is_multi)
460            ));
461        }
462    }
463
464    for act in actual {
465        if !fixture.expected.iter().any(|e| issue_matches(act, e)) {
466            failures.push(format!(
467                "  UNEXPECTED {}",
468                fmt_actual(act, fixture.is_multi)
469            ));
470        }
471    }
472
473    if !failures.is_empty() {
474        panic!(
475            "fixture {path} FAILED:\n{}\n\nAll actual issues:\n{}",
476            failures.join("\n"),
477            fmt_issues(actual, fixture.is_multi)
478        );
479    }
480}
481
482fn issue_matches(actual: &Issue, expected: &ExpectedIssue) -> bool {
483    if actual.kind.name() != expected.kind_name {
484        return false;
485    }
486    if actual.kind.message() != expected.message.as_str() {
487        return false;
488    }
489    if let Some(expected_file) = &expected.file {
490        let actual_basename = Path::new(actual.location.file.as_ref())
491            .file_name()
492            .map(|n| n.to_string_lossy())
493            .unwrap_or_default();
494        if actual_basename.as_ref() != expected_file.as_str() {
495            return false;
496        }
497    }
498    true
499}
500
501// ---------------------------------------------------------------------------
502// UPDATE_FIXTURES rewrite
503// ---------------------------------------------------------------------------
504
505fn rewrite_fixture(path: &str, content: &str, actual: &[Issue], is_multi: bool) {
506    // Preserve everything before ===expect=== and rewrite only the expect section.
507    let exp_pos = content
508        .find(EXPECT_MARKER)
509        .expect("fixture missing ===expect===");
510
511    let mut out = content[..exp_pos].to_string();
512    out.push_str(EXPECT_MARKER);
513    out.push('\n');
514
515    let mut sorted: Vec<&Issue> = actual.iter().collect();
516    if is_multi {
517        sorted.sort_by_key(|i| {
518            let basename = Path::new(i.location.file.as_ref())
519                .file_name()
520                .map(|n| n.to_string_lossy().into_owned())
521                .unwrap_or_default();
522            (
523                basename,
524                i.location.line,
525                i.location.col_start,
526                i.kind.name(),
527            )
528        });
529        for issue in sorted {
530            let basename = Path::new(issue.location.file.as_ref())
531                .file_name()
532                .map(|n| n.to_string_lossy().into_owned())
533                .unwrap_or_default();
534            out.push_str(&format!(
535                "{}: {}: {}\n",
536                basename,
537                issue.kind.name(),
538                issue.kind.message()
539            ));
540        }
541    } else {
542        sorted.sort_by_key(|i| (i.location.line, i.location.col_start, i.kind.name()));
543        for issue in sorted {
544            out.push_str(&format!(
545                "{}: {}\n",
546                issue.kind.name(),
547                issue.kind.message()
548            ));
549        }
550    }
551
552    std::fs::write(path, &out).unwrap_or_else(|e| panic!("failed to write fixture {path}: {e}"));
553}
554
555// ---------------------------------------------------------------------------
556// Assertion helpers (used by inline tests)
557// ---------------------------------------------------------------------------
558
559/// Assert that `issues` contains at least one issue with the exact `IssueKind`
560/// at `line` and `col_start`.
561pub fn assert_issue(issues: &[Issue], kind: IssueKind, line: u32, col_start: u16) {
562    let found = issues
563        .iter()
564        .any(|i| i.kind == kind && i.location.line == line && i.location.col_start == col_start);
565    if !found {
566        panic!(
567            "Expected issue {:?} at line {line}, col {col_start}.\nActual issues:\n{}",
568            kind,
569            fmt_issues(issues, false),
570        );
571    }
572}
573
574/// Assert that `issues` contains at least one issue whose `kind.name()` equals
575/// `kind_name` at `line` and `col_start`.
576pub fn assert_issue_kind(issues: &[Issue], kind_name: &str, line: u32, col_start: u16) {
577    let found = issues.iter().any(|i| {
578        i.kind.name() == kind_name && i.location.line == line && i.location.col_start == col_start
579    });
580    if !found {
581        panic!(
582            "Expected issue {kind_name} at line {line}, col {col_start}.\nActual issues:\n{}",
583            fmt_issues(issues, false),
584        );
585    }
586}
587
588/// Assert that `issues` contains no issue whose `kind.name()` equals `kind_name`.
589pub fn assert_no_issue(issues: &[Issue], kind_name: &str) {
590    let found: Vec<_> = issues
591        .iter()
592        .filter(|i| i.kind.name() == kind_name)
593        .collect();
594    if !found.is_empty() {
595        panic!(
596            "Expected no {kind_name} issues, but found:\n{}",
597            fmt_issues(&found.into_iter().cloned().collect::<Vec<_>>(), false),
598        );
599    }
600}
601
602// ---------------------------------------------------------------------------
603// Formatting helpers
604// ---------------------------------------------------------------------------
605
606fn fmt_expected(exp: &ExpectedIssue, is_multi: bool) -> String {
607    if is_multi {
608        if let Some(f) = &exp.file {
609            return format!("{}: {}: {}", f, exp.kind_name, exp.message);
610        }
611    }
612    format!("{}: {}", exp.kind_name, exp.message)
613}
614
615fn fmt_actual(act: &Issue, is_multi: bool) -> String {
616    if is_multi {
617        let basename = Path::new(act.location.file.as_ref())
618            .file_name()
619            .map(|n| n.to_string_lossy().into_owned())
620            .unwrap_or_default();
621        return format!("{}: {}: {}", basename, act.kind.name(), act.kind.message());
622    }
623    format!("{}: {}", act.kind.name(), act.kind.message())
624}
625
626fn fmt_issues(issues: &[Issue], is_multi: bool) -> String {
627    if issues.is_empty() {
628        return "  (none)".to_string();
629    }
630    issues
631        .iter()
632        .map(|i| format!("  {}", fmt_actual(i, is_multi)))
633        .collect::<Vec<_>>()
634        .join("\n")
635}
636
637// ---------------------------------------------------------------------------
638// Fixture parser validation tests
639// ---------------------------------------------------------------------------
640
641#[cfg(test)]
642mod parser_validation {
643    use super::parse_phpt;
644
645    fn p(content: &str) {
646        parse_phpt(content, "<test>");
647    }
648
649    #[test]
650    #[should_panic(expected = "===file=== must appear at most once")]
651    fn duplicate_bare_file_marker() {
652        p("===file===\n<?php\n===file===\n<?php\n===expect===\n");
653    }
654
655    #[test]
656    #[should_panic(expected = "cannot mix ===file=== and ===file:name===")]
657    fn mixed_bare_and_named_markers() {
658        p("===file===\n<?php\n===file:Other.php===\n<?php\n===expect===\n");
659    }
660
661    #[test]
662    #[should_panic(expected = "===config=== must appear at most once")]
663    fn duplicate_config_section() {
664        p("===config===\nfind_dead_code=false\n===config===\nfind_dead_code=true\n===file===\n<?php\n===expect===\n");
665    }
666
667    #[test]
668    #[should_panic(expected = "unknown config key")]
669    fn unknown_config_key() {
670        p("===config===\nfoo=bar\n===file===\n<?php\n===expect===\n");
671    }
672
673    #[test]
674    #[should_panic(expected = "invalid php_version")]
675    fn invalid_php_version() {
676        p("===config===\nphp_version=banana\n===file===\n<?php\n===expect===\n");
677    }
678
679    #[test]
680    #[should_panic(expected = "find_dead_code must be `true` or `false`")]
681    fn invalid_find_dead_code_value() {
682        p("===config===\nfind_dead_code=maybe\n===file===\n<?php\n===expect===\n");
683    }
684
685    #[test]
686    #[should_panic(expected = "===config=== must appear before the first ===file===")]
687    fn config_after_file_marker() {
688        p("===file===\n<?php\n===config===\nfind_dead_code=true\n===expect===\n");
689    }
690
691    #[test]
692    fn valid_config_is_accepted() {
693        p("===config===\nphp_version=8.1\nfind_dead_code=true\n===file===\n<?php\n===expect===\n");
694    }
695}