// Copyright 2026 International Digital Economy Academy
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
///|
/// A compiled regular expression for string-oriented matching.
pub struct Regex {
priv pat : @re.Pattern
priv mut re : @re.Regex?
}
///|
/// Compiles a regex pattern string into a `Regex` object.
///
/// The regex syntax follows MoonBit `lexmatch` regex literals.
/// The following constructs are recognized:
///
/// - `.` wildcard (matches any character, including newline)
/// - Character classes: `[abc]`, `[^abc]`, `[a-z]`, POSIX classes such as
/// `[[:digit:]]`, `[[:alpha:]]`, `[[:space:]]`, `[[:word:]]`
/// - Quantifiers: `*`, `+`, `?`, `{n}`, `{n,}`, `{n,m}` and non-greedy forms
/// `*?`, `+?`, `??`, `{n}?`, `{n,}?`, `{n,m}?`
/// - Grouping and alternation: `( ... )`, `(?: ... )` (non-capturing),
/// `(? ... )`, `a|b`
/// - Assertions and modifiers: `^`, `$`, `\b`, `\B`, `(?i: ... )`
///
/// Escape sequences include `\n`, `\r`, `\t`, `\f`, `\v`, and escaped
/// metacharacters. In `Regex::compile`, Unicode escapes are supported:
/// `\uXXXX` and `\u{X...}`. `\xHH` is not supported in `Regex::compile`.
///
/// `^` and `$` are non-multiline anchors: they match only the beginning and
/// end of the whole input, not per-line boundaries.
///
/// `\d`, `\D`, `\s`, `\S`, `\w`, and `\W` are not supported; use POSIX
/// character classes instead.
///
/// POSIX character classes are ASCII-based.
///
/// In character classes, the dash `-` is used to specify ranges (e.g., `[a-z]`).
/// To match a literal dash, it must be escaped as `\-`. Placing a dash at the
/// start or end of a character class (e.g., `[-a]` or `[a-]`) is not supported.
///
/// For full grammar and semantics, see:
///
///
/// Raises when `pattern` is not a valid regex pattern.
///
/// Example:
///
/// ```mbt check
/// test {
/// let regex = re"[[:digit:]]+"
/// guard regex.execute("a12b") is Some(m) else { fail("Expected match") }
/// inspect(m.content(), content="12")
/// }
/// ```
#alias(new, deprecated="Use `Regex()` instead")
pub fn Regex::Regex(pattern : StringView) -> Regex raise {
let pat = @regex_parser.parse(
profile=re_profile_unicode,
pattern,
mode=String,
)
{ pat, re: None }
}
///|
const MAX_REPEAT_QUANTIFIER = 256
///|
pub impl @debug.Debug for Regex with fn to_repr(self) {
@debug.Repr::opaque_("Regex", Repr(self.pat))
}
///|
/// Result of one successful match produced by `Regex::execute`.
struct MatchResult {
input : StringView
group_names : ReadOnlyArray[String?]
result : @re.MatchResult
} derive(@debug.Debug)
///|
/// Return match `before` view.
pub fn MatchResult::before(self : MatchResult) -> StringView {
guard self.result.group(0) is Some((start, _end)) else { panic() }
self.input[0:start]
}
///|
/// Return match `after` view.
pub fn MatchResult::after(self : MatchResult) -> StringView {
guard self.result.group(0) is Some((_start, end)) else { panic() }
self.input[end:self.input.length()]
}
///|
/// Return match `content` view.
pub fn MatchResult::content(self : MatchResult) -> StringView {
guard self.result.group(0) is Some((start, end)) else { panic() }
self.input[start:end]
}
///|
/// Access capture `group` information.
pub fn MatchResult::group(self : MatchResult, group_index : Int) -> StringView? {
match self.result.group(group_index) {
None => None
Some((start, end)) => Some(self.input[start:end])
}
}
///|
/// Access capture `named_group` information.
pub fn MatchResult::named_group(
self : MatchResult,
name : String,
) -> StringView? {
match self.group_names.search(Some(name)) {
None => None
Some(index) => self.group(index)
}
}
///|
/// DO NOT call this function even in this package.
#internal(internal, "not intended for public use")
#doc(hidden)
pub fn Regex::internal_compile_pattern(pat : Pattern) -> Regex {
let lowered_pat = re_lower_to_utf16(pat.0)
{ pat: pat.0, re: Some(@re.compile(profile=re_profile_utf16, lowered_pat)) }
}
///|
/// DO NOT call this function even in this package.
#internal(internal, "not intended for public use")
#doc(hidden)
pub fn Regex::internal_from_string(pat : String) -> Regex {
try! Regex(pat)
}
///|
fn Regex::re(self : Regex) -> @re.Regex {
match self.re {
Some(re) => re
None => {
let lowered_pat = re_lower_to_utf16(self.pat)
let re = @re.compile(profile=re_profile_utf16, lowered_pat)
self.re = Some(re)
re
}
}
}
///|
/// Compiles a regex pattern string into a `Regex` object, panicking on invalid patterns.
///
/// This function is equivalent to `Regex(pattern)` but converts any compilation
/// error into a panic instead of raising an exception.
///
/// The regex syntax follows the same rules as `Regex`.
///
/// Panics when `pattern` is not a valid regex pattern.
///
/// Example:
///
/// ```mbt check
/// test {
/// let regex = @string.Regex::unsafe_from_string("[[:digit:]]+")
/// guard regex.execute("a12b") is Some(m) else { fail("Expected match") }
/// inspect(m.content(), content="12")
/// }
/// ```
pub fn Regex::unsafe_from_string(pattern : StringView) -> Regex {
try! Regex(pattern)
}
///|
/// Builds a regex that matches `str` literally.
///
/// This is equivalent to `Regex(Regex::escape(str))`.
///
/// Example:
///
/// ```mbt check
/// test {
/// let regex = @string.Regex::string("a+b(c)")
/// inspect(regex.execute("a+b(c)") is Some(_), content="true")
/// inspect(regex.execute("abcc") is Some(_), content="false")
/// }
/// ```
pub fn Regex::string(str : StringView) -> Regex {
let pat = @re.seq(
ReadOnlyArray::from_array(
str.to_array().map(b => @re.char(@re.RecharSet::char(b.to_int()))),
),
)
{ pat, re: None }
}
///|
/// Repeats this regex with a quantifier and returns a new regex.
///
/// - `min` is the minimum number of repetitions (default `0`)
/// - `max` is the optional maximum number of repetitions
/// - `greedy` controls whether matching is greedy or non-greedy
/// - Panics if `min < 0`, `min > 256`, `max < min`, or `max > 256`
///
/// Example:
///
/// ```mbt check
/// test {
/// let greedy = re"[[:digit:]]".repeat(min=2, max=4)
/// guard greedy.execute("a12345") is Some(m1) else { fail("Expected match") }
/// inspect(m1.content(), content="1234")
///
/// let nongreedy = re"[[:digit:]]".repeat(min=2, max=4, greedy=false)
/// guard nongreedy.execute("a12345") is Some(m2) else { fail("Expected match") }
/// inspect(m2.content(), content="12")
/// }
/// ```
pub fn Regex::repeat(
self : Regex,
min? : Int = 0,
max? : Int,
greedy? : Bool = true,
) -> Regex {
guard min >= 0 && min <= MAX_REPEAT_QUANTIFIER else { panic() }
guard max is None ||
(max is Some(max) && max >= min && max <= MAX_REPEAT_QUANTIFIER) else {
panic()
}
{
pat: @re.quantifier(self.pat, {
min,
max,
mode: if greedy {
Greedy
} else {
NonGreedy
},
}),
re: None,
}
}
///|
/// Concatenates two regexes in sequence.
///
/// Example:
///
/// ```mbt check
/// test {
/// let regex = @string.Regex::string("ab") + @string.Regex::string("cd")
/// guard regex.execute("xabcd") is Some(m) else { fail("Expected match") }
/// inspect(m.content(), content="abcd")
/// }
/// ```
#intrinsic("%regex.seq")
pub impl Add for Regex with fn add(self, other) -> Regex {
{ pat: @re.seq([self.pat, other.pat]), re: None }
}
///|
/// Builds an alternation that matches either regex.
///
/// Example:
///
/// ```mbt check
/// test {
/// let regex = @string.Regex::string("cat") | @string.Regex::string("dog")
/// inspect(regex.execute("dog") is Some(_), content="true")
/// inspect(regex.execute("cow") is Some(_), content="false")
/// }
/// ```
#intrinsic("%regex.alt")
pub impl BitOr for Regex with fn lor(self, other) -> Regex {
{ pat: @re.alt([self.pat, other.pat]), re: None }
}
///|
/// Executes this regex on `input` and returns the first match found.
///
/// Search starts at `last_index` (default `0`). The returned match, when
/// present, starts at or after that index.
///
/// `last_index` must satisfy `0 <= last_index <= input.length()`.
///
/// For inputs containing supplementary Unicode characters, `last_index` must
/// also be a valid UTF-16 character boundary (that is, not the second code
/// unit of a surrogate pair).
///
/// Passing a `last_index` in the middle of a surrogate pair may produce match
/// offsets that later cause `MatchResult::before`, `MatchResult::content`, or
/// `MatchResult::after` to panic when slicing.
///
/// `last_index` only controls where searching starts. It does **not** change
/// anchor semantics:
///
/// - `^` still matches only the beginning of `input`
/// - `$` still matches only the end of `input`
///
/// This parameter is needed by iterative operations such as
/// `Regex::find`, `Regex::replace_by`, and `Regex::split`, which repeatedly
/// resume searching from the end of the previous match while keeping anchor
/// behavior relative to the full `input`.
///
/// Returns `None` when there is no match from `last_index` to the end of
/// `input`.
///
/// Example:
///
/// ```mbt check
/// test {
/// let regex = re"[[:digit:]]+"
/// let input = "a12b34"
///
/// guard regex.execute(input) is Some(first) else {
/// fail("Expected first match")
/// }
/// inspect(first.content(), content="12")
///
/// let next = first.before().length() + first.content().length()
/// guard regex.execute(input, last_index=next) is Some(second) else {
/// fail("Expected second match")
/// }
/// inspect(second.content(), content="34")
/// }
/// ```
///
/// ```mbt check
/// test {
/// let anchored = re"^ab$"
/// inspect(anchored.execute("ab", last_index=0) is Some(_), content="true")
/// inspect(anchored.execute("xaby", last_index=1) is Some(_), content="false")
/// }
/// ```
pub fn Regex::execute(
self : Regex,
input : StringView,
last_index? : Int = 0,
) -> MatchResult? {
match self.re().execute(input, last_index) {
None => None
Some(result) =>
Some({ input, group_names: self.re().group_names(), result })
}
}
///|
/// Wraps this regex in a named capture group.
///
/// Returns a new regex that captures the entire match of `self` with the
/// specified `group_name`.
///
/// Example:
///
/// ```mbt check
/// test {
/// let digit = re"[[:digit:]]+".capture("number")
/// let regex = @string.Regex::string("ID: ") + digit
/// guard regex.execute("ID: 12345") is Some(m) else { fail("Expected match") }
/// debug_inspect(
/// m.named_group("number"),
/// content=(
/// #|Some()
/// ),
/// )
/// }
/// ```
///
/// ```mbt check
/// test {
/// let user = re"[[:alpha:]]+".capture("user")
/// let domain = re"[[:alpha:]]+".capture("domain")
/// let tld = re"[[:alpha:]]+".capture("tld")
/// let email = user +
/// @string.Regex::string("@") +
/// domain +
/// @string.Regex::string(".") +
/// tld
/// guard email.execute("john@example.com") is Some(m) else {
/// fail("Expected match")
/// }
/// debug_inspect(
/// m.named_group("user"),
/// content=(
/// #|Some()
/// ),
/// )
/// debug_inspect(
/// m.named_group("domain"),
/// content=(
/// #|Some()
/// ),
/// )
/// debug_inspect(
/// m.named_group("tld"),
/// content=(
/// #|Some()
/// ),
/// )
/// }
/// ```
pub fn Regex::capture(self : Regex, group_name : String) -> Regex {
{ pat: @re.capture(name=group_name, self.pat), re: None }
}