satay-codegen 0.1.1

Generate Rust client code from OpenAPI 3.1 documents
Documentation
use std::collections::BTreeSet;

use crate::ident::{type_ident, unique_ident};
use crate::model::{
    Component, ComponentKind, ConstrainedType, EnumVariant, RangeScalar, RangeType, RangeTypeRef,
    TypeRef, Validation,
};

#[derive(Debug, Default)]
pub(crate) struct TypeRegistry {
    generated: Vec<ConstrainedType>,
    inline_enums: Vec<Component>,
    inline_ranges: Vec<Component>,
    used_names: BTreeSet<String>,
}

impl TypeRegistry {
    pub(crate) fn reserve(&mut self, rust_name: String) {
        self.used_names.insert(rust_name);
    }

    pub(crate) fn reserve_preferred_type_name(
        &mut self,
        candidates: impl IntoIterator<Item = String>,
    ) -> String {
        let mut last_candidate = None;

        for candidate in candidates {
            if !self.used_names.contains(&candidate) {
                self.used_names.insert(candidate.clone());
                return candidate;
            }
            last_candidate = Some(candidate);
        }

        unique_ident(
            last_candidate.expect("reserve_preferred_type_name requires at least one candidate"),
            &mut self.used_names,
        )
    }

    pub(crate) fn constrained_ref(
        &mut self,
        type_name_hint: &str,
        description: Option<String>,
        inner: TypeRef,
        validation: Validation,
    ) -> TypeRef {
        let rust_name = self.generated_type_name(type_name_hint);

        self.generated.push(ConstrainedType {
            rust_name: rust_name.clone(),
            description,
            inner: inner.clone(),
            validation,
        });

        TypeRef::Constrained {
            rust_name,
            inner: Box::new(inner),
        }
    }

    pub(crate) fn inline_enum_ref(
        &mut self,
        type_name_hint: &str,
        description: Option<String>,
        variants: Vec<EnumVariant>,
    ) -> TypeRef {
        let rust_name = self.generated_type_name(type_name_hint);

        self.inline_enums.push(Component {
            rust_name: rust_name.clone(),
            description,
            kind: ComponentKind::Enum(variants),
        });

        TypeRef::Named(rust_name)
    }

    pub(crate) fn inline_range_ref(
        &mut self,
        type_name_hint: &str,
        description: Option<String>,
        scalar: RangeScalar,
    ) -> TypeRef {
        let rust_name = self.generated_type_name(type_name_hint);

        self.inline_ranges.push(Component {
            rust_name: rust_name.clone(),
            description: description.clone(),
            kind: ComponentKind::Range(RangeType {
                rust_name: rust_name.clone(),
                description,
                scalar,
            }),
        });

        TypeRef::Range(RangeTypeRef { rust_name, scalar })
    }

    pub(crate) fn finish(
        self,
        mut components: Vec<Component>,
    ) -> (Vec<Component>, Vec<ConstrainedType>) {
        components.extend(self.inline_enums);
        components.extend(self.inline_ranges);
        (components, self.generated)
    }

    fn generated_type_name(&mut self, type_name_hint: &str) -> String {
        let candidate = type_ident(type_name_hint);
        if self.used_names.insert(candidate.clone()) {
            return candidate;
        }

        let candidate = format!("{candidate}_{}", stable_suffix(type_name_hint));
        unique_ident(candidate, &mut self.used_names)
    }
}

