# A Little Language for Surveys: An Internal DSL in Ruby (#9)
# H. Conrad Cunningham, Professor
# Computer and Information Science
# University of Mississippi (Ole Miss)

# Version #1:  18 October  2006 
# Version #2:  21 October  2006 
# Version #3:  18 November 2006 
# Version #4:  20 November 2006 
# Version #5:   4 December 2007 
# Version #6:   6 December 2007
# Version #7:  13 December 2007
# Version #8:  20 December 2007
# Version #9   23 March    2008

#2345678901234567890123456789012345678901234567890123456789012345678901234567890

# This Ruby program implements a domain specific language (DSL) for
# describing simple surveys consisting of sequences of questions with
# each question having a sequence of possible responses.  The
# development of this DSL and program was inspired by an example in
# Jon Bentley's Programming Pearls column on "Little Languages" (CACM,
# Vol. 29, No. 8, pp. 711-21, August 1986).  The initial program
# design also benefited from study of the designs of Martin Fowler's
# Reader Framework configuration DSL described in his "Language
# Workbenches" and "Generating Code for DSLs" articles at
# http:///www.martinfowler.com.  (In earlier work, the author
# reimplemented these in Ruby.)

# The DSL is implemented as an internal (sometimes called embedded)
# DSL in the Ruby programming language.  That is, the DSL is a
# restricted subset of Ruby using some method calls implemented in the
# SurveyDSL base class, intended to be called from DSL code in a
# subclass (such as SurveyBuilder).  Jim Freeze's March 2006 article
# "Creating DSLs with Ruby" on the _Artima Developer_ website
# (http://www.artima.com) motivated some of the techniques used.
# Later enhancements are also motivated, in part, by Martin Fowler's
# online materials on "Domain Specific Languages" that will eventually
# appear as a book (http://www.martinfowler.com/dslwip).


# THE DSL

# Below is an example Ruby DSL description of a survey.  

# A survey specification written in the DSL consists of a title and a
# sequence of "questions" of various types.  The order of the
# questions in the survey specification is the order in which the
# questions will be presented to the survey respondent. Questions are
# numbered consecutively from 1 within the survey.

# Statement "title" gives a title to the survey.  The statement may
# appear at any position in the sequence of "questions", but it may
# appear at most once. 

# Statement "question" defines a basic survey question.  It has two
# arguments and a do-end block.  The first argument is the question's
# text.  The second is an optional argument giving the number of
# expected responses, which defaults to 1 if omitted. The required
# block is normally written with a beginning "do" and terminating
# "end".  It defines a sequence of possible responses to the question,
# with the order of presentation the same as the order in the block.
# Responses are labeled consecutively from 'a' within a question.  The
# "response" consists of an optional "condition" statement, a sequence
# of "response" statements, and an optional "action" statement.  The
# optional "condition" and "action" statements may appear anywhere in
# the sequence, but, when executed, the "condition" is executed first,
# then the sequence of "response" statements in the order given, then
# the "action".

# Within the do-end block for a "question", a "response" statement
# defines a possible response to the question.  It has one argument,
# the text of the response, and an optional block.  The block is only
# executed if that response is chosen

# Within the do-end block for a "question", the "condition" statement
# is optional.  If present, it defines a boolean condition (expressed
# as a Ruby block) under which the question is used in a survey.

# Within the do-end block for a "question", the "action" statement is
# also optional.  If present, it gives a group of actions that is
# executed at the end of the processing of a question.  

# The survey answers array generated by executing the survey is an
# array of pairs.  Each pair is an array that has the question number
# as its first element and an array of the (zero or more) selected
# response labels as its second component.

# The "condition" and "action" blocks on the "question" statement may
# contain any Ruby expressions.  They may create new instance
# variables and reference other instance variables created previously
# in the processing of the survey.  Included among the instance
# variables are values supplied by the execution framework including
# the current question number (@question_num), the text and number of
# selections for the current question (@question_text and
# @question_num_of_sels).  For action blocks associated with specific
# responses, there are also instance variables for the current
# response label (@response_label) and the response text
# (@response_text). For guard blocks associated with specific
# alternatives, there are also the response label and response text
# instance variables.  The answers for previous survey questions are
# also available in the answer array @survey_answers.  The instance
# variables @context and @pass must NOT be set in the survey's blocks.

# A "result" statement represents a silent question.  It has two
# arguments and a required do-end block.  The first argument is the
# silent question's text. The second is an optional argument giving
# the number of expected responses, which defaults to 1 if omitted.
# The "result" statement calculates an answer internally rather than
# seeking input from the respondent.  The "alternative" statements
# within the do-end block are the possible responses for this silent
# question. The "alternative" statements are similar to the "response"
# statements associated with the "question" statement.  The block
# attached to an alternative is an optional boolean condition.  If the
# block evaluates is left off or evaluates to true, then the
# associated alternative is enabled.  If the block evaluates to false,
# then it is not enabled. The alternative's conditions are executed in
# the order given; execution of alternatives stops when the expected
# number of responses ahave been collected. The "result" statement may
# also have "condition" and "action" statements within its do-end
# block.  The syntax and semantics of these are as in "question"
# statements. The labeling of alternatives is the same as for
# responses within "question" statements.  The "result" statement is
# counted as a question in the display numbering scheme.

