Profiles
Annotate a .did file
Write profile tags as doc comments beside the Candid they describe.
This is tokens.did from Write a profile with that
guide's profile written into it as doc comments. The interface itself is unchanged:
// The tokens example, with its profile written as doc comments.
type Tokens = record {
/// @amount(decimals = 8, symbol = "TOK")
/// @describe(label = "Amount")
e8s : nat64;
};
type SendArgs = record {
to : principal;
amount : Tokens;
/// @bytes(encoding = "utf8")
/// @default(value = "null", mode = suggest)
memo : opt blob;
/// @timestamp(unit = ns, epoch = unix)
created_at : opt nat64;
};
type SendError = variant {
Insufficient : record { balance : Tokens };
Duplicate : record { block : nat };
Other : text;
};
service : {
/// @describe(at = "->(0).e8s", label = "Balance")
balance : (principal) -> (Tokens) query;
/// Send tokens to another principal.
/// @intent(title = "Send TOK", template = "Send {amount.e8s} to {to}[ with memo {memo}]")
/// @effect(kind = "transfer", irreversible = true)
/// @idempotency(mode = replay_same_payload, stamp = "created_at")
/// @outcome(classes = { Ok = success, "Err.Duplicate" = already_done, "Err.Insufficient" = fail, "Err.Other" = fail })
send : (SendArgs) -> (variant { Ok : nat; Err : SendError });
}Read the comments into a profile#
npm install --save-exact @meantjs/profile@0.5.0 @candid-core/cli@0.4.0Save this as from-docs.mjs beside tokens.did:
import { readFileSync } from "node:fs";
import { didToDocAttributes } from "@candid-core/cli";
import { createSchemaValidator, profileFromDocAttributes, resolveService } from "@meantjs/profile";
const did = readFileSync("tokens.did", "utf8");
const { ok, doc, diagnostics } = profileFromDocAttributes(await didToDocAttributes(did), { name: "tokens-docs" });
for (const d of diagnostics) console.log(d.severity, d.code, d.at, d.message);
if (!ok || doc === null) process.exit(1);
for (const a of doc.annotations) console.log(a.at.padEnd(25), a.tag.padEnd(12), JSON.stringify(a.args));
const validateSchema = await createSchemaValidator();
const r = await resolveService({ did, validateSchema, profiles: [{ doc }] });
console.log(r.ok, r.profiles.map((p) => `${p.name} ${p.status}`), r.diagnostics.length);node from-docs.mjsbalance->(0).e8s describe {"label":"Balance"}
send intent {"title":"Send TOK","template":"Send {amount.e8s} to {to}[ with memo {memo}]"}
send effect {"kind":"transfer","irreversible":true}
send idempotency {"mode":"replay_same_payload","stamp":"created_at"}
send outcome {"classes":{"Ok":"success","Err.Duplicate":"already_done","Err.Insufficient":"fail","Err.Other":"fail"}}
send describe {"help":"Send tokens to another principal."}
type:SendArgs.memo bytes {"encoding":"utf8"}
type:SendArgs.memo default {"value":"null","mode":"suggest"}
type:SendArgs.created_at timestamp {"unit":"ns","epoch":"unix"}
type:Tokens.e8s amount {"decimals":8,"symbol":"TOK"}
type:Tokens.e8s describe {"label":"Amount"}
true [ 'tokens-docs applied' ] 0
Two calls do the work. didToDocAttributes from @candid-core/cli compiles
the .did and parses every @name(…) doc line, failing on a malformed one;
it gives no name a meaning. profileFromDocAttributes from
@meantjs/profile keeps the names that are profile tags, works out the address each one
lands on, and returns an author canister profile bound to the interface's
interface_id. That profile then goes through resolveService like any
other, which is where its tags are checked. The result is the profile of
Write a profile, with one more annotation: the prose line on
send, as describe.help.
The candid-profile command has no option that reads doc comments; this is a library
call. profileFromDocAttributes(response, opts) takes the profile's
name (default "doc-annotations"), origin
("author", the default, or "third_party"), language (default
"en") and a canisterId to bind to as well. doc is
null when the comments hold no tag, and ok is false when a diagnostic is an
error; with errors, doc still holds every tag that could be placed.
The syntax#
A doc line that starts with @, a lowercase name and ( with no space before
it is an attribute: @<tag>(key = value, …), where the tag is one of the 18 profile
tags and the keys are its arguments. The values:
| Write | The profile gets |
|---|---|
"TOK", "Err.Duplicate" | A string. Escapes \", \\, \n, \r, \t and \u{…}. |
ns, suggest, success | An identifier, read as the same string. |
8, -1 | A whole number, kept exact: past 253, and for range bounds, a decimal string. A fraction is EE_DOC_VALUE. |
true, false | A boolean. |
query(decimals), now(), caller() | A dynamic value: { "from": "query:decimals" }, { "from": "now" }, { "from": "caller" }. Any other call is EE_DOC_VALUE. |
[a, b], { Ok = success } | A list, or an object. A key that is not an identifier, such as a path, is written as a string. |
An attribute whose brackets are still open at the end of a line continues on the next doc line, so
a long @outcome can span lines; a string cannot. An attribute line that does not parse
to its end, or a key repeated in one argument list, makes didToDocAttributes fail, and
the reader reports each as EE_DOC_SYNTAX with no profile. A line where the name is followed by a space or by text
(@amount (decimals = 8), @param amount the value) is prose, not an
attribute. The full grammar is candid-core's
doc attributes v1.
Only bare names that are profile tags are read. A dotted name such as @ui.hidden
belongs to another tool and is left alone (II_DOC_IGNORED), and so is any other bare
name, with WW_DOC_UNKNOWN_TAG when it is one or two letters from a tag
(@amout). open Whether the tags stay bare (@amount) or take
a prefix that cannot collide with other conventions is open question OQ-5, owned by W0, in
Open questions.
Which comments count#
Doc lines are the // and /// line comments on their own lines directly
above an item, with no blank line between them and the item. A comment after code on the same
line, a block comment, and a comment separated from the item by a blank line are not doc lines.
Neither is a comment above a quoted method name ("foo-bar" : …).
A file's opening comment with no blank line after it documents the first item below it. With
the example's original first line, // Example canister interface …, directly above
type Tokens, the run above also produced a describe.help on
type:Tokens holding that sentence (observed with @candid-core/cli
0.4.0). The blank line after the header in the file above is there to stop that.
Reaching arguments and results with at#
Candid has no doc comments on function parameters, so a method's comment reaches its argument and
result positions with at, a path relative to the method. This is the example of spec
§9:
type SendArgs = record { to : principal; amount : nat; memo : opt blob };
service : {
decimals : () -> (nat8) query;
/// Send tokens to another principal.
/// @intent(title = "Send", template = "Send {amount} to {to}[ with memo {memo}]")
/// @effect(kind = "transfer", irreversible = true)
/// @amount(at = "amount", decimals = query(decimals), symbol = "TOK")
/// @bytes(at = "memo", encoding = "utf8")
/// @amount(at = "->(0).Ok", decimals = query(decimals), symbol = "TOK")
send : (SendArgs) -> (variant { Ok : nat; Err : text });
}at = "amount"targets the arguments:send(0).amount.at = "->(0).Ok"targets the results:send->(0).Ok.- When the method takes more than one argument (or returns more than one result), the path
starts with
(i):at = "(1)"is the second argument. - Method tags (
intent,effect,idempotency,outcome,list,names) apply to the method and take noat. A value tag on a method needs one.@describewithoutatdescribes the method.
Where the comment sits decides the address the tag gets:
| A comment on | Becomes |
|---|---|
A declaration, type Payment = … | type:Payment; with at, a path inside it. A pathless type: address is allowed only on a record or a variant (EE_TYPE_NODE). |
| A field of a named type | type:SendArgs.memo, a type: address that reaches every position of that edge. |
| A field of an inline record in a method's signature | A method address, such as inline(0).items[].price: an opt adds nothing to the path and a vec adds []. Spec §9 left this mapping to confirm against real SourceInfo output (OQ-4); it is now answered, and the resolver implements it. |
| A method | The method, for method tags; the method's positions through at. |
| The service as a whole | Nothing: no tag applies there (EE_DOC_TARGET). |
Because field comments become type: addresses, they behave as in a profile file:
one comment on Tokens.e8s reaches every Tokens in the interface, and a
method address beats it at the same position. That is why the Balance label above
sits on the balance method, with at. Addresses
explains both kinds.
What has no doc form#
-
Case tags. An
intentoreffectat a variant case (propose(0).Transfer, spec §5.2, D-053) cannot be written as a comment in v0.@intentand@effecton a method take noat, and on a field or a declaration they are not allowed (EE_DOC_TARGET). Write case tags in a profile file, and give both to the resolver: they merge in the sameauthorlayer. -
Positions inside a
funcorservicevalue, such as a callback's arguments: no v0 address reaches them (EE_DOC_TARGET). -
Methods with quoted names (
"foo-bar"): v0 addresses cannot name them (open question OQ-3), and the compiler does not attach comments to them.
Prose becomes describe.help#
Doc lines that are not attributes are prose. The lines of a paragraph are joined with spaces,
paragraphs are kept, and the text becomes the item's describe.help: the line
/// Send tokens to another principal. above is
{ "at": "send", "tag": "describe", "args": { "help": "Send tokens to another principal." } }.
When the same comment has a @describe without at, the prose fills its
help; when that @describe already gives help, the prose is
left out.
Prose is not attached, and is reported with II_DOC_PROSE, in two places. On an integer
position whose comment gives it no value tag that reads it (amount,
timestamp, duration or named): a described integer is an agent
input of plain digits (D-044), so a comment such as "the amount in e8s" would turn a bare number
into an input without a unit. Add the value tag, or an explicit @describe if plain
digits are right. And on a declaration that is not a record or a variant, such as
type Tokens = nat, which a whole-declaration tag cannot describe.
Diagnostics#
service : {
decimals : () -> (nat8) query;
/// @amount(decimals = query(decimals))
/// @amout(at = "(0)", decimals = 8)
send : (nat) -> ();
}error E_DOC_TARGET send doc line 1: @amount: a value tag on a method needs `at`: at = "<path>" for an argument, at = "-><path>" for a result
warning W_DOC_UNKNOWN_TAG send doc line 2: @amout: not a profile tag; did you mean @amount?
Each diagnostic names the address of the item (or its SourceInfo occurrence path, such as
actor.methods[0], when it has no address) and the doc line, counted from 1. It also
carries path and source, the item's SourceInfo path and the file it is in.
| Code | When |
|---|---|
| EE_DOC_TARGET | A tag no v0 address reaches (inside a func or service value), or one in the wrong place: a value tag on a method without at, a method tag on a field, an at that is not a relative path, a missing (i). |
| EE_DOC_VALUE | A value the profile cannot hold: a fraction, an unknown call, query with more than one name. |
| EE_DOC_SYNTAX | candid-core refused the comment: an attribute line that does not parse, or a repeated key. |
| EE_DOC_INPUT | What was passed in is not a doc attributes v1 response. |
| EE_CANDID | The .did itself does not compile. |
| WW_DOC_UNKNOWN_TAG | A bare name one or two letters from a tag. |
| II_DOC_IGNORED | A name that is not a profile tag, or comments that hold no tag at all. |
| II_DOC_PROSE | Prose that is not attached, and why. |
These are the codes of the comment reader. The tags it placed are then checked by
resolveService like those of any profile, with the codes on
Profile diagnostics.
Identity#
import { readFileSync } from "node:fs";
import { didToDocAttributes } from "@candid-core/cli";
for (const file of ["tokens.plain.did", "tokens.did"]) {
const r = await didToDocAttributes(readFileSync(file, "utf8"));
if (!r.ok) throw new Error(`${file} does not compile`);
const contract = /** @type {{ identities: { contract: string, interface: string } }} */ (r.contract);
const info = /** @type {{ source_bundle_id: string }} */ (r.source_info);
console.log(file);
console.log(" interface_id ", contract.identities.interface);
console.log(" contract_id ", contract.identities.contract);
console.log(" source_bundle_id", info.source_bundle_id);
}tokens.plain.did
interface_id candid-core:interface:v1:sha256:f8358625dd47a9b6a237a3a268ba83291fcd0dc27093e80ac27cc48a49dc1d6c
contract_id candid-core:contract:v1:sha256:9f5c81035f14d348d05279bf3683ef288679897e69dcb752b74250afc558ae67
source_bundle_id candid-core:source-bundle:v1:sha256:85aaf4e378bdcd6e77113b703c60ff7863b0601a2b46aaf6d518db0e34f38cc2
tokens.did
interface_id candid-core:interface:v1:sha256:f8358625dd47a9b6a237a3a268ba83291fcd0dc27093e80ac27cc48a49dc1d6c
contract_id candid-core:contract:v1:sha256:9f5c81035f14d348d05279bf3683ef288679897e69dcb752b74250afc558ae67
source_bundle_id candid-core:source-bundle:v1:sha256:fbba36e930d16c43c0482841e3ea296579d7d19b126973d678f3a86ac3ffba65
tokens.plain.did is a copy of profile/examples/tokens.did, which carries no
tags.
Both files have the same interface_id and contract_id: comments live in the
SourceInfo sidecar, outside the Contract, so annotating an interface never changes the identity a
profile binds to. Only source_bundle_id moves, because it hashes the source text.
Content-addressed identities explains the three.
Comments and a profile file together#
A profile read from comments is an author profile, and so is a profile file the author
ships. Give both to resolveService and they merge in the author layer:
different tags combine, and two different values for one tag at one position are
EE_CONFLICT. A canister that serves a tagged .did as its
candid:service serves an author profile this way too;
Serve a profile from a canister covers it, and
Validate and resolve profiles covers the merge.