From e03e5714ebbe64d09c47d2e041d1ad3eb2510141 Mon Sep 17 00:00:00 2001 From: Dima Date: Mon, 20 Jul 2026 15:49:22 -0700 Subject: [PATCH 001/119] Start Milestone 2: canton-codegen scaffold (Phase A) Begin the M2 code generator on a decoder-agnostic IR so the pivotal LF-decoder choice (JVM daml-lf-archive vs a native Rust decoder) stays isolated to one module. This first slice covers the decision-independent half: - ir: intermediate representation (records, fields, the DamlType sum) - map: the Daml-LF -> Rust type mapping (Party/ContractId/Numeric/List/ Optional/TextMap/GenMap/... + references and type parameters) - emit: record -> struct generation via quote/prettyplease, with Rust keyword escaping and snake_case field names - generate_record verifies its own output parses as valid Rust (syn) Includes the M2 execution plan (planning/milestone-2-plan.md) and a runnable example. Tests + clippy green. --- Cargo.lock | 11 +++ Cargo.toml | 6 ++ crates/canton-codegen/Cargo.toml | 24 +++++ crates/canton-codegen/examples/demo.rs | 47 ++++++++++ crates/canton-codegen/src/emit.rs | 124 +++++++++++++++++++++++++ crates/canton-codegen/src/ir.rs | 73 +++++++++++++++ crates/canton-codegen/src/lib.rs | 108 +++++++++++++++++++++ crates/canton-codegen/src/map.rs | 66 +++++++++++++ 8 files changed, 459 insertions(+) create mode 100644 crates/canton-codegen/Cargo.toml create mode 100644 crates/canton-codegen/examples/demo.rs create mode 100644 crates/canton-codegen/src/emit.rs create mode 100644 crates/canton-codegen/src/ir.rs create mode 100644 crates/canton-codegen/src/lib.rs create mode 100644 crates/canton-codegen/src/map.rs diff --git a/Cargo.lock b/Cargo.lock index 5be5f9c..fa480e0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -178,6 +178,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "canton-codegen" +version = "0.1.0" +dependencies = [ + "heck", + "prettyplease", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "canton-core" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 7db057c..684e224 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,6 +50,12 @@ uuid = { version = "1", features = ["v4"] } tonic-prost-build = { version = "0.14" } protoc-bin-vendored = { version = "3" } walkdir = { version = "2" } +# codegen (M2): Rust source emission + formatting + case conversion +proc-macro2 = { version = "1" } +quote = { version = "1" } +syn = { version = "2", features = ["full", "parsing"] } +prettyplease = { version = "0.2" } +heck = { version = "0.5" } [workspace.lints.rust] unsafe_code = "forbid" diff --git a/crates/canton-codegen/Cargo.toml b/crates/canton-codegen/Cargo.toml new file mode 100644 index 0000000..724275c --- /dev/null +++ b/crates/canton-codegen/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "canton-codegen" +version = { workspace = true } +edition = { workspace = true } +rust-version = { workspace = true } +license = { workspace = true } +readme = { workspace = true } +authors = { workspace = true } +repository = { workspace = true } +homepage = { workspace = true } +categories = { workspace = true } +keywords = { workspace = true } +description = "Type-safe Rust code generation from Daml packages (Canton SDK, Milestone 2 — work in progress)." +publish = false + +[dependencies] +proc-macro2 = { workspace = true } +quote = { workspace = true } +syn = { workspace = true } +prettyplease = { workspace = true } +heck = { workspace = true } + +[lints] +workspace = true diff --git a/crates/canton-codegen/examples/demo.rs b/crates/canton-codegen/examples/demo.rs new file mode 100644 index 0000000..afa4595 --- /dev/null +++ b/crates/canton-codegen/examples/demo.rs @@ -0,0 +1,47 @@ +//! Print the Rust generated for a sample Daml record. +//! +//! `cargo run -p canton-codegen --example demo` + +use canton_codegen::generate_record; +use canton_codegen::ir::{DamlType, Field, Record, TypeRef}; + +fn main() { + let record = Record { + name: "AppInstallRequest".to_string(), + type_params: Vec::new(), + fields: vec![ + Field { + label: "provider".to_string(), + ty: DamlType::Party, + }, + Field { + label: "installId".to_string(), + ty: DamlType::Text, + }, + Field { + label: "amount".to_string(), + ty: DamlType::Numeric(10), + }, + Field { + label: "wallet".to_string(), + ty: DamlType::ContractId(Box::new(DamlType::Ref(TypeRef { + name: "Wallet".to_string(), + args: Vec::new(), + }))), + }, + Field { + label: "tags".to_string(), + ty: DamlType::List(Box::new(DamlType::Text)), + }, + Field { + label: "note".to_string(), + ty: DamlType::Optional(Box::new(DamlType::Text)), + }, + ], + }; + + match generate_record(&record) { + Ok(src) => print!("{src}"), + Err(e) => eprintln!("codegen error: {e}"), + } +} diff --git a/crates/canton-codegen/src/emit.rs b/crates/canton-codegen/src/emit.rs new file mode 100644 index 0000000..cc884a9 --- /dev/null +++ b/crates/canton-codegen/src/emit.rs @@ -0,0 +1,124 @@ +//! Emit Rust source items from the [`crate::ir`] types. + +use heck::{ToSnakeCase, ToUpperCamelCase}; +use proc_macro2::{Ident, Span, TokenStream}; +use quote::quote; + +use crate::ir::Record; +use crate::map::rust_type; + +/// Generate the `struct` for a record data type (also used for template +/// payloads). Field names are snake-cased for Rust; the original Daml label is +/// kept in a doc comment (the serde/`Value` codec that pins the wire name lands +/// in Phase C). +#[must_use] +pub fn record_struct(record: &Record) -> TokenStream { + let name = type_ident(&record.name); + let generics = generics(&record.type_params); + let fields = record.fields.iter().map(|field| { + let field_name = field_ident(&field.label); + let ty = rust_type(&field.ty); + let doc = format!("The Daml `{}` field.", field.label); + quote! { + #[doc = #doc] + pub #field_name: #ty, + } + }); + + quote! { + #[derive(Clone, Debug, PartialEq)] + pub struct #name #generics { + #(#fields)* + } + } +} + +/// The generic parameter list `` for a type's parameters, or empty tokens +/// when the type is not generic. +fn generics(type_params: &[String]) -> TokenStream { + if type_params.is_empty() { + return TokenStream::new(); + } + let params = type_params.iter().map(|param| type_ident(param)); + quote!(<#(#params),*>) +} + +/// A Rust identifier for a type name or type parameter (Daml `PascalCase`; a +/// lowercase type variable like `a` becomes `A` for Rust convention). +#[must_use] +pub fn type_ident(name: &str) -> Ident { + ident(&name.to_upper_camel_case()) +} + +/// A Rust identifier for a record field: the Daml label, snake-cased, with Rust +/// keywords escaped so labels like `type` stay valid. +fn field_ident(label: &str) -> Ident { + ident(&label.to_snake_case()) +} + +/// Build an [`Ident`], escaping Rust keywords. Most keywords become raw +/// identifiers (`r#type`); the four that cannot be raw are suffixed with `_`. +fn ident(name: &str) -> Ident { + match name { + "crate" | "self" | "Self" | "super" => Ident::new(&format!("{name}_"), Span::call_site()), + _ if is_keyword(name) => Ident::new_raw(name, Span::call_site()), + _ => Ident::new(name, Span::call_site()), + } +} + +/// Whether `name` is a Rust keyword (strict + reserved) that must be escaped. +fn is_keyword(name: &str) -> bool { + matches!( + name, + "as" | "break" + | "const" + | "continue" + | "crate" + | "dyn" + | "else" + | "enum" + | "extern" + | "false" + | "fn" + | "for" + | "if" + | "impl" + | "in" + | "let" + | "loop" + | "match" + | "mod" + | "move" + | "mut" + | "pub" + | "ref" + | "return" + | "self" + | "Self" + | "static" + | "struct" + | "super" + | "trait" + | "true" + | "type" + | "unsafe" + | "use" + | "where" + | "while" + | "async" + | "await" + | "abstract" + | "become" + | "box" + | "do" + | "final" + | "macro" + | "override" + | "priv" + | "typeof" + | "unsized" + | "virtual" + | "yield" + | "try" + ) +} diff --git a/crates/canton-codegen/src/ir.rs b/crates/canton-codegen/src/ir.rs new file mode 100644 index 0000000..69126f1 --- /dev/null +++ b/crates/canton-codegen/src/ir.rs @@ -0,0 +1,73 @@ +//! A decoder-agnostic intermediate representation (IR) of Daml types. +//! +//! The IR is the seam between "decode Daml-LF" (Phase B — JVM `daml-lf-archive` +//! or a native decoder) and "emit Rust" (this crate's generator). Neither side +//! knows about the other: a decoder produces this IR, the generator consumes it. +//! That keeps the pivotal LF-decoder decision isolated to one module. + +/// A Daml type — a primitive, a container, or a reference to a named data type. +/// +/// This is the type a record field, choice argument, or contract key can take. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum DamlType { + /// The `Unit` type `()`. + Unit, + /// `Bool`. + Bool, + /// `Int64`. + Int64, + /// `Numeric n` — a fixed-scale decimal; the value is the scale (decimals). + Numeric(u8), + /// `Text`. + Text, + /// `Timestamp` (microseconds since the Unix epoch, UTC). + Timestamp, + /// `Date` (days since the Unix epoch). + Date, + /// `Party`. + Party, + /// `ContractId t` — a handle to a contract of the referenced payload type. + ContractId(Box), + /// `List t` / `[t]`. + List(Box), + /// `Optional t`. + Optional(Box), + /// `TextMap t` — a map keyed by `Text`. + TextMap(Box), + /// `GenMap k v` — a map with arbitrary key type. + GenMap(Box, Box), + /// A reference to a named data type (record / variant / enum). + Ref(TypeRef), + /// A type parameter (`a`, `b`, …) inside a generic data type. + Var(String), +} + +/// A reference to a named Daml data type, with any applied type arguments. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TypeRef { + /// The data type's name (PascalCase, as in Daml). + pub name: String, + /// Applied type arguments, if the referenced type is generic. + pub args: Vec, +} + +/// One field of a record. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Field { + /// The Daml field label, in its source casing (usually camelCase). + pub label: String, + /// The field's type. + pub ty: DamlType, +} + +/// A record data type. Template payloads are records too, so this is reused for +/// both a plain `data … = … with` record and a `template … with` payload. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Record { + /// The type name (PascalCase, as in Daml). + pub name: String, + /// Type parameters, in order, if the record is generic. + pub type_params: Vec, + /// The fields, in declaration order. + pub fields: Vec, +} diff --git a/crates/canton-codegen/src/lib.rs b/crates/canton-codegen/src/lib.rs new file mode 100644 index 0000000..1f4c054 --- /dev/null +++ b/crates/canton-codegen/src/lib.rs @@ -0,0 +1,108 @@ +//! `canton-codegen` — generate typed Rust bindings from Daml packages. +//! +//! **Milestone 2, work in progress.** This crate turns a Daml package into +//! idiomatic Rust: templates and records into structs, choices into typed +//! exercise builders, with JSON and gRPC codecs on the generated types. +//! +//! # Architecture +//! +//! A decoder-agnostic [`ir`] (intermediate representation) sits between decoding +//! Daml-LF and emitting Rust: +//! +//! ```text +//! DAR ──(decoder)──▶ ir ──(this crate)──▶ Rust source ──▶ crate +//! ``` +//! +//! The generator ([`gen`]) and the type mapping ([`map`]) consume the IR and +//! never touch Daml-LF, so the LF-decoder choice (JVM `daml-lf-archive` vs a +//! native Rust decoder) is isolated to the decoder module (Phase B). +//! +//! Current status: Phase A — IR, the Daml-LF → Rust type mapping, and record +//! emission, with a test that the generated source is valid Rust. + +pub mod emit; +pub mod ir; +pub mod map; + +use crate::ir::Record; + +/// Generate formatted Rust source for a single record data type. +/// +/// # Errors +/// Returns a [`syn::Error`] if the generated tokens are not valid Rust — that +/// would be a generator bug, so callers can treat it as such. +pub fn generate_record(record: &Record) -> Result { + let tokens = emit::record_struct(record); + let file: syn::File = syn::parse2(tokens)?; + Ok(prettyplease::unparse(&file)) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use super::*; + use crate::ir::{DamlType, Field, Record, TypeRef}; + + fn field(label: &str, ty: DamlType) -> Field { + Field { + label: label.to_string(), + ty, + } + } + + #[test] + fn generates_valid_rust_for_a_record() { + let record = Record { + name: "AppInstallRequest".to_string(), + type_params: Vec::new(), + fields: vec![ + field("provider", DamlType::Party), + field("installId", DamlType::Text), + field("amount", DamlType::Numeric(10)), + field( + "cid", + DamlType::ContractId(Box::new(DamlType::Ref(TypeRef { + name: "Foo".to_string(), + args: Vec::new(), + }))), + ), + field("tags", DamlType::List(Box::new(DamlType::Text))), + field("note", DamlType::Optional(Box::new(DamlType::Text))), + // A Daml field whose name collides with a Rust keyword. + field("type", DamlType::Text), + ], + }; + + let src = generate_record(&record).expect("generator emits valid Rust"); + // The strongest guarantee: the output parses as a Rust file. + syn::parse_file(&src).expect("output must be valid Rust"); + + // Spot-check the type mapping, snake_case, and keyword escaping. + assert!(src.contains("pub struct AppInstallRequest"), "{src}"); + assert!(src.contains("pub provider: rt::Party"), "{src}"); + assert!(src.contains("pub install_id: String"), "{src}"); + assert!(src.contains("pub amount: rt::Numeric"), "{src}"); + assert!(src.contains("rt::ContractId"), "{src}"); + assert!(src.contains("Vec"), "{src}"); + assert!(src.contains("Option"), "{src}"); + assert!(src.contains("r#type: String"), "{src}"); + } + + #[test] + fn generic_record_carries_type_params() { + let record = Record { + name: "Pair".to_string(), + type_params: vec!["a".to_string(), "b".to_string()], + fields: vec![ + field("fst", DamlType::Var("a".to_string())), + field("snd", DamlType::Var("b".to_string())), + ], + }; + + let src = generate_record(&record).unwrap(); + syn::parse_file(&src).unwrap(); + // Lowercase Daml vars are upper-camel-cased to Rust generics. + assert!(src.contains("pub struct Pair"), "{src}"); + assert!(src.contains("pub fst: A"), "{src}"); + } +} diff --git a/crates/canton-codegen/src/map.rs b/crates/canton-codegen/src/map.rs new file mode 100644 index 0000000..6548b1b --- /dev/null +++ b/crates/canton-codegen/src/map.rs @@ -0,0 +1,66 @@ +//! The Daml-LF → Rust **type mapping** (the documented M2 deliverable, as code). +//! +//! Container and primitive types map to `std` / runtime types; references map to +//! the generated type's name. The runtime types (`Party`, `ContractId`, +//! `Numeric`, `Timestamp`, `Date`, `TextMap`, `GenMap`) are provided by a small +//! runtime crate the generated code depends on, reached here through the `rt` +//! path — a placeholder until Phase C stands that crate up. The mapping itself +//! is decoder-independent, so it is stable regardless of the LF-decoder choice. + +use proc_macro2::TokenStream; +use quote::quote; + +use crate::emit::type_ident; +use crate::ir::{DamlType, TypeRef}; + +/// Map a [`DamlType`] to the Rust type that represents it in generated code. +#[must_use] +pub fn rust_type(ty: &DamlType) -> TokenStream { + match ty { + DamlType::Unit => quote!(()), + DamlType::Bool => quote!(bool), + DamlType::Int64 => quote!(i64), + DamlType::Numeric(_) => quote!(rt::Numeric), + DamlType::Text => quote!(String), + DamlType::Timestamp => quote!(rt::Timestamp), + DamlType::Date => quote!(rt::Date), + DamlType::Party => quote!(rt::Party), + DamlType::ContractId(inner) => { + let inner = rust_type(inner); + quote!(rt::ContractId<#inner>) + } + DamlType::List(inner) => { + let inner = rust_type(inner); + quote!(Vec<#inner>) + } + DamlType::Optional(inner) => { + let inner = rust_type(inner); + quote!(Option<#inner>) + } + DamlType::TextMap(inner) => { + let inner = rust_type(inner); + quote!(rt::TextMap<#inner>) + } + DamlType::GenMap(key, value) => { + let key = rust_type(key); + let value = rust_type(value); + quote!(rt::GenMap<#key, #value>) + } + DamlType::Ref(reference) => rust_ref(reference), + DamlType::Var(name) => { + let ident = type_ident(name); + quote!(#ident) + } + } +} + +/// A reference to a named data type, applying any type arguments. +fn rust_ref(reference: &TypeRef) -> TokenStream { + let name = type_ident(&reference.name); + if reference.args.is_empty() { + quote!(#name) + } else { + let args = reference.args.iter().map(rust_type); + quote!(#name<#(#args),*>) + } +} From bfa8cff4fcaa5a2b016de2484fb2882b395112bb Mon Sep 17 00:00:00 2001 From: Dima Date: Mon, 20 Jul 2026 21:49:01 -0700 Subject: [PATCH 002/119] M2 Phase A: variants, enums, templates + typed choices Extend canton-codegen's IR and emitter beyond records: - ir: DataType (Record/Variant/Enum), Variant/VariantConstructor, Enum, Template, Choice - emit: variant -> Rust enum (payload or nullary constructors), enum -> C-like enum, template -> payload struct + a typed `rt::Choice` impl per choice (arg -> template -> return, NAME/CONSUMING) - fix identifier handling: Daml type/constructor names are used as-is (they are already valid Rust idents); only type *variables* are upper-camel-cased (a -> A), so names with `_` are no longer mangled - generate_data_type / generate_template, each verifying valid Rust (syn) 5 tests, demo covers template/variant/enum. Plan updated with the decoder evaluation (fujiapple daml-lf is LF-1.14/archived; Phase B -> a native LF-2.x decoder blueprinted on its design). --- crates/canton-codegen/examples/demo.rs | 103 +++++++++++++------ crates/canton-codegen/src/emit.rs | 122 ++++++++++++++++++++++- crates/canton-codegen/src/ir.rs | 66 +++++++++++++ crates/canton-codegen/src/lib.rs | 132 +++++++++++++++++++++++-- crates/canton-codegen/src/map.rs | 4 +- 5 files changed, 380 insertions(+), 47 deletions(-) diff --git a/crates/canton-codegen/examples/demo.rs b/crates/canton-codegen/examples/demo.rs index afa4595..e7139cb 100644 --- a/crates/canton-codegen/examples/demo.rs +++ b/crates/canton-codegen/examples/demo.rs @@ -1,46 +1,85 @@ -//! Print the Rust generated for a sample Daml record. +//! Print the Rust generated for a sample Daml template, variant, and enum. //! //! `cargo run -p canton-codegen --example demo` -use canton_codegen::generate_record; -use canton_codegen::ir::{DamlType, Field, Record, TypeRef}; +use canton_codegen::ir::{ + Choice, DamlType, Enum, Field, Template, TypeRef, Variant, VariantConstructor, +}; +use canton_codegen::{generate_data_type, generate_template}; + +fn field(label: &str, ty: DamlType) -> Field { + Field { + label: label.to_string(), + ty, + } +} + +fn reference(name: &str) -> DamlType { + DamlType::Ref(TypeRef { + name: name.to_string(), + args: Vec::new(), + }) +} fn main() { - let record = Record { - name: "AppInstallRequest".to_string(), - type_params: Vec::new(), + // A template with a payload and one consuming choice. + let template = Template { + name: "AppInstall".to_string(), fields: vec![ - Field { - label: "provider".to_string(), - ty: DamlType::Party, - }, - Field { - label: "installId".to_string(), - ty: DamlType::Text, - }, - Field { - label: "amount".to_string(), - ty: DamlType::Numeric(10), - }, - Field { - label: "wallet".to_string(), - ty: DamlType::ContractId(Box::new(DamlType::Ref(TypeRef { - name: "Wallet".to_string(), - args: Vec::new(), - }))), + field("provider", DamlType::Party), + field("user", DamlType::Party), + field("amount", DamlType::Numeric(10)), + field("tags", DamlType::List(Box::new(DamlType::Text))), + ], + choices: vec![Choice { + name: "Accept".to_string(), + consuming: true, + argument: reference("AppInstall_Accept"), + returns: DamlType::ContractId(Box::new(reference("AppInstalled"))), + }], + key: None, + }; + + // A variant (sum) type. + let variant = canton_codegen::ir::DataType::Variant(Variant { + name: "Shape".to_string(), + type_params: Vec::new(), + constructors: vec![ + VariantConstructor { + name: "Circle".to_string(), + payload: Some(DamlType::Numeric(10)), }, - Field { - label: "tags".to_string(), - ty: DamlType::List(Box::new(DamlType::Text)), + VariantConstructor { + name: "Rectangle".to_string(), + payload: Some(reference("Dimensions")), }, - Field { - label: "note".to_string(), - ty: DamlType::Optional(Box::new(DamlType::Text)), + VariantConstructor { + name: "Point".to_string(), + payload: None, }, ], - }; + }); + + // A plain enum. + let enumeration = canton_codegen::ir::DataType::Enum(Enum { + name: "DayOfWeek".to_string(), + constructors: vec![ + "Monday".to_string(), + "Tuesday".to_string(), + "Wednesday".to_string(), + ], + }); + + println!("// ── template ──"); + emit(generate_template(&template)); + println!("\n// ── variant ──"); + emit(generate_data_type(&variant)); + println!("\n// ── enum ──"); + emit(generate_data_type(&enumeration)); +} - match generate_record(&record) { +fn emit(result: Result) { + match result { Ok(src) => print!("{src}"), Err(e) => eprintln!("codegen error: {e}"), } diff --git a/crates/canton-codegen/src/emit.rs b/crates/canton-codegen/src/emit.rs index cc884a9..37334c0 100644 --- a/crates/canton-codegen/src/emit.rs +++ b/crates/canton-codegen/src/emit.rs @@ -4,9 +4,19 @@ use heck::{ToSnakeCase, ToUpperCamelCase}; use proc_macro2::{Ident, Span, TokenStream}; use quote::quote; -use crate::ir::Record; +use crate::ir::{DataType, Enum, Record, Template, Variant}; use crate::map::rust_type; +/// Emit the Rust item(s) for a named data type (record, variant, or enum). +#[must_use] +pub fn data_type(data_type: &DataType) -> TokenStream { + match data_type { + DataType::Record(record) => record_struct(record), + DataType::Variant(variant) => variant_enum(variant), + DataType::Enum(enumeration) => enum_type(enumeration), + } +} + /// Generate the `struct` for a record data type (also used for template /// payloads). Field names are snake-cased for Rust; the original Daml label is /// kept in a doc comment (the serde/`Value` codec that pins the wire name lands @@ -33,20 +43,124 @@ pub fn record_struct(record: &Record) -> TokenStream { } } +/// Emit a variant (sum) type as a Rust `enum` — one variant per constructor, +/// carrying the constructor's payload type (or nothing for a nullary one). +#[must_use] +pub fn variant_enum(variant: &Variant) -> TokenStream { + let name = type_ident(&variant.name); + let generics = generics(&variant.type_params); + let constructors = variant.constructors.iter().map(|ctor| { + let ctor_name = type_ident(&ctor.name); + let doc = format!("The Daml `{}` constructor.", ctor.name); + if let Some(payload) = &ctor.payload { + let payload = rust_type(payload); + quote! { + #[doc = #doc] + #ctor_name(#payload), + } + } else { + quote! { + #[doc = #doc] + #ctor_name, + } + } + }); + + quote! { + #[derive(Clone, Debug, PartialEq)] + pub enum #name #generics { + #(#constructors)* + } + } +} + +/// Emit an enumeration as a C-like Rust `enum` (constructors carry no data). +#[must_use] +pub fn enum_type(enumeration: &Enum) -> TokenStream { + let name = type_ident(&enumeration.name); + let constructors = enumeration.constructors.iter().map(|ctor| { + let ctor_name = type_ident(ctor); + let doc = format!("The Daml `{ctor}` value."); + quote! { + #[doc = #doc] + #ctor_name, + } + }); + + quote! { + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + pub enum #name { + #(#constructors)* + } + } +} + +/// Emit a template: its payload `struct` plus a typed `rt::Choice` impl for each +/// choice, linking the choice-argument type to the template and its return type. +/// (The template identifier — package/module/entity — and the create/exercise +/// command builders arrive with the runtime crate in Phase C.) +#[must_use] +pub fn template(template: &Template) -> TokenStream { + let payload = record_struct(&Record { + name: template.name.clone(), + type_params: Vec::new(), + fields: template.fields.clone(), + }); + let self_ty = type_ident(&template.name); + + let choices = template.choices.iter().map(|choice| { + let argument = rust_type(&choice.argument); + let returns = rust_type(&choice.returns); + let choice_name = &choice.name; + let consuming = choice.consuming; + let doc = format!( + "The `{}` choice on [`{}`] ({}).", + choice.name, + template.name, + if choice.consuming { + "consuming" + } else { + "non-consuming" + } + ); + quote! { + #[doc = #doc] + impl rt::Choice<#self_ty> for #argument { + type Return = #returns; + const NAME: &'static str = #choice_name; + const CONSUMING: bool = #consuming; + } + } + }); + + quote! { + #payload + #(#choices)* + } +} + /// The generic parameter list `` for a type's parameters, or empty tokens /// when the type is not generic. fn generics(type_params: &[String]) -> TokenStream { if type_params.is_empty() { return TokenStream::new(); } - let params = type_params.iter().map(|param| type_ident(param)); + let params = type_params.iter().map(|param| type_var_ident(param)); quote!(<#(#params),*>) } -/// A Rust identifier for a type name or type parameter (Daml `PascalCase`; a -/// lowercase type variable like `a` becomes `A` for Rust convention). +/// A Rust identifier for a Daml type or constructor **name**. Daml type names +/// are already valid Rust identifiers, so they are used as-is (keywords +/// escaped) — not case-converted, which would mangle names containing `_`. #[must_use] pub fn type_ident(name: &str) -> Ident { + ident(name) +} + +/// A Rust identifier for a Daml **type variable** (`a` → `A`), upper-camel-cased +/// to follow Rust's generic-parameter naming convention. +#[must_use] +pub fn type_var_ident(name: &str) -> Ident { ident(&name.to_upper_camel_case()) } diff --git a/crates/canton-codegen/src/ir.rs b/crates/canton-codegen/src/ir.rs index 69126f1..7af2ecd 100644 --- a/crates/canton-codegen/src/ir.rs +++ b/crates/canton-codegen/src/ir.rs @@ -71,3 +71,69 @@ pub struct Record { /// The fields, in declaration order. pub fields: Vec, } + +/// A named data type declared in a module: a record, a variant, or an enum. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum DataType { + /// A record (product) type. + Record(Record), + /// A variant (sum) type. + Variant(Variant), + /// An enumeration (constructors carrying no payload). + Enum(Enum), +} + +/// A variant (sum) type: named constructors, each optionally carrying a payload. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Variant { + /// The type name (PascalCase). + pub name: String, + /// Type parameters, in order, if generic. + pub type_params: Vec, + /// The constructors, in declaration order. + pub constructors: Vec, +} + +/// One constructor of a [`Variant`]. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct VariantConstructor { + /// The constructor name (PascalCase). + pub name: String, + /// The payload type, or `None` for a constructor that carries no data. + pub payload: Option, +} + +/// An enumeration: named constructors that carry no payload. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Enum { + /// The type name (PascalCase). + pub name: String, + /// The constructor names, in declaration order. + pub constructors: Vec, +} + +/// A template: its payload fields, its choices, and an optional contract key. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Template { + /// The template name (PascalCase). + pub name: String, + /// The payload fields, in declaration order. + pub fields: Vec, + /// The choices exercisable on a contract of this template. + pub choices: Vec, + /// The contract key type, if the template declares a key. + pub key: Option, +} + +/// A choice on a [`Template`]. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Choice { + /// The choice name (PascalCase). + pub name: String, + /// Whether exercising the choice archives the contract. + pub consuming: bool, + /// The choice argument type (usually a reference to a record). + pub argument: DamlType, + /// The type the choice returns. + pub returns: DamlType, +} diff --git a/crates/canton-codegen/src/lib.rs b/crates/canton-codegen/src/lib.rs index 1f4c054..f5aadf1 100644 --- a/crates/canton-codegen/src/lib.rs +++ b/crates/canton-codegen/src/lib.rs @@ -13,30 +13,57 @@ //! DAR ──(decoder)──▶ ir ──(this crate)──▶ Rust source ──▶ crate //! ``` //! -//! The generator ([`gen`]) and the type mapping ([`map`]) consume the IR and +//! The generator ([`emit`]) and the type mapping ([`map`]) consume the IR and //! never touch Daml-LF, so the LF-decoder choice (JVM `daml-lf-archive` vs a //! native Rust decoder) is isolated to the decoder module (Phase B). //! -//! Current status: Phase A — IR, the Daml-LF → Rust type mapping, and record -//! emission, with a test that the generated source is valid Rust. +//! Current status: Phase A — the IR, the Daml-LF → Rust type mapping, and +//! emission of records, variants, enums, and templates (with typed choice +//! impls); every generator verifies its output is valid Rust. pub mod emit; pub mod ir; pub mod map; -use crate::ir::Record; +use proc_macro2::TokenStream; -/// Generate formatted Rust source for a single record data type. +use crate::ir::{DataType, Record, Template}; + +/// Format a stream of generated items as Rust source, first checking it is +/// syntactically valid Rust. /// /// # Errors -/// Returns a [`syn::Error`] if the generated tokens are not valid Rust — that -/// would be a generator bug, so callers can treat it as such. -pub fn generate_record(record: &Record) -> Result { - let tokens = emit::record_struct(record); +/// Returns a [`syn::Error`] if the tokens are not valid Rust — a generator bug. +fn format_items(tokens: TokenStream) -> Result { let file: syn::File = syn::parse2(tokens)?; Ok(prettyplease::unparse(&file)) } +/// Generate formatted Rust source for a single record data type. +/// +/// # Errors +/// Returns a [`syn::Error`] if the generated tokens are not valid Rust. +pub fn generate_record(record: &Record) -> Result { + format_items(emit::record_struct(record)) +} + +/// Generate formatted Rust source for a named data type (record / variant / enum). +/// +/// # Errors +/// Returns a [`syn::Error`] if the generated tokens are not valid Rust. +pub fn generate_data_type(data_type: &DataType) -> Result { + format_items(emit::data_type(data_type)) +} + +/// Generate formatted Rust source for a template: its payload struct plus the +/// typed choice impls. +/// +/// # Errors +/// Returns a [`syn::Error`] if the generated tokens are not valid Rust. +pub fn generate_template(template: &Template) -> Result { + format_items(emit::template(template)) +} + #[cfg(test)] #[allow(clippy::unwrap_used, clippy::expect_used)] mod tests { @@ -88,6 +115,93 @@ mod tests { assert!(src.contains("r#type: String"), "{src}"); } + #[test] + fn variant_generates_a_rust_enum() { + use crate::ir::{Variant, VariantConstructor}; + + let variant = Variant { + name: "Shape".to_string(), + type_params: Vec::new(), + constructors: vec![ + VariantConstructor { + name: "Circle".to_string(), + payload: Some(DamlType::Numeric(10)), + }, + VariantConstructor { + name: "Point".to_string(), + payload: None, + }, + ], + }; + + let src = generate_data_type(&DataType::Variant(variant)).unwrap(); + syn::parse_file(&src).unwrap(); + assert!(src.contains("pub enum Shape"), "{src}"); + assert!(src.contains("Circle(rt::Numeric)"), "{src}"); + // A nullary constructor has no payload. + assert!(src.contains("Point,"), "{src}"); + } + + #[test] + fn enum_generates_a_c_like_enum() { + use crate::ir::Enum; + + let enumeration = Enum { + name: "DayOfWeek".to_string(), + constructors: vec!["Monday".to_string(), "Tuesday".to_string()], + }; + + let src = generate_data_type(&DataType::Enum(enumeration)).unwrap(); + syn::parse_file(&src).unwrap(); + assert!(src.contains("pub enum DayOfWeek"), "{src}"); + assert!(src.contains("Monday,"), "{src}"); + // C-like enums are `Copy`. + assert!(src.contains("Copy"), "{src}"); + } + + #[test] + fn template_generates_payload_and_typed_choices() { + use crate::ir::{Choice, Template, TypeRef}; + + let template = Template { + name: "AppInstall".to_string(), + fields: vec![field("provider", DamlType::Party)], + choices: vec![Choice { + name: "Accept".to_string(), + consuming: true, + argument: DamlType::Ref(TypeRef { + name: "AppInstall_Accept".to_string(), + args: Vec::new(), + }), + returns: DamlType::ContractId(Box::new(DamlType::Ref(TypeRef { + name: "AppInstalled".to_string(), + args: Vec::new(), + }))), + }], + key: None, + }; + + let src = generate_template(&template).unwrap(); + syn::parse_file(&src).unwrap(); + // The payload struct… + assert!(src.contains("pub struct AppInstall"), "{src}"); + assert!(src.contains("pub provider: rt::Party"), "{src}"); + // …and the typed choice impl linking arg → template → return. + assert!( + src.contains("impl rt::Choice for AppInstall_Accept"), + "{src}" + ); + assert!( + src.contains("type Return = rt::ContractId"), + "{src}" + ); + assert!( + src.contains("const NAME: &'static str = \"Accept\""), + "{src}" + ); + assert!(src.contains("const CONSUMING: bool = true"), "{src}"); + } + #[test] fn generic_record_carries_type_params() { let record = Record { diff --git a/crates/canton-codegen/src/map.rs b/crates/canton-codegen/src/map.rs index 6548b1b..b6501cd 100644 --- a/crates/canton-codegen/src/map.rs +++ b/crates/canton-codegen/src/map.rs @@ -10,7 +10,7 @@ use proc_macro2::TokenStream; use quote::quote; -use crate::emit::type_ident; +use crate::emit::{type_ident, type_var_ident}; use crate::ir::{DamlType, TypeRef}; /// Map a [`DamlType`] to the Rust type that represents it in generated code. @@ -48,7 +48,7 @@ pub fn rust_type(ty: &DamlType) -> TokenStream { } DamlType::Ref(reference) => rust_ref(reference), DamlType::Var(name) => { - let ident = type_ident(name); + let ident = type_var_ident(name); quote!(#ident) } } From c8aa54e361af509bfd6c05dc0bec34b8e129b5de Mon Sep 17 00:00:00 2001 From: Dima Date: Mon, 20 Jul 2026 22:00:24 -0700 Subject: [PATCH 003/119] M2: canton-daml runtime + module generation (increment 1) Close the codegen loop so generated types compile and move on/off the wire. New crate `canton-daml` (the `rt` runtime generated code depends on): - primitives: Party, ContractId (typed, phantom tag), Numeric, Timestamp, Date, TextMap, GenMap - Choice trait (Return / NAME / CONSUMING), matching the emitted impls - ToValue / FromValue codecs to the Ledger API `Value` (gRPC wire form) for every primitive + Option/Vec/TextMap, with typed decode errors canton-codegen: `generate_module` wraps emitted items with the module preamble (`use canton_daml as rt;` + allow-attrs for generated names like `AppInstall_Accept`), producing a ready-to-write `.rs` file. Tests: canton-daml round-trips primitives/containers/contract-ids through `Value` and checks Choice metadata + shape-mismatch errors; canton-codegen validates whole-module output is valid Rust. clippy/fmt green. --- Cargo.lock | 7 + crates/canton-codegen/src/emit.rs | 13 +- crates/canton-codegen/src/ir.rs | 9 + crates/canton-codegen/src/lib.rs | 60 ++++++- crates/canton-daml/Cargo.toml | 20 +++ crates/canton-daml/src/choice.rs | 15 ++ crates/canton-daml/src/lib.rs | 105 +++++++++++ crates/canton-daml/src/primitives.rs | 83 +++++++++ crates/canton-daml/src/value.rs | 259 +++++++++++++++++++++++++++ 9 files changed, 569 insertions(+), 2 deletions(-) create mode 100644 crates/canton-daml/Cargo.toml create mode 100644 crates/canton-daml/src/choice.rs create mode 100644 crates/canton-daml/src/lib.rs create mode 100644 crates/canton-daml/src/primitives.rs create mode 100644 crates/canton-daml/src/value.rs diff --git a/Cargo.lock b/Cargo.lock index fa480e0..19d234e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -209,6 +209,13 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "canton-daml" +version = "0.1.0" +dependencies = [ + "canton-proto", +] + [[package]] name = "canton-ledger" version = "0.1.0" diff --git a/crates/canton-codegen/src/emit.rs b/crates/canton-codegen/src/emit.rs index 37334c0..8544b3f 100644 --- a/crates/canton-codegen/src/emit.rs +++ b/crates/canton-codegen/src/emit.rs @@ -4,7 +4,7 @@ use heck::{ToSnakeCase, ToUpperCamelCase}; use proc_macro2::{Ident, Span, TokenStream}; use quote::quote; -use crate::ir::{DataType, Enum, Record, Template, Variant}; +use crate::ir::{DataType, Enum, Module, Record, Template, Variant}; use crate::map::rust_type; /// Emit the Rust item(s) for a named data type (record, variant, or enum). @@ -17,6 +17,17 @@ pub fn data_type(data_type: &DataType) -> TokenStream { } } +/// Emit every item of a module — its data types, then its templates. +#[must_use] +pub fn module_items(module: &Module) -> TokenStream { + let data_types = module.data_types.iter().map(data_type); + let templates = module.templates.iter().map(template); + quote! { + #(#data_types)* + #(#templates)* + } +} + /// Generate the `struct` for a record data type (also used for template /// payloads). Field names are snake-cased for Rust; the original Daml label is /// kept in a doc comment (the serde/`Value` codec that pins the wire name lands diff --git a/crates/canton-codegen/src/ir.rs b/crates/canton-codegen/src/ir.rs index 7af2ecd..d979ac1 100644 --- a/crates/canton-codegen/src/ir.rs +++ b/crates/canton-codegen/src/ir.rs @@ -137,3 +137,12 @@ pub struct Choice { /// The type the choice returns. pub returns: DamlType, } + +/// A module's worth of generated declarations: its data types and templates. +#[derive(Clone, Debug, PartialEq, Eq, Default)] +pub struct Module { + /// The named data types (records, variants, enums). + pub data_types: Vec, + /// The templates. + pub templates: Vec