# The "no_result" statement takes an optional text argument and allows
# "condition" and "action" statements within its associated do-end
# block.  Execution of the "no_result" statement occurs if the
# "condition" is missing or evaluates to true.  In this case, the
# optional text will be displayed and the action block will be
# executed for its effects on the survey instance variables. The
# "no_result" statement is not counted as a question in the display
# numbering scheme.  The optional text argument can be used to display
# instructions

# A DSL file may include blank lines and Ruby-style end-of-line
# comments beginning with a "#" symbol.

# Here is a simple example.  (In general, output operations should be
# avoided in the actions except for debugging purposes.)
#
#   title "This is an Silly Test Survey"
#   question "What is your gender?", 1 do
#     response "Male" do
#       @male = true
#     end
#     response "Female" do
#       @female = true
#     end
#     response("Unknown") do
#       @confused = true
#     end
#     action { puts "You're a comedian" if @confused }
#   end
#   no_result "If you are female, we will not ask your age." do
#     condition { @female }
#   end
#   question "What is your age?", 1 do
#     condition { @male }
#     response "Not any of your business"
#     response "Ancient"
#     response "Dead" do
#       @dead = true
#     end
#     action { puts "In question #{@question_num}, " +
#                "male = #{@male}, female = #{@female}" }
#   end
#
#   question "Where do you live?" do
#     response "Oxford"
#     response "University"
#     response "Lafayette outside Oxford and University"
#     response "Somewhere Else"
#     action { puts @survey_answers }
#   end
#   
#   result "Are you weird?" do
#     condition  { @question_num > 1 }
#     alternative "Yes" do
#       @weird = (@confused || @dead)
#     end
#     alternative "No"
#     action { @weird ||= false }
#   end
#   
#   no_result do
#     condition { @male }
#     action { puts "Hi there"}
#  end


# PROGRAM DESIGN

# The surveys are processed in two passes.  The first pass reads the
# Ruby DSL file and builds an abstract syntax tree (AST) for the file.
# This is implemented below as classes DSLContext, SurveyDSL, and
# SurveyBuilder. The second pass uses the AST to drive some other
# process such as the execution of a survey.  The AST takes a Visitor
# object to enable several different operations to be plugged in to
# the pass. This is illustrated below by class SurveyInteractiveText
# that implements a simple, command-line oriented, interactive program
# to administer a survey to a respondent.  The intention is that the
# two passes be independent of one another except that the first-pass
# object is used as a data storage environment for the execution of
# code blocks in the second pass.

# Note: Following Bertrand Meyer's concept of command-query separation,
# the usual best practice for class design is to have pure command
# (also called setter, mutator, or writer) methods that are procedures
# and pure query (also called getter, accessor, or reader) methods
# that functions free of side effects. However, Ruby methods always
# return the value of the last statement executed.  In this
# implementation, many of the procedure methods are implemented to
# return a reference to the object with which it is associated.  One
# motivation for this approach is the "method chaining" technique for
# implementing libraries or DSLs with "fluent interfaces" as discussed
# by Martin Fowler's evolving book on "Domain Specific Languages".
# This technique enables several method calls to be sequenced in the
# same expression.  In the context where these calls are building up
# an expression (such as the AST in this application), Fowler calls
# this an application of the Expression Builder DSL pattern. The
# current design of this package does not exploit this technique.

# Design and implementation issues: 
#   - Testing of the program is minimal so far.  
#   - The definition of the DSL syntax and semantics is informal.  
#   - The feature of using the SurveyBuilder object to store the instance 
#     variables for the DSL blocks during their execution in the second
#     pass is not ideal. It leads to coupling between the second pass and 
#     the specific first-pass builder object.  Other techniques should be 
#     explored.  
#   - The implementation of the Visitor pattern is traditional.  This could 
#     be implemented by using Ruby's open class feature to add the needed 
#     "execute" methods to the AST classes.
#   - The code probably should be reorganized into modules for the first pass, 
#     AST, and second pass. 

# FIRST PASS

# Class DSLContext holds the state of the DSL parser, including
# information about the current parsing context and the options
# selected.  This is the Memento class used in the like-named design
# pattern for storing and externalizing the state of the SurveyDSL
# parser objects.  In Martin Fowler's evolving book, this is also
# known as the Context Variable DSL pattern.

