Skip to main content

mir_issues/
lib.rs

1use std::collections::HashSet;
2use std::fmt;
3use std::sync::Arc;
4
5use owo_colors::OwoColorize;
6use serde::{Deserialize, Serialize};
7
8// ---------------------------------------------------------------------------
9// Severity
10// ---------------------------------------------------------------------------
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
13pub enum Severity {
14    /// Only shown with `--show-info`
15    Info,
16    /// Warnings — shown at default level
17    Warning,
18    /// Errors — always shown; non-zero exit code
19    Error,
20}
21
22impl fmt::Display for Severity {
23    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
24        match self {
25            Severity::Info => write!(f, "info"),
26            Severity::Warning => write!(f, "warning"),
27            Severity::Error => write!(f, "error"),
28        }
29    }
30}
31
32// ---------------------------------------------------------------------------
33// Location
34// ---------------------------------------------------------------------------
35
36pub use mir_types::Location;
37
38// ---------------------------------------------------------------------------
39// IssueKind
40// ---------------------------------------------------------------------------
41
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43#[non_exhaustive]
44pub enum IssueKind {
45    // --- Undefined ----------------------------------------------------------
46    InvalidScope {
47        /// `true` when inside a class but in a static method; `false` when outside a class.
48        in_class: bool,
49    },
50    UndefinedVariable {
51        name: String,
52    },
53    UndefinedFunction {
54        name: String,
55    },
56    UndefinedMethod {
57        class: String,
58        method: String,
59    },
60    UndefinedClass {
61        name: String,
62    },
63    UndefinedProperty {
64        class: String,
65        property: String,
66    },
67    UndefinedConstant {
68        name: String,
69    },
70    PossiblyUndefinedVariable {
71        name: String,
72    },
73
74    // --- Nullability --------------------------------------------------------
75    NullArgument {
76        param: String,
77        fn_name: String,
78    },
79    NullPropertyFetch {
80        property: String,
81    },
82    NullMethodCall {
83        method: String,
84    },
85    NullArrayAccess,
86    PossiblyNullArgument {
87        param: String,
88        fn_name: String,
89    },
90    PossiblyInvalidArgument {
91        param: String,
92        fn_name: String,
93        expected: String,
94        actual: String,
95    },
96    PossiblyNullPropertyFetch {
97        property: String,
98    },
99    PossiblyNullMethodCall {
100        method: String,
101    },
102    PossiblyNullArrayAccess,
103    NullableReturnStatement {
104        expected: String,
105        actual: String,
106    },
107
108    // --- Type mismatches ----------------------------------------------------
109    InvalidReturnType {
110        expected: String,
111        actual: String,
112    },
113    InvalidArgument {
114        param: String,
115        fn_name: String,
116        expected: String,
117        actual: String,
118    },
119    TooFewArguments {
120        fn_name: String,
121        expected: usize,
122        actual: usize,
123    },
124    TooManyArguments {
125        fn_name: String,
126        expected: usize,
127        actual: usize,
128    },
129    InvalidNamedArgument {
130        fn_name: String,
131        name: String,
132    },
133    InvalidPassByReference {
134        fn_name: String,
135        param: String,
136    },
137    InvalidPropertyAssignment {
138        property: String,
139        expected: String,
140        actual: String,
141    },
142    InvalidCast {
143        from: String,
144        to: String,
145    },
146    InvalidOperand {
147        op: String,
148        left: String,
149        right: String,
150    },
151    MismatchingDocblockReturnType {
152        declared: String,
153        inferred: String,
154    },
155    MismatchingDocblockParamType {
156        param: String,
157        declared: String,
158        inferred: String,
159    },
160
161    // --- Array issues -------------------------------------------------------
162    InvalidArrayOffset {
163        expected: String,
164        actual: String,
165    },
166    NonExistentArrayOffset {
167        key: String,
168    },
169    PossiblyInvalidArrayOffset {
170        expected: String,
171        actual: String,
172    },
173
174    // --- Redundancy ---------------------------------------------------------
175    RedundantCondition {
176        ty: String,
177    },
178    RedundantCast {
179        from: String,
180        to: String,
181    },
182    UnnecessaryVarAnnotation {
183        var: String,
184    },
185    TypeDoesNotContainType {
186        left: String,
187        right: String,
188    },
189
190    // --- Dead code ----------------------------------------------------------
191    UnusedVariable {
192        name: String,
193    },
194    UnusedParam {
195        name: String,
196    },
197    UnreachableCode,
198    UnusedMethod {
199        class: String,
200        method: String,
201    },
202    UnusedProperty {
203        class: String,
204        property: String,
205    },
206    UnusedFunction {
207        name: String,
208    },
209
210    // --- Readonly -----------------------------------------------------------
211    ReadonlyPropertyAssignment {
212        class: String,
213        property: String,
214    },
215
216    // --- Inheritance --------------------------------------------------------
217    UnimplementedAbstractMethod {
218        class: String,
219        method: String,
220    },
221    UnimplementedInterfaceMethod {
222        class: String,
223        interface: String,
224        method: String,
225    },
226    MethodSignatureMismatch {
227        class: String,
228        method: String,
229        detail: String,
230    },
231    OverriddenMethodAccess {
232        class: String,
233        method: String,
234    },
235    FinalClassExtended {
236        parent: String,
237        child: String,
238    },
239    FinalMethodOverridden {
240        class: String,
241        method: String,
242        parent: String,
243    },
244
245    // --- Security (taint) ---------------------------------------------------
246    TaintedInput {
247        sink: String,
248    },
249    TaintedHtml,
250    TaintedSql,
251    TaintedShell,
252
253    // --- Generics -----------------------------------------------------------
254    InvalidTemplateParam {
255        name: String,
256        expected_bound: String,
257        actual: String,
258    },
259    ShadowedTemplateParam {
260        name: String,
261    },
262
263    // --- Other --------------------------------------------------------------
264    DeprecatedCall {
265        name: String,
266        message: Option<Arc<str>>,
267    },
268    DeprecatedMethodCall {
269        class: String,
270        method: String,
271        message: Option<Arc<str>>,
272    },
273    DeprecatedMethod {
274        class: String,
275        method: String,
276        message: Option<Arc<str>>,
277    },
278    DeprecatedClass {
279        name: String,
280        message: Option<Arc<str>>,
281    },
282    InternalMethod {
283        class: String,
284        method: String,
285    },
286    MissingReturnType {
287        fn_name: String,
288    },
289    MissingParamType {
290        fn_name: String,
291        param: String,
292    },
293    InvalidThrow {
294        ty: String,
295    },
296    MissingThrowsDocblock {
297        class: String,
298    },
299    ParseError {
300        message: String,
301    },
302    InvalidDocblock {
303        message: String,
304    },
305    MixedArgument {
306        param: String,
307        fn_name: String,
308    },
309    MixedAssignment {
310        var: String,
311    },
312    MixedMethodCall {
313        method: String,
314    },
315    MixedPropertyFetch {
316        property: String,
317    },
318    CircularInheritance {
319        class: String,
320    },
321
322    // --- Trait constraints --------------------------------------------------
323    InvalidTraitUse {
324        trait_name: String,
325        reason: String,
326    },
327}
328
329fn append_deprecation_message(base: String, message: &Option<Arc<str>>) -> String {
330    match message.as_deref().filter(|m| !m.is_empty()) {
331        Some(msg) => format!("{base}: {msg}"),
332        None => base,
333    }
334}
335
336impl IssueKind {
337    /// Default severity for this issue kind.
338    pub fn default_severity(&self) -> Severity {
339        match self {
340            // Errors (always blocking)
341            IssueKind::InvalidScope { .. }
342            | IssueKind::UndefinedVariable { .. }
343            | IssueKind::UndefinedFunction { .. }
344            | IssueKind::UndefinedMethod { .. }
345            | IssueKind::UndefinedClass { .. }
346            | IssueKind::UndefinedConstant { .. }
347            | IssueKind::InvalidReturnType { .. }
348            | IssueKind::InvalidArgument { .. }
349            | IssueKind::TooFewArguments { .. }
350            | IssueKind::TooManyArguments { .. }
351            | IssueKind::InvalidNamedArgument { .. }
352            | IssueKind::InvalidPassByReference { .. }
353            | IssueKind::InvalidThrow { .. }
354            | IssueKind::UnimplementedAbstractMethod { .. }
355            | IssueKind::UnimplementedInterfaceMethod { .. }
356            | IssueKind::MethodSignatureMismatch { .. }
357            | IssueKind::FinalClassExtended { .. }
358            | IssueKind::FinalMethodOverridden { .. }
359            | IssueKind::InvalidTemplateParam { .. }
360            | IssueKind::ReadonlyPropertyAssignment { .. }
361            | IssueKind::ParseError { .. }
362            | IssueKind::TaintedInput { .. }
363            | IssueKind::TaintedHtml
364            | IssueKind::TaintedSql
365            | IssueKind::TaintedShell
366            | IssueKind::CircularInheritance { .. }
367            | IssueKind::InvalidTraitUse { .. } => Severity::Error,
368
369            // Warnings (shown at default error level)
370            IssueKind::NullArgument { .. }
371            | IssueKind::NullPropertyFetch { .. }
372            | IssueKind::NullMethodCall { .. }
373            | IssueKind::NullArrayAccess
374            | IssueKind::NullableReturnStatement { .. }
375            | IssueKind::InvalidPropertyAssignment { .. }
376            | IssueKind::InvalidArrayOffset { .. }
377            | IssueKind::NonExistentArrayOffset { .. }
378            | IssueKind::PossiblyInvalidArrayOffset { .. }
379            | IssueKind::UndefinedProperty { .. }
380            | IssueKind::InvalidOperand { .. }
381            | IssueKind::OverriddenMethodAccess { .. }
382            | IssueKind::MissingThrowsDocblock { .. }
383            | IssueKind::UnusedVariable { .. } => Severity::Warning,
384
385            // PossiblyUndefined: shown at default error level (same as Warning)
386            IssueKind::PossiblyUndefinedVariable { .. } => Severity::Warning,
387
388            // Possibly-null / possibly-invalid (only shown in strict mode, level ≥ 7)
389            IssueKind::PossiblyNullArgument { .. }
390            | IssueKind::PossiblyInvalidArgument { .. }
391            | IssueKind::PossiblyNullPropertyFetch { .. }
392            | IssueKind::PossiblyNullMethodCall { .. }
393            | IssueKind::PossiblyNullArrayAccess => Severity::Info,
394
395            // Info
396            IssueKind::RedundantCondition { .. }
397            | IssueKind::RedundantCast { .. }
398            | IssueKind::UnnecessaryVarAnnotation { .. }
399            | IssueKind::TypeDoesNotContainType { .. }
400            | IssueKind::UnusedParam { .. }
401            | IssueKind::UnreachableCode
402            | IssueKind::UnusedMethod { .. }
403            | IssueKind::UnusedProperty { .. }
404            | IssueKind::UnusedFunction { .. }
405            | IssueKind::DeprecatedCall { .. }
406            | IssueKind::DeprecatedMethodCall { .. }
407            | IssueKind::DeprecatedMethod { .. }
408            | IssueKind::DeprecatedClass { .. }
409            | IssueKind::InternalMethod { .. }
410            | IssueKind::MissingReturnType { .. }
411            | IssueKind::MissingParamType { .. }
412            | IssueKind::MismatchingDocblockReturnType { .. }
413            | IssueKind::MismatchingDocblockParamType { .. }
414            | IssueKind::InvalidDocblock { .. }
415            | IssueKind::InvalidCast { .. }
416            | IssueKind::MixedArgument { .. }
417            | IssueKind::MixedAssignment { .. }
418            | IssueKind::MixedMethodCall { .. }
419            | IssueKind::MixedPropertyFetch { .. }
420            | IssueKind::ShadowedTemplateParam { .. } => Severity::Info,
421        }
422    }
423
424    /// Identifier name used in config and `@psalm-suppress` / `@suppress` annotations.
425    pub fn name(&self) -> &'static str {
426        match self {
427            IssueKind::InvalidScope { .. } => "InvalidScope",
428            IssueKind::UndefinedVariable { .. } => "UndefinedVariable",
429            IssueKind::UndefinedFunction { .. } => "UndefinedFunction",
430            IssueKind::UndefinedMethod { .. } => "UndefinedMethod",
431            IssueKind::UndefinedClass { .. } => "UndefinedClass",
432            IssueKind::UndefinedProperty { .. } => "UndefinedProperty",
433            IssueKind::UndefinedConstant { .. } => "UndefinedConstant",
434            IssueKind::PossiblyUndefinedVariable { .. } => "PossiblyUndefinedVariable",
435            IssueKind::NullArgument { .. } => "NullArgument",
436            IssueKind::NullPropertyFetch { .. } => "NullPropertyFetch",
437            IssueKind::NullMethodCall { .. } => "NullMethodCall",
438            IssueKind::NullArrayAccess => "NullArrayAccess",
439            IssueKind::PossiblyNullArgument { .. } => "PossiblyNullArgument",
440            IssueKind::PossiblyInvalidArgument { .. } => "PossiblyInvalidArgument",
441            IssueKind::PossiblyNullPropertyFetch { .. } => "PossiblyNullPropertyFetch",
442            IssueKind::PossiblyNullMethodCall { .. } => "PossiblyNullMethodCall",
443            IssueKind::PossiblyNullArrayAccess => "PossiblyNullArrayAccess",
444            IssueKind::NullableReturnStatement { .. } => "NullableReturnStatement",
445            IssueKind::InvalidReturnType { .. } => "InvalidReturnType",
446            IssueKind::InvalidArgument { .. } => "InvalidArgument",
447            IssueKind::TooFewArguments { .. } => "TooFewArguments",
448            IssueKind::TooManyArguments { .. } => "TooManyArguments",
449            IssueKind::InvalidNamedArgument { .. } => "InvalidNamedArgument",
450            IssueKind::InvalidPassByReference { .. } => "InvalidPassByReference",
451            IssueKind::InvalidPropertyAssignment { .. } => "InvalidPropertyAssignment",
452            IssueKind::InvalidCast { .. } => "InvalidCast",
453            IssueKind::InvalidOperand { .. } => "InvalidOperand",
454            IssueKind::MismatchingDocblockReturnType { .. } => "MismatchingDocblockReturnType",
455            IssueKind::MismatchingDocblockParamType { .. } => "MismatchingDocblockParamType",
456            IssueKind::InvalidArrayOffset { .. } => "InvalidArrayOffset",
457            IssueKind::NonExistentArrayOffset { .. } => "NonExistentArrayOffset",
458            IssueKind::PossiblyInvalidArrayOffset { .. } => "PossiblyInvalidArrayOffset",
459            IssueKind::RedundantCondition { .. } => "RedundantCondition",
460            IssueKind::RedundantCast { .. } => "RedundantCast",
461            IssueKind::UnnecessaryVarAnnotation { .. } => "UnnecessaryVarAnnotation",
462            IssueKind::TypeDoesNotContainType { .. } => "TypeDoesNotContainType",
463            IssueKind::UnusedVariable { .. } => "UnusedVariable",
464            IssueKind::UnusedParam { .. } => "UnusedParam",
465            IssueKind::UnreachableCode => "UnreachableCode",
466            IssueKind::UnusedMethod { .. } => "UnusedMethod",
467            IssueKind::UnusedProperty { .. } => "UnusedProperty",
468            IssueKind::UnusedFunction { .. } => "UnusedFunction",
469            IssueKind::UnimplementedAbstractMethod { .. } => "UnimplementedAbstractMethod",
470            IssueKind::UnimplementedInterfaceMethod { .. } => "UnimplementedInterfaceMethod",
471            IssueKind::MethodSignatureMismatch { .. } => "MethodSignatureMismatch",
472            IssueKind::OverriddenMethodAccess { .. } => "OverriddenMethodAccess",
473            IssueKind::FinalClassExtended { .. } => "FinalClassExtended",
474            IssueKind::FinalMethodOverridden { .. } => "FinalMethodOverridden",
475            IssueKind::ReadonlyPropertyAssignment { .. } => "ReadonlyPropertyAssignment",
476            IssueKind::InvalidTemplateParam { .. } => "InvalidTemplateParam",
477            IssueKind::ShadowedTemplateParam { .. } => "ShadowedTemplateParam",
478            IssueKind::TaintedInput { .. } => "TaintedInput",
479            IssueKind::TaintedHtml => "TaintedHtml",
480            IssueKind::TaintedSql => "TaintedSql",
481            IssueKind::TaintedShell => "TaintedShell",
482            IssueKind::DeprecatedCall { .. } => "DeprecatedCall",
483            IssueKind::DeprecatedMethodCall { .. } => "DeprecatedMethodCall",
484            IssueKind::DeprecatedMethod { .. } => "DeprecatedMethod",
485            IssueKind::DeprecatedClass { .. } => "DeprecatedClass",
486            IssueKind::InternalMethod { .. } => "InternalMethod",
487            IssueKind::MissingReturnType { .. } => "MissingReturnType",
488            IssueKind::MissingParamType { .. } => "MissingParamType",
489            IssueKind::InvalidThrow { .. } => "InvalidThrow",
490            IssueKind::MissingThrowsDocblock { .. } => "MissingThrowsDocblock",
491            IssueKind::ParseError { .. } => "ParseError",
492            IssueKind::InvalidDocblock { .. } => "InvalidDocblock",
493            IssueKind::MixedArgument { .. } => "MixedArgument",
494            IssueKind::MixedAssignment { .. } => "MixedAssignment",
495            IssueKind::MixedMethodCall { .. } => "MixedMethodCall",
496            IssueKind::MixedPropertyFetch { .. } => "MixedPropertyFetch",
497            IssueKind::CircularInheritance { .. } => "CircularInheritance",
498            IssueKind::InvalidTraitUse { .. } => "InvalidTraitUse",
499        }
500    }
501
502    /// Human-readable message for this issue.
503    pub fn message(&self) -> String {
504        match self {
505            IssueKind::InvalidScope { in_class } => {
506                if *in_class {
507                    "$this cannot be used in a static method".to_string()
508                } else {
509                    "$this cannot be used outside of a class".to_string()
510                }
511            }
512            IssueKind::UndefinedVariable { name } => format!("Variable ${name} is not defined"),
513            IssueKind::UndefinedFunction { name } => format!("Function {name}() is not defined"),
514            IssueKind::UndefinedMethod { class, method } => {
515                format!("Method {class}::{method}() does not exist")
516            }
517            IssueKind::UndefinedClass { name } => format!("Class {name} does not exist"),
518            IssueKind::UndefinedProperty { class, property } => {
519                format!("Property {class}::${property} does not exist")
520            }
521            IssueKind::UndefinedConstant { name } => format!("Constant {name} is not defined"),
522            IssueKind::PossiblyUndefinedVariable { name } => {
523                format!("Variable ${name} might not be defined")
524            }
525
526            IssueKind::NullArgument { param, fn_name } => {
527                format!("Argument ${param} of {fn_name}() cannot be null")
528            }
529            IssueKind::NullPropertyFetch { property } => {
530                format!("Cannot access property ${property} on null")
531            }
532            IssueKind::NullMethodCall { method } => {
533                format!("Cannot call method {method}() on null")
534            }
535            IssueKind::NullArrayAccess => "Cannot access array on null".to_string(),
536            IssueKind::PossiblyNullArgument { param, fn_name } => {
537                format!("Argument ${param} of {fn_name}() might be null")
538            }
539            IssueKind::PossiblyInvalidArgument {
540                param,
541                fn_name,
542                expected,
543                actual,
544            } => {
545                format!("Argument ${param} of {fn_name}() expects '{expected}', possibly different type '{actual}' provided")
546            }
547            IssueKind::PossiblyNullPropertyFetch { property } => {
548                format!("Cannot access property ${property} on possibly null value")
549            }
550            IssueKind::PossiblyNullMethodCall { method } => {
551                format!("Cannot call method {method}() on possibly null value")
552            }
553            IssueKind::PossiblyNullArrayAccess => {
554                "Cannot access array on possibly null value".to_string()
555            }
556            IssueKind::NullableReturnStatement { expected, actual } => {
557                format!("Return type '{actual}' is not compatible with declared '{expected}'")
558            }
559
560            IssueKind::InvalidReturnType { expected, actual } => {
561                format!("Return type '{actual}' is not compatible with declared '{expected}'")
562            }
563            IssueKind::InvalidArgument {
564                param,
565                fn_name,
566                expected,
567                actual,
568            } => {
569                format!("Argument ${param} of {fn_name}() expects '{expected}', got '{actual}'")
570            }
571            IssueKind::TooFewArguments {
572                fn_name,
573                expected,
574                actual,
575            } => {
576                format!(
577                    "Too few arguments for {}(): expected {}, got {}",
578                    fn_name, expected, actual
579                )
580            }
581            IssueKind::TooManyArguments {
582                fn_name,
583                expected,
584                actual,
585            } => {
586                format!(
587                    "Too many arguments for {}(): expected {}, got {}",
588                    fn_name, expected, actual
589                )
590            }
591            IssueKind::InvalidNamedArgument { fn_name, name } => {
592                format!("{}() has no parameter named ${}", fn_name, name)
593            }
594            IssueKind::InvalidPassByReference { fn_name, param } => {
595                format!(
596                    "Argument ${} of {}() must be passed by reference",
597                    param, fn_name
598                )
599            }
600            IssueKind::InvalidPropertyAssignment {
601                property,
602                expected,
603                actual,
604            } => {
605                format!("Property ${property} expects '{expected}', cannot assign '{actual}'")
606            }
607            IssueKind::InvalidCast { from, to } => {
608                format!("Cannot cast '{from}' to '{to}'")
609            }
610            IssueKind::InvalidOperand { op, left, right } => {
611                format!("Operator '{op}' not supported between '{left}' and '{right}'")
612            }
613            IssueKind::MismatchingDocblockReturnType { declared, inferred } => {
614                format!("Docblock return type '{declared}' does not match inferred '{inferred}'")
615            }
616            IssueKind::MismatchingDocblockParamType {
617                param,
618                declared,
619                inferred,
620            } => {
621                format!(
622                    "Docblock type '{declared}' for ${param} does not match inferred '{inferred}'"
623                )
624            }
625
626            IssueKind::InvalidArrayOffset { expected, actual } => {
627                format!("Array offset expects '{expected}', got '{actual}'")
628            }
629            IssueKind::NonExistentArrayOffset { key } => {
630                format!("Array offset '{key}' does not exist")
631            }
632            IssueKind::PossiblyInvalidArrayOffset { expected, actual } => {
633                format!("Array offset might be invalid: expects '{expected}', got '{actual}'")
634            }
635
636            IssueKind::RedundantCondition { ty } => {
637                format!("Condition is always true/false for type '{ty}'")
638            }
639            IssueKind::RedundantCast { from, to } => {
640                format!("Casting '{from}' to '{to}' is redundant")
641            }
642            IssueKind::UnnecessaryVarAnnotation { var } => {
643                format!("@var annotation for ${var} is unnecessary")
644            }
645            IssueKind::TypeDoesNotContainType { left, right } => {
646                format!("Type '{left}' can never contain type '{right}'")
647            }
648
649            IssueKind::UnusedVariable { name } => format!("Variable ${name} is never read"),
650            IssueKind::UnusedParam { name } => format!("Parameter ${name} is never used"),
651            IssueKind::UnreachableCode => "Unreachable code detected".to_string(),
652            IssueKind::UnusedMethod { class, method } => {
653                format!("Private method {class}::{method}() is never called")
654            }
655            IssueKind::UnusedProperty { class, property } => {
656                format!("Private property {class}::${property} is never read")
657            }
658            IssueKind::UnusedFunction { name } => {
659                format!("Function {name}() is never called")
660            }
661
662            IssueKind::UnimplementedAbstractMethod { class, method } => {
663                format!("Class {class} must implement abstract method {method}()")
664            }
665            IssueKind::UnimplementedInterfaceMethod {
666                class,
667                interface,
668                method,
669            } => {
670                format!("Class {class} must implement {interface}::{method}() from interface")
671            }
672            IssueKind::MethodSignatureMismatch {
673                class,
674                method,
675                detail,
676            } => {
677                format!("Method {class}::{method}() signature mismatch: {detail}")
678            }
679            IssueKind::OverriddenMethodAccess { class, method } => {
680                format!("Method {class}::{method}() overrides with less visibility")
681            }
682            IssueKind::ReadonlyPropertyAssignment { class, property } => {
683                format!(
684                    "Cannot assign to readonly property {class}::${property} outside of constructor"
685                )
686            }
687            IssueKind::FinalClassExtended { parent, child } => {
688                format!("Class {child} cannot extend final class {parent}")
689            }
690            IssueKind::InvalidTemplateParam {
691                name,
692                expected_bound,
693                actual,
694            } => {
695                format!(
696                    "Template type '{name}' inferred as '{actual}' does not satisfy bound '{expected_bound}'"
697                )
698            }
699            IssueKind::ShadowedTemplateParam { name } => {
700                format!(
701                    "Method template parameter '{name}' shadows class-level template parameter with the same name"
702                )
703            }
704            IssueKind::FinalMethodOverridden {
705                class,
706                method,
707                parent,
708            } => {
709                format!("Method {class}::{method}() cannot override final method from {parent}")
710            }
711
712            IssueKind::TaintedInput { sink } => format!("Tainted input reaching sink '{sink}'"),
713            IssueKind::TaintedHtml => "Tainted HTML output — possible XSS".to_string(),
714            IssueKind::TaintedSql => "Tainted SQL query — possible SQL injection".to_string(),
715            IssueKind::TaintedShell => {
716                "Tainted shell command — possible command injection".to_string()
717            }
718
719            IssueKind::DeprecatedCall { name, message } => {
720                let base = format!("Call to deprecated function {name}");
721                append_deprecation_message(base, message)
722            }
723            IssueKind::DeprecatedMethodCall {
724                class,
725                method,
726                message,
727            } => {
728                let base = format!("Call to deprecated method {class}::{method}");
729                append_deprecation_message(base, message)
730            }
731            IssueKind::DeprecatedMethod {
732                class,
733                method,
734                message,
735            } => {
736                let base = format!("Method {class}::{method}() is deprecated");
737                append_deprecation_message(base, message)
738            }
739            IssueKind::DeprecatedClass { name, message } => {
740                let base = format!("Class {name} is deprecated");
741                append_deprecation_message(base, message)
742            }
743            IssueKind::InternalMethod { class, method } => {
744                format!("Method {class}::{method}() is marked @internal")
745            }
746            IssueKind::MissingReturnType { fn_name } => {
747                format!("Function {fn_name}() has no return type annotation")
748            }
749            IssueKind::MissingParamType { fn_name, param } => {
750                format!("Parameter ${param} of {fn_name}() has no type annotation")
751            }
752            IssueKind::InvalidThrow { ty } => {
753                format!("Thrown type '{ty}' does not extend Throwable")
754            }
755            IssueKind::MissingThrowsDocblock { class } => {
756                format!("Exception {class} is thrown but not declared in @throws")
757            }
758            IssueKind::ParseError { message } => format!("Parse error: {message}"),
759            IssueKind::InvalidDocblock { message } => format!("Invalid docblock: {message}"),
760            IssueKind::MixedArgument { param, fn_name } => {
761                format!("Argument ${param} of {fn_name}() is mixed")
762            }
763            IssueKind::MixedAssignment { var } => {
764                format!("Variable ${var} is assigned a mixed type")
765            }
766            IssueKind::MixedMethodCall { method } => {
767                format!("Method {method}() called on mixed type")
768            }
769            IssueKind::MixedPropertyFetch { property } => {
770                format!("Property ${property} fetched on mixed type")
771            }
772            IssueKind::CircularInheritance { class } => {
773                format!("Class {class} has a circular inheritance chain")
774            }
775            IssueKind::InvalidTraitUse { trait_name, reason } => {
776                format!("Trait {trait_name} used incorrectly: {reason}")
777            }
778        }
779    }
780}
781
782// ---------------------------------------------------------------------------
783// Issue
784// ---------------------------------------------------------------------------
785
786#[derive(Debug, Clone, Serialize, Deserialize)]
787pub struct Issue {
788    pub kind: IssueKind,
789    pub severity: Severity,
790    pub location: Location,
791    pub snippet: Option<String>,
792    pub suppressed: bool,
793}
794
795impl Issue {
796    pub fn new(kind: IssueKind, location: Location) -> Self {
797        let severity = kind.default_severity();
798        Self {
799            severity,
800            kind,
801            location,
802            snippet: None,
803            suppressed: false,
804        }
805    }
806
807    pub fn with_snippet(mut self, snippet: impl Into<String>) -> Self {
808        self.snippet = Some(snippet.into());
809        self
810    }
811
812    pub fn suppress(mut self) -> Self {
813        self.suppressed = true;
814        self
815    }
816}
817
818impl fmt::Display for Issue {
819    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
820        let sev = match self.severity {
821            Severity::Error => "error".red().to_string(),
822            Severity::Warning => "warning".yellow().to_string(),
823            Severity::Info => "info".blue().to_string(),
824        };
825        write!(
826            f,
827            "{} {} {}: {}",
828            self.location.bright_black(),
829            sev,
830            self.kind.name().bold(),
831            self.kind.message()
832        )
833    }
834}
835
836// ---------------------------------------------------------------------------
837// IssueBuffer — collects issues for a single file pass
838// ---------------------------------------------------------------------------
839
840#[derive(Debug, Default)]
841pub struct IssueBuffer {
842    issues: Vec<Issue>,
843    seen: HashSet<(&'static str, Arc<str>, u32, u16)>,
844    /// Issue names suppressed at the file level (from `@psalm-suppress` / `@suppress` on the file docblock)
845    file_suppressions: Vec<String>,
846}
847
848impl IssueBuffer {
849    pub fn new() -> Self {
850        Self::default()
851    }
852
853    pub fn add(&mut self, issue: Issue) {
854        let key = (
855            issue.kind.name(),
856            issue.location.file.clone(),
857            issue.location.line,
858            issue.location.col_start,
859        );
860        if self.seen.insert(key) {
861            self.issues.push(issue);
862        }
863    }
864
865    pub fn add_suppression(&mut self, name: impl Into<String>) {
866        self.file_suppressions.push(name.into());
867    }
868
869    /// Consume the buffer and return unsuppressed issues.
870    pub fn into_issues(self) -> Vec<Issue> {
871        self.issues
872            .into_iter()
873            .filter(|i| !i.suppressed)
874            .filter(|i| !self.file_suppressions.contains(&i.kind.name().to_string()))
875            .collect()
876    }
877
878    /// Mark all issues added since index `from` as suppressed if their issue
879    /// name appears in `suppressions`. Used for `@psalm-suppress` / `@suppress` on statements.
880    pub fn suppress_range(&mut self, from: usize, suppressions: &[String]) {
881        if suppressions.is_empty() {
882            return;
883        }
884        for issue in self.issues[from..].iter_mut() {
885            if suppressions.iter().any(|s| s == issue.kind.name()) {
886                issue.suppressed = true;
887            }
888        }
889    }
890
891    /// Current number of buffered issues. Use before analyzing a statement to
892    /// get the `from` index for `suppress_range`.
893    pub fn issue_count(&self) -> usize {
894        self.issues.len()
895    }
896
897    pub fn is_empty(&self) -> bool {
898        self.issues.is_empty()
899    }
900
901    pub fn len(&self) -> usize {
902        self.issues.len()
903    }
904
905    pub fn error_count(&self) -> usize {
906        self.issues
907            .iter()
908            .filter(|i| !i.suppressed && i.severity == Severity::Error)
909            .count()
910    }
911
912    pub fn warning_count(&self) -> usize {
913        self.issues
914            .iter()
915            .filter(|i| !i.suppressed && i.severity == Severity::Warning)
916            .count()
917    }
918}