Completely custom Zeitwerk inflector

… and check why 5600+ Rails engineers read also this

Completely custom Zeitwerk inflector

In my previous post, I discussed the difference between how the classic autoloader and Zeitwerk autoloader match constant and file names. Short reminder:

  • Classic autoloader maps missing constant name Report::PL::X123 to a file name by calling Report::PL::X123.to_s.underscore
  • Zeitwerk autoloader finds lib/report/pl/x123/products.rb and maps it to Report::PL::X123::Products constant name with the help of defined inflectors rules.

What is an inflector?

In general, an inflector is a software component responsible for transforming words according to predefined rules. In the context of web frameworks like Ruby on Rails, inflectors are used to handle different linguistic transformations, such as pluralization, singularization, acronym handling, and humanization of attribute names.

Rails::Autoloader::Inflector is the one that is used by default in Rails integration with Zeitwerk:

module Rails
  class Autoloaders
    module Inflector # :nodoc:
      @overrides = {}

      def self.camelize(basename, _abspath)
        @overrides[basename] || basename.camelize
      end

      def self.inflect(overrides)
        @overrides.merge!(overrides)
      end
    end
  end
end

Its camelize method checks for the overrides and if it finds one, it uses it, otherwise it calls String#camelize method, which is part of ActiveSupport core extensions for String.

def camelize(first_letter = :upper)
  case first_letter
  when :upper
    ActiveSupport::Inflector.camelize(self, true)
  when :lower
    ActiveSupport::Inflector.camelize(self, false)
  else
    raise ArgumentError, "Invalid option, use either :upper or :lower."
  end
end

As you can see String#camelize delegates to ActiveSupport::Inflector under the hood.

ActiveSupport::Inflector has been a part of Rails since the very beginning and is used to transform words from singular to plural, class names to table names, modularized class names to ones without, and class names to foreign keys.

However, in the context, of Zeitwerk, acronym handling is an essential feature of inflector.

An example of acronym is “REST” (Representational State Transfer). It is not uncommon to have a constant including it, such as API::REST::Client.

When the classic autoloader encounters an undefined constant API::REST::Client, it calls API::REST::Client.to_s.underscore to find the api/rest/client.rb file in the autoloaded directories.

On the other hand, Zeitwerk locates api/rest/client.rb and invokes 'api/rest/client'.camelize. Without acronym handling rules, this results in Api::Rest::Client. To get API::REST::Client, we need to supply an inflector with acronym handling rules. In this post, I will demonstrate four distinct methods to accomplish that.

1. Configure ActiveSupport::Inflector

An intuitive and pretty common way is to configure ActiveSupport::Inflector directly. But doing so affects how ActiveSupport inflects these phrases globally. It’s not always desired.

# config/initializers/inflections.rb

ActiveSupport::Inflector.inflections(:en) do |inflect|
  inflect.acronym 'API'
  inflect.acronym 'REST'
end

2. Set overrides for Rails::Autoloader::Inflector

In some cases, you don’t want to add certain class or module naming rules to the ActiveSupport inflector. It’s not mandatory. You have the option to override particular inflections only for Zeitwerk and leave the Rails global inflector as it is. However, even if you do that, Zeitwerk will still fall back to String#camelize and ActiveSupport::Inflector when it cannot find a specific key.

# config/initializers/zeitwerk.rb

Rails.autoloaders.each do |autoloader|
  autoloader.inflector.inflect(
    "api" => "API",
    "rest" => "REST",
  )
end

3. Use Zeitwerk::Inflector

Zeitwerk is a gem designed to be used independently from Rails and it provides an alternative implementation of inflector that you can use instead of Rails::Autoloader::Inflector. By doing so, you will have complete control over the acronyms you use in modules and classes naming conventions in a single place. Furthermore, it will help you avoid polluting the ActiveSupport general-purpose inflector with autoloader-specific rules.

# config/initializers/zeitwerk.rb

Rails.autoloaders.each do |autoloader|
  autoloader.inflector = Zeitwerk::Inflector.new
  autoloader.inflector.inflect(
    "api" => "API",
    "rest" => "REST",
  )
end

4. Implement your custom inflector

Consider a scenario where, apart from the API::REST::Client, you also have the User::Activities::Rest constant in your codebase. Both of them include the /rest/i substring, but you cannot use the same inflection rule to derive the constant name from the file name.

This is a good example of when you may need to provide a custom inflector implementation.

Let’s revisit the standard Rails::Autoloader::Inflector#camelize method implementation to better understand this.

def self.camelize(basename, _abspath)
  @overrides[basename] || basename.camelize
end

As you can see it is designed to take 2 arguments: basename and _abspath. The basename is the file name without the extension and the _abspath is the absolute path to the file.

Note that the _abspath is not used in either the Rails::Autoloader::Inflector or the Zeitwerk::Inflector implementation.

However, you can still take advantage of this argument presence in your custom implementation.

# config/initializers/zeitwerk.rb

class UnconventionalInflector
  def self.conditional_inflection_for(basename:, inflection:, path:)
    Module.new do
      define_method :camelize do |basename_, abspath|
        if basename_ == basename && path.match?(abspath)
          inflection
        else
          super(basename_, abspath)
        end
      end
    end
  end

  prepend conditional_inflection_for(
            basename: 'rest',
            inflection: 'REST',
            path: /\A#{Rails.root.join('lib', 'api')}/,
          )

  # ...

  def initialize
    @inflector = Rails::Autoloader::Inflector
  end

  def camelize(basename, abspath)
    @inflector.camelize(basename, abspath)
  end

  def inflect(overrides)
    @inflector.inflect(overrides)
  end
end

Rails.autoloaders.each do |autoloader|
  autoloader.inflector = UnconventionalInflector.new
  autoloader.inflector.inflect(
    'api' => 'API'
  )
end

The implementation above utilizes Rails::Autoloader::Inflector module. However, it prepends its camelize implementation with the one that first checks if the file path matches an unconventional inflection rule. If it does, the method uses an non-standard inflection. If not, it falls back to the default implementation.


I understand that the example of Rest and REST may seem contrived, but it serves to illustrate the point. In real-life situations, there may be more convincing reasons to implement a custom inflector, just as we did on a project we were consulting, where it proved to be very helpful.

You might also like