# typed: strict # frozen_string_literal: true module Tapioca module ConfigHelper extend T::Sig extend T::Helpers requires_ancestor { Thor } sig { returns(String) } attr_reader :command_name sig { returns(Thor::CoreExt::HashWithIndifferentAccess) } attr_reader :defaults sig { params(args: T.untyped, local_options: T.untyped, config: T.untyped).void } def initialize(args = [], local_options = {}, config = {}) # Store current command command = config[:current_command] command_options = config[:command_options] @command_name = T.let(command.name, String) @merged_options = T.let(nil, T.nilable(Thor::CoreExt::HashWithIndifferentAccess)) @defaults = T.let(Thor::CoreExt::HashWithIndifferentAccess.new, Thor::CoreExt::HashWithIndifferentAccess) # Filter command options unless we are handling the help command. # This is so that the defaults are printed filter_defaults(command_options) unless command_name == "help" super end sig { returns(Thor::CoreExt::HashWithIndifferentAccess) } def options @merged_options ||= begin original_options = super config_options = config_options(original_options) merge_options(defaults, config_options, original_options) end end private sig { params(options: T::Hash[Symbol, Thor::Option]).void } def filter_defaults(options) options.each do |key, option| # Store the value of the current default in our defaults hash defaults[key] = option.default # Remove the default value from the option option.instance_variable_set(:@default, nil) end end sig { params(options: Thor::CoreExt::HashWithIndifferentAccess).returns(Thor::CoreExt::HashWithIndifferentAccess) } def config_options(options) config_file = options[:config] config = {} if File.exist?(config_file) config = YAML.load_file(config_file, fallback: {}) end validate_config!(config_file, config) Thor::CoreExt::HashWithIndifferentAccess.new(config[command_name] || {}) end sig { params(config_file: String, config: T::Hash[T.untyped, T.untyped]).void } def validate_config!(config_file, config) # To ensure that this is not re-entered, we mark during validation return if @validating_config @validating_config = T.let(true, T.nilable(T::Boolean)) commands = T.cast(self, Thor).class.commands errors = config.flat_map do |config_key, config_options| command = commands[config_key.to_s] unless command next build_error("unknown key `#{config_key}`") end validate_config_options(command.options, config_key, config_options || {}) end.compact unless errors.empty? raise Thor::Error, build_error_message(config_file, errors) end ensure @validating_config = false end sig do params( command_options: T::Hash[Symbol, Thor::Option], config_key: String, config_options: T::Hash[T.untyped, T.untyped], ).returns(T::Array[ConfigError]) end def validate_config_options(command_options, config_key, config_options) config_options.filter_map do |config_option_key, config_option_value| command_option = command_options[config_option_key.to_sym] error_msg = "unknown option `#{config_option_key}` for key `#{config_key}`" next build_error(error_msg) unless command_option config_option_value_type = case config_option_value when FalseClass, TrueClass :boolean when Numeric :numeric when Hash :hash when Array :array when String :string else :object end error_msg = "invalid value for option `#{config_option_key}` for key `#{config_key}` - expected " \ "`#{command_option.type.capitalize}` but found #{config_option_value_type.capitalize}" next build_error(error_msg) unless config_option_value_type == command_option.type case config_option_value_type when :array error_msg = "invalid value for option `#{config_option_key}` for key `#{config_key}` - expected " \ "`Array[String]` but found `#{config_option_value}`" next build_error(error_msg) unless config_option_value.all? { |v| v.is_a?(String) } when :hash error_msg = "invalid value for option `#{config_option_key}` for key `#{config_key}` - expected " \ "`Hash[String, String]` but found `#{config_option_value}`" all_strings = (config_option_value.keys + config_option_value.values).all? { |v| v.is_a?(String) } next build_error(error_msg) unless all_strings end end end class ConfigErrorMessagePart < T::Struct const :message, String const :colors, T::Array[Symbol] end class ConfigError < T::Struct const :message_parts, T::Array[ConfigErrorMessagePart] end sig { params(msg: String).returns(ConfigError) } def build_error(msg) parts = msg.split(/(`[^`]+` ?)/) message_parts = parts.map do |part| match = part.match(/`([^`]+)`( ?)/) if match ConfigErrorMessagePart.new( message: "#{match[1]}#{match[2]}", colors: [:bold, :blue], ) else ConfigErrorMessagePart.new( message: part, colors: [:yellow], ) end end ConfigError.new( message_parts: message_parts, ) end sig { params(config_file: String, errors: T::Array[ConfigError]).returns(String) } def build_error_message(config_file, errors) error_messages = errors.map do |error| "- " + error.message_parts.map do |part| T.unsafe(self).set_color(part.message, *part.colors) end.join end.join("\n") <<~ERROR #{set_color("\nConfiguration file", :red)} #{set_color(config_file, :blue, :bold)} #{set_color("has the following errors:", :red)} #{error_messages} ERROR end sig do params(options: T.nilable(Thor::CoreExt::HashWithIndifferentAccess)) .returns(Thor::CoreExt::HashWithIndifferentAccess) end def merge_options(*options) merged = options.each_with_object({}) do |option, result| result.merge!(option || {}) do |_, this_val, other_val| if this_val.is_a?(Hash) && other_val.is_a?(Hash) Thor::CoreExt::HashWithIndifferentAccess.new(this_val.merge(other_val)) else other_val end end end Thor::CoreExt::HashWithIndifferentAccess.new(merged) end end end