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//! stub_file=stubs/helpers.php
32//! stub_dir=stubs
33//! ===file===
34//! <?php
35//! ...
36//! ===expect===
37//! ...
38//! ```
39//!
40//! `stub_file=path` and `stub_dir=path` refer to files/directories already declared
41//! with `===file:path===` markers. They are passed to `ProjectAnalyzer::stub_files` /
42//! `stub_dirs` and excluded from the analysis file list, so only the non-stub PHP
43//! files are analysed. Multiple `stub_file=` and `stub_dir=` lines are allowed.
44//!
45//! **With Composer/PSR-4**:
46//! ```text
47//! ===file:composer.json===
48//! {"autoload":{"psr-4":{"App\\":"src/"}}}
49//! ===file:src/Base.php===
50//! <?php
51//! namespace App;
52//! class Base { ... }
53//! ===file:Child.php===
54//! <?php
55//! class Child extends \App\Base { ... }
56//! ===expect===
57//! Child.php: UndefinedMethod: Method Child::bar() does not exist
58//! ```
59//!
60//! **With description** (optional `===description===` section, must appear before file sections):
61//! ```text
62//! ===description===
63//! Verify that calling a method on a null variable is reported.
64//! ===file===
65//! <?php
66//! ...
67//! ===expect===
68//! ...
69//! ```
70//!
71//! **Skipped / WIP fixture** (`===ignore===`, must appear before file sections):
72//! ```text
73//! ===ignore===
74//! TODO: narrowing through loop variables not yet implemented
75//! ===file===
76//! <?php
77//! ...
78//! ===expect===
79//! ...
80//! ```
81//!
82//! The presence of `===ignore===` causes the generated test to be marked
83//! `#[ignore]` at compile time (via `build.rs`), so it shows up as `ignored`
84//! rather than `ok` or `FAILED` in test output.
85//!
86//! # Validation rules
87//!
88//! - `===file===` (bare, no name) must appear **at most once** per fixture.
89//! - `===file===` and `===file:name===` cannot appear in the same fixture.
90//! - A fixture with no file section at all fails immediately.
91//! - `===config===` must appear **at most once** per fixture.
92//! - Every key in `===config===` must be a recognised key (`php_version`,
93//!   `find_dead_code`); unknown keys fail the test.
94//! - `php_version` is parsed via [`PhpVersion::from_str`] (same parser as the
95//!   real CLI config); invalid values fail the test.
96//! - `find_dead_code` accepts only the literals `true` or `false`.
97//! - `stub_file` and `stub_dir` accept a relative path (matching a `===file:===` name).
98//! - `===description===` must appear **at most once** and before any file section.
99//! - `===ignore===` must appear **at most once** and before any file section.
100//!
101//! # Expect format
102//!
103//! Single-file fixtures use `KindName@line:col: message`.
104//! Multi-file fixtures use `FileName.php: KindName@line:col: message`.
105//!
106//! Location assertions (`@line:col`) are **required**. Both line and column must be specified
107//! and must match for the issue to be considered a match.
108//!
109//! Set `UPDATE_FIXTURES=1` to rewrite the expect section with actual output (including locations).
110
111use std::collections::HashSet;
112use std::path::{Path, PathBuf};
113use std::sync::atomic::{AtomicU64, Ordering};
114use std::sync::Arc;
115
116use crate::{project::ProjectAnalyzer, PhpVersion};
117use mir_issues::{Issue, IssueKind};
118
119static COUNTER: AtomicU64 = AtomicU64::new(0);
120
121// ---------------------------------------------------------------------------
122// Fixture configuration
123// ---------------------------------------------------------------------------
124
125#[derive(Default)]
126struct FixtureConfig {
127    php_version: Option<PhpVersion>,
128    find_dead_code: bool,
129    /// Paths (relative to temp dir) to pass as `analyzer.stub_files`.
130    stub_files: Vec<String>,
131    /// Paths (relative to temp dir) to pass as `analyzer.stub_dirs`.
132    stub_dirs: Vec<String>,
133}
134
135// ---------------------------------------------------------------------------
136// Public inline-analysis API
137// ---------------------------------------------------------------------------
138
139/// Run the full analyzer on an inline PHP string and return all unsuppressed issues.
140pub fn check(src: &str) -> Vec<Issue> {
141    run_analyzer(&[("test.php", src)], &FixtureConfig::default())
142}
143
144/// Analyze a set of named PHP files together, returning all unsuppressed issues.
145///
146/// Each entry is `(filename, php_source)`. Files are written to a unique temp
147/// directory, analyzed together, then cleaned up.
148///
149/// If a `"composer.json"` entry is included, a `Psr4Map` is built from it.
150/// Files under PSR-4-mapped directories are left for lazy discovery and are
151/// **not** passed to `analyze()` explicitly.
152pub fn check_files(files: &[(&str, &str)]) -> Vec<Issue> {
153    run_analyzer(files, &FixtureConfig::default())
154}
155
156// ---------------------------------------------------------------------------
157// Fixture data types
158// ---------------------------------------------------------------------------
159
160/// One expected issue from a `.phpt` fixture's `===expect===` section.
161pub(crate) struct ExpectedIssue {
162    pub file: Option<String>,
163    pub kind_name: String,
164    pub message: String,
165    pub line: Option<u32>,
166    pub col_start: Option<u16>,
167}
168
169/// Parsed representation of a `.phpt` fixture.
170pub(crate) struct ParsedFixture {
171    /// `(filename, content)` pairs — always at least one entry.
172    pub files: Vec<(String, String)>,
173    pub expected: Vec<ExpectedIssue>,
174    pub is_multi: bool,
175    /// Optional human-readable description from `===description===`.
176    pub description: Option<String>,
177    config: FixtureConfig,
178}
179
180// ---------------------------------------------------------------------------
181// Fixture parsing
182// ---------------------------------------------------------------------------
183
184const BARE_FILE: &str = "===file===";
185const FILE_PREFIX: &str = "===file:";
186const CONFIG_MARKER: &str = "===config===";
187const EXPECT_MARKER: &str = "===expect===";
188const DESCRIPTION_MARKER: &str = "===description===";
189const IGNORE_MARKER: &str = "===ignore===";
190
191/// Parse a `.phpt` fixture file.
192pub(crate) fn parse_phpt(content: &str, path: &str) -> ParsedFixture {
193    // --- Locate expect (required, exactly once) ---
194    let expect_count = count_occurrences(content, EXPECT_MARKER);
195    assert_eq!(
196        expect_count, 1,
197        "fixture {path}: ===expect=== must appear exactly once, found {expect_count} times"
198    );
199    let expect_pos = content.find(EXPECT_MARKER).unwrap();
200    let header_region = &content[..expect_pos];
201    let expect_content = content[expect_pos + EXPECT_MARKER.len()..].trim();
202
203    // --- Validate config section ---
204    let config_count = count_occurrences(header_region, CONFIG_MARKER);
205    assert!(
206        config_count <= 1,
207        "fixture {path}: ===config=== must appear at most once, found {config_count} times"
208    );
209
210    // --- Validate description section ---
211    let description_count = count_occurrences(header_region, DESCRIPTION_MARKER);
212    assert!(
213        description_count <= 1,
214        "fixture {path}: ===description=== must appear at most once, found {description_count} times"
215    );
216
217    // --- Validate ignore marker ---
218    let ignore_count = count_occurrences(header_region, IGNORE_MARKER);
219    assert!(
220        ignore_count <= 1,
221        "fixture {path}: ===ignore=== must appear at most once, found {ignore_count} times"
222    );
223
224    // --- Count and validate file markers ---
225    // Config, description, and ignore must appear before any file marker so their
226    // text is never silently included in the PHP source of the first file.
227    if config_count == 1 {
228        if let (Some(cfg_pos), Some(first_file_pos)) = (
229            header_region.find(CONFIG_MARKER),
230            header_region.find("===file"),
231        ) {
232            assert!(
233                cfg_pos < first_file_pos,
234                "fixture {path}: ===config=== must appear before the first ===file=== / ===file:name=== marker"
235            );
236        }
237    }
238    if description_count == 1 {
239        if let (Some(desc_pos), Some(first_file_pos)) = (
240            header_region.find(DESCRIPTION_MARKER),
241            header_region.find("===file"),
242        ) {
243            assert!(
244                desc_pos < first_file_pos,
245                "fixture {path}: ===description=== must appear before the first ===file=== / ===file:name=== marker"
246            );
247        }
248    }
249    if ignore_count == 1 {
250        if let (Some(ignore_pos), Some(first_file_pos)) = (
251            header_region.find(IGNORE_MARKER),
252            header_region.find("===file"),
253        ) {
254            assert!(
255                ignore_pos < first_file_pos,
256                "fixture {path}: ===ignore=== must appear before the first ===file=== / ===file:name=== marker"
257            );
258        }
259    }
260
261    // ---
262    let bare_count = count_occurrences(header_region, BARE_FILE);
263    // FILE_PREFIX ("===file:") won't match BARE_FILE ("===file===") since after
264    // "file" one has ':' and the other '='.
265    let named_count = count_occurrences(header_region, FILE_PREFIX);
266
267    assert!(
268        !(bare_count > 0 && named_count > 0),
269        "fixture {path}: cannot mix ===file=== and ===file:name=== markers in the same fixture"
270    );
271    assert!(
272        bare_count > 0 || named_count > 0,
273        "fixture {path}: no ===file=== or ===file:name=== section found"
274    );
275    assert!(
276        bare_count <= 1,
277        "fixture {path}: ===file=== must appear at most once, found {bare_count} times"
278    );
279
280    let is_multi = named_count > 0;
281
282    // --- Extract file content(s) ---
283    let files = if is_multi {
284        extract_named_files(header_region, path)
285    } else {
286        let bare_pos = header_region.find(BARE_FILE).unwrap();
287        let src = header_region[bare_pos + BARE_FILE.len()..]
288            .trim()
289            .to_string();
290        vec![("test.php".to_string(), src)]
291    };
292
293    // --- Parse config section ---
294    let config = if config_count == 1 {
295        let cfg_pos = header_region.find(CONFIG_MARKER).unwrap();
296        let after_cfg = cfg_pos + CONFIG_MARKER.len();
297        // Config body ends at the first ===file marker (bare or named).
298        let cfg_end = header_region[after_cfg..]
299            .find("===file")
300            .map(|r| after_cfg + r)
301            .unwrap_or(header_region.len());
302        let cfg_text = header_region[after_cfg..cfg_end].trim();
303        parse_config_section(cfg_text, path)
304    } else {
305        FixtureConfig::default()
306    };
307
308    // --- Parse description section ---
309    let description = if description_count == 1 {
310        let desc_pos = header_region.find(DESCRIPTION_MARKER).unwrap();
311        let after_desc = desc_pos + DESCRIPTION_MARKER.len();
312        // Description body ends at the next section marker.
313        let desc_end = header_region[after_desc..]
314            .find("===")
315            .map(|r| after_desc + r)
316            .unwrap_or(header_region.len());
317        Some(header_region[after_desc..desc_end].trim().to_string())
318    } else {
319        None
320    };
321
322    // --- Parse expect lines ---
323    let expected = expect_content
324        .lines()
325        .map(str::trim)
326        .filter(|l| !l.is_empty() && !l.starts_with('#'))
327        .map(|l| {
328            if is_multi {
329                parse_multi_expect_line(l, path)
330            } else {
331                parse_single_expect_line(l, path)
332            }
333        })
334        .collect();
335
336    ParsedFixture {
337        files,
338        expected,
339        is_multi,
340        description,
341        config,
342    }
343}
344
345fn parse_config_section(text: &str, path: &str) -> FixtureConfig {
346    let mut config = FixtureConfig::default();
347    for raw_line in text.lines() {
348        let line = raw_line.trim();
349        if line.is_empty() {
350            continue;
351        }
352        let (key, value) = line.split_once('=').unwrap_or_else(|| {
353            panic!("fixture {path}: invalid config line {line:?} — expected key=value")
354        });
355        match key.trim() {
356            "php_version" => {
357                let v = value.trim().parse::<PhpVersion>().unwrap_or_else(|e| {
358                    panic!("fixture {path}: invalid php_version: {e}")
359                });
360                config.php_version = Some(v);
361            }
362            "find_dead_code" => {
363                config.find_dead_code = match value.trim() {
364                    "true" => true,
365                    "false" => false,
366                    other => panic!(
367                        "fixture {path}: find_dead_code must be `true` or `false`, got {other:?}"
368                    ),
369                };
370            }
371            "stub_file" => {
372                config.stub_files.push(value.trim().to_string());
373            }
374            "stub_dir" => {
375                config.stub_dirs.push(value.trim().to_string());
376            }
377            other => panic!(
378                "fixture {path}: unknown config key {other:?} — valid keys: php_version, find_dead_code, stub_file, stub_dir"
379            ),
380        }
381    }
382    config
383}
384
385fn extract_named_files(region: &str, path: &str) -> Vec<(String, String)> {
386    let mut files = Vec::new();
387    let mut search_from = 0;
388
389    while let Some(marker_rel) = region[search_from..].find(FILE_PREFIX) {
390        let marker_abs = search_from + marker_rel;
391        let after_prefix = marker_abs + FILE_PREFIX.len();
392
393        let close_rel = region[after_prefix..]
394            .find("===")
395            .unwrap_or_else(|| panic!("fixture {path}: unclosed ===file: marker"));
396
397        let file_name = region[after_prefix..after_prefix + close_rel].to_string();
398        let content_start = after_prefix + close_rel + "===".len();
399
400        let content_end = region[content_start..]
401            .find(FILE_PREFIX)
402            .map(|r| content_start + r)
403            .unwrap_or(region.len());
404
405        let file_content = region[content_start..content_end].trim().to_string();
406        files.push((file_name, file_content));
407        search_from = content_end;
408    }
409
410    files
411}
412
413fn parse_single_expect_line(line: &str, path: &str) -> ExpectedIssue {
414    let parts: Vec<&str> = line.splitn(2, ": ").collect();
415    assert_eq!(
416        parts.len(),
417        2,
418        "fixture {path}: invalid expect line {line:?} — expected \"KindName@line:col: message\" (location is required)"
419    );
420
421    let kind_part = parts[0];
422    let (kind_name, line_col) = if let Some(at_pos) = kind_part.find('@') {
423        (
424            kind_part[..at_pos].trim().to_string(),
425            Some(&kind_part[at_pos + 1..]),
426        )
427    } else {
428        (kind_part.trim().to_string(), None)
429    };
430
431    let (line_num, col_start) = if let Some(loc) = line_col {
432        let loc_parts: Vec<&str> = loc.split(':').collect();
433        if loc_parts.len() == 2 {
434            let l = loc_parts[0]
435                .parse::<u32>()
436                .unwrap_or_else(|_| panic!("fixture {path}: invalid line number in {line:?}"));
437            let c = loc_parts[1]
438                .parse::<u16>()
439                .unwrap_or_else(|_| panic!("fixture {path}: invalid column number in {line:?}"));
440            (Some(l), Some(c))
441        } else {
442            panic!("fixture {path}: invalid location format in {line:?} — expected \"@line:col\"");
443        }
444    } else {
445        (None, None)
446    };
447
448    ExpectedIssue {
449        file: None,
450        kind_name,
451        message: parts[1].trim().to_string(),
452        line: line_num,
453        col_start,
454    }
455}
456
457fn parse_multi_expect_line(line: &str, path: &str) -> ExpectedIssue {
458    let parts: Vec<&str> = line.splitn(3, ": ").collect();
459    assert_eq!(
460        parts.len(),
461        3,
462        "fixture {path}: invalid multi-file expect line {line:?} — expected \"FileName.php: KindName@line:col: message\" (location is required)"
463    );
464
465    let kind_part = parts[1];
466    let (kind_name, line_col) = if let Some(at_pos) = kind_part.find('@') {
467        (
468            kind_part[..at_pos].trim().to_string(),
469            Some(&kind_part[at_pos + 1..]),
470        )
471    } else {
472        (kind_part.trim().to_string(), None)
473    };
474
475    let (line_num, col_start) = if let Some(loc) = line_col {
476        let loc_parts: Vec<&str> = loc.split(':').collect();
477        if loc_parts.len() == 2 {
478            let l = loc_parts[0]
479                .parse::<u32>()
480                .unwrap_or_else(|_| panic!("fixture {path}: invalid line number in {line:?}"));
481            let c = loc_parts[1]
482                .parse::<u16>()
483                .unwrap_or_else(|_| panic!("fixture {path}: invalid column number in {line:?}"));
484            (Some(l), Some(c))
485        } else {
486            panic!("fixture {path}: invalid location format in {line:?} — expected \"@line:col\"");
487        }
488    } else {
489        (None, None)
490    };
491
492    ExpectedIssue {
493        file: Some(parts[0].trim().to_string()),
494        kind_name,
495        message: parts[2].trim().to_string(),
496        line: line_num,
497        col_start,
498    }
499}
500
501fn count_occurrences(haystack: &str, needle: &str) -> usize {
502    let mut count = 0;
503    let mut start = 0;
504    while let Some(pos) = haystack[start..].find(needle) {
505        count += 1;
506        start += pos + needle.len();
507    }
508    count
509}
510
511// ---------------------------------------------------------------------------
512// Fixture runner
513// ---------------------------------------------------------------------------
514
515/// Run a `.phpt` fixture file and assert issues match the `===expect===` section.
516///
517/// Set `UPDATE_FIXTURES=1` to rewrite the expect section with actual output.
518pub fn run_fixture(path: &str) {
519    let content = std::fs::read_to_string(path)
520        .unwrap_or_else(|e| panic!("failed to read fixture {path}: {e}"));
521
522    let fixture = parse_phpt(&content, path);
523    let file_refs: Vec<(&str, &str)> = fixture
524        .files
525        .iter()
526        .map(|(n, s)| (n.as_str(), s.as_str()))
527        .collect();
528    let actual = run_analyzer(&file_refs, &fixture.config);
529
530    if std::env::var("UPDATE_FIXTURES").as_deref() == Ok("1") {
531        rewrite_fixture(path, &content, &actual, fixture.is_multi);
532        return;
533    }
534
535    assert_fixture(path, &fixture, &actual);
536}
537
538// ---------------------------------------------------------------------------
539// Core analyzer runner
540// ---------------------------------------------------------------------------
541
542fn run_analyzer(files: &[(&str, &str)], config: &FixtureConfig) -> Vec<Issue> {
543    let id = COUNTER.fetch_add(1, Ordering::Relaxed);
544    let tmp_dir = std::env::temp_dir().join(format!("mir_fixture_{id}"));
545    std::fs::create_dir_all(&tmp_dir)
546        .unwrap_or_else(|e| panic!("failed to create temp dir {}: {e}", tmp_dir.display()));
547
548    let paths: Vec<PathBuf> = files
549        .iter()
550        .map(|(name, src)| {
551            let path = tmp_dir.join(name);
552            if let Some(parent) = path.parent() {
553                std::fs::create_dir_all(parent)
554                    .unwrap_or_else(|e| panic!("failed to create dir for {name}: {e}"));
555            }
556            std::fs::write(&path, src).unwrap_or_else(|e| panic!("failed to write {name}: {e}"));
557            path
558        })
559        .collect();
560
561    let tmp_dir_str = tmp_dir.to_string_lossy().into_owned();
562
563    let mut analyzer = ProjectAnalyzer::new();
564    analyzer.find_dead_code = config.find_dead_code;
565    if let Some(version) = config.php_version {
566        analyzer = analyzer.with_php_version(version);
567    }
568
569    // Register user stub files and directories from the fixture config.
570    for stub_file in &config.stub_files {
571        analyzer.stub_files.push(tmp_dir.join(stub_file));
572    }
573    for stub_dir in &config.stub_dirs {
574        analyzer.stub_dirs.push(tmp_dir.join(stub_dir));
575    }
576
577    // Build a set of paths that belong to user stubs so they are excluded from
578    // the list of files passed to `analyze()` (stubs are loaded separately).
579    let stub_file_set: HashSet<PathBuf> =
580        config.stub_files.iter().map(|f| tmp_dir.join(f)).collect();
581    let stub_dir_set: Vec<PathBuf> = config.stub_dirs.iter().map(|d| tmp_dir.join(d)).collect();
582    let is_stub = |p: &PathBuf| -> bool {
583        stub_file_set.contains(p) || stub_dir_set.iter().any(|d| p.starts_with(d))
584    };
585
586    let has_composer = files.iter().any(|(name, _)| *name == "composer.json");
587    let explicit_paths: Vec<PathBuf> = if has_composer {
588        match crate::composer::Psr4Map::from_composer(&tmp_dir) {
589            Ok(psr4) => {
590                let psr4 = Arc::new(psr4);
591                let psr4_files: HashSet<PathBuf> = psr4.project_files().into_iter().collect();
592                let explicit: Vec<PathBuf> = paths
593                    .iter()
594                    .filter(|p| p.extension().map(|e| e == "php").unwrap_or(false))
595                    .filter(|p| !psr4_files.contains(*p) && !is_stub(p))
596                    .cloned()
597                    .collect();
598                analyzer.psr4 = Some(psr4);
599                explicit
600            }
601            Err(_) => php_files_only(&paths)
602                .into_iter()
603                .filter(|p| !is_stub(p))
604                .collect(),
605        }
606    } else {
607        php_files_only(&paths)
608            .into_iter()
609            .filter(|p| !is_stub(p))
610            .collect()
611    };
612
613    let result = analyzer.analyze(&explicit_paths);
614    std::fs::remove_dir_all(&tmp_dir).ok();
615
616    result
617        .issues
618        .into_iter()
619        .filter(|i| !i.suppressed)
620        // When dead-code analysis is enabled the analyzer walks the entire
621        // codebase including stubs. Filter to issues from the temp directory
622        // only so stub-side false positives don't pollute fixture output.
623        .filter(|i| {
624            !config.find_dead_code || i.location.file.as_ref().starts_with(tmp_dir_str.as_str())
625        })
626        .collect()
627}
628
629fn php_files_only(paths: &[PathBuf]) -> Vec<PathBuf> {
630    paths
631        .iter()
632        .filter(|p| p.extension().map(|e| e == "php").unwrap_or(false))
633        .cloned()
634        .collect()
635}
636
637// ---------------------------------------------------------------------------
638// Fixture assertion
639// ---------------------------------------------------------------------------
640
641fn assert_fixture(path: &str, fixture: &ParsedFixture, actual: &[Issue]) {
642    let mut failures: Vec<String> = Vec::new();
643
644    for exp in &fixture.expected {
645        if exp.line.is_none() || exp.col_start.is_none() {
646            failures.push(format!(
647                "  MISSING LOCATION  {}: expected issue must include @line:col (e.g., {}@1:1: {})",
648                exp.kind_name, exp.kind_name, exp.message
649            ));
650        }
651        if !actual.iter().any(|a| issue_matches(a, exp)) {
652            failures.push(format!(
653                "  MISSING  {}",
654                fmt_expected(exp, fixture.is_multi)
655            ));
656        }
657    }
658
659    for act in actual {
660        if !fixture.expected.iter().any(|e| issue_matches(act, e)) {
661            failures.push(format!(
662                "  UNEXPECTED {}",
663                fmt_actual(act, fixture.is_multi)
664            ));
665        }
666    }
667
668    if !failures.is_empty() {
669        let desc = fixture
670            .description
671            .as_deref()
672            .map(|d| format!("\n\nDescription: {d}"))
673            .unwrap_or_default();
674        panic!(
675            "fixture {path} FAILED:{desc}\n{}\n\nTo fix: ensure all expected issues have @line:col locations, then run: UPDATE_FIXTURES=1 cargo test --lib fixture\n\nAll actual issues:\n{}",
676            failures.join("\n"),
677            fmt_issues(actual, fixture.is_multi)
678        );
679    }
680}
681
682fn issue_matches(actual: &Issue, expected: &ExpectedIssue) -> bool {
683    if actual.kind.name() != expected.kind_name {
684        return false;
685    }
686    if actual.kind.message() != expected.message.as_str() {
687        return false;
688    }
689    if let Some(expected_file) = &expected.file {
690        let actual_basename = Path::new(actual.location.file.as_ref())
691            .file_name()
692            .map(|n| n.to_string_lossy())
693            .unwrap_or_default();
694        if actual_basename.as_ref() != expected_file.as_str() {
695            return false;
696        }
697    }
698    if let Some(line) = expected.line {
699        if actual.location.line != line {
700            return false;
701        }
702    }
703    if let Some(col) = expected.col_start {
704        if actual.location.col_start != col {
705            return false;
706        }
707    }
708    true
709}
710
711// ---------------------------------------------------------------------------
712// UPDATE_FIXTURES rewrite
713// ---------------------------------------------------------------------------
714
715fn rewrite_fixture(path: &str, content: &str, actual: &[Issue], is_multi: bool) {
716    // Preserve everything before ===expect=== and rewrite only the expect section.
717    let exp_pos = content
718        .find(EXPECT_MARKER)
719        .expect("fixture missing ===expect===");
720
721    let mut out = content[..exp_pos].to_string();
722    out.push_str(EXPECT_MARKER);
723    out.push('\n');
724
725    let mut sorted: Vec<&Issue> = actual.iter().collect();
726    if is_multi {
727        sorted.sort_by_key(|i| {
728            let basename = Path::new(i.location.file.as_ref())
729                .file_name()
730                .map(|n| n.to_string_lossy().into_owned())
731                .unwrap_or_default();
732            (
733                basename,
734                i.location.line,
735                i.location.col_start,
736                i.kind.name(),
737            )
738        });
739        for issue in sorted {
740            let basename = Path::new(issue.location.file.as_ref())
741                .file_name()
742                .map(|n| n.to_string_lossy().into_owned())
743                .unwrap_or_default();
744            out.push_str(&format!(
745                "{}: {}@{}:{}: {}\n",
746                basename,
747                issue.kind.name(),
748                issue.location.line,
749                issue.location.col_start,
750                issue.kind.message()
751            ));
752        }
753    } else {
754        sorted.sort_by_key(|i| (i.location.line, i.location.col_start, i.kind.name()));
755        for issue in sorted {
756            out.push_str(&format!(
757                "{}@{}:{}: {}\n",
758                issue.kind.name(),
759                issue.location.line,
760                issue.location.col_start,
761                issue.kind.message()
762            ));
763        }
764    }
765
766    std::fs::write(path, &out).unwrap_or_else(|e| panic!("failed to write fixture {path}: {e}"));
767}
768
769// ---------------------------------------------------------------------------
770// Assertion helpers (used by inline tests)
771// ---------------------------------------------------------------------------
772
773/// Assert that `issues` contains at least one issue with the exact `IssueKind`
774/// at `line` and `col_start`.
775pub fn assert_issue(issues: &[Issue], kind: IssueKind, line: u32, col_start: u16) {
776    let found = issues
777        .iter()
778        .any(|i| i.kind == kind && i.location.line == line && i.location.col_start == col_start);
779    if !found {
780        panic!(
781            "Expected issue {:?} at line {line}, col {col_start}.\nActual issues:\n{}",
782            kind,
783            fmt_issues(issues, false),
784        );
785    }
786}
787
788/// Assert that `issues` contains at least one issue whose `kind.name()` equals
789/// `kind_name` at `line` and `col_start`.
790pub fn assert_issue_kind(issues: &[Issue], kind_name: &str, line: u32, col_start: u16) {
791    let found = issues.iter().any(|i| {
792        i.kind.name() == kind_name && i.location.line == line && i.location.col_start == col_start
793    });
794    if !found {
795        panic!(
796            "Expected issue {kind_name} at line {line}, col {col_start}.\nActual issues:\n{}",
797            fmt_issues(issues, false),
798        );
799    }
800}
801
802/// Assert that `issues` contains no issue whose `kind.name()` equals `kind_name`.
803pub fn assert_no_issue(issues: &[Issue], kind_name: &str) {
804    let found: Vec<_> = issues
805        .iter()
806        .filter(|i| i.kind.name() == kind_name)
807        .collect();
808    if !found.is_empty() {
809        panic!(
810            "Expected no {kind_name} issues, but found:\n{}",
811            fmt_issues(&found.into_iter().cloned().collect::<Vec<_>>(), false),
812        );
813    }
814}
815
816// ---------------------------------------------------------------------------
817// Formatting helpers
818// ---------------------------------------------------------------------------
819
820fn fmt_expected(exp: &ExpectedIssue, is_multi: bool) -> String {
821    let kind_with_loc = if let (Some(line), Some(col)) = (exp.line, exp.col_start) {
822        format!("{}@{}:{}", exp.kind_name, line, col)
823    } else {
824        exp.kind_name.clone()
825    };
826
827    if is_multi {
828        if let Some(f) = &exp.file {
829            return format!("{}: {}: {}", f, kind_with_loc, exp.message);
830        }
831    }
832    format!("{}: {}", kind_with_loc, exp.message)
833}
834
835fn fmt_actual(act: &Issue, is_multi: bool) -> String {
836    if is_multi {
837        let basename = Path::new(act.location.file.as_ref())
838            .file_name()
839            .map(|n| n.to_string_lossy().into_owned())
840            .unwrap_or_default();
841        return format!(
842            "{}: {}@{}:{}: {}",
843            basename,
844            act.kind.name(),
845            act.location.line,
846            act.location.col_start,
847            act.kind.message()
848        );
849    }
850    format!(
851        "{}@{}:{}: {}",
852        act.kind.name(),
853        act.location.line,
854        act.location.col_start,
855        act.kind.message()
856    )
857}
858
859fn fmt_issues(issues: &[Issue], is_multi: bool) -> String {
860    if issues.is_empty() {
861        return "  (none)".to_string();
862    }
863    issues
864        .iter()
865        .map(|i| format!("  {}", fmt_actual(i, is_multi)))
866        .collect::<Vec<_>>()
867        .join("\n")
868}
869
870// ---------------------------------------------------------------------------
871// Fixture parser validation tests
872// ---------------------------------------------------------------------------
873
874#[cfg(test)]
875mod parser_validation {
876    use super::{parse_phpt, ParsedFixture};
877
878    fn p(content: &str) -> ParsedFixture {
879        parse_phpt(content, "<test>")
880    }
881
882    #[test]
883    #[should_panic(expected = "===file=== must appear at most once")]
884    fn duplicate_bare_file_marker() {
885        p("===file===\n<?php\n===file===\n<?php\n===expect===\n");
886    }
887
888    #[test]
889    #[should_panic(expected = "cannot mix ===file=== and ===file:name===")]
890    fn mixed_bare_and_named_markers() {
891        p("===file===\n<?php\n===file:Other.php===\n<?php\n===expect===\n");
892    }
893
894    #[test]
895    #[should_panic(expected = "===config=== must appear at most once")]
896    fn duplicate_config_section() {
897        p("===config===\nfind_dead_code=false\n===config===\nfind_dead_code=true\n===file===\n<?php\n===expect===\n");
898    }
899
900    #[test]
901    #[should_panic(expected = "unknown config key")]
902    fn unknown_config_key() {
903        p("===config===\nfoo=bar\n===file===\n<?php\n===expect===\n");
904    }
905
906    #[test]
907    #[should_panic(expected = "invalid php_version")]
908    fn invalid_php_version() {
909        p("===config===\nphp_version=banana\n===file===\n<?php\n===expect===\n");
910    }
911
912    #[test]
913    #[should_panic(expected = "find_dead_code must be `true` or `false`")]
914    fn invalid_find_dead_code_value() {
915        p("===config===\nfind_dead_code=maybe\n===file===\n<?php\n===expect===\n");
916    }
917
918    #[test]
919    #[should_panic(expected = "===config=== must appear before the first ===file===")]
920    fn config_after_file_marker() {
921        p("===file===\n<?php\n===config===\nfind_dead_code=true\n===expect===\n");
922    }
923
924    #[test]
925    fn valid_config_is_accepted() {
926        p("===config===\nphp_version=8.1\nfind_dead_code=true\n===file===\n<?php\n===expect===\n");
927    }
928
929    #[test]
930    #[should_panic(expected = "===description=== must appear at most once")]
931    fn duplicate_description_section() {
932        p("===description===\nfoo\n===description===\nbar\n===file===\n<?php\n===expect===\n");
933    }
934
935    #[test]
936    #[should_panic(expected = "===description=== must appear before the first ===file===")]
937    fn description_after_file_marker() {
938        p("===file===\n<?php\n===description===\nfoo\n===expect===\n");
939    }
940
941    #[test]
942    fn valid_description_is_accepted() {
943        let f = p("===description===\nChecks null method call.\n===file===\n<?php\n===expect===\n");
944        assert_eq!(f.description.as_deref(), Some("Checks null method call."));
945    }
946
947    #[test]
948    #[should_panic(expected = "===ignore=== must appear at most once")]
949    fn duplicate_ignore_marker() {
950        p("===ignore===\n===ignore===\n===file===\n<?php\n===expect===\n");
951    }
952
953    #[test]
954    #[should_panic(expected = "===ignore=== must appear before the first ===file===")]
955    fn ignore_after_file_marker() {
956        p("===file===\n<?php\n===ignore===\n===expect===\n");
957    }
958
959    #[test]
960    fn valid_ignore_is_accepted() {
961        let f = p("===ignore===\nTODO: not yet implemented\n===file===\n<?php\n===expect===\n");
962        assert!(f.description.is_none());
963    }
964}