rigortype 0.1.5 → 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 (66) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +36 -50
  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/project_scan.rb +39 -0
  9. data/lib/rigor/analysis/runner.rb +309 -22
  10. data/lib/rigor/analysis/worker_session.rb +14 -2
  11. data/lib/rigor/builtins/hkt_builtins.rb +342 -0
  12. data/lib/rigor/builtins/static_return_refinements.rb +120 -0
  13. data/lib/rigor/cache/store.rb +33 -3
  14. data/lib/rigor/cli/lsp_command.rb +129 -0
  15. data/lib/rigor/cli/type_of_command.rb +44 -5
  16. data/lib/rigor/cli.rb +74 -12
  17. data/lib/rigor/configuration.rb +38 -2
  18. data/lib/rigor/environment/hkt_registry_holder.rb +33 -0
  19. data/lib/rigor/environment/rbs_coverage_report.rb +1 -1
  20. data/lib/rigor/environment/rbs_loader.rb +45 -2
  21. data/lib/rigor/environment/reporters.rb +40 -0
  22. data/lib/rigor/environment.rb +106 -9
  23. data/lib/rigor/inference/acceptance.rb +48 -3
  24. data/lib/rigor/inference/expression_typer.rb +47 -0
  25. data/lib/rigor/inference/hkt_body.rb +171 -0
  26. data/lib/rigor/inference/hkt_body_parser.rb +363 -0
  27. data/lib/rigor/inference/hkt_reducer.rb +256 -0
  28. data/lib/rigor/inference/hkt_registry.rb +223 -0
  29. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +125 -30
  30. data/lib/rigor/inference/method_dispatcher/receiver_affinity.rb +87 -0
  31. data/lib/rigor/inference/method_dispatcher.rb +154 -3
  32. data/lib/rigor/inference/project_patched_methods.rb +70 -0
  33. data/lib/rigor/inference/project_patched_scanner.rb +210 -0
  34. data/lib/rigor/inference/scope_indexer.rb +156 -12
  35. data/lib/rigor/inference/statement_evaluator.rb +106 -6
  36. data/lib/rigor/inference/synthetic_method_scanner.rb +94 -16
  37. data/lib/rigor/language_server/buffer_table.rb +63 -0
  38. data/lib/rigor/language_server/completion_provider.rb +438 -0
  39. data/lib/rigor/language_server/debouncer.rb +86 -0
  40. data/lib/rigor/language_server/diagnostic_publisher.rb +167 -0
  41. data/lib/rigor/language_server/document_symbol_provider.rb +142 -0
  42. data/lib/rigor/language_server/folding_range_provider.rb +75 -0
  43. data/lib/rigor/language_server/hover_provider.rb +74 -0
  44. data/lib/rigor/language_server/hover_renderer.rb +312 -0
  45. data/lib/rigor/language_server/loop.rb +71 -0
  46. data/lib/rigor/language_server/project_context.rb +145 -0
  47. data/lib/rigor/language_server/selection_range_provider.rb +93 -0
  48. data/lib/rigor/language_server/server.rb +384 -0
  49. data/lib/rigor/language_server/signature_help_provider.rb +249 -0
  50. data/lib/rigor/language_server/synchronized_writer.rb +28 -0
  51. data/lib/rigor/language_server/uri.rb +40 -0
  52. data/lib/rigor/language_server.rb +29 -0
  53. data/lib/rigor/plugin/base.rb +63 -0
  54. data/lib/rigor/plugin/macro/heredoc_template.rb +125 -11
  55. data/lib/rigor/plugin/manifest.rb +54 -7
  56. data/lib/rigor/plugin/registry.rb +19 -0
  57. data/lib/rigor/rbs_extended/hkt_directives.rb +326 -0
  58. data/lib/rigor/rbs_extended.rb +82 -2
  59. data/lib/rigor/sig_gen/generator.rb +12 -3
  60. data/lib/rigor/type/app.rb +107 -0
  61. data/lib/rigor/type.rb +1 -0
  62. data/lib/rigor/version.rb +1 -1
  63. data/sig/rigor/environment.rbs +8 -4
  64. data/sig/rigor/inference.rbs +2 -0
  65. data/sig/rigor.rbs +3 -1
  66. metadata +54 -1
@@ -177,8 +177,10 @@ module Rigor
177
177
  return if def_node.body.nil? || qualified_prefix.empty?
178
178
 
179
179
  class_name = qualified_prefix.join("::")
