use std::collections::HashMap;
use tower_lsp::lsp_types::*;
use crate::Backend;
use crate::code_actions::CodeActionData;
use crate::code_actions::make_code_action_data;
use crate::php_type::PhpType;
use crate::util::{offset_to_position, ranges_overlap, short_name};
const UNUSED_TYPE_ID: &str = "throws.unusedType";
const NOT_THROWABLE_ID: &str = "throws.notThrowable";
impl Backend {
pub(crate) fn collect_remove_throws_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 != UNUSED_TYPE_ID && identifier != NOT_THROWABLE_ID {
continue;
}
let type_name = match extract_throws_type(&diag.message, identifier) {
Some(t) => t,
None => continue,
};
let type_name_str = type_name.to_string();
let short_name = short_name(&type_name_str);
let diag_line = diag.range.start.line as usize;
let docblock = match find_docblock_above_line(content, diag_line) {
Some(db) => db,
None => continue,
};
if build_remove_throws_edit(content, &docblock, &type_name_str).is_none() {
continue;
}
let title = format!("Remove @throws {}", short_name);
let extra = serde_json::json!({
"diagnostic_message": diag.message,
"diagnostic_line": diag_line,
"diagnostic_code": identifier,
});
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(make_code_action_data(
"phpstan.removeThrows",
uri,
¶ms.range,
extra,
)),
}));
}
}
pub(crate) fn resolve_remove_throws(
&self,
data: &CodeActionData,
content: &str,
) -> Option<WorkspaceEdit> {
let extra = &data.extra;
let message = extra.get("diagnostic_message")?.as_str()?;
let line = extra.get("diagnostic_line")?.as_u64()? as usize;
let code = extra.get("diagnostic_code")?.as_str()?;
let type_name = extract_throws_type(message, code)?;
let type_name_str = type_name.to_string();
let docblock = find_docblock_above_line(content, line)?;
let throws_edit = build_remove_throws_edit(content, &docblock, &type_name_str)?;
let doc_uri: Url = data.uri.parse().ok()?;
let mut changes = HashMap::new();
changes.insert(doc_uri, vec![throws_edit]);
Some(WorkspaceEdit {
changes: Some(changes),
document_changes: None,
change_annotations: None,
})
}
}
fn extract_throws_type(message: &str, identifier: &str) -> Option<PhpType> {
let raw = if identifier == UNUSED_TYPE_ID {
let marker = " has ";
let start = message.find(marker)? + marker.len();
let rest = &message[start..];
let end = rest.find(" in PHPDoc @throws tag")?;
rest[..end].trim()
} else {
let marker = "@throws with type ";
let start = message.find(marker)? + marker.len();
let rest = &message[start..];
let end = rest.find(" is not subtype")?;
rest[..end].trim()
};
if raw.is_empty() {
return None;
}
Some(PhpType::parse(raw))
}
struct DocblockAbove {
start: usize,
end: usize,
text: String,
}
fn find_docblock_above_line(content: &str, line: usize) -> Option<DocblockAbove> {
let lines: Vec<&str> = content.lines().collect();
if line == 0 || line > lines.len() {
return None;
}
let mut doc_end_line = None;
for i in (0..line).rev() {
let trimmed = lines[i].trim();
if trimmed.is_empty() {
continue;
}
if trimmed.ends_with("*/") {
doc_end_line = Some(i);
break;
}
if trimmed.starts_with("#[") {
continue;
}
break;
}
let end_line = doc_end_line?;
let mut doc_start_line = None;
for i in (0..=end_line).rev() {
let trimmed = lines[i].trim();
if trimmed.contains("/**") {
doc_start_line = Some(i);
break;
}
if !trimmed.starts_with('*') && !trimmed.ends_with("*/") {
break;
}
}
let start_line = doc_start_line?;
let mut byte_offset = 0;
let mut start_byte = 0;
let mut end_byte = 0;
for (i, line_text) in lines.iter().enumerate() {
if i == start_line {
start_byte = byte_offset;
}
byte_offset += line_text.len() + 1; if i == end_line {
end_byte = byte_offset; }
}
let text = content
.get(start_byte..end_byte.min(content.len()))
.unwrap_or("")
.to_string();
Some(DocblockAbove {
start: start_byte,
end: end_byte.min(content.len()),
text,
})
}
fn build_remove_throws_edit(
content: &str,
docblock: &DocblockAbove,
type_name: &str,
) -> Option<TextEdit> {
let short = short_name(type_name);
let doc_lines: Vec<&str> = docblock.text.lines().collect();
let mut lines_to_remove: Vec<usize> = Vec::new();
for (i, line) in doc_lines.iter().enumerate() {
let mut trimmed = line.trim();
if let Some(inner) = trimmed.strip_prefix("/**") {
trimmed = inner.trim_start();
}
if let Some(inner) = trimmed.strip_suffix("*/") {
trimmed = inner.trim_end();
}
trimmed = trimmed.trim_start_matches('*').trim();
if let Some(rest) = trimmed.strip_prefix("@throws") {
let rest = rest.trim_start();
let tag_type = rest.split_whitespace().next().unwrap_or("");
let tag_short = short_name(tag_type);
if tag_short.eq_ignore_ascii_case(short)
|| crate::util::strip_fqn_prefix(tag_type)
.eq_ignore_ascii_case(crate::util::strip_fqn_prefix(type_name))
{
lines_to_remove.push(i);
}
}
}
if lines_to_remove.is_empty() {
return None;
}
let mut extra_removals: Vec<usize> = Vec::new();
for &idx in &lines_to_remove {
if idx > 0 && !lines_to_remove.contains(&(idx - 1)) {
let prev = doc_lines[idx - 1].trim().trim_start_matches('*').trim();
if prev.is_empty() {
let next_idx = lines_to_remove
.iter()
.filter(|&&j| j > idx)
.max()
.copied()
.unwrap_or(idx)
+ 1;
if next_idx < doc_lines.len() {
let next = doc_lines[next_idx].trim();
if next == "*/" || next.trim_start_matches('*').trim().is_empty() {
extra_removals.push(idx - 1);
}
} else {
extra_removals.push(idx - 1);
}
}
}
}
lines_to_remove.extend(extra_removals);
lines_to_remove.sort();
lines_to_remove.dedup();
let new_lines: Vec<&str> = doc_lines
.iter()
.enumerate()
.filter(|(i, _)| !lines_to_remove.contains(i))
.map(|(_, l)| *l)
.collect();
let has_content = new_lines.iter().any(|l| {
let mut t = l.trim();
if let Some(inner) = t.strip_prefix("/**") {
t = inner.trim_start();
}
if let Some(inner) = t.strip_suffix("*/") {
t = inner.trim_end();
}
t = t.trim_start_matches('*').trim();
!t.is_empty()
});
let new_text = if !has_content && new_lines.len() <= 3 {
String::new()
} else {
let mut text = new_lines.join("\n");
if docblock.text.ends_with('\n') && !text.ends_with('\n') {
text.push('\n');
}
text
};
let start = offset_to_position(content, docblock.start);
let end = offset_to_position(content, docblock.end);
Some(TextEdit {
range: Range { start, end },
new_text,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extracts_unused_type_from_method_message() {
let msg = "Method App\\Controllers\\Foo::bar() has Luxplus\\Decimal\\Decimal in PHPDoc @throws tag but it's not thrown.";
let t = extract_throws_type(msg, UNUSED_TYPE_ID).unwrap();
assert_eq!(t.to_string(), "Luxplus\\Decimal\\Decimal");
}
#[test]
fn extracts_unused_type_from_function_message() {
let msg = "Function doStuff() has App\\Exceptions\\FooException in PHPDoc @throws tag but it's not thrown.";
let t = extract_throws_type(msg, UNUSED_TYPE_ID).unwrap();
assert_eq!(t.to_string(), "App\\Exceptions\\FooException");
}
#[test]
fn extracts_unused_type_from_property_hook_message() {
let msg = "Get hook for property App\\Foo::$bar has App\\Exceptions\\PropException in PHPDoc @throws tag but it's not thrown.";
let t = extract_throws_type(msg, UNUSED_TYPE_ID).unwrap();
assert_eq!(t.to_string(), "App\\Exceptions\\PropException");
}
#[test]
fn extracts_not_throwable_type() {
let msg =
"PHPDoc tag @throws with type App\\Http\\Controllers\\not is not subtype of Throwable";
let t = extract_throws_type(msg, NOT_THROWABLE_ID).unwrap();
assert_eq!(t.to_string(), "App\\Http\\Controllers\\not");
}
#[test]
fn extracts_not_throwable_fqn_type() {
let msg = "PHPDoc tag @throws with type \\TheSeer\\Tokenizer\\Exception is not subtype of Throwable";
let t = extract_throws_type(msg, NOT_THROWABLE_ID).unwrap();
assert_eq!(t.to_string(), "\\TheSeer\\Tokenizer\\Exception");
}
#[test]
fn returns_none_for_unrelated_message() {
assert!(extract_throws_type("Some other error.", UNUSED_TYPE_ID).is_none());
assert!(extract_throws_type("Some other error.", NOT_THROWABLE_ID).is_none());
}
#[test]
fn short_name_simple() {
assert_eq!(short_name("RuntimeException"), "RuntimeException");
}
#[test]
fn short_name_namespaced() {
assert_eq!(short_name("App\\Exceptions\\FooException"), "FooException");
}
#[test]
fn short_name_leading_backslash() {
assert_eq!(short_name("\\TheSeer\\Tokenizer\\Exception"), "Exception");
}
#[test]
fn short_name_non_class() {
assert_eq!(short_name("not"), "not");
}
#[test]
fn finds_docblock_directly_above() {
let php = "\
<?php
class Foo {
/**
* @throws Decimal
*/
public function bar(): void {}
}
";
let db = find_docblock_above_line(php, 5).unwrap();
assert!(db.text.contains("@throws Decimal"), "got: {}", db.text);
}
#[test]
fn finds_docblock_with_blank_line_between() {
let php = "\
<?php
class Foo {
/**
* @throws Decimal
*/
public function bar(): void {}
}
";
let db = find_docblock_above_line(php, 6).unwrap();
assert!(db.text.contains("@throws Decimal"), "got: {}", db.text);
}
#[test]
fn finds_docblock_with_attribute_between() {
let php = "\
<?php
class Foo {
/**
* @throws Decimal
*/
#[SomeAttribute]
public function bar(): void {}
}
";
let db = find_docblock_above_line(php, 6).unwrap();
assert!(db.text.contains("@throws Decimal"), "got: {}", db.text);
}
#[test]
fn no_docblock_found() {
let php = "\
<?php
class Foo {
public function bar(): void {}
}
";
assert!(find_docblock_above_line(php, 2).is_none());
}
#[test]
fn removes_throws_line_from_docblock() {
let php = "\
<?php
class Foo {
/**
* Summary.
*
* @throws Decimal
*/
public function bar(): void {}
}
";
let db = find_docblock_above_line(php, 7).unwrap();
let edit = build_remove_throws_edit(php, &db, "Luxplus\\Decimal\\Decimal").unwrap();
assert!(
!edit.new_text.contains("@throws"),
"should remove @throws: {:?}",
edit.new_text
);
assert!(
edit.new_text.contains("Summary."),
"should preserve summary: {:?}",
edit.new_text
);
}
#[test]
fn removes_fqn_throws_line() {
let php = "\
<?php
class Foo {
/**
* @throws \\TheSeer\\Tokenizer\\Exception
*/
public function bar(): void {}
}
";
let db = find_docblock_above_line(php, 5).unwrap();
let edit = build_remove_throws_edit(php, &db, "\\TheSeer\\Tokenizer\\Exception").unwrap();
assert_eq!(
edit.new_text, "",
"empty docblock should be removed entirely"
);
}
#[test]
fn removes_short_name_throws_matching_fqn() {
let php = "\
<?php
class Foo {
/**
* @throws Exception
*/
public function bar(): void {}
}
";
let db = find_docblock_above_line(php, 5).unwrap();
let edit = build_remove_throws_edit(php, &db, "TheSeer\\Tokenizer\\Exception").unwrap();
assert_eq!(edit.new_text, "");
}
#[test]
fn preserves_other_throws_tags() {
let php = "\
<?php
class Foo {
/**
* @throws FooException
* @throws BarException
*/
public function bar(): void {}
}
";
let db = find_docblock_above_line(php, 6).unwrap();
let edit = build_remove_throws_edit(php, &db, "FooException").unwrap();
assert!(
!edit.new_text.contains("FooException"),
"should remove FooException: {:?}",
edit.new_text
);
assert!(
edit.new_text.contains("@throws BarException"),
"should keep BarException: {:?}",
edit.new_text
);
}
#[test]
fn removes_throws_with_non_class_text() {
let php = "\
<?php
class Foo {
/**
* @throws not even correct
*/
public function bar(): void {}
}
";
let db = find_docblock_above_line(php, 5).unwrap();
let edit = build_remove_throws_edit(php, &db, "App\\Http\\Controllers\\not").unwrap();
assert_eq!(edit.new_text, "");
}
#[test]
fn removes_entire_empty_docblock() {
let php = "\
<?php
class Foo {
/**
* @throws Decimal
*/
public function bar(): void {}
}
";
let db = find_docblock_above_line(php, 5).unwrap();
let edit = build_remove_throws_edit(php, &db, "Decimal").unwrap();
assert_eq!(
edit.new_text, "",
"docblock with only @throws should be removed"
);
}
#[test]
fn keeps_docblock_with_other_content() {
let php = "\
<?php
class Foo {
/**
* Summary.
*
* @param string $a
* @throws Decimal
*
* @return string
*/
public function bar(string $a): string {}
}
";
let db = find_docblock_above_line(php, 10).unwrap();
let edit = build_remove_throws_edit(php, &db, "Decimal").unwrap();
assert!(
edit.new_text.contains("Summary."),
"should keep summary: {:?}",
edit.new_text
);
assert!(
edit.new_text.contains("@param"),
"should keep @param: {:?}",
edit.new_text
);
assert!(
edit.new_text.contains("@return"),
"should keep @return: {:?}",
edit.new_text
);
assert!(
!edit.new_text.contains("@throws"),
"should remove @throws: {:?}",
edit.new_text
);
}
#[test]
fn no_match_returns_none() {
let php = "\
<?php
class Foo {
/**
* @throws FooException
*/
public function bar(): void {}
}
";
let db = find_docblock_above_line(php, 5).unwrap();
assert!(build_remove_throws_edit(php, &db, "BarException").is_none());
}
#[test]
fn removes_orphaned_blank_separator() {
let php = "\
<?php
class Foo {
/**
* @param string $a
*
* @throws Decimal
*/
public function bar(string $a): void {}
}
";
let db = find_docblock_above_line(php, 7).unwrap();
let edit = build_remove_throws_edit(php, &db, "Decimal").unwrap();
let trailing_blank = edit.new_text.contains(" *\n */");
assert!(
!trailing_blank,
"should not leave orphaned blank line: {:?}",
edit.new_text
);
}
#[test]
fn removes_single_line_docblock_with_throws() {
let php = "<?php\nclass Foo {\n /** @throws Decimal */\n public function bar(): void {}\n}\n";
let db = find_docblock_above_line(php, 3)
.expect("should find single-line docblock above line 3");
let edit = build_remove_throws_edit(php, &db, "Decimal").unwrap();
assert_eq!(
edit.new_text, "",
"single-line docblock with only @throws should be removed"
);
}
}