Skip to content

Commit b51c0c5

Browse files
sasa-tomiclwshangclaude
authored
feat(candid): preserve doc comments on exported types and fields (#707)
### Solution - Add `TypeDoc`, `FieldDoc`, and `TypeDocs` types to carry doc metadata alongside the type graph - Extend `CandidType` derive to extract Rust doc comments via new `_ty_doc()` hook; store per-TypeId in thread-local DOC_ENV - Update pretty-printer to render docs above type definitions, record fields, and variant members ### Details - Docs flow through `TypeContainer` which maps Rust TypeId docs to final Candid export names - Tuples with field docs fall back to explicit numeric field syntax ### Meta - This is needed for my work on Immutable Object Storage. I currently have some hacks to add doc comments to .did files, but everyone would benefit if this was fixed properly. --------- Co-authored-by: Linwei Shang <linwei.shang@dfinity.org> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent ba72cf4 commit b51c0c5

10 files changed

Lines changed: 749 additions & 71 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
## Unreleased
44

5+
* Non-breaking changes:
6+
+ Preserve Rust doc comments on exported Candid types, record fields, and variant members when generating `.did` files via `#[derive(CandidType)]`
7+
58
## 2026-02-27
69

710
### Candid 0.10.24

rust/candid/src/pretty/candid.rs

Lines changed: 93 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
use std::collections::HashMap;
22

33
use crate::pretty::utils::*;
4-
use crate::types::{Field, FuncMode, Function, Label, SharedLabel, Type, TypeEnv, TypeInner};
4+
use crate::types::{
5+
Field, FieldDoc, FuncMode, Function, Label, SharedLabel, Type, TypeDoc, TypeDocs, TypeEnv,
6+
TypeInner,
7+
};
58
use pretty::RcDoc;
69

710
static KEYWORDS: [&str; 30] = [
@@ -125,6 +128,12 @@ pub fn pp_docs<'a>(docs: &'a [String]) -> RcDoc<'a> {
125128
lines(docs.iter().map(|line| RcDoc::text("// ").append(line)))
126129
}
127130

131+
fn maybe_pp_docs<'a>(docs: Option<&'a [String]>) -> RcDoc<'a> {
132+
docs.filter(|docs| !docs.is_empty())
133+
.map(pp_docs)
134+
.unwrap_or_else(RcDoc::nil)
135+
}
136+
128137
/// This function is kept for backward compatibility.
129138
///
130139
/// 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<'_> {
153162
enclose_space("{", concat(fields, ";"), "}")
154163
}
155164

165+
fn pp_field_with_doc<'a>(
166+
field: &'a Field,
167+
is_variant: bool,
168+
doc: Option<&'a FieldDoc>,
169+
) -> RcDoc<'a> {
170+
let docs = maybe_pp_docs(doc.map(|doc| doc.docs.as_slice()));
171+
let ty_doc = if is_variant && *field.ty == TypeInner::Null {
172+
RcDoc::nil()
173+
} else {
174+
kwd(" :").append(pp_ty_with_doc(
175+
&field.ty,
176+
doc.and_then(|doc| doc.ty.as_deref()),
177+
))
178+
};
179+
docs.append(pp_label_raw(&field.id)).append(ty_doc)
180+
}
181+
182+
fn pp_fields_with_doc<'a>(
183+
fs: &'a [Field],
184+
is_variant: bool,
185+
doc: Option<&'a TypeDoc>,
186+
) -> RcDoc<'a> {
187+
let fields = fs.iter().map(|field| {
188+
let field_doc = doc.and_then(|doc| doc.fields.get(&field.id.get_id()));
189+
pp_field_with_doc(field, is_variant, field_doc)
190+
});
191+
enclose_space("{", concat(fields, ";"), "}")
192+
}
193+
194+
fn has_field_docs(doc: Option<&TypeDoc>) -> bool {
195+
doc.map(|doc| doc.fields.values().any(|field| !field.is_empty()))
196+
.unwrap_or(false)
197+
}
198+
199+
fn pp_ty_with_doc<'a>(ty: &'a Type, doc: Option<&'a TypeDoc>) -> RcDoc<'a> {
200+
use TypeInner::*;
201+
match ty.as_ref() {
202+
Record(ref fs) => {
203+
if ty.is_tuple() && !has_field_docs(doc) {
204+
let tuple = concat(fs.iter().map(|f| pp_ty_with_doc(&f.ty, None)), ";");
205+
kwd("record").append(enclose_space("{", tuple, "}"))
206+
} else {
207+
kwd("record").append(pp_fields_with_doc(fs, false, doc))
208+
}
209+
}
210+
Variant(ref fs) => kwd("variant").append(pp_fields_with_doc(fs, true, doc)),
211+
ty => pp_ty_inner(ty),
212+
}
213+
}
214+
156215
pub fn pp_function(func: &Function) -> RcDoc<'_> {
157216
let args = pp_args(&func.args);
158217
let rets = pp_rets(&func.rets);
@@ -204,7 +263,7 @@ fn pp_service<'a>(serv: &'a [(String, Type)], docs: Option<&'a DocComments>) ->
204263
enclose_space("{", doc, "}")
205264
}
206265

