rigortype 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +10 -10
  3. data/data/builtins/ruby_core/array.yml +416 -392
  4. data/data/builtins/ruby_core/file.yml +42 -42
  5. data/data/builtins/ruby_core/hash.yml +302 -302
  6. data/data/builtins/ruby_core/io.yml +191 -191
  7. data/data/builtins/ruby_core/numeric.yml +321 -366
  8. data/data/builtins/ruby_core/proc.yml +124 -124
  9. data/data/builtins/ruby_core/range.yml +21 -21
  10. data/data/builtins/ruby_core/rational.yml +39 -39
  11. data/data/builtins/ruby_core/re.yml +65 -65
  12. data/data/builtins/ruby_core/set.yml +106 -106
  13. data/data/builtins/ruby_core/struct.yml +14 -14
  14. data/data/core_overlay/pathname.rbs +5 -0
  15. data/data/core_overlay/resolv.rbs +31 -0
  16. data/data/core_overlay/string_scanner.rbs +11 -5
  17. data/data/gem_overlay/activesupport/core_ext.rbs +10 -6
  18. data/data/vendored_gem_sigs/bundler/bundler.rbs +39 -27
  19. data/data/vendored_gem_sigs/cgi/cgi_extras.rbs +6 -0
  20. data/data/vendored_gem_sigs/prism/prism_supplement.rbs +7 -0
  21. data/data/vendored_gem_sigs/racc/racc.rbs +67 -0
  22. data/data/vendored_gem_sigs/rubygems/rubygems_extras.rbs +20 -9
  23. data/docs/handbook/01-getting-started.md +22 -34
  24. data/docs/handbook/06-classes.md +1 -1
  25. data/docs/handbook/07-rbs-and-extended.md +76 -101
  26. data/docs/handbook/08-understanding-errors.md +114 -247
  27. data/docs/handbook/09-plugins.md +54 -144
  28. data/docs/handbook/README.md +5 -3
  29. data/docs/handbook/appendix-liskov.md +4 -2
  30. data/docs/handbook/appendix-phpstan.md +14 -7
  31. data/docs/handbook/appendix-steep.md +4 -2
  32. data/docs/handbook/appendix-type-theory.md +3 -1
  33. data/docs/manual/02-cli-reference.md +103 -11
  34. data/docs/manual/03-configuration.md +16 -1
  35. data/docs/manual/04-diagnostics.md +36 -4
  36. data/docs/manual/06-baseline.md +35 -1
  37. data/docs/manual/08-skills.md +6 -1
  38. data/docs/manual/09-editor-integration.md +3 -2
  39. data/docs/manual/12-caching.md +17 -5
  40. data/docs/manual/15-type-protection-coverage.md +122 -1
  41. data/docs/manual/plugins/rigor-actioncable.md +32 -0
  42. data/docs/manual/plugins/rigor-devise.md +4 -2
  43. data/docs/manual/plugins/rigor-rbs-inline.md +22 -0
  44. data/lib/rigor/analysis/check_rules/rule_ids.rb +1 -0
  45. data/lib/rigor/analysis/check_rules/void_value_use_collector.rb +21 -2
  46. data/lib/rigor/analysis/check_rules.rb +79 -19
  47. data/lib/rigor/analysis/incremental_session.rb +156 -11
  48. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +14 -0
  49. data/lib/rigor/analysis/run_cache_key.rb +27 -1
  50. data/lib/rigor/analysis/runner/buffer_pool_dispatcher.rb +201 -0
  51. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +28 -10
  52. data/lib/rigor/analysis/runner/pool_coordinator.rb +7 -10
  53. data/lib/rigor/analysis/runner/project_pre_passes.rb +3 -1
  54. data/lib/rigor/analysis/runner.rb +80 -8
  55. data/lib/rigor/bleeding_edge.rb +132 -7
  56. data/lib/rigor/cache/descriptor.rb +6 -1
  57. data/lib/rigor/cache/engine_source.rb +162 -0
  58. data/lib/rigor/cache/incremental_snapshot.rb +87 -18
  59. data/lib/rigor/cache/rbs_cache_producer.rb +11 -1
  60. data/lib/rigor/cache/rbs_environment_marshal_patch.rb +38 -0
  61. data/lib/rigor/cache/store.rb +99 -24
  62. data/lib/rigor/cli/check_command.rb +61 -34
  63. data/lib/rigor/cli/check_invocation.rb +84 -0
  64. data/lib/rigor/cli/coverage_command.rb +15 -2
  65. data/lib/rigor/cli/coverage_mutation.rb +242 -11
  66. data/lib/rigor/cli/doctor_command.rb +6 -8
  67. data/lib/rigor/cli/fused_protection_renderer.rb +10 -0
  68. data/lib/rigor/cli/fused_protection_report.rb +12 -3
  69. data/lib/rigor/cli/mutation_fork_scan.rb +64 -0
  70. data/lib/rigor/cli/mutation_protection_renderer.rb +12 -0
  71. data/lib/rigor/cli/mutation_protection_report.rb +26 -5
  72. data/lib/rigor/cli/protection_renderer.rb +13 -0
  73. data/lib/rigor/cli/protection_report.rb +11 -3
  74. data/lib/rigor/cli/show_bleedingedge_command.rb +17 -4
  75. data/lib/rigor/cli/sig_gen_command.rb +5 -4
  76. data/lib/rigor/cli/skill_command.rb +21 -1
  77. data/lib/rigor/cli/skill_deep_probe.rb +172 -0
  78. data/lib/rigor/cli/skill_describe.rb +75 -9
  79. data/lib/rigor/configuration.rb +45 -6
  80. data/lib/rigor/environment/bundle_sig_discovery.rb +2 -1
  81. data/lib/rigor/environment/default_libraries.rb +5 -4
  82. data/lib/rigor/environment/rbs_coverage_report.rb +1 -1
  83. data/lib/rigor/environment/rbs_loader.rb +434 -75
  84. data/lib/rigor/environment.rb +38 -14
  85. data/lib/rigor/inference/expression_typer.rb +15 -0
  86. data/lib/rigor/inference/fork_map.rb +9 -0
  87. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +50 -2
  88. data/lib/rigor/inference/method_dispatcher/data_folding.rb +1 -1
  89. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +35 -1
  90. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +6 -1
  91. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +16 -0
  92. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +64 -5
  93. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +137 -9
  94. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +3 -2
  95. data/lib/rigor/inference/method_dispatcher/uri_folding.rb +124 -1
  96. data/lib/rigor/inference/mutation_widening.rb +30 -25
  97. data/lib/rigor/inference/narrowing.rb +3 -1
  98. data/lib/rigor/inference/optimistic_origin.rb +58 -0
  99. data/lib/rigor/inference/protection_scanner.rb +18 -2
  100. data/lib/rigor/inference/receiver_alias.rb +57 -0
  101. data/lib/rigor/inference/scope_indexer.rb +78 -27
  102. data/lib/rigor/inference/statement_evaluator.rb +40 -2
  103. data/lib/rigor/language_server/buffer_resolution.rb +6 -3
  104. data/lib/rigor/language_server/buffer_table.rb +68 -6
  105. data/lib/rigor/language_server/diagnostic_publisher.rb +207 -4
  106. data/lib/rigor/language_server/incremental_sync.rb +159 -0
  107. data/lib/rigor/language_server/project_context.rb +45 -0
  108. data/lib/rigor/language_server/publish_batcher.rb +82 -0
  109. data/lib/rigor/language_server/server.rb +38 -9
  110. data/lib/rigor/language_server.rb +2 -0
  111. data/lib/rigor/plugin/base.rb +29 -2
  112. data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +9 -3
  113. data/lib/rigor/protection/closure_kill_oracle.rb +171 -0
  114. data/lib/rigor/protection/dependency_closure.rb +59 -0
  115. data/lib/rigor/protection/diagnostic_oracle.rb +12 -8
  116. data/lib/rigor/protection/discovery_seed.rb +116 -0
  117. data/lib/rigor/protection/kill_signature.rb +31 -0
  118. data/lib/rigor/protection/mutation_cache.rb +355 -0
  119. data/lib/rigor/protection/mutation_scanner.rb +55 -11
  120. data/lib/rigor/protection/mutator.rb +26 -4
  121. data/lib/rigor/runtime/jit.rb +63 -1
  122. data/lib/rigor/scope/discovery_index.rb +9 -0
  123. data/lib/rigor/scope.rb +52 -8
  124. data/lib/rigor/sig_gen/generator.rb +130 -31
  125. data/lib/rigor/sig_gen/layout_index.rb +6 -0
  126. data/lib/rigor/sig_gen/meta_class_shape.rb +84 -0
  127. data/lib/rigor/sig_gen/renderer.rb +20 -1
  128. data/lib/rigor/sig_gen/write_result.rb +6 -4
  129. data/lib/rigor/sig_gen/writer.rb +195 -47
  130. data/lib/rigor/sig_gen.rb +1 -0
  131. data/lib/rigor/version.rb +1 -1
  132. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +51 -1
  133. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +6 -6
  134. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/result_shape.rb +146 -0
  135. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +177 -12
  136. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +74 -7
  137. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +257 -0
  138. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/params_shape.rb +69 -0
  139. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +87 -11
  140. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +34 -4
  141. data/sig/rigor/cache.rbs +6 -0
  142. data/sig/rigor/inference/void_origin.rbs +21 -0
  143. data/sig/rigor/plugin/base.rbs +4 -3
  144. data/sig/rigor/scope.rbs +8 -0
  145. metadata +23 -3
