diff --git a/README.md b/README.md
index 212c535..8c78459 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,62 @@
# ldiff — lexer-based diff for MoonBit code
-playground: https://moonbit-community.github.io/ldiff/
\ No newline at end of file
+playground: https://moonbit-community.github.io/ldiff/
+
+`ldiff` separates diff calculation from presentation. The root package
+tokenizes MoonBit, aligns lines and tokens, groups hunks, and returns a public
+renderer-neutral `DiffDocument`. HTML and unified patch text live in dedicated
+packages that consume the same calculated document.
+
+```text
+moonbit-community/ldiff calculation and public diff IR
+moonbit-community/ldiff/html split and unified HTML rendering
+moonbit-community/ldiff/text unified patch text rendering
+```
+
+## Usage
+
+Add the packages needed by the caller. An explicit alias keeps the ldiff HTML
+renderer distinct from other packages commonly named `html`:
+
+```moon.pkg
+import {
+ "moonbit-community/ldiff",
+ "moonbit-community/ldiff/html" @ldiff_html,
+ "moonbit-community/ldiff/text" @ldiff_text,
+}
+```
+
+Calculate once and select any renderer:
+
+```mbt
+let document = @ldiff.diff(
+ old=["let total = price"],
+ new=["let total = price + tax"],
+ context=3,
+)
+let split = @ldiff_html.render_side_by_side(document, line_numbers=true)
+let unified = @ldiff_html.render_unified(document)
+let patches = @ldiff_text.render_unified_hunks(document)
+```
+
+Use `@ldiff.line_diff` for plain text. It performs a Patience line diff without
+MoonBit tokenization, semantic cleanup, or intraline highlights. The existing
+convenience signatures remain available in their renderer packages, for
+example `@ldiff_html.side_by_side_html(old~, new~)` and
+`@ldiff_text.unified_hunks(old~, new~)`.
+
+## Migration from the single root package
+
+The calculation and rendered bytes are unchanged, but rendering names moved:
+
+| Previous name | New name |
+| --- | --- |
+| `@ldiff.side_by_side_html` and other `*_html` functions | `@ldiff_html.side_by_side_html` and the corresponding HTML function |
+| `@ldiff.html_page` | `@ldiff_html.html_page` |
+| `@ldiff.HunkNote` | `@ldiff_html.HunkNote` |
+| `@ldiff.unified_hunks` | `@ldiff_text.unified_hunks` |
+| `@ldiff.unified_line_hunks` | `@ldiff_text.unified_line_hunks` |
+
+The root package continues to expose `TokKind`, `Tok`, `weight`,
+`tokenize_line`, and `similarity`, and now also exposes `diff`, `line_diff`,
+and the `DiffDocument` IR types.
diff --git a/cleanup_test.mbt b/cleanup_test.mbt
index 04f45d0..5d7ef3d 100644
--- a/cleanup_test.mbt
+++ b/cleanup_test.mbt
@@ -25,17 +25,16 @@ fn count_substring(text : String, needle : String) -> Int {
///|
test "token cleanup highlights the natural repeated-token boundary" {
- let split = @ldiff.side_by_side_html(old=["x x"], new=["x"], context=0)
- inspect(
+ let split = @ldiff_html.side_by_side_html(old=["x x"], new=["x"], context=0)
+ assert_true(
split.contains(
"
x x | x | ",
),
- content="true",
)
- let comment = @ldiff.unified_html(old=["// foo foo"], new=["// foo"])
- inspect(comment.contains("// foo foo"), content="true")
- let unicode = @ldiff.side_by_side_html(old=["名 名"], new=["名"])
- inspect(unicode.contains("名 名"), content="true")
+ let comment = @ldiff_html.unified_html(old=["// foo foo"], new=["// foo"])
+ assert_true(comment.contains("// foo foo"))
+ let unicode = @ldiff_html.side_by_side_html(old=["名 名"], new=["名"])
+ assert_true(unicode.contains("名 名"))
}
///|
@@ -45,22 +44,27 @@ test "global traceback fixes left-leaning insertion in both renderers" {
" if candidate.normalized_expr_identifier_name() is Some(candidate_name) {",
]
let expected = " if candidate.normalized_expr_identifier_name() is Some(candidate_name) {"
- let split = @ldiff.side_by_side_html(
+ let split = @ldiff_html.side_by_side_html(
+ old~,
+ new~,
+ context=0,
+ line_cleanup=false,
+ )
+ let unified = @ldiff_html.unified_html(
old~,
new~,
context=0,
line_cleanup=false,
)
- let unified = @ldiff.unified_html(old~, new~, context=0, line_cleanup=false)
assert_true(split.contains(expected))
assert_true(unified.contains(expected))
assert_eq(
split,
- @ldiff.side_by_side_html(old~, new~, context=0, line_cleanup=true),
+ @ldiff_html.side_by_side_html(old~, new~, context=0, line_cleanup=true),
)
assert_eq(
unified,
- @ldiff.unified_html(old~, new~, context=0, line_cleanup=true),
+ @ldiff_html.unified_html(old~, new~, context=0, line_cleanup=true),
)
}
@@ -68,49 +72,48 @@ test "global traceback fixes left-leaning insertion in both renderers" {
test "line cleanup is opt-in and removes a misleading blank anchor" {
let old = ["let a = old", "", "let b = old", "let c = old"]
let new = ["let a = new", "let b = new", "let c = new", ""]
- let default_split = @ldiff.side_by_side_html(old~, new~, context=0)
+ let default_split = @ldiff_html.side_by_side_html(old~, new~, context=0)
assert_eq(
default_split,
- @ldiff.side_by_side_html(old~, new~, context=0, line_cleanup=false),
- )
- inspect(
- count_substring(default_split, "class=\"hunk-header\"") == 2,
- content="true",
+ @ldiff_html.side_by_side_html(old~, new~, context=0, line_cleanup=false),
)
- let cleaned = @ldiff.side_by_side_html(
+ assert_true(count_substring(default_split, "class=\"hunk-header\"") == 2)
+ let cleaned = @ldiff_html.side_by_side_html(
old~,
new~,
context=0,
line_cleanup=true,
)
- inspect(
+ assert_true(
cleaned.contains(
"let b = old | let b = new | ",
),
- content="true",
- )
- inspect(
- count_substring(cleaned, "class=\"hunk-header\"") == 1,
- content="true",
)
+ assert_true(count_substring(cleaned, "class=\"hunk-header\"") == 1)
}
///|
test "re-aligned identical meaningful line is rendered as context" {
let old = ["let a = old", "same line", "let b = old", "}", "let c = old", "}"]
let new = ["let a = new", "}", "same line", "let b = new", "let c = new", "}"]
- let split = @ldiff.side_by_side_html(old~, new~, context=1, line_cleanup=true)
- inspect(
+ let split = @ldiff_html.side_by_side_html(
+ old~,
+ new~,
+ context=1,
+ line_cleanup=true,
+ )
+ assert_true(
split.contains(
"same line | same line | ",
),
- content="true",
)
- let unified = @ldiff.unified_html(old~, new~, context=1, line_cleanup=true)
- inspect(
- unified.contains(" same line"),
- content="true",
+ let unified = @ldiff_html.unified_html(
+ old~,
+ new~,
+ context=1,
+ line_cleanup=true,
)
+ assert_true(unified.contains(" same line"))
}
///|
@@ -147,13 +150,23 @@ test "patience keeps an unchanged branch ahead of repeated tuple boilerplate" {
.map(StringView::to_owned)
.collect()
let marker = " Some({ review: Some({ baseline: ReviewMissing, .. }), .. }) =>"
- let split = @ldiff.side_by_side_html(old~, new~, context=1, line_cleanup=true)
+ let split = @ldiff_html.side_by_side_html(
+ old~,
+ new~,
+ context=1,
+ line_cleanup=true,
+ )
assert_true(
split.contains(
"\{marker} | \{marker} | ",
),
)
- let unified = @ldiff.unified_html(old~, new~, context=1, line_cleanup=true)
+ let unified = @ldiff_html.unified_html(
+ old~,
+ new~,
+ context=1,
+ line_cleanup=true,
+ )
assert_true(unified.contains(" \{marker}"))
assert_false(unified.contains("-\{marker}"))
assert_false(unified.contains("+\{marker}"))
@@ -171,13 +184,18 @@ test "both renderers preserve exact core hunk headers for all contexts" {
(99, ["@@ -1,10 +1,10 @@"]),
] {
let (context, headers) = case
- let split = @ldiff.side_by_side_html(
+ let split = @ldiff_html.side_by_side_html(
+ old~,
+ new~,
+ context~,
+ line_cleanup=true,
+ )
+ let unified = @ldiff_html.unified_html(
old~,
new~,
context~,
line_cleanup=true,
)
- let unified = @ldiff.unified_html(old~, new~, context~, line_cleanup=true)
assert_eq(count_substring(split, "class=\"hunk-header\""), headers.length())
assert_eq(
count_substring(unified, "class=\"hunk-header\""),
@@ -197,7 +215,7 @@ test "both renderers preserve exact core hunk headers for all contexts" {
///|
test "pure insertion and deletion retain file-edge hunk ranges" {
inspect(
- @ldiff.side_by_side_html(
+ @ldiff_html.side_by_side_html(
old=[],
new=["x", "y"],
context=0,
@@ -213,7 +231,12 @@ test "pure insertion and deletion retain file-edge hunk ranges" {
),
)
inspect(
- @ldiff.unified_html(old=["x", "y"], new=[], context=0, line_cleanup=true),
+ @ldiff_html.unified_html(
+ old=["x", "y"],
+ new=[],
+ context=0,
+ line_cleanup=true,
+ ),
content=(
#|
#|
@@ -231,12 +254,12 @@ fn assert_line_cleanup_budget_fallback(
new : Array[String],
) -> Unit raise {
assert_eq(
- @ldiff.side_by_side_html(old~, new~, context=0, line_cleanup=true),
- @ldiff.side_by_side_html(old~, new~, context=0, line_cleanup=false),
+ @ldiff_html.side_by_side_html(old~, new~, context=0, line_cleanup=true),
+ @ldiff_html.side_by_side_html(old~, new~, context=0, line_cleanup=false),
)
assert_eq(
- @ldiff.unified_html(old~, new~, context=0, line_cleanup=true),
- @ldiff.unified_html(old~, new~, context=0, line_cleanup=false),
+ @ldiff_html.unified_html(old~, new~, context=0, line_cleanup=true),
+ @ldiff_html.unified_html(old~, new~, context=0, line_cleanup=false),
)
}
@@ -281,23 +304,25 @@ test "line cleanup restores original grouping at all window budgets" {
test "per-pair traceback budget keeps pairing but omits highlights" {
let old_line = "x ".repeat(256) + "old"
let new_line = "x ".repeat(256) + "new"
- let split = @ldiff.side_by_side_html(
+ let split = @ldiff_html.side_by_side_html(
old=[old_line],
new=[new_line],
context=0,
)
- inspect(split.contains("\{old_line}| \{new_line} | ",
),
- content="true",
)
- let unified = @ldiff.unified_html(old=[old_line], new=[new_line], context=0)
- inspect(unified.contains(" Int {
let del = prev[j] + alignment_weight(a[i - 1])
let ins = cur[j - 1] + alignment_weight(b[j - 1])
let mut best = if del < ins { del } else { ins }
- match subst_cost(a[i - 1], b[j - 1]) {
- Some(c) => if prev[j - 1] + c < best { best = prev[j - 1] + c }
- None => ()
+ if subst_cost(a[i - 1], b[j - 1]) is Some(c) {
+ if prev[j - 1] + c < best {
+ best = prev[j - 1] + c
+ }
}
cur[j] = best
}
@@ -367,14 +368,14 @@ fn window_stream(lines : ArrayView[Array[Tok]]) -> WindowStream {
for line_index, tokens in lines {
for token_index, token in tokens {
if token.kind is Space {
- gap = gap + token.text
+ gap += token.text
} else {
atoms.push({ token, line: line_index, token_index, gap_before: gap })
gap = ""
}
}
if line_index + 1 < lines.length() {
- gap = gap + "\n"
+ gap += "\n"
}
}
{ atoms, trailing_gap: gap }
@@ -857,39 +858,35 @@ fn pair_trace(a : WindowStream, b : WindowStream, mode : PairMode) -> PairTrace
state,
1,
)
- } else {
- match subst_cost(a.atoms[i - 1].token, b.atoms[j - 1].token) {
- Some(cost) => {
- let changed = pair_changed_score(
- diagonal_source,
- state,
- a.atoms,
- b.atoms,
- a.trailing_gap,
- b.trailing_gap,
- mode,
- i - 1,
- j - 1,
- cost,
- )
- let candidate = {
- ..changed,
- gap: changed.gap +
- pair_diagonal_gap_score(mode, a.atoms, b.atoms, i - 1, j - 1),
- }
- let next_state = pair_changed_state(state, true, true)
- pair_consider(
- cur,
- pair_row_index(j, next_state),
- backpointers,
- pair_backpointer_index(i, j, width, next_state),
- candidate,
- state,
- 1,
- )
- }
- None => ()
+ } else if subst_cost(a.atoms[i - 1].token, b.atoms[j - 1].token)
+ is Some(cost) {
+ let changed = pair_changed_score(
+ diagonal_source,
+ state,
+ a.atoms,
+ b.atoms,
+ a.trailing_gap,
+ b.trailing_gap,
+ mode,
+ i - 1,
+ j - 1,
+ cost,
+ )
+ let candidate = {
+ ..changed,
+ gap: changed.gap +
+ pair_diagonal_gap_score(mode, a.atoms, b.atoms, i - 1, j - 1),
}
+ let next_state = pair_changed_state(state, true, true)
+ pair_consider(
+ cur,
+ pair_row_index(j, next_state),
+ backpointers,
+ pair_backpointer_index(i, j, width, next_state),
+ candidate,
+ state,
+ 1,
+ )
}
}
}
diff --git a/html.mbt b/html.mbt
deleted file mode 100644
index c700bde..0000000
--- a/html.mbt
+++ /dev/null
@@ -1,759 +0,0 @@
-// Copyright 2026 International Digital Economy Academy
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-// HTML rendering: a GitHub-style split view and a unified view, both with
-// intraline highlights taken from the alignment DP's own traceback.
-
-///|
-fn esc(s : String) -> String {
- let buf = StringBuilder::new()
- for c in s {
- match c {
- '&' => buf <+ "&"
- '<' => buf <+ "<"
- '>' => buf <+ ">"
- _ => buf.write_char(c)
- }
- }
- buf.to_string()
-}
-
-///|
-fn token_text_is_whitespace(token : Tok) -> Bool {
- if token.text is "" {
- return false
- }
- for c in token.text {
- if !c.is_whitespace() {
- return false
- }
- }
- true
-}
-
-///|
-fn write_escaped_tokens(
- buf : StringBuilder,
- run : Array[Tok],
- start : Int,
- end : Int,
-) -> Unit {
- for i in start.. Unit {
- let mut semantic_start = 0
- while semantic_start < run.length() &&
- token_text_is_whitespace(run[semantic_start]) {
- semantic_start += 1
- }
- if semantic_start == run.length() {
- write_escaped_tokens(buf, run, 0, run.length())
- } else {
- let mut semantic_end = run.length()
- while semantic_end > semantic_start &&
- token_text_is_whitespace(run[semantic_end - 1]) {
- semantic_end -= 1
- }
- write_escaped_tokens(buf, run, 0, semantic_start)
- buf <+ ""
- write_escaped_tokens(buf, run, semantic_start, semantic_end)
- buf <+ ""
- write_escaped_tokens(buf, run, semantic_end, run.length())
- }
- run.clear()
-}
-
-///|
-/// Strongly highlight every non-whitespace token in one present line. The
-/// shared changed-run renderer keeps leading and trailing whitespace outside
-/// the `` and escapes token text; blank and whitespace-only lines stay
-/// plain.
-fn full_line_html(tokens : ArrayView[Tok], class_name : String) -> String {
- let buf = StringBuilder::new()
- let run = tokens.to_owned()
- flush_changed_run(buf, run, class_name)
- buf.to_string()
-}
-
-///|
-/// Render one aligned pair's ops as (left row, right row) HTML. Adjacent
-/// changed tokens form one run. On each side independently, all-whitespace
-/// boundary tokens remain plain while the smallest semantic interval receives
-/// strong intraline emphasis; internal whitespace stays inside that interval.
-fn pair_row_html(ops : Array[Op]) -> (String, String) {
- let l = StringBuilder::new()
- let r = StringBuilder::new()
- let lrun : Array[Tok] = []
- let rrun : Array[Tok] = []
- fn flushes() {
- flush_changed_run(l, lrun, "wd")
- flush_changed_run(r, rrun, "wa")
- }
-
- for op in ops {
- match op {
- OEq(t) => {
- flushes()
- l <+ "\{esc(t.text)}"
- r <+ "\{esc(t.text)}"
- }
- OSub(a, b) => {
- lrun.push(a)
- rrun.push(b)
- }
- ODel(a) => lrun.push(a)
- OIns(b) => rrun.push(b)
- }
- }
- flushes()
- (l.to_string(), r.to_string())
-}
-
-///|
-/// Render one source line from a window traceback projection. A gap never
-/// opens a strong-emphasis run, but horizontal `Space` tokens between two
-/// changed real atoms stay inside the same run. Boundary whitespace remains
-/// plain through `flush_changed_run`, matching the legacy renderer.
-fn projected_line_html(
- tokens : ArrayView[Tok],
- changed : ArrayView[Bool],
- class_name : String,
-) -> String {
- let buf = StringBuilder::new()
- let run : Array[Tok] = []
- fn flush() {
- flush_changed_run(buf, run, class_name)
- }
-
- for index, token in tokens {
- if changed[index] {
- run.push(token)
- } else if token.kind is Space && run.length() > 0 {
- let mut next = index + 1
- while next < tokens.length() && tokens[next].kind is Space {
- next += 1
- }
- if next < tokens.length() && changed[next] {
- run.push(token)
- } else {
- flush()
- buf <+ "\{esc(token.text)}"
- }
- } else {
- flush()
- buf <+ "\{esc(token.text)}"
- }
- }
- flush()
- buf.to_string()
-}
-
-///|
-/// One renderer-neutral row in a prepared replacement block. `None` means an
-/// absent side; `Some("")` is a present blank line and must remain distinct.
-priv struct ReplacementRow {
- old_html : String?
- new_html : String?
- old_offset : Int?
- new_offset : Int?
-}
-
-///|
-/// Prepare replacement pairing and intraline markup once for both HTML
-/// views. A qualifying multi-line window projects the shared semantic
-/// traceback over every present line while `align` continues to determine row
-/// order. Rejected windows keep legacy token diffs for paired rows and strongly
-/// highlight the non-whitespace content of unpaired rows. The shared line
-/// alignment budgets still return plain, unpaired rows, and an oversized
-/// legacy pair remains paired but carries no `` highlight.
-fn prepare_replacement(
- old_lines : ArrayView[DiffLine],
- new_lines : ArrayView[DiffLine],
-) -> Array[ReplacementRow] {
- let rows : Array[ReplacementRow] = []
- match alignment_tokens(old_lines, new_lines, line => line.text) {
- None => {
- for offset, line in old_lines {
- rows.push({
- old_html: Some(esc(line.text)),
- new_html: None,
- old_offset: Some(offset),
- new_offset: None,
- })
- }
- for offset, line in new_lines {
- rows.push({
- old_html: None,
- new_html: Some(esc(line.text)),
- old_offset: None,
- new_offset: Some(offset),
- })
- }
- }
- Some(tokens) => {
- let changes = window_changes(tokens.olds, tokens.news)
- for pair in align(tokens.olds, tokens.news) {
- match pair {
- (Some(old_index), Some(new_index)) => {
- let (old_html, new_html) = match changes {
- Some(window) =>
- (
- projected_line_html(
- tokens.olds[old_index],
- window.old_changed[old_index],
- "wd",
- ),
- projected_line_html(
- tokens.news[new_index],
- window.new_changed[new_index],
- "wa",
- ),
- )
- None =>
- if !traceback_cells_within_limit(
- tokens.olds[old_index].length(),
- tokens.news[new_index].length(),
- ) {
- (
- esc(old_lines[old_index].text),
- esc(new_lines[new_index].text),
- )
- } else {
- pair_row_html(
- pair_ops(tokens.olds[old_index], tokens.news[new_index]),
- )
- }
- }
- rows.push({
- old_html: Some(old_html),
- new_html: Some(new_html),
- old_offset: Some(old_index),
- new_offset: Some(new_index),
- })
- }
- (Some(old_index), None) => {
- let old_html = match changes {
- Some(window) =>
- projected_line_html(
- tokens.olds[old_index],
- window.old_changed[old_index],
- "wd",
- )
- None => full_line_html(tokens.olds[old_index], "wd")
- }
- rows.push({
- old_html: Some(old_html),
- new_html: None,
- old_offset: Some(old_index),
- new_offset: None,
- })
- }
- (None, Some(new_index)) => {
- let new_html = match changes {
- Some(window) =>
- projected_line_html(
- tokens.news[new_index],
- window.new_changed[new_index],
- "wa",
- )
- None => full_line_html(tokens.news[new_index], "wa")
- }
- rows.push({
- old_html: None,
- new_html: Some(new_html),
- old_offset: None,
- new_offset: Some(new_index),
- })
- }
- (None, None) => ()
- }
- }
- }
- }
- rows
-}
-
-///|
-fn line_number_text(number : Int?) -> String {
- match number {
- Some(value) => value.to_string()
- None => ""
- }
-}
-
-///|
-fn hunk_note_at(notes : ArrayView[HunkNote?], hunk_index : Int) -> HunkNote? {
- match notes.get(hunk_index) {
- Some(note) => note
- None => None
- }
-}
-
-///|
-fn table_hunk_note(buf : StringBuilder, note : HunkNote, columns : Int) -> Unit {
- buf <+
- "| \{esc(note.title)}: \{esc(note.body)} |
\n"
-}
-
-///|
-fn pre_hunk_note(buf : StringBuilder, note : HunkNote) -> Unit {
- buf <+
- "\{esc(note.title)}: \{esc(note.body)}\n"
-}
-
-///|
-fn split_row(
- buf : StringBuilder,
- old_number : Int?,
- old_class : String,
- old_html : String,
- new_number : Int?,
- new_class : String,
- new_html : String,
- line_numbers : Bool,
-) -> Unit {
- if line_numbers {
- buf <+
- "| \{line_number_text(old_number)} | \{old_html} | \{line_number_text(new_number)} | \{new_html} |
\n"
- } else {
- buf <+
- "| \{old_html} | \{new_html} |
\n"
- }
-}
-
-///|
-fn split_replacement(
- buf : StringBuilder,
- old_lines : ArrayView[DiffLine],
- new_lines : ArrayView[DiffLine],
- old_start : Int,
- new_start : Int,
- line_numbers : Bool,
-) -> Unit {
- for prepared in prepare_replacement(old_lines, new_lines) {
- let (old_html, old_class) = match prepared.old_html {
- Some(body) => (body, "del")
- None => ("", "empty")
- }
- let (new_html, new_class) = match prepared.new_html {
- Some(body) => (body, "add")
- None => ("", "empty")
- }
- split_row(
- buf,
- prepared.old_offset.map(offset => old_start + offset + 1),
- old_class,
- old_html,
- prepared.new_offset.map(offset => new_start + offset + 1),
- new_class,
- new_html,
- line_numbers,
- )
- }
-}
-
-///|
-fn append_split_hunk(
- buf : StringBuilder,
- h : @diff.Hunk[DiffLine],
- hunk_index : Int,
- line_numbers : Bool,
- hunk_notes : ArrayView[HunkNote?],
-) -> Unit {
- if line_numbers {
- buf <+
- "
\n"
- } else {
- buf <+
- "
\n"
- }
- match hunk_note_at(hunk_notes, hunk_index) {
- Some(note) => table_hunk_note(buf, note, if line_numbers { 4 } else { 2 })
- None => ()
- }
- let edits = h.edits()
- let o = h.old_view()
- let n = h.new_view()
- let mut i = 0
- while i < edits.length() {
- match edits[i] {
- Delete(old_index~, old_len~, ..) if i + 1 < edits.length() &&
- edits[i + 1] is Insert(..) => {
- guard! edits[i + 1] is Insert(new_index~, new_len~, ..)
- split_replacement(
- buf,
- o.view(start=old_index, end=old_index + old_len),
- n.view(start=new_index, end=new_index + new_len),
- old_index,
- new_index,
- line_numbers,
- )
- i += 2
- }
- Equal(old_index~, new_index~, len~) => {
- for offset, l in o.view(start=old_index, end=old_index + len) {
- split_row(
- buf,
- Some(old_index + offset + 1),
- "ctx",
- esc(l.text),
- Some(new_index + offset + 1),
- "ctx",
- esc(l.text),
- line_numbers,
- )
- }
- i += 1
- }
- Delete(old_index~, old_len~, ..) => {
- for offset, l in o.view(start=old_index, end=old_index + old_len) {
- split_row(
- buf,
- Some(old_index + offset + 1),
- "del",
- esc(l.text),
- None,
- "empty",
- "",
- line_numbers,
- )
- }
- i += 1
- }
- Insert(new_index~, new_len~, ..) => {
- for offset, l in n.view(start=new_index, end=new_index + new_len) {
- split_row(
- buf,
- None,
- "empty",
- "",
- Some(new_index + offset + 1),
- "add",
- esc(l.text),
- line_numbers,
- )
- }
- i += 1
- }
- }
- }
-}
-
-///|
-/// GitHub-style split (side-by-side) view of a diff, as an HTML ``.
-/// Equal lines appear on both sides; replacement blocks are aligned by
-/// weighted similarity and their paired rows carry word-level highlights;
-/// unpaired lines leave the other cell empty. Style it with `html_page` or
-/// your own CSS (classes: split, hunk-header, ctx, del, add, empty, wd, wa,
-/// line-number, old-line-number, new-line-number).
-/// Set `line_cleanup=true` to reopen bounded low-information equal anchors;
-/// it is disabled by default. Set `line_numbers=true` for old-number,
-/// old-code, new-number, new-code columns; it is disabled by default so the
-/// historical two-column HTML remains byte-for-byte unchanged.
-/// `hunk_notes[index]` optionally inserts an escaped title and body directly
-/// below the corresponding zero-based hunk header.
-pub fn side_by_side_html(
- old~ : ArrayView[String],
- new~ : ArrayView[String],
- context? : Int = 3,
- line_cleanup? : Bool = false,
- line_numbers? : Bool = false,
- hunk_notes? : ArrayView[HunkNote?] = [],
-) -> String {
- let buf = StringBuilder::new()
- buf <+ "\n"
- for hunk_index, h in rendering_diff(old, new, line_cleanup).group(context~) {
- append_split_hunk(buf, h, hunk_index, line_numbers, hunk_notes)
- }
- buf <+ "
\n"
- buf.to_string()
-}
-
-///|
-/// Render each MoonBit-aware diff hunk as its own split HTML table. Array
-/// indexes match `unified_hunks` and the zero-based `hunk_notes` indexes.
-pub fn side_by_side_hunks_html(
- old~ : ArrayView[String],
- new~ : ArrayView[String],
- context? : Int = 3,
- line_cleanup? : Bool = false,
- line_numbers? : Bool = false,
- hunk_notes? : ArrayView[HunkNote?] = [],
-) -> Array[String] {
- let rendered : Array[String] = []
- for hunk_index, h in rendering_diff(old, new, line_cleanup).group(context~) {
- let buf = StringBuilder::new()
- buf <+ "\n"
- append_split_hunk(buf, h, hunk_index, line_numbers, hunk_notes)
- buf <+ "
\n"
- rendered.push(buf.to_string())
- }
- rendered
-}
-
-///|
-/// Wrap rendered diff HTML in a complete standalone page with default
-/// styling (light red/green rows, deeper word-level highlights).
-pub fn html_page(title~ : String, body : String) -> String {
- let buf = StringBuilder::new()
- buf <+ "\n"
- buf <+ "\{esc(title)}\n"
- buf <+ "\{body}"
- buf <+ "\n"
- buf.to_string()
-}
-
-///|
-fn unified_text_line(
- buf : StringBuilder,
- class_name : String,
- prefix : String,
- body : String,
-) -> Unit {
- buf <+ "\{prefix}\{body}\n"
-}
-
-///|
-fn unified_numbered_line(
- buf : StringBuilder,
- class_name : String,
- prefix : String,
- old_number : Int?,
- new_number : Int?,
- body : String,
-) -> Unit {
- buf <+
- "| \{line_number_text(old_number)} | \{line_number_text(new_number)} | \{prefix}\{body} |
\n"
-}
-
-///|
-fn append_unified_hunk(
- buf : StringBuilder,
- h : @diff.Hunk[DiffLine],
- hunk_index : Int,
- line_numbers : Bool,
- hunk_notes : ArrayView[HunkNote?],
-) -> Unit {
- if line_numbers {
- buf <+
- "
\n"
- } else {
- buf <+ "\n"
- }
- match hunk_note_at(hunk_notes, hunk_index) {
- Some(note) =>
- if line_numbers {
- table_hunk_note(buf, note, 3)
- } else {
- pre_hunk_note(buf, note)
- }
- None => ()
- }
- let edits = h.edits()
- let o = h.old_view()
- let n = h.new_view()
- let mut i = 0
- while i < edits.length() {
- match edits[i] {
- Delete(old_index~, old_len~, ..) if i + 1 < edits.length() &&
- edits[i + 1] is Insert(..) => {
- guard! edits[i + 1] is Insert(new_index~, new_len~, ..)
- let old_lines = o.view(start=old_index, end=old_index + old_len)
- let new_lines = n.view(start=new_index, end=new_index + new_len)
- let prepared = prepare_replacement(old_lines, new_lines)
- for row in prepared {
- match row.old_html {
- Some(body) =>
- if line_numbers {
- unified_numbered_line(
- buf,
- "del",
- "-",
- row.old_offset.map(offset => old_index + offset + 1),
- None,
- body,
- )
- } else {
- unified_text_line(buf, "del", "-", body)
- }
- None => ()
- }
- }
- for row in prepared {
- match row.new_html {
- Some(body) =>
- if line_numbers {
- unified_numbered_line(
- buf,
- "add",
- "+",
- None,
- row.new_offset.map(offset => new_index + offset + 1),
- body,
- )
- } else {
- unified_text_line(buf, "add", "+", body)
- }
- None => ()
- }
- }
- i += 2
- }
- Equal(old_index~, new_index~, len~) => {
- for offset, l in o.view(start=old_index, end=old_index + len) {
- if line_numbers {
- unified_numbered_line(
- buf,
- "ctx",
- " ",
- Some(old_index + offset + 1),
- Some(new_index + offset + 1),
- esc(l.text),
- )
- } else {
- unified_text_line(buf, "ctx", " ", esc(l.text))
- }
- }
- i += 1
- }
- Delete(old_index~, old_len~, ..) => {
- for offset, l in o.view(start=old_index, end=old_index + old_len) {
- if line_numbers {
- unified_numbered_line(
- buf,
- "del",
- "-",
- Some(old_index + offset + 1),
- None,
- esc(l.text),
- )
- } else {
- unified_text_line(buf, "del", "-", esc(l.text))
- }
- }
- i += 1
- }
- Insert(new_index~, new_len~, ..) => {
- for offset, l in n.view(start=new_index, end=new_index + new_len) {
- if line_numbers {
- unified_numbered_line(
- buf,
- "add",
- "+",
- None,
- Some(new_index + offset + 1),
- esc(l.text),
- )
- } else {
- unified_text_line(buf, "add", "+", esc(l.text))
- }
- }
- i += 1
- }
- }
- }
-}
-
-///|
-/// Unified (single-column) view of a diff: hunk headers followed by
-/// ` `/`-`/`+`-prefixed lines, with the same weighted alignment and
-/// word-level highlights as the split view — deletions of a replacement
-/// block first, then its insertions. Wrap the result in `` or use
-/// `html_page`. Set `line_cleanup=true` to opt into bounded soft-anchor
-/// cleanup; it is disabled by default. Set `line_numbers=true` to render a
-/// three-column table containing old number, new number, and prefixed code;
-/// it is disabled by default so the historical `` HTML is unchanged.
-/// `hunk_notes[index]` optionally inserts an escaped title and body directly
-/// below the corresponding zero-based hunk header.
-pub fn unified_html(
- old~ : ArrayView[String],
- new~ : ArrayView[String],
- context? : Int = 3,
- line_cleanup? : Bool = false,
- line_numbers? : Bool = false,
- hunk_notes? : ArrayView[HunkNote?] = [],
-) -> String {
- let buf = StringBuilder::new()
- if line_numbers {
- buf <+ "
\n"
- } else {
- buf <+ "\n"
- }
- for hunk_index, h in rendering_diff(old, new, line_cleanup).group(context~) {
- append_unified_hunk(buf, h, hunk_index, line_numbers, hunk_notes)
- }
- if line_numbers {
- buf <+ "
\n"
- } else {
- buf <+ "\n"
- }
- buf.to_string()
-}
-
-///|
-/// Render each MoonBit-aware diff hunk as its own unified HTML block. Array
-/// indexes match `unified_hunks` and the zero-based `hunk_notes` indexes.
-pub fn unified_hunks_html(
- old~ : ArrayView[String],
- new~ : ArrayView[String],
- context? : Int = 3,
- line_cleanup? : Bool = false,
- line_numbers? : Bool = false,
- hunk_notes? : ArrayView[HunkNote?] = [],
-) -> Array[String] {
- let rendered : Array[String] = []
- for hunk_index, h in rendering_diff(old, new, line_cleanup).group(context~) {
- let buf = StringBuilder::new()
- if line_numbers {
- buf <+ "\n"
- } else {
- buf <+ "\n"
- }
- append_unified_hunk(buf, h, hunk_index, line_numbers, hunk_notes)
- if line_numbers {
- buf <+ "
\n"
- } else {
- buf <+ "\n"
- }
- rendered.push(buf.to_string())
- }
- rendered
-}
diff --git a/snapshot/__snapshot__/basic.html b/html/__snapshot__/basic.html
similarity index 100%
rename from snapshot/__snapshot__/basic.html
rename to html/__snapshot__/basic.html
diff --git a/snapshot/__snapshot__/html_fallback_resource_path.html b/html/__snapshot__/html_fallback_resource_path.html
similarity index 100%
rename from snapshot/__snapshot__/html_fallback_resource_path.html
rename to html/__snapshot__/html_fallback_resource_path.html
diff --git a/snapshot/__snapshot__/left_leaning_insert.html b/html/__snapshot__/left_leaning_insert.html
similarity index 100%
rename from snapshot/__snapshot__/left_leaning_insert.html
rename to html/__snapshot__/left_leaning_insert.html
diff --git a/snapshot/moon.pkg b/html/moon.pkg
similarity index 86%
rename from snapshot/moon.pkg
rename to html/moon.pkg
index ec15ac1..486d6e3 100644
--- a/snapshot/moon.pkg
+++ b/html/moon.pkg
@@ -1,4 +1,7 @@
import {
"moonbit-community/ldiff",
+}
+
+import {
"moonbitlang/core/test",
} for "test"
diff --git a/html/pkg.generated.mbti b/html/pkg.generated.mbti
new file mode 100644
index 0000000..b41496c
--- /dev/null
+++ b/html/pkg.generated.mbti
@@ -0,0 +1,45 @@
+// Generated using `moon info`, DON'T EDIT IT
+package "moonbit-community/ldiff/html"
+
+import {
+ "moonbit-community/ldiff",
+}
+
+// Values
+pub fn html_page(title~ : String, String) -> String
+
+pub fn render_side_by_side(@ldiff.DiffDocument, line_numbers? : Bool, hunk_notes? : ArrayView[HunkNote?]) -> String
+
+pub fn render_side_by_side_hunks(@ldiff.DiffDocument, line_numbers? : Bool, hunk_notes? : ArrayView[HunkNote?]) -> Array[String]
+
+pub fn render_unified(@ldiff.DiffDocument, line_numbers? : Bool, hunk_notes? : ArrayView[HunkNote?]) -> String
+
+pub fn render_unified_hunks(@ldiff.DiffDocument, line_numbers? : Bool, hunk_notes? : ArrayView[HunkNote?]) -> Array[String]
+
+pub fn side_by_side_html(old~ : ArrayView[String], new~ : ArrayView[String], context? : Int, line_cleanup? : Bool, line_numbers? : Bool, hunk_notes? : ArrayView[HunkNote?]) -> String
+
+pub fn side_by_side_hunks_html(old~ : ArrayView[String], new~ : ArrayView[String], context? : Int, line_cleanup? : Bool, line_numbers? : Bool, hunk_notes? : ArrayView[HunkNote?]) -> Array[String]
+
+pub fn side_by_side_line_html(old~ : ArrayView[String], new~ : ArrayView[String], context? : Int, line_numbers? : Bool, hunk_notes? : ArrayView[HunkNote?]) -> String
+
+pub fn side_by_side_line_hunks_html(old~ : ArrayView[String], new~ : ArrayView[String], context? : Int, line_numbers? : Bool, hunk_notes? : ArrayView[HunkNote?]) -> Array[String]
+
+pub fn unified_html(old~ : ArrayView[String], new~ : ArrayView[String], context? : Int, line_cleanup? : Bool, line_numbers? : Bool, hunk_notes? : ArrayView[HunkNote?]) -> String
+
+pub fn unified_hunks_html(old~ : ArrayView[String], new~ : ArrayView[String], context? : Int, line_cleanup? : Bool, line_numbers? : Bool, hunk_notes? : ArrayView[HunkNote?]) -> Array[String]
+
+pub fn unified_line_html(old~ : ArrayView[String], new~ : ArrayView[String], context? : Int, line_numbers? : Bool, hunk_notes? : ArrayView[HunkNote?]) -> String
+
+pub fn unified_line_hunks_html(old~ : ArrayView[String], new~ : ArrayView[String], context? : Int, line_numbers? : Bool, hunk_notes? : ArrayView[HunkNote?]) -> Array[String]
+
+// Errors
+
+// Types and methods
+pub(all) struct HunkNote {
+ title : String
+ body : String
+} derive(Eq)
+
+// Type aliases
+
+// Traits
diff --git a/html/render.mbt b/html/render.mbt
new file mode 100644
index 0000000..2995efb
--- /dev/null
+++ b/html/render.mbt
@@ -0,0 +1,526 @@
+// Copyright 2026 International Digital Economy Academy
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+///|
+/// A short annotation rendered immediately below a diff hunk header.
+pub(all) struct HunkNote {
+ title : String
+ body : String
+} derive(Eq)
+
+///|
+fn esc(text : String) -> String {
+ let buf = StringBuilder::new()
+ for char in text {
+ match char {
+ '&' => buf <+ "&"
+ '<' => buf <+ "<"
+ '>' => buf <+ ">"
+ _ => buf.write_char(char)
+ }
+ }
+ buf.to_string()
+}
+
+///|
+fn line_html(line : @ldiff.DiffLine, changed_class : String) -> String {
+ let buf = StringBuilder::new()
+ for segment in line.segments {
+ match segment.kind {
+ Unchanged => buf <+ "\{esc(segment.text)}"
+ Changed => buf <+ "\{esc(segment.text)}"
+ }
+ }
+ buf.to_string()
+}
+
+///|
+fn line_number_text(number : Int?) -> String {
+ if number is Some(value) {
+ value.to_string()
+ } else {
+ ""
+ }
+}
+
+///|
+fn hunk_note_at(notes : ArrayView[HunkNote?], hunk_index : Int) -> HunkNote? {
+ if notes.get(hunk_index) is Some(note) {
+ note
+ } else {
+ None
+ }
+}
+
+///|
+fn table_hunk_note(buf : StringBuilder, note : HunkNote, columns : Int) -> Unit {
+ buf <+
+ "| \{esc(note.title)}: \{esc(note.body)} |
\n"
+}
+
+///|
+fn pre_hunk_note(buf : StringBuilder, note : HunkNote) -> Unit {
+ buf <+
+ "\{esc(note.title)}: \{esc(note.body)}\n"
+}
+
+///|
+fn split_row(
+ buf : StringBuilder,
+ old_number : Int?,
+ old_class : String,
+ old_html : String,
+ new_number : Int?,
+ new_class : String,
+ new_html : String,
+ line_numbers : Bool,
+) -> Unit {
+ if line_numbers {
+ buf <+
+ "| \{line_number_text(old_number)} | \{old_html} | \{line_number_text(new_number)} | \{new_html} |
\n"
+ } else {
+ buf <+
+ "| \{old_html} | \{new_html} |
\n"
+ }
+}
+
+///|
+fn append_split_hunk(
+ buf : StringBuilder,
+ hunk : @ldiff.DiffHunk,
+ hunk_index : Int,
+ line_numbers : Bool,
+ hunk_notes : ArrayView[HunkNote?],
+) -> Unit {
+ if line_numbers {
+ buf <+
+ "
\n"
+ } else {
+ buf <+
+ "
\n"
+ }
+ if hunk_note_at(hunk_notes, hunk_index) is Some(note) {
+ table_hunk_note(buf, note, if line_numbers { 4 } else { 2 })
+ }
+ for block in hunk.blocks {
+ match block {
+ ContextBlock(lines) =>
+ for line in lines {
+ split_row(
+ buf,
+ Some(line.old_index + 1),
+ "ctx",
+ esc(line.text),
+ Some(line.new_index + 1),
+ "ctx",
+ esc(line.text),
+ line_numbers,
+ )
+ }
+ ChangeBlock(rows) =>
+ for row in rows {
+ match row {
+ Paired(old_line, new_line) =>
+ split_row(
+ buf,
+ Some(old_line.index + 1),
+ "del",
+ line_html(old_line, "wd"),
+ Some(new_line.index + 1),
+ "add",
+ line_html(new_line, "wa"),
+ line_numbers,
+ )
+ OldOnly(old_line) =>
+ split_row(
+ buf,
+ Some(old_line.index + 1),
+ "del",
+ line_html(old_line, "wd"),
+ None,
+ "empty",
+ "",
+ line_numbers,
+ )
+ NewOnly(new_line) =>
+ split_row(
+ buf,
+ None,
+ "empty",
+ "",
+ Some(new_line.index + 1),
+ "add",
+ line_html(new_line, "wa"),
+ line_numbers,
+ )
+ }
+ }
+ }
+ }
+}
+
+///|
+/// Render a calculated diff as a GitHub-style side-by-side HTML table.
+pub fn render_side_by_side(
+ document : @ldiff.DiffDocument,
+ line_numbers? : Bool = false,
+ hunk_notes? : ArrayView[HunkNote?] = [],
+) -> String {
+ let buf = StringBuilder::new()
+ buf <+ "\n"
+ for hunk_index, hunk in document.hunks {
+ append_split_hunk(buf, hunk, hunk_index, line_numbers, hunk_notes)
+ }
+ buf <+ "
\n"
+ buf.to_string()
+}
+
+///|
+/// Render every calculated hunk as its own side-by-side HTML table.
+pub fn render_side_by_side_hunks(
+ document : @ldiff.DiffDocument,
+ line_numbers? : Bool = false,
+ hunk_notes? : ArrayView[HunkNote?] = [],
+) -> Array[String] {
+ let rendered : Array[String] = []
+ for hunk_index, hunk in document.hunks {
+ let buf = StringBuilder::new()
+ buf <+ "\n"
+ append_split_hunk(buf, hunk, hunk_index, line_numbers, hunk_notes)
+ buf <+ "
\n"
+ rendered.push(buf.to_string())
+ }
+ rendered
+}
+
+///|
+fn unified_text_line(
+ buf : StringBuilder,
+ class_name : String,
+ prefix : String,
+ body : String,
+) -> Unit {
+ buf <+ "\{prefix}\{body}\n"
+}
+
+///|
+fn unified_numbered_line(
+ buf : StringBuilder,
+ class_name : String,
+ prefix : String,
+ old_number : Int?,
+ new_number : Int?,
+ body : String,
+) -> Unit {
+ buf <+
+ "| \{line_number_text(old_number)} | \{line_number_text(new_number)} | \{prefix}\{body} |
\n"
+}
+
+///|
+fn unified_old_line(
+ buf : StringBuilder,
+ line : @ldiff.DiffLine,
+ line_numbers : Bool,
+) -> Unit {
+ let body = line_html(line, "wd")
+ if line_numbers {
+ unified_numbered_line(buf, "del", "-", Some(line.index + 1), None, body)
+ } else {
+ unified_text_line(buf, "del", "-", body)
+ }
+}
+
+///|
+fn unified_new_line(
+ buf : StringBuilder,
+ line : @ldiff.DiffLine,
+ line_numbers : Bool,
+) -> Unit {
+ let body = line_html(line, "wa")
+ if line_numbers {
+ unified_numbered_line(buf, "add", "+", None, Some(line.index + 1), body)
+ } else {
+ unified_text_line(buf, "add", "+", body)
+ }
+}
+
+///|
+fn append_unified_hunk(
+ buf : StringBuilder,
+ hunk : @ldiff.DiffHunk,
+ hunk_index : Int,
+ line_numbers : Bool,
+ hunk_notes : ArrayView[HunkNote?],
+) -> Unit {
+ if line_numbers {
+ buf <+
+ "
\n"
+ } else {
+ buf <+ "\n"
+ }
+ if hunk_note_at(hunk_notes, hunk_index) is Some(note) {
+ if line_numbers {
+ table_hunk_note(buf, note, 3)
+ } else {
+ pre_hunk_note(buf, note)
+ }
+ }
+ for block in hunk.blocks {
+ match block {
+ ContextBlock(lines) =>
+ for line in lines {
+ if line_numbers {
+ unified_numbered_line(
+ buf,
+ "ctx",
+ " ",
+ Some(line.old_index + 1),
+ Some(line.new_index + 1),
+ esc(line.text),
+ )
+ } else {
+ unified_text_line(buf, "ctx", " ", esc(line.text))
+ }
+ }
+ ChangeBlock(rows) => {
+ for row in rows {
+ match row {
+ Paired(old_line, _) | OldOnly(old_line) =>
+ unified_old_line(buf, old_line, line_numbers)
+ NewOnly(_) => ()
+ }
+ }
+ for row in rows {
+ match row {
+ Paired(_, new_line) | NewOnly(new_line) =>
+ unified_new_line(buf, new_line, line_numbers)
+ OldOnly(_) => ()
+ }
+ }
+ }
+ }
+ }
+}
+
+///|
+/// Render a calculated diff as unified HTML.
+pub fn render_unified(
+ document : @ldiff.DiffDocument,
+ line_numbers? : Bool = false,
+ hunk_notes? : ArrayView[HunkNote?] = [],
+) -> String {
+ let buf = StringBuilder::new()
+ if line_numbers {
+ buf <+ "\n"
+ } else {
+ buf <+ "\n"
+ }
+ for hunk_index, hunk in document.hunks {
+ append_unified_hunk(buf, hunk, hunk_index, line_numbers, hunk_notes)
+ }
+ if line_numbers {
+ buf <+ "
\n"
+ } else {
+ buf <+ "\n"
+ }
+ buf.to_string()
+}
+
+///|
+/// Render every calculated hunk as its own unified HTML block.
+pub fn render_unified_hunks(
+ document : @ldiff.DiffDocument,
+ line_numbers? : Bool = false,
+ hunk_notes? : ArrayView[HunkNote?] = [],
+) -> Array[String] {
+ let rendered : Array[String] = []
+ for hunk_index, hunk in document.hunks {
+ let buf = StringBuilder::new()
+ if line_numbers {
+ buf <+ "\n"
+ } else {
+ buf <+ "\n"
+ }
+ append_unified_hunk(buf, hunk, hunk_index, line_numbers, hunk_notes)
+ if line_numbers {
+ buf <+ "
\n"
+ } else {
+ buf <+ "\n"
+ }
+ rendered.push(buf.to_string())
+ }
+ rendered
+}
+
+///|
+/// GitHub-style split view of a MoonBit-aware diff.
+pub fn side_by_side_html(
+ old~ : ArrayView[String],
+ new~ : ArrayView[String],
+ context? : Int = 3,
+ line_cleanup? : Bool = false,
+ line_numbers? : Bool = false,
+ hunk_notes? : ArrayView[HunkNote?] = [],
+) -> String {
+ render_side_by_side(
+ @ldiff.diff(old~, new~, context~, line_cleanup~),
+ line_numbers~,
+ hunk_notes~,
+ )
+}
+
+///|
+/// Render each MoonBit-aware hunk as its own split HTML table.
+pub fn side_by_side_hunks_html(
+ old~ : ArrayView[String],
+ new~ : ArrayView[String],
+ context? : Int = 3,
+ line_cleanup? : Bool = false,
+ line_numbers? : Bool = false,
+ hunk_notes? : ArrayView[HunkNote?] = [],
+) -> Array[String] {
+ render_side_by_side_hunks(
+ @ldiff.diff(old~, new~, context~, line_cleanup~),
+ line_numbers~,
+ hunk_notes~,
+ )
+}
+
+///|
+/// Unified HTML view of a MoonBit-aware diff.
+pub fn unified_html(
+ old~ : ArrayView[String],
+ new~ : ArrayView[String],
+ context? : Int = 3,
+ line_cleanup? : Bool = false,
+ line_numbers? : Bool = false,
+ hunk_notes? : ArrayView[HunkNote?] = [],
+) -> String {
+ render_unified(
+ @ldiff.diff(old~, new~, context~, line_cleanup~),
+ line_numbers~,
+ hunk_notes~,
+ )
+}
+
+///|
+/// Render each MoonBit-aware hunk as its own unified HTML block.
+pub fn unified_hunks_html(
+ old~ : ArrayView[String],
+ new~ : ArrayView[String],
+ context? : Int = 3,
+ line_cleanup? : Bool = false,
+ line_numbers? : Bool = false,
+ hunk_notes? : ArrayView[HunkNote?] = [],
+) -> Array[String] {
+ render_unified_hunks(
+ @ldiff.diff(old~, new~, context~, line_cleanup~),
+ line_numbers~,
+ hunk_notes~,
+ )
+}
+
+///|
+/// GitHub-style split view of a plain Patience line diff.
+pub fn side_by_side_line_html(
+ old~ : ArrayView[String],
+ new~ : ArrayView[String],
+ context? : Int = 3,
+ line_numbers? : Bool = false,
+ hunk_notes? : ArrayView[HunkNote?] = [],
+) -> String {
+ render_side_by_side(
+ @ldiff.line_diff(old~, new~, context~),
+ line_numbers~,
+ hunk_notes~,
+ )
+}
+
+///|
+/// Render each plain Patience hunk as its own split HTML table.
+pub fn side_by_side_line_hunks_html(
+ old~ : ArrayView[String],
+ new~ : ArrayView[String],
+ context? : Int = 3,
+ line_numbers? : Bool = false,
+ hunk_notes? : ArrayView[HunkNote?] = [],
+) -> Array[String] {
+ render_side_by_side_hunks(
+ @ldiff.line_diff(old~, new~, context~),
+ line_numbers~,
+ hunk_notes~,
+ )
+}
+
+///|
+/// Unified HTML view of a plain Patience line diff.
+pub fn unified_line_html(
+ old~ : ArrayView[String],
+ new~ : ArrayView[String],
+ context? : Int = 3,
+ line_numbers? : Bool = false,
+ hunk_notes? : ArrayView[HunkNote?] = [],
+) -> String {
+ render_unified(
+ @ldiff.line_diff(old~, new~, context~),
+ line_numbers~,
+ hunk_notes~,
+ )
+}
+
+///|
+/// Render each plain Patience hunk as its own unified HTML block.
+pub fn unified_line_hunks_html(
+ old~ : ArrayView[String],
+ new~ : ArrayView[String],
+ context? : Int = 3,
+ line_numbers? : Bool = false,
+ hunk_notes? : ArrayView[HunkNote?] = [],
+) -> Array[String] {
+ render_unified_hunks(
+ @ldiff.line_diff(old~, new~, context~),
+ line_numbers~,
+ hunk_notes~,
+ )
+}
+
+///|
+/// Wrap rendered diff HTML in a complete standalone page with default styles.
+pub fn html_page(title~ : String, body : String) -> String {
+ let buf = StringBuilder::new()
+ buf <+ "\n"
+ buf <+ "\{esc(title)}\n"
+ buf <+ "\{body}"
+ buf <+ "\n"
+ buf.to_string()
+}
diff --git a/snapshot/snapshot_test.mbt b/html/snapshot_test.mbt
similarity index 85%
rename from snapshot/snapshot_test.mbt
rename to html/snapshot_test.mbt
index 854bf5f..6101db4 100644
--- a/snapshot/snapshot_test.mbt
+++ b/html/snapshot_test.mbt
@@ -26,9 +26,9 @@ test "basic" (t : @test.Test) {
][:]
let body = StringBuilder::new()
body <+ "Split view
\n"
- body <+ "\{@ldiff.side_by_side_html(old~, new~)}"
+ body <+ "\{@html.side_by_side_html(old~, new~)}"
body <+ "Unified view
\n"
- body <+ "\{@ldiff.unified_html(old~, new~)}"
+ body <+ "\{@html.unified_html(old~, new~)}"
// attributes, Unicode comments, HTML-special characters
let sp_old = [
"#deprecated(\"use `f` instead\")", "fn g(a : Int) -> Int { a < 3 && a > 0 } // 判断范围",
@@ -37,8 +37,8 @@ test "basic" (t : @test.Test) {
"#deprecated(\"use `h` instead\")", "fn g(a : Int) -> Bool { a <= 3 && a > 0 } // 判断闭区间",
][:]
body <+ "Attributes, Unicode, HTML-specials
\n"
- body <+ "\{@ldiff.side_by_side_html(old=sp_old, new=sp_new)}"
- t.write(@ldiff.html_page(title="ldiff basic", body.to_string()))
+ body <+ "\{@html.side_by_side_html(old=sp_old, new=sp_new)}"
+ t.write(@html.html_page(title="ldiff basic", body.to_string()))
t.snapshot(filename="basic.html")
}
@@ -56,10 +56,10 @@ test "left leaning insert" (t : @test.Test) {
][:]
let body = StringBuilder::new()
body <+ "Split view
\n"
- body <+ "\{@ldiff.side_by_side_html(old~, new~)}"
+ body <+ "\{@html.side_by_side_html(old~, new~)}"
body <+ "Unified view
\n"
- body <+ "\{@ldiff.unified_html(old~, new~)}"
- t.write(@ldiff.html_page(title="left leaning insert", body.to_string()))
+ body <+ "\{@html.unified_html(old~, new~)}"
+ t.write(@html.html_page(title="left leaning insert", body.to_string()))
t.snapshot(filename="left_leaning_insert.html")
}
@@ -78,11 +78,11 @@ test "html fallback resource path" (t : @test.Test) {
][:]
let body = StringBuilder::new()
body <+ "Split view
\n"
- body <+ "\{@ldiff.side_by_side_html(old~, new~)}"
+ body <+ "\{@html.side_by_side_html(old~, new~)}"
body <+ "Unified view
\n"
- body <+ "\{@ldiff.unified_html(old~, new~)}"
+ body <+ "\{@html.unified_html(old~, new~)}"
t.write(
- @ldiff.html_page(title="html fallback resource path", body.to_string()),
+ @html.html_page(title="html fallback resource path", body.to_string()),
)
t.snapshot(filename="html_fallback_resource_path.html")
}
diff --git a/hunks.mbt b/hunks.mbt
deleted file mode 100644
index 8543109..0000000
--- a/hunks.mbt
+++ /dev/null
@@ -1,107 +0,0 @@
-// Copyright 2026 International Digital Economy Academy
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-///|
-/// A short annotation rendered immediately below a diff hunk header.
-pub(all) struct HunkNote {
- title : String
- body : String
-} derive(Eq)
-
-///|
-fn append_diff_line_hunk(
- buf : StringBuilder,
- hunk : @diff.Hunk[DiffLine],
-) -> Unit {
- buf <+ "\{hunk.header()}\n"
- let old_view = hunk.old_view()
- let new_view = hunk.new_view()
- for edit in hunk.edits() {
- match edit {
- Equal(old_index~, len~, ..) =>
- for line in old_view.view(start=old_index, end=old_index + len) {
- buf <+ " \{line.text}\n"
- }
- Delete(old_index~, old_len~, ..) =>
- for line in old_view.view(start=old_index, end=old_index + old_len) {
- buf <+ "-\{line.text}\n"
- }
- Insert(new_index~, new_len~, ..) =>
- for line in new_view.view(start=new_index, end=new_index + new_len) {
- buf <+ "+\{line.text}\n"
- }
- }
- }
-}
-
-///|
-/// Return unified patch hunks using the same MoonBit-aware grouping boundaries
-/// as `side_by_side_html` and `unified_html`.
-pub fn unified_hunks(
- old~ : ArrayView[String],
- new~ : ArrayView[String],
- context? : Int = 3,
- line_cleanup? : Bool = false,
-) -> Array[String] {
- let hunks : Array[String] = []
- for hunk in rendering_diff(old, new, line_cleanup).group(context~) {
- let buf = StringBuilder::new()
- append_diff_line_hunk(buf, hunk)
- hunks.push(buf.to_string())
- }
- hunks
-}
-
-///|
-fn append_plain_line_hunk(
- buf : StringBuilder,
- hunk : @diff.Hunk[String],
-) -> Unit {
- buf <+ "\{hunk.header()}\n"
- let old_view = hunk.old_view()
- let new_view = hunk.new_view()
- for edit in hunk.edits() {
- match edit {
- Equal(old_index~, len~, ..) =>
- for line in old_view.view(start=old_index, end=old_index + len) {
- buf <+ " \{line}\n"
- }
- Delete(old_index~, old_len~, ..) =>
- for line in old_view.view(start=old_index, end=old_index + old_len) {
- buf <+ "-\{line}\n"
- }
- Insert(new_index~, new_len~, ..) =>
- for line in new_view.view(start=new_index, end=new_index + new_len) {
- buf <+ "+\{line}\n"
- }
- }
- }
-}
-
-///|
-/// Return unified patch hunks using the same plain Patience-diff grouping
-/// boundaries as `side_by_side_line_html` and `unified_line_html`.
-pub fn unified_line_hunks(
- old~ : ArrayView[String],
- new~ : ArrayView[String],
- context? : Int = 3,
-) -> Array[String] {
- let hunks : Array[String] = []
- for hunk in @diff.Diff(old~, new~, algorithm=Patience).group(context~) {
- let buf = StringBuilder::new()
- append_plain_line_hunk(buf, hunk)
- hunks.push(buf.to_string())
- }
- hunks
-}
diff --git a/hunks_test.mbt b/hunks_test.mbt
index 08ace06..9049497 100644
--- a/hunks_test.mbt
+++ b/hunks_test.mbt
@@ -16,19 +16,19 @@
test "plain unified hunks preserve renderer boundaries and file-edge ranges" {
let old = ["0", "old", "2", "3", "4", "5", "6", "gone", "8"]
let new = ["0", "new", "2", "3", "4", "5", "6", "added", "8"]
- assert_eq(@ldiff.unified_line_hunks(old~, new~, context=0), [
+ assert_eq(@ldiff_text.unified_line_hunks(old~, new~, context=0), [
"@@ -2 +2 @@\n-old\n+new\n", "@@ -8 +8 @@\n-gone\n+added\n",
])
- assert_eq(@ldiff.unified_line_hunks(old=[], new=["x", ""], context=0), [
+ assert_eq(@ldiff_text.unified_line_hunks(old=[], new=["x", ""], context=0), [
"@@ -0,0 +1,2 @@\n+x\n+\n",
])
- assert_eq(@ldiff.unified_line_hunks(old=["x", ""], new=[], context=0), [
+ assert_eq(@ldiff_text.unified_line_hunks(old=["x", ""], new=[], context=0), [
"@@ -1,2 +0,0 @@\n-x\n-\n",
])
- for patch in @ldiff.unified_line_hunks(old~, new~, context=0) {
+ for patch in @ldiff_text.unified_line_hunks(old~, new~, context=0) {
let header = patch.view(end_offset=patch.find("\n").unwrap())
assert_true(
- @ldiff.unified_line_html(old~, new~, context=0).contains(
+ @ldiff_html.unified_line_html(old~, new~, context=0).contains(
"",
),
)
@@ -39,14 +39,19 @@ test "plain unified hunks preserve renderer boundaries and file-edge ranges" {
test "MoonBit hunks follow semantic line cleanup used by HTML" {
let old = ["let a = old", "", "let b = old", "let c = old"]
let new = ["let a = new", "let b = new", "let c = new", ""]
- assert_eq(@ldiff.unified_hunks(old~, new~, context=0).length(), 2)
- let cleaned = @ldiff.unified_hunks(old~, new~, context=0, line_cleanup=true)
+ assert_eq(@ldiff_text.unified_hunks(old~, new~, context=0).length(), 2)
+ let cleaned = @ldiff_text.unified_hunks(
+ old~,
+ new~,
+ context=0,
+ line_cleanup=true,
+ )
assert_eq(cleaned.length(), 1)
assert_true(cleaned[0].contains("-let b = old\n"))
assert_true(cleaned[0].contains("+let b = new\n"))
let header = cleaned[0].view(end_offset=cleaned[0].find("\n").unwrap())
assert_true(
- @ldiff.side_by_side_html(old~, new~, context=0, line_cleanup=true).contains(
+ @ldiff_html.side_by_side_html(old~, new~, context=0, line_cleanup=true).contains(
"",
),
)
@@ -56,25 +61,30 @@ test "MoonBit hunks follow semantic line cleanup used by HTML" {
test "hunk notes are escaped immediately after matching headers in all views" {
let old = ["0", "old", "2", "3", "4", "5", "6", "gone", "8"]
let new = ["0", "new", "2", "3", "4", "5", "6", "added", "8"]
- let notes : Array[@ldiff.HunkNote?] = [
+ let notes : Array[@ldiff_html.HunkNote?] = [
Some({ title: "Group <&>", body: "Explain & new." }),
None,
]
- let split = @ldiff.side_by_side_html(
+ let split = @ldiff_html.side_by_side_html(
old~,
new~,
context=0,
line_numbers=true,
hunk_notes=notes,
)
- let unified = @ldiff.unified_html(old~, new~, context=0, hunk_notes=notes)
- let plain_split = @ldiff.side_by_side_line_html(
+ let unified = @ldiff_html.unified_html(
+ old~,
+ new~,
+ context=0,
+ hunk_notes=notes,
+ )
+ let plain_split = @ldiff_html.side_by_side_line_html(
old~,
new~,
context=0,
hunk_notes=notes,
)
- let plain_unified = @ldiff.unified_line_html(
+ let plain_unified = @ldiff_html.unified_line_html(
old~,
new~,
context=0,
@@ -109,20 +119,20 @@ test "omitting notes remains byte-identical to an explicit empty array" {
let old = ["old"]
let new = ["new"]
assert_eq(
- @ldiff.side_by_side_html(old~, new~),
- @ldiff.side_by_side_html(old~, new~, hunk_notes=[]),
+ @ldiff_html.side_by_side_html(old~, new~),
+ @ldiff_html.side_by_side_html(old~, new~, hunk_notes=[]),
)
assert_eq(
- @ldiff.unified_html(old~, new~),
- @ldiff.unified_html(old~, new~, hunk_notes=[]),
+ @ldiff_html.unified_html(old~, new~),
+ @ldiff_html.unified_html(old~, new~, hunk_notes=[]),
)
assert_eq(
- @ldiff.side_by_side_line_html(old~, new~),
- @ldiff.side_by_side_line_html(old~, new~, hunk_notes=[]),
+ @ldiff_html.side_by_side_line_html(old~, new~),
+ @ldiff_html.side_by_side_line_html(old~, new~, hunk_notes=[]),
)
assert_eq(
- @ldiff.unified_line_html(old~, new~),
- @ldiff.unified_line_html(old~, new~, hunk_notes=[]),
+ @ldiff_html.unified_line_html(old~, new~),
+ @ldiff_html.unified_line_html(old~, new~, hunk_notes=[]),
)
}
@@ -130,21 +140,21 @@ test "omitting notes remains byte-identical to an explicit empty array" {
test "per-hunk HTML renderers preserve indexes, wrappers, and notes" {
let old = ["0", "old", "2", "3", "4", "5", "6", "gone", "8"]
let new = ["0", "new", "2", "3", "4", "5", "6", "added", "8"]
- let notes : Array[@ldiff.HunkNote?] = [
+ let notes : Array[@ldiff_html.HunkNote?] = [
Some({ title: "First ", body: "First explanation." }),
Some({ title: "Second & group", body: "Second explanation." }),
]
- let semantic_patches = @ldiff.unified_hunks(old~, new~, context=0)
- let plain_patches = @ldiff.unified_line_hunks(old~, new~, context=0)
+ let semantic_patches = @ldiff_text.unified_hunks(old~, new~, context=0)
+ let plain_patches = @ldiff_text.unified_line_hunks(old~, new~, context=0)
let semantic_renderers = [
- @ldiff.side_by_side_hunks_html(
+ @ldiff_html.side_by_side_hunks_html(
old~,
new~,
context=0,
line_numbers=true,
hunk_notes=notes,
),
- @ldiff.unified_hunks_html(
+ @ldiff_html.unified_hunks_html(
old~,
new~,
context=0,
@@ -153,14 +163,14 @@ test "per-hunk HTML renderers preserve indexes, wrappers, and notes" {
),
]
let plain_renderers = [
- @ldiff.side_by_side_line_hunks_html(
+ @ldiff_html.side_by_side_line_hunks_html(
old~,
new~,
context=0,
line_numbers=true,
hunk_notes=notes,
),
- @ldiff.unified_line_hunks_html(
+ @ldiff_html.unified_line_hunks_html(
old~,
new~,
context=0,
@@ -190,8 +200,8 @@ test "per-hunk HTML renderers preserve indexes, wrappers, and notes" {
assert_eq(rendered.split("class=\"hunk-header\"").length(), 2)
}
}
- assert_eq(@ldiff.side_by_side_hunks_html(old=[], new=[]), [])
- assert_eq(@ldiff.unified_hunks_html(old=[], new=[]), [])
- assert_eq(@ldiff.side_by_side_line_hunks_html(old=[], new=[]), [])
- assert_eq(@ldiff.unified_line_hunks_html(old=[], new=[]), [])
+ assert_eq(@ldiff_html.side_by_side_hunks_html(old=[], new=[]), [])
+ assert_eq(@ldiff_html.unified_hunks_html(old=[], new=[]), [])
+ assert_eq(@ldiff_html.side_by_side_line_hunks_html(old=[], new=[]), [])
+ assert_eq(@ldiff_html.unified_line_hunks_html(old=[], new=[]), [])
}
diff --git a/ldiff_test.mbt b/ldiff_test.mbt
index 3651dae..2f00bca 100644
--- a/ldiff_test.mbt
+++ b/ldiff_test.mbt
@@ -34,37 +34,28 @@ test "tokenizer is faithful and language-aware" {
///|
test "similarity: comments help but cannot veto" {
// similar comments pair; unrelated comments do not
- inspect(
- @ldiff.similarity("// add tax", "// add the tax") > 400,
- content="true",
- )
- inspect(
+ assert_true(@ldiff.similarity("// add tax", "// add the tax") > 400)
+ assert_true(
@ldiff.similarity("// promoted; output is not", "// another topic entirely") <
400,
- content="true",
)
// code identical, comment rewritten: still a strong pair
- inspect(
- @ldiff.similarity("let x = 1 // sum", "let x = 1 // total") > 900,
- content="true",
- )
+ assert_true(@ldiff.similarity("let x = 1 // sum", "let x = 1 // total") > 900)
// identical comment beats a rewritten one (tie-breaker)
- inspect(
+ assert_true(
@ldiff.similarity("let x = 1 // a", "let x = 1 // a") >
@ldiff.similarity("let x = 1 // a", "let x = 1 // b"),
- content="true",
)
// lines with nothing in common stay under the threshold
- inspect(
+ assert_true(
@ldiff.similarity("alpha beta gamma delta eps", "one two three four five") <
400,
- content="true",
)
// respacing barely matters
- inspect(@ldiff.similarity("f(a,b)", "f( a , b )") > 800, content="true")
+ assert_true(@ldiff.similarity("f(a,b)", "f( a , b )") > 800)
// zero-mass lines pair only when identical
- inspect(@ldiff.similarity("//", "") == 0, content="true")
- inspect(@ldiff.similarity("//", "//") == 1000, content="true")
+ assert_true(@ldiff.similarity("//", "") == 0)
+ assert_true(@ldiff.similarity("//", "//") == 1000)
}
///|
@@ -77,128 +68,115 @@ test "split view: alignment, highlights, escaping" {
"fn total(items : Array[Item], tax~ : Int) -> Int {", " let mut sum = 0 // running total",
" sum + tax", "}",
][:]
- let html = @ldiff.side_by_side_html(old~, new~, context=1)
+ let html = @ldiff_html.side_by_side_html(old~, new~, context=1)
// paired rows carry word-level highlights
- inspect(html.contains(""), content="true")
+ assert_true(html.contains(""))
// the unrelated comment line remains an unpaired deletion; the accepted
// window projection may now emphasize its deleted semantic content
- inspect(
+ assert_true(
html.contains(
" // helper detail | ",
),
- content="true",
)
// escaping
- let esc_html = @ldiff.side_by_side_html(old=["if a < b && c > d {"], new=[
+ let esc_html = @ldiff_html.side_by_side_html(old=["if a < b && c > d {"], new=[
"if a <= b && c > d {",
])
- inspect(esc_html.contains("<"), content="true")
- inspect(esc_html.contains(""), content="true")
+ assert_true(esc_html.contains("<"))
+ assert_true(esc_html.contains(""))
}
///|
test "whitespace-only edit runs avoid strong intraline highlights" {
let old_indent = " let value = item"
let new_indent = " let value = item"
- let split = @ldiff.side_by_side_html(
+ let split = @ldiff_html.side_by_side_html(
old=[old_indent],
new=[new_indent],
context=0,
)
- inspect(
+ assert_true(
split.contains(
" | \{old_indent} | \{new_indent} | ",
),
- content="true",
)
- inspect(split.contains("-\{old_indent}"),
- content="true",
- )
- inspect(
- unified.contains("+\{new_indent}"),
- content="true",
- )
- inspect(unified.contains("-\{old_indent}"))
+ assert_true(unified.contains("+\{new_indent}"))
+ assert_false(unified.contains(" let old = 1 let new = 1 | ",
),
- content="true",
)
for pair in [("f( x)", "f(x)"), ("f(x)", "f( x)")] {
let (old, new) = pair
- let spacing = @ldiff.side_by_side_html(old=[old], new=[new], context=0)
- inspect(spacing.contains("a : Int"), content="true")
+ assert_true(phrase.contains("a : Int"))
}
///|
test "semantic highlights exclude boundary whitespace in both HTML views" {
- let count_split = @ldiff.side_by_side_html(
+ let count_split = @ldiff_html.side_by_side_html(
old=["count"],
new=["count + tax"],
context=0,
)
- inspect(
+ assert_true(
count_split.contains(
"count | count + tax | ",
),
- content="true",
)
- let count_unified = @ldiff.unified_html(
+ let count_unified = @ldiff_html.unified_html(
old=["count"],
new=["count + tax"],
context=0,
)
- inspect(
+ assert_true(
count_unified.contains(
"+count + tax",
),
- content="true",
)
- let parameter_split = @ldiff.side_by_side_html(
+ let parameter_split = @ldiff_html.side_by_side_html(
old=["f()"],
new=["f( a : Int )"],
context=0,
)
- inspect(
+ assert_true(
parameter_split.contains(
"f() | f( a : Int ) | ",
),
- content="true",
)
- let parameter_unified = @ldiff.unified_html(
+ let parameter_unified = @ldiff_html.unified_html(
old=["f()"],
new=["f( a : Int )"],
context=0,
)
- inspect(
+ assert_true(
parameter_unified.contains(
"+f( a : Int )",
),
- content="true",
)
}
@@ -207,8 +185,8 @@ test "budget fallbacks render plain rows" {
// per-line token cap
let big_old = "word ".repeat(600) + "same tail here"
let big_new = "word ".repeat(600) + "same tail CHANGED"
- let html = @ldiff.side_by_side_html(old=[big_old], new=[big_new])
- inspect(html.contains(""), content="true")
- inspect(html.contains(" | "), content="true")
+ assert_true(html.contains(" | "))
+ assert_true(html.contains(" | "))
}
///|
test "unified view mirrors the split alignment" {
let old = ["let total = a + b // sum", "unrelated_one"][:]
let new = ["let sum = a + b // sum", "other_entirely stuff"][:]
- let html = @ldiff.unified_html(old~, new~, context=0)
+ let html = @ldiff_html.unified_html(old~, new~, context=0)
// paired replacement: highlighted rename, deletions before insertions
- inspect(html.contains("total"), content="true")
- inspect(html.contains("sum"), content="true")
+ assert_true(html.contains("total"))
+ assert_true(html.contains("sum"))
let del_idx = html.find("class=\"del\"").unwrap()
let add_idx = html.find("class=\"add\"").unwrap()
- inspect(del_idx < add_idx, content="true")
+ assert_true(del_idx < add_idx)
}
///|
@@ -296,95 +273,87 @@ test "line numbers are opt-in and preserve legacy HTML when disabled" {
let old = ["a", "old", "same", "gone", "z"]
let new = ["a", "new", "same", "added", "z"]
assert_eq(
- @ldiff.side_by_side_html(old~, new~, context=1),
- @ldiff.side_by_side_html(old~, new~, context=1, line_numbers=false),
+ @ldiff_html.side_by_side_html(old~, new~, context=1),
+ @ldiff_html.side_by_side_html(old~, new~, context=1, line_numbers=false),
)
assert_eq(
- @ldiff.unified_html(old~, new~, context=1),
- @ldiff.unified_html(old~, new~, context=1, line_numbers=false),
+ @ldiff_html.unified_html(old~, new~, context=1),
+ @ldiff_html.unified_html(old~, new~, context=1, line_numbers=false),
)
}
///|
test "numbered split has four columns and complete old and new positions" {
- let html = @ldiff.side_by_side_html(
+ let html = @ldiff_html.side_by_side_html(
old=["ctx", "old ", "", "tail"],
new=["ctx", "new & value", "inserted", "tail"],
context=1,
line_cleanup=true,
line_numbers=true,
)
- inspect(html.contains("class=\"hunk-header\" colspan=\"4\""), content="true")
- inspect(
+ assert_true(html.contains("class=\"hunk-header\" colspan=\"4\""))
+ assert_true(
html.contains(
"1 | ctx | 1 | ctx | ",
),
- content="true",
)
- inspect(
+ assert_true(
html.contains(
"2 | old <tag> | ",
),
- content="true",
)
- inspect(
+ assert_true(
html.contains(
"3 | | ",
),
- content="true",
)
- inspect(
+ assert_true(
html.contains(
"3 | inserted | ",
),
- content="true",
)
- inspect(html.contains("new & value"), content="true")
+ assert_true(html.contains("new & value"))
}
///|
test "numbered unified has three columns across replacements and multiple hunks" {
let old = ["0", "old", "2", "3", "4", "5", "6", "gone", "8"]
let new = ["0", "new", "2", "3", "4", "5", "6", "added", "8"]
- let html = @ldiff.unified_html(
+ let html = @ldiff_html.unified_html(
old~,
new~,
context=0,
line_cleanup=true,
line_numbers=true,
)
- inspect(html.has_prefix(""), content="true")
- inspect(html.contains("class=\"hunk-header\" colspan=\"3\""), content="true")
- inspect(
+ assert_true(html.has_prefix(""))
+ assert_true(html.contains("class=\"hunk-header\" colspan=\"3\""))
+ assert_true(
html.contains(
"| 2 | | -old | ",
),
- content="true",
)
- inspect(
+ assert_true(
html.contains(
" | 2 | +new | ",
),
- content="true",
)
- inspect(
+ assert_true(
html.contains(
"8 | | -gone | ",
),
- content="true",
)
- inspect(
+ assert_true(
html.contains(
" | 8 | +added | ",
),
- content="true",
)
}
///|
test "numbered pure add and delete use an empty number cell for the absent side" {
inspect(
- @ldiff.side_by_side_html(
+ @ldiff_html.side_by_side_html(
old=[],
new=["名", ""],
context=0,
@@ -399,16 +368,15 @@ test "numbered pure add and delete use an empty number cell for the absent side"
#|
),
)
- let deleted = @ldiff.unified_html(
+ let deleted = @ldiff_html.unified_html(
old=["x", ""],
new=[],
context=0,
line_numbers=true,
)
- inspect(
+ assert_true(
deleted.contains(
"2 | | - | ",
),
- content="true",
)
}
diff --git a/ldiff_wbtest.mbt b/ldiff_wbtest.mbt
index 2afb2f2..74c94c8 100644
--- a/ldiff_wbtest.mbt
+++ b/ldiff_wbtest.mbt
@@ -42,7 +42,7 @@ test "unrelated block orders delete before insert" {
test "comma carries less alignment evidence than structural punctuation" {
let comma = Tok::{ kind: Punct, text: "," }
let close = Tok::{ kind: Punct, text: ")" }
- assert_eq(weight(Punct), 6)
+ assert_eq(Punct.weight(), 6)
assert_eq(alignment_weight(comma), 1)
assert_eq(alignment_weight(close), 6)
assert_true(similarity("old,", "new,") < THETA)
@@ -99,7 +99,7 @@ test "strong crossing pair beats weak monotone pairs" {
///|
test "long-line fallback is selected before the recursive lexer and is lossless" {
let line = "let 名 = " + "value + ".repeat(300) + "😀 < end"
- inspect(lexer_complexity_within_limit(line), content="false")
+ assert_false(lexer_complexity_within_limit(line))
assert_eq(fallback_tokenize_line(line).map(t => t.text).join(""), line)
assert_eq(tokenize_line(line).map(t => t.text).join(""), line)
}
@@ -107,19 +107,19 @@ test "long-line fallback is selected before the recursive lexer and is lossless"
///|
test "long-line fallback preserves marker comments Unicode and empty runs" {
let line = "///| " + "说明/word ".repeat(300) + "😀"
- inspect(lexer_complexity_within_limit(line), content="false")
+ assert_false(lexer_complexity_within_limit(line))
assert_eq(tokenize_line(line).map(t => t.text).join(""), line)
- inspect(tokenize_line(line)[0].kind is Marker, content="true")
+ assert_true(tokenize_line(line)[0].kind is Marker)
let punctuation = "(".repeat(2000) + "😀" + ")".repeat(2000)
- inspect(lexer_complexity_within_limit(punctuation), content="false")
+ assert_false(lexer_complexity_within_limit(punctuation))
assert_eq(tokenize_line(punctuation).map(t => t.text).join(""), punctuation)
let interpolation = "\\" + "{value} + "
let slash_in_string = "let url = \"https://example.test/" +
interpolation.repeat(300) +
"\""
- inspect(lexer_complexity_within_limit(slash_in_string), content="false")
+ assert_false(lexer_complexity_within_limit(slash_in_string))
assert_eq(
tokenize_line(slash_in_string).map(t => t.text).join(""),
slash_in_string,
@@ -132,73 +132,92 @@ test "fallback tokens still feed the existing alignment budgets" {
let new = "word + ".repeat(600) + "new"
let old_tokens = tokenize_line(old)
let new_tokens = tokenize_line(new)
- inspect(old_tokens.length() > 1024, content="true")
- inspect(new_tokens.length() > 1024, content="true")
- inspect(alignment_tokens([old], [new], line => line) is None, content="true")
+ assert_true(old_tokens.length() > 1024)
+ assert_true(new_tokens.length() > 1024)
+ assert_true(alignment_tokens([old], [new], line => line) is None)
}
///|
-test "pair renderer trims whitespace tokens at every changed-run boundary" {
+fn diff_segment_signature(segments : ArrayView[DiffSegment]) -> String {
+ let buf = StringBuilder::new()
+ for segment in segments {
+ match segment.kind {
+ Unchanged => buf <+ "U[\{segment.text}]"
+ Changed => buf <+ "C[\{segment.text}]"
+ }
+ }
+ buf.to_string()
+}
+
+///|
+test "pair calculation trims whitespace at every changed-run boundary" {
let space = Tok::{ kind: Space, text: " " }
let old = Tok::{ kind: Word, text: "old" }
let new = Tok::{ kind: Word, text: "new" }
- let (both_left, both_right) = pair_row_html([
+ let (both_left, both_right) = pair_row_segments([
OSub(space, space),
OSub(old, new),
OSub(space, space),
])
- assert_eq(both_left, " old ")
- assert_eq(both_right, " new ")
+ assert_eq(diff_segment_signature(both_left), "U[ ]C[old]U[ ]")
+ assert_eq(diff_segment_signature(both_right), "U[ ]C[new]U[ ]")
- let (before, _) = pair_row_html([ODel(space), ODel(old)])
- assert_eq(before, " old")
- let (after, _) = pair_row_html([ODel(old), ODel(space)])
- assert_eq(after, "old ")
+ let (before, _) = pair_row_segments([ODel(space), ODel(old)])
+ assert_eq(diff_segment_signature(before), "U[ ]C[old]")
+ let (after, _) = pair_row_segments([ODel(old), ODel(space)])
+ assert_eq(diff_segment_signature(after), "C[old]U[ ]")
}
///|
-test "pair renderer uses token text and trims each side independently" {
- let (left, right) = pair_row_html([
+test "pair calculation uses token text and trims each side independently" {
+ let (left, right) = pair_row_segments([
OSub({ kind: Filler, text: "\t" }, { kind: Word, text: "new" }),
OSub({ kind: Word, text: "old" }, { kind: Filler, text: " " }),
])
- assert_eq(left, "\told")
- assert_eq(right, "new ")
+ assert_eq(diff_segment_signature(left), "U[\t]C[old]")
+ assert_eq(diff_segment_signature(right), "C[new]U[ ]")
- let (_, internal) = pair_row_html([
+ let (_, internal) = pair_row_segments([
OIns({ kind: Word, text: "a" }),
OIns({ kind: Space, text: " " }),
OIns({ kind: Punct, text: ":" }),
OIns({ kind: Filler, text: " " }),
OIns({ kind: Word, text: "Int" }),
])
- assert_eq(internal, "a : Int")
+ assert_eq(diff_segment_signature(internal), "C[a : Int]")
- let (plain, _) = pair_row_html([
+ let (plain, _) = pair_row_segments([
ODel({ kind: Space, text: " " }),
ODel({ kind: Filler, text: "\t" }),
])
- assert_eq(plain, " \t")
+ assert_eq(diff_segment_signature(plain), "U[ \t]")
- let (_, comment) = pair_row_html([
+ let (_, comment) = pair_row_segments([
OIns({ kind: Filler, text: "//" }),
OIns({ kind: Filler, text: " " }),
OIns({ kind: Comment, text: "word" }),
OIns({ kind: Filler, text: " " }),
])
- assert_eq(comment, "// word ")
+ assert_eq(diff_segment_signature(comment), "C[// word]U[ ]")
}
///|
-test "full-line renderer trims whitespace escapes content and skips empty emphasis" {
+test "full-line calculation trims whitespace and skips empty emphasis" {
+ assert_eq(
+ diff_segment_signature(
+ full_line_segments(tokenize_line(" \t ")),
+ ),
+ "U[ \t]C[]U[ ]",
+ )
assert_eq(
- full_line_html(tokenize_line(" \t "), "wd"),
- " \t<old & gone> ",
+ diff_segment_signature(
+ full_line_segments(tokenize_line("\tnew & ")),
+ ),
+ "U[\t]C[new & ]U[ ]",
)
assert_eq(
- full_line_html(tokenize_line("\tnew & "), "wa"),
- "\tnew & <value> ",
+ diff_segment_signature(full_line_segments(tokenize_line(" \t "))),
+ "U[ \t ]",
)
- assert_eq(full_line_html(tokenize_line(" \t "), "wd"), " \t ")
- assert_eq(full_line_html(tokenize_line(""), "wa"), "")
+ assert_eq(diff_segment_signature(full_line_segments(tokenize_line(""))), "")
}
diff --git a/line_cleanup.mbt b/line_cleanup.mbt
index 4bfb5ee..fc5edc8 100644
--- a/line_cleanup.mbt
+++ b/line_cleanup.mbt
@@ -19,14 +19,14 @@
// group/hunk the intended script without accidentally matching duplicate text.
///|
-priv struct DiffLine {
+priv struct ComparableLine {
text : String
key : (Int, Int)
} derive(Eq, Hash)
///|
-fn original_diff_lines(lines : ArrayView[String]) -> Array[DiffLine] {
- let out : Array[DiffLine] = []
+fn original_diff_lines(lines : ArrayView[String]) -> Array[ComparableLine] {
+ let out : Array[ComparableLine] = []
for text in lines {
out.push({ text, key: (0, 0) })
}
@@ -128,12 +128,12 @@ fn soft_equal_edits(
fn cleaned_diff_lines(
old : ArrayView[String],
new : ArrayView[String],
-) -> (Array[DiffLine], Array[DiffLine]) {
- let old_lines : Array[DiffLine] = []
+) -> (Array[ComparableLine], Array[ComparableLine]) {
+ let old_lines : Array[ComparableLine] = []
for i, text in old {
old_lines.push({ text, key: (0, i) })
}
- let new_lines : Array[DiffLine] = []
+ let new_lines : Array[ComparableLine] = []
for i, text in new {
new_lines.push({ text, key: (1, i) })
}
@@ -171,34 +171,31 @@ fn cleaned_diff_lines(
covered_through = last
let (old_start, new_start, _, _) = edit_bounds(edits[first])
let (_, _, old_end, new_end) = edit_bounds(edits[last])
- match
- alignment_tokens(
+ if alignment_tokens(
old.view(start=old_start, end=old_end),
new.view(start=new_start, end=new_end),
line => line,
- ) {
- None => () // preserve every original anchor in this window
- Some(tokens) => {
- for old_index in old_start.. {
- let old_index = old_start + old_offset
- let new_index = new_start + new_offset
- if old[old_index] == new[new_index] {
- let key = (2, next_anchor)
- old_lines[old_index] = { text: old[old_index], key }
- new_lines[new_index] = { text: new[new_index], key }
- next_anchor += 1
- }
+ )
+ is Some(tokens) {
+ for old_index in old_start.. {
+ let old_index = old_start + old_offset
+ let new_index = new_start + new_offset
+ if old[old_index] == new[new_index] {
+ let key = (2, next_anchor)
+ old_lines[old_index] = { text: old[old_index], key }
+ new_lines[new_index] = { text: new[new_index], key }
+ next_anchor += 1
}
- _ => ()
}
+ _ => ()
}
}
}
@@ -211,7 +208,7 @@ fn rendering_diff(
old : ArrayView[String],
new : ArrayView[String],
line_cleanup : Bool,
-) -> @diff.Diff[DiffLine] {
+) -> @diff.Diff[ComparableLine] {
if line_cleanup {
let (old_lines, new_lines) = cleaned_diff_lines(old, new)
Diff(old=old_lines, new=new_lines, algorithm=Patience)
diff --git a/line_html.mbt b/line_html.mbt
deleted file mode 100644
index 2606a15..0000000
--- a/line_html.mbt
+++ /dev/null
@@ -1,358 +0,0 @@
-// Copyright 2026 International Digital Economy Academy
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-// Plain line renderers. These deliberately use the core line-level diff
-// directly: they do not tokenize source text, reopen equal anchors, or run
-// intraline alignment.
-
-///|
-fn split_line_replacement(
- buf : StringBuilder,
- old_lines : ArrayView[String],
- new_lines : ArrayView[String],
- old_start : Int,
- new_start : Int,
- line_numbers : Bool,
-) -> Unit {
- let row_count = if old_lines.length() > new_lines.length() {
- old_lines.length()
- } else {
- new_lines.length()
- }
- for offset in 0.. Unit {
- if line_numbers {
- buf <+
- "
\n"
- } else {
- buf <+
- "
\n"
- }
- match hunk_note_at(hunk_notes, hunk_index) {
- Some(note) => table_hunk_note(buf, note, if line_numbers { 4 } else { 2 })
- None => ()
- }
- let edits = h.edits()
- let old_view = h.old_view()
- let new_view = h.new_view()
- let mut index = 0
- while index < edits.length() {
- match edits[index] {
- Delete(old_index~, old_len~, ..) if index + 1 < edits.length() &&
- edits[index + 1] is Insert(..) => {
- guard! edits[index + 1] is Insert(new_index~, new_len~, ..)
- split_line_replacement(
- buf,
- old_view.view(start=old_index, end=old_index + old_len),
- new_view.view(start=new_index, end=new_index + new_len),
- old_index,
- new_index,
- line_numbers,
- )
- index += 2
- }
- Equal(old_index~, new_index~, len~) => {
- for offset, line in old_view.view(start=old_index, end=old_index + len) {
- split_row(
- buf,
- Some(old_index + offset + 1),
- "ctx",
- esc(line),
- Some(new_index + offset + 1),
- "ctx",
- esc(line),
- line_numbers,
- )
- }
- index += 1
- }
- Delete(old_index~, old_len~, ..) => {
- for
- offset, line in old_view.view(
- start=old_index,
- end=old_index + old_len,
- ) {
- split_row(
- buf,
- Some(old_index + offset + 1),
- "del",
- esc(line),
- None,
- "empty",
- "",
- line_numbers,
- )
- }
- index += 1
- }
- Insert(new_index~, new_len~, ..) => {
- for
- offset, line in new_view.view(
- start=new_index,
- end=new_index + new_len,
- ) {
- split_row(
- buf,
- None,
- "empty",
- "",
- Some(new_index + offset + 1),
- "add",
- esc(line),
- line_numbers,
- )
- }
- index += 1
- }
- }
- }
-}
-
-///|
-/// Render a plain line-level diff as a GitHub-style side-by-side HTML table.
-/// Replacement blocks pair old and new lines by position, leaving the other
-/// cell empty when one side has extra lines. Source text is HTML-escaped, but
-/// no MoonBit lexing, line cleanup, or intraline highlighting is performed.
-/// Set `line_numbers=true` for old-number, old-code, new-number, new-code
-/// columns. `hunk_notes[index]` optionally annotates that zero-based hunk.
-pub fn side_by_side_line_html(
- old~ : ArrayView[String],
- new~ : ArrayView[String],
- context? : Int = 3,
- line_numbers? : Bool = false,
- hunk_notes? : ArrayView[HunkNote?] = [],
-) -> String {
- let buf = StringBuilder::new()
- buf <+ "\n"
- for
- hunk_index, h in @diff.Diff(old~, new~, algorithm=Patience).group(context~) {
- append_split_line_hunk(buf, h, hunk_index, line_numbers, hunk_notes)
- }
- buf <+ "
\n"
- buf.to_string()
-}
-
-///|
-/// Render each plain Patience-diff hunk as its own split HTML table. Array
-/// indexes match `unified_line_hunks` and the zero-based `hunk_notes` indexes.
-pub fn side_by_side_line_hunks_html(
- old~ : ArrayView[String],
- new~ : ArrayView[String],
- context? : Int = 3,
- line_numbers? : Bool = false,
- hunk_notes? : ArrayView[HunkNote?] = [],
-) -> Array[String] {
- let rendered : Array[String] = []
- for
- hunk_index, h in @diff.Diff(old~, new~, algorithm=Patience).group(context~) {
- let buf = StringBuilder::new()
- buf <+ "\n"
- append_split_line_hunk(buf, h, hunk_index, line_numbers, hunk_notes)
- buf <+ "
\n"
- rendered.push(buf.to_string())
- }
- rendered
-}
-
-///|
-fn append_unified_line_hunk(
- buf : StringBuilder,
- h : @diff.Hunk[String],
- hunk_index : Int,
- line_numbers : Bool,
- hunk_notes : ArrayView[HunkNote?],
-) -> Unit {
- if line_numbers {
- buf <+
- "
\n"
- } else {
- buf <+ "\n"
- }
- match hunk_note_at(hunk_notes, hunk_index) {
- Some(note) =>
- if line_numbers {
- table_hunk_note(buf, note, 3)
- } else {
- pre_hunk_note(buf, note)
- }
- None => ()
- }
- let old_view = h.old_view()
- let new_view = h.new_view()
- for edit in h.edits() {
- match edit {
- Equal(old_index~, new_index~, len~) =>
- for offset, value in old_view.view(start=old_index, end=old_index + len) {
- if line_numbers {
- unified_numbered_line(
- buf,
- "ctx",
- " ",
- Some(old_index + offset + 1),
- Some(new_index + offset + 1),
- esc(value),
- )
- } else {
- unified_text_line(buf, "ctx", " ", esc(value))
- }
- }
- Delete(old_index~, old_len~, ..) =>
- for
- offset, value in old_view.view(
- start=old_index,
- end=old_index + old_len,
- ) {
- if line_numbers {
- unified_numbered_line(
- buf,
- "del",
- "-",
- Some(old_index + offset + 1),
- None,
- esc(value),
- )
- } else {
- unified_text_line(buf, "del", "-", esc(value))
- }
- }
- Insert(new_index~, new_len~, ..) =>
- for
- offset, value in new_view.view(
- start=new_index,
- end=new_index + new_len,
- ) {
- if line_numbers {
- unified_numbered_line(
- buf,
- "add",
- "+",
- None,
- Some(new_index + offset + 1),
- esc(value),
- )
- } else {
- unified_text_line(buf, "add", "+", esc(value))
- }
- }
- }
- }
-}
-
-///|
-/// Render a plain line-level diff in unified HTML form. Replacement blocks
-/// emit every deletion before every insertion. Source text is HTML-escaped,
-/// but no MoonBit lexing, line cleanup, or intraline highlighting is
-/// performed. Set `line_numbers=true` for old-number, new-number, and
-/// prefixed-code columns. `hunk_notes[index]` optionally annotates that
-/// zero-based hunk.
-pub fn unified_line_html(
- old~ : ArrayView[String],
- new~ : ArrayView[String],
- context? : Int = 3,
- line_numbers? : Bool = false,
- hunk_notes? : ArrayView[HunkNote?] = [],
-) -> String {
- let buf = StringBuilder::new()
- if line_numbers {
- buf <+ "\n"
- } else {
- buf <+ "\n"
- }
- for
- hunk_index, h in @diff.Diff(old~, new~, algorithm=Patience).group(context~) {
- append_unified_line_hunk(buf, h, hunk_index, line_numbers, hunk_notes)
- }
- if line_numbers {
- buf <+ "
\n"
- } else {
- buf <+ "\n"
- }
- buf.to_string()
-}
-
-///|
-/// Render each plain Patience-diff hunk as its own unified HTML block. Array
-/// indexes match `unified_line_hunks` and the zero-based `hunk_notes` indexes.
-pub fn unified_line_hunks_html(
- old~ : ArrayView[String],
- new~ : ArrayView[String],
- context? : Int = 3,
- line_numbers? : Bool = false,
- hunk_notes? : ArrayView[HunkNote?] = [],
-) -> Array[String] {
- let rendered : Array[String] = []
- for
- hunk_index, h in @diff.Diff(old~, new~, algorithm=Patience).group(context~) {
- let buf = StringBuilder::new()
- if line_numbers {
- buf <+ "\n"
- } else {
- buf <+ "\n"
- }
- append_unified_line_hunk(buf, h, hunk_index, line_numbers, hunk_notes)
- if line_numbers {
- buf <+ "
\n"
- } else {
- buf <+ "\n"
- }
- rendered.push(buf.to_string())
- }
- rendered
-}
diff --git a/line_html_test.mbt b/line_html_test.mbt
index 1688dd7..e834094 100644
--- a/line_html_test.mbt
+++ b/line_html_test.mbt
@@ -14,7 +14,7 @@
///|
test "plain split pairs replacement lines by position and leaves extras empty" {
- let fewer_new = @ldiff.side_by_side_line_html(
+ let fewer_new = @ldiff_html.side_by_side_line_html(
old=["old ", "old two"],
new=["new & one"],
context=0,
@@ -31,7 +31,7 @@ test "plain split pairs replacement lines by position and leaves extras empty" {
),
)
- let fewer_old = @ldiff.side_by_side_line_html(
+ let fewer_old = @ldiff_html.side_by_side_line_html(
old=["old"],
new=["new one", "new two"],
context=0,
@@ -51,7 +51,7 @@ test "plain split pairs replacement lines by position and leaves extras empty" {
test "plain split preserves requested context and absolute line numbers" {
let old = ["0", "old", "2", "3", "4", "5", "6", "gone", "8"]
let new = ["0", "new", "2", "3", "4", "5", "6", "added", "8"]
- let html = @ldiff.side_by_side_line_html(
+ let html = @ldiff_html.side_by_side_line_html(
old~,
new~,
context=1,
@@ -73,7 +73,7 @@ test "plain split preserves requested context and absolute line numbers" {
///|
test "plain unified emits deletions before insertions without token markup" {
- let html = @ldiff.unified_line_html(
+ let html = @ldiff_html.unified_line_html(
old=["before", "old ", "old two", "after"],
new=["before", "new & one", "new two", "new three", "after"],
context=1,
@@ -99,7 +99,7 @@ test "plain unified emits deletions before insertions without token markup" {
///|
test "plain unified keeps the historical unnumbered wrapper" {
inspect(
- @ldiff.unified_line_html(old=[""], new=["&new"], context=0),
+ @ldiff_html.unified_line_html(old=[""], new=["&new"], context=0),
content=(
#|
#|
diff --git a/model.mbt b/model.mbt
new file mode 100644
index 0000000..5dd9479
--- /dev/null
+++ b/model.mbt
@@ -0,0 +1,538 @@
+// Copyright 2026 International Digital Economy Academy
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+///|
+/// Whether one source segment is unchanged or belongs to an intraline change.
+/// Renderers decide how to present `Changed`; the calculation layer never
+/// assigns markup, colors, or terminal styles.
+pub(all) enum DiffSegmentKind {
+ Unchanged
+ Changed
+} derive(Eq)
+
+///|
+/// One exact source-text segment in a changed line. Concatenating a line's
+/// segment texts always reconstructs that source line byte-for-byte.
+pub(all) struct DiffSegment {
+ kind : DiffSegmentKind
+ text : String
+} derive(Eq)
+
+///|
+/// One changed source line. `index` is zero-based in its original input.
+pub(all) struct DiffLine {
+ index : Int
+ segments : Array[DiffSegment]
+} derive(Eq)
+
+///|
+/// Reconstruct the exact source text of this line.
+pub fn DiffLine::text(self : DiffLine) -> String {
+ let buf = StringBuilder::new()
+ for segment in self.segments {
+ buf <+ "\{segment.text}"
+ }
+ buf.to_string()
+}
+
+///|
+/// One unchanged line shared by both inputs. Indices are zero-based.
+pub(all) struct ContextLine {
+ old_index : Int
+ new_index : Int
+ text : String
+} derive(Eq)
+
+///|
+/// One aligned row in a change block. The enum keeps a present blank line
+/// distinct from a missing side without relying on empty-string sentinels.
+pub(all) enum DiffRow {
+ Paired(DiffLine, DiffLine)
+ OldOnly(DiffLine)
+ NewOnly(DiffLine)
+} derive(Eq)
+
+///|
+/// One ordered block inside a hunk. Change-block boundaries are retained so a
+/// unified renderer can emit all deletions before all insertions while a split
+/// renderer consumes the aligned rows directly.
+pub(all) enum DiffBlock {
+ ContextBlock(Array[ContextLine])
+ ChangeBlock(Array[DiffRow])
+} derive(Eq)
+
+///|
+/// One grouped diff hunk. Starts are zero-based cursors and lengths are source
+/// line counts. `header` preserves the canonical core-diff range spelling.
+pub(all) struct DiffHunk {
+ header : String
+ old_start : Int
+ old_len : Int
+ new_start : Int
+ new_len : Int
+ blocks : Array[DiffBlock]
+} derive(Eq)
+
+///|
+/// A fully calculated, renderer-neutral diff document.
+pub(all) struct DiffDocument {
+ hunks : Array[DiffHunk]
+} derive(Eq)
+
+///|
+fn push_diff_segment(
+ segments : Array[DiffSegment],
+ kind : DiffSegmentKind,
+ text : String,
+) -> Unit {
+ if text == "" {
+ return
+ }
+ let last = segments.length() - 1
+ if last >= 0 && segments[last].kind == kind {
+ let previous = segments[last]
+ segments[last] = { kind, text: previous.text + text }
+ } else {
+ segments.push({ kind, text })
+ }
+}
+
+///|
+fn push_token_range(
+ segments : Array[DiffSegment],
+ tokens : ArrayView[Tok],
+ start : Int,
+ end : Int,
+ kind : DiffSegmentKind,
+) -> Unit {
+ let buf = StringBuilder::new()
+ for index in start.. Bool {
+ if token.text == "" {
+ return false
+ }
+ for char in token.text {
+ if !char.is_whitespace() {
+ return false
+ }
+ }
+ true
+}
+
+///|
+/// Append one changed token run while leaving leading and trailing whitespace
+/// unchanged. Internal whitespace remains part of the changed segment.
+fn flush_changed_tokens(
+ segments : Array[DiffSegment],
+ run : Array[Tok],
+) -> Unit {
+ let mut semantic_start = 0
+ while semantic_start < run.length() &&
+ diff_token_text_is_whitespace(run[semantic_start]) {
+ semantic_start += 1
+ }
+ if semantic_start == run.length() {
+ push_token_range(segments, run, 0, run.length(), Unchanged)
+ } else {
+ let mut semantic_end = run.length()
+ while semantic_end > semantic_start &&
+ diff_token_text_is_whitespace(run[semantic_end - 1]) {
+ semantic_end -= 1
+ }
+ push_token_range(segments, run, 0, semantic_start, Unchanged)
+ push_token_range(segments, run, semantic_start, semantic_end, Changed)
+ push_token_range(segments, run, semantic_end, run.length(), Unchanged)
+ }
+ run.clear()
+}
+
+///|
+fn plain_segments(text : String) -> Array[DiffSegment] {
+ if text == "" {
+ []
+ } else {
+ [{ kind: Unchanged, text }]
+ }
+}
+
+///|
+fn full_line_segments(tokens : ArrayView[Tok]) -> Array[DiffSegment] {
+ let segments : Array[DiffSegment] = []
+ let run = tokens.to_owned()
+ flush_changed_tokens(segments, run)
+ segments
+}
+
+///|
+fn pair_row_segments(
+ ops : ArrayView[Op],
+) -> (Array[DiffSegment], Array[DiffSegment]) {
+ let old_segments : Array[DiffSegment] = []
+ let new_segments : Array[DiffSegment] = []
+ let old_run : Array[Tok] = []
+ let new_run : Array[Tok] = []
+ for op in ops {
+ match op {
+ OEq(token) => {
+ flush_changed_tokens(old_segments, old_run)
+ flush_changed_tokens(new_segments, new_run)
+ push_diff_segment(old_segments, Unchanged, token.text)
+ push_diff_segment(new_segments, Unchanged, token.text)
+ }
+ OSub(old_token, new_token) => {
+ old_run.push(old_token)
+ new_run.push(new_token)
+ }
+ ODel(token) => old_run.push(token)
+ OIns(token) => new_run.push(token)
+ }
+ }
+ flush_changed_tokens(old_segments, old_run)
+ flush_changed_tokens(new_segments, new_run)
+ (old_segments, new_segments)
+}
+
+///|
+fn projected_line_segments(
+ tokens : ArrayView[Tok],
+ changed : ArrayView[Bool],
+) -> Array[DiffSegment] {
+ let segments : Array[DiffSegment] = []
+ let run : Array[Tok] = []
+ for index, token in tokens {
+ if changed[index] {
+ run.push(token)
+ } else if token.kind is Space && run.length() > 0 {
+ let mut next = index + 1
+ while next < tokens.length() && tokens[next].kind is Space {
+ next += 1
+ }
+ if next < tokens.length() && changed[next] {
+ run.push(token)
+ } else {
+ flush_changed_tokens(segments, run)
+ push_diff_segment(segments, Unchanged, token.text)
+ }
+ } else {
+ flush_changed_tokens(segments, run)
+ push_diff_segment(segments, Unchanged, token.text)
+ }
+ }
+ flush_changed_tokens(segments, run)
+ segments
+}
+
+///|
+fn make_diff_line(index : Int, segments : Array[DiffSegment]) -> DiffLine {
+ { index, segments }
+}
+
+///|
+fn make_plain_diff_line(index : Int, text : String) -> DiffLine {
+ { index, segments: plain_segments(text) }
+}
+
+///|
+/// Calculate semantic row pairing and intraline segments for one replacement
+/// block. This is the renderer-neutral counterpart of the former HTML
+/// `prepare_replacement` helper.
+fn semantic_change_rows(
+ old_lines : ArrayView[ComparableLine],
+ new_lines : ArrayView[ComparableLine],
+ old_start : Int,
+ new_start : Int,
+) -> Array[DiffRow] {
+ let rows : Array[DiffRow] = []
+ if alignment_tokens(old_lines, new_lines, line => line.text) is Some(tokens) {
+ let changes = window_changes(tokens.olds, tokens.news)
+ for pair in align(tokens.olds, tokens.news) {
+ match pair {
+ (Some(old_index), Some(new_index)) => {
+ let (old_segments, new_segments) = if changes is Some(window) {
+ (
+ projected_line_segments(
+ tokens.olds[old_index],
+ window.old_changed[old_index],
+ ),
+ projected_line_segments(
+ tokens.news[new_index],
+ window.new_changed[new_index],
+ ),
+ )
+ } else if !traceback_cells_within_limit(
+ tokens.olds[old_index].length(),
+ tokens.news[new_index].length(),
+ ) {
+ (
+ plain_segments(old_lines[old_index].text),
+ plain_segments(new_lines[new_index].text),
+ )
+ } else {
+ pair_row_segments(
+ pair_ops(tokens.olds[old_index], tokens.news[new_index]),
+ )
+ }
+ rows.push(
+ Paired(
+ make_diff_line(old_start + old_index, old_segments),
+ make_diff_line(new_start + new_index, new_segments),
+ ),
+ )
+ }
+ (Some(old_index), None) => {
+ let segments = if changes is Some(window) {
+ projected_line_segments(
+ tokens.olds[old_index],
+ window.old_changed[old_index],
+ )
+ } else {
+ full_line_segments(tokens.olds[old_index])
+ }
+ rows.push(OldOnly(make_diff_line(old_start + old_index, segments)))
+ }
+ (None, Some(new_index)) => {
+ let segments = if changes is Some(window) {
+ projected_line_segments(
+ tokens.news[new_index],
+ window.new_changed[new_index],
+ )
+ } else {
+ full_line_segments(tokens.news[new_index])
+ }
+ rows.push(NewOnly(make_diff_line(new_start + new_index, segments)))
+ }
+ (None, None) => ()
+ }
+ }
+ } else {
+ for offset, line in old_lines {
+ rows.push(OldOnly(make_plain_diff_line(old_start + offset, line.text)))
+ }
+ for offset, line in new_lines {
+ rows.push(NewOnly(make_plain_diff_line(new_start + offset, line.text)))
+ }
+ }
+ rows
+}
+
+///|
+fn plain_change_rows(
+ old_lines : ArrayView[String],
+ new_lines : ArrayView[String],
+ old_start : Int,
+ new_start : Int,
+) -> Array[DiffRow] {
+ let rows : Array[DiffRow] = []
+ let row_count = if old_lines.length() > new_lines.length() {
+ old_lines.length()
+ } else {
+ new_lines.length()
+ }
+ for offset in 0..
+ rows.push(
+ Paired(
+ make_plain_diff_line(old_start + offset, old_line),
+ make_plain_diff_line(new_start + offset, new_line),
+ ),
+ )
+ (Some(old_line), None) =>
+ rows.push(OldOnly(make_plain_diff_line(old_start + offset, old_line)))
+ (None, Some(new_line)) =>
+ rows.push(NewOnly(make_plain_diff_line(new_start + offset, new_line)))
+ (None, None) => ()
+ }
+ }
+ rows
+}
+
+///|
+fn hunk_range(edits : ArrayView[@diff.Edit]) -> (Int, Int, Int, Int) {
+ let (old_start, new_start, _, _) = edit_bounds(edits[0])
+ let (_, _, old_end, new_end) = edit_bounds(edits[edits.length() - 1])
+ (old_start, old_end - old_start, new_start, new_end - new_start)
+}
+
+///|
+fn semantic_hunk(hunk : @diff.Hunk[ComparableLine]) -> DiffHunk {
+ let blocks : Array[DiffBlock] = []
+ let edits = hunk.edits()
+ let old_view = hunk.old_view()
+ let new_view = hunk.new_view()
+ let mut index = 0
+ while index < edits.length() {
+ match edits[index] {
+ Delete(old_index~, old_len~, ..) if index + 1 < edits.length() &&
+ edits[index + 1] is Insert(..) => {
+ guard! edits[index + 1] is Insert(new_index~, new_len~, ..)
+ blocks.push(
+ ChangeBlock(
+ semantic_change_rows(
+ old_view.view(start=old_index, end=old_index + old_len),
+ new_view.view(start=new_index, end=new_index + new_len),
+ old_index,
+ new_index,
+ ),
+ ),
+ )
+ index += 2
+ }
+ Equal(old_index~, new_index~, len~) => {
+ let lines : Array[ContextLine] = []
+ for offset in 0.. {
+ let rows : Array[DiffRow] = []
+ for offset in 0.. {
+ let rows : Array[DiffRow] = []
+ for offset in 0.. DiffHunk {
+ let blocks : Array[DiffBlock] = []
+ let edits = hunk.edits()
+ let old_view = hunk.old_view()
+ let new_view = hunk.new_view()
+ let mut index = 0
+ while index < edits.length() {
+ match edits[index] {
+ Delete(old_index~, old_len~, ..) if index + 1 < edits.length() &&
+ edits[index + 1] is Insert(..) => {
+ guard! edits[index + 1] is Insert(new_index~, new_len~, ..)
+ blocks.push(
+ ChangeBlock(
+ plain_change_rows(
+ old_view.view(start=old_index, end=old_index + old_len),
+ new_view.view(start=new_index, end=new_index + new_len),
+ old_index,
+ new_index,
+ ),
+ ),
+ )
+ index += 2
+ }
+ Equal(old_index~, new_index~, len~) => {
+ let lines : Array[ContextLine] = []
+ for offset in 0.. {
+ let rows : Array[DiffRow] = []
+ for offset in 0.. {
+ let rows : Array[DiffRow] = []
+ for offset in 0.. DiffDocument {
+ let hunks : Array[DiffHunk] = []
+ for hunk in rendering_diff(old, new, line_cleanup).group(context~) {
+ hunks.push(semantic_hunk(hunk))
+ }
+ { hunks, }
+}
+
+///|
+/// Calculate a plain Patience line diff without lexing, semantic cleanup, or
+/// intraline changes.
+pub fn line_diff(
+ old~ : ArrayView[String],
+ new~ : ArrayView[String],
+ context? : Int = 3,
+) -> DiffDocument {
+ let hunks : Array[DiffHunk] = []
+ for hunk in @diff.Diff(old~, new~, algorithm=Patience).group(context~) {
+ hunks.push(plain_hunk(hunk))
+ }
+ { hunks, }
+}
diff --git a/model_test.mbt b/model_test.mbt
new file mode 100644
index 0000000..2e6dbdf
--- /dev/null
+++ b/model_test.mbt
@@ -0,0 +1,109 @@
+// Copyright 2026 International Digital Economy Academy
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+///|
+test "public IR retains hunk ranges, block boundaries, and source text" {
+ let document = @ldiff.diff(
+ old=["context", " let old = 1", "tail"],
+ new=["context", " let new = 1", "tail"],
+ context=1,
+ )
+ guard document.hunks is [hunk] else { fail("expected one hunk") }
+ assert_eq(hunk.header, "@@ -1,3 +1,3 @@")
+ assert_eq(
+ (hunk.old_start, hunk.old_len, hunk.new_start, hunk.new_len),
+ (0, 3, 0, 3),
+ )
+ guard hunk.blocks
+ is [ContextBlock(before), ChangeBlock(rows), ContextBlock(after)] else {
+ fail("expected context/change/context blocks")
+ }
+ guard before is [before_line] && after is [after_line] else {
+ fail("expected one context line on each side of the change")
+ }
+ assert_eq(
+ (before_line.old_index, before_line.new_index, before_line.text),
+ (0, 0, "context"),
+ )
+ assert_eq(
+ (after_line.old_index, after_line.new_index, after_line.text),
+ (2, 2, "tail"),
+ )
+ guard rows is [Paired(old_line, new_line)] else {
+ fail("expected one paired change row")
+ }
+ assert_eq(old_line.index, 1)
+ assert_eq(new_line.index, 1)
+ assert_eq(old_line.text(), " let old = 1")
+ assert_eq(new_line.text(), " let new = 1")
+ assert_true(old_line.segments.any(segment => segment.kind is Changed))
+ assert_true(new_line.segments.any(segment => segment.kind is Changed))
+}
+
+///|
+test "public IR distinguishes blank inserted lines from a missing old side" {
+ let document = @ldiff.line_diff(old=[], new=["x", ""], context=0)
+ guard document.hunks is [hunk] else { fail("expected one insertion hunk") }
+ assert_eq(hunk.header, "@@ -0,0 +1,2 @@")
+ assert_eq(
+ (hunk.old_start, hunk.old_len, hunk.new_start, hunk.new_len),
+ (0, 0, 0, 2),
+ )
+ guard hunk.blocks is [ChangeBlock([NewOnly(first), NewOnly(blank)])] else {
+ fail("expected two present new-side rows")
+ }
+ assert_eq((first.index, first.text()), (0, "x"))
+ assert_eq((blank.index, blank.text()), (1, ""))
+ assert_eq(blank.segments.length(), 0)
+}
+
+///|
+test "plain line diff never marks intraline segments as changed" {
+ let document = @ldiff.line_diff(old=["old"], new=["new"], context=0)
+ guard document.hunks is [{ blocks: [ChangeBlock(rows)], .. }] else {
+ fail("expected one change block")
+ }
+ for row in rows {
+ match row {
+ Paired(old_line, new_line) => {
+ assert_true(old_line.segments.all(segment => segment.kind is Unchanged))
+ assert_true(new_line.segments.all(segment => segment.kind is Unchanged))
+ }
+ OldOnly(line) | NewOnly(line) =>
+ assert_true(line.segments.all(segment => segment.kind is Unchanged))
+ }
+ }
+}
+
+///|
+test "calculation IR retains raw text without renderer markup or escaping" {
+ let document = @ldiff.diff(
+ old=["x < old && y"],
+ new=["x < new && y"],
+ context=0,
+ )
+ guard document.hunks
+ is [{ blocks: [ChangeBlock([Paired(old_line, new_line)])], .. }] else {
+ fail("expected one paired semantic row")
+ }
+ assert_eq(old_line.text(), "x < old && y")
+ assert_eq(new_line.text(), "x < new && y")
+ for line in [old_line, new_line] {
+ for segment in line.segments {
+ assert_false(segment.text.contains(" String
+pub fn diff(old~ : ArrayView[String], new~ : ArrayView[String], context? : Int, line_cleanup? : Bool) -> DiffDocument
-pub fn side_by_side_html(old~ : ArrayView[String], new~ : ArrayView[String], context? : Int, line_cleanup? : Bool, line_numbers? : Bool, hunk_notes? : ArrayView[HunkNote?]) -> String
-
-pub fn side_by_side_hunks_html(old~ : ArrayView[String], new~ : ArrayView[String], context? : Int, line_cleanup? : Bool, line_numbers? : Bool, hunk_notes? : ArrayView[HunkNote?]) -> Array[String]
-
-pub fn side_by_side_line_html(old~ : ArrayView[String], new~ : ArrayView[String], context? : Int, line_numbers? : Bool, hunk_notes? : ArrayView[HunkNote?]) -> String
-
-pub fn side_by_side_line_hunks_html(old~ : ArrayView[String], new~ : ArrayView[String], context? : Int, line_numbers? : Bool, hunk_notes? : ArrayView[HunkNote?]) -> Array[String]
+pub fn line_diff(old~ : ArrayView[String], new~ : ArrayView[String], context? : Int) -> DiffDocument
pub fn similarity(String, String) -> Int
pub fn tokenize_line(String) -> Array[Tok]
-pub fn unified_html(old~ : ArrayView[String], new~ : ArrayView[String], context? : Int, line_cleanup? : Bool, line_numbers? : Bool, hunk_notes? : ArrayView[HunkNote?]) -> String
+// Errors
-pub fn unified_hunks(old~ : ArrayView[String], new~ : ArrayView[String], context? : Int, line_cleanup? : Bool) -> Array[String]
+// Types and methods
+pub(all) struct ContextLine {
+ old_index : Int
+ new_index : Int
+ text : String
+} derive(Eq)
-pub fn unified_hunks_html(old~ : ArrayView[String], new~ : ArrayView[String], context? : Int, line_cleanup? : Bool, line_numbers? : Bool, hunk_notes? : ArrayView[HunkNote?]) -> Array[String]
+pub(all) enum DiffBlock {
+ ContextBlock(Array[ContextLine])
+ ChangeBlock(Array[DiffRow])
+} derive(Eq)
-pub fn unified_line_html(old~ : ArrayView[String], new~ : ArrayView[String], context? : Int, line_numbers? : Bool, hunk_notes? : ArrayView[HunkNote?]) -> String
+pub(all) struct DiffDocument {
+ hunks : Array[DiffHunk]
+} derive(Eq)
-pub fn unified_line_hunks(old~ : ArrayView[String], new~ : ArrayView[String], context? : Int) -> Array[String]
+pub(all) struct DiffHunk {
+ header : String
+ old_start : Int
+ old_len : Int
+ new_start : Int
+ new_len : Int
+ blocks : Array[DiffBlock]
+} derive(Eq)
-pub fn unified_line_hunks_html(old~ : ArrayView[String], new~ : ArrayView[String], context? : Int, line_numbers? : Bool, hunk_notes? : ArrayView[HunkNote?]) -> Array[String]
+pub(all) struct DiffLine {
+ index : Int
+ segments : Array[DiffSegment]
+} derive(Eq)
+pub fn DiffLine::text(Self) -> String
-pub fn weight(TokKind) -> Int
+pub(all) enum DiffRow {
+ Paired(DiffLine, DiffLine)
+ OldOnly(DiffLine)
+ NewOnly(DiffLine)
+} derive(Eq)
-// Errors
+pub(all) struct DiffSegment {
+ kind : DiffSegmentKind
+ text : String
+} derive(Eq)
-// Types and methods
-pub(all) struct HunkNote {
- title : String
- body : String
+pub(all) enum DiffSegmentKind {
+ Unchanged
+ Changed
} derive(Eq)
pub struct Tok {
diff --git a/playground/main/analysis.mbt b/playground/main/analysis.mbt
index d0c480d..4cb4c0b 100644
--- a/playground/main/analysis.mbt
+++ b/playground/main/analysis.mbt
@@ -225,16 +225,24 @@ extern "js" fn send_analysis_request(
#| .catch(() => failed());
#| }
+///|
+fn file_diff_document(
+ mode : FileDiffMode,
+ old : ArrayView[String],
+ new : ArrayView[String],
+) -> @ldiff.DiffDocument {
+ match mode {
+ MoonBitDiff => @ldiff.diff(old~, new~, context=3, line_cleanup=true)
+ LineDiff => @ldiff.line_diff(old~, new~, context=3)
+ }
+}
+
///|
fn file_hunks(file : FileState) -> Array[String] {
guard (file.old_side, file.new_side) is (Ready(old), Ready(new)) else {
return []
}
- match file.diff_mode {
- MoonBitDiff =>
- @ldiff.unified_hunks(old~, new~, context=3, line_cleanup=true)
- LineDiff => @ldiff.unified_line_hunks(old~, new~, context=3)
- }
+ @ldiff_text.render_unified_hunks(file_diff_document(file.diff_mode, old, new))
}
///|
@@ -280,17 +288,12 @@ fn build_analysis_request(
let skipped : Array[SkippedFile] = []
let mut patch_bytes = 0
for file_index, state in view.files {
- match blocking_source_problem(state) {
- Some(message) =>
- return Err("Could not prepare \{state.file.filename}: \{message}")
- None => ()
+ if blocking_source_problem(state) is Some(message) {
+ return Err("Could not prepare \{state.file.filename}: \{message}")
}
- match non_text_reason(state) {
- Some(reason) => {
- skipped.push({ path: state.file.filename, reason })
- continue
- }
- None => ()
+ if non_text_reason(state) is Some(reason) {
+ skipped.push({ path: state.file.filename, reason })
+ continue
}
for hunk_index, patch in file_hunks(state) {
patch_bytes += @utf8.encode(patch).length()
@@ -305,9 +308,10 @@ fn build_analysis_request(
hunks.push({
id: "f\{file_index}-h\{hunk_index}",
path: state.file.filename,
- previous_path: match state.file.previous_filename {
- Some(path) => Json::string(path)
- None => Json::null()
+ previous_path: if state.file.previous_filename is Some(path) {
+ Json::string(path)
+ } else {
+ Json::null()
},
status: state.file.status,
patch,
@@ -320,9 +324,10 @@ fn build_analysis_request(
owner: view.reference.owner,
repo: view.reference.repo,
sha: view.sha,
- parent_sha: match view.parent_sha {
- Some(parent) => Json::string(parent)
- None => Json::null()
+ parent_sha: if view.parent_sha is Some(parent) {
+ Json::string(parent)
+ } else {
+ Json::null()
},
message: view.message,
html_url: view.html_url,
@@ -338,7 +343,7 @@ fn analysis_note(
result : AnalysisResult,
file_index : Int,
hunk_index : Int,
-) -> @ldiff.HunkNote? {
+) -> @ldiff_html.HunkNote? {
let id = "f\{file_index}-h\{hunk_index}"
for group in result.groups {
for hunk in group.hunks {
@@ -353,11 +358,11 @@ fn analysis_note(
///|
fn notes_for_file(
result : AnalysisResult,
- file : FileState,
file_index : Int,
-) -> Array[@ldiff.HunkNote?] {
- let notes : Array[@ldiff.HunkNote?] = []
- for hunk_index, _ in file_hunks(file) {
+ hunk_count : Int,
+) -> Array[@ldiff_html.HunkNote?] {
+ let notes : Array[@ldiff_html.HunkNote?] = []
+ for hunk_index in 0.. SideLoad {
- match url {
- None => Ready([])
- Some(_) => if expanded { Loading } else { NotRequested }
+ if url is Some(_) {
+ if expanded {
+ Loading
+ } else {
+ NotRequested
+ }
+ } else {
+ Ready([])
}
}
@@ -414,18 +419,14 @@ fn continue_analysis(
return (model, @rabbita.none)
}
for file in view.files {
- match blocking_source_problem(file) {
- Some(message) =>
- return (
- with_analysis_status(
- model,
- AnalysisFailed(
- "Could not prepare \{file.file.filename}: \{message}",
- ),
- ),
- @rabbita.none,
- )
- None => ()
+ if blocking_source_problem(file) is Some(message) {
+ return (
+ with_analysis_status(
+ model,
+ AnalysisFailed("Could not prepare \{file.file.filename}: \{message}"),
+ ),
+ @rabbita.none,
+ )
}
}
if sources_pending(view) {
@@ -551,15 +552,15 @@ fn update(
}
}
CopyShareLink =>
- match model.share_url {
- None => (model, @rabbita.none)
- Some(url) =>
- (
- model,
- @clipboard.copy(Text(url), copied=emit(ShareCopied), failed=message => {
- emit(ShareCopyFailed(message))
- }),
- )
+ if model.share_url is Some(url) {
+ (
+ model,
+ @clipboard.copy(Text(url), copied=emit(ShareCopied), failed=message => {
+ emit(ShareCopyFailed(message))
+ }),
+ )
+ } else {
+ (model, @rabbita.none)
}
ShareCopied =>
(
@@ -630,19 +631,18 @@ fn update(
ToggleAnalysisGroup(index) =>
match model.analysis_status {
AnalysisDone(result, skipped, expanded) =>
- match expanded.get(index) {
- Some(is_expanded) => {
- let changed = expanded.copy()
- changed[index] = !is_expanded
- (
- with_analysis_status(
- model,
- AnalysisDone(result, skipped, changed),
- ),
- @rabbita.none,
- )
- }
- None => (model, @rabbita.none)
+ if expanded.get(index) is Some(is_expanded) {
+ let changed = expanded.copy()
+ changed[index] = !is_expanded
+ (
+ with_analysis_status(
+ model,
+ AnalysisDone(result, skipped, changed),
+ ),
+ @rabbita.none,
+ )
+ } else {
+ (model, @rabbita.none)
}
_ => (model, @rabbita.none)
}
diff --git a/playground/main/view.mbt b/playground/main/view.mbt
index 16ab27f..a231132 100644
--- a/playground/main/view.mbt
+++ b/playground/main/view.mbt
@@ -163,41 +163,40 @@ fn copy_button_label(status : CopyStatus) -> String {
///|
fn share_control(model : Model, emit : @rabbita.Emit[Msg]) -> @rabbita.Html {
- match model.share_url {
- None => @html.nothing
- Some(url) => {
- let label = copy_button_label(model.copy_status)
- @html.fragment([
- @html.div(class="share-accessibility", [
- @html.label(for_="share-url", "Shareable playground URL"),
- @html.input(
- id="share-url",
- value=url,
- read_only=true,
- class="share-url",
- attrs=@html.Attrs::build().tabindex(-1),
- ),
- ]),
- @html.button(
- type_="button",
- class=if model.copy_status is Copied {
- "toolbar-button copy-button copied"
- } else {
- "toolbar-button copy-button"
- },
- title=match model.copy_status {
- CopyError(message) => "Clipboard access failed: \{message}"
- _ => label
- },
- attrs=@html.Attrs::build().aria_label(label),
- on_click=emit(CopyShareLink),
- [
- @html.span(class="copy-icon", ""),
- @html.span(class="copy-label", label),
- ],
+ if model.share_url is Some(url) {
+ let label = copy_button_label(model.copy_status)
+ @html.fragment([
+ @html.div(class="share-accessibility", [
+ @html.label(for_="share-url", "Shareable playground URL"),
+ @html.input(
+ id="share-url",
+ value=url,
+ read_only=true,
+ class="share-url",
+ attrs=@html.Attrs::build().tabindex(-1),
),
- ])
- }
+ ]),
+ @html.button(
+ type_="button",
+ class=if model.copy_status is Copied {
+ "toolbar-button copy-button copied"
+ } else {
+ "toolbar-button copy-button"
+ },
+ title=match model.copy_status {
+ CopyError(message) => "Clipboard access failed: \{message}"
+ _ => label
+ },
+ attrs=@html.Attrs::build().aria_label(label),
+ on_click=emit(CopyShareLink),
+ [
+ @html.span(class="copy-icon", ""),
+ @html.span(class="copy-label", label),
+ ],
+ ),
+ ])
+ } else {
+ @html.nothing
}
}
@@ -274,9 +273,10 @@ fn file_diff_view(
mode : ViewMode,
emit : @rabbita.Emit[Msg],
) -> @rabbita.Html {
- let problem = match source_problem(file.old_side) {
- Some(message) => Some(message)
- None => source_problem(file.new_side)
+ let problem = if source_problem(file.old_side) is Some(message) {
+ Some(message)
+ } else {
+ source_problem(file.new_side)
}
if problem is Some(message) {
let retry = file.old_side is LoadFailed(_) || file.new_side is LoadFailed(_)
@@ -296,32 +296,10 @@ fn file_diff_view(
}
match (file.old_side, file.new_side) {
(Ready(old), Ready(new)) => {
- let rendered = match (file.diff_mode, mode) {
- (MoonBitDiff, Split) =>
- @ldiff.side_by_side_html(
- old~,
- new~,
- context=3,
- line_cleanup=true,
- line_numbers=true,
- )
- (MoonBitDiff, Unified) =>
- @ldiff.unified_html(
- old~,
- new~,
- context=3,
- line_cleanup=true,
- line_numbers=true,
- )
- (LineDiff, Split) =>
- @ldiff.side_by_side_line_html(
- old~,
- new~,
- context=3,
- line_numbers=true,
- )
- (LineDiff, Unified) =>
- @ldiff.unified_line_html(old~, new~, context=3, line_numbers=true)
+ let document = file_diff_document(file.diff_mode, old, new)
+ let rendered = match mode {
+ Split => @ldiff_html.render_side_by_side(document, line_numbers=true)
+ Unified => @ldiff_html.render_unified(document, line_numbers=true)
}
// `rendered` is the only raw HTML accepted by the application. ldiff
// has already escaped every source line and hunk header.
@@ -427,42 +405,17 @@ fn analysis_file_hunks_html(
guard (file.old_side, file.new_side) is (Ready(old), Ready(new)) else {
return []
}
- let hunk_notes = notes_for_file(result, file, file_index)
- match (file.diff_mode, mode) {
- (MoonBitDiff, Split) =>
- @ldiff.side_by_side_hunks_html(
- old~,
- new~,
- context=3,
- line_cleanup=true,
- line_numbers=true,
- hunk_notes~,
- )
- (MoonBitDiff, Unified) =>
- @ldiff.unified_hunks_html(
- old~,
- new~,
- context=3,
- line_cleanup=true,
- line_numbers=true,
- hunk_notes~,
- )
- (LineDiff, Split) =>
- @ldiff.side_by_side_line_hunks_html(
- old~,
- new~,
- context=3,
- line_numbers=true,
- hunk_notes~,
- )
- (LineDiff, Unified) =>
- @ldiff.unified_line_hunks_html(
- old~,
- new~,
- context=3,
+ let document = file_diff_document(file.diff_mode, old, new)
+ let hunk_notes = notes_for_file(result, file_index, document.hunks.length())
+ match mode {
+ Split =>
+ @ldiff_html.render_side_by_side_hunks(
+ document,
line_numbers=true,
hunk_notes~,
)
+ Unified =>
+ @ldiff_html.render_unified_hunks(document, line_numbers=true, hunk_notes~)
}
}
@@ -514,30 +467,29 @@ fn analysis_hunk_view(
hunk : AnalyzedHunk,
rendered : Map[String, RenderedAnalysisHunk],
) -> @rabbita.Html {
- match rendered.get(hunk.id) {
- Some(item) =>
- @html.article(class="analysis-hunk", [
- @html.div(class="analysis-hunk-heading", [
- @html.div(class="analysis-hunk-file", [
- @html.span(class=status_class(item.file.file.status), ""),
- @html.code(class="file-path", file_label(item.file.file)),
- ]),
- @html.span(class="status-label", status_label(item.file.file.status)),
+ if rendered.get(hunk.id) is Some(item) {
+ @html.article(class="analysis-hunk", [
+ @html.div(class="analysis-hunk-heading", [
+ @html.div(class="analysis-hunk-file", [
+ @html.span(class=status_class(item.file.file.status), ""),
+ @html.code(class="file-path", file_label(item.file.file)),
]),
- // ldiff escapes source text, hunk headers, group titles, and hunk
- // explanations before this raw HTML reaches the application.
- @html.div(
- class="diff-scroll",
- attrs=@html.Attrs::build().inner_html(item.html),
- ([] : Array[@rabbita.Html]),
- ),
- ])
- None =>
- @html.article(class="analysis-hunk analysis-hunk-error", [
- @html.p(
- "The analysis referenced \{hunk.id}, but that diff hunk could not be rendered.",
- ),
- ])
+ @html.span(class="status-label", status_label(item.file.file.status)),
+ ]),
+ // ldiff escapes source text, hunk headers, group titles, and hunk
+ // explanations before this raw HTML reaches the application.
+ @html.div(
+ class="diff-scroll",
+ attrs=@html.Attrs::build().inner_html(item.html),
+ ([] : Array[@rabbita.Html]),
+ ),
+ ])
+ } else {
+ @html.article(class="analysis-hunk analysis-hunk-error", [
+ @html.p(
+ "The analysis referenced \{hunk.id}, but that diff hunk could not be rendered.",
+ ),
+ ])
}
}
@@ -688,9 +640,10 @@ fn commit_view(
),
@html.p(
class="parent",
- match commit.parent_sha {
- Some(parent) => "Compared with first parent \{parent}."
- None => "Root commit; compared with an empty tree."
+ if commit.parent_sha is Some(parent) {
+ "Compared with first parent \{parent}."
+ } else {
+ "Root commit; compared with an empty tree."
},
),
]),
diff --git a/renderer_migration_test.mbt b/renderer_migration_test.mbt
new file mode 100644
index 0000000..a3b9b98
--- /dev/null
+++ b/renderer_migration_test.mbt
@@ -0,0 +1,89 @@
+// Copyright 2026 International Digital Economy Academy
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+///|
+test "semantic convenience renderers equal direct IR renderers" {
+ let old = [
+ "before semantic anchor", "call(alpha, old, omega)", "", "after semantic anchor",
+ ]
+ let new = [
+ "before semantic anchor", "call(", " alpha,", " fresh,", " omega,", ")", "after semantic anchor",
+ ]
+ let cleaned = @ldiff.diff(old~, new~, context=1, line_cleanup=true)
+ let raw = @ldiff.diff(old~, new~, context=0, line_cleanup=false)
+ assert_eq(
+ @ldiff_html.render_side_by_side(cleaned, line_numbers=true),
+ @ldiff_html.side_by_side_html(
+ old~,
+ new~,
+ context=1,
+ line_cleanup=true,
+ line_numbers=true,
+ ),
+ )
+ assert_eq(
+ @ldiff_html.render_unified(raw, line_numbers=false),
+ @ldiff_html.unified_html(
+ old~,
+ new~,
+ context=0,
+ line_cleanup=false,
+ line_numbers=false,
+ ),
+ )
+ assert_eq(
+ @ldiff_html.render_side_by_side_hunks(raw),
+ @ldiff_html.side_by_side_hunks_html(old~, new~, context=0),
+ )
+ assert_eq(
+ @ldiff_html.render_unified_hunks(raw, line_numbers=true),
+ @ldiff_html.unified_hunks_html(old~, new~, context=0, line_numbers=true),
+ )
+ assert_eq(
+ @ldiff_text.render_unified_hunks(
+ @ldiff.diff(old~, new~, context=0, line_cleanup=true),
+ ),
+ @ldiff_text.unified_hunks(old~, new~, context=0, line_cleanup=true),
+ )
+}
+
+///|
+test "plain convenience renderers equal direct IR renderers" {
+ for
+ pair in [
+ (([] : Array[String]), ["x", ""]),
+ (["x", ""], ([] : Array[String])),
+ (["same", "old", "tail"], ["same", "new", "extra", "tail"]),
+ ] {
+ let (old, new) = pair
+ let document = @ldiff.line_diff(old~, new~, context=0)
+ assert_eq(
+ @ldiff_html.render_side_by_side(document, line_numbers=true),
+ @ldiff_html.side_by_side_line_html(
+ old~,
+ new~,
+ context=0,
+ line_numbers=true,
+ ),
+ )
+ assert_eq(
+ @ldiff_html.render_unified(document),
+ @ldiff_html.unified_line_html(old~, new~, context=0),
+ )
+ assert_eq(
+ @ldiff_text.render_unified_hunks(document),
+ @ldiff_text.unified_line_hunks(old~, new~, context=0),
+ )
+ }
+}
diff --git a/snapshot/pkg.generated.mbti b/snapshot/pkg.generated.mbti
deleted file mode 100644
index 9294454..0000000
--- a/snapshot/pkg.generated.mbti
+++ /dev/null
@@ -1,12 +0,0 @@
-// Generated using `moon info`, DON'T EDIT IT
-package "moonbit-community/ldiff/snapshot"
-
-// Values
-
-// Errors
-
-// Types and methods
-
-// Type aliases
-
-// Traits
diff --git a/text/moon.pkg b/text/moon.pkg
new file mode 100644
index 0000000..cfbd645
--- /dev/null
+++ b/text/moon.pkg
@@ -0,0 +1,3 @@
+import {
+ "moonbit-community/ldiff",
+}
diff --git a/text/pkg.generated.mbti b/text/pkg.generated.mbti
new file mode 100644
index 0000000..5a3e253
--- /dev/null
+++ b/text/pkg.generated.mbti
@@ -0,0 +1,21 @@
+// Generated using `moon info`, DON'T EDIT IT
+package "moonbit-community/ldiff/text"
+
+import {
+ "moonbit-community/ldiff",
+}
+
+// Values
+pub fn render_unified_hunks(@ldiff.DiffDocument) -> Array[String]
+
+pub fn unified_hunks(old~ : ArrayView[String], new~ : ArrayView[String], context? : Int, line_cleanup? : Bool) -> Array[String]
+
+pub fn unified_line_hunks(old~ : ArrayView[String], new~ : ArrayView[String], context? : Int) -> Array[String]
+
+// Errors
+
+// Types and methods
+
+// Type aliases
+
+// Traits
diff --git a/text/render.mbt b/text/render.mbt
new file mode 100644
index 0000000..388a846
--- /dev/null
+++ b/text/render.mbt
@@ -0,0 +1,84 @@
+// Copyright 2026 International Digital Economy Academy
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+///|
+fn append_diff_line(
+ buf : StringBuilder,
+ prefix : String,
+ line : @ldiff.DiffLine,
+) -> Unit {
+ buf <+ "\{prefix}"
+ for segment in line.segments {
+ buf <+ "\{segment.text}"
+ }
+ buf <+ "\n"
+}
+
+///|
+fn render_hunk(hunk : @ldiff.DiffHunk) -> String {
+ let buf = StringBuilder::new()
+ buf <+ "\{hunk.header}\n"
+ for block in hunk.blocks {
+ match block {
+ ContextBlock(lines) =>
+ for line in lines {
+ buf <+ " \{line.text}\n"
+ }
+ ChangeBlock(rows) => {
+ for row in rows {
+ match row {
+ Paired(old_line, _) | OldOnly(old_line) =>
+ append_diff_line(buf, "-", old_line)
+ NewOnly(_) => ()
+ }
+ }
+ for row in rows {
+ match row {
+ Paired(_, new_line) | NewOnly(new_line) =>
+ append_diff_line(buf, "+", new_line)
+ OldOnly(_) => ()
+ }
+ }
+ }
+ }
+ }
+ buf.to_string()
+}
+
+///|
+/// Render every hunk in a calculated diff as unified patch text.
+pub fn render_unified_hunks(document : @ldiff.DiffDocument) -> Array[String] {
+ document.hunks.map(render_hunk)
+}
+
+///|
+/// Return unified patch hunks for a MoonBit-aware diff.
+pub fn unified_hunks(
+ old~ : ArrayView[String],
+ new~ : ArrayView[String],
+ context? : Int = 3,
+ line_cleanup? : Bool = false,
+) -> Array[String] {
+ render_unified_hunks(@ldiff.diff(old~, new~, context~, line_cleanup~))
+}
+
+///|
+/// Return unified patch hunks for a plain Patience line diff.
+pub fn unified_line_hunks(
+ old~ : ArrayView[String],
+ new~ : ArrayView[String],
+ context? : Int = 3,
+) -> Array[String] {
+ render_unified_hunks(@ldiff.line_diff(old~, new~, context~))
+}
diff --git a/token.mbt b/token.mbt
index 969c02d..07c3bac 100644
--- a/token.mbt
+++ b/token.mbt
@@ -49,8 +49,8 @@ pub fn Tok::text(self : Tok) -> String {
/// The baseline alignment weight of a token class (integer; all scoring is
/// integer so results are bit-identical on every backend). Token-level
/// refinements such as comma downweighting are applied by the internal scorer.
-pub fn weight(k : TokKind) -> Int {
- match k {
+fn TokKind::weight(self : TokKind) -> Int {
+ match self {
Word | Str | Marker => 20
Punct => 6
Comment => 2
@@ -68,7 +68,7 @@ fn alignment_weight(token : Tok) -> Int {
if token.kind is Punct && token.text == "," {
1
} else {
- weight(token.kind)
+ token.kind.weight()
}
}
diff --git a/window_test.mbt b/window_test.mbt
index 9a6dbdf..d06439b 100644
--- a/window_test.mbt
+++ b/window_test.mbt
@@ -16,8 +16,8 @@
test "pure cross-line reflow keeps row colors without strong token markup" {
let old = ["call(alpha, beta, gamma)"]
let new = ["call(", " alpha, beta,", " gamma)"]
- let split = @ldiff.side_by_side_html(old~, new~, context=0)
- let unified = @ldiff.unified_html(old~, new~, context=0)
+ let split = @ldiff_html.side_by_side_html(old~, new~, context=0)
+ let unified = @ldiff_html.unified_html(old~, new~, context=0)
assert_true(split.contains("class=\"del\""))
assert_true(split.contains("class=\"add\""))
assert_true(unified.contains("class=\"del\""))
@@ -30,8 +30,18 @@ test "pure cross-line reflow keeps row colors without strong token markup" {
test "expanded reflow highlights inserted tokens on unpaired lines" {
let old = ["compute(alpha, beta)"]
let new = ["compute(", " alpha,", " beta,", " gamma < limit, ", ")"]
- let split = @ldiff.side_by_side_html(old~, new~, context=0, line_numbers=true)
- let unified = @ldiff.unified_html(old~, new~, context=0, line_numbers=true)
+ let split = @ldiff_html.side_by_side_html(
+ old~,
+ new~,
+ context=0,
+ line_numbers=true,
+ )
+ let unified = @ldiff_html.unified_html(
+ old~,
+ new~,
+ context=0,
+ line_numbers=true,
+ )
let beta = " beta,"
let added = " gamma < limit, "
assert_true(split.contains(beta))
@@ -56,8 +66,8 @@ test "expanded reflow highlights inserted tokens on unpaired lines" {
test "reflowed substitution and trailing comma stay independently changed" {
let old = ["call(alpha, old, omega)"]
let new = ["call(", " alpha,", " fresh,", " omega,", ")"]
- let split = @ldiff.side_by_side_html(old~, new~, context=0)
- let unified = @ldiff.unified_html(old~, new~, context=0)
+ let split = @ldiff_html.side_by_side_html(old~, new~, context=0)
+ let unified = @ldiff_html.unified_html(old~, new~, context=0)
let old_change = "call(alpha, old, omega)"
let replacement = " fresh,"
let trailing_comma = " omega,"
@@ -72,8 +82,8 @@ test "reflowed substitution and trailing comma stay independently changed" {
test "rejected multi-line window strongly highlights unpaired lines" {
let old = ["let total = price", "unrelated old"]
let new = ["let sum = price", "different new"]
- let split = @ldiff.side_by_side_html(old~, new~, context=0)
- let unified = @ldiff.unified_html(old~, new~, context=0)
+ let split = @ldiff_html.side_by_side_html(old~, new~, context=0)
+ let unified = @ldiff_html.unified_html(old~, new~, context=0)
let old_pair = "let total = price"
let new_pair = "let sum = price"
let old_unpaired = "unrelated old"
@@ -104,8 +114,8 @@ test "window traceback cell overflow takes the complete bounded fallback" {
let new_line = "x ".repeat(256) + "new"
let old = [old_line, old_line]
let new = [new_line, new_line]
- let split = @ldiff.side_by_side_html(old~, new~, context=0)
- let unified = @ldiff.unified_html(old~, new~, context=0)
+ let split = @ldiff_html.side_by_side_html(old~, new~, context=0)
+ let unified = @ldiff_html.unified_html(old~, new~, context=0)
assert_false(split.contains("\{old_line}"))
@@ -121,24 +131,29 @@ test "context and line cleanup keep meaningful anchors outside the window" {
"before semantic anchor", "call(", " alpha,", " fresh,", " omega,", ")", "after semantic anchor",
]
for context in [0, 3] {
- let split = @ldiff.side_by_side_html(
+ let split = @ldiff_html.side_by_side_html(
+ old~,
+ new~,
+ context~,
+ line_cleanup=false,
+ )
+ let unified = @ldiff_html.unified_html(
old~,
new~,
context~,
line_cleanup=false,
)
- let unified = @ldiff.unified_html(old~, new~, context~, line_cleanup=false)
assert_true(split.contains("old"))
assert_true(split.contains("fresh"))
assert_true(unified.contains("old"))
assert_true(unified.contains("fresh"))
assert_eq(
split,
- @ldiff.side_by_side_html(old~, new~, context~, line_cleanup=true),
+ @ldiff_html.side_by_side_html(old~, new~, context~, line_cleanup=true),
)
assert_eq(
unified,
- @ldiff.unified_html(old~, new~, context~, line_cleanup=true),
+ @ldiff_html.unified_html(old~, new~, context~, line_cleanup=true),
)
}
}