2011-01-11 Ojan Vafai <ojan@chromium.org>
[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 .add {
208     background-color: #dfd;
209 }
210
211 .add ins {
212     background-color: #9e9;
213     text-decoration: none;
214 }
215
216 .remove {
217     background-color: #fdd;
218 }
219
220 .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 {
321   margin: 0;
322 }
323
324 .ExpandText {
325   margin-left: 0.67em;
326 }
327
328 .LinkContainer a {
329   border: 0;
330 }
331
332 .LinkContainer a:after {
333   content: " | ";
334   color: black;
335 }
336
337 .LinkContainer a:last-of-type:after {
338   content: "";
339 }
340
341 .help {
342  color: gray;
343  font-style: italic;
344 }
345
346 #message {
347   font-size: small;
348   font-family: sans-serif;
349 }
350
351 .commentStatus {
352   font-style: italic;
353 }
354
355 .comment, .previousComment, .frozenComment {
356   background-color: #ffd;
357 }
358
359 .overallComments {
360   -webkit-box-flex: 1;
361   -moz-box-flex: 1;
362   margin-right: 3px;
363 }
364
365 .previousComment, .frozenComment {
366   border: inset 1px;
367   padding: 5px;
368   white-space: pre-wrap;
369 }
370
371 .comment button {
372   width: 6em;
373 }
374
375 .focused {
376   border: 1px solid blue;
377 }
378
379 .statusBubble {
380   /* The width/height get set to the bubble contents via postMessage on browsers that support it. */
381   width: 450px;
382   height: 20px;
383   margin: 2px 2px 0 0;
384   border: none;
385   vertical-align: middle;
386 }
387
388 .pseudo_resize_event_iframe {
389   height: 10%;
390   width: 10%;
391   position: absolute;
392   top: -11%;
393 }
394 </style>
395 <script src="https://ajax.googleapis.com/ajax/libs/jquery/1.4.2/jquery.min.js"></script> 
396 <script src="code-review.js?version=21"></script>
397 EOF
398
399     def self.revisionOrDescription(string)
400         case string
401         when /\(revision \d+\)/
402             /\(revision (\d+)\)/.match(string)[1]
403         when /\(.*\)/
404             /\((.*)\)/.match(string)[1]
405         end
406     end
407
408     def self.has_image_suffix(filename)
409         filename =~ /\.(png|jpg|gif)$/
410     end
411
412     class FileDiff
413         def initialize(lines)
414             @filename = PrettyPatch.filename_from_diff_header(lines[0].chomp)
415             startOfSections = 1
416             for i in 0...lines.length
417                 case lines[i]
418                 when /^--- /
419                     @from = PrettyPatch.revisionOrDescription(lines[i])
420                 when /^\+\+\+ /
421                     @filename = PrettyPatch.filename_from_diff_header(lines[i].chomp) if @filename.nil?
422                     @to = PrettyPatch.revisionOrDescription(lines[i])
423                     startOfSections = i + 1
424                     break
425                 when BINARY_FILE_MARKER_FORMAT
426                     @binary = true
427                     if (IMAGE_FILE_MARKER_FORMAT.match(lines[i + 1]) or PrettyPatch.has_image_suffix(@filename)) then
428                         @image = true
429                         startOfSections = i + 2
430                         for x in startOfSections...lines.length
431                             # Binary diffs often have property changes listed before the actual binary data.  Skip them.
432                             if START_OF_BINARY_DATA_FORMAT.match(lines[x]) then
433                                 startOfSections = x
434                                 break
435                             end
436                         end
437                     end
438                     break
439                 when GIT_INDEX_MARKER_FORMAT
440                     @git_indexes = [$1, $2]
441                 when GIT_BINARY_FILE_MARKER_FORMAT
442                     @binary = true
443                     if (GIT_LITERAL_FORMAT.match(lines[i + 1]) and PrettyPatch.has_image_suffix(@filename)) then
444                         @git_image = true
445                         startOfSections = i + 1
446                     end
447                     break
448                 end
449             end
450             lines_with_contents = lines[startOfSections...lines.length]
451             @sections = DiffSection.parse(lines_with_contents) unless @binary
452             if @image
453                 @image_url = "data:image/png;base64," + lines_with_contents.join
454             elsif @git_image
455                 begin
456                     raise "index line is missing" unless @git_indexes
457
458                     chunks = nil
459                     for i in 0...lines_with_contents.length
460                         if lines_with_contents[i] =~ /^$/
461                             chunks = [lines_with_contents[i + 1 .. -1], lines_with_contents[0 .. i]]
462                             break
463                         end
464                     end
465
466                     raise "no binary chunks" unless chunks
467
468                     @image_urls = chunks.zip(@git_indexes).collect do |chunk, git_index|
469                         FileDiff.extract_contents_from_git_binary_chunk(chunk, git_index)
470                     end
471                 rescue
472                     @image_error = "Exception raised during decoding git binary patch:<pre>#{CGI.escapeHTML($!.to_s + "\n" + $!.backtrace.join("\n"))}</pre>"
473                 end
474             end
475             nil
476         end
477
478         def to_html
479             str = "<div class='FileDiff'>\n"
480             str += "<h1>#{PrettyPatch.linkifyFilename(@filename)}</h1>\n"
481             if @image then
482                 str += "<img class='image' src='" + @image_url + "' />"
483             elsif @git_image then
484                 if @image_error
485                     str += @image_error
486                 else
487                     for i in (0...2)
488                         image_url = @image_urls[i]
489                         style = ["remove", "add"][i]
490                         str += "<p class=\"#{style}\">"
491                         if image_url
492                             str += "<img class='image' src='" + image_url + "' />"
493                         else
494                             str += ["Added", "Removed"][i]
495                         end
496                     end
497                 end
498             elsif @binary then
499                 str += "<span class='text'>Binary file, nothing to see here</span>"
500             else
501                 str += @sections.collect{ |section| section.to_html }.join("<br>\n") unless @sections.nil?
502             end
503             str += "</div>\n"
504         end
505
506         def self.parse(string)
507             haveSeenDiffHeader = false
508             linesForDiffs = []
509             string.each_line do |line|
510                 if (PrettyPatch.diff_header?(line))
511                     linesForDiffs << []
512                     haveSeenDiffHeader = true
513                 elsif (!haveSeenDiffHeader && line =~ /^--- /)
514                     linesForDiffs << []
515                     haveSeenDiffHeader = false
516                 end
517                 linesForDiffs.last << line unless linesForDiffs.last.nil?
518             end
519
520             linesForDiffs.collect { |lines| FileDiff.new(lines) }
521         end
522
523         def self.git_new_file_binary_patch(filename, encoded_chunk, git_index)
524             return <<END
525 diff --git a/#{filename} b/#{filename}
526 new file mode 100644
527 index 0000000000000000000000000000000000000000..#{git_index}
528 GIT binary patch
529 #{encoded_chunk.join("")}literal 0
530 HcmV?d00001
531
532 END
533         end
534
535         def self.extract_contents_from_git_binary_chunk(encoded_chunk, git_index)
536             # We use Tempfile we need a unique file among processes.
537             tempfile = Tempfile.new("PrettyPatch")
538             # We need a filename which doesn't exist to apply a patch
539             # which creates a new file. Append a suffix so filename
540             # doesn't exist.
541             filepath = tempfile.path + '.bin'
542             filename = File.basename(filepath)
543
544             patch = FileDiff.git_new_file_binary_patch(filename, encoded_chunk, git_index)
545
546             # Apply the git binary patch using git-apply.
547             cmd = GIT_PATH + " apply --directory=" + File.dirname(filepath)
548             stdin, stdout, stderr = *Open3.popen3(cmd)
549             begin
550                 stdin.puts(patch)
551                 stdin.close
552
553                 error = stderr.read
554                 raise error if error != ""
555
556                 contents = File.read(filepath)
557             ensure
558                 stdin.close unless stdin.closed?
559                 stdout.close
560                 stderr.close
561                 File.unlink(filename) if File.exists?(filename)
562             end
563
564             return nil if contents.empty?
565             return "data:image/png;base64," + [contents].pack("m")
566         end
567     end
568
569     class DiffSection
570         def initialize(lines)
571             lines.length >= 1 or raise "DiffSection.parse only received %d lines" % lines.length
572
573             matches = START_OF_SECTION_FORMAT.match(lines[0])
574             from, to = [matches[1].to_i, matches[2].to_i] unless matches.nil?
575
576             @lines = lines[1...lines.length].collect do |line|
577                 startOfLine = line =~ /^[-\+ ]/ ? 1 : 0
578                 text = line[startOfLine...line.length].chomp
579                 case line[0]
580                 when ?-
581                     result = CodeLine.new(from, nil, text)
582                     from += 1 unless from.nil?
583                     result
584                 when ?+
585                     result = CodeLine.new(nil, to, text)
586                     to += 1 unless to.nil?
587                     result
588                 else
589                     result = CodeLine.new(from, to, text)
590                     from += 1 unless from.nil?
591                     to += 1 unless to.nil?
592                     result
593                 end
594             end
595
596             @lines.unshift(ContextLine.new(matches[3])) unless matches.nil? || matches[3].empty?
597
598             changes = [ [ [], [] ] ]
599             for line in @lines
600                 if (!line.fromLineNumber.nil? and !line.toLineNumber.nil?) then
601                     changes << [ [], [] ]
602                     next
603                 end
604                 changes.last.first << line if line.toLineNumber.nil?
605                 changes.last.last << line if line.fromLineNumber.nil?
606             end
607
608             for change in changes
609                 next unless change.first.length == change.last.length
610                 for i in (0...change.first.length)
611                     raw_operations = HTMLDiff::DiffBuilder.new(change.first[i].text, change.last[i].text).operations
612                     operations = []
613                     back = 0
614                     raw_operations.each_with_index do |operation, j|
615                         if operation.action == :equal and j < raw_operations.length - 1
616                            length = operation.end_in_new - operation.start_in_new
617                            if length < SMALLEST_EQUAL_OPERATION
618                                back = length
619                                next
620                            end
621                         end
622                         operation.start_in_old -= back
623                         operation.start_in_new -= back
624                         back = 0
625                         operations << operation
626                     end
627                     change.first[i].operations = operations
628                     change.last[i].operations = operations
629                 end
630             end
631         end
632
633         def to_html
634             str = "<div class='DiffSection'>\n"
635             str += @lines.collect{ |line| line.to_html }.join
636             str += "</div>\n"
637         end
638         
639         def self.parse(lines)
640             linesForSections = lines.inject([[]]) do |sections, line|
641                 sections << [] if line =~ /^@@/
642                 sections.last << line
643                 sections
644             end
645
646             linesForSections.delete_if { |lines| lines.nil? or lines.empty? }
647             linesForSections.collect { |lines| DiffSection.new(lines) }
648         end
649     end
650
651     class Line
652         attr_reader :fromLineNumber
653         attr_reader :toLineNumber
654         attr_reader :text
655
656         def initialize(from, to, text)
657             @fromLineNumber = from
658             @toLineNumber = to
659             @text = text
660         end
661
662         def text_as_html
663             CGI.escapeHTML(text)
664         end
665
666         def classes
667             lineClasses = ["Line"]
668             lineClasses << ["add"] unless @toLineNumber.nil? or !@fromLineNumber.nil?
669             lineClasses << ["remove"] unless @fromLineNumber.nil? or !@toLineNumber.nil?
670             lineClasses
671         end
672
673         def to_html
674             markedUpText = self.text_as_html
675             str = "<div class='%s'>\n" % self.classes.join(' ')
676             str += "<span class='from lineNumber'>%s</span><span class='to lineNumber'>%s</span>\n" %
677                    [@fromLineNumber.nil? ? '&nbsp;' : @fromLineNumber,
678                     @toLineNumber.nil? ? '&nbsp;' : @toLineNumber] unless @fromLineNumber.nil? and @toLineNumber.nil?
679             str += "<span class='text'>%s</span>\n" % markedUpText
680             str += "</div>\n"
681         end
682     end
683
684     class CodeLine < Line
685         attr :operations, true
686
687         def text_as_html
688             html = []
689             tag = @fromLineNumber.nil? ? "ins" : "del"
690             if @operations.nil? or @operations.empty?
691                 return CGI.escapeHTML(@text)
692             end
693             @operations.each do |operation|
694                 start = @fromLineNumber.nil? ? operation.start_in_new : operation.start_in_old
695                 eend = @fromLineNumber.nil? ? operation.end_in_new : operation.end_in_old
696                 escaped_text = CGI.escapeHTML(@text[start...eend])
697                 if eend - start === 0 or operation.action === :equal
698                     html << escaped_text
699                 else
700                     html << "<#{tag}>#{escaped_text}</#{tag}>"
701                 end
702             end
703             html.join
704         end
705     end
706
707     class ContextLine < Line
708         def initialize(context)
709             super("@", "@", context)
710         end
711
712         def classes
713             super << "context"
714         end
715     end
716 end