Skip to main content

mir_issues/
lib.rs

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