diff --git a/CHANGELOG.md b/CHANGELOG.md index f7635cd1..71a93de7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +### v0.7.1 - Unreleased +- **[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)). + ### v0.7.0 - 2026-04-25 - **[BREAKING]** Rename `derive_unsafe` to `derive_unchecked` (both the feature flag and the attribute). - **[FEATURE]** Support `cfg_attr` for conditional derives, e.g. `cfg_attr(feature = "serde", derive(Serialize, Deserialize))`. Supports complex predicates and multiple entries. diff --git a/_typos.toml b/_typos.toml new file mode 100644 index 00000000..a47edbb3 --- /dev/null +++ b/_typos.toml @@ -0,0 +1,4 @@ +[default.extend-words] +# Intentional misspelling used in tests/docs that demonstrate the +# "Did you mean ...?" suggestion for mistyped nutype attributes. +validte = "validte" diff --git a/nutype_macros/src/common/parse/mod.rs b/nutype_macros/src/common/parse/mod.rs index 870009dc..b7263198 100644 --- a/nutype_macros/src/common/parse/mod.rs +++ b/nutype_macros/src/common/parse/mod.rs @@ -381,8 +381,10 @@ where return Err(syn::Error::new(ident.span(), msg)); } } else { - let msg = format!("Unknown attribute `{ident}`"); - return Err(syn::Error::new(ident.span(), msg)); + return Err(syn::Error::new( + ident.span(), + unknown_top_level_attribute_message(&ident.to_string()), + )); } // Parse `,` unless it's the end of the stream @@ -395,6 +397,54 @@ where } } +fn known_top_level_attributes() -> Vec<&'static str> { + let mut names: Vec<&'static str> = vec![ + "sanitize", + "validate", + "derive", + "default", + "const_fn", + "cfg_attr", + "constructor", + ]; + if cfg!(feature = "new_unchecked") { + names.push("new_unchecked"); + } + if cfg!(feature = "derive_unchecked") { + names.push("derive_unchecked"); + } + names +} + +fn unknown_top_level_attribute_message(ident: &str) -> String { + use crate::utils::levenshtein::closest_match; + + let known = known_top_level_attributes(); + let suggestion = closest_match(ident, &known, 2); + + let format_list = |names: &[&str]| -> String { + names + .iter() + .map(|n| format!("`{n}`")) + .collect::>() + .join(", ") + }; + + match suggestion { + Some(suggested) => { + let others: Vec<&str> = known.iter().copied().filter(|n| *n != suggested).collect(); + format!( + "Unknown nutype attribute `{ident}`. Did you mean `{suggested}`?\nOther available nutype attributes are: {}.", + format_list(&others) + ) + } + None => format!( + "Unknown nutype attribute `{ident}`.\nAvailable nutype attributes are: {}.", + format_list(&known) + ), + } +} + pub fn parse_number(input: ParseStream) -> syn::Result<(T, Span)> where T: FromStr, diff --git a/nutype_macros/src/utils/levenshtein.rs b/nutype_macros/src/utils/levenshtein.rs new file mode 100644 index 00000000..237b1edf --- /dev/null +++ b/nutype_macros/src/utils/levenshtein.rs @@ -0,0 +1,68 @@ +/// Compute the Levenshtein edit distance between two strings. +/// +/// Used to suggest a likely-intended attribute name when the user mistypes one, +/// e.g. `validte` -> `validate`. +pub fn levenshtein(a: &str, b: &str) -> usize { + let a: Vec = a.chars().collect(); + let b: Vec = b.chars().collect(); + let m = a.len(); + let n = b.len(); + if m == 0 { + return n; + } + if n == 0 { + return m; + } + + let mut prev: Vec = (0..=n).collect(); + let mut curr: Vec = vec![0; n + 1]; + for i in 1..=m { + curr[0] = i; + for j in 1..=n { + let cost = if a[i - 1] == b[j - 1] { 0 } else { 1 }; + curr[j] = (curr[j - 1] + 1).min(prev[j] + 1).min(prev[j - 1] + cost); + } + core::mem::swap(&mut prev, &mut curr); + } + prev[n] +} + +/// Return the candidate closest to `query` if its edit distance is `<= max_distance`. +/// Ties are broken by the order in `candidates`. +pub fn closest_match<'a>( + query: &str, + candidates: &[&'a str], + max_distance: usize, +) -> Option<&'a str> { + let mut best: Option<(usize, &'a str)> = None; + for cand in candidates { + let d = levenshtein(query, cand); + if d <= max_distance && best.map(|(bd, _)| d < bd).unwrap_or(true) { + best = Some((d, cand)); + } + } + best.map(|(_, s)| s) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn distance_basics() { + assert_eq!(levenshtein("", ""), 0); + assert_eq!(levenshtein("abc", ""), 3); + assert_eq!(levenshtein("", "abc"), 3); + assert_eq!(levenshtein("abc", "abc"), 0); + assert_eq!(levenshtein("validte", "validate"), 1); + assert_eq!(levenshtein("kitten", "sitting"), 3); + } + + #[test] + fn picks_closest() { + let candidates = ["sanitize", "validate", "derive", "default"]; + assert_eq!(closest_match("validte", &candidates, 2), Some("validate")); + assert_eq!(closest_match("derve", &candidates, 2), Some("derive")); + assert_eq!(closest_match("xyz", &candidates, 2), None); + } +} diff --git a/nutype_macros/src/utils/mod.rs b/nutype_macros/src/utils/mod.rs index d0616b4c..fecb05b6 100644 --- a/nutype_macros/src/utils/mod.rs +++ b/nutype_macros/src/utils/mod.rs @@ -1 +1,2 @@ pub mod issue_reporter; +pub mod levenshtein; diff --git a/test_suite/tests/ui/common/custom_validaiton_no_with.rs b/test_suite/tests/ui/common/custom_validation_no_with.rs similarity index 100% rename from test_suite/tests/ui/common/custom_validaiton_no_with.rs rename to test_suite/tests/ui/common/custom_validation_no_with.rs diff --git a/test_suite/tests/ui/common/custom_validaiton_no_with.stderr b/test_suite/tests/ui/common/custom_validation_no_with.stderr similarity index 80% rename from test_suite/tests/ui/common/custom_validaiton_no_with.stderr rename to test_suite/tests/ui/common/custom_validation_no_with.stderr index b4ddb6b6..abc6cfcb 100644 --- a/test_suite/tests/ui/common/custom_validaiton_no_with.stderr +++ b/test_suite/tests/ui/common/custom_validation_no_with.stderr @@ -1,6 +1,6 @@ error: The `error` attribute requires an accompanying `with` attribute. Please provide the validation function that returns Result<(), NumError>. - --> tests/ui/common/custom_validaiton_no_with.rs:4:30 + --> tests/ui/common/custom_validation_no_with.rs:4:30 | 4 | validate(error = NumError) | ^ diff --git a/test_suite/tests/ui/common/unknown_top_level_attribute.rs b/test_suite/tests/ui/common/unknown_top_level_attribute.rs new file mode 100644 index 00000000..0b397cc8 --- /dev/null +++ b/test_suite/tests/ui/common/unknown_top_level_attribute.rs @@ -0,0 +1,6 @@ +use nutype::nutype; + +#[nutype(validte)] +pub struct MaxPositionPercentage(i32); + +fn main() {} diff --git a/test_suite/tests/ui/common/unknown_top_level_attribute.stderr b/test_suite/tests/ui/common/unknown_top_level_attribute.stderr new file mode 100644 index 00000000..db7a59a1 --- /dev/null +++ b/test_suite/tests/ui/common/unknown_top_level_attribute.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/unknown_top_level_attribute.rs:3:10 + | +3 | #[nutype(validte)] + | ^^^^^^^