Class: Ryac::Pipeline::FileCollector
- Inherits:
-
Object
- Object
- Ryac::Pipeline::FileCollector
- Defined in:
- lib/ryac/pipeline/file_collector.rb,
sig/ryac/pipeline/file_collector.rbs
Overview
Stage 1: File Collection Discovers all dependencies via static analysis of require/require_relative/autoload
Instance Method Summary collapse
-
#call(entry_path, project_root: nil, gem_names: [], gem_require_paths: []) ⇒ DependencyGraph
Graph of all discovered files.
-
#collect_file(file_path, required_from: nil, line: nil, lazy: false) ⇒ void
Recursively collect a file and its dependencies.
-
#collect_lazy_candidates(node, arg, nodes, file_path, in_class:) ⇒ void
A dynamic require inside a method stays a runtime require.
- #collect_rbs_files(_entry_paths) ⇒ void
- #collect_rbs_stdlib_files ⇒ void
-
#dir_interpolated_path(path_arg) ⇒ String?
"#__dir__/mixin/foo" reads as dynamic but is a constant at collection time: dir is the directory of the file being collected.
- #ensure_load_paths(require_paths) ⇒ void
-
#extract_require_nodes(file_path, content) ⇒ Array[require_node_info]
Extract require/require_relative/autoload nodes from source.
- #find_project_root(entry_paths) ⇒ String?
- #handle_autoload(node, nodes, file_path, in_method: false, in_class: false) ⇒ void
- #handle_call_node(node, nodes, file_path, in_method: false, in_class: false) ⇒ void
- #handle_require(node, nodes, file_path, in_method: false, in_class: false) ⇒ void
- #handle_require_relative(node, nodes, file_path, in_method: false, in_class: false) ⇒ void
- #load_rbs_from(dir) ⇒ void
-
#resolve_bare_require(path) ⇒ String?
Resolve a bare require path (e.g., "foo") via $LOAD_PATH.
- #resolve_relative_path(path, from_file) ⇒ String
-
#traverse_for_requires(node, nodes, file_path, in_method: false, in_class: false) ⇒ void
Traverse AST to find require statements.
Instance Method Details
#call(entry_path, project_root: nil, gem_names: [], gem_require_paths: []) ⇒ DependencyGraph
Returns Graph of all discovered files.
15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 |
# File 'lib/ryac/pipeline/file_collector.rb', line 15 def call(entry_path, project_root: nil, gem_names: [], gem_require_paths: []) raise NoFilesError.new if entry_path.nil? entry_paths = Array(entry_path) #: Array[String] raise NoFilesError.new if entry_paths.empty? @graph = DependencyGraph.new @visited = Set.new @pending_lazy = [] #: Array[String] @gem_names = gem_names @project_roots = if project_root # Array() leaves only Strings whichever of the two shapes came in Array(project_root).map { |p| File.(p) } # steep:ignore ArgumentTypeMismatch else root = find_project_root(entry_paths) root ? [root] : [] end ensure_load_paths(gem_require_paths) entry_paths.each do |path| = File.(path) raise FileNotFoundError.new() unless File.exist?() collect_file() end # Only once the static world is complete: a candidate it reached is # bundled flat and already visited; the rest — and whatever they # require that nothing static did — become lazy regions. collect_file(@pending_lazy.shift, lazy: true) until @pending_lazy.empty? collect_rbs_files(entry_paths) @graph end |
#collect_file(file_path, required_from: nil, line: nil, lazy: false) ⇒ void
This method returns an undefined value.
Recursively collect a file and its dependencies
53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 |
# File 'lib/ryac/pipeline/file_collector.rb', line 53 def collect_file(file_path, required_from: nil, line: nil, lazy: false) return if @visited.include?(file_path) unless File.exist?(file_path) raise FileNotFoundError.new(file_path, required_from: required_from, line: line) end @visited.add(file_path) # Ruby source defaults to UTF-8; reading with the ambient locale instead # tags non-ASCII sources US-ASCII under a POSIX locale and every later # String operation raises on the invalid bytes. content = File.read(file_path, encoding: Encoding::UTF_8) # Parse and extract require statements require_nodes = extract_require_nodes(file_path, content) dependencies = [] #: Array[String] in_class_dependencies = [] #: Array[String] require_nodes.each do |node_info| dep_path = node_info[:resolved_path] next unless dep_path if node_info[:in_class] in_class_dependencies << dep_path else dependencies << dep_path end collect_file(dep_path, required_from: file_path, line: node_info[:line], lazy: lazy) end entry = FileEntry.new( path: file_path, content: content, dependencies: dependencies, in_class_dependencies: in_class_dependencies, require_nodes: require_nodes, lazy: lazy ) @graph.add_file(entry) end |
#collect_lazy_candidates(node, arg, nodes, file_path, in_class:) ⇒ void
This method returns an undefined value.
A dynamic require inside a method stays a runtime require. When its path starts with a static directory ("driver/#name"), the files it can load exist on disk now, so they join the bundle — as lazy regions, each run at the moment the require would have run — and the site is recorded for the Concatenator to point at the loader.
196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 |
# File 'lib/ryac/pipeline/file_collector.rb', line 196 def collect_lazy_candidates(node, arg, nodes, file_path, in_class:) return unless arg.is_a?(Prism::InterpolatedStringNode) head = arg.parts.first return unless head.is_a?(Prism::StringNode) slash = head.unescaped.rindex('/') return unless slash prefix = head.unescaped[0..slash] #: String # The prefix is respelled in place, which needs the source to spell # it as it reads. return unless head.content.start_with?(prefix) dir = File.(prefix, File.dirname(file_path)) candidates = Dir.glob(File.join(dir, '*.rb')).sort return if candidates.empty? tail = arg.parts.last suffix_start = tail.is_a?(Prism::StringNode) && tail.content.end_with?('.rb') ? tail.content_loc.end_offset - 3 : nil nodes << { type: :require_lazy, path: prefix, line: node.location.start_line, start_offset: node.location.start_offset, length: node.location.length, in_class: in_class, in_method: true, lazy: { dir: dir, arg_start_offset: arg.location.start_offset, arg_end_offset: arg.location.end_offset, prefix_start_offset: head.content_loc.start_offset, prefix_length: prefix.bytesize, suffix_start_offset: suffix_start } } @pending_lazy.concat(candidates) end |
#collect_rbs_files(_entry_paths) ⇒ void
This method returns an undefined value.
363 364 365 366 367 368 369 |
# File 'lib/ryac/pipeline/file_collector.rb', line 363 def collect_rbs_files(_entry_paths) @project_roots.each do |root| load_rbs_from(File.join(root, "sig")) end collect_rbs_stdlib_files end |
#collect_rbs_stdlib_files ⇒ void
This method returns an undefined value.
371 372 373 374 375 376 377 378 379 380 381 382 383 384 |
# File 'lib/ryac/pipeline/file_collector.rb', line 371 def collect_rbs_stdlib_files return if @gem_names.empty? stdlib_root = RBS::Repository::DEFAULT_STDLIB_ROOT @gem_names.each do |gem_name| gem_rbs_dir = File.join(stdlib_root, gem_name) versions = Dir.children(gem_rbs_dir) rescue next next if versions.empty? latest = versions.max_by { |v| Gem::Version.new(v) } #: String load_rbs_from(File.join(gem_rbs_dir, latest)) end end |
#dir_interpolated_path(path_arg) ⇒ String?
"#__dir__/mixin/foo" reads as dynamic but is a constant at collection time: dir is the directory of the file being collected. Recognizes exactly that shape — a sole receiverless, argument-less dir interpolation, then a /-prefixed literal tail — and returns it as the file-relative "./mixin/foo" so the ordinary relative resolution runs; nil for every other interpolation.
340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 |
# File 'lib/ryac/pipeline/file_collector.rb', line 340 def dir_interpolated_path(path_arg) return nil unless path_arg.is_a?(Prism::InterpolatedStringNode) parts = path_arg.parts return nil unless parts.size == 2 interp, rest = parts return nil unless interp.is_a?(Prism::EmbeddedStatementsNode) && rest.is_a?(Prism::StringNode) call = AstUtils.unwrap_statements(interp.statements) return nil unless call.is_a?(Prism::CallNode) && call.name == :__dir__ && call.receiver.nil? && call.arguments.nil? tail = rest.unescaped tail.start_with?('/') ? ".#{tail}" : nil end |
#ensure_load_paths(require_paths) ⇒ void
This method returns an undefined value.
357 358 359 360 361 |
# File 'lib/ryac/pipeline/file_collector.rb', line 357 def ensure_load_paths(require_paths) require_paths.each do |path| $LOAD_PATH.unshift(path) unless $LOAD_PATH.include?(path) end end |
#extract_require_nodes(file_path, content) ⇒ Array[require_node_info]
Extract require/require_relative/autoload nodes from source
96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 |
# File 'lib/ryac/pipeline/file_collector.rb', line 96 def extract_require_nodes(file_path, content) result = Prism.parse(content) # Collection is the only point that still knows which file a byte # came from — a syntax error surfaces here with real coordinates, # or downstream as a nameless internal failure. error = result.errors[0] if error raise Ryac::SyntaxError.new(error., path: file_path, line: error.location.start_line, column: error.location.start_column) end nodes = [] #: Array[require_node_info] traverse_for_requires(result.value, nodes, file_path) nodes end |
#find_project_root(entry_paths) ⇒ String?
392 393 394 395 396 397 398 399 |
# File 'lib/ryac/pipeline/file_collector.rb', line 392 def find_project_root(entry_paths) dir = File.dirname(File.(entry_paths.first)) until dir == "/" return dir if File.exist?(File.join(dir, "Gemfile")) || File.directory?(File.join(dir, ".git")) dir = File.dirname(dir) end nil end |
#handle_autoload(node, nodes, file_path, in_method: false, in_class: false) ⇒ void
This method returns an undefined value.
286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 |
# File 'lib/ryac/pipeline/file_collector.rb', line 286 def handle_autoload(node, nodes, file_path, in_method: false, in_class: false) args = node.arguments&.arguments return unless args && args.size >= 2 path_arg = args[1] path = if path_arg.is_a?(Prism::StringNode) path_arg.unescaped else dir_interpolated_path(path_arg) end if path # Treat autoload paths like require_relative for local files if path.start_with?('./', '../') || !path.include?('/') nodes << { type: :autoload, path: path, line: node.location.start_line, start_offset: node.location.start_offset, length: node.location.length, in_class: in_class, in_method: in_method, resolved_path: resolve_relative_path(path, file_path) } elsif (resolved = (path)) nodes << { type: :autoload, path: path, line: node.location.start_line, start_offset: node.location.start_offset, length: node.location.length, in_class: in_class, in_method: in_method, resolved_path: resolved } end else return if in_method raise DynamicRequireError.new( file_path, line: node.location.start_line, expression: node.slice ) end end |
#handle_call_node(node, nodes, file_path, in_method: false, in_class: false) ⇒ void
This method returns an undefined value.
143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 |
# File 'lib/ryac/pipeline/file_collector.rb', line 143 def handle_call_node(node, nodes, file_path, in_method: false, in_class: false) method_name = node.name case method_name when :require_relative handle_require_relative(node, nodes, file_path, in_method: in_method, in_class: in_class) when :require handle_require(node, nodes, file_path, in_method: in_method, in_class: in_class) when :autoload handle_autoload(node, nodes, file_path, in_method: in_method, in_class: in_class) end # Continue traversing for nested calls node.arguments&.arguments&.each do |arg| traverse_for_requires(arg, nodes, file_path, in_method: in_method, in_class: in_class) end traverse_for_requires(node.block, nodes, file_path, in_method: in_method, in_class: in_class) if node.block end |
#handle_require(node, nodes, file_path, in_method: false, in_class: false) ⇒ void
This method returns an undefined value.
236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 |
# File 'lib/ryac/pipeline/file_collector.rb', line 236 def handle_require(node, nodes, file_path, in_method: false, in_class: false) arg = node.arguments&.arguments&.first return unless arg if arg.is_a?(Prism::StringNode) path = arg.unescaped if path.start_with?('./', '../') nodes << { type: :require, path: path, line: node.location.start_line, start_offset: node.location.start_offset, length: node.location.length, in_class: in_class, in_method: in_method, resolved_path: resolve_relative_path(path, file_path) } elsif (resolved = (path)) nodes << { type: :require, path: path, line: node.location.start_line, start_offset: node.location.start_offset, length: node.location.length, in_class: in_class, in_method: in_method, resolved_path: resolved } else nodes << { type: :require_stdlib, path: path, line: node.location.start_line, start_offset: node.location.start_offset, length: node.location.length, in_class: in_class, in_method: in_method } end else return if in_method raise DynamicRequireError.new( file_path, line: node.location.start_line, expression: node.slice ) end end |
#handle_require_relative(node, nodes, file_path, in_method: false, in_class: false) ⇒ void
This method returns an undefined value.
162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 |
# File 'lib/ryac/pipeline/file_collector.rb', line 162 def handle_require_relative(node, nodes, file_path, in_method: false, in_class: false) arg = node.arguments&.arguments&.first return unless arg if arg.is_a?(Prism::StringNode) nodes << { type: :require_relative, path: arg.unescaped, line: node.location.start_line, start_offset: node.location.start_offset, length: node.location.length, in_class: in_class, in_method: in_method, resolved_path: resolve_relative_path(arg.unescaped, file_path) } else if in_method collect_lazy_candidates(node, arg, nodes, file_path, in_class: in_class) return end raise DynamicRequireError.new( file_path, line: node.location.start_line, expression: node.slice ) end end |
#load_rbs_from(dir) ⇒ void
This method returns an undefined value.
386 387 388 389 390 |
# File 'lib/ryac/pipeline/file_collector.rb', line 386 def load_rbs_from(dir) Dir.glob(File.join(dir, "**", "*.rbs")).each do |path| @graph.rbs_files[path] = File.read(path, encoding: Encoding::UTF_8) end end |
#resolve_bare_require(path) ⇒ String?
Resolve a bare require path (e.g., "foo") via $LOAD_PATH. Returns absolute path if the file is under any project root, nil otherwise.
408 409 410 411 412 413 414 415 416 417 |
# File 'lib/ryac/pipeline/file_collector.rb', line 408 def (path) result = $LOAD_PATH.resolve_feature_path(path) return nil unless result type, abs_path = result return nil unless type == :rb return nil unless @project_roots.any? { |root| abs_path.start_with?("#{root}/") } abs_path end |
#resolve_relative_path(path, from_file) ⇒ String
401 402 403 404 |
# File 'lib/ryac/pipeline/file_collector.rb', line 401 def resolve_relative_path(path, from_file) path += '.rb' unless path.end_with?('.rb') File.(path, File.dirname(from_file)) end |
#traverse_for_requires(node, nodes, file_path, in_method: false, in_class: false) ⇒ void
This method returns an undefined value.
Traverse AST to find require statements
116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 |
# File 'lib/ryac/pipeline/file_collector.rb', line 116 def traverse_for_requires(node, nodes, file_path, in_method: false, in_class: false) return unless node case node when Prism::CallNode handle_call_node(node, nodes, file_path, in_method: in_method, in_class: in_class) when Prism::ProgramNode traverse_for_requires(node.statements, nodes, file_path, in_method: in_method, in_class: in_class) when Prism::StatementsNode node.body.each { |child| traverse_for_requires(child, nodes, file_path, in_method: in_method, in_class: in_class) } when Prism::ClassNode, Prism::ModuleNode traverse_for_requires(node.body, nodes, file_path, in_method: in_method, in_class: true) when Prism::DefNode traverse_for_requires(node.body, nodes, file_path, in_method: true, in_class: in_class) when Prism::IfNode traverse_for_requires(node.statements, nodes, file_path, in_method: in_method, in_class: in_class) traverse_for_requires(node.subsequent, nodes, file_path, in_method: in_method, in_class: in_class) when Prism::UnlessNode traverse_for_requires(node.statements, nodes, file_path, in_method: in_method, in_class: in_class) traverse_for_requires(node.else_clause, nodes, file_path, in_method: in_method, in_class: in_class) when Prism::ElseNode traverse_for_requires(node.statements, nodes, file_path, in_method: in_method, in_class: in_class) when Prism::BeginNode traverse_for_requires(node.statements, nodes, file_path, in_method: in_method, in_class: in_class) end end |