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.
- checksums.yaml +4 -4
- data/README.md +10 -10
- data/data/builtins/ruby_core/array.yml +416 -392
- data/data/builtins/ruby_core/file.yml +42 -42
- data/data/builtins/ruby_core/hash.yml +302 -302
- data/data/builtins/ruby_core/io.yml +191 -191
- data/data/builtins/ruby_core/numeric.yml +321 -366
- data/data/builtins/ruby_core/proc.yml +124 -124
- data/data/builtins/ruby_core/range.yml +21 -21
- data/data/builtins/ruby_core/rational.yml +39 -39
- data/data/builtins/ruby_core/re.yml +65 -65
- data/data/builtins/ruby_core/set.yml +106 -106
- data/data/builtins/ruby_core/struct.yml +14 -14
- data/data/core_overlay/pathname.rbs +5 -0
- data/data/core_overlay/resolv.rbs +31 -0
- data/data/core_overlay/string_scanner.rbs +11 -5
- data/data/gem_overlay/activesupport/core_ext.rbs +10 -6
- data/data/vendored_gem_sigs/bundler/bundler.rbs +39 -27
- data/data/vendored_gem_sigs/cgi/cgi_extras.rbs +6 -0
- data/data/vendored_gem_sigs/prism/prism_supplement.rbs +7 -0
- data/data/vendored_gem_sigs/racc/racc.rbs +67 -0
- data/data/vendored_gem_sigs/rubygems/rubygems_extras.rbs +20 -9
- data/docs/handbook/01-getting-started.md +22 -34
- data/docs/handbook/06-classes.md +1 -1
- data/docs/handbook/07-rbs-and-extended.md +76 -101
- data/docs/handbook/08-understanding-errors.md +114 -247
- data/docs/handbook/09-plugins.md +54 -144
- data/docs/handbook/README.md +5 -3
- data/docs/handbook/appendix-liskov.md +4 -2
- data/docs/handbook/appendix-phpstan.md +14 -7
- data/docs/handbook/appendix-steep.md +4 -2
- data/docs/handbook/appendix-type-theory.md +3 -1
- data/docs/manual/02-cli-reference.md +103 -11
- data/docs/manual/03-configuration.md +16 -1
- data/docs/manual/04-diagnostics.md +36 -4
- data/docs/manual/06-baseline.md +35 -1
- data/docs/manual/08-skills.md +6 -1
- data/docs/manual/09-editor-integration.md +3 -2
- data/docs/manual/12-caching.md +17 -5
- data/docs/manual/15-type-protection-coverage.md +122 -1
- data/docs/manual/plugins/rigor-actioncable.md +32 -0
- data/docs/manual/plugins/rigor-devise.md +4 -2
- data/docs/manual/plugins/rigor-rbs-inline.md +22 -0
- data/lib/rigor/analysis/check_rules/rule_ids.rb +1 -0
- data/lib/rigor/analysis/check_rules/void_value_use_collector.rb +21 -2
- data/lib/rigor/analysis/check_rules.rb +79 -19
- data/lib/rigor/analysis/incremental_session.rb +156 -11
- data/lib/rigor/analysis/plugin_fact_fingerprint.rb +14 -0
- data/lib/rigor/analysis/run_cache_key.rb +27 -1
- data/lib/rigor/analysis/runner/buffer_pool_dispatcher.rb +201 -0
- data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +28 -10
- data/lib/rigor/analysis/runner/pool_coordinator.rb +7 -10
- data/lib/rigor/analysis/runner/project_pre_passes.rb +3 -1
- data/lib/rigor/analysis/runner.rb +80 -8
- data/lib/rigor/bleeding_edge.rb +132 -7
- data/lib/rigor/cache/descriptor.rb +6 -1
- data/lib/rigor/cache/engine_source.rb +162 -0
- data/lib/rigor/cache/incremental_snapshot.rb +87 -18
- data/lib/rigor/cache/rbs_cache_producer.rb +11 -1
- data/lib/rigor/cache/rbs_environment_marshal_patch.rb +38 -0
- data/lib/rigor/cache/store.rb +99 -24
- data/lib/rigor/cli/check_command.rb +61 -34
- data/lib/rigor/cli/check_invocation.rb +84 -0
- data/lib/rigor/cli/coverage_command.rb +15 -2
- data/lib/rigor/cli/coverage_mutation.rb +242 -11
- data/lib/rigor/cli/doctor_command.rb +6 -8
- data/lib/rigor/cli/fused_protection_renderer.rb +10 -0
- data/lib/rigor/cli/fused_protection_report.rb +12 -3
- data/lib/rigor/cli/mutation_fork_scan.rb +64 -0
- data/lib/rigor/cli/mutation_protection_renderer.rb +12 -0
- data/lib/rigor/cli/mutation_protection_report.rb +26 -5
- data/lib/rigor/cli/protection_renderer.rb +13 -0
- data/lib/rigor/cli/protection_report.rb +11 -3
- data/lib/rigor/cli/show_bleedingedge_command.rb +17 -4
- data/lib/rigor/cli/sig_gen_command.rb +5 -4
- data/lib/rigor/cli/skill_command.rb +21 -1
- data/lib/rigor/cli/skill_deep_probe.rb +172 -0
- data/lib/rigor/cli/skill_describe.rb +75 -9
- data/lib/rigor/configuration.rb +45 -6
- data/lib/rigor/environment/bundle_sig_discovery.rb +2 -1
- data/lib/rigor/environment/default_libraries.rb +5 -4
- data/lib/rigor/environment/rbs_coverage_report.rb +1 -1
- data/lib/rigor/environment/rbs_loader.rb +434 -75
- data/lib/rigor/environment.rb +38 -14
- data/lib/rigor/inference/expression_typer.rb +15 -0
- data/lib/rigor/inference/fork_map.rb +9 -0
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +50 -2
- data/lib/rigor/inference/method_dispatcher/data_folding.rb +1 -1
- data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +35 -1
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +6 -1
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +16 -0
- data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +64 -5
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +137 -9
- data/lib/rigor/inference/method_dispatcher/struct_folding.rb +3 -2
- data/lib/rigor/inference/method_dispatcher/uri_folding.rb +124 -1
- data/lib/rigor/inference/mutation_widening.rb +30 -25
- data/lib/rigor/inference/narrowing.rb +3 -1
- data/lib/rigor/inference/optimistic_origin.rb +58 -0
- data/lib/rigor/inference/protection_scanner.rb +18 -2
- data/lib/rigor/inference/receiver_alias.rb +57 -0
- data/lib/rigor/inference/scope_indexer.rb +78 -27
- data/lib/rigor/inference/statement_evaluator.rb +40 -2
- data/lib/rigor/language_server/buffer_resolution.rb +6 -3
- data/lib/rigor/language_server/buffer_table.rb +68 -6
- data/lib/rigor/language_server/diagnostic_publisher.rb +207 -4
- data/lib/rigor/language_server/incremental_sync.rb +159 -0
- data/lib/rigor/language_server/project_context.rb +45 -0
- data/lib/rigor/language_server/publish_batcher.rb +82 -0
- data/lib/rigor/language_server/server.rb +38 -9
- data/lib/rigor/language_server.rb +2 -0
- data/lib/rigor/plugin/base.rb +29 -2
- data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +9 -3
- data/lib/rigor/protection/closure_kill_oracle.rb +171 -0
- data/lib/rigor/protection/dependency_closure.rb +59 -0
- data/lib/rigor/protection/diagnostic_oracle.rb +12 -8
- data/lib/rigor/protection/discovery_seed.rb +116 -0
- data/lib/rigor/protection/kill_signature.rb +31 -0
- data/lib/rigor/protection/mutation_cache.rb +355 -0
- data/lib/rigor/protection/mutation_scanner.rb +55 -11
- data/lib/rigor/protection/mutator.rb +26 -4
- data/lib/rigor/runtime/jit.rb +63 -1
- data/lib/rigor/scope/discovery_index.rb +9 -0
- data/lib/rigor/scope.rb +52 -8
- data/lib/rigor/sig_gen/generator.rb +130 -31
- data/lib/rigor/sig_gen/layout_index.rb +6 -0
- data/lib/rigor/sig_gen/meta_class_shape.rb +84 -0
- data/lib/rigor/sig_gen/renderer.rb +20 -1
- data/lib/rigor/sig_gen/write_result.rb +6 -4
- data/lib/rigor/sig_gen/writer.rb +195 -47
- data/lib/rigor/sig_gen.rb +1 -0
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +51 -1
- data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +6 -6
- data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/result_shape.rb +146 -0
- data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +177 -12
- data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +74 -7
- data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +257 -0
- data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/params_shape.rb +69 -0
- data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +87 -11
- data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +34 -4
- data/sig/rigor/cache.rbs +6 -0
- data/sig/rigor/inference/void_origin.rbs +21 -0
- data/sig/rigor/plugin/base.rbs +4 -3
- data/sig/rigor/scope.rbs +8 -0
- 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).
|
|
18
|
-
#
|
|
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
|
|
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) {
|
|
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
|