180
+ singleton = def_node.receiver.is_a?(Prism::SelfNode) ||
181
+ def_receiver_targets_lexical_self?(def_node.receiver, qualified_prefix)
180
182
  self_type =
181
- if def_node.receiver.is_a?(Prism::SelfNode)
183
+ if singleton
182
184
  Type::Combinator.singleton_of(class_name)
183
185
  else
184
186
  Type::Combinator.nominal_of(class_name)
@@ -371,9 +373,12 @@ module Rigor
371
373
  return
372
374
  end
373
375
  when Prism::SingletonClassNode
374
- if node.expression.is_a?(Prism::SelfNode) && node.body
375
- walk_methods(node.body, qualified_prefix, true, accumulator)
376
- return
376
+ if node.body
377
+ singleton_prefix = singleton_class_prefix(node, qualified_prefix)
378
+ if singleton_prefix
379
+ walk_methods(node.body, singleton_prefix, true, accumulator)
380
+ return
381
+ end
377
382
  end
378
383
  when Prism::ConstantWriteNode
379
384
  if meta_new_block_body(node)
@@ -395,6 +400,30 @@ module Rigor
395
400
  walk_methods(child, qualified_prefix, in_singleton_class, accumulator)
396
401
  end
397
402
  end
403
+
404
+ # Resolves a `class << X` body's qualified prefix.
405
+ # - `class << self` keeps `qualified_prefix` (the enclosing class).
406
+ # - `class << Foo` inside `class Foo` collapses to the same prefix
407
+ # (semantically `class << self`).
408
+ # - `class << Foo` not nested in `class Foo` returns `[Foo]`
409
+ # so methods defined inside register on Foo's singleton.
410
+ # - Any other expression (variable, method call) returns nil
411
+ # so the walker falls through and skips the body.
412
+ def singleton_class_prefix(node, qualified_prefix)
413
+ case node.expression
414
+ when Prism::SelfNode
415
+ qualified_prefix
416
+ when Prism::ConstantReadNode, Prism::ConstantPathNode
417
+ rendered = qualified_name_for(node.expression)
418
+ return nil unless rendered
419
+
420
+ if !qualified_prefix.empty? && qualified_prefix.last == rendered
421
+ qualified_prefix
422
+ else
423
+ rendered.split("::")
424
+ end
425
+ end
426
+ end
398
427
  # rubocop:enable Metrics/CyclomaticComplexity, Metrics/MethodLength
399
428
 
400
429
  # v0.1.2 — when a `Const = Data.define(*sym) do ... end`
@@ -418,11 +447,50 @@ module Rigor
418
447
  return if qualified_prefix.empty?
419
448
 
420
449
  class_name = qualified_prefix.join("::")
421
- kind = def_node.receiver.is_a?(Prism::SelfNode) || in_singleton_class ? :singleton : :instance
450
+ singleton = def_singleton?(def_node, qualified_prefix, in_singleton_class)
451
+ kind = singleton ? :singleton : :instance
422
452
  accumulator[class_name] ||= {}
423
453
  accumulator[class_name][def_node.name] = kind
424
454
  end
425
455
 
456
+ # `def Foo.bar` inside `module Foo` (or `def Meta.init` inside
457
+ # `module Meta`) is semantically equivalent to `def self.bar`:
458
+ # at the def-site, the runtime value of the constant `Foo` is
459
+ # the module itself (== `self`). Recognise the form so the
460
+ # method registers as singleton on the enclosing class.
461
+ #
462
+ # The cross-class form `def Bar.baz` inside `module Foo` —
463
+ # where the receiver names a constant other than the
464
+ # enclosing class — is not supported at this slice; falls
465
+ # through to `:instance` (current behaviour) rather than
466
+ # silently re-routing the registration.
467
+ def def_singleton?(def_node, qualified_prefix, in_singleton_class)
468
+ return true if def_node.receiver.is_a?(Prism::SelfNode) || in_singleton_class
469
+
470
+ def_receiver_targets_lexical_self?(def_node.receiver, qualified_prefix)
471
+ end
472
+
473
+ # Only `Prism::ConstantReadNode` is observed in real Ruby —
474
+ # Prism mis-parses `def C::P.method` as `def C.P` (Ruby itself
475
+ # rejects the form as a SyntaxError). The ConstantPathNode
476
+ # branch stays defensive in case Prism's grammar widens.
477
+ def def_receiver_targets_lexical_self?(receiver, qualified_prefix)
478
+ return false if qualified_prefix.empty?
479
+
480
+ case receiver
481
+ when Prism::ConstantReadNode
482
+ receiver.name.to_s == qualified_prefix.last
483
+ when Prism::ConstantPathNode
484
+ rendered = render_constant_path(receiver)
485
+ return false unless rendered
486
+
487
+ path = rendered.split("::")
488
+ qualified_prefix.last(path.length) == path
489
+ else
490
+ false
491
+ end
492
+ end
493
+
426
494
  # v0.0.2 #5 — instance-side def-node recording. Walks
