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