apollo-errors-derive 0.4.0

Proc macro for deriving apollo-errors::Error trait
Documentation
//! Format string field extraction utilities

use super::field::FieldDefinition;

/// Validate that no optional fields are used in the error message.
///
/// Returns an error if an `Option<T>` field is referenced in the format string,
/// since `Option<T>` doesn't implement `Display`.
pub(crate) fn validate_no_optional_fields_in_message(
    error_message: &str,
    fields: &[FieldDefinition],
) -> syn::Result<()> {
    let referenced_fields = extract_field_names(error_message);
    for field_name in referenced_fields {
        if let Some(field) = fields.iter().find(|f| f.rust_name == field_name)
            && field.is_option
        {
            return Err(syn::Error::new_spanned(
                &field.rust_name,
                format!(
                    "optional field `{field_name}` cannot be used in error message; \
                     Option<T> does not implement Display"
                ),
            ));
        }
    }
    Ok(())
}

/// Extract field names referenced in a format string.
///
/// Handles Rust format string syntax including:
/// - `{field}` - simple field reference
/// - `{field:format}` - field with format specifier
/// - `{{` and `}}` - escaped braces (ignored)
fn extract_field_names(format_str: &str) -> Vec<String> {
    let mut fields = Vec::new();
    let mut chars = format_str.chars().peekable();

    while let Some(c) = chars.next() {
        if c == '{' {
            // Check for escaped brace `{{`
            if chars.peek() == Some(&'{') {
                chars.next();
                continue;
            }

            // Collect content until closing brace
            let mut content = String::new();
            for inner in chars.by_ref() {
                if inner == '}' {
                    break;
                }
                content.push(inner);
            }

            // Extract field name (everything before `:` or the whole content)
            let field_name = content.split(':').next().unwrap_or("");

            // Skip positional arguments (empty or numeric)
            if !field_name.is_empty() && !field_name.chars().all(|c| c.is_ascii_digit()) {
                fields.push(field_name.to_string());
            }
        }
    }

    fields
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn simple_field() {
        assert_eq!(extract_field_names("hello {name}"), vec!["name"]);
    }

    #[test]
    fn multiple_fields() {
        assert_eq!(extract_field_names("{a} and {b}"), vec!["a", "b"]);
    }

    #[test]
    fn field_with_format() {
        assert_eq!(extract_field_names("{count:03}"), vec!["count"]);
    }

    #[test]
    fn escaped_braces() {
        assert_eq!(
            extract_field_names("literal {{ and }}"),
            Vec::<String>::new()
        );
    }

    #[test]
    fn mixed() {
        assert_eq!(
            extract_field_names("Error: {message} (code: {code:04})"),
            vec!["message", "code"]
        );
    }

    #[test]
    fn positional_args_ignored() {
        assert_eq!(extract_field_names("{0} and {1}"), Vec::<String>::new());
    }

    #[test]
    fn empty_braces_ignored() {
        assert_eq!(extract_field_names("{}"), Vec::<String>::new());
    }
}