Ruby libraries have implemented linters

Upon hearing the term “linter” or “lint,” you likely form an idea of how this tool functions and its purpose.

You might envision Rubocop, which one Toptal developer maintains, or perhaps JSLint, ESLint, or even tools lesser-known or used.

This piece will introduce you to a different type of linter. These linters don’t examine code syntax or validate the Abstract-Syntax-Tree; instead, they focus on code verification. Their role is to confirm if an implementation aligns with a specific interface, going beyond lexical checks (like duck typing and standard interfaces) to sometimes include semantic verification.

To understand them better, let’s delve into some practical illustrations. If you’re not very familiar with Rails, a quick read of this might be helpful.

Let’s begin with a basic Lint example.

ActiveModel::Lint::Tests

official Rails documentation offers a detailed explanation of this Lint’s behavior:

“To test if an object adheres to the Active Model API, include ActiveModel::Lint::Tests in your TestCase. This incorporates tests indicating if your object is fully compliant or highlights any unimplemented API aspects. Remember, an object isn’t obligated to implement every API for compatibility with Action Pack. This module’s sole purpose is guidance if you desire all features out-of-the-box.”

Therefore, when implementing a class intended for use with existing Rails features like redirect_to or form_for, implementing a few methods is necessary. This functionality isn’t restricted to ActiveRecord objects. Your objects can utilize it too, but they must “quack” correctly.

Implementation

The implementation is quite straightforward. It involves a module designed for inclusion in test cases. The methods prefixed with test_ will be implemented by your framework. The @model instance variable is expected to be set up by the user before the test:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
module ActiveModel
  module Lint
    module Tests
      def test_to_key
        assert_respond_to model, :to_key
        def model.persisted?() false end
        assert model.to_key.nil?, "to_key should return nil when `persisted?` returns false"
      end

      def test_to_param
        assert_respond_to model, :to_param
        def model.to_key() [1] end
        def model.persisted?() false end
        assert model.to_param.nil?, "to_param should return nil when `persisted?` returns false"
      end

      ...

      private

      def model
        assert_respond_to @model, :to_model
        @model.to_model
      end

Usage

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class Person
  def persisted?
    false
  end

  def to_key
    nil
  end

  def to_param
    nil
  end

  # ...
end
# test/models/person_test.rb
require "test_helper"

class PersonTest < ActiveSupport::TestCase
  include ActiveModel::Lint::Tests

  setup do
    @model = Person.new
  end
end

ActiveModel::Serializer::Lint::Tests

While not new, Active model serializers offer ongoing learning opportunities. Incorporating ActiveModel::Serializer::Lint::Tests helps verify if an object conforms to the Active Model Serializers API. In case of non-compliance, the tests will pinpoint the missing elements.

However, the docs presents a crucial caveat: semantic correctness isn’t checked:

“These tests do not attempt to determine the semantic correctness of the returned values. For instance, you could implement serializable_hash to always return {}, and the tests would pass. It is up to you to ensure that the values are semantically meaningful.”

Essentially, only the interface’s structure is being examined. Let’s examine its implementation.

Implementation

This closely mirrors the ActiveModel::Lint::Tests implementation we encountered earlier, with slightly more rigor in certain aspects. It checks the arity or classes of returned values:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
module ActiveModel
  class Serializer
    module Lint
      module Tests
        # Passes if the object responds to <tt>read_attribute_for_serialization</tt>
        # and if it requires one argument (the attribute to be read).
        # Fails otherwise.
        #
        # <tt>read_attribute_for_serialization</tt> gets the attribute value for serialization
        # Typically, it is implemented by including ActiveModel::Serialization.
        def test_read_attribute_for_serialization
          assert_respond_to resource, :read_attribute_for_serialization, 'The resource should respond to read_attribute_for_serialization'
          actual_arity = resource.method(:read_attribute_for_serialization).arity
          # using absolute value since arity is:
          #  1 for def read_attribute_for_serialization(name); end
          # -1 for alias :read_attribute_for_serialization :send
          assert_equal 1, actual_arity.abs, "expected #{actual_arity.inspect}.abs to be 1 or -1"
        end

        # Passes if the object's class responds to <tt>model_name</tt> and if it
        # is in an instance of +ActiveModel::Name+.
        # Fails otherwise.
        #
        # <tt>model_name</tt> returns an ActiveModel::Name instance.
        # It is used by the serializer to identify the object's type.
        # It is not required unless caching is enabled.
        def test_model_name
          resource_class = resource.class
          assert_respond_to resource_class, :model_name
          assert_instance_of resource_class.model_name, ActiveModel::Name
        end

        ...

