class SpecParser class Exception < ::Exception end macro incompatible_methods(*names) {% for name in names %} {% if name.id == "as_s" %} def {{name}} : String raise "short string expected; which does not exist in #{self.class}" end {% elsif name.id == "as_a_or_s" %} def {{name}} : Array(String) raise "list or string expected; which does not exist in #{self.class}" end {% elsif name.id == "as_s_or_ls" %} def {{name}} : String raise "string or multiline text section expected; which does not exist in #{self.class}" end {% end %} {% end %} end class StringContainer property value : String def as_s : String @value end def as_a_or_s : Array(String) # FIXME: We should probably be splitting the string around comas. @value.split(",").map(&.gsub /(^ *| *$)/, "") end def as_s_or_ls : String @value end def initialize(@value) end end class LongStringContainer SpecParser.incompatible_methods as_s, as_a_or_s property value : String def as_s_or_ls : String @value end def initialize(@value) end end class ArrayContainer SpecParser.incompatible_methods as_s, as_s_or_ls property value : Array(String) def as_a_or_s : Array(String) @value end def initialize(@value) end end class Section SpecParser.incompatible_methods as_s, as_a_or_s, as_s_or_ls property name : String property options : Array(String) property content : Hash(String, StringContainer | ArrayContainer | LongStringContainer) def initialize(@name) @options = Array(String).new @content = Hash(String, StringContainer | ArrayContainer | LongStringContainer).new end end property assignments : Hash(String, StringContainer | LongStringContainer | ArrayContainer) property sections : Array(Section) property current_line_number = 0 def initialize @assignments = Hash(String, StringContainer | LongStringContainer | ArrayContainer).new @sections = Array(Section).new end def parse_assignment (line : String, content : Array(String)) # puts "simple assignment: #{line}" name = /([a-zA-Z][a-zA-Z0-9-_]*):/.match(line).try &.[1] value = /[a-zA-Z][a-zA-Z0-9-_]*: ([^#]*)/.match(line).try &.[1] if name.nil? return end if value.nil? return end # In case the content is over several lines. value = grab_content content, value @assignments[name] = StringContainer.new value.lstrip(" ").rstrip(" ") end def parse_list(header : String, content : Array(String)) name = /[a-zA-Z][a-zA-Z0-9-_]*/.match(header).try &.[0] # puts "new list: #{name}" list = Array(String).new while line = content.shift? @current_line_number += 1 case line when /^[ \t]*(#.*)?$/ # puts "blank line or comment, still in a list" when /^[ \t]+-([^#]*)/ # puts "line content: #{$~[1]}" current_line = $~[1].lstrip(" ") list.push grab_content(content, current_line) else content.unshift line @current_line_number -= 1 break end end if name.nil? return end if list.nil? return end @assignments[name] = ArrayContainer.new list end def parse_code_block(header : String, content : Array(String)) name = /[a-zA-Z][a-zA-Z0-9-_]*/.match(header).try &.[0] # puts "new code block: #{name}" value = String.build do |str| while line = content.shift? @current_line_number += 1 case line when /^[ \t]*(#.*)?$/ # puts "blank line or comment, still in a code block" when /^[ \t]+(.*)/ # puts "code content: #{$~[1]}" # old version: str << "#{$~[1].lstrip(" ").rstrip(" ")}" # new version does not change the line (better shell integration) str << "#{$~[1]}\n" else content.unshift line @current_line_number -= 1 break end end end if name.nil? return end if value.nil? return end @assignments[name] = LongStringContainer.new value end def parse_section(header : String, content : Array(String)) results = /^[%]([a-zA-Z][a-zA-Z0-9-_]*)[ \t]*([^#]*)/.match(header) name = results[1]? if results options = results[2]? if results if name.nil? return end section = Section.new name unless options.nil? section.options = options.split(",").map &.lstrip(" ").rstrip(" ") end while line = content.shift? @current_line_number += 1 case line when /^[ \t]*#.*$/ # puts "blank line or comment, still in a section" when /^([ \t]+)([^:]+):[ \t]+(.+)/ aname = $~[2].lstrip(" ").rstrip(" ") avalue = $~[3].lstrip(" ").rstrip(" ") # puts "simple assignation: #{aname} => #{avalue}" section.content[aname] = StringContainer.new avalue when /^([ \t]+)[@]([^ \t#]+)/ # free text indent = $~[1] codeblockname = $~[2].lstrip(" ").rstrip(" ") # puts "code block name: #{codeblockname}" codeblockvalue = String.build do |str| while line = content.shift? @current_line_number += 1 case line when /^[ \t]*#.*?$/ # puts "blank line or comment, still in a free text in a section" when /#{indent}[ \t]+(.*)/ # puts "code content in section: #{$~[1]}" str << "#{$~[1]}\n" else content.unshift line @current_line_number -= 1 break end end end section.content[codeblockname] = LongStringContainer.new codeblockvalue when /^([ \t]+)([^:]+):/ indent = $~[1] lname = $~[2].lstrip(" ").rstrip(" ") # puts "list: #{lname}" list = Array(String).new while line = content.shift? @current_line_number += 1 case line when /^[ \t]*#.*?$/ # puts "blank line or comment, still in a list in a section" when /^#{indent}[ \t]+-([^#]*)/ lcontent = $~[1].lstrip(" ").rstrip(" ") # puts "list item in section: #{lcontent}" list.push lcontent else content.unshift line @current_line_number -= 1 break end end if list.empty? next end section.content[lname] = ArrayContainer.new list else content.unshift line @current_line_number -= 1 break end end sections.push section end # Remove \n when line ends with a backslash. def grab_content(content : Array(String), current_value : String) while current_value =~ /\\$/ # Remove trailing backslash current_value = current_value.rstrip "\\" nl = content.shift? if nl @current_line_number += 1 current_value += nl.lstrip else raise Exception.new "last line is ending with a backslash" end end current_value end def parse_lines(content : Array(String)) while line = content.shift? @current_line_number += 1 case line when /^[a-zA-Z][a-zA-Z0-9-_]*:[ \t]*([#].*)?$/ parse_list line, content when /^[a-zA-Z][a-zA-Z0-9-_]*:[ \t]+[^#]+/ parse_assignment line, content when /^[@][a-zA-Z][a-zA-Z0-9-_]*[ \t]*([#].*)?/ parse_code_block line, content when /^[%][a-zA-Z][a-zA-Z0-9-_]*[ \t]*([#].*)?/ parse_section line, content when /^[ \t]*#/ # puts "comment" when /^[ \t]+$/ # puts "empty line" when /^[ \t]+[^ \t#]/ raise Exception.new "line #{@current_line_number}: line starting with spaces or a tabulation outside a list or a section: should not happen" end end end def replace_string (str : String) reg = /%\{([^}]*)\}/ while str =~ reg x = reg.match(str) unless x.nil? var = x.captures() if var[0]? && @assignments[var[0]]? replacement_object = @assignments[var[0]] case replacement_object when StringContainer replacement_value = replacement_object.value str = str.gsub "%{#{var[0]}}", "#{replacement_value}" end else # TODO: search in sections for assignments? raise "cannot find variable \033[31m#{var[0]}\033[00m" end end end str end def replace_string_obj (v : StringContainer | LongStringContainer) is_missing_references = false value = v.value begin v.value = replace_string value rescue e puts "#{e}" is_missing_references = true end if is_missing_references raise "there are missing references in the document: fix it" end end def replace_array (a : Array(String)) newarray = Array(String).new is_missing_references = false a.each do |value| begin newarray.push replace_string(value) rescue e puts "#{e}" is_missing_references = true end end if is_missing_references raise "there are missing references in the document: fix it" end newarray end def replace_array_obj (v : ArrayContainer) str_array = v.value newarray = replace_array str_array v.value = newarray end def replace_section_obj (v : Section) is_missing_references = false v.options = replace_array v.options v.content.map do |k, v| case v when StringContainer replace_string_obj v when LongStringContainer replace_string_obj v when ArrayContainer replace_array_obj v end end end def rewrite # replaces all occurrences of %{variable} by the content of @assignments[variable] @assignments.map do |k, v| case v when StringContainer replace_string_obj v when LongStringContainer replace_string_obj v when ArrayContainer replace_array_obj v end end sections.each do |section| replace_section_obj section end end # # public functions # def self.parse_file(file_name : String, options : Hash(String, String) | Nil = nil) : SpecParser content = File.read(file_name) content = content.rchop specs = parse content, options end def self.parse(data : String, options : Hash(String, String) | Nil = nil) : SpecParser specs = SpecParser.new unless options.nil? options.each do |opt, val| specs.assignments[opt] = StringContainer.new val end end specs.parse_lines (data.split("\n")) specs.rewrite specs rescue e raise Exception.new "unexpected parser error: #{e}, current line #{specs.not_nil!.current_line_number}" end end