facet_diff_core/layout/
render.rs

1//! Layout rendering to output.
2
3use std::fmt::{self, Write};
4
5use super::backend::{AnsiBackend, ColorBackend, PlainBackend, SemanticColor};
6use super::flavor::DiffFlavor;
7use super::{AttrStatus, ChangedGroup, ElementChange, Layout, LayoutNode, ValueType};
8use crate::DiffSymbols;
9
10/// Syntax element type for context-aware coloring.
11#[derive(Clone, Copy)]
12#[allow(dead_code)]
13enum SyntaxElement {
14    Key,
15    Structure,
16    Comment,
17}
18
19/// Get the appropriate semantic color for a syntax element in a given context.
20fn syntax_color(base: SyntaxElement, context: ElementChange) -> SemanticColor {
21    match (base, context) {
22        (SyntaxElement::Key, ElementChange::Deleted) => SemanticColor::DeletedKey,
23        (SyntaxElement::Key, ElementChange::Inserted) => SemanticColor::InsertedKey,
24        (SyntaxElement::Key, _) => SemanticColor::Key,
25
26        (SyntaxElement::Structure, ElementChange::Deleted) => SemanticColor::DeletedStructure,
27        (SyntaxElement::Structure, ElementChange::Inserted) => SemanticColor::InsertedStructure,
28        (SyntaxElement::Structure, _) => SemanticColor::Structure,
29
30        (SyntaxElement::Comment, ElementChange::Deleted) => SemanticColor::DeletedComment,
31        (SyntaxElement::Comment, ElementChange::Inserted) => SemanticColor::InsertedComment,
32        (SyntaxElement::Comment, _) => SemanticColor::Comment,
33    }
34}
35
36/// Get the appropriate semantic color for a value based on its type and context.
37fn value_color(value_type: ValueType, context: ElementChange) -> SemanticColor {
38    match (value_type, context) {
39        (ValueType::String, ElementChange::Deleted) => SemanticColor::DeletedString,
40        (ValueType::String, ElementChange::Inserted) => SemanticColor::InsertedString,
41        (ValueType::String, _) => SemanticColor::String,
42
43        (ValueType::Number, ElementChange::Deleted) => SemanticColor::DeletedNumber,
44        (ValueType::Number, ElementChange::Inserted) => SemanticColor::InsertedNumber,
45        (ValueType::Number, _) => SemanticColor::Number,
46
47        (ValueType::Boolean, ElementChange::Deleted) => SemanticColor::DeletedBoolean,
48        (ValueType::Boolean, ElementChange::Inserted) => SemanticColor::InsertedBoolean,
49        (ValueType::Boolean, _) => SemanticColor::Boolean,
50
51        (ValueType::Null, ElementChange::Deleted) => SemanticColor::DeletedNull,
52        (ValueType::Null, ElementChange::Inserted) => SemanticColor::InsertedNull,
53        (ValueType::Null, _) => SemanticColor::Null,
54
55        // Other/unknown types use accent colors
56        (ValueType::Other, ElementChange::Deleted) => SemanticColor::Deleted,
57        (ValueType::Other, ElementChange::Inserted) => SemanticColor::Inserted,
58        (ValueType::Other, ElementChange::MovedFrom)
59        | (ValueType::Other, ElementChange::MovedTo) => SemanticColor::Moved,
60        (ValueType::Other, ElementChange::None) => SemanticColor::Unchanged,
61    }
62}
63
64/// Get semantic color for highlight background (changed values).
65fn value_color_highlight(value_type: ValueType, context: ElementChange) -> SemanticColor {
66    match (value_type, context) {
67        (ValueType::String, ElementChange::Deleted) => SemanticColor::DeletedString,
68        (ValueType::String, ElementChange::Inserted) => SemanticColor::InsertedString,
69
70        (ValueType::Number, ElementChange::Deleted) => SemanticColor::DeletedNumber,
71        (ValueType::Number, ElementChange::Inserted) => SemanticColor::InsertedNumber,
72
73        (ValueType::Boolean, ElementChange::Deleted) => SemanticColor::DeletedBoolean,
74        (ValueType::Boolean, ElementChange::Inserted) => SemanticColor::InsertedBoolean,
75
76        (ValueType::Null, ElementChange::Deleted) => SemanticColor::DeletedNull,
77        (ValueType::Null, ElementChange::Inserted) => SemanticColor::InsertedNull,
78
79        // Highlight uses generic highlights for Other/unchanged
80        (_, ElementChange::Deleted) => SemanticColor::DeletedHighlight,
81        (_, ElementChange::Inserted) => SemanticColor::InsertedHighlight,
82        (_, ElementChange::MovedFrom) | (_, ElementChange::MovedTo) => {
83            SemanticColor::MovedHighlight
84        }
85        _ => SemanticColor::Unchanged,
86    }
87}
88
89/// Information for inline element diff rendering.
90/// When all attributes fit on one line, we render the full element on each -/+ line.
91struct InlineElementInfo {
92    /// Width of each attr slot (padded to max of old/new values)
93    slot_widths: Vec<usize>,
94}
95
96impl InlineElementInfo {
97    /// Calculate inline element info if all attrs fit on one line.
98    /// Returns None if the element is not suitable for inline rendering.
99    fn calculate<F: DiffFlavor>(
100        attrs: &[super::Attr],
101        tag: &str,
102        flavor: &F,
103        max_line_width: usize,
104        indent_width: usize,
105    ) -> Option<Self> {
106        if attrs.is_empty() {
107            return None;
108        }
109
110        let mut slot_widths = Vec::with_capacity(attrs.len());
111        let mut total_width = 0usize;
112
113        // struct_open (e.g., "<Point" or "Point {")
114        total_width += flavor.struct_open(tag).len();
115
116        for (i, attr) in attrs.iter().enumerate() {
117            // Space or separator before attr
118            if i > 0 {
119                total_width += flavor.field_separator().len();
120            } else {
121                total_width += 1; // space after opening
122            }
123
124            // Calculate slot width for this attr (max of old/new/both)
125            let slot_width = match &attr.status {
126                AttrStatus::Unchanged { value } => {
127                    // name="value" -> prefix + value + suffix
128                    flavor.format_field_prefix(&attr.name).len()
129                        + value.width
130                        + flavor.format_field_suffix().len()
131                }
132                AttrStatus::Changed { old, new } => {
133                    let max_val = old.width.max(new.width);
134                    flavor.format_field_prefix(&attr.name).len()
135                        + max_val
136                        + flavor.format_field_suffix().len()
137                }
138                AttrStatus::Deleted { value } => {
139                    flavor.format_field_prefix(&attr.name).len()
140                        + value.width
141                        + flavor.format_field_suffix().len()
142                }
143                AttrStatus::Inserted { value } => {
144                    flavor.format_field_prefix(&attr.name).len()
145                        + value.width
146                        + flavor.format_field_suffix().len()
147                }
148            };
149
150            slot_widths.push(slot_width);
151            total_width += slot_width;
152        }
153
154        // struct_close (e.g., "/>" or "}")
155        total_width += 1; // space before close for XML
156        total_width += flavor.struct_close(tag, true).len();
157
158        // Check if it fits (account for "- " prefix and indent)
159        let available = max_line_width.saturating_sub(indent_width + 2);
160        if total_width > available {
161            return None;
162        }
163
164        Some(Self { slot_widths })
165    }
166}
167
168/// Options for rendering a layout.
169#[derive(Clone, Debug)]
170pub struct RenderOptions<B: ColorBackend> {
171    /// Symbols to use for diff markers.
172    pub symbols: DiffSymbols,
173    /// Color backend for styling output.
174    pub backend: B,
175    /// Indentation string (default: 2 spaces).
176    pub indent: &'static str,
177}
178
179impl Default for RenderOptions<AnsiBackend> {
180    fn default() -> Self {
181        Self {
182            symbols: DiffSymbols::default(),
183            backend: AnsiBackend::default(),
184            indent: "    ",
185        }
186    }
187}
188
189impl RenderOptions<PlainBackend> {
190    /// Create options with plain backend (no colors).
191    pub fn plain() -> Self {
192        Self {
193            symbols: DiffSymbols::default(),
194            backend: PlainBackend,
195            indent: "    ",
196        }
197    }
198}
199
200impl<B: ColorBackend> RenderOptions<B> {
201    /// Create options with a custom backend.
202    pub fn with_backend(backend: B) -> Self {
203        Self {
204            symbols: DiffSymbols::default(),
205            backend,
206            indent: "    ",
207        }
208    }
209}
210
211/// Render a layout to a writer.
212///
213/// Starts at depth 1 to provide a gutter for change prefixes (- / +).
214pub fn render<W: Write, B: ColorBackend, F: DiffFlavor>(
215    layout: &Layout,
216    w: &mut W,
217    opts: &RenderOptions<B>,
218    flavor: &F,
219) -> fmt::Result {
220    render_node(layout, w, layout.root, 1, opts, flavor)
221}
222
223/// Render a layout to a String.
224pub fn render_to_string<B: ColorBackend, F: DiffFlavor>(
225    layout: &Layout,
226    opts: &RenderOptions<B>,
227    flavor: &F,
228) -> String {
229    let mut out = String::new();
230    render(layout, &mut out, opts, flavor).expect("writing to String cannot fail");
231    out
232}
233
234fn element_change_to_semantic(change: ElementChange) -> SemanticColor {
235    match change {
236        ElementChange::None => SemanticColor::Unchanged,
237        ElementChange::Deleted => SemanticColor::Deleted,
238        ElementChange::Inserted => SemanticColor::Inserted,
239        ElementChange::MovedFrom | ElementChange::MovedTo => SemanticColor::Moved,
240    }
241}
242
243fn render_node<W: Write, B: ColorBackend, F: DiffFlavor>(
244    layout: &Layout,
245    w: &mut W,
246    node_id: indextree::NodeId,
247    depth: usize,
248    opts: &RenderOptions<B>,
249    flavor: &F,
250) -> fmt::Result {
251    let node = layout.get(node_id).expect("node exists");
252
253    match node {
254        LayoutNode::Element {
255            tag,
256            field_name,
257            attrs,
258            changed_groups,
259            change,
260        } => {
261            let tag = *tag;
262            let field_name = *field_name;
263            let change = *change;
264            let attrs = attrs.clone();
265            let changed_groups = changed_groups.clone();
266
267            render_element(
268                layout,
269                w,
270                node_id,
271                depth,
272                opts,
273                flavor,
274                tag,
275                field_name,
276                &attrs,
277                &changed_groups,
278                change,
279            )
280        }
281
282        LayoutNode::Sequence {
283            change,
284            item_type,
285            field_name,
286        } => {
287            let change = *change;
288            let item_type = *item_type;
289            let field_name = *field_name;
290            render_sequence(
291                layout, w, node_id, depth, opts, flavor, change, item_type, field_name,
292            )
293        }
294
295        LayoutNode::Collapsed { count } => {
296            let count = *count;
297            write_indent(w, depth, opts)?;
298            let comment = flavor.comment(&format!("{} unchanged", count));
299            opts.backend
300                .write_styled(w, &comment, SemanticColor::Comment)?;
301            writeln!(w)
302        }
303
304        LayoutNode::Text { value, change } => {
305            let text = layout.get_string(value.span);
306            let change = *change;
307
308            write_indent(w, depth, opts)?;
309            if let Some(prefix) = change.prefix() {
310                opts.backend
311                    .write_prefix(w, prefix, element_change_to_semantic(change))?;
312                write!(w, " ")?;
313            }
314
315            let semantic = value_color(value.value_type, change);
316            opts.backend.write_styled(w, text, semantic)?;
317            writeln!(w)
318        }
319
320        LayoutNode::ItemGroup {
321            items,
322            change,
323            collapsed_suffix,
324            item_type,
325        } => {
326            let items = items.clone();
327            let change = *change;
328            let collapsed_suffix = *collapsed_suffix;
329            let item_type = *item_type;
330
331            // For changed items, the prefix eats into the indent (goes in the "gutter")
332            if let Some(prefix) = change.prefix() {
333                // Write indent minus 2 chars, then prefix + space
334                write_indent_minus_prefix(w, depth, opts)?;
335                opts.backend
336                    .write_prefix(w, prefix, element_change_to_semantic(change))?;
337                write!(w, " ")?;
338            } else {
339                write_indent(w, depth, opts)?;
340            }
341
342            // Render items with flavor separator and optional wrapping
343            for (i, item) in items.iter().enumerate() {
344                if i > 0 {
345                    write!(w, "{}", flavor.item_separator())?;
346                }
347                let raw_value = layout.get_string(item.span);
348                let formatted = flavor.format_seq_item(item_type, raw_value);
349                let semantic = value_color(item.value_type, change);
350                opts.backend.write_styled(w, &formatted, semantic)?;
351            }
352
353            // Render collapsed suffix if present (context-aware)
354            if let Some(count) = collapsed_suffix {
355                let suffix = flavor.comment(&format!("{} more", count));
356                write!(w, " ")?;
357                opts.backend.write_styled(
358                    w,
359                    &suffix,
360                    syntax_color(SyntaxElement::Comment, change),
361                )?;
362            }
363
364            writeln!(w)
365        }
366    }
367}
368
369#[allow(clippy::too_many_arguments)]
370fn render_element<W: Write, B: ColorBackend, F: DiffFlavor>(
371    layout: &Layout,
372    w: &mut W,
373    node_id: indextree::NodeId,
374    depth: usize,
375    opts: &RenderOptions<B>,
376    flavor: &F,
377    tag: &str,
378    field_name: Option<&str>,
379    attrs: &[super::Attr],
380    changed_groups: &[ChangedGroup],
381    change: ElementChange,
382) -> fmt::Result {
383    let has_attr_changes = !changed_groups.is_empty()
384        || attrs.iter().any(|a| {
385            matches!(
386                a.status,
387                AttrStatus::Deleted { .. } | AttrStatus::Inserted { .. }
388            )
389        });
390
391    let children: Vec<_> = layout.children(node_id).collect();
392    let has_children = !children.is_empty();
393
394    // Check if we can render as inline element diff (all attrs on one -/+ line pair)
395    // This is only viable when:
396    // 1. There are attribute changes (otherwise no need for -/+ lines)
397    // 2. No children (self-closing element)
398    // 3. All attrs fit on one line
399    if has_attr_changes && !has_children {
400        let indent_width = depth * opts.indent.len();
401        if let Some(info) = InlineElementInfo::calculate(attrs, tag, flavor, 80, indent_width) {
402            return render_inline_element(
403                layout, w, depth, opts, flavor, tag, field_name, attrs, &info,
404            );
405        }
406    }
407
408    let tag_color = match change {
409        ElementChange::None => SemanticColor::Structure,
410        ElementChange::Deleted => SemanticColor::DeletedStructure,
411        ElementChange::Inserted => SemanticColor::InsertedStructure,
412        ElementChange::MovedFrom | ElementChange::MovedTo => SemanticColor::Moved,
413    };
414
415    // Opening tag/struct
416    write_indent(w, depth, opts)?;
417    if let Some(prefix) = change.prefix() {
418        opts.backend
419            .write_prefix(w, prefix, element_change_to_semantic(change))?;
420        write!(w, " ")?;
421    }
422
423    // Render field name prefix if this element is a struct field (e.g., "point: " for Rust)
424    // Uses format_child_open which handles the difference between:
425    // - Rust/JSON: `field_name: `
426    // - XML: `` (empty - nested elements don't use attribute syntax)
427    if let Some(name) = field_name {
428        let prefix = flavor.format_child_open(name);
429        if !prefix.is_empty() {
430            opts.backend
431                .write_styled(w, &prefix, SemanticColor::Unchanged)?;
432        }
433    }
434
435    let open = flavor.struct_open(tag);
436    opts.backend.write_styled(w, &open, tag_color)?;
437
438    // Render type comment in muted color if present (context-aware)
439    if let Some(comment) = flavor.type_comment(tag) {
440        write!(w, " ")?;
441        opts.backend
442            .write_styled(w, &comment, syntax_color(SyntaxElement::Comment, change))?;
443    }
444
445    if has_attr_changes {
446        // Multi-line attribute format
447        writeln!(w)?;
448
449        // Render changed groups as -/+ line pairs
450        for group in changed_groups {
451            render_changed_group(layout, w, depth + 1, opts, flavor, attrs, group)?;
452        }
453
454        // Render deleted attributes (prefix uses indent gutter)
455        for (i, attr) in attrs.iter().enumerate() {
456            if let AttrStatus::Deleted { value } = &attr.status {
457                // Skip if already in a changed group
458                if changed_groups.iter().any(|g| g.attr_indices.contains(&i)) {
459                    continue;
460                }
461                write_indent_minus_prefix(w, depth + 1, opts)?;
462                opts.backend.write_prefix(w, '-', SemanticColor::Deleted)?;
463                write!(w, " ")?;
464                render_attr_deleted(layout, w, opts, flavor, &attr.name, value)?;
465                // Trailing comma (no highlight background)
466                opts.backend.write_styled(
467                    w,
468                    flavor.trailing_separator(),
469                    SemanticColor::Whitespace,
470                )?;
471                writeln!(w)?;
472            }
473        }
474
475        // Render inserted attributes (prefix uses indent gutter)
476        for (i, attr) in attrs.iter().enumerate() {
477            if let AttrStatus::Inserted { value } = &attr.status {
478                if changed_groups.iter().any(|g| g.attr_indices.contains(&i)) {
479                    continue;
480                }
481                write_indent_minus_prefix(w, depth + 1, opts)?;
482                opts.backend.write_prefix(w, '+', SemanticColor::Inserted)?;
483                write!(w, " ")?;
484                render_attr_inserted(layout, w, opts, flavor, &attr.name, value)?;
485                // Trailing comma (no highlight background)
486                opts.backend.write_styled(
487                    w,
488                    flavor.trailing_separator(),
489                    SemanticColor::Whitespace,
490                )?;
491                writeln!(w)?;
492            }
493        }
494
495        // Render unchanged attributes on one line
496        let unchanged: Vec<_> = attrs
497            .iter()
498            .filter(|a| matches!(a.status, AttrStatus::Unchanged { .. }))
499            .collect();
500        if !unchanged.is_empty() {
501            write_indent(w, depth + 1, opts)?;
502            for (i, attr) in unchanged.iter().enumerate() {
503                if i > 0 {
504                    write!(w, "{}", flavor.field_separator())?;
505                }
506                if let AttrStatus::Unchanged { value } = &attr.status {
507                    render_attr_unchanged(layout, w, opts, flavor, &attr.name, value)?;
508                }
509            }
510            // Trailing comma (no background)
511            opts.backend
512                .write_styled(w, flavor.trailing_separator(), SemanticColor::Whitespace)?;
513            writeln!(w)?;
514        }
515
516        // Closing bracket
517        write_indent(w, depth, opts)?;
518        if has_children {
519            let open_close = flavor.struct_open_close();
520            opts.backend.write_styled(w, open_close, tag_color)?;
521        } else {
522            let close = flavor.struct_close(tag, true);
523            opts.backend.write_styled(w, &close, tag_color)?;
524        }
525        writeln!(w)?;
526    } else if has_children && !attrs.is_empty() {
527        // Unchanged attributes with children: put attrs on their own lines
528        writeln!(w)?;
529        for attr in attrs.iter() {
530            write_indent(w, depth + 1, opts)?;
531            if let AttrStatus::Unchanged { value } = &attr.status {
532                render_attr_unchanged(layout, w, opts, flavor, &attr.name, value)?;
533            }
534            // Trailing comma (no background)
535            opts.backend
536                .write_styled(w, flavor.trailing_separator(), SemanticColor::Whitespace)?;
537            writeln!(w)?;
538        }
539        // Close the opening (e.g., ">" for XML) - only if non-empty
540        let open_close = flavor.struct_open_close();
541        if !open_close.is_empty() {
542            write_indent(w, depth, opts)?;
543            opts.backend.write_styled(w, open_close, tag_color)?;
544            writeln!(w)?;
545        }
546    } else {
547        // Inline attributes (no changes, no children) or no attrs
548        for (i, attr) in attrs.iter().enumerate() {
549            if i > 0 {
550                write!(w, "{}", flavor.field_separator())?;
551            } else {
552                write!(w, " ")?;
553            }
554            if let AttrStatus::Unchanged { value } = &attr.status {
555                render_attr_unchanged(layout, w, opts, flavor, &attr.name, value)?;
556            }
557        }
558
559        if has_children {
560            // Close the opening tag (e.g., ">" for XML)
561            let open_close = flavor.struct_open_close();
562            opts.backend.write_styled(w, open_close, tag_color)?;
563        } else {
564            // Self-closing
565            let close = flavor.struct_close(tag, true);
566            opts.backend.write_styled(w, &close, tag_color)?;
567        }
568        writeln!(w)?;
569    }
570
571    // Children
572    for child_id in children {
573        render_node(layout, w, child_id, depth + 1, opts, flavor)?;
574    }
575
576    // Closing tag (if we have children, we already printed opening part above)
577    if has_children {
578        write_indent(w, depth, opts)?;
579        if let Some(prefix) = change.prefix() {
580            opts.backend
581                .write_prefix(w, prefix, element_change_to_semantic(change))?;
582            write!(w, " ")?;
583        }
584        let close = flavor.struct_close(tag, false);
585        opts.backend.write_styled(w, &close, tag_color)?;
586        writeln!(w)?;
587    }
588
589    Ok(())
590}
591
592/// Render an element with all attrs on one line per -/+ row.
593/// This is used when all attrs fit on a single line for a more compact diff.
594#[allow(clippy::too_many_arguments)]
595fn render_inline_element<W: Write, B: ColorBackend, F: DiffFlavor>(
596    layout: &Layout,
597    w: &mut W,
598    depth: usize,
599    opts: &RenderOptions<B>,
600    flavor: &F,
601    tag: &str,
602    field_name: Option<&str>,
603    attrs: &[super::Attr],
604    info: &InlineElementInfo,
605) -> fmt::Result {
606    // Render field name prefix if present (for nested struct fields)
607    let field_prefix = field_name.map(|name| flavor.format_child_open(name));
608    let open = flavor.struct_open(tag);
609    let close = flavor.struct_close(tag, true);
610
611    // --- Before line (old values) ---
612    // Line background applies to structural parts, highlight background to changed values
613    // Use ← for "changed from" (vs - for "deleted entirely")
614    write_indent_minus_prefix(w, depth, opts)?;
615    opts.backend.write_prefix(w, '←', SemanticColor::Deleted)?;
616    opts.backend.write_styled(w, " ", SemanticColor::Deleted)?;
617
618    // Field name prefix (line bg)
619    if let Some(ref prefix) = field_prefix
620        && !prefix.is_empty()
621    {
622        opts.backend
623            .write_styled(w, prefix, SemanticColor::Deleted)?;
624    }
625
626    // Opening tag (line bg, with deleted context blending)
627    opts.backend
628        .write_styled(w, &open, SemanticColor::DeletedStructure)?;
629
630    // Attributes (old values or spaces for inserted)
631    for (i, (attr, &slot_width)) in attrs.iter().zip(info.slot_widths.iter()).enumerate() {
632        if i > 0 {
633            opts.backend
634                .write_styled(w, flavor.field_separator(), SemanticColor::Whitespace)?;
635        } else {
636            opts.backend.write_styled(w, " ", SemanticColor::Deleted)?;
637        }
638
639        let written = match &attr.status {
640            AttrStatus::Unchanged { value } => {
641                // Unchanged: context-aware colors for structural elements
642                opts.backend.write_styled(
643                    w,
644                    &flavor.format_field_prefix(&attr.name),
645                    SemanticColor::DeletedKey,
646                )?;
647                let val = layout.get_string(value.span);
648                let color = value_color(value.value_type, ElementChange::Deleted);
649                opts.backend.write_styled(w, val, color)?;
650                opts.backend.write_styled(
651                    w,
652                    flavor.format_field_suffix(),
653                    SemanticColor::DeletedStructure,
654                )?;
655                flavor.format_field_prefix(&attr.name).len()
656                    + value.width
657                    + flavor.format_field_suffix().len()
658            }
659            AttrStatus::Changed { old, .. } => {
660                // Changed: context-aware key color, highlight bg for value only
661                opts.backend.write_styled(
662                    w,
663                    &flavor.format_field_prefix(&attr.name),
664                    SemanticColor::DeletedKey,
665                )?;
666                let val = layout.get_string(old.span);
667                let color = value_color_highlight(old.value_type, ElementChange::Deleted);
668                opts.backend.write_styled(w, val, color)?;
669                opts.backend.write_styled(
670                    w,
671                    flavor.format_field_suffix(),
672                    SemanticColor::DeletedStructure,
673                )?;
674                flavor.format_field_prefix(&attr.name).len()
675                    + old.width
676                    + flavor.format_field_suffix().len()
677            }
678            AttrStatus::Deleted { value } => {
679                // Deleted entirely: highlight bg for key AND value
680                opts.backend.write_styled(
681                    w,
682                    &flavor.format_field_prefix(&attr.name),
683                    SemanticColor::DeletedHighlight,
684                )?;
685                let val = layout.get_string(value.span);
686                let color = value_color_highlight(value.value_type, ElementChange::Deleted);
687                opts.backend.write_styled(w, val, color)?;
688                opts.backend.write_styled(
689                    w,
690                    flavor.format_field_suffix(),
691                    SemanticColor::DeletedHighlight,
692                )?;
693                flavor.format_field_prefix(&attr.name).len()
694                    + value.width
695                    + flavor.format_field_suffix().len()
696            }
697            AttrStatus::Inserted { .. } => {
698                // Empty slot on minus line - show ∅ placeholder
699                opts.backend.write_styled(w, "∅", SemanticColor::Deleted)?;
700                1 // ∅ is 1 char wide
701            }
702        };
703
704        // Pad to slot width (line bg)
705        let padding = slot_width.saturating_sub(written);
706        if padding > 0 {
707            let spaces: String = " ".repeat(padding);
708            opts.backend
709                .write_styled(w, &spaces, SemanticColor::Whitespace)?;
710        }
711    }
712
713    // Closing (line bg, with deleted context blending)
714    opts.backend.write_styled(w, " ", SemanticColor::Deleted)?;
715    opts.backend
716        .write_styled(w, &close, SemanticColor::DeletedStructure)?;
717    writeln!(w)?;
718
719    // --- After line (new values) ---
720    // Use → for "changed to" (vs + for "inserted entirely")
721    write_indent_minus_prefix(w, depth, opts)?;
722    opts.backend.write_prefix(w, '→', SemanticColor::Inserted)?;
723    opts.backend.write_styled(w, " ", SemanticColor::Inserted)?;
724
725    // Field name prefix (line bg)
726    if let Some(ref prefix) = field_prefix
727        && !prefix.is_empty()
728    {
729        opts.backend
730            .write_styled(w, prefix, SemanticColor::Inserted)?;
731    }
732
733    // Opening tag (line bg, with inserted context blending)
734    opts.backend
735        .write_styled(w, &open, SemanticColor::InsertedStructure)?;
736
737    // Attributes (new values or spaces for deleted)
738    for (i, (attr, &slot_width)) in attrs.iter().zip(info.slot_widths.iter()).enumerate() {
739        if i > 0 {
740            opts.backend
741                .write_styled(w, flavor.field_separator(), SemanticColor::Whitespace)?;
742        } else {
743            opts.backend.write_styled(w, " ", SemanticColor::Inserted)?;
744        }
745
746        let written = match &attr.status {
747            AttrStatus::Unchanged { value } => {
748                // Unchanged: context-aware colors for structural elements
749                opts.backend.write_styled(
750                    w,
751                    &flavor.format_field_prefix(&attr.name),
752                    SemanticColor::InsertedKey,
753                )?;
754                let val = layout.get_string(value.span);
755                let color = value_color(value.value_type, ElementChange::Inserted);
756                opts.backend.write_styled(w, val, color)?;
757                opts.backend.write_styled(
758                    w,
759                    flavor.format_field_suffix(),
760                    SemanticColor::InsertedStructure,
761                )?;
762                flavor.format_field_prefix(&attr.name).len()
763                    + value.width
764                    + flavor.format_field_suffix().len()
765            }
766            AttrStatus::Changed { new, .. } => {
767                // Changed: context-aware key color, highlight bg for value only
768                opts.backend.write_styled(
769                    w,
770                    &flavor.format_field_prefix(&attr.name),
771                    SemanticColor::InsertedKey,
772                )?;
773                let val = layout.get_string(new.span);
774                let color = value_color_highlight(new.value_type, ElementChange::Inserted);
775                opts.backend.write_styled(w, val, color)?;
776                opts.backend.write_styled(
777                    w,
778                    flavor.format_field_suffix(),
779                    SemanticColor::InsertedStructure,
780                )?;
781                flavor.format_field_prefix(&attr.name).len()
782                    + new.width
783                    + flavor.format_field_suffix().len()
784            }
785            AttrStatus::Deleted { .. } => {
786                // Empty slot on plus line - show ∅ placeholder
787                opts.backend.write_styled(w, "∅", SemanticColor::Inserted)?;
788                1 // ∅ is 1 char wide
789            }
790            AttrStatus::Inserted { value } => {
791                // Inserted entirely: highlight bg for key AND value
792                opts.backend.write_styled(
793                    w,
794                    &flavor.format_field_prefix(&attr.name),
795                    SemanticColor::InsertedHighlight,
796                )?;
797                let val = layout.get_string(value.span);
798                let color = value_color_highlight(value.value_type, ElementChange::Inserted);
799                opts.backend.write_styled(w, val, color)?;
800                opts.backend.write_styled(
801                    w,
802                    flavor.format_field_suffix(),
803                    SemanticColor::InsertedHighlight,
804                )?;
805                flavor.format_field_prefix(&attr.name).len()
806                    + value.width
807                    + flavor.format_field_suffix().len()
808            }
809        };
810
811        // Pad to slot width (no background for spaces)
812        let padding = slot_width.saturating_sub(written);
813        if padding > 0 {
814            let spaces: String = " ".repeat(padding);
815            opts.backend
816                .write_styled(w, &spaces, SemanticColor::Whitespace)?;
817        }
818    }
819
820    // Closing (line bg, with inserted context blending)
821    opts.backend.write_styled(w, " ", SemanticColor::Inserted)?;
822    opts.backend
823        .write_styled(w, &close, SemanticColor::InsertedStructure)?;
824    writeln!(w)?;
825
826    Ok(())
827}
828
829#[allow(clippy::too_many_arguments)]
830fn render_sequence<W: Write, B: ColorBackend, F: DiffFlavor>(
831    layout: &Layout,
832    w: &mut W,
833    node_id: indextree::NodeId,
834    depth: usize,
835    opts: &RenderOptions<B>,
836    flavor: &F,
837    change: ElementChange,
838    _item_type: &str, // Item type available for future use (items use it via ItemGroup)
839    field_name: Option<&str>,
840) -> fmt::Result {
841    let children: Vec<_> = layout.children(node_id).collect();
842
843    let tag_color = match change {
844        ElementChange::None => SemanticColor::Structure,
845        ElementChange::Deleted => SemanticColor::DeletedStructure,
846        ElementChange::Inserted => SemanticColor::InsertedStructure,
847        ElementChange::MovedFrom | ElementChange::MovedTo => SemanticColor::Moved,
848    };
849
850    // Empty sequences: render on single line
851    if children.is_empty() {
852        // Always render empty sequences with field name (e.g., "elements: []")
853        // Only skip if unchanged AND no field name
854        if change == ElementChange::None && field_name.is_none() {
855            return Ok(());
856        }
857
858        write_indent(w, depth, opts)?;
859        if let Some(prefix) = change.prefix() {
860            opts.backend
861                .write_prefix(w, prefix, element_change_to_semantic(change))?;
862            write!(w, " ")?;
863        }
864
865        // Open and close with optional field name
866        if let Some(name) = field_name {
867            let open = flavor.format_seq_field_open(name);
868            let close = flavor.format_seq_field_close(name);
869            opts.backend.write_styled(w, &open, tag_color)?;
870            opts.backend.write_styled(w, &close, tag_color)?;
871        } else {
872            let open = flavor.seq_open();
873            let close = flavor.seq_close();
874            opts.backend.write_styled(w, &open, tag_color)?;
875            opts.backend.write_styled(w, &close, tag_color)?;
876        }
877
878        // Trailing comma for fields (context-aware)
879        if field_name.is_some() {
880            opts.backend
881                .write_styled(w, flavor.trailing_separator(), SemanticColor::Whitespace)?;
882        }
883        writeln!(w)?;
884        return Ok(());
885    }
886
887    // Opening bracket with optional field name
888    write_indent(w, depth, opts)?;
889    if let Some(prefix) = change.prefix() {
890        opts.backend
891            .write_prefix(w, prefix, element_change_to_semantic(change))?;
892        write!(w, " ")?;
893    }
894
895    // Open with optional field name
896    if let Some(name) = field_name {
897        let open = flavor.format_seq_field_open(name);
898        opts.backend.write_styled(w, &open, tag_color)?;
899    } else {
900        let open = flavor.seq_open();
901        opts.backend.write_styled(w, &open, tag_color)?;
902    }
903    writeln!(w)?;
904
905    // Children
906    for child_id in children {
907        render_node(layout, w, child_id, depth + 1, opts, flavor)?;
908    }
909
910    // Closing bracket
911    write_indent(w, depth, opts)?;
912    if let Some(prefix) = change.prefix() {
913        opts.backend
914            .write_prefix(w, prefix, element_change_to_semantic(change))?;
915        write!(w, " ")?;
916    }
917
918    // Close with optional field name
919    if let Some(name) = field_name {
920        let close = flavor.format_seq_field_close(name);
921        opts.backend.write_styled(w, &close, tag_color)?;
922    } else {
923        let close = flavor.seq_close();
924        opts.backend.write_styled(w, &close, tag_color)?;
925    }
926
927    // Trailing comma for fields (context-aware)
928    if field_name.is_some() {
929        opts.backend
930            .write_styled(w, flavor.trailing_separator(), SemanticColor::Whitespace)?;
931    }
932    writeln!(w)?;
933
934    Ok(())
935}
936
937fn render_changed_group<W: Write, B: ColorBackend, F: DiffFlavor>(
938    layout: &Layout,
939    w: &mut W,
940    depth: usize,
941    opts: &RenderOptions<B>,
942    flavor: &F,
943    attrs: &[super::Attr],
944    group: &ChangedGroup,
945) -> fmt::Result {
946    // Before line - use ← for "changed from" (prefix uses indent gutter)
947    write_indent_minus_prefix(w, depth, opts)?;
948    opts.backend.write_prefix(w, '←', SemanticColor::Deleted)?;
949    write!(w, " ")?;
950
951    let last_idx = group.attr_indices.len().saturating_sub(1);
952    for (i, &idx) in group.attr_indices.iter().enumerate() {
953        if i > 0 {
954            write!(w, "{}", flavor.field_separator())?;
955        }
956        let attr = &attrs[idx];
957        if let AttrStatus::Changed { old, new } = &attr.status {
958            // Each field padded to max of its own old/new value width
959            let field_max_width = old.width.max(new.width);
960            // Use context-aware key color for field prefix (line bg)
961            opts.backend.write_styled(
962                w,
963                &flavor.format_field_prefix(&attr.name),
964                SemanticColor::DeletedKey,
965            )?;
966            // Changed value uses highlight background for contrast
967            let old_str = layout.get_string(old.span);
968            let color = value_color_highlight(old.value_type, ElementChange::Deleted);
969            opts.backend.write_styled(w, old_str, color)?;
970            // Use context-aware structure color for field suffix (line bg)
971            opts.backend.write_styled(
972                w,
973                flavor.format_field_suffix(),
974                SemanticColor::DeletedStructure,
975            )?;
976            // Pad to align with the + line's value (only between fields, not at end)
977            if i < last_idx {
978                let value_padding = field_max_width.saturating_sub(old.width);
979                for _ in 0..value_padding {
980                    write!(w, " ")?;
981                }
982            }
983        }
984    }
985    writeln!(w)?;
986
987    // After line - use → for "changed to" (prefix uses indent gutter)
988    write_indent_minus_prefix(w, depth, opts)?;
989    opts.backend.write_prefix(w, '→', SemanticColor::Inserted)?;
990    write!(w, " ")?;
991
992    for (i, &idx) in group.attr_indices.iter().enumerate() {
993        if i > 0 {
994            write!(w, "{}", flavor.field_separator())?;
995        }
996        let attr = &attrs[idx];
997        if let AttrStatus::Changed { old, new } = &attr.status {
998            // Each field padded to max of its own old/new value width
999            let field_max_width = old.width.max(new.width);
1000            // Use context-aware key color for field prefix (line bg)
1001            opts.backend.write_styled(
1002                w,
1003                &flavor.format_field_prefix(&attr.name),
1004                SemanticColor::InsertedKey,
1005            )?;
1006            // Changed value uses highlight background for contrast
1007            let new_str = layout.get_string(new.span);
1008            let color = value_color_highlight(new.value_type, ElementChange::Inserted);
1009            opts.backend.write_styled(w, new_str, color)?;
1010            // Use context-aware structure color for field suffix (line bg)
1011            opts.backend.write_styled(
1012                w,
1013                flavor.format_field_suffix(),
1014                SemanticColor::InsertedStructure,
1015            )?;
1016            // Pad to align with the - line's value (only between fields, not at end)
1017            if i < last_idx {
1018                let value_padding = field_max_width.saturating_sub(new.width);
1019                for _ in 0..value_padding {
1020                    write!(w, " ")?;
1021                }
1022            }
1023        }
1024    }
1025    writeln!(w)?;
1026
1027    Ok(())
1028}
1029
1030fn render_attr_unchanged<W: Write, B: ColorBackend, F: DiffFlavor>(
1031    layout: &Layout,
1032    w: &mut W,
1033    opts: &RenderOptions<B>,
1034    flavor: &F,
1035    name: &str,
1036    value: &super::FormattedValue,
1037) -> fmt::Result {
1038    let value_str = layout.get_string(value.span);
1039    let formatted = flavor.format_field(name, value_str);
1040    opts.backend
1041        .write_styled(w, &formatted, SemanticColor::Unchanged)
1042}
1043
1044fn render_attr_deleted<W: Write, B: ColorBackend, F: DiffFlavor>(
1045    layout: &Layout,
1046    w: &mut W,
1047    opts: &RenderOptions<B>,
1048    flavor: &F,
1049    name: &str,
1050    value: &super::FormattedValue,
1051) -> fmt::Result {
1052    let value_str = layout.get_string(value.span);
1053    // Entire field uses highlight background for deleted (better contrast)
1054    let formatted = flavor.format_field(name, value_str);
1055    opts.backend
1056        .write_styled(w, &formatted, SemanticColor::DeletedHighlight)
1057}
1058
1059fn render_attr_inserted<W: Write, B: ColorBackend, F: DiffFlavor>(
1060    layout: &Layout,
1061    w: &mut W,
1062    opts: &RenderOptions<B>,
1063    flavor: &F,
1064    name: &str,
1065    value: &super::FormattedValue,
1066) -> fmt::Result {
1067    let value_str = layout.get_string(value.span);
1068    // Entire field uses highlight background for inserted (better contrast)
1069    let formatted = flavor.format_field(name, value_str);
1070    opts.backend
1071        .write_styled(w, &formatted, SemanticColor::InsertedHighlight)
1072}
1073
1074fn write_indent<W: Write, B: ColorBackend>(
1075    w: &mut W,
1076    depth: usize,
1077    opts: &RenderOptions<B>,
1078) -> fmt::Result {
1079    for _ in 0..depth {
1080        write!(w, "{}", opts.indent)?;
1081    }
1082    Ok(())
1083}
1084
1085/// Write indent minus 2 characters for the prefix gutter.
1086/// The "- " or "+ " prefix will occupy those 2 characters.
1087fn write_indent_minus_prefix<W: Write, B: ColorBackend>(
1088    w: &mut W,
1089    depth: usize,
1090    opts: &RenderOptions<B>,
1091) -> fmt::Result {
1092    let total_indent = depth * opts.indent.len();
1093    let gutter_indent = total_indent.saturating_sub(2);
1094    for _ in 0..gutter_indent {
1095        write!(w, " ")?;
1096    }
1097    Ok(())
1098}
1099
1100#[cfg(test)]
1101mod tests {
1102    use indextree::Arena;
1103
1104    use super::*;
1105    use crate::layout::{Attr, FormatArena, FormattedValue, Layout, LayoutNode, XmlFlavor};
1106
1107    fn make_test_layout() -> Layout {
1108        let mut strings = FormatArena::new();
1109        let tree = Arena::new();
1110
1111        // Create a simple element with one changed attribute
1112        let (red_span, red_width) = strings.push_str("red");
1113        let (blue_span, blue_width) = strings.push_str("blue");
1114
1115        let fill_attr = Attr::changed(
1116            "fill",
1117            4,
1118            FormattedValue::new(red_span, red_width),
1119            FormattedValue::new(blue_span, blue_width),
1120        );
1121
1122        let attrs = vec![fill_attr];
1123        let changed_groups = super::super::group_changed_attrs(&attrs, 80, 0);
1124
1125        let root = LayoutNode::Element {
1126            tag: "rect",
1127            field_name: None,
1128            attrs,
1129            changed_groups,
1130            change: ElementChange::None,
1131        };
1132
1133        Layout::new(strings, tree, root)
1134    }
1135
1136    #[test]
1137    fn test_render_simple_change() {
1138        let layout = make_test_layout();
1139        let opts = RenderOptions::plain();
1140        let output = render_to_string(&layout, &opts, &XmlFlavor);
1141
1142        // With inline element diff, the format uses ← / → for changed state:
1143        // ← <rect fill="red"  />
1144        // → <rect fill="blue" />
1145        assert!(output.contains("← <rect fill=\"red\""));
1146        assert!(output.contains("→ <rect fill=\"blue\""));
1147        assert!(output.contains("/>"));
1148    }
1149
1150    #[test]
1151    fn test_render_collapsed() {
1152        let strings = FormatArena::new();
1153        let tree = Arena::new();
1154
1155        let root = LayoutNode::collapsed(5);
1156        let layout = Layout::new(strings, tree, root);
1157
1158        let opts = RenderOptions::plain();
1159        let output = render_to_string(&layout, &opts, &XmlFlavor);
1160
1161        assert!(output.contains("<!-- 5 unchanged -->"));
1162    }
1163
1164    #[test]
1165    fn test_render_with_children() {
1166        let mut strings = FormatArena::new();
1167        let mut tree = Arena::new();
1168
1169        // Parent element
1170        let parent = tree.new_node(LayoutNode::Element {
1171            tag: "svg",
1172            field_name: None,
1173            attrs: vec![],
1174            changed_groups: vec![],
1175            change: ElementChange::None,
1176        });
1177
1178        // Child element with change
1179        let (red_span, red_width) = strings.push_str("red");
1180        let (blue_span, blue_width) = strings.push_str("blue");
1181
1182        let fill_attr = Attr::changed(
1183            "fill",
1184            4,
1185            FormattedValue::new(red_span, red_width),
1186            FormattedValue::new(blue_span, blue_width),
1187        );
1188        let attrs = vec![fill_attr];
1189        let changed_groups = super::super::group_changed_attrs(&attrs, 80, 0);
1190
1191        let child = tree.new_node(LayoutNode::Element {
1192            tag: "rect",
1193            field_name: None,
1194            attrs,
1195            changed_groups,
1196            change: ElementChange::None,
1197        });
1198
1199        parent.append(child, &mut tree);
1200
1201        let layout = Layout {
1202            strings,
1203            tree,
1204            root: parent,
1205        };
1206
1207        let opts = RenderOptions::plain();
1208        let output = render_to_string(&layout, &opts, &XmlFlavor);
1209
1210        assert!(output.contains("<svg>"));
1211        assert!(output.contains("</svg>"));
1212        assert!(output.contains("<rect"));
1213    }
1214
1215    #[test]
1216    fn test_ansi_backend_produces_escapes() {
1217        let layout = make_test_layout();
1218        let opts = RenderOptions::default();
1219        let output = render_to_string(&layout, &opts, &XmlFlavor);
1220
1221        // Should contain ANSI escape codes
1222        assert!(
1223            output.contains("\x1b["),
1224            "output should contain ANSI escapes"
1225        );
1226    }
1227}