=begin -*-mode: ruby-*-
  FileProcessor.rb

  Copyright (c) 2002, Alan Eldridge

  All rights reserved.

  Redistribution and use in source and binary forms, with or without
  modification, are permitted provided that the following conditions 
  are met:

  * Redistributions of source code must retain the above copyright
  notice, this list of conditions and the following disclaimer.

  * Redistributions in binary form must reproduce the above copyright
  notice, this list of conditions and the following disclaimer in the
  documentation and/or other materials provided with the distribution.

  * Neither the name of the copyright owner nor the names of its
  contributors may be used to endorse or promote products derived
  from this software without specific prior written permission.

  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
  AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
  IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
  ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
  CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
  SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
  INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
  POSSIBILITY OF SUCH DAMAGE.


  2002/12/02 alane@geeksrus.net

=end

require "sytab"

######################################################################
# Message from FileProcessor
class ParseMsg < StandardError

  ######################################################################
  # [lvl]	severity level (string)
  # [msg]	message describing error
  # [trace]	array (stack) of [file,line] showing input location
  def initialize(lvl, msg, trace)
    @lvl = lvl
    @msg = msg
    @trace = trace
  end # initialize(lvl, msg, trace)

  ######################################################################
  # produce printable message, parsable by most editors
  def message()
    to_s
  end # message()

  ######################################################################
  # produce printable message, parsable by most editors
  def to_s()
    msg = ""
    if (start = @trace.size - 2) >= 0
      start.downto(0) {
	|i| msg += "\tIn file included from: #{@trace[i].join(":")}\n"
      }
    end
    msg += "#{@lvl}: #{@trace.last.join(":")}: #{@msg}.\n"
  end # to_s()
end # ParseMsg < StandardError


######################################################################
# Error message
class ParseError < ParseMsg

  ######################################################################
  # [msg]	message describing error
  # [trace]	array (stack) of [file,line] showing input location
  def initialize(msg, trace)
    super("ERROR", msg, trace)
  end
end # ParseError < ParseMsg


######################################################################
# Warning message
class ParseWarning < ParseMsg

  ######################################################################
  # [msg]	message describing warning
  # [trace]	array (stack) of [file,line] showing input location
  def initialize(msg, trace)
    super("WARNING", msg, trace)
  end
end # ParseWarning < ParseMsg


######################################################################
# Evaluate a conditional expression
class Cond

  ######################################################################
  # [s]		string to evaluate
  # [sytab]	the symbol table context
  def initialize(s, sytab)
    @s = s
    @sytab = sytab
  end

  ######################################################################
  # Provided so expressions can contain 'defined("FOO")' tests
  # [sy]	name of symbol to check symbol table for
  def defined(sy)
    @sytab.key?(sy)
  end # defined(sy)

  ######################################################################
  # Provided so expressions can contain 'exists("FOO")' tests
  # [fn]	name of file to check for existence
  def exists(fn)
    File::exists?(fn)
  end # defined(sy)

  ######################################################################
  # Replace symbols with values and pass to Ruby for evaluation;
  # Result is interpreted as true/false.
  def value()
    eval(@sytab << @s)
  end # value()
end # class Cond


