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::ast::{ParsedDoc, SourceView, format_type_hint};
15use crate::hover::format_params_str;
16use crate::util::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                // Only consider classes whose declaration overlaps the requested range.
61                let class_start = sv.position_of(stmt.span.start).line;
62                let class_end = sv.position_of(stmt.span.end).line;
63                if class_start > range.end.line || class_end < range.start.line {
64                    continue;
65                }
66
67                // Gather method names already in this class.
68                let existing: HashSet<String> = c
69                    .body
70                    .members
71                    .iter()
72                    .filter_map(|m| {
73                        if let ClassMemberKind::Method(method) = &m.kind {
74                            Some(method.name.to_string())
75                        } else {
76                            None
77                        }
78                    })
79                    .collect();
80
81                let mut missing: Vec<MethodStub> = Vec::new();
82
83                // Interfaces this class implements.
84                for iface in c.implements.iter() {
85                    let iface_name = iface.to_string_repr().into_owned();
86                    let short = fqn_short_name(&iface_name).to_string();
87                    // Try to resolve through `use` imports first; fall back to short-name scan.
88                    let fqn = file_imports.get(&short).cloned();
89                    for stub in abstract_methods_of(&short, fqn.as_deref(), all_docs) {
90                        if !existing.contains(&stub.name) {
91                            missing.push(stub);
92                        }
93                    }
94                }
95
96                // Abstract parent class (if any).
97                if let Some(parent) = &c.extends {
98                    let parent_name = parent.to_string_repr().into_owned();
99                    let short = fqn_short_name(&parent_name).to_string();
100                    let fqn = file_imports.get(&short).cloned();
101                    for stub in abstract_methods_of(&short, fqn.as_deref(), all_docs) {
102                        if !existing.contains(&stub.name) {
103                            missing.push(stub);
104                        }
105                    }
106                }
107
108                // Deduplicate by method name (multiple interfaces may declare the same method).
109                {
110                    let mut seen = HashSet::new();
111                    missing.retain(|s| seen.insert(s.name.clone()));
112                }
113
114                if missing.is_empty() {
115                    continue;
116                }
117
118                let mut stub_text = generate_stub_text(&missing);
119                // Insert just before the closing `}` of the class.
120                let closing_pos = sv.position_of(stmt.span.end.saturating_sub(1));
121                let insert_pos = closing_pos;
122                // For single-line classes `class Foo {}` the `}` is not at column 0,
123                // so we need a leading newline to avoid the stub running onto the
124                // opening brace of the class.
125                if closing_pos.character > 0 {
126                    stub_text = format!("\n{stub_text}");
127                }
128                let edit = TextEdit {
129                    range: Range {
130                        start: insert_pos,
131                        end: insert_pos,
132                    },
133                    new_text: stub_text,
134                };
135                let mut changes = HashMap::new();
136                changes.insert(uri.clone(), vec![edit]);
137
138                let n = missing.len();
139                let title = if n == 1 {
140                    "Implement missing method".to_string()
141                } else {
142                    format!("Implement {n} missing methods")
143                };
144                out.push(CodeActionOrCommand::CodeAction(CodeAction {
145                    title,
146                    kind: Some(CodeActionKind::QUICKFIX),
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.
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
209    for stmt in stmts {
210        match &stmt.kind {
211            StmtKind::Interface(i) if i.name == short => {
212                // Verify the namespace matches.
213                let declared_fqn = if current_ns.is_empty() {
214                    i.name.to_string()
215                } else {
216                    format!("{}\\{}", current_ns, &i.name.to_string())
217                };
218                if fqn_eq(fqn, &declared_fqn) {
219                    let stubs = i
220                        .body
221                        .members
222                        .iter()
223                        .filter_map(|m| {
224                            if let ClassMemberKind::Method(method) = &m.kind {
225                                Some(MethodStub {
226                                    name: method.name.to_string(),
227                                    visibility: "public",
228                                    is_static: method.is_static,
229                                    params: format_params_str(&method.params),
230                                    return_type: method
231                                        .return_type
232                                        .as_ref()
233                                        .map(|t| format_type_hint(t)),
234                                })
235                            } else {
236                                None
237                            }
238                        })
239                        .collect();
240                    return Some(stubs);
241                }
242            }
243            StmtKind::Class(c)
244                if c.name.as_ref().map(|n| n.to_string()) == Some(short.to_string())
245                    && c.modifiers.is_abstract =>
246            {
247                let declared_fqn = if current_ns.is_empty() {
248                    short.to_string()
249                } else {
250                    format!("{}\\{}", current_ns, short)
251                };
252                if fqn_eq(fqn, &declared_fqn) {
253                    let stubs = c
254                        .body
255                        .members
256                        .iter()
257                        .filter_map(|m| {
258                            if let ClassMemberKind::Method(method) = &m.kind {
259                                if method.is_abstract {
260                                    Some(MethodStub {
261                                        name: method.name.to_string(),
262                                        visibility: visibility_str(method.visibility.as_ref()),
263                                        is_static: method.is_static,
264                                        params: format_params_str(&method.params),
265                                        return_type: method
266                                            .return_type
267                                            .as_ref()
268                                            .map(|t| format_type_hint(t)),
269                                    })
270                                } else {
271                                    None
272                                }
273                            } else {
274                                None
275                            }
276                        })
277                        .collect();
278                    return Some(stubs);
279                }
280            }
281            StmtKind::Namespace(ns) => {
282                if let NamespaceBody::Braced(inner) = &ns.body {
283                    let ns_name = ns.name.as_ref().map(|n| n.to_string_repr().into_owned());
284                    let child_ns = match &ns_name {
285                        Some(n) if !current_ns.is_empty() => format!("{}\\{}", current_ns, n),
286                        Some(n) => n.clone(),
287                        None => current_ns.to_string(),
288                    };
289                    if let Some(stubs) = collect_abstract_methods_fqn(&inner.stmts, fqn, &child_ns)
290                    {
291                        return Some(stubs);
292                    }
293                }
294            }
295            _ => {}
296        }
297    }
298    None
299}
300
301/// Compare two FQNs ignoring a leading backslash.
302fn fqn_eq(a: &str, b: &str) -> bool {
303    a.trim_start_matches('\\') == b.trim_start_matches('\\')
304}
305
306fn collect_abstract_methods(stmts: &[Stmt<'_, '_>], name: &str) -> Option<Vec<MethodStub>> {
307    for stmt in stmts {
308        match &stmt.kind {
309            StmtKind::Interface(i) if i.name == name => {
310                let stubs = i
311                    .body
312                    .members
313                    .iter()
314                    .filter_map(|m| {
315                        if let ClassMemberKind::Method(method) = &m.kind {
316                            Some(MethodStub {
317                                name: method.name.to_string(),
318                                visibility: "public",
319                                is_static: method.is_static,
320                                params: format_params_str(&method.params),
321                                return_type: method
322                                    .return_type
323                                    .as_ref()
324                                    .map(|t| format_type_hint(t)),
325                            })
326                        } else {
327                            None
328                        }
329                    })
330                    .collect();
331                return Some(stubs);
332            }
333            StmtKind::Class(c)
334                if c.name.as_ref().map(|n| n.to_string()) == Some(name.to_string())
335                    && c.modifiers.is_abstract =>
336            {
337                let stubs = c
338                    .body
339                    .members
340                    .iter()
341                    .filter_map(|m| {
342                        if let ClassMemberKind::Method(method) = &m.kind {
343                            if method.is_abstract {
344                                Some(MethodStub {
345                                    name: method.name.to_string(),
346                                    visibility: visibility_str(method.visibility.as_ref()),
347                                    is_static: method.is_static,
348                                    params: format_params_str(&method.params),
349                                    return_type: method
350                                        .return_type
351                                        .as_ref()
352                                        .map(|t| format_type_hint(t)),
353                                })
354                            } else {
355                                None
356                            }
357                        } else {
358                            None
359                        }
360                    })
361                    .collect();
362                return Some(stubs);
363            }
364            StmtKind::Namespace(ns) => {
365                if let NamespaceBody::Braced(inner) = &ns.body
366                    && let Some(stubs) = collect_abstract_methods(&inner.stmts, name)
367                {
368                    return Some(stubs);
369                }
370            }
371            _ => {}
372        }
373    }
374    None
375}
376
377fn visibility_str(v: Option<&Visibility>) -> &'static str {
378    match v {
379        Some(Visibility::Protected) => "protected",
380        Some(Visibility::Private) => "private",
381        _ => "public",
382    }
383}
384
385fn generate_stub_text(stubs: &[MethodStub]) -> String {
386    let mut text = String::new();
387    for stub in stubs {
388        let static_kw = if stub.is_static { "static " } else { "" };
389        let ret = match &stub.return_type {
390            Some(t) => format!(": {t}"),
391            None => String::new(),
392        };
393        text.push_str(&format!(
394            "    {} {}function {}({}){ret}\n    {{\n        throw new \\RuntimeException('Not implemented');\n    }}\n\n",
395            stub.visibility, static_kw, stub.name, stub.params
396        ));
397    }
398    text
399}
400
401#[cfg(test)]
402mod tests {
403    use super::*;
404    use tower_lsp::lsp_types::Position;
405
406    fn uri(path: &str) -> Url {
407        Url::parse(&format!("file://{path}")).unwrap()
408    }
409
410    fn doc(src: &str) -> (Url, Arc<ParsedDoc>) {
411        (uri("/a.php"), Arc::new(ParsedDoc::parse(src.to_string())))
412    }
413
414    fn full_range() -> Range {
415        Range {
416            start: Position {
417                line: 0,
418                character: 0,
419            },
420            end: Position {
421                line: u32::MAX,
422                character: u32::MAX,
423            },
424        }
425    }
426
427    #[test]
428    fn resolves_interface_through_use_import() {
429        // The interface is declared in a braced namespace; the class file imports it via `use`.
430        let iface_src = "<?php\nnamespace App\\Contracts {\ninterface Renderable {\n    public function render(): string;\n}\n}";
431        let class_src =
432            "<?php\nuse App\\Contracts\\Renderable;\nclass View implements Renderable {\n}";
433        let all_docs = vec![
434            (
435                uri("/contracts/Renderable.php"),
436                Arc::new(ParsedDoc::parse(iface_src.to_string())),
437            ),
438            (
439                uri("/View.php"),
440                Arc::new(ParsedDoc::parse(class_src.to_string())),
441            ),
442        ];
443        let class_doc = ParsedDoc::parse(class_src.to_string());
444        let actions = implement_missing_actions(
445            class_src,
446            &class_doc,
447            &all_docs,
448            full_range(),
449            &uri("/View.php"),
450            &HashMap::new(),
451        );
452        assert!(
453            !actions.is_empty(),
454            "expected action when interface is resolved through use import"
455        );
456        if let CodeActionOrCommand::CodeAction(action) = &actions[0] {
457            let changes = action.edit.as_ref().unwrap().changes.as_ref().unwrap();
458            let edits = changes.values().next().unwrap();
459            assert!(
460                edits[0].new_text.contains("function render()"),
461                "stub should contain 'function render()', got: {}",
462                edits[0].new_text
463            );
464        } else {
465            panic!("expected CodeAction");
466        }
467    }
468
469    #[test]
470    fn use_import_resolution_picks_correct_interface_over_same_short_name() {
471        // Two interfaces share the short name `Logger`; only the imported one's
472        // methods should be stubbed.  Both use braced-namespace syntax so the
473        // AST traversal can track the namespace prefix.
474        let wrong_iface = "<?php\nnamespace Other {\ninterface Logger {\n    public function wrong(): void;\n}\n}";
475        let right_iface = "<?php\nnamespace App\\Logging {\ninterface Logger {\n    public function log(string $msg): void;\n}\n}";
476        let class_src = "<?php\nuse App\\Logging\\Logger;\nclass FileLogger implements Logger {\n}";
477        let all_docs = vec![
478            (
479                uri("/other/Logger.php"),
480                Arc::new(ParsedDoc::parse(wrong_iface.to_string())),
481            ),
482            (
483                uri("/logging/Logger.php"),
484                Arc::new(ParsedDoc::parse(right_iface.to_string())),
485            ),
486            (
487                uri("/FileLogger.php"),
488                Arc::new(ParsedDoc::parse(class_src.to_string())),
489            ),
490        ];
491        let class_doc = ParsedDoc::parse(class_src.to_string());
492        let imports = HashMap::from([("Logger".to_string(), "App\\Logging\\Logger".to_string())]);
493        let actions = implement_missing_actions(
494            class_src,
495            &class_doc,
496            &all_docs,
497            full_range(),
498            &uri("/FileLogger.php"),
499            &imports,
500        );
501        assert!(!actions.is_empty(), "expected action");
502        if let CodeActionOrCommand::CodeAction(action) = &actions[0] {
503            let changes = action.edit.as_ref().unwrap().changes.as_ref().unwrap();
504            let edits = changes.values().next().unwrap();
505            assert!(
506                edits[0].new_text.contains("function log("),
507                "should stub the correct Logger's 'log' method, got: {}",
508                edits[0].new_text
509            );
510            assert!(
511                !edits[0].new_text.contains("function wrong("),
512                "should NOT stub the wrong Logger's 'wrong' method"
513            );
514        } else {
515            panic!("expected CodeAction");
516        }
517    }
518}