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) ⇒ 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.



117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
# File 'lib/rigor/environment/rbs_loader.rb', line 117

def initialize(libraries: [], signature_paths: [], cache_store: nil)
  @libraries = libraries.map(&:to_s).freeze
  @signature_paths = signature_paths.map { |p| Pathname(p) }.freeze
  @cache_store = cache_store
  # 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.



98
99
100
# File 'lib/rigor/environment/rbs_loader.rb', line 98

def cache_store
  @cache_store
end

#librariesObject (readonly)

Returns the value of attribute libraries.



98
99
100
# File 'lib/rigor/environment/rbs_loader.rb', line 98

def libraries
  @libraries
end

#signature_pathsObject (readonly)

Returns the value of attribute signature_paths.



98
99
100
# File 'lib/rigor/environment/rbs_loader.rb', line 98

def signature_paths
  @signature_paths
end

Class Method Details

.build_env_for(libraries:, signature_paths:) ⇒ 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
# File 'lib/rigor/environment/rbs_loader.rb', line 59

def build_env_for(libraries:, signature_paths:)
  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
  RBS::Environment.from_loader(rbs_loader).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



89
90
91
92
93
94
95
# File 'lib/rigor/environment/rbs_loader.rb', line 89

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.



243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
# File 'lib/rigor/environment/rbs_loader.rb', line 243

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)


152
153
154
155
156
157
158
159
160
161
# File 'lib/rigor/environment/rbs_loader.rb', line 152

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



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

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.



360
361
362
363
364
365
366
367
368
369
370
# File 'lib/rigor/environment/rbs_loader.rb', line 360

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.



381
382
383
384
385
386
387
# File 'lib/rigor/environment/rbs_loader.rb', line 381

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.



421
422
423
424
425
426
427
428
429
430
431
432
# File 'lib/rigor/environment/rbs_loader.rb', line 421

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).



213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
# File 'lib/rigor/environment/rbs_loader.rb', line 213

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).



394
395
396
397
398
399
400
401
402
403
# File 'lib/rigor/environment/rbs_loader.rb', line 394

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?.



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

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).



276
277
278
279
280
281
282
283
284
285
# File 'lib/rigor/environment/rbs_loader.rb', line 276

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)


296
297
298
299
300
301
# File 'lib/rigor/environment/rbs_loader.rb', line 296

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.



453
454
455
456
457
458
459
460
461
462
463
464
# File 'lib/rigor/environment/rbs_loader.rb', line 453

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)


173
174
175
176
177
178
179
180
181
182
183
# File 'lib/rigor/environment/rbs_loader.rb', line 173

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.



479
480
481
482
483
484
485
486
487
488
489
490
491
# File 'lib/rigor/environment/rbs_loader.rb', line 479

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).



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

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.



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

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.



291
292
293
# File 'lib/rigor/environment/rbs_loader.rb', line 291

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.



325
326
327
# File 'lib/rigor/environment/rbs_loader.rb', line 325

def uncached_singleton_definition(class_name)
  build_singleton_definition(class_name)
end