######################################################################
# Transform a template file into a new file by processing the %-directives
# and replacing symbol references <?FOO> with values.
#
# Supported directives: %#, %if, %elif, %else, %endif, %define, %undef,
# and %include.
class FileProcessor

  public

  MaxErrors = 19

  ######################################################################
  # String that introduces a preprocessor token
  DefMetaStr =	"%"

  ######################################################################
  # Delimiters surrounding variables to be substituted
  VarDelims =	"<: >"

  private

  ######################################################################
  # Single-bit values for preprocessor tokens
  Comment =	0
  IfTok =	1
  ElifTok =	1 << 1
  ElseTok =	1 << 2
  EndifTok =	1 << 3
  EofTok =	1 << 4
  CondBegin =	IfTok
  EOF =		EofTok
  DefTok =	1 << 8
  UndefTok =	1 << 9
  InclTok =	1 << 10
  LicenseTok =  1 << 11
  MetaStrTok =	1 << 12
  MetaPushTok =	1 << 13
  MetaPopTok =	1 << 14
  NonCond =	DefTok | UndefTok | InclTok | MetaStrTok | EOF

  ######################################################################
  # Map from token text to bit values
  TOKENS = {
    "#" =>	Comment,
    "if" =>	IfTok,
    "elif" =>	ElifTok,
    "else" =>	ElseTok,
    "endif" =>	EndifTok,
    "end" =>	EofTok,
    "define" =>	DefTok,
    "undef" =>	UndefTok,
    "include" =>InclTok,
    "license" =>LicenseTok,
    "metastr" => MetaStrTok,
    "metapush" => MetaPushTok,
    "metapop" => MetaPopTok,
  }

  public 

  ######################################################################
  # Setup symbol table, conditional evaluation stack, and map of token
  # bit values to handler Procs
  def initialize()
    @abend = false
    @goteof = false
    @sytab = SyTab.new(VarDelims)
    @allowed = Array.new
    @allowed.push(NonCond | CondBegin)
    @condstk = Array.new
    @condstk.push(true)
    @filestk = Array.new
    @filestk.push(nil)
    @metastk = Array.new
    @metastk.push(DefMetaStr)
    @metastr = @metastk.last
    @metalen = @metastk.last.size
    @outtext = Array.new
    @errlist = Array.new
    @fileFinder = nil
    @linebuf = "" 
    # Map from token bit values to Proc objects which invoke the handler
    # in the context of the current object.
    @handlers = {
      Comment =>	proc { |ln| true },
      IfTok =>		proc { |ln| do_if(ln) },
      ElifTok =>	proc { |ln| do_elif(ln) },
      ElseTok =>	proc { |ln| do_else(ln) },
      EndifTok =>	proc { |ln| do_endif(ln) },
      EofTok =>		proc { |ln| do_eof(ln) if @condstk.last },
      DefTok =>		proc { |ln| do_define(ln) if @condstk.last },
      UndefTok =>	proc { |ln| do_undef(ln) if @condstk.last },
      InclTok =>	proc { |ln| do_include(ln) if @condstk.last },
      LicenseTok=>	proc { |ln| do_license(ln) if @condstk.last },
      MetaStrTok =>	proc { |ln| do_metastr(ln) if @condstk.last },
      MetaPushTok =>	proc { |ln| do_metapush(ln) if @condstk.last },
      MetaPopTok =>	proc { |ln| do_metapop(ln) if @condstk.last },
    }
  end # initialize()

  attr_reader :errlist, :outtext
  attr_accessor :sytab, :fileFinder

  private

  ######################################################################
  # Report an error
  # [msg]	Text describing error condition
  def error(msg)
    trace = []
    @filestk.each {
      |io|
      trace.push(io ? [ io.path, io.lineno ] : [ "<<start>>", 0 ])
    }
    @errlist.push(ParseError.new(msg, trace))
    if @errlist.size > MaxErrors
      @abend = true
      @errlist.push(ParseError.new("Bailing out: too many errors", trace))
    end
  end # error(msg)

  ######################################################################
  # Process %if
  # [ln]	Remainder of line
  def do_if(ln)
    @condstk.push(@condstk.last && Cond.new(ln, @sytab).value)
    @allowed.push(NonCond | CondBegin | ElifTok | ElseTok | EndifTok)
  end # do_if(ln)

  ######################################################################
  # Process %else
  # [ln]	Remainder of line (ignored)
  def do_else(ln)
    @condstk[-1] = !@condstk[-1]
    @condstk[-1] &&= @condstk[-2]
    @allowed[-1] = NonCond | CondBegin | EndifTok
  end # do_else(ln)

  ######################################################################
  # Process %elif
  # [ln]	Remainder of line
  def do_elif(ln)
    do_else(ln)
    do_if(ln)
    @condstk[-1] &&= @condstk[-2]
    @condstk.delete_at(-2)
    @allowed.delete_at(-2)
  end # do_elif(ln)

  ######################################################################
  # Process %endif
  # [ln]	Remainder of line (ignored)
  def do_endif(ln)
    @condstk.pop
    @allowed.pop
  end # do_endif(ln)

  ######################################################################
  # Process %end
  # [ln]	Remainder of line (ignored)
  def do_eof(ln)
    @goteof = true
  end # do_eof(ln)

  ######################################################################
  # Process %undef
  # [ln]	Remainder of line (all but first word ignored)
  def do_undef(ln)
    @sytab.delete(first_word(ln))
  end # do_undef(ln)

  ######################################################################
  # Remove quotes from string
  # [s]		String to be unquoted
  def unquote(s)
    if /^\\["']/ =~ s
      s = s[1..-1]
    elsif /^(["']).*\1$/ =~ s
      s = s[1..-2]
    end
    s
  end # unquote(s)

  ######################################################################
  # Define/redefine a variable
  # [ln]	
  #	Remainder of line: first word is the variable name;
  #	rest, without leading or trailing spaces, is value. One
  #	layer of quotes is removed unless preceded by \\.
  def do_define(ln)
    args = ln.strip.split(%r<\s+>,2)
    @sytab[ args[0] ] = unquote(args[1])
  end # do_define(ln)

  ######################################################################
  # Find a file specified to %include in search path; returns full path
  # to file or nil.
  # [fn]	Name of file to find
  def findfile(fn)
    if fn == nil || fn == ""
      "/dev/null"
    elsif fn[0] == ?/
      File.file?(fn) ? fn : nil
    else
      @fileFinder ? @fileFinder.findFile(fn) : nil
    end
  end # findfile(fn)

  ######################################################################
  # Process %include
  # [ln]	
  #	Remainder of line, used as filename to include; may be
  #	enclosed in \<\> brackets, as in C.
  def do_include(ln)
    ln = ln.strip
    ln = ln[1..-2] if /^<.*>$/ =~ ln
    ln = '"' + ln + '"' if ln !~ /^["<'"]/
    ln = Cond.new(ln, @sytab).value()
    fn = findfile(ln)
    if (fn)
      readfile(fn) 
    else 
      error("can't find include file: \"#{ln}\"")
    end
  end # do_include(ln)

  ######################################################################
  # Process %license
  # [ln]	
  #	Remainder of line, ignored.
  def do_license(ln)
    fn = @sytab["LICENSE"] + "@license.inc"
    path = findfile(fn)
    if (path)
      readfile(path) 
    else 
      error("can't find include file: \"#{fn}\"")
    end
  end # do_include(ln)

  ######################################################################
  # Process %metastr
  # [ln]	Remainder of line (all but first word ignored)
  def do_metastr(ln)
    do_metapush(ln)
  end # do_undef(ln)

  ######################################################################
  # Process %metapush
  # [ln]	Remainder of line (all but first word ignored)
  def do_metapush(ln)
    @metastk.push(first_word(ln))
    @metastr = @metastk.last
    @metalen = @metastk.last.size
  end # do_undef(ln)

  ######################################################################
  # Process %metapop
  # [ln]	Remainder of line (ignored)
  def do_metapop(ln)
    @metastk.pop
    @metastr = @metastk.last
    @metalen = @metastk.last.size
  end # do_undef(ln)

  ######################################################################
  # Handle a line beginning with @metastr by calling a proc
  # [tok]	Number representing token
  # [ln]	Remainder of line after token and whitespace
  def do_token(tok, ln)
    @handlers[ tok ].call(ln)
  end # do_token(tok, ln)

  ######################################################################
  # Get the first word of a line
  # [ln]	Line of text
  def first_word(ln)
    ln.sub(/\s.*/,"")
  end # first_word(ln)

  ######################################################################
  # Handle a line beginning with @metastr by validating and either
  # calling #do_token or #error
  # [ln]	Line of text with leading % removed
  def do_meta(ln)
    word = first_word(ln)
    token = TOKENS[ word ]
    if token != nil
      do_token(token, @sytab << ln.sub(/^[a-z]+\s*/,""))
    else
      error("unknown directive: %#{word}")
    end
  end # do_meta(ln)

  ######################################################################
  # Process input text: replace symbols and copy to output if top of 
  # condition stack is not false
  # [ln]	Line of input text
  def do_text(ln)
    @outtext.push(@sytab << ln) if @condstk.last
  end # do_text(ln)

  ######################################################################
  # Decide what to do with a line and call appropriate method
  # [ln]	Line of input text
  def do_line(ln)
    i = ln.index(@metastr)
    if i != 0
      if i == 1 && ln[0,1] = "\\"
	ln = ln[ 1, ln.size - 1 ]
      end
      do_text(ln)
    else
      do_meta(ln[ @metalen, ln.size - @metalen ])
    end      
  end # do_line(ln)

  ######################################################################
  # Build logical lines: if a physical line ends with "%+\n", then
  # the next line is a continuation, and the "%+\n" is replaced by a
  # single space. Calls do_line once a logical line is assembled.
  # [ln]
  #	partial or complete logical line read from input
  def cons_line(ln)
    if @linebuf.size == 0
      @linebuf = ln
    else
      @linebuf += " "
      if %r{^\s*(\S+.*)} =~ ln
	@linebuf += $1
      end
    end
    if @linebuf.size < 2 || @linebuf[0,1] != "%" || @linebuf[-2,2] != "%+"
      tmp = @linebuf
      @linebuf = ""
      do_line(tmp)
    else
      @linebuf.slice!(-2,2)
    end
  end # cons_line(ln)

  ######################################################################
  # Process an input file (does not search for file)
  # [fn]	path to file to read
  def readfile(fn) 
    if File::file?(fn) && File::readable?(fn)
      File.open(fn) {
	|io|
	@filestk.push(io)
	@metastk.push(@metastr)
	ccond = @condstk.size
	cmeta = @metastk.size
	io.each_line { |ln| 
	  if @abend
	    break
	  else
	    cons_line(ln.chomp)
	    if @goteof
	      @goteof = false; break
	    end
	  end # if abend
	}
	if @linebuf.size > 0
	  error("File ends with line continuation")
	  cons_line("") # force the partial line out
	end
	if @condstk.size != ccond
	  error("Mismatched conditionals in file")
	  if @condstk.size > ccond
	    error("Unclosed %if(s): recovering ...")
	    while @condtstk.size > ccond
	      @condstk.pop
	    end
	  else
	    @abend = true
	    error("Too many %endifs (corrupted): bailing out");
	  end
	end
	if @metastk.size < cmeta
	    @abend = true
	    error("Too many %metapops (corrupted): bailing out");
	end
	while @metastk.size > cmeta
	  @metastk.pop
	end
	@metastr = @metastk.last
	@metalen = @metastr.size
	@filestk.pop
      }
    else
      error("File '#{fn}' not found/readable")
    end
  end # readfile(fn) 

  public

  ######################################################################
  # Process file, returning array of output text or nil if errors
  # occurred
  # [fn]	path to file to read
  def process(fn)
    readfile(fn)
    @errlist.size == 0 ? @outtext : nil
  end # process(fn)

  ######################################################################
  # Shorthand for process(fn)
  def <<(fn)
    process(fn)
  end # <<(fn)
end # class FileProcessor
#EOF 
