// Copyright 2026 Leo Cheng
// SPDX-License-Identifier: Apache-2.0
///|
/// Which way a lookaround looks, and whether it wants to find something.
pub enum Look {
/// `(?=…)` / `(?!…)`
Ahead(positive~ : Bool)
/// `(?<=…)` / `(?…)`: once through, no backtracking into it.
Atomic(node~ : Node)
/// A lookaround.
Lookaround(node~ : Node, look~ : Look)
/// A backreference to what a group captured, case-folded when `icase`.
Backref(slot~ : Int32, icase~ : Bool)
/// A conditional: test, then one branch or the other.
Cond(probe~ : Cond, yes~ : Node, no~ : Node?)
/// An assertion.
Anchor(anchor~ : Anchor)
/// `\K`: everything before here stops counting towards the match.
Keep
/// `\X`: one grapheme cluster.
Grapheme
/// The enclosed node, matched with a budget of edits.
Fuzzy(spec~ : FuzzySpec, node~ : Node)
/// `(?(DEFINE)…)`: declare subroutines, match empty. The body's groups are
/// registered (and compiled into sub-programs) but never matched inline.
Define(node~ : Node)
/// A recursive call back into the pattern.
Recurse(target~ : Recurse)
/// A control verb: `(*ACCEPT)`, `(*FAIL)`, `(*MARK:name)`, `(*PRUNE)`,
/// `(*SKIP)`.
Verb(kind~ : VerbKind)
} derive(Eq, Debug)
///|
pub extend Node with Eq::{equal, not_equal}
///|
pub extend Node with Debug::{to_repr}
///|
/// A parsed pattern: the tree, and what the parse learned about it.
///
/// `flags` is the compile-time flags with the pattern's own global inline
/// flags merged in — what `Pattern::flags` answers.
pub struct Ast {
root : Node
/// How many capture groups the pattern has, group 0 included.
groups : Int32
/// Group name to the slots carrying it. An array, because ECMA-262 since
/// 2025 allows the same name in different branches.
groupindex : Map[String, Array[Int32]]
/// `\L` named lists, expanded at compile time.
named_lists : Map[String, Array[String]]
/// The effective flags, global inline flags merged in.
flags : Flags
}
///|
/// The tree on its own, for the executor.
pub fn Ast::root(self : Ast) -> Node {
self.root
}
///|
/// How many capture groups the pattern has.
pub fn Ast::groups(self : Ast) -> Int32 {
self.groups
}
///|
/// Group name to the slots carrying it.
pub fn Ast::groupindex(self : Ast) -> Map[String, Array[Int32]] {
self.groupindex
}
///|
/// The named lists the pattern declared with `\L`.
pub fn Ast::named_lists(self : Ast) -> Map[String, Array[String]] {
self.named_lists
}
///|
/// The effective flags.
pub fn Ast::flags(self : Ast) -> Flags {
self.flags
}
///|
/// The syntax tree as text: what `Pattern::dump` answers.
///
/// Not the pattern — a parse and re-print loses spacing and comments, and
/// folds flags into the nodes — but close enough to read a pattern back from,
/// which is what tests and debugging want.
pub fn Ast::dump(self : Ast) -> String {
let buf = StringBuilder::StringBuilder()
dump_node(self.root, buf)
buf.to_string()
}
///|
fn dump_node(node : Node, buf : StringBuilder) -> Unit {
match node {
Empty => buf.write_string("")
Char(code~) => dump_char(code, buf)
Any(dotall~) => buf.write_string(if dotall { "\\n" } else { "." })
Class(set~) => dump_class(set, buf)
Seq(items~) =>
for item in items {
dump_node(item, buf)
}
Alt(items~) => {
let mut first = true
for item in items {
if !first {
buf.write_string("|")
}
first = false
dump_node(item, buf)
}
}
Repeat(node~, min~, max~, greedy~) => {
dump_node(node, buf)
match max {
None =>
if min == 0 {
buf.write_string("*")
} else if min == 1 {
buf.write_string("+")
} else {
buf.write_string("{")
buf.write_string(min.to_string())
buf.write_string(",}")
}
Some(max) =>
if min == max {
buf.write_string("{")
buf.write_string(min.to_string())
buf.write_string("}")
} else if min == 0 && max == 1 {
buf.write_string("?")
} else if min == 0 {
buf.write_string("{")
buf.write_string(max.to_string())
buf.write_string("}")
} else {
buf.write_string("{")
buf.write_string(min.to_string())
buf.write_string(",")
buf.write_string(max.to_string())
buf.write_string("}")
}
}
if !greedy {
buf.write_string("?")
}
}
Group(slot~, node~) => {
ignore(slot)
buf.write_string("(")
dump_node(node, buf)
buf.write_string(")")
}
Atomic(node~) => {
buf.write_string("(?>")
dump_node(node, buf)
buf.write_string(")")
}
Lookaround(node~, look~) => {
buf.write_string(
match look {
Ahead(positive=true) => "(?="
Ahead(positive=false) => "(?!"
Behind(positive=true) => "(?<="
Behind(positive=false) => "(? {
buf.write_string("\\")
buf.write_string(slot.to_string())
}
Cond(probe~, yes~, no~) => {
buf.write_string("(?(")
match probe {
ByGroup(slot~) => buf.write_string("\{slot}")
ByLook(..) => buf.write_string("?")
ByRecurse(target~) =>
buf.write_string(
match target {
AnyRecursion => "R"
GroupRecursion(slot~) => "R\{slot}"
NamedRecursion(name~) => "R&" + name
},
)
}
dump_node(yes, buf)
match no {
None => ()
Some(no) => {
buf.write_string("|")
dump_node(no, buf)
}
}
buf.write_string(")")
}
Anchor(anchor~) =>
buf.write_string(
match anchor {
StartLine(multiline=true) => "^"
StartLine(multiline=false) => "\\A"
EndLine(multiline=true, ..) => "$"
EndLine(multiline=false, before_final_newline=true) => "$"
EndLine(multiline=false, before_final_newline=false) => "\\Z"
TextStart => "\\A"
TextEnd => "\\Z"
WordBoundary(negate=false, ..) => "\\b"
WordBoundary(negate=true, ..) => "\\B"
WordStart => "\\m"
WordEnd => "\\M"
SearchAnchor => "\\G"
},
)
Keep => buf.write_string("\\K")
Grapheme => buf.write_string("\\X")
Fuzzy(spec~, node~) => {
dump_node(node, buf)
buf.write_string(dump_fuzzy(spec))
}
Define(node~) => {
buf.write_string("(?(DEFINE)")
dump_node(node, buf)
buf.write_string(")")
}
Recurse(target~) =>
match target {
Whole => buf.write_string("(?R)")
Group(slot~) => {
buf.write_string("(?")
buf.write_string(slot.to_string())
buf.write_string(")")
}
Named(name~) => {
buf.write_string("(?&")
buf.write_string(name)
buf.write_string(")")
}
}
Verb(kind~) =>
buf.write_string(
match kind {
Accept => "(*ACCEPT)"
Fail => "(*FAIL)"
Commit => "(*COMMIT)"
Mark(name~) => "(*MARK:" + name + ")"
Prune(name~) => verb_arg("(*PRUNE", name)
Skip(name~) => verb_arg("(*SKIP", name)
Then(name~) => verb_arg("(*THEN", name)
LimitMatch(n~) => "(*LIMIT_MATCH:\{n})"
LimitRecursion(n~) => "(*LIMIT_RECURSION:\{n})"
Version => "(*VERSION)"
Fence => "(*FENCE)"
},
)
}
}
///|
fn verb_arg(verb : String, name : String?) -> String {
match name {
None => verb + ")"
Some(n) => verb + ":" + n + ")"
}
}
///|
fn dump_fuzzy(spec : FuzzySpec) -> String {
let parts : Array[String] = []
match spec.insertions {
Some(n) => parts.push("i<=\{n}")
None => ()
}
match spec.deletions {
Some(n) => parts.push("d<=\{n}")
None => ()
}
match spec.substitutions {
Some(n) => parts.push("s<=\{n}")
None => ()
}
match spec.errors {
Some(n) => parts.push("e<=\{n}")
None => ()
}
match spec.cost {
None => ()
Some((i, d, s)) =>
if i == 1 && d == 1 && s == 1 {
parts.push("e<=1")
} else {
parts.push("\{i}i+\{d}d+\{s}s<=1")
}
}
"{" + parts.join(",") + "}"
}
///|
fn hex_of(code : Int32) -> String {
let table = "0123456789ABCDEF"
let buf = StringBuilder::StringBuilder()
let mut n = code
if n == 0 {
buf.write_char('0')
return buf.to_string()
}
// Collect the digits backwards, then write them in order.
let digs : Array[Char] = []
while n > 0 {
digs.push(table[n % 16].to_int().unsafe_to_char())
n = n / 16
}
let mut i = digs.length() - 1
while i >= 0 {
buf.write_char(digs[i])
i -= 1
}
buf.to_string()
}
///|
fn dump_char(code : Int32, buf : StringBuilder) -> Unit {
// Printable ASCII stands for itself; everything else is spelled out, so the
// dump is unambiguous about what the tree holds.
if code >= 0x20 && code < 0x7F {
buf.write_char(code.unsafe_to_char())
} else {
buf.write_string("\\x")
buf.write_string(hex_of(code))
}
}
///|
fn dump_class(set : @prop.CharSet, buf : StringBuilder) -> Unit {
buf.write_string("[")
for (lo, hi) in set.ranges() {
if lo == hi {
dump_char(lo, buf)
} else {
dump_char(lo, buf)
buf.write_string("-")
dump_char(hi, buf)
}
}
buf.write_string("]")
}
// ---------------------------------------------------------------------------
// Building a tree by hand
// ---------------------------------------------------------------------------
///|
/// An `Ast` assembled from parts rather than parsed, so patterns can be
/// composed in code and still be the same kind of value as a parsed one.
pub fn Ast::built(
root : Node,
groups~ : Int32,
groupindex~ : Map[String, Array[Int32]],
flags~ : Flags,
) -> Ast {
{ root, groups, groupindex, named_lists: {}, flags, }
}
///|
/// The tree with every capture slot moved up by `by`.
///
/// Composing two patterns renumbers the right-hand one's groups so the result
/// is numbered as one written pattern would be — the left operand keeps
/// `1..n`, the right one continues at `n+1`.
pub fn Node::shift_slots(self : Node, by : Int32) -> Node {
if by == 0 {
return self
}
shift(self, by)
}
///|
fn shift(node : Node, by : Int32) -> Node {
match node {
Empty
| Char(_)
| Any(_)
| Class(_)
| Anchor(_)
| Keep
| Grapheme
| Verb(_) => node
Seq(items~) => Seq(items=items.map(item => shift(item, by)))
Alt(items~) => Alt(items=items.map(item => shift(item, by)))
Repeat(node~, min~, max~, greedy~) =>
Repeat(node=shift(node, by), min~, max~, greedy~)
Group(slot~, node~) => Group(slot=slot + by, node=shift(node, by))
Atomic(node~) => Atomic(node=shift(node, by))
Lookaround(node~, look~) => Lookaround(node=shift(node, by), look~)
Backref(slot~, icase~) => Backref(slot=slot + by, icase~)
Cond(probe~, yes~, no~) =>
Cond(
probe=match probe {
ByGroup(slot~) => ByGroup(slot=slot + by)
ByLook(node~, look~) => ByLook(node=shift(node, by), look~)
ByRecurse(target~) => ByRecurse(target~)
},
yes=shift(yes, by),
no=match no {
None => None
Some(n) => Some(shift(n, by))
},
)
Fuzzy(spec~, node~) => Fuzzy(spec~, node=shift(node, by))
Define(node~) => Define(node=shift(node, by))
Recurse(target~) =>
Recurse(
target=match target {
Whole => Whole
Group(slot~) => Group(slot=slot + by)
Named(name~) => Named(name~)
},
)
}
}