use std::cmp::Ordering;
use std::iter::Once;
use std::str::FromStr;
use foldhash::HashMap;
use foldhash::HashMapExt;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
use strum::Display;
use strum::VariantNames;
use mago_database::GlobSettings;
use mago_database::file::FileId;
use mago_database::matcher::ExclusionMatcher;
use mago_span::Span;
use mago_text_edit::TextEdit;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(untagged)]
pub enum IgnoreEntry {
Code(String),
Scoped {
code: String,
#[serde(rename = "in", deserialize_with = "one_or_many")]
paths: Vec<String>,
},
}
fn one_or_many<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum OneOrMany {
One(String),
Many(Vec<String>),
}
match OneOrMany::deserialize(deserializer)? {
OneOrMany::One(s) => Ok(vec![s]),
OneOrMany::Many(v) => Ok(v),
}
}
mod formatter;
mod internal;
pub mod baseline;
pub mod color;
pub mod error;
pub mod output;
pub mod reporter;
pub use color::ColorChoice;
pub use formatter::ReportingFormat;
pub use output::ReportingTarget;
#[derive(Debug, PartialEq, Eq, Ord, Copy, Clone, Hash, PartialOrd, Deserialize, Serialize)]
pub enum AnnotationKind {
Primary,
Secondary,
}
#[derive(Debug, PartialEq, Eq, Ord, Clone, Hash, PartialOrd, Deserialize, Serialize)]
pub struct Annotation {
pub message: Option<String>,
pub kind: AnnotationKind,
pub span: Span,
}
#[derive(
Debug, PartialEq, Eq, Ord, Copy, Clone, Hash, PartialOrd, Deserialize, Serialize, Display, VariantNames, JsonSchema,
)]
#[strum(serialize_all = "lowercase")]
pub enum Level {
#[serde(alias = "note")]
Note,
#[serde(alias = "help")]
Help,
#[serde(alias = "warning", alias = "warn")]
Warning,
#[serde(alias = "error", alias = "err")]
Error,
}
impl FromStr for Level {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"note" => Ok(Self::Note),
"help" => Ok(Self::Help),
"warning" => Ok(Self::Warning),
"error" => Ok(Self::Error),
_ => Err(()),
}
}
}
type IssueEdits = Vec<TextEdit>;
type IssueEditBatches = Vec<(Option<String>, IssueEdits)>;
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
pub struct Issue {
pub level: Level,
pub code: Option<String>,
pub message: String,
pub notes: Vec<String>,
pub help: Option<String>,
pub link: Option<String>,
pub annotations: Vec<Annotation>,
pub edits: HashMap<FileId, IssueEdits>,
}
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
pub struct IssueCollection {
issues: Vec<Issue>,
}
impl AnnotationKind {
#[inline]
#[must_use]
pub const fn is_primary(&self) -> bool {
matches!(self, AnnotationKind::Primary)
}
#[inline]
#[must_use]
pub const fn is_secondary(&self) -> bool {
matches!(self, AnnotationKind::Secondary)
}
}
impl Annotation {
#[must_use]
pub fn new(kind: AnnotationKind, span: Span) -> Self {
Self { message: None, kind, span }
}
#[must_use]
pub fn primary(span: Span) -> Self {
Self::new(AnnotationKind::Primary, span)
}
#[must_use]
pub fn secondary(span: Span) -> Self {
Self::new(AnnotationKind::Secondary, span)
}
#[must_use]
pub fn with_message(mut self, message: impl Into<String>) -> Self {
self.message = Some(message.into());
self
}
#[must_use]
pub fn is_primary(&self) -> bool {
self.kind == AnnotationKind::Primary
}
}
impl Level {
#[must_use]
pub fn downgrade(&self) -> Self {
match self {
Level::Error => Level::Warning,
Level::Warning => Level::Help,
Level::Help | Level::Note => Level::Note,
}
}
}
impl Issue {
pub fn new(level: Level, message: impl Into<String>) -> Self {
Self {
level,
code: None,
message: message.into(),
annotations: Vec::new(),
notes: Vec::new(),
help: None,
link: None,
edits: HashMap::default(),
}
}
pub fn error(message: impl Into<String>) -> Self {
Self::new(Level::Error, message)
}
pub fn warning(message: impl Into<String>) -> Self {
Self::new(Level::Warning, message)
}
pub fn help(message: impl Into<String>) -> Self {
Self::new(Level::Help, message)
}
pub fn note(message: impl Into<String>) -> Self {
Self::new(Level::Note, message)
}
#[must_use]
pub fn with_code(mut self, code: impl Into<String>) -> Self {
self.code = Some(code.into());
self
}
#[must_use]
pub fn with_annotation(mut self, annotation: Annotation) -> Self {
self.annotations.push(annotation);
self
}
#[must_use]
pub fn with_annotations(mut self, annotation: impl IntoIterator<Item = Annotation>) -> Self {
self.annotations.extend(annotation);
self
}
#[must_use]
pub fn primary_annotation(&self) -> Option<&Annotation> {
self.annotations.iter().filter(|annotation| annotation.is_primary()).min_by_key(|annotation| annotation.span)
}
#[must_use]
pub fn primary_span(&self) -> Option<Span> {
self.primary_annotation().map(|annotation| annotation.span)
}
#[must_use]
pub fn with_note(mut self, note: impl Into<String>) -> Self {
self.notes.push(note.into());
self
}
#[must_use]
pub fn with_help(mut self, help: impl Into<String>) -> Self {
self.help = Some(help.into());
self
}
#[must_use]
pub fn with_link(mut self, link: impl Into<String>) -> Self {
self.link = Some(link.into());
self
}
#[must_use]
pub fn with_edit(mut self, file_id: FileId, edit: TextEdit) -> Self {
self.edits.entry(file_id).or_default().push(edit);
self
}
#[must_use]
pub fn with_file_edits(mut self, file_id: FileId, edits: IssueEdits) -> Self {
if !edits.is_empty() {
self.edits.entry(file_id).or_default().extend(edits);
}
self
}
#[must_use]
pub fn take_edits(&mut self) -> HashMap<FileId, IssueEdits> {
std::mem::replace(&mut self.edits, HashMap::with_capacity(0))
}
}
impl IssueCollection {
#[must_use]
pub fn new() -> Self {
Self { issues: Vec::new() }
}
pub fn from(issues: impl IntoIterator<Item = Issue>) -> Self {
Self { issues: issues.into_iter().collect() }
}
pub fn push(&mut self, issue: Issue) {
self.issues.push(issue);
}
pub fn extend(&mut self, issues: impl IntoIterator<Item = Issue>) {
self.issues.extend(issues);
}
pub fn reserve(&mut self, additional: usize) {
self.issues.reserve(additional);
}
pub fn shrink_to_fit(&mut self) {
self.issues.shrink_to_fit();
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.issues.is_empty()
}
#[must_use]
pub fn len(&self) -> usize {
self.issues.len()
}
#[must_use]
pub fn with_maximum_level(self, level: Level) -> Self {
Self { issues: self.issues.into_iter().filter(|issue| issue.level <= level).collect() }
}
#[must_use]
pub fn with_minimum_level(self, level: Level) -> Self {
Self { issues: self.issues.into_iter().filter(|issue| issue.level >= level).collect() }
}
#[must_use]
pub fn has_minimum_level(&self, level: Level) -> bool {
self.issues.iter().any(|issue| issue.level >= level)
}
#[must_use]
pub fn get_level_count(&self, level: Level) -> usize {
self.issues.iter().filter(|issue| issue.level == level).count()
}
#[must_use]
pub fn get_highest_level(&self) -> Option<Level> {
self.issues.iter().map(|issue| issue.level).max()
}
#[must_use]
pub fn get_lowest_level(&self) -> Option<Level> {
self.issues.iter().map(|issue| issue.level).min()
}
pub fn filter_out_ignored<F>(&mut self, ignore: &[IgnoreEntry], glob: GlobSettings, resolve_file_name: F)
where
F: Fn(FileId) -> Option<String>,
{
if ignore.is_empty() {
return;
}
enum CompiledEntry<'a> {
Code(&'a str),
Scoped { code: &'a str, matcher: ExclusionMatcher<&'a str> },
}
let compiled: Vec<CompiledEntry<'_>> = ignore
.iter()
.filter_map(|entry| match entry {
IgnoreEntry::Code(code) => Some(CompiledEntry::Code(code.as_str())),
IgnoreEntry::Scoped { code, paths } => {
match ExclusionMatcher::compile(paths.iter().map(String::as_str), glob) {
Ok(matcher) => Some(CompiledEntry::Scoped { code: code.as_str(), matcher }),
Err(err) => {
tracing::error!(
"Failed to compile ignore patterns for `{code}`: {err}. Entry will be skipped."
);
None
}
}
}
})
.collect();
self.issues.retain(|issue| {
let Some(code) = &issue.code else {
return true;
};
let mut resolved_file_name: Option<Option<String>> = None;
for entry in &compiled {
match entry {
CompiledEntry::Code(ignored) if *ignored == code => return false,
CompiledEntry::Scoped { code: ignored, matcher } if *ignored == code => {
let file_name = resolved_file_name
.get_or_insert_with(|| {
issue.primary_span().and_then(|span| resolve_file_name(span.file_id))
})
.as_deref();
if let Some(name) = file_name
&& matcher.is_match(name)
{
return false;
}
}
_ => {}
}
}
true
});
}
pub fn filter_retain_codes(&mut self, retain_codes: &[String]) {
self.issues.retain(|issue| if let Some(code) = &issue.code { retain_codes.contains(code) } else { false });
}
pub fn take_edits(&mut self) -> impl Iterator<Item = (FileId, IssueEdits)> + '_ {
self.issues.iter_mut().flat_map(|issue| issue.take_edits().into_iter())
}
#[must_use]
pub fn with_edits(self) -> Self {
Self { issues: self.issues.into_iter().filter(|issue| !issue.edits.is_empty()).collect() }
}
#[must_use]
pub fn sorted(self) -> Self {
let mut issues = self.issues;
issues.sort_by(|a, b| match a.level.cmp(&b.level) {
Ordering::Greater => Ordering::Greater,
Ordering::Less => Ordering::Less,
Ordering::Equal => match a.code.as_deref().cmp(&b.code.as_deref()) {
Ordering::Less => Ordering::Less,
Ordering::Greater => Ordering::Greater,
Ordering::Equal => {
let a_span = a.primary_span();
let b_span = b.primary_span();
match (a_span, b_span) {
(Some(a_span), Some(b_span)) => a_span.cmp(&b_span),
(Some(_), None) => Ordering::Less,
(None, Some(_)) => Ordering::Greater,
(None, None) => Ordering::Equal,
}
}
},
});
Self { issues }
}
pub fn iter(&self) -> impl Iterator<Item = &Issue> {
self.issues.iter()
}
#[must_use]
pub fn to_edit_batches(self) -> HashMap<FileId, IssueEditBatches> {
let mut result: HashMap<FileId, Vec<(Option<String>, IssueEdits)>> = HashMap::default();
for issue in self.issues.into_iter().filter(|issue| !issue.edits.is_empty()) {
let code = issue.code;
for (file_id, edit_list) in issue.edits {
result.entry(file_id).or_default().push((code.clone(), edit_list));
}
}
result
}
}
impl IntoIterator for IssueCollection {
type Item = Issue;
type IntoIter = std::vec::IntoIter<Issue>;
fn into_iter(self) -> Self::IntoIter {
self.issues.into_iter()
}
}
impl<'a> IntoIterator for &'a IssueCollection {
type Item = &'a Issue;
type IntoIter = std::slice::Iter<'a, Issue>;
fn into_iter(self) -> Self::IntoIter {
self.issues.iter()
}
}
impl Default for IssueCollection {
fn default() -> Self {
Self::new()
}
}
impl IntoIterator for Issue {
type Item = Issue;
type IntoIter = Once<Issue>;
fn into_iter(self) -> Self::IntoIter {
std::iter::once(self)
}
}
impl FromIterator<Issue> for IssueCollection {
fn from_iter<T: IntoIterator<Item = Issue>>(iter: T) -> Self {
Self { issues: iter.into_iter().collect() }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
pub fn test_highest_collection_level() {
let mut collection = IssueCollection::from(vec![]);
assert_eq!(collection.get_highest_level(), None);
collection.push(Issue::note("note"));
assert_eq!(collection.get_highest_level(), Some(Level::Note));
collection.push(Issue::help("help"));
assert_eq!(collection.get_highest_level(), Some(Level::Help));
collection.push(Issue::warning("warning"));
assert_eq!(collection.get_highest_level(), Some(Level::Warning));
collection.push(Issue::error("error"));
assert_eq!(collection.get_highest_level(), Some(Level::Error));
}
#[test]
pub fn test_level_downgrade() {
assert_eq!(Level::Error.downgrade(), Level::Warning);
assert_eq!(Level::Warning.downgrade(), Level::Help);
assert_eq!(Level::Help.downgrade(), Level::Note);
assert_eq!(Level::Note.downgrade(), Level::Note);
}
#[test]
pub fn test_issue_collection_with_maximum_level() {
let mut collection = IssueCollection::from(vec![
Issue::error("error"),
Issue::warning("warning"),
Issue::help("help"),
Issue::note("note"),
]);
collection = collection.with_maximum_level(Level::Warning);
assert_eq!(collection.len(), 3);
assert_eq!(
collection.iter().map(|issue| issue.level).collect::<Vec<_>>(),
vec![Level::Warning, Level::Help, Level::Note]
);
}
#[test]
pub fn test_issue_collection_with_minimum_level() {
let mut collection = IssueCollection::from(vec![
Issue::error("error"),
Issue::warning("warning"),
Issue::help("help"),
Issue::note("note"),
]);
collection = collection.with_minimum_level(Level::Warning);
assert_eq!(collection.len(), 2);
assert_eq!(collection.iter().map(|issue| issue.level).collect::<Vec<_>>(), vec![Level::Error, Level::Warning,]);
}
#[test]
pub fn test_issue_collection_has_minimum_level() {
let mut collection = IssueCollection::from(vec![]);
assert!(!collection.has_minimum_level(Level::Error));
assert!(!collection.has_minimum_level(Level::Warning));
assert!(!collection.has_minimum_level(Level::Help));
assert!(!collection.has_minimum_level(Level::Note));
collection.push(Issue::note("note"));
assert!(!collection.has_minimum_level(Level::Error));
assert!(!collection.has_minimum_level(Level::Warning));
assert!(!collection.has_minimum_level(Level::Help));
assert!(collection.has_minimum_level(Level::Note));
collection.push(Issue::help("help"));
assert!(!collection.has_minimum_level(Level::Error));
assert!(!collection.has_minimum_level(Level::Warning));
assert!(collection.has_minimum_level(Level::Help));
assert!(collection.has_minimum_level(Level::Note));
collection.push(Issue::warning("warning"));
assert!(!collection.has_minimum_level(Level::Error));
assert!(collection.has_minimum_level(Level::Warning));
assert!(collection.has_minimum_level(Level::Help));
assert!(collection.has_minimum_level(Level::Note));
collection.push(Issue::error("error"));
assert!(collection.has_minimum_level(Level::Error));
assert!(collection.has_minimum_level(Level::Warning));
assert!(collection.has_minimum_level(Level::Help));
assert!(collection.has_minimum_level(Level::Note));
}
#[test]
pub fn test_issue_collection_level_count() {
let mut collection = IssueCollection::from(vec![]);
assert_eq!(collection.get_level_count(Level::Error), 0);
assert_eq!(collection.get_level_count(Level::Warning), 0);
assert_eq!(collection.get_level_count(Level::Help), 0);
assert_eq!(collection.get_level_count(Level::Note), 0);
collection.push(Issue::error("error"));
assert_eq!(collection.get_level_count(Level::Error), 1);
assert_eq!(collection.get_level_count(Level::Warning), 0);
assert_eq!(collection.get_level_count(Level::Help), 0);
assert_eq!(collection.get_level_count(Level::Note), 0);
collection.push(Issue::warning("warning"));
assert_eq!(collection.get_level_count(Level::Error), 1);
assert_eq!(collection.get_level_count(Level::Warning), 1);
assert_eq!(collection.get_level_count(Level::Help), 0);
assert_eq!(collection.get_level_count(Level::Note), 0);
collection.push(Issue::help("help"));
assert_eq!(collection.get_level_count(Level::Error), 1);
assert_eq!(collection.get_level_count(Level::Warning), 1);
assert_eq!(collection.get_level_count(Level::Help), 1);
assert_eq!(collection.get_level_count(Level::Note), 0);
collection.push(Issue::note("note"));
assert_eq!(collection.get_level_count(Level::Error), 1);
assert_eq!(collection.get_level_count(Level::Warning), 1);
assert_eq!(collection.get_level_count(Level::Help), 1);
assert_eq!(collection.get_level_count(Level::Note), 1);
}
#[test]
pub fn test_primary_span_is_deterministic() {
let file = FileId::zero();
let span_later = Span::new(file, 20_u32.into(), 25_u32.into());
let span_earlier = Span::new(file, 5_u32.into(), 10_u32.into());
let issue = Issue::error("x")
.with_annotation(Annotation::primary(span_later))
.with_annotation(Annotation::primary(span_earlier));
assert_eq!(issue.primary_span(), Some(span_earlier));
}
fn ignore_fixture() -> (IssueCollection, std::collections::HashMap<FileId, &'static str>) {
let file_id = |name: &str| FileId::new(name);
let paths = ["src/App.php", "tests/Unit/FooTest.php", "modules/auth/views/login.tpl", "types/user/form.tpl"];
let mut mapping = std::collections::HashMap::new();
let issues: Vec<Issue> = paths
.iter()
.map(|p| {
let id = file_id(p);
mapping.insert(id, *p);
Issue::error("oops").with_code("invalid-global").with_annotation(Annotation::primary(Span::new(
id,
0_u32.into(),
1_u32.into(),
)))
})
.collect();
(IssueCollection::from(issues), mapping)
}
#[test]
pub fn test_filter_out_ignored_with_plain_prefix() {
let (mut collection, mapping) = ignore_fixture();
let ignore =
vec![IgnoreEntry::Scoped { code: "invalid-global".to_string(), paths: vec!["tests/".to_string()] }];
collection.filter_out_ignored(&ignore, GlobSettings::default(), |id| mapping.get(&id).map(|s| s.to_string()));
let remaining: Vec<String> = collection
.iter()
.flat_map(|issue| issue.primary_span().and_then(|s| mapping.get(&s.file_id)).copied())
.map(String::from)
.collect();
assert_eq!(
remaining,
vec![
"src/App.php".to_string(),
"modules/auth/views/login.tpl".to_string(),
"types/user/form.tpl".to_string(),
]
);
}
#[test]
pub fn test_filter_out_ignored_with_glob_pattern() {
let (mut collection, mapping) = ignore_fixture();
let ignore = vec![IgnoreEntry::Scoped {
code: "invalid-global".to_string(),
paths: vec!["modules/*/*/*.tpl".to_string(), "types/*/*.tpl".to_string()],
}];
collection.filter_out_ignored(&ignore, GlobSettings::default(), |id| mapping.get(&id).map(|s| s.to_string()));
let remaining: Vec<String> = collection
.iter()
.flat_map(|issue| issue.primary_span().and_then(|s| mapping.get(&s.file_id)).copied())
.map(String::from)
.collect();
assert_eq!(remaining, vec!["src/App.php".to_string(), "tests/Unit/FooTest.php".to_string(),]);
}
#[test]
pub fn test_filter_out_ignored_mixes_plain_and_glob() {
let (mut collection, mapping) = ignore_fixture();
let ignore = vec![IgnoreEntry::Scoped {
code: "invalid-global".to_string(),
paths: vec!["tests/".to_string(), "modules/*/*/*.tpl".to_string(), "types/*/*.tpl".to_string()],
}];
collection.filter_out_ignored(&ignore, GlobSettings::default(), |id| mapping.get(&id).map(|s| s.to_string()));
let remaining: Vec<String> = collection
.iter()
.flat_map(|issue| issue.primary_span().and_then(|s| mapping.get(&s.file_id)).copied())
.map(String::from)
.collect();
assert_eq!(remaining, vec!["src/App.php".to_string()]);
}
#[test]
pub fn test_filter_out_ignored_respects_code_scope() {
let (mut collection, mapping) = ignore_fixture();
let ignore = vec![IgnoreEntry::Scoped { code: "different-code".to_string(), paths: vec!["**/*".to_string()] }];
collection.filter_out_ignored(&ignore, GlobSettings::default(), |id| mapping.get(&id).map(|s| s.to_string()));
assert_eq!(collection.len(), 4);
}
}