diff --git a/Cargo.lock b/Cargo.lock index 555ac2f3..cb137bd4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -982,6 +982,7 @@ dependencies = [ "mago-source", "serde", "serde_json", + "strum", "termtree", "tokio", ] @@ -1160,9 +1161,13 @@ dependencies = [ "ahash", "codespan-reporting", "mago-fixer", + "mago-interner", "mago-source", "mago-span", "serde", + "serde_json", + "strum", + "termcolor", ] [[package]] @@ -1217,7 +1222,6 @@ dependencies = [ name = "mago-source" version = "0.0.8" dependencies = [ - "codespan-reporting", "dashmap", "mago-interner", "serde", diff --git a/Cargo.toml b/Cargo.toml index f04afc84..88e9c1ab 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -89,6 +89,7 @@ bitflags = "2.6.0" wasm-bindgen = "0.2.97" serde-wasm-bindgen = "0.4" diffy = "0.4.0" +termcolor = "1.4.1" [dependencies] mago-cli = { workspace = true } diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 54a3f683..751f98b1 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -25,3 +25,4 @@ clap = { workspace = true } ahash = { workspace = true } termtree = { workspace = true } serde_json = { workspace = true } +strum = { workspace = true } diff --git a/crates/cli/src/commands/ast.rs b/crates/cli/src/commands/ast.rs index c8447229..0147f65c 100644 --- a/crates/cli/src/commands/ast.rs +++ b/crates/cli/src/commands/ast.rs @@ -6,10 +6,13 @@ use mago_ast::node::NodeKind; use mago_ast::Node; use mago_interner::ThreadedInterner; use mago_reporting::reporter::Reporter; +use mago_reporting::reporter::ReportingFormat; +use mago_reporting::reporter::ReportingTarget; use mago_reporting::Issue; use mago_service::ast::AstService; use mago_source::SourceManager; +use crate::enum_variants; use crate::utils::bail; #[derive(Parser, Debug)] @@ -24,6 +27,12 @@ pub struct AstCommand { #[arg(long, help = "Outputs the result in JSON format.")] pub json: bool, + + #[arg(long, default_value_t, help = "The issue reporting target to use.", ignore_case = true, value_parser = enum_variants!(ReportingTarget))] + pub reporting_target: ReportingTarget, + + #[arg(long, default_value_t, help = "The issue reporting format to use.", ignore_case = true, value_parser = enum_variants!(ReportingFormat))] + pub reporting_format: ReportingFormat, } pub async fn execute(command: AstCommand) -> i32 { @@ -32,11 +41,13 @@ pub async fn execute(command: AstCommand) -> i32 { // Check if the file exists and is readable if !file_path.exists() { mago_feedback::error!("file '{}' does not exist.", command.file); + return 1; } if !file_path.is_file() { mago_feedback::error!("'{}' is not a valid file.", command.file); + return 1; } @@ -68,7 +79,9 @@ pub async fn execute(command: AstCommand) -> i32 { if let Some(error) = &error { let issue = Into::::into(error); - Reporter::new(source_manager).report(issue); + Reporter::new(interner, source_manager, command.reporting_target) + .report([issue], command.reporting_format) + .unwrap_or_else(bail); } } diff --git a/crates/cli/src/commands/lint.rs b/crates/cli/src/commands/lint.rs index 8d84edfa..0c275720 100644 --- a/crates/cli/src/commands/lint.rs +++ b/crates/cli/src/commands/lint.rs @@ -2,11 +2,14 @@ use clap::Parser; use mago_interner::ThreadedInterner; use mago_reporting::reporter::Reporter; +use mago_reporting::reporter::ReportingFormat; +use mago_reporting::reporter::ReportingTarget; use mago_reporting::Level; use mago_service::config::Configuration; use mago_service::linter::LintService; use mago_service::source::SourceService; +use crate::enum_variants; use crate::utils::bail; #[derive(Parser, Debug)] @@ -24,6 +27,12 @@ If `mago.toml` is not found, the default configuration is used. The command outp pub struct LintCommand { #[arg(long, short, help = "Only show fixable issues", default_value_t = false)] pub only_fixable: bool, + + #[arg(long, default_value_t, help = "The issue reporting target to use.", ignore_case = true, value_parser = enum_variants!(ReportingTarget))] + pub reporting_target: ReportingTarget, + + #[arg(long, default_value_t, help = "The issue reporting format to use.", ignore_case = true, value_parser = enum_variants!(ReportingFormat))] + pub reporting_format: ReportingFormat, } pub async fn execute(command: LintCommand, configuration: Configuration) -> i32 { @@ -36,10 +45,12 @@ pub async fn execute(command: LintCommand, configuration: Configuration) -> i32 let issues = lint_service.run().await.unwrap_or_else(bail); let issues_contain_errors = issues.get_highest_level().is_some_and(|level| level >= Level::Error); + let reporter = Reporter::new(interner, source_manager, command.reporting_target); + if command.only_fixable { - Reporter::new(source_manager).report_all(issues.only_fixable()); + reporter.report(issues.only_fixable(), command.reporting_format).unwrap_or_else(bail); } else { - Reporter::new(source_manager).report_all(issues); + reporter.report(issues, command.reporting_format).unwrap_or_else(bail); } if issues_contain_errors { diff --git a/crates/cli/src/utils/clap.rs b/crates/cli/src/utils/clap.rs new file mode 100644 index 00000000..0dcdfdaa --- /dev/null +++ b/crates/cli/src/utils/clap.rs @@ -0,0 +1,9 @@ +#[macro_export] +macro_rules! enum_variants { + ($e: ty) => {{ + use clap::builder::TypedValueParser; + use strum::VariantNames; + + clap::builder::PossibleValuesParser::new(<$e>::VARIANTS).map(|s| s.parse::<$e>().unwrap()) + }}; +} diff --git a/crates/cli/src/utils/mod.rs b/crates/cli/src/utils/mod.rs index 227fa221..0bb994ed 100644 --- a/crates/cli/src/utils/mod.rs +++ b/crates/cli/src/utils/mod.rs @@ -1,5 +1,7 @@ use std::error::Error; +pub mod clap; + pub fn print(error: impl Error) { mago_feedback::error!(target = "mago", "{}", error); mago_feedback::debug!(target = "mago", "{:#?}", error); diff --git a/crates/parser/src/error.rs b/crates/parser/src/error.rs index b4272dec..66ffe05e 100644 --- a/crates/parser/src/error.rs +++ b/crates/parser/src/error.rs @@ -85,7 +85,9 @@ impl From for ParseError { } impl From<&ParseError> for Issue { - fn from(val: &ParseError) -> Self { - Issue::error(val.to_string()).with_annotation(Annotation::primary(val.span())) + fn from(error: &ParseError) -> Self { + let span = error.span(); + + Issue::error(error.to_string()).with_annotation(Annotation::primary(span)) } } diff --git a/crates/reporting/Cargo.toml b/crates/reporting/Cargo.toml index 3ed5b389..aa24cab1 100644 --- a/crates/reporting/Cargo.toml +++ b/crates/reporting/Cargo.toml @@ -13,9 +13,13 @@ rust-version.workspace = true workspace = true [dependencies] +mago-interner = { workspace = true } mago-span = { workspace = true } mago-source = { workspace = true } mago-fixer = { workspace = true } ahash = { workspace = true } serde = { workspace = true } +serde_json = { workspace = true } codespan-reporting = { workspace = true } +termcolor = { workspace = true } +strum = { workspace = true } diff --git a/crates/reporting/src/error.rs b/crates/reporting/src/error.rs new file mode 100644 index 00000000..7794094b --- /dev/null +++ b/crates/reporting/src/error.rs @@ -0,0 +1,65 @@ +use codespan_reporting::files::Error as FilesError; +use serde_json::Error as JsonError; +use std::io::Error as IoError; + +use mago_source::error::SourceError; + +#[derive(Debug)] +pub enum ReportingError { + SourceError(SourceError), + JsonError(JsonError), + FilesError(FilesError), + IoError(IoError), + InvalidTarget(String), + InvalidFormat(String), +} + +impl std::fmt::Display for ReportingError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::SourceError(error) => write!(f, "source error: {}", error), + Self::JsonError(error) => write!(f, "json error: {}", error), + Self::FilesError(error) => write!(f, "files error: {}", error), + Self::IoError(error) => write!(f, "io error: {}", error), + Self::InvalidTarget(target) => write!(f, "invalid target: {}", target), + Self::InvalidFormat(format) => write!(f, "invalid format: {}", format), + } + } +} + +impl std::error::Error for ReportingError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::SourceError(error) => Some(error), + Self::JsonError(error) => Some(error), + Self::FilesError(error) => Some(error), + Self::IoError(error) => Some(error), + Self::InvalidTarget(_) => None, + Self::InvalidFormat(_) => None, + } + } +} + +impl From for ReportingError { + fn from(error: SourceError) -> Self { + Self::SourceError(error) + } +} + +impl From for ReportingError { + fn from(error: JsonError) -> Self { + Self::JsonError(error) + } +} + +impl From for ReportingError { + fn from(error: FilesError) -> Self { + Self::FilesError(error) + } +} + +impl From for ReportingError { + fn from(error: IoError) -> Self { + Self::IoError(error) + } +} diff --git a/crates/reporting/src/internal/emitter/codespan.rs b/crates/reporting/src/internal/emitter/codespan.rs new file mode 100644 index 00000000..7eb78647 --- /dev/null +++ b/crates/reporting/src/internal/emitter/codespan.rs @@ -0,0 +1,261 @@ +use std::cmp::Ordering; +use std::ops::Range; + +use codespan_reporting::diagnostic::Diagnostic; +use codespan_reporting::diagnostic::Label; +use codespan_reporting::diagnostic::LabelStyle; +use codespan_reporting::diagnostic::Severity; +use codespan_reporting::files::Error; +use codespan_reporting::files::Files; +use codespan_reporting::term; +use codespan_reporting::term::Config; +use codespan_reporting::term::DisplayStyle; +use termcolor::WriteColor; + +use mago_interner::ThreadedInterner; +use mago_source::error::SourceError; +use mago_source::SourceIdentifier; +use mago_source::SourceManager; + +use crate::error::ReportingError; +use crate::Annotation; +use crate::AnnotationKind; +use crate::Issue; +use crate::IssueCollection; +use crate::Level; + +pub fn rich_format( + writer: &mut dyn WriteColor, + sources: &SourceManager, + interner: &ThreadedInterner, + issues: IssueCollection, +) -> Result, ReportingError> { + codespan_format_with_config( + writer, + sources, + interner, + issues, + Config { display_style: DisplayStyle::Rich, ..Default::default() }, + ) +} + +pub fn medium_format( + writer: &mut dyn WriteColor, + sources: &SourceManager, + interner: &ThreadedInterner, + issues: IssueCollection, +) -> Result, ReportingError> { + codespan_format_with_config( + writer, + sources, + interner, + issues, + Config { display_style: DisplayStyle::Medium, ..Default::default() }, + ) +} + +pub fn short_format( + writer: &mut dyn WriteColor, + sources: &SourceManager, + interner: &ThreadedInterner, + issues: IssueCollection, +) -> Result, ReportingError> { + codespan_format_with_config( + writer, + sources, + interner, + issues, + Config { display_style: DisplayStyle::Short, ..Default::default() }, + ) +} + +fn codespan_format_with_config( + writer: &mut dyn WriteColor, + sources: &SourceManager, + interner: &ThreadedInterner, + issues: IssueCollection, + config: Config, +) -> Result, ReportingError> { + let files = SourceManagerFile(sources, interner); + + let highest_level = issues.get_highest_level(); + let mut errors = 0; + let mut warnings = 0; + let mut notes = 0; + let mut help = 0; + let mut suggestions = 0; + + for issue in issues { + match &issue.level { + Level::Note => { + notes += 1; + } + Level::Help => { + help += 1; + } + Level::Warning => { + warnings += 1; + } + Level::Error => { + errors += 1; + } + } + + if !issue.suggestions.is_empty() { + suggestions += 1; + } + + let diagnostic: Diagnostic = issue.into(); + + term::emit(writer, &config, &files, &diagnostic)?; + } + + if let Some(highest_level) = highest_level { + let total_issues = errors + warnings + notes + help; + let mut message_notes = vec![]; + if errors > 0 { + message_notes.push(format!("{} error(s)", errors)); + } + + if warnings > 0 { + message_notes.push(format!("{} warning(s)", warnings)); + } + + if notes > 0 { + message_notes.push(format!("{} note(s)", notes)); + } + + if help > 0 { + message_notes.push(format!("{} help message(s)", help)); + } + + let mut diagnostic: Diagnostic = Diagnostic::new(highest_level.into()).with_message(format!( + "found {} issues: {}", + total_issues, + message_notes.join(", ") + )); + + if suggestions > 0 { + diagnostic = diagnostic.with_notes(vec![format!("{} issues contain auto-fix suggestions", suggestions)]); + } + + term::emit(writer, &config, &files, &diagnostic)?; + } + + Ok(highest_level) +} + +struct SourceManagerFile<'a>(&'a SourceManager, &'a ThreadedInterner); + +impl<'a> Files<'a> for SourceManagerFile<'_> { + type FileId = SourceIdentifier; + type Name = &'a str; + type Source = &'a str; + + fn name(&'a self, file_id: SourceIdentifier) -> Result<&'a str, Error> { + self.0.load(&file_id).map(|source| self.1.lookup(&source.identifier.value())).map_err(|e| match e { + SourceError::UnavailableSource(_) => Error::FileMissing, + SourceError::IOError(error) => Error::Io(error), + }) + } + + fn source(&'a self, file_id: SourceIdentifier) -> Result<&'a str, Error> { + self.0.load(&file_id).map(|source| self.1.lookup(&source.content)).map_err(|e| match e { + SourceError::UnavailableSource(_) => Error::FileMissing, + SourceError::IOError(error) => Error::Io(error), + }) + } + + fn line_index(&self, file_id: SourceIdentifier, byte_index: usize) -> Result { + let source = self.0.load(&file_id).map_err(|e| match e { + SourceError::UnavailableSource(_) => Error::FileMissing, + SourceError::IOError(error) => Error::Io(error), + })?; + + Ok(source.line_number(byte_index)) + } + + fn line_range(&self, file_id: SourceIdentifier, line_index: usize) -> Result, Error> { + let source = self.0.load(&file_id).map_err(|e| match e { + SourceError::UnavailableSource(_) => Error::FileMissing, + SourceError::IOError(error) => Error::Io(error), + })?; + + codespan_line_range(&source.lines, source.size, line_index) + } +} + +fn codespan_line_start(lines: &[usize], size: usize, line_index: usize) -> Result { + match line_index.cmp(&lines.len()) { + Ordering::Less => Ok(lines.get(line_index).cloned().expect("failed despite previous check")), + Ordering::Equal => Ok(size), + Ordering::Greater => Err(Error::LineTooLarge { given: line_index, max: lines.len() - 1 }), + } +} + +fn codespan_line_range(lines: &[usize], size: usize, line_index: usize) -> Result, Error> { + let line_start = codespan_line_start(lines, size, line_index)?; + let next_line_start = codespan_line_start(lines, size, line_index + 1)?; + + Ok(line_start..next_line_start) +} + +impl From for LabelStyle { + fn from(kind: AnnotationKind) -> LabelStyle { + match kind { + AnnotationKind::Primary => LabelStyle::Primary, + AnnotationKind::Secondary => LabelStyle::Secondary, + } + } +} + +impl From for Label { + fn from(annotation: Annotation) -> Label { + let mut label = Label::new(annotation.kind.into(), annotation.span.start.source, annotation.span); + + if let Some(message) = annotation.message { + label.message = message; + } + + label + } +} + +impl From for Severity { + fn from(level: Level) -> Severity { + match level { + Level::Note => Severity::Note, + Level::Help => Severity::Help, + Level::Warning => Severity::Warning, + Level::Error => Severity::Error, + } + } +} + +impl From for Diagnostic { + fn from(issue: Issue) -> Diagnostic { + let mut diagnostic = Diagnostic::new(issue.level.into()).with_message(issue.message); + + if let Some(code) = issue.code { + diagnostic.code = Some(code); + } + + for annotation in issue.annotations { + diagnostic.labels.push(annotation.into()); + } + + for note in issue.notes { + diagnostic.notes.push(note); + } + + if let Some(help) = issue.help { + diagnostic.notes.push(format!("help: {}", help)); + } + + if let Some(link) = issue.link { + diagnostic.notes.push(format!("see: {}", link)); + } + + diagnostic + } +} diff --git a/crates/reporting/src/internal/emitter/github.rs b/crates/reporting/src/internal/emitter/github.rs new file mode 100644 index 00000000..9958a50f --- /dev/null +++ b/crates/reporting/src/internal/emitter/github.rs @@ -0,0 +1,53 @@ +use termcolor::WriteColor; + +use mago_interner::ThreadedInterner; +use mago_source::HasSource; +use mago_source::SourceManager; + +use crate::error::ReportingError; +use crate::IssueCollection; +use crate::Level; + +pub fn github_format( + writer: &mut dyn WriteColor, + sources: &SourceManager, + interner: &ThreadedInterner, + issues: IssueCollection, +) -> Result, ReportingError> { + let highest_level = issues.get_highest_level(); + + for issue in issues.iter() { + let level = match &issue.level { + Level::Note => "notice", + Level::Help => "notice", + Level::Warning => "warning", + Level::Error => "error", + }; + + let properties = match issue.annotations.iter().find(|annotation| annotation.is_primary()) { + Some(annotation) => { + let source = sources.load(&annotation.span.source())?; + let name = interner.lookup(&source.identifier.0); + let start_line = source.line_number(annotation.span.start.offset) + 1; + let end_line = source.line_number(annotation.span.end.offset) + 1; + + if let Some(code) = issue.code.as_ref() { + format!("file={name},line={start_line},endLine={end_line},title={code}") + } else { + format!("file={name},line={start_line},endLine={end_line}") + } + } + None => { + if let Some(code) = issue.code.as_ref() { + format!("title={code}") + } else { + String::new() + } + } + }; + + writeln!(writer, "::{} {}::{}", level, properties, issue.message.as_str())?; + } + + Ok(highest_level) +} diff --git a/crates/reporting/src/internal/emitter/json.rs b/crates/reporting/src/internal/emitter/json.rs new file mode 100644 index 00000000..c46021c3 --- /dev/null +++ b/crates/reporting/src/internal/emitter/json.rs @@ -0,0 +1,23 @@ +use termcolor::WriteColor; + +use mago_interner::ThreadedInterner; +use mago_source::SourceManager; + +use crate::error::ReportingError; +use crate::internal::Expandable; +use crate::IssueCollection; +use crate::Level; + +pub fn json_format( + writer: &mut dyn WriteColor, + sources: &SourceManager, + interner: &ThreadedInterner, + issues: IssueCollection, +) -> Result, ReportingError> { + let highest_level = issues.get_highest_level(); + let issues = issues.expand(sources, interner)?; + + serde_json::to_writer_pretty(writer, &issues)?; + + Ok(highest_level) +} diff --git a/crates/reporting/src/internal/emitter/mod.rs b/crates/reporting/src/internal/emitter/mod.rs new file mode 100644 index 00000000..bc24b092 --- /dev/null +++ b/crates/reporting/src/internal/emitter/mod.rs @@ -0,0 +1,61 @@ +use termcolor::WriteColor; + +use mago_interner::ThreadedInterner; +use mago_source::SourceManager; + +use crate::error::ReportingError; +use crate::reporter::ReportingFormat; +use crate::IssueCollection; +use crate::Level; + +pub mod codespan; +pub mod github; +pub mod json; + +pub trait Emitter { + fn emit( + &self, + writer: &mut dyn WriteColor, + sources: &SourceManager, + interner: &ThreadedInterner, + issues: IssueCollection, + ) -> Result, ReportingError>; +} + +impl Emitter for T +where + T: Fn( + &mut dyn WriteColor, + &SourceManager, + &ThreadedInterner, + IssueCollection, + ) -> Result, ReportingError>, +{ + fn emit( + &self, + writer: &mut dyn WriteColor, + sources: &SourceManager, + interner: &ThreadedInterner, + issues: IssueCollection, + ) -> Result, ReportingError> { + self(writer, sources, interner, issues) + } +} + +impl Emitter for ReportingFormat { + fn emit( + &self, + writer: &mut dyn WriteColor, + sources: &SourceManager, + interner: &ThreadedInterner, + issues: IssueCollection, + ) -> Result, ReportingError> { + match self { + ReportingFormat::Rich => codespan::rich_format.emit(writer, sources, interner, issues), + ReportingFormat::Medium => codespan::medium_format.emit(writer, sources, interner, issues), + ReportingFormat::Short => codespan::short_format.emit(writer, sources, interner, issues), + ReportingFormat::Github => github::github_format.emit(writer, sources, interner, issues), + ReportingFormat::Json => json::json_format.emit(writer, sources, interner, issues), + } + } +} diff --git a/crates/reporting/src/internal/mod.rs b/crates/reporting/src/internal/mod.rs new file mode 100644 index 00000000..999ab735 --- /dev/null +++ b/crates/reporting/src/internal/mod.rs @@ -0,0 +1,169 @@ +use std::path::PathBuf; + +use serde::Deserialize; +use serde::Serialize; + +use mago_fixer::FixPlan; +use mago_interner::ThreadedInterner; +use mago_source::error::SourceError; +use mago_source::SourceIdentifier; +use mago_source::SourceManager; +use mago_span::Position; +use mago_span::Span; + +use crate::Annotation; +use crate::AnnotationKind; +use crate::Issue; +use crate::IssueCollection; +use crate::Level; + +pub mod emitter; +pub mod writer; + +/// Expanded representation of a source identifier. +#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize, Deserialize, PartialOrd, Ord)] +pub struct ExpandedSourceIdentifier { + pub identifier: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub path: Option, + pub size: usize, + pub user_defined: bool, +} + +/// Expanded representation of a position within a source file. +#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize, Deserialize, PartialOrd, Ord)] +pub struct ExpandedPosition { + pub source: ExpandedSourceIdentifier, + pub offset: usize, + pub line: usize, +} + +/// Expanded representation of a span, including start and end positions. +#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize, Deserialize, PartialOrd, Ord)] +pub struct ExpandedSpan { + pub start: ExpandedPosition, + pub end: ExpandedPosition, +} + +/// Expanded annotation, enriched with resolved spans. +#[derive(Debug, PartialEq, Eq, Ord, Clone, Hash, PartialOrd, Deserialize, Serialize)] +pub struct ExpandedAnnotation { + #[serde(skip_serializing_if = "Option::is_none")] + pub message: Option, + pub kind: AnnotationKind, + pub span: ExpandedSpan, +} + +/// Expanded issue, containing detailed information for display or external reporting. +#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize, Deserialize)] +pub struct ExpandedIssue { + pub level: Level, + #[serde(skip_serializing_if = "Option::is_none")] + pub code: Option, + pub message: String, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub notes: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub help: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub link: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub annotations: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub suggestions: Vec<(ExpandedSourceIdentifier, FixPlan)>, +} + +/// A collection of expanded issues. +#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize, Deserialize)] +pub struct ExpandedIssueCollection { + issues: Vec, +} + +pub trait Expandable { + fn expand(&self, manager: &SourceManager, interner: &ThreadedInterner) -> Result; +} + +impl Expandable for SourceIdentifier { + fn expand( + &self, + manager: &SourceManager, + interner: &ThreadedInterner, + ) -> Result { + let source = manager.load(self)?; + + Ok(ExpandedSourceIdentifier { + identifier: interner.lookup(&source.identifier.0).to_string(), + path: source.path.clone(), + size: source.size, + user_defined: source.identifier.is_user_defined(), + }) + } +} + +impl Expandable for Position { + fn expand(&self, manager: &SourceManager, interner: &ThreadedInterner) -> Result { + let source = manager.load(&self.source)?; + + Ok(ExpandedPosition { + source: self.source.expand(manager, interner)?, + offset: self.offset, + line: source.line_number(self.offset), + }) + } +} + +impl Expandable for Span { + fn expand(&self, manager: &SourceManager, interner: &ThreadedInterner) -> Result { + Ok(ExpandedSpan { start: self.start.expand(manager, interner)?, end: self.end.expand(manager, interner)? }) + } +} + +impl Expandable for Annotation { + fn expand(&self, manager: &SourceManager, interner: &ThreadedInterner) -> Result { + Ok(ExpandedAnnotation { + message: self.message.clone(), + kind: self.kind, + span: self.span.expand(manager, interner)?, + }) + } +} + +impl Expandable for Issue { + fn expand(&self, manager: &SourceManager, interner: &ThreadedInterner) -> Result { + let mut annotations = Vec::new(); + for annotation in &self.annotations { + annotations.push(annotation.expand(manager, interner)?); + } + + let mut suggestions = Vec::new(); + for (source, fix) in &self.suggestions { + suggestions.push((source.expand(manager, interner)?, fix.clone())); + } + + Ok(ExpandedIssue { + level: self.level, + code: self.code.clone(), + message: self.message.clone(), + notes: self.notes.clone(), + help: self.help.clone(), + link: self.link.clone(), + annotations, + suggestions, + }) + } +} + +impl Expandable for IssueCollection { + fn expand( + &self, + manager: &SourceManager, + interner: &ThreadedInterner, + ) -> Result { + let mut expanded_issues = Vec::new(); + for issue in self.issues.iter() { + expanded_issues.push(issue.expand(manager, interner)?); + } + + Ok(ExpandedIssueCollection { issues: expanded_issues }) + } +} diff --git a/crates/reporting/src/internal/writer.rs b/crates/reporting/src/internal/writer.rs new file mode 100644 index 00000000..59e72251 --- /dev/null +++ b/crates/reporting/src/internal/writer.rs @@ -0,0 +1,119 @@ +use std::sync::Arc; +use std::sync::Mutex; +use std::sync::MutexGuard; + +/// The format to use when writing the report. +pub use termcolor::*; + +use crate::reporter::ReportingTarget; + +/// A thread-safe wrapper around `StandardStream`, enabling colorized and styled output to +/// either `stdout` or `stderr`. +#[derive(Clone)] +pub(crate) struct ReportWriter { + /// Inner `StandardStream` wrapped in an `Arc` to ensure thread-safe access. + inner: Arc>, +} + +impl ReportWriter { + /// Creates a new `ReportWriter` for the specified target (`stdout` or `stderr`). + /// + /// # Parameters + /// + /// - `target`: The output target, either `Target::Stdout` or `Target::Stderr`. + /// + /// # Returns + /// + /// A new `ReportWriter` instance configured for the specified target. + pub fn new(target: ReportingTarget) -> Self { + let stream = match target { + ReportingTarget::Stdout => StandardStream::stdout(ColorChoice::Auto), + ReportingTarget::Stderr => StandardStream::stderr(ColorChoice::Auto), + }; + + Self { inner: Arc::new(Mutex::new(stream)) } + } + + /// Acquires a lock on the internal `StandardStream`, returning a `Gaurd` for performing write operations. + /// + /// # Returns + /// A `Gaurd` object, which implements `Write` and `WriteColor` traits for text and styled output. + /// + /// # Panics + /// Panics if the internal `Mutex` is poisoned. + /// + /// # Example + /// ```rust + /// use mago_reporting::{ReportWriter, Target}; + /// use std::io::Write; + /// + /// let writer = ReportWriter::new(Target::Stdout); + /// let mut guard = writer.lock(); + /// + /// writeln!(guard, "Hello, world!").unwrap(); + /// ``` + pub fn lock(&self) -> Gaurd { + Gaurd(self.inner.lock().expect("writer lock poisoned, this should never happen")) + } +} + +/// A guard object for safely accessing and writing to the `StandardStream`. +/// +/// This struct is created by the `lock` method of `ReportWriter`. +pub(crate) struct Gaurd<'a>(MutexGuard<'a, StandardStream>); + +impl WriteColor for Gaurd<'_> { + /// Sets the color for subsequent output written through this guard. + /// + /// # Parameters + /// - `spec`: A `ColorSpec` describing the desired text styling. + /// + /// # Returns + /// + /// A `Result` indicating success or failure. + fn set_color(&mut self, spec: &ColorSpec) -> std::io::Result<()> { + self.0.set_color(spec) + } + + /// Resets the text styling to default. + /// + /// # Returns + /// + /// A `Result` indicating success or failure. + fn reset(&mut self) -> std::io::Result<()> { + self.0.reset() + } + + /// Checks whether the underlying stream supports color output. + /// + /// # Returns + /// + /// `true` if the stream supports color, `false` otherwise. + fn supports_color(&self) -> bool { + self.0.supports_color() + } +} + +impl std::io::Write for Gaurd<'_> { + /// Writes a buffer to the stream. + /// + /// # Parameters + /// + /// - `buf`: A byte slice containing the data to write. + /// + /// # Returns + /// + /// The number of bytes written or an error. + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.0.write(buf) + } + + /// Flushes the stream, ensuring all buffered data is written out. + /// + /// # Returns + /// + /// A `Result` indicating success or failure. + fn flush(&mut self) -> std::io::Result<()> { + self.0.flush() + } +} diff --git a/crates/reporting/src/lib.rs b/crates/reporting/src/lib.rs index ac11393f..ca2bbd77 100644 --- a/crates/reporting/src/lib.rs +++ b/crates/reporting/src/lib.rs @@ -2,10 +2,6 @@ use std::collections::hash_map::Entry; use std::iter::Once; use ahash::HashMap; -use codespan_reporting::diagnostic::Diagnostic; -use codespan_reporting::diagnostic::Label; -use codespan_reporting::diagnostic::LabelStyle; -use codespan_reporting::diagnostic::Severity; use serde::Deserialize; use serde::Serialize; @@ -13,11 +9,13 @@ use mago_fixer::FixPlan; use mago_source::SourceIdentifier; use mago_span::Span; +mod internal; + +pub mod error; pub mod reporter; /// Represents the kind of annotation associated with an issue. #[derive(Debug, PartialEq, Eq, Ord, Copy, Clone, Hash, PartialOrd, Deserialize, Serialize)] -#[serde(tag = "type", content = "value")] pub enum AnnotationKind { /// A primary annotation, typically highlighting the main source of the issue. Primary, @@ -27,7 +25,6 @@ pub enum AnnotationKind { /// An annotation associated with an issue, providing additional context or highlighting specific code spans. #[derive(Debug, PartialEq, Eq, Ord, Clone, Hash, PartialOrd, Deserialize, Serialize)] -#[serde(rename_all = "snake_case")] pub struct Annotation { /// An optional message associated with the annotation. pub message: Option, @@ -39,7 +36,6 @@ pub struct Annotation { /// Represents the severity level of an issue. #[derive(Debug, PartialEq, Eq, Ord, Copy, Clone, Hash, PartialOrd, Deserialize, Serialize)] -#[serde(tag = "type", content = "value")] pub enum Level { /// A note, providing additional information or context. Note, @@ -153,6 +149,11 @@ impl Annotation { self } + + /// Returns `true` if this annotation is a primary annotation. + pub fn is_primary(&self) -> bool { + self.kind == AnnotationKind::Primary + } } impl Issue { @@ -432,66 +433,6 @@ impl IntoIterator for IssueCollection { } } -impl From for LabelStyle { - fn from(kind: AnnotationKind) -> LabelStyle { - match kind { - AnnotationKind::Primary => LabelStyle::Primary, - AnnotationKind::Secondary => LabelStyle::Secondary, - } - } -} - -impl From for Label { - fn from(annotation: Annotation) -> Label { - let mut label = Label::new(annotation.kind.into(), annotation.span.start.source, annotation.span); - - if let Some(message) = annotation.message { - label.message = message; - } - - label - } -} - -impl From for Severity { - fn from(level: Level) -> Severity { - match level { - Level::Note => Severity::Note, - Level::Help => Severity::Help, - Level::Warning => Severity::Warning, - Level::Error => Severity::Error, - } - } -} - -impl From for Diagnostic { - fn from(issue: Issue) -> Diagnostic { - let mut diagnostic = Diagnostic::new(issue.level.into()).with_message(issue.message); - - if let Some(code) = issue.code { - diagnostic.code = Some(code); - } - - for annotation in issue.annotations { - diagnostic.labels.push(annotation.into()); - } - - for note in issue.notes { - diagnostic.notes.push(note); - } - - if let Some(help) = issue.help { - diagnostic.notes.push(format!("help: {}", help)); - } - - if let Some(link) = issue.link { - diagnostic.notes.push(format!("see: {}", link)); - } - - diagnostic - } -} - impl Default for IssueCollection { fn default() -> Self { Self::new() diff --git a/crates/reporting/src/reporter.rs b/crates/reporting/src/reporter.rs index 59c33236..5ecf3a80 100644 --- a/crates/reporting/src/reporter.rs +++ b/crates/reporting/src/reporter.rs @@ -1,111 +1,64 @@ -use std::sync::Arc; -use std::sync::Mutex; -use std::sync::MutexGuard; +use std::str::FromStr; -pub use codespan_reporting::term::termcolor::*; +use serde::Deserialize; +use serde::Serialize; +use strum::Display; +use strum::VariantNames; -use codespan_reporting::diagnostic::Diagnostic; -use codespan_reporting::term; -use codespan_reporting::term::Config; - -use mago_source::SourceIdentifier; +use mago_interner::ThreadedInterner; use mago_source::SourceManager; +use crate::error::ReportingError; +use crate::internal::emitter::Emitter; +use crate::internal::writer::ReportWriter; use crate::Issue; use crate::IssueCollection; use crate::Level; +/// Defines the output target for the `ReportWriter`. +#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Display, VariantNames)] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase")] +pub enum ReportingTarget { + /// Direct output to standard output (stdout). + #[default] + Stdout, + /// Direct output to standard error (stderr). + Stderr, +} + +/// The format to use when writing the report. +#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Display, VariantNames)] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase")] +pub enum ReportingFormat { + #[default] + Rich, + Medium, + Short, + Github, + Json, +} + #[derive(Clone)] pub struct Reporter { - writer: Arc>, - config: Arc, + interner: ThreadedInterner, manager: SourceManager, + target: ReportingTarget, + writer: ReportWriter, } impl Reporter { - pub fn new(manager: SourceManager) -> Self { - Self { - writer: Arc::new(Mutex::new(StandardStream::stdout(ColorChoice::Auto))), - config: Arc::new(Config::default()), - manager, - } + pub fn new(interner: ThreadedInterner, manager: SourceManager, target: ReportingTarget) -> Self { + Self { interner, manager, target, writer: ReportWriter::new(target) } } - pub fn report(&self, issue: Issue) { - let mut writer = Gaurd(self.writer.lock().expect("failed to aquire lock")); - - let diagnostic: Diagnostic = issue.into(); - - term::emit(&mut writer, &self.config, &self.manager, &diagnostic).unwrap(); - } - - pub fn report_all(&self, issues: impl IntoIterator) -> Option { - let collection = IssueCollection::from(issues); - let mut writer = Gaurd(self.writer.lock().expect("failed to aquire lock")); - - let highest_level = collection.get_highest_level(); - let mut errors = 0; - let mut warnings = 0; - let mut notes = 0; - let mut help = 0; - let mut suggestions = 0; - - for issue in collection { - match &issue.level { - Level::Note => { - notes += 1; - } - Level::Help => { - help += 1; - } - Level::Warning => { - warnings += 1; - } - Level::Error => { - errors += 1; - } - } - - if !issue.suggestions.is_empty() { - suggestions += 1; - } - - let diagnostic: Diagnostic = issue.into(); - - term::emit(&mut writer, &self.config, &self.manager, &diagnostic).unwrap(); - } - - if let Some(highest_level) = highest_level { - let total_issues = errors + warnings + notes + help; - let mut message_notes = vec![]; - if errors > 0 { - message_notes.push(format!("{} error(s)", errors)); - } - - if warnings > 0 { - message_notes.push(format!("{} warning(s)", warnings)); - } - - if notes > 0 { - message_notes.push(format!("{} note(s)", notes)); - } - - if help > 0 { - message_notes.push(format!("{} help message(s)", help)); - } - - let mut diagnostic: Diagnostic = Diagnostic::new(highest_level.into()) - .with_message(format!("found {} issues: {}", total_issues, message_notes.join(", "))); - - if suggestions > 0 { - diagnostic = - diagnostic.with_notes(vec![format!("{} issues contain auto-fix suggestions", suggestions)]); - } - - term::emit(&mut writer, &self.config, &self.manager, &diagnostic).unwrap(); - } - - highest_level + pub fn report( + &self, + issues: impl IntoIterator, + format: ReportingFormat, + ) -> Result, ReportingError> { + format.emit(&mut self.writer.lock(), &self.manager, &self.interner, IssueCollection::from(issues)) } } @@ -114,32 +67,37 @@ unsafe impl Sync for Reporter {} impl std::fmt::Debug for Reporter { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("Reporter").field("config", &self.config).field("manager", &self.manager).finish_non_exhaustive() + f.debug_struct("Reporter") + .field("interner", &self.interner) + .field("manager", &self.manager) + .field("target", &self.target) + .finish_non_exhaustive() } } -struct Gaurd<'a>(MutexGuard<'a, StandardStream>); +impl FromStr for ReportingTarget { + type Err = ReportingError; -impl WriteColor for Gaurd<'_> { - fn set_color(&mut self, spec: &term::termcolor::ColorSpec) -> std::io::Result<()> { - self.0.set_color(spec) - } - - fn reset(&mut self) -> std::io::Result<()> { - self.0.reset() - } - - fn supports_color(&self) -> bool { - self.0.supports_color() + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "stdout" | "out" => Ok(Self::Stdout), + "stderr" | "err" => Ok(Self::Stderr), + _ => Err(ReportingError::InvalidTarget(s.to_string())), + } } } -impl std::io::Write for Gaurd<'_> { - fn write(&mut self, buf: &[u8]) -> std::io::Result { - self.0.write(buf) - } - - fn flush(&mut self) -> std::io::Result<()> { - self.0.flush() +impl FromStr for ReportingFormat { + type Err = ReportingError; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "rich" => Ok(Self::Rich), + "medium" => Ok(Self::Medium), + "short" => Ok(Self::Short), + "github" => Ok(Self::Github), + "json" => Ok(Self::Json), + _ => Err(ReportingError::InvalidFormat(s.to_string())), + } } } diff --git a/crates/service/src/ast/mod.rs b/crates/service/src/ast/mod.rs index 7c52aaab..4feaacf4 100644 --- a/crates/service/src/ast/mod.rs +++ b/crates/service/src/ast/mod.rs @@ -18,7 +18,7 @@ impl AstService { /// Parse the given bytes into an AST. pub async fn parse(&self, source: SourceIdentifier) -> Result<(Program, Option), SourceError> { - let source = self.source_manager.load(source)?; + let source = self.source_manager.load(&source)?; Ok(mago_parser::parse_source(&self.interner, &source)) } diff --git a/crates/service/src/formatter/mod.rs b/crates/service/src/formatter/mod.rs index 925ae872..53b9b3fd 100644 --- a/crates/service/src/formatter/mod.rs +++ b/crates/service/src/formatter/mod.rs @@ -56,7 +56,7 @@ impl FormatterService { async move { // Step 1: load the source - let source = manager.load(source_id)?; + let source = manager.load(&source_id)?; source_pb.inc(1); // Step 2: parse the source diff --git a/crates/service/src/linter/mod.rs b/crates/service/src/linter/mod.rs index 07bd0269..6c8c7082 100644 --- a/crates/service/src/linter/mod.rs +++ b/crates/service/src/linter/mod.rs @@ -128,7 +128,7 @@ impl LintService { let fix_pb = fix_pb.clone(); async move { - let source = source_manager.load(source)?; + let source = source_manager.load(&source)?; let source_content = interner.lookup(&source.content); let result = utils::apply_changes( &interner, @@ -178,7 +178,7 @@ impl LintService { async move { // Step 1: load the source - let source = manager.load(source_id)?; + let source = manager.load(&source_id)?; source_pb.inc(1); // Step 2: build semantics diff --git a/crates/source/Cargo.toml b/crates/source/Cargo.toml index ce03a6c8..bd6a8d70 100644 --- a/crates/source/Cargo.toml +++ b/crates/source/Cargo.toml @@ -14,7 +14,6 @@ workspace = true [dependencies] mago-interner = { workspace = true } -codespan-reporting = { workspace = true } serde = { workspace = true } tracing = { workspace = true } dashmap = { workspace = true } diff --git a/crates/source/src/error.rs b/crates/source/src/error.rs index f54a48b4..5b89103f 100644 --- a/crates/source/src/error.rs +++ b/crates/source/src/error.rs @@ -6,9 +6,6 @@ pub enum SourceError { IOError(std::io::Error), } -unsafe impl Send for SourceError {} -unsafe impl Sync for SourceError {} - impl std::fmt::Display for SourceError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { diff --git a/crates/source/src/lib.rs b/crates/source/src/lib.rs index bc9061a4..175e692d 100644 --- a/crates/source/src/lib.rs +++ b/crates/source/src/lib.rs @@ -1,12 +1,7 @@ use std::borrow::Cow; -use std::cmp::Ordering; -use std::ops::Range; use std::path::PathBuf; use std::sync::Arc; -use codespan_reporting::files::line_starts; -use codespan_reporting::files::Error; -use codespan_reporting::files::Files; use dashmap::DashMap; use serde::Deserialize; use serde::Serialize; @@ -196,14 +191,14 @@ impl SourceManager { /// # Returns /// /// The source with the given identifier, or an error if the source does not exist, or could not be loaded. - pub fn load(&self, source_id: SourceIdentifier) -> Result { - let Some(mut entry) = self.sources.get_mut(&source_id) else { - return Err(SourceError::UnavailableSource(source_id)); + pub fn load(&self, source_id: &SourceIdentifier) -> Result { + let Some(mut entry) = self.sources.get_mut(source_id) else { + return Err(SourceError::UnavailableSource(*source_id)); }; match &entry.content { Some((content, size, lines)) => Ok(Source { - identifier: source_id, + identifier: *source_id, path: entry.path.clone(), content: *content, size: *size, @@ -230,7 +225,7 @@ impl SourceManager { let size = content.len(); let content = self.interner.intern(content); - let source = Source { identifier: source_id, path: Some(path), content, size, lines: lines.clone() }; + let source = Source { identifier: *source_id, path: Some(path), content, size, lines: lines.clone() }; v.content = Some((content, size, lines)); @@ -271,75 +266,17 @@ impl SourceManager { pub fn is_empty(&self) -> bool { self.sources.is_empty() } - - fn get(&self, source_id: SourceIdentifier) -> Result { - self.sources - .get(&source_id) - .map(|entry| { - let entry = entry.value(); - let (content, size, lines) = entry - .content - .as_ref() - .expect("content must be initialized when source entry is present in the map"); - - Source { - identifier: source_id, - path: entry.path.clone(), - content: *content, - size: *size, - lines: lines.clone(), - } - }) - .ok_or(Error::FileMissing) - } } unsafe impl Send for SourceManager {} unsafe impl Sync for SourceManager {} -impl<'a> Files<'a> for SourceManager { - type FileId = SourceIdentifier; - type Name = &'a str; - type Source = &'a str; - - fn name(&'a self, file_id: SourceIdentifier) -> Result<&'a str, Error> { - self.get(file_id).map(|source| self.interner.lookup(&source.identifier.value())) - } - - fn source(&'a self, file_id: SourceIdentifier) -> Result<&'a str, Error> { - self.get(file_id).map(|source| self.interner.lookup(&source.content)) - } - - fn line_index(&self, file_id: SourceIdentifier, byte_index: usize) -> Result { - let source = self.get(file_id)?; - - Ok(source.line_number(byte_index)) - } - - fn line_range(&self, file_id: SourceIdentifier, line_index: usize) -> Result, Error> { - let source = self.get(file_id)?; - - codespan_line_range(&source.lines, source.size, line_index) - } -} - -fn codespan_line_start(lines: &[usize], size: usize, line_index: usize) -> Result { - match line_index.cmp(&lines.len()) { - Ordering::Less => Ok(lines.get(line_index).cloned().expect("failed despite previous check")), - Ordering::Equal => Ok(size), - Ordering::Greater => Err(Error::LineTooLarge { given: line_index, max: lines.len() - 1 }), - } -} - -fn codespan_line_range(lines: &[usize], size: usize, line_index: usize) -> Result, Error> { - let line_start = codespan_line_start(lines, size, line_index)?; - let next_line_start = codespan_line_start(lines, size, line_index + 1)?; - - Ok(line_start..next_line_start) -} - impl HasSource for Box { fn source(&self) -> SourceIdentifier { self.as_ref().source() } } + +fn line_starts(source: &str) -> impl '_ + Iterator { + std::iter::once(0).chain(source.match_indices('\n').map(|(i, _)| i + 1)) +} diff --git a/crates/wasm/src/lib.rs b/crates/wasm/src/lib.rs index 6ca0a287..59bbd6c4 100644 --- a/crates/wasm/src/lib.rs +++ b/crates/wasm/src/lib.rs @@ -125,7 +125,7 @@ pub fn mago_format(code: String, settings: Option) -> Result) -> Result let interner = ThreadedInterner::new(); let mut manager = SourceManager::new(interner.clone()); let source_id = manager.insert_content("code.php".to_string(), code, true); - let source = manager.load(source_id).map_err(|e| JsValue::from_str(&e.to_string()))?; + let source = manager.load(&source_id).map_err(|e| JsValue::from_str(&e.to_string()))?; let semantics = Semantics::build(&interner, source); let mut formatted = None; if semantics.parse_error.is_none() {