427
495
  # class bodies the same way as `build_discovered_methods`
428
496
  # but records the actual `Prism::DefNode` for each
@@ -452,9 +520,12 @@ module Rigor
452
520
  return
453
521
  end
454
522
  when Prism::SingletonClassNode
455
- if node.expression.is_a?(Prism::SelfNode) && node.body
456
- walk_def_nodes(node.body, qualified_prefix, true, accumulator)
457
- return
523
+ if node.body
524
+ singleton_prefix = singleton_class_prefix(node, qualified_prefix)
525
+ if singleton_prefix
526
+ walk_def_nodes(node.body, singleton_prefix, true, accumulator)
527
+ return
528
+ end
458
529
  end
459
530
  when Prism::ConstantWriteNode
460
531
  if meta_new_block_body(node)
@@ -481,7 +552,7 @@ module Rigor
481
552
  TOP_LEVEL_DEF_KEY = "<toplevel>"
482
553
 
483
554
  def record_def_node(def_node, qualified_prefix, in_singleton_class, accumulator)
484
- return if def_node.receiver.is_a?(Prism::SelfNode) || in_singleton_class
555
+ return if def_singleton?(def_node, qualified_prefix, in_singleton_class)
485
556
 
486
557
  class_name = qualified_prefix.empty? ? TOP_LEVEL_DEF_KEY : qualified_prefix.join("::")
487
558
  accumulator[class_name] ||= {}
@@ -530,9 +601,12 @@ module Rigor
530
601
  return current_visibility
531
602
  end
532
603
  when Prism::SingletonClassNode
533
- if node.expression.is_a?(Prism::SelfNode) && node.body
534
- walk_method_visibilities(node.body, qualified_prefix, true, :public, accumulator)
535
- return current_visibility
604
+ if node.body
605
+ singleton_prefix = singleton_class_prefix(node, qualified_prefix)
606
+ if singleton_prefix
607
+ walk_method_visibilities(node.body, singleton_prefix, true, :public, accumulator)
608
+ return current_visibility
609
+ end
536
610
  end
537
611
  when Prism::ConstantWriteNode
538
612
  if meta_new_block_body(node)
@@ -703,6 +777,76 @@ module Rigor
703
777
  node.unescaped&.to_sym
704
778
  end
705
779
 
