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