mod common;
use std::sync::Arc;
use mir_analyzer::{AnalysisSession, BatchOptions, FileAnalyzer, PhpVersion, ReferenceKind};
fn var_type(symbols: &[mir_analyzer::ResolvedSymbol], name: &str) -> String {
symbols
.iter()
.find(|s| matches!(&s.kind, ReferenceKind::Variable(n) if n.as_ref() == name))
.map(|s| format!("{}", s.resolved_type))
.unwrap_or_else(|| "not found".to_string())
}
fn new_session() -> AnalysisSession {
AnalysisSession::new(PhpVersion::LATEST)
}
use self::common::{create_temp_dir, write_file};
#[test]
fn re_analyze_file_picks_up_new_error() {
let src_dir = create_temp_dir("test");
let file_a = write_file(
&src_dir,
"A.php",
"<?php\nfunction greet(): string { return 'hello'; }\n",
);
let analyzer = new_session();
let result1 = analyzer.analyze_paths(std::slice::from_ref(&file_a), &BatchOptions::new());
let undef_fn_count = result1
.issues
.iter()
.filter(|i| i.kind.name() == "UndefinedFunction")
.count();
assert_eq!(
undef_fn_count, 0,
"initial code should have no UndefinedFunction"
);
let file_path = file_a.to_string_lossy().to_string();
let new_content = "<?php\nfunction test(): void { nonexistent_func(); }\n";
let result2 = analyzer.re_analyze_file(&file_path, new_content, &BatchOptions::new());
let undef_fn_count2 = result2
.issues
.iter()
.filter(|i| i.kind.name() == "UndefinedFunction")
.count();
assert!(
undef_fn_count2 > 0,
"re-analyzed code should report UndefinedFunction, got issues: {:?}",
result2
.issues
.iter()
.map(|i| i.kind.name())
.collect::<Vec<_>>()
);
}
#[test]
fn re_analyze_file_removes_old_definitions() {
let src_dir = create_temp_dir("test");
let file_a = write_file(
&src_dir,
"A.php",
"<?php\nclass Foo { public function bar(): void {} }\n",
);
let file_b = write_file(
&src_dir,
"B.php",
"<?php\nfunction test(): void { $f = new Foo(); $f->bar(); }\n",
);
let analyzer = new_session();
let result1 = analyzer.analyze_paths(&[file_a.clone(), file_b.clone()], &BatchOptions::new());
let undef_method = result1
.issues
.iter()
.filter(|i| i.kind.name() == "UndefinedMethod")
.count();
assert_eq!(undef_method, 0, "bar() should be found");
let file_path_a = file_a.to_string_lossy().to_string();
let new_content_a = "<?php\nclass Foo { public function baz(): void {} }\n";
let _result2 = analyzer.re_analyze_file(&file_path_a, new_content_a, &BatchOptions::new());
assert!(
analyzer.contains_method("Foo", "baz"),
"baz() should exist after re-analysis"
);
assert!(
!analyzer.contains_method("Foo", "bar"),
"bar() should be removed after re-analysis"
);
}
#[test]
fn re_analyze_file_fixes_error() {
let src_dir = create_temp_dir("test");
let file_a = write_file(
&src_dir,
"A.php",
"<?php\nfunction test(): void { missing_fn(); }\n",
);
let analyzer = new_session();
let result1 = analyzer.analyze_paths(std::slice::from_ref(&file_a), &BatchOptions::new());
let undef_count = result1
.issues
.iter()
.filter(|i| i.kind.name() == "UndefinedFunction")
.count();
assert!(undef_count > 0, "should have UndefinedFunction initially");
let file_path = file_a.to_string_lossy().to_string();
let new_content =
"<?php\nfunction missing_fn(): void {}\nfunction test(): void { missing_fn(); }\n";
let result2 = analyzer.re_analyze_file(&file_path, new_content, &BatchOptions::new());
let undef_count2 = result2
.issues
.iter()
.filter(|i| i.kind.name() == "UndefinedFunction")
.count();
assert_eq!(undef_count2, 0, "after fix, no UndefinedFunction expected");
}
#[test]
fn re_analyze_file_uses_cache_on_unchanged_content() {
let src_dir = create_temp_dir("test");
let cache_dir = create_temp_dir("cache");
let content = "<?php\nfunction test(): void { ghost_fn(); }\n";
let file_a = write_file(&src_dir, "A.php", content);
let file_path = file_a.to_string_lossy().to_string();
let analyzer = AnalysisSession::new(PhpVersion::LATEST).with_cache_dir(cache_dir.path());
let result1 = analyzer.analyze_paths(std::slice::from_ref(&file_a), &BatchOptions::new());
let undef_count = result1
.issues
.iter()
.filter(|i| i.kind.name() == "UndefinedFunction")
.count();
assert!(
undef_count > 0,
"initial analysis should report UndefinedFunction"
);
analyzer.set_file_text(
Arc::from("ghost_fn.php"),
Arc::from("<?php\nfunction ghost_fn(): void {}\n"),
);
let result2 = analyzer.re_analyze_file(&file_path, content, &BatchOptions::new());
let undef_count2 = result2
.issues
.iter()
.filter(|i| i.kind.name() == "UndefinedFunction")
.count();
assert_eq!(
undef_count2, undef_count,
"cache hit should return the same cached issues; slow-path would return 0 \
because ghost_fn was inserted into the codebase"
);
}
#[test]
fn re_analyze_preserves_namespace_and_use_alias_resolution() {
let src_dir = create_temp_dir("test");
let _entity = write_file(
&src_dir,
"Entity.php",
"<?php\nnamespace App\\Model;\nclass Entity {}\n",
);
let handler_src = "<?php\nnamespace App\\Service;\nuse App\\Model\\Entity;\n\
function handle(): void { $e = new Entity(); }\n";
let handler = write_file(&src_dir, "Handler.php", handler_src);
let analyzer = new_session();
let result1 = analyzer.analyze_paths(
&[src_dir.path().join("Entity.php"), handler.clone()],
&BatchOptions::new(),
);
let undef1: Vec<_> = result1
.issues
.iter()
.filter(|i| i.kind.name() == "UndefinedClass")
.collect();
assert!(
undef1.is_empty(),
"initial analysis must not report UndefinedClass; got: {undef1:?}"
);
let handler_src2 = "<?php\nnamespace App\\Service;\nuse App\\Model\\Entity;\n\
function handle(): void { $e = new Entity(); /* re-analyzed */ }\n";
let result2 = analyzer.re_analyze_file(
handler.to_string_lossy().as_ref(),
handler_src2,
&BatchOptions::new(),
);
let undef2: Vec<_> = result2
.issues
.iter()
.filter(|i| i.kind.name() == "UndefinedClass")
.collect();
assert!(
undef2.is_empty(),
"re_analyze_file must not produce false UndefinedClass after namespace/import restoration; \
got: {undef2:?}"
);
}
#[test]
fn re_analyze_file_primes_inferred_return_type_for_same_file_calls() {
let src_dir = create_temp_dir("test");
let content =
"<?php\nfunction bar() { return 'hello'; }\nfunction foo(): string { return bar(); }\n";
let file = write_file(&src_dir, "A.php", content);
let file_path = file.to_string_lossy().to_string();
let analyzer = new_session();
let result1 = analyzer.analyze_paths(std::slice::from_ref(&file), &BatchOptions::new());
let issues1: Vec<_> = result1
.issues
.iter()
.filter(|i| i.kind.name() == "InvalidReturnType")
.collect();
assert!(
issues1.is_empty(),
"initial analysis must not report InvalidReturnType; got: {issues1:?}"
);
let content2 = "<?php\nfunction bar() { return 'hello'; }\nfunction foo(): string { return bar(); /* re-analyzed */ }\n";
let result2 = analyzer.re_analyze_file(&file_path, content2, &BatchOptions::new());
let issues2: Vec<_> = result2
.issues
.iter()
.filter(|i| i.kind.name() == "InvalidReturnType")
.collect();
assert!(
issues2.is_empty(),
"re_analyze_file must not report false InvalidReturnType after body-only change; \
got: {issues2:?}"
);
}
#[test]
fn file_analyzer_sees_fresh_return_type_after_ingest() {
let a_src_v1 = "<?php\nclass Apple {}\nclass Maker { public function make(): Apple { return new Apple(); } }\n";
let a_src_v2 = "<?php\nclass Banana {}\nclass Maker { public function make(): Banana { return new Banana(); } }\n";
let b_src = "<?php\n$x = (new Maker)->make();\n$_ = $x;\n";
let session = AnalysisSession::new(PhpVersion::LATEST);
let file_a: Arc<str> = Arc::from("a.php");
let file_b: Arc<str> = Arc::from("b.php");
session.ingest_file(file_a.clone(), Arc::from(a_src_v1));
session.ingest_file(file_b.clone(), Arc::from(b_src));
let parsed_b = php_rs_parser::parse(b_src);
session.ingest_file(file_a.clone(), Arc::from(a_src_v2));
let analysis = FileAnalyzer::new(&session).analyze(
file_b.clone(),
b_src,
&parsed_b.program,
&parsed_b.source_map,
);
let x_ty = var_type(&analysis.symbols, "x");
assert_eq!(
x_ty, "Banana",
"$x must resolve to Banana immediately after ingest_file updates a.php; got {x_ty}"
);
}
#[test]
fn re_analyze_file_evicts_dependents_after_ingest() {
let src_dir = create_temp_dir("ingest_evicts_dependents");
let cache_dir = create_temp_dir("ingest_evicts_dependents_cache");
let a_src_v1 = "<?php\nclass Apple {}\nclass Banana {}\nclass Maker { public function make(): Banana { return new Banana(); } }\n";
let a_src_v2 = "<?php\nclass Apple {}\nclass Banana {}\nclass Maker { public function make(): Apple { return new Apple(); } }\n";
let b_src =
"<?php\nfunction expect_banana(Banana $v): void {}\nexpect_banana((new Maker)->make());\n";
let file_a = write_file(&src_dir, "a.php", a_src_v1);
let file_b = write_file(&src_dir, "b.php", b_src);
let file_a_path = file_a.to_string_lossy().to_string();
let file_b_path = file_b.to_string_lossy().to_string();
let session = AnalysisSession::new(PhpVersion::LATEST).with_cache_dir(cache_dir.path());
let initial = session.analyze_paths(&[file_a.clone(), file_b.clone()], &BatchOptions::new());
assert!(
initial
.issues
.iter()
.all(|i| i.kind.name() != "InvalidArgument"),
"initial analysis must be clean; got: {:?}",
initial
.issues
.iter()
.map(|i| i.kind.name())
.collect::<Vec<_>>()
);
session.ingest_file(Arc::from(file_a_path.as_str()), Arc::from(a_src_v2));
let result = session.re_analyze_file(&file_b_path, b_src, &BatchOptions::new());
let has_invalid_arg = result
.issues
.iter()
.any(|i| i.kind.name() == "InvalidArgument");
assert!(
has_invalid_arg,
"re_analyze_file must re-analyse b.php after ingest_file updated a.php's return type; \
expected InvalidArgument but got: {:?}",
result
.issues
.iter()
.map(|i| i.kind.name())
.collect::<Vec<_>>()
);
}
#[test]
fn re_analyze_file_flags_unused_suppress_without_cache() {
let source = "<?php\nclass Foo {\n /**\n * @suppress UndefinedClass\n */\n public string $bar = \"baz\";\n}\n";
let analyzer = new_session();
let result = analyzer.re_analyze_file("Foo.php", source, &BatchOptions::new());
assert!(
result
.issues
.iter()
.any(|i| i.kind.name() == "UnusedSuppress"),
"expected UnusedSuppress, got: {:?}",
result
.issues
.iter()
.map(|i| i.kind.name())
.collect::<Vec<_>>()
);
}