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