use std::collections::{HashMap, HashSet};
use php_ast::{ClassMemberKind, NamespaceBody, Stmt, StmtKind};
use tower_lsp::lsp_types::{
CodeAction, CodeActionKind, CodeActionOrCommand, Position, Range, TextEdit, Url, WorkspaceEdit,
};
use crate::ast::{ParsedDoc, format_type_hint, offset_to_position};
pub fn generate_constructor_actions(
source: &str,
doc: &ParsedDoc,
range: Range,
uri: &Url,
) -> Vec<CodeActionOrCommand> {
let mut out = Vec::new();
collect_constructor(&doc.program().stmts, source, range, uri, &mut out);
out
}
pub fn generate_getters_setters_actions(
source: &str,
doc: &ParsedDoc,
range: Range,
uri: &Url,
) -> Vec<CodeActionOrCommand> {
let mut out = Vec::new();
collect_getters_setters(&doc.program().stmts, source, range, uri, &mut out);
out
}
struct Prop {
name: String,
type_str: Option<String>,
}
fn collect_constructor<'a>(
stmts: &[Stmt<'a, 'a>],
source: &str,
range: Range,
uri: &Url,
out: &mut Vec<CodeActionOrCommand>,
) {
for stmt in stmts {
match &stmt.kind {
StmtKind::Class(c) => {
let class_start = offset_to_position(source, stmt.span.start).line;
let class_end = offset_to_position(source, stmt.span.end).line;
if class_start > range.end.line || class_end < range.start.line {
continue;
}
let has_ctor = c.members.iter().any(|m| {
matches!(&m.kind, ClassMemberKind::Method(method) if method.name == "__construct")
});
if has_ctor {
continue;
}
let props = non_static_props(c);
if props.is_empty() {
continue;
}
let text = generate_constructor_text(&props);
push_action(
source,
stmt.span.end,
text,
"Generate constructor",
uri,
out,
);
}
StmtKind::Namespace(ns) => {
if let NamespaceBody::Braced(inner) = &ns.body {
collect_constructor(inner, source, range, uri, out);
}
}
_ => {}
}
}
}
fn collect_getters_setters<'a>(
stmts: &[Stmt<'a, 'a>],
source: &str,
range: Range,
uri: &Url,
out: &mut Vec<CodeActionOrCommand>,
) {
for stmt in stmts {
match &stmt.kind {
StmtKind::Class(c) => {
let class_start = offset_to_position(source, stmt.span.start).line;
let class_end = offset_to_position(source, stmt.span.end).line;
if class_start > range.end.line || class_end < range.start.line {
continue;
}
let existing: HashSet<String> = c
.members
.iter()
.filter_map(|m| {
if let ClassMemberKind::Method(method) = &m.kind {
Some(method.name.to_string())
} else {
None
}
})
.collect();
let props = non_static_props(c);
if props.is_empty() {
continue;
}
let mut text = String::new();
let mut count = 0usize;
for p in &props {
let cap = capitalize(&p.name);
let getter = format!("get{cap}");
if !existing.contains(&getter) {
let ret = p
.type_str
.as_deref()
.map(|t| format!(": {t}"))
.unwrap_or_default();
text.push_str(&format!(
" public function {getter}(){ret}\n {{\n return $this->{};\n }}\n\n",
p.name
));
count += 1;
}
let setter = format!("set{cap}");
if !existing.contains(&setter) {
let param = match &p.type_str {
Some(t) => format!("{t} ${}", p.name),
None => format!("${}", p.name),
};
text.push_str(&format!(
" public function {setter}({param}): void\n {{\n $this->{n} = ${n};\n }}\n\n",
n = p.name
));
count += 1;
}
}
if count == 0 {
continue;
}
let title = if count == 1 {
"Generate getter/setter".to_string()
} else {
format!("Generate {count} getters/setters")
};
push_action(source, stmt.span.end, text, &title, uri, out);
}
StmtKind::Namespace(ns) => {
if let NamespaceBody::Braced(inner) = &ns.body {
collect_getters_setters(inner, source, range, uri, out);
}
}
_ => {}
}
}
}
fn non_static_props(c: &php_ast::ClassDecl<'_, '_>) -> Vec<Prop> {
let mut props: Vec<Prop> = c
.members
.iter()
.filter_map(|m| {
if let ClassMemberKind::Property(p) = &m.kind
&& !p.is_static
{
return Some(Prop {
name: p.name.to_string(),
type_str: p.type_hint.as_ref().map(format_type_hint),
});
}
None
})
.collect();
if let Some(ctor) = c.members.iter().find_map(|m| {
if let ClassMemberKind::Method(method) = &m.kind
&& method.name == "__construct"
{
Some(method)
} else {
None
}
}) {
for p in ctor.params.iter() {
if p.visibility.is_some() {
props.push(Prop {
name: p.name.to_string(),
type_str: p.type_hint.as_ref().map(format_type_hint),
});
}
}
}
props
}
fn generate_constructor_text(props: &[Prop]) -> String {
let mut text = String::from(" public function __construct(\n");
for p in props {
match &p.type_str {
Some(t) => text.push_str(&format!(" {t} ${},\n", p.name)),
None => text.push_str(&format!(" ${},\n", p.name)),
}
}
text.push_str(" ) {\n");
for p in props {
text.push_str(&format!(" $this->{n} = ${n};\n", n = p.name));
}
text.push_str(" }\n\n");
text
}
fn push_action(
source: &str,
class_end_offset: u32,
new_text: String,
title: &str,
uri: &Url,
out: &mut Vec<CodeActionOrCommand>,
) {
let closing_line = offset_to_position(source, class_end_offset.saturating_sub(1)).line;
let pos = Position {
line: closing_line,
character: 0,
};
let mut changes = HashMap::new();
changes.insert(
uri.clone(),
vec![TextEdit {
range: Range {
start: pos,
end: pos,
},
new_text,
}],
);
out.push(CodeActionOrCommand::CodeAction(CodeAction {
title: title.to_string(),
kind: Some(CodeActionKind::REFACTOR),
edit: Some(WorkspaceEdit {
changes: Some(changes),
..Default::default()
}),
..Default::default()
}));
}
fn capitalize(s: &str) -> String {
let mut chars = s.chars();
match chars.next() {
None => String::new(),
Some(c) => c.to_uppercase().to_string() + chars.as_str(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use tower_lsp::lsp_types::Position;
fn uri() -> Url {
Url::parse("file:///test.php").unwrap()
}
fn full_range() -> Range {
Range {
start: Position {
line: 0,
character: 0,
},
end: Position {
line: u32::MAX,
character: u32::MAX,
},
}
}
#[test]
fn generates_constructor_for_class_with_properties() {
let src = "<?php\nclass User {\n private string $name;\n private int $age;\n}";
let doc = ParsedDoc::parse(src.to_string());
let actions = generate_constructor_actions(src, &doc, full_range(), &uri());
assert!(
!actions.is_empty(),
"expected a generate constructor action"
);
if let CodeActionOrCommand::CodeAction(a) = &actions[0] {
let edits = a.edit.as_ref().unwrap().changes.as_ref().unwrap();
let text = &edits.values().next().unwrap()[0].new_text;
assert!(text.contains("__construct"), "should contain __construct");
assert!(text.contains("$this->name = $name"), "should assign name");
assert!(text.contains("$this->age = $age"), "should assign age");
assert!(text.contains("string $name"), "should include type hint");
}
}
#[test]
fn no_constructor_action_when_constructor_exists() {
let src = "<?php\nclass User {\n private string $name;\n public function __construct(string $name) { $this->name = $name; }\n}";
let doc = ParsedDoc::parse(src.to_string());
let actions = generate_constructor_actions(src, &doc, full_range(), &uri());
assert!(
actions.is_empty(),
"no action when constructor already exists"
);
}
#[test]
fn no_constructor_action_for_class_without_properties() {
let src = "<?php\nclass Empty {}";
let doc = ParsedDoc::parse(src.to_string());
let actions = generate_constructor_actions(src, &doc, full_range(), &uri());
assert!(actions.is_empty(), "no action for class with no properties");
}
#[test]
fn generates_getters_and_setters() {
let src = "<?php\nclass User {\n private string $name;\n}";
let doc = ParsedDoc::parse(src.to_string());
let actions = generate_getters_setters_actions(src, &doc, full_range(), &uri());
assert!(!actions.is_empty(), "expected getter/setter action");
if let CodeActionOrCommand::CodeAction(a) = &actions[0] {
let edits = a.edit.as_ref().unwrap().changes.as_ref().unwrap();
let text = &edits.values().next().unwrap()[0].new_text;
assert!(text.contains("getName"), "should contain getter");
assert!(text.contains("setName"), "should contain setter");
assert!(
text.contains("return $this->name"),
"getter should return property"
);
}
}
#[test]
fn skips_existing_getter_setter() {
let src = "<?php\nclass User {\n private string $name;\n public function getName(): string { return $this->name; }\n}";
let doc = ParsedDoc::parse(src.to_string());
let actions = generate_getters_setters_actions(src, &doc, full_range(), &uri());
if let Some(CodeActionOrCommand::CodeAction(a)) = actions.first() {
let edits = a.edit.as_ref().unwrap().changes.as_ref().unwrap();
let text = &edits.values().next().unwrap()[0].new_text;
assert!(
!text.contains("getName"),
"should not regenerate existing getter"
);
assert!(
text.contains("setName"),
"should still generate missing setter"
);
}
}
}