@@ -3,6 +3,7 @@
3
3
  require "tempfile"
4
4
 
5
5
  require_relative "uri"
6
+ require_relative "publish_batcher"
6
7
  require_relative "../analysis/runner"
7
8
  require_relative "../analysis/buffer_binding"
8
9
 
@@ -14,8 +15,10 @@ module Rigor
14
15
  # the Runner, and pushes the resulting LSP `Diagnostic[]` through the writer.
15
16
  #
16
17
  # Debouncing is wired via an optional `Debouncer` injected at construction (delay defaults to 200ms
17
- # quiet-time); without a debouncer each call blocks synchronously (primarily for specs). Ractor-pool
18
- # dispatch is queued.
18
+ # quiet-time); without a debouncer each call blocks synchronously (primarily for specs). When several
19
+ # buffers' debounce timers elapse around the same moment, the `PublishBatcher` coalesces them into one
20
+ # `#publish_many` round dispatched across the fork-based worker pool (issue #142) instead of N
21
+ # independent, GVL-serialized `Runner` calls.
19
22
  class DiagnosticPublisher
20
23
  # Maps Rigor severity symbols to LSP DiagnosticSeverity
21
24
  # integers per spec § "Diagnostic":
@@ -37,6 +40,10 @@ module Rigor
37
40
  # debounced publish fires. 0 with a debouncer means
