rustdiff 0.1.2

Semantic JSON & XML diff tool with a native GTK4 desktop UI
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
//! Resaltado visual de diferencias directamente en los editores SourceView.
//!
//! Cuando se ejecuta una comparación, este módulo busca las regiones afectadas
//! en el texto de cada editor y les aplica `GtkTextTag` con colores de fondo,
//! creando un efecto visual similar a un diff de código.
//!
//! Estrategia:
//! - Para cada `DiffItem`, extraemos el segmento de su ruta que identifica
//!   la clave o valor en el texto formateado (pretty-printed).
//! - Buscamos ese fragmento en el buffer y aplicamos un tag de color.
//! - También usamos `similar` para diff línea-por-línea como complemento
//!   del diff semántico, resaltando las líneas exactas que difieren.

use gtk::prelude::*;
use gtk4 as gtk;
use similar::{ChangeTag, TextDiff};
use sourceview5 as sv;

use crate::diff_engine::{DiffItem, DiffKind, DiffResult};

// ─────────────────────────────────────────────
// Nombres de los TextTags
// ─────────────────────────────────────────────
//
// Se usan dos familias de tags:
//   * `*-line-*`  → pintan todo el párrafo con un color muy suave
//                   (vista general del diff línea por línea).
//   * Sin sufijo  → pintan sólo el rango exacto (clave/valor) del
//                   `DiffItem` con un color un poco más saturado.
//
// Al separarlas evitamos que ambos estilos se superpongan sobre la
// misma línea y produzcan bloques oscuros.

const TAG_LINE_ADDED: &str = "rustdiff-line-added";
const TAG_LINE_REMOVED: &str = "rustdiff-line-removed";

const TAG_ADDED: &str = "rustdiff-added";
const TAG_REMOVED: &str = "rustdiff-removed";
const TAG_CHANGED: &str = "rustdiff-changed";

// Paleta inspirada en GitHub: visible tanto en tema claro como oscuro
// gracias al alpha bajo; el fondo del editor tiñe el color final.
//
// `*_LINE_*`   → fondo de párrafo muy sutil (sombra general).
// `*_INLINE_*` → fondo por carácter con algo más de contraste.
const COLOR_LINE_ADDED: &str = "rgba(46, 160, 67, 0.22)";
const COLOR_LINE_REMOVED: &str = "rgba(248, 81, 73, 0.22)";

const COLOR_INLINE_ADDED: &str = "rgba(46, 160, 67, 0.45)";
const COLOR_INLINE_REMOVED: &str = "rgba(248, 81, 73, 0.45)";
const COLOR_INLINE_CHANGED: &str = "rgba(210, 153, 34, 0.50)";

// ─────────────────────────────────────────────
// API pública
// ─────────────────────────────────────────────

/// Aplica resaltado visual en ambos editores basándose en el resultado del diff.
///
/// Combina dos estrategias:
/// 1. **Diff línea-por-línea** (via `similar`): resalta líneas completas que
///    difieren entre los dos textos. Esto da una vista general inmediata.
/// 2. **Resaltado por ruta semántica**: busca valores específicos del `DiffItem`
///    en el texto y los marca con mayor precisión.
pub fn apply_highlights(
    left_view: &sv::View,
    right_view: &sv::View,
    left_text: &str,
    right_text: &str,
    diff_result: &DiffResult,
) {
    let left_buf = left_view.buffer();
    let right_buf = right_view.buffer();

    // Limpiar tags anteriores
    clear_highlights(&left_buf);
    clear_highlights(&right_buf);

    // Asegurar que los tags existen en ambos buffers
    ensure_tags(&left_buf);
    ensure_tags(&right_buf);

    // 1. Diff línea-por-línea con `similar` para resaltado general
    apply_line_diff(&left_buf, &right_buf, left_text, right_text);

    // 2. Resaltado preciso por valor semántico
    apply_semantic_highlights(&left_buf, left_text, diff_result, Side::Left);
    apply_semantic_highlights(&right_buf, right_text, diff_result, Side::Right);
}

/// Elimina todo el resaltado de diferencias de un buffer.
pub fn clear_highlights(buffer: &gtk::TextBuffer) {
    let start = buffer.start_iter();
    let end = buffer.end_iter();

    for tag in [
        TAG_LINE_ADDED,
        TAG_LINE_REMOVED,
        TAG_ADDED,
        TAG_REMOVED,
        TAG_CHANGED,
    ] {
        buffer.remove_tag_by_name(tag, &start, &end);
    }
}

/// Hace scroll en un SourceView hasta la primera ocurrencia de `search_text`.
/// Devuelve `true` si encontró el texto.
pub fn scroll_to_text(view: &sv::View, search_text: &str) -> bool {
    let buffer = view.buffer();
    let start = buffer.start_iter();

    if let Some((match_start, match_end)) =
        start.forward_search(search_text, gtk::TextSearchFlags::CASE_INSENSITIVE, None)
    {
        // Colocar el cursor en la coincidencia
        buffer.place_cursor(&match_start);
        // Seleccionar el texto encontrado
        buffer.select_range(&match_start, &match_end);
        // Hacer scroll hasta la marca del cursor
        view.scroll_to_iter(&mut match_start.clone(), 0.1, false, 0.0, 0.0);
        true
    } else {
        false
    }
}

