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