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    AbstractInstantiation {
245        class: String,
246    },
247
248    // --- Security (taint) ---------------------------------------------------
249    TaintedInput {
250        sink: String,
251    },
252    TaintedHtml,
253    TaintedSql,
254    TaintedShell,
255
256    // --- Generics -----------------------------------------------------------
257    InvalidTemplateParam {
258        name: String,
259        expected_bound: String,
260        actual: String,
261    },
262    ShadowedTemplateParam {
263        name: String,
264    },
265
266    // --- Other --------------------------------------------------------------
267    DeprecatedCall {
268        name: String,
269        message: Option<Arc<str>>,
270    },
271    DeprecatedMethodCall {
272        class: String,
273        method: String,
274        message: Option<Arc<str>>,
275    },
276    DeprecatedMethod {
277        class: String,
278        method: String,
279        message: Option<Arc<str>>,
280    },
281    DeprecatedClass {
282        name: String,
283        message: Option<Arc<str>>,
284    },
285    InternalMethod {
286        class: String,
287        method: String,
288    },
289    MissingReturnType {
290        fn_name: String,
291    },
292    MissingParamType {
293        fn_name: String,
294        param: String,
295    },
296    InvalidThrow {
297        ty: String,
298    },
299    MissingThrowsDocblock {
300        class: String,
301    },
302    ImplicitToStringCast {
303        class: String,
304    },
305    ImplicitFloatToIntCast {
306        from: String,
307    },
308    ParseError {
309        message: String,
310    },
311    InvalidDocblock {
312        message: String,
313    },
314    MixedArgument {
315        param: String,
316        fn_name: String,
317    },
318    MixedAssignment {
319        var: String,
320    },
321    MixedMethodCall {
322        method: String,
323    },
324    MixedPropertyFetch {
325        property: String,
326    },
327    CircularInheritance {
328        class: String,
329    },
330
331    // --- Trait constraints --------------------------------------------------
332    InvalidTraitUse {
333        trait_name: String,
334        reason: String,
335    },
336}
337
338fn append_deprecation_message(base: String, message: &Option<Arc<str>>) -> String {
339    match message.as_deref().filter(|m| !m.is_empty()) {
340        Some(msg) => format!("{base}: {msg}"),
341        None => base,
342    }
343}
344
345impl IssueKind {
346    /// Default severity for this issue kind.
347    pub fn default_severity(&self) -> Severity {
348        match self {
349            // Errors (always blocking)
350            IssueKind::InvalidScope { .. }
351            | IssueKind::UndefinedVariable { .. }
352            | IssueKind::UndefinedFunction { .. }
353            | IssueKind::UndefinedMethod { .. }
354            | IssueKind::UndefinedClass { .. }
355            | IssueKind::UndefinedConstant { .. }
356            | IssueKind::InvalidReturnType { .. }
357            | IssueKind::InvalidArgument { .. }
358            | IssueKind::TooFewArguments { .. }
359            | IssueKind::TooManyArguments { .. }
360            | IssueKind::InvalidNamedArgument { .. }
361            | IssueKind::InvalidPassByReference { .. }
362            | IssueKind::InvalidThrow { .. }
363            | IssueKind::UnimplementedAbstractMethod { .. }
364            | IssueKind::UnimplementedInterfaceMethod { .. }
365            | IssueKind::MethodSignatureMismatch { .. }
366            | IssueKind::FinalClassExtended { .. }
367            | IssueKind::FinalMethodOverridden { .. }
368            | IssueKind::AbstractInstantiation { .. }
369            | IssueKind::InvalidTemplateParam { .. }
370            | IssueKind::ReadonlyPropertyAssignment { .. }
371            | IssueKind::ParseError { .. }
372            | IssueKind::TaintedInput { .. }
373            | IssueKind::TaintedHtml
374            | IssueKind::TaintedSql
375            | IssueKind::TaintedShell
376            | IssueKind::CircularInheritance { .. }
377            | IssueKind::InvalidTraitUse { .. } => Severity::Error,
378
379            // Warnings (shown at default error level)
380            IssueKind::NullArgument { .. }
381            | IssueKind::NullPropertyFetch { .. }
382            | IssueKind::NullMethodCall { .. }
383            | IssueKind::NullArrayAccess
384            | IssueKind::NullableReturnStatement { .. }
385            | IssueKind::InvalidPropertyAssignment { .. }
386            | IssueKind::InvalidArrayOffset { .. }
387            | IssueKind::NonExistentArrayOffset { .. }
388            | IssueKind::PossiblyInvalidArrayOffset { .. }
389            | IssueKind::UndefinedProperty { .. }
390            | IssueKind::InvalidOperand { .. }
391            | IssueKind::OverriddenMethodAccess { .. }
392            | IssueKind::MissingThrowsDocblock { .. }
393            | IssueKind::ImplicitToStringCast { .. }
394            | IssueKind::ImplicitFloatToIntCast { .. }
395            | IssueKind::UnusedVariable { .. } => Severity::Warning,
396
397            // PossiblyUndefined: shown at default error level (same as Warning)
398            IssueKind::PossiblyUndefinedVariable { .. } => Severity::Warning,
399
400            // Possibly-null / possibly-invalid (only shown in strict mode, level ≥ 7)
401            IssueKind::PossiblyNullArgument { .. }
402            | IssueKind::PossiblyInvalidArgument { .. }
403            | IssueKind::PossiblyNullPropertyFetch { .. }
404            | IssueKind::PossiblyNullMethodCall { .. }
405            | IssueKind::PossiblyNullArrayAccess => Severity::Info,
406
407            // Info
408            IssueKind::RedundantCondition { .. }
409            | IssueKind::RedundantCast { .. }
410            | IssueKind::UnnecessaryVarAnnotation { .. }
411            | IssueKind::TypeDoesNotContainType { .. }
412            | IssueKind::UnusedParam { .. }
413            | IssueKind::UnreachableCode
414            | IssueKind::UnusedMethod { .. }
415            | IssueKind::UnusedProperty { .. }
416            | IssueKind::UnusedFunction { .. }
417            | IssueKind::DeprecatedCall { .. }
418            | IssueKind::DeprecatedMethodCall { .. }
419            | IssueKind::DeprecatedMethod { .. }
420            | IssueKind::DeprecatedClass { .. }
421            | IssueKind::InternalMethod { .. }
422            | IssueKind::MissingReturnType { .. }
423            | IssueKind::MissingParamType { .. }
424            | IssueKind::MismatchingDocblockReturnType { .. }
425            | IssueKind::MismatchingDocblockParamType { .. }
426            | IssueKind::InvalidDocblock { .. }
427            | IssueKind::InvalidCast { .. }
428            | IssueKind::MixedArgument { .. }
429            | IssueKind::MixedAssignment { .. }
430            | IssueKind::MixedMethodCall { .. }
431            | IssueKind::MixedPropertyFetch { .. }
432            | IssueKind::ShadowedTemplateParam { .. } => Severity::Info,
433        }
434    }
435
436    /// Identifier name used in config and `@psalm-suppress` / `@suppress` annotations.
437    pub fn name(&self) -> &'static str {
438        match self {
439            IssueKind::InvalidScope { .. } => "InvalidScope",
440            IssueKind::UndefinedVariable { .. } => "UndefinedVariable",
441            IssueKind::UndefinedFunction { .. } => "UndefinedFunction",
442            IssueKind::UndefinedMethod { .. } => "UndefinedMethod",
443            IssueKind::UndefinedClass { .. } => "UndefinedClass",
444            IssueKind::UndefinedProperty { .. } => "UndefinedProperty",
445            IssueKind::UndefinedConstant { .. } => "UndefinedConstant",
446            IssueKind::PossiblyUndefinedVariable { .. } => "PossiblyUndefinedVariable",
447            IssueKind::NullArgument { .. } => "NullArgument",
448            IssueKind::NullPropertyFetch { .. } => "NullPropertyFetch",
449            IssueKind::NullMethodCall { .. } => "NullMethodCall",
450            IssueKind::NullArrayAccess => "NullArrayAccess",
451            IssueKind::PossiblyNullArgument { .. } => "PossiblyNullArgument",
452            IssueKind::PossiblyInvalidArgument { .. } => "PossiblyInvalidArgument",
453            IssueKind::PossiblyNullPropertyFetch { .. } => "PossiblyNullPropertyFetch",
454            IssueKind::PossiblyNullMethodCall { .. } => "PossiblyNullMethodCall",
455            IssueKind::PossiblyNullArrayAccess => "PossiblyNullArrayAccess",
456            IssueKind::NullableReturnStatement { .. } => "NullableReturnStatement",
457            IssueKind::InvalidReturnType { .. } => "InvalidReturnType",
458            IssueKind::InvalidArgument { .. } => "InvalidArgument",
459            IssueKind::TooFewArguments { .. } => "TooFewArguments",
460            IssueKind::TooManyArguments { .. } => "TooManyArguments",
461            IssueKind::InvalidNamedArgument { .. } => "InvalidNamedArgument",
462            IssueKind::InvalidPassByReference { .. } => "InvalidPassByReference",
463            IssueKind::InvalidPropertyAssignment { .. } => "InvalidPropertyAssignment",
464            IssueKind::InvalidCast { .. } => "InvalidCast",
465            IssueKind::InvalidOperand { .. } => "InvalidOperand",
466            IssueKind::MismatchingDocblockReturnType { .. } => "MismatchingDocblockReturnType",
467            IssueKind::MismatchingDocblockParamType { .. } => "MismatchingDocblockParamType",
468            IssueKind::InvalidArrayOffset { .. } => "InvalidArrayOffset",
469            IssueKind::NonExistentArrayOffset { .. } => "NonExistentArrayOffset",
470            IssueKind::PossiblyInvalidArrayOffset { .. } => "PossiblyInvalidArrayOffset",
471            IssueKind::RedundantCondition { .. } => "RedundantCondition",
472            IssueKind::RedundantCast { .. } => "RedundantCast",
473            IssueKind::UnnecessaryVarAnnotation { .. } => "UnnecessaryVarAnnotation",
474            IssueKind::TypeDoesNotContainType { .. } => "TypeDoesNotContainType",
475            IssueKind::UnusedVariable { .. } => "UnusedVariable",
476            IssueKind::UnusedParam { .. } => "UnusedParam",
477            IssueKind::UnreachableCode => "UnreachableCode",
478            IssueKind::UnusedMethod { .. } => "UnusedMethod",
479            IssueKind::UnusedProperty { .. } => "UnusedProperty",
480            IssueKind::UnusedFunction { .. } => "UnusedFunction",
481            IssueKind::UnimplementedAbstractMethod { .. } => "UnimplementedAbstractMethod",
482            IssueKind::UnimplementedInterfaceMethod { .. } => "UnimplementedInterfaceMethod",
483            IssueKind::MethodSignatureMismatch { .. } => "MethodSignatureMismatch",
484            IssueKind::OverriddenMethodAccess { .. } => "OverriddenMethodAccess",
485            IssueKind::FinalClassExtended { .. } => "FinalClassExtended",
486            IssueKind::FinalMethodOverridden { .. } => "FinalMethodOverridden",
487            IssueKind::AbstractInstantiation { .. } => "AbstractInstantiation",
488            IssueKind::ReadonlyPropertyAssignment { .. } => "ReadonlyPropertyAssignment",
489            IssueKind::InvalidTemplateParam { .. } => "InvalidTemplateParam",
490            IssueKind::ShadowedTemplateParam { .. } => "ShadowedTemplateParam",
491            IssueKind::TaintedInput { .. } => "TaintedInput",
492            IssueKind::TaintedHtml => "TaintedHtml",
493            IssueKind::TaintedSql => "TaintedSql",
494            IssueKind::TaintedShell => "TaintedShell",
495            IssueKind::DeprecatedCall { .. } => "DeprecatedCall",
496            IssueKind::DeprecatedMethodCall { .. } => "DeprecatedMethodCall",
497            IssueKind::DeprecatedMethod { .. } => "DeprecatedMethod",
498            IssueKind::DeprecatedClass { .. } => "DeprecatedClass",
499            IssueKind::InternalMethod { .. } => "InternalMethod",
500            IssueKind::MissingReturnType { .. } => "MissingReturnType",
501            IssueKind::MissingParamType { .. } => "MissingParamType",
502            IssueKind::InvalidThrow { .. } => "InvalidThrow",
503            IssueKind::MissingThrowsDocblock { .. } => "MissingThrowsDocblock",
504            IssueKind::ImplicitToStringCast { .. } => "ImplicitToStringCast",
505            IssueKind::ImplicitFloatToIntCast { .. } => "ImplicitFloatToIntCast",
506            IssueKind::ParseError { .. } => "ParseError",
507            IssueKind::InvalidDocblock { .. } => "InvalidDocblock",
508            IssueKind::MixedArgument { .. } => "MixedArgument",
509            IssueKind::MixedAssignment { .. } => "MixedAssignment",
510            IssueKind::MixedMethodCall { .. } => "MixedMethodCall",
511            IssueKind::MixedPropertyFetch { .. } => "MixedPropertyFetch",
512            IssueKind::CircularInheritance { .. } => "CircularInheritance",
513            IssueKind::InvalidTraitUse { .. } => "InvalidTraitUse",
514        }
515    }
516
517    /// Human-readable message for this issue.
518    pub fn message(&self) -> String {
519        match self {
520            IssueKind::InvalidScope { in_class } => {
521                if *in_class {
522                    "$this cannot be used in a static method".to_string()
523                } else {
524                    "$this cannot be used outside of a class".to_string()
525                }
526            }
527            IssueKind::UndefinedVariable { name } => format!("Variable ${name} is not defined"),
528            IssueKind::UndefinedFunction { name } => format!("Function {name}() is not defined"),
529            IssueKind::UndefinedMethod { class, method } => {
530                format!("Method {class}::{method}() does not exist")
531            }
532            IssueKind::UndefinedClass { name } => format!("Class {name} does not exist"),
533            IssueKind::UndefinedProperty { class, property } => {
534                format!("Property {class}::${property} does not exist")
535            }
536            IssueKind::UndefinedConstant { name } => format!("Constant {name} is not defined"),
537            IssueKind::PossiblyUndefinedVariable { name } => {
538                format!("Variable ${name} might not be defined")
539            }
540
541            IssueKind::NullArgument { param, fn_name } => {
542                format!("Argument ${param} of {fn_name}() cannot be null")
543            }
544            IssueKind::NullPropertyFetch { property } => {
545                format!("Cannot access property ${property} on null")
546            }
547            IssueKind::NullMethodCall { method } => {
548                format!("Cannot call method {method}() on null")
549            }
550            IssueKind::NullArrayAccess => "Cannot access array on null".to_string(),
551            IssueKind::PossiblyNullArgument { param, fn_name } => {
552                format!("Argument ${param} of {fn_name}() might be null")
553            }
554            IssueKind::PossiblyInvalidArgument {
555                param,
556                fn_name,
557                expected,
558                actual,
559            } => {
560                format!("Argument ${param} of {fn_name}() expects '{expected}', possibly different type '{actual}' provided")
561            }
562            IssueKind::PossiblyNullPropertyFetch { property } => {
563                format!("Cannot access property ${property} on possibly null value")
564            }
565            IssueKind::PossiblyNullMethodCall { method } => {
566                format!("Cannot call method {method}() on possibly null value")
567            }
568            IssueKind::PossiblyNullArrayAccess => {
569                "Cannot access array on possibly null value".to_string()
570            }
571            IssueKind::NullableReturnStatement { expected, actual } => {
572                format!("Return type '{actual}' is not compatible with declared '{expected}'")
573            }
574
575            IssueKind::InvalidReturnType { expected, actual } => {
576                format!("Return type '{actual}' is not compatible with declared '{expected}'")
577            }
578            IssueKind::InvalidArgument {
579                param,
580                fn_name,
581                expected,
582                actual,
583            } => {
584                format!("Argument ${param} of {fn_name}() expects '{expected}', got '{actual}'")
585            }
586            IssueKind::TooFewArguments {
587                fn_name,
588                expected,
589                actual,
590            } => {
591                format!(
592                    "Too few arguments for {}(): expected {}, got {}",
593                    fn_name, expected, actual
594                )
595            }
596            IssueKind::TooManyArguments {
597                fn_name,
598                expected,
599                actual,
600            } => {
601                format!(
602                    "Too many arguments for {}(): expected {}, got {}",
603                    fn_name, expected, actual
604                )
605            }
606            IssueKind::InvalidNamedArgument { fn_name, name } => {
607                format!("{}() has no parameter named ${}", fn_name, name)
608            }
609            IssueKind::InvalidPassByReference { fn_name, param } => {
610                format!(
611                    "Argument ${} of {}() must be passed by reference",
612                    param, fn_name
613                )
614            }
615            IssueKind::InvalidPropertyAssignment {
616                property,
617                expected,
618                actual,
619            } => {
620                format!("Property ${property} expects '{expected}', cannot assign '{actual}'")
621            }
622            IssueKind::InvalidCast { from, to } => {
623                format!("Cannot cast '{from}' to '{to}'")
624            }
625            IssueKind::InvalidOperand { op, left, right } => {
626                format!("Operator '{op}' not supported between '{left}' and '{right}'")
627            }
628            IssueKind::MismatchingDocblockReturnType { declared, inferred } => {
629                format!("Docblock return type '{declared}' does not match inferred '{inferred}'")
630            }
631            IssueKind::MismatchingDocblockParamType {
632                param,
633                declared,
634                inferred,
635            } => {
636                format!(
637                    "Docblock type '{declared}' for ${param} does not match inferred '{inferred}'"
638                )
639            }
640
641            IssueKind::InvalidArrayOffset { expected, actual } => {
642                format!("Array offset expects '{expected}', got '{actual}'")
643            }
644            IssueKind::NonExistentArrayOffset { key } => {
645                format!("Array offset '{key}' does not exist")
646            }
647            IssueKind::PossiblyInvalidArrayOffset { expected, actual } => {
648                format!("Array offset might be invalid: expects '{expected}', got '{actual}'")
649            }
650
651            IssueKind::RedundantCondition { ty } => {
652                format!("Condition is always true/false for type '{ty}'")
653            }
654            IssueKind::RedundantCast { from, to } => {
655                format!("Casting '{from}' to '{to}' is redundant")
656            }
657            IssueKind::UnnecessaryVarAnnotation { var } => {
658                format!("@var annotation for ${var} is unnecessary")
659            }
660            IssueKind::TypeDoesNotContainType { left, right } => {
661                format!("Type '{left}' can never contain type '{right}'")
662            }
663
664            IssueKind::UnusedVariable { name } => format!("Variable ${name} is never read"),
665            IssueKind::UnusedParam { name } => format!("Parameter ${name} is never used"),
666            IssueKind::UnreachableCode => "Unreachable code detected".to_string(),
667            IssueKind::UnusedMethod { class, method } => {
668                format!("Private method {class}::{method}() is never called")
669            }
670            IssueKind::UnusedProperty { class, property } => {
671                format!("Private property {class}::${property} is never read")
672            }
673            IssueKind::UnusedFunction { name } => {
674                format!("Function {name}() is never called")
675            }
676
677            IssueKind::UnimplementedAbstractMethod { class, method } => {
678                format!("Class {class} must implement abstract method {method}()")
679            }
680            IssueKind::UnimplementedInterfaceMethod {
681                class,
682                interface,
683                method,
684            } => {
685                format!("Class {class} must implement {interface}::{method}() from interface")
686            }
687            IssueKind::MethodSignatureMismatch {
688                class,
689                method,
690                detail,
691            } => {
692                format!("Method {class}::{method}() signature mismatch: {detail}")
693            }
694            IssueKind::OverriddenMethodAccess { class, method } => {
695                format!("Method {class}::{method}() overrides with less visibility")
696            }
697            IssueKind::ReadonlyPropertyAssignment { class, property } => {
698                format!(
699                    "Cannot assign to readonly property {class}::${property} outside of constructor"
700                )
701            }
702            IssueKind::FinalClassExtended { parent, child } => {
703                format!("Class {child} cannot extend final class {parent}")
704            }
705            IssueKind::InvalidTemplateParam {
706                name,
707                expected_bound,
708                actual,
709            } => {
710                format!(
711                    "Template type '{name}' inferred as '{actual}' does not satisfy bound '{expected_bound}'"
712                )
713            }
714            IssueKind::ShadowedTemplateParam { name } => {
715                format!(
716                    "Method template parameter '{name}' shadows class-level template parameter with the same name"
717                )
718            }
719            IssueKind::FinalMethodOverridden {
720                class,
721                method,
722                parent,
723            } => {
724                format!("Method {class}::{method}() cannot override final method from {parent}")
725            }
726            IssueKind::AbstractInstantiation { class } => {
727                format!("Cannot instantiate abstract class {class}")
728            }
729
730            IssueKind::TaintedInput { sink } => format!("Tainted input reaching sink '{sink}'"),
731            IssueKind::TaintedHtml => "Tainted HTML output — possible XSS".to_string(),
732            IssueKind::TaintedSql => "Tainted SQL query — possible SQL injection".to_string(),
733            IssueKind::TaintedShell => {
734                "Tainted shell command — possible command injection".to_string()
735            }
736
737            IssueKind::DeprecatedCall { name, message } => {
738                let base = format!("Call to deprecated function {name}");
739                append_deprecation_message(base, message)
740            }
741            IssueKind::DeprecatedMethodCall {
742                class,
743                method,
744                message,
745            } => {
746                let base = format!("Call to deprecated method {class}::{method}");
747                append_deprecation_message(base, message)
748            }
749            IssueKind::DeprecatedMethod {
750                class,
751                method,
752                message,
753            } => {
754                let base = format!("Method {class}::{method}() is deprecated");
755                append_deprecation_message(base, message)
756            }
757            IssueKind::DeprecatedClass { name, message } => {
758                let base = format!("Class {name} is deprecated");
759                append_deprecation_message(base, message)
760            }
761            IssueKind::InternalMethod { class, method } => {
762                format!("Method {class}::{method}() is marked @internal")
763            }
764            IssueKind::MissingReturnType { fn_name } => {
765                format!("Function {fn_name}() has no return type annotation")
766            }
767            IssueKind::MissingParamType { fn_name, param } => {
768                format!("Parameter ${param} of {fn_name}() has no type annotation")
769            }
770            IssueKind::InvalidThrow { ty } => {
771                format!("Thrown type '{ty}' does not extend Throwable")
772            }
773            IssueKind::MissingThrowsDocblock { class } => {
774                format!("Exception {class} is thrown but not declared in @throws")
775            }
776            IssueKind::ImplicitToStringCast { class } => {
777                format!("Class {class} does not implement __toString()")
778            }
779            IssueKind::ImplicitFloatToIntCast { from } => {
780                format!("Implicit cast from {from} to int truncates the fractional part")
781            }
782            IssueKind::ParseError { message } => format!("Parse error: {message}"),
783            IssueKind::InvalidDocblock { message } => format!("Invalid docblock: {message}"),
784            IssueKind::MixedArgument { param, fn_name } => {
785                format!("Argument ${param} of {fn_name}() is mixed")
786            }
787            IssueKind::MixedAssignment { var } => {
788                format!("Variable ${var} is assigned a mixed type")
789            }
790            IssueKind::MixedMethodCall { method } => {
791                format!("Method {method}() called on mixed type")
792            }
793            IssueKind::MixedPropertyFetch { property } => {
794                format!("Property ${property} fetched on mixed type")
795            }
796            IssueKind::CircularInheritance { class } => {
797                format!("Class {class} has a circular inheritance chain")
798            }
799            IssueKind::InvalidTraitUse { trait_name, reason } => {
800                format!("Trait {trait_name} used incorrectly: {reason}")
801            }
802        }
803    }
804}
805
806// ---------------------------------------------------------------------------
807// Issue
808// ---------------------------------------------------------------------------
809
810#[derive(Debug, Clone, Serialize, Deserialize)]
811pub struct Issue {
812    pub kind: IssueKind,
813    pub severity: Severity,
814    pub location: Location,
815    pub snippet: Option<String>,
816    pub suppressed: bool,
817}
818
819impl Issue {
820    pub fn new(kind: IssueKind, location: Location) -> Self {
821        let severity = kind.default_severity();
822        Self {
823            severity,
824            kind,
825            location,
826            snippet: None,
827            suppressed: false,
828        }
829    }
830
831    pub fn with_snippet(mut self, snippet: impl Into<String>) -> Self {
832        self.snippet = Some(snippet.into());
833        self
834    }
835
836    pub fn suppress(mut self) -> Self {
837        self.suppressed = true;
838        self
839    }
840}
841
842impl fmt::Display for Issue {
843    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
844        let sev = match self.severity {
845            Severity::Error => "error".red().to_string(),
846            Severity::Warning => "warning".yellow().to_string(),
847            Severity::Info => "info".blue().to_string(),
848        };
849        write!(
850            f,
851            "{} {} {}: {}",
852            self.location.bright_black(),
853            sev,
854            self.kind.name().bold(),
855            self.kind.message()
856        )
857    }
858}
859
860// ---------------------------------------------------------------------------
861// IssueBuffer — collects issues for a single file pass
862// ---------------------------------------------------------------------------
863
864#[derive(Debug, Default)]
865pub struct IssueBuffer {
866    issues: Vec<Issue>,
867    seen: HashSet<(&'static str, Arc<str>, u32, u16)>,
868    /// Issue names suppressed at the file level (from `@psalm-suppress` / `@suppress` on the file docblock)
869    file_suppressions: Vec<String>,
870}
871
872impl IssueBuffer {
873    pub fn new() -> Self {
874        Self::default()
875    }
876
877    pub fn add(&mut self, issue: Issue) {
878        let key = (
879            issue.kind.name(),
880            issue.location.file.clone(),
881            issue.location.line,
882            issue.location.col_start,
883        );
884        if self.seen.insert(key) {
885            self.issues.push(issue);
886        }
887    }
888
889    pub fn add_suppression(&mut self, name: impl Into<String>) {
890        self.file_suppressions.push(name.into());
891    }
892
893    /// Consume the buffer and return unsuppressed issues.
894    pub fn into_issues(self) -> Vec<Issue> {
895        self.issues
896            .into_iter()
897            .filter(|i| !i.suppressed)
898            .filter(|i| !self.file_suppressions.contains(&i.kind.name().to_string()))
899            .collect()
900    }
901
902    /// Mark all issues added since index `from` as suppressed if their issue
903    /// name appears in `suppressions`. Used for `@psalm-suppress` / `@suppress` on statements.
904    pub fn suppress_range(&mut self, from: usize, suppressions: &[String]) {
905        if suppressions.is_empty() {
906            return;
907        }
908        for issue in self.issues[from..].iter_mut() {
909            if suppressions.iter().any(|s| s == issue.kind.name()) {
910                issue.suppressed = true;
911            }
912        }
913    }
914
915    /// Current number of buffered issues. Use before analyzing a statement to
916    /// get the `from` index for `suppress_range`.
917    pub fn issue_count(&self) -> usize {
918        self.issues.len()
919    }
920
921    pub fn is_empty(&self) -> bool {
922        self.issues.is_empty()
923    }
924
925    pub fn len(&self) -> usize {
926        self.issues.len()
927    }
928
929    pub fn error_count(&self) -> usize {
930        self.issues
931            .iter()
932            .filter(|i| !i.suppressed && i.severity == Severity::Error)
933            .count()
934    }
935
936    pub fn warning_count(&self) -> usize {
937        self.issues
938            .iter()
939            .filter(|i| !i.suppressed && i.severity == Severity::Warning)
940            .count()
941    }
942}