Usage

Here’s an illustration of how ActiveModelSerializers utilizes the lint by integrating it into its test case:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
module ActiveModelSerializers
  class ModelTest < ActiveSupport::TestCase
    include ActiveModel::Serializer::Lint::Tests

    setup do
      @resource = ActiveModelSerializers::Model.new
    end

    def test_initialization_with_string_keys
      klass = Class.new(ActiveModelSerializers::Model) do
        attributes :key
      end
      value = 'value'

      model_instance = klass.new('key' => value)

      assert_equal model_instance.read_attribute_for_serialization(:key), value
    end

Rack::Lint

Our previous examples disregarded semantics.

However, Rack::Lint operates quite differently. It functions as Rack middleware, enveloping your application. This middleware acts as a linter, verifying if requests and responses adhere to the Rack specification. It proves useful when implementing a Rack server like Puma to serve the Rack application, ensuring adherence to the Rack specification.

Alternatively, it’s employed when building a barebones application to prevent basic HTTP protocol errors.

Implementation

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
module Rack
  class Lint
    def initialize(app)
      @app = app
      @content_length = nil
    end

    def call(env = nil)
      dup._call(env)
    end

    def _call(env)
      raise LintError, "No env given" unless env
      check_env env

      env[RACK_INPUT] = InputWrapper.new(env[RACK_INPUT])
      env[RACK_ERRORS] = ErrorWrapper.new(env[RACK_ERRORS])

      ary = @app.call(env)
      raise LintError, "response is not an Array, but #{ary.class}" unless ary.kind_of? Array
      raise LintError, "response array has #{ary.size} elements instead of 3" unless ary.size == 3

      status, headers, @body = ary
      check_status status
      check_headers headers

      hijack_proc = check_hijack_response headers, env
      if hijack_proc && headers.is_a?(Hash)
        headers[RACK_HIJACK] = hijack_proc
      end

      check_content_type status, headers
      check_content_length status, headers
      @head_request = env[REQUEST_METHOD] == HEAD
      [status, headers, self]
    end

    ## === The Content-Type
    def check_content_type(status, headers)
      headers.each { |key, value|
        ## There must not be a <tt>Content-Type</tt>, when the +Status+ is 1xx, 204 or 304.
        if key.downcase == "content-type"
          if Rack::Utils::STATUS_WITH_NO_ENTITY_BODY.key? status.to_i
            raise LintError, "Content-Type header found in #{status} response, not allowed"
          end
          return
        end
      }
    end

    ## === The Content-Length
    def check_content_length(status, headers)
      headers.each { |key, value|
        if key.downcase == 'content-length'
          ## There must not be a <tt>Content-Length</tt> header when the +Status+ is 1xx, 204 or 304.
          if Rack::Utils::STATUS_WITH_NO_ENTITY_BODY.key? status.to_i
            raise LintError, "Content-Length header found in #{status} response, not allowed"
          end
          @content_length = value
        end
      }
    end

    ...

Usage in Your App

Imagine constructing a very basic endpoint. While it should sometimes respond with “No Content,” a deliberate error causes it to send content in 50% of cases:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# foo.rb
# run with rackup foo.rb
Foo = Rack::Builder.new do
  use Rack::Lint
  use Rack::ContentLength
  app = proc do |env|
    if rand > 0.5
      no_content = Rack::Utils::HTTP_STATUS_CODES.invert['No Content']
      [no_content, { 'Content-Type' => 'text/plain' }, ['bummer no content with content']]
    else
      ok = Rack::Utils::HTTP_STATUS_CODES.invert['OK']
      [ok, { 'Content-Type' => 'text/plain' }, ['good']]
    end
  end
  run app
end.to_app

Rack::Lint intervenes in such scenarios, intercepting the response, verifying it, and raising an exception:

1
2
3
Rack::Lint::LintError: Content-Type header found in 204 response, not allowed
    /Users/dev/.rbenv/versions/2.6.5/lib/ruby/gems/2.6.0/gems/rack-2.2.3/lib/rack/lint.rb:21:in `assert'
    /Users/dev/.rbenv/versions/2.6.5/lib/ruby/gems/2.6.0/gems/rack-2.2.3/lib/rack/lint.rb:710:in `block in check_content_type'

Usage in Puma

This example demonstrates Puma wrapping a simple application lambda { |env| [200, { "X-Header" => "Works" }, ["Hello"]] } first within ServerLint (inheriting from Rack::Lint) and then within ErrorChecker.

