Skip to content

Commit 9f012d6

Browse files
committed
feat(source): the unbalanced-format error offers to insert the missing ^XA or ^XZ
1 parent db3e0b6 commit 9f012d6

7 files changed

Lines changed: 147 additions & 33 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ The **ZPL** panel highlights syntax and collapses long blocks of image data. Sel
9797

9898
The **ZPL reference** beside the code follows the cursor. Search to filter the list, or click a command to pin its details. A double-click or the right-click menu inserts the command at the cursor. The chevron in the heading folds the list.
9999

100-
While you edit, the canvas shows a live preview. Canvas editing and the **Properties** tab are disabled. Page switching stays enabled. The editor refuses source that is too large or has mismatched `^XA`/`^XZ` commands, and marks the mismatches.
100+
While you edit, the canvas shows a live preview. Canvas editing and the **Properties** tab are disabled. Page switching stays enabled. The editor refuses source that is too large or has mismatched `^XA`/`^XZ` commands, and marks the mismatches. Click a mark to insert the missing command.
101101

102102
- **Apply** or leaving the panel commits your changes.
103103
- `Esc` or **Cancel** discards them.

‎src/components/Output/ZplCodeMirror.test.tsx‎

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -368,7 +368,7 @@ describe("ZplCodeMirror diagnostics", () => {
368368
value={value}
369369
onChange={onChange}
370370
ariaLabel="zpl" placeholderText="ph"
371-
diagnostics={[{ from, to: from + dropped.length, severity: "warning", message: "dropped", fix: { command: "^FS", label: "Insert ^FS" } }]}
371+
diagnostics={[{ from, to: from + dropped.length, severity: "warning", message: "dropped", fix: { command: "^FS", label: "Insert ^FS", place: "after" } }]}
372372
/>,
373373
);
374374
const view = EditorView.findFromDOM(container as HTMLElement)!;
@@ -380,6 +380,25 @@ describe("ZplCodeMirror diagnostics", () => {
380380
expect(onChange).toHaveBeenCalledWith(expect.stringContaining(`${dropped}#FS#FO10,60`));
381381
});
382382

383+
it("closes an open format before the opener that interrupts it, spelled with the prefix in force", async () => {
384+
const { forEachDiagnostic } = await import("@codemirror/lint");
385+
const value = "^CC#\n#XA#FO10,10#FDa#FS\n#XA#FDb#FS#XZ";
386+
const from = value.indexOf("#XA");
387+
const { container } = render(
388+
<ZplCodeMirror
389+
value={value}
390+
onChange={vi.fn()}
391+
ariaLabel="zpl" placeholderText="ph"
392+
diagnostics={[{ from, to: from + 3, severity: "error", message: "open", fix: { command: "^XZ", label: "Insert ^XZ", place: "closeFormat" } }]}
393+
/>,
394+
);
395+
const view = EditorView.findFromDOM(container as HTMLElement)!;
396+
const seen: { d: Diagnostic; from: number; to: number }[] = [];
397+
forEachDiagnostic(view.state, (d, start, end) => seen.push({ d, from: start, to: end }));
398+
act(() => seen[0]?.d.actions?.[0]?.apply(view, seen[0].from, seen[0].to));
399+
expect(view.state.doc.toString()).toBe("^CC#\n#XA#FO10,10#FDa#FS\n#XZ\n#XA#FDb#FS#XZ");
400+
});
401+
383402
it("keeps the field's trailing data when the lint's end was trimmed", async () => {
384403
const { forEachDiagnostic } = await import("@codemirror/lint");
385404
// The parser trims the span for the underline, but "ab " is the data.
@@ -390,7 +409,7 @@ describe("ZplCodeMirror diagnostics", () => {
390409
value={value}
391410
onChange={vi.fn()}
392411
ariaLabel="zpl" placeholderText="ph"
393-
diagnostics={[{ from: 3, to, severity: "warning", message: "dropped", fix: { command: "^FS", label: "Insert ^FS" } }]}
412+
diagnostics={[{ from: 3, to, severity: "warning", message: "dropped", fix: { command: "^FS", label: "Insert ^FS", place: "after" } }]}
394413
/>,
395414
);
396415
const view = EditorView.findFromDOM(container as HTMLElement)!;
@@ -409,7 +428,7 @@ describe("ZplCodeMirror diagnostics", () => {
409428
value={value}
410429
onChange={onChange}
411430
ariaLabel="zpl" placeholderText="ph" readOnly
412-
diagnostics={[{ from: 3, to: 12, severity: "warning", message: "dropped", fix: { command: "^FS", label: "Insert ^FS" } }]}
431+
diagnostics={[{ from: 3, to: 12, severity: "warning", message: "dropped", fix: { command: "^FS", label: "Insert ^FS", place: "after" } }]}
413432
/>,
414433
);
415434
const view = EditorView.findFromDOM(container as HTMLElement)!;

‎src/lib/sourceDiagnostics.test.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,19 @@ import { fallbackTranslations as en } from "../locales";
66
import type { ImportFinding } from "@zplab/core/lib/importReport";
77

88
describe("buildSourceDiagnostics", () => {
9-
it("names each imbalance kind with the bytes it found", () => {
9+
it("names each imbalance kind with the bytes it found and offers the missing command", () => {
1010
const of = (ub: { kind: "strayXz" | "unclosedXa"; at: number; cmd: string }) =>
1111
buildSourceDiagnostics({ reason: "unbalanced", unbalanced: ub }, [], en);
1212
// The generic banner told the user to ADD what the diagnostic points at.
1313
expect(of({ kind: "strayXz", at: 0, cmd: "#XZ" })).toEqual([{
1414
from: 0, to: 3, severity: "error",
1515
message: formatTemplate(en.output.lintStrayXzFmt, { cmd: "#XZ" }),
16+
fix: { command: "^XA", label: formatTemplate(en.output.lintInsertCmdFmt, { cmd: "^XA" }), place: "openFormat" },
1617
}]);
1718
expect(of({ kind: "unclosedXa", at: 4, cmd: "#XA" })[0]?.message)
1819
.toBe(formatTemplate(en.output.lintUnclosedXaFmt, { cmd: "#XA" }));
20+
expect(of({ kind: "unclosedXa", at: 4, cmd: "#XA" })[0]?.fix)
21+
.toEqual({ command: "^XZ", label: formatTemplate(en.output.lintInsertCmdFmt, { cmd: "^XZ" }), place: "closeFormat" });
1922
});
2023

2124
it("adds the repair site as a weaker second mark", () => {
@@ -25,6 +28,7 @@ describe("buildSourceDiagnostics", () => {
2528
}, [], en);
2629
expect(lints.map((l) => [l.from, l.severity])).toEqual([[0, "error"], [40, "related"]]);
2730
expect(lints[1]?.message).toBe(formatTemplate(en.output.lintStillOpenHereFmt, { cmd: "^XA" }));
31+
expect(lints.map((l) => l.fix?.place)).toEqual(["closeFormat", "before"]);
2832
});
2933

3034
it("stays silent for a refusal without a located command", () => {
@@ -65,7 +69,7 @@ describe("buildSourceDiagnostics", () => {
6569
const [a, b] = buildSourceDiagnostics(null, [dropped, other], en);
6670
expect(a?.to).toBe(29);
6771
expect(a?.message).toContain(en.importReport.unterminatedFieldTitleFmt.replace("{fs}", "^FS"));
68-
expect(a?.fix).toEqual({ command: "^FS", label: formatTemplate(en.output.lintInsertCmdFmt, { cmd: "^FS" }) });
72+
expect(a?.fix).toEqual({ command: "^FS", label: formatTemplate(en.output.lintInsertCmdFmt, { cmd: "^FS" }), place: "after" });
6973
expect(b?.fix).toBeUndefined();
7074
});
7175

‎src/lib/sourceDiagnostics.ts‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,14 @@ import { describeFinding } from './importReport';
55
import { formatTemplate } from './formatTemplate';
66
import type { Translations } from '../locales';
77

8-
/** One-click repair: the command the editor inserts at the first command boundary at
9-
* or after the lint, spelled with the prefix in force there; the label stays canonical,
10-
* as in the catalog. The imbalance kinds get none, as no single byte is the right spot. */
8+
/** Where a repair lands: after its lint, before it, where the open format must close, or where the orphan run must open. */
9+
export type FixPlace = 'after' | 'before' | 'closeFormat' | 'openFormat';
10+
11+
/** One-click repair whose label stays canonical, as in the catalog, while the editor re-spells `command` where it inserts it. */
1112
export interface SourceFix {
1213
command: string;
1314
label: string;
15+
place: FixPlace;
1416
}
1517

1618
/** Editor-framework-neutral lint in STRING offsets of its build's text. */
@@ -45,11 +47,14 @@ export function buildSourceDiagnostics(
4547
const lints: SourceLint[] = [];
4648
if (refusal?.reason === 'unbalanced') {
4749
const ub: UnbalancedFormat = refusal.unbalanced;
50+
const missing = ub.kind === 'strayXz' ? '^XA' : '^XZ';
51+
const label = formatTemplate(t.output.lintInsertCmdFmt, { cmd: missing });
4852
lints.push({
4953
from: ub.at,
5054
to: ub.at + ub.cmd.length,
5155
severity: 'error',
5256
message: formatTemplate(t.output[KIND_MSG[ub.kind]], { cmd: ub.cmd }),
57+
fix: { command: missing, label, place: ub.kind === 'strayXz' ? 'openFormat' : 'closeFormat' },
5358
});
5459
// The defect is the unterminated format, but the repair goes here, which in
5560
// a multi-label document is a whole label away from the opener.
@@ -59,6 +64,7 @@ export function buildSourceDiagnostics(
5964
to: ub.related.at + ub.related.cmd.length,
6065
severity: 'related',
6166
message: formatTemplate(t.output.lintStillOpenHereFmt, { cmd: ub.cmd }),
67+
fix: { command: missing, label, place: 'before' },
6268
});
6369
}
6470
}
@@ -74,7 +80,7 @@ export function buildSourceDiagnostics(
7480
};
7581
// The field ends where the next command begins, which is where ^FS belongs.
7682
if (f.kind === 'unterminatedField') {
77-
lint.fix = { command: '^FS', label: formatTemplate(t.output.lintInsertCmdFmt, { cmd: '^FS' }) };
83+
lint.fix = { command: '^FS', label: formatTemplate(t.output.lintInsertCmdFmt, { cmd: '^FS' }), place: 'after' };
7884
}
7985
warnings.push(lint);
8086
}

‎src/lib/zplCmLint.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,10 @@ export function toDiagnostic(d: SourceLint, docPos: (offset: number) => number):
2222
? [
2323
{
2424
name: fix.label,
25-
// `to` arrives live: CM has mapped the range through edits made since the build.
26-
apply: (view: EditorView, _from: number, to: number) => {
25+
// CM has mapped this range through the edits made since the build.
26+
apply: (view: EditorView, from: number, to: number) => {
2727
if (view.state.readOnly) return;
28-
view.dispatch(fixInsertion(view.state, fix.command, to));
28+
view.dispatch(fixInsertion(view.state, fix.command, fix.place, from, to));
2929
},
3030
},
3131
]

‎src/lib/zplLanguage.test.ts‎

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { describe, it, expect } from "vitest";
33
import { EditorState, type TransactionSpec } from "@codemirror/state";
44
import { ensureSyntaxTree, foldable, syntaxTree } from "@codemirror/language";
55
import { highlightCode } from "@lezer/highlight";
6+
import type { FixPlace } from "./sourceDiagnostics";
67
import {
78
buildZplTree,
89
commandAtCursor,
@@ -296,15 +297,79 @@ describe("placesCaret", () => {
296297
});
297298
});
298299

300+
const NL = String.fromCharCode(10);
301+
const CRLF = String.fromCharCode(13) + NL;
302+
299303
describe("fixInsertion", () => {
300304
it("lands before the next command as the tokenizer sees it, not before a payload tilde", () => {
301305
// ^BX escapes like ~1 are data; only a tilde that opens a command ends the field.
302306
const doc = "^XA^FO1,1^BXN,5,200,,,,~^FDab~1x^FO2,2^FDb^FS^XZ";
303307
const state = EditorState.create({ doc, extensions: [zpl()] });
304308
const at = doc.indexOf("~1x");
305-
const spec = fixInsertion(state, "^FS", at);
309+
const spec = fixInsertion(state, "^FS", "after", at, at);
306310
expect(state.update(spec).state.doc.toString()).toBe("^XA^FO1,1^BXN,5,200,,,,~^FDab~1x^FS^FO2,2^FDb^FS^XZ");
307311
});
312+
313+
const fixed = (doc: string, command: string, place: FixPlace, from: number, to: number) => {
314+
const state = EditorState.create({ doc, extensions: [zpl()] });
315+
return state.update(fixInsertion(state, command, place, from, to)).state.doc.toString();
316+
};
317+
318+
it("closes an open format on its own line before the opener that interrupts it", () => {
319+
expect(fixed("^XA^FO1,1^FDa^FS^XA^FDb^FS^XZ", "^XZ", "closeFormat", 0, 3)).toBe("^XA^FO1,1^FDa^FS\n^XZ\n^XA^FDb^FS^XZ");
320+
});
321+
322+
it("closes a format with no later opener at the end of the document", () => {
323+
expect(fixed("^XA^FO1,1^FDa^FS", "^XZ", "closeFormat", 0, 3)).toBe("^XA^FO1,1^FDa^FS\n^XZ");
324+
});
325+
326+
it("inserts the closer in front of the command the repair points at", () => {
327+
const doc = "^XA^FO1,1^FDa^FS\n^XA^FDb^FS^XZ";
328+
const at = doc.lastIndexOf("^XA");
329+
expect(fixed(doc, "^XZ", "before", at, at + 3)).toBe("^XA^FO1,1^FDa^FS\n^XZ\n^XA^FDb^FS^XZ");
330+
});
331+
332+
it("opens an orphan run after the previous closer", () => {
333+
const doc = "^XA^FDa^FS^XZ^FO1,1^FDb^FS^XZ";
334+
const at = doc.lastIndexOf("^XZ");
335+
expect(fixed(doc, "^XA", "openFormat", at, at + 3)).toBe("^XA^FDa^FS^XZ\n^XA\n^FO1,1^FDb^FS^XZ");
336+
});
337+
338+
it("opens an orphan run at the start of the stream", () => {
339+
const doc = "^FO1,1^FDb^FS^XZ";
340+
const at = doc.indexOf("^XZ");
341+
expect(fixed(doc, "^XA", "openFormat", at, at + 3)).toBe("^XA\n^FO1,1^FDb^FS^XZ");
342+
});
343+
344+
it("places the caret after the inserted closer under CRLF too", () => {
345+
const doc = "^XA^FO1,1^FDa^FS" + CRLF + "^XA^FDb^FS^XZ";
346+
const state = EditorState.create({ doc, extensions: [zpl(), EditorState.lineSeparator.of(CRLF)] });
347+
const next = state.update(fixInsertion(state, "^XZ", "closeFormat", 0, 3)).state;
348+
expect(next.doc.toString()).toBe("^XA^FO1,1^FDa^FS" + NL + "^XZ" + NL + "^XA^FDb^FS^XZ");
349+
expect(next.doc.sliceString(next.selection.main.head - 3, next.selection.main.head)).toBe("^XZ");
350+
const end = EditorState.create({ doc: "^XA^FO1,1^FDa^FS", extensions: [zpl(), EditorState.lineSeparator.of(CRLF)] });
351+
expect(() => end.update(fixInsertion(end, "^XZ", "closeFormat", 0, 3))).not.toThrow();
352+
});
353+
354+
it("still inserts when a byte payload ends in the same letters", () => {
355+
const doc = "^XA^FO1,1^FDa^FS~DYE:X.TTF,B,T,5,,ab^XZ^XA^FDb^FS^XZ";
356+
expect(fixed(doc, "^XZ", "closeFormat", 0, 3)).toContain("ab^XZ" + NL + "^XZ" + NL + "^XA^FDb");
357+
});
358+
359+
it("inserts nothing when the command already sits at the spot", () => {
360+
const doc = "^XA^FO1,1^FDa^FS\n^XZ\n^XA^FDb^FS^XZ";
361+
expect(fixed(doc, "^XZ", "closeFormat", 0, 3)).toBe(doc);
362+
const fs = "^XA^FO1,1^FDa^FS^XZ";
363+
expect(fixed(fs, "^FS", "after", 9, 13)).toBe(fs);
364+
});
365+
366+
it("finds the interrupting opener beyond the reach of the live tree", () => {
367+
const filler = Array.from({ length: 4000 }, (_, i) => `^FXfiller ${i} ................^FS`).join(NL);
368+
const doc = `^XA^FO1,1^FDa^FS${NL}${filler}${NL}^XA^FDb^FS^XZ`;
369+
const out = fixed(doc, "^XZ", "closeFormat", 0, 3);
370+
expect(out.endsWith(`^XZ${NL}^XA^FDb^FS^XZ`)).toBe(true);
371+
expect(out.indexOf("^XZ")).toBeGreaterThan(doc.length - 40);
372+
});
308373
});
309374

310375
describe("zpl highlight style", () => {

‎src/lib/zplLanguage.ts‎

Lines changed: 39 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
import type { EditorState, Transaction, TransactionSpec } from "@codemirror/state";
1717
import { applyPrefixRemap, PAYLOAD_CMDS, tokenize, type TokenizerChars } from "@zplab/core/lib/zplParser/helpers";
1818
import { commandBoundaryAt, prefixCharsAt, spellPrefix } from "@zplab/core/lib/zplCanonicalPrefixes";
19+
import type { FixPlace } from "./sourceDiagnostics";
1920

2021
// A ~DY/~DG payload can be megabytes on one line; folding keeps the editor
2122
// responsive, and the read-only preview truncates at the same length.
@@ -306,11 +307,9 @@ export interface CommandOccurrence {
306307
to: number;
307308
}
308309

309-
/** Command names touching [from, to] on the tree parsed so far: a range past the parse
310-
* budget yields nothing now, and the parser's next step revisits it. */
311-
export function commandOccurrences(state: EditorState, from: number, to: number): CommandOccurrence[] {
310+
function namesIn(state: EditorState, tree: Tree, from: number, to: number): CommandOccurrence[] {
312311
const out: CommandOccurrence[] = [];
313-
syntaxTree(state).iterate({
312+
tree.iterate({
314313
from,
315314
to,
316315
enter: (n) => {
@@ -322,20 +321,23 @@ export function commandOccurrences(state: EditorState, from: number, to: number)
322321
return out;
323322
}
324323

324+
/** The live tree may end before the partner command, so the whole document is parsed. */
325+
const wholeDocNames = (state: EditorState) => namesIn(state, treeUpTo(state, state.doc.length, WHOLE_DOC_PARSE_MS), 0, state.doc.length);
326+
327+
const structuralAt = (state: EditorState, id: "^XA" | "^XZ") => wholeDocNames(state).filter((c) => c.id === id);
328+
329+
/** Command names touching [from, to] on the tree parsed so far: a range past the parse
330+
* budget yields nothing now, and the parser's next step revisits it. */
331+
export function commandOccurrences(state: EditorState, from: number, to: number): CommandOccurrence[] {
332+
return namesIn(state, syntaxTree(state), from, to);
333+
}
334+
325335
/** Positional: the page-th ^XZ of the live document (the export emits one block per page),
326336
* else the last ^XZ, else null; goes stale if the user removes earlier blocks mid-session.
327337
* Read from the tree, so a ^CC remap still finds it. */
328338
export function formatCloseFor(state: EditorState, page: number): number | null {
329-
const tree = treeUpTo(state, state.doc.length, WHOLE_DOC_PARSE_MS);
330-
const closes: number[] = [];
331-
tree.iterate({
332-
enter: (n) => {
333-
// Only the caret form closes a format; the core parser reports ~XZ as unknown.
334-
if (!NAME_NODES.has(n.name) || readCommandName(state, n).id !== "^XZ") return undefined;
335-
closes.push(n.from);
336-
return false;
337-
},
338-
});
339+
// Only the caret form closes a format; the core parser reports ~XZ as unknown.
340+
const closes = structuralAt(state, "^XZ").map((c) => c.from);
339341
return closes[page] ?? closes[closes.length - 1] ?? null;
340342
}
341343

@@ -362,12 +364,30 @@ export function placesCaret(tr: Transaction): boolean {
362364
/** The tree keeps only spelled names, so the head up to `pos` is replayed for the prefix in force. */
363365
const spellAt = (state: EditorState, text: string, pos: number): string => spellPrefix(text, prefixCharsAt(state.doc.sliceString(0, pos)));
364366

365-
/** Transaction for a lint's repair: `command` in front of the first command at or after `at`,
366-
* spelled with the prefix in force. Data runs up to that prefix, trailing whitespace included,
367-
* so the lint's trimmed end would cut it short. */
368-
export function fixInsertion(state: EditorState, command: string, at: number): TransactionSpec {
367+
/** Transaction that inserts `command` at `place`, spelled with the prefix in force there, or an empty one when it already sits there.
368+
* For `after` the data runs up to the next prefix, trailing whitespace included, so the lint's trimmed end would cut it short. */
369+
export function fixInsertion(state: EditorState, command: string, place: FixPlace, from: number, to: number): TransactionSpec {
370+
const at =
371+
place === "after"
372+
? to
373+
: place === "before"
374+
? from
375+
: place === "closeFormat"
376+
? (structuralAt(state, "^XA").find((c) => c.from >= to)?.from ?? state.doc.length)
377+
: (structuralAt(state, "^XZ").filter((c) => c.to <= from).at(-1)?.to ?? 0);
369378
const { pos, chars } = commandBoundaryAt(state.doc.toString(), at);
370-
return { changes: { from: pos, insert: spellPrefix(command, chars) }, userEvent: LINTFIX_USER_EVENT };
379+
const spelled = spellPrefix(command, chars);
380+
// The diagnostic outlives the first click by the reparse window, so a second click must not stack a copy.
381+
const before = state.doc.sliceString(0, pos).trimEnd().length;
382+
const already = wholeDocNames(state).some((c) => c.id === command && (c.from === pos || c.to === before));
383+
if (already) return { userEvent: LINTFIX_USER_EVENT };
384+
if (place === "after") return { changes: { from: pos, insert: spelled }, userEvent: LINTFIX_USER_EVENT };
385+
// The generator writes a format command on its own line.
386+
const lead = pos > 0 && state.doc.lineAt(pos).from !== pos ? state.lineBreak : "";
387+
const tail = pos < state.doc.length ? state.lineBreak : "";
388+
// A line break is one document position whatever the separator's length.
389+
const anchor = pos + (lead ? 1 : 0) + spelled.length;
390+
return { changes: { from: pos, insert: `${lead}${spelled}${tail}` }, selection: { anchor }, scrollIntoView: true, userEvent: LINTFIX_USER_EVENT };
371391
}
372392

373393
/** Where a catalog insert goes: the user's caret, or a page's block. */

0 commit comments

Comments
 (0)