⎇ e77a32f4d6a09fe29701811f4d2e60dd2cb678db
crates/modules/semantic_dht/minga-vfs/src/render.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
feat(minga): minga-vfs — proyecta el repo como filesystem FUSE
1//! Renderizado de un `SemanticNode` a texto legible. Lógica **pura**:
2//! sin IO, sin FUSE, sin `sled`. El VFS la usa para materializar el
3//! contenido de cada archivo virtual bajo demanda, pero es reutilizable
4//! por cualquier frontend (web, TUI).
5//!
6//! Dos vistas complementarias:
7//!
8//! - [`render_source`]: reconstrucción **canónica** del código fuente.
9//! El AST semántico descartó whitespace y comentarios al ingerir
10//! (son `extra` en tree-sitter), así que esto NO recupera el archivo
11//! original byte-a-byte: es una forma *normalizada*, re-indentada por
12//! estructura de llaves. Para lenguajes con bloques por llaves
13//! (Rust/TS/JS/Go) sale legible; Python —cuya estructura vive en la
14//! indentación, y la indentación es trivia— sale como una secuencia
15//! de tokens en pocas líneas. Es esperado: el hash es de la
16//! estructura, no del formato.
17//!
18//! - [`render_sexp`]: el árbol como S-expression indentada. Vista
19//! exacta y sin pérdida de lo que el store guarda de verdad.
20
21use minga_core::SemanticNode;
22
23/// Reconstruye el código fuente de un subárbol en forma canónica.
24///
25/// Recolecta los tokens hoja en orden y los re-imprime con un
26/// pretty-printer mínimo, consciente de llaves: indenta tras `{`,
27/// desindenta antes de `}`, corta línea tras `;`. El resultado termina
28/// siempre con exactamente un `\n`.
29pub fn render_source(node: &SemanticNode) -> String {
30 let mut tokens = Vec::new();
31 collect_leaves(node, &mut tokens);
32 pretty_print(&tokens)
33}
34
35/// Recolecta el texto de los nodos hoja en orden de recorrido (DFS).
36/// Sólo las hojas tienen `leaf_text`; los nodos internos se recurren.
37fn collect_leaves(node: &SemanticNode, out: &mut Vec<String>) {
38 match &node.leaf_text {
39 Some(bytes) => {
40 let text = String::from_utf8_lossy(bytes);
41 let trimmed = text.trim();
42 if !trimmed.is_empty() {
43 out.push(trimmed.to_string());
44 }
45 }
46 None => {
47 for child in &node.children {
48 collect_leaves(child, out);
49 }
50 }
51 }
52}
53
54/// Tokens que no quieren un espacio a su izquierda (puntuación que se
55/// pega al token anterior). Incluye `(` y `[`: en una vista normalizada
56/// se pegan al identificador previo (`main()`, `v[0]`) — el caso de
57/// llamada/indexado es el dominante.
58fn no_space_before(t: &str) -> bool {
59 matches!(
60 t,
61 ")" | "]" | "," | ";" | "." | "::" | "?" | ":" | "!" | "(" | "["
62 )
63}
64
65/// Tokens tras los cuales no va espacio (abren un grupo o son prefijos
66/// que se pegan al token siguiente).
67fn no_space_after(t: &str) -> bool {
68 matches!(t, "(" | "[" | "." | "::" | "!" | "#")
69}
70
71/// ¿Hace falta un espacio entre `prev` y `cur` en una misma línea?
72fn needs_space(cur: &str, prev: &str) -> bool {
73 !no_space_before(cur) && !no_space_after(prev)
74}
75
76fn push_indent(out: &mut String, indent: usize) {
77 for _ in 0..indent {
78 out.push_str(" ");
79 }
80}
81
82/// Re-imprime una secuencia de tokens con indentación por llaves.
83fn pretty_print(tokens: &[String]) -> String {
84 let mut out = String::new();
85 let mut indent: usize = 0;
86 // ¿Hay ya contenido en la línea en curso?
87 let mut line_open = false;
88
89 for (i, tok) in tokens.iter().enumerate() {
90 let t = tok.as_str();
91 let next = tokens.get(i + 1).map(String::as_str);
92 match t {
93 "{" => {
94 if line_open {
95 out.push(' ');
96 }
97 out.push('{');
98 indent += 1;
99 out.push('\n');
100 line_open = false;
101 }
102 "}" => {
103 indent = indent.saturating_sub(1);
104 if line_open {
105 out.push('\n');
106 }
107 push_indent(&mut out, indent);
108 out.push('}');
109 line_open = true;
110 // `} else`, `},`, `};`, `})`, `}.` se quedan en línea;
111 // cualquier otra cosa abre línea nueva.
112 if !matches!(next, Some("else") | Some(",") | Some(";") | Some(")") | Some(".")) {
113 out.push('\n');
114 line_open = false;
115 }
116 }
117 ";" => {
118 out.push(';');
119 out.push('\n');
120 line_open = false;
121 }
122 _ => {
123 if !line_open {
124 push_indent(&mut out, indent);
125 line_open = true;
126 } else if let Some(prev) = tokens.get(i.wrapping_sub(1)).map(String::as_str) {
127 if i > 0 && needs_space(t, prev) {
128 out.push(' ');
129 }
130 }
131 out.push_str(t);
132 }
133 }
134 }
135
136 // Final canónico: exactamente un newline.
137 while out.ends_with([' ', '\t', '\n']) {
138 out.pop();
139 }
140 out.push('\n');
141 out
142}
143
144/// Renderiza el subárbol como S-expression indentada (2 espacios por
145/// nivel). Cada nodo es `(kind ...)`; los nodos con `field_name` lo
146/// prefijan como `field: (kind ...)`; las hojas llevan su texto entre
147/// comillas. Es la representación literal de lo que hay en el store.
148pub fn render_sexp(node: &SemanticNode) -> String {
149 let mut out = String::new();
150 sexp_node(node, 0, &mut out);
151 out.push('\n');
152 out
153}
154
155fn sexp_node(node: &SemanticNode, depth: usize, out: &mut String) {
156 for _ in 0..depth {
157 out.push_str(" ");
158 }
159 // Convención tree-sitter: el nombre de campo va FUERA del paréntesis.
160 if let Some(field) = &node.field_name {
161 out.push_str(field);
162 out.push_str(": ");
163 }
164 out.push('(');
165 out.push_str(&node.kind);
166
167 match &node.leaf_text {
168 Some(bytes) => {
169 out.push(' ');
170 out.push('"');
171 out.push_str(&escape(&String::from_utf8_lossy(bytes)));
172 out.push('"');
173 out.push(')');
174 }
175 None if node.children.is_empty() => {
176 out.push(')');
177 }
178 None => {
179 for child in &node.children {
180 out.push('\n');
181 sexp_node(child, depth + 1, out);
182 }
183 out.push(')');
184 }
185 }
186}
187
188/// Escapa una cadena para que quepa entre comillas en la S-expression.
189fn escape(s: &str) -> String {
190 let mut out = String::with_capacity(s.len());
191 for c in s.chars() {
192 match c {
193 '\\' => out.push_str("\\\\"),
194 '"' => out.push_str("\\\""),
195 '\n' => out.push_str("\\n"),
196 '\r' => out.push_str("\\r"),
197 '\t' => out.push_str("\\t"),
198 _ => out.push(c),
199 }
200 }
201 out
202}
203
204#[cfg(test)]
205mod tests {
206 use super::*;
207 use minga_core::ast::SemanticNode;
208
209 fn leaf(kind: &str, text: &str) -> SemanticNode {
210 SemanticNode {
211 kind: kind.to_string(),
212 field_name: None,
213 leaf_text: Some(text.as_bytes().to_vec()),
214 children: Vec::new(),
215 }
216 }
217
218 fn branch(kind: &str, children: Vec<SemanticNode>) -> SemanticNode {
219 SemanticNode {
220 kind: kind.to_string(),
221 field_name: None,
222 leaf_text: None,
223 children,
224 }
225 }
226
227 #[test]
228 fn source_indents_on_braces() {
229 // tokens: fn main ( ) { let x = 1 ; }
230 let tree = branch(
231 "fn_item",
232 vec![
233 leaf("fn", "fn"),
234 leaf("ident", "main"),
235 leaf("(", "("),
236 leaf(")", ")"),
237 leaf("{", "{"),
238 leaf("let", "let"),
239 leaf("ident", "x"),
240 leaf("=", "="),
241 leaf("int", "1"),
242 leaf(";", ";"),
243 leaf("}", "}"),
244 ],
245 );
246 let out = render_source(&tree);
247 assert!(out.contains("fn main()"), "tokens pegados a paréntesis: {out:?}");
248 assert!(out.contains(" let x = 1;"), "cuerpo indentado: {out:?}");
249 assert!(out.ends_with("}\n"), "termina en una sola llave + newline: {out:?}");
250 }
251
252 #[test]
253 fn sexp_shows_kinds_fields_and_leaves() {
254 let mut id = leaf("identifier", "x");
255 id.field_name = Some("name".to_string());
256 let tree = branch("declaration", vec![id]);
257 let out = render_sexp(&tree);
258 assert!(out.contains("(declaration"));
259 assert!(out.contains("name: (identifier \"x\")"));
260 }
261
262 #[test]
263 fn sexp_escapes_special_chars() {
264 let out = render_sexp(&leaf("string", "a\"b\nc"));
265 assert!(out.contains("\\\""), "comilla escapada: {out:?}");
266 assert!(out.contains("\\n"), "newline escapado: {out:?}");
267 }
268}