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