38
41
  # "schedule on next-tick" (still async); without a
39
42
  # debouncer the value is unused.
43
+ # The single Debouncer key every save round shares (#246). Per-URI keys would let two saves start two
44
+ # concurrent rounds; one key means a burst collapses into the last one.
45
+ PROJECT_ROUND_KEY = :__rigor_project_round__
46
+
40
47
  def initialize(writer:, buffer_table:, project_context:,
41
48
  debouncer: nil, debounce_seconds: 0.2)
42
49
  @writer = writer
@@ -44,23 +51,72 @@ module Rigor
44
51
  @project_context = project_context
45
52
  @debouncer = debouncer
46
53
  @debounce_seconds = debounce_seconds
54
+ @round_lock = Mutex.new
55
+ @round_running = false
56
+ @round_pending = false
57
+ # Issue #142 — coalesces buffers whose OWN debounce timers elapse close together into one
58
+ # `#publish_many` round instead of N independent GVL-serialized `Runner` calls. Separate from
59
+ # `@round_lock` above, which single-flights the whole-project SAVE round; this single-flights the
60
+ # per-buffer DIDCHANGE batch instead. See `PublishBatcher` for the coalescing mechanics.
61
+ @batcher = PublishBatcher.new(
62
+ on_batch: ->(uris) { publish_many(uris) },
63
+ on_error: ->(e) { warn "DiagnosticPublisher batch round: #{e.class}: #{e.message}" }
64
+ )
47
65
  end
48
66
 
49
67
  # Run analysis for the buffer at `uri` (looked up in the BufferTable) and push a
50
68
  # `textDocument/publishDiagnostics` notification. No-op when the URI isn't a `file://` form or the
51
69
  # buffer isn't currently open. When a Debouncer is wired, the analysis is scheduled async per the
52
- # configured `debounce_seconds`; otherwise it runs inline.
70
+ # configured `debounce_seconds` and joins the batch coalescing layer (`PublishBatcher`) once its own
71
+ # quiet-time elapses; otherwise it runs inline (primarily for specs).
53
72
  def publish_for(uri)
54
73
  path = Uri.to_path(uri)
55
74
  return if path.nil?
56
75
 
57
76
  if @debouncer
58
- @debouncer.schedule(uri, delay: @debounce_seconds) { run_and_notify(uri, path) }
77
+ @debouncer.schedule(uri, delay: @debounce_seconds) { @batcher.enqueue(uri) }
59
78
  else
60
79
  run_and_notify(uri, path)
61
80
  end
62
81
  end
63
82
 
83
+ # Issue #142 — publishes N dirty buffers' diagnostics through ONE dispatch across the fork-based
84
+ # worker pool (`Analysis::Runner::BufferPoolDispatcher`) instead of N independent, GVL-serialized
85
+ # `Runner` calls. Each URI's OWN `BufferBinding` (its logical path bound to its OWN editor tempfile)
86
+ # travels with it, so a worker analyses that buffer's in-flight bytes — never the file as it sits on
87
+ # disk — even when several buffers are dispatched in the same round.
88
+ #
89
+ # Degrades to `#run_and_notify`'s existing single-buffer path when only one URI is eligible after
90
+ # filtering (a buffer closed mid-debounce-window is dropped; a desynchronised one publishes empty
91
+ # immediately) — a lone edit takes exactly the path it takes today. The dispatcher itself degrades to
92
+ # sequential in-process execution for any other precondition (see
93
+ # `BufferPoolDispatcher#dispatchable?`), so a pool that cannot start never fails a publish, only slows
94
+ # it back down to today's wall time.
95
+ def publish_many(uris)
96
+ eligible = uris.uniq.filter_map { |uri| eligible_job(uri) }
97
+ return if eligible.empty?
98
+ return run_and_notify(eligible.first.fetch(:uri), eligible.first.fetch(:path)) if eligible.size == 1
99
+
100
+ publish_batch(eligible)
101
+ end
102
+
103
+ # Runs one whole-project save round and publishes to the publish set (#246). Called from `didSave`.
104
+ #
105
+ # Analysis scope is the whole project; the PUBLISH SET is the open buffers that are not dirty, plus the
106
+ # buffer that was just saved. A dirty buffer is excluded because only its own `didChange` analysis has
107
+ # seen its bytes — publishing this round's on-disk answer for it would replace correct markers with
108
+ # markers for a file the user has already changed.
109
+ #
110
+ # Scheduled through the same Debouncer the per-buffer path uses, under one project-wide key, so the
111
+ # dispatcher never blocks on it.
112
+ def publish_project(saved_uri)
113
+ if @debouncer
114
+ @debouncer.schedule(PROJECT_ROUND_KEY, delay: 0) { run_project_round(saved_uri) }
115
+ else
116
+ run_project_round(saved_uri)
117
+ end
118
+ end
119
+
64
120
  # Publishes an EMPTY diagnostic array for `uri`. The LSP-spec idiom for "clear inline markers" — called
