Skip to content

Commit 9764332

Browse files
authored
Merge pull request #287 from ZenVoich/mops-fmt
[cli] Add `mops format` command
2 parents 4b0ef29 + a2321cd commit 9764332

11 files changed

Lines changed: 573 additions & 43 deletions

File tree

.github/workflows/cli-bundle.yml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ jobs:
1111
build:
1212
strategy:
1313
matrix:
14-
node-version: [20, 22]
14+
node-version: [18, 20, 24]
1515
os: [ubuntu-latest]
1616

1717
runs-on: ${{ matrix.os }}
@@ -40,4 +40,6 @@ jobs:
4040
run: npm i -g ./cli/bundle/cli.tgz
4141

4242
- name: Check global bundle
43-
run: mops -v
43+
run: mops -v
44+
45+
- run: mops format

cli/bundle-package-json.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ packageJson.dependencies = {
1212
'dhall-to-json-cli': packageJson.dependencies['dhall-to-json-cli'],
1313
'decomp-tarxz': packageJson.dependencies['decomp-tarxz'],
1414
'buffer': packageJson.dependencies['buffer'],
15+
'prettier-plugin-motoko': packageJson.dependencies['prettier-plugin-motoko'],
1516
};
1617

1718
writeFileSync('./bundle/package.json', JSON.stringify(packageJson, null, ' '));

cli/cli.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {resolvePackages} from './resolve-packages.js';
2929
import {watch} from './commands/watch/watch.js';
3030
import {addOwner, printOwners, removeOwner} from './commands/owner.js';
3131
import {addMaintainer, printMaintainers, removeMaintainer} from './commands/maintainer.js';
32+
import {format} from './commands/format.js';
3233

3334
declare global {
3435
// eslint-disable-next-line no-var
@@ -470,6 +471,7 @@ program
470471
.description('Watch *.mo files and check for syntax errors, warnings, run tests, generate declarations and deploy canisters')
471472
.option('-e, --error', 'Check Motoko canisters or *.mo files for syntax errors')
472473
.option('-w, --warning', 'Check Motoko canisters or *.mo files for warnings')
474+
.option('-f, --format', 'Format Motoko code')
473475
.option('-t, --test', 'Run tests')
474476
.option('-g, --generate', 'Generate declarations for Motoko canisters')
475477
.option('-d, --deploy', 'Deploy Motoko canisters')
@@ -478,4 +480,18 @@ program
478480
await watch(options);
479481
});
480482

483+
// format
484+
program
485+
.command('format [filter]')
486+
.alias('fmt')
487+
.description('Format Motoko code')
488+
.addOption(new Option('--check', 'Check code formatting (do not change source files)'))
489+
.action(async (filter, options) => {
490+
checkConfigFile(true);
491+
let {ok} = await format(filter, options);
492+
if (!ok) {
493+
process.exit(1);
494+
}
495+
});
496+
481497
program.parse();

