From 409f5ec25bb4c1c39fe5d802ae41dfa1a50be1f8 Mon Sep 17 00:00:00 2001 From: Kamil Listopad Date: Thu, 7 May 2026 13:41:52 +0200 Subject: [PATCH 1/5] feat(motoko): emit type alias names as UpperCamelCase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Candid type aliases are now rendered in UpperCamelCase in generated Motoko bindings (e.g. `my_type` → `MyType`). When two names would map to the same UpperCamelCase form (e.g. `A` and `a`), the collision is detected and the original escaped name is kept instead. `Self` is always reserved as it is emitted verbatim by the actor declaration. All-separator inputs (e.g. `_`) that produce an empty transform also fall back to the original name. Adds `to_upper_camel_case`, `escape_str` (string-returning escape helper reused by both `escape` and the collision fallback), and `type_display_name` which performs the collision check against the full `TypeEnv`. All printer functions now accept `env: &TypeEnv`, mirroring the TypeScript binding generator. Co-authored-by: Cursor --- rust/candid_parser/src/bindings/motoko.rs | 163 +++++++++++------- rust/candid_parser/tests/assets/ok/actor.mo | 18 +- rust/candid_parser/tests/assets/ok/comment.mo | 2 +- rust/candid_parser/tests/assets/ok/example.mo | 46 ++--- .../candid_parser/tests/assets/ok/fieldnat.mo | 6 +- rust/candid_parser/tests/assets/ok/keyword.mo | 22 +-- .../tests/assets/ok/management.mo | 114 ++++++------ .../tests/assets/ok/recursion.mo | 16 +- .../tests/assets/ok/recursive_class.mo | 4 +- 9 files changed, 215 insertions(+), 176 deletions(-) diff --git a/rust/candid_parser/src/bindings/motoko.rs b/rust/candid_parser/src/bindings/motoko.rs index 326b7fc61..cda1ba959 100644 --- a/rust/candid_parser/src/bindings/motoko.rs +++ b/rust/candid_parser/src/bindings/motoko.rs @@ -78,47 +78,88 @@ static KEYWORDS: [&str; 48] = [ "while", "with", ]; -fn escape(id: &str, is_method: bool) -> RcDoc<'_> { - if KEYWORDS.contains(&id) { - str(id).append("_") +fn escape_str(id: &str) -> String { + if KEYWORDS.contains(&id) || (is_valid_as_id(id) && id.ends_with('_')) { + format!("{id}_") } else if is_valid_as_id(id) { - if id.ends_with('_') { - str(id).append("_") + id.to_string() + } else { + format!("_{}_", candid::idl_hash(id)) + } +} + +fn escape(id: &str, is_method: bool) -> RcDoc<'_> { + if is_method && !KEYWORDS.contains(&id) && !is_valid_as_id(id) { + panic!("Candid method {id} is not a valid Motoko id"); + } + RcDoc::text(escape_str(id)) +} + +fn to_upper_camel_case(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut capitalize = true; + for c in s.chars() { + // '_' is the standard Candid separator; '-' is included defensively + if c == '_' || c == '-' { + capitalize = true; + } else if capitalize { + out.extend(c.to_uppercase()); + capitalize = false; } else { - str(id) + out.push(c); + } + } + out +} + +// Returns the UpperCamelCase display name for a type alias, falling back to the +// original escaped name when: +// - the transform produces an empty string (e.g. id = "_"), +// - the result would collide with another alias in env, or +// - the result is "Self" (always reserved: pp_actor emits it when an actor is +// present, and being conservative here avoids surprises if a file is later +// extended with a service declaration). +fn type_display_name(env: &TypeEnv, id: &str) -> String { + let camel = to_upper_camel_case(id); + let collision = camel.is_empty() + || camel == "Self" + || env.0.keys().any(|k| k != id && to_upper_camel_case(k) == camel); + if collision { + let fallback = escape_str(id); + // escape_str doesn't know about "Self" being reserved, so guard explicitly. + if fallback == "Self" { + format!("{fallback}_") + } else { + fallback } - } else if !is_method { - str("_") - .append(candid::idl_hash(id).to_string()) - .append("_") } else { - panic!("Candid method {id} is not a valid Motoko id"); + camel } } -fn pp_ty_rich<'a>(ty: &'a Type, syntax: Option<&'a IDLType>) -> RcDoc<'a> { +fn pp_ty_rich<'a>(env: &'a TypeEnv, ty: &'a Type, syntax: Option<&'a IDLType>) -> RcDoc<'a> { match (ty.as_ref(), syntax) { (TypeInner::Service(ref meths), Some(IDLType::ServT(methods))) => { - pp_service(meths, Some(methods)) + pp_service(env, meths, Some(methods)) } (TypeInner::Class(ref args, t), Some(IDLType::ClassT(_, syntax_t))) => { - pp_class((args, t), Some(syntax_t)) + pp_class(env, (args, t), Some(syntax_t)) } (TypeInner::Record(ref fields), Some(IDLType::RecordT(syntax_fields))) => { - pp_record(fields, Some(syntax_fields)) + pp_record(env, fields, Some(syntax_fields)) } (TypeInner::Variant(ref fields), Some(IDLType::VariantT(syntax_fields))) => { - pp_variant(fields, Some(syntax_fields)) + pp_variant(env, fields, Some(syntax_fields)) } (TypeInner::Opt(ref inner), Some(IDLType::OptT(syntax))) => { - str("?").append(pp_ty_rich(inner, Some(syntax))) + str("?").append(pp_ty_rich(env, inner, Some(syntax))) } - (TypeInner::Vec(ref inner), Some(IDLType::VecT(syntax))) => pp_vec(inner, Some(syntax)), - (_, _) => pp_ty(ty), + (TypeInner::Vec(ref inner), Some(IDLType::VecT(syntax))) => pp_vec(env, inner, Some(syntax)), + (_, _) => pp_ty(env, ty), } } -fn pp_ty(ty: &Type) -> RcDoc<'_> { +fn pp_ty<'a>(env: &'a TypeEnv, ty: &'a Type) -> RcDoc<'a> { use TypeInner::*; match ty.as_ref() { Null => str("Null"), @@ -138,15 +179,15 @@ fn pp_ty(ty: &Type) -> RcDoc<'_> { Text => str("Text"), Reserved => str("Any"), Empty => str("None"), - Var(ref s) => escape(s, false), + Var(ref s) => RcDoc::text(type_display_name(env, s)), Principal => str("Principal"), - Opt(ref t) => str("?").append(pp_ty(t)), - Vec(ref t) => pp_vec(t, None), - Record(ref fs) => pp_record(fs, None), - Variant(ref fs) => pp_variant(fs, None), - Func(ref func) => pp_function(func), - Service(ref serv) => pp_service(serv, None), - Class(ref args, ref t) => pp_class((args, t), None), + Opt(ref t) => str("?").append(pp_ty(env, t)), + Vec(ref t) => pp_vec(env, t, None), + Record(ref fs) => pp_record(env, fs, None), + Variant(ref fs) => pp_variant(env, fs, None), + Func(ref func) => pp_function(env, func), + Service(ref serv) => pp_service(env, serv, None), + Class(ref args, ref t) => pp_class(env, (args, t), None), Knot(_) | Unknown | Future => unreachable!(), } } @@ -161,9 +202,9 @@ fn pp_label(id: &SharedLabel) -> RcDoc<'_> { } } -fn pp_function(func: &Function) -> RcDoc<'_> { - let args = pp_args(&func.args); - let rets = pp_rets(&func.rets); +fn pp_function<'a>(env: &'a TypeEnv, func: &'a Function) -> RcDoc<'a> { + let args = pp_args(env, &func.args); + let rets = pp_rets(env, &func.rets); match func.modes.as_slice() { [FuncMode::Oneway] => kwd("shared").append(args).append(" -> ").append("()"), [FuncMode::Query] => kwd("shared query") @@ -185,27 +226,27 @@ fn pp_function(func: &Function) -> RcDoc<'_> { } .nest(INDENT_SPACE) } -fn pp_args(args: &[Type]) -> RcDoc<'_> { +fn pp_args<'a>(env: &'a TypeEnv, args: &'a [Type]) -> RcDoc<'a> { match args { [ty] => { if is_tuple(ty) { - enclose("(", pp_ty(ty), ")") + enclose("(", pp_ty(env, ty), ")") } else { - pp_ty(ty) + pp_ty(env, ty) } } _ => { - let doc = concat(args.iter().map(pp_ty), ","); + let doc = concat(args.iter().map(|ty| pp_ty(env, ty)), ","); enclose("(", doc, ")") } } } -fn pp_rets(args: &[Type]) -> RcDoc<'_> { - pp_args(args) +fn pp_rets<'a>(env: &'a TypeEnv, args: &'a [Type]) -> RcDoc<'a> { + pp_args(env, args) } -fn pp_service<'a>(serv: &'a [(String, Type)], syntax: Option<&'a [syntax::Binding]>) -> RcDoc<'a> { +fn pp_service<'a>(env: &'a TypeEnv, serv: &'a [(String, Type)], syntax: Option<&'a [syntax::Binding]>) -> RcDoc<'a> { let methods = serv.iter().map(|(id, func)| { let mut docs = RcDoc::nil(); let mut syntax_field_ty = None; @@ -217,21 +258,21 @@ fn pp_service<'a>(serv: &'a [(String, Type)], syntax: Option<&'a [syntax::Bindin } docs.append(escape(id, true)) .append(" : ") - .append(pp_ty_rich(func, syntax_field_ty)) + .append(pp_ty_rich(env, func, syntax_field_ty)) }); kwd("actor").append(enclose_space("{", concat(methods, ";"), "}")) } -fn pp_tuple<'a>(fields: &'a [Field]) -> RcDoc<'a> { - let tuple = concat(fields.iter().map(|f| pp_ty(&f.ty)), ","); +fn pp_tuple<'a>(env: &'a TypeEnv, fields: &'a [Field]) -> RcDoc<'a> { + let tuple = concat(fields.iter().map(|f| pp_ty(env, &f.ty)), ","); enclose("(", tuple, ")") } -fn pp_vec<'a>(inner: &'a Type, syntax: Option<&'a IDLType>) -> RcDoc<'a> { +fn pp_vec<'a>(env: &'a TypeEnv, inner: &'a Type, syntax: Option<&'a IDLType>) -> RcDoc<'a> { if matches!(inner.as_ref(), TypeInner::Nat8) { str("Blob") } else { - enclose("[", pp_ty_rich(inner, syntax), "]") + enclose("[", pp_ty_rich(env, inner, syntax), "]") } } @@ -250,20 +291,20 @@ fn find_field<'a>( (docs, syntax_field_ty) } -fn pp_record<'a>(fields: &'a [Field], syntax: Option<&'a [syntax::TypeField]>) -> RcDoc<'a> { +fn pp_record<'a>(env: &'a TypeEnv, fields: &'a [Field], syntax: Option<&'a [syntax::TypeField]>) -> RcDoc<'a> { if is_tuple_fields(fields) { - return pp_tuple(fields); + return pp_tuple(env, fields); } let fields = fields.iter().map(|field| { let (docs, syntax_field) = find_field(syntax, &field.id); docs.append(pp_label(&field.id)) .append(" : ") - .append(pp_ty_rich(&field.ty, syntax_field)) + .append(pp_ty_rich(env, &field.ty, syntax_field)) }); enclose_space("{", concat(fields, ";"), "}") } -fn pp_variant<'a>(fields: &'a [Field], syntax: Option<&'a [syntax::TypeField]>) -> RcDoc<'a> { +fn pp_variant<'a>(env: &'a TypeEnv, fields: &'a [Field], syntax: Option<&'a [syntax::TypeField]>) -> RcDoc<'a> { if fields.is_empty() { return str("{#}"); } @@ -272,7 +313,7 @@ fn pp_variant<'a>(fields: &'a [Field], syntax: Option<&'a [syntax::TypeField]>) let doc = docs.append(str("#")).append(pp_label(&field.id)); if *field.ty != TypeInner::Null { doc.append(" : ") - .append(pp_ty_rich(&field.ty, syntax_field)) + .append(pp_ty_rich(env, &field.ty, syntax_field)) } else { doc } @@ -280,11 +321,11 @@ fn pp_variant<'a>(fields: &'a [Field], syntax: Option<&'a [syntax::TypeField]>) enclose_space("{", concat(fields, ";"), "}") } -fn pp_class<'a>((args, ty): (&'a [Type], &'a Type), syntax: Option<&'a IDLType>) -> RcDoc<'a> { - let doc = pp_args(args).append(" -> async "); +fn pp_class<'a>(env: &'a TypeEnv, (args, ty): (&'a [Type], &'a Type), syntax: Option<&'a IDLType>) -> RcDoc<'a> { + let doc = pp_args(env, args).append(" -> async "); match ty.as_ref() { - TypeInner::Service(_) => doc.append(pp_ty_rich(ty, syntax)), - TypeInner::Var(_) => doc.append(pp_ty(ty)), + TypeInner::Service(_) => doc.append(pp_ty_rich(env, ty, syntax)), + TypeInner::Var(_) => doc.append(pp_ty(env, ty)), _ => unreachable!(), } } @@ -300,14 +341,14 @@ fn pp_defs<'a>(env: &'a TypeEnv, prog: &'a IDLMergedProg) -> RcDoc<'a> { .map(|b| pp_docs(b.docs.as_ref())) .unwrap_or(RcDoc::nil()); docs.append(kwd("public type")) - .append(escape(id, false)) + .append(RcDoc::text(type_display_name(env, id))) .append(" = ") - .append(pp_ty_rich(ty, syntax.map(|b| &b.typ))) + .append(pp_ty_rich(env, ty, syntax.map(|b| &b.typ))) .append(";") })) } -fn pp_actor<'a>(ty: &'a Type, syntax: Option<&'a IDLActorType>) -> RcDoc<'a> { +fn pp_actor<'a>(env: &'a TypeEnv, ty: &'a Type, syntax: Option<&'a IDLActorType>) -> RcDoc<'a> { let self_doc = kwd("public type Self ="); match ty.as_ref() { TypeInner::Service(ref serv) => match syntax { @@ -316,9 +357,9 @@ fn pp_actor<'a>(ty: &'a Type, syntax: Option<&'a IDLActorType>) -> RcDoc<'a> { docs, }) => { let docs = pp_docs(docs); - docs.append(self_doc).append(pp_service(serv, Some(fields))) + docs.append(self_doc).append(pp_service(env, serv, Some(fields))) } - _ => pp_service(serv, None), + _ => pp_service(env, serv, None), }, TypeInner::Class(ref args, ref t) => match syntax { Some(IDLActorType { @@ -327,11 +368,11 @@ fn pp_actor<'a>(ty: &'a Type, syntax: Option<&'a IDLActorType>) -> RcDoc<'a> { }) => { let docs = pp_docs(docs); docs.append(self_doc) - .append(pp_class((args, t), Some(syntax_t))) + .append(pp_class(env, (args, t), Some(syntax_t))) } - _ => self_doc.append(pp_class((args, t), None)), + _ => self_doc.append(pp_class(env, (args, t), None)), }, - TypeInner::Var(_) => self_doc.append(pp_ty(ty)), + TypeInner::Var(_) => self_doc.append(pp_ty(env, ty)), _ => unreachable!(), } } @@ -345,7 +386,7 @@ pub fn compile(env: &TypeEnv, actor: &Option, prog: &IDLMergedProg) -> Str None => pp_defs(env, prog), Some(actor) => { let defs = pp_defs(env, prog); - let actor = pp_actor(actor, syntax_actor.as_ref()); + let actor = pp_actor(env, actor, syntax_actor.as_ref()); defs.append(actor) } }; diff --git a/rust/candid_parser/tests/assets/ok/actor.mo b/rust/candid_parser/tests/assets/ok/actor.mo index 98531f3fc..1e453f781 100644 --- a/rust/candid_parser/tests/assets/ok/actor.mo +++ b/rust/candid_parser/tests/assets/ok/actor.mo @@ -2,15 +2,15 @@ // Please use `import service "ic:canister_id"` instead to call canisters on the IC if possible. module { - public type f = shared Int8 -> async Int8; - public type g = f; - public type h = shared f -> async f; - public type o = ?o; + public type F = shared Int8 -> async Int8; + public type G = F; + public type H = shared F -> async F; + public type O = ?O; public type Self = actor { - f : shared Nat -> async h; - g : f; - h : g; - h2 : h; - o : shared o -> async o; + f : shared Nat -> async H; + g : F; + h : G; + h2 : H; + o : shared O -> async O; } } diff --git a/rust/candid_parser/tests/assets/ok/comment.mo b/rust/candid_parser/tests/assets/ok/comment.mo index 789f14289..b0d103b48 100644 --- a/rust/candid_parser/tests/assets/ok/comment.mo +++ b/rust/candid_parser/tests/assets/ok/comment.mo @@ -4,6 +4,6 @@ module { /// line comment /// - public type id = Nat8; + public type Id = Nat8; } diff --git a/rust/candid_parser/tests/assets/ok/example.mo b/rust/candid_parser/tests/assets/ok/example.mo index 8dd8d9bf9..732295cfb 100644 --- a/rust/candid_parser/tests/assets/ok/example.mo +++ b/rust/candid_parser/tests/assets/ok/example.mo @@ -14,20 +14,20 @@ module { public type a = { #a; #b : b }; public type b = (Int, Nat); /// Doc comment for broker service - public type broker = actor { + public type Broker = actor { find : shared Text -> async actor { current : shared () -> async Nat32; up : shared () -> async (); }; }; - public type f = shared (List, shared Int32 -> async Int64) -> async ( + public type F = shared (List, shared Int32 -> async Int64) -> async ( ?List, - res, + Res, ); - public type list = ?node; + public type list = ?Node; /// Doc comment for prim type - public type my_type = Principal; - public type my_variant = { + public type MyType = Principal; + public type MyVariant = { /// Doc comment for my_variant field a #a : { /// Doc comment for my_variant field a field b @@ -46,7 +46,7 @@ module { }; }; /// Doc comment for nested type - public type nested = { + public type Nested = { _0_ : Nat; _1_ : Nat; /// Doc comment for nested record @@ -57,14 +57,14 @@ module { _42_ : Nat; }; /// Doc comment for nested_records - public type nested_records = { + public type NestedRecords = { /// Doc comment for nested_records field nested nested : ?{ /// Doc comment for nested_records field nested_field nested_field : Text; }; }; - public type nested_res = { + public type NestedRes = { #Ok : { #Ok; #Err }; #Err : { /// Doc comment for Ok in nested variant @@ -73,9 +73,9 @@ module { #Err : { _0_ : Int }; }; }; - public type node = { head : Nat; tail : list }; + public type Node = { head : Nat; tail : list }; /// Doc comment for res type - public type res = { + public type Res = { /// Doc comment for Ok variant #Ok : (Int, Nat); /// Doc comment for Err variant @@ -86,36 +86,36 @@ module { }; }; /// Doc comment for service id - public type s = actor { f : t; g : shared list -> async (B, tree, stream) }; - public type stream = ?{ head : Nat; next : shared query () -> async stream }; - public type t = shared s -> async (); - public type tree = { - #branch : { val : Int; left : tree; right : tree }; + public type S = actor { f : T; g : shared list -> async (B, Tree, Stream) }; + public type Stream = ?{ head : Nat; next : shared query () -> async Stream }; + public type T = shared S -> async (); + public type Tree = { + #branch : { val : Int; left : Tree; right : Tree }; #leaf : Int; }; /// Doc comment for service public type Self = actor { /// Doc comment for f1 method of service f1 : shared (list, Blob, ?Bool) -> (); - g1 : shared query (my_type, List, ?List, nested) -> async ( + g1 : shared query (MyType, List, ?List, Nested) -> async ( Int, - broker, - nested_res, + Broker, + NestedRes, ); h : shared ([?Text], { #A : Nat; #B : ?Text }, ?List) -> async { _42_ : {}; id : Nat; }; /// Doc comment for i method of service - i : f; + i : F; x : shared composite query (a, b) -> async ( ?a, ?b, { #Ok : { result : Text }; #Err : { #a; #b } }, ); - y : shared query nested_records -> async ((nested_records, my_variant)); - f : t; - g : shared list -> async (B, tree, stream); + y : shared query NestedRecords -> async ((NestedRecords, MyVariant)); + f : T; + g : shared list -> async (B, Tree, Stream); /// Doc comment for imported bbbbb service method bbbbb : shared b -> async (); } diff --git a/rust/candid_parser/tests/assets/ok/fieldnat.mo b/rust/candid_parser/tests/assets/ok/fieldnat.mo index 64f771834..723877de0 100644 --- a/rust/candid_parser/tests/assets/ok/fieldnat.mo +++ b/rust/candid_parser/tests/assets/ok/fieldnat.mo @@ -2,14 +2,14 @@ // Please use `import service "ic:canister_id"` instead to call canisters on the IC if possible. module { - public type non_tuple = { _1_ : Text; _2_ : Text }; - public type tuple = (Text, Text); + public type NonTuple = { _1_ : Text; _2_ : Text }; + public type Tuple = (Text, Text); public type Self = actor { bab : shared (Int, Nat) -> async (); bar : shared { _50_ : Int } -> async { #e20; #e30 }; bas : shared ((Int, Int)) -> async ((Text, Nat)); baz : shared { _2_ : Int; _50_ : Nat } -> async {}; - bba : shared tuple -> async non_tuple; + bba : shared Tuple -> async NonTuple; bib : shared { _0_ : Int } -> async { #_0_ : Int }; foo : shared { _2_ : Int } -> async { _2_ : Int; _2 : Int }; } diff --git a/rust/candid_parser/tests/assets/ok/keyword.mo b/rust/candid_parser/tests/assets/ok/keyword.mo index 2802968e2..3608837f8 100644 --- a/rust/candid_parser/tests/assets/ok/keyword.mo +++ b/rust/candid_parser/tests/assets/ok/keyword.mo @@ -2,26 +2,26 @@ // Please use `import service "ic:canister_id"` instead to call canisters on the IC if possible. module { - public type if_ = { - #branch : { val : Int; left : if_; right : if_ }; + public type If = { + #branch : { val : Int; left : If; right : If }; #leaf : Int; }; - public type list = ?node; - public type node = { head : Nat; tail : list }; - public type o = ?o; - public type return_ = actor { f : t; g : shared list -> async (if_, stream) }; - public type stream = ?{ head : Nat; next : shared query () -> async stream }; - public type t = shared return_ -> async (); + public type List = ?Node; + public type Node = { head : Nat; tail : List }; + public type O = ?O; + public type Return = actor { f : T; g : shared List -> async (If, Stream) }; + public type Stream = ?{ head : Nat; next : shared query () -> async Stream }; + public type T = shared Return -> async (); public type Self = actor { Oneway : shared () -> (); - f__ : shared o -> async o; + f__ : shared O -> async O; field : shared { test : Nat16; _1291438163_ : Nat8 } -> async {}; fieldnat : shared { _2_ : Int; _50_ : Nat } -> async { _0_ : Int }; oneway : shared Nat8 -> (); oneway__ : shared Nat8 -> (); query_ : shared query Blob -> async Blob; - return_ : shared o -> async o; - service : t; + return_ : shared O -> async O; + service : T; tuple : shared ((Int, Blob, Text)) -> async ((Int, Nat8)); variant : shared { #A; #B; #C; #D : Float } -> async (); } diff --git a/rust/candid_parser/tests/assets/ok/management.mo b/rust/candid_parser/tests/assets/ok/management.mo index 0609c7387..341573300 100644 --- a/rust/candid_parser/tests/assets/ok/management.mo +++ b/rust/candid_parser/tests/assets/ok/management.mo @@ -2,81 +2,79 @@ // Please use `import service "ic:canister_id"` instead to call canisters on the IC if possible. module { - public type bitcoin_address = Text; - public type bitcoin_network = { #mainnet; #testnet }; - public type block_hash = Blob; - public type canister_id = Principal; - public type canister_settings = { + public type BitcoinAddress = Text; + public type BitcoinNetwork = { #mainnet; #testnet }; + public type BlockHash = Blob; + public type CanisterId = Principal; + public type CanisterSettings = { freezing_threshold : ?Nat; controllers : ?[Principal]; memory_allocation : ?Nat; compute_allocation : ?Nat; }; - public type definite_canister_settings = { + public type DefiniteCanisterSettings = { freezing_threshold : Nat; controllers : [Principal]; memory_allocation : Nat; compute_allocation : Nat; }; - public type ecdsa_curve = { #secp256k1 }; - public type get_balance_request = { - network : bitcoin_network; - address : bitcoin_address; + public type EcdsaCurve = { #secp256k1 }; + public type GetBalanceRequest = { + network : BitcoinNetwork; + address : BitcoinAddress; min_confirmations : ?Nat32; }; - public type get_current_fee_percentiles_request = { - network : bitcoin_network; - }; - public type get_utxos_request = { - network : bitcoin_network; + public type GetCurrentFeePercentilesRequest = { network : BitcoinNetwork }; + public type GetUtxosRequest = { + network : BitcoinNetwork; filter : ?{ #page : Blob; #min_confirmations : Nat32 }; - address : bitcoin_address; + address : BitcoinAddress; }; - public type get_utxos_response = { + public type GetUtxosResponse = { next_page : ?Blob; tip_height : Nat32; - tip_block_hash : block_hash; - utxos : [utxo]; + tip_block_hash : BlockHash; + utxos : [Utxo]; }; - public type http_header = { value : Text; name : Text }; - public type http_response = { + public type HttpHeader = { value : Text; name : Text }; + public type HttpResponse = { status : Nat; body : Blob; - headers : [http_header]; + headers : [HttpHeader]; }; - public type millisatoshi_per_byte = Nat64; - public type outpoint = { txid : Blob; vout : Nat32 }; - public type satoshi = Nat64; - public type send_transaction_request = { + public type MillisatoshiPerByte = Nat64; + public type Outpoint = { txid : Blob; vout : Nat32 }; + public type Satoshi = Nat64; + public type SendTransactionRequest = { transaction : Blob; - network : bitcoin_network; + network : BitcoinNetwork; }; - public type user_id = Principal; - public type utxo = { height : Nat32; value : satoshi; outpoint : outpoint }; - public type wasm_module = Blob; + public type UserId = Principal; + public type Utxo = { height : Nat32; value : Satoshi; outpoint : Outpoint }; + public type WasmModule = Blob; public type Self = actor { - bitcoin_get_balance : shared get_balance_request -> async satoshi; - bitcoin_get_current_fee_percentiles : shared get_current_fee_percentiles_request -> async [ - millisatoshi_per_byte + bitcoin_get_balance : shared GetBalanceRequest -> async Satoshi; + bitcoin_get_current_fee_percentiles : shared GetCurrentFeePercentilesRequest -> async [ + MillisatoshiPerByte ]; - bitcoin_get_utxos : shared get_utxos_request -> async get_utxos_response; - bitcoin_send_transaction : shared send_transaction_request -> async (); - canister_status : shared { canister_id : canister_id } -> async { + bitcoin_get_utxos : shared GetUtxosRequest -> async GetUtxosResponse; + bitcoin_send_transaction : shared SendTransactionRequest -> async (); + canister_status : shared { canister_id : CanisterId } -> async { status : { #stopped; #stopping; #running }; memory_size : Nat; cycles : Nat; - settings : definite_canister_settings; + settings : DefiniteCanisterSettings; idle_cycles_burned_per_day : Nat; module_hash : ?Blob; }; - create_canister : shared { settings : ?canister_settings } -> async { - canister_id : canister_id; + create_canister : shared { settings : ?CanisterSettings } -> async { + canister_id : CanisterId; }; - delete_canister : shared { canister_id : canister_id } -> async (); - deposit_cycles : shared { canister_id : canister_id } -> async (); + delete_canister : shared { canister_id : CanisterId } -> async (); + deposit_cycles : shared { canister_id : CanisterId } -> async (); ecdsa_public_key : shared { - key_id : { name : Text; curve : ecdsa_curve }; - canister_id : ?canister_id; + key_id : { name : Text; curve : EcdsaCurve }; + canister_id : ?CanisterId; derivation_path : [Blob]; } -> async { public_key : Blob; chain_code : Blob }; http_request : shared { @@ -87,39 +85,39 @@ module { transform : ?{ function : shared query { context : Blob; - response : http_response; - } -> async http_response; + response : HttpResponse; + } -> async HttpResponse; context : Blob; }; - headers : [http_header]; - } -> async http_response; + headers : [HttpHeader]; + } -> async HttpResponse; install_code : shared { arg : Blob; - wasm_module : wasm_module; + wasm_module : WasmModule; mode : { #reinstall; #upgrade; #install }; - canister_id : canister_id; + canister_id : CanisterId; } -> async (); provisional_create_canister_with_cycles : shared { - settings : ?canister_settings; - specified_id : ?canister_id; + settings : ?CanisterSettings; + specified_id : ?CanisterId; amount : ?Nat; - } -> async { canister_id : canister_id }; + } -> async { canister_id : CanisterId }; provisional_top_up_canister : shared { - canister_id : canister_id; + canister_id : CanisterId; amount : Nat; } -> async (); raw_rand : shared () -> async Blob; sign_with_ecdsa : shared { - key_id : { name : Text; curve : ecdsa_curve }; + key_id : { name : Text; curve : EcdsaCurve }; derivation_path : [Blob]; message_hash : Blob; } -> async { signature : Blob }; - start_canister : shared { canister_id : canister_id } -> async (); - stop_canister : shared { canister_id : canister_id } -> async (); - uninstall_code : shared { canister_id : canister_id } -> async (); + start_canister : shared { canister_id : CanisterId } -> async (); + stop_canister : shared { canister_id : CanisterId } -> async (); + uninstall_code : shared { canister_id : CanisterId } -> async (); update_settings : shared { canister_id : Principal; - settings : canister_settings; + settings : CanisterSettings; } -> async (); } } diff --git a/rust/candid_parser/tests/assets/ok/recursion.mo b/rust/candid_parser/tests/assets/ok/recursion.mo index cdb3eb519..50a339127 100644 --- a/rust/candid_parser/tests/assets/ok/recursion.mo +++ b/rust/candid_parser/tests/assets/ok/recursion.mo @@ -4,15 +4,15 @@ module { public type A = B; public type B = ?A; - public type list = ?node; - public type node = { head : Nat; tail : list }; + public type List = ?Node; + public type Node = { head : Nat; tail : List }; /// Doc comment for service id - public type s = actor { f : t; g : shared list -> async (B, tree, stream) }; - public type stream = ?{ head : Nat; next : shared query () -> async stream }; - public type t = shared s -> async (); - public type tree = { - #branch : { val : Int; left : tree; right : tree }; + public type S = actor { f : T; g : shared List -> async (B, Tree, Stream) }; + public type Stream = ?{ head : Nat; next : shared query () -> async Stream }; + public type T = shared S -> async (); + public type Tree = { + #branch : { val : Int; left : Tree; right : Tree }; #leaf : Int; }; - public type Self = s + public type Self = S } diff --git a/rust/candid_parser/tests/assets/ok/recursive_class.mo b/rust/candid_parser/tests/assets/ok/recursive_class.mo index 7d6af941e..f5ba80067 100644 --- a/rust/candid_parser/tests/assets/ok/recursive_class.mo +++ b/rust/candid_parser/tests/assets/ok/recursive_class.mo @@ -2,6 +2,6 @@ // Please use `import service "ic:canister_id"` instead to call canisters on the IC if possible. module { - public type s = actor { next : shared () -> async s }; - public type Self = s -> async s + public type S = actor { next : shared () -> async S }; + public type Self = S -> async S } From 61cc0c88fa351cb45ff7ac5532a181738578b7a1 Mon Sep 17 00:00:00 2001 From: Kamil Listopad Date: Thu, 7 May 2026 13:45:29 +0200 Subject: [PATCH 2/5] style: rustfmt motoko.rs Co-authored-by: Cursor --- rust/candid_parser/src/bindings/motoko.rs | 36 ++++++++++++++++++----- 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/rust/candid_parser/src/bindings/motoko.rs b/rust/candid_parser/src/bindings/motoko.rs index cda1ba959..438a2a8fd 100644 --- a/rust/candid_parser/src/bindings/motoko.rs +++ b/rust/candid_parser/src/bindings/motoko.rs @@ -123,7 +123,10 @@ fn type_display_name(env: &TypeEnv, id: &str) -> String { let camel = to_upper_camel_case(id); let collision = camel.is_empty() || camel == "Self" - || env.0.keys().any(|k| k != id && to_upper_camel_case(k) == camel); + || env + .0 + .keys() + .any(|k| k != id && to_upper_camel_case(k) == camel); if collision { let fallback = escape_str(id); // escape_str doesn't know about "Self" being reserved, so guard explicitly. @@ -154,7 +157,9 @@ fn pp_ty_rich<'a>(env: &'a TypeEnv, ty: &'a Type, syntax: Option<&'a IDLType>) - (TypeInner::Opt(ref inner), Some(IDLType::OptT(syntax))) => { str("?").append(pp_ty_rich(env, inner, Some(syntax))) } - (TypeInner::Vec(ref inner), Some(IDLType::VecT(syntax))) => pp_vec(env, inner, Some(syntax)), + (TypeInner::Vec(ref inner), Some(IDLType::VecT(syntax))) => { + pp_vec(env, inner, Some(syntax)) + } (_, _) => pp_ty(env, ty), } } @@ -246,7 +251,11 @@ fn pp_rets<'a>(env: &'a TypeEnv, args: &'a [Type]) -> RcDoc<'a> { pp_args(env, args) } -fn pp_service<'a>(env: &'a TypeEnv, serv: &'a [(String, Type)], syntax: Option<&'a [syntax::Binding]>) -> RcDoc<'a> { +fn pp_service<'a>( + env: &'a TypeEnv, + serv: &'a [(String, Type)], + syntax: Option<&'a [syntax::Binding]>, +) -> RcDoc<'a> { let methods = serv.iter().map(|(id, func)| { let mut docs = RcDoc::nil(); let mut syntax_field_ty = None; @@ -291,7 +300,11 @@ fn find_field<'a>( (docs, syntax_field_ty) } -fn pp_record<'a>(env: &'a TypeEnv, fields: &'a [Field], syntax: Option<&'a [syntax::TypeField]>) -> RcDoc<'a> { +fn pp_record<'a>( + env: &'a TypeEnv, + fields: &'a [Field], + syntax: Option<&'a [syntax::TypeField]>, +) -> RcDoc<'a> { if is_tuple_fields(fields) { return pp_tuple(env, fields); } @@ -304,7 +317,11 @@ fn pp_record<'a>(env: &'a TypeEnv, fields: &'a [Field], syntax: Option<&'a [synt enclose_space("{", concat(fields, ";"), "}") } -fn pp_variant<'a>(env: &'a TypeEnv, fields: &'a [Field], syntax: Option<&'a [syntax::TypeField]>) -> RcDoc<'a> { +fn pp_variant<'a>( + env: &'a TypeEnv, + fields: &'a [Field], + syntax: Option<&'a [syntax::TypeField]>, +) -> RcDoc<'a> { if fields.is_empty() { return str("{#}"); } @@ -321,7 +338,11 @@ fn pp_variant<'a>(env: &'a TypeEnv, fields: &'a [Field], syntax: Option<&'a [syn enclose_space("{", concat(fields, ";"), "}") } -fn pp_class<'a>(env: &'a TypeEnv, (args, ty): (&'a [Type], &'a Type), syntax: Option<&'a IDLType>) -> RcDoc<'a> { +fn pp_class<'a>( + env: &'a TypeEnv, + (args, ty): (&'a [Type], &'a Type), + syntax: Option<&'a IDLType>, +) -> RcDoc<'a> { let doc = pp_args(env, args).append(" -> async "); match ty.as_ref() { TypeInner::Service(_) => doc.append(pp_ty_rich(env, ty, syntax)), @@ -357,7 +378,8 @@ fn pp_actor<'a>(env: &'a TypeEnv, ty: &'a Type, syntax: Option<&'a IDLActorType> docs, }) => { let docs = pp_docs(docs); - docs.append(self_doc).append(pp_service(env, serv, Some(fields))) + docs.append(self_doc) + .append(pp_service(env, serv, Some(fields))) } _ => pp_service(env, serv, None), }, From 89f48c8a15a7a6566864d794c2e39bd621fdd171 Mon Sep 17 00:00:00 2001 From: Kamil Listopad Date: Thu, 7 May 2026 13:47:26 +0200 Subject: [PATCH 3/5] refactor(motoko): rename to_upper_camel_case -> to_pascal_case Co-authored-by: Cursor --- rust/candid_parser/src/bindings/motoko.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/rust/candid_parser/src/bindings/motoko.rs b/rust/candid_parser/src/bindings/motoko.rs index 438a2a8fd..a91abe442 100644 --- a/rust/candid_parser/src/bindings/motoko.rs +++ b/rust/candid_parser/src/bindings/motoko.rs @@ -95,7 +95,7 @@ fn escape(id: &str, is_method: bool) -> RcDoc<'_> { RcDoc::text(escape_str(id)) } -fn to_upper_camel_case(s: &str) -> String { +fn to_pascal_case(s: &str) -> String { let mut out = String::with_capacity(s.len()); let mut capitalize = true; for c in s.chars() { @@ -112,7 +112,7 @@ fn to_upper_camel_case(s: &str) -> String { out } -// Returns the UpperCamelCase display name for a type alias, falling back to the +// Returns the PascalCase display name for a type alias, falling back to the // original escaped name when: // - the transform produces an empty string (e.g. id = "_"), // - the result would collide with another alias in env, or @@ -120,13 +120,13 @@ fn to_upper_camel_case(s: &str) -> String { // present, and being conservative here avoids surprises if a file is later // extended with a service declaration). fn type_display_name(env: &TypeEnv, id: &str) -> String { - let camel = to_upper_camel_case(id); + let camel = to_pascal_case(id); let collision = camel.is_empty() || camel == "Self" || env .0 .keys() - .any(|k| k != id && to_upper_camel_case(k) == camel); + .any(|k| k != id && to_pascal_case(k) == camel); if collision { let fallback = escape_str(id); // escape_str doesn't know about "Self" being reserved, so guard explicitly. From a0b1e070f761c67cb1253b846efc18e897ca8ee1 Mon Sep 17 00:00:00 2001 From: Kamil Listopad Date: Thu, 7 May 2026 15:07:38 +0200 Subject: [PATCH 4/5] refactor(motoko): precompute name dict, Ctx struct, test fixtures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Introduce BindingCtx<'a> (Copy+Clone) bundling env + precomputed names map, threaded through all printer functions instead of bare &TypeEnv. compile_inner handles the lifetime split. - build_names() runs one O(N) pass over env to count PascalCase collisions, then assigns display names in a second pass — replaces the previous O(N²) per-type env scan. - to_pascal_case() simplified: just requires lowercase first char, no artificial domain restrictions needed since build_names sees all names at once and detects collisions via count map. - Rename Ctx → BindingCtx; pp_defs uses ctx.names[id] (infallible). - Add pascal_collision.did and self_type.did test fixtures covering two-name PascalCase collisions, verbatim env-key collisions, and the Self reservation guard. - CHANGELOG: document breaking PascalCase rename. Co-authored-by: Cursor --- CHANGELOG.md | 3 + rust/candid_parser/src/bindings/motoko.rs | 220 +++++++++++------- .../tests/assets/ok/pascal_collision.d.ts | 15 ++ .../tests/assets/ok/pascal_collision.did | 7 + .../tests/assets/ok/pascal_collision.js | 5 + .../tests/assets/ok/pascal_collision.mo | 12 + .../tests/assets/ok/pascal_collision.rs | 14 ++ .../tests/assets/ok/self_type.d.ts | 13 ++ .../tests/assets/ok/self_type.did | 5 + .../tests/assets/ok/self_type.js | 3 + .../tests/assets/ok/self_type.mo | 10 + .../tests/assets/ok/self_type.rs | 12 + .../tests/assets/pascal_collision.did | 6 + rust/candid_parser/tests/assets/self_type.did | 4 + 14 files changed, 239 insertions(+), 90 deletions(-) create mode 100644 rust/candid_parser/tests/assets/ok/pascal_collision.d.ts create mode 100644 rust/candid_parser/tests/assets/ok/pascal_collision.did create mode 100644 rust/candid_parser/tests/assets/ok/pascal_collision.js create mode 100644 rust/candid_parser/tests/assets/ok/pascal_collision.mo create mode 100644 rust/candid_parser/tests/assets/ok/pascal_collision.rs create mode 100644 rust/candid_parser/tests/assets/ok/self_type.d.ts create mode 100644 rust/candid_parser/tests/assets/ok/self_type.did create mode 100644 rust/candid_parser/tests/assets/ok/self_type.js create mode 100644 rust/candid_parser/tests/assets/ok/self_type.mo create mode 100644 rust/candid_parser/tests/assets/ok/self_type.rs create mode 100644 rust/candid_parser/tests/assets/pascal_collision.did create mode 100644 rust/candid_parser/tests/assets/self_type.did diff --git a/CHANGELOG.md b/CHANGELOG.md index 042d169b5..73a4c12e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ ### candid_parser +* **Breaking changes:** + + Motoko binding: type alias names are now emitted in PascalCase (e.g. `my_type` → `MyType`, `list` → `List`). Names that cannot be unambiguously converted — those not starting with a lowercase letter, those where two names would collide to the same PascalCase form, or those whose PascalCase form is already taken by another type — fall back to the original escaped name. Any code referencing generated Motoko type aliases by name will need to be updated. + * Bug fixes: + Motoko binding: emit `Float32` for Candid `float32` instead of panicking. `float32` support was added to Motoko in version 1.4.0. diff --git a/rust/candid_parser/src/bindings/motoko.rs b/rust/candid_parser/src/bindings/motoko.rs index a91abe442..8f09a1462 100644 --- a/rust/candid_parser/src/bindings/motoko.rs +++ b/rust/candid_parser/src/bindings/motoko.rs @@ -7,6 +7,7 @@ use candid::pretty::utils::*; use candid::types::{Field, FuncMode, Function, Label, SharedLabel, Type, TypeInner}; use candid::TypeEnv; use pretty::RcDoc; +use std::collections::HashMap; // The definition of tuple is language specific. fn is_tuple(t: &Type) -> bool { @@ -78,6 +79,7 @@ static KEYWORDS: [&str; 48] = [ "while", "with", ]; + fn escape_str(id: &str) -> String { if KEYWORDS.contains(&id) || (is_valid_as_id(id) && id.ends_with('_')) { format!("{id}_") @@ -95,12 +97,18 @@ fn escape(id: &str, is_method: bool) -> RcDoc<'_> { RcDoc::text(escape_str(id)) } -fn to_pascal_case(s: &str) -> String { +// Capitalises the first letter and each letter following '_', stripping +// underscores. Returns None for names that don't start with a lowercase letter; +// those are left as-is by build_names. A trailing '_' is silently consumed +// (the capitalize flag is set but no character follows). +fn to_pascal_case(s: &str) -> Option { + if !s.starts_with(|c: char| c.is_ascii_lowercase()) { + return None; + } let mut out = String::with_capacity(s.len()); let mut capitalize = true; for c in s.chars() { - // '_' is the standard Candid separator; '-' is included defensively - if c == '_' || c == '-' { + if c == '_' { capitalize = true; } else if capitalize { out.extend(c.to_uppercase()); @@ -109,62 +117,80 @@ fn to_pascal_case(s: &str) -> String { out.push(c); } } - out + Some(out) } -// Returns the PascalCase display name for a type alias, falling back to the -// original escaped name when: -// - the transform produces an empty string (e.g. id = "_"), -// - the result would collide with another alias in env, or -// - the result is "Self" (always reserved: pp_actor emits it when an actor is -// present, and being conservative here avoids surprises if a file is later -// extended with a service declaration). -fn type_display_name(env: &TypeEnv, id: &str) -> String { - let camel = to_pascal_case(id); - let collision = camel.is_empty() - || camel == "Self" - || env - .0 - .keys() - .any(|k| k != id && to_pascal_case(k) == camel); - if collision { - let fallback = escape_str(id); - // escape_str doesn't know about "Self" being reserved, so guard explicitly. - if fallback == "Self" { - format!("{fallback}_") - } else { - fallback +// Precomputes the display name for every type alias in env. Used once at the +// top of compile so all printer functions can do O(1) name lookups. +// +// A PascalCase name is used when it is unambiguous: no other name in env maps +// to the same PascalCase form (count == 1), the result is not the reserved +// "Self", and no verbatim env key already has that name. +fn build_names(env: &TypeEnv) -> HashMap { + let mut pascal_counts: HashMap = HashMap::new(); + for id in env.0.keys() { + if let Some(pascal) = to_pascal_case(id) { + *pascal_counts.entry(pascal).or_default() += 1; } - } else { - camel } + + env.0 + .keys() + .map(|id| { + let display = match to_pascal_case(id) { + Some(pascal) + if pascal_counts.get(&pascal).copied() == Some(1) + && pascal != "Self" + && !env.0.contains_key(&pascal) => + { + pascal + } + _ => { + let fallback = escape_str(id); + // escape_str doesn't reserve "Self"; guard explicitly. + if fallback == "Self" { + format!("{fallback}_") + } else { + fallback + } + } + }; + (id.clone(), display) + }) + .collect() } -fn pp_ty_rich<'a>(env: &'a TypeEnv, ty: &'a Type, syntax: Option<&'a IDLType>) -> RcDoc<'a> { +#[derive(Copy, Clone)] +struct BindingCtx<'a> { + env: &'a TypeEnv, + names: &'a HashMap, +} + +fn pp_ty_rich<'a>(ctx: BindingCtx<'a>, ty: &'a Type, syntax: Option<&'a IDLType>) -> RcDoc<'a> { match (ty.as_ref(), syntax) { (TypeInner::Service(ref meths), Some(IDLType::ServT(methods))) => { - pp_service(env, meths, Some(methods)) + pp_service(ctx, meths, Some(methods)) } (TypeInner::Class(ref args, t), Some(IDLType::ClassT(_, syntax_t))) => { - pp_class(env, (args, t), Some(syntax_t)) + pp_class(ctx, (args, t), Some(syntax_t)) } (TypeInner::Record(ref fields), Some(IDLType::RecordT(syntax_fields))) => { - pp_record(env, fields, Some(syntax_fields)) + pp_record(ctx, fields, Some(syntax_fields)) } (TypeInner::Variant(ref fields), Some(IDLType::VariantT(syntax_fields))) => { - pp_variant(env, fields, Some(syntax_fields)) + pp_variant(ctx, fields, Some(syntax_fields)) } (TypeInner::Opt(ref inner), Some(IDLType::OptT(syntax))) => { - str("?").append(pp_ty_rich(env, inner, Some(syntax))) + str("?").append(pp_ty_rich(ctx, inner, Some(syntax))) } (TypeInner::Vec(ref inner), Some(IDLType::VecT(syntax))) => { - pp_vec(env, inner, Some(syntax)) + pp_vec(ctx, inner, Some(syntax)) } - (_, _) => pp_ty(env, ty), + (_, _) => pp_ty(ctx, ty), } } -fn pp_ty<'a>(env: &'a TypeEnv, ty: &'a Type) -> RcDoc<'a> { +fn pp_ty<'a>(ctx: BindingCtx<'a>, ty: &'a Type) -> RcDoc<'a> { use TypeInner::*; match ty.as_ref() { Null => str("Null"), @@ -184,15 +210,15 @@ fn pp_ty<'a>(env: &'a TypeEnv, ty: &'a Type) -> RcDoc<'a> { Text => str("Text"), Reserved => str("Any"), Empty => str("None"), - Var(ref s) => RcDoc::text(type_display_name(env, s)), + Var(ref s) => RcDoc::text(ctx.names.get(s).cloned().unwrap_or_else(|| escape_str(s))), Principal => str("Principal"), - Opt(ref t) => str("?").append(pp_ty(env, t)), - Vec(ref t) => pp_vec(env, t, None), - Record(ref fs) => pp_record(env, fs, None), - Variant(ref fs) => pp_variant(env, fs, None), - Func(ref func) => pp_function(env, func), - Service(ref serv) => pp_service(env, serv, None), - Class(ref args, ref t) => pp_class(env, (args, t), None), + Opt(ref t) => str("?").append(pp_ty(ctx, t)), + Vec(ref t) => pp_vec(ctx, t, None), + Record(ref fs) => pp_record(ctx, fs, None), + Variant(ref fs) => pp_variant(ctx, fs, None), + Func(ref func) => pp_function(ctx, func), + Service(ref serv) => pp_service(ctx, serv, None), + Class(ref args, ref t) => pp_class(ctx, (args, t), None), Knot(_) | Unknown | Future => unreachable!(), } } @@ -207,9 +233,9 @@ fn pp_label(id: &SharedLabel) -> RcDoc<'_> { } } -fn pp_function<'a>(env: &'a TypeEnv, func: &'a Function) -> RcDoc<'a> { - let args = pp_args(env, &func.args); - let rets = pp_rets(env, &func.rets); +fn pp_function<'a>(ctx: BindingCtx<'a>, func: &'a Function) -> RcDoc<'a> { + let args = pp_args(ctx, &func.args); + let rets = pp_rets(ctx, &func.rets); match func.modes.as_slice() { [FuncMode::Oneway] => kwd("shared").append(args).append(" -> ").append("()"), [FuncMode::Query] => kwd("shared query") @@ -231,28 +257,29 @@ fn pp_function<'a>(env: &'a TypeEnv, func: &'a Function) -> RcDoc<'a> { } .nest(INDENT_SPACE) } -fn pp_args<'a>(env: &'a TypeEnv, args: &'a [Type]) -> RcDoc<'a> { + +fn pp_args<'a>(ctx: BindingCtx<'a>, args: &'a [Type]) -> RcDoc<'a> { match args { [ty] => { if is_tuple(ty) { - enclose("(", pp_ty(env, ty), ")") + enclose("(", pp_ty(ctx, ty), ")") } else { - pp_ty(env, ty) + pp_ty(ctx, ty) } } _ => { - let doc = concat(args.iter().map(|ty| pp_ty(env, ty)), ","); + let doc = concat(args.iter().map(|ty| pp_ty(ctx, ty)), ","); enclose("(", doc, ")") } } } -fn pp_rets<'a>(env: &'a TypeEnv, args: &'a [Type]) -> RcDoc<'a> { - pp_args(env, args) +fn pp_rets<'a>(ctx: BindingCtx<'a>, args: &'a [Type]) -> RcDoc<'a> { + pp_args(ctx, args) } fn pp_service<'a>( - env: &'a TypeEnv, + ctx: BindingCtx<'a>, serv: &'a [(String, Type)], syntax: Option<&'a [syntax::Binding]>, ) -> RcDoc<'a> { @@ -267,21 +294,21 @@ fn pp_service<'a>( } docs.append(escape(id, true)) .append(" : ") - .append(pp_ty_rich(env, func, syntax_field_ty)) + .append(pp_ty_rich(ctx, func, syntax_field_ty)) }); kwd("actor").append(enclose_space("{", concat(methods, ";"), "}")) } -fn pp_tuple<'a>(env: &'a TypeEnv, fields: &'a [Field]) -> RcDoc<'a> { - let tuple = concat(fields.iter().map(|f| pp_ty(env, &f.ty)), ","); +fn pp_tuple<'a>(ctx: BindingCtx<'a>, fields: &'a [Field]) -> RcDoc<'a> { + let tuple = concat(fields.iter().map(|f| pp_ty(ctx, &f.ty)), ","); enclose("(", tuple, ")") } -fn pp_vec<'a>(env: &'a TypeEnv, inner: &'a Type, syntax: Option<&'a IDLType>) -> RcDoc<'a> { +fn pp_vec<'a>(ctx: BindingCtx<'a>, inner: &'a Type, syntax: Option<&'a IDLType>) -> RcDoc<'a> { if matches!(inner.as_ref(), TypeInner::Nat8) { str("Blob") } else { - enclose("[", pp_ty_rich(env, inner, syntax), "]") + enclose("[", pp_ty_rich(ctx, inner, syntax), "]") } } @@ -301,24 +328,24 @@ fn find_field<'a>( } fn pp_record<'a>( - env: &'a TypeEnv, + ctx: BindingCtx<'a>, fields: &'a [Field], syntax: Option<&'a [syntax::TypeField]>, ) -> RcDoc<'a> { if is_tuple_fields(fields) { - return pp_tuple(env, fields); + return pp_tuple(ctx, fields); } let fields = fields.iter().map(|field| { let (docs, syntax_field) = find_field(syntax, &field.id); docs.append(pp_label(&field.id)) .append(" : ") - .append(pp_ty_rich(env, &field.ty, syntax_field)) + .append(pp_ty_rich(ctx, &field.ty, syntax_field)) }); enclose_space("{", concat(fields, ";"), "}") } fn pp_variant<'a>( - env: &'a TypeEnv, + ctx: BindingCtx<'a>, fields: &'a [Field], syntax: Option<&'a [syntax::TypeField]>, ) -> RcDoc<'a> { @@ -330,7 +357,7 @@ fn pp_variant<'a>( let doc = docs.append(str("#")).append(pp_label(&field.id)); if *field.ty != TypeInner::Null { doc.append(" : ") - .append(pp_ty_rich(env, &field.ty, syntax_field)) + .append(pp_ty_rich(ctx, &field.ty, syntax_field)) } else { doc } @@ -339,14 +366,14 @@ fn pp_variant<'a>( } fn pp_class<'a>( - env: &'a TypeEnv, + ctx: BindingCtx<'a>, (args, ty): (&'a [Type], &'a Type), syntax: Option<&'a IDLType>, ) -> RcDoc<'a> { - let doc = pp_args(env, args).append(" -> async "); + let doc = pp_args(ctx, args).append(" -> async "); match ty.as_ref() { - TypeInner::Service(_) => doc.append(pp_ty_rich(env, ty, syntax)), - TypeInner::Var(_) => doc.append(pp_ty(env, ty)), + TypeInner::Service(_) => doc.append(pp_ty_rich(ctx, ty, syntax)), + TypeInner::Var(_) => doc.append(pp_ty(ctx, ty)), _ => unreachable!(), } } @@ -355,21 +382,22 @@ fn pp_docs<'a>(docs: &'a [String]) -> RcDoc<'a> { lines(docs.iter().map(|line| RcDoc::text("/// ").append(line))) } -fn pp_defs<'a>(env: &'a TypeEnv, prog: &'a IDLMergedProg) -> RcDoc<'a> { - lines(env.0.iter().map(|(id, ty)| { +fn pp_defs<'a>(ctx: BindingCtx<'a>, prog: &'a IDLMergedProg) -> RcDoc<'a> { + lines(ctx.env.0.iter().map(|(id, ty)| { let syntax = prog.lookup(id); let docs = syntax .map(|b| pp_docs(b.docs.as_ref())) .unwrap_or(RcDoc::nil()); + let name = ctx.names[id].clone(); docs.append(kwd("public type")) - .append(RcDoc::text(type_display_name(env, id))) + .append(RcDoc::text(name)) .append(" = ") - .append(pp_ty_rich(env, ty, syntax.map(|b| &b.typ))) + .append(pp_ty_rich(ctx, ty, syntax.map(|b| &b.typ))) .append(";") })) } -fn pp_actor<'a>(env: &'a TypeEnv, ty: &'a Type, syntax: Option<&'a IDLActorType>) -> RcDoc<'a> { +fn pp_actor<'a>(ctx: BindingCtx<'a>, ty: &'a Type, syntax: Option<&'a IDLActorType>) -> RcDoc<'a> { let self_doc = kwd("public type Self ="); match ty.as_ref() { TypeInner::Service(ref serv) => match syntax { @@ -379,9 +407,9 @@ fn pp_actor<'a>(env: &'a TypeEnv, ty: &'a Type, syntax: Option<&'a IDLActorType> }) => { let docs = pp_docs(docs); docs.append(self_doc) - .append(pp_service(env, serv, Some(fields))) + .append(pp_service(ctx, serv, Some(fields))) } - _ => pp_service(env, serv, None), + _ => pp_service(ctx, serv, None), }, TypeInner::Class(ref args, ref t) => match syntax { Some(IDLActorType { @@ -390,33 +418,45 @@ fn pp_actor<'a>(env: &'a TypeEnv, ty: &'a Type, syntax: Option<&'a IDLActorType> }) => { let docs = pp_docs(docs); docs.append(self_doc) - .append(pp_class(env, (args, t), Some(syntax_t))) + .append(pp_class(ctx, (args, t), Some(syntax_t))) } - _ => self_doc.append(pp_class(env, (args, t), None)), + _ => self_doc.append(pp_class(ctx, (args, t), None)), }, - TypeInner::Var(_) => self_doc.append(pp_ty(env, ty)), + TypeInner::Var(_) => self_doc.append(pp_ty(ctx, ty)), _ => unreachable!(), } } -pub fn compile(env: &TypeEnv, actor: &Option, prog: &IDLMergedProg) -> String { +// Separate from `compile` so that `names` and `syntax_actor` (locals in +// `compile`) are provably live for the entire lifetime of the RcDoc they feed. +fn compile_inner<'a>( + ctx: BindingCtx<'a>, + actor: &'a Option, + syntax_actor: Option<&'a IDLActorType>, + prog: &'a IDLMergedProg, +) -> String { let header = r#"// This is a generated Motoko binding. // Please use `import service "ic:canister_id"` instead to call canisters on the IC if possible. "#; - let syntax_actor = prog.resolve_actor().ok().flatten(); let doc = match actor { - None => pp_defs(env, prog), - Some(actor) => { - let defs = pp_defs(env, prog); - let actor = pp_actor(env, actor, syntax_actor.as_ref()); - defs.append(actor) - } + None => pp_defs(ctx, prog), + Some(actor) => pp_defs(ctx, prog).append(pp_actor(ctx, actor, syntax_actor)), }; - let doc = RcDoc::text(header) + RcDoc::text(header) .append(RcDoc::line()) .append("module ") .append(enclose_space("{", doc, "}")) .pretty(LINE_WIDTH) - .to_string(); - doc + .to_string() +} + +pub fn compile(env: &TypeEnv, actor: &Option, prog: &IDLMergedProg) -> String { + let syntax_actor = prog.resolve_actor().ok().flatten(); + let names = build_names(env); + compile_inner( + BindingCtx { env, names: &names }, + actor, + syntax_actor.as_ref(), + prog, + ) } diff --git a/rust/candid_parser/tests/assets/ok/pascal_collision.d.ts b/rust/candid_parser/tests/assets/ok/pascal_collision.d.ts new file mode 100644 index 000000000..e41a4f067 --- /dev/null +++ b/rust/candid_parser/tests/assets/ok/pascal_collision.d.ts @@ -0,0 +1,15 @@ +import type { Principal } from '@icp-sdk/core/principal'; +import type { ActorMethod } from '@icp-sdk/core/agent'; +import type { IDL } from '@icp-sdk/core/candid'; + +/** + * PascalCase output collides with a verbatim env key — foo_baz should fall back. + */ +export type FooBaz = bigint; +export type fooBar = string; +/** + * Two names that both map to the same PascalCase form — both should fall back. + */ +export type foo_bar = bigint; +export type foo_baz = string; + diff --git a/rust/candid_parser/tests/assets/ok/pascal_collision.did b/rust/candid_parser/tests/assets/ok/pascal_collision.did new file mode 100644 index 000000000..81191891b --- /dev/null +++ b/rust/candid_parser/tests/assets/ok/pascal_collision.did @@ -0,0 +1,7 @@ +// Two names that both map to the same PascalCase form — both should fall back. +type foo_bar = nat; +type fooBar = text; +// PascalCase output collides with a verbatim env key — foo_baz should fall back. +type FooBaz = nat; +type foo_baz = text; + diff --git a/rust/candid_parser/tests/assets/ok/pascal_collision.js b/rust/candid_parser/tests/assets/ok/pascal_collision.js new file mode 100644 index 000000000..a0b3ee07d --- /dev/null +++ b/rust/candid_parser/tests/assets/ok/pascal_collision.js @@ -0,0 +1,5 @@ +const FooBaz = IDL.Nat; +const fooBar = IDL.Text; +const foo_bar = IDL.Nat; +const foo_baz = IDL.Text; + diff --git a/rust/candid_parser/tests/assets/ok/pascal_collision.mo b/rust/candid_parser/tests/assets/ok/pascal_collision.mo new file mode 100644 index 000000000..2858d2d2b --- /dev/null +++ b/rust/candid_parser/tests/assets/ok/pascal_collision.mo @@ -0,0 +1,12 @@ +// This is a generated Motoko binding. +// Please use `import service "ic:canister_id"` instead to call canisters on the IC if possible. + +module { + /// PascalCase output collides with a verbatim env key — foo_baz should fall back. + public type FooBaz = Nat; + public type fooBar = Text; + /// Two names that both map to the same PascalCase form — both should fall back. + public type foo_bar = Nat; + public type foo_baz = Text; + +} diff --git a/rust/candid_parser/tests/assets/ok/pascal_collision.rs b/rust/candid_parser/tests/assets/ok/pascal_collision.rs new file mode 100644 index 000000000..e9c9a58f9 --- /dev/null +++ b/rust/candid_parser/tests/assets/ok/pascal_collision.rs @@ -0,0 +1,14 @@ +// This is an experimental feature to generate Rust binding from Candid. +// You may want to manually adjust some of the types. +#![allow(dead_code, unused_imports)] +use candid::{self, CandidType, Deserialize, Principal}; +use ic_cdk::api::call::CallResult as Result; + +/// PascalCase output collides with a verbatim env key — foo_baz should fall back. +pub type FooBaz = candid::Nat; +pub type FooBar = String; +/// Two names that both map to the same PascalCase form — both should fall back. +pub type FooBar = candid::Nat; +pub type FooBaz = String; + + diff --git a/rust/candid_parser/tests/assets/ok/self_type.d.ts b/rust/candid_parser/tests/assets/ok/self_type.d.ts new file mode 100644 index 000000000..aa7baa066 --- /dev/null +++ b/rust/candid_parser/tests/assets/ok/self_type.d.ts @@ -0,0 +1,13 @@ +import type { Principal } from '@icp-sdk/core/principal'; +import type { ActorMethod } from '@icp-sdk/core/agent'; +import type { IDL } from '@icp-sdk/core/candid'; + +/** + * Verbatim "Self" in env — falls back to "Self_" to avoid shadowing pp_actor output. + */ +export type Self = string; +/** + * "self" would PascalCase to "Self" which is reserved — falls back to "self". + */ +export type self = bigint; + diff --git a/rust/candid_parser/tests/assets/ok/self_type.did b/rust/candid_parser/tests/assets/ok/self_type.did new file mode 100644 index 000000000..cf61bd6eb --- /dev/null +++ b/rust/candid_parser/tests/assets/ok/self_type.did @@ -0,0 +1,5 @@ +// "self" would PascalCase to "Self" which is reserved — falls back to "self". +type self = nat; +// Verbatim "Self" in env — falls back to "Self_" to avoid shadowing pp_actor output. +type Self = text; + diff --git a/rust/candid_parser/tests/assets/ok/self_type.js b/rust/candid_parser/tests/assets/ok/self_type.js new file mode 100644 index 000000000..35f2aa415 --- /dev/null +++ b/rust/candid_parser/tests/assets/ok/self_type.js @@ -0,0 +1,3 @@ +const Self = IDL.Text; +const self = IDL.Nat; + diff --git a/rust/candid_parser/tests/assets/ok/self_type.mo b/rust/candid_parser/tests/assets/ok/self_type.mo new file mode 100644 index 000000000..1a8fca16a --- /dev/null +++ b/rust/candid_parser/tests/assets/ok/self_type.mo @@ -0,0 +1,10 @@ +// This is a generated Motoko binding. +// Please use `import service "ic:canister_id"` instead to call canisters on the IC if possible. + +module { + /// Verbatim "Self" in env — falls back to "Self_" to avoid shadowing pp_actor output. + public type Self_ = Text; + /// "self" would PascalCase to "Self" which is reserved — falls back to "self". + public type self = Nat; + +} diff --git a/rust/candid_parser/tests/assets/ok/self_type.rs b/rust/candid_parser/tests/assets/ok/self_type.rs new file mode 100644 index 000000000..17feaa0ff --- /dev/null +++ b/rust/candid_parser/tests/assets/ok/self_type.rs @@ -0,0 +1,12 @@ +// This is an experimental feature to generate Rust binding from Candid. +// You may want to manually adjust some of the types. +#![allow(dead_code, unused_imports)] +use candid::{self, CandidType, Deserialize, Principal}; +use ic_cdk::api::call::CallResult as Result; + +/// Verbatim "Self" in env — falls back to "Self_" to avoid shadowing pp_actor output. +pub type Self_ = String; +/// "self" would PascalCase to "Self" which is reserved — falls back to "self". +pub type Self_ = candid::Nat; + + diff --git a/rust/candid_parser/tests/assets/pascal_collision.did b/rust/candid_parser/tests/assets/pascal_collision.did new file mode 100644 index 000000000..3413e165b --- /dev/null +++ b/rust/candid_parser/tests/assets/pascal_collision.did @@ -0,0 +1,6 @@ +// Two names that both map to the same PascalCase form — both should fall back. +type foo_bar = nat; +type fooBar = text; +// PascalCase output collides with a verbatim env key — foo_baz should fall back. +type FooBaz = nat; +type foo_baz = text; diff --git a/rust/candid_parser/tests/assets/self_type.did b/rust/candid_parser/tests/assets/self_type.did new file mode 100644 index 000000000..5f7cfda20 --- /dev/null +++ b/rust/candid_parser/tests/assets/self_type.did @@ -0,0 +1,4 @@ +// "self" would PascalCase to "Self" which is reserved — falls back to "self". +type self = nat; +// Verbatim "Self" in env — falls back to "Self_" to avoid shadowing pp_actor output. +type Self = text; From febe384cf982ee41cc3baced1c7559dffbc50543 Mon Sep 17 00:00:00 2001 From: Kamil Listopad Date: Thu, 7 May 2026 16:01:48 +0200 Subject: [PATCH 5/5] refactor(motoko): two-pass build_names, BindingCtx, cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - build_names: pass 1 assigns collision-free escape_str fallbacks, pass 2 upgrades to PascalCase where unclaimed — no implicit ordering dependency, fallback collisions provably impossible. - to_pascal_case simplified: just requires lowercase first char. - Rename Ctx → BindingCtx; pp_defs uses ctx.names[id] (infallible). - Remove dead env.0.contains_key collision proxy. - Trim comments throughout. Co-authored-by: Cursor --- rust/candid_parser/src/bindings/motoko.rs | 62 +++++++------------ .../tests/assets/ok/pascal_collision.d.ts | 2 +- .../tests/assets/ok/pascal_collision.did | 2 +- .../tests/assets/ok/pascal_collision.mo | 4 +- .../tests/assets/ok/pascal_collision.rs | 2 +- .../tests/assets/pascal_collision.did | 2 +- 6 files changed, 30 insertions(+), 44 deletions(-) diff --git a/rust/candid_parser/src/bindings/motoko.rs b/rust/candid_parser/src/bindings/motoko.rs index 8f09a1462..57ee6f677 100644 --- a/rust/candid_parser/src/bindings/motoko.rs +++ b/rust/candid_parser/src/bindings/motoko.rs @@ -7,7 +7,7 @@ use candid::pretty::utils::*; use candid::types::{Field, FuncMode, Function, Label, SharedLabel, Type, TypeInner}; use candid::TypeEnv; use pretty::RcDoc; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; // The definition of tuple is language specific. fn is_tuple(t: &Type) -> bool { @@ -97,10 +97,8 @@ fn escape(id: &str, is_method: bool) -> RcDoc<'_> { RcDoc::text(escape_str(id)) } -// Capitalises the first letter and each letter following '_', stripping -// underscores. Returns None for names that don't start with a lowercase letter; -// those are left as-is by build_names. A trailing '_' is silently consumed -// (the capitalize flag is set but no character follows). +// Strips underscores and capitalises each segment start. Returns None for +// names not starting with lowercase (left as-is). Trailing '_' is consumed. fn to_pascal_case(s: &str) -> Option { if !s.starts_with(|c: char| c.is_ascii_lowercase()) { return None; @@ -120,44 +118,32 @@ fn to_pascal_case(s: &str) -> Option { Some(out) } -// Precomputes the display name for every type alias in env. Used once at the -// top of compile so all printer functions can do O(1) name lookups. -// -// A PascalCase name is used when it is unambiguous: no other name in env maps -// to the same PascalCase form (count == 1), the result is not the reserved -// "Self", and no verbatim env key already has that name. +// Precomputes display names for all env type aliases. fn build_names(env: &TypeEnv) -> HashMap { - let mut pascal_counts: HashMap = HashMap::new(); + // Baseline: every id gets its escaped original name (collision-free). + let mut names: HashMap = env + .0 + .keys() + .map(|id| { + let f = escape_str(id); + // escape_str doesn't reserve "Self". + (id.clone(), if f == "Self" { format!("{f}_") } else { f }) + }) + .collect(); + + // Upgrade to PascalCase where the name is unclaimed (first alphabetically wins). + let mut taken: HashSet = names.values().cloned().collect(); for id in env.0.keys() { if let Some(pascal) = to_pascal_case(id) { - *pascal_counts.entry(pascal).or_default() += 1; + if pascal != "Self" && !taken.contains(&pascal) { + let display = names.get_mut(id).unwrap(); + taken.remove(display.as_str()); + taken.insert(pascal.clone()); + *display = pascal; + } } } - - env.0 - .keys() - .map(|id| { - let display = match to_pascal_case(id) { - Some(pascal) - if pascal_counts.get(&pascal).copied() == Some(1) - && pascal != "Self" - && !env.0.contains_key(&pascal) => - { - pascal - } - _ => { - let fallback = escape_str(id); - // escape_str doesn't reserve "Self"; guard explicitly. - if fallback == "Self" { - format!("{fallback}_") - } else { - fallback - } - } - }; - (id.clone(), display) - }) - .collect() + names } #[derive(Copy, Clone)] diff --git a/rust/candid_parser/tests/assets/ok/pascal_collision.d.ts b/rust/candid_parser/tests/assets/ok/pascal_collision.d.ts index e41a4f067..3e1f1ee28 100644 --- a/rust/candid_parser/tests/assets/ok/pascal_collision.d.ts +++ b/rust/candid_parser/tests/assets/ok/pascal_collision.d.ts @@ -8,7 +8,7 @@ import type { IDL } from '@icp-sdk/core/candid'; export type FooBaz = bigint; export type fooBar = string; /** - * Two names that both map to the same PascalCase form — both should fall back. + * Two names that map to the same PascalCase form — first alphabetically wins, second falls back. */ export type foo_bar = bigint; export type foo_baz = string; diff --git a/rust/candid_parser/tests/assets/ok/pascal_collision.did b/rust/candid_parser/tests/assets/ok/pascal_collision.did index 81191891b..fb31dbaba 100644 --- a/rust/candid_parser/tests/assets/ok/pascal_collision.did +++ b/rust/candid_parser/tests/assets/ok/pascal_collision.did @@ -1,4 +1,4 @@ -// Two names that both map to the same PascalCase form — both should fall back. +// Two names that map to the same PascalCase form — first alphabetically wins, second falls back. type foo_bar = nat; type fooBar = text; // PascalCase output collides with a verbatim env key — foo_baz should fall back. diff --git a/rust/candid_parser/tests/assets/ok/pascal_collision.mo b/rust/candid_parser/tests/assets/ok/pascal_collision.mo index 2858d2d2b..142144b2c 100644 --- a/rust/candid_parser/tests/assets/ok/pascal_collision.mo +++ b/rust/candid_parser/tests/assets/ok/pascal_collision.mo @@ -4,8 +4,8 @@ module { /// PascalCase output collides with a verbatim env key — foo_baz should fall back. public type FooBaz = Nat; - public type fooBar = Text; - /// Two names that both map to the same PascalCase form — both should fall back. + public type FooBar = Text; + /// Two names that map to the same PascalCase form — first alphabetically wins, second falls back. public type foo_bar = Nat; public type foo_baz = Text; diff --git a/rust/candid_parser/tests/assets/ok/pascal_collision.rs b/rust/candid_parser/tests/assets/ok/pascal_collision.rs index e9c9a58f9..1ec9dc1f1 100644 --- a/rust/candid_parser/tests/assets/ok/pascal_collision.rs +++ b/rust/candid_parser/tests/assets/ok/pascal_collision.rs @@ -7,7 +7,7 @@ use ic_cdk::api::call::CallResult as Result; /// PascalCase output collides with a verbatim env key — foo_baz should fall back. pub type FooBaz = candid::Nat; pub type FooBar = String; -/// Two names that both map to the same PascalCase form — both should fall back. +/// Two names that map to the same PascalCase form — first alphabetically wins, second falls back. pub type FooBar = candid::Nat; pub type FooBaz = String; diff --git a/rust/candid_parser/tests/assets/pascal_collision.did b/rust/candid_parser/tests/assets/pascal_collision.did index 3413e165b..f573bba9e 100644 --- a/rust/candid_parser/tests/assets/pascal_collision.did +++ b/rust/candid_parser/tests/assets/pascal_collision.did @@ -1,4 +1,4 @@ -// Two names that both map to the same PascalCase form — both should fall back. +// Two names that map to the same PascalCase form — first alphabetically wins, second falls back. type foo_bar = nat; type fooBar = text; // PascalCase output collides with a verbatim env key — foo_baz should fall back.