class DSLContext

  attr_reader    :survey, :ql_count, :rl_count
  attr_accessor  :question, :response, :alt, 
                 :level, :qtype, :ql_error,
                 :err_out, :auto_print, :auto_clear, :trace

  # Method DSLContext#initialize initializes the state of the
  # DSLContext object and, hence, of the SurveyDSL object that uses
  # it.  The argument "survey_root" should be a reference to the AST
  # object to be used to store the survey.

  def initialize(survey_root)
    @survey    = survey_root  # root of AST (normally should be empty)
    @question  = nil          # current question node
    @response  = nil          # current response node
    @alt       = nil          # current alternative node
    @level     = :no_level    # current AST level from { :no_level ,
                              #   :survey_level,:question_level,:response_level}
    @qtype     = :no_type     # current question node type from {:no_type,
                              #   :question_type,:result_type,:no_result_type}
    @ql_count  = 0            # running count of question-level statements
    @rl_count  = 0            # running count of response-level statements
    @ql_error  = false        # flag indicated error occurred within current
                              # question-level statment (probably need to abort)
    @errmsgs   = []           # array of generated error message lines
    @err_out   = STDERR       # error output IO, by default STDERR
    @auto_prt  = true         # by default print error messages when added
    @auto_clr  = true         # by default clear error messages when printed
    @trace     = false        # trace DSL input
  end#initialize

  # Method DSLContext#incr_ql_count increments the question-level statement
  # counter and clears the response-level statement counter.

  def incr_ql_count
    @ql_count  += 1
    @rl_count   = 0
    self
  end#incr_ql_count

  # Method DSLContext#incr_rl_count increments the response-level
  # statement counter.

  def incr_rl_count
    @rl_count += 1
    self
  end#incr_rl_count

  # Method DSLContext#clr_rl_count clears the response-level
  # statement counter.

  def clr_rl_count
    @rl_count = 0
    self
  end#clr_rl_count

  # Method DSLContext#add_error adds the String-like error message
  # "line" to the current parser state.  By default, it prints the
  # stored message.

  # Note: I use the term "String-like" to denote Strings and anything
  # that can be converted to a string by calling its to_s method. 

  def add_error(line)
    @errmsgs << line.to_s 
    print_error if @auto_prt
    self
  end#add_error

  # Method DSLContext#clr_error clears all error message lines in the
  # current parser state.

  def clr_error
    @errmsgs = []
    self
  end#clr_error

  # Method DSLContext#print_error prints all error message lines in
  # the current parser state.  By default, it clears the printed
  # message.

  def print_error
    @errmsgs.each {|m| @err_out.puts(m)}
    clr_error if @auto_clr
    self
  end#print_error

end#DSLContext


# Class SurveyDSL is an abstract class that implements the parser for
# the Survey DSL statements.  It builds an abstract syntax tree (AST)
# for the DSL input.  This is the base of a class hierarchy that
# implements the DSL using the Fowler's Object Scoping DSL pattern.

