1#![allow(clippy::pub_use, clippy::exhaustive_enums)]
2
3use std::cmp::Ordering;
16use std::iter::Once;
17use std::str::FromStr;
18
19use foldhash::HashMap;
20use foldhash::HashMapExt;
21use regex::Regex;
22use schemars::JsonSchema;
23use strum::Display;
24use strum::VariantNames;
25
26use mago_database::GlobSettings;
27use mago_database::file::FileId;
28use mago_database::matcher::ExclusionMatcher;
29use mago_span::Span;
30use mago_text_edit::TextEdit;
31
32mod formatter;
33#[cfg(feature = "serde")]
34mod internal;
35
36pub mod baseline;
37pub mod color;
38pub mod error;
39pub mod output;
40pub mod reporter;
41
42pub use color::ColorChoice;
43pub use formatter::ReportingFormat;
44pub use formatter::utils::osc8_hyperlink;
45pub use output::ReportingTarget;
46
47#[derive(Debug, Clone, PartialEq, Eq, JsonSchema)]
66#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
67#[cfg_attr(feature = "serde", serde(untagged))]
68pub enum IgnoreEntry {
69 Code(String),
71 Scoped {
74 code: String,
75 #[cfg_attr(feature = "serde", serde(rename = "in", deserialize_with = "one_or_many"))]
76 paths: Vec<String>,
77 },
78 Pattern {
81 pattern: String,
87 #[cfg_attr(feature = "serde", serde(default, skip_serializing_if = "Option::is_none"))]
90 code: Option<String>,
91 #[cfg_attr(
93 feature = "serde",
94 serde(
95 rename = "in",
96 default,
97 skip_serializing_if = "Option::is_none",
98 deserialize_with = "opt_one_or_many"
99 )
100 )]
101 paths: Option<Vec<String>>,
102 },
103}
104
105#[cfg(feature = "serde")]
106fn one_or_many<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
107where
108 D: serde::Deserializer<'de>,
109{
110 #[cfg_attr(feature = "serde", derive(serde::Deserialize))]
111 #[cfg_attr(feature = "serde", serde(untagged))]
112 enum OneOrMany {
113 One(String),
114 Many(Vec<String>),
115 }
116
117 match <OneOrMany as serde::Deserialize>::deserialize(deserializer)? {
118 OneOrMany::One(s) => Ok(vec![s]),
119 OneOrMany::Many(v) => Ok(v),
120 }
121}
122
123#[cfg(feature = "serde")]
124fn opt_one_or_many<'de, D>(deserializer: D) -> Result<Option<Vec<String>>, D::Error>
125where
126 D: serde::Deserializer<'de>,
127{
128 Ok(Some(one_or_many(deserializer)?))
129}
130
131#[derive(Debug, Default)]
138pub struct CompiledIgnoreSet {
139 entries: Vec<CompiledIgnoreEntry>,
140}
141
142#[derive(Debug)]
143enum CompiledIgnoreEntry {
144 Code(String),
145 Scoped { code: String, matcher: ExclusionMatcher<String> },
146 Pattern { regex: Regex, code: Option<String>, matcher: Option<ExclusionMatcher<String>> },
147}
148
149#[derive(Debug, PartialEq, Eq, Ord, Copy, Clone, Hash, PartialOrd)]
151#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
152pub enum AnnotationKind {
153 Primary,
155 Secondary,
157}
158
159#[derive(Debug, PartialEq, Eq, Ord, Clone, Hash, PartialOrd)]
161#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
162pub struct Annotation {
163 pub message: Option<String>,
165 pub kind: AnnotationKind,
167 pub span: Span,
169}
170
171#[derive(Debug, PartialEq, Eq, Ord, Copy, Clone, Hash, PartialOrd, Display, VariantNames, JsonSchema)]
173#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
174#[strum(serialize_all = "lowercase")]
175pub enum Level {
176 #[cfg_attr(feature = "serde", serde(alias = "note"))]
178 Note,
179 #[cfg_attr(feature = "serde", serde(alias = "help"))]
181 Help,
182 #[cfg_attr(feature = "serde", serde(alias = "warning", alias = "warn"))]
184 Warning,
185 #[cfg_attr(feature = "serde", serde(alias = "error", alias = "err"))]
187 Error,
188}
189
190impl FromStr for Level {
191 type Err = ();
192
193 fn from_str(s: &str) -> Result<Self, Self::Err> {
194 match s.to_lowercase().as_str() {
195 "note" => Ok(Self::Note),
196 "help" => Ok(Self::Help),
197 "warning" => Ok(Self::Warning),
198 "error" => Ok(Self::Error),
199 _ => Err(()),
200 }
201 }
202}
203
204type IssueEdits = Vec<TextEdit>;
205type IssueEditBatches = Vec<(Option<String>, IssueEdits)>;
206
207#[derive(Debug, Clone, Eq, PartialEq)]
209#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
210pub struct Issue {
211 pub level: Level,
213 pub code: Option<String>,
215 pub message: String,
217 pub notes: Vec<String>,
219 pub help: Option<String>,
221 pub link: Option<String>,
223 pub annotations: Vec<Annotation>,
225 pub edits: HashMap<FileId, IssueEdits>,
227}
228
229#[derive(Debug, Clone, Eq, PartialEq)]
231#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
232pub struct IssueCollection {
233 issues: Vec<Issue>,
234}
235
236impl AnnotationKind {
237 #[inline]
239 #[must_use]
240 pub const fn is_primary(&self) -> bool {
241 matches!(self, AnnotationKind::Primary)
242 }
243
244 #[inline]
246 #[must_use]
247 pub const fn is_secondary(&self) -> bool {
248 matches!(self, AnnotationKind::Secondary)
249 }
250}
251
252impl CompiledIgnoreSet {
253 #[must_use]
258 pub fn compile(entries: &[IgnoreEntry], glob: GlobSettings) -> Self {
259 let mut compiled = Vec::with_capacity(entries.len());
260 for entry in entries {
261 match entry {
262 IgnoreEntry::Code(code) => compiled.push(CompiledIgnoreEntry::Code(code.clone())),
263 IgnoreEntry::Scoped { code, paths } => match ExclusionMatcher::compile(paths.iter().cloned(), glob) {
264 Ok(matcher) => compiled.push(CompiledIgnoreEntry::Scoped { code: code.clone(), matcher }),
265 Err(err) => {
266 tracing::error!("Failed to compile ignore patterns for `{code}`: {err}. Entry will be skipped.")
267 }
268 },
269 IgnoreEntry::Pattern { pattern, code, paths } => {
270 let regex = match Regex::new(pattern) {
271 Ok(regex) => regex,
272 Err(err) => {
273 tracing::error!(
274 "Failed to compile ignore regex `{pattern}`: {err}. Entry will be skipped."
275 );
276
277 continue;
278 }
279 };
280
281 let matcher = match paths {
282 Some(paths) => match ExclusionMatcher::compile(paths.iter().cloned(), glob) {
283 Ok(matcher) => Some(matcher),
284 Err(err) => {
285 tracing::error!(
286 "Failed to compile ignore paths for regex `{pattern}`: {err}. Entry will be skipped."
287 );
288
289 continue;
290 }
291 },
292 None => None,
293 };
294
295 compiled.push(CompiledIgnoreEntry::Pattern { regex, code: code.clone(), matcher });
296 }
297 }
298 }
299
300 Self { entries: compiled }
301 }
302
303 #[must_use]
304 pub fn is_empty(&self) -> bool {
305 self.entries.is_empty()
306 }
307
308 #[must_use]
309 pub fn len(&self) -> usize {
310 self.entries.len()
311 }
312}
313
314impl Annotation {
315 #[must_use]
332 pub fn new(kind: AnnotationKind, span: Span) -> Self {
333 Self { message: None, kind, span }
334 }
335
336 #[must_use]
353 pub fn primary(span: Span) -> Self {
354 Self::new(AnnotationKind::Primary, span)
355 }
356
357 #[must_use]
374 pub fn secondary(span: Span) -> Self {
375 Self::new(AnnotationKind::Secondary, span)
376 }
377
378 #[must_use]
395 pub fn with_message(mut self, message: impl Into<String>) -> Self {
396 self.message = Some(message.into());
397
398 self
399 }
400
401 #[must_use]
403 pub fn is_primary(&self) -> bool {
404 self.kind == AnnotationKind::Primary
405 }
406}
407
408impl Level {
409 #[must_use]
436 pub fn downgrade(&self) -> Self {
437 match self {
438 Level::Error => Level::Warning,
439 Level::Warning => Level::Help,
440 Level::Help | Level::Note => Level::Note,
441 }
442 }
443}
444
445impl Issue {
446 pub fn new(level: Level, message: impl Into<String>) -> Self {
456 Self {
457 level,
458 code: None,
459 message: message.into(),
460 annotations: Vec::new(),
461 notes: Vec::new(),
462 help: None,
463 link: None,
464 edits: HashMap::default(),
465 }
466 }
467
468 pub fn error(message: impl Into<String>) -> Self {
478 Self::new(Level::Error, message)
479 }
480
481 pub fn warning(message: impl Into<String>) -> Self {
491 Self::new(Level::Warning, message)
492 }
493
494 pub fn help(message: impl Into<String>) -> Self {
504 Self::new(Level::Help, message)
505 }
506
507 pub fn note(message: impl Into<String>) -> Self {
517 Self::new(Level::Note, message)
518 }
519
520 #[must_use]
530 pub fn with_code(mut self, code: impl Into<String>) -> Self {
531 self.code = Some(code.into());
532
533 self
534 }
535
536 #[must_use]
554 pub fn with_annotation(mut self, annotation: Annotation) -> Self {
555 self.annotations.push(annotation);
556
557 self
558 }
559
560 #[must_use]
561 pub fn with_annotations(mut self, annotation: impl IntoIterator<Item = Annotation>) -> Self {
562 self.annotations.extend(annotation);
563
564 self
565 }
566
567 #[must_use]
571 pub fn primary_annotation(&self) -> Option<&Annotation> {
572 self.annotations.iter().filter(|annotation| annotation.is_primary()).min_by_key(|annotation| annotation.span)
573 }
574
575 #[must_use]
577 pub fn primary_span(&self) -> Option<Span> {
578 self.primary_annotation().map(|annotation| annotation.span)
579 }
580
581 #[must_use]
591 pub fn with_note(mut self, note: impl Into<String>) -> Self {
592 self.notes.push(note.into());
593
594 self
595 }
596
597 #[must_use]
609 pub fn with_help(mut self, help: impl Into<String>) -> Self {
610 self.help = Some(help.into());
611
612 self
613 }
614
615 #[must_use]
625 pub fn with_link(mut self, link: impl Into<String>) -> Self {
626 self.link = Some(link.into());
627
628 self
629 }
630
631 #[must_use]
633 pub fn with_edit(mut self, file_id: FileId, edit: TextEdit) -> Self {
634 self.edits.entry(file_id).or_default().push(edit);
635
636 self
637 }
638
639 #[must_use]
641 pub fn with_file_edits(mut self, file_id: FileId, edits: IssueEdits) -> Self {
642 if !edits.is_empty() {
643 self.edits.entry(file_id).or_default().extend(edits);
644 }
645
646 self
647 }
648
649 #[must_use]
651 pub fn take_edits(&mut self) -> HashMap<FileId, IssueEdits> {
652 std::mem::replace(&mut self.edits, HashMap::new())
653 }
654}
655
656impl IssueCollection {
657 #[must_use]
658 pub fn new() -> Self {
659 Self { issues: Vec::new() }
660 }
661
662 pub fn from(issues: impl IntoIterator<Item = Issue>) -> Self {
663 Self { issues: issues.into_iter().collect() }
664 }
665
666 pub fn push(&mut self, issue: Issue) {
667 self.issues.push(issue);
668 }
669
670 pub fn extend(&mut self, issues: impl IntoIterator<Item = Issue>) {
671 self.issues.extend(issues);
672 }
673
674 pub fn reserve(&mut self, additional: usize) {
675 self.issues.reserve(additional);
676 }
677
678 pub fn shrink_to_fit(&mut self) {
679 self.issues.shrink_to_fit();
680 }
681
682 #[must_use]
683 pub fn is_empty(&self) -> bool {
684 self.issues.is_empty()
685 }
686
687 #[must_use]
688 pub fn len(&self) -> usize {
689 self.issues.len()
690 }
691
692 #[must_use]
695 pub fn with_maximum_level(self, level: Level) -> Self {
696 Self { issues: self.issues.into_iter().filter(|issue| issue.level <= level).collect() }
697 }
698
699 #[must_use]
702 pub fn with_minimum_level(self, level: Level) -> Self {
703 Self { issues: self.issues.into_iter().filter(|issue| issue.level >= level).collect() }
704 }
705
706 #[must_use]
709 pub fn has_minimum_level(&self, level: Level) -> bool {
710 self.issues.iter().any(|issue| issue.level >= level)
711 }
712
713 #[must_use]
715 pub fn get_level_count(&self, level: Level) -> usize {
716 self.issues.iter().filter(|issue| issue.level == level).count()
717 }
718
719 #[must_use]
721 pub fn get_highest_level(&self) -> Option<Level> {
722 self.issues.iter().map(|issue| issue.level).max()
723 }
724
725 #[must_use]
727 pub fn get_lowest_level(&self) -> Option<Level> {
728 self.issues.iter().map(|issue| issue.level).min()
729 }
730
731 pub fn filter_out_ignored<F>(&mut self, set: &CompiledIgnoreSet, resolve_file_name: F)
732 where
733 F: Fn(FileId) -> Option<String>,
734 {
735 if set.is_empty() {
736 return;
737 }
738
739 self.issues.retain(|issue| {
740 let mut cached_path: Option<Option<String>> = None;
741 let mut resolve_path = |issue: &Issue| -> Option<String> {
742 cached_path
743 .get_or_insert_with(|| issue.primary_span().and_then(|span| resolve_file_name(span.file_id)))
744 .clone()
745 };
746
747 for entry in &set.entries {
748 match entry {
749 CompiledIgnoreEntry::Code(ignored_code) => {
750 if let Some(code) = &issue.code
751 && ignored_code == code
752 {
753 return false;
754 }
755 }
756 CompiledIgnoreEntry::Scoped { code: ignored_code, matcher } => {
757 let Some(code) = &issue.code else {
758 continue;
759 };
760
761 if ignored_code != code {
762 continue;
763 }
764
765 if let Some(name) = resolve_path(issue)
766 && matcher.is_match(&name)
767 {
768 return false;
769 }
770 }
771 CompiledIgnoreEntry::Pattern { regex, code: ignored_code, matcher } => {
772 if let Some(ignored_code) = ignored_code {
773 let Some(code) = &issue.code else {
774 continue;
775 };
776
777 if ignored_code != code {
778 continue;
779 }
780 }
781
782 if let Some(matcher) = matcher {
783 let Some(name) = resolve_path(issue) else {
784 continue;
785 };
786
787 if !matcher.is_match(&name) {
788 continue;
789 }
790 }
791
792 if issue_text_matches(issue, regex) {
793 return false;
794 }
795 }
796 }
797 }
798
799 true
800 });
801 }
802
803 pub fn filter_retain_codes(&mut self, retain_codes: &[String]) {
804 self.issues.retain(|issue| if let Some(code) = &issue.code { retain_codes.contains(code) } else { false });
805 }
806
807 pub fn take_edits(&mut self) -> impl Iterator<Item = (FileId, IssueEdits)> + '_ {
808 self.issues.iter_mut().flat_map(|issue| issue.take_edits().into_iter())
809 }
810
811 #[must_use]
813 pub fn with_edits(self) -> Self {
814 Self { issues: self.issues.into_iter().filter(|issue| !issue.edits.is_empty()).collect() }
815 }
816
817 #[must_use]
822 pub fn sorted(self) -> Self {
823 let mut issues = self.issues;
824
825 issues.sort_by(|a, b| match a.level.cmp(&b.level) {
826 Ordering::Greater => Ordering::Greater,
827 Ordering::Less => Ordering::Less,
828 Ordering::Equal => match a.code.as_deref().cmp(&b.code.as_deref()) {
829 Ordering::Less => Ordering::Less,
830 Ordering::Greater => Ordering::Greater,
831 Ordering::Equal => {
832 let a_span = a.primary_span();
833 let b_span = b.primary_span();
834
835 match (a_span, b_span) {
836 (Some(a_span), Some(b_span)) => a_span.cmp(&b_span),
837 (Some(_), None) => Ordering::Less,
838 (None, Some(_)) => Ordering::Greater,
839 (None, None) => Ordering::Equal,
840 }
841 }
842 },
843 });
844
845 Self { issues }
846 }
847
848 pub fn iter(&self) -> impl Iterator<Item = &Issue> {
849 self.issues.iter()
850 }
851
852 #[must_use]
860 pub fn to_edit_batches(self) -> HashMap<FileId, IssueEditBatches> {
861 let mut result: HashMap<FileId, Vec<(Option<String>, IssueEdits)>> = HashMap::default();
862 for issue in self.issues.into_iter().filter(|issue| !issue.edits.is_empty()) {
863 let code = issue.code;
864 for (file_id, edit_list) in issue.edits {
865 result.entry(file_id).or_default().push((code.clone(), edit_list));
866 }
867 }
868
869 result
870 }
871}
872
873fn issue_text_matches(issue: &Issue, regex: &Regex) -> bool {
879 if regex.is_match(&issue.message) {
880 return true;
881 }
882
883 if issue
884 .annotations
885 .iter()
886 .any(|annotation| annotation.message.as_ref().is_some_and(|message| regex.is_match(message)))
887 {
888 return true;
889 }
890
891 if issue.notes.iter().any(|note| regex.is_match(note)) {
892 return true;
893 }
894
895 issue.help.as_ref().is_some_and(|help| regex.is_match(help))
896}
897
898impl IntoIterator for IssueCollection {
899 type Item = Issue;
900
901 type IntoIter = std::vec::IntoIter<Issue>;
902
903 fn into_iter(self) -> Self::IntoIter {
904 self.issues.into_iter()
905 }
906}
907
908impl<'collection> IntoIterator for &'collection IssueCollection {
909 type Item = &'collection Issue;
910
911 type IntoIter = std::slice::Iter<'collection, Issue>;
912
913 fn into_iter(self) -> Self::IntoIter {
914 self.issues.iter()
915 }
916}
917
918impl Default for IssueCollection {
919 fn default() -> Self {
920 Self::new()
921 }
922}
923
924impl IntoIterator for Issue {
925 type Item = Issue;
926 type IntoIter = Once<Issue>;
927
928 fn into_iter(self) -> Self::IntoIter {
929 std::iter::once(self)
930 }
931}
932
933impl FromIterator<Issue> for IssueCollection {
934 fn from_iter<T>(iter: T) -> Self
935 where
936 T: IntoIterator<Item = Issue>,
937 {
938 Self { issues: iter.into_iter().collect() }
939 }
940}
941
942#[cfg(test)]
943mod tests {
944 use std::collections::HashMap;
945
946 use super::*;
947
948 #[test]
949 pub fn test_highest_collection_level() {
950 let mut collection = IssueCollection::from(vec![]);
951 assert_eq!(collection.get_highest_level(), None);
952
953 collection.push(Issue::note("note"));
954 assert_eq!(collection.get_highest_level(), Some(Level::Note));
955
956 collection.push(Issue::help("help"));
957 assert_eq!(collection.get_highest_level(), Some(Level::Help));
958
959 collection.push(Issue::warning("warning"));
960 assert_eq!(collection.get_highest_level(), Some(Level::Warning));
961
962 collection.push(Issue::error("error"));
963 assert_eq!(collection.get_highest_level(), Some(Level::Error));
964 }
965
966 #[test]
967 pub fn test_level_downgrade() {
968 assert_eq!(Level::Error.downgrade(), Level::Warning);
969 assert_eq!(Level::Warning.downgrade(), Level::Help);
970 assert_eq!(Level::Help.downgrade(), Level::Note);
971 assert_eq!(Level::Note.downgrade(), Level::Note);
972 }
973
974 #[test]
975 pub fn test_issue_collection_with_maximum_level() {
976 let mut collection = IssueCollection::from(vec![
977 Issue::error("error"),
978 Issue::warning("warning"),
979 Issue::help("help"),
980 Issue::note("note"),
981 ]);
982
983 collection = collection.with_maximum_level(Level::Warning);
984 assert_eq!(collection.len(), 3);
985 assert_eq!(
986 collection.iter().map(|issue| issue.level).collect::<Vec<_>>(),
987 vec![Level::Warning, Level::Help, Level::Note]
988 );
989 }
990
991 #[test]
992 pub fn test_issue_collection_with_minimum_level() {
993 let mut collection = IssueCollection::from(vec![
994 Issue::error("error"),
995 Issue::warning("warning"),
996 Issue::help("help"),
997 Issue::note("note"),
998 ]);
999
1000 collection = collection.with_minimum_level(Level::Warning);
1001 assert_eq!(collection.len(), 2);
1002 assert_eq!(collection.iter().map(|issue| issue.level).collect::<Vec<_>>(), vec![Level::Error, Level::Warning,]);
1003 }
1004
1005 #[test]
1006 pub fn test_issue_collection_has_minimum_level() {
1007 let mut collection = IssueCollection::from(vec![]);
1008
1009 assert!(!collection.has_minimum_level(Level::Error));
1010 assert!(!collection.has_minimum_level(Level::Warning));
1011 assert!(!collection.has_minimum_level(Level::Help));
1012 assert!(!collection.has_minimum_level(Level::Note));
1013
1014 collection.push(Issue::note("note"));
1015
1016 assert!(!collection.has_minimum_level(Level::Error));
1017 assert!(!collection.has_minimum_level(Level::Warning));
1018 assert!(!collection.has_minimum_level(Level::Help));
1019 assert!(collection.has_minimum_level(Level::Note));
1020
1021 collection.push(Issue::help("help"));
1022
1023 assert!(!collection.has_minimum_level(Level::Error));
1024 assert!(!collection.has_minimum_level(Level::Warning));
1025 assert!(collection.has_minimum_level(Level::Help));
1026 assert!(collection.has_minimum_level(Level::Note));
1027
1028 collection.push(Issue::warning("warning"));
1029
1030 assert!(!collection.has_minimum_level(Level::Error));
1031 assert!(collection.has_minimum_level(Level::Warning));
1032 assert!(collection.has_minimum_level(Level::Help));
1033 assert!(collection.has_minimum_level(Level::Note));
1034
1035 collection.push(Issue::error("error"));
1036
1037 assert!(collection.has_minimum_level(Level::Error));
1038 assert!(collection.has_minimum_level(Level::Warning));
1039 assert!(collection.has_minimum_level(Level::Help));
1040 assert!(collection.has_minimum_level(Level::Note));
1041 }
1042
1043 #[test]
1044 pub fn test_issue_collection_level_count() {
1045 let mut collection = IssueCollection::from(vec![]);
1046
1047 assert_eq!(collection.get_level_count(Level::Error), 0);
1048 assert_eq!(collection.get_level_count(Level::Warning), 0);
1049 assert_eq!(collection.get_level_count(Level::Help), 0);
1050 assert_eq!(collection.get_level_count(Level::Note), 0);
1051
1052 collection.push(Issue::error("error"));
1053
1054 assert_eq!(collection.get_level_count(Level::Error), 1);
1055 assert_eq!(collection.get_level_count(Level::Warning), 0);
1056 assert_eq!(collection.get_level_count(Level::Help), 0);
1057 assert_eq!(collection.get_level_count(Level::Note), 0);
1058
1059 collection.push(Issue::warning("warning"));
1060
1061 assert_eq!(collection.get_level_count(Level::Error), 1);
1062 assert_eq!(collection.get_level_count(Level::Warning), 1);
1063 assert_eq!(collection.get_level_count(Level::Help), 0);
1064 assert_eq!(collection.get_level_count(Level::Note), 0);
1065
1066 collection.push(Issue::help("help"));
1067
1068 assert_eq!(collection.get_level_count(Level::Error), 1);
1069 assert_eq!(collection.get_level_count(Level::Warning), 1);
1070 assert_eq!(collection.get_level_count(Level::Help), 1);
1071 assert_eq!(collection.get_level_count(Level::Note), 0);
1072
1073 collection.push(Issue::note("note"));
1074
1075 assert_eq!(collection.get_level_count(Level::Error), 1);
1076 assert_eq!(collection.get_level_count(Level::Warning), 1);
1077 assert_eq!(collection.get_level_count(Level::Help), 1);
1078 assert_eq!(collection.get_level_count(Level::Note), 1);
1079 }
1080
1081 #[test]
1082 pub fn test_primary_span_is_deterministic() {
1083 let file = FileId::zero();
1084 let span_later = Span::new(file, 20u32.into(), 25u32.into());
1085 let span_earlier = Span::new(file, 5u32.into(), 10u32.into());
1086
1087 let issue = Issue::error("x")
1088 .with_annotation(Annotation::primary(span_later))
1089 .with_annotation(Annotation::primary(span_earlier));
1090
1091 assert_eq!(issue.primary_span(), Some(span_earlier));
1092 }
1093
1094 fn ignore_fixture() -> (IssueCollection, HashMap<FileId, &'static [u8]>) {
1095 let file_id = |name: &[u8]| FileId::new(name);
1096
1097 let paths: [&[u8]; 4] =
1098 [b"src/App.php", b"tests/Unit/FooTest.php", b"modules/auth/views/login.tpl", b"types/user/form.tpl"];
1099
1100 let mut mapping = HashMap::new();
1101 let issues: Vec<Issue> = paths
1102 .iter()
1103 .map(|p| {
1104 let id = file_id(p);
1105 mapping.insert(id, *p);
1106 Issue::error("oops").with_code("invalid-global").with_annotation(Annotation::primary(Span::new(
1107 id,
1108 0u32.into(),
1109 1u32.into(),
1110 )))
1111 })
1112 .collect();
1113
1114 (IssueCollection::from(issues), mapping)
1115 }
1116
1117 fn resolve<'mapping>(
1118 mapping: &'mapping HashMap<FileId, &'static [u8]>,
1119 ) -> impl Fn(FileId) -> Option<String> + 'mapping {
1120 move |id| mapping.get(&id).map(|s| String::from_utf8_lossy(s).into_owned())
1121 }
1122
1123 fn remaining_paths(collection: &IssueCollection, mapping: &HashMap<FileId, &'static [u8]>) -> Vec<String> {
1124 collection
1125 .iter()
1126 .filter_map(|issue| issue.primary_span().and_then(|s| mapping.get(&s.file_id)).copied())
1127 .map(|bytes| String::from_utf8_lossy(bytes).into_owned())
1128 .collect()
1129 }
1130
1131 #[test]
1132 pub fn test_filter_out_ignored_with_plain_prefix() {
1133 let (mut collection, mapping) = ignore_fixture();
1134 let entries =
1135 vec![IgnoreEntry::Scoped { code: "invalid-global".to_string(), paths: vec!["tests/".to_string()] }];
1136 let set = CompiledIgnoreSet::compile(&entries, GlobSettings::default());
1137
1138 collection.filter_out_ignored(&set, resolve(&mapping));
1139
1140 assert_eq!(
1141 remaining_paths(&collection, &mapping),
1142 vec![
1143 "src/App.php".to_string(),
1144 "modules/auth/views/login.tpl".to_string(),
1145 "types/user/form.tpl".to_string(),
1146 ]
1147 );
1148 }
1149
1150 #[test]
1151 pub fn test_filter_out_ignored_with_glob_pattern() {
1152 let (mut collection, mapping) = ignore_fixture();
1153 let entries = vec![IgnoreEntry::Scoped {
1154 code: "invalid-global".to_string(),
1155 paths: vec!["modules/*/*/*.tpl".to_string(), "types/*/*.tpl".to_string()],
1156 }];
1157 let set = CompiledIgnoreSet::compile(&entries, GlobSettings::default());
1158
1159 collection.filter_out_ignored(&set, resolve(&mapping));
1160
1161 assert_eq!(
1162 remaining_paths(&collection, &mapping),
1163 vec!["src/App.php".to_string(), "tests/Unit/FooTest.php".to_string()]
1164 );
1165 }
1166
1167 #[test]
1168 pub fn test_filter_out_ignored_mixes_plain_and_glob() {
1169 let (mut collection, mapping) = ignore_fixture();
1170 let entries = vec![IgnoreEntry::Scoped {
1171 code: "invalid-global".to_string(),
1172 paths: vec!["tests/".to_string(), "modules/*/*/*.tpl".to_string(), "types/*/*.tpl".to_string()],
1173 }];
1174 let set = CompiledIgnoreSet::compile(&entries, GlobSettings::default());
1175
1176 collection.filter_out_ignored(&set, resolve(&mapping));
1177
1178 assert_eq!(remaining_paths(&collection, &mapping), vec!["src/App.php".to_string()]);
1179 }
1180
1181 #[test]
1182 pub fn test_filter_out_ignored_respects_code_scope() {
1183 let (mut collection, mapping) = ignore_fixture();
1184 let entries = vec![IgnoreEntry::Scoped { code: "different-code".to_string(), paths: vec!["**/*".to_string()] }];
1185 let set = CompiledIgnoreSet::compile(&entries, GlobSettings::default());
1186
1187 collection.filter_out_ignored(&set, resolve(&mapping));
1188
1189 assert_eq!(collection.len(), 4);
1190 }
1191
1192 fn pattern_fixture() -> (IssueCollection, HashMap<FileId, &'static [u8]>) {
1193 let paths: [&[u8]; 3] = [b"src/App.php", b"src/Bridge/Symfony.php", b"tests/Unit/FooTest.php"];
1194 let mut mapping = HashMap::new();
1195 let mut issues: Vec<Issue> = Vec::new();
1196
1197 let id0 = FileId::new(blake3::hash(paths[0]).as_bytes());
1198 mapping.insert(id0, paths[0]);
1199 issues.push(
1200 Issue::error("Saw type `mixed` in Symfony bridge.")
1201 .with_code("mixed-assignment")
1202 .with_annotation(Annotation::primary(Span::new(id0, 0u32.into(), 1u32.into()))),
1203 );
1204
1205 let id1 = FileId::new(blake3::hash(paths[1]).as_bytes());
1206 mapping.insert(id1, paths[1]);
1207 issues.push(
1208 Issue::error("Could not infer a precise return type.")
1209 .with_code("mixed-assignment")
1210 .with_note("Originates from Symfony vendor stubs.")
1211 .with_annotation(Annotation::primary(Span::new(id1, 0u32.into(), 1u32.into()))),
1212 );
1213
1214 let id2 = FileId::new(blake3::hash(paths[2]).as_bytes());
1215 mapping.insert(id2, paths[2]);
1216 issues.push(
1217 Issue::error("Unused variable.")
1218 .with_code("unused-variable")
1219 .with_annotation(Annotation::primary(Span::new(id2, 0u32.into(), 1u32.into()))),
1220 );
1221
1222 (IssueCollection::from(issues), mapping)
1223 }
1224
1225 #[test]
1226 pub fn test_pattern_matches_title_and_note() {
1227 let (mut collection, mapping) = pattern_fixture();
1228 let entries = vec![IgnoreEntry::Pattern {
1229 pattern: "Symfony".to_string(),
1230 code: Some("mixed-assignment".to_string()),
1231 paths: None,
1232 }];
1233 let set = CompiledIgnoreSet::compile(&entries, GlobSettings::default());
1234
1235 collection.filter_out_ignored(&set, resolve(&mapping));
1236
1237 assert_eq!(remaining_paths(&collection, &mapping), vec!["tests/Unit/FooTest.php".to_string()]);
1238 }
1239
1240 #[test]
1241 pub fn test_pattern_without_code_matches_across_codes() {
1242 let (mut collection, mapping) = pattern_fixture();
1243 let entries = vec![IgnoreEntry::Pattern { pattern: "Symfony".to_string(), code: None, paths: None }];
1244 let set = CompiledIgnoreSet::compile(&entries, GlobSettings::default());
1245
1246 collection.filter_out_ignored(&set, resolve(&mapping));
1247
1248 assert_eq!(remaining_paths(&collection, &mapping), vec!["tests/Unit/FooTest.php".to_string()]);
1249 }
1250
1251 #[test]
1252 pub fn test_pattern_with_path_scope() {
1253 let (mut collection, mapping) = pattern_fixture();
1254 let entries = vec![IgnoreEntry::Pattern {
1255 pattern: "Symfony".to_string(),
1256 code: None,
1257 paths: Some(vec!["src/Bridge/".to_string()]),
1258 }];
1259 let set = CompiledIgnoreSet::compile(&entries, GlobSettings::default());
1260
1261 collection.filter_out_ignored(&set, resolve(&mapping));
1262
1263 assert_eq!(
1264 remaining_paths(&collection, &mapping),
1265 vec!["src/App.php".to_string(), "tests/Unit/FooTest.php".to_string()]
1266 );
1267 }
1268
1269 #[test]
1270 pub fn test_pattern_case_insensitive_with_flag() {
1271 let (mut collection, mapping) = pattern_fixture();
1272 let entries = vec![IgnoreEntry::Pattern { pattern: "(?i)symfony".to_string(), code: None, paths: None }];
1273 let set = CompiledIgnoreSet::compile(&entries, GlobSettings::default());
1274
1275 collection.filter_out_ignored(&set, resolve(&mapping));
1276
1277 assert_eq!(remaining_paths(&collection, &mapping), vec!["tests/Unit/FooTest.php".to_string()]);
1278 }
1279
1280 #[test]
1281 pub fn test_pattern_invalid_regex_is_skipped() {
1282 let (mut collection, mapping) = pattern_fixture();
1283 let entries = vec![
1284 IgnoreEntry::Pattern { pattern: "[unterminated".to_string(), code: None, paths: None },
1285 IgnoreEntry::Code("unused-variable".to_string()),
1286 ];
1287 let set = CompiledIgnoreSet::compile(&entries, GlobSettings::default());
1288
1289 assert_eq!(set.len(), 1);
1290
1291 collection.filter_out_ignored(&set, resolve(&mapping));
1292
1293 assert_eq!(
1294 remaining_paths(&collection, &mapping),
1295 vec!["src/App.php".to_string(), "src/Bridge/Symfony.php".to_string()]
1296 );
1297 }
1298
1299 #[test]
1300 pub fn test_pattern_matches_help_message() {
1301 let id = FileId::new(blake3::hash(b"src/foo.php").as_bytes());
1302 let mut mapping: HashMap<FileId, &'static [u8]> = HashMap::new();
1303 mapping.insert(id, &b"src/foo.php"[..]);
1304 let mut collection = IssueCollection::from(vec![
1305 Issue::error("Title.")
1306 .with_code("some-code")
1307 .with_help("Consider migrating off legacy Symfony bridge.")
1308 .with_annotation(Annotation::primary(Span::new(id, 0u32.into(), 1u32.into()))),
1309 ]);
1310
1311 let entries = vec![IgnoreEntry::Pattern { pattern: "Symfony".to_string(), code: None, paths: None }];
1312 let set = CompiledIgnoreSet::compile(&entries, GlobSettings::default());
1313
1314 collection.filter_out_ignored(&set, resolve(&mapping));
1315
1316 assert!(collection.is_empty());
1317 }
1318}