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