tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo (pin tawasuyu.git v0.3.0)
1//! Qué se puede plegar en un texto, y hasta dónde llega cada bloque.
2//!
3//! El criterio es **la indentación**, y una sola regla sin excepciones: una
4//! línea encabeza un bloque plegable si la que le sigue está más adentro, y el
5//! bloque llega hasta la última línea consecutiva que siga más adentro que la
6//! cabecera.
7//!
8//! Podría haber sido por llaves, o por el árbol de tree-sitter, o por los
9//! `document symbols` del LSP. La indentación gana por tres razones concretas:
10//!
11//! - **Funciona en todos los lenguajes del editor**, incluidos los de llaves —
12//! el cuerpo de un `fn { … }` está indentado igual que el de un `def:`— y en
13//! los que no tienen gramática acá (YAML, Markdown, un `.conf`).
14//! - **No depende de que haya un servidor de lenguaje andando.** Los `document
15//! symbols` sólo aparecen si `rust-analyzer` levantó, y plegar es de las cosas
16//! que uno espera que anden en un archivo cualquiera recién abierto.
17//! - **Es una regla sin criterio**: no hay que decidir si un `match` cuenta o si
18//! un `impl` de una línea vale. Lo que se ve indentado se pliega.
19//!
20//! Lo que la indentación **no** da es el pliegue que no coincide con sangría —
21//! un comentario de bloque `/* … */` a columna cero, o un `<div>` mal formateado.
22//! Cuando eso moleste, el arreglo es sumar una segunda fuente de bloques y
23//! fusionarlas, no cambiar ésta.
24//!
25//! Vive en el núcleo agnóstico porque es texto puro: sirve igual a un TUI o a un
26//! `--fold` de línea de comandos. La capa visual (qué está plegado ahora, la
27//! flecha en el gutter) es del widget.
28
29use alloc::vec::Vec;
30
31/// Un bloque que se puede plegar.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub struct Plegable {
34 /// Línea que lo encabeza. Queda **visible** al plegar y es la que lleva la
35 /// flecha en el gutter.
36 pub cabecera: usize,
37 /// Última línea del cuerpo, inclusive. El cuerpo es `cabecera+1 ..= ultima`.
38 pub ultima: usize,
39}
40
41impl Plegable {
42 /// Cuántas líneas esconde si se lo pliega.
43 pub const fn cuerpo(&self) -> usize {
44 if self.ultima > self.cabecera {
45 self.ultima - self.cabecera
46 } else {
47 0
48 }
49 }
50}
51
52/// Ancho con el que se expande un tab al medir la sangría. Es sólo para
53/// **comparar** profundidades, así que el número exacto no cambia qué se pliega
54/// mientras el archivo no mezcle tabs y espacios en el mismo nivel.
55pub const ANCHO_TAB: usize = 4;
56
57/// La sangría de una línea en columnas, o `None` si la línea está en blanco.
58///
59/// Las líneas en blanco devuelven `None` a propósito y no cero: una línea vacía
60/// en medio de una función no la corta, y contarla como sangría cero cortaría
61/// todos los bloques del archivo en el primer renglón vacío.
62fn sangria(linea: &str) -> Option<usize> {
63 let mut cols = 0usize;
64 for c in linea.chars() {
65 match c {
66 ' ' => cols += 1,
67 '\t' => cols += ANCHO_TAB - (cols % ANCHO_TAB),
68 '\n' | '\r' => break,
69 _ => return Some(cols),
70 }
71 }
72 None
73}
74
75/// Las cabeceras que **contienen** a `linea`, de la más externa a la más
76/// interna. Es la cadena que un editor pega arriba de todo para que no se
77/// pierda de vista de qué `impl`, de qué `fn` o de qué clave del YAML es lo que
78/// se está mirando.
79///
80/// Misma regla que [`plegables`] y por las mismas tres razones: la sangría. Se
81/// camina **hacia arriba** desde la línea y se anota cada vez que la sangría
82/// baja; eso es exactamente la cadena de ancestros, y cuesta lo que hay entre la
83/// línea y el margen izquierdo —unas decenas de líneas en código real— en vez
84/// del archivo entero, que es lo que costaría pedir `plegables` por cuadro.
85///
86/// `tope` acota ese camino para el caso patológico (un archivo enorme donde
87/// nada llega a la columna cero): se corta y se devuelve lo que se encontró, que
88/// es un encabezado de menos y nunca uno equivocado.
89///
90/// Las líneas en blanco no cortan nada, igual que en [`plegables`]. Si la línea
91/// de arranque está en blanco, la sangría de referencia es la de la primera
92/// línea con texto **hacia abajo**: entre dos funciones, el contexto es el de la
93/// que viene.
94pub fn cadena_de_bloques(
95 linea: usize,
96 tope: usize,
97 leer: impl Fn(usize) -> Option<alloc::string::String>,
98) -> Vec<usize> {
99 // La sangría de referencia: la de la línea, o la de la primera con texto
100 // que le siga.
101 let mut nivel = None;
102 for l in linea..linea.saturating_add(tope) {
103 let Some(texto) = leer(l) else { break };
104 if let Some(s) = sangria(&texto) {
105 nivel = Some(s);
106 break;
107 }
108 }
109 let Some(mut nivel) = nivel else { return Vec::new() };
110
111 let desde = linea.saturating_sub(tope);
112 let mut cadena = Vec::new();
113 for l in (desde..linea).rev() {
114 let Some(texto) = leer(l) else { continue };
115 let Some(s) = sangria(&texto) else { continue };
116 if s < nivel {
117 cadena.push(l);
118 nivel = s;
119 if s == 0 {
120 break;
121 }
122 }
123 }
124 cadena.reverse();
125 cadena
126}
127
128/// Todos los bloques plegables del texto, ordenados por cabecera.
129///
130/// Los bloques **anidan**: una función adentro de un `impl` produce dos, y el de
131/// afuera contiene al de adentro. Es O(n) en líneas, con una pila cuya
132/// profundidad es la del anidamiento.
133///
134/// No se llama por cuadro: el resultado se guarda y se rehace cuando el buffer
135/// cambia. Recorrer cien mil líneas en cada `view()` es exactamente el costo que
136/// la geometría del editor viene a evitar.
137pub fn plegables<S: AsRef<str>>(lineas: impl IntoIterator<Item = S>) -> Vec<Plegable> {
138 let sangrias: Vec<Option<usize>> = lineas.into_iter().map(|l| sangria(l.as_ref())).collect();
139 let mut bloques: Vec<Plegable> = Vec::new();
140 // Pila de bloques abiertos: (línea de la cabecera, su sangría).
141 let mut abiertos: Vec<(usize, usize)> = Vec::new();
142 // Última línea con contenido: es hasta donde llega un bloque que se cierra,
143 // porque las líneas en blanco del final pertenecen a lo que viene después.
144 let mut ultima_con_texto: Option<usize> = None;
145
146 for (n, s) in sangrias.iter().enumerate() {
147 let Some(actual) = *s else { continue };
148 // Todo bloque cuya cabecera está a esta profundidad o más adentro se
149 // termina acá: la línea actual ya no es cuerpo suyo.
150 while let Some(&(cabecera, sang)) = abiertos.last() {
151 if actual > sang {
152 break;
153 }
154 abiertos.pop();
155 if let Some(fin) = ultima_con_texto {
156 if fin > cabecera {
157 bloques.push(Plegable { cabecera, ultima: fin });
158 }
159 }
160 }
161 abiertos.push((n, actual));
162 ultima_con_texto = Some(n);
163 }
164 // Lo que quedó abierto al llegar al final llega hasta la última línea con
165 // texto.
166 while let Some((cabecera, _)) = abiertos.pop() {
167 if let Some(fin) = ultima_con_texto {
168 if fin > cabecera {
169 bloques.push(Plegable { cabecera, ultima: fin });
170 }
171 }
172 }
173 bloques.sort_by_key(|b| b.cabecera);
174 bloques
175}
176
177/// El bloque que encabeza esa línea exacta, si hay alguno.
178pub fn plegable_en(bloques: &[Plegable], linea: usize) -> Option<Plegable> {
179 bloques.iter().copied().find(|b| b.cabecera == linea)
180}
181
182/// El bloque que encabeza `linea`, resuelto **sin barrer el archivo**.
183///
184/// `leer` entrega el texto de una línea, o `None` pasado el final. La función
185/// mira la sangría de la cabecera y avanza mientras el texto siga más adentro:
186/// cuesta el tamaño del bloque, no el del archivo.
187///
188/// Existe porque [`plegables`] no puede llamarse por cuadro. El gutter necesita
189/// saber, de cada línea **visible**, si lleva flecha; con el barrido completo
190/// eso serían cien mil `String` por pulsación de tecla en un archivo grande,
191/// que es exactamente el costo que la geometría del editor viene evitando. Con
192/// esto son treinta líneas por cuadro.
193pub fn plegable_desde(linea: usize, leer: impl Fn(usize) -> Option<alloc::string::String>) -> Option<Plegable> {
194 let cabecera_sang = sangria(leer(linea)?.as_ref())?;
195 let mut ultima = None;
196 let mut n = linea + 1;
197 loop {
198 let Some(texto) = leer(n) else { break };
199 match sangria(texto.as_ref()) {
200 // Una línea en blanco no corta el bloque, pero tampoco lo estira:
201 // si el bloque termina acá, termina en la última con texto.
202 None => {}
203 Some(s) if s > cabecera_sang => ultima = Some(n),
204 Some(_) => break,
205 }
206 n += 1;
207 }
208 ultima.map(|u| Plegable { cabecera: linea, ultima: u })
209}
210
211/// Si esa línea encabeza un bloque plegable. Más barato que
212/// [`plegable_desde`]: alcanza con mirar hasta la primera línea con texto que
213/// venga después.
214pub fn hay_plegable_en(linea: usize, leer: impl Fn(usize) -> Option<alloc::string::String>) -> bool {
215 let Some(Some(cabecera_sang)) = leer(linea).map(|t| sangria(t.as_ref())) else {
216 return false;
217 };
218 let mut n = linea + 1;
219 loop {
220 let Some(texto) = leer(n) else { return false };
221 match sangria(texto.as_ref()) {
222 None => {}
223 Some(s) => return s > cabecera_sang,
224 }
225 n += 1;
226 }
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232 use alloc::vec;
233
234 fn de(texto: &str) -> Vec<Plegable> {
235 plegables(texto.lines())
236 }
237
238 #[test]
239 fn una_funcion_se_pliega_desde_su_firma() {
240 let b = de("fn a() {\n uno();\n dos();\n}\n");
241 // La firma encabeza; el cuerpo llega hasta la llave de cierre, que está
242 // a la misma sangría que la firma… y por eso el bloque termina en la
243 // línea de antes. Es el comportamiento clásico del fold por sangría.
244 assert_eq!(b, vec![Plegable { cabecera: 0, ultima: 2 }]);
245 assert_eq!(b[0].cuerpo(), 2);
246 }
247
248 #[test]
249 fn los_bloques_anidan() {
250 let b = de("impl X {\n fn a() {\n uno();\n }\n}\n");
251 assert_eq!(
252 b,
253 vec![
254 Plegable { cabecera: 0, ultima: 3 },
255 Plegable { cabecera: 1, ultima: 2 },
256 ]
257 );
258 }
259
260 #[test]
261 fn una_linea_en_blanco_no_corta_el_bloque() {
262 let b = de("fn a() {\n uno();\n\n dos();\n}\n");
263 assert_eq!(b, vec![Plegable { cabecera: 0, ultima: 3 }]);
264 }
265
266 #[test]
267 fn las_lineas_en_blanco_del_final_no_estiran_el_bloque() {
268 let b = de("fn a() {\n uno();\n\n\nfn b() {\n dos();\n");
269 // El primer bloque termina en la última línea CON TEXTO de su cuerpo,
270 // no en los renglones vacíos que lo separan del siguiente.
271 assert_eq!(b[0], Plegable { cabecera: 0, ultima: 1 });
272 assert_eq!(b[1], Plegable { cabecera: 4, ultima: 5 });
273 }
274
275 #[test]
276 fn un_texto_plano_sin_sangria_no_tiene_nada_que_plegar() {
277 assert!(de("uno\ndos\ntres\n").is_empty());
278 assert!(de("").is_empty());
279 }
280
281 #[test]
282 fn python_se_pliega_igual_que_rust_porque_la_regla_es_la_sangria() {
283 let b = de("def a():\n uno()\n dos()\n\ndef b():\n tres()\n");
284 assert_eq!(b[0], Plegable { cabecera: 0, ultima: 2 });
285 assert_eq!(b[1], Plegable { cabecera: 4, ultima: 5 });
286 }
287
288 #[test]
289 fn el_tab_cuenta_como_una_parada_de_tabulacion() {
290 assert_eq!(sangria("\tx"), Some(4));
291 assert_eq!(sangria(" \tx"), Some(4)); // 2 espacios + tab → siguiente parada
292 assert_eq!(sangria(" x"), Some(5));
293 assert_eq!(sangria(" "), None);
294 assert_eq!(sangria(""), None);
295 }
296
297 #[test]
298 fn un_bloque_de_una_sola_linea_no_es_plegable() {
299 // `if x { y }` en una línea no esconde nada: plegarlo sería un botón que
300 // no hace nada.
301 let b = de("fn a() { uno() }\nfn b() { dos() }\n");
302 assert!(b.is_empty());
303 }
304
305 fn lector(texto: &'static str) -> impl Fn(usize) -> Option<alloc::string::String> {
306 let lineas: Vec<alloc::string::String> =
307 texto.lines().map(alloc::string::ToString::to_string).collect();
308 move |n| lineas.get(n).cloned()
309 }
310
311 #[test]
312 fn el_bloque_de_una_linea_se_resuelve_sin_leer_el_archivo_entero() {
313 let texto = "fn a() {\n uno();\n dos();\n}\nfn b() {\n tres();\n}\n";
314 // Lo mismo que da el barrido completo, sin barrer.
315 assert_eq!(
316 plegable_desde(0, lector(texto)),
317 Some(Plegable { cabecera: 0, ultima: 2 })
318 );
319 assert_eq!(
320 plegable_desde(4, lector(texto)),
321 Some(Plegable { cabecera: 4, ultima: 5 })
322 );
323 // Una línea que no encabeza nada no produce bloque.
324 assert_eq!(plegable_desde(1, lector(texto)), None);
325 assert_eq!(plegable_desde(3, lector(texto)), None);
326 }
327
328 #[test]
329 fn el_barato_y_el_completo_dicen_lo_mismo() {
330 let texto = "impl X {\n fn a() {\n uno();\n\n dos();\n }\n}\nsuelta\n";
331 let completo = de(texto);
332 for n in 0..texto.lines().count() {
333 let barato = hay_plegable_en(n, lector(texto));
334 let esperado = plegable_en(&completo, n).is_some();
335 assert_eq!(barato, esperado, "línea {n}");
336 if esperado {
337 assert_eq!(
338 plegable_desde(n, lector(texto)).map(|b| b.ultima),
339 plegable_en(&completo, n).map(|b| b.ultima),
340 "línea {n}"
341 );
342 }
343 }
344 }
345
346 #[test]
347 fn el_dedent_parcial_cierra_solo_lo_que_corresponde() {
348 let b = de("a:\n b:\n c\n d:\n e\nf\n");
349 // `a` contiene todo hasta `e`; `b` y `d` son hermanos adentro.
350 assert_eq!(b[0], Plegable { cabecera: 0, ultima: 4 });
351 assert_eq!(b[1], Plegable { cabecera: 1, ultima: 2 });
352 assert_eq!(b[2], Plegable { cabecera: 3, ultima: 4 });
353 }
354 #[test]
355 fn la_cadena_de_bloques_son_los_ancestros_por_sangria() {
356 let texto = [
357 "impl Cosa {", // 0
358 " fn una() {", // 1
359 " if x {", // 2
360 " hacer();", // 3
361 " }", // 4
362 " }", // 5
363 "}", // 6
364 ];
365 let leer = |n: usize| texto.get(n).map(|s| alloc::string::String::from(*s));
366 // Parado en el cuerpo del `if`: los tres de afuera, de afuera hacia
367 // adentro.
368 assert_eq!(cadena_de_bloques(3, 100, leer), alloc::vec![0, 1, 2]);
369 // Parado en la línea del `if`: sus dos ancestros, no él mismo — ya se ve.
370 assert_eq!(cadena_de_bloques(2, 100, leer), alloc::vec![0, 1]);
371 // En la primera línea no hay nada arriba.
372 assert!(cadena_de_bloques(0, 100, leer).is_empty());
373 // El cierre del `impl` está a sangría cero: tampoco tiene ancestros.
374 assert!(cadena_de_bloques(6, 100, leer).is_empty());
375 }
376
377 #[test]
378 fn una_linea_en_blanco_toma_el_contexto_de_la_que_sigue() {
379 let texto = ["fn a() {", "", " paso();", "}"];
380 let leer = |n: usize| texto.get(n).map(|s| alloc::string::String::from(*s));
381 // La línea 1 está vacía: la referencia es la sangría de `paso()`.
382 assert_eq!(cadena_de_bloques(1, 100, leer), alloc::vec![0]);
383 }
384
385 #[test]
386 fn el_tope_corta_el_camino_y_no_inventa_un_ancestro() {
387 let mut texto = alloc::vec::Vec::new();
388 texto.push(alloc::string::String::from("fn grande() {"));
389 for _ in 0..500 {
390 texto.push(alloc::string::String::from(" paso();"));
391 }
392 let leer = |n: usize| texto.get(n).cloned();
393 // Con tope de sobra, aparece la cabecera.
394 assert_eq!(cadena_de_bloques(400, 1000, leer), alloc::vec![0]);
395 // Con un tope corto se devuelve lo que se encontró: nada. Un encabezado
396 // de menos, nunca uno equivocado.
397 assert!(cadena_de_bloques(400, 10, leer).is_empty());
398 }
399
400}