How do I work with regular expressions?

← Getting Started · Ref: Q33

EK9 has a built-in RegEx type with /pattern/ literal syntax (same as JavaScript), avoiding double-escaping.

REGEX LITERALS

  digitPattern <- /\d+/
  emailPattern <- /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/

No string escaping needed: /\d+/ is what you mean. Java requires Pattern.compile("\\d+").

MATCHING

Bidirectional 'matches' operator:

  require 'hello123' matches /[a-z]+\d+/
  require /[a-z]+\d+/ matches 'hello123'

Negate with 'not matches':

  require 'hello' not matches /\d+/

SPLITTING STRINGS

Split with regex delimiter, works both directions:

  parts <- 'one:two:three'.split(/:/)
  alsoParts <- /:/.split('one:two:three')

Both produce List of String.

CAPTURE GROUPS

group() is the extractor - the equivalent of Java's Matcher.group(), Python's match.group(), JS match() and Go's FindStringSubmatch. It finds the FIRST match anywhere in the input and returns the capture groups as a List of String, in pattern order - the whole match is NOT included (wrap the pattern in (...) if you want it). Works both directions, like split:

  fields <- dueLine.group(/(\d{4})-(\d{2})-(\d{2})/)
  year <- fields.getOrDefault(0, "?")

No match is UNSET - the absence of groups - so the guard form runs only on a match:

  if parts <- line.group(isoDate)
    year <- parts.getOrDefault(0, "?")

A match on a pattern with no capture groups is a set, EMPTY list, so the two are distinguishable. An optional group that did not take part is an UNSET element at its index, never a dropped one. group() is a find, not a whole-input test: 'matches' can be false on the same input. Anchor with ^ and $ when you want the whole input.

NAMED GROUPS

Name a group with (?<name>...) and ask for it by name - one String, unset on no match, no such name, or a group that did not take part:

  year <- dueLine.group(/(?<year>\d{4})-(?<month>\d{2})/, "year")

REPLACE

replace() replaces EVERY match; the replacement can refer to groups as $1 or ${name}:

  iso <- dueLine.replace(/(\d{2})\/(\d{2})\/(\d{4})/, "$3-$2-$1")

No match leaves the text as it was (set). A reference the pattern cannot satisfy ($4 with three groups) is unset rather than an exception. Both work in either direction, like split.

SPLIT IS A DELIMITER SPLIT

