8c40f3d51bad9ca7a4a345dad4d946eb65a9fe98
[WebKit-https.git] / BugsSite / PrettyPatch / PrettyPatch.rb
1 require 'cgi'
2 require 'diff'
3 require 'open3'
4 require 'pp'
5 require 'set'
6 require 'tempfile'
7
8 module PrettyPatch
9
10 public
11
12     GIT_PATH = "git"
13
14     def self.prettify(string)
15         fileDiffs = FileDiff.parse(string)
16
17         str = HEADER + "\n"
18         str += fileDiffs.collect{ |diff| diff.to_html }.join
19     end
20
21     def self.filename_from_diff_header(line)
22         DIFF_HEADER_FORMATS.each do |format|
23             match = format.match(line)
24             return match[1] unless match.nil?
25         end
26         nil
27     end
28
29     def self.diff_header?(line)
30         RELAXED_DIFF_HEADER_FORMATS.any? { |format| line =~ format }
31     end
32
33 private
34     DIFF_HEADER_FORMATS = [
35         /^Index: (.*)\r?$/,
36         /^diff --git "?a\/.+"? "?b\/(.+)"?\r?$/,
37         /^\+\+\+ ([^\t]+)(\t.*)?\r?$/
38     ]
39
40     RELAXED_DIFF_HEADER_FORMATS = [
41         /^Index:/,
42         /^diff/
43     ]
44
45     BINARY_FILE_MARKER_FORMAT = /^Cannot display: file marked as a binary type.$/
46
47     IMAGE_FILE_MARKER_FORMAT = /^svn:mime-type = image\/png$/
48
49     GIT_INDEX_MARKER_FORMAT = /^index ([0-9a-f]{40})\.\.([0-9a-f]{40})/
50
51     GIT_BINARY_FILE_MARKER_FORMAT = /^GIT binary patch$/
52
53     GIT_LITERAL_FORMAT = /^literal \d+$/
54
55     START_OF_BINARY_DATA_FORMAT = /^[0-9a-zA-Z\+\/=]{20,}/ # Assume 20 chars without a space is base64 binary data.
56
57     START_OF_SECTION_FORMAT = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@\s*(.*)/
58
59     START_OF_EXTENT_STRING = "%c" % 0
60     END_OF_EXTENT_STRING = "%c" % 1
61
62     SMALLEST_EQUAL_OPERATION = 3
63
64     OPENSOURCE_TRAC_URL = "http://trac.webkit.org/"
65
66     OPENSOURCE_DIRS = Set.new %w[
67         BugsSite
68         JavaScriptCore
69         JavaScriptGlue
70         LayoutTests
71         PageLoadTests
72         PlanetWebKit
73         SunSpider
74         WebCore
75         WebKit
76         WebKit2
77         WebKitExamplePlugins
78         WebKitLibraries
79         WebKitSite
80         WebKitTools
81         autotools
82         cmake
83     ]
84
85     def self.find_url_and_path(file_path)
86         # Search file_path from the bottom up, at each level checking whether
87         # we've found a directory we know exists in the source tree.
88
89         dirname, basename = File.split(file_path)
90         dirname.split(/\//).reverse.inject(basename) do |path, directory|
91             path = directory + "/" + path
92
93             return [OPENSOURCE_TRAC_URL, path] if OPENSOURCE_DIRS.include?(directory)
94
95             path
96         end
97
98         [nil, file_path]
99     end
100
101     def self.linkifyFilename(filename)
102         url, pathBeneathTrunk = find_url_and_path(filename)
103
104         url.nil? ? filename : "<a href='#{url}browser/trunk/#{pathBeneathTrunk}'>#{filename}</a>"
105     end
106
107
108     HEADER =<<EOF
109 <style>
110 :link, :visited {
111     text-decoration: none;
112     border-bottom: 1px dotted;
113 }
114
115 .FileDiff {
116     background-color: #f8f8f8;
117     border: 1px solid #ddd;
118     font-family: monospace;
119     margin: 2em 0px;
120 }
121
122 h1 {
123     color: #333;
124     font-family: sans-serif;
125     font-size: 1em;
126     margin-left: 0.5em;
127 }
128
129 h1 :link, h1 :visited {
130     color: inherit;
131 }
132
133 h1 :hover {
134     color: #555;
135     background-color: #eee;
136 }
137
138 .DiffSection {
139     background-color: white;
140     border: solid #ddd;
141     border-width: 1px 0px;
142 }
143
144 .lineNumber {
145     background-color: #eed;
146     border-bottom: 1px solid #998;
147     border-right: 1px solid #ddd;
148     color: #444;
149     display: inline-block;
150     padding: 1px 5px 0px 0px;
151     text-align: right;
152     vertical-align: bottom;
153     width: 3em;
154 }
155
156 .text {
157     padding-left: 5px;
158     white-space: pre;
159     white-space: pre-wrap;
160 }
161
162 .image {
163     border: 2px solid black;
164 }
165
166 .context, .context .lineNumber {
167     color: #849;
168     background-color: #fef;
169 }
170
171 .add {
172     background-color: #dfd;
173 }
174
175 .add ins {
176     background-color: #9e9;
177     text-decoration: none;
178 }
179
180 .remove {
181     background-color: #fdd;
182 }
183
184 .remove del {
185     background-color: #e99;
186     text-decoration: none;
187 }
188
189 /* Support for inline comments */
190
191 .author {
192   font-style: italic;
193 }
194
195 .comment {
196   position: relative;
197 }
198
199 .comment textarea, .overallComments textarea {
200   display: block;
201   width: 100%;
202   height: 2em;
203 }
204
205 .overallComments .open {
206   -webkit-transition: height .2s;
207   height: 4em;
208 }
209
210 #statusBubbleContainer.wrap {
211   display: block;
212 }
213
214 body {
215   margin-bottom: 40px;
216 }
217
218 #toolbar {
219   display: -webkit-box;
220   display: -moz-box;
221   position: fixed;
222   padding: 3px;
223   bottom: 0;
224   left: 0;
225   right: 0;
226   border-top: 1px solid #ddd;
227   background-color: #eee;
228   font-family: sans-serif;
229 }
230
231 #toolbar .actions {
232   float: right;
233 }
234
235 .winter {
236   position: fixed;
237   z-index: 5;
238   left: 0;
239   right: 0;
240   top: 0;
241   bottom: 0;
242   background-color: black;
243   opacity: 0.8;
244 }
245
246 .inactive {
247   display: none;
248 }
249
250 .lightbox {
251   position: fixed;
252   z-index: 6;
253   left: 10%;
254   right: 10%;
255   top: 10%;
256   bottom: 10%;
257 }
258
259 .lightbox iframe {
260   width: 100%;
261   height: 100%;
262 }
263
264 .commentContext .lineNumber {
265   background-color: yellow;
266 }
267
268 .selected .lineNumber {
269   background-color: #69F;
270   border-bottom-color: #69F;
271   border-right-color: #69F;
272 }
273
274 .help {
275  color: gray;
276  font-style: italic;
277 }
278
279 #message {
280   font-size: small;
281   font-family: sans-serif;
282 }
283
284 .commentStatus {
285   font-style: italic;
286 }
287
288 .comment, .previousComment, .frozenComment {
289   background-color: #ffd;
290 }
291
292 .overallComments {
293   -webkit-box-flex: 1;
294   -moz-box-flex: 1;
295   margin-right: 3px;
296 }
297
298 .previousComment, .frozenComment {
299   border: inset 1px;
300   padding: 5px;
301   white-space: pre-wrap;
302 }
303
304 .comment button {
305   width: 6em;
306 }
307
308 .focused {
309   border: 1px solid blue;
310 }
311
312 .statusBubble {
313   margin-top: 2px;
314   /* FIXME: Size the statusBubble via postMessage so it sizes to it's content. */
315   width: 300px;
316   height: 20px;
317   border: none;
318   vertical-align: middle;
319 }
320 </style>
321 <script src="https://ajax.googleapis.com/ajax/libs/jquery/1.4.2/jquery.min.js"></script> 
322 <script src="code-review.js?version=14"></script> 
323 EOF
324
325     def self.revisionOrDescription(string)
326         case string
327         when /\(revision \d+\)/
328             /\(revision (\d+)\)/.match(string)[1]
329         when /\(.*\)/
330             /\((.*)\)/.match(string)[1]
331         end
332     end
333
334     def self.has_image_suffix(filename)
335         filename =~ /\.(png|jpg|gif)$/
336     end
337
338     class FileDiff
339         def initialize(lines)
340             @filename = PrettyPatch.filename_from_diff_header(lines[0].chomp)
341             startOfSections = 1
342             for i in 0...lines.length
343                 case lines[i]
344                 when /^--- /
345                     @from = PrettyPatch.revisionOrDescription(lines[i])
346                 when /^\+\+\+ /
347                     @filename = PrettyPatch.filename_from_diff_header(lines[i].chomp) if @filename.nil?
348                     @to = PrettyPatch.revisionOrDescription(lines[i])
349                     startOfSections = i + 1
350                     break
351                 when BINARY_FILE_MARKER_FORMAT
352                     @binary = true
353                     if (IMAGE_FILE_MARKER_FORMAT.match(lines[i + 1]) or PrettyPatch.has_image_suffix(@filename)) then
354                         @image = true
355                         startOfSections = i + 2
356                         for x in startOfSections...lines.length
357                             # Binary diffs often have property changes listed before the actual binary data.  Skip them.
358                             if START_OF_BINARY_DATA_FORMAT.match(lines[x]) then
359                                 startOfSections = x
360                                 break
361                             end
362                         end
363                     end
364                     break
365                 when GIT_INDEX_MARKER_FORMAT
366                     @git_indexes = [$1, $2]
367                 when GIT_BINARY_FILE_MARKER_FORMAT
368                     @binary = true
369                     if (GIT_LITERAL_FORMAT.match(lines[i + 1]) and PrettyPatch.has_image_suffix(@filename)) then
370                         @git_image = true
371                         startOfSections = i + 1
372                     end
373                     break
374                 end
375             end
376             lines_with_contents = lines[startOfSections...lines.length]
377             @sections = DiffSection.parse(lines_with_contents) unless @binary
378             if @image
379                 @image_url = "data:image/png;base64," + lines_with_contents.join
380             elsif @git_image
381                 begin
382                     raise "index line is missing" unless @git_indexes
383
384                     chunks = nil
385                     for i in 0...lines_with_contents.length
386                         if lines_with_contents[i] =~ /^$/
387                             chunks = [lines_with_contents[i + 1 .. -1], lines_with_contents[0 .. i]]
388                             break
389                         end
390                     end
391
392                     raise "no binary chunks" unless chunks
393
394                     @image_urls = chunks.zip(@git_indexes).collect do |chunk, git_index|
395                         FileDiff.extract_contents_from_git_binary_chunk(chunk, git_index)
396                     end
397                 rescue
398                     @image_error = "Exception raised during decoding git binary patch:<pre>#{CGI.escapeHTML($!.to_s + "\n" + $!.backtrace.join("\n"))}</pre>"
399                 end
400             end
401             nil
402         end
403
404         def to_html
405             str = "<div class='FileDiff'>\n"
406             str += "<h1>#{PrettyPatch.linkifyFilename(@filename)}</h1>\n"
407             if @image then
408                 str += "<img class='image' src='" + @image_url + "' />"
409             elsif @git_image then
410                 if @image_error
411                     str += @image_error
412                 else
413                     for i in (0...2)
414                         image_url = @image_urls[i]
415                         style = ["remove", "add"][i]
416                         str += "<p class=\"#{style}\">"
417                         if image_url
418                             str += "<img class='image' src='" + image_url + "' />"
419                         else
420                             str += ["Added", "Removed"][i]
421                         end
422                     end
423                 end
424             elsif @binary then
425                 str += "<span class='text'>Binary file, nothing to see here</span>"
426             else
427                 str += @sections.collect{ |section| section.to_html }.join("<br>\n") unless @sections.nil?
428             end
429             str += "</div>\n"
430         end
431
432         def self.parse(string)
433             haveSeenDiffHeader = false
434             linesForDiffs = []
435             string.each_line do |line|
436                 if (PrettyPatch.diff_header?(line))
437                     linesForDiffs << []
438                     haveSeenDiffHeader = true
439                 elsif (!haveSeenDiffHeader && line =~ /^--- /)
440                     linesForDiffs << []
441                     haveSeenDiffHeader = false
442                 end
443                 linesForDiffs.last << line unless linesForDiffs.last.nil?
444             end
445
446             linesForDiffs.collect { |lines| FileDiff.new(lines) }
447         end
448
449         def self.git_new_file_binary_patch(filename, encoded_chunk, git_index)
450             return <<END
451 diff --git a/#{filename} b/#{filename}
452 new file mode 100644
453 index 0000000000000000000000000000000000000000..#{git_index}
454 GIT binary patch
455 #{encoded_chunk.join("")}literal 0
456 HcmV?d00001
457
458 END
459         end
460
461         def self.extract_contents_from_git_binary_chunk(encoded_chunk, git_index)
462             # We use Tempfile we need a unique file among processes.
463             tempfile = Tempfile.new("PrettyPatch")
464             # We need a filename which doesn't exist to apply a patch
465             # which creates a new file. Append a suffix so filename
466             # doesn't exist.
467             filepath = tempfile.path + '.bin'
468             filename = File.basename(filepath)
469
470             patch = FileDiff.git_new_file_binary_patch(filename, encoded_chunk, git_index)
471
472             # Apply the git binary patch using git-apply.
473             cmd = GIT_PATH + " apply --directory=" + File.dirname(filepath)
474             stdin, stdout, stderr = *Open3.popen3(cmd)
475             begin
476                 stdin.puts(patch)
477                 stdin.close
478
479                 error = stderr.read
480                 raise error if error != ""
481
482                 contents = File.read(filepath)
483             ensure
484                 stdin.close unless stdin.closed?
485                 stdout.close
486                 stderr.close
487                 File.unlink(filename) if File.exists?(filename)
488             end
489
490             return nil if contents.empty?
491             return "data:image/png;base64," + [contents].pack("m")
492         end
493     end
494
495     class DiffSection
496         def initialize(lines)
497             lines.length >= 1 or raise "DiffSection.parse only received %d lines" % lines.length
498
499             matches = START_OF_SECTION_FORMAT.match(lines[0])
500             from, to = [matches[1].to_i, matches[2].to_i] unless matches.nil?
501
502             @lines = lines[1...lines.length].collect do |line|
503                 startOfLine = line =~ /^[-\+ ]/ ? 1 : 0
504                 text = line[startOfLine...line.length].chomp
505                 case line[0]
506                 when ?-
507                     result = CodeLine.new(from, nil, text)
508                     from += 1 unless from.nil?
509                     result
510                 when ?+
511                     result = CodeLine.new(nil, to, text)
512                     to += 1 unless to.nil?
513                     result
514                 else
515                     result = CodeLine.new(from, to, text)
516                     from += 1 unless from.nil?
517                     to += 1 unless to.nil?
518                     result
519                 end
520             end
521
522             @lines.unshift(ContextLine.new(matches[3])) unless matches.nil? || matches[3].empty?
523
524             changes = [ [ [], [] ] ]
525             for line in @lines
526                 if (!line.fromLineNumber.nil? and !line.toLineNumber.nil?) then
527                     changes << [ [], [] ]
528                     next
529                 end
530                 changes.last.first << line if line.toLineNumber.nil?
531                 changes.last.last << line if line.fromLineNumber.nil?
532             end
533
534             for change in changes
535                 next unless change.first.length == change.last.length
536                 for i in (0...change.first.length)
537                     raw_operations = HTMLDiff::DiffBuilder.new(change.first[i].text, change.last[i].text).operations
538                     operations = []
539                     back = 0
540                     raw_operations.each_with_index do |operation, j|
541                         if operation.action == :equal and j < raw_operations.length - 1
542                            length = operation.end_in_new - operation.start_in_new
543                            if length < SMALLEST_EQUAL_OPERATION
544                                back = length
545                                next
546                            end
547                         end
548                         operation.start_in_old -= back
549                         operation.start_in_new -= back
550                         back = 0
551                         operations << operation
552                     end
553                     change.first[i].operations = operations
554                     change.last[i].operations = operations
555                 end
556             end
557         end
558
559         def to_html
560             str = "<div class='DiffSection'>\n"
561             str += @lines.collect{ |line| line.to_html }.join
562             str += "</div>\n"
563         end
564         
565         def self.parse(lines)
566             linesForSections = lines.inject([[]]) do |sections, line|
567                 sections << [] if line =~ /^@@/
568                 sections.last << line
569                 sections
570             end
571
572             linesForSections.delete_if { |lines| lines.nil? or lines.empty? }
573             linesForSections.collect { |lines| DiffSection.new(lines) }
574         end
575     end
576
577     class Line
578         attr_reader :fromLineNumber
579         attr_reader :toLineNumber
580         attr_reader :text
581
582         def initialize(from, to, text)
583             @fromLineNumber = from
584             @toLineNumber = to
585             @text = text
586         end
587
588         def text_as_html
589             CGI.escapeHTML(text)
590         end
591
592         def classes
593             lineClasses = ["Line"]
594             lineClasses << ["add"] unless @toLineNumber.nil? or !@fromLineNumber.nil?
595             lineClasses << ["remove"] unless @fromLineNumber.nil? or !@toLineNumber.nil?
596             lineClasses
597         end
598
599         def to_html
600             markedUpText = self.text_as_html
601             str = "<div class='%s'>\n" % self.classes.join(' ')
602             str += "<span class='from lineNumber'>%s</span><span class='to lineNumber'>%s</span>\n" %
603                    [@fromLineNumber.nil? ? '&nbsp;' : @fromLineNumber,
604                     @toLineNumber.nil? ? '&nbsp;' : @toLineNumber] unless @fromLineNumber.nil? and @toLineNumber.nil?
605             str += "<span class='text'>%s</span>\n" % markedUpText
606             str += "</div>\n"
607         end
608     end
609
610     class CodeLine < Line
611         attr :operations, true
612
613         def text_as_html
614             html = []
615             tag = @fromLineNumber.nil? ? "ins" : "del"
616             if @operations.nil? or @operations.empty?
617                 return CGI.escapeHTML(@text)
618             end
619             @operations.each do |operation|
620                 start = @fromLineNumber.nil? ? operation.start_in_new : operation.start_in_old
621                 eend = @fromLineNumber.nil? ? operation.end_in_new : operation.end_in_old
622                 escaped_text = CGI.escapeHTML(@text[start...eend])
623                 if eend - start === 0 or operation.action === :equal
624                     html << escaped_text
625                 else
626                     html << "<#{tag}>#{escaped_text}</#{tag}>"
627                 end
628             end
629             html.join
630         end
631     end
632
633     class ContextLine < Line
634         def initialize(context)
635             super("@", "@", context)
636         end
637
638         def classes
639             super << "context"
640         end
641     end
642 end