class SurveyDSL

  attr_accessor :context

  # The state of the parsing is stored in a parser context object from
  # class DSLContext.  This object is passed in at initialization.

  def initialize(dsl_context)
    @context = dsl_context
  end#initialize

  private # DSL methods available to subclasses

  # SurveyDSL methods "title", "question", "result", "no_result",
  # "condition", "action" , "response", and "alternative" implement
  # the corresponding statements used in the DSL input. These methods
  # build the abstract syntax tree (AST) for the survey using
  # instances of the classes SurveyRoot, QuestionNode, ResultNode,
  # NoResultNode, and ResponseNode.  

  # Method SurveyDSL#title gives a title to the survey.  It takes one
  # argument which is the text of the title.  A survey can only have
  # one title.  The title is stored in root node of the AST being
  # built.

  def title(text)
    @context.incr_ql_count
    trace_text_msg("title",text)

    @context.ql_error = false
    if @context.level == :survey_level && @context.survey.title == nil
      @context.survey.title = text.to_s
    else
      illegal_stmt_msg("result")
      stmt_text_msg("Title",text)
      if @context.survey.title != nil
        at_most_one_msg("title","survey")
      end
    end
    self
  end#title

  # Method SurveyDSL#question represents a survey question to be asked
  # of the responder.  It takes three arguments.  The first is a
  # String-like argument with the text to be displayed for the
  # question.  The second argument is the number of selections to
  # request.  This argument may be omitted and the default will be
  # 1. The third argument is a required block that holds the needed
  # "response", "condition", and "action" statements. This method
  # creates a QuestionNode instance and adds it to the AST at the
  # question level.

  def question(text,*args) # text, nsel, block
    @context.incr_ql_count
    trace_text_msg("question",text,*args)

    @context.ql_error = false
    if @context.level == :survey_level && block_given?
      @context.level    = :question_level
      @context.qtype    = :question_type
      nsel  = 1
      nsel  = args[0].to_i if args.size > 0
      @context.question = QuestionNode.new(text.to_s,nsel)
      yield   # execute the block on the question call in the DSL
      nresp = @context.question.responses.size
      if nresp < @context.question.num_to_sel
        too_few_responses_msg("question")
        stmt_text_msg("Question",text)
        @context.question.num_to_sel = nresp 
      end
      if !@context.ql_error
        @context.survey.add_question(@context.question)
      else
        error_within_block_msg("question")
        stmt_text_msg("Question",text)
        @context.ql_error = false
      end    
      @context.level    = :survey_level
      @context.qtype    = :no_type
    else
      illegal_stmt_msg("question")
      stmt_text_msg("Question",text)
      if !block_given?
        missing_req_block_msg("question")
      end
    end
    @context.question = nil
    self
  end#question

  # Method SurveyDSL#result represents a silent question, for which
  # the result is derived from previous information.  It takes three
  # arguments.  The first is a String-like argument with the text of
  # the "question".  The second argument is the number of selections
  # in the result.  This argument may be omitted and the default is
  # 1. The third is a required block that holds the needed condition
  # and action statements. This method creates a ResultNode instance
  # and adds it to the AST at the question level.

  def result(text,*args) # text, nsel, block
    @context.incr_ql_count
    trace_text_msg("result",text,*args)

    @context.ql_error = false
    if @context.level == :survey_level && block_given?
      @context.level    = :question_level
      @context.qtype    = :result_type
      nsel = 1
      nsel = args[0].to_i if args.size > 0
      @context.question = ResultNode.new(text.to_s,nsel)
      yield   # execute the block on the result call in the DSL
      nresp = @context.question.responses.size 
      if nresp < @context.question.num_to_sel
        too_few_responses_msg("result")
        stmt_text_msg("Silent question (\"result\")",text)
        @context.question.num_to_sel = nresp
      end
      if !@context.ql_error
        @context.survey.add_question(@context.question)
      else
        error_within_block_msg("result")
        stmt_text_msg("Result",text)
        @context.ql_error = false
      end    
      @context.level    = :survey_level
      @context.qtype    = :no_type
    else
      illegal_stmt_msg("result")
      stmt_text_msg("Result",text)
      if !block_given?
        missing_req_block_msg("result")
      end
    end
    @context.question = nil
    self
  end#result

  # Method SurveyDSL#no_result is a question-like construct that takes
  # two arguments.  One is an optional String-like argument with
  # instruction text to be displayed.  The second is a block. The
  # block may contain "condition" and "action" statements.  If there
  # is no associated "condition" statement or if the one given is
  # true, then the "no_result" statement is executed. If the statement
  # is executed, then any text argument is displayed and any
  # associated "action" block is executed for its effects on the
  # survey variables.  However, no survey answers are generated.  This
  # method creates a NoResultNode instance and adds it to the AST at
  # the question level.

  def no_result(*args) # text, block
    @context.incr_ql_count
    trace_msg("no_result",*args)

    @context.ql_error = false
    if @context.level == :survey_level && block_given? 
      @context.level    = :question_level
      @context.qtype    = :no_result_type
      text = nil
      text = args[0].to_s if args.size > 0        
      @context.question = NoResultNode.new(text)
      yield   # execute the block on the result call in the DSL
      if !@context.ql_error
        @context.survey.add_question(@context.question)
      else
        error_within_block_msg("no_result")
        stmt_text_msg("No_result",text)
        @context.ql_error = false
      end    
      @context.level    = :survey_level
      @context.qtype    = :no_type
    else
      illegal_stmt_msg("no_result")
      stmt_text_msg("No_result",text)
      if !block_given?
        missing_req_block_msg("no_result")
      end
    end
    @context.question = nil
    self
  end#no_result

  # Method SurveyDSL#condition takes its argument block from the DSL
  # file and stores it in the current question-level node instance of
  # the AST as an unevaluated Proc (i.e., a closure).  This block
  # should evaluate to true if the corresponding question node should
  # be used in the survey.  The "condition" block is evaluated in the
  # second pass to determine whether to include the corresponding
  # question, result, or no_result in the survey processing.

  # Note: The stored block may use instance variables.  These are
  # currently instance variables of the object in which the code
  # blocks (closures) were initially defined (not where they are
  # executed).  That is, they are instance variables of the SurveyDSL
  # subclass object.  Alternatives to this design should be explored.

  # Note: The "&" on the last parameter of a method definition means that
  # the corresponding argument is a block, treated as a Proc object within
  # the class.

  def condition(&check)
    @context.incr_rl_count
    trace_msg("condition")

    if @context.level == :question_level && block_given? && 
          @context.question.condition == nil
      @context.question.condition = check
    else
      illegal_stmt_msg("action")
      stmt_type_msg
      if !block_given?
        missing_req_block_msg("condition")
      end
      if @context.question.condition != nil
        at_most_one_msg("condition","question")
      end
      @context.ql_error = true
    end
    self
  end#condition

  # Method SurveyDSL#action takes its argument block from the DSL file
  # and stores it in the current question-level node instance in the
  # AST as an unevaluated Proc (i.e., closure) .  The "action" block
  # is evaluated in the second pass to take actions such as to change
  # the values of variables used in "condition" blocks.  An "action"
  # block used in a "result" statement must return a response label or
  # array of response labels the size of the num_of_sels argument.  An
  # "action" block in "question" and "no_result" statements are
  # executed just for their side effects.

  def action(&action)
    @context.incr_rl_count
    trace_msg("action")

    if @context.level == :question_level && block_given? && 
          @context.question.action == nil
      @context.question.action = action
    else
      illegal_stmt_msg("response")
      stmt_type_msg
      if !block_given?
        missing_req_block_msg("action")
      end
      if @context.question.action != nil
        at_most_one_msg("action","question")
      end
      @context.ql_error = true
    end
    self
  end#action

  # Method SurveyDSL#response has one or two arguments and stores them
  # appropriately in the current QuestionNode in the AST.  The first
  # argument is a String-like argument to be displayed for this
  # possible response.  The second argument is the optional block on
  # the "response" statement, which is stored in the AST as and
  # unevaluated Proc (i.e., closure).  This block may access and
  # create instance variables as it executes in the second pass.

  def response(resp_text,&action)
    @context.incr_rl_count
    trace_text_msg("response",resp_text)

    if @context.level == :question_level && @context.qtype == :question_type
      @context.level           = :response_level
      @context.response        = ResponseNode.new(resp_text.to_s)
      @context.response.action = action if block_given?
      @context.question.add_response(@context.response)
      @context.level           = :question_level
    else
      illegal_stmt_msg("response")
      stmt_type_msg
      stmt_text_msg("Response", resp_text)
      @context.ql_error = true
    end
    @context.response = nil
    self      
  end#response

  # Method SurveyDSL#alternative has one or two arguments and stores
  # them appropriately in the current ResultNode in the AST.  The
  # first argument is a String-like argument holding the text of this
  # alternative response to the silent question.  The second argument
  # is an optional block on the "alternative" statement, which is
  # stored in the AST as an unevaluated Proc (i.e., closure).  This
  # block is the "guard" for the alternative, which will be evaluated
  # as a boolean.  If evaluated to true, then this alternative may be
  # chosen as the value of the "result" statement.

  def alternative(alt_text,&guard)
    @context.incr_rl_count
    trace_text_msg("alternative",alt_text)

    if @context.level == :question_level && @context.qtype == :result_type
      @context.level     = :response_level
      @context.alt       = AlternativeNode.new(alt_text.to_s)
      @context.alt.guard = guard if block_given?
      @context.question.add_response(@context.alt)
      @context.level = :question_level
    else
      illegal_stmt_msg("alternative")
      stmt_type_msg
      stmt_text_msg("Alternative", alt_text)
      @context.ql_error = true
    end      
    @context.alt = nil
    self
  end#alternative

  # Methods for generating common parts of error messages.

  def trace_msg(stmt,*args)
    if @context.trace 
      @context.add_error("#{rl_num} #{stmt} " + 
                         (args.size > 0 ? "\"#{args[0]}\"" : ""))
    end
  end

  def trace_text_msg(stmt,text,*args)
    if @context.trace 
      @context.add_error("#{rl_num} #{stmt} \"#{text}\"" + 
                         (args.size > 0 ? ", #{args[0]}" : ""))
    end
  end

  def too_few_responses_msg(stmt)
    @context.add_error("#{ql_num} Fewer possible responses for \"#{stmt}\" " +
                       "than the number requested.")
  end

  def missing_req_block_msg(resp)
    @context.add_error(
        "#{rl_num} Missing required block on \"#{resp}\" statement.")
  end

  def error_within_block_msg(stmt)
    @context.add_error("#{rl_num} Cancelling \"#{stmt}\" statement " +
        "because of errors within block.")
  end

  def at_most_one_msg(stmt,scope)
    @context.add_error(
        "#{rl_num} Only one \"#{stmt}\" statement allowed in #{scope}.")
  end

  def illegal_stmt_msg(stmt)
    @context.add_error(
        "#{rl_num} Illegal use of \"#{stmt}\" statement at #{st_level}.")
  end

  def stmt_type_msg
    @context.add_error("#{rl_num} Question type is #{st_type}.")
  end

  def stmt_text_msg(stmt,text)
    @context.add_error("#{rl_num} #{stmt} text is \"#{text}\".")
  end

  def ql_num
    "#{@context.ql_count}."
  end

  def rl_num
    "#{ql_num}" + (@context.rl_count > 0 ? "#{@context.rl_count}." : "")
  end

  def st_level
    "#{@context.level.to_s.upcase}"
  end

  def st_type
    "#{@context.qtype.to_s.upcase}"
  end

