Skip to main content

php_lsp/actions/
extract_interface_action.rs

1/// Code action: "Extract interface from class"
2///
3/// Offered when the cursor is on the class declaration. Collects all public
4/// non-constructor methods, generates a `NameInterface` above the class, and
5/// adds `implements NameInterface` (or appends to an existing `implements`
6/// list) to the class header.
7use std::collections::HashMap;
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 MethodSig {
19    name: String,
20    is_static: bool,
21    by_ref: bool,
22    params: String,
23    return_type: Option<String>,
24}
25
26pub fn extract_interface_actions(
27    source: &str,
28    doc: &ParsedDoc,
29    range: Range,
30    uri: &Uri,
31) -> Vec<CodeActionOrCommand> {
32    let sv = doc.view();
33    let mut out = Vec::new();
34    let root = &doc.program().stmts;
35    collect(root, root, source, sv, range, uri, &mut out);
36    out
37}
38
39fn collect<'a>(
40    stmts: &[Stmt<'a, 'a>],
41    root: &[Stmt<'a, 'a>],
42    source: &str,
43    sv: SourceView<'_>,
44    range: Range,
45    uri: &Uri,
46    out: &mut Vec<CodeActionOrCommand>,
47) {
48    for stmt in stmts {
49        match &stmt.kind {
50            StmtKind::Class(c) => {
51                let class_start_line = sv.position_of(stmt.span.start).line;
52                let brace_line = sv.position_of(c.body.span.start).line;
53                if range.start.line < class_start_line || range.start.line > brace_line {
54                    continue;
55                }
56
57                let Some(class_name) = &c.name else { continue };
58                let class_name_str = class_name.to_string();
59                let interface_name = format!("{class_name_str}Interface");
60
61                // Don't offer if the class already implements this interface.
62                if c.implements.iter().any(|iface| {
63                    let repr = iface.to_string_repr();
64                    repr == interface_name || repr == format!("\\{interface_name}")
65                }) {
66                    continue;
67                }
68
69                let mut methods = public_method_sigs(c.body.members.iter());
70                let mut seen: std::collections::HashSet<String> =
71                    methods.iter().map(|m| m.name.clone()).collect();
72
73                // Public methods brought in via `use SomeTrait;` are genuinely
74                // part of the class's public API too — a class's own method of
75                // the same name (an override) wins, matching PHP's own
76                // real-member-wins-over-trait-member resolution order.
77                for member in c.body.members.iter() {
78                    if let ClassMemberKind::TraitUse(tu) = &member.kind {
79                        for trait_name in tu.traits.iter() {
80                            let short = fqn_short_name(&trait_name.to_string_repr()).to_string();
81                            for m in find_trait_methods(root, &short) {
82                                if seen.insert(m.name.clone()) {
83                                    methods.push(m);
84                                }
85                            }
86                        }
87                    }
88                }
89
90                if methods.is_empty() {
91                    continue;
92                }
93
94                // Insert interface text immediately before the class statement.
95                let insert_pos = sv.position_of(stmt.span.start);
96                let interface_text = generate_interface_text(&interface_name, &methods);
97                let interface_edit = TextEdit {
98                    range: Range {
99                        start: insert_pos,
100                        end: insert_pos,
101                    },
102                    new_text: format!("{interface_text}\n\n"),
103                };
104
105                // Add `implements InterfaceName` (or `, InterfaceName`) before the `{`.
106                //
107                // Find the last non-whitespace byte before `{` and insert there so the
108                // clause stays on the same line regardless of whether `{` is on its own
109                // line or not.
110                let brace_byte = c.body.span.start as usize;
111                let trimmed_len = source[..brace_byte]
112                    .trim_end_matches(|ch: char| ch.is_ascii_whitespace())
113                    .len();
114                let insert_implements_pos = sv.position_of(trimmed_len as u32);
115                let implements_text = if c.implements.is_empty() {
116                    format!(" implements {interface_name}")
117                } else {
118                    format!(", {interface_name}")
119                };
120                let implements_edit = TextEdit {
121                    range: Range {
122                        start: insert_implements_pos,
123                        end: insert_implements_pos,
124                    },
125                    new_text: implements_text,
126                };
127
128                let mut changes = HashMap::new();
129                changes.insert(uri.clone(), vec![interface_edit, implements_edit]);
130
131                out.push(CodeActionOrCommand::CodeAction(CodeAction {
132                    title: format!("Extract interface '{interface_name}'"),
133                    kind: Some(CodeActionKind::REFACTOR_EXTRACT),
134                    edit: Some(WorkspaceEdit {
135                        changes: Some(changes),
136                        ..Default::default()
137                    }),
138                    ..Default::default()
139                }));
140            }
141            StmtKind::Namespace(ns) => {
142                if let NamespaceBody::Braced(inner) = &ns.body {
143                    collect(&inner.stmts, root, source, sv, range, uri, out);
144                }
145            }
146            _ => {}
147        }
148    }
149}
150
151/// Extracts public, non-lifecycle method signatures from a class or trait's
152/// member list (shared by the class itself and by any traits it uses).
153fn public_method_sigs<'a>(
154    members: impl Iterator<Item = &'a php_ast::ClassMember<'a, 'a>>,
155) -> Vec<MethodSig> {
156    members
157        .filter_map(|member| {
158            let ClassMemberKind::Method(m) = &member.kind else {
159                return None;
160            };
161            // Skip non-public and lifecycle methods.
162            if !matches!(m.visibility, Some(Visibility::Public) | None) {
163                return None;
164            }
165            let name = m.name.to_string();
166            if name == "__construct" || name == "__destruct" {
167                return None;
168            }
169            Some(MethodSig {
170                name,
171                is_static: m.is_static,
172                by_ref: m.by_ref,
173                params: format_params_str(&m.params),
174                return_type: m.return_type.as_ref().map(format_type_hint),
175            })
176        })
177        .collect()
178}
179
180/// Searches `stmts` (recursing into braced namespaces) for a `trait` declared
181/// with short name `name` and returns its public method signatures. No
182/// cross-file resolution — matches this action's existing single-file scope.
183fn find_trait_methods<'a>(stmts: &[Stmt<'a, 'a>], name: &str) -> Vec<MethodSig> {
184    for stmt in stmts {
185        match &stmt.kind {
186            StmtKind::Trait(t) if t.name.as_str() == Some(name) => {
187                return public_method_sigs(t.body.members.iter());
188            }
189            StmtKind::Namespace(ns) => {
190                if let NamespaceBody::Braced(inner) = &ns.body {
191                    let found = find_trait_methods(&inner.stmts, name);
192                    if !found.is_empty() {
193                        return found;
194                    }
195                }
196            }
197            _ => {}
198        }
199    }
200    Vec::new()
201}
202
203fn generate_interface_text(name: &str, methods: &[MethodSig]) -> String {
204    let mut text = format!("interface {name}\n{{\n");
205    for m in methods {
206        let static_kw = if m.is_static { "static " } else { "" };
207        let by_ref = if m.by_ref { "&" } else { "" };
208        let ret = match &m.return_type {
209            Some(t) => format!(": {t}"),
210            None => String::new(),
211        };
212        text.push_str(&format!(
213            "    public {}function {}{}({}){ret};\n",
214            static_kw, by_ref, m.name, m.params,
215        ));
216    }
217    text.push('}');
218    text
219}