Skip to main content

olai_codegen/parsing/
types.rs

1//! Type conversion utilities for code generation
2//!
3//! This module provides utilities for converting between protobuf types and Rust types
4//! during code generation.
5
6use convert_case::{Case, Casing};
7use proc_macro2::TokenStream;
8use quote::quote;
9
10use crate::codegen::Runtime;
11use crate::utils::{extract_qualified_type_name, extract_simple_type_name};
12
13/// Context for rendering types in different situations
14#[derive(Debug, Clone, Copy)]
15pub enum RenderContext {
16    /// A constructor (new method) in Rust
17    Constructor,
18    /// when extracting from a request inside implementations of FromRequest or FromRequestParts
19    /// (path params: renders enum as i32 for direct axum::extract::Path deserialization)
20    Extractor,
21    /// when building the QueryParams serde struct for query string deserialization
22    /// (renders enum as its actual Rust type so serde can deserialize from string variant names)
23    QueryExtractor,
24    /// Regular parameter type
25    Parameter,
26    /// Return type
27    ReturnType,
28    /// Field type in a struct
29    FieldType,
30    /// Builder method parameter
31    BuilderMethod,
32    /// Python parameter type (Rust FFI signatures for PyO3 bindings)
33    PythonParameter,
34    /// NAPI parameter type (Rust NAPI function signatures for Node.js bindings)
35    NapiParameter,
36}
37
38/// Trait for rendering a [`UnifiedType`] into a language-specific string.
39///
40/// Each language backend provides a concrete impl. The four existing free functions
41/// (`unified_to_rust`, `unified_to_python_type`, `unified_to_napi`, `unified_to_typescript`)
42/// are kept as thin wrappers so call-sites don't change.
43pub trait TypeRenderer {
44    fn render(&self, ty: &UnifiedType) -> String;
45}
46
47/// Rust type renderer — handles optional `impl Into<>` / `Option<>` wrapping.
48pub struct RustRenderer(pub RenderContext);
49
50impl TypeRenderer for RustRenderer {
51    fn render(&self, ty: &UnifiedType) -> String {
52        unified_to_rust(ty, self.0)
53    }
54}
55
56/// Python type annotation renderer.
57pub struct PythonRenderer;
58
59impl TypeRenderer for PythonRenderer {
60    fn render(&self, ty: &UnifiedType) -> String {
61        unified_to_python_type(ty)
62    }
63}
64
65/// Rust NAPI parameter type renderer.
66pub struct NapiRenderer;
67
68impl TypeRenderer for NapiRenderer {
69    fn render(&self, ty: &UnifiedType) -> String {
70        unified_to_napi(ty)
71    }
72}
73
74/// TypeScript type annotation renderer.
75pub struct TypeScriptRenderer;
76
77impl TypeRenderer for TypeScriptRenderer {
78    fn render(&self, ty: &UnifiedType) -> String {
79        unified_to_typescript(ty)
80    }
81}
82
83/// Convert a unified type to a Rust type string.
84///
85/// Note: the *type* a field renders as is currently runtime-independent. Under both prost
86/// and buffa, generated builder/constructor params and the query-deserialization struct use
87/// the bare enum type `E` (serde deserializes the variant-name string into it), and the
88/// runtime-specific wrapping (`as i32` / `EnumValue::Known`) is applied by
89/// [`field_assignment`]. If a future runtime needs a different field *type* (e.g. buffa
90/// zero-copy view types), thread a `Runtime` argument back in here.
91pub fn unified_to_rust(unified_type: &UnifiedType, context: RenderContext) -> String {
92    let base_type_str = match &unified_type.base_type {
93        BaseType::String => {
94            if matches!(context, RenderContext::Constructor) && !unified_type.is_optional {
95                "impl Into<String>".to_string()
96            } else {
97                "String".to_string()
98            }
99        }
100        BaseType::Int32 => "i32".to_string(),
101        BaseType::Int64 => "i64".to_string(),
102        BaseType::Bool => "bool".to_string(),
103        BaseType::Float64 => "f64".to_string(),
104        BaseType::Float32 => "f32".to_string(),
105        BaseType::Bytes => "Vec<u8>".to_string(),
106        BaseType::Unit => "()".to_string(),
107        BaseType::Message(name) => extract_simple_type_name(name),
108        BaseType::Enum(name) => {
109            if matches!(
110                context,
111                RenderContext::Extractor | RenderContext::NapiParameter
112            ) {
113                "i32".to_string()
114            } else {
115                convert_protobuf_enum_to_rust_type(&format!("TYPE_ENUM:{}", name))
116            }
117        }
118        BaseType::OneOf(name) => extract_simple_type_name(name),
119        BaseType::Map(key_type, value_type) => {
120            if matches!(context, RenderContext::Constructor) && !unified_type.is_optional {
121                "impl IntoIterator<Item = (impl Into<String>, impl Into<String>)>".to_string()
122            } else {
123                let key_str = unified_to_rust(key_type, context);
124                let value_str = unified_to_rust(value_type, context);
125                format!("HashMap<{}, {}>", key_str, value_str)
126            }
127        }
128    };
129
130    let mut result = base_type_str;
131
132    // In builder methods we require the inner type only.
133    if unified_type.is_repeated && !matches!(context, RenderContext::BuilderMethod) {
134        result = format!("Vec<{}>", result);
135    }
136
137    if should_wrap_in_option(context, unified_type) {
138        result = format!("Option<{}>", result);
139    }
140
141    result
142}
143
144/// Return `true` when the Rust representation of `ty` in `ctx` should be wrapped in `Option<T>`.
145///
146/// Two independent conditions trigger wrapping:
147///
148/// 1. **Optional field outside a builder method** — builder methods use `impl Into<Option<T>>`
149///    for their parameter type and leave the inner `Option<T>` implicit, so wrapping is skipped
150///    in that context.
151///
152/// 2. **FFI boundaries (Python / NAPI) with maps or repeated fields** — at FFI boundaries we
153///    distinguish "field absent" from "field is an empty collection" by wrapping maps and
154///    repeated fields in `Option<T>`, even when the field is not marked optional in proto.
155fn should_wrap_in_option(ctx: RenderContext, ty: &UnifiedType) -> bool {
156    let is_optional_non_builder = ty.is_optional && !matches!(ctx, RenderContext::BuilderMethod);
157    let is_ffi = matches!(
158        ctx,
159        RenderContext::PythonParameter | RenderContext::NapiParameter
160    );
161    // At FFI boundaries, collections (maps, repeated) are wrapped in `Option` so callers can
162    // distinguish "absent" from "empty". Message/oneof optionality is carried by `ty.is_optional`
163    // (set during analysis from the field's `REQUIRED` behavior), so a required message body
164    // renders as a bare `T` required param while an optional one renders as `Option<T>`.
165    let is_ffi_collection =
166        is_ffi && (matches!(ty.base_type, BaseType::Map(_, _)) || ty.is_repeated);
167    is_optional_non_builder || is_ffi_collection
168}
169
170/// Generate field assignment code.
171///
172/// Wraps a constructor/builder parameter so it can be stored into the request struct field.
173/// `runtime` selects the ABI of the protobuf library the generated code consumes:
174///
175/// - [`Runtime::Prost`]: open enums are `i32` (`#field as i32`); singular message/oneof
176///   fields are `Option<Box<T>>` (`Some(#field)`).
177/// - [`Runtime::Buffa`]: open enums are `EnumValue<E>` (`buffa::EnumValue::Known(#field)`);
178///   singular message/oneof fields are `MessageField<T>` (`buffa::MessageField::some(#field)`).
179pub fn field_assignment(
180    unified_type: &UnifiedType,
181    field_ident: &proc_macro2::Ident,
182    ctx: &RenderContext,
183    runtime: Runtime,
184) -> TokenStream {
185    if matches!(ctx, RenderContext::BuilderMethod) {
186        return flexible_optional_field_assignment(unified_type, field_ident, runtime);
187    }
188    match &unified_type.base_type {
189        BaseType::String if !unified_type.is_optional => quote! { #field_ident.into() },
190        BaseType::Enum(_) => match runtime {
191            Runtime::Prost if unified_type.is_repeated => {
192                quote! { #field_ident.into_iter().map(|v| v as i32).collect() }
193            }
194            Runtime::Prost => quote! { #field_ident as i32 },
195            Runtime::Buffa if unified_type.is_repeated => {
196                quote! { #field_ident.into_iter().map(buffa::EnumValue::Known).collect() }
197            }
198            Runtime::Buffa => quote! { buffa::EnumValue::Known(#field_ident) },
199        },
200        BaseType::Map(_, _) => quote! {
201            #field_ident.into_iter().map(|(k, v)| (k.into(), v.into())).collect()
202        },
203        // Singular message/oneof fields wrap the bare `T` constructor param into the request
204        // struct's optional-message representation. (Repeated messages are `Vec<T>`, not
205        // optional, so they fall through unchanged.) Oneof fields are `Option<Oneof>` under both
206        // runtimes, so they always take the `Some(..)` form regardless of `runtime`.
207        BaseType::OneOf(_) if !unified_type.is_repeated => {
208            quote! { Some(#field_ident) }
209        }
210        BaseType::Message(_) if !unified_type.is_repeated => match runtime {
211            // prost: `Option<Box<T>>`
212            Runtime::Prost => quote! { Some(#field_ident) },
213            // buffa: `MessageField<T>`
214            Runtime::Buffa => quote! { buffa::MessageField::some(#field_ident) },
215        },
216        _ => quote! { #field_ident },
217    }
218}
219
220/// Convert a unified type to a Python type annotation string
221pub fn unified_to_python_type(unified_type: &UnifiedType) -> String {
222    let base_type_str = match &unified_type.base_type {
223        BaseType::String => "str".to_string(),
224        BaseType::Int32 | BaseType::Int64 => "int".to_string(),
225        BaseType::Bool => "bool".to_string(),
226        BaseType::Float64 | BaseType::Float32 => "float".to_string(),
227        BaseType::Bytes => "bytes".to_string(),
228        BaseType::Unit => "None".to_string(),
229        // Python typings emit flat classes, so nested types use the parent-qualified name (e.g.
230        // `GenerateTemporaryTableCredentialsRequestOperation`) to stay collision-free. Top-level
231        // types are unaffected (qualified == simple).
232        BaseType::Message(name) => extract_qualified_type_name(name),
233        BaseType::Enum(name) => extract_qualified_type_name(name),
234        BaseType::OneOf(name) => extract_qualified_type_name(name),
235        BaseType::Map(key_type, value_type) => {
236            let key_str = unified_to_python_type(key_type);
237            let value_str = unified_to_python_type(value_type);
238            format!("Dict[{}, {}]", key_str, value_str)
239        }
240    };
241
242    let mut result = base_type_str;
243
244    if unified_type.is_repeated {
245        result = format!("List[{}]", result);
246    }
247
248    if unified_type.is_optional {
249        result = format!("Optional[{}]", result);
250    }
251
252    result
253}
254
255/// Convert a unified type to a Rust NAPI parameter type string.
256///
257/// NAPI requires concrete types (no `impl Into<>`). Optional fields
258/// become `Option<T>`, maps become `Option<HashMap<K, V>>`.
259pub fn unified_to_napi(unified_type: &UnifiedType) -> String {
260    let base_type_str = match &unified_type.base_type {
261        BaseType::String => "String".to_string(),
262        BaseType::Int32 => "i32".to_string(),
263        BaseType::Int64 => "i64".to_string(),
264        BaseType::Bool => "bool".to_string(),
265        BaseType::Float64 => "f64".to_string(),
266        BaseType::Float32 => "f32".to_string(),
267        BaseType::Bytes => "Vec<u8>".to_string(),
268        BaseType::Unit => "()".to_string(),
269        BaseType::Message(name) => extract_simple_type_name(name),
270        BaseType::Enum(name) => convert_protobuf_enum_to_rust_type(&format!("TYPE_ENUM:{}", name)),
271        BaseType::OneOf(name) => extract_simple_type_name(name),
272        BaseType::Map(key_type, value_type) => {
273            let key_str = unified_to_napi(key_type);
274            let value_str = unified_to_napi(value_type);
275            format!("HashMap<{}, {}>", key_str, value_str)
276        }
277    };
278
279    let mut result = base_type_str;
280
281    if unified_type.is_repeated {
282        result = format!("Vec<{}>", result);
283    }
284
285    if unified_type.is_optional
286        || matches!(unified_type.base_type, BaseType::Map(_, _))
287        || unified_type.is_repeated
288    {
289        result = format!("Option<{}>", result);
290    }
291
292    result
293}
294
295/// Convert a unified type to a TypeScript type annotation string.
296///
297/// Enums are mapped to `number` since they cross the NAPI boundary as `i32`.
298pub fn unified_to_typescript(unified_type: &UnifiedType) -> String {
299    let base_type_str = match &unified_type.base_type {
300        BaseType::String => "string".to_string(),
301        BaseType::Int32 | BaseType::Int64 => "number".to_string(),
302        BaseType::Bool => "boolean".to_string(),
303        BaseType::Float64 | BaseType::Float32 => "number".to_string(),
304        BaseType::Bytes => "Uint8Array".to_string(),
305        BaseType::Unit => "void".to_string(),
306        BaseType::Message(name) => extract_simple_type_name(name),
307        BaseType::Enum(_) => "number".to_string(),
308        BaseType::OneOf(name) => extract_simple_type_name(name),
309        BaseType::Map(key_type, value_type) => {
310            let key_str = unified_to_typescript(key_type);
311            let value_str = unified_to_typescript(value_type);
312            format!("Record<{}, {}>", key_str, value_str)
313        }
314    };
315
316    let mut result = base_type_str;
317
318    if unified_type.is_repeated {
319        result = format!("{}[]", result);
320    }
321
322    if unified_type.is_optional {
323        result = format!("{} | undefined", result);
324    }
325
326    result
327}
328
329fn flexible_optional_field_assignment(
330    unified_type: &UnifiedType,
331    field_ident: &proc_macro2::Ident,
332    runtime: Runtime,
333) -> TokenStream {
334    if unified_type.is_optional {
335        match (&unified_type.base_type, runtime) {
336            (BaseType::Enum(_), Runtime::Prost) => quote! { #field_ident.into().map(|e| e as i32) },
337            (BaseType::Enum(_), Runtime::Buffa) => {
338                quote! { #field_ident.into().map(buffa::EnumValue::Known) }
339            }
340            _ => quote! { #field_ident.into() },
341        }
342    } else {
343        match (&unified_type.base_type, runtime) {
344            (BaseType::String, _) => quote! { #field_ident.into() },
345            (
346                BaseType::Int32
347                | BaseType::Int64
348                | BaseType::Bool
349                | BaseType::Float64
350                | BaseType::Float32,
351                _,
352            ) => {
353                quote! { #field_ident.into() }
354            }
355            (BaseType::Enum(_), Runtime::Prost) => quote! { #field_ident as i32 },
356            (BaseType::Enum(_), Runtime::Buffa) => quote! { buffa::EnumValue::Known(#field_ident) },
357            // Message, OneOf, and Map types are always handled by their own
358            // dedicated branches in `builder_with_impl` and never reach this path.
359            _ => quote! { #field_ident },
360        }
361    }
362}
363
364/// Convert protobuf enum type to Rust type
365///
366/// # FIXME: Fragile heuristic for nested vs package-level enums
367///
368/// This function guesses whether an enum is nested inside a message (e.g.
369/// `example.Catalog.Status`) or defined at package level (e.g.
370/// `example.catalog.v1.CatalogType`) by inspecting the casing of the last
371/// path segment before the enum name.  The special-case for `"V1"` and the
372/// all-lowercase check are workarounds for common proto package naming
373/// conventions, not a principled solution.
374///
375/// The correct fix (Phase 3): accept a `package_prefix: &str` parameter so
376/// callers can strip the known package prefix and always identify the boundary
377/// between package segments and message/enum names deterministically.
378fn convert_protobuf_enum_to_rust_type(proto_type: &str) -> String {
379    if let Some(enum_name) = proto_type.strip_prefix("TYPE_ENUM:") {
380        // Remove leading dot if present
381        let enum_name = enum_name.trim_start_matches('.');
382
383        // Check if this is a nested enum (has a parent message)
384        // Nested enums have PascalCase message names before the enum name
385        if let Some(last_dot) = enum_name.rfind('.') {
386            let parent_part = &enum_name[..last_dot];
387            let enum_simple_name = &enum_name[last_dot + 1..];
388
389            // Check if the parent part ends with a PascalCase message name (nested enum)
390            // vs. a package name like "v1" (package-level enum)
391            let parent_parts: Vec<&str> = parent_part.split('.').collect();
392            if let Some(last_part) = parent_parts.last() {
393                // If the last part starts with uppercase, it's likely a message name (nested enum)
394                // If it's "v1" or similar version, it's a package-level enum
395                if last_part.chars().next().is_some_and(|c| c.is_uppercase())
396                    && *last_part != "V1"
397                    && !last_part
398                        .chars()
399                        .all(|c| c.is_lowercase() || c.is_numeric())
400                {
401                    // This is a nested enum - convert parent message to snake_case module
402                    let snake_case_module = last_part.to_case(Case::Snake);
403
404                    // Convert enum name from UPPER_SNAKE_CASE to PascalCase if needed
405                    let enum_rust_name = if enum_simple_name.contains('_')
406                        && enum_simple_name
407                            .chars()
408                            .all(|c| c.is_uppercase() || c == '_')
409                    {
410                        enum_simple_name.to_case(Case::Pascal)
411                    } else {
412                        enum_simple_name.to_string()
413                    };
414
415                    format!("{}::{}", snake_case_module, enum_rust_name)
416                } else {
417                    // This is a package-level enum - just use the simple name
418                    convert_enum_name_to_rust(enum_simple_name)
419                }
420            } else {
421                // Fallback to simple enum name
422                convert_enum_name_to_rust(enum_simple_name)
423            }
424        } else {
425            // Simple enum name without dots
426            convert_enum_name_to_rust(enum_name)
427        }
428    } else {
429        "i32".to_string() // Fallback for unknown enum types
430    }
431}
432
433/// Convert enum name from proto format to Rust format
434fn convert_enum_name_to_rust(enum_name: &str) -> String {
435    // Convert from proto naming to Rust naming if needed
436    if enum_name.contains('_') && enum_name.chars().all(|c| c.is_uppercase() || c == '_') {
437        enum_name.to_case(Case::Pascal)
438    } else {
439        enum_name.to_string()
440    }
441}
442
443/// A language-neutral description of a protobuf field's type.
444///
445/// `UnifiedType` is the bridge between parsing and generation: a proto field's type and
446/// cardinality are normalized into this single shape once, then each language backend renders it
447/// via a [`TypeRenderer`] (e.g. [`unified_to_rust`]). The [`base_type`](Self::base_type) carries
448/// the underlying category while [`is_optional`](Self::is_optional) and
449/// [`is_repeated`](Self::is_repeated) describe the cardinality wrapping applied around it.
450#[derive(Debug, Clone)]
451pub struct UnifiedType {
452    /// The underlying type category.
453    pub base_type: BaseType,
454    /// Whether this type is optional (`Option<T>` in Rust).
455    pub is_optional: bool,
456    /// Whether this type is repeated (`Vec<T>` in Rust).
457    pub is_repeated: bool,
458}
459
460/// The underlying category of a [`UnifiedType`], independent of optional/repeated wrapping.
461///
462/// Scalars map directly to a primitive in each target language; the named variants
463/// ([`Message`](Self::Message), [`Enum`](Self::Enum), [`OneOf`](Self::OneOf)) carry the
464/// fully-qualified protobuf type name, from which renderers take the final segment.
465#[derive(Debug, Clone)]
466pub enum BaseType {
467    /// String type
468    String,
469    /// 32-bit integer
470    Int32,
471    /// 64-bit integer
472    Int64,
473    /// Boolean
474    Bool,
475    /// 64-bit float
476    Float64,
477    /// 32-bit float
478    Float32,
479    /// Byte array
480    Bytes,
481    /// Protobuf message type
482    Message(String),
483    /// Protobuf enum type
484    Enum(String),
485    /// Protobuf oneof type
486    OneOf(String),
487    /// Map type with key and value types
488    Map(Box<UnifiedType>, Box<UnifiedType>),
489    /// Unit/void type
490    Unit,
491}
492
493impl UnifiedType {
494    /// Extract a syn::Ident for the inner type name (last segment of a qualified name).
495    pub fn type_ident(&self) -> syn::Ident {
496        let name = match &self.base_type {
497            BaseType::Message(n) | BaseType::Enum(n) | BaseType::OneOf(n) => {
498                n.split('.').next_back().unwrap_or(n)
499            }
500            BaseType::String => "String",
501            BaseType::Int32 => "i32",
502            BaseType::Int64 => "i64",
503            BaseType::Bool => "bool",
504            BaseType::Float64 => "f64",
505            BaseType::Float32 => "f32",
506            BaseType::Bytes => "Bytes",
507            BaseType::Unit => "()",
508            BaseType::Map(_, _) => "HashMap",
509        };
510        quote::format_ident!("{}", name)
511    }
512
513    /// Create a simple string type
514    pub fn string() -> Self {
515        Self {
516            base_type: BaseType::String,
517            is_optional: false,
518            is_repeated: false,
519        }
520    }
521
522    /// Create an optional version of this type
523    pub fn optional(mut self) -> Self {
524        self.is_optional = true;
525        self
526    }
527
528    /// Create a repeated version of this type
529    pub fn repeated(mut self) -> Self {
530        self.is_repeated = true;
531        self
532    }
533
534    /// Create a map type
535    pub fn map(key: UnifiedType, value: UnifiedType) -> Self {
536        Self {
537            base_type: BaseType::Map(Box::new(key), Box::new(value)),
538            is_optional: false,
539            is_repeated: false,
540        }
541    }
542}