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