lib/games_dice/parser.rb in games_dice-0.3.12 vs lib/games_dice/parser.rb in games_dice-0.4.0

- old
+ new

@@ -1,218 +1,219 @@ -require 'parslet' - -# 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 GamesDice::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) { opint_or_int.as(:condition) >> comma >> ctl_string.as(:type) >> comma >> integer.as(:num) } - rule(:condition_num_and_output) { opint_or_int.as(:condition) >> comma >> plus_minus_integer.as(:num) >> comma >> output_string.as(:output) } - 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 => collect_bunches( dice_expressions ), :offset => collect_offset( dice_expressions ) } - end - - private - - def collect_bunches dice_expressions - dice_expressions[:bunches].select {|h| h[:ndice] }.map do |in_hash| - out_hash = {} - # Convert integers - [:ndice, :sides].each do |s| - next unless in_hash[s] - out_hash[s] = in_hash[s].to_i - end - - # Multiplier - if in_hash[:op] - optype = in_hash[:op].to_s - out_hash[:multiplier] = case optype - when '+' then 1 - when '-' then -1 - end - end - - # Modifiers - if in_hash[:mods] - in_hash[:mods].each do |mod| - case - when mod[:alias] - collect_alias_modifier mod, out_hash - when mod[:keep] - collect_keeper_rule mod, out_hash - when mod[:map] - out_hash[:maps] ||= [] - collect_map_rule mod, out_hash - when mod[:reroll] - out_hash[:rerolls] ||= [] - collect_reroll_rule mod, out_hash - end - end - end - - 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 - total - end - end - - # 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 - - # 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 - - if reroll_mod[:num] - out_hash[:rerolls] << [ v, op, type, reroll_mod[:num].to_i ] - else - out_hash[:rerolls] << [ 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 - - # 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 - if map_mod[:num] - out_val = map_mod[:num].to_i - end - - if map_mod[:output] - out_hash[:maps] << [ v, op, out_val, map_mod[:output].to_s ] - else - out_hash[:maps] << [ 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 = { - '==' => :==, - '>=' => :<=, - '>' => :<, - '<' => :>, - '<=' => :>=, - } - - def get_op_symbol parsed_op_string - OP_CONVERSION[ parsed_op_string.to_s ] - end - -end # class Parser +# 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: collect_bunches(dice_expressions), offset: collect_offset(dice_expressions) } + end + + private + + def collect_bunches(dice_expressions) + dice_expressions[:bunches].select { |h| h[:ndice] }.map do |in_hash| + out_hash = {} + # Convert integers + %i[ndice sides].each do |s| + next unless in_hash[s] + + out_hash[s] = in_hash[s].to_i + end + + # Multiplier + if in_hash[:op] + optype = in_hash[:op].to_s + out_hash[:multiplier] = case optype + when '+' then 1 + when '-' then -1 + end + end + + # Modifiers + in_hash[:mods]&.each do |mod| + if mod[:alias] + collect_alias_modifier mod, out_hash + elsif mod[:keep] + collect_keeper_rule mod, out_hash + elsif mod[:map] + out_hash[:maps] ||= [] + collect_map_rule mod, out_hash + elsif mod[:reroll] + out_hash[:rerolls] ||= [] + collect_reroll_rule mod, out_hash + end + end + + 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 + total + end + end + + # 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 + + # 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 + + # 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