Skip to content

Commit 916ac48

Browse files
committed
[cli] fix check --fix crash on overlapping diagnostic edits
Replace `TextDocument.applyEdits()` (which throws on overlapping edits) with a custom `applyDiagnosticFixes()` that processes each diagnostic atomically — if any edit from a diagnostic overlaps with an already-accepted edit, the entire diagnostic is skipped and retried in the next iteration. Also ensures `--error-format=json` is passed last to moc so user-provided flags like `--error-format=human` don't break the fix pipeline. Made-with: Cursor
1 parent 6a8731a commit 916ac48

File tree

5 files changed

+168
-59
lines changed

5 files changed

+168
-59
lines changed

cli/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# Mops CLI Changelog
22

33
## Next
4+
- Fix `mops check --fix` crash on overlapping diagnostic edits (e.g., nested function calls)
45

56
## 2.1.0
67
- Add `mops check --fix` subcommand (for Motoko files) with autofix logic

cli/helpers/autofix-motoko.ts

Lines changed: 119 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,6 @@ import {
66
type TextEdit,
77
} from "vscode-languageserver-textdocument";
88

9-
interface Fix {
10-
file: string;
11-
code: string;
12-
edit: TextEdit;
13-
}
14-
159
interface MocSpan {
1610
file: string;
1711
line_start: number;
@@ -46,35 +40,127 @@ export function parseDiagnostics(stdout: string): MocDiagnostic[] {
4640
.filter((d) => d !== null);
4741
}
4842

49-
function extractFixes(diagnostics: MocDiagnostic[]): Fix[] {
50-
const fixes: Fix[] = [];
43+
interface DiagnosticFix {
44+
code: string;
45+
edits: TextEdit[];
46+
}
47+
48+
function extractDiagnosticFixes(
49+
diagnostics: MocDiagnostic[],
50+
): Map<string, DiagnosticFix[]> {
51+
const result = new Map<string, DiagnosticFix[]>();
52+
5153
for (const diag of diagnostics) {
54+
const editsByFile = new Map<string, TextEdit[]>();
55+
5256
for (const span of diag.spans) {
5357
if (
5458
span.suggestion_applicability === "MachineApplicable" &&
5559
span.suggested_replacement !== null
5660
) {
57-
fixes.push({
58-
file: span.file,
59-
code: diag.code,
60-
edit: {
61-
range: {
62-
start: {
63-
line: span.line_start - 1,
64-
character: span.column_start - 1,
65-
},
66-
end: {
67-
line: span.line_end - 1,
68-
character: span.column_end - 1,
69-
},
61+
const file = resolve(span.file);
62+
const edits = editsByFile.get(file) ?? [];
63+
edits.push({
64+
range: {
65+
start: {
66+
line: span.line_start - 1,
67+
character: span.column_start - 1,
68+
},
69+
end: {
70+
line: span.line_end - 1,
71+
character: span.column_end - 1,
7072
},
71-
newText: span.suggested_replacement,
7273
},
74+
newText: span.suggested_replacement,
7375
});
76+
editsByFile.set(file, edits);
7477
}
7578
}
79+
80+
for (const [file, edits] of editsByFile) {
81+
const existing = result.get(file) ?? [];
82+
existing.push({ code: diag.code, edits });
83+
result.set(file, existing);
84+
}
85+
}
86+
87+
return result;
88+
}
89+
90+
type Range = TextEdit["range"];
91+
92+
function normalizeRange(range: Range): Range {
93+
const { start, end } = range;
94+
if (
95+
start.line > end.line ||
96+
(start.line === end.line && start.character > end.character)
97+
) {
98+
return { start: end, end: start };
99+
}
100+
return range;
101+
}
102+
103+
interface OffsetEdit {
104+
start: number;
105+
end: number;
106+
newText: string;
107+
}
108+
109+
/**
110+
* Applies diagnostic fixes to a document, processing each diagnostic as
111+
* an atomic unit. If any edit from a diagnostic overlaps with an already-accepted
112+
* edit, the entire diagnostic is skipped (picked up in subsequent iterations).
113+
* Based on vscode-languageserver-textdocument's TextDocument.applyEdits.
114+
*/
115+
function applyDiagnosticFixes(
116+
doc: TextDocument,
117+
fixes: DiagnosticFix[],
118+
): { text: string; appliedCodes: string[] } {
119+
const acceptedEdits: OffsetEdit[] = [];
120+
const appliedCodes: string[] = [];
121+
122+
for (const fix of fixes) {
123+
const offsets: OffsetEdit[] = fix.edits.map((e) => {
124+
const range = normalizeRange(e.range);
125+
return {
126+
start: doc.offsetAt(range.start),
127+
end: doc.offsetAt(range.end),
128+
newText: e.newText,
129+
};
130+
});
131+
132+
const overlaps = offsets.some((o) =>
133+
acceptedEdits.some((a) => o.start < a.end && o.end > a.start),
134+
);
135+
if (overlaps) {
136+
continue;
137+
}
138+
139+
acceptedEdits.push(...offsets);
140+
appliedCodes.push(fix.code);
141+
}
142+
143+
acceptedEdits.sort((a, b) => a.start - b.start);
144+
145+
const text = doc.getText();
146+
const spans: string[] = [];
147+
let lastOffset = 0;
148+
149+
for (const edit of acceptedEdits) {
150+
if (edit.start < lastOffset) {
151+
continue;
152+
}
153+
if (edit.start > lastOffset) {
154+
spans.push(text.substring(lastOffset, edit.start));
155+
}
156+
if (edit.newText.length) {
157+
spans.push(edit.newText);
158+
}
159+
lastOffset = edit.end;
76160
}
77-
return fixes;
161+
162+
spans.push(text.substring(lastOffset));
163+
return { text: spans.join(""), appliedCodes };
78164
}
79165

80166
const MAX_FIX_ITERATIONS = 10;
@@ -93,47 +179,33 @@ export async function autofixMotoko(
93179
const fixedFilesCodes = new Map<string, string[]>();
94180

95181
for (let iteration = 0; iteration < MAX_FIX_ITERATIONS; iteration++) {
96-
const allFixes: Fix[] = [];
182+
const fixesByFile = new Map<string, DiagnosticFix[]>();
97183

98184
for (const file of files) {
99185
const result = await execa(
100186
mocPath,
101-
[file, "--error-format=json", ...mocArgs],
187+
[file, ...mocArgs, "--error-format=json"],
102188
{ stdio: "pipe", reject: false },
103189
);
104190

105191
const diagnostics = parseDiagnostics(result.stdout);
106-
allFixes.push(...extractFixes(diagnostics));
192+
for (const [targetFile, fixes] of extractDiagnosticFixes(diagnostics)) {
193+
const existing = fixesByFile.get(targetFile) ?? [];
194+
existing.push(...fixes);
195+
fixesByFile.set(targetFile, existing);
196+
}
107197
}
108198

109-
if (allFixes.length === 0) {
199+
if (fixesByFile.size === 0) {
110200
break;
111201
}
112202

113-
const fixesByFile = new Map<string, Fix[]>();
114-
for (const fix of allFixes) {
115-
const normalizedPath = resolve(fix.file);
116-
const existing = fixesByFile.get(normalizedPath) ?? [];
117-
existing.push(fix);
118-
fixesByFile.set(normalizedPath, existing);
119-
}
120-
121203
let progress = false;
122204

123205
for (const [file, fixes] of fixesByFile) {
124206
const original = await readFile(file, "utf-8");
125207
const doc = TextDocument.create(`file://${file}`, "motoko", 0, original);
126-
127-
let result: string;
128-
try {
129-
result = TextDocument.applyEdits(
130-
doc,
131-
fixes.map((f) => f.edit),
132-
);
133-
} catch (err) {
134-
console.warn(`Warning: could not apply fixes to ${file}: ${err}`);
135-
continue;
136-
}
208+
const { text: result, appliedCodes } = applyDiagnosticFixes(doc, fixes);
137209

138210
if (result === original) {
139211
continue;
@@ -143,9 +215,7 @@ export async function autofixMotoko(
143215
progress = true;
144216

145217
const existing = fixedFilesCodes.get(file) ?? [];
146-
for (const fix of fixes) {
147-
existing.push(fix.code);
148-
}
218+
existing.push(...appliedCodes);
149219
fixedFilesCodes.set(file, existing);
150220
}
151221

cli/tests/__snapshots__/check-fix.test.ts.snap

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,9 @@ persistent actor {
3737
`;
3838

3939
exports[`check --fix M0236: fix output 1`] = `
40-
"Fixed run/M0236.mo (2 fixes: M0236)
40+
"Fixed run/M0236.mo (1 fix: M0236)
4141
42-
2 fixes applied to 1 file"
42+
1 fix applied to 1 file"
4343
`;
4444

4545
exports[`check --fix M0237 1`] = `
@@ -64,7 +64,8 @@ exports[`check --fix M0237: fix output 1`] = `
6464
`;
6565

6666
exports[`check --fix edit-suggestions 1`] = `
67-
"import Map "mo:core/Map";
67+
"import Array "mo:core/Array";
68+
import Map "mo:core/Map";
6869
import Nat "mo:core/Nat";
6970
import Text "mo:core/Text";
7071
import { type Order } "mo:core/Order";
@@ -173,7 +174,7 @@ do {
173174
ignore data.put(1, "a");
174175
175176
// implicit at the end -> M0236 + M0237
176-
ignore data.find(1, );
177+
ignore Impl.find(data, 1, );
177178
ignore data.sort1(); // -> M0236 + M0237
178179
ignore Impl.sort2(data, ); // no dot suggestion (notSelf), M0237 only
179180
@@ -199,20 +200,28 @@ do {
199200
"John",
200201
); // warn M0223 + M0236 + M0237
201202
};
203+
204+
// Overlapping fixable errors
205+
do {
206+
let ar = [1, 2, 3];
207+
let _ = ar.filter(func(x) { x > 0 }).filter(
208+
func(x) { x > 0 },
209+
);
210+
};
202211
"
203212
`;
204213

205214
exports[`check --fix edit-suggestions: fix output 1`] = `
206-
"Fixed run/edit-suggestions.mo (41 fixes: M0223, M0236, M0237)
215+
"Fixed run/edit-suggestions.mo (33 fixes: M0223, M0236, M0237)
207216
208-
41 fixes applied to 1 file"
217+
33 fixes applied to 1 file"
209218
`;
210219

211220
exports[`check --fix transitive imports: fix output 1`] = `
212-
"Fixed run/transitive-lib.mo (2 fixes: M0236)
221+
"Fixed run/transitive-lib.mo (1 fix: M0236)
213222
Fixed run/transitive-main.mo (1 fix: M0223)
214223
215-
3 fixes applied to 2 files"
224+
2 fixes applied to 2 files"
216225
`;
217226

218227
exports[`check --fix transitive imports: lib file 1`] = `

cli/tests/check-fix.test.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,8 @@ describe("check --fix", () => {
7474

7575
test("edit-suggestions", async () => {
7676
await testCheckFix("edit-suggestions.mo", {
77-
M0223: 2,
78-
M0236: 11,
77+
M0223: 3,
78+
M0236: 12,
7979
M0237: 17,
8080
});
8181
});
@@ -100,6 +100,25 @@ describe("check --fix", () => {
100100
expect(countCodes(afterResult.stdout)).toEqual({});
101101
});
102102

103+
test("--error-format=human does not break --fix", async () => {
104+
const runFilePath = copyFixture("M0223.mo");
105+
106+
const fixResult = await cli(
107+
[
108+
"check",
109+
runFilePath,
110+
"--fix",
111+
"--",
112+
warningFlags,
113+
"--error-format=human",
114+
],
115+
{ cwd: fixDir },
116+
);
117+
118+
expect(fixResult.stdout).toContain("1 fix applied");
119+
expect(readFileSync(runFilePath, "utf-8")).not.toContain("<Nat>");
120+
});
121+
103122
test("verbose", async () => {
104123
const result = await cli(["check", "Ok.mo", "--fix", "--verbose"], {
105124
cwd: fixDir,

cli/tests/check/fix/edit-suggestions.mo

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import Array "mo:core/Array";
12
import Map "mo:core/Map";
23
import Nat "mo:core/Nat";
34
import Text "mo:core/Text";
@@ -141,3 +142,12 @@ do {
141142
"John",
142143
); // warn M0223 + M0236 + M0237
143144
};
145+
146+
// Overlapping fixable errors
147+
do {
148+
let ar = [1, 2, 3];
149+
let _ = Array.filter<Nat>(
150+
Array.filter<Nat>(ar, func(x) { x > 0 }),
151+
func(x) { x > 0 },
152+
);
153+
};

0 commit comments

Comments
 (0)