use std::collections::{HashMap, HashSet};
use tower_lsp::lsp_types::*;
use crate::Backend;
use crate::symbol_map::SymbolKind;
use super::helpers::{ByteRange, compute_use_line_ranges, is_offset_in_ranges};
use super::offset_range_to_lsp_range;
impl Backend {
pub fn collect_unused_import_diagnostics(
&self,
uri: &str,
content: &str,
out: &mut Vec<Diagnostic>,
) {
let file_use_map: HashMap<String, String> = self.file_use_map(uri);
if file_use_map.is_empty() {
return;
}
let symbol_map = match self.symbol_maps.read().get(uri) {
Some(sm) => sm.clone(),
None => return,
};
let use_line_ranges = compute_use_line_ranges(content);
let decl_line_ranges = compute_declaration_line_ranges(content);
let mut referenced_aliases: HashSet<String> = HashSet::new();
for span in &symbol_map.spans {
if is_offset_in_ranges(span.start, &use_line_ranges) {
continue;
}
match &span.kind {
SymbolKind::ClassReference { name, .. } => {
let first_segment = extract_first_segment(name);
if file_use_map.contains_key(first_segment) {
referenced_aliases.insert(first_segment.to_string());
}
}
SymbolKind::MemberAccess {
subject_text,
is_static: true,
..
} => {
let trimmed = subject_text.trim();
if !trimmed.starts_with('$')
&& trimmed != "self"
&& trimmed != "static"
&& trimmed != "parent"
{
let first_segment = extract_first_segment(trimmed);
if file_use_map.contains_key(first_segment) {
referenced_aliases.insert(first_segment.to_string());
}
}
}
SymbolKind::FunctionCall { name, .. } => {
let first_segment = extract_first_segment(name);
if file_use_map.contains_key(first_segment) {
referenced_aliases.insert(first_segment.to_string());
}
}
SymbolKind::ConstantReference { name } => {
let first_segment = extract_first_segment(name);
if file_use_map.contains_key(first_segment) {
referenced_aliases.insert(first_segment.to_string());
}
}
_ => {}
}
}
let unused_aliases: Vec<&String> = file_use_map
.keys()
.filter(|alias| !referenced_aliases.contains(alias.as_str()))
.collect();
if unused_aliases.is_empty() {
return;
}
for alias in &unused_aliases {
let fqn = match file_use_map.get(alias.as_str()) {
Some(f) => f,
None => continue,
};
if alias_is_referenced_in_content(
content,
alias,
fqn,
&use_line_ranges,
&decl_line_ranges,
) {
continue;
}
if let Some(range) = find_use_statement_range(content, alias, fqn) {
out.push(Diagnostic {
range,
severity: Some(DiagnosticSeverity::HINT),
code: Some(NumberOrString::String("unused_import".to_string())),
code_description: None,
source: Some("phpantom".to_string()),
message: format!("Unused import '{}'", fqn),
related_information: None,
tags: Some(vec![DiagnosticTag::UNNECESSARY]),
data: None,
});
}
}
}
}
fn compute_declaration_line_ranges(content: &str) -> Vec<ByteRange> {
let mut ranges = Vec::new();
let mut offset: usize = 0;
for line in content.split('\n') {
let trimmed = line.trim_start();
if (trimmed.starts_with("class ")
|| trimmed.starts_with("interface ")
|| trimmed.starts_with("trait ")
|| trimmed.starts_with("enum ")
|| trimmed.starts_with("abstract class ")
|| trimmed.starts_with("final class ")
|| trimmed.starts_with("readonly class ")
|| trimmed.starts_with("final readonly class ")
|| trimmed.starts_with("readonly final class "))
&& !trimmed.starts_with("//")
{
ranges.push((offset, offset + line.len()));
}
offset += line.len() + 1;
}
ranges
}
fn extract_first_segment(name: &str) -> &str {
name.split('\\').next().unwrap_or(name)
}
fn alias_is_referenced_in_content(
content: &str,
alias: &str,
_fqn: &str,
use_ranges: &[ByteRange],
decl_ranges: &[ByteRange],
) -> bool {
let alias_bytes = alias.as_bytes();
let content_bytes = content.as_bytes();
let alias_len = alias_bytes.len();
if alias_len == 0 {
return false;
}
let mut search_from = 0;
while search_from + alias_len <= content_bytes.len() {
let pos = match content[search_from..].find(alias) {
Some(p) => search_from + p,
None => break,
};
let before_ok = if pos == 0 {
true
} else {
!is_ident_char(content_bytes[pos - 1])
};
let after_ok = if pos + alias_len >= content_bytes.len() {
true
} else {
let next_byte = content_bytes[pos + alias_len];
next_byte == b'\\' || !is_ident_char(next_byte)
};
if before_ok && after_ok {
if is_offset_in_ranges(pos as u32, use_ranges) {
search_from = pos + alias_len;
continue;
}
if is_offset_in_ranges(pos as u32, decl_ranges) {
search_from = pos + alias_len;
continue;
}
let line_start = content[..pos].rfind('\n').map_or(0, |p| p + 1);
let line_end = content[pos..].find('\n').map_or(content.len(), |p| pos + p);
let line_prefix = &content[line_start..pos];
let full_line = &content[line_start..line_end];
if line_prefix.contains("//") {
search_from = pos + alias_len;
continue;
}
if (line_prefix.trim_start().starts_with('*')
|| line_prefix.trim_start().starts_with("/**"))
&& !line_contains_phpdoc_type_tag(full_line)
{
search_from = pos + alias_len;
continue;
}
return true;
}
search_from = pos + 1;
}
false
}
const PHPDOC_TYPE_TAGS: &[&str] = &[
"@var",
"@param",
"@return",
"@throws",
"@template",
"@extends",
"@implements",
"@use",
"@mixin",
"@method",
"@property",
"@property-read",
"@property-write",
"@phpstan-type",
"@psalm-type",
"@phpstan-import-type",
"@phpstan-param",
"@phpstan-return",
"@phpstan-var",
"@psalm-param",
"@psalm-return",
"@psalm-var",
"@phpstan-extends",
"@phpstan-implements",
"@psalm-extends",
"@psalm-implements",
];
fn line_contains_phpdoc_type_tag(line: &str) -> bool {
let trimmed = line.trim();
PHPDOC_TYPE_TAGS.iter().any(|tag| trimmed.contains(tag))
}
fn is_ident_char(b: u8) -> bool {
b.is_ascii_alphanumeric() || b == b'_' || b == b'\\' || b > 0x7F
}
fn find_use_statement_range(content: &str, alias: &str, fqn: &str) -> Option<Range> {
let short_name = fqn.rsplit('\\').next().unwrap_or(fqn);
let has_alias = short_name != alias;
let mut byte_offset: usize = 0;
for line in content.split('\n') {
let trimmed = line.trim_start();
let leading_ws = line.len() - trimmed.len();
if trimmed.starts_with("use ") && trimmed.contains(';') {
let is_match = if has_alias {
trimmed.contains(fqn) && trimmed.contains(&format!("as {}", alias))
} else if trimmed.contains('{') {
is_group_import_match(trimmed, fqn, short_name)
} else {
trimmed.contains(fqn)
};
if is_match {
if trimmed.contains('{')
&& !has_alias
&& let Some(member_range) =
find_group_member_range(content, byte_offset, line, short_name)
{
return Some(member_range);
}
let line_start = byte_offset + leading_ws;
let line_end = byte_offset + line.len();
return offset_range_to_lsp_range(content, line_start, line_end);
}
}
byte_offset += line.len() + 1;
}
None
}
fn is_group_import_match(line: &str, fqn: &str, short_name: &str) -> bool {
if let Some(brace_pos) = line.find('{') {
let prefix_part = line["use ".len()..brace_pos].trim().trim_end_matches('\\');
let expected_prefix = if let Some(prefix_end) = fqn.rfind('\\') {
&fqn[..prefix_end]
} else {
return false;
};
if prefix_part == expected_prefix {
if let Some(close_brace) = line.find('}') {
let group_content = &line[brace_pos + 1..close_brace];
return group_content
.split(',')
.any(|item| item.trim() == short_name);
}
}
}
false
}
fn find_group_member_range(
content: &str,
line_byte_offset: usize,
line: &str,
short_name: &str,
) -> Option<Range> {
let brace_pos = line.find('{')?;
let close_brace = line.find('}')?;
let group_content = &line[brace_pos + 1..close_brace];
let members: Vec<&str> = group_content.split(',').collect();
let member_count = members.len();
let mut group_offset = brace_pos + 1; for (i, member) in members.iter().enumerate() {
let trimmed = member.trim();
if trimmed == short_name {
let member_start_in_line = group_offset + member.find(trimmed).unwrap_or(0);
let member_end_in_line = member_start_in_line + trimmed.len();
if member_count == 1 {
return None; }
let abs_start = line_byte_offset + member_start_in_line;
let abs_end = line_byte_offset + member_end_in_line;
return offset_range_to_lsp_range(content, abs_start, abs_end);
}
group_offset += member.len();
if i < member_count - 1 {
group_offset += 1; }
}
None
}
#[cfg(test)]
mod tests {
use super::*;
fn referenced(content: &str, alias: &str) -> bool {
alias_is_referenced_in_content(content, alias, "", &[], &[])
}
#[test]
fn backslash_after_alias_counts_as_reference() {
let content = r#"<?php
use Symfony\Component\Validator\Constraints as Assert;
class Dto {
public function __construct(
#[Assert\Uuid(message: 'bad')]
public string $id,
) {}
}
"#;
let use_ranges = compute_use_line_ranges(content);
assert!(
alias_is_referenced_in_content(content, "Assert", "", &use_ranges, &[]),
"Assert\\Uuid should count as a usage of the Assert alias"
);
}
#[test]
fn backslash_before_alias_does_not_count() {
assert!(!referenced(r#"Foo\Assert"#, "Assert"));
}
#[test]
fn standalone_alias_still_detected() {
assert!(referenced("new Assert();", "Assert"));
}
#[test]
fn alias_inside_longer_word_not_detected() {
assert!(!referenced("new Assertion();", "Assert"));
}
#[test]
fn alias_with_static_access_through_namespace() {
assert!(referenced("Assert\\Uuid::V7_MONOTONIC", "Assert"));
}
#[test]
fn alias_on_use_line_not_counted() {
let content = "use Foo\\Bar as Assert;\n";
let use_ranges = compute_use_line_ranges(content);
assert!(
!alias_is_referenced_in_content(content, "Assert", "", &use_ranges, &[]),
"Alias on a use-statement line should not count as a reference"
);
}
#[test]
fn alias_in_comment_not_counted() {
assert!(!referenced("// Assert is great\n", "Assert"));
}
#[test]
fn alias_in_docblock_prose_not_counted() {
assert!(!referenced(" * Assert something here\n", "Assert"));
}
#[test]
fn alias_in_docblock_type_tag_counted() {
assert!(referenced(" * @param Assert $x\n", "Assert"));
}
}