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;
use super::split_phpstan_tip;
const CLASS_PREFIXED_ID: &str = "class.prefixed";
const ACTION_KIND: &str = "phpstan.fixPrefixedClass";
#[derive(Debug, Clone, PartialEq, Eq)]
struct PrefixedClassInfo {
prefixed: String,
corrected: String,
}
impl Backend {
pub(crate) fn collect_fix_prefixed_class_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 != CLASS_PREFIXED_ID {
continue;
}
let info = match parse_prefixed_diagnostic(&diag.message) {
Some(i) => i,
None => continue,
};
let diag_line = diag.range.start.line as usize;
let lines: Vec<&str> = content.lines().collect();
if diag_line >= lines.len() {
continue;
}
let line_text = lines[diag_line];
let fqn_prefixed = format!("\\{}", info.prefixed);
let actual_prefixed = if find_occurrence(line_text, &fqn_prefixed).is_some() {
fqn_prefixed
} else if find_occurrence(line_text, &info.prefixed).is_some() {
info.prefixed.clone()
} else {
continue;
};
let title = format!("Replace {} with {}", actual_prefixed, info.corrected);
let extra = serde_json::json!({
"diagnostic_message": diag.message,
"diagnostic_line": diag_line,
"diagnostic_code": CLASS_PREFIXED_ID,
"prefixed_name": actual_prefixed,
"corrected_name": info.corrected,
});
let data = make_code_action_data(ACTION_KIND, 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_fix_prefixed_class(
&self,
data: &CodeActionData,
content: &str,
) -> Option<WorkspaceEdit> {
let extra = &data.extra;
let diag_line = extra.get("diagnostic_line")?.as_u64()? as usize;
let prefixed = extra.get("prefixed_name")?.as_str()?;
let corrected = extra.get("corrected_name")?.as_str()?;
let edit = build_fix_prefixed_edit(content, diag_line, prefixed, corrected)?;
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 parse_prefixed_diagnostic(message: &str) -> Option<PrefixedClassInfo> {
let prefixed = extract_prefixed_name(message)?;
let corrected = extract_corrected_name(message)?;
Some(PrefixedClassInfo {
prefixed,
corrected,
})
}
fn extract_prefixed_name(message: &str) -> Option<String> {
let (msg, _tip) = split_phpstan_tip(message);
let marker = " class: ";
let start = msg.find(marker)? + marker.len();
let rest = &msg[start..];
let name = rest.trim_end_matches('.');
if name.is_empty() {
return None;
}
Some(name.to_string())
}
fn extract_corrected_name(message: &str) -> Option<String> {
let (_msg, tip) = split_phpstan_tip(message);
let tip = tip?;
let marker = "Did you mean to type ";
let start = tip.find(marker)? + marker.len();
let rest = &tip[start..];
let end = rest.rfind('?')?;
let name = rest[..end].trim();
if name.is_empty() {
return None;
}
Some(name.to_string())
}
fn build_fix_prefixed_edit(
content: &str,
diag_line: usize,
prefixed: &str,
corrected: &str,
) -> Option<TextEdit> {
let lines: Vec<&str> = content.lines().collect();
if diag_line >= lines.len() {
return None;
}
let line_text = lines[diag_line];
let byte_col = find_occurrence(line_text, prefixed)?;
let start_char = byte_offset_to_utf16(line_text, byte_col);
let end_char = byte_offset_to_utf16(line_text, byte_col + prefixed.len());
Some(TextEdit {
range: Range {
start: Position {
line: diag_line as u32,
character: start_char,
},
end: Position {
line: diag_line as u32,
character: end_char,
},
},
new_text: corrected.to_string(),
})
}
fn find_occurrence(line: &str, name: &str) -> Option<usize> {
let mut search_start = 0;
while let Some(pos) = line[search_start..].find(name) {
let abs_pos = search_start + pos;
if abs_pos > 0 {
let prev_byte = line.as_bytes()[abs_pos - 1];
if prev_byte.is_ascii_alphanumeric() || prev_byte == b'_' || prev_byte == b'\\' {
search_start = abs_pos + 1;
continue;
}
}
let end_pos = abs_pos + name.len();
if end_pos < line.len() {
let next_byte = line.as_bytes()[end_pos];
if next_byte.is_ascii_alphanumeric() || next_byte == b'_' {
search_start = abs_pos + 1;
continue;
}
}
return Some(abs_pos);
}
None
}
fn byte_offset_to_utf16(line: &str, byte_offset: usize) -> u32 {
let prefix = &line[..byte_offset.min(line.len())];
prefix.encode_utf16().count() as u32
}
pub(crate) fn is_fix_prefixed_class_stale(content: &str, diag_line: usize, message: &str) -> bool {
let prefixed = match extract_prefixed_name(message) {
Some(name) => name,
None => return false,
};
let lines: Vec<&str> = content.lines().collect();
if diag_line >= lines.len() {
return true;
}
let line_text = lines[diag_line];
let fqn_prefixed = format!("\\{}", prefixed);
find_occurrence(line_text, &prefixed).is_none()
&& find_occurrence(line_text, &fqn_prefixed).is_none()
}
#[cfg(test)]
mod tests {
use super::*;
const PHPSTAN_MSG: &str = "Referencing prefixed PHPStan class: _PHPStan_test\\SomeClass.\n\
This is most likely unintentional. Did you mean to type \\SomeClass?";
const RECTOR_MSG: &str = "Referencing prefixed Rector class: RectorPrefix202501\\Symfony\\Component\\Console\\Application.\n\
This is most likely unintentional. Did you mean to type \\Symfony\\Component\\Console\\Application?";
const SCOPER_MSG: &str = "Referencing prefixed PHP-Scoper class: _PhpScoper123\\GuzzleHttp\\Client.\n\
This is most likely unintentional. Did you mean to type \\GuzzleHttp\\Client?";
const PHPUNIT_MSG: &str = "Referencing prefixed PHPUnit class: PHPUnitPHAR\\SebastianBergmann\\Diff\\Differ.\n\
This is most likely unintentional. Did you mean to type \\SebastianBergmann\\Diff\\Differ?";
const HUMBUG_MSG: &str = "Referencing prefixed Box class: _HumbugBox456\\Composer\\Autoload\\ClassLoader.\n\
This is most likely unintentional. Did you mean to type \\Composer\\Autoload\\ClassLoader?";
#[test]
fn extracts_phpstan_prefixed_name() {
assert_eq!(
extract_prefixed_name(PHPSTAN_MSG),
Some("_PHPStan_test\\SomeClass".into())
);
}
#[test]
fn extracts_rector_prefixed_name() {
assert_eq!(
extract_prefixed_name(RECTOR_MSG),
Some("RectorPrefix202501\\Symfony\\Component\\Console\\Application".into())
);
}
#[test]
fn extracts_scoper_prefixed_name() {
assert_eq!(
extract_prefixed_name(SCOPER_MSG),
Some("_PhpScoper123\\GuzzleHttp\\Client".into())
);
}
#[test]
fn extracts_phpunit_prefixed_name() {
assert_eq!(
extract_prefixed_name(PHPUNIT_MSG),
Some("PHPUnitPHAR\\SebastianBergmann\\Diff\\Differ".into())
);
}
#[test]
fn extracts_humbug_prefixed_name() {
assert_eq!(
extract_prefixed_name(HUMBUG_MSG),
Some("_HumbugBox456\\Composer\\Autoload\\ClassLoader".into())
);
}
#[test]
fn prefixed_name_returns_none_without_marker() {
assert_eq!(extract_prefixed_name("Some unrelated error."), None);
}
#[test]
fn extracts_phpstan_corrected_name() {
assert_eq!(
extract_corrected_name(PHPSTAN_MSG),
Some("\\SomeClass".into())
);
}
#[test]
fn extracts_rector_corrected_name() {
assert_eq!(
extract_corrected_name(RECTOR_MSG),
Some("\\Symfony\\Component\\Console\\Application".into())
);
}
#[test]
fn corrected_name_returns_none_without_tip() {
assert_eq!(
extract_corrected_name("Referencing prefixed PHPStan class: _PHPStan_test\\Bar."),
None
);
}
#[test]
fn corrected_name_returns_none_for_wrong_tip_format() {
let msg = "Some error.\nSome tip without the expected format.";
assert_eq!(extract_corrected_name(msg), None);
}
#[test]
fn handles_extra_whitespace_in_tip() {
let msg = "Error.\n Did you mean to type \\Foo ?";
assert_eq!(extract_corrected_name(msg), Some("\\Foo".into()));
}
#[test]
fn parses_full_diagnostic_phpstan() {
let info = parse_prefixed_diagnostic(PHPSTAN_MSG).unwrap();
assert_eq!(info.prefixed, "_PHPStan_test\\SomeClass");
assert_eq!(info.corrected, "\\SomeClass");
}
#[test]
fn parses_full_diagnostic_rector() {
let info = parse_prefixed_diagnostic(RECTOR_MSG).unwrap();
assert_eq!(
info.prefixed,
"RectorPrefix202501\\Symfony\\Component\\Console\\Application"
);
assert_eq!(info.corrected, "\\Symfony\\Component\\Console\\Application");
}
#[test]
fn parses_full_diagnostic_scoper() {
let info = parse_prefixed_diagnostic(SCOPER_MSG).unwrap();
assert_eq!(info.prefixed, "_PhpScoper123\\GuzzleHttp\\Client");
assert_eq!(info.corrected, "\\GuzzleHttp\\Client");
}
#[test]
fn returns_none_without_both_parts() {
assert!(
parse_prefixed_diagnostic("Referencing prefixed PHPStan class: _PHPStan_test\\Bar.")
.is_none()
);
}
#[test]
fn finds_prefixed_at_start_of_line() {
assert_eq!(
find_occurrence(
"_PHPStan_test\\SomeClass::create()",
"_PHPStan_test\\SomeClass"
),
Some(0)
);
}
#[test]
fn finds_prefixed_after_new() {
assert_eq!(
find_occurrence("new _PHPStan_test\\SomeClass()", "_PHPStan_test\\SomeClass"),
Some(4)
);
}
#[test]
fn finds_fqn_prefixed_after_new() {
assert_eq!(
find_occurrence(
"new \\_PHPStan_test\\SomeClass()",
"\\_PHPStan_test\\SomeClass"
),
Some(4)
);
}
#[test]
fn skips_partial_match_longer_name() {
assert_eq!(
find_occurrence(
"new _PHPStan_test\\SomeClassFactory()",
"_PHPStan_test\\SomeClass"
),
None
);
}
#[test]
fn skips_embedded_in_longer_prefix() {
assert_eq!(
find_occurrence(
"x_PHPStan_test\\SomeClass::bar()",
"_PHPStan_test\\SomeClass"
),
None
);
}
#[test]
fn finds_prefixed_before_semicolon() {
assert_eq!(
find_occurrence(
"$x = new _PhpScoper123\\GuzzleHttp\\Client;",
"_PhpScoper123\\GuzzleHttp\\Client"
),
Some(9)
);
}
#[test]
fn finds_fqn_prefixed_before_semicolon() {
assert_eq!(
find_occurrence(
"$x = new \\_PhpScoper123\\GuzzleHttp\\Client;",
"\\_PhpScoper123\\GuzzleHttp\\Client"
),
Some(9)
);
}
#[test]
fn finds_prefixed_before_paren() {
assert_eq!(
find_occurrence(
"_PhpScoper123\\GuzzleHttp\\Client::create()",
"_PhpScoper123\\GuzzleHttp\\Client"
),
Some(0)
);
}
#[test]
fn allows_backslash_after_match() {
assert_eq!(
find_occurrence("new _PHPStan_test\\SomeClass()", "_PHPStan_test\\Some"),
None
);
}
#[test]
fn builds_edit_phpstan_class_bare() {
let content = "<?php\nnew _PHPStan_test\\SomeClass();";
let edit =
build_fix_prefixed_edit(content, 1, "_PHPStan_test\\SomeClass", "\\SomeClass").unwrap();
assert_eq!(edit.range.start.line, 1);
assert_eq!(edit.range.start.character, 4);
assert_eq!(edit.range.end.character, 27);
assert_eq!(edit.new_text, "\\SomeClass");
}
#[test]
fn builds_edit_phpstan_class_fqn() {
let content = "<?php\nnew \\_PHPStan_test\\SomeClass();";
let edit = build_fix_prefixed_edit(content, 1, "\\_PHPStan_test\\SomeClass", "\\SomeClass")
.unwrap();
assert_eq!(edit.range.start.line, 1);
assert_eq!(edit.range.start.character, 4);
assert_eq!(edit.range.end.character, 28);
assert_eq!(edit.new_text, "\\SomeClass");
}
#[test]
fn builds_edit_rector_class() {
let content = "<?php\nRectorPrefix202501\\Symfony\\Component\\Console\\Application::run();";
let edit = build_fix_prefixed_edit(
content,
1,
"RectorPrefix202501\\Symfony\\Component\\Console\\Application",
"\\Symfony\\Component\\Console\\Application",
)
.unwrap();
assert_eq!(edit.range.start.line, 1);
assert_eq!(edit.range.start.character, 0);
assert_eq!(edit.new_text, "\\Symfony\\Component\\Console\\Application");
}
#[test]
fn returns_none_when_prefixed_not_found() {
let content = "<?php\nnew \\SomeClass();";
assert!(
build_fix_prefixed_edit(content, 1, "_PHPStan_test\\SomeClass", "\\SomeClass")
.is_none()
);
}
#[test]
fn returns_none_for_out_of_bounds_line() {
let content = "<?php\n";
assert!(
build_fix_prefixed_edit(content, 5, "_PHPStan_test\\SomeClass", "\\SomeClass")
.is_none()
);
}
#[test]
fn utf16_ascii_line() {
assert_eq!(byte_offset_to_utf16("new _PHPStan_foo\\SomeClass()", 4), 4);
assert_eq!(
byte_offset_to_utf16("new _PHPStan_foo\\SomeClass()", 25),
25
);
}
#[test]
fn utf16_with_multibyte_before() {
let line = "é_PHPStan_test\\Cls";
assert_eq!(byte_offset_to_utf16(line, 2), 1); }
#[test]
fn stale_when_prefix_removed() {
let content = "<?php\nnew \\SomeClass();";
assert!(is_fix_prefixed_class_stale(content, 1, PHPSTAN_MSG));
}
#[test]
fn not_stale_when_prefix_still_present() {
let content = "<?php\nnew _PHPStan_test\\SomeClass();";
assert!(!is_fix_prefixed_class_stale(content, 1, PHPSTAN_MSG));
}
#[test]
fn not_stale_when_fqn_prefix_still_present() {
let content = "<?php\nnew \\_PHPStan_test\\SomeClass();";
assert!(!is_fix_prefixed_class_stale(content, 1, PHPSTAN_MSG));
}
#[test]
fn stale_when_line_deleted() {
let content = "<?php\n";
assert!(is_fix_prefixed_class_stale(content, 5, PHPSTAN_MSG));
}
#[test]
fn not_stale_without_parseable_message() {
let content = "<?php\nnew _PHPStan_foo\\SomeClass();";
assert!(!is_fix_prefixed_class_stale(
content,
1,
"Some error without the expected format."
));
}
#[test]
fn full_roundtrip_phpstan_bare() {
let content = "<?php\nnew _PHPStan_test\\SomeClass();";
let info = parse_prefixed_diagnostic(PHPSTAN_MSG).unwrap();
assert_eq!(info.prefixed, "_PHPStan_test\\SomeClass");
assert_eq!(info.corrected, "\\SomeClass");
let edit = build_fix_prefixed_edit(content, 1, &info.prefixed, &info.corrected).unwrap();
assert_eq!(edit.new_text, "\\SomeClass");
let line = content.lines().nth(1).unwrap();
let start = edit.range.start.character as usize;
let end = edit.range.end.character as usize;
let before = &line[..start];
let after = &line[end..];
let result = format!("<?php\n{}{}{}", before, edit.new_text, after);
assert_eq!(result, "<?php\nnew \\SomeClass();");
assert!(!is_fix_prefixed_class_stale(content, 1, PHPSTAN_MSG));
assert!(is_fix_prefixed_class_stale(&result, 1, PHPSTAN_MSG));
}
#[test]
fn full_roundtrip_phpstan_fqn() {
let content = "<?php\nnew \\_PHPStan_test\\SomeClass();";
let info = parse_prefixed_diagnostic(PHPSTAN_MSG).unwrap();
let fqn_prefixed = format!("\\{}", info.prefixed);
let edit = build_fix_prefixed_edit(content, 1, &fqn_prefixed, &info.corrected).unwrap();
assert_eq!(edit.new_text, "\\SomeClass");
let line = content.lines().nth(1).unwrap();
let start = edit.range.start.character as usize;
let end = edit.range.end.character as usize;
let before = &line[..start];
let after = &line[end..];
let result = format!("<?php\n{}{}{}", before, edit.new_text, after);
assert_eq!(result, "<?php\nnew \\SomeClass();");
assert!(!is_fix_prefixed_class_stale(content, 1, PHPSTAN_MSG));
assert!(is_fix_prefixed_class_stale(&result, 1, PHPSTAN_MSG));
}
#[test]
fn full_roundtrip_rector_bare() {
let content = "<?php\nRectorPrefix202501\\Symfony\\Component\\Console\\Application::run();";
let info = parse_prefixed_diagnostic(RECTOR_MSG).unwrap();
let edit = build_fix_prefixed_edit(content, 1, &info.prefixed, &info.corrected).unwrap();
let line = content.lines().nth(1).unwrap();
let start = edit.range.start.character as usize;
let end = edit.range.end.character as usize;
let before = &line[..start];
let after = &line[end..];
let result = format!("<?php\n{}{}{}", before, edit.new_text, after);
assert_eq!(
result,
"<?php\n\\Symfony\\Component\\Console\\Application::run();"
);
}
#[test]
fn full_roundtrip_scoper() {
let content = "<?php\n$c = new _PhpScoper123\\GuzzleHttp\\Client();";
let info = parse_prefixed_diagnostic(SCOPER_MSG).unwrap();
let edit = build_fix_prefixed_edit(content, 1, &info.prefixed, &info.corrected).unwrap();
assert_eq!(edit.new_text, "\\GuzzleHttp\\Client");
}
#[test]
fn full_roundtrip_phpunit() {
let content = "<?php\n$d = new PHPUnitPHAR\\SebastianBergmann\\Diff\\Differ();";
let info = parse_prefixed_diagnostic(PHPUNIT_MSG).unwrap();
let edit = build_fix_prefixed_edit(content, 1, &info.prefixed, &info.corrected).unwrap();
assert_eq!(edit.new_text, "\\SebastianBergmann\\Diff\\Differ");
}
#[test]
fn full_roundtrip_humbug() {
let content = "<?php\n$e = new _HumbugBox456\\Composer\\Autoload\\ClassLoader();";
let info = parse_prefixed_diagnostic(HUMBUG_MSG).unwrap();
let edit = build_fix_prefixed_edit(content, 1, &info.prefixed, &info.corrected).unwrap();
assert_eq!(edit.new_text, "\\Composer\\Autoload\\ClassLoader");
}
#[test]
fn multiple_occurrences_fixes_first() {
let content = "<?php\n_PHPStan_test\\SomeClass::bar(new _PHPStan_test\\SomeClass());";
let edit =
build_fix_prefixed_edit(content, 1, "_PHPStan_test\\SomeClass", "\\SomeClass").unwrap();
assert_eq!(edit.range.start.character, 0);
}
#[test]
fn does_not_match_substring_class() {
let content = "<?php\nnew _PHPStan_test\\SomeClassFactory();";
assert!(
build_fix_prefixed_edit(content, 1, "_PHPStan_test\\SomeClass", "\\SomeClass")
.is_none()
);
}
#[test]
fn instanceof_context() {
let content = "<?php\nif ($a instanceof _PHPStan_foo\\SomeInterface) {}";
let edit =
build_fix_prefixed_edit(content, 1, "_PHPStan_foo\\SomeInterface", "\\SomeInterface")
.unwrap();
assert_eq!(edit.new_text, "\\SomeInterface");
}
#[test]
fn type_hint_context() {
let content = "<?php\nfunction doSomething(_PhpScoper123\\GuzzleHttp\\ClientInterface $client): void {}";
let edit = build_fix_prefixed_edit(
content,
1,
"_PhpScoper123\\GuzzleHttp\\ClientInterface",
"\\GuzzleHttp\\ClientInterface",
)
.unwrap();
assert_eq!(edit.new_text, "\\GuzzleHttp\\ClientInterface");
}
#[test]
fn catch_clause_context() {
let content = "<?php\n} catch (_PHPStan_test\\SomeException $e) {";
let edit = build_fix_prefixed_edit(
content,
1,
"_PHPStan_test\\SomeException",
"\\SomeException",
)
.unwrap();
assert_eq!(edit.new_text, "\\SomeException");
}
}