From cd91af8ff7b2e26e01de6a7507f4bb3bb2c23058 Mon Sep 17 00:00:00 2001
From: Florian Hammerschmidt
Date: Wed, 9 Sep 2026 15:13:28 +0200
Subject: [PATCH 1/6] Developer playground: Add lambda views before and after
optimisation, including diffs
Signed-off-by: Florian Hammerschmidt
---
CHANGELOG.md | 1 +
compiler/core/lam_compile_main.ml | 6 +-
compiler/core/lam_compile_main.mli | 4 +
compiler/jsoo/jsoo_playground_main.ml | 25 +-
packages/dev-playground/package.json | 2 +-
.../scripts/lambda-diff.test.mjs | 241 ++++++++++++++++++
packages/dev-playground/src/Bindings.res | 1 +
packages/dev-playground/src/CompilerApi.res | 14 +-
packages/dev-playground/src/CompilerApi.resi | 1 +
packages/dev-playground/src/LambdaDiff.res | 161 ++++++++++++
.../dev-playground/src/LambdaInlineDiff.res | 220 ++++++++++++++++
packages/dev-playground/src/LambdaView.res | 116 +++++++++
packages/dev-playground/src/Main.res | 35 ++-
packages/dev-playground/src/styles.css | 100 ++++++++
packages/playground/playground_test.cjs | 21 ++
15 files changed, 929 insertions(+), 19 deletions(-)
create mode 100644 packages/dev-playground/scripts/lambda-diff.test.mjs
create mode 100644 packages/dev-playground/src/LambdaDiff.res
create mode 100644 packages/dev-playground/src/LambdaInlineDiff.res
create mode 100644 packages/dev-playground/src/LambdaView.res
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c2774158f0f..7091f433944 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -122,6 +122,7 @@
#### :rocket: New Feature
+- Add Before optimization, After optimization, and Diff views to the developer playground's Lambda tab, with a dependency-free, work-limited diff, inline highlighting of changed Lambda tokens, and optimized Lambda output in playground API v9.
- Support UTF-16 surrogate-pair escapes such as `"\uD83D\uDE00"` in ordinary string literals. https://github.com/rescript-lang/rescript/pull/8606
- Support dynamic imports of external bindings annotated with `@scope`; the generated import follows the complete property path. These imports were previously rejected. https://github.com/rescript-lang/rescript/pull/8582
- Add `@res.hoistedFunction` for emitting nested module functions as flat JavaScript exports. https://github.com/rescript-lang/rescript/pull/8402
diff --git a/compiler/core/lam_compile_main.ml b/compiler/core/lam_compile_main.ml
index cada1a101d8..1c27804fc4d 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.current ()).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 f19f783442c..b66c568ffb3 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
@@ -670,8 +671,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
@@ -728,7 +739,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 d4ca941b9f3..d37873ebe66 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..b1874157c8d
--- /dev/null
+++ b/packages/dev-playground/scripts/lambda-diff.test.mjs
@@ -0,0 +1,241 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import {
+ compare,
+ maxCharacters,
+ maxLines,
+ sections,
+} from "../src/LambdaDiff.res.mjs";
+import * as Inline from "../src/LambdaInlineDiff.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(2000);
+ 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.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 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);
+});
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..14848a4bfe4
--- /dev/null
+++ b/packages/dev-playground/src/LambdaDiff.res
@@ -0,0 +1,161 @@
+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
+ if n + m > 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 {
+ result->Array.push(Visible(Array.slice(lines, ~start, ~end=head)))
+ result->Array.push(Collapsed(Array.slice(lines, ~start=head, ~end=tail)))
+ 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..049a63d85f2
--- /dev/null
+++ b/packages/dev-playground/src/LambdaInlineDiff.res
@@ -0,0 +1,220 @@
+// Inline refinement runs only inside consecutive Removed/Added line blocks.
+// For each candidate line pair, an LCS of non-whitespace Lambda tokens 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, and block size caps line-pairing
+// work, so this refinement cannot introduce unbounded quadratic comparisons.
+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
+ table[cell] = if (
+ left.significant->Array.getUnsafe(i.contents) ===
+ right.significant->Array.getUnsafe(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 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 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)
+ if n > 0 && m > 0 {
+ let left = before->Array.map(line => tokenize(line.text))
+ let right = after->Array.map(line => tokenize(line.text))
+ 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..698a41a1d91
--- /dev/null
+++ b/packages/dev-playground/src/LambdaView.res
@@ -0,0 +1,116 @@
+type mode = Before | After | Diff
+
+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 = 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)
+
+
+ {View.text(
+ "โ removed from Before ยท + added in After. Stronger shading marks changed tokens in similar lines. Line numbers show Before / After.",
+ )}
+