9b351b4df6e805fabca8a5c14e111998367b387c
[WebKit-https.git] / Websites / bugs.webkit.org / PrettyPatch / PrettyPatch.rb
1 require 'cgi'
2 require 'diff'
3 require 'open3'
4 require 'open-uri'
5 require 'pp'
6 require 'set'
7 require 'tempfile'
8
9 module PrettyPatch
10
11 public
12
13     GIT_PATH = "git"
14
15     def self.prettify(string)
16         $last_prettify_file_count = -1
17         $last_prettify_part_count = { "remove" => 0, "add" => 0, "shared" => 0, "binary" => 0, "extract-error" => 0 }
18         string = normalize_line_ending(string)
19         str = "#{HEADER}<body>\n"
20
21         # Just look at the first line to see if it is an SVN revision number as added
22         # by webkit-patch for git checkouts.
23         $svn_revision = 0
24         string.each_line do |line|
25             match = /^Subversion\ Revision: (\d*)$/.match(line)
26             unless match.nil?
27                 str << "<span class='revision'>#{match[1]}</span>\n"
28                 $svn_revision = match[1].to_i;
29             end
30             break
31         end
32
33         fileDiffs = FileDiff.parse(string)
34
35         $last_prettify_file_count = fileDiffs.length
36         str << fileDiffs.collect{ |diff| diff.to_html }.join
37         str << "</body></html>"
38     end
39
40     def self.filename_from_diff_header(line)
41         DIFF_HEADER_FORMATS.each do |format|
42             match = format.match(line)
43             return match[1] unless match.nil?
44         end
45         nil
46     end
47
48     def self.diff_header?(line)
49         RELAXED_DIFF_HEADER_FORMATS.any? { |format| line =~ format }
50     end
51
52 private
53     DIFF_HEADER_FORMATS = [
54         /^Index: (.*)\r?$/,
55         /^diff --git "?a\/.+"? "?b\/(.+)"?\r?$/,
56         /^\+\+\+ ([^\t]+)(\t.*)?\r?$/
57     ]
58
59     RELAXED_DIFF_HEADER_FORMATS = [
60         /^Index:/,
61         /^diff/
62     ]
63
64     BINARY_FILE_MARKER_FORMAT = /^Cannot display: file marked as a binary type.$/
65
66     IMAGE_FILE_MARKER_FORMAT = /^svn:mime-type = image\/png$/
67
68     GIT_INDEX_MARKER_FORMAT = /^index ([0-9a-f]{40})\.\.([0-9a-f]{40})/
69
70     GIT_BINARY_FILE_MARKER_FORMAT = /^GIT binary patch$/
71
72     GIT_BINARY_PATCH_FORMAT = /^(literal|delta) \d+$/
73
74     GIT_LITERAL_FORMAT = /^literal \d+$/
75
76     GIT_DELTA_FORMAT = /^delta \d+$/
77
78     START_OF_BINARY_DATA_FORMAT = /^[0-9a-zA-Z\+\/=]{20,}/ # Assume 20 chars without a space is base64 binary data.
79
80     START_OF_SECTION_FORMAT = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@\s*(.*)/
81
82     START_OF_EXTENT_STRING = "%c" % 0
83     END_OF_EXTENT_STRING = "%c" % 1
84
85     # We won't search for intra-line diffs in lines longer than this length, to avoid hangs. See <http://webkit.org/b/56109>.
86     MAXIMUM_INTRALINE_DIFF_LINE_LENGTH = 10000
87
88     SMALLEST_EQUAL_OPERATION = 3
89
90     OPENSOURCE_TRAC_URL = "http://trac.webkit.org/"
91
92     OPENSOURCE_DIRS = Set.new %w[
93         Examples
94         LayoutTests
95         PerformanceTests
96         Source
97         Tools
98         WebKitLibraries
99         Websites
100     ]
101
102     IMAGE_CHECKSUM_ERROR = "INVALID: Image lacks a checksum. This will fail with a MISSING error in run-webkit-tests. Always generate new png files using run-webkit-tests."
103
104     def self.normalize_line_ending(s)
105         if RUBY_VERSION >= "1.9"
106             # Transliteration table from http://stackoverflow.com/a/6609998
107             transliteration_table = { '\xc2\x82' => ',',        # High code comma
108                                       '\xc2\x84' => ',,',       # High code double comma
109                                       '\xc2\x85' => '...',      # Tripple dot
110                                       '\xc2\x88' => '^',        # High carat
111                                       '\xc2\x91' => '\x27',     # Forward single quote
112                                       '\xc2\x92' => '\x27',     # Reverse single quote
113                                       '\xc2\x93' => '\x22',     # Forward double quote
114                                       '\xc2\x94' => '\x22',     # Reverse double quote
115                                       '\xc2\x95' => ' ',
116                                       '\xc2\x96' => '-',        # High hyphen
117                                       '\xc2\x97' => '--',       # Double hyphen
118                                       '\xc2\x99' => ' ',
119                                       '\xc2\xa0' => ' ',
120                                       '\xc2\xa6' => '|',        # Split vertical bar
121                                       '\xc2\xab' => '<<',       # Double less than
122                                       '\xc2\xbb' => '>>',       # Double greater than
123                                       '\xc2\xbc' => '1/4',      # one quarter
124                                       '\xc2\xbd' => '1/2',      # one half
125                                       '\xc2\xbe' => '3/4',      # three quarters
126                                       '\xca\xbf' => '\x27',     # c-single quote
127                                       '\xcc\xa8' => '',         # modifier - under curve
128                                       '\xcc\xb1' => ''          # modifier - under line
129                                    }
130             encoded_string = s.force_encoding('UTF-8').encode('UTF-16', :invalid => :replace, :replace => '', :fallback => transliteration_table).encode('UTF-8')
131             encoded_string.gsub /\r\n?/, "\n"
132         else
133             s.gsub /\r\n?/, "\n"
134         end
135     end
136
137     def self.find_url_and_path(file_path)
138         # Search file_path from the bottom up, at each level checking whether
139         # we've found a directory we know exists in the source tree.
140
141         dirname, basename = File.split(file_path)
142         dirname.split(/\//).reverse.inject(basename) do |path, directory|
143             path = directory + "/" + path
144
145             return [OPENSOURCE_TRAC_URL, path] if OPENSOURCE_DIRS.include?(directory)
146
147             path
148         end
149
150         [nil, file_path]
151     end
152
153     def self.linkifyFilename(filename)
154         url, pathBeneathTrunk = find_url_and_path(filename)
155
156         url.nil? ? filename : "<a href='#{url}browser/trunk/#{pathBeneathTrunk}'>#{filename}</a>"
157     end
158
159
160     HEADER =<<EOF
161 <html>
162 <head>
163 <style>
164 :link, :visited {
165     text-decoration: none;
166     border-bottom: 1px dotted;
167 }
168
169 :link {
170     color: #039;
171 }
172
173 .FileDiff {
174     background-color: #f8f8f8;
175     border: 1px solid #ddd;
176     font-family: monospace;
177     margin: 1em 0;
178     position: relative;
179 }
180
181 h1 {
182     color: #333;
183     font-family: sans-serif;
184     font-size: 1em;
185     margin-left: 0.5em;
186     display: table-cell;
187     width: 100%;
188     padding: 0.5em;
189 }
190
191 h1 :link, h1 :visited {
192     color: inherit;
193 }
194
195 h1 :hover {
196     color: #555;
197     background-color: #eee;
198 }
199
200 .DiffLinks {
201     float: right;
202 }
203
204 .FileDiffLinkContainer {
205     opacity: 0;
206     display: table-cell;
207     padding-right: 0.5em;
208     white-space: nowrap;
209 }
210
211 .DiffSection {
212     background-color: white;
213     border: solid #ddd;
214     border-width: 1px 0px;
215 }
216
217 .ExpansionLine, .LineContainer {
218     white-space: nowrap;
219 }
220
221 .sidebyside .DiffBlockPart.add:first-child {
222     float: right;
223 }
224
225 .LineSide,
226 .sidebyside .DiffBlockPart.remove,
227 .sidebyside .DiffBlockPart.add {
228     display:inline-block;
229     width: 50%;
230     vertical-align: top;
231 }
232
233 .sidebyside .resizeHandle {
234     width: 5px;
235     height: 100%;
236     cursor: move;
237     position: absolute;
238     top: 0;
239     left: 50%;
240 }
241
242 .sidebyside .resizeHandle:hover {
243     background-color: grey;
244     opacity: 0.5;
245 }
246
247 .sidebyside .DiffBlockPart.remove .to,
248 .sidebyside .DiffBlockPart.add .from {
249     display: none;
250 }
251
252 .lineNumber, .expansionLineNumber {
253     border-bottom: 1px solid #998;
254     border-right: 1px solid #ddd;
255     color: #444;
256     display: inline-block;
257     padding: 1px 5px 0px 0px;
258     text-align: right;
259     vertical-align: bottom;
260     width: 3em;
261 }
262
263 .lineNumber {
264   background-color: #eed;
265 }
266
267 .expansionLineNumber {
268   background-color: #eee;
269 }
270
271 .text {
272     padding-left: 5px;
273     white-space: pre-wrap;
274     word-wrap: break-word;
275 }
276
277 .image {
278     border: 2px solid black;
279 }
280
281 .context, .context .lineNumber {
282     color: #849;
283     background-color: #fef;
284 }
285
286 .Line.add, .FileDiff .add {
287     background-color: #dfd;
288 }
289
290 .Line.add ins {
291     background-color: #9e9;
292     text-decoration: none;
293 }
294
295 .Line.remove, .FileDiff .remove {
296     background-color: #fdd;
297 }
298
299 .Line.remove del {
300     background-color: #e99;
301     text-decoration: none;
302 }
303
304 /* Support for inline comments */
305
306 .author {
307   font-style: italic;
308 }
309
310 .comment {
311   position: relative;
312 }
313
314 .comment textarea {
315   height: 6em;
316 }
317
318 .overallComments textarea {
319   height: 2em;
320   max-width: 100%;
321   min-width: 200px;
322 }
323
324 .comment textarea, .overallComments textarea {
325   display: block;
326   width: 100%;
327 }
328
329 .overallComments .open {
330   -webkit-transition: height .2s;
331   height: 4em;
332 }
333
334 #statusBubbleContainer.wrap {
335   display: block;
336 }
337
338 #toolbar {
339   display: -webkit-flex;
340   display: -moz-flex;
341   padding: 3px;
342   left: 0;
343   right: 0;
344   border: 1px solid #ddd;
345   background-color: #eee;
346   font-family: sans-serif;
347   position: fixed;
348   bottom: 0;
349 }
350
351 #toolbar .actions {
352   float: right;
353 }
354
355 .winter {
356   position: fixed;
357   z-index: 5;
358   left: 0;
359   right: 0;
360   top: 0;
361   bottom: 0;
362   background-color: black;
363   opacity: 0.8;
364 }
365
366 .inactive {
367   display: none;
368 }
369
370 .lightbox {
371   position: fixed;
372   z-index: 6;
373   left: 10%;
374   right: 10%;
375   top: 10%;
376   bottom: 10%;
377   background: white;
378 }
379
380 .lightbox iframe {
381   width: 100%;
382   height: 100%;
383 }
384
385 .commentContext .lineNumber {
386   background-color: yellow;
387 }
388
389 .selected .lineNumber {
390   background-color: #69F;
391   border-bottom-color: #69F;
392   border-right-color: #69F;
393 }
394
395 .ExpandLinkContainer {
396   opacity: 0;
397   border-top: 1px solid #ddd;
398   border-bottom: 1px solid #ddd;
399 }
400
401 .ExpandArea {
402   margin: 0;
403 }
404
405 .ExpandText {
406   margin-left: 0.67em;
407 }
408
409 .LinkContainer {
410   font-family: sans-serif;
411   font-size: small;
412   font-style: normal;
413   -webkit-transition: opacity 0.5s;
414 }
415
416 .LinkContainer a {
417   border: 0;
418 }
419
420 .LinkContainer a:after {
421   content: " | ";
422   color: black;
423 }
424
425 .LinkContainer a:last-of-type:after {
426   content: "";
427 }
428
429 .help {
430  color: gray;
431  font-style: italic;
432 }
433
434 #message {
435   font-size: small;
436   font-family: sans-serif;
437 }
438
439 .commentStatus {
440   font-style: italic;
441 }
442
443 .comment, .previousComment, .frozenComment {
444   background-color: #ffd;
445 }
446
447 .overallComments {
448   -webkit-flex: 1;
449   -moz-flex: 1;
450   margin-right: 3px;
451 }
452
453 .previousComment, .frozenComment {
454   border: inset 1px;
455   padding: 5px;
456   white-space: pre-wrap;
457 }
458
459 .comment button {
460   width: 6em;
461 }
462
463 div:focus {
464   outline: 1px solid blue;
465   outline-offset: -1px;
466 }
467
468 .statusBubble {
469   /* The width/height get set to the bubble contents via postMessage on browsers that support it. */
470   width: 450px;
471   height: 20px;
472   margin: 2px 2px 0 0;
473   border: none;
474   vertical-align: middle;
475 }
476
477 .revision {
478   display: none;
479 }
480
481 .autosave-state {
482   position: absolute;
483   right: 0;
484   top: -1.3em;
485   padding: 0 3px;
486   outline: 1px solid #DDD;
487   color: #8FDF5F;
488   font-size: small;   
489   background-color: #EEE;
490 }
491
492 .autosave-state:empty {
493   outline: 0px;
494 }
495 .autosave-state.saving {
496   color: #E98080;
497 }
498
499 .clear_float {
500     clear: both;
501 }
502 </style>
503 <script src="https://ajax.googleapis.com/ajax/libs/jquery/1.4.2/jquery.min.js"></script> 
504 <script src="code-review.js?version=43"></script>
505 </head>
506 EOF
507
508     def self.revisionOrDescription(string)
509         case string
510         when /\(revision \d+\)/
511             /\(revision (\d+)\)/.match(string)[1]
512         when /\(.*\)/
513             /\((.*)\)/.match(string)[1]
514         end
515     end
516
517     def self.has_image_suffix(filename)
518         filename =~ /\.(png|jpg|gif)$/
519     end
520
521     class FileDiff
522         def initialize(lines)
523             @filename = PrettyPatch.filename_from_diff_header(lines[0].chomp)
524             startOfSections = 1
525             for i in 0...lines.length
526                 case lines[i]
527                 when /^--- /
528                     @from = PrettyPatch.revisionOrDescription(lines[i])
529                 when /^\+\+\+ /
530                     @filename = PrettyPatch.filename_from_diff_header(lines[i].chomp) if @filename.nil?
531                     @to = PrettyPatch.revisionOrDescription(lines[i])
532                     startOfSections = i + 1
533                     break
534                 when BINARY_FILE_MARKER_FORMAT
535                     @binary = true
536                     if (IMAGE_FILE_MARKER_FORMAT.match(lines[i + 1]) or PrettyPatch.has_image_suffix(@filename)) then
537                         @image = true
538                         startOfSections = i + 2
539                         for x in startOfSections...lines.length
540                             # Binary diffs often have property changes listed before the actual binary data.  Skip them.
541                             if START_OF_BINARY_DATA_FORMAT.match(lines[x]) then
542                                 startOfSections = x
543                                 break
544                             end
545                         end
546                     end
547                     break
548                 when GIT_INDEX_MARKER_FORMAT
549                     @git_indexes = [$1, $2]
550                 when GIT_BINARY_FILE_MARKER_FORMAT
551                     @binary = true
552                     if (GIT_BINARY_PATCH_FORMAT.match(lines[i + 1]) and PrettyPatch.has_image_suffix(@filename)) then
553                         @git_image = true
554                         startOfSections = i + 1
555                     end
556                     break
557                 end
558             end
559             lines_with_contents = lines[startOfSections...lines.length]
560             @sections = DiffSection.parse(lines_with_contents) unless @binary
561             if @image and not lines_with_contents.empty?
562                 @image_url = "data:image/png;base64," + lines_with_contents.join
563                 @image_checksum = FileDiff.read_checksum_from_png(lines_with_contents.join.unpack("m").join)
564             elsif @git_image
565                 begin
566                     raise "index line is missing" unless @git_indexes
567
568                     chunks = nil
569                     for i in 0...lines_with_contents.length
570                         if lines_with_contents[i] =~ /^$/
571                             chunks = [lines_with_contents[i + 1 .. -1], lines_with_contents[0 .. i]]
572                             break
573                         end
574                     end
575
576                     raise "no binary chunks" unless chunks
577
578                     from_filepath = FileDiff.extract_contents_of_from_revision(@filename, chunks[0], @git_indexes[0])
579                     to_filepath = FileDiff.extract_contents_of_to_revision(@filename, chunks[1], @git_indexes[1], from_filepath, @git_indexes[0])
580                     filepaths = from_filepath, to_filepath
581
582                     binary_contents = filepaths.collect { |filepath| File.exists?(filepath) ? File.read(filepath) : nil }
583                     @image_urls = binary_contents.collect { |content| (content and not content.empty?) ? "data:image/png;base64," + [content].pack("m") : nil }
584                     @image_checksums = binary_contents.collect { |content| FileDiff.read_checksum_from_png(content) }
585                 rescue
586                     $last_prettify_part_count["extract-error"] += 1
587                     @image_error = "Exception raised during decoding git binary patch:<pre>#{CGI.escapeHTML($!.to_s + "\n" + $!.backtrace.join("\n"))}</pre>"
588                 ensure
589                     File.unlink(from_filepath) if (from_filepath and File.exists?(from_filepath))
590                     File.unlink(to_filepath) if (to_filepath and File.exists?(to_filepath))
591                 end
592             end
593             nil
594         end
595
596         def image_to_html
597             if not @image_url then
598                 return "<span class='text'>Image file removed</span>"
599             end
600
601             image_checksum = ""
602             if @image_checksum
603                 image_checksum = @image_checksum
604             elsif @filename.include? "-expected.png" and @image_url
605                 image_checksum = IMAGE_CHECKSUM_ERROR
606             end
607
608             return "<p>" + image_checksum + "</p><img class='image' src='" + @image_url + "' />"
609         end
610
611         def to_html
612             str = "<div class='FileDiff'>\n"
613             str += "<h1>#{PrettyPatch.linkifyFilename(@filename)}</h1>\n"
614             if @image then
615                 str += self.image_to_html
616             elsif @git_image then
617                 if @image_error
618                     str += @image_error
619                 else
620                     for i in (0...2)
621                         image_url = @image_urls[i]
622                         image_checksum = @image_checksums[i]
623
624                         style = ["remove", "add"][i]
625                         str += "<p class=\"#{style}\">"
626
627                         if image_checksum
628                             str += image_checksum
629                         elsif @filename.include? "-expected.png" and image_url
630                             str += IMAGE_CHECKSUM_ERROR
631                         end
632
633                         str += "<br>"
634
635                         if image_url
636                             str += "<img class='image' src='" + image_url + "' />"
637                         else
638                             str += ["</p>Added", "</p>Removed"][i]
639                         end
640                     end
641                 end
642             elsif @binary then
643                 $last_prettify_part_count["binary"] += 1
644                 str += "<span class='text'>Binary file, nothing to see here</span>"
645             else
646                 str += @sections.collect{ |section| section.to_html }.join("<br>\n") unless @sections.nil?
647             end
648
649             if @from then
650                 str += "<span class='revision'>" + @from + "</span>"
651             end
652
653             str += "</div>\n"
654         end
655
656         def self.parse(string)
657             haveSeenDiffHeader = false
658             linesForDiffs = []
659             string.each_line do |line|
660                 if (PrettyPatch.diff_header?(line))
661                     linesForDiffs << []
662                     haveSeenDiffHeader = true
663                 elsif (!haveSeenDiffHeader && line =~ /^--- /)
664                     linesForDiffs << []
665                     haveSeenDiffHeader = false
666                 end
667                 linesForDiffs.last << line unless linesForDiffs.last.nil?
668             end
669
670             linesForDiffs.collect { |lines| FileDiff.new(lines) }
671         end
672
673         def self.read_checksum_from_png(png_bytes)
674             match = png_bytes && png_bytes.match(/tEXtchecksum\0([a-fA-F0-9]{32})/)
675             match ? match[1] : nil
676         end
677
678         def self.git_new_file_binary_patch(filename, encoded_chunk, git_index)
679             return <<END
680 diff --git a/#{filename} b/#{filename}
681 new file mode 100644
682 index 0000000000000000000000000000000000000000..#{git_index}
683 GIT binary patch
684 #{encoded_chunk.join("")}literal 0
685 HcmV?d00001
686
687 END
688         end
689
690         def self.git_changed_file_binary_patch(to_filename, from_filename, encoded_chunk, to_git_index, from_git_index)
691             return <<END
692 diff --git a/#{from_filename} b/#{to_filename}
693 copy from #{from_filename}
694 +++ b/#{to_filename}
695 index #{from_git_index}..#{to_git_index}
696 GIT binary patch
697 #{encoded_chunk.join("")}literal 0
698 HcmV?d00001
699
700 END
701         end
702
703         def self.get_svn_uri(repository_path)
704             "http://svn.webkit.org/repository/webkit/!svn/bc/" + $svn_revision.to_s + "/trunk/" + (repository_path)
705         end
706
707         def self.get_new_temp_filepath_and_name
708             tempfile = Tempfile.new("PrettyPatch")
709             filepath = tempfile.path + '.bin'
710             filename = File.basename(filepath)
711             return filepath, filename
712         end
713
714         def self.download_from_revision_from_svn(repository_path)
715             filepath, filename = get_new_temp_filepath_and_name
716             svn_uri = get_svn_uri(repository_path)
717             open(filepath, 'wb') do |to_file|
718                 to_file << open(svn_uri) { |from_file| from_file.read }
719             end
720             return filepath
721         end
722
723         def self.run_git_apply_on_patch(output_filepath, patch)
724             # Apply the git binary patch using git-apply.
725             cmd = GIT_PATH + " apply --directory=" + File.dirname(output_filepath)
726             stdin, stdout, stderr = *Open3.popen3(cmd)
727             begin
728                 stdin.puts(patch)
729                 stdin.close
730
731                 error = stderr.read
732                 if error != ""
733                     error = "Error running " + cmd + "\n" + "with patch:\n" + patch[0..500] + "...\n" + error
734                 end
735                 raise error if error != ""
736             ensure
737                 stdin.close unless stdin.closed?
738                 stdout.close
739                 stderr.close
740             end
741         end
742
743         def self.extract_contents_from_git_binary_literal_chunk(encoded_chunk, git_index)
744             filepath, filename = get_new_temp_filepath_and_name
745             patch = FileDiff.git_new_file_binary_patch(filename, encoded_chunk, git_index)
746             run_git_apply_on_patch(filepath, patch)
747             return filepath
748         end
749
750         def self.extract_contents_from_git_binary_delta_chunk(from_filepath, from_git_index, encoded_chunk, to_git_index)
751             to_filepath, to_filename = get_new_temp_filepath_and_name
752             from_filename = File.basename(from_filepath)
753             patch = FileDiff.git_changed_file_binary_patch(to_filename, from_filename, encoded_chunk, to_git_index, from_git_index)
754             run_git_apply_on_patch(to_filepath, patch)
755             return to_filepath
756         end
757
758         def self.extract_contents_of_from_revision(repository_path, encoded_chunk, git_index)
759             # For literal encoded, simply reconstruct.
760             if GIT_LITERAL_FORMAT.match(encoded_chunk[0])
761                 return extract_contents_from_git_binary_literal_chunk(encoded_chunk, git_index)
762             end
763             #  For delta encoded, download from svn.
764             if GIT_DELTA_FORMAT.match(encoded_chunk[0])
765                 return download_from_revision_from_svn(repository_path)
766             end
767             raise "Error: unknown git patch encoding"
768         end
769
770         def self.extract_contents_of_to_revision(repository_path, encoded_chunk, git_index, from_filepath, from_git_index)
771             # For literal encoded, simply reconstruct.
772             if GIT_LITERAL_FORMAT.match(encoded_chunk[0])
773                 return extract_contents_from_git_binary_literal_chunk(encoded_chunk, git_index)
774             end
775             # For delta encoded, reconstruct using delta and previously constructed 'from' revision.
776             if GIT_DELTA_FORMAT.match(encoded_chunk[0])
777                 return extract_contents_from_git_binary_delta_chunk(from_filepath, from_git_index, encoded_chunk, git_index)
778             end
779             raise "Error: unknown git patch encoding"
780         end
781     end
782
783     class DiffBlock
784         attr_accessor :parts
785
786         def initialize(container)
787             @parts = []
788             container << self
789         end
790
791         def to_html
792             str = "<div class='DiffBlock'>\n"
793             str += @parts.collect{ |part| part.to_html }.join
794             str += "<div class='clear_float'></div></div>\n"
795         end
796     end
797
798     class DiffBlockPart
799         attr_reader :className
800         attr :lines
801
802         def initialize(className, container)
803             $last_prettify_part_count[className] += 1
804             @className = className
805             @lines = []
806             container.parts << self
807         end
808
809         def to_html
810             str = "<div class='DiffBlockPart %s'>\n" % @className
811             str += @lines.collect{ |line| line.to_html }.join
812             # Don't put white-space after this so adjacent inline-block DiffBlockParts will not wrap.
813             str += "</div>"
814         end
815     end
816
817     class DiffSection
818         def initialize(lines)
819             lines.length >= 1 or raise "DiffSection.parse only received %d lines" % lines.length
820
821             matches = START_OF_SECTION_FORMAT.match(lines[0])
822
823             if matches
824                 from, to = [matches[1].to_i, matches[3].to_i]
825                 if matches[2] and matches[4]
826                     from_end = from + matches[2].to_i
827                     to_end = to + matches[4].to_i
828                 end
829             end
830
831             @blocks = []
832             diff_block = nil
833             diff_block_part = nil
834
835             for line in lines[1...lines.length]
836                 startOfLine = line =~ /^[-\+ ]/ ? 1 : 0
837                 text = line[startOfLine...line.length].chomp
838                 case line[0]
839                 when ?-
840                     if (diff_block_part.nil? or diff_block_part.className != 'remove')
841                         diff_block = DiffBlock.new(@blocks)
842                         diff_block_part = DiffBlockPart.new('remove', diff_block)
843                     end
844
845                     diff_block_part.lines << CodeLine.new(from, nil, text)
846                     from += 1 unless from.nil?
847                 when ?+
848                     if (diff_block_part.nil? or diff_block_part.className != 'add')
849                         # Put add lines that immediately follow remove lines into the same DiffBlock.
850                         if (diff_block.nil? or diff_block_part.className != 'remove')
851                             diff_block = DiffBlock.new(@blocks)
852                         end
853
854                         diff_block_part = DiffBlockPart.new('add', diff_block)
855                     end
856
857                     diff_block_part.lines << CodeLine.new(nil, to, text)
858                     to += 1 unless to.nil?
859                 else
860                     if (diff_block_part.nil? or diff_block_part.className != 'shared')
861                         diff_block = DiffBlock.new(@blocks)
862                         diff_block_part = DiffBlockPart.new('shared', diff_block)
863                     end
864
865                     diff_block_part.lines << CodeLine.new(from, to, text)
866                     from += 1 unless from.nil?
867                     to += 1 unless to.nil?
868                 end
869
870                 break if from_end and to_end and from == from_end and to == to_end
871             end
872
873             changes = [ [ [], [] ] ]
874             for block in @blocks
875                 for block_part in block.parts
876                     for line in block_part.lines
877                         if (!line.fromLineNumber.nil? and !line.toLineNumber.nil?) then
878                             changes << [ [], [] ]
879                             next
880                         end
881                         changes.last.first << line if line.toLineNumber.nil?
882                         changes.last.last << line if line.fromLineNumber.nil?
883                     end
884                 end
885             end
886
887             for change in changes
888                 next unless change.first.length == change.last.length
889                 for i in (0...change.first.length)
890                     from_text = change.first[i].text
891                     to_text = change.last[i].text
892                     next if from_text.length > MAXIMUM_INTRALINE_DIFF_LINE_LENGTH or to_text.length > MAXIMUM_INTRALINE_DIFF_LINE_LENGTH
893                     raw_operations = HTMLDiff::DiffBuilder.new(from_text, to_text).operations
894                     operations = []
895                     back = 0
896                     raw_operations.each_with_index do |operation, j|
897                         if operation.action == :equal and j < raw_operations.length - 1
898                            length = operation.end_in_new - operation.start_in_new
899                            if length < SMALLEST_EQUAL_OPERATION
900                                back = length
901                                next
902                            end
903                         end
904                         operation.start_in_old -= back
905                         operation.start_in_new -= back
906                         back = 0
907                         operations << operation
908                     end
909                     change.first[i].operations = operations
910                     change.last[i].operations = operations
911                 end
912             end
913
914             @blocks.unshift(ContextLine.new(matches[5])) unless matches.nil? || matches[5].empty?
915         end
916
917         def to_html
918             str = "<div class='DiffSection'>\n"
919             str += @blocks.collect{ |block| block.to_html }.join
920             str += "</div>\n"
921         end
922
923         def self.parse(lines)
924             linesForSections = lines.inject([[]]) do |sections, line|
925                 sections << [] if line =~ /^@@/
926                 sections.last << line
927                 sections
928             end
929
930             linesForSections.delete_if { |lines| lines.nil? or lines.empty? }
931             linesForSections.collect { |lines| DiffSection.new(lines) }
932         end
933     end
934
935     class Line
936         attr_reader :fromLineNumber
937         attr_reader :toLineNumber
938         attr_reader :text
939
940         def initialize(from, to, text)
941             @fromLineNumber = from
942             @toLineNumber = to
943             @text = text
944         end
945
946         def text_as_html
947             CGI.escapeHTML(text)
948         end
949
950         def classes
951             lineClasses = ["Line", "LineContainer"]
952             lineClasses << ["add"] unless @toLineNumber.nil? or !@fromLineNumber.nil?
953             lineClasses << ["remove"] unless @fromLineNumber.nil? or !@toLineNumber.nil?
954             lineClasses
955         end
956
957         def to_html
958             markedUpText = self.text_as_html
959             str = "<div class='%s'>\n" % self.classes.join(' ')
960             str += "<span class='from lineNumber'>%s</span><span class='to lineNumber'>%s</span>" %
961                    [@fromLineNumber.nil? ? '&nbsp;' : @fromLineNumber,
962                     @toLineNumber.nil? ? '&nbsp;' : @toLineNumber] unless @fromLineNumber.nil? and @toLineNumber.nil?
963             str += "<span class='text'>%s</span>\n" % markedUpText
964             str += "</div>\n"
965         end
966     end
967
968     class CodeLine < Line
969         attr :operations, true
970
971         def text_as_html
972             html = []
973             tag = @fromLineNumber.nil? ? "ins" : "del"
974             if @operations.nil? or @operations.empty?
975                 return CGI.escapeHTML(@text)
976             end
977             @operations.each do |operation|
978                 start = @fromLineNumber.nil? ? operation.start_in_new : operation.start_in_old
979                 eend = @fromLineNumber.nil? ? operation.end_in_new : operation.end_in_old
980                 escaped_text = CGI.escapeHTML(@text[start...eend])
981                 if eend - start === 0 or operation.action === :equal
982                     html << escaped_text
983                 else
984                     html << "<#{tag}>#{escaped_text}</#{tag}>"
985                 end
986             end
987             html.join
988         end
989     end
990
991     class ContextLine < Line
992         def initialize(context)
993             super("@", "@", context)
994         end
995
996         def classes
997             super << "context"
998         end
999     end
1000 end