Skip to main content

mir_issues/
lib.rs

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