use std::collections::HashMap;
use php_ast::{ClassMemberKind, EnumMemberKind, NamespaceBody, Param, Stmt, StmtKind, TypeHint};
use tower_lsp_server::ls_types::{
CodeAction, CodeActionKind, CodeActionOrCommand, Position, Range, TextEdit, Uri, WorkspaceEdit,
};
use crate::document::ast::{ParsedDoc, SourceView, format_type_hint};
use crate::lang::docblock::{Docblock, parse_docblock};
pub fn update_phpdoc_actions(uri: &Uri, doc: &ParsedDoc, range: Range) -> Vec<CodeActionOrCommand> {
let sv = doc.view();
let mut out = Vec::new();
collect_stmts(&doc.program().stmts, uri, sv, range, &mut out);
out
}
fn collect_stmts<'a>(
stmts: &[Stmt<'a, 'a>],
uri: &Uri,
sv: SourceView<'_>,
range: Range,
out: &mut Vec<CodeActionOrCommand>,
) {
for stmt in stmts {
match &stmt.kind {
StmtKind::Function(f) => {
if line_in_range(sv.position_of(stmt.span.start).line, range)
&& let Some(dc) = &f.doc_comment
{
maybe_push(uri, sv, dc, &f.params, f.return_type.as_ref(), out);
}
}
StmtKind::Class(c) => {
for member in c.body.members.iter() {
if let ClassMemberKind::Method(m) = &member.kind
&& line_in_range(sv.position_of(member.span.start).line, range)
&& let Some(dc) = &m.doc_comment
{
maybe_push(uri, sv, dc, &m.params, m.return_type.as_ref(), out);
}
}
}
StmtKind::Trait(t) => {
for member in t.body.members.iter() {
if let ClassMemberKind::Method(m) = &member.kind
&& line_in_range(sv.position_of(member.span.start).line, range)
&& let Some(dc) = &m.doc_comment
{
maybe_push(uri, sv, dc, &m.params, m.return_type.as_ref(), out);
}
}
}
StmtKind::Enum(e) => {
for member in e.body.members.iter() {
if let EnumMemberKind::Method(m) = &member.kind
&& line_in_range(sv.position_of(member.span.start).line, range)
&& let Some(dc) = &m.doc_comment
{
maybe_push(uri, sv, dc, &m.params, m.return_type.as_ref(), out);
}
}
}
StmtKind::Namespace(ns) => {
if let NamespaceBody::Braced(inner) = &ns.body {
collect_stmts(&inner.stmts, uri, sv, range, out);
}
}
_ => {}
}
}
}
fn maybe_push<'a>(
uri: &Uri,
sv: SourceView<'_>,
doc_comment: &php_ast::Comment<'a>,
params: &[Param<'a, 'a>],
return_type: Option<&TypeHint<'a, 'a>>,
out: &mut Vec<CodeActionOrCommand>,
) {
let parsed = parse_docblock(doc_comment.text);
if !needs_update(&parsed, params, return_type) {
return;
}
let source = sv.source();
let doc_start_line = sv.position_of(doc_comment.span.start).line;
let doc_end_line = sv.position_of(doc_comment.span.end.saturating_sub(1)).line;
let indent = extract_indent(source, doc_start_line);
let new_text = generate_updated_docblock(&indent, &parsed, params, return_type);
let edit_range = Range {
start: Position {
line: doc_start_line,
character: 0,
},
end: Position {
line: doc_end_line + 1,
character: 0,
},
};
let mut changes = HashMap::new();
changes.insert(
uri.clone(),
vec![TextEdit {
range: edit_range,
new_text,
}],
);
out.push(CodeActionOrCommand::CodeAction(CodeAction {
title: "Update PHPDoc to match signature".to_string(),
kind: Some(CodeActionKind::REFACTOR),
edit: Some(WorkspaceEdit {
changes: Some(changes),
..Default::default()
}),
..Default::default()
}));
}
fn needs_update<'a>(
parsed: &Docblock,
params: &[Param<'a, 'a>],
return_type: Option<&TypeHint<'a, 'a>>,
) -> bool {
if parsed.is_inherit_doc {
return false;
}
if parsed.params.len() != params.len() {
return true;
}
for (doc_p, actual_p) in parsed.params.iter().zip(params.iter()) {
let doc_name = doc_p.name.trim_start_matches('$');
let actual_name = actual_p.name.or_error();
if doc_name != actual_name {
return true;
}
}
if let Some(ret) = return_type
&& format_type_hint(ret) != "void"
&& parsed.return_type.is_none()
{
return true;
}
false
}
fn extract_indent(source: &str, line: u32) -> String {
source
.lines()
.nth(line as usize)
.map(|l| {
let n = l.len() - l.trim_start().len();
l[..n].to_string()
})
.unwrap_or_default()
}
fn generate_updated_docblock<'a>(
indent: &str,
parsed: &Docblock,
params: &[Param<'a, 'a>],
return_type: Option<&TypeHint<'a, 'a>>,
) -> String {
let mut lines: Vec<String> = vec![format!("{indent}/**")];
if !parsed.description.is_empty() {
for desc_line in parsed.description.lines() {
let trimmed = desc_line.trim();
if trimmed.is_empty() {
lines.push(format!("{indent} *"));
} else {
lines.push(format!("{indent} * {trimmed}"));
}
}
if !params.is_empty() || return_type.is_some() || parsed.return_type.is_some() {
lines.push(format!("{indent} *"));
}
}
for param in params {
let actual_name = param.name.or_error();
let type_hint = param
.type_hint
.as_ref()
.map(|t| format_type_hint(t))
.or_else(|| {
parsed
.params
.iter()
.find(|p| p.name.trim_start_matches('$') == actual_name)
.filter(|p| !p.type_hint.is_empty() && p.type_hint != "mixed")
.map(|p| p.type_hint.clone())
})
.unwrap_or_else(|| "mixed".to_string());
let desc = parsed
.params
.iter()
.find(|p| p.name.trim_start_matches('$') == actual_name)
.map(|p| p.description.as_str())
.unwrap_or("");
if desc.is_empty() {
lines.push(format!("{indent} * @param {type_hint} ${actual_name}"));
} else {
lines.push(format!(
"{indent} * @param {type_hint} ${actual_name} {desc}"
));
}
}
if let Some(ret) = return_type {
let ret_type = format_type_hint(ret);
if ret_type != "void" {
let desc = parsed
.return_type
.as_ref()
.map(|r| r.description.as_str())
.unwrap_or("");
if desc.is_empty() {
lines.push(format!("{indent} * @return {ret_type}"));
} else {
lines.push(format!("{indent} * @return {ret_type} {desc}"));
}
}
} else if let Some(doc_ret) = &parsed.return_type {
let desc = doc_ret.description.as_str();
if desc.is_empty() {
lines.push(format!("{indent} * @return {}", doc_ret.type_hint));
} else {
lines.push(format!("{indent} * @return {} {desc}", doc_ret.type_hint));
}
}
for t in &parsed.throws {
if t.description.is_empty() {
lines.push(format!("{indent} * @throws {}", t.class));
} else {
lines.push(format!("{indent} * @throws {} {}", t.class, t.description));
}
}
for s in &parsed.see {
lines.push(format!("{indent} * @see {s}"));
}
lines.push(format!("{indent} */"));
lines.join("\n") + "\n"
}
fn line_in_range(line: u32, range: Range) -> bool {
line >= range.start.line && line <= range.end.line
}