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