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.
- checksums.yaml +4 -4
- data/README.md +76 -79
- data/lib/rigor/analysis/baseline.rb +347 -0
- data/lib/rigor/analysis/buffer_binding.rb +36 -0
- data/lib/rigor/analysis/check_rules.rb +68 -3
- data/lib/rigor/analysis/dependency_source_inference/index.rb +14 -1
- data/lib/rigor/analysis/dependency_source_inference/return_type_heuristic.rb +105 -0
- data/lib/rigor/analysis/dependency_source_inference/walker.rb +32 -12
- data/lib/rigor/analysis/project_scan.rb +39 -0
- data/lib/rigor/analysis/runner.rb +309 -22
- data/lib/rigor/analysis/worker_session.rb +14 -2
- data/lib/rigor/builtins/hkt_builtins.rb +342 -0
- data/lib/rigor/builtins/static_return_refinements.rb +142 -0
- data/lib/rigor/cache/store.rb +33 -3
- data/lib/rigor/cli/baseline_command.rb +377 -0
- data/lib/rigor/cli/lsp_command.rb +129 -0
- data/lib/rigor/cli/type_of_command.rb +44 -5
- data/lib/rigor/cli.rb +142 -13
- data/lib/rigor/configuration.rb +58 -2
- data/lib/rigor/environment/hkt_registry_holder.rb +33 -0
- data/lib/rigor/environment/rbs_coverage_report.rb +1 -1
- data/lib/rigor/environment/rbs_loader.rb +67 -2
- data/lib/rigor/environment/reporters.rb +40 -0
- data/lib/rigor/environment.rb +119 -9
- data/lib/rigor/flow_contribution/fact.rb +20 -10
- data/lib/rigor/inference/acceptance.rb +48 -3
- data/lib/rigor/inference/expression_typer.rb +64 -2
- data/lib/rigor/inference/hkt_body.rb +171 -0
- data/lib/rigor/inference/hkt_body_parser.rb +363 -0
- data/lib/rigor/inference/hkt_reducer.rb +256 -0
- data/lib/rigor/inference/hkt_registry.rb +223 -0
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +125 -30
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +32 -11
- data/lib/rigor/inference/method_dispatcher/receiver_affinity.rb +87 -0
- data/lib/rigor/inference/method_dispatcher.rb +174 -6
- data/lib/rigor/inference/narrowing.rb +103 -1
- data/lib/rigor/inference/project_patched_methods.rb +70 -0
- data/lib/rigor/inference/project_patched_scanner.rb +210 -0
- data/lib/rigor/inference/scope_indexer.rb +209 -19
- data/lib/rigor/inference/statement_evaluator.rb +172 -11
- data/lib/rigor/inference/synthetic_method_scanner.rb +94 -16
- data/lib/rigor/language_server/buffer_table.rb +63 -0
- data/lib/rigor/language_server/completion_provider.rb +438 -0
- data/lib/rigor/language_server/debouncer.rb +86 -0
- data/lib/rigor/language_server/diagnostic_publisher.rb +167 -0
- data/lib/rigor/language_server/document_symbol_provider.rb +142 -0
- data/lib/rigor/language_server/folding_range_provider.rb +75 -0
- data/lib/rigor/language_server/hover_provider.rb +74 -0
- data/lib/rigor/language_server/hover_renderer.rb +312 -0
- data/lib/rigor/language_server/loop.rb +71 -0
- data/lib/rigor/language_server/project_context.rb +145 -0
- data/lib/rigor/language_server/selection_range_provider.rb +93 -0
- data/lib/rigor/language_server/server.rb +384 -0
- data/lib/rigor/language_server/signature_help_provider.rb +249 -0
- data/lib/rigor/language_server/synchronized_writer.rb +28 -0
- data/lib/rigor/language_server/uri.rb +40 -0
- data/lib/rigor/language_server.rb +29 -0
- data/lib/rigor/plugin/base.rb +63 -0
- data/lib/rigor/plugin/macro/heredoc_template.rb +127 -13
- data/lib/rigor/plugin/macro/trait_registry.rb +1 -1
- data/lib/rigor/plugin/manifest.rb +54 -7
- data/lib/rigor/plugin/registry.rb +19 -0
- data/lib/rigor/rbs_extended/hkt_directives.rb +326 -0
- data/lib/rigor/rbs_extended.rb +82 -2
- data/lib/rigor/sig_gen/generator.rb +12 -3
- data/lib/rigor/type/app.rb +107 -0
- data/lib/rigor/type.rb +1 -0
- data/lib/rigor/version.rb +1 -1
- data/sig/rigor/environment.rbs +10 -4
- data/sig/rigor/inference.rbs +2 -0
- data/sig/rigor.rbs +4 -1
- metadata +56 -1
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "hkt_body"
|
|
4
|
+
require_relative "../type"
|
|
5
|
+
|
|
6
|
+
module Rigor
|
|
7
|
+
module Inference
|
|
8
|
+
# ADR-20 slice 2b — parses the body of an
|
|
9
|
+
# `HktRegistry::Definition` (a `String`, as populated by
|
|
10
|
+
# Slice 1's `HktDirectives.parse_define` from
|
|
11
|
+
# `%a{rigor:v1:hkt_define}` payloads) into the `HktBody`
|
|
12
|
+
# node tree the Slice 2a reducer evaluates against.
|
|
13
|
+
#
|
|
14
|
+
# The minimum-viable grammar covered here is the
|
|
15
|
+
# union-of-atoms-and-parameterised-forms subset of ADR-20
|
|
16
|
+
# § D3 — sufficient for `JSON.parse`'s `json::value`
|
|
17
|
+
# recursive sum and for any other recursive-data-shape
|
|
18
|
+
# signatures (Lisp value trees, dry-types refinements
|
|
19
|
+
# without conditionals). The conditional / indexed-access
|
|
20
|
+
# forms (`E <: T ? A : B`, `E in [k1, k2]`) drafted in D3
|
|
21
|
+
# remain a follow-up slice — bodies that contain `?`
|
|
22
|
+
# raise `ParseError` and the calling directive parser
|
|
23
|
+
# drops the body_tree (the body String remains stored and
|
|
24
|
+
# the reducer falls back to `app.bound`).
|
|
25
|
+
#
|
|
26
|
+
# ## Grammar (slice 2b)
|
|
27
|
+
#
|
|
28
|
+
# body := union
|
|
29
|
+
# union := type_expr ("|" type_expr)*
|
|
30
|
+
# type_expr := atom | nominal_app | app_ref | param
|
|
31
|
+
# atom := "nil" | "true" | "false" | "bool" | "untyped"
|
|
32
|
+
# param := UCNAME (when UCNAME ∈ params)
|
|
33
|
+
# nominal_app := class_name ("[" type_expr ("," type_expr)* "]")?
|
|
34
|
+
# class_name := "::"? UCNAME ("::" UCNAME)*
|
|
35
|
+
# app_ref := "App" "[" uri "," type_expr ("," type_expr)* "]"
|
|
36
|
+
# uri := IDENT ("::" IDENT)+
|
|
37
|
+
# UCNAME := /[A-Z]\w*/
|
|
38
|
+
# IDENT := /[a-z_]\w*/
|
|
39
|
+
#
|
|
40
|
+
# ## Disambiguation
|
|
41
|
+
#
|
|
42
|
+
# The same syntactic UCNAME spells both a parameter
|
|
43
|
+
# reference (`K` when `params = [:K]`) and a nominal class
|
|
44
|
+
# name (`Integer`). The parser resolves by checking the
|
|
45
|
+
# `params` set passed to {.parse}; an unknown UCNAME is
|
|
46
|
+
# treated as a nominal class name. `App` is reserved at
|
|
47
|
+
# the head position of an `App[...]` form; using `App` as
|
|
48
|
+
# a class name is therefore not supported.
|
|
49
|
+
#
|
|
50
|
+
# Atoms are kept verbatim as `HktBody::TypeLeaf` nodes
|
|
51
|
+
# wrapping the appropriate `Rigor::Type::*` carrier:
|
|
52
|
+
# `nil` / `true` / `false` produce `Constant` carriers;
|
|
53
|
+
# `bool` produces the `Constant<true> | Constant<false>`
|
|
54
|
+
# union; `untyped` produces `Combinator.untyped`
|
|
55
|
+
# (i.e. `Dynamic[Top]`). Nominal class names produce raw
|
|
56
|
+
# `Type::Nominal` carriers (no `name_scope` resolution at
|
|
57
|
+
# this slice — the reducer trusts the name verbatim).
|
|
58
|
+
module HktBodyParser
|
|
59
|
+
class ParseError < StandardError; end
|
|
60
|
+
|
|
61
|
+
module_function
|
|
62
|
+
|
|
63
|
+
def parse(string, params:)
|
|
64
|
+
raise ArgumentError, "string must be a String, got #{string.class}" unless string.is_a?(String)
|
|
65
|
+
raise ArgumentError, "params must be an Array, got #{params.class}" unless params.is_a?(Array)
|
|
66
|
+
|
|
67
|
+
params_set = params.to_h { |p| [p.to_sym, true] }
|
|
68
|
+
tokens = Tokenizer.new(string).tokenize
|
|
69
|
+
parser = Parser.new(tokens, params_set)
|
|
70
|
+
result = parser.parse_union
|
|
71
|
+
parser.expect_eof!
|
|
72
|
+
result
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
Token = Data.define(:kind, :value, :pos)
|
|
76
|
+
|
|
77
|
+
class Tokenizer
|
|
78
|
+
SCANNER_REGEX = /
|
|
79
|
+
\G
|
|
80
|
+
(?:
|
|
81
|
+
(?<ws>\s+)
|
|
82
|
+
| (?<lb>\[)
|
|
83
|
+
| (?<rb>\])
|
|
84
|
+
| (?<lparen>\()
|
|
85
|
+
| (?<rparen>\))
|
|
86
|
+
| (?<comma>,)
|
|
87
|
+
| (?<pipe>\|)
|
|
88
|
+
| (?<sub><:)
|
|
89
|
+
| (?<eq>==)
|
|
90
|
+
| (?<sep>::)
|
|
91
|
+
| (?<colon>:(?!:))
|
|
92
|
+
| (?<question>\?)
|
|
93
|
+
| (?<ident>[a-z_][a-zA-Z0-9_]*)
|
|
94
|
+
| (?<ucname>[A-Z][a-zA-Z0-9_]*)
|
|
95
|
+
)
|
|
96
|
+
/x
|
|
97
|
+
|
|
98
|
+
def initialize(string)
|
|
99
|
+
@string = string
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
TOKEN_KINDS = SCANNER_REGEX.named_captures.keys.freeze
|
|
103
|
+
private_constant :TOKEN_KINDS
|
|
104
|
+
|
|
105
|
+
def tokenize
|
|
106
|
+
tokens = []
|
|
107
|
+
pos = 0
|
|
108
|
+
while pos < @string.size
|
|
109
|
+
match = SCANNER_REGEX.match(@string, pos)
|
|
110
|
+
raise ParseError, "unexpected character at position #{pos}: #{@string[pos].inspect}" if match.nil?
|
|
111
|
+
|
|
112
|
+
kind = TOKEN_KINDS.find { |k| match[k] }
|
|
113
|
+
raise ParseError, "internal tokenizer error at position #{pos}" if kind.nil?
|
|
114
|
+
|
|
115
|
+
value = match[kind.to_sym]
|
|
116
|
+
raise ParseError, "internal tokenizer error: no match for #{kind}" if value.nil?
|
|
117
|
+
|
|
118
|
+
pos += value.size
|
|
119
|
+
next if kind == "ws"
|
|
120
|
+
|
|
121
|
+
tokens << Token.new(kind: kind.to_sym, value: value, pos: pos - value.size)
|
|
122
|
+
end
|
|
123
|
+
tokens
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
class Parser
|
|
128
|
+
def initialize(tokens, params_set)
|
|
129
|
+
@tokens = tokens
|
|
130
|
+
@pos = 0
|
|
131
|
+
@params_set = params_set
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def parse_union
|
|
135
|
+
arms = [parse_type_expr]
|
|
136
|
+
while peek_kind == :pipe
|
|
137
|
+
consume
|
|
138
|
+
arms << parse_type_expr
|
|
139
|
+
end
|
|
140
|
+
return arms.first if arms.size == 1
|
|
141
|
+
|
|
142
|
+
HktBody::Union.new(arms: arms)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def parse_type_expr
|
|
146
|
+
tok = peek
|
|
147
|
+
raise ParseError, "unexpected end of input; expected type expression" if tok.nil?
|
|
148
|
+
|
|
149
|
+
case tok.kind
|
|
150
|
+
when :lparen then parse_conditional
|
|
151
|
+
when :ident then parse_lowercase_atom
|
|
152
|
+
when :ucname then parse_ucname_form
|
|
153
|
+
when :sep then parse_classname_with_leading_sep
|
|
154
|
+
else
|
|
155
|
+
raise ParseError, "unexpected token #{tok.kind} (#{tok.value.inspect}) at position #{tok.pos}"
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# ADR-20 § D3 conditional parser. Grammar:
|
|
160
|
+
#
|
|
161
|
+
# conditional := "(" test "?" union ":" union ")"
|
|
162
|
+
# test := type_expr ("<:" | "==") type_expr
|
|
163
|
+
#
|
|
164
|
+
# Parens delimit a conditional unambiguously — bare
|
|
165
|
+
# `(type_expr)` grouping is not supported at this slice
|
|
166
|
+
# (no use case yet). Branches can be unions; test sides
|
|
167
|
+
# are single arms (wrap in `App[my_union, ...]` if you
|
|
168
|
+
# need a union there). `in [opt1, opt2]` membership
|
|
169
|
+
# tests are programmatically supported via
|
|
170
|
+
# `HktBody::TestMembership` but the parser does not yet
|
|
171
|
+
# recognise the `in` keyword (no concrete demand yet).
|
|
172
|
+
def parse_conditional
|
|
173
|
+
expect!(:lparen)
|
|
174
|
+
test = parse_test
|
|
175
|
+
expect!(:question)
|
|
176
|
+
then_branch = parse_union
|
|
177
|
+
expect!(:colon)
|
|
178
|
+
else_branch = parse_union
|
|
179
|
+
expect!(:rparen)
|
|
180
|
+
HktBody::Conditional.new(test: test, then_branch: then_branch, else_branch: else_branch)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def parse_test
|
|
184
|
+
left = parse_type_expr
|
|
185
|
+
op = peek
|
|
186
|
+
case op&.kind
|
|
187
|
+
when :sub
|
|
188
|
+
consume
|
|
189
|
+
HktBody::TestSubtype.new(left: left, right: parse_type_expr)
|
|
190
|
+
when :eq
|
|
191
|
+
consume
|
|
192
|
+
HktBody::TestEquality.new(left: left, right: parse_type_expr)
|
|
193
|
+
when :ident
|
|
194
|
+
parse_in_membership(left, op_token: op)
|
|
195
|
+
else
|
|
196
|
+
actual = op.nil? ? "end of input" : "#{op.kind} (#{op.value.inspect})"
|
|
197
|
+
raise ParseError, "expected `<:`, `==`, or `in` in conditional test, got #{actual}"
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# `left in [opt1, opt2, ...]` membership test.
|
|
202
|
+
# Distinguished from a lowercase atom by the
|
|
203
|
+
# subsequent `[` — the only place an identifier
|
|
204
|
+
# `in` is permitted at this position is membership
|
|
205
|
+
# syntax.
|
|
206
|
+
def parse_in_membership(left, op_token:)
|
|
207
|
+
unless op_token.value == "in"
|
|
208
|
+
raise ParseError,
|
|
209
|
+
"expected `<:`, `==`, or `in` in conditional test, got ident (#{op_token.value.inspect})"
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
consume # in
|
|
213
|
+
expect!(:lb)
|
|
214
|
+
options = [parse_type_expr]
|
|
215
|
+
while peek_kind == :comma
|
|
216
|
+
consume
|
|
217
|
+
options << parse_type_expr
|
|
218
|
+
end
|
|
219
|
+
expect!(:rb)
|
|
220
|
+
HktBody::TestMembership.new(left: left, options: options)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def parse_lowercase_atom
|
|
224
|
+
tok = consume
|
|
225
|
+
type = case tok.value
|
|
226
|
+
when "nil" then Type::Constant.new(nil)
|
|
227
|
+
when "true" then Type::Constant.new(true)
|
|
228
|
+
when "false" then Type::Constant.new(false)
|
|
229
|
+
when "bool" then Type::Combinator.union(Type::Constant.new(true), Type::Constant.new(false))
|
|
230
|
+
when "untyped" then Type::Combinator.untyped
|
|
231
|
+
else raise ParseError, "unknown atom #{tok.value.inspect} at position #{tok.pos}"
|
|
232
|
+
end
|
|
233
|
+
HktBody::TypeLeaf.new(type: type)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def parse_ucname_form
|
|
237
|
+
tok = peek
|
|
238
|
+
return parse_app_ref if tok.value == "App"
|
|
239
|
+
|
|
240
|
+
if @params_set.key?(tok.value.to_sym) && !class_continuation?
|
|
241
|
+
consume
|
|
242
|
+
return HktBody::Param.new(name: tok.value.to_sym)
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
parse_nominal_or_param_with_args
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Returns true when the current UCName is followed by
|
|
249
|
+
# `::` (qualified class name continuation) or `[`
|
|
250
|
+
# (parameterised application). In either case the
|
|
251
|
+
# token is a nominal, not a param ref — Slice 2b's
|
|
252
|
+
# `Param` nodes are always single bare identifiers.
|
|
253
|
+
def class_continuation?
|
|
254
|
+
next_tok = @tokens[@pos + 1]
|
|
255
|
+
next_tok && %i[sep lb].include?(next_tok.kind)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def parse_nominal_or_param_with_args
|
|
259
|
+
class_name = parse_class_name
|
|
260
|
+
if peek_kind == :lb
|
|
261
|
+
consume
|
|
262
|
+
args = parse_arg_list
|
|
263
|
+
expect!(:rb)
|
|
264
|
+
HktBody::NominalApp.new(class_name: class_name, args: args)
|
|
265
|
+
else
|
|
266
|
+
HktBody::TypeLeaf.new(type: Type::Nominal.new(class_name))
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def parse_classname_with_leading_sep
|
|
271
|
+
# The leading "::" form (`::Foo::Bar`). Consume the
|
|
272
|
+
# separator so the rest threads through parse_class_name.
|
|
273
|
+
consume
|
|
274
|
+
tok = peek
|
|
275
|
+
raise ParseError, "expected class name after `::`" if tok.nil? || tok.kind != :ucname
|
|
276
|
+
|
|
277
|
+
parse_nominal_or_param_with_args
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def parse_class_name
|
|
281
|
+
parts = [expect!(:ucname).value]
|
|
282
|
+
while peek_kind == :sep && @tokens[@pos + 1]&.kind == :ucname
|
|
283
|
+
consume # ::
|
|
284
|
+
parts << expect!(:ucname).value
|
|
285
|
+
end
|
|
286
|
+
parts.join("::")
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def parse_app_ref
|
|
290
|
+
tok = consume
|
|
291
|
+
raise ParseError, "expected `App[...]`, got #{tok.value.inspect}" unless tok.value == "App"
|
|
292
|
+
|
|
293
|
+
expect!(:lb)
|
|
294
|
+
uri = parse_uri
|
|
295
|
+
expect!(:comma)
|
|
296
|
+
args = parse_arg_list
|
|
297
|
+
expect!(:rb)
|
|
298
|
+
HktBody::AppRef.new(uri: uri, args: args)
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def parse_uri
|
|
302
|
+
parts = [expect!(:ident).value]
|
|
303
|
+
while peek_kind == :sep
|
|
304
|
+
consume
|
|
305
|
+
parts << expect!(:ident).value
|
|
306
|
+
end
|
|
307
|
+
raise ParseError, "uri must be namespaced (`a::b`), got #{parts.first.inspect}" if parts.size < 2
|
|
308
|
+
|
|
309
|
+
parts.join("::").to_sym
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# Arg list for `Foo[A, B, C]` and `App[uri, A, B]`
|
|
313
|
+
# forms. Each arg is parsed as a union so per-arg
|
|
314
|
+
# `A | B` forms work (`Array[K | nil]`); the COMMA
|
|
315
|
+
# at the top level still separates args, so
|
|
316
|
+
# `Hash[K, V]` reads as two args (each a single-arm
|
|
317
|
+
# union that collapses to the arm) rather than one
|
|
318
|
+
# union of two.
|
|
319
|
+
def parse_arg_list
|
|
320
|
+
args = [parse_union]
|
|
321
|
+
while peek_kind == :comma
|
|
322
|
+
consume
|
|
323
|
+
args << parse_union
|
|
324
|
+
end
|
|
325
|
+
args
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def expect_eof!
|
|
329
|
+
return if @pos >= @tokens.size
|
|
330
|
+
|
|
331
|
+
tok = @tokens[@pos]
|
|
332
|
+
raise ParseError, "expected end of input, got #{tok.kind} (#{tok.value.inspect}) at position #{tok.pos}"
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
private
|
|
336
|
+
|
|
337
|
+
def peek
|
|
338
|
+
@tokens[@pos]
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def peek_kind
|
|
342
|
+
@tokens[@pos]&.kind
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def consume
|
|
346
|
+
tok = @tokens[@pos]
|
|
347
|
+
@pos += 1
|
|
348
|
+
tok
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def expect!(kind)
|
|
352
|
+
tok = @tokens[@pos]
|
|
353
|
+
if tok.nil? || tok.kind != kind
|
|
354
|
+
actual = tok.nil? ? "end of input" : "#{tok.kind} (#{tok.value.inspect})"
|
|
355
|
+
raise ParseError, "expected #{kind}, got #{actual}"
|
|
356
|
+
end
|
|
357
|
+
@pos += 1
|
|
358
|
+
tok
|
|
359
|
+
end
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
end
|
|
363
|
+
end
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "hkt_body"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
module Inference
|
|
7
|
+
# ADR-20 Slice 2a — reducer that walks a `Definition`'s
|
|
8
|
+
# `body_tree` against a concrete `Type::App` and returns a
|
|
9
|
+
# fully-typed `Rigor::Type`.
|
|
10
|
+
#
|
|
11
|
+
# Reduction is the operational interpretation of ADR-20
|
|
12
|
+
# § D4 ("Evaluation rules"):
|
|
13
|
+
#
|
|
14
|
+
# 1. **Resolve `F`.** Look up the registered body via
|
|
15
|
+
# `registry.definition(uri)`.
|
|
16
|
+
# 2. **Substitute arguments.** Walk the body tree, replacing
|
|
17
|
+
# `{HktBody::Param}` nodes with the matching positional
|
|
18
|
+
# arg from the application.
|
|
19
|
+
# 3. **Build types.** `{HktBody::TypeLeaf}` returns its
|
|
20
|
+
# wrapped type as-is; `{HktBody::Union}` and
|
|
21
|
+
# `{HktBody::NominalApp}` route their reduced children
|
|
22
|
+
# through `Type::Combinator.union` / `.nominal_of` so
|
|
23
|
+
# normalization applies.
|
|
24
|
+
# 4. **Recurse on `{HktBody::AppRef}` nodes.** Reduce the
|
|
25
|
+
# args first; if the resulting `(uri, args)` matches an
|
|
26
|
+
# App already on the current reduction stack, return the
|
|
27
|
+
# in-progress `Type::App` carrier as-is (lazy
|
|
28
|
+
# self-reference handling — the standard "tying the
|
|
29
|
+
# knot" trick for recursive type aliases like
|
|
30
|
+
# `json::value`). Otherwise build a fresh
|
|
31
|
+
# `Type::App` and recursively reduce it against the
|
|
32
|
+
# same registry, sharing the fuel budget.
|
|
33
|
+
# 5. **Fuel budget.** Each visited node consumes one unit.
|
|
34
|
+
# On exhaustion, reduction unwinds to `app.bound`.
|
|
35
|
+
#
|
|
36
|
+
# The reducer is **pure** with respect to its inputs (the
|
|
37
|
+
# registry + the App) but uses a per-call mutable state
|
|
38
|
+
# bag for fuel + cycle tracking. Concurrent reductions
|
|
39
|
+
# MUST allocate fresh reducers (or fresh `_reduce` calls)
|
|
40
|
+
# — the per-call state is not shared.
|
|
41
|
+
class HktReducer
|
|
42
|
+
DEFAULT_FUEL = 64
|
|
43
|
+
|
|
44
|
+
class FuelExhausted < StandardError; end
|
|
45
|
+
|
|
46
|
+
def initialize(registry)
|
|
47
|
+
raise ArgumentError, "registry must be an HktRegistry" unless registry.is_a?(HktRegistry)
|
|
48
|
+
|
|
49
|
+
@registry = registry
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Reduce `app` against the registry.
|
|
53
|
+
#
|
|
54
|
+
# @param app [Rigor::Type::App]
|
|
55
|
+
# @param fuel [Integer] reduction-step budget (default 64
|
|
56
|
+
# per ADR-20 WD3). Each visited body node costs one
|
|
57
|
+
# unit. On exhaustion the reduction returns `app.bound`.
|
|
58
|
+
# @return [Rigor::Type] the reduced type, or `app.bound`
|
|
59
|
+
# when reduction is impossible (URI not defined, arity
|
|
60
|
+
# mismatch, body_tree absent, fuel exhausted).
|
|
61
|
+
def reduce(app, fuel: DEFAULT_FUEL)
|
|
62
|
+
raise ArgumentError, "expected a Rigor::Type::App, got #{app.class}" unless app.is_a?(Type::App)
|
|
63
|
+
|
|
64
|
+
definition = @registry.definition(app.uri)
|
|
65
|
+
return app.bound if definition.nil? || definition.body_tree.nil?
|
|
66
|
+
return app.bound if definition.params.size != app.args.size
|
|
67
|
+
|
|
68
|
+
state = State.new(fuel: fuel)
|
|
69
|
+
begin
|
|
70
|
+
state.with_in_progress(app.uri, app.args, app) do
|
|
71
|
+
walk(definition.body_tree, bindings: bindings_for(definition, app.args), state: state) || app.bound
|
|
72
|
+
end
|
|
73
|
+
rescue FuelExhausted
|
|
74
|
+
app.bound
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def bindings_for(definition, args)
|
|
81
|
+
definition.params.zip(args).to_h
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def walk(node, bindings:, state:)
|
|
85
|
+
state.consume_fuel!
|
|
86
|
+
|
|
87
|
+
case node
|
|
88
|
+
when HktBody::TypeLeaf
|
|
89
|
+
node.type
|
|
90
|
+
when HktBody::Param
|
|
91
|
+
bindings.fetch(node.name) do
|
|
92
|
+
raise ArgumentError, "unknown param #{node.name.inspect}; declared: #{bindings.keys}"
|
|
93
|
+
end
|
|
94
|
+
when HktBody::Union
|
|
95
|
+
reduced = node.arms.map { |arm| walk(arm, bindings: bindings, state: state) }
|
|
96
|
+
Type::Combinator.union(*reduced)
|
|
97
|
+
when HktBody::NominalApp
|
|
98
|
+
reduced_args = node.args.map { |arg| walk(arg, bindings: bindings, state: state) }
|
|
99
|
+
Type::Combinator.nominal_of(node.class_name, type_args: reduced_args)
|
|
100
|
+
when HktBody::AppRef
|
|
101
|
+
reduced_args = node.args.map { |arg| walk(arg, bindings: bindings, state: state) }
|
|
102
|
+
reduce_app_ref(node.uri, reduced_args, state: state)
|
|
103
|
+
when HktBody::Conditional
|
|
104
|
+
walk_conditional(node, bindings: bindings, state: state)
|
|
105
|
+
else
|
|
106
|
+
raise ArgumentError, "unknown body node: #{node.class}"
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# ADR-20 § D3 conditional reduction. Resolves the test
|
|
111
|
+
# against the current bindings and picks a branch:
|
|
112
|
+
#
|
|
113
|
+
# - test = `yes` → return the reduced `then_branch`.
|
|
114
|
+
# - test = `no` → return the reduced `else_branch`.
|
|
115
|
+
# - test = `maybe` → widen to the union of both
|
|
116
|
+
# reduced branches (per ADR-20 WD7).
|
|
117
|
+
def walk_conditional(node, bindings:, state:)
|
|
118
|
+
verdict = evaluate_test(node.test, bindings: bindings, state: state)
|
|
119
|
+
case verdict
|
|
120
|
+
when :yes
|
|
121
|
+
walk(node.then_branch, bindings: bindings, state: state)
|
|
122
|
+
when :no
|
|
123
|
+
walk(node.else_branch, bindings: bindings, state: state)
|
|
124
|
+
else
|
|
125
|
+
# `:maybe` — widen to the union of both branches.
|
|
126
|
+
then_t = walk(node.then_branch, bindings: bindings, state: state)
|
|
127
|
+
else_t = walk(node.else_branch, bindings: bindings, state: state)
|
|
128
|
+
Type::Combinator.union(then_t, else_t)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def evaluate_test(test, bindings:, state:)
|
|
133
|
+
case test
|
|
134
|
+
when HktBody::TestSubtype
|
|
135
|
+
left = walk(test.left, bindings: bindings, state: state)
|
|
136
|
+
right = walk(test.right, bindings: bindings, state: state)
|
|
137
|
+
subtype_verdict(left, right)
|
|
138
|
+
when HktBody::TestEquality
|
|
139
|
+
left = walk(test.left, bindings: bindings, state: state)
|
|
140
|
+
right = walk(test.right, bindings: bindings, state: state)
|
|
141
|
+
equality_verdict(left, right)
|
|
142
|
+
when HktBody::TestMembership
|
|
143
|
+
left = walk(test.left, bindings: bindings, state: state)
|
|
144
|
+
options = test.options.map { |o| walk(o, bindings: bindings, state: state) }
|
|
145
|
+
membership_verdict(left, options)
|
|
146
|
+
else
|
|
147
|
+
raise ArgumentError, "unknown test node: #{test.class}"
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# `left <: right`. Slice 7a verdict policy: structural
|
|
152
|
+
# equality is `:yes`; clearly-disjoint nominal /
|
|
153
|
+
# constant pairs are `:no`; everything else is `:maybe`
|
|
154
|
+
# (widens to the union per ADR-20 WD7 — robustness
|
|
155
|
+
# principle keeps us conservative on undecided tests).
|
|
156
|
+
def subtype_verdict(left, right)
|
|
157
|
+
return :yes if left == right
|
|
158
|
+
|
|
159
|
+
# Both Nominals with different class names AND
|
|
160
|
+
# neither carries Dynamic — `:no` (statically
|
|
161
|
+
# disjoint). For any other shape pair, `:maybe`.
|
|
162
|
+
return :no if disjoint_nominals?(left, right)
|
|
163
|
+
return :no if disjoint_constants?(left, right)
|
|
164
|
+
|
|
165
|
+
:maybe
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Structural equality verdict — same shape as
|
|
169
|
+
# subtype_verdict but symmetric.
|
|
170
|
+
def equality_verdict(left, right)
|
|
171
|
+
return :yes if left == right
|
|
172
|
+
return :no if disjoint_nominals?(left, right)
|
|
173
|
+
return :no if disjoint_constants?(left, right)
|
|
174
|
+
|
|
175
|
+
:maybe
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def membership_verdict(left, options)
|
|
179
|
+
verdicts = options.map { |o| equality_verdict(left, o) }
|
|
180
|
+
return :yes if verdicts.include?(:yes)
|
|
181
|
+
return :no if verdicts.all? { |v| v == :no }
|
|
182
|
+
|
|
183
|
+
:maybe
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def disjoint_nominals?(left, right)
|
|
187
|
+
return false unless left.is_a?(Type::Nominal) && right.is_a?(Type::Nominal)
|
|
188
|
+
|
|
189
|
+
left.class_name != right.class_name
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def disjoint_constants?(left, right)
|
|
193
|
+
return false unless left.is_a?(Type::Constant) && right.is_a?(Type::Constant)
|
|
194
|
+
|
|
195
|
+
left.value != right.value
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def reduce_app_ref(uri, reduced_args, state:)
|
|
199
|
+
# Cycle detection — when the same `(uri, args)` is
|
|
200
|
+
# already on the reduction stack, return the
|
|
201
|
+
# in-progress App carrier as-is so recursive type
|
|
202
|
+
# aliases (`Array[App[json::value, K]]` inside the
|
|
203
|
+
# `json::value` body) terminate.
|
|
204
|
+
existing = state.in_progress_for(uri, reduced_args)
|
|
205
|
+
return existing if existing
|
|
206
|
+
|
|
207
|
+
registration = @registry.registration(uri)
|
|
208
|
+
bound = registration&.bound || Type::Combinator.untyped
|
|
209
|
+
new_app = Type::App.new(uri, reduced_args, bound: bound)
|
|
210
|
+
|
|
211
|
+
definition = @registry.definition(uri)
|
|
212
|
+
return new_app if definition.nil? || definition.body_tree.nil?
|
|
213
|
+
return new_app if definition.params.size != reduced_args.size
|
|
214
|
+
|
|
215
|
+
state.with_in_progress(uri, reduced_args, new_app) do
|
|
216
|
+
walk(definition.body_tree, bindings: bindings_for(definition, reduced_args), state: state) || new_app
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Per-call mutable bag carrying the remaining fuel
|
|
221
|
+
# budget and the active reduction stack (for cycle
|
|
222
|
+
# detection). Not shared across `reduce` calls.
|
|
223
|
+
class State
|
|
224
|
+
def initialize(fuel:)
|
|
225
|
+
@fuel = fuel
|
|
226
|
+
@in_progress = {}
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def consume_fuel!
|
|
230
|
+
raise FuelExhausted if @fuel <= 0
|
|
231
|
+
|
|
232
|
+
@fuel -= 1
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def in_progress_for(uri, args)
|
|
236
|
+
@in_progress[[uri, args]]
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def with_in_progress(uri, args, app)
|
|
240
|
+
key = [uri, args]
|
|
241
|
+
previous = @in_progress[key]
|
|
242
|
+
@in_progress[key] = app
|
|
243
|
+
begin
|
|
244
|
+
yield
|
|
245
|
+
ensure
|
|
246
|
+
if previous.nil?
|
|
247
|
+
@in_progress.delete(key)
|
|
248
|
+
else
|
|
249
|
+
@in_progress[key] = previous
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
end
|