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::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(values) => self.enumeration(hint, values)?,
TypeIr::Object { fields, additional } => {
if !matches!(additional, ObjectAdditionalIr::Closed) {
return unsupported(format!(
"object `{hint}` permits additional properties without an exact WIT map shape"
));
}
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(),
})
}