Skip to main content

php_lsp/actions/
update_phpdoc_action.rs

1/// Code action: update an existing PHPDoc to match the current function/method
2/// signature (add missing @param, remove stale @param, rename mismatched @param,
3/// add missing @return).
4use 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
14/// Return "Update PHPDoc to match signature" for every function/method whose
15/// declaration line falls within `range`, already has a docblock, and whose
16/// @param/@return section is out of sync with the actual signature.
17pub 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
132/// Return `true` when the docblock's @param/@return section is out of sync.
133///
134/// Triggers when param count or names differ, or when the signature declares a
135/// return type but the docblock has no @return tag. Type-hint changes for
136/// already-matching params are not compared (the generated block always uses
137/// the signature type, so they are updated unconditionally on the next trigger).
138fn 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    // Missing @return when the signature declares a non-void return type.
157    // void is self-documenting via the type annotation; don't force @return void.
158    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    // Description — preserve verbatim, one `* ` line per input line.
187    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    // @param — one per actual parameter.
202    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                // Fall back to the existing docblock type for the same param name.
210                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    // @return — use signature type if present and non-void; otherwise preserve existing.
236    // void is self-documenting; don't clutter the docblock with @return void.
237    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    // @throws — preserved from existing docblock.
261    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    // @see / @link — preserved.
270    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}