diff --git a/crates/js/src/builtins.rs b/crates/js/src/builtins.rs index 8161a58..acffdbe 100644 --- a/crates/js/src/builtins.rs +++ b/crates/js/src/builtins.rs @@ -240,6 +240,9 @@ pub fn init_builtins(vm: &mut Vm) { // Create and register Date constructor. init_date_builtins(vm); + // Create and register RegExp constructor. + init_regexp_builtins(vm); + // Create and register JSON object (static methods only). init_json_object(vm); @@ -1498,6 +1501,9 @@ fn init_string_prototype(gc: &mut Gc, proto: GcRef) { ("split", string_proto_split), ("replace", string_proto_replace), ("replaceAll", string_proto_replace_all), + ("match", string_proto_match), + ("matchAll", string_proto_match_all), + ("search", string_proto_search), ("toLowerCase", string_proto_to_lower_case), ("toUpperCase", string_proto_to_upper_case), ("at", string_proto_at), @@ -1864,11 +1870,19 @@ fn string_proto_split(args: &[Value], ctx: &mut NativeContext) -> Result = str_chars(&s) .into_iter() @@ -1893,8 +1907,78 @@ fn string_proto_split(args: &[Value], ctx: &mut NativeContext) -> Result, + s: &str, + regexp: &Value, + limit: usize, +) -> Result { + use crate::regex::{exec, CompiledRegex}; + + let pattern = regexp_get_pattern(gc, regexp).unwrap_or_default(); + let flags_str = regexp_get_flags(gc, regexp).unwrap_or_default(); + let compiled = CompiledRegex::new(&pattern, &flags_str).map_err(RuntimeError::syntax_error)?; + let chars: Vec = s.chars().collect(); + + let mut items = Vec::new(); + let mut last_end = 0usize; + + loop { + if items.len() >= limit { + break; + } + match exec(&compiled, s, last_end) { + Some(m) => { + // Avoid infinite loop on zero-length matches. + if m.start == m.end && m.start == last_end { + if last_end >= chars.len() { + break; + } + items.push(Value::String(chars[last_end].to_string())); + last_end += 1; + continue; + } + let piece: String = chars[last_end..m.start].iter().collect(); + items.push(Value::String(piece)); + // Add capturing groups. + for i in 1..m.captures.len() { + if items.len() >= limit { + break; + } + match m.captures[i] { + Some((cs, ce)) => { + let cap: String = chars[cs..ce].iter().collect(); + items.push(Value::String(cap)); + } + None => items.push(Value::Undefined), + } + } + last_end = m.end; + } + None => break, + } + } + if items.len() < limit { + let rest: String = chars[last_end..].iter().collect(); + items.push(Value::String(rest)); + } + Ok(make_value_array(gc, &items)) +} + fn string_proto_replace(args: &[Value], ctx: &mut NativeContext) -> Result { let s = this_string(ctx); + + // Check if search argument is a RegExp. + if let Some(arg0) = args.first() { + if is_regexp(ctx.gc, arg0) { + let replacement = args + .get(1) + .map(|v| v.to_js_string(ctx.gc)) + .unwrap_or_default(); + return string_replace_regexp(ctx.gc, &s, arg0, &replacement); + } + } + let search = args .first() .map(|v| v.to_js_string(ctx.gc)) @@ -1915,11 +1999,144 @@ fn string_proto_replace(args: &[Value], ctx: &mut NativeContext) -> Result, + s: &str, + regexp: &Value, + replacement: &str, +) -> Result { + use crate::regex::{exec, CompiledRegex}; + + let pattern = regexp_get_pattern(gc, regexp).unwrap_or_default(); + let flags_str = regexp_get_flags(gc, regexp).unwrap_or_default(); + let compiled = CompiledRegex::new(&pattern, &flags_str).map_err(RuntimeError::syntax_error)?; + let is_global = compiled.flags.global; + let chars: Vec = s.chars().collect(); + let mut result = String::new(); + let mut last_end = 0usize; + + while let Some(m) = exec(&compiled, s, last_end) { + // Append text before match. + let before: String = chars[last_end..m.start].iter().collect(); + result.push_str(&before); + // Process replacement with $-substitutions. + let matched: String = chars[m.start..m.end].iter().collect(); + result.push_str(&apply_replacement( + replacement, + &matched, + &m.captures, + &chars, + )); + last_end = m.end; + if !is_global { + break; + } + // Avoid infinite loop on zero-length match. + if m.start == m.end { + if last_end < chars.len() { + result.push(chars[last_end]); + last_end += 1; + } else { + break; + } + } + } + let rest: String = chars[last_end..].iter().collect(); + result.push_str(&rest); + Ok(Value::String(result)) +} + +/// Apply replacement string with $-substitutions ($&, $1, etc.). +fn apply_replacement( + replacement: &str, + matched: &str, + captures: &[Option<(usize, usize)>], + chars: &[char], +) -> String { + let rep_chars: Vec = replacement.chars().collect(); + let mut result = String::new(); + let mut i = 0; + while i < rep_chars.len() { + if rep_chars[i] == '$' && i + 1 < rep_chars.len() { + match rep_chars[i + 1] { + '$' => { + result.push('$'); + i += 2; + } + '&' => { + result.push_str(matched); + i += 2; + } + '`' => { + // $` — text before match. + if let Some(Some((start, _))) = captures.first() { + let before: String = chars[..*start].iter().collect(); + result.push_str(&before); + } + i += 2; + } + '\'' => { + // $' — text after match. + if let Some(Some((_, end))) = captures.first() { + let after: String = chars[*end..].iter().collect(); + result.push_str(&after); + } + i += 2; + } + d if d.is_ascii_digit() => { + // $1, $12 etc. + let mut num_str = String::new(); + let mut j = i + 1; + while j < rep_chars.len() && rep_chars[j].is_ascii_digit() { + num_str.push(rep_chars[j]); + j += 1; + } + if let Ok(idx) = num_str.parse::() { + if idx > 0 && idx < captures.len() { + if let Some((s, e)) = captures[idx] { + let cap: String = chars[s..e].iter().collect(); + result.push_str(&cap); + } + } + } + i = j; + } + _ => { + result.push('$'); + i += 1; + } + } + } else { + result.push(rep_chars[i]); + i += 1; + } + } + result +} + fn string_proto_replace_all( args: &[Value], ctx: &mut NativeContext, ) -> Result { let s = this_string(ctx); + + // If search is a RegExp, it must have the global flag. + if let Some(arg0) = args.first() { + if is_regexp(ctx.gc, arg0) { + let flags = regexp_get_flags(ctx.gc, arg0).unwrap_or_default(); + if !flags.contains('g') { + return Err(RuntimeError::type_error( + "String.prototype.replaceAll called with a non-global RegExp argument", + )); + } + let replacement = args + .get(1) + .map(|v| v.to_js_string(ctx.gc)) + .unwrap_or_default(); + return string_replace_regexp(ctx.gc, &s, arg0, &replacement); + } + } + let search = args .first() .map(|v| v.to_js_string(ctx.gc)) @@ -1931,6 +2148,142 @@ fn string_proto_replace_all( Ok(Value::String(s.replace(&search, &replacement))) } +fn string_proto_match(args: &[Value], ctx: &mut NativeContext) -> Result { + let s = this_string(ctx); + if args.is_empty() { + return Ok(Value::Null); + } + + let arg0 = &args[0]; + // If arg is not a RegExp, create one. + let regexp_val = if is_regexp(ctx.gc, arg0) { + arg0.clone() + } else { + let pattern = arg0.to_js_string(ctx.gc); + let proto = REGEXP_PROTO.with(|cell| cell.get()); + make_regexp_obj(ctx.gc, &pattern, "", proto).map_err(RuntimeError::syntax_error)? + }; + + let is_global = regexp_get_flags(ctx.gc, ®exp_val) + .map(|f| f.contains('g')) + .unwrap_or(false); + + if !is_global { + // Non-global: return exec result. + return regexp_exec_internal(ctx.gc, ®exp_val, &s); + } + + // Global: collect all matches. + regexp_set_last_index(ctx.gc, ®exp_val, 0.0); + let mut matches = Vec::new(); + loop { + let result = regexp_exec_internal(ctx.gc, ®exp_val, &s)?; + if matches!(result, Value::Null) { + break; + } + // Get the matched string (index 0 of the result array). + if let Value::Object(r) = &result { + if let Some(HeapObject::Object(data)) = ctx.gc.get(*r) { + if let Some(prop) = data.properties.get("0") { + matches.push(prop.value.clone()); + // Advance past zero-length matches. + let match_str = prop.value.to_js_string(ctx.gc); + if match_str.is_empty() { + let li = regexp_get_last_index(ctx.gc, ®exp_val); + regexp_set_last_index(ctx.gc, ®exp_val, li + 1.0); + } + } + } + } + } + if matches.is_empty() { + Ok(Value::Null) + } else { + Ok(make_value_array(ctx.gc, &matches)) + } +} + +fn string_proto_match_all(args: &[Value], ctx: &mut NativeContext) -> Result { + let s = this_string(ctx); + if args.is_empty() { + return Ok(make_value_array(ctx.gc, &[])); + } + + let arg0 = &args[0]; + // If arg is a RegExp, it must have global flag. + let regexp_val = if is_regexp(ctx.gc, arg0) { + let flags = regexp_get_flags(ctx.gc, arg0).unwrap_or_default(); + if !flags.contains('g') { + return Err(RuntimeError::type_error( + "String.prototype.matchAll called with a non-global RegExp argument", + )); + } + arg0.clone() + } else { + let pattern = arg0.to_js_string(ctx.gc); + let proto = REGEXP_PROTO.with(|cell| cell.get()); + make_regexp_obj(ctx.gc, &pattern, "g", proto).map_err(RuntimeError::syntax_error)? + }; + + // Collect all match results. + regexp_set_last_index(ctx.gc, ®exp_val, 0.0); + let mut results = Vec::new(); + loop { + let result = regexp_exec_internal(ctx.gc, ®exp_val, &s)?; + if matches!(result, Value::Null) { + break; + } + // Advance past zero-length matches. + if let Value::Object(r) = &result { + if let Some(HeapObject::Object(data)) = ctx.gc.get(*r) { + if let Some(prop) = data.properties.get("0") { + let match_str = prop.value.to_js_string(ctx.gc); + if match_str.is_empty() { + let li = regexp_get_last_index(ctx.gc, ®exp_val); + regexp_set_last_index(ctx.gc, ®exp_val, li + 1.0); + } + } + } + } + results.push(result); + } + Ok(make_value_array(ctx.gc, &results)) +} + +fn string_proto_search(args: &[Value], ctx: &mut NativeContext) -> Result { + let s = this_string(ctx); + if args.is_empty() { + return Ok(Value::Number(0.0)); // /(?:)/ matches at 0. + } + + let arg0 = &args[0]; + let regexp_val = if is_regexp(ctx.gc, arg0) { + arg0.clone() + } else { + let pattern = arg0.to_js_string(ctx.gc); + let proto = REGEXP_PROTO.with(|cell| cell.get()); + make_regexp_obj(ctx.gc, &pattern, "", proto).map_err(RuntimeError::syntax_error)? + }; + + // search always starts from 0 and ignores global/lastIndex. + let result = regexp_exec_internal(ctx.gc, ®exp_val, &s)?; + match result { + Value::Null => Ok(Value::Number(-1.0)), + Value::Object(r) => { + let idx = match ctx.gc.get(r) { + Some(HeapObject::Object(data)) => data + .properties + .get("index") + .map(|p| p.value.to_number()) + .unwrap_or(-1.0), + _ => -1.0, + }; + Ok(Value::Number(idx)) + } + _ => Ok(Value::Number(-1.0)), + } +} + fn string_proto_to_lower_case( args: &[Value], ctx: &mut NativeContext, @@ -3088,6 +3441,375 @@ fn date_to_json(_args: &[Value], ctx: &mut NativeContext) -> Result> = const { std::cell::Cell::new(None) }; +} + +fn init_regexp_builtins(vm: &mut Vm) { + // RegExp.prototype. + let mut regexp_proto_data = ObjectData::new(); + if let Some(proto) = vm.object_prototype { + regexp_proto_data.prototype = Some(proto); + } + let regexp_proto = vm.gc.alloc(HeapObject::Object(regexp_proto_data)); + init_regexp_prototype(&mut vm.gc, regexp_proto); + + vm.regexp_prototype = Some(regexp_proto); + REGEXP_PROTO.with(|cell| cell.set(Some(regexp_proto))); + + // RegExp constructor function. + let ctor = vm.gc.alloc(HeapObject::Function(Box::new(FunctionData { + name: "RegExp".to_string(), + kind: FunctionKind::Native(NativeFunc { + callback: regexp_constructor, + }), + prototype_obj: Some(regexp_proto), + properties: HashMap::new(), + upvalues: Vec::new(), + }))); + + vm.set_global("RegExp", Value::Function(ctor)); +} + +fn init_regexp_prototype(gc: &mut Gc, proto: GcRef) { + let methods: &[NativeMethod] = &[ + ("test", regexp_proto_test), + ("exec", regexp_proto_exec), + ("toString", regexp_proto_to_string), + ]; + for &(name, callback) in methods { + let f = make_native(gc, name, callback); + set_builtin_prop(gc, proto, name, Value::Function(f)); + } +} + +/// Create a RegExp object storing compiled regex state in hidden properties. +pub fn make_regexp_obj( + gc: &mut Gc, + pattern: &str, + flags_str: &str, + proto: Option, +) -> Result { + use crate::regex::CompiledRegex; + + let compiled = CompiledRegex::new(pattern, flags_str)?; + let flags = compiled.flags; + + let mut data = ObjectData::new(); + if let Some(p) = proto { + data.prototype = Some(p); + } + // Store pattern and flags as properties. + data.properties.insert( + "source".to_string(), + Property::builtin(Value::String(pattern.to_string())), + ); + let flags_string = flags.as_flag_string(); + data.properties.insert( + "flags".to_string(), + Property::builtin(Value::String(flags_string)), + ); + data.properties.insert( + "global".to_string(), + Property::builtin(Value::Boolean(flags.global)), + ); + data.properties.insert( + "ignoreCase".to_string(), + Property::builtin(Value::Boolean(flags.ignore_case)), + ); + data.properties.insert( + "multiline".to_string(), + Property::builtin(Value::Boolean(flags.multiline)), + ); + data.properties.insert( + "dotAll".to_string(), + Property::builtin(Value::Boolean(flags.dot_all)), + ); + data.properties.insert( + "unicode".to_string(), + Property::builtin(Value::Boolean(flags.unicode)), + ); + data.properties.insert( + "sticky".to_string(), + Property::builtin(Value::Boolean(flags.sticky)), + ); + data.properties + .insert("lastIndex".to_string(), Property::data(Value::Number(0.0))); + // Hidden: serialized pattern for re-compilation. + data.properties.insert( + "__regexp_pattern__".to_string(), + Property::builtin(Value::String(pattern.to_string())), + ); + data.properties.insert( + "__regexp_flags__".to_string(), + Property::builtin(Value::String(flags.as_flag_string())), + ); + + Ok(Value::Object(gc.alloc(HeapObject::Object(data)))) +} + +/// Check if a Value is a RegExp object. +pub fn is_regexp(gc: &Gc, val: &Value) -> bool { + match val { + Value::Object(r) => match gc.get(*r) { + Some(HeapObject::Object(data)) => data.properties.contains_key("__regexp_pattern__"), + _ => false, + }, + _ => false, + } +} + +/// Extract the pattern from a RegExp object. +fn regexp_get_pattern(gc: &Gc, val: &Value) -> Option { + match val { + Value::Object(r) => match gc.get(*r) { + Some(HeapObject::Object(data)) => { + data.properties + .get("__regexp_pattern__") + .and_then(|p| match &p.value { + Value::String(s) => Some(s.clone()), + _ => None, + }) + } + _ => None, + }, + _ => None, + } +} + +/// Extract the flags string from a RegExp object. +fn regexp_get_flags(gc: &Gc, val: &Value) -> Option { + match val { + Value::Object(r) => match gc.get(*r) { + Some(HeapObject::Object(data)) => { + data.properties + .get("__regexp_flags__") + .and_then(|p| match &p.value { + Value::String(s) => Some(s.clone()), + _ => None, + }) + } + _ => None, + }, + _ => None, + } +} + +/// Get lastIndex from a RegExp object. +fn regexp_get_last_index(gc: &Gc, val: &Value) -> f64 { + match val { + Value::Object(r) => match gc.get(*r) { + Some(HeapObject::Object(data)) => data + .properties + .get("lastIndex") + .map(|p| p.value.to_number()) + .unwrap_or(0.0), + _ => 0.0, + }, + _ => 0.0, + } +} + +/// Set lastIndex on a RegExp object. +fn regexp_set_last_index(gc: &mut Gc, val: &Value, idx: f64) { + if let Value::Object(r) = val { + if let Some(HeapObject::Object(data)) = gc.get_mut(*r) { + if let Some(prop) = data.properties.get_mut("lastIndex") { + prop.value = Value::Number(idx); + } + } + } +} + +/// Execute the regex on a string and return a match result array or null. +fn regexp_exec_internal( + gc: &mut Gc, + this: &Value, + input: &str, +) -> Result { + use crate::regex::{exec, CompiledRegex}; + + let pattern = regexp_get_pattern(gc, this) + .ok_or_else(|| RuntimeError::type_error("not a RegExp".to_string()))?; + let flags_str = regexp_get_flags(gc, this).unwrap_or_default(); + let compiled = CompiledRegex::new(&pattern, &flags_str).map_err(RuntimeError::syntax_error)?; + let is_global = compiled.flags.global; + let is_sticky = compiled.flags.sticky; + + let start_index = if is_global || is_sticky { + let li = regexp_get_last_index(gc, this); + if li < 0.0 { + 0 + } else { + li as usize + } + } else { + 0 + }; + + let chars: Vec = input.chars().collect(); + let result = exec(&compiled, input, start_index); + + match result { + Some(m) => { + if is_global || is_sticky { + regexp_set_last_index(gc, this, m.end as f64); + } + + // Build result array: [fullMatch, ...groups] + let mut items: Vec = Vec::new(); + + // Full match (index 0). + let full: String = chars[m.start..m.end].iter().collect(); + items.push(Value::String(full)); + + // Capture groups (index 1..n). + for i in 1..m.captures.len() { + match m.captures[i] { + Some((s, e)) => { + let cap: String = chars[s..e].iter().collect(); + items.push(Value::String(cap)); + } + None => items.push(Value::Undefined), + } + } + + // Build named groups object (if any) before creating the array. + let groups_val = { + let (node, _) = + crate::regex::parse_pattern(&pattern).map_err(RuntimeError::syntax_error)?; + let named = collect_named_groups(&node); + if named.is_empty() { + Value::Undefined + } else { + let mut groups_data = ObjectData::new(); + for (name, idx) in &named { + let cap_idx = *idx as usize; + let val = if cap_idx < m.captures.len() { + match m.captures[cap_idx] { + Some((s, e)) => { + let cap: String = chars[s..e].iter().collect(); + Value::String(cap) + } + None => Value::Undefined, + } + } else { + Value::Undefined + }; + groups_data + .properties + .insert(name.clone(), Property::data(val)); + } + Value::Object(gc.alloc(HeapObject::Object(groups_data))) + } + }; + + let arr = make_value_array(gc, &items); + // Set index, input, and groups properties on the result array. + if let Value::Object(r) = arr { + if let Some(HeapObject::Object(data)) = gc.get_mut(r) { + data.properties.insert( + "index".to_string(), + Property::data(Value::Number(m.start as f64)), + ); + data.properties.insert( + "input".to_string(), + Property::data(Value::String(input.to_string())), + ); + data.properties + .insert("groups".to_string(), Property::data(groups_val)); + } + Ok(Value::Object(r)) + } else { + Ok(arr) + } + } + None => { + if is_global || is_sticky { + regexp_set_last_index(gc, this, 0.0); + } + Ok(Value::Null) + } + } +} + +/// Collect named groups from a regex AST node. +fn collect_named_groups(node: &crate::regex::Node) -> Vec<(String, u32)> { + use crate::regex::Node; + let mut result = Vec::new(); + match node { + Node::Group { + index, + name: Some(name), + node: inner, + } => { + result.push((name.clone(), *index)); + result.extend(collect_named_groups(inner)); + } + Node::Group { node: inner, .. } + | Node::NonCapturingGroup(inner) + | Node::Lookahead(inner) + | Node::NegativeLookahead(inner) => { + result.extend(collect_named_groups(inner)); + } + Node::Quantifier { node: inner, .. } => { + result.extend(collect_named_groups(inner)); + } + Node::Sequence(nodes) | Node::Alternation(nodes) => { + for n in nodes { + result.extend(collect_named_groups(n)); + } + } + _ => {} + } + result +} + +fn regexp_constructor(args: &[Value], ctx: &mut NativeContext) -> Result { + let proto = REGEXP_PROTO.with(|cell| cell.get()); + let pattern = args + .first() + .map(|v| v.to_js_string(ctx.gc)) + .unwrap_or_default(); + let flags = args + .get(1) + .map(|v| { + if matches!(v, Value::Undefined) { + String::new() + } else { + v.to_js_string(ctx.gc) + } + }) + .unwrap_or_default(); + + make_regexp_obj(ctx.gc, &pattern, &flags, proto).map_err(RuntimeError::syntax_error) +} + +fn regexp_proto_test(args: &[Value], ctx: &mut NativeContext) -> Result { + let input = args + .first() + .map(|v| v.to_js_string(ctx.gc)) + .unwrap_or_default(); + let result = regexp_exec_internal(ctx.gc, &ctx.this, &input)?; + Ok(Value::Boolean(!matches!(result, Value::Null))) +} + +fn regexp_proto_exec(args: &[Value], ctx: &mut NativeContext) -> Result { + let input = args + .first() + .map(|v| v.to_js_string(ctx.gc)) + .unwrap_or_default(); + regexp_exec_internal(ctx.gc, &ctx.this, &input) +} + +fn regexp_proto_to_string(_args: &[Value], ctx: &mut NativeContext) -> Result { + let pattern = regexp_get_pattern(ctx.gc, &ctx.this).unwrap_or_default(); + let flags = regexp_get_flags(ctx.gc, &ctx.this).unwrap_or_default(); + Ok(Value::String(format!("/{}/{}", pattern, flags))) +} + // ── JSON object ────────────────────────────────────────────── fn init_json_object(vm: &mut Vm) { diff --git a/crates/js/src/compiler.rs b/crates/js/src/compiler.rs index 5a25e86..d6c0c98 100644 --- a/crates/js/src/compiler.rs +++ b/crates/js/src/compiler.rs @@ -2403,9 +2403,26 @@ fn compile_expr(fc: &mut FunctionCompiler, expr: &Expr, dst: Reg) -> Result<(), compile_expr(fc, inner, dst)?; } - ExprKind::RegExp { .. } => { - // RegExp literals are created at runtime by the VM. - fc.builder.emit_reg(Op::LoadUndefined, dst); + ExprKind::RegExp { pattern, flags } => { + // Compile as: RegExp(pattern, flags) — a call to the global constructor. + let func_reg = fc.alloc_reg(); + let name_idx = fc.builder.add_name("RegExp"); + fc.builder.emit_reg_u16(Op::LoadGlobal, func_reg, name_idx); + + let args_start = fc.next_reg; + let pat_reg = fc.alloc_reg(); + let pat_idx = fc.builder.add_constant(Constant::String(pattern.clone())); + fc.builder.emit_reg_u16(Op::LoadConst, pat_reg, pat_idx); + + let flags_reg = fc.alloc_reg(); + let flags_idx = fc.builder.add_constant(Constant::String(flags.clone())); + fc.builder.emit_reg_u16(Op::LoadConst, flags_reg, flags_idx); + + fc.builder.emit_call(dst, func_reg, args_start, 2); + + fc.next_reg -= 1; // flags_reg + fc.next_reg -= 1; // pat_reg + fc.free_reg(func_reg); } ExprKind::OptionalChain { base } => { diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index a30e64f..5f99a76 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -7,6 +7,7 @@ pub mod compiler; pub mod gc; pub mod lexer; pub mod parser; +pub mod regex; pub mod vm; use std::fmt; diff --git a/crates/js/src/regex.rs b/crates/js/src/regex.rs new file mode 100644 index 0000000..2342ddd --- /dev/null +++ b/crates/js/src/regex.rs @@ -0,0 +1,1370 @@ +//! Pure-Rust regular expression engine for the JavaScript RegExp built-in. +//! +//! Implements a regex parser (pattern → IR) and a backtracking matcher. +//! Supports: character classes, quantifiers (greedy/lazy), anchors, groups +//! (capturing, non-capturing, named), alternation, backreferences, lookahead, +//! and standard escape sequences. + +// ── Regex AST ─────────────────────────────────────────────── + +/// A parsed regex node. +#[derive(Debug, Clone)] +pub enum Node { + /// Match a single literal character. + Literal(char), + /// `.` — match any character (respects dotAll flag). + Dot, + /// `^` — start anchor. + Start, + /// `$` — end anchor. + End, + /// `\b` — word boundary. + WordBoundary, + /// `\B` — non-word boundary. + NonWordBoundary, + /// Character class `[...]` or `[^...]`. + CharClass { + negated: bool, + ranges: Vec, + }, + /// Alternation `a|b`. + Alternation(Vec), + /// Sequence of nodes (implicit concatenation). + Sequence(Vec), + /// Quantifier applied to a sub-node. + Quantifier { + node: Box, + min: u32, + max: Option, // None = unbounded + greedy: bool, + }, + /// Capturing group `(...)`. + Group { + index: u32, + name: Option, + node: Box, + }, + /// Non-capturing group `(?:...)`. + NonCapturingGroup(Box), + /// Backreference `\1` or `\k`. + Backref(u32), + /// Positive lookahead `(?=...)`. + Lookahead(Box), + /// Negative lookahead `(?!...)`. + NegativeLookahead(Box), +} + +/// A range within a character class. +#[derive(Debug, Clone)] +pub enum ClassRange { + /// Single character. + Char(char), + /// Character range `a-z`. + Range(char, char), + /// Predefined class like `\d`, `\w`, `\s`. + Predefined(PredefinedClass), +} + +/// Predefined character classes. +#[derive(Debug, Clone, Copy)] +pub enum PredefinedClass { + Digit, // \d + NonDigit, // \D + Word, // \w + NonWord, // \W + Space, // \s + NonSpace, // \S +} + +// ── Regex flags ───────────────────────────────────────────── + +/// Parsed regex flags. +#[derive(Debug, Clone, Copy, Default)] +pub struct RegexFlags { + pub global: bool, + pub ignore_case: bool, + pub multiline: bool, + pub dot_all: bool, + pub unicode: bool, + pub sticky: bool, +} + +impl RegexFlags { + pub fn parse(flags_str: &str) -> Result { + let mut f = RegexFlags::default(); + for ch in flags_str.chars() { + match ch { + 'g' => { + if f.global { + return Err(format!("duplicate flag '{}'", ch)); + } + f.global = true; + } + 'i' => { + if f.ignore_case { + return Err(format!("duplicate flag '{}'", ch)); + } + f.ignore_case = true; + } + 'm' => { + if f.multiline { + return Err(format!("duplicate flag '{}'", ch)); + } + f.multiline = true; + } + 's' => { + if f.dot_all { + return Err(format!("duplicate flag '{}'", ch)); + } + f.dot_all = true; + } + 'u' => { + if f.unicode { + return Err(format!("duplicate flag '{}'", ch)); + } + f.unicode = true; + } + 'y' => { + if f.sticky { + return Err(format!("duplicate flag '{}'", ch)); + } + f.sticky = true; + } + _ => return Err(format!("invalid flag '{}'", ch)), + } + } + Ok(f) + } + + pub fn as_flag_string(&self) -> String { + let mut s = String::new(); + if self.global { + s.push('g'); + } + if self.ignore_case { + s.push('i'); + } + if self.multiline { + s.push('m'); + } + if self.dot_all { + s.push('s'); + } + if self.unicode { + s.push('u'); + } + if self.sticky { + s.push('y'); + } + s + } +} + +// ── Regex Parser ──────────────────────────────────────────── + +/// Parse a regex pattern string into a `Node` tree. +pub fn parse_pattern(pattern: &str) -> Result<(Node, u32), String> { + let chars: Vec = pattern.chars().collect(); + let mut parser = PatternParser { + chars: &chars, + pos: 0, + group_count: 0, + group_names: Vec::new(), + }; + let node = parser.parse_alternation()?; + if parser.pos < parser.chars.len() { + return Err(format!( + "unexpected '{}' at position {}", + parser.chars[parser.pos], parser.pos + )); + } + Ok((node, parser.group_count)) +} + +struct PatternParser<'a> { + chars: &'a [char], + pos: usize, + group_count: u32, + group_names: Vec<(String, u32)>, +} + +impl<'a> PatternParser<'a> { + fn peek(&self) -> Option { + self.chars.get(self.pos).copied() + } + + fn advance(&mut self) -> Option { + let ch = self.chars.get(self.pos).copied(); + if ch.is_some() { + self.pos += 1; + } + ch + } + + fn expect(&mut self, expected: char) -> Result<(), String> { + match self.advance() { + Some(ch) if ch == expected => Ok(()), + Some(ch) => Err(format!("expected '{}', got '{}'", expected, ch)), + None => Err(format!("expected '{}', got end of pattern", expected)), + } + } + + /// Parse alternation: `a|b|c` + fn parse_alternation(&mut self) -> Result { + let mut branches = vec![self.parse_sequence()?]; + while self.peek() == Some('|') { + self.advance(); // consume '|' + branches.push(self.parse_sequence()?); + } + if branches.len() == 1 { + Ok(branches.pop().unwrap()) + } else { + Ok(Node::Alternation(branches)) + } + } + + /// Parse a sequence of atoms (concatenation). + fn parse_sequence(&mut self) -> Result { + let mut nodes = Vec::new(); + while let Some(ch) = self.peek() { + if ch == '|' || ch == ')' { + break; + } + nodes.push(self.parse_quantified()?); + } + if nodes.len() == 1 { + Ok(nodes.pop().unwrap()) + } else { + Ok(Node::Sequence(nodes)) + } + } + + /// Parse an atom with optional quantifier. + fn parse_quantified(&mut self) -> Result { + let node = self.parse_atom()?; + if let Some(ch) = self.peek() { + match ch { + '*' | '+' | '?' => { + self.advance(); + let (min, max) = match ch { + '*' => (0, None), + '+' => (1, None), + '?' => (0, Some(1)), + _ => unreachable!(), + }; + let greedy = if self.peek() == Some('?') { + self.advance(); + false + } else { + true + }; + Ok(Node::Quantifier { + node: Box::new(node), + min, + max, + greedy, + }) + } + '{' => self.parse_brace_quantifier(node), + _ => Ok(node), + } + } else { + Ok(node) + } + } + + /// Parse `{n}`, `{n,}`, `{n,m}` quantifier. + fn parse_brace_quantifier(&mut self, node: Node) -> Result { + let save = self.pos; + self.advance(); // consume '{' + let min = match self.parse_decimal() { + Some(n) => n, + None => { + // Not a valid quantifier — treat '{' as literal. + self.pos = save; + return Ok(node); + } + }; + let max; + match self.peek() { + Some('}') => { + self.advance(); + max = Some(min); + } + Some(',') => { + self.advance(); + if self.peek() == Some('}') { + self.advance(); + max = None; + } else { + match self.parse_decimal() { + Some(n) => { + max = Some(n); + if self.peek() != Some('}') { + self.pos = save; + return Ok(node); + } + self.advance(); + } + None => { + self.pos = save; + return Ok(node); + } + } + } + } + _ => { + self.pos = save; + return Ok(node); + } + } + let greedy = if self.peek() == Some('?') { + self.advance(); + false + } else { + true + }; + Ok(Node::Quantifier { + node: Box::new(node), + min, + max, + greedy, + }) + } + + fn parse_decimal(&mut self) -> Option { + let start = self.pos; + while let Some(ch) = self.peek() { + if ch.is_ascii_digit() { + self.advance(); + } else { + break; + } + } + if self.pos == start { + return None; + } + let s: String = self.chars[start..self.pos].iter().collect(); + s.parse().ok() + } + + /// Parse a single atom. + fn parse_atom(&mut self) -> Result { + match self.peek() { + None => Err("unexpected end of pattern".to_string()), + Some('.') => { + self.advance(); + Ok(Node::Dot) + } + Some('^') => { + self.advance(); + Ok(Node::Start) + } + Some('$') => { + self.advance(); + Ok(Node::End) + } + Some('\\') => self.parse_escape(), + Some('[') => self.parse_char_class(), + Some('(') => self.parse_group(), + Some(ch) => { + self.advance(); + Ok(Node::Literal(ch)) + } + } + } + + /// Parse an escape sequence. + fn parse_escape(&mut self) -> Result { + self.advance(); // consume '\' + match self.advance() { + None => Err("unexpected end of pattern after '\\'".to_string()), + Some('d') => Ok(Node::CharClass { + negated: false, + ranges: vec![ClassRange::Predefined(PredefinedClass::Digit)], + }), + Some('D') => Ok(Node::CharClass { + negated: false, + ranges: vec![ClassRange::Predefined(PredefinedClass::NonDigit)], + }), + Some('w') => Ok(Node::CharClass { + negated: false, + ranges: vec![ClassRange::Predefined(PredefinedClass::Word)], + }), + Some('W') => Ok(Node::CharClass { + negated: false, + ranges: vec![ClassRange::Predefined(PredefinedClass::NonWord)], + }), + Some('s') => Ok(Node::CharClass { + negated: false, + ranges: vec![ClassRange::Predefined(PredefinedClass::Space)], + }), + Some('S') => Ok(Node::CharClass { + negated: false, + ranges: vec![ClassRange::Predefined(PredefinedClass::NonSpace)], + }), + Some('b') => Ok(Node::WordBoundary), + Some('B') => Ok(Node::NonWordBoundary), + Some('n') => Ok(Node::Literal('\n')), + Some('r') => Ok(Node::Literal('\r')), + Some('t') => Ok(Node::Literal('\t')), + Some('f') => Ok(Node::Literal('\x0C')), + Some('v') => Ok(Node::Literal('\x0B')), + Some('0') => Ok(Node::Literal('\0')), + Some('x') => { + let hi = self.advance().ok_or("expected hex digit")?; + let lo = self.advance().ok_or("expected hex digit")?; + let code = hex2(hi, lo)?; + Ok(Node::Literal(char::from(code))) + } + Some('u') => { + if self.peek() == Some('{') { + self.advance(); + let mut code_str = String::new(); + while self.peek() != Some('}') { + code_str.push(self.advance().ok_or("expected '}'")?); + } + self.advance(); // consume '}' + let code = + u32::from_str_radix(&code_str, 16).map_err(|_| "invalid unicode escape")?; + let ch = char::from_u32(code).ok_or("invalid unicode code point")?; + Ok(Node::Literal(ch)) + } else { + let a = self.advance().ok_or("expected hex digit")?; + let b = self.advance().ok_or("expected hex digit")?; + let c = self.advance().ok_or("expected hex digit")?; + let d = self.advance().ok_or("expected hex digit")?; + let code = hex4(a, b, c, d)?; + let ch = char::from_u32(code).ok_or("invalid unicode escape")?; + Ok(Node::Literal(ch)) + } + } + Some('k') => { + // Named backreference \k + self.expect('<')?; + let mut name = String::new(); + while self.peek() != Some('>') { + name.push(self.advance().ok_or("expected '>'")?); + } + self.advance(); // consume '>' + // Resolve name to group index. + for &(ref n, idx) in &self.group_names { + if *n == name { + return Ok(Node::Backref(idx)); + } + } + Err(format!("unknown group name '{}'", name)) + } + Some(ch) if ch.is_ascii_digit() && ch != '0' => { + // Numeric backreference \1, \12, etc. + let mut num_str = String::new(); + num_str.push(ch); + while let Some(next) = self.peek() { + if next.is_ascii_digit() { + num_str.push(next); + self.advance(); + } else { + break; + } + } + let idx: u32 = num_str.parse().map_err(|_| "invalid backreference")?; + Ok(Node::Backref(idx)) + } + // Escaped metacharacters — treat as literal. + Some(ch) => Ok(Node::Literal(ch)), + } + } + + /// Parse a character class `[...]` or `[^...]`. + fn parse_char_class(&mut self) -> Result { + self.advance(); // consume '[' + let negated = if self.peek() == Some('^') { + self.advance(); + true + } else { + false + }; + let mut ranges = Vec::new(); + // Allow ']' as first character in class. + if self.peek() == Some(']') { + self.advance(); + ranges.push(ClassRange::Char(']')); + } + while self.peek() != Some(']') { + if self.peek().is_none() { + return Err("unterminated character class".to_string()); + } + let item = self.parse_class_atom()?; + // Check for range `a-b`. + if self.peek() == Some('-') + && self.chars.get(self.pos + 1) != Some(&']') + && self.chars.get(self.pos + 1).is_some() + { + if let ClassRange::Char(start) = item { + self.advance(); // consume '-' + let end_item = self.parse_class_atom()?; + if let ClassRange::Char(end) = end_item { + if start > end { + return Err("character class range out of order".to_string()); + } + ranges.push(ClassRange::Range(start, end)); + continue; + } else { + // Not a valid range, push start, '-', and end_item. + ranges.push(ClassRange::Char(start)); + ranges.push(ClassRange::Char('-')); + ranges.push(end_item); + continue; + } + } + } + ranges.push(item); + } + self.advance(); // consume ']' + Ok(Node::CharClass { negated, ranges }) + } + + fn parse_class_atom(&mut self) -> Result { + match self.peek() { + Some('\\') => { + self.advance(); // consume '\' + match self.advance() { + None => Err("unexpected end of class".to_string()), + Some('d') => Ok(ClassRange::Predefined(PredefinedClass::Digit)), + Some('D') => Ok(ClassRange::Predefined(PredefinedClass::NonDigit)), + Some('w') => Ok(ClassRange::Predefined(PredefinedClass::Word)), + Some('W') => Ok(ClassRange::Predefined(PredefinedClass::NonWord)), + Some('s') => Ok(ClassRange::Predefined(PredefinedClass::Space)), + Some('S') => Ok(ClassRange::Predefined(PredefinedClass::NonSpace)), + Some('n') => Ok(ClassRange::Char('\n')), + Some('r') => Ok(ClassRange::Char('\r')), + Some('t') => Ok(ClassRange::Char('\t')), + Some('f') => Ok(ClassRange::Char('\x0C')), + Some('v') => Ok(ClassRange::Char('\x0B')), + Some('0') => Ok(ClassRange::Char('\0')), + Some('x') => { + let hi = self.advance().ok_or("expected hex digit")?; + let lo = self.advance().ok_or("expected hex digit")?; + let code = hex2(hi, lo)?; + Ok(ClassRange::Char(char::from(code))) + } + Some('u') => { + if self.peek() == Some('{') { + self.advance(); + let mut code_str = String::new(); + while self.peek() != Some('}') { + code_str.push(self.advance().ok_or("expected '}'")?); + } + self.advance(); + let code = u32::from_str_radix(&code_str, 16) + .map_err(|_| "invalid unicode escape")?; + let ch = char::from_u32(code) + .ok_or_else(|| "invalid unicode code point".to_string())?; + Ok(ClassRange::Char(ch)) + } else { + let a = self.advance().ok_or("expected hex digit")?; + let b = self.advance().ok_or("expected hex digit")?; + let c = self.advance().ok_or("expected hex digit")?; + let d = self.advance().ok_or("expected hex digit")?; + let code = hex4(a, b, c, d)?; + let ch = char::from_u32(code) + .ok_or_else(|| "invalid unicode escape".to_string())?; + Ok(ClassRange::Char(ch)) + } + } + Some(ch) => Ok(ClassRange::Char(ch)), + } + } + Some(ch) => { + self.advance(); + Ok(ClassRange::Char(ch)) + } + None => Err("unexpected end of character class".to_string()), + } + } + + /// Parse a group `(...)`. + fn parse_group(&mut self) -> Result { + self.advance(); // consume '(' + if self.peek() == Some('?') { + self.advance(); // consume '?' + match self.peek() { + Some(':') => { + self.advance(); + let inner = self.parse_alternation()?; + self.expect(')')?; + Ok(Node::NonCapturingGroup(Box::new(inner))) + } + Some('=') => { + self.advance(); + let inner = self.parse_alternation()?; + self.expect(')')?; + Ok(Node::Lookahead(Box::new(inner))) + } + Some('!') => { + self.advance(); + let inner = self.parse_alternation()?; + self.expect(')')?; + Ok(Node::NegativeLookahead(Box::new(inner))) + } + Some('<') => { + self.advance(); // consume '<' + let mut name = String::new(); + while self.peek() != Some('>') { + name.push(self.advance().ok_or("expected '>'")?); + } + self.advance(); // consume '>' + self.group_count += 1; + let idx = self.group_count; + self.group_names.push((name.clone(), idx)); + let inner = self.parse_alternation()?; + self.expect(')')?; + Ok(Node::Group { + index: idx, + name: Some(name), + node: Box::new(inner), + }) + } + _ => Err("invalid group specifier".to_string()), + } + } else { + self.group_count += 1; + let idx = self.group_count; + let inner = self.parse_alternation()?; + self.expect(')')?; + Ok(Node::Group { + index: idx, + name: None, + node: Box::new(inner), + }) + } + } +} + +// ── Hex helpers ───────────────────────────────────────────── + +fn hex_digit(ch: char) -> Result { + match ch { + '0'..='9' => Ok(ch as u32 - '0' as u32), + 'a'..='f' => Ok(ch as u32 - 'a' as u32 + 10), + 'A'..='F' => Ok(ch as u32 - 'A' as u32 + 10), + _ => Err(format!("invalid hex digit '{}'", ch)), + } +} + +fn hex2(hi: char, lo: char) -> Result { + Ok((hex_digit(hi)? * 16 + hex_digit(lo)?) as u8) +} + +fn hex4(a: char, b: char, c: char, d: char) -> Result { + Ok(hex_digit(a)? * 4096 + hex_digit(b)? * 256 + hex_digit(c)? * 16 + hex_digit(d)?) +} + +// ── Compiled regex ────────────────────────────────────────── + +/// A compiled regular expression ready for matching. +#[derive(Debug, Clone)] +pub struct CompiledRegex { + pub pattern: String, + pub flags: RegexFlags, + pub node: Node, + pub group_count: u32, +} + +impl CompiledRegex { + pub fn new(pattern: &str, flags_str: &str) -> Result { + let flags = RegexFlags::parse(flags_str)?; + let (node, group_count) = parse_pattern(pattern)?; + Ok(CompiledRegex { + pattern: pattern.to_string(), + flags, + node, + group_count, + }) + } +} + +// ── Backtracking matcher ──────────────────────────────────── + +/// Result of a successful regex match. +#[derive(Debug, Clone)] +pub struct MatchResult { + /// Overall match start index (in chars). + pub start: usize, + /// Overall match end index (in chars). + pub end: usize, + /// Capture group contents: index 0 = full match, 1..n = groups. + pub captures: Vec>, +} + +/// Execute the regex on the given string, starting the search at `start_pos` (char index). +pub fn exec(regex: &CompiledRegex, input: &str, start_pos: usize) -> Option { + let chars: Vec = input.chars().collect(); + let group_count = regex.group_count as usize; + + if regex.flags.sticky { + // Sticky: only try at start_pos. + let mut captures = vec![None; group_count + 1]; + let mut ctx = MatchContext { + chars: &chars, + flags: ®ex.flags, + captures: &mut captures, + backtrack_limit: 1_000_000, + backtrack_count: 0, + }; + if let Some(end) = match_node(&mut ctx, ®ex.node, start_pos) { + captures = ctx.captures.to_vec(); + captures[0] = Some((start_pos, end)); + return Some(MatchResult { + start: start_pos, + end, + captures, + }); + } + return None; + } + + // Non-sticky: try each position starting from start_pos. + for i in start_pos..=chars.len() { + let mut captures = vec![None; group_count + 1]; + let mut ctx = MatchContext { + chars: &chars, + flags: ®ex.flags, + captures: &mut captures, + backtrack_limit: 1_000_000, + backtrack_count: 0, + }; + if let Some(end) = match_node(&mut ctx, ®ex.node, i) { + captures = ctx.captures.to_vec(); + captures[0] = Some((i, end)); + return Some(MatchResult { + start: i, + end, + captures, + }); + } + } + None +} + +struct MatchContext<'a> { + chars: &'a [char], + flags: &'a RegexFlags, + captures: &'a mut Vec>, + backtrack_limit: u32, + backtrack_count: u32, +} + +/// Try to match `node` at position `pos`, returning the end position on success. +fn match_node(ctx: &mut MatchContext, node: &Node, pos: usize) -> Option { + ctx.backtrack_count += 1; + if ctx.backtrack_count > ctx.backtrack_limit { + return None; + } + + match node { + Node::Literal(ch) => { + if pos < ctx.chars.len() { + let input_ch = ctx.chars[pos]; + if ctx.flags.ignore_case { + if char_eq_ignore_case(input_ch, *ch) { + Some(pos + 1) + } else { + None + } + } else if input_ch == *ch { + Some(pos + 1) + } else { + None + } + } else { + None + } + } + + Node::Dot => { + if pos < ctx.chars.len() { + let ch = ctx.chars[pos]; + if ctx.flags.dot_all || (ch != '\n' && ch != '\r') { + Some(pos + 1) + } else { + None + } + } else { + None + } + } + + Node::Start => { + if pos == 0 + || (ctx.flags.multiline && pos > 0 && is_line_terminator(ctx.chars[pos - 1])) + { + Some(pos) + } else { + None + } + } + + Node::End => { + if pos == ctx.chars.len() + || (ctx.flags.multiline + && pos < ctx.chars.len() + && is_line_terminator(ctx.chars[pos])) + { + Some(pos) + } else { + None + } + } + + Node::WordBoundary => { + let before = if pos > 0 { + is_word_char(ctx.chars[pos - 1]) + } else { + false + }; + let after = if pos < ctx.chars.len() { + is_word_char(ctx.chars[pos]) + } else { + false + }; + if before != after { + Some(pos) + } else { + None + } + } + + Node::NonWordBoundary => { + let before = if pos > 0 { + is_word_char(ctx.chars[pos - 1]) + } else { + false + }; + let after = if pos < ctx.chars.len() { + is_word_char(ctx.chars[pos]) + } else { + false + }; + if before == after { + Some(pos) + } else { + None + } + } + + Node::CharClass { negated, ranges } => { + if pos >= ctx.chars.len() { + return None; + } + let ch = ctx.chars[pos]; + let matched = class_matches(ranges, ch, ctx.flags.ignore_case); + if matched != *negated { + Some(pos + 1) + } else { + None + } + } + + Node::Sequence(nodes) => match_sequence(ctx, nodes, 0, pos), + + Node::Alternation(branches) => { + for branch in branches { + let saved = ctx.captures.clone(); + if let Some(end) = match_node(ctx, branch, pos) { + return Some(end); + } + *ctx.captures = saved; + } + None + } + + Node::Quantifier { + node: inner, + min, + max, + greedy, + } => match_quantifier_standalone(ctx, inner, pos, *min, *max, *greedy), + + Node::Group { + index, node: inner, .. + } => { + let idx = *index as usize; + let saved = if idx < ctx.captures.len() { + ctx.captures[idx] + } else { + None + }; + let result = match_node(ctx, inner, pos); + if let Some(end) = result { + if idx < ctx.captures.len() { + ctx.captures[idx] = Some((pos, end)); + } + Some(end) + } else { + if idx < ctx.captures.len() { + ctx.captures[idx] = saved; + } + None + } + } + + Node::NonCapturingGroup(inner) => match_node(ctx, inner, pos), + + Node::Backref(idx) => { + let idx = *idx as usize; + if idx >= ctx.captures.len() { + return Some(pos); // Unmatched backref matches empty. + } + match ctx.captures[idx] { + Some((start, end)) => { + let cap_len = end - start; + if pos + cap_len > ctx.chars.len() { + return None; + } + for i in 0..cap_len { + let a = ctx.chars[start + i]; + let b = ctx.chars[pos + i]; + if ctx.flags.ignore_case { + if !char_eq_ignore_case(a, b) { + return None; + } + } else if a != b { + return None; + } + } + Some(pos + cap_len) + } + None => Some(pos), // Unmatched group — backref matches empty. + } + } + + Node::Lookahead(inner) => { + let saved = ctx.captures.clone(); + if match_node(ctx, inner, pos).is_some() { + Some(pos) // Lookahead doesn't consume. + } else { + *ctx.captures = saved; + None + } + } + + Node::NegativeLookahead(inner) => { + let saved = ctx.captures.clone(); + if match_node(ctx, inner, pos).is_some() { + *ctx.captures = saved; + None + } else { + *ctx.captures = saved; + Some(pos) + } + } + } +} + +/// Match a sequence of nodes with backtracking support for quantifiers. +fn match_sequence(ctx: &mut MatchContext, nodes: &[Node], idx: usize, pos: usize) -> Option { + if idx >= nodes.len() { + return Some(pos); + } + + let node = &nodes[idx]; + + // For quantifiers, try each count with the remaining sequence as continuation. + if let Node::Quantifier { + node: inner, + min, + max, + greedy, + } = node + { + return match_quantifier_in_seq(ctx, inner, pos, *min, *max, *greedy, &nodes[idx + 1..]); + } + + // For non-quantifier nodes, match and continue. + let saved = ctx.captures.clone(); + match match_node(ctx, node, pos) { + Some(next) => match_sequence(ctx, nodes, idx + 1, next).or_else(|| { + *ctx.captures = saved; + None + }), + None => None, + } +} + +/// Match a quantifier within a sequence, trying each count with the continuation. +fn match_quantifier_in_seq( + ctx: &mut MatchContext, + inner: &Node, + pos: usize, + min: u32, + max: Option, + greedy: bool, + continuation: &[Node], +) -> Option { + // Collect all reachable positions (match inner 0 to max times). + let mut positions = vec![pos]; // positions[0] = 0 matches + let mut cur = pos; + + loop { + let count = positions.len() - 1; + if let Some(m) = max { + if count as u32 >= m { + break; + } + } + let saved = ctx.captures.clone(); + match match_node(ctx, inner, cur) { + Some(next) if next > cur => { + cur = next; + positions.push(cur); + } + Some(next) => { + // Zero-width match — record once and stop to prevent infinite loop. + *ctx.captures = saved; + if next == cur && (count as u32) < min { + positions.push(cur); + } + break; + } + None => { + *ctx.captures = saved; + break; + } + } + } + + let max_count = positions.len() - 1; + let min_count = min as usize; + if max_count < min_count { + return None; + } + + if greedy { + // Try from most matches to fewest. + for &p in positions[min_count..=max_count].iter().rev() { + let saved = ctx.captures.clone(); + if let Some(end) = match_sequence(ctx, continuation, 0, p) { + return Some(end); + } + *ctx.captures = saved; + } + None + } else { + // Lazy: try from fewest to most. + for &p in &positions[min_count..=max_count] { + let saved = ctx.captures.clone(); + if let Some(end) = match_sequence(ctx, continuation, 0, p) { + return Some(end); + } + *ctx.captures = saved; + } + None + } +} + +/// Match a standalone quantifier (not in a sequence context). +fn match_quantifier_standalone( + ctx: &mut MatchContext, + inner: &Node, + pos: usize, + min: u32, + max: Option, + greedy: bool, +) -> Option { + let mut positions = vec![pos]; + let mut cur = pos; + + loop { + let count = positions.len() - 1; + if let Some(m) = max { + if count as u32 >= m { + break; + } + } + let saved = ctx.captures.clone(); + match match_node(ctx, inner, cur) { + Some(next) if next > cur => { + cur = next; + positions.push(cur); + } + Some(next) => { + *ctx.captures = saved; + if next == cur && (count as u32) < min { + positions.push(cur); + } + break; + } + None => { + *ctx.captures = saved; + break; + } + } + } + + let max_count = positions.len() - 1; + let min_count = min as usize; + if max_count < min_count { + return None; + } + + if greedy { + Some(positions[max_count]) + } else { + Some(positions[min_count]) + } +} + +// ── Character helpers ─────────────────────────────────────── + +fn is_line_terminator(ch: char) -> bool { + matches!(ch, '\n' | '\r' | '\u{2028}' | '\u{2029}') +} + +fn is_word_char(ch: char) -> bool { + ch.is_ascii_alphanumeric() || ch == '_' +} + +fn char_eq_ignore_case(a: char, b: char) -> bool { + a.eq_ignore_ascii_case(&b) +} + +fn class_matches(ranges: &[ClassRange], ch: char, ignore_case: bool) -> bool { + for range in ranges { + match range { + ClassRange::Char(c) => { + if ignore_case { + if char_eq_ignore_case(ch, *c) { + return true; + } + } else if ch == *c { + return true; + } + } + ClassRange::Range(start, end) => { + if ignore_case { + let ch_lower = ch.to_ascii_lowercase(); + let start_lower = start.to_ascii_lowercase(); + let end_lower = end.to_ascii_lowercase(); + if ch_lower >= start_lower && ch_lower <= end_lower { + return true; + } + } else if ch >= *start && ch <= *end { + return true; + } + } + ClassRange::Predefined(class) => { + if predefined_matches(*class, ch) { + return true; + } + } + } + } + false +} + +fn predefined_matches(class: PredefinedClass, ch: char) -> bool { + match class { + PredefinedClass::Digit => ch.is_ascii_digit(), + PredefinedClass::NonDigit => !ch.is_ascii_digit(), + PredefinedClass::Word => is_word_char(ch), + PredefinedClass::NonWord => !is_word_char(ch), + PredefinedClass::Space => matches!( + ch, + ' ' | '\t' | '\n' | '\r' | '\x0B' | '\x0C' | '\u{00A0}' | '\u{FEFF}' + ), + PredefinedClass::NonSpace => !matches!( + ch, + ' ' | '\t' | '\n' | '\r' | '\x0B' | '\x0C' | '\u{00A0}' | '\u{FEFF}' + ), + } +} + +// ── Tests ─────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + fn match_at(pattern: &str, flags: &str, input: &str, start: usize) -> Option { + let regex = CompiledRegex::new(pattern, flags).unwrap(); + exec(®ex, input, start) + } + + fn assert_match(pattern: &str, input: &str, expected_start: usize, expected_end: usize) { + let m = match_at(pattern, "", input, 0).expect("expected match"); + assert_eq!( + m.start, expected_start, + "pattern={} input={}", + pattern, input + ); + assert_eq!(m.end, expected_end, "pattern={} input={}", pattern, input); + } + + fn assert_no_match(pattern: &str, input: &str) { + assert!( + match_at(pattern, "", input, 0).is_none(), + "expected no match: pattern={} input={}", + pattern, + input + ); + } + + #[test] + fn test_literal() { + assert_match("abc", "xabcx", 1, 4); + assert_no_match("abc", "xyz"); + } + + #[test] + fn test_dot() { + assert_match("a.c", "abc", 0, 3); + assert_match("a.c", "axc", 0, 3); + assert_no_match("a.c", "a\nc"); + // dotAll flag + let m = match_at("a.c", "s", "a\nc", 0).unwrap(); + assert_eq!(m.start, 0); + assert_eq!(m.end, 3); + } + + #[test] + fn test_anchors() { + assert_match("^abc", "abc", 0, 3); + assert_no_match("^abc", "xabc"); + assert_match("abc$", "abc", 0, 3); + assert_no_match("abc$", "abcx"); + } + + #[test] + fn test_quantifiers() { + assert_match("a*", "aaa", 0, 3); + assert_match("a+", "aaa", 0, 3); + assert_no_match("a+", "bbb"); + assert_match("a?b", "ab", 0, 2); + assert_match("a?b", "b", 0, 1); + assert_match("a{2}", "aaa", 0, 2); + assert_match("a{2,3}", "aaaa", 0, 3); + assert_match("a{2,}", "aaaa", 0, 4); + } + + #[test] + fn test_char_class() { + assert_match("[abc]", "b", 0, 1); + assert_no_match("[abc]", "d"); + assert_match("[a-z]", "m", 0, 1); + assert_no_match("[a-z]", "M"); + assert_match("[^abc]", "d", 0, 1); + assert_no_match("[^abc]", "a"); + } + + #[test] + fn test_predefined_classes() { + assert_match("\\d+", "abc123", 3, 6); + assert_match("\\w+", "hello world", 0, 5); + assert_match("\\s+", "a b", 1, 2); + } + + #[test] + fn test_alternation() { + assert_match("cat|dog", "dog", 0, 3); + assert_match("cat|dog", "catdog", 0, 3); + } + + #[test] + fn test_groups() { + let m = match_at("(a)(b)(c)", "", "abc", 0).unwrap(); + assert_eq!(m.captures[1], Some((0, 1))); + assert_eq!(m.captures[2], Some((1, 2))); + assert_eq!(m.captures[3], Some((2, 3))); + } + + #[test] + fn test_non_capturing_group() { + let m = match_at("(?:ab)(c)", "", "abc", 0).unwrap(); + assert_eq!(m.captures[1], Some((2, 3))); + } + + #[test] + fn test_named_group() { + let m = match_at("(?\\w+)", "", "hello", 0).unwrap(); + assert_eq!(m.captures[1], Some((0, 5))); + } + + #[test] + fn test_backreference() { + assert_match("(a)\\1", "aa", 0, 2); + assert_no_match("(a)\\1", "ab"); + } + + #[test] + fn test_word_boundary() { + assert_match("\\bfoo\\b", "a foo b", 2, 5); + assert_no_match("\\bfoo\\b", "afoo"); + } + + #[test] + fn test_lookahead() { + assert_match("a(?=b)", "ab", 0, 1); + assert_no_match("a(?=b)", "ac"); + assert_match("a(?!b)", "ac", 0, 1); + assert_no_match("a(?!b)", "ab"); + } + + #[test] + fn test_ignore_case() { + let m = match_at("abc", "i", "ABC", 0).unwrap(); + assert_eq!(m.start, 0); + assert_eq!(m.end, 3); + } + + #[test] + fn test_multiline() { + let m = match_at("^b", "m", "a\nb", 0).unwrap(); + assert_eq!(m.start, 2); + } + + #[test] + fn test_global_multiple() { + let regex = CompiledRegex::new("a", "g").unwrap(); + let m1 = exec(®ex, "aba", 0).unwrap(); + assert_eq!(m1.start, 0); + let m2 = exec(®ex, "aba", m1.end).unwrap(); + assert_eq!(m2.start, 2); + assert!(exec(®ex, "aba", m2.end).is_none()); + } + + #[test] + fn test_escape_sequences() { + assert_match("\\n", "\n", 0, 1); + assert_match("\\t", "\t", 0, 1); + assert_match("\\x41", "A", 0, 1); + assert_match("\\u0041", "A", 0, 1); + } + + #[test] + fn test_lazy_quantifiers() { + let m = match_at("a+?", "", "aaa", 0).unwrap(); + assert_eq!(m.end, 1); // Lazy: match as few as possible. + } + + #[test] + fn test_empty_pattern() { + let m = match_at("", "", "abc", 0).unwrap(); + assert_eq!(m.start, 0); + assert_eq!(m.end, 0); + } + + #[test] + fn test_flags_parse() { + let f = RegexFlags::parse("gims").unwrap(); + assert!(f.global); + assert!(f.ignore_case); + assert!(f.multiline); + assert!(f.dot_all); + assert!(!f.unicode); + assert!(!f.sticky); + + assert!(RegexFlags::parse("gg").is_err()); + assert!(RegexFlags::parse("x").is_err()); + } +} diff --git a/crates/js/src/vm.rs b/crates/js/src/vm.rs index cdd06f4..a5484d4 100644 --- a/crates/js/src/vm.rs +++ b/crates/js/src/vm.rs @@ -697,6 +697,8 @@ pub struct Vm { pub boolean_prototype: Option, /// Built-in Date.prototype (for Date constructor objects). pub date_prototype: Option, + /// Built-in RegExp.prototype (for RegExp constructor objects). + pub regexp_prototype: Option, } /// Maximum register file size. @@ -719,6 +721,7 @@ impl Vm { number_prototype: None, boolean_prototype: None, date_prototype: None, + regexp_prototype: None, }; crate::builtins::init_builtins(&mut vm); vm @@ -4192,4 +4195,330 @@ mod tests { v => panic!("expected '{{}}', got {v:?}"), } } + + // ── RegExp tests ──────────────────────────────────────── + + #[test] + fn test_regexp_constructor() { + match eval("var r = new RegExp('abc', 'g'); r.source").unwrap() { + Value::String(s) => assert_eq!(s, "abc"), + v => panic!("expected 'abc', got {v:?}"), + } + match eval("var r = new RegExp('abc', 'gi'); r.flags").unwrap() { + Value::String(s) => assert_eq!(s, "gi"), + v => panic!("expected 'gi', got {v:?}"), + } + match eval("var r = new RegExp('abc'); r.global").unwrap() { + Value::Boolean(b) => assert!(!b), + v => panic!("expected false, got {v:?}"), + } + match eval("var r = new RegExp('abc', 'g'); r.global").unwrap() { + Value::Boolean(b) => assert!(b), + v => panic!("expected true, got {v:?}"), + } + } + + #[test] + fn test_regexp_test() { + match eval("var r = new RegExp('abc'); r.test('xabcx')").unwrap() { + Value::Boolean(b) => assert!(b), + v => panic!("expected true, got {v:?}"), + } + match eval("var r = new RegExp('abc'); r.test('xyz')").unwrap() { + Value::Boolean(b) => assert!(!b), + v => panic!("expected false, got {v:?}"), + } + match eval("var r = new RegExp('\\\\d+'); r.test('abc123')").unwrap() { + Value::Boolean(b) => assert!(b), + v => panic!("expected true, got {v:?}"), + } + } + + #[test] + fn test_regexp_exec() { + match eval("var r = new RegExp('(a)(b)(c)'); var m = r.exec('abc'); m[0]").unwrap() { + Value::String(s) => assert_eq!(s, "abc"), + v => panic!("expected 'abc', got {v:?}"), + } + match eval("var r = new RegExp('(a)(b)(c)'); var m = r.exec('abc'); m[1]").unwrap() { + Value::String(s) => assert_eq!(s, "a"), + v => panic!("expected 'a', got {v:?}"), + } + match eval("var r = new RegExp('b+'); var m = r.exec('aabbc'); m[0]").unwrap() { + Value::String(s) => assert_eq!(s, "bb"), + v => panic!("expected 'bb', got {v:?}"), + } + match eval("var r = new RegExp('xyz'); r.exec('abc')").unwrap() { + Value::Null => {} + v => panic!("expected null, got {v:?}"), + } + } + + #[test] + fn test_regexp_exec_global() { + let src = "var r = new RegExp('a', 'g'); r.exec('aba')[0]"; + match eval(src).unwrap() { + Value::String(s) => assert_eq!(s, "a"), + v => panic!("expected 'a', got {v:?}"), + } + let src = r#" + var r = new RegExp('a', 'g'); + r.exec('aba'); + var m = r.exec('aba'); + m[0] + ',' + m.index + "#; + match eval(src).unwrap() { + Value::String(s) => assert_eq!(s, "a,2"), + v => panic!("expected 'a,2', got {v:?}"), + } + } + + #[test] + fn test_regexp_to_string() { + match eval("var r = new RegExp('abc', 'gi'); r.toString()").unwrap() { + Value::String(s) => assert_eq!(s, "/abc/gi"), + v => panic!("expected '/abc/gi', got {v:?}"), + } + match eval("/hello\\d+/.toString()").unwrap() { + Value::String(s) => assert_eq!(s, "/hello\\d+/"), + v => panic!("expected '/hello\\d+/', got {v:?}"), + } + } + + #[test] + fn test_regexp_literal() { + match eval("/abc/.test('abc')").unwrap() { + Value::Boolean(b) => assert!(b), + v => panic!("expected true, got {v:?}"), + } + match eval("/abc/.test('xyz')").unwrap() { + Value::Boolean(b) => assert!(!b), + v => panic!("expected false, got {v:?}"), + } + match eval("/\\d+/.test('123')").unwrap() { + Value::Boolean(b) => assert!(b), + v => panic!("expected true, got {v:?}"), + } + match eval("/abc/i.test('ABC')").unwrap() { + Value::Boolean(b) => assert!(b), + v => panic!("expected true, got {v:?}"), + } + } + + #[test] + fn test_regexp_literal_exec() { + match eval("var m = /([a-z]+)(\\d+)/.exec('abc123'); m[0]").unwrap() { + Value::String(s) => assert_eq!(s, "abc123"), + v => panic!("expected 'abc123', got {v:?}"), + } + match eval("var m = /([a-z]+)(\\d+)/.exec('abc123'); m[1]").unwrap() { + Value::String(s) => assert_eq!(s, "abc"), + v => panic!("expected 'abc', got {v:?}"), + } + match eval("var m = /([a-z]+)(\\d+)/.exec('abc123'); m[2]").unwrap() { + Value::String(s) => assert_eq!(s, "123"), + v => panic!("expected '123', got {v:?}"), + } + } + + #[test] + fn test_string_match_regexp() { + match eval("'hello world'.match(/world/)[0]").unwrap() { + Value::String(s) => assert_eq!(s, "world"), + v => panic!("expected 'world', got {v:?}"), + } + match eval("'aaa'.match(/a/g).length").unwrap() { + Value::Number(n) => assert_eq!(n, 3.0), + v => panic!("expected 3, got {v:?}"), + } + match eval("'abc'.match(/xyz/)").unwrap() { + Value::Null => {} + v => panic!("expected null, got {v:?}"), + } + } + + #[test] + fn test_string_search_regexp() { + match eval("'hello world'.search(/world/)").unwrap() { + Value::Number(n) => assert_eq!(n, 6.0), + v => panic!("expected 6, got {v:?}"), + } + match eval("'abc'.search(/xyz/)").unwrap() { + Value::Number(n) => assert_eq!(n, -1.0), + v => panic!("expected -1, got {v:?}"), + } + match eval("'abc123'.search(/\\d/)").unwrap() { + Value::Number(n) => assert_eq!(n, 3.0), + v => panic!("expected 3, got {v:?}"), + } + } + + #[test] + fn test_string_replace_regexp() { + match eval("'hello world'.replace(/world/, 'rust')").unwrap() { + Value::String(s) => assert_eq!(s, "hello rust"), + v => panic!("expected 'hello rust', got {v:?}"), + } + match eval("'aaa'.replace(/a/, 'b')").unwrap() { + Value::String(s) => assert_eq!(s, "baa"), + v => panic!("expected 'baa', got {v:?}"), + } + match eval("'aaa'.replace(/a/g, 'b')").unwrap() { + Value::String(s) => assert_eq!(s, "bbb"), + v => panic!("expected 'bbb', got {v:?}"), + } + } + + #[test] + fn test_string_replace_capture_groups() { + let src = r#"'John Smith'.replace(/(\w+) (\w+)/, '$2, $1')"#; + match eval(src).unwrap() { + Value::String(s) => assert_eq!(s, "Smith, John"), + v => panic!("expected 'Smith, John', got {v:?}"), + } + match eval("'abc'.replace(/(b)/, '[$1]')").unwrap() { + Value::String(s) => assert_eq!(s, "a[b]c"), + v => panic!("expected 'a[b]c', got {v:?}"), + } + } + + #[test] + fn test_string_split_regexp() { + match eval("'a1b2c3'.split(/\\d/).length").unwrap() { + Value::Number(n) => assert_eq!(n, 4.0), + v => panic!("expected 4, got {v:?}"), + } + match eval("'a1b2c3'.split(/\\d/)[0]").unwrap() { + Value::String(s) => assert_eq!(s, "a"), + v => panic!("expected 'a', got {v:?}"), + } + } + + #[test] + fn test_regexp_ignore_case() { + match eval("/abc/i.exec('XAbCx')[0]").unwrap() { + Value::String(s) => assert_eq!(s, "AbC"), + v => panic!("expected 'AbC', got {v:?}"), + } + } + + #[test] + fn test_regexp_multiline() { + match eval("/^b/m.test('a\\nb')").unwrap() { + Value::Boolean(b) => assert!(b), + v => panic!("expected true, got {v:?}"), + } + match eval("/^b/.test('a\\nb')").unwrap() { + Value::Boolean(b) => assert!(!b), + v => panic!("expected false, got {v:?}"), + } + } + + #[test] + fn test_regexp_dot_all() { + match eval("/a.b/s.test('a\\nb')").unwrap() { + Value::Boolean(b) => assert!(b), + v => panic!("expected true, got {v:?}"), + } + match eval("/a.b/.test('a\\nb')").unwrap() { + Value::Boolean(b) => assert!(!b), + v => panic!("expected false, got {v:?}"), + } + } + + #[test] + fn test_regexp_word_boundary() { + match eval("/\\bfoo\\b/.test('a foo b')").unwrap() { + Value::Boolean(b) => assert!(b), + v => panic!("expected true, got {v:?}"), + } + match eval("/\\bfoo\\b/.test('foobar')").unwrap() { + Value::Boolean(b) => assert!(!b), + v => panic!("expected false, got {v:?}"), + } + } + + #[test] + fn test_regexp_quantifiers_vm() { + match eval("/a{3}/.test('aaa')").unwrap() { + Value::Boolean(b) => assert!(b), + v => panic!("expected true, got {v:?}"), + } + match eval("/a{3}/.test('aa')").unwrap() { + Value::Boolean(b) => assert!(!b), + v => panic!("expected false, got {v:?}"), + } + match eval("/a+?/.exec('aaa')[0]").unwrap() { + Value::String(s) => assert_eq!(s, "a"), + v => panic!("expected 'a', got {v:?}"), + } + } + + #[test] + fn test_regexp_alternation_vm() { + match eval("/cat|dog/.exec('I have a dog')[0]").unwrap() { + Value::String(s) => assert_eq!(s, "dog"), + v => panic!("expected 'dog', got {v:?}"), + } + } + + #[test] + fn test_regexp_lookahead_vm() { + match eval("/a(?=b)/.test('ab')").unwrap() { + Value::Boolean(b) => assert!(b), + v => panic!("expected true, got {v:?}"), + } + match eval("/a(?=b)/.test('ac')").unwrap() { + Value::Boolean(b) => assert!(!b), + v => panic!("expected false, got {v:?}"), + } + match eval("/a(?!b)/.test('ac')").unwrap() { + Value::Boolean(b) => assert!(b), + v => panic!("expected true, got {v:?}"), + } + } + + #[test] + fn test_regexp_char_class_vm() { + match eval("/[abc]/.test('b')").unwrap() { + Value::Boolean(b) => assert!(b), + v => panic!("expected true, got {v:?}"), + } + match eval("/[a-z]+/.exec('Hello')[0]").unwrap() { + Value::String(s) => assert_eq!(s, "ello"), + v => panic!("expected 'ello', got {v:?}"), + } + } + + #[test] + fn test_regexp_backreference_vm() { + match eval("/(a)\\1/.test('aa')").unwrap() { + Value::Boolean(b) => assert!(b), + v => panic!("expected true, got {v:?}"), + } + match eval("/(a)\\1/.test('ab')").unwrap() { + Value::Boolean(b) => assert!(!b), + v => panic!("expected false, got {v:?}"), + } + } + + #[test] + fn test_regexp_properties() { + match eval("var r = /abc/gim; r.global").unwrap() { + Value::Boolean(b) => assert!(b), + v => panic!("expected true, got {v:?}"), + } + match eval("/abc/.lastIndex").unwrap() { + Value::Number(n) => assert_eq!(n, 0.0), + v => panic!("expected 0, got {v:?}"), + } + } + + #[test] + fn test_string_replace_all_regexp() { + match eval("'aba'.replaceAll(/a/g, 'x')").unwrap() { + Value::String(s) => assert_eq!(s, "xbx"), + v => panic!("expected 'xbx', got {v:?}"), + } + } }