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