use std::collections::HashMap;
use tower_lsp::lsp_types::*;
use crate::Backend;
use crate::code_actions::{CodeActionData, make_code_action_data};
use crate::util::ranges_overlap;
const UNMATCHED_IGNORE_PREFIX: &str = "ignore.unmatched";
impl Backend {
pub(crate) fn collect_phpstan_ignore_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 ignore_groups: HashMap<(u32, String), Vec<Diagnostic>> = HashMap::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 == "phpstan" || identifier.is_empty() {
continue;
}
if identifier.starts_with(UNMATCHED_IGNORE_PREFIX) {
continue;
}
if !is_ignorable(diag) {
continue;
}
let line = diag.range.start.line;
ignore_groups
.entry((line, identifier.to_string()))
.or_default()
.push(diag.clone());
}
for ((line, identifier), diags) in &ignore_groups {
let extra = serde_json::json!({
"identifier": identifier,
"line": line,
});
out.push(CodeActionOrCommand::CodeAction(CodeAction {
title: format!("Ignore PHPStan error ({})", identifier),
kind: Some(CodeActionKind::QUICKFIX),
diagnostics: Some(diags.clone()),
edit: None,
command: None,
is_preferred: Some(false),
disabled: None,
data: Some(make_code_action_data(
"phpstan.addIgnore",
uri,
¶ms.range,
extra,
)),
}));
}
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.starts_with(UNMATCHED_IGNORE_PREFIX) {
continue;
}
let ignore_id = parse_unmatched_identifier(&diag.message);
let extra = serde_json::json!({
"diagnostic_message": diag.message,
"diagnostic_line": diag.range.start.line,
"diagnostic_code": identifier,
});
out.push(CodeActionOrCommand::CodeAction(CodeAction {
title: match ignore_id {
Some(ref id) => format!("Remove unnecessary @phpstan-ignore ({})", id),
None => "Remove unnecessary @phpstan-ignore".to_string(),
},
kind: Some(CodeActionKind::QUICKFIX),
diagnostics: Some(vec![diag.clone()]),
edit: None,
command: None,
is_preferred: Some(true),
disabled: None,
data: Some(make_code_action_data(
"phpstan.removeIgnore",
uri,
¶ms.range,
extra,
)),
}));
}
}
pub(crate) fn resolve_add_ignore(
&self,
data: &CodeActionData,
content: &str,
) -> Option<WorkspaceEdit> {
let identifier = data.extra.get("identifier")?.as_str()?;
let line = data.extra.get("line")?.as_u64()? as u32;
let line_text = content.lines().nth(line as usize)?;
let edit = build_add_ignore_edit(content, line, line_text, identifier);
let doc_uri: Url = data.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,
})
}
pub(crate) fn resolve_remove_ignore(
&self,
data: &CodeActionData,
content: &str,
) -> Option<WorkspaceEdit> {
let diagnostic_message = data.extra.get("diagnostic_message")?.as_str()?;
let diagnostic_line = data.extra.get("diagnostic_line")?.as_u64()? as u32;
let ignore_id = parse_unmatched_identifier(diagnostic_message);
let ignore_line = parse_unmatched_line(diagnostic_message);
let message_line = ignore_line.unwrap_or(diagnostic_line);
let edit =
build_remove_ignore_edit(content, message_line, diagnostic_line, ignore_id.as_deref())?;
let doc_uri: Url = data.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 build_add_ignore_edit(content: &str, line: u32, line_text: &str, identifier: &str) -> TextEdit {
if let Some(ignore_pos) = line_text.find("@phpstan-ignore") {
let after_tag = &line_text[ignore_pos + "@phpstan-ignore".len()..];
if after_tag.starts_with("-line") || after_tag.starts_with("-next-line") {
return build_eol_comment(content, line, line_text, identifier);
}
let ids_start = ignore_pos + "@phpstan-ignore".len();
let ids_text = &line_text[ids_start..];
let ids_trimmed = ids_text.trim_start();
let ids_offset = ids_text.len() - ids_trimmed.len();
let ids_end = ids_trimmed
.find("*/")
.or_else(|| {
ids_trimmed.find(" (")
})
.unwrap_or(ids_trimmed.len());
let existing_ids = ids_trimmed[..ids_end].trim();
if existing_ids.split(',').any(|id| id.trim() == identifier) {
return TextEdit {
range: Range {
start: Position { line, character: 0 },
end: Position { line, character: 0 },
},
new_text: String::new(),
};
}
let insert_col = (ids_start + ids_offset + ids_end) as u32;
let separator = if existing_ids.is_empty() { "" } else { ", " };
return TextEdit {
range: Range {
start: Position {
line,
character: insert_col,
},
end: Position {
line,
character: insert_col,
},
},
new_text: format!("{}{}", separator, identifier),
};
}
build_eol_comment(content, line, line_text, identifier)
}
fn build_eol_comment(_content: &str, line: u32, line_text: &str, identifier: &str) -> TextEdit {
let end_col = line_text.len() as u32;
TextEdit {
range: Range {
start: Position {
line,
character: end_col,
},
end: Position {
line,
character: end_col,
},
},
new_text: format!(" // @phpstan-ignore {}", identifier),
}
}
fn build_remove_ignore_edit(
content: &str,
message_line: u32,
diag_line: u32,
remove_id: Option<&str>,
) -> Option<TextEdit> {
let lines: Vec<&str> = content.lines().collect();
let mut search_lines = vec![message_line];
if message_line > 0 {
search_lines.push(message_line - 1);
}
if diag_line != message_line && (message_line == 0 || diag_line != message_line - 1) {
search_lines.push(diag_line);
}
for &check_line in &search_lines {
let line_text = match lines.get(check_line as usize) {
Some(l) => *l,
None => continue,
};
if let Some(ignore_pos) = line_text.find("@phpstan-ignore") {
let after_tag = &line_text[ignore_pos + "@phpstan-ignore".len()..];
if after_tag.starts_with("-line") || after_tag.starts_with("-next-line") {
return build_remove_whole_ignore(content, check_line, line_text, ignore_pos);
}
if let Some(id) = remove_id {
return build_remove_single_id(content, check_line, line_text, ignore_pos, id);
}
return build_remove_whole_ignore(content, check_line, line_text, ignore_pos);
}
}
None
}
fn build_remove_whole_ignore(
content: &str,
line: u32,
line_text: &str,
ignore_pos: usize,
) -> Option<TextEdit> {
let before = &line_text[..ignore_pos];
let comment_start = before
.rfind("//")
.or_else(|| before.rfind("/*"))
.unwrap_or(ignore_pos);
let before_comment = line_text[..comment_start].trim_end();
if before_comment.is_empty() {
let line_count = content.lines().count();
let next_line = line + 1;
if (next_line as usize) <= line_count {
Some(TextEdit {
range: Range {
start: Position { line, character: 0 },
end: Position {
line: next_line,
character: 0,
},
},
new_text: String::new(),
})
} else {
if line > 0 {
let prev_line_text = content.lines().nth((line - 1) as usize).unwrap_or("");
Some(TextEdit {
range: Range {
start: Position {
line: line - 1,
character: prev_line_text.len() as u32,
},
end: Position {
line,
character: line_text.len() as u32,
},
},
new_text: String::new(),
})
} else {
Some(TextEdit {
range: Range {
start: Position { line, character: 0 },
end: Position {
line,
character: line_text.len() as u32,
},
},
new_text: String::new(),
})
}
}
} else {
let end_col = if line_text[comment_start..].starts_with("/*") {
line_text[comment_start..]
.find("*/")
.map(|p| comment_start + p + 2)
.unwrap_or(line_text.len())
} else {
line_text.len()
};
let trim_start = before_comment.len();
Some(TextEdit {
range: Range {
start: Position {
line,
character: trim_start as u32,
},
end: Position {
line,
character: end_col as u32,
},
},
new_text: String::new(),
})
}
}
fn build_remove_single_id(
content: &str,
line: u32,
line_text: &str,
ignore_pos: usize,
remove_id: &str,
) -> Option<TextEdit> {
let ids_start = ignore_pos + "@phpstan-ignore".len();
let ids_text = &line_text[ids_start..];
let ids_trimmed = ids_text.trim_start();
let ids_offset = ids_text.len() - ids_trimmed.len();
let ids_end = ids_trimmed
.find("*/")
.or_else(|| ids_trimmed.find(" ("))
.unwrap_or(ids_trimmed.len());
let ids_str = ids_trimmed[..ids_end].trim();
let ids: Vec<&str> = ids_str.split(',').map(|s| s.trim()).collect();
if ids.len() <= 1 || (ids.len() == 1 && ids[0] == remove_id) {
return build_remove_whole_ignore(content, line, line_text, ignore_pos);
}
if !ids.contains(&remove_id) {
return build_remove_whole_ignore(content, line, line_text, ignore_pos);
}
let new_ids: Vec<&str> = ids.iter().filter(|&&id| id != remove_id).copied().collect();
let new_ids_str = new_ids.join(", ");
let abs_ids_start = (ids_start + ids_offset) as u32;
let abs_ids_end = (ids_start + ids_offset + ids_end) as u32;
Some(TextEdit {
range: Range {
start: Position {
line,
character: abs_ids_start,
},
end: Position {
line,
character: abs_ids_end,
},
},
new_text: new_ids_str,
})
}
fn parse_unmatched_identifier(message: &str) -> Option<String> {
let prefix = "No error with identifier ";
let start = message.find(prefix)?;
let after = &message[start + prefix.len()..];
let end = after.find(" is reported")?;
Some(after[..end].to_string())
}
fn parse_unmatched_line(message: &str) -> Option<u32> {
let prefix = "is reported on line ";
let start = message.find(prefix)?;
let after = &message[start + prefix.len()..];
let end = after.find('.')?;
let line_1based: u32 = after[..end].parse().ok()?;
Some(line_1based.saturating_sub(1))
}
fn is_ignorable(diag: &Diagnostic) -> bool {
match &diag.data {
Some(data) => data
.get("ignorable")
.and_then(|v| v.as_bool())
.unwrap_or(true),
None => true,
}
}
#[cfg(test)]
#[allow(clippy::bool_assert_comparison)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn parse_identifier_from_standard_message() {
let msg = "No error with identifier variable.undefined is reported on line 31.";
assert_eq!(
parse_unmatched_identifier(msg),
Some("variable.undefined".to_string())
);
}
#[test]
fn parse_identifier_from_dotted_identifier() {
let msg = "No error with identifier argument.type is reported on line 10.";
assert_eq!(
parse_unmatched_identifier(msg),
Some("argument.type".to_string())
);
}
#[test]
fn parse_identifier_returns_none_for_unrelated_message() {
let msg = "Method Foo::bar() should return string but returns int.";
assert_eq!(parse_unmatched_identifier(msg), None);
}
#[test]
fn parse_line_from_standard_message() {
let msg = "No error with identifier variable.undefined is reported on line 31.";
assert_eq!(parse_unmatched_line(msg), Some(30));
}
#[test]
fn parse_line_returns_none_for_unrelated_message() {
let msg = "Method Foo::bar() has no return type.";
assert_eq!(parse_unmatched_line(msg), None);
}
#[test]
fn adds_eol_comment_to_plain_line() {
let content = "<?php\n$x = 1;\necho $x;\n";
let edit = build_add_ignore_edit(content, 1, "$x = 1;", "variable.undefined");
assert_eq!(edit.new_text, " // @phpstan-ignore variable.undefined");
assert_eq!(edit.range.start.line, 1);
assert_eq!(edit.range.start.character, 7); }
#[test]
fn appends_to_existing_ignore_comment() {
let content = "<?php\necho $x; // @phpstan-ignore variable.undefined\n";
let line_text = "echo $x; // @phpstan-ignore variable.undefined";
let edit = build_add_ignore_edit(content, 1, line_text, "argument.type");
assert_eq!(edit.new_text, ", argument.type");
}
#[test]
fn does_not_duplicate_existing_identifier() {
let content = "<?php\necho $x; // @phpstan-ignore variable.undefined\n";
let line_text = "echo $x; // @phpstan-ignore variable.undefined";
let edit = build_add_ignore_edit(content, 1, line_text, "variable.undefined");
assert_eq!(edit.new_text, "");
assert_eq!(edit.range.start, edit.range.end);
}
#[test]
fn adds_eol_for_ignore_next_line() {
let content = "<?php\n// @phpstan-ignore-next-line\necho $x;\n";
let line_text = "// @phpstan-ignore-next-line";
let edit = build_add_ignore_edit(content, 1, line_text, "variable.undefined");
assert_eq!(edit.new_text, " // @phpstan-ignore variable.undefined");
}
#[test]
fn appends_to_block_comment_ignore() {
let content = "<?php\necho $x; /** @phpstan-ignore variable.undefined */\n";
let line_text = "echo $x; /** @phpstan-ignore variable.undefined */";
let edit = build_add_ignore_edit(content, 1, line_text, "argument.type");
assert_eq!(edit.new_text, ", argument.type");
}
#[test]
fn appends_to_ignore_with_reason() {
let content = "<?php\necho $x; // @phpstan-ignore variable.undefined (lazy)\n";
let line_text = "echo $x; // @phpstan-ignore variable.undefined (lazy)";
let edit = build_add_ignore_edit(content, 1, line_text, "argument.type");
assert_eq!(edit.new_text, ", argument.type");
let insert_char = edit.range.start.character as usize;
assert!(
insert_char <= line_text.find(" (lazy)").unwrap(),
"Insert position {} should be before the reason at {}",
insert_char,
line_text.find(" (lazy)").unwrap()
);
}
#[test]
fn removes_standalone_ignore_comment_line() {
let content = "<?php\n /** @phpstan-ignore variable.undefined */\n echo $x;\n";
let edit = build_remove_ignore_edit(content, 2, 2, Some("variable.undefined"));
let edit = edit.unwrap();
assert_eq!(edit.range.start.line, 1);
assert_eq!(edit.range.start.character, 0);
assert_eq!(edit.range.end.line, 2);
assert_eq!(edit.range.end.character, 0);
assert_eq!(edit.new_text, "");
}
#[test]
fn removes_eol_ignore_comment() {
let content = "<?php\necho $x; // @phpstan-ignore variable.undefined\n";
let edit = build_remove_ignore_edit(content, 1, 1, Some("variable.undefined"));
let edit = edit.unwrap();
assert_eq!(edit.range.start.line, 1);
assert_eq!(edit.new_text, "");
assert_eq!(edit.range.start.character, 8);
}
#[test]
fn removes_single_id_from_multi_id_ignore() {
let content = "<?php\necho $x; // @phpstan-ignore variable.undefined, argument.type\n";
let edit = build_remove_ignore_edit(content, 1, 1, Some("variable.undefined"));
let edit = edit.unwrap();
assert_eq!(edit.new_text, "argument.type");
}
#[test]
fn removes_second_id_from_multi_id_ignore() {
let content = "<?php\necho $x; // @phpstan-ignore variable.undefined, argument.type\n";
let edit = build_remove_ignore_edit(content, 1, 1, Some("argument.type"));
let edit = edit.unwrap();
assert_eq!(edit.new_text, "variable.undefined");
}
#[test]
fn removes_whole_comment_when_no_specific_id() {
let content = "<?php\necho $x; // @phpstan-ignore variable.undefined\n";
let edit = build_remove_ignore_edit(content, 1, 1, None);
let edit = edit.unwrap();
assert_eq!(edit.new_text, "");
}
#[test]
fn finds_ignore_on_previous_line() {
let content = "<?php\n// @phpstan-ignore variable.undefined\necho $x;\n";
let edit = build_remove_ignore_edit(content, 2, 1, Some("variable.undefined"));
let edit = edit.unwrap();
assert_eq!(edit.range.start.line, 1);
}
#[test]
fn returns_none_when_no_ignore_found() {
let content = "<?php\necho $x;\n";
let edit = build_remove_ignore_edit(content, 1, 1, Some("variable.undefined"));
assert!(edit.is_none());
}
#[test]
fn removes_block_comment_ignore_on_previous_line() {
let content = "public function update(): void\n\
{\t/** @phpstan-ignore variable.undefined */\n\
\t$request = new GetOrderStateRequest((string)$this->orderId);\n";
let edit = build_remove_ignore_edit(content, 2, 1, Some("variable.undefined"));
let edit = edit.unwrap();
assert_eq!(edit.range.start.line, 1);
assert_eq!(edit.new_text, "");
}
#[test]
fn finds_ignore_via_diag_line_when_message_line_differs() {
let content = "<?php\necho $x; // @phpstan-ignore variable.undefined\necho $y;\n";
let edit = build_remove_ignore_edit(content, 2, 1, Some("variable.undefined"));
let edit = edit.unwrap();
assert_eq!(edit.range.start.line, 1);
}
#[test]
fn overlapping_ranges() {
let a = Range {
start: Position {
line: 5,
character: 0,
},
end: Position {
line: 5,
character: 10,
},
};
let b = Range {
start: Position {
line: 5,
character: 5,
},
end: Position {
line: 5,
character: 15,
},
};
assert!(ranges_overlap(&a, &b));
}
#[test]
fn non_overlapping_ranges() {
let a = Range {
start: Position {
line: 1,
character: 0,
},
end: Position {
line: 1,
character: 5,
},
};
let b = Range {
start: Position {
line: 3,
character: 0,
},
end: Position {
line: 3,
character: 5,
},
};
assert!(!ranges_overlap(&a, &b));
}
fn make_diag_with_data(data: Option<serde_json::Value>) -> Diagnostic {
Diagnostic {
range: Range {
start: Position {
line: 0,
character: 0,
},
end: Position {
line: 0,
character: u32::MAX,
},
},
severity: Some(DiagnosticSeverity::ERROR),
code: Some(NumberOrString::String("method.visibility".to_string())),
code_description: None,
source: Some("phpstan".to_string()),
message: "some error".to_string(),
related_information: None,
tags: None,
data,
}
}
#[test]
fn ignorable_true_when_data_says_true() {
let diag = make_diag_with_data(Some(json!({ "ignorable": true })));
assert_eq!(is_ignorable(&diag), true);
}
#[test]
fn ignorable_false_when_data_says_false() {
let diag = make_diag_with_data(Some(json!({ "ignorable": false })));
assert_eq!(is_ignorable(&diag), false);
}
#[test]
fn ignorable_defaults_true_when_data_is_none() {
let diag = make_diag_with_data(None);
assert_eq!(is_ignorable(&diag), true);
}
#[test]
fn ignorable_defaults_true_when_field_missing() {
let diag = make_diag_with_data(Some(json!({})));
assert_eq!(is_ignorable(&diag), true);
}
#[test]
fn cursor_range_overlaps_full_line() {
let cursor = Range {
start: Position {
line: 5,
character: 10,
},
end: Position {
line: 5,
character: 10,
},
};
let diag_range = Range {
start: Position {
line: 5,
character: 0,
},
end: Position {
line: 5,
character: u32::MAX,
},
};
assert!(ranges_overlap(&cursor, &diag_range));
}
}