# 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