skip to content

In Ruby, how do you extract the region code and serial number from a licence plate using named captures and MatchData?

level: middleimportance: should knowfreq 45%

answer

  1. (?<name>…) inside the pattern
  2. md[:region] or md["region"]
  3. named_captures(symbolize_names: true)
  4. match returns nil on a miss
  5. literal on the left of =~ makes locals

basics

~10 s

Name each group with (?<region>…) and (?<number>…), call match, and read md[:region] and md[:number] from the MatchData, after checking it is not nil. named_captures returns all of them as a Hash.

solid answer

~40 s

Write `PLATE = /\A(?<region>[A-Z]{2})-(?<number>\d{3})\z/` and call `md = PLATE.match(input)`. `match` returns `nil` when there is no match, so guard it (`if md`, or `input.match(PLATE) { |md| ... }`, whose block runs only on a match). `md[:region]` and `md["region"]` both return `"AB"`; `md.named_captures` returns `{"region" => "AB", "number" => "123"}`, and since Ruby 3.3 `named_captures(symbolize_names: true)` gives Symbol keys. `pre_match`, `post_match` and `begin(:number)` report context and positions. With a regexp **literal** on the left, `/(?<region>…)…/ =~ input` also assigns local variables `region` and `number`. Once a pattern has named groups, plain `( )` groups stop capturing. To extract every plate from a text, `scan` returns one array of captures per match.

code

ruby · 13 lines
ruby
PLATE_IN_TEXT = /
  (?<region>[A-Z]{2})   # region code
  -
  (?<number>\d{3})      # serial number
/x

text = "Seen: AB-123, CD-456."
text.scan(PLATE_IN_TEXT)            # => [["AB", "123"], ["CD", "456"]]
text.scan(PLATE_IN_TEXT) { puts $~[:region] }   # prints AB, then CD

md = text.match(PLATE_IN_TEXT)
md.pre_match                         # => "Seen: "
md.begin(:number)                    # => 9

go deeper

for a junior

Recall the (?<name>…) syntax, reading md[:name], and that match returns nil when the plate does not match.

for a middle

Explain the MatchData readers, the nil guard or block form, the literal-on-the-left rule for locals, and why named and unnamed groups do not mix.

for a senior

Keep extraction patterns readable with the x flag, return structured values instead of leaking MatchData, and cover misses and duplicates in tests.

for a principal

Decide when a parser object or a dedicated format library beats a growing regexp, and set the complexity threshold at which the team switches.

