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