780
+ # Walks every file in `paths` (each path is parsed once with
781
+ # `Prism.parse_file`) and returns the unioned project-wide
782
+ # `discovered_classes` Hash: `{qualified_name => Singleton[…]}`.
783
+ # Used by `Analysis::Runner` to seed each file's
784
+ # `default_scope.discovered_classes` so that lexical
785
+ # constant lookup in one file resolves a `class Foo`
786
+ # declared in a sibling file. Per-file collisions are
787
+ # last-write-wins (matches the existing in-file merge
788
+ # semantics). Parse failures fail-soft to an empty
789
+ # contribution. The `buffer` argument, when present,
790
+ # redirects reads for the bound logical path to the
791
+ # buffer's physical path so editor-mode pre-passes see
792
+ # the in-flight bytes.
793
+ #
794
+ # **Modules are intentionally excluded** from the
795
+ # project-wide seed: a `module M; module_function; def x; end; end`
796
+ # body, when surfaced as `singleton(M)` to the dispatcher,
797
+ # falls through to `Kernel#x` (or any Module ancestor
798
+ # method) when the project's per-file
799
+ # `discovered_methods` doesn't know `M.x` — leading to
800
+ # surprising types like `Kernel.select → Array[String]`.
801
+ # Until cross-file `discovered_methods` follows the same
802
+ # project-wide seed, registering modules here would
803
+ # introduce regressions in modules-with-module_function
804
+ # idioms that previously resolved to `Dynamic[Top]`.
805
+ # Class declarations are safe because per-file
806
+ # `discovered_methods` already tracks `def self.x` /
807
+ # `def x` instance and singleton methods consistently.
808
+ #
809
+ # @param paths [Array<String>] project file paths.
810
+ # @param buffer [Rigor::Analysis::BufferBinding, nil]
811
+ # @return [Hash{String => Rigor::Type::Singleton}]
812
+ def discovered_classes_for_paths(paths, buffer: nil)
813
+ accumulator = {}
814
+ paths.each do |path|
815
+ physical = buffer ? buffer.resolve(path) : path
816
+ source = File.read(physical)
817
+ root = Prism.parse(source, filepath: path).value
818
+ collect_class_decls(root, [], accumulator)
819
+ rescue StandardError
820
+ # Skip files that fail to parse or read; the per-file
821
+ # analyzer surfaces the parse error separately.
822
+ next
823
+ end
824
+ accumulator.freeze
825
+ end
826
+
827
+ # Class-only variant of `record_declarations` — descends
828
+ # into nested module bodies (so `module Foo; class Bar`
829
+ # registers `Foo::Bar`) but never registers the module
830
+ # itself in `accumulator`.
831
+ def collect_class_decls(node, qualified_prefix, accumulator)
832
+ return unless node.is_a?(Prism::Node)
833
+
834
+ case node
835
+ when Prism::ClassNode
836
+ name = qualified_name_for(node.constant_path)
837
+ if name
838
+ full = (qualified_prefix + [name]).join("::")
839
+ accumulator[full] = Type::Combinator.singleton_of(full)
840
+ return collect_class_decls(node.body, qualified_prefix + [name], accumulator) if node.body
841
+ end
842
+ when Prism::ModuleNode
843
+ name = qualified_name_for(node.constant_path)
844
+ return collect_class_decls(node.body, qualified_prefix + [name], accumulator) if name && node.body
845
+ end
846
+
847
+ node.compact_child_nodes.each { |child| collect_class_decls(child, qualified_prefix, accumulator) }
848
+ end
849
+
706
850
  # Walks the program once for `Prism::ModuleNode` and
707
851
  # `Prism::ClassNode`, recording the `Singleton[<qualified>]`
708
852
  # type for the outermost `constant_path` node of each
@@ -760,6 +760,19 @@ module Rigor
760
760
  # name as a Symbol, so the produced type is `Constant[:name]`.
761
761
  def eval_def(node)
762
762
  body_scope = build_method_entry_scope(node)
763
+ # Parameter default value expressions (e.g. `self.x` in
764
+ # `def copy(x: self.x)`) execute when the method is
765
+ # *invoked*, not when the `def` is read; their `self` is
766
+ # the instance receiver, not the surrounding class body.
767
+ # Walk the parameters subtree under `body_scope` so the
768
+ # scope-index records the instance `self_type` for every
769
+ # node inside parameter defaults. `propagate` would
770
+ # otherwise drop them to the outer class-body scope (where
771
+ # `self_type` is `singleton(C)`), making `self.foo` look
772
+ # like a singleton-side call. Observed surfacing 915 false
773
+ # positives in `prism-1.9.0`'s auto-generated `copy`
774
+ # methods alone.
775
+ sub_eval(node.parameters, body_scope, class_context: @class_context) if node.parameters
763
776
  sub_eval(node.body, body_scope, class_context: @class_context) if node.body
764
777
  [Type::Combinator.constant_of(node.name), scope]
765
778
  end
@@ -783,6 +796,21 @@ module Rigor
783
796
  def eval_call(node)
784
797
  call_type = scope.type_of(node, tracer: tracer)
785
798
  evaluate_block_if_present(node)
799
+ # `ruby2_keywords def foo(...)` (and similar wrappers like
800
+ # `private def`, `public def`, `module_function def`) parse
801
+ # the def as the call's positional argument; the
802
+ # ExpressionTyper#type_of_def handler types it as
803
+ # `Constant[:foo]` without walking the body. Without
804
+ # explicitly evaluating the argument-position def, the body's
805
+ # scope-index entries inherit the outer class-body
806
+ # `self_type = singleton(C)` from `ScopeIndexer.propagate`,
807
+ # so `self.helper` inside reports `undefined method 'helper'
808
+ # for singleton(C)`. Walking each argument-position def under
809
+ # the current evaluator (not a sub_eval — the def's effects
810
+ # do not bind into the surrounding scope) populates the
811
+ # scope index with the correct instance / singleton
812
+ # `self_type` for the def's body.
813
+ evaluate_def_arguments(node)
786
814
  post_scope = record_closure_escape_if_any(node)
