@@ -163,6 +163,20 @@ impl Utf16ToByteMapper {
163163 count
164164 }
165165
166+ /// Converts a UTF-8 byte offset to a UTF-16 code unit offset.
167+ /// Returns `None` if the byte offset is past the end of the mapping.
168+ #[ expect( clippy:: cast_possible_truncation) ]
169+ fn byte_to_utf16 ( & self , byte_offset : u32 ) -> Option < u32 > {
170+ let mut idx = self . entries . partition_point ( |e| e. byte_offset < byte_offset) ;
171+ // If we landed on a low surrogate, the byte offset is "after" the
172+ // supplementary character. The correct UTF-16 position is the next
173+ // index (which shares the same byte_offset).
174+ if idx < self . entries . len ( ) && self . entries [ idx] . is_low_surrogate ( ) {
175+ idx += 1 ;
176+ }
177+ ( idx < self . entries . len ( ) ) . then_some ( idx as u32 )
178+ }
179+
166180 /// Returns the total UTF-8 byte length of the original string.
167181 /// This is the correct sentinel for out-of-bounds index clamping in `slice`.
168182 fn total_len ( & self ) -> u32 {
@@ -427,12 +441,8 @@ impl BindingMagicString<'_> {
427441 /// not supported by the `regex` crate (e.g. backreferences, lookaround).
428442 /// Sticky (`y`) flag always uses the `regress` path since `regex` doesn't support it,
429443 /// and `lastIndex` is respected via `find_from`.
430- fn regex_replace (
431- & mut self ,
432- js_regex : & JsRegExp ,
433- replacement : & str ,
434- global : bool ,
435- ) -> napi:: Result < Option < u32 > > {
444+ fn regex_replace ( & mut self , js_regex : & JsRegExp , replacement : & str ) -> napi:: Result < Option < u32 > > {
445+ let global = js_regex. flags . contains ( 'g' ) ;
436446 let flags_without_g: String = js_regex. flags . chars ( ) . filter ( |& c| c != 'g' ) . collect ( ) ;
437447 let reg = HybridRegex :: with_flags ( & js_regex. source , & flags_without_g)
438448 . map_err ( |e| napi:: Error :: from_reason ( format ! ( "Invalid regex: {e}" ) ) ) ?;
@@ -470,8 +480,16 @@ impl BindingMagicString<'_> {
470480 HybridRegex :: Ecma ( r) => {
471481 let is_sticky = js_regex. flags . contains ( 'y' ) ;
472482 // For global regexes, JS resets lastIndex to 0 before matching.
473- // For non-global sticky, use the caller's lastIndex.
474- let start = if global || !is_sticky { 0 } else { js_regex. last_index } ;
483+ // For non-global sticky, use the caller's lastIndex (converted from UTF-16 to byte offset).
484+ // If lastIndex is out of bounds, sticky must immediately fail (no match).
485+ let start = if global || !is_sticky {
486+ 0
487+ } else {
488+ match self . utf16_to_byte_mapper . utf16_to_byte ( js_regex. last_index as u32 ) {
489+ Some ( byte_offset) => byte_offset as usize ,
490+ None => return Ok ( None ) ,
491+ }
492+ } ;
475493
476494 if is_sticky {
477495 // Sticky: only accept contiguous matches starting at `start`.
@@ -507,20 +525,24 @@ impl BindingMagicString<'_> {
507525 last_match_end = Some ( m. range . end as u32 ) ;
508526 let matched = & source[ m. range . clone ( ) ] ;
509527 let rep = apply_replacement_regress ( replacement, matched, & m, source) ;
510- ( rep != matched) . then ( || ( m. range . start as u32 , m. range . end as u32 , rep) )
528+ ( rep != matched) . then_some ( ( m. range . start as u32 , m. range . end as u32 , rep) )
511529 } )
512530 . collect ( )
513531 }
514532 }
515533 } ;
516534
517- for ( start, end, rep) in & overwrites {
535+ // Convert byte offset back to UTF-16 code units for JS lastIndex writeback.
536+ let last_match_end_utf16 =
537+ last_match_end. and_then ( |b| self . utf16_to_byte_mapper . byte_to_utf16 ( b) ) ;
538+
539+ for ( start, end, rep) in overwrites {
518540 self
519541 . inner
520- . update_with ( * start, * end, rep. clone ( ) , UpdateOptions { overwrite : true , keep_original : false } )
542+ . update_with ( start, end, rep, UpdateOptions { overwrite : true , keep_original : false } )
521543 . map_err ( napi:: Error :: from_reason) ?;
522544 }
523- Ok ( last_match_end )
545+ Ok ( last_match_end_utf16 )
524546 }
525547
526548 /// Applies `self.offset` to a u32 character index.
@@ -567,22 +589,16 @@ impl BindingMagicString<'_> {
567589 Ok ( this)
568590 }
569591
570- /// Returns the byte offset past the last match, or -1 if no match was found.
592+ /// Returns the UTF-16 offset past the last match, or -1 if no match was found.
571593 /// The JS wrapper uses this to update `lastIndex` on the caller's RegExp.
594+ /// Global/sticky behavior is derived from the regex's own flags.
572595 #[ napi( js_name = "replaceRegex" ) ]
573- pub fn replace_regex ( & mut self , from : JsRegExp , to : String ) -> napi:: Result < i32 > {
574- let is_global = from. flags . contains ( 'g' ) ;
575- let last_end = self . regex_replace ( & from, & to, is_global) ?;
576- #[ expect( clippy:: cast_possible_wrap) ]
577- Ok ( last_end. map_or ( -1 , |v| v as i32 ) )
578- }
579-
580- /// Note: non-global RegExp check is handled in the JS wrapper (`binding-magic-string.ts`)
581- /// which throws a proper `TypeError`. If called directly without the wrapper,
582- /// a non-global regex will simply be treated as global (safe fallback).
583- #[ napi( js_name = "replaceAllRegex" ) ]
584- pub fn replace_all_regex ( & mut self , from : JsRegExp , to : String ) -> napi:: Result < i32 > {
585- let last_end = self . regex_replace ( & from, & to, true ) ?;
596+ pub fn replace_regex (
597+ & mut self ,
598+ #[ napi( ts_arg_type = "RegExp" ) ] from : JsRegExp ,
599+ to : String ,
600+ ) -> napi:: Result < i32 > {
601+ let last_end = self . regex_replace ( & from, & to) ?;
586602 #[ expect( clippy:: cast_possible_wrap) ]
587603 Ok ( last_end. map_or ( -1 , |v| v as i32 ) )
588604 }
@@ -1143,6 +1159,10 @@ fn apply_replacement<'a>(
11431159 group_count : usize ,
11441160 get_group : impl Fn ( usize ) -> Option < & ' a str > ,
11451161) -> String {
1162+ // Fast path: no substitution tokens — return replacement as-is.
1163+ if !replacement. contains ( '$' ) {
1164+ return replacement. to_owned ( ) ;
1165+ }
11461166 let mut result = String :: with_capacity ( replacement. len ( ) ) ;
11471167 let bytes = replacement. as_bytes ( ) ;
11481168 let len = bytes. len ( ) ;
0 commit comments