Skip to main content

mago_reporting/
lib.rs

1#![allow(clippy::pub_use, clippy::exhaustive_enums)]
2
3//! Issue reporting and formatting for Mago.
4//!
5//! This crate provides functionality for reporting code issues identified by the linter and analyzer.
6//! It includes support for multiple output formats, baseline filtering, and rich terminal output.
7//!
8//! # Core Types
9//!
10//! - [`Issue`]: Represents a single code issue with severity level, annotations, and optional fixes
11//! - [`IssueCollection`]: A collection of issues with filtering and sorting capabilities
12//! - [`reporter::Reporter`]: Handles formatting and outputting issues in various formats
13//! - [`baseline::Baseline`]: Manages baseline files to filter out known issues
14
15use std::cmp::Ordering;
16use std::iter::Once;
17use std::str::FromStr;
18
19use foldhash::HashMap;
20use foldhash::HashMapExt;
21use regex::Regex;
22use schemars::JsonSchema;
23use strum::Display;
24use strum::VariantNames;
25
26use mago_database::GlobSettings;
27use mago_database::file::FileId;
28use mago_database::matcher::ExclusionMatcher;
29use mago_span::Span;
30use mago_text_edit::TextEdit;
31
32mod formatter;
33#[cfg(feature = "serde")]
34mod internal;
35
36pub mod baseline;
37pub mod color;
38pub mod error;
39pub mod output;
40pub mod reporter;
41
42pub use color::ColorChoice;
43pub use formatter::ReportingFormat;
44pub use formatter::utils::osc8_file_hyperlink;
45pub use formatter::utils::osc8_hyperlink;
46pub use output::ReportingTarget;
47
48/// Represents an entry in the analyzer's `ignore` configuration.
49///
50/// One of three shapes:
51///
52/// * A plain code string ignored everywhere: `"code1"`.
53/// * A code scoped to one or more paths/globs:
54///   `{ code = "code2", in = ["tests/", "src/**/*.php"] }`.
55/// * A regex pattern matched against the issue's textual content
56///   (title, notes, help, and annotation messages), optionally narrowed
57///   by `code` and/or `in`:
58///   `{ pattern = "Symfony", code = "mixed-assignment" }`.
59///
60/// Path entries accept both plain directory/file prefixes (e.g. `"tests/"`,
61/// `"src/Legacy.php"`) and glob patterns (e.g. `"src/**/*.php"`); entries
62/// containing any of `*`, `?`, `[`, `{` are matched with [`ExclusionMatcher`].
63///
64/// The `pattern` field is a [bare Rust regex](https://docs.rs/regex/) — use
65/// `(?i)` for case-insensitive matching. No surrounding delimiters.
66#[derive(Debug, Clone, PartialEq, Eq, JsonSchema)]
67#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
68#[cfg_attr(feature = "serde", serde(untagged))]
69pub enum IgnoreEntry {
70    /// Ignore a code everywhere: `"code1"`
71    Code(String),
72    /// Ignore a code in specific paths or glob patterns:
73    /// `{ code = "code2", in = ["tests/", "src/**/*.php"] }`
74    Scoped {
75        code: String,
76        #[cfg_attr(feature = "serde", serde(rename = "in", deserialize_with = "one_or_many"))]
77        paths: Vec<String>,
78    },
79    /// Ignore by regex against issue text, with optional code and path scoping:
80    /// `{ pattern = "Symfony", code = "mixed-assignment", in = ["src/Bridge/"] }`.
81    Pattern {
82        /// A bare regex tested against the issue's title, annotation messages,
83        /// notes, and help message, in that order. First match short-circuits.
84        /// The most instance-specific text is searched first (title,
85        /// annotations); notes and help are tested last because they are
86        /// typically templated per rule.
87        pattern: String,
88        /// Optional code to narrow the match. When set, only issues with this
89        /// code are tested against the pattern.
90        #[cfg_attr(feature = "serde", serde(default, skip_serializing_if = "Option::is_none"))]
91        code: Option<String>,
92        /// Optional paths/globs to narrow the match.
93        #[cfg_attr(
94            feature = "serde",
95            serde(
96                rename = "in",
97                default,
98                skip_serializing_if = "Option::is_none",
99                deserialize_with = "opt_one_or_many"
100            )
101        )]
102        paths: Option<Vec<String>>,
103    },
104}
105
106#[cfg(feature = "serde")]
107fn one_or_many<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
108where
109    D: serde::Deserializer<'de>,
110{
111    #[cfg_attr(feature = "serde", derive(serde::Deserialize))]
112    #[cfg_attr(feature = "serde", serde(untagged))]
113    enum OneOrMany {
114        One(String),
115        Many(Vec<String>),
116    }
117
118    match <OneOrMany as serde::Deserialize>::deserialize(deserializer)? {
119        OneOrMany::One(s) => Ok(vec![s]),
120        OneOrMany::Many(v) => Ok(v),
121    }
122}
123
124#[cfg(feature = "serde")]
125fn opt_one_or_many<'de, D>(deserializer: D) -> Result<Option<Vec<String>>, D::Error>
126where
127    D: serde::Deserializer<'de>,
128{
129    Ok(Some(one_or_many(deserializer)?))
130}
131
132/// Pre-compiled ignore entries ready for use by [`IssueCollection::filter_out_ignored`].
133///
134/// Build once per analysis (regex compilation and glob building are non-trivial),
135/// then reuse across watch-mode rebuilds and LSP analyses. Entries with invalid
136/// regex or invalid glob patterns are logged and skipped — a bad config line
137/// silently drops that entry rather than crashing the run.
138#[derive(Debug, Default)]
139pub struct CompiledIgnoreSet {
140    entries: Vec<CompiledIgnoreEntry>,
141}
142
143#[derive(Debug)]
144enum CompiledIgnoreEntry {
145    Code(String),
146    Scoped { code: String, matcher: ExclusionMatcher<String> },
147    Pattern { regex: Regex, code: Option<String>, matcher: Option<ExclusionMatcher<String>> },
148}
149
150/// Represents the kind of annotation associated with an issue.
151#[derive(Debug, PartialEq, Eq, Ord, Copy, Clone, Hash, PartialOrd)]
152#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
153pub enum AnnotationKind {
154    /// A primary annotation, typically highlighting the main source of the issue.
155    Primary,
156    /// A secondary annotation, providing additional context or related information.
157    Secondary,
158}
159
160/// An annotation associated with an issue, providing additional context or highlighting specific code spans.
161#[derive(Debug, PartialEq, Eq, Ord, Clone, Hash, PartialOrd)]
162#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
163pub struct Annotation {
164    /// An optional message associated with the annotation.
165    pub message: Option<String>,
166    /// The kind of annotation.
167    pub kind: AnnotationKind,
168    /// The code span that the annotation refers to.
169    pub span: Span,
170}
171
172/// Represents the severity level of an issue.
173#[derive(Debug, PartialEq, Eq, Ord, Copy, Clone, Hash, PartialOrd, Display, VariantNames, JsonSchema)]
174#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
175#[strum(serialize_all = "lowercase")]
176pub enum Level {
177    /// A note, providing additional information or context.
178    #[cfg_attr(feature = "serde", serde(alias = "note"))]
179    Note,
180    /// A help message, suggesting possible solutions or further actions.
181    #[cfg_attr(feature = "serde", serde(alias = "help"))]
182    Help,
183    /// A warning, indicating a potential problem that may need attention.
184    #[cfg_attr(feature = "serde", serde(alias = "warning", alias = "warn"))]
185    Warning,
186    /// An error, indicating a problem that prevents the code from functioning correctly.
187    #[cfg_attr(feature = "serde", serde(alias = "error", alias = "err"))]
188    Error,
189}
190
191impl FromStr for Level {
192    type Err = ();
193
194    fn from_str(s: &str) -> Result<Self, Self::Err> {
195        match s.to_lowercase().as_str() {
196            "note" => Ok(Self::Note),
197            "help" => Ok(Self::Help),
198            "warning" => Ok(Self::Warning),
199            "error" => Ok(Self::Error),
200            _ => Err(()),
201        }
202    }
203}
204
205type IssueEdits = Vec<TextEdit>;
206type IssueEditBatches = Vec<(Option<String>, IssueEdits)>;
207
208/// Represents an issue identified in the code.
209#[derive(Debug, Clone, Eq, PartialEq)]
210#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
211pub struct Issue {
212    /// The severity level of the issue.
213    pub level: Level,
214    /// An optional code associated with the issue.
215    pub code: Option<String>,
216    /// The main message describing the issue.
217    pub message: String,
218    /// Additional notes related to the issue.
219    pub notes: Vec<String>,
220    /// An optional help message suggesting possible solutions or further actions.
221    pub help: Option<String>,
222    /// An optional link to external resources for more information about the issue.
223    pub link: Option<String>,
224    /// Annotations associated with the issue, providing additional context or highlighting specific code spans.
225    pub annotations: Vec<Annotation>,
226    /// Text edits that can be applied to fix the issue, grouped by file.
227    pub edits: HashMap<FileId, IssueEdits>,
228}
229
230/// A collection of issues.
231#[derive(Debug, Clone, Eq, PartialEq)]
232#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
233pub struct IssueCollection {
234    issues: Vec<Issue>,
235}
236
237impl AnnotationKind {
238    /// Returns `true` if this annotation kind is primary.
239    #[inline]
240    #[must_use]
241    pub const fn is_primary(&self) -> bool {
242        matches!(self, AnnotationKind::Primary)
243    }
244
245    /// Returns `true` if this annotation kind is secondary.
246    #[inline]
247    #[must_use]
248    pub const fn is_secondary(&self) -> bool {
249        matches!(self, AnnotationKind::Secondary)
250    }
251}
252
253impl CompiledIgnoreSet {
254    /// Compiles the given ignore entries into a reusable matcher set.
255    ///
256    /// Bad regex/glob entries are reported via `tracing::error!` and skipped;
257    /// the returned set still contains the valid entries.
258    #[must_use]
259    pub fn compile(entries: &[IgnoreEntry], glob: GlobSettings) -> Self {
260        let mut compiled = Vec::with_capacity(entries.len());
261        for entry in entries {
262            match entry {
263                IgnoreEntry::Code(code) => compiled.push(CompiledIgnoreEntry::Code(code.clone())),
264                IgnoreEntry::Scoped { code, paths } => match ExclusionMatcher::compile(paths.iter().cloned(), glob) {
265                    Ok(matcher) => compiled.push(CompiledIgnoreEntry::Scoped { code: code.clone(), matcher }),
266                    Err(err) => {
267                        tracing::error!("Failed to compile ignore patterns for `{code}`: {err}. Entry will be skipped.")
268                    }
269                },
270                IgnoreEntry::Pattern { pattern, code, paths } => {
271                    let regex = match Regex::new(pattern) {
272                        Ok(regex) => regex,
273                        Err(err) => {
274                            tracing::error!(
275                                "Failed to compile ignore regex `{pattern}`: {err}. Entry will be skipped."
276                            );
277
278                            continue;
279                        }
280                    };
281
282                    let matcher = match paths {
283                        Some(paths) => match ExclusionMatcher::compile(paths.iter().cloned(), glob) {
284                            Ok(matcher) => Some(matcher),
285                            Err(err) => {
286                                tracing::error!(
287                                    "Failed to compile ignore paths for regex `{pattern}`: {err}. Entry will be skipped."
288                                );
289
290                                continue;
291                            }
292                        },
293                        None => None,
294                    };
295
296                    compiled.push(CompiledIgnoreEntry::Pattern { regex, code: code.clone(), matcher });
297                }
298            }
299        }
300
301        Self { entries: compiled }
302    }
303
304    #[must_use]
305    pub fn is_empty(&self) -> bool {
306        self.entries.is_empty()
307    }
308
309    #[must_use]
310    pub fn len(&self) -> usize {
311        self.entries.len()
312    }
313}
314
315impl Annotation {
316    /// Creates a new annotation with the given kind and span.
317    ///
318    /// # Examples
319    ///
320    /// ```
321    /// use mago_reporting::{Annotation, AnnotationKind};
322    /// use mago_database::file::FileId;
323    /// use mago_span::Span;
324    /// use mago_span::Position;
325    ///
326    /// let file = FileId::zero();
327    /// let start = Position::new(0);
328    /// let end = Position::new(5);
329    /// let span = Span::new(file, start, end);
330    /// let annotation = Annotation::new(AnnotationKind::Primary, span);
331    /// ```
332    #[must_use]
333    pub fn new(kind: AnnotationKind, span: Span) -> Self {
334        Self { message: None, kind, span }
335    }
336
337    /// Creates a new primary annotation with the given span.
338    ///
339    /// # Examples
340    ///
341    /// ```
342    /// use mago_reporting::{Annotation, AnnotationKind};
343    /// use mago_database::file::FileId;
344    /// use mago_span::Span;
345    /// use mago_span::Position;
346    ///
347    /// let file = FileId::zero();
348    /// let start = Position::new(0);
349    /// let end = Position::new(5);
350    /// let span = Span::new(file, start, end);
351    /// let annotation = Annotation::primary(span);
352    /// ```
353    #[must_use]
354    pub fn primary(span: Span) -> Self {
355        Self::new(AnnotationKind::Primary, span)
356    }
357
358    /// Creates a new secondary annotation with the given span.
359    ///
360    /// # Examples
361    ///
362    /// ```
363    /// use mago_reporting::{Annotation, AnnotationKind};
364    /// use mago_database::file::FileId;
365    /// use mago_span::Span;
366    /// use mago_span::Position;
367    ///
368    /// let file = FileId::zero();
369    /// let start = Position::new(0);
370    /// let end = Position::new(5);
371    /// let span = Span::new(file, start, end);
372    /// let annotation = Annotation::secondary(span);
373    /// ```
374    #[must_use]
375    pub fn secondary(span: Span) -> Self {
376        Self::new(AnnotationKind::Secondary, span)
377    }
378
379    /// Sets the message of this annotation.
380    ///
381    /// # Examples
382    ///
383    /// ```
384    /// use mago_reporting::{Annotation, AnnotationKind};
385    /// use mago_database::file::FileId;
386    /// use mago_span::Span;
387    /// use mago_span::Position;
388    ///
389    /// let file = FileId::zero();
390    /// let start = Position::new(0);
391    /// let end = Position::new(5);
392    /// let span = Span::new(file, start, end);
393    /// let annotation = Annotation::primary(span).with_message("This is a primary annotation");
394    /// ```
395    #[must_use]
396    pub fn with_message(mut self, message: impl Into<String>) -> Self {
397        self.message = Some(message.into());
398
399        self
400    }
401
402    /// Returns `true` if this annotation is a primary annotation.
403    #[must_use]
404    pub fn is_primary(&self) -> bool {
405        self.kind == AnnotationKind::Primary
406    }
407}
408
409impl Level {
410    /// Downgrades the level to the next lower severity.
411    ///
412    /// This function maps levels to their less severe counterparts:
413    ///
414    /// - `Error` becomes `Warning`
415    /// - `Warning` becomes `Help`
416    /// - `Help` becomes `Note`
417    /// - `Note` remains as `Note`
418    ///
419    /// # Examples
420    ///
421    /// ```
422    /// use mago_reporting::Level;
423    ///
424    /// let level = Level::Error;
425    /// assert_eq!(level.downgrade(), Level::Warning);
426    ///
427    /// let level = Level::Warning;
428    /// assert_eq!(level.downgrade(), Level::Help);
429    ///
430    /// let level = Level::Help;
431    /// assert_eq!(level.downgrade(), Level::Note);
432    ///
433    /// let level = Level::Note;
434    /// assert_eq!(level.downgrade(), Level::Note);
435    /// ```
436    #[must_use]
437    pub fn downgrade(&self) -> Self {
438        match self {
439            Level::Error => Level::Warning,
440            Level::Warning => Level::Help,
441            Level::Help | Level::Note => Level::Note,
442        }
443    }
444}
445
446impl Issue {
447    /// Creates a new issue with the given level and message.
448    ///
449    /// # Examples
450    ///
451    /// ```
452    /// use mago_reporting::{Issue, Level};
453    ///
454    /// let issue = Issue::new(Level::Error, "This is an error");
455    /// ```
456    pub fn new(level: Level, message: impl Into<String>) -> Self {
457        Self {
458            level,
459            code: None,
460            message: message.into(),
461            annotations: Vec::new(),
462            notes: Vec::new(),
463            help: None,
464            link: None,
465            edits: HashMap::default(),
466        }
467    }
468
469    /// Creates a new error issue with the given message.
470    ///
471    /// # Examples
472    ///
473    /// ```
474    /// use mago_reporting::Issue;
475    ///
476    /// let issue = Issue::error("This is an error");
477    /// ```
478    pub fn error(message: impl Into<String>) -> Self {
479        Self::new(Level::Error, message)
480    }
481
482    /// Creates a new warning issue with the given message.
483    ///
484    /// # Examples
485    ///
486    /// ```
487    /// use mago_reporting::Issue;
488    ///
489    /// let issue = Issue::warning("This is a warning");
490    /// ```
491    pub fn warning(message: impl Into<String>) -> Self {
492        Self::new(Level::Warning, message)
493    }
494
495    /// Creates a new help issue with the given message.
496    ///
497    /// # Examples
498    ///
499    /// ```
500    /// use mago_reporting::Issue;
501    ///
502    /// let issue = Issue::help("This is a help message");
503    /// ```
504    pub fn help(message: impl Into<String>) -> Self {
505        Self::new(Level::Help, message)
506    }
507
508    /// Creates a new note issue with the given message.
509    ///
510    /// # Examples
511    ///
512    /// ```
513    /// use mago_reporting::Issue;
514    ///
515    /// let issue = Issue::note("This is a note");
516    /// ```
517    pub fn note(message: impl Into<String>) -> Self {
518        Self::new(Level::Note, message)
519    }
520
521    /// Adds a code to this issue.
522    ///
523    /// # Examples
524    ///
525    /// ```
526    /// use mago_reporting::{Issue, Level};
527    ///
528    /// let issue = Issue::error("This is an error").with_code("E0001");
529    /// ```
530    #[must_use]
531    pub fn with_code(mut self, code: impl Into<String>) -> Self {
532        self.code = Some(code.into());
533
534        self
535    }
536
537    /// Add an annotation to this issue.
538    ///
539    /// # Examples
540    ///
541    /// ```
542    /// use mago_reporting::{Issue, Annotation, AnnotationKind};
543    /// use mago_database::file::FileId;
544    /// use mago_span::Span;
545    /// use mago_span::Position;
546    ///
547    /// let file = FileId::zero();
548    /// let start = Position::new(0);
549    /// let end = Position::new(5);
550    /// let span = Span::new(file, start, end);
551    ///
552    /// let issue = Issue::error("This is an error").with_annotation(Annotation::primary(span));
553    /// ```
554    #[must_use]
555    pub fn with_annotation(mut self, annotation: Annotation) -> Self {
556        self.annotations.push(annotation);
557
558        self
559    }
560
561    #[must_use]
562    pub fn with_annotations(mut self, annotation: impl IntoIterator<Item = Annotation>) -> Self {
563        self.annotations.extend(annotation);
564
565        self
566    }
567
568    /// Returns the deterministic primary annotation for this issue.
569    ///
570    /// If multiple primary annotations exist, the one with the smallest span is returned.
571    #[must_use]
572    pub fn primary_annotation(&self) -> Option<&Annotation> {
573        self.annotations.iter().filter(|annotation| annotation.is_primary()).min_by_key(|annotation| annotation.span)
574    }
575
576    /// Returns the deterministic primary span for this issue.
577    #[must_use]
578    pub fn primary_span(&self) -> Option<Span> {
579        self.primary_annotation().map(|annotation| annotation.span)
580    }
581
582    /// Add a note to this issue.
583    ///
584    /// # Examples
585    ///
586    /// ```
587    /// use mago_reporting::Issue;
588    ///
589    /// let issue = Issue::error("This is an error").with_note("This is a note");
590    /// ```
591    #[must_use]
592    pub fn with_note(mut self, note: impl Into<String>) -> Self {
593        self.notes.push(note.into());
594
595        self
596    }
597
598    /// Add a help message to this issue.
599    ///
600    /// This is useful for providing additional context to the user on how to resolve the issue.
601    ///
602    /// # Examples
603    ///
604    /// ```
605    /// use mago_reporting::Issue;
606    ///
607    /// let issue = Issue::error("This is an error").with_help("This is a help message");
608    /// ```
609    #[must_use]
610    pub fn with_help(mut self, help: impl Into<String>) -> Self {
611        self.help = Some(help.into());
612
613        self
614    }
615
616    /// Add a link to this issue.
617    ///
618    /// # Examples
619    ///
620    /// ```
621    /// use mago_reporting::Issue;
622    ///
623    /// let issue = Issue::error("This is an error").with_link("https://example.com");
624    /// ```
625    #[must_use]
626    pub fn with_link(mut self, link: impl Into<String>) -> Self {
627        self.link = Some(link.into());
628
629        self
630    }
631
632    /// Add a single edit to this issue.
633    #[must_use]
634    pub fn with_edit(mut self, file_id: FileId, edit: TextEdit) -> Self {
635        self.edits.entry(file_id).or_default().push(edit);
636
637        self
638    }
639
640    /// Add multiple edits to this issue.
641    #[must_use]
642    pub fn with_file_edits(mut self, file_id: FileId, edits: IssueEdits) -> Self {
643        if !edits.is_empty() {
644            self.edits.entry(file_id).or_default().extend(edits);
645        }
646
647        self
648    }
649
650    /// Take the edits from this issue.
651    #[must_use]
652    pub fn take_edits(&mut self) -> HashMap<FileId, IssueEdits> {
653        std::mem::replace(&mut self.edits, HashMap::new())
654    }
655}
656
657impl IssueCollection {
658    #[must_use]
659    pub fn new() -> Self {
660        Self { issues: Vec::new() }
661    }
662
663    pub fn from(issues: impl IntoIterator<Item = Issue>) -> Self {
664        Self { issues: issues.into_iter().collect() }
665    }
666
667    pub fn push(&mut self, issue: Issue) {
668        self.issues.push(issue);
669    }
670
671    pub fn extend(&mut self, issues: impl IntoIterator<Item = Issue>) {
672        self.issues.extend(issues);
673    }
674
675    pub fn reserve(&mut self, additional: usize) {
676        self.issues.reserve(additional);
677    }
678
679    pub fn shrink_to_fit(&mut self) {
680        self.issues.shrink_to_fit();
681    }
682
683    #[must_use]
684    pub fn is_empty(&self) -> bool {
685        self.issues.is_empty()
686    }
687
688    #[must_use]
689    pub fn len(&self) -> usize {
690        self.issues.len()
691    }
692
693    /// Filters the issues in the collection to only include those with a severity level
694    /// lower than or equal to the given level.
695    #[must_use]
696    pub fn with_maximum_level(self, level: Level) -> Self {
697        Self { issues: self.issues.into_iter().filter(|issue| issue.level <= level).collect() }
698    }
699
700    /// Filters the issues in the collection to only include those with a severity level
701    ///  higher than or equal to the given level.
702    #[must_use]
703    pub fn with_minimum_level(self, level: Level) -> Self {
704        Self { issues: self.issues.into_iter().filter(|issue| issue.level >= level).collect() }
705    }
706
707    /// Returns `true` if the collection contains any issues with a severity level
708    ///  higher than or equal to the given level.
709    #[must_use]
710    pub fn has_minimum_level(&self, level: Level) -> bool {
711        self.issues.iter().any(|issue| issue.level >= level)
712    }
713
714    /// Returns the number of issues in the collection with the given severity level.
715    #[must_use]
716    pub fn get_level_count(&self, level: Level) -> usize {
717        self.issues.iter().filter(|issue| issue.level == level).count()
718    }
719
720    /// Returns the highest severity level of the issues in the collection.
721    #[must_use]
722    pub fn get_highest_level(&self) -> Option<Level> {
723        self.issues.iter().map(|issue| issue.level).max()
724    }
725
726    /// Returns the lowest severity level of the issues in the collection.
727    #[must_use]
728    pub fn get_lowest_level(&self) -> Option<Level> {
729        self.issues.iter().map(|issue| issue.level).min()
730    }
731
732    pub fn filter_out_ignored<F>(&mut self, set: &CompiledIgnoreSet, resolve_file_name: F)
733    where
734        F: Fn(FileId) -> Option<String>,
735    {
736        if set.is_empty() {
737            return;
738        }
739
740        self.issues.retain(|issue| {
741            let mut cached_path: Option<Option<String>> = None;
742            let mut resolve_path = |issue: &Issue| -> Option<String> {
743                cached_path
744                    .get_or_insert_with(|| issue.primary_span().and_then(|span| resolve_file_name(span.file_id)))
745                    .clone()
746            };
747
748            for entry in &set.entries {
749                match entry {
750                    CompiledIgnoreEntry::Code(ignored_code) => {
751                        if let Some(code) = &issue.code
752                            && ignored_code == code
753                        {
754                            return false;
755                        }
756                    }
757                    CompiledIgnoreEntry::Scoped { code: ignored_code, matcher } => {
758                        let Some(code) = &issue.code else {
759                            continue;
760                        };
761
762                        if ignored_code != code {
763                            continue;
764                        }
765
766                        if let Some(name) = resolve_path(issue)
767                            && matcher.is_match(&name)
768                        {
769                            return false;
770                        }
771                    }
772                    CompiledIgnoreEntry::Pattern { regex, code: ignored_code, matcher } => {
773                        if let Some(ignored_code) = ignored_code {
774                            let Some(code) = &issue.code else {
775                                continue;
776                            };
777
778                            if ignored_code != code {
779                                continue;
780                            }
781                        }
782
783                        if let Some(matcher) = matcher {
784                            let Some(name) = resolve_path(issue) else {
785                                continue;
786                            };
787
788                            if !matcher.is_match(&name) {
789                                continue;
790                            }
791                        }
792
793                        if issue_text_matches(issue, regex) {
794                            return false;
795                        }
796                    }
797                }
798            }
799
800            true
801        });
802    }
803
804    pub fn filter_retain_codes(&mut self, retain_codes: &[String]) {
805        self.issues.retain(|issue| if let Some(code) = &issue.code { retain_codes.contains(code) } else { false });
806    }
807
808    pub fn take_edits(&mut self) -> impl Iterator<Item = (FileId, IssueEdits)> + '_ {
809        self.issues.iter_mut().flat_map(|issue| issue.take_edits().into_iter())
810    }
811
812    /// Filters the issues in the collection to only include those that have associated edits.
813    #[must_use]
814    pub fn with_edits(self) -> Self {
815        Self { issues: self.issues.into_iter().filter(|issue| !issue.edits.is_empty()).collect() }
816    }
817
818    /// Sorts the issues in the collection.
819    ///
820    /// The issues are sorted by severity level in ascending order,
821    /// then by code in ascending order, and finally by the primary annotation span.
822    #[must_use]
823    pub fn sorted(self) -> Self {
824        let mut issues = self.issues;
825
826        issues.sort_by(|a, b| match a.level.cmp(&b.level) {
827            Ordering::Greater => Ordering::Greater,
828            Ordering::Less => Ordering::Less,
829            Ordering::Equal => match a.code.as_deref().cmp(&b.code.as_deref()) {
830                Ordering::Less => Ordering::Less,
831                Ordering::Greater => Ordering::Greater,
832                Ordering::Equal => {
833                    let a_span = a.primary_span();
834                    let b_span = b.primary_span();
835
836                    match (a_span, b_span) {
837                        (Some(a_span), Some(b_span)) => a_span.cmp(&b_span),
838                        (Some(_), None) => Ordering::Less,
839                        (None, Some(_)) => Ordering::Greater,
840                        (None, None) => Ordering::Equal,
841                    }
842                }
843            },
844        });
845
846        Self { issues }
847    }
848
849    pub fn iter(&self) -> impl Iterator<Item = &Issue> {
850        self.issues.iter()
851    }
852
853    /// Converts the collection into a map of edit batches grouped by file.
854    ///
855    /// Each batch contains all edits from a single issue along with the rule code.
856    /// All edits from an issue must be applied together as a batch to maintain code validity.
857    ///
858    /// Returns `HashMap<FileId, Vec<(Option<String>, IssueEdits)>>` where each tuple
859    /// is (rule_code, edits_for_that_issue).
860    #[must_use]
861    pub fn to_edit_batches(self) -> HashMap<FileId, IssueEditBatches> {
862        let mut result: HashMap<FileId, Vec<(Option<String>, IssueEdits)>> = HashMap::default();
863        for issue in self.issues.into_iter().filter(|issue| !issue.edits.is_empty()) {
864            let code = issue.code;
865            for (file_id, edit_list) in issue.edits {
866                result.entry(file_id).or_default().push((code.clone(), edit_list));
867            }
868        }
869
870        result
871    }
872}
873
874/// Returns `true` when any of the issue's textual fields matches the regex.
875///
876/// Tested in order: title, annotation messages, notes, help. The most
877/// instance-specific fields are searched first; notes and help are last
878/// because they are typically templated per rule.
879fn issue_text_matches(issue: &Issue, regex: &Regex) -> bool {
880    if regex.is_match(&issue.message) {
881        return true;
882    }
883
884    if issue
885        .annotations
886        .iter()
887        .any(|annotation| annotation.message.as_ref().is_some_and(|message| regex.is_match(message)))
888    {
889        return true;
890    }
891
892    if issue.notes.iter().any(|note| regex.is_match(note)) {
893        return true;
894    }
895
896    issue.help.as_ref().is_some_and(|help| regex.is_match(help))
897}
898
899impl IntoIterator for IssueCollection {
900    type Item = Issue;
901
902    type IntoIter = std::vec::IntoIter<Issue>;
903
904    fn into_iter(self) -> Self::IntoIter {
905        self.issues.into_iter()
906    }
907}
908
909impl<'collection> IntoIterator for &'collection IssueCollection {
910    type Item = &'collection Issue;
911
912    type IntoIter = std::slice::Iter<'collection, Issue>;
913
914    fn into_iter(self) -> Self::IntoIter {
915        self.issues.iter()
916    }
917}
918
919impl Default for IssueCollection {
920    fn default() -> Self {
921        Self::new()
922    }
923}
924
925impl IntoIterator for Issue {
926    type Item = Issue;
927    type IntoIter = Once<Issue>;
928
929    fn into_iter(self) -> Self::IntoIter {
930        std::iter::once(self)
931    }
932}
933
934impl FromIterator<Issue> for IssueCollection {
935    fn from_iter<T>(iter: T) -> Self
936    where
937        T: IntoIterator<Item = Issue>,
938    {
939        Self { issues: iter.into_iter().collect() }
940    }
941}
942
943#[cfg(test)]
944mod tests {
945    use std::collections::HashMap;
946
947    use super::*;
948
949    #[test]
950    pub fn test_highest_collection_level() {
951        let mut collection = IssueCollection::from(vec![]);
952        assert_eq!(collection.get_highest_level(), None);
953
954        collection.push(Issue::note("note"));
955        assert_eq!(collection.get_highest_level(), Some(Level::Note));
956
957        collection.push(Issue::help("help"));
958        assert_eq!(collection.get_highest_level(), Some(Level::Help));
959
960        collection.push(Issue::warning("warning"));
961        assert_eq!(collection.get_highest_level(), Some(Level::Warning));
962
963        collection.push(Issue::error("error"));
964        assert_eq!(collection.get_highest_level(), Some(Level::Error));
965    }
966
967    #[test]
968    pub fn test_level_downgrade() {
969        assert_eq!(Level::Error.downgrade(), Level::Warning);
970        assert_eq!(Level::Warning.downgrade(), Level::Help);
971        assert_eq!(Level::Help.downgrade(), Level::Note);
972        assert_eq!(Level::Note.downgrade(), Level::Note);
973    }
974
975    #[test]
976    pub fn test_issue_collection_with_maximum_level() {
977        let mut collection = IssueCollection::from(vec![
978            Issue::error("error"),
979            Issue::warning("warning"),
980            Issue::help("help"),
981            Issue::note("note"),
982        ]);
983
984        collection = collection.with_maximum_level(Level::Warning);
985        assert_eq!(collection.len(), 3);
986        assert_eq!(
987            collection.iter().map(|issue| issue.level).collect::<Vec<_>>(),
988            vec![Level::Warning, Level::Help, Level::Note]
989        );
990    }
991
992    #[test]
993    pub fn test_issue_collection_with_minimum_level() {
994        let mut collection = IssueCollection::from(vec![
995            Issue::error("error"),
996            Issue::warning("warning"),
997            Issue::help("help"),
998            Issue::note("note"),
999        ]);
1000
1001        collection = collection.with_minimum_level(Level::Warning);
1002        assert_eq!(collection.len(), 2);
1003        assert_eq!(collection.iter().map(|issue| issue.level).collect::<Vec<_>>(), vec![Level::Error, Level::Warning,]);
1004    }
1005
1006    #[test]
1007    pub fn test_issue_collection_has_minimum_level() {
1008        let mut collection = IssueCollection::from(vec![]);
1009
1010        assert!(!collection.has_minimum_level(Level::Error));
1011        assert!(!collection.has_minimum_level(Level::Warning));
1012        assert!(!collection.has_minimum_level(Level::Help));
1013        assert!(!collection.has_minimum_level(Level::Note));
1014
1015        collection.push(Issue::note("note"));
1016
1017        assert!(!collection.has_minimum_level(Level::Error));
1018        assert!(!collection.has_minimum_level(Level::Warning));
1019        assert!(!collection.has_minimum_level(Level::Help));
1020        assert!(collection.has_minimum_level(Level::Note));
1021
1022        collection.push(Issue::help("help"));
1023
1024        assert!(!collection.has_minimum_level(Level::Error));
1025        assert!(!collection.has_minimum_level(Level::Warning));
1026        assert!(collection.has_minimum_level(Level::Help));
1027        assert!(collection.has_minimum_level(Level::Note));
1028
1029        collection.push(Issue::warning("warning"));
1030
1031        assert!(!collection.has_minimum_level(Level::Error));
1032        assert!(collection.has_minimum_level(Level::Warning));
1033        assert!(collection.has_minimum_level(Level::Help));
1034        assert!(collection.has_minimum_level(Level::Note));
1035
1036        collection.push(Issue::error("error"));
1037
1038        assert!(collection.has_minimum_level(Level::Error));
1039        assert!(collection.has_minimum_level(Level::Warning));
1040        assert!(collection.has_minimum_level(Level::Help));
1041        assert!(collection.has_minimum_level(Level::Note));
1042    }
1043
1044    #[test]
1045    pub fn test_issue_collection_level_count() {
1046        let mut collection = IssueCollection::from(vec![]);
1047
1048        assert_eq!(collection.get_level_count(Level::Error), 0);
1049        assert_eq!(collection.get_level_count(Level::Warning), 0);
1050        assert_eq!(collection.get_level_count(Level::Help), 0);
1051        assert_eq!(collection.get_level_count(Level::Note), 0);
1052
1053        collection.push(Issue::error("error"));
1054
1055        assert_eq!(collection.get_level_count(Level::Error), 1);
1056        assert_eq!(collection.get_level_count(Level::Warning), 0);
1057        assert_eq!(collection.get_level_count(Level::Help), 0);
1058        assert_eq!(collection.get_level_count(Level::Note), 0);
1059
1060        collection.push(Issue::warning("warning"));
1061
1062        assert_eq!(collection.get_level_count(Level::Error), 1);
1063        assert_eq!(collection.get_level_count(Level::Warning), 1);
1064        assert_eq!(collection.get_level_count(Level::Help), 0);
1065        assert_eq!(collection.get_level_count(Level::Note), 0);
1066
1067        collection.push(Issue::help("help"));
1068
1069        assert_eq!(collection.get_level_count(Level::Error), 1);
1070        assert_eq!(collection.get_level_count(Level::Warning), 1);
1071        assert_eq!(collection.get_level_count(Level::Help), 1);
1072        assert_eq!(collection.get_level_count(Level::Note), 0);
1073
1074        collection.push(Issue::note("note"));
1075
1076        assert_eq!(collection.get_level_count(Level::Error), 1);
1077        assert_eq!(collection.get_level_count(Level::Warning), 1);
1078        assert_eq!(collection.get_level_count(Level::Help), 1);
1079        assert_eq!(collection.get_level_count(Level::Note), 1);
1080    }
1081
1082    #[test]
1083    pub fn test_primary_span_is_deterministic() {
1084        let file = FileId::zero();
1085        let span_later = Span::new(file, 20u32.into(), 25u32.into());
1086        let span_earlier = Span::new(file, 5u32.into(), 10u32.into());
1087
1088        let issue = Issue::error("x")
1089            .with_annotation(Annotation::primary(span_later))
1090            .with_annotation(Annotation::primary(span_earlier));
1091
1092        assert_eq!(issue.primary_span(), Some(span_earlier));
1093    }
1094
1095    fn ignore_fixture() -> (IssueCollection, HashMap<FileId, &'static [u8]>) {
1096        let file_id = |name: &[u8]| FileId::new(name);
1097
1098        let paths: [&[u8]; 4] =
1099            [b"src/App.php", b"tests/Unit/FooTest.php", b"modules/auth/views/login.tpl", b"types/user/form.tpl"];
1100
1101        let mut mapping = HashMap::new();
1102        let issues: Vec<Issue> = paths
1103            .iter()
1104            .map(|p| {
1105                let id = file_id(p);
1106                mapping.insert(id, *p);
1107                Issue::error("oops").with_code("invalid-global").with_annotation(Annotation::primary(Span::new(
1108                    id,
1109                    0u32.into(),
1110                    1u32.into(),
1111                )))
1112            })
1113            .collect();
1114
1115        (IssueCollection::from(issues), mapping)
1116    }
1117
1118    fn resolve<'mapping>(
1119        mapping: &'mapping HashMap<FileId, &'static [u8]>,
1120    ) -> impl Fn(FileId) -> Option<String> + 'mapping {
1121        move |id| mapping.get(&id).map(|s| String::from_utf8_lossy(s).into_owned())
1122    }
1123
1124    fn remaining_paths(collection: &IssueCollection, mapping: &HashMap<FileId, &'static [u8]>) -> Vec<String> {
1125        collection
1126            .iter()
1127            .filter_map(|issue| issue.primary_span().and_then(|s| mapping.get(&s.file_id)).copied())
1128            .map(|bytes| String::from_utf8_lossy(bytes).into_owned())
1129            .collect()
1130    }
1131
1132    #[test]
1133    pub fn test_filter_out_ignored_with_plain_prefix() {
1134        let (mut collection, mapping) = ignore_fixture();
1135        let entries =
1136            vec![IgnoreEntry::Scoped { code: "invalid-global".to_string(), paths: vec!["tests/".to_string()] }];
1137        let set = CompiledIgnoreSet::compile(&entries, GlobSettings::default());
1138
1139        collection.filter_out_ignored(&set, resolve(&mapping));
1140
1141        assert_eq!(
1142            remaining_paths(&collection, &mapping),
1143            vec![
1144                "src/App.php".to_string(),
1145                "modules/auth/views/login.tpl".to_string(),
1146                "types/user/form.tpl".to_string(),
1147            ]
1148        );
1149    }
1150
1151    #[test]
1152    pub fn test_filter_out_ignored_with_glob_pattern() {
1153        let (mut collection, mapping) = ignore_fixture();
1154        let entries = vec![IgnoreEntry::Scoped {
1155            code: "invalid-global".to_string(),
1156            paths: vec!["modules/*/*/*.tpl".to_string(), "types/*/*.tpl".to_string()],
1157        }];
1158        let set = CompiledIgnoreSet::compile(&entries, GlobSettings::default());
1159
1160        collection.filter_out_ignored(&set, resolve(&mapping));
1161
1162        assert_eq!(
1163            remaining_paths(&collection, &mapping),
1164            vec!["src/App.php".to_string(), "tests/Unit/FooTest.php".to_string()]
1165        );
1166    }
1167
1168    #[test]
1169    pub fn test_filter_out_ignored_mixes_plain_and_glob() {
1170        let (mut collection, mapping) = ignore_fixture();
1171        let entries = vec![IgnoreEntry::Scoped {
1172            code: "invalid-global".to_string(),
1173            paths: vec!["tests/".to_string(), "modules/*/*/*.tpl".to_string(), "types/*/*.tpl".to_string()],
1174        }];
1175        let set = CompiledIgnoreSet::compile(&entries, GlobSettings::default());
1176
1177        collection.filter_out_ignored(&set, resolve(&mapping));
1178
1179        assert_eq!(remaining_paths(&collection, &mapping), vec!["src/App.php".to_string()]);
1180    }
1181
1182    #[test]
1183    pub fn test_filter_out_ignored_respects_code_scope() {
1184        let (mut collection, mapping) = ignore_fixture();
1185        let entries = vec![IgnoreEntry::Scoped { code: "different-code".to_string(), paths: vec!["**/*".to_string()] }];
1186        let set = CompiledIgnoreSet::compile(&entries, GlobSettings::default());
1187
1188        collection.filter_out_ignored(&set, resolve(&mapping));
1189
1190        assert_eq!(collection.len(), 4);
1191    }
1192
1193    fn pattern_fixture() -> (IssueCollection, HashMap<FileId, &'static [u8]>) {
1194        let paths: [&[u8]; 3] = [b"src/App.php", b"src/Bridge/Symfony.php", b"tests/Unit/FooTest.php"];
1195        let mut mapping = HashMap::new();
1196        let mut issues: Vec<Issue> = Vec::new();
1197
1198        let id0 = FileId::new(blake3::hash(paths[0]).as_bytes());
1199        mapping.insert(id0, paths[0]);
1200        issues.push(
1201            Issue::error("Saw type `mixed` in Symfony bridge.")
1202                .with_code("mixed-assignment")
1203                .with_annotation(Annotation::primary(Span::new(id0, 0u32.into(), 1u32.into()))),
1204        );
1205
1206        let id1 = FileId::new(blake3::hash(paths[1]).as_bytes());
1207        mapping.insert(id1, paths[1]);
1208        issues.push(
1209            Issue::error("Could not infer a precise return type.")
1210                .with_code("mixed-assignment")
1211                .with_note("Originates from Symfony vendor stubs.")
1212                .with_annotation(Annotation::primary(Span::new(id1, 0u32.into(), 1u32.into()))),
1213        );
1214
1215        let id2 = FileId::new(blake3::hash(paths[2]).as_bytes());
1216        mapping.insert(id2, paths[2]);
1217        issues.push(
1218            Issue::error("Unused variable.")
1219                .with_code("unused-variable")
1220                .with_annotation(Annotation::primary(Span::new(id2, 0u32.into(), 1u32.into()))),
1221        );
1222
1223        (IssueCollection::from(issues), mapping)
1224    }
1225
1226    #[test]
1227    pub fn test_pattern_matches_title_and_note() {
1228        let (mut collection, mapping) = pattern_fixture();
1229        let entries = vec![IgnoreEntry::Pattern {
1230            pattern: "Symfony".to_string(),
1231            code: Some("mixed-assignment".to_string()),
1232            paths: None,
1233        }];
1234        let set = CompiledIgnoreSet::compile(&entries, GlobSettings::default());
1235
1236        collection.filter_out_ignored(&set, resolve(&mapping));
1237
1238        assert_eq!(remaining_paths(&collection, &mapping), vec!["tests/Unit/FooTest.php".to_string()]);
1239    }
1240
1241    #[test]
1242    pub fn test_pattern_without_code_matches_across_codes() {
1243        let (mut collection, mapping) = pattern_fixture();
1244        let entries = vec![IgnoreEntry::Pattern { pattern: "Symfony".to_string(), code: None, paths: None }];
1245        let set = CompiledIgnoreSet::compile(&entries, GlobSettings::default());
1246
1247        collection.filter_out_ignored(&set, resolve(&mapping));
1248
1249        assert_eq!(remaining_paths(&collection, &mapping), vec!["tests/Unit/FooTest.php".to_string()]);
1250    }
1251
1252    #[test]
1253    pub fn test_pattern_with_path_scope() {
1254        let (mut collection, mapping) = pattern_fixture();
1255        let entries = vec![IgnoreEntry::Pattern {
1256            pattern: "Symfony".to_string(),
1257            code: None,
1258            paths: Some(vec!["src/Bridge/".to_string()]),
1259        }];
1260        let set = CompiledIgnoreSet::compile(&entries, GlobSettings::default());
1261
1262        collection.filter_out_ignored(&set, resolve(&mapping));
1263
1264        assert_eq!(
1265            remaining_paths(&collection, &mapping),
1266            vec!["src/App.php".to_string(), "tests/Unit/FooTest.php".to_string()]
1267        );
1268    }
1269
1270    #[test]
1271    pub fn test_pattern_case_insensitive_with_flag() {
1272        let (mut collection, mapping) = pattern_fixture();
1273        let entries = vec![IgnoreEntry::Pattern { pattern: "(?i)symfony".to_string(), code: None, paths: None }];
1274        let set = CompiledIgnoreSet::compile(&entries, GlobSettings::default());
1275
1276        collection.filter_out_ignored(&set, resolve(&mapping));
1277
1278        assert_eq!(remaining_paths(&collection, &mapping), vec!["tests/Unit/FooTest.php".to_string()]);
1279    }
1280
1281    #[test]
1282    pub fn test_pattern_invalid_regex_is_skipped() {
1283        let (mut collection, mapping) = pattern_fixture();
1284        let entries = vec![
1285            IgnoreEntry::Pattern { pattern: "[unterminated".to_string(), code: None, paths: None },
1286            IgnoreEntry::Code("unused-variable".to_string()),
1287        ];
1288        let set = CompiledIgnoreSet::compile(&entries, GlobSettings::default());
1289
1290        assert_eq!(set.len(), 1);
1291
1292        collection.filter_out_ignored(&set, resolve(&mapping));
1293
1294        assert_eq!(
1295            remaining_paths(&collection, &mapping),
1296            vec!["src/App.php".to_string(), "src/Bridge/Symfony.php".to_string()]
1297        );
1298    }
1299
1300    #[test]
1301    pub fn test_pattern_matches_help_message() {
1302        let id = FileId::new(blake3::hash(b"src/foo.php").as_bytes());
1303        let mut mapping: HashMap<FileId, &'static [u8]> = HashMap::new();
1304        mapping.insert(id, &b"src/foo.php"[..]);
1305        let mut collection = IssueCollection::from(vec![
1306            Issue::error("Title.")
1307                .with_code("some-code")
1308                .with_help("Consider migrating off legacy Symfony bridge.")
1309                .with_annotation(Annotation::primary(Span::new(id, 0u32.into(), 1u32.into()))),
1310        ]);
1311
1312        let entries = vec![IgnoreEntry::Pattern { pattern: "Symfony".to_string(), code: None, paths: None }];
1313        let set = CompiledIgnoreSet::compile(&entries, GlobSettings::default());
1314
1315        collection.filter_out_ignored(&set, resolve(&mapping));
1316
1317        assert!(collection.is_empty());
1318    }
1319}