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