Skip to main content

sui_graphql_macros/
validation.rs

1//! Field path validation against the GraphQL schema and Rust types.
2//!
3//! Validates paths by matching:
4//! - Schema: field existence and list types
5//! - Type structure: `Option`/`Vec` nesting matches `?`/`[]` markers in the path
6//!
7//! Null markers (`?`) in the path must correspond to `Option` wrappers in the type,
8//! and `[]` markers must correspond to `Vec` wrappers.
9
10use std::fmt::Write;
11
12use crate::path::ParsedPath;
13use crate::schema::Schema;
14
15/// Represents the nesting structure of `Option` and `Vec` in a field type.
16#[derive(Debug, Clone, PartialEq)]
17pub enum TypeStructure {
18    /// A type that is neither `Option` nor `Vec` (e.g., `String`, `u64`)
19    Plain,
20    /// `Option<T>` wrapping an inner structure
21    Optional(Box<TypeStructure>),
22    /// `Vec<T>` wrapping an inner structure
23    Vector(Box<TypeStructure>),
24}
25
26/// Analyze a `syn::Type` into a `TypeStructure`.
27///
28/// Note: Type detection uses simple name matching (e.g., `ident == "Option"`),
29/// the same approach used by serde_derive. This works for standard library types
30/// but won't distinguish custom types with the same name.
31/// See: https://github.com/serde-rs/serde/blob/master/serde_derive/src/internals/attr.rs
32pub fn analyze_type(ty: &syn::Type) -> TypeStructure {
33    if let Some(inner) = unwrap_option(ty) {
34        TypeStructure::Optional(Box::new(analyze_type(inner)))
35    } else if let Some(inner) = unwrap_vec(ty) {
36        TypeStructure::Vector(Box::new(analyze_type(inner)))
37    } else {
38        TypeStructure::Plain
39    }
40}
41
42/// Returns the inner type if `ty` is `Option<T>`, otherwise `None`.
43fn unwrap_option(ty: &syn::Type) -> Option<&syn::Type> {
44    unwrap_type(ty, "Option")
45}
46
47/// Returns the inner type if `ty` is `Vec<T>`, otherwise `None`.
48fn unwrap_vec(ty: &syn::Type) -> Option<&syn::Type> {
49    unwrap_type(ty, "Vec")
50}
51
52/// Returns the inner type if `ty` matches `TypeName<T>`, otherwise `None`.
53fn unwrap_type<'a>(ty: &'a syn::Type, type_name: &str) -> Option<&'a syn::Type> {
54    let syn::Type::Path(type_path) = ungroup(ty) else {
55        return None;
56    };
57    let seg = type_path.path.segments.last()?;
58    let syn::PathArguments::AngleBracketed(args) = &seg.arguments else {
59        return None;
60    };
61
62    if seg.ident == type_name
63        && args.args.len() == 1
64        && let syn::GenericArgument::Type(inner) = &args.args[0]
65    {
66        return Some(inner);
67    }
68    None
69}
70
71/// Unwrap `syn::Type::Group` nodes which may appear in macro-generated code.
72///
73/// When a macro captures a type with `$t:ty` and substitutes it, the type may be
74/// wrapped in an invisible `Group` node. For example, `Option<String>` might appear
75/// as `Group(Path("Option<String>"))` instead of just `Path("Option<String>")`.
76///
77/// Credit: serde_derive (https://github.com/serde-rs/serde)
78fn ungroup(mut ty: &syn::Type) -> &syn::Type {
79    while let syn::Type::Group(group) = ty {
80        ty = &group.elem;
81    }
82    ty
83}
84
85/// Count the number of `Vec` wrappers in a type structure.
86pub fn count_vec_depth(ts: &TypeStructure) -> usize {
87    match ts {
88        TypeStructure::Plain => 0,
89        TypeStructure::Optional(inner) => count_vec_depth(inner),
90        TypeStructure::Vector(inner) => 1 + count_vec_depth(inner),
91    }
92}
93
94/// Scalars whose values can be objects or arrays in JSON.
95///
96/// These scalars represent structured data (not simple strings or numbers), so the macro
97/// cannot validate Vec count — the Rust type is the user's responsibility.
98const OBJECT_LIKE_SCALARS: &[&str] = &[
99    "JSON",
100    "MoveTypeLayout",
101    "MoveTypeSignature",
102    "OpenMoveTypeSignature",
103];
104
105/// Validate a parsed field path against the schema.
106///
107/// Checks that all fields in the path exist in the schema and that `[]` markers
108/// match the schema's list types. List fields must always use `[]` explicitly.
109///
110/// Returns the terminal type name (the type of the last field in the path).
111pub fn validate_path_against_schema<'a>(
112    schema: &'a Schema,
113    root_type: &'a str,
114    path: &ParsedPath,
115    span: proc_macro2::Span,
116) -> Result<&'a str, syn::Error> {
117    let mut current_type: &str = root_type;
118
119    for segment in &path.segments {
120        let field = schema
121            .field(current_type, segment.field)
122            .ok_or_else(|| field_not_found_error(schema, current_type, segment.field, span))?;
123
124        if segment.is_list() && !field.is_list {
125            return Err(syn::Error::new(
126                span,
127                format!(
128                    "Cannot use '[]' on non-list field '{}' (type '{}')",
129                    segment.field, field.type_name
130                ),
131            ));
132        }
133
134        if !segment.is_list() && field.is_list {
135            return Err(syn::Error::new(
136                span,
137                format!(
138                    "Field '{}' is a list type, use '{}[]' to iterate over it",
139                    segment.field, segment.field
140                ),
141            ));
142        }
143
144        current_type = &field.type_name;
145    }
146
147    Ok(current_type)
148}
149
150/// Validate that a type name is a member of a union.
151pub fn validate_union_member(
152    schema: &Schema,
153    union_type: &str,
154    member_name: &str,
155    span: proc_macro2::Span,
156) -> Result<(), syn::Error> {
157    let union_types = schema.union_types(union_type);
158    if !union_types.contains(&member_name) {
159        let suggestion = find_similar(&union_types, member_name);
160
161        let mut msg = format!(
162            "'{}' is not a member of union '{}'. Members: {}",
163            member_name,
164            union_type,
165            union_types.join(", ")
166        );
167        if let Some(s) = suggestion {
168            msg.push_str(&format!(". Did you mean '{}'?", s));
169        }
170        return Err(syn::Error::new(span, msg));
171    }
172    Ok(())
173}
174
175/// Returns true if the type name is a scalar that can represent objects or arrays.
176pub fn is_object_like_scalar(type_name: &str) -> bool {
177    OBJECT_LIKE_SCALARS.contains(&type_name)
178}
179
180/// Validate that the Rust type structure matches the `?` and `[]` markers in the path.
181///
182/// Walks segments and checks `TypeStructure` layers match:
183/// - `?` markers on segments before the next `[]` → one `Optional` wrapper
184/// - `[]` marker → one `Vector` wrapper
185/// - `[]?` (elements_nullable) → one `Optional` wrapper on the element type
186///
187/// Multiple `?` markers between `[]` boundaries share one `Option` wrapper.
188///
189/// When `skip_vec_excess_check` is true, extra Vec wrappers beyond the list count are
190/// allowed. This is used for object-like scalars (e.g., JSON) whose values can be arrays.
191pub fn validate_type_matches_path(
192    path: &ParsedPath<'_>,
193    ty: &syn::Type,
194    skip_vec_excess_check: bool,
195) -> Result<(), syn::Error> {
196    let analyzed = analyze_type(ty);
197    let mut type_structure = &analyzed;
198
199    let mut peeled_optional_in_group = false;
200
201    for segment in &path.segments {
202        // If segment has ?, peel Optional (first time in this group)
203        if segment.is_nullable && !peeled_optional_in_group {
204            let TypeStructure::Optional(inner) = type_structure else {
205                return Err(syn::Error::new_spanned(
206                    ty,
207                    format!(
208                        "'{}' is marked nullable with '?' but type is not wrapped in Option<...>",
209                        segment.field
210                    ),
211                ));
212            };
213            type_structure = inner.as_ref();
214            peeled_optional_in_group = true;
215        }
216
217        // If segment is a list, peel Vector (and handle []?)
218        if let Some(list) = &segment.list {
219            // Catch un-peeled Optional before peeling Vector
220            if !peeled_optional_in_group && matches!(type_structure, TypeStructure::Optional(_)) {
221                return Err(syn::Error::new_spanned(
222                    ty,
223                    format!(
224                        "type is Option but no segment before '{}[]' has a '?' marker; \
225                         add '?' to mark which segment is nullable",
226                        segment.field
227                    ),
228                ));
229            }
230
231            // Peel Vector
232            let TypeStructure::Vector(element_type) = type_structure else {
233                return Err(syn::Error::new_spanned(
234                    ty,
235                    format!(
236                        "field '{}' is a list but type has no Vec wrapper for it",
237                        segment.field
238                    ),
239                ));
240            };
241            type_structure = element_type.as_ref();
242
243            // Handle []? — peel Optional for element type
244            if list.elements_nullable {
245                let TypeStructure::Optional(inner) = type_structure else {
246                    return Err(syn::Error::new_spanned(
247                        ty,
248                        format!(
249                            "'{}' has '[]?' but element type is not wrapped in Option<...>",
250                            segment.field
251                        ),
252                    ));
253                };
254                type_structure = inner.as_ref();
255            }
256
257            // New group starts after [].
258            // If elements_nullable, keep peeled=true so subsequent `?` can coexist.
259            peeled_optional_in_group = list.elements_nullable;
260        }
261    }
262
263    // Final boundary: check for un-peeled Optional
264    if !peeled_optional_in_group && matches!(type_structure, TypeStructure::Optional(_)) {
265        let last_field = path.segments.last().map(|s| s.field).unwrap_or(path.raw);
266        return Err(syn::Error::new_spanned(
267            ty,
268            format!(
269                "type is Option but no '?' found at or before '{}'; \
270                 add '?' to mark which segments are nullable",
271                last_field
272            ),
273        ));
274    }
275
276    // Check for excess Vec wrappers
277    if !skip_vec_excess_check && count_vec_depth(type_structure) > 0 {
278        return Err(syn::Error::new_spanned(
279            ty,
280            format!(
281                "type has {} excess Vec wrapper(s) but path '{}' has no matching list field(s)",
282                count_vec_depth(type_structure),
283                path.raw
284            ),
285        ));
286    }
287
288    Ok(())
289}
290
291/// Generate an error for a field not found, with "Did you mean?" suggestion.
292fn field_not_found_error(
293    schema: &Schema,
294    type_name: &str,
295    field_name: &str,
296    span: proc_macro2::Span,
297) -> syn::Error {
298    let available = schema.field_names(type_name);
299    let suggestion = find_similar(&available, field_name);
300
301    let mut msg = format!("Field '{field_name}' not found on type '{type_name}'");
302
303    if let Some(suggested) = suggestion {
304        write!(msg, ". Did you mean '{suggested}'?").unwrap();
305    } else if !available.is_empty() {
306        // Sort for deterministic error messages (HashMap iteration order is random)
307        let mut fields: Vec<_> = available;
308        fields.sort();
309        write!(msg, ". Available fields: {}", fields.join(", ")).unwrap();
310    }
311
312    syn::Error::new(span, msg)
313}
314
315/// Find a similar string using Levenshtein distance.
316pub fn find_similar<'a>(candidates: &[&'a str], target: &str) -> Option<&'a str> {
317    candidates
318        .iter()
319        .filter_map(|&candidate| {
320            let distance = edit_distance::edit_distance(candidate, target);
321            // Suggest if within 3 edits
322            if distance <= 3 {
323                Some((candidate, distance))
324            } else {
325                None
326            }
327        })
328        .min_by_key(|(_, d)| *d)
329        .map(|(c, _)| c)
330}
331
332#[cfg(test)]
333mod tests {
334    use super::*;
335
336    #[test]
337    fn test_find_similar() {
338        let candidates = vec!["address", "version", "digest", "owner"];
339        assert_eq!(find_similar(&candidates, "addrss"), Some("address"));
340        assert_eq!(find_similar(&candidates, "vesion"), Some("version"));
341        assert_eq!(find_similar(&candidates, "xyz"), None);
342    }
343}