diff --git a/CHANGELOG.md b/CHANGELOG.md index a7cd268..77289a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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). diff --git a/nutype_macros/src/common/fallback.rs b/nutype_macros/src/common/fallback.rs new file mode 100644 index 0000000..b719562 --- /dev/null +++ b/nutype_macros/src/common/fallback.rs @@ -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 { + 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` (every type satisfies the + // reflexive `Inner: Into`), 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 { + ::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::(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) where T: Default; }; + let out = fallback_skeleton(&input).expect("should produce a skeleton"); + + syn::parse2::(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()); + } +} diff --git a/nutype_macros/src/common/mod.rs b/nutype_macros/src/common/mod.rs index 8e053d0..ed3fd11 100644 --- a/nutype_macros/src/common/mod.rs +++ b/nutype_macros/src/common/mod.rs @@ -1,3 +1,4 @@ +pub mod fallback; pub mod generate; pub mod models; pub mod parse; diff --git a/nutype_macros/src/lib.rs b/nutype_macros/src/lib.rs index 5b7848b..c38cb26 100644 --- a/nutype_macros/src/lib.rs +++ b/nutype_macros/src/lib.rs @@ -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() } diff --git a/test_suite/tests/ui/common/fallback_keeps_type_resolvable.rs b/test_suite/tests/ui/common/fallback_keeps_type_resolvable.rs new file mode 100644 index 0000000..8206536 --- /dev/null +++ b/test_suite/tests/ui/common/fallback_keeps_type_resolvable.rs @@ -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; +} diff --git a/test_suite/tests/ui/common/fallback_keeps_type_resolvable.stderr b/test_suite/tests/ui/common/fallback_keeps_type_resolvable.stderr new file mode 100644 index 0000000..5b3469f --- /dev/null +++ b/test_suite/tests/ui/common/fallback_keeps_type_resolvable.stderr @@ -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)] + | ^^^^^^^ diff --git a/test_suite/tests/ui_decimal_on/const_fn_rejected.stderr b/test_suite/tests/ui_decimal_on/const_fn_rejected.stderr index c9fc0b4..3dfc93b 100644 --- a/test_suite/tests/ui_decimal_on/const_fn_rejected.stderr +++ b/test_suite/tests/ui_decimal_on/const_fn_rejected.stderr @@ -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 diff --git a/test_suite/tests/ui_decimal_on/derive_valuable.stderr b/test_suite/tests/ui_decimal_on/derive_valuable.stderr index 3a92f06..b5c21c5 100644 --- a/test_suite/tests/ui_decimal_on/derive_valuable.stderr +++ b/test_suite/tests/ui_decimal_on/derive_valuable.stderr @@ -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 diff --git a/test_suite/tests/ui_decimal_on/invalid_default_literal.stderr b/test_suite/tests/ui_decimal_on/invalid_default_literal.stderr index e61d577..4aa39c9 100644 --- a/test_suite/tests/ui_decimal_on/invalid_default_literal.stderr +++ b/test_suite/tests/ui_decimal_on/invalid_default_literal.stderr @@ -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 diff --git a/test_suite/tests/ui_decimal_on/unknown_validator.stderr b/test_suite/tests/ui_decimal_on/unknown_validator.stderr index 88d65ba..91ceebd 100644 --- a/test_suite/tests/ui_decimal_on/unknown_validator.stderr +++ b/test_suite/tests/ui_decimal_on/unknown_validator.stderr @@ -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 diff --git a/test_suite/tests/ui_decimal_on_arbitrary/arbitrary_exclusive_bound.stderr b/test_suite/tests/ui_decimal_on_arbitrary/arbitrary_exclusive_bound.stderr index 141611e..e4b357b 100644 --- a/test_suite/tests/ui_decimal_on_arbitrary/arbitrary_exclusive_bound.stderr +++ b/test_suite/tests/ui_decimal_on_arbitrary/arbitrary_exclusive_bound.stderr @@ -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