neilslater/games_dice

View on GitHub
lib/games_dice/parser.rb

Summary

Maintainability
A
40 mins
Test Coverage
# frozen_string_literal: true

require 'parslet'

module GamesDice
  # Based on the parslet gem, this class defines the dice mini-language used by GamesDice.create
  #
  # An instance of this class is a parser for the language. There are no user-definable instance
  # variables.
  #
  class Parser < Parslet::Parser
    # Parslet rules that define the dice string grammar.
    rule(:integer) { match('[0-9]').repeat(1) }
    rule(:plus_minus_integer) { (match('[+-]') >> integer) | integer }
    rule(:range) { integer.as(:range_start) >> str('..') >> integer.as(:range_end) }
    rule(:dlabel) { match('[d]') }
    rule(:space) { match('\s').repeat(1) }
    rule(:space?) { space.maybe }
    rule(:underscore) { str('_').repeat(1) }
    rule(:underscore?) { space.maybe }

    rule(:bunch_start) { integer.as(:ndice) >> dlabel >> integer.as(:sides) }

    rule(:reroll_label) { match(['r']).as(:reroll) }
    rule(:keep_label) { match(['k']).as(:keep) }
    rule(:map_label) { match(['m']).as(:map) }
    rule(:alias_label) { match(['x']).as(:alias) }

    rule(:single_modifier) { alias_label }
    rule(:modifier_label) {  reroll_label | keep_label | map_label }
    rule(:simple_modifier) { modifier_label >> integer.as(:simple_value) }
    rule(:comparison_op) { str('>=') | str('<=') | str('==') | str('>') | str('<') }
    rule(:ctl_string) { match('[a-z_]').repeat(1) }
    rule(:output_string) { match('[A-Za-z0-9_]').repeat(1) }

    rule(:opint_or_int) { (comparison_op.as(:comparison) >> integer.as(:compare_num)) | integer.as(:compare_num) }
    rule(:comma) { str(',') }
    rule(:stop) { str('.') }

    rule(:condition_only) { opint_or_int.as(:condition) }
    rule(:num_only) { integer.as(:num) }

    rule(:condition_and_type) { opint_or_int.as(:condition) >> comma >> ctl_string.as(:type) }
    rule(:condition_and_num) { opint_or_int.as(:condition) >> comma >> plus_minus_integer.as(:num) }

    rule(:condition_type_and_num) do
      opint_or_int.as(:condition) >> comma >> ctl_string.as(:type) >> comma >> integer.as(:num)
    end

    rule(:condition_num_and_output) do
      opint_or_int.as(:condition) >> comma >> plus_minus_integer.as(:num) >> comma >> output_string.as(:output)
    end

    rule(:num_and_type) { integer.as(:num) >> comma >> ctl_string.as(:type) }

    rule(:reroll_params) { condition_type_and_num | condition_and_type | condition_only }
    rule(:map_params) { condition_num_and_output | condition_and_num | condition_only }
    rule(:keeper_params) { num_and_type | num_only }

    rule(:full_reroll) { reroll_label >> str(':') >> reroll_params >> stop }
    rule(:full_map) { map_label >> str(':') >> map_params >> stop }
    rule(:full_keepers) { keep_label >> str(':') >> keeper_params >> stop }

    rule(:complex_modifier) { full_reroll | full_map | full_keepers }

    rule(:bunch_modifier) { complex_modifier | (single_modifier >> stop.maybe) | (simple_modifier >> stop.maybe) }
    rule(:bunch) { bunch_start >> bunch_modifier.repeat.as(:mods) }

    rule(:operator) { match('[+-]').as(:op) >> space? }
    rule(:add_bunch) { operator >> bunch >> space? }
    rule(:add_constant) { operator >> integer.as(:constant) >> space? }
    rule(:dice_expression) { add_bunch | add_constant }
    rule(:expressions) { dice_expression.repeat.as(:bunches) }
    root :expressions

    # Parses a string description in the dice mini-language, and returns data for feeding into
    # GamesDice::Dice constructor.
    # @param [String] dice_description Text to parse e.g. '1d6'
    # @return [Hash] Analysis of dice_description
    def parse(dice_description)
      dice_description = dice_description.to_s.strip
      # Force first item to start '+' for simpler parse rules
      dice_description = "+#{dice_description}" unless dice_description =~ /\A[+-]/
      dice_expressions = super(dice_description)
      {
        bunches: ParseTreeProcessor.collect_bunches(dice_expressions),
        offset: ParseTreeProcessor.collect_offset(dice_expressions)
      }
    end

    # Class converts parse tree to GamesDice hash model
    # @!visibility private
    class ParseTreeProcessor
      class << self
        def collect_bunches(dice_expressions)
          dice_expressions[:bunches].select { |h| h[:ndice] }.map do |in_hash|
            out_hash = {}
            collect_bunch_basics(in_hash, out_hash)
            collect_bunch_multiplier(in_hash, out_hash) if in_hash[:op]

            in_hash[:mods]&.each do |mod|
              collect_bunch_modifier(mod, out_hash)
            end

            out_hash
          end
        end

        def collect_bunch_basics(in_hash, out_hash)
          %i[ndice sides].each do |s|
            next unless in_hash[s]

            out_hash[s] = in_hash[s].to_i
          end
        end

        def collect_bunch_multiplier(in_hash, out_hash)
          optype = in_hash[:op].to_s
          out_hash[:multiplier] = case optype
                                  when '+' then 1
                                  when '-' then -1
                                  end
        end

        def collect_bunch_modifier(mod, out_hash)
          if mod[:alias]
            ParseTreeBunchModifier.collect_alias_modifier mod, out_hash
          elsif mod[:keep]
            ParseTreeBunchModifier.collect_keeper_rule mod, out_hash
          elsif mod[:map]
            ParseTreeBunchModifier.collect_map_rule mod, out_hash
          elsif mod[:reroll]
            ParseTreeBunchModifier.collect_reroll_rule mod, out_hash
          end
        end

        def collect_offset(dice_expressions)
          dice_expressions[:bunches].select { |h| h[:constant] }.inject(0) do |total, in_hash|
            c = in_hash[:constant].to_i
            optype = in_hash[:op].to_s
            if optype == '+'
              total + c
            else
              total - c
            end
          end
        end
      end
    end

    # Class for collating bunch data into bunch construction hash
    # @!visibility private
    class ParseTreeBunchModifier
      class << self
        # Called when we have a single letter convenient alias for common dice adjustments
        def collect_alias_modifier(alias_mod, out_hash)
          alias_name = alias_mod[:alias].to_s
          case alias_name
          when 'x' # Exploding re-roll
            out_hash[:rerolls] ||= []
            out_hash[:rerolls] << [out_hash[:sides], :==, :reroll_add]
          end
        end

        # Called for any parsed reroll rule
        def collect_reroll_rule(reroll_mod, out_hash)
          out_hash[:rerolls] ||= []
          if reroll_mod[:simple_value]
            out_hash[:rerolls] << [reroll_mod[:simple_value].to_i, :>=, :reroll_replace]
            return
          end

          collect_complex_reroll_rule(reroll_mod, out_hash)
        end

        def collect_complex_reroll_rule(reroll_mod, out_hash)
          # Typical reroll_mod: {:reroll=>"r"@5, :condition=>{:compare_num=>"10"@7}, :type=>"add"@10}
          op = get_op_symbol(reroll_mod[:condition][:comparison] || '==')
          v = reroll_mod[:condition][:compare_num].to_i
          type = "reroll_#{reroll_mod[:type] || 'replace'}".to_sym

          out_hash[:rerolls] << if reroll_mod[:num]
                                  [v, op, type, reroll_mod[:num].to_i]
                                else
                                  [v, op, type]
                                end
        end

        # Called for any parsed keeper mode
        def collect_keeper_rule(keeper_mod, out_hash)
          raise 'Cannot set keepers for a bunch twice' if out_hash[:keep_mode]

          if keeper_mod[:simple_value]
            out_hash[:keep_mode] = :keep_best
            out_hash[:keep_number] = keeper_mod[:simple_value].to_i
            return
          end

          # Typical keeper_mod: {:keep=>"k"@5, :num=>"1"@7, :type=>"worst"@9}
          out_hash[:keep_number] = keeper_mod[:num].to_i
          out_hash[:keep_mode] = "keep_#{keeper_mod[:type] || 'best'}".to_sym
        end

        # Called for any parsed map mode
        def collect_map_rule(map_mod, out_hash)
          out_hash[:maps] ||= []
          if map_mod[:simple_value]
            out_hash[:maps] << [map_mod[:simple_value].to_i, :<=, 1]
            return
          end

          collect_complex_map_rule(map_mod, out_hash)
        end

        def collect_complex_map_rule(map_mod, out_hash)
          # Typical map_mod: {:map=>"m"@4, :condition=>{:compare_num=>"5"@6}, :num=>"2"@8, :output=>"Qwerty"@10}
          op = get_op_symbol(map_mod[:condition][:comparison] || '>=')
          v = map_mod[:condition][:compare_num].to_i
          out_val = 1
          out_val = map_mod[:num].to_i if map_mod[:num]

          out_hash[:maps] << if map_mod[:output]
                               [v, op, out_val, map_mod[:output].to_s]
                             else
                               [v, op, out_val]
                             end
        end

        # The dice description language uses (r).op.x, whilst GamesDice::RerollRule uses x.op.(r), so
        # as well as converting to a symbol, we must reverse sense of input to constructor
        OP_CONVERSION = {
          '==' => :==,
          '>=' => :<=,
          '>' => :<,
          '<' => :>,
          '<=' => :>=
        }.freeze

        def get_op_symbol(parsed_op_string)
          OP_CONVERSION[parsed_op_string.to_s]
        end
      end
    end
  end
end