rescript-openapi 0.1.0

Generate type-safe ReScript clients from OpenAPI specifications
Documentation
// SPDX-License-Identifier: PMPL-1.0-or-later
// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell

//! ReScript type generation

use crate::ir::{ApiSpec, TypeDef};
use super::{Config, VariantMode};
use super::schema::{topological_sort_scc, get_dependencies};
use anyhow::Result;
use heck::ToLowerCamelCase;

pub fn generate(spec: &ApiSpec, config: &Config) -> Result<String> {
    let mut output = String::new();

    // Header
    output.push_str("// SPDX-License-Identifier: PMPL-1.0-or-later\n");
    output.push_str("// Generated by rescript-openapi - DO NOT EDIT\n");
    output.push_str(&format!("// Source: {} v{}\n\n", spec.title, spec.version));

    // Topologically sort types, grouping strongly connected components (SCCs)
    let sccs = topological_sort_scc(&spec.types);

    // Generate each SCC
    for scc in sccs {
        output.push_str(&generate_scc(&scc, config));
        output.push('\n');
    }

    Ok(output)
}

pub fn generate_scc(scc: &[&TypeDef], config: &Config) -> String {
    let mut output = String::new();

    if scc.len() == 1 {
        let type_def = scc[0];
        let name = match type_def {
            TypeDef::Record { name, .. } => name.to_lower_camel_case(),
            TypeDef::Variant { name, .. } => name.to_lower_camel_case(),
            TypeDef::Alias { name, .. } => name.to_lower_camel_case(),
        };

        // Check for self-recursion
        let deps = get_dependencies(type_def);
        let is_recursive = deps.contains(&name);

        output.push_str(&generate_type(type_def, is_recursive, config));
    } else {
        // Mutually recursive types
        // type rec a = ...
        // and b = ...
        for (i, type_def) in scc.iter().enumerate() {
            if i == 0 {
                output.push_str(&generate_type(type_def, true, config)); // type rec ...
            } else {
                // Replace "type " with "and "
                let def = generate_type(type_def, false, config);
                let def = def.replacen("type ", "and ", 1);
                output.push_str(&def);
            }
        }
    }

    output
}

pub fn generate_type(type_def: &TypeDef, is_rec: bool, config: &Config) -> String {
    let mut output = String::new();
    let keyword = if is_rec { "type rec" } else { "type" };

    match type_def {
        TypeDef::Record { name, doc, fields } => {
            if let Some(doc) = doc {
                output.push_str(&format!("/** {} */\n", doc));
            }

            let type_name = name.to_lower_camel_case();
            output.push_str(&format!("{} {} = {{\n", keyword, type_name));

            for field in fields {
                if let Some(doc) = &field.doc {
                    output.push_str(&format!("  /** {} */\n", doc));
                }

                // Use @as for JSON field mapping if different
                if field.name != field.original_name {
                    output.push_str(&format!("  @as(\"{}\") ", field.original_name));
                } else {
                    output.push_str("  ");
                }

                output.push_str(&format!("{}: {},\n", field.name, field.ty.to_rescript()));
            }

            output.push_str("}\n");
        }

        TypeDef::Variant { name, doc, cases } => {
            if let Some(doc) = doc {
                output.push_str(&format!("/** {} */\n", doc));
            }

            let type_name = name.to_lower_camel_case();
            let has_payloads = cases.iter().any(|c| c.payload.is_some());

            if has_payloads {
                // oneOf/anyOf with payloads - generate regular variant type
                // type pet = Cat(cat) | Dog(dog)
                output.push_str(&format!("{} {} =\n", keyword, type_name));

                for case in cases {
                    match &case.payload {
                        Some(ty) => {
                            output.push_str(&format!("  | {}({})\n", case.name, ty.to_rescript()));
                        }
                        None => {
                            output.push_str(&format!("  | {}\n", case.name));
                        }
                    }
                }
            } else if config.variant_mode == VariantMode::Standard {
                // String enum - generate as standard variant with @as
                output.push_str(&format!("{} {} =\n", keyword, type_name));

                for case in cases {
                    output.push_str(&format!("  | @as(\"{}\") {}\n", case.original_name, case.name));
                }
            } else {
                // String enum - generate as polymorphic variant for better JSON interop
                output.push_str(&format!("{} {} = [\n", keyword, type_name));

                for case in cases {
                    output.push_str(&format!("  | #{}\n", case.name));
                }

                output.push_str("]\n");
            }
        }

        TypeDef::Alias { name, doc, target } => {
            if let Some(doc) = doc {
                output.push_str(&format!("/** {} */\n", doc));
            }

            let type_name = name.to_lower_camel_case();
            output.push_str(&format!("{} {} = {}\n", keyword, type_name, target.to_rescript()));
        }
    }

    output
}