cli/commands/format.ts

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import fs from 'node:fs/promises';
2+
import path from 'node:path';
3+
import {globSync} from 'glob';
4+
import chalk from 'chalk';
5+
import * as prettier from 'prettier';
6+
import motokoPlugin from 'prettier-plugin-motoko';
7+
8+
import {getRootDir} from '../mops.js';
9+
import {absToRel} from './test/utils.js';
10+
import {parallel} from '../parallel.js';
11+
12+
let ignore = [
13+
'**/node_modules/**',
14+
'**/.mops/**',
15+
'**/.vessel/**',
16+
'**/.git/**',
17+
'**/dist/**',
18+
];
19+
20+
let globConfig = {
21+
nocase: true,
22+
ignore: ignore,
23+
};
24+
25+
type FormatOptions = {
26+
check : boolean,
27+
silent : boolean,
28+
};
29+
30+
export type FormatResult = {
31+
ok : boolean,
32+
total : number,
33+
checked : number,
34+
valid : number,
35+
invalid : number,
36+
formatted : number,
37+
};
38+
39+
export async function format(filter : string, options : Partial<FormatOptions> = {}, signal ?: AbortSignal, onProgress ?: (result : FormatResult) => void) : Promise<FormatResult> {
40+
let startTime = Date.now();
41+
42+
let rootDir = getRootDir();
43+
let globStr = '**/*.mo';
44+
if (filter) {
45+
globStr = `**/*${filter}*.mo`;
46+
}
47+
48+
let files = globSync(path.join(rootDir, globStr), {
49+
...globConfig,
50+
cwd: rootDir,
51+
});
52+
let invalidFiles = 0;
53+
let checkedFiles = 0;
54+
55+
let getResult = (ok : boolean) => {
56+
let result : FormatResult = {
57+
ok,
58+
total: files.length,
59+
checked: checkedFiles,
60+
valid: files.length - invalidFiles,
61+
invalid: invalidFiles,
62+
formatted: invalidFiles,
63+
};
64+
onProgress?.(result);
65+
return result;
66+
};
67+
68+
if (!files.length) {
69+
if (filter) {
70+
options.silent || console.log(`No files found for filter '${filter}'`);
71+
return getResult(false);
72+
}
73+
if (!options.silent) {
74+
console.log('No *.mo files found');
75+
}
76+
return getResult(false);
77+
}
78+
79+
if (signal?.aborted) {
80+
return getResult(false);
81+
}
82+
83+
// get prettier config from .prettierrc
84+
let prettierConfigFile = await prettier.resolveConfigFile();
85+
86+
await parallel(4, files, async (file) => {
87+
if (signal?.aborted) {
88+
return;
89+
}
90+
91+
let conf = await prettier.resolveConfig(file, {editorconfig: true});
92+
let prettierConfig : prettier.Options = {};
93+
if (prettierConfigFile) {
94+
if (conf) {
95+
prettierConfig = conf;
96+
}
97+
}
98+
99+
// merge config from mops.toml [format]
100+
// disabled, because we lose vscode extension support
101+
// if (config.format) {
102+
// Object.assign(prettierConfig, config.format);
103+
// }
104+
105+
// add motoko parser plugin
106+
Object.assign(prettierConfig, {
107+
parser: 'motoko-tt-parse',
108+
plugins: [motokoPlugin],
109+
filepath: file,
110+
});
111+
112+
// check file
113+
let code = await fs.readFile(file, 'utf8');
114+
let formatted = await prettier.format(code, prettierConfig);
115+
let ok = formatted === code;
116+
invalidFiles += Number(!ok);
117+
118+
if (options.check) {
119+
if (ok) {
120+
options.silent || console.log(`${chalk.green('✓')} ${absToRel(file)} ${chalk.gray('valid')}`);
121+
}
122+
else {
123+
options.silent || console.log(`${chalk.red('✖')} ${absToRel(file)} ${chalk.gray('invalid')}`);
124+
}
125+
}
126+
else {
127+
if (ok) {
128+
options.silent || console.log(`${chalk.green('✓')} ${absToRel(file)} ${chalk.gray('valid')}`);
129+
}
130+
else {
131+
await fs.writeFile(file, formatted);
132+
options.silent || console.log(`${chalk.yellow('*')} ${absToRel(file)} ${chalk.gray('formatted')}`);
133+
}
134+
}
135+
136+
checkedFiles += 1;
137+
138+
// trigger onProgress
139+
getResult(false);
140+
});
141+
142+
if (signal?.aborted) {
143+
return getResult(false);
144+
}
145+
146+
if (!options.silent) {
147+
console.log('-'.repeat(50));
148+
149+
let plural = (n : number) => n === 1 ? '' : 's';
150+
let str = `Checked ${chalk.gray(files.length)} file${plural(files.length)} in ${chalk.gray(((Date.now() - startTime) / 1000).toFixed(2) + 's')}`;
151+
if (invalidFiles) {
152+
str += options.check
153+
? `, invalid ${chalk.redBright(invalidFiles)} file${plural(invalidFiles)}`
154+
: `, formatted ${chalk.yellowBright(invalidFiles)} file${plural(invalidFiles)}`;
155+
}
156+
console.log(str);
157+
158+
if (!invalidFiles) {
159+
console.log(chalk.green('✓ All files have valid formatting'));
160+
}
161+
}
162+
163+
if (options.check && invalidFiles && !options.silent) {
164+
console.log(`${(`Run '${chalk.yellow('mops format' + (filter ? ` ${filter}` : ''))}' to format your code`)}`);
165+
return getResult(false);
166+
}
167+
168+
return getResult(true);
169+
}

cli/commands/watch/formatter.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import chalk from 'chalk';
2+
import {format, FormatResult} from '../format.js';
3+
import {ErrorChecker} from './error-checker.js';
4+
5+
export class Formatter {
6+
verbose = false;
7+
status : 'pending' | 'running' | 'syntax-error' | 'error' | 'success' = 'pending';
8+
errorChecker : ErrorChecker;
9+
aborted = false;
10+
controller = new AbortController();
11+
currentRun : Promise<any> | undefined;
12+
result : FormatResult | undefined;
13+
14+
constructor({verbose, errorChecker} : {verbose : boolean, errorChecker : ErrorChecker}) {
15+
this.verbose = verbose;
16+
this.errorChecker = errorChecker;
17+
}
18+
19+
reset() {
20+
this.status = 'pending';
21+
this.result = undefined;
22+
}
23+
24+
async abortCurrent() {
25+
this.aborted = true;
26+
await this.currentRun;
27+
this.reset();
28+
this.aborted = false;
29+
}
30+
31+
async run(onProgress : () => void) {
32+
await this.abortCurrent();
33+
34+
if (this.errorChecker.status === 'error') {
35+
this.status = 'syntax-error';
36+
onProgress();
37+
return;
38+
}
39+
40+
this.status = 'running';
41+
onProgress();
42+
43+
this.controller = new AbortController();
44+
45+
this.currentRun = format('', {silent: true}, this.controller.signal, (result) => {
46+
this.result = result;
47+
onProgress();
48+
});
49+
await this.currentRun;
50+
51+
if (!this.aborted) {
52+
this.status = 'success';
53+
}
54+
onProgress();
55+
}
56+
57+
getOutput() : string {
58+
if (this.status === 'pending') {
59+
return `Format: ${chalk.gray('(pending)')}`;
60+
}
61+
if (this.status === 'syntax-error') {
62+
return `Format: ${chalk.gray('(errors)')}`;
63+
}
64+
65+
if (!this.result) {
66+
return `Format: ${chalk.gray('(pending)')}`;
67+
}
68+
69+
if (this.status === 'running') {
70+
return `Format: ${this.result.checked}/${this.result.total} ${chalk.gray('(running)')}`;
71+
}
72+
return `Format: ${chalk.greenBright(`${this.result.formatted}`)}`;
73+
}
74+
}

0 commit comments

Comments
 (0)