end#SurveyDSL


# Class SurveyBuilder reads the DSL file and builds the Abstract
# Syntax Tree for the DSL file.  It is a concrete subclass that
# extends SurveyDSL and supplies the DSL input that is parsed.

class SurveyBuilder < SurveyDSL

  attr_reader :pass

  # Initialize the Builder object.

  def initialize
    super(nil)
    @pass = 0
  end#initialize

  # Method SurveyBuilder#survey is a reader method that delegates its
  # operation to the context object's method of the same name.

  def survey 
    @context.survey 
  end#survey

  # Method SurveyBuilder#initialize_DSL (re)initializes the DSL parser
  # by creating a new parser context object and making it the current context.

  def initialize_DSL
    survey   = SurveyRoot.new(self)
    @context = DSLContext.new(survey)
    @pass    = 0
    self
  end#initialize_DSL

  # Method SurveyBuilder#begin_pass changes the processing pass to
  # "p", where "p = 1" is the DSL parsing pass and "p = 2" is the
  # survey processing pass.  If an inappropriate change is requested,
  # then an error message is generated.

  def begin_pass(p)
    pp = p.to_i
    if pp == 1 && @pass == 0
      @context.level = :survey_level  #enable parsing
      @pass = 1
    elsif  pp == 2 && @pass >= 0 && @pass <= 1
      @context.level = :no_level      # disable parsing
      @pass = 2
    else
      @context.add_error("Illegal attempt to begin DSL processing pass.")
      @context.add_error("    Current PASS is:       #{@pass}")
      @context.add_error("    Requested new PASS is: #{pp}")
    end
    self
  end#begin_pass

  # Method SurveyBuilder#end_pass ends the processing pass "p" and
  # returns the pass to the idle state. If an inappropriate change is
  # requested, then an error message is generated.

  def end_pass(p)
    pp = p.to_i
    if pp == @pass && pp >= 1 && pp <= 2
      @context.level = :no_level  # disable parsing
      @pass = 0
    else
      @context.add_error("Illegal attempt to end DSL processing pass.")
      @context.add_error("    Current PASS is:       #{@pass}")
      @context.add_error("    Requested new PASS is: #{pp}")
    end
    self
  end#end_pass

  # Method SurveyBuilder#process_DSL (re)initializes the parser
  # context, reads the DSL input from argument "dsl_input", parses it
  # to build the corresponding AST.  Argument "dsl_input" may be the
  # name of a file that contains the DSL input or it may be array of
  # filenames, each of which hold part of the DSL input. In the latter
  # case, the DSL input consists of the contents of the files
  # concatenated in order of increasing index.

  def process_DSL(dsl_input)
    initialize_DSL
    begin_pass(1)
    if dsl_input.kind_of? Array    # assume array of file names
      dsl_input.each {|e| read_DSL(e)}
    else                           # assume a file name
      read_DSL(dsl_input)
    end
    end_pass(1)
    self
  end#process_DSL

  # Method SurveyBuilder#accept processes the AST in the current
  # context with the SurveyVisitor "executor".

  def accept(executor)
    if @context != nil
      begin_pass(2)
      @context.survey.accept(executor)
      end_pass(2)
    else
      STDERR.puts "SurveyBuilder Error.  Attempt to execute undefined survey."
    end
    self
  end#accept

  # Method Survey#process_survey (re)initializes the parser context,
  # reads the DSL input from argument "dsl_input", parses it, and
  # processes the resulting AST with the SurveyVisitor "executor".
  # Argument "dsl_input" may be the name of a file that contains the
  # DSL input or it may be array of filenames, each of which hold part
  # of the DSL input. In the latter case, the DSL input consists of
  # the contents of the files concatenated in order of increasing
  # index.

  def process_survey(dsl_input,executor)
    process_DSL(dsl_input)
    accept(executor)
  end#process_survey

