Class: Rigor::ModuleGraph::ZeitwerkResolver

Inherits:
Object
  • Object
show all
Defined in:
lib/rigor/module_graph/zeitwerk_resolver.rb

Overview

Converts a Ruby source path into the fully-qualified constant the Zeitwerk convention says it should define.

Pure function, no I/O. The plugin instantiates one per run from ‘.rigor.yml` config and asks for `resolve(path)` per file. Two configuration knobs:

  • ‘autoload_paths`: roots stripped from the path before camelising. Defaults to the standard Rails layout.

  • ‘concern_dirs`: directories that act as transparent namespaces under Zeitwerk (`app/models/concerns/auditable.rb` resolves to `Auditable`, not `Concerns::Auditable`).

The resolver is order-sensitive: longer / more specific roots MUST be tried before their parents so ‘app/models/concerns/foo.rb` picks up the concern root, not `app/models`. We sort by length descending at construction time, so config order does not matter.

Constant Summary collapse

DEFAULT_AUTOLOAD_PATHS =
%w[
  app/models
  app/controllers
  app/services
  app/jobs
  app/mailers
  app/helpers
  app/channels
  app/workers
  lib
].freeze
DEFAULT_CONCERN_DIRS =
%w[
  app/models/concerns
  app/controllers/concerns
].freeze

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(autoload_paths: DEFAULT_AUTOLOAD_PATHS, concern_dirs: DEFAULT_CONCERN_DIRS, project_root: nil) ⇒ ZeitwerkResolver

Returns a new instance of ZeitwerkResolver.



42
43
44
45
46
47
48
49
# File 'lib/rigor/module_graph/zeitwerk_resolver.rb', line 42

def initialize(autoload_paths: DEFAULT_AUTOLOAD_PATHS,
               concern_dirs: DEFAULT_CONCERN_DIRS,
               project_root: nil)
  @project_root = project_root && File.expand_path(project_root)
  @autoload_paths = normalise_roots(autoload_paths)
  @concern_dirs = normalise_roots(concern_dirs)
  @sorted_roots = (@concern_dirs + @autoload_paths).sort_by { |r| -r.length }.uniq
end

Instance Attribute Details

#autoload_pathsObject (readonly)

Returns the value of attribute autoload_paths.



40
41
42
# File 'lib/rigor/module_graph/zeitwerk_resolver.rb', line 40

def autoload_paths
  @autoload_paths
end

#concern_dirsObject (readonly)

Returns the value of attribute concern_dirs.



40
41
42
# File 'lib/rigor/module_graph/zeitwerk_resolver.rb', line 40

def concern_dirs
  @concern_dirs
end

Instance Method Details

#camelise_path(rel_no_ext) ⇒ Object



107
108
109
# File 'lib/rigor/module_graph/zeitwerk_resolver.rb', line 107

def camelise_path(rel_no_ext)
  rel_no_ext.split("/").map { |seg| camelise_segment(seg) }.join("::")
end

#camelise_segment(segment) ⇒ Object



111
112
113
# File 'lib/rigor/module_graph/zeitwerk_resolver.rb', line 111

def camelise_segment(segment)
  segment.split("_").map(&:capitalize).join
end

#matches?(actual, inferred) ⇒ Boolean

True when ‘inferred` matches the (probably syntax-derived) `actual` constant under Zeitwerk’s conventions. We compare ignoring leading “::” since absolute / relative are not a meaningful distinction here.

Returns:

  • (Boolean)


75
76
77
78
79
# File 'lib/rigor/module_graph/zeitwerk_resolver.rb', line 75

def matches?(actual, inferred)
  return false if actual.nil? || inferred.nil?

  strip_leading(actual) == strip_leading(inferred)
end

#normalise_roots(roots) ⇒ Object



99
100
101
# File 'lib/rigor/module_graph/zeitwerk_resolver.rb', line 99

def normalise_roots(roots)
  Array(roots).map { |r| r.to_s.sub(%r{/+\z}, "") }.reject(&:empty?).freeze
end

#relativise(path) ⇒ Object



81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
# File 'lib/rigor/module_graph/zeitwerk_resolver.rb', line 81

def relativise(path)
  absolute = File.expand_path(path)
  if @project_root && absolute.start_with?(@project_root + "/")
    absolute[(@project_root.length + 1)..]
  elsif path.start_with?("/")
    # Absolute path with no project root configured: try every
    # autoload root as a suffix match. Used by integration runs
    # where files live in a tmpdir.
    suffix = @sorted_roots.find { |r| absolute.include?("/" + r + "/") }
    if suffix
      idx = absolute.rindex("/" + suffix + "/")
      absolute[(idx + 1)..]
    end
  else
    path
  end
end

#resolve(path) ⇒ String?

Returns the inferred constant name, or nil when the path is not under any configured root or has no .rb extension.

Parameters:

  • path (String)

    either relative to the project root or absolute. Both ‘app/models/billing/invoice.rb` and the `realpath` form work.

Returns:

  • (String, nil)

    the inferred constant name, or nil when the path is not under any configured root or has no .rb extension.



57
58
59
60
61
62
63
64
65
66
67
68
69
# File 'lib/rigor/module_graph/zeitwerk_resolver.rb', line 57

def resolve(path)
  return nil unless path

  rel = relativise(path)
  return nil unless rel
  return nil unless rel.end_with?(".rb")

  root = @sorted_roots.find { |r| rel.start_with?(r + "/") }
  return nil unless root

  suffix = rel[(root.length + 1)..]
  camelise_path(suffix.delete_suffix(".rb"))
end

#strip_leading(name) ⇒ Object



103
104
105
# File 'lib/rigor/module_graph/zeitwerk_resolver.rb', line 103

def strip_leading(name)
  name.sub(/\A::/, "")
end