diff --git a/components/src/compare.ts b/components/src/compare.ts index 8fab1b220..eb0938c0f 100644 --- a/components/src/compare.ts +++ b/components/src/compare.ts @@ -1,16 +1,30 @@ import { isErr, Ok } from "@davidsouther/jiffies/lib/esm/result.js"; -import { CMP } from "@nand2tetris/simulator/languages/cmp.js"; +import { Span } from "@nand2tetris/simulator/languages/base"; +import { Cmp, CMP } from "@nand2tetris/simulator/languages/cmp.js"; -export const compare = (cmp: string, out: string) => { - const cmpResult = CMP.parse(cmp); - const outResult = CMP.parse(out); +interface Diff { + row: number; + col: number; + expected: string; + given: string; +} - if (isErr(cmpResult) || isErr(outResult)) { - return false; - } +interface DiffLineDisplay { + expectedLine: string; + givenLine: string; + correctCellSpans: Span[]; + incorrectCellSpans: Span[]; +} - const cmpData = Ok(cmpResult); - const outData = Ok(outResult); +export interface DiffDisplay { + text: string; + failureNum: number; + correctCellSpans: Span[]; + incorrectCellSpans: Span[]; +} + +function getDiffs(cmpData: Cmp, outData: Cmp): Diff[] { + const diffs: Diff[] = []; for (let i = 0; i < Math.min(cmpData.length, outData.length); i++) { const cmpI = cmpData[i] ?? []; @@ -22,9 +36,135 @@ export const compare = (cmp: string, out: string) => { if ( !(cmpJ?.trim().match(/^\*+$/) !== null || outJ?.trim() === cmpJ?.trim()) ) { - return false; + diffs.push({ row: i, col: j, expected: cmpJ, given: outJ }); } } } - return true; -}; + return diffs; +} + +export function compare(cmp: string, out: string) { + const cmpResult = CMP.parse(cmp); + const outResult = CMP.parse(out); + + if (isErr(cmpResult) || isErr(outResult)) { + return false; + } + + const cmpData = Ok(cmpResult); + const outData = Ok(outResult); + + return getDiffs(cmpData, outData).length == 0; +} + +export function generateDiffs(cmp: string, out: string): DiffDisplay { + const cmpResult = CMP.parse(cmp); + const outResult = CMP.parse(out); + + if (isErr(cmpResult) || isErr(outResult)) { + return { + text: "", + failureNum: 0, + correctCellSpans: [], + incorrectCellSpans: [], + }; + } + + const cmpData = Ok(cmpResult); + const outData = Ok(outResult); + + const diffs = getDiffs(cmpData, outData); + + const diffsByLine: Map = new Map(); + for (const diff of diffs) { + const lineDiffs = diffsByLine.get(diff.row); + if (lineDiffs) { + lineDiffs.push(diff); + } else { + diffsByLine.set(diff.row, [diff]); + } + } + + const lines = out.split("\n"); + const diffLines: Map = new Map(); + for (const [row, diffs] of diffsByLine) { + diffLines.set(row, generateDiffLine(lines[row], diffs)); + } + + const finalLines: string[] = []; + let lineStart = 0; + const correctCellSpans: Span[] = []; + const incorrectCellSpans: Span[] = []; + + for (let i = 0; i < lines.length; i++) { + const diffLine = diffLines.get(i); + if (diffLine) { + finalLines.push(diffLine.expectedLine); + correctCellSpans.push( + ...diffLine.correctCellSpans.map((span) => { + return { + start: span.start + lineStart, + end: span.end + lineStart, + line: span.line, + }; + }) + ); + lineStart += diffLine.expectedLine.length + 1; // +1 for the newline character + finalLines.push(diffLine.givenLine); + incorrectCellSpans.push( + ...diffLine.correctCellSpans.map((span) => { + return { + start: span.start + lineStart, + end: span.end + lineStart, + line: span.line, + }; + }) + ); + lineStart += diffLine.givenLine.length + 1; + } else { + finalLines.push(lines[i]); + lineStart += lines[i].length + 1; + } + } + + return { + text: finalLines.join("\n"), + failureNum: diffs.length, + correctCellSpans, + incorrectCellSpans, + }; +} + +function generateDiffLine(original: string, diffs: Diff[]): DiffLineDisplay { + const cells = original.split("|").filter((cell) => cell != ""); + const newCells = cells.map((cell) => " ".repeat(cell.length)); + + const cellStarts: number[] = []; + let sum = 0; + for (let i = 0; i < cells.length; i++) { + cellStarts.push(sum + 1); + sum += cells[i].length + 1; + } + + const correctCellSpans: Span[] = []; + const incorrectCellSpans: Span[] = []; + + for (const diff of diffs) { + cells[diff.col] = diff.expected; + newCells[diff.col] = diff.given; + + const span = { + start: cellStarts[diff.col], + end: cellStarts[diff.col] + diff.expected.length, + line: 0, // not used + }; + correctCellSpans.push(span); + incorrectCellSpans.push(span); + } + return { + expectedLine: `|${cells.join("|")}|`, + givenLine: `|${newCells.join("|")}|`, + correctCellSpans, + incorrectCellSpans, + }; +} diff --git a/web/src/shell/editor.scss b/web/src/shell/editor.scss index 2b45dc94c..1baea9d4d 100644 --- a/web/src/shell/editor.scss +++ b/web/src/shell/editor.scss @@ -14,6 +14,14 @@ background-color: var(--mark-background-color); } + .red { + color: rgb(190, 16, 16); + } + + .green { + color: green; + } + textarea { flex: 1; } diff --git a/web/src/shell/editor.tsx b/web/src/shell/editor.tsx index 9755b234e..d57cf9fd5 100644 --- a/web/src/shell/editor.tsx +++ b/web/src/shell/editor.tsx @@ -61,10 +61,16 @@ const Textarea = ({ const MONACO_LIGHT_THEME = "vs"; const MONACO_DARK_THEME = "vs-dark"; -const makeHighlight = ( +export interface Decoration { + span: Span; + cssClass: string; +} + +const makeDecorations = ( monaco: typeof monacoT | null, editor: monacoT.editor.IStandaloneCodeEditor | undefined, highlight: Span | undefined, + additionalDecorations: Decoration[], decorations: string[] ): string[] => { if (!(editor && highlight)) return decorations; @@ -83,6 +89,18 @@ const makeHighlight = ( editor.revealRangeInCenter(range); } } + for (const decoration of additionalDecorations) { + const range = monaco?.Range.fromPositions( + model.getPositionAt(decoration.span.start), + model.getPositionAt(decoration.span.end) + ); + if (range) { + nextDecoration.push({ + range, + options: { inlineClassName: decoration.cssClass }, + }); + } + } return editor.deltaDecorations(decorations, nextDecoration); }; @@ -94,6 +112,7 @@ const Monaco = ({ error, disabled = false, highlight: currentHighlight, + customDecorations: currentCustomDecorations = [], dynamicHeight = false, lineNumberTransform, }: { @@ -104,6 +123,7 @@ const Monaco = ({ error?: CompilationError; disabled?: boolean; highlight?: Span; + customDecorations?: Decoration[]; dynamicHeight?: boolean; lineNumberTransform?: (n: number) => string; }) => { @@ -114,6 +134,7 @@ const Monaco = ({ const editor = useRef(); const decorations = useRef([]); const highlight = useRef(undefined); + const customDecorations = useRef([]); const codeTheme = useCallback(() => { const isDark = @@ -123,8 +144,8 @@ const Monaco = ({ return isDark ? MONACO_DARK_THEME : MONACO_LIGHT_THEME; }, [theme]); - const doHighlight = useCallback(() => { - decorations.current = makeHighlight( + const doDecorations = useCallback(() => { + decorations.current = makeDecorations( monaco, editor.current, // I'm not sure why this makes things work, but it is load bearing. @@ -133,6 +154,7 @@ const Monaco = ({ // cause a 1-character highlight in the editor view, so don't do that // either. highlight.current ?? { start: 0, end: 0, line: 0 }, + customDecorations.current, decorations.current ); }, [decorations, monaco, editor, highlight]); @@ -149,9 +171,14 @@ const Monaco = ({ // Mark and center highlighted spans useEffect(() => { highlight.current = currentHighlight; - doHighlight(); + doDecorations(); }, [currentHighlight]); + useEffect(() => { + customDecorations.current = currentCustomDecorations; + doDecorations(); + }, [currentCustomDecorations]); + // Set options when mounting const onMount: OnMount = useCallback( (ed) => { @@ -171,7 +198,7 @@ const Monaco = ({ lineNumbers: lineNumberTransform ?? "on", folding: false, }); - doHighlight(); + doDecorations(); calculateHeight(); editor.current?.onDidChangeCursorPosition((e) => { const index = editor.current?.getModel()?.getOffsetAt(e.position); @@ -255,6 +282,7 @@ export const Editor = ({ grammar, language, highlight, + customDecorations = [], dynamicHeight = false, lineNumberTransform, }: { @@ -268,6 +296,7 @@ export const Editor = ({ grammar?: ohm.Grammar; language: string; highlight?: Span; + customDecorations?: Decoration[]; dynamicHeight?: boolean; lineNumberTransform?: (n: number) => string; }) => { @@ -287,6 +316,7 @@ export const Editor = ({ error={error} disabled={disabled} highlight={highlight} + customDecorations={customDecorations} dynamicHeight={dynamicHeight} lineNumberTransform={lineNumberTransform} /> diff --git a/web/src/shell/test_panel.tsx b/web/src/shell/test_panel.tsx index c3d2c2bd1..93e0b40cb 100644 --- a/web/src/shell/test_panel.tsx +++ b/web/src/shell/test_panel.tsx @@ -1,5 +1,5 @@ import { Trans } from "@lingui/macro"; -import { DiffTable } from "@nand2tetris/components/difftable.js"; +import { DiffDisplay, generateDiffs } from "@nand2tetris/components/compare.js"; import { Runbar } from "@nand2tetris/components/runbar.js"; import { BaseContext } from "@nand2tetris/components/stores/base.context.js"; import { Span } from "@nand2tetris/simulator/languages/base"; @@ -12,6 +12,7 @@ import { RefObject, useCallback, useContext, + useEffect, useState, } from "react"; import { AppContext } from "../App.context"; @@ -68,6 +69,12 @@ export const TestPanel = ({ } }, [filePicker, setStatus, fs]); + const [diffDisplay, setDiffDisplay] = useState(); + + useEffect(() => { + setDiffDisplay(generateDiffs(cmp, out)); + }, [out, cmp]); + return ( ""} />
- + {out == "" &&

Execute test script to compare output.

} + {(diffDisplay?.failureNum ?? 0) > 0 && ( +

+ {diffDisplay?.failureNum} failure + {diffDisplay?.failureNum === 1 ? "" : "s"} +

+ )} + { + return; + }} + language={""} + disabled={true} + lineNumberTransform={(_) => ""} + customDecorations={diffDisplay?.correctCellSpans + .map((span) => { + return { span, cssClass: "green" }; + }) + .concat( + diffDisplay?.incorrectCellSpans.map((span) => { + return { span, cssClass: "red" }; + }) + )} + />