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