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