use std::sync::Arc;
use php_ast::{ClassMemberKind, EnumMemberKind, ExprKind, NamespaceBody, Stmt, StmtKind};
use tower_lsp::lsp_types::{Diagnostic, DiagnosticSeverity, NumberOrString, Position, Range, Url};
use crate::ast::{ParsedDoc, offset_to_position};
use crate::backend::DiagnosticsConfig;
use crate::docblock::{docblock_before, parse_docblock};
pub fn semantic_diagnostics(
uri: &Url,
doc: &ParsedDoc,
codebase: &mir_codebase::Codebase,
cfg: &DiagnosticsConfig,
) -> Vec<Diagnostic> {
if !cfg.enabled {
return vec![];
}
let file: Arc<str> = Arc::from(uri.as_str());
codebase.remove_file_definitions(&file);
let source_map = php_ast::source_map::SourceMap::new(doc.source());
let collector = mir_analyzer::collector::DefinitionCollector::new(
codebase,
file.clone(),
doc.source(),
&source_map,
);
let collector_issues = collector.collect(doc.program());
codebase.finalize();
let mut issue_buffer = mir_issues::IssueBuffer::new();
let mut symbols = Vec::new();
let mut analyzer = mir_analyzer::stmt::StatementsAnalyzer::new(
codebase,
file.clone(),
doc.source(),
&source_map,
&mut issue_buffer,
&mut symbols,
);
let mut ctx = mir_analyzer::context::Context::new();
analyzer.analyze_stmts(&doc.program().stmts, &mut ctx);
collector_issues
.into_iter()
.chain(issue_buffer.into_issues())
.filter(|i| !i.suppressed)
.filter(|i| issue_passes_filter(i, cfg))
.map(|i| to_lsp_diagnostic(i, uri))
.collect()
}
fn issue_passes_filter(issue: &mir_issues::Issue, cfg: &DiagnosticsConfig) -> bool {
use mir_issues::IssueKind;
match &issue.kind {
IssueKind::UndefinedVariable { .. } | IssueKind::PossiblyUndefinedVariable { .. } => {
cfg.undefined_variables
}
IssueKind::UndefinedFunction { .. } | IssueKind::UndefinedMethod { .. } => {
cfg.undefined_functions
}
IssueKind::UndefinedClass { .. } => cfg.undefined_classes,
IssueKind::InvalidReturnType { .. }
| IssueKind::InvalidArgument { .. }
| IssueKind::NullMethodCall { .. }
| IssueKind::NullPropertyFetch { .. }
| IssueKind::NullableReturnStatement { .. }
| IssueKind::InvalidPropertyAssignment { .. }
| IssueKind::InvalidOperand { .. } => cfg.type_errors,
IssueKind::DeprecatedMethod { .. } | IssueKind::DeprecatedClass { .. } => {
cfg.deprecated_calls
}
_ => true,
}
}
pub fn deprecated_call_diagnostics(
source: &str,
doc: &ParsedDoc,
other_docs: &[Arc<ParsedDoc>],
cfg: &DiagnosticsConfig,
) -> Vec<Diagnostic> {
if !cfg.enabled || !cfg.deprecated_calls {
return vec![];
}
let mut diags = Vec::new();
collect_deprecated_calls(source, &doc.program().stmts, doc, other_docs, &mut diags);
diags
}
fn collect_deprecated_calls(
source: &str,
stmts: &[Stmt<'_, '_>],
doc: &ParsedDoc,
other_docs: &[Arc<ParsedDoc>],
diags: &mut Vec<Diagnostic>,
) {
for stmt in stmts {
match &stmt.kind {
StmtKind::Expression(e) => {
check_expr_for_deprecated(source, e, doc, other_docs, diags);
}
StmtKind::Namespace(ns) => {
if let NamespaceBody::Braced(inner) = &ns.body {
collect_deprecated_calls(source, inner, doc, other_docs, diags);
}
}
StmtKind::Function(f) => {
collect_deprecated_calls(source, &f.body, doc, other_docs, diags);
}
StmtKind::Class(c) => {
for member in c.members.iter() {
if let ClassMemberKind::Method(m) = &member.kind
&& let Some(body) = &m.body
{
collect_deprecated_calls(source, body, doc, other_docs, diags);
}
}
}
StmtKind::Trait(t) => {
for member in t.members.iter() {
if let ClassMemberKind::Method(m) = &member.kind
&& let Some(body) = &m.body
{
collect_deprecated_calls(source, body, doc, other_docs, diags);
}
}
}
StmtKind::Enum(e) => {
for member in e.members.iter() {
if let EnumMemberKind::Method(m) = &member.kind
&& let Some(body) = &m.body
{
collect_deprecated_calls(source, body, doc, other_docs, diags);
}
}
}
_ => {}
}
}
}
fn check_expr_for_deprecated(
source: &str,
expr: &php_ast::Expr<'_, '_>,
doc: &ParsedDoc,
other_docs: &[Arc<ParsedDoc>],
diags: &mut Vec<Diagnostic>,
) {
if let ExprKind::Assign(a) = &expr.kind {
check_expr_for_deprecated(source, a.value, doc, other_docs, diags);
return;
}
if let ExprKind::FunctionCall(call) = &expr.kind {
if let ExprKind::Identifier(name) = &call.name.kind {
let func_name = name;
let all_sources: Vec<(&str, &ParsedDoc)> = std::iter::once((source, doc))
.chain(other_docs.iter().map(|d| (d.source(), d.as_ref())))
.collect();
for (src, d) in &all_sources {
if let Some(span_start) = find_function_span(d, func_name)
&& let Some(raw) = docblock_before(src, span_start)
{
let db = parse_docblock(&raw);
if db.is_deprecated() {
let start_pos = offset_to_position(source, call.name.span.start);
let end_pos = offset_to_position(source, call.name.span.end);
let msg = match &db.deprecated {
Some(m) if !m.is_empty() => {
format!("Deprecated: {} — {}", func_name, m)
}
_ => format!("Deprecated: {}", func_name),
};
diags.push(Diagnostic {
range: Range {
start: Position {
line: start_pos.line,
character: start_pos.character,
},
end: Position {
line: end_pos.line,
character: end_pos.character,
},
},
severity: Some(DiagnosticSeverity::WARNING),
source: Some("php-lsp".to_string()),
message: msg,
..Default::default()
});
break;
}
}
}
}
for arg in call.args.iter() {
check_expr_for_deprecated(source, &arg.value, doc, other_docs, diags);
}
}
if let ExprKind::MethodCall(call) = &expr.kind {
if let ExprKind::Identifier(name) = &call.method.kind {
let method_name = name;
let all_sources: Vec<(&str, &ParsedDoc)> = std::iter::once((source, doc))
.chain(other_docs.iter().map(|d| (d.source(), d.as_ref())))
.collect();
for (src, d) in &all_sources {
if let Some(span_start) = find_method_span(d, method_name)
&& let Some(raw) = docblock_before(src, span_start)
{
let db = parse_docblock(&raw);
if db.is_deprecated() {
let start_pos = offset_to_position(source, call.method.span.start);
let end_pos = offset_to_position(source, call.method.span.end);
let msg = match &db.deprecated {
Some(m) if !m.is_empty() => {
format!("Deprecated: {} — {}", method_name, m)
}
_ => format!("Deprecated: {}", method_name),
};
diags.push(Diagnostic {
range: Range {
start: Position {
line: start_pos.line,
character: start_pos.character,
},
end: Position {
line: end_pos.line,
character: end_pos.character,
},
},
severity: Some(DiagnosticSeverity::WARNING),
source: Some("php-lsp".to_string()),
message: msg,
..Default::default()
});
break;
}
}
}
}
check_expr_for_deprecated(source, call.object, doc, other_docs, diags);
for arg in call.args.iter() {
check_expr_for_deprecated(source, &arg.value, doc, other_docs, diags);
}
}
}
fn find_function_span(doc: &ParsedDoc, func_name: &str) -> Option<u32> {
find_function_span_in_stmts(&doc.program().stmts, func_name)
}
fn find_function_span_in_stmts(stmts: &[Stmt<'_, '_>], func_name: &str) -> Option<u32> {
for stmt in stmts {
match &stmt.kind {
StmtKind::Function(f) if f.name == func_name => {
return Some(stmt.span.start);
}
StmtKind::Namespace(ns) => {
if let NamespaceBody::Braced(inner) = &ns.body
&& let Some(s) = find_function_span_in_stmts(inner, func_name)
{
return Some(s);
}
}
_ => {}
}
}
None
}
fn find_method_span(doc: &ParsedDoc, method_name: &str) -> Option<u32> {
find_method_span_in_stmts(&doc.program().stmts, method_name)
}
fn find_method_span_in_stmts(stmts: &[Stmt<'_, '_>], method_name: &str) -> Option<u32> {
for stmt in stmts {
match &stmt.kind {
StmtKind::Class(c) => {
for member in c.members.iter() {
if let ClassMemberKind::Method(m) = &member.kind
&& m.name == method_name
{
return Some(member.span.start);
}
}
}
StmtKind::Trait(t) => {
for member in t.members.iter() {
if let ClassMemberKind::Method(m) = &member.kind
&& m.name == method_name
{
return Some(member.span.start);
}
}
}
StmtKind::Enum(e) => {
for member in e.members.iter() {
if let EnumMemberKind::Method(m) = &member.kind
&& m.name == method_name
{
return Some(member.span.start);
}
}
}
StmtKind::Namespace(ns) => {
if let NamespaceBody::Braced(inner) = &ns.body
&& let Some(s) = find_method_span_in_stmts(inner, method_name)
{
return Some(s);
}
}
_ => {}
}
}
None
}
pub fn duplicate_declaration_diagnostics(
source: &str,
doc: &ParsedDoc,
cfg: &DiagnosticsConfig,
) -> Vec<Diagnostic> {
if !cfg.enabled || !cfg.duplicate_declarations {
return vec![];
}
let mut seen: std::collections::HashMap<String, ()> = std::collections::HashMap::new();
let mut diags = Vec::new();
collect_duplicate_decls(source, &doc.program().stmts, "", &mut seen, &mut diags);
diags
}
fn collect_duplicate_decls(
source: &str,
stmts: &[php_ast::Stmt<'_, '_>],
current_ns: &str,
seen: &mut std::collections::HashMap<String, ()>,
diags: &mut Vec<Diagnostic>,
) {
let mut active_ns = current_ns.to_string();
for stmt in stmts {
let name_and_span: Option<(&str, u32)> = match &stmt.kind {
StmtKind::Class(c) => c.name.map(|n| (n, stmt.span.start)),
StmtKind::Interface(i) => Some((i.name, stmt.span.start)),
StmtKind::Trait(t) => Some((t.name, stmt.span.start)),
StmtKind::Enum(e) => Some((e.name, stmt.span.start)),
StmtKind::Function(f) => Some((f.name, stmt.span.start)),
StmtKind::Namespace(ns) => {
let ns_name = ns
.name
.as_ref()
.map(|n| n.to_string_repr().to_string())
.unwrap_or_default();
match &ns.body {
php_ast::NamespaceBody::Braced(inner) => {
let child_ns = if current_ns.is_empty() {
ns_name
} else {
format!("{}\\{}", current_ns, ns_name)
};
collect_duplicate_decls(source, inner, &child_ns, seen, diags);
}
php_ast::NamespaceBody::Simple => {
active_ns = if current_ns.is_empty() {
ns_name
} else {
format!("{}\\{}", current_ns, ns_name)
};
}
}
None
}
_ => None,
};
if let Some((name, span_start)) = name_and_span {
let key = if active_ns.is_empty() {
name.to_string()
} else {
format!("{}\\{}", active_ns, name)
};
if seen.insert(key, ()).is_some() {
let pos = crate::ast::offset_to_position(source, span_start);
diags.push(Diagnostic {
range: Range {
start: pos,
end: pos,
},
severity: Some(DiagnosticSeverity::WARNING),
message: format!(
"Duplicate declaration: `{name}` is already defined in this file"
),
source: Some("php-lsp".to_string()),
..Default::default()
});
}
}
}
}
fn to_lsp_diagnostic(issue: mir_issues::Issue, _uri: &Url) -> Diagnostic {
let line = issue.location.line.saturating_sub(1);
let col_start = issue.location.col_start as u32;
let col_end = issue.location.col_end as u32;
Diagnostic {
range: Range {
start: Position {
line,
character: col_start,
},
end: Position {
line,
character: col_end.max(col_start + 1),
},
},
severity: Some(match issue.severity {
mir_issues::Severity::Error => DiagnosticSeverity::ERROR,
mir_issues::Severity::Warning => DiagnosticSeverity::WARNING,
mir_issues::Severity::Info => DiagnosticSeverity::INFORMATION,
}),
code: Some(NumberOrString::String(issue.kind.name().to_string())),
source: Some("php-lsp".to_string()),
message: issue.kind.message(),
..Default::default()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn deprecated_function_call_emits_warning() {
let src =
"<?php\n/** @deprecated Use newFunc() instead */\nfunction oldFunc() {}\n\noldFunc();";
let doc = ParsedDoc::parse(src.to_string());
let diags = deprecated_call_diagnostics(src, &doc, &[], &DiagnosticsConfig::default());
assert_eq!(
diags.len(),
1,
"expected exactly 1 deprecated warning diagnostic"
);
let d = &diags[0];
assert_eq!(d.severity, Some(DiagnosticSeverity::WARNING));
assert!(
d.message.contains("oldFunc"),
"message should mention the function name"
);
}
#[test]
fn duplicate_class_emits_warning() {
let src = "<?php\nclass Foo {}\nclass Foo {}";
let doc = ParsedDoc::parse(src.to_string());
let diags = duplicate_declaration_diagnostics(src, &doc, &DiagnosticsConfig::default());
assert_eq!(
diags.len(),
1,
"expected exactly 1 duplicate warning, got: {:?}",
diags
);
assert_eq!(diags[0].severity, Some(DiagnosticSeverity::WARNING));
assert!(
diags[0].message.contains("Foo"),
"message should mention 'Foo'"
);
}
#[test]
fn no_duplicate_for_unique_declarations() {
let src = "<?php\nclass Foo {}\nclass Bar {}";
let doc = ParsedDoc::parse(src.to_string());
let diags = duplicate_declaration_diagnostics(src, &doc, &DiagnosticsConfig::default());
assert!(diags.is_empty());
}
#[test]
fn namespace_scoped_duplicate_not_flagged() {
let src = "<?php\nnamespace App\\A {\nclass Foo {}\n}\nnamespace App\\B {\nclass Foo {}\n}";
let doc = ParsedDoc::parse(src.to_string());
let diags = duplicate_declaration_diagnostics(src, &doc, &DiagnosticsConfig::default());
assert!(
diags.is_empty(),
"classes with same name in different namespaces should not be flagged, got: {:?}",
diags
);
}
#[test]
fn duplicate_interface_declaration() {
let src = "<?php\ninterface Logger {}\ninterface Logger {}";
let doc = ParsedDoc::parse(src.to_string());
let diags = duplicate_declaration_diagnostics(src, &doc, &DiagnosticsConfig::default());
assert_eq!(
diags.len(),
1,
"expected exactly 1 duplicate-declaration diagnostic, got: {:?}",
diags
);
assert!(
diags[0].message.contains("Logger"),
"diagnostic message should mention 'Logger'"
);
assert_eq!(
diags[0].severity,
Some(DiagnosticSeverity::WARNING),
"duplicate declaration should be a warning"
);
}
#[test]
fn duplicate_trait_declaration() {
let src = "<?php\ntrait Serializable {}\ntrait Serializable {}";
let doc = ParsedDoc::parse(src.to_string());
let diags = duplicate_declaration_diagnostics(src, &doc, &DiagnosticsConfig::default());
assert_eq!(
diags.len(),
1,
"expected exactly 1 duplicate-declaration diagnostic, got: {:?}",
diags
);
assert!(
diags[0].message.contains("Serializable"),
"diagnostic message should mention 'Serializable'"
);
assert_eq!(
diags[0].severity,
Some(DiagnosticSeverity::WARNING),
"duplicate trait declaration should be a warning"
);
}
#[test]
fn deprecated_method_call_emits_warning() {
let src = concat!(
"<?php\n",
"class Mailer {\n",
" /** @deprecated Use sendAsync() instead */\n",
" public function send() {}\n",
"}\n",
"$m = new Mailer();\n",
"$m->send();\n",
);
let doc = ParsedDoc::parse(src.to_string());
let diags = deprecated_call_diagnostics(src, &doc, &[], &DiagnosticsConfig::default());
assert_eq!(
diags.len(),
1,
"expected exactly 1 deprecated warning, got: {:?}",
diags
);
let d = &diags[0];
assert_eq!(d.severity, Some(DiagnosticSeverity::WARNING));
assert!(
d.message.contains("send"),
"message should mention 'send', got: {}",
d.message
);
assert!(
d.message.to_lowercase().contains("deprecated"),
"message should contain 'deprecated', got: {}",
d.message
);
}
#[test]
fn deprecated_function_warning_has_correct_message() {
let src = "<?php\n/** @deprecated old API */\nfunction legacyFn() {}\n\nlegacyFn();";
let doc = ParsedDoc::parse(src.to_string());
let diags = deprecated_call_diagnostics(src, &doc, &[], &DiagnosticsConfig::default());
assert_eq!(diags.len(), 1, "expected exactly 1 diagnostic");
let msg = &diags[0].message;
assert!(
msg.contains("legacyFn"),
"message should contain function name 'legacyFn', got: {msg}"
);
assert!(
msg.to_lowercase().contains("deprecated"),
"message should contain 'deprecated', got: {msg}"
);
}
#[test]
fn duplicate_diagnostic_has_warning_severity() {
let src = "<?php\nfunction doWork() {}\nfunction doWork() {}";
let doc = ParsedDoc::parse(src.to_string());
let diags = duplicate_declaration_diagnostics(src, &doc, &DiagnosticsConfig::default());
assert_eq!(diags.len(), 1, "expected exactly 1 duplicate diagnostic");
assert_eq!(
diags[0].severity,
Some(DiagnosticSeverity::WARNING),
"duplicate declaration diagnostic should have WARNING severity"
);
}
#[test]
fn deprecated_call_nested_in_argument_is_detected() {
let src = concat!(
"<?php\n",
"/** @deprecated */\n",
"function oldFn(): string { return ''; }\n",
"function wrapper(string $s): void {}\n",
"wrapper(oldFn());\n",
);
let doc = ParsedDoc::parse(src.to_string());
let diags = deprecated_call_diagnostics(src, &doc, &[], &DiagnosticsConfig::default());
assert_eq!(
diags.len(),
1,
"expected 1 deprecation warning for nested call, got: {:?}",
diags
);
assert!(
diags[0].message.contains("oldFn"),
"message should mention 'oldFn'"
);
}
#[test]
fn deprecated_method_in_trait_is_detected() {
let src = concat!(
"<?php\n",
"trait Logger {\n",
" /** @deprecated Use logAsync() instead */\n",
" public function log() {}\n",
"}\n",
"class App { use Logger; }\n",
"$a = new App();\n",
"$a->log();\n",
);
let doc = ParsedDoc::parse(src.to_string());
let diags = deprecated_call_diagnostics(src, &doc, &[], &DiagnosticsConfig::default());
assert_eq!(
diags.len(),
1,
"expected 1 deprecated warning for trait method, got: {:?}",
diags
);
assert!(
diags[0].message.contains("log"),
"message should mention 'log'"
);
}
#[test]
fn deprecated_method_in_enum_is_detected() {
let src = concat!(
"<?php\n",
"enum Status {\n",
" case Active;\n",
" /** @deprecated Use activeLabel() instead */\n",
" public function label(): string { return 'active'; }\n",
"}\n",
"$s = Status::Active;\n",
"$s->label();\n",
);
let doc = ParsedDoc::parse(src.to_string());
let diags = deprecated_call_diagnostics(src, &doc, &[], &DiagnosticsConfig::default());
assert_eq!(
diags.len(),
1,
"expected 1 deprecated warning for enum method, got: {:?}",
diags
);
assert!(
diags[0].message.contains("label"),
"message should mention 'label'"
);
}
#[test]
fn unbraced_namespace_classes_with_same_name_not_flagged() {
let src = "<?php\nnamespace App\\A;\nclass Foo {}\nnamespace App\\B;\nclass Foo {}";
let doc = ParsedDoc::parse(src.to_string());
let diags = duplicate_declaration_diagnostics(src, &doc, &DiagnosticsConfig::default());
assert!(
diags.is_empty(),
"classes with same name in different unbraced namespaces should not be flagged, got: {:?}",
diags
);
}
#[test]
fn unbraced_namespace_duplicate_in_same_namespace_is_flagged() {
let src = "<?php\nnamespace App;\nclass Foo {}\nclass Foo {}";
let doc = ParsedDoc::parse(src.to_string());
let diags = duplicate_declaration_diagnostics(src, &doc, &DiagnosticsConfig::default());
assert_eq!(
diags.len(),
1,
"expected 1 duplicate-declaration diagnostic, got: {:?}",
diags
);
assert!(diags[0].message.contains("Foo"));
}
#[test]
fn to_lsp_diagnostic_sets_code_to_issue_kind_name() {
use mir_issues::{Issue, IssueKind, Location};
use std::sync::Arc;
use tower_lsp::lsp_types::{NumberOrString, Url};
let uri = Url::parse("file:///test.php").unwrap();
let location = Location {
file: Arc::from("file:///test.php"),
line: 1,
col_start: 0,
col_end: 3,
};
let issue = Issue::new(
IssueKind::UndefinedClass {
name: "Foo".to_string(),
},
location,
);
let diag = to_lsp_diagnostic(issue, &uri);
assert_eq!(
diag.code,
Some(NumberOrString::String("UndefinedClass".to_string())),
"diagnostic code must be the IssueKind name so code actions can match by type"
);
assert!(
diag.message.contains("Foo"),
"diagnostic message should mention the class name"
);
}
}