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