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