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