rigortype 0.1.4 → 0.1.6

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 (107) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +69 -56
  3. data/lib/rigor/analysis/buffer_binding.rb +36 -0
  4. data/lib/rigor/analysis/check_rules.rb +11 -1
  5. data/lib/rigor/analysis/dependency_source_inference/index.rb +14 -1
  6. data/lib/rigor/analysis/dependency_source_inference/return_type_heuristic.rb +105 -0
  7. data/lib/rigor/analysis/dependency_source_inference/walker.rb +32 -12
  8. data/lib/rigor/analysis/fact_store.rb +15 -3
  9. data/lib/rigor/analysis/project_scan.rb +39 -0
  10. data/lib/rigor/analysis/result.rb +11 -3
  11. data/lib/rigor/analysis/run_stats.rb +193 -0
  12. data/lib/rigor/analysis/runner.rb +681 -19
  13. data/lib/rigor/analysis/worker_session.rb +339 -0
  14. data/lib/rigor/builtins/hkt_builtins.rb +342 -0
  15. data/lib/rigor/builtins/imported_refinements.rb +6 -2
  16. data/lib/rigor/builtins/regex_refinement.rb +17 -12
  17. data/lib/rigor/builtins/static_return_refinements.rb +120 -0
  18. data/lib/rigor/cache/rbs_descriptor.rb +3 -1
  19. data/lib/rigor/cache/store.rb +72 -9
  20. data/lib/rigor/cli/lsp_command.rb +129 -0
  21. data/lib/rigor/cli/type_of_command.rb +44 -5
  22. data/lib/rigor/cli.rb +122 -10
  23. data/lib/rigor/configuration.rb +168 -7
  24. data/lib/rigor/environment/bundle_sig_discovery.rb +198 -0
  25. data/lib/rigor/environment/class_registry.rb +12 -3
  26. data/lib/rigor/environment/hkt_registry_holder.rb +33 -0
  27. data/lib/rigor/environment/lockfile_resolver.rb +125 -0
  28. data/lib/rigor/environment/rbs_collection_discovery.rb +126 -0
  29. data/lib/rigor/environment/rbs_coverage_report.rb +112 -0
  30. data/lib/rigor/environment/rbs_loader.rb +238 -7
  31. data/lib/rigor/environment/reflection.rb +152 -0
  32. data/lib/rigor/environment/reporters.rb +40 -0
  33. data/lib/rigor/environment.rb +179 -10
  34. data/lib/rigor/inference/acceptance.rb +83 -4
  35. data/lib/rigor/inference/builtins/method_catalog.rb +12 -5
  36. data/lib/rigor/inference/builtins/numeric_catalog.rb +15 -4
  37. data/lib/rigor/inference/expression_typer.rb +59 -2
  38. data/lib/rigor/inference/hkt_body.rb +171 -0
  39. data/lib/rigor/inference/hkt_body_parser.rb +363 -0
  40. data/lib/rigor/inference/hkt_reducer.rb +256 -0
  41. data/lib/rigor/inference/hkt_registry.rb +223 -0
  42. data/lib/rigor/inference/macro_block_self_type.rb +96 -0
  43. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +29 -29
  44. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +4 -4
  45. data/lib/rigor/inference/method_dispatcher/method_folding.rb +18 -1
  46. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +126 -31
  47. data/lib/rigor/inference/method_dispatcher/receiver_affinity.rb +87 -0
  48. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +46 -40
  49. data/lib/rigor/inference/method_dispatcher.rb +282 -6
  50. data/lib/rigor/inference/method_parameter_binder.rb +21 -11
  51. data/lib/rigor/inference/narrowing.rb +127 -8
  52. data/lib/rigor/inference/project_patched_methods.rb +70 -0
  53. data/lib/rigor/inference/project_patched_scanner.rb +210 -0
  54. data/lib/rigor/inference/scope_indexer.rb +156 -12
  55. data/lib/rigor/inference/statement_evaluator.rb +106 -6
  56. data/lib/rigor/inference/synthetic_method.rb +86 -0
  57. data/lib/rigor/inference/synthetic_method_index.rb +82 -0
  58. data/lib/rigor/inference/synthetic_method_scanner.rb +599 -0
  59. data/lib/rigor/language_server/buffer_table.rb +63 -0
  60. data/lib/rigor/language_server/completion_provider.rb +438 -0
  61. data/lib/rigor/language_server/debouncer.rb +86 -0
  62. data/lib/rigor/language_server/diagnostic_publisher.rb +167 -0
  63. data/lib/rigor/language_server/document_symbol_provider.rb +142 -0
  64. data/lib/rigor/language_server/folding_range_provider.rb +75 -0
  65. data/lib/rigor/language_server/hover_provider.rb +74 -0
  66. data/lib/rigor/language_server/hover_renderer.rb +312 -0
  67. data/lib/rigor/language_server/loop.rb +71 -0
  68. data/lib/rigor/language_server/project_context.rb +145 -0
  69. data/lib/rigor/language_server/selection_range_provider.rb +93 -0
  70. data/lib/rigor/language_server/server.rb +384 -0
  71. data/lib/rigor/language_server/signature_help_provider.rb +249 -0
  72. data/lib/rigor/language_server/synchronized_writer.rb +28 -0
  73. data/lib/rigor/language_server/uri.rb +40 -0
  74. data/lib/rigor/language_server.rb +29 -0
  75. data/lib/rigor/plugin/base.rb +63 -0
  76. data/lib/rigor/plugin/blueprint.rb +60 -0
  77. data/lib/rigor/plugin/loader.rb +3 -1
  78. data/lib/rigor/plugin/macro/block_as_method.rb +131 -0
  79. data/lib/rigor/plugin/macro/external_file.rb +143 -0
  80. data/lib/rigor/plugin/macro/heredoc_template.rb +315 -0
  81. data/lib/rigor/plugin/macro/trait_registry.rb +198 -0
  82. data/lib/rigor/plugin/macro.rb +31 -0
  83. data/lib/rigor/plugin/manifest.rb +127 -9
  84. data/lib/rigor/plugin/registry.rb +51 -2
  85. data/lib/rigor/plugin.rb +1 -0
  86. data/lib/rigor/rbs_extended/hkt_directives.rb +326 -0
  87. data/lib/rigor/rbs_extended.rb +82 -2
  88. data/lib/rigor/sig_gen/generator.rb +12 -3
  89. data/lib/rigor/trinary.rb +15 -11
  90. data/lib/rigor/type/app.rb +107 -0
  91. data/lib/rigor/type/bot.rb +6 -3
  92. data/lib/rigor/type/combinator.rb +12 -1
  93. data/lib/rigor/type/integer_range.rb +7 -7
  94. data/lib/rigor/type/refined.rb +18 -12
  95. data/lib/rigor/type/top.rb +4 -3
  96. data/lib/rigor/type.rb +1 -0
  97. data/lib/rigor/type_node/generic.rb +7 -1
  98. data/lib/rigor/type_node/identifier.rb +9 -1
  99. data/lib/rigor/type_node/string_literal.rb +4 -1
  100. data/lib/rigor/version.rb +1 -1
  101. data/sig/rigor/environment.rbs +11 -4
  102. data/sig/rigor/inference.rbs +2 -0
  103. data/sig/rigor/plugin/blueprint.rbs +7 -0
  104. data/sig/rigor/plugin/manifest.rbs +1 -1
  105. data/sig/rigor/plugin/registry.rbs +14 -1
  106. data/sig/rigor.rbs +37 -2
  107. metadata +92 -1
