Text Diff Viewer — Ruby source
Compare two pieces of text and see exactly what changed. Highlights added and removed lines, words, or characters, shows a per-side summary, and exports a unified diff you can paste into a PR or commit. Runs 100% in your browser.
This is the Ruby implementation — the same logic the interactive tool runs, in a shareable, citable form.
# text-diff — line-granularity diff via an LCS dynamic-programming table. Language: Ruby (3.1+, stdlib only). Port of src/lib/text-diff.ts — core tokenizer/backwards-DP/greedy-walk/run-merge; word/char granularity, normalization options and unified hunk headers live in this dir's javascript.js (80-line budget).
module TextDiff
# A merged run of consecutive same-type tokens (line tokens rejoin with '\n').
DiffPart = Struct.new(:type, :text, keyword_init: true)
module_function
# Content-only lines — the TS tokenizer's 'line' case: joining the tokens
# back with '\n' reconstructs the input exactly; '' tokenizes to [].
def tokenize(text)
text == '' ? [] : text.split("\n", -1) # -1 keeps a trailing '' marker
end
# dp[i][j] = LCS length of a[i..] and b[j..], built backwards. The greedy
# walk emits an equal part on token match, else drops the side whose
# remaining LCS is larger — the '>=' tie favors 'removed', as in the TS.
def diff(old_text, new_text)
a = tokenize(old_text)
b = tokenize(new_text)
n = a.length
m = b.length
dp = Array.new(n + 1) { Array.new(m + 1, 0) }
(n - 1).downto(0) do |i|
(m - 1).downto(0) do |j|
dp[i][j] = if a[i] == b[j] then dp[i + 1][j + 1] + 1
elsif dp[i + 1][j] >= dp[i][j + 1] then dp[i + 1][j]
else dp[i][j + 1]
end
end
end
parts = [] # merge step: consecutive same-type tokens rejoin with '\n'
push = lambda do |type, tok|
last = parts.last
if last && last.type == type
last.text = "#{last.text}\n#{tok}"
else
parts << DiffPart.new(type:, text: tok)
end
end
i = 0
j = 0
while i < n || j < m
if i < n && j < m && a[i] == b[j]
push.call(:equal, a[i]); i += 1; j += 1
elsif j == m || (i < n && dp[i + 1][j] >= dp[i][j + 1])
push.call(:removed, a[i]); i += 1
else
push.call(:added, b[j]); j += 1
end
end
parts
end
end
a = "const x = 1;\nfunction greet(name) {\n return 'hi ' + name;\n}\nconsole.log(greet('dev'));"
b = "const x = 2;\nfunction greet(name) {\n return 'hello, ' + name + '!';\n}\nconsole.log(greet('dev'));"
added = removed = unchanged = 0
TextDiff.diff(a, b).each do |part| # one prefix per line inside each part
prefix = { added: '+', removed: '-', equal: ' ' }.fetch(part.type)
part.text.split("\n", -1).each { |line| puts "#{prefix} #{line}" }
case part.type
when :added then added += part.text.length
when :removed then removed += part.text.length
else unchanged += part.text.length
end
end
puts format('summary: +%d added, -%d removed, =%d unchanged chars', added, removed, unchanged)
Also available in 13 other languages
Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →