diff --git a/CHANGELOG.md b/CHANGELOG.md index ef6b4a43b..81bdcd1ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +* Non-breaking changes: + + Preserve Rust doc comments on exported Candid types, record fields, and variant members when generating `.did` files via `#[derive(CandidType)]` + ## 2026-02-27 ### Candid 0.10.24 diff --git a/rust/candid/src/pretty/candid.rs b/rust/candid/src/pretty/candid.rs index 062b5d3a0..cb04d68bc 100644 --- a/rust/candid/src/pretty/candid.rs +++ b/rust/candid/src/pretty/candid.rs @@ -1,7 +1,10 @@ use std::collections::HashMap; use crate::pretty::utils::*; -use crate::types::{Field, FuncMode, Function, Label, SharedLabel, Type, TypeEnv, TypeInner}; +use crate::types::{ + Field, FieldDoc, FuncMode, Function, Label, SharedLabel, Type, TypeDoc, TypeDocs, TypeEnv, + TypeInner, +}; use pretty::RcDoc; static KEYWORDS: [&str; 30] = [ @@ -125,6 +128,12 @@ pub fn pp_docs<'a>(docs: &'a [String]) -> RcDoc<'a> { lines(docs.iter().map(|line| RcDoc::text("// ").append(line))) } +fn maybe_pp_docs<'a>(docs: Option<&'a [String]>) -> RcDoc<'a> { + docs.filter(|docs| !docs.is_empty()) + .map(pp_docs) + .unwrap_or_else(RcDoc::nil) +} + /// This function is kept for backward compatibility. /// /// It is recommended to use [`pp_label_raw`] instead, which accepts a [`Label`]. @@ -153,6 +162,56 @@ fn pp_fields(fs: &[Field], is_variant: bool) -> RcDoc<'_> { enclose_space("{", concat(fields, ";"), "}") } +fn pp_field_with_doc<'a>( + field: &'a Field, + is_variant: bool, + doc: Option<&'a FieldDoc>, +) -> RcDoc<'a> { + let docs = maybe_pp_docs(doc.map(|doc| doc.docs.as_slice())); + let ty_doc = if is_variant && *field.ty == TypeInner::Null { + RcDoc::nil() + } else { + kwd(" :").append(pp_ty_with_doc( + &field.ty, + doc.and_then(|doc| doc.ty.as_deref()), + )) + }; + docs.append(pp_label_raw(&field.id)).append(ty_doc) +} + +fn pp_fields_with_doc<'a>( + fs: &'a [Field], + is_variant: bool, + doc: Option<&'a TypeDoc>, +) -> RcDoc<'a> { + let fields = fs.iter().map(|field| { + let field_doc = doc.and_then(|doc| doc.fields.get(&field.id.get_id())); + pp_field_with_doc(field, is_variant, field_doc) + }); + enclose_space("{", concat(fields, ";"), "}") +} + +fn has_field_docs(doc: Option<&TypeDoc>) -> bool { + doc.map(|doc| doc.fields.values().any(|field| !field.is_empty())) + .unwrap_or(false) +} + +fn pp_ty_with_doc<'a>(ty: &'a Type, doc: Option<&'a TypeDoc>) -> RcDoc<'a> { + use TypeInner::*; + match ty.as_ref() { + Record(ref fs) => { + if ty.is_tuple() && !has_field_docs(doc) { + let tuple = concat(fs.iter().map(|f| pp_ty_with_doc(&f.ty, None)), ";"); + kwd("record").append(enclose_space("{", tuple, "}")) + } else { + kwd("record").append(pp_fields_with_doc(fs, false, doc)) + } + } + Variant(ref fs) => kwd("variant").append(pp_fields_with_doc(fs, true, doc)), + ty => pp_ty_inner(ty), + } +} + pub fn pp_function(func: &Function) -> RcDoc<'_> { let args = pp_args(&func.args); let rets = pp_rets(&func.rets); @@ -204,7 +263,7 @@ fn pp_service<'a>(serv: &'a [(String, Type)], docs: Option<&'a DocComments>) -> enclose_space("{", doc, "}") } -fn pp_defs(env: &TypeEnv) -> RcDoc<'_> { +fn pp_defs_plain(env: &TypeEnv) -> RcDoc<'_> { lines(env.0.iter().map(|(id, ty)| { kwd("type") .append(ident(id)) @@ -214,6 +273,18 @@ fn pp_defs(env: &TypeEnv) -> RcDoc<'_> { })) } +fn pp_defs<'a>(env: &'a TypeEnv, docs: &'a DocComments) -> RcDoc<'a> { + lines(env.0.iter().map(|(id, ty)| { + let type_doc = docs.lookup_type_def(id); + maybe_pp_docs(type_doc.map(|doc| doc.docs.as_slice())) + .append(kwd("type")) + .append(ident(id)) + .append(kwd("=")) + .append(pp_ty_with_doc(ty, type_doc)) + .append(";") + })) +} + fn pp_class<'a>(args: &'a [Type], t: &'a Type, docs: Option<&'a DocComments>) -> RcDoc<'a> { let doc = pp_args(args).append(" ->").append(RcDoc::space()); match t.as_ref() { @@ -234,13 +305,14 @@ fn pp_actor<'a>(ty: &'a Type, docs: &'a DocComments) -> RcDoc<'a> { /// Pretty-prints the initialization arguments for a Candid actor. pub fn pp_init_args<'a>(env: &'a TypeEnv, args: &'a [Type]) -> RcDoc<'a> { - pp_defs(env).append(pp_args(args)) + pp_defs_plain(env).append(pp_args(args)) } /// Collects doc comments that can be passed to the [compile_with_docs] function. #[derive(Default, Debug)] pub struct DocComments { service_methods: HashMap>, + type_defs: HashMap, } impl DocComments { @@ -255,6 +327,22 @@ impl DocComments { pub fn lookup_service_method(&self, method: &str) -> Option<&Vec> { self.service_methods.get(method) } + + pub fn add_type_def(&mut self, name: String, doc: TypeDoc) { + if !doc.is_empty() { + self.type_defs.insert(name, doc); + } + } + + pub fn lookup_type_def(&self, name: &str) -> Option<&TypeDoc> { + self.type_defs.get(name) + } + + pub fn extend_types(&mut self, docs: TypeDocs) { + for (name, doc) in docs.named { + self.add_type_def(name, doc); + } + } } pub fn compile(env: &TypeEnv, actor: &Option) -> String { @@ -281,9 +369,9 @@ pub fn compile(env: &TypeEnv, actor: &Option) -> String { /// ``` pub fn compile_with_docs(env: &TypeEnv, actor: &Option, docs: &DocComments) -> String { match actor { - None => pp_defs(env).pretty(LINE_WIDTH).to_string(), + None => pp_defs(env, docs).pretty(LINE_WIDTH).to_string(), Some(actor) => { - let defs = pp_defs(env); + let defs = pp_defs(env, docs); let actor = kwd("service :").append(pp_actor(actor, docs)); let doc = defs.append(actor); doc.pretty(LINE_WIDTH).to_string() diff --git a/rust/candid/src/types/internal.rs b/rust/candid/src/types/internal.rs index b4a862ec4..6e82e15cf 100644 --- a/rust/candid/src/types/internal.rs +++ b/rust/candid/src/types/internal.rs @@ -71,6 +71,39 @@ impl TypeName { } } +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct TypeDocs { + pub named: BTreeMap, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct TypeDoc { + pub docs: Vec, + pub fields: BTreeMap, +} + +impl TypeDoc { + pub fn is_empty(&self) -> bool { + self.docs.is_empty() && self.fields.is_empty() + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct FieldDoc { + pub docs: Vec, + pub ty: Option>, +} + +impl FieldDoc { + pub fn is_empty(&self) -> bool { + self.docs.is_empty() + && match self.ty.as_deref() { + None => true, + Some(doc) => doc.is_empty(), + } + } +} + /// Used for `candid_derive::export_service` to generate `TypeEnv` from `Type`. /// /// It performs a global rewriting of `Type` to resolve: @@ -85,11 +118,13 @@ impl TypeName { #[derive(Default)] pub struct TypeContainer { pub env: crate::TypeEnv, + pub docs: TypeDocs, } impl TypeContainer { pub fn new() -> Self { TypeContainer { env: crate::TypeEnv::new(), + docs: TypeDocs::default(), } } pub fn add(&mut self) -> Type { @@ -115,8 +150,10 @@ impl TypeContainer { } let id = ID.with(|n| n.borrow().get(t).cloned()); if let Some(id) = id { - self.env.0.insert(id.to_string(), res); - TypeInner::Var(id.to_string()) + let name = id.to_string(); + self.env.0.insert(name.clone(), res); + self.remember_named_doc(&id, &name); + TypeInner::Var(name) } else { // if the type is part of an enum, the id won't be recorded. // we want to inline the type in this case. @@ -135,8 +172,10 @@ impl TypeContainer { .into(); let id = ID.with(|n| n.borrow().get(t).cloned()); if let Some(id) = id { - self.env.0.insert(id.to_string(), res); - TypeInner::Var(id.to_string()) + let name = id.to_string(); + self.env.0.insert(name.clone(), res); + self.remember_named_doc(&id, &name); + TypeInner::Var(name) } else { return res; } @@ -145,6 +184,7 @@ impl TypeContainer { let name = id.to_string(); let ty = ENV.with(|e| e.borrow().get(id).unwrap().clone()); self.env.0.insert(id.to_string(), ty); + self.remember_named_doc(id, &name); TypeInner::Var(name) } TypeInner::Func(func) => TypeInner::Func(Function { @@ -164,6 +204,14 @@ impl TypeContainer { } .into() } + + fn remember_named_doc(&mut self, id: &TypeId, name: &str) { + if let Some(doc) = find_type_doc(id) { + if !doc.is_empty() { + self.docs.named.entry(name.to_string()).or_insert(doc); + } + } + } } #[derive(Debug, PartialEq, Hash, Eq, Clone, PartialOrd, Ord)] @@ -644,6 +692,7 @@ pub fn unroll(t: &Type) -> Type { thread_local! { static ENV: RefCell> = const { RefCell::new(BTreeMap::new()) }; + static DOC_ENV: RefCell> = const { RefCell::new(BTreeMap::new()) }; // only used for TypeContainer static ID: RefCell> = const { RefCell::new(BTreeMap::new()) }; static NAME: RefCell = RefCell::new(TypeName::default()); @@ -653,6 +702,10 @@ pub fn find_type(id: &TypeId) -> Option { ENV.with(|e| e.borrow().get(id).cloned()) } +pub fn find_type_doc(id: &TypeId) -> Option { + DOC_ENV.with(|e| e.borrow().get(id).cloned()) +} + // only for debugging #[allow(dead_code)] pub(crate) fn show_env() { @@ -664,6 +717,7 @@ pub(crate) fn env_add(id: TypeId, t: Type) { } pub fn env_clear() { ENV.with(|e| e.borrow_mut().clear()); + DOC_ENV.with(|e| e.borrow_mut().clear()); } pub(crate) fn env_id(id: TypeId, t: Type) { @@ -684,6 +738,10 @@ pub(crate) fn env_id(id: TypeId, t: Type) { }); } +pub(crate) fn env_doc(id: TypeId, doc: TypeDoc) { + DOC_ENV.with(|e| e.borrow_mut().insert(id, doc)); +} + pub fn get_type(_v: &T) -> Type where T: CandidType, diff --git a/rust/candid/src/types/mod.rs b/rust/candid/src/types/mod.rs index 367780e0f..42b905473 100644 --- a/rust/candid/src/types/mod.rs +++ b/rust/candid/src/types/mod.rs @@ -15,7 +15,8 @@ pub mod type_env; pub mod value; pub use self::internal::{ - get_type, Field, FuncMode, Function, Label, SharedLabel, Type, TypeId, TypeInner, + get_type, Field, FieldDoc, FuncMode, Function, Label, SharedLabel, Type, TypeDoc, TypeDocs, + TypeId, TypeInner, }; pub use type_env::TypeEnv; @@ -44,7 +45,8 @@ pub trait CandidType { self::internal::env_add(id.clone(), TypeInner::Unknown.into()); let t = Self::_ty(); self::internal::env_add(id.clone(), t.clone()); - self::internal::env_id(id, t.clone()); + self::internal::env_id(id.clone(), t.clone()); + self::internal::env_doc(id, Self::_ty_doc()); t } } @@ -52,6 +54,9 @@ pub trait CandidType { TypeId::of::() } fn _ty() -> Type; + fn _ty_doc() -> internal::TypeDoc { + internal::TypeDoc::default() + } // only serialize the value encoding fn idl_serialize(&self, serializer: S) -> Result<(), S::Error> where diff --git a/rust/candid/tests/types.rs b/rust/candid/tests/types.rs index 688fcf2f9..5de3ab2df 100644 --- a/rust/candid/tests/types.rs +++ b/rust/candid/tests/types.rs @@ -339,3 +339,120 @@ fn test_counter() { let expected = "service : { inc : () -> (); read : () -> (nat64) query; set : (nat64) -> () }"; assert_eq!(expected, __export_service()); } + +#[test] +fn test_export_service_type_and_field_docs() { + /// Status docs. + #[derive(CandidType, Deserialize)] + enum Status { + /// Account is active. + Active, + /// Account is banned. + Banned(String), + } + + /// User payload docs. + #[derive(CandidType, Deserialize)] + struct User { + /// Stable identifier. + id: u64, + /// Display name. + name: String, + } + + /// Lookup docs. + #[candid_method(query)] + fn lookup(_: User) -> Status { + unreachable!() + } + + candid::export_service!(); + let expected = r#"// Status docs. +type Status = variant { + // Account is active. + Active; + // Account is banned. + Banned : text; +}; +// User payload docs. +type User = record { + // Stable identifier. + id : nat64; + // Display name. + name : text; +}; +service : { + // Lookup docs. + lookup : (User) -> (Status) query; +}"#; + assert_eq!(expected, __export_service()); +} + +#[test] +fn test_exported_did_parses_with_docs_attached() { + use candid_parser::syntax::{Dec, IDLProg, IDLType}; + + /// Status docs. + #[derive(CandidType, Deserialize)] + enum Status { + /// Account is active. + Active, + /// Account is banned. + Banned(String), + } + + /// User payload docs. + #[derive(CandidType, Deserialize)] + struct User { + /// Stable identifier. + id: u64, + /// Display name. + name: String, + } + + /// Lookup docs. + #[candid_method(query)] + fn lookup(_: User) -> Status { + unreachable!() + } + + candid::export_service!(); + let ast: IDLProg = __export_service().parse().unwrap(); + + let status = ast + .decs + .iter() + .find_map(|dec| match dec { + Dec::TypD(binding) if binding.id == "Status" => Some(binding), + _ => None, + }) + .unwrap(); + assert_eq!(status.docs, vec!["Status docs."]); + let IDLType::VariantT(status_fields) = &status.typ else { + panic!("expected Status to be a variant"); + }; + assert_eq!(status_fields[0].docs, vec!["Account is active."]); + assert_eq!(status_fields[1].docs, vec!["Account is banned."]); + + let user = ast + .decs + .iter() + .find_map(|dec| match dec { + Dec::TypD(binding) if binding.id == "User" => Some(binding), + _ => None, + }) + .unwrap(); + assert_eq!(user.docs, vec!["User payload docs."]); + let IDLType::RecordT(user_fields) = &user.typ else { + panic!("expected User to be a record"); + }; + assert_eq!(user_fields[0].docs, vec!["Stable identifier."]); + assert_eq!(user_fields[1].docs, vec!["Display name."]); + + let actor = ast.actor.unwrap(); + let IDLType::ServT(methods) = &actor.typ else { + panic!("expected actor to be a service"); + }; + assert_eq!(methods[0].id, "lookup"); + assert_eq!(methods[0].docs, vec!["Lookup docs."]); +} diff --git a/rust/candid_derive/src/derive.rs b/rust/candid_derive/src/derive.rs index 7f2838ff1..8da7fa26f 100644 --- a/rust/candid_derive/src/derive.rs +++ b/rust/candid_derive/src/derive.rs @@ -1,4 +1,4 @@ -use super::{candid_path, get_lit_str, idl_hash}; +use super::{candid_path, docs::extract_doc_comments, get_lit_str, idl_hash}; use proc_macro2::TokenStream; use quote::quote; use std::collections::BTreeSet; @@ -11,14 +11,22 @@ pub(crate) fn derive_idl_type( custom_candid_path: &Option, ) -> TokenStream { let candid = candid_path(custom_candid_path); + let root_docs = extract_doc_comments(&input.attrs); let name = input.ident; let generics = add_trait_bounds(input.generics, custom_candid_path); let (impl_generics, ty_generics, where_clause) = generics.split_for_impl(); - let (ty_body, ser_body) = match input.data { - Data::Enum(ref data) => enum_from_ast(&name, &data.variants, custom_candid_path), + let (ty_body, ty_doc_body, ser_body) = match input.data { + Data::Enum(ref data) => { + enum_from_ast(&name, &root_docs, &data.variants, custom_candid_path) + } Data::Struct(ref data) => { - let (ty, idents, is_bytes, _) = struct_from_ast(&data.fields, custom_candid_path); - (ty, serialize_struct(&idents, &is_bytes, custom_candid_path)) + let mut shape = struct_from_ast(&data.fields, custom_candid_path); + shape.doc.docs = root_docs; + ( + shape.ty, + quote_type_doc(&candid, &shape.doc), + serialize_struct(&shape.members, &shape.is_bytes, custom_candid_path), + ) } Data::Union(_) => unimplemented!("doesn't derive union type"), }; @@ -27,6 +35,9 @@ pub(crate) fn derive_idl_type( fn _ty() -> #candid::types::Type { #ty_body } + fn _ty_doc() -> #candid::types::TypeDoc { + #ty_doc_body + } fn id() -> #candid::types::TypeId { #candid::types::TypeId::of::<#name #ty_generics>() } fn idl_serialize<__S>(&self, __serializer: __S) -> ::std::result::Result<(), __S::Error> @@ -41,11 +52,50 @@ pub(crate) fn derive_idl_type( gen } +#[derive(Clone, Default)] +struct TypeDocSpec { + docs: Vec, + fields: Vec, +} + +impl TypeDocSpec { + fn is_empty(&self) -> bool { + self.docs.is_empty() && self.fields.iter().all(DocField::is_empty) + } +} + +#[derive(Clone)] +struct DocField { + key: u32, + docs: Vec, + ty: Option>, +} + +impl DocField { + fn is_empty(&self) -> bool { + self.docs.is_empty() + && match self.ty.as_deref() { + None => true, + Some(doc) => doc.is_empty(), + } + } +} + +struct Shape { + ty: TokenStream, + doc: TypeDocSpec, + members: Vec, + is_bytes: Vec, + style: Style, +} + struct Variant { real_ident: syn::Ident, renamed_ident: String, hash: u32, ty: TokenStream, + docs: Vec, + payload_doc: Option, members: Vec, with_bytes: bool, style: Style, @@ -91,9 +141,10 @@ impl Variant { fn enum_from_ast( name: &syn::Ident, + root_docs: &[String], variants: &Punctuated, custom_candid_path: &Option, -) -> (TokenStream, TokenStream) { +) -> (TokenStream, TokenStream, TokenStream) { let mut fs: Vec<_> = variants .iter() .map(|variant| { @@ -107,15 +158,17 @@ fn enum_from_ast( (id, hash) } }; - let (ty, idents, _, style) = struct_from_ast(&variant.fields, custom_candid_path); + let shape = struct_from_ast(&variant.fields, custom_candid_path); Variant { real_ident: id, renamed_ident, hash, - ty, - members: idents, + ty: shape.ty, + docs: extract_doc_comments(&variant.attrs), + payload_doc: (!shape.doc.is_empty()).then_some(shape.doc), + members: shape.members, with_bytes: attrs.with_bytes, - style, + style: shape.style, } }) .collect(); @@ -136,6 +189,20 @@ fn enum_from_ast( ] ).into() }; + let ty_doc_gen = quote_type_doc( + &candid, + &TypeDocSpec { + docs: root_docs.to_vec(), + fields: fs + .iter() + .map(|variant| DocField { + key: variant.hash, + docs: variant.docs.clone(), + ty: variant.payload_doc.clone().map(Box::new), + }) + .collect(), + }, + ); let id = fs.iter().map(|Variant { real_ident, .. }| { syn::parse_str::(&format!("{name}::{real_ident}")).unwrap() @@ -168,7 +235,7 @@ fn enum_from_ast( }; Ok(()) }; - (ty_gen, variant_gen) + (ty_gen, ty_doc_gen, variant_gen) } fn serialize_struct( @@ -192,41 +259,49 @@ fn serialize_struct( } } -fn struct_from_ast( - fields: &syn::Fields, - custom_candid_path: &Option, -) -> (TokenStream, Vec, Vec, Style) { +fn struct_from_ast(fields: &syn::Fields, custom_candid_path: &Option) -> Shape { let candid = candid_path(custom_candid_path); match *fields { syn::Fields::Named(ref fields) => { - let (fs, idents, is_bytes) = fields_from_ast(&fields.named, custom_candid_path); - ( - quote! { #candid::types::TypeInner::Record(#fs).into() }, - idents, + let (fs, doc, idents, is_bytes) = fields_from_ast(&fields.named, custom_candid_path); + Shape { + ty: quote! { #candid::types::TypeInner::Record(#fs).into() }, + doc, + members: idents, is_bytes, - Style::Struct, - ) + style: Style::Struct, + } } syn::Fields::Unnamed(ref fields) => { - let (fs, idents, is_bytes) = fields_from_ast(&fields.unnamed, custom_candid_path); + let (fs, doc, idents, is_bytes) = fields_from_ast(&fields.unnamed, custom_candid_path); if idents.len() == 1 { + // Newtypes are inlined to the inner type (no record wrapper), + // so field-level docs are not representable in the output. let newtype = derive_type(&fields.unnamed[0].ty, custom_candid_path); - (quote! { #newtype }, idents, is_bytes, Style::Tuple) + Shape { + ty: quote! { #newtype }, + doc: TypeDocSpec::default(), + members: idents, + is_bytes, + style: Style::Tuple, + } } else { - ( - quote! { #candid::types::TypeInner::Record(#fs).into() }, - idents, + Shape { + ty: quote! { #candid::types::TypeInner::Record(#fs).into() }, + doc, + members: idents, is_bytes, - Style::Tuple, - ) + style: Style::Tuple, + } } } - syn::Fields::Unit => ( - quote! { #candid::types::TypeInner::Null.into() }, - Vec::new(), - Vec::new(), - Style::Unit, - ), + syn::Fields::Unit => Shape { + ty: quote! { #candid::types::TypeInner::Null.into() }, + doc: TypeDocSpec::default(), + members: Vec::new(), + is_bytes: Vec::new(), + style: Style::Unit, + }, } } @@ -261,6 +336,7 @@ struct Field { renamed_ident: Ident, hash: u32, ty: TokenStream, + docs: Vec, with_bytes: bool, } @@ -327,7 +403,7 @@ fn get_attrs(attrs: &[syn::Attribute]) -> Attributes { fn fields_from_ast( fields: &Punctuated, custom_candid_path: &Option, -) -> (TokenStream, Vec, Vec) { +) -> (TokenStream, TypeDocSpec, Vec, Vec) { let candid = candid_path(custom_candid_path); let mut fs: Vec<_> = fields .iter() @@ -356,6 +432,7 @@ fn fields_from_ast( renamed_ident, hash, ty: derive_type(&field.ty, custom_candid_path), + docs: extract_doc_comments(&field.attrs), with_bytes: attrs.with_bytes, } }) @@ -390,7 +467,64 @@ fn fields_from_ast( .map(|Field { real_ident, .. }| real_ident.clone()) .collect(); let is_bytes: Vec<_> = fs.iter().map(|f| f.with_bytes).collect(); - (ty_gen, idents, is_bytes) + let doc = TypeDocSpec { + docs: Vec::new(), + fields: fs + .iter() + .map(|field| DocField { + key: field.hash, + docs: field.docs.clone(), + ty: None, + }) + .collect(), + }; + (ty_gen, doc, idents, is_bytes) +} + +fn quote_doc_lines(docs: &[String]) -> Vec { + docs.iter().map(|doc| quote! { #doc.to_string() }).collect() +} + +fn quote_type_doc(candid: &TokenStream, doc: &TypeDocSpec) -> TokenStream { + if doc.is_empty() { + return quote! { #candid::types::TypeDoc::default() }; + } + let docs = quote_doc_lines(&doc.docs); + let field_inserts: Vec<_> = doc + .fields + .iter() + .filter(|field| !field.is_empty()) + .map(|field| { + let key = field.key; + let field_doc = quote_field_doc(candid, field); + quote! { + doc.fields.insert(#key, #field_doc); + } + }) + .collect(); + quote! {{ + let mut doc = #candid::types::TypeDoc::default(); + doc.docs = vec![#(#docs,)*]; + #(#field_inserts)* + doc + }} +} + +fn quote_field_doc(candid: &TokenStream, doc: &DocField) -> TokenStream { + let docs = quote_doc_lines(&doc.docs); + let ty = match doc.ty.as_deref() { + Some(ty) if !ty.is_empty() => { + let ty = quote_type_doc(candid, ty); + quote! { Some(Box::new(#ty)) } + } + _ => quote! { None }, + }; + quote! { + #candid::types::FieldDoc { + docs: vec![#(#docs,)*], + ty: #ty, + } + } } fn derive_type(t: &syn::Type, custom_candid_path: &Option) -> TokenStream { diff --git a/rust/candid_derive/src/docs.rs b/rust/candid_derive/src/docs.rs new file mode 100644 index 000000000..4fb4b0e46 --- /dev/null +++ b/rust/candid_derive/src/docs.rs @@ -0,0 +1,23 @@ +use crate::get_lit_str; +use syn::Attribute; + +pub(crate) fn extract_doc_comments(attrs: &[Attribute]) -> Vec { + let mut docs = Vec::new(); + for attr in attrs { + if attr.path().is_ident("doc") { + if let syn::Meta::NameValue(meta) = &attr.meta { + if let Ok(lit) = get_lit_str(&meta.value) { + let doc_content = lit.value(); + if !doc_content.is_empty() { + for line in doc_content.lines() { + docs.push(line.trim().to_string()); + } + } else { + docs.push(String::new()); + } + } + } + } + } + docs +} diff --git a/rust/candid_derive/src/func.rs b/rust/candid_derive/src/func.rs index 577424564..65cb1cc95 100644 --- a/rust/candid_derive/src/func.rs +++ b/rust/candid_derive/src/func.rs @@ -1,10 +1,10 @@ -use super::{candid_path, get_lit_str}; +use super::{candid_path, docs::extract_doc_comments, get_lit_str}; use lazy_static::lazy_static; use proc_macro2::TokenStream; use quote::{quote, ToTokens}; use std::collections::BTreeMap; use std::sync::Mutex; -use syn::{Attribute, Error, ItemFn, Meta, Result, ReturnType, Signature, Type}; +use syn::{Error, ItemFn, Meta, Result, ReturnType, Signature, Type}; type RawArgs = Vec; type RawRets = Vec; @@ -169,6 +169,7 @@ pub(crate) fn export_service(path: Option) -> TokenStream { fn __export_service() -> String { #service #actor + docs.extend_types(env.docs); let result = #candid::pretty::candid::compile_with_docs(&env.env, &actor, &docs); format!("{}", result) } @@ -248,25 +249,3 @@ fn get_candid_attribute(attrs: Vec) -> Result { } Ok(res) } - -fn extract_doc_comments(attrs: &[Attribute]) -> Vec { - let mut docs = Vec::new(); - for attr in attrs { - if attr.path().is_ident("doc") { - if let syn::Meta::NameValue(meta) = &attr.meta { - if let Ok(lit) = get_lit_str(&meta.value) { - let doc_content = lit.value(); - if !doc_content.is_empty() { - for line in doc_content.lines() { - let trimmed = line.trim().to_string(); - docs.push(trimmed); - } - } else { - docs.push("".to_string()); - } - } - } - } - } - docs -} diff --git a/rust/candid_derive/src/lib.rs b/rust/candid_derive/src/lib.rs index eb26e4263..4b55ed7d3 100644 --- a/rust/candid_derive/src/lib.rs +++ b/rust/candid_derive/src/lib.rs @@ -2,6 +2,7 @@ use proc_macro::TokenStream; use syn::{parse_macro_input, Result}; mod derive; +mod docs; mod func; #[proc_macro_derive(CandidType, attributes(candid_path))] diff --git a/spec/Type-doc-comments.md b/spec/Type-doc-comments.md new file mode 100644 index 000000000..ce266ba23 --- /dev/null +++ b/spec/Type-doc-comments.md @@ -0,0 +1,270 @@ +# Candid Type Doc Comments + +Date: Mar 12, 2026 + +## Motivation + +Rust canisters often use Rust doc comments as the primary source of interface +documentation. This works well for service methods exported with +`#[candid_method]`, because these doc comments are preserved when generating +textual Candid. However, the same is not true for data types exported through +`#[derive(CandidType)]`. + +As a result, generated `.did` files contain method-level documentation, but +drop documentation for: + +* type definitions +* record fields +* variant members + +This is problematic because much of the semantic meaning of a canister +interface is expressed on the data model rather than on the methods alone. +Request and response types often carry the important descriptions, constraints, +and invariants that downstream users need to understand. + +The goal of this proposal is to preserve Rust doc comments on exported Candid +types and their members so that generated `.did` files can act as a more +complete interface document. + +## Design + +The implementation follows the same overall pattern that is already used for +service method docs: doc comments are collected from Rust source and carried to +the textual Candid printer through a side channel. + +The crucial design choice is that docs are **not** stored on the structural type +representation itself. The runtime types in `candid::types` are used for +normalization, equality, hashing, and name assignment. Making docs part of +those structures would either perturb structural equality or require special +rules to ignore docs during comparison. Both would be awkward and fragile. + +Instead, the implementation introduces a parallel metadata flow: + +1. `candid_derive` extracts Rust doc comments while deriving `CandidType`. +2. `candid::types` memoizes that metadata per Rust `TypeId`. +3. `TypeContainer` translates that metadata to the final exported Candid type + names when constructing a `TypeEnv`. +4. `candid::pretty::candid` renders the docs when pretty-printing named type + definitions and their members. + +This preserves the current type pipeline while allowing documentation to follow +the exported interface. + +### Capturing docs in Rust derives + +When deriving `CandidType`, the derive machinery now extracts docs from: + +* the struct or enum item itself +* struct fields +* enum variants +* fields nested inside inline record payloads of variants + +The same extraction logic is shared with `#[candid_method]`, so line order and +blank lines are preserved consistently. + +The derive implementation emits both the existing structural type description +and a second metadata description. Concretely, the `CandidType` trait gains a +new default hook: + +```rust +fn _ty_doc() -> internal::TypeDoc { + internal::TypeDoc::default() +} +``` + +Manual implementations of `CandidType` remain valid because the new method has +a default implementation. + +### Representing type docs + +The additional metadata is represented separately from `TypeInner`: + +```rust +pub struct TypeDocs { + pub named: BTreeMap, +} + +pub struct TypeDoc { + pub docs: Vec, + pub fields: BTreeMap, +} + +pub struct FieldDoc { + pub docs: Vec, + pub ty: Option>, +} +``` + +`TypeDoc.docs` stores doc lines for a named type definition. + +`TypeDoc.fields` stores docs for members of that type. The key is the canonical +field identity used by Candid: + +* for named labels, `idl_hash(label)` +* for tuple labels, the numeric field id + +`FieldDoc.docs` stores the docs attached to a record field or variant member. + +`FieldDoc.ty` stores docs for nested inline record or variant payloads when +those payloads belong to a named exported type. + +### Type naming and association + +One challenge is that Rust names are not the final names used in textual +Candid. `TypeContainer` already rewrites the type graph to choose stable export +names and to normalize recursive and generic types. Therefore, doc attachment is +resolved at the same stage. + +The metadata is first memoized per Rust `TypeId`. When `TypeContainer` decides +to emit a named Candid type in `TypeEnv`, it also stores the corresponding doc +metadata under the exported type name. + +This ensures that doc comments remain attached correctly even when: + +* fields are sorted canonically by field id +* fields or variants are renamed through `serde(rename = "...")` +* generic or recursive types receive rewritten export names such as `List_1` + +### Rendering docs in textual Candid + +The existing `DocComments` structure currently stores only service method docs. +It is extended to also carry docs for named type definitions. + +When pretty-printing textual Candid, docs are emitted: + +* immediately above `type Foo = ...;` +* immediately above record fields +* immediately above variant members +* immediately above fields nested in inline record or variant payloads that are + rooted under a named type definition + +The existing service method behavior remains unchanged. + +The result is that Rust definitions such as + +```rust +/// Arguments for creating a cashier. +#[derive(CandidType, Deserialize)] +pub struct CashierArgs { + /// Human-readable display name. + pub name: String, + + /// Whether this cashier starts enabled. + pub enabled: bool, +} +``` + +produce Candid of the form + +```did +// Arguments for creating a cashier. +type CashierArgs = record { + // Whether this cashier starts enabled. + enabled : bool; + // Human-readable display name. + name : text; +}; +``` + +where field order still follows the canonical Candid ordering rules. + +## Special Cases + +### Tuples + +Tuple records are normally printed using tuple shorthand, e.g. + +```did +record { nat; text } +``` + +This syntax has no place to attach docs to individual tuple positions. Therefore +the printer falls back to explicit numeric fields when tuple members carry docs: + +```did +record { + // First element. + 0 : nat; + // Second element. + 1 : text; +} +``` + +This preserves the docs without changing the meaning of the Candid type. + +### Newtypes + +Single-field tuple structs continue to use their existing structural encoding. +Docs on the outer named type are preserved, but docs on the inner field are not +generally representable without changing the generated Candid shape. This +proposal deliberately preserves the existing shape. + +### Anonymous inline types in service signatures + +This proposal focuses on docs rooted at named exported types. Anonymous inline +record or variant types that appear only directly in service method arguments or +results are not given a separate documentation channel here. Supporting those +would require a second namespace keyed by service methods and argument/result +positions. + +## Alternatives + +### Docs on `TypeInner` + +One alternative is to store docs directly on `TypeInner` and `Field`, and let +the pretty-printer read them from the type tree itself. + +This was rejected because those structures are used for structural identity. +Adding docs there would either make otherwise-equal types compare differently, +or require custom equality semantics that ignore docs. Both options would make +the type system more brittle for the sake of a feature that only matters during +export. + +### Key docs directly in the pretty-printer + +Another option is to keep the core type system unchanged and let +`candid_derive` populate a richer `DocComments` map directly. + +This was rejected because `candid_derive` does not know the final exported +Candid names. If docs were keyed too early, they could become detached when the +type graph is normalized, fields are sorted, or export names are rewritten. + +### Rebuild a syntax AST + +The `candid_parser` crate already has a syntax-level pretty-printer that stores +docs directly on AST nodes. Reusing that machinery by reconstructing an AST from +the runtime type graph was considered as well. + +This was rejected as too invasive. It would require a larger refactoring of the +printer pipeline and still would not eliminate the need to transport doc +metadata from Rust derives into that AST. + +## Properties + +This design preserves the following properties: + +* structural type identity is unchanged +* service method docs continue to work as before +* doc extraction preserves line order and blank lines +* doc attachment is stable under canonical field sorting +* doc attachment is stable under renamed fields and variants +* generated `.did` output is deterministic + +## Tests + +The expected behavior is verified with end-to-end tests in +`rust/candid/tests/types.rs`. + +The tests cover: + +* docs on named record types +* docs on record fields +* docs on variant types +* docs on variant members +* coexistence with service method docs +* parsing the generated `.did` back through `candid_parser` and verifying that + docs are attached to the correct AST nodes + +These tests complement the existing parser-side doc comment tests in +`rust/candid_parser/tests`, which already cover syntax-level parsing and +pretty-printing of Candid comments.