4652551ca41d3ac76f909e726e10022ab7311e68
[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 }
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_LITERAL_FORMAT = /^literal \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_LITERAL_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                 str += "<span class='text'>Binary file, nothing to see here</span>"
589             else
590                 str += @sections.collect{ |section| section.to_html }.join("<br>\n") unless @sections.nil?
591             end
592
593             if @from then
594                 str += "<span class='revision'>" + @from + "</span>"
595             end
596
597             str += "</div>\n"
598         end
599
600         def self.parse(string)
601             haveSeenDiffHeader = false
602             linesForDiffs = []
603             string.each_line do |line|
604                 if (PrettyPatch.diff_header?(line))
605                     linesForDiffs << []
606                     haveSeenDiffHeader = true
607                 elsif (!haveSeenDiffHeader && line =~ /^--- /)
608                     linesForDiffs << []
609                     haveSeenDiffHeader = false
610                 end
611                 linesForDiffs.last << line unless linesForDiffs.last.nil?
612             end
613
614             linesForDiffs.collect { |lines| FileDiff.new(lines) }
615         end
616
617         def self.read_checksum_from_png(png_bytes)
618             match = png_bytes && png_bytes.match(/tEXtchecksum\0([a-fA-F0-9]{32})/)
619             match ? match[1] : nil
620         end
621
622         def self.git_new_file_binary_patch(filename, encoded_chunk, git_index)
623             return <<END
624 diff --git a/#{filename} b/#{filename}
625 new file mode 100644
626 index 0000000000000000000000000000000000000000..#{git_index}
627 GIT binary patch
628 #{encoded_chunk.join("")}literal 0
629 HcmV?d00001
630
631 END
632         end
633
634         def self.extract_contents_from_git_binary_chunk(encoded_chunk, git_index)
635             # We use Tempfile we need a unique file among processes.
636             tempfile = Tempfile.new("PrettyPatch")
637             # We need a filename which doesn't exist to apply a patch
638             # which creates a new file. Append a suffix so filename
639             # doesn't exist.
640             filepath = tempfile.path + '.bin'
641             filename = File.basename(filepath)
642
643             patch = FileDiff.git_new_file_binary_patch(filename, encoded_chunk, git_index)
644
645             # Apply the git binary patch using git-apply.
646             cmd = GIT_PATH + " apply --directory=" + File.dirname(filepath)
647             stdin, stdout, stderr = *Open3.popen3(cmd)
648             begin
649                 stdin.puts(patch)
650                 stdin.close
651
652                 error = stderr.read
653                 raise error if error != ""
654
655                 contents = File.read(filepath)
656             ensure
657                 stdin.close unless stdin.closed?
658                 stdout.close
659                 stderr.close
660                 File.unlink(filename) if File.exists?(filename)
661             end
662
663             return nil if contents.empty?
664             return contents
665         end
666     end
667
668     class DiffBlock
669         attr_accessor :parts
670
671         def initialize(container)
672             @parts = []
673             container << self
674         end
675
676         def to_html
677             str = "<div class='DiffBlock'>\n"
678             str += @parts.collect{ |part| part.to_html }.join
679             str += "<div class='clear_float'></div></div>\n"
680         end
681     end
682
683     class DiffBlockPart
684         attr_reader :className
685         attr :lines
686
687         def initialize(className, container)
688             $last_prettify_part_count[className] += 1
689             @className = className
690             @lines = []
691             container.parts << self
692         end
693
694         def to_html
695             str = "<div class='DiffBlockPart %s'>\n" % @className
696             str += @lines.collect{ |line| line.to_html }.join
697             # Don't put white-space after this so adjacent inline-block DiffBlockParts will not wrap.
698             str += "</div>"
699         end
700     end
701
702     class DiffSection
703         def initialize(lines)
704             lines.length >= 1 or raise "DiffSection.parse only received %d lines" % lines.length
705
706             matches = START_OF_SECTION_FORMAT.match(lines[0])
707
708             if matches
709                 from, to = [matches[1].to_i, matches[3].to_i]
710                 if matches[2] and matches[4]
711                     from_end = from + matches[2].to_i
712                     to_end = to + matches[4].to_i
713                 end
714             end
715
716             @blocks = []
717             diff_block = nil
718             diff_block_part = nil
719
720             for line in lines[1...lines.length]
721                 startOfLine = line =~ /^[-\+ ]/ ? 1 : 0
722                 text = line[startOfLine...line.length].chomp
723                 case line[0]
724                 when ?-
725                     if (diff_block_part.nil? or diff_block_part.className != 'remove')
726                         diff_block = DiffBlock.new(@blocks)
727                         diff_block_part = DiffBlockPart.new('remove', diff_block)
728                     end
729
730                     diff_block_part.lines << CodeLine.new(from, nil, text)
731                     from += 1 unless from.nil?
732                 when ?+
733                     if (diff_block_part.nil? or diff_block_part.className != 'add')
734                         # Put add lines that immediately follow remove lines into the same DiffBlock.
735                         if (diff_block.nil? or diff_block_part.className != 'remove')
736                             diff_block = DiffBlock.new(@blocks)
737                         end
738
739                         diff_block_part = DiffBlockPart.new('add', diff_block)
740                     end
741
742                     diff_block_part.lines << CodeLine.new(nil, to, text)
743                     to += 1 unless to.nil?
744                 else
745                     if (diff_block_part.nil? or diff_block_part.className != 'shared')
746                         diff_block = DiffBlock.new(@blocks)
747                         diff_block_part = DiffBlockPart.new('shared', diff_block)
748                     end
749
750                     diff_block_part.lines << CodeLine.new(from, to, text)
751                     from += 1 unless from.nil?
752                     to += 1 unless to.nil?
753                 end
754
755                 break if from_end and to_end and from == from_end and to == to_end
756             end
757
758             changes = [ [ [], [] ] ]
759             for block in @blocks
760                 for block_part in block.parts
761                     for line in block_part.lines
762                         if (!line.fromLineNumber.nil? and !line.toLineNumber.nil?) then
763                             changes << [ [], [] ]
764                             next
765                         end
766                         changes.last.first << line if line.toLineNumber.nil?
767                         changes.last.last << line if line.fromLineNumber.nil?
768                     end
769                 end
770             end
771
772             for change in changes
773                 next unless change.first.length == change.last.length
774                 for i in (0...change.first.length)
775                     from_text = change.first[i].text
776                     to_text = change.last[i].text
777                     next if from_text.length > MAXIMUM_INTRALINE_DIFF_LINE_LENGTH or to_text.length > MAXIMUM_INTRALINE_DIFF_LINE_LENGTH
778                     raw_operations = HTMLDiff::DiffBuilder.new(from_text, to_text).operations
779                     operations = []
780                     back = 0
781                     raw_operations.each_with_index do |operation, j|
782                         if operation.action == :equal and j < raw_operations.length - 1
783                            length = operation.end_in_new - operation.start_in_new
784                            if length < SMALLEST_EQUAL_OPERATION
785                                back = length
786                                next
787                            end
788                         end
789                         operation.start_in_old -= back
790                         operation.start_in_new -= back
791                         back = 0
792                         operations << operation
793                     end
794                     change.first[i].operations = operations
795                     change.last[i].operations = operations
796                 end
797             end
798
799             @blocks.unshift(ContextLine.new(matches[5])) unless matches.nil? || matches[5].empty?
800         end
801
802         def to_html
803             str = "<div class='DiffSection'>\n"
804             str += @blocks.collect{ |block| block.to_html }.join
805             str += "</div>\n"
806         end
807         
808         def self.parse(lines)
809             linesForSections = lines.inject([[]]) do |sections, line|
810                 sections << [] if line =~ /^@@/
811                 sections.last << line
812                 sections
813             end
814
815             linesForSections.delete_if { |lines| lines.nil? or lines.empty? }
816             linesForSections.collect { |lines| DiffSection.new(lines) }
817         end
818     end
819
820     class Line
821         attr_reader :fromLineNumber
822         attr_reader :toLineNumber
823         attr_reader :text
824
825         def initialize(from, to, text)
826             @fromLineNumber = from
827             @toLineNumber = to
828             @text = text
829         end
830
831         def text_as_html
832             CGI.escapeHTML(text)
833         end
834
835         def classes
836             lineClasses = ["Line", "LineContainer"]
837             lineClasses << ["add"] unless @toLineNumber.nil? or !@fromLineNumber.nil?
838             lineClasses << ["remove"] unless @fromLineNumber.nil? or !@toLineNumber.nil?
839             lineClasses
840         end
841
842         def to_html
843             markedUpText = self.text_as_html
844             str = "<div class='%s'>\n" % self.classes.join(' ')
845             str += "<span class='from lineNumber'>%s</span><span class='to lineNumber'>%s</span>" %
846                    [@fromLineNumber.nil? ? '&nbsp;' : @fromLineNumber,
847                     @toLineNumber.nil? ? '&nbsp;' : @toLineNumber] unless @fromLineNumber.nil? and @toLineNumber.nil?
848             str += "<span class='text'>%s</span>\n" % markedUpText
849             str += "</div>\n"
850         end
851     end
852
853     class CodeLine < Line
854         attr :operations, true
855
856         def text_as_html
857             html = []
858             tag = @fromLineNumber.nil? ? "ins" : "del"
859             if @operations.nil? or @operations.empty?
860                 return CGI.escapeHTML(@text)
861             end
862             @operations.each do |operation|
863                 start = @fromLineNumber.nil? ? operation.start_in_new : operation.start_in_old
864                 eend = @fromLineNumber.nil? ? operation.end_in_new : operation.end_in_old
865                 escaped_text = CGI.escapeHTML(@text[start...eend])
866                 if eend - start === 0 or operation.action === :equal
867                     html << escaped_text
868                 else
869                     html << "<#{tag}>#{escaped_text}</#{tag}>"
870                 end
871             end
872             html.join
873         end
874     end
875
876     class ContextLine < Line
877         def initialize(context)
878             super("@", "@", context)
879         end
880
881         def classes
882             super << "context"
883         end
884     end
885 end