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