diff --git a/CHANGELOG.md b/CHANGELOG.md index f1844c4b0b..980b2ceef0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,7 +61,7 @@ - Preserve trailing comments between the type and `=` in locally abstract value constraints (`let f: type a. t /* comment */ = value`). https://github.com/rescript-lang/rescript/pull/8575 - Enforce function arity in interface/module inclusion and type coercion. Previously a curried implementation (e.g. `int => int => int`) could satisfy an uncurried interface (`(int, int) => int`) or be coerced to it, which could miscompile calls made through the interface type. Such mismatches are now compile errors with an explanatory hint. https://github.com/rescript-lang/rescript/pull/8559 - Fix termination-analysis false positives for functions whose progress flows through un-annotated helpers: collecting the callees of a function binding was accidentally disabled in 2024 (the collection guard required a node shape that uncurried code never produces), so helpers calling `@progress` functions were no longer added to the function table. https://github.com/rescript-lang/rescript/pull/8568 -- Fix default values of optional parameters being computed at the wrong time for curried functions: in `(~x=default, y) => (~z=default, w) => ...`, `x`'s default was only computed when the *inner* function was applied. Each default is now computed when its own parameter group is applied. https://github.com/rescript-lang/rescript/pull/8568 +- Fix default values of optional parameters being computed at the wrong time for curried functions: in `(~x=default, y) => (~z=default, w) => ...`, `x`'s default was only computed when the _inner_ function was applied. Each default is now computed when its own parameter group is applied. https://github.com/rescript-lang/rescript/pull/8568 - Fix bare labeled arrow types (`~x: int => string`) getting no arity: they printed identically to their parenthesized form (`(~x: int) => string`) but did not unify with it. https://github.com/rescript-lang/rescript/pull/8563 - Fix losses of fidelity when code passes through an external PPX: the internal `@res.async` marker no longer leaks into the program, attributes on an arrow type or on an `await` expression are no longer dropped or relocated (previously this could crash the formatter), JSX elements keep their closing tag, and PPX-emitted OCaml-style `function` is desugared instead of crashing the compiler. https://github.com/rescript-lang/rescript/pull/8561 - Preserve multibyte characters when wrapping long source lines in compiler code frames. https://github.com/rescript-lang/rescript/pull/8520 @@ -79,6 +79,7 @@ #### :house: Internal +- 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 - Developer playground: Make panes resizable with wrapping text. https://github.com/rescript-lang/rescript/pull/8628 - Normalize Lambda terms where they are built: a match guard stays structured data until its fallthrough is known, and `apply` and `mk_builtin` go through the folding constructors. https://github.com/rescript-lang/rescript/pull/8615 - Replace non-escaping local mutable blocks with scalar bindings when all uses are direct field accesses, generalizing reference unboxing to multi-field records and references captured by JavaScript closures. https://github.com/rescript-lang/rescript/pull/8617 diff --git a/compiler/core/lam_compile_main.ml b/compiler/core/lam_compile_main.ml index 4aee81b8c0..d11c9f64fc 100644 --- a/compiler/core/lam_compile_main.ml +++ b/compiler/core/lam_compile_main.ml @@ -260,7 +260,8 @@ let required_modules (lam : Lambda.t) : 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 @@ -334,6 +335,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 c62ff0dca4..cbff2fbf5a 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 8e36ce3a19..758228e3d5 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 @@ -667,8 +668,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 @@ -725,7 +736,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 e9f0c2b630..f38baf9e01 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 0000000000..b1874157c8 --- /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 edf48d1525..ecca8b32b7 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 b9f232dcc4..de3daa15e3 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 5535d15e04..ee857fc33d 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 0000000000..14848a4bfe --- /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 0000000000..049a63d85f --- /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 0000000000..698a41a1d9 --- /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.", + )} +

+ {View.fragment( + LambdaDiff.sections(lines)->Array.map(section => + switch section { + | Visible(lines) => +
 {renderLines(highlights, lines)} 
+ | Collapsed(lines) => +
+ + {View.text(`Show ${Array.length(lines)->Int.toString} unchanged lines`)} + +
 {renderLines(highlights, lines)} 
+
+ } + ), + )} +
+ } + +@jsx.component +let make = (~before, ~after: option, ~mode: Signal.t) => { + let difference = Lazy.make(() => + switch after { + | Some(after) => LambdaDiff.compare(before, after) + | None => LambdaDiff.Identical + } + ) +
+
+ {View.fragment( + [Before, After, Diff]->Array.map(tab => + + ), + )} +
+ {View.tracked(() => + switch (Signal.get(mode), after) { + | (Before, _) =>
 {View.text(before)} 
+ | (After | Diff, None) => +

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

+ | (After, Some(after)) =>
 {View.text(after)} 
+ | (Diff, Some(_)) => renderDiff(Lazy.get(difference)) + } + )} +
+} diff --git a/packages/dev-playground/src/Main.res b/packages/dev-playground/src/Main.res index 9a37f0ad1b..fc70a64149 100644 --- a/packages/dev-playground/src/Main.res +++ b/packages/dev-playground/src/Main.res @@ -827,6 +827,7 @@ 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) @@ -1351,19 +1352,27 @@ 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),
-                  )
-                })}
-              
+ {View.tracked(() => { + let selectedTab = Signal.get(activeTab) + let snapshot = Signal.get(compileResult) + switch (selectedTab, snapshot) { + | (Lambda, Some({result: Ok(result)})) => + + | _ => +
+                    {interactiveOutputNode(
+                      snapshot,
+                      Signal.get(source),
+                      selectedTab,
+                      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 03b7556adb..f30f923bd2 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/README.md b/packages/playground/README.md index 1ef71110a7..0f76d6ceef 100644 --- a/packages/playground/README.md +++ b/packages/playground/README.md @@ -54,3 +54,15 @@ comp.rescript.compile("let a =
"); ``` The script above will be able to successfully compile code using Belt and React, since both libraries were injected into the compiler's state. + +## 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. diff --git a/packages/playground/playground_test.cjs b/packages/playground/playground_test.cjs index 6dc64bcf4f..c66b914caf 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 --");