From 37a2faf53c8b9f31a41f23cc3971af6a1ded0720 Mon Sep 17 00:00:00 2001 From: viyic Date: Thu, 22 May 2025 23:08:11 +0700 Subject: [PATCH 1/5] Add support for captured groups in Find & Replace --- src/buffer/mod.rs | 63 ++++++++++++++++++++++++++++++++++++++++++++--- src/icu.rs | 16 ++++++++++++ 2 files changed, 76 insertions(+), 3 deletions(-) diff --git a/src/buffer/mod.rs b/src/buffer/mod.rs index 94b471aa411..a87ada5fce6 100644 --- a/src/buffer/mod.rs +++ b/src/buffer/mod.rs @@ -1048,7 +1048,8 @@ impl TextBuffer { if let (Some(search), Some(..)) = (&mut self.search, &self.selection) { let search = search.get_mut(); if search.selection_generation == self.selection_generation { - self.write(replacement.as_bytes(), true); + let processed_replacement = self.get_regex_replacement(replacement); + self.write(processed_replacement.as_bytes(), true); } } @@ -1062,7 +1063,6 @@ impl TextBuffer { options: SearchOptions, replacement: &str, ) -> apperr::Result<()> { - let replacement = replacement.as_bytes(); let mut search = self.find_construct_search(pattern, options)?; let mut offset = 0; @@ -1071,7 +1071,9 @@ impl TextBuffer { if !self.has_selection() { break; } - self.write(replacement, true); + + let processed_replacement = self.get_regex_replacement(replacement); + self.write(processed_replacement.as_bytes(), true); offset = self.cursor.offset; } @@ -2378,6 +2380,61 @@ impl TextBuffer { pub fn read_forward(&self, off: usize) -> &[u8] { self.buffer.read_forward(off) } + + /// Processes the replacement string when using regex for capture groups. + fn get_regex_replacement<'a>(&mut self, replacement: &'a str) -> Cow<'a, str> { + let search = if let Some(search) = &mut self.search { + search.get_mut() + } else { + return Cow::Borrowed(replacement); + }; + + if !search.options.use_regex || !replacement.contains('$') { + return Cow::Borrowed(replacement); + } + + let scratch = scratch_arena(None); + let mut result = String::with_capacity(replacement.len()); + let mut chars = replacement.chars().peekable(); + + while let Some(ch) = chars.next() { + match ch { + '$' => { + let mut digits = ArenaString::new_in(&scratch); + + while let Some(&next_ch) = chars.peek() { + if digits.is_empty() && next_ch == '$' { + // Consume the escaped dollar sign. + chars.next(); + break; + } + + if !next_ch.is_ascii_digit() { + break; + } + + digits.push(next_ch); + chars.next(); + } + + if !digits.is_empty() { + if let Ok(group_num) = digits.parse::() { + if let Some(range) = search.regex.get_captured_group_range(group_num) { + let mut out = Vec::new(); + self.buffer.extract_raw(range.start, range.end, &mut out, 0); + result.push_str(&String::from_utf8_lossy(&out)); + } + } + } else { + result.push(ch); + } + } + _ => result.push(ch), + } + } + + Cow::Owned(result) + } } pub enum Bom { diff --git a/src/icu.rs b/src/icu.rs index 0829970bb6b..1b2375c444b 100644 --- a/src/icu.rs +++ b/src/icu.rs @@ -634,6 +634,22 @@ impl Regex { let mut status = icu_ffi::U_ZERO_ERROR; unsafe { (f.uregex_reset64)(self.0, index as i64, &mut status) }; } + + /// Gets the text range of a captured group by index. + pub fn get_captured_group_range(&mut self, group: i32) -> Option> { + let f = assume_loaded(); + + let mut status = icu_ffi::U_ZERO_ERROR; + let start = unsafe { (f.uregex_start64)(self.0, group, &mut status) }; + let end = unsafe { (f.uregex_end64)(self.0, group, &mut status) }; + if status.is_failure() { + return None; + } + + let start = start.max(0); + let end = end.max(start); + Some(start as usize..end as usize) + } } impl Iterator for Regex { From 86e3c0319c83fa3e9bba210fcbe6e363baf66857 Mon Sep 17 00:00:00 2001 From: viyic Date: Fri, 23 May 2025 11:18:26 +0700 Subject: [PATCH 2/5] Add ICU uregex_groupCount function --- src/icu.rs | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/icu.rs b/src/icu.rs index 1b2375c444b..dd600ae1fb9 100644 --- a/src/icu.rs +++ b/src/icu.rs @@ -635,6 +635,19 @@ impl Regex { unsafe { (f.uregex_reset64)(self.0, index as i64, &mut status) }; } + /// Gets captured group count. + pub fn get_captured_group_count(&mut self) -> Option { + let f = assume_loaded(); + + let mut status = icu_ffi::U_ZERO_ERROR; + let count = unsafe { (f.uregex_groupCount)(self.0, &mut status) }; + if status.is_failure() { + return None; + } + + Some(count) + } + /// Gets the text range of a captured group by index. pub fn get_captured_group_range(&mut self, group: i32) -> Option> { let f = assume_loaded(); @@ -852,6 +865,7 @@ struct LibraryFunctions { uregex_setUText: icu_ffi::uregex_setUText, uregex_reset64: icu_ffi::uregex_reset64, uregex_findNext: icu_ffi::uregex_findNext, + uregex_groupCount: icu_ffi::uregex_groupCount, uregex_start64: icu_ffi::uregex_start64, uregex_end64: icu_ffi::uregex_end64, ucol_open: icu_ffi::ucol_open, @@ -871,7 +885,7 @@ const LIBICUUC_PROC_NAMES: [&CStr; 9] = [ c"utext_close", ]; -const LIBICUI18N_PROC_NAMES: [&CStr; 10] = [ +const LIBICUI18N_PROC_NAMES: [&CStr; 11] = [ // Found in libicui18n.so on UNIX, icuin.dll/icu.dll on Windows. c"uregex_open", c"uregex_close", @@ -879,6 +893,7 @@ const LIBICUI18N_PROC_NAMES: [&CStr; 10] = [ c"uregex_setUText", c"uregex_reset64", c"uregex_findNext", + c"uregex_groupCount", c"uregex_start64", c"uregex_end64", c"ucol_open", @@ -1224,6 +1239,8 @@ mod icu_ffi { unsafe extern "C" fn(regexp: *mut URegularExpression, index: i64, status: &mut UErrorCode); pub type uregex_findNext = unsafe extern "C" fn(regexp: *mut URegularExpression, status: &mut UErrorCode) -> bool; + pub type uregex_groupCount = + unsafe extern "C" fn(regexp: *mut URegularExpression, status: &mut UErrorCode) -> i32; pub type uregex_start64 = unsafe extern "C" fn( regexp: *mut URegularExpression, group_num: i32, From 1f1da92e5e1bede9911aae51358f51965feff725 Mon Sep 17 00:00:00 2001 From: viyic Date: Fri, 23 May 2025 12:05:56 +0700 Subject: [PATCH 3/5] Fix referencing nonexistent captured group --- src/buffer/mod.rs | 59 ++++++++++++++++++++++++++++++++--------------- 1 file changed, 40 insertions(+), 19 deletions(-) diff --git a/src/buffer/mod.rs b/src/buffer/mod.rs index a87ada5fce6..cf71961b864 100644 --- a/src/buffer/mod.rs +++ b/src/buffer/mod.rs @@ -36,7 +36,7 @@ use std::str; use gap_buffer::GapBuffer; -use crate::arena::{ArenaString, scratch_arena}; +use crate::arena::{Arena, ArenaString, scratch_arena}; use crate::cell::SemiRefCell; use crate::document::{ReadableDocument, WriteableDocument}; use crate::framebuffer::{Framebuffer, IndexedColor}; @@ -1048,7 +1048,8 @@ impl TextBuffer { if let (Some(search), Some(..)) = (&mut self.search, &self.selection) { let search = search.get_mut(); if search.selection_generation == self.selection_generation { - let processed_replacement = self.get_regex_replacement(replacement); + let scratch = scratch_arena(None); + let processed_replacement = self.get_regex_replacement(&scratch, replacement); self.write(processed_replacement.as_bytes(), true); } } @@ -1063,6 +1064,7 @@ impl TextBuffer { options: SearchOptions, replacement: &str, ) -> apperr::Result<()> { + let scratch = scratch_arena(None); let mut search = self.find_construct_search(pattern, options)?; let mut offset = 0; @@ -1072,7 +1074,7 @@ impl TextBuffer { break; } - let processed_replacement = self.get_regex_replacement(replacement); + let processed_replacement = self.get_regex_replacement(&scratch, replacement); self.write(processed_replacement.as_bytes(), true); offset = self.cursor.offset; } @@ -2382,25 +2384,37 @@ impl TextBuffer { } /// Processes the replacement string when using regex for capture groups. - fn get_regex_replacement<'a>(&mut self, replacement: &'a str) -> Cow<'a, str> { - let search = if let Some(search) = &mut self.search { - search.get_mut() - } else { - return Cow::Borrowed(replacement); + fn get_regex_replacement<'a>( + &mut self, + arena: &'a Arena, + replacement: &str, + ) -> ArenaString<'a> { + let mut result = ArenaString::with_capacity_in(replacement.len(), arena); + + let Some(search) = &mut self.search else { + result.push_str(replacement); + return result; }; + let search = search.get_mut(); if !search.options.use_regex || !replacement.contains('$') { - return Cow::Borrowed(replacement); + result.push_str(replacement); + return result; } - let scratch = scratch_arena(None); - let mut result = String::with_capacity(replacement.len()); + let Some(group_count) = search.regex.get_captured_group_count() else { + result.push_str(replacement); + return result; + }; + + let scratch = scratch_arena(Some(arena)); let mut chars = replacement.chars().peekable(); while let Some(ch) = chars.next() { match ch { '$' => { let mut digits = ArenaString::new_in(&scratch); + let mut group_num: Option = None; while let Some(&next_ch) = chars.peek() { if digits.is_empty() && next_ch == '$' { @@ -2414,16 +2428,23 @@ impl TextBuffer { } digits.push(next_ch); + + let Ok(next_group_num) = digits.parse::() else { + break; + }; + if next_group_num > group_count { + break; + } + + group_num = Some(next_group_num); chars.next(); } - if !digits.is_empty() { - if let Ok(group_num) = digits.parse::() { - if let Some(range) = search.regex.get_captured_group_range(group_num) { - let mut out = Vec::new(); - self.buffer.extract_raw(range.start, range.end, &mut out, 0); - result.push_str(&String::from_utf8_lossy(&out)); - } + if let Some(group_num) = group_num { + if let Some(range) = search.regex.get_captured_group_range(group_num) { + let mut out = Vec::new(); + self.buffer.extract_raw(range.start, range.end, &mut out, 0); + result.push_str(&String::from_utf8_lossy(&out)); } } else { result.push(ch); @@ -2433,7 +2454,7 @@ impl TextBuffer { } } - Cow::Owned(result) + result } } From 104a7626fbaa8ba6f763064e63950e5b5e55ceeb Mon Sep 17 00:00:00 2001 From: Leonard Hecker Date: Tue, 17 Jun 2025 23:14:01 +0200 Subject: [PATCH 4/5] Make replacement parsing faster and testable --- src/bin/edit/draw_editor.rs | 4 +- src/buffer/mod.rs | 216 ++++++++++++++++++++++-------------- src/icu.rs | 30 ++--- 3 files changed, 145 insertions(+), 105 deletions(-) diff --git a/src/bin/edit/draw_editor.rs b/src/bin/edit/draw_editor.rs index cab726bd9c4..94f7dbfc50f 100644 --- a/src/bin/edit/draw_editor.rs +++ b/src/bin/edit/draw_editor.rs @@ -181,12 +181,12 @@ pub fn search_execute(ctx: &mut Context, state: &mut State, action: SearchAction SearchAction::Replace => doc.buffer.borrow_mut().find_and_replace( &state.search_needle, state.search_options, - &state.search_replacement, + state.search_replacement.as_bytes(), ), SearchAction::ReplaceAll => doc.buffer.borrow_mut().find_and_replace_all( &state.search_needle, state.search_options, - &state.search_replacement, + state.search_replacement.as_bytes(), ), } .is_ok(); diff --git a/src/buffer/mod.rs b/src/buffer/mod.rs index 898c51a3f30..8d949bb9b0a 100644 --- a/src/buffer/mod.rs +++ b/src/buffer/mod.rs @@ -136,6 +136,11 @@ pub struct SearchOptions { pub use_regex: bool, } +enum RegexReplacement<'a> { + Group(i32), + Text(Vec), +} + /// Caches the start and length of the active edit line for a single edit. /// This helps us avoid having to remeasure the buffer after an edit. struct ActiveEditLineInfo { @@ -1078,15 +1083,18 @@ impl TextBuffer { &mut self, pattern: &str, options: SearchOptions, - replacement: &str, + replacement: &[u8], ) -> apperr::Result<()> { // Editors traditionally replace the previous search hit, not the next possible one. - if let (Some(search), Some(..)) = (&mut self.search, &self.selection) { - let search = search.get_mut(); + if let (Some(search), Some(..)) = (&self.search, &self.selection) { + let search = unsafe { &mut *search.get() }; if search.selection_generation == self.selection_generation { let scratch = scratch_arena(None); - let processed_replacement = self.get_regex_replacement(&scratch, replacement); - self.write(processed_replacement.as_bytes(), self.cursor, true); + let parsed_replacements = + Self::find_parse_replacement(&scratch, &mut *search, replacement); + let replacement = + self.find_fill_replacement(&mut *search, replacement, &parsed_replacements); + self.write(&replacement, self.cursor, true); } } @@ -1098,11 +1106,12 @@ impl TextBuffer { &mut self, pattern: &str, options: SearchOptions, - replacement: &str, + replacement: &[u8], ) -> apperr::Result<()> { let scratch = scratch_arena(None); let mut search = self.find_construct_search(pattern, options)?; let mut offset = 0; + let parsed_replacements = Self::find_parse_replacement(&scratch, &mut search, replacement); loop { self.find_select_next(&mut search, offset, false); @@ -1110,8 +1119,9 @@ impl TextBuffer { break; } - let processed_replacement = self.get_regex_replacement(&scratch, replacement); - self.write(processed_replacement.as_bytes(), self.cursor, true); + let replacement = + self.find_fill_replacement(&mut search, replacement, &parsed_replacements); + self.write(&replacement, self.cursor, true); offset = self.cursor.offset; } @@ -1219,6 +1229,122 @@ impl TextBuffer { }; } + fn find_parse_replacement<'a>( + arena: &'a Arena, + search: &mut ActiveSearch, + replacement: &[u8], + ) -> Vec, &'a Arena> { + let mut res = Vec::new_in(arena); + + if !search.options.use_regex { + return res; + } + + let group_count = search.regex.group_count(); + let mut text = Vec::new_in(arena); + let mut text_beg = 0; + + loop { + let mut off = memchr2(b'$', b'\\', replacement, text_beg); + + // Push the raw, unescaped text, if any. + if text_beg < off { + text.extend_from_slice(&replacement[text_beg..off]); + } + + // Unescape any escaped characters. + while off < replacement.len() && replacement[off] == b'\\' { + off += 2; + text.push(match replacement.get(off - 1).map_or(b'\\', |&c| c) { + b'n' => b'\n', + b'r' => b'\r', + b't' => b'\t', + ch => ch, + }); + } + + // Parse out a group number, if any. + let mut group = -1; + if off < replacement.len() && replacement[off] == b'$' { + let mut beg = off; + let mut end = off + 1; + let mut acc = 0i32; + let mut acc_bad = true; + + if end < replacement.len() { + let ch = replacement[end]; + + if ch == b'$' { + // Translate "$$" to "$". + beg += 1; + end += 1; + } else if ch.is_ascii_digit() { + // Parse "$1234" into 1234i32. + // If the number is larger than the group count, + // we flag `acc_bad` which causes us to treat it as text. + acc_bad = false; + while { + acc = + acc.wrapping_mul(10).wrapping_add((replacement[end] - b'0') as i32); + acc_bad |= acc > group_count; + end += 1; + end < replacement.len() && replacement[end].is_ascii_digit() + } {} + } + } + + if !acc_bad { + group = acc; + } else { + text.extend_from_slice(&replacement[beg..end]); + } + + off = end; + } + + if !text.is_empty() { + res.push(RegexReplacement::Text(text)); + text = Vec::new_in(arena); + } + if group >= 0 { + res.push(RegexReplacement::Group(group)); + } + + text_beg = off; + if text_beg >= replacement.len() { + break; + } + } + + res + } + + fn find_fill_replacement<'a>( + &self, + search: &mut ActiveSearch, + replacement: &'a [u8], + parsed_replacements: &[RegexReplacement], + ) -> Cow<'a, [u8]> { + if !search.options.use_regex { + Cow::Borrowed(replacement) + } else { + let mut res = Vec::new(); + + for replacement in parsed_replacements { + match replacement { + RegexReplacement::Text(text) => res.extend_from_slice(text), + RegexReplacement::Group(group) => { + if let Some(range) = search.regex.group(*group) { + self.buffer.extract_raw(range, &mut res, usize::MAX); + } + } + } + } + + Cow::Owned(res) + } + } + fn measurement_config(&self) -> MeasurementConfig<'_> { MeasurementConfig::new(&self.buffer) .with_word_wrap_column(self.word_wrap_column) @@ -2514,80 +2640,6 @@ impl TextBuffer { pub fn read_forward(&self, off: usize) -> &[u8] { self.buffer.read_forward(off) } - - /// Processes the replacement string when using regex for capture groups. - fn get_regex_replacement<'a>( - &mut self, - arena: &'a Arena, - replacement: &str, - ) -> ArenaString<'a> { - let mut result = ArenaString::with_capacity_in(replacement.len(), arena); - - let Some(search) = &mut self.search else { - result.push_str(replacement); - return result; - }; - let search = search.get_mut(); - - if !search.options.use_regex || !replacement.contains('$') { - result.push_str(replacement); - return result; - } - - let Some(group_count) = search.regex.get_captured_group_count() else { - result.push_str(replacement); - return result; - }; - - let scratch = scratch_arena(Some(arena)); - let mut chars = replacement.chars().peekable(); - - while let Some(ch) = chars.next() { - match ch { - '$' => { - let mut digits = ArenaString::new_in(&scratch); - let mut group_num: Option = None; - - while let Some(&next_ch) = chars.peek() { - if digits.is_empty() && next_ch == '$' { - // Consume the escaped dollar sign. - chars.next(); - break; - } - - if !next_ch.is_ascii_digit() { - break; - } - - digits.push(next_ch); - - let Ok(next_group_num) = digits.parse::() else { - break; - }; - if next_group_num > group_count { - break; - } - - group_num = Some(next_group_num); - chars.next(); - } - - if let Some(group_num) = group_num { - if let Some(range) = search.regex.get_captured_group_range(group_num) { - let mut out = Vec::new(); - self.buffer.extract_raw(range.start, range.end, &mut out, 0); - result.push_str(&String::from_utf8_lossy(&out)); - } - } else { - result.push(ch); - } - } - _ => result.push(ch), - } - } - - result - } } pub enum Bom { diff --git a/src/icu.rs b/src/icu.rs index ddb5e160999..b0261abf94d 100644 --- a/src/icu.rs +++ b/src/icu.rs @@ -679,32 +679,28 @@ impl Regex { } /// Gets captured group count. - pub fn get_captured_group_count(&mut self) -> Option { + pub fn group_count(&mut self) -> i32 { let f = assume_loaded(); let mut status = icu_ffi::U_ZERO_ERROR; let count = unsafe { (f.uregex_groupCount)(self.0, &mut status) }; - if status.is_failure() { - return None; - } - - Some(count) + if status.is_failure() { 0 } else { count } } /// Gets the text range of a captured group by index. - pub fn get_captured_group_range(&mut self, group: i32) -> Option> { + pub fn group(&mut self, group: i32) -> Option> { let f = assume_loaded(); let mut status = icu_ffi::U_ZERO_ERROR; let start = unsafe { (f.uregex_start64)(self.0, group, &mut status) }; let end = unsafe { (f.uregex_end64)(self.0, group, &mut status) }; if status.is_failure() { - return None; + None + } else { + let start = start.max(0); + let end = end.max(start); + Some(start as usize..end as usize) } - - let start = start.max(0); - let end = end.max(start); - Some(start as usize..end as usize) } } @@ -720,15 +716,7 @@ impl Iterator for Regex { return None; } - let start = unsafe { (f.uregex_start64)(self.0, 0, &mut status) }; - let end = unsafe { (f.uregex_end64)(self.0, 0, &mut status) }; - if status.is_failure() { - return None; - } - - let start = start.max(0); - let end = end.max(start); - Some(start as usize..end as usize) + self.group(0) } } From bf471dd4d2cd78c0a6fae06292987d22e7f494a6 Mon Sep 17 00:00:00 2001 From: Leonard Hecker Date: Wed, 18 Jun 2025 01:11:45 +0200 Subject: [PATCH 5/5] Comments! --- src/buffer/mod.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/buffer/mod.rs b/src/buffer/mod.rs index 8d949bb9b0a..d4ee34fa2a7 100644 --- a/src/buffer/mod.rs +++ b/src/buffer/mod.rs @@ -1255,7 +1255,15 @@ impl TextBuffer { // Unescape any escaped characters. while off < replacement.len() && replacement[off] == b'\\' { off += 2; - text.push(match replacement.get(off - 1).map_or(b'\\', |&c| c) { + + // If this backslash is the last character (e.g. because + // `replacement` is just 1 byte long, holding just b"\\"), + // we can't unescape it. In that case, we map it to `b'\\'` here. + // This results in us appending a literal backslash to the text. + let ch = replacement.get(off - 1).map_or(b'\\', |&c| c); + + // Unescape and append the character. + text.push(match ch { b'n' => b'\n', b'r' => b'\r', b't' => b'\t',