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