787
815
  post_scope = apply_rbs_extended_assertions(node, post_scope)
788
816
  post_scope = apply_plugin_assertions(node, post_scope)
@@ -790,6 +818,15 @@ module Rigor
790
818
  [call_type, post_scope]
791
819
  end
792
820
 
821
+ def evaluate_def_arguments(call_node)
822
+ args = call_node.arguments
823
+ return unless args.respond_to?(:arguments)
824
+
825
+ args.arguments.each do |arg|
826
+ eval_def(arg) if arg.is_a?(Prism::DefNode)
827
+ end
828
+ end
829
+
793
830
  # v0.0.3 — recognises a small catalogue of RSpec
794
831
  # matcher patterns as assert-shaped narrows on the
795
832
  # local passed to `expect(...)`. The pattern is
@@ -1399,7 +1436,37 @@ module Rigor
1399
1436
  end
1400
1437
 
1401
1438
  def singleton_def?(def_node)
1402
- def_node.receiver.is_a?(Prism::SelfNode) || current_frame_singleton?
1439
+ return true if def_node.receiver.is_a?(Prism::SelfNode) || current_frame_singleton?
1440
+
1441
+ def_receiver_targets_lexical_self?(def_node.receiver)
1442
+ end
1443
+
1444
+ # `def Foo.bar` inside `module Foo` (or `def Meta.init` inside
1445
+ # `module Meta`) — explicit-receiver def that semantically
1446
+ # equals `def self.bar` because the receiver constant
1447
+ # resolves to `self` at the def-site. Matched against the
1448
+ # current class context's tail to cover both the
1449
+ # `def OpenURI.x` form (single segment) and the
1450
+ # `def OpenURI::Meta.x` form (qualified path). Cross-class
1451
+ # receivers (`def Bar.baz` inside `module Foo` where the
1452
+ # receiver names a different constant) are not promoted to
1453
+ # singleton at this slice.
1454
+ def def_receiver_targets_lexical_self?(receiver)
1455
+ return false if @class_context.empty?
1456
+
1457
+ prefix = @class_context.map(&:name)
1458
+ case receiver
1459
+ when Prism::ConstantReadNode
1460
+ receiver.name.to_s == prefix.last
1461
+ when Prism::ConstantPathNode
1462
+ rendered = render_constant_path(receiver)
1463
+ return false unless rendered
1464
+
1465
+ path = rendered.split("::")
1466
+ prefix.last(path.length) == path
1467
+ else
1468
+ false
1469
+ end
1403
1470
  end
1404
1471
 
1405
1472
  # Slice A-engine. Inside a class body `class Foo; ...; end`,
@@ -1452,12 +1519,45 @@ module Rigor
1452
1519
  end
1453
1520
 
1454
1521
  def singleton_context_for(node)
1455
- return @class_context unless node.expression.is_a?(Prism::SelfNode)
1456
- return @class_context if @class_context.empty?
1522
+ case node.expression
1523
+ when Prism::SelfNode
1524
+ return @class_context if @class_context.empty?
1525
+
1526
+ outer = @class_context[0..-2]
1527
+ last = @class_context.last
1528
+ outer + [ClassFrame.new(name: last.name, singleton: true)]
1529
+ when Prism::ConstantReadNode, Prism::ConstantPathNode
1530
+ target = singleton_constant_target(node.expression)
1531
+ return @class_context unless target
1532
+
1533
+ # `class << Foo` inside `class Foo` (the canonical
1534
+ # pattern in Ruby's own time.rb) is semantically
1535
+ # `class << self` — replace the enclosing frame with a
1536
+ # singleton frame for the same name so method
1537
+ # registration and `self_type` lookup land on Foo.
1538
+ # When the target names a different constant (rare
1539
+ # cross-class form), append a fresh singleton frame
1540
+ # tagged with the target FQN; the bodies are scoped to
1541
+ # that target rather than to the lexical enclosing
1542
+ # class.
1543
+ if !@class_context.empty? && @class_context.last.name == target
1544
+ outer = @class_context[0..-2]
1545
+ outer + [ClassFrame.new(name: target, singleton: true)]
1546
+ else
1547
+ [ClassFrame.new(name: target, singleton: true)]
1548
+ end
1549
+ else
1550
+ @class_context
1551
+ end
1552
+ end
1457
1553
 
