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