private

  # SurveyBuilder#read_DSL takes a String-like argument holding the
  # filename for the DSL input file, reads the DSL input, evaluates it
  # as Ruby code in the context of this object, and builds the AST.
  # This method assumes that the parser context has been properly
  # initialized.

  def read_DSL(rb_dsl_file)
    dsl_file = rb_dsl_file.to_s

    # exit if DSL input file does not exist or is unreadable.
    unless File.exist? dsl_file
      STDERR.puts("DSL input file #{rb_dsl_file} does not exist.")
      return self
    end
    unless File.readable? dsl_file
      STDERR.puts("DSL input file #{rb_dsl_file} is not readable.")
      return self
    end

    rb_file = File.new(dsl_file)
    instance_eval(rb_file.read, dsl_file) # load and evaluate in context
    rb_file.close                         #  of this object
    self
  end#read_DSL   

  # Method SurveyBuilder#method_missing intercepts undefined method
  # calls.  In the first pass, it prints and error message for what is
  # likely an illegal statement in the DSL input and returns a
  # reference to the SurveyBuilder object.  In the second pass, it
  # assumes these missing methods are actually attempts to write to a
  # new instance variable during execution of the survey, creates the
  # needed methods using "attr_accessor", and then calls the method
  # just created returning whatever that call returns. The approach
  # for the second pass is inspired somewhat by Jim Freeze's article
  # "Creating DSLs with Ruby" on Artima Developer.

  def method_missing(sym, *args)
    if @pass == 1 # Likely a syntax error in the DSL
      @context.add_error("#{ql_num} Call of unknown method \"#{sym}\" " +
          "during first pass with arguments:")
      @context.add_error("#{ql_num}  #{args.map {|a| "\""+a.to_s+"\" "}}")
      @context.add_error(
          "#{ql_num} Probably is an illegal statement in the DSL input.")
      if @context.level == :question_level || @context.level == :response_level
        @context.ql_error = true
      end
      nil                     
    elsif @pass == 2 # Create new readers and writers for the survey execution
      str = sym.to_s
      if str[-1,1] == "="
        base = str[0..-2].to_sym
        if self.respond_to? base
          @context.add_error("Illegal attempt to write survey variable #{base}")
        else
          SurveyBuilder.class_eval "attr_accessor :#{base}"
          send(sym, *args)
        end
      else
        @context.add_error(
            "Illegal attempt to read undefined survey variable #{str}") 
        nil
      end
    else # Bad value for pass
      @context.add_error("#{ql_num} Illegal value \"#{@pass}\" " +
          "for processing PASS in \"missing_method\" call.")
      @context.add_error("#{ql_num} Attempt to call method #{sym} with #{args}")
      nil
    end
  end#method_missing

end#SurveyBuilder


# ABSTRACT SYNTAX TREE CLASSES

# The abstract syntax tree (AST) for the DSL has three levels. An
# instance of class SurveyRoot forms the root of the AST and holds all
# information about the survey.  The second level of the AST is made
# up of instances of classes QuestionNode, ResultNode, and
# NoResultNode, sequenced in the same order specified in the DSL input
# file. The third (leaf) level of the AST is made up of instances of
# class ResponseNode, sequenced within a QuestionNode in the same
# order specified in the DSL input file.

