Skip to content

Commit 94e4e61

Browse files
committed
Use Dir.scan if available
1 parent f845a27 commit 94e4e61

File tree

4 files changed

+164
-125
lines changed

4 files changed

+164
-125
lines changed

lib/zeitwerk/loader.rb

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
module Zeitwerk
77
class Loader
8+
require_relative "loader/scanner"
89
require_relative "loader/helpers"
910
require_relative "loader/callbacks"
1011
require_relative "loader/config"
@@ -13,8 +14,9 @@ class Loader
1314
extend Internal
1415

1516
include RealModName
16-
include Callbacks
17+
include Scanner
1718
include Helpers
19+
include Callbacks
1820
include Config
1921
include EagerLoad
2022

@@ -168,7 +170,7 @@ def unload
168170
# and the constant path would escape unloadable_cpath? This is just
169171
# defensive code to clean things up as much as we are able to.
170172
unload_cref(cref)
171-
unloaded_files.add(abspath) if ruby?(abspath)
173+
unloaded_files.add(abspath) if rb_extension?(abspath)
172174
end
173175
end
174176

@@ -186,7 +188,7 @@ def unload
186188
end
187189

188190
unload_cref(cref)
189-
unloaded_files.add(abspath) if ruby?(abspath)
191+
unloaded_files.add(abspath) if rb_extension?(abspath)
190192
end
191193

192194
unless unloaded_files.empty?
@@ -482,7 +484,7 @@ def all_dirs
482484
#: (Zeitwerk::Cref, String) -> void
483485
private def autoload_subdir(cref, subdir)
484486
if autoload_path = autoload_path_set_by_me_for?(cref)
485-
if ruby?(autoload_path)
487+
if rb_extension?(autoload_path)
486488
# Scanning visited a Ruby file first, and now a directory for the same
487489
# constant has been found. This means we are dealing with an explicit
488490
# namespace whose definition was seen first.
@@ -512,7 +514,7 @@ def all_dirs
512514
private def autoload_file(cref, file)
513515
if autoload_path = cref.autoload? || Registry.inceptions.registered?(cref)
514516
# First autoload for a Ruby file wins, just ignore subsequent ones.
515-
if ruby?(autoload_path)
517+
if rb_extension?(autoload_path)
516518
shadowed_files << file
517519
log("file #{file} is ignored because #{autoload_path} has precedence") if logger
518520
else
@@ -547,7 +549,7 @@ def all_dirs
547549
cref.autoload(abspath)
548550

549551
if logger
550-
if ruby?(abspath)
552+
if rb_extension?(abspath)
551553
log("autoload set for #{cref}, to be loaded from #{abspath}")
552554
else
553555
log("autoload set for #{cref}, to be autovivified from #{abspath}")

lib/zeitwerk/loader/eager_load.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ def load_file(path)
116116
abspath = File.expand_path(path)
117117

118118
raise Zeitwerk::Error.new("#{abspath} does not exist") unless File.exist?(abspath)
119-
raise Zeitwerk::Error.new("#{abspath} is not a Ruby file") if !ruby?(abspath)
119+
raise Zeitwerk::Error.new("#{abspath} is not a Ruby file") if !rb_extension?(abspath)
120120
raise Zeitwerk::Error.new("#{abspath} is ignored") if ignored_path?(abspath)
121121

122122
file_basename = File.basename(abspath, ".rb")

lib/zeitwerk/loader/helpers.rb

Lines changed: 3 additions & 118 deletions
Original file line numberDiff line numberDiff line change
@@ -1,130 +1,15 @@
11
# frozen_string_literal: true
22

33
module Zeitwerk::Loader::Helpers
4-
# --- Logging -----------------------------------------------------------------------------------
4+
CNAME_VALIDATOR = Module.new #: Module
5+
private_constant :CNAME_VALIDATOR
56

67
#: (to_s() -> String) -> void
78
private def log(message)
89
method_name = logger.respond_to?(:debug) ? :debug : :call
910
logger.send(method_name, "Zeitwerk@#{tag}: #{message}")
1011
end
1112

