use std::collections::HashMap;
use php_ast::{ClassMemberKind, ExprKind, NamespaceBody, Stmt, StmtKind, Visibility};
use tower_lsp::lsp_types::{
CodeAction, CodeActionKind, CodeActionOrCommand, Range, TextEdit, Url, WorkspaceEdit,
};
use crate::ast::{ParsedDoc, SourceView};
pub fn promote_constructor_actions(
_source: &str,
doc: &ParsedDoc,
range: Range,
uri: &Url,
) -> Vec<CodeActionOrCommand> {
let sv = doc.view();
let mut out = Vec::new();
collect_promote(&doc.program().stmts, sv, range, uri, &mut out);
out
}
struct Promotion {
prop_span_start: u32,
prop_span_end: u32,
param_span_start: u32,
visibility: &'static str,
is_readonly: bool,
type_hint: Option<String>,
assign_span_start: u32,
assign_span_end: u32,
}
fn collect_promote<'a>(
stmts: &[Stmt<'a, 'a>],
sv: SourceView<'_>,
range: Range,
uri: &Url,
out: &mut Vec<CodeActionOrCommand>,
) {
for stmt in stmts {
match &stmt.kind {
StmtKind::Class(c) => {
let class_start = sv.position_of(stmt.span.start).line;
let class_end = sv.position_of(stmt.span.end).line;
if class_start > range.end.line || class_end < range.start.line {
continue;
}
let ctor_member = c.body.members.iter().find(|m| {
matches!(&m.kind, ClassMemberKind::Method(method) if method.name == "__construct")
});
let ctor_member = match ctor_member {
Some(m) => m,
None => continue,
};
let ctor = match &ctor_member.kind {
ClassMemberKind::Method(m) => m,
_ => continue,
};
let ctor_body = match &ctor.body {
Some(b) => b,
None => continue,
};
let mut prop_info: HashMap<String, (u32, u32, &'static str, bool, Option<String>)> =
HashMap::new();
for member in c.body.members.iter() {
if let ClassMemberKind::Property(p) = &member.kind
&& !p.is_static
&& p.visibility.is_some()
{
let vis = match &p.visibility {
Some(Visibility::Private) => "private",
Some(Visibility::Protected) => "protected",
_ => "public",
};
let type_hint = p
.type_hint
.as_ref()
.map(|t| crate::ast::format_type_hint(t));
prop_info.insert(
p.name.to_string(),
(
member.span.start,
member.span.end,
vis,
p.is_readonly,
type_hint,
),
);
}
}
if prop_info.is_empty() {
continue;
}
let mut promotions: Vec<Promotion> = Vec::new();
for param in ctor.params.iter() {
if param.visibility.is_some() {
continue;
}
let param_name = param.name;
let (prop_start, prop_end, vis, is_readonly, type_hint) =
match prop_info.get(param_name.to_string().as_str()) {
Some(info) => info.clone(),
None => continue,
};
let assign_span =
find_this_assign(sv.source(), &ctor_body.stmts, ¶m_name.to_string());
let (assign_start, assign_end) = match assign_span {
Some(s) => s,
None => continue,
};
let effective_type_hint = if param.type_hint.is_some() {
None
} else {
type_hint
};
promotions.push(Promotion {
prop_span_start: prop_start,
prop_span_end: prop_end,
param_span_start: param.span.start,
visibility: vis,
is_readonly,
type_hint: effective_type_hint,
assign_span_start: assign_start,
assign_span_end: assign_end,
});
}
if promotions.is_empty() {
continue;
}
let count = promotions.len();
let title = if count == 1 {
"Promote constructor parameter".to_string()
} else {
format!("Promote {count} constructor parameters")
};
if let Some(action) = build_action(sv, uri, &promotions, &title) {
out.push(action);
}
}
StmtKind::Namespace(ns) => {
if let NamespaceBody::Braced(inner) = &ns.body {
collect_promote(&inner.stmts, sv, range, uri, out);
}
}
_ => {}
}
}
}
fn find_this_assign(source: &str, stmts: &[Stmt<'_, '_>], param_name: &str) -> Option<(u32, u32)> {
for stmt in stmts {
if let StmtKind::Expression(expr) = &stmt.kind
&& let ExprKind::Assign(assign) = &expr.kind
{
if let ExprKind::PropertyAccess(pa) = &assign.target.kind {
let is_this =
matches!(&pa.object.kind, ExprKind::Variable(v) if v.as_str() == "this");
let prop_src = source
.get(pa.property.span.start as usize..pa.property.span.end as usize)
.unwrap_or("");
let rhs_matches =
matches!(&assign.value.kind, ExprKind::Variable(v) if v.as_str() == param_name);
if is_this && prop_src == param_name && rhs_matches {
return Some((stmt.span.start, stmt.span.end));
}
}
}
}
None
}
fn build_action(
sv: SourceView<'_>,
uri: &Url,
promotions: &[Promotion],
title: &str,
) -> Option<CodeActionOrCommand> {
let mut edits: Vec<TextEdit> = Vec::new();
for p in promotions {
let prop_remove_range = whole_line_range(sv, p.prop_span_start, p.prop_span_end);
edits.push(TextEdit {
range: prop_remove_range,
new_text: String::new(),
});
let insert_pos = sv.position_of(p.param_span_start);
let prefix = match (&p.type_hint, p.is_readonly) {
(Some(th), true) => format!("{} {} readonly ", p.visibility, th),
(Some(th), false) => format!("{} {} ", p.visibility, th),
(None, true) => format!("{} readonly ", p.visibility),
(None, false) => format!("{} ", p.visibility),
};
edits.push(TextEdit {
range: Range {
start: insert_pos,
end: insert_pos,
},
new_text: prefix,
});
let assign_remove_range = whole_line_range(sv, p.assign_span_start, p.assign_span_end);
edits.push(TextEdit {
range: assign_remove_range,
new_text: String::new(),
});
}
edits.sort_by(|a, b| {
b.range
.start
.line
.cmp(&a.range.start.line)
.then(b.range.start.character.cmp(&a.range.start.character))
});
let mut changes = HashMap::new();
changes.insert(uri.clone(), edits);
Some(CodeActionOrCommand::CodeAction(CodeAction {
title: title.to_string(),
kind: Some(CodeActionKind::REFACTOR),
edit: Some(WorkspaceEdit {
changes: Some(changes),
..Default::default()
}),
..Default::default()
}))
}
fn whole_line_range(sv: SourceView<'_>, span_start: u32, span_end: u32) -> Range {
let start_off = span_start as usize;
let end_off = (span_end as usize).min(sv.source().len());
let line_start = sv.source()[..start_off]
.rfind('\n')
.map(|i| i + 1)
.unwrap_or(0);
let line_end = if end_off < sv.source().len() && sv.source().as_bytes()[end_off] == b'\n' {
end_off + 1
} else {
sv.source()[end_off..]
.find('\n')
.map(|i| end_off + i + 1)
.unwrap_or(sv.source().len())
};
Range {
start: sv.position_of(line_start as u32),
end: sv.position_of(line_end as u32),
}
}