class SurveyRoot

  attr_reader   :env, :questions
  attr_accessor :title

  # Takes a reference to the builder object.
  def initialize(builder_env)
    @env       = builder_env
    @questions = []
    @title     = nil
  end#initialize

  # Add a question-level node "question" to the AST root.
  def add_question(question)
    @questions << question
    self
  end#add_question

  # Accept a Visitor object to process the survey in the tree
  def accept(survey_visitor)
    @env.survey_title   = @title
    @env.survey_answers = []   # make available to DSL block execution
    @env.question_num   = 1    # make available to DSL block execution
    survey_visitor.execute_title(@env,self)
    questions.each do |q|
      q.accept(@env,survey_visitor)
    end
    self
  end#accept

end#SurveyRoot

# Classes QuestionNode, ResultNode, and NoResultNode are all
# question-level nodes.  All are implemented as subclasses of
# superclass QuestionLevelNode.  Although the syntax for all are
# similar, the semantics are quite different.  NoResultNode does not
# need any attributes except "condition" and "action" to implement the
# semantics of the "no_result" statement.  Class NullQuestionNode is a
# null Question-level node designed according to the Null Object
# design pattern.

class QuestionLevelNode

  attr_accessor :text, :num_to_sel, :condition, :action
  attr_reader   :responses

  # Takes the question's text and the number of desired selections.
  def initialize(question_text,num_sels)
    @text       = question_text.to_s
    @num_to_sel = num_sels.to_i
    @num_to_sel = 0 if @num_to_sel < 0
    @responses  = []
    @condition  = nil
    @action     = nil
  end#initialize

  # Add a response-level node "response" to the current question-level node.
  def add_response(response)
    @responses << response
    self
  end#add_response

end#QuestionLevelNode


class QuestionNode < QuestionLevelNode

  def initialize(*args)
    super(*args)
  end#initialize

  # Method QuestionNode#accept takes a Visitor object that processes the
  # survey elements described by this node.

  def accept(env,survey_visitor)
    env.question_text       = @text       # make available to DSL block
    env.question_num_to_sel = @num_to_sel # make available to DSL block
    survey_visitor.execute_question(env,self)
    env.question_text       = nil
    env.question_num_to_sel = nil
    self
  end#accept

end#QuestionNode


class ResultNode < QuestionLevelNode

  def initialize(*args)
    super(*args)
  end#initialize

  # Method ResultNode#accept takes a Visitor object that processes the survey
  # elements described by this node.

  def accept(env,survey_visitor)
    env.question_text       = @text        # make available to DSL blocks
    env.question_num_to_sel = @num_to_sel  # make available to DSL blocks
    survey_visitor.execute_result(env,self)
    env.question_text       = nil
    env.question_num_to_sel = nil
    self
  end#acept

end#ResultNode


class NoResultNode < QuestionLevelNode

  def initialize(text)
    @text       = text.to_s
    @num_to_sel = 0
    @responses  = nil
    @condition  = nil
    @action     = nil
  end#initialize

  # Method NoResultNode#accept takes a Visitor object that processes the
  # survey elements described by this node.

  def accept(env,survey_visitor)
    env.question_text       = @text        # make available to DSL blocks
    env.question_num_to_sel = @num_to_sel  # make available to DSL blocks
    survey_visitor.execute_noresult(env,self)
    env.question_text       = nil
    env.question_num_to_sel = nil
    self
  end#accept

end#NoResultNode


class NullQuestionNode < QuestionLevelNode

  def initialize(text)
    super(text,0)
  end#initialize

  # Method ErrorQuestionNode#accept takes a Visitor object and
  # does nothing except return.

  def accept(env,survey_visitor)
    self
  end#accept

end#NullQuestionNode


# Classes ResponseNode and AlternativeNode are both response-level
# nodes with parent class ResponseLevelNode.

class ResponseLevelNode

  attr_reader   :text

  def initialize(resp_text)
    @text   = resp_text.to_s
  end#initiailize

end#ResponseLevelNode


class ResponseNode < ResponseLevelNode

  attr_accessor :action

  def initialize(resp_text)
    super(resp_text)
    @action = nil
  end#initialize

end#ResponseNode


class AlternativeNode < ResponseLevelNode

  attr_accessor :guard

  def initialize(alt_text)
    super(alt_text)
    @guard = nil
  end#initialize

end#AlternativeNode


#  SECOND PASS


# Class SurveyVisitor is an "abstract" class that defines a Visitor
# to traverse and process the AST during the second pass.

class SurveyVisitor

  # Method SurveyVisitor#execute_title takes an environment reference
  # for the survey variables and a reference for the survey root node
  # and, by default, prints the title.  It should be overridden  to give
  # it the desired behavior.

  def execute_title(env,survey)
    STDOUT.puts "Survey Title: #{survey.title}"
    self
  end#execute_title  

  # Method SurveyVisitor#execute_question takes an environment
  # reference for the survey variables and a QuestionNode.  It must be 
  # overridden to get the desired action.

  def execute_question(env,q)
    STDERR.puts "Must define execute_question in subclass!"
    self
  end#execute_question

  # Method SurveyVisitor#execute_result takes an environment
  # reference for the survey variables and a ResultNode.  It must be 
  # overridden to get the desired behavior.

  def execute_result(env,q)
    STDERR.puts "Must define execute_result in subclass!"
    self
  end#execute_result

  # Method SurveyVisitor#execute_noresult takes an environment 
  # reference for the survey variables and a NoResultNode.  It must be 
  # overridden to get the desired behavior.

  def execute_noresult(env,q)
    STDERR.puts "Must define execute_noresult in subclass!"
    self
  end#execute_noresult