12-
# --- Files and directories ---------------------------------------------------------------------
13-
14-
#: (String) { (String, String, Symbol) -> void } -> void
15-
private def ls(dir)
16-
children = scan_dir(dir)
17-
18-
# The order in which a directory is listed depends on the file system.
19-
#
20-
# Since client code may run on different platforms, it seems convenient to
21-
# sort directory entries. This provides more deterministic behavior, with
22-
# consistent eager loading in particular.
23-
children.sort_by!(&:first)
24-
25-
children.each do |basename, abspath, ftype|
26-
if ftype == :directory && !has_at_least_one_ruby_file?(abspath)
27-
log("directory #{abspath} is ignored because it has no Ruby files") if logger
28-
next
29-
end
30-
31-
yield basename, abspath, ftype
32-
end
33-
end
34-
35-
# Looks for a Ruby file using breadth-first search. This type of search is
36-
# important to list as less directories as possible and return fast in the
37-
# common case in which there are Ruby files.
38-
#
39-
#: (String) -> bool
40-
private def has_at_least_one_ruby_file?(dir)
41-
to_visit = [dir]
42-
43-
while (dir = to_visit.shift)
44-
scan_dir(dir) do |_, abspath, ftype|
45-
return true if ftype == :file
46-
to_visit << abspath
47-
end
48-
end
49-
50-
false
51-
end
52-
53-
# This is a low-level method to scan directories. It filters out some stuff
54-
# the loader is never interested in, and passes the ftype up. The rest of the
55-
# library should generally use `ls`.
56-
#
57-
# Keep an eye on https://bugs.ruby-lang.org/issues/21800.
58-
#
59-
#: (String) { (String, String, Symbol) -> void } -> void
60-
#: (String) -> [[String, String, Symbol]]
61-
private def scan_dir(dir)
62-
children = [] unless block_given?
63-
64-
Dir.each_child(dir) do |basename|
65-
next if hidden?(basename)
66-
67-
abspath = File.join(dir, basename)
68-
next if ignored_path?(abspath)
69-
70-
ftype = supported_ftype?(abspath)
71-
next unless ftype
72-
73-
# Conceptually, root directories start separate trees.
74-
next if :directory == ftype && root_dir?(abspath)
75-
76-
# We freeze abspath because that saves allocations when passed later to
77-
# File methods. See https://github.com/fxn/zeitwerk/pull/125.
78-
if block_given?
79-
yield basename, abspath.freeze, ftype
80-
else
81-
children << [basename, abspath.freeze, ftype]
82-
end
83-
end
84-
85-
children unless block_given?
86-
end
87-
88-
# Encodes the documented conventions.
89-
#
90-
#: (String) -> Symbol?
91-
private def supported_ftype?(abspath)
92-
if ruby?(abspath)
93-
:file # By convention, we can avoid a syscall here.
94-
elsif dir?(abspath)
95-
:directory
96-
end
97-
end
98-
99-
#: (String) -> bool
100-
private def ruby?(path)
101-
path.end_with?(".rb")
102-
end
103-
104-
#: (String) -> bool
105-
private def dir?(path)
106-
File.directory?(path)
107-
end
108-
109-
#: (String) -> bool
110-
private def hidden?(basename)
111-
basename.start_with?(".")
112-
end
113-
114-
#: (String) { (String) -> void } -> void
115-
private def walk_up(abspath)
116-
loop do
117-
yield abspath
118-
abspath, basename = File.split(abspath)
119-
break if basename == "/"
120-
end
121-
end
122-
123-
# --- Inflection --------------------------------------------------------------------------------
124-
125-
CNAME_VALIDATOR = Module.new #: Module
126-
private_constant :CNAME_VALIDATOR
127-
12813
#: (String, String) -> Symbol ! Zeitwerk::NameError
12914
private def cname_for(basename, abspath)
13015
cname = inflector.camelize(basename, abspath)
@@ -146,7 +31,7 @@ module Zeitwerk::Loader::Helpers
14631
begin
14732
CNAME_VALIDATOR.const_defined?(cname, false)
14833
rescue ::NameError => error
149-
path_type = ruby?(abspath) ? "file" : "directory"
34+
path_type = rb_extension?(abspath) ? "file" : "directory"
15035

15136
raise Zeitwerk::NameError.new(<<~MESSAGE, error.name)
15237
#{error.message} inferred by #{inflector.class} from #{path_type}

