diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c8ae11..f586a8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Fixed + +- `EncodingError` and `ParseIndexError` now implement `Diagnostic`, which + unifies the API for errors originating from parse-like operations. +- Fixes returning an incorrect error type when parsing a `Token` that + terminates with `~`. This now correctly classifies the error as a `Tilde` + error. + +### Removed + +- Some methods were removed from `InvalidCharacterError`, as that type no + longer holds a copy of the input string internally. This is a breaking + change. To access equivalent functionality, use the `Diagnostic` API + integration. + ### Changed - Sealed the `Diagnose` trait. - Implementation of the `Default` trait for `Pointer` now doesn't constrain the lifetime. diff --git a/src/assign.rs b/src/assign.rs index 8b4e3bf..870e4b2 100644 --- a/src/assign.rs +++ b/src/assign.rs @@ -608,7 +608,6 @@ mod tests { index::{InvalidCharacterError, OutOfBoundsError, ParseIndexError}, Pointer, }; - use alloc::vec; use core::fmt::{Debug, Display}; #[derive(Debug)] @@ -798,10 +797,7 @@ mod tests { expected: Err(Error::FailedToParseIndex { position: 0, offset: 0, - source: ParseIndexError::InvalidCharacter(InvalidCharacterError { - source: "12a".into(), - offset: 2, - }), + source: ParseIndexError::InvalidCharacter(InvalidCharacterError { offset: 2 }), }), expected_data: json!([]), }, @@ -823,10 +819,7 @@ mod tests { expected: Err(Error::FailedToParseIndex { position: 0, offset: 0, - source: ParseIndexError::InvalidCharacter(InvalidCharacterError { - source: "+23".into(), - offset: 0, - }), + source: ParseIndexError::InvalidCharacter(InvalidCharacterError { offset: 0 }), }), expected_data: json!([]), }, @@ -839,6 +832,7 @@ mod tests { #[test] #[cfg(feature = "toml")] fn assign_toml() { + use alloc::vec; use toml::{toml, Table, Value}; [ Test { @@ -976,10 +970,7 @@ mod tests { expected: Err(Error::FailedToParseIndex { position: 0, offset: 0, - source: ParseIndexError::InvalidCharacter(InvalidCharacterError { - source: "a".into(), - offset: 0, - }), + source: ParseIndexError::InvalidCharacter(InvalidCharacterError { offset: 0 }), }), expected_data: Value::Array(vec![]), }, diff --git a/src/diagnostic.rs b/src/diagnostic.rs index d3e6943..72e9d79 100644 --- a/src/diagnostic.rs +++ b/src/diagnostic.rs @@ -231,8 +231,6 @@ mod private { #[cfg(test)] mod tests { - use super::*; - use crate::{Pointer, PointerBuf}; #[test] #[cfg(all( feature = "assign", @@ -241,6 +239,8 @@ mod tests { feature = "json" ))] fn assign_error() { + use crate::{diagnostic::Diagnose, PointerBuf}; + let mut v = serde_json::json!({"foo": {"bar": ["0"]}}); let ptr = PointerBuf::parse("/foo/bar/invalid/cannot/reach").unwrap(); let report = ptr.assign(&mut v, "qux").diagnose(ptr).unwrap_err(); @@ -259,6 +259,7 @@ mod tests { feature = "json" ))] fn resolve_error() { + use crate::{diagnostic::Diagnose, PointerBuf}; let v = serde_json::json!({"foo": {"bar": ["0"]}}); let ptr = PointerBuf::parse("/foo/bar/invalid/cannot/reach").unwrap(); let report = ptr.resolve(&v).diagnose(ptr).unwrap_err(); @@ -272,6 +273,7 @@ mod tests { #[test] #[cfg(feature = "miette")] fn parse_error() { + use crate::{diagnostic::Diagnose, Pointer, PointerBuf}; let invalid = "/foo/bar/invalid~3~encoding/cannot/reach"; let report = Pointer::parse(invalid).diagnose(invalid).unwrap_err(); diff --git a/src/index.rs b/src/index.rs index bad40bc..c1ebf4c 100644 --- a/src/index.rs +++ b/src/index.rs @@ -35,9 +35,12 @@ //! assert_eq!(Index::Next.for_len_unchecked(30), 30); //! ``` -use crate::Token; -use alloc::string::String; -use core::{fmt, num::ParseIntError, str::FromStr}; +use crate::{ + diagnostic::{diagnostic_url, Diagnostic, Label}, + Token, +}; +use alloc::{boxed::Box, string::String}; +use core::{fmt, iter::once, num::ParseIntError, str::FromStr}; /// Represents an abstract index into an array. /// @@ -177,7 +180,6 @@ impl FromStr for Index { // representing a `usize` but not allowed in RFC 6901 array // indices Err(ParseIndexError::InvalidCharacter(InvalidCharacterError { - source: String::from(s), offset, })) }, @@ -309,6 +311,88 @@ impl fmt::Display for ParseIndexError { } } +// shouldn't be used directly, but is part of a public interface +#[doc(hidden)] +#[derive(Debug)] +pub enum StringOrToken { + String(String), + Token(Token<'static>), +} + +impl From for StringOrToken { + fn from(value: String) -> Self { + Self::String(value) + } +} + +impl From> for StringOrToken { + fn from(value: Token<'static>) -> Self { + Self::Token(value) + } +} + +impl core::ops::Deref for StringOrToken { + type Target = str; + + fn deref(&self) -> &Self::Target { + match self { + StringOrToken::String(s) => s.as_str(), + StringOrToken::Token(t) => t.encoded(), + } + } +} + +#[cfg(feature = "miette")] +impl miette::SourceCode for StringOrToken { + fn read_span<'a>( + &'a self, + span: &miette::SourceSpan, + context_lines_before: usize, + context_lines_after: usize, + ) -> Result + 'a>, miette::MietteError> { + let s: &str = &**self; + s.read_span(span, context_lines_before, context_lines_after) + } +} + +impl Diagnostic for ParseIndexError { + type Subject = StringOrToken; + + fn url() -> &'static str { + diagnostic_url!(enum ParseIndexError) + } + + fn labels( + &self, + subject: &Self::Subject, + ) -> Option>> { + let subject = &**subject; + match self { + ParseIndexError::InvalidInteger(_) => None, + ParseIndexError::LeadingZeros => { + let len = subject + .chars() + .position(|c| c != '0') + .expect("starts with zeros"); + let text = String::from("leading zeros"); + Some(Box::new(once(Label::new(text, 0, len)))) + } + ParseIndexError::InvalidCharacter(err) => { + let len = subject + .chars() + .skip(err.offset) + .position(|c| c.is_ascii_digit()) + .unwrap_or(subject.len()); + let text = String::from("invalid character(s)"); + Some(Box::new(once(Label::new(text, err.offset, len)))) + } + } + } +} + +#[cfg(feature = "miette")] +impl miette::Diagnostic for ParseIndexError {} + #[cfg(feature = "std")] impl std::error::Error for ParseIndexError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { @@ -323,7 +407,6 @@ impl std::error::Error for ParseIndexError { /// Indicates that a non-digit character was found when parsing the RFC 6901 array index. #[derive(Debug, Clone, PartialEq, Eq)] pub struct InvalidCharacterError { - pub(crate) source: String, pub(crate) offset: usize, } @@ -334,29 +417,14 @@ impl InvalidCharacterError { pub fn offset(&self) -> usize { self.offset } - - /// Returns the source string. - pub fn source(&self) -> &str { - &self.source - } - - /// Returns the offending character. - #[allow(clippy::missing_panics_doc)] - pub fn char(&self) -> char { - self.source - .chars() - .nth(self.offset) - .expect("char was found at offset") - } } impl fmt::Display for InvalidCharacterError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!( f, - "token contains the non-digit character '{}', \ - which is disallowed by RFC 6901", - self.char() + "token contains a non-digit character, \ + which is disallowed by RFC 6901", ) } } @@ -377,7 +445,7 @@ impl std::error::Error for InvalidCharacterError {} #[cfg(test)] mod tests { use super::*; - use crate::Token; + use crate::{Diagnose, Token}; #[test] fn index_from_usize() { @@ -467,4 +535,80 @@ mod tests { let index = Index::try_from(&token).unwrap(); assert_eq!(index, Index::Next); } + + #[test] + fn diagnose_works_with_token_or_string() { + let token = Token::new("foo"); + // despite the clone, this is cheap because `token` is borrowed + Index::try_from(token.clone()).diagnose(token).unwrap_err(); + let s = String::from("bar"); + Index::try_from(&s).diagnose(s).unwrap_err(); + } + + #[test] + fn error_from_invalid_chars() { + let s = String::from("bar"); + let err = Index::try_from(&s).diagnose(s).unwrap_err(); + + #[cfg(feature = "miette")] + { + let labels: Vec<_> = miette::Diagnostic::labels(&err) + .unwrap() + .into_iter() + .collect(); + assert_eq!( + labels, + vec![miette::LabeledSpan::new( + Some("invalid character(s)".into()), + 0, + 3 + )] + ); + } + + let (src, sub) = err.decompose(); + let labels: Vec<_> = src.labels(&sub).unwrap().into_iter().collect(); + + assert_eq!( + labels, + vec![Label::new("invalid character(s)".into(), 0, 3)] + ); + } + + #[test] + fn error_from_leading_zeros() { + let s = String::from("000001"); + let err = Index::try_from(&s).diagnose(s).unwrap_err(); + + #[cfg(feature = "miette")] + { + let labels: Vec<_> = miette::Diagnostic::labels(&err) + .unwrap() + .into_iter() + .collect(); + assert_eq!( + labels, + vec![miette::LabeledSpan::new(Some("leading zeros".into()), 0, 5)] + ); + } + + let (src, sub) = err.decompose(); + let labels: Vec<_> = src.labels(&sub).unwrap().into_iter().collect(); + + assert_eq!(labels, vec![Label::new("leading zeros".into(), 0, 5)]); + } + + #[test] + fn error_from_empty_string() { + let s = String::from(""); + let err = Index::try_from(&s).diagnose(s).unwrap_err(); + + #[cfg(feature = "miette")] + { + assert!(miette::Diagnostic::labels(&err).is_none()); + } + + let (src, sub) = err.decompose(); + assert!(src.labels(&sub).is_none()); + } } diff --git a/src/token.rs b/src/token.rs index fe574a2..25119c1 100644 --- a/src/token.rs +++ b/src/token.rs @@ -1,8 +1,12 @@ -use core::str::Split; +use core::{iter::once, str::Split}; -use crate::index::{Index, ParseIndexError}; +use crate::{ + diagnostic::{diagnostic_url, Diagnostic, Label}, + index::{Index, ParseIndexError}, +}; use alloc::{ borrow::Cow, + boxed::Box, fmt, string::{String, ToString}, vec::Vec, @@ -99,7 +103,7 @@ impl<'a> Token<'a> { if escaped { return Err(EncodingError { offset: s.len(), - source: InvalidEncoding::Slash, + source: InvalidEncoding::Tilde, }); } Ok(Self { inner: s.into() }) @@ -381,13 +385,6 @@ pub struct EncodingError { pub source: InvalidEncoding, } -#[cfg(feature = "std")] -impl std::error::Error for EncodingError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - Some(&self.source) - } -} - impl fmt::Display for EncodingError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!( @@ -398,6 +395,38 @@ impl fmt::Display for EncodingError { } } +impl Diagnostic for EncodingError { + type Subject = String; + + fn url() -> &'static str { + diagnostic_url!(struct EncodingError) + } + + fn labels(&self, subject: &Self::Subject) -> Option>> { + let (text, offset) = match self.source { + InvalidEncoding::Tilde => { + if self.offset == subject.len() { + ("incomplete escape sequence", self.offset - 1) + } else { + ("must be 0 or 1", self.offset) + } + } + InvalidEncoding::Slash => ("invalid character", self.offset), + }; + Some(Box::new(once(Label::new(text.to_string(), offset, 1)))) + } +} + +#[cfg(feature = "miette")] +impl miette::Diagnostic for EncodingError {} + +#[cfg(feature = "std")] +impl std::error::Error for EncodingError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + Some(&self.source) + } +} + /* ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ╔══════════════════════════════════════════════════════════════════════════════╗ @@ -425,6 +454,7 @@ impl fmt::Display for InvalidEncoding { } } } + #[cfg(feature = "std")] impl std::error::Error for InvalidEncoding {} @@ -476,8 +506,95 @@ mod tests { assert_eq!(Token::from_encoded("~0~1").unwrap().encoded(), "~0~1"); let t = Token::from_encoded("a~1b").unwrap(); assert_eq!(t.decoded(), "a/b"); - assert!(Token::from_encoded("a/b").is_err()); - assert!(Token::from_encoded("a~a").is_err()); + + let sub = String::from("a/b"); + let err = Token::from_encoded(&sub).unwrap_err(); + let labels: Vec<_> = err.labels(&sub).unwrap().into_iter().collect(); + assert_eq!(labels, vec![Label::new("invalid character".into(), 1, 1)]); + let err = err.into_report(sub); + #[cfg(feature = "miette")] + { + let labels: Vec<_> = miette::Diagnostic::labels(&err) + .unwrap() + .into_iter() + .collect(); + assert_eq!( + labels, + vec![miette::LabeledSpan::new( + Some("invalid character".into()), + 1, + 1 + )] + ); + } + let (err, _) = err.decompose(); + assert_eq!( + err, + EncodingError { + offset: 1, + source: InvalidEncoding::Slash + } + ); + + let sub = String::from("a~a"); + let err = Token::from_encoded(&sub).unwrap_err(); + let labels: Vec<_> = err.labels(&sub).unwrap().into_iter().collect(); + assert_eq!(labels, vec![Label::new("must be 0 or 1".into(), 2, 1)]); + let err = err.into_report(sub); + #[cfg(feature = "miette")] + { + let labels: Vec<_> = miette::Diagnostic::labels(&err) + .unwrap() + .into_iter() + .collect(); + assert_eq!( + labels, + vec![miette::LabeledSpan::new( + Some("must be 0 or 1".into()), + 2, + 1 + )] + ); + } + let (err, _) = err.decompose(); + assert_eq!( + err, + EncodingError { + offset: 2, + source: InvalidEncoding::Tilde + } + ); + let sub = String::from("a~"); + let err = Token::from_encoded(&sub).unwrap_err(); + let labels: Vec<_> = err.labels(&sub).unwrap().into_iter().collect(); + assert_eq!( + labels, + vec![Label::new("incomplete escape sequence".into(), 1, 1)] + ); + let err = err.into_report(sub); + #[cfg(feature = "miette")] + { + let labels: Vec<_> = miette::Diagnostic::labels(&err) + .unwrap() + .into_iter() + .collect(); + assert_eq!( + labels, + vec![miette::LabeledSpan::new( + Some("incomplete escape sequence".into()), + 1, + 1 + )] + ); + } + let (err, _) = err.decompose(); + assert_eq!( + err, + EncodingError { + offset: 2, + source: InvalidEncoding::Tilde + } + ); } #[test]