65
121
  # from `didClose` so clients drop stale highlights when the user closes a buffer.
66
122
  def publish_empty(uri)
@@ -75,16 +131,141 @@ module Rigor
75
131
 
76
132
  private
77
133
 
134
+ # Single-flight. The Debouncer only cancels a task that has not started, so without this two rounds
135
+ # could run concurrently over one session's mutable state. A save that arrives mid-round sets the
136
+ # pending flag instead of starting a second round, and the running one repeats once when it finishes —
137
+ # so a burst of saves costs at most one extra round, and the last save is always accounted for.
138
+ def run_project_round(saved_uri)
139
+ return unless claim_round
140
+
141
+ loop do
142
+ execute_project_round(saved_uri)
143
+ break unless consume_pending
144
+ end
145
+ end
146
+
147
+ # True when this call owns the round. A save arriving while one is in flight records itself as pending
148
+ # instead — the running round will pick it up.
149
+ def claim_round
150
+ @round_lock.synchronize do
151
+ if @round_running
152
+ @round_pending = true
153
+ next false
154
+ end
155
+
156
+ @round_running = true
157
+ true
158
+ end
159
+ end
160
+
161
+ # True when a save arrived mid-round and the loop should run once more. Releases ownership otherwise.
162
+ # `next`, not `break`: inside `Mutex#synchronize`'s block, `break` returns from the SYNCHRONIZE call, so
163
+ # a `break` written here would leave the caller's `loop` spinning forever — which is exactly what it did
164
+ # before this was split out.
165
+ def consume_pending
166
+ @round_lock.synchronize do
167
+ next(@round_running = false) unless @round_pending
168
+
169
+ @round_pending = false
170
+ true
171
+ end
172
+ end
173
+
174
+ # One round: analyse the project as it now stands on disk, then publish each target URI's slice.
175
+ # The generation captured before the analysis is re-read after it — a `didChangeWatchedFiles` or a
176
+ # configuration change during a long round invalidates the world these diagnostics describe, and
177
+ # publishing them would put an answer about a superseded project on the user's screen.
178
+ def execute_project_round(saved_uri)
179
+ generation = @project_context.generation
180
+ diagnostics = @project_context.project_diagnostics
181
+ return if generation != @project_context.generation
182
+
183
+ by_path = diagnostics.group_by(&:path)
184
+ publish_set(saved_uri).each do |uri|
185
+ path = Uri.to_path(uri)
186
+ next if path.nil?
187
+
188
+ notify(uri, (by_path[path] || []).filter_map { |diagnostic| to_lsp_diagnostic(diagnostic, path) })
189
+ end
190
+ end
191
+
192
+ # The URIs this round may speak for: every open buffer whose bytes are the ones on disk. The just-saved
193
+ # URI qualifies by definition (the client wrote it), and is named explicitly so a client that sends
194
+ # `didSave` without a preceding applied `didChange` still refreshes it.
195
+ def publish_set(saved_uri)
196
+ ([saved_uri] + @buffer_table.uris).uniq.select do |uri|
197
+ @buffer_table.open?(uri) && !@buffer_table.dirty?(uri) && !@buffer_table.desynchronized?(uri)
198
+ end
199
+ end
200
+
78
201
  def run_and_notify(uri, path)
79
202
  entry = @buffer_table[uri]
80
203
  # The buffer may have been closed during the debounce window — drop the publish; the empty
81
204
  # notification from didClose already cleared the markers.
82
205
  return if entry.nil?
206
+ # An incremental change the table could not apply: the held text no longer matches the editor's, so
207
+ # every span we could compute from it would be misplaced. Clear the markers and stay silent until a
208
+ # full-text change or a re-open re-establishes the buffer.
209
+ return notify(uri, []) if @buffer_table.desynchronized?(uri)
83
210
 
84
211
  diagnostics = run_analysis(path: path, bytes: entry.bytes)
85
212
  notify(uri, diagnostics)
86
213
  end
87
214
 