end#SurveyVisitor


# Class SurveyInteractiveText defines a Visitor to traverse and process 
# the AST during the second pass. This class implements a simple sequential,'
# interactive, textual interface for conducting surveys described by the DSL.

class SurveyInteractiveText < SurveyVisitor

  # Method SurveyInteractiveText#execute_title takes default behavior
  # from root.

  # Method SurveyInteractiveText#execute_question takes an environment
  # reference for the survey variables and a QuestionNode and executes
  # the display of the question and the solicitation of responses from
  # the user.

  def execute_question(env,q)
    # skip question if its condition evaluates to false
    if q.condition == nil || q.condition.call
      display_question(env.question_num,q.text)
      resp  = {}   # collect the action blocks to be evaluated later
      label = 'a'  # labels for responses are lowercase alphabetic from 'a'
      q.responses.each do |r|
        display_response(label,r.text)
        resp[label] = [r.action,r.text]
        label       = label.succ
      end
      answers = get_answers(q.num_to_sel,'a'...label)
      env.survey_answers << [env.question_num,answers]
      answers.each do |a| # evaluate action blocks of selected responses
        env.response_label = a          # make available to DSL blocks
        env.response_text  = resp[a][1] # make available to DSL blocks
        act = resp[a][0]
        act.call unless act == nil
        env.response_label = nil
        env.response_text  = nil
      end
      q.action.call unless q.action == nil # evaluate question's action block
    else
      env.survey_answers << [env.question_num,[]]
    end
    env.question_num = env.question_num + 1
    self
  end#execute_question

  # Method SurveyInteractiveText#execute_result takes an environment
  # reference for the survey variables and a ResultNode and executes
  # the processing of the silent question and the generation of the
  # answers.

  def execute_result(env,q)
    # skip question if its condition evaluates to false
    if q.condition == nil || q.condition.call
      result = []
      label  = 'a'  # labels for responses are lowercase alphabetic from 'a'
      num    = q.num_to_sel
      i      = 0
      # Process "alternative" statements while more choices needed and more 
      # statements to check
      while num > 0 && i < q.responses.length
        r = q.responses[i]
        env.response_label = label  # make available to DSL blocks
        env.response_text  = r.text # make available to DSL blocks
        if r.guard == nil || r.guard.call
          result << label
          num  -= 1
        end
        env.response_label = nil
        env.response_text  = nil
        label = label.succ
        i += 1
      end
      env.survey_answers << [env.question_num,result]
      q.action.call unless q.action == nil
    else
      env.survey_answers << [env.question_num,[]]
    end
    env.question_num += 1
    self
  end#execute_result

  # Method SurveyInteractiveText#execute_noresult takes an environment
  # reference for the survey variables and a NoResultNode and executes
  # the processing of this construct that may change the internal
  # state and display text but not generate an answer.

  def execute_noresult(env,q)
    # skip question if its condition evaluates to false
    if q.condition == nil || q.condition.call
     STDOUT.puts q.text unless q.text == nil
      q.action.call unless q.action == nil # evaluate question's action block
    end
    # do not increment question counter or determine answer
    self
  end#execute_noresult

private

  # Methods display_question and display_response of SurveyInteractiveText
  # are private methods used by execute_question to display questions
  # and responses, respectively.

  def display_question(qnum,text)
    STDOUT.puts "#{qnum}.  #{text}" 
  end

  def display_response(label,text)
    STDOUT.puts  "  #{label}.  #{text}"
  end

  # Method SurveyInteractiveText#get_answers is a private method that
  # takes the number of selections required and the valid range of
  # labels.  It repeatedly prompts for the labels of valid selections.
  # Only the first non-blank character on an input line is examined.
  # It returns the set of selected labels, without duplicates.  Note
  # that if the user enters the same valid label more than once, this
  # method will only return that answer once and will return fewer
  # than the specified number of answers.

  def get_answers(nsel,label_range)
    answers = []
    nsel.times do 
      label = nil
      while label == nil do
        STDOUT.print "Please enter selection:  "
        label  = STDIN.gets.strip.downcase[0,1]
        if label != nil && label_range === label then
          answers << label 
        else
          STDOUT.puts "Illegal response label input. Try again."
          label = nil
        end
      end
    end
    answers.uniq # remove duplicates
  end#get_answers

end#SurveyInteractiveText


# PRELIMINARY TESTING

# Execute test program by typing command TestSurvey.run or
# TestSurvey.process to irb.

class TestSurvey

  def TestSurvey.run
    puts "Begin first pass"
    builder = SurveyBuilder.new
    builder.process_DSL("survey.rb")
    puts "End first pass"

    builder.survey.questions.each {|q| puts q.inspect}

    puts "\nBegin second pass"
    executor = SurveyInteractiveText.new
    builder.accept(executor)
    puts "End second pass"
    puts builder.survey_answers.inspect
    builder
  end

  def TestSurvey.process
    builder  = SurveyBuilder.new
    executor = SurveyInteractiveText.new
    builder.process_survey(["survey.rb"],executor)
    puts builder.survey_answers.inspect
    builder
  end

end