lib/zeitwerk/loader/scanner.rb

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
# frozen_string_literal: true
2+
3+
module Zeitwerk::Loader::Scanner
4+
#: (String) { (String, String, Symbol) -> void } -> void
5+
private def ls(dir)
6+
children = relevant_dir_entries(dir)
7+
8+
# The order in which a directory is listed depends on the file system.
9+
#
10+
# Since client code may run on different platforms, it seems convenient to
11+
# sort directory entries. This provides more deterministic behavior, with
12+
# consistent eager loading in particular.
13+
children.sort_by!(&:first)
14+
15+
children.each do |basename, abspath, ftype|
16+
if :directory == ftype && !has_at_least_one_ruby_file?(abspath)
17+
log("directory #{abspath} is ignored because it has no Ruby files") if logger
18+
next
19+
end
20+
21+
yield basename, abspath, ftype
22+
end
23+
end
24+
25+
# Looks for a Ruby file using breadth-first search. This type of search is
26+
# important to list as less directories as possible and return fast in the
27+
# common case in which there are Ruby files in the passed directory.
28+
#
29+
#: (String) -> bool
30+
private def has_at_least_one_ruby_file?(dir)
31+
to_visit = [dir]
32+
33+
while (dir = to_visit.shift)
34+
relevant_dir_entries(dir) do |_, abspath, ftype|
35+
return true if :file == ftype
36+
to_visit << abspath
37+
end
38+
end
39+
40+
false
41+
end
42+
43+
#: (String) { (String, String, Symbol) -> void } -> void
44+
#: (String) -> [[String, String, Symbol]]
45+
private def relevant_dir_entries(dir)
46+
return enum_for(__method__, dir).to_a unless block_given?
47+
48+
each_ruby_file_or_directory(dir) do |basename, abspath, ftype|
49+
next if ignored_path?(abspath)
50+
51+
if :link == ftype
52+
begin
53+
ftype = File.stat(abspath).ftype.to_sym
54+
rescue Errno::ENOENT
55+
warn "ignoring broken symlink #{abspath}"
56+
next
57+
end
58+
end
59+
60+
if :file == ftype
61+
yield basename, abspath, ftype if rb_extension?(basename)
62+
elsif :directory == ftype
63+
# Conceptually, root directories represent a separate project tree.
64+
yield basename, abspath, ftype unless root_dir?(abspath)
65+
end
66+
end
67+
end
68+
69+
# Dir.scan is more efficient in common platforms, but it is going to take a
70+
# while for it to be available.
71+
#
72+
# The following compatibility methods have the same semantics but are written
73+
# to favor the performance of the Ruby fallback, which can save syscalls.
74+
#
75+
# In particular, by convention, any directory entry with a .rb extension is
76+
# assumed to be a file or a symlink to a file.
77+
#
78+
# These methods also freeze abspaths because that saves allocations when
79+
# passed later to File methods. See https://github.com/fxn/zeitwerk/pull/125.
80+
81+
if Dir.respond_to?(:scan) # Available in Ruby 4.1.
82+
#: (String) { (String, String, Symbol) -> void } -> void
83+
private def each_ruby_file_or_directory(dir)
84+
Dir.scan(dir) do |basename, ftype|
85+
next if hidden?(basename)
86+
87+
if rb_extension?(basename)
88+
abspath = File.join(dir, basename).freeze
89+
yield basename, abspath, :file # By convention.
90+
elsif :directory == ftype
91+
abspath = File.join(dir, basename).freeze
92+
yield basename, abspath, :directory
93+
elsif :link == ftype
94+
abspath = File.join(dir, basename).freeze
95+
yield basename, abspath, :directory if dir?(abspath)
96+
end
97+
end
98+
end
99+
else
100+
#: (String) { (String, String, Symbol) -> void } -> void
101+
private def each_ruby_file_or_directory(dir)
102+
Dir.each_child(dir) do |basename|
103+
next if hidden?(basename)
104+
105+
if rb_extension?(basename)
106+
abspath = File.join(dir, basename).freeze
107+
yield basename, abspath, :file # By convention.
108+
else
109+
abspath = File.join(dir, basename).freeze
110+
if dir?(abspath)
111+
yield basename, abspath, :directory
112+
end
113+
end
114+
end
115+
end
116+
end
117+
118+
# Encodes the documented conventions.
119+
#
120+
#: (String) -> Symbol?
121+
private def supported_ftype?(abspath)
122+
if rb_extension?(abspath)
123+
:file # By convention, we can avoid a syscall here.
124+
elsif dir?(abspath)
125+
:directory
126+
end
127+
end
128+
129+
#: (String) -> bool
130+
private def rb_extension?(path)
131+
path.end_with?(".rb")
132+
end
133+
134+
#: (String) -> bool
135+
private def dir?(path)
136+
File.directory?(path)
137+
end
138+
139+
#: (String) -> bool
140+
private def hidden?(basename)
141+
basename.start_with?(".")
142+
end
143+
144+
#: (String) { (String) -> void } -> void
145+
private def walk_up(abspath)
146+
loop do
147+
yield abspath
148+
abspath, basename = File.split(abspath)
149+
break if basename == "/"
150+
end
151+
end
152+
end

0 commit comments

Comments
 (0)