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