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