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