rigortype 0.3.0 → 0.3.1
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 +1 -1
- 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/string_scanner.rbs +6 -5
- 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 +32 -0
- 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/plugins/rigor-actioncable.md +32 -0
- data/docs/manual/plugins/rigor-devise.md +4 -2
- data/lib/rigor/analysis/check_rules/void_value_use_collector.rb +21 -2
- data/lib/rigor/analysis/check_rules.rb +34 -13
- data/lib/rigor/analysis/run_cache_key.rb +10 -0
- data/lib/rigor/analysis/runner.rb +2 -1
- 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 +12 -6
- data/lib/rigor/cli/check_invocation.rb +84 -0
- data/lib/rigor/cli/doctor_command.rb +6 -8
- 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/environment/default_libraries.rb +5 -4
- data/lib/rigor/environment.rb +10 -1
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +6 -1
- data/lib/rigor/language_server/buffer_resolution.rb +6 -3
- data/lib/rigor/language_server/buffer_table.rb +46 -6
- data/lib/rigor/language_server/diagnostic_publisher.rb +4 -0
- data/lib/rigor/language_server/incremental_sync.rb +159 -0
- data/lib/rigor/language_server/server.rb +19 -9
- data/lib/rigor/language_server.rb +1 -0
- data/lib/rigor/plugin/base.rb +29 -2
- data/lib/rigor/sig_gen/writer.rb +183 -47
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +51 -1
- data/sig/rigor/cache.rbs +6 -0
- data/sig/rigor/inference/void_origin.rbs +18 -0
- data/sig/rigor/plugin/base.rbs +4 -3
- metadata +7 -3
|
@@ -142,27 +142,43 @@ module Rigor
|
|
|
142
142
|
|
|
143
143
|
# @param skills [Array<Hash>] discovered skills, each `{name:, path:}`.
|
|
144
144
|
# @param root [String] project root to probe (defaults to the cwd).
|
|
145
|
-
|
|
145
|
+
# @param deep [Boolean] opt into `--deep`: run a real `rigor check` and let its result pick the headline. Off by
|
|
146
|
+
# default, and off is ADR-73 WD2's contract — see {SkillDeepProbe} for why the whole analysis path lives
|
|
147
|
+
# behind this one flag and in its own file.
|
|
148
|
+
def initialize(skills:, root: Dir.pwd, deep: false)
|
|
146
149
|
@skills = skills
|
|
147
150
|
@root = root
|
|
151
|
+
@deep = deep
|
|
148
152
|
end
|
|
149
153
|
|
|
150
154
|
# @return [String] the full describe report.
|
|
151
155
|
def render
|
|
152
156
|
catalog = catalog_skills
|
|
153
157
|
state = ProjectStateProbe.new(@root).to_h
|
|
154
|
-
|
|
158
|
+
deep = deep_report(state)
|
|
159
|
+
recommendation = recommend(state, catalog, deep)
|
|
155
160
|
[
|
|
156
161
|
title,
|
|
157
162
|
state_section(state),
|
|
163
|
+
deep_section(deep),
|
|
158
164
|
recommendation_section(recommendation),
|
|
159
165
|
catalog_section(catalog),
|
|
160
|
-
agent_prompt(recommendation)
|
|
161
|
-
].join("\n")
|
|
166
|
+
agent_prompt(recommendation, deep)
|
|
167
|
+
].compact.join("\n")
|
|
162
168
|
end
|
|
163
169
|
|
|
164
170
|
private
|
|
165
171
|
|
|
172
|
+
# The `--deep` analysis, or nil when the flag was not given. `require` sits here rather than at the top of the
|
|
173
|
+
# file so the default, presence-only path never even loads the check-invocation plumbing — WD2's "runs no
|
|
174
|
+
# analysis" guarantee is enforced by what gets loaded, not only by what gets called.
|
|
175
|
+
def deep_report(state)
|
|
176
|
+
return nil unless @deep
|
|
177
|
+
|
|
178
|
+
require_relative "skill_deep_probe"
|
|
179
|
+
SkillDeepProbe.new(config: state.fetch(:config), root: @root).run
|
|
180
|
+
end
|
|
181
|
+
|
|
166
182
|
# The skills offered as "what to do next", in adoption-journey order. The entry-point skill is excluded, and
|
|
167
183
|
# unknown skills sort after the known journey, alphabetically.
|
|
168
184
|
def catalog_skills
|
|
@@ -173,8 +189,12 @@ module Rigor
|
|
|
173
189
|
|
|
174
190
|
# The decision tree (ADR-73 WD2). Returns `{ skill:, reason: }` for the recommended next step, or nil when no
|
|
175
191
|
# catalogue skill matches.
|
|
176
|
-
|
|
177
|
-
|
|
192
|
+
#
|
|
193
|
+
# A `--deep` report overrides the presence-only tree only when it actually routed: a check that was skipped,
|
|
194
|
+
# could not run, or ran clean leaves the tree's answer standing untouched. So the un-flagged behaviour is
|
|
195
|
+
# literally the `deep == nil` branch of this one method.
|
|
196
|
+
def recommend(state, catalog, deep = nil)
|
|
197
|
+
name, reason = deep&.route ? [deep.route, deep.reason] : recommended_name_and_reason(state)
|
|
178
198
|
skill = catalog.find { |candidate| candidate.fetch(:name) == name }
|
|
179
199
|
skill.nil? ? nil : { skill: skill, reason: reason }
|
|
180
200
|
end
|
|
@@ -248,6 +268,18 @@ module Rigor
|
|
|
248
268
|
STATE
|
|
249
269
|
end
|
|
250
270
|
|
|
271
|
+
# Printed only under `--deep`, so the un-flagged report is byte-identical to what it has always been.
|
|
272
|
+
def deep_section(deep)
|
|
273
|
+
return nil if deep.nil?
|
|
274
|
+
|
|
275
|
+
<<~DEEP
|
|
276
|
+
## Deep check (--deep)
|
|
277
|
+
A real `rigor check` was run for this recommendation — unlike the probe above it
|
|
278
|
+
is slow and it writes `.rigor/cache`, exactly as `rigor check` does.
|
|
279
|
+
- Result: #{deep.detail}
|
|
280
|
+
DEEP
|
|
281
|
+
end
|
|
282
|
+
|
|
251
283
|
def recommendation_section(recommendation)
|
|
252
284
|
return "## Recommended next step\n- (no bundled skill matched the current state)\n" if recommendation.nil?
|
|
253
285
|
|
|
@@ -267,7 +299,7 @@ module Rigor
|
|
|
267
299
|
"## All skills you can run next\n#{lines.join("\n")}\n"
|
|
268
300
|
end
|
|
269
301
|
|
|
270
|
-
def agent_prompt(recommendation)
|
|
302
|
+
def agent_prompt(recommendation, deep = nil)
|
|
271
303
|
opener =
|
|
272
304
|
if recommendation.nil?
|
|
273
305
|
"Ask the user what they would like to do next"
|
|
@@ -279,6 +311,33 @@ module Rigor
|
|
|
279
311
|
#{opener}, then run `rigor skill <name>` for the chosen skill and
|
|
280
312
|
follow its body top to bottom.
|
|
281
313
|
|
|
314
|
+
#{check_awareness(deep)}
|
|
315
|
+
|
|
316
|
+
Re-run `rigor skill describe` whenever you need the next step — it always
|
|
317
|
+
reflects the project's current state.
|
|
318
|
+
PROMPT
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# The check-aware routing block. Without `--deep` it teaches the agent to make the call itself from a check it
|
|
322
|
+
# has already run (the 2026-06-20 field-trial fix, unchanged); with `--deep` the headline already made that
|
|
323
|
+
# call, so the block reports what happened instead of re-teaching it. The routing vocabulary is one taxonomy
|
|
324
|
+
# shared by both branches and by {SkillDeepProbe}.
|
|
325
|
+
def check_awareness(deep)
|
|
326
|
+
return presence_only_check_awareness if deep.nil?
|
|
327
|
+
return deep_failure_check_awareness if deep.failed?
|
|
328
|
+
|
|
329
|
+
<<~DEEP.chomp
|
|
330
|
+
The recommendation above already factors in the `--deep` check result, using the
|
|
331
|
+
same routing this command teaches without `--deep`: proven project monkey-patches
|
|
332
|
+
→ rigor-monkeypatch-resolve; `RBS classes available: 0` or a `configuration-error`
|
|
333
|
+
→ rigor-doctor; remaining error diagnostics → rigor-baseline-reduce. It does not
|
|
334
|
+
route on weaker signals (framework calls typing as Dynamic → rigor-plugin-tune is
|
|
335
|
+
still yours to judge from the check output).
|
|
336
|
+
DEEP
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
def presence_only_check_awareness
|
|
340
|
+
<<~PROMPT.chomp
|
|
282
341
|
The recommendation above is from a presence-only probe — it does not run
|
|
283
342
|
`rigor check`. If you have run (or now run) `rigor check`, let its findings
|
|
284
343
|
refine the choice:
|
|
@@ -287,9 +346,16 @@ module Rigor
|
|
|
287
346
|
- framework calls (ActiveRecord, routes, i18n …) typing as Dynamic with no
|
|
288
347
|
matching plugins enabled → rigor-plugin-tune
|
|
289
348
|
- `RBS classes available: 0` or a `configuration-error` diagnostic → rigor-doctor
|
|
349
|
+
(`rigor skill describe --deep` runs that check for you and applies this routing
|
|
350
|
+
to the headline itself — at the cost of a full analysis.)
|
|
351
|
+
PROMPT
|
|
352
|
+
end
|
|
290
353
|
|
|
291
|
-
|
|
292
|
-
|
|
354
|
+
def deep_failure_check_awareness
|
|
355
|
+
<<~PROMPT.chomp
|
|
356
|
+
The `--deep` check did NOT complete, so the recommendation above is the
|
|
357
|
+
presence-only one. Do not treat the project as clean: run `rigor doctor`
|
|
358
|
+
(or `rigor check`) and fix what it reports before trusting any routing.
|
|
293
359
|
PROMPT
|
|
294
360
|
end
|
|
295
361
|
|
|
@@ -6,10 +6,11 @@ module Rigor
|
|
|
6
6
|
# unless the caller passes an explicit `libraries:` array. Each entry MUST be a stdlib library name
|
|
7
7
|
# accepted by `RBS::EnvironmentLoader#has_library?`; unknown libraries MUST fail-soft
|
|
8
8
|
# (`RbsLoader#build_env` already filters through `has_library?`). The default set covers the common
|
|
9
|
-
# stdlib surface a Ruby program is likely to import
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
9
|
+
# stdlib surface a Ruby program is likely to import, plus the analyzer-adjacent gems shipping their own
|
|
10
|
+
# RBS in this bundle (`prism`, `rbs`). The list below is the enumeration — no prose gloss restates a
|
|
11
|
+
# subset of it, here or in `docs/internal-spec/inference-engine.md` § Environment Surface, because a
|
|
12
|
+
# restated subset silently rots as entries are added. On hosts where one of these libraries is not
|
|
13
|
+
# installed, the loader silently drops it.
|
|
13
14
|
#
|
|
14
15
|
# Callers MAY add to the default by passing `libraries: %w[csv ...]`; the explicit list is appended to
|
|
15
16
|
# `DEFAULT_LIBRARIES` and de-duplicated. Callers that need a strictly RBS-core view MUST construct an
|
data/lib/rigor/environment.rb
CHANGED
|
@@ -394,7 +394,8 @@ module Rigor
|
|
|
394
394
|
cache_store.fetch_or_compute(
|
|
395
395
|
producer_id: SYNTHESIZER_CACHE_PRODUCER_ID,
|
|
396
396
|
params: {},
|
|
397
|
-
descriptor: descriptor
|
|
397
|
+
descriptor: descriptor,
|
|
398
|
+
generation_cap: synthesizer_generation_cap
|
|
398
399
|
) { invoke_synthesizer_safely(callable, path) || "" }
|
|
399
400
|
end
|
|
400
401
|
|
|
@@ -420,6 +421,14 @@ module Rigor
|
|
|
420
421
|
SYNTHESIZER_CACHE_PRODUCER_ID = "plugin.source_rbs_synthesizer"
|
|
421
422
|
private_constant :SYNTHESIZER_CACHE_PRODUCER_ID
|
|
422
423
|
|
|
424
|
+
# One entry per (plugin, source file), all of them live for as long as the file is in the project — a
|
|
425
|
+
# generation count says nothing about staleness here, so this producer declares itself out of
|
|
426
|
+
# `Cache::Store#evict!`'s compaction pass and is bounded only by the size-based LRU pass. A method
|
|
427
|
+
# rather than a constant: `Cache::Store` is not loaded yet when this class body runs.
|
|
428
|
+
def synthesizer_generation_cap
|
|
429
|
+
Cache::Store::UNBOUNDED_GENERATIONS
|
|
430
|
+
end
|
|
431
|
+
|
|
423
432
|
def build_synthesizer_cache_descriptor(plugin, path)
|
|
424
433
|
Cache::Descriptor.new(
|
|
425
434
|
files: [Cache::Descriptor::FileEntry.new(
|
|
@@ -55,7 +55,12 @@ module Rigor
|
|
|
55
55
|
"::io" => ["IO"],
|
|
56
56
|
"::encoding" => %w[Encoding String],
|
|
57
57
|
"::path" => ["String"],
|
|
58
|
-
"::boolean" => %w[TrueClass FalseClass]
|
|
58
|
+
"::boolean" => %w[TrueClass FalseClass],
|
|
59
|
+
# `range[T] = Range[T] | _Range[T]`. Generic, unlike the others, but the strict arm is still a
|
|
60
|
+
# single nominal and the args are irrelevant to this pass. rbs 4.1 rewrote `Array#[]`'s slicing
|
|
61
|
+
# overload from `(::Range[::Integer?])` to `(range[int])`; without the entry both it and the
|
|
62
|
+
# `(int) -> E` overload look alias-typed, so `a[1..2]` resolved to the element type.
|
|
63
|
+
"::range" => ["Range"]
|
|
59
64
|
}.freeze
|
|
60
65
|
private_constant :ALIAS_STRICT_NOMINALS
|
|
61
66
|
|
|
@@ -13,12 +13,15 @@ module Rigor
|
|
|
13
13
|
module BufferResolution
|
|
14
14
|
private
|
|
15
15
|
|
|
16
|
-
# Resolves `[path, entry]` for the document `uri`, or nil when the uri has no file path
|
|
17
|
-
# buffer
|
|
18
|
-
#
|
|
16
|
+
# Resolves `[path, entry]` for the document `uri`, or nil when the uri has no file path, no open buffer,
|
|
17
|
+
# or a buffer the server could not keep in sync with the editor (see `BufferTable#apply_changes`) — a
|
|
18
|
+
# position answered from text that has drifted from the editor's is worse than no answer. A caller that
|
|
19
|
+
# destructures `path, entry = buffer_for(uri)` can guard on `entry.nil?` to cover every miss (a nil
|
|
20
|
+
# return leaves both locals nil).
|
|
19
21
|
def buffer_for(uri)
|
|
20
22
|
path = Uri.to_path(uri)
|
|
21
23
|
return nil if path.nil?
|
|
24
|
+
return nil if @buffer_table.desynchronized?(uri)
|
|
22
25
|
|
|
23
26
|
entry = @buffer_table[uri]
|
|
24
27
|
return nil if entry.nil?
|
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "incremental_sync"
|
|
4
|
+
|
|
3
5
|
module Rigor
|
|
4
6
|
module LanguageServer
|
|
5
7
|
# Per-session virtual file table. The LSP server maintains the canonical view of every open buffer here;
|
|
6
8
|
# analysis (slice 4+) reads from this table instead of disk so in-flight edits are reflected immediately.
|
|
7
9
|
#
|
|
8
|
-
# Keyed by `DocumentUri` (LSP `file://...` URIs).
|
|
9
|
-
# `TextDocumentSyncKind::
|
|
10
|
-
#
|
|
10
|
+
# Keyed by `DocumentUri` (LSP `file://...` URIs). Sync is INCREMENTAL (LSP
|
|
11
|
+
# `TextDocumentSyncKind::Incremental = 2`): a `didChange` carries range edits which {#apply_changes} splices
|
|
12
|
+
# into the held text through {IncrementalSync}. The full-text form ({#change}, and a `contentChanges` entry
|
|
13
|
+
# with no `range`) stays supported — it is still legal under incremental sync and is what `didOpen` and a
|
|
14
|
+
# client-side resync send.
|
|
11
15
|
class BufferTable
|
|
12
16
|
# @!attribute uri [String] the LSP DocumentUri (e.g. `file:///abs/path/lib/foo.rb`).
|
|
13
17
|
# @!attribute bytes [String] the current full text of the buffer.
|
|
@@ -16,23 +20,59 @@ module Rigor
|
|
|
16
20
|
|
|
17
21
|
def initialize
|
|
18
22
|
@entries = {}
|
|
23
|
+
@desynchronized = {}
|
|
19
24
|
end
|
|
20
25
|
|
|
21
26
|
# Records a `textDocument/didOpen` event. Replaces any existing entry (LSP clients may re-open a
|
|
22
|
-
# previously closed URI; the new version is authoritative)
|
|
27
|
+
# previously closed URI; the new version is authoritative) and clears any desynchronised mark — the
|
|
28
|
+
# payload carries the client's full text, so the two views agree again.
|
|
23
29
|
def open(uri:, bytes:, version:)
|
|
30
|
+
@desynchronized.delete(uri)
|
|
24
31
|
@entries[uri] = Entry.new(uri: uri, bytes: bytes, version: version)
|
|
25
32
|
end
|
|
26
33
|
|
|
27
|
-
# Records a `textDocument/didChange
|
|
28
|
-
#
|
|
34
|
+
# Records a full-text `textDocument/didChange`. The full new buffer text replaces the entry. If the
|
|
35
|
+
# client sends a `didChange` for a URI that was never opened (spec violation), the entry is still
|
|
29
36
|
# created — defensive.
|
|
30
37
|
def change(uri:, bytes:, version:)
|
|
38
|
+
@desynchronized.delete(uri)
|
|
31
39
|
@entries[uri] = Entry.new(uri: uri, bytes: bytes, version: version)
|
|
32
40
|
end
|
|
33
41
|
|
|
42
|
+
# Applies a `textDocument/didChange` payload under INCREMENTAL sync. Each entry of `changes` is applied
|
|
43
|
+
# in order against the result of the previous one, per the LSP contract.
|
|
44
|
+
#
|
|
45
|
+
# On success the entry is replaced with the spliced text at the new version. On failure — a malformed
|
|
46
|
+
# change, or a range edit for a URI with no held buffer — the held text is left EXACTLY as it was and the
|
|
47
|
+
# URI is marked desynchronised: the server's view and the editor's view have diverged, and every position
|
|
48
|
+
# computed from the stale text would be wrong. Consumers check {#desynchronized?} and decline to answer
|
|
49
|
+
# rather than answering wrongly; the mark clears on the next `didOpen` or full-text change.
|
|
50
|
+
#
|
|
51
|
+
# @return [Boolean] true when the changes were applied.
|
|
52
|
+
def apply_changes(uri:, changes:, version:)
|
|
53
|
+
text = IncrementalSync.apply_all(@entries[uri]&.bytes, changes)
|
|
54
|
+
@desynchronized.delete(uri)
|
|
55
|
+
@entries[uri] = Entry.new(uri: uri, bytes: text, version: version)
|
|
56
|
+
true
|
|
57
|
+
rescue IncrementalSync::UnappliableChange => e
|
|
58
|
+
@desynchronized[uri] = e.message
|
|
59
|
+
false
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# @return [Boolean] true when the last `didChange` for `uri` could not be applied, so the held text no
|
|
63
|
+
# longer matches the editor's.
|
|
64
|
+
def desynchronized?(uri)
|
|
65
|
+
@desynchronized.key?(uri)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# @return [String, nil] why `uri` is desynchronised, for the log line; nil when it is in sync.
|
|
69
|
+
def desynchronization_reason(uri)
|
|
70
|
+
@desynchronized[uri]
|
|
71
|
+
end
|
|
72
|
+
|
|
34
73
|
# Records a `textDocument/didClose` event. The entry is removed. Subsequent reads via `#[]` return nil.
|
|
35
74
|
def close(uri:)
|
|
75
|
+
@desynchronized.delete(uri)
|
|
36
76
|
@entries.delete(uri)
|
|
37
77
|
end
|
|
38
78
|
|
|
@@ -80,6 +80,10 @@ module Rigor
|
|
|
80
80
|
# The buffer may have been closed during the debounce window — drop the publish; the empty
|
|
81
81
|
# notification from didClose already cleared the markers.
|
|
82
82
|
return if entry.nil?
|
|
83
|
+
# An incremental change the table could not apply: the held text no longer matches the editor's, so
|
|
84
|
+
# every span we could compute from it would be misplaced. Clear the markers and stay silent until a
|
|
85
|
+
# full-text change or a re-open re-establishes the buffer.
|
|
86
|
+
return notify(uri, []) if @buffer_table.desynchronized?(uri)
|
|
83
87
|
|
|
84
88
|
diagnostics = run_analysis(path: path, bytes: entry.bytes)
|
|
85
89
|
notify(uri, diagnostics)
|
|
@@ -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
|
|
@@ -21,8 +21,15 @@ module Rigor
|
|
|
21
21
|
ERROR_SERVER_NOT_INITIALIZED = -32_002
|
|
22
22
|
ERROR_INVALID_REQUEST_AFTER_SHUTDOWN = -32_600
|
|
23
23
|
|
|
24
|
-
# `TextDocumentSyncKind::
|
|
25
|
-
|
|
24
|
+
# `TextDocumentSyncKind::Incremental = 2`: `didChange` carries range edits, which `IncrementalSync`
|
|
25
|
+
# splices into the buffer the table already holds instead of re-sending — and re-parsing — the whole
|
|
26
|
+
# document on every keystroke. The full-text entry form (a `contentChanges` entry with no `range`) stays
|
|
27
|
+
# legal under this mode and is still handled.
|
|
28
|
+
TEXT_DOCUMENT_SYNC_INCREMENTAL = 2
|
|
29
|
+
|
|
30
|
+
# LSP `PositionEncodingKind`. UTF-16 is the protocol default and the encoding `IncrementalSync` does its
|
|
31
|
+
# offset arithmetic in; advertising it explicitly states the contract rather than leaving it implied.
|
|
32
|
+
POSITION_ENCODING_UTF16 = "utf-16"
|
|
26
33
|
|
|
27
34
|
# Methods callable BEFORE `initialize`. Per LSP spec § 3 only `initialize` and `exit` are allowed
|
|
28
35
|
# pre-initialization; every other request returns `ServerNotInitialized`. We also accept `shutdown` so a
|
|
@@ -151,9 +158,10 @@ module Rigor
|
|
|
151
158
|
|
|
152
159
|
def advertised_capabilities
|
|
153
160
|
caps = {
|
|
161
|
+
positionEncoding: POSITION_ENCODING_UTF16,
|
|
154
162
|
textDocumentSync: {
|
|
155
163
|
openClose: true,
|
|
156
|
-
change:
|
|
164
|
+
change: TEXT_DOCUMENT_SYNC_INCREMENTAL
|
|
157
165
|
}
|
|
158
166
|
}
|
|
159
167
|
caps[:hoverProvider] = true if @hover_provider
|
|
@@ -213,19 +221,21 @@ module Rigor
|
|
|
213
221
|
nil
|
|
214
222
|
end
|
|
215
223
|
|
|
216
|
-
# textDocument/didChange under
|
|
217
|
-
#
|
|
218
|
-
#
|
|
219
|
-
#
|
|
224
|
+
# textDocument/didChange under INCREMENTAL sync. Every `contentChanges` entry is applied in order, each
|
|
225
|
+
# against the result of the previous — an entry with a `range` splices that span, an entry without one is
|
|
226
|
+
# the full new document text. The application (and its UTF-16 offset arithmetic) lives in
|
|
227
|
+
# `IncrementalSync`; the table keeps the last known-good text and flags the URI when a change cannot be
|
|
228
|
+
# applied. Triggers `publishDiagnostics` either way: a desynchronised buffer publishes an EMPTY set,
|
|
229
|
+
# which clears the markers instead of leaving stale ones on screen.
|
|
220
230
|
def handle_did_change(params)
|
|
221
231
|
doc = params.fetch(:textDocument)
|
|
222
232
|
changes = params.fetch(:contentChanges)
|
|
223
233
|
return nil if changes.empty?
|
|
224
234
|
|
|
225
235
|
uri = doc.fetch(:uri)
|
|
226
|
-
@buffer_table.
|
|
236
|
+
@buffer_table.apply_changes(
|
|
227
237
|
uri: uri,
|
|
228
|
-
|
|
238
|
+
changes: changes,
|
|
229
239
|
version: doc.fetch(:version)
|
|
230
240
|
)
|
|
231
241
|
@publisher&.publish_for(uri)
|
data/lib/rigor/plugin/base.rb
CHANGED
|
@@ -8,6 +8,9 @@ require "prism"
|
|
|
8
8
|
require_relative "manifest"
|
|
9
9
|
require_relative "node_context"
|
|
10
10
|
require_relative "../analysis/diagnostic"
|
|
11
|
+
# `producer generation_cap:` defaults to (and validates against) `Cache::Store::UNBOUNDED_GENERATIONS`, and a
|
|
12
|
+
# plugin class body can be evaluated before anything else pulled the cache layer in.
|
|
13
|
+
require_relative "../cache/store"
|
|
11
14
|
require_relative "../source/node_walker"
|
|
12
15
|
|
|
13
16
|
module Rigor
|
|
@@ -86,17 +89,40 @@ module Rigor
|
|
|
86
89
|
#
|
|
87
90
|
# Producer ids are auto-prefixed `plugin.<manifest.id>.` at the cache layer (slice 6-C) so plugin-side
|
|
88
91
|
# ids cannot collide with built-in producers.
|
|
89
|
-
|
|
92
|
+
#
|
|
93
|
+
# `generation_cap:` declares how many generations of this producer's entries survive
|
|
94
|
+
# `Cache::Store#evict!`'s compaction pass. The default —
|
|
95
|
+
# `Cache::Store::UNBOUNDED_GENERATIONS` — suits the usual plugin producer, which keys per file or per
|
|
96
|
+
# discovered unit and keeps many entries live at once; only the size-based LRU pass bounds it. A
|
|
97
|
+
# producer whose entries are WHOLE-PROJECT and content-keyed (each run's inputs produce a fresh key
|
|
98
|
+
# and orphan the previous one) should declare a small positive Integer instead, so the orphans are
|
|
99
|
+
# reclaimed rather than accumulating under the global byte cap.
|
|
100
|
+
def producer(id, watch: nil, serialize: nil, deserialize: nil,
|
|
101
|
+
generation_cap: Cache::Store::UNBOUNDED_GENERATIONS, &block)
|
|
90
102
|
raise ArgumentError, "Plugin::Base.producer requires a block body" if block.nil?
|
|
91
103
|
|
|
92
104
|
validate_producer_watch!(watch)
|
|
105
|
+
validate_producer_generation_cap!(id, generation_cap)
|
|
93
106
|
@producers ||= {}
|
|
94
107
|
@producers[id.to_sym] = {
|
|
95
|
-
block: block, watch: watch, serialize: serialize, deserialize: deserialize
|
|
108
|
+
block: block, watch: watch, serialize: serialize, deserialize: deserialize,
|
|
109
|
+
generation_cap: generation_cap
|
|
96
110
|
}.freeze
|
|
97
111
|
id.to_sym
|
|
98
112
|
end
|
|
99
113
|
|
|
114
|
+
# A bad `generation_cap:` is caught at class-definition time (plugin load) rather than at the first
|
|
115
|
+
# `cache_for` round-trip, so the plugin author sees it before any caching happens.
|
|
116
|
+
def validate_producer_generation_cap!(id, generation_cap)
|
|
117
|
+
return if generation_cap == Cache::Store::UNBOUNDED_GENERATIONS
|
|
118
|
+
return if generation_cap.is_a?(Integer) && generation_cap.positive?
|
|
119
|
+
|
|
120
|
+
raise ArgumentError,
|
|
121
|
+
"Plugin::Base.producer #{id.inspect} generation_cap: must be a positive Integer or " \
|
|
122
|
+
"#{Cache::Store::UNBOUNDED_GENERATIONS.inspect}, got #{generation_cap.inspect}"
|
|
123
|
+
end
|
|
124
|
+
private :validate_producer_generation_cap!
|
|
125
|
+
|
|
100
126
|
# ADR-60 WD3 — `watch:` is nil (no glob coverage), a static tuple Array, or a Proc evaluated per
|
|
101
127
|
# `cache_for` call.
|
|
102
128
|
def validate_producer_watch!(watch)
|
|
@@ -728,6 +754,7 @@ module Rigor
|
|
|
728
754
|
store.fetch_or_validate(
|
|
729
755
|
producer_id: prefixed_id,
|
|
730
756
|
key_descriptor: key_descriptor,
|
|
757
|
+
generation_cap: producer[:generation_cap],
|
|
731
758
|
params: params,
|
|
732
759
|
serialize: pair_serializer(producer[:serialize]),
|
|
733
760
|
deserialize: pair_deserializer(producer[:deserialize])
|