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
36pub use mir_types::Location;
37
38// ---------------------------------------------------------------------------
39// IssueKind
40// ---------------------------------------------------------------------------
41
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43#[non_exhaustive]
44pub enum IssueKind {
45    // --- Undefined ----------------------------------------------------------
46    /// Emitted by `mir-analyzer/src/expr/variables.rs`.
47    /// Fixtures: `tests/fixtures/by-kind/invalid_scope/`.
48    InvalidScope {
49        /// `true` when inside a class but in a static method; `false` when outside a class.
50        in_class: bool,
51    },
52    /// Emitted by `mir-analyzer/src/expr/variables.rs`.
53    /// Fixtures: `tests/fixtures/by-kind/undefined_variable/`.
54    UndefinedVariable { name: String },
55    /// Emitted by `mir-analyzer/src/call/function.rs`.
56    /// Fixtures: `tests/fixtures/by-kind/undefined_function/`.
57    UndefinedFunction { name: String },
58    /// Emitted by `mir-analyzer/src/call/static_call.rs`.
59    /// Fixtures: `tests/fixtures/by-kind/undefined_method/`.
60    UndefinedMethod { class: String, method: String },
61    /// Emitted by `mir-analyzer/src/batch.rs`.
62    /// Fixtures: `tests/fixtures/by-kind/undefined_class/`.
63    UndefinedClass { name: String },
64    /// Emitted by `mir-analyzer/src/expr/objects.rs`.
65    /// Fixtures: `tests/fixtures/by-kind/undefined_property/`.
66    UndefinedProperty { class: String, property: String },
67    /// Emitted by `mir-analyzer/src/expr/variables.rs`.
68    /// Fixtures: `tests/fixtures/by-kind/undefined_constant/`.
69    UndefinedConstant { name: String },
70    /// Emitted by `mir-analyzer/src/expr/variables.rs`.
71    /// Fixtures: `tests/fixtures/by-kind/possibly_undefined_variable/`.
72    PossiblyUndefinedVariable { name: String },
73    /// Emitted by `mir-analyzer/src/body_analysis.rs`.
74    /// Fixtures: `tests/fixtures/by-kind/undefined_trait/`.
75    UndefinedTrait { name: String },
76    /// Emitted by `mir-analyzer/src/expr/objects.rs`.
77    /// Fixtures: `tests/fixtures/by-kind/invalid_string_class/`.
78    InvalidStringClass { actual: String },
79
80    // --- Nullability --------------------------------------------------------
81    /// Emitted by `mir-analyzer/src/call/args.rs`.
82    /// Fixtures: `tests/fixtures/by-kind/null_argument/`.
83    NullArgument { param: String, fn_name: String },
84    /// Emitted by `mir-analyzer/src/expr/objects.rs`.
85    /// Fixtures: `tests/fixtures/by-kind/null_property_fetch/`.
86    NullPropertyFetch { property: String },
87    /// Emitted by `mir-analyzer/src/call/method.rs`.
88    /// Fixtures: `tests/fixtures/by-kind/null_method_call/`.
89    NullMethodCall { method: String },
90    /// Emitted by `mir-analyzer/src/expr/arrays.rs`.
91    /// Fixtures: `tests/fixtures/by-kind/null_array_access/`.
92    NullArrayAccess,
93    /// Emitted by `mir-analyzer/src/call/args.rs`.
94    /// Fixtures: `tests/fixtures/by-kind/possibly_null_argument/`.
95    PossiblyNullArgument { param: String, fn_name: String },
96    /// Emitted by `mir-analyzer/src/call/args.rs`.
97    /// Fixtures: `tests/fixtures/by-kind/possibly_invalid_argument/`.
98    PossiblyInvalidArgument {
99        param: String,
100        fn_name: String,
101        expected: String,
102        actual: String,
103    },
104    /// Emitted by `mir-analyzer/src/expr/objects.rs`.
105    /// Fixtures: `tests/fixtures/by-kind/possibly_null_property_fetch/`.
106    PossiblyNullPropertyFetch { property: String },
107    /// Emitted by `mir-analyzer/src/call/method.rs`.
108    /// Fixtures: `tests/fixtures/by-kind/possibly_null_method_call/`.
109    PossiblyNullMethodCall { method: String },
110    /// Emitted by `mir-analyzer/src/expr/arrays.rs`.
111    /// Fixtures: `tests/fixtures/by-kind/possibly_null_array_access/`.
112    PossiblyNullArrayAccess,
113    /// Not yet emitted. Fixtures: `tests/fixtures/by-kind/nullable_return_statement/` (planned).
114    NullableReturnStatement { expected: String, actual: String },
115
116    // --- Type mismatches ----------------------------------------------------
117    /// Emitted by `mir-analyzer/src/stmt/flow.rs`.
118    /// Fixtures: `tests/fixtures/by-kind/invalid_return_type/`.
119    InvalidReturnType { expected: String, actual: String },
120    /// Emitted by `mir-analyzer/src/call/args.rs`.
121    /// Fixtures: `tests/fixtures/by-kind/invalid_argument/`.
122    InvalidArgument {
123        param: String,
124        fn_name: String,
125        expected: String,
126        actual: String,
127    },
128    /// Emitted by `mir-analyzer/src/call/callable.rs`.
129    /// Fixtures: `tests/fixtures/by-kind/too_few_arguments/`.
130    TooFewArguments {
131        fn_name: String,
132        expected: usize,
133        actual: usize,
134    },
135    /// Emitted by `mir-analyzer/src/call/function.rs`.
136    /// Fixtures: `tests/fixtures/by-kind/too_many_arguments/`.
137    TooManyArguments {
138        fn_name: String,
139        expected: usize,
140        actual: usize,
141    },
142    /// Emitted by `mir-analyzer/src/call/args.rs`.
143    /// Fixtures: `tests/fixtures/by-kind/invalid_named_argument/`.
144    InvalidNamedArgument { fn_name: String, name: String },
145    /// Emitted by `mir-analyzer/src/call/args.rs`.
146    /// Fixtures: `tests/fixtures/by-kind/invalid_pass_by_reference/`.
147    InvalidPassByReference { fn_name: String, param: String },
148    /// Emitted by `mir-analyzer/src/expr/assignment.rs`.
149    /// Fixtures: `tests/fixtures/by-kind/invalid_property_assignment/`.
150    InvalidPropertyAssignment {
151        property: String,
152        expected: String,
153        actual: String,
154    },
155    /// Emitted by `mir-analyzer/src/expr/casts.rs`.
156    /// Fixtures: `tests/fixtures/by-kind/invalid_cast/`.
157    InvalidCast { from: String, to: String },
158    /// Not yet emitted. Fixtures: `tests/fixtures/by-kind/invalid_operand/` (planned).
159    InvalidOperand {
160        op: String,
161        left: String,
162        right: String,
163    },
164    /// Not yet emitted. Fixtures: `tests/fixtures/by-kind/mismatching_docblock_return_type/` (planned).
165    MismatchingDocblockReturnType { declared: String, inferred: String },
166    /// Not yet emitted. Fixtures: `tests/fixtures/by-kind/mismatching_docblock_param_type/` (planned).
167    MismatchingDocblockParamType {
168        param: String,
169        declared: String,
170        inferred: String,
171    },
172    /// Emitted by `mir-analyzer/src/stmt/mod.rs`.
173    /// Fixtures: `tests/fixtures/by-kind/type_check_mismatch/`.
174    TypeCheckMismatch {
175        var: String,
176        expected: String,
177        actual: String,
178    },
179
180    // --- Array issues -------------------------------------------------------
181    /// Not yet emitted. Fixtures: `tests/fixtures/by-kind/invalid_array_offset/` (planned).
182    InvalidArrayOffset { expected: String, actual: String },
183    /// Not yet emitted. No fixtures yet.
184    NonExistentArrayOffset { key: String },
185    /// Emitted by `mir-analyzer/src/expr/assignment.rs`.
186    /// Fixtures: `tests/fixtures/by-kind/possibly_invalid_array_offset/`.
187    PossiblyInvalidArrayOffset { expected: String, actual: String },
188
189    // --- Redundancy ---------------------------------------------------------
190    /// Emitted by `mir-analyzer/src/stmt/control_flow.rs`.
191    /// Fixtures: `tests/fixtures/by-kind/redundant_condition/`.
192    RedundantCondition { ty: String },
193    /// Emitted by `mir-analyzer/src/expr/casts.rs`.
194    /// Fixtures: `tests/fixtures/by-kind/redundant_cast/`.
195    RedundantCast { from: String, to: String },
196    /// Not yet emitted. Fixtures: `tests/fixtures/by-kind/unnecessary_var_annotation/` (planned).
197    UnnecessaryVarAnnotation { var: String },
198    /// Not yet emitted. Fixtures: `tests/fixtures/by-kind/type_does_not_contain_type/` (planned).
199    TypeDoesNotContainType { left: String, right: String },
200
201    // --- Dead code ----------------------------------------------------------
202    /// Emitted by `mir-analyzer/src/diagnostics.rs`.
203    /// Fixtures: `tests/fixtures/by-kind/unused_variable/`.
204    UnusedVariable { name: String },
205    /// Emitted by `mir-analyzer/src/diagnostics.rs`.
206    /// Fixtures: `tests/fixtures/by-kind/unused_param/`.
207    UnusedParam { name: String },
208    /// Emitted by `mir-analyzer/src/stmt/mod.rs`.
209    /// Fixtures: `tests/fixtures/by-kind/unreachable_code/`.
210    UnreachableCode,
211    /// Emitted by `mir-analyzer/src/dead_code.rs`.
212    /// Fixtures: `tests/fixtures/by-kind/unused_method/`.
213    UnusedMethod { class: String, method: String },
214    /// Emitted by `mir-analyzer/src/dead_code.rs`.
215    /// Fixtures: `tests/fixtures/by-kind/unused_property/`.
216    UnusedProperty { class: String, property: String },
217    /// Emitted by `mir-analyzer/src/dead_code.rs`.
218    /// Fixtures: `tests/fixtures/by-kind/unused_function/`.
219    UnusedFunction { name: String },
220
221    // --- Readonly -----------------------------------------------------------
222    /// Emitted by `mir-analyzer/src/expr/assignment.rs`.
223    /// Fixtures: `tests/fixtures/by-kind/readonly_property_assignment/`.
224    ReadonlyPropertyAssignment { class: String, property: String },
225
226    // --- Inheritance --------------------------------------------------------
227    /// Emitted by `mir-analyzer/src/class.rs`.
228    /// Fixtures: `tests/fixtures/by-kind/unimplemented_abstract_method/`.
229    UnimplementedAbstractMethod { class: String, method: String },
230    /// Emitted by `mir-analyzer/src/class.rs`.
231    /// Fixtures: `tests/fixtures/by-kind/unimplemented_interface_method/`.
232    UnimplementedInterfaceMethod {
233        class: String,
234        interface: String,
235        method: String,
236    },
237    /// Emitted by `mir-analyzer/src/class.rs`.
238    /// Fixtures: `tests/fixtures/by-kind/method_signature_mismatch/`.
239    MethodSignatureMismatch {
240        class: String,
241        method: String,
242        detail: String,
243    },
244    /// Emitted by `mir-analyzer/src/class.rs`.
245    /// Fixtures: `tests/fixtures/by-kind/overridden_method_access/`.
246    OverriddenMethodAccess { class: String, method: String },
247    /// Emitted by `mir-analyzer/src/class.rs`.
248    /// Fixtures: `tests/fixtures/by-kind/final_class_extended/`.
249    FinalClassExtended { parent: String, child: String },
250    /// Emitted by `mir-analyzer/src/class.rs`.
251    /// Fixtures: `tests/fixtures/by-kind/final_method_overridden/`.
252    FinalMethodOverridden {
253        class: String,
254        method: String,
255        parent: String,
256    },
257    /// Emitted by `mir-analyzer/src/expr/objects.rs`.
258    /// Fixtures: `tests/fixtures/by-kind/abstract_instantiation/`.
259    AbstractInstantiation { class: String },
260
261    // --- Security (taint) ---------------------------------------------------
262    /// Not yet emitted (generic taint sink; specific sinks use `TaintedHtml`, `TaintedSql`, `TaintedShell`).
263    /// No fixtures yet.
264    TaintedInput { sink: String },
265    /// Emitted by `mir-analyzer/src/call/function.rs`.
266    /// Fixtures: `tests/fixtures/by-kind/tainted_html/`.
267    TaintedHtml,
268    /// Emitted by `mir-analyzer/src/call/function.rs`.
269    /// Fixtures: `tests/fixtures/by-kind/tainted_sql/`.
270    TaintedSql,
271    /// Emitted by `mir-analyzer/src/call/function.rs`.
272    /// Fixtures: `tests/fixtures/by-kind/tainted_shell/`.
273    TaintedShell,
274
275    // --- Generics -----------------------------------------------------------
276    /// Emitted by `mir-analyzer/src/call/function.rs`.
277    /// Fixtures: `tests/fixtures/by-kind/invalid_template_param/`.
278    InvalidTemplateParam {
279        name: String,
280        expected_bound: String,
281        actual: String,
282    },
283    /// Emitted by `mir-analyzer/src/call/method.rs`.
284    /// Fixtures: `tests/fixtures/by-kind/shadowed_template_param/`.
285    ShadowedTemplateParam { name: String },
286
287    // --- Other --------------------------------------------------------------
288    /// Emitted by `mir-analyzer/src/call/function.rs`.
289    /// Fixtures: `tests/fixtures/by-kind/deprecated_call/`.
290    DeprecatedCall {
291        name: String,
292        message: Option<Arc<str>>,
293    },
294    /// Emitted by `mir-analyzer/src/call/method.rs`.
295    /// Fixtures: `tests/fixtures/by-kind/deprecated_method_call/`.
296    DeprecatedMethodCall {
297        class: String,
298        method: String,
299        message: Option<Arc<str>>,
300    },
301    /// Emitted by `mir-analyzer/src/call/method.rs`.
302    /// Fixtures: `tests/fixtures/by-kind/deprecated_method/`.
303    DeprecatedMethod {
304        class: String,
305        method: String,
306        message: Option<Arc<str>>,
307    },
308    /// Emitted by `mir-analyzer/src/class.rs`.
309    /// Fixtures: `tests/fixtures/by-kind/deprecated_class/`.
310    DeprecatedClass {
311        name: String,
312        message: Option<Arc<str>>,
313    },
314    /// Emitted by `mir-analyzer/src/call/method.rs`.
315    /// Fixtures: `tests/fixtures/by-kind/internal_method/`.
316    InternalMethod { class: String, method: String },
317    /// Not yet emitted. Fixtures: `tests/fixtures/by-kind/missing_return_type/` (planned).
318    MissingReturnType { fn_name: String },
319    /// Not yet emitted. Fixtures: `tests/fixtures/by-kind/missing_param_type/` (planned).
320    MissingParamType { fn_name: String, param: String },
321    /// Emitted by `mir-analyzer/src/stmt/flow.rs`.
322    /// Fixtures: `tests/fixtures/by-kind/invalid_throw/`.
323    InvalidThrow { ty: String },
324    /// Emitted by `mir-analyzer/src/stmt/flow.rs`.
325    /// Fixtures: `tests/fixtures/by-kind/missing_throws_docblock/`.
326    MissingThrowsDocblock { class: String },
327    /// Emitted by `mir-analyzer/src/stmt/expressions.rs`.
328    /// Fixtures: `tests/fixtures/by-kind/implicit_to_string_cast/`.
329    ImplicitToStringCast { class: String },
330    /// Emitted by `mir-analyzer/src/call/args.rs`.
331    /// Fixtures: `tests/fixtures/by-kind/implicit_float_to_int_cast/`.
332    ImplicitFloatToIntCast { from: String },
333    /// Emitted by `mir-analyzer/src/parser/mod.rs`.
334    /// Fixtures: `tests/fixtures/by-kind/parse_error/`.
335    ParseError { message: String },
336    /// Emitted by `mir-analyzer/src/collector/annotation.rs`.
337    /// Fixtures: `tests/fixtures/by-kind/invalid_docblock/`.
338    InvalidDocblock { message: String },
339    /// Not yet emitted. Fixtures: `tests/fixtures/by-kind/mixed_argument/` (planned).
340    MixedArgument { param: String, fn_name: String },
341    /// Not yet emitted. Fixtures: `tests/fixtures/by-kind/mixed_assignment/` (planned).
342    MixedAssignment { var: String },
343    /// Emitted by `mir-analyzer/src/call/method.rs`.
344    /// Fixtures: `tests/fixtures/by-kind/mixed_method_call/`.
345    MixedMethodCall { method: String },
346    /// Not yet emitted. Fixtures: `tests/fixtures/by-kind/mixed_property_fetch/` (planned).
347    MixedPropertyFetch { property: String },
348    /// Emitted by `mir-analyzer/src/expr/mod.rs`.
349    /// Fixtures: `tests/fixtures/by-kind/mixed_clone/`.
350    MixedClone,
351    /// Emitted by `mir-analyzer/src/class.rs`.
352    /// Fixtures: `tests/fixtures/by-kind/circular_inheritance/`.
353    CircularInheritance { class: String },
354
355    // --- Trait constraints --------------------------------------------------
356    /// Emitted by `mir-analyzer/src/body_analysis.rs`.
357    /// Fixtures: `tests/fixtures/by-kind/invalid_trait_use/`.
358    InvalidTraitUse { trait_name: String, reason: String },
359}
360
361fn append_deprecation_message(base: String, message: &Option<Arc<str>>) -> String {
362    match message.as_deref().filter(|m| !m.is_empty()) {
363        Some(msg) => format!("{base}: {msg}"),
364        None => base,
365    }
366}
367
368impl IssueKind {
369    /// Default severity for this issue kind.
370    pub fn default_severity(&self) -> Severity {
371        match self {
372            // Errors (always blocking)
373            IssueKind::InvalidScope { .. }
374            | IssueKind::UndefinedVariable { .. }
375            | IssueKind::UndefinedFunction { .. }
376            | IssueKind::UndefinedMethod { .. }
377            | IssueKind::UndefinedClass { .. }
378            | IssueKind::UndefinedConstant { .. }
379            | IssueKind::InvalidReturnType { .. }
380            | IssueKind::InvalidArgument { .. }
381            | IssueKind::TooFewArguments { .. }
382            | IssueKind::TooManyArguments { .. }
383            | IssueKind::InvalidNamedArgument { .. }
384            | IssueKind::InvalidPassByReference { .. }
385            | IssueKind::InvalidThrow { .. }
386            | IssueKind::UnimplementedAbstractMethod { .. }
387            | IssueKind::UnimplementedInterfaceMethod { .. }
388            | IssueKind::MethodSignatureMismatch { .. }
389            | IssueKind::FinalClassExtended { .. }
390            | IssueKind::FinalMethodOverridden { .. }
391            | IssueKind::AbstractInstantiation { .. }
392            | IssueKind::InvalidTemplateParam { .. }
393            | IssueKind::ReadonlyPropertyAssignment { .. }
394            | IssueKind::ParseError { .. }
395            | IssueKind::TaintedInput { .. }
396            | IssueKind::TaintedHtml
397            | IssueKind::TaintedSql
398            | IssueKind::TaintedShell
399            | IssueKind::CircularInheritance { .. }
400            | IssueKind::InvalidTraitUse { .. }
401            | IssueKind::UndefinedTrait { .. }
402            | IssueKind::TypeCheckMismatch { .. } => Severity::Error,
403
404            // Warnings (shown at default error level)
405            IssueKind::NullArgument { .. }
406            | IssueKind::NullPropertyFetch { .. }
407            | IssueKind::NullMethodCall { .. }
408            | IssueKind::NullArrayAccess
409            | IssueKind::NullableReturnStatement { .. }
410            | IssueKind::InvalidPropertyAssignment { .. }
411            | IssueKind::InvalidArrayOffset { .. }
412            | IssueKind::NonExistentArrayOffset { .. }
413            | IssueKind::PossiblyInvalidArrayOffset { .. }
414            | IssueKind::UndefinedProperty { .. }
415            | IssueKind::InvalidOperand { .. }
416            | IssueKind::OverriddenMethodAccess { .. }
417            | IssueKind::ImplicitToStringCast { .. }
418            | IssueKind::ImplicitFloatToIntCast { .. }
419            | IssueKind::UnusedVariable { .. }
420            | IssueKind::InvalidStringClass { .. } => Severity::Warning,
421
422            // PossiblyUndefined: shown at default error level (same as Warning)
423            IssueKind::PossiblyUndefinedVariable { .. } => Severity::Warning,
424
425            // Possibly-null / possibly-invalid (only shown in strict mode, level ≥ 7)
426            IssueKind::PossiblyNullArgument { .. }
427            | IssueKind::PossiblyInvalidArgument { .. }
428            | IssueKind::PossiblyNullPropertyFetch { .. }
429            | IssueKind::PossiblyNullMethodCall { .. }
430            | IssueKind::PossiblyNullArrayAccess => Severity::Info,
431
432            // Info
433            IssueKind::RedundantCondition { .. }
434            | IssueKind::RedundantCast { .. }
435            | IssueKind::UnnecessaryVarAnnotation { .. }
436            | IssueKind::TypeDoesNotContainType { .. }
437            | IssueKind::UnusedParam { .. }
438            | IssueKind::UnreachableCode
439            | IssueKind::UnusedMethod { .. }
440            | IssueKind::UnusedProperty { .. }
441            | IssueKind::UnusedFunction { .. }
442            | IssueKind::DeprecatedCall { .. }
443            | IssueKind::DeprecatedMethodCall { .. }
444            | IssueKind::DeprecatedMethod { .. }
445            | IssueKind::DeprecatedClass { .. }
446            | IssueKind::InternalMethod { .. }
447            | IssueKind::MissingReturnType { .. }
448            | IssueKind::MissingParamType { .. }
449            | IssueKind::MismatchingDocblockReturnType { .. }
450            | IssueKind::MismatchingDocblockParamType { .. }
451            | IssueKind::InvalidDocblock { .. }
452            | IssueKind::InvalidCast { .. }
453            | IssueKind::MixedArgument { .. }
454            | IssueKind::MixedAssignment { .. }
455            | IssueKind::MixedMethodCall { .. }
456            | IssueKind::MixedPropertyFetch { .. }
457            | IssueKind::MixedClone
458            | IssueKind::ShadowedTemplateParam { .. }
459            | IssueKind::MissingThrowsDocblock { .. } => Severity::Info,
460        }
461    }
462
463    /// Stable error code (e.g. `"MIR0005"`).
464    ///
465    /// Codes are assigned in bands by category and are part of the public API:
466    /// once a code ships, it must never be reused for a different issue kind.
467    /// New variants take the next free slot in their band; obsolete variants
468    /// retire their code (the slot stays burnt). Bands have headroom for growth.
469    ///
470    /// Bands:
471    ///
472    /// | Range         | Category                        |
473    /// |---------------|---------------------------------|
474    /// | 0001 – 0099   | Undefined symbols               |
475    /// | 0100 – 0199   | Nullability                     |
476    /// | 0200 – 0299   | Type mismatches                 |
477    /// | 0300 – 0399   | Array / offset                  |
478    /// | 0400 – 0499   | Redundancy                      |
479    /// | 0500 – 0599   | Dead code                       |
480    /// | 0600 – 0699   | Readonly                        |
481    /// | 0700 – 0799   | Inheritance                     |
482    /// | 0800 – 0899   | Security (taint)                |
483    /// | 0900 – 0999   | Generics                        |
484    /// | 1000 – 1099   | Deprecation / internal          |
485    /// | 1100 – 1199   | Missing types / docblocks       |
486    /// | 1200 – 1299   | Mixed                           |
487    /// | 1300 – 1399   | Trait                           |
488    /// | 1400 – 1499   | Parse                           |
489    /// | 1500 – 1599   | Other                           |
490    pub fn code(&self) -> &'static str {
491        match self {
492            // Undefined (0001-0099)
493            IssueKind::InvalidScope { .. } => "MIR0001",
494            IssueKind::UndefinedVariable { .. } => "MIR0002",
495            IssueKind::UndefinedFunction { .. } => "MIR0003",
496            IssueKind::UndefinedMethod { .. } => "MIR0004",
497            IssueKind::UndefinedClass { .. } => "MIR0005",
498            IssueKind::UndefinedProperty { .. } => "MIR0006",
499            IssueKind::UndefinedConstant { .. } => "MIR0007",
500            IssueKind::PossiblyUndefinedVariable { .. } => "MIR0008",
501            IssueKind::UndefinedTrait { .. } => "MIR0009",
502
503            // Nullability (0100-0199)
504            IssueKind::NullArgument { .. } => "MIR0100",
505            IssueKind::NullPropertyFetch { .. } => "MIR0101",
506            IssueKind::NullMethodCall { .. } => "MIR0102",
507            IssueKind::NullArrayAccess => "MIR0103",
508            IssueKind::PossiblyNullArgument { .. } => "MIR0104",
509            IssueKind::PossiblyInvalidArgument { .. } => "MIR0105",
510            IssueKind::PossiblyNullPropertyFetch { .. } => "MIR0106",
511            IssueKind::PossiblyNullMethodCall { .. } => "MIR0107",
512            IssueKind::PossiblyNullArrayAccess => "MIR0108",
513            IssueKind::NullableReturnStatement { .. } => "MIR0109",
514
515            // Type mismatches (0200-0299)
516            IssueKind::InvalidReturnType { .. } => "MIR0200",
517            IssueKind::InvalidArgument { .. } => "MIR0201",
518            IssueKind::TooFewArguments { .. } => "MIR0202",
519            IssueKind::TooManyArguments { .. } => "MIR0203",
520            IssueKind::InvalidNamedArgument { .. } => "MIR0204",
521            IssueKind::InvalidPassByReference { .. } => "MIR0205",
522            IssueKind::InvalidPropertyAssignment { .. } => "MIR0206",
523            IssueKind::InvalidCast { .. } => "MIR0207",
524            IssueKind::InvalidOperand { .. } => "MIR0208",
525            IssueKind::MismatchingDocblockReturnType { .. } => "MIR0209",
526            IssueKind::MismatchingDocblockParamType { .. } => "MIR0210",
527            IssueKind::InvalidStringClass { .. } => "MIR0211",
528            IssueKind::TypeCheckMismatch { .. } => "MIR0212",
529
530            // Array / offset (0300-0399)
531            IssueKind::InvalidArrayOffset { .. } => "MIR0300",
532            IssueKind::NonExistentArrayOffset { .. } => "MIR0301",
533            IssueKind::PossiblyInvalidArrayOffset { .. } => "MIR0302",
534
535            // Redundancy (0400-0499)
536            IssueKind::RedundantCondition { .. } => "MIR0400",
537            IssueKind::RedundantCast { .. } => "MIR0401",
538            IssueKind::UnnecessaryVarAnnotation { .. } => "MIR0402",
539            IssueKind::TypeDoesNotContainType { .. } => "MIR0403",
540
541            // Dead code (0500-0599)
542            IssueKind::UnusedVariable { .. } => "MIR0500",
543            IssueKind::UnusedParam { .. } => "MIR0501",
544            IssueKind::UnreachableCode => "MIR0502",
545            IssueKind::UnusedMethod { .. } => "MIR0503",
546            IssueKind::UnusedProperty { .. } => "MIR0504",
547            IssueKind::UnusedFunction { .. } => "MIR0505",
548
549            // Readonly (0600-0699)
550            IssueKind::ReadonlyPropertyAssignment { .. } => "MIR0600",
551
552            // Inheritance (0700-0799)
553            IssueKind::UnimplementedAbstractMethod { .. } => "MIR0700",
554            IssueKind::UnimplementedInterfaceMethod { .. } => "MIR0701",
555            IssueKind::MethodSignatureMismatch { .. } => "MIR0702",
556            IssueKind::OverriddenMethodAccess { .. } => "MIR0703",
557            IssueKind::FinalClassExtended { .. } => "MIR0704",
558            IssueKind::FinalMethodOverridden { .. } => "MIR0705",
559            IssueKind::AbstractInstantiation { .. } => "MIR0706",
560            IssueKind::CircularInheritance { .. } => "MIR0707",
561
562            // Security / taint (0800-0899)
563            IssueKind::TaintedInput { .. } => "MIR0800",
564            IssueKind::TaintedHtml => "MIR0801",
565            IssueKind::TaintedSql => "MIR0802",
566            IssueKind::TaintedShell => "MIR0803",
567
568            // Generics (0900-0999)
569            IssueKind::InvalidTemplateParam { .. } => "MIR0900",
570            IssueKind::ShadowedTemplateParam { .. } => "MIR0901",
571
572            // Deprecation / internal (1000-1099)
573            IssueKind::DeprecatedCall { .. } => "MIR1000",
574            IssueKind::DeprecatedMethodCall { .. } => "MIR1001",
575            IssueKind::DeprecatedMethod { .. } => "MIR1002",
576            IssueKind::DeprecatedClass { .. } => "MIR1003",
577            IssueKind::InternalMethod { .. } => "MIR1004",
578
579            // Missing types / docblocks (1100-1199)
580            IssueKind::MissingReturnType { .. } => "MIR1100",
581            IssueKind::MissingParamType { .. } => "MIR1101",
582            IssueKind::MissingThrowsDocblock { .. } => "MIR1102",
583            IssueKind::InvalidDocblock { .. } => "MIR1103",
584
585            // Mixed (1200-1299)
586            IssueKind::MixedArgument { .. } => "MIR1200",
587            IssueKind::MixedAssignment { .. } => "MIR1201",
588            IssueKind::MixedMethodCall { .. } => "MIR1202",
589            IssueKind::MixedPropertyFetch { .. } => "MIR1203",
590            IssueKind::MixedClone => "MIR1204",
591
592            // Trait (1300-1399)
593            IssueKind::InvalidTraitUse { .. } => "MIR1300",
594
595            // Parse (1400-1499)
596            IssueKind::ParseError { .. } => "MIR1400",
597
598            // Other (1500-1599)
599            IssueKind::InvalidThrow { .. } => "MIR1500",
600            IssueKind::ImplicitToStringCast { .. } => "MIR1501",
601            IssueKind::ImplicitFloatToIntCast { .. } => "MIR1502",
602        }
603    }
604
605    /// Identifier name used in config and `@psalm-suppress` / `@suppress` annotations.
606    pub fn name(&self) -> &'static str {
607        match self {
608            IssueKind::InvalidScope { .. } => "InvalidScope",
609            IssueKind::UndefinedVariable { .. } => "UndefinedVariable",
610            IssueKind::UndefinedFunction { .. } => "UndefinedFunction",
611            IssueKind::UndefinedMethod { .. } => "UndefinedMethod",
612            IssueKind::UndefinedClass { .. } => "UndefinedClass",
613            IssueKind::UndefinedProperty { .. } => "UndefinedProperty",
614            IssueKind::UndefinedConstant { .. } => "UndefinedConstant",
615            IssueKind::PossiblyUndefinedVariable { .. } => "PossiblyUndefinedVariable",
616            IssueKind::UndefinedTrait { .. } => "UndefinedTrait",
617            IssueKind::InvalidStringClass { .. } => "InvalidStringClass",
618            IssueKind::NullArgument { .. } => "NullArgument",
619            IssueKind::NullPropertyFetch { .. } => "NullPropertyFetch",
620            IssueKind::NullMethodCall { .. } => "NullMethodCall",
621            IssueKind::NullArrayAccess => "NullArrayAccess",
622            IssueKind::PossiblyNullArgument { .. } => "PossiblyNullArgument",
623            IssueKind::PossiblyInvalidArgument { .. } => "PossiblyInvalidArgument",
624            IssueKind::PossiblyNullPropertyFetch { .. } => "PossiblyNullPropertyFetch",
625            IssueKind::PossiblyNullMethodCall { .. } => "PossiblyNullMethodCall",
626            IssueKind::PossiblyNullArrayAccess => "PossiblyNullArrayAccess",
627            IssueKind::NullableReturnStatement { .. } => "NullableReturnStatement",
628            IssueKind::InvalidReturnType { .. } => "InvalidReturnType",
629            IssueKind::InvalidArgument { .. } => "InvalidArgument",
630            IssueKind::TooFewArguments { .. } => "TooFewArguments",
631            IssueKind::TooManyArguments { .. } => "TooManyArguments",
632            IssueKind::InvalidNamedArgument { .. } => "InvalidNamedArgument",
633            IssueKind::InvalidPassByReference { .. } => "InvalidPassByReference",
634            IssueKind::InvalidPropertyAssignment { .. } => "InvalidPropertyAssignment",
635            IssueKind::InvalidCast { .. } => "InvalidCast",
636            IssueKind::InvalidOperand { .. } => "InvalidOperand",
637            IssueKind::MismatchingDocblockReturnType { .. } => "MismatchingDocblockReturnType",
638            IssueKind::MismatchingDocblockParamType { .. } => "MismatchingDocblockParamType",
639            IssueKind::TypeCheckMismatch { .. } => "TypeCheckMismatch",
640            IssueKind::InvalidArrayOffset { .. } => "InvalidArrayOffset",
641            IssueKind::NonExistentArrayOffset { .. } => "NonExistentArrayOffset",
642            IssueKind::PossiblyInvalidArrayOffset { .. } => "PossiblyInvalidArrayOffset",
643            IssueKind::RedundantCondition { .. } => "RedundantCondition",
644            IssueKind::RedundantCast { .. } => "RedundantCast",
645            IssueKind::UnnecessaryVarAnnotation { .. } => "UnnecessaryVarAnnotation",
646            IssueKind::TypeDoesNotContainType { .. } => "TypeDoesNotContainType",
647            IssueKind::UnusedVariable { .. } => "UnusedVariable",
648            IssueKind::UnusedParam { .. } => "UnusedParam",
649            IssueKind::UnreachableCode => "UnreachableCode",
650            IssueKind::UnusedMethod { .. } => "UnusedMethod",
651            IssueKind::UnusedProperty { .. } => "UnusedProperty",
652            IssueKind::UnusedFunction { .. } => "UnusedFunction",
653            IssueKind::UnimplementedAbstractMethod { .. } => "UnimplementedAbstractMethod",
654            IssueKind::UnimplementedInterfaceMethod { .. } => "UnimplementedInterfaceMethod",
655            IssueKind::MethodSignatureMismatch { .. } => "MethodSignatureMismatch",
656            IssueKind::OverriddenMethodAccess { .. } => "OverriddenMethodAccess",
657            IssueKind::FinalClassExtended { .. } => "FinalClassExtended",
658            IssueKind::FinalMethodOverridden { .. } => "FinalMethodOverridden",
659            IssueKind::AbstractInstantiation { .. } => "AbstractInstantiation",
660            IssueKind::ReadonlyPropertyAssignment { .. } => "ReadonlyPropertyAssignment",
661            IssueKind::InvalidTemplateParam { .. } => "InvalidTemplateParam",
662            IssueKind::ShadowedTemplateParam { .. } => "ShadowedTemplateParam",
663            IssueKind::TaintedInput { .. } => "TaintedInput",
664            IssueKind::TaintedHtml => "TaintedHtml",
665            IssueKind::TaintedSql => "TaintedSql",
666            IssueKind::TaintedShell => "TaintedShell",
667            IssueKind::DeprecatedCall { .. } => "DeprecatedCall",
668            IssueKind::DeprecatedMethodCall { .. } => "DeprecatedMethodCall",
669            IssueKind::DeprecatedMethod { .. } => "DeprecatedMethod",
670            IssueKind::DeprecatedClass { .. } => "DeprecatedClass",
671            IssueKind::InternalMethod { .. } => "InternalMethod",
672            IssueKind::MissingReturnType { .. } => "MissingReturnType",
673            IssueKind::MissingParamType { .. } => "MissingParamType",
674            IssueKind::InvalidThrow { .. } => "InvalidThrow",
675            IssueKind::MissingThrowsDocblock { .. } => "MissingThrowsDocblock",
676            IssueKind::ImplicitToStringCast { .. } => "ImplicitToStringCast",
677            IssueKind::ImplicitFloatToIntCast { .. } => "ImplicitFloatToIntCast",
678            IssueKind::ParseError { .. } => "ParseError",
679            IssueKind::InvalidDocblock { .. } => "InvalidDocblock",
680            IssueKind::MixedArgument { .. } => "MixedArgument",
681            IssueKind::MixedAssignment { .. } => "MixedAssignment",
682            IssueKind::MixedMethodCall { .. } => "MixedMethodCall",
683            IssueKind::MixedPropertyFetch { .. } => "MixedPropertyFetch",
684            IssueKind::MixedClone => "MixedClone",
685            IssueKind::CircularInheritance { .. } => "CircularInheritance",
686            IssueKind::InvalidTraitUse { .. } => "InvalidTraitUse",
687        }
688    }
689
690    /// Human-readable message for this issue.
691    pub fn message(&self) -> String {
692        match self {
693            IssueKind::InvalidScope { in_class } => {
694                if *in_class {
695                    "$this cannot be used in a static method".to_string()
696                } else {
697                    "$this cannot be used outside of a class".to_string()
698                }
699            }
700            IssueKind::UndefinedVariable { name } => format!("Variable ${name} is not defined"),
701            IssueKind::UndefinedFunction { name } => format!("Function {name}() is not defined"),
702            IssueKind::UndefinedMethod { class, method } => {
703                format!("Method {class}::{method}() does not exist")
704            }
705            IssueKind::UndefinedClass { name } => format!("Class {name} does not exist"),
706            IssueKind::UndefinedProperty { class, property } => {
707                format!("Property {class}::${property} does not exist")
708            }
709            IssueKind::UndefinedConstant { name } => format!("Constant {name} is not defined"),
710            IssueKind::PossiblyUndefinedVariable { name } => {
711                format!("Variable ${name} might not be defined")
712            }
713            IssueKind::UndefinedTrait { name } => format!("Trait {name} does not exist"),
714            IssueKind::InvalidStringClass { actual } => {
715                format!("Dynamic class instantiation requires string or class-string type, got '{actual}'")
716            }
717
718            IssueKind::NullArgument { param, fn_name } => {
719                format!("Argument ${param} of {fn_name}() cannot be null")
720            }
721            IssueKind::NullPropertyFetch { property } => {
722                format!("Cannot access property ${property} on null")
723            }
724            IssueKind::NullMethodCall { method } => {
725                format!("Cannot call method {method}() on null")
726            }
727            IssueKind::NullArrayAccess => "Cannot access array on null".to_string(),
728            IssueKind::PossiblyNullArgument { param, fn_name } => {
729                format!("Argument ${param} of {fn_name}() might be null")
730            }
731            IssueKind::PossiblyInvalidArgument {
732                param,
733                fn_name,
734                expected,
735                actual,
736            } => {
737                format!("Argument ${param} of {fn_name}() expects '{expected}', possibly different type '{actual}' provided")
738            }
739            IssueKind::PossiblyNullPropertyFetch { property } => {
740                format!("Cannot access property ${property} on possibly null value")
741            }
742            IssueKind::PossiblyNullMethodCall { method } => {
743                format!("Cannot call method {method}() on possibly null value")
744            }
745            IssueKind::PossiblyNullArrayAccess => {
746                "Cannot access array on possibly null value".to_string()
747            }
748            IssueKind::NullableReturnStatement { expected, actual } => {
749                format!("Return type '{actual}' is not compatible with declared '{expected}'")
750            }
751
752            IssueKind::InvalidReturnType { expected, actual } => {
753                format!("Return type '{actual}' is not compatible with declared '{expected}'")
754            }
755            IssueKind::InvalidArgument {
756                param,
757                fn_name,
758                expected,
759                actual,
760            } => {
761                format!("Argument ${param} of {fn_name}() expects '{expected}', got '{actual}'")
762            }
763            IssueKind::TooFewArguments {
764                fn_name,
765                expected,
766                actual,
767            } => {
768                format!(
769                    "Too few arguments for {}(): expected {}, got {}",
770                    fn_name, expected, actual
771                )
772            }
773            IssueKind::TooManyArguments {
774                fn_name,
775                expected,
776                actual,
777            } => {
778                format!(
779                    "Too many arguments for {}(): expected {}, got {}",
780                    fn_name, expected, actual
781                )
782            }
783            IssueKind::InvalidNamedArgument { fn_name, name } => {
784                format!("{}() has no parameter named ${}", fn_name, name)
785            }
786            IssueKind::InvalidPassByReference { fn_name, param } => {
787                format!(
788                    "Argument ${} of {}() must be passed by reference",
789                    param, fn_name
790                )
791            }
792            IssueKind::InvalidPropertyAssignment {
793                property,
794                expected,
795                actual,
796            } => {
797                format!("Property ${property} expects '{expected}', cannot assign '{actual}'")
798            }
799            IssueKind::InvalidCast { from, to } => {
800                format!("Cannot cast '{from}' to '{to}'")
801            }
802            IssueKind::InvalidOperand { op, left, right } => {
803                format!("Operator '{op}' not supported between '{left}' and '{right}'")
804            }
805            IssueKind::MismatchingDocblockReturnType { declared, inferred } => {
806                format!("Docblock return type '{declared}' does not match inferred '{inferred}'")
807            }
808            IssueKind::MismatchingDocblockParamType {
809                param,
810                declared,
811                inferred,
812            } => {
813                format!(
814                    "Docblock type '{declared}' for ${param} does not match inferred '{inferred}'"
815                )
816            }
817            IssueKind::TypeCheckMismatch {
818                var,
819                expected,
820                actual,
821            } => {
822                format!("Type of ${var} is expected to be {expected}, got {actual}")
823            }
824
825            IssueKind::InvalidArrayOffset { expected, actual } => {
826                format!("Array offset expects '{expected}', got '{actual}'")
827            }
828            IssueKind::NonExistentArrayOffset { key } => {
829                format!("Array offset '{key}' does not exist")
830            }
831            IssueKind::PossiblyInvalidArrayOffset { expected, actual } => {
832                format!("Array offset might be invalid: expects '{expected}', got '{actual}'")
833            }
834
835            IssueKind::RedundantCondition { ty } => {
836                format!("Condition is always true/false for type '{ty}'")
837            }
838            IssueKind::RedundantCast { from, to } => {
839                format!("Casting '{from}' to '{to}' is redundant")
840            }
841            IssueKind::UnnecessaryVarAnnotation { var } => {
842                format!("@var annotation for ${var} is unnecessary")
843            }
844            IssueKind::TypeDoesNotContainType { left, right } => {
845                format!("Type '{left}' can never contain type '{right}'")
846            }
847
848            IssueKind::UnusedVariable { name } => format!("Variable ${name} is never read"),
849            IssueKind::UnusedParam { name } => format!("Parameter ${name} is never used"),
850            IssueKind::UnreachableCode => "Unreachable code detected".to_string(),
851            IssueKind::UnusedMethod { class, method } => {
852                format!("Private method {class}::{method}() is never called")
853            }
854            IssueKind::UnusedProperty { class, property } => {
855                format!("Private property {class}::${property} is never read")
856            }
857            IssueKind::UnusedFunction { name } => {
858                format!("Function {name}() is never called")
859            }
860
861            IssueKind::UnimplementedAbstractMethod { class, method } => {
862                format!("Class {class} must implement abstract method {method}()")
863            }
864            IssueKind::UnimplementedInterfaceMethod {
865                class,
866                interface,
867                method,
868            } => {
869                format!("Class {class} must implement {interface}::{method}() from interface")
870            }
871            IssueKind::MethodSignatureMismatch {
872                class,
873                method,
874                detail,
875            } => {
876                format!("Method {class}::{method}() signature mismatch: {detail}")
877            }
878            IssueKind::OverriddenMethodAccess { class, method } => {
879                format!("Method {class}::{method}() overrides with less visibility")
880            }
881            IssueKind::ReadonlyPropertyAssignment { class, property } => {
882                format!(
883                    "Cannot assign to readonly property {class}::${property} outside of constructor"
884                )
885            }
886            IssueKind::FinalClassExtended { parent, child } => {
887                format!("Class {child} cannot extend final class {parent}")
888            }
889            IssueKind::InvalidTemplateParam {
890                name,
891                expected_bound,
892                actual,
893            } => {
894                format!(
895                    "Template type '{name}' inferred as '{actual}' does not satisfy bound '{expected_bound}'"
896                )
897            }
898            IssueKind::ShadowedTemplateParam { name } => {
899                format!(
900                    "Method template parameter '{name}' shadows class-level template parameter with the same name"
901                )
902            }
903            IssueKind::FinalMethodOverridden {
904                class,
905                method,
906                parent,
907            } => {
908                format!("Method {class}::{method}() cannot override final method from {parent}")
909            }
910            IssueKind::AbstractInstantiation { class } => {
911                format!("Cannot instantiate abstract class {class}")
912            }
913
914            IssueKind::TaintedInput { sink } => format!("Tainted input reaching sink '{sink}'"),
915            IssueKind::TaintedHtml => "Tainted HTML output — possible XSS".to_string(),
916            IssueKind::TaintedSql => "Tainted SQL query — possible SQL injection".to_string(),
917            IssueKind::TaintedShell => {
918                "Tainted shell command — possible command injection".to_string()
919            }
920
921            IssueKind::DeprecatedCall { name, message } => {
922                let base = format!("Call to deprecated function {name}");
923                append_deprecation_message(base, message)
924            }
925            IssueKind::DeprecatedMethodCall {
926                class,
927                method,
928                message,
929            } => {
930                let base = format!("Call to deprecated method {class}::{method}");
931                append_deprecation_message(base, message)
932            }
933            IssueKind::DeprecatedMethod {
934                class,
935                method,
936                message,
937            } => {
938                let base = format!("Method {class}::{method}() is deprecated");
939                append_deprecation_message(base, message)
940            }
941            IssueKind::DeprecatedClass { name, message } => {
942                let base = format!("Class {name} is deprecated");
943                append_deprecation_message(base, message)
944            }
945            IssueKind::InternalMethod { class, method } => {
946                format!("Method {class}::{method}() is marked @internal")
947            }
948            IssueKind::MissingReturnType { fn_name } => {
949                format!("Function {fn_name}() has no return type annotation")
950            }
951            IssueKind::MissingParamType { fn_name, param } => {
952                format!("Parameter ${param} of {fn_name}() has no type annotation")
953            }
954            IssueKind::InvalidThrow { ty } => {
955                format!("Thrown type '{ty}' does not extend Throwable")
956            }
957            IssueKind::MissingThrowsDocblock { class } => {
958                format!("Exception {class} is thrown but not declared in @throws")
959            }
960            IssueKind::ImplicitToStringCast { class } => {
961                format!("Class {class} does not implement __toString()")
962            }
963            IssueKind::ImplicitFloatToIntCast { from } => {
964                format!("Implicit cast from {from} to int truncates the fractional part")
965            }
966            IssueKind::ParseError { message } => format!("Parse error: {message}"),
967            IssueKind::InvalidDocblock { message } => format!("Invalid docblock: {message}"),
968            IssueKind::MixedArgument { param, fn_name } => {
969                format!("Argument ${param} of {fn_name}() is mixed")
970            }
971            IssueKind::MixedAssignment { var } => {
972                format!("Variable ${var} is assigned a mixed type")
973            }
974            IssueKind::MixedMethodCall { method } => {
975                format!("Method {method}() called on mixed type")
976            }
977            IssueKind::MixedPropertyFetch { property } => {
978                format!("Property ${property} fetched on mixed type")
979            }
980            IssueKind::MixedClone => "cannot clone mixed".to_string(),
981            IssueKind::CircularInheritance { class } => {
982                format!("Class {class} has a circular inheritance chain")
983            }
984            IssueKind::InvalidTraitUse { trait_name, reason } => {
985                format!("Trait {trait_name} used incorrectly: {reason}")
986            }
987        }
988    }
989}
990
991// ---------------------------------------------------------------------------
992// Issue
993// ---------------------------------------------------------------------------
994
995#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
996pub struct Issue {
997    pub kind: IssueKind,
998    pub severity: Severity,
999    pub location: Location,
1000    pub snippet: Option<String>,
1001    pub suppressed: bool,
1002}
1003
1004impl Issue {
1005    pub fn new(kind: IssueKind, location: Location) -> Self {
1006        let severity = kind.default_severity();
1007        Self {
1008            severity,
1009            kind,
1010            location,
1011            snippet: None,
1012            suppressed: false,
1013        }
1014    }
1015
1016    pub fn with_snippet(mut self, snippet: impl Into<String>) -> Self {
1017        self.snippet = Some(snippet.into());
1018        self
1019    }
1020
1021    pub fn suppress(mut self) -> Self {
1022        self.suppressed = true;
1023        self
1024    }
1025}
1026
1027impl fmt::Display for Issue {
1028    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1029        let sev = match self.severity {
1030            Severity::Error => "error".red().to_string(),
1031            Severity::Warning => "warning".yellow().to_string(),
1032            Severity::Info => "info".blue().to_string(),
1033        };
1034        write!(
1035            f,
1036            "{} {}[{}] {}: {}",
1037            self.location.bright_black(),
1038            sev,
1039            self.kind.code().bright_black(),
1040            self.kind.name().bold(),
1041            self.kind.message()
1042        )
1043    }
1044}
1045
1046// ---------------------------------------------------------------------------
1047// IssueBuffer — collects issues for a single file pass
1048// ---------------------------------------------------------------------------
1049
1050#[derive(Debug, Default)]
1051pub struct IssueBuffer {
1052    issues: Vec<Issue>,
1053    seen: HashSet<(&'static str, Arc<str>, u32, u16)>,
1054    /// Issue names suppressed at the file level (from `@psalm-suppress` / `@suppress` on the file docblock)
1055    file_suppressions: Vec<String>,
1056}
1057
1058impl IssueBuffer {
1059    pub fn new() -> Self {
1060        Self::default()
1061    }
1062
1063    pub fn add(&mut self, issue: Issue) {
1064        let key = (
1065            issue.kind.name(),
1066            issue.location.file.clone(),
1067            issue.location.line,
1068            issue.location.col_start,
1069        );
1070        if self.seen.insert(key) {
1071            self.issues.push(issue);
1072        }
1073    }
1074
1075    pub fn add_suppression(&mut self, name: impl Into<String>) {
1076        self.file_suppressions.push(name.into());
1077    }
1078
1079    /// Consume the buffer and return unsuppressed issues.
1080    pub fn into_issues(self) -> Vec<Issue> {
1081        self.issues
1082            .into_iter()
1083            .filter(|i| !i.suppressed)
1084            .filter(|i| !self.file_suppressions.contains(&i.kind.name().to_string()))
1085            .collect()
1086    }
1087
1088    /// Mark all issues added since index `from` as suppressed if their issue
1089    /// name appears in `suppressions`. Used for `@psalm-suppress` / `@suppress` on statements.
1090    pub fn suppress_range(&mut self, from: usize, suppressions: &[String]) {
1091        if suppressions.is_empty() {
1092            return;
1093        }
1094        for issue in self.issues[from..].iter_mut() {
1095            if suppressions.iter().any(|s| s == issue.kind.name()) {
1096                issue.suppressed = true;
1097            }
1098        }
1099    }
1100
1101    /// Current number of buffered issues. Use before analyzing a statement to
1102    /// get the `from` index for `suppress_range`.
1103    pub fn issue_count(&self) -> usize {
1104        self.issues.len()
1105    }
1106
1107    pub fn is_empty(&self) -> bool {
1108        self.issues.is_empty()
1109    }
1110
1111    pub fn len(&self) -> usize {
1112        self.issues.len()
1113    }
1114
1115    pub fn error_count(&self) -> usize {
1116        self.issues
1117            .iter()
1118            .filter(|i| !i.suppressed && i.severity == Severity::Error)
1119            .count()
1120    }
1121
1122    pub fn warning_count(&self) -> usize {
1123        self.issues
1124            .iter()
1125            .filter(|i| !i.suppressed && i.severity == Severity::Warning)
1126            .count()
1127    }
1128}
1129
1130#[cfg(test)]
1131mod code_tests {
1132    use super::*;
1133    use std::collections::HashSet;
1134
1135    /// Returns one instance of every `IssueKind` variant.
1136    ///
1137    /// Updating `IssueKind` without updating this list will compile (it's a
1138    /// regular `Vec`), but `codes_cover_every_variant` will catch the omission
1139    /// — the test below asserts the count matches the exhaustive `code()` arm.
1140    fn one_of_each() -> Vec<IssueKind> {
1141        let s = || String::new();
1142        vec![
1143            IssueKind::InvalidScope { in_class: false },
1144            IssueKind::UndefinedVariable { name: s() },
1145            IssueKind::UndefinedFunction { name: s() },
1146            IssueKind::UndefinedMethod {
1147                class: s(),
1148                method: s(),
1149            },
1150            IssueKind::UndefinedClass { name: s() },
1151            IssueKind::UndefinedProperty {
1152                class: s(),
1153                property: s(),
1154            },
1155            IssueKind::UndefinedConstant { name: s() },
1156            IssueKind::PossiblyUndefinedVariable { name: s() },
1157            IssueKind::NullArgument {
1158                param: s(),
1159                fn_name: s(),
1160            },
1161            IssueKind::NullPropertyFetch { property: s() },
1162            IssueKind::NullMethodCall { method: s() },
1163            IssueKind::NullArrayAccess,
1164            IssueKind::PossiblyNullArgument {
1165                param: s(),
1166                fn_name: s(),
1167            },
1168            IssueKind::PossiblyInvalidArgument {
1169                param: s(),
1170                fn_name: s(),
1171                expected: s(),
1172                actual: s(),
1173            },
1174            IssueKind::PossiblyNullPropertyFetch { property: s() },
1175            IssueKind::PossiblyNullMethodCall { method: s() },
1176            IssueKind::PossiblyNullArrayAccess,
1177            IssueKind::NullableReturnStatement {
1178                expected: s(),
1179                actual: s(),
1180            },
1181            IssueKind::InvalidReturnType {
1182                expected: s(),
1183                actual: s(),
1184            },
1185            IssueKind::InvalidArgument {
1186                param: s(),
1187                fn_name: s(),
1188                expected: s(),
1189                actual: s(),
1190            },
1191            IssueKind::TooFewArguments {
1192                fn_name: s(),
1193                expected: 0,
1194                actual: 0,
1195            },
1196            IssueKind::TooManyArguments {
1197                fn_name: s(),
1198                expected: 0,
1199                actual: 0,
1200            },
1201            IssueKind::InvalidNamedArgument {
1202                fn_name: s(),
1203                name: s(),
1204            },
1205            IssueKind::InvalidPassByReference {
1206                fn_name: s(),
1207                param: s(),
1208            },
1209            IssueKind::InvalidPropertyAssignment {
1210                property: s(),
1211                expected: s(),
1212                actual: s(),
1213            },
1214            IssueKind::InvalidCast { from: s(), to: s() },
1215            IssueKind::InvalidOperand {
1216                op: s(),
1217                left: s(),
1218                right: s(),
1219            },
1220            IssueKind::MismatchingDocblockReturnType {
1221                declared: s(),
1222                inferred: s(),
1223            },
1224            IssueKind::MismatchingDocblockParamType {
1225                param: s(),
1226                declared: s(),
1227                inferred: s(),
1228            },
1229            IssueKind::TypeCheckMismatch {
1230                var: s(),
1231                expected: s(),
1232                actual: s(),
1233            },
1234            IssueKind::InvalidArrayOffset {
1235                expected: s(),
1236                actual: s(),
1237            },
1238            IssueKind::NonExistentArrayOffset { key: s() },
1239            IssueKind::PossiblyInvalidArrayOffset {
1240                expected: s(),
1241                actual: s(),
1242            },
1243            IssueKind::RedundantCondition { ty: s() },
1244            IssueKind::RedundantCast { from: s(), to: s() },
1245            IssueKind::UnnecessaryVarAnnotation { var: s() },
1246            IssueKind::TypeDoesNotContainType {
1247                left: s(),
1248                right: s(),
1249            },
1250            IssueKind::UnusedVariable { name: s() },
1251            IssueKind::UnusedParam { name: s() },
1252            IssueKind::UnreachableCode,
1253            IssueKind::UnusedMethod {
1254                class: s(),
1255                method: s(),
1256            },
1257            IssueKind::UnusedProperty {
1258                class: s(),
1259                property: s(),
1260            },
1261            IssueKind::UnusedFunction { name: s() },
1262            IssueKind::ReadonlyPropertyAssignment {
1263                class: s(),
1264                property: s(),
1265            },
1266            IssueKind::UnimplementedAbstractMethod {
1267                class: s(),
1268                method: s(),
1269            },
1270            IssueKind::UnimplementedInterfaceMethod {
1271                class: s(),
1272                interface: s(),
1273                method: s(),
1274            },
1275            IssueKind::MethodSignatureMismatch {
1276                class: s(),
1277                method: s(),
1278                detail: s(),
1279            },
1280            IssueKind::OverriddenMethodAccess {
1281                class: s(),
1282                method: s(),
1283            },
1284            IssueKind::FinalClassExtended {
1285                parent: s(),
1286                child: s(),
1287            },
1288            IssueKind::FinalMethodOverridden {
1289                class: s(),
1290                method: s(),
1291                parent: s(),
1292            },
1293            IssueKind::AbstractInstantiation { class: s() },
1294            IssueKind::CircularInheritance { class: s() },
1295            IssueKind::TaintedInput { sink: s() },
1296            IssueKind::TaintedHtml,
1297            IssueKind::TaintedSql,
1298            IssueKind::TaintedShell,
1299            IssueKind::InvalidTemplateParam {
1300                name: s(),
1301                expected_bound: s(),
1302                actual: s(),
1303            },
1304            IssueKind::ShadowedTemplateParam { name: s() },
1305            IssueKind::DeprecatedCall {
1306                name: s(),
1307                message: None,
1308            },
1309            IssueKind::DeprecatedMethodCall {
1310                class: s(),
1311                method: s(),
1312                message: None,
1313            },
1314            IssueKind::DeprecatedMethod {
1315                class: s(),
1316                method: s(),
1317                message: None,
1318            },
1319            IssueKind::DeprecatedClass {
1320                name: s(),
1321                message: None,
1322            },
1323            IssueKind::InternalMethod {
1324                class: s(),
1325                method: s(),
1326            },
1327            IssueKind::MissingReturnType { fn_name: s() },
1328            IssueKind::MissingParamType {
1329                fn_name: s(),
1330                param: s(),
1331            },
1332            IssueKind::MissingThrowsDocblock { class: s() },
1333            IssueKind::InvalidDocblock { message: s() },
1334            IssueKind::MixedArgument {
1335                param: s(),
1336                fn_name: s(),
1337            },
1338            IssueKind::MixedAssignment { var: s() },
1339            IssueKind::MixedMethodCall { method: s() },
1340            IssueKind::MixedPropertyFetch { property: s() },
1341            IssueKind::MixedClone,
1342            IssueKind::InvalidTraitUse {
1343                trait_name: s(),
1344                reason: s(),
1345            },
1346            IssueKind::ParseError { message: s() },
1347            IssueKind::InvalidThrow { ty: s() },
1348            IssueKind::ImplicitToStringCast { class: s() },
1349            IssueKind::ImplicitFloatToIntCast { from: s() },
1350        ]
1351    }
1352
1353    #[test]
1354    fn codes_have_expected_shape() {
1355        for kind in one_of_each() {
1356            let code = kind.code();
1357            assert!(
1358                code.len() == 7
1359                    && code.starts_with("MIR")
1360                    && code[3..].chars().all(|c| c.is_ascii_digit()),
1361                "code {code:?} for {} does not match MIR####",
1362                kind.name(),
1363            );
1364        }
1365    }
1366
1367    #[test]
1368    fn codes_are_unique() {
1369        let kinds = one_of_each();
1370        let mut seen: HashSet<&'static str> = HashSet::new();
1371        for kind in &kinds {
1372            assert!(
1373                seen.insert(kind.code()),
1374                "duplicate code {} (variant {})",
1375                kind.code(),
1376                kind.name(),
1377            );
1378        }
1379    }
1380
1381    #[test]
1382    fn display_includes_code() {
1383        let issue = Issue::new(
1384            IssueKind::UndefinedClass {
1385                name: "Foo".to_string(),
1386            },
1387            Location {
1388                file: Arc::from("src/x.php"),
1389                line: 1,
1390                line_end: 1,
1391                col_start: 0,
1392                col_end: 3,
1393            },
1394        );
1395        // Strip ANSI escape sequences so the assertion isn't dependent on
1396        // owo-colors' tty detection.
1397        let raw = format!("{issue}");
1398        let stripped: String = {
1399            let mut out = String::new();
1400            let mut chars = raw.chars();
1401            while let Some(c) = chars.next() {
1402                if c == '\u{1b}' {
1403                    for c2 in chars.by_ref() {
1404                        if c2 == 'm' {
1405                            break;
1406                        }
1407                    }
1408                } else {
1409                    out.push(c);
1410                }
1411            }
1412            out
1413        };
1414        assert!(
1415            stripped.contains("error[MIR0005] UndefinedClass:"),
1416            "Display output missing code/name segment: {stripped:?}",
1417        );
1418    }
1419
1420    /// Guards against forgetting to add a new variant to `one_of_each()`.
1421    /// If you add a variant, add it to `one_of_each()` *and* bump this count.
1422    #[test]
1423    fn one_of_each_has_every_variant() {
1424        // 77 = current variant count. If this assertion fires after you added
1425        // a new variant, also add it to `one_of_each()` so the uniqueness
1426        // and shape tests cover it.
1427        assert_eq!(one_of_each().len(), 77);
1428    }
1429}