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