/// Busca y resalta un `DiffItem` específico en ambos editores.
/// Se usa cuando el usuario hace click en una fila del panel de diferencias.
pub fn highlight_and_scroll_to_item(left_view: &sv::View, right_view: &sv::View, item: &DiffItem) {
    // Buscar en el panel izquierdo (valor eliminado o cambiado)
    if let Some(ref left_val) = item.left {
        let search = clean_search_value(left_val);
        scroll_to_text(left_view, &search);
    }

    // Buscar en el panel derecho (valor añadido o cambiado)
    if let Some(ref right_val) = item.right {
        let search = clean_search_value(right_val);
        scroll_to_text(right_view, &search);
    }

    // Si no hay valor (solo ruta), buscar la clave en ambos paneles
    if item.left.is_none() && item.right.is_none() {
        let key = extract_key_from_path(&item.path);
        scroll_to_text(left_view, &key);
        scroll_to_text(right_view, &key);
    }
}

// ─────────────────────────────────────────────
// Tipos internos
// ─────────────────────────────────────────────

#[derive(Clone, Copy)]
enum Side {
    Left,
    Right,
}

// ─────────────────────────────────────────────
// Funciones internas
// ─────────────────────────────────────────────

/// Crea los `GtkTextTag` necesarios si aún no existen en el buffer.
fn ensure_tags(buffer: &gtk::TextBuffer) {
    let table = buffer.tag_table();

    // Tags de línea: sólo paragraph_background (sutil)
    if table.lookup(TAG_LINE_ADDED).is_none() {
        let tag = gtk::TextTag::builder()
            .name(TAG_LINE_ADDED)
            .paragraph_background(COLOR_LINE_ADDED)
            .build();
        table.add(&tag);
    }

    if table.lookup(TAG_LINE_REMOVED).is_none() {
        let tag = gtk::TextTag::builder()
            .name(TAG_LINE_REMOVED)
            .paragraph_background(COLOR_LINE_REMOVED)
            .build();
        table.add(&tag);
    }

    // Tags inline: sólo background (más visible en el rango exacto)
    if table.lookup(TAG_ADDED).is_none() {
        let tag = gtk::TextTag::builder()
            .name(TAG_ADDED)
            .background(COLOR_INLINE_ADDED)
            .build();
        table.add(&tag);
    }

    if table.lookup(TAG_REMOVED).is_none() {
        let tag = gtk::TextTag::builder()
            .name(TAG_REMOVED)
            .background(COLOR_INLINE_REMOVED)
            .build();
        table.add(&tag);
    }

    if table.lookup(TAG_CHANGED).is_none() {
        let tag = gtk::TextTag::builder()
            .name(TAG_CHANGED)
            .background(COLOR_INLINE_CHANGED)
            .build();
        table.add(&tag);
    }
}

/// Diff línea-por-línea entre los dos textos usando `similar`.
/// Resalta líneas completas que fueron añadidas, eliminadas o cambiadas.
fn apply_line_diff(
    left_buf: &gtk::TextBuffer,
    right_buf: &gtk::TextBuffer,
    left_text: &str,
    right_text: &str,
) {
    let diff = TextDiff::from_lines(left_text, right_text);

    // Rastrear la línea actual en cada buffer
    let mut left_line: i32 = 0;
    let mut right_line: i32 = 0;

    for change in diff.iter_all_changes() {
        match change.tag() {
            ChangeTag::Equal => {
                // Línea sin cambios: avanzar ambos contadores
                left_line += 1;
                right_line += 1;
            }
            ChangeTag::Delete => {
                // Línea eliminada (solo en el izquierdo)
                tag_line(left_buf, left_line, TAG_LINE_REMOVED);
                left_line += 1;
            }
            ChangeTag::Insert => {
                // Línea añadida (solo en el derecho)
                tag_line(right_buf, right_line, TAG_LINE_ADDED);
                right_line += 1;
            }
        }
    }
}

/// Aplica un tag a una línea completa del buffer.
fn tag_line(buffer: &gtk::TextBuffer, line: i32, tag_name: &str) {
    let total_lines = buffer.line_count();
    if line >= total_lines {
        return;
    }

    let start = buffer.iter_at_line(line);
    let end = buffer.iter_at_line(line);

    // Algunas versiones de gtk4-rs devuelven Option<TextIter>
    // y otras devuelven bool + modifican in-place.
    // Usamos iter_at_line que devuelve Option en gtk4 0.9+
    if let (Some(ref mut s), Some(ref mut e)) = (start, end) {
        e.forward_to_line_end();
        buffer.apply_tag_by_name(tag_name, s, e);
    }
}

