Class: Rigor::LanguageServer::ProjectContext

Inherits:
Object
  • Object
show all
Defined in:
lib/rigor/language_server/project_context.rb

Overview

Per-session cache of the project-wide analyzer state the LSP reads on every request — chiefly the Environment (with its ~100-300ms RBS env build), a read-only Cache::Store that lets the runner hit the on-disk RBS cache without writing back, and (since the pre-pass cache slice) a frozen Analysis::ProjectScan snapshot covering the plugin registry, dependency-source index, and pre-pass scanner outputs.

The pre-pass scan lets DiagnosticPublisher#run_analysis build a Runner with prebuilt: so per-buffer publishes skip plugin #prepare, the synthetic-method scanner, the project-patched scanner, and the dependency-source walker. For projects with substrate plugins / opt-in dependency source / sizeable pre_eval: configuration this cuts publish wall time substantially — for the trivial case the savings are small (the per-publish path is already ≈2ms once Environment is warm).

Invalidation:

  • #invalidate! drops the cached environment AND project scan + bumps the generation counter; the next reader rebuilds. Watched-file changes (workspace/didChangeWatchedFiles) and configuration refreshes (workspace/didChangeConfiguration) both trigger this — the next publish observes the new project state.
  • The cache store is NOT invalidated on file change — it's content-addressed (digests over file contents), so stale entries naturally lose their key match. We DO keep a single Store instance across the session so the in-process memo serves repeat reads cheaply.

Editor-mode trade-off: the cached project_scan was built without any buffer: binding so scanners observed on-disk bytes for every project file (including the file the user is editing right now). Edits to a file that itself declares Plugin::Macro::HeredocTemplate consumers or pre_eval:-listed methods are not visible until a watched-file change triggers invalidate!. The common editor flow (save → file watch fires → publish) refreshes automatically; the rare in-flight edit to a substrate-DSL file is the documented edge case.

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(configuration:) ⇒ ProjectContext

Returns a new instance of ProjectContext.



44
45
46
47
48
49
50
51
52
# File 'lib/rigor/language_server/project_context.rb', line 44

def initialize(configuration:)
  @configuration = configuration
  @generation = 0
  @environment = nil
  @cache_store = nil
  @project_scan = nil
  @incremental_session = nil
  @session_primed = false
end

Instance Attribute Details

#configurationObject (readonly)

generation is the invalidation counter: a long-running save round (#246) captures it on entry and discards its result if it moved, so diagnostics computed against a world that has since been invalidated never reach the editor.



42
43
44
# File 'lib/rigor/language_server/project_context.rb', line 42

def configuration
  @configuration
end

#generationObject (readonly)

generation is the invalidation counter: a long-running save round (#246) captures it on entry and discards its result if it moved, so diagnostics computed against a world that has since been invalidated never reach the editor.



42
43
44
# File 'lib/rigor/language_server/project_context.rb', line 42

def generation
  @generation
end

Instance Method Details

#cache_storeObject

Returns the per-session read-only Cache::Store. Read-only so multiple LSP sessions against the same project don't race on cache writes — same contract editor mode v1 already uses for the CLI --tmp-file path.



88
89
90
# File 'lib/rigor/language_server/project_context.rb', line 88

def cache_store
  @cache_store ||= Cache::Store.new(root: @configuration.cache_path, read_only: true)
end

#environmentObject

Returns the cached Rigor::Environment for this session, building it on first access. The build includes the project's full scan state (plugin registry, dependency-source index, synthetic-method / project-patched indexes — drawn from #project_scan) AND every Bundler / RBS-collection axis the runner consults at build time, so the resulting env is bit-for-bit equivalent to what Runner.run would have built on its own.

DiagnosticPublisher passes this env through Runner.new(environment: …) so per-buffer publishes share one instance instead of repeating the Environment.for_project build per call (bundler discovery, RbsLoader construction, signature_paths composition). Subsequent calls return the same instance until #invalidate! drops the cache.

The runner attaches its own per-call reporter pair onto the shared env's Reporters slot at the start of each #analyze_files — so diagnostic events stay scoped to a single publish and do NOT accumulate across publishes.



68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
# File 'lib/rigor/language_server/project_context.rb', line 68

def environment
  @environment ||= Environment.for_project(
    libraries: @configuration.libraries,
    signature_paths: @configuration.signature_paths,
    cache_store: cache_store,
    plugin_registry: project_scan.plugin_registry,
    dependency_source_index: project_scan.dependency_source_index,
    synthetic_method_index: project_scan.synthetic_method_index,
    project_patched_methods: project_scan.project_patched_methods,
    bundler_bundle_path: @configuration.bundler_bundle_path,
    bundler_auto_detect: @configuration.bundler_auto_detect,
    bundler_lockfile: @configuration.bundler_lockfile,
    rbs_collection_lockfile: @configuration.rbs_collection_lockfile,
    rbs_collection_auto_detect: @configuration.rbs_collection_auto_detect
  )
end

#invalidate!Object

Drops every cached collaborator and bumps the generation. The next reader rebuilds from scratch. Triggered by workspace/didChangeWatchedFiles for project source files and by workspace/didChangeConfiguration.



126
127
128
129
130
131
132
133
134
135
136
137
# File 'lib/rigor/language_server/project_context.rb', line 126

def invalidate!
  @generation += 1
  @environment = nil
  @project_scan = nil
  # The session's per-file cache was computed against the old environment / project scan, so it cannot
  # outlive them. The next save round primes a fresh one.
  @incremental_session = nil
  @session_primed = false
  # Cache store stays — it's content-addressed; a stale env build won't be served because the file
  # digest mixed into the cache key has changed.
  nil
end

#project_diagnosticsObject

Whole-project diagnostics for one save round (#246). The first call primes an in-process Analysis::IncrementalSession — seeded from the on-disk snapshot a terminal rigor check --incremental may have left, and otherwise from a full baseline; every later call is a recheck against what changed on disk since.

The session is never persisted. Its state lives as long as this context, which is what a long-running server needs, and writing it would race exactly the way the read-only #cache_store exists to avoid. Seeding is therefore one-directional: the terminal warms the server, not the reverse.



108
109
110
111
112
113
114
115
116
117
118
119
120
121
# File 'lib/rigor/language_server/project_context.rb', line 108

def project_diagnostics
  session = (@incremental_session ||= build_incremental_session)
  return session.recheck.diagnostics if @session_primed

  @session_primed = true
  diagnostics, = session.run_incremental(
    snapshot: Cache::IncrementalSnapshot.new(root: @configuration.cache_path),
    fingerprint: Cache::IncrementalSnapshot.fingerprint(
      configuration: @configuration, roots: @configuration.paths
    ),
    persist: false
  )
  diagnostics
end

#project_scanObject

Returns the cached Analysis::ProjectScan for this session, building it lazily by spinning up a project-only Runner (no buffer binding, no paths override) and calling #prepare_project_scan. The cold build pays the full pre-pass cost once per generation; every subsequent Runner.new(prebuilt: project_scan) skips it.



96
97
98
# File 'lib/rigor/language_server/project_context.rb', line 96

def project_scan
  @project_scan ||= build_project_scan
end