2011-01-12 Ojan Vafai <ojan@chromium.org>
[WebKit.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   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=21"></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 DiffSection
572         def initialize(lines)
573             lines.length >= 1 or raise "DiffSection.parse only received %d lines" % lines.length
574
575             matches = START_OF_SECTION_FORMAT.match(lines[0])
576             from, to = [matches[1].to_i, matches[2].to_i] unless matches.nil?
577
578             @lines = lines[1...lines.length].collect do |line|
579                 startOfLine = line =~ /^[-\+ ]/ ? 1 : 0
580                 text = line[startOfLine...line.length].chomp
581                 case line[0]
582                 when ?-
583                     result = CodeLine.new(from, nil, text)
584                     from += 1 unless from.nil?
585                     result
586                 when ?+
587                     result = CodeLine.new(nil, to, text)
588                     to += 1 unless to.nil?
589                     result
590                 else
591                     result = CodeLine.new(from, to, text)
592                     from += 1 unless from.nil?
593                     to += 1 unless to.nil?
594                     result
595                 end
596             end
597
598             @lines.unshift(ContextLine.new(matches[3])) unless matches.nil? || matches[3].empty?
599
600             changes = [ [ [], [] ] ]
601             for line in @lines
602                 if (!line.fromLineNumber.nil? and !line.toLineNumber.nil?) then
603                     changes << [ [], [] ]
604                     next
605                 end
606                 changes.last.first << line if line.toLineNumber.nil?
607                 changes.last.last << line if line.fromLineNumber.nil?
608             end
609
610             for change in changes
611                 next unless change.first.length == change.last.length
612                 for i in (0...change.first.length)
613                     raw_operations = HTMLDiff::DiffBuilder.new(change.first[i].text, change.last[i].text).operations
614                     operations = []
615                     back = 0
616                     raw_operations.each_with_index do |operation, j|
617                         if operation.action == :equal and j < raw_operations.length - 1
618                            length = operation.end_in_new - operation.start_in_new
619                            if length < SMALLEST_EQUAL_OPERATION
620                                back = length
621                                next
622                            end
623                         end
624                         operation.start_in_old -= back
625                         operation.start_in_new -= back
626                         back = 0
627                         operations << operation
628                     end
629                     change.first[i].operations = operations
630                     change.last[i].operations = operations
631                 end
632             end
633         end
634
635         def to_html
636             str = "<div class='DiffSection'>\n"
637             str += @lines.collect{ |line| line.to_html }.join
638             str += "</div>\n"
639         end
640         
641         def self.parse(lines)
642             linesForSections = lines.inject([[]]) do |sections, line|
643                 sections << [] if line =~ /^@@/
644                 sections.last << line
645                 sections
646             end
647
648             linesForSections.delete_if { |lines| lines.nil? or lines.empty? }
649             linesForSections.collect { |lines| DiffSection.new(lines) }
650         end
651     end
652
653     class Line
654         attr_reader :fromLineNumber
655         attr_reader :toLineNumber
656         attr_reader :text
657
658         def initialize(from, to, text)
659             @fromLineNumber = from
660             @toLineNumber = to
661             @text = text
662         end
663
664         def text_as_html
665             CGI.escapeHTML(text)
666         end
667
668         def classes
669             lineClasses = ["Line"]
670             lineClasses << ["add"] unless @toLineNumber.nil? or !@fromLineNumber.nil?
671             lineClasses << ["remove"] unless @fromLineNumber.nil? or !@toLineNumber.nil?
672             lineClasses
673         end
674
675         def to_html
676             markedUpText = self.text_as_html
677             str = "<div class='%s'>\n" % self.classes.join(' ')
678             str += "<span class='from lineNumber'>%s</span><span class='to lineNumber'>%s</span>\n" %
679                    [@fromLineNumber.nil? ? '&nbsp;' : @fromLineNumber,
680                     @toLineNumber.nil? ? '&nbsp;' : @toLineNumber] unless @fromLineNumber.nil? and @toLineNumber.nil?
681             str += "<span class='text'>%s</span>\n" % markedUpText
682             str += "</div>\n"
683         end
684     end
685
686     class CodeLine < Line
687         attr :operations, true
688
689         def text_as_html
690             html = []
691             tag = @fromLineNumber.nil? ? "ins" : "del"
692             if @operations.nil? or @operations.empty?
693                 return CGI.escapeHTML(@text)
694             end
695             @operations.each do |operation|
696                 start = @fromLineNumber.nil? ? operation.start_in_new : operation.start_in_old
697                 eend = @fromLineNumber.nil? ? operation.end_in_new : operation.end_in_old
698                 escaped_text = CGI.escapeHTML(@text[start...eend])
699                 if eend - start === 0 or operation.action === :equal
700                     html << escaped_text
701                 else
702                     html << "<#{tag}>#{escaped_text}</#{tag}>"
703                 end
704             end
705             html.join
706         end
707     end
708
709     class ContextLine < Line
710         def initialize(context)
711             super("@", "@", context)
712         end
713
714         def classes
715             super << "context"
716         end
717     end
718 end