2011-02-17 Ojan Vafai <ojan@chromium.org>
[WebKit-https.git] / Websites / bugs.webkit.org / 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
19         # Just look at the first line to see if it is an SVN revision number as added
20         # by webkit-patch for git checkouts.
21         string.each_line do |line|
22             match = /^Subversion\ Revision: (\d*)$/.match(line)
23             unless match.nil?
24               str += "<span class='revision'>" + match[1] + "</span>\n"
25             end
26             break
27         end
28
29         str += fileDiffs.collect{ |diff| diff.to_html }.join
30     end
31
32     def self.filename_from_diff_header(line)
33         DIFF_HEADER_FORMATS.each do |format|
34             match = format.match(line)
35             return match[1] unless match.nil?
36         end
37         nil
38     end
39
40     def self.diff_header?(line)
41         RELAXED_DIFF_HEADER_FORMATS.any? { |format| line =~ format }
42     end
43
44 private
45     DIFF_HEADER_FORMATS = [
46         /^Index: (.*)\r?$/,
47         /^diff --git "?a\/.+"? "?b\/(.+)"?\r?$/,
48         /^\+\+\+ ([^\t]+)(\t.*)?\r?$/
49     ]
50
51     RELAXED_DIFF_HEADER_FORMATS = [
52         /^Index:/,
53         /^diff/
54     ]
55
56     BINARY_FILE_MARKER_FORMAT = /^Cannot display: file marked as a binary type.$/
57
58     IMAGE_FILE_MARKER_FORMAT = /^svn:mime-type = image\/png$/
59
60     GIT_INDEX_MARKER_FORMAT = /^index ([0-9a-f]{40})\.\.([0-9a-f]{40})/
61
62     GIT_BINARY_FILE_MARKER_FORMAT = /^GIT binary patch$/
63
64     GIT_LITERAL_FORMAT = /^literal \d+$/
65
66     START_OF_BINARY_DATA_FORMAT = /^[0-9a-zA-Z\+\/=]{20,}/ # Assume 20 chars without a space is base64 binary data.
67
68     START_OF_SECTION_FORMAT = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@\s*(.*)/
69
70     START_OF_EXTENT_STRING = "%c" % 0
71     END_OF_EXTENT_STRING = "%c" % 1
72
73     SMALLEST_EQUAL_OPERATION = 3
74
75     OPENSOURCE_TRAC_URL = "http://trac.webkit.org/"
76
77     OPENSOURCE_DIRS = Set.new %w[
78         Examples
79         LayoutTests
80         PerformanceTests
81         Source
82         Tools
83         WebKit
84         WebKitLibraries
85         Websites
86     ]
87
88     def self.find_url_and_path(file_path)
89         # Search file_path from the bottom up, at each level checking whether
90         # we've found a directory we know exists in the source tree.
91
92         dirname, basename = File.split(file_path)
93         dirname.split(/\//).reverse.inject(basename) do |path, directory|
94             path = directory + "/" + path
95
96             return [OPENSOURCE_TRAC_URL, path] if OPENSOURCE_DIRS.include?(directory)
97
98             path
99         end
100
101         [nil, file_path]
102     end
103
104     def self.linkifyFilename(filename)
105         url, pathBeneathTrunk = find_url_and_path(filename)
106
107         url.nil? ? filename : "<a href='#{url}browser/trunk/#{pathBeneathTrunk}'>#{filename}</a>"
108     end
109
110
111     HEADER =<<EOF
112 <style>
113 :link, :visited {
114     text-decoration: none;
115     border-bottom: 1px dotted;
116 }
117
118 :link {
119     color: #039;
120 }
121
122 .FileDiff {
123     background-color: #f8f8f8;
124     border: 1px solid #ddd;
125     font-family: monospace;
126     margin: 1em 0;
127 }
128
129 h1 {
130     color: #333;
131     font-family: sans-serif;
132     font-size: 1em;
133     margin-left: 0.5em;
134     display: table-cell;
135     width: 100%;
136     padding: 0.5em;
137 }
138
139 h1 :link, h1 :visited {
140     color: inherit;
141 }
142
143 h1 :hover {
144     color: #555;
145     background-color: #eee;
146 }
147
148 .DiffLinks {
149     float: right;
150 }
151
152 .FileDiffLinkContainer {
153     opacity: 0;
154     display: table-cell;
155     padding-right: 0.5em;
156     white-space: nowrap;
157 }
158
159 .DiffSection {
160     background-color: white;
161     border: solid #ddd;
162     border-width: 1px 0px;
163 }
164
165 .ExpansionLine, .LineContainer {
166     white-space: nowrap;
167 }
168
169 .sidebyside .DiffBlockPart.add:first-child {
170     float: right;
171 }
172
173 .LineSide,
174 .sidebyside .DiffBlockPart.remove,
175 .sidebyside .DiffBlockPart.add {
176     display:inline-block;
177     width:50%;
178     vertical-align: top;
179 }
180
181 .sidebyside .DiffBlockPart.remove .to,
182 .sidebyside .DiffBlockPart.add .from {
183     display: none;
184 }
185
186 .lineNumber, .expansionLineNumber {
187     border-bottom: 1px solid #998;
188     border-right: 1px solid #ddd;
189     color: #444;
190     display: inline-block;
191     padding: 1px 5px 0px 0px;
192     text-align: right;
193     vertical-align: bottom;
194     width: 3em;
195 }
196
197 .lineNumber {
198   background-color: #eed;
199 }
200
201 .expansionLineNumber {
202   background-color: #eee;
203 }
204
205 .text {
206     padding-left: 5px;
207     white-space: pre-wrap;
208     word-wrap: break-word;
209 }
210
211 .image {
212     border: 2px solid black;
213 }
214
215 .context, .context .lineNumber {
216     color: #849;
217     background-color: #fef;
218 }
219
220 .Line.add {
221     background-color: #dfd;
222 }
223
224 .Line.add ins {
225     background-color: #9e9;
226     text-decoration: none;
227 }
228
229 .Line.remove {
230     background-color: #fdd;
231 }
232
233 .Line.remove del {
234     background-color: #e99;
235     text-decoration: none;
236 }
237
238 /* Support for inline comments */
239
240 .author {
241   font-style: italic;
242 }
243
244 .comment {
245   position: relative;
246 }
247
248 .comment textarea {
249   height: 6em;
250 }
251
252 .overallComments textarea {
253   height: 2em;
254 }
255
256 .comment textarea, .overallComments textarea {
257   display: block;
258   width: 100%;
259 }
260
261 .overallComments .open {
262   -webkit-transition: height .2s;
263   height: 4em;
264 }
265
266 #statusBubbleContainer.wrap {
267   display: block;
268 }
269
270 body {
271   margin-bottom: 40px;
272 }
273
274 #toolbar {
275   display: -webkit-box;
276   display: -moz-box;
277   padding: 3px;
278   left: 0;
279   right: 0;
280   border: 1px solid #ddd;
281   background-color: #eee;
282   font-family: sans-serif;
283   position: fixed;
284 }
285
286 #toolbar.anchored {
287   bottom: 0;
288 }
289
290 #toolbar .actions {
291   float: right;
292 }
293
294 .winter {
295   position: fixed;
296   z-index: 5;
297   left: 0;
298   right: 0;
299   top: 0;
300   bottom: 0;
301   background-color: black;
302   opacity: 0.8;
303 }
304
305 .inactive {
306   display: none;
307 }
308
309 .lightbox {
310   position: fixed;
311   z-index: 6;
312   left: 10%;
313   right: 10%;
314   top: 10%;
315   bottom: 10%;
316   background: white;
317 }
318
319 .lightbox iframe {
320   width: 100%;
321   height: 100%;
322 }
323
324 .commentContext .lineNumber {
325   background-color: yellow;
326 }
327
328 .selected .lineNumber {
329   background-color: #69F;
330   border-bottom-color: #69F;
331   border-right-color: #69F;
332 }
333
334 .ExpandLinkContainer {
335   opacity: 0;
336   border-top: 1px solid #ddd;
337   border-bottom: 1px solid #ddd;
338 }
339
340 .ExpandArea {
341   margin: 0;
342 }
343
344 .ExpandText {
345   margin-left: 0.67em;
346 }
347
348 .LinkContainer {
349   font-family: sans-serif;
350   font-size: small;
351   font-style: normal;
352   -webkit-transition: opacity 0.5s;
353 }
354
355 .LinkContainer a {
356   border: 0;
357 }
358
359 .LinkContainer a:after {
360   content: " | ";
361   color: black;
362 }
363
364 .LinkContainer a:last-of-type:after {
365   content: "";
366 }
367
368 .help {
369  color: gray;
370  font-style: italic;
371 }
372
373 #message {
374   font-size: small;
375   font-family: sans-serif;
376 }
377
378 .commentStatus {
379   font-style: italic;
380 }
381
382 .comment, .previousComment, .frozenComment {
383   background-color: #ffd;
384 }
385
386 .overallComments {
387   -webkit-box-flex: 1;
388   -moz-box-flex: 1;
389   margin-right: 3px;
390 }
391
392 .previousComment, .frozenComment {
393   border: inset 1px;
394   padding: 5px;
395   white-space: pre-wrap;
396 }
397
398 .comment button {
399   width: 6em;
400 }
401
402 div:focus {
403   outline: 1px solid blue;
404   outline-offset: -1px;
405 }
406
407 .statusBubble {
408   /* The width/height get set to the bubble contents via postMessage on browsers that support it. */
409   width: 450px;
410   height: 20px;
411   margin: 2px 2px 0 0;
412   border: none;
413   vertical-align: middle;
414 }
415
416 .pseudo_resize_event_iframe {
417   height: 10%;
418   width: 10%;
419   position: absolute;
420   top: -11%;
421 }
422
423 .revision {
424   display: none;
425 }
426
427 .autosave-state {
428   position: absolute;
429   right: 0;
430   top: -1.3em;
431   padding: 0 3px;
432   outline: 1px solid #DDD;
433   color: #8FDF5F;
434   font-size: small;   
435   background-color: #EEE;
436 }
437
438 .autosave-state:empty {
439   outline: 0px;
440 }
441 .autosave-state.saving {
442   color: #E98080;
443 }
444
445 .clear_float {
446     clear: both;
447 }
448 </style>
449 <script src="https://ajax.googleapis.com/ajax/libs/jquery/1.4.2/jquery.min.js"></script> 
450 <script src="code-review.js?version=36"></script>
451 EOF
452
453     def self.revisionOrDescription(string)
454         case string
455         when /\(revision \d+\)/
456             /\(revision (\d+)\)/.match(string)[1]
457         when /\(.*\)/
458             /\((.*)\)/.match(string)[1]
459         end
460     end
461
462     def self.has_image_suffix(filename)
463         filename =~ /\.(png|jpg|gif)$/
464     end
465
466     class FileDiff
467         def initialize(lines)
468             @filename = PrettyPatch.filename_from_diff_header(lines[0].chomp)
469             startOfSections = 1
470             for i in 0...lines.length
471                 case lines[i]
472                 when /^--- /
473                     @from = PrettyPatch.revisionOrDescription(lines[i])
474                 when /^\+\+\+ /
475                     @filename = PrettyPatch.filename_from_diff_header(lines[i].chomp) if @filename.nil?
476                     @to = PrettyPatch.revisionOrDescription(lines[i])
477                     startOfSections = i + 1
478                     break
479                 when BINARY_FILE_MARKER_FORMAT
480                     @binary = true
481                     if (IMAGE_FILE_MARKER_FORMAT.match(lines[i + 1]) or PrettyPatch.has_image_suffix(@filename)) then
482                         @image = true
483                         startOfSections = i + 2
484                         for x in startOfSections...lines.length
485                             # Binary diffs often have property changes listed before the actual binary data.  Skip them.
486                             if START_OF_BINARY_DATA_FORMAT.match(lines[x]) then
487                                 startOfSections = x
488                                 break
489                             end
490                         end
491                     end
492                     break
493                 when GIT_INDEX_MARKER_FORMAT
494                     @git_indexes = [$1, $2]
495                 when GIT_BINARY_FILE_MARKER_FORMAT
496                     @binary = true
497                     if (GIT_LITERAL_FORMAT.match(lines[i + 1]) and PrettyPatch.has_image_suffix(@filename)) then
498                         @git_image = true
499                         startOfSections = i + 1
500                     end
501                     break
502                 end
503             end
504             lines_with_contents = lines[startOfSections...lines.length]
505             @sections = DiffSection.parse(lines_with_contents) unless @binary
506             if @image
507                 @image_url = "data:image/png;base64," + lines_with_contents.join
508             elsif @git_image
509                 begin
510                     raise "index line is missing" unless @git_indexes
511
512                     chunks = nil
513                     for i in 0...lines_with_contents.length
514                         if lines_with_contents[i] =~ /^$/
515                             chunks = [lines_with_contents[i + 1 .. -1], lines_with_contents[0 .. i]]
516                             break
517                         end
518                     end
519
520                     raise "no binary chunks" unless chunks
521
522                     @image_urls = chunks.zip(@git_indexes).collect do |chunk, git_index|
523                         FileDiff.extract_contents_from_git_binary_chunk(chunk, git_index)
524                     end
525                 rescue
526                     @image_error = "Exception raised during decoding git binary patch:<pre>#{CGI.escapeHTML($!.to_s + "\n" + $!.backtrace.join("\n"))}</pre>"
527                 end
528             end
529             nil
530         end
531
532         def to_html
533             str = "<div class='FileDiff'>\n"
534             str += "<h1>#{PrettyPatch.linkifyFilename(@filename)}</h1>\n"
535             if @image then
536                 str += "<img class='image' src='" + @image_url + "' />"
537             elsif @git_image then
538                 if @image_error
539                     str += @image_error
540                 else
541                     for i in (0...2)
542                         image_url = @image_urls[i]
543                         style = ["remove", "add"][i]
544                         str += "<p class=\"#{style}\">"
545                         if image_url
546                             str += "<img class='image' src='" + image_url + "' />"
547                         else
548                             str += ["Added", "Removed"][i]
549                         end
550                     end
551                 end
552             elsif @binary then
553                 str += "<span class='text'>Binary file, nothing to see here</span>"
554             else
555                 str += @sections.collect{ |section| section.to_html }.join("<br>\n") unless @sections.nil?
556             end
557
558             if @from then
559                 str += "<span class='revision'>" + @from + "</span>"
560             end
561
562             str += "</div>\n"
563         end
564
565         def self.parse(string)
566             haveSeenDiffHeader = false
567             linesForDiffs = []
568             string.each_line do |line|
569                 if (PrettyPatch.diff_header?(line))
570                     linesForDiffs << []
571                     haveSeenDiffHeader = true
572                 elsif (!haveSeenDiffHeader && line =~ /^--- /)
573                     linesForDiffs << []
574                     haveSeenDiffHeader = false
575                 end
576                 linesForDiffs.last << line unless linesForDiffs.last.nil?
577             end
578
579             linesForDiffs.collect { |lines| FileDiff.new(lines) }
580         end
581
582         def self.git_new_file_binary_patch(filename, encoded_chunk, git_index)
583             return <<END
584 diff --git a/#{filename} b/#{filename}
585 new file mode 100644
586 index 0000000000000000000000000000000000000000..#{git_index}
587 GIT binary patch
588 #{encoded_chunk.join("")}literal 0
589 HcmV?d00001
590
591 END
592         end
593
594         def self.extract_contents_from_git_binary_chunk(encoded_chunk, git_index)
595             # We use Tempfile we need a unique file among processes.
596             tempfile = Tempfile.new("PrettyPatch")
597             # We need a filename which doesn't exist to apply a patch
598             # which creates a new file. Append a suffix so filename
599             # doesn't exist.
600             filepath = tempfile.path + '.bin'
601             filename = File.basename(filepath)
602
603             patch = FileDiff.git_new_file_binary_patch(filename, encoded_chunk, git_index)
604
605             # Apply the git binary patch using git-apply.
606             cmd = GIT_PATH + " apply --directory=" + File.dirname(filepath)
607             stdin, stdout, stderr = *Open3.popen3(cmd)
608             begin
609                 stdin.puts(patch)
610                 stdin.close
611
612                 error = stderr.read
613                 raise error if error != ""
614
615                 contents = File.read(filepath)
616             ensure
617                 stdin.close unless stdin.closed?
618                 stdout.close
619                 stderr.close
620                 File.unlink(filename) if File.exists?(filename)
621             end
622
623             return nil if contents.empty?
624             return "data:image/png;base64," + [contents].pack("m")
625         end
626     end
627
628     class DiffBlock
629         attr_accessor :parts
630
631         def initialize(container)
632             @parts = []
633             container << self
634         end
635
636         def to_html
637             str = "<div class='DiffBlock'>\n"
638             str += @parts.collect{ |part| part.to_html }.join
639             str += "<div class='clear_float'></div></div>\n"
640         end
641     end
642
643     class DiffBlockPart
644         attr_reader :className
645         attr :lines
646
647         def initialize(className, container)
648             @className = className
649             @lines = []
650             container.parts << self
651         end
652
653         def to_html
654             str = "<div class='DiffBlockPart %s'>\n" % @className
655             str += @lines.collect{ |line| line.to_html }.join
656             # Don't put white-space after this so adjacent inline-block DiffBlockParts will not wrap.
657             str += "</div>"
658         end
659     end
660
661     class DiffSection
662         def initialize(lines)
663             lines.length >= 1 or raise "DiffSection.parse only received %d lines" % lines.length
664
665             matches = START_OF_SECTION_FORMAT.match(lines[0])
666             from, to = [matches[1].to_i, matches[2].to_i] unless matches.nil?
667
668             @blocks = []
669             diff_block = nil
670             diff_block_part = nil
671
672             for line in lines[1...lines.length]
673                 startOfLine = line =~ /^[-\+ ]/ ? 1 : 0
674                 text = line[startOfLine...line.length].chomp
675                 case line[0]
676                 when ?-
677                     if (diff_block_part.nil? or diff_block_part.className != 'remove')
678                         diff_block = DiffBlock.new(@blocks)
679                         diff_block_part = DiffBlockPart.new('remove', diff_block)
680                     end
681
682                     diff_block_part.lines << CodeLine.new(from, nil, text)
683                     from += 1 unless from.nil?
684                 when ?+
685                     if (diff_block_part.nil? or diff_block_part.className != 'add')
686                         # Put add lines that immediately follow remove lines into the same DiffBlock.
687                         if (diff_block.nil? or diff_block_part.className != 'remove')
688                             diff_block = DiffBlock.new(@blocks)
689                         end
690
691                         diff_block_part = DiffBlockPart.new('add', diff_block)
692                     end
693
694                     diff_block_part.lines << CodeLine.new(nil, to, text)
695                     to += 1 unless to.nil?
696                 else
697                     if (diff_block_part.nil? or diff_block_part.className != 'shared')
698                         diff_block = DiffBlock.new(@blocks)
699                         diff_block_part = DiffBlockPart.new('shared', diff_block)
700                     end
701
702                     diff_block_part.lines << CodeLine.new(from, to, text)
703                     from += 1 unless from.nil?
704                     to += 1 unless to.nil?
705                 end
706             end
707
708             changes = [ [ [], [] ] ]
709             for block in @blocks
710                 for block_part in block.parts
711                     for line in block_part.lines
712                         if (!line.fromLineNumber.nil? and !line.toLineNumber.nil?) then
713                             changes << [ [], [] ]
714                             next
715                         end
716                         changes.last.first << line if line.toLineNumber.nil?
717                         changes.last.last << line if line.fromLineNumber.nil?
718                     end
719                 end
720             end
721
722             for change in changes
723                 next unless change.first.length == change.last.length
724                 for i in (0...change.first.length)
725                     raw_operations = HTMLDiff::DiffBuilder.new(change.first[i].text, change.last[i].text).operations
726                     operations = []
727                     back = 0
728                     raw_operations.each_with_index do |operation, j|
729                         if operation.action == :equal and j < raw_operations.length - 1
730                            length = operation.end_in_new - operation.start_in_new
731                            if length < SMALLEST_EQUAL_OPERATION
732                                back = length
733                                next
734                            end
735                         end
736                         operation.start_in_old -= back
737                         operation.start_in_new -= back
738                         back = 0
739                         operations << operation
740                     end
741                     change.first[i].operations = operations
742                     change.last[i].operations = operations
743                 end
744             end
745
746             @blocks.unshift(ContextLine.new(matches[3])) unless matches.nil? || matches[3].empty?
747         end
748
749         def to_html
750             str = "<div class='DiffSection'>\n"
751             str += @blocks.collect{ |block| block.to_html }.join
752             str += "</div>\n"
753         end
754         
755         def self.parse(lines)
756             linesForSections = lines.inject([[]]) do |sections, line|
757                 sections << [] if line =~ /^@@/
758                 sections.last << line
759                 sections
760             end
761
762             linesForSections.delete_if { |lines| lines.nil? or lines.empty? }
763             linesForSections.collect { |lines| DiffSection.new(lines) }
764         end
765     end
766
767     class Line
768         attr_reader :fromLineNumber
769         attr_reader :toLineNumber
770         attr_reader :text
771
772         def initialize(from, to, text)
773             @fromLineNumber = from
774             @toLineNumber = to
775             @text = text
776         end
777
778         def text_as_html
779             CGI.escapeHTML(text)
780         end
781
782         def classes
783             lineClasses = ["Line", "LineContainer"]
784             lineClasses << ["add"] unless @toLineNumber.nil? or !@fromLineNumber.nil?
785             lineClasses << ["remove"] unless @fromLineNumber.nil? or !@toLineNumber.nil?
786             lineClasses
787         end
788
789         def to_html
790             markedUpText = self.text_as_html
791             str = "<div class='%s'>\n" % self.classes.join(' ')
792             str += "<span class='from lineNumber'>%s</span><span class='to lineNumber'>%s</span>" %
793                    [@fromLineNumber.nil? ? '&nbsp;' : @fromLineNumber,
794                     @toLineNumber.nil? ? '&nbsp;' : @toLineNumber] unless @fromLineNumber.nil? and @toLineNumber.nil?
795             str += "<span class='text'>%s</span>\n" % markedUpText
796             str += "</div>\n"
797         end
798     end
799
800     class CodeLine < Line
801         attr :operations, true
802
803         def text_as_html
804             html = []
805             tag = @fromLineNumber.nil? ? "ins" : "del"
806             if @operations.nil? or @operations.empty?
807                 return CGI.escapeHTML(@text)
808             end
809             @operations.each do |operation|
810                 start = @fromLineNumber.nil? ? operation.start_in_new : operation.start_in_old
811                 eend = @fromLineNumber.nil? ? operation.end_in_new : operation.end_in_old
812                 escaped_text = CGI.escapeHTML(@text[start...eend])
813                 if eend - start === 0 or operation.action === :equal
814                     html << escaped_text
815                 else
816                     html << "<#{tag}>#{escaped_text}</#{tag}>"
817                 end
818             end
819             html.join
820         end
821     end
822
823     class ContextLine < Line
824         def initialize(context)
825             super("@", "@", context)
826         end
827
828         def classes
829             super << "context"
830         end
831     end
832 end