lenso-contract-codegen 0.6.4

Code generator for portable Lenso Capability contracts.
Documentation
use std::{collections::BTreeSet, fmt::Write as _};

use crate::{
    CodegenError,
    ir::{ContractIr, ErrorVariantIr, ObjectAdditionalIr, TypeIr},
};

const HEADER: &str = "// @generated by lenso-contract-codegen; do not edit.\n";

pub(super) fn generate_wit(contract: &ContractIr) -> Result<String, CodegenError> {
    if !contract.portable {
        return unsupported("Capability Descriptor is not marked portable");
    }
    let mut generator = WitGenerator::default();
    let mut functions = String::new();
    for operation in &contract.operations {
        if operation.interaction != "request" {
            return Err(CodegenError::UnsupportedInteraction {
                operation: operation.name.clone(),
                interaction: operation.interaction.clone(),
            });
        }
        let operation_type = pascal(&operation.name);
        let request = generator.ty(
            &operation.request,
            &format!("{operation_type}Request"),
            true,
        )?;
        let response = generator.ty(
            &operation.response,
            &format!("{operation_type}Response"),
            true,
        )?;
        let domain_error =
            generator.domain_error(&operation.domain_errors, &format!("{operation_type}Error"))?;
        writeln!(
            functions,
            "  {}: func(request: {}) -> result<{}, {}>;",
            kebab(&operation.name),
            request,
            response,
            domain_error
        )
        .expect("String writes cannot fail");
    }

    let (namespace, name) = wit_package(&contract.capability_id)?;
    let mut output = String::from(HEADER);
    writeln!(output, "package {namespace}:{name}@{};\n", contract.version)
        .expect("String writes cannot fail");
    output.push_str("interface capability {\n");
    for declaration in &generator.declarations {
        for line in declaration.lines() {
            writeln!(output, "  {line}").expect("String writes cannot fail");
        }
        output.push('\n');
    }
    output.push_str(&functions);
    output.push_str("}\n\nworld provider {\n  export capability;\n}\n\n");
    output.push_str("world consumer {\n  import capability;\n}\n");
    Ok(output)
}

#[derive(Debug, Default)]
struct WitGenerator {
    declarations: Vec<String>,
    declared: BTreeSet<String>,
}

impl WitGenerator {
    fn ty(&mut self, ty: &TypeIr, hint: &str, required: bool) -> Result<String, CodegenError> {
        if !required && ty.is_nullable() {
            return unsupported(format!(
                "field `{hint}` is both optional and nullable, which WIT option cannot distinguish"
            ));
        }
        let rendered = match ty {
            TypeIr::Any => return unsupported(format!("`{hint}` uses untyped JSON")),
            TypeIr::String | TypeIr::RawJson | TypeIr::Timestamp | TypeIr::Duration => {
                "string".to_owned()
            }
            TypeIr::Int64 | TypeIr::Integer => "s64".to_owned(),
            TypeIr::Uint64 => "u64".to_owned(),
            TypeIr::Bytes => "list<u8>".to_owned(),
            TypeIr::Number => "float64".to_owned(),
            TypeIr::Boolean => "bool".to_owned(),
            TypeIr::Null => return unsupported(format!("`{hint}` is always null")),
            TypeIr::Nullable(inner) => format!("option<{}>", self.ty(inner, hint, true)?),
            TypeIr::Array(item) => {
                format!("list<{}>", self.ty(item, &format!("{hint}Item"), true)?)
            }
            TypeIr::Enum { name, values } => {
                self.enumeration(name.as_deref().unwrap_or(hint), values)?
            }
            TypeIr::Object {
                name,
                fields,
                additional,
            } => {
                if !matches!(additional, ObjectAdditionalIr::Closed) {
                    return unsupported(format!(
                        "object `{hint}` permits additional properties without an exact WIT map shape"
                    ));
                }
                let hint = name.as_deref().unwrap_or(hint);
                let name = kebab(hint);
                let mut declaration = format!("record {name} {{\n");
                for field in fields {
                    let field_type = self.ty(
                        &field.ty,
                        &format!("{hint}{}", pascal(&field.name)),
                        field.required,
                    )?;
                    let field_type = if field.required {
                        field_type
                    } else {
                        format!("option<{field_type}>")
                    };
                    writeln!(declaration, "  {}: {field_type},", kebab(&field.name))
                        .expect("String writes cannot fail");
                }
                declaration.push('}');
                self.declare(&name, declaration)?;
                name
            }
        };
        Ok(rendered)
    }

