Module: HunkReviewChanges::Diff

Defined in:
lib/hunk_review_changes/diff.rb

Overview

Parses a unified-diff hunk and renders it as an HTML table with line-number gutters. Two layers of highlighting:

* Rouge syntax highlighting on context lines and unpaired add/del lines. The
class-based HTML formatter emits  tokens whose colours come
from CSS (app.css), which is what lets the same markup render light or dark.
* Word-level diff on lines that were modified in place: a run of deletions
immediately followed by a run of additions is paired line-by-line, and the
changed words are wrapped in dw-del / dw-add spans so the eye lands on exactly
what moved.

Defined Under Namespace

Classes: Row

Constant Summary collapse

FORMATTER =
Rouge::Formatters::HTML.new
SKIP_PREFIXES =

Metadata lines that carry no reviewable content; always dropped. None of these collide with an in-hunk content line, whose raw form always begins with +, -, or a space.

[
  "diff --git", "index ", "new file", "deleted file",
  "similarity", "rename ", "old mode", "new mode", "\\ No newline"
].freeze
FILE_HEADER_PREFIXES =

File header markers that DO collide with content once the line marker is added: a deleted "-- x" reads "--- x" and an added "++ x" reads "+++ x". Drop them only outside a hunk, where they are genuinely headers.

["--- ", "+++ "].freeze

Class Method Summary collapse

Class Method Details

.esc(str) ⇒ Object



201
202
203
# File 'lib/hunk_review_changes/diff.rb', line 201

def esc(str)
  CGI.escapeHTML(str.to_s)
end

.gutter(num) ⇒ Object



179
180
181
# File 'lib/hunk_review_changes/diff.rb', line 179

