rigortype 0.2.9 → 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 (222) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  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/03-narrowing.md +2 -1
  17. data/docs/handbook/04-tuples-and-shapes.md +7 -3
  18. data/docs/handbook/06-classes.md +19 -10
  19. data/docs/handbook/07-rbs-and-extended.md +76 -101
  20. data/docs/handbook/08-understanding-errors.md +114 -246
  21. data/docs/handbook/09-plugins.md +54 -144
  22. data/docs/handbook/11-sig-gen.md +11 -1
  23. data/docs/handbook/README.md +5 -3
  24. data/docs/handbook/appendix-liskov.md +4 -2
  25. data/docs/handbook/appendix-mypy.md +4 -3
  26. data/docs/handbook/appendix-phpstan.md +14 -7
  27. data/docs/handbook/appendix-steep.md +4 -2
  28. data/docs/handbook/appendix-type-theory.md +3 -1
  29. data/docs/install.md +14 -6
  30. data/docs/manual/01-installation.md +98 -4
  31. data/docs/manual/02-cli-reference.md +81 -12
  32. data/docs/manual/03-configuration.md +34 -2
  33. data/docs/manual/04-diagnostics.md +59 -4
  34. data/docs/manual/06-baseline.md +35 -1
  35. data/docs/manual/07-plugins.md +4 -4
  36. data/docs/manual/08-skills.md +6 -1
  37. data/docs/manual/09-editor-integration.md +7 -6
  38. data/docs/manual/11-ci.md +51 -0
  39. data/docs/manual/12-caching.md +65 -0
  40. data/docs/manual/plugins/rigor-actioncable.md +32 -0
  41. data/docs/manual/plugins/rigor-devise.md +4 -2
  42. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +9 -1
  43. data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +5 -3
  44. data/lib/rigor/analysis/check_rules/duplicate_hash_key_collector.rb +128 -0
  45. data/lib/rigor/analysis/check_rules/inferred_param_guard.rb +60 -0
  46. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +3 -2
  47. data/lib/rigor/analysis/check_rules/return_in_ensure_collector.rb +117 -0
  48. data/lib/rigor/analysis/check_rules/rule_ids.rb +125 -0
  49. data/lib/rigor/analysis/check_rules/rule_walk.rb +2 -1
  50. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +2 -1
  51. data/lib/rigor/analysis/check_rules/shadowed_rescue_collector.rb +283 -0
  52. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +10 -1
  53. data/lib/rigor/analysis/check_rules/void_value_use_collector.rb +112 -0
  54. data/lib/rigor/analysis/check_rules.rb +581 -98
  55. data/lib/rigor/analysis/dependency_recorder.rb +93 -9
  56. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
  57. data/lib/rigor/analysis/incremental_session.rb +456 -51
  58. data/lib/rigor/analysis/path_expansion.rb +42 -0
  59. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +188 -0
  60. data/lib/rigor/analysis/rule_catalog.rb +226 -1
  61. data/lib/rigor/analysis/run_cache_key.rb +78 -0
  62. data/lib/rigor/analysis/run_cache_probe.rb +72 -0
  63. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +181 -28
  64. data/lib/rigor/analysis/runner/pool_coordinator.rb +77 -15
  65. data/lib/rigor/analysis/runner/project_pre_passes.rb +95 -49
  66. data/lib/rigor/analysis/runner/run_snapshots.rb +9 -2
  67. data/lib/rigor/analysis/runner.rb +344 -68
  68. data/lib/rigor/analysis/severity_stamp.rb +43 -0
  69. data/lib/rigor/analysis/worker_session.rb +30 -2
  70. data/lib/rigor/bleeding_edge.rb +37 -8
  71. data/lib/rigor/builtins/regex_refinement.rb +36 -1
  72. data/lib/rigor/cache/descriptor.rb +88 -28
  73. data/lib/rigor/cache/file_digest.rb +158 -0
  74. data/lib/rigor/cache/incremental_snapshot.rb +41 -5
  75. data/lib/rigor/cache/rbs_cache_producer.rb +11 -1
  76. data/lib/rigor/cache/rbs_descriptor.rb +56 -10
  77. data/lib/rigor/cache/rbs_environment_marshal_patch.rb +38 -0
  78. data/lib/rigor/cache/store.rb +132 -27
  79. data/lib/rigor/ci_detector.rb +90 -0
  80. data/lib/rigor/cli/annotate_command.rb +15 -9
  81. data/lib/rigor/cli/baseline_command.rb +4 -1
  82. data/lib/rigor/cli/check_command.rb +223 -32
  83. data/lib/rigor/cli/check_invocation.rb +84 -0
  84. data/lib/rigor/cli/coverage_command.rb +6 -1
  85. data/lib/rigor/cli/docs_command.rb +0 -30
  86. data/lib/rigor/cli/doctor_command.rb +169 -31
  87. data/lib/rigor/cli/lsp_command.rb +5 -0
  88. data/lib/rigor/cli/mcp_command.rb +5 -0
  89. data/lib/rigor/cli/plugins_command.rb +36 -6
  90. data/lib/rigor/cli/plugins_renderer.rb +44 -12
  91. data/lib/rigor/cli/probe_environment.rb +85 -0
  92. data/lib/rigor/cli/sig_gen_command.rb +36 -9
  93. data/lib/rigor/cli/skill_command.rb +22 -33
  94. data/lib/rigor/cli/skill_deep_probe.rb +172 -0
  95. data/lib/rigor/cli/skill_describe.rb +75 -9
  96. data/lib/rigor/cli/trace_command.rb +5 -6
  97. data/lib/rigor/cli/type_of_command.rb +9 -8
  98. data/lib/rigor/cli/type_scan_command.rb +8 -8
  99. data/lib/rigor/cli.rb +15 -4
  100. data/lib/rigor/config_audit.rb +48 -2
  101. data/lib/rigor/configuration/severity_profile.rb +31 -0
  102. data/lib/rigor/configuration.rb +156 -8
  103. data/lib/rigor/environment/default_libraries.rb +36 -0
  104. data/lib/rigor/environment/rbs_loader.rb +164 -5
  105. data/lib/rigor/environment.rb +29 -28
  106. data/lib/rigor/inference/budget_trace.rb +77 -2
  107. data/lib/rigor/inference/closure_escape_analyzer.rb +14 -1
  108. data/lib/rigor/inference/def_handle.rb +23 -0
  109. data/lib/rigor/inference/def_node_resolver.rb +90 -0
  110. data/lib/rigor/inference/def_return_typer.rb +2 -1
  111. data/lib/rigor/inference/expression_typer.rb +361 -113
  112. data/lib/rigor/inference/method_dispatcher/data_folding.rb +30 -5
  113. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +200 -0
  114. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +22 -16
  115. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +33 -0
  116. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +17 -1
  117. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +42 -9
  118. data/lib/rigor/inference/method_dispatcher/receiver_affinity.rb +5 -0
  119. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +28 -35
  120. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +40 -8
  121. data/lib/rigor/inference/method_dispatcher.rb +57 -1
  122. data/lib/rigor/inference/mutation_widening.rb +42 -8
  123. data/lib/rigor/inference/narrowing.rb +127 -17
  124. data/lib/rigor/inference/parameter_inference_collector.rb +15 -0
  125. data/lib/rigor/inference/project_patched_scanner.rb +2 -1
  126. data/lib/rigor/inference/rbs_type_translator.rb +8 -1
  127. data/lib/rigor/inference/scope_indexer.rb +451 -36
  128. data/lib/rigor/inference/statement_evaluator.rb +64 -15
  129. data/lib/rigor/inference/struct_fold_safety.rb +48 -11
  130. data/lib/rigor/inference/synthetic_method_scanner.rb +15 -14
  131. data/lib/rigor/inference/void_origin.rb +25 -0
  132. data/lib/rigor/inference/void_tail_summary.rb +220 -0
  133. data/lib/rigor/language_server/buffer_resolution.rb +6 -3
  134. data/lib/rigor/language_server/buffer_table.rb +46 -6
  135. data/lib/rigor/language_server/completion_provider.rb +2 -1
  136. data/lib/rigor/language_server/diagnostic_publisher.rb +4 -0
  137. data/lib/rigor/language_server/document_symbol_provider.rb +2 -1
  138. data/lib/rigor/language_server/folding_range_provider.rb +2 -1
  139. data/lib/rigor/language_server/incremental_sync.rb +159 -0
  140. data/lib/rigor/language_server/selection_range_provider.rb +2 -1
  141. data/lib/rigor/language_server/server.rb +19 -9
  142. data/lib/rigor/language_server/signature_help_provider.rb +2 -1
  143. data/lib/rigor/language_server.rb +1 -0
  144. data/lib/rigor/plugin/base.rb +38 -24
  145. data/lib/rigor/plugin/inflector.rb +12 -3
  146. data/lib/rigor/plugin/io_boundary.rb +5 -1
  147. data/lib/rigor/plugin/isolation.rb +81 -11
  148. data/lib/rigor/plugin/load_error.rb +10 -1
  149. data/lib/rigor/plugin/loader.rb +96 -14
  150. data/lib/rigor/plugin/node_rule_walk.rb +5 -3
  151. data/lib/rigor/plugin/registry.rb +32 -23
  152. data/lib/rigor/plugin.rb +26 -0
  153. data/lib/rigor/protection/mutator.rb +3 -2
  154. data/lib/rigor/reflection.rb +64 -0
  155. data/lib/rigor/runtime/jit.rb +128 -0
  156. data/lib/rigor/scope/discovery_index.rb +12 -2
  157. data/lib/rigor/scope.rb +122 -28
  158. data/lib/rigor/sig_gen/classification.rb +5 -1
  159. data/lib/rigor/sig_gen/generator.rb +38 -6
  160. data/lib/rigor/sig_gen/observation_collector.rb +4 -3
  161. data/lib/rigor/sig_gen/rbs_validity.rb +44 -0
  162. data/lib/rigor/sig_gen/renderer.rb +10 -0
  163. data/lib/rigor/sig_gen/write_result.rb +9 -4
  164. data/lib/rigor/sig_gen/writer.rb +207 -49
  165. data/lib/rigor/source/node_children.rb +116 -0
  166. data/lib/rigor/source/node_locator.rb +3 -1
  167. data/lib/rigor/source/node_walker.rb +4 -2
  168. data/lib/rigor/source.rb +1 -0
  169. data/lib/rigor/type/difference.rb +28 -24
  170. data/lib/rigor/type/hash_shape.rb +34 -10
  171. data/lib/rigor/version.rb +1 -1
  172. data/lib/rigortype.rb +24 -0
  173. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +4 -2
  174. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +51 -1
  175. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +6 -4
  176. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +5 -3
  177. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +4 -2
  178. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +3 -1
  179. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +4 -2
  180. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +4 -2
  181. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +3 -1
  182. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +4 -2
  183. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +38 -6
  184. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +3 -1
  185. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +3 -1
  186. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +10 -0
  187. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +11 -0
  188. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +3 -1
  189. data/plugins/rigor-rails/lib/rigor-rails.rb +11 -12
  190. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/grape_api_discoverer.rb +3 -1
  191. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_discoverer.rb +3 -1
  192. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +7 -5
  193. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +83 -9
  194. data/plugins/rigor-rbs-inline/lib/rigor-rbs-inline.rb +9 -5
  195. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +3 -1
  196. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +3 -1
  197. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +11 -0
  198. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +4 -1
  199. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +4 -2
  200. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +3 -1
  201. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +74 -14
  202. data/sig/prism_node_children.rbs +9 -0
  203. data/sig/rigor/cache.rbs +6 -0
  204. data/sig/rigor/inference/void_origin.rbs +18 -0
  205. data/sig/rigor/inference.rbs +7 -0
  206. data/sig/rigor/plugin/base.rbs +6 -6
  207. data/sig/rigor/reflection.rbs +1 -0
  208. data/sig/rigor/scope.rbs +12 -1
  209. data/skills/rigor-ci-setup/SKILL.md +10 -0
  210. data/skills/rigor-editor-setup/SKILL.md +11 -0
  211. data/skills/rigor-next-steps/SKILL.md +23 -2
  212. data/skills/rigor-plugin-author/SKILL.md +3 -3
  213. data/skills/rigor-plugin-author/references/02-walker-and-types.md +2 -2
  214. data/skills/rigor-plugin-review/SKILL.md +2 -2
  215. data/skills/rigor-plugin-review/references/01-best-practices-checklist.md +5 -4
  216. data/skills/rigor-project-init/SKILL.md +29 -3
  217. data/skills/rigor-project-init/references/01-detect.md +1 -1
  218. data/skills/rigor-project-init/references/02-configure.md +39 -5
  219. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +20 -0
  220. data/skills/rigor-project-init/references/05-jit-performance.md +125 -0
  221. metadata +48 -26
  222. data/lib/rigor/cli/ci_detector.rb +0 -89
