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