tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — vello 0.9 / wgpu 29, 120 miembros
1//! Búsqueda en el buffer. PMV: case-insensitive opcional, sin regex,
2//! sin replace. La UI del prompt vive en el caller (típicamente una
3//! barra arriba del editor); este módulo sólo provee:
4//!
5//! - [`FindState`] con el query actual + dirección + flag case-sensitive.
6//! - [`find_next`] / [`find_prev`] que devuelven la próxima/anterior
7//! match desde el caret del editor.
8//! - [`all_matches`] para que el render resalte cada ocurrencia.
9
10use alloc::string::String;
11use alloc::vec::Vec;
12
13use crate::buffer::Buffer;
14use crate::cursor::{Cursor, Pos};
15
16/// Configuración de búsqueda del editor.
17#[derive(Debug, Clone, Default)]
18pub struct FindState {
19 pub query: String,
20 pub case_sensitive: bool,
21}
22
23impl FindState {
24 pub fn new() -> Self {
25 Self::default()
26 }
27 pub fn with_query(query: impl Into<String>) -> Self {
28 Self { query: query.into(), case_sensitive: false }
29 }
30 pub fn is_active(&self) -> bool {
31 !self.query.is_empty()
32 }
33}
34
35/// Devuelve todas las ocurrencias del query en el buffer como
36/// `(start_offset, end_offset)` en char offsets. Vacío si query vacío.
37pub fn all_matches(buf: &Buffer, find: &FindState) -> Vec<(usize, usize)> {
38 if find.query.is_empty() {
39 return Vec::new();
40 }
41 let hay = buf.text();
42 let (hay_search, needle_search) = if find.case_sensitive {
43 (hay.clone(), find.query.clone())
44 } else {
45 (hay.to_lowercase(), find.query.to_lowercase())
46 };
47
48 // Buscamos en bytes; convertimos a char_offsets al devolver.
49 let mut out: Vec<(usize, usize)> = Vec::new();
50 let mut byte_start = 0;
51 while let Some(pos) = hay_search[byte_start..].find(&needle_search) {
52 let byte_match = byte_start + pos;
53 let char_start = hay[..byte_match].chars().count();
54 let char_end = char_start + find.query.chars().count();
55 out.push((char_start, char_end));
56 byte_start = byte_match + needle_search.len().max(1);
57 }
58 out
59}
60
61/// Encuentra la próxima ocurrencia con `start >= caret_off` (la match
62/// **en** el caret cuenta, no la saltea). Para avanzar a la siguiente
63/// real, el caller mueve el caret al `end` de la match anterior y
64/// vuelve a llamar. Wrap-around al fin del buffer → primera match.
65pub fn find_next(buf: &Buffer, find: &FindState, cursor: &Cursor) -> Option<(Pos, Pos)> {
66 let matches = all_matches(buf, find);
67 if matches.is_empty() {
68 return None;
69 }
70 let caret_off = buf.pos_to_offset(cursor.caret.line, cursor.caret.col);
71 let next = matches
72 .iter()
73 .find(|(s, _)| *s >= caret_off)
74 .copied()
75 .or_else(|| matches.first().copied())?;
76 Some(positions_of(buf, next))
77}
78
79/// Como [`find_next`] pero en reverso.
80pub fn find_prev(buf: &Buffer, find: &FindState, cursor: &Cursor) -> Option<(Pos, Pos)> {
81 let matches = all_matches(buf, find);
82 if matches.is_empty() {
83 return None;
84 }
85 let caret_off = buf.pos_to_offset(cursor.caret.line, cursor.caret.col);
86 let prev = matches
87 .iter()
88 .rev()
89 .find(|(_, e)| *e < caret_off)
90 .copied()
91 .or_else(|| matches.last().copied())?;
92 Some(positions_of(buf, prev))
93}
94
95fn positions_of(buf: &Buffer, (start, end): (usize, usize)) -> (Pos, Pos) {
96 let (sl, sc) = buf.offset_to_pos(start);
97 let (el, ec) = buf.offset_to_pos(end);
98 (Pos::new(sl, sc), Pos::new(el, ec))
99}
100
101/// Reemplaza la **próxima** ocurrencia desde el caret por `replacement` y
102/// deja el caret al final del texto insertado. Devuelve el [`EditDelta`]
103/// para la pila de undo, o `None` si no hay ninguna ocurrencia. El
104/// wrap-around es el de [`find_next`].
105pub fn replace_next(
106 buf: &mut Buffer,
107 find: &FindState,
108 cursor: &mut Cursor,
109 replacement: &str,
110) -> Option<crate::ops::EditDelta> {
111 let (start_pos, end_pos) = find_next(buf, find, cursor)?;
112 cursor.anchor = Some(start_pos);
113 cursor.caret = end_pos;
114 Some(crate::ops::replace_selection(buf, cursor, replacement))
115}
116
117/// Reemplaza **todas** las ocurrencias del query por `replacement` en una
118/// sola operación reversible. Devuelve `(delta, n)` con el delta para undo
119/// y la cantidad reemplazada, o `None` si no había ninguna. El reemplazo
120/// es literal (sin grupos/regex).
121pub fn replace_all(
122 buf: &mut Buffer,
123 find: &FindState,
124 cursor: &mut Cursor,
125 replacement: &str,
126) -> Option<(crate::ops::EditDelta, usize)> {
127 let matches = all_matches(buf, find);
128 if matches.is_empty() {
129 return None;
130 }
131 let n = matches.len();
132 // Región mínima que cubre de la primera a la última match: un único
133 // delta atómico (un solo undo deshace todo el reemplazo).
134 let region_start = matches.first().unwrap().0;
135 let region_end = matches.last().unwrap().1;
136 let removed = buf.slice(region_start, region_end);
137
138 // Reconstruimos la región reemplazando cada match, copiando lo de entre
139 // medio tal cual.
140 let mut inserted = String::with_capacity(removed.len());
141 let mut prev_end = region_start;
142 for (s, e) in &matches {
143 inserted.push_str(&buf.slice(prev_end, *s));
144 inserted.push_str(replacement);
145 prev_end = *e;
146 }
147 inserted.push_str(&buf.slice(prev_end, region_end));
148
149 let before = *cursor;
150 buf.delete(region_start, region_end);
151 buf.insert(region_start, &inserted);
152 let new_off = region_start + inserted.chars().count();
153 let (line, col) = buf.offset_to_pos(new_off);
154 cursor.caret = Pos::new(line, col);
155 cursor.desired_col = col;
156 cursor.anchor = None;
157 let delta = crate::ops::EditDelta {
158 start: region_start,
159 removed,
160 inserted,
161 cursor_before: before,
162 cursor_after: *cursor,
163 };
164 Some((delta, n))
165}
166
167#[cfg(test)]
168mod tests {
169 use super::*;
170
171 #[test]
172 fn all_matches_vacio_devuelve_vacio() {
173 let b = Buffer::from_str("hola hola");
174 let f = FindState::new();
175 assert!(all_matches(&b, &f).is_empty());
176 }
177
178 #[test]
179 fn all_matches_encuentra_todas() {
180 let b = Buffer::from_str("ab cd ab ef ab");
181 let f = FindState::with_query("ab");
182 let m = all_matches(&b, &f);
183 assert_eq!(m, vec![(0, 2), (6, 8), (12, 14)]);
184 }
185
186 #[test]
187 fn case_insensitive_por_default() {
188 let b = Buffer::from_str("Hola HOLA hola");
189 let f = FindState::with_query("hola");
190 assert_eq!(all_matches(&b, &f).len(), 3);
191 }
192
193 #[test]
194 fn case_sensitive_filtra() {
195 let b = Buffer::from_str("Hola HOLA hola");
196 let f = FindState { query: "hola".into(), case_sensitive: true };
197 assert_eq!(all_matches(&b, &f).len(), 1);
198 }
199
200 #[test]
201 fn find_next_wrap_al_final() {
202 let b = Buffer::from_str("ab cd ab");
203 let f = FindState::with_query("ab");
204 let c = Cursor::at(0, 8); // al final
205 let (a, _) = find_next(&b, &f, &c).unwrap();
206 assert_eq!(a, Pos::new(0, 0)); // wrap al primero
207 }
208
209 #[test]
210 fn find_prev_wrap_al_principio() {
211 let b = Buffer::from_str("ab cd ab");
212 let f = FindState::with_query("ab");
213 let c = Cursor::at(0, 0);
214 let (a, _) = find_prev(&b, &f, &c).unwrap();
215 assert_eq!(a, Pos::new(0, 6)); // wrap al último
216 }
217
218 #[test]
219 fn find_next_devuelve_match_en_el_caret() {
220 let b = Buffer::from_str("ab ab ab");
221 let f = FindState::with_query("ab");
222 let c = Cursor::at(0, 0);
223 let (a, _) = find_next(&b, &f, &c).unwrap();
224 assert_eq!(a, Pos::new(0, 0));
225 }
226
227 #[test]
228 fn replace_next_reemplaza_una() {
229 let mut b = Buffer::from_str("ab cd ab");
230 let f = FindState::with_query("ab");
231 let mut c = Cursor::at(0, 0);
232 replace_next(&mut b, &f, &mut c, "XY");
233 assert_eq!(b.text(), "XY cd ab");
234 }
235
236 #[test]
237 fn replace_all_reemplaza_todas() {
238 let mut b = Buffer::from_str("ab cd ab ef ab");
239 let f = FindState::with_query("ab");
240 let mut c = Cursor::at(0, 0);
241 let (_, n) = replace_all(&mut b, &f, &mut c, "Z").unwrap();
242 assert_eq!(n, 3);
243 assert_eq!(b.text(), "Z cd Z ef Z");
244 }
245
246 #[test]
247 fn replace_all_distinto_largo_y_undo() {
248 let mut b = Buffer::from_str("foo foo");
249 let f = FindState::with_query("foo");
250 let mut c = Cursor::at(0, 0);
251 let (delta, _) = replace_all(&mut b, &f, &mut c, "barbar").unwrap();
252 assert_eq!(b.text(), "barbar barbar");
253 delta.undo(&mut b, &mut c);
254 assert_eq!(b.text(), "foo foo");
255 }
256
257 #[test]
258 fn find_next_avanza_si_caret_va_al_fin_de_match_anterior() {
259 let b = Buffer::from_str("ab ab ab");
260 let f = FindState::with_query("ab");
261 let mut c = Cursor::at(0, 0);
262 let (_, end1) = find_next(&b, &f, &c).unwrap();
263 c.caret = end1; // (0, 2) — fin de la primera
264 let (a2, _) = find_next(&b, &f, &c).unwrap();
265 assert_eq!(a2, Pos::new(0, 3));
266 }
267}