@@ -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
 
@@ -8,6 +8,7 @@ require_relative "../environment"
8
8
  require_relative "../reflection"
9
9
  require_relative "../scope"
10
10
  require_relative "../source/node_locator"
11
+ require_relative "../source/node_children"
11
12
  require_relative "../inference/scope_indexer"
12
13
  require_relative "../type/nominal"
13
14
  require_relative "../type/singleton"
@@ -187,7 +188,7 @@ module Rigor
187
188
  n.location.start_offset <= symbol_offset && symbol_offset <= n.location.end_offset
188
189
  result = n
189
190
  end
190
- n.compact_child_nodes.each(&walk)
191
+ n.rigor_each_child(&walk)
191
192
  end
192
193
  walk.call(root)
193
194
  result
@@ -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)
@@ -4,6 +4,7 @@ require "prism"
4
4
 
5
5
  require_relative "uri"
6
6
  require_relative "buffer_resolution"
7
+ require_relative "../source/node_children"
7
8
 
8
9
  module Rigor
9
10
  module LanguageServer
@@ -67,7 +68,7 @@ module Rigor
67
68
  when Prism::DefNode
68
69
  block.call(def_symbol(node, in_namespace: in_namespace))
69
70
  else
70
- node.compact_child_nodes.each do |child|
71
+ node.rigor_each_child do |child|
71
72
  each_decl(child, in_namespace: in_namespace, &block)
