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