def gutter(num)
  %(<td class="diff-gutter">#{num}</td>)
end

.highlight(lexer, content) ⇒ Object



183
184
185
186
187
# File 'lib/hunk_review_changes/diff.rb', line 183

def highlight(lexer, content)
  return "" if content.empty?

  FORMATTER.format(lexer.lex(content)).chomp
end

.hunk_bounds(line, old_ln, new_ln) ⇒ Object

Line numbers the next content line starts from, read off the @@ header.



70
71
72
73
74
# File 'lib/hunk_review_changes/diff.rb', line 70

def hunk_bounds(line, old_ln, new_ln)
  return [old_ln, new_ln] unless (m = line.match(/@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/))

  [m[1].to_i, m[2].to_i]
end

.lexer_for(file) ⇒ Object



189
190
191
192
193
194
195
196
197
198
199
# File 'lib/hunk_review_changes/diff.rb', line 189

def lexer_for(file)
  lexer =
    begin
      Rouge::Lexer.guess_by_filename(file.to_s)
    rescue Rouge::Guesser::Ambiguous => e
      e.alternatives.first
    rescue StandardError
      nil
    end
  (lexer || Rouge::Lexers::PlainText).new
end

.pair_modifications!(rows) ⇒ Object

Find each run of deletions followed immediately by a run of additions and pair them line-by-line, filling :html with word-level highlighting on both sides.



103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
# File 'lib/hunk_review_changes/diff.rb', line 103

def pair_modifications!(rows)
  index = 0
  while index < rows.length
    unless rows[index].kind == :del
      index += 1
      next
    end

    del_start = index
    index += 1 while index < rows.length && rows[index].kind == :del
    add_start = index
    index += 1 while index < rows.length && rows[index].kind == :add

    dels = rows[del_start...add_start]
    adds = rows[add_start...index]
    [dels.length, adds.length].min.times do |offset|
      del_html, add_html = word_diff(dels[offset].text, adds[offset].text)
      dels[offset].html = del_html
      adds[offset].html = add_html
    end
  end
end

.parse(diff_text) ⇒ Object



47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
# File 'lib/hunk_review_changes/diff.rb', line 47

def parse(diff_text)
  old_ln = new_ln = nil
  in_hunk = false
  rows = []
  diff_text.to_s.each_line do |raw|
    line = raw.chomp
    if line.start_with?("@@")
      old_ln, new_ln = hunk_bounds(line, old_ln, new_ln)
      in_hunk = true
      rows << Row.new(kind: :hunk, text: line)
      next
    end

    # A new file section ends the current hunk, so its ---/+++ lines are headers.
    in_hunk = false if line.start_with?("diff --git")
    next if skip_metadata?(line, in_hunk)

    old_ln, new_ln = push_content(rows, line, old_ln, new_ln)
  end
  rows
end

.push_content(rows, line, old_ln, new_ln) ⇒ Object

Append the add/del/context row for a content line and return the advanced [old_ln, new_ln] cursor (nil bounds stay nil).



86
87
88
89
90
91
92
93
94
95
96
97
98
99
# File 'lib/hunk_review_changes/diff.rb', line 86

def push_content(rows, line, old_ln, new_ln)
  content = line.length > 1 ? line[1..] : ""
  case line[0]
  when "+"
    rows << Row.new(kind: :add, new_ln: new_ln, text: content)
    [old_ln, new_ln && (new_ln + 1)]
  when "-"
    rows << Row.new(kind: :del, old_ln: old_ln, text: content)
    [old_ln && (old_ln + 1), new_ln]
  else # context (space marker or blank line)
    rows << Row.new(kind: :ctx, old_ln: old_ln, new_ln: new_ln, text: content)
    [old_ln && (old_ln + 1), new_ln && (new_ln + 1)]
  end
end

.render(rows, lexer) ⇒ Object



154
155
156
157
158
159
160
161
# File 'lib/hunk_review_changes/diff.rb', line 154

def render(rows, lexer)
  out = +%(<table class="diff highlight">)
  rows.each do |row|
    out << render_row(row, lexer)
  end
  out << "</table>"
  out
end

.render_row(row, lexer) ⇒ Object



163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
# File 'lib/hunk_review_changes/diff.rb', line 163

def render_row(row, lexer)
  return %(<tr class="diff-hunk"><td colspan="3">#{esc(row.text)}</td></tr>) if row.kind == :hunk

  sign =
    case row.kind
    when :add then "+"
    when :del then "-"
    else " "
    end
  code = row.html || highlight(lexer, row.text)
  %(<tr class="diff-row diff-#{row.kind}">) <<
    gutter(row.old_ln) <<
    gutter(row.new_ln) <<
    %(<td class="diff-code"><span class="diff-sign">#{sign}</span>#{code}</td></tr>)
end

.skip_metadata?(line, in_hunk) ⇒ Boolean

File-metadata lines are dropped; the ---/+++ pair only outside a hunk, where it is a real header rather than an added/deleted line that happens to start with +/-.

Returns:

  • (Boolean)


78
79
80
81
82
# File 'lib/hunk_review_changes/diff.rb', line 78

def skip_metadata?(line, in_hunk)
  return true if SKIP_PREFIXES.any? { |prefix| line.start_with?(prefix) }

  !in_hunk && FILE_HEADER_PREFIXES.any? { |prefix| line.start_with?(prefix) }
end

.to_html(diff_text, file) ⇒ Object



40
41
42
43
44
45
# File 'lib/hunk_review_changes/diff.rb', line 40

def to_html(diff_text, file)
  rows = parse(diff_text)
  pair_modifications!(rows)
  lexer = lexer_for(file)
  render(rows, lexer)
end

.tokenize(text) ⇒ Object

Split a line into an alternating stream of identifiers, whitespace runs, and single punctuation characters so the word diff aligns on meaningful units.



128
129
130
# File 'lib/hunk_review_changes/diff.rb', line 128

def tokenize(text)
  text.scan(/\w+|\s+|[^\w\s]/)
end

.word_diff(old_text, new_text) ⇒ Object

Returns [del_html, add_html] with changed tokens wrapped in dw-del / dw-add.



133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
# File 'lib/hunk_review_changes/diff.rb', line 133

def word_diff(old_text, new_text)
  changes = ::Diff::LCS.sdiff(tokenize(old_text), tokenize(new_text))
  del = +""
  add = +""
  changes.each do |change|
    case change.action
    when "=" # unchanged on both sides
      del << esc(change.old_element)
      add << esc(change.new_element)
    when "-" # only in the old line
      del << %(<span class="dw-del">#{esc(change.old_element)}</span>)
    when "+" # only in the new line
      add << %(<span class="dw-add">#{esc(change.new_element)}</span>)
    when "!" # replaced
      del << %(<span class="dw-del">#{esc(change.old_element)}</span>)
      add << %(<span class="dw-add">#{esc(change.new_element)}</span>)
    end
  end
  [del, add]
end