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”:
| Call | Python's name | The match must |
|---|---|---|
search | re.search | be anywhere, leftmost first |
prefix | re.match | start where the scan starts |
full | re.fullmatch | cover 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:
| Dialect | Written by | Example |
|---|---|---|
Python | a person, for text | (?P<w>\w+)\s+\1 |
Ecma262 | a schema, a form, JavaScript | ^[a-z]+$ |
Lexmatch | a 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:
| Construct | Python | ECMA-262 |
|---|---|---|
| named group | (?P<n>…), also (?<n>…) | (?<n>…) |
$ without m | also before a final newline | only at the end of the text |
\w, \d | Unicode | ASCII, unless spelled \p{…} |
. | any character but a newline | the same |
\A, \Z | yes | no — ^ 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("٢"),
])
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:
- A pattern the core engine can run is lowered onto it.
Pattern::loweranswers the@string.Regex, orNonewhen the pattern says something that engine cannot. - The five combinators are the same five, with the same meaning:
string,+,|,repeat,capture. - A match carries
content,beforeandafteras core spells them, next togroupandspan.
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"))