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