fxn/zeitwerk

Zeitwerk not loading children of struct namespaces

BroiSatse opened this issue · 5 comments

Reproduction setup

File lib/a.rb

A = Struct.new(:a)

File lib/a/b.rb

class A::B
end

File start.rb:

require 'zeitwerk'
require 'pry'

loader = Zeitwerk::Loader.new
loader.push_dir(File.join(File.dirname(__FILE__), 'lib'))
loader.setup # ready!

puts A::B.name

Expected output

Program to print 'A::B'

Received output

start.rb:8:in `<main>': uninitialized constant A::B (NameError)

Notes

Replacing lib/a.rb file with regular (non-struct) class/module definition seems to be working just fine, so does requiring lib/a/b.rb file manually.

Zeitwerk version: 2.6.11
Ruby 3.0.5

Actually it seems to be an issue with all dynamically created classes. The problem can be reproduced with A = Class.new, or A = Module.new

fxn commented

Yes, unfortunately this is a limitation in explicit namespaces, documented here. This limitation does not exist for classes or modules that are not namespaces.

I have not tested this, but in principle you should be able to subclass:

class A < Struct.new(:a)
end

Indeed, it would be a nice addition to the docs.

Ah, that's very interesting - sorry I've missed this is already documented! Yes, inheriting a struct works, however it's discouraged by default Rubocop configuration.

Actually, I am really curious what's the technical reason for this - I was always under impression that A = Class.new is pretty much an equivalent of class A, with an exception of Module.nesting complications within the class body.

Thanks for replying so quickly.

fxn commented

Actually, I am really curious what's the technical reason for this - I was always under impression that A = Class.new is pretty much an equivalent of class A, with an exception of Module.nesting complications within the class body.

Indeed, you are right. And that is why it works in the rest of files.

There's a obscure difference that rarely matters in practice but that is crucial here. When an explicit namespace is defined, Zeitwerk needs to issue Module#autoload calls before the body is executed, so descendants are found.

Let me explain. Imagine this scenario:

# hotel.rb
class Hotel
  include Pricing
end

# hotel/pricing.rb
module Hotel::Pricing
end

In order to be able to load hotel.rb, you need this (conceptually):

class Hotel
  autoload :Pricing, "/full/path/to/hotel/pricing.rb" # <- Dynamically injected by Zeitwerk.
  include Pricing
end

To be able to do that, Zeitwerk listens to TracePoint :class events, and these are not fired for Class.new (which would not even be assigned to A at the time, anyway).

That is why.

Thanks for replying so quickly.

My pleasure.

@fxn Sorry for the late response - only just saw your answer. That's a fantastic explanation! I wonder if this could be worked-out by monkey patching Module.new and Struct.new methods (which seems not to be executed when creating modules/classes in traditional way). I'd imagine this could be wrapped in a helper module and maybe configurable as well?