diff --git a/.agents/skills/mops-cli/SKILL.md b/.agents/skills/mops-cli/SKILL.md index 35bb896b..5a80dff8 100644 --- a/.agents/skills/mops-cli/SKILL.md +++ b/.agents/skills/mops-cli/SKILL.md @@ -146,6 +146,24 @@ mops generate candid backend -o # single canister, ad-hoc path (Re)generates the curated `.did` from current Motoko source. With `[canisters.].candid` set, overwrites that file. Without it, writes `.did` next to `main` (e.g. `main = "src/Backend.mo"` → `src/backend.did`) and sets `[canisters.].candid` in `mops.toml`. Run after every interface change; commit `.did` + `mops.toml` together. Same moc invocation as `mops build`, so the result always passes `mops build`'s subtype check. +### `mops generate bindings` + +```bash +mops generate bindings # all [bindings.*] +mops generate bindings ICRC # one named binding +mops generate bindings foo.did -o out.mo # ad-hoc (mops.toml optional) +``` + +Generates Motoko binding modules from committed `.did` files for runtime `actor(id) : Foo.Self` calls. Configure in `mops.toml`: + +```toml +[bindings.ICRC] +did = "candid/icrc.did" +# out = "bindings/ICRC.mo" # optional; default /.mo +``` + +Commit the `.did` (source of truth) and the generated `.mo`. Prefer `canister:` + `--actor-env-alias` when the target principal is a single fixed canister. `.did` `import`s are rejected — flatten into a self-contained interface. + ### `mops toolchain` ```bash diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index 29fb69b6..2fd843a7 100644 --- a/cli/CHANGELOG.md +++ b/cli/CHANGELOG.md @@ -2,6 +2,8 @@ ## Next +- `mops generate bindings` generates Motoko modules from committed `.did` files (same `candid_parser` Motoko bindgen as `didc bind -t mo`, embedded in mops; `.did` imports not supported). Declare `[bindings.]` with `did` (and optional `out`) in `mops.toml`, or pass a `.did` path with `-o` for ad-hoc generation. Use for runtime-chosen principals (`actor(id) : Foo.Self`); for a single fixed target prefer `canister:` + `--actor-env-alias`. + ## 2.19.2 - Fix local path dependencies being written into `mops.lock` as absolute filesystem paths, which made committed lockfiles non-portable across machines. Local deps are now stored root-relative (e.g. `./packages/shared`, `../lib`). Regenerate an existing absolute lock with `mops install --lock update` (a plain `mops install` will not rewrite it). After regenerating, all environments need a CLI that includes this fix — older CLIs treat relative lock paths as cwd-relative and break from subdirectories. diff --git a/cli/cli.ts b/cli/cli.ts index daa1bf05..a9576a0f 100755 --- a/cli/cli.ts +++ b/cli/cli.ts @@ -18,6 +18,7 @@ import { docsCoverage } from "./commands/docs-coverage.js"; import { docs } from "./commands/docs.js"; import { format } from "./commands/format.js"; import { generateCandid } from "./commands/generate.js"; +import { generateBindings } from "./commands/generate-bindings.js"; import { info } from "./commands/info.js"; import { init } from "./commands/init.js"; import { lint } from "./commands/lint.js"; @@ -939,7 +940,9 @@ program.addCommand(migrateCommand); // generate const generateCommand = new Command("generate") - .description("Generate source-derived artifacts (Candid, ...)") + .description( + "Generate source-derived artifacts (Candid, Motoko bindings, ...)", + ) .showHelpAfterError(); generateCommand @@ -973,6 +976,26 @@ generateCommand }); }); +generateCommand + .command("bindings [targets...]") + .description( + "Generate Motoko binding modules from committed `.did` interfaces (for runtime `actor(id) : Foo.Self`). With no names, generates all `[bindings.*]` entries. Pass a `.did` path with `--output` for ad-hoc generation (mops.toml optional).", + ) + .addOption( + new Option( + "--output, -o ", + "Write the generated .mo to (single binding or ad-hoc .did only; does not touch mops.toml)", + ), + ) + .addOption(new Option("--verbose", "Verbose console output")) + .action(async (targets, options) => { + const adHoc = targets.some((t: string) => String(t).endsWith(".did")); + if (!adHoc) { + checkConfigFile(true); + } + await generateBindings(targets.length ? targets : undefined, options); + }); + program.addCommand(generateCommand); // self diff --git a/cli/commands/generate-bindings.ts b/cli/commands/generate-bindings.ts new file mode 100644 index 00000000..58538282 --- /dev/null +++ b/cli/commands/generate-bindings.ts @@ -0,0 +1,225 @@ +import chalk from "chalk"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { cliError } from "../error.js"; +import { bindMotoko } from "../helpers/bind-motoko.js"; +import { getRootDir, readConfig, resolveConfigPath } from "../mops.js"; +import { BindingConfig } from "../types.js"; + +export interface GenerateBindingsOptions { + output?: string; + verbose?: boolean; +} + +export async function generateBindings( + targets: string[] | undefined, + options: GenerateBindingsOptions, +): Promise { + if (targets?.length === 0) { + cliError("No bindings specified"); + } + + // Ad-hoc: `mops generate bindings path.did -o out.mo` + if (targets?.length === 1 && looksLikeDidPath(targets[0]!)) { + await generateAdHoc(targets[0]!, options); + return; + } + if (targets?.some(looksLikeDidPath)) { + if (options.output) { + cliError( + "Ad-hoc generation accepts only one .did path at a time:\n" + + " mops generate bindings candid/foo.did -o bindings/Foo.mo", + ); + } + cliError( + "Ad-hoc generation takes a single .did path and requires --output / -o:\n" + + " mops generate bindings candid/foo.did -o bindings/Foo.mo", + ); + } + + const config = readConfig(); + const bindings = config.bindings ?? {}; + const names = Object.keys(bindings); + + if (!names.length) { + cliError( + "No [bindings.*] entries in mops.toml.\n" + + "Add one, e.g.:\n" + + " [bindings.ICRC]\n" + + ' did = "candid/icrc.did"\n' + + "Or generate ad-hoc:\n" + + " mops generate bindings candid/icrc.did -o bindings/ICRC.mo", + ); + } + + const selected = targets?.length + ? targets.map((name) => { + if (!bindings[name]) { + cliError( + `Binding ${JSON.stringify(name)} not found in mops.toml [bindings.*]`, + ); + } + return name; + }) + : names; + + if (options.output && selected.length > 1) { + cliError( + "--output / -o is only supported when generating a single binding", + ); + } + + const rootDir = getRootDir(); + + for (const name of selected) { + const entry = bindings[name]!; + validateBindingEntry(name, entry); + const didFs = resolveConfigPath(entry.did); + const dest = resolveBindingDestination( + name, + entry, + options.output, + rootDir, + ); + + console.log( + chalk.blue("generate bindings"), + chalk.bold(name), + chalk.gray(`← ${entry.did}`), + chalk.gray(`→ ${dest.display}`), + ); + + const didText = await readDidFile(didFs); + let mo: string; + try { + mo = bindMotoko(didText); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + cliError(`Failed to generate Motoko bindings for ${name}: ${msg}`); + } + + await mkdir(path.dirname(dest.fsPath), { recursive: true }); + await writeFile(dest.fsPath, mo, "utf8"); + + if (options.verbose) { + console.log(chalk.gray(`wrote ${dest.fsPath} (${mo.length} bytes)`)); + } + } + + console.log( + chalk.green( + `\n✓ Generated Motoko bindings for ${selected.length} interface${selected.length === 1 ? "" : "s"}`, + ), + ); +} + +async function generateAdHoc( + didPath: string, + options: GenerateBindingsOptions, +): Promise { + if (!options.output) { + cliError( + "Ad-hoc generation requires --output / -o:\n" + + ` mops generate bindings ${didPath} -o `, + ); + } + + const rootDir = getRootDir(); + const didFs = path.isAbsolute(didPath) + ? didPath + : path.resolve(process.cwd(), didPath); + const dest = resolveOutputPath(options.output, rootDir); + + console.log( + chalk.blue("generate bindings"), + chalk.gray(`← ${didPath}`), + chalk.gray(`→ ${dest.display}`), + ); + + const didText = await readDidFile(didFs); + let mo: string; + try { + mo = bindMotoko(didText); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + cliError(`Failed to generate Motoko bindings: ${msg}`); + } + + await mkdir(path.dirname(dest.fsPath), { recursive: true }); + await writeFile(dest.fsPath, mo, "utf8"); + + if (options.verbose) { + console.log(chalk.gray(`wrote ${dest.fsPath} (${mo.length} bytes)`)); + } + + console.log(chalk.green(`\n✓ Generated Motoko bindings`)); +} + +function looksLikeDidPath(arg: string): boolean { + return arg.endsWith(".did"); +} + +function validateBindingEntry(name: string, entry: BindingConfig): void { + if (!entry || typeof entry !== "object") { + cliError( + `[bindings.${name}] must be a table with a did field, e.g. did = "candid/foo.did"`, + ); + } + if (!entry.did || typeof entry.did !== "string") { + cliError(`[bindings.${name}] is missing required field "did"`); + } + if (entry.out != null && typeof entry.out !== "string") { + cliError(`[bindings.${name}].out must be a string path`); + } +} + +interface Destination { + fsPath: string; + display: string; +} + +function resolveBindingDestination( + name: string, + entry: BindingConfig, + outputFlag: string | undefined, + rootDir: string, +): Destination { + if (outputFlag) { + return resolveOutputPath(outputFlag, rootDir); + } + if (entry.out) { + return resolveOutputPath(resolveConfigPath(entry.out), rootDir, entry.out); + } + const didDir = path.dirname(entry.did).replace(/\\/g, "/"); + const projectRel = + didDir === "." || didDir === "" ? `${name}.mo` : `${didDir}/${name}.mo`; + return resolveOutputPath(resolveConfigPath(projectRel), rootDir, projectRel); +} + +function resolveOutputPath( + fsPath: string, + rootDir: string, + display?: string, +): Destination { + const absPath = path.resolve(fsPath); + const projectRoot = rootDir || process.cwd(); + const dotMopsDir = path.resolve(projectRoot, ".mops"); + if (absPath === dotMopsDir || absPath.startsWith(dotMopsDir + path.sep)) { + cliError( + `Refusing to write Motoko bindings inside .mops/ (private build cache): ${fsPath}\n` + + "Choose a path outside .mops/ — it should be importable source.", + ); + } + return { + fsPath, + display: display ?? fsPath, + }; +} + +async function readDidFile(fsPath: string): Promise { + try { + return await readFile(fsPath, "utf8"); + } catch { + cliError(`Candid file not found: ${fsPath}`); + } +} diff --git a/cli/helpers/bind-motoko.ts b/cli/helpers/bind-motoko.ts new file mode 100644 index 00000000..92d80821 --- /dev/null +++ b/cli/helpers/bind-motoko.ts @@ -0,0 +1,5 @@ +import { getWasmBindings } from "../wasm.js"; + +export function bindMotoko(did: string): string { + return getWasmBindings().bind_motoko(did); +} diff --git a/cli/tests/__snapshots__/generate.test.ts.snap b/cli/tests/__snapshots__/generate.test.ts.snap index 49381a2a..8a4e6ed0 100644 --- a/cli/tests/__snapshots__/generate.test.ts.snap +++ b/cli/tests/__snapshots__/generate.test.ts.snap @@ -1,5 +1,52 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing +exports[`generate bindings configured bindings: default out next to .did 1`] = ` +{ + "exitCode": 0, + "stderr": "", + "stdout": "generate bindings Ledger ← candid/ledger.did → candid/Ledger.mo + +✓ Generated Motoko bindings for 1 interface", +} +`; + +exports[`generate bindings configured bindings: default out next to .did: candid/Ledger.mo 1`] = ` +"// This is a generated Motoko binding. +// Please use \`import service "ic:canister_id"\` instead to call canisters on the IC if possible. + +module { + public type Account = { owner : Principal; subaccount : ?Blob }; + public type TransferArgs = { to : Account; amount : Nat }; + public type TransferResult = { #Ok : Nat; #Err : Text }; + public type Self = actor { + icrc1_balance_of : shared query Account -> async Nat; + icrc1_transfer : shared TransferArgs -> async TransferResult; + } +} +" +`; + +exports[`generate bindings configured out path is used 1`] = ` +{ + "exitCode": 0, + "stderr": "", + "stdout": "generate bindings CustomOut ← candid/custom.did → src/Custom.mo + +✓ Generated Motoko bindings for 1 interface", +} +`; + +exports[`generate bindings no names: generates all bindings 1`] = ` +{ + "exitCode": 0, + "stderr": "", + "stdout": "generate bindings Ledger ← candid/ledger.did → candid/Ledger.mo +generate bindings CustomOut ← candid/custom.did → src/Custom.mo + +✓ Generated Motoko bindings for 2 interfaces", +} +`; + exports[`generate candid configured path: overwrites in place and does not touch mops.toml 1`] = ` { "exitCode": 0, diff --git a/cli/tests/generate.test.ts b/cli/tests/generate.test.ts index 6eef077a..15f8e604 100644 --- a/cli/tests/generate.test.ts +++ b/cli/tests/generate.test.ts @@ -1,8 +1,8 @@ import { afterEach, describe, expect, jest, test } from "@jest/globals"; import { existsSync, readFileSync } from "node:fs"; -import { cp, rm } from "node:fs/promises"; +import { cp, rm, writeFile } from "node:fs/promises"; import path from "path"; -import { cli, cliSnapshot } from "./helpers"; +import { cli, cliSnapshot, useTempFixtures } from "./helpers"; const fixturesDir = path.join(import.meta.dirname, "generate"); @@ -138,3 +138,157 @@ describe("generate candid", () => { expect(result.stderr).toMatch(/collides with \[canisters\.bar\]\.candid/); }); }); + +describe("generate bindings", () => { + jest.setTimeout(60_000); + + const makeTempFixture = useTempFixtures(fixturesDir); + + test("configured bindings: default out next to .did", async () => { + const cwd = await makeTempFixture("bindings"); + await cliSnapshot(["generate", "bindings", "Ledger"], { cwd }, 0); + + const mo = readFileSync(path.join(cwd, "candid/Ledger.mo"), "utf-8"); + expect(mo).toMatch(/public type Self = actor/); + expect(mo).toMatch(/icrc1_transfer/); + expect(mo).toMatchSnapshot("candid/Ledger.mo"); + }); + + test("configured out path is used", async () => { + const cwd = await makeTempFixture("bindings"); + await cliSnapshot(["generate", "bindings", "CustomOut"], { cwd }, 0); + expect(existsSync(path.join(cwd, "src/Custom.mo"))).toBe(true); + expect(readFileSync(path.join(cwd, "src/Custom.mo"), "utf-8")).toMatch( + /ping/, + ); + }); + + test("no names: generates all bindings", async () => { + const cwd = await makeTempFixture("bindings"); + await cliSnapshot(["generate", "bindings"], { cwd }, 0); + expect(existsSync(path.join(cwd, "candid/Ledger.mo"))).toBe(true); + expect(existsSync(path.join(cwd, "src/Custom.mo"))).toBe(true); + }); + + test("ad-hoc .did with -o", async () => { + const cwd = await makeTempFixture("bindings"); + const outPath = path.join(cwd, "out/AdHoc.mo"); + const result = await cli( + ["generate", "bindings", "candid/ledger.did", "-o", outPath], + { cwd }, + ); + expect(result.exitCode).toBe(0); + expect(existsSync(outPath)).toBe(true); + expect(readFileSync(outPath, "utf-8")).toMatch(/icrc1_balance_of/); + }); + + test("ad-hoc without -o errors", async () => { + const cwd = await makeTempFixture("bindings"); + const result = await cli(["generate", "bindings", "candid/ledger.did"], { + cwd, + }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toMatch(/--output/i); + }); + + test("unknown binding name errors", async () => { + const cwd = await makeTempFixture("bindings"); + const result = await cli(["generate", "bindings", "Nope"], { cwd }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toMatch(/not found in mops\.toml/i); + }); + + test("rejects destination inside .mops/", async () => { + const cwd = await makeTempFixture("bindings"); + const result = await cli( + ["generate", "bindings", "Ledger", "-o", ".mops/Ledger.mo"], + { cwd }, + ); + expect(result.exitCode).toBe(1); + expect(result.stderr).toMatch(/\.mops\//); + }); + + test("missing [bindings] errors with hint", async () => { + const cwd = await makeTempFixture("basic"); + const result = await cli(["generate", "bindings"], { cwd }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toMatch(/No \[bindings\.\*\]/); + }); + + test("invalid candid errors without writing output", async () => { + const cwd = await makeTempFixture("bindings"); + await writeFile( + path.join(cwd, "candid/ledger.did"), + "this is not candid {{{", + ); + const result = await cli(["generate", "bindings", "Ledger"], { cwd }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toMatch(/Failed to generate Motoko bindings/); + expect(existsSync(path.join(cwd, "candid/Ledger.mo"))).toBe(false); + }); + + test("import statements are rejected", async () => { + const cwd = await makeTempFixture("bindings"); + await writeFile( + path.join(cwd, "candid/ledger.did"), + 'import service "other.did";\nservice : { ping : () -> () query; }\n', + ); + const result = await cli(["generate", "bindings", "Ledger"], { cwd }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toMatch(/import/i); + expect(existsSync(path.join(cwd, "candid/Ledger.mo"))).toBe(false); + }); + + test("named binding with -o writes to given path", async () => { + const cwd = await makeTempFixture("bindings"); + const outPath = path.join(cwd, "elsewhere/Ledger.mo"); + const result = await cli( + ["generate", "bindings", "Ledger", "-o", outPath], + { cwd }, + ); + expect(result.exitCode).toBe(0); + expect(existsSync(outPath)).toBe(true); + expect(existsSync(path.join(cwd, "candid/Ledger.mo"))).toBe(false); + }); + + test("--output with multiple binding names errors", async () => { + const cwd = await makeTempFixture("bindings"); + const result = await cli( + ["generate", "bindings", "Ledger", "CustomOut", "-o", "x.mo"], + { cwd }, + ); + expect(result.exitCode).toBe(1); + expect(result.stderr).toMatch(/single binding/i); + }); + + test("ad-hoc works without mops.toml", async () => { + const cwd = await makeTempFixture("bindings"); + await rm(path.join(cwd, "mops.toml")); + const outPath = path.join(cwd, "out/NoToml.mo"); + const result = await cli( + ["generate", "bindings", "candid/ledger.did", "-o", outPath], + { cwd }, + ); + expect(result.exitCode).toBe(0); + expect(existsSync(outPath)).toBe(true); + }); + + test("multi ad-hoc without mops.toml still errors as one-path", async () => { + const cwd = await makeTempFixture("bindings"); + await rm(path.join(cwd, "mops.toml")); + const result = await cli( + [ + "generate", + "bindings", + "candid/ledger.did", + "candid/custom.did", + "-o", + "out.mo", + ], + { cwd }, + ); + expect(result.exitCode).toBe(1); + expect(result.stderr).toMatch(/only one \.did path/i); + expect(result.stderr).not.toMatch(/mops\.toml not found/i); + }); +}); diff --git a/cli/tests/generate/bindings/candid/custom.did b/cli/tests/generate/bindings/candid/custom.did new file mode 100644 index 00000000..4e86e211 --- /dev/null +++ b/cli/tests/generate/bindings/candid/custom.did @@ -0,0 +1,3 @@ +service : { + ping : () -> (text) query; +} diff --git a/cli/tests/generate/bindings/candid/ledger.did b/cli/tests/generate/bindings/candid/ledger.did new file mode 100644 index 00000000..bb243494 --- /dev/null +++ b/cli/tests/generate/bindings/candid/ledger.did @@ -0,0 +1,7 @@ +type Account = record { owner : principal; subaccount : opt blob }; +type TransferArgs = record { to : Account; amount : nat }; +type TransferResult = variant { Ok : nat; Err : text }; +service : { + icrc1_transfer : (TransferArgs) -> (TransferResult); + icrc1_balance_of : (Account) -> (nat) query; +} diff --git a/cli/tests/generate/bindings/mops.toml b/cli/tests/generate/bindings/mops.toml new file mode 100644 index 00000000..96daa58a --- /dev/null +++ b/cli/tests/generate/bindings/mops.toml @@ -0,0 +1,9 @@ +[toolchain] +moc = "1.5.0" + +[bindings.Ledger] +did = "candid/ledger.did" + +[bindings.CustomOut] +did = "candid/custom.did" +out = "src/Custom.mo" diff --git a/cli/types.ts b/cli/types.ts index b7574d75..1588f63f 100644 --- a/cli/types.ts +++ b/cli/types.ts @@ -23,6 +23,7 @@ export type Config = { args?: string[]; }; canisters?: Record; + bindings?: Record; build?: { outputDir?: string; args?: string[]; @@ -51,6 +52,12 @@ export type MigrationsConfig = { "build-limit"?: number; }; +export type BindingConfig = { + did: string; + /** Defaults to `/.mo`. */ + out?: string; +}; + export type CanisterConfig = { main?: string; args?: string[]; diff --git a/cli/wasm.ts b/cli/wasm.ts index 228605c5..51529b5d 100644 --- a/cli/wasm.ts +++ b/cli/wasm.ts @@ -5,6 +5,7 @@ export interface CustomSection { export interface WasmBindings { is_candid_compatible: (newCandid: string, originalCandid: string) => boolean; + bind_motoko: (did: string) => string; add_custom_sections: ( bytes: Uint8Array, customSections: CustomSection[], diff --git a/cli/wasm/src/lib.rs b/cli/wasm/src/lib.rs index 12b964be..50c25b1e 100644 --- a/cli/wasm/src/lib.rs +++ b/cli/wasm/src/lib.rs @@ -1,7 +1,14 @@ mod utils; mod wasm_utils; -use candid_parser::utils::{service_compatible, CandidSource}; +use candid_parser::{ + bindings::motoko, + pretty_parse, + syntax::{Dec, IDLMergedProg, IDLProg}, + typing::check_prog, + utils::{service_compatible, CandidSource}, + TypeEnv, +}; use wasm_bindgen::prelude::*; use crate::utils::{js_value, JsResult}; @@ -15,6 +22,31 @@ pub fn is_candid_compatible(new_interface: &str, original_interface: &str) -> bo .is_ok() } +/// Motoko bindings from a self-contained `.did` (imports rejected). +#[wasm_bindgen] +pub fn bind_motoko(did: &str) -> JsResult { + let ast: IDLProg = pretty_parse("anonymous.did", did) + .map_err(|e| JsError::new(&e.to_string()))?; + for dec in &ast.decs { + match dec { + Dec::ImportType(path) | Dec::ImportServ(path) => { + return Err(JsError::new(&format!( + "`.did` import {path:?} is not supported; flatten into a self-contained `.did` first" + ))); + } + Dec::TypD(_) => {} + } + } + let mut env = TypeEnv::new(); + let actor = check_prog(&mut env, &ast).map_err(|e| JsError::new(&e.to_string()))?; + let prog = IDLMergedProg::new(ast); + let mut mo = motoko::compile(&env, &actor, &prog); + if !mo.ends_with('\n') { + mo.push('\n'); + } + Ok(mo) +} + #[wasm_bindgen] pub fn add_custom_sections(bytes: &[u8], custom_sections: JsValue) -> JsResult> { wasm_utils::add_custom_sections(bytes, js_value(custom_sections)?) diff --git a/docs/docs/09-mops.toml.md b/docs/docs/09-mops.toml.md index 289a0c6d..f7e8d2f1 100644 --- a/docs/docs/09-mops.toml.md +++ b/docs/docs/09-mops.toml.md @@ -178,6 +178,27 @@ backend = "src/main.mo" ``` + +## [bindings] + +Declare external Candid interfaces to turn into Motoko binding modules via [`mops generate bindings`](/cli/mops-generate). + +Each entry is a named binding (the Motoko module basename). + +| Field | Description | +| ----- | ----------- | +| did | Path to the source `.did` file (required) | +| out | Destination `.mo` path (optional). Defaults to `/.mo` | + +Example: +```toml +[bindings.ICRC] +did = "candid/icrc.did" +out = "bindings/ICRC.mo" +``` + +Import the generated module from Motoko and type runtime principals, e.g. `actor(id) : ICRC.Self`. + ## [build] Global build settings used by [`mops build`](/cli/mops-build). diff --git a/docs/docs/cli/4-dev/09-mops-generate.md b/docs/docs/cli/4-dev/09-mops-generate.md index 53711ac4..a771a805 100644 --- a/docs/docs/cli/4-dev/09-mops-generate.md +++ b/docs/docs/cli/4-dev/09-mops-generate.md @@ -5,7 +5,7 @@ sidebar_label: mops generate # `mops generate` -Generate source-derived artifacts from your Motoko code. +Generate project artifacts: curated Candid from Motoko, or Motoko bindings from Candid. ## `mops generate candid` @@ -70,3 +70,66 @@ When `moc` fails, neither the destination file nor `mops.toml` is touched. ## Relation to `mops build` `mops build` subtype-checks the auto-generated interface against `[canisters.].candid` (when set) and embeds the curated file as `candid:service` metadata. Use `mops generate candid` to keep that curated file in sync with source. The two commands share moc invocation logic so the generated `.did` always passes the build's compatibility check. + +## `mops generate bindings` + +``` +mops generate bindings [targets...] +``` + +Generate Motoko binding modules from committed `.did` interfaces. Use this when a canister talks to **many** principals sharing one interface (e.g. ICRC ledgers chosen at runtime via `actor(id) : ICRC.Self`). For a **single** fixed target, prefer `canister:` imports with `--actor-env-alias` instead. + +The `.did` is the source of truth — commit it, regenerate after interface changes, and commit the generated `.mo` (or regenerate in CI). Codegen uses the same `candid_parser` Motoko bindgen as `didc bind -t mo`, embedded in mops (no separate `didc` install). Unlike `didc`, `.did` `import`s are rejected — flatten the interface first. + +### Config + +Declare interfaces under `[bindings.]` in `mops.toml`: + +```toml +[bindings.ICRC] +did = "candid/icrc.did" +# optional; default: /.mo → candid/ICRC.mo +# out = "bindings/ICRC.mo" +``` + +### Where the file is written + +1. `--output ` if given (single target only) — writes there; does not touch `mops.toml`. +2. `[bindings.].out` if set — overwrites that path. +3. Default — `.mo` next to the `.did` file. + +Paths inside `.mops/` are rejected. + +### Examples + +Generate all configured bindings +``` +mops generate bindings +``` + +Generate one binding +``` +mops generate bindings ICRC +``` + +Ad-hoc (no `[bindings]` entry required; `mops.toml` optional) +``` +mops generate bindings candid/icrc.did -o bindings/ICRC.mo +``` + +### Options + +#### `--output`, `-o` + +Write the generated `.mo` to the given path. Single binding or ad-hoc `.did` only. Does not update `mops.toml`. + +#### `--verbose` + +Show extra details (bytes written). + +### Relation to `mops generate candid` + +| Command | Direction | Typical use | +| --- | --- | --- | +| `mops generate candid` | Motoko → `.did` | Your canister's public interface | +| `mops generate bindings` | `.did` → Motoko | External service types for inter-canister calls |