rigortype 0.1.5 → 0.1.7

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 (72) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +76 -79
  3. data/lib/rigor/analysis/baseline.rb +347 -0
  4. data/lib/rigor/analysis/buffer_binding.rb +36 -0
  5. data/lib/rigor/analysis/check_rules.rb +68 -3
  6. data/lib/rigor/analysis/dependency_source_inference/index.rb +14 -1
  7. data/lib/rigor/analysis/dependency_source_inference/return_type_heuristic.rb +105 -0
  8. data/lib/rigor/analysis/dependency_source_inference/walker.rb +32 -12
  9. data/lib/rigor/analysis/project_scan.rb +39 -0
  10. data/lib/rigor/analysis/runner.rb +309 -22
  11. data/lib/rigor/analysis/worker_session.rb +14 -2
  12. data/lib/rigor/builtins/hkt_builtins.rb +342 -0
  13. data/lib/rigor/builtins/static_return_refinements.rb +142 -0
  14. data/lib/rigor/cache/store.rb +33 -3
  15. data/lib/rigor/cli/baseline_command.rb +377 -0
  16. data/lib/rigor/cli/lsp_command.rb +129 -0
  17. data/lib/rigor/cli/type_of_command.rb +44 -5
  18. data/lib/rigor/cli.rb +142 -13
  19. data/lib/rigor/configuration.rb +58 -2
  20. data/lib/rigor/environment/hkt_registry_holder.rb +33 -0
  21. data/lib/rigor/environment/rbs_coverage_report.rb +1 -1
  22. data/lib/rigor/environment/rbs_loader.rb +67 -2
  23. data/lib/rigor/environment/reporters.rb +40 -0
  24. data/lib/rigor/environment.rb +119 -9
  25. data/lib/rigor/flow_contribution/fact.rb +20 -10
  26. data/lib/rigor/inference/acceptance.rb +48 -3
  27. data/lib/rigor/inference/expression_typer.rb +64 -2
  28. data/lib/rigor/inference/hkt_body.rb +171 -0
  29. data/lib/rigor/inference/hkt_body_parser.rb +363 -0
  30. data/lib/rigor/inference/hkt_reducer.rb +256 -0
  31. data/lib/rigor/inference/hkt_registry.rb +223 -0
  32. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +125 -30
  33. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +32 -11
  34. data/lib/rigor/inference/method_dispatcher/receiver_affinity.rb +87 -0
  35. data/lib/rigor/inference/method_dispatcher.rb +174 -6
  36. data/lib/rigor/inference/narrowing.rb +103 -1
  37. data/lib/rigor/inference/project_patched_methods.rb +70 -0
  38. data/lib/rigor/inference/project_patched_scanner.rb +210 -0
  39. data/lib/rigor/inference/scope_indexer.rb +209 -19
  40. data/lib/rigor/inference/statement_evaluator.rb +172 -11
  41. data/lib/rigor/inference/synthetic_method_scanner.rb +94 -16
  42. data/lib/rigor/language_server/buffer_table.rb +63 -0
  43. data/lib/rigor/language_server/completion_provider.rb +438 -0
  44. data/lib/rigor/language_server/debouncer.rb +86 -0
  45. data/lib/rigor/language_server/diagnostic_publisher.rb +167 -0
  46. data/lib/rigor/language_server/document_symbol_provider.rb +142 -0
  47. data/lib/rigor/language_server/folding_range_provider.rb +75 -0
  48. data/lib/rigor/language_server/hover_provider.rb +74 -0
  49. data/lib/rigor/language_server/hover_renderer.rb +312 -0
  50. data/lib/rigor/language_server/loop.rb +71 -0
  51. data/lib/rigor/language_server/project_context.rb +145 -0
  52. data/lib/rigor/language_server/selection_range_provider.rb +93 -0
  53. data/lib/rigor/language_server/server.rb +384 -0
  54. data/lib/rigor/language_server/signature_help_provider.rb +249 -0
  55. data/lib/rigor/language_server/synchronized_writer.rb +28 -0
  56. data/lib/rigor/language_server/uri.rb +40 -0
  57. data/lib/rigor/language_server.rb +29 -0
  58. data/lib/rigor/plugin/base.rb +63 -0
  59. data/lib/rigor/plugin/macro/heredoc_template.rb +127 -13
  60. data/lib/rigor/plugin/macro/trait_registry.rb +1 -1
  61. data/lib/rigor/plugin/manifest.rb +54 -7
  62. data/lib/rigor/plugin/registry.rb +19 -0
  63. data/lib/rigor/rbs_extended/hkt_directives.rb +326 -0
  64. data/lib/rigor/rbs_extended.rb +82 -2
  65. data/lib/rigor/sig_gen/generator.rb +12 -3
  66. data/lib/rigor/type/app.rb +107 -0
  67. data/lib/rigor/type.rb +1 -0
  68. data/lib/rigor/version.rb +1 -1
  69. data/sig/rigor/environment.rbs +10 -4
  70. data/sig/rigor/inference.rbs +2 -0
  71. data/sig/rigor.rbs +4 -1
  72. metadata +56 -1