207-
fn pp_defs(env: &TypeEnv) -> RcDoc<'_> {
266+
fn pp_defs_plain(env: &TypeEnv) -> RcDoc<'_> {
208267
lines(env.0.iter().map(|(id, ty)| {
209268
kwd("type")
210269
.append(ident(id))
@@ -214,6 +273,18 @@ fn pp_defs(env: &TypeEnv) -> RcDoc<'_> {
214273
}))
215274
}
216275

276+
fn pp_defs<'a>(env: &'a TypeEnv, docs: &'a DocComments) -> RcDoc<'a> {
277+
lines(env.0.iter().map(|(id, ty)| {
278+
let type_doc = docs.lookup_type_def(id);
279+
maybe_pp_docs(type_doc.map(|doc| doc.docs.as_slice()))
280+
.append(kwd("type"))
281+
.append(ident(id))
282+
.append(kwd("="))
283+
.append(pp_ty_with_doc(ty, type_doc))
284+
.append(";")
285+
}))
286+
}
287+
217288
fn pp_class<'a>(args: &'a [Type], t: &'a Type, docs: Option<&'a DocComments>) -> RcDoc<'a> {
218289
let doc = pp_args(args).append(" ->").append(RcDoc::space());
219290
match t.as_ref() {
@@ -234,13 +305,14 @@ fn pp_actor<'a>(ty: &'a Type, docs: &'a DocComments) -> RcDoc<'a> {
234305

235306
/// Pretty-prints the initialization arguments for a Candid actor.
236307
pub fn pp_init_args<'a>(env: &'a TypeEnv, args: &'a [Type]) -> RcDoc<'a> {
237-
pp_defs(env).append(pp_args(args))
308+
pp_defs_plain(env).append(pp_args(args))
238309
}
239310

240311
/// Collects doc comments that can be passed to the [compile_with_docs] function.
241312
#[derive(Default, Debug)]
242313
pub struct DocComments {
243314
service_methods: HashMap<String, Vec<String>>,
315+
type_defs: HashMap<String, TypeDoc>,
244316
}
245317

246318
impl DocComments {
@@ -255,6 +327,22 @@ impl DocComments {
255327
pub fn lookup_service_method(&self, method: &str) -> Option<&Vec<String>> {
256328
self.service_methods.get(method)
257329
}
330+
331+
pub fn add_type_def(&mut self, name: String, doc: TypeDoc) {
332+
if !doc.is_empty() {
333+
self.type_defs.insert(name, doc);
334+
}
335+
}
336+
337+
pub fn lookup_type_def(&self, name: &str) -> Option<&TypeDoc> {
338+
self.type_defs.get(name)
339+
}
340+
341+
pub fn extend_types(&mut self, docs: TypeDocs) {
342+
for (name, doc) in docs.named {
343+
self.add_type_def(name, doc);
344+
}
345+
}
258346
}
259347

260348
pub fn compile(env: &TypeEnv, actor: &Option<Type>) -> String {
@@ -281,9 +369,9 @@ pub fn compile(env: &TypeEnv, actor: &Option<Type>) -> String {
281369
/// ```
282370
pub fn compile_with_docs(env: &TypeEnv, actor: &Option<Type>, docs: &DocComments) -> String {
283371
match actor {
284-
None => pp_defs(env).pretty(LINE_WIDTH).to_string(),
372+
None => pp_defs(env, docs).pretty(LINE_WIDTH).to_string(),
285373
Some(actor) => {
286-
let defs = pp_defs(env);
374+
let defs = pp_defs(env, docs);
287375
let actor = kwd("service :").append(pp_actor(actor, docs));
288376
let doc = defs.append(actor);
289377
doc.pretty(LINE_WIDTH).to_string()

rust/candid/src/types/internal.rs

Lines changed: 62 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,39 @@ impl TypeName {
7171
}
7272
}
7373

74+
#[derive(Debug, Clone, Default, PartialEq, Eq)]
75+
pub struct TypeDocs {
76+
pub named: BTreeMap<String, TypeDoc>,
77+
}
78+
79+
#[derive(Debug, Clone, Default, PartialEq, Eq)]
80+
pub struct TypeDoc {
81+
pub docs: Vec<String>,
82+
pub fields: BTreeMap<u32, FieldDoc>,
83+
}
84+
85+
impl TypeDoc {
86+
pub fn is_empty(&self) -> bool {
87+
self.docs.is_empty() && self.fields.is_empty()
88+
}
89+
}
90+
91+
#[derive(Debug, Clone, Default, PartialEq, Eq)]
92+
pub struct FieldDoc {
93+
pub docs: Vec<String>,
94+
pub ty: Option<Box<TypeDoc>>,
95+
}
96+
97+
impl FieldDoc {
98+
pub fn is_empty(&self) -> bool {
99+
self.docs.is_empty()
100+
&& match self.ty.as_deref() {
101+
None => true,
102+
Some(doc) => doc.is_empty(),
103+
}
104+
}
105+
}
106+
74107
/// Used for `candid_derive::export_service` to generate `TypeEnv` from `Type`.
75108
///
76109
/// It performs a global rewriting of `Type` to resolve:
@@ -85,11 +118,13 @@ impl TypeName {
85118
#[derive(Default)]
86119
pub struct TypeContainer {
87120
pub env: crate::TypeEnv,
121+
pub docs: TypeDocs,
88122
}
89123
impl TypeContainer {
90124
pub fn new() -> Self {
91125
TypeContainer {
92126
env: crate::TypeEnv::new(),
127+
docs: TypeDocs::default(),
93128
}
94129
}
95130
pub fn add<T: CandidType>(&mut self) -> Type {
@@ -115,8 +150,10 @@ impl TypeContainer {
115150
}
116151
let id = ID.with(|n| n.borrow().get(t).cloned());
117152
if let Some(id) = id {
118-
self.env.0.insert(id.to_string(), res);
119-
TypeInner::Var(id.to_string())
153+
let name = id.to_string();
154+
self.env.0.insert(name.clone(), res);
155+
self.remember_named_doc(&id, &name);
156+
TypeInner::Var(name)
120157
} else {
121158
// if the type is part of an enum, the id won't be recorded.
122159
// we want to inline the type in this case.
@@ -135,8 +172,10 @@ impl TypeContainer {
135172
.into();
136173
let id = ID.with(|n| n.borrow().get(t).cloned());
137174
if let Some(id) = id {
138-
self.env.0.insert(id.to_string(), res);
139-
TypeInner::Var(id.to_string())
175+
let name = id.to_string();
176+
self.env.0.insert(name.clone(), res);
177+
self.remember_named_doc(&id, &name);
178+
TypeInner::Var(name)
140179
} else {
141180
return res;
142181
}
@@ -145,6 +184,7 @@ impl TypeContainer {
145184
let name = id.to_string();
146185
let ty = ENV.with(|e| e.borrow().get(id).unwrap().clone());
147186
self.env.0.insert(id.to_string(), ty);
187+
self.remember_named_doc(id, &name);
148188
TypeInner::Var(name)
149189
}
150190
TypeInner::Func(func) => TypeInner::Func(Function {
@@ -164,6 +204,14 @@ impl TypeContainer {
164204
}
165205
.into()
166206
}
207+
208+
fn remember_named_doc(&mut self, id: &TypeId, name: &str) {
209+
if let Some(doc) = find_type_doc(id) {
210+
if !doc.is_empty() {
211+
self.docs.named.entry(name.to_string()).or_insert(doc);
212+
}
213+
}
214+
}
167215
}
168216

169217
#[derive(Debug, PartialEq, Hash, Eq, Clone, PartialOrd, Ord)]
@@ -644,6 +692,7 @@ pub fn unroll(t: &Type) -> Type {
644692

645693
thread_local! {
646694
static ENV: RefCell<BTreeMap<TypeId, Type>> = const { RefCell::new(BTreeMap::new()) };
695+
static DOC_ENV: RefCell<BTreeMap<TypeId, TypeDoc>> = const { RefCell::new(BTreeMap::new()) };
647696
// only used for TypeContainer
648697
static ID: RefCell<BTreeMap<Type, TypeId>> = const { RefCell::new(BTreeMap::new()) };
649698
static NAME: RefCell<TypeName> = RefCell::new(TypeName::default());
@@ -653,6 +702,10 @@ pub fn find_type(id: &TypeId) -> Option<Type> {
653702
ENV.with(|e| e.borrow().get(id).cloned())
654703
}
655704

705+
pub fn find_type_doc(id: &TypeId) -> Option<TypeDoc> {
706+
DOC_ENV.with(|e| e.borrow().get(id).cloned())
707+
}
708+
656709
// only for debugging
657710
#[allow(dead_code)]
658711
pub(crate) fn show_env() {
@@ -664,6 +717,7 @@ pub(crate) fn env_add(id: TypeId, t: Type) {
664717
}
665718
pub fn env_clear() {
666719
ENV.with(|e| e.borrow_mut().clear());
720+
DOC_ENV.with(|e| e.borrow_mut().clear());
667721
}
668722

669723
pub(crate) fn env_id(id: TypeId, t: Type) {
@@ -684,6 +738,10 @@ pub(crate) fn env_id(id: TypeId, t: Type) {
684738
});
685739
}
686740

741+
pub(crate) fn env_doc(id: TypeId, doc: TypeDoc) {
742+
DOC_ENV.with(|e| e.borrow_mut().insert(id, doc));
743+
}
744+
687745
pub fn get_type<T>(_v: &T) -> Type
688746
where
689747
T: CandidType,

rust/candid/src/types/mod.rs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ pub mod type_env;
1515
pub mod value;
1616

1717
pub use self::internal::{
18-
get_type, Field, FuncMode, Function, Label, SharedLabel, Type, TypeId, TypeInner,
18+
get_type, Field, FieldDoc, FuncMode, Function, Label, SharedLabel, Type, TypeDoc, TypeDocs,
19+
TypeId, TypeInner,
1920
};
2021
pub use type_env::TypeEnv;
2122

@@ -44,14 +45,18 @@ pub trait CandidType {
4445
self::internal::env_add(id.clone(), TypeInner::Unknown.into());
4546
let t = Self::_ty();
4647
self::internal::env_add(id.clone(), t.clone());
47-
self::internal::env_id(id, t.clone());
48+
self::internal::env_id(id.clone(), t.clone());
49+
self::internal::env_doc(id, Self::_ty_doc());
4850
t
4951
}
5052
}
5153
fn id() -> TypeId {
5254
TypeId::of::<Self>()
5355
}
5456
fn _ty() -> Type;
57+
fn _ty_doc() -> internal::TypeDoc {
58+
internal::TypeDoc::default()
59+
}
5560
// only serialize the value encoding
5661
fn idl_serialize<S>(&self, serializer: S) -> Result<(), S::Error>
5762
where

0 commit comments

Comments
 (0)