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