From 9707713972327812eac571bd78b6c89fa163671e Mon Sep 17 00:00:00 2001 From: myfreess Date: Wed, 12 Aug 2026 16:17:34 +0800 Subject: [PATCH 1/2] cleanup --- cleanup_test.mbt | 38 ++--- distance.mbt | 71 +++++---- html.mbt | 278 +++++++++++++++++------------------ ldiff_test.mbt | 126 ++++++---------- ldiff_wbtest.mbt | 16 +- line_cleanup.mbt | 43 +++--- line_html.mbt | 19 +-- playground/main/analysis.mbt | 29 ++-- playground/main/state.mbt | 74 +++++----- playground/main/view.mbt | 126 ++++++++-------- 10 files changed, 375 insertions(+), 445 deletions(-) diff --git a/cleanup_test.mbt b/cleanup_test.mbt index 04f45d0..42a486a 100644 --- a/cleanup_test.mbt +++ b/cleanup_test.mbt @@ -26,16 +26,15 @@ 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( + assert_true( split.contains( "x xx", ), - content="true", ) let comment = @ldiff.unified_html(old=["// foo foo"], new=["// foo"]) - inspect(comment.contains("// foo foo"), content="true") + assert_true(comment.contains("// foo foo")) let unicode = @ldiff.side_by_side_html(old=["名 名"], new=["名"]) - inspect(unicode.contains("名 "), content="true") + assert_true(unicode.contains("名 ")) } ///| @@ -73,26 +72,19 @@ test "line cleanup is opt-in and removes a misleading blank anchor" { 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", - ) + assert_true(count_substring(default_split, "class=\"hunk-header\"") == 2) let cleaned = @ldiff.side_by_side_html( old~, new~, context=0, line_cleanup=true, ) - inspect( + assert_true( cleaned.contains( "let b = oldlet b = new", ), - content="true", - ) - inspect( - count_substring(cleaned, "class=\"hunk-header\"") == 1, - content="true", ) + assert_true(count_substring(cleaned, "class=\"hunk-header\"") == 1) } ///| @@ -100,17 +92,13 @@ 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( + assert_true( split.contains( "same linesame line", ), - content="true", ) let unified = @ldiff.unified_html(old~, new~, context=1, line_cleanup=true) - inspect( - unified.contains(" same line"), - content="true", - ) + assert_true(unified.contains(" same line")) } ///| @@ -286,18 +274,16 @@ test "per-pair traceback budget keeps pairing but omits highlights" { 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 index c700bde..93b9762 100644 --- a/html.mbt +++ b/html.mbt @@ -190,121 +190,114 @@ fn prepare_replacement( 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), - }) + 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_html, new_html) = if changes is 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", + ), + ) + } else 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]), + ) } - (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, - }) + 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 = if changes is Some(window) { + projected_line_html( + tokens.olds[old_index], + window.old_changed[old_index], + "wd", + ) + } else { + full_line_html(tokens.olds[old_index], "wd") } - (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), - }) + 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 = if changes is Some(window) { + projected_line_html( + tokens.news[new_index], + window.new_changed[new_index], + "wa", + ) + } else { + full_line_html(tokens.news[new_index], "wa") } - (None, None) => () + rows.push({ + old_html: None, + new_html: Some(new_html), + old_offset: None, + new_offset: Some(new_index), + }) } + (None, None) => () } } + } else { + 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), + }) + } } rows } ///| fn line_number_text(number : Int?) -> String { - match number { - Some(value) => value.to_string() - None => "" + if number is Some(value) { + value.to_string() + } else { + "" } } ///| fn hunk_note_at(notes : ArrayView[HunkNote?], hunk_index : Int) -> HunkNote? { - match notes.get(hunk_index) { - Some(note) => note - None => None + if notes.get(hunk_index) is Some(note) { + note + } else { + None } } @@ -350,13 +343,15 @@ fn split_replacement( 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 (old_html, old_class) = if prepared.old_html is Some(body) { + (body, "del") + } else { + ("", "empty") } - let (new_html, new_class) = match prepared.new_html { - Some(body) => (body, "add") - None => ("", "empty") + let (new_html, new_class) = if prepared.new_html is Some(body) { + (body, "add") + } else { + ("", "empty") } split_row( buf, @@ -386,9 +381,8 @@ fn append_split_hunk( buf <+ "\{esc(h.header())}\n" } - match hunk_note_at(hunk_notes, hunk_index) { - Some(note) => table_hunk_note(buf, note, if line_numbers { 4 } else { 2 }) - None => () + if hunk_note_at(hunk_notes, hunk_index) is Some(note) { + table_hunk_note(buf, note, if line_numbers { 4 } else { 2 }) } let edits = h.edits() let o = h.old_view() @@ -578,14 +572,12 @@ fn append_unified_hunk( } else { buf <+ "\{esc(h.header())}\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 => () + 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) + } } let edits = h.edits() let o = h.old_view() @@ -600,39 +592,35 @@ fn append_unified_hunk( 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 => () + if row.old_html is 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) + } } } 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 => () + if row.new_html is 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) + } } } i += 2 diff --git a/ldiff_test.mbt b/ldiff_test.mbt index 3651dae..e66242a 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) } ///| @@ -79,21 +70,20 @@ test "split view: alignment, highlights, escaping" { ][:] let html = @ldiff.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=[ "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("")) } ///| @@ -105,44 +95,36 @@ test "whitespace-only edit runs avoid strong intraline highlights" { 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")) } ///| @@ -160,22 +142,20 @@ test "semantic highlights exclude boundary whitespace in both HTML views" { new=["count + tax"], context=0, ) - inspect( + assert_true( count_split.contains( "countcount + tax", ), - content="true", ) let count_unified = @ldiff.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( @@ -183,22 +163,20 @@ test "semantic highlights exclude boundary whitespace in both HTML views" { new=["f( a : Int )"], context=0, ) - inspect( + assert_true( parameter_split.contains( "f()f( a : Int )", ), - content="true", ) let parameter_unified = @ldiff.unified_html( old=["f()"], new=["f( a : Int )"], context=0, ) - inspect( + assert_true( parameter_unified.contains( "+f( a : Int )", ), - content="true", ) } @@ -208,7 +186,7 @@ test "budget fallbacks render plain rows" { 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("")) } ///| @@ -284,11 +261,11 @@ test "unified view mirrors the split alignment" { let new = ["let sum = a + b // sum", "other_entirely stuff"][:] let html = @ldiff.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) } ///| @@ -314,32 +291,28 @@ test "numbered split has four columns and complete old and new positions" { 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( "1ctx1ctx", ), - content="true", ) - inspect( + assert_true( html.contains( "2old <tag>", ), - content="true", ) - inspect( + assert_true( html.contains( "3", ), - content="true", ) - inspect( + assert_true( html.contains( "3inserted", ), - content="true", ) - inspect(html.contains("new & value"), content="true") + assert_true(html.contains("new & value")) } ///| @@ -353,31 +326,27 @@ test "numbered unified has three columns across replacements and multiple hunks" 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( "", ), - content="true", ) - inspect( + assert_true( html.contains( "", ), - content="true", ) - inspect( + assert_true( html.contains( "", ), - content="true", ) - inspect( + assert_true( html.contains( "", ), - content="true", ) } @@ -405,10 +374,9 @@ test "numbered pure add and delete use an empty number cell for the absent side" context=0, line_numbers=true, ) - inspect( + assert_true( deleted.contains( "", ), - content="true", ) } diff --git a/ldiff_wbtest.mbt b/ldiff_wbtest.mbt index 2afb2f2..fe1abdc 100644 --- a/ldiff_wbtest.mbt +++ b/ldiff_wbtest.mbt @@ -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,9 +132,9 @@ 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) } ///| diff --git a/line_cleanup.mbt b/line_cleanup.mbt index 4bfb5ee..5aaa919 100644 --- a/line_cleanup.mbt +++ b/line_cleanup.mbt @@ -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 } - _ => () } + _ => () } } } diff --git a/line_html.mbt b/line_html.mbt index 2606a15..12001c8 100644 --- a/line_html.mbt +++ b/line_html.mbt @@ -85,9 +85,8 @@ fn append_split_line_hunk( buf <+ "\n" } - match hunk_note_at(hunk_notes, hunk_index) { - Some(note) => table_hunk_note(buf, note, if line_numbers { 4 } else { 2 }) - None => () + if hunk_note_at(hunk_notes, hunk_index) is Some(note) { + table_hunk_note(buf, note, if line_numbers { 4 } else { 2 }) } let edits = h.edits() let old_view = h.old_view() @@ -225,14 +224,12 @@ fn append_unified_line_hunk( } else { buf <+ "\{esc(h.header())}\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 => () + 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) + } } let old_view = h.old_view() let new_view = h.new_view() diff --git a/playground/main/analysis.mbt b/playground/main/analysis.mbt index d0c480d..331b550 100644 --- a/playground/main/analysis.mbt +++ b/playground/main/analysis.mbt @@ -280,17 +280,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 +300,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 +316,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, diff --git a/playground/main/state.mbt b/playground/main/state.mbt index 9663df5..f098576 100644 --- a/playground/main/state.mbt +++ b/playground/main/state.mbt @@ -154,9 +154,14 @@ fn source_request( ///| fn initial_side(url : String?, expanded : Bool) -> 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..f4a918a 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(_) @@ -514,30 +514,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 +687,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." }, ), ]), From 387f48c45e0c93888f72d7935898093c911bc464 Mon Sep 17 00:00:00 2001 From: myfreess Date: Thu, 13 Aug 2026 16:53:03 +0800 Subject: [PATCH 2/2] refactor --- README.md | 61 +- cleanup_test.mbt | 87 +- html.mbt | 747 ------------------ {snapshot => html}/__snapshot__/basic.html | 0 .../html_fallback_resource_path.html | 0 .../__snapshot__/left_leaning_insert.html | 0 {snapshot => html}/moon.pkg | 3 + html/pkg.generated.mbti | 45 ++ html/render.mbt | 526 ++++++++++++ {snapshot => html}/snapshot_test.mbt | 20 +- hunks.mbt | 107 --- hunks_test.mbt | 74 +- ldiff_test.mbt | 46 +- ldiff_wbtest.mbt | 71 +- line_cleanup.mbt | 14 +- line_html.mbt | 355 --------- line_html_test.mbt | 10 +- model.mbt | 538 +++++++++++++ model_test.mbt | 109 +++ moon.pkg | 2 + pkg.generated.mbti | 61 +- playground/main/analysis.mbt | 28 +- playground/main/moon.pkg | 2 + playground/main/view.mbt | 71 +- renderer_migration_test.mbt | 89 +++ snapshot/pkg.generated.mbti | 12 - text/moon.pkg | 3 + text/pkg.generated.mbti | 21 + text/render.mbt | 84 ++ token.mbt | 6 +- window_test.mbt | 43 +- 31 files changed, 1780 insertions(+), 1455 deletions(-) delete mode 100644 html.mbt rename {snapshot => html}/__snapshot__/basic.html (100%) rename {snapshot => html}/__snapshot__/html_fallback_resource_path.html (100%) rename {snapshot => html}/__snapshot__/left_leaning_insert.html (100%) rename {snapshot => html}/moon.pkg (86%) create mode 100644 html/pkg.generated.mbti create mode 100644 html/render.mbt rename {snapshot => html}/snapshot_test.mbt (85%) delete mode 100644 hunks.mbt delete mode 100644 line_html.mbt create mode 100644 model.mbt create mode 100644 model_test.mbt create mode 100644 renderer_migration_test.mbt delete mode 100644 snapshot/pkg.generated.mbti create mode 100644 text/moon.pkg create mode 100644 text/pkg.generated.mbti create mode 100644 text/render.mbt 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 42a486a..5d7ef3d 100644 --- a/cleanup_test.mbt +++ b/cleanup_test.mbt @@ -25,15 +25,15 @@ 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) + let split = @ldiff_html.side_by_side_html(old=["x x"], new=["x"], context=0) assert_true( split.contains( "", ), ) - let comment = @ldiff.unified_html(old=["// foo foo"], new=["// foo"]) + let comment = @ldiff_html.unified_html(old=["// foo foo"], new=["// foo"]) assert_true(comment.contains("// foo foo")) - let unicode = @ldiff.side_by_side_html(old=["名 名"], new=["名"]) + let unicode = @ldiff_html.side_by_side_html(old=["名 名"], new=["名"]) assert_true(unicode.contains("名 ")) } @@ -44,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), ) } @@ -67,13 +72,13 @@ 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), + @ldiff_html.side_by_side_html(old~, new~, context=0, line_cleanup=false), ) assert_true(count_substring(default_split, "class=\"hunk-header\"") == 2) - let cleaned = @ldiff.side_by_side_html( + let cleaned = @ldiff_html.side_by_side_html( old~, new~, context=0, @@ -91,13 +96,23 @@ test "line cleanup is opt-in and removes a misleading blank anchor" { 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) + let split = @ldiff_html.side_by_side_html( + old~, + new~, + context=1, + line_cleanup=true, + ) assert_true( split.contains( "", ), ) - 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(" same line")) } @@ -135,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( "", ), ) - 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}")) @@ -159,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\""), @@ -185,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, @@ -201,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=( #|
       #|@@ -1,2 +0,0 @@