215
+ # @return [Hash, nil] `{ uri:, path:, bytes: }` when `uri` is eligible for the batch, or nil to
216
+ # exclude it. Mirrors `#run_and_notify`'s own guards: a buffer closed during the debounce window is
217
+ # dropped silently (its didClose empty publish already cleared the markers); a desynchronised buffer
218
+ # publishes an EMPTY set immediately (same as the single-buffer path) rather than joining the batch.
219
+ def eligible_job(uri)
220
+ entry = @buffer_table[uri]
221
+ return nil if entry.nil?
222
+
223
+ path = Uri.to_path(uri)
224
+ return nil if path.nil?
225
+
226
+ if @buffer_table.desynchronized?(uri)
227
+ notify(uri, [])
228
+ return nil
229
+ end
230
+
231
+ { uri: uri, path: path, bytes: entry.bytes }
232
+ end
233
+
234
+ # Materialises one tempfile + `BufferBinding` per job, dispatches all of them through
235
+ # `BufferPoolDispatcher#analyze` in ONE call, and publishes each job's own slice. `dispatcher.analyze`
236
+ # returns diagnostics IN INPUT ORDER, so `jobs`/`bindings`/the result array stay index-aligned — the
237
+ # parent absorbs worker results in this stable order, so two runs of the same dirty set publish
238
+ # byte-identical results.
239
+ def publish_batch(jobs)
240
+ with_tempfiles(jobs) do |bound_jobs|
241
+ bindings = bound_jobs.map { |job| job.fetch(:binding) }
242
+ dispatcher = Analysis::Runner::BufferPoolDispatcher.new(
243
+ configuration: @project_context.configuration,
244
+ cache_store: @project_context.cache_store,
245
+ environment: @project_context.environment,
246
+ prebuilt: @project_context.project_scan,
247
+ workers: worker_count
248
+ )
249
+ diagnostics_per_binding = dispatcher.analyze(bindings)
250
+ bound_jobs.each_with_index do |job, index|
251
+ diagnostics = diagnostics_per_binding.fetch(index, []).filter_map do |diagnostic|
252
+ to_lsp_diagnostic(diagnostic, job.fetch(:path))
253
+ end
254
+ notify(job.fetch(:uri), diagnostics)
255
+ end
256
+ end
257
+ end
258
+
259
+ # Worker-pool size, mirroring `rigor check`'s own precedence minus the CLI flag the LSP does not have:
260
+ # env `RIGOR_RACTOR_WORKERS` (if set and non-empty) wins, else `.rigor.yml` `parallel.workers:` (0 —
261
+ # sequential — by default). See `docs/design/20260517-language-server.md` § "Concurrency".
262
+ def worker_count
263
+ env_value = ENV.fetch("RIGOR_RACTOR_WORKERS", nil)
264
+ return [Integer(env_value), 0].max if env_value && !env_value.empty?
265
+
266
+ @project_context.configuration.parallel_workers
267
+ end
268
+
88
269
  # Runs `Analysis::Runner` with a `BufferBinding` so the buffer bytes (instead of the on-disk file) drive
89
270
  # the parse. The `Rigor::Analysis::ProjectScan` cached on the ProjectContext is passed through
90
271
  # `prebuilt:` so plugin `#prepare`, the dependency-source walker, and the synthetic-method /
@@ -117,6 +298,28 @@ module Rigor
117
298
  tmp&.unlink
118
299
  end
119
300
 
301
+ # Plural form of `#with_tempfile` for `#publish_batch` — writes one tempfile per job up front, yields
302
+ # each job Hash augmented with its own `binding:` (a `BufferBinding` pairing the job's logical path
303
+ # with ITS OWN physical tempfile), and unlinks every tempfile afterward regardless of how the block
304
+ # exits.
305
+ def with_tempfiles(jobs)
306
+ tempfiles = []
307
+ bound_jobs = jobs.map do |job|
308
+ tmp = Tempfile.new(["rigor-lsp-buffer-", ".rb"])
309
+ tmp.write(job.fetch(:bytes))
310
+ tmp.flush
311
+ tempfiles << tmp
312
+ binding = Analysis::BufferBinding.new(logical_path: job.fetch(:path), physical_path: tmp.path)
313
+ job.merge(binding: binding)
314
+ end
315
+ yield bound_jobs
316
+ ensure
317
+ tempfiles.each do |tmp|
318
+ tmp.close
319
+ tmp.unlink
320
+ end
321
+ end
322
+
120
323
  # @return [Hash, nil] the LSP `Diagnostic` Hash, or nil to
121
324
  # skip diagnostics outside the buffer's own path (e.g.
