Module: Rigor::RbsExtended

Defined in:
lib/rigor/rbs_extended.rb

Overview

Slice 7 phase 15 — first-preview reader for the ‘RBS::Extended` annotation surface described in `docs/type-specification/rbs-extended.md`.

This module reads ‘%a<payload>` annotations off RBS method definitions and returns well-typed effect objects the inference engine can consume. v0.0.2 recognises:

  • ‘rigor:v1:predicate-if-true <target> is <ClassName>`

  • ‘rigor:v1:predicate-if-false <target> is <ClassName>`

  • ‘rigor:v1:assert <target> is <ClassName>`

  • ‘rigor:v1:assert-if-true <target> is <ClassName>`

  • ‘rigor:v1:assert-if-false <target> is <ClassName>`

‘predicate-if-*` fires when the call is used as an `if` / `unless` condition; `assert` fires unconditionally at the call’s post-scope; ‘assert-if-true` / `assert-if-false` fire at the post-scope only when the call’s return value can be observed as truthy / falsey (currently: when the call is the predicate of a subsequent ‘if` / `unless`). Other directives in the spec (`param`, `return`, `conforms-to`, negation `~T`, `target: self` narrowing, …) remain on the v0.0.x roadmap. Annotations whose key is in the `rigor:v1:` namespace but whose directive is unrecognised are silently ignored at first-preview quality (a future slice MAY surface them as diagnostics-on-Rigor-itself per the spec’s “unsupported metadata” guidance).

The parser is minimal: it accepts a strict shape ‘<target> is <ClassName>` where `<target>` is a Ruby identifier (parameter name) or `self`, and `<ClassName>` is a single non-namespaced class identifier or a `::Foo::Bar` style constant path. Negative refinements (`~T`), intersections, and unions are deferred to the next iteration.

Defined Under Namespace

Classes: AssertEffect, ParamOverride, PredicateEffect

Constant Summary collapse

DIRECTIVE_PREFIX =

rubocop:disable Metrics/ModuleLength

"rigor:v1:"

Class Method Summary collapse

Class Method Details

.param_type_override_map(method_def) ⇒ Object

Convenience reader for call sites that want to look up a single override by parameter name. Returns a frozen Hash<Symbol, Rigor::Type>; missing keys mean “use the RBS-declared type”. Callers MUST treat the hash as read-only.



381
382
383
# File 'lib/rigor/rbs_extended.rb', line 381

def param_type_override_map(method_def)
  read_param_type_overrides(method_def).to_h { |o| [o.param_name, o.type] }.freeze
end

.parse_assert_annotation(string) ⇒ Object



202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
# File 'lib/rigor/rbs_extended.rb', line 202

def parse_assert_annotation(string)
  match = ASSERT_DIRECTIVE_PATTERN.match(string)
  return nil if match.nil?

  directive = match[:directive].to_s
  condition = ASSERT_CONDITIONS[directive]
  return nil if condition.nil?

  target = match[:target].to_s
  target_kind, target_name = target_fields(target)
  class_name, refinement_type, negative = resolve_directive_rhs(match)
  return nil if class_name.nil? && refinement_type.nil?

  AssertEffect.new(
    condition: condition,
    target_kind: target_kind,
    target_name: target_name,
    class_name: class_name,
    negative: negative,
    refinement_type: refinement_type
  )
end

.parse_param_annotation(string) ⇒ Object



404
405
406
407
408
409
410
411
412
# File 'lib/rigor/rbs_extended.rb', line 404

def parse_param_annotation(string)
  match = PARAM_DIRECTIVE_PATTERN.match(string)
  return nil if match.nil?

  type = Builtins::ImportedRefinements.parse(match[:payload])
  return nil if type.nil?

  ParamOverride.new(param_name: match[:param].to_sym, type: type)
end

.parse_predicate_annotation(string) ⇒ Object



138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
# File 'lib/rigor/rbs_extended.rb', line 138

def parse_predicate_annotation(string)
  match = PREDICATE_DIRECTIVE_PATTERN.match(string)
  return nil if match.nil?

  directive = match[:directive].to_s
  target = match[:target].to_s
  edge = directive == "predicate-if-true" ? :truthy_only : :falsey_only
  target_kind, target_name = target_fields(target)
  class_name, refinement_type, negative = resolve_directive_rhs(match)
  return nil if class_name.nil? && refinement_type.nil?

  PredicateEffect.new(
    edge: edge,
    target_kind: target_kind,
    target_name: target_name,
    class_name: class_name,
    negative: negative,
    refinement_type: refinement_type
  )
end

.parse_return_type_override(string) ⇒ Object



330
331
332
333
334
335
# File 'lib/rigor/rbs_extended.rb', line 330

def parse_return_type_override(string)
  match = RETURN_DIRECTIVE_PATTERN.match(string)
  return nil if match.nil?

  Builtins::ImportedRefinements.parse(match[:payload])
end

.read_assert_effects(method_def) ⇒ Object

Reads RBS::Extended assertion effects (‘assert`, `assert-if-true`, `assert-if-false`) off `RBS::Definition::Method#annotations`. Returns an empty array when no recognised assertion directives are attached to the method.



164
165
166
167
168
169
170
171
172
173
174
175
176
# File 'lib/rigor/rbs_extended.rb', line 164