72
73
  end
73
74
  end
@@ -4,6 +4,7 @@ require "prism"
4
4
 
5
5
  require_relative "uri"
6
6
  require_relative "buffer_resolution"
7
+ require_relative "../source/node_children"
7
8
 
8
9
  module Rigor
9
10
  module LanguageServer
@@ -48,7 +49,7 @@ module Rigor
48
49
  Prism::BlockNode
49
50
  add_range(node, ranges)
50
51
  end
51
- node.compact_child_nodes.each { |child| walk(child, ranges) }
52
+ node.rigor_each_child { |child| walk(child, ranges) }
52
53
  end
53
54
 
54
55
  def add_range(node, ranges)
@@ -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
@@ -4,6 +4,7 @@ require "prism"
4
4
 
5
5
  require_relative "uri"
6
6
  require_relative "buffer_resolution"
7
+ require_relative "../source/node_children"
7
8
 
8
9
  module Rigor
9
10
  module LanguageServer
@@ -48,7 +49,7 @@ module Rigor
48
49
  return chain unless node.location && offset_in?(node.location, offset)
49
50
 
50
51
  chain << node
51
- node.compact_child_nodes.each { |child| ancestor_chain(child, offset, chain) }
52
+ node.rigor_each_child { |child| ancestor_chain(child, offset, chain) }
52
53
  chain
