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                    edit: Some(WorkspaceEdit {
143                        changes: Some(changes),
144                        ..Default::default()
145                    }),
146                    ..Default::default()
147                }));
148            }
149            StmtKind::Namespace(ns) => {
150                if let NamespaceBody::Braced(inner) = &ns.body {
151                    collect_actions(&inner.stmts, sv, all_docs, file_imports, range, uri, out);
152                }
153            }
154            _ => {}
155        }
156    }
157}
158
159/// Collect abstract/interface methods declared by `name` across all documents.
160///
161/// When `fqn` is provided (resolved from a `use` statement), the search uses
162/// FQN-aware matching only — it looks for a document whose namespace + class
163/// name matches the FQN exactly.  This avoids picking up a different class that
164/// happens to share the same short name in another namespace.
165///
166/// When `fqn` is `None` (no `use` import found), falls back to a plain
167/// short-name scan across all documents, preserving the original behaviour.
168fn abstract_methods_of(
169    name: &str,
170    fqn: Option<&str>,
171    all_docs: &[(Url, Arc<ParsedDoc>)],
172) -> Vec<MethodStub> {
173    if let Some(fqn) = fqn {
174        // FQN-aware pass: only return stubs when the exact namespace matches.
175        // Do NOT fall back to short-name scan to avoid picking the wrong class.
176        for (_, doc) in all_docs {
177            if let Some(stubs) = collect_abstract_methods_fqn(&doc.program().stmts, fqn, "") {
178                return stubs;
179            }
180        }
181        return vec![];
182    }
183
184    // Short-name fallback (no `use` import): scan all docs as before.
185    for (_, doc) in all_docs {
186        if let Some(stubs) = collect_abstract_methods(&doc.program().stmts, name) {
187            return stubs;
188        }
189    }
190    vec![]
191}
192
193/// Like `collect_abstract_methods` but matches the fully-qualified name
194/// `namespace\ClassName` by tracking the current namespace prefix while
195/// recursing into `StmtKind::Namespace` blocks (both braced and unbraced).
196fn collect_abstract_methods_fqn(
197    stmts: &[Stmt<'_, '_>],
198    fqn: &str,
199    current_ns: &str,
200) -> Option<Vec<MethodStub>> {
201    // The expected short name is the last segment of the FQN.
202    let short = fqn_short_name(fqn);
203    // For unbraced namespaces (`namespace Foo;`) the active namespace changes
204    // mid-statement-list; track it mutably as we iterate.
205    let mut active_ns = current_ns.to_string();
206
207    for stmt in stmts {
208        match &stmt.kind {
209            StmtKind::Interface(i) if i.name == short => {
210                // Verify the namespace matches.
211                let declared_fqn = if active_ns.is_empty() {
212                    i.name.to_string()
213                } else {
214                    format!("{}\\{}", active_ns, &i.name.to_string())
215                };
216                if fqn_eq(fqn, &declared_fqn) {
217                    let stubs = i
218                        .body
219                        .members
220                        .iter()
221                        .filter_map(|m| {
222                            if let ClassMemberKind::Method(method) = &m.kind {
223                                Some(MethodStub {
224                                    name: method.name.to_string(),
225                                    visibility: "public",
226                                    is_static: method.is_static,
227                                    params: format_params_str(&method.params),
228                                    return_type: method
229                                        .return_type
230                                        .as_ref()
231                                        .map(|t| format_type_hint(t)),
232                                })
233                            } else {
234                                None
235                            }
236                        })
237                        .collect();
238                    return Some(stubs);
239                }
240            }
241            StmtKind::Class(c)
242                if c.name.as_ref().map(|n| n.to_string()) == Some(short.to_string())
243                    && c.modifiers.is_abstract =>
244            {
245                let declared_fqn = if active_ns.is_empty() {
246                    short.to_string()
247                } else {
248                    format!("{}\\{}", active_ns, short)
249                };
250                if fqn_eq(fqn, &declared_fqn) {
251                    let stubs = c
252                        .body
253                        .members
254                        .iter()
255                        .filter_map(|m| {
256                            if let ClassMemberKind::Method(method) = &m.kind {
257                                if method.is_abstract {
258                                    Some(MethodStub {
259                                        name: method.name.to_string(),
260                                        visibility: visibility_str(method.visibility.as_ref()),
261                                        is_static: method.is_static,
262                                        params: format_params_str(&method.params),
263                                        return_type: method
264                                            .return_type
265                                            .as_ref()
266                                            .map(|t| format_type_hint(t)),
267                                    })
268                                } else {
269                                    None
270                                }
271                            } else {
272                                None
273                            }
274                        })
275                        .collect();
276                    return Some(stubs);
277                }
278            }
279            StmtKind::Namespace(ns) => {
280                let ns_name = ns.name.as_ref().map(|n| n.to_string_repr().into_owned());
281                match &ns.body {
282                    NamespaceBody::Braced(inner) => {
283                        let child_ns = match &ns_name {
284                            Some(n) if !active_ns.is_empty() => {
285                                format!("{}\\{}", active_ns, n)
286                            }
287                            Some(n) => n.clone(),
288                            None => active_ns.clone(),
289                        };
290                        if let Some(stubs) =
291                            collect_abstract_methods_fqn(&inner.stmts, fqn, &child_ns)
292                        {
293                            return Some(stubs);
294                        }
295                    }
296                    NamespaceBody::Simple => {
297                        // Unbraced form: all subsequent statements are in this namespace.
298                        active_ns = ns_name.unwrap_or_default();
299                    }
300                }
301            }
302            _ => {}
303        }
304    }
305    None
306}
307
308/// Compare two FQNs ignoring a leading backslash.
309fn fqn_eq(a: &str, b: &str) -> bool {
310    a.trim_start_matches('\\') == b.trim_start_matches('\\')
311}
312
313fn collect_abstract_methods(stmts: &[Stmt<'_, '_>], name: &str) -> Option<Vec<MethodStub>> {
314    for stmt in stmts {
315        match &stmt.kind {
316            StmtKind::Interface(i) if i.name == name => {
317                let stubs = i
318                    .body
319                    .members
320                    .iter()
321                    .filter_map(|m| {
322                        if let ClassMemberKind::Method(method) = &m.kind {
323                            Some(MethodStub {
324                                name: method.name.to_string(),
325                                visibility: "public",
326                                is_static: method.is_static,
327                                params: format_params_str(&method.params),
328                                return_type: method
329                                    .return_type
330                                    .as_ref()
331                                    .map(|t| format_type_hint(t)),
332                            })
333                        } else {
334                            None
335                        }
336                    })
337                    .collect();
338                return Some(stubs);
339            }
340            StmtKind::Class(c)
341                if c.name.as_ref().map(|n| n.to_string()) == Some(name.to_string())
342                    && c.modifiers.is_abstract =>
343            {
344                let stubs = c
345                    .body
346                    .members
347                    .iter()
348                    .filter_map(|m| {
349                        if let ClassMemberKind::Method(method) = &m.kind {
350                            if method.is_abstract {
351                                Some(MethodStub {
352                                    name: method.name.to_string(),
353                                    visibility: visibility_str(method.visibility.as_ref()),
354                                    is_static: method.is_static,
355                                    params: format_params_str(&method.params),
356                                    return_type: method
357                                        .return_type
358                                        .as_ref()
359                                        .map(|t| format_type_hint(t)),
360                                })
361                            } else {
362                                None
363                            }
364                        } else {
365                            None
366                        }
367                    })
368                    .collect();
369                return Some(stubs);
370            }
371            StmtKind::Namespace(ns) => {
372                if let NamespaceBody::Braced(inner) = &ns.body
373                    && let Some(stubs) = collect_abstract_methods(&inner.stmts, name)
374                {
375                    return Some(stubs);
376                }
377            }
378            _ => {}
379        }
380    }
381    None
382}
383
384fn visibility_str(v: Option<&Visibility>) -> &'static str {
385    match v {
386        Some(Visibility::Protected) => "protected",
387        Some(Visibility::Private) => "private",
388        _ => "public",
389    }
390}
391
392fn generate_stub_text(stubs: &[MethodStub]) -> String {
393    let mut text = String::new();
394    for stub in stubs {
395        let static_kw = if stub.is_static { "static " } else { "" };
396        let ret = match &stub.return_type {
397            Some(t) => format!(": {t}"),
398            None => String::new(),
399        };
400        text.push_str(&format!(
401            "    {} {}function {}({}){ret}\n    {{\n        throw new \\RuntimeException('Not implemented');\n    }}\n\n",
402            stub.visibility, static_kw, stub.name, stub.params
403        ));
404    }
405    text
406}
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411    use tower_lsp::lsp_types::Position;
412
413    fn uri(path: &str) -> Url {
414        Url::parse(&format!("file://{path}")).unwrap()
415    }
416
417    fn doc(src: &str) -> (Url, Arc<ParsedDoc>) {
418        (uri("/a.php"), Arc::new(ParsedDoc::parse(src.to_string())))
419    }
420
421    fn full_range() -> Range {
422        Range {
423            start: Position {
424                line: 0,
425                character: 0,
426            },
427            end: Position {
428                line: u32::MAX,
429                character: u32::MAX,
430            },
431        }
432    }
433
434    #[test]
435    fn resolves_interface_through_use_import() {
436        // The interface is declared in a braced namespace; the class file imports it via `use`.
437        let iface_src = "<?php\nnamespace App\\Contracts {\ninterface Renderable {\n    public function render(): string;\n}\n}";
438        let class_src =
439            "<?php\nuse App\\Contracts\\Renderable;\nclass View implements Renderable {\n}";
440        let all_docs = vec![
441            (
442                uri("/contracts/Renderable.php"),
443                Arc::new(ParsedDoc::parse(iface_src.to_string())),
444            ),
445            (
446                uri("/View.php"),
447                Arc::new(ParsedDoc::parse(class_src.to_string())),
448            ),
449        ];
450        let class_doc = ParsedDoc::parse(class_src.to_string());
451        let actions = implement_missing_actions(
452            class_src,
453            &class_doc,
454            &all_docs,
455            full_range(),
456            &uri("/View.php"),
457            &HashMap::new(),
458        );
459        assert!(
460            !actions.is_empty(),
461            "expected action when interface is resolved through use import"
462        );
463        if let CodeActionOrCommand::CodeAction(action) = &actions[0] {
464            let changes = action.edit.as_ref().unwrap().changes.as_ref().unwrap();
465            let edits = changes.values().next().unwrap();
466            assert!(
467                edits[0].new_text.contains("function render()"),
468                "stub should contain 'function render()', got: {}",
469                edits[0].new_text
470            );
471        } else {
472            panic!("expected CodeAction");
473        }
474    }
475
476    #[test]
477    fn use_import_resolution_picks_correct_interface_over_same_short_name() {
478        // Two interfaces share the short name `Logger`; only the imported one's
479        // methods should be stubbed.  Both use braced-namespace syntax so the
480        // AST traversal can track the namespace prefix.
481        let wrong_iface = "<?php\nnamespace Other {\ninterface Logger {\n    public function wrong(): void;\n}\n}";
482        let right_iface = "<?php\nnamespace App\\Logging {\ninterface Logger {\n    public function log(string $msg): void;\n}\n}";
483        let class_src = "<?php\nuse App\\Logging\\Logger;\nclass FileLogger implements Logger {\n}";
484        let all_docs = vec![
485            (
486                uri("/other/Logger.php"),
487                Arc::new(ParsedDoc::parse(wrong_iface.to_string())),
488            ),
489            (
490                uri("/logging/Logger.php"),
491                Arc::new(ParsedDoc::parse(right_iface.to_string())),
492            ),
493            (
494                uri("/FileLogger.php"),
495                Arc::new(ParsedDoc::parse(class_src.to_string())),
496            ),
497        ];
498        let class_doc = ParsedDoc::parse(class_src.to_string());
499        let imports = HashMap::from([("Logger".to_string(), "App\\Logging\\Logger".to_string())]);
500        let actions = implement_missing_actions(
501            class_src,
502            &class_doc,
503            &all_docs,
504            full_range(),
505            &uri("/FileLogger.php"),
506            &imports,
507        );
508        assert!(!actions.is_empty(), "expected action");
509        if let CodeActionOrCommand::CodeAction(action) = &actions[0] {
510            let changes = action.edit.as_ref().unwrap().changes.as_ref().unwrap();
511            let edits = changes.values().next().unwrap();
512            assert!(
513                edits[0].new_text.contains("function log("),
514                "should stub the correct Logger's 'log' method, got: {}",
515                edits[0].new_text
516            );
517            assert!(
518                !edits[0].new_text.contains("function wrong("),
519                "should NOT stub the wrong Logger's 'wrong' method"
520            );
521        } else {
522            panic!("expected CodeAction");
523        }
524    }
525}