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