#!/usr/local/bin/ruby

# reviz is a CGI program which browse CVS repository like ViewCVS or cvsweb.

require 'reviz-conf.rb'

require 'cvs'
require 'cgi'
require 'tempfile'
require 'rsp'

class ReViz
  StickyParameter = {
    'cvsroot' => CVSROOT_LIST[0][0],
    'hideattic' => nil,
    'sortby' => nil,
    'logsort' => nil,
    'diff_format' => nil,
    'only_with_tag' => nil,
  }

  def initialize
    @cgi = CGI.new
  end

  def error(msg)
    @cgi.out("text/plain") {msg}
    exit 0
  end

  def init_cvsroot
    d = @cgi['cvsroot']
    if d.empty?
      cvsroot = CVSROOT_LIST[0][1]
    elsif (d = CVSROOT_LIST.assoc(d[0]))
      cvsroot = d[1]
    else
      error("unknown cvsroot")
    end
    @cvsroot = CVS.create(cvsroot, true)

    return unless @cgi.path_info

    path_info = @cgi.path_info
    path = []
    path_info.scan(/[^\/]+/) {|name|
      next if name == '.' || name == '..'
      path << name
    }

    @cvsdir = @cvsroot.top_dir
    @cvsfile = nil
    if path_info =~ /\/\z/
      path.each {|name| @cvsdir = @cvsdir.simple_dir(name)}
    else
      path[0..-2].each {|name| @cvsdir = @cvsdir.simple_dir(name)}
      @cvsfile = @cvsdir.simple_file(path[-1])
    end
  end

  def init_url
    @url_base = @cgi.script_name || 'reviz.cgi'
    @url_params = {}
    StickyParameter.each {|k,v|
      if @cgi.has_key? k
	@url_params[k] = @cgi[k][0]
      else
	@url_params[k] = v
      end
    }
  end

  def params(extra_params={})
    params = @url_params.dup
    if extra_params
      extra_params.each {|k,v|
	if v == nil
	  params.delete(k)
	else
	  params[k] = v
	end
      }
    end
    StickyParameter.each {|k,v|
      params.delete(k) if params[k] == v
    }
    return params
  end

  def url(path_info, extra_params={}, fragment=nil)
    ps = params(extra_params)

    result = @url_base.dup
    if path_info
      case path_info
      when CVS::D
        result << '/' << path_info.path << '/'
      when CVS::F
        result << '/' << path_info.path
      else
	result << '/' << path_info
      end
    end
    unless ps.empty?
      result << '?' << ps.keys.sort.collect {|k|
                         "#{k}=#{CGI::escape(ps[k])}"
		       }.join('&')
    end
    if fragment != nil
      result << '#' << fragment
    end
    return result
  end

  def only_with_tag
    if @cgi.has_key? 'only_with_tag'
      tag = @cgi['only_with_tag'][0]
      if /\A[A-Za-z][A-Za-z0-9\-_]*\z/ =~ tag
        return tag
      else
        return nil
      end
    else
      return nil
    end
  end

  def main
    errortrap {
      init_cvsroot
      init_url

      if @cvsfile
	if @cgi.has_key? 'rev'
	  view_checkout(CVS::Revision.create(@cgi['rev'][0]))
	elsif @cgi.has_key? 'annotate'
	  view_annotate(CVS::Revision.create(@cgi['annotate'][0]))
	elsif @cgi.has_key?('r1') && @cgi.has_key?('r2')
	  view_diff(CVS::Revision.create(@cgi['r1'][0]), CVS::Revision.create(@cgi['r2'][0]))
	else
	  view_log
	end
      elsif @cvsdir
	view_directory
      else
        list_repository
      end
    }
  end

  def errortrap
    @cgi_options = {'type' => 'text/html'}
    begin
      contents = yield
    rescue
      ex = $!
      @cgi.print @cgi.header('text/plain')
      @cgi.print ex.to_s, "\n"
      ex.backtrace.each {|s|
        @cgi.print s, "\n"
      }
      @cgi.print "\n"
      return
    end
    @cgi.print @cgi.header(@cgi_options)
    @cgi.print contents
  end

  def list_repository
    return RSP.load("#{RSP_DIR}/list_repositories.rsp").new(
      CVSROOT_LIST.collect {|name, *rest|
	RSP[
	  :name => name,
	  :url => url('', {'cvsroot'=>name})
	]}).gen
  end

  # RevFilter represents subset of revisions.
  class RevFilter
    def RevFilter.create(spec=nil)
      case spec
      when nil
	return All.new
      when String
        case spec
	when 'MAIN'
	  return MainTrunk.new
	when 'HEAD'
	  return Head.new
	end
	begin
	  rev = CVS::Revision.create(spec)
	  return Rev.new(rev)
	rescue CVS::Revision::RevisionError
	  return Tag.new(spec)
	end
      when Revision
	return Rev.new(spec)
      end
      raise RevFilterError.new("unrecognized spec: #{spec.inspect}")
    end
    class RevFilterError < StandardError
    end

    def initialize
      @branch = nil
      @tag = {}
      @revs = []
      @one = nil
    end
    attr_reader :revs, :one

    def head(rev)
      @head = rev
    end

    def branch(rev)
      @branch = rev
    end

    def symbol(sym, rev)
      @tag[sym] = rev
    end

    def revision(rev)
      update_revs(rev)
      update_one(rev)
    end

    def update_revs(rev)
      @revs << rev
    end

    def update_one(rev)
      @one = rev if !@one || @one < rev
    end

    def parse_log_opts_one
      return parse_log_opts_all
    end

    def parse_log_opts_all
      return []
    end

    def attic_sensitive?
      return false
    end

    class All < RevFilter
      def parse_log_opts_one
	return ['-r']
      end

      def parse_log_opts_all
	return []
      end

      def update_one(rev)
	super if @branch ? rev.on?(@branch) : rev.on_trunk?
      end

      def attic_sensitive?
	return true
      end
    end

    class MainTrunk < RevFilter
      def parse_log_opts_one
	return ['-r']
      end

      def parse_log_opts_all
	return []
      end

      def revision(rev)
	super if @branch ? rev.on?(@branch) : rev.on_trunk?
      end

      def attic_sensitive?
	return true
      end
    end

    class Head < RevFilter
      def parse_log_opts_all
	return ['-r']
      end

      def revision(rev)
	if @branch
	  if rev.on?(@branch)
	    if @revs.empty?
	      @revs << rev
	      @one = rev
	    else
	      @revs[0] = @one = rev if @one < rev
	    end
	  end
	else
	  if rev.on_trunk?
	    if @revs.empty?
	      @revs << rev
	      @one = rev
	    else
	      @revs[0] = @one = rev if @one < rev
	    end
	  end
	end
      end

      def attic_sensitive?
	return true
      end
    end

    class Tag < RevFilter
      def initialize(tag)
	super()
        @target_tag = tag
      end

      def symbol(sym, rev)
        super
	@target_rev = rev if sym == @target_tag
      end

      def revision(rev)
        if @target_rev
	  if @target_rev.branch?
	    super if rev.on?(@target_rev)
	  else
	    super if rev == @target_rev
	  end
	end
      end
    end

    class Rev < RevFilter
      def initialize(rev)
	super()
        @target_rev = rev
      end

      def parse_log_opts_one
	if @target_rev.branch?
	  return ["-r#{@target_rev.to_s}:."]
	else
	  return ["-r#{@target_rev.to_s}"]
	end
      end

      def parse_log_opts_all?(rev)
	return ["-r#{@target_rev.to_s}"]
      end

      def revision(rev)
	if @target_rev.branch?
	  super if rev.on?(@target_rev)
	else
	  super if rev == @target_rev
	end
      end
    end
  end

  def view_directory
    filter_gen = lambda { RevFilter.create(only_with_tag) }
    opts = filter_gen.call.parse_log_opts_one

    dirtags = {}
    files = []
    @cvsdir.parse_log(ViewDirectoryVisitor.new(self, @cvsdir, filter_gen, files, dirtags), opts)
    files.sort! {|a,b| a.name <=> b.name}
    vendor_tags = []
    branch_tags = []
    nonbranch_tags = []
    special_tags = []
    dirtags.each {|tag, types|
      types.compact!
      url = url(@cvsdir, {'only_with_tag'=>tag})
      if types.length == 1
        case types[0]
	when :vendor
	  vendor_tags << [tag, url]
	when :branch
	  branch_tags << [tag, url]
	when :nonbranch
	  nonbranch_tags << [tag, url]
	end
      else
	special_tags << [tag, url]
      end
    }
    vendor_tags.sort! {|(a,), (b,)| b <=> a}
    branch_tags.sort! {|(a,), (b,)| b <=> a}
    nonbranch_tags.sort! {|(a,), (b,)| b <=> a}
    special_tags.sort! {|(a,), (b,)| b <=> a}
    special_tags << ['HEAD', url(@cvsdir, {'only_with_tag'=>'HEAD'})]
    special_tags << ['MAIN', url(@cvsdir, {'only_with_tag'=>'MAIN'})]
    return RSP.load("#{RSP_DIR}/view_directory.rsp").new(
      RSP[
        :directory =>
	  @cvsdir.listdir.sort {|a,b| a.path <=> b.path}.collect {|subdir|
	    RSP[
	      :name => File.basename(subdir.path),
	      :path => subdir.path,
	      :url => url(subdir)]},
        :file => files,
	:vendor_tags => vendor_tags,
	:branch_tags => branch_tags,
	:nonbranch_tags => nonbranch_tags,
	:special_tags => special_tags,
	:params => params('only_with_tag'=>nil)
      ]).gen
  end
  class ViewDirectoryVisitor < CVS::Visitor
    def initialize(reviz, cvsdir, filter_gen, files, dirtags)
      @reviz = reviz
      @cvsdir = cvsdir
      @filter_gen = filter_gen
      @files = files
      @dirtags = dirtags
    end

    def rcsfile_splitted(dir, file, attic)
      @filter = @filter_gen.call
      @cvsfile = @cvsdir.file(file, attic)
      @target = nil
      @date = nil
      @author = nil
      @state = nil
      @message = nil
    end

    def head(rev)
      @filter.head(rev)
    end

    def branch(rev)
      @filter.branch(rev)
    end

    def symbol(sym, rev)
      @filter.symbol(sym, rev)
      @dirtags[sym] = [nil, nil, nil] unless @dirtags.include? sym
      if rev.vendor_branch?
        @dirtags[sym][0] = :vendor
      elsif rev.branch?
        @dirtags[sym][1] = :branch
      else
        @dirtags[sym][2] = :nonbranch
      end
    end

    def delta_without_next(rev, date, author, state, branches)
      @filter.revision(rev)
      if rev == @filter.one
	@target = rev
	@date = date
	@author = author
	@state = state
      end
    end

    def deltatext_log(rev, message)
      if rev == @filter.one
        @message = message
      end
    end

    def finished(buf)
      if @target
	@files << RSP[
	  :name => @cvsfile.name,
	  :path => @cvsfile.path,
	  :url => @reviz.url(@cvsfile),
	  :rev => @target,
	  :rev_url => @reviz.url(@cvsfile, {'rev' => @target.to_s}),
	  :date => @date,
	  :author => @author,
	  :state => @state,
	  :message => @message,
	  :removed => @state == 'dead' || @filter.attic_sensitive? && @cvsfile.attic
	]
      end
    end
  end

  def view_log
    logs = {}
    bs = {}
    @cvsfile.parse_log(ViewLogVisitor.new {|rev, *rest|
      logs[rev] = rsp_log(rev, *rest)
      b = rev.on_trunk? ? nil : rev.branch
      bs[b] = [] unless bs.include? b
      bs[b] << rev
    })
    prev_rev = {}
    bs.each {|b, rs|
      rs.sort!
      prev_rev[rs[0]] = b.origin if b
      r1 = rs[0]
      (1..rs.length).each {|i|
        prev_rev[rs[i]] = rs[i-1]
      }
    }
    logs.each {|rev, log|
      diff_revs = {}
      if prev_rev.include? rev
	r = prev_rev[rev]
	log.prev_rev = r.to_s
	diff_revs[r] = true
	log.diff_prev_rev = r.to_s
	log.diff_prev_url = url(@cvsfile, {'r1'=>r.to_s, 'r2'=>rev.to_s})
      end
      if !rev.on_trunk? && !rev.branch.vendor_branch?
	r = rev.origin
	unless diff_revs.include? r
	  diff_revs[r] = true
	  log.diff_branchpoint_rev = r.to_s
	  log.diff_branchpoint_url = url(@cvsfile, {'r1'=>r.to_s, 'r2'=>rev.to_s})
	end
      end
      if @cgi.has_key? 'r1'
        r = CVS::Revision.create(@cgi['r1'][0])
	unless diff_revs.include? r
	  diff_revs[r] = true
	  log.diff_selected_rev = r.to_s
	  log.diff_selected_url = url(@cvsfile, {'r1'=>r.to_s, 'r2'=>rev.to_s})
	end
      end
    }
    revs = logs.keys
    revs.sort!
    revs.reverse!
    return RSP.load("#{RSP_DIR}/view_log.rsp").new(
      RSP[
        :logs => revs.collect {|rev| logs[rev]}
      ]).gen
  end
  class ViewLogVisitor < CVS::Visitor
    def initialize(&block)
      @block = block
      @rev2sym = {}
      @rev2sym.default = [].freeze
    end
    def symbol(sym, rev)
      unless @rev2sym.has_key? rev
        @rev2sym[rev] = []
      end
      @rev2sym[rev] << sym
    end
    def delta_rlog(rev, locked_by, date, author, state,
                   add, del, branches, message)
      #p [rev, locked_by, date, author, state, add, del, branches, message]
      @block.call(rev, locked_by, date, author, state,
                  add, del, branches, message, @rev2sym[rev])
    end
  end

  def rsp_log(rev, locked_by, date, author, state,
	  add, del, branches, message, tags)
    RSP[
      :rev => rev.to_s,
      :tags => tags,
      :tagged_urls => Hash[*tags.collect {|tag| [tag, url(@cvsfile, {'only_with_tag'=>tag})]}.flatten],
      :checkout_url => url(@cvsfile, {'rev'=>rev.to_s}),
      :annotate_url => url(@cvsfile, {'annotate'=>rev.to_s}),
      :date => date,
      :author => author,
      :message => message
    ]
  end

  def view_checkout(rev)
    log = nil
    @cvsfile.parse_log(ViewLogVisitor.new {|args| log = rsp_log(*args)},
                       ["-r#{rev.to_s}"])
    contents, attributes = @cvsfile.checkout(rev) {|c, a| [c, a]}
    return RSP.load("#{RSP_DIR}/view_checkout.rsp").new(
      RSP[
	:log => log,
	:contents => contents,
	:attributes => attributes.inspect
      ]).gen

  end

  def view_annotate(rev)
    lines = []
    rev1len = 0
    rev2len = 0
    authorlen = 0
    @cvsfile.fullannotate(rev) {|contents, date1, rev1, author, rev2, date2|
      h = {:contents => contents,
	   :date1 => date1,
	   :rev1 => rev1,
	   :author => author,
	   :rev2 => rev2
      }
      h[:date2] = date2 ? date2 : nil
      h[:nonewline] = /\n\z/ !~ contents
      lines << RSP[h]
      if rev1len < (l = rev1.to_s.length)
	rev1len = l
      end
      if rev2len < (l = rev2.to_s.length)
	rev2len = l
      end
      if authorlen < (l = author.length)
	authorlen = l
      end
    }
    return RSP.load("#{RSP_DIR}/view_annotate.rsp").new(
      RSP[
        :rev1len => rev1len,
        :rev2len => rev2len,
        :authorlen => authorlen,
	:lines => lines
      ]).gen
  end

  def view_diff(r1, r2)
    c1, a1 = @cvsfile.checkout(r1) {|c, a| [c, a]}
    f1 = Tempfile.new('ruby-cvs-diff1')
    f1.print c1
    f1.close

    c2, a2 = @cvsfile.checkout(r2) {|c, a| [c, a]}
    f2 = Tempfile.new('ruby-cvs-diff2')
    f2.print c2
    f2.close

    diff = []
    orig = CVS::RCSText.split(c1)
    orig.unshift nil
    orig_beg = 1
    IO.popen("diff -n #{f1.path} #{f2.path}") {|f|
      CVS::RCSText.parse_diff(f) {|com, beg, len, adds|
	if com == :del
	  if orig_beg < beg
	    diff << [:common, orig[orig_beg...beg]]
	  end
	  diff << [:del, orig[beg, len]]
	  orig_beg = beg + len
	else
	  if orig_beg <= beg
	    diff << [:common, orig[orig_beg..beg]]
	    orig_beg = beg + 1
	  end
	  diff << [:add, adds]
	end
      }
    }
    diff << [:common, orig[orig_beg..-1]] if orig_beg < orig.length

    hunks = []
    split_diff(diff) {|line1_beg, line1_len, line2_beg, line2_len, hunk|
      hunks << [line1_beg, line1_len, line2_beg, line2_len, hunk]
    }

    return RSP.load("#{RSP_DIR}/view_diff.rsp").new(
      RSP[
	:path => @cvsfile.path,
	:rev1 => r1.to_s,
	:rev2 => r2.to_s,
	:mtime1 => a1.mtime,
	:mtime2 => a2.mtime,
	:hunks => hunks
      ]).gen
  end

  def split_diff(diff, context=3)
    return if diff.empty?

    t, lines = diff[0]
    if t == :common
      b1 = 1
      line1_beg = line2_beg = lines.length + 1
    else
      b1 = 0
      line1_beg = line2_beg = 1
    end

    while b1 < diff.length
      i = b1 + 1
      while i < diff.length
	t, lines = diff[i]
	if t == :common && context * 2 < lines.length
	  break
	end
	i += 1
      end
      b2 = i - 1

      line1_end = line1_beg
      line2_end = line2_beg
      hunk = []
      if 0 < b1
	t, lines = diff[b1 - 1]
	lines = lines[-context..-1] if context < lines.length
	hunk.concat lines.collect {|line| [t, line]}
	line1_beg -= lines.length
	line2_beg -= lines.length
      end
      (b1..b2).each {|i|
	t, lines = diff[i]
	hunk.concat lines.collect {|line| [t, line]}
	if t == :add
	  line2_end += lines.length
	elsif t == :del
	  line1_end += lines.length
	else
	  line1_end += lines.length
	  line2_end += lines.length
	end
      }
      if b2 + 1 < diff.length
	t, lines = diff[b2 + 1]
	line1_next = line1_end + lines.length
	line2_next = line2_end + lines.length
	lines = lines[0...context] if context < lines.length
	hunk.concat lines.collect {|line| [t, line]}
	line1_end += lines.length
	line2_end += lines.length
      end

      yield line1_beg, line1_end - line1_beg, line2_beg, line2_end - line2_beg, hunk

      b1 = b2 + 2
      line1_beg = line1_next
      line2_beg = line2_next
    end
  end

end

ReViz.new.main