1458
- outer = @class_context[0..-2]
1459
- last = @class_context.last
1460
- outer + [ClassFrame.new(name: last.name, singleton: true)]
1554
+ def singleton_constant_target(expression)
1555
+ case expression
1556
+ when Prism::ConstantReadNode
1557
+ expression.name.to_s
1558
+ when Prism::ConstantPathNode
1559
+ render_constant_path(expression)
1560
+ end
1461
1561
  end
1462
1562
 
1463
1563
  # The qualified name of the immediately-enclosing class (joining
@@ -53,24 +53,36 @@ module Rigor
53
53
  # inheritance resolution against RBS-known classes
54
54
  # (ActiveRecord::Base, Dry::Struct, etc.) that aren't
55
55
  # declared in project source.
56
+ # @param fact_store [Rigor::Plugin::FactStore, nil]
57
+ # the per-run cross-plugin fact store. ADR-18 lookups
58
+ # (`Plugin::Macro::HeredocTemplate::Emit#returns_from_arg`)
59
+ # consult this at scan time to resolve per-call-site
60
+ # return types from published facts; without it, those
61
+ # emit rows fall back to their static `returns:` (or
62
+ # `"untyped"` → `Dynamic[Top]`).
63
+ # @param buffer [Rigor::Analysis::BufferBinding, nil]
64
+ # editor-mode buffer binding. When set, reads for the
65
+ # logical path resolve to the buffer's physical path so
66
+ # the pre-pass sees the in-flight bytes instead of the
67
+ # on-disk copy.
56
68
  # @return [Rigor::Inference::SyntheticMethodIndex]
57
- def scan(plugin_registry:, paths:, environment: nil)
69
+ def scan(plugin_registry:, paths:, environment: nil, fact_store: nil, buffer: nil)
58
70
  templates = collect_templates(plugin_registry)
59
71
  registries = collect_trait_registries(plugin_registry)
60
72
  return SyntheticMethodIndex::EMPTY if templates.empty? && registries.empty?
61
73
 
62
- asts = parse_paths(paths)
74
+ asts = parse_paths(paths, buffer: buffer)
63
75
  hierarchy = build_hierarchy(asts)
64
76
  concern_index = build_concern_index(asts)
65
77
 
66
78
  entries = []
67
79
  asts.each do |path, ast|
68
80
  walk_class_bodies(ast) do |class_name, call_node|
69
- collect_entries(entries, templates, class_name, call_node, hierarchy, environment, path)
81
+ collect_entries(entries, templates, class_name, call_node, hierarchy, environment, path, fact_store)
70
82
  collect_trait_entries(entries, registries, class_name, call_node, hierarchy, environment, path)
71
83
  collect_concern_re_targeted_entries(
72
84
  entries, call_node, class_name, concern_index,
73
- templates, registries, hierarchy, environment, path
85
+ templates, registries, hierarchy, environment, path, fact_store
74
86
  )
75
87
  end
76
88
  end
@@ -107,10 +119,11 @@ module Rigor
107
119
  end
108
120
  end
109
121
 
110
- def parse_paths(paths)
122
+ def parse_paths(paths, buffer: nil)
111
123
  paths.to_h do |path|
112
- source = File.read(path)
113
- [path, Prism.parse(source).value]
124
+ physical = buffer ? buffer.resolve(path) : path
125
+ source = File.read(physical)
126
+ [path, Prism.parse(source, filepath: path).value]
114
127
  rescue StandardError
115
128
  [path, nil]
116
129
  end
@@ -209,7 +222,7 @@ module Rigor
209
222
  # `collect_trait_entries` fire just as if the calls had been
210
223
  # written directly in X's body.
211
224
  def collect_concern_re_targeted_entries(entries, call_node, class_name, concern_index, # rubocop:disable Metrics/ParameterLists
212
- templates, registries, hierarchy, environment, path)
225
+ templates, registries, hierarchy, environment, path, fact_store = nil)
213
226
  return unless call_node.name == :include && call_node.receiver.nil?
214
227
  return if concern_index.empty?
215
228
 
@@ -220,7 +233,7 @@ module Rigor
220
233
  next unless deferred
221
234
 
222
235
  deferred.each do |inner_call|
223
- collect_entries(entries, templates, class_name, inner_call, hierarchy, environment, path)
236
+ collect_entries(entries, templates, class_name, inner_call, hierarchy, environment, path, fact_store)
224
237
  collect_trait_entries(entries, registries, class_name, inner_call, hierarchy, environment, path)