122
325
  # `.rigor.yml`-anchored info diagnostics get filtered —
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "strscan"
4
+
5
+ module Rigor
6
+ module LanguageServer
7
+ # Applies LSP `TextDocumentContentChangeEvent`s to a held buffer under `TextDocumentSyncKind::Incremental`.
8
+ #
9
+ # ## Why the offsets need care
10
+ #
11
+ # An LSP `Position#character` counts **UTF-16 code units** (`positionEncoding: "utf-16"`, the protocol
12
+ # default and the only encoding every client supports), while a Ruby String indexes by **codepoint**. The
13
+ # two agree for every character in the Basic Multilingual Plane — all of ASCII, Latin-1, Greek, Cyrillic,
14
+ # kana and common kanji — and disagree above U+FFFF, where an emoji or a CJK-extension ideograph is ONE
15
+ # Ruby character and TWO UTF-16 code units. Reading one as the other shifts every subsequent edit on the
16
+ # line, and a shifted edit desynchronises the server's text from what the editor shows for the rest of the
17
+ # session: every diagnostic after that point lands on the wrong span, silently.
18
+ #
19
+ # So the conversion is explicit. {.utf16_offset_to_index} walks the line one character at a time, charging
20
+ # 2 code units for a codepoint above U+FFFF and 1 for everything else, and stops when the requested count
21
+ # is spent. An all-ASCII line short-circuits the walk, since there the offset IS the index — that is the
22
+ # keystroke path, and `String#ascii_only?` answers it from the cached coderange.
23
+ #
24
+ # ## Failure is a resync, never a guess
25
+ #
26
+ # A change whose shape cannot be applied confidently raises {UnappliableChange} rather than producing a
27
+ # best-effort buffer: {BufferTable#apply_changes} then keeps the last known-good text and marks the URI
28
+ # desynchronised, which suppresses diagnostics until a full-text change or a re-open re-establishes the
29
+ # buffer. A stale-but-flagged buffer is recoverable; a silently wrong one is not.
30
+ module IncrementalSync
31
+ # Raised when a `contentChanges` entry cannot be applied to the held text: a malformed payload, a
32
+ # position that is not a non-negative Integer pair, or a range edit against a buffer the server never
33
+ # received a `didOpen` / full-text change for.
34
+ class UnappliableChange < StandardError; end
35
+
36
+ # LSP considers a line delimited by `\n`, `\r\n`, or a lone `\r`. Ruby's own line splitting only knows
37
+ # `\n`, so the scan is explicit.
38
+ LINE_TERMINATOR = /\r\n|\n|\r/
39
+
40
+ # UTF-8 encodes exactly the codepoints above the BMP — the ones UTF-16 must encode as a surrogate pair —
41
+ # in four bytes. So a character's UTF-8 byte length answers "one code unit or two?" without decoding it.
42
+ SURROGATE_PAIR_BYTES = 4
43
+
44
+ module_function
45
+
46
+ # Applies every change in order, each against the result of the previous — the LSP contract for a
47
+ # multi-change `didChange` notification.
48
+ #
49
+ # @param text [String, nil] the held buffer text; nil when no buffer is open for the URI.
50
+ # @param changes [Array<Hash>] the `contentChanges` array.
51
+ # @return [String] the new buffer text.
52
+ # @raise [UnappliableChange] if any change cannot be applied.
53
+ def apply_all(text, changes)
54
+ raise UnappliableChange, "contentChanges must be an Array, got #{changes.class}" unless changes.is_a?(Array)
55
+
56
+ changes.reduce(text) { |acc, change| apply(acc, change) }
57
+ end
58
+
59
+ # Applies one `TextDocumentContentChangeEvent`.
60
+ #
61
+ # Two shapes are legal under incremental sync. Without `range` the entry is the full new document text
62
+ # (clients fall back to it for a paste, an undo, or a file reload, and it stays legal under
63
+ # `Incremental`); with `range` it replaces the spanned text. `rangeLength` is the deprecated pre-3.16
64
+ # companion to `range` and is accepted-but-ignored: it is redundant with `range`, historically ambiguous
65
+ # about its units, and `range` is the authoritative field.
66
+ #
67
+ # @raise [UnappliableChange]
68
+ def apply(text, change)
69
+ raise UnappliableChange, "contentChanges entry must be a Hash, got #{change.class}" unless change.is_a?(Hash)
70
+
71
+ replacement = change[:text]
72
+ raise UnappliableChange, "contentChanges entry has no `text`" unless replacement.is_a?(String)
73
+
74
+ range = change[:range]
75
+ return replacement.dup if range.nil?
76
+ raise UnappliableChange, "`range` must be a Hash, got #{range.class}" unless range.is_a?(Hash)
77
+ raise UnappliableChange, "range edit for a URI with no open buffer" if text.nil?
78
+
79
+ splice(text, range, replacement)
80
+ end
81
+
82
+ # Replaces the `range` span of `text` with `replacement`.
83
+ #
84
+ # Text that is not valid UTF-8 makes the line scan itself raise. That is not a buffer JSON transport can
85
+ # deliver, but if one appears there is no offset arithmetic to be confident about, so it becomes a
86
+ # resync like any other unappliable shape.
87
+ def splice(text, range, replacement)
88
+ spans = line_spans(text)
89
+ from = char_offset(text, spans, range[:start])
90
+ # A client that inverts the range — or an `end` rounded below `start` off a mid-surrogate
91
+ # position — would otherwise slice backwards; collapse to an insertion at `from` instead.
92
+ to = char_offset(text, spans, range[:end]).clamp(from, text.length)
93
+ "#{text[0, from]}#{replacement}#{text[to..]}"
94
+ rescue ArgumentError, Encoding::CompatibilityError => e
95
+ raise UnappliableChange, "held text is not valid UTF-8: #{e.message}"
96
+ end
97
+
98
+ # @return [Array<Array(Integer, Integer)>] one `[content_start, content_end]` pair per line, in Ruby
99
+ # character indices. `content_end` excludes the line terminator, so it doubles as the clamp target for
100
+ # an over-long `character`. A trailing terminator yields a final empty span — the virtual last line an
101
+ # editor puts the cursor on, and the anchor for an end-of-document insert.
102
+ def line_spans(text)
103
+ spans = []
104
+ scanner = StringScanner.new(text)
105
+ start = 0
106
+ while scanner.skip_until(LINE_TERMINATOR)
107
+ stop = scanner.charpos
108
+ spans << [start, stop - scanner.matched.length]
109
+ start = stop
110
+ end
111
+ spans << [start, text.length]
112
+ spans
113
+ end
114
+
115
+ # Converts an LSP `Position` into a Ruby character index into `text`.
116
+ #
117
+ # A `line` past the last line clamps to the end of the document rather than raising: clients do address
118
+ # the position one past the final line, and the clamp is what the protocol's own end-of-document
119
+ # convention implies.
120
+ def char_offset(text, spans, position)
121
+ line = position_field(position, :line)
122
+ character = position_field(position, :character)
123
+ return text.length if line >= spans.length
124
+
125
+ start, stop = spans[line]
126
+ start + utf16_offset_to_index(text[start...stop], character)
127
+ end
128
+
129
+ # Converts a UTF-16 code-unit offset into `line` to a Ruby character index into the same line.
130
+ #
131
+ # Clamps an offset past the end of the line to the line length, per LSP's rule that a `character`
132
+ # greater than the line length defaults back to the line length.
133
+ def utf16_offset_to_index(line, units)
134
+ # Fast path: on an all-ASCII line one UTF-16 code unit is one Ruby character, so the offset is the
135
+ # index. This is the keystroke case, and `ascii_only?` reads the string's cached coderange.
136
+ return units.clamp(0, line.length) if line.ascii_only?
137
+
138
+ index = 0
139
+ remaining = units
140
+ line.each_char do |char|
141
+ break if remaining <= 0
142
+
143
+ remaining -= char.bytesize == SURROGATE_PAIR_BYTES ? 2 : 1
144
+ index += 1
145
+ end
146
+ # `remaining` below zero means the offset addressed the low half of a surrogate pair — a position no
147
+ # conforming client sends. Round DOWN to the character boundary; splitting the pair is not an option.
148
+ remaining.negative? ? index - 1 : index
149
+ end
150
+
151
+ def position_field(position, key)
152
+ value = position.is_a?(Hash) ? position[key] : nil
153
+ return value if value.is_a?(Integer) && !value.negative?
154
+
155
+ raise UnappliableChange, "position `#{key}` must be a non-negative Integer, got #{value.inspect}"
156
+ end
157
+ end
158
+ end
159
+ end
@@ -3,6 +3,8 @@
3
3
  require_relative "../environment"
