diff --git a/lib/super_slim/linter.rb b/lib/super_slim/linter.rb index 854d0e5..9541295 100644 --- a/lib/super_slim/linter.rb +++ b/lib/super_slim/linter.rb @@ -76,6 +76,15 @@ def each_tag(&) = tag_visits.each(&) # The layout rules: what `super-slim format` rewrites. LAYOUT_RULE_NAMES = %w[Indentation Tab TrailingWhitespace EmptyLines ControlStatementSpacing RedundantDiv].freeze GLOB_OPTIONS = %w[include exclude].freeze + # What the default of a rule's option asks of the value that replaces it. + VALUE_KIND_BY_DEFAULT_CLASS = { + TrueClass => ["true or false", [TrueClass, FalseClass]], + FalseClass => ["true or false", [TrueClass, FalseClass]], + Integer => ["a whole number", [Integer]], + # Left empty under a preset, `classes:` is nil. + Hash => ["a mapping", [Hash, NilClass]], + Array => ["a list", [Array]] + }.freeze def self.load(directory = Dir.pwd) new(configuration(directory).fetch("linters", {}), directory) @@ -88,22 +97,48 @@ def self.configuration(directory = Dir.pwd) configuration end - # A value of the wrong shape would fail later, inside a rule, with a NoMethodError naming none of this file. + # A value of the wrong shape would fail later, inside a rule or the formatter, on every template and with an error + # naming none of this file: `max: "120"` compares a number with text. def self.check_configuration(configuration) raise Error, "#{CONFIG_FILE}: expected a mapping of settings" unless configuration.is_a?(Hash) %w[linters format fmt].each do |key| raise Error, "#{CONFIG_FILE}: `#{key}` must be a mapping" unless configuration.fetch(key, {}).is_a?(Hash) end - configuration.fetch("linters", {}).each do |rule_name, options| - raise Error, "#{CONFIG_FILE}: `linters: #{rule_name}` must be a mapping of options" unless options.is_a?(Hash) + raise Error, "#{CONFIG_FILE}: `tailwind_classes` must be true or false" unless [true, false, nil].include?(configuration["tailwind_classes"]) - GLOB_OPTIONS.each do |key| - globs = Array(options[key]) - raise Error, "#{CONFIG_FILE}: `#{rule_name}: #{key}` must be a list of globs" unless globs.all?(String) - end + configuration.fetch("linters", {}).each { |rule_name, options| check_rule_options(rule_name, options) } + configuration.slice("format", "fmt").each { |key, options| check_format_options(key, options) } + end + + def self.check_rule_options(rule_name, options) + raise Error, "#{CONFIG_FILE}: `linters: #{rule_name}` must be a mapping of options" unless options.is_a?(Hash) + + GLOB_OPTIONS.each do |key| + globs = Array(options[key]) + raise Error, "#{CONFIG_FILE}: `#{rule_name}: #{key}` must be a list of globs" unless globs.all?(String) + end + rule_class = Rules.all.find { _1.rule_name == rule_name } or return + rule_class::DEFAULTS.each do |key, default| + next unless options.key?(key.to_s) + + kind, classes = VALUE_KIND_BY_DEFAULT_CLASS.fetch(default.class) + raise Error, "#{CONFIG_FILE}: `#{rule_name}: #{key}` must be #{kind}" unless classes.any? { options[key.to_s].is_a?(_1) } end end + private_class_method :check_rule_options + + def self.check_format_options(key, options) + max_line_length = options["max_line_length"] + wraps_nothing_or_at_a_length = max_line_length.nil? || (max_line_length.is_a?(Integer) && max_line_length.positive?) + raise Error, "#{CONFIG_FILE}: `#{key}: max_line_length` must be a whole number above 0" unless wraps_nothing_or_at_a_length + + delimiters = Formatter::LineWrapper::OPENING_BY_ATTRIBUTE_DELIMITERS.keys + return if !options.key?("attribute_delimiters") || delimiters.include?(options["attribute_delimiters"]) + + raise Error, "#{CONFIG_FILE}: `#{key}: attribute_delimiters` must be one of #{delimiters.join(", ")}" + end + private_class_method :check_format_options # include: and exclude: globs match paths relative to `root`, the directory of .super-slim.yml. def initialize(options_by_rule_name = {}, root = Dir.pwd) diff --git a/test/cli_test.rb b/test/cli_test.rb index 016839d..75e2763 100644 --- a/test/cli_test.rb +++ b/test/cli_test.rb @@ -99,6 +99,14 @@ def test_a_configuration_of_the_wrong_shape_fails_with_a_usage_error { "linters: 5\n" => ".super-slim.yml: `linters` must be a mapping", "linters:\n LineLength:\n include: 3\n" => ".super-slim.yml: `LineLength: include` must be a list of globs", + "linters:\n LineLength:\n max: \"120\"\n" => ".super-slim.yml: `LineLength: max` must be a whole number", + "linters:\n LineLength:\n enabled: \"false\"\n" => ".super-slim.yml: `LineLength: enabled` must be true or false", + "linters:\n DeprecatedClasses:\n classes: [close]\n" => ".super-slim.yml: `DeprecatedClasses: classes` must be a mapping", + "linters:\n EmbeddedEngines:\n forbidden_engines: css\n" => ".super-slim.yml: `EmbeddedEngines: forbidden_engines` must be a list", + "format:\n max_line_length: \"120\"\n" => ".super-slim.yml: `format: max_line_length` must be a whole number above 0", + "fmt:\n max_line_length: 0\n" => ".super-slim.yml: `fmt: max_line_length` must be a whole number above 0", + "format:\n attribute_delimiters: \"<>\"\n" => ".super-slim.yml: `format: attribute_delimiters` must be one of (), [], {}", + "tailwind_classes: maybe\n" => ".super-slim.yml: `tailwind_classes` must be true or false", "x: !ruby/object:Object {}\n" => "Tried to load unspecified class: Object" }.each do |configuration, message| in_templates("p Hello\n") do |directory| @@ -109,6 +117,15 @@ def test_a_configuration_of_the_wrong_shape_fails_with_a_usage_error end end + def test_a_preset_table_and_a_line_length_left_empty_are_no_error + in_templates("p Hello\n") do |directory| + File.write(File.join(directory, ".super-slim.yml"), "linters:\n DeprecatedAttributes:\n preset: bootstrap5\n attributes:\nformat:\n max_line_length:\n") + + assert_equal [0, ""], run_cli("lint", ".") + assert_equal [0, ""], run_cli("format", "--check", ".") + end + end + def test_include_and_exclude_globs_match_paths_from_the_configuration_directory in_templates("p style=\"color: red\" Hello\n") do |directory| FileUtils.mkdir_p(File.join(directory, "mailers"))