Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 60 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,62 @@
# ldiff — lexer-based diff for MoonBit code

playground: https://moonbit-community.github.io/ldiff/
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.
123 changes: 74 additions & 49 deletions cleanup_test.mbt
Original file line number Diff line number Diff line change
Expand Up @@ -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(
"<td class=\"del\">x <b class=\"wd\">x</b></td><td class=\"add\">x</td>",
),
content="true",
)
let comment = @ldiff.unified_html(old=["// foo foo"], new=["// foo"])
inspect(comment.contains("// foo <b class=\"wd\">foo</b>"), content="true")
let unicode = @ldiff.side_by_side_html(old=["名 名"], new=["名"])
inspect(unicode.contains("名 <b class=\"wd\">名</b>"), content="true")
let comment = @ldiff_html.unified_html(old=["// foo foo"], new=["// foo"])
assert_true(comment.contains("// foo <b class=\"wd\">foo</b>"))
let unicode = @ldiff_html.side_by_side_html(old=["名 名"], new=["名"])
assert_true(unicode.contains("名 <b class=\"wd\">名</b>"))
}

///|
Expand All @@ -45,72 +44,76 @@ test "global traceback fixes left-leaning insertion in both renderers" {
" if candidate.normalized_expr_identifier_name() is Some(candidate_name) {",
]
let expected = " <b class=\"wa\">if</b> candidate.normalized_expr_identifier_name() <b class=\"wa\">is Some(candidate_name)</b> {"
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),
)
}

///|
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(
"<td class=\"del\">let b = <b class=\"wd\">old</b></td><td class=\"add\">let b = <b class=\"wa\">new</b></td>",
),
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(
"<td class=\"ctx\">same line</td><td class=\"ctx\">same line</td>",
),
content="true",
)
let unified = @ldiff.unified_html(old~, new~, context=1, line_cleanup=true)
inspect(
unified.contains("<span class=\"ctx\"> same line</span>"),
content="true",
let unified = @ldiff_html.unified_html(
old~,
new~,
context=1,
line_cleanup=true,
)
assert_true(unified.contains("<span class=\"ctx\"> same line</span>"))
}

///|
Expand Down Expand Up @@ -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, .. }), .. }) =&gt;"
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(
"<td class=\"ctx\">\{marker}</td><td class=\"ctx\">\{marker}</td>",
),
)
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("<span class=\"ctx\"> \{marker}</span>"))
assert_false(unified.contains("<span class=\"del\">-\{marker}</span>"))
assert_false(unified.contains("<span class=\"add\">+\{marker}</span>"))
Expand All @@ -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\""),
Expand All @@ -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,
Expand All @@ -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=(
#|<pre class="unified">
#|<span class="hunk-header">@@ -1,2 +0,0 @@</span>
Expand All @@ -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),
)
}

Expand Down Expand Up @@ -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("<b class="), content="false")
inspect(
assert_false(split.contains("<b class="))
assert_true(
split.contains(
"<td class=\"del\">\{old_line}</td><td class=\"add\">\{new_line}</td>",
),
content="true",
)
let unified = @ldiff.unified_html(old=[old_line], new=[new_line], context=0)
inspect(unified.contains("<b class="), content="false")
inspect(
let unified = @ldiff_html.unified_html(
old=[old_line],
new=[new_line],
context=0,
)
assert_false(unified.contains("<b class="))
assert_true(
unified.find("class=\"del\"").unwrap() <
unified.find("class=\"add\"").unwrap(),
content="true",
)
}
Loading
Loading