use std::collections::HashMap;
use tower_lsp::lsp_types::*;
use crate::Backend;
use crate::code_actions::{CodeActionData, make_code_action_data};
use crate::util::{contains_php_attribute, offset_to_position, ranges_overlap, strip_fqn_prefix};
const METHOD_OVERRIDE_ID: &str = "method.override";
const PROPERTY_OVERRIDE_ID: &str = "property.override";
const PROPERTY_OVERRIDE_ATTR_ID: &str = "property.overrideAttribute";
impl Backend {
pub(crate) fn collect_remove_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()
};
let mut by_line: std::collections::BTreeMap<u32, Vec<&Diagnostic>> =
std::collections::BTreeMap::new();
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 != METHOD_OVERRIDE_ID
&& identifier != PROPERTY_OVERRIDE_ID
&& identifier != PROPERTY_OVERRIDE_ATTR_ID
{
continue;
}
by_line.entry(diag.range.start.line).or_default().push(diag);
}
for diags in by_line.values() {
let first = diags[0];
let diag_line = first.range.start.line as usize;
if find_override_attribute_line(content, diag_line).is_none() {
continue;
}
let member_name = diags.iter().find_map(|d| {
let id = match &d.code {
Some(NumberOrString::String(s)) => s.as_str(),
_ => return None,
};
extract_member_name(&d.message, id)
});
let title = match member_name {
Some(name) => format!("Remove #[Override] from {}", name),
None => "Remove #[Override]".to_string(),
};
let extra = serde_json::json!({
"diagnostic_message": first.message,
"diagnostic_line": first.range.start.line,
"diagnostic_code": match &first.code {
Some(NumberOrString::String(s)) => s.as_str(),
_ => "",
},
});
let data = make_code_action_data("phpstan.removeOverride", uri, ¶ms.range, extra);
out.push(CodeActionOrCommand::CodeAction(CodeAction {
title,
kind: Some(CodeActionKind::QUICKFIX),
diagnostics: Some(diags.iter().map(|d| (*d).clone()).collect()),
edit: None,
command: None,
is_preferred: Some(true),
disabled: None,
data: Some(data),
}));
}
}
pub(crate) fn resolve_remove_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 attr_line = find_override_attribute_line(content, diag_line)?;
let edit = build_remove_override_edit(content, attr_line)?;
let doc_uri: Url = uri.parse().ok()?;
let mut changes = HashMap::new();
changes.insert(doc_uri, vec![edit]);
Some(WorkspaceEdit {
changes: Some(changes),
document_changes: None,
change_annotations: None,
})
}
}
fn extract_member_name<'a>(message: &'a str, identifier: &str) -> Option<&'a str> {
if identifier == METHOD_OVERRIDE_ID {
let after = message.strip_prefix("Method ")?;
let paren_pos = after.find('(')?;
let class_and_name = &after[..paren_pos];
let name = class_and_name.rsplit("::").next()?;
if name.is_empty() {
return None;
}
Some(name)
} else if identifier == PROPERTY_OVERRIDE_ID {
let after = message.strip_prefix("Property ")?;
let has_pos = after.find(" has ")?;
let class_and_name = &after[..has_pos];
let name = class_and_name.rsplit("::").next()?;
if name.is_empty() {
return None;
}
Some(name)
} else {
None
}
}
fn find_override_attribute_line(content: &str, diag_line: usize) -> Option<usize> {
let lines: Vec<&str> = content.lines().collect();
if diag_line >= lines.len() {
return None;
}
let search_start = diag_line.saturating_sub(10);
for i in (search_start..=diag_line).rev() {
let trimmed = lines[i].trim();
if trimmed.starts_with("#[") && contains_php_attribute(trimmed, b"Override") {
return Some(i);
}
}
None
}
fn build_remove_override_edit(content: &str, attr_line: usize) -> Option<TextEdit> {
let lines: Vec<&str> = content.lines().collect();
if attr_line >= lines.len() {
return None;
}
let line_text = lines[attr_line];
let trimmed = line_text.trim();
if is_sole_override_attribute(trimmed) {
let start = line_byte_offset(content, attr_line);
let end = if attr_line + 1 < lines.len() {
line_byte_offset(content, attr_line + 1)
} else {
content.len()
};
let start_pos = offset_to_position(content, start);
let end_pos = offset_to_position(content, end);
Some(TextEdit {
range: Range {
start: start_pos,
end: end_pos,
},
new_text: String::new(),
})
} else {
let new_line = remove_override_from_attribute_list(trimmed)?;
let indent: String = line_text
.chars()
.take_while(|c| c.is_whitespace())
.collect();
let replacement = format!("{}{}", indent, new_line);
let start = line_byte_offset(content, attr_line);
let end = start + line_text.len();
let start_pos = offset_to_position(content, start);
let end_pos = offset_to_position(content, end);
Some(TextEdit {
range: Range {
start: start_pos,
end: end_pos,
},
new_text: replacement,
})
}
}
fn is_sole_override_attribute(trimmed: &str) -> bool {
let inner = trimmed.strip_prefix("#[").and_then(|s| s.strip_suffix(']'));
let Some(inner) = inner else {
return false;
};
let inner = inner.trim();
let inner = strip_fqn_prefix(inner);
if let Some(rest) = inner.strip_prefix("Override") {
let rest = rest.trim();
rest.is_empty() || (rest.starts_with('(') && rest.ends_with(')'))
} else {
false
}
}
fn remove_override_from_attribute_list(trimmed: &str) -> Option<String> {
let inner = trimmed
.strip_prefix("#[")
.and_then(|s| s.strip_suffix(']'))?;
let parts: Vec<&str> = inner.split(',').collect();
let mut kept: Vec<String> = Vec::new();
for part in &parts {
let p = part.trim();
let without_backslash = strip_fqn_prefix(p);
let is_override = if let Some(rest) = without_backslash.strip_prefix("Override") {
let rest = rest.trim();
rest.is_empty() || (rest.starts_with('(') && rest.ends_with(')'))
} else {
false
};
if !is_override {
kept.push(p.to_string());
}
}
if kept.is_empty() {
return None;
}
Some(format!("#[{}]", kept.join(", ")))
}
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()
}
pub(crate) fn is_remove_override_stale(content: &str, diag_line: usize) -> bool {
find_override_attribute_line(content, diag_line).is_none()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extracts_method_name_from_method_override_message() {
let msg =
"Method App\\Foo::bar() has #[\\Override] attribute but does not override any method.";
assert_eq!(extract_member_name(msg, METHOD_OVERRIDE_ID), Some("bar"));
}
#[test]
fn extracts_property_name_from_property_override_message() {
let msg = "Property App\\Foo::$baz has #[\\Override] attribute but does not override any property.";
assert_eq!(extract_member_name(msg, PROPERTY_OVERRIDE_ID), Some("$baz"));
}
#[test]
fn returns_none_for_unrelated_message() {
let msg = "Some other PHPStan error.";
assert_eq!(extract_member_name(msg, METHOD_OVERRIDE_ID), None);
}
#[test]
fn returns_none_for_override_attribute_message() {
let msg = "Attribute class Override can be used with properties only on PHP 8.5 and later.";
assert_eq!(extract_member_name(msg, PROPERTY_OVERRIDE_ATTR_ID), None);
}
#[test]
fn extracts_constructor_name() {
let msg = "Method App\\Foo::__construct() has #[\\Override] attribute but does not override any method.";
assert_eq!(
extract_member_name(msg, METHOD_OVERRIDE_ID),
Some("__construct")
);
}
#[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_sole_override() {
assert!(is_sole_override_attribute("#[Override]"));
assert!(is_sole_override_attribute("#[\\Override]"));
assert!(is_sole_override_attribute("#[Override()]"));
assert!(is_sole_override_attribute("#[\\Override()]"));
}
#[test]
fn rejects_multi_attribute_as_sole() {
assert!(!is_sole_override_attribute("#[Override, Deprecated]"));
assert!(!is_sole_override_attribute("#[Deprecated, Override]"));
}
#[test]
fn rejects_non_override_as_sole() {
assert!(!is_sole_override_attribute("#[Deprecated]"));
assert!(!is_sole_override_attribute("#[Route('/foo')]"));
}
#[test]
fn removes_override_first_in_list() {
let result = remove_override_from_attribute_list("#[Override, Deprecated]");
assert_eq!(result, Some("#[Deprecated]".to_string()));
}
#[test]
fn removes_override_last_in_list() {
let result = remove_override_from_attribute_list("#[Deprecated, Override]");
assert_eq!(result, Some("#[Deprecated]".to_string()));
}
#[test]
fn removes_backslash_override_from_list() {
let result = remove_override_from_attribute_list("#[\\Override, Deprecated]");
assert_eq!(result, Some("#[Deprecated]".to_string()));
}
#[test]
fn removes_override_middle_of_list() {
let result = remove_override_from_attribute_list("#[Route('/foo'), Override, Deprecated]");
assert_eq!(result, Some("#[Route('/foo'), Deprecated]".to_string()));
}
#[test]
fn returns_none_when_only_override() {
let result = remove_override_from_attribute_list("#[Override]");
assert_eq!(result, None);
}
#[test]
fn finds_override_line_directly_above() {
let content =
"<?php\nclass Foo {\n #[\\Override]\n public function bar(): void {}\n}\n";
assert_eq!(find_override_attribute_line(content, 3), Some(2));
}
#[test]
fn finds_override_line_on_diag_line() {
let content = "<?php\n#[Override]\n";
assert_eq!(find_override_attribute_line(content, 1), Some(1));
}
#[test]
fn returns_none_when_no_override() {
let content = "<?php\nclass Foo {\n public function bar(): void {}\n}\n";
assert_eq!(find_override_attribute_line(content, 2), None);
}
#[test]
fn finds_override_with_other_attrs_between() {
let content = "<?php\nclass Foo {\n #[\\Override]\n #[Route('/bar')]\n public function bar(): void {}\n}\n";
assert_eq!(find_override_attribute_line(content, 4), Some(2));
}
#[test]
fn removes_entire_line_for_sole_override() {
let content =
"<?php\nclass Foo {\n #[\\Override]\n public function bar(): void {}\n}\n";
let edit = build_remove_override_edit(content, 2).unwrap();
assert_eq!(edit.new_text, "");
assert_eq!(edit.range.start.line, 2);
assert_eq!(edit.range.start.character, 0);
assert_eq!(edit.range.end.line, 3);
assert_eq!(edit.range.end.character, 0);
}
#[test]
fn removes_override_from_multi_attr_line() {
let content = "<?php\nclass Foo {\n #[Override, Deprecated]\n public function bar(): void {}\n}\n";
let edit = build_remove_override_edit(content, 2).unwrap();
assert_eq!(edit.new_text, " #[Deprecated]");
assert_eq!(edit.range.start.line, 2);
assert_eq!(edit.range.end.line, 2);
}
#[test]
fn removes_backslash_override_from_multi_attr_line() {
let content = "<?php\nclass Foo {\n #[\\Override, Deprecated]\n public function bar(): void {}\n}\n";
let edit = build_remove_override_edit(content, 2).unwrap();
assert_eq!(edit.new_text, " #[Deprecated]");
}
#[test]
fn stale_when_override_removed() {
let content = "<?php\nclass Foo {\n public function bar(): void {}\n}\n";
assert!(is_remove_override_stale(content, 2));
}
#[test]
fn not_stale_when_override_still_present() {
let content =
"<?php\nclass Foo {\n #[\\Override]\n public function bar(): void {}\n}\n";
assert!(!is_remove_override_stale(content, 3));
}
#[test]
fn offers_remove_override_action_for_method() {
let backend = crate::Backend::defaults();
let uri = "file:///test.php";
let content = r#"<?php
class Foo {
#[\Override]
public function bar(): 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(METHOD_OVERRIDE_ID.to_string())),
source: Some("PHPStan".to_string()),
message:
"Method Foo::bar() has #[\\Override] attribute but does not override any method."
.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 remove_action = actions.iter().find_map(|a| match a {
CodeActionOrCommand::CodeAction(ca) if ca.title.contains("Remove #[Override]") => {
Some(ca)
}
_ => None,
});
assert!(
remove_action.is_some(),
"should offer Remove #[Override] action"
);
let action = remove_action.unwrap();
assert_eq!(action.kind, Some(CodeActionKind::QUICKFIX));
assert_eq!(action.is_preferred, Some(true));
assert!(
action.title.contains("bar"),
"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_eq!(edits[0].new_text, "");
}
#[test]
fn offers_remove_override_action_for_property() {
let backend = crate::Backend::defaults();
let uri = "file:///test.php";
let content = r#"<?php
class Foo {
#[\Override]
public string $baz = '';
}
"#;
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(PROPERTY_OVERRIDE_ID.to_string())),
source: Some("PHPStan".to_string()),
message:
"Property Foo::$baz has #[\\Override] attribute but does not override any property."
.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 remove_action = actions.iter().find_map(|a| match a {
CodeActionOrCommand::CodeAction(ca) if ca.title.contains("Remove #[Override]") => {
Some(ca)
}
_ => None,
});
assert!(
remove_action.is_some(),
"should offer Remove #[Override] action for property"
);
let action = remove_action.unwrap();
assert!(
action.title.contains("$baz"),
"title should mention property name: {}",
action.title
);
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);
assert_eq!(edits[0].new_text, "");
}
#[test]
fn offers_remove_override_action_for_override_attribute_on_property() {
let backend = crate::Backend::defaults();
let uri = "file:///test.php";
let content = r#"<?php
class Foo {
#[\Override]
public string $baz = '';
}
"#;
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(
PROPERTY_OVERRIDE_ATTR_ID.to_string(),
)),
source: Some("PHPStan".to_string()),
message:
"Attribute class Override can be used with properties only on PHP 8.5 and later."
.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 remove_actions: Vec<_> = actions
.iter()
.filter_map(|a| match a {
CodeActionOrCommand::CodeAction(ca) if ca.title.contains("Remove #[Override]") => {
Some(ca)
}
_ => None,
})
.collect();
assert_eq!(
remove_actions.len(),
1,
"should offer exactly one Remove #[Override] action"
);
let action = remove_actions[0];
assert_eq!(action.title, "Remove #[Override]");
assert_eq!(action.kind, Some(CodeActionKind::QUICKFIX));
assert_eq!(action.is_preferred, Some(true));
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);
assert_eq!(edits[0].new_text, "");
}
#[test]
fn deduplicates_property_override_and_override_attribute() {
let backend = crate::Backend::defaults();
let uri = "file:///test.php";
let content = r#"<?php
class Foo {
#[\Override]
public string $baz = '';
}
"#;
backend.update_ast(uri, content);
backend
.open_files
.write()
.insert(uri.to_string(), std::sync::Arc::new(content.to_string()));
let diag1 = Diagnostic {
range: Range {
start: Position::new(3, 0),
end: Position::new(3, 80),
},
severity: Some(DiagnosticSeverity::ERROR),
code: Some(NumberOrString::String(PROPERTY_OVERRIDE_ID.to_string())),
source: Some("PHPStan".to_string()),
message:
"Property Foo::$baz has #[\\Override] attribute but does not override any property."
.to_string(),
..Default::default()
};
let diag2 = Diagnostic {
range: Range {
start: Position::new(3, 0),
end: Position::new(3, 80),
},
severity: Some(DiagnosticSeverity::ERROR),
code: Some(NumberOrString::String(
PROPERTY_OVERRIDE_ATTR_ID.to_string(),
)),
source: Some("PHPStan".to_string()),
message:
"Attribute class Override can be used with properties only on PHP 8.5 and later."
.to_string(),
..Default::default()
};
{
let mut cache = backend.phpstan_last_diags().lock();
let entry = cache.entry(uri.to_string()).or_default();
entry.push(diag1);
entry.push(diag2);
}
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 remove_actions: Vec<_> = actions
.iter()
.filter_map(|a| match a {
CodeActionOrCommand::CodeAction(ca) if ca.title.contains("Remove #[Override]") => {
Some(ca)
}
_ => None,
})
.collect();
assert_eq!(
remove_actions.len(),
1,
"should deduplicate into a single action, got: {:?}",
remove_actions.iter().map(|a| &a.title).collect::<Vec<_>>()
);
let action = remove_actions[0];
assert!(
action.title.contains("$baz"),
"title should mention property name: {}",
action.title
);
let attached = action.diagnostics.as_ref().unwrap();
assert_eq!(
attached.len(),
2,
"should attach both diagnostics to the action"
);
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);
assert_eq!(edits[0].new_text, "");
}
#[test]
fn no_action_when_override_already_removed() {
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(METHOD_OVERRIDE_ID.to_string())),
source: Some("PHPStan".to_string()),
message:
"Method Foo::bar() has #[\\Override] attribute but does not override any method."
.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 remove_action = actions.iter().find_map(|a| match a {
CodeActionOrCommand::CodeAction(ca) if ca.title.contains("Remove #[Override]") => {
Some(ca)
}
_ => None,
});
assert!(
remove_action.is_none(),
"should NOT offer action when #[Override] already removed"
);
}
#[test]
fn no_action_for_other_identifiers() {
let backend = crate::Backend::defaults();
let uri = "file:///test.php";
let content = r#"<?php
class Foo {
#[\Override]
public function bar(): 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("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(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 remove_action = actions.iter().find_map(|a| match a {
CodeActionOrCommand::CodeAction(ca) if ca.title.contains("Remove #[Override]") => {
Some(ca)
}
_ => None,
});
assert!(
remove_action.is_none(),
"should NOT offer action for non-override identifiers"
);
}
#[test]
fn removes_override_from_shared_attribute_line() {
let backend = crate::Backend::defaults();
let uri = "file:///test.php";
let content = r#"<?php
class Foo {
#[Override, Deprecated]
public function bar(): 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(METHOD_OVERRIDE_ID.to_string())),
source: Some("PHPStan".to_string()),
message:
"Method Foo::bar() has #[\\Override] attribute but does not override any method."
.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("Remove #[Override]") => {
Some(ca)
}
_ => None,
})
.expect("should offer action");
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);
assert_eq!(edits[0].new_text, " #[Deprecated]");
}
}