Skip to content

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 →