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::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 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 {
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 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
159fn 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 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 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
193fn collect_abstract_methods_fqn(
197 stmts: &[Stmt<'_, '_>],
198 fqn: &str,
199 current_ns: &str,
200) -> Option<Vec<MethodStub>> {
201 let short = fqn_short_name(fqn);
203 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 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 active_ns = ns_name.unwrap_or_default();
299 }
300 }
301 }
302 _ => {}
303 }
304 }
305 None
306}
307
308fn 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 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 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}