use std::collections::HashSet;
use std::path::PathBuf;
use phpantom_lsp::classmap_scanner::scan_workspace_fallback_full;
use phpantom_lsp::composer::{
discover_subproject_roots, parse_autoload_classmap, parse_autoload_files, parse_composer_json,
};
#[test]
fn discover_finds_single_subproject() {
let dir = tempfile::tempdir().unwrap();
let sub = dir.path().join("project-a");
std::fs::create_dir_all(&sub).unwrap();
std::fs::write(
sub.join("composer.json"),
r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
)
.unwrap();
let roots = discover_subproject_roots(dir.path());
assert_eq!(roots.len(), 1);
assert_eq!(roots[0].0, sub);
assert_eq!(roots[0].1, "vendor");
}
#[test]
fn discover_finds_multiple_subprojects_at_different_depths() {
let dir = tempfile::tempdir().unwrap();
let sub_a = dir.path().join("project-a");
std::fs::create_dir_all(&sub_a).unwrap();
std::fs::write(sub_a.join("composer.json"), "{}").unwrap();
let sub_b = dir.path().join("packages").join("project-b");
std::fs::create_dir_all(&sub_b).unwrap();
std::fs::write(sub_b.join("composer.json"), "{}").unwrap();
let sub_c = dir.path().join("deep").join("nested").join("project-c");
std::fs::create_dir_all(&sub_c).unwrap();
std::fs::write(sub_c.join("composer.json"), "{}").unwrap();
let roots = discover_subproject_roots(dir.path());
let paths: HashSet<PathBuf> = roots.iter().map(|(p, _)| p.clone()).collect();
assert_eq!(paths.len(), 3);
assert!(paths.contains(&sub_a));
assert!(paths.contains(&sub_b));
assert!(paths.contains(&sub_c));
}
#[test]
fn discover_skips_nested_composer_json_inside_subproject() {
let dir = tempfile::tempdir().unwrap();
let sub_a = dir.path().join("project-a");
std::fs::create_dir_all(&sub_a).unwrap();
std::fs::write(sub_a.join("composer.json"), "{}").unwrap();
let nested = sub_a.join("subdir");
std::fs::create_dir_all(&nested).unwrap();
std::fs::write(nested.join("composer.json"), "{}").unwrap();
let roots = discover_subproject_roots(dir.path());
assert_eq!(
roots.len(),
1,
"nested composer.json should be filtered out: {:?}",
roots
);
assert_eq!(roots[0].0, sub_a);
}
#[test]
fn discover_skips_workspace_root_composer_json() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("composer.json"), "{}").unwrap();
let sub = dir.path().join("subproject");
std::fs::create_dir_all(&sub).unwrap();
std::fs::write(sub.join("composer.json"), "{}").unwrap();
let roots = discover_subproject_roots(dir.path());
let paths: Vec<PathBuf> = roots.iter().map(|(p, _)| p.clone()).collect();
assert!(
!paths.contains(&dir.path().to_path_buf()),
"workspace root should not be in subproject list"
);
assert_eq!(roots.len(), 1);
assert_eq!(roots[0].0, sub);
}
#[test]
fn discover_reads_custom_vendor_dir() {
let dir = tempfile::tempdir().unwrap();
let sub = dir.path().join("project");
std::fs::create_dir_all(&sub).unwrap();
std::fs::write(
sub.join("composer.json"),
r#"{"config":{"vendor-dir":"deps"}}"#,
)
.unwrap();
let roots = discover_subproject_roots(dir.path());
assert_eq!(roots.len(), 1);
assert_eq!(roots[0].1, "deps");
}
#[test]
fn discover_returns_empty_when_no_subprojects() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("index.php"), "<?php\necho 'hello';").unwrap();
let roots = discover_subproject_roots(dir.path());
assert!(roots.is_empty());
}
#[test]
fn discover_skips_hidden_directories() {
let dir = tempfile::tempdir().unwrap();
let hidden = dir.path().join(".hidden-project");
std::fs::create_dir_all(&hidden).unwrap();
std::fs::write(hidden.join("composer.json"), "{}").unwrap();
let roots = discover_subproject_roots(dir.path());
assert!(
roots.is_empty(),
"hidden directories should be skipped: {:?}",
roots
);
}
#[test]
fn classmaps_merge_from_two_subprojects() {
let dir = tempfile::tempdir().unwrap();
let sub_a = dir.path().join("project-a");
let vendor_a = sub_a.join("vendor").join("composer");
std::fs::create_dir_all(&vendor_a).unwrap();
std::fs::write(
sub_a.join("composer.json"),
r#"{"autoload":{"psr-4":{"A\\":"src/"}}}"#,
)
.unwrap();
let src_a = sub_a.join("src");
std::fs::create_dir_all(&src_a).unwrap();
std::fs::write(src_a.join("Foo.php"), "<?php\nnamespace A;\nclass Foo {}").unwrap();
std::fs::write(
vendor_a.join("autoload_classmap.php"),
"<?php\nreturn array(\n 'A\\\\Foo' => $baseDir . '/src/Foo.php',\n);",
)
.unwrap();
let sub_b = dir.path().join("project-b");
let vendor_b = sub_b.join("vendor").join("composer");
std::fs::create_dir_all(&vendor_b).unwrap();
std::fs::write(
sub_b.join("composer.json"),
r#"{"autoload":{"psr-4":{"B\\":"src/"}}}"#,
)
.unwrap();
let src_b = sub_b.join("src");
std::fs::create_dir_all(&src_b).unwrap();
std::fs::write(src_b.join("Bar.php"), "<?php\nnamespace B;\nclass Bar {}").unwrap();
std::fs::write(
vendor_b.join("autoload_classmap.php"),
"<?php\nreturn array(\n 'B\\\\Bar' => $baseDir . '/src/Bar.php',\n);",
)
.unwrap();
let cm_a = parse_autoload_classmap(&sub_a, "vendor");
let cm_b = parse_autoload_classmap(&sub_b, "vendor");
let mut merged = cm_a;
for (fqcn, path) in cm_b {
merged.entry(fqcn).or_insert(path);
}
assert!(
merged.contains_key("A\\Foo"),
"should have class from subproject A: {:?}",
merged.keys().collect::<Vec<_>>()
);
assert!(
merged.contains_key("B\\Bar"),
"should have class from subproject B: {:?}",
merged.keys().collect::<Vec<_>>()
);
}
#[test]
fn psr4_mappings_resolve_across_subprojects() {
let dir = tempfile::tempdir().unwrap();
let sub_a = dir.path().join("project-a");
let src_a = sub_a.join("src");
std::fs::create_dir_all(&src_a).unwrap();
std::fs::write(
sub_a.join("composer.json"),
r#"{"autoload":{"psr-4":{"Alpha\\":"src/"}}}"#,
)
.unwrap();
std::fs::write(
src_a.join("Widget.php"),
"<?php\nnamespace Alpha;\nclass Widget {}",
)
.unwrap();
let sub_b = dir.path().join("project-b");
let src_b = sub_b.join("lib");
std::fs::create_dir_all(&src_b).unwrap();
std::fs::write(
sub_b.join("composer.json"),
r#"{"autoload":{"psr-4":{"Beta\\":"lib/"}}}"#,
)
.unwrap();
std::fs::write(
src_b.join("Gadget.php"),
"<?php\nnamespace Beta;\nclass Gadget {}",
)
.unwrap();
let (mappings_a, _) = parse_composer_json(&sub_a);
let (mappings_b, _) = parse_composer_json(&sub_b);
let mut all_mappings = Vec::new();
for m in &mappings_a {
let abs_base = sub_a.join(&m.base_path).to_string_lossy().to_string();
all_mappings.push(phpantom_lsp::composer::Psr4Mapping {
prefix: m.prefix.clone(),
base_path: phpantom_lsp::composer::normalise_path(&abs_base),
});
}
for m in &mappings_b {
let abs_base = sub_b.join(&m.base_path).to_string_lossy().to_string();
all_mappings.push(phpantom_lsp::composer::Psr4Mapping {
prefix: m.prefix.clone(),
base_path: phpantom_lsp::composer::normalise_path(&abs_base),
});
}
all_mappings.sort_by(|a, b| b.prefix.len().cmp(&a.prefix.len()));
let empty_root = std::path::Path::new("");
let result_a =
phpantom_lsp::composer::resolve_class_path(&all_mappings, empty_root, "Alpha\\Widget");
assert!(
result_a.is_some(),
"should resolve Alpha\\Widget from subproject A"
);
let result_b =
phpantom_lsp::composer::resolve_class_path(&all_mappings, empty_root, "Beta\\Gadget");
assert!(
result_b.is_some(),
"should resolve Beta\\Gadget from subproject B"
);
}
#[test]
fn autoload_files_indexed_from_subproject() {
let dir = tempfile::tempdir().unwrap();
let sub = dir.path().join("project");
let vendor = sub.join("vendor");
let composer_dir = vendor.join("composer");
std::fs::create_dir_all(&composer_dir).unwrap();
std::fs::write(sub.join("composer.json"), "{}").unwrap();
let helpers = vendor.join("some-pkg").join("src");
std::fs::create_dir_all(&helpers).unwrap();
std::fs::write(
helpers.join("helpers.php"),
"<?php\nfunction my_helper(): string { return ''; }",
)
.unwrap();
let helpers_path = helpers.join("helpers.php");
let helpers_rel = helpers_path
.strip_prefix(&sub)
.unwrap()
.to_string_lossy()
.replace('\\', "/");
std::fs::write(
composer_dir.join("autoload_files.php"),
format!("<?php\nreturn array(\n 'abc123' => $baseDir . '/{helpers_rel}',\n);",),
)
.unwrap();
let files = parse_autoload_files(&sub, "vendor");
assert!(
!files.is_empty(),
"should find autoload files in subproject"
);
assert!(
files.iter().any(|p| p.ends_with("helpers.php")),
"should include helpers.php: {:?}",
files
);
}
#[test]
fn loose_files_discovered_outside_subprojects() {
let dir = tempfile::tempdir().unwrap();
let sub = dir.path().join("project-a");
let sub_src = sub.join("src");
std::fs::create_dir_all(&sub_src).unwrap();
std::fs::write(
sub_src.join("Internal.php"),
"<?php\nnamespace A;\nclass Internal {}",
)
.unwrap();
std::fs::write(
dir.path().join("bootstrap.php"),
"<?php\nfunction bootstrap(): void {}\ndefine('APP_ROOT', __DIR__);\nclass Config {}",
)
.unwrap();
let scripts = dir.path().join("scripts");
std::fs::create_dir_all(&scripts).unwrap();
std::fs::write(
scripts.join("migrate.php"),
"<?php\nfunction run_migrations(): void {}",
)
.unwrap();
let mut skip_dirs = HashSet::new();
skip_dirs.insert(sub.clone());
let result = scan_workspace_fallback_full(dir.path(), &skip_dirs);
assert!(
result.classmap.contains_key("Config"),
"should find loose class: {:?}",
result.classmap.keys().collect::<Vec<_>>()
);
assert!(
result.function_index.contains_key("bootstrap"),
"should find loose function: {:?}",
result.function_index.keys().collect::<Vec<_>>()
);
assert!(
result.constant_index.contains_key("APP_ROOT"),
"should find loose constant: {:?}",
result.constant_index.keys().collect::<Vec<_>>()
);
assert!(
result.function_index.contains_key("run_migrations"),
"should find function in loose subdirectory: {:?}",
result.function_index.keys().collect::<Vec<_>>()
);
assert!(
!result.classmap.contains_key("A\\Internal"),
"should skip classes inside subproject directories"
);
}
#[test]
fn no_double_scanning_of_subproject_files() {
let dir = tempfile::tempdir().unwrap();
let sub = dir.path().join("my-project");
let sub_src = sub.join("src");
std::fs::create_dir_all(&sub_src).unwrap();
std::fs::write(
sub_src.join("Service.php"),
"<?php\nnamespace MyProject;\nclass Service {}",
)
.unwrap();
let mut skip_dirs = HashSet::new();
skip_dirs.insert(sub.clone());
let result = scan_workspace_fallback_full(dir.path(), &skip_dirs);
assert!(
!result.classmap.contains_key("MyProject\\Service"),
"subproject files should not be scanned by full-scan walker"
);
}
#[test]
fn single_project_still_works_with_root_composer_json() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("src");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(
dir.path().join("composer.json"),
r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
)
.unwrap();
std::fs::write(
src.join("Model.php"),
"<?php\nnamespace App;\nclass Model {}",
)
.unwrap();
let (mappings, vendor_dir) = parse_composer_json(dir.path());
assert!(!mappings.is_empty(), "should find PSR-4 mappings");
assert_eq!(vendor_dir, "vendor");
assert!(mappings.iter().any(|m| m.prefix == "App\\"));
}
#[test]
fn full_scan_with_empty_skip_set_finds_everything() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("app.php"),
"<?php\nfunction app_func(): void {}\nclass AppClass {}",
)
.unwrap();
let sub = dir.path().join("lib");
std::fs::create_dir_all(&sub).unwrap();
std::fs::write(sub.join("util.php"), "<?php\nfunction lib_func(): void {}").unwrap();
let skip = HashSet::new();
let result = scan_workspace_fallback_full(dir.path(), &skip);
assert!(result.classmap.contains_key("AppClass"));
assert!(result.function_index.contains_key("app_func"));
assert!(result.function_index.contains_key("lib_func"));
}
#[test]
fn subproject_without_vendor_produces_no_errors() {
let dir = tempfile::tempdir().unwrap();
let sub = dir.path().join("project");
let src = sub.join("src");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(
sub.join("composer.json"),
r#"{"autoload":{"psr-4":{"My\\":"src/"}}}"#,
)
.unwrap();
std::fs::write(
src.join("Thing.php"),
"<?php\nnamespace My;\nclass Thing {}",
)
.unwrap();
let roots = discover_subproject_roots(dir.path());
assert_eq!(roots.len(), 1);
assert_eq!(roots[0].0, sub);
let cm = parse_autoload_classmap(&sub, "vendor");
assert!(cm.is_empty(), "no classmap without vendor dir");
let files = parse_autoload_files(&sub, "vendor");
assert!(files.is_empty(), "no autoload files without vendor dir");
let (mappings, _) = parse_composer_json(&sub);
assert!(
!mappings.is_empty(),
"PSR-4 mappings should exist even without vendor"
);
}
#[test]
fn first_subproject_wins_for_duplicate_fqns() {
let dir = tempfile::tempdir().unwrap();
let sub_a = dir.path().join("alpha");
let vendor_a = sub_a.join("vendor").join("composer");
std::fs::create_dir_all(&vendor_a).unwrap();
std::fs::write(sub_a.join("composer.json"), "{}").unwrap();
let src_a = sub_a.join("src");
std::fs::create_dir_all(&src_a).unwrap();
std::fs::write(
src_a.join("Logger.php"),
"<?php\nnamespace Shared;\nclass Logger { /* alpha */ }",
)
.unwrap();
std::fs::write(
vendor_a.join("autoload_classmap.php"),
"<?php\nreturn array(\n 'Shared\\\\Logger' => $baseDir . '/src/Logger.php',\n);",
)
.unwrap();
let sub_b = dir.path().join("beta");
let vendor_b = sub_b.join("vendor").join("composer");
std::fs::create_dir_all(&vendor_b).unwrap();
std::fs::write(sub_b.join("composer.json"), "{}").unwrap();
let src_b = sub_b.join("src");
std::fs::create_dir_all(&src_b).unwrap();
std::fs::write(
src_b.join("Logger.php"),
"<?php\nnamespace Shared;\nclass Logger { /* beta */ }",
)
.unwrap();
std::fs::write(
vendor_b.join("autoload_classmap.php"),
"<?php\nreturn array(\n 'Shared\\\\Logger' => $baseDir . '/src/Logger.php',\n);",
)
.unwrap();
let cm_a = parse_autoload_classmap(&sub_a, "vendor");
let cm_b = parse_autoload_classmap(&sub_b, "vendor");
let mut merged = cm_a;
for (fqcn, path) in cm_b {
merged.entry(fqcn).or_insert(path);
}
assert!(merged.contains_key("Shared\\Logger"));
let resolved_path = &merged["Shared\\Logger"];
assert!(
resolved_path.starts_with(&sub_a),
"first subproject should win for duplicate FQN; got {:?}",
resolved_path
);
}
#[test]
fn full_scan_skips_hidden_directories() {
let dir = tempfile::tempdir().unwrap();
let hidden = dir.path().join(".cache");
std::fs::create_dir_all(&hidden).unwrap();
std::fs::write(
hidden.join("cached.php"),
"<?php\nfunction cached_func(): void {}",
)
.unwrap();
std::fs::write(
dir.path().join("app.php"),
"<?php\nfunction app_func(): void {}",
)
.unwrap();
let skip = HashSet::new();
let result = scan_workspace_fallback_full(dir.path(), &skip);
assert!(result.function_index.contains_key("app_func"));
assert!(
!result.function_index.contains_key("cached_func"),
"hidden directory functions should be excluded"
);
}