diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 86082330092..f5d82c01af0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -428,6 +428,18 @@ $ node > eval(result.js_code); ``` +### Lambda debug outputs (API v9) + +`comp.rescript.compileWithDebug(source)` returns `lambda` before the backend's +Lambda optimization passes and `lambda_optimized` after the whole-term passes, +before export grouping and JavaScript lowering. Both use the same Lambda printer +so their text can be compared directly. JavaScript lowering performs further +transformations; this snapshot is not the final JavaScript IR. + +Regular `comp.rescript.compile(source)` does not capture these debug outputs. +Older bundles do not provide `lambda_optimized`; consumers should treat it as +optional when supporting multiple API versions. + ### Testing the Playground bundle Run `yarn workspace playground test` for a quick sanity check to see if all the build artifacts are working together correctly. When releasing the playground bundle, the test will always be executed before publishing to catch regressions. diff --git a/changes/developer-playground-lambda-diff.internal.md b/changes/developer-playground-lambda-diff.internal.md new file mode 100644 index 00000000000..8ca23ac832b --- /dev/null +++ b/changes/developer-playground-lambda-diff.internal.md @@ -0,0 +1 @@ +- Add before/after optimization views and inline diffs to the developer playground's Lambda tab, with optimized Lambda output in playground API v9. https://github.com/rescript-lang/rescript/pull/8636 diff --git a/compiler/core/lam_compile_main.ml b/compiler/core/lam_compile_main.ml index 1507bf8140c..0470436660d 100644 --- a/compiler/core/lam_compile_main.ml +++ b/compiler/core/lam_compile_main.ml @@ -254,7 +254,8 @@ let required_modules (lam : Lambda.t) : unit Lam_module_ident.Hash_set.t = collect lam; required -let compile (output_prefix : string) export_idents hoisted (lam : Lambda.t) = +let compile ?on_optimized_lambda (output_prefix : string) export_idents hoisted + (lam : Lambda.t) = let debug_ir = !Js_config.debug_ir in let diagnostics = if debug_ir then Some (Ir_diagnostics.create ~output_prefix) else None @@ -329,6 +330,9 @@ let compile (output_prefix : string) export_idents hoisted (lam : Lambda.t) = lam in + (* Capture the final whole-term optimization result before export grouping + and JavaScript lowering. The callback is absent in normal compilation. *) + Option.iter (fun capture -> capture lam) on_optimized_lambda; let ({Lam_coercion.groups} as coerced_input), meta = Lam_coercion.coerce_and_group_big_lambda meta lam in diff --git a/compiler/core/lam_compile_main.mli b/compiler/core/lam_compile_main.mli index c62ff0dca4e..cbff2fbf5a2 100644 --- a/compiler/core/lam_compile_main.mli +++ b/compiler/core/lam_compile_main.mli @@ -28,6 +28,7 @@ *) val compile : + ?on_optimized_lambda:(Lambda.t -> unit) -> string -> Ident.t list -> Lambda.hoisted_function list -> @@ -35,6 +36,9 @@ val compile : J.deps_program (** For toplevel, [filename] is [""] which is the same as {!Env.get_unit_name ()} + + [on_optimized_lambda], when supplied, observes the whole-term Lambda after + optimization and before export grouping and JavaScript lowering. *) val lambda_as_module : J.deps_program -> string -> unit diff --git a/compiler/jsoo/jsoo_playground_main.ml b/compiler/jsoo/jsoo_playground_main.ml index 3c3806a3a92..6f82cc3ece0 100644 --- a/compiler/jsoo/jsoo_playground_main.ml +++ b/compiler/jsoo/jsoo_playground_main.ml @@ -53,8 +53,9 @@ * v6: Added `config.experimental_features` and `config.jsx_preserve_mode` to the BundleConfig. * v7: Added debug dump output APIs for developer playground tooling. * v8: Added genType and source map configuration and compilation outputs. + * v9: Added optimized Lambda output to debug compilation. * *) -let api_version = "8" +let api_version = "9" module Js = Js_of_ocaml.Js @@ -669,8 +670,18 @@ module Compile = struct let {Translmod.lambda; exports; hoisted_functions} = Translmod.transl_implementation modulename typed_tree in + let optimized_lambda = ref None in + let on_optimized_lambda = + if include_debug_outputs then + Some + (fun lambda -> + optimized_lambda := + Some (Printer.to_string Printlambda.lambda lambda)) + else None + in let lambda_output = - Lam_compile_main.compile "" exports hoisted_functions lambda + Lam_compile_main.compile ?on_optimized_lambda "" exports + hoisted_functions lambda in let js_code, source_map = render_javascript ~module_system ~filename ~source:str ~source_map_mode @@ -727,7 +738,15 @@ module Compile = struct ("lam", inject @@ Js.string lam); |] in - Js.Unsafe.obj (Array.concat [attrs; debug_attrs; gentype_attrs]) + let optimized_lambda_attrs = + match !optimized_lambda with + | None -> [||] + | Some output -> + Js.Unsafe.[|("lambda_optimized", inject @@ Js.string output)|] + in + Js.Unsafe.obj + (Array.concat + [attrs; debug_attrs; gentype_attrs; optimized_lambda_attrs]) else Js.Unsafe.obj attrs with e -> ( match e with diff --git a/packages/dev-playground/package.json b/packages/dev-playground/package.json index e9f0c2b6306..8e446765ae3 100644 --- a/packages/dev-playground/package.json +++ b/packages/dev-playground/package.json @@ -9,7 +9,7 @@ "prepare-pages-site": "node scripts/prepare-pages-site.mjs", "res:build": "rescript", "res:watch": "rescript -w", - "test": "rescript && node --test scripts/source-map-navigation.test.mjs scripts/pane-layout.test.mjs", + "test": "rescript && node --test scripts/source-map-navigation.test.mjs scripts/pane-layout.test.mjs scripts/lambda-diff.test.mjs", "dev": "vite --host 127.0.0.1", "build": "rescript && vite build", "preview": "vite preview --host 127.0.0.1" diff --git a/packages/dev-playground/scripts/lambda-diff.test.mjs b/packages/dev-playground/scripts/lambda-diff.test.mjs new file mode 100644 index 00000000000..29b7ee0c5a5 --- /dev/null +++ b/packages/dev-playground/scripts/lambda-diff.test.mjs @@ -0,0 +1,369 @@ +import assert from "node:assert/strict"; +import { register } from "node:module"; +import test from "node:test"; +import * as Signal from "xote/src/Signal.res.mjs"; +import { + compare, + maxCharacters, + maxLines, + sections, +} from "../src/LambdaDiff.res.mjs"; +import * as Inline from "../src/LambdaInlineDiff.res.mjs"; + +// Match vite.config.js: Xote and the playground use the published v12 runtime. +register(new URL("./runtime-loader.mjs", import.meta.url)); +const LambdaView = await import("../src/LambdaView.res.mjs"); +const { renderToString } = await import("xote/src/SSR.res.mjs"); + +function changes(before, after) { + const result = compare(before, after); + assert.equal(result.TAG, "Changes"); + const lines = result._0; + for (const [side, text] of [ + ["before", before], + ["after", after], + ]) { + const original = lines.filter(line => line[side] !== undefined); + assert.equal(original.map(line => line.text).join("\n"), text); + assert.deepEqual( + original.map(line => line[side]), + original.map((_, index) => index + 1), + ); + } + return lines; +} + +test("identical dumps, including empty dumps, need no diff", () => { + assert.equal(compare("", ""), "Identical"); + assert.equal(compare("(makeblock 42)\n", "(makeblock 42)\n"), "Identical"); +}); + +test("replacement retains before and after line numbers", () => { + assert.deepEqual( + changes("(let\n unused/1\n result)\n", "(let\n result)\n"), + [ + { kind: "Same", text: "(let", before: 1, after: 1 }, + { kind: "Removed", text: " unused/1", before: 2, after: undefined }, + { kind: "Same", text: " result)", before: 3, after: 2 }, + { kind: "Same", text: "", before: 4, after: 3 }, + ], + ); + assert.deepEqual( + changes("before", "after").map(line => line.kind), + ["Removed", "Added"], + ); +}); + +test("insertions, deletions, empty inputs and final newlines are lossless", () => { + for (const [before, after] of [ + ["", "(block 42)"], + ["(block 42)", ""], + ["a\nb", "a\ninsert\nb"], + ["a\nb", "insert\na\nb\nend"], + ["a\n", "a"], + ["a", "a\n"], + ["\n\n", "\n"], + ]) + changes(before, after); +}); + +test("repeated Lambda lines align deterministically without dropping text", () => { + const before = "(let\n x/1\n (let\n x/1\n ))"; + const after = "(let\n (let\n x/1\n ))"; + assert.equal( + changes(before, after).filter(line => line.kind !== "Same").length, + 1, + ); + assert.deepEqual(compare(before, after), compare(before, after)); + changes("x/1", "x/2"); // Identifier changes must not be normalized away. +}); + +test("exhaustive small dumps reconstruct both inputs", () => { + const dumps = [""]; + for (let length = 1; length <= 4; length++) { + for (let bits = 0; bits < 2 ** length; bits++) { + dumps.push( + Array.from({ length }, (_, i) => + (bits >> i) & 1 ? "(x)" : "(y)", + ).join("\n"), + ); + } + } + for (const before of dumps) { + for (const after of dumps) { + if (before !== after) changes(before, after); + } + } +}); + +test("bounds comparison work and output size", () => { + assert.equal(compare("a\n".repeat(1100), "b\n".repeat(1100)), "TooLarge"); + assert.equal(compare("a".repeat(maxCharacters), "b"), "TooLarge"); + assert.equal(compare("\n".repeat(maxLines), ""), "TooLarge"); +}); + +test("trims common edges before applying the comparison budget", () => { + const edge = "(unchanged)\n".repeat(5000); + const lines = changes(`${edge}before\n${edge}`, `${edge}after\n${edge}`); + assert.equal(lines.filter(line => line.kind !== "Same").length, 2); +}); + +test("collapsed regions preserve every line and keep context around changes", () => { + const context = Array.from({ length: 20 }, (_, i) => `line ${i}`).join("\n"); + const lines = changes( + `${context}\nold\n${context}\nold\n${context}`, + `${context}\nnew\n${context}\nnew\n${context}`, + ); + const grouped = sections(lines); + assert.ok(grouped.every(section => section._0.length > 0)); + assert.deepEqual( + grouped.flatMap(section => section._0), + lines, + ); + const hidden = grouped.filter(section => section.TAG === "Collapsed"); + assert.deepEqual( + hidden.map(section => section._0.length), + [17, 14, 17], + ); + for (const section of hidden) { + assert.ok(section._0.every(line => line.kind === "Same")); + } + assert.ok( + sections(changes("a\nb\nc", "a\nB\nc")).every( + section => section.TAG === "Visible", + ), + ); +}); + +function inlineChanges(before, after) { + const lines = changes(before, after); + const highlights = Inline.highlight(lines); + for (const line of lines) { + const parts = Inline.forLine(highlights, line); + if (parts) assert.equal(parts.map(part => part.text).join(""), line.text); + if (line.kind === "Same") assert.equal(parts, undefined); + } + return highlights; +} + +const changedText = parts => + parts.filter(part => part.changed).map(part => part.text); + +test("inline diff highlights changed Lambda tokens, not the shared expression", () => { + const result = inlineChanges(" (+ value/1 10)", " (+ value/1 20)"); + assert.deepEqual(changedText(result.before[1]), ["10"]); + assert.deepEqual(changedText(result.after[1]), ["20"]); + const renamed = inlineChanges("(apply value/123 42)", "(apply value/124 42)"); + assert.deepEqual(changedText(renamed.before[1]), ["value/123"]); + assert.deepEqual(changedText(renamed.after[1]), ["value/124"]); +}); + +test("inline diff preserves whitespace, Unicode, and escaped quoted constants", () => { + const result = inlineChanges( + ' (apply print/1 "a \\"quoted\\" ๐Ÿ™‚")', + '\t(apply print/1 "b \\"quoted\\" ๐Ÿ™‚")', + ); + assert.deepEqual(changedText(result.before[1]), ['"a \\"quoted\\" ๐Ÿ™‚"']); + assert.deepEqual(changedText(result.after[1]), ['"b \\"quoted\\" ๐Ÿ™‚"']); + const whitespace = inlineChanges( + " (apply cafรฉ/1 42)", + "\t(apply cafรฉ/1 42)", + ); + assert.deepEqual(changedText(whitespace.before[1]), []); + assert.deepEqual(changedText(whitespace.after[1]), []); +}); + +test("inline diff handles token insertion, deletion and repeated tokens", () => { + const result = inlineChanges("(apply f/1 x/2 x/2)", "(apply f/1 x/2)"); + assert.deepEqual(changedText(result.before[1]), ["x/2"]); + assert.deepEqual(changedText(result.after[1]), []); + const inserted = inlineChanges("(apply f/1 x/2)", "(apply f/1 x/2 42)"); + assert.deepEqual(changedText(inserted.before[1]), []); + assert.deepEqual(changedText(inserted.after[1]), ["42"]); +}); + +test("inline alignment prioritizes shared atoms over repeated punctuation", () => { + const result = inlineChanges("((a", "a(("); + assert.deepEqual(changedText(result.before[1]), ["(", "("]); + assert.deepEqual(changedText(result.after[1]), ["(", "("]); +}); + +test("inline pairing skips unmatched lines in an uneven changed block", () => { + const result = inlineChanges( + "(unused z/9)\n (+ x/1 10)\n (- y/2 30)", + " (+ x/1 20)\n (- y/2 40)", + ); + assert.equal(result.before[1], undefined); + assert.deepEqual(changedText(result.before[2]), ["10"]); + assert.deepEqual(changedText(result.after[1]), ["20"]); + assert.deepEqual(changedText(result.before[3]), ["30"]); + assert.deepEqual(changedText(result.after[2]), ["40"]); +}); + +test("inline pairing prefers a better later match over a weaker first match", () => { + const result = inlineChanges( + "(apply other/1 10)\n(apply f/2 20)", + "(apply f/2 30)", + ); + assert.equal(result.before[1], undefined); + assert.deepEqual(changedText(result.before[2]), ["20"]); + assert.deepEqual(changedText(result.after[1]), ["30"]); +}); + +test("unrelated expressions and one-sided changes retain only line highlighting", () => { + for (const [before, after] of [ + [" (foo/1 10)", " (bar/2 20)"], + ["((", "))"], + ["(a)\n(b)", "(a)"], + ["(a)", "(a)\n(b)"], + ]) { + assert.deepEqual(inlineChanges(before, after), { before: {}, after: {} }); + } +}); + +test("inline highlighting falls back for oversized blocks, lines and token counts", () => { + const largeBlock = Array.from( + { length: Inline.maxBlockLines }, + (_, i) => `(apply f/${i} 10)`, + ).join("\n"); + assert.deepEqual( + inlineChanges(largeBlock, largeBlock.replaceAll(" 10)", " 20)")), + { before: {}, after: {} }, + ); + for (const body of [ + "x".repeat(Inline.maxLineCharacters), + "x ".repeat(Inline.maxTokens), + ]) { + assert.deepEqual( + inlineChanges(`(apply ${body} 10)`, `(apply ${body} 20)`), + { before: {}, after: {} }, + ); + } +}); + +test("inline token work is budgeted across blocks and resets for each dump", () => { + const body = "x ".repeat(110); + const before = Array.from( + { length: 30 }, + (_, i) => `(apply ${body} 10)\nseparator/${i}`, + ).join("\n"); + const after = before.replaceAll(" 10)", " 20)"); + const result = inlineChanges(before, after); + assert.ok(Object.keys(result.before).length > 0); + assert.ok(Object.keys(result.before).length < 30); + assert.deepEqual(inlineChanges(before, after), result); +}); + +test("an oversized candidate block leaves the token budget for later changes", () => { + const block = Array.from( + { length: 16 }, + (_, i) => `(apply f/${i} ${"x ".repeat(100)}10)`, + ).join("\n"); + const result = inlineChanges( + `${block}\nseparator\n(+ value/1 10)`, + `${block.replaceAll("10)", "20)")}\nseparator\n(+ value/1 20)`, + ); + assert.deepEqual(Object.keys(result.before), ["18"]); + assert.deepEqual(changedText(result.before[18]), ["10"]); + assert.deepEqual(changedText(result.after[18]), ["20"]); +}); + +function elements(node) { + switch (node.TAG) { + case "Element": + return [node, ...node.children.flatMap(elements)]; + case "Fragment": + return node._0.flatMap(elements); + case "SignalFragment": + return Signal.peek(node._0).flatMap(elements); + case "LazyComponent": + return elements(node._0()); + case "Keyed": + return elements(node.child); + default: + return []; + } +} + +function lambdaView(contextLines = 20) { + const context = "unchanged\n".repeat(contextLines); + const input = Signal.make({ + before: `${context}old\n${context}`, + after: `${context}new\n${context}`, + }); + const mode = Signal.make("Before"); + const visible = Signal.make(true); + const view = LambdaView.make({ + input: () => Signal.get(input), + mode, + visible: () => Signal.get(visible), + }); + const html = () => renderToString(() => view); + const diff = () => + elements(view).find( + node => + node.tag === "div" && + renderToString(() => node).startsWith('
'), + ); + return { input, mode, visible, view, html, diff }; +} + +test("Lambda view defers diff work and does not render collapsed lines", () => { + const { mode, html, diff } = lambdaView(5000); + assert.equal(diff(), undefined); + Signal.set(mode, "Diff"); + assert.ok(diff()); + assert.equal( + (html().match(/class="lambda-diff-line(?: |")/g) ?? []).length, + 8, + ); + assert.equal((html().match(/
{ + const { input, mode, visible, diff } = lambdaView(); + Signal.set(mode, "Diff"); + const initial = diff(); + assert.ok(initial); + Signal.set(input, { ...Signal.peek(input) }); + assert.equal(diff(), initial); + Signal.set(mode, "Before"); + Signal.set(input, { ...Signal.peek(input), after: "changed while hidden" }); + assert.equal(diff(), initial); + Signal.set(mode, "Diff"); + assert.notEqual(diff(), initial); + const changed = diff(); + Signal.set(visible, false); + Signal.set(visible, true); + assert.equal(diff(), changed); +}); + +test("expanded unchanged regions survive edits to a changed line", () => { + const { input, mode, view, html } = lambdaView(); + Signal.set(mode, "Diff"); + const summary = elements(view).find(node => node.tag === "summary"); + const click = summary.events.find(([name]) => name === "click")[1]; + let prevented = false; + click({ + preventDefault: () => { + prevented = true; + }, + }); + assert.equal(prevented, true); + const openDetails = () => + (html().match(/]* open(?: |>|=)/g) ?? []).length; + assert.equal(openDetails(), 1); + Signal.set(input, { + ...Signal.peek(input), + after: Signal.peek(input).after.replace("new", "edited"), + }); + assert.equal(openDetails(), 1); +}); + +test("older bundles retain the optimized Lambda availability notice", () => { + const { input, mode, html } = lambdaView(); + Signal.set(input, { ...Signal.peek(input), after: undefined }); + Signal.set(mode, "Diff"); + assert.match(html(), /does not provide optimized Lambda/); + assert.doesNotMatch(html(), /class="lambda-diff"/); +}); diff --git a/packages/dev-playground/scripts/runtime-loader.mjs b/packages/dev-playground/scripts/runtime-loader.mjs new file mode 100644 index 00000000000..86e57d78a26 --- /dev/null +++ b/packages/dev-playground/scripts/runtime-loader.mjs @@ -0,0 +1,10 @@ +// Keep Node rendering tests on the same runtime as vite.config.js. +export function resolve(specifier, context, nextResolve) { + if (specifier.startsWith("@rescript/runtime/")) { + return nextResolve( + new URL(`../node_modules/${specifier}`, import.meta.url).href, + context, + ); + } + return nextResolve(specifier, context); +} diff --git a/packages/dev-playground/src/Bindings.res b/packages/dev-playground/src/Bindings.res index edf48d15251..ecca8b32b79 100644 --- a/packages/dev-playground/src/Bindings.res +++ b/packages/dev-playground/src/Bindings.res @@ -80,6 +80,7 @@ module CompileResult = { @get external parsetree: compileResult => option = "parsetree" @get external typedtree: compileResult => option = "typedtree" @get external lambda: compileResult => option = "lambda" + @get external lambdaOptimized: compileResult => option = "lambda_optimized" @get external gentype: compileResult => option = "gentype" @get external sourceMap: compileResult => option = "source_map" @get external errors: compileResult => option> = "errors" diff --git a/packages/dev-playground/src/CompilerApi.res b/packages/dev-playground/src/CompilerApi.res index 1715921e14d..798999609cb 100644 --- a/packages/dev-playground/src/CompilerApi.res +++ b/packages/dev-playground/src/CompilerApi.res @@ -62,6 +62,7 @@ type success = { parsetree: string, typedtree: string, lambda: string, + lambdaOptimized: option, gentype: option, sourceMap: option, warnings: array, @@ -415,9 +416,20 @@ let normalize = (compileOutput, elapsedMs): compileResult => { } let gentype = compileOutput->CompileResult.gentype + let lambdaOptimized = compileOutput->CompileResult.lambdaOptimized let sourceMap = compileOutput->CompileResult.sourceMap - Ok({jsCode, parsetree, typedtree, lambda, gentype, sourceMap, warnings, time: elapsedMs}) + Ok({ + jsCode, + parsetree, + typedtree, + lambda, + lambdaOptimized, + gentype, + sourceMap, + warnings, + time: elapsedMs, + }) | _ => Error(failureFromCompileOutput(compileOutput, elapsedMs)) } diff --git a/packages/dev-playground/src/CompilerApi.resi b/packages/dev-playground/src/CompilerApi.resi index 5535d15e04d..ee857fc33d2 100644 --- a/packages/dev-playground/src/CompilerApi.resi +++ b/packages/dev-playground/src/CompilerApi.resi @@ -25,6 +25,7 @@ type success = { parsetree: string, typedtree: string, lambda: string, + lambdaOptimized: option, gentype: option, sourceMap: option, warnings: array, diff --git a/packages/dev-playground/src/LambdaDiff.res b/packages/dev-playground/src/LambdaDiff.res new file mode 100644 index 00000000000..5e04cf8225e --- /dev/null +++ b/packages/dev-playground/src/LambdaDiff.res @@ -0,0 +1,169 @@ +type kind = Same | Removed | Added +type line = {kind: kind, text: string, before: option, after: option} +type result = Identical | TooLarge | Changes(array) + +// Bound both comparison work and output size. Compiler dumps can greatly +// exceed the source size; never allocate an unbounded quadratic table. +let maxCells = 1000000 +let maxCharacters = 1000000 +let maxLines = 20000 + +// Line diff: strip identical prefixes/suffixes, then find the longest common +// subsequence (LCS) of exact lines in the remaining middle. Each table cell +// stores how many lines can still match from that pair of positions: equal +// lines take 1 + the diagonal cell; otherwise take the larger of skipping a +// line on either side. Walking the table emits Same/Removed/Added lines with +// their original line numbers (ties prefer removal for deterministic output). +// The middle costs O(rows * columns) time and space, so the limits above return +// TooLarge before allocating an excessive table. LambdaInlineDiff separately +// refines changed blocks; it never changes this line alignment or source text. +let compare = (before, after) => { + if before === after { + Identical + } else if String.length(before) + String.length(after) > maxCharacters { + TooLarge + } else { + let left = before->String.split("\n") + let right = after->String.split("\n") + let n = Array.length(left) + let m = Array.length(right) + let prefix = ref(0) + while ( + prefix.contents < n && + prefix.contents < m && + left->Array.getUnsafe(prefix.contents) === right->Array.getUnsafe(prefix.contents) + ) { + prefix := prefix.contents + 1 + } + let suffix = ref(0) + while ( + suffix.contents < n - prefix.contents && + suffix.contents < m - prefix.contents && + left->Array.getUnsafe(n - suffix.contents - 1) === + right->Array.getUnsafe(m - suffix.contents - 1) + ) { + suffix := suffix.contents + 1 + } + let rows = n - prefix.contents - suffix.contents + let columns = m - prefix.contents - suffix.contents + + // Shared edges appear once in the output, not once for each input. + if ( + n + m - prefix.contents - suffix.contents > maxLines || (rows + 1) * (columns + 1) > maxCells + ) { + TooLarge + } else { + // Longest common subsequence on the changed middle only. Keeping the + // exact printer text avoids hiding meaningful identifier changes. + let stride = columns + 1 + let table = Array.make(~length=(rows + 1) * stride, 0) + let i = ref(rows - 1) + while i.contents >= 0 { + let j = ref(columns - 1) + while j.contents >= 0 { + let cell = i.contents * stride + j.contents + table[cell] = if ( + left->Array.getUnsafe(prefix.contents + i.contents) === + right->Array.getUnsafe(prefix.contents + j.contents) + ) { + 1 + table->Array.getUnsafe(cell + stride + 1) + } else { + Math.Int.max(table->Array.getUnsafe(cell + stride), table->Array.getUnsafe(cell + 1)) + } + j := j.contents - 1 + } + i := i.contents - 1 + } + let lines: array = [] + let x = ref(0) + let y = ref(0) + let same = () => { + lines->Array.push({ + kind: Same, + text: left->Array.getUnsafe(x.contents), + before: Some(x.contents + 1), + after: Some(y.contents + 1), + }) + x := x.contents + 1 + y := y.contents + 1 + } + while x.contents < prefix.contents { + same() + } + while x.contents < n - suffix.contents || y.contents < m - suffix.contents { + if ( + x.contents < n - suffix.contents && + y.contents < m - suffix.contents && + left->Array.getUnsafe(x.contents) === right->Array.getUnsafe(y.contents) + ) { + same() + } else if ( + x.contents < n - suffix.contents && + (y.contents >= m - suffix.contents || + table->Array.getUnsafe( + (x.contents - prefix.contents + 1) * stride + y.contents - prefix.contents, + ) >= + table->Array.getUnsafe( + (x.contents - prefix.contents) * stride + y.contents - prefix.contents + 1, + )) + ) { + lines->Array.push({ + kind: Removed, + text: left->Array.getUnsafe(x.contents), + before: Some(x.contents + 1), + after: None, + }) + x := x.contents + 1 + } else { + lines->Array.push({ + kind: Added, + text: right->Array.getUnsafe(y.contents), + before: None, + after: Some(y.contents + 1), + }) + y := y.contents + 1 + } + } + while x.contents < n { + same() + } + Changes(lines) + } + } +} + +type section = Visible(array) | Collapsed(array) + +let sections = lines => { + let result = [] + let index = ref(0) + let length = Array.length(lines) + while index.contents < length { + let start = index.contents + if (lines->Array.getUnsafe(start)).kind !== Same { + while index.contents < length && (lines->Array.getUnsafe(index.contents)).kind !== Same { + index := index.contents + 1 + } + result->Array.push(Visible(Array.slice(lines, ~start, ~end=index.contents))) + } else { + while index.contents < length && (lines->Array.getUnsafe(index.contents)).kind === Same { + index := index.contents + 1 + } + let end_ = index.contents + let head = start === 0 ? start : Math.Int.min(start + 3, end_) + let tail = end_ === length ? end_ : Math.Int.max(head, end_ - 3) + if tail - head <= 3 { + result->Array.push(Visible(Array.slice(lines, ~start, ~end=end_))) + } else { + if head > start { + result->Array.push(Visible(Array.slice(lines, ~start, ~end=head))) + } + result->Array.push(Collapsed(Array.slice(lines, ~start=head, ~end=tail))) + if tail < end_ { + result->Array.push(Visible(Array.slice(lines, ~start=tail, ~end=end_))) + } + } + } + } + result +} diff --git a/packages/dev-playground/src/LambdaInlineDiff.res b/packages/dev-playground/src/LambdaInlineDiff.res new file mode 100644 index 00000000000..c7cbf33e23e --- /dev/null +++ b/packages/dev-playground/src/LambdaInlineDiff.res @@ -0,0 +1,229 @@ +// Inline refinement runs only inside consecutive Removed/Added line blocks. +// For each candidate line pair, an LCS prioritizing Lambda atoms marks +// the shared tokens. Pairing requires shared atoms (not just punctuation) to +// cover at least half the atom count of the larger line. A second dynamic +// programming table chooses the highest-scoring order-preserving line pairs, +// allowing unmatched lines on either side. Unmatched tokens in chosen pairs +// receive stronger shading; whitespace and the original text stay intact. +// Oversized or dissimilar pairs keep whole-line highlighting. Token comparison +// cells share a budget across the entire dump. Admit whole blocks before doing +// token comparisons, so an oversized block cannot consume the budget partially +// and starve later, smaller changes. Block size also caps line-pairing work. +type part = {text: string, changed: bool} +type highlights = {before: Dict.t>, after: Dict.t>} +type tokens = {all: array, significant: array, atoms: int} +type pair = {before: array, after: array, score: int} + +// One budget for the entire dump, not a fresh quadratic budget per line. +let maxCells = 250000 +let maxBlockLines = 32 +let maxLineCharacters = 4096 +let maxTokens = 128 + +let isSpace = text => String.trim(text) === "" +let isAtom = text => !isSpace(text) && !RegExp.test(/^[()[\]{},;]+$/, text) + +let tokenize = text => { + if String.length(text) > maxLineCharacters { + None + } else { + // Keep stamped identifiers and operators intact. Quoted constants include + // escapes; whitespace is retained verbatim but does not affect alignment. + let all = + text + ->String.match(/"(?:\\.|[^"\\])*"|\s+|[()[\]{},;]|[^\s()[\]{},;"]+|"/g) + ->Option.getOr([]) + ->Array.keepSome + let significant = all->Array.filter(token => !isSpace(token)) + if Array.length(significant) > maxTokens { + None + } else { + Some({all, significant, atoms: significant->Array.filter(isAtom)->Array.length}) + } + } +} + +let parts = (tokens, matched) => { + let index = ref(0) + tokens.all->Array.map(text => { + let changed = if isSpace(text) { + false + } else { + let changed = !(matched->Array.getUnsafe(index.contents)) + index := index.contents + 1 + changed + } + {text, changed} + }) +} + +let compareTokens = (left, right, budget) => { + let n = Array.length(left.significant) + let m = Array.length(right.significant) + let stride = m + 1 + let cells = (n + 1) * stride + if left.atoms === 0 || right.atoms === 0 || cells > budget.contents { + None + } else { + budget := budget.contents - cells + let table = Array.make(~length=cells, 0) + let i = ref(n - 1) + while i.contents >= 0 { + let j = ref(m - 1) + while j.contents >= 0 { + let cell = i.contents * stride + j.contents + let token = left.significant->Array.getUnsafe(i.contents) + table[cell] = if token === right.significant->Array.getUnsafe(j.contents) { + // One shared atom outweighs every possible punctuation match. + (isAtom(token) ? maxTokens + 1 : 1) + table->Array.getUnsafe(cell + stride + 1) + } else { + Math.Int.max(table->Array.getUnsafe(cell + stride), table->Array.getUnsafe(cell + 1)) + } + j := j.contents - 1 + } + i := i.contents - 1 + } + let leftMatched = Array.make(~length=n, false) + let rightMatched = Array.make(~length=m, false) + let shared = ref(0) + let x = ref(0) + let y = ref(0) + while x.contents < n && y.contents < m { + let token = left.significant->Array.getUnsafe(x.contents) + if token === right.significant->Array.getUnsafe(y.contents) { + leftMatched[x.contents] = true + rightMatched[y.contents] = true + if isAtom(token) { + shared := shared.contents + 1 + } + x := x.contents + 1 + y := y.contents + 1 + } else if ( + table->Array.getUnsafe((x.contents + 1) * stride + y.contents) >= + table->Array.getUnsafe(x.contents * stride + y.contents + 1) + ) { + x := x.contents + 1 + } else { + y := y.contents + 1 + } + } + // Parentheses/indentation alone must not make unrelated expressions a pair. + let largest = Math.Int.max(left.atoms, right.atoms) + if shared.contents === 0 || shared.contents * 2 < largest { + None + } else { + Some({ + before: parts(left, leftMatched), + after: parts(right, rightMatched), + score: shared.contents * 100 / largest, + }) + } + } +} + +let tokenRows = tokens => + tokens->Array.reduce(0, (sum, tokens) => + sum + + switch tokens { + | Some({significant, atoms}) if atoms > 0 => Array.length(significant) + 1 + | Some(_) | None => 0 + } + ) + +let highlight = (lines: array): highlights => { + let result = {before: Dict.make(), after: Dict.make()} + let budget = ref(maxCells) + let index = ref(0) + while index.contents < Array.length(lines) { + if (lines->Array.getUnsafe(index.contents)).kind === Same { + index := index.contents + 1 + } else { + let start = index.contents + while ( + index.contents < Array.length(lines) && + (lines->Array.getUnsafe(index.contents)).kind !== Same + ) { + index := index.contents + 1 + } + if index.contents - start <= maxBlockLines && budget.contents > 0 { + let block = Array.slice(lines, ~start, ~end=index.contents) + let before = block->Array.filter(line => line.kind === Removed) + let after = block->Array.filter(line => line.kind === Added) + let n = Array.length(before) + let m = Array.length(after) + let left = before->Array.map(line => tokenize(line.text)) + let right = after->Array.map(line => tokenize(line.text)) + if n > 0 && m > 0 && tokenRows(left) * tokenRows(right) <= budget.contents { + let candidates = Array.make(~length=n * m, None) + for i in 0 to n - 1 { + for j in 0 to m - 1 { + candidates[ + i * m + j + ] = switch (left->Array.getUnsafe(i), right->Array.getUnsafe(j)) { + | (Some(left), Some(right)) => compareTokens(left, right, budget) + | _ => None + } + } + } + // Best ordered pairing, allowing either side to skip whole lines. + let stride = m + 1 + let scores = Array.make(~length=(n + 1) * stride, 0) + let i = ref(n - 1) + while i.contents >= 0 { + let j = ref(m - 1) + while j.contents >= 0 { + let cell = i.contents * stride + j.contents + let skip = Math.Int.max( + scores->Array.getUnsafe(cell + stride), + scores->Array.getUnsafe(cell + 1), + ) + scores[cell] = switch candidates->Array.getUnsafe(i.contents * m + j.contents) { + | Some(pair) => + Math.Int.max(skip, pair.score + scores->Array.getUnsafe(cell + stride + 1)) + | None => skip + } + j := j.contents - 1 + } + i := i.contents - 1 + } + let x = ref(0) + let y = ref(0) + while x.contents < n && y.contents < m { + let cell = x.contents * stride + y.contents + switch candidates->Array.getUnsafe(x.contents * m + y.contents) { + | Some(pair) + if pair.score + scores->Array.getUnsafe(cell + stride + 1) === + scores->Array.getUnsafe(cell) => + switch ( + (before->Array.getUnsafe(x.contents)).before, + (after->Array.getUnsafe(y.contents)).after, + ) { + | (Some(before), Some(after)) => + Dict.set(result.before, Int.toString(before), pair.before) + Dict.set(result.after, Int.toString(after), pair.after) + | _ => () + } + x := x.contents + 1 + y := y.contents + 1 + | _ => + if scores->Array.getUnsafe(cell + stride) >= scores->Array.getUnsafe(cell + 1) { + x := x.contents + 1 + } else { + y := y.contents + 1 + } + } + } + } + } + } + } + result +} + +let forLine = (highlights: highlights, line: LambdaDiff.line) => + switch line.kind { + | Same => None + | Removed => + line.before->Option.flatMap(number => Dict.get(highlights.before, Int.toString(number))) + | Added => line.after->Option.flatMap(number => Dict.get(highlights.after, Int.toString(number))) + } diff --git a/packages/dev-playground/src/LambdaView.res b/packages/dev-playground/src/LambdaView.res new file mode 100644 index 00000000000..540357086a9 --- /dev/null +++ b/packages/dev-playground/src/LambdaView.res @@ -0,0 +1,173 @@ +type mode = Before | After | Diff +type input = {before: string, after: option} + +let sameInput = (left, right) => + switch (left, right) { + | (None, None) => true + | (Some(left), Some(right)) => left.before === right.before && left.after === right.after + | (None, Some(_)) | (Some(_), None) => false + } + +let label = mode => + switch mode { + | Before => "Before optimization" + | After => "After optimization" + | Diff => "Diff" + } + +let renderLine = (highlights, line: LambdaDiff.line) => { + let (className, marker) = switch line.kind { + | Same => ("lambda-diff-line", " ") + | Removed => ("lambda-diff-line lambda-diff-removed", "โˆ’") + | Added => ("lambda-diff-line lambda-diff-added", "+") + } + let number = number => number->Option.map(value => Int.toString(value))->Option.getOr("") + + {View.text(number(line.before))} + {View.text(number(line.after))} + {View.text(marker)} + + {switch LambdaInlineDiff.forLine(highlights, line) { + | None => View.text(line.text) + | Some(parts) => + View.fragment( + parts->Array.map(part => + part.changed + ? {View.text(part.text)} + : View.text(part.text) + ), + ) + }} + {View.text("\n")} + + +} + +let renderLines = (highlights, lines) => + View.fragment(Array.map(lines, line => renderLine(highlights, line))) + +let renderDiff = (contextState, result) => + switch result { + | LambdaDiff.Identical => +

+ {View.text("No changes in the printed Lambda after optimization.")} +

+ | TooLarge => +

+ {View.text( + "This Lambda comparison exceeds the diff work limit. Use Before optimization and After optimization to inspect the full dumps.", + )} +

+ | Changes(lines) => + let highlights = LambdaInlineDiff.highlight(lines) + let previous = contextState.contents + let current = Dict.make() + let occurrences = Dict.make() + let sections = LambdaDiff.sections(lines)->Array.map(section => + switch section { + | Visible(lines) =>
 {renderLines(highlights, lines)} 
+ | Collapsed(lines) => + let text = lines->Array.map(line => line.text)->Array.join("\n") + let occurrence = Dict.get(occurrences, text)->Option.getOr(0) + Dict.set(occurrences, text, occurrence + 1) + let key = `${occurrence->Int.toString}:${text}` + let expanded = switch Dict.get(previous, key) { + | Some(expanded) => expanded + | None => Signal.make(false) + } + Dict.set(current, key, expanded) +
Signal.get(expanded) ? Some("") : None)]} + > + { + event->Event.preventDefault + Signal.update(expanded, open_ => !open_) + }} + > + {View.text(`Show ${Array.length(lines)->Int.toString} unchanged lines`)} + + {View.tracked(() => + Signal.get(expanded) + ?
 {renderLines(highlights, lines)} 
+ : View.empty() + )} +
+ } + ) + // Retain expansion only for regions still present in this comparison. + contextState := current +
+

+ {View.text( + "โˆ’ removed from Before ยท + added in After. Stronger shading marks changed tokens in similar lines. Line numbers show Before / After.", + )} +

+ {View.fragment(sections)} +
+ } + +@jsx.component +let make = (~input: unit => option, ~visible, ~mode: Signal.t) => { + let contextState = ref(Dict.make()) + let unavailable = () => +

+ {View.text( + "This compiler bundle does not provide optimized Lambda. Select a compiler with playground API v9 or newer.", + )} +

+ // Cache the complete rendered diff by text, including inline highlights and + // sections. Hidden tabs keep their last result and defer new comparison work. + let cached = ref((None, View.empty())) + let difference = Computed.make(() => { + let (previous, rendered) = cached.contents + let current = visible() && Signal.get(mode) === Diff ? input() : previous + if sameInput(previous, current) { + rendered + } else { + let rendered = switch current { + | Some({before, after: Some(after)}) => + renderDiff(contextState, LambdaDiff.compare(before, after)) + | Some({after: None}) => unavailable() + | None => View.empty() + } + cached := (current, rendered) + rendered + } + }) +
visible() ? "lambda-view" : "lambda-view hidden-panel"}> +
+ {View.fragment( + [Before, After, Diff]->Array.map(tab => + + ), + )} +
+
 Signal.get(mode) === Before ? "output" : "output hidden-panel"}>
+      {View.signalText(() => input()->Option.map(value => value.before)->Option.getOr(""))}
+    
+
Signal.get(mode) === After ? "" : "hidden-panel"}> + {View.tracked(() => + switch input()->Option.flatMap(value => value.after) { + | Some(after) =>
 {View.text(after)} 
+ | None => unavailable() + } + )} +
+
Signal.get(mode) === Diff ? "" : "hidden-panel"}> + {View.tracked(() => { + // Reuse the DOM too when reactive dependencies are reevaluated. + let child = Signal.get(difference) + View.Keyed({key: "lambda-diff", identity: Obj.magic(child), child}) + })} +
+
+} diff --git a/packages/dev-playground/src/Main.res b/packages/dev-playground/src/Main.res index d7e97f67a35..4e13e9b3e44 100644 --- a/packages/dev-playground/src/Main.res +++ b/packages/dev-playground/src/Main.res @@ -814,11 +814,19 @@ module App = { let make = () => { let source = Signal.make(defaultSource) let activeTab = Signal.make(JavaScript) + let lambdaMode = Signal.make(LambdaView.Before) let mappedSourcePosition: Signal.t> = Signal.make(None) let mappedGeneratedPosition: Signal.t> = Signal.make(None) let status = Signal.make(Loading) let compilerInfo: Signal.t> = Signal.make(None) let compileResult: Signal.t> = Signal.make(None) + let lambdaInput = () => + switch Signal.get(compileResult) { + | Some({result: Ok(result)}) => + Some({LambdaView.before: result.lambda, after: result.lambdaOptimized}) + | Some({result: Error(_)}) | None => None + } + let lambdaVisible = () => Signal.get(activeTab) === Lambda && lambdaInput()->Option.isSome let config = Signal.make(CompilerApi.defaultConfig) let visibleTabNodes: Signal.t> = Obj.magic( Computed.make(() => @@ -1327,18 +1335,22 @@ module App = { {View.signalText(() => resultSummary(Signal.get(compileResult)))}
-
-                {View.tracked(() => {
-                  let selectedTab = Signal.get(activeTab)
-                  interactiveOutputNode(
-                    Signal.get(compileResult),
-                    Signal.get(source),
-                    selectedTab,
-                    Signal.get(mappedGeneratedPosition),
-                    revealOriginalMapping,
-                    () => Signal.set(activeTab, SourceMap),
-                  )
-                })}
+              
+              
 lambdaVisible() ? "output hidden-panel" : "output"}>
+                {View.tracked(() =>
+                  if lambdaVisible() {
+                    View.empty()
+                  } else {
+                    interactiveOutputNode(
+                      Signal.get(compileResult),
+                      Signal.get(source),
+                      Signal.get(activeTab),
+                      Signal.get(mappedGeneratedPosition),
+                      revealOriginalMapping,
+                      () => Signal.set(activeTab, SourceMap),
+                    )
+                  }
+                )}
               
diff --git a/packages/dev-playground/src/styles.css b/packages/dev-playground/src/styles.css index 03b7556adb4..f30f923bd2d 100644 --- a/packages/dev-playground/src/styles.css +++ b/packages/dev-playground/src/styles.css @@ -468,6 +468,106 @@ button { background: var(--playground-panel); } +.lambda-subtabs { + position: sticky; + top: 0; + z-index: 2; + display: flex; + flex-wrap: wrap; + gap: 4px; + padding: 8px 16px; + background: var(--playground-panel); + border-bottom: 1px solid var(--playground-border-soft); +} + +.lambda-subtab { + padding: 6px 10px; + border: 1px solid transparent; + border-radius: 4px; + background: transparent; + color: var(--playground-text-secondary); +} + +.lambda-subtab:hover, +.lambda-subtab-active { + color: var(--playground-text-primary); + background: var(--playground-hover); +} + +.lambda-subtab-active { + border-color: var(--fire); +} + +.lambda-subtab:focus-visible { + outline: 2px solid var(--fire); + outline-offset: 1px; +} + +.lambda-notice { + margin: 0; + padding: 12px 18px; +} + +.lambda-diff-block { + margin: 0; + color: var(--playground-text-primary); + font: 13px / 1.55 var(--font-mono); + tab-size: 2; +} + +.lambda-diff-line { + display: grid; + grid-template-columns: 5ch 5ch 2ch minmax(0, 1fr); +} + +.lambda-diff-number { + color: var(--gray-60); + text-align: right; + padding-right: 1ch; + user-select: none; +} + +.lambda-diff-text { + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.lambda-diff-removed { + background: rgba(230, 72, 79, 0.12); +} + +.lambda-diff-added { + background: rgba(56, 139, 114, 0.15); +} + +.lambda-diff-token { + border-radius: 2px; + box-decoration-break: clone; + -webkit-box-decoration-break: clone; +} + +.lambda-diff-removed .lambda-diff-token { + background: rgba(230, 72, 79, 0.38); +} + +.lambda-diff-added .lambda-diff-token { + background: rgba(56, 139, 114, 0.45); +} + +.lambda-diff-removed .lambda-diff-marker { + color: var(--fire); +} + +.lambda-diff-added .lambda-diff-marker { + color: var(--turtle-dark); +} + +.lambda-diff-context > summary { + cursor: pointer; + padding: 6px 18px; + background: var(--playground-hover); +} + .result-meta { min-height: 36px; padding: 9px 16px; diff --git a/packages/playground/playground_test.cjs b/packages/playground/playground_test.cjs index 6dc64bcf4f4..c66b914caf0 100644 --- a/packages/playground/playground_test.cjs +++ b/packages/playground/playground_test.cjs @@ -163,3 +163,24 @@ assert.equal(disabledResult.source_map, undefined); assert.doesNotMatch(disabledResult.js_code, /\/\/# sourceMappingURL=/); console.log("-- Playground source map test complete --"); + +assert.equal(rescript_compiler.api_version, "9"); +const lambdaSource = "let compute = x => { let unused = x + 1; x }"; +const lambdaResult = compiler.rescript.compileWithDebug(lambdaSource); +assert.equal(lambdaResult.type, "success"); +assert.match(lambdaResult.lambda, /unused\//); +assert.equal(typeof lambdaResult.lambda_optimized, "string"); +assert.doesNotMatch(lambdaResult.lambda_optimized, /unused\//); +assert.match(lambdaResult.lambda_optimized, /function x\/\d+ x\/\d+/); + +const regularLambdaResult = compiler.rescript.compile(lambdaSource); +assert.equal(regularLambdaResult.type, "success"); +assert.equal(regularLambdaResult.js_code, lambdaResult.js_code); +assert.equal(regularLambdaResult.lambda_optimized, undefined); + +const nextLambdaResult = compiler.rescript.compileWithDebug("let answer = 42"); +assert.equal(nextLambdaResult.type, "success"); +assert.equal(nextLambdaResult.lambda_optimized.trim(), "(makeblock module/exports 42)"); +assert.doesNotMatch(nextLambdaResult.lambda_optimized, /compute/); + +console.log("-- Playground optimized Lambda test complete --");