use std::collections::HashMap;
use std::sync::Arc;
use tower_lsp::lsp_types::*;
use crate::Backend;
use crate::php_type::PhpType;
use crate::types::{ClassInfo, ClassLikeKind, MethodInfo, Visibility};
use crate::util::offset_to_position;
impl Backend {
pub(crate) fn collect_implement_methods_actions(
&self,
uri: &str,
content: &str,
params: &CodeActionParams,
out: &mut Vec<CodeActionOrCommand>,
) {
let ctx = self.file_context(uri);
let cursor_offset = crate::util::position_to_offset(content, params.range.start);
let current_class = match ctx
.classes
.iter()
.filter(|c| {
let effective_start = if c.keyword_offset > 0 {
c.keyword_offset
} else {
c.start_offset
};
cursor_offset >= effective_start && cursor_offset <= c.end_offset
})
.min_by_key(|c| c.end_offset - c.start_offset)
{
Some(c) => c,
None => return,
};
if current_class.kind != ClassLikeKind::Class || current_class.is_abstract {
return;
}
let class_loader = self.class_loader(&ctx);
let missing = collect_missing_methods(current_class, &class_loader);
if missing.is_empty() {
return;
}
let use_map: HashMap<String, String> = ctx.use_map.clone();
let file_namespace = ctx.namespace.clone();
let stub_text =
build_method_stubs(&missing, &use_map, &file_namespace, content, current_class);
let insert_offset = (current_class.end_offset - 1) as usize;
let insert_pos = offset_to_position(content, insert_offset);
let title = if missing.len() == 1 {
format!("Implement `{}`", missing[0].name)
} else {
format!("Implement {} missing methods", missing.len())
};
let edit = TextEdit {
range: Range {
start: insert_pos,
end: insert_pos,
},
new_text: stub_text,
};
let doc_uri: Url = match uri.parse() {
Ok(u) => u,
Err(_) => return,
};
let mut changes = HashMap::new();
changes.insert(doc_uri, vec![edit]);
out.push(CodeActionOrCommand::CodeAction(CodeAction {
title,
kind: Some(CodeActionKind::QUICKFIX),
diagnostics: None,
edit: Some(WorkspaceEdit {
changes: Some(changes),
document_changes: None,
change_annotations: None,
}),
command: None,
is_preferred: Some(true),
disabled: None,
data: None,
}));
}
}
pub(crate) fn collect_missing_methods(
class: &ClassInfo,
class_loader: &dyn Fn(&str) -> Option<Arc<ClassInfo>>,
) -> Vec<MethodInfo> {
let mut implemented_names: Vec<String> = class
.methods
.iter()
.map(|m| m.name.to_lowercase())
.collect();
collect_concrete_trait_methods(&class.used_traits, class_loader, &mut implemented_names, 0);
collect_concrete_parent_methods(&class.parent_class, class_loader, &mut implemented_names, 0);
let mut missing: Vec<MethodInfo> = Vec::new();
let mut seen: Vec<String> = Vec::new();
for iface_name in &class.interfaces {
collect_from_interface(
iface_name,
class_loader,
&implemented_names,
&mut missing,
&mut seen,
0,
);
}
collect_from_parent_chain(
&class.parent_class,
class_loader,
&implemented_names,
&mut missing,
&mut seen,
0,
);
missing
}
fn collect_concrete_parent_methods(
parent_name: &Option<String>,
class_loader: &dyn Fn(&str) -> Option<Arc<ClassInfo>>,
implemented: &mut Vec<String>,
depth: usize,
) {
if depth > crate::types::MAX_INHERITANCE_DEPTH as usize {
return;
}
let parent_name = match parent_name {
Some(n) => n,
None => return,
};
let parent = match class_loader(parent_name) {
Some(c) => c,
None => return,
};
for method in &parent.methods {
if !method.is_abstract {
let lower = method.name.to_lowercase();
if !implemented.contains(&lower) {
implemented.push(lower);
}
}
}
collect_concrete_trait_methods(&parent.used_traits, class_loader, implemented, depth + 1);
collect_concrete_parent_methods(&parent.parent_class, class_loader, implemented, depth + 1);
}
fn collect_concrete_trait_methods(
trait_names: &[String],
class_loader: &dyn Fn(&str) -> Option<Arc<ClassInfo>>,
implemented: &mut Vec<String>,
depth: usize,
) {
if depth > crate::types::MAX_INHERITANCE_DEPTH as usize {
return;
}
for trait_name in trait_names {
let trait_info = match class_loader(trait_name) {
Some(c) => c,
None => continue,
};
for method in &trait_info.methods {
if !method.is_abstract {
let lower = method.name.to_lowercase();
if !implemented.contains(&lower) {
implemented.push(lower);
}
}
}
if !trait_info.used_traits.is_empty() {
collect_concrete_trait_methods(
&trait_info.used_traits,
class_loader,
implemented,
depth + 1,
);
}
collect_concrete_parent_methods(
&trait_info.parent_class,
class_loader,
implemented,
depth + 1,
);
}
}
fn collect_from_interface(
iface_name: &str,
class_loader: &dyn Fn(&str) -> Option<Arc<ClassInfo>>,
own_methods: &[String],
missing: &mut Vec<MethodInfo>,
seen: &mut Vec<String>,
depth: usize,
) {
if depth > crate::types::MAX_INHERITANCE_DEPTH as usize {
return;
}
let iface = match class_loader(iface_name) {
Some(c) if c.kind == ClassLikeKind::Interface => c,
_ => return,
};
for method in &iface.methods {
let lower = method.name.to_lowercase();
if own_methods.contains(&lower) || seen.contains(&lower) {
continue;
}
seen.push(lower);
missing.push(method.clone());
}
for parent_iface in &iface.interfaces {
collect_from_interface(
parent_iface,
class_loader,
own_methods,
missing,
seen,
depth + 1,
);
}
if let Some(ref parent) = iface.parent_class {
collect_from_interface(parent, class_loader, own_methods, missing, seen, depth + 1);
}
}
fn collect_from_parent_chain(
parent_name: &Option<String>,
class_loader: &dyn Fn(&str) -> Option<Arc<ClassInfo>>,
own_methods: &[String],
missing: &mut Vec<MethodInfo>,
seen: &mut Vec<String>,
depth: usize,
) {
if depth > crate::types::MAX_INHERITANCE_DEPTH as usize {
return;
}
let parent_name = match parent_name {
Some(n) => n,
None => return,
};
let parent = match class_loader(parent_name) {
Some(c) => c,
None => return,
};
for iface_name in &parent.interfaces {
collect_from_interface(
iface_name,
class_loader,
own_methods,
missing,
seen,
depth + 1,
);
}
for method in &parent.methods {
if !method.is_abstract {
continue;
}
let lower = method.name.to_lowercase();
if own_methods.contains(&lower) || seen.contains(&lower) {
continue;
}
seen.push(lower);
missing.push(method.clone());
}
collect_from_parent_chain(
&parent.parent_class,
class_loader,
own_methods,
missing,
seen,
depth + 1,
);
}
fn build_method_stubs(
methods: &[MethodInfo],
use_map: &HashMap<String, String>,
file_namespace: &Option<String>,
content: &str,
class: &ClassInfo,
) -> String {
let indent = detect_class_indent(content, class);
let mut result = String::new();
for method in methods {
result.push('\n');
let vis = match method.visibility {
Visibility::Private => "public",
Visibility::Protected => "protected",
Visibility::Public => "public",
};
let static_kw = if method.is_static { "static " } else { "" };
let params = format_params(method, use_map, file_namespace);
let return_type = format_return_type(method, use_map, file_namespace);
result.push_str(&indent);
result.push_str(&format!(
"{} {}function {}({}){}\n",
vis, static_kw, method.name, params, return_type,
));
result.push_str(&indent);
result.push_str("{\n");
result.push_str(&indent);
result.push_str("}\n");
}
result
}
fn format_params(
method: &MethodInfo,
use_map: &HashMap<String, String>,
file_namespace: &Option<String>,
) -> String {
let mut parts = Vec::new();
for param in &method.parameters {
let mut s = String::new();
if let Some(ref hint) = param.native_type_hint {
let shortened = shorten_php_type_direct(hint, use_map, file_namespace);
s.push_str(&shortened);
s.push(' ');
}
if param.is_reference {
s.push('&');
}
if param.is_variadic {
s.push_str("...");
}
s.push_str(¶m.name);
if let Some(ref default) = param.default_value {
s.push_str(" = ");
s.push_str(default);
}
parts.push(s);
}
parts.join(", ")
}
fn format_return_type(
method: &MethodInfo,
use_map: &HashMap<String, String>,
file_namespace: &Option<String>,
) -> String {
if let Some(ref native) = method.native_return_type {
let shortened = shorten_php_type_direct(native, use_map, file_namespace);
if !shortened.is_empty() {
return format!(": {}", shortened);
}
}
if let Some(ref ret) = method.return_type {
let shortened = shorten_php_type_direct(ret, use_map, file_namespace);
if !shortened.is_empty() {
return format!(": {}", shortened);
}
}
String::new()
}
#[cfg(test)]
fn shorten_type(
type_str: &str,
use_map: &HashMap<String, String>,
file_namespace: &Option<String>,
) -> String {
let parsed = PhpType::parse(type_str);
parsed
.resolve_names(&|name| shorten_single_type(name, use_map, file_namespace))
.to_string()
}
fn shorten_php_type_direct(
ty: &PhpType,
use_map: &HashMap<String, String>,
file_namespace: &Option<String>,
) -> String {
ty.resolve_names(&|name| shorten_single_type(name, use_map, file_namespace))
.to_string()
}
fn shorten_single_type(
type_str: &str,
use_map: &HashMap<String, String>,
file_namespace: &Option<String>,
) -> String {
for (short, fqn) in use_map {
if fqn.trim_start_matches('\\') == type_str {
return short.clone();
}
}
if let Some(ns) = file_namespace {
let prefix = format!("{}\\", ns);
if let Some(rest) = type_str.strip_prefix(&prefix)
&& !rest.contains('\\')
{
return rest.to_string();
}
}
type_str.to_string()
}
pub(crate) fn detect_class_indent(content: &str, class: &ClassInfo) -> String {
let brace_offset = class.start_offset as usize;
if brace_offset < content.len() {
let after_brace = &content[brace_offset..];
for line in after_brace.lines().skip(1) {
if line.trim().is_empty() {
continue;
}
let indent: String = line.chars().take_while(|c| c.is_whitespace()).collect();
if !indent.is_empty() {
return indent;
}
}
}
" ".to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::php_type::PhpType;
use crate::types::{ParameterInfo, Visibility};
#[test]
fn shorten_type_with_use_map() {
let mut use_map = HashMap::new();
use_map.insert("User".to_string(), "App\\Models\\User".to_string());
let ns = Some("App\\Http\\Controllers".to_string());
assert_eq!(shorten_type("App\\Models\\User", &use_map, &ns), "User");
}
#[test]
fn shorten_type_same_namespace() {
let use_map = HashMap::new();
let ns = Some("App\\Models".to_string());
assert_eq!(shorten_type("App\\Models\\User", &use_map, &ns), "User");
}
#[test]
fn shorten_type_nullable() {
let mut use_map = HashMap::new();
use_map.insert("User".to_string(), "App\\Models\\User".to_string());
let ns = None;
assert_eq!(shorten_type("?App\\Models\\User", &use_map, &ns), "?User");
}
#[test]
fn shorten_type_union() {
let mut use_map = HashMap::new();
use_map.insert("User".to_string(), "App\\Models\\User".to_string());
let ns = None;
assert_eq!(
shorten_type("App\\Models\\User|null", &use_map, &ns),
"User|null"
);
}
#[test]
fn shorten_scalar_types_unchanged() {
let use_map = HashMap::new();
let ns = None;
assert_eq!(shorten_type("string", &use_map, &ns), "string");
assert_eq!(shorten_type("int", &use_map, &ns), "int");
assert_eq!(shorten_type("bool", &use_map, &ns), "bool");
assert_eq!(shorten_type("void", &use_map, &ns), "void");
}
#[test]
fn format_params_basic() {
let method = MethodInfo {
parameters: vec![
ParameterInfo {
name: "$name".to_string(),
is_required: true,
type_hint: Some(PhpType::parse("string")),
native_type_hint: Some(PhpType::parse("string")),
description: None,
default_value: None,
is_variadic: false,
is_reference: false,
closure_this_type: None,
},
ParameterInfo {
name: "$age".to_string(),
is_required: false,
type_hint: Some(PhpType::parse("int")),
native_type_hint: Some(PhpType::parse("int")),
description: None,
default_value: Some("0".to_string()),
is_variadic: false,
is_reference: false,
closure_this_type: None,
},
],
..MethodInfo::virtual_method("test", None)
};
let result = format_params(&method, &HashMap::new(), &None);
assert_eq!(result, "string $name, int $age = 0");
}
#[test]
fn format_params_variadic_and_reference() {
let method = MethodInfo {
parameters: vec![
ParameterInfo {
name: "$items".to_string(),
is_required: true,
type_hint: Some(PhpType::parse("string")),
native_type_hint: Some(PhpType::parse("string")),
description: None,
default_value: None,
is_variadic: true,
is_reference: false,
closure_this_type: None,
},
ParameterInfo {
name: "$out".to_string(),
is_required: true,
type_hint: Some(PhpType::parse("array")),
native_type_hint: Some(PhpType::parse("array")),
description: None,
default_value: None,
is_variadic: false,
is_reference: true,
closure_this_type: None,
},
],
..MethodInfo::virtual_method("test", None)
};
let result = format_params(&method, &HashMap::new(), &None);
assert_eq!(result, "string ...$items, array &$out");
}
#[test]
fn format_return_type_with_native() {
let method = MethodInfo {
native_return_type: Some(PhpType::parse("string")),
return_type: Some(PhpType::parse("string")),
..MethodInfo::virtual_method("test", Some("string"))
};
assert_eq!(
format_return_type(&method, &HashMap::new(), &None),
": string"
);
}
#[test]
fn format_return_type_void() {
let method = MethodInfo {
native_return_type: Some(PhpType::parse("void")),
..MethodInfo::virtual_method("test", Some("void"))
};
assert_eq!(
format_return_type(&method, &HashMap::new(), &None),
": void"
);
}
#[test]
fn format_return_type_none() {
let method = MethodInfo {
native_return_type: None,
return_type: None,
..MethodInfo::virtual_method("test", None)
};
assert_eq!(format_return_type(&method, &HashMap::new(), &None), "");
}
#[test]
fn detect_indent_from_class_body() {
let content = "<?php\nclass Foo {\n public function bar() {}\n}\n";
let class = ClassInfo {
name: "Foo".to_string(),
start_offset: content.find('{').unwrap() as u32,
end_offset: content.rfind('}').unwrap() as u32 + 1,
..Default::default()
};
assert_eq!(detect_class_indent(content, &class), " ");
}
#[test]
fn detect_indent_tabs() {
let content = "<?php\nclass Foo {\n\tpublic function bar() {}\n}\n";
let class = ClassInfo {
name: "Foo".to_string(),
start_offset: content.find('{').unwrap() as u32,
end_offset: content.rfind('}').unwrap() as u32 + 1,
..Default::default()
};
assert_eq!(detect_class_indent(content, &class), "\t");
}
#[test]
fn collects_interface_methods() {
let interface = ClassInfo {
kind: ClassLikeKind::Interface,
name: "Renderable".to_string(),
methods: vec![MethodInfo::virtual_method("render", Some("string"))].into(),
..Default::default()
};
let class = ClassInfo {
kind: ClassLikeKind::Class,
name: "Page".to_string(),
interfaces: vec!["Renderable".to_string()],
methods: Default::default(),
..Default::default()
};
let loader = |name: &str| -> Option<Arc<ClassInfo>> {
if name == "Renderable" {
Some(Arc::new(interface.clone()))
} else {
None
}
};
let missing = collect_missing_methods(&class, &loader);
assert_eq!(missing.len(), 1);
assert_eq!(missing[0].name, "render");
}
#[test]
fn skips_already_implemented_methods() {
let interface = ClassInfo {
kind: ClassLikeKind::Interface,
name: "Renderable".to_string(),
methods: vec![MethodInfo::virtual_method("render", Some("string"))].into(),
..Default::default()
};
let class = ClassInfo {
kind: ClassLikeKind::Class,
name: "Page".to_string(),
interfaces: vec!["Renderable".to_string()],
methods: vec![MethodInfo::virtual_method("render", Some("string"))].into(),
..Default::default()
};
let loader = |name: &str| -> Option<Arc<ClassInfo>> {
if name == "Renderable" {
Some(Arc::new(interface.clone()))
} else {
None
}
};
let missing = collect_missing_methods(&class, &loader);
assert!(missing.is_empty());
}
#[test]
fn collects_abstract_parent_methods() {
let parent = ClassInfo {
kind: ClassLikeKind::Class,
name: "AbstractBase".to_string(),
is_abstract: true,
methods: vec![
MethodInfo {
is_abstract: true,
..MethodInfo::virtual_method("doWork", None)
},
MethodInfo::virtual_method("helper", Some("void")),
]
.into(),
..Default::default()
};
let class = ClassInfo {
kind: ClassLikeKind::Class,
name: "ConcreteChild".to_string(),
parent_class: Some("AbstractBase".to_string()),
methods: Default::default(),
..Default::default()
};
let loader = |name: &str| -> Option<Arc<ClassInfo>> {
if name == "AbstractBase" {
Some(Arc::new(parent.clone()))
} else {
None
}
};
let missing = collect_missing_methods(&class, &loader);
assert_eq!(missing.len(), 1);
assert_eq!(missing[0].name, "doWork");
}
#[test]
fn case_insensitive_method_matching() {
let interface = ClassInfo {
kind: ClassLikeKind::Interface,
name: "Renderable".to_string(),
methods: vec![MethodInfo::virtual_method("Render", Some("string"))].into(),
..Default::default()
};
let class = ClassInfo {
kind: ClassLikeKind::Class,
name: "Page".to_string(),
interfaces: vec!["Renderable".to_string()],
methods: vec![MethodInfo::virtual_method("render", Some("string"))].into(),
..Default::default()
};
let loader = |name: &str| -> Option<Arc<ClassInfo>> {
if name == "Renderable" {
Some(Arc::new(interface.clone()))
} else {
None
}
};
let missing = collect_missing_methods(&class, &loader);
assert!(missing.is_empty(), "PHP method names are case-insensitive");
}
#[test]
fn collects_from_parent_interfaces() {
let parent = ClassInfo {
kind: ClassLikeKind::Class,
name: "AbstractBase".to_string(),
is_abstract: true,
interfaces: vec!["Serializable".to_string()],
methods: Default::default(),
..Default::default()
};
let serializable = ClassInfo {
kind: ClassLikeKind::Interface,
name: "Serializable".to_string(),
methods: vec![
MethodInfo::virtual_method("serialize", Some("string")),
MethodInfo::virtual_method("unserialize", None),
]
.into(),
..Default::default()
};
let class = ClassInfo {
kind: ClassLikeKind::Class,
name: "ConcreteChild".to_string(),
parent_class: Some("AbstractBase".to_string()),
methods: Default::default(),
..Default::default()
};
let loader = |name: &str| -> Option<Arc<ClassInfo>> {
match name {
"AbstractBase" => Some(Arc::new(parent.clone())),
"Serializable" => Some(Arc::new(serializable.clone())),
_ => None,
}
};
let missing = collect_missing_methods(&class, &loader);
assert_eq!(missing.len(), 2);
let names: Vec<&str> = missing.iter().map(|m| m.name.as_str()).collect();
assert!(names.contains(&"serialize"));
assert!(names.contains(&"unserialize"));
}
#[test]
fn stub_includes_return_type() {
let methods = vec![MethodInfo {
native_return_type: Some(PhpType::parse("string")),
visibility: Visibility::Public,
..MethodInfo::virtual_method("render", Some("string"))
}];
let content = "<?php\nclass Foo {\n \n}\n";
let class = ClassInfo {
name: "Foo".to_string(),
start_offset: content.find('{').unwrap() as u32,
end_offset: content.rfind('}').unwrap() as u32 + 1,
..Default::default()
};
let result = build_method_stubs(&methods, &HashMap::new(), &None, content, &class);
assert!(result.contains("public function render(): string"));
}
#[test]
fn stub_preserves_static_modifier() {
let methods = vec![MethodInfo {
is_static: true,
native_return_type: Some(PhpType::parse("void")),
visibility: Visibility::Public,
..MethodInfo::virtual_method("init", Some("void"))
}];
let content = "<?php\nclass Foo {\n \n}\n";
let class = ClassInfo {
name: "Foo".to_string(),
start_offset: content.find('{').unwrap() as u32,
end_offset: content.rfind('}').unwrap() as u32 + 1,
..Default::default()
};
let result = build_method_stubs(&methods, &HashMap::new(), &None, content, &class);
assert!(result.contains("public static function init(): void"));
}
#[test]
fn shorten_type_generic_with_nested_union() {
let mut use_map = HashMap::new();
use_map.insert("User".to_string(), "App\\Models\\User".to_string());
let ns = None;
assert_eq!(
shorten_type("Collection<App\\Models\\User|null>", &use_map, &ns),
"Collection<User|null>"
);
}
#[test]
fn stub_keeps_protected_visibility() {
let methods = vec![MethodInfo {
visibility: Visibility::Protected,
..MethodInfo::virtual_method("doWork", None)
}];
let content = "<?php\nclass Foo {\n \n}\n";
let class = ClassInfo {
name: "Foo".to_string(),
start_offset: content.find('{').unwrap() as u32,
end_offset: content.rfind('}').unwrap() as u32 + 1,
..Default::default()
};
let result = build_method_stubs(&methods, &HashMap::new(), &None, content, &class);
assert!(result.contains("protected function doWork()"));
}
#[test]
fn stub_promotes_private_to_public() {
let methods = vec![MethodInfo {
visibility: Visibility::Private,
..MethodInfo::virtual_method("doWork", None)
}];
let content = "<?php\nclass Foo {\n \n}\n";
let class = ClassInfo {
name: "Foo".to_string(),
start_offset: content.find('{').unwrap() as u32,
end_offset: content.rfind('}').unwrap() as u32 + 1,
..Default::default()
};
let result = build_method_stubs(&methods, &HashMap::new(), &None, content, &class);
assert!(result.contains("public function doWork()"));
}
#[test]
fn stub_with_parameters_and_defaults() {
let methods = vec![MethodInfo {
parameters: vec![
ParameterInfo {
name: "$name".to_string(),
is_required: true,
type_hint: Some(PhpType::parse("string")),
native_type_hint: Some(PhpType::parse("string")),
description: None,
default_value: None,
is_variadic: false,
is_reference: false,
closure_this_type: None,
},
ParameterInfo {
name: "$options".to_string(),
is_required: false,
type_hint: Some(PhpType::parse("array")),
native_type_hint: Some(PhpType::parse("array")),
description: None,
default_value: Some("[]".to_string()),
is_variadic: false,
is_reference: false,
closure_this_type: None,
},
],
native_return_type: Some(PhpType::parse("void")),
visibility: Visibility::Public,
..MethodInfo::virtual_method("process", Some("void"))
}];
let content = "<?php\nclass Foo {\n \n}\n";
let class = ClassInfo {
name: "Foo".to_string(),
start_offset: content.find('{').unwrap() as u32,
end_offset: content.rfind('}').unwrap() as u32 + 1,
..Default::default()
};
let result = build_method_stubs(&methods, &HashMap::new(), &None, content, &class);
assert!(
result.contains("public function process(string $name, array $options = []): void")
);
}
}