pub(super) fn quoted_value_after_key(tokens: &str, key: &str) -> Option<String> {
for (start, _) in tokens.match_indices(key) {
if key == "rename" && tokens[start..].starts_with("rename_all") {
continue;
}
if !is_standalone_word_at(tokens, start, key) && !is_qualified_key(tokens, start) {
continue;
}
let remaining = &tokens[start + key.len()..];
let Some(equals_pos) = remaining.find('=') else {
continue;
};
let value_part = remaining[equals_pos + 1..].trim();
let Some(quote_start) = value_part.find('"') else {
continue;
};
let after_quote = &value_part[quote_start + 1..];
let Some(quote_end) = after_quote.find('"') else {
continue;
};
return Some(after_quote[..quote_end].to_string());
}
None
}
pub(super) fn contains_standalone_word(tokens: &str, word: &str) -> bool {
tokens.match_indices(word).any(|(start, _)| {
is_standalone_word_at(tokens, start, word) || is_qualified_key(tokens, start)
})
}
fn is_qualified_key(tokens: &str, start: usize) -> bool {
start >= 2 && &tokens[start - 2..start] == "::"
}
fn is_standalone_word_at(tokens: &str, start: usize, word: &str) -> bool {
let before = if start > 0 { &tokens[..start] } else { "" };
let after = &tokens[start + word.len()..];
let before_char = before.chars().last().unwrap_or(' ');
let after_char = after.chars().next().unwrap_or(' ');
let before_ok = before_char == ' ' || before_char == ',' || before_char == '(';
let after_ok = after_char == ' ' || after_char == ',' || after_char == ')' || after_char == '=';
before_ok && after_ok
}
#[allow(clippy::option_option)]
pub(super) fn scan_default_from_raw_tokens(tokens: &str) -> Option<Option<String>> {
let start = tokens.find("default")?;
let remaining = &tokens[start + "default".len()..];
if remaining.trim_start().starts_with('=') {
let after_equals = remaining
.trim_start()
.strip_prefix('=')
.unwrap_or("")
.trim_start();
let quote_start = after_equals.find('"')?;
let after_quote = &after_equals[quote_start + 1..];
let quote_end = after_quote.find('"')?;
Some(Some(after_quote[..quote_end].to_string()))
} else if is_standalone_word_at(tokens, start, "default") {
Some(None)
} else {
None
}
}
#[cfg(test)]
mod tests {
use crate::parser::schema::serde_attrs::*;
use proc_macro2::{Span, TokenStream};
use quote::quote;
use rstest::rstest;
fn get_struct_attrs(serde_content: &str) -> Vec<syn::Attribute> {
let src = format!(r"#[serde({serde_content})] struct Foo;");
let item: syn::ItemStruct = syn::parse_str(&src).unwrap();
item.attrs
}
fn get_field_attrs(serde_content: &str) -> Vec<syn::Attribute> {
let src = format!(r"struct Foo {{ #[serde({serde_content})] field: i32 }}");
let item: syn::ItemStruct = syn::parse_str(&src).unwrap();
if let syn::Fields::Named(fields) = &item.fields {
fields.named.first().unwrap().attrs.clone()
} else {
vec![]
}
}
fn create_attr_with_raw_tokens(tokens: TokenStream) -> syn::Attribute {
syn::Attribute {
pound_token: syn::token::Pound::default(),
style: syn::AttrStyle::Outer,
bracket_token: syn::token::Bracket::default(),
meta: syn::Meta::List(syn::MetaList {
path: syn::Path::from(syn::Ident::new("serde", Span::call_site())),
delimiter: syn::MacroDelimiter::Paren(syn::token::Paren::default()),
tokens,
}),
}
}
#[test]
fn test_extract_rename_all_fallback_path() {
let attrs = get_struct_attrs(r#"rename_all = "camelCase""#);
let result = extract_rename_all(&attrs);
assert_eq!(result.as_deref(), Some("camelCase"));
}
#[test]
fn test_extract_field_rename_fallback_path() {
let attrs = get_field_attrs(r#"rename = "myField""#);
let result = extract_field_rename(&attrs);
assert_eq!(result.as_deref(), Some("myField"));
}
#[test]
fn test_extract_default_standalone_fallback_path() {
let attrs = get_field_attrs(r"default");
let result = extract_default(&attrs);
assert_eq!(result, Some(None));
}
#[test]
fn test_extract_default_standalone_fallback_when_nested_meta_fails() {
let tokens: TokenStream = "@bogus, default".parse().expect("token stream parses");
let attr = create_attr_with_raw_tokens(tokens);
let result = extract_default(&[attr]);
assert_eq!(
result,
Some(None),
"fallback path must detect bare `default`"
);
}
#[test]
fn test_extract_default_substring_in_identifier_is_not_a_match() {
let tokens: TokenStream = "@bogus, field_default"
.parse()
.expect("token stream parses");
let attr = create_attr_with_raw_tokens(tokens);
let result = extract_default(&[attr]);
assert_eq!(
result, None,
"embedded 'default' substring must not register as default"
);
}
#[test]
fn test_extract_default_with_function_fallback_path() {
let attrs = get_field_attrs(r#"default = "my_default_fn""#);
let result = extract_default(&attrs);
assert_eq!(result, Some(Some("my_default_fn".to_string())));
}
#[test]
fn test_extract_field_rename_avoids_rename_all() {
let attrs = get_field_attrs(r#"rename_all = "camelCase""#);
let result = extract_field_rename(&attrs);
assert_eq!(result, None); }
#[test]
fn test_extract_functions_with_empty_serde() {
let item: syn::ItemStruct = syn::parse_str(r"#[serde()] struct Foo;").unwrap();
assert_eq!(extract_rename_all(&item.attrs), None);
}
#[test]
fn test_extract_functions_ignore_non_serde() {
let item: syn::ItemStruct = syn::parse_str(r"#[derive(Debug)] struct Foo;").unwrap();
assert_eq!(extract_rename_all(&item.attrs), None);
assert_eq!(extract_field_rename(&item.attrs), None);
}
#[test]
fn test_extract_rename_all_non_list_serde() {
let item: syn::ItemStruct = syn::parse_str(r"#[serde] struct Foo;").unwrap();
let result = extract_rename_all(&item.attrs);
assert_eq!(result, None);
}
#[test]
fn test_extract_field_rename_complex_attr() {
let attrs = get_field_attrs(
r#"default, rename = "field_name", skip_serializing_if = "Option::is_none""#,
);
let result = extract_field_rename(&attrs);
assert_eq!(result.as_deref(), Some("field_name"));
}
#[test]
fn test_extract_rename_all_multiple_serde_attrs() {
let item: syn::ItemStruct = syn::parse_str(
r#"
#[serde(default)]
#[serde(rename_all = "snake_case")]
struct Foo;
"#,
)
.unwrap();
let result = extract_rename_all(&item.attrs);
assert_eq!(result.as_deref(), Some("snake_case"));
}
#[test]
fn test_extract_rename_all_with_whitespace() {
let attrs = get_struct_attrs(r#"rename_all = "PascalCase""#);
let result = extract_rename_all(&attrs);
assert_eq!(result.as_deref(), Some("PascalCase"));
}
#[test]
fn test_extract_field_rename_at_end() {
let attrs = get_field_attrs(r#"skip_serializing_if = "is_none", rename = "lastField""#);
let result = extract_field_rename(&attrs);
assert_eq!(result.as_deref(), Some("lastField"));
}
#[test]
fn test_extract_default_among_other_attrs() {
let attrs =
get_field_attrs(r#"skip_serializing_if = "is_none", default, rename = "field""#);
let result = extract_default(&attrs);
assert_eq!(result, Some(None));
}
#[test]
fn test_extract_skip_basic() {
let attrs = get_field_attrs(r"skip");
let result = extract_skip(&attrs);
assert!(result);
}
#[test]
fn test_extract_skip_not_skip_serializing_if() {
let attrs = get_field_attrs(r#"skip_serializing_if = "Option::is_none""#);
let result = extract_skip(&attrs);
assert!(!result);
}
#[test]
fn test_extract_skip_not_skip_deserializing() {
let attrs = get_field_attrs(r"skip_deserializing");
let result = extract_skip(&attrs);
assert!(!result);
}
#[test]
fn test_extract_skip_with_other_attrs() {
let attrs = get_field_attrs(r"skip, default");
let result = extract_skip(&attrs);
assert!(result);
}
#[test]
fn test_extract_default_with_path() {
let attrs = get_field_attrs(r#"default = "Default::default""#);
let result = extract_default(&attrs);
assert_eq!(result, Some(Some("Default::default".to_string())));
}
#[rstest]
#[case("camelCase")]
#[case("snake_case")]
#[case("kebab-case")]
#[case("PascalCase")]
#[case("lowercase")]
#[case("UPPERCASE")]
#[case("SCREAMING_SNAKE_CASE")]
#[case("SCREAMING-KEBAB-CASE")]
fn test_extract_rename_all_all_formats(#[case] format: &str) {
let attrs = get_struct_attrs(&format!(r#"rename_all = "{format}""#));
let result = extract_rename_all(&attrs);
assert_eq!(result.as_deref(), Some(format));
}
#[test]
fn test_mixed_attributes() {
let item: syn::ItemStruct = syn::parse_str(
r#"
#[derive(Debug, Clone)]
#[serde(rename_all = "camelCase")]
#[doc = "Some documentation"]
struct Foo;
"#,
)
.unwrap();
let result = extract_rename_all(&item.attrs);
assert_eq!(result.as_deref(), Some("camelCase"));
}
#[test]
fn test_field_multiple_serde_attrs() {
let item: syn::ItemStruct = syn::parse_str(
r#"
struct Foo {
#[serde(default)]
#[serde(rename = "customName")]
field: i32
}
"#,
)
.unwrap();
if let syn::Fields::Named(fields) = &item.fields {
let attrs = &fields.named.first().unwrap().attrs;
let rename = extract_field_rename(attrs);
let default = extract_default(attrs);
assert_eq!(rename.as_deref(), Some("customName"));
assert_eq!(default, Some(None));
}
}
#[test]
fn test_extract_rename_all_programmatic() {
let tokens = quote!(rename_all = "camelCase");
let attr = create_attr_with_raw_tokens(tokens);
let result = extract_rename_all(&[attr]);
assert_eq!(result.as_deref(), Some("camelCase"));
}
#[test]
fn test_extract_rename_all_invalid_value() {
let tokens = quote!(rename_all = camelCase);
let attr = create_attr_with_raw_tokens(tokens);
let result = extract_rename_all(&[attr]);
assert!(result.is_none());
}
#[test]
fn test_extract_rename_all_no_equals() {
let tokens = quote!(rename_all "camelCase");
let attr = create_attr_with_raw_tokens(tokens);
let result = extract_rename_all(&[attr]);
assert!(result.is_none());
}
#[test]
fn test_extract_field_rename_programmatic() {
let tokens = quote!(rename = "customField");
let attr = create_attr_with_raw_tokens(tokens);
let result = extract_field_rename(&[attr]);
assert_eq!(result.as_deref(), Some("customField"));
}
#[test]
fn test_extract_default_programmatic() {
let tokens = quote!(default);
let attr = create_attr_with_raw_tokens(tokens);
let result = extract_default(&[attr]);
assert_eq!(result, Some(None));
}
#[test]
fn test_extract_default_with_fn_programmatic() {
let tokens = quote!(default = "my_fn");
let attr = create_attr_with_raw_tokens(tokens);
let result = extract_default(&[attr]);
assert_eq!(result, Some(Some("my_fn".to_string())));
}
#[test]
fn test_extract_skip_programmatic() {
let tokens = quote!(skip);
let attr = create_attr_with_raw_tokens(tokens);
let result = extract_skip(&[attr]);
assert!(result);
}
#[test]
fn test_rename_all_not_rename() {
let tokens = quote!(rename_all = "camelCase");
let attr = create_attr_with_raw_tokens(tokens);
let result = extract_field_rename(&[attr]);
assert_eq!(result, None);
}
#[test]
fn test_multiple_items_programmatic() {
let tokens = quote!(default, rename = "myField", skip_serializing_if = "is_none");
let attr = create_attr_with_raw_tokens(tokens);
let rename_result = extract_field_rename(std::slice::from_ref(&attr));
let default_result = extract_default(std::slice::from_ref(&attr));
assert_eq!(rename_result.as_deref(), Some("myField"));
assert_eq!(default_result, Some(None));
}
#[test]
fn test_extract_rename_all_fallback_manual_parsing() {
let tokens = quote!(rename_all = "kebab-case");
let attr = create_attr_with_raw_tokens(tokens);
let result = extract_rename_all(&[attr]);
assert_eq!(result.as_deref(), Some("kebab-case"));
}
#[test]
fn test_extract_rename_all_complex_attribute_fallback() {
let tokens = quote!(default, rename_all = "SCREAMING_SNAKE_CASE", skip);
let attr = create_attr_with_raw_tokens(tokens);
let result = extract_rename_all(&[attr]);
assert_eq!(result.as_deref(), Some("SCREAMING_SNAKE_CASE"));
}
#[test]
fn test_extract_rename_all_no_quote_start() {
let tokens = quote!(rename_all = snake_case);
let attr = create_attr_with_raw_tokens(tokens);
let result = extract_rename_all(&[attr]);
assert!(result.is_none());
}
#[test]
fn test_extract_rename_all_unclosed_quote() {
let tokens = quote!(rename_all = "camelCase");
let attr = create_attr_with_raw_tokens(tokens);
let result = extract_rename_all(&[attr]);
assert_eq!(result.as_deref(), Some("camelCase"));
}
#[test]
fn test_extract_rename_all_empty_string() {
let tokens = quote!(rename_all = "");
let attr = create_attr_with_raw_tokens(tokens);
let result = extract_rename_all(&[attr]);
assert_eq!(result.as_deref(), Some(""));
}
#[test]
fn test_extract_rename_all_qualified_path_forces_fallback() {
let tokens = quote!(serde_with::rename_all = "camelCase");
let attr = create_attr_with_raw_tokens(tokens);
let result = extract_rename_all(&[attr]);
assert_eq!(result.as_deref(), Some("camelCase"));
}
#[test]
fn test_extract_rename_all_module_qualified_forces_fallback() {
let tokens = quote!(my_module::rename_all = "snake_case");
let attr = create_attr_with_raw_tokens(tokens);
let result = extract_rename_all(&[attr]);
assert_eq!(result.as_deref(), Some("snake_case"));
}
#[test]
fn test_extract_rename_all_deeply_qualified_forces_fallback() {
let tokens = quote!(a::b::rename_all = "PascalCase");
let attr = create_attr_with_raw_tokens(tokens);
let result = extract_rename_all(&[attr]);
assert_eq!(result.as_deref(), Some("PascalCase"));
}
#[test]
fn test_extract_rename_all_raw_tokens_force_fallback() {
let tokens: TokenStream = "__rename_all_prefix::rename_all = \"lowercase\""
.parse()
.unwrap();
let attr = create_attr_with_raw_tokens(tokens);
if let syn::Meta::List(list) = &attr.meta {
let token_str = list.tokens.to_string();
assert!(
token_str.contains("rename_all"),
"Token string should contain rename_all: {token_str}"
);
}
let result = extract_rename_all(&[attr]);
assert_eq!(
result.as_deref(),
Some("lowercase"),
"Fallback parsing must extract the value"
);
}
#[test]
fn test_extract_rename_all_crate_qualified_forces_fallback() {
let tokens: TokenStream = "crate::rename_all = \"UPPERCASE\"".parse().unwrap();
let attr = create_attr_with_raw_tokens(tokens);
let result = extract_rename_all(&[attr]);
assert_eq!(result.as_deref(), Some("UPPERCASE"));
}
#[test]
fn test_extract_rename_all_self_qualified_forces_fallback() {
let tokens: TokenStream = "self::rename_all = \"kebab-case\"".parse().unwrap();
let attr = create_attr_with_raw_tokens(tokens);
let result = extract_rename_all(&[attr]);
assert_eq!(result.as_deref(), Some("kebab-case"));
}
#[test]
fn test_extract_field_rename_fallback_word_boundary() {
let tokens: TokenStream = "my_module::rename = \"value\"".parse().unwrap();
let attr = create_attr_with_raw_tokens(tokens);
let result = extract_field_rename(&[attr]);
assert_eq!(result.as_deref(), Some("value"));
}
#[test]
fn test_extract_field_rename_fallback_complex_attr() {
let tokens: TokenStream = "crate::other::rename = \"custom_field\", default"
.parse()
.unwrap();
let attr = create_attr_with_raw_tokens(tokens);
let result = extract_field_rename(&[attr]);
assert_eq!(result.as_deref(), Some("custom_field"));
}
#[test]
fn test_extract_field_rename_fallback_avoids_rename_all() {
let tokens: TokenStream = "some::rename_all = \"camelCase\"".parse().unwrap();
let attr = create_attr_with_raw_tokens(tokens);
let result = extract_field_rename(&[attr]);
assert_eq!(result, None);
}
#[test]
fn test_extract_flatten_fallback_path() {
let tokens: TokenStream = "my_module::flatten".parse().unwrap();
let attr = create_attr_with_raw_tokens(tokens);
let result = extract_flatten(&[attr]);
assert!(result, "Fallback should find 'flatten' in token string");
}
#[test]
fn test_extract_flatten_fallback_complex() {
let tokens: TokenStream = "crate::flatten, default = \"my_fn\"".parse().unwrap();
let attr = create_attr_with_raw_tokens(tokens);
let result = extract_flatten(&[attr]);
assert!(result, "Fallback should detect flatten with other attrs");
}
#[test]
fn test_extract_flatten_fallback_at_end() {
let tokens: TokenStream = "default, some::flatten".parse().unwrap();
let attr = create_attr_with_raw_tokens(tokens);
let result = extract_flatten(&[attr]);
assert!(result);
}
#[test]
fn test_extract_flatten_fallback_no_partial_match() {
let tokens: TokenStream = "flattened".parse().unwrap();
let attr = create_attr_with_raw_tokens(tokens);
let result = extract_flatten(&[attr]);
assert!(!result, "Should not match 'flattened' as 'flatten'");
}
#[test]
fn test_extract_field_rename_form_data_fallback() {
let struct_src = r#"struct Foo { #[form_data(field_name = "my_file")] field: i32 }"#;
let item: syn::ItemStruct = syn::parse_str(struct_src).unwrap();
if let syn::Fields::Named(fields) = &item.fields {
let field = fields.named.first().unwrap();
let result = extract_field_rename(&field.attrs);
assert_eq!(result.as_deref(), Some("my_file"));
}
}
#[test]
fn test_extract_field_rename_serde_over_form_data() {
let struct_src = r#"struct Foo { #[serde(rename = "serde_name")] #[form_data(field_name = "form_name")] field: i32 }"#;
let item: syn::ItemStruct = syn::parse_str(struct_src).unwrap();
if let syn::Fields::Named(fields) = &item.fields {
let field = fields.named.first().unwrap();
let result = extract_field_rename(&field.attrs);
assert_eq!(result.as_deref(), Some("serde_name"));
}
}
#[test]
fn test_extract_field_rename_form_data_no_field_name() {
let struct_src = r#"struct Foo { #[form_data(limit = "10MiB")] field: i32 }"#;
let item: syn::ItemStruct = syn::parse_str(struct_src).unwrap();
if let syn::Fields::Named(fields) = &item.fields {
let field = fields.named.first().unwrap();
let result = extract_field_rename(&field.attrs);
assert_eq!(result, None);
}
}
#[test]
fn test_extract_rename_all_try_from_multipart_fallback() {
let item: syn::ItemStruct =
syn::parse_str(r#"#[try_from_multipart(rename_all = "camelCase")] struct Foo;"#)
.unwrap();
let result = extract_rename_all(&item.attrs);
assert_eq!(result.as_deref(), Some("camelCase"));
}
#[test]
fn test_extract_rename_all_serde_over_try_from_multipart() {
let item: syn::ItemStruct = syn::parse_str(r#"#[serde(rename_all = "snake_case")] #[try_from_multipart(rename_all = "camelCase")] struct Foo;"#).unwrap();
let result = extract_rename_all(&item.attrs);
assert_eq!(result.as_deref(), Some("snake_case"));
}
#[test]
fn test_extract_rename_all_try_from_multipart_no_rename_all() {
let item: syn::ItemStruct =
syn::parse_str(r"#[try_from_multipart(strict)] struct Foo;").unwrap();
let result = extract_rename_all(&item.attrs);
assert_eq!(result, None);
}
#[rstest]
#[case::embedded_key("some_rename = \"x\"", "rename")]
#[case::missing_quote("rename = value", "rename")]
#[case::missing_end_quote("rename = \"value", "rename")]
fn quoted_value_after_key_rejects_malformed_candidates(
#[case] tokens: &str,
#[case] key: &str,
) {
assert_eq!(super::quoted_value_after_key(tokens, key), None);
}
#[rstest]
#[case("default", "default", true)]
#[case("(default)", "default", true)]
#[case("default,", "default", true)]
#[case("xdefault", "default", false)]
#[case("defaultx", "default", false)]
fn standalone_word_boundaries(
#[case] tokens: &str,
#[case] word: &str,
#[case] expected: bool,
) {
let start = tokens.find(word).unwrap();
assert_eq!(super::is_standalone_word_at(tokens, start, word), expected);
}
#[rstest]
#[case("module::rename", 8, true)]
#[case("module:rename", 7, false)]
#[case("rename", 0, false)]
fn qualified_key_detection(#[case] tokens: &str, #[case] start: usize, #[case] expected: bool) {
assert_eq!(super::is_qualified_key(tokens, start), expected);
}
}