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