# 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