Skip to main content

canton_codegen/
lib.rs

1//! `canton-codegen` — generate typed Rust bindings from Daml packages.
2//!
3//! This crate turns a DAR into idiomatic Rust: templates and records into
4//! structs, choices into typed exercise impls, interfaces into typed markers
5//! with views, with JSON and gRPC codecs on every generated type. The
6//! command-line front-end is `canton-codegen-cli` (`dpm codegen-rust`); most
7//! users drive codegen through that.
8//!
9//! # Usage
10//!
11//! [`lower_dar`] turns a `.dar` into the [`ir`], and [`generate_crate`] turns
12//! that into the source of a self-contained bindings crate:
13//!
14//! ```no_run
15//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
16//! let dar = canton_lf::Dar::open("my-app-0.1.0.dar")?;
17//! let (krate, skipped) = canton_codegen::lower_dar(&dar)?;
18//! let source = canton_codegen::generate_crate(&krate)?;
19//! for skip in &skipped {
20//!     eprintln!("warning: skipped {}", skip);
21//! }
22//! std::fs::write("src/lib.rs", source)?;
23//! # Ok(()) }
24//! ```
25//!
26//! # Architecture
27//!
28//! A decoder-agnostic [`ir`] (intermediate representation) sits between
29//! decoding Daml-LF and emitting Rust:
30//!
31//! ```text
32//! DAR ──(canton-lf: decode)──▶ lower ──▶ ir ──(emit/map)──▶ Rust source
33//! ```
34//!
35//! The generator and the type mapping consume the IR and never touch Daml-LF;
36//! lowering is the one step that reads the decoded LF AST (from `canton-lf`,
37//! which is held to the official `daml-lf-archive` reader by a conformance
38//! oracle). Every generator checks its output parses as valid Rust before
39//! returning it. The IR is public so a caller can emit something other than
40//! Rust from it, or post-process before emission; the emitter and type mapper
41//! are private (their signatures are `proc-macro2` token streams, which would
42//! otherwise become part of this crate's SemVer surface).
43//!
44//! The human-readable specification of the type mapping lives in
45//! `docs/daml-lf-type-mapping.md`.
46
47pub mod ir;
48
49mod generate;
50
51pub(crate) mod emit;
52pub(crate) mod lower;
53pub(crate) mod map;
54
55/// The Daml-LF reader this crate is built on, re-exported because its types
56/// (`Dar`, `DarError`, `DecodeError`) appear in the signatures below — so a
57/// caller never has to add `canton-lf` separately and match its version.
58pub use canton_lf;
59
60pub use generate::{GenerateError, Options, Runtime, Stats, default_crate_name, generate};
61pub use lower::{
62    ExternalPackages, Selection, SkippedType, lower_dar, lower_dar_selecting, lower_dar_with,
63    lower_packages_selecting,
64};
65
66use proc_macro2::TokenStream;
67
68/// Emitting Rust from the [`ir`] failed.
69///
70/// This means the generator produced tokens that are not valid Rust — always a
71/// bug in `canton-codegen` (or in a hand-built [`ir`]), never bad user input.
72/// The parse error is the payload; it is deliberately opaque so the `syn`
73/// dependency stays out of this crate's public API.
74#[derive(Debug)]
75pub struct CodegenError(syn::Error);
76
77impl std::fmt::Display for CodegenError {
78    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79        write!(f, "generated source is not valid Rust: {}", self.0)
80    }
81}
82
83impl std::error::Error for CodegenError {}
84
85use crate::ir::{Crate, DataType, Module, Record, Template};
86
87/// The header prepended to every generated module: lints appropriate for
88/// generated code, plus the `use canton_daml as rt;` alias the emitted `rt::…`
89/// references resolve through.
90const MODULE_PREAMBLE: &str = concat!(
91    "#![allow(non_camel_case_types, non_snake_case, unused_imports, dead_code, unused_variables, clippy::all)]\n",
92    "//! Generated Daml bindings — do not edit by hand.\n\n",
93    "use canton_daml as rt;\n\n",
94);
95
96/// The lint header for a whole generated crate. Daml names are not Rust
97/// casing and the emitter deliberately emits some unused glue, so those two
98/// lints are silenced; `clippy::all` is off because generated code is not
99/// hand-maintained and its style is not the reader's business. `unsafe_code`
100/// is forbidden outright: the emitter never writes any, and a generated crate
101/// outside this workspace does not inherit the workspace's `forbid`, so the
102/// guarantee has to travel in the file.
103const CRATE_LINTS: &str = "#![forbid(unsafe_code)]\n\
104     #![allow(non_camel_case_types, non_snake_case, unused_imports, clippy::all)]\n";
105
106/// The crate-level docs for a generated crate: what it is, how it is used, and
107/// an index of the Daml packages inside it — otherwise a reader landing on
108/// docs.rs sees twenty-odd opaque module names and no entry point.
109fn crate_docs(krate: &Crate) -> String {
110    use std::fmt::Write as _;
111
112    let mut docs = String::new();
113    docs.push_str("//! Typed Rust bindings generated from a Daml archive (DAR).\n//!\n");
114    docs.push_str(
115        "//! **Generated file — do not edit by hand.** Regenerate with\n\
116         //! `dpm-codegen-rust` from the source this crate's `Cargo.toml` records\n\
117         //! (a DAR, or a participant's package payloads); edits are lost.\n//!\n\
118         //! Each Daml package in the DAR's dependency closure is one top-level\n\
119         //! module, and each Daml module a submodule under it, so cross-package\n\
120         //! references resolve and names never collide. Templates carry typed\n\
121         //! choices; the runtime traits they implement (`Template`, `Choice`,\n\
122         //! `Contract`) and the command builders live in `canton-daml`, which the\n\
123         //! generated code imports as `rt`.\n//!\n\
124         //! # Packages in this crate\n//!\n",
125    );
126    for package in &krate.packages {
127        let modules = package.modules.len();
128        let plural = if modules == 1 { "module" } else { "modules" };
129        let _ = writeln!(docs, "//! - [`{}`] — {modules} Daml {plural}", package.name);
130    }
131    docs.push('\n');
132    docs
133}
134
135/// Format a stream of generated items as Rust source, first checking it is
136/// syntactically valid Rust.
137///
138/// # Errors
139/// Returns [`CodegenError`] if the tokens are not valid Rust — a generator bug.
140fn format_items(tokens: TokenStream) -> Result<String, CodegenError> {
141    let file: syn::File = syn::parse2(tokens).map_err(CodegenError)?;
142    Ok(prettyplease::unparse(&file))
143}
144
145/// Generate formatted Rust source for a single record data type.
146///
147/// # Errors
148/// Returns [`CodegenError`] if the generated tokens are not valid Rust.
149pub fn generate_record(record: &Record) -> Result<String, CodegenError> {
150    format_items(emit::record_items(record))
151}
152
153/// Generate formatted Rust source for a named data type (record / variant / enum).
154///
155/// # Errors
156/// Returns [`CodegenError`] if the generated tokens are not valid Rust.
157pub fn generate_data_type(data_type: &DataType) -> Result<String, CodegenError> {
158    format_items(emit::data_type(data_type))
159}
160
161/// Generate formatted Rust source for a template: its payload struct plus the
162/// typed choice impls.
163///
164/// # Errors
165/// Returns [`CodegenError`] if the generated tokens are not valid Rust.
166pub fn generate_template(template: &Template) -> Result<String, CodegenError> {
167    format_items(emit::template(template))
168}
169
170/// Generate formatted Rust source for an interface's impls (its `Contract` /
171/// `Interface` identity and typed choice impls) on its marker type.
172///
173/// # Errors
174/// Returns [`CodegenError`] if the generated tokens are not valid Rust.
175pub fn generate_interface(interface: &crate::ir::Interface) -> Result<String, CodegenError> {
176    format_items(emit::interface(interface))
177}
178
179/// Generate a complete module file: the `use canton_daml as rt;` preamble plus
180/// every data type and template, ready to write to a `.rs` file in a generated
181/// crate.
182///
183/// # Errors
184/// Returns [`CodegenError`] if the generated tokens are not valid Rust.
185pub fn generate_module(module: &Module) -> Result<String, CodegenError> {
186    let body = format_items(emit::module_items(module))?;
187    Ok(format!("{MODULE_PREAMBLE}{body}"))
188}
189
190/// Generate a whole crate's `lib.rs` from a lowered DAR: a `pub mod` tree
191/// (package → module → types) where every cross-module and cross-package
192/// reference resolves through its qualified path.
193///
194/// # Errors
195/// Returns [`CodegenError`] if the generated tokens are not valid Rust.
196pub fn generate_crate(krate: &Crate) -> Result<String, CodegenError> {
197    let body = format_items(emit::crate_items(krate))?;
198    Ok(format!("{CRATE_LINTS}{}{body}", crate_docs(krate)))
199}
200
201#[cfg(test)]
202#[allow(clippy::unwrap_used, clippy::expect_used)]
203mod tests {
204    use super::*;
205    use crate::ir::{DamlType, Field, Record, TypeRef, Variant, VariantConstructor};
206
207    fn field(label: &str, ty: DamlType) -> Field {
208        Field {
209            label: label.to_string(),
210            ty,
211        }
212    }
213
214    #[test]
215    fn generates_valid_rust_for_a_record() {
216        let record = Record {
217            name: "AppInstallRequest".to_string(),
218            type_params: Vec::new(),
219            fields: vec![
220                field("provider", DamlType::Party),
221                field("installId", DamlType::Text),
222                field("amount", DamlType::Numeric(10)),
223                field(
224                    "cid",
225                    DamlType::ContractId(Box::new(DamlType::Ref(TypeRef::local(
226                        "Foo",
227                        Vec::new(),
228                    )))),
229                ),
230                field("tags", DamlType::List(Box::new(DamlType::Text))),
231                field("note", DamlType::Optional(Box::new(DamlType::Text))),
232                // A Daml field whose name collides with a Rust keyword.
233                field("type", DamlType::Text),
234            ],
235        };
236
237        let src = generate_record(&record).expect("generator emits valid Rust");
238        // The strongest guarantee: the output parses as a Rust file.
239        syn::parse_file(&src).expect("output must be valid Rust");
240
241        // Spot-check the type mapping, snake_case, and keyword escaping.
242        assert!(src.contains("pub struct AppInstallRequest"), "{src}");
243        assert!(src.contains("pub provider: rt::Party"), "{src}");
244        assert!(
245            src.contains("pub install_id: ::std::string::String"),
246            "{src}"
247        );
248        assert!(src.contains("pub amount: rt::Numeric"), "{src}");
249        assert!(src.contains("rt::ContractId<Foo>"), "{src}");
250        assert!(src.contains("Vec<::std::string::String>"), "{src}");
251        assert!(src.contains("Option<::std::string::String>"), "{src}");
252        assert!(src.contains("r#type: ::std::string::String"), "{src}");
253    }
254
255    #[test]
256    fn only_a_renamed_field_carries_a_doc() {
257        let record = Record {
258            name: "Payload".to_string(),
259            type_params: Vec::new(),
260            fields: vec![
261                field("owner", DamlType::Party),
262                field("acceptArg", DamlType::Text),
263                field("type", DamlType::Text),
264            ],
265        };
266
267        let src = generate_record(&record).unwrap();
268        syn::parse_file(&src).unwrap();
269        // Camel-cased on the wire, snake_cased in Rust: the doc is the only
270        // place a reader sees the label they have to send.
271        assert!(src.contains("Daml field `acceptArg`"), "{src}");
272        // Same spelling in both languages — a doc here would only restate the
273        // identifier under it, on every field of every record.
274        assert!(!src.contains("Daml field `owner`"), "{src}");
275        // A raw identifier is the same name, so it is not a rename either.
276        assert!(src.contains("r#type"), "{src}");
277        assert!(!src.contains("Daml field `type`"), "{src}");
278    }
279
280    #[test]
281    fn generated_decoders_attach_the_field_name_to_a_failure() {
282        // Without this a mismatch three records down arrives as a bare
283        // "expected Text" with nothing to locate it by. `ValueError::at`
284        // prepends, so each layer contributes its own label on the way up.
285        let record = Record {
286            name: "Payload".to_string(),
287            type_params: Vec::new(),
288            fields: vec![
289                field("owner", DamlType::Party),
290                field("note", DamlType::Optional(Box::new(DamlType::Text))),
291            ],
292        };
293        let src = generate_record(&record).unwrap();
294        syn::parse_file(&src).unwrap();
295
296        // Both the required and the optional path carry it.
297        assert!(src.contains(r#".map_err(|e| e.at("owner"))"#), "{src}");
298        assert!(src.contains(r#".map_err(|e| e.at("note"))"#), "{src}");
299        // …and it wraps the *decode*, not the lookup: `required_field` already
300        // names the field it could not find, so wrapping that too would say it
301        // twice.
302        assert!(
303            src.contains(r#"rt::required_field(value, 0usize, "owner")?)"#),
304            "{src}"
305        );
306    }
307
308    #[test]
309    fn record_emits_value_codecs() {
310        let record = Record {
311            name: "Payload".to_string(),
312            type_params: Vec::new(),
313            fields: vec![
314                field("owner", DamlType::Party),
315                field("count", DamlType::Int64),
316            ],
317        };
318
319        let src = generate_record(&record).unwrap();
320        syn::parse_file(&src).unwrap();
321        assert!(src.contains("impl rt::ToValue for Payload"), "{src}");
322        assert!(src.contains("impl rt::FromValue for Payload"), "{src}");
323        assert!(src.contains("rt::record("), "{src}");
324        // Fields decode by label *and* declaration index, so non-verbose
325        // (label-less) records bind positionally.
326        assert!(
327            src.contains("rt::required_field(value, 0usize, \"owner\")"),
328            "{src}"
329        );
330    }
331
332    #[test]
333    fn optional_fields_decode_as_absent_tolerant() {
334        // Canton normalizes records by dropping trailing empty-Optional fields;
335        // an Optional field must therefore decode through `optional_field` (an
336        // absent field is `None`), while required fields stay strict.
337        let record = Record {
338            name: "Payload".to_string(),
339            type_params: Vec::new(),
340            fields: vec![
341                field("owner", DamlType::Party),
342                field("note", DamlType::Optional(Box::new(DamlType::Text))),
343            ],
344        };
345
346        let src = generate_record(&record).unwrap();
347        syn::parse_file(&src).unwrap();
348        assert!(
349            src.contains("rt::optional_field(value, 1usize, \"note\")"),
350            "{src}"
351        );
352        assert!(
353            src.contains("rt::required_field(value, 0usize, \"owner\")"),
354            "{src}"
355        );
356    }
357
358    #[test]
359    fn nested_optional_maps_to_nested_opt() {
360        let record = Record {
361            name: "R".to_string(),
362            type_params: Vec::new(),
363            fields: vec![
364                field("single", DamlType::Optional(Box::new(DamlType::Text))),
365                field(
366                    "nested",
367                    DamlType::Optional(Box::new(DamlType::Optional(Box::new(DamlType::Text)))),
368                ),
369            ],
370        };
371
372        let src = generate_record(&record).unwrap();
373        syn::parse_file(&src).unwrap();
374        // A single Optional stays a plain Option.
375        assert!(
376            src.contains("pub single: ::core::option::Option<::std::string::String>"),
377            "{src}"
378        );
379        // A nested Optional wraps the inner layer in rt::NestedOpt (list JSON).
380        assert!(
381            src.contains(
382                "pub nested: ::core::option::Option<rt::NestedOpt<::std::string::String>>"
383            ),
384            "{src}"
385        );
386    }
387
388    #[test]
389    fn record_derives_the_json_codec() {
390        let record = Record {
391            name: "Payload".to_string(),
392            type_params: Vec::new(),
393            fields: vec![field("installId", DamlType::Text)],
394        };
395
396        let src = generate_record(&record).unwrap();
397        syn::parse_file(&src).unwrap();
398        assert!(src.contains("rt::serde::Serialize"), "{src}");
399        assert!(src.contains("rt::serde::Deserialize"), "{src}");
400        assert!(src.contains("serde(crate = \"rt::serde\")"), "{src}");
401        // The JSON key is the Daml label, not the snake_cased Rust field.
402        assert!(src.contains("serde(rename = \"installId\")"), "{src}");
403        assert!(
404            src.contains("pub install_id: ::std::string::String"),
405            "{src}"
406        );
407    }
408
409    #[test]
410    fn variant_generates_a_rust_enum() {
411        use crate::ir::{Variant, VariantConstructor};
412
413        let variant = Variant {
414            name: "Shape".to_string(),
415            type_params: Vec::new(),
416            constructors: vec![
417                VariantConstructor {
418                    name: "Circle".to_string(),
419                    payload: Some(DamlType::Numeric(10)),
420                },
421                VariantConstructor {
422                    name: "Point".to_string(),
423                    payload: None,
424                },
425            ],
426        };
427
428        let src = generate_data_type(&DataType::Variant(variant)).unwrap();
429        syn::parse_file(&src).unwrap();
430        assert!(src.contains("pub enum Shape"), "{src}");
431        assert!(src.contains("Circle(rt::Numeric)"), "{src}");
432        // A nullary constructor carries `rt::Unit` (LF-JSON `{}`), not nothing —
433        // so its wire form is `{"tag":"Point","value":{}}`, which the Ledger API
434        // sends and a bare unit variant would fail to parse.
435        assert!(src.contains("Point(rt::Unit)"), "{src}");
436    }
437
438    #[test]
439    fn enum_generates_a_c_like_enum() {
440        use crate::ir::Enum;
441
442        let enumeration = Enum {
443            name: "DayOfWeek".to_string(),
444            constructors: vec!["Monday".to_string(), "Tuesday".to_string()],
445        };
446
447        let src = generate_data_type(&DataType::Enum(enumeration)).unwrap();
448        syn::parse_file(&src).unwrap();
449        assert!(src.contains("pub enum DayOfWeek"), "{src}");
450        assert!(src.contains("Monday,"), "{src}");
451        // C-like enums are `Copy`.
452        assert!(src.contains("Copy"), "{src}");
453    }
454
455    #[test]
456    fn template_generates_payload_and_typed_choices() {
457        use crate::ir::{Choice, Template, TypeRef};
458
459        let template = Template {
460            name: "AppInstall".to_string(),
461            module_name: "Licensing.AppInstall".to_string(),
462            package_id: "abc123".to_string(),
463            package_name: "app-install".to_string(),
464            fields: vec![field("provider", DamlType::Party)],
465            choices: vec![Choice {
466                name: "Accept".to_string(),
467                consuming: true,
468                argument: DamlType::Ref(TypeRef::local("AppInstall_Accept", Vec::new())),
469                returns: DamlType::ContractId(Box::new(DamlType::Ref(TypeRef::local(
470                    "AppInstalled",
471                    Vec::new(),
472                )))),
473            }],
474            key: None,
475        };
476
477        let src = generate_template(&template).unwrap();
478        syn::parse_file(&src).unwrap();
479        // The payload struct…
480        assert!(src.contains("pub struct AppInstall"), "{src}");
481        assert!(src.contains("pub provider: rt::Party"), "{src}");
482        // …and the typed choice impl linking arg → template → return.
483        assert!(
484            src.contains("impl rt::Choice<AppInstall> for AppInstall_Accept"),
485            "{src}"
486        );
487        assert!(
488            src.contains("type Return = rt::ContractId<AppInstalled>"),
489            "{src}"
490        );
491        assert!(
492            src.contains("const NAME: &'static str = \"Accept\""),
493            "{src}"
494        );
495        assert!(src.contains("const CONSUMING: bool = true"), "{src}");
496        // …and the on-ledger template identity.
497        assert!(src.contains("impl rt::Template for AppInstall"), "{src}");
498        assert!(
499            src.contains("const MODULE_NAME: &'static str = \"Licensing.AppInstall\""),
500            "{src}"
501        );
502        assert!(
503            src.contains("const ENTITY_NAME: &'static str = \"AppInstall\""),
504            "{src}"
505        );
506        assert!(
507            src.contains("const PACKAGE_NAME: &'static str = \"app-install\""),
508            "{src}"
509        );
510    }
511
512    #[test]
513    fn a_template_documents_its_identity_and_its_choices() {
514        use crate::ir::{Choice, Template, TypeRef};
515
516        let template = Template {
517            name: "AppInstall".to_string(),
518            module_name: "Licensing.AppInstall".to_string(),
519            package_id: "abc123".to_string(),
520            package_name: "app-install".to_string(),
521            fields: vec![field("provider", DamlType::Party)],
522            choices: vec![
523                Choice {
524                    name: "Accept".to_string(),
525                    consuming: true,
526                    argument: DamlType::Ref(TypeRef::local("AppInstall_Accept", Vec::new())),
527                    returns: DamlType::Unit,
528                },
529                Choice {
530                    name: "Peek".to_string(),
531                    consuming: false,
532                    argument: DamlType::Ref(TypeRef::local("AppInstall_Peek", Vec::new())),
533                    returns: DamlType::Unit,
534                },
535            ],
536            key: None,
537        };
538
539        let src = generate_template(&template).unwrap();
540        syn::parse_file(&src).unwrap();
541        // rustdoc has no reverse index for `impl Choice<This> for That`, so the
542        // payload struct is the only place a reader can learn what it can do.
543        assert!(
544            src.contains("The Daml template `Licensing.AppInstall:AppInstall`"),
545            "{src}"
546        );
547        assert!(
548            src.contains("`#app-install:Licensing.AppInstall:AppInstall`"),
549            "{src}"
550        );
551        assert!(src.contains("- `Accept` — consuming"), "{src}");
552        assert!(src.contains("- `Peek` — non-consuming"), "{src}");
553        // One `#[doc]` per line, never a block comment: prettyplease indents a
554        // `/** */` continuation line to match the item, and rustdoc reads four
555        // spaces as a code block — which makes the prose a failing doctest in
556        // the user's crate.
557        assert!(!src.contains("/**"), "{src}");
558    }
559
560    /// A DAR is untrusted input. A choice name, field label or package name
561    /// carrying a newline and a code fence must not become a doctest in the
562    /// user's crate (rustdoc reads doc attributes as Markdown, and
563    /// `cargo test` runs doctests).
564    #[test]
565    fn hostile_names_cannot_open_a_doctest_in_generated_docs() {
566        use crate::ir::{Choice, Template};
567
568        let payload = "```\nfn main() { std::process::exit(7) }\n```";
569        let template = Template {
570            name: "Deal".to_string(),
571            module_name: "Market".to_string(),
572            package_id: "abc123".to_string(),
573            package_name: format!("market\n{payload}"),
574            fields: vec![field(&format!("owner\n    {payload}"), DamlType::Party)],
575            choices: vec![Choice {
576                name: format!("Accept\n{payload}\n"),
577                consuming: true,
578                argument: DamlType::Unit,
579                returns: DamlType::Unit,
580            }],
581            key: None,
582        };
583
584        let src = generate_template(&template).unwrap();
585        syn::parse_file(&src).unwrap();
586        assert!(!src.contains("/**"), "{src}");
587        // Every doc attribute is one line, so no line of the output can open
588        // a fence; the payload survives only as inert text inside one line.
589        for line in src.lines() {
590            assert!(!line.trim_start().starts_with("```"), "{src}");
591            assert!(
592                !line.trim_start().starts_with("///") || !line.contains('\n'),
593                "{src}"
594            );
595        }
596        assert!(!src.contains("/// ```"), "{src}");
597    }
598
599    #[test]
600    fn keyed_template_emits_with_key_impl() {
601        use crate::ir::{Template, TypeRef};
602
603        let template = Template {
604            name: "Account".to_string(),
605            module_name: "Bank.Account".to_string(),
606            package_id: "abc123".to_string(),
607            package_name: "bank".to_string(),
608            fields: vec![field("owner", DamlType::Party)],
609            choices: Vec::new(),
610            // A record key (owner + number) referenced by name.
611            key: Some(DamlType::Ref(TypeRef::local("AccountKey", Vec::new()))),
612        };
613
614        let src = generate_template(&template).unwrap();
615        syn::parse_file(&src).unwrap();
616        assert!(src.contains("impl rt::WithKey for Account"), "{src}");
617        assert!(src.contains("type Key = AccountKey"), "{src}");
618    }
619
620    #[test]
621    fn interface_emits_identity_view_and_choice_impls() {
622        use crate::ir::{Choice, Interface, TypeRef};
623
624        let interface = Interface {
625            name: "Holding".to_string(),
626            module_name: "Splice.Api.Token.HoldingV1".to_string(),
627            package_id: "abc123".to_string(),
628            package_name: "splice-api-token-holding".to_string(),
629            view: Some(DamlType::Ref(TypeRef::local("HoldingView", Vec::new()))),
630            choices: vec![Choice {
631                name: "Transfer".to_string(),
632                consuming: true,
633                argument: DamlType::Ref(TypeRef::local("Holding_Transfer", Vec::new())),
634                returns: DamlType::Unit,
635            }],
636        };
637
638        let src = generate_interface(&interface).unwrap();
639        syn::parse_file(&src).unwrap();
640        // Identity (Contract) + the interface's view type…
641        assert!(src.contains("impl rt::Contract for Holding"), "{src}");
642        assert!(src.contains("impl rt::Interface for Holding"), "{src}");
643        assert!(src.contains("type View = HoldingView"), "{src}");
644        // …and the choice, exercisable through the interface marker.
645        assert!(
646            src.contains("impl rt::Choice<Holding> for Holding_Transfer"),
647            "{src}"
648        );
649    }
650
651    #[test]
652    fn keyless_template_emits_no_with_key_impl() {
653        use crate::ir::Template;
654
655        let template = Template {
656            name: "Note".to_string(),
657            module_name: "M".to_string(),
658            package_id: "p".to_string(),
659            package_name: "m".to_string(),
660            fields: vec![field("text", DamlType::Text)],
661            choices: Vec::new(),
662            key: None,
663        };
664
665        let src = generate_template(&template).unwrap();
666        syn::parse_file(&src).unwrap();
667        assert!(
668            !src.contains("WithKey"),
669            "keyless template has no key impl: {src}"
670        );
671    }
672
673    #[test]
674    fn module_wraps_items_with_the_runtime_preamble() {
675        use crate::ir::{Choice, Enum, Module, Template};
676
677        let module = Module {
678            data_types: vec![DataType::Enum(Enum {
679                name: "Color".to_string(),
680                constructors: vec!["Red".to_string(), "Green".to_string()],
681            })],
682            templates: vec![Template {
683                name: "AppInstall".to_string(),
684                module_name: "Licensing.AppInstall".to_string(),
685                package_id: "abc123".to_string(),
686                package_name: "app-install".to_string(),
687                fields: vec![field("provider", DamlType::Party)],
688                choices: vec![Choice {
689                    name: "Accept".to_string(),
690                    consuming: true,
691                    argument: DamlType::Ref(TypeRef::local("AppInstall_Accept", Vec::new())),
692                    returns: DamlType::Unit,
693                }],
694                key: None,
695            }],
696            interfaces: Vec::new(),
697        };
698
699        let src = generate_module(&module).unwrap();
700        // The whole file (preamble + items) is valid Rust.
701        syn::parse_file(&src).unwrap();
702        assert!(src.contains("use canton_daml as rt;"), "{src}");
703        assert!(src.contains("#![allow(non_camel_case_types"), "{src}");
704        assert!(src.contains("pub enum Color"), "{src}");
705        assert!(src.contains("pub struct AppInstall"), "{src}");
706        assert!(
707            src.contains("impl rt::Choice<AppInstall> for AppInstall_Accept"),
708            "{src}"
709        );
710    }
711
712    #[test]
713    fn generic_record_carries_type_params() {
714        let record = Record {
715            name: "Pair".to_string(),
716            type_params: vec!["a".to_string(), "b".to_string()],
717            fields: vec![
718                field("fst", DamlType::Var("a".to_string())),
719                field("snd", DamlType::Var("b".to_string())),
720            ],
721        };
722
723        let src = generate_record(&record).unwrap();
724        syn::parse_file(&src).unwrap();
725        // Lowercase Daml vars are upper-camel-cased to Rust generics.
726        assert!(src.contains("pub struct Pair<A, B>"), "{src}");
727        assert!(src.contains("pub fst: A"), "{src}");
728    }
729    /// Every keyword the escape list claims to cover, checked by generating a
730    /// field named after it and looking for the escape.
731    ///
732    /// The list is only worth what it is complete for, and completeness is
733    /// exactly what reading it cannot establish — `gen` was missing until this
734    /// went looking. Generated crates declare edition 2021, where `gen` is an
735    /// ordinary identifier, so nothing would have failed until the same file
736    /// was compiled in a 2024 crate; `r#gen` is valid in both.
737    #[test]
738    fn every_keyword_in_the_list_is_escaped_in_a_field_name() {
739        // Spot-check across the three shapes the escape takes: raw identifiers,
740        // the four that cannot be raw and get a `_` suffix, and a 2024 addition.
741        for (daml, expected) in [
742            ("type", "r#type:"),
743            ("match", "r#match:"),
744            ("gen", "r#gen:"),
745            ("try", "r#try:"),
746            ("async", "r#async:"),
747            ("box", "r#box:"),
748            ("crate", "crate_:"),
749            ("self", "self_:"),
750        ] {
751            let record = Record {
752                name: "Keyworded".to_string(),
753                type_params: Vec::new(),
754                fields: vec![field(daml, DamlType::Text)],
755            };
756            let src = generate_record(&record).expect("generator emits valid Rust");
757            assert!(
758                src.contains(expected),
759                "a Daml field named `{daml}` should emit `{expected}`:\n{src}"
760            );
761            // And the wire label survives the escaping, or the field would be
762            // written to the ledger under the Rust spelling.
763            assert!(
764                src.contains(&format!("#[serde(rename = \"{daml}\")]")),
765                "the wire label `{daml}` must survive:\n{src}"
766            );
767        }
768    }
769    /// A Daml type parameter that no field uses must not be forced to carry a
770    /// codec.
771    ///
772    /// Daml permits a phantom parameter, and the natural thing to instantiate
773    /// one with is an interface marker — which by design has no codec at all,
774    /// because it exists only as the tag of a `ContractId`. Bounding every
775    /// declared parameter therefore emitted Rust that is valid until somebody
776    /// writes `Wrapper Holding`, and then does not compile, with the error
777    /// landing in generated code the reader did not write.
778    #[test]
779    fn a_phantom_type_parameter_is_not_forced_to_carry_a_codec() {
780        let mut record = Record::new("Wrapper");
781        record.type_params = vec!["a".to_string(), "b".to_string()];
782        // Only `b` is used by a field; `a` is phantom.
783        record.fields = vec![Field::new("value", DamlType::Var("b".to_string()))];
784
785        let src = generate_data_type(&DataType::Record(record)).unwrap();
786        assert!(
787            src.contains("B: rt::ToValue"),
788            "the used parameter is bounded: {src}"
789        );
790        assert!(
791            !src.contains("A: rt::ToValue"),
792            "the phantom parameter must not be bounded: {src}"
793        );
794        // It still appears as a parameter of the type, and in the PhantomData.
795        assert!(src.contains("pub struct Wrapper<A, B>"), "{src}");
796        assert!(src.contains("PhantomData"), "{src}");
797    }
798
799    /// The same for a variant: only the parameters a constructor payload
800    /// mentions get the bound.
801    #[test]
802    fn a_variants_phantom_parameter_is_not_bounded_either() {
803        let mut variant = Variant::new("Choice");
804        variant.type_params = vec!["a".to_string(), "b".to_string()];
805        variant.constructors = vec![
806            VariantConstructor::with_payload("Some", DamlType::Var("b".to_string())),
807            VariantConstructor::new("None"),
808        ];
809
810        let src = generate_data_type(&DataType::Variant(variant)).unwrap();
811        assert!(src.contains("B: rt::ToValue"), "{src}");
812        assert!(!src.contains("A: rt::ToValue"), "{src}");
813    }
814}