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:

candidtokens.did
// 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#

bash
npm install --save-exact @meantjs/profile@0.5.0 @candid-core/cli@0.4.0

Save this as from-docs.mjs beside tokens.did:

js
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);
bash
node from-docs.mjs
text
balance->(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:

WriteThe profile gets
"TOK", "Err.Duplicate"A string. Escapes \", \\, \n, \r, \t and \u{…}.
ns, suggest, successAn identifier, read as the same string.
8, -1A whole number, kept exact: past 253, and for range bounds, a decimal string. A fraction is EE_DOC_VALUE.
true, falseA 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 header comment documents the first item

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:

candidprofile/packages/resolver/test/fixtures/doc-attributes/spec-9.did
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 no at. A value tag on a method needs one. @describe without at describes the method.

Where the comment sits decides the address the tag gets:

A comment onBecomes
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 typetype:SendArgs.memo, a type: address that reaches every position of that edge.
A field of an inline record in a method's signatureA 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 methodThe method, for method tags; the method's positions through at.
The service as a wholeNothing: 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 intent or effect at a variant case (propose(0).Transfer, spec §5.2, D-053) cannot be written as a comment in v0. @intent and @effect on a method take no at, 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 same author layer.
  • Positions inside a func or service value, 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#

candidbroken.did
service : {
  decimals : () -> (nat8) query;
  /// @amount(decimals = query(decimals))
  /// @amout(at = "(0)", decimals = 8)
  send : (nat) -> ();
}
text
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.

CodeWhen
EE_DOC_TARGETA 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_VALUEA value the profile cannot hold: a fraction, an unknown call, query with more than one name.
EE_DOC_SYNTAXcandid-core refused the comment: an attribute line that does not parse, or a repeated key.
EE_DOC_INPUTWhat was passed in is not a doc attributes v1 response.
EE_CANDIDThe .did itself does not compile.
WW_DOC_UNKNOWN_TAGA bare name one or two letters from a tag.
II_DOC_IGNOREDA name that is not a profile tag, or comments that hold no tag at all.
II_DOC_PROSEProse 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#

js
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);
}
text
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.