Rubydex linter

Rubydex ships with a built-in linter and infrastructure to write custom rules. It includes the built-in rules that are part of the core analysis by default, such as parse errors, dynamic constant references, and others that will be available in the future like undefined constant, cyclic ancestor, superclass mismatch and so on. The goal is to provide a unified interface for linting the code from both a base analysis and custom rule perspective.

Usage

Commands

The linter includes the following commands:

# Lint the codebase.
bundle exec rdx lint

# Explain a given rule. This works for custom rules made available by dependencies or by
# the current workspace. The command prints the documentation written on top of a rule definition,
# which should provide longer explanations than a diagnostic message.
bundle exec rdx lint explain MyRule

Configuration

All Rubydex tools are always configured in rubydex.toml within their specific sections. Linter configurations allow users to disable rules, change their severity or exclude certain paths.

[linter.rules.MyCustomRule]
enabled = true
exclude = ["path_to_skip/**"]
severity = "warning" # Valid values: "hint" | "information" | "warning" | "error"

Editor support

Linting results are automatically surfaced by the Ruby LSP. The only caveat is that only the beta version of the Ruby LSP is currently supported due to the Rubydex requirement. We are working on stabilizing a release of the Ruby LSP to remove this requirement. You can enable the beta in VS Code with the following setting

{
  // VS Code settings. Enable the beta version of the server
  "rubyLsp.featureFlags": {
    "betaServer": true
  }
}

For other editors, pass the betaServer flag through initialization options.

Creating custom rules

Rubydex ships with the base infrastructure allowing developers to create their own rules, which can exist inside of a given project or even published as a gem. Rules are loaded automatically by Rubydex as long as they follow the expected path patterns:

IMPORTANT: Rubydex’s analysis is still being built and therefore it cannot reason about every kind of Ruby code. For example, local variables are not yet supported. Rule implementations are limited to what the graph can currently offer.

Custom rules inherit from Rubydex::Linter::CustomRule and must define the lint method. All rules have access to a fully populated Rubydex::Graph, which can be inspected for finding possible mistakes, in addition to convenience helpers.

Consider the following example, which is a rule to prohibit re-opening Rails models:

module Rubydex
  module Linter
    module Rules
      # Prohibits Rails models from being re-opened (more than one definition).
      #
      # Note: rule class names become the config key.
      class ProhibitModelReopen < CustomRule
        class << self
          # The default severity of this rule, which can be overridden by the user's configuration.
          #
          # @override
          #: () -> singleton(Severity::Base)
          def default_severity
            Severity::Error
          end
        end

        # The inspection that this rule performs to find possible mistakes.
        #
        # @override
        #: () -> void
        def lint
          # All descendants of ActiveRecord::Base are models
          models = child_classes("ActiveRecord::Base")

          models.each do |model|
            # Every place that this model was defined
            definitions = model.definitions

            # If there's more than one definition, produce a diagnostic
            unless definitions.one?
              first, *others = definitions.to_a

              add_diagnostic(
                "`#{model.name}` is defined more than once.",
                first.name_location,
                related_information: others.map do |other_def|
                  RelatedInformation.new(
                    "`#{model.name}` is also defined here.",
                    other_def.name_location,
                  )
                end,
              )
            end
          end
        end
      end
    end
  end
end

Note: there’s currently no support for auto correct.

Best practices

# bad: loops through the entire graph to find out which declarations inherit from Foo
graph.declarations.select { |d| d.has_ancestor?("Foo") }

# good: directly inspects the desired descendants. No loop
graph["Foo"].descendants

# bad: loops through the entire graph to find all methods named `is_a?` declared on any type
graph.declarations.select { |d| d.is_a?(Rubydex::Method) && d.name.end_with?("#is_a?()") }

# good: uses the search API to find all matches directly
graph.search("#is_a?()")

Testing a rule

Rubydex provides the RuleTestCase parent class to conveniently test rules with a more visual approach.

require "rubydex/linter/rule_test_case"

module Rubydex
  module Linter
    module Rules
      class ProhibitModelReopenTest < RuleTestCase
        def setup
          super

          # Add a shared piece of code that will be automatically included in
          # the graph for all examples
          source = <<~RUBY
            module ActiveRecord
              class Base; end
            end
          RUBY
          add_shared_source("fake/active_record.rb" => source)
        end

        def test_no_violations_with_single_definition
          assert_no_diagnostics("app/models/post.rb" => <<~RUBY)
            class Post < ActiveRecord::Base
            end
          RUBY
        end

        def test_catches_model_reopens
          # Expected violations use ^^^^ to point to the exact spot in the code
          # where the diagnostic will be produced. Assertions can include both
          # the diagnostic and its related information, so that developers can visually
          # see the experience that will be delivered to users.
          assert_diagnostics(
            "app/models/post.rb" => <<~RUBY,
              class Post < ActiveRecord::Base
                    ^^^^ `Post` is defined more than once.
              end
            RUBY
            "app/models/other_post.rb" => <<~RUBY,
              class Post < ActiveRecord::Base
                    ^^^^ `Post` is also defined here.
              end
            RUBY
          )
        end
      end
    end
  end
end