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 serde::Deserialize;
24use serde::Serialize;
25use strum::Display;
26use strum::VariantNames;
27
28use mago_database::GlobSettings;
29use mago_database::file::FileId;
30use mago_database::matcher::ExclusionMatcher;
31use mago_span::Span;
32use mago_text_edit::TextEdit;
33
34mod formatter;
35mod internal;
36
37pub mod baseline;
38pub mod color;
39pub mod error;
40pub mod output;
41pub mod reporter;
42
43pub use color::ColorChoice;
44pub use formatter::ReportingFormat;
45pub use output::ReportingTarget;
46
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
66#[serde(untagged)]
67pub enum IgnoreEntry {
68 Code(String),
70 Scoped {
73 code: String,
74 #[serde(rename = "in", deserialize_with = "one_or_many")]
75 paths: Vec<String>,
76 },
77 Pattern {
80 pattern: String,
86 #[serde(default, skip_serializing_if = "Option::is_none")]
89 code: Option<String>,
90 #[serde(rename = "in", default, skip_serializing_if = "Option::is_none", deserialize_with = "opt_one_or_many")]
92 paths: Option<Vec<String>>,
93 },
94}
95
96fn one_or_many<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
97where
98 D: serde::Deserializer<'de>,
99{
100 #[derive(Deserialize)]
101 #[serde(untagged)]
102 enum OneOrMany {
103 One(String),
104 Many(Vec<String>),
105 }
106
107 match OneOrMany::deserialize(deserializer)? {
108 OneOrMany::One(s) => Ok(vec![s]),
109 OneOrMany::Many(v) => Ok(v),
110 }
111}
112
113fn opt_one_or_many<'de, D>(deserializer: D) -> Result<Option<Vec<String>>, D::Error>
114where
115 D: serde::Deserializer<'de>,
116{
117 Ok(Some(one_or_many(deserializer)?))
118}
119
120#[derive(Debug, Default)]
127pub struct CompiledIgnoreSet {
128 entries: Vec<CompiledIgnoreEntry>,
129}
130
131#[derive(Debug)]
132enum CompiledIgnoreEntry {
133 Code(String),
134 Scoped { code: String, matcher: ExclusionMatcher<String> },
135 Pattern { regex: Regex, code: Option<String>, matcher: Option<ExclusionMatcher<String>> },
136}
137
138#[derive(Debug, PartialEq, Eq, Ord, Copy, Clone, Hash, PartialOrd, Deserialize, Serialize)]
140pub enum AnnotationKind {
141 Primary,
143 Secondary,
145}
146
147#[derive(Debug, PartialEq, Eq, Ord, Clone, Hash, PartialOrd, Deserialize, Serialize)]
149pub struct Annotation {
150 pub message: Option<String>,
152 pub kind: AnnotationKind,
154 pub span: Span,
156}
157
158#[derive(
160 Debug, PartialEq, Eq, Ord, Copy, Clone, Hash, PartialOrd, Deserialize, Serialize, Display, VariantNames, JsonSchema,
161)]
162#[strum(serialize_all = "lowercase")]
163pub enum Level {
164 #[serde(alias = "note")]
166 Note,
167 #[serde(alias = "help")]
169 Help,
170 #[serde(alias = "warning", alias = "warn")]
172 Warning,
173 #[serde(alias = "error", alias = "err")]
175 Error,
176}
177
178impl FromStr for Level {
179 type Err = ();
180
181 fn from_str(s: &str) -> Result<Self, Self::Err> {
182 match s.to_lowercase().as_str() {
183 "note" => Ok(Self::Note),
184 "help" => Ok(Self::Help),
185 "warning" => Ok(Self::Warning),
186 "error" => Ok(Self::Error),
187 _ => Err(()),
188 }
189 }
190}
191
192type IssueEdits = Vec<TextEdit>;
193type IssueEditBatches = Vec<(Option<String>, IssueEdits)>;
194
195#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
197pub struct Issue {
198 pub level: Level,
200 pub code: Option<String>,
202 pub message: String,
204 pub notes: Vec<String>,
206 pub help: Option<String>,
208 pub link: Option<String>,
210 pub annotations: Vec<Annotation>,
212 pub edits: HashMap<FileId, IssueEdits>,
214}
215
216#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
218pub struct IssueCollection {
219 issues: Vec<Issue>,
220}
221
222impl AnnotationKind {
223 #[inline]
225 #[must_use]
226 pub const fn is_primary(&self) -> bool {
227 matches!(self, AnnotationKind::Primary)
228 }
229
230 #[inline]
232 #[must_use]
233 pub const fn is_secondary(&self) -> bool {
234 matches!(self, AnnotationKind::Secondary)
235 }
236}
237
238impl CompiledIgnoreSet {
239 #[must_use]
244 pub fn compile(entries: &[IgnoreEntry], glob: GlobSettings) -> Self {
245 let mut compiled = Vec::with_capacity(entries.len());
246 for entry in entries {
247 match entry {
248 IgnoreEntry::Code(code) => compiled.push(CompiledIgnoreEntry::Code(code.clone())),
249 IgnoreEntry::Scoped { code, paths } => match ExclusionMatcher::compile(paths.iter().cloned(), glob) {
250 Ok(matcher) => compiled.push(CompiledIgnoreEntry::Scoped { code: code.clone(), matcher }),
251 Err(err) => {
252 tracing::error!("Failed to compile ignore patterns for `{code}`: {err}. Entry will be skipped.")
253 }
254 },
255 IgnoreEntry::Pattern { pattern, code, paths } => {
256 let regex = match Regex::new(pattern) {
257 Ok(regex) => regex,
258 Err(err) => {
259 tracing::error!(
260 "Failed to compile ignore regex `{pattern}`: {err}. Entry will be skipped."
261 );
262
263 continue;
264 }
265 };
266
267 let matcher = match paths {
268 Some(paths) => match ExclusionMatcher::compile(paths.iter().cloned(), glob) {
269 Ok(matcher) => Some(matcher),
270 Err(err) => {
271 tracing::error!(
272 "Failed to compile ignore paths for regex `{pattern}`: {err}. Entry will be skipped."
273 );
274
275 continue;
276 }
277 },
278 None => None,
279 };
280
281 compiled.push(CompiledIgnoreEntry::Pattern { regex, code: code.clone(), matcher });
282 }
283 }
284 }
285
286 Self { entries: compiled }
287 }
288
289 #[must_use]
290 pub fn is_empty(&self) -> bool {
291 self.entries.is_empty()
292 }
293
294 #[must_use]
295 pub fn len(&self) -> usize {
296 self.entries.len()
297 }
298}
299
300impl Annotation {
301 #[must_use]
318 pub fn new(kind: AnnotationKind, span: Span) -> Self {
319 Self { message: None, kind, span }
320 }
321
322 #[must_use]
339 pub fn primary(span: Span) -> Self {
340 Self::new(AnnotationKind::Primary, span)
341 }
342
343 #[must_use]
360 pub fn secondary(span: Span) -> Self {
361 Self::new(AnnotationKind::Secondary, span)
362 }
363
364 #[must_use]
381 pub fn with_message(mut self, message: impl Into<String>) -> Self {
382 self.message = Some(message.into());
383
384 self
385 }
386
387 #[must_use]
389 pub fn is_primary(&self) -> bool {
390 self.kind == AnnotationKind::Primary
391 }
392}
393
394impl Level {
395 #[must_use]
422 pub fn downgrade(&self) -> Self {
423 match self {
424 Level::Error => Level::Warning,
425 Level::Warning => Level::Help,
426 Level::Help | Level::Note => Level::Note,
427 }
428 }
429}
430
431impl Issue {
432 pub fn new(level: Level, message: impl Into<String>) -> Self {
442 Self {
443 level,
444 code: None,
445 message: message.into(),
446 annotations: Vec::new(),
447 notes: Vec::new(),
448 help: None,
449 link: None,
450 edits: HashMap::default(),
451 }
452 }
453
454 pub fn error(message: impl Into<String>) -> Self {
464 Self::new(Level::Error, message)
465 }
466
467 pub fn warning(message: impl Into<String>) -> Self {
477 Self::new(Level::Warning, message)
478 }
479
480 pub fn help(message: impl Into<String>) -> Self {
490 Self::new(Level::Help, message)
491 }
492
493 pub fn note(message: impl Into<String>) -> Self {
503 Self::new(Level::Note, message)
504 }
505
506 #[must_use]
516 pub fn with_code(mut self, code: impl Into<String>) -> Self {
517 self.code = Some(code.into());
518
519 self
520 }
521
522 #[must_use]
540 pub fn with_annotation(mut self, annotation: Annotation) -> Self {
541 self.annotations.push(annotation);
542
543 self
544 }
545
546 #[must_use]
547 pub fn with_annotations(mut self, annotation: impl IntoIterator<Item = Annotation>) -> Self {
548 self.annotations.extend(annotation);
549
550 self
551 }
552
553 #[must_use]
557 pub fn primary_annotation(&self) -> Option<&Annotation> {
558 self.annotations.iter().filter(|annotation| annotation.is_primary()).min_by_key(|annotation| annotation.span)
559 }
560
561 #[must_use]
563 pub fn primary_span(&self) -> Option<Span> {
564 self.primary_annotation().map(|annotation| annotation.span)
565 }
566
567 #[must_use]
577 pub fn with_note(mut self, note: impl Into<String>) -> Self {
578 self.notes.push(note.into());
579
580 self
581 }
582
583 #[must_use]
595 pub fn with_help(mut self, help: impl Into<String>) -> Self {
596 self.help = Some(help.into());
597
598 self
599 }
600
601 #[must_use]
611 pub fn with_link(mut self, link: impl Into<String>) -> Self {
612 self.link = Some(link.into());
613
614 self
615 }
616
617 #[must_use]
619 pub fn with_edit(mut self, file_id: FileId, edit: TextEdit) -> Self {
620 self.edits.entry(file_id).or_default().push(edit);
621
622 self
623 }
624
625 #[must_use]
627 pub fn with_file_edits(mut self, file_id: FileId, edits: IssueEdits) -> Self {
628 if !edits.is_empty() {
629 self.edits.entry(file_id).or_default().extend(edits);
630 }
631
632 self
633 }
634
635 #[must_use]
637 pub fn take_edits(&mut self) -> HashMap<FileId, IssueEdits> {
638 std::mem::replace(&mut self.edits, HashMap::with_capacity(0))
639 }
640}
641
642impl IssueCollection {
643 #[must_use]
644 pub fn new() -> Self {
645 Self { issues: Vec::new() }
646 }
647
648 pub fn from(issues: impl IntoIterator<Item = Issue>) -> Self {
649 Self { issues: issues.into_iter().collect() }
650 }
651
652 pub fn push(&mut self, issue: Issue) {
653 self.issues.push(issue);
654 }
655
656 pub fn extend(&mut self, issues: impl IntoIterator<Item = Issue>) {
657 self.issues.extend(issues);
658 }
659
660 pub fn reserve(&mut self, additional: usize) {
661 self.issues.reserve(additional);
662 }
663
664 pub fn shrink_to_fit(&mut self) {
665 self.issues.shrink_to_fit();
666 }
667
668 #[must_use]
669 pub fn is_empty(&self) -> bool {
670 self.issues.is_empty()
671 }
672
673 #[must_use]
674 pub fn len(&self) -> usize {
675 self.issues.len()
676 }
677
678 #[must_use]
681 pub fn with_maximum_level(self, level: Level) -> Self {
682 Self { issues: self.issues.into_iter().filter(|issue| issue.level <= level).collect() }
683 }
684
685 #[must_use]
688 pub fn with_minimum_level(self, level: Level) -> Self {
689 Self { issues: self.issues.into_iter().filter(|issue| issue.level >= level).collect() }
690 }
691
692 #[must_use]
695 pub fn has_minimum_level(&self, level: Level) -> bool {
696 self.issues.iter().any(|issue| issue.level >= level)
697 }
698
699 #[must_use]
701 pub fn get_level_count(&self, level: Level) -> usize {
702 self.issues.iter().filter(|issue| issue.level == level).count()
703 }
704
705 #[must_use]
707 pub fn get_highest_level(&self) -> Option<Level> {
708 self.issues.iter().map(|issue| issue.level).max()
709 }
710
711 #[must_use]
713 pub fn get_lowest_level(&self) -> Option<Level> {
714 self.issues.iter().map(|issue| issue.level).min()
715 }
716
717 pub fn filter_out_ignored<F>(&mut self, set: &CompiledIgnoreSet, resolve_file_name: F)
718 where
719 F: Fn(FileId) -> Option<String>,
720 {
721 if set.is_empty() {
722 return;
723 }
724
725 self.issues.retain(|issue| {
726 let mut cached_path: Option<Option<String>> = None;
727 let mut resolve_path = |issue: &Issue| -> Option<String> {
728 cached_path
729 .get_or_insert_with(|| issue.primary_span().and_then(|span| resolve_file_name(span.file_id)))
730 .clone()
731 };
732
733 for entry in &set.entries {
734 match entry {
735 CompiledIgnoreEntry::Code(ignored_code) => {
736 if let Some(code) = &issue.code
737 && ignored_code == code
738 {
739 return false;
740 }
741 }
742 CompiledIgnoreEntry::Scoped { code: ignored_code, matcher } => {
743 let Some(code) = &issue.code else {
744 continue;
745 };
746
747 if ignored_code != code {
748 continue;
749 }
750
751 if let Some(name) = resolve_path(issue)
752 && matcher.is_match(&name)
753 {
754 return false;
755 }
756 }
757 CompiledIgnoreEntry::Pattern { regex, code: ignored_code, matcher } => {
758 if let Some(ignored_code) = ignored_code {
759 let Some(code) = &issue.code else {
760 continue;
761 };
762
763 if ignored_code != code {
764 continue;
765 }
766 }
767
768 if let Some(matcher) = matcher {
769 let Some(name) = resolve_path(issue) else {
770 continue;
771 };
772
773 if !matcher.is_match(&name) {
774 continue;
775 }
776 }
777
778 if issue_text_matches(issue, regex) {
779 return false;
780 }
781 }
782 }
783 }
784
785 true
786 });
787 }
788
789 pub fn filter_retain_codes(&mut self, retain_codes: &[String]) {
790 self.issues.retain(|issue| if let Some(code) = &issue.code { retain_codes.contains(code) } else { false });
791 }
792
793 pub fn take_edits(&mut self) -> impl Iterator<Item = (FileId, IssueEdits)> + '_ {
794 self.issues.iter_mut().flat_map(|issue| issue.take_edits().into_iter())
795 }
796
797 #[must_use]
799 pub fn with_edits(self) -> Self {
800 Self { issues: self.issues.into_iter().filter(|issue| !issue.edits.is_empty()).collect() }
801 }
802
803 #[must_use]
808 pub fn sorted(self) -> Self {
809 let mut issues = self.issues;
810
811 issues.sort_by(|a, b| match a.level.cmp(&b.level) {
812 Ordering::Greater => Ordering::Greater,
813 Ordering::Less => Ordering::Less,
814 Ordering::Equal => match a.code.as_deref().cmp(&b.code.as_deref()) {
815 Ordering::Less => Ordering::Less,
816 Ordering::Greater => Ordering::Greater,
817 Ordering::Equal => {
818 let a_span = a.primary_span();
819 let b_span = b.primary_span();
820
821 match (a_span, b_span) {
822 (Some(a_span), Some(b_span)) => a_span.cmp(&b_span),
823 (Some(_), None) => Ordering::Less,
824 (None, Some(_)) => Ordering::Greater,
825 (None, None) => Ordering::Equal,
826 }
827 }
828 },
829 });
830
831 Self { issues }
832 }
833
834 pub fn iter(&self) -> impl Iterator<Item = &Issue> {
835 self.issues.iter()
836 }
837
838 #[must_use]
846 pub fn to_edit_batches(self) -> HashMap<FileId, IssueEditBatches> {
847 let mut result: HashMap<FileId, Vec<(Option<String>, IssueEdits)>> = HashMap::default();
848 for issue in self.issues.into_iter().filter(|issue| !issue.edits.is_empty()) {
849 let code = issue.code;
850 for (file_id, edit_list) in issue.edits {
851 result.entry(file_id).or_default().push((code.clone(), edit_list));
852 }
853 }
854
855 result
856 }
857}
858
859fn issue_text_matches(issue: &Issue, regex: &Regex) -> bool {
865 if regex.is_match(&issue.message) {
866 return true;
867 }
868
869 if issue
870 .annotations
871 .iter()
872 .any(|annotation| annotation.message.as_ref().is_some_and(|message| regex.is_match(message)))
873 {
874 return true;
875 }
876
877 if issue.notes.iter().any(|note| regex.is_match(note)) {
878 return true;
879 }
880
881 issue.help.as_ref().is_some_and(|help| regex.is_match(help))
882}
883
884impl IntoIterator for IssueCollection {
885 type Item = Issue;
886
887 type IntoIter = std::vec::IntoIter<Issue>;
888
889 fn into_iter(self) -> Self::IntoIter {
890 self.issues.into_iter()
891 }
892}
893
894impl<'collection> IntoIterator for &'collection IssueCollection {
895 type Item = &'collection Issue;
896
897 type IntoIter = std::slice::Iter<'collection, Issue>;
898
899 fn into_iter(self) -> Self::IntoIter {
900 self.issues.iter()
901 }
902}
903
904impl Default for IssueCollection {
905 fn default() -> Self {
906 Self::new()
907 }
908}
909
910impl IntoIterator for Issue {
911 type Item = Issue;
912 type IntoIter = Once<Issue>;
913
914 fn into_iter(self) -> Self::IntoIter {
915 std::iter::once(self)
916 }
917}
918
919impl FromIterator<Issue> for IssueCollection {
920 fn from_iter<T>(iter: T) -> Self
921 where
922 T: IntoIterator<Item = Issue>,
923 {
924 Self { issues: iter.into_iter().collect() }
925 }
926}
927
928#[cfg(test)]
929mod tests {
930 use std::collections::HashMap;
931
932 use super::*;
933
934 #[test]
935 pub fn test_highest_collection_level() {
936 let mut collection = IssueCollection::from(vec![]);
937 assert_eq!(collection.get_highest_level(), None);
938
939 collection.push(Issue::note("note"));
940 assert_eq!(collection.get_highest_level(), Some(Level::Note));
941
942 collection.push(Issue::help("help"));
943 assert_eq!(collection.get_highest_level(), Some(Level::Help));
944
945 collection.push(Issue::warning("warning"));
946 assert_eq!(collection.get_highest_level(), Some(Level::Warning));
947
948 collection.push(Issue::error("error"));
949 assert_eq!(collection.get_highest_level(), Some(Level::Error));
950 }
951
952 #[test]
953 pub fn test_level_downgrade() {
954 assert_eq!(Level::Error.downgrade(), Level::Warning);
955 assert_eq!(Level::Warning.downgrade(), Level::Help);
956 assert_eq!(Level::Help.downgrade(), Level::Note);
957 assert_eq!(Level::Note.downgrade(), Level::Note);
958 }
959
960 #[test]
961 pub fn test_issue_collection_with_maximum_level() {
962 let mut collection = IssueCollection::from(vec![
963 Issue::error("error"),
964 Issue::warning("warning"),
965 Issue::help("help"),
966 Issue::note("note"),
967 ]);
968
969 collection = collection.with_maximum_level(Level::Warning);
970 assert_eq!(collection.len(), 3);
971 assert_eq!(
972 collection.iter().map(|issue| issue.level).collect::<Vec<_>>(),
973 vec![Level::Warning, Level::Help, Level::Note]
974 );
975 }
976
977 #[test]
978 pub fn test_issue_collection_with_minimum_level() {
979 let mut collection = IssueCollection::from(vec![
980 Issue::error("error"),
981 Issue::warning("warning"),
982 Issue::help("help"),
983 Issue::note("note"),
984 ]);
985
986 collection = collection.with_minimum_level(Level::Warning);
987 assert_eq!(collection.len(), 2);
988 assert_eq!(collection.iter().map(|issue| issue.level).collect::<Vec<_>>(), vec![Level::Error, Level::Warning,]);
989 }
990
991 #[test]
992 pub fn test_issue_collection_has_minimum_level() {
993 let mut collection = IssueCollection::from(vec![]);
994
995 assert!(!collection.has_minimum_level(Level::Error));
996 assert!(!collection.has_minimum_level(Level::Warning));
997 assert!(!collection.has_minimum_level(Level::Help));
998 assert!(!collection.has_minimum_level(Level::Note));
999
1000 collection.push(Issue::note("note"));
1001
1002 assert!(!collection.has_minimum_level(Level::Error));
1003 assert!(!collection.has_minimum_level(Level::Warning));
1004 assert!(!collection.has_minimum_level(Level::Help));
1005 assert!(collection.has_minimum_level(Level::Note));
1006
1007 collection.push(Issue::help("help"));
1008
1009 assert!(!collection.has_minimum_level(Level::Error));
1010 assert!(!collection.has_minimum_level(Level::Warning));
1011 assert!(collection.has_minimum_level(Level::Help));
1012 assert!(collection.has_minimum_level(Level::Note));
1013
1014 collection.push(Issue::warning("warning"));
1015
1016 assert!(!collection.has_minimum_level(Level::Error));
1017 assert!(collection.has_minimum_level(Level::Warning));
1018 assert!(collection.has_minimum_level(Level::Help));
1019 assert!(collection.has_minimum_level(Level::Note));
1020
1021 collection.push(Issue::error("error"));
1022
1023 assert!(collection.has_minimum_level(Level::Error));
1024 assert!(collection.has_minimum_level(Level::Warning));
1025 assert!(collection.has_minimum_level(Level::Help));
1026 assert!(collection.has_minimum_level(Level::Note));
1027 }
1028
1029 #[test]
1030 pub fn test_issue_collection_level_count() {
1031 let mut collection = IssueCollection::from(vec![]);
1032
1033 assert_eq!(collection.get_level_count(Level::Error), 0);
1034 assert_eq!(collection.get_level_count(Level::Warning), 0);
1035 assert_eq!(collection.get_level_count(Level::Help), 0);
1036 assert_eq!(collection.get_level_count(Level::Note), 0);
1037
1038 collection.push(Issue::error("error"));
1039
1040 assert_eq!(collection.get_level_count(Level::Error), 1);
1041 assert_eq!(collection.get_level_count(Level::Warning), 0);
1042 assert_eq!(collection.get_level_count(Level::Help), 0);
1043 assert_eq!(collection.get_level_count(Level::Note), 0);
1044
1045 collection.push(Issue::warning("warning"));
1046
1047 assert_eq!(collection.get_level_count(Level::Error), 1);
1048 assert_eq!(collection.get_level_count(Level::Warning), 1);
1049 assert_eq!(collection.get_level_count(Level::Help), 0);
1050 assert_eq!(collection.get_level_count(Level::Note), 0);
1051
1052 collection.push(Issue::help("help"));
1053
1054 assert_eq!(collection.get_level_count(Level::Error), 1);
1055 assert_eq!(collection.get_level_count(Level::Warning), 1);
1056 assert_eq!(collection.get_level_count(Level::Help), 1);
1057 assert_eq!(collection.get_level_count(Level::Note), 0);
1058
1059 collection.push(Issue::note("note"));
1060
1061 assert_eq!(collection.get_level_count(Level::Error), 1);
1062 assert_eq!(collection.get_level_count(Level::Warning), 1);
1063 assert_eq!(collection.get_level_count(Level::Help), 1);
1064 assert_eq!(collection.get_level_count(Level::Note), 1);
1065 }
1066
1067 #[test]
1068 pub fn test_primary_span_is_deterministic() {
1069 let file = FileId::zero();
1070 let span_later = Span::new(file, 20u32.into(), 25u32.into());
1071 let span_earlier = Span::new(file, 5u32.into(), 10u32.into());
1072
1073 let issue = Issue::error("x")
1074 .with_annotation(Annotation::primary(span_later))
1075 .with_annotation(Annotation::primary(span_earlier));
1076
1077 assert_eq!(issue.primary_span(), Some(span_earlier));
1078 }
1079
1080 fn ignore_fixture() -> (IssueCollection, HashMap<FileId, &'static [u8]>) {
1081 let file_id = |name: &[u8]| FileId::new(name);
1082
1083 let paths: [&[u8]; 4] =
1084 [b"src/App.php", b"tests/Unit/FooTest.php", b"modules/auth/views/login.tpl", b"types/user/form.tpl"];
1085
1086 let mut mapping = HashMap::new();
1087 let issues: Vec<Issue> = paths
1088 .iter()
1089 .map(|p| {
1090 let id = file_id(p);
1091 mapping.insert(id, *p);
1092 Issue::error("oops").with_code("invalid-global").with_annotation(Annotation::primary(Span::new(
1093 id,
1094 0u32.into(),
1095 1u32.into(),
1096 )))
1097 })
1098 .collect();
1099
1100 (IssueCollection::from(issues), mapping)
1101 }
1102
1103 fn resolve<'mapping>(
1104 mapping: &'mapping HashMap<FileId, &'static [u8]>,
1105 ) -> impl Fn(FileId) -> Option<String> + 'mapping {
1106 move |id| mapping.get(&id).map(|s| String::from_utf8_lossy(s).into_owned())
1107 }
1108
1109 fn remaining_paths(collection: &IssueCollection, mapping: &HashMap<FileId, &'static [u8]>) -> Vec<String> {
1110 collection
1111 .iter()
1112 .filter_map(|issue| issue.primary_span().and_then(|s| mapping.get(&s.file_id)).copied())
1113 .map(|bytes| String::from_utf8_lossy(bytes).into_owned())
1114 .collect()
1115 }
1116
1117 #[test]
1118 pub fn test_filter_out_ignored_with_plain_prefix() {
1119 let (mut collection, mapping) = ignore_fixture();
1120 let entries =
1121 vec![IgnoreEntry::Scoped { code: "invalid-global".to_string(), paths: vec!["tests/".to_string()] }];
1122 let set = CompiledIgnoreSet::compile(&entries, GlobSettings::default());
1123
1124 collection.filter_out_ignored(&set, resolve(&mapping));
1125
1126 assert_eq!(
1127 remaining_paths(&collection, &mapping),
1128 vec![
1129 "src/App.php".to_string(),
1130 "modules/auth/views/login.tpl".to_string(),
1131 "types/user/form.tpl".to_string(),
1132 ]
1133 );
1134 }
1135
1136 #[test]
1137 pub fn test_filter_out_ignored_with_glob_pattern() {
1138 let (mut collection, mapping) = ignore_fixture();
1139 let entries = vec![IgnoreEntry::Scoped {
1140 code: "invalid-global".to_string(),
1141 paths: vec!["modules/*/*/*.tpl".to_string(), "types/*/*.tpl".to_string()],
1142 }];
1143 let set = CompiledIgnoreSet::compile(&entries, GlobSettings::default());
1144
1145 collection.filter_out_ignored(&set, resolve(&mapping));
1146
1147 assert_eq!(
1148 remaining_paths(&collection, &mapping),
1149 vec!["src/App.php".to_string(), "tests/Unit/FooTest.php".to_string()]
1150 );
1151 }
1152
1153 #[test]
1154 pub fn test_filter_out_ignored_mixes_plain_and_glob() {
1155 let (mut collection, mapping) = ignore_fixture();
1156 let entries = vec![IgnoreEntry::Scoped {
1157 code: "invalid-global".to_string(),
1158 paths: vec!["tests/".to_string(), "modules/*/*/*.tpl".to_string(), "types/*/*.tpl".to_string()],
1159 }];
1160 let set = CompiledIgnoreSet::compile(&entries, GlobSettings::default());
1161
1162 collection.filter_out_ignored(&set, resolve(&mapping));
1163
1164 assert_eq!(remaining_paths(&collection, &mapping), vec!["src/App.php".to_string()]);
1165 }
1166
1167 #[test]
1168 pub fn test_filter_out_ignored_respects_code_scope() {
1169 let (mut collection, mapping) = ignore_fixture();
1170 let entries = vec![IgnoreEntry::Scoped { code: "different-code".to_string(), paths: vec!["**/*".to_string()] }];
1171 let set = CompiledIgnoreSet::compile(&entries, GlobSettings::default());
1172
1173 collection.filter_out_ignored(&set, resolve(&mapping));
1174
1175 assert_eq!(collection.len(), 4);
1176 }
1177
1178 fn pattern_fixture() -> (IssueCollection, HashMap<FileId, &'static [u8]>) {
1179 let paths: [&[u8]; 3] = [b"src/App.php", b"src/Bridge/Symfony.php", b"tests/Unit/FooTest.php"];
1180 let mut mapping = HashMap::new();
1181 let mut issues: Vec<Issue> = Vec::new();
1182
1183 let id0 = FileId::new(blake3::hash(paths[0]).as_bytes());
1184 mapping.insert(id0, paths[0]);
1185 issues.push(
1186 Issue::error("Saw type `mixed` in Symfony bridge.")
1187 .with_code("mixed-assignment")
1188 .with_annotation(Annotation::primary(Span::new(id0, 0u32.into(), 1u32.into()))),
1189 );
1190
1191 let id1 = FileId::new(blake3::hash(paths[1]).as_bytes());
1192 mapping.insert(id1, paths[1]);
1193 issues.push(
1194 Issue::error("Could not infer a precise return type.")
1195 .with_code("mixed-assignment")
1196 .with_note("Originates from Symfony vendor stubs.")
1197 .with_annotation(Annotation::primary(Span::new(id1, 0u32.into(), 1u32.into()))),
1198 );
1199
1200 let id2 = FileId::new(blake3::hash(paths[2]).as_bytes());
1201 mapping.insert(id2, paths[2]);
1202 issues.push(
1203 Issue::error("Unused variable.")
1204 .with_code("unused-variable")
1205 .with_annotation(Annotation::primary(Span::new(id2, 0u32.into(), 1u32.into()))),
1206 );
1207
1208 (IssueCollection::from(issues), mapping)
1209 }
1210
1211 #[test]
1212 pub fn test_pattern_matches_title_and_note() {
1213 let (mut collection, mapping) = pattern_fixture();
1214 let entries = vec![IgnoreEntry::Pattern {
1215 pattern: "Symfony".to_string(),
1216 code: Some("mixed-assignment".to_string()),
1217 paths: None,
1218 }];
1219 let set = CompiledIgnoreSet::compile(&entries, GlobSettings::default());
1220
1221 collection.filter_out_ignored(&set, resolve(&mapping));
1222
1223 assert_eq!(remaining_paths(&collection, &mapping), vec!["tests/Unit/FooTest.php".to_string()]);
1224 }
1225
1226 #[test]
1227 pub fn test_pattern_without_code_matches_across_codes() {
1228 let (mut collection, mapping) = pattern_fixture();
1229 let entries = vec![IgnoreEntry::Pattern { pattern: "Symfony".to_string(), code: None, paths: None }];
1230 let set = CompiledIgnoreSet::compile(&entries, GlobSettings::default());
1231
1232 collection.filter_out_ignored(&set, resolve(&mapping));
1233
1234 assert_eq!(remaining_paths(&collection, &mapping), vec!["tests/Unit/FooTest.php".to_string()]);
1235 }
1236
1237 #[test]
1238 pub fn test_pattern_with_path_scope() {
1239 let (mut collection, mapping) = pattern_fixture();
1240 let entries = vec![IgnoreEntry::Pattern {
1241 pattern: "Symfony".to_string(),
1242 code: None,
1243 paths: Some(vec!["src/Bridge/".to_string()]),
1244 }];
1245 let set = CompiledIgnoreSet::compile(&entries, GlobSettings::default());
1246
1247 collection.filter_out_ignored(&set, resolve(&mapping));
1248
1249 assert_eq!(
1250 remaining_paths(&collection, &mapping),
1251 vec!["src/App.php".to_string(), "tests/Unit/FooTest.php".to_string()]
1252 );
1253 }
1254
1255 #[test]
1256 pub fn test_pattern_case_insensitive_with_flag() {
1257 let (mut collection, mapping) = pattern_fixture();
1258 let entries = vec![IgnoreEntry::Pattern { pattern: "(?i)symfony".to_string(), code: None, paths: None }];
1259 let set = CompiledIgnoreSet::compile(&entries, GlobSettings::default());
1260
1261 collection.filter_out_ignored(&set, resolve(&mapping));
1262
1263 assert_eq!(remaining_paths(&collection, &mapping), vec!["tests/Unit/FooTest.php".to_string()]);
1264 }
1265
1266 #[test]
1267 pub fn test_pattern_invalid_regex_is_skipped() {
1268 let (mut collection, mapping) = pattern_fixture();
1269 let entries = vec![
1270 IgnoreEntry::Pattern { pattern: "[unterminated".to_string(), code: None, paths: None },
1271 IgnoreEntry::Code("unused-variable".to_string()),
1272 ];
1273 let set = CompiledIgnoreSet::compile(&entries, GlobSettings::default());
1274
1275 assert_eq!(set.len(), 1);
1276
1277 collection.filter_out_ignored(&set, resolve(&mapping));
1278
1279 assert_eq!(
1280 remaining_paths(&collection, &mapping),
1281 vec!["src/App.php".to_string(), "src/Bridge/Symfony.php".to_string()]
1282 );
1283 }
1284
1285 #[test]
1286 pub fn test_pattern_matches_help_message() {
1287 let id = FileId::new(blake3::hash(b"src/foo.php").as_bytes());
1288 let mut mapping: HashMap<FileId, &'static [u8]> = HashMap::new();
1289 mapping.insert(id, &b"src/foo.php"[..]);
1290 let mut collection = IssueCollection::from(vec![
1291 Issue::error("Title.")
1292 .with_code("some-code")
1293 .with_help("Consider migrating off legacy Symfony bridge.")
1294 .with_annotation(Annotation::primary(Span::new(id, 0u32.into(), 1u32.into()))),
1295 ]);
1296
1297 let entries = vec![IgnoreEntry::Pattern { pattern: "Symfony".to_string(), code: None, paths: None }];
1298 let set = CompiledIgnoreSet::compile(&entries, GlobSettings::default());
1299
1300 collection.filter_out_ignored(&set, resolve(&mapping));
1301
1302 assert!(collection.is_empty());
1303 }
1304}