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 schemars::JsonSchema;
22use serde::Deserialize;
23use serde::Serialize;
24use strum::Display;
25use strum::VariantNames;
26
27use mago_database::GlobSettings;
28use mago_database::file::FileId;
29use mago_database::matcher::ExclusionMatcher;
30use mago_span::Span;
31use mago_text_edit::TextEdit;
32
33/// Represents an entry in the analyzer's `ignore` configuration.
34///
35/// Can be either a plain code string (ignored everywhere) or a scoped entry
36/// that only ignores a code in specific paths. Scoped paths accept both
37/// plain directory/file prefixes (e.g. `"tests/"`, `"src/Legacy.php"`) and
38/// glob patterns (e.g. `"src/**/*.php"`); entries
39/// containing any of `*`, `?`, `[`, `{` are matched with [`ExclusionMatcher`].
40#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
41#[serde(untagged)]
42pub enum IgnoreEntry {
43    /// Ignore a code everywhere: `"code1"`
44    Code(String),
45    /// Ignore a code in specific paths or glob patterns:
46    /// `{ code = "code2", in = ["tests/", "src/**/*.php"] }`
47    Scoped {
48        code: String,
49        #[serde(rename = "in", deserialize_with = "one_or_many")]
50        paths: Vec<String>,
51    },
52}
53
54fn one_or_many<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
55where
56    D: serde::Deserializer<'de>,
57{
58    #[derive(Deserialize)]
59    #[serde(untagged)]
60    enum OneOrMany {
61        One(String),
62        Many(Vec<String>),
63    }
64
65    match OneOrMany::deserialize(deserializer)? {
66        OneOrMany::One(s) => Ok(vec![s]),
67        OneOrMany::Many(v) => Ok(v),
68    }
69}
70
71mod formatter;
72mod internal;
73
74pub mod baseline;
75pub mod color;
76pub mod error;
77pub mod output;
78pub mod reporter;
79
80pub use color::ColorChoice;
81pub use formatter::ReportingFormat;
82pub use output::ReportingTarget;
83
84/// Represents the kind of annotation associated with an issue.
85#[derive(Debug, PartialEq, Eq, Ord, Copy, Clone, Hash, PartialOrd, Deserialize, Serialize)]
86pub enum AnnotationKind {
87    /// A primary annotation, typically highlighting the main source of the issue.
88    Primary,
89    /// A secondary annotation, providing additional context or related information.
90    Secondary,
91}
92
93/// An annotation associated with an issue, providing additional context or highlighting specific code spans.
94#[derive(Debug, PartialEq, Eq, Ord, Clone, Hash, PartialOrd, Deserialize, Serialize)]
95pub struct Annotation {
96    /// An optional message associated with the annotation.
97    pub message: Option<String>,
98    /// The kind of annotation.
99    pub kind: AnnotationKind,
100    /// The code span that the annotation refers to.
101    pub span: Span,
102}
103
104/// Represents the severity level of an issue.
105#[derive(
106    Debug, PartialEq, Eq, Ord, Copy, Clone, Hash, PartialOrd, Deserialize, Serialize, Display, VariantNames, JsonSchema,
107)]
108#[strum(serialize_all = "lowercase")]
109pub enum Level {
110    /// A note, providing additional information or context.
111    #[serde(alias = "note")]
112    Note,
113    /// A help message, suggesting possible solutions or further actions.
114    #[serde(alias = "help")]
115    Help,
116    /// A warning, indicating a potential problem that may need attention.
117    #[serde(alias = "warning", alias = "warn")]
118    Warning,
119    /// An error, indicating a problem that prevents the code from functioning correctly.
120    #[serde(alias = "error", alias = "err")]
121    Error,
122}
123
124impl FromStr for Level {
125    type Err = ();
126
127    fn from_str(s: &str) -> Result<Self, Self::Err> {
128        match s.to_lowercase().as_str() {
129            "note" => Ok(Self::Note),
130            "help" => Ok(Self::Help),
131            "warning" => Ok(Self::Warning),
132            "error" => Ok(Self::Error),
133            _ => Err(()),
134        }
135    }
136}
137
138type IssueEdits = Vec<TextEdit>;
139type IssueEditBatches = Vec<(Option<String>, IssueEdits)>;
140
141/// Represents an issue identified in the code.
142#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
143pub struct Issue {
144    /// The severity level of the issue.
145    pub level: Level,
146    /// An optional code associated with the issue.
147    pub code: Option<String>,
148    /// The main message describing the issue.
149    pub message: String,
150    /// Additional notes related to the issue.
151    pub notes: Vec<String>,
152    /// An optional help message suggesting possible solutions or further actions.
153    pub help: Option<String>,
154    /// An optional link to external resources for more information about the issue.
155    pub link: Option<String>,
156    /// Annotations associated with the issue, providing additional context or highlighting specific code spans.
157    pub annotations: Vec<Annotation>,
158    /// Text edits that can be applied to fix the issue, grouped by file.
159    pub edits: HashMap<FileId, IssueEdits>,
160}
161
162/// A collection of issues.
163#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
164pub struct IssueCollection {
165    issues: Vec<Issue>,
166}
167
168impl AnnotationKind {
169    /// Returns `true` if this annotation kind is primary.
170    #[inline]
171    #[must_use]
172    pub const fn is_primary(&self) -> bool {
173        matches!(self, AnnotationKind::Primary)
174    }
175
176    /// Returns `true` if this annotation kind is secondary.
177    #[inline]
178    #[must_use]
179    pub const fn is_secondary(&self) -> bool {
180        matches!(self, AnnotationKind::Secondary)
181    }
182}
183
184impl Annotation {
185    /// Creates a new annotation with the given kind and span.
186    ///
187    /// # Examples
188    ///
189    /// ```
190    /// use mago_reporting::{Annotation, AnnotationKind};
191    /// use mago_database::file::FileId;
192    /// use mago_span::Span;
193    /// use mago_span::Position;
194    ///
195    /// let file = FileId::zero();
196    /// let start = Position::new(0);
197    /// let end = Position::new(5);
198    /// let span = Span::new(file, start, end);
199    /// let annotation = Annotation::new(AnnotationKind::Primary, span);
200    /// ```
201    #[must_use]
202    pub fn new(kind: AnnotationKind, span: Span) -> Self {
203        Self { message: None, kind, span }
204    }
205
206    /// Creates a new primary annotation with the given span.
207    ///
208    /// # Examples
209    ///
210    /// ```
211    /// use mago_reporting::{Annotation, AnnotationKind};
212    /// use mago_database::file::FileId;
213    /// use mago_span::Span;
214    /// use mago_span::Position;
215    ///
216    /// let file = FileId::zero();
217    /// let start = Position::new(0);
218    /// let end = Position::new(5);
219    /// let span = Span::new(file, start, end);
220    /// let annotation = Annotation::primary(span);
221    /// ```
222    #[must_use]
223    pub fn primary(span: Span) -> Self {
224        Self::new(AnnotationKind::Primary, span)
225    }
226
227    /// Creates a new secondary annotation with the given span.
228    ///
229    /// # Examples
230    ///
231    /// ```
232    /// use mago_reporting::{Annotation, AnnotationKind};
233    /// use mago_database::file::FileId;
234    /// use mago_span::Span;
235    /// use mago_span::Position;
236    ///
237    /// let file = FileId::zero();
238    /// let start = Position::new(0);
239    /// let end = Position::new(5);
240    /// let span = Span::new(file, start, end);
241    /// let annotation = Annotation::secondary(span);
242    /// ```
243    #[must_use]
244    pub fn secondary(span: Span) -> Self {
245        Self::new(AnnotationKind::Secondary, span)
246    }
247
248    /// Sets the message of this annotation.
249    ///
250    /// # Examples
251    ///
252    /// ```
253    /// use mago_reporting::{Annotation, AnnotationKind};
254    /// use mago_database::file::FileId;
255    /// use mago_span::Span;
256    /// use mago_span::Position;
257    ///
258    /// let file = FileId::zero();
259    /// let start = Position::new(0);
260    /// let end = Position::new(5);
261    /// let span = Span::new(file, start, end);
262    /// let annotation = Annotation::primary(span).with_message("This is a primary annotation");
263    /// ```
264    #[must_use]
265    pub fn with_message(mut self, message: impl Into<String>) -> Self {
266        self.message = Some(message.into());
267
268        self
269    }
270
271    /// Returns `true` if this annotation is a primary annotation.
272    #[must_use]
273    pub fn is_primary(&self) -> bool {
274        self.kind == AnnotationKind::Primary
275    }
276}
277
278impl Level {
279    /// Downgrades the level to the next lower severity.
280    ///
281    /// This function maps levels to their less severe counterparts:
282    ///
283    /// - `Error` becomes `Warning`
284    /// - `Warning` becomes `Help`
285    /// - `Help` becomes `Note`
286    /// - `Note` remains as `Note`
287    ///
288    /// # Examples
289    ///
290    /// ```
291    /// use mago_reporting::Level;
292    ///
293    /// let level = Level::Error;
294    /// assert_eq!(level.downgrade(), Level::Warning);
295    ///
296    /// let level = Level::Warning;
297    /// assert_eq!(level.downgrade(), Level::Help);
298    ///
299    /// let level = Level::Help;
300    /// assert_eq!(level.downgrade(), Level::Note);
301    ///
302    /// let level = Level::Note;
303    /// assert_eq!(level.downgrade(), Level::Note);
304    /// ```
305    #[must_use]
306    pub fn downgrade(&self) -> Self {
307        match self {
308            Level::Error => Level::Warning,
309            Level::Warning => Level::Help,
310            Level::Help | Level::Note => Level::Note,
311        }
312    }
313}
314
315impl Issue {
316    /// Creates a new issue with the given level and message.
317    ///
318    /// # Examples
319    ///
320    /// ```
321    /// use mago_reporting::{Issue, Level};
322    ///
323    /// let issue = Issue::new(Level::Error, "This is an error");
324    /// ```
325    pub fn new(level: Level, message: impl Into<String>) -> Self {
326        Self {
327            level,
328            code: None,
329            message: message.into(),
330            annotations: Vec::new(),
331            notes: Vec::new(),
332            help: None,
333            link: None,
334            edits: HashMap::default(),
335        }
336    }
337
338    /// Creates a new error issue with the given message.
339    ///
340    /// # Examples
341    ///
342    /// ```
343    /// use mago_reporting::Issue;
344    ///
345    /// let issue = Issue::error("This is an error");
346    /// ```
347    pub fn error(message: impl Into<String>) -> Self {
348        Self::new(Level::Error, message)
349    }
350
351    /// Creates a new warning issue with the given message.
352    ///
353    /// # Examples
354    ///
355    /// ```
356    /// use mago_reporting::Issue;
357    ///
358    /// let issue = Issue::warning("This is a warning");
359    /// ```
360    pub fn warning(message: impl Into<String>) -> Self {
361        Self::new(Level::Warning, message)
362    }
363
364    /// Creates a new help issue with the given message.
365    ///
366    /// # Examples
367    ///
368    /// ```
369    /// use mago_reporting::Issue;
370    ///
371    /// let issue = Issue::help("This is a help message");
372    /// ```
373    pub fn help(message: impl Into<String>) -> Self {
374        Self::new(Level::Help, message)
375    }
376
377    /// Creates a new note issue with the given message.
378    ///
379    /// # Examples
380    ///
381    /// ```
382    /// use mago_reporting::Issue;
383    ///
384    /// let issue = Issue::note("This is a note");
385    /// ```
386    pub fn note(message: impl Into<String>) -> Self {
387        Self::new(Level::Note, message)
388    }
389
390    /// Adds a code to this issue.
391    ///
392    /// # Examples
393    ///
394    /// ```
395    /// use mago_reporting::{Issue, Level};
396    ///
397    /// let issue = Issue::error("This is an error").with_code("E0001");
398    /// ```
399    #[must_use]
400    pub fn with_code(mut self, code: impl Into<String>) -> Self {
401        self.code = Some(code.into());
402
403        self
404    }
405
406    /// Add an annotation to this issue.
407    ///
408    /// # Examples
409    ///
410    /// ```
411    /// use mago_reporting::{Issue, Annotation, AnnotationKind};
412    /// use mago_database::file::FileId;
413    /// use mago_span::Span;
414    /// use mago_span::Position;
415    ///
416    /// let file = FileId::zero();
417    /// let start = Position::new(0);
418    /// let end = Position::new(5);
419    /// let span = Span::new(file, start, end);
420    ///
421    /// let issue = Issue::error("This is an error").with_annotation(Annotation::primary(span));
422    /// ```
423    #[must_use]
424    pub fn with_annotation(mut self, annotation: Annotation) -> Self {
425        self.annotations.push(annotation);
426
427        self
428    }
429
430    #[must_use]
431    pub fn with_annotations(mut self, annotation: impl IntoIterator<Item = Annotation>) -> Self {
432        self.annotations.extend(annotation);
433
434        self
435    }
436
437    /// Returns the deterministic primary annotation for this issue.
438    ///
439    /// If multiple primary annotations exist, the one with the smallest span is returned.
440    #[must_use]
441    pub fn primary_annotation(&self) -> Option<&Annotation> {
442        self.annotations.iter().filter(|annotation| annotation.is_primary()).min_by_key(|annotation| annotation.span)
443    }
444
445    /// Returns the deterministic primary span for this issue.
446    #[must_use]
447    pub fn primary_span(&self) -> Option<Span> {
448        self.primary_annotation().map(|annotation| annotation.span)
449    }
450
451    /// Add a note to this issue.
452    ///
453    /// # Examples
454    ///
455    /// ```
456    /// use mago_reporting::Issue;
457    ///
458    /// let issue = Issue::error("This is an error").with_note("This is a note");
459    /// ```
460    #[must_use]
461    pub fn with_note(mut self, note: impl Into<String>) -> Self {
462        self.notes.push(note.into());
463
464        self
465    }
466
467    /// Add a help message to this issue.
468    ///
469    /// This is useful for providing additional context to the user on how to resolve the issue.
470    ///
471    /// # Examples
472    ///
473    /// ```
474    /// use mago_reporting::Issue;
475    ///
476    /// let issue = Issue::error("This is an error").with_help("This is a help message");
477    /// ```
478    #[must_use]
479    pub fn with_help(mut self, help: impl Into<String>) -> Self {
480        self.help = Some(help.into());
481
482        self
483    }
484
485    /// Add a link to this issue.
486    ///
487    /// # Examples
488    ///
489    /// ```
490    /// use mago_reporting::Issue;
491    ///
492    /// let issue = Issue::error("This is an error").with_link("https://example.com");
493    /// ```
494    #[must_use]
495    pub fn with_link(mut self, link: impl Into<String>) -> Self {
496        self.link = Some(link.into());
497
498        self
499    }
500
501    /// Add a single edit to this issue.
502    #[must_use]
503    pub fn with_edit(mut self, file_id: FileId, edit: TextEdit) -> Self {
504        self.edits.entry(file_id).or_default().push(edit);
505
506        self
507    }
508
509    /// Add multiple edits to this issue.
510    #[must_use]
511    pub fn with_file_edits(mut self, file_id: FileId, edits: IssueEdits) -> Self {
512        if !edits.is_empty() {
513            self.edits.entry(file_id).or_default().extend(edits);
514        }
515
516        self
517    }
518
519    /// Take the edits from this issue.
520    #[must_use]
521    pub fn take_edits(&mut self) -> HashMap<FileId, IssueEdits> {
522        std::mem::replace(&mut self.edits, HashMap::with_capacity(0))
523    }
524}
525
526impl IssueCollection {
527    #[must_use]
528    pub fn new() -> Self {
529        Self { issues: Vec::new() }
530    }
531
532    pub fn from(issues: impl IntoIterator<Item = Issue>) -> Self {
533        Self { issues: issues.into_iter().collect() }
534    }
535
536    pub fn push(&mut self, issue: Issue) {
537        self.issues.push(issue);
538    }
539
540    pub fn extend(&mut self, issues: impl IntoIterator<Item = Issue>) {
541        self.issues.extend(issues);
542    }
543
544    pub fn reserve(&mut self, additional: usize) {
545        self.issues.reserve(additional);
546    }
547
548    pub fn shrink_to_fit(&mut self) {
549        self.issues.shrink_to_fit();
550    }
551
552    #[must_use]
553    pub fn is_empty(&self) -> bool {
554        self.issues.is_empty()
555    }
556
557    #[must_use]
558    pub fn len(&self) -> usize {
559        self.issues.len()
560    }
561
562    /// Filters the issues in the collection to only include those with a severity level
563    /// lower than or equal to the given level.
564    #[must_use]
565    pub fn with_maximum_level(self, level: Level) -> Self {
566        Self { issues: self.issues.into_iter().filter(|issue| issue.level <= level).collect() }
567    }
568
569    /// Filters the issues in the collection to only include those with a severity level
570    ///  higher than or equal to the given level.
571    #[must_use]
572    pub fn with_minimum_level(self, level: Level) -> Self {
573        Self { issues: self.issues.into_iter().filter(|issue| issue.level >= level).collect() }
574    }
575
576    /// Returns `true` if the collection contains any issues with a severity level
577    ///  higher than or equal to the given level.
578    #[must_use]
579    pub fn has_minimum_level(&self, level: Level) -> bool {
580        self.issues.iter().any(|issue| issue.level >= level)
581    }
582
583    /// Returns the number of issues in the collection with the given severity level.
584    #[must_use]
585    pub fn get_level_count(&self, level: Level) -> usize {
586        self.issues.iter().filter(|issue| issue.level == level).count()
587    }
588
589    /// Returns the highest severity level of the issues in the collection.
590    #[must_use]
591    pub fn get_highest_level(&self) -> Option<Level> {
592        self.issues.iter().map(|issue| issue.level).max()
593    }
594
595    /// Returns the lowest severity level of the issues in the collection.
596    #[must_use]
597    pub fn get_lowest_level(&self) -> Option<Level> {
598        self.issues.iter().map(|issue| issue.level).min()
599    }
600
601    pub fn filter_out_ignored<F>(&mut self, ignore: &[IgnoreEntry], glob: GlobSettings, resolve_file_name: F)
602    where
603        F: Fn(FileId) -> Option<String>,
604    {
605        if ignore.is_empty() {
606            return;
607        }
608
609        enum CompiledEntry<'entry> {
610            Code(&'entry str),
611            Scoped { code: &'entry str, matcher: ExclusionMatcher<&'entry str> },
612        }
613
614        let compiled: Vec<CompiledEntry<'_>> = ignore
615            .iter()
616            .filter_map(|entry| match entry {
617                IgnoreEntry::Code(code) => Some(CompiledEntry::Code(code.as_str())),
618                IgnoreEntry::Scoped { code, paths } => {
619                    match ExclusionMatcher::compile(paths.iter().map(String::as_str), glob) {
620                        Ok(matcher) => Some(CompiledEntry::Scoped { code: code.as_str(), matcher }),
621                        Err(err) => {
622                            tracing::error!(
623                                "Failed to compile ignore patterns for `{code}`: {err}. Entry will be skipped."
624                            );
625                            None
626                        }
627                    }
628                }
629            })
630            .collect();
631
632        self.issues.retain(|issue| {
633            let Some(code) = &issue.code else {
634                return true;
635            };
636
637            let mut cached_path: Option<Option<String>> = None;
638
639            for entry in &compiled {
640                match entry {
641                    CompiledEntry::Code(ignored_code) if *ignored_code == code => return false,
642                    CompiledEntry::Scoped { code: ignored_code, matcher } if *ignored_code == code => {
643                        let file_name = cached_path
644                            .get_or_insert_with(|| {
645                                issue.primary_span().and_then(|span| resolve_file_name(span.file_id))
646                            })
647                            .as_deref();
648
649                        if let Some(name) = file_name
650                            && matcher.is_match(name)
651                        {
652                            return false;
653                        }
654                    }
655                    _ => {}
656                }
657            }
658
659            true
660        });
661    }
662
663    pub fn filter_retain_codes(&mut self, retain_codes: &[String]) {
664        self.issues.retain(|issue| if let Some(code) = &issue.code { retain_codes.contains(code) } else { false });
665    }
666
667    pub fn take_edits(&mut self) -> impl Iterator<Item = (FileId, IssueEdits)> + '_ {
668        self.issues.iter_mut().flat_map(|issue| issue.take_edits().into_iter())
669    }
670
671    /// Filters the issues in the collection to only include those that have associated edits.
672    #[must_use]
673    pub fn with_edits(self) -> Self {
674        Self { issues: self.issues.into_iter().filter(|issue| !issue.edits.is_empty()).collect() }
675    }
676
677    /// Sorts the issues in the collection.
678    ///
679    /// The issues are sorted by severity level in ascending order,
680    /// then by code in ascending order, and finally by the primary annotation span.
681    #[must_use]
682    pub fn sorted(self) -> Self {
683        let mut issues = self.issues;
684
685        issues.sort_by(|a, b| match a.level.cmp(&b.level) {
686            Ordering::Greater => Ordering::Greater,
687            Ordering::Less => Ordering::Less,
688            Ordering::Equal => match a.code.as_deref().cmp(&b.code.as_deref()) {
689                Ordering::Less => Ordering::Less,
690                Ordering::Greater => Ordering::Greater,
691                Ordering::Equal => {
692                    let a_span = a.primary_span();
693                    let b_span = b.primary_span();
694
695                    match (a_span, b_span) {
696                        (Some(a_span), Some(b_span)) => a_span.cmp(&b_span),
697                        (Some(_), None) => Ordering::Less,
698                        (None, Some(_)) => Ordering::Greater,
699                        (None, None) => Ordering::Equal,
700                    }
701                }
702            },
703        });
704
705        Self { issues }
706    }
707
708    pub fn iter(&self) -> impl Iterator<Item = &Issue> {
709        self.issues.iter()
710    }
711
712    /// Converts the collection into a map of edit batches grouped by file.
713    ///
714    /// Each batch contains all edits from a single issue along with the rule code.
715    /// All edits from an issue must be applied together as a batch to maintain code validity.
716    ///
717    /// Returns `HashMap<FileId, Vec<(Option<String>, IssueEdits)>>` where each tuple
718    /// is (rule_code, edits_for_that_issue).
719    #[must_use]
720    pub fn to_edit_batches(self) -> HashMap<FileId, IssueEditBatches> {
721        let mut result: HashMap<FileId, Vec<(Option<String>, IssueEdits)>> = HashMap::default();
722        for issue in self.issues.into_iter().filter(|issue| !issue.edits.is_empty()) {
723            let code = issue.code;
724            for (file_id, edit_list) in issue.edits {
725                result.entry(file_id).or_default().push((code.clone(), edit_list));
726            }
727        }
728
729        result
730    }
731}
732
733impl IntoIterator for IssueCollection {
734    type Item = Issue;
735
736    type IntoIter = std::vec::IntoIter<Issue>;
737
738    fn into_iter(self) -> Self::IntoIter {
739        self.issues.into_iter()
740    }
741}
742
743impl<'collection> IntoIterator for &'collection IssueCollection {
744    type Item = &'collection Issue;
745
746    type IntoIter = std::slice::Iter<'collection, Issue>;
747
748    fn into_iter(self) -> Self::IntoIter {
749        self.issues.iter()
750    }
751}
752
753impl Default for IssueCollection {
754    fn default() -> Self {
755        Self::new()
756    }
757}
758
759impl IntoIterator for Issue {
760    type Item = Issue;
761    type IntoIter = Once<Issue>;
762
763    fn into_iter(self) -> Self::IntoIter {
764        std::iter::once(self)
765    }
766}
767
768impl FromIterator<Issue> for IssueCollection {
769    fn from_iter<T>(iter: T) -> Self
770    where
771        T: IntoIterator<Item = Issue>,
772    {
773        Self { issues: iter.into_iter().collect() }
774    }
775}
776
777#[cfg(test)]
778mod tests {
779    use std::collections::HashMap;
780
781    use super::*;
782
783    #[test]
784    pub fn test_highest_collection_level() {
785        let mut collection = IssueCollection::from(vec![]);
786        assert_eq!(collection.get_highest_level(), None);
787
788        collection.push(Issue::note("note"));
789        assert_eq!(collection.get_highest_level(), Some(Level::Note));
790
791        collection.push(Issue::help("help"));
792        assert_eq!(collection.get_highest_level(), Some(Level::Help));
793
794        collection.push(Issue::warning("warning"));
795        assert_eq!(collection.get_highest_level(), Some(Level::Warning));
796
797        collection.push(Issue::error("error"));
798        assert_eq!(collection.get_highest_level(), Some(Level::Error));
799    }
800
801    #[test]
802    pub fn test_level_downgrade() {
803        assert_eq!(Level::Error.downgrade(), Level::Warning);
804        assert_eq!(Level::Warning.downgrade(), Level::Help);
805        assert_eq!(Level::Help.downgrade(), Level::Note);
806        assert_eq!(Level::Note.downgrade(), Level::Note);
807    }
808
809    #[test]
810    pub fn test_issue_collection_with_maximum_level() {
811        let mut collection = IssueCollection::from(vec![
812            Issue::error("error"),
813            Issue::warning("warning"),
814            Issue::help("help"),
815            Issue::note("note"),
816        ]);
817
818        collection = collection.with_maximum_level(Level::Warning);
819        assert_eq!(collection.len(), 3);
820        assert_eq!(
821            collection.iter().map(|issue| issue.level).collect::<Vec<_>>(),
822            vec![Level::Warning, Level::Help, Level::Note]
823        );
824    }
825
826    #[test]
827    pub fn test_issue_collection_with_minimum_level() {
828        let mut collection = IssueCollection::from(vec![
829            Issue::error("error"),
830            Issue::warning("warning"),
831            Issue::help("help"),
832            Issue::note("note"),
833        ]);
834
835        collection = collection.with_minimum_level(Level::Warning);
836        assert_eq!(collection.len(), 2);
837        assert_eq!(collection.iter().map(|issue| issue.level).collect::<Vec<_>>(), vec![Level::Error, Level::Warning,]);
838    }
839
840    #[test]
841    pub fn test_issue_collection_has_minimum_level() {
842        let mut collection = IssueCollection::from(vec![]);
843
844        assert!(!collection.has_minimum_level(Level::Error));
845        assert!(!collection.has_minimum_level(Level::Warning));
846        assert!(!collection.has_minimum_level(Level::Help));
847        assert!(!collection.has_minimum_level(Level::Note));
848
849        collection.push(Issue::note("note"));
850
851        assert!(!collection.has_minimum_level(Level::Error));
852        assert!(!collection.has_minimum_level(Level::Warning));
853        assert!(!collection.has_minimum_level(Level::Help));
854        assert!(collection.has_minimum_level(Level::Note));
855
856        collection.push(Issue::help("help"));
857
858        assert!(!collection.has_minimum_level(Level::Error));
859        assert!(!collection.has_minimum_level(Level::Warning));
860        assert!(collection.has_minimum_level(Level::Help));
861        assert!(collection.has_minimum_level(Level::Note));
862
863        collection.push(Issue::warning("warning"));
864
865        assert!(!collection.has_minimum_level(Level::Error));
866        assert!(collection.has_minimum_level(Level::Warning));
867        assert!(collection.has_minimum_level(Level::Help));
868        assert!(collection.has_minimum_level(Level::Note));
869
870        collection.push(Issue::error("error"));
871
872        assert!(collection.has_minimum_level(Level::Error));
873        assert!(collection.has_minimum_level(Level::Warning));
874        assert!(collection.has_minimum_level(Level::Help));
875        assert!(collection.has_minimum_level(Level::Note));
876    }
877
878    #[test]
879    pub fn test_issue_collection_level_count() {
880        let mut collection = IssueCollection::from(vec![]);
881
882        assert_eq!(collection.get_level_count(Level::Error), 0);
883        assert_eq!(collection.get_level_count(Level::Warning), 0);
884        assert_eq!(collection.get_level_count(Level::Help), 0);
885        assert_eq!(collection.get_level_count(Level::Note), 0);
886
887        collection.push(Issue::error("error"));
888
889        assert_eq!(collection.get_level_count(Level::Error), 1);
890        assert_eq!(collection.get_level_count(Level::Warning), 0);
891        assert_eq!(collection.get_level_count(Level::Help), 0);
892        assert_eq!(collection.get_level_count(Level::Note), 0);
893
894        collection.push(Issue::warning("warning"));
895
896        assert_eq!(collection.get_level_count(Level::Error), 1);
897        assert_eq!(collection.get_level_count(Level::Warning), 1);
898        assert_eq!(collection.get_level_count(Level::Help), 0);
899        assert_eq!(collection.get_level_count(Level::Note), 0);
900
901        collection.push(Issue::help("help"));
902
903        assert_eq!(collection.get_level_count(Level::Error), 1);
904        assert_eq!(collection.get_level_count(Level::Warning), 1);
905        assert_eq!(collection.get_level_count(Level::Help), 1);
906        assert_eq!(collection.get_level_count(Level::Note), 0);
907
908        collection.push(Issue::note("note"));
909
910        assert_eq!(collection.get_level_count(Level::Error), 1);
911        assert_eq!(collection.get_level_count(Level::Warning), 1);
912        assert_eq!(collection.get_level_count(Level::Help), 1);
913        assert_eq!(collection.get_level_count(Level::Note), 1);
914    }
915
916    #[test]
917    pub fn test_primary_span_is_deterministic() {
918        let file = FileId::zero();
919        let span_later = Span::new(file, 20u32.into(), 25u32.into());
920        let span_earlier = Span::new(file, 5u32.into(), 10u32.into());
921
922        let issue = Issue::error("x")
923            .with_annotation(Annotation::primary(span_later))
924            .with_annotation(Annotation::primary(span_earlier));
925
926        assert_eq!(issue.primary_span(), Some(span_earlier));
927    }
928
929    fn ignore_fixture() -> (IssueCollection, HashMap<FileId, &'static [u8]>) {
930        let file_id = |name: &[u8]| FileId::new(name);
931
932        let paths: [&[u8]; 4] =
933            [b"src/App.php", b"tests/Unit/FooTest.php", b"modules/auth/views/login.tpl", b"types/user/form.tpl"];
934
935        let mut mapping = HashMap::new();
936        let issues: Vec<Issue> = paths
937            .iter()
938            .map(|p| {
939                let id = file_id(p);
940                mapping.insert(id, *p);
941                Issue::error("oops").with_code("invalid-global").with_annotation(Annotation::primary(Span::new(
942                    id,
943                    0u32.into(),
944                    1u32.into(),
945                )))
946            })
947            .collect();
948
949        (IssueCollection::from(issues), mapping)
950    }
951
952    #[test]
953    pub fn test_filter_out_ignored_with_plain_prefix() {
954        let (mut collection, mapping) = ignore_fixture();
955        let ignore =
956            vec![IgnoreEntry::Scoped { code: "invalid-global".to_string(), paths: vec!["tests/".to_string()] }];
957
958        collection.filter_out_ignored(&ignore, GlobSettings::default(), |id| {
959            mapping.get(&id).map(|s| String::from_utf8_lossy(s).into_owned())
960        });
961
962        let remaining: Vec<String> = collection
963            .iter()
964            .filter_map(|issue| issue.primary_span().and_then(|s| mapping.get(&s.file_id)).copied())
965            .map(|bytes| String::from_utf8_lossy(bytes).into_owned())
966            .collect();
967
968        assert_eq!(
969            remaining,
970            vec![
971                "src/App.php".to_string(),
972                "modules/auth/views/login.tpl".to_string(),
973                "types/user/form.tpl".to_string(),
974            ]
975        );
976    }
977
978    #[test]
979    pub fn test_filter_out_ignored_with_glob_pattern() {
980        let (mut collection, mapping) = ignore_fixture();
981        let ignore = vec![IgnoreEntry::Scoped {
982            code: "invalid-global".to_string(),
983            paths: vec!["modules/*/*/*.tpl".to_string(), "types/*/*.tpl".to_string()],
984        }];
985
986        collection.filter_out_ignored(&ignore, GlobSettings::default(), |id| {
987            mapping.get(&id).map(|s| String::from_utf8_lossy(s).into_owned())
988        });
989
990        let remaining: Vec<String> = collection
991            .iter()
992            .filter_map(|issue| issue.primary_span().and_then(|s| mapping.get(&s.file_id)).copied())
993            .map(|bytes| String::from_utf8_lossy(bytes).into_owned())
994            .collect();
995
996        assert_eq!(remaining, vec!["src/App.php".to_string(), "tests/Unit/FooTest.php".to_string(),]);
997    }
998
999    #[test]
1000    pub fn test_filter_out_ignored_mixes_plain_and_glob() {
1001        let (mut collection, mapping) = ignore_fixture();
1002        let ignore = vec![IgnoreEntry::Scoped {
1003            code: "invalid-global".to_string(),
1004            paths: vec!["tests/".to_string(), "modules/*/*/*.tpl".to_string(), "types/*/*.tpl".to_string()],
1005        }];
1006
1007        collection.filter_out_ignored(&ignore, GlobSettings::default(), |id| {
1008            mapping.get(&id).map(|s| String::from_utf8_lossy(s).into_owned())
1009        });
1010
1011        let remaining: Vec<String> = collection
1012            .iter()
1013            .filter_map(|issue| issue.primary_span().and_then(|s| mapping.get(&s.file_id)).copied())
1014            .map(|bytes| String::from_utf8_lossy(bytes).into_owned())
1015            .collect();
1016
1017        assert_eq!(remaining, vec!["src/App.php".to_string()]);
1018    }
1019
1020    #[test]
1021    pub fn test_filter_out_ignored_respects_code_scope() {
1022        let (mut collection, mapping) = ignore_fixture();
1023        let ignore = vec![IgnoreEntry::Scoped { code: "different-code".to_string(), paths: vec!["**/*".to_string()] }];
1024
1025        collection.filter_out_ignored(&ignore, GlobSettings::default(), |id| {
1026            mapping.get(&id).map(|s| String::from_utf8_lossy(s).into_owned())
1027        });
1028
1029        assert_eq!(collection.len(), 4);
1030    }
1031}