@@ -219,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),
   )
 }
 
@@ -269,7 +304,7 @@ 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,
@@ -280,7 +315,11 @@ test "per-pair traceback budget keeps pairing but omits highlights" {
       "
", ), ) - let unified = @ldiff.unified_html(old=[old_line], new=[new_line], context=0) + let unified = @ldiff_html.unified_html( + old=[old_line], + new=[new_line], + context=0, + ) assert_false(unified.contains(" 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] = [] - 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_html, new_html) = if changes is 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", - ), - ) - } else 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 = if changes is Some(window) { - projected_line_html( - tokens.olds[old_index], - window.old_changed[old_index], - "wd", - ) - } else { - 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 = if changes is Some(window) { - projected_line_html( - tokens.news[new_index], - window.new_changed[new_index], - "wa", - ) - } else { - 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) => () - } - } - } else { - 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), - }) - } - } - rows -} - -///| -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 <+ - "\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 <+ - "\n" - } else { - buf <+ - "\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) = if prepared.old_html is Some(body) { - (body, "del") - } else { - ("", "empty") - } - let (new_html, new_class) = if prepared.new_html is Some(body) { - (body, "add") - } else { - ("", "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" - } - if hunk_note_at(hunk_notes, hunk_index) is Some(note) { - table_hunk_note(buf, note, if line_numbers { 4 } else { 2 }) - } - 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 `
2-old2+new8-gone8+added2-
\{esc(h.header())}
x xxsame linesame line\{marker}\{marker}\{old_line}\{new_line}
\{esc(note.title)}: \{esc(note.body)}
\{line_number_text(old_number)}\{old_html}\{line_number_text(new_number)}\{new_html}
\{old_html}\{new_html}
\{esc(h.header())}
\{esc(h.header())}
`. -/// 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 <+ - "\{esc(h.header())}\n" - } else { - buf <+ "\{esc(h.header())}\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) - } - } - 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 { - if row.old_html is 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) - } - } - } - for row in prepared { - if row.new_html is 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) - } - } - } - 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 <+ + "\{esc(hunk.header)}\n" + } else { + buf <+ + "\{esc(hunk.header)}\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 <+ + "\{esc(hunk.header)}\n" + } else { + buf <+ "\{esc(hunk.header)}\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( "\{header}", ), ) @@ -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( "\{header}", ), ) @@ -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 e66242a..2f00bca 100644 --- a/ldiff_test.mbt +++ b/ldiff_test.mbt @@ -68,7 +68,7 @@ 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 assert_true(html.contains("")) // the unrelated comment line remains an unpaired deletion; the accepted @@ -79,7 +79,7 @@ test "split view: alignment, highlights, escaping" { ), ) // 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 {", ]) assert_true(esc_html.contains("<")) @@ -90,7 +90,7 @@ test "split view: alignment, highlights, escaping" { 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, @@ -101,7 +101,7 @@ test "whitespace-only edit runs avoid strong intraline highlights" { ), ) assert_false(split.contains("+\{new_indent}")) assert_false(unified.contains("countcount + tax", ), ) - let count_unified = @ldiff.unified_html( + let count_unified = @ldiff_html.unified_html( old=["count"], new=["count + tax"], context=0, @@ -158,7 +158,7 @@ test "semantic highlights exclude boundary whitespace in both HTML views" { ), ) - 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, @@ -168,7 +168,7 @@ test "semantic highlights exclude boundary whitespace in both HTML views" { "f()f( a : Int )", ), ) - let parameter_unified = @ldiff.unified_html( + let parameter_unified = @ldiff_html.unified_html( old=["f()"], new=["f( a : Int )"], context=0, @@ -185,7 +185,7 @@ 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]) + let html = @ldiff_html.side_by_side_html(old=[big_old], new=[big_new]) assert_false(html.contains("")) @@ -259,7 +259,7 @@ test "split view: blank-but-present vs absent cells" { 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 assert_true(html.contains("total")) assert_true(html.contains("sum")) @@ -273,18 +273,18 @@ 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, @@ -319,7 +319,7 @@ test "numbered split has four columns and complete old and new positions" { 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, @@ -353,7 +353,7 @@ test "numbered unified has three columns across replacements and multiple hunks" ///| 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, @@ -368,7 +368,7 @@ 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, diff --git a/ldiff_wbtest.mbt b/ldiff_wbtest.mbt index fe1abdc..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) @@ -138,67 +138,86 @@ test "fallback tokens still feed the existing alignment budgets" { } ///| -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 5aaa919..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) }) } @@ -208,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 12001c8..0000000 --- a/line_html.mbt +++ /dev/null @@ -1,355 +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 <+ - "\{esc(h.header())}\n" - } else { - buf <+ - "\{esc(h.header())}\n" - } - if hunk_note_at(hunk_notes, hunk_index) is Some(note) { - table_hunk_note(buf, note, if line_numbers { 4 } else { 2 }) - } - 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 <+ - "\{esc(h.header())}\n" - } else { - buf <+ "\{esc(h.header())}\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) - } - } - 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=( #|
       #|@@ -1 +1 @@
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 331b550..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))
 }
 
 ///|
@@ -335,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 {
@@ -350,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.. {
-      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~)
   }
 }
 
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),
     )
   }
 }