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