53
54
  end
54
55
 
@@ -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)
@@ -8,6 +8,7 @@ require_relative "../environment"
8
8
  require_relative "../reflection"
9
9
  require_relative "../scope"
10
10
  require_relative "../source/node_locator"
11
+ require_relative "../source/node_children"
11
12
  require_relative "../inference/scope_indexer"
12
13
  require_relative "../type/nominal"
13
14
  require_relative "../type/singleton"
@@ -99,7 +100,7 @@ module Rigor
99
100
  if n.is_a?(Prism::CallNode) && n.arguments && offset_in?(n.arguments.location, cursor_offset)
100
101
  result = n # Innermost-wins because we keep walking children.
101
102
  end
102
- n.compact_child_nodes.each(&walk)
103
+ n.rigor_each_child(&walk)
103
104
  end
104
105
  walk.call(root)
105
106
  result
@@ -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)
@@ -390,8 +416,8 @@ module Rigor
390
416
  # the fact slot.
391
417
  #
392
418
  # Renamed from `type_specifier` (ADR-80): the old name read as a parallel to `dynamic_return` (a
393
- # type) when it actually returns facts. {.type_specifier} survives as a deprecating alias through
394
- # 0.2.x and is removed in 0.3.0.
419
+ # type) when it actually returns facts. The old verb was a deprecating alias through 0.2.x and is
420
+ # gone in 0.3.0 `narrowing_facts` is the only spelling.
395
421
  def narrowing_facts(methods:, &block)
