mod common;
use std::fs;
use std::sync::Arc;
use mir_analyzer::{AnalysisSession, IndexCancel, IndexParallelism, PhpVersion};
use self::common::create_temp_dir;
fn make_session(root: &std::path::Path) -> AnalysisSession {
let psr4 = mir_analyzer::composer::Psr4Map::from_composer(root).expect("psr4 map");
AnalysisSession::new(PhpVersion::LATEST).with_psr4(Arc::new(psr4))
}
fn write_composer(root: &std::path::Path) {
fs::write(
root.join("composer.json"),
r#"{"autoload":{"psr-4":{"App\\":"src/","Vendor\\":"vendor/VendorLib/src/"}}}"#,
)
.unwrap();
}
fn indexable_files(root: &std::path::Path) -> Vec<(Arc<str>, Arc<str>)> {
let psr4 = mir_analyzer::composer::Psr4Map::from_composer(root).expect("psr4 map");
let mut paths = psr4.project_files();
paths.extend(psr4.all_vendor_files());
paths.sort();
paths.dedup();
paths
.into_iter()
.filter_map(|p| {
let t = fs::read_to_string(&p).ok()?;
Some((
Arc::from(p.to_string_lossy().as_ref()),
Arc::from(t.as_str()),
))
})
.collect()
}
fn class_like_ptr(session: &AnalysisSession) -> usize {
session.read(|db| {
let idx = mir_analyzer::db::workspace_index(db);
Arc::as_ptr(&idx.class_like) as usize
})
}
#[test]
fn incremental_index_matches_full_rebuild() {
let root = create_temp_dir("incr_matches_full");
let vendor_src = root.path().join("vendor/VendorLib/src");
fs::create_dir_all(&vendor_src).unwrap();
write_composer(root.path());
for i in 0..20usize {
fs::write(
vendor_src.join(format!("C{i}.php")),
format!("<?php\nnamespace Vendor;\nclass C{i} {{ public function m(): void {{}} }}\n"),
)
.unwrap();
}
let mut files = indexable_files(root.path());
files.reverse();
let inc = make_session(root.path());
let cancel = IndexCancel::new();
for chunk in files.chunks(3) {
inc.index_batch(chunk, IndexParallelism::Sequential, &cancel);
}
for i in 0..20usize {
assert!(
inc.contains_class(&format!("Vendor\\C{i}")),
"incremental index missing Vendor\\C{i} before finalize"
);
}
let full = make_session(root.path());
for chunk in files.chunks(3) {
full.index_batch(chunk, IndexParallelism::Sequential, &cancel);
}
full.finalize_index();
for i in 0..20usize {
assert_eq!(
inc.contains_class(&format!("Vendor\\C{i}")),
full.contains_class(&format!("Vendor\\C{i}")),
"incremental vs full rebuild disagree on Vendor\\C{i}"
);
}
inc.finalize_index();
for i in 0..20usize {
assert!(inc.contains_class(&format!("Vendor\\C{i}")));
}
}
#[test]
fn body_only_edits_do_not_churn_workspace_index() {
let root = create_temp_dir("no_churn");
let app_src = root.path().join("src");
let vendor_src = root.path().join("vendor/VendorLib/src");
fs::create_dir_all(&app_src).unwrap();
fs::create_dir_all(&vendor_src).unwrap();
write_composer(root.path());
fs::write(
vendor_src.join("Dep.php"),
"<?php\nnamespace Vendor;\nclass Dep { public function go(): int { return 1; } }\n",
)
.unwrap();
let session = make_session(root.path());
let cancel = IndexCancel::new();
let vfiles = indexable_files(root.path());
session.index_batch(&vfiles, IndexParallelism::Sequential, &cancel);
session.finalize_index();
let svc_path: Arc<str> = Arc::from(app_src.join("Svc.php").to_string_lossy().as_ref());
let svc = |n: i32| {
format!(
"<?php\nnamespace App;\nclass Svc {{ public function run(): int {{ return {n}; }} }}\n"
)
};
session.ingest_file(svc_path.clone(), Arc::from(svc(0).as_str()));
let ptr_before = class_like_ptr(&session);
assert!(session.contains_class("App\\Svc"));
assert!(session.contains_class("Vendor\\Dep"));
for n in 1..=30 {
session.ingest_file(svc_path.clone(), Arc::from(svc(n).as_str()));
}
let ptr_after = class_like_ptr(&session);
assert_eq!(
ptr_before, ptr_after,
"body-only edits must not rewrite the workspace symbol index singleton (warm cache)"
);
}
#[test]
fn declaration_change_updates_index_incrementally() {
let root = create_temp_dir("decl_change");
let app_src = root.path().join("src");
fs::create_dir_all(&app_src).unwrap();
write_composer(root.path());
let session = make_session(root.path());
session.ensure_all_stubs();
session.finalize_index();
let path: Arc<str> = Arc::from(app_src.join("A.php").to_string_lossy().as_ref());
session.ingest_file(
path.clone(),
Arc::from("<?php\nnamespace App;\nclass Alpha {}\n"),
);
assert!(session.contains_class("App\\Alpha"));
session.ingest_file(
path.clone(),
Arc::from("<?php\nnamespace App;\nclass Beta {}\n"),
);
assert!(
session.contains_class("App\\Beta"),
"renamed class must resolve after incremental update"
);
assert!(
!session.contains_class("App\\Alpha"),
"old class name must stop resolving after rename (incremental subtract)"
);
}
#[test]
fn index_batch_honours_cancellation() {
let root = create_temp_dir("cancel_index");
let vendor_src = root.path().join("vendor/VendorLib/src");
fs::create_dir_all(&vendor_src).unwrap();
write_composer(root.path());
fs::write(
vendor_src.join("X.php"),
"<?php\nnamespace Vendor;\nclass X {}\n",
)
.unwrap();
let session = make_session(root.path());
let files: Vec<(Arc<str>, Arc<str>)> = vec![(
Arc::from("x"),
Arc::from("<?php\nnamespace Vendor;\nclass X {}\n"),
)];
let cancel = IndexCancel::new();
cancel.cancel();
let outcome = session.index_batch(&files, IndexParallelism::Sequential, &cancel);
assert!(outcome.cancelled);
assert_eq!(outcome.registered, 0);
}