/// Resaltado semántico: busca valores específicos del diff en el texto del editor.
fn apply_semantic_highlights(
    buffer: &gtk::TextBuffer,
    _text: &str,
    diff_result: &DiffResult,
    side: Side,
) {
    // Para cada item del diff, buscar su valor en el texto
    let items = match side {
        Side::Left => {
            // En el lado izquierdo: resaltar removed y changed (valor viejo)
            diff_result
                .removed
                .iter()
                .chain(diff_result.changed.iter())
                .collect::<Vec<_>>()
        }
        Side::Right => {
            // En el lado derecho: resaltar added y changed (valor nuevo)
            diff_result
                .added
                .iter()
                .chain(diff_result.changed.iter())
                .collect::<Vec<_>>()
        }
    };

    for item in items {
        let (search_value, tag_name) = match side {
            Side::Left => {
                let val = item.left.as_deref().unwrap_or_default();
                let tag = match item.kind {
                    DiffKind::Removed => TAG_REMOVED,
                    DiffKind::Changed => TAG_CHANGED,
                    _ => continue,
                };
                (val, tag)
            }
            Side::Right => {
                let val = item.right.as_deref().unwrap_or_default();
                let tag = match item.kind {
                    DiffKind::Added => TAG_ADDED,
                    DiffKind::Changed => TAG_CHANGED,
                    _ => continue,
                };
                (val, tag)
            }
        };

        if search_value.is_empty() {
            continue;
        }

        // Limpiar comillas para búsqueda en el texto formateado
        let clean = clean_search_value(search_value);
        if clean.is_empty() {
            continue;
        }

        // Buscar y marcar la primera ocurrencia en el buffer
        highlight_first_occurrence(buffer, &clean, tag_name);

        // También intentar buscar con la clave completa "key": value
        let key = extract_key_from_path(&item.path);
        if !key.is_empty() {
            // Buscar patrones tipo "key": valor o <key>valor</key>
            let json_pattern = format!("\"{key}\"");
            highlight_first_occurrence(buffer, &json_pattern, tag_name);
        }
    }
}

/// Busca la primera ocurrencia de `needle` en el buffer y aplica el tag.
fn highlight_first_occurrence(buffer: &gtk::TextBuffer, needle: &str, tag_name: &str) {
    let start_iter = buffer.start_iter();

    if let Some((match_start, match_end)) =
        start_iter.forward_search(needle, gtk::TextSearchFlags::CASE_INSENSITIVE, None)
    {
        buffer.apply_tag_by_name(tag_name, &match_start, &match_end);
    }
}

/// Limpia un valor de búsqueda eliminando comillas JSON externas.
fn clean_search_value(value: &str) -> String {
    let trimmed = value.trim();
    // Quitar comillas envolventes de strings JSON
    if trimmed.starts_with('"') && trimmed.ends_with('"') && trimmed.len() >= 2 {
        trimmed[1..trimmed.len() - 1].to_string()
    } else {
        trimmed.to_string()
    }
}

/// Extrae el último segmento de una ruta de diff como nombre de clave.
/// Ejemplo: `"$.usuario.perfil.ciudad"` → `"ciudad"`
/// Ejemplo: `"config.db[@host]"` → `"host"`
/// Ejemplo: `"$.users[0].nombre"` → `"nombre"`
fn extract_key_from_path(path: &str) -> String {
    // Manejar atributos XML: [@attr] al final
    if let Some(start) = path.rfind("[@") {
        if let Some(end) = path[start..].find(']') {
            return path[start + 2..start + end].to_string();
        }
    }

    // Manejar texto XML: [text] al final
    if path.ends_with("[text]") {
        let without_text = path.trim_end_matches(".[text]");
        return extract_last_segment(without_text);
    }

    // Tomar el último segmento separado por '.'
    let last = path.rsplit('.').next().unwrap_or(path);

    // Si el último segmento termina con índice de array [N], quitarlo
    if let Some(bracket) = last.rfind('[') {
        let name = &last[..bracket];
        if !name.is_empty() {
            return name.to_string();
        }
    }

    last.to_string()
}

/// Extrae el último segmento separado por `.`
fn extract_last_segment(path: &str) -> String {
    path.rsplit('.').next().unwrap_or(path).to_string()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn clean_search_value_quita_comillas() {
        assert_eq!(clean_search_value("\"hola\""), "hola");
        assert_eq!(clean_search_value("42"), "42");
        assert_eq!(clean_search_value("  \"test\"  "), "test");
        assert_eq!(clean_search_value("null"), "null");
    }

    #[test]
    fn extract_key_ruta_json() {
        assert_eq!(extract_key_from_path("$.usuario.perfil.ciudad"), "ciudad");
        assert_eq!(extract_key_from_path("$.data"), "data");
        assert_eq!(extract_key_from_path("$"), "$");
    }

    #[test]
    fn extract_key_ruta_con_indice() {
        assert_eq!(extract_key_from_path("$.users[0].nombre"), "nombre");
        assert_eq!(extract_key_from_path("$.items[2]"), "items");
    }

    #[test]
    fn extract_key_ruta_xml_atributo() {
        assert_eq!(extract_key_from_path("server[@version]"), "version");
        assert_eq!(extract_key_from_path("config.db[@host]"), "host");
    }

    #[test]
    fn extract_key_ruta_xml_texto() {
        assert_eq!(extract_key_from_path("config.db.host.[text]"), "host");
        assert_eq!(extract_key_from_path("root.[text]"), "root");
    }
}