Find what you meant,
not what you typed.

Three grammars, one syntax tree, one executor, the same answers on every backend. This guide goes from the first match to the parts that surprise people.

01First match

moon add moonbitstack/moonregex
let m = @moonregex.search("\\d{4}", "copyright 2026").unwrap()

search compiles the pattern, scans left to right, and answers the leftmost match as a Match?. When a pattern is used more than once, compile it first — the module functions compile on every call.

let year = @moonregex.compile("\\d{4}")
assert_eq(year.findall("1999 and 2026"), [Some("1999"), Some("2026")])

A pattern that does not compile raises Refused, which carries a message and where reading stopped:

let (message, at) = try {
  let _ = @moonregex.compile("(a")
  ("", 0)
} catch {
  @moonregex.Refused::Pattern(msg~, at~, ..) => (msg, at)
  e => raise e
}

02Where a match may fall

Three functions answer the same question with a different answer to “where”:

CallPython's nameThe match must
searchre.searchbe anywhere, leftmost first
prefixre.matchstart where the scan starts
fullre.fullmatchcover the whole region
let word = @moonregex.compile("[a-z]+")
assert_true(word.search("  hello ") is Some(_))
assert_true(word.prefix("  hello ") is None)
assert_true(word.full("hello") is Some(_))

All three take pos= and endpos=, which bound the region without changing what ^ and $ mean.

03Groups

Parentheses capture, and a name reaches the same slot as its number:

let p = @moonregex.compile("(?<year>\\d{4})-(?<month>\\d{2})")
let m = p.search("on 2026-10-28").unwrap()
assert_eq(m.group(1), Some("2026"))
assert_eq(m.group("month"), Some("10"))
assert_eq(m.span("month"), (8, 10))
assert_eq(m.groups(), [Some("2026"), Some("10")])
assert_eq(m.groupdict(), { "year": "2026", "month": "10" })

A group that took no part answers None, not an empty string — the difference matters in patterns with alternation:

let m = @moonregex.compile("(a)|(b)").search("b").unwrap()
assert_eq(m.group(1), None)
assert_eq(m.group(2), Some("b"))

findall takes one group and answers its text; with two or more groups Python answers a tuple, and since a list of strings cannot say that, the call is refused until you name one:

assert_eq(p.findall("a=1", group=Num(n=1)), [Some("a")])
assert_eq(p.finditer("a=1").length(), 1)

04Replacing and splitting

assert_eq(@moonregex.sub("\\s+", " ", "a   b  c"), "a b c")
assert_eq(@moonregex.subn("\\d", "#", "a1b2"), ("a#b#", 2))

let parts = @moonregex.split("\\s*,\\s*", "a , b,c")
assert_eq(parts, [Some("a"), Some("b"), Some("c")])

A replacement is a template: \1, \g<name>, and \g<0> for the whole match. When the replacement is computed, sub_with takes the Match and answers the text:

assert_eq(@moonregex.sub("(\\w+)@(\\w+)", "\\g<2>:\\g<1>", "u@h"), "h:u")

let shouted = @moonregex.sub_with(
  "\\d+",
  m => "<" + m.content().to_owned() + ">",
  "a1b",
)
"a<1>b"

split keeps the captured groups in the result, the way Python does; a group that took no part is None, which is why the answer is Array[String?].

05Flags

Flags are a record, so one switch is set and the rest stay as they were:

let icase = @moonregex.Flags::new(ignorecase=true)
let p = @moonregex.compile("hello", flags=icase)
assert_true(p.full("HeLLo") is Some(_))

The common ones also have an inline spelling, which is part of the pattern and needs no argument — and works inside a scoped group:

let p = @moonregex.compile("(?i)hello")
assert_true(p.full("HELLO") is Some(_))

let q = @moonregex.compile("(?i:hello) world")
assert_true(q.full("HELLO world") is Some(_))
assert_true(q.full("HELLO WORLD") is None)

multiline moves ^ and $ to line boundaries, dotall lets . cross a newline, verbose drops whitespace and # comments from the pattern, and ascii narrows \w, \d, \s and \b to their ASCII reading.

06Case, and where it comes from

Flags can change inside a pattern: (?i:…) turns case-insensitivity on for one group, (?-i:…) turns it off again. Two things then take their case from different places. A group is read under the flags around the group. A backreference is compared under the flags around the reference — the place it is written — so the two need not agree, and when they do not, the reference decides.

// A group is read under the flags around it; a reference is compared under
// the flags around the reference. When the two differ, the reference wins.
let caseless = @moonregex.compile("(?i)(?-i:(a))\\1")
assert_eq(caseless.search("aA").unwrap().content(), "aA")

// The same pattern the other way round: the group is caseless, so it can
// take either letter, and the reference insists on the one it captured.
let exact = @moonregex.compile("(?i:(a))\\1")
assert_true(exact.search("AA") is Some(_))
assert_true(exact.search("aA") is None)

In (?i)(?-i:(a))\1 the group must match the exact letter while the reference accepts either case; in (?i:(a))\1 it is the other way round, and aA is no match. The comparison walks the capture one code point at a time and folds each one, so it holds over every script rather than ASCII alone:

