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_simple_type_name, last_segment};
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 over its free function (`unified_to_rust`,
41/// `unified_to_napi`, `unified_to_typescript`).
42pub trait TypeRenderer {
43    fn render(&self, ty: &UnifiedType) -> String;
44}
45
46/// Rust type renderer — handles optional `impl Into<>` / `Option<>` wrapping.
47pub struct RustRenderer(pub RenderContext, pub Runtime);
48
49impl TypeRenderer for RustRenderer {
50    fn render(&self, ty: &UnifiedType) -> String {
51        unified_to_rust(ty, self.0, self.1)
52    }
53}
54
55/// Rust NAPI parameter type renderer.
56pub struct NapiRenderer;
57
58impl TypeRenderer for NapiRenderer {
59    fn render(&self, ty: &UnifiedType) -> String {
60        unified_to_napi(ty)
61    }
62}
63
64/// TypeScript type annotation renderer.
65pub struct TypeScriptRenderer;
66
67impl TypeRenderer for TypeScriptRenderer {
68    fn render(&self, ty: &UnifiedType) -> String {
69        unified_to_typescript(ty)
70    }
71}
72
73/// Convert a unified type to a Rust type string.
74///
75/// The `runtime` only selects the crate of the free-form JSON types ([`JsonType::rust_path`]).
76/// Otherwise the type is runtime-independent: under both prost and buffa, generated
77/// builder/constructor params and the query-deserialization struct use the bare enum type `E`
78/// (serde deserializes the variant-name string into it), and the runtime-specific wrapping
79/// (`as i32` / `EnumValue::Known`) is applied by [`field_assignment`].
80pub fn unified_to_rust(
81    unified_type: &UnifiedType,
82    context: RenderContext,
83    runtime: Runtime,
84) -> String {
85    let base_type_str = match &unified_type.base_type {
86        BaseType::String => {
87            // A repeated string stays a plain `Vec<String>`.
88            if matches!(context, RenderContext::Constructor)
89                && !unified_type.is_optional
90                && !unified_type.is_repeated
91            {
92                "impl Into<String>".to_string()
93            } else {
94                "String".to_string()
95            }
96        }
97        BaseType::Int32 => "i32".to_string(),
98        BaseType::Int64 => "i64".to_string(),
99        BaseType::Bool => "bool".to_string(),
100        BaseType::Float64 => "f64".to_string(),
101        BaseType::Float32 => "f32".to_string(),
102        BaseType::Bytes => "Vec<u8>".to_string(),
103        BaseType::Unit => "()".to_string(),
104        BaseType::Message(name) => match JsonType::of(name) {
105            Some(json) => json.rust_path(runtime).to_string(),
106            None => extract_simple_type_name(name),
107        },
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(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                let value = match value_type.base_type {
122                    BaseType::String => "impl Into<String>".to_string(),
123                    _ => unified_to_rust(value_type, RenderContext::Parameter, runtime),
124                };
125                format!("impl IntoIterator<Item = (impl Into<String>, {value})>")
126            } else {
127                let key_str = unified_to_rust(key_type, context, runtime);
128                let value_str = unified_to_rust(value_type, context, runtime);
129                format!("HashMap<{key_str}, {value_str}>")
130            }
131        }
132    };
133
134    let mut result = base_type_str;
135
136    // In builder methods we require the inner type only.
137    if unified_type.is_repeated && !matches!(context, RenderContext::BuilderMethod) {
138        result = format!("Vec<{result}>");
139    }
140
141    if should_wrap_in_option(context, unified_type) {
142        result = format!("Option<{result}>");
143    }
144
145    result
146}
147
148/// Return `true` when the Rust representation of `ty` in `ctx` should be wrapped in `Option<T>`.
149///
150/// Two independent conditions trigger wrapping:
151///
152/// 1. **Optional field outside a builder method** — builder methods use `impl Into<Option<T>>`
153///    for their parameter type and leave the inner `Option<T>` implicit, so wrapping is skipped
154///    in that context.
155///
156/// 2. **FFI boundaries (Python / NAPI) with maps or repeated fields** — at FFI boundaries we
157///    distinguish "field absent" from "field is an empty collection" by wrapping maps and
158///    repeated fields in `Option<T>`, even when the field is not marked optional in proto.
159fn should_wrap_in_option(ctx: RenderContext, ty: &UnifiedType) -> bool {
160    let is_optional_non_builder = ty.is_optional && !matches!(ctx, RenderContext::BuilderMethod);
161    let is_ffi = matches!(
162        ctx,
163        RenderContext::PythonParameter | RenderContext::NapiParameter
164    );
165    // At FFI boundaries, collections (maps, repeated) are wrapped in `Option` so callers can
166    // distinguish "absent" from "empty". Message/oneof optionality is carried by `ty.is_optional`
167    // (set during analysis from the field's `REQUIRED` behavior), so a required message body
168    // renders as a bare `T` required param while an optional one renders as `Option<T>`.
169    let is_ffi_collection =
170        is_ffi && (matches!(ty.base_type, BaseType::Map(_, _)) || ty.is_repeated);
171    is_optional_non_builder || is_ffi_collection
172}
173
174/// Generate field assignment code.
175///
176/// Wraps a constructor/builder parameter so it can be stored into the request struct field.
177/// `runtime` selects the ABI of the protobuf library the generated code consumes:
178///
179/// - [`Runtime::Prost`]: open enums are `i32` (`#field as i32`); singular message/oneof
180///   fields are `Option<Box<T>>` (`Some(#field)`).
181/// - [`Runtime::Buffa`]: open enums are `EnumValue<E>` (`buffa::EnumValue::Known(#field)`);
182///   singular message/oneof fields are `MessageField<T>` (`buffa::MessageField::some(#field)`).
183pub fn field_assignment(
184    unified_type: &UnifiedType,
185    field_ident: &proc_macro2::Ident,
186    ctx: &RenderContext,
187    runtime: Runtime,
188) -> TokenStream {
189    if matches!(ctx, RenderContext::BuilderMethod) {
190        return flexible_optional_field_assignment(unified_type, field_ident, runtime);
191    }
192    match &unified_type.base_type {
193        // A single string is an `impl Into<String>`; a repeated one is already `Vec<String>`.
194        BaseType::String if !unified_type.is_optional && !unified_type.is_repeated => {
195            quote! { #field_ident.into() }
196        }
197        BaseType::Enum(_) => match runtime {
198            Runtime::Prost if unified_type.is_repeated => {
199                quote! { #field_ident.into_iter().map(|v| v as i32).collect() }
200            }
201            Runtime::Prost => quote! { #field_ident as i32 },
202            Runtime::Buffa if unified_type.is_repeated => {
203                quote! { #field_ident.into_iter().map(buffa::EnumValue::Known).collect() }
204            }
205            Runtime::Buffa => quote! { buffa::EnumValue::Known(#field_ident) },
206        },
207        // Only string map values go through `.into()`; others already have the field's type.
208        BaseType::Map(_, value)
209            if matches!(ctx, RenderContext::Constructor)
210                && !matches!(value.base_type, BaseType::String) =>
211        {
212            quote! { #field_ident.into_iter().map(|(k, v)| (k.into(), v)).collect() }
213        }
214        BaseType::Map(_, _) => quote! {
215            #field_ident.into_iter().map(|(k, v)| (k.into(), v.into())).collect()
216        },
217        // Singular message/oneof fields wrap the bare `T` constructor param into the request
218        // struct's optional-message representation. (Repeated messages are `Vec<T>`, not
219        // optional, so they fall through unchanged.) Oneof fields are `Option<Oneof>` under both
220        // runtimes, so they always take the `Some(..)` form regardless of `runtime`.
221        BaseType::OneOf(_) if !unified_type.is_repeated => {
222            quote! { Some(#field_ident) }
223        }
224        BaseType::Message(_) if !unified_type.is_repeated => match runtime {
225            // prost: `Option<Box<T>>`
226            Runtime::Prost => quote! { Some(#field_ident) },
227            // buffa: `MessageField<T>`
228            Runtime::Buffa => quote! { buffa::MessageField::some(#field_ident) },
229        },
230        _ => quote! { #field_ident },
231    }
232}
233
234/// Convert a unified type to a Rust NAPI parameter type string.
235///
236/// NAPI requires concrete types (no `impl Into<>`). Optional fields
237/// become `Option<T>`, maps become `Option<HashMap<K, V>>`.
238pub fn unified_to_napi(unified_type: &UnifiedType) -> String {
239    let base_type_str = match &unified_type.base_type {
240        BaseType::String => "String".to_string(),
241        BaseType::Int32 => "i32".to_string(),
242        BaseType::Int64 => "i64".to_string(),
243        BaseType::Bool => "bool".to_string(),
244        BaseType::Float64 => "f64".to_string(),
245        BaseType::Float32 => "f32".to_string(),
246        BaseType::Bytes => "Vec<u8>".to_string(),
247        BaseType::Unit => "()".to_string(),
248        BaseType::Message(name) => extract_simple_type_name(name),
249        BaseType::Enum(name) => convert_protobuf_enum_to_rust_type(name),
250        BaseType::OneOf(name) => extract_simple_type_name(name),
251        BaseType::Map(key_type, value_type) => {
252            let key_str = unified_to_napi(key_type);
253            let value_str = unified_to_napi(value_type);
254            format!("HashMap<{key_str}, {value_str}>")
255        }
256    };
257
258    let mut result = base_type_str;
259
260    if unified_type.is_repeated {
261        result = format!("Vec<{result}>");
262    }
263
264    if unified_type.is_optional
265        || matches!(unified_type.base_type, BaseType::Map(_, _))
266        || unified_type.is_repeated
267    {
268        result = format!("Option<{result}>");
269    }
270
271    result
272}
273
274/// Convert a unified type to a TypeScript type annotation string.
275///
276/// Enums are mapped to `number` since they cross the NAPI boundary as `i32`.
277pub fn unified_to_typescript(unified_type: &UnifiedType) -> String {
278    let base_type_str = match &unified_type.base_type {
279        BaseType::String => "string".to_string(),
280        BaseType::Int32 | BaseType::Int64 => "number".to_string(),
281        BaseType::Bool => "boolean".to_string(),
282        BaseType::Float64 | BaseType::Float32 => "number".to_string(),
283        BaseType::Bytes => "Uint8Array".to_string(),
284        BaseType::Unit => "void".to_string(),
285        BaseType::Message(name) => extract_simple_type_name(name),
286        BaseType::Enum(_) => "number".to_string(),
287        BaseType::OneOf(name) => extract_simple_type_name(name),
288        BaseType::Map(key_type, value_type) => {
289            let key_str = unified_to_typescript(key_type);
290            let value_str = unified_to_typescript(value_type);
291            format!("Record<{key_str}, {value_str}>")
292        }
293    };
294
295    let mut result = base_type_str;
296
297    if unified_type.is_repeated {
298        result = format!("{result}[]");
299    }
300
301    if unified_type.is_optional {
302        result = format!("{result} | undefined");
303    }
304
305    result
306}
307
308fn flexible_optional_field_assignment(
309    unified_type: &UnifiedType,
310    field_ident: &proc_macro2::Ident,
311    runtime: Runtime,
312) -> TokenStream {
313    if unified_type.is_optional {
314        match (&unified_type.base_type, runtime) {
315            (BaseType::Enum(_), Runtime::Prost) => quote! { #field_ident.into().map(|e| e as i32) },
316            (BaseType::Enum(_), Runtime::Buffa) => {
317                quote! { #field_ident.into().map(buffa::EnumValue::Known) }
318            }
319            // buffa stores singular message/oneof fields as `MessageField<T>`. The
320            // setter arg is `impl Into<Option<T>>`, so resolve to `Option<T>` first
321            // (annotating the intermediate disambiguates the chained `.into()`), then
322            // convert via `MessageField: From<Option<T>>`.
323            (BaseType::Message(_) | BaseType::OneOf(_), Runtime::Buffa) => quote! {
324                {
325                    let #field_ident: ::core::option::Option<_> = #field_ident.into();
326                    buffa::MessageField::from(#field_ident)
327                }
328            },
329            _ => quote! { #field_ident.into() },
330        }
331    } else {
332        match (&unified_type.base_type, runtime) {
333            (
334                BaseType::String
335                | BaseType::Int32
336                | BaseType::Int64
337                | BaseType::Bool
338                | BaseType::Float64
339                | BaseType::Float32,
340                _,
341            ) => quote! { #field_ident.into() },
342            (BaseType::Enum(_), Runtime::Prost) => quote! { #field_ident as i32 },
343            (BaseType::Enum(_), Runtime::Buffa) => quote! { buffa::EnumValue::Known(#field_ident) },
344            // Message, OneOf, and Map types are always handled by their own
345            // dedicated branches in `builder_with_impl` and never reach this path.
346            _ => quote! { #field_ident },
347        }
348    }
349}
350
351/// Convert protobuf enum type to Rust type
352///
353/// # FIXME: Fragile heuristic for nested vs package-level enums
354///
355/// This function guesses whether an enum is nested inside a message (e.g.
356/// `example.Catalog.Status`) or defined at package level (e.g.
357/// `example.catalog.v1.CatalogType`) by inspecting the casing of the last
358/// path segment before the enum name.  The special-case for `"V1"` and the
359/// all-lowercase check are workarounds for common proto package naming
360/// conventions, not a principled solution.
361///
362/// The correct fix (Phase 3): accept a `package_prefix: &str` parameter so
363/// callers can strip the known package prefix and always identify the boundary
364/// between package segments and message/enum names deterministically.
365pub(crate) fn convert_protobuf_enum_to_rust_type(enum_name: &str) -> String {
366    let enum_name = enum_name.trim_start_matches('.');
367    let Some((parent_part, enum_simple_name)) = enum_name.rsplit_once('.') else {
368        return convert_enum_name_to_rust(enum_name);
369    };
370    let enum_rust_name = convert_enum_name_to_rust(enum_simple_name);
371
372    // A nested enum's parent is a PascalCase message name; a package-level enum's is a package
373    // segment like `v1`.
374    let parent = last_segment(parent_part);
375    let is_nested = parent.starts_with(char::is_uppercase)
376        && parent != "V1"
377        && !parent.chars().all(|c| c.is_lowercase() || c.is_numeric());
378    if is_nested {
379        // The parent message becomes its snake_case module.
380        format!("{}::{enum_rust_name}", parent.to_case(Case::Snake))
381    } else {
382        enum_rust_name
383    }
384}
385
386/// Convert enum name from proto format to Rust format
387fn convert_enum_name_to_rust(enum_name: &str) -> String {
388    // Convert from proto naming to Rust naming if needed
389    if enum_name.contains('_') && enum_name.chars().all(|c| c.is_uppercase() || c == '_') {
390        enum_name.to_case(Case::Pascal)
391    } else {
392        enum_name.to_string()
393    }
394}
395
396/// A language-neutral description of a protobuf field's type.
397///
398/// `UnifiedType` is the bridge between parsing and generation: a proto field's type and
399/// cardinality are normalized into this single shape once, then each language backend renders it
400/// via a [`TypeRenderer`] (e.g. [`unified_to_rust`]). The [`base_type`](Self::base_type) carries
401/// the underlying category while [`is_optional`](Self::is_optional) and
402/// [`is_repeated`](Self::is_repeated) describe the cardinality wrapping applied around it.
403#[derive(Debug, Clone)]
404pub struct UnifiedType {
405    /// The underlying type category.
406    pub base_type: BaseType,
407    /// Whether this type is optional (`Option<T>` in Rust).
408    pub is_optional: bool,
409    /// Whether this type is repeated (`Vec<T>` in Rust).
410    pub is_repeated: bool,
411}
412
413/// A free-form JSON type of `google/protobuf/struct.proto`: the runtime's well-known type in
414/// Rust, a plain object in Python.
415#[derive(Debug, Clone, Copy, PartialEq, Eq)]
416pub enum JsonType {
417    /// `google.protobuf.Value`: any JSON value.
418    Value,
419    /// `google.protobuf.Struct`: a JSON object.
420    Struct,
421    /// `google.protobuf.ListValue`: a JSON array.
422    ListValue,
423}
424
425impl JsonType {
426    /// The JSON type of the proto message `name`, if it is one.
427    pub fn of(name: &str) -> Option<Self> {
428        match name.trim_start_matches('.') {
429            "google.protobuf.Value" => Some(Self::Value),
430            "google.protobuf.Struct" => Some(Self::Struct),
431            "google.protobuf.ListValue" => Some(Self::ListValue),
432            _ => None,
433        }
434    }
435
436    /// The JSON type of a field type: the type itself, or the value type of a map.
437    pub fn of_field(ty: &UnifiedType) -> Option<Self> {
438        match &ty.base_type {
439            BaseType::Message(name) => Self::of(name),
440            BaseType::Map(_, value) => Self::of_field(value),
441            _ => None,
442        }
443    }
444
445    /// The path of the type in the `runtime`'s well-known-types crate.
446    pub fn rust_path(self, runtime: Runtime) -> &'static str {
447        match (runtime, self) {
448            (Runtime::Buffa, Self::Value) => "::buffa_types::google::protobuf::Value",
449            (Runtime::Buffa, Self::Struct) => "::buffa_types::google::protobuf::Struct",
450            (Runtime::Buffa, Self::ListValue) => "::buffa_types::google::protobuf::ListValue",
451            (Runtime::Prost, Self::Value) => "::prost_types::Value",
452            (Runtime::Prost, Self::Struct) => "::prost_types::Struct",
453            (Runtime::Prost, Self::ListValue) => "::prost_types::ListValue",
454        }
455    }
456
457    /// The suffix of the type's conversion functions: `value`, `struct`, `list`.
458    pub fn fn_suffix(self) -> &'static str {
459        match self {
460            Self::Value => "value",
461            Self::Struct => "struct",
462            Self::ListValue => "list",
463        }
464    }
465}
466
467/// The underlying category of a [`UnifiedType`], independent of optional/repeated wrapping.
468///
469/// Scalars map directly to a primitive in each target language; the named variants
470/// ([`Message`](Self::Message), [`Enum`](Self::Enum), [`OneOf`](Self::OneOf)) carry the
471/// fully-qualified protobuf type name, from which renderers take the final segment.
472#[derive(Debug, Clone)]
473pub enum BaseType {
474    /// String type
475    String,
476    /// 32-bit integer
477    Int32,
478    /// 64-bit integer
479    Int64,
480    /// Boolean
481    Bool,
482    /// 64-bit float
483    Float64,
484    /// 32-bit float
485    Float32,
486    /// Byte array
487    Bytes,
488    /// Protobuf message type
489    Message(String),
490    /// Protobuf enum type
491    Enum(String),
492    /// Protobuf oneof type
493    OneOf(String),
494    /// Map type with key and value types
495    Map(Box<UnifiedType>, Box<UnifiedType>),
496    /// Unit/void type
497    Unit,
498}
499
500impl UnifiedType {
501    /// Extract a syn::Ident for the inner type name (last segment of a qualified name).
502    pub fn type_ident(&self) -> syn::Ident {
503        let name = match &self.base_type {
504            BaseType::Message(n) | BaseType::Enum(n) | BaseType::OneOf(n) => last_segment(n),
505            BaseType::String => "String",
506            BaseType::Int32 => "i32",
507            BaseType::Int64 => "i64",
508            BaseType::Bool => "bool",
509            BaseType::Float64 => "f64",
510            BaseType::Float32 => "f32",
511            BaseType::Bytes => "Bytes",
512            BaseType::Unit => "()",
513            BaseType::Map(_, _) => "HashMap",
514        };
515        quote::format_ident!("{}", name)
516    }
517
518    /// A singular `bytes` value: not repeated.
519    pub fn is_singular_bytes(&self) -> bool {
520        matches!(self.base_type, BaseType::Bytes) && !self.is_repeated
521    }
522
523    /// Create a simple string type
524    pub fn string() -> Self {
525        Self {
526            base_type: BaseType::String,
527            is_optional: false,
528            is_repeated: false,
529        }
530    }
531
532    /// Create an optional version of this type
533    pub fn optional(mut self) -> Self {
534        self.is_optional = true;
535        self
536    }
537
538    /// Create a repeated version of this type
539    pub fn repeated(mut self) -> Self {
540        self.is_repeated = true;
541        self
542    }
543
544    /// Create a map type
545    pub fn map(key: UnifiedType, value: UnifiedType) -> Self {
546        Self {
547            base_type: BaseType::Map(Box::new(key), Box::new(value)),
548            is_optional: false,
549            is_repeated: false,
550        }
551    }
552}
553
554#[cfg(test)]
555mod tests {
556    use super::*;
557
558    #[test]
559    fn json_types_render_as_the_runtime_well_known_type() {
560        let structs = UnifiedType::map(
561            UnifiedType::string(),
562            UnifiedType {
563                base_type: BaseType::Message(".google.protobuf.Struct".to_string()),
564                is_optional: false,
565                is_repeated: false,
566            },
567        );
568        assert_eq!(
569            unified_to_rust(&structs, RenderContext::Parameter, Runtime::Prost),
570            "HashMap<String, ::prost_types::Struct>"
571        );
572        assert_eq!(
573            unified_to_rust(&structs, RenderContext::Parameter, Runtime::Buffa),
574            "HashMap<String, ::buffa_types::google::protobuf::Struct>"
575        );
576    }
577}