use crate::common::create_psr4_workspace;
use phpantom_lsp::classmap_scanner;
use tower_lsp::LanguageServer;
use tower_lsp::lsp_types::*;
#[test]
fn find_classes_extracts_namespaced_class() {
let content = b"<?php\nnamespace App\\Models;\nclass User {}";
let classes = classmap_scanner::find_classes(content);
assert_eq!(classes, vec!["App\\Models\\User"]);
}
#[test]
fn find_classes_extracts_interface_trait_enum() {
let content = br"<?php
namespace App\Contracts;
interface Cacheable {}
trait Loggable {}
enum Status: string { case Active = 'active'; }
";
let classes = classmap_scanner::find_classes(content);
assert_eq!(
classes,
vec![
"App\\Contracts\\Cacheable",
"App\\Contracts\\Loggable",
"App\\Contracts\\Status"
]
);
}
#[test]
fn find_classes_skips_anonymous_classes() {
let content = br"<?php
namespace App;
class Real {}
$anon = new class extends Real {};
$anon2 = new class implements \Countable { public function count(): int { return 0; } };
";
let classes = classmap_scanner::find_classes(content);
assert_eq!(classes, vec!["App\\Real"]);
}
#[test]
fn scan_psr4_directories_respects_namespace_filtering() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("src");
let models = src.join("Models");
std::fs::create_dir_all(&models).unwrap();
std::fs::write(
models.join("User.php"),
"<?php\nnamespace App\\Models;\nclass User {}",
)
.unwrap();
std::fs::write(
models.join("WrongNs.php"),
"<?php\nnamespace Wrong\\Namespace;\nclass WrongNs {}",
)
.unwrap();
let classmap = classmap_scanner::scan_psr4_directories(&[("App\\".to_string(), src)], &[], &[]);
assert!(classmap.contains_key("App\\Models\\User"));
assert!(
!classmap.contains_key("Wrong\\Namespace\\WrongNs"),
"Non-PSR-4-compliant class should be excluded"
);
}
#[test]
fn scan_psr4_directories_handles_classmap_entries() {
let dir = tempfile::tempdir().unwrap();
let lib = dir.path().join("lib");
std::fs::create_dir_all(&lib).unwrap();
std::fs::write(lib.join("Legacy.php"), "<?php\nclass LegacyHelper {}").unwrap();
let classmap = classmap_scanner::scan_psr4_directories(&[], &[lib], &[]);
assert!(classmap.contains_key("LegacyHelper"));
}
#[test]
fn scan_vendor_packages_composer_v1_format() {
let dir = tempfile::tempdir().unwrap();
let vendor = dir.path().join("vendor");
let composer_dir = vendor.join("composer");
std::fs::create_dir_all(&composer_dir).unwrap();
let pkg_src = vendor.join("acme").join("tools").join("src");
std::fs::create_dir_all(&pkg_src).unwrap();
std::fs::write(
pkg_src.join("Hammer.php"),
"<?php\nnamespace Acme\\Tools;\nclass Hammer {}",
)
.unwrap();
let installed = serde_json::json!([
{
"name": "acme/tools",
"autoload": {
"psr-4": {
"Acme\\Tools\\": "src/"
}
}
}
]);
std::fs::write(
composer_dir.join("installed.json"),
serde_json::to_string(&installed).unwrap(),
)
.unwrap();
let classmap = classmap_scanner::scan_vendor_packages(dir.path(), "vendor");
assert!(
classmap.contains_key("Acme\\Tools\\Hammer"),
"keys: {:?}",
classmap.keys().collect::<Vec<_>>()
);
}
#[test]
fn scan_vendor_packages_composer_v2_format() {
let dir = tempfile::tempdir().unwrap();
let vendor = dir.path().join("vendor");
let composer_dir = vendor.join("composer");
std::fs::create_dir_all(&composer_dir).unwrap();
let pkg_src = vendor.join("foo").join("bar").join("src");
std::fs::create_dir_all(&pkg_src).unwrap();
std::fs::write(
pkg_src.join("Baz.php"),
"<?php\nnamespace Foo\\Bar;\nclass Baz {}",
)
.unwrap();
let installed = serde_json::json!({
"packages": [
{
"name": "foo/bar",
"install-path": "../foo/bar",
"autoload": {
"psr-4": {
"Foo\\Bar\\": "src/"
}
}
}
]
});
std::fs::write(
composer_dir.join("installed.json"),
serde_json::to_string(&installed).unwrap(),
)
.unwrap();
let classmap = classmap_scanner::scan_vendor_packages(dir.path(), "vendor");
assert!(classmap.contains_key("Foo\\Bar\\Baz"));
}
#[test]
fn scan_vendor_packages_missing_installed_json() {
let dir = tempfile::tempdir().unwrap();
let classmap = classmap_scanner::scan_vendor_packages(dir.path(), "vendor");
assert!(classmap.is_empty());
}
#[test]
fn scan_vendor_packages_multiple_psr4_paths() {
let dir = tempfile::tempdir().unwrap();
let vendor = dir.path().join("vendor");
let composer_dir = vendor.join("composer");
std::fs::create_dir_all(&composer_dir).unwrap();
let src1 = vendor.join("multi").join("pkg").join("src");
let src2 = vendor.join("multi").join("pkg").join("lib");
std::fs::create_dir_all(&src1).unwrap();
std::fs::create_dir_all(&src2).unwrap();
std::fs::write(
src1.join("Alpha.php"),
"<?php\nnamespace Multi\\Pkg;\nclass Alpha {}",
)
.unwrap();
std::fs::write(
src2.join("Beta.php"),
"<?php\nnamespace Multi\\Pkg;\nclass Beta {}",
)
.unwrap();
let installed = serde_json::json!({
"packages": [
{
"name": "multi/pkg",
"install-path": "../multi/pkg",
"autoload": {
"psr-4": {
"Multi\\Pkg\\": ["src/", "lib/"]
}
}
}
]
});
std::fs::write(
composer_dir.join("installed.json"),
serde_json::to_string(&installed).unwrap(),
)
.unwrap();
let classmap = classmap_scanner::scan_vendor_packages(dir.path(), "vendor");
assert!(classmap.contains_key("Multi\\Pkg\\Alpha"));
assert!(classmap.contains_key("Multi\\Pkg\\Beta"));
}
#[test]
fn scan_workspace_fallback_skips_hidden_and_vendor() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("Visible.php"), "<?php\nclass Visible {}").unwrap();
let hidden = dir.path().join(".hidden");
std::fs::create_dir_all(&hidden).unwrap();
std::fs::write(hidden.join("Secret.php"), "<?php\nclass Secret {}").unwrap();
let vendor = dir.path().join("vendor");
std::fs::create_dir_all(&vendor).unwrap();
std::fs::write(vendor.join("Vendored.php"), "<?php\nclass Vendored {}").unwrap();
let nm = dir.path().join("node_modules");
std::fs::create_dir_all(&nm).unwrap();
std::fs::write(nm.join("Fake.php"), "<?php\nclass Fake {}").unwrap();
std::fs::write(dir.path().join(".ignore"), "node_modules/\n").unwrap();
let vendor_dir_paths = vec![dir.path().join("vendor")];
let classmap = classmap_scanner::scan_workspace_fallback(dir.path(), &vendor_dir_paths);
assert!(classmap.contains_key("Visible"));
assert!(!classmap.contains_key("Secret"));
assert!(!classmap.contains_key("Vendored"));
assert!(!classmap.contains_key("Fake"));
}
#[test]
fn scan_workspace_fallback_recurses_into_subdirectories() {
let dir = tempfile::tempdir().unwrap();
let deep = dir.path().join("a").join("b").join("c");
std::fs::create_dir_all(&deep).unwrap();
std::fs::write(
deep.join("Deep.php"),
"<?php\nnamespace A\\B\\C;\nclass Deep {}",
)
.unwrap();
let vendor_dir_paths = vec![dir.path().join("vendor")];
let classmap = classmap_scanner::scan_workspace_fallback(dir.path(), &vendor_dir_paths);
assert!(classmap.contains_key("A\\B\\C\\Deep"));
}
#[tokio::test]
async fn self_scan_classmap_enables_cross_file_completion() {
let (backend, dir) = create_psr4_workspace(
r#"{"autoload": {"psr-4": {"App\\": "src/"}}}"#,
&[
(
"src/Models/Product.php",
r#"<?php
namespace App\Models;
class Product {
public function getName(): string { return ''; }
public function getPrice(): float { return 0.0; }
}
"#,
),
(
"src/Service.php",
r#"<?php
namespace App;
use App\Models\Product;
class Service {
public function handle(): void {
$product = new Product();
$product->
}
}
"#,
),
],
);
let service_uri = Url::from_file_path(dir.path().join("src/Service.php")).unwrap();
let service_content = std::fs::read_to_string(dir.path().join("src/Service.php")).unwrap();
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: service_uri.clone(),
language_id: "php".to_string(),
version: 1,
text: service_content,
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier {
uri: service_uri.clone(),
},
position: Position {
line: 8,
character: 19,
},
},
work_done_progress_params: WorkDoneProgressParams {
work_done_token: None,
},
partial_result_params: PartialResultParams {
partial_result_token: None,
},
context: None,
})
.await
.unwrap();
let items = match result {
Some(CompletionResponse::List(list)) => list.items,
Some(CompletionResponse::Array(items)) => items,
None => vec![],
};
let labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
assert!(
labels.iter().any(|l| l.starts_with("getName")),
"Expected 'getName' in completions, got: {:?}",
labels
);
assert!(
labels.iter().any(|l| l.starts_with("getPrice")),
"Expected 'getPrice' in completions, got: {:?}",
labels
);
}
#[tokio::test]
async fn self_scan_classmap_populates_backend_classmap() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("src").join("Models");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(
src.join("User.php"),
"<?php\nnamespace App\\Models;\nclass User {\n public string $email;\n}\n",
)
.unwrap();
let classmap = classmap_scanner::scan_psr4_directories(
&[("App\\".to_string(), dir.path().join("src"))],
&[],
&[],
);
assert!(classmap.contains_key("App\\Models\\User"));
let user_path = classmap.get("App\\Models\\User").unwrap();
assert!(user_path.ends_with("User.php"));
let mappings = vec![phpantom_lsp::composer::Psr4Mapping {
prefix: "App\\".to_string(),
base_path: "src/".to_string(),
}];
let backend =
phpantom_lsp::Backend::new_test_with_workspace(dir.path().to_path_buf(), mappings);
{
let mut cm = backend.classmap().write();
*cm = classmap;
}
let test_file = r#"<?php
namespace App;
use App\Models\User;
class Test {
public function run(): void {
$u = new User();
$u->
}
}
"#;
let uri = Url::parse("file:///test.php").unwrap();
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: test_file.to_string(),
},
})
.await;
let result = backend
.completion(CompletionParams {
text_document_position: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri: uri.clone() },
position: Position {
line: 6,
character: 13,
},
},
work_done_progress_params: WorkDoneProgressParams {
work_done_token: None,
},
partial_result_params: PartialResultParams {
partial_result_token: None,
},
context: None,
})
.await
.unwrap();
let items = match result {
Some(CompletionResponse::List(list)) => list.items,
Some(CompletionResponse::Array(items)) => items,
None => vec![],
};
let labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
assert!(
labels.contains(&"email"),
"Expected 'email' property in completions, got: {:?}",
labels
);
}
#[test]
fn scanner_handles_php_with_html_template() {
let content = br#"<html>
<body>
<?php
class LegacyPage {
public function render(): string { return ''; }
}
?>
</body>
</html>"#;
let classes = classmap_scanner::find_classes(content);
assert_eq!(classes, vec!["LegacyPage"]);
}
#[test]
fn scanner_handles_multiple_php_blocks() {
let content = br"<?php
namespace App;
class First {}
?>
Some HTML
<?php
class Second {}
";
let classes = classmap_scanner::find_classes(content);
assert_eq!(classes, vec!["App\\First", "App\\Second"]);
}
#[test]
fn scanner_handles_class_with_complex_body() {
let content = br#"<?php
namespace App\Services;
class UserService {
private const TABLE = 'users';
public function __construct(
private readonly \PDO $db,
private string $prefix = 'app_',
) {}
public function find(int $id): ?array {
$sql = "SELECT * FROM {$this->prefix}" . self::TABLE . " WHERE id = :id";
// class keyword in a comment
$stmt = $this->db->prepare($sql);
$stmt->execute(['id' => $id]);
return $stmt->fetch(\PDO::FETCH_ASSOC) ?: null;
}
}
"#;
let classes = classmap_scanner::find_classes(content);
assert_eq!(classes, vec!["App\\Services\\UserService"]);
}
#[test]
fn scanner_handles_enum_with_methods() {
let content = br"<?php
namespace App\Enums;
enum Color: string {
case Red = 'red';
case Green = 'green';
case Blue = 'blue';
public function label(): string {
return match($this) {
self::Red => 'Red Color',
self::Green => 'Green Color',
self::Blue => 'Blue Color',
};
}
}
";
let classes = classmap_scanner::find_classes(content);
assert_eq!(classes, vec!["App\\Enums\\Color"]);
}
#[test]
fn scanner_handles_abstract_and_final_classes() {
let content = br"<?php
namespace App;
abstract class Base {}
final class Concrete extends Base {}
";
let classes = classmap_scanner::find_classes(content);
assert_eq!(classes, vec!["App\\Base", "App\\Concrete"]);
}
#[test]
fn scanner_handles_readonly_class() {
let content = br"<?php
namespace App\DTO;
readonly class Point {
public function __construct(
public float $x,
public float $y,
) {}
}
";
let classes = classmap_scanner::find_classes(content);
assert_eq!(classes, vec!["App\\DTO\\Point"]);
}
#[test]
fn scanner_handles_braced_namespace() {
let content = br"<?php
namespace App\Models {
class User {}
}
namespace App\Services {
class UserService {}
}
";
let classes = classmap_scanner::find_classes(content);
assert_eq!(
classes,
vec!["App\\Models\\User", "App\\Services\\UserService"]
);
}
#[test]
fn first_class_wins_in_classmap() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("src");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(src.join("A.php"), "<?php\nclass Dup {}").unwrap();
std::fs::write(src.join("B.php"), "<?php\nclass Dup {}").unwrap();
let classmap = classmap_scanner::scan_directories(&[src], &[]);
assert_eq!(classmap.len(), 1);
assert!(classmap.contains_key("Dup"));
}
#[test]
fn scan_directories_ignores_non_php_files() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("src");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(src.join("Real.php"), "<?php\nclass Real {}").unwrap();
std::fs::write(src.join("README.md"), "# class Fake").unwrap();
std::fs::write(src.join("style.css"), ".class { }").unwrap();
std::fs::write(src.join("data.json"), r#"{"class": "Fake"}"#).unwrap();
let classmap = classmap_scanner::scan_directories(&[src], &[]);
assert_eq!(classmap.len(), 1);
assert!(classmap.contains_key("Real"));
}
#[test]
fn config_strategy_defaults_to_composer() {
use phpantom_lsp::config::{Config, IndexingStrategy};
let config: Config = toml::from_str("").unwrap();
assert_eq!(config.indexing.strategy(), IndexingStrategy::Composer);
}
#[test]
fn config_strategy_self_scan() {
use phpantom_lsp::config::{Config, IndexingStrategy};
let config: Config = toml::from_str("[indexing]\nstrategy = \"self\"\n").unwrap();
assert_eq!(config.indexing.strategy, Some(IndexingStrategy::SelfScan));
}
#[test]
fn config_strategy_none() {
use phpantom_lsp::config::{Config, IndexingStrategy};
let config: Config = toml::from_str("[indexing]\nstrategy = \"none\"\n").unwrap();
assert_eq!(config.indexing.strategy, Some(IndexingStrategy::None));
}
#[test]
fn config_strategy_full() {
use phpantom_lsp::config::{Config, IndexingStrategy};
let config: Config = toml::from_str("[indexing]\nstrategy = \"full\"\n").unwrap();
assert_eq!(config.indexing.strategy, Some(IndexingStrategy::Full));
}
#[test]
fn config_invalid_strategy_errors() {
use phpantom_lsp::config::Config;
let result = toml::from_str::<Config>("[indexing]\nstrategy = \"invalid\"\n");
assert!(result.is_err());
}
#[test]
fn scan_vendor_packages_with_classmap_file_entry() {
let dir = tempfile::tempdir().unwrap();
let vendor = dir.path().join("vendor");
let composer_dir = vendor.join("composer");
std::fs::create_dir_all(&composer_dir).unwrap();
let pkg = vendor.join("legacy").join("lib");
std::fs::create_dir_all(&pkg).unwrap();
std::fs::write(
pkg.join("functions.php"),
"<?php\nclass LegacyGlobal {}\nclass AnotherGlobal {}",
)
.unwrap();
let installed = serde_json::json!({
"packages": [
{
"name": "legacy/lib",
"install-path": "../legacy/lib",
"autoload": {
"classmap": ["functions.php"]
}
}
]
});
std::fs::write(
composer_dir.join("installed.json"),
serde_json::to_string(&installed).unwrap(),
)
.unwrap();
let classmap = classmap_scanner::scan_vendor_packages(dir.path(), "vendor");
assert!(
classmap.contains_key("LegacyGlobal"),
"keys: {:?}",
classmap.keys().collect::<Vec<_>>()
);
assert!(classmap.contains_key("AnotherGlobal"));
}
#[test]
fn scan_vendor_packages_skips_missing_package_dirs() {
let dir = tempfile::tempdir().unwrap();
let vendor = dir.path().join("vendor");
let composer_dir = vendor.join("composer");
std::fs::create_dir_all(&composer_dir).unwrap();
let installed = serde_json::json!({
"packages": [
{
"name": "ghost/package",
"install-path": "../ghost/package",
"autoload": {
"psr-4": {
"Ghost\\": "src/"
}
}
}
]
});
std::fs::write(
composer_dir.join("installed.json"),
serde_json::to_string(&installed).unwrap(),
)
.unwrap();
let classmap = classmap_scanner::scan_vendor_packages(dir.path(), "vendor");
assert!(classmap.is_empty());
}
#[test]
fn scan_vendor_packages_skips_packages_without_autoload() {
let dir = tempfile::tempdir().unwrap();
let vendor = dir.path().join("vendor");
let composer_dir = vendor.join("composer");
std::fs::create_dir_all(&composer_dir).unwrap();
let installed = serde_json::json!({
"packages": [
{
"name": "some/meta-package"
}
]
});
std::fs::write(
composer_dir.join("installed.json"),
serde_json::to_string(&installed).unwrap(),
)
.unwrap();
let classmap = classmap_scanner::scan_vendor_packages(dir.path(), "vendor");
assert!(classmap.is_empty());
}
#[test]
fn scan_workspace_fallback_respects_custom_vendor_dir() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("App.php"), "<?php\nclass App {}").unwrap();
let custom_vendor = dir.path().join("libs");
std::fs::create_dir_all(&custom_vendor).unwrap();
std::fs::write(
custom_vendor.join("Vendored.php"),
"<?php\nclass Vendored {}",
)
.unwrap();
let vendor_dir_paths = vec![dir.path().join("libs")];
let classmap = classmap_scanner::scan_workspace_fallback(dir.path(), &vendor_dir_paths);
assert!(classmap.contains_key("App"));
assert!(
!classmap.contains_key("Vendored"),
"Custom vendor dir should be excluded"
);
}
#[test]
fn scan_realistic_laravel_like_structure() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let dirs = &["app/Models", "app/Http/Controllers", "app/Services"];
for d in dirs {
std::fs::create_dir_all(root.join(d)).unwrap();
}
std::fs::write(
root.join("app/Models/User.php"),
"<?php\nnamespace App\\Models;\nclass User {}",
)
.unwrap();
std::fs::write(
root.join("app/Models/Post.php"),
"<?php\nnamespace App\\Models;\nclass Post {}",
)
.unwrap();
std::fs::write(
root.join("app/Http/Controllers/UserController.php"),
"<?php\nnamespace App\\Http\\Controllers;\nclass UserController {}",
)
.unwrap();
std::fs::write(
root.join("app/Services/AuthService.php"),
"<?php\nnamespace App\\Services;\nclass AuthService {}",
)
.unwrap();
std::fs::create_dir_all(root.join("database/seeders")).unwrap();
std::fs::write(
root.join("database/seeders/DatabaseSeeder.php"),
"<?php\nnamespace Database\\Seeders;\nclass DatabaseSeeder {}",
)
.unwrap();
let classmap = classmap_scanner::scan_psr4_directories(
&[("App\\".to_string(), root.join("app"))],
&[root.join("database")],
&[],
);
assert_eq!(classmap.len(), 5);
assert!(classmap.contains_key("App\\Models\\User"));
assert!(classmap.contains_key("App\\Models\\Post"));
assert!(classmap.contains_key("App\\Http\\Controllers\\UserController"));
assert!(classmap.contains_key("App\\Services\\AuthService"));
assert!(classmap.contains_key("Database\\Seeders\\DatabaseSeeder"));
}