Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 37 additions & 2 deletions src/ibidem.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright 2022 Oxide Computer Company
// Copyright 2026 Oxide Computer Company

use std::cell::RefCell;

Expand Down Expand Up @@ -56,7 +56,14 @@ impl<'de, P: syn::parse::Parse> Deserialize<'de> for ParseWrapper<P> {
{
let token_stream = deserializer.deserialize_bytes(WrapperVisitor)?;

Ok(Self(syn::parse2::<P>(token_stream).map_err(D::Error::custom)?))
match syn::parse2::<P>(token_stream) {
Ok(parsed) => Ok(Self(parsed)),
Err(err) => {
let msg = err.to_string();
set_parse_error(err);
Err(D::Error::custom(msg))
}
}
}
}

Expand Down Expand Up @@ -142,6 +149,24 @@ thread_local! {
// Because this set/take sequence is immediate without anything in between,
// there's no nesting to be worried about.
static WRAPPER_TOKENS: RefCell<Option<TokenStream>> = Default::default();

// A second side channel for preserving span information from syn parse
// errors through serde's `D::Error::custom` bottleneck.
//
// When `ParseWrapper::deserialize` calls `syn::parse2` and it fails, the
// resulting `syn::Error` carries precise span information. But
// `D::Error::custom` only accepts `T: Display`, flattening the error to a
// string and losing that span. To preserve it:
//
// 1. `ParseWrapper::deserialize` stores the `syn::Error` here via
// `set_parse_error`.
// 2. `ParseWrapper::deserialize` calls `D::Error::custom(msg)`.
// 3. `InternalError::custom` calls `take_parse_error` and, if set,
// returns `InternalError::Normal(syn_error)` instead of
// `InternalError::NoData(msg)`.
//
// As with `WRAPPER_TOKENS`, the set/take sequence is immediate.
static PARSE_ERROR: RefCell<Option<syn::Error>> = Default::default();
}

pub(crate) fn set_wrapper_tokens(tokens: Vec<TokenTree>) {
Expand All @@ -161,3 +186,13 @@ fn take_wrapper_tokens() -> TokenStream {
)
})
}

fn set_parse_error(err: syn::Error) {
PARSE_ERROR.with(|cell| {
*cell.borrow_mut() = Some(err);
});
}

pub(crate) fn take_parse_error() -> Option<syn::Error> {
PARSE_ERROR.with(|cell| cell.borrow_mut().take())
}
12 changes: 10 additions & 2 deletions src/serde_tokenstream.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright 2022 Oxide Computer Company
// Copyright 2026 Oxide Computer Company

use core::iter::Peekable;
use std::{
Expand All @@ -15,6 +15,7 @@ use serde::{Deserialize, Deserializer};
use syn::{ExprLit, Lit};

use crate::ibidem::set_wrapper_tokens;
use crate::ibidem::take_parse_error;

/// Alias for `syn::Error`.
///
Expand Down Expand Up @@ -328,7 +329,14 @@ impl serde::de::Error for InternalError {
where
T: std::fmt::Display,
{
InternalError::NoData(format!("{}", msg))
// Check whether a ParseWrapper stored a syn::Error with span
// information via the PARSE_ERROR side channel. If so, use it
// directly to preserve the span.
if let Some(parse_err) = take_parse_error() {
InternalError::Normal(parse_err)
} else {
InternalError::NoData(format!("{}", msg))
}
}
}
impl std::error::Error for InternalError {}
Expand Down
44 changes: 43 additions & 1 deletion testlib/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright 2020 Oxide Computer Company
// Copyright 2026 Oxide Computer Company

//! Simple proc macro consumer of `serde_tokenstream` that we use for testing
//! various failure cases.
Expand All @@ -22,6 +22,7 @@ struct Annotation {
unit: (),
tup: (u32, f32),
bool_expr: Option<ParseWrapper<syn::Expr>>,
painted: Option<ParseWrapper<Painted>>,
}

#[derive(Deserialize)]
Expand All @@ -40,6 +41,47 @@ struct Nested {
gosling: f64,
}

/// An inline struct used to test span preservation in ParseWrapper.
#[derive(Deserialize)]
#[allow(dead_code)]
struct PaintColor {
red: bool,
green: bool,
blue: bool,
}

/// A compound struct with a hand-written `Parse` impl that internally uses
/// `serde_tokenstream`. Used to test that `ParseWrapper` preserves span
/// information from the inner `syn::Error` rather than re-attributing it to the
/// surrounding group.
///
/// A `ParseWrapper` over a compound struct is not normally necessary -- our
/// case could just as well be `painted: Option<Painted>`. But it is necessary
/// when a hand-written `Parse` implementation must be provided, such as when
/// either a scalar value or a compound struct is allowed in a position.
#[allow(dead_code)]
struct Painted {
color: PaintColor,
}

impl syn::parse::Parse for Painted {
fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
#[derive(Deserialize)]
struct PaintedHelper {
color: PaintColor,
}

let content;
let brace_token = syn::braced!(content in input);
let stream: proc_macro2::TokenStream = content.parse()?;
let inner: PaintedHelper = serde_tokenstream::from_tokenstream_spanned(
&brace_token.span,
&stream,
)?;
Ok(Painted { color: inner.color })
}
}

#[proc_macro_attribute]
pub fn annotation(
attr: proc_macro::TokenStream,
Expand Down
18 changes: 18 additions & 0 deletions ui-tests/tests/ui/bad_parse_wrapper_compound.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// Copyright 2026 Oxide Computer Company

// Ensure that ParseWrapper over a compound struct with a hand-written Parse
// impl preserves span information from the inner syn::Error. The error for
// `"not a bool"` should point at that token, not at the surrounding groups.

use testlib::annotation;

#[annotation {
string = "test",
options = OptionA,
unit = (),
tup = (1, 2.0),
painted = { color = { red = true, green = "not a bool", blue = false } }
}]
fn test() {}

fn main() {}
5 changes: 5 additions & 0 deletions ui-tests/tests/ui/bad_parse_wrapper_compound.stderr
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
error: expected bool, but found `"not a bool"`
--> tests/ui/bad_parse_wrapper_compound.rs:14:47
|
14 | painted = { color = { red = true, green = "not a bool", blue = false } }
| ^^^^^^^^^^^^