fn stable_suffix(value: &str) -> String {
    let mut hash = 0xcbf2_9ce4_8422_2325u64;
    for byte in value.as_bytes() {
        hash ^= u64::from(*byte);
        hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
    }
    format!("{hash:08X}")
}

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

    #[test]
    fn stable_suffix_is_deterministic() {
        assert_eq!(stable_suffix(""), "CBF29CE484222325");
        assert_eq!(stable_suffix("User name"), "B9537F8B37389455");
        assert_ne!(stable_suffix("User name"), stable_suffix("user name"));
    }

    #[test]
    fn honors_reserved_names_when_allocating_generated_types() {
        let mut registry = TypeRegistry::default();
        registry.reserve("UserName".to_owned());

        let ty = registry.constrained_ref(
            "User name",
            None,
            TypeRef::String,
            Validation::String {
                min_length: Some(1),
                max_length: None,
                pattern: None,
            },
        );

        match ty {
            TypeRef::Constrained { rust_name, inner } => {
                assert_eq!(
                    rust_name,
                    format!("UserName_{}", stable_suffix("User name"))
                );
                assert_eq!(inner.as_ref(), &TypeRef::String);
            }
            other => panic!("expected constrained ref, got {other:?}"),
        }
    }

    #[test]
    fn reserve_preferred_type_name_uses_first_available_candidate() {
        let mut registry = TypeRegistry::default();
        registry.reserve("PsiResponse".to_owned());

        let allocated = registry.reserve_preferred_type_name([
            "PsiResponse".to_owned(),
            "PsiOperationResponse".to_owned(),
        ]);
        let next = registry.reserve_preferred_type_name(["PsiOperationResponse".to_owned()]);

        assert_eq!(allocated, "PsiOperationResponse");
        assert_eq!(next, "PsiOperationResponse_2");
    }

    #[test]
    fn allocates_stable_collision_suffixes_and_numeric_tie_breakers() {
        let mut registry = TypeRegistry::default();
        registry.reserve("UserName".to_owned());
        registry.reserve(format!("UserName_{}", stable_suffix("User name")));

        let first = registry.inline_enum_ref(
            "User name",
            None,
            vec![EnumVariant {
                wire_name: "active".to_owned(),
                rust_name: "Active".to_owned(),
            }],
        );
        let second =
            registry.inline_range_ref("User name", None, RangeScalar::Integer(IntegerType::U8));

        assert_eq!(
            first,
            TypeRef::Named(format!("UserName_{}_2", stable_suffix("User name")))
        );
        assert_eq!(
            second,
            TypeRef::Range(RangeTypeRef {
                rust_name: format!("UserName_{}_3", stable_suffix("User name")),
                scalar: RangeScalar::Integer(IntegerType::U8),
            })
        );
    }

    #[test]
    fn accumulates_inline_enums_ranges_and_constrained_types_on_finish() {
        let mut registry = TypeRegistry::default();

        let constrained = registry.constrained_ref(
            "Search term",
            Some("Search text.".to_owned()),
            TypeRef::String,
            Validation::String {
                min_length: Some(2),
                max_length: Some(80),
                pattern: None,
            },
        );
        let inline_enum = registry.inline_enum_ref(
            "Search state",
            None,
            vec![EnumVariant {
                wire_name: "open".to_owned(),
                rust_name: "Open".to_owned(),
            }],
        );
        let inline_range = registry.inline_range_ref(
            "Search window",
            None,
            RangeScalar::Integer(IntegerType::U16),
        );

        assert!(matches!(constrained, TypeRef::Constrained { .. }));
        assert_eq!(inline_enum, TypeRef::Named("SearchState".to_owned()));
        assert_eq!(
            inline_range,
            TypeRef::Range(RangeTypeRef {
                rust_name: "SearchWindow".to_owned(),
                scalar: RangeScalar::Integer(IntegerType::U16),
            })
        );

        let base = vec![Component {
            rust_name: "Existing".to_owned(),
            description: None,
            kind: ComponentKind::Alias(TypeRef::String),
        }];
        let (components, constrained_types) = registry.finish(base);

        assert_eq!(
            components
                .iter()
                .map(|component| component.rust_name.as_str())
                .collect::<Vec<_>>(),
            ["Existing", "SearchState", "SearchWindow"]
        );
        assert_eq!(constrained_types.len(), 1);
        assert_eq!(constrained_types[0].rust_name, "SearchTerm");
    }
}