@@ -0,0 +1,377 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ require_relative "../analysis/baseline"
6
+ require_relative "../analysis/runner"
7
+ require_relative "../cache/store"
8
+ require_relative "../configuration"
9
+
10
+ module Rigor
11
+ class CLI
12
+ # ADR-22 Slice 1 — `rigor baseline {generate}` subcommands.
13
+ # Backed by `Rigor::Analysis::Baseline`. Future slices
14
+ # extend the subcommand surface with `dump`, `drift`,
15
+ # `prune`, `regenerate`.
16
+ #
17
+ # Initial subcommand: `generate`.
18
+ #
19
+ # rigor baseline generate # default: rule-ID rows
20
+ # rigor baseline generate --match-mode message
21
+ # rigor baseline generate --force # overwrite existing
22
+ # rigor baseline generate --output=PATH
23
+ class BaselineCommand # rubocop:disable Metrics/ClassLength
24
+ EXIT_USAGE = 64
25
+ DEFAULT_BASELINE_PATH = ".rigor-baseline.yml"
26
+
27
+ SUBCOMMANDS = %w[generate dump drift prune].freeze
28
+
29
+ def initialize(argv:, out: $stdout, err: $stderr)
30
+ @argv = argv
31
+ @out = out
32
+ @err = err
33
+ end
34
+
35
+ def run
36
+ subcommand = @argv.shift
37
+ case subcommand
38
+ when nil, "help", "-h", "--help"
39
+ @out.puts(help)
40
+ 0
41
+ when "generate" then run_generate
42
+ when "dump" then run_dump
43
+ when "drift" then run_drift
44
+ when "prune" then run_prune
45
+ else
46
+ @err.puts("Unknown baseline subcommand: #{subcommand.inspect}")
47
+ @err.puts(help)
48
+ EXIT_USAGE
49
+ end
50
+ rescue OptionParser::ParseError => e
51
+ @err.puts(e.message)
52
+ EXIT_USAGE
53
+ end
54
+
55
+ private
56
+
57
+ def run_generate
58
+ options = parse_generate_options
59
+ path = options.fetch(:output)
60
+
61
+ if File.exist?(path) && !options.fetch(:force)
62
+ @err.puts("rigor: #{path} already exists. Re-run with --force to overwrite.")
63
+ return EXIT_USAGE
64
+ end
65
+
66
+ configuration = Configuration.load(options.fetch(:config))
67
+ diagnostics = collect_diagnostics(configuration, options)
68
+
69
+ baseline = Analysis::Baseline.from_diagnostics(diagnostics, match_mode: options.fetch(:match_mode))
70
+ File.write(path, baseline.to_yaml)
71
+
72
+ bucket_count = baseline.size
73
+ diagnostic_count = diagnostics.size
74
+ @err.puts(
75
+ "rigor: wrote baseline to #{path} " \
76
+ "(#{bucket_count} bucket(s) covering #{diagnostic_count} diagnostic(s); " \
77
+ "match-mode: #{options.fetch(:match_mode)})"
78
+ )
79
+ if configuration.baseline_path.nil?
80
+ @err.puts(
81
+ "rigor: note — `.rigor.yml` does not declare `baseline:`; " \
82
+ "add `baseline: #{path}` to activate the suppression."
83
+ )
84
+ end
85
+ 0
86
+ end
87
+
88
+ def parse_generate_options
89
+ options = {
90
+ config: nil,
91
+ output: DEFAULT_BASELINE_PATH,
92
+ match_mode: :rule,
93
+ force: false
94
+ }
95
+ parser = OptionParser.new do |opts|
96
+ opts.banner = "Usage: rigor baseline generate [options]"
97
+ opts.on("--config=PATH", "Path to the Rigor configuration file") { |v| options[:config] = v }
98
+ opts.on("--output=PATH", "Write baseline to PATH (default: #{DEFAULT_BASELINE_PATH})") do |v|
99
+ options[:output] = v
100
+ end
101
+ opts.on("--match-mode=MODE", %i[rule message],
102
+ "Row form: rule (default) or message") do |v|
103
+ options[:match_mode] = v
104
+ end
105
+ opts.on("--force", "Overwrite an existing baseline file") { options[:force] = true }
106
+ end
107
+ parser.parse!(@argv)
108
+ options
109
+ end
110
+
111
+ def collect_diagnostics(configuration, _options)
112
+ cache_store = Cache::Store.new(root: configuration.cache_path)
113
+ # IMPORTANT: do NOT activate the existing baseline when
114
+ # generating a fresh one — otherwise the new file
115
+ # records the post-filter (silenced) diagnostic set,
116
+ # which is empty after a successful first run.
117
+ configuration_for_generation = override_configuration_baseline_off(configuration)
118
+ runner = Analysis::Runner.new(
119
+ configuration: configuration_for_generation,
120
+ cache_store: cache_store,
121
+ collect_stats: false
122
+ )
123
+ runner.run(configuration_for_generation.paths).diagnostics
124
+ end
125
+
126
+ def override_configuration_baseline_off(configuration)
127
+ # Synthesise a new Configuration with `baseline` explicitly
128
+ # disabled. The original Configuration is frozen-ish so we
129
+ # round-trip through the constructor with an override hash.
130
+ defaults = Configuration::DEFAULTS.merge(
131
+ "paths" => configuration.paths,
132
+ "exclude" => configuration.exclude_patterns,
133
+ "plugins" => configuration.plugins.map(&:to_h),
134
+ "disable" => configuration.disabled_rules,
135
+ "libraries" => configuration.libraries,
136
+ "signature_paths" => configuration.signature_paths,
137
+ "pre_eval" => configuration.pre_eval,
138
+ "severity_profile" => configuration.severity_profile.to_s,
139
+ "severity_overrides" => configuration.severity_overrides,
140
+ "baseline" => false,
141
+ "cache" => { "path" => configuration.cache_path }
142
+ )
143
+ Configuration.new(defaults)
144
+ end
145
+
146
+ # ---- dump --------------------------------------------------
147
+
148
+ def run_dump
149
+ options = parse_dump_options
150
+ baseline = load_baseline_or_exit(options.fetch(:baseline))
151
+ return EXIT_USAGE if baseline == :error
152
+
153
+ rows = filter_dump_rows(baseline.buckets, options)
154
+ case options.fetch(:format)
155
+ when :json then @out.puts(JSON.pretty_generate(dump_to_json(rows)))
156
+ else dump_text(rows, options)
157
+ end
158
+ 0
159
+ end
160
+
161
+ def parse_dump_options
162
+ options = { baseline: DEFAULT_BASELINE_PATH, format: :text, rule: nil, file: nil }
163
+ parser = OptionParser.new do |opts|
164
+ opts.banner = "Usage: rigor baseline dump [options]"
165
+ opts.on("--baseline=PATH", "Path to the baseline file (default: #{DEFAULT_BASELINE_PATH})") do |v|
166
+ options[:baseline] = v
167
+ end
168
+ opts.on("--format=FORMAT", %i[text json], "Output format: text (default) or json") do |v|
169
+ options[:format] = v
170
+ end
171
+ opts.on("--rule=RULE", "Filter rows by exact rule id") { |v| options[:rule] = v }
172
+ opts.on("--file=GLOB", "Filter rows by File.fnmatch? glob") { |v| options[:file] = v }
173
+ end
174
+ parser.parse!(@argv)
175
+ options
176
+ end
177
+
178
+ def filter_dump_rows(buckets, options)
179
+ buckets.select do |b|
180
+ next false if options[:rule] && b.rule != options[:rule]
181
+ next false if options[:file] && !File.fnmatch?(options[:file], b.file)
182
+
183
+ true
184
+ end
185
+ end
186
+
187
+ def dump_text(rows, options)
188
+ if rows.empty?
189
+ @out.puts("(no baseline rows matching the supplied filters)")
190
+ return
191
+ end
192
+
193
+ # Group by rule for readability; rules with the most
194
+ # entries first.
195
+ by_rule = rows.group_by(&:rule).sort_by { |_rule, group| -group.size }
196
+ by_rule.each do |rule, group|
197
+ total = group.sum(&:count)
198
+ @out.puts("#{rule} (#{group.size} bucket(s), #{total} occurrence(s))")
199
+ group.sort_by { |b| [-b.count, b.file] }.each do |bucket|
200
+ label = if bucket.message_regex
201
+ " #{bucket.file}: #{bucket.count} ~/#{bucket.message_regex.source}/"
202
+ else
203
+ " #{bucket.file}: #{bucket.count}"
204
+ end
205
+ @out.puts(label)
206
+ end
207
+ @out.puts("")
208
+ end
209
+ @out.puts("Total: #{rows.size} bucket(s), #{rows.sum(&:count)} occurrence(s)")
210
+ _ = options # reserved for future flags
211
+ end
212
+
213
+ def dump_to_json(rows)
214
+ {
215
+ "version" => Analysis::Baseline::CURRENT_VERSION,
216
+ "ignored" => rows.map do |b|
217
+ row = { "file" => b.file, "rule" => b.rule, "count" => b.count }
218
+ row["message"] = b.message_regex.source if b.message_regex
219
+ row
220
+ end
221
+ }
222
+ end
223
+
224
+ # ---- drift --------------------------------------------------
225
+
226
+ def run_drift
227
+ options = parse_drift_options
228
+ baseline = load_baseline_or_exit(options.fetch(:baseline))
229
+ return EXIT_USAGE if baseline == :error
230
+
231
+ configuration = Configuration.load(options.fetch(:config))
232
+ diagnostics = collect_diagnostics(configuration, options)
233
+ drift_rows = baseline.audit(diagnostics)
234
+
235
+ shown = if options.fetch(:only).nil?
236
+ drift_rows.reject { |r| r.delta.zero? }
237
+ else
238
+ drift_rows.select { |r| r.status == options.fetch(:only) }
239
+ end
240
+
241
+ if shown.empty?
242
+ @out.puts("No drift detected.")
243
+ return 0
244
+ end
245
+
246
+ report_drift_rows(shown, baseline_path: options.fetch(:baseline))
247
+ 0
248
+ end
249
+
250
+ def parse_drift_options
251
+ options = {
252
+ config: nil,
253
+ baseline: DEFAULT_BASELINE_PATH,
254
+ only: nil
255
+ }
256
+ parser = OptionParser.new do |opts|
257
+ opts.banner = "Usage: rigor baseline drift [options]"
258
+ opts.on("--config=PATH", "Path to the Rigor configuration file") { |v| options[:config] = v }
259
+ opts.on("--baseline=PATH", "Path to the baseline file (default: #{DEFAULT_BASELINE_PATH})") do |v|
260
+ options[:baseline] = v
261
+ end
262
+ opts.on("--only=STATUS", %i[within over cleared reducible],
263
+ "Show only buckets with the given status (within|over|cleared|reducible)") do |v|
264
+ options[:only] = v
265
+ end
266
+ end
267
+ parser.parse!(@argv)
268
+ options
269
+ end
270
+
271
+ def report_drift_rows(rows, baseline_path:) # rubocop:disable Metrics/AbcSize
272
+ @out.puts("Drift report against #{baseline_path}:")
273
+ @out.puts("")
274
+ groups = rows.group_by(&:status)
275
+ %i[over cleared reducible within].each do |status|
276
+ group = groups[status] || []
277
+ next if group.empty?
278
+
279
+ @out.puts(drift_section_header(status, group.size))
280
+ group.sort_by { |r| [r.bucket.file, r.bucket.rule] }.each do |row|
281
+ delta_str = row.delta.positive? ? "+#{row.delta}" : row.delta.to_s
282
+ @out.puts(" #{row.bucket.file} [#{row.bucket.rule}] " \
283
+ "#{row.bucket.count} → #{row.actual_count} (Δ#{delta_str})")
284
+ end
285
+ @out.puts("")
286
+ end
287
+ end
288
+
289
+ def drift_section_header(status, count)
290
+ case status
291
+ when :over then "## Over threshold (#{count}) — bucket exceeded; check the regular diagnostic output."
292
+ when :cleared then "## Cleared (#{count}) — `rigor baseline prune` can drop these."
293
+ when :reducible then "## Reducible (#{count}) — tightening opportunity; consider `regenerate` (slice 5)."
294
+ when :within then "## Within threshold (#{count})"
295
+ end
296
+ end
297
+
298
+ # ---- prune --------------------------------------------------
299
+
300
+ def run_prune
301
+ options = parse_prune_options
302
+ baseline = load_baseline_or_exit(options.fetch(:baseline))
303
+ return EXIT_USAGE if baseline == :error
304
+
305
+ configuration = Configuration.load(options.fetch(:config))
306
+ diagnostics = collect_diagnostics(configuration, options)
307
+ drift_rows = baseline.audit(diagnostics)
308
+ cleared = drift_rows.select { |r| r.status == :cleared }
309
+
310
+ if cleared.empty?
311
+ @out.puts("No cleared buckets to prune.")
312
+ return 0
313
+ end
314
+
315
+ announce_prune(cleared, options.fetch(:baseline))
316
+ return 0 if options.fetch(:dry_run)
317
+
318
+ pruned = baseline.without(cleared.map(&:bucket))
319
+ File.write(options.fetch(:baseline), pruned.to_yaml)
320
+ @err.puts("rigor: pruned #{cleared.size} bucket(s); baseline now has #{pruned.size} entries.")
321
+ 0
322
+ end
323
+
324
+ def parse_prune_options
325
+ options = {
326
+ config: nil,
327
+ baseline: DEFAULT_BASELINE_PATH,
328
+ dry_run: false
329
+ }
330
+ parser = OptionParser.new do |opts|
331
+ opts.banner = "Usage: rigor baseline prune [options]"
332
+ opts.on("--config=PATH", "Path to the Rigor configuration file") { |v| options[:config] = v }
333
+ opts.on("--baseline=PATH", "Path to the baseline file (default: #{DEFAULT_BASELINE_PATH})") do |v|
334
+ options[:baseline] = v
335
+ end
336
+ opts.on("--dry-run", "Show what would be dropped without writing the file") { options[:dry_run] = true }
337
+ end
338
+ parser.parse!(@argv)
339
+ options
340
+ end
341
+
342
+ def announce_prune(cleared, baseline_path)
343
+ @out.puts("#{cleared.size} bucket(s) to prune from #{baseline_path}:")
344
+ cleared.sort_by { |r| [r.bucket.file, r.bucket.rule] }.each do |row|
345
+ @out.puts(" - #{row.bucket.file} [#{row.bucket.rule}] (was: #{row.bucket.count})")
346
+ end
347
+ end
348
+
349
+ # ---- shared helpers ----------------------------------------
350
+
351
+ def load_baseline_or_exit(path)
352
+ unless File.exist?(path)
353
+ @err.puts("rigor: baseline file not found: #{path}")
354
+ return :error
355
+ end
356
+ Analysis::Baseline.load(path)
357
+ rescue Analysis::Baseline::LoadError => e
358
+ @err.puts("rigor: baseline load failed: #{e.message}")
359
+ :error
360
+ end
361
+
362
+ def help
363
+ <<~HELP
364
+ Usage: rigor baseline <subcommand> [options]
365
+
366
+ Subcommands:
367
+ generate Write a fresh baseline file from a `rigor check` run.
368
+ dump Print the contents of an existing baseline.
369
+ drift Compare baseline vs current diagnostics (reduction / regression hints).
370
+ prune Drop cleared buckets (`actual == 0`) from the baseline.
371
+
372
+ Run `rigor baseline <subcommand> --help` for subcommand options.
373
+ HELP
374
+ end
375
+ end
376
+ end
377
+ end
@@ -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