@@ -9,7 +9,7 @@ module Rigor
9
9
  # (`decimal-int-string`, `hex-int-string`, `octal-int-string`,
10
10
  # `lowercase-string`, `uppercase-string`, `numeric-string`).
11
11
  # See `docs/type-specification/imported-built-in-types.md` for
12
- # the registry the refinements come from and `docs/MILESTONES.md`
12
+ # the registry the refinements come from and `docs/ROADMAP.md`
13
13
  # § "v0.1.1 — Planned" Track 1 slice 1 for the binding scope of
14
14
  # this recogniser.
15
15
  #
@@ -47,17 +47,22 @@ module Rigor
47
47
  QUANTIFIER_SOURCE = '(?:\+|\{\d+(?:,\d+)?\})'
48
48
  private_constant :QUANTIFIER_SOURCE
49
49
 
50
- RULES = [
51
- [/\A\\d#{QUANTIFIER_SOURCE}\z/, :decimal_int_string],
52
- [/\A\\h#{QUANTIFIER_SOURCE}\z/, :hex_int_string],
53
- [/\A\[0-9a-fA-F\]#{QUANTIFIER_SOURCE}\z/, :hex_int_string],
54
- [/\A\[0-9a-f\]#{QUANTIFIER_SOURCE}\z/, :hex_int_string],
55
- [/\A\[0-9A-F\]#{QUANTIFIER_SOURCE}\z/, :hex_int_string],
56
- [/\A\[0-7\]#{QUANTIFIER_SOURCE}\z/, :octal_int_string],
57
- [/\A\[a-z\]#{QUANTIFIER_SOURCE}\z/, :lowercase_string],
58
- [/\A\[A-Z\]#{QUANTIFIER_SOURCE}\z/, :uppercase_string],
59
- [/\A\[\[:digit:\]\]#{QUANTIFIER_SOURCE}\z/, :numeric_string]
60
- ].freeze
50
+ # ADR-15 Phase 4b.x — `Ractor.make_shareable` (not `.freeze`)
51
+ # because the outer Array contains two-element `[Regexp, Symbol]`
52
+ # rows whose inner Arrays are not frozen by the outer freeze.
53
+ # A worker Ractor iterating `RULES.find { ... }` would trip
54
+ # `Ractor::IsolationError` on the first row access.
55
+ RULES = Ractor.make_shareable([
56
+ [/\A\\d#{QUANTIFIER_SOURCE}\z/, :decimal_int_string],
57
+ [/\A\\h#{QUANTIFIER_SOURCE}\z/, :hex_int_string],
58
+ [/\A\[0-9a-fA-F\]#{QUANTIFIER_SOURCE}\z/, :hex_int_string],
59
+ [/\A\[0-9a-f\]#{QUANTIFIER_SOURCE}\z/, :hex_int_string],
60
+ [/\A\[0-9A-F\]#{QUANTIFIER_SOURCE}\z/, :hex_int_string],
61
+ [/\A\[0-7\]#{QUANTIFIER_SOURCE}\z/, :octal_int_string],
62
+ [/\A\[a-z\]#{QUANTIFIER_SOURCE}\z/, :lowercase_string],
63
+ [/\A\[A-Z\]#{QUANTIFIER_SOURCE}\z/, :uppercase_string],
64
+ [/\A\[\[:digit:\]\]#{QUANTIFIER_SOURCE}\z/, :numeric_string]
65
+ ])
61
66
  private_constant :RULES
62
67
 
63
68
  BOUND_RE = /\{(\d+)(?:,(\d+))?\}\z/
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../type"
4
+
5
+ module Rigor
6
+ module Builtins
7
+ # Static return-type refinements for stdlib (or other built-in)
8
+ # methods whose upstream RBS signature is broader than the
9
+ # method's documented behaviour and where adding a
10
+ # `%a{rigor:v1:return: ...}` annotation upstream is impractical
11
+ # (the RBS lives in the vendored `ruby/rbs` submodule).
12
+ #
13
+ # This tier sits in `MethodDispatcher.dispatch` between the
14
+ # HKT-builtin tier (which handles parametric / shape-bearing
15
+ # returns like `JSON.parse`'s `json::value`) and the RBS
16
+ # dispatch tier (the canonical lookup). It is consulted only
17
+ # when the method name and arg shape match an entry in the
18
+ # table below, so the standard RBS path stays in charge of
19
+ # every other call.
20
+ #
21
+ # Match policy:
22
+ #
23
+ # - Entries are keyed by `(owner_class_name, method_name,
24
+ # kind)`. `owner_class_name` is the class that *defines*
25
+ # the method (e.g., `"Kernel"`), not necessarily the
26
+ # receiver's static class — Kernel methods are mixed into
27
+ # every non-BasicObject class, so a `__dir__` call on any
28
+ # receiver routes here.
29
+ # - `kind: :both` matches both the singleton-receiver
30
+ # shape (`Kernel.__dir__`, `Singleton[Kernel]` receiver)
31
+ # AND the instance-receiver shape (an implicit-self call
32
+ # like `__dir__` inside any class body, or `obj.__dir__`
33
+ # on an instance).
34
+ # - `kind: :singleton` / `kind: :instance` restrict the
35
+ # match to one of the two shapes.
36
+ # - The handler is called with `(arg_types)` so future
37
+ # entries can refine based on argument types (e.g. a
38
+ # `File.expand_path(string)` entry that returns
39
+ # `non-empty-string` regardless of the upstream return).
40
+ #
41
+ # The override fires ABOVE RBS dispatch — if RBS would have
42
+ # returned a wider type (`String?` for `Kernel#__dir__`), the
43
+ # override returns the refined union (`non-empty-string | nil`)
44
+ # instead. RBS erasure of the refined return goes back to the
45
+ # original upstream shape, so downstream RBS-shaped observers
46
+ # see no difference.
47
+ module StaticReturnRefinements
48
+ # Pre-built carrier reused across calls so structural
49
+ # equality matches across analyzer invocations.
50
+ NON_EMPTY_STRING_OR_NIL = Type::Combinator.union(
51
+ Type::Combinator.non_empty_string,
52
+ Type::Combinator.constant_of(nil)
53
+ ).freeze
54
+ private_constant :NON_EMPTY_STRING_OR_NIL
55
+
56
+ # `Kernel#__dir__` returns the canonical directory of the
57
+ # source file the call appears in, or `nil` when the file
58
+ # is invalid / not available (typically `-e` and similar
59
+ # one-liner contexts). When non-nil the value is always a
60
+ # filesystem-canonical path — never the empty string — so
61
+ # `non-empty-string` is exact.
62
+ KERNEL_DIR = ->(_arg_types) { NON_EMPTY_STRING_OR_NIL }
63
+ private_constant :KERNEL_DIR
64
+
65
+ # Frozen ((owner_class_name, method_name, kind) => handler)
66
+ # table. The kind tag is `:both`, `:singleton`, or
67
+ # `:instance`. New entries SHOULD prefer `:both` unless the
68
+ # singleton- and instance-side shapes genuinely differ.
69
+ OVERRIDES = {
70
+ ["Kernel", :__dir__, :both] => KERNEL_DIR
71
+ }.freeze
72
+ private_constant :OVERRIDES
73
+
74
+ # Looks up a refined return type for the given call.
75
+ #
76
+ # @param owner_class_name [String, nil] the class on which
77
+ # the method is defined (e.g., `"Kernel"`). Pass `nil`
78
+ # when the caller hasn't resolved a defining owner yet —
79
+ # the lookup will then fall back to matching by
80
+ # `(method_name, kind)` against entries whose owner is
81
+ # currently in the table.
82
+ # @param method_name [Symbol]
83
+ # @param kind [Symbol] one of `:singleton`, `:instance`. The
84
+ # caller passes the shape of the actual call site; the
85
+ # table stores `:both` for entries that match either.
86
+ # @param arg_types [Array<Rigor::Type>] positional argument
87
+ # types. Forwarded to the handler so future entries can
88
+ # discriminate on argument shape.
89
+ # @return [Rigor::Type, nil] the refined return type, or
90
+ # `nil` when no override matches.
91
+ def self.lookup(owner_class_name:, method_name:, kind:, arg_types: [])
92
+ return nil if owner_class_name.nil?
93
+
94
+ method_sym = method_name.to_sym
95
+ handler = OVERRIDES[[owner_class_name, method_sym, :both]] ||
96
+ OVERRIDES[[owner_class_name, method_sym, kind]]
97
+ handler&.call(arg_types)
98
+ end
99
+
100
+ # Indexed view by `(method_name, kind)` — used by the
101
+ # dispatcher when the receiver's owner is not yet resolved
102
+ # but the method name alone uniquely identifies a stdlib
103
+ # override (today: `__dir__` → Kernel). The table is small
104
+ # and the index rebuild cost trivial, but precomputing keeps
105
+ # `dispatch`'s hot path free of an O(n) scan.
106
+ OWNERS_BY_METHOD = OVERRIDES.each_with_object({}) do |((owner, mname, _kind), _h), acc|
107
+ acc[mname] ||= []
108
+ acc[mname] << owner unless acc[mname].include?(owner)
109
+ end.freeze
110
+ private_constant :OWNERS_BY_METHOD
111
+
112
+ # @return [Array<String>] the candidate owner class names
113
+ # for a bare method-name lookup. Empty when no override
114
+ # names this method.
115
+ def self.owners_for(method_name)
116
+ OWNERS_BY_METHOD[method_name.to_sym] || []
117
+ end
118
+ end
119
+ end
120
+ end
@@ -28,7 +28,9 @@ module Rigor
28
28
  end
29
29
 
30
30
  def self.file_entries(loader)
31
- loader.signature_paths.flat_map do |root|
31
+ roots = loader.signature_paths +
32
+ Rigor::Environment::RbsLoader.vendored_gem_sig_paths
33
+ roots.flat_map do |root|
32
34
  next [] unless root.directory?
33
35
 
34
36
  Dir.glob(root.join("**", "*.rbs")).map do |path|
@@ -3,6 +3,7 @@
3
3
  require "digest"
4
4
  require "fileutils"
5
5
  require "json"
6
+ require "monitor"
6
7
  require "securerandom"
7
8
 
8
9
  require_relative "descriptor"
@@ -21,7 +22,7 @@ module Rigor
21
22
  # next write replaces the bad entry. The trailing SHA-256 catches
22
23
  # accidental corruption (partial writes, FS errors); it is **not**
23
24
  # a security boundary, per ADR-2's trusted-gem trust model.
24
- class Store
25
+ class Store # rubocop:disable Metrics/ClassLength
25
26
  # Header literal: 5-byte ASCII magic, 1-byte separator, 1-byte
26
27
  # format version. Bumped on incompatible on-disk format changes
27
28
  # (independent of {Descriptor::SCHEMA_VERSION}, which covers
@@ -30,16 +31,55 @@ module Rigor
30
31
 
31
32
  VALID_PRODUCER_ID = /\A[a-z][a-z0-9._-]*\z/
32
33
 
33
- def initialize(root:)
34
+ # @param root [String] cache root directory.
35
+ # @param read_only [Boolean] when true, every disk-side
36
+ # side-effect is suppressed: `fetch_or_compute` still
37
+ # reads existing entries (hits) and still runs the
38
+ # producer block on miss, but it does NOT write the
39
+ # produced value to disk, does NOT update the
40
+ # `schema_version.txt` marker, and does NOT touch the
41
+ # on-disk root directory. The in-process memo is still
42
+ # populated so repeated lookups within the same run stay
43
+ # cheap. Used by editor mode so multiple buffer-mode
44
+ # invocations can read from the same cache concurrently
45
+ # without churning it. See
46
+ # `docs/design/20260516-editor-mode.md` § "Cache behaviour".
47
+ def initialize(root:, read_only: false)
34
48
  @root = root.to_s.dup.freeze
49
+ @read_only = read_only
35
50
  @hits = 0
36
51
  @misses = 0
37
52
  @writes = 0
38
53
  @by_producer = Hash.new { |h, k| h[k] = { hits: 0, misses: 0, writes: 0 } }
54
+ # Process-level in-memory layer keyed by
55
+ # `(producer_id, cache_key)`. Avoids the disk read +
56
+ # `Marshal.load` cost (the dominant share of repeated
57
+ # cache-hit calls per stackprof) when many short-lived
58
+ # `Analysis::Runner` instances share one `Store` — the
59
+ # spec process, the LSP daemon's repeated re-check
60
+ # path, and any other "many runs, same project" loop.
61
+ # Keys are content-derived (descriptor digests), so
62
+ # cross-fixture contamination is impossible.
63
+ @memo = {}
64
+ # `Analysis::Runner` walks files concurrently (file-
65
+ # level parallelism); the per-file workers share one
66
+ # Store. The monitor guards `@memo` + the counter
67
+ # hashes against concurrent writes. The Monitor is
68
+ # re-entrant so producer blocks can recursively
69
+ # consult the Store (e.g. one cache layer building on
70
+ # another) without dead-locking.
71
+ @monitor = Monitor.new
39
72
  end
40
73
 
41
74
  attr_reader :root
42
75
 
76
+ # @return [Boolean] whether this Store suppresses disk writes
77
+ # (`schema_version.txt`, entry creation). Reads are
78
+ # unaffected.
79
+ def read_only?
80
+ @read_only
81
+ end
82
+
43
83
  # Returns a frozen snapshot of this Store's per-run hit / miss /
44
84
  # write counters. The bookkeeping is in-memory only — every new
45
85
  # `Store.new` starts at zero — so the counters reflect activity
@@ -49,8 +89,10 @@ module Rigor
49
89
  #
50
90
  # @return [Hash] `{ hits:, misses:, writes:, by_producer: { id => { hits:, misses:, writes: } } }`
51
91
  def stats
52
- per_producer = @by_producer.transform_values { |counts| counts.dup.freeze }.freeze
53
- { hits: @hits, misses: @misses, writes: @writes, by_producer: per_producer }.freeze
92
+ @monitor.synchronize do
93
+ per_producer = @by_producer.transform_values { |counts| counts.dup.freeze }.freeze
94
+ { hits: @hits, misses: @misses, writes: @writes, by_producer: per_producer }.freeze
95
+ end
54
96
  end
55
97
 
56
98
  # Walks the on-disk cache rooted at `root` and reports a
@@ -128,18 +170,30 @@ module Rigor
128
170
  ensure_schema_version!
129
171
 
130
172
  key = descriptor.cache_key_for(producer_id: producer_id, params: params)
131
- path = entry_path(producer_id, key)
173
+ memo_key = [producer_id, key].freeze
174
+ memoed = @monitor.synchronize { @memo[memo_key] if @memo.key?(memo_key) }
175
+ unless memoed.nil?
176
+ @monitor.synchronize { record(:hits, producer_id) }
177
+ return memoed
178
+ end
132
179
 
180
+ path = entry_path(producer_id, key)
133
181
  cached = read_entry(path, deserialize: deserialize)
134
182
  unless cached.nil?
135
- record(:hits, producer_id)
183
+ @monitor.synchronize do
184
+ record(:hits, producer_id)
185
+ @memo[memo_key] = cached.value
186
+ end
136
187
  return cached.value
137
188
  end
138
189
 
139
- record(:misses, producer_id)
140
190
  value = block.call
141
- write_entry(path, descriptor, value, serialize: serialize)
142
- record(:writes, producer_id)
191
+ write_entry(path, descriptor, value, serialize: serialize) unless @read_only
192
+ @monitor.synchronize do
193
+ record(:misses, producer_id)
194
+ record(:writes, producer_id) unless @read_only
195
+ @memo[memo_key] = value
196
+ end
143
197
  value
144
198
  end
145
199
 
@@ -269,6 +323,15 @@ module Rigor
269
323
  end
270
324
 
271
325
  def ensure_schema_version!
326
+ # Read-only stores never touch the cache root — no mkdir,
327
+ # no marker write, no destructive clear on schema
328
+ # mismatch. A stale or wrong-schema marker simply yields
329
+ # nothing back (entries read through the version check
330
+ # are content-keyed, so a write under the new schema
331
+ # never collides with a read under the old). The next
332
+ # writable run will repair the cache.
333
+ return if @read_only
334
+
272
335
  FileUtils.mkdir_p(@root)
273
336
  marker = File.join(@root, "schema_version.txt")
274
337
  current = Descriptor::SCHEMA_VERSION.to_s
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optionparser"
4
+
5
+ module Rigor
6
+ class CLI
7
+ # Executes the `rigor lsp` command.
8
+ #
9
+ # See `docs/design/20260517-language-server.md` for the design.
10
+ # Slice 1 (this commit) ships the CLI subcommand entry point.
11
+ # The actual stdio JSON-RPC reader / writer is queued for slice 2;
12
+ # invoking `rigor lsp` at slice 1 returns immediately after
13
+ # validating the transport flag.
14
+ class LspCommand
15
+ USAGE = "Usage: rigor lsp [options]"
16
+
17
+ def initialize(argv:, out:, err:)
18
+ @argv = argv
19
+ @out = out
20
+ @err = err
21
+ end
22
+
23
+ # @return [Integer] CLI exit status.
24
+ def run
25
+ options = parse_options
26
+ return CLI::EXIT_USAGE if options == :usage_error
27
+
28
+ transport = options.fetch(:transport)
29
+ unless transport == "stdio"
30
+ @err.puts("rigor lsp: unsupported transport: #{transport.inspect} (only `stdio` is supported in v1)")
31
+ return CLI::EXIT_USAGE
32
+ end
33
+
34
+ require_relative "../language_server"
35
+ require_relative "../configuration"
36
+ require "language_server-protocol"
37
+
38
+ # STDIN is read frame-by-frame via the gem's `Io::Reader`;
39
+ # STDOUT is wrapped in `SynchronizedWriter` so concurrent
40
+ # writes from the main dispatch thread + the Debouncer's
41
+ # async threads don't interleave frames. The Loop runs
42
+ # until either STDIN hits EOF or `server.exited?`; the
43
+ # process then exits with the server's recorded code
44
+ # (0 after a clean shutdown+exit, 1 otherwise).
45
+ writer = LanguageServer::SynchronizedWriter.new(
46
+ ::LanguageServer::Protocol::Transport::Io::Writer.new($stdout)
47
+ )
48
+ server, loop_runner = build_server(writer: writer, config_path: options.fetch(:config))
49
+ loop_runner.run
50
+ server.exit_code || 0
51
+ end
52
+
53
+ private
54
+
55
+ # Builds the full collaborator graph from a fresh
56
+ # `Configuration` + `ProjectContext`. Returns `[server,
57
+ # loop]` so the caller drives the loop and reads
58
+ # `server.exit_code` for the process exit status.
59
+ def build_server(writer:, config_path:) # rubocop:disable Metrics/MethodLength
60
+ configuration = Configuration.load(config_path)
61
+ # ProjectContext caches Environment + Cache::Store across
62
+ # requests so hover / publish hit the warm path. Invalidated
63
+ # by `workspace/didChangeWatchedFiles` and
64
+ # `workspace/didChangeConfiguration`.
65
+ project_context = LanguageServer::ProjectContext.new(configuration: configuration)
66
+ # Single source of truth for buffer state — threaded to
67
+ # Server + all three providers.
68
+ buffer_table = LanguageServer::BufferTable.new
69
+ debouncer = LanguageServer::Debouncer.new
70
+ publisher = LanguageServer::DiagnosticPublisher.new(
71
+ writer: writer, buffer_table: buffer_table, project_context: project_context,
72
+ debouncer: debouncer, debounce_seconds: 0.2
73
+ )
74
+ server = LanguageServer::Server.new(
75
+ buffer_table: buffer_table,
76
+ publisher: publisher,
77
+ hover_provider: LanguageServer::HoverProvider.new(
78
+ buffer_table: buffer_table, project_context: project_context
79
+ ),
80
+ document_symbol_provider: LanguageServer::DocumentSymbolProvider.new(
81
+ buffer_table: buffer_table, project_context: project_context
82
+ ),
83
+ completion_provider: LanguageServer::CompletionProvider.new(
84
+ buffer_table: buffer_table, project_context: project_context
85
+ ),
86
+ signature_help_provider: LanguageServer::SignatureHelpProvider.new(
87
+ buffer_table: buffer_table, project_context: project_context
88
+ ),
89
+ folding_range_provider: LanguageServer::FoldingRangeProvider.new(
90
+ buffer_table: buffer_table, project_context: project_context
91
+ ),
92
+ selection_range_provider: LanguageServer::SelectionRangeProvider.new(
93
+ buffer_table: buffer_table, project_context: project_context
94
+ ),
95
+ project_context: project_context
96
+ )
97
+ loop_runner = LanguageServer::Loop.new(
98
+ reader: ::LanguageServer::Protocol::Transport::Io::Reader.new($stdin),
99
+ writer: writer,
100
+ server: server
101
+ )
102
+ [server, loop_runner]
103
+ end
104
+
105
+ def parse_options
106
+ options = { transport: "stdio", log: nil, config: nil }
107
+
108
+ parser = OptionParser.new do |opts|
109
+ opts.banner = USAGE
110
+ opts.on("--transport=NAME", "Transport (default: stdio; only stdio supported in v1)") do |value|
111
+ options[:transport] = value
112
+ end
113
+ opts.on("--log=PATH", "Write LSP wire log + server debug to PATH (default: stderr)") do |value|
114
+ options[:log] = value
115
+ end
116
+ opts.on("--config=PATH", "Path to the Rigor configuration file") do |value|
117
+ options[:config] = value
118
+ end
119
+ end
120
+ parser.parse!(@argv)
121
+ options
122
+ rescue OptionParser::ParseError => e
123
+ @err.puts(e.message)
124
+ @err.puts(USAGE)
125
+ :usage_error
126
+ end
127
+ end
128
+ end
129
+ end
@@ -3,6 +3,7 @@
3
3
  require "optionparser"
4
4
  require "prism"
5
5
 
6
+ require_relative "../analysis/buffer_binding"
6
7
  require_relative "../configuration"
7
8
  require_relative "../environment"
8
9
  require_relative "../scope"
@@ -38,35 +39,73 @@ module Rigor
38
39
  # @return [Integer] CLI exit status.
39
40
  def run
40
41
  options = parse_options
42
+ buffer = resolve_buffer_binding(options)
43
+ return CLI::EXIT_USAGE if buffer == :usage_error
41
44
 
42
45
  target = parse_position_argument(@argv)
43
46
  return CLI::EXIT_USAGE if target.nil?
44
47
 
45
- execute(target: target, options: options)
48
+ execute(target: target, options: options, buffer: buffer)
46
49
  end
47
50
 
48
51
  private
49
52
 
50
53
  def parse_options
51
- options = { format: "text", trace: false, config: nil }
54
+ options = { format: "text", trace: false, config: nil, tmp_file: nil, instead_of: nil }
52
55
 
53
56
  parser = OptionParser.new do |opts|
54
57
  opts.banner = USAGE
55
58
  opts.on("--format=FORMAT", "Output format: text or json") { |value| options[:format] = value }
56
59
  opts.on("--trace", "Record fail-soft fallbacks via FallbackTracer") { options[:trace] = true }
57
60
  opts.on("--config=PATH", "Path to the Rigor configuration file") { |value| options[:config] = value }
61
+ opts.on("--tmp-file=PATH",
62
+ "Editor mode: read source bytes from PATH instead of --instead-of (paired)") do |value|
63
+ options[:tmp_file] = value
64
+ end
65
+ opts.on("--instead-of=PATH",
66
+ "Editor mode: the logical project path the buffer represents (paired with --tmp-file)") do |value|
67
+ options[:instead_of] = value
68
+ end
58
69
  end
59
70
  parser.parse!(@argv)
60
71
 
61
72
  options
62
73
  end
63
74
 
64
- def execute(target:, options:)
75
+ # Mirrors `Rigor::CLI#resolve_buffer_binding` (the `check`
76
+ # path). Returns nil / BufferBinding / :usage_error. The
77
+ # symbol return path lets the caller translate to
78
+ # `CLI::EXIT_USAGE` without raising.
79
+ def resolve_buffer_binding(options)
80
+ tmp = options[:tmp_file]
81
+ instead = options[:instead_of]
82
+ return nil if tmp.nil? && instead.nil?
83
+
84
+ if tmp.nil? || instead.nil?
85
+ @err.puts("--tmp-file and --instead-of must appear together")
86
+ return :usage_error
87
+ end
88
+
89
+ unless File.file?(tmp)
90
+ @err.puts("--tmp-file #{tmp.inspect}: no such file or not readable")
91
+ return :usage_error
92
+ end
93
+
94
+ Rigor::Analysis::BufferBinding.new(logical_path: instead, physical_path: tmp)
95
+ end
96
+
97
+ def execute(target:, options:, buffer: nil)
65
98
  file, line, column = target
66
- return 1 unless file_exists?(file)
99
+ # Under editor mode the logical `file` may not exist on disk
100
+ # (user editing a new file); the runtime check is only that
101
+ # the BUFFER is readable, which `resolve_buffer_binding`
102
+ # has already enforced. For non-editor mode `file` must
103
+ # exist.
104
+ physical = buffer ? buffer.resolve(file) : file
105
+ return 1 unless file_exists?(buffer ? physical : file)
67
106
 
68
107
  configuration = Configuration.load(options.fetch(:config))
69
- source = File.read(file)
108
+ source = File.read(physical)
70
109
  parse_result = Prism.parse(source, filepath: file, version: configuration.target_ruby)
71
110
  return 1 if parse_errors?(parse_result, file)
72
111