Skip to content

Commit 4648e10

Browse files
authored
Merge pull request #246 from greyblake/rust-analyzer-support
Improve RA: generate skeleton when macro fails
2 parents c0f6540 + d7e37bf commit 4648e10

11 files changed

Lines changed: 187 additions & 42 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
- **[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)).
33
- **[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)).
44
- **[FIX]** Fix misleading error for value-type mismatches in validators (see [#241](https://github.com/greyblake/nutype/issues/241)).
5+
- **[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)).
56

67
### v0.7.0 - 2026-04-25
78
- **[BREAKING]** Rename `derive_unsafe` to `derive_unchecked` (both the feature flag and the attribute).
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
//! Best-effort recovery used when `#[nutype(...)]` fails to expand.
2+
//!
3+
//! `nutype` consumes the annotated struct and re-emits a brand new
4+
//! `struct Name(Inner);`. When expansion fails (most commonly because the user
5+
//! is still *typing* the attribute arguments and they don't parse yet), the
6+
//! macro would otherwise emit only `compile_error!(...)`, which makes the type
7+
//! itself vanish from the compiler's and rust-analyzer's view. Every downstream
8+
//! `Name::...`, `let x: Name`, and field access then turns red, even though the
9+
//! only thing wrong is an unfinished attribute.
10+
//!
11+
//! To keep rust-analyzer resilient, the error path emits the skeleton produced
12+
//! here *alongside* the real `compile_error!`. The skeleton only needs to
13+
//! type-check; it does not enforce any invariants.
14+
15+
use proc_macro2::TokenStream;
16+
use quote::quote;
17+
use syn::{Data, DeriveInput, Fields};
18+
19+
/// Best-effort, type-checkable skeleton of the newtype.
20+
///
21+
/// Returns `None` when we cannot even recover a single-field tuple struct from
22+
/// the input (in that case the caller emits only the compile error).
23+
pub fn fallback_skeleton(type_definition: &TokenStream) -> Option<TokenStream> {
24+
let input: DeriveInput = syn::parse2(type_definition.clone()).ok()?;
25+
26+
let DeriveInput {
27+
vis,
28+
ident,
29+
generics,
30+
data,
31+
..
32+
} = input;
33+
34+
let data_struct = match data {
35+
Data::Struct(s) => s,
36+
_ => return None,
37+
};
38+
39+
let inner_ty = match data_struct.fields {
40+
Fields::Unnamed(fields) => fields.unnamed.into_iter().next()?.ty,
41+
_ => return None,
42+
};
43+
44+
let (impl_generics, ty_generics, where_clause) = generics.split_for_impl();
45+
46+
Some(quote! {
47+
// NB: the inner field visibility is intentionally dropped. The real
48+
// expansion forbids a visible inner field; the skeleton only needs to
49+
// type-check, not to enforce that invariant.
50+
#vis struct #ident #generics (#inner_ty) #where_clause;
51+
52+
// Stub the inherent methods nutype always generates (the constructors and
53+
// `into_inner`) so that completion and resolution on `Name::...` keep
54+
// working while the attribute is broken. We can't know whether the real
55+
// type will end up validated (`try_new`) or not (`new`), so we provide
56+
// both. `unimplemented!()` diverges and coerces to any return type, the
57+
// argument is taken via `impl Into<Inner>` (every type satisfies the
58+
// reflexive `Inner: Into<Inner>`), and no extra type is introduced, so
59+
// this type-checks for every inner type without polluting the namespace.
60+
#[doc(hidden)]
61+
#[allow(dead_code, unused_variables, clippy::all)]
62+
impl #impl_generics #ident #ty_generics #where_clause {
63+
pub fn new(raw_value: impl ::core::convert::Into<#inner_ty>) -> Self {
64+
::core::unimplemented!()
65+
}
66+
67+
pub fn try_new(
68+
raw_value: impl ::core::convert::Into<#inner_ty>,
69+
) -> ::core::result::Result<Self, ::core::convert::Infallible> {
70+
::core::unimplemented!()
71+
}
72+
73+
pub fn into_inner(self) -> #inner_ty {
74+
::core::unimplemented!()
75+
}
76+
}
77+
})
78+
}
79+
80+
#[cfg(test)]
81+
mod tests {
82+
use super::*;
83+
84+
fn renders(ts: &TokenStream) -> String {
85+
ts.to_string()
86+
}
87+
88+
#[test]
89+
fn skeleton_for_simple_string_newtype() {
90+
let input = quote! { pub struct Foo(String); };
91+
let out = fallback_skeleton(&input).expect("should produce a skeleton");
92+
93+
// The output must be valid Rust.
94+
syn::parse2::<syn::File>(out.clone()).expect("skeleton must parse as a file");
95+
96+
let rendered = renders(&out);
97+
assert!(rendered.contains("struct Foo"));
98+
assert!(rendered.contains("String"));
99+
// All the inherent methods nutype always generates are stubbed so that
100+
// `Foo::new(..)`, `Foo::try_new(..)` and `foo.into_inner()` keep
101+
// resolving while the attribute is broken.
102+
assert!(rendered.contains("fn new"));
103+
assert!(rendered.contains("fn try_new"));
104+
assert!(rendered.contains("fn into_inner"));
105+
}
106+
107+
#[test]
108+
fn skeleton_preserves_generics_and_where_clause() {
109+
let input = quote! { pub struct Wrapper<T: Clone>(T) where T: Default; };
110+
let out = fallback_skeleton(&input).expect("should produce a skeleton");
111+
112+
syn::parse2::<syn::File>(out.clone()).expect("skeleton must parse as a file");
113+
114+
let rendered = renders(&out);
115+
assert!(rendered.contains("struct Wrapper"));
116+
assert!(rendered.contains("Clone"));
117+
assert!(rendered.contains("Default"));
118+
}
119+
120+
#[test]
121+
fn no_skeleton_for_named_struct() {
122+
let input = quote! { struct S { x: u8 } };
123+
assert!(fallback_skeleton(&input).is_none());
124+
}
125+
126+
#[test]
127+
fn no_skeleton_for_enum() {
128+
let input = quote! { enum E { A, B } };
129+
assert!(fallback_skeleton(&input).is_none());
130+
}
131+
132+
#[test]
133+
fn no_skeleton_for_empty_tuple_struct() {
134+
let input = quote! { struct S(); };
135+
assert!(fallback_skeleton(&input).is_none());
136+
}
137+
138+
#[test]
139+
fn no_skeleton_for_unparsable_input() {
140+
let input = quote! { this is not a struct @ # !; };
141+
assert!(fallback_skeleton(&input).is_none());
142+
}
143+
}

nutype_macros/src/common/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
pub mod fallback;
12
pub mod generate;
23
pub mod models;
34
pub mod parse;

nutype_macros/src/lib.rs

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,18 @@ pub fn nutype(
3333
attrs: proc_macro::TokenStream,
3434
type_definition: proc_macro::TokenStream,
3535
) -> proc_macro::TokenStream {
36-
expand_nutype(attrs.into(), type_definition.into())
37-
.unwrap_or_else(|e| syn::Error::to_compile_error(&e))
36+
let type_definition: TokenStream = type_definition.into();
37+
expand_nutype(attrs.into(), type_definition.clone())
38+
.unwrap_or_else(|e| {
39+
let compile_error = e.to_compile_error();
40+
// Emit a best-effort skeleton of the newtype alongside the error so
41+
// rust-analyzer keeps resolving the type while the attribute is
42+
// still being typed. See `common::fallback` for details.
43+
match common::fallback::fallback_skeleton(&type_definition) {
44+
Some(skeleton) => quote::quote! { #skeleton #compile_error },
45+
None => compile_error,
46+
}
47+
})
3848
.into()
3949
}
4050

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
use nutype::nutype;
2+
3+
// The attribute is broken, so expansion fails. Thanks to the fallback skeleton,
4+
// the type `Name`, its constructors and `into_inner` still exist, so the usages
5+
// below do NOT produce spurious "cannot find type/function `Name`" errors --
6+
// only the attribute error surfaces.
7+
#[nutype(validte)]
8+
pub struct Name(String);
9+
10+
fn use_it(n: Name) -> String {
11+
n.into_inner()
12+
}
13+
14+
fn construct() {
15+
// Both constructors are stubbed because we cannot know whether the fixed
16+
// attribute will end up validated (`try_new`) or not (`new`).
17+
let _ = Name::new("hello");
18+
let _ = Name::try_new("hello");
19+
}
20+
21+
fn main() {
22+
let _ = use_it;
23+
let _ = construct;
24+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
error: Unknown nutype attribute `validte`. Did you mean `validate`?
2+
Other available nutype attributes are: `sanitize`, `derive`, `default`, `const_fn`, `cfg_attr`, `constructor`.
3+
--> tests/ui/common/fallback_keeps_type_resolvable.rs:7:10
4+
|
5+
7 | #[nutype(validte)]
6+
| ^^^^^^^

test_suite/tests/ui_decimal_on/const_fn_rejected.stderr

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,3 @@ error: `const_fn` is not supported for `rust_decimal::Decimal`, because Decimal'
99
| |__^
1010
|
1111
= note: this error originates in the attribute macro `nutype` (in Nightly builds, run with -Z macro-backtrace for more info)
12-
13-
warning: unused import: `rust_decimal::Decimal`
14-
--> tests/ui_decimal_on/const_fn_rejected.rs:2:5
15-
|
16-
2 | use rust_decimal::Decimal;
17-
| ^^^^^^^^^^^^^^^^^^^^^
18-
|
19-
= note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default

test_suite/tests/ui_decimal_on/derive_valuable.stderr

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,3 @@ error: #[nutype] cannot derive `Valuable` trait for `rust_decimal::Decimal`, bec
33
|
44
6 | #[nutype(derive(Debug, Valuable))]
55
| ^^^^^^^^
6-
7-
warning: unused import: `rust_decimal::Decimal`
8-
--> tests/ui_decimal_on/derive_valuable.rs:2:5
9-
|
10-
2 | use rust_decimal::Decimal;
11-
| ^^^^^^^^^^^^^^^^^^^^^
12-
|
13-
= note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default

test_suite/tests/ui_decimal_on/invalid_default_literal.stderr

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,3 @@ error: Invalid decimal default value `1e40`: Scale exceeds the maximum precision
33
|
44
8 | default = 1e40,
55
| ^^^^
6-
7-
warning: unused import: `rust_decimal::Decimal`
8-
--> tests/ui_decimal_on/invalid_default_literal.rs:2:5
9-
|
10-
2 | use rust_decimal::Decimal;
11-
| ^^^^^^^^^^^^^^^^^^^^^
12-
|
13-
= note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default

test_suite/tests/ui_decimal_on/unknown_validator.stderr

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,3 @@ error: Unknown validation attribute: `finite`.
44
|
55
7 | validate(finite),
66
| ^^^^^^
7-
8-
warning: unused import: `rust_decimal::Decimal`
9-
--> tests/ui_decimal_on/unknown_validator.rs:2:5
10-
|
11-
2 | use rust_decimal::Decimal;
12-
| ^^^^^^^^^^^^^^^^^^^^^
13-
|
14-
= note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default

0 commit comments

Comments
 (0)