diff --git a/Cargo.lock b/Cargo.lock index f6fddf3..edb0eaa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -132,6 +132,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "backtrace-ext" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537beee3be4a18fb023b570f80e3ae28003db9167a751266b259926e25539d50" +dependencies = [ + "backtrace", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -270,7 +279,7 @@ dependencies = [ "encode_unicode", "libc", "once_cell", - "unicode-width", + "unicode-width 0.2.1", "windows-sys 0.60.2", ] @@ -736,6 +745,12 @@ dependencies = [ "similar", ] +[[package]] +name = "is_ci" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" + [[package]] name = "is_terminal_polyfill" version = "1.70.1" @@ -850,6 +865,36 @@ version = "2.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "backtrace", + "backtrace-ext", + "cfg-if", + "miette-derive", + "owo-colors", + "supports-color", + "supports-hyperlinks", + "supports-unicode", + "terminal_size", + "textwrap", + "unicode-width 0.1.14", +] + +[[package]] +name = "miette-derive" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -926,6 +971,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" +[[package]] +name = "owo-colors" +version = "4.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48dd4f4a2c8405440fd0462561f0e5806bd0f77e86f51c761481bdd4018b545e" + [[package]] name = "parking_lot" version = "0.12.4" @@ -1026,7 +1077,7 @@ dependencies = [ "strum_macros", "thiserror", "unicode-segmentation", - "unicode-width", + "unicode-width 0.2.1", ] [[package]] @@ -1108,6 +1159,7 @@ dependencies = [ "indoc", "insta", "itertools 0.14.0", + "miette", "ropey", "serde", "serde_json", @@ -1361,6 +1413,27 @@ dependencies = [ "syn", ] +[[package]] +name = "supports-color" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c64fc7232dd8d2e4ac5ce4ef302b1d81e0b80d055b9d77c7c4f51f6aa4c867d6" +dependencies = [ + "is_ci", +] + +[[package]] +name = "supports-hyperlinks" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "804f44ed3c63152de6a9f90acbea1a110441de43006ea51bcce8f436196a288b" + +[[package]] +name = "supports-unicode" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2" + [[package]] name = "syn" version = "2.0.104" @@ -1383,6 +1456,26 @@ dependencies = [ "syn", ] +[[package]] +name = "terminal_size" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45c6481c4829e4cc63825e62c49186a34538b7b2750b73b266581ffb612fb5ed" +dependencies = [ + "rustix 1.0.7", + "windows-sys 0.59.0", +] + +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +dependencies = [ + "unicode-linebreak", + "unicode-width 0.2.1", +] + [[package]] name = "thiserror" version = "2.0.12" @@ -1637,12 +1730,24 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + [[package]] name = "unicode-segmentation" version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + [[package]] name = "unicode-width" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index b22e091..56fd903 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ console = "0.16.0" ignore = "0.4.23" indoc = "2.0.6" itertools = "0.14.0" +miette = { version = "7.4.0", features = ["fancy"] } ropey = "1.6.1" serde = "1.0.219" serde_json = "1.0.140" diff --git a/crates/roughly/Cargo.toml b/crates/roughly/Cargo.toml index d0e070e..01cca6b 100644 --- a/crates/roughly/Cargo.toml +++ b/crates/roughly/Cargo.toml @@ -9,6 +9,7 @@ console.workspace = true ignore.workspace = true indoc.workspace = true itertools.workspace = true +miette.workspace = true ropey.workspace = true serde.workspace = true similar.workspace = true diff --git a/crates/roughly/src/cli.rs b/crates/roughly/src/cli.rs index 7a5d65b..a97dc47 100644 --- a/crates/roughly/src/cli.rs +++ b/crates/roughly/src/cli.rs @@ -7,13 +7,156 @@ use { }, console::style, ignore::Walk, + miette::{Diagnostic, Report, SourceSpan}, ropey::Rope, std::{ path::{Path, PathBuf}, time::Duration, }, + thiserror::Error, }; +// +// ERRORS +// + +#[derive(Error, Debug, Diagnostic)] +pub enum CliError { + #[error("Check failed")] + #[diagnostic(code(roughly::check::failed))] + Check, + + #[error("Format failed")] + #[diagnostic(code(roughly::format::failed))] + Format, + + #[error("Debug command failed")] + #[diagnostic(code(roughly::debug::failed))] + Debug, + + #[error("Configuration error")] + #[diagnostic(code(roughly::config::error))] + Config { + #[source] + source: config::ConfigError, + }, + + #[error("I/O error")] + #[diagnostic(code(roughly::io::error))] + Io { + #[source] + source: std::io::Error, + path: PathBuf, + }, + + #[error("Format error")] + #[diagnostic(code(roughly::format::error))] + FormatError { + #[source] + source: format::FormatError, + path: PathBuf, + #[source_code] + source_code: String, + }, +} + +#[derive(Error, Debug, Diagnostic)] +pub enum CheckDiagnostic { + #[error("{message}")] + #[diagnostic(code(roughly::check::info))] + Info { + message: String, + #[source_code] + source_code: String, + #[label("here")] + span: miette::SourceSpan, + filename: String, + }, + + #[error("{message}")] + #[diagnostic(code(roughly::check::warning))] + Warning { + message: String, + #[source_code] + source_code: String, + #[label("here")] + span: miette::SourceSpan, + filename: String, + }, + + #[error("{message}")] + #[diagnostic(code(roughly::check::error))] + Error { + message: String, + #[source_code] + source_code: String, + #[label("here")] + span: miette::SourceSpan, + filename: String, + }, +} + +// Keep the old error types for backwards compatibility +#[derive(Debug)] +pub struct CheckError; + +#[derive(Debug)] +pub struct FmtError; + +#[derive(Debug)] +pub struct DebugError; + +/// Report an error using miette +pub fn report_error>(error: T) { + eprintln!("{:?}", error.into()); +} + +/// Report a diagnostic error using miette +pub fn report_diagnostic_error(error: T) { + let report = Report::new(error); + eprintln!("{:?}", report); +} + +/// Convert LSP diagnostic to miette diagnostic +fn lsp_diagnostic_to_miette( + diagnostic: &lsp_types::Diagnostic, + source_code: &str, + filename: &str, + rope: &Rope, +) -> CheckDiagnostic { + let start_line = diagnostic.range.start.line as usize; + let start_char = diagnostic.range.start.character as usize; + let end_line = diagnostic.range.end.line as usize; + let end_char = diagnostic.range.end.character as usize; + + // Convert LSP range to byte offset + let start_offset = rope.line_to_char(start_line) + start_char; + let end_offset = rope.line_to_char(end_line) + end_char; + + let span = SourceSpan::new(start_offset.into(), (end_offset - start_offset).into()); + + match diagnostic.severity { + Some(DiagnosticSeverity::INFORMATION) => CheckDiagnostic::Info { + message: diagnostic.message.clone(), + source_code: source_code.to_string(), + span, + filename: filename.to_string(), + }, + Some(DiagnosticSeverity::WARNING) => CheckDiagnostic::Warning { + message: diagnostic.message.clone(), + source_code: source_code.to_string(), + span, + filename: filename.to_string(), + }, + Some(DiagnosticSeverity::ERROR) | None | Some(_) => CheckDiagnostic::Error { + message: diagnostic.message.clone(), + source_code: source_code.to_string(), + span, + filename: filename.to_string(), + }, + } +} + // // LOG // @@ -53,9 +196,6 @@ pub fn error(message: &str) { // CHECK // -#[derive(Debug)] -pub struct CheckError; - pub fn check( maybe_files: Option<&[PathBuf]>, experimental_features: ExperimentalFeatures, @@ -71,7 +211,7 @@ pub fn check( let config = match config::Config::from_path(file, experimental_features) { Ok(config) => config, Err(err) => { - error(&err.to_string()); + report_diagnostic_error(err); return Err(CheckError); } }; @@ -114,81 +254,15 @@ pub fn check( for diagnostic in diagnostics::analyze_full(tree.root_node(), &rope, config.lint) { n_errors += 1; - log( - match diagnostic.severity { - Some(DiagnosticSeverity::INFORMATION) => LogLevel::Info, - Some(DiagnosticSeverity::WARNING) => LogLevel::Warn, - Some(DiagnosticSeverity::ERROR) => LogLevel::Error, - _ => LogLevel::Info, - }, - &diagnostic.message, - ); - let range = diagnostic.range; - let padding_arrow = range.end.line.to_string().len(); - eprintln!( - "{}{} {}:{}:{}", - " ".repeat(padding_arrow), - style("-->").bold().blue(), - path.display(), - range.start.line, - range.start.character - ); - - let line_start = usize::max(1, range.start.line as usize) - 1; - let lines = { - let start = rope.line_to_char(line_start); - let end = - rope.line_to_char(range.end.line as usize) + range.end.character as usize; - rope.slice(start..end) - }; - let width = padding_arrow + 1; - for (i, line) in lines.lines().enumerate() { - eprint!( - "{} {}", - style(format!("{: arrow.blue(), - Some(DiagnosticSeverity::WARNING) => arrow.yellow(), - Some(DiagnosticSeverity::ERROR) => arrow.red(), - _ => arrow, - } - } - ); - eprintln!( - "{}{} {}", - " ".repeat(width), - " ".repeat(usize::min( - range.start.character as usize, - range.end.character as usize - )), - { - let message = style(&diagnostic.message).bold(); - match diagnostic.severity { - Some(DiagnosticSeverity::INFORMATION) => message.blue(), - Some(DiagnosticSeverity::WARNING) => message.yellow(), - Some(DiagnosticSeverity::ERROR) => message.red(), - _ => message, - } - } + + let miette_diagnostic = lsp_diagnostic_to_miette( + &diagnostic, + &old, + &path.display().to_string(), + &rope, ); - - eprintln!("\n") + + report_diagnostic_error(miette_diagnostic); } } } @@ -209,9 +283,6 @@ pub fn check( // FMT // -#[derive(Debug)] -pub struct FmtError; - pub fn fmt( maybe_files: Option<&[PathBuf]>, check: bool, @@ -228,7 +299,7 @@ pub fn fmt( .iter() .map(|file| { let config = config::Config::from_path(file, experimental_features).map_err(|err| { - error(&err.to_string()); + report_diagnostic_error(err); FmtError })?; @@ -279,8 +350,8 @@ pub fn fmt( Ok(new) => new, Err(err) => { n_errors += 1; - error(&format!("failed to format: {}", path.display())); - eprintln!("{err}"); + eprintln!("Failed to format: {}", path.display()); + report_diagnostic_error(err); continue; } }; @@ -354,9 +425,6 @@ pub fn server(experimental_features: ExperimentalFeatures) { // DEBUG // -#[derive(Debug)] -pub struct DebugError; - pub fn index(paths: Option<&[PathBuf]>, nested: bool, print_items: bool) -> Result<(), DebugError> { let mut parser = tree::new_parser(); @@ -497,3 +565,159 @@ pub fn parse_experimental_flags(flags: &[impl AsRef]) -> ExperimentalFeatur features } + +#[cfg(test)] +mod tests { + use super::*; + use miette::Report; + use crate::config::ConfigError; + use crate::format::FormatError; + + #[test] + fn test_config_error_is_miette_diagnostic() { + let io_error = std::io::Error::new(std::io::ErrorKind::NotFound, "config file not found"); + let config_error = ConfigError::IoError(io_error); + + // Test that we can create a miette Report from the error + let report = Report::new(config_error); + let error_string = format!("{:?}", report); + + // Should contain the error code and help text + assert!(error_string.contains("roughly::config::io")); + assert!(error_string.contains("Failed to read config file")); + } + + #[test] + fn test_format_error_is_miette_diagnostic() { + let format_error = FormatError::SyntaxError { + kind: "identifier", + line: 1, + col: 10, + }; + + // Test that we can create a miette Report from the error + let report = Report::new(format_error); + let error_string = format!("{:?}", report); + + // Should contain the error code and help text + assert!(error_string.contains("roughly::format::syntax")); + assert!(error_string.contains("Syntax error")); + } + + #[test] + fn test_lsp_diagnostic_to_miette() { + use crate::lsp_types::{Position, Range, DiagnosticSeverity}; + use ropey::Rope; + + let source_code = "x <- 1\ny <- 2\nz <- 3"; + let rope = Rope::from_str(source_code); + + let diagnostic = crate::lsp_types::Diagnostic { + message: "Test error message".to_string(), + severity: Some(DiagnosticSeverity::ERROR), + range: Range { + start: Position { line: 1, character: 0 }, + end: Position { line: 1, character: 1 }, + }, + code: None, + code_description: None, + source: None, + related_information: None, + tags: None, + data: None, + }; + + let miette_diagnostic = lsp_diagnostic_to_miette( + &diagnostic, + source_code, + "test.R", + &rope, + ); + + // Test that we can create a miette Report from the diagnostic + let report = Report::new(miette_diagnostic); + let error_string = format!("{:?}", report); + + // Should contain the error code and message + assert!(error_string.contains("roughly::check::error")); + assert!(error_string.contains("Test error message")); + } + + #[test] + fn test_lsp_diagnostic_to_miette_warning() { + use crate::lsp_types::{Position, Range, DiagnosticSeverity}; + use ropey::Rope; + + let source_code = "x <- 1\ny <- 2\nz <- 3"; + let rope = Rope::from_str(source_code); + + let diagnostic = crate::lsp_types::Diagnostic { + message: "Test warning message".to_string(), + severity: Some(DiagnosticSeverity::WARNING), + range: Range { + start: Position { line: 0, character: 0 }, + end: Position { line: 0, character: 1 }, + }, + code: None, + code_description: None, + source: None, + related_information: None, + tags: None, + data: None, + }; + + let miette_diagnostic = lsp_diagnostic_to_miette( + &diagnostic, + source_code, + "test.R", + &rope, + ); + + // Test that we can create a miette Report from the diagnostic + let report = Report::new(miette_diagnostic); + let error_string = format!("{:?}", report); + + // Should contain the warning code and message + assert!(error_string.contains("roughly::check::warning")); + assert!(error_string.contains("Test warning message")); + } + + #[test] + fn test_lsp_diagnostic_to_miette_info() { + use crate::lsp_types::{Position, Range, DiagnosticSeverity}; + use ropey::Rope; + + let source_code = "x <- 1\ny <- 2\nz <- 3"; + let rope = Rope::from_str(source_code); + + let diagnostic = crate::lsp_types::Diagnostic { + message: "Test info message".to_string(), + severity: Some(DiagnosticSeverity::INFORMATION), + range: Range { + start: Position { line: 2, character: 0 }, + end: Position { line: 2, character: 1 }, + }, + code: None, + code_description: None, + source: None, + related_information: None, + tags: None, + data: None, + }; + + let miette_diagnostic = lsp_diagnostic_to_miette( + &diagnostic, + source_code, + "test.R", + &rope, + ); + + // Test that we can create a miette Report from the diagnostic + let report = Report::new(miette_diagnostic); + let error_string = format!("{:?}", report); + + // Should contain the info code and message + assert!(error_string.contains("roughly::check::info")); + assert!(error_string.contains("Test info message")); + } +} diff --git a/crates/roughly/src/config.rs b/crates/roughly/src/config.rs index 47a833b..e954070 100644 --- a/crates/roughly/src/config.rs +++ b/crates/roughly/src/config.rs @@ -1,5 +1,6 @@ use { crate::{diagnostics::Config as LintConfig, format::Config as FormatConfig}, + miette::Diagnostic, serde::Deserialize, std::{io, path::Path}, thiserror::Error, @@ -43,11 +44,20 @@ pub enum Case { Snake, } -#[derive(Error, Debug)] +#[derive(Error, Debug, Diagnostic)] pub enum ConfigError { - #[error("failed to read config")] + #[error("Failed to read config file")] + #[diagnostic( + code(roughly::config::io), + help("Check that the config file exists and is readable") + )] IoError(#[from] io::Error), - #[error("invalid config file")] + + #[error("Invalid config file format")] + #[diagnostic( + code(roughly::config::invalid), + help("Check the TOML syntax in your roughly.toml file") + )] Invalid(#[from] toml::de::Error), } diff --git a/crates/roughly/src/format.rs b/crates/roughly/src/format.rs index 4b5f983..7d02764 100644 --- a/crates/roughly/src/format.rs +++ b/crates/roughly/src/format.rs @@ -4,6 +4,7 @@ use { utils, }, itertools::Itertools, + miette::Diagnostic, ropey::Rope, serde::Deserialize, std::time::Instant, @@ -35,26 +36,45 @@ pub enum LineEnding { CrLf, } -#[derive(Error, Debug)] +#[derive(Error, Debug, Diagnostic)] pub enum FormatError { #[error("Syntax error: Unexpected {kind} at line {line}, column {col}")] + #[diagnostic( + code(roughly::format::syntax), + help("Check the R syntax in your file") + )] SyntaxError { kind: &'static str, line: usize, col: usize, }, + #[error("Missing node: Expected {kind} at line {line}, column {col}")] + #[diagnostic( + code(roughly::format::missing), + help("This appears to be a parser error - the code may have syntax issues") + )] Missing { kind: &'static str, line: usize, col: usize, }, + #[error("Missing required field '{field}' in node of type '{kind}'")] + #[diagnostic( + code(roughly::format::missing_field), + help("This is an internal error - please report this bug") + )] MissingField { kind: &'static str, field: &'static str, }, + #[error("Encountered unknown node type '{kind}' with content: \"{raw}\"")] + #[diagnostic( + code(roughly::format::unknown_kind), + help("This is an internal error - please report this bug") + )] UnknownKind { kind: &'static str, raw: String }, } diff --git a/crates/roughly/src/main.rs b/crates/roughly/src/main.rs index 0b7f963..6ccfb95 100644 --- a/crates/roughly/src/main.rs +++ b/crates/roughly/src/main.rs @@ -6,6 +6,9 @@ use { }; fn main() -> ExitCode { + // Initialize miette + miette::set_panic_hook(); + tracing_subscriber::registry() .with(tracing_subscriber::EnvFilter::from_default_env()) .with(