225
238
  end
226
239
  end
@@ -318,7 +331,7 @@ module Rigor
318
331
  parent_str ? "#{parent_str}::#{name}" : name
319
332
  end
320
333
 
321
- def collect_entries(entries, templates, class_name, call_node, hierarchy, environment, path)
334
+ def collect_entries(entries, templates, class_name, call_node, hierarchy, environment, path, fact_store = nil) # rubocop:disable Metrics/ParameterLists
322
335
  templates.each do |(plugin_id, template)|
323
336
  next unless call_node.name == template.method_name
324
337
  next unless class_inherits_from?(class_name, template.receiver_constraint, hierarchy, environment)
@@ -326,7 +339,7 @@ module Rigor
326
339
  symbol_arg = literal_symbol_arg(call_node, template.symbol_arg_position)
327
340
  next if symbol_arg.nil?
328
341
 
329
- emit_entries_for(entries, class_name, symbol_arg, template, plugin_id, path, call_node)
342
+ emit_entries_for(entries, class_name, symbol_arg, template, plugin_id, path, call_node, fact_store)
330
343
  end
331
344
  end
332
345
 
@@ -439,28 +452,31 @@ module Rigor
439
452
  )
440
453
  end
441
454
 
442
- def emit_entries_for(entries, class_name, symbol_arg, template, plugin_id, path, call_node)
455
+ def emit_entries_for(entries, class_name, symbol_arg, template, plugin_id, path, call_node, fact_store = nil) # rubocop:disable Metrics/ParameterLists
443
456
  template.emit.each do |row|
444
457
  entries << build_synthetic_method(
445
458
  class_name: class_name, name_arg: symbol_arg, row: row,
446
459
  template: template, plugin_id: plugin_id, path: path, call_node: call_node,
447
- kind: SyntheticMethod::INSTANCE
460
+ kind: SyntheticMethod::INSTANCE, fact_store: fact_store
448
461
  )
449
462
  end
450
463
  template.class_level_emit.each do |row|
451
464
  entries << build_synthetic_method(
452
465
  class_name: class_name, name_arg: symbol_arg, row: row,
453
466
  template: template, plugin_id: plugin_id, path: path, call_node: call_node,
454
- kind: SyntheticMethod::SINGLETON
467
+ kind: SyntheticMethod::SINGLETON, fact_store: fact_store
455
468
  )
456
469
  end
457
470
  end
458
471
 
