use std::collections::HashMap;
use tower_lsp::lsp_types::*;
use crate::Backend;
use crate::code_actions::{CodeActionData, make_code_action_data};
use crate::completion::use_edit::{analyze_use_block, build_use_edit, use_import_conflicts};
use crate::util::{
contains_function_keyword, contains_php_attribute, offset_to_position, ranges_overlap,
};
const MISSING_OVERRIDE_ID: &str = "method.missingOverride";
impl Backend {
pub(crate) fn collect_add_override_actions(
&self,
uri: &str,
content: &str,
params: &CodeActionParams,
out: &mut Vec<CodeActionOrCommand>,
) {
let phpstan_diags: Vec<Diagnostic> = {
let cache = self.phpstan_last_diags.lock();
cache.get(uri).cloned().unwrap_or_default()
};
for diag in &phpstan_diags {
if !ranges_overlap(&diag.range, ¶ms.range) {
continue;
}
let identifier = match &diag.code {
Some(NumberOrString::String(s)) => s.as_str(),
_ => continue,
};
if identifier != MISSING_OVERRIDE_ID {
continue;
}
let diag_line = diag.range.start.line as usize;
let Some(insertion) = find_method_insertion_point(content, diag_line) else {
continue;
};
if already_has_override(content, &insertion) {
continue;
}
let method_name = extract_method_name(&diag.message).unwrap_or("method");
let title = format!("Add #[Override] to {}", method_name);
let extra = serde_json::json!({
"diagnostic_message": diag.message,
"diagnostic_line": diag.range.start.line,
"diagnostic_code": MISSING_OVERRIDE_ID,
});
let data = make_code_action_data("phpstan.addOverride", uri, ¶ms.range, extra);
out.push(CodeActionOrCommand::CodeAction(CodeAction {
title,
kind: Some(CodeActionKind::QUICKFIX),
diagnostics: Some(vec![diag.clone()]),
edit: None,
command: None,
is_preferred: Some(true),
disabled: None,
data: Some(data),
}));
}
}
pub(crate) fn resolve_add_override(
&self,
data: &CodeActionData,
content: &str,
) -> Option<WorkspaceEdit> {
let uri = &data.uri;
let diag_line = data.extra.get("diagnostic_line")?.as_u64()? as usize;
let insertion = find_method_insertion_point(content, diag_line)?;
if already_has_override(content, &insertion) {
return None;
}
let file_use_map: HashMap<String, String> = self.file_use_map(uri);
let file_namespace: Option<String> = self.namespace_map.read().get(uri).cloned().flatten();
let already_imported = file_use_map.iter().any(|(alias, fqn)| {
alias.eq_ignore_ascii_case("Override") && fqn.eq_ignore_ascii_case("Override")
});
let same_namespace = file_namespace.is_none();
let needs_import = !already_imported && !same_namespace;
let use_fqn = needs_import && use_import_conflicts("Override", &file_use_map);
let attr_text = if use_fqn {
"#[\\Override]"
} else {
"#[Override]"
};
let insert_text = format!("{}{}\n", insertion.indent, attr_text);
let insert_pos = offset_to_position(content, insertion.insert_offset);
let mut edits = vec![TextEdit {
range: Range {
start: insert_pos,
end: insert_pos,
},
new_text: insert_text,
}];
if needs_import && !use_fqn {
let use_block = analyze_use_block(content);
if let Some(import_edits) = build_use_edit("Override", &use_block, &file_namespace) {
edits.extend(import_edits);
}
}
let doc_uri: Url = uri.parse().ok()?;
let mut changes = HashMap::new();
changes.insert(doc_uri, edits);
Some(WorkspaceEdit {
changes: Some(changes),
document_changes: None,
change_annotations: None,
})
}
}
struct InsertionPoint {
insert_offset: usize,
indent: String,
first_token_offset: usize,
attrs_end_offset: usize,
}
fn extract_method_name(message: &str) -> Option<&str> {
let after_method = message.strip_prefix("Method ")?;
let paren_pos = after_method.find('(')?;
let class_and_name = &after_method[..paren_pos];
let name = class_and_name.rsplit("::").next()?;
if name.is_empty() {
return None;
}
Some(name)
}
fn find_method_insertion_point(content: &str, diag_line: usize) -> Option<InsertionPoint> {
let lines: Vec<&str> = content.lines().collect();
if diag_line >= lines.len() {
return None;
}
let mut func_line = None;
let search_start = diag_line.min(lines.len().saturating_sub(1));
for i in (search_start.saturating_sub(5)..=search_start).rev() {
if contains_function_keyword(lines[i]) {
func_line = Some(i);
break;
}
}
let func_line = func_line?;
let mut first_decl_line = func_line;
let mut check_line = func_line;
loop {
if check_line == 0 {
break;
}
let prev = check_line - 1;
let prev_trimmed = lines[prev].trim();
if prev_trimmed.is_empty() {
break;
}
if is_modifier_line(prev_trimmed) {
first_decl_line = prev;
check_line = prev;
continue;
}
break;
}
let mut first_attr_line = first_decl_line;
let mut check_line = first_decl_line;
loop {
if check_line == 0 {
break;
}
let prev = check_line - 1;
let prev_trimmed = lines[prev].trim();
if prev_trimmed.is_empty() {
break;
}
if is_attribute_line(prev_trimmed) {
first_attr_line = prev;
check_line = prev;
continue;
}
break;
}
let target_line = first_attr_line;
let insert_offset = line_byte_offset(content, target_line);
let indent: String = lines[func_line]
.chars()
.take_while(|c| c.is_whitespace())
.collect();
let first_token_offset = insert_offset;
let attrs_end_offset = if first_attr_line < first_decl_line {
line_byte_offset(content, first_decl_line)
} else {
first_token_offset
};
Some(InsertionPoint {
insert_offset,
indent,
first_token_offset,
attrs_end_offset,
})
}
fn already_has_override(content: &str, insertion: &InsertionPoint) -> bool {
if insertion.attrs_end_offset <= insertion.first_token_offset {
return false;
}
let attr_region = &content[insertion.first_token_offset..insertion.attrs_end_offset];
for line in attr_region.lines() {
let trimmed = line.trim();
if trimmed.starts_with("#[") {
if contains_php_attribute(trimmed, b"Override") {
return true;
}
}
}
false
}
fn is_modifier_line(trimmed: &str) -> bool {
let modifiers = [
"public",
"protected",
"private",
"static",
"abstract",
"final",
"readonly",
];
modifiers.iter().any(|kw| {
trimmed.starts_with(kw)
&& trimmed[kw.len()..].starts_with(|c: char| c.is_whitespace() || c == '\0')
})
}
fn is_attribute_line(trimmed: &str) -> bool {
trimmed.starts_with("#[")
}
fn line_byte_offset(content: &str, line: usize) -> usize {
let mut offset = 0;
for (i, l) in content.lines().enumerate() {
if i == line {
return offset;
}
offset += l.len() + 1; }
content.len()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extracts_method_name_from_standard_message() {
let msg = "Method App\\Foo::bar() overrides method App\\Base::bar() but is missing the #[Override] attribute.";
assert_eq!(extract_method_name(msg), Some("bar"));
}
#[test]
fn extracts_method_name_with_deep_namespace() {
let msg = "Method App\\Http\\Controllers\\UserController::index() overrides method App\\Http\\Controllers\\Controller::index() but is missing the #[Override] attribute.";
assert_eq!(extract_method_name(msg), Some("index"));
}
#[test]
fn returns_none_for_unrelated_message() {
let msg = "Some other PHPStan error about something.";
assert_eq!(extract_method_name(msg), None);
}
#[test]
fn extracts_constructor_name() {
let msg = "Method App\\Foo::__construct() overrides method App\\Base::__construct() but is missing the #[Override] attribute.";
assert_eq!(extract_method_name(msg), Some("__construct"));
}
#[test]
fn detects_function_keyword() {
assert!(contains_function_keyword(
" public function bar(): void {"
));
assert!(contains_function_keyword("function foo()"));
assert!(contains_function_keyword(
" protected static function baz()"
));
}
#[test]
fn rejects_function_in_string() {
assert!(!contains_function_keyword(" $functionality = true;"));
assert!(!contains_function_keyword(" // some_function()"));
}
#[test]
fn detects_modifier_lines() {
assert!(is_modifier_line("public function"));
assert!(is_modifier_line("protected static"));
assert!(is_modifier_line("abstract public"));
assert!(is_modifier_line("final protected"));
}
#[test]
fn rejects_non_modifier_lines() {
assert!(!is_modifier_line("function foo()"));
assert!(!is_modifier_line("$public = true;"));
assert!(!is_modifier_line("// public function"));
}
#[test]
fn detects_attribute_lines() {
assert!(is_attribute_line("#[Override]"));
assert!(is_attribute_line("#[\\Override]"));
assert!(is_attribute_line("#[Route('/foo')]"));
assert!(is_attribute_line("#[Override, Deprecated]"));
}
#[test]
fn rejects_non_attribute_lines() {
assert!(!is_attribute_line("// #[Override]"));
assert!(!is_attribute_line("public function foo()"));
}
#[test]
fn finds_override_simple() {
assert!(contains_php_attribute("#[Override]", b"Override"));
}
#[test]
fn finds_override_with_backslash() {
assert!(contains_php_attribute("#[\\Override]", b"Override"));
}
#[test]
fn finds_override_in_list() {
assert!(contains_php_attribute(
"#[Override, Deprecated]",
b"Override"
));
assert!(contains_php_attribute(
"#[Deprecated, Override]",
b"Override"
));
assert!(contains_php_attribute(
"#[Deprecated, \\Override]",
b"Override"
));
}
#[test]
fn does_not_match_partial() {
assert!(!contains_php_attribute("#[OverrideSomething]", b"Override"));
assert!(!contains_php_attribute("#[MyOverride]", b"Override"));
}
#[test]
fn detects_existing_override() {
let content =
"<?php\nclass Foo {\n #[\\Override]\n public function bar(): void {}\n}\n";
let insertion = InsertionPoint {
insert_offset: content.find("#[\\Override]").unwrap(),
indent: " ".to_string(),
first_token_offset: content.find("#[\\Override]").unwrap(),
attrs_end_offset: content.find(" public function").unwrap(),
};
assert!(already_has_override(content, &insertion));
}
#[test]
fn no_override_when_no_attrs() {
let content = "<?php\nclass Foo {\n public function bar(): void {}\n}\n";
let offset = content.find(" public function").unwrap();
let insertion = InsertionPoint {
insert_offset: offset,
indent: " ".to_string(),
first_token_offset: offset,
attrs_end_offset: offset,
};
assert!(!already_has_override(content, &insertion));
}
#[test]
fn finds_insertion_for_simple_method() {
let content = "<?php\nclass Foo {\n public function bar(): void {}\n}\n";
let line = 2; let ins = find_method_insertion_point(content, line).unwrap();
assert_eq!(ins.indent, " ");
let expected_offset = content.find(" public function").unwrap();
assert_eq!(ins.insert_offset, expected_offset);
}
#[test]
fn finds_insertion_for_method_with_existing_attributes() {
let content =
"<?php\nclass Foo {\n #[Route('/bar')]\n public function bar(): void {}\n}\n";
let line = 3; let ins = find_method_insertion_point(content, line).unwrap();
assert_eq!(ins.indent, " ");
let expected_offset = content.find(" #[Route").unwrap();
assert_eq!(ins.insert_offset, expected_offset);
}
#[test]
fn finds_insertion_with_multiple_attributes() {
let content = "<?php\nclass Foo {\n #[Route('/bar')]\n #[Deprecated]\n public function bar(): void {}\n}\n";
let line = 4; let ins = find_method_insertion_point(content, line).unwrap();
let expected_offset = content.find(" #[Route").unwrap();
assert_eq!(ins.insert_offset, expected_offset);
}
#[test]
fn builds_correct_override_text() {
let content = "<?php\nclass Foo {\n public function bar(): void {}\n}\n";
let line = 2;
let ins = find_method_insertion_point(content, line).unwrap();
let insert_text = format!("{}#[Override]\n", ins.indent);
assert_eq!(insert_text, " #[Override]\n");
}
#[test]
fn builds_correct_override_text_nested() {
let content = "<?php\nclass Foo {\n protected function bar(): void {}\n}\n";
let line = 2;
let ins = find_method_insertion_point(content, line).unwrap();
let insert_text = format!("{}#[Override]\n", ins.indent);
assert_eq!(insert_text, " #[Override]\n");
}
#[test]
fn offers_add_override_action() {
let backend = crate::Backend::defaults();
let uri = "file:///test.php";
let content = r#"<?php
class Child extends Base {
public function foo(): void {}
}
"#;
backend.update_ast(uri, content);
backend
.open_files
.write()
.insert(uri.to_string(), std::sync::Arc::new(content.to_string()));
let diag = Diagnostic {
range: Range {
start: Position::new(2, 0),
end: Position::new(2, 80),
},
severity: Some(DiagnosticSeverity::ERROR),
code: Some(NumberOrString::String(MISSING_OVERRIDE_ID.to_string())),
source: Some("PHPStan".to_string()),
message: "Method Child::foo() overrides method Base::foo() but is missing the #[Override] attribute.".to_string(),
..Default::default()
};
{
let mut cache = backend.phpstan_last_diags().lock();
cache.entry(uri.to_string()).or_default().push(diag);
}
let params = CodeActionParams {
text_document: TextDocumentIdentifier {
uri: uri.parse().unwrap(),
},
range: Range {
start: Position::new(2, 4),
end: Position::new(2, 4),
},
context: CodeActionContext {
diagnostics: vec![],
only: None,
trigger_kind: None,
},
work_done_progress_params: WorkDoneProgressParams {
work_done_token: None,
},
partial_result_params: PartialResultParams {
partial_result_token: None,
},
};
let actions = backend.handle_code_action(uri, content, ¶ms);
let override_action = actions.iter().find_map(|a| match a {
CodeActionOrCommand::CodeAction(ca) if ca.title.contains("#[Override]") => Some(ca),
_ => None,
});
assert!(
override_action.is_some(),
"should offer Add #[Override] action"
);
let action = override_action.unwrap();
assert_eq!(action.kind, Some(CodeActionKind::QUICKFIX));
assert_eq!(action.is_preferred, Some(true));
assert!(
action.title.contains("foo"),
"title should mention method name: {}",
action.title
);
assert!(action.edit.is_none(), "Phase 1 should not compute the edit");
assert!(
action.data.is_some(),
"Phase 1 should set data for deferred resolve"
);
let (resolved, _republish) = backend.resolve_code_action(action.clone());
let edit = resolved
.edit
.as_ref()
.expect("resolve should produce an edit");
let changes = edit.changes.as_ref().unwrap();
let edits: Vec<&TextEdit> = changes.values().flat_map(|v| v.iter()).collect();
assert_eq!(edits.len(), 1);
assert!(edits[0].new_text.contains("#[Override]"));
assert!(
!edits[0].new_text.contains("#[\\Override]"),
"should use short form in non-namespaced file"
);
}
#[test]
fn no_action_when_override_already_present() {
let backend = crate::Backend::defaults();
let uri = "file:///test.php";
let content = r#"<?php
class Child extends Base {
#[\Override]
public function foo(): void {}
}
"#;
backend.update_ast(uri, content);
let diag = Diagnostic {
range: Range {
start: Position::new(3, 0),
end: Position::new(3, 80),
},
severity: Some(DiagnosticSeverity::ERROR),
code: Some(NumberOrString::String(MISSING_OVERRIDE_ID.to_string())),
source: Some("PHPStan".to_string()),
message: "Method Child::foo() overrides method Base::foo() but is missing the #[Override] attribute.".to_string(),
..Default::default()
};
{
let mut cache = backend.phpstan_last_diags().lock();
cache.entry(uri.to_string()).or_default().push(diag);
}
let params = CodeActionParams {
text_document: TextDocumentIdentifier {
uri: uri.parse().unwrap(),
},
range: Range {
start: Position::new(3, 4),
end: Position::new(3, 4),
},
context: CodeActionContext {
diagnostics: vec![],
only: None,
trigger_kind: None,
},
work_done_progress_params: WorkDoneProgressParams {
work_done_token: None,
},
partial_result_params: PartialResultParams {
partial_result_token: None,
},
};
let actions = backend.handle_code_action(uri, content, ¶ms);
let override_action = actions.iter().find_map(|a| match a {
CodeActionOrCommand::CodeAction(ca) if ca.title.contains("#[Override]") => Some(ca),
_ => None,
});
assert!(
override_action.is_none(),
"should NOT offer action when #[Override] already present"
);
}
#[test]
fn no_action_for_other_identifiers() {
let backend = crate::Backend::defaults();
let uri = "file:///test.php";
let content = r#"<?php
class Foo {
public function bar(): void {}
}
"#;
backend.update_ast(uri, content);
let diag = Diagnostic {
range: Range {
start: Position::new(2, 0),
end: Position::new(2, 80),
},
severity: Some(DiagnosticSeverity::ERROR),
code: Some(NumberOrString::String("return.unusedType".to_string())),
source: Some("PHPStan".to_string()),
message: "Some other error.".to_string(),
..Default::default()
};
{
let mut cache = backend.phpstan_last_diags().lock();
cache.entry(uri.to_string()).or_default().push(diag);
}
let params = CodeActionParams {
text_document: TextDocumentIdentifier {
uri: uri.parse().unwrap(),
},
range: Range {
start: Position::new(2, 4),
end: Position::new(2, 4),
},
context: CodeActionContext {
diagnostics: vec![],
only: None,
trigger_kind: None,
},
work_done_progress_params: WorkDoneProgressParams {
work_done_token: None,
},
partial_result_params: PartialResultParams {
partial_result_token: None,
},
};
let actions = backend.handle_code_action(uri, content, ¶ms);
let override_action = actions.iter().find_map(|a| match a {
CodeActionOrCommand::CodeAction(ca) if ca.title.contains("#[Override]") => Some(ca),
_ => None,
});
assert!(
override_action.is_none(),
"should NOT offer action for non-missingOverride identifiers"
);
}
#[test]
fn inserts_before_existing_attributes() {
let backend = crate::Backend::defaults();
let uri = "file:///test.php";
let content = r#"<?php
class Child extends Base {
#[Route('/foo')]
public function foo(): void {}
}
"#;
backend.update_ast(uri, content);
backend
.open_files
.write()
.insert(uri.to_string(), std::sync::Arc::new(content.to_string()));
let diag = Diagnostic {
range: Range {
start: Position::new(3, 0),
end: Position::new(3, 80),
},
severity: Some(DiagnosticSeverity::ERROR),
code: Some(NumberOrString::String(MISSING_OVERRIDE_ID.to_string())),
source: Some("PHPStan".to_string()),
message: "Method Child::foo() overrides method Base::foo() but is missing the #[Override] attribute.".to_string(),
..Default::default()
};
{
let mut cache = backend.phpstan_last_diags().lock();
cache.entry(uri.to_string()).or_default().push(diag);
}
let params = CodeActionParams {
text_document: TextDocumentIdentifier {
uri: uri.parse().unwrap(),
},
range: Range {
start: Position::new(3, 4),
end: Position::new(3, 4),
},
context: CodeActionContext {
diagnostics: vec![],
only: None,
trigger_kind: None,
},
work_done_progress_params: WorkDoneProgressParams {
work_done_token: None,
},
partial_result_params: PartialResultParams {
partial_result_token: None,
},
};
let actions = backend.handle_code_action(uri, content, ¶ms);
let action = actions
.iter()
.find_map(|a| match a {
CodeActionOrCommand::CodeAction(ca) if ca.title.contains("#[Override]") => Some(ca),
_ => None,
})
.expect("should offer action");
assert!(action.edit.is_none(), "Phase 1 should not have edit");
let (resolved, _) = backend.resolve_code_action(action.clone());
let edit = resolved.edit.as_ref().expect("resolve should produce edit");
let changes = edit.changes.as_ref().unwrap();
let edits: Vec<&TextEdit> = changes.values().flat_map(|v| v.iter()).collect();
assert_eq!(
edits[0].range.start.line, 2,
"should insert before existing attributes"
);
}
#[test]
fn adds_use_import_in_namespaced_file() {
let backend = crate::Backend::defaults();
let uri = "file:///test.php";
let content = r#"<?php
namespace App\Http\Controllers;
class Child extends Base {
public function foo(): void {}
}
"#;
backend.update_ast(uri, content);
backend
.open_files
.write()
.insert(uri.to_string(), std::sync::Arc::new(content.to_string()));
let diag = Diagnostic {
range: Range {
start: Position::new(4, 0),
end: Position::new(4, 80),
},
severity: Some(DiagnosticSeverity::ERROR),
code: Some(NumberOrString::String(MISSING_OVERRIDE_ID.to_string())),
source: Some("PHPStan".to_string()),
message: "Method App\\Http\\Controllers\\Child::foo() overrides method App\\Http\\Controllers\\Base::foo() but is missing the #[Override] attribute.".to_string(),
..Default::default()
};
{
let mut cache = backend.phpstan_last_diags().lock();
cache.entry(uri.to_string()).or_default().push(diag);
}
let params = CodeActionParams {
text_document: TextDocumentIdentifier {
uri: uri.parse().unwrap(),
},
range: Range {
start: Position::new(4, 4),
end: Position::new(4, 4),
},
context: CodeActionContext {
diagnostics: vec![],
only: None,
trigger_kind: None,
},
work_done_progress_params: WorkDoneProgressParams {
work_done_token: None,
},
partial_result_params: PartialResultParams {
partial_result_token: None,
},
};
let actions = backend.handle_code_action(uri, content, ¶ms);
let action = actions
.iter()
.find_map(|a| match a {
CodeActionOrCommand::CodeAction(ca) if ca.title.contains("#[Override]") => Some(ca),
_ => None,
})
.expect("should offer action");
assert!(action.edit.is_none(), "Phase 1 should not have edit");
let (resolved, _) = backend.resolve_code_action(action.clone());
let edit = resolved.edit.as_ref().expect("resolve should produce edit");
let changes = edit.changes.as_ref().unwrap();
let edits: Vec<&TextEdit> = changes.values().flat_map(|v| v.iter()).collect();
assert_eq!(edits.len(), 2, "should have attribute + use import edits");
let has_attr = edits.iter().any(|e| e.new_text.contains("#[Override]"));
let has_import = edits.iter().any(|e| e.new_text.contains("use Override;"));
assert!(has_attr, "should insert #[Override] attribute");
assert!(has_import, "should add `use Override;` import");
assert!(
!edits.iter().any(|e| e.new_text.contains("#[\\Override]")),
"should use short form #[Override], not FQN"
);
}
#[test]
fn no_import_in_non_namespaced_file() {
let backend = crate::Backend::defaults();
let uri = "file:///test.php";
let content = r#"<?php
class Child extends Base {
public function foo(): void {}
}
"#;
backend.update_ast(uri, content);
backend
.open_files
.write()
.insert(uri.to_string(), std::sync::Arc::new(content.to_string()));
let diag = Diagnostic {
range: Range {
start: Position::new(2, 0),
end: Position::new(2, 80),
},
severity: Some(DiagnosticSeverity::ERROR),
code: Some(NumberOrString::String(MISSING_OVERRIDE_ID.to_string())),
source: Some("PHPStan".to_string()),
message: "Method Child::foo() overrides method Base::foo() but is missing the #[Override] attribute.".to_string(),
..Default::default()
};
{
let mut cache = backend.phpstan_last_diags().lock();
cache.entry(uri.to_string()).or_default().push(diag);
}
let params = CodeActionParams {
text_document: TextDocumentIdentifier {
uri: uri.parse().unwrap(),
},
range: Range {
start: Position::new(2, 4),
end: Position::new(2, 4),
},
context: CodeActionContext {
diagnostics: vec![],
only: None,
trigger_kind: None,
},
work_done_progress_params: WorkDoneProgressParams {
work_done_token: None,
},
partial_result_params: PartialResultParams {
partial_result_token: None,
},
};
let actions = backend.handle_code_action(uri, content, ¶ms);
let action = actions
.iter()
.find_map(|a| match a {
CodeActionOrCommand::CodeAction(ca) if ca.title.contains("#[Override]") => Some(ca),
_ => None,
})
.expect("should offer action");
assert!(action.edit.is_none(), "Phase 1 should not have edit");
let (resolved, _) = backend.resolve_code_action(action.clone());
let edit = resolved.edit.as_ref().expect("resolve should produce edit");
let changes = edit.changes.as_ref().unwrap();
let edits: Vec<&TextEdit> = changes.values().flat_map(|v| v.iter()).collect();
assert_eq!(edits.len(), 1, "should have only attribute edit, no import");
assert!(edits[0].new_text.contains("#[Override]"));
assert!(
!edits.iter().any(|e| e.new_text.contains("use Override;")),
"should NOT add use import in non-namespaced file"
);
}
#[test]
fn no_duplicate_import_when_already_imported() {
let backend = crate::Backend::defaults();
let uri = "file:///test.php";
let content = r#"<?php
namespace App\Controllers;
use Override;
class Child extends Base {
public function foo(): void {}
}
"#;
backend.update_ast(uri, content);
backend
.open_files
.write()
.insert(uri.to_string(), std::sync::Arc::new(content.to_string()));
let diag = Diagnostic {
range: Range {
start: Position::new(6, 0),
end: Position::new(6, 80),
},
severity: Some(DiagnosticSeverity::ERROR),
code: Some(NumberOrString::String(MISSING_OVERRIDE_ID.to_string())),
source: Some("PHPStan".to_string()),
message: "Method App\\Controllers\\Child::foo() overrides method App\\Controllers\\Base::foo() but is missing the #[Override] attribute.".to_string(),
..Default::default()
};
{
let mut cache = backend.phpstan_last_diags().lock();
cache.entry(uri.to_string()).or_default().push(diag);
}
let params = CodeActionParams {
text_document: TextDocumentIdentifier {
uri: uri.parse().unwrap(),
},
range: Range {
start: Position::new(6, 4),
end: Position::new(6, 4),
},
context: CodeActionContext {
diagnostics: vec![],
only: None,
trigger_kind: None,
},
work_done_progress_params: WorkDoneProgressParams {
work_done_token: None,
},
partial_result_params: PartialResultParams {
partial_result_token: None,
},
};
let actions = backend.handle_code_action(uri, content, ¶ms);
let action = actions
.iter()
.find_map(|a| match a {
CodeActionOrCommand::CodeAction(ca) if ca.title.contains("#[Override]") => Some(ca),
_ => None,
})
.expect("should offer action");
assert!(action.edit.is_none(), "Phase 1 should not have edit");
let (resolved, _) = backend.resolve_code_action(action.clone());
let edit = resolved.edit.as_ref().expect("resolve should produce edit");
let changes = edit.changes.as_ref().unwrap();
let edits: Vec<&TextEdit> = changes.values().flat_map(|v| v.iter()).collect();
assert_eq!(
edits.len(),
1,
"should have only attribute edit when already imported"
);
assert!(
!edits.iter().any(|e| e.new_text.contains("use Override;")),
"should NOT duplicate existing use import"
);
}
}