Module: Rigor::Builtins::RegexRefinement

Defined in:
lib/rigor/builtins/regex_refinement.rb

Overview

Maps a curated table of canonical regex sub-patterns onto the imported refinement carriers Rigor already ships (decimal-int-string, hex-int-string, octal-int-string, lowercase-string, uppercase-string, numeric-string). See docs/type-specification/imported-built-in-types.md for the registry the refinements come from and docs/ROADMAP.md § "v0.1.1 — Planned" Track 1 slice 1 for the binding scope of this recogniser.

The intended consumer is Inference::Narrowing.analyse_match_write: given if /(?<year>\d+)/ =~ str; year; end, the v0.1.0 baseline narrows year to plain String; v0.1.1 introspects the regex source and narrows further to decimal-int-string whenever the named-capture body matches one of the rows in RULES.

Recognised body shapes (each row admits the + quantifier and the bounded {n} / {n,m} forms with n >= 1):

- `\d`, `[0-9]`            -> decimal-int-string
- `\h`                     -> hex-int-string
- `[0-9a-fA-F]`            -> hex-int-string
- `[0-9a-f]`, `[0-9A-F]`   -> hex-int-string
- `[0-7]`                  -> octal-int-string
- `[a-z]`                  -> lowercase-string
- `[A-Z]`                  -> uppercase-string
- `[[:digit:]]`            -> numeric-string

Anything outside the table returns nil so the calling narrowing site falls back to its previous behaviour (plain String). Arbitrary regex semantic equivalence is undecidable, so the table is intentionally a small audited set of canonical shapes rather than a general equivalence checker.

Class Method Summary collapse

Class Method Details

.for_capture_body(body) ⇒ Rigor::Type?

Returns the matching imported refinement carrier, or nil if body is not a recognised shape.

Parameters:

  • body (String, nil)

    a regex sub-pattern, typically the inner body of a (?<name>body) named capture. Anchors (\A, \z, ^, $) are not stripped — the recogniser table targets bodies that the regex engine treats as anchored to the capture group bounds.

Returns:

  • (Rigor::Type, nil)

    the matching imported refinement carrier, or nil if body is not a recognised shape.



71
72
73
74
75
76
77
78
79
# File 'lib/rigor/builtins/regex_refinement.rb', line 71

def for_capture_body(body)
  return nil if body.nil? || body.empty?

  rule = RULES.find { |pattern, _| pattern.match?(body) }
  return nil if rule.nil?
  return nil unless valid_bounds?(body)

  Type::Combinator.public_send(rule.last)
end

.for_whole_pattern(source) ⇒ Rigor::Type?

Whole-receiver regime (#164). Where for_capture_body narrows a named-capture body — sound without anchors because the capture boundary is itself the anchor — this maps a WHOLE regex source (as from Regexp#source or a literal RegularExpressionNode) matched against an entire string via String#match? / =~. The predicate proves a property of the whole string only when the pattern is fully anchored, so a required leading \A and trailing \z are load-bearing: both must be present, and the inner body must be one of the curated single-char-class shapes for_capture_body already audits.

Deliberately rejected (each admits a string the carrier would misdescribe):

- bare / one-sided anchoring (`\d+`, `\A\d+`, `\d+\z`) — matches a substring, not the
whole string;
- `\Z` (capital) — anchors before an optional trailing newline, so `"12\n"` matches
`/\A\d+\Z/` yet is not a decimal-int-string;
- line anchors `^` / `$` — match per-line, so `"12\nabc"` matches `/^\d+$/`;
- any inner body outside the curated table (`\w+`, alternation, multi-class).

Extended (//x) mode is the caller's concern — the char-class table cannot see the free-whitespace/#-comment flag from the source alone, so consumers bail on it before calling here.

Parameters:

  • source (String, nil)

    the full regex source string.

Returns:

  • (Rigor::Type, nil)

    the matching imported refinement carrier, or nil.



105
106
107
108
109
110
111
112
113
# File 'lib/rigor/builtins/regex_refinement.rb', line 105

def for_whole_pattern(source)
  return nil if source.nil?
  return nil unless source.start_with?('\A') && source.end_with?('\z')

  inner = source[2...-2]
  return nil if inner.nil? || inner.empty?

  for_capture_body(inner)
end

.valid_bounds?(body) ⇒ Boolean

Filters the bounded-quantifier forms to ones whose lower bound is at least 1 and whose upper bound (if any) is at least the lower bound. Without this, \d{0,5} would be accepted even though it admits the empty string, which is not a valid decimal-int-string.

Returns:

  • (Boolean)


119
120
121
122
123
124
125
126
127
128
129
130
# File 'lib/rigor/builtins/regex_refinement.rb', line 119

def valid_bounds?(body)
  m = BOUND_RE.match(body)
  return true if m.nil?

  low = Integer(m[1])
  return false if low < 1

  high = m[2] && Integer(m[2])
  return true if high.nil?

  low <= high
end