    fn enumeration(&mut self, hint: &str, values: &[String]) -> Result<String, CodegenError> {
        if values.is_empty() {
            return unsupported(format!("enum `{hint}` has no cases"));
        }
        let name = kebab(hint);
        let mut declaration = format!("enum {name} {{\n");
        let mut cases = BTreeSet::new();
        for value in values {
            let case = kebab(value);
            if case.is_empty() || !cases.insert(case.clone()) {
                return unsupported(format!("enum `{hint}` has colliding WIT case `{case}`"));
            }
            writeln!(declaration, "  {case},").expect("String writes cannot fail");
        }
        declaration.push('}');
        self.declare(&name, declaration)?;
        Ok(name)
    }

    fn domain_error(
        &mut self,
        variants: &[ErrorVariantIr],
        hint: &str,
    ) -> Result<String, CodegenError> {
        if variants.is_empty() {
            return unsupported(format!("Domain Error `{hint}` has no variants"));
        }
        let name = kebab(hint);
        let mut declaration = format!("variant {name} {{\n");
        let mut cases = BTreeSet::new();
        for variant in variants {
            let case = kebab(&variant.code);
            if !cases.insert(case.clone()) {
                return unsupported(format!("Domain Error `{hint}` has colliding case `{case}`"));
            }
            if let Some(payload) = &variant.payload {
                let payload = self.ty(
                    payload,
                    &format!("{hint}{}Payload", pascal(&variant.name)),
                    variant.payload_required,
                )?;
                writeln!(declaration, "  {case}({payload}),").expect("String writes cannot fail");
            } else {
                writeln!(declaration, "  {case},").expect("String writes cannot fail");
            }
        }
        declaration.push('}');
        self.declare(&name, declaration)?;
        Ok(name)
    }

    fn declare(&mut self, name: &str, declaration: String) -> Result<(), CodegenError> {
        if !self.declared.insert(name.to_owned()) {
            return unsupported(format!("generated WIT type `{name}` collides"));
        }
        self.declarations.push(declaration);
        Ok(())
    }
}

fn wit_package(capability_id: &str) -> Result<(String, String), CodegenError> {
    let Some((identity, _major)) = capability_id.rsplit_once('@') else {
        return unsupported("Capability identity has no major suffix");
    };
    let mut segments = identity.split('.').collect::<Vec<_>>();
    let Some(name) = segments.pop() else {
        return unsupported("Capability identity is empty");
    };
    let namespace = if segments.is_empty() {
        "lenso-generated".to_owned()
    } else {
        kebab(&segments.join("-"))
    };
    Ok((namespace, kebab(name)))
}

fn kebab(value: &str) -> String {
    let mut output = String::new();
    for (index, character) in value.chars().enumerate() {
        if character.is_ascii_uppercase() {
            if index != 0 && !output.ends_with('-') {
                output.push('-');
            }
            output.push(character.to_ascii_lowercase());
        } else if character.is_ascii_alphanumeric() {
            output.push(character.to_ascii_lowercase());
        } else if !output.ends_with('-') {
            output.push('-');
        }
    }
    output.trim_matches('-').to_owned()
}

fn pascal(value: &str) -> String {
    value
        .split(|character: char| !character.is_ascii_alphanumeric())
        .filter(|segment| !segment.is_empty())
        .map(|segment| {
            let mut characters = segment.chars();
            characters.next().map_or_else(String::new, |first| {
                first.to_ascii_uppercase().to_string() + characters.as_str()
            })
        })
        .collect()
}

fn unsupported<T>(detail: impl Into<String>) -> Result<T, CodegenError> {
    Err(CodegenError::UnsupportedWit {
        detail: detail.into(),
    })
}