//! Buffer del editor — fachada fina con las conversiones de coordenadas //! que el resto del crate usa, sobre UNA de dos espaldas del mismo //! contrato: //! //! - **ropey** (feature `ropey`, default): rope de verdad, O(log n) — //! el escritorio, archivos grandes. //! - **la soga** (sin la feature): `String` + índice de líneas, `no_std` //! + alloc, O(n) por edición — la jaula WASM de wawa, donde lo que se //! edita cabe en memoria y no hay `std`. //! //! La semántica es idéntica por contrato (la de `Rope::lines()`), y los //! tests de este módulo corren contra la espalda que esté puesta: //! `cargo test -p llimphi-widget-text-editor-core` prueba ropey, //! `cargo test -p llimphi-widget-text-editor-core --no-default-features` //! prueba la soga. //! //! Coordenadas: //! - `char_offset`: índice de carácter (no byte) en el buffer entero. //! - `(line, col)`: línea (0-based) + columna en chars dentro de esa línea. //! //! Convenciones: //! - Las líneas son las que define `Rope::lines()` — un `\n` separa //! líneas; la última línea puede o no terminar en `\n` (en cuyo caso //! hay una línea vacía extra después). //! - `col` cuenta chars, no graphemes ni bytes. Para CJK ancho doble //! el render decidirá el ancho visual; el cursor avanza en chars. use alloc::string::{String, ToString}; use alloc::vec::Vec; // --------------------------------------------------------------------------- // La espalda ropey (escritorio). // --------------------------------------------------------------------------- #[cfg(feature = "ropey")] mod nucleo { use super::{String, ToString}; use ropey::Rope; #[derive(Debug, Clone)] pub(super) struct Nucleo { rope: Rope, } impl Nucleo { pub fn new() -> Self { Self { rope: Rope::new() } } pub fn from_str(s: &str) -> Self { Self { rope: Rope::from_str(s) } } pub fn text(&self) -> String { self.rope.to_string() } pub fn len_chars(&self) -> usize { self.rope.len_chars() } pub fn len_lines(&self) -> usize { self.rope.len_lines() } /// La línea `n` con su `\n` terminal si no es la última. /// Precondición del caller: `n < len_lines()`. pub fn line_string(&self, n: usize) -> String { self.rope.line(n).to_string() } /// Chars de la línea `n` SIN contar el `\n` terminal. /// Precondición del caller: `n < len_lines()`. pub fn line_len_chars(&self, n: usize) -> usize { let line = self.rope.line(n); let mut len = line.len_chars(); if len > 0 && line.char(len - 1) == '\n' { len -= 1; } len } /// El largo de **todas** las líneas, de un solo recorrido. /// /// No es `(0..n).map(line_len_chars)`: cada `rope.line(n)` es una /// búsqueda `O(log n)` en el árbol, y el iterador secuencial no. Con /// 100.000 líneas la diferencia es de decenas de milisegundos, que es /// justo el orden en el que un rearmado por tecla se siente. pub fn line_lens(&self) -> Vec { self.rope .lines() .map(|l| { let n = l.len_chars(); if n > 0 && l.char(n - 1) == '\n' { n - 1 } else { n } }) .collect() } /// Precondición del caller: `off <= len_chars()`. pub fn char_to_line(&self, off: usize) -> usize { self.rope.char_to_line(off) } /// Precondición del caller: `n <= len_lines()`. pub fn line_to_char(&self, n: usize) -> usize { self.rope.line_to_char(n) } /// Precondición del caller: `off <= len_chars()`. pub fn char_to_byte(&self, off: usize) -> usize { self.rope.char_to_byte(off) } /// Precondición del caller: `n <= len_lines()`. pub fn line_to_byte(&self, n: usize) -> usize { self.rope.line_to_byte(n) } /// Precondición del caller: `off < len_chars()`. pub fn char_at(&self, off: usize) -> char { self.rope.char(off) } /// Precondición del caller: `s <= e <= len_chars()`. pub fn slice(&self, s: usize, e: usize) -> String { self.rope.slice(s..e).to_string() } /// Precondición del caller: `off <= len_chars()`. pub fn insert(&mut self, off: usize, s: &str) { self.rope.insert(off, s); } /// Precondición del caller: `s <= e <= len_chars()`. pub fn remove(&mut self, s: usize, e: usize) { self.rope.remove(s..e); } } } // --------------------------------------------------------------------------- // La soga (no_std): String + índice de arranques de línea. // // El índice guarda, por línea, su `(char_start, byte_start)`. Se // reconstruye entero en cada edición — O(n), y está bien: la soga existe // para editar lo que cabe en una jaula (notas, config, celdas), no para // abrir un log de 200 MB. Quien necesite eso está en el escritorio, con // ropey. // --------------------------------------------------------------------------- #[cfg(not(feature = "ropey"))] mod nucleo { use super::{String, ToString, Vec}; #[derive(Debug, Clone)] pub(super) struct Nucleo { texto: String, /// `(char_start, byte_start)` de cada línea. Nunca vacío: el /// buffer vacío tiene una línea (la 0, vacía) — igual que ropey. lineas: Vec<(usize, usize)>, n_chars: usize, } impl Nucleo { pub fn new() -> Self { Self::from_str("") } pub fn from_str(s: &str) -> Self { let mut n = Self { texto: s.to_string(), lineas: Vec::new(), n_chars: 0, }; n.reindexar(); n } /// Reconstruye el índice de líneas y el conteo de chars. La única /// función O(n) sobre el texto entero; TODO lo demás la consulta. fn reindexar(&mut self) { self.lineas.clear(); self.lineas.push((0, 0)); let mut ci = 0usize; for (bi, c) in self.texto.char_indices() { ci += 1; if c == '\n' { self.lineas.push((ci, bi + 1)); } } self.n_chars = ci; } pub fn text(&self) -> String { self.texto.clone() } pub fn len_chars(&self) -> usize { self.n_chars } pub fn len_lines(&self) -> usize { self.lineas.len() } /// Rango de bytes `[inicio..fin)` de la línea `n` (con su `\n`). fn bytes_de_linea(&self, n: usize) -> (usize, usize) { let ini = self.lineas[n].1; let fin = match self.lineas.get(n + 1) { Some(&(_, b)) => b, None => self.texto.len(), }; (ini, fin) } pub fn line_string(&self, n: usize) -> String { let (ini, fin) = self.bytes_de_linea(n); self.texto[ini..fin].to_string() } pub fn line_len_chars(&self, n: usize) -> usize { let fin_chars = match self.lineas.get(n + 1) { // La línea siguiente arranca después del `\n` de ésta. Some(&(c, _)) => c - 1, None => self.n_chars, }; fin_chars - self.lineas[n].0 } /// El largo de todas las líneas. Acá el índice ya está armado, así /// que es una resta por línea. pub fn line_lens(&self) -> Vec { (0..self.lineas.len()).map(|n| self.line_len_chars(n)).collect() } pub fn char_to_line(&self, off: usize) -> usize { // Última línea cuyo char_start <= off. `partition_point` // devuelve el primer índice donde el predicado falla. self.lineas.partition_point(|&(c, _)| c <= off) - 1 } pub fn line_to_char(&self, n: usize) -> usize { match self.lineas.get(n) { Some(&(c, _)) => c, // ropey permite `line_to_char(len_lines()) == len_chars()`. None => self.n_chars, } } pub fn char_to_byte(&self, off: usize) -> usize { let linea = self.char_to_line(off); let (cs, bs) = self.lineas[linea]; // Caminar chars desde el arranque de la línea — corto: una línea. let mut b = bs; let mut c = cs; let mut iter = self.texto[bs..].chars(); while c < off { // `off <= len_chars` garantiza que el char existe. let ch = iter.next().expect("char_to_byte: offset fuera del texto"); b += ch.len_utf8(); c += 1; } b } pub fn line_to_byte(&self, n: usize) -> usize { match self.lineas.get(n) { Some(&(_, b)) => b, None => self.texto.len(), } } pub fn char_at(&self, off: usize) -> char { let b = self.char_to_byte(off); self.texto[b..] .chars() .next() .expect("char_at: offset fuera del texto") } pub fn slice(&self, s: usize, e: usize) -> String { let bs = self.char_to_byte(s); let be = self.char_to_byte(e); self.texto[bs..be].to_string() } pub fn insert(&mut self, off: usize, s: &str) { let b = self.char_to_byte(off); self.texto.insert_str(b, s); self.reindexar(); } pub fn remove(&mut self, s: usize, e: usize) { let bs = self.char_to_byte(s); let be = self.char_to_byte(e); self.texto.replace_range(bs..be, ""); self.reindexar(); } } } use nucleo::Nucleo; #[derive(Debug, Clone)] pub struct Buffer { rope: Nucleo, } impl Default for Buffer { fn default() -> Self { Self::new() } } impl Buffer { pub fn new() -> Self { Self { rope: Nucleo::new() } } pub fn from_str(s: &str) -> Self { Self { rope: Nucleo::from_str(s) } } pub fn text(&self) -> String { self.rope.text() } pub fn len_chars(&self) -> usize { self.rope.len_chars() } pub fn len_lines(&self) -> usize { self.rope.len_lines().max(1) } pub fn is_empty(&self) -> bool { self.rope.len_chars() == 0 } /// Devuelve la línea `n` como `String` (incluye su trailing `\n` si /// no es la última). Si `n` está fuera de rango devuelve `""`. pub fn line(&self, n: usize) -> String { if n >= self.rope.len_lines() { return String::new(); } self.rope.line_string(n) } /// Cantidad de chars en la línea `n` **sin contar** el `\n` terminal. pub fn line_len_chars(&self, n: usize) -> usize { if n >= self.rope.len_lines() { return 0; } self.rope.line_len_chars(n) } /// El largo (en chars, sin el `\n`) de **todas** las líneas, de un solo /// recorrido. Para quien necesita el perfil entero del archivo —el mapa de /// ajuste de línea, el minimapa— y no una línea suelta. pub fn line_lens(&self) -> Vec { self.rope.line_lens() } /// Convierte `char_offset` global a `(line, col)`. pub fn offset_to_pos(&self, offset: usize) -> (usize, usize) { let off = offset.min(self.rope.len_chars()); let line = self.rope.char_to_line(off); let line_start = self.rope.line_to_char(line); (line, off - line_start) } /// Convierte `(line, col)` a `char_offset`. Clampea `line` y `col` /// para no panicear con coordenadas fuera de rango. pub fn pos_to_offset(&self, line: usize, col: usize) -> usize { let line = line.min(self.rope.len_lines().saturating_sub(1)); let line_start = self.rope.line_to_char(line); let line_chars = self.line_len_chars(line); let col = col.min(line_chars); line_start + col } /// Carácter en `char_offset`. `None` si está fuera de rango. pub fn char_at(&self, offset: usize) -> Option { if offset >= self.rope.len_chars() { return None; } Some(self.rope.char_at(offset)) } /// Slice `[start..end)` como `String`. Clampea para no panicear. pub fn slice(&self, start: usize, end: usize) -> String { let len = self.rope.len_chars(); let s = start.min(len); let e = end.min(len).max(s); self.rope.slice(s, e) } /// Inserta `s` en `offset`. Clampea `offset`. pub fn insert(&mut self, offset: usize, s: &str) { let off = offset.min(self.rope.len_chars()); self.rope.insert(off, s); } /// Borra `[start..end)`. Clampea ambos. pub fn delete(&mut self, start: usize, end: usize) { let len = self.rope.len_chars(); let s = start.min(len); let e = end.min(len).max(s); if s == e { return; } self.rope.remove(s, e); } pub fn set_text(&mut self, s: &str) { self.rope = Nucleo::from_str(s); } pub fn replace_all(&mut self, s: &str) { self.set_text(s); } /// Convierte char_offset → byte_offset. tree-sitter trabaja en bytes /// (UTF-8); el editor en chars. Esto las conecta. pub fn char_to_byte(&self, char_offset: usize) -> usize { let off = char_offset.min(self.rope.len_chars()); self.rope.char_to_byte(off) } /// Línea (0-based) que contiene el char_offset dado. pub fn char_to_line(&self, char_offset: usize) -> usize { let off = char_offset.min(self.rope.len_chars()); self.rope.char_to_line(off) } /// Byte_offset del primer char de la línea `n`. pub fn line_to_byte(&self, line: usize) -> usize { let line = line.min(self.rope.len_lines()); self.rope.line_to_byte(line) } /// Devuelve el rango `[start_col..col)` que contiene el "word" actual /// — desde el último carácter no-de-palabra hasta `col`, en la línea /// `line`. Útil para autocompletion (smart-replace del prefijo). pub fn current_word_prefix(&self, line: usize, col: usize) -> (usize, String) { let line_text = self.line(line); let chars: Vec = line_text .chars() .filter(|c| *c != '\n') .collect(); let end = col.min(chars.len()); let mut start = end; while start > 0 && is_word_char(chars[start - 1]) { start -= 1; } let prefix: String = chars[start..end].iter().collect(); (start, prefix) } } fn is_word_char(c: char) -> bool { c.is_alphanumeric() || c == '_' } #[cfg(test)] mod tests { use super::*; #[test] fn empty_buffer_has_one_line() { let b = Buffer::new(); assert_eq!(b.len_lines(), 1); assert_eq!(b.line_len_chars(0), 0); } #[test] fn pos_offset_roundtrip() { let b = Buffer::from_str("hola\nmundo\nfin"); let cases = [(0usize, 0usize), (0, 4), (1, 0), (1, 5), (2, 3)]; for (line, col) in cases { let off = b.pos_to_offset(line, col); assert_eq!(b.offset_to_pos(off), (line, col)); } } #[test] fn line_len_excludes_trailing_newline() { let b = Buffer::from_str("hola\nfin"); assert_eq!(b.line_len_chars(0), 4); // "hola" sin \n assert_eq!(b.line_len_chars(1), 3); // "fin" } #[test] fn insert_and_delete_modify_text() { let mut b = Buffer::from_str("ab"); b.insert(1, "X"); assert_eq!(b.text(), "aXb"); b.delete(1, 2); assert_eq!(b.text(), "ab"); } #[test] fn slice_clampea() { let b = Buffer::from_str("hola"); assert_eq!(b.slice(0, 100), "hola"); assert_eq!(b.slice(50, 100), ""); assert_eq!(b.slice(2, 1), ""); // end < start clampea } #[test] fn current_word_prefix_basic() { let b = Buffer::from_str("let hola_mundo = 1;"); // Caret en col 14 (después de la 'o' de "hola_mundo"). let (start, p) = b.current_word_prefix(0, 14); assert_eq!(start, 4); assert_eq!(p, "hola_mundo"); } #[test] fn current_word_prefix_en_inicio_es_vacio() { let b = Buffer::from_str("hola"); let (start, p) = b.current_word_prefix(0, 0); assert_eq!(start, 0); assert!(p.is_empty()); } #[test] fn current_word_prefix_caret_despues_de_no_word() { let b = Buffer::from_str("foo.bar"); let (start, p) = b.current_word_prefix(0, 4); // El '.' no es word; el prefijo empieza ahí. assert_eq!(start, 4); assert!(p.is_empty()); } #[test] fn pos_to_offset_clampea_col() { let b = Buffer::from_str("ab\ncd"); // col fuera de rango → fin de línea assert_eq!(b.pos_to_offset(0, 99), 2); assert_eq!(b.pos_to_offset(1, 99), 5); } // --- Contrato de espaldas: estos casos clavan la semántica que las // --- DOS espaldas (ropey y soga) deben compartir. Corren contra la // --- que esté compilada. #[test] fn texto_con_newline_final_tiene_linea_vacia_extra() { // Semántica de `Rope::lines()`: "a\n" son DOS líneas ("a\n", ""). let b = Buffer::from_str("hola\n"); assert_eq!(b.len_lines(), 2); assert_eq!(b.line(0), "hola\n"); assert_eq!(b.line(1), ""); assert_eq!(b.line_len_chars(1), 0); } #[test] fn char_to_line_en_el_borde() { let b = Buffer::from_str("ab\ncd"); assert_eq!(b.char_to_line(0), 0); assert_eq!(b.char_to_line(2), 0); // el '\n' pertenece a la línea 0 assert_eq!(b.char_to_line(3), 1); // primer char de "cd" assert_eq!(b.char_to_line(5), 1); // len_chars → última línea } #[test] fn char_byte_multibyte() { // "ñ" = 2 bytes, "中" = 3 bytes: chars ≠ bytes. let b = Buffer::from_str("ñ中x\nfin"); assert_eq!(b.len_chars(), 7); assert_eq!(b.char_to_byte(0), 0); assert_eq!(b.char_to_byte(1), 2); // después de "ñ" assert_eq!(b.char_to_byte(2), 5); // después de "中" assert_eq!(b.char_to_byte(3), 6); // después de "x" assert_eq!(b.line_to_byte(1), 7); // "fin" arranca tras el '\n' assert_eq!(b.char_at(1), Some('中')); assert_eq!(b.slice(0, 2), "ñ中"); } #[test] fn editar_multibyte_conserva_indices() { let mut b = Buffer::from_str("aña\nbé"); b.insert(2, "中"); assert_eq!(b.text(), "añ中a\nbé"); assert_eq!(b.line_len_chars(0), 4); b.delete(0, 3); assert_eq!(b.text(), "a\nbé"); assert_eq!(b.len_lines(), 2); assert_eq!(b.line_len_chars(1), 2); } #[test] fn line_to_byte_permite_len_lines() { // ropey permite pedir la línea después de la última: devuelve el // final del texto. La soga debe hacer lo mismo. let b = Buffer::from_str("ab\ncd"); assert_eq!(b.line_to_byte(2), 5); } }