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