rigortype 0.0.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 (73) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +373 -0
  3. data/README.md +152 -0
  4. data/exe/rigor +9 -0
  5. data/lib/rigor/analysis/check_rules.rb +503 -0
  6. data/lib/rigor/analysis/diagnostic.rb +35 -0
  7. data/lib/rigor/analysis/fact_store.rb +133 -0
  8. data/lib/rigor/analysis/result.rb +29 -0
  9. data/lib/rigor/analysis/runner.rb +119 -0
  10. data/lib/rigor/ast/type_node.rb +41 -0
  11. data/lib/rigor/ast.rb +22 -0
  12. data/lib/rigor/cli/type_of_command.rb +160 -0
  13. data/lib/rigor/cli/type_of_renderer.rb +88 -0
  14. data/lib/rigor/cli/type_scan_command.rb +160 -0
  15. data/lib/rigor/cli/type_scan_renderer.rb +165 -0
  16. data/lib/rigor/cli/type_scan_report.rb +32 -0
  17. data/lib/rigor/cli.rb +195 -0
  18. data/lib/rigor/configuration.rb +49 -0
  19. data/lib/rigor/environment/class_registry.rb +141 -0
  20. data/lib/rigor/environment/rbs_hierarchy.rb +64 -0
  21. data/lib/rigor/environment/rbs_loader.rb +244 -0
  22. data/lib/rigor/environment.rb +177 -0
  23. data/lib/rigor/inference/acceptance.rb +444 -0
  24. data/lib/rigor/inference/block_parameter_binder.rb +198 -0
  25. data/lib/rigor/inference/closure_escape_analyzer.rb +191 -0
  26. data/lib/rigor/inference/coverage_scanner.rb +85 -0
  27. data/lib/rigor/inference/expression_typer.rb +831 -0
  28. data/lib/rigor/inference/fallback.rb +35 -0
  29. data/lib/rigor/inference/fallback_tracer.rb +64 -0
  30. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +102 -0
  31. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +169 -0
  32. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +421 -0
  33. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +336 -0
  34. data/lib/rigor/inference/method_dispatcher.rb +213 -0
  35. data/lib/rigor/inference/method_parameter_binder.rb +257 -0
  36. data/lib/rigor/inference/multi_target_binder.rb +143 -0
  37. data/lib/rigor/inference/narrowing.rb +1008 -0
  38. data/lib/rigor/inference/rbs_type_translator.rb +219 -0
  39. data/lib/rigor/inference/scope_indexer.rb +468 -0
  40. data/lib/rigor/inference/statement_evaluator.rb +1017 -0
  41. data/lib/rigor/rbs_extended.rb +98 -0
  42. data/lib/rigor/scope.rb +340 -0
  43. data/lib/rigor/source/node_locator.rb +104 -0
  44. data/lib/rigor/source/node_walker.rb +37 -0
  45. data/lib/rigor/source.rb +15 -0
  46. data/lib/rigor/testing.rb +65 -0
  47. data/lib/rigor/trinary.rb +108 -0
  48. data/lib/rigor/type/accepts_result.rb +109 -0
  49. data/lib/rigor/type/bot.rb +57 -0
  50. data/lib/rigor/type/combinator.rb +148 -0
  51. data/lib/rigor/type/constant.rb +90 -0
  52. data/lib/rigor/type/dynamic.rb +60 -0
  53. data/lib/rigor/type/hash_shape.rb +246 -0
  54. data/lib/rigor/type/nominal.rb +83 -0
  55. data/lib/rigor/type/singleton.rb +65 -0
  56. data/lib/rigor/type/top.rb +56 -0
  57. data/lib/rigor/type/tuple.rb +84 -0
  58. data/lib/rigor/type/union.rb +65 -0
  59. data/lib/rigor/type.rb +23 -0
  60. data/lib/rigor/version.rb +5 -0
  61. data/lib/rigor.rb +29 -0
  62. data/sig/rigor/analysis/fact_store.rbs +51 -0
  63. data/sig/rigor/ast.rbs +11 -0
  64. data/sig/rigor/environment.rbs +59 -0
  65. data/sig/rigor/inference.rbs +151 -0
  66. data/sig/rigor/rbs_extended.rbs +22 -0
  67. data/sig/rigor/scope.rbs +49 -0
  68. data/sig/rigor/source.rbs +20 -0
  69. data/sig/rigor/testing.rbs +9 -0
  70. data/sig/rigor/trinary.rbs +29 -0
  71. data/sig/rigor/type.rbs +171 -0
  72. data/sig/rigor.rbs +70 -0
  73. metadata +260 -0
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "type"
4
+
5
+ module Rigor
6
+ # Slice 7 phase 15 — first-preview reader for the
7
+ # `RBS::Extended` annotation surface described in
8
+ # `docs/type-specification/rbs-extended.md`.
9
+ #
10
+ # This module reads `%a{rigor:v1:<directive> <payload>}`
11
+ # annotations off RBS method definitions and returns
12
+ # well-typed effect objects the inference engine can
13
+ # consume. The first preview ships only the **type
14
+ # predicate** directives:
15
+ #
16
+ # - `rigor:v1:predicate-if-true <target> is <ClassName>`
17
+ # - `rigor:v1:predicate-if-false <target> is <ClassName>`
18
+ #
19
+ # Other directives in the spec (`assert`, `assert-if-true`,
20
+ # `assert-if-false`, `param`, `return`, `conforms-to`, ...)
21
+ # are intentionally deferred. Annotations whose key is in
22
+ # the `rigor:v1:` namespace but whose directive is
23
+ # unrecognised are silently ignored at first-preview
24
+ # quality (a future slice MAY surface them as
25
+ # diagnostics-on-Rigor-itself per the spec's "unsupported
26
+ # metadata" guidance).
27
+ #
28
+ # The parser is minimal: it accepts a strict shape
29
+ # `<target> is <ClassName>` where `<target>` is a Ruby
30
+ # identifier (parameter name) or `self`, and `<ClassName>`
31
+ # is a single non-namespaced class identifier or a
32
+ # `::Foo::Bar` style constant path. Negative refinements
33
+ # (`~T`), intersections, and unions are deferred to the
34
+ # next iteration.
35
+ module RbsExtended
36
+ DIRECTIVE_PREFIX = "rigor:v1:"
37
+
38
+ # Returned for `predicate-if-true` / `predicate-if-false`.
39
+ # `target_kind` is `:parameter` (with `target_name` the
40
+ # Ruby parameter symbol) or `:self`.
41
+ PredicateEffect = Data.define(:edge, :target_kind, :target_name, :class_name) do
42
+ def truthy_only? = edge == :truthy_only
43
+ def falsey_only? = edge == :falsey_only
44
+ end
45
+
46
+ module_function
47
+
48
+ # Reads RBS::Extended predicate effects off
49
+ # `RBS::Definition::Method#annotations`. Returns the
50
+ # effects in source order; duplicates and unrecognised
51
+ # `rigor:v1:` directives are dropped. Returns an empty
52
+ # array (NEVER `nil`) for a method with no recognised
53
+ # annotations so callers can iterate unconditionally.
54
+ def read_predicate_effects(method_def)
55
+ return [] if method_def.nil?
56
+
57
+ annotations = method_def.annotations
58
+ return [] if annotations.nil? || annotations.empty?
59
+
60
+ effects = []
61
+ annotations.each do |annotation|
62
+ effect = parse_predicate_annotation(annotation.string)
63
+ effects << effect if effect
64
+ end
65
+ effects.uniq
66
+ end
67
+
68
+ PREDICATE_DIRECTIVE_PATTERN = /
69
+ \A
70
+ rigor:v1:(?<directive>predicate-if-(?:true|false))
71
+ \s+
72
+ (?<target>self|[a-z_][a-zA-Z0-9_]*)
73
+ \s+is\s+
74
+ (?<class_name>(?:::)?[A-Z][A-Za-z0-9_]*(?:::[A-Z][A-Za-z0-9_]*)*)
75
+ \s*
76
+ \z
77
+ /x
78
+ private_constant :PREDICATE_DIRECTIVE_PATTERN
79
+
80
+ def parse_predicate_annotation(string)
81
+ match = PREDICATE_DIRECTIVE_PATTERN.match(string)
82
+ return nil if match.nil?
83
+
84
+ directive = match[:directive].to_s
85
+ target = match[:target].to_s
86
+ class_name = match[:class_name].to_s.sub(/\A::/, "")
87
+ edge = directive == "predicate-if-true" ? :truthy_only : :falsey_only
88
+ target_kind = target == "self" ? :self : :parameter
89
+ target_name = target == "self" ? :self : target.to_sym
90
+ PredicateEffect.new(
91
+ edge: edge,
92
+ target_kind: target_kind,
93
+ target_name: target_name,
94
+ class_name: class_name
95
+ )
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,340 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "type"
4
+ require_relative "environment"
5
+ require_relative "analysis/fact_store"
6
+ require_relative "inference/expression_typer"
7
+ require_relative "inference/statement_evaluator"
8
+
9
+ module Rigor
10
+ # Immutable analyzer scope: holds local-variable bindings and a reference
11
+ # to the surrounding Environment. State changes return new scopes through
12
+ # explicit transition methods (#with_local). The central query is
13
+ # #type_of(node), the Rigor counterpart of PHPStan's
14
+ # $scope->getType($node).
15
+ #
16
+ # See docs/internal-spec/inference-engine.md for the binding contract.
17
+ # rubocop:disable Metrics/ClassLength,Metrics/ParameterLists
18
+ class Scope
19
+ attr_reader :environment, :locals, :fact_store, :self_type, :declared_types,
20
+ :ivars, :cvars, :globals,
21
+ :class_ivars, :class_cvars, :program_globals,
22
+ :discovered_classes, :in_source_constants, :discovered_methods
23
+
24
+ EMPTY_DECLARED_TYPES = {}.compare_by_identity.freeze
25
+ EMPTY_VAR_BINDINGS = {}.freeze
26
+ EMPTY_CLASS_BINDINGS = {}.freeze
27
+ private_constant :EMPTY_DECLARED_TYPES, :EMPTY_VAR_BINDINGS, :EMPTY_CLASS_BINDINGS
28
+
29
+ class << self
30
+ def empty(environment: Environment.default)
31
+ new(environment: environment, locals: {}.freeze, fact_store: Analysis::FactStore.empty)
32
+ end
33
+ end
34
+
35
+ def initialize(
36
+ environment:, locals:,
37
+ fact_store: Analysis::FactStore.empty,
38
+ self_type: nil,
39
+ declared_types: EMPTY_DECLARED_TYPES,
40
+ ivars: EMPTY_VAR_BINDINGS,
41
+ cvars: EMPTY_VAR_BINDINGS,
42
+ globals: EMPTY_VAR_BINDINGS,
43
+ class_ivars: EMPTY_CLASS_BINDINGS,
44
+ class_cvars: EMPTY_CLASS_BINDINGS,
45
+ program_globals: EMPTY_VAR_BINDINGS,
46
+ discovered_classes: EMPTY_VAR_BINDINGS,
47
+ in_source_constants: EMPTY_VAR_BINDINGS,
48
+ discovered_methods: EMPTY_CLASS_BINDINGS
49
+ )
50
+ @environment = environment
51
+ @locals = locals
52
+ @fact_store = fact_store
53
+ @self_type = self_type
54
+ @declared_types = declared_types
55
+ @ivars = ivars
56
+ @cvars = cvars
57
+ @globals = globals
58
+ @class_ivars = class_ivars
59
+ @class_cvars = class_cvars
60
+ @program_globals = program_globals
61
+ @discovered_classes = discovered_classes
62
+ @in_source_constants = in_source_constants
63
+ @discovered_methods = discovered_methods
64
+ freeze
65
+ end
66
+
67
+ def local(name)
68
+ @locals[name.to_sym]
69
+ end
70
+
71
+ def with_local(name, type)
72
+ new_locals = @locals.merge(name.to_sym => type).freeze
73
+ new_fact_store = fact_store.invalidate_target(Analysis::FactStore::Target.local(name))
74
+ rebuild(locals: new_locals, fact_store: new_fact_store)
75
+ end
76
+
77
+ def with_fact(fact)
78
+ rebuild(fact_store: fact_store.with_fact(fact))
79
+ end
80
+
81
+ # Slice A-engine. Returns a scope with `self_type` set to `type`,
82
+ # preserving locals and facts. `StatementEvaluator` injects this
83
+ # at class-body and method-body boundaries; `ExpressionTyper`
84
+ # consults it when typing `Prism::SelfNode` and implicit-self
85
+ # `Prism::CallNode` receivers.
86
+ def with_self_type(type)
87
+ rebuild(self_type: type)
88
+ end
89
+
90
+ # Slice A-declarations. Returns a scope that carries an
91
+ # identity-comparing Hash of `Prism::Node => Rigor::Type`
92
+ # overrides. `ExpressionTyper#type_of(node)` MUST consult
93
+ # `declared_types[node]` before any other dispatch and
94
+ # return the recorded type as-is when present. The table is
95
+ # populated by `ScopeIndexer` for declaration-position
96
+ # nodes (the `constant_path` of `Prism::ModuleNode` and
97
+ # `Prism::ClassNode`) so a `module Foo` / `class Bar`
98
+ # header types as `Singleton[<qualified path>]` instead of
99
+ # falling through to `Dynamic[Top]`. The table is shared
100
+ # by structural reference across every derived scope so
101
+ # `with_local` / `with_fact` / `with_self_type` carry it
102
+ # transparently.
103
+ def with_declared_types(table)
104
+ rebuild(declared_types: table)
105
+ end
106
+
107
+ # Slice 7 phase 1 — instance/class/global variable bindings.
108
+ # `ivar(name)` / `cvar(name)` / `global(name)` return the
109
+ # type currently bound for the named variable, or `nil` when
110
+ # the variable has not been written in the analyzed slice of
111
+ # the program. The first cut tracks bindings only within a
112
+ # single method body (each `def` enters with a fresh binding
113
+ # map), so reads in other methods of the same class fall
114
+ # through to `Dynamic[Top]`. Cross-method ivar/cvar inference
115
+ # is a follow-up slice.
116
+ def ivar(name)
117
+ @ivars[name.to_sym]
118
+ end
119
+
120
+ def cvar(name)
121
+ @cvars[name.to_sym]
122
+ end
123
+
124
+ def global(name)
125
+ @globals[name.to_sym]
126
+ end
127
+
128
+ def with_ivar(name, type)
129
+ rebuild(ivars: @ivars.merge(name.to_sym => type).freeze)
130
+ end
131
+
132
+ def with_cvar(name, type)
133
+ rebuild(cvars: @cvars.merge(name.to_sym => type).freeze)
134
+ end
135
+
136
+ def with_global(name, type)
137
+ rebuild(globals: @globals.merge(name.to_sym => type).freeze)
138
+ end
139
+
140
+ # Slice 7 phase 2 — class-level ivar accumulator. Keyed by
141
+ # the qualified class name (e.g. `"Rigor::Scope"`); the
142
+ # value is a `Hash[Symbol, Type::t]` of every ivar that
143
+ # appears as a write target inside any def body of that
144
+ # class. `StatementEvaluator#build_method_entry_scope`
145
+ # seeds the method body's `ivars` map from this table so a
146
+ # `def get; @x; end` reads the type written in a sibling
147
+ # `def init; @x = 1; end`.
148
+ #
149
+ # `ScopeIndexer` populates the table once at index time
150
+ # through a separate pre-pass over the program. The map is
151
+ # frozen and shared by structural reference across every
152
+ # derived scope.
153
+ def class_ivars_for(class_name)
154
+ return EMPTY_VAR_BINDINGS if class_name.nil?
155
+
156
+ @class_ivars[class_name.to_s] || EMPTY_VAR_BINDINGS
157
+ end
158
+
159
+ def with_class_ivars(table)
160
+ rebuild(class_ivars: table)
161
+ end
162
+
163
+ # Slice 7 phase 6 — class-level cvar accumulator (same shape
164
+ # as `class_ivars` but populated from `Prism::ClassVariableWriteNode`
165
+ # writes, and seeded on BOTH instance and singleton method
166
+ # bodies because Ruby cvars are visible from each).
167
+ def class_cvars_for(class_name)
168
+ return EMPTY_VAR_BINDINGS if class_name.nil?
169
+
170
+ @class_cvars[class_name.to_s] || EMPTY_VAR_BINDINGS
171
+ end
172
+
173
+ def with_class_cvars(table)
174
+ rebuild(class_cvars: table)
175
+ end
176
+
177
+ # Slice 7 phase 6 — program-level globals accumulator.
178
+ # Globals are process-wide in Ruby, so the analyzer carries a
179
+ # single map (`Hash[Symbol, Type]`) keyed by the variable name
180
+ # and seeded into every method body (instance and singleton)
181
+ # plus the top-level program scope. `ScopeIndexer` populates
182
+ # it from a single program-wide pre-pass.
183
+ def with_program_globals(table)
184
+ rebuild(program_globals: table)
185
+ end
186
+
187
+ # Slice 7 phase 7 — in-source class discovery. Maps a
188
+ # qualified class name (e.g. `"Account"`) to its
189
+ # `Type::Singleton` so references to user-defined classes
190
+ # in the analyzed files resolve through
191
+ # `ExpressionTyper#resolve_constant_name` even when no RBS
192
+ # decl exists. Populated once at index time by
193
+ # `ScopeIndexer` from every `Prism::ClassNode` and
194
+ # `Prism::ModuleNode` it walks.
195
+ def with_discovered_classes(table)
196
+ rebuild(discovered_classes: table)
197
+ end
198
+
199
+ # Slice 7 phase 9 — in-source constant-value tracking.
200
+ # Maps a qualified constant name (e.g. `"BUCKETS"` or
201
+ # `"Rigor::Analysis::FactStore::BUCKETS"`) to the type of
202
+ # the rvalue assigned at its `Prism::ConstantWriteNode` /
203
+ # `Prism::ConstantPathWriteNode`. Populated by
204
+ # `ScopeIndexer` once at index time. `ExpressionTyper#resolve_constant_name`
205
+ # consults this map after class lookups so an in-source
206
+ # constant assignment overrides any RBS-declared constant
207
+ # of the same qualified name (matching Ruby's runtime
208
+ # precedence: a constant defined in user code is the
209
+ # authoritative value).
210
+ def with_in_source_constants(table)
211
+ rebuild(in_source_constants: table)
212
+ end
213
+
214
+ # Slice 7 phase 12 — in-source method discovery. Maps a
215
+ # qualified class name to a `Hash[Symbol, Symbol]` of
216
+ # `method_name => :instance | :singleton`. Populated by
217
+ # `ScopeIndexer` from every `Prism::DefNode` and recognised
218
+ # `define_method` invocation inside class/module bodies. The
219
+ # `rigor check` undefined-method and wrong-arity rules
220
+ # consult this map to suppress diagnostics for methods the
221
+ # user has defined dynamically, even when no RBS sig
222
+ # describes them.
223
+ def discovered_method?(class_name, method_name, kind)
224
+ table = @discovered_methods[class_name.to_s]
225
+ return false unless table
226
+
227
+ table[method_name.to_sym] == kind
228
+ end
229
+
230
+ def with_discovered_methods(table)
231
+ rebuild(discovered_methods: table)
232
+ end
233
+
234
+ def facts_for(target: nil, bucket: nil)
235
+ fact_store.facts_for(target: target, bucket: bucket)
236
+ end
237
+
238
+ def local_facts(name, bucket: nil)
239
+ facts_for(target: Analysis::FactStore::Target.local(name), bucket: bucket)
240
+ end
241
+
242
+ def type_of(node, tracer: nil)
243
+ Inference::ExpressionTyper.new(scope: self, tracer: tracer).type_of(node)
244
+ end
245
+
246
+ # Statement-level evaluation: returns the pair `[type, scope']`
247
+ # where `type` is what the node produces and `scope'` is the
248
+ # scope observable after the node has run. The receiver scope is
249
+ # never mutated. See {Rigor::Inference::StatementEvaluator} for
250
+ # the catalogue of nodes that thread scope; everything else
251
+ # defers to {#type_of} and returns the receiver scope unchanged.
252
+ def evaluate(node, tracer: nil)
253
+ Inference::StatementEvaluator.new(scope: self, tracer: tracer).evaluate(node)
254
+ end
255
+
256
+ # Joins this scope with another at a control-flow merge point. The
257
+ # joined scope is bound to every local that BOTH branches bind, with
258
+ # the type widened to the union of both sides. Names bound in only
259
+ # one branch are dropped from the joined scope; the eventual
260
+ # statement-level evaluator (Slice 3 phase 2) is responsible for
261
+ # nil-injecting half-bound names where the language semantics demand
262
+ # it. The two scopes MUST share the same Environment.
263
+ def join(other)
264
+ raise ArgumentError, "join requires a Rigor::Scope, got #{other.class}" unless other.is_a?(Scope)
265
+
266
+ unless environment.equal?(other.environment)
267
+ raise ArgumentError, "join requires both scopes to share the same Environment"
268
+ end
269
+
270
+ joined_locals = join_bindings(locals, other.locals)
271
+ joined_ivars = join_bindings(ivars, other.ivars)
272
+ joined_cvars = join_bindings(cvars, other.cvars)
273
+ joined_globals = join_bindings(globals, other.globals)
274
+ build_joined_scope(joined_locals, joined_ivars, joined_cvars, joined_globals, other)
275
+ end
276
+
277
+ def ==(other) # rubocop:disable Metrics/CyclomaticComplexity
278
+ other.is_a?(Scope) &&
279
+ environment.equal?(other.environment) &&
280
+ @locals == other.locals &&
281
+ fact_store == other.fact_store &&
282
+ self_type == other.self_type &&
283
+ @ivars == other.ivars &&
284
+ @cvars == other.cvars &&
285
+ @globals == other.globals
286
+ end
287
+ alias eql? ==
288
+
289
+ def hash
290
+ [Scope, environment.object_id, @locals, fact_store, self_type, @ivars, @cvars, @globals].hash
291
+ end
292
+
293
+ private
294
+
295
+ def rebuild(
296
+ locals: @locals, fact_store: @fact_store, self_type: @self_type,
297
+ declared_types: @declared_types, ivars: @ivars, cvars: @cvars, globals: @globals,
298
+ class_ivars: @class_ivars, class_cvars: @class_cvars, program_globals: @program_globals,
299
+ discovered_classes: @discovered_classes, in_source_constants: @in_source_constants,
300
+ discovered_methods: @discovered_methods
301
+ )
302
+ self.class.new(
303
+ environment: environment, locals: locals,
304
+ fact_store: fact_store, self_type: self_type,
305
+ declared_types: declared_types,
306
+ ivars: ivars, cvars: cvars, globals: globals,
307
+ class_ivars: class_ivars, class_cvars: class_cvars,
308
+ program_globals: program_globals,
309
+ discovered_classes: discovered_classes,
310
+ in_source_constants: in_source_constants,
311
+ discovered_methods: discovered_methods
312
+ )
313
+ end
314
+
315
+ def join_bindings(left, right)
316
+ shared = left.keys & right.keys
317
+ shared.to_h { |name| [name, Type::Combinator.union(left[name], right[name])] }.freeze
318
+ end
319
+
320
+ def build_joined_scope(joined_locals, joined_ivars, joined_cvars, joined_globals, other)
321
+ self.class.new(
322
+ environment: environment,
323
+ locals: joined_locals.freeze,
324
+ fact_store: fact_store.join(other.fact_store),
325
+ self_type: self_type == other.self_type ? self_type : nil,
326
+ declared_types: declared_types,
327
+ ivars: joined_ivars,
328
+ cvars: joined_cvars,
329
+ globals: joined_globals,
330
+ class_ivars: class_ivars,
331
+ class_cvars: class_cvars,
332
+ program_globals: program_globals,
333
+ discovered_classes: discovered_classes,
334
+ in_source_constants: in_source_constants,
335
+ discovered_methods: discovered_methods
336
+ )
337
+ end
338
+ end
339
+ # rubocop:enable Metrics/ClassLength,Metrics/ParameterLists
340
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Rigor
6
+ module Source
7
+ # Locates the deepest Prism AST node enclosing a given source position.
8
+ #
9
+ # The locator works on byte offsets internally so that multibyte source
10
+ # text behaves consistently with Prism, which itself reports offsets in
11
+ # bytes. Convenience constructors translate from `(line, column)` pairs.
12
+ #
13
+ # Lines are 1-indexed (matching editor / Prism / gcc conventions).
14
+ # Columns are 1-indexed when supplied via the `(line, column)` API; this
15
+ # matches the canonical `file.rb:line:col` form most tools emit. Internal
16
+ # offsets remain 0-indexed bytes.
17
+ #
18
+ # The locator is read-only: a single instance binds to one source buffer
19
+ # and AST root, and queries are pure functions of the byte offset.
20
+ class NodeLocator
21
+ class OutOfRangeError < StandardError; end
22
+
23
+ class << self
24
+ # @param source [String]
25
+ # @param root [Prism::Node]
26
+ # @param line [Integer] 1-indexed line number
27
+ # @param column [Integer] 1-indexed column number (byte index within the line)
28
+ # @return [Prism::Node, nil]
29
+ def at_position(source:, root:, line:, column:)
30
+ new(source: source, root: root).at_position(line: line, column: column)
31
+ end
32
+
33
+ # @param root [Prism::Node]
34
+ # @param offset [Integer] 0-indexed byte offset
35
+ # @return [Prism::Node, nil]
36
+ def at_offset(root:, offset:)
37
+ new(source: nil, root: root).at_offset(offset)
38
+ end
39
+ end
40
+
41
+ # @param source [String, nil] used by `#at_position`; may be omitted when only `#at_offset` is needed.
42
+ # @param root [Prism::Node]
43
+ def initialize(source:, root:)
44
+ @source = source
45
+ @root = root
46
+ end
47
+
48
+ # Resolve a `(line, column)` pair (1-indexed) to the deepest enclosing node.
49
+ #
50
+ # @raise [OutOfRangeError] if the line or column falls outside the source buffer.
51
+ def at_position(line:, column:)
52
+ offset = position_to_offset(line, column)
53
+ at_offset(offset)
54
+ end
55
+
56
+ # Resolve a byte offset (0-indexed) to the deepest enclosing node.
57
+ def at_offset(offset)
58
+ descend(@root, offset)
59
+ end
60
+
61
+ # Translate a `(line, column)` pair into a 0-indexed byte offset for the
62
+ # bound source buffer.
63
+ def position_to_offset(line, column)
64
+ raise ArgumentError, "source buffer required for position lookup" if @source.nil?
65
+ raise OutOfRangeError, "line must be >= 1, got #{line}" if line < 1
66
+ raise OutOfRangeError, "column must be >= 1, got #{column}" if column < 1
67
+
68
+ offset = 0
69
+ current_line = 1
70
+ @source.each_line do |chunk|
71
+ break if current_line == line
72
+
73
+ offset += chunk.bytesize
74
+ current_line += 1
75
+ end
76
+
77
+ raise OutOfRangeError, "line #{line} is past the end of the source buffer" if current_line != line
78
+
79
+ offset + (column - 1)
80
+ end
81
+
82
+ private
83
+
84
+ def descend(node, offset)
85
+ return nil unless node.is_a?(Prism::Node)
86
+ return nil unless contains?(node, offset)
87
+
88
+ node.compact_child_nodes.each do |child|
89
+ deeper = descend(child, offset)
90
+ return deeper if deeper
91
+ end
92
+
93
+ node
94
+ end
95
+
96
+ def contains?(node, offset)
97
+ location = node.location
98
+ return false if location.nil?
99
+
100
+ location.start_offset <= offset && offset < location.end_offset
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Rigor
6
+ module Source
7
+ # Yields every `Prism::Node` reachable from a root in DFS pre-order.
8
+ #
9
+ # The walker is the source-positioning analogue to `NodeLocator`: where the
10
+ # locator answers "what node is at this point?", the walker enumerates the
11
+ # full set of Prism nodes for tooling that needs to operate on each one
12
+ # (coverage probes, lint passes, IDE outlines).
13
+ #
14
+ # Non-Prism children (literals embedded in node attributes, virtual nodes,
15
+ # or `nil` slots) are silently skipped so callers can rely on every yielded
16
+ # value responding to the `Prism::Node` API.
17
+ module NodeWalker
18
+ module_function
19
+
20
+ # @yieldparam node [Prism::Node]
21
+ # @return [Enumerator] when no block is given.
22
+ def each(root, &)
23
+ return to_enum(__method__, root) unless block_given?
24
+
25
+ walk(root, &)
26
+ nil
27
+ end
28
+
29
+ def walk(node, &)
30
+ return unless node.is_a?(Prism::Node)
31
+
32
+ yield node
33
+ node.compact_child_nodes.each { |child| walk(child, &) }
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Source-text and AST positioning utilities.
4
+ #
5
+ # Anything that maps between a Ruby source buffer and Prism AST nodes belongs
6
+ # here. The contents of this namespace deliberately stay independent of the
7
+ # inference engine so that future tooling (LSP, refactoring helpers, doc
8
+ # extractors) can reuse the same primitives without dragging in `Rigor::Type`.
9
+ module Rigor
10
+ module Source
11
+ end
12
+ end
13
+
14
+ require_relative "source/node_locator"
15
+ require_relative "source/node_walker"
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ # Slice 7 phase 19 — PHPStan-style typing helpers.
5
+ #
6
+ # `Rigor::Testing` ships two runtime no-op helpers that serve
7
+ # as anchors for static-analysis diagnostics:
8
+ #
9
+ # - `dump_type(value)` — returns `value` unchanged at runtime.
10
+ # The Rigor analyzer surfaces an `:info`-severity diagnostic
11
+ # at the call site showing the inferred type of `value` so
12
+ # the user can see what the engine sees at that program point.
13
+ # - `assert_type(expected, value)` — returns `value` unchanged
14
+ # at runtime. The analyzer compares `value`'s inferred type
15
+ # (rendered through `Rigor::Type#describe(:short)`) against
16
+ # the literal `expected` String; a mismatch produces an
17
+ # `:error`-severity diagnostic. This lets a user-written
18
+ # fixture be self-asserting: `rigor check fixture.rb` exits
19
+ # non-zero exactly when the engine's inference drifts from
20
+ # what the fixture documents.
21
+ #
22
+ # Three usage shapes are recognised by the static rules:
23
+ #
24
+ # require "rigor/testing"
25
+ # include Rigor::Testing
26
+ # dump_type(x)
27
+ # assert_type("Constant[1]", x)
28
+ #
29
+ # ... or fully qualified:
30
+ #
31
+ # Rigor::Testing.dump_type(x)
32
+ # Rigor::Testing.assert_type("String | nil", x)
33
+ #
34
+ # ... or via the convenience top-level alias `Rigor` itself:
35
+ #
36
+ # Rigor.dump_type(x)
37
+ # Rigor.assert_type("Constant[\"hello\"]", x)
38
+ #
39
+ # All three resolve to the same no-op runtime body, so a
40
+ # fixture may freely run under MRI without depending on the
41
+ # analyzer being present.
42
+ module Testing
43
+ module_function
44
+
45
+ def dump_type(value)
46
+ value
47
+ end
48
+
49
+ def assert_type(_expected, value)
50
+ value
51
+ end
52
+ end
53
+
54
+ class << self
55
+ # Convenience aliases on `Rigor` itself, so fixtures can
56
+ # write `Rigor.dump_type(x)` without an `include` line.
57
+ def dump_type(value)
58
+ Testing.dump_type(value)
59
+ end
60
+
61
+ def assert_type(expected, value)
62
+ Testing.assert_type(expected, value)
63
+ end
64
+ end
65
+ end