feat: llimphi standalone — framework UI soberano extraído del monorepo
Motor gráfico Llimphi como workspace independiente: bucle Elm (input→update→view→layout→raster→present) sobre wgpu+vello+taffy+parley. Núcleo (hal/raster/layout/text/ui/theme/surface/motion/icons) + ~40 widgets + módulos, sin dependencias al resto del monorepo. cargo check --workspace pasa (64 crates). Puerta de entrada: cargo run -p llimphi-ui --example counter. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "llimphi-widget-text-editor-core"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
publish.workspace = true
|
||||
description = "llimphi-widget-text-editor-core — núcleo agnóstico del editor de código: rope buffer (ropey), cursor + selección, undo/redo, bracket matching, find, diagnostics y syntax highlighting (tree-sitter). Sin dependencias de render — reutilizable en TUI/web/headless. La capa Llimphi (state + view) vive en `llimphi-widget-text-editor`."
|
||||
|
||||
[dependencies]
|
||||
ropey = { workspace = true }
|
||||
tree-sitter = { workspace = true }
|
||||
tree-sitter-rust = { workspace = true }
|
||||
tree-sitter-python = { workspace = true }
|
||||
# peniko sólo aporta el tipo de color (peniko::Color) para SyntaxPalette;
|
||||
# es un crate de tipos sin GPU — no arrastra wgpu/vello. Versión alineada
|
||||
# con la que expone vello 0.5 (ver workspace root).
|
||||
peniko = "0.4"
|
||||
@@ -0,0 +1,165 @@
|
||||
//! Matching de paréntesis/corchetes/llaves bajo el cursor.
|
||||
//!
|
||||
//! Si el carácter inmediatamente *antes* o *en* el caret es un bracket
|
||||
//! abridor o cerrador, busca su par contando profundidad y devuelve las
|
||||
//! dos posiciones. Útil para el visor (resaltar ambas).
|
||||
//!
|
||||
//! Restricciones del PMV: no diferencia brackets dentro de strings ni
|
||||
//! comentarios — el tokenizer del bloque de highlight (tree-sitter) lo
|
||||
//! resolverá mejor en una pasada futura. Para WAT/JSON/Lisp esto basta.
|
||||
|
||||
use crate::buffer::Buffer;
|
||||
use crate::cursor::{Cursor, Pos};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Direction {
|
||||
Forward,
|
||||
Backward,
|
||||
}
|
||||
|
||||
/// Pares reconocidos.
|
||||
const PAIRS: &[(char, char)] = &[('(', ')'), ('[', ']'), ('{', '}')];
|
||||
|
||||
fn pair_of(c: char) -> Option<(char, char, Direction)> {
|
||||
for &(o, cl) in PAIRS {
|
||||
if c == o {
|
||||
return Some((o, cl, Direction::Forward));
|
||||
}
|
||||
if c == cl {
|
||||
return Some((o, cl, Direction::Backward));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Si el caret toca un bracket, devuelve `(pos_del_bracket, pos_del_par)`.
|
||||
pub fn find_bracket_pair(buf: &Buffer, cursor: &Cursor) -> Option<(Pos, Pos)> {
|
||||
let caret_off = buf.pos_to_offset(cursor.caret.line, cursor.caret.col);
|
||||
|
||||
// Probamos en `caret` y `caret-1` — un caret "entre" dos chars puede
|
||||
// tocar al de la izquierda visualmente.
|
||||
let candidates: [Option<usize>; 2] = [
|
||||
Some(caret_off).filter(|&o| o < buf.len_chars()),
|
||||
caret_off.checked_sub(1),
|
||||
];
|
||||
|
||||
for opt in candidates {
|
||||
let Some(off) = opt else { continue };
|
||||
let Some(ch) = buf.char_at(off) else { continue };
|
||||
let Some((open, close, dir)) = pair_of(ch) else { continue };
|
||||
let mate = match dir {
|
||||
Direction::Forward => find_forward(buf, off + 1, open, close),
|
||||
Direction::Backward => find_backward(buf, off, open, close),
|
||||
};
|
||||
if let Some(mate_off) = mate {
|
||||
let a = buf.offset_to_pos(off);
|
||||
let b = buf.offset_to_pos(mate_off);
|
||||
return Some((Pos::new(a.0, a.1), Pos::new(b.0, b.1)));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn find_forward(buf: &Buffer, from: usize, open: char, close: char) -> Option<usize> {
|
||||
let mut depth = 1usize;
|
||||
let mut off = from;
|
||||
let len = buf.len_chars();
|
||||
while off < len {
|
||||
match buf.char_at(off) {
|
||||
Some(c) if c == open => depth += 1,
|
||||
Some(c) if c == close => {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
return Some(off);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
off += 1;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn find_backward(buf: &Buffer, before: usize, open: char, close: char) -> Option<usize> {
|
||||
if before == 0 {
|
||||
return None;
|
||||
}
|
||||
let mut depth = 1usize;
|
||||
let mut off = before;
|
||||
while off > 0 {
|
||||
off -= 1;
|
||||
match buf.char_at(off) {
|
||||
Some(c) if c == close => depth += 1,
|
||||
Some(c) if c == open => {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
return Some(off);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn empareja_paren_simple() {
|
||||
let b = Buffer::from_str("(a)");
|
||||
let c = Cursor::at(0, 0); // caret antes del '('
|
||||
let (a, m) = find_bracket_pair(&b, &c).unwrap();
|
||||
assert_eq!(a, Pos::new(0, 0));
|
||||
assert_eq!(m, Pos::new(0, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empareja_desde_el_lado_derecho() {
|
||||
let b = Buffer::from_str("(a)");
|
||||
let c = Cursor::at(0, 3); // caret después del ')'
|
||||
let (a, m) = find_bracket_pair(&b, &c).unwrap();
|
||||
assert_eq!(a, Pos::new(0, 2)); // ')'
|
||||
assert_eq!(m, Pos::new(0, 0)); // '('
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anidados_respeta_profundidad() {
|
||||
let b = Buffer::from_str("((a))");
|
||||
let c = Cursor::at(0, 0); // primer '('
|
||||
let (_, m) = find_bracket_pair(&b, &c).unwrap();
|
||||
assert_eq!(m, Pos::new(0, 4)); // último ')'
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empareja_brackets_y_llaves() {
|
||||
let b = Buffer::from_str("[a]");
|
||||
assert!(find_bracket_pair(&b, &Cursor::at(0, 0)).is_some());
|
||||
|
||||
let b2 = Buffer::from_str("{a}");
|
||||
assert!(find_bracket_pair(&b2, &Cursor::at(0, 0)).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caret_lejos_de_bracket_devuelve_none() {
|
||||
let b = Buffer::from_str("hola");
|
||||
let c = Cursor::at(0, 2);
|
||||
assert!(find_bracket_pair(&b, &c).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bracket_sin_par_devuelve_none() {
|
||||
let b = Buffer::from_str("(a");
|
||||
let c = Cursor::at(0, 0);
|
||||
assert!(find_bracket_pair(&b, &c).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multilinea_pasa_saltos() {
|
||||
let b = Buffer::from_str("(\n a\n)");
|
||||
let c = Cursor::at(0, 0);
|
||||
let (_, m) = find_bracket_pair(&b, &c).unwrap();
|
||||
assert_eq!(m, Pos::new(2, 0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
//! Buffer del editor — wrapper fino sobre [`ropey::Rope`] con las
|
||||
//! conversiones de coordenadas que el resto del crate usa.
|
||||
//!
|
||||
//! 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 ropey::Rope;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Buffer {
|
||||
rope: Rope,
|
||||
}
|
||||
|
||||
impl Default for Buffer {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Buffer {
|
||||
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().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(n).to_string()
|
||||
}
|
||||
|
||||
/// 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;
|
||||
}
|
||||
let line = self.rope.line(n);
|
||||
let mut len = line.len_chars();
|
||||
// Quitamos el `\n` final si lo hay.
|
||||
if len > 0 && line.char(len - 1) == '\n' {
|
||||
len -= 1;
|
||||
}
|
||||
len
|
||||
}
|
||||
|
||||
/// 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<char> {
|
||||
if offset >= self.rope.len_chars() {
|
||||
return None;
|
||||
}
|
||||
Some(self.rope.char(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).to_string()
|
||||
}
|
||||
|
||||
/// 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 = Rope::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<char> = 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
//! Clipboard abstracto. El editor no quiere acoplarse a un backend de
|
||||
//! SO concreto (X11 / Wayland / macOS / Windows), así que define el
|
||||
//! trait y entrega un mock para tests. La impl real (vía `arboard`)
|
||||
//! vive del lado del caller — típicamente la app embebida en
|
||||
//! `nada` o el visor del notebook.
|
||||
|
||||
/// Backend de clipboard. `set` mete texto; `get` lo lee. Cualquiera de
|
||||
/// los dos puede fallar (sin display, headless CI, race con otro
|
||||
/// programa) — `None` / no-op silencioso es válido.
|
||||
pub trait Clipboard: Send {
|
||||
fn get(&mut self) -> Option<String>;
|
||||
fn set(&mut self, s: &str);
|
||||
}
|
||||
|
||||
/// Clipboard de memoria — útil para tests y como fallback cuando el
|
||||
/// sistema no expone uno.
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct MemClipboard {
|
||||
content: Option<String>,
|
||||
}
|
||||
|
||||
impl MemClipboard {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
pub fn with(s: impl Into<String>) -> Self {
|
||||
Self { content: Some(s.into()) }
|
||||
}
|
||||
}
|
||||
|
||||
impl Clipboard for MemClipboard {
|
||||
fn get(&mut self) -> Option<String> {
|
||||
self.content.clone()
|
||||
}
|
||||
fn set(&mut self, s: &str) {
|
||||
self.content = Some(s.to_owned());
|
||||
}
|
||||
}
|
||||
|
||||
/// "No clipboard" — `set` descarta, `get` devuelve `None`. Útil cuando
|
||||
/// el caller quiere desactivar copy/paste explícitamente.
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
pub struct NullClipboard;
|
||||
|
||||
impl Clipboard for NullClipboard {
|
||||
fn get(&mut self) -> Option<String> {
|
||||
None
|
||||
}
|
||||
fn set(&mut self, _: &str) {}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
//! Cursor + selección. Coordenadas en `(line, col)` (col en chars).
|
||||
//!
|
||||
//! Un [`Cursor`] tiene siempre una posición `caret` y opcionalmente un
|
||||
//! `anchor`: si están en distintos puntos, hay una **selección**.
|
||||
//! Movimiento sin `shift` colapsa la selección al caret nuevo;
|
||||
//! movimiento con `shift` extiende desde el `anchor`.
|
||||
|
||||
use crate::buffer::Buffer;
|
||||
|
||||
fn is_word(c: char) -> bool {
|
||||
c.is_alphanumeric() || c == '_'
|
||||
}
|
||||
fn is_ws(c: char) -> bool {
|
||||
c.is_whitespace() && c != '\n'
|
||||
}
|
||||
|
||||
/// Posición lógica del cursor — (línea, columna en chars).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct Pos {
|
||||
pub line: usize,
|
||||
pub col: usize,
|
||||
}
|
||||
|
||||
impl Pos {
|
||||
pub const fn new(line: usize, col: usize) -> Self {
|
||||
Self { line, col }
|
||||
}
|
||||
pub const ORIGIN: Pos = Pos { line: 0, col: 0 };
|
||||
}
|
||||
|
||||
/// Selección activa (anchor + caret). El rango efectivo es
|
||||
/// `(min(anchor,caret), max(anchor,caret))` en orden de offset.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Selection {
|
||||
pub anchor: Pos,
|
||||
pub caret: Pos,
|
||||
}
|
||||
|
||||
impl Selection {
|
||||
pub fn new(anchor: Pos, caret: Pos) -> Self {
|
||||
Self { anchor, caret }
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.anchor == self.caret
|
||||
}
|
||||
}
|
||||
|
||||
/// Cursor: caret + (opcional) anchor cuando hay selección.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Cursor {
|
||||
pub caret: Pos,
|
||||
pub anchor: Option<Pos>,
|
||||
/// Columna "deseada" — preserva la posición horizontal al saltar
|
||||
/// entre líneas de distinto largo. Se setea al mover horizontal
|
||||
/// y se respeta al mover vertical.
|
||||
pub desired_col: usize,
|
||||
}
|
||||
|
||||
impl Default for Cursor {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Cursor {
|
||||
pub fn new() -> Self {
|
||||
Self { caret: Pos::ORIGIN, anchor: None, desired_col: 0 }
|
||||
}
|
||||
|
||||
pub fn at(line: usize, col: usize) -> Self {
|
||||
Self { caret: Pos::new(line, col), anchor: None, desired_col: col }
|
||||
}
|
||||
|
||||
pub fn selection(&self) -> Option<Selection> {
|
||||
self.anchor.map(|a| Selection::new(a, self.caret))
|
||||
}
|
||||
|
||||
pub fn has_selection(&self) -> bool {
|
||||
self.anchor.map_or(false, |a| a != self.caret)
|
||||
}
|
||||
|
||||
/// Rango efectivo `(start, end)` en `char_offset` global. Si no hay
|
||||
/// selección, ambos son el caret.
|
||||
pub fn selection_range(&self, buf: &Buffer) -> (usize, usize) {
|
||||
let caret_off = buf.pos_to_offset(self.caret.line, self.caret.col);
|
||||
match self.anchor {
|
||||
None => (caret_off, caret_off),
|
||||
Some(a) => {
|
||||
let anchor_off = buf.pos_to_offset(a.line, a.col);
|
||||
if anchor_off <= caret_off {
|
||||
(anchor_off, caret_off)
|
||||
} else {
|
||||
(caret_off, anchor_off)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Colapsa la selección dejando el caret donde está.
|
||||
pub fn collapse(&mut self) {
|
||||
self.anchor = None;
|
||||
}
|
||||
|
||||
/// Asegura que `anchor = caret` si `extending` es true y no había
|
||||
/// anchor; si es false, colapsa.
|
||||
pub fn set_extending(&mut self, extending: bool) {
|
||||
match (extending, self.anchor) {
|
||||
(true, None) => self.anchor = Some(self.caret),
|
||||
(true, Some(_)) => {}
|
||||
(false, _) => self.anchor = None,
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Movimiento por chars -----
|
||||
|
||||
pub fn move_left(&mut self, buf: &Buffer, extending: bool) {
|
||||
self.set_extending(extending);
|
||||
if self.caret.col > 0 {
|
||||
self.caret.col -= 1;
|
||||
} else if self.caret.line > 0 {
|
||||
self.caret.line -= 1;
|
||||
self.caret.col = buf.line_len_chars(self.caret.line);
|
||||
}
|
||||
self.desired_col = self.caret.col;
|
||||
}
|
||||
|
||||
pub fn move_right(&mut self, buf: &Buffer, extending: bool) {
|
||||
self.set_extending(extending);
|
||||
let line_len = buf.line_len_chars(self.caret.line);
|
||||
if self.caret.col < line_len {
|
||||
self.caret.col += 1;
|
||||
} else if self.caret.line + 1 < buf.len_lines() {
|
||||
self.caret.line += 1;
|
||||
self.caret.col = 0;
|
||||
}
|
||||
self.desired_col = self.caret.col;
|
||||
}
|
||||
|
||||
pub fn move_up(&mut self, buf: &Buffer, extending: bool) {
|
||||
self.set_extending(extending);
|
||||
if self.caret.line == 0 {
|
||||
self.caret.col = 0;
|
||||
} else {
|
||||
self.caret.line -= 1;
|
||||
self.caret.col = self.desired_col.min(buf.line_len_chars(self.caret.line));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn move_down(&mut self, buf: &Buffer, extending: bool) {
|
||||
self.set_extending(extending);
|
||||
if self.caret.line + 1 >= buf.len_lines() {
|
||||
self.caret.col = buf.line_len_chars(self.caret.line);
|
||||
} else {
|
||||
self.caret.line += 1;
|
||||
self.caret.col = self.desired_col.min(buf.line_len_chars(self.caret.line));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn move_home(&mut self, _buf: &Buffer, extending: bool) {
|
||||
self.set_extending(extending);
|
||||
// Atajo: ir al inicio del primer non-whitespace; segundo Home
|
||||
// iría al 0 — por ahora siempre al 0.
|
||||
self.caret.col = 0;
|
||||
self.desired_col = 0;
|
||||
}
|
||||
|
||||
pub fn move_end(&mut self, buf: &Buffer, extending: bool) {
|
||||
self.set_extending(extending);
|
||||
self.caret.col = buf.line_len_chars(self.caret.line);
|
||||
self.desired_col = self.caret.col;
|
||||
}
|
||||
|
||||
pub fn move_page_up(&mut self, buf: &Buffer, extending: bool, page: usize) {
|
||||
self.set_extending(extending);
|
||||
self.caret.line = self.caret.line.saturating_sub(page);
|
||||
self.caret.col = self.desired_col.min(buf.line_len_chars(self.caret.line));
|
||||
}
|
||||
|
||||
pub fn move_page_down(&mut self, buf: &Buffer, extending: bool, page: usize) {
|
||||
self.set_extending(extending);
|
||||
self.caret.line = (self.caret.line + page).min(buf.len_lines().saturating_sub(1));
|
||||
self.caret.col = self.desired_col.min(buf.line_len_chars(self.caret.line));
|
||||
}
|
||||
|
||||
pub fn move_doc_start(&mut self, _buf: &Buffer, extending: bool) {
|
||||
self.set_extending(extending);
|
||||
self.caret = Pos::ORIGIN;
|
||||
self.desired_col = 0;
|
||||
}
|
||||
|
||||
pub fn move_doc_end(&mut self, buf: &Buffer, extending: bool) {
|
||||
self.set_extending(extending);
|
||||
let last_line = buf.len_lines().saturating_sub(1);
|
||||
self.caret = Pos::new(last_line, buf.line_len_chars(last_line));
|
||||
self.desired_col = self.caret.col;
|
||||
}
|
||||
|
||||
// ----- Word movement -----
|
||||
|
||||
/// Movimiento por palabra a la izquierda — salta whitespace, después
|
||||
/// caracteres de palabra (alfanumérico + `_`).
|
||||
pub fn move_word_left(&mut self, buf: &Buffer, extending: bool) {
|
||||
self.set_extending(extending);
|
||||
let mut off = buf.pos_to_offset(self.caret.line, self.caret.col);
|
||||
while off > 0 && buf.char_at(off - 1).map_or(false, is_ws) {
|
||||
off -= 1;
|
||||
}
|
||||
while off > 0 && buf.char_at(off - 1).map_or(false, is_word) {
|
||||
off -= 1;
|
||||
}
|
||||
let (l, c) = buf.offset_to_pos(off);
|
||||
self.caret = Pos::new(l, c);
|
||||
self.desired_col = c;
|
||||
}
|
||||
|
||||
pub fn move_word_right(&mut self, buf: &Buffer, extending: bool) {
|
||||
self.set_extending(extending);
|
||||
let len = buf.len_chars();
|
||||
let mut off = buf.pos_to_offset(self.caret.line, self.caret.col);
|
||||
while off < len && buf.char_at(off).map_or(false, is_word) {
|
||||
off += 1;
|
||||
}
|
||||
while off < len && buf.char_at(off).map_or(false, is_ws) {
|
||||
off += 1;
|
||||
}
|
||||
let (l, c) = buf.offset_to_pos(off);
|
||||
self.caret = Pos::new(l, c);
|
||||
self.desired_col = c;
|
||||
}
|
||||
|
||||
// ----- Setters -----
|
||||
|
||||
pub fn set_caret(&mut self, buf: &Buffer, pos: Pos) {
|
||||
let line = pos.line.min(buf.len_lines().saturating_sub(1));
|
||||
let col = pos.col.min(buf.line_len_chars(line));
|
||||
self.caret = Pos::new(line, col);
|
||||
self.desired_col = col;
|
||||
self.anchor = None;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn buf() -> Buffer {
|
||||
Buffer::from_str("hola\nmundo\nfin")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_new_is_origin() {
|
||||
let c = Cursor::new();
|
||||
assert_eq!(c.caret, Pos::ORIGIN);
|
||||
assert!(!c.has_selection());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_right_atraviesa_lineas() {
|
||||
let b = buf();
|
||||
let mut c = Cursor::at(0, 4); // fin de "hola"
|
||||
c.move_right(&b, false);
|
||||
assert_eq!(c.caret, Pos::new(1, 0)); // inicio de "mundo"
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_left_retrocede_a_linea_anterior() {
|
||||
let b = buf();
|
||||
let mut c = Cursor::at(1, 0);
|
||||
c.move_left(&b, false);
|
||||
assert_eq!(c.caret, Pos::new(0, 4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_up_preserva_desired_col() {
|
||||
let b = Buffer::from_str("abcdefgh\nxy\nlmnop");
|
||||
let mut c = Cursor::at(0, 7);
|
||||
c.move_down(&b, false);
|
||||
// "xy" sólo tiene 2 chars; el cursor se pega a col=2
|
||||
assert_eq!(c.caret, Pos::new(1, 2));
|
||||
// pero al bajar de nuevo, el desired (7) reanima.
|
||||
c.move_down(&b, false);
|
||||
assert_eq!(c.caret, Pos::new(2, 5)); // "lmnop" tiene 5
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_arrow_inicia_seleccion() {
|
||||
let b = buf();
|
||||
let mut c = Cursor::at(0, 0);
|
||||
c.move_right(&b, true);
|
||||
c.move_right(&b, true);
|
||||
assert!(c.has_selection());
|
||||
let (s, e) = c.selection_range(&b);
|
||||
assert_eq!((s, e), (0, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrow_sin_shift_colapsa() {
|
||||
let b = buf();
|
||||
let mut c = Cursor::at(0, 0);
|
||||
c.move_right(&b, true);
|
||||
c.move_right(&b, true);
|
||||
c.move_right(&b, false);
|
||||
assert!(!c.has_selection());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn home_end_son_locales_a_la_linea() {
|
||||
let b = buf();
|
||||
let mut c = Cursor::at(1, 2);
|
||||
c.move_home(&b, false);
|
||||
assert_eq!(c.caret, Pos::new(1, 0));
|
||||
c.move_end(&b, false);
|
||||
assert_eq!(c.caret, Pos::new(1, 5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doc_start_y_end() {
|
||||
let b = buf();
|
||||
let mut c = Cursor::at(1, 2);
|
||||
c.move_doc_end(&b, false);
|
||||
assert_eq!(c.caret, Pos::new(2, 3));
|
||||
c.move_doc_start(&b, false);
|
||||
assert_eq!(c.caret, Pos::ORIGIN);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
//! Diagnósticos del editor — espejo minimal del shape de `lsp-types`
|
||||
//! sin depender del crate. Pensado para que un client LSP (rust-analyzer,
|
||||
//! pylsp, etc.) lo poble desde fuera; el render del editor los pinta
|
||||
//! como subrayado bajo el rango.
|
||||
//!
|
||||
//! El client real vive aparte (proceso + JSON-RPC) — este módulo sólo
|
||||
//! define el shape de los datos y el helper para renderizarlos.
|
||||
|
||||
use crate::cursor::Pos;
|
||||
|
||||
/// Severidad — mismos valores y orden que en LSP (1 = Error es el más alto).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Severity {
|
||||
Error,
|
||||
Warning,
|
||||
Information,
|
||||
Hint,
|
||||
}
|
||||
|
||||
/// Rango cerrado de un diagnostic. `end` exclusivo en `col`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct DiagnosticRange {
|
||||
pub start: Pos,
|
||||
pub end: Pos,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Diagnostic {
|
||||
pub range: DiagnosticRange,
|
||||
pub severity: Severity,
|
||||
/// Mensaje humano corto — el render lo trunca para mostrar al hover/
|
||||
/// en una mini popup futura. En esta versión solo se usa el rango.
|
||||
pub message: String,
|
||||
/// Source del diagnostic — "rust-analyzer", "pylsp", "clippy", etc.
|
||||
/// `None` si no se conoce.
|
||||
pub source: Option<String>,
|
||||
}
|
||||
|
||||
impl Diagnostic {
|
||||
pub fn error(line_start: usize, col_start: usize, line_end: usize, col_end: usize, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
range: DiagnosticRange {
|
||||
start: Pos::new(line_start, col_start),
|
||||
end: Pos::new(line_end, col_end),
|
||||
},
|
||||
severity: Severity::Error,
|
||||
message: message.into(),
|
||||
source: None,
|
||||
}
|
||||
}
|
||||
pub fn warning(line_start: usize, col_start: usize, line_end: usize, col_end: usize, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
range: DiagnosticRange {
|
||||
start: Pos::new(line_start, col_start),
|
||||
end: Pos::new(line_end, col_end),
|
||||
},
|
||||
severity: Severity::Warning,
|
||||
message: message.into(),
|
||||
source: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn constructors_funcionan() {
|
||||
let e = Diagnostic::error(1, 2, 1, 5, "boom");
|
||||
assert_eq!(e.severity, Severity::Error);
|
||||
assert_eq!(e.range.start, Pos::new(1, 2));
|
||||
assert_eq!(e.range.end, Pos::new(1, 5));
|
||||
|
||||
let w = Diagnostic::warning(0, 0, 0, 10, "ojo");
|
||||
assert_eq!(w.severity, Severity::Warning);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
//! Búsqueda en el buffer. PMV: case-insensitive opcional, sin regex,
|
||||
//! sin replace. La UI del prompt vive en el caller (típicamente una
|
||||
//! barra arriba del editor); este módulo sólo provee:
|
||||
//!
|
||||
//! - [`FindState`] con el query actual + dirección + flag case-sensitive.
|
||||
//! - [`find_next`] / [`find_prev`] que devuelven la próxima/anterior
|
||||
//! match desde el caret del editor.
|
||||
//! - [`all_matches`] para que el render resalte cada ocurrencia.
|
||||
|
||||
use crate::buffer::Buffer;
|
||||
use crate::cursor::{Cursor, Pos};
|
||||
|
||||
/// Configuración de búsqueda del editor.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct FindState {
|
||||
pub query: String,
|
||||
pub case_sensitive: bool,
|
||||
}
|
||||
|
||||
impl FindState {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
pub fn with_query(query: impl Into<String>) -> Self {
|
||||
Self { query: query.into(), case_sensitive: false }
|
||||
}
|
||||
pub fn is_active(&self) -> bool {
|
||||
!self.query.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// Devuelve todas las ocurrencias del query en el buffer como
|
||||
/// `(start_offset, end_offset)` en char offsets. Vacío si query vacío.
|
||||
pub fn all_matches(buf: &Buffer, find: &FindState) -> Vec<(usize, usize)> {
|
||||
if find.query.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let hay = buf.text();
|
||||
let (hay_search, needle_search) = if find.case_sensitive {
|
||||
(hay.clone(), find.query.clone())
|
||||
} else {
|
||||
(hay.to_lowercase(), find.query.to_lowercase())
|
||||
};
|
||||
|
||||
// Buscamos en bytes; convertimos a char_offsets al devolver.
|
||||
let mut out: Vec<(usize, usize)> = Vec::new();
|
||||
let mut byte_start = 0;
|
||||
while let Some(pos) = hay_search[byte_start..].find(&needle_search) {
|
||||
let byte_match = byte_start + pos;
|
||||
let char_start = hay[..byte_match].chars().count();
|
||||
let char_end = char_start + find.query.chars().count();
|
||||
out.push((char_start, char_end));
|
||||
byte_start = byte_match + needle_search.len().max(1);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Encuentra la próxima ocurrencia con `start >= caret_off` (la match
|
||||
/// **en** el caret cuenta, no la saltea). Para avanzar a la siguiente
|
||||
/// real, el caller mueve el caret al `end` de la match anterior y
|
||||
/// vuelve a llamar. Wrap-around al fin del buffer → primera match.
|
||||
pub fn find_next(buf: &Buffer, find: &FindState, cursor: &Cursor) -> Option<(Pos, Pos)> {
|
||||
let matches = all_matches(buf, find);
|
||||
if matches.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let caret_off = buf.pos_to_offset(cursor.caret.line, cursor.caret.col);
|
||||
let next = matches
|
||||
.iter()
|
||||
.find(|(s, _)| *s >= caret_off)
|
||||
.copied()
|
||||
.or_else(|| matches.first().copied())?;
|
||||
Some(positions_of(buf, next))
|
||||
}
|
||||
|
||||
/// Como [`find_next`] pero en reverso.
|
||||
pub fn find_prev(buf: &Buffer, find: &FindState, cursor: &Cursor) -> Option<(Pos, Pos)> {
|
||||
let matches = all_matches(buf, find);
|
||||
if matches.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let caret_off = buf.pos_to_offset(cursor.caret.line, cursor.caret.col);
|
||||
let prev = matches
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|(_, e)| *e < caret_off)
|
||||
.copied()
|
||||
.or_else(|| matches.last().copied())?;
|
||||
Some(positions_of(buf, prev))
|
||||
}
|
||||
|
||||
fn positions_of(buf: &Buffer, (start, end): (usize, usize)) -> (Pos, Pos) {
|
||||
let (sl, sc) = buf.offset_to_pos(start);
|
||||
let (el, ec) = buf.offset_to_pos(end);
|
||||
(Pos::new(sl, sc), Pos::new(el, ec))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn all_matches_vacio_devuelve_vacio() {
|
||||
let b = Buffer::from_str("hola hola");
|
||||
let f = FindState::new();
|
||||
assert!(all_matches(&b, &f).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_matches_encuentra_todas() {
|
||||
let b = Buffer::from_str("ab cd ab ef ab");
|
||||
let f = FindState::with_query("ab");
|
||||
let m = all_matches(&b, &f);
|
||||
assert_eq!(m, vec![(0, 2), (6, 8), (12, 14)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn case_insensitive_por_default() {
|
||||
let b = Buffer::from_str("Hola HOLA hola");
|
||||
let f = FindState::with_query("hola");
|
||||
assert_eq!(all_matches(&b, &f).len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn case_sensitive_filtra() {
|
||||
let b = Buffer::from_str("Hola HOLA hola");
|
||||
let f = FindState { query: "hola".into(), case_sensitive: true };
|
||||
assert_eq!(all_matches(&b, &f).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_next_wrap_al_final() {
|
||||
let b = Buffer::from_str("ab cd ab");
|
||||
let f = FindState::with_query("ab");
|
||||
let c = Cursor::at(0, 8); // al final
|
||||
let (a, _) = find_next(&b, &f, &c).unwrap();
|
||||
assert_eq!(a, Pos::new(0, 0)); // wrap al primero
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_prev_wrap_al_principio() {
|
||||
let b = Buffer::from_str("ab cd ab");
|
||||
let f = FindState::with_query("ab");
|
||||
let c = Cursor::at(0, 0);
|
||||
let (a, _) = find_prev(&b, &f, &c).unwrap();
|
||||
assert_eq!(a, Pos::new(0, 6)); // wrap al último
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_next_devuelve_match_en_el_caret() {
|
||||
let b = Buffer::from_str("ab ab ab");
|
||||
let f = FindState::with_query("ab");
|
||||
let c = Cursor::at(0, 0);
|
||||
let (a, _) = find_next(&b, &f, &c).unwrap();
|
||||
assert_eq!(a, Pos::new(0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_next_avanza_si_caret_va_al_fin_de_match_anterior() {
|
||||
let b = Buffer::from_str("ab ab ab");
|
||||
let f = FindState::with_query("ab");
|
||||
let mut c = Cursor::at(0, 0);
|
||||
let (_, end1) = find_next(&b, &f, &c).unwrap();
|
||||
c.caret = end1; // (0, 2) — fin de la primera
|
||||
let (a2, _) = find_next(&b, &f, &c).unwrap();
|
||||
assert_eq!(a2, Pos::new(0, 3));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,590 @@
|
||||
//! Syntax highlighting. Cada `Language` produce una `Vec<LineSpans>`:
|
||||
//! por línea, una secuencia ordenada de `(start_col, end_col, TokenKind)`
|
||||
//! que cubre toda la línea. El renderer pinta cada span con el color
|
||||
//! que el [`SyntaxPalette`] mapea desde el `TokenKind`.
|
||||
//!
|
||||
//! - **Rust / Python**: tree-sitter parseando el buffer entero (ineficiente
|
||||
//! pero adecuado para celdas de notebook ≤ ~1k LOC). Las queries se
|
||||
//! compilan una vez por `Language`.
|
||||
//! - **WAT**: tokenizer en Rust puro (LISP-like: paren, `$`-prefijo,
|
||||
//! strings, números, keywords típicos del subset MVP).
|
||||
//! - **Plain**: un solo span por línea con `TokenKind::Other`.
|
||||
|
||||
use peniko::Color;
|
||||
|
||||
/// Lenguajes soportados — la matriz se extiende sumando un variant +
|
||||
/// una rama en [`Highlighter::tokenize_line`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum Language {
|
||||
Plain,
|
||||
Rust,
|
||||
Python,
|
||||
Wat,
|
||||
}
|
||||
|
||||
impl Language {
|
||||
/// Heurística: derivar el `Language` del `language` del `CellKind`.
|
||||
pub fn from_cell_language(s: &str) -> Self {
|
||||
match s.to_ascii_lowercase().as_str() {
|
||||
"rust" | "rs" => Language::Rust,
|
||||
"python" | "py" => Language::Python,
|
||||
"wasm" | "wat" => Language::Wat,
|
||||
_ => Language::Plain,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Categorías de token — lo suficientemente granular para colores
|
||||
/// distintos sin saturar el theme.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TokenKind {
|
||||
Keyword,
|
||||
Type,
|
||||
Function,
|
||||
String,
|
||||
Number,
|
||||
Comment,
|
||||
Operator,
|
||||
Punctuation,
|
||||
Identifier,
|
||||
Other,
|
||||
}
|
||||
|
||||
/// Un span dentro de una línea: `[start_col..end_col)` de la línea,
|
||||
/// más su categoría.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Span {
|
||||
pub start_col: usize,
|
||||
pub end_col: usize,
|
||||
pub kind: TokenKind,
|
||||
}
|
||||
|
||||
/// Paleta de colores por categoría — el theme la deriva.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct SyntaxPalette {
|
||||
pub keyword: Color,
|
||||
pub typ: Color,
|
||||
pub function: Color,
|
||||
pub string: Color,
|
||||
pub number: Color,
|
||||
pub comment: Color,
|
||||
pub operator: Color,
|
||||
pub punctuation: Color,
|
||||
pub identifier: Color,
|
||||
pub other: Color,
|
||||
}
|
||||
|
||||
impl SyntaxPalette {
|
||||
pub fn color(&self, k: TokenKind) -> Color {
|
||||
match k {
|
||||
TokenKind::Keyword => self.keyword,
|
||||
TokenKind::Type => self.typ,
|
||||
TokenKind::Function => self.function,
|
||||
TokenKind::String => self.string,
|
||||
TokenKind::Number => self.number,
|
||||
TokenKind::Comment => self.comment,
|
||||
TokenKind::Operator => self.operator,
|
||||
TokenKind::Punctuation => self.punctuation,
|
||||
TokenKind::Identifier => self.identifier,
|
||||
TokenKind::Other => self.other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// El constructor `dark_default(theme)` — única pieza que dependía de
|
||||
// `llimphi_theme` — vive ahora en `llimphi-widget-text-editor`
|
||||
// (`syntax_palette_dark`), para que este núcleo no arrastre el stack de
|
||||
// render. Aquí queda sólo el modelo de color puro (peniko::Color).
|
||||
|
||||
// Pool thread-local de parsers tree-sitter. Reconstruir el parser
|
||||
// (con `set_language`) es caro; reusarlo entre highlights del mismo
|
||||
// lenguaje es un ahorro grande. `tree_sitter::Parser` no es Send/
|
||||
// Sync ni Clone, así que vive en thread-local — un parser por
|
||||
// lenguaje por thread.
|
||||
thread_local! {
|
||||
static PARSER_POOL: std::cell::RefCell<std::collections::HashMap<Language, tree_sitter::Parser>>
|
||||
= std::cell::RefCell::new(std::collections::HashMap::new());
|
||||
/// Cache del último árbol parseado por lenguaje. Se pasa como hint
|
||||
/// al siguiente `parse(source, Some(&old_tree))`. El "verdadero
|
||||
/// incremental" (aplicar `InputEdit`s al tree antes de reparsear)
|
||||
/// ya está cableado: `EditorState` acumula los edits por delta y
|
||||
/// llama a [`apply_pending_edits`] antes de cada highlight, de modo
|
||||
/// que tree-sitter sólo reconstruye los subtrees afectados.
|
||||
static TREE_CACHE: std::cell::RefCell<std::collections::HashMap<Language, tree_sitter::Tree>>
|
||||
= std::cell::RefCell::new(std::collections::HashMap::new());
|
||||
}
|
||||
|
||||
/// Invalida el árbol cached para `language` — el caller lo invoca al
|
||||
/// hacer `set_text` o cambios masivos donde el hint puede confundir
|
||||
/// más que ayudar. No es estrictamente necesario, pero es defensivo.
|
||||
pub fn invalidate_tree_cache(language: Language) {
|
||||
TREE_CACHE.with(|c| {
|
||||
c.borrow_mut().remove(&language);
|
||||
});
|
||||
}
|
||||
|
||||
/// Aplica una lista de `InputEdit` al tree cached del `language`.
|
||||
/// Llamarlo ANTES de `parse(source, Some(&old_tree))` activa el modo
|
||||
/// incremental real de tree-sitter — solo reconstruye los subtrees
|
||||
/// afectados por las edits.
|
||||
pub fn apply_pending_edits(language: Language, edits: &[tree_sitter::InputEdit]) {
|
||||
if edits.is_empty() {
|
||||
return;
|
||||
}
|
||||
TREE_CACHE.with(|c| {
|
||||
let mut c = c.borrow_mut();
|
||||
if let Some(tree) = c.get_mut(&language) {
|
||||
for e in edits {
|
||||
tree.edit(e);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Highlighter — fina capa sin estado mutable propio. La parser real
|
||||
/// vive en el pool thread-local.
|
||||
pub struct Highlighter {
|
||||
language: Language,
|
||||
}
|
||||
|
||||
impl Highlighter {
|
||||
pub fn new(language: Language) -> Self {
|
||||
Self { language }
|
||||
}
|
||||
|
||||
pub fn language(&self) -> Language {
|
||||
self.language
|
||||
}
|
||||
|
||||
pub fn set_language(&mut self, language: Language) {
|
||||
self.language = language;
|
||||
}
|
||||
|
||||
/// Tokeniza el `source` entero y devuelve los spans por línea.
|
||||
/// `result.len() == source.lines().count().max(1)`.
|
||||
pub fn highlight(&mut self, source: &str) -> Vec<Vec<Span>> {
|
||||
match self.language {
|
||||
Language::Plain => plain_lines(source),
|
||||
Language::Wat => highlight_wat(source),
|
||||
Language::Rust => self.highlight_treesitter(source, rust_kind),
|
||||
Language::Python => self.highlight_treesitter(source, python_kind),
|
||||
}
|
||||
}
|
||||
|
||||
fn highlight_treesitter(
|
||||
&mut self,
|
||||
source: &str,
|
||||
kind_of: fn(&str) -> Option<TokenKind>,
|
||||
) -> Vec<Vec<Span>> {
|
||||
let language = self.language;
|
||||
// Parsea con hint del tree previo si lo hay. tree-sitter puede
|
||||
// reusar subtrees por hash incluso sin InputEdits aplicados.
|
||||
let tree_opt = PARSER_POOL.with(|pool| {
|
||||
let mut pool = pool.borrow_mut();
|
||||
let parser = pool.entry(language).or_insert_with(|| {
|
||||
make_ts_parser(language).unwrap_or_else(tree_sitter::Parser::new)
|
||||
});
|
||||
let old = TREE_CACHE.with(|c| c.borrow().get(&language).cloned());
|
||||
parser.parse(source, old.as_ref())
|
||||
});
|
||||
let Some(tree) = tree_opt else {
|
||||
return plain_lines(source);
|
||||
};
|
||||
// Guarda el nuevo árbol para la próxima invocación.
|
||||
TREE_CACHE.with(|c| {
|
||||
c.borrow_mut().insert(language, tree.clone());
|
||||
});
|
||||
|
||||
// Por línea: recopilamos spans de los nodos *named* tipados que
|
||||
// matchean kind_of. Luego rellenamos los huecos con `Other`.
|
||||
let line_count = source.lines().count().max(1)
|
||||
+ (if source.ends_with('\n') { 1 } else { 0 });
|
||||
let mut per_line: Vec<Vec<Span>> = vec![Vec::new(); line_count.max(1)];
|
||||
|
||||
let mut stack: Vec<tree_sitter::Node> = vec![tree.root_node()];
|
||||
while let Some(node) = stack.pop() {
|
||||
if node.child_count() == 0 {
|
||||
// hoja: tomamos el tipo del nodo (token).
|
||||
let kind = node.kind();
|
||||
if let Some(tk) = kind_of(kind) {
|
||||
let start = node.start_position();
|
||||
let end = node.end_position();
|
||||
// Sólo manejamos tokens single-line (los multi-line
|
||||
// como block strings se splitean por línea).
|
||||
if start.row == end.row {
|
||||
if let Some(line) = per_line.get_mut(start.row) {
|
||||
line.push(Span {
|
||||
start_col: start.column,
|
||||
end_col: end.column,
|
||||
kind: tk,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Multi-line: marca cada línea entera como ese kind.
|
||||
// Aproximación; suficiente para strings multi-línea.
|
||||
for row in start.row..=end.row {
|
||||
if let Some(line) = per_line.get_mut(row) {
|
||||
let line_text =
|
||||
source.lines().nth(row).unwrap_or("");
|
||||
let s = if row == start.row { start.column } else { 0 };
|
||||
let e =
|
||||
if row == end.row { end.column } else { line_text.chars().count() };
|
||||
line.push(Span { start_col: s, end_col: e, kind: tk });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for i in (0..node.child_count()).rev() {
|
||||
if let Some(c) = node.child(i) {
|
||||
stack.push(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Por cada línea: ordena, fusiona overlapping, rellena huecos.
|
||||
let mut result: Vec<Vec<Span>> = Vec::with_capacity(per_line.len());
|
||||
for (row, mut spans) in per_line.into_iter().enumerate() {
|
||||
let line_text = source.lines().nth(row).unwrap_or("");
|
||||
spans.sort_by_key(|s| s.start_col);
|
||||
result.push(fill_gaps(spans, line_text.chars().count()));
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
fn make_ts_parser(language: Language) -> Option<tree_sitter::Parser> {
|
||||
let mut parser = tree_sitter::Parser::new();
|
||||
let lang: tree_sitter::Language = match language {
|
||||
Language::Rust => tree_sitter_rust::LANGUAGE.into(),
|
||||
Language::Python => tree_sitter_python::LANGUAGE.into(),
|
||||
_ => return None,
|
||||
};
|
||||
parser.set_language(&lang).ok()?;
|
||||
Some(parser)
|
||||
}
|
||||
|
||||
/// Mapeo de tree-sitter node `kind` → TokenKind para Rust.
|
||||
fn rust_kind(kind: &str) -> Option<TokenKind> {
|
||||
// Lista deliberadamente acotada al subset común; nodos no listados
|
||||
// caen como Identifier/Other vía fill_gaps.
|
||||
match kind {
|
||||
// Keywords
|
||||
"fn" | "let" | "mut" | "const" | "static" | "if" | "else" | "match"
|
||||
| "for" | "while" | "loop" | "break" | "continue" | "return" | "use"
|
||||
| "mod" | "pub" | "impl" | "trait" | "struct" | "enum" | "type"
|
||||
| "where" | "as" | "in" | "ref" | "move" | "self" | "Self" | "crate"
|
||||
| "super" | "async" | "await" | "dyn" | "unsafe" | "extern" => {
|
||||
Some(TokenKind::Keyword)
|
||||
}
|
||||
// Tipos primitivos
|
||||
"primitive_type" => Some(TokenKind::Type),
|
||||
// Literales
|
||||
"string_literal" | "raw_string_literal" | "char_literal" | "string_content" => {
|
||||
Some(TokenKind::String)
|
||||
}
|
||||
"integer_literal" | "float_literal" | "boolean_literal" => Some(TokenKind::Number),
|
||||
// Comentarios
|
||||
"line_comment" | "block_comment" => Some(TokenKind::Comment),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Mapeo para Python.
|
||||
fn python_kind(kind: &str) -> Option<TokenKind> {
|
||||
match kind {
|
||||
"def" | "class" | "if" | "elif" | "else" | "for" | "while" | "return"
|
||||
| "import" | "from" | "as" | "in" | "is" | "not" | "and" | "or"
|
||||
| "with" | "try" | "except" | "finally" | "raise" | "yield" | "pass"
|
||||
| "break" | "continue" | "global" | "nonlocal" | "lambda" | "True"
|
||||
| "False" | "None" | "async" | "await" => Some(TokenKind::Keyword),
|
||||
"string" | "string_start" | "string_content" | "string_end" => Some(TokenKind::String),
|
||||
"integer" | "float" | "true" | "false" | "none" => Some(TokenKind::Number),
|
||||
"comment" => Some(TokenKind::Comment),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// WAT — tokenizer en Rust puro (sin tree-sitter).
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
fn highlight_wat(source: &str) -> Vec<Vec<Span>> {
|
||||
let mut out: Vec<Vec<Span>> = Vec::new();
|
||||
for line in iterate_lines(source) {
|
||||
out.push(tokenize_wat_line(line));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn iterate_lines(source: &str) -> Vec<&str> {
|
||||
let mut out: Vec<&str> = source.lines().collect();
|
||||
if source.ends_with('\n') || source.is_empty() {
|
||||
out.push("");
|
||||
}
|
||||
if out.is_empty() {
|
||||
out.push("");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn tokenize_wat_line(line: &str) -> Vec<Span> {
|
||||
let mut out: Vec<Span> = Vec::new();
|
||||
let chars: Vec<char> = line.chars().collect();
|
||||
let len = chars.len();
|
||||
let mut i = 0usize;
|
||||
|
||||
while i < len {
|
||||
let c = chars[i];
|
||||
|
||||
if c.is_whitespace() {
|
||||
let start = i;
|
||||
while i < len && chars[i].is_whitespace() {
|
||||
i += 1;
|
||||
}
|
||||
out.push(Span { start_col: start, end_col: i, kind: TokenKind::Other });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Comentario línea `;; ...`
|
||||
if c == ';' && i + 1 < len && chars[i + 1] == ';' {
|
||||
out.push(Span { start_col: i, end_col: len, kind: TokenKind::Comment });
|
||||
break;
|
||||
}
|
||||
|
||||
// Paren
|
||||
if c == '(' || c == ')' {
|
||||
out.push(Span { start_col: i, end_col: i + 1, kind: TokenKind::Punctuation });
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// String "..."
|
||||
if c == '"' {
|
||||
let start = i;
|
||||
i += 1;
|
||||
while i < len {
|
||||
let cc = chars[i];
|
||||
if cc == '\\' && i + 1 < len {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if cc == '"' {
|
||||
i += 1;
|
||||
break;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
out.push(Span { start_col: start, end_col: i, kind: TokenKind::String });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Identificador `$nombre`
|
||||
if c == '$' {
|
||||
let start = i;
|
||||
i += 1;
|
||||
while i < len && is_wat_ident_char(chars[i]) {
|
||||
i += 1;
|
||||
}
|
||||
out.push(Span { start_col: start, end_col: i, kind: TokenKind::Identifier });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Número (entero/hex/float — simplificado: empieza con dígito o -dígito).
|
||||
if c.is_ascii_digit() || (c == '-' && i + 1 < len && chars[i + 1].is_ascii_digit()) {
|
||||
let start = i;
|
||||
if c == '-' {
|
||||
i += 1;
|
||||
}
|
||||
while i < len {
|
||||
let cc = chars[i];
|
||||
if cc.is_ascii_digit() || cc == '.' || cc == 'x' || cc.is_ascii_hexdigit() {
|
||||
i += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
out.push(Span { start_col: start, end_col: i, kind: TokenKind::Number });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Word: keyword o identificador
|
||||
if is_wat_word_start(c) {
|
||||
let start = i;
|
||||
while i < len && is_wat_ident_char(chars[i]) {
|
||||
i += 1;
|
||||
}
|
||||
let word: String = chars[start..i].iter().collect();
|
||||
let kind = wat_word_kind(&word);
|
||||
out.push(Span { start_col: start, end_col: i, kind });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Otros (operadores como `.`)
|
||||
out.push(Span { start_col: i, end_col: i + 1, kind: TokenKind::Operator });
|
||||
i += 1;
|
||||
}
|
||||
|
||||
fill_gaps(out, len)
|
||||
}
|
||||
|
||||
fn is_wat_word_start(c: char) -> bool {
|
||||
c.is_ascii_alphabetic() || c == '_'
|
||||
}
|
||||
fn is_wat_ident_char(c: char) -> bool {
|
||||
c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '!' | '#' | '$' | '%' | '&' | '\'' | '*' | '+' | '-' | '/' | ':' | '<' | '=' | '>' | '?' | '@' | '\\' | '^' | '`' | '|' | '~')
|
||||
}
|
||||
|
||||
fn wat_word_kind(w: &str) -> TokenKind {
|
||||
const KEYWORDS: &[&str] = &[
|
||||
"module", "func", "param", "result", "local", "import", "export",
|
||||
"memory", "data", "table", "elem", "type", "global", "start", "block",
|
||||
"loop", "if", "then", "else", "end", "br", "br_if", "br_table",
|
||||
"return", "call", "call_indirect",
|
||||
];
|
||||
const TYPES: &[&str] = &["i32", "i64", "f32", "f64", "v128", "funcref", "externref", "anyref"];
|
||||
|
||||
if KEYWORDS.contains(&w) {
|
||||
TokenKind::Keyword
|
||||
} else if TYPES.contains(&w) {
|
||||
TokenKind::Type
|
||||
} else if w.contains('.') {
|
||||
// Instrucciones tipo `i32.const`, `local.get`, etc.
|
||||
TokenKind::Function
|
||||
} else {
|
||||
TokenKind::Identifier
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Plain + utilities
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
fn plain_lines(source: &str) -> Vec<Vec<Span>> {
|
||||
let mut out: Vec<Vec<Span>> = Vec::new();
|
||||
for line in iterate_lines(source) {
|
||||
let len = line.chars().count();
|
||||
out.push(vec![Span { start_col: 0, end_col: len, kind: TokenKind::Other }]);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Rellena los huecos entre spans con `Other` para cubrir `[0..line_len)`.
|
||||
fn fill_gaps(spans: Vec<Span>, line_len: usize) -> Vec<Span> {
|
||||
if spans.is_empty() {
|
||||
return vec![Span { start_col: 0, end_col: line_len, kind: TokenKind::Other }];
|
||||
}
|
||||
let mut out: Vec<Span> = Vec::with_capacity(spans.len() * 2);
|
||||
let mut cursor = 0usize;
|
||||
for s in spans {
|
||||
if s.start_col > cursor {
|
||||
out.push(Span { start_col: cursor, end_col: s.start_col, kind: TokenKind::Other });
|
||||
}
|
||||
// Clampea overlaps con el anterior.
|
||||
if s.end_col > cursor {
|
||||
let start_col = s.start_col.max(cursor);
|
||||
out.push(Span { start_col, end_col: s.end_col, kind: s.kind });
|
||||
cursor = s.end_col;
|
||||
}
|
||||
}
|
||||
if cursor < line_len {
|
||||
out.push(Span { start_col: cursor, end_col: line_len, kind: TokenKind::Other });
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn plain_devuelve_un_span_por_linea() {
|
||||
let mut h = Highlighter::new(Language::Plain);
|
||||
let r = h.highlight("hola\nmundo");
|
||||
assert_eq!(r.len(), 2);
|
||||
assert_eq!(r[0].len(), 1);
|
||||
assert_eq!(r[0][0].kind, TokenKind::Other);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wat_paren_es_punctuation() {
|
||||
let mut h = Highlighter::new(Language::Wat);
|
||||
let r = h.highlight("(module)");
|
||||
let line = &r[0];
|
||||
let paren = line.iter().find(|s| s.kind == TokenKind::Punctuation).unwrap();
|
||||
assert_eq!(paren.start_col, 0);
|
||||
assert_eq!(paren.end_col, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wat_keyword_module_clasifica_como_keyword() {
|
||||
let mut h = Highlighter::new(Language::Wat);
|
||||
let r = h.highlight("(module)");
|
||||
let kw = r[0].iter().find(|s| s.kind == TokenKind::Keyword).unwrap();
|
||||
assert_eq!(kw.start_col, 1);
|
||||
assert_eq!(kw.end_col, 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wat_tipo_i32_es_type() {
|
||||
let mut h = Highlighter::new(Language::Wat);
|
||||
let r = h.highlight("(result i32)");
|
||||
assert!(r[0].iter().any(|s| s.kind == TokenKind::Type));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wat_string_y_comment() {
|
||||
let mut h = Highlighter::new(Language::Wat);
|
||||
let r = h.highlight(r#"(data "hola") ;; comentario"#);
|
||||
assert!(r[0].iter().any(|s| s.kind == TokenKind::String));
|
||||
assert!(r[0].iter().any(|s| s.kind == TokenKind::Comment));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wat_instruction_dotted_es_function() {
|
||||
let mut h = Highlighter::new(Language::Wat);
|
||||
let r = h.highlight("i32.const 42");
|
||||
assert!(r[0].iter().any(|s| s.kind == TokenKind::Function));
|
||||
assert!(r[0].iter().any(|s| s.kind == TokenKind::Number));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rust_keyword_fn() {
|
||||
let mut h = Highlighter::new(Language::Rust);
|
||||
let r = h.highlight("fn main() {}");
|
||||
// El span de "fn" debe estar marcado como keyword.
|
||||
assert!(r[0].iter().any(|s| s.kind == TokenKind::Keyword));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn python_keyword_def() {
|
||||
let mut h = Highlighter::new(Language::Python);
|
||||
let r = h.highlight("def f():\n return 1");
|
||||
assert!(r[0].iter().any(|s| s.kind == TokenKind::Keyword));
|
||||
// "return" en la línea 2.
|
||||
assert!(r[1].iter().any(|s| s.kind == TokenKind::Keyword));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fill_gaps_rellena_y_clampea() {
|
||||
let spans = vec![
|
||||
Span { start_col: 2, end_col: 4, kind: TokenKind::Keyword },
|
||||
Span { start_col: 6, end_col: 9, kind: TokenKind::String },
|
||||
];
|
||||
let filled = fill_gaps(spans, 10);
|
||||
// [Other 0..2] [Keyword 2..4] [Other 4..6] [String 6..9] [Other 9..10]
|
||||
assert_eq!(filled.len(), 5);
|
||||
assert_eq!(filled[0].kind, TokenKind::Other);
|
||||
assert_eq!(filled[4].kind, TokenKind::Other);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_cell_language_mapea_aliases() {
|
||||
assert_eq!(Language::from_cell_language("rust"), Language::Rust);
|
||||
assert_eq!(Language::from_cell_language("rs"), Language::Rust);
|
||||
assert_eq!(Language::from_cell_language("py"), Language::Python);
|
||||
assert_eq!(Language::from_cell_language("wat"), Language::Wat);
|
||||
assert_eq!(Language::from_cell_language("desconocido"), Language::Plain);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
//! `llimphi-widget-text-editor-core` — núcleo agnóstico del editor de código.
|
||||
//!
|
||||
//! Capas finas y **puras** (sin IO, sin Llimphi, sin GPU) sobre [`ropey`]:
|
||||
//!
|
||||
//! - [`buffer`] — wrapper de `Rope` con conversiones (línea, col) ↔ char_offset.
|
||||
//! - [`cursor`] — `Cursor` + `Selection`; movimiento por char/word/line/page.
|
||||
//! - [`ops`] — operaciones puras de edición sobre `(Buffer, Cursor) → (Buffer, Cursor)`.
|
||||
//! - [`undo`] — pila reversible: cada operación se registra como `EditDelta`.
|
||||
//! - [`bracket`] — matching de paréntesis/llaves/corchetes.
|
||||
//! - [`find`] — búsqueda incremental sobre el buffer.
|
||||
//! - [`diagnostics`] — modelo de diagnósticos (errores/warnings) por rango.
|
||||
//! - [`clipboard`] — abstracción de portapapeles (mem/null) sin tocar el SO.
|
||||
//! - [`highlight`] — syntax highlighting con tree-sitter (Rust/Python/WAT/Plain).
|
||||
//!
|
||||
//! Único acoplamiento externo: [`peniko::Color`] en [`highlight::SyntaxPalette`]
|
||||
//! — un tipo de color, no el stack de render. Eso deja el núcleo reutilizable
|
||||
//! desde un TUI, una mini-REPL, un text-input single-line, un backend web, etc.
|
||||
//! La capa visual (state + view sobre Llimphi) vive en
|
||||
//! `llimphi-widget-text-editor`, que re-exporta todo este núcleo.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod bracket;
|
||||
pub mod buffer;
|
||||
pub mod clipboard;
|
||||
pub mod cursor;
|
||||
pub mod diagnostics;
|
||||
pub mod find;
|
||||
pub mod highlight;
|
||||
pub mod ops;
|
||||
pub mod undo;
|
||||
|
||||
pub use buffer::Buffer;
|
||||
pub use clipboard::{Clipboard, MemClipboard, NullClipboard};
|
||||
pub use cursor::{Cursor, Pos, Selection};
|
||||
pub use diagnostics::{Diagnostic, DiagnosticRange, Severity};
|
||||
pub use find::{all_matches, find_next, find_prev, FindState};
|
||||
pub use highlight::{Highlighter, Language, Span, SyntaxPalette, TokenKind};
|
||||
pub use ops::{indent_str, EditDelta};
|
||||
pub use undo::UndoStack;
|
||||
@@ -0,0 +1,384 @@
|
||||
//! Operaciones de edición. Cada una toma `&mut Buffer + &mut Cursor` y
|
||||
//! devuelve un [`EditDelta`] reversible que la pila de undo guarda.
|
||||
//!
|
||||
//! El delta es minimal: el rango `[start..end)` que se reemplazó + el
|
||||
//! texto que estaba antes + el texto nuevo. Aplicado en reversa,
|
||||
//! restaura el estado anterior exactamente.
|
||||
|
||||
use crate::buffer::Buffer;
|
||||
use crate::cursor::{Cursor, Pos};
|
||||
|
||||
/// Delta atómico de edición — útil para undo/redo y log de cambios.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct EditDelta {
|
||||
pub start: usize,
|
||||
pub removed: String,
|
||||
pub inserted: String,
|
||||
/// Caret antes de la operación (para restaurarlo en undo).
|
||||
pub cursor_before: Cursor,
|
||||
/// Caret después de la operación.
|
||||
pub cursor_after: Cursor,
|
||||
}
|
||||
|
||||
impl EditDelta {
|
||||
/// Aplica el delta a `(buf, cursor)`.
|
||||
pub fn apply(&self, buf: &mut Buffer, cursor: &mut Cursor) {
|
||||
let end = self.start + self.removed.chars().count();
|
||||
buf.delete(self.start, end);
|
||||
if !self.inserted.is_empty() {
|
||||
buf.insert(self.start, &self.inserted);
|
||||
}
|
||||
*cursor = self.cursor_after;
|
||||
}
|
||||
|
||||
/// Aplica el inverso (undo).
|
||||
pub fn undo(&self, buf: &mut Buffer, cursor: &mut Cursor) {
|
||||
let end = self.start + self.inserted.chars().count();
|
||||
buf.delete(self.start, end);
|
||||
if !self.removed.is_empty() {
|
||||
buf.insert(self.start, &self.removed);
|
||||
}
|
||||
*cursor = self.cursor_before;
|
||||
}
|
||||
}
|
||||
|
||||
/// Genera la string de indentación según la config.
|
||||
pub fn indent_str(tab_to_spaces: bool, indent_size: usize) -> String {
|
||||
if tab_to_spaces {
|
||||
" ".repeat(indent_size)
|
||||
} else {
|
||||
"\t".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Reemplaza la selección activa por `text`. Si no hay selección,
|
||||
/// inserta `text` en el caret. Devuelve el delta resultante.
|
||||
pub fn replace_selection(
|
||||
buf: &mut Buffer,
|
||||
cursor: &mut Cursor,
|
||||
text: &str,
|
||||
) -> EditDelta {
|
||||
let before = *cursor;
|
||||
let (start, end) = cursor.selection_range(buf);
|
||||
let removed = buf.slice(start, end);
|
||||
|
||||
if start != end {
|
||||
buf.delete(start, end);
|
||||
}
|
||||
if !text.is_empty() {
|
||||
buf.insert(start, text);
|
||||
}
|
||||
|
||||
let new_off = start + text.chars().count();
|
||||
let (line, col) = buf.offset_to_pos(new_off);
|
||||
cursor.caret = Pos::new(line, col);
|
||||
cursor.desired_col = col;
|
||||
cursor.anchor = None;
|
||||
|
||||
EditDelta {
|
||||
start,
|
||||
removed,
|
||||
inserted: text.to_string(),
|
||||
cursor_before: before,
|
||||
cursor_after: *cursor,
|
||||
}
|
||||
}
|
||||
|
||||
/// Borra hacia atrás (Backspace). Si hay selección, la borra; si no,
|
||||
/// borra el char antes del caret. Devuelve `None` si no había nada que
|
||||
/// borrar (cursor al inicio + sin selección).
|
||||
pub fn delete_backward(buf: &mut Buffer, cursor: &mut Cursor) -> Option<EditDelta> {
|
||||
if cursor.has_selection() {
|
||||
return Some(replace_selection(buf, cursor, ""));
|
||||
}
|
||||
let before = *cursor;
|
||||
let caret_off = buf.pos_to_offset(cursor.caret.line, cursor.caret.col);
|
||||
if caret_off == 0 {
|
||||
return None;
|
||||
}
|
||||
let removed = buf.slice(caret_off - 1, caret_off);
|
||||
buf.delete(caret_off - 1, caret_off);
|
||||
let (line, col) = buf.offset_to_pos(caret_off - 1);
|
||||
cursor.caret = Pos::new(line, col);
|
||||
cursor.desired_col = col;
|
||||
Some(EditDelta {
|
||||
start: caret_off - 1,
|
||||
removed,
|
||||
inserted: String::new(),
|
||||
cursor_before: before,
|
||||
cursor_after: *cursor,
|
||||
})
|
||||
}
|
||||
|
||||
/// Borra hacia adelante (Delete).
|
||||
pub fn delete_forward(buf: &mut Buffer, cursor: &mut Cursor) -> Option<EditDelta> {
|
||||
if cursor.has_selection() {
|
||||
return Some(replace_selection(buf, cursor, ""));
|
||||
}
|
||||
let before = *cursor;
|
||||
let caret_off = buf.pos_to_offset(cursor.caret.line, cursor.caret.col);
|
||||
if caret_off >= buf.len_chars() {
|
||||
return None;
|
||||
}
|
||||
let removed = buf.slice(caret_off, caret_off + 1);
|
||||
buf.delete(caret_off, caret_off + 1);
|
||||
Some(EditDelta {
|
||||
start: caret_off,
|
||||
removed,
|
||||
inserted: String::new(),
|
||||
cursor_before: before,
|
||||
cursor_after: *cursor,
|
||||
})
|
||||
}
|
||||
|
||||
/// Inserta un salto de línea con **indentación automática**: copia los
|
||||
/// whitespace iniciales del renglón actual al renglón nuevo.
|
||||
pub fn insert_newline_auto_indent(buf: &mut Buffer, cursor: &mut Cursor) -> EditDelta {
|
||||
let current_line = buf.line(cursor.caret.line);
|
||||
let indent: String = current_line
|
||||
.chars()
|
||||
.take_while(|c| *c == ' ' || *c == '\t')
|
||||
.collect();
|
||||
let text = format!("\n{indent}");
|
||||
replace_selection(buf, cursor, &text)
|
||||
}
|
||||
|
||||
/// Inserta un tab (o `indent_size` spaces según config). Si hay
|
||||
/// selección **multilínea**, indenta cada línea de la selección.
|
||||
pub fn indent_or_insert_tab(
|
||||
buf: &mut Buffer,
|
||||
cursor: &mut Cursor,
|
||||
tab_to_spaces: bool,
|
||||
indent_size: usize,
|
||||
) -> EditDelta {
|
||||
let indent = indent_str(tab_to_spaces, indent_size);
|
||||
|
||||
// Sin selección o selección en una sola línea → inserta indent.
|
||||
let multi_line = match cursor.selection() {
|
||||
Some(sel) => sel.anchor.line != sel.caret.line,
|
||||
None => false,
|
||||
};
|
||||
if !multi_line {
|
||||
return replace_selection(buf, cursor, &indent);
|
||||
}
|
||||
|
||||
// Selección multilínea: indenta cada línea afectada por el rango.
|
||||
let before = *cursor;
|
||||
let sel = cursor.selection().expect("multi_line implica selección");
|
||||
let first = sel.anchor.line.min(sel.caret.line);
|
||||
let last = sel.anchor.line.max(sel.caret.line);
|
||||
|
||||
let mut start_global = buf.pos_to_offset(first, 0);
|
||||
let removed = String::new();
|
||||
let mut inserted = String::new();
|
||||
for line in first..=last {
|
||||
let line_start = buf.pos_to_offset(line, 0);
|
||||
buf.insert(line_start, &indent);
|
||||
inserted.push_str(&indent);
|
||||
let _ = start_global; // (sin uso; se mantiene por simetría)
|
||||
start_global = buf.pos_to_offset(first, 0);
|
||||
}
|
||||
|
||||
// Mantenemos la selección extendida sobre las líneas indentadas.
|
||||
let n_added = indent.chars().count();
|
||||
let new_anchor = Pos::new(sel.anchor.line, sel.anchor.col + n_added);
|
||||
let new_caret = Pos::new(sel.caret.line, sel.caret.col + n_added);
|
||||
cursor.anchor = Some(new_anchor);
|
||||
cursor.caret = new_caret;
|
||||
cursor.desired_col = new_caret.col;
|
||||
|
||||
EditDelta {
|
||||
start: start_global,
|
||||
removed,
|
||||
inserted,
|
||||
cursor_before: before,
|
||||
cursor_after: *cursor,
|
||||
}
|
||||
}
|
||||
|
||||
/// Quita un nivel de indent del renglón actual (o de cada línea si hay
|
||||
/// selección multilínea). Devuelve `None` si nada cambió.
|
||||
pub fn dedent(
|
||||
buf: &mut Buffer,
|
||||
cursor: &mut Cursor,
|
||||
tab_to_spaces: bool,
|
||||
indent_size: usize,
|
||||
) -> Option<EditDelta> {
|
||||
let before = *cursor;
|
||||
let (first, last) = match cursor.selection() {
|
||||
Some(sel) => (
|
||||
sel.anchor.line.min(sel.caret.line),
|
||||
sel.anchor.line.max(sel.caret.line),
|
||||
),
|
||||
None => (cursor.caret.line, cursor.caret.line),
|
||||
};
|
||||
|
||||
let mut total_removed = 0usize;
|
||||
let mut removed_text = String::new();
|
||||
let start_offset = buf.pos_to_offset(first, 0);
|
||||
|
||||
for line in first..=last {
|
||||
let line_str = buf.line(line);
|
||||
let mut n = 0usize;
|
||||
let mut chars = line_str.chars();
|
||||
if tab_to_spaces {
|
||||
for _ in 0..indent_size {
|
||||
if chars.next() == Some(' ') {
|
||||
n += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else if chars.next() == Some('\t') {
|
||||
n = 1;
|
||||
}
|
||||
if n == 0 {
|
||||
continue;
|
||||
}
|
||||
let line_start = buf.pos_to_offset(line, 0);
|
||||
removed_text.push_str(&buf.slice(line_start, line_start + n));
|
||||
buf.delete(line_start, line_start + n);
|
||||
total_removed += n;
|
||||
}
|
||||
|
||||
if total_removed == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Cursor: clampea col al nuevo line_len.
|
||||
let caret_line = cursor.caret.line;
|
||||
let caret_col = cursor
|
||||
.caret
|
||||
.col
|
||||
.saturating_sub(if caret_line >= first && caret_line <= last {
|
||||
// Cuánto se removió de esta línea (varía); aproximamos al
|
||||
// common case de mismo n por línea. Si fuera distinto el
|
||||
// visual queda OK porque clampea.
|
||||
removed_text.chars().count() / (last - first + 1).max(1)
|
||||
} else {
|
||||
0
|
||||
});
|
||||
cursor.caret.col = caret_col.min(buf.line_len_chars(caret_line));
|
||||
cursor.desired_col = cursor.caret.col;
|
||||
|
||||
if let Some(anchor) = cursor.anchor.as_mut() {
|
||||
if anchor.line >= first && anchor.line <= last {
|
||||
anchor.col = anchor
|
||||
.col
|
||||
.saturating_sub(removed_text.chars().count() / (last - first + 1).max(1))
|
||||
.min(buf.line_len_chars(anchor.line));
|
||||
}
|
||||
}
|
||||
|
||||
Some(EditDelta {
|
||||
start: start_offset,
|
||||
removed: removed_text,
|
||||
inserted: String::new(),
|
||||
cursor_before: before,
|
||||
cursor_after: *cursor,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn replace_selection_sin_seleccion_inserta() {
|
||||
let mut b = Buffer::from_str("ab");
|
||||
let mut c = Cursor::at(0, 1);
|
||||
let d = replace_selection(&mut b, &mut c, "X");
|
||||
assert_eq!(b.text(), "aXb");
|
||||
assert_eq!(c.caret, Pos::new(0, 2));
|
||||
assert_eq!(d.removed, "");
|
||||
assert_eq!(d.inserted, "X");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_selection_con_seleccion_reemplaza() {
|
||||
let mut b = Buffer::from_str("hola mundo");
|
||||
let mut c = Cursor { caret: Pos::new(0, 9), anchor: Some(Pos::new(0, 5)), desired_col: 9 };
|
||||
replace_selection(&mut b, &mut c, "luna");
|
||||
assert_eq!(b.text(), "hola lunao");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backspace_borra_char() {
|
||||
let mut b = Buffer::from_str("hola");
|
||||
let mut c = Cursor::at(0, 4);
|
||||
delete_backward(&mut b, &mut c);
|
||||
assert_eq!(b.text(), "hol");
|
||||
assert_eq!(c.caret, Pos::new(0, 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backspace_en_inicio_no_hace_nada() {
|
||||
let mut b = Buffer::from_str("a");
|
||||
let mut c = Cursor::at(0, 0);
|
||||
assert!(delete_backward(&mut b, &mut c).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_forward_borra_char() {
|
||||
let mut b = Buffer::from_str("ab");
|
||||
let mut c = Cursor::at(0, 0);
|
||||
delete_forward(&mut b, &mut c);
|
||||
assert_eq!(b.text(), "b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newline_copia_indent_del_renglon_anterior() {
|
||||
let mut b = Buffer::from_str(" hola");
|
||||
let mut c = Cursor::at(0, 8);
|
||||
insert_newline_auto_indent(&mut b, &mut c);
|
||||
assert_eq!(b.text(), " hola\n ");
|
||||
assert_eq!(c.caret, Pos::new(1, 4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_inserta_spaces() {
|
||||
let mut b = Buffer::from_str("ab");
|
||||
let mut c = Cursor::at(0, 1);
|
||||
indent_or_insert_tab(&mut b, &mut c, true, 4);
|
||||
assert_eq!(b.text(), "a b");
|
||||
assert_eq!(c.caret, Pos::new(0, 5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_con_seleccion_multilinea_indenta_cada_linea() {
|
||||
let mut b = Buffer::from_str("a\nb\nc");
|
||||
let mut c = Cursor {
|
||||
anchor: Some(Pos::new(0, 0)),
|
||||
caret: Pos::new(2, 1),
|
||||
desired_col: 1,
|
||||
};
|
||||
indent_or_insert_tab(&mut b, &mut c, true, 2);
|
||||
assert_eq!(b.text(), " a\n b\n c");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dedent_quita_indent_del_renglon() {
|
||||
let mut b = Buffer::from_str(" hola");
|
||||
let mut c = Cursor::at(0, 8);
|
||||
dedent(&mut b, &mut c, true, 4);
|
||||
assert_eq!(b.text(), "hola");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dedent_sin_indent_devuelve_none() {
|
||||
let mut b = Buffer::from_str("hola");
|
||||
let mut c = Cursor::at(0, 0);
|
||||
assert!(dedent(&mut b, &mut c, true, 4).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delta_undo_restaura_estado() {
|
||||
let mut b = Buffer::from_str("hola");
|
||||
let mut c = Cursor::at(0, 4);
|
||||
let d = replace_selection(&mut b, &mut c, "!");
|
||||
assert_eq!(b.text(), "hola!");
|
||||
d.undo(&mut b, &mut c);
|
||||
assert_eq!(b.text(), "hola");
|
||||
assert_eq!(c.caret, Pos::new(0, 4));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
//! Pila de undo/redo basada en [`EditDelta`].
|
||||
//!
|
||||
//! API simple: `push(delta)` añade al historial y limpia el stack de
|
||||
//! redo; `undo`/`redo` aplican o reaplican deltas existentes. No
|
||||
//! coalesce inserciones consecutivas — cada keystroke es un delta;
|
||||
//! para una UX más fina, el llamador puede agrupar deltas relacionados
|
||||
//! (ej. cada secuencia de chars imprimibles hasta whitespace).
|
||||
|
||||
use crate::buffer::Buffer;
|
||||
use crate::cursor::Cursor;
|
||||
use crate::ops::EditDelta;
|
||||
|
||||
const DEFAULT_CAPACITY: usize = 256;
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct UndoStack {
|
||||
/// Deltas aplicados, en orden cronológico. El `Vec::last` es el
|
||||
/// próximo candidato a deshacer.
|
||||
done: Vec<EditDelta>,
|
||||
/// Deltas deshechos disponibles para redo (en orden inverso del
|
||||
/// `undo`: el último deshecho es el primero a rehacer).
|
||||
undone: Vec<EditDelta>,
|
||||
capacity: usize,
|
||||
}
|
||||
|
||||
impl UndoStack {
|
||||
pub fn new() -> Self {
|
||||
Self::with_capacity(DEFAULT_CAPACITY)
|
||||
}
|
||||
pub fn with_capacity(capacity: usize) -> Self {
|
||||
Self {
|
||||
done: Vec::with_capacity(capacity.min(64)),
|
||||
undone: Vec::new(),
|
||||
capacity,
|
||||
}
|
||||
}
|
||||
|
||||
/// Registra un delta. Limpia el stack de redo (la rama alternativa
|
||||
/// se pierde, como en todo editor estándar).
|
||||
pub fn push(&mut self, delta: EditDelta) {
|
||||
self.done.push(delta);
|
||||
self.undone.clear();
|
||||
if self.done.len() > self.capacity {
|
||||
// Truncamos por el extremo viejo.
|
||||
let drop = self.done.len() - self.capacity;
|
||||
self.done.drain(0..drop);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn can_undo(&self) -> bool {
|
||||
!self.done.is_empty()
|
||||
}
|
||||
pub fn can_redo(&self) -> bool {
|
||||
!self.undone.is_empty()
|
||||
}
|
||||
|
||||
pub fn undo(&mut self, buf: &mut Buffer, cursor: &mut Cursor) -> bool {
|
||||
let Some(delta) = self.done.pop() else {
|
||||
return false;
|
||||
};
|
||||
delta.undo(buf, cursor);
|
||||
self.undone.push(delta);
|
||||
true
|
||||
}
|
||||
|
||||
pub fn redo(&mut self, buf: &mut Buffer, cursor: &mut Cursor) -> bool {
|
||||
let Some(delta) = self.undone.pop() else {
|
||||
return false;
|
||||
};
|
||||
delta.apply(buf, cursor);
|
||||
self.done.push(delta);
|
||||
true
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.done.clear();
|
||||
self.undone.clear();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::ops::replace_selection;
|
||||
|
||||
#[test]
|
||||
fn undo_y_redo_son_simetricos() {
|
||||
let mut b = Buffer::from_str("a");
|
||||
let mut c = Cursor::at(0, 1);
|
||||
let mut st = UndoStack::new();
|
||||
|
||||
st.push(replace_selection(&mut b, &mut c, "b"));
|
||||
st.push(replace_selection(&mut b, &mut c, "c"));
|
||||
assert_eq!(b.text(), "abc");
|
||||
|
||||
assert!(st.undo(&mut b, &mut c));
|
||||
assert_eq!(b.text(), "ab");
|
||||
assert!(st.undo(&mut b, &mut c));
|
||||
assert_eq!(b.text(), "a");
|
||||
|
||||
assert!(st.redo(&mut b, &mut c));
|
||||
assert_eq!(b.text(), "ab");
|
||||
assert!(st.redo(&mut b, &mut c));
|
||||
assert_eq!(b.text(), "abc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_limpia_redo() {
|
||||
let mut b = Buffer::from_str("a");
|
||||
let mut c = Cursor::at(0, 1);
|
||||
let mut st = UndoStack::new();
|
||||
st.push(replace_selection(&mut b, &mut c, "b"));
|
||||
st.undo(&mut b, &mut c);
|
||||
assert!(st.can_redo());
|
||||
st.push(replace_selection(&mut b, &mut c, "X"));
|
||||
assert!(!st.can_redo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capacity_descartan_viejos() {
|
||||
let mut b = Buffer::from_str("");
|
||||
let mut c = Cursor::at(0, 0);
|
||||
let mut st = UndoStack::with_capacity(2);
|
||||
for ch in ["a", "b", "c"] {
|
||||
st.push(replace_selection(&mut b, &mut c, ch));
|
||||
}
|
||||
// Sólo deberían quedar los últimos 2 deltas; el undo del primero
|
||||
// (cuando ya no está) no debería hacer nada.
|
||||
st.undo(&mut b, &mut c);
|
||||
st.undo(&mut b, &mut c);
|
||||
assert!(!st.undo(&mut b, &mut c));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user