Skip to content

Commit b458aa2

Browse files
autofix-ci[bot]IWANABETHATGUY
authored andcommitted
[autofix.ci] apply automated fixes
1 parent b8f766e commit b458aa2

File tree

4 files changed

+69
-38
lines changed

4 files changed

+69
-38
lines changed

crates/rolldown_binding/src/types/binding_magic_string.rs

Lines changed: 46 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -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();

packages/rolldown/src/binding-magic-string.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@ Object.defineProperty(NativeBindingMagicString.prototype, 'isRolldownMagicString
1313

1414
// Override replace/replaceAll to support RegExp patterns.
1515
// String patterns delegate to the native Rust implementation.
16-
// RegExp patterns delegate to native replaceRegex/replaceAllRegex which use
17-
// the regress crate for ECMAScript-compatible regex matching with capture groups.
16+
// RegExp patterns delegate to native replaceRegex which uses the regress crate
17+
// for ECMAScript-compatible regex matching with capture groups.
1818
// eslint-disable-next-line @typescript-eslint/unbound-method -- intentionally saving refs before overriding
1919
const nativeReplace = NativeBindingMagicString.prototype.replace;
2020
// eslint-disable-next-line @typescript-eslint/unbound-method
@@ -31,7 +31,7 @@ NativeBindingMagicString.prototype.replace = function (
3131
if (searchValue.global) {
3232
searchValue.lastIndex = 0;
3333
}
34-
// replaceRegex returns the byte offset past the last match, or -1 if no match.
34+
// replaceRegex returns the UTF-16 offset past the last match, or -1 if no match.
3535
const lastMatchEnd: number = (this as any).replaceRegex(searchValue, replacement);
3636
// Update lastIndex to match JS semantics:
3737
// - Global: reset to 0 (exec loop exhaustion)
@@ -58,7 +58,7 @@ NativeBindingMagicString.prototype.replaceAll = function (
5858
);
5959
}
6060
searchValue.lastIndex = 0;
61-
(this as any).replaceAllRegex(searchValue, replacement);
61+
(this as any).replaceRegex(searchValue, replacement);
6262
searchValue.lastIndex = 0;
6363
return this;
6464
};

packages/rolldown/src/binding.d.cts

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1490,16 +1490,11 @@ export declare class BindingMagicString {
14901490
replace(from: string, to: string): this
14911491
replaceAll(from: string, to: string): this
14921492
/**
1493-
* Returns the byte offset past the last match, or -1 if no match was found.
1493+
* Returns the UTF-16 offset past the last match, or -1 if no match was found.
14941494
* The JS wrapper uses this to update `lastIndex` on the caller's RegExp.
1495+
* Global/sticky behavior is derived from the regex's own flags.
14951496
*/
1496-
replaceRegex(from: JsRegExp, to: string): number
1497-
/**
1498-
* Note: non-global RegExp check is handled in the JS wrapper (`binding-magic-string.ts`)
1499-
* which throws a proper `TypeError`. If called directly without the wrapper,
1500-
* a non-global regex will simply be treated as global (safe fallback).
1501-
*/
1502-
replaceAllRegex(from: JsRegExp, to: string): number
1497+
replaceRegex(from: RegExp, to: string): number
15031498
prepend(content: string): this
15041499
append(content: string): this
15051500
prependLeft(index: number, content: string): this

packages/rolldown/tests/magic-string/rolldown-magic-string.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,4 +220,20 @@ describe('regex replace', () => {
220220
assert.strictEqual(s.toString(), '\u{1F937}x');
221221
assert.strictEqual(regex.lastIndex, 3);
222222
});
223+
224+
it('returns correct lastIndex when match ends at a supplementary character boundary', () => {
225+
// Matching the emoji itself — the match end byte offset lands on the
226+
// low surrogate entry in the mapper, which must be skipped to produce
227+
// the correct UTF-16 lastIndex.
228+
const s = new MagicString('A\u{1F937}B');
229+
const regex = /./uy;
230+
231+
// Start at the emoji (UTF-16 index 1).
232+
regex.lastIndex = 1;
233+
s.replace(regex, 'X');
234+
235+
assert.strictEqual(s.toString(), 'AXB');
236+
// The emoji occupies UTF-16 indices 1-2, so lastIndex should be 3.
237+
assert.strictEqual(regex.lastIndex, 3);
238+
});
223239
});

0 commit comments

Comments
 (0)