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