// The comparison is per code point over every script, not ASCII alone.
assert_true(@moonregex.compile("(?i)(é)\\1").full("éÉ") is Some(_))
assert_true(@moonregex.compile("(?i)(k)\\1").full("k\u{212A}") is Some(_))

A reference to a group that captured nothing never matches: there is no text to compare, which is why (a)?\1 does not match an empty string.

07Three grammars

The grammar is a parameter, not a fork:

DialectWritten byExample
Pythona person, for text(?P<w>\w+)\s+\1
Ecma262a schema, a form, JavaScript^[a-z]+$
Lexmatcha MoonBit lexer[[:digit:]]+
let p = @moonregex.compile("^[a-z]+$", dialect=@moonregex.ecma262)
assert_true(p.full("abc") is Some(_))
assert_true(p.full("ABC") is None)

The differences are the standards' own, and they are settled while reading the pattern:

ConstructPythonECMA-262
named group(?P<n>…), also (?<n>…)(?<n>…)
$ without malso before a final newlineonly at the end of the text
\w, \dUnicodeASCII, unless spelled \p{…}
.any character but a newlinethe same
\A, \Zyesno — ^ and $ only

Lexmatch is the core grammar plus the two things a lexer usually wants and that grammar leaves out: \d, \w, \s and their negations are read the Python way, and (?<name>…) captures under a name. A pattern written for either grammar keeps its meaning in the other, and one that uses them still lowers onto the core engine — the classes are written out as the ranges they stand for:

let tag = @moonregex.compile(
  "(?<name>\\w+)=(?<value>\\d+)",
  dialect=@moonregex.lexmatch_dialect,
)
assert_eq(tag.search("size=42").unwrap().group("value"), Some("42"))
// `\d` is every decimal digit the UCD knows, not the ASCII ten.
assert_eq(tag.findall("a=1 b=٢", group=Name(n="value")), [
  Some("1"),
  Some("٢"),
])
A pattern from a JSON Schema or an OpenAPI document is ECMA-262 by definition. Reading it with the Python grammar mostly works and is wrong exactly where it matters.

08Unicode

\p{…} takes a general category, a script, a block or a binary property, under any of their spellings:

let words = @moonregex.findall("\\p{Lu}\\p{Ll}+", "Paris and Αθήνα")
assert_eq(words, [Some("Paris"), Some("Αθήνα")])

let han = @moonregex.compile("\\p{Script=Han}+")
assert_eq(han.findall("漢字 and kana"), [Some("漢字")])

let latin = @moonregex.compile("\\p{Block=Basic Latin}+")
assert_eq(latin.findall("abcé"), [Some("abc")])

The sets come from the Unicode Character Database through the moonucd packages, so the version a pattern is read against is the version the rest of the ecosystem uses. A property is one lookup in a table of ranges, so the first pattern that asks for one does not walk the code space to answer.

\X matches one grapheme cluster, by the UCD's own segmentation: a letter with its accents, a flag, an emoji with its modifiers.

let p = @moonregex.compile("\\X")
let cluster = p.search("e\u{301}x").unwrap().content()

09The core engine

MoonBit's core library has a regular-expression engine of its own, reached through @string.Regex and the lexmatch grammar. This library stands next to it rather than beside it:

let date = @moonregex.compile("\\d{4}").capture("year") +
  @moonregex.compile("-") +
  @moonregex.compile("\\d{2}").capture("month")
let m = date.prefix("2026-10").unwrap()
assert_eq(m.group("month"), Some("10"))
assert_eq(m.before(), "")
assert_eq(m.after(), "")

The same date, written three ways, matches the same way — and the assembled one can be handed to the core engine:

let core = date.lower().unwrap()
assert_true(core.execute("2026-10") is Some(_))

Backreferences, lookaround, conditionals, recursion, atomic groups, fuzzy matching, \X and Unicode word boundaries answer None: those run on this library's own engine, which is why the two agree rather than overlap.

10Limits and streams

Backtracking can be made expensive. Every call carries a step ceiling — ten million by default — and raises rather than hanging:

let stopped = try {
  let _ = @moonregex.full("(a+)+b", "aaaaaaaaaaaaaaaaaaaa", limit=100)
  None
} catch {
  @moonregex.Refused::Exceeded(limit~, got~) => Some((limit, got))
  e => raise e
}

limit= sets it per call when a pattern is known to be cheap. partial=true answers a match when the text ends in the middle of one, flagged on the result, which is what a streaming parser needs:

let p = @moonregex.compile("\\d{4}-\\d{2}", partial=true)
let m = p.prefix("2026-").unwrap()
assert_eq(m.content(), "2026-")
assert_true(m.partial())

11Globs

Shell patterns are their own grammar, and turn into regular expressions here, so the Unicode handling and the dialects come for free:

assert_true(@glob.fnmatch("cat.txt", "*.txt"))
assert_eq(@glob.filter(["a.txt", "b.csv"], "*.txt"), ["a.txt"])

let g = @glob.Glob::compile("src/**/*.mbt")
assert_true(g.matches("src/parse/lex.mbt"))
assert_true(g.matches("src/lex.mbt"))

Next: the API reference →