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 callingReport::PL::X123.to_s.underscore
- Zeitwerk autoloader finds
lib/report/pl/x123/products.rb
and maps it toReport::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.