def read_assert_effects(method_def)
  return [] if method_def.nil?

  annotations = method_def.annotations
  return [] if annotations.nil? || annotations.empty?

  effects = []
  annotations.each do |annotation|
    effect = parse_assert_annotation(annotation.string)
    effects << effect if effect
  end
  effects.uniq
end

.read_param_type_overrides(method_def) ⇒ Object

Reads every ‘rigor:v1:param: <name> <refinement>` directive off `RBS::Definition::Method#annotations` and returns the resolved `ParamOverride` list. Annotations the parser cannot resolve (typo, unknown refinement, no `param:` directive at all) are silently dropped — the call site keeps the RBS-declared parameter type for those parameters. The reader accepts a nil method definition so call sites can pass through optional method lookups without a guard.

Example annotation in an RBS file:

class Slug
  %a{rigor:v1:param: id is non-empty-string}
  def normalise: (::String id) -> String
end

The RBS-declared type of ‘id` is `String`. The override tightens it to `non-empty-string` for argument-check purposes; passing a too-wide `Nominal` argument is flagged as an argument-type mismatch at the call site.



367
368
369
370
371
372
373
374
# File 'lib/rigor/rbs_extended.rb', line 367

def read_param_type_overrides(method_def)
  return [] if method_def.nil?

  annotations = method_def.annotations
  return [] if annotations.nil? || annotations.empty?

  annotations.filter_map { |annotation| parse_param_annotation(annotation.string) }
end

.read_predicate_effects(method_def) ⇒ Object

Reads RBS::Extended predicate effects off ‘RBS::Definition::Method#annotations`. Returns the effects in source order; duplicates and unrecognised `rigor:v1:` directives are dropped. Returns an empty array (NEVER `nil`) for a method with no recognised annotations so callers can iterate unconditionally.



99
100
101
102
103
104
105
106
107
108
109
110
111
# File 'lib/rigor/rbs_extended.rb', line 99

def read_predicate_effects(method_def)
  return [] if method_def.nil?

  annotations = method_def.annotations
  return [] if annotations.nil? || annotations.empty?

  effects = []
  annotations.each do |annotation|
    effect = parse_predicate_annotation(annotation.string)
    effects << effect if effect
  end
  effects.uniq
end

.read_return_type_override(method_def) ⇒ Object

Reads the ‘rigor:v1:return: <kebab-name>` directive off `RBS::Definition::Method#annotations`. The directive overrides a method’s RBS-declared return type with one of the imported-built-in refinements registered in ‘Rigor::Builtins::ImportedRefinements`. The override is the primary integration path for refinement carriers (`non-empty-string`, `positive-int`, `non-empty-array`, …) in v0.0 — annotation-driven, opt-in per method, and never silently rewrites a hand-authored RBS signature outside the annotation.

Example annotation in an RBS file:

class User
  %a{rigor:v1:return: non-empty-string}
  def name: () -> String
end

The RBS-declared return is ‘String`. The override tightens it to `non-empty-string` (i.e. `Difference[String, “”]`) for callers; RBS erasure of the tightened return goes back to `String` so the round-trip to ordinary RBS is unaffected.

Returns the resolved ‘Rigor::Type` value, or `nil` when:

  • the method has no annotations,

  • none of the annotations match the ‘rigor:v1:return:` directive,

  • the directive’s payload names a refinement not registered in ‘Rigor::Builtins::ImportedRefinements` (the analyzer prefers a silent miss over crashing on a typo; future slices MAY surface the miss as a `:warning` self-diagnostic).



298
299
300
301
302
303
304
305
306
307
308
309
# File 'lib/rigor/rbs_extended.rb', line 298

def read_return_type_override(method_def)
  return nil if method_def.nil?

  annotations = method_def.annotations
  return nil if annotations.nil? || annotations.empty?

  annotations.each do |annotation|
    type = parse_return_type_override(annotation.string)
    return type if type
  end
  nil
end

.resolve_directive_rhs(match) ⇒ Object

Resolves the ‘class_name` / `refinement` alternation in the assert / predicate directive patterns. Returns `[class_name, refinement_type, negative]`:

  • Class-name arm matched: ‘class_name` is the resolved string (leading `::` stripped), `refinement_type` is nil, `negative` reflects the optional `~` prefix.

  • Refinement arm matched: ‘class_name` is nil, `refinement_type` is the resolved `Rigor::Type`, `negative` reflects the `~` prefix. v0.0.5 supports refinement-form negation for the `Difference[base, Constant]` shape (the narrowing tier computes the complement decomposition); other refinement carriers under negation fall back to the conservative “current_type unchanged” answer.

  • Refinement payload unparseable: returns ‘[nil, nil, false]` so callers can drop the directive silently (fail-soft policy).



243
244
245
246
247
248
249
250
251
252
253
254
255
# File 'lib/rigor/rbs_extended.rb', line 243

def resolve_directive_rhs(match)
  negative = match[:negation].to_s == "~"
  class_capture = match[:class_name]
  return [class_capture.to_s.sub(/\A::/, ""), nil, negative] if class_capture

  refinement_capture = match[:refinement]
  return [nil, nil, false] if refinement_capture.nil?

  type = Builtins::ImportedRefinements.parse(refinement_capture)
  return [nil, nil, false] if type.nil?

  [nil, type, negative]
end

.target_fields(target) ⇒ Object



257
258
259
260
261
262
263
# File 'lib/rigor/rbs_extended.rb', line 257

def target_fields(target)
  if target == "self"
    %i[self self]
  else
    [:parameter, target.to_sym]
  end
end