## Naming the parts of a pattern A **capture group** records the text matched by one part of a regexp. Ruby lets you name a group with `(?<name>…)` (or `(?'name'…)`), so code can ask for the region code by name instead of by position. For a plate made of a two-letter region code, a hyphen and a three-digit serial number: ```ruby PLATE = /\A(?<region>[A-Z]{2})-(?<number>\d{3})\z/ md = PLATE.match("AB-123") md[:region] # => "AB" md["number"] # => "123" md[1] # => "AB", numbers still work md.named_captures # => {"region" => "AB", "number" => "123"} md.named_captures(symbolize_names: true) # => {region: "AB", number: "123"} ``` ## What a MatchData offers `Regexp#match` and `String#match` return a **`MatchData`**, or `nil` when there is no match. Its most used readers: - `md[0]` or `md.to_s`: the whole matched text; `md[n]`, `md[:name]` or `md["name"]`: one capture. - `md.captures`: an Array of all captures; `md.named_captures`: a Hash from name to text; `md.names`: the group names. - `md.values_at(:region, :number)`: several captures at once. - `md.pre_match` and `md.post_match`: the text before and after the match. - `md.begin(:number)` and `md.end(:number)`: character offsets of a capture. Because a miss returns `nil`, calling `md[:region]` without a guard raises `NoMethodError` on `nil`. Two idioms avoid that: 1. `if (md = PLATE.match(input))` and read `md` inside the branch. 2. `input.match(PLATE) { |md| md[:number].to_i }`, whose block runs only on a match; on a miss the call returns `nil`. ## Local variables from `=~` When a regexp **literal** with named groups sits on the **left** of `=~` and has no `#{}` interpolation, Ruby assigns each capture to a local variable of the same name: ```ruby if /(?<region>[A-Z]{2})-(?<number>\d{3})/ =~ "Car XY-987" region # => "XY" number # => "987" end ``` The variables are set to `nil` when the match fails. Put a Regexp held in a constant or variable on the left, put the string on the left, or interpolate into the literal, and no locals are created. The feature is handy in scripts; in application code, an explicit `MatchData` makes the data flow easier to follow. ## Positions in characters and in bytes `md.begin(:number)`, `md.end(:number)` and `md.offset(:number)` report **character** offsets, which match `String#[]`. When the surrounding code works in bytes, for example with `byteslice` on a buffer, `md.byteoffset(:number)` returns the byte offsets instead, and Ruby 3.4 added `bytebegin` and `byteend` as single-value forms. For an ASCII plate the two agree; they diverge as soon as the text before the match contains multi-byte characters. ## Rules that surprise people - **Named and unnamed groups do not mix.** Once a pattern has a named group, plain `( … )` groups no longer capture, so `/(?<region>[A-Z]{2})-(\d{3})/` exposes only `region`. Use `(?:…)` for grouping without capture, and name everything you need. - **Duplicate names** are allowed; `md[:name]` then returns the last group of that name that matched. - **`\k<name>`** refers back to a named capture inside the pattern and inside a `sub`/`gsub` replacement string. ## Returning a value instead of a MatchData A `MatchData` is a parsing detail; the rest of the application usually wants a small value object. Ruby's `Data.define` builds one, and `named_captures(symbolize_names: true)` supplies exactly the keyword arguments it needs: ```ruby Plate = Data.define(:region, :number) def parse_plate(input) md = PLATE.match(input) or return nil Plate.new(**md.named_captures(symbolize_names: true)) end parse_plate("AB-123") # => #<data Plate region="AB", number="123"> parse_plate("nope") # => nil ``` Callers then depend on `plate.region` rather than on group names or positions, and the regexp can change without touching them. ## Extracting every plate with `scan` `String#scan` finds all non-overlapping matches. Its result depends on groups: | Pattern | `"Seen: AB-123, CD-456".scan(p)` | |---|---| | no groups: `/[A-Z]{2}-\d{3}/` | `["AB-123", "CD-456"]` | | two groups | `[["AB", "123"], ["CD", "456"]]` | With a block, `scan` yields each result and sets `$~` for that match, so `$~[:region]` works inside the block. ## Keeping long patterns readable The **`x`** flag ignores literal whitespace and lets `#` start a comment, so each part of a pattern can be labelled: ```ruby PLATE_IN_TEXT = / (?<region>[A-Z]{2}) # region code - (?<number>\d{3}) # serial number /x ``` In extended mode a literal space must be written as `\s`, `\ ` or `[ ]`.

  • In Ruby, why does /(?<region>[A-Z]{2})-(\d{3})/.match("AB-123").captures return only ["AB"]?
    When a regexp contains a named group, Ruby stops capturing its unnamed `( … )` groups; they only group. Name the serial number too, `(?<number>\d{3})`, or drop the name from the region and use numbered groups throughout.
  • In Ruby, when does /(?<region>..)/ =~ input create a local variable named region?
    Only when the regexp is a literal, written on the left of `=~`, with no `#{}` interpolation. A Regexp stored in a constant, a string on the left, or an interpolated literal creates no variables. On a failed match the variables exist and are `nil`.

saying these in an interview costs you the question

  • match returns an empty MatchData when nothing matches
  • Named and unnamed groups can be mixed and all of them capture
  • Any Regexp object on either side of =~ creates local variables for named groups
  • md[:region] and md["region"] return different things
  • scan returns MatchData objects