Class: Rigor::Environment::RbsLoader

Inherits:
Object
  • Object
show all
Defined in:
lib/rigor/environment/rbs_loader.rb

Overview

Loads RBS class declarations and method definitions from disk and exposes them to the inference engine in a small, stable surface.

Slice 4 phase 1 only enabled the RBS core signatures shipped with the ‘rbs` gem (`Object`, `Integer`, `String`, `Array`, …). Phase 2a adds opt-in stdlib library loading (`pathname`, `json`, `tempfile`, …) and arbitrary-directory signature loading (typically the project’s local ‘sig/` tree). Both are off by default on `RbsLoader.default` so the core-only fast path stays cheap; project-aware loading is opted into through for_project or by constructing a custom loader.

The default instance is shared across the process: building the core RBS environment costs hundreds of milliseconds and the data is read-only. The shared instance is frozen, but holds a mutable state hash for lazy memoization of the heavy ‘RBS::Environment` and `RBS::DefinitionBuilder` – the user-visible API stays purely functional.

See docs/internal-spec/inference-engine.md for the binding contract. rubocop:disable Metrics/ClassLength

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(libraries: [], signature_paths: [], cache_store: nil, virtual_rbs: []) ⇒ RbsLoader

Returns a new instance of RbsLoader.

Parameters:

  • libraries (Array<String, Symbol>) (defaults to: [])

    stdlib library names to load on top of core (e.g., ‘[“pathname”, “json”]`). Empty by default. Each entry MUST correspond to a directory under the `rbs` gem’s ‘stdlib/` tree; unknown names are silently dropped on environment build (the underlying `RBS::EnvironmentLoader` raises and we fail-soft).

  • signature_paths (Array<String, Pathname>) (defaults to: [])

    additional directories of ‘.rbs` files to load (typically the project’s ‘sig/` tree). Non-existent or non-directory paths are filtered out at build time so the loader stays robust to fixtures and bare repositories.

  • cache_store (Rigor::Cache::Store, nil) (defaults to: nil)

    the persistent cache the loader consults for translated constant lookups (and, in later v0.0.9 slices, other Marshal-clean reflection artefacts). Pass ‘nil` (the default) to skip the cache entirely; the runner threads its own Store through here when caching is enabled.

  • virtual_rbs (Array<[String, String]>) (defaults to: [])

    ADR-32 WD4 —‘[virtual_filename, rbs_source]` pairs synthesised from project source by a plugin’s ‘Manifest#source_rbs_synthesizer`. Merged into the env after `signature_paths:` and the vendored stubs. Pass `[]` (the default) when no synthesizer-emitting plugin is loaded.



153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
# File 'lib/rigor/environment/rbs_loader.rb', line 153

def initialize(libraries: [], signature_paths: [], cache_store: nil, virtual_rbs: [])
  @libraries = libraries.map(&:to_s).freeze
  @signature_paths = signature_paths.map { |p| Pathname(p) }.freeze
  @cache_store = cache_store
  @virtual_rbs = virtual_rbs.map { |name, content| [name.to_s.dup.freeze, content.to_s.dup.freeze].freeze }.freeze
  # Per-loader memoization bucket. Held as a single
  # mutable Hash so the loader instance itself can be
  # `.freeze`d (per ADR-15 reflection-facade contract)
  # without losing the lazy-memo behaviour. Slot names
  # currently consulted: `:env`, `:env_loaded`,
  # `:env_build_warned`, `:builder`, `:reflection`,
  # `:instance_definitions_table`,
  # `:singleton_definitions_table`. Constructed via
  # `Hash.new` (NOT a `{ ... }` literal) so Rigor's
  # `HashShape` narrowing doesn't infer a fixed key set
  # from the initial state and fold post-initial slot
  # reads (e.g. `@state[:env_loaded]`) to a constant
  # `nil`.
  @state = Hash.new # rubocop:disable Style/EmptyLiteral
  @instance_definition_cache = {}
  @singleton_definition_cache = {}
  @class_known_cache = {}
  @hierarchy = RbsHierarchy.new(self)
end

Instance Attribute Details

#cache_storeObject (readonly)

Returns the value of attribute cache_store.



127
128
129
# File 'lib/rigor/environment/rbs_loader.rb', line 127

def cache_store
  @cache_store
end

#librariesObject (readonly)

Returns the value of attribute libraries.



127
128
129
# File 'lib/rigor/environment/rbs_loader.rb', line 127

def libraries
  @libraries
end

#signature_pathsObject (readonly)

Returns the value of attribute signature_paths.



127
128
129
# File 'lib/rigor/environment/rbs_loader.rb', line 127

def signature_paths
  @signature_paths
end

#virtual_rbsObject (readonly)

Returns the value of attribute virtual_rbs.



127
128
129
# File 'lib/rigor/environment/rbs_loader.rb', line 127

def virtual_rbs
  @virtual_rbs
end

Class Method Details

.add_virtual_rbs(env, virtual_rbs) ⇒ Object

ADR-32 WD4 — merge synthesised-from-source RBS strings into the freshly-built environment. Each entry is a ‘[virtual_filename, rbs_source]` pair. `virtual_filename` is purely for diagnostic provenance (RBS parse errors cite it) — it is not a real file path. Per WD6 the synthesizer-emit path is responsible for catching its own parse errors and returning `nil` rather than garbage; this method assumes its input is parseable and only rescues `RBS::ParsingError` as a fail-soft.



87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
# File 'lib/rigor/environment/rbs_loader.rb', line 87

def add_virtual_rbs(env, virtual_rbs)
  return if virtual_rbs.nil? || virtual_rbs.empty?

  virtual_rbs.each do |filename, content|
    next if content.nil? || content.empty?

    buffer = ::RBS::Buffer.new(name: filename.to_s, content: content.to_s)
    _, directives, decls = ::RBS::Parser.parse_signature(buffer)
    source = ::RBS::Source::RBS.new(buffer, directives || [], decls || [])
    env.add_source(source)
  rescue ::RBS::BaseError
    # WD6 fail-soft: a single broken virtual RBS contribution
    # does not pull the whole env down. The plugin layer
    # records a `source-rbs-synthesis-failed` info diagnostic
    # in slice 2; here we just skip the entry.
  end
end

.build_env_for(libraries:, signature_paths:, virtual_rbs: []) ⇒ Object

Builds an ‘RBS::Environment` from explicit `libraries` and `signature_paths`. Stateless surface so the v0.0.9 Cache::RbsEnvironment producer can build an env on cache miss without holding a loader instance, and the instance-side #build_env delegates here so the implementation stays single-rooted.

Vendored gem stubs (‘data/vendored_gem_sigs/<gem>/`) are loaded on top of `signature_paths` so the per-gem RBS bundled with Rigor itself is in scope for every analysis run. The gem stubs are intentionally read-only and appended LAST so user-supplied `signature_paths` win on name conflicts.



59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
# File 'lib/rigor/environment/rbs_loader.rb', line 59

def build_env_for(libraries:, signature_paths:, virtual_rbs: [])
  rbs_loader = RBS::EnvironmentLoader.new
  libraries.each do |library|
    next unless rbs_loader.has_library?(library: library, version: nil)

    rbs_loader.add(library: library, version: nil)
  end
  signature_paths.each do |path|
    path = Pathname(path) unless path.is_a?(Pathname)
    rbs_loader.add(path: path) if path.directory?
  end
  vendored_gem_sig_paths.each do |path|
    rbs_loader.add(path: path) if path.directory?
  end
  env = RBS::Environment.from_loader(rbs_loader)
  add_virtual_rbs(env, virtual_rbs)
  env.resolve_type_names
end

.defaultObject



34
35
36
# File 'lib/rigor/environment/rbs_loader.rb', line 34

def default
  @default ||= new.freeze
end

.reset_default!Object

Used by tests to discard the cached default loader; production code MUST NOT call this. The shared loader holds a several-MB RBS::Environment, so dropping it during a normal run wastes the cost of rebuilding it.



42
43
44
# File 'lib/rigor/environment/rbs_loader.rb', line 42

def reset_default!
  @default = nil
end

.vendored_gem_sig_pathsObject



118
119
120
121
122
123
124
# File 'lib/rigor/environment/rbs_loader.rb', line 118

def vendored_gem_sig_paths
  return [] unless File.directory?(VENDORED_GEM_SIGS_ROOT)

  Dir.children(VENDORED_GEM_SIGS_ROOT).map do |gem_dir|
    Pathname(File.join(VENDORED_GEM_SIGS_ROOT, gem_dir))
  end
end

Instance Method Details

#class_decl_pathsObject

Returns a frozen ‘Hash<String, String>` mapping each loaded class / module name (top-level prefixed) to the file path of its FIRST declaration’s RBS source. Used by Analysis::RunStats to attribute the type universe between “project sig/” (paths under the configured ‘signature_paths`) and “bundled” (everything else — RBS core, stdlib libraries, gem-bundled RBS). Each value is a frozen `String` so the whole result is `Ractor.shareable?` — the Phase 4b worker pool ships a snapshot back to the coordinator on the first `:prepare` message.



280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
# File 'lib/rigor/environment/rbs_loader.rb', line 280

def class_decl_paths
  return {}.freeze if env.nil?

  result = {}
  env.class_decls.each do |rbs_name, entry|
    decl = entry.primary_decl
    next if decl.nil?

    location = decl.location
    next if location.nil?

    buffer = location.buffer
    name = buffer.respond_to?(:name) ? buffer.name : nil
    next if name.nil?

    result[rbs_name.to_s.dup.freeze] = name.to_s.dup.freeze
  end
  result.freeze
rescue ::RBS::BaseError
  {}.freeze
end

#class_known?(name) ⇒ Boolean

Returns true when an RBS class or module declaration with the given name is loaded. Accepts unprefixed or top-level-prefixed names (“Integer” or “::Integer”). Memoized per-name (positive and negative results both cache).

When ‘cache_store` is set, the loader fetches the entire set of known class / module / alias names once (per process) through Cache::RbsKnownClassNames.fetch and answers `class_known?` from the in-memory Set. Cold runs pay a single env walk and persist the result; warm runs (and a separate loader sharing the same Store) skip the env walk entirely.

Returns:

  • (Boolean)


189
190
191
192
193
194
195
196
197
198
# File 'lib/rigor/environment/rbs_loader.rb', line 189

def class_known?(name)
  key = name.to_s
  return @class_known_cache[key] if @class_known_cache.key?(key)

  @class_known_cache[key] = if cache_store
                              cached_class_known(name)
                            else
                              compute_class_known(name)
                            end
end

#class_ordering(lhs, rhs) ⇒ Object



409
410
411
# File 'lib/rigor/environment/rbs_loader.rb', line 409

def class_ordering(lhs, rhs)
  @hierarchy.class_ordering(lhs, rhs)
end

#class_type_param_names(class_name) ⇒ Object

Slice 4 phase 2d. Returns the class’s declared type-parameter names as Symbols (e.g., ‘[:Elem]` for `Array`, `[:K, :V]` for `Hash`). Used by the dispatcher to build the substitution map from receiver `type_args` into the method’s return type. The instance definition is the canonical source because singleton methods (e.g., ‘Array.new`) parameterize over the same `Elem` as instance methods.

Returns an empty array for non-generic classes and for unknown names (the loader stays fail-soft). NOTE: in the ‘rbs` gem, `RBS::Definition#type_params` returns `Array<Symbol>` directly, not the AST `TypeParam` object (those live on the AST level).

When ‘cache_store` is set, the loader fetches the entire type-parameter-name table once (per process) through Cache::RbsClassTypeParamNames.fetch and answers point lookups from it. Cold runs build the table once and persist it; warm runs (and a separate loader sharing the same Store) skip the env walk entirely.



397
398
399
400
401
402
403
404
405
406
407
# File 'lib/rigor/environment/rbs_loader.rb', line 397

def class_type_param_names(class_name)
  if cache_store
    key = class_name.to_s.delete_prefix("::")
    return type_param_names_table.fetch(key, []).dup
  end

  definition = instance_definition(class_name)
  return [] unless definition

  definition.type_params.dup
end

#constant_namesArray<String>

Returns every RBS-declared constant name (top-level prefixed, e.g., ‘“::Math::PI”`) currently loaded into the environment. Used by the cache producer that materialises the constant-type table; ordinary callers should keep using #constant_type for point lookups.

Returns:

  • (Array<String>)

    every RBS-declared constant name (top-level prefixed, e.g., ‘“::Math::PI”`) currently loaded into the environment. Used by the cache producer that materialises the constant-type table; ordinary callers should keep using #constant_type for point lookups.



418
419
420
421
422
423
424
# File 'lib/rigor/environment/rbs_loader.rb', line 418

def constant_names
  return [] if env.nil?

  env.constant_decls.keys.map(&:to_s)
rescue ::RBS::BaseError
  []
end

#constant_type(name) ⇒ Object

Slice A constant-value lookup. Returns the translated ‘Rigor::Type` for a non-class constant declaration (`BUCKETS: Array`, `DEFAULT_PATH: String`, …) or `nil` when no constant entry exists for `name` in the loaded RBS environment. Callers MUST treat the return value as authoritative when present and as “unknown” when nil; the loader does NOT consult the class declarations here — class objects are still resolved through #class_known? and `Environment#singleton_for_name`.

When ‘cache_store` is set, the loader fetches the entire translated constant table once (per process) through Cache::RbsConstantTable.fetch and answers point lookups from it. Cold runs pay the translation cost up-front and write the result to disk; warm runs skip the translation entirely and pay only a `Marshal.load` of the table.



458
459
460
461
462
463
464
465
466
467
468
469
# File 'lib/rigor/environment/rbs_loader.rb', line 458

def constant_type(name)
  rbs_name = parse_type_name(name)
  return nil unless rbs_name

  if cache_store
    constant_type_table[rbs_name.to_s]
  else
    translate_constant_decl(rbs_name)
  end
rescue ::RBS::BaseError
  nil
end

#each_class_decl_annotationObject

ADR-20 slice 2e — iterates over every ‘%a…` annotation attached to a class- or module-level declaration in the loaded RBS environment, yielding `(annotation_string, source_location)` pairs. Used by Inference::HktRegistry.scan_rbs_loader to find `rigor:v1:hkt_register` / `rigor:v1:hkt_define` directives in user-authored overlays and merge them into the per-`Environment` HKT registry. Yields nothing when the env failed to build (fail-soft, same shape as #each_known_class_name).



250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
# File 'lib/rigor/environment/rbs_loader.rb', line 250

def each_class_decl_annotation
  return enum_for(:each_class_decl_annotation) unless block_given?
  return if env.nil?

  env.class_decls.each_value do |entry|
    entry.each_decl do |decl|
      next unless decl.respond_to?(:annotations)

      decl.annotations.each { |a| yield a.string, a.location }
    end
  end
rescue ::RBS::BaseError, ::Ractor::IsolationError
  # fail-soft: matches each_known_class_name's policy.
  # Ractor::IsolationError surfaces when the scan is
  # invoked from a non-main Ractor pool worker before
  # ADR-15's full deep-freeze migration completes — the
  # worker falls back to the base (builtins-only)
  # registry rather than crashing.
end

#each_constant_declObject

Yields ‘(name, entry)` for every RBS constant declaration currently loaded into the environment. The cache producer uses this to materialise the constant-type table without going back through #constant_type (which would recurse back into the cache when `cache_store` is set).



431
432
433
434
435
436
437
438
439
440
# File 'lib/rigor/environment/rbs_loader.rb', line 431

def each_constant_decl
  return enum_for(:each_constant_decl) unless block_given?
  return if env.nil?

  env.constant_decls.each do |rbs_name, entry|
    yield rbs_name.to_s, entry
  end
rescue ::RBS::BaseError
  # fail-soft: a broken RBS environment yields no entries.
end

#each_known_class_nameObject

Yields every known class / module / alias name (top-level prefixed) currently loaded into the environment. The cache producer that materialises the known-name set uses this so it never recurses back through #class_known?.



226
227
228
229
230
231
232
233
234
235
236
237
238
# File 'lib/rigor/environment/rbs_loader.rb', line 226

def each_known_class_name
  return enum_for(:each_known_class_name) unless block_given?
  return if env.nil?

  env.class_decls.each_key { |rbs_name| yield rbs_name.to_s }
  env.class_alias_decls.each_key { |rbs_name| yield rbs_name.to_s }
rescue ::RBS::BaseError
  # fail-soft: a broken RBS environment yields no names.
  # Analyzer-internal errors (NameError, NoMethodError,
  # LoadError) are NOT swallowed — those are bugs and
  # must surface so they don't hide silently the way the
  # v0.0.9 cache `Cache::Descriptor` regression did.
end

#instance_definition(class_name) ⇒ RBS::Definition?

When ‘cache_store` is set, the loader fetches the per-class definition through Cache::RbsInstanceDefinitions.fetch so subsequent runs (and other loaders sharing the same Store) skip the `RBS::DefinitionBuilder.build_instance` step. In-memory `@instance_definition_cache` keeps the per-process short-circuit on top.

Returns:

  • (RBS::Definition, nil)

    the resolved instance definition for ‘class_name`, or nil when the class is unknown or its definition cannot be built (RBS may raise on broken hierarchies; we fail-soft and return nil so the caller can fall back).



313
314
315
316
317
318
319
320
321
322
# File 'lib/rigor/environment/rbs_loader.rb', line 313

def instance_definition(class_name)
  key = class_name.to_s
  return @instance_definition_cache[key] if @instance_definition_cache.key?(key)

  @instance_definition_cache[key] = if cache_store
                                      cached_instance_definition(class_name)
                                    else
                                      build_instance_definition(class_name)
                                    end
end

#instance_method(class_name:, method_name:) ⇒ RBS::Definition::Method?

Returns:

  • (RBS::Definition::Method, nil)


333
334
335
336
337
338
# File 'lib/rigor/environment/rbs_loader.rb', line 333

def instance_method(class_name:, method_name:)
  definition = instance_definition(class_name)
  return nil unless definition

  definition.methods[method_name.to_sym]
end

#prewarmObject

ADR-15 Phase 4b.x — eagerly drives every cached producer so a subsequent worker Ractor can serve all of its RBS queries from the Marshal blob on disk without ever calling ‘RBS::EnvironmentLoader.new`. The loader path that calls `EnvironmentLoader.new` transitively reads a chain of non-`Ractor.shareable?` module constants (`RBS::EnvironmentLoader::DEFAULT_CORE_ROOT`, `RBS::Repository::DEFAULT_STDLIB_ROOT`, `Gem::Requirement::DefaultRequirement`, …) and trips `Ractor::IsolationError`. Pre-warming the cache on the main Ractor and letting workers consult ONLY the Marshal-loaded blob sidesteps the whole chain.

No-op when ‘cache_store` is nil — without a Store the worker has no choice but to build env via the loader, so the caller MUST ensure pool mode runs with caching enabled. Returns `self` so the call chains cleanly from the `Runner` pre-spawn hook.



490
491
492
493
494
495
496
497
498
499
500
501
# File 'lib/rigor/environment/rbs_loader.rb', line 490

def prewarm
  return self if cache_store.nil?

  env
  known_class_names_set
  constant_type_table
  type_param_names_table
  ancestor_names_table
  instance_definitions_table
  singleton_definitions_table
  self
end

#rbs_module?(name) ⇒ Boolean

Returns true when the named RBS declaration is a Module (‘RBS::AST::Declarations::Module`) rather than a Class. The `user_class_fallback_receiver` tier consults this to route `Nominal.some_kernel_method` (where M is a module mixin like `PP::ObjectMixin`) through the `Nominal` fallback, because every concrete includer of M sees Kernel / Object instance methods as part of its own ancestor chain.

Returns false for classes, for unknown names, and when the RBS environment failed to build (fail-soft).

Returns:

  • (Boolean)


210
211
212
213
214
215
216
217
218
219
220
# File 'lib/rigor/environment/rbs_loader.rb', line 210

def rbs_module?(name)
  return false if env.nil?

  rbs_name = parse_type_name(name)
  return false if rbs_name.nil?

  entry = env.class_decls[rbs_name]
  entry.is_a?(::RBS::Environment::ModuleEntry)
rescue ::RBS::BaseError
  false
end

#reflectionObject

ADR-15 Phase 2b — return the loader’s read-only query surface as a frozen, ‘Ractor.shareable?` Rigor::Environment::Reflection value object. Built lazily on first access; the loader memoises so repeated calls return the same instance.

The Reflection consumes the loader’s already-warmed cache producers (or, when no ‘cache_store` is set, eagerly walks the env). Once constructed, the Reflection carries the derived tables independently and never re-consults the loader — making it safe to share across Ractors while the loader stays per- process / per-Ractor for write-path operations.



516
517
518
519
520
521
522
523
524
525
526
527
528
# File 'lib/rigor/environment/rbs_loader.rb', line 516

def reflection
  @state[:reflection] ||= begin
    require_relative "reflection"
    Environment::Reflection.new(
      known_class_names: known_class_names_set,
      instance_definitions: instance_definitions_table,
      singleton_definitions: singleton_definitions_table,
      type_param_names: type_param_names_table,
      constant_types: constant_type_table,
      ancestor_names: ancestor_names_table
    )
  end
end

#singleton_definition(class_name) ⇒ RBS::Definition?

When ‘cache_store` is set, the loader fetches the per-class singleton definition through Cache::RbsSingletonDefinitions.fetch; the same caching discipline as #instance_definition.

Returns:

  • (RBS::Definition, nil)

    the resolved singleton (class object) definition for ‘class_name`. The methods on this definition are the *class methods* of `class_name`, including those inherited from `Class` and `Module` for class types. Returns nil for unknown names and on RBS build errors (fail-soft).



350
351
352
353
354
355
356
357
358
359
# File 'lib/rigor/environment/rbs_loader.rb', line 350

def singleton_definition(class_name)
  key = class_name.to_s
  return @singleton_definition_cache[key] if @singleton_definition_cache.key?(key)

  @singleton_definition_cache[key] = if cache_store
                                       cached_singleton_definition(class_name)
                                     else
                                       build_singleton_definition(class_name)
                                     end
end

#singleton_method(class_name:, method_name:) ⇒ RBS::Definition::Method?

Returns the class method on ‘class_name`. For example, `singleton_method(class_name: “Integer”, method_name: :sqrt)` returns the definition for `Integer.sqrt`, while `singleton_method(class_name: “Foo”, method_name: :new)` returns Class#new for any class type.

Returns:

  • (RBS::Definition::Method, nil)

    the class method on ‘class_name`. For example, `singleton_method(class_name: “Integer”, method_name: :sqrt)` returns the definition for `Integer.sqrt`, while `singleton_method(class_name: “Foo”, method_name: :new)` returns Class#new for any class type.



371
372
373
374
375
376
# File 'lib/rigor/environment/rbs_loader.rb', line 371

def singleton_method(class_name:, method_name:)
  definition = singleton_definition(class_name)
  return nil unless definition

  definition.methods[method_name.to_sym]
end

#uncached_instance_definition(class_name) ⇒ Object

Public uncached accessor used by the cache producer (Cache::RbsInstanceDefinitions). Avoids the ‘private_method_called` round-trip a `loader.send(…)` callsite would require.



328
329
330
# File 'lib/rigor/environment/rbs_loader.rb', line 328

def uncached_instance_definition(class_name)
  build_instance_definition(class_name)
end

#uncached_singleton_definition(class_name) ⇒ Object

Public uncached accessor used by the cache producer.



362
363
364
# File 'lib/rigor/environment/rbs_loader.rb', line 362

def uncached_singleton_definition(class_name)
  build_singleton_definition(class_name)
end