Skip to main content

php_lsp/actions/
implement_action.rs

1/// Code action: "Implement missing methods"
2///
3/// When a class `implements` an interface or `extends` an abstract class,
4/// this action generates stub methods for any abstract/interface methods
5/// that are not yet implemented in the class body.
6use std::collections::{HashMap, HashSet};
7use std::sync::Arc;
8
9use php_ast::{ClassMemberKind, NamespaceBody, Stmt, StmtKind, Visibility};
10use tower_lsp::lsp_types::{
11    CodeAction, CodeActionKind, CodeActionOrCommand, Range, TextEdit, Url, WorkspaceEdit,
12};
13
14use crate::document::ast::{ParsedDoc, SourceView, format_type_hint};
15use crate::hover::format_params_str;
16use crate::text::fqn_short_name;
17
18struct MethodStub {
19    name: String,
20    visibility: &'static str,
21    is_static: bool,
22    params: String,
23    return_type: Option<String>,
24}
25
26pub fn implement_missing_actions(
27    _source: &str,
28    doc: &ParsedDoc,
29    all_docs: &[(Url, Arc<ParsedDoc>)],
30    range: Range,
31    uri: &Url,
32    file_imports: &HashMap<String, String>,
33) -> Vec<CodeActionOrCommand> {
34    let sv = doc.view();
35    let mut actions = Vec::new();
36    collect_actions(
37        &doc.program().stmts,
38        sv,
39        all_docs,
40        file_imports,
41        range,
42        uri,
43        &mut actions,
44    );
45    actions
46}
47
48fn collect_actions(
49    stmts: &[Stmt<'_, '_>],
50    sv: SourceView<'_>,
51    all_docs: &[(Url, Arc<ParsedDoc>)],
52    file_imports: &HashMap<String, String>,
53    range: Range,
54    uri: &Url,
55    out: &mut Vec<CodeActionOrCommand>,
56) {
57    for stmt in stmts {
58        match &stmt.kind {
59            StmtKind::Class(c) => {
60                let class_start = sv.position_of(stmt.span.start).line;
61                let class_end = sv.position_of(stmt.span.end).line;
62                if class_start > range.end.line || class_end < range.start.line {
63                    continue;
64                }
65
66                let existing: HashSet<String> = c
67                    .body
68                    .members
69                    .iter()
70                    .filter_map(|m| {
71                        if let ClassMemberKind::Method(method) = &m.kind {
72                            Some(method.name.to_string())
73                        } else {
74                            None
75                        }
76                    })
77                    .collect();
78
79                let mut missing: Vec<MethodStub> = Vec::new();
80
81                for iface in c.implements.iter() {
82                    let iface_name = iface.to_string_repr().into_owned();
83                    let short = fqn_short_name(&iface_name).to_string();
84                    // Try to resolve through `use` imports first; fall back to short-name scan.
85                    let fqn = file_imports.get(&short).cloned();
86                    for stub in abstract_methods_of(&short, fqn.as_deref(), all_docs) {
87                        if !existing.contains(&stub.name) {
88                            missing.push(stub);
89                        }
90                    }
91                }
92
93                if let Some(parent) = &c.extends {
94                    let parent_name = parent.to_string_repr().into_owned();
95                    let short = fqn_short_name(&parent_name).to_string();
96                    let fqn = file_imports.get(&short).cloned();
97                    for stub in abstract_methods_of(&short, fqn.as_deref(), all_docs) {
98                        if !existing.contains(&stub.name) {
99                            missing.push(stub);
100                        }
101                    }
102                }
103
104                // Deduplicate by method name (multiple interfaces may declare the same method).
105                {
106                    let mut seen = HashSet::new();
107                    missing.retain(|s| seen.insert(s.name.clone()));
108                }
109
110                if missing.is_empty() {
111                    continue;
112                }
113
114                let mut stub_text = generate_stub_text(&missing);
115                let closing_pos = sv.position_of(stmt.span.end.saturating_sub(1));
116                let insert_pos = closing_pos;
117                // For single-line classes `class Foo {}` the `}` is not at column 0,
118                // so we need a leading newline to avoid the stub running onto the
119                // opening brace of the class.
120                if closing_pos.character > 0 {
121                    stub_text = format!("\n{stub_text}");
122                }
123                let edit = TextEdit {
124                    range: Range {
125                        start: insert_pos,
126                        end: insert_pos,
127                    },
128                    new_text: stub_text,
129                };
130                let mut changes = HashMap::new();
131                changes.insert(uri.clone(), vec![edit]);
132
133                let n = missing.len();
134                let title = if n == 1 {
135                    "Implement missing method".to_string()
136                } else {
137                    format!("Implement {n} missing methods")
138                };
139                out.push(CodeActionOrCommand::CodeAction(CodeAction {
140                    title,
141                    kind: Some(CodeActionKind::QUICKFIX),
142                    // The only way to satisfy an interface's abstract methods
143                    // is to implement them — there's no competing quickfix
144                    // for this diagnostic, so editors can safely offer this
145                    // as the auto-apply default (e.g. VS Code's Cmd+.).
146                    is_preferred: Some(true),
147                    edit: Some(WorkspaceEdit {
148                        changes: Some(changes),
149                        ..Default::default()
150                    }),
151                    ..Default::default()
152                }));
153            }
154            StmtKind::Namespace(ns) => {
155                if let NamespaceBody::Braced(inner) = &ns.body {
156                    collect_actions(&inner.stmts, sv, all_docs, file_imports, range, uri, out);
157                }
158            }
159            _ => {}
160        }
161    }
162}
163
164/// Collect abstract/interface methods declared by `name` across all documents.
165///
166/// When `fqn` is provided (resolved from a `use` statement), the search uses
167/// FQN-aware matching only — it looks for a document whose namespace + class
168/// name matches the FQN exactly.  This avoids picking up a different class that
169/// happens to share the same short name in another namespace.
170///
171/// When `fqn` is `None` (no `use` import found), falls back to a plain
172/// short-name scan across all documents, preserving the original behaviour.
173fn abstract_methods_of(
174    name: &str,
175    fqn: Option<&str>,
176    all_docs: &[(Url, Arc<ParsedDoc>)],
177) -> Vec<MethodStub> {
178    if let Some(fqn) = fqn {
179        // FQN-aware pass: only return stubs when the exact namespace matches.
180        // Do NOT fall back to short-name scan to avoid picking the wrong class.
181        for (_, doc) in all_docs {
182            if let Some(stubs) = collect_abstract_methods_fqn(&doc.program().stmts, fqn, "") {
183                return stubs;
184            }
185        }
186        return vec![];
187    }
188
189    // Short-name fallback (no `use` import): scan all docs as before.
190    for (_, doc) in all_docs {
191        if let Some(stubs) = collect_abstract_methods(&doc.program().stmts, name) {
192            return stubs;
193        }
194    }
195    vec![]
196}
197
198/// Like `collect_abstract_methods` but matches the fully-qualified name
199/// `namespace\ClassName` by tracking the current namespace prefix while
200/// recursing into `StmtKind::Namespace` blocks (both braced and unbraced).
201fn collect_abstract_methods_fqn(
202    stmts: &[Stmt<'_, '_>],
203    fqn: &str,
204    current_ns: &str,
205) -> Option<Vec<MethodStub>> {
206    // The expected short name is the last segment of the FQN.
207    let short = fqn_short_name(fqn);
208    // For unbraced namespaces (`namespace Foo;`) the active namespace changes
209    // mid-statement-list; track it mutably as we iterate.
210    let mut active_ns = current_ns.to_string();
211
212    for stmt in stmts {
213        match &stmt.kind {
214            StmtKind::Interface(i) if i.name == short => {
215                // Verify the namespace matches.
216                let declared_fqn = if active_ns.is_empty() {
217                    i.name.to_string()
218                } else {
219                    format!("{}\\{}", active_ns, &i.name.to_string())
220                };
221                if fqn_eq(fqn, &declared_fqn) {
222                    let stubs = i
223                        .body
224                        .members
225                        .iter()
226                        .filter_map(|m| {
227                            if let ClassMemberKind::Method(method) = &m.kind {
228                                Some(MethodStub {
229                                    name: method.name.to_string(),
230                                    visibility: "public",
231                                    is_static: method.is_static,
232                                    params: format_params_str(&method.params),
233                                    return_type: method
234                                        .return_type
235                                        .as_ref()
236                                        .map(|t| format_type_hint(t)),
237                                })
238                            } else {
239                                None
240                            }
241                        })
242                        .collect();
243                    return Some(stubs);
244                }
245            }
246            StmtKind::Class(c)
247                if c.name.as_ref().map(|n| n.to_string()) == Some(short.to_string())
248                    && c.modifiers.is_abstract =>
249            {
250                let declared_fqn = if active_ns.is_empty() {
251                    short.to_string()
252                } else {
253                    format!("{}\\{}", active_ns, short)
254                };
255                if fqn_eq(fqn, &declared_fqn) {
256                    let stubs = c
257                        .body
258                        .members
259                        .iter()
260                        .filter_map(|m| {
261                            if let ClassMemberKind::Method(method) = &m.kind {
262                                if method.is_abstract {
263                                    Some(MethodStub {
264                                        name: method.name.to_string(),
265                                        visibility: visibility_str(method.visibility.as_ref()),
266                                        is_static: method.is_static,
267                                        params: format_params_str(&method.params),
268                                        return_type: method
269                                            .return_type
270                                            .as_ref()
271                                            .map(|t| format_type_hint(t)),
272                                    })
273                                } else {
274                                    None
275                                }
276                            } else {
277                                None
278                            }
279                        })
280                        .collect();
281                    return Some(stubs);
282                }
283            }
284            StmtKind::Namespace(ns) => {
285                let ns_name = ns.name.as_ref().map(|n| n.to_string_repr().into_owned());
286                match &ns.body {
287                    NamespaceBody::Braced(inner) => {
288                        let child_ns = match &ns_name {
289                            Some(n) if !active_ns.is_empty() => {
290                                format!("{}\\{}", active_ns, n)
291                            }
292                            Some(n) => n.clone(),
293                            None => active_ns.clone(),
294                        };
295                        if let Some(stubs) =
296                            collect_abstract_methods_fqn(&inner.stmts, fqn, &child_ns)
297                        {
298                            return Some(stubs);
299                        }
300                    }
301                    NamespaceBody::Simple => {
302                        // Unbraced form: all subsequent statements are in this namespace.
303                        active_ns = ns_name.unwrap_or_default();
304                    }
305                }
306            }
307            _ => {}
308        }
309    }
310    None
311}
312
313/// Compare two FQNs ignoring a leading backslash.
314fn fqn_eq(a: &str, b: &str) -> bool {
315    a.trim_start_matches('\\') == b.trim_start_matches('\\')
316}
317
318fn collect_abstract_methods(stmts: &[Stmt<'_, '_>], name: &str) -> Option<Vec<MethodStub>> {
319    for stmt in stmts {
320        match &stmt.kind {
321            StmtKind::Interface(i) if i.name == name => {
322                let stubs = i
323                    .body
324                    .members
325                    .iter()
326                    .filter_map(|m| {
327                        if let ClassMemberKind::Method(method) = &m.kind {
328                            Some(MethodStub {
329                                name: method.name.to_string(),
330                                visibility: "public",
331                                is_static: method.is_static,
332                                params: format_params_str(&method.params),
333                                return_type: method
334                                    .return_type
335                                    .as_ref()
336                                    .map(|t| format_type_hint(t)),
337                            })
338                        } else {
339                            None
340                        }
341                    })
342                    .collect();
343                return Some(stubs);
344            }
345            StmtKind::Class(c)
346                if c.name.as_ref().map(|n| n.to_string()) == Some(name.to_string())
347                    && c.modifiers.is_abstract =>
348            {
349                let stubs = c
350                    .body
351                    .members
352                    .iter()
353                    .filter_map(|m| {
354                        if let ClassMemberKind::Method(method) = &m.kind {
355                            if method.is_abstract {
356                                Some(MethodStub {
357                                    name: method.name.to_string(),
358                                    visibility: visibility_str(method.visibility.as_ref()),
359                                    is_static: method.is_static,
360                                    params: format_params_str(&method.params),
361                                    return_type: method
362                                        .return_type
363                                        .as_ref()
364                                        .map(|t| format_type_hint(t)),
365                                })
366                            } else {
367                                None
368                            }
369                        } else {
370                            None
371                        }
372                    })
373                    .collect();
374                return Some(stubs);
375            }
376            StmtKind::Namespace(ns) => {
377                if let NamespaceBody::Braced(inner) = &ns.body
378                    && let Some(stubs) = collect_abstract_methods(&inner.stmts, name)
379                {
380                    return Some(stubs);
381                }
382            }
383            _ => {}
384        }
385    }
386    None
387}
388
389fn visibility_str(v: Option<&Visibility>) -> &'static str {
390    match v {
391        Some(Visibility::Protected) => "protected",
392        Some(Visibility::Private) => "private",
393        _ => "public",
394    }
395}
396
397fn generate_stub_text(stubs: &[MethodStub]) -> String {
398    let mut text = String::new();
399    for stub in stubs {
400        let static_kw = if stub.is_static { "static " } else { "" };
401        let ret = match &stub.return_type {
402            Some(t) => format!(": {t}"),
403            None => String::new(),
404        };
405        text.push_str(&format!(
406            "    {} {}function {}({}){ret}\n    {{\n        throw new \\RuntimeException('Not implemented');\n    }}\n\n",
407            stub.visibility, static_kw, stub.name, stub.params
408        ));
409    }
410    text
411}