split() removes the MATCHED text and returns what surrounded it - capture groups play no part in it. It is keep-all (Java's limit -1), so the empty pieces at either end are retained rather than silently dropped:

  aroundDigits <- text.split(/\d+/)

Splitting on the COMPLEMENT and selecting a piece still works for one self-delimiting field, but group() is the tool for extraction.

NO-MATCH SHAPES

split never found the delimiter: the whole string back as ONE element, set. group never found the pattern: UNSET. Different on purpose - a split always has something to hand back, an extraction does not.

ESCAPING IN LITERALS

/\\/ matches literal backslash. /\// matches literal forward slash.
Shortcuts: \d \w \s . Quantifiers: ? * + {n} {n,m}. Anchors: ^ $ \b. Classes: [a-z] [^0-9].

REGEX COMPOSITION

Build patterns with + operator:

  combined <- /^Hello/ + / / + /World$/

Extend with +=:

  pattern <- /^Start/
  pattern += /.*End$/

COMPARISON AND IDENTITY

== and <> compare patterns. ? checks if set. $ converts to string. length returns pattern length.

Use 'ek9 -h RegEx' and 'ek9 -h String' for the full API.

See Q37 for string operations. See Q29 for tri-state semantics.

Example

defines module qa.regex

  defines program
    RegExDemo()
      stdout <- Stdout()

      // === REGEX LITERALS ===

      // Regex literals use /pattern/ syntax - like JavaScript
      // No double-escaping needed (unlike Java's Pattern.compile("\\d+"))
      digitPattern <- /\d+/
      emailPattern <- /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/
      stdout.println(`Digit pattern: ${digitPattern}`)
      require "user@example.com" matches emailPattern
      require "not-an-email" not matches emailPattern

      // === MATCHING ===

      // The 'matches' operator works in both directions
      require "hello123" matches /[a-z]+\d+/
      require /[a-z]+\d+/ matches "hello123"

      // 'not matches' for negation
      require "hello" not matches /\d+/

      // Match name variants with character classes and alternation
      namePattern <- /[S|s]te(?:ven?|phen)/
      require "Steve" matches namePattern
      require "steve" matches namePattern
      require "Stephen" matches namePattern
      require "Steven" matches namePattern
      require "Stephene" not matches namePattern

      // Character class with quantifier
      sixChars <- /[a-zA-Z0-9]{6}/
      require sixChars matches "arun32"
      require sixChars not matches "kkvarun32"
      require sixChars not matches "arun$2"

      // === SPLITTING STRINGS ===

      // Split works both directions: string.split(regex) or regex.split(string)
      colonRegEx <- /:/
      colonDelimited <- "one:two:three:four:five"

      fromString <- colonDelimited.split(colonRegEx)
      fromRegEx <- colonRegEx.split(colonDelimited)
      require fromString == fromRegEx
      stdout.println(`Split: ${fromString}`)

      // NOTE: $ on a List renders the whole COLLECTION and quotes its String elements
      // (see the Split: line above) - it is not a join, so check the elements instead.
      // There is no unchecked indexed get: getOrDefault makes the missing case explicit.
      require length fromString == 5
      require fromString.getOrDefault(0, "?") == "one"
      require fromString.getOrDefault(4, "?") == "five"
      require fromString contains "three"

      // === CAPTURE GROUPS ===

      // group() is the extractor: the capture groups of the FIRST match anywhere in the
      // input, in pattern order. The whole match is not an element - index 0 is group 1.
      text <- "This is a sample Text 1234 with numbers in between."
      numberInContext <- /Text (\d+) with/
      captured <- text.group(numberInContext)
      require length captured == 1
      justNumber <- captured.getOrDefault(0, "?")
      require justNumber == "1234"
      stdout.println(`Extracted number: ${justNumber}`)

      // Several fields from one pattern come back aligned with its groups
      isoDate <- /(\d{4})-(\d{2})-(\d{2})/
      dueLine <- "Due 2024-06-15 at noon"
      fields <- dueLine.group(isoDate)
      require length fields == 3
      year <- fields.getOrDefault(0, "?")
      month <- fields.getOrDefault(1, "?")
      day <- fields.getOrDefault(2, "?")
      stdout.println(`Date fields: ${year}, ${month}, ${day}`)

      // group() is a find, not a whole-input test - 'matches' is false on that same line
      require dueLine not matches isoDate

      // No match is UNSET - the absence of groups - so the guard form runs only on a match
      noDateHere <- "No date on this line."
      noFields <- noDateHere.group(isoDate)
      require not noFields?
      if guarded <- noDateHere.group(isoDate)
        stdout.println(`Unexpected: ${guarded}`)
      else
        stdout.println("No match, group is unset")

      // === NAMED GROUPS ===

      // (?<name>...) names a group; ask for it by name and get one String
      namedDate <- /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/
      require dueLine.group(namedDate, "year") == "2024"
      require not dueLine.group(namedDate, "hour")?
      stdout.println(`Named month: ${dueLine.group(namedDate, "month")}`)

      // === REPLACE ===

      // Every match is replaced; $n and ${name} refer to the groups
      require dueLine.replace(namedDate, "$3/$2/$1") == "Due 15/06/2024 at noon"
      stdout.println(`Reordered: ${dueLine.replace(namedDate, "${day}.${month}.${year}")}`)
      require noDateHere.replace(namedDate, "X") == noDateHere
      require not dueLine.replace(namedDate, "$4")?

      // === SPLITTING IS NOT EXTRACTING ===

      // split() is a DELIMITER split: the matched text is REMOVED and what surrounded it
      // is returned. Capture groups in the pattern play no part in a split.
      digitRun <- /\d+/
      aroundDigits <- text.split(digitRun)

      stdout.println(`Around the digits: ${aroundDigits}`)
      require length aroundDigits == 2
      require aroundDigits.getOrDefault(0, "") == "This is a sample Text "
      require aroundDigits.getOrDefault(1, "") == " with numbers in between."

      // The fallback before group() existed: split on the COMPLEMENT and pick the piece.
      // Keep-all retains the empty pieces either side. Fine for one self-delimiting field;
      // for anything with several fields, alternation or optional parts, use group().
      nonDigitRun <- /\D+/
      pieces <- text.split(nonDigitRun)
      require length pieces == 3
      require pieces.getOrDefault(0, "?") == ""
      viaComplement <- cat pieces | skip 1 | head 1 | collect as String
      require viaComplement == justNumber
      stdout.println(`Via the complement: ${viaComplement}`)

      // Convert extracted text to Integer
      asInteger <- Integer(justNumber)
      require asInteger == 1234
      stdout.println(`As integer: ${asInteger}`)

      // === NO MATCH IS A ONE-ELEMENT LIST, NOT UNSET ===

      // A List is set the moment it exists, so 'delimiter never found' hands back the
      // whole string as a single element. It does NOT come back unset.
      noNumbers <- "No digits here at all."
      noMatch <- noNumbers.split(digitRun)
      require noMatch?
      require length noMatch == 1
      require noMatch.getOrDefault(0, "") == noNumbers
      stdout.println(`No match, single element: ${noMatch}`)

      // === ESCAPING IN LITERALS ===

      // Backslash in regex literal: /\\/ matches a literal backslash
      backslashPattern <- /^Some\\Thing$/
      require backslashPattern matches "Some\Thing"

      // Forward slash in regex literal: /\// matches a literal forward slash
      slashPattern <- /^Some\/Thing$/
      require slashPattern matches "Some/Thing"

      // Fraction matching with escaped slash
      fractionPattern <- /.*\/.*/
      require fractionPattern matches "3/4"

      // === REGEX COMPOSITION ===

      // Build patterns with + operator
      prefix <- /^Hello/
      combined <- prefix + / / + /World$/
      require combined matches "Hello World"
      stdout.println(`Combined: ${combined}`)

      // += to extend a pattern
      growing <- /^Start/
      growing += /.*End$/
      require growing matches "Start and End"

      // === COMMON PATTERNS ===

      // Phone number
      require "555-1234" matches /^\d{3}-\d{4}$/

      // UK postcode (simplified)
      require "SW1A 1AA" matches /^[A-Z]{1,2}\d[A-Z\d]?\s?\d[A-Z]{2}$/

      // ISO date format
      require "2024-06-15" matches /^\d{4}-\d{2}-\d{2}$/

      stdout.println("All regex tests passed")

Common mistakes

E50001 — EK9 rejects variable names that shadow operator keywords. 'matches' is a reserved operator keyword. Use descriptive names like 'digitPattern' or 'emailPattern'. See ek9 -h E50001 for details.

Incorrect:

matches <- /\d+/

Correct:

digitPattern <- /\d+/

E50060 — group() returns a List of String, and a List has no unchecked get(index) - the out-of-range case must be stated. Use getOrDefault(index, fallback); the fallback is also what you see when the pattern has fewer groups than you indexed. Index 0 is the FIRST capture group - the whole match is not an element. See ek9 -h E50060 for details.

Incorrect:

year <- fields.get(0)

Correct:

year <- fields.getOrDefault(0, "?")
Other ways to ask this
  • Does EK9 have regex support?
  • How do I match patterns in strings?
  • How do I split strings with a regex in EK9?
  • How do I extract groups from a regex match?
  • How do I use named capture groups?
  • How do I do a regex replace with group references?

Coming from another language?

Java: Pattern.compile() double-escaping, Pattern/Matcher ceremony. Python: re.compile(r'pattern'). JavaScript: /pattern/ (EK9 follows this). Rust/Go: no literal syntax. EK9: /pattern/ literal, bidirectional matches, split both directions, no double-escaping. Capture groups: group() is the Matcher.group() / re.search().group() / JS match() / Go FindStringSubmatch equivalent - first match, the groups only (no whole-match element), UNSET on no match so the guard form applies. group(name) reads one named group; replace() is replaceAll with $1 / ${name} references (Java Matcher.replaceAll, Python re.sub, JS replace with $1). split() is a delimiter split only and capture groups play no part in it. A no-match split yields the whole string as one element, never an unset List.

Keywords: migrate, split, character, class, regular, beginner, start, expression, quantifier, escape, group, match, matches, regex, intro, first, pattern, literal, capture, anchor