Skip to main content

mago_reporting/
lib.rs

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