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