4
4
  require_relative "../cache/store"
5
5
  require_relative "../analysis/runner"
6
+ require_relative "../analysis/incremental_session"
7
+ require_relative "../cache/incremental_snapshot"
6
8
 
7
9
  module Rigor
8
10
  module LanguageServer
@@ -34,6 +36,9 @@ module Rigor
34
36
  # watch fires → publish) refreshes automatically; the rare in-flight edit to a substrate-DSL file is the
35
37
  # documented edge case.
36
38
  class ProjectContext
39
+ # `generation` is the invalidation counter: a long-running save round (#246) captures it on entry and
40
+ # discards its result if it moved, so diagnostics computed against a world that has since been
41
+ # invalidated never reach the editor.
37
42
  attr_reader :configuration, :generation
38
43
 
39
44
  def initialize(configuration:)
@@ -42,6 +47,8 @@ module Rigor
42
47
  @environment = nil
43
48
  @cache_store = nil
44
49
  @project_scan = nil
50
+ @incremental_session = nil
51
+ @session_primed = false
45
52
  end
46
53
 
47
54
  # Returns the cached `Rigor::Environment` for this session, building it on first access. The build
@@ -90,6 +97,29 @@ module Rigor
90
97
  @project_scan ||= build_project_scan
91
98
  end
92
99
 
100
+ # Whole-project diagnostics for one save round (#246). The first call primes an in-process
101
+ # {Analysis::IncrementalSession} — seeded from the on-disk snapshot a terminal
102
+ # `rigor check --incremental` may have left, and otherwise from a full baseline; every later call is a
103
+ # recheck against what changed on disk since.
104
+ #
105
+ # The session is never persisted. Its state lives as long as this context, which is what a long-running
106
+ # server needs, and writing it would race exactly the way the read-only {#cache_store} exists to avoid.
107
+ # Seeding is therefore one-directional: the terminal warms the server, not the reverse.
108
+ def project_diagnostics
109
+ session = (@incremental_session ||= build_incremental_session)
110
+ return session.recheck.diagnostics if @session_primed
111
+
112
+ @session_primed = true
113
+ diagnostics, = session.run_incremental(
114
+ snapshot: Cache::IncrementalSnapshot.new(root: @configuration.cache_path),
115
+ fingerprint: Cache::IncrementalSnapshot.fingerprint(
116
+ configuration: @configuration, roots: @configuration.paths
117
+ ),
118
+ persist: false
119
+ )
120
+ diagnostics
121
+ end
122
+
93
123
  # Drops every cached collaborator and bumps the generation. The next reader rebuilds from scratch.
