1use 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 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 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 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 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 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 {
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 let closing_pos = sv.position_of(stmt.span.end.saturating_sub(1));
121 let insert_pos = closing_pos;
122 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
164fn 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 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 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
198fn collect_abstract_methods_fqn(
202 stmts: &[Stmt<'_, '_>],
203 fqn: &str,
204 current_ns: &str,
205) -> Option<Vec<MethodStub>> {
206 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 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
301fn 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 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 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}