use crate::common::{create_psr4_workspace, create_test_backend};
use phpantom_lsp::Backend;
use phpantom_lsp::composer::parse_autoload_classmap;
use std::fs;
use tower_lsp::LanguageServer;
use tower_lsp::lsp_types::request::{GotoImplementationParams, GotoImplementationResponse};
use tower_lsp::lsp_types::*;
async fn implementation_at(
backend: &phpantom_lsp::Backend,
uri: &Url,
line: u32,
character: u32,
) -> Vec<Location> {
let params = GotoImplementationParams {
text_document_position_params: TextDocumentPositionParams {
text_document: TextDocumentIdentifier { uri: uri.clone() },
position: Position { line, character },
},
work_done_progress_params: WorkDoneProgressParams::default(),
partial_result_params: PartialResultParams::default(),
};
match backend.goto_implementation(params).await.unwrap() {
Some(GotoImplementationResponse::Scalar(loc)) => vec![loc],
Some(GotoImplementationResponse::Array(locs)) => locs,
Some(GotoImplementationResponse::Link(links)) => links
.into_iter()
.map(|l| Location {
uri: l.target_uri,
range: l.target_selection_range,
})
.collect(),
None => vec![],
}
}
async fn open(backend: &phpantom_lsp::Backend, uri: &Url, text: &str) {
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
}
#[tokio::test]
async fn test_implementation_interface_name() {
let backend = create_test_backend();
let uri = Url::parse("file:///impl_iface.php").unwrap();
let text = concat!(
"<?php\n", "interface Renderable {\n", " public function render(): string;\n", "}\n", "class HtmlView implements Renderable {\n", " public function render(): string { return ''; }\n", "}\n", "class JsonView implements Renderable {\n", " public function render(): string { return ''; }\n", "}\n", "class PlainClass {\n", " public function render(): string { return ''; }\n", "}\n", );
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let locations = implementation_at(&backend, &uri, 1, 12).await;
assert!(
locations.len() >= 2,
"Should find at least 2 implementors of Renderable, got {}",
locations.len()
);
let lines: Vec<u32> = locations.iter().map(|l| l.range.start.line).collect();
assert!(
lines.contains(&4),
"Should include HtmlView (line 4), got lines: {:?}",
lines
);
assert!(
lines.contains(&7),
"Should include JsonView (line 7), got lines: {:?}",
lines
);
assert!(
!lines.contains(&10),
"Should NOT include PlainClass (line 10), got lines: {:?}",
lines
);
}
#[tokio::test]
async fn test_implementation_abstract_class_name() {
let backend = create_test_backend();
let uri = Url::parse("file:///impl_abstract.php").unwrap();
let text = concat!(
"<?php\n", "abstract class Shape {\n", " abstract public function area(): float;\n", "}\n", "class Circle extends Shape {\n", " public function area(): float { return 3.14; }\n", "}\n", "class Square extends Shape {\n", " public function area(): float { return 1.0; }\n", "}\n", );
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let locations = implementation_at(&backend, &uri, 1, 18).await;
assert!(
locations.len() >= 2,
"Should find at least 2 subclasses of Shape, got {}",
locations.len()
);
let lines: Vec<u32> = locations.iter().map(|l| l.range.start.line).collect();
assert!(
lines.contains(&4),
"Should include Circle (line 4), got lines: {:?}",
lines
);
assert!(
lines.contains(&7),
"Should include Square (line 7), got lines: {:?}",
lines
);
}
#[tokio::test]
async fn test_implementation_method_on_interface() {
let backend = create_test_backend();
let uri = Url::parse("file:///impl_method.php").unwrap();
let text = concat!(
"<?php\n", "interface Renderable {\n", " public function render(): string;\n", "}\n", "class HtmlView implements Renderable {\n", " public function render(): string { return ''; }\n", "}\n", "class JsonView implements Renderable {\n", " public function render(): string { return ''; }\n", "}\n", "class Service {\n", " public function handle(Renderable $view) {\n", " $view->render();\n", " }\n", "}\n", );
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let locations = implementation_at(&backend, &uri, 12, 16).await;
assert!(
locations.len() >= 2,
"Should find at least 2 implementations of render(), got {}",
locations.len()
);
let lines: Vec<u32> = locations.iter().map(|l| l.range.start.line).collect();
assert!(
lines.contains(&5),
"Should include HtmlView::render() (line 5), got lines: {:?}",
lines
);
assert!(
lines.contains(&8),
"Should include JsonView::render() (line 8), got lines: {:?}",
lines
);
}
#[tokio::test]
async fn test_implementation_method_on_abstract_class() {
let backend = create_test_backend();
let uri = Url::parse("file:///impl_abstract_method.php").unwrap();
let text = concat!(
"<?php\n", "abstract class Shape {\n", " abstract public function area(): float;\n", "}\n", "class Circle extends Shape {\n", " public function area(): float { return 3.14; }\n", "}\n", "class Square extends Shape {\n", " public function area(): float { return 1.0; }\n", "}\n", "function calc(Shape $s) {\n", " $s->area();\n", "}\n", );
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let locations = implementation_at(&backend, &uri, 11, 10).await;
assert!(
locations.len() >= 2,
"Should find at least 2 implementations of area(), got {}",
locations.len()
);
let lines: Vec<u32> = locations.iter().map(|l| l.range.start.line).collect();
assert!(
lines.contains(&5),
"Should include Circle::area() (line 5), got lines: {:?}",
lines
);
assert!(
lines.contains(&8),
"Should include Square::area() (line 8), got lines: {:?}",
lines
);
}
#[tokio::test]
async fn test_implementation_concrete_class_returns_subclasses() {
let backend = create_test_backend();
let uri = Url::parse("file:///impl_concrete.php").unwrap();
let text = concat!(
"<?php\n", "class User {\n", " public string $name;\n", "}\n", "class Admin extends User {\n", " public string $role;\n", "}\n", );
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let locations = implementation_at(&backend, &uri, 1, 7).await;
let lines: Vec<u32> = locations.iter().map(|l| l.range.start.line).collect();
assert!(
lines.contains(&4),
"Should include Admin (line 4), got lines: {:?}",
lines
);
}
#[tokio::test]
async fn test_implementation_final_class_returns_none() {
let backend = create_test_backend();
let uri = Url::parse("file:///impl_final.php").unwrap();
let text = concat!(
"<?php\n", "final class Singleton {\n", " public string $id;\n", "}\n", );
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let locations = implementation_at(&backend, &uri, 1, 14).await;
assert!(
locations.is_empty(),
"Final class should not return implementations, got {:?}",
locations
);
}
#[tokio::test]
async fn test_implementation_concrete_class_includes_abstract_subclass() {
let backend = create_test_backend();
let uri = Url::parse("file:///impl_concrete_abstract.php").unwrap();
let text = concat!(
"<?php\n", "class Base {\n", " public function run(): void {}\n", "}\n", "abstract class Middle extends Base {\n", "}\n", "class Leaf extends Middle {\n", "}\n", );
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let locations = implementation_at(&backend, &uri, 1, 7).await;
let lines: Vec<u32> = locations.iter().map(|l| l.range.start.line).collect();
assert!(
lines.contains(&4),
"Should include abstract Middle (line 4), got lines: {:?}",
lines
);
assert!(
lines.contains(&6),
"Should include concrete Leaf (line 6), got lines: {:?}",
lines
);
}
#[tokio::test]
async fn test_implementation_transitive_via_parent() {
let backend = create_test_backend();
let uri = Url::parse("file:///impl_transitive.php").unwrap();
let text = concat!(
"<?php\n", "interface Renderable {\n", " public function render(): string;\n", "}\n", "class BaseView implements Renderable {\n", " public function render(): string { return ''; }\n", "}\n", "class AdminView extends BaseView {\n", " public function render(): string { return ''; }\n", "}\n", );
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let locations = implementation_at(&backend, &uri, 1, 12).await;
assert!(
locations.len() >= 2,
"Should find at least 2 implementors (direct + transitive), got {}",
locations.len()
);
let lines: Vec<u32> = locations.iter().map(|l| l.range.start.line).collect();
assert!(
lines.contains(&4),
"Should include BaseView (line 4), got lines: {:?}",
lines
);
assert!(
lines.contains(&7),
"Should include AdminView (line 7, transitive), got lines: {:?}",
lines
);
}
#[tokio::test]
async fn test_implementation_multiple_interfaces() {
let backend = create_test_backend();
let uri = Url::parse("file:///impl_multi.php").unwrap();
let text = concat!(
"<?php\n", "interface Serializable {\n", " public function serialize(): string;\n", "}\n", "interface Printable {\n", " public function print(): void;\n", "}\n", "class Report implements Serializable, Printable {\n", " public function serialize(): string { return ''; }\n", " public function print(): void {}\n", "}\n", );
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let locs_serial = implementation_at(&backend, &uri, 1, 12).await;
assert!(
!locs_serial.is_empty(),
"Serializable should have at least one implementor"
);
let serial_lines: Vec<u32> = locs_serial.iter().map(|l| l.range.start.line).collect();
assert!(
serial_lines.contains(&7),
"Report should implement Serializable, got lines: {:?}",
serial_lines
);
let locs_print = implementation_at(&backend, &uri, 4, 12).await;
assert!(
!locs_print.is_empty(),
"Printable should have at least one implementor"
);
let print_lines: Vec<u32> = locs_print.iter().map(|l| l.range.start.line).collect();
assert!(
print_lines.contains(&7),
"Report should implement Printable, got lines: {:?}",
print_lines
);
}
#[tokio::test]
async fn test_implementation_enum_implements_interface() {
let backend = create_test_backend();
let uri = Url::parse("file:///impl_enum.php").unwrap();
let text = concat!(
"<?php\n", "interface HasLabel {\n", " public function label(): string;\n", "}\n", "enum Color: string implements HasLabel {\n", " case Red = 'red';\n", " public function label(): string {\n", " return $this->value;\n", " }\n", "}\n", );
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let locations = implementation_at(&backend, &uri, 1, 12).await;
assert!(
!locations.is_empty(),
"HasLabel should have at least one implementor (Color enum)"
);
let lines: Vec<u32> = locations.iter().map(|l| l.range.start.line).collect();
assert!(
lines.contains(&4),
"Should include Color enum (line 4), got lines: {:?}",
lines
);
}
#[tokio::test]
async fn test_implementation_cross_file_psr4() {
let composer = r#"{
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
}"#;
let interface_php = concat!(
"<?php\n",
"namespace App\\Contracts;\n",
"interface Logger {\n",
" public function log(string $msg): void;\n",
"}\n",
);
let file_logger_php = concat!(
"<?php\n",
"namespace App\\Logging;\n",
"use App\\Contracts\\Logger;\n",
"class FileLogger implements Logger {\n",
" public function log(string $msg): void {}\n",
"}\n",
);
let db_logger_php = concat!(
"<?php\n",
"namespace App\\Logging;\n",
"use App\\Contracts\\Logger;\n",
"class DbLogger implements Logger {\n",
" public function log(string $msg): void {}\n",
"}\n",
);
let service_php = concat!(
"<?php\n",
"namespace App\\Services;\n",
"use App\\Contracts\\Logger;\n",
"class AppService {\n",
" public function run(Logger $logger) {\n",
" $logger->log('hello');\n",
" }\n",
"}\n",
);
let (backend, _dir) = create_psr4_workspace(
composer,
&[
("src/Contracts/Logger.php", interface_php),
("src/Logging/FileLogger.php", file_logger_php),
("src/Logging/DbLogger.php", db_logger_php),
("src/Services/AppService.php", service_php),
],
);
let iface_uri = Url::parse("file:///logger_iface.php").unwrap();
open(&backend, &iface_uri, interface_php).await;
let file_logger_uri = Url::parse("file:///file_logger.php").unwrap();
open(&backend, &file_logger_uri, file_logger_php).await;
let db_logger_uri = Url::parse("file:///db_logger.php").unwrap();
open(&backend, &db_logger_uri, db_logger_php).await;
let service_uri = Url::parse("file:///service.php").unwrap();
open(&backend, &service_uri, service_php).await;
let locations = implementation_at(&backend, &iface_uri, 2, 12).await;
assert!(
locations.len() >= 2,
"Should find at least 2 implementors of Logger across files, got {}",
locations.len()
);
}
#[tokio::test]
async fn test_implementation_method_cross_file() {
let composer = r#"{
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
}"#;
let interface_php = concat!(
"<?php\n",
"namespace App\\Contracts;\n",
"interface Formatter {\n",
" public function format(string $data): string;\n",
"}\n",
);
let html_formatter_php = concat!(
"<?php\n",
"namespace App\\Formatters;\n",
"use App\\Contracts\\Formatter;\n",
"class HtmlFormatter implements Formatter {\n",
" public function format(string $data): string { return $data; }\n",
"}\n",
);
let json_formatter_php = concat!(
"<?php\n",
"namespace App\\Formatters;\n",
"use App\\Contracts\\Formatter;\n",
"class JsonFormatter implements Formatter {\n",
" public function format(string $data): string { return $data; }\n",
"}\n",
);
let service_php = concat!(
"<?php\n", "namespace App\\Services;\n", "use App\\Contracts\\Formatter;\n", "class RenderService {\n", " public function render(Formatter $f) {\n", " $f->format('hello');\n", " }\n", "}\n", );
let (backend, _dir) = create_psr4_workspace(
composer,
&[
("src/Contracts/Formatter.php", interface_php),
("src/Formatters/HtmlFormatter.php", html_formatter_php),
("src/Formatters/JsonFormatter.php", json_formatter_php),
("src/Services/RenderService.php", service_php),
],
);
let iface_uri = Url::parse("file:///formatter_iface.php").unwrap();
open(&backend, &iface_uri, interface_php).await;
let html_uri = Url::parse("file:///html_formatter.php").unwrap();
open(&backend, &html_uri, html_formatter_php).await;
let json_uri = Url::parse("file:///json_formatter.php").unwrap();
open(&backend, &json_uri, json_formatter_php).await;
let service_uri = Url::parse("file:///render_service.php").unwrap();
open(&backend, &service_uri, service_php).await;
let locations = implementation_at(&backend, &service_uri, 5, 14).await;
assert!(
locations.len() >= 2,
"Should find at least 2 implementations of format() across files, got {}",
locations.len()
);
}
#[tokio::test]
async fn test_implementation_no_implementors() {
let backend = create_test_backend();
let uri = Url::parse("file:///impl_none.php").unwrap();
let text = concat!(
"<?php\n",
"interface Cacheable {\n",
" public function cache(): void;\n",
"}\n",
);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let locations = implementation_at(&backend, &uri, 1, 12).await;
assert!(
locations.is_empty(),
"Interface with no implementors should return empty, got {:?}",
locations
);
}
#[tokio::test]
async fn test_implementation_on_variable_no_crash() {
let backend = create_test_backend();
let uri = Url::parse("file:///impl_var.php").unwrap();
let text = concat!("<?php\n", "$x = 42;\n", "$x;\n",);
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let locations = implementation_at(&backend, &uri, 2, 1).await;
let _ = locations;
}
#[tokio::test]
async fn test_implementation_skips_abstract_subclasses() {
let backend = create_test_backend();
let uri = Url::parse("file:///impl_skip_abstract.php").unwrap();
let text = concat!(
"<?php\n", "abstract class Animal {\n", " abstract public function speak(): string;\n", "}\n", "abstract class Pet extends Animal {\n", "}\n", "class Dog extends Pet {\n", " public function speak(): string { return 'woof'; }\n", "}\n", "class Cat extends Pet {\n", " public function speak(): string { return 'meow'; }\n", "}\n", );
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let locations = implementation_at(&backend, &uri, 1, 18).await;
let lines: Vec<u32> = locations.iter().map(|l| l.range.start.line).collect();
assert!(
!lines.contains(&4),
"Should NOT include abstract Pet (line 4), got lines: {:?}",
lines
);
assert!(
lines.contains(&6),
"Should include Dog (line 6), got lines: {:?}",
lines
);
assert!(
lines.contains(&9),
"Should include Cat (line 9), got lines: {:?}",
lines
);
}
#[tokio::test]
async fn test_implementation_method_only_overriders() {
let backend = create_test_backend();
let uri = Url::parse("file:///impl_override.php").unwrap();
let text = concat!(
"<?php\n", "interface Renderable {\n", " public function render(): string;\n", "}\n", "class BaseView implements Renderable {\n", " public function render(): string { return ''; }\n", "}\n", "class ChildView extends BaseView {\n", " // Does NOT override render()\n", "}\n", "function show(Renderable $v) {\n", " $v->render();\n", "}\n", );
backend
.did_open(DidOpenTextDocumentParams {
text_document: TextDocumentItem {
uri: uri.clone(),
language_id: "php".to_string(),
version: 1,
text: text.to_string(),
},
})
.await;
let locations = implementation_at(&backend, &uri, 11, 10).await;
let lines: Vec<u32> = locations.iter().map(|l| l.range.start.line).collect();
assert!(
lines.contains(&5),
"Should include BaseView::render() (line 5), got lines: {:?}",
lines
);
assert!(
!lines.iter().any(|&l| l == 7 || l == 8),
"Should NOT include ChildView which doesn't override render(), got lines: {:?}",
lines
);
}
#[tokio::test]
async fn test_server_advertises_implementation_capability() {
let backend = create_test_backend();
let init_params = InitializeParams {
root_uri: None,
capabilities: ClientCapabilities::default(),
..InitializeParams::default()
};
let result = backend.initialize(init_params).await.unwrap();
assert!(
result.capabilities.implementation_provider.is_some(),
"Server should advertise implementationProvider capability"
);
}
#[tokio::test]
async fn test_implementation_classmap_file_scan() {
let dir = tempfile::tempdir().expect("failed to create temp dir");
fs::write(
dir.path().join("composer.json"),
r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
)
.expect("failed to write composer.json");
let interface_php = concat!(
"<?php\n",
"namespace App\\Contracts;\n",
"interface Cacheable {\n",
" public function cacheKey(): string;\n",
"}\n",
);
let redis_cache_php = concat!(
"<?php\n",
"namespace App\\Cache;\n",
"use App\\Contracts\\Cacheable;\n",
"class RedisCache implements Cacheable {\n",
" public function cacheKey(): string { return 'redis'; }\n",
"}\n",
);
let file_cache_php = concat!(
"<?php\n",
"namespace App\\Cache;\n",
"use App\\Contracts\\Cacheable;\n",
"class FileCache implements Cacheable {\n",
" public function cacheKey(): string { return 'file'; }\n",
"}\n",
);
let src = dir.path().join("src");
fs::create_dir_all(src.join("Contracts")).unwrap();
fs::create_dir_all(src.join("Cache")).unwrap();
fs::write(src.join("Contracts/Cacheable.php"), interface_php).unwrap();
fs::write(src.join("Cache/RedisCache.php"), redis_cache_php).unwrap();
fs::write(src.join("Cache/FileCache.php"), file_cache_php).unwrap();
let composer_dir = dir.path().join("vendor").join("composer");
fs::create_dir_all(&composer_dir).unwrap();
fs::write(
composer_dir.join("autoload_classmap.php"),
concat!(
"<?php\n",
"$baseDir = dirname(dirname(__DIR__));\n",
"return array(\n",
" 'App\\\\Contracts\\\\Cacheable' => $baseDir . '/src/Contracts/Cacheable.php',\n",
" 'App\\\\Cache\\\\RedisCache' => $baseDir . '/src/Cache/RedisCache.php',\n",
" 'App\\\\Cache\\\\FileCache' => $baseDir . '/src/Cache/FileCache.php',\n",
");\n",
),
)
.unwrap();
let classmap = parse_autoload_classmap(dir.path(), "vendor");
assert_eq!(classmap.len(), 3, "classmap should have 3 entries");
let (mappings, _vendor_dir) = phpantom_lsp::composer::parse_composer_json(dir.path());
let backend = Backend::new_test_with_workspace(dir.path().to_path_buf(), mappings);
{
let mut cm = backend.classmap().write();
*cm = classmap;
}
let iface_uri = Url::from_file_path(src.join("Contracts/Cacheable.php")).unwrap();
open(&backend, &iface_uri, interface_php).await;
let locations = implementation_at(&backend, &iface_uri, 2, 12).await;
assert!(
locations.len() >= 2,
"Should find at least 2 implementors of Cacheable via classmap scan, got {}",
locations.len()
);
}
#[tokio::test]
async fn test_implementation_psr4_directory_scan() {
let dir = tempfile::tempdir().expect("failed to create temp dir");
fs::write(
dir.path().join("composer.json"),
r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
)
.expect("failed to write composer.json");
let interface_php = concat!(
"<?php\n",
"namespace App\\Contracts;\n",
"interface Notifier {\n",
" public function send(string $msg): void;\n",
"}\n",
);
let email_notifier_php = concat!(
"<?php\n",
"namespace App\\Notifiers;\n",
"use App\\Contracts\\Notifier;\n",
"class EmailNotifier implements Notifier {\n",
" public function send(string $msg): void {}\n",
"}\n",
);
let sms_notifier_php = concat!(
"<?php\n",
"namespace App\\Notifiers;\n",
"use App\\Contracts\\Notifier;\n",
"class SmsNotifier implements Notifier {\n",
" public function send(string $msg): void {}\n",
"}\n",
);
let src = dir.path().join("src");
fs::create_dir_all(src.join("Contracts")).unwrap();
fs::create_dir_all(src.join("Notifiers")).unwrap();
fs::write(src.join("Contracts/Notifier.php"), interface_php).unwrap();
fs::write(src.join("Notifiers/EmailNotifier.php"), email_notifier_php).unwrap();
fs::write(src.join("Notifiers/SmsNotifier.php"), sms_notifier_php).unwrap();
let (mappings, _vendor_dir) = phpantom_lsp::composer::parse_composer_json(dir.path());
let backend = Backend::new_test_with_workspace(dir.path().to_path_buf(), mappings);
let iface_uri = Url::from_file_path(src.join("Contracts/Notifier.php")).unwrap();
open(&backend, &iface_uri, interface_php).await;
let locations = implementation_at(&backend, &iface_uri, 2, 12).await;
assert!(
locations.len() >= 2,
"Should find at least 2 implementors via PSR-4 directory scan, got {}",
locations.len()
);
}
#[tokio::test]
async fn test_implementation_psr4_scan_skips_classmap_files() {
let dir = tempfile::tempdir().expect("failed to create temp dir");
fs::write(
dir.path().join("composer.json"),
r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
)
.expect("failed to write composer.json");
let interface_php = concat!(
"<?php\n",
"namespace App\\Contracts;\n",
"interface Serializable {\n",
" public function serialize(): string;\n",
"}\n",
);
let json_impl_php = concat!(
"<?php\n",
"namespace App\\Serializers;\n",
"use App\\Contracts\\Serializable;\n",
"class JsonSerializer implements Serializable {\n",
" public function serialize(): string { return '{}'; }\n",
"}\n",
);
let xml_impl_php = concat!(
"<?php\n",
"namespace App\\Serializers;\n",
"use App\\Contracts\\Serializable;\n",
"class XmlSerializer implements Serializable {\n",
" public function serialize(): string { return '<xml/>'; }\n",
"}\n",
);
let src = dir.path().join("src");
fs::create_dir_all(src.join("Contracts")).unwrap();
fs::create_dir_all(src.join("Serializers")).unwrap();
fs::write(src.join("Contracts/Serializable.php"), interface_php).unwrap();
fs::write(src.join("Serializers/JsonSerializer.php"), json_impl_php).unwrap();
fs::write(src.join("Serializers/XmlSerializer.php"), xml_impl_php).unwrap();
let composer_dir = dir.path().join("vendor").join("composer");
fs::create_dir_all(&composer_dir).unwrap();
fs::write(
composer_dir.join("autoload_classmap.php"),
concat!(
"<?php\n",
"$baseDir = dirname(dirname(__DIR__));\n",
"return array(\n",
" 'App\\\\Serializers\\\\JsonSerializer' => $baseDir . '/src/Serializers/JsonSerializer.php',\n",
");\n",
),
)
.unwrap();
let classmap = parse_autoload_classmap(dir.path(), "vendor");
let (mappings, _vendor_dir) = phpantom_lsp::composer::parse_composer_json(dir.path());
let backend = Backend::new_test_with_workspace(dir.path().to_path_buf(), mappings);
{
let mut cm = backend.classmap().write();
*cm = classmap;
}
let iface_uri = Url::from_file_path(src.join("Contracts/Serializable.php")).unwrap();
open(&backend, &iface_uri, interface_php).await;
let locations = implementation_at(&backend, &iface_uri, 2, 12).await;
assert!(
locations.len() >= 2,
"Should find both classmap and PSR-4-only implementors, got {}",
locations.len()
);
}
#[tokio::test]
async fn test_implementation_psr4_scan_abstract_class() {
let dir = tempfile::tempdir().expect("failed to create temp dir");
fs::write(
dir.path().join("composer.json"),
r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
)
.expect("failed to write composer.json");
let abstract_php = concat!(
"<?php\n",
"namespace App\\Base;\n",
"abstract class Handler {\n",
" abstract public function handle(): void;\n",
"}\n",
);
let concrete_php = concat!(
"<?php\n",
"namespace App\\Handlers;\n",
"use App\\Base\\Handler;\n",
"class ConcreteHandler extends Handler {\n",
" public function handle(): void {}\n",
"}\n",
);
let src = dir.path().join("src");
fs::create_dir_all(src.join("Base")).unwrap();
fs::create_dir_all(src.join("Handlers")).unwrap();
fs::write(src.join("Base/Handler.php"), abstract_php).unwrap();
fs::write(src.join("Handlers/ConcreteHandler.php"), concrete_php).unwrap();
let (mappings, _vendor_dir) = phpantom_lsp::composer::parse_composer_json(dir.path());
let backend = Backend::new_test_with_workspace(dir.path().to_path_buf(), mappings);
let iface_uri = Url::from_file_path(src.join("Base/Handler.php")).unwrap();
open(&backend, &iface_uri, abstract_php).await;
let locations = implementation_at(&backend, &iface_uri, 2, 18).await;
assert!(
!locations.is_empty(),
"Should find ConcreteHandler via PSR-4 scan, got {}",
locations.len()
);
}
#[tokio::test]
async fn test_implementation_method_via_psr4_scan() {
let dir = tempfile::tempdir().expect("failed to create temp dir");
fs::write(
dir.path().join("composer.json"),
r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
)
.expect("failed to write composer.json");
let interface_php = concat!(
"<?php\n",
"namespace App\\Contracts;\n",
"interface Repository {\n",
" public function find(int $id): object;\n",
"}\n",
);
let service_php = concat!(
"<?php\n",
"namespace App\\Services;\n",
"use App\\Contracts\\Repository;\n",
"class UserService {\n",
" public function get(Repository $repo) {\n",
" $repo->find(1);\n",
" }\n",
"}\n",
);
let impl_php = concat!(
"<?php\n",
"namespace App\\Repos;\n",
"use App\\Contracts\\Repository;\n",
"class UserRepository implements Repository {\n",
" public function find(int $id): object { return (object)[]; }\n",
"}\n",
);
let src = dir.path().join("src");
fs::create_dir_all(src.join("Contracts")).unwrap();
fs::create_dir_all(src.join("Services")).unwrap();
fs::create_dir_all(src.join("Repos")).unwrap();
fs::write(src.join("Contracts/Repository.php"), interface_php).unwrap();
fs::write(src.join("Services/UserService.php"), service_php).unwrap();
fs::write(src.join("Repos/UserRepository.php"), impl_php).unwrap();
let (mappings, _vendor_dir) = phpantom_lsp::composer::parse_composer_json(dir.path());
let backend = Backend::new_test_with_workspace(dir.path().to_path_buf(), mappings);
let iface_uri = Url::from_file_path(src.join("Contracts/Repository.php")).unwrap();
open(&backend, &iface_uri, interface_php).await;
let svc_uri = Url::from_file_path(src.join("Services/UserService.php")).unwrap();
open(&backend, &svc_uri, service_php).await;
let locations = implementation_at(&backend, &svc_uri, 5, 16).await;
assert!(
!locations.is_empty(),
"Should find UserRepository::find via PSR-4 scan"
);
}
#[tokio::test]
async fn test_implementation_transitive_interface_extends() {
let backend = create_test_backend();
let uri = Url::parse("file:///transitive_iface.php").unwrap();
let text = concat!(
"<?php\n", "interface InterfaceA {\n", " public function doA(): void;\n", "}\n", "interface InterfaceB extends InterfaceA {\n", " public function doB(): void;\n", "}\n", "class ConcreteC implements InterfaceB {\n", " public function doA(): void {}\n", " public function doB(): void {}\n", "}\n", "class DirectImpl implements InterfaceA {\n", " public function doA(): void {}\n", "}\n", );
open(&backend, &uri, text).await;
let locations = implementation_at(&backend, &uri, 1, 12).await;
let lines: Vec<u32> = locations.iter().map(|l| l.range.start.line).collect();
assert!(
lines.contains(&7),
"Should find ConcreteC (line 7) via transitive InterfaceB extends InterfaceA, got lines: {:?}",
lines
);
assert!(
lines.contains(&11),
"Should find DirectImpl (line 11) via direct implements, got lines: {:?}",
lines
);
}
#[tokio::test]
async fn test_implementation_deeply_transitive_interface() {
let backend = create_test_backend();
let uri = Url::parse("file:///deep_transitive.php").unwrap();
let text = concat!(
"<?php\n", "interface BaseContract {\n", " public function execute(): void;\n", "}\n", "interface MiddleContract extends BaseContract {\n", " public function prepare(): void;\n", "}\n", "interface LeafContract extends MiddleContract {\n", " public function finalize(): void;\n", "}\n", "class Worker implements LeafContract {\n", " public function execute(): void {}\n", " public function prepare(): void {}\n", " public function finalize(): void {}\n", "}\n", );
open(&backend, &uri, text).await;
let locations = implementation_at(&backend, &uri, 1, 12).await;
let lines: Vec<u32> = locations.iter().map(|l| l.range.start.line).collect();
assert!(
lines.contains(&10),
"Should find Worker (line 10) via deeply transitive interface chain, got lines: {:?}",
lines
);
}
#[tokio::test]
async fn test_implementation_multi_extends_interface() {
let backend = create_test_backend();
let uri = Url::parse("file:///multi_extends.php").unwrap();
let text = concat!(
"<?php\n", "interface Readable {\n", " public function read(): string;\n", "}\n", "interface Writable {\n", " public function write(string $data): void;\n", "}\n", "interface ReadWritable extends Readable, Writable {\n", "}\n", "class FileStream implements ReadWritable {\n", " public function read(): string { return ''; }\n", " public function write(string $data): void {}\n", "}\n", );
open(&backend, &uri, text).await;
let locations_readable = implementation_at(&backend, &uri, 1, 12).await;
let lines_readable: Vec<u32> = locations_readable
.iter()
.map(|l| l.range.start.line)
.collect();
assert!(
lines_readable.contains(&9),
"Should find FileStream (line 9) via Readable -> ReadWritable, got lines: {:?}",
lines_readable
);
let locations_writable = implementation_at(&backend, &uri, 4, 12).await;
let lines_writable: Vec<u32> = locations_writable
.iter()
.map(|l| l.range.start.line)
.collect();
assert!(
lines_writable.contains(&9),
"Should find FileStream (line 9) via Writable -> ReadWritable, got lines: {:?}",
lines_writable
);
}
#[tokio::test]
async fn test_implementation_transitive_interface_via_parent_class() {
let backend = create_test_backend();
let uri = Url::parse("file:///trans_via_parent.php").unwrap();
let text = concat!(
"<?php\n", "interface InterfaceBase {\n", " public function base(): void;\n", "}\n", "interface InterfaceX extends InterfaceBase {\n", " public function extra(): void;\n", "}\n", "class ClassA implements InterfaceX {\n", " public function base(): void {}\n", " public function extra(): void {}\n", "}\n", "class ClassB extends ClassA {\n", "}\n", );
open(&backend, &uri, text).await;
let locations = implementation_at(&backend, &uri, 1, 12).await;
let lines: Vec<u32> = locations.iter().map(|l| l.range.start.line).collect();
assert!(
lines.contains(&7),
"Should find ClassA (line 7) via InterfaceX extends InterfaceBase, got lines: {:?}",
lines
);
assert!(
lines.contains(&11),
"Should find ClassB (line 11) via parent ClassA -> InterfaceX -> InterfaceBase, got lines: {:?}",
lines
);
}
#[tokio::test]
async fn test_implementation_reverse_jump_to_interface_method() {
let backend = create_test_backend();
let uri = Url::parse("file:///impl_reverse.php").unwrap();
let text = concat!(
"<?php\n", "interface Handler {\n", " public function handle(): void;\n", "}\n", "class ConcreteHandler implements Handler {\n", " public function handle(): void {}\n", "}\n", );
open(&backend, &uri, text).await;
let locations = implementation_at(&backend, &uri, 5, 20).await;
assert!(
!locations.is_empty(),
"Reverse jump should find the interface method declaration"
);
let lines: Vec<u32> = locations.iter().map(|l| l.range.start.line).collect();
assert!(
lines.contains(&2),
"Should jump to Handler::handle() on line 2, got lines: {:?}",
lines
);
}
#[tokio::test]
async fn test_implementation_forward_jump_from_interface_declaration() {
let backend = create_test_backend();
let uri = Url::parse("file:///impl_fwd_decl.php").unwrap();
let text = concat!(
"<?php\n", "interface Processor {\n", " public function process(): void;\n", "}\n", "class FooProcessor implements Processor {\n", " public function process(): void {}\n", "}\n", "class BarProcessor implements Processor {\n", " public function process(): void {}\n", "}\n", );
open(&backend, &uri, text).await;
let locations = implementation_at(&backend, &uri, 2, 20).await;
assert!(
locations.len() >= 2,
"Forward jump from interface declaration should find concrete implementations, got {}",
locations.len()
);
let lines: Vec<u32> = locations.iter().map(|l| l.range.start.line).collect();
assert!(
lines.contains(&5),
"Should include FooProcessor::process() on line 5, got lines: {:?}",
lines
);
assert!(
lines.contains(&8),
"Should include BarProcessor::process() on line 8, got lines: {:?}",
lines
);
}
#[tokio::test]
async fn test_implementation_forward_jump_from_abstract_declaration() {
let backend = create_test_backend();
let uri = Url::parse("file:///impl_abstract_decl.php").unwrap();
let text = concat!(
"<?php\n", "abstract class Shape {\n", " abstract public function area(): float;\n", "}\n", "class Circle extends Shape {\n", " public function area(): float { return 3.14; }\n", "}\n", "class Square extends Shape {\n", " public function area(): float { return 1.0; }\n", "}\n", );
open(&backend, &uri, text).await;
let locations = implementation_at(&backend, &uri, 2, 29).await;
assert!(
locations.len() >= 2,
"Forward jump from abstract declaration should find concrete implementations, got {}",
locations.len()
);
let lines: Vec<u32> = locations.iter().map(|l| l.range.start.line).collect();
assert!(
lines.contains(&5),
"Should include Circle::area() on line 5, got lines: {:?}",
lines
);
assert!(
lines.contains(&8),
"Should include Square::area() on line 8, got lines: {:?}",
lines
);
}
#[tokio::test]
async fn test_implementation_reverse_jump_to_abstract_method() {
let backend = create_test_backend();
let uri = Url::parse("file:///impl_reverse_abstract.php").unwrap();
let text = concat!(
"<?php\n", "abstract class Logger {\n", " abstract public function log(string $msg): void;\n", "}\n", "class FileLogger extends Logger {\n", " public function log(string $msg): void {}\n", "}\n", );
open(&backend, &uri, text).await;
let locations = implementation_at(&backend, &uri, 5, 20).await;
assert!(
!locations.is_empty(),
"Reverse jump should find the abstract method declaration"
);
let lines: Vec<u32> = locations.iter().map(|l| l.range.start.line).collect();
assert!(
lines.contains(&2),
"Should jump to Logger::log() on line 2, got lines: {:?}",
lines
);
}
#[tokio::test]
async fn test_implementation_reverse_jump_transitive_interface() {
let backend = create_test_backend();
let uri = Url::parse("file:///impl_reverse_transitive.php").unwrap();
let text = concat!(
"<?php\n", "interface Serializable {\n", " public function serialize(): string;\n", "}\n", "abstract class BaseModel implements Serializable {\n", "}\n", "class User extends BaseModel {\n", " public function serialize(): string { return ''; }\n", "}\n", );
open(&backend, &uri, text).await;
let locations = implementation_at(&backend, &uri, 7, 20).await;
assert!(
!locations.is_empty(),
"Reverse jump should find the interface method via transitive inheritance"
);
let lines: Vec<u32> = locations.iter().map(|l| l.range.start.line).collect();
assert!(
lines.contains(&2),
"Should jump to Serializable::serialize() on line 2, got lines: {:?}",
lines
);
}
#[tokio::test]
async fn test_implementation_reverse_jump_no_interface_returns_none() {
let backend = create_test_backend();
let uri = Url::parse("file:///impl_reverse_none.php").unwrap();
let text = concat!(
"<?php\n", "class StandaloneClass {\n", " public function doStuff(): void {}\n", "}\n", );
open(&backend, &uri, text).await;
let locations = implementation_at(&backend, &uri, 2, 20).await;
assert!(
locations.is_empty(),
"No interface or abstract parent — should return empty, got {} locations",
locations.len()
);
}
#[tokio::test]
async fn test_implementation_fqn_dedup_different_namespaces() {
let backend = create_test_backend();
let uri = Url::parse("file:///impl_fqn_dedup.php").unwrap();
let text = concat!(
"<?php\n", "namespace App;\n", "interface Logger {\n", " public function log(): void;\n", "}\n", "class FileLogger implements Logger {\n", " public function log(): void {}\n", "}\n", );
let uri2 = Url::parse("file:///impl_fqn_dedup2.php").unwrap();
let text2 = concat!(
"<?php\n", "namespace Vendor;\n", "interface Logger {\n", " public function log(): void;\n", "}\n", "class ConsoleLogger implements Logger {\n", " public function log(): void {}\n", "}\n", );
open(&backend, &uri, text).await;
open(&backend, &uri2, text2).await;
let locations = implementation_at(&backend, &uri, 2, 12).await;
let uris: Vec<String> = locations.iter().map(|l| l.uri.to_string()).collect();
assert!(
uris.iter().all(|u| u.contains("impl_fqn_dedup.php")),
"App\\Logger should only find implementors from the App namespace, got: {:?}",
uris
);
assert!(
!locations.is_empty(),
"Should find FileLogger as an implementor of App\\Logger"
);
}
#[tokio::test]
async fn test_implementation_transitive_interface_cross_file() {
let (backend, _dir) = create_psr4_workspace(
r#"{
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
}"#,
&[
(
"src/Contracts/Loggable.php",
concat!(
"<?php\n",
"namespace App\\Contracts;\n",
"interface Loggable {\n",
" public function log(string $msg): void;\n",
"}\n",
),
),
(
"src/Contracts/AuditLoggable.php",
concat!(
"<?php\n",
"namespace App\\Contracts;\n",
"interface AuditLoggable extends Loggable {\n",
" public function auditLog(string $action): void;\n",
"}\n",
),
),
(
"src/Services/AuditService.php",
concat!(
"<?php\n",
"namespace App\\Services;\n",
"use App\\Contracts\\AuditLoggable;\n",
"class AuditService implements AuditLoggable {\n",
" public function log(string $msg): void {}\n",
" public function auditLog(string $action): void {}\n",
"}\n",
),
),
],
);
let loggable_uri = Url::from_file_path(_dir.path().join("src/Contracts/Loggable.php")).unwrap();
let loggable_text =
std::fs::read_to_string(_dir.path().join("src/Contracts/Loggable.php")).unwrap();
open(&backend, &loggable_uri, &loggable_text).await;
let audit_loggable_uri =
Url::from_file_path(_dir.path().join("src/Contracts/AuditLoggable.php")).unwrap();
let audit_loggable_text =
std::fs::read_to_string(_dir.path().join("src/Contracts/AuditLoggable.php")).unwrap();
open(&backend, &audit_loggable_uri, &audit_loggable_text).await;
let service_uri =
Url::from_file_path(_dir.path().join("src/Services/AuditService.php")).unwrap();
let service_text =
std::fs::read_to_string(_dir.path().join("src/Services/AuditService.php")).unwrap();
open(&backend, &service_uri, &service_text).await;
let locations = implementation_at(&backend, &loggable_uri, 2, 12).await;
assert!(
!locations.is_empty(),
"Should find AuditService via transitive AuditLoggable extends Loggable"
);
}