1use 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
39#[serde(untagged)]
40pub enum IgnoreEntry {
41 Code(String),
43 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#[derive(Debug, PartialEq, Eq, Ord, Copy, Clone, Hash, PartialOrd, Deserialize, Serialize)]
84pub enum AnnotationKind {
85 Primary,
87 Secondary,
89}
90
91#[derive(Debug, PartialEq, Eq, Ord, Clone, Hash, PartialOrd, Deserialize, Serialize)]
93pub struct Annotation {
94 pub message: Option<String>,
96 pub kind: AnnotationKind,
98 pub span: Span,
100}
101
102#[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 #[serde(alias = "note")]
110 Note,
111 #[serde(alias = "help")]
113 Help,
114 #[serde(alias = "warning", alias = "warn")]
116 Warning,
117 #[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#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
141pub struct Issue {
142 pub level: Level,
144 pub code: Option<String>,
146 pub message: String,
148 pub notes: Vec<String>,
150 pub help: Option<String>,
152 pub link: Option<String>,
154 pub annotations: Vec<Annotation>,
156 pub edits: HashMap<FileId, IssueEdits>,
158}
159
160#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
162pub struct IssueCollection {
163 issues: Vec<Issue>,
164}
165
166impl AnnotationKind {
167 #[inline]
169 #[must_use]
170 pub const fn is_primary(&self) -> bool {
171 matches!(self, AnnotationKind::Primary)
172 }
173
174 #[inline]
176 #[must_use]
177 pub const fn is_secondary(&self) -> bool {
178 matches!(self, AnnotationKind::Secondary)
179 }
180}
181
182impl Annotation {
183 #[must_use]
200 pub fn new(kind: AnnotationKind, span: Span) -> Self {
201 Self { message: None, kind, span }
202 }
203
204 #[must_use]
221 pub fn primary(span: Span) -> Self {
222 Self::new(AnnotationKind::Primary, span)
223 }
224
225 #[must_use]
242 pub fn secondary(span: Span) -> Self {
243 Self::new(AnnotationKind::Secondary, span)
244 }
245
246 #[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 #[must_use]
271 pub fn is_primary(&self) -> bool {
272 self.kind == AnnotationKind::Primary
273 }
274}
275
276impl Level {
277 #[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 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 pub fn error(message: impl Into<String>) -> Self {
346 Self::new(Level::Error, message)
347 }
348
349 pub fn warning(message: impl Into<String>) -> Self {
359 Self::new(Level::Warning, message)
360 }
361
362 pub fn help(message: impl Into<String>) -> Self {
372 Self::new(Level::Help, message)
373 }
374
375 pub fn note(message: impl Into<String>) -> Self {
385 Self::new(Level::Note, message)
386 }
387
388 #[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 #[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 #[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 #[must_use]
445 pub fn primary_span(&self) -> Option<Span> {
446 self.primary_annotation().map(|annotation| annotation.span)
447 }
448
449 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[must_use]
577 pub fn has_minimum_level(&self, level: Level) -> bool {
578 self.issues.iter().any(|issue| issue.level >= level)
579 }
580
581 #[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 #[must_use]
589 pub fn get_highest_level(&self) -> Option<Level> {
590 self.issues.iter().map(|issue| issue.level).max()
591 }
592
593 #[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 #[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 #[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 #[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}