2009-12-06 Shinichiro Hamaji <hamaji@chromium.org>
[WebKit-https.git] / BugsSite / PrettyPatch / PrettyPatch.rb
1 require 'cgi'
2 require 'diff'
3 require 'pp'
4 require 'set'
5
6 module PrettyPatch
7
8 public
9
10     def self.prettify(string)
11         fileDiffs = FileDiff.parse(string)
12
13         str = HEADER + "\n"
14         str += fileDiffs.collect{ |diff| diff.to_html }.join
15     end
16
17     def self.filename_from_diff_header(line)
18         DIFF_HEADER_FORMATS.each do |format|
19             match = format.match(line)
20             return match[1] unless match.nil?
21         end
22         nil
23     end
24
25     def self.diff_header?(line)
26         RELAXED_DIFF_HEADER_FORMATS.any? { |format| line =~ format }
27     end
28
29 private
30     DIFF_HEADER_FORMATS = [
31         /^Index: (.*)\r?$/,
32         /^diff --git "?a\/.+"? "?b\/(.+)"?\r?$/,
33         /^\+\+\+ ([^\t]+)(\t.*)?\r?$/
34     ]
35
36     RELAXED_DIFF_HEADER_FORMATS = [
37         /^Index:/,
38         /^diff/
39     ]
40
41     BINARY_FILE_MARKER_FORMAT = /^(?:Cannot display: file marked as a binary type.)|(?:GIT binary patch)$/
42
43     IMAGE_FILE_MARKER_FORMAT = /^svn:mime-type = image\/png$/
44
45     START_OF_BINARY_DATA_FORMAT = /^[0-9a-zA-Z\+\/=]{20,}/ # Assume 20 chars without a space is base64 binary data.
46
47     START_OF_SECTION_FORMAT = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@\s*(.*)/
48
49     START_OF_EXTENT_STRING = "%c" % 0
50     END_OF_EXTENT_STRING = "%c" % 1
51
52     SMALLEST_EQUAL_OPERATION = 3
53
54     OPENSOURCE_TRAC_URL = "http://trac.webkit.org/"
55
56     OPENSOURCE_DIRS = Set.new %w[
57         BugsSite
58         JavaScriptCore
59         JavaScriptGlue
60         LayoutTests
61         PageLoadTests
62         PlanetWebKit
63         SunSpider
64         WebCore
65         WebKit
66         WebKitExamplePlugins
67         WebKitLibraries
68         WebKitSite
69         WebKitTools
70     ]
71
72     def self.find_url_and_path(file_path)
73         # Search file_path from the bottom up, at each level checking whether
74         # we've found a directory we know exists in the source tree.
75
76         dirname, basename = File.split(file_path)
77         dirname.split(/\//).reverse.inject(basename) do |path, directory|
78             path = directory + "/" + path
79
80             return [OPENSOURCE_TRAC_URL, path] if OPENSOURCE_DIRS.include?(directory)
81
82             path
83         end
84
85         [nil, file_path]
86     end
87
88     def self.linkifyFilename(filename)
89         url, pathBeneathTrunk = find_url_and_path(filename)
90
91         url.nil? ? filename : "<a href='#{url}browser/trunk/#{pathBeneathTrunk}'>#{filename}</a>"
92     end
93
94
95     HEADER =<<EOF
96 <style>
97 :link, :visited {
98     text-decoration: none;
99     border-bottom: 1px dotted;
100 }
101
102 .FileDiff {
103     background-color: #f8f8f8;
104     border: 1px solid #ddd;
105     font-family: monospace;
106     margin: 2em 0px;
107 }
108
109 h1 {
110     color: #333;
111     font-family: sans-serif;
112     font-size: 1em;
113     margin-left: 0.5em;
114 }
115
116 h1 :link, h1 :visited {
117     color: inherit;
118 }
119
120 h1 :hover {
121     color: #555;
122     background-color: #eee;
123 }
124
125 .DiffSection {
126     background-color: white;
127     border: solid #ddd;
128     border-width: 1px 0px;
129 }
130
131 .lineNumber {
132     background-color: #eed;
133     border-bottom: 1px solid #998;
134     border-right: 1px solid #ddd;
135     color: #444;
136     display: inline-block;
137     padding: 1px 5px 0px 0px;
138     text-align: right;
139     vertical-align: bottom;
140     width: 3em;
141 }
142
143 .text {
144     padding-left: 5px;
145     white-space: pre;
146     white-space: pre-wrap;
147 }
148
149 .image {
150     border: 2px solid black;
151 }
152
153 .context, .context .lineNumber {
154     color: #849;
155     background-color: #fef;
156 }
157
158 .add {
159     background-color: #dfd;
160 }
161
162 .add ins {
163     background-color: #9e9;
164     text-decoration: none;
165 }
166
167 .remove {
168     background-color: #fdd;
169 }
170
171 .remove del {
172     background-color: #e99;
173     text-decoration: none;
174 }
175 </style>
176 EOF
177
178     def self.revisionOrDescription(string)
179         case string
180         when /\(revision \d+\)/
181             /\(revision (\d+)\)/.match(string)[1]
182         when /\(.*\)/
183             /\((.*)\)/.match(string)[1]
184         end
185     end
186
187     def self.has_image_suffix(filename)
188         filename =~ /\.(png|jpg|gif)$/
189     end
190
191     class FileDiff
192         def initialize(lines)
193             @filename = PrettyPatch.filename_from_diff_header(lines[0].chomp)
194             startOfSections = 1
195             for i in 0...lines.length
196                 case lines[i]
197                 when /^--- /
198                     @from = PrettyPatch.revisionOrDescription(lines[i])
199                 when /^\+\+\+ /
200                     @filename = PrettyPatch.filename_from_diff_header(lines[i].chomp) if @filename.nil?
201                     @to = PrettyPatch.revisionOrDescription(lines[i])
202                     startOfSections = i + 1
203                     break
204                 when BINARY_FILE_MARKER_FORMAT
205                     @binary = true
206                     if (IMAGE_FILE_MARKER_FORMAT.match(lines[i + 1]) or PrettyPatch.has_image_suffix(@filename)) then
207                         @image = true
208                         startOfSections = i + 2
209                         for x in startOfSections...lines.length
210                             # Binary diffs often have property changes listed before the actual binary data.  Skip them.
211                             if START_OF_BINARY_DATA_FORMAT.match(lines[x]) then
212                                 startOfSections = x
213                                 break
214                             end
215                         end
216                     end
217                     break
218                 end
219             end
220             lines_with_contents = lines[startOfSections...lines.length]
221             @sections = DiffSection.parse(lines_with_contents) unless @binary
222             @image_url = "data:image/png;base64," + lines_with_contents.join if @image
223             nil
224         end
225
226         def to_html
227             str = "<div class='FileDiff'>\n"
228             str += "<h1>#{PrettyPatch.linkifyFilename(@filename)}</h1>\n"
229             if @image then
230                 str += "<img class='image' src='" + @image_url + "' />"
231             elsif @binary then
232                 str += "<span class='text'>Binary file, nothing to see here</span>"
233             else
234                 str += @sections.collect{ |section| section.to_html }.join("<br>\n") unless @sections.nil?
235             end
236             str += "</div>\n"
237         end
238
239         def self.parse(string)
240             haveSeenDiffHeader = false
241             linesForDiffs = string.inject([]) do |diffChunks, line|
242                 if (PrettyPatch.diff_header?(line))
243                     diffChunks << []
244                     haveSeenDiffHeader = true
245                 elsif (!haveSeenDiffHeader && line =~ /^--- /)
246                     diffChunks << []
247                     haveSeenDiffHeader = false
248                 end
249                 diffChunks.last << line unless diffChunks.last.nil?
250                 diffChunks
251             end
252
253             linesForDiffs.collect { |lines| FileDiff.new(lines) }
254         end
255     end
256
257     class DiffSection
258         def initialize(lines)
259             lines.length >= 1 or raise "DiffSection.parse only received %d lines" % lines.length
260
261             matches = START_OF_SECTION_FORMAT.match(lines[0])
262             from, to = [matches[1].to_i, matches[2].to_i] unless matches.nil?
263
264             @lines = lines[1...lines.length].collect do |line|
265                 startOfLine = line =~ /^[-\+ ]/ ? 1 : 0
266                 text = line[startOfLine...line.length].chomp
267                 case line[0]
268                 when ?-
269                     result = CodeLine.new(from, nil, text)
270                     from += 1 unless from.nil?
271                     result
272                 when ?+
273                     result = CodeLine.new(nil, to, text)
274                     to += 1 unless to.nil?
275                     result
276                 else
277                     result = CodeLine.new(from, to, text)
278                     from += 1 unless from.nil?
279                     to += 1 unless to.nil?
280                     result
281                 end
282             end
283
284             @lines.unshift(ContextLine.new(matches[3])) unless matches.nil? || matches[3].empty?
285
286             changes = [ [ [], [] ] ]
287             for line in @lines
288                 if (!line.fromLineNumber.nil? and !line.toLineNumber.nil?) then
289                     changes << [ [], [] ]
290                     next
291                 end
292                 changes.last.first << line if line.toLineNumber.nil?
293                 changes.last.last << line if line.fromLineNumber.nil?
294             end
295
296             for change in changes
297                 next unless change.first.length == change.last.length
298                 for i in (0...change.first.length)
299                     raw_operations = HTMLDiff::DiffBuilder.new(change.first[i].text, change.last[i].text).operations
300                     operations = []
301                     back = 0
302                     raw_operations.each_with_index do |operation, j|
303                         if operation.action == :equal and j < raw_operations.length - 1
304                            length = operation.end_in_new - operation.start_in_new
305                            if length < SMALLEST_EQUAL_OPERATION
306                                back = length
307                                next
308                            end
309                         end
310                         operation.start_in_old -= back
311                         operation.start_in_new -= back
312                         back = 0
313                         operations << operation
314                     end
315                     change.first[i].operations = operations
316                     change.last[i].operations = operations
317                 end
318             end
319         end
320
321         def to_html
322             str = "<div class='DiffSection'>\n"
323             str += @lines.collect{ |line| line.to_html }.join
324             str += "</div>\n"
325         end
326         
327         def self.parse(lines)
328             linesForSections = lines.inject([[]]) do |sections, line|
329                 sections << [] if line =~ /^@@/
330                 sections.last << line
331                 sections
332             end
333
334             linesForSections.delete_if { |lines| lines.nil? or lines.empty? }
335             linesForSections.collect { |lines| DiffSection.new(lines) }
336         end
337     end
338
339     class Line
340         attr_reader :fromLineNumber
341         attr_reader :toLineNumber
342         attr_reader :text
343
344         def initialize(from, to, text)
345             @fromLineNumber = from
346             @toLineNumber = to
347             @text = text
348         end
349
350         def text_as_html
351             CGI.escapeHTML(text)
352         end
353
354         def classes
355             lineClasses = ["Line"]
356             lineClasses << ["add"] unless @toLineNumber.nil? or !@fromLineNumber.nil?
357             lineClasses << ["remove"] unless @fromLineNumber.nil? or !@toLineNumber.nil?
358             lineClasses
359         end
360
361         def to_html
362             markedUpText = self.text_as_html
363             str = "<div class='%s'>\n" % self.classes.join(' ')
364             str += "<span class='from lineNumber'>%s</span><span class='to lineNumber'>%s</span>\n" %
365                    [@fromLineNumber.nil? ? '&nbsp;' : @fromLineNumber,
366                     @toLineNumber.nil? ? '&nbsp;' : @toLineNumber] unless @fromLineNumber.nil? and @toLineNumber.nil?
367             str += "<span class='text'>%s</span>\n" % markedUpText
368             str += "</div>\n"
369         end
370     end
371
372     class CodeLine < Line
373         attr :operations, true
374
375         def text_as_html
376             html = []
377             tag = @fromLineNumber.nil? ? "ins" : "del"
378             if @operations.nil? or @operations.empty?
379                 return CGI.escapeHTML(@text)
380             end
381             @operations.each do |operation|
382                 start = @fromLineNumber.nil? ? operation.start_in_new : operation.start_in_old
383                 eend = @fromLineNumber.nil? ? operation.end_in_new : operation.end_in_old
384                 escaped_text = CGI.escapeHTML(@text[start...eend])
385                 if eend - start === 0 or operation.action === :equal
386                     html << escaped_text
387                 else
388                     html << "<#{tag}>#{escaped_text}</#{tag}>"
389                 end
390             end
391             html.join
392         end
393     end
394
395     class ContextLine < Line
396         def initialize(context)
397             super("@", "@", context)
398         end
399
400         def classes
401             super << "context"
402         end
403     end
404 end