From 6b51694733a64228f96c9c8cfed943509a5da27a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kat=20March=C3=A1n?= Date: Wed, 4 Aug 2021 20:16:59 -0700 Subject: [PATCH] feat(protocol) overhauled entire protocol to be based on byte offsets (#1) Includes other improvements! BREAKING CHANGE: Yeah this is pretty much a rewrite. --- Cargo.toml | 1 + README.md | 29 +++++---- src/chain.rs | 2 +- src/error.rs | 14 +++++ src/lib.rs | 3 +- src/protocol.rs | 150 +++++++++++++++++++++++++++++++++++--------- src/reporter.rs | 137 ++++++++++++++++++++++++++-------------- src/source_impls.rs | 86 ++++++++++++++++++++----- tests/reporter.rs | 40 ++++++------ 9 files changed, 337 insertions(+), 125 deletions(-) create mode 100644 src/error.rs diff --git a/Cargo.toml b/Cargo.toml index ff45104..9293acc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ edition = "2018" [dependencies] indenter = "0.3.3" +thiserror = "1.0.26" [dev-dependencies] thiserror = "1.0.26" diff --git a/README.md b/README.md index 9941fa4..287570e 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ you FAIL miette? you fail her compiler like the unsafe C program? oh! oh! jail f Here's an example of using something like `thisdiagnostic` to define Diagnostics declaratively. -```rust +```ignore use thiserror::Error; use thisdiagnostic::Diagnostic; @@ -40,24 +40,27 @@ pub enum MyDiagnostic { )] #[error("Tried to add a {bad_type} to a {good_type}")] BadArithmetic { - src: PathBuf, - other_src: PathBuf, - + // Regular error metadata for programmatic use. good_type: Type, bad_type: Type, bad_var: Var, - #[span_source(src)] - #[label("This is a {bad_type}")] - bad_var_span: SourceSpan, + // Anything implementing the Source trait can be used as a source. + src: PathBuf, + other_src: String, - #[span_source(src)] - #[label("This is a {good_type}")] + // The context is the span of code that will be rendered. + // There can be multiple contexts in a Diagnostic. + #[context(src, "This region is where things went wrong.")] + ctx: SourceSpan, + + // Highlights underline and label specific subsections of the context. + #[highlight(ctx, "This is a {bad_type}")] + bad_var_span: SourceSpan, // These can span multiple lines! + + // They can be optional! + #[highlight(ctx, "This is a {good_type}")] good_var_span: Option, - - #[span(other_src)] - #[label("{bad_var} is defined here")] - bad_var_definition_span: SourceSpan, // multiline span }, #[error(transparent)] diff --git a/src/chain.rs b/src/chain.rs index 74895c0..9cd93a9 100644 --- a/src/chain.rs +++ b/src/chain.rs @@ -1,7 +1,7 @@ /*! Iterate over error `.source()` chains. -NOTE: This module is taken wholesale from https://crates.io/crates/eyre. +NOTE: This module is taken wholesale from . */ use std::error::Error as StdError; use std::vec; diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..bbc9205 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,14 @@ +pub use std::io; + +pub use thiserror::Error; + +/** +Error enum for miette. Used by certain operations in the protocol. +*/ +#[derive(Debug, Error)] +pub enum MietteError { + #[error(transparent)] + IoError(#[from] io::Error), + #[error("The given offset is outside the bounds of its Source")] + OutOfBounds +} diff --git a/src/lib.rs b/src/lib.rs index cd9b2af..4d1946f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,10 +1,11 @@ #![doc = include_str!("../README.md")] -pub use chain::*; +pub use error::*; pub use protocol::*; pub use reporter::*; mod chain; +mod error; mod source_impls; mod protocol; mod reporter; diff --git a/src/protocol.rs b/src/protocol.rs index 57f951a..6980b13 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -1,5 +1,12 @@ +/*! +This module defines the core of the miette protocol: a series of types and traits +that you can implement to get access to miette's (and related library's) full +reporting and such features. +*/ + use std::fmt::Display; -use std::io::{self, Read}; + +use crate::MietteError; /** Adds rich metadata to your Error that can be used by [DiagnosticReporter] to print @@ -13,7 +20,7 @@ pub trait Diagnostic: std::error::Error + Send + Sync + 'static { /// `E0123` or Enums will work just fine. fn code(&self) -> &(dyn Display + 'static); - /// Diagnostic severity. This may be used by [Reporter]s to change the + /// Diagnostic severity. This may be used by [DiagnosticReporter]s to change the /// display format of this diagnostic. fn severity(&self) -> Severity; @@ -23,9 +30,9 @@ pub trait Diagnostic: std::error::Error + Send + Sync + 'static { None } - /// Additional contextual details. This is typically used for adding + /// Additional contextual snippets. This is typically used for adding /// marked-up source file output the way compilers often do. - fn details(&self) -> Option<&[DiagnosticDetail]> { + fn snippets(&self) -> Option<&[DiagnosticSnippet]> { None } } @@ -69,52 +76,137 @@ pub enum Severity { } /** -Represents a readable source of some sort: a source file, a String, etc. +Represents a readable source of some sort. + +This trait is able to support simple Source types like [String]s, as well +as more involved types like indexes into centralized `SourceMap`-like types, +file handles, and even network streams. + +If you can read it, you can source it, +and it's not necessary to read the whole thing--meaning you should be able to +support Sources which are gigabytes or larger in size. */ pub trait Source: std::fmt::Debug + Send + Sync + 'static { - /// Get a `Read`er from a given [Source]. - fn open(&self) -> io::Result>; + /// Read the bytes for a specific span from this Source. + fn read_span<'a>(&'a self, span: &SourceSpan) + -> Result + '_>, MietteError>; } /** -Details and additional context to be displayed. +Contents of a [Source] covered by [SourceSpan]. + +Includes line and column information to optimize highlight calculations. +*/ +pub trait SpanContents<'a> { + /// Reference to the data inside the associated span, in bytes. + fn data(&self) -> &[u8]; + /// The 0-indexed line in the associated [Source] where the data begins. + fn line(&self) -> usize; + /// The 0-indexed column in the associated [Source] where the data begins, + /// relative to `line`. + fn column(&self) -> usize; +} + +/** +Basic implementation of the [SpanContents] trait, for convenience. +*/ +#[derive(Clone, Debug)] +pub struct MietteSpanContents<'a> { + /// Data from a [Source], in bytes. + data: &'a [u8], + // The 0-indexed line where the associated [SourceSpan] _starts_. + line: usize, + // The 0-indexed column where the associated [SourceSpan] _starts_. + column: usize, +} + +impl<'a> MietteSpanContents<'a> { + /// Make a new [MietteSpanContents] object. + pub fn new(data: &'a [u8], line: usize, column: usize) -> MietteSpanContents<'a> { + MietteSpanContents { data, line, column } + } +} + +impl<'a> SpanContents<'a> for MietteSpanContents<'a> { + fn data(&self) -> &[u8] { + self.data + } + fn line(&self) -> usize { + self.line + } + fn column(&self) -> usize { + self.column + } +} + +/** +A snippet from a [Source] to be displayed with a message and possibly some highlights. */ #[derive(Debug)] -pub struct DiagnosticDetail { - /// Explanation of this specific diagnostic detail. +pub struct DiagnosticSnippet { + /// Explanation of this specific diagnostic snippet. pub message: Option, - /// The "filename" for this diagnostic. + /// The "filename" for this snippet. pub source_name: String, /// A [Source] that can be used to read the actual text of a source. pub source: Box, /// The primary [SourceSpan] where this diagnostic is located. - pub span: SourceSpan, - /// Additional [SourceSpan]s that can add secondary context. - pub other_spans: Option>, + pub context: SourceSpan, + /// Additional [SourceSpan]s that mark specific sections of the span, for + /// example, to underline specific text within the larger span. They're + /// paired with labels that should be applied to those sections. + pub highlights: Option>, } /** Span within a [Source] with an associated message. */ -#[derive(Debug)] +#[derive(Clone, Debug)] pub struct SourceSpan { - /// A name for the thing this SourceSpan is actually pointing to. - pub label: String, /// The start of the span. - pub start: SourceLocation, - /// The end of the span. Optional - pub end: Option, + pub start: SourceOffset, + /// The (exclusive) end of the span. + pub end: SourceOffset, +} + +impl SourceSpan { + pub fn new(start: SourceOffset, end: SourceOffset) -> Self { + assert!( + start.offset() <= end.offset(), + "Starting offset must come before the end offset." + ); + Self { start, end } + } + + pub fn len(&self) -> usize { + self.end.offset() - self.start.offset() + 1 + } + + pub fn is_empty(&self) -> bool { + self.start.offset() == self.end.offset() + } } /** -Specific location in a [SourceSpan] +"Raw" type for the byte offset from the beginning of a [Source]. */ -#[derive(Debug)] -pub struct SourceLocation { - /// 0-indexed column of location. - pub column: usize, - /// 0-indexed line of location. - pub line: usize, - /// 0-indexed _character_ offset of location. - pub offset: usize, +pub type ByteOffset = usize; + +/** +Newtype that represents the [ByteOffset] from the beginning of a [Source] +*/ +#[derive(Clone, Copy, Debug)] +pub struct SourceOffset(ByteOffset); + +impl SourceOffset { + /// Actual byte offset. + pub fn offset(&self) -> ByteOffset { + self.0 + } +} + +impl From for SourceOffset { + fn from(bytes: ByteOffset) -> Self { + SourceOffset(bytes) + } } diff --git a/src/reporter.rs b/src/reporter.rs index e4c9288..e5636fb 100644 --- a/src/reporter.rs +++ b/src/reporter.rs @@ -2,56 +2,104 @@ Basic reporter for Diagnostics. Probably good enough for most use-cases, but largely meant to be an example. */ +use std::fmt; + use indenter::indented; use crate::chain::Chain; -use crate::protocol::{Diagnostic, DiagnosticDetail, DiagnosticReporter, Severity}; +use crate::protocol::{Diagnostic, DiagnosticSnippet, DiagnosticReporter, Severity}; -pub struct Reporter; +/** +Reference implementation of the [DiagnosticReporter] trait. This is generally +good enough for simple use-cases, but you might want to implement your own if +you want custom reporting for your tool or app. +*/ +pub struct MietteReporter; -impl Reporter { - fn render_detail( - &self, - f: &mut core::fmt::Formatter<'_>, - detail: &DiagnosticDetail, - ) -> core::fmt::Result { - use core::fmt::Write as _; - write!(f, "\n[{}]", detail.source_name)?; - if let Some(msg) = &detail.message { +impl MietteReporter { + fn render_snippet(&self, f: &mut fmt::Formatter<'_>, snippet: &DiagnosticSnippet) -> fmt::Result { + use fmt::Write as _; + write!(f, "\n[{}]", snippet.source_name)?; + if let Some(msg) = &snippet.message { write!(f, " {}:", msg)?; } - writeln!( - indented(f), - "\n\n({}) @ line {}, col {} ", - detail.span.label, - detail.span.start.line + 1, - detail.span.start.column + 1 - )?; - if let Some(other_spans) = &detail.other_spans { - for span in other_spans { - writeln!( - indented(f), - "\n{} @ line {}, col {} ", - span.label, - span.start.line + 1, - span.start.column + 1 - )?; + writeln!(f)?; + writeln!(f)?; + let context_data = snippet + .source + .read_span(&snippet.context) + .map_err(|_| fmt::Error)?; + let context = std::str::from_utf8(context_data.data()).expect("Bad utf8 detected"); + let mut line = context_data.line(); + let mut column = context_data.column(); + let mut offset = snippet.context.start.offset(); + let mut line_offset = offset; + let mut iter = context.chars().peekable(); + let mut line_str = String::new(); + let highlights = snippet.highlights.as_ref(); + while let Some(char) = iter.next() { + offset += char.len_utf8(); + match char { + '\r' => { + if iter.next_if_eq(&'\n').is_some() { + offset += 1; + line += 1; + column = 0; + } else { + line_str.push(char); + column += 1; + } + } + '\n' => { + line += 1; + column = 0; + } + _ => { + line_str.push(char); + column += 1; + } + } + if iter.peek().is_none() { + line += 1; + } + + if column == 0 || iter.peek().is_none() { + writeln!(indented(f), "{: <2} | {}", line, line_str)?; + line_str.clear(); + if let Some(highlights) = highlights { + for (label, span) in highlights { + if span.start.offset() >= line_offset && span.end.offset() < offset { + // Highlight only covers one line. + write!(indented(f), "{: <2} | ", "⫶")?; + write!( + f, + "{}{} ", + " ".repeat(span.start.offset() - line_offset), + "^".repeat(span.len()) + )?; + writeln!(f, "{}", label)?; + } else if span.start.offset() < offset + && span.start.offset() >= line_offset + && span.end.offset() >= offset + { + // Multiline highlight. + todo!("Multiline highlights."); + } + } + } + line_offset = offset; } } Ok(()) } } -impl DiagnosticReporter for Reporter { - fn debug( - &self, - diagnostic: &(dyn Diagnostic), - f: &mut core::fmt::Formatter<'_>, - ) -> core::fmt::Result { - use core::fmt::Write as _; +impl DiagnosticReporter for MietteReporter { + fn debug(&self, diagnostic: &(dyn Diagnostic), f: &mut fmt::Formatter<'_>) -> fmt::Result { + use fmt::Write as _; if f.alternate() { - return core::fmt::Debug::fmt(diagnostic, f); + return fmt::Debug::fmt(diagnostic, f); } let sev = match diagnostic.severity() { @@ -75,10 +123,10 @@ impl DiagnosticReporter for Reporter { } } - if let Some(details) = diagnostic.details() { + if let Some(snippets) = diagnostic.snippets() { writeln!(f)?; - for detail in details { - self.render_detail(f, detail)?; + for snippet in snippets { + self.render_snippet(f, snippet)?; } } @@ -93,16 +141,13 @@ impl DiagnosticReporter for Reporter { } } +/// Literally what it says on the tin. pub struct JokeReporter; impl DiagnosticReporter for JokeReporter { - fn debug( - &self, - diagnostic: &(dyn Diagnostic), - f: &mut core::fmt::Formatter<'_>, - ) -> core::fmt::Result { + fn debug(&self, diagnostic: &(dyn Diagnostic), f: &mut fmt::Formatter<'_>) -> fmt::Result { if f.alternate() { - return core::fmt::Debug::fmt(diagnostic, f); + return fmt::Debug::fmt(diagnostic, f); } let sev = match diagnostic.severity() { @@ -124,8 +169,8 @@ impl DiagnosticReporter for JokeReporter { f, "miette, her eyes enormous: you {} miette? you {}? oh! oh! jail for mother! jail for mother for One Thousand Years!!!!", diagnostic.code(), - diagnostic.details().map(|details| { - details.iter().map(|detail| detail.message.clone()).collect::>>() + diagnostic.snippets().map(|snippets| { + snippets.iter().map(|snippet| snippet.message.clone()).collect::>>() }).flatten().map(|x| x.join(", ")).unwrap_or_else(||"try and cause miette to panic".into()) )?; diff --git a/src/source_impls.rs b/src/source_impls.rs index 963374b..5b0e822 100644 --- a/src/source_impls.rs +++ b/src/source_impls.rs @@ -1,26 +1,82 @@ /*! Default trait implementations for [Source]. */ -use std::fs::File; -use std::io::{Cursor, Read, Result}; -use std::path::{Path, PathBuf}; - -use crate::{Source}; +use crate::{MietteError, MietteSpanContents, Source, SourceSpan, SpanContents}; impl Source for String { - fn open(&self) -> Result> { - Ok(Box::new(Cursor::new(self.clone()))) + fn read_span(&self, span: &SourceSpan) -> Result, MietteError> { + let mut offset = 0usize; + let mut start_line = 0usize; + let mut start_column = 0usize; + let mut iter = self.chars().peekable(); + while let Some(char) = iter.next() { + if offset < span.start.offset() { + match char { + '\r' => { + if iter.next_if_eq(&'\n').is_some() { + offset += 1; + } + start_line += 1; + start_column = 0; + } + '\n' => { + start_line += 1; + start_column = 0; + } + _ => { + start_column += 1; + } + } + } + + if offset >= span.end.offset() { + return Ok(Box::new(MietteSpanContents::new( + &self.as_bytes()[span.start.offset()..=span.end.offset()], + start_line, + start_column, + ))); + } + + offset += char.len_utf8(); + } + Err(MietteError::OutOfBounds) } } -impl Source for Path { - fn open(&self) -> Result> { - Ok(Box::new(File::open(self)?)) - } -} +#[cfg(test)] +mod tests { + use super::*; -impl Source for PathBuf { - fn open(&self) -> Result> { - Ok(Box::new(File::open(self)?)) + #[test] + fn basic() -> Result<(), MietteError> { + let src = String::from("foo\n"); + let contents = src.read_span(&SourceSpan { + start: 0.into(), + end: 3.into(), + })?; + assert_eq!("foo\n", std::str::from_utf8(contents.data()).unwrap()); + Ok(()) + } + + #[test] + fn middle() -> Result<(), MietteError> { + let src = String::from("foo\nbar\nbaz\n"); + let contents = src.read_span(&SourceSpan { + start: 4.into(), + end: 7.into(), + })?; + assert_eq!("bar\n", std::str::from_utf8(contents.data()).unwrap()); + Ok(()) + } + + #[test] + fn with_crlf() -> Result<(), MietteError> { + let src = String::from("foo\r\nbar\r\nbaz\r\n"); + let contents = src.read_span(&SourceSpan { + start: 5.into(), + end: 9.into(), + })?; + assert_eq!("bar\r\n", std::str::from_utf8(contents.data()).unwrap()); + Ok(()) } } diff --git a/tests/reporter.rs b/tests/reporter.rs index 91c0cc7..84227c6 100644 --- a/tests/reporter.rs +++ b/tests/reporter.rs @@ -1,20 +1,19 @@ -use std::{fmt, io}; +use std::fmt; use miette::{ - Diagnostic, DiagnosticDetail, DiagnosticReporter, Reporter, Severity, SourceLocation, - SourceSpan, + Diagnostic, DiagnosticSnippet, DiagnosticReporter, MietteError, MietteReporter, Severity, SourceSpan, }; use thiserror::Error; #[derive(Error)] #[error("oops!")] struct MyBad { - details: Vec, + details: Vec, } impl fmt::Debug for MyBad { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - Reporter.debug(self, f) + MietteReporter.debug(self, f) } } @@ -31,13 +30,13 @@ impl Diagnostic for MyBad { Some(&["try doing it better next time?"]) } - fn details(&self) -> Option<&[DiagnosticDetail]> { + fn snippets(&self) -> Option<&[DiagnosticSnippet]> { Some(&self.details) } } #[test] -fn basic() -> io::Result<()> { +fn basic() -> Result<(), MietteError> { let err = MyBad { details: Vec::new(), }; @@ -50,26 +49,27 @@ fn basic() -> io::Result<()> { } #[test] -fn fancy() -> io::Result<()> { +fn fancy() -> Result<(), MietteError> { + let src = "source\n text\n here".to_string(); let err = MyBad { - details: vec![DiagnosticDetail { + details: vec![DiagnosticSnippet { message: Some("This is the part that broke".into()), source_name: "bad_file.rs".into(), - source: Box::new("source_text".to_string()), - other_spans: None, - span: SourceSpan { - label: "this thing here is bad".into(), - start: SourceLocation { - line: 0, - column: 0, - offset: 0, - }, - end: None, + source: Box::new(src.clone()), + highlights: Some(vec![ + ("this bit here".into(), SourceSpan { + start: 9.into(), + end: 12.into(), + }) + ]), + context: SourceSpan { + start: 0.into(), + end: (src.len() - 1).into(), }, }], }; let out = format!("{:?}", err); // println!("{}", out); - assert_eq!("Error[oops::my::bad]: oops!\n\n[bad_file.rs] This is the part that broke:\n\n (this thing here is bad) @ line 1, col 1 \n\n﹦try doing it better next time?\n".to_string(), out); + assert_eq!("Error[oops::my::bad]: oops!\n\n[bad_file.rs] This is the part that broke:\n\n 1 | source\n 2 | text\n ⫶ | ^^^^ this bit here\n 3 | here\n\n﹦try doing it better next time?\n".to_string(), out); Ok(()) }