mod argument_count;
mod deprecated;
pub(crate) mod helpers;
mod implementation_errors;
mod syntax_errors;
pub(crate) mod undefined_variables;
pub(crate) mod unknown_classes;
pub(crate) mod unknown_functions;
pub(crate) mod unknown_members;
pub(crate) mod unresolved_member_access;
mod unused_imports;
use std::sync::Arc;
use std::sync::atomic::Ordering;
use tower_lsp::lsp_types::*;
use crate::Backend;
use crate::phpstan;
use crate::util::ranges_overlap;
impl Backend {
fn should_skip_diagnostics(&self, uri_str: &str) -> bool {
uri_str.starts_with("phpantom-stub://") || uri_str.starts_with("phpantom-stub-fn://")
}
pub(crate) fn collect_fast_diagnostics(
&self,
uri_str: &str,
content: &str,
out: &mut Vec<Diagnostic>,
) {
self.collect_syntax_error_diagnostics(uri_str, content, out);
self.collect_unused_import_diagnostics(uri_str, content, out);
}
pub(crate) fn collect_slow_diagnostics(
&self,
uri_str: &str,
content: &str,
out: &mut Vec<Diagnostic>,
) {
self.collect_unknown_class_diagnostics(uri_str, content, out);
self.collect_unknown_member_diagnostics(uri_str, content, out);
self.collect_unknown_function_diagnostics(uri_str, content, out);
self.collect_argument_count_diagnostics(uri_str, content, out);
self.collect_implementation_error_diagnostics(uri_str, content, out);
self.collect_deprecated_diagnostics(uri_str, content, out);
self.collect_undefined_variable_diagnostics(uri_str, content, out);
}
fn merge_fast_with_cached(&self, uri_str: &str, fast: &[Diagnostic]) -> Vec<Diagnostic> {
let mut merged = fast.to_vec();
{
let cache = self.diag_last_slow.lock();
if let Some(prev_slow) = cache.get(uri_str) {
merged.extend(prev_slow.iter().cloned());
}
}
{
let content: Option<Arc<String>> = self.open_files.read().get(uri_str).cloned();
let mut cache = self.phpstan_last_diags.lock();
if let Some(prev_phpstan) = cache.get(uri_str) {
let filtered: Vec<Diagnostic> = prev_phpstan
.iter()
.filter(|d| {
if let Some(ref text) = content {
!is_stale_phpstan_diagnostic(d, text)
} else {
true
}
})
.cloned()
.collect();
if filtered.len() != prev_phpstan.len() {
cache.insert(uri_str.to_string(), filtered.clone());
}
merged.extend(filtered);
}
}
deduplicate_diagnostics(&mut merged);
merged
}
}
fn is_stale_phpstan_diagnostic(diag: &Diagnostic, content: &str) -> bool {
let identifier = match &diag.code {
Some(NumberOrString::String(s)) => s.as_str(),
_ => return false,
};
if !identifier.is_empty()
&& identifier != "phpstan"
&& !identifier.starts_with("ignore.unmatched")
&& line_has_ignore_for(content, diag.range.start.line, identifier)
{
return true;
}
if identifier == "method.override"
|| identifier == "property.override"
|| identifier == "property.overrideAttribute"
{
return crate::code_actions::phpstan::remove_override::is_remove_override_stale(
content,
diag.range.start.line as usize,
);
}
if identifier == "method.tentativeReturnType" {
return crate::code_actions::phpstan::add_return_type_will_change::is_add_return_type_will_change_stale(
content,
diag.range.start.line as usize,
);
}
if identifier == "return.phpDocType"
|| identifier == "parameter.phpDocType"
|| identifier == "property.phpDocType"
{
return crate::code_actions::phpstan::fix_phpdoc_type::is_fix_phpdoc_type_stale(
content,
diag.range.start.line as usize,
&diag.message,
identifier,
);
}
if identifier == "new.static" {
return crate::code_actions::phpstan::new_static::is_new_static_stale(
content,
diag.range.start.line as usize,
);
}
if identifier == "class.prefixed" {
return crate::code_actions::phpstan::fix_prefixed_class::is_fix_prefixed_class_stale(
content,
diag.range.start.line as usize,
&diag.message,
);
}
if identifier == "function.alreadyNarrowedType"
&& diag.message.starts_with("Call to function assert()")
{
return crate::code_actions::phpstan::remove_assert::is_remove_assert_stale(
content,
diag.range.start.line as usize,
);
}
if identifier == "return.void"
|| identifier == "return.empty"
|| identifier == "missingType.return"
{
return crate::code_actions::phpstan::fix_return_type::is_fix_return_type_stale(
content,
diag.range.start.line as usize,
identifier,
);
}
if identifier == "deadCode.unreachable" {
return crate::code_actions::phpstan::remove_unreachable::is_remove_unreachable_stale(
content,
diag.range.start.line as usize,
);
}
if identifier == "missingType.iterableValue" {
return crate::code_actions::phpstan::add_iterable_type::is_add_iterable_type_stale(
content,
diag.range.start.line as usize,
&diag.message,
);
}
if identifier == "return.unusedType" {
return crate::code_actions::phpstan::remove_unused_return_type::is_remove_unused_return_type_stale(
content,
diag.range.start.line as usize,
&diag.message,
);
}
false
}
#[cfg(test)]
#[allow(dead_code)]
fn extract_throws_diag_type(message: &str, identifier: &str) -> Option<String> {
if identifier == "throws.unusedType" {
let start = message.find(" has ")? + 5;
let rest = &message[start..];
let end = rest.find(" in PHPDoc @throws tag")?;
Some(rest[..end].trim().to_string())
} else {
let start = message.find("@throws with type ")? + 18;
let rest = &message[start..];
let end = rest.find(" is not subtype")?;
Some(rest[..end].trim().to_string())
}
}
#[cfg(test)]
#[allow(dead_code)]
fn extract_checked_exception_fqn(message: &str) -> Option<String> {
let marker = "throws checked exception ";
let start = message.find(marker)? + marker.len();
let rest = &message[start..];
let end = rest.find(" but")?;
let fqn = crate::util::strip_fqn_prefix(rest[..end].trim());
if fqn.is_empty() {
return None;
}
Some(fqn.to_string())
}
fn line_has_ignore_for(content: &str, diag_line: u32, identifier: &str) -> bool {
let lines: Vec<&str> = content.lines().collect();
let line_idx = diag_line as usize;
for idx in [line_idx, line_idx.wrapping_sub(1)] {
if idx >= lines.len() {
continue;
}
let line = lines[idx];
if let Some(ignore_pos) = line.find("@phpstan-ignore") {
let after = &line[ignore_pos + "@phpstan-ignore".len()..];
if after.starts_with("-line") || after.starts_with("-next-line") {
continue;
}
let ids_text = after.trim_start();
let ids_end = ids_text
.find("*/")
.or_else(|| ids_text.find(" ("))
.unwrap_or(ids_text.len());
let ids = &ids_text[..ids_end];
if ids.split(',').any(|id| id.trim() == identifier) {
return true;
}
}
}
false
}
#[cfg(test)]
#[allow(dead_code)]
fn enclosing_docblock_text(content: &str, diag_line: usize) -> String {
use crate::util::{contains_function_keyword, strip_trailing_modifiers};
let lines: Vec<&str> = content.lines().collect();
if diag_line >= lines.len() {
return String::new();
}
let mut func_line: Option<usize> = None;
for idx in (0..=diag_line).rev() {
if contains_function_keyword(lines[idx]) {
func_line = Some(idx);
break;
}
}
if func_line.is_none() {
let start = diag_line + 1;
let limit = (diag_line + 10).min(lines.len());
for (i, line) in lines[start..limit].iter().enumerate() {
if contains_function_keyword(line) {
func_line = Some(start + i);
break;
}
}
}
let func_line = match func_line {
Some(l) => l,
None => return String::new(),
};
let line_byte_start: usize = lines.iter().take(func_line).map(|l| l.len() + 1).sum();
let func_kw_rel = match lines[func_line].find("function") {
Some(p) => p,
None => return String::new(),
};
let func_kw_pos = line_byte_start + func_kw_rel;
let before_func = &content[..func_kw_pos];
let trimmed = before_func.trim_end();
let after_mods = strip_trailing_modifiers(trimmed);
if after_mods.ends_with("*/")
&& let Some(open) = after_mods.rfind("/**")
{
return after_mods[open..].to_string();
}
String::new()
}
#[cfg(test)]
#[allow(dead_code)]
fn scope_has_throws_tag(scope: &str, short_name: &str) -> bool {
let lower = short_name.to_lowercase();
crate::docblock::extract_throws_tags(scope)
.iter()
.any(|ty| {
ty.base_name()
.map(crate::util::short_name)
.is_some_and(|s| s.eq_ignore_ascii_case(&lower))
})
}
const DIAGNOSTIC_DEBOUNCE_MS: u64 = 500;
const PHPSTAN_DEBOUNCE_MS: u64 = 2_000;
impl Backend {
pub(crate) async fn publish_diagnostics_for_file(&self, uri_str: &str, content: &str) {
let client = match &self.client {
Some(c) => c,
None => return,
};
if self.should_skip_diagnostics(uri_str) {
return;
}
let pull_mode = self.supports_pull_diagnostics.load(Ordering::Acquire);
let mut fast_diagnostics = Vec::new();
self.collect_fast_diagnostics(uri_str, content, &mut fast_diagnostics);
let phase1 = self.merge_fast_with_cached(uri_str, &fast_diagnostics);
let phase1 = self.filter_suppressed(phase1);
let uri = match uri_str.parse::<Url>() {
Ok(u) => u,
Err(_) => return,
};
client.publish_diagnostics(uri.clone(), phase1, None).await;
let mut slow_diagnostics = Vec::new();
{
let _cache_guard = crate::virtual_members::with_active_resolved_class_cache(
&self.resolved_class_cache,
);
let _subj_guard = crate::completion::resolver::with_diagnostic_subject_cache();
if let Some(sm) = self.symbol_maps.read().get(uri_str) {
crate::completion::resolver::set_diagnostic_subject_cache_scopes(
sm.scopes.clone(),
sm.var_defs.clone(),
sm.narrowing_blocks.clone(),
sm.assert_narrowing_offsets.clone(),
);
}
self.collect_slow_diagnostics(uri_str, content, &mut slow_diagnostics);
}
{
let mut cache = self.diag_last_slow.lock();
cache.insert(uri_str.to_string(), slow_diagnostics.clone());
}
let mut full = fast_diagnostics;
full.extend(slow_diagnostics);
let phpstan_before: Vec<Diagnostic> = {
let cache = self.phpstan_last_diags.lock();
match cache.get(uri_str) {
Some(diags) => diags.clone(),
None => Vec::new(),
}
};
full.extend(phpstan_before.iter().cloned());
deduplicate_diagnostics(&mut full);
let full = self.filter_suppressed(full);
if !phpstan_before.is_empty() {
let pruned: Vec<Diagnostic> = phpstan_before
.into_iter()
.filter(|d| full.iter().any(|f| f.range == d.range))
.collect();
let mut cache = self.phpstan_last_diags.lock();
cache.insert(uri_str.to_string(), pruned);
}
if pull_mode {
{
let mut cache = self.diag_last_full.lock();
cache.insert(uri_str.to_string(), full);
}
{
let mut ids = self.diag_result_ids.lock();
let id = ids.entry(uri_str.to_string()).or_insert(0);
*id += 1;
}
let _ = client.workspace_diagnostic_refresh().await;
} else {
client.publish_diagnostics(uri, full, None).await;
}
}
pub(crate) fn schedule_diagnostics(&self, uri: String) {
let pull_mode = self.supports_pull_diagnostics.load(Ordering::Acquire);
if pull_mode {
self.diag_last_full.lock().remove(&uri);
}
{
let mut pending = self.diag_pending_uris.lock();
if !pending.contains(&uri) {
pending.push(uri.clone());
}
}
self.diag_version.fetch_add(1, Ordering::Release);
self.diag_notify.notify_one();
self.schedule_phpstan(uri);
}
pub(crate) fn schedule_diagnostics_for_open_files(&self, exclude_uri: &str) {
let pull_mode = self.supports_pull_diagnostics.load(Ordering::Acquire);
let uris: Vec<String> = self
.open_files
.read()
.keys()
.filter(|u| u.as_str() != exclude_uri)
.cloned()
.collect();
if uris.is_empty() {
return;
}
if pull_mode {
let mut cache = self.diag_last_full.lock();
for uri in &uris {
cache.remove(uri);
}
}
{
let mut pending = self.diag_pending_uris.lock();
for uri in uris {
if !pending.contains(&uri) {
pending.push(uri);
}
}
}
self.diag_version.fetch_add(1, Ordering::Release);
self.diag_notify.notify_one();
}
pub(crate) async fn diagnostic_worker(&self) {
loop {
if self.shutdown_flag.load(Ordering::Acquire) {
return;
}
self.diag_notify.notified().await;
if self.shutdown_flag.load(Ordering::Acquire) {
return;
}
loop {
let version_before = self.diag_version.load(Ordering::Acquire);
tokio::time::sleep(std::time::Duration::from_millis(DIAGNOSTIC_DEBOUNCE_MS)).await;
let version_after = self.diag_version.load(Ordering::Acquire);
if version_before == version_after {
break;
}
}
let uris: Vec<String> = {
let mut pending = self.diag_pending_uris.lock();
std::mem::take(&mut *pending)
};
if uris.is_empty() {
continue;
}
for uri in &uris {
let content = {
let files = self.open_files.read();
match files.get(uri) {
Some(c) => c.clone(),
None => continue,
}
};
self.publish_diagnostics_for_file(uri, &content).await;
}
}
}
fn schedule_phpstan(&self, uri: String) {
*self.phpstan_pending_uri.lock() = Some(uri);
self.phpstan_notify.notify_one();
}
pub(crate) async fn phpstan_worker(&self) {
loop {
if self.shutdown_flag.load(Ordering::Acquire) {
return;
}
self.phpstan_notify.notified().await;
if self.shutdown_flag.load(Ordering::Acquire) {
return;
}
let _ = tokio::time::timeout(std::time::Duration::ZERO, self.phpstan_notify.notified())
.await;
loop {
let version_before = self.diag_version.load(Ordering::Acquire);
tokio::time::sleep(std::time::Duration::from_millis(PHPSTAN_DEBOUNCE_MS)).await;
let version_after = self.diag_version.load(Ordering::Acquire);
if version_before == version_after {
break;
}
}
let uri = {
let mut pending = self.phpstan_pending_uri.lock();
pending.take()
};
let uri = match uri {
Some(u) => u,
None => continue,
};
let content = {
let files = self.open_files.read();
match files.get(&uri) {
Some(c) => c.clone(),
None => continue,
}
};
let config = self.config();
if config.phpstan.is_disabled() {
continue;
}
let file_path = match uri.parse::<Url>().ok().and_then(|u| u.to_file_path().ok()) {
Some(p) => p,
None => continue,
};
let workspace_root = self.workspace_root.read().clone();
let workspace_root = match workspace_root {
Some(root) => root,
None => continue,
};
let bin_dir: Option<String> = crate::composer::read_composer_package(&workspace_root)
.map(|pkg| crate::composer::get_bin_dir(&pkg));
let resolved = match phpstan::resolve_phpstan(
Some(&workspace_root),
&config.phpstan,
bin_dir.as_deref(),
) {
Some(r) => r,
None => continue,
};
let phpstan_config = config.phpstan.clone();
let shutdown_flag = Arc::clone(&self.shutdown_flag);
let phpstan_diags = {
let result = tokio::task::spawn_blocking(move || {
phpstan::run_phpstan(
&resolved,
&content,
&file_path,
&workspace_root,
&phpstan_config,
&shutdown_flag,
)
})
.await;
match result {
Ok(Ok(diags)) => diags,
Ok(Err(_e)) => {
continue;
}
Err(_join_err) => {
continue;
}
}
};
let content = {
let files = self.open_files.read();
match files.get(&uri) {
Some(c) => c.clone(),
None => continue,
}
};
{
let mut cache = self.phpstan_last_diags.lock();
cache.insert(uri.clone(), phpstan_diags);
}
self.publish_diagnostics_for_file(&uri, &content).await;
}
}
pub(crate) async fn clear_diagnostics_for_file(&self, uri_str: &str) {
self.diag_last_slow.lock().remove(uri_str);
self.phpstan_last_diags.lock().remove(uri_str);
self.diag_result_ids.lock().remove(uri_str);
self.diag_last_full.lock().remove(uri_str);
let client = match &self.client {
Some(c) => c,
None => return,
};
let uri = match uri_str.parse::<Url>() {
Ok(u) => u,
Err(_) => return,
};
client.publish_diagnostics(uri, Vec::new(), None).await;
if self.supports_pull_diagnostics.load(Ordering::Acquire) {
let _ = client.workspace_diagnostic_refresh().await;
}
}
}
impl Backend {
fn filter_suppressed(&self, mut diagnostics: Vec<Diagnostic>) -> Vec<Diagnostic> {
let mut suppressed = self.diag_suppressed.lock();
if suppressed.is_empty() {
return diagnostics;
}
diagnostics.retain(|d| {
!suppressed
.iter()
.any(|s| d.range == s.range && d.message == s.message && d.code == s.code)
});
suppressed.clear();
diagnostics
}
}
fn deduplicate_diagnostics(diagnostics: &mut Vec<Diagnostic>) {
if diagnostics.is_empty() {
return;
}
let priority_codes: &[&str] = &[
"unknown_class",
"unknown_member",
"scalar_member_access",
"unknown_function",
];
let priority_ranges: Vec<Range> = diagnostics
.iter()
.filter(|d| {
d.code
.as_ref()
.map(|c| match c {
NumberOrString::String(s) => priority_codes.contains(&s.as_str()),
_ => false,
})
.unwrap_or(false)
})
.map(|d| d.range)
.collect();
let mut lines_with_precise: std::collections::HashSet<u32> = std::collections::HashSet::new();
for d in diagnostics.iter() {
if !is_full_line_range(&d.range) {
lines_with_precise.insert(d.range.start.line);
}
}
diagnostics.retain(|d| {
let is_unresolved = d
.code
.as_ref()
.map(|c| match c {
NumberOrString::String(s) => s == "unresolved_member_access",
_ => false,
})
.unwrap_or(false);
if is_unresolved {
return !priority_ranges
.iter()
.any(|pr| ranges_overlap(pr, &d.range));
}
if is_full_line_range(&d.range) && lines_with_precise.contains(&d.range.start.line) {
return false;
}
true
});
diagnostics.sort_by(|a, b| {
a.range
.start
.line
.cmp(&b.range.start.line)
.then_with(|| a.range.start.character.cmp(&b.range.start.character))
.then_with(|| a.range.end.line.cmp(&b.range.end.line))
.then_with(|| a.range.end.character.cmp(&b.range.end.character))
});
}
fn is_full_line_range(range: &Range) -> bool {
range.start.line == range.end.line && range.start.character == 0 && range.end.character >= 1000
}
pub(crate) fn offset_range_to_lsp_range(
content: &str,
start_byte: usize,
end_byte: usize,
) -> Option<Range> {
if start_byte > content.len() || end_byte > content.len() {
return None;
}
Some(crate::util::byte_range_to_lsp_range(
content, start_byte, end_byte,
))
}
#[cfg(test)]
mod tests {
use super::*;
fn make_range(start_line: u32, start_char: u32, end_line: u32, end_char: u32) -> Range {
Range {
start: Position {
line: start_line,
character: start_char,
},
end: Position {
line: end_line,
character: end_char,
},
}
}
fn make_diagnostic(
range: Range,
severity: DiagnosticSeverity,
code: &str,
message: &str,
) -> Diagnostic {
Diagnostic {
range,
severity: Some(severity),
code: Some(NumberOrString::String(code.to_string())),
code_description: None,
source: Some("phpantom".to_string()),
message: message.to_string(),
related_information: None,
tags: None,
data: None,
}
}
#[test]
fn overlapping_ranges_on_same_line() {
let a = make_range(5, 0, 5, 10);
let b = make_range(5, 5, 5, 15);
assert!(ranges_overlap(&a, &b));
assert!(ranges_overlap(&b, &a));
}
#[test]
fn non_overlapping_ranges_on_same_line() {
let a = make_range(5, 0, 5, 5);
let b = make_range(5, 5, 5, 10);
assert!(!ranges_overlap(&a, &b));
assert!(!ranges_overlap(&b, &a));
}
#[test]
fn non_overlapping_ranges_on_different_lines() {
let a = make_range(1, 0, 1, 10);
let b = make_range(2, 0, 2, 10);
assert!(!ranges_overlap(&a, &b));
}
#[test]
fn identical_ranges_overlap() {
let r = make_range(3, 5, 3, 10);
assert!(ranges_overlap(&r, &r));
}
#[test]
fn contained_range_overlaps() {
let outer = make_range(1, 0, 10, 0);
let inner = make_range(5, 5, 5, 10);
assert!(ranges_overlap(&outer, &inner));
assert!(ranges_overlap(&inner, &outer));
}
#[test]
fn suppresses_unresolved_member_when_unknown_class_overlaps() {
let range = make_range(5, 0, 5, 15);
let mut diags = vec![
make_diagnostic(
range,
DiagnosticSeverity::WARNING,
"unknown_class",
"Unknown class X",
),
make_diagnostic(
range,
DiagnosticSeverity::HINT,
"unresolved_member_access",
"Unresolved member access on X",
),
];
deduplicate_diagnostics(&mut diags);
assert_eq!(diags.len(), 1);
assert_eq!(
diags[0].code,
Some(NumberOrString::String("unknown_class".to_string()))
);
}
#[test]
fn suppresses_unresolved_member_when_unknown_member_overlaps() {
let range = make_range(10, 0, 10, 20);
let mut diags = vec![
make_diagnostic(
range,
DiagnosticSeverity::WARNING,
"unknown_member",
"Unknown member foo",
),
make_diagnostic(
range,
DiagnosticSeverity::HINT,
"unresolved_member_access",
"Unresolved member access",
),
];
deduplicate_diagnostics(&mut diags);
assert_eq!(diags.len(), 1);
assert_eq!(
diags[0].code,
Some(NumberOrString::String("unknown_member".to_string()))
);
}
#[test]
fn suppresses_unresolved_member_when_scalar_member_access_overlaps() {
let range_outer = make_range(3, 0, 3, 20);
let range_inner = make_range(3, 5, 3, 15);
let mut diags = vec![
make_diagnostic(
range_outer,
DiagnosticSeverity::ERROR,
"scalar_member_access",
"Cannot access member on scalar",
),
make_diagnostic(
range_inner,
DiagnosticSeverity::HINT,
"unresolved_member_access",
"Unresolved member access",
),
];
deduplicate_diagnostics(&mut diags);
assert_eq!(diags.len(), 1);
assert_eq!(
diags[0].code,
Some(NumberOrString::String("scalar_member_access".to_string()))
);
}
#[test]
fn keeps_unresolved_member_when_no_priority_diagnostic() {
let range = make_range(5, 0, 5, 15);
let mut diags = vec![make_diagnostic(
range,
DiagnosticSeverity::HINT,
"unresolved_member_access",
"Unresolved member access",
)];
deduplicate_diagnostics(&mut diags);
assert_eq!(diags.len(), 1);
}
#[test]
fn keeps_unresolved_member_on_different_range() {
let mut diags = vec![
make_diagnostic(
make_range(5, 0, 5, 10),
DiagnosticSeverity::WARNING,
"unknown_class",
"Unknown class X",
),
make_diagnostic(
make_range(10, 0, 10, 10),
DiagnosticSeverity::HINT,
"unresolved_member_access",
"Unresolved member access on Y",
),
];
deduplicate_diagnostics(&mut diags);
assert_eq!(diags.len(), 2);
}
#[test]
fn suppresses_multiple_unresolved_members_with_priority_overlap() {
let range = make_range(5, 0, 5, 15);
let mut diags = vec![
make_diagnostic(
range,
DiagnosticSeverity::WARNING,
"unknown_class",
"Unknown class X",
),
make_diagnostic(
range,
DiagnosticSeverity::HINT,
"unresolved_member_access",
"Unresolved 1",
),
make_diagnostic(
range,
DiagnosticSeverity::HINT,
"unresolved_member_access",
"Unresolved 2",
),
make_diagnostic(
make_range(20, 0, 20, 10),
DiagnosticSeverity::HINT,
"unresolved_member_access",
"Unresolved 3 (different range)",
),
];
deduplicate_diagnostics(&mut diags);
assert_eq!(diags.len(), 2);
}
#[test]
fn no_op_when_no_diagnostics() {
let mut diags: Vec<Diagnostic> = vec![];
deduplicate_diagnostics(&mut diags);
assert!(diags.is_empty());
}
#[test]
fn suppresses_full_line_phpstan_when_precise_diagnostic_on_same_line() {
let phpstan = Diagnostic {
range: make_range(5, 0, 5, u32::MAX),
severity: Some(DiagnosticSeverity::ERROR),
code: Some(NumberOrString::String("argument.type".to_string())),
source: Some("phpstan".to_string()),
message: "Parameter #1 $x expects int, string given.".to_string(),
..Default::default()
};
let precise = Diagnostic {
range: make_range(5, 10, 5, 20),
severity: Some(DiagnosticSeverity::ERROR),
code: Some(NumberOrString::String("unknown_class".to_string())),
source: Some("phpantom".to_string()),
message: "Class 'Foo' not found".to_string(),
..Default::default()
};
let mut diags = vec![phpstan, precise.clone()];
deduplicate_diagnostics(&mut diags);
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].message, precise.message);
}
#[test]
fn suppresses_full_line_regardless_of_code() {
let phpstan = Diagnostic {
range: make_range(5, 0, 5, u32::MAX),
severity: Some(DiagnosticSeverity::ERROR),
code: Some(NumberOrString::String("class.prefixed".to_string())),
source: Some("phpstan".to_string()),
message: "Class prefixed with vendor namespace.".to_string(),
..Default::default()
};
let syntax_error = Diagnostic {
range: make_range(5, 3, 5, 10),
severity: Some(DiagnosticSeverity::ERROR),
code: Some(NumberOrString::String("syntax_error".to_string())),
source: Some("phpantom".to_string()),
message: "Syntax error: unexpected token `->`".to_string(),
..Default::default()
};
let mut diags = vec![phpstan, syntax_error.clone()];
deduplicate_diagnostics(&mut diags);
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].message, syntax_error.message);
}
#[test]
fn keeps_full_line_phpstan_when_no_precise_diagnostic_on_line() {
let phpstan = Diagnostic {
range: make_range(5, 0, 5, u32::MAX),
severity: Some(DiagnosticSeverity::ERROR),
code: Some(NumberOrString::String("argument.type".to_string())),
source: Some("phpstan".to_string()),
message: "Parameter #1 $x expects int, string given.".to_string(),
..Default::default()
};
let precise_other_line = Diagnostic {
range: make_range(10, 3, 10, 15),
severity: Some(DiagnosticSeverity::ERROR),
code: Some(NumberOrString::String("unknown_class".to_string())),
source: Some("phpantom".to_string()),
message: "Class 'Bar' not found".to_string(),
..Default::default()
};
let mut diags = vec![phpstan.clone(), precise_other_line.clone()];
deduplicate_diagnostics(&mut diags);
assert_eq!(diags.len(), 2);
}
#[test]
fn keeps_precise_phpstan_diagnostic_on_same_line() {
let phpstan_precise = Diagnostic {
range: make_range(5, 8, 5, 20),
severity: Some(DiagnosticSeverity::ERROR),
code: Some(NumberOrString::String("argument.type".to_string())),
source: Some("phpstan".to_string()),
message: "Parameter #1 $x expects int, string given.".to_string(),
..Default::default()
};
let native_precise = Diagnostic {
range: make_range(5, 3, 5, 10),
severity: Some(DiagnosticSeverity::ERROR),
code: Some(NumberOrString::String("unknown_class".to_string())),
source: Some("phpantom".to_string()),
message: "Class 'Foo' not found".to_string(),
..Default::default()
};
let mut diags = vec![phpstan_precise.clone(), native_precise.clone()];
deduplicate_diagnostics(&mut diags);
assert_eq!(diags.len(), 2);
}
#[test]
fn suppresses_multiple_full_line_diags_when_precise_exists() {
let phpstan1 = Diagnostic {
range: make_range(5, 0, 5, u32::MAX),
severity: Some(DiagnosticSeverity::ERROR),
code: Some(NumberOrString::String("argument.type".to_string())),
source: Some("phpstan".to_string()),
message: "Error one".to_string(),
..Default::default()
};
let phpstan2 = Diagnostic {
range: make_range(5, 0, 5, u32::MAX),
severity: Some(DiagnosticSeverity::ERROR),
code: Some(NumberOrString::String("return.type".to_string())),
source: Some("phpstan".to_string()),
message: "Error two".to_string(),
..Default::default()
};
let precise = Diagnostic {
range: make_range(5, 2, 5, 8),
severity: Some(DiagnosticSeverity::WARNING),
code: Some(NumberOrString::String("unknown_member".to_string())),
source: Some("phpantom".to_string()),
message: "Method 'foo' not found".to_string(),
..Default::default()
};
let mut diags = vec![phpstan1, phpstan2, precise.clone()];
deduplicate_diagnostics(&mut diags);
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].message, precise.message);
}
#[test]
fn keeps_multiple_diagnostics_on_same_range() {
let range = make_range(7, 3, 7, 12);
let diag1 = make_diagnostic(
range,
DiagnosticSeverity::WARNING,
"unknown_member",
"Method 'foo' not found on class Bar",
);
let diag2 = make_diagnostic(
range,
DiagnosticSeverity::HINT,
"deprecated",
"Method 'foo' is deprecated",
);
let mut diags = vec![diag1, diag2];
deduplicate_diagnostics(&mut diags);
assert_eq!(diags.len(), 2);
}
#[test]
fn keeps_multiple_phpstan_diagnostics_on_same_line() {
let make_phpstan = |code: &str, msg: &str| Diagnostic {
range: make_range(10, 0, 10, u32::MAX),
severity: Some(DiagnosticSeverity::ERROR),
code: Some(NumberOrString::String(code.to_string())),
source: Some("phpstan".to_string()),
message: msg.to_string(),
..Default::default()
};
let mut diags = vec![
make_phpstan("argument.type", "Parameter #1 expects int, string given."),
make_phpstan("return.type", "Should return int but returns string."),
make_phpstan("missingType.return", "Method has no return type."),
];
deduplicate_diagnostics(&mut diags);
assert_eq!(diags.len(), 3);
}
fn make_phpstan_diag(line: u32, code: &str, message: &str) -> Diagnostic {
Diagnostic {
range: make_range(line, 0, line, 200),
severity: Some(DiagnosticSeverity::ERROR),
code: Some(NumberOrString::String(code.to_string())),
source: Some("PHPStan".to_string()),
message: message.to_string(),
..Default::default()
}
}
#[test]
fn throws_unused_type_not_stale_via_heuristic() {
let content = "<?php\nclass Foo {\n public function bar(): void {}\n}\n";
let diag = make_phpstan_diag(
2,
"throws.unusedType",
"Method App\\Foo::bar() has App\\Exceptions\\FooException in PHPDoc @throws tag but it's not thrown.",
);
assert!(
!is_stale_phpstan_diagnostic(&diag, content),
"throws.unusedType should NOT be stale via heuristic (cleared by resolve instead)"
);
}
#[test]
fn missing_checked_exception_not_stale_via_heuristic() {
let content = "<?php\nclass Foo {\n /**\n * @throws FooException\n */\n public function bar(): void {}\n}\n";
let diag = make_phpstan_diag(
5,
"missingType.checkedException",
"Method App\\Foo::bar() throws checked exception App\\Exceptions\\FooException but it's missing from the PHPDoc @throws tag.",
);
assert!(
!is_stale_phpstan_diagnostic(&diag, content),
"missingType.checkedException should NOT be stale via heuristic (cleared by resolve instead)"
);
}
#[test]
fn stale_when_phpstan_ignore_covers_identifier() {
let content = "<?php\nclass Foo {\n public function bar(): void {} // @phpstan-ignore return.type\n}\n";
let diag = make_phpstan_diag(
2,
"return.type",
"Method App\\Foo::bar() should return string but returns void.",
);
assert!(
is_stale_phpstan_diagnostic(&diag, content),
"should be stale when @phpstan-ignore lists the identifier"
);
}
#[test]
fn not_stale_when_phpstan_ignore_covers_different_identifier() {
let content = "<?php\nclass Foo {\n public function bar(): void {} // @phpstan-ignore argument.type\n}\n";
let diag = make_phpstan_diag(
2,
"return.type",
"Method App\\Foo::bar() should return string but returns void.",
);
assert!(
!is_stale_phpstan_diagnostic(&diag, content),
"should NOT be stale when @phpstan-ignore lists a different identifier"
);
}
#[test]
fn not_stale_for_phpstan_ignore_line_blanket() {
let content =
"<?php\nclass Foo {\n public function bar(): void {} // @phpstan-ignore-line\n}\n";
let diag = make_phpstan_diag(
2,
"return.type",
"Method App\\Foo::bar() should return string but returns void.",
);
assert!(
!is_stale_phpstan_diagnostic(&diag, content),
"should NOT be stale for blanket @phpstan-ignore-line"
);
}
#[test]
fn missing_override_not_stale_via_heuristic() {
let content = "<?php\nclass Foo extends Bar {\n #[\\Override]\n public function baz(): void {}\n}\n";
let diag = make_phpstan_diag(
3,
"method.missingOverride",
"Method Foo::baz() overrides method Bar::baz() but is missing the #[\\Override] attribute.",
);
assert!(
!is_stale_phpstan_diagnostic(&diag, content),
"method.missingOverride should NOT be stale via heuristic (cleared by resolve instead)"
);
}
#[test]
fn not_stale_for_phpstan_ignore_next_line_blanket() {
let content = "<?php\nclass Foo {\n // @phpstan-ignore-next-line\n public function bar(): void {}\n}\n";
let diag = make_phpstan_diag(
3,
"return.type",
"Method App\\Foo::bar() should return string but returns void.",
);
assert!(
!is_stale_phpstan_diagnostic(&diag, content),
"should NOT be stale for blanket @phpstan-ignore-next-line"
);
}
#[test]
fn stale_when_phpstan_ignore_on_previous_line() {
let content = "<?php\nclass Foo {\n // @phpstan-ignore return.type\n public function bar(): void {}\n}\n";
let diag = make_phpstan_diag(
3,
"return.type",
"Method App\\Foo::bar() should return string but returns void.",
);
assert!(
is_stale_phpstan_diagnostic(&diag, content),
"should be stale when @phpstan-ignore on previous line lists the identifier"
);
}
#[test]
fn stale_phpstan_ignore_with_multiple_ids() {
let content = "<?php\nclass Foo {\n public function bar(): void {} // @phpstan-ignore return.type, argument.type\n}\n";
let return_diag = make_phpstan_diag(
2,
"return.type",
"Method App\\Foo::bar() should return string but returns void.",
);
let arg_diag = make_phpstan_diag(
2,
"argument.type",
"Parameter #1 $x expects string, int given.",
);
let other_diag = make_phpstan_diag(2, "method.notFound", "Call to undefined method.");
assert!(
is_stale_phpstan_diagnostic(&return_diag, content),
"return.type should be stale (listed in ignore)"
);
assert!(
is_stale_phpstan_diagnostic(&arg_diag, content),
"argument.type should be stale (listed in ignore)"
);
assert!(
!is_stale_phpstan_diagnostic(&other_diag, content),
"method.notFound should NOT be stale (not listed)"
);
}
#[test]
fn diag_with_no_code_is_never_stale() {
let content = "<?php\n// @phpstan-ignore return.type\nfoo();";
let diag = Diagnostic {
range: make_range(1, 0, 1, 200),
severity: Some(DiagnosticSeverity::ERROR),
code: None,
source: Some("PHPStan".to_string()),
message: "Some error.".to_string(),
..Default::default()
};
assert!(
!is_stale_phpstan_diagnostic(&diag, content),
"diagnostic without a code should never be considered stale"
);
}
#[test]
fn ignore_unmatched_diag_is_never_stale_via_ignore_check() {
let content = "<?php\n$x = 1; // @phpstan-ignore ignore.unmatchedIdentifier\n";
let diag = make_phpstan_diag(
1,
"ignore.unmatchedIdentifier",
"No error with identifier foo is reported on line 2.",
);
assert!(
!is_stale_phpstan_diagnostic(&diag, content),
"ignore.unmatched* diagnostics must not be pruned by the ignore check"
);
}
#[test]
fn throws_not_stale_even_when_tag_on_same_function() {
let content = concat!(
"<?php\nclass Foo {\n",
" public function bar(): void {}\n",
" /**\n",
" * @throws FooException\n",
" */\n",
" public function baz(): void {\n",
" throw new FooException();\n",
" }\n",
"}\n",
);
let diag = make_phpstan_diag(
7,
"missingType.checkedException",
"Method App\\Foo::baz() throws checked exception App\\Exceptions\\FooException but it's missing from the PHPDoc @throws tag.",
);
assert!(
!is_stale_phpstan_diagnostic(&diag, content),
"missingType.checkedException should NOT be stale via heuristic"
);
}
#[test]
fn unused_throws_not_stale_via_heuristic_even_when_tag_removed() {
let content = concat!(
"<?php\nclass Foo {\n",
" /**\n",
" * @throws FooException\n",
" */\n",
" public function bar(): void {\n",
" }\n",
" public function baz(): void {\n",
" }\n",
"}\n",
);
let bar_diag = make_phpstan_diag(
5,
"throws.unusedType",
"Method App\\Foo::bar() has App\\Exceptions\\FooException in PHPDoc @throws tag but it's not thrown.",
);
assert!(
!is_stale_phpstan_diagnostic(&bar_diag, content),
"bar()'s throws.unusedType should NOT be stale via heuristic"
);
let baz_diag = make_phpstan_diag(
7,
"throws.unusedType",
"Method App\\Foo::baz() has App\\Exceptions\\FooException in PHPDoc @throws tag but it's not thrown.",
);
assert!(
!is_stale_phpstan_diagnostic(&baz_diag, content),
"baz()'s throws.unusedType should NOT be stale via heuristic"
);
}
#[test]
fn enclosing_docblock_text_finds_correct_docblock() {
let content = concat!(
"<?php\nclass Foo {\n",
" /**\n",
" * @throws BarException\n",
" */\n",
" public function bar(): void {\n",
" // line 6\n",
" }\n",
" /**\n",
" * @throws BazException\n",
" */\n",
" public function baz(): void {\n",
" // line 12\n",
" }\n",
"}\n",
);
let bar_doc = enclosing_docblock_text(content, 6);
assert!(
bar_doc.contains("BarException"),
"bar()'s docblock should mention BarException, got: {}",
bar_doc
);
assert!(
!bar_doc.contains("BazException"),
"bar()'s docblock should NOT mention BazException, got: {}",
bar_doc
);
let baz_doc = enclosing_docblock_text(content, 12);
assert!(
baz_doc.contains("BazException"),
"baz()'s docblock should mention BazException, got: {}",
baz_doc
);
assert!(
!baz_doc.contains("BarException"),
"baz()'s docblock should NOT mention BarException, got: {}",
baz_doc
);
}
#[test]
fn enclosing_docblock_text_returns_empty_when_no_docblock() {
let content = "<?php\nfunction foo(): void {\n // line 2\n}\n";
let doc = enclosing_docblock_text(content, 2);
assert!(
doc.is_empty(),
"should return empty when no docblock exists, got: {}",
doc
);
}
}