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”:
| 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 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:
| 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:]]+ |
@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:
| 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 |
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:
- 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.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