From a6ba0c34c2aca0ea12d7c0bbf6ff968f981c8274 Mon Sep 17 00:00:00 2001 From: Kamil Listopad Date: Wed, 29 Jul 2026 11:46:41 +0200 Subject: [PATCH 1/4] =?UTF-8?q?feat(cli):=20add=20`mops=20generate=20bindi?= =?UTF-8?q?ngs`=20for=20.did=20=E2=86=92=20Motoko?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Embed candid_parser's Motoko bindgen in the CLI wasm so projects can regenerate service types for runtime-chosen principals without installing didc. Co-authored-by: Cursor --- .agents/skills/mops-cli/SKILL.md | 18 ++ cli-releases/frontend/package-lock.json | 15 ++ cli/CHANGELOG.md | 2 + cli/cli.ts | 22 +- cli/commands/generate-bindings.ts | 214 ++++++++++++++++++ cli/helpers/bind-motoko.ts | 5 + cli/tests/__snapshots__/generate.test.ts.snap | 46 ++++ cli/tests/generate.test.ts | 93 +++++++- cli/tests/generate/bindings/candid/custom.did | 3 + cli/tests/generate/bindings/candid/ledger.did | 7 + cli/tests/generate/bindings/mops.toml | 9 + cli/types.ts | 7 + cli/wasm.ts | 1 + cli/wasm/src/lib.rs | 20 +- docs/docs/09-mops.toml.md | 21 ++ docs/docs/cli/4-dev/09-mops-generate.md | 67 +++++- docs/package-lock.json | 1 - package-lock.json | 2 +- 18 files changed, 546 insertions(+), 7 deletions(-) create mode 100644 cli/commands/generate-bindings.ts create mode 100644 cli/helpers/bind-motoko.ts create mode 100644 cli/tests/generate/bindings/candid/custom.did create mode 100644 cli/tests/generate/bindings/candid/ledger.did create mode 100644 cli/tests/generate/bindings/mops.toml diff --git a/.agents/skills/mops-cli/SKILL.md b/.agents/skills/mops-cli/SKILL.md index 35bb896b..8071da18 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 (no toml entry) +``` + +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 not resolved — use a self-contained interface. + ### `mops toolchain` ```bash diff --git a/cli-releases/frontend/package-lock.json b/cli-releases/frontend/package-lock.json index 3160d985..2530e251 100644 --- a/cli-releases/frontend/package-lock.json +++ b/cli-releases/frontend/package-lock.json @@ -3210,6 +3210,21 @@ } } }, + "node_modules/svelte-check/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/svelte-eslint-parser": { "version": "0.43.0", "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-0.43.0.tgz", diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index 29fb69b6..14757af2 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 Motoko bindgen as `didc bind -t mo`, embedded in mops). 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..7a9e94d9 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,23 @@ 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 without mops.toml.", + ) + .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) => { + 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..16671594 --- /dev/null +++ b/cli/commands/generate-bindings.ts @@ -0,0 +1,214 @@ +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)) { + 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"); + + console.log(chalk.green(`\n✓ Generated Motoko bindings`)); +} + +function looksLikeDidPath(arg: string): boolean { + return arg.endsWith(".did") || arg.includes("/") || arg.includes("\\"); +} + +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 dotMopsDir = path.resolve(rootDir, ".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..8afc1c1c 100644 --- a/cli/tests/__snapshots__/generate.test.ts.snap +++ b/cli/tests/__snapshots__/generate.test.ts.snap @@ -1,5 +1,51 @@ // 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..eb261ec9 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,92 @@ 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); + }); +}); 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..0c00e32f 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::{IDLMergedProg, IDLProg}, + typing::check_prog, + utils::{service_compatible, CandidSource}, + TypeEnv, +}; use wasm_bindgen::prelude::*; use crate::utils::{js_value, JsResult}; @@ -15,6 +22,17 @@ pub fn is_candid_compatible(new_interface: &str, original_interface: &str) -> bo .is_ok() } +/// Motoko bindings from a self-contained `.did` (imports not resolved). +#[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()))?; + 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); + Ok(motoko::compile(&env, &actor, &prog)) +} + #[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..c40f69d5 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 source-derived artifacts: curated Candid from Motoko, or Motoko bindings from Candid. ## `mops generate candid` @@ -70,3 +70,68 @@ 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). The generator is the same Motoko bindgen `didc bind -t mo` uses, embedded in mops (no separate `didc` install). + +`.did` files with `import` statements are not supported yet — flatten or inline 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 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 | diff --git a/docs/package-lock.json b/docs/package-lock.json index fcfeadf4..12f48462 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -4,7 +4,6 @@ "requires": true, "packages": { "": { - "name": "docs", "dependencies": { "@docusaurus/core": "3.9.2", "@docusaurus/preset-classic": "3.9.2", diff --git a/package-lock.json b/package-lock.json index ee1708a7..3dd9b108 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "mops", + "name": "mops-a05acb1ce984", "lockfileVersion": 3, "requires": true, "packages": { From 8293cefb1325306985d36ab70a3a5e1456e685e7 Mon Sep 17 00:00:00 2001 From: Kamil Listopad Date: Wed, 29 Jul 2026 11:46:55 +0200 Subject: [PATCH 2/4] chore: drop unrelated lockfile churn from generate-bindings commit Co-authored-by: Cursor --- cli-releases/frontend/package-lock.json | 15 --------------- docs/package-lock.json | 1 + package-lock.json | 2 +- 3 files changed, 2 insertions(+), 16 deletions(-) diff --git a/cli-releases/frontend/package-lock.json b/cli-releases/frontend/package-lock.json index 2530e251..3160d985 100644 --- a/cli-releases/frontend/package-lock.json +++ b/cli-releases/frontend/package-lock.json @@ -3210,21 +3210,6 @@ } } }, - "node_modules/svelte-check/node_modules/picomatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/svelte-eslint-parser": { "version": "0.43.0", "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-0.43.0.tgz", diff --git a/docs/package-lock.json b/docs/package-lock.json index 12f48462..fcfeadf4 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -4,6 +4,7 @@ "requires": true, "packages": { "": { + "name": "docs", "dependencies": { "@docusaurus/core": "3.9.2", "@docusaurus/preset-classic": "3.9.2", diff --git a/package-lock.json b/package-lock.json index 3dd9b108..ee1708a7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "mops-a05acb1ce984", + "name": "mops", "lockfileVersion": 3, "requires": true, "packages": { From 89828b48af7231534e867f7eaaff33c544da9711 Mon Sep 17 00:00:00 2001 From: Kamil Listopad Date: Wed, 29 Jul 2026 11:54:35 +0200 Subject: [PATCH 3/4] fix(cli): harden generate bindings edge cases from review Reject .did imports, allow ad-hoc without mops.toml, tighten path detection and errors, and cover those paths in tests. Co-authored-by: Cursor --- .agents/skills/mops-cli/SKILL.md | 4 +- cli/CHANGELOG.md | 2 +- cli/cli.ts | 7 ++- cli/commands/generate-bindings.ts | 13 +++-- cli/tests/__snapshots__/generate.test.ts.snap | 3 +- cli/tests/generate.test.ts | 48 ++++++++++++++++++- cli/wasm/src/lib.rs | 20 ++++++-- docs/docs/cli/4-dev/09-mops-generate.md | 8 ++-- 8 files changed, 87 insertions(+), 18 deletions(-) diff --git a/.agents/skills/mops-cli/SKILL.md b/.agents/skills/mops-cli/SKILL.md index 8071da18..5a80dff8 100644 --- a/.agents/skills/mops-cli/SKILL.md +++ b/.agents/skills/mops-cli/SKILL.md @@ -151,7 +151,7 @@ mops generate candid backend -o # single canister, ad-hoc path ```bash mops generate bindings # all [bindings.*] mops generate bindings ICRC # one named binding -mops generate bindings foo.did -o out.mo # ad-hoc (no toml entry) +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`: @@ -162,7 +162,7 @@ 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 not resolved — use a self-contained interface. +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` diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index 14757af2..2fd843a7 100644 --- a/cli/CHANGELOG.md +++ b/cli/CHANGELOG.md @@ -2,7 +2,7 @@ ## Next -- `mops generate bindings` generates Motoko modules from committed `.did` files (same Motoko bindgen as `didc bind -t mo`, embedded in mops). 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`. +- `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 diff --git a/cli/cli.ts b/cli/cli.ts index 7a9e94d9..f9920a44 100755 --- a/cli/cli.ts +++ b/cli/cli.ts @@ -979,7 +979,7 @@ 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 without mops.toml.", + "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( @@ -989,7 +989,10 @@ generateCommand ) .addOption(new Option("--verbose", "Verbose console output")) .action(async (targets, options) => { - checkConfigFile(true); + const adHoc = targets.length === 1 && String(targets[0]).endsWith(".did"); + if (!adHoc) { + checkConfigFile(true); + } await generateBindings(targets.length ? targets : undefined, options); }); diff --git a/cli/commands/generate-bindings.ts b/cli/commands/generate-bindings.ts index 16671594..f157c6fe 100644 --- a/cli/commands/generate-bindings.ts +++ b/cli/commands/generate-bindings.ts @@ -25,6 +25,12 @@ export async function generateBindings( 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", @@ -37,7 +43,7 @@ export async function generateBindings( if (!names.length) { cliError( - "No [bindings] entries in mops.toml.\n" + + "No [bindings.*] entries in mops.toml.\n" + "Add one, e.g.:\n" + " [bindings.ICRC]\n" + ' did = "candid/icrc.did"\n' + @@ -146,7 +152,7 @@ async function generateAdHoc( } function looksLikeDidPath(arg: string): boolean { - return arg.endsWith(".did") || arg.includes("/") || arg.includes("\\"); + return arg.endsWith(".did"); } function validateBindingEntry(name: string, entry: BindingConfig): void { @@ -192,7 +198,8 @@ function resolveOutputPath( display?: string, ): Destination { const absPath = path.resolve(fsPath); - const dotMopsDir = path.resolve(rootDir, ".mops"); + 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` + diff --git a/cli/tests/__snapshots__/generate.test.ts.snap b/cli/tests/__snapshots__/generate.test.ts.snap index 8afc1c1c..8a4e6ed0 100644 --- a/cli/tests/__snapshots__/generate.test.ts.snap +++ b/cli/tests/__snapshots__/generate.test.ts.snap @@ -22,7 +22,8 @@ module { icrc1_balance_of : shared query Account -> async Nat; icrc1_transfer : shared TransferArgs -> async TransferResult; } -}" +} +" `; exports[`generate bindings configured out path is used 1`] = ` diff --git a/cli/tests/generate.test.ts b/cli/tests/generate.test.ts index eb261ec9..fe8d2a44 100644 --- a/cli/tests/generate.test.ts +++ b/cli/tests/generate.test.ts @@ -212,7 +212,7 @@ describe("generate bindings", () => { const cwd = await makeTempFixture("basic"); const result = await cli(["generate", "bindings"], { cwd }); expect(result.exitCode).toBe(1); - expect(result.stderr).toMatch(/No \[bindings\]/); + expect(result.stderr).toMatch(/No \[bindings\.\*\]/); }); test("invalid candid errors without writing output", async () => { @@ -226,4 +226,50 @@ describe("generate bindings", () => { 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); + }); }); diff --git a/cli/wasm/src/lib.rs b/cli/wasm/src/lib.rs index 0c00e32f..50c25b1e 100644 --- a/cli/wasm/src/lib.rs +++ b/cli/wasm/src/lib.rs @@ -4,7 +4,7 @@ mod wasm_utils; use candid_parser::{ bindings::motoko, pretty_parse, - syntax::{IDLMergedProg, IDLProg}, + syntax::{Dec, IDLMergedProg, IDLProg}, typing::check_prog, utils::{service_compatible, CandidSource}, TypeEnv, @@ -22,15 +22,29 @@ pub fn is_candid_compatible(new_interface: &str, original_interface: &str) -> bo .is_ok() } -/// Motoko bindings from a self-contained `.did` (imports not resolved). +/// 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); - Ok(motoko::compile(&env, &actor, &prog)) + let mut mo = motoko::compile(&env, &actor, &prog); + if !mo.ends_with('\n') { + mo.push('\n'); + } + Ok(mo) } #[wasm_bindgen] diff --git a/docs/docs/cli/4-dev/09-mops-generate.md b/docs/docs/cli/4-dev/09-mops-generate.md index c40f69d5..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: curated Candid from Motoko, or Motoko bindings from Candid. +Generate project artifacts: curated Candid from Motoko, or Motoko bindings from Candid. ## `mops generate candid` @@ -79,9 +79,7 @@ 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). The generator is the same Motoko bindgen `didc bind -t mo` uses, embedded in mops (no separate `didc` install). - -`.did` files with `import` statements are not supported yet — flatten or inline the interface first. +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 @@ -114,7 +112,7 @@ Generate one binding mops generate bindings ICRC ``` -Ad-hoc (no `[bindings]` entry required) +Ad-hoc (no `[bindings]` entry required; `mops.toml` optional) ``` mops generate bindings candid/icrc.did -o bindings/ICRC.mo ``` From 1df8e2992ee4e7eff6708b424fd8af3dfc2d3839 Mon Sep 17 00:00:00 2001 From: Kamil Listopad Date: Wed, 29 Jul 2026 11:58:20 +0200 Subject: [PATCH 4/4] fix(cli): skip config check for any ad-hoc .did target Co-authored-by: Cursor --- cli/cli.ts | 2 +- cli/commands/generate-bindings.ts | 6 +++++- cli/tests/generate.test.ts | 19 +++++++++++++++++++ 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/cli/cli.ts b/cli/cli.ts index f9920a44..a9576a0f 100755 --- a/cli/cli.ts +++ b/cli/cli.ts @@ -989,7 +989,7 @@ generateCommand ) .addOption(new Option("--verbose", "Verbose console output")) .action(async (targets, options) => { - const adHoc = targets.length === 1 && String(targets[0]).endsWith(".did"); + const adHoc = targets.some((t: string) => String(t).endsWith(".did")); if (!adHoc) { checkConfigFile(true); } diff --git a/cli/commands/generate-bindings.ts b/cli/commands/generate-bindings.ts index f157c6fe..58538282 100644 --- a/cli/commands/generate-bindings.ts +++ b/cli/commands/generate-bindings.ts @@ -56,7 +56,7 @@ export async function generateBindings( ? targets.map((name) => { if (!bindings[name]) { cliError( - `Binding ${JSON.stringify(name)} not found in mops.toml [bindings]`, + `Binding ${JSON.stringify(name)} not found in mops.toml [bindings.*]`, ); } return name; @@ -148,6 +148,10 @@ async function generateAdHoc( 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`)); } diff --git a/cli/tests/generate.test.ts b/cli/tests/generate.test.ts index fe8d2a44..15f8e604 100644 --- a/cli/tests/generate.test.ts +++ b/cli/tests/generate.test.ts @@ -272,4 +272,23 @@ describe("generate bindings", () => { 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); + }); });