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.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/data/builtins/ruby_core/array.yml +416 -392
  4. data/data/builtins/ruby_core/file.yml +42 -42
  5. data/data/builtins/ruby_core/hash.yml +302 -302
  6. data/data/builtins/ruby_core/io.yml +191 -191
  7. data/data/builtins/ruby_core/numeric.yml +321 -366
  8. data/data/builtins/ruby_core/proc.yml +124 -124
  9. data/data/builtins/ruby_core/range.yml +21 -21
  10. data/data/builtins/ruby_core/rational.yml +39 -39
  11. data/data/builtins/ruby_core/re.yml +65 -65
  12. data/data/builtins/ruby_core/set.yml +106 -106
  13. data/data/builtins/ruby_core/struct.yml +14 -14
  14. data/data/core_overlay/string_scanner.rbs +6 -5
  15. data/docs/handbook/01-getting-started.md +22 -34
  16. data/docs/handbook/06-classes.md +1 -1
  17. data/docs/handbook/07-rbs-and-extended.md +76 -101
  18. data/docs/handbook/08-understanding-errors.md +114 -247
  19. data/docs/handbook/09-plugins.md +54 -144
  20. data/docs/handbook/README.md +5 -3
  21. data/docs/handbook/appendix-liskov.md +4 -2
  22. data/docs/handbook/appendix-phpstan.md +14 -7
  23. data/docs/handbook/appendix-steep.md +4 -2
  24. data/docs/handbook/appendix-type-theory.md +3 -1
  25. data/docs/manual/02-cli-reference.md +32 -0
  26. data/docs/manual/04-diagnostics.md +36 -4
  27. data/docs/manual/06-baseline.md +35 -1
  28. data/docs/manual/08-skills.md +6 -1
  29. data/docs/manual/09-editor-integration.md +3 -2
  30. data/docs/manual/plugins/rigor-actioncable.md +32 -0
  31. data/docs/manual/plugins/rigor-devise.md +4 -2
  32. data/lib/rigor/analysis/check_rules/void_value_use_collector.rb +21 -2
  33. data/lib/rigor/analysis/check_rules.rb +34 -13
  34. data/lib/rigor/analysis/run_cache_key.rb +10 -0
  35. data/lib/rigor/analysis/runner.rb +2 -1
  36. data/lib/rigor/cache/rbs_cache_producer.rb +11 -1
  37. data/lib/rigor/cache/rbs_environment_marshal_patch.rb +38 -0
  38. data/lib/rigor/cache/store.rb +99 -24
  39. data/lib/rigor/cli/check_command.rb +12 -6
  40. data/lib/rigor/cli/check_invocation.rb +84 -0
  41. data/lib/rigor/cli/doctor_command.rb +6 -8
  42. data/lib/rigor/cli/skill_command.rb +21 -1
  43. data/lib/rigor/cli/skill_deep_probe.rb +172 -0
  44. data/lib/rigor/cli/skill_describe.rb +75 -9
  45. data/lib/rigor/environment/default_libraries.rb +5 -4
  46. data/lib/rigor/environment.rb +10 -1
  47. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +6 -1
  48. data/lib/rigor/language_server/buffer_resolution.rb +6 -3
  49. data/lib/rigor/language_server/buffer_table.rb +46 -6
  50. data/lib/rigor/language_server/diagnostic_publisher.rb +4 -0
  51. data/lib/rigor/language_server/incremental_sync.rb +159 -0
  52. data/lib/rigor/language_server/server.rb +19 -9
  53. data/lib/rigor/language_server.rb +1 -0
  54. data/lib/rigor/plugin/base.rb +29 -2
  55. data/lib/rigor/sig_gen/writer.rb +183 -47
  56. data/lib/rigor/version.rb +1 -1
  57. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +51 -1
  58. data/sig/rigor/cache.rbs +6 -0
  59. data/sig/rigor/inference/void_origin.rbs +18 -0
  60. data/sig/rigor/plugin/base.rbs +4 -3
  61. 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
- def initialize(skills:, root: Dir.pwd)
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
- recommendation = recommend(state, catalog)
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
- def recommend(state, catalog)
177
- name, reason = recommended_name_and_reason(state)
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
- Re-run `rigor skill describe` whenever you need the next step — it always
292
- reflects the project's current state.
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 (`pathname`, `optparse`, `json`, `yaml`, `fileutils`,
10
- # `tempfile`, `uri`, `logger`, `date`) plus the analyzer-adjacent gems shipping their own RBS in this
11
- # bundle (`prism`, `rbs`). On hosts where one of these libraries is not installed, the loader silently
12
- # drops it.
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
@@ -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 or no open
17
- # buffer. A caller that destructures `path, entry = buffer_for(uri)` can guard on `entry.nil?` to cover
18
- # both misses (a nil return leaves both locals nil).
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). v1 ships FULL text sync (LSP
9
- # `TextDocumentSyncKind::Full = 1`) so each `didChange` carries the entire buffer text — there's no
10
- # incremental edit application yet. Incremental sync is slice 10 (deferred per the design doc).
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` event under FULL sync. The full new buffer text replaces the entry.
28
- # If the client sends a `didChange` for a URI that was never opened (spec violation), the entry is still
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::Full = 1`. Slice 10 (deferred) promotes to `Incremental = 2`.
25
- TEXT_DOCUMENT_SYNC_FULL = 1
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: TEXT_DOCUMENT_SYNC_FULL
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 FULL sync. Each `contentChanges` entry carries only `{ text: }`; the LAST
217
- # entry is the new full document text. Per LSP spec § "FULL sync" the array MUST be exactly one entry in
218
- # practice we still take `.last` defensively for clients that pad. Triggers `publishDiagnostics`
219
- # afterwards.
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.change(
236
+ @buffer_table.apply_changes(
227
237
  uri: uri,
228
- bytes: changes.last.fetch(:text),
238
+ changes: changes,
229
239
  version: doc.fetch(:version)
230
240
  )
231
241
  @publisher&.publish_for(uri)
@@ -9,6 +9,7 @@ module Rigor
9
9
  end
10
10
  end
11
11
 
12
+ require_relative "language_server/incremental_sync"
12
13
  require_relative "language_server/buffer_table"
13
14
  require_relative "language_server/uri"
14
15
  require_relative "language_server/project_context"
@@ -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
- def producer(id, watch: nil, serialize: nil, deserialize: nil, &block)
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])