1use std::collections::HashSet;
73use std::path::{Path, PathBuf};
74use std::sync::atomic::{AtomicU64, Ordering};
75use std::sync::Arc;
76
77use crate::{project::ProjectAnalyzer, PhpVersion};
78use mir_issues::{Issue, IssueKind};
79
80static COUNTER: AtomicU64 = AtomicU64::new(0);
81
82#[derive(Default)]
87struct FixtureConfig {
88 php_version: Option<PhpVersion>,
89 find_dead_code: bool,
90}
91
92pub fn check(src: &str) -> Vec<Issue> {
98 run_analyzer(&[("test.php", src)], &FixtureConfig::default())
99}
100
101pub fn check_files(files: &[(&str, &str)]) -> Vec<Issue> {
110 run_analyzer(files, &FixtureConfig::default())
111}
112
113pub(crate) struct ExpectedIssue {
119 pub file: Option<String>,
120 pub kind_name: String,
121 pub message: String,
122}
123
124pub(crate) struct ParsedFixture {
126 pub files: Vec<(String, String)>,
128 pub expected: Vec<ExpectedIssue>,
129 pub is_multi: bool,
130 config: FixtureConfig,
131}
132
133const BARE_FILE: &str = "===file===";
138const FILE_PREFIX: &str = "===file:";
139const CONFIG_MARKER: &str = "===config===";
140const EXPECT_MARKER: &str = "===expect===";
141
142pub(crate) fn parse_phpt(content: &str, path: &str) -> ParsedFixture {
144 let expect_count = count_occurrences(content, EXPECT_MARKER);
146 assert_eq!(
147 expect_count, 1,
148 "fixture {path}: ===expect=== must appear exactly once, found {expect_count} times"
149 );
150 let expect_pos = content.find(EXPECT_MARKER).unwrap();
151 let header_region = &content[..expect_pos];
152 let expect_content = content[expect_pos + EXPECT_MARKER.len()..].trim();
153
154 let config_count = count_occurrences(header_region, CONFIG_MARKER);
156 assert!(
157 config_count <= 1,
158 "fixture {path}: ===config=== must appear at most once, found {config_count} times"
159 );
160
161 if config_count == 1 {
165 if let (Some(cfg_pos), Some(first_file_pos)) = (
166 header_region.find(CONFIG_MARKER),
167 header_region.find("===file"),
168 ) {
169 assert!(
170 cfg_pos < first_file_pos,
171 "fixture {path}: ===config=== must appear before the first ===file=== / ===file:name=== marker"
172 );
173 }
174 }
175
176 let bare_count = count_occurrences(header_region, BARE_FILE);
178 let named_count = count_occurrences(header_region, FILE_PREFIX);
181
182 assert!(
183 !(bare_count > 0 && named_count > 0),
184 "fixture {path}: cannot mix ===file=== and ===file:name=== markers in the same fixture"
185 );
186 assert!(
187 bare_count > 0 || named_count > 0,
188 "fixture {path}: no ===file=== or ===file:name=== section found"
189 );
190 assert!(
191 bare_count <= 1,
192 "fixture {path}: ===file=== must appear at most once, found {bare_count} times"
193 );
194
195 let is_multi = named_count > 0;
196
197 let files = if is_multi {
199 extract_named_files(header_region, path)
200 } else {
201 let bare_pos = header_region.find(BARE_FILE).unwrap();
202 let src = header_region[bare_pos + BARE_FILE.len()..]
203 .trim()
204 .to_string();
205 vec![("test.php".to_string(), src)]
206 };
207
208 let config = if config_count == 1 {
210 let cfg_pos = header_region.find(CONFIG_MARKER).unwrap();
211 let after_cfg = cfg_pos + CONFIG_MARKER.len();
212 let cfg_end = header_region[after_cfg..]
214 .find("===file")
215 .map(|r| after_cfg + r)
216 .unwrap_or(header_region.len());
217 let cfg_text = header_region[after_cfg..cfg_end].trim();
218 parse_config_section(cfg_text, path)
219 } else {
220 FixtureConfig::default()
221 };
222
223 let expected = expect_content
225 .lines()
226 .map(str::trim)
227 .filter(|l| !l.is_empty() && !l.starts_with('#'))
228 .map(|l| {
229 if is_multi {
230 parse_multi_expect_line(l, path)
231 } else {
232 parse_single_expect_line(l, path)
233 }
234 })
235 .collect();
236
237 ParsedFixture {
238 files,
239 expected,
240 is_multi,
241 config,
242 }
243}
244
245fn parse_config_section(text: &str, path: &str) -> FixtureConfig {
246 let mut config = FixtureConfig::default();
247 for raw_line in text.lines() {
248 let line = raw_line.trim();
249 if line.is_empty() {
250 continue;
251 }
252 let (key, value) = line.split_once('=').unwrap_or_else(|| {
253 panic!("fixture {path}: invalid config line {line:?} — expected key=value")
254 });
255 match key.trim() {
256 "php_version" => {
257 let v = value.trim().parse::<PhpVersion>().unwrap_or_else(|e| {
258 panic!("fixture {path}: invalid php_version: {e}")
259 });
260 config.php_version = Some(v);
261 }
262 "find_dead_code" => {
263 config.find_dead_code = match value.trim() {
264 "true" => true,
265 "false" => false,
266 other => panic!(
267 "fixture {path}: find_dead_code must be `true` or `false`, got {other:?}"
268 ),
269 };
270 }
271 other => panic!(
272 "fixture {path}: unknown config key {other:?} — valid keys: php_version, find_dead_code"
273 ),
274 }
275 }
276 config
277}
278
279fn extract_named_files(region: &str, path: &str) -> Vec<(String, String)> {
280 let mut files = Vec::new();
281 let mut search_from = 0;
282
283 while let Some(marker_rel) = region[search_from..].find(FILE_PREFIX) {
284 let marker_abs = search_from + marker_rel;
285 let after_prefix = marker_abs + FILE_PREFIX.len();
286
287 let close_rel = region[after_prefix..]
288 .find("===")
289 .unwrap_or_else(|| panic!("fixture {path}: unclosed ===file: marker"));
290
291 let file_name = region[after_prefix..after_prefix + close_rel].to_string();
292 let content_start = after_prefix + close_rel + "===".len();
293
294 let content_end = region[content_start..]
295 .find(FILE_PREFIX)
296 .map(|r| content_start + r)
297 .unwrap_or(region.len());
298
299 let file_content = region[content_start..content_end].trim().to_string();
300 files.push((file_name, file_content));
301 search_from = content_end;
302 }
303
304 files
305}
306
307fn parse_single_expect_line(line: &str, path: &str) -> ExpectedIssue {
308 let parts: Vec<&str> = line.splitn(2, ": ").collect();
309 assert_eq!(
310 parts.len(),
311 2,
312 "fixture {path}: invalid expect line {line:?} — expected \"KindName: message\""
313 );
314 ExpectedIssue {
315 file: None,
316 kind_name: parts[0].trim().to_string(),
317 message: parts[1].trim().to_string(),
318 }
319}
320
321fn parse_multi_expect_line(line: &str, path: &str) -> ExpectedIssue {
322 let parts: Vec<&str> = line.splitn(3, ": ").collect();
323 assert_eq!(
324 parts.len(),
325 3,
326 "fixture {path}: invalid multi-file expect line {line:?} — expected \"FileName.php: KindName: message\""
327 );
328 ExpectedIssue {
329 file: Some(parts[0].trim().to_string()),
330 kind_name: parts[1].trim().to_string(),
331 message: parts[2].trim().to_string(),
332 }
333}
334
335fn count_occurrences(haystack: &str, needle: &str) -> usize {
336 let mut count = 0;
337 let mut start = 0;
338 while let Some(pos) = haystack[start..].find(needle) {
339 count += 1;
340 start += pos + needle.len();
341 }
342 count
343}
344
345pub fn run_fixture(path: &str) {
353 let content = std::fs::read_to_string(path)
354 .unwrap_or_else(|e| panic!("failed to read fixture {path}: {e}"));
355
356 let fixture = parse_phpt(&content, path);
357 let file_refs: Vec<(&str, &str)> = fixture
358 .files
359 .iter()
360 .map(|(n, s)| (n.as_str(), s.as_str()))
361 .collect();
362 let actual = run_analyzer(&file_refs, &fixture.config);
363
364 if std::env::var("UPDATE_FIXTURES").as_deref() == Ok("1") {
365 rewrite_fixture(path, &content, &actual, fixture.is_multi);
366 return;
367 }
368
369 assert_fixture(path, &fixture, &actual);
370}
371
372fn run_analyzer(files: &[(&str, &str)], config: &FixtureConfig) -> Vec<Issue> {
377 let id = COUNTER.fetch_add(1, Ordering::Relaxed);
378 let tmp_dir = std::env::temp_dir().join(format!("mir_fixture_{id}"));
379 std::fs::create_dir_all(&tmp_dir)
380 .unwrap_or_else(|e| panic!("failed to create temp dir {}: {e}", tmp_dir.display()));
381
382 let paths: Vec<PathBuf> = files
383 .iter()
384 .map(|(name, src)| {
385 let path = tmp_dir.join(name);
386 if let Some(parent) = path.parent() {
387 std::fs::create_dir_all(parent)
388 .unwrap_or_else(|e| panic!("failed to create dir for {name}: {e}"));
389 }
390 std::fs::write(&path, src).unwrap_or_else(|e| panic!("failed to write {name}: {e}"));
391 path
392 })
393 .collect();
394
395 let tmp_dir_str = tmp_dir.to_string_lossy().into_owned();
396
397 let mut analyzer = ProjectAnalyzer::new();
398 analyzer.find_dead_code = config.find_dead_code;
399 if let Some(version) = config.php_version {
400 analyzer = analyzer.with_php_version(version);
401 }
402
403 let has_composer = files.iter().any(|(name, _)| *name == "composer.json");
404 let explicit_paths: Vec<PathBuf> = if has_composer {
405 match crate::composer::Psr4Map::from_composer(&tmp_dir) {
406 Ok(psr4) => {
407 let psr4 = Arc::new(psr4);
408 let psr4_files: HashSet<PathBuf> = psr4.project_files().into_iter().collect();
409 let explicit: Vec<PathBuf> = paths
410 .iter()
411 .filter(|p| p.extension().map(|e| e == "php").unwrap_or(false))
412 .filter(|p| !psr4_files.contains(*p))
413 .cloned()
414 .collect();
415 analyzer.psr4 = Some(psr4);
416 explicit
417 }
418 Err(_) => php_files_only(&paths),
419 }
420 } else {
421 php_files_only(&paths)
422 };
423
424 let result = analyzer.analyze(&explicit_paths);
425 std::fs::remove_dir_all(&tmp_dir).ok();
426
427 result
428 .issues
429 .into_iter()
430 .filter(|i| !i.suppressed)
431 .filter(|i| {
435 !config.find_dead_code || i.location.file.as_ref().starts_with(tmp_dir_str.as_str())
436 })
437 .collect()
438}
439
440fn php_files_only(paths: &[PathBuf]) -> Vec<PathBuf> {
441 paths
442 .iter()
443 .filter(|p| p.extension().map(|e| e == "php").unwrap_or(false))
444 .cloned()
445 .collect()
446}
447
448fn assert_fixture(path: &str, fixture: &ParsedFixture, actual: &[Issue]) {
453 let mut failures: Vec<String> = Vec::new();
454
455 for exp in &fixture.expected {
456 if !actual.iter().any(|a| issue_matches(a, exp)) {
457 failures.push(format!(
458 " MISSING {}",
459 fmt_expected(exp, fixture.is_multi)
460 ));
461 }
462 }
463
464 for act in actual {
465 if !fixture.expected.iter().any(|e| issue_matches(act, e)) {
466 failures.push(format!(
467 " UNEXPECTED {}",
468 fmt_actual(act, fixture.is_multi)
469 ));
470 }
471 }
472
473 if !failures.is_empty() {
474 panic!(
475 "fixture {path} FAILED:\n{}\n\nAll actual issues:\n{}",
476 failures.join("\n"),
477 fmt_issues(actual, fixture.is_multi)
478 );
479 }
480}
481
482fn issue_matches(actual: &Issue, expected: &ExpectedIssue) -> bool {
483 if actual.kind.name() != expected.kind_name {
484 return false;
485 }
486 if actual.kind.message() != expected.message.as_str() {
487 return false;
488 }
489 if let Some(expected_file) = &expected.file {
490 let actual_basename = Path::new(actual.location.file.as_ref())
491 .file_name()
492 .map(|n| n.to_string_lossy())
493 .unwrap_or_default();
494 if actual_basename.as_ref() != expected_file.as_str() {
495 return false;
496 }
497 }
498 true
499}
500
501fn rewrite_fixture(path: &str, content: &str, actual: &[Issue], is_multi: bool) {
506 let exp_pos = content
508 .find(EXPECT_MARKER)
509 .expect("fixture missing ===expect===");
510
511 let mut out = content[..exp_pos].to_string();
512 out.push_str(EXPECT_MARKER);
513 out.push('\n');
514
515 let mut sorted: Vec<&Issue> = actual.iter().collect();
516 if is_multi {
517 sorted.sort_by_key(|i| {
518 let basename = Path::new(i.location.file.as_ref())
519 .file_name()
520 .map(|n| n.to_string_lossy().into_owned())
521 .unwrap_or_default();
522 (
523 basename,
524 i.location.line,
525 i.location.col_start,
526 i.kind.name(),
527 )
528 });
529 for issue in sorted {
530 let basename = Path::new(issue.location.file.as_ref())
531 .file_name()
532 .map(|n| n.to_string_lossy().into_owned())
533 .unwrap_or_default();
534 out.push_str(&format!(
535 "{}: {}: {}\n",
536 basename,
537 issue.kind.name(),
538 issue.kind.message()
539 ));
540 }
541 } else {
542 sorted.sort_by_key(|i| (i.location.line, i.location.col_start, i.kind.name()));
543 for issue in sorted {
544 out.push_str(&format!(
545 "{}: {}\n",
546 issue.kind.name(),
547 issue.kind.message()
548 ));
549 }
550 }
551
552 std::fs::write(path, &out).unwrap_or_else(|e| panic!("failed to write fixture {path}: {e}"));
553}
554
555pub fn assert_issue(issues: &[Issue], kind: IssueKind, line: u32, col_start: u16) {
562 let found = issues
563 .iter()
564 .any(|i| i.kind == kind && i.location.line == line && i.location.col_start == col_start);
565 if !found {
566 panic!(
567 "Expected issue {:?} at line {line}, col {col_start}.\nActual issues:\n{}",
568 kind,
569 fmt_issues(issues, false),
570 );
571 }
572}
573
574pub fn assert_issue_kind(issues: &[Issue], kind_name: &str, line: u32, col_start: u16) {
577 let found = issues.iter().any(|i| {
578 i.kind.name() == kind_name && i.location.line == line && i.location.col_start == col_start
579 });
580 if !found {
581 panic!(
582 "Expected issue {kind_name} at line {line}, col {col_start}.\nActual issues:\n{}",
583 fmt_issues(issues, false),
584 );
585 }
586}
587
588pub fn assert_no_issue(issues: &[Issue], kind_name: &str) {
590 let found: Vec<_> = issues
591 .iter()
592 .filter(|i| i.kind.name() == kind_name)
593 .collect();
594 if !found.is_empty() {
595 panic!(
596 "Expected no {kind_name} issues, but found:\n{}",
597 fmt_issues(&found.into_iter().cloned().collect::<Vec<_>>(), false),
598 );
599 }
600}
601
602fn fmt_expected(exp: &ExpectedIssue, is_multi: bool) -> String {
607 if is_multi {
608 if let Some(f) = &exp.file {
609 return format!("{}: {}: {}", f, exp.kind_name, exp.message);
610 }
611 }
612 format!("{}: {}", exp.kind_name, exp.message)
613}
614
615fn fmt_actual(act: &Issue, is_multi: bool) -> String {
616 if is_multi {
617 let basename = Path::new(act.location.file.as_ref())
618 .file_name()
619 .map(|n| n.to_string_lossy().into_owned())
620 .unwrap_or_default();
621 return format!("{}: {}: {}", basename, act.kind.name(), act.kind.message());
622 }
623 format!("{}: {}", act.kind.name(), act.kind.message())
624}
625
626fn fmt_issues(issues: &[Issue], is_multi: bool) -> String {
627 if issues.is_empty() {
628 return " (none)".to_string();
629 }
630 issues
631 .iter()
632 .map(|i| format!(" {}", fmt_actual(i, is_multi)))
633 .collect::<Vec<_>>()
634 .join("\n")
635}
636
637#[cfg(test)]
642mod parser_validation {
643 use super::parse_phpt;
644
645 fn p(content: &str) {
646 parse_phpt(content, "<test>");
647 }
648
649 #[test]
650 #[should_panic(expected = "===file=== must appear at most once")]
651 fn duplicate_bare_file_marker() {
652 p("===file===\n<?php\n===file===\n<?php\n===expect===\n");
653 }
654
655 #[test]
656 #[should_panic(expected = "cannot mix ===file=== and ===file:name===")]
657 fn mixed_bare_and_named_markers() {
658 p("===file===\n<?php\n===file:Other.php===\n<?php\n===expect===\n");
659 }
660
661 #[test]
662 #[should_panic(expected = "===config=== must appear at most once")]
663 fn duplicate_config_section() {
664 p("===config===\nfind_dead_code=false\n===config===\nfind_dead_code=true\n===file===\n<?php\n===expect===\n");
665 }
666
667 #[test]
668 #[should_panic(expected = "unknown config key")]
669 fn unknown_config_key() {
670 p("===config===\nfoo=bar\n===file===\n<?php\n===expect===\n");
671 }
672
673 #[test]
674 #[should_panic(expected = "invalid php_version")]
675 fn invalid_php_version() {
676 p("===config===\nphp_version=banana\n===file===\n<?php\n===expect===\n");
677 }
678
679 #[test]
680 #[should_panic(expected = "find_dead_code must be `true` or `false`")]
681 fn invalid_find_dead_code_value() {
682 p("===config===\nfind_dead_code=maybe\n===file===\n<?php\n===expect===\n");
683 }
684
685 #[test]
686 #[should_panic(expected = "===config=== must appear before the first ===file===")]
687 fn config_after_file_marker() {
688 p("===file===\n<?php\n===config===\nfind_dead_code=true\n===expect===\n");
689 }
690
691 #[test]
692 fn valid_config_is_accepted() {
693 p("===config===\nphp_version=8.1\nfind_dead_code=true\n===file===\n<?php\n===expect===\n");
694 }
695}