1use php_ast::StmtKind;
6use tower_lsp::lsp_types::{Diagnostic, DiagnosticSeverity, NumberOrString, Position, Range, Url};
7
8use crate::ast::{ParsedDoc, SourceView};
9use crate::config::DiagnosticsConfig;
10use crate::diagnostics::PHP_LSP_SOURCE;
11
12pub fn semantic_diagnostics(
19 uri: &Url,
20 doc: &ParsedDoc,
21 session: &mir_analyzer::AnalysisSession,
22 cfg: &DiagnosticsConfig,
23) -> Vec<Diagnostic> {
24 if !cfg.enabled {
25 return vec![];
26 }
27 let file: std::sync::Arc<str> = std::sync::Arc::from(uri.as_str());
28 session.ingest_file(file.clone(), doc.source_arc());
29 let source_map = php_rs_parser::source_map::SourceMap::new(doc.source());
30 let owned_program = php_ast::owned::to_owned_program(doc.program());
31 let analyzer = mir_analyzer::FileAnalyzer::new(session);
32 let analysis = analyzer.analyze(file.clone(), doc.source(), &owned_program, &source_map);
33 let class_issues = session.class_issues(std::slice::from_ref(&file));
34 analysis
35 .issues
36 .into_iter()
37 .chain(class_issues)
38 .filter(|i| !i.suppressed)
39 .filter(|i| issue_passes_filter(i, cfg))
40 .map(to_lsp_diagnostic)
41 .collect()
42}
43
44pub fn issues_to_diagnostics(
49 issues: &[mir_issues::Issue],
50 _uri: &Url,
51 cfg: &DiagnosticsConfig,
52) -> Vec<Diagnostic> {
53 if !cfg.enabled {
54 return vec![];
55 }
56 issues
57 .iter()
58 .filter(|i| issue_passes_filter(i, cfg))
59 .cloned()
60 .map(to_lsp_diagnostic)
61 .collect()
62}
63
64fn issue_passes_filter(issue: &mir_issues::Issue, cfg: &DiagnosticsConfig) -> bool {
66 use mir_issues::IssueKind;
67 match &issue.kind {
68 IssueKind::UndefinedVariable { .. } | IssueKind::PossiblyUndefinedVariable { .. } => {
69 cfg.undefined_variables
70 }
71 IssueKind::UndefinedFunction { .. } | IssueKind::UndefinedMethod { .. } => {
72 cfg.undefined_functions
73 }
74 IssueKind::UndefinedClass { .. } | IssueKind::UndefinedTrait { .. } => {
75 cfg.undefined_classes
76 }
77 IssueKind::InvalidTraitUse { .. } => cfg.type_errors,
78 IssueKind::TooFewArguments { .. }
79 | IssueKind::TooManyArguments { .. }
80 | IssueKind::InvalidPassByReference { .. }
81 | IssueKind::InvalidNamedArgument { .. } => cfg.arity_errors,
82 IssueKind::InvalidArgument { .. } | IssueKind::PossiblyInvalidArgument { .. } => {
85 cfg.arity_errors || cfg.type_errors
86 }
87 IssueKind::InvalidReturnType { .. }
88 | IssueKind::NullMethodCall { .. }
89 | IssueKind::NullPropertyFetch { .. }
90 | IssueKind::NullArrayAccess
91 | IssueKind::NullArgument { .. }
92 | IssueKind::PossiblyNullMethodCall { .. }
93 | IssueKind::PossiblyNullPropertyFetch { .. }
94 | IssueKind::PossiblyNullArrayAccess
95 | IssueKind::PossiblyNullArgument { .. }
96 | IssueKind::NullableReturnStatement { .. }
97 | IssueKind::InvalidPropertyAssignment { .. }
98 | IssueKind::InvalidOperand { .. }
99 | IssueKind::InvalidCast { .. }
100 | IssueKind::AbstractInstantiation { .. }
101 | IssueKind::MixedClone => cfg.type_errors,
102 IssueKind::DeprecatedCall { .. }
103 | IssueKind::DeprecatedMethodCall { .. }
104 | IssueKind::DeprecatedMethod { .. }
105 | IssueKind::DeprecatedClass { .. } => cfg.deprecated_calls,
106 IssueKind::CircularInheritance { .. } => cfg.type_errors,
107 IssueKind::UnusedVariable { .. }
110 | IssueKind::UnusedParam { .. }
111 | IssueKind::UnusedMethod { .. }
112 | IssueKind::UnusedProperty { .. }
113 | IssueKind::UnusedFunction { .. } => cfg.unused_symbols,
114 _ => true,
115 }
116}
117
118pub fn duplicate_declaration_diagnostics(
120 _source: &str,
121 doc: &ParsedDoc,
122 cfg: &DiagnosticsConfig,
123) -> Vec<Diagnostic> {
124 if !cfg.enabled || !cfg.duplicate_declarations {
125 return vec![];
126 }
127 let sv = doc.view();
128 let mut seen: std::collections::HashMap<String, ()> = std::collections::HashMap::new();
129 let mut diags = Vec::new();
130 collect_duplicate_decls(sv, &doc.program().stmts, "", &mut seen, &mut diags);
131 diags
132}
133
134fn collect_duplicate_decls(
135 sv: SourceView<'_>,
136 stmts: &[php_ast::Stmt<'_, '_>],
137 current_ns: &str,
138 seen: &mut std::collections::HashMap<String, ()>,
139 diags: &mut Vec<Diagnostic>,
140) {
141 let mut active_ns = current_ns.to_string();
143
144 for stmt in stmts {
145 let name_and_span: Option<(String, u32)> = match &stmt.kind {
146 StmtKind::Class(c) => c.name.as_ref().map(|n| (n.to_string(), stmt.span.start)),
147 StmtKind::Interface(i) => Some((i.name.to_string(), stmt.span.start)),
148 StmtKind::Trait(t) => Some((t.name.to_string(), stmt.span.start)),
149 StmtKind::Enum(e) => Some((e.name.to_string(), stmt.span.start)),
150 StmtKind::Function(f) => Some((f.name.to_string(), stmt.span.start)),
151 StmtKind::Namespace(ns) => {
152 let ns_name = ns
153 .name
154 .as_ref()
155 .map(|n| n.to_string_repr().to_string())
156 .unwrap_or_default();
157 match &ns.body {
158 php_ast::NamespaceBody::Braced(inner) => {
159 let child_ns = if current_ns.is_empty() {
160 ns_name
161 } else {
162 format!("{}\\{}", current_ns, ns_name)
163 };
164 collect_duplicate_decls(sv, &inner.stmts, &child_ns, seen, diags);
165 }
166 php_ast::NamespaceBody::Simple => {
167 active_ns = if current_ns.is_empty() {
169 ns_name
170 } else {
171 format!("{}\\{}", current_ns, ns_name)
172 };
173 }
174 }
175 None
176 }
177 _ => None,
178 };
179 if let Some((name, span_start)) = name_and_span {
180 let key = if active_ns.is_empty() {
181 name.clone()
182 } else {
183 format!("{}\\{}", active_ns, name)
184 };
185 if seen.insert(key, ()).is_some() {
186 let name_byte_offset = find_name_offset(&sv.source()[span_start as usize..], &name)
190 .map(|off| span_start + off as u32)
191 .unwrap_or(span_start);
192
193 let start_pos = sv.position_of(name_byte_offset);
194 let name_utf16_len = name.chars().map(|c| c.len_utf16() as u32).sum::<u32>();
196 let end_pos = Position {
197 line: start_pos.line,
198 character: start_pos.character + name_utf16_len,
199 };
200 diags.push(Diagnostic {
201 range: Range {
202 start: start_pos,
203 end: end_pos,
204 },
205 severity: Some(DiagnosticSeverity::WARNING),
206 message: format!(
207 "Duplicate declaration: `{name}` is already defined in this file"
208 ),
209 source: Some(PHP_LSP_SOURCE.to_string()),
210 ..Default::default()
211 });
212 }
213 }
214 }
215}
216
217fn find_name_offset(source: &str, name: &str) -> Option<usize> {
220 let bytes = source.as_bytes();
221 for i in 0..source.len() {
222 if source[i..].starts_with(name) {
223 let before_ok = i == 0 || !is_identifier_char(bytes[i - 1] as char);
225 let after_idx = i + name.len();
227 let after_ok =
228 after_idx >= source.len() || !is_identifier_char(bytes[after_idx] as char);
229 if before_ok && after_ok {
230 return Some(i);
231 }
232 }
233 }
234 None
235}
236
237fn is_identifier_char(c: char) -> bool {
239 c.is_alphanumeric() || c == '_'
240}
241
242fn uses_codebase_location(kind: &mir_issues::IssueKind) -> bool {
246 use mir_issues::IssueKind;
247 matches!(
248 kind,
249 IssueKind::CircularInheritance { .. }
250 | IssueKind::FinalClassExtended { .. }
251 | IssueKind::UnimplementedAbstractMethod { .. }
252 | IssueKind::UnimplementedInterfaceMethod { .. }
253 | IssueKind::FinalMethodOverridden { .. }
254 | IssueKind::OverriddenMethodAccess { .. }
255 | IssueKind::MethodSignatureMismatch { .. }
256 | IssueKind::InvalidTraitUse { .. }
257 )
258}
259
260fn to_lsp_diagnostic(issue: mir_issues::Issue) -> Diagnostic {
261 let line = issue.location.line.saturating_sub(1);
265 let (col_start, col_end) = if uses_codebase_location(&issue.kind) {
266 (
267 issue.location.col_start as u32,
268 issue.location.col_end as u32,
269 )
270 } else {
271 (
272 issue.location.col_start.saturating_sub(1) as u32,
273 issue.location.col_end.saturating_sub(1) as u32,
274 )
275 };
276 Diagnostic {
277 range: Range {
278 start: Position {
279 line,
280 character: col_start,
281 },
282 end: Position {
283 line,
284 character: col_end.max(col_start + 1),
285 },
286 },
287 severity: Some(match issue.severity {
288 mir_issues::Severity::Error => DiagnosticSeverity::ERROR,
289 mir_issues::Severity::Warning => DiagnosticSeverity::WARNING,
290 mir_issues::Severity::Info => DiagnosticSeverity::INFORMATION,
291 }),
292 code: Some(NumberOrString::String(issue.kind.name().to_string())),
293 source: Some(PHP_LSP_SOURCE.to_string()),
294 message: issue.kind.message(),
295 ..Default::default()
296 }
297}
298
299#[cfg(test)]
300mod tests {
301 use super::*;
302
303 #[test]
304 fn duplicate_class_emits_warning() {
305 let src = "<?php\nclass Foo {}\nclass Foo {}";
306 let doc = ParsedDoc::parse(src.to_string());
307 let diags = duplicate_declaration_diagnostics(src, &doc, &DiagnosticsConfig::all_enabled());
308 assert_eq!(
309 diags.len(),
310 1,
311 "expected exactly 1 duplicate warning, got: {:?}",
312 diags
313 );
314 assert_eq!(diags[0].severity, Some(DiagnosticSeverity::WARNING));
315 assert!(
316 diags[0].message.contains("Foo"),
317 "message should mention 'Foo'"
318 );
319 }
320
321 #[test]
322 fn no_duplicate_for_unique_declarations() {
323 let src = "<?php\nclass Foo {}\nclass Bar {}";
324 let doc = ParsedDoc::parse(src.to_string());
325 let diags = duplicate_declaration_diagnostics(src, &doc, &DiagnosticsConfig::all_enabled());
326 assert!(diags.is_empty());
327 }
328
329 #[test]
330 fn namespace_scoped_duplicate_not_flagged() {
331 let src = "<?php\nnamespace App\\A {\nclass Foo {}\n}\nnamespace App\\B {\nclass Foo {}\n}";
333 let doc = ParsedDoc::parse(src.to_string());
334 let diags = duplicate_declaration_diagnostics(src, &doc, &DiagnosticsConfig::all_enabled());
335 assert!(
336 diags.is_empty(),
337 "classes with same name in different namespaces should not be flagged, got: {:?}",
338 diags
339 );
340 }
341
342 #[test]
343 fn duplicate_interface_declaration() {
344 let src = "<?php\ninterface Logger {}\ninterface Logger {}";
346 let doc = ParsedDoc::parse(src.to_string());
347 let diags = duplicate_declaration_diagnostics(src, &doc, &DiagnosticsConfig::all_enabled());
348 assert_eq!(
349 diags.len(),
350 1,
351 "expected exactly 1 duplicate-declaration diagnostic, got: {:?}",
352 diags
353 );
354 assert!(
355 diags[0].message.contains("Logger"),
356 "diagnostic message should mention 'Logger'"
357 );
358 assert_eq!(
359 diags[0].severity,
360 Some(DiagnosticSeverity::WARNING),
361 "duplicate declaration should be a warning"
362 );
363 }
364
365 #[test]
366 fn duplicate_trait_declaration() {
367 let src = "<?php\ntrait Serializable {}\ntrait Serializable {}";
369 let doc = ParsedDoc::parse(src.to_string());
370 let diags = duplicate_declaration_diagnostics(src, &doc, &DiagnosticsConfig::all_enabled());
371 assert_eq!(
372 diags.len(),
373 1,
374 "expected exactly 1 duplicate-declaration diagnostic, got: {:?}",
375 diags
376 );
377 assert!(
378 diags[0].message.contains("Serializable"),
379 "diagnostic message should mention 'Serializable'"
380 );
381 assert_eq!(
382 diags[0].severity,
383 Some(DiagnosticSeverity::WARNING),
384 "duplicate trait declaration should be a warning"
385 );
386 }
387
388 #[test]
389 fn duplicate_diagnostic_has_warning_severity() {
390 let src = "<?php\nfunction doWork() {}\nfunction doWork() {}";
393 let doc = ParsedDoc::parse(src.to_string());
394 let diags = duplicate_declaration_diagnostics(src, &doc, &DiagnosticsConfig::all_enabled());
395 assert_eq!(diags.len(), 1, "expected exactly 1 duplicate diagnostic");
396 assert_eq!(
397 diags[0].severity,
398 Some(DiagnosticSeverity::WARNING),
399 "duplicate declaration diagnostic should have WARNING severity"
400 );
401 }
402
403 #[test]
404 fn unbraced_namespace_classes_with_same_name_not_flagged() {
405 let src = "<?php\nnamespace App\\A;\nclass Foo {}\nnamespace App\\B;\nclass Foo {}";
407 let doc = ParsedDoc::parse(src.to_string());
408 let diags = duplicate_declaration_diagnostics(src, &doc, &DiagnosticsConfig::all_enabled());
409 assert!(
410 diags.is_empty(),
411 "classes with same name in different unbraced namespaces should not be flagged, got: {:?}",
412 diags
413 );
414 }
415
416 #[test]
417 fn unbraced_namespace_duplicate_in_same_namespace_is_flagged() {
418 let src = "<?php\nnamespace App;\nclass Foo {}\nclass Foo {}";
420 let doc = ParsedDoc::parse(src.to_string());
421 let diags = duplicate_declaration_diagnostics(src, &doc, &DiagnosticsConfig::all_enabled());
422 assert_eq!(
423 diags.len(),
424 1,
425 "expected 1 duplicate-declaration diagnostic, got: {:?}",
426 diags
427 );
428 assert!(diags[0].message.contains("Foo"));
429 }
430
431 #[test]
432 fn duplicate_declaration_range_spans_full_name() {
433 let src = "<?php\nclass Foo {}\nclass Foo {}";
435 let doc = ParsedDoc::parse(src.to_string());
436 let diags = duplicate_declaration_diagnostics(src, &doc, &DiagnosticsConfig::all_enabled());
437 assert_eq!(diags.len(), 1, "expected exactly 1 duplicate diagnostic");
438
439 let d = &diags[0];
440 let range_len = d.range.end.character - d.range.start.character;
441 let expected_len = "Foo".chars().map(|c| c.len_utf16() as u32).sum::<u32>();
442 assert_eq!(
443 range_len, expected_len,
444 "range length {} should match 'Foo' length {}",
445 range_len, expected_len
446 );
447
448 assert_eq!(
452 d.range.start.character, 6,
453 "range should start at 'F' in 'Foo'"
454 );
455 assert_eq!(
456 d.range.end.character, 9,
457 "range should end after 'o' in 'Foo'"
458 );
459 }
460
461 #[test]
462 fn duplicate_function_declaration_range_spans_name() {
463 let src = "<?php\nfunction doWork() {}\nfunction doWork() {}";
465 let doc = ParsedDoc::parse(src.to_string());
466 let diags = duplicate_declaration_diagnostics(src, &doc, &DiagnosticsConfig::all_enabled());
467 assert_eq!(diags.len(), 1, "expected exactly 1 duplicate diagnostic");
468
469 let d = &diags[0];
470 let range_len = d.range.end.character - d.range.start.character;
471 let expected_len = "doWork".chars().map(|c| c.len_utf16() as u32).sum::<u32>();
472 assert_eq!(
473 range_len, expected_len,
474 "range length {} should match 'doWork' length {}",
475 range_len, expected_len
476 );
477
478 assert_eq!(
482 d.range.start.character, 9,
483 "range should start at 'd' in 'doWork'"
484 );
485 assert_eq!(
486 d.range.end.character, 15,
487 "range should end after 'k' in 'doWork'"
488 );
489 }
490
491 #[test]
492 fn duplicate_interface_range_spans_name() {
493 let src = "<?php\ninterface Logger {}\ninterface Logger {}";
495 let doc = ParsedDoc::parse(src.to_string());
496 let diags = duplicate_declaration_diagnostics(src, &doc, &DiagnosticsConfig::all_enabled());
497 assert_eq!(diags.len(), 1, "expected exactly 1 duplicate diagnostic");
498
499 let d = &diags[0];
500 let range_len = d.range.end.character - d.range.start.character;
501 let expected_len = "Logger".chars().map(|c| c.len_utf16() as u32).sum::<u32>();
502 assert_eq!(
503 range_len, expected_len,
504 "range length {} should match 'Logger' length {}",
505 range_len, expected_len
506 );
507
508 assert_eq!(
512 d.range.start.character, 10,
513 "range should start at 'L' in 'Logger'"
514 );
515 assert_eq!(
516 d.range.end.character, 16,
517 "range should end after 'r' in 'Logger'"
518 );
519 }
520
521 #[test]
522 fn duplicate_declaration_range_on_correct_line() {
523 let src = "<?php\nclass Foo {}\n\nclass Foo {}";
525 let doc = ParsedDoc::parse(src.to_string());
526 let diags = duplicate_declaration_diagnostics(src, &doc, &DiagnosticsConfig::all_enabled());
527 assert_eq!(diags.len(), 1, "expected exactly 1 duplicate diagnostic");
528
529 let d = &diags[0];
530 assert_eq!(
532 d.range.start.line, 3,
533 "duplicate should be reported on line 3 (0-indexed)"
534 );
535 assert_eq!(
536 d.range.end.line, 3,
537 "range end should be on same line as start"
538 );
539 }
540
541 #[test]
542 fn to_lsp_diagnostic_sets_code_to_issue_kind_name() {
543 use mir_issues::{Issue, IssueKind, Location};
544 use std::sync::Arc;
545 use tower_lsp::lsp_types::NumberOrString;
546
547 let location = Location {
548 file: Arc::from("file:///test.php"),
549 line: 1,
550 line_end: 1,
551 col_start: 0,
552 col_end: 3,
553 };
554 let issue = Issue::new(
555 IssueKind::UndefinedClass {
556 name: "Foo".to_string(),
557 },
558 location,
559 );
560 let diag = to_lsp_diagnostic(issue);
561 assert_eq!(
562 diag.code,
563 Some(NumberOrString::String("UndefinedClass".to_string())),
564 "diagnostic code must be the IssueKind name so code actions can match by type"
565 );
566 assert!(
567 diag.message.contains("Foo"),
568 "diagnostic message should mention the class name"
569 );
570 }
571}