use super::*;
use expect_test::expect;
use serde_json::json;
fn copy_dir_all(src: &std::path::Path, dst: &std::path::Path) -> std::io::Result<()> {
std::fs::create_dir_all(dst)?;
for entry in std::fs::read_dir(src)? {
let entry = entry?;
let from = entry.path();
let to = dst.join(entry.file_name());
if entry.file_type()?.is_dir() {
copy_dir_all(&from, &to)?;
} else {
std::fs::copy(&from, &to)?;
}
}
Ok(())
}
fn fixture_path(name: &str) -> std::path::PathBuf {
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures")
.join(name)
}
fn count_bin_files(dir: &std::path::Path) -> usize {
std::fs::read_dir(dir)
.map(|rd| {
rd.flatten()
.filter(|e| e.path().extension().is_some_and(|ext| ext == "bin"))
.count()
})
.unwrap_or(0)
}
async fn wait_for_new_cache_entry(dir: &std::path::Path, before: usize) {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
while count_bin_files(dir) <= before {
assert!(
std::time::Instant::now() < deadline,
"timed out waiting for a new cache entry in {}",
dir.display()
);
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
}
}
#[tokio::test]
async fn warm_start_serves_symbols_correctly() {
let workspace = tempfile::tempdir().expect("workspace tempdir");
let cache_dir = tempfile::tempdir().expect("cache tempdir");
copy_dir_all(&fixture_path("psr4-mini"), workspace.path()).expect("copy fixture");
let opts = json!({
"cachePath": cache_dir.path().to_str().unwrap(),
"diagnostics": {"enabled": false},
});
{
let mut s = TestServer::with_root_and_options(workspace.path(), opts.clone()).await;
s.wait_for_index_ready().await;
let syms = s.snapshot_workspace_symbols("User").await;
expect![[r#"
Class User @ src/Model/User.php:4
Property $users @ src/Service/Registry.php:9"#]]
.assert_eq(&syms);
}
{
let mut s = TestServer::with_root_and_options(workspace.path(), opts).await;
s.wait_for_index_ready().await;
let syms = s.snapshot_workspace_symbols("User").await;
expect![[r#"
Class User @ src/Model/User.php:4
Property $users @ src/Service/Registry.php:9"#]]
.assert_eq(&syms);
}
}
#[tokio::test]
async fn warm_start_detects_changed_file_content() {
let workspace = tempfile::tempdir().expect("workspace tempdir");
let cache_dir = tempfile::tempdir().expect("cache tempdir");
copy_dir_all(&fixture_path("psr4-mini"), workspace.path()).expect("copy fixture");
let opts = json!({
"cachePath": cache_dir.path().to_str().unwrap(),
"diagnostics": {"enabled": false},
});
{
let mut s = TestServer::with_root_and_options(workspace.path(), opts.clone()).await;
s.wait_for_index_ready().await;
let syms = s.snapshot_workspace_symbols("Widget").await;
expect![[r#"<no symbols>"#]].assert_eq(&syms);
}
let user_php = workspace.path().join("src/Model/User.php");
std::fs::write(
&user_php,
"<?php\nnamespace App\\Model;\n\nclass Widget {}\n",
)
.expect("overwrite User.php");
{
let mut s = TestServer::with_root_and_options(workspace.path(), opts).await;
s.wait_for_index_ready().await;
let syms = s.snapshot_workspace_symbols("Widget").await;
expect![[r#"Class Widget @ src/Model/User.php:3"#]].assert_eq(&syms);
}
}
#[tokio::test]
async fn did_save_writes_a_cache_entry_findable_by_key() {
let workspace = tempfile::tempdir().expect("workspace tempdir");
let cache_dir = tempfile::tempdir().expect("cache tempdir");
copy_dir_all(&fixture_path("psr4-mini"), workspace.path()).expect("copy fixture");
let opts = json!({
"cachePath": cache_dir.path().to_str().unwrap(),
"diagnostics": {"enabled": false},
});
let mut s = TestServer::with_root_and_options(workspace.path(), opts).await;
s.wait_for_index_ready().await;
let user_content = "<?php\nnamespace App\\Model;\n\nclass User { public int $id; }\n";
let user_php = workspace.path().join("src/Model/User.php");
std::fs::write(&user_php, user_content).expect("write User.php");
let uri = s.uri("src/Model/User.php");
s.client()
.notify(
"textDocument/didSave",
serde_json::json!({ "textDocument": { "uri": uri } }),
)
.await;
let cache = php_lsp::cache::WorkspaceCache::with_dir(cache_dir.path().to_path_buf());
let key = php_lsp::cache::WorkspaceCache::key_for(&uri, user_content);
let mut cached: Option<php_lsp::file_index::FileIndex> = None;
for _ in 0..100 {
cached = cache.read(&key);
if cached.is_some() {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
assert!(
cached.is_some(),
"did_save should have written a FileIndex cache entry keyed by (uri, saved content)"
);
let classes: Vec<&str> = cached
.as_ref()
.unwrap()
.classes
.iter()
.map(|c| c.name.as_ref())
.collect();
assert_eq!(
classes,
vec!["User"],
"cached FileIndex should reflect the saved content, not the original fixture"
);
}
#[tokio::test]
async fn did_save_cache_is_found_by_subsequent_scan() {
let workspace = tempfile::tempdir().expect("workspace tempdir");
let cache_dir = tempfile::tempdir().expect("cache tempdir");
copy_dir_all(&fixture_path("psr4-mini"), workspace.path()).expect("copy fixture");
let opts = json!({
"cachePath": cache_dir.path().to_str().unwrap(),
"diagnostics": {"enabled": false},
});
{
let mut s = TestServer::with_root_and_options(workspace.path(), opts.clone()).await;
s.wait_for_index_ready().await;
let before = count_bin_files(cache_dir.path());
let user_content = "<?php\nnamespace App\\Model;\n\nclass User { public int $id; }\n";
let user_php = workspace.path().join("src/Model/User.php");
std::fs::write(&user_php, user_content).expect("write User.php");
let uri = s.uri("src/Model/User.php");
s.client()
.notify(
"textDocument/didSave",
serde_json::json!({ "textDocument": { "uri": uri } }),
)
.await;
wait_for_new_cache_entry(cache_dir.path(), before).await;
}
{
let mut s = TestServer::with_root_and_options(workspace.path(), opts).await;
s.wait_for_index_ready().await;
let syms = s.snapshot_workspace_symbols("User").await;
expect![[r#"
Class User @ src/Model/User.php:3
Property $users @ src/Service/Registry.php:9"#]]
.assert_eq(&syms);
}
}
#[tokio::test]
async fn warm_start_replays_reference_postings_from_first_session() {
let workspace = tempfile::tempdir().expect("workspace tempdir");
let cache_dir = tempfile::tempdir().expect("cache tempdir");
let widget = "<?php\nclass Widget {\n public function spin(): void {}\n}\n";
let caller = "<?php\n$w = new Widget();\n$w->spin();\n";
std::fs::write(workspace.path().join("widget.php"), widget).expect("write widget.php");
std::fs::write(workspace.path().join("caller.php"), caller).expect("write caller.php");
let opts = |warm_analysis: bool| {
json!({
"cachePath": cache_dir.path().to_str().unwrap(),
"diagnostics": {"enabled": false},
"phpVersion": "8.3",
"warmAnalysis": warm_analysis,
})
};
let caller_uri = {
let mut s = TestServer::with_root_and_options(workspace.path(), opts(true)).await;
s.wait_for_index_ready().await;
assert!(
s.wait_for_warm_sweeps(1).await,
"warm sweep did not complete"
);
s.uri("caller.php")
};
let session_dir = cache_dir.path().join("session");
let php_v = "8.3"
.parse::<mir_analyzer::PhpVersion>()
.expect("valid version")
.cache_byte();
let mir_cache = mir_analyzer::cache::AnalysisCache::open(&session_dir, php_v, 0);
let (_, ref_locs) = mir_cache
.get(&caller_uri, &mir_analyzer::cache::hash_content(caller))
.expect("warm sweep must persist an AnalysisCache entry for caller.php");
assert!(
!ref_locs.is_empty(),
"persisted entry must carry caller.php's reference postings"
);
{
let mut s = TestServer::with_root_and_options(workspace.path(), opts(false)).await;
s.wait_for_index_ready().await;
s.open("widget.php", widget).await;
let resp = s.references("widget.php", 2, 20, false).await;
let out = render_locations(&resp, &s.uri(""));
expect![[r#"caller.php:2:4-2:8"#]].assert_eq(&out);
}
}
#[tokio::test]
async fn periodic_flush_persists_query_commits_without_shutdown() {
let workspace = tempfile::tempdir().expect("workspace tempdir");
let cache_dir = tempfile::tempdir().expect("cache tempdir");
let widget = "<?php\nclass Widget {\n public function spin(): void {}\n}\n";
let caller = "<?php\n$w = new Widget();\n$w->spin();\n";
std::fs::write(workspace.path().join("widget.php"), widget).expect("write widget.php");
std::fs::write(workspace.path().join("caller.php"), caller).expect("write caller.php");
let opts = json!({
"cachePath": cache_dir.path().to_str().unwrap(),
"diagnostics": {"enabled": false},
"phpVersion": "8.3",
"warmAnalysis": false,
"analysisCacheFlushIntervalMs": 50,
});
let session_dir = cache_dir.path().join("session");
let php_v = "8.3"
.parse::<mir_analyzer::PhpVersion>()
.expect("valid version")
.cache_byte();
let content_hash = mir_analyzer::cache::hash_content(caller);
let caller_uri = {
let mut s = TestServer::with_root_and_options(workspace.path(), opts).await;
s.wait_for_index_ready().await;
s.open("widget.php", widget).await;
s.references("widget.php", 2, 20, false).await;
let uri = s.uri("caller.php");
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
loop {
if mir_analyzer::cache::AnalysisCache::open(&session_dir, php_v, 0)
.get(&uri, &content_hash)
.is_some()
{
break;
}
assert!(
std::time::Instant::now() < deadline,
"timed out waiting for the periodic flush to persist caller.php"
);
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
}
uri
};
let mir_cache = mir_analyzer::cache::AnalysisCache::open(&session_dir, php_v, 0);
let (_, ref_locs) = mir_cache
.get(&caller_uri, &content_hash)
.expect("periodic flush must persist the query-staged entry for caller.php");
assert!(
!ref_locs.is_empty(),
"persisted entry must carry caller.php's reference postings"
);
}
#[tokio::test]
async fn early_open_still_attaches_session_cache() {
let workspace = tempfile::tempdir().expect("workspace tempdir");
let cache_dir = tempfile::tempdir().expect("cache tempdir");
copy_dir_all(&fixture_path("psr4-mini"), workspace.path()).expect("copy fixture");
let opts = json!({
"cachePath": cache_dir.path().to_str().unwrap(),
"diagnostics": {"enabled": true},
});
{
let mut s = TestServer::with_root_and_options(workspace.path(), opts).await;
let user_php =
std::fs::read_to_string(workspace.path().join("src/Model/User.php")).unwrap();
s.open("src/Model/User.php", &user_php).await;
s.wait_for_index_ready().await;
}
assert!(
cache_dir.path().join("session/stubs").is_dir(),
"session cache dir must exist on disk even when a didOpen preceded initialized"
);
}
#[tokio::test]
async fn warm_start_seeds_symbol_index_and_first_query_avoids_tracked_walk() {
let workspace = tempfile::tempdir().expect("workspace tempdir");
let cache_dir = tempfile::tempdir().expect("cache tempdir");
let widget = "<?php\nclass Widget {\n public function spin(): void {}\n}\n";
let caller = "<?php\n$w = new Widget();\n$w->spin();\n";
std::fs::write(workspace.path().join("widget.php"), widget).expect("write widget.php");
std::fs::write(workspace.path().join("caller.php"), caller).expect("write caller.php");
let opts = |warm_analysis: bool| {
json!({
"cachePath": cache_dir.path().to_str().unwrap(),
"diagnostics": {"enabled": false},
"phpVersion": "8.3",
"warmAnalysis": warm_analysis,
})
};
{
let mut s = TestServer::with_root_and_options(workspace.path(), opts(true)).await;
s.wait_for_index_ready().await;
assert!(
s.wait_for_warm_sweeps(1).await,
"warm sweep did not complete"
);
s.shutdown().await;
}
{
let mut s = TestServer::with_root_and_options(workspace.path(), opts(false)).await;
s.wait_for_index_ready().await;
let (ready, _) = s.debug_stats_symbol_index().await;
assert!(
ready,
"warm start over a populated cache must seed the symbol index singleton"
);
s.open("widget.php", widget).await;
let resp = s.references("widget.php", 2, 20, false).await;
let out = render_locations(&resp, &s.uri(""));
expect![[r#"caller.php:2:4-2:8"#]].assert_eq(&out);
let (_, walks) = s.debug_stats_symbol_index().await;
assert_eq!(
walks, 0,
"a cache-warm session must never run the tracked O(all-files) index walk"
);
s.shutdown().await;
}
}
#[tokio::test]
async fn watcher_rename_stays_visible_in_seeded_symbol_index() {
let workspace = tempfile::tempdir().expect("workspace tempdir");
let cache_dir = tempfile::tempdir().expect("cache tempdir");
let shape = "<?php\nclass OldShape {\n public static function draw(): void {}\n}\n";
let user = "<?php\nOldShape::draw();\n";
std::fs::write(workspace.path().join("shape.php"), shape).expect("write shape.php");
std::fs::write(workspace.path().join("user.php"), user).expect("write user.php");
let opts = |warm_analysis: bool| {
json!({
"cachePath": cache_dir.path().to_str().unwrap(),
"diagnostics": {"enabled": false},
"phpVersion": "8.3",
"warmAnalysis": warm_analysis,
})
};
{
let mut s = TestServer::with_root_and_options(workspace.path(), opts(true)).await;
s.wait_for_index_ready().await;
assert!(
s.wait_for_warm_sweeps(1).await,
"warm sweep did not complete"
);
s.shutdown().await;
}
{
let mut s = TestServer::with_root_and_options(workspace.path(), opts(false)).await;
s.wait_for_index_ready().await;
let (ready, _) = s.debug_stats_symbol_index().await;
assert!(ready, "second launch must seed the singleton");
let new_shape = "<?php\nclass NewShape {\n public static function draw(): void {}\n}\n";
let new_user = "<?php\nNewShape::draw();\n";
std::fs::write(workspace.path().join("shape.php"), new_shape).expect("rewrite shape.php");
std::fs::write(workspace.path().join("user.php"), new_user).expect("rewrite user.php");
s.did_change_watched_files(vec![
(s.uri("shape.php"), 2), (s.uri("user.php"), 2),
])
.await;
s.open("shape.php", new_shape).await;
let resp = s.references("shape.php", 2, 27, false).await;
let out = render_locations(&resp, &s.uri(""));
expect!["user.php:1:10-1:14"].assert_eq(&out);
s.shutdown().await;
}
}