396
422
  raise ArgumentError, "Plugin::Base.narrowing_facts requires a block body" if block.nil?
397
423
  unless methods.is_a?(Array) && !methods.empty? &&
@@ -401,30 +427,17 @@ module Rigor
401
427
  "got #{methods.inspect}"
402
428
  end
403
429
 
404
- @type_specifiers ||= []
405
- @type_specifiers << { methods: methods.map(&:to_sym).freeze, block: block }.freeze
430
+ @narrowing_facts_rules ||= []
431
+ @narrowing_facts_rules << { methods: methods.map(&:to_sym).freeze, block: block }.freeze
406
432
  nil
407
433
  end
408
434
 
409
- # DEPRECATED (ADR-80) renamed to {.narrowing_facts}. This hook supplies post-return narrowing
410
- # *facts*, not a type; the old name misleads by parallel with {.dynamic_return}. Retained as a
411
- # warning-emitting alias through 0.2.x; REMOVED in 0.3.0. Migrate `type_specifier methods: …` →
412
- # `narrowing_facts methods: …`.
413
- def type_specifier(methods:, &)
414
- unless @type_specifier_deprecation_warned
415
- @type_specifier_deprecation_warned = true
416
- warn("[rigor] Plugin::Base.type_specifier is deprecated (ADR-80) and will be " \
417
- "removed in Rigor 0.3.0; rename it to `narrowing_facts`. (#{name})")
418
- end
419
- narrowing_facts(methods:, &)
420
- end
421
-
422
- # Frozen snapshot of the declared type-specifier rules. Memoised for the same reason as
435
+ # Frozen snapshot of the declared narrowing-facts rules. Memoised for the same reason as
423
436
  # {dynamic_returns} — consulted per plugin per dispatch, over an array fixed at class-definition
424
437
  # time.
425
438
  # rubocop:disable Naming/MemoizedInstanceVariableName -- see dynamic_returns
426
- def type_specifiers
427
- @type_specifiers_snapshot ||= (@type_specifiers || []).dup.freeze
439
+ def narrowing_facts_rules
440
+ @narrowing_facts_rules_snapshot ||= (@narrowing_facts_rules || []).dup.freeze
428
441
  end
429
442
  # rubocop:enable Naming/MemoizedInstanceVariableName
430
443
  end
@@ -558,8 +571,8 @@ module Rigor
558
571
  # ADR-37 slice 2 — the post-return narrowing facts contributed by this plugin's {.narrowing_facts}
559
572
  # rules for a call. The engine calls this from `StatementEvaluator`; a rule fires only when
