diff --git a/cli/cli.ts b/cli/cli.ts index 490e8779..a7bf8cd2 100755 --- a/cli/cli.ts +++ b/cli/cli.ts @@ -13,6 +13,7 @@ import { bump } from "./commands/bump.js"; import { checkCandid } from "./commands/check-candid.js"; import { docsCoverage } from "./commands/docs-coverage.js"; import { docs } from "./commands/docs.js"; +import { fix } from "./commands/fix.js"; import { format } from "./commands/format.js"; import { init } from "./commands/init.js"; import { installAll } from "./commands/install/install-all.js"; @@ -509,6 +510,16 @@ maintainerCommand program.addCommand(maintainerCommand); +// fix +program + .command("fix ") + .description("Automatically fix code issues in a file") + .option("--dry-run", "Show what would be fixed without modifying files") + .allowExcessArguments(true) + .action(async (file, options) => { + await fix(file, { ...options, extraArgs: program.args }); + }); + // bump program .command("bump [major|minor|patch]") diff --git a/cli/commands/fix.ts b/cli/commands/fix.ts new file mode 100644 index 00000000..ea080896 --- /dev/null +++ b/cli/commands/fix.ts @@ -0,0 +1,265 @@ +import mo from "motoko"; +import fs from "node:fs"; +import { promisify } from "node:util"; + +const readFile = promisify(fs.readFile); +const writeFile = promisify(fs.writeFile); + +interface Diagnostic { + source: string; + message: string; + range: { + start: { line: number; character: number }; + end: { line: number; character: number }; + }; + code?: string; + severity: number; +} + +interface Fix { + range: { + start: { line: number; character: number }; + end: { line: number; character: number }; + }; + newText: string; + message: string; +} + +export const fix = async ( + file: string, + options: { dryRun?: boolean; extraArgs?: string[] } = {}, +) => { + console.log(`Checking for fixes in ${file}...`); + + try { + const content = await readFile(file, "utf8"); + mo.write(file, content); + + if (options.extraArgs && options.extraArgs.length > 0) { + mo.setExtraFlags(options.extraArgs); + } + + const diagnostics = mo.check(file) as any as Diagnostic[]; + + if (!diagnostics || diagnostics.length === 0) { + console.log("No fixes needed."); + return; + } + + const fixes: Fix[] = []; + + for (const diag of diagnostics) { + // Fix M0223: Redundant type instantiation + // Remove type arguments like from inferred(1) -> inferred(1) + // The range always covers the whole type instantiation, so we can just remove it + if (diag.code === "M0223") { + fixes.push({ + range: diag.range, + newText: "", // Remove the type instantiation entirely + message: diag.message, + }); + } + + // Fix M0236: Dot notation suggestion + if (diag.code === "M0236") { + const match = diag.message.match( + /You can use the dot notation `(.+)\.(.+)\(\.\.\.\)` here/, + ); + if (match) { + const suggestedMethod = match[2]; + + const originalText = extractText(content, diag.range); + const parsed = parseCall(originalText); + + if (parsed && parsed.args.length > 0) { + const receiver = parsed.args[0]; + const restArgs = parsed.args.slice(1).join(", "); + const newText = `${receiver}.${suggestedMethod}(${restArgs})`; + + fixes.push({ + range: diag.range, + newText: newText, + message: diag.message, + }); + } + } + } + + // Fix M0237: Redundant explicit implicit arguments + // Remove explicit implicit arguments like Nat.compare from get(Nat.compare, 1) -> get(1) + // The range covers the argument, and we optionally remove whitespace + comma after it + if (diag.code === "M0237") { + const lines = content.split("\n"); + const lineIdx = diag.range.end.line; + const line = lines[lineIdx]; + + if (line) { + const restOfLine = line.substring(diag.range.end.character); + const nextLine = lines[lineIdx + 1]; + const textToCheck = + nextLine !== undefined ? restOfLine + "\n" + nextLine : restOfLine; + + const match = textToCheck.match(/^\s*,\s*/); + + if (match) { + const fullMatch = match[0]; + + let endLine = lineIdx; + let endChar = diag.range.end.character; + + const nlIndex = fullMatch.lastIndexOf("\n"); + if (nlIndex !== -1) { + endLine++; + endChar = fullMatch.length - (nlIndex + 1); + } else { + endChar += fullMatch.length; + } + + fixes.push({ + range: { + start: diag.range.start, + end: { line: endLine, character: endChar }, + }, + newText: "", + message: diag.message, + }); + } + } + } + } + + if (fixes.length > 0) { + console.log(`Found ${fixes.length} fix(es)`); + + if (options.dryRun) { + for (const f of fixes) { + console.log( + ` Would replace '${extractText(content, f.range)}' at ${f.range.start.line + 1}:${f.range.start.character + 1} with: '${f.newText}'`, + ); + } + } else { + const fixedContent = applyFixes(content, fixes); + await writeFile(file, fixedContent); + console.log(`Applied ${fixes.length} fix(es).`); + } + } else { + console.log("No fixes applied."); + } + } catch (err) { + console.error(`Error processing ${file}:`, err); + throw err; + } +}; + +function extractText( + content: string, + range: { + start: { line: number; character: number }; + end: { line: number; character: number }; + }, +): string { + const lines = content.split("\n"); + const startLineNr = range.start.line; + const endLineNr = range.end.line; + const startChar = range.start.character; + const endChar = range.end.character; + const startLine = lines[startLineNr]; + if (startLine === undefined) { + throw new Error(`Start line not found: ${startLineNr}`); + } + + const endLine = lines[endLineNr]; + if (endLine === undefined) { + throw new Error(`End line not found: ${endLineNr}`); + } + + if (startLineNr === endLineNr) { + return startLine.substring(startChar, endChar); + } + + let text = startLine.substring(startChar); + for (let i = startLineNr + 1; i < endLineNr; i++) { + text += "\n" + lines[i]; + } + text += "\n" + endLine.substring(0, endChar); + return text; +} + +function parseCall(code: string): { func: string; args: string[] } | null { + // Matches Func(args) or Module.Func(args) + const match = code.match(/^([\w.]+)\s*\(([\s\S]*)\)$/); + if (!match) { + return null; + } + + const func = match[1] || ""; + const argsStr = match[2] || ""; + + const args: string[] = []; + let current = ""; + let depth = 0; + + for (let i = 0; i < argsStr.length; i++) { + const char = argsStr[i]; + if (char === "(" || char === "[" || char === "{") { + depth++; + } + if (char === ")" || char === "]" || char === "}") { + depth--; + } + + if (char === "," && depth === 0) { + args.push(current.trim()); + current = ""; + } else { + current += char; + } + } + if (current.trim()) { + args.push(current.trim()); + } + + // Handle empty args case "()" + if (args.length === 1 && args[0] === "") { + return { func, args: [] }; + } + + return { func, args }; +} + +function applyFixes(content: string, fixes: Fix[]): string { + return applyFixesString(content, fixes); +} + +function applyFixesString(content: string, fixes: Fix[]): string { + // Sort fixes in reverse order + const sortedFixes = [...fixes].sort((a, b) => { + if (a.range.start.line !== b.range.start.line) { + return b.range.start.line - a.range.start.line; + } + return b.range.start.character - a.range.start.character; + }); + + // We need to map (line, char) to absolute index + const lines = content.split("\n"); + const lineOffsets: number[] = []; + let currentOffset = 0; + for (const line of lines) { + lineOffsets.push(currentOffset); + currentOffset += line.length + 1; // +1 for \n + } + + let result = content; + + for (const fix of sortedFixes) { + const startOffset = + lineOffsets[fix.range.start.line]! + fix.range.start.character; + const endOffset = + lineOffsets[fix.range.end.line]! + fix.range.end.character; + + result = + result.slice(0, startOffset) + fix.newText + result.slice(endOffset); + } + + return result; +} diff --git a/cli/package-lock.json b/cli/package-lock.json index 0e3597d8..a0e2ead0 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -37,6 +37,7 @@ "mdast-util-from-markdown": "2.0.2", "mdast-util-to-markdown": "2.1.2", "minimatch": "10.0.1", + "motoko": "3.16.0", "ncp": "2.0.0", "node-fetch": "3.3.2", "octokit": "3.1.2", @@ -4929,6 +4930,57 @@ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "license": "MIT" }, + "node_modules/cross-fetch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.5.tgz", + "integrity": "sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw==", + "license": "MIT", + "dependencies": { + "node-fetch": "2.6.7" + } + }, + "node_modules/cross-fetch/node_modules/node-fetch": { + "version": "2.6.7", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", + "integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/cross-fetch/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/cross-fetch/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/cross-fetch/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -10576,6 +10628,41 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/motoko": { + "version": "3.16.0", + "resolved": "https://registry.npmjs.org/motoko/-/motoko-3.16.0.tgz", + "integrity": "sha512-6LQiRYp5LsakznOewQKqppF76pn818ynhhU1cy9emCu7e8VMI1OroCDD0Ya8j7TSug1Q7DsrkfrGi2jbgxaJDw==", + "license": "Apache-2.0", + "dependencies": { + "cross-fetch": "3.1.5", + "debug": "4.3.4", + "parse-github-url": "1.0.3", + "sanitize-filename": "1.6.3" + } + }, + "node_modules/motoko/node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "license": "MIT", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/motoko/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -11237,6 +11324,18 @@ "node": ">=6" } }, + "node_modules/parse-github-url": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/parse-github-url/-/parse-github-url-1.0.3.tgz", + "integrity": "sha512-tfalY5/4SqGaV/GIGzWyHnFjlpTPTNpENR9Ea2lLldSJ8EWXMsvacWucqY3m3I4YPtas15IxTLQVQ5NSYXPrww==", + "license": "MIT", + "bin": { + "parse-github-url": "cli.js" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/parse-json": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", @@ -12195,6 +12294,15 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/sanitize-filename": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.3.tgz", + "integrity": "sha512-y/52Mcy7aw3gRm7IrcGDFx/bCk4AhRh2eI9luHOQM86nZsqwiRkkq2GekHXBBD+SmPidc8i2PqtYZl+pWJ8Oeg==", + "license": "WTFPL OR ISC", + "dependencies": { + "truncate-utf8-bytes": "^1.0.0" + } + }, "node_modules/saxes": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", @@ -13101,6 +13209,15 @@ "node": ">=18" } }, + "node_modules/truncate-utf8-bytes": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", + "integrity": "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==", + "license": "WTFPL", + "dependencies": { + "utf8-byte-length": "^1.0.1" + } + }, "node_modules/ts-jest": { "version": "29.4.5", "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.5.tgz", @@ -13587,6 +13704,12 @@ "punycode": "^2.1.0" } }, + "node_modules/utf8-byte-length": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz", + "integrity": "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==", + "license": "(WTFPL OR MIT)" + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/cli/package.json b/cli/package.json index 7d128add..28f87bcf 100644 --- a/cli/package.json +++ b/cli/package.json @@ -78,6 +78,7 @@ "mdast-util-from-markdown": "2.0.2", "mdast-util-to-markdown": "2.1.2", "minimatch": "10.0.1", + "motoko": "3.16.0", "ncp": "2.0.0", "node-fetch": "3.3.2", "octokit": "3.1.2", diff --git a/cli/tests/fix.test.ts b/cli/tests/fix.test.ts new file mode 100644 index 00000000..ce3ac949 --- /dev/null +++ b/cli/tests/fix.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, test, beforeAll } from "@jest/globals"; +import { execa } from "execa"; +import { execSync } from "child_process"; +import fs from "fs"; +import path from "path"; +import { promisify } from "util"; + +const readFile = promisify(fs.readFile); +const writeFile = promisify(fs.writeFile); +const rm = promisify(fs.rm); + +interface CliOptions { + cwd?: string; +} + +const cli = async (args: string[], { cwd }: CliOptions = {}) => { + return await execa("npm", ["run", "mops", "--", ...args], { + env: { MOPS_CWD: cwd }, + stdio: "pipe", + reject: false, + }); +}; + +const MO_VERSION = "1.0.0"; +// Enable all warning codes we test +const WARNING_FLAGS = "-W=M0223,M0236,M0237"; + +/** + * Creates a temporary file from a source file and ensures cleanup + */ +async function withTmpFile( + sourceFile: string, + callback: (tmpFile: string) => Promise, +): Promise { + const tmpFile = `${sourceFile}.tmp`; + try { + // Copy the original source file to temp location + const originalContent = await readFile(sourceFile, "utf8"); + await writeFile(tmpFile, originalContent); + return await callback(tmpFile); + } finally { + // Clean up temporary file + await rm(tmpFile, { force: true }); + } +} + +/** + * Gets the moc binary path from toolchain + */ +async function getMocPath(): Promise { + const mocBinResult = await cli(["toolchain", "bin", "moc"]); + // Extract just the path, removing npm output prefix + const lines = mocBinResult.stdout.split("\n"); + const mocPath = lines[lines.length - 1]!.trim(); + + if (!mocPath || !fs.existsSync(mocPath)) { + throw new Error( + `moc binary not found at ${mocPath}. Toolchain setup may have failed.`, + ); + } + return mocPath; +} + +function getWarnings(filePath: string, mocPath: string, cwd: string): string[] { + const output = execSync( + `"${mocPath}" --check "${filePath}" ${WARNING_FLAGS} 2>&1`, + { encoding: "utf8", cwd }, + ); + + const warnings: string[] = []; + + const warningRegex = /warning \[(M\d+)\]/gi; + let match; + while ((match = warningRegex.exec(output)) !== null) { + warnings.push(match[1]!); + } + + return warnings; +} + +describe("mops fix", () => { + let mocPath: string; + + beforeAll(async () => { + // Ensure toolchain is initialized and moc is installed + const initResult = await cli(["toolchain", "init"]); + if ( + initResult.exitCode !== 0 && + !initResult.stdout.includes("already initialized") + ) { + throw new Error(`toolchain init failed: ${initResult.stderr}`); + } + + const useResult = await cli(["toolchain", "use", "moc", MO_VERSION]); + if (useResult.exitCode !== 0) { + throw new Error(`toolchain use failed: ${useResult.stderr}`); + } + + mocPath = await getMocPath(); + }, 120000); + + const testCases = [ + { code: "M0223", file: "m0223.mo" }, + { code: "M0236", file: "m0236.mo" }, + { code: "M0237", file: "m0237.mo" }, + ]; + + for (const { code, file } of testCases) { + test(`fixes ${code} warning`, async () => { + const srcFile = path.join(import.meta.dirname, "fix", file); + const tmpDir = path.dirname(srcFile); + + await withTmpFile(srcFile, async (tmpFile) => { + // Check initial state - should have the warning + const beforeWarnings = getWarnings(tmpFile, mocPath, tmpDir); + expect(beforeWarnings).toContain(code); + + // Run mops fix on the temp file + const fixResult = await cli(["fix", tmpFile, "--", WARNING_FLAGS]); + expect(fixResult.exitCode).toBe(0); + + // Verify compilation after fix - should not have warnings or errors + const afterWarnings = getWarnings(tmpFile, mocPath, tmpDir); + expect(afterWarnings).toHaveLength(0); + }); + }, 60000); + } +}); diff --git a/cli/tests/fix/m0223.mo b/cli/tests/fix/m0223.mo new file mode 100644 index 00000000..45ba8212 --- /dev/null +++ b/cli/tests/fix/m0223.mo @@ -0,0 +1,8 @@ +module M { + public func inferred(x : T) : T = x; + + public func main() { + let n1 = inferred(1); // Redundant type instantiation + ignore n1; + }; +}; diff --git a/cli/tests/fix/m0236.mo b/cli/tests/fix/m0236.mo new file mode 100644 index 00000000..d58ca37f --- /dev/null +++ b/cli/tests/fix/m0236.mo @@ -0,0 +1,12 @@ +module Map { + public type Map = { map : [(K, [var V])] }; + public func empty() : Map = { map = [] }; + public func size(self : Map) : Nat { self.map.size() }; +}; + +module M { + public func main() { + let peopleMap = Map.empty(); + ignore Map.size(peopleMap); + }; +}; diff --git a/cli/tests/fix/m0237.mo b/cli/tests/fix/m0237.mo new file mode 100644 index 00000000..5a8a9245 --- /dev/null +++ b/cli/tests/fix/m0237.mo @@ -0,0 +1,34 @@ +type Order = { + #less; + #equal; + #greater; +}; + +module Nat { + public func compare(_ : Nat, _ : Nat) : Order { #equal }; +}; + +module Map { + public type Map = { map : [(K, [var V])] }; + public func empty() : Map = { map = [] }; + + public func get( + self : Map, + compare : (implicit : (K, K) -> Order), + n : K, + ) : ?V { + ignore (self, compare, n); + null; + }; +}; + +module M { + public func main() { + let peopleMap = Map.empty(); + ignore peopleMap.get(Nat.compare, 1); // Redundant explicit argument + ignore peopleMap.get( + Nat.compare, + 1, + ); // Redundant explicit argument + }; +}; diff --git a/mops.lock b/mops.lock index 1294c685..8cd87e38 100644 --- a/mops.lock +++ b/mops.lock @@ -1,6 +1,37 @@ { - "version": 2, + "version": 3, "mopsTomlDepsHash": "722dc0bee276245351407defa34eb1cd2f96334559d4736d892f8871144cfeb1", + "deps": { + "base": "0.14.14", + "time-consts": "1.0.1", + "map": "9.0.1", + "ic": "3.2.0", + "backup": "3.0.0", + "linked-list": "0.1.0", + "http-types": "1.0.1", + "motoko-datetime": "https://github.com/ByronBecker/motoko-datetime#v0.1.1@bda6139ec56d36731326727ae28510f1e1843f27", + "memory-region": "0.1.1", + "stableheapbtreemap": "1.3.0", + "matchers": "https://github.com/kritzcreek/motoko-matchers#v1.3.0", + "fuzz": "1.0.0", + "sha2": "0.1.0", + "vector": "0.4.0", + "datetime": "1.0.0", + "core": "0.6.0", + "xtended-text": "2.0.0", + "xtended-numbers": "2.0.0", + "buffer": "0.0.1", + "principal-ext": "0.1.0", + "telegram-bot": "0.1.1", + "serde": "3.3.2", + "itertools": "0.2.2", + "candid": "2.0.0", + "cbor": "4.0.0", + "byte-utils": "0.1.1", + "base@0.7.3": "0.7.3", + "test": "2.1.1", + "bench": "1.0.0" + }, "hashes": { "base@0.14.14": { "base@0.14.14/NOTICE": "3960a8d25fa5fc909325817b08b36c1146970930ca15b6352f8ea6db803cab47", diff --git a/mops.toml b/mops.toml index b9f5c1cb..e12ac8ee 100644 --- a/mops.toml +++ b/mops.toml @@ -17,6 +17,6 @@ fuzz = "1.0.0" bench = "1.0.0" [toolchain] -moc = "0.14.14" +moc = "1.0.0" wasmtime = "34.0.1" -# pocket-ic = "9.0.3" \ No newline at end of file +# pocket-ic = "9.0.3"