Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
187 changes: 163 additions & 24 deletions bin/tx3c/src/codegen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,7 @@ where
fn schema_type_for(schema: &Value, language: &str) -> String {
if let Some(schema_map) = schema.as_object() {
if let Some(reference) = schema_map.get("$ref").and_then(|r| r.as_str()) {
let type_name = reference.rsplit('#').next().unwrap_or(reference);
return map_ref_type(type_name, language);
return map_ref_type(extract_ref_name(reference), language);
}

if let Some(schema_type) = schema_map.get("type").and_then(|t| t.as_str()) {
Expand All @@ -54,37 +53,52 @@ fn schema_type_for(schema: &Value, language: &str) -> String {
default_json_type(language)
}

/// Extracts the bare type name from a `$ref`, handling both the builtin form
/// (`…/tii#/$defs/Bytes`) and the custom-type form (`#/components/schemas/Foo`):
/// take the fragment after `#`, then its last path segment.
fn extract_ref_name(reference: &str) -> &str {
let fragment = reference.rsplit('#').next().unwrap_or(reference);
fragment.rsplit('/').next().unwrap_or(fragment)
}

/// Maps a referenced type name to a language type. Builtins map to native
/// types; any other name is a user-defined type from `components.schemas` and
/// maps to its generated name (PascalCase).
fn map_ref_type(type_name: &str, language: &str) -> String {
match language {
let builtin = match language {
"rust" => match type_name {
"Bytes" => "Vec<u8>".to_string(),
"Address" => "Address".to_string(),
"UtxoRef" => "UtxoRef".to_string(),
"AnyAsset" => "String".to_string(),
_ => default_json_type(language),
"Bytes" => Some("Vec<u8>"),
"Address" => Some("Address"),
"UtxoRef" => Some("UtxoRef"),
"AnyAsset" => Some("String"),
"Utxo" => Some("serde_json::Value"),
_ => None,
},
"typescript" => match type_name {
"Bytes" => "Uint8Array".to_string(),
"Address" => "string".to_string(),
"UtxoRef" => "string".to_string(),
"AnyAsset" => "string".to_string(),
_ => default_json_type(language),
"Bytes" => Some("Uint8Array"),
"Address" | "UtxoRef" | "AnyAsset" => Some("string"),
"Utxo" => Some("unknown"),
_ => None,
},
"python" => match type_name {
"Bytes" => "bytes".to_string(),
"Address" => "str".to_string(),
"UtxoRef" => "str".to_string(),
"AnyAsset" => "str".to_string(),
_ => default_json_type(language),
"Bytes" => Some("bytes"),
"Address" | "UtxoRef" | "AnyAsset" => Some("str"),
"Utxo" => Some("Any"),
_ => None,
},
"go" => match type_name {
"Bytes" => "[]byte".to_string(),
"Address" => "string".to_string(),
"UtxoRef" => "string".to_string(),
"AnyAsset" => "string".to_string(),
_ => default_json_type(language),
"Bytes" => Some("[]byte"),
"Address" | "UtxoRef" | "AnyAsset" => Some("string"),
"Utxo" => Some("interface{}"),
_ => None,
},
_ => default_json_type(language),
_ => None,
};

match builtin {
Some(ty) => ty.to_string(),
// Not a builtin: a user-defined type, referenced by its generated name.
None => type_name.to_case(Case::Pascal),
}
}

Expand Down Expand Up @@ -155,6 +169,107 @@ fn default_json_type(language: &str) -> String {
}
}

/// Renders the named type declarations for every entry in `components.schemas`.
///
/// Records (single-case types) get a full named struct / interface / dataclass.
/// Variants (`oneOf`) get a named but permissive alias with a TODO: tagged-union
/// codegen is deferred until the SDK can encode variant arg values (the resolver
/// side of `ParamType.custom` is not implemented yet), so emitting elaborate
/// union types the SDK cannot serialize would be premature.
fn render_component_types(schemas: &Value, language: &str) -> String {
let Some(map) = schemas.as_object() else {
return String::new();
};

let mut names: Vec<&String> = map.keys().collect();
names.sort();

let mut out = String::new();
for name in names {
let schema = &map[name];
let decl = if schema.get("oneOf").is_some() {
render_variant_alias(name, language)
} else {
render_record_type(name, schema, language)
};
out.push_str(&decl);
out.push('\n');
}
out
}

/// Collects a record's `(original field name, language type)` pairs, in the
/// declared `properties` order.
fn record_fields(schema: &Value, language: &str) -> Vec<(String, String)> {
schema
.get("properties")
.and_then(|p| p.as_object())
.map(|props| {
props
.iter()
.map(|(key, value)| (key.clone(), schema_type_for(value, language)))
.collect()
})
.unwrap_or_default()
}

fn render_record_type(name: &str, schema: &Value, language: &str) -> String {
let type_name = name.to_case(Case::Pascal);
let fields = record_fields(schema, language);

match language {
"rust" => {
let mut body = String::new();
for (field, ty) in &fields {
let snake = field.to_case(Case::Snake);
if &snake != field {
body.push_str(&format!(" #[serde(rename = \"{field}\")]\n"));
}
body.push_str(&format!(" pub {snake}: {ty},\n"));
}
format!("#[derive(Debug, Clone, Serialize)]\npub struct {type_name} {{\n{body}}}\n")
}
"typescript" => {
let mut body = String::new();
for (field, ty) in &fields {
body.push_str(&format!(" {field}: {ty};\n"));
}
format!("export type {type_name} = {{\n{body}}};\n")
}
"python" => {
let mut body = String::new();
for (field, ty) in &fields {
body.push_str(&format!(" {}: {ty}\n", field.to_case(Case::Snake)));
}
if body.is_empty() {
body.push_str(" pass\n");
}
format!("@dataclass\nclass {type_name}:\n{body}")
}
"go" => {
let mut body = String::new();
for (field, ty) in &fields {
let pascal = field.to_case(Case::Pascal);
body.push_str(&format!("\t{pascal} {ty} `json:\"{field}\"`\n"));
}
format!("type {type_name} struct {{\n{body}}}\n")
}
_ => String::new(),
}
}

fn render_variant_alias(name: &str, language: &str) -> String {
let type_name = name.to_case(Case::Pascal);
let todo = "TODO: tagged-union codegen pending the variant arg encoder";
match language {
"rust" => format!("// {todo}\npub type {type_name} = serde_json::Value;\n"),
"typescript" => format!("// {todo}\nexport type {type_name} = unknown;\n"),
"python" => format!("# {todo}\n{type_name} = Any\n"),
"go" => format!("// {todo}\ntype {type_name} = interface{{}}\n"),
_ => String::new(),
}
}

fn register_helpers(handlebars: &mut Handlebars<'_>) {
#[allow(clippy::type_complexity)]
let helpers: &[(&str, fn(&str) -> String)] = &[
Expand Down Expand Up @@ -195,6 +310,30 @@ fn register_helpers(handlebars: &mut Handlebars<'_>) {
),
);

handlebars.register_helper(
"componentTypes",
Box::new(
|h: &Helper,
_: &Handlebars,
_: &HbContext,
_: &mut RenderContext,
out: &mut dyn Output| {
// param(0) is `tii.components.schemas`, which is absent when the
// protocol declares no custom types — render nothing in that case.
let schemas = h.param(0).map(|p| p.value()).unwrap_or(&Value::Null);
let lang_param = h.param(1).ok_or_else(|| {
handlebars::RenderErrorReason::ParamNotFoundForIndex("componentTypes", 1)
})?;
let language = lang_param.value().as_str().ok_or_else(|| {
handlebars::RenderErrorReason::InvalidParamType("Expected language as string")
})?;

out.write(&render_component_types(schemas, language))?;
Ok(())
},
),
);

handlebars.register_helper(
"json",
Box::new(
Expand Down
110 changes: 19 additions & 91 deletions bin/tx3c/src/tii/mod.rs
Original file line number Diff line number Diff line change
@@ -1,105 +1,20 @@
use anyhow::{anyhow, Context};
use schemars::Schema;
use serde_json::{json, Value};
use serde_json::json;
use std::{
collections::{BTreeMap, HashMap, HashSet},
path::PathBuf,
};

pub mod schema;
pub mod types;

use tx3_lang::ast;
pub use types::*;

use crate::build::Args;

/// Attaches a `description` key to a JSON Schema property when the AST field
/// carries a docstring. Mutates `schema` in place and is a no-op when the
/// schema is not a JSON object (the `map_ast_type_to_json_schema` outputs are
/// always objects, but stay defensive in case the shape changes).
fn attach_description(schema: &mut Value, docstring: Option<&String>) {
let Some(doc) = docstring else { return };
if let Some(obj) = schema.as_object_mut() {
obj.insert("description".to_string(), json!(doc));
}
}

pub fn map_ast_type_to_json_schema(r#type: &tx3_lang::ast::Type) -> Value {
match r#type {
tx3_lang::ast::Type::Int => json!({"type": "integer"}),
tx3_lang::ast::Type::Bool => json!({"type": "boolean"}),
tx3_lang::ast::Type::Bytes => {
json!({ "$ref": "https://tx3.land/specs/v1beta0/tii#/$defs/Bytes" })
}
tx3_lang::ast::Type::Address => {
json!({ "$ref": "https://tx3.land/specs/v1beta0/tii#/$defs/Address" })
}
tx3_lang::ast::Type::UtxoRef => {
json!({ "$ref": "https://tx3.land/specs/v1beta0/tii#/$defs/UtxoRef" })
}
tx3_lang::ast::Type::Unit => json!({"type": "null"}),
tx3_lang::ast::Type::List(inner) => json!({
"type": "array",
"items": map_ast_type_to_json_schema(inner)
}),
tx3_lang::ast::Type::Map(_, value) => json!({
"type": "object",
"additionalProperties": map_ast_type_to_json_schema(value)
}),
tx3_lang::ast::Type::Custom(_) => json!({"type": "object"}),
tx3_lang::ast::Type::Undefined => json!({"type": "null"}),
tx3_lang::ast::Type::Utxo => {
json!({ "$ref": "https://tx3.land/specs/v1beta0/tii#/$defs/Utxo" })
}
tx3_lang::ast::Type::AnyAsset => {
json!({ "$ref": "https://tx3.land/specs/v1beta0/tii#/$defs/AnyAsset" })
}
}
}

pub fn infer_env_schema(ast: &tx3_lang::ast::Program) -> Schema {
let mut properties = serde_json::Map::new();
let mut required = Vec::new();

if let Some(env) = &ast.env {
for field in env.fields.iter() {
let mut field_schema = map_ast_type_to_json_schema(&field.r#type);
attach_description(&mut field_schema, field.docstring.as_ref());
properties.insert(field.name.clone(), field_schema);
required.push(field.name.clone());
}
}

let schema_json = json!({
"type": "object",
"properties": properties,
"required": required
});

serde_json::from_value(schema_json)
.unwrap_or_else(|_| serde_json::from_value(json!({"type": "object"})).unwrap())
}

pub fn infer_tx_params_schema(ast: &tx3_lang::ast::TxDef) -> Schema {
let mut properties = serde_json::Map::new();
let mut required = Vec::new();

for param in ast.parameters.parameters.iter() {
let mut field_schema = map_ast_type_to_json_schema(&param.r#type);
attach_description(&mut field_schema, param.docstring.as_ref());
properties.insert(param.name.value.clone(), field_schema);
required.push(param.name.value.clone());
}

let schema_json = json!({
"type": "object",
"properties": properties,
"required": required
});
use schema::{infer_env_schema, infer_tx_params_schema, SchemaCtx};

serde_json::from_value(schema_json)
.unwrap_or_else(|_| serde_json::from_value(json!({"type": "object"})).unwrap())
}
use crate::build::Args;

fn infer_available_profiles(args: &Args) -> HashSet<String> {
let mut profiles = HashSet::new();
Expand Down Expand Up @@ -198,7 +113,11 @@ fn load_dotfile(path: Option<&PathBuf>) -> anyhow::Result<BTreeMap<String, Strin
pub fn emit_tii(args: Args, ws: &tx3_lang::Workspace) -> anyhow::Result<()> {
let ast = ws.ast().ok_or(anyhow!("Failed to get AST"))?;

let env_schema = infer_env_schema(ast);
// Accumulates custom-type schemas referenced by env + params, emitted under
// `components.schemas`.
let mut ctx = SchemaCtx::new(ast);

let env_schema = infer_env_schema(&mut ctx, ast);

let mut tii = TiiFile {
tii: TiiInfo {
Expand Down Expand Up @@ -231,7 +150,7 @@ pub fn emit_tii(args: Args, ws: &tx3_lang::Workspace) -> anyhow::Result<()> {
.tir(&tx.name.value)
.ok_or_else(|| anyhow!("Failed to get TIR for transaction: {}", tx.name.value))?;

let params_schema = infer_tx_params_schema(tx);
let params_schema = infer_tx_params_schema(&mut ctx, tx);

// Convert TIR to bytes
let (bytes, version) = tx3_tir::encoding::to_bytes(tir);
Expand All @@ -254,6 +173,15 @@ pub fn emit_tii(args: Args, ws: &tx3_lang::Workspace) -> anyhow::Result<()> {
);
}

if !ctx.schemas.is_empty() {
let schemas = ctx
.schemas
.into_iter()
.map(|(name, value)| Ok((name, serde_json::from_value(value)?)))
.collect::<anyhow::Result<HashMap<String, Schema>>>()?;
tii.components = Some(Components { schemas });
}

for profile in infer_available_profiles(&args) {
let env_file = args
.profile_env_files
Expand Down
Loading
Loading