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