SCUA

How-to

Match text with regular expressions

The regex module is a ReDoS-safe regular-expression engine: it matches in linear time and can never hang on a pathological pattern, so it is safe to run on patterns that came from a user or a model. It needs no capability — it is pure computation. The dialect is the RE2 / Rust / ripgrep subset, so if you know ripgrep's regexes you already know these.

import regex

#Compile once, then reuse

regex.compile(pattern) returns Ok(re) or a clear Error — it never silently mis-compiles. Compile a pattern once and reuse the re across many matches.

match regex.compile("fn\\s+(?<name>\\w+)")
  Ok(re) -> ...          -- use `re` below
  Error(why) -> print(`bad pattern: {why}`)
end

#The match verbs

Offsets are 0-based, half-open codepoint positions — the same convention as str.find, str.slice and array indexing. The one rule worth remembering is the identity that follows from it:

str.slice(s, m.start, m.stop) == m.text

The engine is codepoint-native, so multibyte text just works: an offset counts characters, never bytes. (The stop field is named stop, not end, because end is a keyword.)

  • regex.is_match(re, s)bool — does re match anywhere in s?
  • regex.find(re, s){ start, stop, text } | nil — the leftmost match (half-open start..stop), or nil.
  • regex.find_all(re, s){ { start, stop, text } } — every non-overlapping match, left to right.
  • regex.captures(re, s){ groups, named } | nil — the match with its groups. groups is an array of { start, stop, text } records (nil where a group didn't participate). Regex group numbering is 1-based — \\1 is group 1 — and arrays are 0-based, so group k is groups[k - 1]. named maps each (?<name>) to its group.
  • regex.replace(re, s, repl)string — replace every match. In repl, $1..$9 are numbered groups, ${name} is a named group, $0 the whole match, and $$ a literal $.
  • regex.source(re)string — the pattern the re was compiled from.
import regex
import str

let s = "mail: ada@example here"
match regex.compile("(?<user>\\w+)@(?<host>\\w+)")
  Ok(re) -> do
    print(regex.is_match(re, s))
    let m = regex.find(re, s)
    print(`{m.start}..{m.stop} is "{m.text}"`)
    print(str.slice(s, m.start, m.stop) == m.text)
    print(`before the match: "{str.slice(s, 0, m.start)}"`)
    let caps = regex.captures(re, s)
    print(caps.named.host.text)
    print(caps.groups[0].text)
    print(regex.replace(re, "a@x b@y", "${host}/${user}"))
  end
  Error(why) -> print(why)
end
$ scua offsets.scua
true
6..17 is "ada@example"
true
before the match: "mail: "
example
ada
x/a y/b

fs.grep is the exception, and it is a deliberate one: its line and col are 1-based, because they are editor coordinates — the file:line:col that grep, ripgrep and compiler diagnostics print, and that editors jump to. Offsets into a string are 0-based; positions in a file are 1-based.

(Inside a function that returns a Result you can use the postfix ? operator — let re = regex.compile(pattern)? — to unwrap the Ok or return the Error to your caller.)

#Supported syntax

Literals and . (any codepoint except newline); character classes [a-z], [^0-9], and the shorthands \w \s \d \W \S \D; anchors ^ $ \b \B; alternation a|b; groups — capturing (…), non-capturing (?:…), named (?<name>…); quantifiers * + ? {m} {m,} {m,n} and their lazy *? +? ?? forms; and the inline flag (?i) for case-insensitive matching.

#Loud failure, never silent

Backreferences and lookaround cannot be supported by a linear-time engine, so they — and other unsupported constructs — fail at compile with a specific message that names the problem, rather than silently matching nothing:

regex.compile("a\\1")      -- Error: backreferences ... are not supported
regex.compile("(?=x)")     -- Error: lookaround ... is not supported by the linear-time engine
regex.compile("(a")        -- Error: unbalanced parenthesis

This matches ripgrep's default engine, which also has no backreferences or lookaround.

#On a data-table column

The same re works inside a frame filter:

import regex

let tickets = frame({
    note = ["refund request", "shipped", "CHARGEBACK dispute"],
  })
match regex.compile("(?i)refund|chargeback")
  Ok(re) -> print(tickets.filter(note.matches(re)))
  Error(why) -> print(why)
end

-- Or the multi-column shortcut:
print(tickets.search("ship", { columns = ["note"] }))

See Search a text column. Fixed-string column filters use note.contains("lit") (no regex needed).

#v1 limits

\w \s \d and \b use ASCII semantics for now (identical to Unicode on ASCII identifiers, which is what most code search needs); \p{L} and full Unicode character classes are a planned addition and currently fail to compile. Multiline matching (. across newlines) is not available — patterns are line-scoped. The match atom and every offset are still whole codepoints.