459
- def build_synthetic_method(class_name:, name_arg:, row:, template:, plugin_id:, path:, call_node:, kind:) # rubocop:disable Metrics/ParameterLists
472
+ # rubocop:disable Metrics/ParameterLists
473
+ def build_synthetic_method(class_name:, name_arg:, row:, template:, plugin_id:, path:, call_node:, kind:,
474
+ fact_store: nil)
475
+ # rubocop:enable Metrics/ParameterLists
460
476
  SyntheticMethod.new(
461
477
  class_name: class_name,
462
478
  method_name: interpolate(row.name, name_arg).to_sym,
463
- return_type: row.returns,
479
+ return_type: resolve_emit_return_type(row, call_node, fact_store),
464
480
  kind: kind,
465
481
  provenance: {
466
482
  plugin_id: plugin_id,
@@ -472,6 +488,68 @@ module Rigor
472
488
  )
473
489
  end
474
490
 
491
+ # ADR-18 three-tier fallback for the synthetic method's
492
+ # `return_type` string:
493
+ #
494
+ # 1. When `row.returns_from_arg` is present AND the
495
+ # call-site argument at the declared position is a
496
+ # resolvable constant reference AND the fact_store
497
+ # has a matching value, use that as the return type.
498
+ # 2. Else if `row.returns` is a non-empty String, use it
499
+ # (the slice-6b static path).
500
+ # 3. Else use `"untyped"` so the dispatcher's
501
+ # `promote_via_return_type` sentinel chain yields
502
+ # `Dynamic[Top]`.
503
+ def resolve_emit_return_type(row, call_node, fact_store)
504
+ resolved = resolve_returns_from_arg(row.returns_from_arg, call_node, fact_store)
505
+ return resolved if resolved
506
+ return row.returns if row.returns
507
+
508
+ "untyped"
509
+ end
510
+
511
+ def resolve_returns_from_arg(returns_from_arg, call_node, fact_store)
512
+ return nil if returns_from_arg.nil?
513
+
514
+ source_rep = argument_source_representation(call_node, returns_from_arg.position)
515
+ return nil if source_rep.nil?
516
+ return nil if fact_store.nil?
517
+
518
+ fact = fact_store.read(plugin_id: returns_from_arg.plugin_id, name: returns_from_arg.fact)
519
+ return nil unless fact.is_a?(Hash)
520
+
521
+ fact[source_rep]
522
+ end
523
+
524
+ # Extracts the source-text qualified-constant representation
525
+ # of the call's positional argument (e.g.,
526
+ # `"Types::String"`). Returns nil for non-constant shapes
527
+ # (literals, method chains, blocks, …). The floor
528
+ # intentionally accepts only ConstantReadNode /
529
+ # ConstantPathNode per ADR-18; chained-call argument
530
+ # resolution stays deferred.
531
+ def argument_source_representation(call_node, position)
532
+ args = call_node.arguments&.arguments
533
+ return nil if args.nil? || position >= args.size
534
+
535
+ node = args[position]
536
+ case node
537
+ when Prism::ConstantReadNode then node.name.to_s
538
+ when Prism::ConstantPathNode then qualified_constant_name(node)
539
+ end
540
+ end
541
+
542
+ def qualified_constant_name(node)
543
+ case node
544
+ when Prism::ConstantReadNode then node.name.to_s
545
+ when Prism::ConstantPathNode
546
+ parent_name = node.parent.nil? ? nil : qualified_constant_name(node.parent)
547
+ return nil if !node.parent.nil? && parent_name.nil?
548
+
549
+ parent_name.nil? ? node.name.to_s : "#{parent_name}::#{node.name}"
550
+ end
551
+ end
552
+
475
553
  def interpolate(template_name, name_arg)
476
554
  template_name.gsub(Rigor::Plugin::Macro::HeredocTemplate::NAME_PLACEHOLDER, name_arg.to_s)
477
555
  end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module LanguageServer
5
+ # Per-session virtual file table. The LSP server maintains the
6
+ # canonical view of every open buffer here; analysis (slice 4+)
7
+ # reads from this table instead of disk so in-flight edits are
8
+ # reflected immediately.
9
+ #
10
+ # Keyed by `DocumentUri` (LSP `file://...` URIs). v1 ships
11
+ # FULL text sync (LSP `TextDocumentSyncKind::Full = 1`) so each
12
+ # `didChange` carries the entire buffer text — there's no
13
+ # incremental edit application yet. Incremental sync is slice
14
+ # 10 (deferred per the design doc).
15
+ class BufferTable
16
+ # @!attribute uri [String] the LSP DocumentUri (e.g. `file:///abs/path/lib/foo.rb`).
17
+ # @!attribute bytes [String] the current full text of the buffer.
18
+ # @!attribute version [Integer] the monotonically increasing LSP version number.
19
+ Entry = Data.define(:uri, :bytes, :version)
20
+
21
+ def initialize
22
+ @entries = {}
23
+ end
24
+
25
+ # Records a `textDocument/didOpen` event. Replaces any
26
+ # existing entry (LSP clients may re-open a previously closed
27
+ # URI; the new version is authoritative).
28
+ def open(uri:, bytes:, version:)
29
+ @entries[uri] = Entry.new(uri: uri, bytes: bytes, version: version)
30
+ end
31
+
32
+ # Records a `textDocument/didChange` event under FULL sync.
33
+ # The full new buffer text replaces the entry. If the client
34
+ # sends a `didChange` for a URI that was never opened (spec
35
+ # violation), the entry is still created — defensive.
36
+ def change(uri:, bytes:, version:)
37
+ @entries[uri] = Entry.new(uri: uri, bytes: bytes, version: version)
38
+ end
39
+
40
+ # Records a `textDocument/didClose` event. The entry is
41
+ # removed. Subsequent reads via `#[]` return nil.
42
+ def close(uri:)
43
+ @entries.delete(uri)
44
+ end
45
+
46
+ def [](uri)
47
+ @entries[uri]
48
+ end
49
+
50
+ def open?(uri)
51
+ @entries.key?(uri)
52
+ end
53
+
54
+ def size
55
+ @entries.size
56
+ end
57
+
58
+ def uris
59
+ @entries.keys
60
+ end
61
+ end
62
+ end
63
+ end