lib/tapioca/helpers/config_helper.rb in tapioca-0.6.1 vs lib/tapioca/helpers/config_helper.rb in tapioca-0.6.2

- old
+ new

@@ -4,11 +4,14 @@ require "yaml" 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 @@ -58,10 +61,123 @@ 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? + print_errors(config_file, errors) + exit(1) + 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.map do |config_option_key, config_option_value| + command_option = command_options[config_option_key.to_sym] + + unless command_option + next build_error("unknown option `#{config_option_key}` for key `#{config_key}`") + end + + 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 + + unless config_option_value_type == command_option.type + next build_error("invalid value for option `#{config_option_key}` for key `#{config_key}` " \ + "- expected `#{command_option.type.capitalize}` but found #{config_option_value_type.capitalize}") + end + end.compact + 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]).void } + def print_errors(config_file, errors) + say_error("\nConfiguration file ", :red) + say_error("#{config_file} ", :blue, :bold) + say_error("has the following errors:\n\n", :red) + + errors.each do |error| + say_error("- ") + error.message_parts.each do |part| + T.unsafe(self).say_error(part.message, *part.colors) + end + end end sig do params(options: T.nilable(Thor::CoreExt::HashWithIndifferentAccess)) .returns(Thor::CoreExt::HashWithIndifferentAccess)