1use 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#[derive(Default)]
95struct FixtureConfig {
96 php_version: Option<PhpVersion>,
97 find_dead_code: bool,
98 stub_files: Vec<String>,
100 stub_dirs: Vec<String>,
102}
103
104pub fn check(src: &str) -> Vec<Issue> {
110 run_analyzer(&[("test.php", src)], &FixtureConfig::default())
111}
112
113pub fn check_files(files: &[(&str, &str)]) -> Vec<Issue> {
122 run_analyzer(files, &FixtureConfig::default())
123}
124
125pub(crate) struct ExpectedIssue {
131 pub file: Option<String>,
132 pub kind_name: String,
133 pub message: String,
134}
135
136pub(crate) struct ParsedFixture {
138 pub files: Vec<(String, String)>,
140 pub expected: Vec<ExpectedIssue>,
141 pub is_multi: bool,
142 config: FixtureConfig,
143}
144
145const BARE_FILE: &str = "===file===";
150const FILE_PREFIX: &str = "===file:";
151const CONFIG_MARKER: &str = "===config===";
152const EXPECT_MARKER: &str = "===expect===";
153
154pub(crate) fn parse_phpt(content: &str, path: &str) -> ParsedFixture {
156 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 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 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 let bare_count = count_occurrences(header_region, BARE_FILE);
190 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 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 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 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 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
363pub 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
390fn 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 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 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 .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
489fn 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
542fn rewrite_fixture(path: &str, content: &str, actual: &[Issue], is_multi: bool) {
547 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
596pub 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
615pub 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
629pub 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
643fn 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#[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}