lib/diffy/diff.rb
module Diffy class Diff ORIGINAL_DEFAULT_OPTIONS = { :diff => '-U 10000', :source => 'strings', :include_diff_info => false, :include_plus_and_minus_in_html => false, :context => nil, :allow_empty_diff => true, } class << self attr_writer :default_format def default_format @default_format || :text end # default options passed to new Diff objects attr_writer :default_options def default_options @default_options ||= ORIGINAL_DEFAULT_OPTIONS.dup end end include Enumerable attr_reader :string1, :string2, :options, :diff # supported options # +:diff+:: A cli options string passed to diff # +:source+:: Either _strings_ or _files_. Determines whether string1 # and string2 should be interpreted as strings or file paths. # +:include_diff_info+:: Include diff header info # +:include_plus_and_minus_in_html+:: Show the +, -, ' ' at the # beginning of lines in html output. def initialize(string1, string2, options = {}) @options = self.class.default_options.merge(options) if ! ['strings', 'files'].include?(@options[:source]) raise ArgumentError, "Invalid :source option #{@options[:source].inspect}. Supported options are 'strings' and 'files'." end @string1, @string2 = string1, string2 end def diff @diff ||= begin paths = case options[:source] when 'strings' [tempfile(string1), tempfile(string2)] when 'files' [string1, string2] end if WINDOWS # don't use open3 on windows cmd = sprintf '"%s" %s %s', diff_bin, diff_options.join(' '), paths.map { |s| %("#{s}") }.join(' ') diff = `#{cmd}` else diff = Open3.popen3(diff_bin, *(diff_options + paths)) { |i, o, e| o.read } end diff.force_encoding('ASCII-8BIT') if diff.respond_to?(:valid_encoding?) && !diff.valid_encoding? if diff =~ /\A\s*\Z/ && !options[:allow_empty_diff] diff = case options[:source] when 'strings' then string1 when 'files' then File.read(string1) end.gsub(/^/, " ") end diff end ensure # unlink the tempfiles explicitly now that the diff is generated Array(@tempfiles).each do |t| begin # check that the path is not nil and file still exists. # REE seems to be very agressive with when it magically removes # tempfiles t.unlink if t.path && File.exist?(t.path) rescue => e warn "#{e.class}: #{e}" warn e.backtrace.join("\n") end end end def each lines = case @options[:include_diff_info] when false then diff.split("\n").reject{|x| x =~ /^(---|\+\+\+|@@|\\\\)/ }.map {|line| line + "\n" } when true then diff.split("\n").map {|line| line + "\n" } end if block_given? lines.each{|line| yield line} else lines.to_enum end end def each_chunk old_state = nil chunks = inject([]) do |cc, line| state = line.each_char.first if state == old_state cc.last << line else cc.push line.dup end old_state = state cc end if block_given? chunks.each{|chunk| yield chunk } else chunks.to_enum end end def tempfile(string) t = Tempfile.new('diffy') # ensure tempfiles aren't unlinked when GC runs by maintaining a # reference to them. @tempfiles ||=[] @tempfiles.push(t) t.print(string) t.flush t.close t.path end def to_s(format = nil) format ||= self.class.default_format formats = Format.instance_methods(false).map{|x| x.to_s} if formats.include? format.to_s enum = self enum.extend Format enum.send format else raise ArgumentError, "Format #{format.inspect} not found in #{formats.inspect}" end end private @@bin = nil def diff_bin return @@bin if @@bin if @@bin = ENV['DIFFY_DIFF'] # system() trick from Minitest raise "Can't execute diff program '#@@bin'" unless system(@@bin, __FILE__, __FILE__) return @@bin end diffs = ['diff', 'ldiff'] diffs.first << '.exe' if WINDOWS # ldiff does not have exe extension @@bin = diffs.find { |name| system(name, __FILE__, __FILE__) } if @@bin.nil? raise "Can't find a diff executable in PATH #{ENV['PATH']}" end @@bin end # options pass to diff program def diff_options Array(options[:context] ? "-U #{options[:context]}" : options[:diff]) end end end