1use std::collections::HashMap;
5
6use php_ast::{ClassMemberKind, EnumMemberKind, NamespaceBody, Param, Stmt, StmtKind, TypeHint};
7use tower_lsp_server::ls_types::{
8 CodeAction, CodeActionKind, CodeActionOrCommand, Position, Range, TextEdit, Uri, WorkspaceEdit,
9};
10
11use crate::document::ast::{ParsedDoc, SourceView, format_type_hint};
12use crate::lang::docblock::{Docblock, parse_docblock};
13
14pub fn update_phpdoc_actions(uri: &Uri, doc: &ParsedDoc, range: Range) -> Vec<CodeActionOrCommand> {
18 let sv = doc.view();
19 let mut out = Vec::new();
20 collect_stmts(&doc.program().stmts, uri, sv, range, &mut out);
21 out
22}
23
24fn collect_stmts<'a>(
25 stmts: &[Stmt<'a, 'a>],
26 uri: &Uri,
27 sv: SourceView<'_>,
28 range: Range,
29 out: &mut Vec<CodeActionOrCommand>,
30) {
31 for stmt in stmts {
32 match &stmt.kind {
33 StmtKind::Function(f) => {
34 if line_in_range(sv.position_of(stmt.span.start).line, range)
35 && let Some(dc) = &f.doc_comment
36 {
37 maybe_push(uri, sv, dc, &f.params, f.return_type.as_ref(), out);
38 }
39 }
40 StmtKind::Class(c) => {
41 for member in c.body.members.iter() {
42 if let ClassMemberKind::Method(m) = &member.kind
43 && line_in_range(sv.position_of(member.span.start).line, range)
44 && let Some(dc) = &m.doc_comment
45 {
46 maybe_push(uri, sv, dc, &m.params, m.return_type.as_ref(), out);
47 }
48 }
49 }
50 StmtKind::Trait(t) => {
51 for member in t.body.members.iter() {
52 if let ClassMemberKind::Method(m) = &member.kind
53 && line_in_range(sv.position_of(member.span.start).line, range)
54 && let Some(dc) = &m.doc_comment
55 {
56 maybe_push(uri, sv, dc, &m.params, m.return_type.as_ref(), out);
57 }
58 }
59 }
60 StmtKind::Enum(e) => {
61 for member in e.body.members.iter() {
62 if let EnumMemberKind::Method(m) = &member.kind
63 && line_in_range(sv.position_of(member.span.start).line, range)
64 && let Some(dc) = &m.doc_comment
65 {
66 maybe_push(uri, sv, dc, &m.params, m.return_type.as_ref(), out);
67 }
68 }
69 }
70 StmtKind::Namespace(ns) => {
71 if let NamespaceBody::Braced(inner) = &ns.body {
72 collect_stmts(&inner.stmts, uri, sv, range, out);
73 }
74 }
75 _ => {}
76 }
77 }
78}
79
80fn maybe_push<'a>(
81 uri: &Uri,
82 sv: SourceView<'_>,
83 doc_comment: &php_ast::Comment<'a>,
84 params: &[Param<'a, 'a>],
85 return_type: Option<&TypeHint<'a, 'a>>,
86 out: &mut Vec<CodeActionOrCommand>,
87) {
88 let parsed = parse_docblock(doc_comment.text);
89
90 if !needs_update(&parsed, params, return_type) {
91 return;
92 }
93
94 let source = sv.source();
95 let doc_start_line = sv.position_of(doc_comment.span.start).line;
96 let doc_end_line = sv.position_of(doc_comment.span.end.saturating_sub(1)).line;
97
98 let indent = extract_indent(source, doc_start_line);
99 let new_text = generate_updated_docblock(&indent, &parsed, params, return_type);
100
101 let edit_range = Range {
102 start: Position {
103 line: doc_start_line,
104 character: 0,
105 },
106 end: Position {
107 line: doc_end_line + 1,
108 character: 0,
109 },
110 };
111
112 let mut changes = HashMap::new();
113 changes.insert(
114 uri.clone(),
115 vec![TextEdit {
116 range: edit_range,
117 new_text,
118 }],
119 );
120
121 out.push(CodeActionOrCommand::CodeAction(CodeAction {
122 title: "Update PHPDoc to match signature".to_string(),
123 kind: Some(CodeActionKind::REFACTOR),
124 edit: Some(WorkspaceEdit {
125 changes: Some(changes),
126 ..Default::default()
127 }),
128 ..Default::default()
129 }));
130}
131
132fn needs_update<'a>(
139 parsed: &Docblock,
140 params: &[Param<'a, 'a>],
141 return_type: Option<&TypeHint<'a, 'a>>,
142) -> bool {
143 if parsed.is_inherit_doc {
144 return false;
145 }
146 if parsed.params.len() != params.len() {
147 return true;
148 }
149 for (doc_p, actual_p) in parsed.params.iter().zip(params.iter()) {
150 let doc_name = doc_p.name.trim_start_matches('$');
151 let actual_name = actual_p.name.or_error();
152 if doc_name != actual_name {
153 return true;
154 }
155 }
156 if let Some(ret) = return_type
159 && format_type_hint(ret) != "void"
160 && parsed.return_type.is_none()
161 {
162 return true;
163 }
164 false
165}
166
167fn extract_indent(source: &str, line: u32) -> String {
168 source
169 .lines()
170 .nth(line as usize)
171 .map(|l| {
172 let n = l.len() - l.trim_start().len();
173 l[..n].to_string()
174 })
175 .unwrap_or_default()
176}
177
178fn generate_updated_docblock<'a>(
179 indent: &str,
180 parsed: &Docblock,
181 params: &[Param<'a, 'a>],
182 return_type: Option<&TypeHint<'a, 'a>>,
183) -> String {
184 let mut lines: Vec<String> = vec![format!("{indent}/**")];
185
186 if !parsed.description.is_empty() {
188 for desc_line in parsed.description.lines() {
189 let trimmed = desc_line.trim();
190 if trimmed.is_empty() {
191 lines.push(format!("{indent} *"));
192 } else {
193 lines.push(format!("{indent} * {trimmed}"));
194 }
195 }
196 if !params.is_empty() || return_type.is_some() || parsed.return_type.is_some() {
197 lines.push(format!("{indent} *"));
198 }
199 }
200
201 for param in params {
203 let actual_name = param.name.or_error();
204 let type_hint = param
205 .type_hint
206 .as_ref()
207 .map(|t| format_type_hint(t))
208 .or_else(|| {
209 parsed
211 .params
212 .iter()
213 .find(|p| p.name.trim_start_matches('$') == actual_name)
214 .filter(|p| !p.type_hint.is_empty() && p.type_hint != "mixed")
215 .map(|p| p.type_hint.clone())
216 })
217 .unwrap_or_else(|| "mixed".to_string());
218
219 let desc = parsed
220 .params
221 .iter()
222 .find(|p| p.name.trim_start_matches('$') == actual_name)
223 .map(|p| p.description.as_str())
224 .unwrap_or("");
225
226 if desc.is_empty() {
227 lines.push(format!("{indent} * @param {type_hint} ${actual_name}"));
228 } else {
229 lines.push(format!(
230 "{indent} * @param {type_hint} ${actual_name} {desc}"
231 ));
232 }
233 }
234
235 if let Some(ret) = return_type {
238 let ret_type = format_type_hint(ret);
239 if ret_type != "void" {
240 let desc = parsed
241 .return_type
242 .as_ref()
243 .map(|r| r.description.as_str())
244 .unwrap_or("");
245 if desc.is_empty() {
246 lines.push(format!("{indent} * @return {ret_type}"));
247 } else {
248 lines.push(format!("{indent} * @return {ret_type} {desc}"));
249 }
250 }
251 } else if let Some(doc_ret) = &parsed.return_type {
252 let desc = doc_ret.description.as_str();
253 if desc.is_empty() {
254 lines.push(format!("{indent} * @return {}", doc_ret.type_hint));
255 } else {
256 lines.push(format!("{indent} * @return {} {desc}", doc_ret.type_hint));
257 }
258 }
259
260 for t in &parsed.throws {
262 if t.description.is_empty() {
263 lines.push(format!("{indent} * @throws {}", t.class));
264 } else {
265 lines.push(format!("{indent} * @throws {} {}", t.class, t.description));
266 }
267 }
268
269 for s in &parsed.see {
271 lines.push(format!("{indent} * @see {s}"));
272 }
273
274 lines.push(format!("{indent} */"));
275 lines.join("\n") + "\n"
276}
277
278fn line_in_range(line: u32, range: Range) -> bool {
279 line >= range.start.line && line <= range.end.line
280}