rigortype 0.3.1 → 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/core_overlay/pathname.rbs +5 -0
- data/data/core_overlay/resolv.rbs +31 -0
- data/data/core_overlay/string_scanner.rbs +5 -0
- 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/manual/02-cli-reference.md +71 -11
- data/docs/manual/03-configuration.md +16 -1
- data/docs/manual/12-caching.md +17 -5
- data/docs/manual/15-type-protection-coverage.md +122 -1
- 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.rb +45 -6
- 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 +17 -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 +78 -7
- 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/cli/check_command.rb +49 -28
- data/lib/rigor/cli/coverage_command.rb +15 -2
- data/lib/rigor/cli/coverage_mutation.rb +242 -11
- 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/configuration.rb +45 -6
- data/lib/rigor/environment/bundle_sig_discovery.rb +2 -1
- data/lib/rigor/environment/rbs_coverage_report.rb +1 -1
- data/lib/rigor/environment/rbs_loader.rb +434 -75
- data/lib/rigor/environment.rb +28 -13
- 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/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_table.rb +22 -0
- data/lib/rigor/language_server/diagnostic_publisher.rb +203 -4
- 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 +20 -1
- data/lib/rigor/language_server.rb +1 -0
- 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 +12 -0
- data/lib/rigor/sig_gen.rb +1 -0
- data/lib/rigor/version.rb +1 -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/inference/void_origin.rbs +6 -3
- data/sig/rigor/scope.rbs +8 -0
- metadata +17 -1
|
@@ -247,9 +247,31 @@ module Rigor
|
|
|
247
247
|
|
|
248
248
|
bound = bound.without_inferred_param_mark(node.name)
|
|
249
249
|
bound = bound.with_local_origin(node.name, rhs_origin(node.value, post_rhs, rhs_type))
|
|
250
|
+
bound = bound.with_optimistic_local(node.name, optimistic_rhs_origin(node.value, post_rhs))
|
|
250
251
|
[rhs_type, bound]
|
|
251
252
|
end
|
|
252
253
|
|
|
254
|
+
# Issue #286 — the optimistic-nil-free counterpart of {#rhs_origin}, differing in two ways. It does not
|
|
255
|
+
# gate on `Dynamic`: the values this channel marks are ordinary `Union` / `Constant` / `Nominal`
|
|
256
|
+
# carriers, which is the whole point. And it resolves a bare local read through its binding, so
|
|
257
|
+
# `w = v` keeps the mark — the same propagation `OriginLookup` performs for the Dynamic channel.
|
|
258
|
+
def optimistic_rhs_origin(value_node, scope_after_rhs)
|
|
259
|
+
optimistic_origin_for(value_node, scope_after_rhs)
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# The effective optimistic-nil-free cause of an expression: the mark on its own node, else — for a bare
|
|
263
|
+
# local read or a local write used in value position (`if (x = MAP[k])`, where the write has already
|
|
264
|
+
# bound the mark) — the one propagated onto the binding.
|
|
265
|
+
def optimistic_origin_for(node, scope)
|
|
266
|
+
recorded = scope.optimistic_origins[node]
|
|
267
|
+
return recorded if recorded
|
|
268
|
+
|
|
269
|
+
case node
|
|
270
|
+
when Prism::LocalVariableReadNode, Prism::LocalVariableWriteNode then scope.optimistic_local(node.name)
|
|
271
|
+
when Prism::InstanceVariableReadNode, Prism::InstanceVariableWriteNode then scope.optimistic_ivar(node.name)
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
|
|
253
275
|
# ADR-82 WD1 — the {Inference::DynamicOrigin} cause to propagate onto a local / ivar being bound to `rhs`.
|
|
254
276
|
# Returns the cause recorded on the assignment's rhs node when the value is `Dynamic` (so a later
|
|
255
277
|
# `x` / `@x` receiver-read resolves to why it is dynamic), else `nil` — `with_local_origin` /
|
|
@@ -277,6 +299,7 @@ module Rigor
|
|
|
277
299
|
rhs_type, post_rhs = sub_eval(node.value, scope)
|
|
278
300
|
bound = post_rhs.with_ivar(node.name, rhs_type)
|
|
279
301
|
bound = bound.with_ivar_origin(node.name, rhs_origin(node.value, post_rhs, rhs_type))
|
|
302
|
+
bound = bound.with_optimistic_ivar(node.name, optimistic_rhs_origin(node.value, post_rhs))
|
|
280
303
|
[rhs_type, bound]
|
|
281
304
|
end
|
|
282
305
|
|
|
@@ -543,19 +566,34 @@ module Rigor
|
|
|
543
566
|
# non-falsey carriers like `Nominal[Integer]` (Integer is always truthy in Ruby — including 0) also collapse the
|
|
544
567
|
# dead else.
|
|
545
568
|
def live_branch_for_if(node, pred_type, post_pred)
|
|
546
|
-
|
|
569
|
+
verdict = optimistic_carrier?(node.predicate, post_pred) ? nil : Narrowing.predicate_certainty(pred_type)
|
|
570
|
+
case verdict
|
|
547
571
|
when :truthy then eval_branch_or_nil(node.statements, post_pred)
|
|
548
572
|
when :falsey then eval_branch_or_nil(node.subsequent, post_pred)
|
|
549
573
|
end
|
|
550
574
|
end
|
|
551
575
|
|
|
552
576
|
def live_branch_for_unless(node, pred_type, post_pred)
|
|
553
|
-
|
|
577
|
+
verdict = optimistic_carrier?(node.predicate, post_pred) ? nil : Narrowing.predicate_certainty(pred_type)
|
|
578
|
+
case verdict
|
|
554
579
|
when :truthy then eval_branch_or_nil(node.else_clause, post_pred)
|
|
555
580
|
when :falsey then eval_branch_or_nil(node.statements, post_pred)
|
|
556
581
|
end
|
|
557
582
|
end
|
|
558
583
|
|
|
584
|
+
# ADR-101 — the branch elision MUST NOT conclude truthiness from a carrier whose nil-freeness rests on
|
|
585
|
+
# the `%a{implicitly-returns-nil}` that `RbsDispatch` reads past. Such a value is optimistic, not proof
|
|
586
|
+
# (see {Inference::OptimisticOrigin} and docs/internal-spec/inference-engine.md), so eliding an arm on
|
|
587
|
+
# it deletes a branch the program really takes when the lookup misses.
|
|
588
|
+
#
|
|
589
|
+
# The decline lives here and NOT in `Narrowing.falsey_nominal?` / `.narrow_falsey`: `&&=` / `||=` and
|
|
590
|
+
# the and/or surviving-left edge read those too, and widening the falsey fragment there would re-admit
|
|
591
|
+
# `nil` into a bound local and buy `possible nil receiver` false positives — a soundness fix paid for
|
|
592
|
+
# in FPs, which is the wrong trade.
|
|
593
|
+
def optimistic_carrier?(predicate, scope)
|
|
594
|
+
!optimistic_origin_for(predicate, scope).nil?
|
|
595
|
+
end
|
|
596
|
+
|
|
559
597
|
def eval_else(node)
|
|
560
598
|
return [Type::Combinator.constant_of(nil), scope] if node.statements.nil?
|
|
561
599
|
|
|
@@ -21,6 +21,7 @@ module Rigor
|
|
|
21
21
|
def initialize
|
|
22
22
|
@entries = {}
|
|
23
23
|
@desynchronized = {}
|
|
24
|
+
@dirty = {}
|
|
24
25
|
end
|
|
25
26
|
|
|
26
27
|
# Records a `textDocument/didOpen` event. Replaces any existing entry (LSP clients may re-open a
|
|
@@ -28,6 +29,7 @@ module Rigor
|
|
|
28
29
|
# payload carries the client's full text, so the two views agree again.
|
|
29
30
|
def open(uri:, bytes:, version:)
|
|
30
31
|
@desynchronized.delete(uri)
|
|
32
|
+
@dirty.delete(uri)
|
|
31
33
|
@entries[uri] = Entry.new(uri: uri, bytes: bytes, version: version)
|
|
32
34
|
end
|
|
33
35
|
|
|
@@ -36,6 +38,7 @@ module Rigor
|
|
|
36
38
|
# created — defensive.
|
|
37
39
|
def change(uri:, bytes:, version:)
|
|
38
40
|
@desynchronized.delete(uri)
|
|
41
|
+
@dirty[uri] = true
|
|
39
42
|
@entries[uri] = Entry.new(uri: uri, bytes: bytes, version: version)
|
|
40
43
|
end
|
|
41
44
|
|
|
@@ -52,6 +55,7 @@ module Rigor
|
|
|
52
55
|
def apply_changes(uri:, changes:, version:)
|
|
53
56
|
text = IncrementalSync.apply_all(@entries[uri]&.bytes, changes)
|
|
54
57
|
@desynchronized.delete(uri)
|
|
58
|
+
@dirty[uri] = true
|
|
55
59
|
@entries[uri] = Entry.new(uri: uri, bytes: text, version: version)
|
|
56
60
|
true
|
|
57
61
|
rescue IncrementalSync::UnappliableChange => e
|
|
@@ -73,9 +77,27 @@ module Rigor
|
|
|
73
77
|
# Records a `textDocument/didClose` event. The entry is removed. Subsequent reads via `#[]` return nil.
|
|
74
78
|
def close(uri:)
|
|
75
79
|
@desynchronized.delete(uri)
|
|
80
|
+
@dirty.delete(uri)
|
|
76
81
|
@entries.delete(uri)
|
|
77
82
|
end
|
|
78
83
|
|
|
84
|
+
# Records a `textDocument/didSave`. The client has written the buffer, so the held text and the file on
|
|
85
|
+
# disk agree again and the URI stops being dirty.
|
|
86
|
+
#
|
|
87
|
+
# Dirtiness is the PROTOCOL's notion — "the client told us it changed and has not told us it saved" —
|
|
88
|
+
# not a byte comparison against disk. A comparison would look stricter and be weaker: it races with the
|
|
89
|
+
# editor's own write, and the server's truth is what the client notified.
|
|
90
|
+
def save(uri:)
|
|
91
|
+
@dirty.delete(uri)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# @return [Boolean] true when `uri` has unsaved changes. A buffer that is dirty may only be published
|
|
95
|
+
# from an analysis that bound ITS bytes — see the publish-set invariant in
|
|
96
|
+
# `docs/design/20260517-language-server.md`.
|
|
97
|
+
def dirty?(uri)
|
|
98
|
+
@dirty.key?(uri)
|
|
99
|
+
end
|
|
100
|
+
|
|
79
101
|
def [](uri)
|
|
80
102
|
@entries[uri]
|
|
81
103
|
end
|
|
@@ -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,6 +131,73 @@ 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
|
|
@@ -89,6 +212,60 @@ module Rigor
|
|
|
89
212
|
notify(uri, diagnostics)
|
|
90
213
|
end
|
|
91
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
|
+
|
|
92
269
|
# Runs `Analysis::Runner` with a `BufferBinding` so the buffer bytes (instead of the on-disk file) drive
|
|
93
270
|
# the parse. The `Rigor::Analysis::ProjectScan` cached on the ProjectContext is passed through
|
|
94
271
|
# `prebuilt:` so plugin `#prepare`, the dependency-source walker, and the synthetic-method /
|
|
@@ -121,6 +298,28 @@ module Rigor
|
|
|
121
298
|
tmp&.unlink
|
|
122
299
|
end
|
|
123
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
|
+
|
|
124
323
|
# @return [Hash, nil] the LSP `Diagnostic` Hash, or nil to
|
|
125
324
|
# skip diagnostics outside the buffer's own path (e.g.
|
|
126
325
|
# `.rigor.yml`-anchored info diagnostics get filtered —
|
|
@@ -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
|
|
@@ -95,6 +95,7 @@ module Rigor
|
|
|
95
95
|
when "exit" then handle_exit
|
|
96
96
|
when "textDocument/didOpen" then handle_did_open(params)
|
|
97
97
|
when "textDocument/didChange" then handle_did_change(params)
|
|
98
|
+
when "textDocument/didSave" then handle_did_save(params)
|
|
98
99
|
when "textDocument/didClose" then handle_did_close(params)
|
|
99
100
|
when "textDocument/hover" then handle_hover(params)
|
|
100
101
|
when "textDocument/documentSymbol" then handle_document_symbol(params)
|
|
@@ -161,7 +162,10 @@ module Rigor
|
|
|
161
162
|
positionEncoding: POSITION_ENCODING_UTF16,
|
|
162
163
|
textDocumentSync: {
|
|
163
164
|
openClose: true,
|
|
164
|
-
change: TEXT_DOCUMENT_SYNC_INCREMENTAL
|
|
165
|
+
change: TEXT_DOCUMENT_SYNC_INCREMENTAL,
|
|
166
|
+
# `includeText: false` — the round reads the file the client just wrote, so the payload's copy
|
|
167
|
+
# would be redundant. See `handle_did_save`.
|
|
168
|
+
save: { includeText: false }
|
|
165
169
|
}
|
|
166
170
|
}
|
|
167
171
|
caps[:hoverProvider] = true if @hover_provider
|
|
@@ -242,6 +246,21 @@ module Rigor
|
|
|
242
246
|
nil
|
|
243
247
|
end
|
|
244
248
|
|
|
249
|
+
# textDocument/didSave notification. Marks the buffer clean — the client has written it, so the held
|
|
250
|
+
# text and the file on disk agree — and starts a whole-project publish round (#246).
|
|
251
|
+
#
|
|
252
|
+
# This is where whole-project scope lives, rather than on `didChange`: a round costs ~0.6s on a
|
|
253
|
+
# mid-sized project against a 250ms `didChange` p50 budget, and "the rest of the project catches up"
|
|
254
|
+
# is what saving means to the user. Because the saved bytes are the bytes on disk, the round needs no
|
|
255
|
+
# buffer binding at all. Design: `docs/design/20260517-language-server.md` § "Whole-project publishes
|
|
256
|
+
# on save".
|
|
257
|
+
def handle_did_save(params)
|
|
258
|
+
uri = params.fetch(:textDocument).fetch(:uri)
|
|
259
|
+
@buffer_table.save(uri: uri)
|
|
260
|
+
@publisher&.publish_project(uri)
|
|
261
|
+
nil
|
|
262
|
+
end
|
|
263
|
+
|
|
245
264
|
# textDocument/hover REQUEST. Slice 5 returns either a `Hover` payload (markdown contents wrapping type
|
|
246
265
|
# + erased-RBS info) or nil when no expression is at the queried position. Nil maps to `result: null`
|
|
247
266
|
# per LSP spec; clients suppress the popup. Returns `MethodNotFound` when no hover_provider is wired
|
|
@@ -14,6 +14,7 @@ require_relative "language_server/buffer_table"
|
|
|
14
14
|
require_relative "language_server/uri"
|
|
15
15
|
require_relative "language_server/project_context"
|
|
16
16
|
require_relative "language_server/debouncer"
|
|
17
|
+
require_relative "language_server/publish_batcher"
|
|
17
18
|
require_relative "language_server/synchronized_writer"
|
|
18
19
|
require_relative "language_server/diagnostic_publisher"
|
|
19
20
|
require_relative "language_server/hover_renderer"
|
|
@@ -16,17 +16,23 @@ module Rigor
|
|
|
16
16
|
# single reporter across the run; entries are appended one at a time during env build (before any per-file
|
|
17
17
|
# analysis runs), so no locking is needed.
|
|
18
18
|
class SourceRbsSynthesisReporter
|
|
19
|
-
|
|
19
|
+
# `kind` separates the two outcomes the Runner reports differently (ADR-32 WD12): `:failed` is a
|
|
20
|
+
# synthesis that raised or could not parse, `:not_honoured` a synthesis that SUCCEEDED while silently
|
|
21
|
+
# dropping an annotation it parsed. They are not the same news — the first says the file contributed
|
|
22
|
+
# nothing, the second that it contributed all but one thing — so they carry distinct diagnostic ids.
|
|
23
|
+
# Defaults to `:failed`, the pre-WD12 meaning, so an older caller records what it always did.
|
|
24
|
+
Entry = Data.define(:plugin_id, :path, :message, :kind)
|
|
20
25
|
|
|
21
26
|
def initialize
|
|
22
27
|
@entries = []
|
|
23
28
|
end
|
|
24
29
|
|
|
25
|
-
def record(plugin_id:, path:, message:)
|
|
30
|
+
def record(plugin_id:, path:, message:, kind: :failed)
|
|
26
31
|
@entries << Entry.new(
|
|
27
32
|
plugin_id: plugin_id.to_s.dup.freeze,
|
|
28
33
|
path: path.to_s.dup.freeze,
|
|
29
|
-
message: message.to_s.dup.freeze
|
|
34
|
+
message: message.to_s.dup.freeze,
|
|
35
|
+
kind: kind
|
|
30
36
|
)
|
|
31
37
|
nil
|
|
32
38
|
end
|