lib/diff/lcs/ldiff.rb



# frozen_string_literal: true

require "optparse"
require "diff/lcs/hunk"

module Diff::LCS::Ldiff # :nodoc:
  # standard:disable Layout/HeredocIndentation
  BANNER = <<-COPYRIGHT
ldiff #{Diff::LCS::VERSION}
  Copyright 2004-2019 Austin Ziegler

  Part of Diff::LCS.
  https://github.com/halostatue/diff-lcs

  This program is free software. It may be redistributed and/or modified under
  the terms of the GPL version 2 (or later), the Perl Artistic licence, or the
  MIT licence.
  COPYRIGHT
  # standard:enable Layout/HeredocIndentation

  InputInfo = Struct.new(:filename, :data, :stat) do
    def initialize(filename)
      super(filename, ::File.read(filename), ::File.stat(filename))
    end
  end

  class << self
    attr_reader :format, :lines # :nodoc:
    attr_reader :file_old, :file_new # :nodoc:
    attr_reader :data_old, :data_new # :nodoc:
  end

  def self.run(args, _input = $stdin, output = $stdout, error = $stderr) # :nodoc:
    @binary = nil

    args.options do |o|
      o.banner = "Usage: #{File.basename($0)} [options] oldfile newfile"
      o.separator ""
      o.on(
        "-c", "-C", "--context [LINES]", Integer,
        "Displays a context diff with LINES lines", "of context. Default 3 lines."
      ) do |ctx|
        @format = :context
        @lines = ctx || 3
      end
      o.on(
        "-u", "-U", "--unified [LINES]", Integer,
        "Displays a unified diff with LINES lines", "of context. Default 3 lines."
      ) do |ctx|
        @format = :unified
        @lines = ctx || 3
      end
      o.on("-e", "Creates an 'ed' script to change", "oldfile to newfile.") do |_ctx|
        @format = :ed
      end
      o.on("-f", "Creates an 'ed' script to change", "oldfile to newfile in reverse order.") do |_ctx|
        @format = :reverse_ed
      end
      o.on(
        "-a", "--text",
        "Treat the files as text and compare them", "line-by-line, even if they do not seem", "to be text."
      ) do |_txt|
        @binary = false
      end
      o.on("--binary", "Treats the files as binary.") do |_bin|
        @binary = true
      end
      o.on("-q", "--brief", "Report only whether or not the files", "differ, not the details.") do |_ctx|
        @format = :report
      end
      o.on_tail("--help", "Shows this text.") do
        error << o
        return 0
      end
      o.on_tail("--version", "Shows the version of Diff::LCS.") do
        error << Diff::LCS::Ldiff::BANNER
        return 0
      end
      o.on_tail ""
      o.on_tail 'By default, runs produces an "old-style" diff, with output like UNIX diff.'
      o.parse!
    end

    unless args.size == 2
      error << args.options
      return 127
    end

    # Defaults are for old-style diff
    @format ||= :old
    @lines ||= 0

    file_old, file_new = *ARGV
    diff?(
      InputInfo.new(file_old),
      InputInfo.new(file_new),
      @format,
      output,
      binary: @binary,
      lines: @lines
    ) ? 1 : 0
  end

  def self.diff?(info_old, info_new, format, output, binary: nil, lines: 0)
    case format
    when :context
      char_old = "*" * 3
      char_new = "-" * 3
    when :unified
      char_old = "-" * 3
      char_new = "+" * 3
    end

    # After we've read up to a certain point in each file, the number of
    # items we've read from each file will differ by FLD (could be 0).
    file_length_difference = 0

    # Test binary status
    if binary.nil?
      old_bin = info_old.data[0, 4096].include?("\0")
      new_bin = info_new.data[0, 4096].include?("\0")
      binary = old_bin || new_bin
    end

    # diff yields lots of pieces, each of which is basically a Block object
    if binary
      has_diffs = (info_old.data != info_new.data)
      if format != :report
        if has_diffs
          output << "Binary files #{info_old.filename} and #{info_new.filename} differ\n"
          return true
        end
        return false
      end
    else
      data_old = info_old.data.lines.to_a
      data_new = info_new.data.lines.to_a
      diffs = Diff::LCS.diff(data_old, data_new)
      return false if diffs.empty?
    end

    case format
    when :report
      output << "Files #{info_old.filename} and #{info_new.filename} differ\n"
      return true
    when :unified, :context
      ft = info_old.stat.mtime.localtime.strftime("%Y-%m-%d %H:%M:%S.000000000 %z")
      output << "#{char_old} #{info_old.filename}\t#{ft}\n"
      ft = info_new.stat.mtime.localtime.strftime("%Y-%m-%d %H:%M:%S.000000000 %z")
      output << "#{char_new} #{info_new.filename}\t#{ft}\n"
    when :ed
      real_output = output
      output = []
    end

    # Loop over hunks. If a hunk overlaps with the last hunk, join them.
    # Otherwise, print out the old one.
    oldhunk = hunk = nil
    diffs.each do |piece|
      begin
        hunk = Diff::LCS::Hunk.new(data_old, data_new, piece, lines, file_length_difference)
        file_length_difference = hunk.file_length_difference

        next unless oldhunk
        next if lines.positive? && hunk.merge(oldhunk)

        output << oldhunk.diff(format)
        output << "\n" if format == :unified
      ensure
        oldhunk = hunk
      end
    end

    last = oldhunk.diff(format, true)
    last << "\n" unless last.is_a?(Diff::LCS::Hunk) || last.empty? || last.end_with?("\n")

    output << last

    output.reverse_each { |e| real_output << e.diff(:ed_finish, e == output[0]) } if format == :ed

    true
  end
end