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