From c0e437e0b852327d638f722a2c149355e67abf60 Mon Sep 17 00:00:00 2001 From: Santiago Date: Mon, 8 Jun 2026 14:11:02 -0300 Subject: [PATCH 1/2] feat(tii): emit JSON Schema for complex protocol types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tx3c collapsed every user-defined record/variant to a bare `{"type":"object"}` and never populated `components.schemas`, so the structure of complex types that participate in a protocol was lost. Resolve custom types by name against the program's own types/aliases and register them under `components.schemas`: records as object schemas, variants as an idiomatic tagged `oneOf`, nested types recursively, and aliases inlined transparently. Params now reference them via `$ref`. Codegen: fix the `$ref` name extraction (the `/$defs/Bytes` form yielded the wrong name, so even builtins generated as `any`) and render named types from `components.schemas` — full struct/interface/dataclass for records, permissive named aliases for variants pending the variant arg encoder. Co-Authored-By: Claude Opus 4.8 (1M context) --- bin/tx3c/src/codegen.rs | 187 ++++++++++++++++++++++---- bin/tx3c/src/tii/mod.rs | 285 ++++++++++++++++++++++++++++++++++------ 2 files changed, 408 insertions(+), 64 deletions(-) diff --git a/bin/tx3c/src/codegen.rs b/bin/tx3c/src/codegen.rs index cacbd9c..2980051 100644 --- a/bin/tx3c/src/codegen.rs +++ b/bin/tx3c/src/codegen.rs @@ -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()) { @@ -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".to_string(), - "Address" => "Address".to_string(), - "UtxoRef" => "UtxoRef".to_string(), - "AnyAsset" => "String".to_string(), - _ => default_json_type(language), + "Bytes" => Some("Vec"), + "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), } } @@ -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)] = &[ @@ -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( diff --git a/bin/tx3c/src/tii/mod.rs b/bin/tx3c/src/tii/mod.rs index 36d4881..e20924e 100644 --- a/bin/tx3c/src/tii/mod.rs +++ b/bin/tx3c/src/tii/mod.rs @@ -24,73 +24,168 @@ fn attach_description(schema: &mut Value, docstring: Option<&String>) { } } -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" }) +/// Builds JSON Schema for AST types, resolving user-defined record / variant +/// types into named entries under `components.schemas` and referencing them by +/// `$ref`. Resolution is by name against the program's own `types` / `aliases` +/// rather than via `Identifier::symbol`: the analyzer resolves symbols on param +/// and env types, but not on types nested inside other type definitions, so a +/// name lookup is the only thing that handles nesting reliably. +struct SchemaCtx<'a> { + types: HashMap<&'a str, &'a ast::TypeDef>, + aliases: HashMap<&'a str, &'a ast::AliasDef>, + schemas: BTreeMap, +} + +impl<'a> SchemaCtx<'a> { + fn new(program: &'a ast::Program) -> Self { + Self { + types: program + .types + .iter() + .map(|t| (t.name.value.as_str(), t)) + .collect(), + aliases: program + .aliases + .iter() + .map(|a| (a.name.value.as_str(), a)) + .collect(), + schemas: BTreeMap::new(), } - tx3_lang::ast::Type::Address => { - json!({ "$ref": "https://tx3.land/specs/v1beta0/tii#/$defs/Address" }) + } + + fn map_type(&mut self, r#type: &ast::Type) -> Value { + match r#type { + ast::Type::Int => json!({"type": "integer"}), + ast::Type::Bool => json!({"type": "boolean"}), + ast::Type::Bytes => { + json!({ "$ref": "https://tx3.land/specs/v1beta0/tii#/$defs/Bytes" }) + } + ast::Type::Address => { + json!({ "$ref": "https://tx3.land/specs/v1beta0/tii#/$defs/Address" }) + } + ast::Type::UtxoRef => { + json!({ "$ref": "https://tx3.land/specs/v1beta0/tii#/$defs/UtxoRef" }) + } + ast::Type::Unit => json!({"type": "null"}), + ast::Type::List(inner) => json!({ + "type": "array", + "items": self.map_type(inner) + }), + ast::Type::Map(_, value) => json!({ + "type": "object", + "additionalProperties": self.map_type(value) + }), + ast::Type::Custom(id) => self.map_custom(&id.value), + ast::Type::Undefined => json!({"type": "null"}), + ast::Type::Utxo => { + json!({ "$ref": "https://tx3.land/specs/v1beta0/tii#/$defs/Utxo" }) + } + ast::Type::AnyAsset => { + json!({ "$ref": "https://tx3.land/specs/v1beta0/tii#/$defs/AnyAsset" }) + } } - tx3_lang::ast::Type::UtxoRef => { - json!({ "$ref": "https://tx3.land/specs/v1beta0/tii#/$defs/UtxoRef" }) + } + + /// Resolves a custom type name. Aliases are transparent (inlined as their + /// target); records / variants register a schema under `components.schemas` + /// on first encounter and yield a `$ref`. An unknown name (e.g. an + /// unresolvable forward reference) falls back to a bare object so emit never + /// hard-fails. + fn map_custom(&mut self, name: &str) -> Value { + if let Some(alias) = self.aliases.get(name).copied() { + return self.map_type(&alias.alias_type); } - 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" }) + + let Some(def) = self.types.get(name).copied() else { + return json!({"type": "object"}); + }; + + if !self.schemas.contains_key(name) { + // Reserve the key before recursing so a self-referential type terminates. + self.schemas.insert(name.to_string(), Value::Null); + let schema = self.build_type_def(def); + self.schemas.insert(name.to_string(), schema); } - tx3_lang::ast::Type::AnyAsset => { - json!({ "$ref": "https://tx3.land/specs/v1beta0/tii#/$defs/AnyAsset" }) + + json!({ "$ref": format!("#/components/schemas/{name}") }) + } + + /// A single-case type is a record (a plain object of its fields); a + /// multi-case type is a tagged union, emitted as the idiomatic JSON Schema + /// `oneOf` of externally tagged case objects. Record / variant-case fields + /// carry no docstrings in the AST, so they get no `description` (unlike + /// params / env fields, which do). + fn build_type_def(&mut self, def: &ast::TypeDef) -> Value { + if def.cases.len() == 1 { + return self.case_fields(&def.cases[0]); } + + let variants: Vec = def + .cases + .iter() + .map(|case| { + let tag = case.name.value.clone(); + json!({ + "type": "object", + "additionalProperties": false, + "required": [tag.clone()], + "properties": { tag: self.case_fields(case) } + }) + }) + .collect(); + + json!({ "oneOf": variants }) + } + + /// An object schema over a case's fields (empty object when fieldless). + fn case_fields(&mut self, case: &ast::VariantCase) -> Value { + let mut properties = serde_json::Map::new(); + let mut required = Vec::new(); + + for field in case.fields.iter() { + properties.insert(field.name.value.clone(), self.map_type(&field.r#type)); + required.push(field.name.value.clone()); + } + + json!({ + "type": "object", + "properties": properties, + "required": required + }) } } -pub fn infer_env_schema(ast: &tx3_lang::ast::Program) -> Schema { +fn infer_env_schema(ctx: &mut SchemaCtx<'_>, ast: &'_ 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); + let mut field_schema = ctx.map_type(&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()) + to_object_schema(properties, required) } -pub fn infer_tx_params_schema(ast: &tx3_lang::ast::TxDef) -> Schema { +fn infer_tx_params_schema(ctx: &mut SchemaCtx<'_>, tx: &'_ 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(¶m.r#type); + for param in tx.parameters.parameters.iter() { + let mut field_schema = ctx.map_type(¶m.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()); } + to_object_schema(properties, required) +} + +fn to_object_schema(properties: serde_json::Map, required: Vec) -> Schema { let schema_json = json!({ "type": "object", "properties": properties, @@ -198,7 +293,11 @@ fn load_dotfile(path: Option<&PathBuf>) -> anyhow::Result 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 { @@ -231,7 +330,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); @@ -254,6 +353,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::>>()?; + tii.components = Some(Components { schemas }); + } + for profile in infer_available_profiles(&args) { let env_file = args .profile_env_files @@ -303,3 +411,100 @@ pub fn emit_tii(args: Args, ws: &tx3_lang::Workspace) -> anyhow::Result<()> { Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use tx3_lang::Workspace; + + fn schemas_for(src: &str) -> BTreeMap { + let mut ws = Workspace::from_string(src.to_string()); + ws.analyze().expect("analyze"); + let ast = ws.ast().expect("ast"); + + let mut ctx = SchemaCtx::new(ast); + infer_env_schema(&mut ctx, ast); + for tx in ast.txs.iter() { + infer_tx_params_schema(&mut ctx, tx); + } + ctx.schemas + } + + const PARTIES: &str = "party Sender; party Receiver;"; + const TX_TAIL: &str = "{ input source { from: Sender, min_amount: Ada(1), } \ + output { to: Receiver, amount: Ada(1), } }"; + + #[test] + fn record_param_registers_object_schema_and_ref() { + let src = format!( + "{PARTIES} type AssetClass {{ policy_id: Bytes, asset_name: Bytes, }} \ + tx t(asset: AssetClass) {TX_TAIL}" + ); + let schemas = schemas_for(&src); + + let asset = schemas.get("AssetClass").expect("AssetClass registered"); + assert_eq!(asset["type"], json!("object")); + assert_eq!(asset["required"], json!(["policy_id", "asset_name"])); + assert_eq!( + asset["properties"]["policy_id"]["$ref"], + json!("https://tx3.land/specs/v1beta0/tii#/$defs/Bytes") + ); + } + + #[test] + fn nested_custom_type_is_registered_and_referenced() { + let src = format!( + "{PARTIES} type Inner {{ x: Int, }} type Outer {{ inner: Inner, y: Int, }} \ + tx t(outer: Outer) {TX_TAIL}" + ); + let schemas = schemas_for(&src); + + assert!(schemas.contains_key("Inner")); + assert_eq!( + schemas["Outer"]["properties"]["inner"]["$ref"], + json!("#/components/schemas/Inner") + ); + assert_eq!(schemas["Inner"]["properties"]["x"]["type"], json!("integer")); + } + + #[test] + fn variant_emits_tagged_one_of() { + let src = format!( + "{PARTIES} type Action {{ Buy, Sell {{ price: Int, }}, }} \ + tx t(action: Action) {TX_TAIL}" + ); + let schemas = schemas_for(&src); + + let cases = schemas["Action"]["oneOf"].as_array().expect("oneOf array"); + assert_eq!(cases.len(), 2); + // Each branch is an externally tagged object keyed by the case name. + assert_eq!(cases[0]["required"], json!(["Buy"])); + assert_eq!(cases[1]["required"], json!(["Sell"])); + assert_eq!( + cases[1]["properties"]["Sell"]["properties"]["price"]["type"], + json!("integer") + ); + } + + #[test] + fn alias_is_inlined_transparently() { + let src = format!( + "{PARTIES} type Lovelace = Int; type Holding {{ amount: Lovelace, }} \ + tx t(h: Holding) {TX_TAIL}" + ); + let schemas = schemas_for(&src); + + // The alias resolves to its target and is not itself registered. + assert!(!schemas.contains_key("Lovelace")); + assert_eq!( + schemas["Holding"]["properties"]["amount"]["type"], + json!("integer") + ); + } + + #[test] + fn primitive_only_protocol_registers_nothing() { + let src = format!("{PARTIES} tx t(quantity: Int) {TX_TAIL}"); + assert!(schemas_for(&src).is_empty()); + } +} From 9a61c11fadfd0190e6d047b13ac91d2030d92096 Mon Sep 17 00:00:00 2001 From: Santiago Date: Mon, 8 Jun 2026 19:56:59 -0300 Subject: [PATCH 2/2] refactor(tii): extract type->JSON Schema mapping into schema module Move SchemaCtx, the env/param schema inference, and their tests out of tii/mod.rs into a dedicated tii/schema.rs. The mapping depends only on the AST and serde_json, so it decouples cleanly and sits alongside the existing tii/types.rs. Co-Authored-By: Claude Opus 4.8 (1M context) --- bin/tx3c/src/tii/mod.rs | 285 +----------------------------------- bin/tx3c/src/tii/schema.rs | 292 +++++++++++++++++++++++++++++++++++++ 2 files changed, 296 insertions(+), 281 deletions(-) create mode 100644 bin/tx3c/src/tii/schema.rs diff --git a/bin/tx3c/src/tii/mod.rs b/bin/tx3c/src/tii/mod.rs index e20924e..737b04c 100644 --- a/bin/tx3c/src/tii/mod.rs +++ b/bin/tx3c/src/tii/mod.rs @@ -1,200 +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)); - } -} - -/// Builds JSON Schema for AST types, resolving user-defined record / variant -/// types into named entries under `components.schemas` and referencing them by -/// `$ref`. Resolution is by name against the program's own `types` / `aliases` -/// rather than via `Identifier::symbol`: the analyzer resolves symbols on param -/// and env types, but not on types nested inside other type definitions, so a -/// name lookup is the only thing that handles nesting reliably. -struct SchemaCtx<'a> { - types: HashMap<&'a str, &'a ast::TypeDef>, - aliases: HashMap<&'a str, &'a ast::AliasDef>, - schemas: BTreeMap, -} - -impl<'a> SchemaCtx<'a> { - fn new(program: &'a ast::Program) -> Self { - Self { - types: program - .types - .iter() - .map(|t| (t.name.value.as_str(), t)) - .collect(), - aliases: program - .aliases - .iter() - .map(|a| (a.name.value.as_str(), a)) - .collect(), - schemas: BTreeMap::new(), - } - } - - fn map_type(&mut self, r#type: &ast::Type) -> Value { - match r#type { - ast::Type::Int => json!({"type": "integer"}), - ast::Type::Bool => json!({"type": "boolean"}), - ast::Type::Bytes => { - json!({ "$ref": "https://tx3.land/specs/v1beta0/tii#/$defs/Bytes" }) - } - ast::Type::Address => { - json!({ "$ref": "https://tx3.land/specs/v1beta0/tii#/$defs/Address" }) - } - ast::Type::UtxoRef => { - json!({ "$ref": "https://tx3.land/specs/v1beta0/tii#/$defs/UtxoRef" }) - } - ast::Type::Unit => json!({"type": "null"}), - ast::Type::List(inner) => json!({ - "type": "array", - "items": self.map_type(inner) - }), - ast::Type::Map(_, value) => json!({ - "type": "object", - "additionalProperties": self.map_type(value) - }), - ast::Type::Custom(id) => self.map_custom(&id.value), - ast::Type::Undefined => json!({"type": "null"}), - ast::Type::Utxo => { - json!({ "$ref": "https://tx3.land/specs/v1beta0/tii#/$defs/Utxo" }) - } - ast::Type::AnyAsset => { - json!({ "$ref": "https://tx3.land/specs/v1beta0/tii#/$defs/AnyAsset" }) - } - } - } - - /// Resolves a custom type name. Aliases are transparent (inlined as their - /// target); records / variants register a schema under `components.schemas` - /// on first encounter and yield a `$ref`. An unknown name (e.g. an - /// unresolvable forward reference) falls back to a bare object so emit never - /// hard-fails. - fn map_custom(&mut self, name: &str) -> Value { - if let Some(alias) = self.aliases.get(name).copied() { - return self.map_type(&alias.alias_type); - } - - let Some(def) = self.types.get(name).copied() else { - return json!({"type": "object"}); - }; - - if !self.schemas.contains_key(name) { - // Reserve the key before recursing so a self-referential type terminates. - self.schemas.insert(name.to_string(), Value::Null); - let schema = self.build_type_def(def); - self.schemas.insert(name.to_string(), schema); - } - - json!({ "$ref": format!("#/components/schemas/{name}") }) - } - - /// A single-case type is a record (a plain object of its fields); a - /// multi-case type is a tagged union, emitted as the idiomatic JSON Schema - /// `oneOf` of externally tagged case objects. Record / variant-case fields - /// carry no docstrings in the AST, so they get no `description` (unlike - /// params / env fields, which do). - fn build_type_def(&mut self, def: &ast::TypeDef) -> Value { - if def.cases.len() == 1 { - return self.case_fields(&def.cases[0]); - } - - let variants: Vec = def - .cases - .iter() - .map(|case| { - let tag = case.name.value.clone(); - json!({ - "type": "object", - "additionalProperties": false, - "required": [tag.clone()], - "properties": { tag: self.case_fields(case) } - }) - }) - .collect(); - - json!({ "oneOf": variants }) - } - - /// An object schema over a case's fields (empty object when fieldless). - fn case_fields(&mut self, case: &ast::VariantCase) -> Value { - let mut properties = serde_json::Map::new(); - let mut required = Vec::new(); - - for field in case.fields.iter() { - properties.insert(field.name.value.clone(), self.map_type(&field.r#type)); - required.push(field.name.value.clone()); - } - - json!({ - "type": "object", - "properties": properties, - "required": required - }) - } -} - -fn infer_env_schema(ctx: &mut SchemaCtx<'_>, ast: &'_ 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 = ctx.map_type(&field.r#type); - attach_description(&mut field_schema, field.docstring.as_ref()); - properties.insert(field.name.clone(), field_schema); - required.push(field.name.clone()); - } - } - - to_object_schema(properties, required) -} - -fn infer_tx_params_schema(ctx: &mut SchemaCtx<'_>, tx: &'_ ast::TxDef) -> Schema { - let mut properties = serde_json::Map::new(); - let mut required = Vec::new(); - - for param in tx.parameters.parameters.iter() { - let mut field_schema = ctx.map_type(¶m.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()); - } - - to_object_schema(properties, required) -} +use schema::{infer_env_schema, infer_tx_params_schema, SchemaCtx}; -fn to_object_schema(properties: serde_json::Map, required: Vec) -> Schema { - 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()) -} +use crate::build::Args; fn infer_available_profiles(args: &Args) -> HashSet { let mut profiles = HashSet::new(); @@ -411,100 +231,3 @@ pub fn emit_tii(args: Args, ws: &tx3_lang::Workspace) -> anyhow::Result<()> { Ok(()) } - -#[cfg(test)] -mod tests { - use super::*; - use tx3_lang::Workspace; - - fn schemas_for(src: &str) -> BTreeMap { - let mut ws = Workspace::from_string(src.to_string()); - ws.analyze().expect("analyze"); - let ast = ws.ast().expect("ast"); - - let mut ctx = SchemaCtx::new(ast); - infer_env_schema(&mut ctx, ast); - for tx in ast.txs.iter() { - infer_tx_params_schema(&mut ctx, tx); - } - ctx.schemas - } - - const PARTIES: &str = "party Sender; party Receiver;"; - const TX_TAIL: &str = "{ input source { from: Sender, min_amount: Ada(1), } \ - output { to: Receiver, amount: Ada(1), } }"; - - #[test] - fn record_param_registers_object_schema_and_ref() { - let src = format!( - "{PARTIES} type AssetClass {{ policy_id: Bytes, asset_name: Bytes, }} \ - tx t(asset: AssetClass) {TX_TAIL}" - ); - let schemas = schemas_for(&src); - - let asset = schemas.get("AssetClass").expect("AssetClass registered"); - assert_eq!(asset["type"], json!("object")); - assert_eq!(asset["required"], json!(["policy_id", "asset_name"])); - assert_eq!( - asset["properties"]["policy_id"]["$ref"], - json!("https://tx3.land/specs/v1beta0/tii#/$defs/Bytes") - ); - } - - #[test] - fn nested_custom_type_is_registered_and_referenced() { - let src = format!( - "{PARTIES} type Inner {{ x: Int, }} type Outer {{ inner: Inner, y: Int, }} \ - tx t(outer: Outer) {TX_TAIL}" - ); - let schemas = schemas_for(&src); - - assert!(schemas.contains_key("Inner")); - assert_eq!( - schemas["Outer"]["properties"]["inner"]["$ref"], - json!("#/components/schemas/Inner") - ); - assert_eq!(schemas["Inner"]["properties"]["x"]["type"], json!("integer")); - } - - #[test] - fn variant_emits_tagged_one_of() { - let src = format!( - "{PARTIES} type Action {{ Buy, Sell {{ price: Int, }}, }} \ - tx t(action: Action) {TX_TAIL}" - ); - let schemas = schemas_for(&src); - - let cases = schemas["Action"]["oneOf"].as_array().expect("oneOf array"); - assert_eq!(cases.len(), 2); - // Each branch is an externally tagged object keyed by the case name. - assert_eq!(cases[0]["required"], json!(["Buy"])); - assert_eq!(cases[1]["required"], json!(["Sell"])); - assert_eq!( - cases[1]["properties"]["Sell"]["properties"]["price"]["type"], - json!("integer") - ); - } - - #[test] - fn alias_is_inlined_transparently() { - let src = format!( - "{PARTIES} type Lovelace = Int; type Holding {{ amount: Lovelace, }} \ - tx t(h: Holding) {TX_TAIL}" - ); - let schemas = schemas_for(&src); - - // The alias resolves to its target and is not itself registered. - assert!(!schemas.contains_key("Lovelace")); - assert_eq!( - schemas["Holding"]["properties"]["amount"]["type"], - json!("integer") - ); - } - - #[test] - fn primitive_only_protocol_registers_nothing() { - let src = format!("{PARTIES} tx t(quantity: Int) {TX_TAIL}"); - assert!(schemas_for(&src).is_empty()); - } -} diff --git a/bin/tx3c/src/tii/schema.rs b/bin/tx3c/src/tii/schema.rs new file mode 100644 index 0000000..817bbda --- /dev/null +++ b/bin/tx3c/src/tii/schema.rs @@ -0,0 +1,292 @@ +//! Derivation of JSON Schema from Tx3 AST types. +//! +//! Maps env / parameter types to the JSON Schema embedded in a TII document, +//! registering user-defined record / variant types under `components.schemas` +//! and referencing them by `$ref`. + +use schemars::Schema; +use serde_json::{json, Value}; +use std::collections::{BTreeMap, HashMap}; + +use tx3_lang::ast; + +/// 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 `SchemaCtx::map_type` 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)); + } +} + +/// Builds JSON Schema for AST types, resolving user-defined record / variant +/// types into named entries under `components.schemas` and referencing them by +/// `$ref`. Resolution is by name against the program's own `types` / `aliases` +/// rather than via `Identifier::symbol`: the analyzer resolves symbols on param +/// and env types, but not on types nested inside other type definitions, so a +/// name lookup is the only thing that handles nesting reliably. +pub(super) struct SchemaCtx<'a> { + types: HashMap<&'a str, &'a ast::TypeDef>, + aliases: HashMap<&'a str, &'a ast::AliasDef>, + /// Custom-type schemas accumulated while mapping; becomes `components.schemas`. + pub(super) schemas: BTreeMap, +} + +impl<'a> SchemaCtx<'a> { + pub(super) fn new(program: &'a ast::Program) -> Self { + Self { + types: program + .types + .iter() + .map(|t| (t.name.value.as_str(), t)) + .collect(), + aliases: program + .aliases + .iter() + .map(|a| (a.name.value.as_str(), a)) + .collect(), + schemas: BTreeMap::new(), + } + } + + fn map_type(&mut self, r#type: &ast::Type) -> Value { + match r#type { + ast::Type::Int => json!({"type": "integer"}), + ast::Type::Bool => json!({"type": "boolean"}), + ast::Type::Bytes => { + json!({ "$ref": "https://tx3.land/specs/v1beta0/tii#/$defs/Bytes" }) + } + ast::Type::Address => { + json!({ "$ref": "https://tx3.land/specs/v1beta0/tii#/$defs/Address" }) + } + ast::Type::UtxoRef => { + json!({ "$ref": "https://tx3.land/specs/v1beta0/tii#/$defs/UtxoRef" }) + } + ast::Type::Unit => json!({"type": "null"}), + ast::Type::List(inner) => json!({ + "type": "array", + "items": self.map_type(inner) + }), + ast::Type::Map(_, value) => json!({ + "type": "object", + "additionalProperties": self.map_type(value) + }), + ast::Type::Custom(id) => self.map_custom(&id.value), + ast::Type::Undefined => json!({"type": "null"}), + ast::Type::Utxo => { + json!({ "$ref": "https://tx3.land/specs/v1beta0/tii#/$defs/Utxo" }) + } + ast::Type::AnyAsset => { + json!({ "$ref": "https://tx3.land/specs/v1beta0/tii#/$defs/AnyAsset" }) + } + } + } + + /// Resolves a custom type name. Aliases are transparent (inlined as their + /// target); records / variants register a schema under `components.schemas` + /// on first encounter and yield a `$ref`. An unknown name (e.g. an + /// unresolvable forward reference) falls back to a bare object so emit never + /// hard-fails. + fn map_custom(&mut self, name: &str) -> Value { + if let Some(alias) = self.aliases.get(name).copied() { + return self.map_type(&alias.alias_type); + } + + let Some(def) = self.types.get(name).copied() else { + return json!({"type": "object"}); + }; + + if !self.schemas.contains_key(name) { + // Reserve the key before recursing so a self-referential type terminates. + self.schemas.insert(name.to_string(), Value::Null); + let schema = self.build_type_def(def); + self.schemas.insert(name.to_string(), schema); + } + + json!({ "$ref": format!("#/components/schemas/{name}") }) + } + + /// A single-case type is a record (a plain object of its fields); a + /// multi-case type is a tagged union, emitted as the idiomatic JSON Schema + /// `oneOf` of externally tagged case objects. Record / variant-case fields + /// carry no docstrings in the AST, so they get no `description` (unlike + /// params / env fields, which do). + fn build_type_def(&mut self, def: &ast::TypeDef) -> Value { + if def.cases.len() == 1 { + return self.case_fields(&def.cases[0]); + } + + let variants: Vec = def + .cases + .iter() + .map(|case| { + let tag = case.name.value.clone(); + json!({ + "type": "object", + "additionalProperties": false, + "required": [tag.clone()], + "properties": { tag: self.case_fields(case) } + }) + }) + .collect(); + + json!({ "oneOf": variants }) + } + + /// An object schema over a case's fields (empty object when fieldless). + fn case_fields(&mut self, case: &ast::VariantCase) -> Value { + let mut properties = serde_json::Map::new(); + let mut required = Vec::new(); + + for field in case.fields.iter() { + properties.insert(field.name.value.clone(), self.map_type(&field.r#type)); + required.push(field.name.value.clone()); + } + + json!({ + "type": "object", + "properties": properties, + "required": required + }) + } +} + +pub(super) fn infer_env_schema(ctx: &mut SchemaCtx<'_>, ast: &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 = ctx.map_type(&field.r#type); + attach_description(&mut field_schema, field.docstring.as_ref()); + properties.insert(field.name.clone(), field_schema); + required.push(field.name.clone()); + } + } + + to_object_schema(properties, required) +} + +pub(super) fn infer_tx_params_schema(ctx: &mut SchemaCtx<'_>, tx: &ast::TxDef) -> Schema { + let mut properties = serde_json::Map::new(); + let mut required = Vec::new(); + + for param in tx.parameters.parameters.iter() { + let mut field_schema = ctx.map_type(¶m.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()); + } + + to_object_schema(properties, required) +} + +fn to_object_schema(properties: serde_json::Map, required: Vec) -> Schema { + 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()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tx3_lang::Workspace; + + fn schemas_for(src: &str) -> BTreeMap { + let mut ws = Workspace::from_string(src.to_string()); + ws.analyze().expect("analyze"); + let ast = ws.ast().expect("ast"); + + let mut ctx = SchemaCtx::new(ast); + infer_env_schema(&mut ctx, ast); + for tx in ast.txs.iter() { + infer_tx_params_schema(&mut ctx, tx); + } + ctx.schemas + } + + const PARTIES: &str = "party Sender; party Receiver;"; + const TX_TAIL: &str = "{ input source { from: Sender, min_amount: Ada(1), } \ + output { to: Receiver, amount: Ada(1), } }"; + + #[test] + fn record_param_registers_object_schema_and_ref() { + let src = format!( + "{PARTIES} type AssetClass {{ policy_id: Bytes, asset_name: Bytes, }} \ + tx t(asset: AssetClass) {TX_TAIL}" + ); + let schemas = schemas_for(&src); + + let asset = schemas.get("AssetClass").expect("AssetClass registered"); + assert_eq!(asset["type"], json!("object")); + assert_eq!(asset["required"], json!(["policy_id", "asset_name"])); + assert_eq!( + asset["properties"]["policy_id"]["$ref"], + json!("https://tx3.land/specs/v1beta0/tii#/$defs/Bytes") + ); + } + + #[test] + fn nested_custom_type_is_registered_and_referenced() { + let src = format!( + "{PARTIES} type Inner {{ x: Int, }} type Outer {{ inner: Inner, y: Int, }} \ + tx t(outer: Outer) {TX_TAIL}" + ); + let schemas = schemas_for(&src); + + assert!(schemas.contains_key("Inner")); + assert_eq!( + schemas["Outer"]["properties"]["inner"]["$ref"], + json!("#/components/schemas/Inner") + ); + assert_eq!(schemas["Inner"]["properties"]["x"]["type"], json!("integer")); + } + + #[test] + fn variant_emits_tagged_one_of() { + let src = format!( + "{PARTIES} type Action {{ Buy, Sell {{ price: Int, }}, }} \ + tx t(action: Action) {TX_TAIL}" + ); + let schemas = schemas_for(&src); + + let cases = schemas["Action"]["oneOf"].as_array().expect("oneOf array"); + assert_eq!(cases.len(), 2); + // Each branch is an externally tagged object keyed by the case name. + assert_eq!(cases[0]["required"], json!(["Buy"])); + assert_eq!(cases[1]["required"], json!(["Sell"])); + assert_eq!( + cases[1]["properties"]["Sell"]["properties"]["price"]["type"], + json!("integer") + ); + } + + #[test] + fn alias_is_inlined_transparently() { + let src = format!( + "{PARTIES} type Lovelace = Int; type Holding {{ amount: Lovelace, }} \ + tx t(h: Holding) {TX_TAIL}" + ); + let schemas = schemas_for(&src); + + // The alias resolves to its target and is not itself registered. + assert!(!schemas.contains_key("Lovelace")); + assert_eq!( + schemas["Holding"]["properties"]["amount"]["type"], + json!("integer") + ); + } + + #[test] + fn primitive_only_protocol_registers_nothing() { + let src = format!("{PARTIES} tx t(quantity: Int) {TX_TAIL}"); + assert!(schemas_for(&src).is_empty()); + } +}