sds/lint-trappings

View on GitHub
lib/lint_trappings/linter.rb

Summary

Maintainability
A
0 mins
Test Coverage
require 'lint_trappings/linter_configuration_validator'
require 'ostruct'

module LintTrappings
  # Base implementation for all lint checks.
  #
  # @abstract
  class Linter
    class << self
      # Return all subclasses.
      #
      # @return [Array<Class>]
      def descendants
        ObjectSpace.each_object(Class).select { |klass| klass < self }
      end

      # Returns the canonical name for this linter class.
      #
      # The canonical name is used as the key for configuring the linter in the
      # configuration file, or when referring to it from the command line.
      #
      # This uses the "Linter" module as an indicator of when to start removing
      # unnecessary module prefixes.
      #
      # @example
      #   LintTrappings::Linter::MyLinter
      #   => "MyLinter"
      #
      # @example
      #   MyCustomNamespace::MyLinter
      #   => "MyCustomNamespace::MyLinter"
      #
      # @example
      #   MyModule::Linter::MyCustomNamespace::MyLinter
      #   => "MyCustomNamespace::MyLinter"
      #
      # @return [String]
      def canonical_name
        @canonical_name ||=
          begin
            full_name = name.to_s.split('::')

            if linter_class_index = full_name.index('Linter')
              # Otherwise, the name follows the `Linter` module
              linter_class_index += 1
            else
              # If not found, include the full name
              linter_class_index = 0
            end

            full_name[linter_class_index..-1].join('::')
          end
      end

      def description(*args)
        if args.any?
          @description = args.first
        else
          @description
        end
      end

      def option(name, options)
        options = options.dup

        @options_spec ||= {}
        opt = @options_spec[name] = {}
        %i[type default description].each do |option_sym|
          opt[option_sym] = options.delete(option_sym) if options[option_sym]
        end

        if options.keys.any?
          raise InvalidOptionSpecificationError,
                "Unknown key `#{options.keys.first}` for `#{name}` option " \
                "specification on linter #{self}"
        end
      end

      def options
        @options_spec || {}
      end

      attr_accessor :options_struct_class
    end

    # Initializes a linter with the specified configuration.
    #
    # @param config [Hash] configuration for this linter
    def initialize(config)
      @orig_hash_config = @config = config
      validate_options_specification
      @config = convert_config_hash_to_struct(@config)
      @lints = []
    end

    # Runs the linter against the given Slim document.
    #
    # @param document [LintTrappings::Document]
    def run(document)
      @document = document
      @lints = []
      scan
      @lints
    end

    # Returns the canonical name of this linter's class.
    #
    # @see {LintTrappings::Linter.canonical_name}
    #
    # @return [String]
    def canonical_name
      self.class.canonical_name
    end

    private

    attr_reader :config, :document, :lints

    # Scans the document for lints.
    def scan
      raise NotImplementedError, 'Subclass must implement #scan'
    end

    def validate_options_specification
      LinterConfigurationValidator.new.validate(self, @config, self.class.options)
    end

    # List of built-in hook options which are available to every hook
    BUILT_IN_HOOK_OPTIONS = %w[enabled severity include exclude].freeze

    # Converts a configuration hash to a struct so configuration values are
    # accessed via method calls. This is valuable as it provides faster feedback
    # in the event of a typo (you get an error instead of a `nil` value).
    #
    # @return [Struct]
    def convert_config_hash_to_struct(hash)
      option_names = self.class.options.keys
      return OpenStruct.new unless option_names.any?
      self.class.options_struct_class ||= Struct.new(*option_names)

      unknown_keys = (hash.keys - option_names.map(&:to_s) - BUILT_IN_HOOK_OPTIONS)
      if unknown_keys.any?
        option_plural = Utils.pluralize('option', unknown_keys.count)
        raise LinterConfigurationError,
              "Unknown configuration #{option_plural} for #{canonical_name}: " \
              "#{unknown_keys.join(', ')}\n" \
              "Available options: #{(BUILT_IN_HOOK_OPTIONS + option_names).join(', ')}"
      end

      values = option_names.map { |option_name| hash[option_name.to_s] }
      self.class.options_struct_class.new(*values)
    end

    # Record a lint for reporting back to the user.
    #
    # @param range [Range<LintTrappings::Location>,#source_range] source range of lint
    # @param message [String] error/warning to display to the user
    def report_lint(range_or_obj, message)
      unless range_or_obj.is_a?(Range) || range_or_obj.respond_to?(:source_range)
        raise LinterError,
              '`report_lint` must be given a Range or an object ' \
              "that responds to `source_range`, but was given: #{range_or_obj.inspect}"
      end

      @lints << Lint.new(
        linter: self,
        path: @document.path,
        source_range: range_or_obj.is_a?(Range) ? range_or_obj : range_or_obj.source_range,
        message: message,
        severity: @orig_hash_config.fetch('severity'),
      )
    end

    # Shortcut for creating a range for a single location.
    #
    # @return [Range<LintTrappings::Location>]
    def location(*args)
      loc = Location.new(*args)
      loc..loc
    end
  end
end