1use std::fmt;
2use std::sync::Arc;
3
4use owo_colors::OwoColorize;
5use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
12pub enum Severity {
13 Info,
15 Warning,
17 Error,
19}
20
21impl fmt::Display for Severity {
22 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
23 match self {
24 Severity::Info => write!(f, "info"),
25 Severity::Warning => write!(f, "warning"),
26 Severity::Error => write!(f, "error"),
27 }
28 }
29}
30
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36pub struct Location {
37 pub file: Arc<str>,
38 pub line: u32,
39 pub col_start: u16,
40 pub col_end: u16,
41}
42
43impl fmt::Display for Location {
44 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
45 write!(f, "{}:{}:{}", self.file, self.line, self.col_start)
46 }
47}
48
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
54#[non_exhaustive]
55pub enum IssueKind {
56 UndefinedVariable { name: String },
58 UndefinedFunction { name: String },
59 UndefinedMethod { class: String, method: String },
60 UndefinedClass { name: String },
61 UndefinedProperty { class: String, property: String },
62 UndefinedConstant { name: String },
63 PossiblyUndefinedVariable { name: String },
64
65 NullArgument { param: String, fn_name: String },
67 NullPropertyFetch { property: String },
68 NullMethodCall { method: String },
69 NullArrayAccess,
70 PossiblyNullArgument { param: String, fn_name: String },
71 PossiblyNullPropertyFetch { property: String },
72 PossiblyNullMethodCall { method: String },
73 PossiblyNullArrayAccess,
74 NullableReturnStatement { expected: String, actual: String },
75
76 InvalidReturnType { expected: String, actual: String },
78 InvalidArgument { param: String, fn_name: String, expected: String, actual: String },
79 InvalidPropertyAssignment { property: String, expected: String, actual: String },
80 InvalidCast { from: String, to: String },
81 InvalidOperand { op: String, left: String, right: String },
82 MismatchingDocblockReturnType { declared: String, inferred: String },
83 MismatchingDocblockParamType { param: String, declared: String, inferred: String },
84
85 InvalidArrayOffset { expected: String, actual: String },
87 NonExistentArrayOffset { key: String },
88 PossiblyInvalidArrayOffset { expected: String, actual: String },
89
90 RedundantCondition { ty: String },
92 RedundantCast { from: String, to: String },
93 UnnecessaryVarAnnotation { var: String },
94 TypeDoesNotContainType { left: String, right: String },
95
96 UnusedVariable { name: String },
98 UnusedParam { name: String },
99 UnreachableCode,
100 UnusedMethod { class: String, method: String },
101 UnusedProperty { class: String, property: String },
102 UnusedFunction { name: String },
103
104 ReadonlyPropertyAssignment { class: String, property: String },
106
107 UnimplementedAbstractMethod { class: String, method: String },
109 UnimplementedInterfaceMethod { class: String, interface: String, method: String },
110 MethodSignatureMismatch { class: String, method: String, detail: String },
111 OverriddenMethodAccess { class: String, method: String },
112 FinalClassExtended { parent: String, child: String },
113 FinalMethodOverridden { class: String, method: String, parent: String },
114
115 TaintedInput { sink: String },
117 TaintedHtml,
118 TaintedSql,
119 TaintedShell,
120
121 InvalidTemplateParam { name: String, expected_bound: String, actual: String },
123
124 DeprecatedMethod { class: String, method: String },
126 DeprecatedClass { name: String },
127 InternalMethod { class: String, method: String },
128 MissingReturnType { fn_name: String },
129 MissingParamType { fn_name: String, param: String },
130 InvalidThrow { ty: String },
131 MissingThrowsDocblock { class: String },
132 ParseError { message: String },
133 InvalidDocblock { message: String },
134 MixedArgument { param: String, fn_name: String },
135 MixedAssignment { var: String },
136 MixedMethodCall { method: String },
137 MixedPropertyFetch { property: String },
138}
139
140impl IssueKind {
141 pub fn default_severity(&self) -> Severity {
143 match self {
144 IssueKind::UndefinedVariable { .. }
146 | IssueKind::UndefinedFunction { .. }
147 | IssueKind::UndefinedMethod { .. }
148 | IssueKind::UndefinedClass { .. }
149 | IssueKind::UndefinedConstant { .. }
150 | IssueKind::InvalidReturnType { .. }
151 | IssueKind::InvalidArgument { .. }
152 | IssueKind::InvalidThrow { .. }
153 | IssueKind::UnimplementedAbstractMethod { .. }
154 | IssueKind::UnimplementedInterfaceMethod { .. }
155 | IssueKind::MethodSignatureMismatch { .. }
156 | IssueKind::FinalClassExtended { .. }
157 | IssueKind::FinalMethodOverridden { .. }
158 | IssueKind::InvalidTemplateParam { .. }
159 | IssueKind::ReadonlyPropertyAssignment { .. }
160 | IssueKind::ParseError { .. }
161 | IssueKind::TaintedInput { .. }
162 | IssueKind::TaintedHtml
163 | IssueKind::TaintedSql
164 | IssueKind::TaintedShell => Severity::Error,
165
166 IssueKind::NullArgument { .. }
168 | IssueKind::NullPropertyFetch { .. }
169 | IssueKind::NullMethodCall { .. }
170 | IssueKind::NullArrayAccess
171 | IssueKind::NullableReturnStatement { .. }
172 | IssueKind::InvalidPropertyAssignment { .. }
173 | IssueKind::InvalidArrayOffset { .. }
174 | IssueKind::NonExistentArrayOffset { .. }
175 | IssueKind::PossiblyInvalidArrayOffset { .. }
176 | IssueKind::UndefinedProperty { .. }
177 | IssueKind::InvalidOperand { .. }
178 | IssueKind::OverriddenMethodAccess { .. }
179 | IssueKind::MissingThrowsDocblock { .. } => Severity::Warning,
180
181 IssueKind::PossiblyUndefinedVariable { .. }
183 | IssueKind::PossiblyNullArgument { .. }
184 | IssueKind::PossiblyNullPropertyFetch { .. }
185 | IssueKind::PossiblyNullMethodCall { .. }
186 | IssueKind::PossiblyNullArrayAccess => Severity::Info,
187
188 IssueKind::RedundantCondition { .. }
190 | IssueKind::RedundantCast { .. }
191 | IssueKind::UnnecessaryVarAnnotation { .. }
192 | IssueKind::TypeDoesNotContainType { .. }
193 | IssueKind::UnusedVariable { .. }
194 | IssueKind::UnusedParam { .. }
195 | IssueKind::UnreachableCode
196 | IssueKind::UnusedMethod { .. }
197 | IssueKind::UnusedProperty { .. }
198 | IssueKind::UnusedFunction { .. }
199 | IssueKind::DeprecatedMethod { .. }
200 | IssueKind::DeprecatedClass { .. }
201 | IssueKind::InternalMethod { .. }
202 | IssueKind::MissingReturnType { .. }
203 | IssueKind::MissingParamType { .. }
204 | IssueKind::MismatchingDocblockReturnType { .. }
205 | IssueKind::MismatchingDocblockParamType { .. }
206 | IssueKind::InvalidDocblock { .. }
207 | IssueKind::InvalidCast { .. }
208 | IssueKind::MixedArgument { .. }
209 | IssueKind::MixedAssignment { .. }
210 | IssueKind::MixedMethodCall { .. }
211 | IssueKind::MixedPropertyFetch { .. } => Severity::Info,
212 }
213 }
214
215 pub fn name(&self) -> &'static str {
217 match self {
218 IssueKind::UndefinedVariable { .. } => "UndefinedVariable",
219 IssueKind::UndefinedFunction { .. } => "UndefinedFunction",
220 IssueKind::UndefinedMethod { .. } => "UndefinedMethod",
221 IssueKind::UndefinedClass { .. } => "UndefinedClass",
222 IssueKind::UndefinedProperty { .. } => "UndefinedProperty",
223 IssueKind::UndefinedConstant { .. } => "UndefinedConstant",
224 IssueKind::PossiblyUndefinedVariable { .. } => "PossiblyUndefinedVariable",
225 IssueKind::NullArgument { .. } => "NullArgument",
226 IssueKind::NullPropertyFetch { .. } => "NullPropertyFetch",
227 IssueKind::NullMethodCall { .. } => "NullMethodCall",
228 IssueKind::NullArrayAccess => "NullArrayAccess",
229 IssueKind::PossiblyNullArgument { .. } => "PossiblyNullArgument",
230 IssueKind::PossiblyNullPropertyFetch { .. } => "PossiblyNullPropertyFetch",
231 IssueKind::PossiblyNullMethodCall { .. } => "PossiblyNullMethodCall",
232 IssueKind::PossiblyNullArrayAccess => "PossiblyNullArrayAccess",
233 IssueKind::NullableReturnStatement { .. } => "NullableReturnStatement",
234 IssueKind::InvalidReturnType { .. } => "InvalidReturnType",
235 IssueKind::InvalidArgument { .. } => "InvalidArgument",
236 IssueKind::InvalidPropertyAssignment { .. } => "InvalidPropertyAssignment",
237 IssueKind::InvalidCast { .. } => "InvalidCast",
238 IssueKind::InvalidOperand { .. } => "InvalidOperand",
239 IssueKind::MismatchingDocblockReturnType { .. } => "MismatchingDocblockReturnType",
240 IssueKind::MismatchingDocblockParamType { .. } => "MismatchingDocblockParamType",
241 IssueKind::InvalidArrayOffset { .. } => "InvalidArrayOffset",
242 IssueKind::NonExistentArrayOffset { .. } => "NonExistentArrayOffset",
243 IssueKind::PossiblyInvalidArrayOffset { .. } => "PossiblyInvalidArrayOffset",
244 IssueKind::RedundantCondition { .. } => "RedundantCondition",
245 IssueKind::RedundantCast { .. } => "RedundantCast",
246 IssueKind::UnnecessaryVarAnnotation { .. } => "UnnecessaryVarAnnotation",
247 IssueKind::TypeDoesNotContainType { .. } => "TypeDoesNotContainType",
248 IssueKind::UnusedVariable { .. } => "UnusedVariable",
249 IssueKind::UnusedParam { .. } => "UnusedParam",
250 IssueKind::UnreachableCode => "UnreachableCode",
251 IssueKind::UnusedMethod { .. } => "UnusedMethod",
252 IssueKind::UnusedProperty { .. } => "UnusedProperty",
253 IssueKind::UnusedFunction { .. } => "UnusedFunction",
254 IssueKind::UnimplementedAbstractMethod { .. } => "UnimplementedAbstractMethod",
255 IssueKind::UnimplementedInterfaceMethod { .. } => "UnimplementedInterfaceMethod",
256 IssueKind::MethodSignatureMismatch { .. } => "MethodSignatureMismatch",
257 IssueKind::OverriddenMethodAccess { .. } => "OverriddenMethodAccess",
258 IssueKind::FinalClassExtended { .. } => "FinalClassExtended",
259 IssueKind::FinalMethodOverridden { .. } => "FinalMethodOverridden",
260 IssueKind::ReadonlyPropertyAssignment { .. } => "ReadonlyPropertyAssignment",
261 IssueKind::InvalidTemplateParam { .. } => "InvalidTemplateParam",
262 IssueKind::TaintedInput { .. } => "TaintedInput",
263 IssueKind::TaintedHtml => "TaintedHtml",
264 IssueKind::TaintedSql => "TaintedSql",
265 IssueKind::TaintedShell => "TaintedShell",
266 IssueKind::DeprecatedMethod { .. } => "DeprecatedMethod",
267 IssueKind::DeprecatedClass { .. } => "DeprecatedClass",
268 IssueKind::InternalMethod { .. } => "InternalMethod",
269 IssueKind::MissingReturnType { .. } => "MissingReturnType",
270 IssueKind::MissingParamType { .. } => "MissingParamType",
271 IssueKind::InvalidThrow { .. } => "InvalidThrow",
272 IssueKind::MissingThrowsDocblock { .. } => "MissingThrowsDocblock",
273 IssueKind::ParseError { .. } => "ParseError",
274 IssueKind::InvalidDocblock { .. } => "InvalidDocblock",
275 IssueKind::MixedArgument { .. } => "MixedArgument",
276 IssueKind::MixedAssignment { .. } => "MixedAssignment",
277 IssueKind::MixedMethodCall { .. } => "MixedMethodCall",
278 IssueKind::MixedPropertyFetch { .. } => "MixedPropertyFetch",
279 }
280 }
281
282 pub fn message(&self) -> String {
284 match self {
285 IssueKind::UndefinedVariable { name } => format!("Variable ${} is not defined", name),
286 IssueKind::UndefinedFunction { name } => format!("Function {}() is not defined", name),
287 IssueKind::UndefinedMethod { class, method } => {
288 format!("Method {}::{}() does not exist", class, method)
289 }
290 IssueKind::UndefinedClass { name } => format!("Class {} does not exist", name),
291 IssueKind::UndefinedProperty { class, property } => {
292 format!("Property {}::${} does not exist", class, property)
293 }
294 IssueKind::UndefinedConstant { name } => format!("Constant {} is not defined", name),
295 IssueKind::PossiblyUndefinedVariable { name } => {
296 format!("Variable ${} might not be defined", name)
297 }
298
299 IssueKind::NullArgument { param, fn_name } => {
300 format!("Argument ${} of {}() cannot be null", param, fn_name)
301 }
302 IssueKind::NullPropertyFetch { property } => {
303 format!("Cannot access property ${} on null", property)
304 }
305 IssueKind::NullMethodCall { method } => {
306 format!("Cannot call method {}() on null", method)
307 }
308 IssueKind::NullArrayAccess => "Cannot access array on null".to_string(),
309 IssueKind::PossiblyNullArgument { param, fn_name } => {
310 format!("Argument ${} of {}() might be null", param, fn_name)
311 }
312 IssueKind::PossiblyNullPropertyFetch { property } => {
313 format!("Cannot access property ${} on possibly null value", property)
314 }
315 IssueKind::PossiblyNullMethodCall { method } => {
316 format!("Cannot call method {}() on possibly null value", method)
317 }
318 IssueKind::PossiblyNullArrayAccess => {
319 "Cannot access array on possibly null value".to_string()
320 }
321 IssueKind::NullableReturnStatement { expected, actual } => {
322 format!("Return type '{}' is not compatible with declared '{}'", actual, expected)
323 }
324
325 IssueKind::InvalidReturnType { expected, actual } => {
326 format!("Return type '{}' is not compatible with declared '{}'", actual, expected)
327 }
328 IssueKind::InvalidArgument { param, fn_name, expected, actual } => {
329 format!(
330 "Argument ${} of {}() expects '{}', got '{}'",
331 param, fn_name, expected, actual
332 )
333 }
334 IssueKind::InvalidPropertyAssignment { property, expected, actual } => {
335 format!(
336 "Property ${} expects '{}', cannot assign '{}'",
337 property, expected, actual
338 )
339 }
340 IssueKind::InvalidCast { from, to } => {
341 format!("Cannot cast '{}' to '{}'", from, to)
342 }
343 IssueKind::InvalidOperand { op, left, right } => {
344 format!("Operator '{}' not supported between '{}' and '{}'", op, left, right)
345 }
346 IssueKind::MismatchingDocblockReturnType { declared, inferred } => {
347 format!("Docblock return type '{}' does not match inferred '{}'", declared, inferred)
348 }
349 IssueKind::MismatchingDocblockParamType { param, declared, inferred } => {
350 format!(
351 "Docblock type '{}' for ${} does not match inferred '{}'",
352 declared, param, inferred
353 )
354 }
355
356 IssueKind::InvalidArrayOffset { expected, actual } => {
357 format!("Array offset expects '{}', got '{}'", expected, actual)
358 }
359 IssueKind::NonExistentArrayOffset { key } => {
360 format!("Array offset '{}' does not exist", key)
361 }
362 IssueKind::PossiblyInvalidArrayOffset { expected, actual } => {
363 format!("Array offset might be invalid: expects '{}', got '{}'", expected, actual)
364 }
365
366 IssueKind::RedundantCondition { ty } => {
367 format!("Condition is always true/false for type '{}'", ty)
368 }
369 IssueKind::RedundantCast { from, to } => {
370 format!("Casting '{}' to '{}' is redundant", from, to)
371 }
372 IssueKind::UnnecessaryVarAnnotation { var } => {
373 format!("@var annotation for ${} is unnecessary", var)
374 }
375 IssueKind::TypeDoesNotContainType { left, right } => {
376 format!("Type '{}' can never contain type '{}'", left, right)
377 }
378
379 IssueKind::UnusedVariable { name } => format!("Variable ${} is never read", name),
380 IssueKind::UnusedParam { name } => format!("Parameter ${} is never used", name),
381 IssueKind::UnreachableCode => "Unreachable code detected".to_string(),
382 IssueKind::UnusedMethod { class, method } => {
383 format!("Private method {}::{}() is never called", class, method)
384 }
385 IssueKind::UnusedProperty { class, property } => {
386 format!("Private property {}::${} is never read", class, property)
387 }
388 IssueKind::UnusedFunction { name } => {
389 format!("Function {}() is never called", name)
390 }
391
392 IssueKind::UnimplementedAbstractMethod { class, method } => {
393 format!("Class {} must implement abstract method {}()", class, method)
394 }
395 IssueKind::UnimplementedInterfaceMethod { class, interface, method } => {
396 format!(
397 "Class {} must implement {}::{}() from interface",
398 class, interface, method
399 )
400 }
401 IssueKind::MethodSignatureMismatch { class, method, detail } => {
402 format!("Method {}::{}() signature mismatch: {}", class, method, detail)
403 }
404 IssueKind::OverriddenMethodAccess { class, method } => {
405 format!(
406 "Method {}::{}() overrides with less visibility",
407 class, method
408 )
409 }
410 IssueKind::ReadonlyPropertyAssignment { class, property } => {
411 format!("Cannot assign to readonly property {}::${} outside of constructor", class, property)
412 }
413 IssueKind::FinalClassExtended { parent, child } => {
414 format!("Class {} cannot extend final class {}", child, parent)
415 }
416 IssueKind::InvalidTemplateParam { name, expected_bound, actual } => {
417 format!(
418 "Template type '{}' inferred as '{}' does not satisfy bound '{}'",
419 name, actual, expected_bound
420 )
421 }
422 IssueKind::FinalMethodOverridden { class, method, parent } => {
423 format!(
424 "Method {}::{}() cannot override final method from {}",
425 class, method, parent
426 )
427 }
428
429 IssueKind::TaintedInput { sink } => format!("Tainted input reaching sink '{}'", sink),
430 IssueKind::TaintedHtml => "Tainted HTML output — possible XSS".to_string(),
431 IssueKind::TaintedSql => "Tainted SQL query — possible SQL injection".to_string(),
432 IssueKind::TaintedShell => {
433 "Tainted shell command — possible command injection".to_string()
434 }
435
436 IssueKind::DeprecatedMethod { class, method } => {
437 format!("Method {}::{}() is deprecated", class, method)
438 }
439 IssueKind::DeprecatedClass { name } => format!("Class {} is deprecated", name),
440 IssueKind::InternalMethod { class, method } => {
441 format!("Method {}::{}() is marked @internal", class, method)
442 }
443 IssueKind::MissingReturnType { fn_name } => {
444 format!("Function {}() has no return type annotation", fn_name)
445 }
446 IssueKind::MissingParamType { fn_name, param } => {
447 format!("Parameter ${} of {}() has no type annotation", param, fn_name)
448 }
449 IssueKind::InvalidThrow { ty } => {
450 format!("Thrown type '{}' does not extend Throwable", ty)
451 }
452 IssueKind::MissingThrowsDocblock { class } => {
453 format!("Exception {} is thrown but not declared in @throws", class)
454 }
455 IssueKind::ParseError { message } => format!("Parse error: {}", message),
456 IssueKind::InvalidDocblock { message } => format!("Invalid docblock: {}", message),
457 IssueKind::MixedArgument { param, fn_name } => {
458 format!("Argument ${} of {}() is mixed", param, fn_name)
459 }
460 IssueKind::MixedAssignment { var } => {
461 format!("Variable ${} is assigned a mixed type", var)
462 }
463 IssueKind::MixedMethodCall { method } => {
464 format!("Method {}() called on mixed type", method)
465 }
466 IssueKind::MixedPropertyFetch { property } => {
467 format!("Property ${} fetched on mixed type", property)
468 }
469 }
470 }
471}
472
473#[derive(Debug, Clone, Serialize, Deserialize)]
478pub struct Issue {
479 pub kind: IssueKind,
480 pub severity: Severity,
481 pub location: Location,
482 pub snippet: Option<String>,
483 pub suppressed: bool,
484}
485
486impl Issue {
487 pub fn new(kind: IssueKind, location: Location) -> Self {
488 let severity = kind.default_severity();
489 Self {
490 severity,
491 kind,
492 location,
493 snippet: None,
494 suppressed: false,
495 }
496 }
497
498 pub fn with_snippet(mut self, snippet: impl Into<String>) -> Self {
499 self.snippet = Some(snippet.into());
500 self
501 }
502
503 pub fn suppress(mut self) -> Self {
504 self.suppressed = true;
505 self
506 }
507}
508
509impl fmt::Display for Issue {
510 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
511 let sev = match self.severity {
512 Severity::Error => "error".red().to_string(),
513 Severity::Warning => "warning".yellow().to_string(),
514 Severity::Info => "info".blue().to_string(),
515 };
516 write!(
517 f,
518 "{} {} {}: {}",
519 self.location.bright_black(),
520 sev,
521 self.kind.name().bold(),
522 self.kind.message()
523 )
524 }
525}
526
527#[derive(Debug, Default)]
532pub struct IssueBuffer {
533 issues: Vec<Issue>,
534 file_suppressions: Vec<String>,
536}
537
538impl IssueBuffer {
539 pub fn new() -> Self {
540 Self::default()
541 }
542
543 pub fn add(&mut self, issue: Issue) {
544 if self.issues.iter().any(|existing| {
546 existing.kind.name() == issue.kind.name()
547 && existing.location.file == issue.location.file
548 && existing.location.line == issue.location.line
549 && existing.location.col_start == issue.location.col_start
550 }) {
551 return;
552 }
553 self.issues.push(issue);
554 }
555
556 pub fn add_suppression(&mut self, name: impl Into<String>) {
557 self.file_suppressions.push(name.into());
558 }
559
560 pub fn into_issues(self) -> Vec<Issue> {
562 self.issues
563 .into_iter()
564 .filter(|i| !i.suppressed)
565 .filter(|i| !self.file_suppressions.contains(&i.kind.name().to_string()))
566 .collect()
567 }
568
569 pub fn suppress_range(&mut self, from: usize, suppressions: &[String]) {
572 if suppressions.is_empty() {
573 return;
574 }
575 for issue in self.issues[from..].iter_mut() {
576 if suppressions.iter().any(|s| s == issue.kind.name()) {
577 issue.suppressed = true;
578 }
579 }
580 }
581
582 pub fn issue_count(&self) -> usize {
585 self.issues.len()
586 }
587
588 pub fn is_empty(&self) -> bool {
589 self.issues.is_empty()
590 }
591
592 pub fn len(&self) -> usize {
593 self.issues.len()
594 }
595
596 pub fn error_count(&self) -> usize {
597 self.issues
598 .iter()
599 .filter(|i| !i.suppressed && i.severity == Severity::Error)
600 .count()
601 }
602
603 pub fn warning_count(&self) -> usize {
604 self.issues
605 .iter()
606 .filter(|i| !i.suppressed && i.severity == Severity::Warning)
607 .count()
608 }
609}