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()
m.content() is "2026"; m.start() is 10, m.end() is 14.

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}")
year.findall("1999 and 2026")   // ["1999", "2026"]

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

try {
  @moonregex.compile("(a")
} catch {
  @moonregex.Pattern(msg~, at~, ..) => println(msg)   // missing ), unterminated group
}

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 p = @moonregex.compile("[a-z]+")
p.search("  hello ")   // Some(_) — at 2
p.prefix("  hello ")   // None    — the scan starts at 0
p.full("hello")        // 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()
m.group(1)              // Some("2026")
m.group("month")        // Some("10")
m.span("month")         // Some((13, 15))
m.groups()            // [Some("2026"), Some("10")]
m.groupdict()         // {"year": Some("2026"), "month": Some("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()
m.group(1)   // None
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:

let p = @moonregex.compile("(\\w+)=(\\d+)")
p.findall("a=1")                // raises Ambiguous(groups=2)
p.findall("a=1", group=Num(n=1))   // ["a"]
p.finditer("a=1").length()       // 1 — and the Match has both

04Replacing and splitting

@moonregex.sub("\\s+", " ", "a   b  c")        // "a b c"
@moonregex.subn("\\d", "#", "a1b2")            // ("a#b#", 2)
@moonregex.split("\\s*,\\s*", "a , b,c")      // ["a", "b", "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:

@moonregex.sub("(\\w+)@(\\w+)", "\g<2>:\g<1>", "u@h")   // "h:u"
@moonregex.sub_with("\\d+", m => "<" + m.content() + ">", "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 p = @moonregex.compile("hello", flags=@moonregex.Flags::new(ignorecase=true))
p.full("HeLLo")   // 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:

@moonregex.compile("(?i)hello").full("HELLO")        // Some(_)
@moonregex.compile("(?i:hello) world").full("HELLO world")  // Some(_)
@moonregex.compile("(?i:hello) world").full("HELLO WORLD")  // 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.

06Three 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:]]+
@moonregex.compile("^[a-z]+$", dialect=@moonregex.ecma262).full("abc")  // Some(_)
@moonregex.compile("^[a-z]+$", dialect=@moonregex.ecma262).full("ABC")  // 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
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.

07Unicode

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

@moonregex.findall("\\p{Lu}\\p{Ll}+", "Paris and Αθήνα")   // ["Paris", "Αθήνα"]
@moonregex.compile("\\p{Script=Han}+").findall("漢字 and kana")   // ["漢字"]
@moonregex.compile("\\p{Block=Basic Latin}+").findall("abcé")  // ["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. The tables behind them are built the first time a property is asked for, not at load.

\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")
p.search("e\u{301}x").unwrap().content()   // "é" — two code points, one cluster

08The 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.Pattern::string("\\d{4}").capture("year") +
  @moonregex.Pattern::string("-") +
  @moonregex.Pattern::string("\\d{2}").capture("month")

let m = date.prefix("2026-10").unwrap()
m.group("month")    // Some("10")
m.before()        // ""
m.after()         // ""

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

match date.lower() {
  Some(core) => core.execute("2026-10") is Some(_)   // true
  None => false
}

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.

09Limits and streams

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

try {
  @moonregex.full("(a+)+b", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
} catch {
  @moonregex.Exceeded(limit~, got~) => println("\hit \{got} steps")
  _ => ()
}

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()
m.partial()   // true — more input would finish it

10Globs

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

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

let g = @glob.Glob::compile("src/**/*.mbt")
g.matches("src/parse/lex.mbt")   // true
g.matches("src/lex.mbt")          // true — ** spans no directory too

Next: the API reference →