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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
- **[FEATURE]** Support `rust_decimal::Decimal` as an inner type behind the `rust_decimal` feature flag, with the standard numeric validators and sanitizers (see [#242](https://github.com/greyblake/nutype/issues/242)).
- **[FEATURE]** Friendlier error when a `#[nutype(...)]` attribute is mistyped: suggests the closest match (e.g. `validte` -> `validate`) and lists the available nutype attributes (see [#240](https://github.com/greyblake/nutype/issues/240)).
- **[FIX]** Fix misleading error for value-type mismatches in validators (see [#241](https://github.com/greyblake/nutype/issues/241)).
- **[FIX]** Improve rust-analyzer resilience: when `#[nutype(...)]` arguments fail to parse (e.g. while still being typed), emit a best-effort type skeleton alongside the error so the newtype stays resolvable and downstream completions keep working (see [#178](https://github.com/greyblake/nutype/issues/178)).

### v0.7.0 - 2026-04-25
- **[BREAKING]** Rename `derive_unsafe` to `derive_unchecked` (both the feature flag and the attribute).
Expand Down
143 changes: 143 additions & 0 deletions nutype_macros/src/common/fallback.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
//! Best-effort recovery used when `#[nutype(...)]` fails to expand.
//!
//! `nutype` consumes the annotated struct and re-emits a brand new
//! `struct Name(Inner);`. When expansion fails (most commonly because the user
//! is still *typing* the attribute arguments and they don't parse yet), the
//! macro would otherwise emit only `compile_error!(...)`, which makes the type
//! itself vanish from the compiler's and rust-analyzer's view. Every downstream
//! `Name::...`, `let x: Name`, and field access then turns red, even though the
//! only thing wrong is an unfinished attribute.
//!
//! To keep rust-analyzer resilient, the error path emits the skeleton produced
//! here *alongside* the real `compile_error!`. The skeleton only needs to
//! type-check; it does not enforce any invariants.

use proc_macro2::TokenStream;
use quote::quote;
use syn::{Data, DeriveInput, Fields};

/// Best-effort, type-checkable skeleton of the newtype.
///
/// Returns `None` when we cannot even recover a single-field tuple struct from
/// the input (in that case the caller emits only the compile error).
pub fn fallback_skeleton(type_definition: &TokenStream) -> Option<TokenStream> {
let input: DeriveInput = syn::parse2(type_definition.clone()).ok()?;

let DeriveInput {
vis,
ident,
generics,
data,
..
} = input;

let data_struct = match data {
Data::Struct(s) => s,
_ => return None,
};

let inner_ty = match data_struct.fields {
Fields::Unnamed(fields) => fields.unnamed.into_iter().next()?.ty,
_ => return None,
};

let (impl_generics, ty_generics, where_clause) = generics.split_for_impl();

Some(quote! {
// NB: the inner field visibility is intentionally dropped. The real
// expansion forbids a visible inner field; the skeleton only needs to
// type-check, not to enforce that invariant.
#vis struct #ident #generics (#inner_ty) #where_clause;

// Stub the inherent methods nutype always generates (the constructors and
// `into_inner`) so that completion and resolution on `Name::...` keep
// working while the attribute is broken. We can't know whether the real
// type will end up validated (`try_new`) or not (`new`), so we provide
// both. `unimplemented!()` diverges and coerces to any return type, the
// argument is taken via `impl Into<Inner>` (every type satisfies the
// reflexive `Inner: Into<Inner>`), and no extra type is introduced, so
// this type-checks for every inner type without polluting the namespace.
#[doc(hidden)]
#[allow(dead_code, unused_variables, clippy::all)]
impl #impl_generics #ident #ty_generics #where_clause {
pub fn new(raw_value: impl ::core::convert::Into<#inner_ty>) -> Self {
::core::unimplemented!()
}

pub fn try_new(
raw_value: impl ::core::convert::Into<#inner_ty>,
) -> ::core::result::Result<Self, ::core::convert::Infallible> {
::core::unimplemented!()
}

pub fn into_inner(self) -> #inner_ty {
::core::unimplemented!()
}
}
})
}

#[cfg(test)]
mod tests {
use super::*;

fn renders(ts: &TokenStream) -> String {
ts.to_string()
}

#[test]
fn skeleton_for_simple_string_newtype() {
let input = quote! { pub struct Foo(String); };
let out = fallback_skeleton(&input).expect("should produce a skeleton");

// The output must be valid Rust.
syn::parse2::<syn::File>(out.clone()).expect("skeleton must parse as a file");

let rendered = renders(&out);
assert!(rendered.contains("struct Foo"));
assert!(rendered.contains("String"));
// All the inherent methods nutype always generates are stubbed so that
// `Foo::new(..)`, `Foo::try_new(..)` and `foo.into_inner()` keep
// resolving while the attribute is broken.
assert!(rendered.contains("fn new"));
assert!(rendered.contains("fn try_new"));
assert!(rendered.contains("fn into_inner"));
}

#[test]
fn skeleton_preserves_generics_and_where_clause() {
let input = quote! { pub struct Wrapper<T: Clone>(T) where T: Default; };
let out = fallback_skeleton(&input).expect("should produce a skeleton");

syn::parse2::<syn::File>(out.clone()).expect("skeleton must parse as a file");

let rendered = renders(&out);
assert!(rendered.contains("struct Wrapper"));
assert!(rendered.contains("Clone"));
assert!(rendered.contains("Default"));
}

#[test]
fn no_skeleton_for_named_struct() {
let input = quote! { struct S { x: u8 } };
assert!(fallback_skeleton(&input).is_none());
}

#[test]
fn no_skeleton_for_enum() {
let input = quote! { enum E { A, B } };
assert!(fallback_skeleton(&input).is_none());
}

#[test]
fn no_skeleton_for_empty_tuple_struct() {
let input = quote! { struct S(); };
assert!(fallback_skeleton(&input).is_none());
}

#[test]
fn no_skeleton_for_unparsable_input() {
let input = quote! { this is not a struct @ # !; };
assert!(fallback_skeleton(&input).is_none());
}
}
1 change: 1 addition & 0 deletions nutype_macros/src/common/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
pub mod fallback;
pub mod generate;
pub mod models;
pub mod parse;
Expand Down
14 changes: 12 additions & 2 deletions nutype_macros/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,18 @@ pub fn nutype(
attrs: proc_macro::TokenStream,
type_definition: proc_macro::TokenStream,
) -> proc_macro::TokenStream {
expand_nutype(attrs.into(), type_definition.into())
.unwrap_or_else(|e| syn::Error::to_compile_error(&e))
let type_definition: TokenStream = type_definition.into();
expand_nutype(attrs.into(), type_definition.clone())
.unwrap_or_else(|e| {
let compile_error = e.to_compile_error();
// Emit a best-effort skeleton of the newtype alongside the error so
// rust-analyzer keeps resolving the type while the attribute is
// still being typed. See `common::fallback` for details.
match common::fallback::fallback_skeleton(&type_definition) {
Some(skeleton) => quote::quote! { #skeleton #compile_error },
None => compile_error,
}
})
.into()
}

Expand Down
24 changes: 24 additions & 0 deletions test_suite/tests/ui/common/fallback_keeps_type_resolvable.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
use nutype::nutype;

// The attribute is broken, so expansion fails. Thanks to the fallback skeleton,
// the type `Name`, its constructors and `into_inner` still exist, so the usages
// below do NOT produce spurious "cannot find type/function `Name`" errors --
// only the attribute error surfaces.
#[nutype(validte)]
pub struct Name(String);

fn use_it(n: Name) -> String {
n.into_inner()
}

fn construct() {
// Both constructors are stubbed because we cannot know whether the fixed
// attribute will end up validated (`try_new`) or not (`new`).
let _ = Name::new("hello");
let _ = Name::try_new("hello");
}

fn main() {
let _ = use_it;
let _ = construct;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
error: Unknown nutype attribute `validte`. Did you mean `validate`?
Other available nutype attributes are: `sanitize`, `derive`, `default`, `const_fn`, `cfg_attr`, `constructor`.
--> tests/ui/common/fallback_keeps_type_resolvable.rs:7:10
|
7 | #[nutype(validte)]
| ^^^^^^^
8 changes: 0 additions & 8 deletions test_suite/tests/ui_decimal_on/const_fn_rejected.stderr
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,3 @@ error: `const_fn` is not supported for `rust_decimal::Decimal`, because Decimal'
| |__^
|
= note: this error originates in the attribute macro `nutype` (in Nightly builds, run with -Z macro-backtrace for more info)

warning: unused import: `rust_decimal::Decimal`
--> tests/ui_decimal_on/const_fn_rejected.rs:2:5
|
2 | use rust_decimal::Decimal;
| ^^^^^^^^^^^^^^^^^^^^^
|
= note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default
8 changes: 0 additions & 8 deletions test_suite/tests/ui_decimal_on/derive_valuable.stderr
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,3 @@ error: #[nutype] cannot derive `Valuable` trait for `rust_decimal::Decimal`, bec
|
6 | #[nutype(derive(Debug, Valuable))]
| ^^^^^^^^

warning: unused import: `rust_decimal::Decimal`
--> tests/ui_decimal_on/derive_valuable.rs:2:5
|
2 | use rust_decimal::Decimal;
| ^^^^^^^^^^^^^^^^^^^^^
|
= note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default
8 changes: 0 additions & 8 deletions test_suite/tests/ui_decimal_on/invalid_default_literal.stderr
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,3 @@ error: Invalid decimal default value `1e40`: Scale exceeds the maximum precision
|
8 | default = 1e40,
| ^^^^

warning: unused import: `rust_decimal::Decimal`
--> tests/ui_decimal_on/invalid_default_literal.rs:2:5
|
2 | use rust_decimal::Decimal;
| ^^^^^^^^^^^^^^^^^^^^^
|
= note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default
8 changes: 0 additions & 8 deletions test_suite/tests/ui_decimal_on/unknown_validator.stderr
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,3 @@ error: Unknown validation attribute: `finite`.
|
7 | validate(finite),
| ^^^^^^

warning: unused import: `rust_decimal::Decimal`
--> tests/ui_decimal_on/unknown_validator.rs:2:5
|
2 | use rust_decimal::Decimal;
| ^^^^^^^^^^^^^^^^^^^^^
|
= note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,3 @@ error: Deriving `Arbitrary` for a Decimal type currently supports inclusive boun
| |__^
|
= note: this error originates in the attribute macro `nutype` (in Nightly builds, run with -Z macro-backtrace for more info)

warning: unused import: `rust_decimal::Decimal`
--> tests/ui_decimal_on_arbitrary/arbitrary_exclusive_bound.rs:2:5
|
2 | use rust_decimal::Decimal;
| ^^^^^^^^^^^^^^^^^^^^^
|
= note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default
Loading