94
124
  # Triggered by `workspace/didChangeWatchedFiles` for project source files and by
95
125
  # `workspace/didChangeConfiguration`.
@@ -97,6 +127,10 @@ module Rigor
97
127
  @generation += 1
98
128
  @environment = nil
99
129
  @project_scan = nil
130
+ # The session's per-file cache was computed against the old environment / project scan, so it cannot
131
+ # outlive them. The next save round primes a fresh one.
132
+ @incremental_session = nil
133
+ @session_primed = false
100
134
  # Cache store stays — it's content-addressed; a stale env build won't be served because the file
101
135
  # digest mixed into the cache key has changed.
102
136
  nil
@@ -104,6 +138,17 @@ module Rigor
104
138
 
105
139
  private
106
140
 
141
+ # The session shares this context's warm Environment, so a round does not rebuild the RBS universe. It
142
+ # deliberately does NOT share the prebuilt ProjectScan: a round runs after a save, i.e. after the file
143
+ # on disk moved, and the scan is what a changed file invalidates.
144
+ def build_incremental_session
145
+ Analysis::IncrementalSession.new(
146
+ configuration: @configuration,
147
+ environment: environment,
148
+ cache_store: cache_store
149
+ )
150
+ end
151
+
107
152
  def build_project_scan
108
153
  runner = Analysis::Runner.new(
109
154
  configuration: @configuration,
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module LanguageServer
5
+ # Issue #142 — coalesces keys that become "ready" close together in wall-clock time into ONE batched
6
+ # round instead of firing one round per key. `DiagnosticPublisher` uses this to fold a burst of buffers
7
+ # whose OWN per-URI debounce timers elapse around the same moment (a workspace-wide rename, a git branch
8
+ # switch that touches many open files) into one `#publish_many` dispatch across the fork-based worker
9
+ # pool — but the mechanism itself carries no LSP- or URI-specific knowledge, so it stays a small,
10
+ # independently testable collaborator rather than inline state on `DiagnosticPublisher`.
11
+ #
12
+ # Single-flight: the first `#enqueue` call to arrive owns the round and runs `on_batch` with every key
13
+ # currently pending (deduplicated); a call that arrives while a round is running just adds its key and
14
+ # returns — the running round loops once more before releasing ownership, so nothing queued mid-round is
15
+ # dropped. The same claim/consume shape `DiagnosticPublisher#run_project_round` already uses for the
16
+ # whole-project save round (#246), generalised to an arbitrary key type and an arbitrary batch action.
17
+ class PublishBatcher
18
+ # @param on_batch [#call] `(keys) -> void`, called with the deduplicated Array of keys pending at the
19
+ # start of one round. May be called more than once in a row when keys keep arriving while a round
20
+ # runs.
21
+ # @param on_error [#call, nil] `(exception) -> void`, called when `on_batch` raises. A round must
22
+ # never wedge the coalescing lock for every future `#enqueue` call — ownership is always released
23
+ # before this fires. Whatever was mid-flight when the round raised is lost; anything enqueued by a
24
+ # concurrent `#enqueue` call after this round's drain but before the rescue stays pending and rides
25
+ # the NEXT round instead. Defaults to a no-op (the exception is swallowed silently).
26
+ def initialize(on_batch:, on_error: nil)
27
+ @on_batch = on_batch
28
+ @on_error = on_error
29
+ @lock = Mutex.new
30
+ @pending = []
31
+ @running = false
32
+ end
33
+
34
+ # Adds `key` to the pending set and, if no round is currently running, becomes the round and drains
35
+ # every key pending (looping until none remain) before returning. A call that arrives while another is
36
+ # already running the round returns immediately having only enqueued its key.
37
+ def enqueue(key)
38
+ start = false
39
+ @lock.synchronize do
40
+ @pending << key
41
+ unless @running
42
+ @running = true
43
+ start = true
44
+ end
45
+ end
46
+ return unless start
47
+
48
+ run_owned_round
49
+ end
50
+
51
+ private
52
+
53
+ def run_owned_round
54
+ loop do
55
+ run_batch
56
+ break unless more_pending?
57
+ end
58
+ rescue StandardError => e
59
+ @lock.synchronize { @running = false }
60
+ @on_error&.call(e)
61
+ end
62
+
63
+ # True when another key joined `@pending` while this round was running — the caller's loop should run
64
+ # once more rather than release ownership. Releases ownership (resets `@running`) otherwise.
65
+ def more_pending?
66
+ @lock.synchronize do
67
+ if @pending.empty?
68
+ @running = false
69
+ false
70
+ else
71
+ true
72
+ end
73
+ end
74
+ end
75
+
76
+ def run_batch
77
+ keys = @lock.synchronize { @pending.uniq.tap { @pending.clear } }
78
+ @on_batch.call(keys)
79
+ end
80
+ end
81
+ end
82
+ end