560
573
  # `call_node.name` is one of its declared `methods:`. Failures isolate to [].
561
- def type_specifier_facts(call_node:, scope:)
562
- rules = self.class.type_specifiers
574
+ def narrowing_facts_for(call_node:, scope:)
575
+ rules = self.class.narrowing_facts_rules
563
576
  return [] if rules.empty? || !call_node.respond_to?(:name)
564
577
 
565
578
  name = call_node.name
@@ -741,6 +754,7 @@ module Rigor
741
754
  store.fetch_or_validate(
742
755
  producer_id: prefixed_id,
743
756
  key_descriptor: key_descriptor,
757
+ generation_cap: producer[:generation_cap],
744
758
  params: params,
745
759
  serialize: pair_serializer(producer[:serialize]),
746
760
  deserialize: pair_deserializer(producer[:deserialize])
@@ -18,9 +18,12 @@ module Rigor
18
18
  # rules are ingested by *statically parsing* `config/initializers/inflections.rb` in a later slice, never
19
19
  # by executing it.)
20
20
  #
21
- # **Absence is silence, never a guess.** When `ActiveSupport::Inflector` cannot be loaded (a
22
- # misconfiguration the consuming plugins declare it as a dependency, so it is present in practice), the
23
- # inflection methods raise {Unavailable} rather than approximate. That raise propagates to the caller's
21
+ # **Absence is silence, never a guess.** ActiveSupport is resolved from Rigor's host gem environment
22
+ # first, then from the analyzed project's own bundler install tree ({Isolation.target_bundle_root},
23
+ # ADR-90) a Rails project always carries its locked activesupport on disk, and that copy is the
24
+ # higher-fidelity source of inflection rules anyway. When neither resolves (a standalone install
25
+ # analyzing a project whose bundle is not installed), the inflection methods raise {Unavailable}
26
+ # rather than approximate. That raise propagates to the caller's
24
27
  # per-plugin rescue boundary, so the inflection-dependent check degrades to **no diagnostics** — reduced
25
28
  # coverage, never a wrong fact. A consumer that wants to fail cleanly up front can gate on {available?}
26
29
  # and emit a single load-error.
@@ -39,6 +42,12 @@ module Rigor
39
42
  FEATURE = "active_support/inflector"
40
43
  RECEIVER = "ActiveSupport::Inflector"
41
44
 
45
+ # The bundled plugins whose checks consume this helper (each README documents the dependency). Used
46
+ # by `rigor plugins` (ADR-90) to probe {available?} at activation time — so a standalone install
47
+ # where inflection silently degrades reports the degradation instead of an unqualified `[OK]`. When
48
+ # a new plugin adopts the helper, add its manifest id here.
49
+ CONSUMER_PLUGIN_IDS = %w[actionmailer actionpack activerecord factorybot rails-routes].freeze
50
+
42
51
  # Raised when `ActiveSupport::Inflector` is required for an inflection but cannot be loaded. Caught by
43
52
  # the per-plugin isolation boundary, so it surfaces as "this plugin produced no diagnostics" rather than
44
53
  # a wrong inflection.
@@ -97,8 +97,12 @@ module Rigor
97
97
  private
98
98
 
99
99
  def record_file_entry(path, contents)
100
+ # ADR-87 WD1 — the boundary descriptor is validation-only (it never keys a cache), so a plugin-read
101
+ # file rides the stat-then-digest `:stat` tier: a warm run stat-checks the (on a Rails monorepo,
102
+ # 30k+ file) plugin-read set instead of re-hashing it. The digest is taken from the in-memory content
103
+ # we just read; `FileEntry.stat` stats the same path to pack the tuple.
100
104
  digest = Digest::SHA256.hexdigest(contents)
101
- entry = Cache::Descriptor::FileEntry.new(path: path, comparator: :digest, value: digest)
105
+ entry = Cache::Descriptor::FileEntry.stat(path: path, digest: digest)
102
106
  @mutex.synchronize { @file_entries[path] = entry }
103
107
  end
104
108