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