Search code examples
ruby-on-railsautoloadrails-engineszeitwerkinflector

Rails 7.1 resolve conflicting Zeitwerk Inflection rules


I have a Rails 7.1 app and multiple (Rails Engine) Gems with conflicting inflection rules. There is Gem1::Api::Gem1Controller under app/controllers/gem1/api/gem1_controller.rb and Gem2::API::Gem2Controller under app/controllers/gem2/api/gem2_controller.rb. As I understand it, Zeitwerk uses global autoloaders for the whole Rails app including all Rails Engines (which are more or less treated as part of the main app).

By default, Zeitwerk can load Gem1::Api::Gem1Controller, but fails to load Gem2::API::Gem2Controller, because the inflection rule for "api" => "API" is missing.

However, using the "normal" approach of adding custom inflection rules will not work, since then it will fail to load Gem1::Api::Gem1Controller because it expects to find Gem1::API::Gem1Controller.

# config/initializers/zeitwerk.rb
Rails.application.autoloaders.each do |autoloader|
  autoloader.inflector.inflect("api" => "API") # works for Gem2, but breaks Gem1 as a result
end

Is there any way to define inflection rules that include the whole namespace? Something like

# config/initializers/zeitwerk.rb
Rails.application.autoloaders.each do |autoloader|
  autoloader.inflector.inflect("gem2/api/gem2_controller" => "Gem2::API::Gem2Controller")
end

Or maybe there is a way to define a per-Gem or per-Rails-Engine inflector? Keep in mind it still needs to work as a Rails Engine.


Solution

  • I found a solution for this problem via a custom Inflector. The regular Rails::Autoloader::Inflector#camelize ignores its second argument, but incorporating the absolute filepath into the camelization enables me to use both Gem1::Api and Gem2::API.

    # config/initializers/autoloaders.rb
    module PathnameSuffixInflector
      @overrides = {}
      @max_overrides_depth = 0
    
      def self.camelize(basename, abspath)
        return @overrides[[basename]] || basename.camelize if @max_overrides_depth <= 1
    
        filenames = Pathname.new(abspath).each_filename.to_a[0..-2] + [basename]
        @max_overrides_depth.downto(1).each do |suffix_length|
          suffix = filenames.last(suffix_length)
          return @overrides[suffix] if @overrides.key?(suffix)
        end
    
        return basename.camelize
      end
    
      def self.inflect(overrides)
        @overrides.merge!(overrides.transform_keys { Pathname.new(_1).each_filename.to_a })
        @max_overrides_depth = @overrides.keys.map(&:length).max || 0
      end
    end
    PathnameSuffixInflector.inflect("gem2/api" => "API")
    
    Rails.application.autoloaders.each do |autoloader|
      autoloader.inflector = PathnameSuffixInflector
    end
    

    EDIT: The above solution is nice if all occurrences of conflicting inflection are namespaced, however, sometimes there is also a conflict on root level vs namespaced level. In the above solution, there may be an inflection like "controllers/api" => "API" which is really ugly because it goes "below" root directory level. For such case, I made an Inflector that inflects relative to each root directory:

    # config/initializers/autoloaders.rb
    class RootRelativeInflector
      def initialize(autoloader, overrides = nil)
        @autoloader = autoloader
        @overrides = overrides || {}
      end
    
      def camelize(basename, abspath)
        return basename.camelize if @overrides.empty?
    
        # find root directory of currently loaded constant for lookup
        closest_root_path = @autoloader.__roots.keys.select { abspath.start_with?(_1) }.max_by(&:length)
        relative_path = abspath[(closest_root_path.length + 1)..-1].delete_suffix(".rb")
        return @overrides[relative_path] || basename.camelize
      end
    end
    
    Rails.application.autoloaders.each do |autoloader|
      autoloader.inflector = RootRelativeInflector.new(autoloader, {
        "api" => "API",
        "gem2/api" => "API"
      })
    end