lib/holidays/definition/context/generator.rb



require 'yaml'

#FIXME This whole file is my next refactor target. We do wayyyyy too much by
#      convention here. We need hard and fast rules and explicit errors when you
#      try to parse something that isn't allowed. So if you are a dev recognize
#      that a lot of the guard statements in here are to codify existing legacy
#      logic. The fact is that we already require these guards, we just don't
#      enforce it explicitly. Now we will. And by doing so things will begin
#      to look very, very messy.
module Holidays
  module Definition
    module Context
      class Generator
        def initialize(custom_method_parser, custom_method_source_decorator, custom_methods_repository, test_parser, test_source_generator, module_source_generator)
          @custom_method_parser = custom_method_parser
          @custom_method_source_decorator = custom_method_source_decorator
          @custom_methods_repository = custom_methods_repository
          @test_parser = test_parser
          @test_source_generator = test_source_generator
          @module_source_generator = module_source_generator
        end

        def parse_definition_files(files)
          raise ArgumentError, "Must have at least one file to parse" if files.nil? || files.empty?

          all_regions = []
          all_rules_by_month = {}
          all_custom_methods = {}
          all_tests = []

          files.flatten!

          files.each do |file|
            definition_file = YAML.load_file(file)

            custom_methods = @custom_method_parser.call(definition_file['methods'])

            regions, rules_by_month = parse_month_definitions(definition_file['months'], custom_methods)

            all_regions << regions.flatten

            all_rules_by_month.merge!(rules_by_month) { |month, existing, new|
              existing << new
              existing.flatten!
            }

            #FIXME This is a problem. We will have a 'global' list of methods. That's always bad. What effects will this have?
            # This is an existing problem (just so we are clear). An issue would be extremely rare because we are generally parsing
            # single files/custom files. But it IS possible that we would parse a bunch of things at the same time and step
            # on each other so we need a solution.
            all_custom_methods.merge!(custom_methods)

            all_tests += @test_parser.call(definition_file['tests'])
          end

          all_regions.flatten!.uniq!

          [all_regions, all_rules_by_month, all_custom_methods, all_tests]
        end

        def generate_definition_source(module_name, files, regions, rules_by_month, custom_methods, tests)
          month_strings = generate_month_definition_strings(rules_by_month, custom_methods)

          # Build the custom methods string
          custom_method_string = ''
          custom_methods.each do |key, code|
            custom_method_string << @custom_method_source_decorator.call(code) + ",\n\n"
          end

          module_src = @module_source_generator.call(module_name, files, regions, month_strings, custom_method_string)
          test_src = @test_source_generator.call(module_name, files, tests)

          return module_src, test_src || ''
        end

        private

        #FIXME This should be a 'month_definitions_parser' like the above parser
        def parse_month_definitions(month_definitions, parsed_custom_methods)
          regions = []
          rules_by_month = {}

          if month_definitions
            month_definitions.each do |month, definitions|
              rules_by_month[month] = [] unless rules_by_month[month]
              definitions.each do |definition|
                rule = {}

                definition.each do |key, val|
                  # Ruby 2.4 doesn't have the `transform_keys` method. Once we drop 2.4 support we can
                  # use `val.transform_keys!(&:to_sym) if val.is_a?(Hash)` instead of this `if` statement.
                  if val.is_a?(Hash)
                    val = val.keys.each_with_object({}) do |k, result|
                      result[k.to_sym] = val[k]
                    end
                  end

                  rule[key.to_sym] = val
                end

                if rule[:year_ranges] && rule[:year_ranges].key?(:between)
                  start_year = rule[:year_ranges][:between]["start"].to_i
                  end_year = rule[:year_ranges][:between]["end"].to_i

                  rule[:year_ranges][:between] = Range.new(start_year, end_year)
                end

                rule[:regions] = rule[:regions].collect { |r| r.to_sym }
                regions << rule[:regions]

                exists = false
                rules_by_month[month].each do |ex|
                  if ex[:name] == rule[:name] and ex[:wday] == rule[:wday] and ex[:mday] == rule[:mday] and ex[:week] == rule[:week] and ex[:type] == rule[:type] and ex[:function] == rule[:function] and ex[:observed] == rule[:observed] and ex[:year_ranges] == rule[:year_ranges]
                    ex[:regions] << rule[:regions].flatten
                    exists = true
                  end
                end

                unless exists
                  # This will add in the custom method arguments so they are immediately
                  # available for 'on the fly' def loading.
                  if rule[:function]
                    rule[:function_arguments] = get_function_arguments(rule[:function], parsed_custom_methods)
                  end

                  rules_by_month[month] << rule
                end
              end
            end
          end

          [regions, rules_by_month]
        end

        #FIXME This should really be split out and tested with its own unit tests.
        def generate_month_definition_strings(rules_by_month, parsed_custom_methods)
          month_strings = []

          rules_by_month.each do |month, rules|
            month_string = "      #{month.to_s} => ["
            rule_strings = []
            rules.each do |rule|
              string = '{'
              if rule[:mday]
                string << ":mday => #{rule[:mday]}, "
              end

              if rule[:function]
                string << ":function => \"#{rule[:function].to_s}\", "

                # We need to add in the arguments so we can know what to send in when calling the custom proc during holiday lookups.
                # NOTE: the allowed arguments are enforced in the custom methods parser.
                string << ":function_arguments => #{get_function_arguments(rule[:function], parsed_custom_methods)}, "

                if rule[:function_modifier]
                  string << ":function_modifier => #{rule[:function_modifier].to_s}, "
                end
              end

              # This is the 'else'. It is possible for mday AND function
              # to be set but this is the fallback. This whole area
              # needs to be reworked!
              if string == '{'
                string << ":wday => #{rule[:wday]}, :week => #{rule[:week]}, "
              end

              if rule[:year_ranges] && rule[:year_ranges].is_a?(Hash)
                selector = rule[:year_ranges].keys.first
                value = rule[:year_ranges][selector]

                string << ":year_ranges => { :#{selector} => #{value} },"
              end

              if rule[:observed]
                string << ":observed => \"#{rule[:observed].to_s}\", "
                string << ":observed_arguments => #{get_function_arguments(rule[:observed], parsed_custom_methods)}, "
              end

              if rule[:type]
                string << ":type => :#{rule[:type]}, "
              end

              # shouldn't allow the same region twice
              string << ":name => \"#{rule[:name]}\", :regions => [:" + rule[:regions].uniq.join(', :') + "]}"
              rule_strings << string
            end
            month_string << rule_strings.join(",\n            ") + "]"
            month_strings << month_string
          end

          return month_strings
        end

        # This method sucks. The issue here is that the custom methods repo has the 'general' methods (like easter)
        # but the 'parsed_custom_methods' have the recently parsed stuff. We don't load those until they are needed later.
        # This entire file is a refactor target so I am adding some tech debt to get me over the hump.
        # What we should do is ensure that all custom methods are loaded into the repo as soon as they are parsed
        # so we only have one place to look.
        def get_function_arguments(function_id, parsed_custom_methods)
          if method = @custom_methods_repository.find(function_id)
            method.parameters.collect { |arg| arg[1] }
          elsif method = parsed_custom_methods[function_id]
            method.arguments.collect { |arg| arg.to_sym }
          end
        end
      end
    end
  end
end