1use 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#[derive(Default)]
126struct FixtureConfig {
127 php_version: Option<PhpVersion>,
128 find_dead_code: bool,
129 stub_files: Vec<String>,
131 stub_dirs: Vec<String>,
133}
134
135pub fn check(src: &str) -> Vec<Issue> {
141 run_analyzer(&[("test.php", src)], &FixtureConfig::default())
142}
143
144pub fn check_files(files: &[(&str, &str)]) -> Vec<Issue> {
153 run_analyzer(files, &FixtureConfig::default())
154}
155
156pub(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
169pub(crate) struct ParsedFixture {
171 pub files: Vec<(String, String)>,
173 pub expected: Vec<ExpectedIssue>,
174 pub is_multi: bool,
175 pub description: Option<String>,
177 config: FixtureConfig,
178}
179
180const 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
191pub(crate) fn parse_phpt(content: &str, path: &str) -> ParsedFixture {
193 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 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 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 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 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 let bare_count = count_occurrences(header_region, BARE_FILE);
263 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 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 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 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 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 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 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
511pub 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
538fn 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 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 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 .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
637fn 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
711fn rewrite_fixture(path: &str, content: &str, actual: &[Issue], is_multi: bool) {
716 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
769pub 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
788pub 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
802pub 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
816fn 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#[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}