fxn/zeitwerk

[Question] Is it possible to add an un-collapsed directory to an already collapsed one?

adrianthedev opened this issue ยท 4 comments

๐Ÿ‘‹ Hey there,
I'm Adrian, author of https://github.com/avo-hq/avo.

TL;DR;

How can we have the following namespacing with this directory structure (in a Rails app)?

  • app/avo/actions/make_admin.rb -> class Avo::Actions::MakeAdmin
  • app/avo/resources/user_resource.rb -> class Avo::Resources::UserResource
app/
โ”œโ”€ avo/
โ”‚  โ”œโ”€ actions/
โ”‚  โ”‚  โ”œโ”€ make_admin.rb
โ”‚  โ”œโ”€ resources/
โ”‚  โ”‚  โ”œโ”€ user_resource.rb
โ”‚  โ”œโ”€ ...other Avo directories/
โ”œโ”€ controllers/
โ”œโ”€ ... other rails directories/

Long Story

We're looking into refactoring some code, and I'm curious if this is possible with Zeitwerk.

Current setup

We add a few primitives to the Rails app to configure the admin panel. Primitives like Actions, Filters, Resources, Dashboards, Cards, and more.
We add those classes inside the app/avo directory.

app/
โ”œโ”€ avo/
โ”‚  โ”œโ”€ actions/
โ”‚  โ”‚  โ”œโ”€ make_admin.rb
โ”‚  โ”œโ”€ resources/
โ”‚  โ”‚  โ”œโ”€ user_resource.rb
โ”‚  โ”œโ”€ ...other Avo directories/
โ”œโ”€ controllers/
โ”œโ”€ ... other rails directories/

Under the current set up we added those paths to Rails.autoloaders.main like so:

Rails.autoloaders.main.push_dir(Rails.root.join('app', 'avo', 'resources').to_s)
Rails.autoloaders.main.push_dir(Rails.root.join('app', 'avo', 'actions').to_s)
# ... more directories

That gave us the ability to add un-namespaced classes to the app.

# app/avo/resources/user_resource.rb
class UserResource < Avo::BaseResource
  # ... class contents
end

The learnings

We now figured out that it's difficult to organize these un-namespaced classes. If the user doesn't add Action or Filter, or Resource suffix to the class name, it could get more challenging to maintain.

So we figured we'd leave Rails and the default auto-loading scheme to take over and have the classes namespace like so:

  • actions -> Avo::Actions::MakeAdmin
  • resources -> Avo::Resources::User
  • etc.

This way, it's implicit what the class/object does.

The problem

We set out to refactor this piece of functionality. We went and removed our own push_dir operations with the idea that the namespacing would happen automatically.

We soon figured out that all the subdirectories of app are collapsed (please correct us if we're wrong. We couldn't find the piece of code that adds the collapsing functionality).

So the namespacing now for our files do not contain the top-level Avo namespace.

  • actions -> Actions::MakeAdmin
  • resources -> Resources::User

The main problem with this namespacing is that we're afraid of conflicts and clashes with other gems or other classes from the parent app

The question

Is there a way to "un-collapse" a directory?

Ideally we'd do something like Rails.autoloaders.main.uncollapse(Rails.root.join('app', 'avo', 'resources').to_s). This might be a dumb way of approaching things, so I'm open to any recommendations from you.

I need to mention that we already have an Avo namespace declared in the gem as the engine namespace.

So, yeah. We're open to suggestions.

Thank you!

fxn commented

Hi Adrian!

Rails automatically adds subdirectories of app to the autoload paths, with some exceptions like the ones for views or assets. So, what you are seeing is not really collapsing, but that app/avo is in the autoload paths just like app/models is. Therefore, app/avo represents Object.

In your previous setup, since app/avo/actions was in the autoload paths too, for example, we had both app/avo and app/avo/actions in the autoload paths. This is a setup with nested root directories, and thus app/avo/actions acted as Object even if it is a subtree of another root directory.

Now, in the new setup you no longer have nested root directories. What you really need is to say that app/avo represents Avo. Zeitwerk has support for that, but Rails does not have API for configuring this. However, reaching the loaders directly does belong to the public interface precisely to be able to customize them in ways that go beyond what the Rails configuration API supports.

The solution would be to have an initializer in the engine like this (untested):

# config/initializers/zeitwerk.rb

ActiveSupport::Dependencies.autoload_paths.delete("#{Rails.root}/app/avo")
Rails.autoloaders.main.push_dir("#{Rails.root}/app/avo", namespace: Avo)

The first line won't be needed in Rails 7.1, but it is necessary now, and won't hurt in Rails 7.1 either if you need to support multiple Rails versions. The argument has to be a string.

As you see, for the second line to be executable, the Avo module has to be already in memory.

Could you give that a try?

Thank you for the quick response @fxn.
That does make sense (removing the path and adding it under the proper namespace).
I'll give it a try and get back to you.

Reporting back with the initial tests.
It works ๐ŸŽ‰

Thank you for your quick response and explanation ๐Ÿ™
This gem and your support are exemplary, and I try to keep up the standard with my library.

I'll close this for now and reopen it if necessary.

fxn commented

Awesome, appreciate your words man! โค๏ธ