Complete implementation of Git-style change metadata and diff utilities

- Finalized the `ChangeMetadata` module to compute structured change metadata, replacing inline calculations in `PatchTree`.
- Introduced `DiffUtil` for accurate line difference calculations using the Myers algorithm, ensuring consistency with Git's behavior.
- Updated `DomLabel` to display git-style change indicators, including added and removed line counts, with hover tooltips for detailed information.
- Comprehensive tests for `DiffUtil` have been added to validate functionality across various scenarios.
- Removed the obsolete `WhitespaceUtil` as its functionality is now integrated into the new modules.
This commit is contained in:
Joe 2026-02-08 16:12:40 -06:00
parent 6deafe2650
commit 9b832c4637
11 changed files with 1253 additions and 193 deletions

View file

@ -0,0 +1,305 @@
return function()
local DiffUtil = require(script.Parent.DiffUtil)
describe("countLineDifferences", function()
it("should return zeros for identical strings", function()
local result = DiffUtil.countLineDifferences("hello\nworld\n", "hello\nworld\n")
expect(result.added).to.equal(0)
expect(result.removed).to.equal(0)
expect(result.isWhitespaceOnly).to.equal(false)
end)
it("should return zeros for empty strings", function()
local result = DiffUtil.countLineDifferences("", "")
expect(result.added).to.equal(0)
expect(result.removed).to.equal(0)
expect(result.isWhitespaceOnly).to.equal(false)
end)
it("should handle nil/non-string inputs gracefully", function()
local result = DiffUtil.countLineDifferences(nil, "hello")
expect(result.added).to.equal(0)
expect(result.removed).to.equal(0)
result = DiffUtil.countLineDifferences("hello", 42)
expect(result.added).to.equal(0)
expect(result.removed).to.equal(0)
end)
it("should count pure additions (empty to non-empty)", function()
local result = DiffUtil.countLineDifferences("", "line1\nline2\nline3\n")
expect(result.added).to.equal(3)
expect(result.removed).to.equal(0)
expect(result.isWhitespaceOnly).to.equal(false)
end)
it("should count pure removals (non-empty to empty)", function()
local result = DiffUtil.countLineDifferences("line1\nline2\nline3\n", "")
expect(result.added).to.equal(0)
expect(result.removed).to.equal(3)
expect(result.isWhitespaceOnly).to.equal(false)
end)
it("should count a single modified line as 1 removal + 1 addition", function()
-- git diff: old line removed, new line added
local result = DiffUtil.countLineDifferences("hello\n", "world\n")
expect(result.added).to.equal(1)
expect(result.removed).to.equal(1)
expect(result.isWhitespaceOnly).to.equal(false)
end)
it("should count multiple modified lines correctly", function()
local old = "line1\nline2\nline3\n"
local new = "changed1\nchanged2\nline3\n"
-- Lines 1 and 2 changed: 2 removals + 2 additions
local result = DiffUtil.countLineDifferences(old, new)
expect(result.added).to.equal(2)
expect(result.removed).to.equal(2)
end)
it("should handle mixed additions and removals", function()
local old = "alpha\nbeta\ngamma\n"
local new = "alpha\ndelta\ngamma\nepsilon\n"
-- beta -> delta (1 remove + 1 add), + epsilon (1 add)
local result = DiffUtil.countLineDifferences(old, new)
expect(result.added).to.equal(2)
expect(result.removed).to.equal(1)
end)
it("should detect whitespace-only changes (trailing spaces)", function()
local old = "hello\nworld\n"
local new = "hello \nworld \n"
local result = DiffUtil.countLineDifferences(old, new)
expect(result.added).to.equal(2)
expect(result.removed).to.equal(2)
expect(result.isWhitespaceOnly).to.equal(true)
end)
it("should detect whitespace-only changes (trailing tabs)", function()
local old = "hello\n"
local new = "hello\t\n"
local result = DiffUtil.countLineDifferences(old, new)
expect(result.isWhitespaceOnly).to.equal(true)
end)
it("should detect whitespace-only changes (trailing \\r)", function()
local old = "hello\n"
local new = "hello\r\n"
local result = DiffUtil.countLineDifferences(old, new)
expect(result.isWhitespaceOnly).to.equal(true)
end)
it("should NOT mark as whitespace-only when content changes", function()
local old = "hello\nworld\n"
local new = "hello \nchanged\n"
local result = DiffUtil.countLineDifferences(old, new)
expect(result.isWhitespaceOnly).to.equal(false)
end)
it("should not produce phantom diffs from trailing newlines", function()
-- "hello\n" and "hello" should diff the same as identical
local result = DiffUtil.countLineDifferences("hello\n", "hello")
expect(result.added).to.equal(0)
expect(result.removed).to.equal(0)
end)
it("should handle single line strings", function()
local result = DiffUtil.countLineDifferences("old", "new")
expect(result.added).to.equal(1)
expect(result.removed).to.equal(1)
end)
it("should compute exact counts for large diffs (no overflow)", function()
local lines = {}
for i = 1, 150 do
lines[i] = "line" .. i
end
local big = table.concat(lines, "\n") .. "\n"
local result = DiffUtil.countLineDifferences("", big)
expect(result.added).to.equal(150)
expect(result.removed).to.equal(0)
end)
it("should handle adding lines at the end", function()
local old = "A\nB\n"
local new = "A\nB\nC\nD\n"
local result = DiffUtil.countLineDifferences(old, new)
expect(result.added).to.equal(2)
expect(result.removed).to.equal(0)
end)
it("should handle removing lines from the beginning", function()
local old = "A\nB\nC\nD\n"
local new = "C\nD\n"
local result = DiffUtil.countLineDifferences(old, new)
expect(result.added).to.equal(0)
expect(result.removed).to.equal(2)
end)
it("should handle interleaved changes", function()
local old = "A\nB\nC\nD\nE\n"
local new = "A\nX\nC\nY\nE\n"
-- B -> X, D -> Y: 2 removals + 2 additions
local result = DiffUtil.countLineDifferences(old, new)
expect(result.added).to.equal(2)
expect(result.removed).to.equal(2)
end)
end)
describe("diffLines", function()
it("should return empty hunks for identical strings", function()
local result = DiffUtil.diffLines("hello\nworld\n", "hello\nworld\n")
expect(result.added).to.equal(0)
expect(result.removed).to.equal(0)
expect(#result.hunks).to.equal(0)
end)
it("should return correct counts matching countLineDifferences", function()
local old = "alpha\nbeta\ngamma\n"
local new = "alpha\ndelta\ngamma\nepsilon\n"
local counts = DiffUtil.countLineDifferences(old, new)
local full = DiffUtil.diffLines(old, new)
expect(full.added).to.equal(counts.added)
expect(full.removed).to.equal(counts.removed)
expect(full.isWhitespaceOnly).to.equal(counts.isWhitespaceOnly)
end)
it("should produce a single hunk for a simple change", function()
local old = "hello\n"
local new = "world\n"
local result = DiffUtil.diffLines(old, new)
expect(#result.hunks).to.equal(1)
local hunk = result.hunks[1]
expect(hunk.oldStart).to.equal(1)
expect(hunk.oldCount).to.equal(1)
expect(hunk.newStart).to.equal(1)
expect(hunk.newCount).to.equal(1)
end)
it("should include context lines around changes", function()
local old = "A\nB\nC\nD\nE\nF\nG\n"
local new = "A\nB\nC\nX\nE\nF\nG\n"
-- D -> X in the middle, with 3 lines context on each side
local result = DiffUtil.diffLines(old, new, 3)
expect(#result.hunks).to.equal(1)
local hunk = result.hunks[1]
-- Should include context: A,B,C (before) + change + E,F,G (after) = all 7 lines
expect(hunk.oldStart).to.equal(1)
expect(hunk.newStart).to.equal(1)
end)
it("should merge adjacent hunks when their contexts overlap", function()
-- Changes at lines 2 and 6 with 3 context = gap of 4 (< 2*3=6), should merge
local old = "A\nB\nC\nD\nE\nF\nG\n"
local new = "A\nX\nC\nD\nE\nY\nG\n"
local result = DiffUtil.diffLines(old, new, 3)
-- B->X and F->Y are only 4 lines apart, contexts overlap -> 1 hunk
expect(#result.hunks).to.equal(1)
end)
it("should produce separate hunks when changes are far apart", function()
-- Build a file with changes separated by more than 2*contextLines
local lines = {}
for i = 1, 20 do
lines[i] = "line" .. i
end
local old = table.concat(lines, "\n") .. "\n"
lines[1] = "changed1"
lines[20] = "changed20"
local new = table.concat(lines, "\n") .. "\n"
local result = DiffUtil.diffLines(old, new, 3)
-- Changes at line 1 and 20, gap of 19 > 2*3=6 -> separate hunks
expect(#result.hunks).to.equal(2)
end)
it("should have correct hunk line types", function()
local old = "A\nB\nC\n"
local new = "A\nX\nC\n"
local result = DiffUtil.diffLines(old, new, 1)
expect(#result.hunks).to.equal(1)
local hunk = result.hunks[1]
-- Should have: context A, remove B, add X, context C
local types = {}
for _, line in hunk.lines do
table.insert(types, line.type)
end
-- Find the remove and add
local hasRemove = false
local hasAdd = false
local hasContext = false
for _, t in types do
if t == "remove" then
hasRemove = true
end
if t == "add" then
hasAdd = true
end
if t == "context" then
hasContext = true
end
end
expect(hasRemove).to.equal(true)
expect(hasAdd).to.equal(true)
expect(hasContext).to.equal(true)
end)
it("should handle pure additions in hunks", function()
local old = "A\nB\n"
local new = "A\nB\nC\nD\n"
local result = DiffUtil.diffLines(old, new, 3)
expect(result.added).to.equal(2)
expect(result.removed).to.equal(0)
expect(#result.hunks).to.equal(1)
end)
it("should handle pure removals in hunks", function()
local old = "A\nB\nC\nD\n"
local new = "A\nB\n"
local result = DiffUtil.diffLines(old, new, 3)
expect(result.added).to.equal(0)
expect(result.removed).to.equal(2)
expect(#result.hunks).to.equal(1)
end)
it("should compute exact hunks for large diffs (no overflow)", function()
local lines = {}
for i = 1, 150 do
lines[i] = "line" .. i
end
local big = table.concat(lines, "\n") .. "\n"
local result = DiffUtil.diffLines("", big)
expect(result.added).to.equal(150)
expect(result.removed).to.equal(0)
expect(#result.hunks).to.equal(1)
end)
it("should use custom context lines", function()
local old = "A\nB\nC\nD\nE\n"
local new = "A\nB\nX\nD\nE\n"
-- C -> X with 1 context line
local result = DiffUtil.diffLines(old, new, 1)
expect(#result.hunks).to.equal(1)
local hunk = result.hunks[1]
-- context B, remove C, add X, context D = 4 lines
local lineCount = #hunk.lines
expect(lineCount).to.equal(4)
end)
end)
describe("EditType", function()
it("should expose frozen edit type constants", function()
expect(DiffUtil.EditType.Equal).to.equal(0)
expect(DiffUtil.EditType.Delete).to.equal(1)
expect(DiffUtil.EditType.Insert).to.equal(2)
end)
end)
end