If the specification is violated, the lint throws exceptions. The checker captures these exceptions and returns a 500 error code. The test code confirms that no exception occurred:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
class TestRackServer < Minitest::Test
  class ErrorChecker
    def initialize(app)
      @app = app
      @exception = nil
    end

    attr_reader :exception, :env

    def call(env)
      begin
        @app.call(env)
      rescue Exception => e
        @exception = e
        [ 500, {}, ["Error detected"] ]
      end
    end
  end

  class ServerLint < Rack::Lint
    def call(env)
      check_env env

      @app.call(env)
    end
  end

  def setup
    @simple = lambda { |env| [200, { "X-Header" => "Works" }, ["Hello"]] }
    @server = Puma::Server.new @simple
    port = (@server.add_tcp_listener "127.0.0.1", 0).addr[1]
    @tcp = "http://127.0.0.1:#{port}"
    @stopped = false
  end

  def test_lint
    @checker = ErrorChecker.new ServerLint.new(@simple)
    @server.app = @checker

    @server.run

    hit(["#{@tcp}/test"])

    stop

    refute @checker.exception, "Checker raised exception"
  end

This process certifies Puma as Rack compatible.

RailsEventStore - Repository Lint

Rails Event Store is a library designed for the publication, consumption, storage, and retrieval of events. Its purpose is to facilitate the implementation of Event-Driven Architecture in Rails applications. This modular library consists of smaller components like repository, mapper, dispatcher, scheduler, subscriptions, and serializer, each allowing interchangeable implementations.

For instance, the default repository utilizes ActiveRecord, assuming a specific table structure for event storage. Conversely, your implementation might employ ROM or function in-memory without event storage, proving beneficial during testing.

But how can you ascertain if your component’s behavior aligns with the library’s expectations? Through the provided linter, of course. And it’s comprehensive, covering around 80 cases. Some are relatively simple:

1
2
3
4
5
6
specify 'adds an initial event to a new stream' do
  repository.append_to_stream([event = SRecord.new], stream, version_none)
  expect(read_events_forward(repository).first).to eq(event)
  expect(read_events_forward(repository, stream).first).to eq(event)
  expect(read_events_forward(repository, stream_other)).to be_empty
end

While others are more intricate, involving unhappy paths:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
it 'does not allow linking same event twice in a stream' do
  repository.append_to_stream(
    [SRecord.new(event_id: "a1b49edb")],
    stream,
    version_none
  ).link_to_stream(["a1b49edb"], stream_flow, version_none)
  expect do
    repository.link_to_stream(["a1b49edb"], stream_flow, version_0)
  end.to raise_error(EventDuplicatedInStream)
end

At nearly 1,400 lines of Ruby code, it’s arguably the largest Ruby linter. However, if you’re aware of a larger one, let me know. Notably, it focuses entirely on semantics.

While it extensively tests the interface too, considering the article’s scope, I’d deem that an afterthought.

Implementation

The repository linter leverages the RSpec Shared Examples functionality:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
module RubyEventStore
  ::RSpec.shared_examples :event_repository do
    let(:helper)        { EventRepositoryHelper.new }
    let(:specification) { Specification.new(SpecificationReader.new(repository, Mappers::NullMapper.new)) }
    let(:global_stream) { Stream.new(GLOBAL_STREAM) }
    let(:stream)        { Stream.new(SecureRandom.uuid) }
    let(:stream_flow)   { Stream.new('flow') }

    # ...

    it 'just created is empty' do
      expect(read_events_forward(repository)).to be_empty
    end

    specify 'append_to_stream returns self' do
      repository
        .append_to_stream([event = SRecord.new], stream, version_none)
        .append_to_stream([event = SRecord.new], stream, version_0)
    end

    # ...

Usage

Similar to others, this linter expects you to furnish certain methods, primarily repository, which returns the implementation requiring verification. Test examples are integrated using RSpec’s built-in include_examples method:

1
2
3
4
RSpec.describe EventRepository do
    include_examples :event_repository
    let(:repository) { EventRepository.new(serializer: YAML) }
end

Wrapping Up

Evidently, “linter” encompasses a broader meaning than typically perceived. Whenever you develop a library anticipating interchangeable collaborators, consider providing a linter.

Even if your library’s class is the sole entity passing such tests initially, it signifies your commitment to extensibility as a software engineer. It also compels you to consciously, rather than accidentally, contemplate the interface of each component in your code.

Resources

Licensed under CC BY-NC-SA 4.0