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    // Handle transparent elements - render children only without wrapper
384    // This is used for Option types which should not create XML elements
385    if tag == "_transparent" {
386        for child_id in layout.children(node_id) {
387            render_node(layout, w, child_id, depth, opts, flavor)?;
388        }
389        return Ok(());
390    }
391
392    // Check what kinds of attribute changes we have
393    let has_changed_attrs = !changed_groups.is_empty();
394    let has_deleted_attrs = attrs
395        .iter()
396        .any(|a| matches!(a.status, AttrStatus::Deleted { .. }));
397    let has_inserted_attrs = attrs
398        .iter()
399        .any(|a| matches!(a.status, AttrStatus::Inserted { .. }));
400
401    // Pure insertion: all non-unchanged attrs are Inserted (no Changed or Deleted)
402    // These should render as a single + line, not ← → pairs with ∅ placeholders
403    let is_pure_insertion = has_inserted_attrs && !has_changed_attrs && !has_deleted_attrs;
404
405    // Pure deletion: all non-unchanged attrs are Deleted (no Changed or Inserted)
406    // These should render as a single - line, not ← → pairs with ∅ placeholders
407    let is_pure_deletion = has_deleted_attrs && !has_changed_attrs && !has_inserted_attrs;
408
409    let has_attr_changes = has_changed_attrs || has_deleted_attrs || has_inserted_attrs;
410
411    let children: Vec<_> = layout.children(node_id).collect();
412    let has_children = !children.is_empty();
413
414    // Check if we can render as inline element diff (all attrs on one -/+ line pair)
415    // This is only viable when:
416    // 1. There are attribute changes (otherwise no need for -/+ lines)
417    // 2. No children (self-closing element)
418    // 3. All attrs fit on one line
419    // 4. NOT a pure insertion/deletion (those should use single line with +/- prefix)
420    if has_attr_changes && !has_children && !is_pure_insertion && !is_pure_deletion {
421        let indent_width = depth * opts.indent.len();
422        if let Some(info) = InlineElementInfo::calculate(attrs, tag, flavor, 80, indent_width) {
423            return render_inline_element(
424                layout, w, depth, opts, flavor, tag, field_name, attrs, &info,
425            );
426        }
427    }
428
429    let tag_color = match change {
430        ElementChange::None => SemanticColor::Structure,
431        ElementChange::Deleted => SemanticColor::DeletedStructure,
432        ElementChange::Inserted => SemanticColor::InsertedStructure,
433        ElementChange::MovedFrom | ElementChange::MovedTo => SemanticColor::Moved,
434    };
435
436    // Opening tag/struct
437    write_indent(w, depth, opts)?;
438    if let Some(prefix) = change.prefix() {
439        opts.backend
440            .write_prefix(w, prefix, element_change_to_semantic(change))?;
441        write!(w, " ")?;
442    }
443
444    // Render field name prefix if this element is a struct field (e.g., "point: " for Rust)
445    // Uses format_child_open which handles the difference between:
446    // - Rust/JSON: `field_name: `
447    // - XML: `` (empty - nested elements don't use attribute syntax)
448    if let Some(name) = field_name {
449        let prefix = flavor.format_child_open(name);
450        if !prefix.is_empty() {
451            opts.backend
452                .write_styled(w, &prefix, SemanticColor::Unchanged)?;
453        }
454    }
455
456    let open = flavor.struct_open(tag);
457    opts.backend.write_styled(w, &open, tag_color)?;
458
459    // Render type comment in muted color if present (context-aware)
460    if let Some(comment) = flavor.type_comment(tag) {
461        write!(w, " ")?;
462        opts.backend
463            .write_styled(w, &comment, syntax_color(SyntaxElement::Comment, change))?;
464    }
465
466    if has_attr_changes {
467        // Multi-line attribute format
468        writeln!(w)?;
469
470        // Render changed groups as -/+ line pairs
471        for group in changed_groups {
472            render_changed_group(layout, w, depth + 1, opts, flavor, attrs, group)?;
473        }
474
475        // Render deleted attributes (prefix uses indent gutter)
476        for (i, attr) in attrs.iter().enumerate() {
477            if let AttrStatus::Deleted { value } = &attr.status {
478                // Skip if already in a changed group
479                if changed_groups.iter().any(|g| g.attr_indices.contains(&i)) {
480                    continue;
481                }
482                write_indent_minus_prefix(w, depth + 1, opts)?;
483                opts.backend.write_prefix(w, '-', SemanticColor::Deleted)?;
484                write!(w, " ")?;
485                render_attr_deleted(layout, w, opts, flavor, &attr.name, value)?;
486                // Trailing comma (no highlight background)
487                opts.backend.write_styled(
488                    w,
489                    flavor.trailing_separator(),
490                    SemanticColor::Whitespace,
491                )?;
492                writeln!(w)?;
493            }
494        }
495
496        // Render inserted attributes (prefix uses indent gutter)
497        for (i, attr) in attrs.iter().enumerate() {
498            if let AttrStatus::Inserted { value } = &attr.status {
499                if changed_groups.iter().any(|g| g.attr_indices.contains(&i)) {
500                    continue;
501                }
502                write_indent_minus_prefix(w, depth + 1, opts)?;
503                opts.backend.write_prefix(w, '+', SemanticColor::Inserted)?;
504                write!(w, " ")?;
505                render_attr_inserted(layout, w, opts, flavor, &attr.name, value)?;
506                // Trailing comma (no highlight background)
507                opts.backend.write_styled(
508                    w,
509                    flavor.trailing_separator(),
510                    SemanticColor::Whitespace,
511                )?;
512                writeln!(w)?;
513            }
514        }
515
516        // Render unchanged attributes on one line
517        let unchanged: Vec<_> = attrs
518            .iter()
519            .filter(|a| matches!(a.status, AttrStatus::Unchanged { .. }))
520            .collect();
521        if !unchanged.is_empty() {
522            write_indent(w, depth + 1, opts)?;
523            for (i, attr) in unchanged.iter().enumerate() {
524                if i > 0 {
525                    write!(w, "{}", flavor.field_separator())?;
526                }
527                if let AttrStatus::Unchanged { value } = &attr.status {
528                    render_attr_unchanged(layout, w, opts, flavor, &attr.name, value)?;
529                }
530            }
531            // Trailing comma (no background)
532            opts.backend
533                .write_styled(w, flavor.trailing_separator(), SemanticColor::Whitespace)?;
534            writeln!(w)?;
535        }
536
537        // Closing bracket
538        write_indent(w, depth, opts)?;
539        if has_children {
540            let open_close = flavor.struct_open_close();
541            opts.backend.write_styled(w, open_close, tag_color)?;
542        } else {
543            let close = flavor.struct_close(tag, true);
544            opts.backend.write_styled(w, &close, tag_color)?;
545        }
546        writeln!(w)?;
547    } else if has_children && !attrs.is_empty() {
548        // Unchanged attributes with children: put attrs on their own lines
549        writeln!(w)?;
550        for attr in attrs.iter() {
551            write_indent(w, depth + 1, opts)?;
552            if let AttrStatus::Unchanged { value } = &attr.status {
553                render_attr_unchanged(layout, w, opts, flavor, &attr.name, value)?;
554            }
555            // Trailing comma (no background)
556            opts.backend
557                .write_styled(w, flavor.trailing_separator(), SemanticColor::Whitespace)?;
558            writeln!(w)?;
559        }
560        // Close the opening (e.g., ">" for XML) - only if non-empty
561        let open_close = flavor.struct_open_close();
562        if !open_close.is_empty() {
563            write_indent(w, depth, opts)?;
564            opts.backend.write_styled(w, open_close, tag_color)?;
565            writeln!(w)?;
566        }
567    } else {
568        // Inline attributes (no changes, no children) or no attrs
569        for (i, attr) in attrs.iter().enumerate() {
570            if i > 0 {
571                write!(w, "{}", flavor.field_separator())?;
572            } else {
573                write!(w, " ")?;
574            }
575            if let AttrStatus::Unchanged { value } = &attr.status {
576                render_attr_unchanged(layout, w, opts, flavor, &attr.name, value)?;
577            }
578        }
579
580        if has_children {
581            // Close the opening tag (e.g., ">" for XML)
582            let open_close = flavor.struct_open_close();
583            opts.backend.write_styled(w, open_close, tag_color)?;
584        } else {
585            // Self-closing
586            let close = flavor.struct_close(tag, true);
587            opts.backend.write_styled(w, &close, tag_color)?;
588        }
589        writeln!(w)?;
590    }
591
592    // Children
593    for child_id in children {
594        render_node(layout, w, child_id, depth + 1, opts, flavor)?;
595    }
596
597    // Closing tag (if we have children, we already printed opening part above)
598    if has_children {
599        write_indent(w, depth, opts)?;
600        if let Some(prefix) = change.prefix() {
601            opts.backend
602                .write_prefix(w, prefix, element_change_to_semantic(change))?;
603            write!(w, " ")?;
604        }
605        let close = flavor.struct_close(tag, false);
606        opts.backend.write_styled(w, &close, tag_color)?;
607        writeln!(w)?;
608    }
609
610    Ok(())
611}
612
613/// Render an element with all attrs on one line per -/+ row.
614/// This is used when all attrs fit on a single line for a more compact diff.
615#[allow(clippy::too_many_arguments)]
616fn render_inline_element<W: Write, B: ColorBackend, F: DiffFlavor>(
617    layout: &Layout,
618    w: &mut W,
619    depth: usize,
620    opts: &RenderOptions<B>,
621    flavor: &F,
622    tag: &str,
623    field_name: Option<&str>,
624    attrs: &[super::Attr],
625    info: &InlineElementInfo,
626) -> fmt::Result {
627    // Render field name prefix if present (for nested struct fields)
628    let field_prefix = field_name.map(|name| flavor.format_child_open(name));
629    let open = flavor.struct_open(tag);
630    let close = flavor.struct_close(tag, true);
631
632    // --- Before line (old values) ---
633    // Line background applies to structural parts, highlight background to changed values
634    // Use ← for "changed from" (vs - for "deleted entirely")
635    write_indent_minus_prefix(w, depth, opts)?;
636    opts.backend.write_prefix(w, '←', SemanticColor::Deleted)?;
637    opts.backend.write_styled(w, " ", SemanticColor::Deleted)?;
638
639    // Field name prefix (line bg)
640    if let Some(ref prefix) = field_prefix
641        && !prefix.is_empty()
642    {
643        opts.backend
644            .write_styled(w, prefix, SemanticColor::Deleted)?;
645    }
646
647    // Opening tag (line bg, with deleted context blending)
648    opts.backend
649        .write_styled(w, &open, SemanticColor::DeletedStructure)?;
650
651    // Attributes (old values or spaces for inserted)
652    for (i, (attr, &slot_width)) in attrs.iter().zip(info.slot_widths.iter()).enumerate() {
653        if i > 0 {
654            opts.backend
655                .write_styled(w, flavor.field_separator(), SemanticColor::Whitespace)?;
656        } else {
657            opts.backend.write_styled(w, " ", SemanticColor::Deleted)?;
658        }
659
660        let written = match &attr.status {
661            AttrStatus::Unchanged { value } => {
662                // Unchanged: context-aware colors for structural elements
663                opts.backend.write_styled(
664                    w,
665                    &flavor.format_field_prefix(&attr.name),
666                    SemanticColor::DeletedKey,
667                )?;
668                let val = layout.get_string(value.span);
669                let color = value_color(value.value_type, ElementChange::Deleted);
670                opts.backend.write_styled(w, val, color)?;
671                opts.backend.write_styled(
672                    w,
673                    flavor.format_field_suffix(),
674                    SemanticColor::DeletedStructure,
675                )?;
676                flavor.format_field_prefix(&attr.name).len()
677                    + value.width
678                    + flavor.format_field_suffix().len()
679            }
680            AttrStatus::Changed { old, .. } => {
681                // Changed: context-aware key color, highlight bg for value only
682                opts.backend.write_styled(
683                    w,
684                    &flavor.format_field_prefix(&attr.name),
685                    SemanticColor::DeletedKey,
686                )?;
687                let val = layout.get_string(old.span);
688                let color = value_color_highlight(old.value_type, ElementChange::Deleted);
689                opts.backend.write_styled(w, val, color)?;
690                opts.backend.write_styled(
691                    w,
692                    flavor.format_field_suffix(),
693                    SemanticColor::DeletedStructure,
694                )?;
695                flavor.format_field_prefix(&attr.name).len()
696                    + old.width
697                    + flavor.format_field_suffix().len()
698            }
699            AttrStatus::Deleted { value } => {
700                // Deleted entirely: highlight bg for key AND value
701                opts.backend.write_styled(
702                    w,
703                    &flavor.format_field_prefix(&attr.name),
704                    SemanticColor::DeletedHighlight,
705                )?;
706                let val = layout.get_string(value.span);
707                let color = value_color_highlight(value.value_type, ElementChange::Deleted);
708                opts.backend.write_styled(w, val, color)?;
709                opts.backend.write_styled(
710                    w,
711                    flavor.format_field_suffix(),
712                    SemanticColor::DeletedHighlight,
713                )?;
714                flavor.format_field_prefix(&attr.name).len()
715                    + value.width
716                    + flavor.format_field_suffix().len()
717            }
718            AttrStatus::Inserted { .. } => {
719                // Empty slot on minus line - show ∅ placeholder
720                opts.backend.write_styled(w, "∅", SemanticColor::Deleted)?;
721                1 // ∅ is 1 char wide
722            }
723        };
724
725        // Pad to slot width (line bg)
726        let padding = slot_width.saturating_sub(written);
727        if padding > 0 {
728            let spaces: String = " ".repeat(padding);
729            opts.backend
730                .write_styled(w, &spaces, SemanticColor::Whitespace)?;
731        }
732    }
733
734    // Closing (line bg, with deleted context blending)
735    opts.backend.write_styled(w, " ", SemanticColor::Deleted)?;
736    opts.backend
737        .write_styled(w, &close, SemanticColor::DeletedStructure)?;
738    writeln!(w)?;
739
740    // --- After line (new values) ---
741    // Use → for "changed to" (vs + for "inserted entirely")
742    write_indent_minus_prefix(w, depth, opts)?;
743    opts.backend.write_prefix(w, '→', SemanticColor::Inserted)?;
744    opts.backend.write_styled(w, " ", SemanticColor::Inserted)?;
745
746    // Field name prefix (line bg)
747    if let Some(ref prefix) = field_prefix
748        && !prefix.is_empty()
749    {
750        opts.backend
751            .write_styled(w, prefix, SemanticColor::Inserted)?;
752    }
753
754    // Opening tag (line bg, with inserted context blending)
755    opts.backend
756        .write_styled(w, &open, SemanticColor::InsertedStructure)?;
757
758    // Attributes (new values or spaces for deleted)
759    for (i, (attr, &slot_width)) in attrs.iter().zip(info.slot_widths.iter()).enumerate() {
760        if i > 0 {
761            opts.backend
762                .write_styled(w, flavor.field_separator(), SemanticColor::Whitespace)?;
763        } else {
764            opts.backend.write_styled(w, " ", SemanticColor::Inserted)?;
765        }
766
767        let written = match &attr.status {
768            AttrStatus::Unchanged { value } => {
769                // Unchanged: context-aware colors for structural elements
770                opts.backend.write_styled(
771                    w,
772                    &flavor.format_field_prefix(&attr.name),
773                    SemanticColor::InsertedKey,
774                )?;
775                let val = layout.get_string(value.span);
776                let color = value_color(value.value_type, ElementChange::Inserted);
777                opts.backend.write_styled(w, val, color)?;
778                opts.backend.write_styled(
779                    w,
780                    flavor.format_field_suffix(),
781                    SemanticColor::InsertedStructure,
782                )?;
783                flavor.format_field_prefix(&attr.name).len()
784                    + value.width
785                    + flavor.format_field_suffix().len()
786            }
787            AttrStatus::Changed { new, .. } => {
788                // Changed: context-aware key color, highlight bg for value only
789                opts.backend.write_styled(
790                    w,
791                    &flavor.format_field_prefix(&attr.name),
792                    SemanticColor::InsertedKey,
793                )?;
794                let val = layout.get_string(new.span);
795                let color = value_color_highlight(new.value_type, ElementChange::Inserted);
796                opts.backend.write_styled(w, val, color)?;
797                opts.backend.write_styled(
798                    w,
799                    flavor.format_field_suffix(),
800                    SemanticColor::InsertedStructure,
801                )?;
802                flavor.format_field_prefix(&attr.name).len()
803                    + new.width
804                    + flavor.format_field_suffix().len()
805            }
806            AttrStatus::Deleted { .. } => {
807                // Empty slot on plus line - show ∅ placeholder
808                opts.backend.write_styled(w, "∅", SemanticColor::Inserted)?;
809                1 // ∅ is 1 char wide
810            }
811            AttrStatus::Inserted { value } => {
812                // Inserted entirely: highlight bg for key AND value
813                opts.backend.write_styled(
814                    w,
815                    &flavor.format_field_prefix(&attr.name),
816                    SemanticColor::InsertedHighlight,
817                )?;
818                let val = layout.get_string(value.span);
819                let color = value_color_highlight(value.value_type, ElementChange::Inserted);
820                opts.backend.write_styled(w, val, color)?;
821                opts.backend.write_styled(
822                    w,
823                    flavor.format_field_suffix(),
824                    SemanticColor::InsertedHighlight,
825                )?;
826                flavor.format_field_prefix(&attr.name).len()
827                    + value.width
828                    + flavor.format_field_suffix().len()
829            }
830        };
831
832        // Pad to slot width (no background for spaces)
833        let padding = slot_width.saturating_sub(written);
834        if padding > 0 {
835            let spaces: String = " ".repeat(padding);
836            opts.backend
837                .write_styled(w, &spaces, SemanticColor::Whitespace)?;
838        }
839    }
840
841    // Closing (line bg, with inserted context blending)
842    opts.backend.write_styled(w, " ", SemanticColor::Inserted)?;
843    opts.backend
844        .write_styled(w, &close, SemanticColor::InsertedStructure)?;
845    writeln!(w)?;
846
847    Ok(())
848}
849
850#[allow(clippy::too_many_arguments)]
851fn render_sequence<W: Write, B: ColorBackend, F: DiffFlavor>(
852    layout: &Layout,
853    w: &mut W,
854    node_id: indextree::NodeId,
855    depth: usize,
856    opts: &RenderOptions<B>,
857    flavor: &F,
858    change: ElementChange,
859    _item_type: &str, // Item type available for future use (items use it via ItemGroup)
860    field_name: Option<&str>,
861) -> fmt::Result {
862    let children: Vec<_> = layout.children(node_id).collect();
863
864    let tag_color = match change {
865        ElementChange::None => SemanticColor::Structure,
866        ElementChange::Deleted => SemanticColor::DeletedStructure,
867        ElementChange::Inserted => SemanticColor::InsertedStructure,
868        ElementChange::MovedFrom | ElementChange::MovedTo => SemanticColor::Moved,
869    };
870
871    // Empty sequences: render on single line
872    if children.is_empty() {
873        // Always render empty sequences with field name (e.g., "elements: []")
874        // Only skip if unchanged AND no field name
875        if change == ElementChange::None && field_name.is_none() {
876            return Ok(());
877        }
878
879        write_indent(w, depth, opts)?;
880        if let Some(prefix) = change.prefix() {
881            opts.backend
882                .write_prefix(w, prefix, element_change_to_semantic(change))?;
883            write!(w, " ")?;
884        }
885
886        // Open and close with optional field name
887        if let Some(name) = field_name {
888            let open = flavor.format_seq_field_open(name);
889            let close = flavor.format_seq_field_close(name);
890            opts.backend.write_styled(w, &open, tag_color)?;
891            opts.backend.write_styled(w, &close, tag_color)?;
892        } else {
893            let open = flavor.seq_open();
894            let close = flavor.seq_close();
895            opts.backend.write_styled(w, &open, tag_color)?;
896            opts.backend.write_styled(w, &close, tag_color)?;
897        }
898
899        // Trailing comma for fields (context-aware)
900        if field_name.is_some() {
901            opts.backend
902                .write_styled(w, flavor.trailing_separator(), SemanticColor::Whitespace)?;
903        }
904        writeln!(w)?;
905        return Ok(());
906    }
907
908    // Opening bracket with optional field name
909    write_indent(w, depth, opts)?;
910    if let Some(prefix) = change.prefix() {
911        opts.backend
912            .write_prefix(w, prefix, element_change_to_semantic(change))?;
913        write!(w, " ")?;
914    }
915
916    // Open with optional field name
917    if let Some(name) = field_name {
918        let open = flavor.format_seq_field_open(name);
919        opts.backend.write_styled(w, &open, tag_color)?;
920    } else {
921        let open = flavor.seq_open();
922        opts.backend.write_styled(w, &open, tag_color)?;
923    }
924    writeln!(w)?;
925
926    // Children
927    for child_id in children {
928        render_node(layout, w, child_id, depth + 1, opts, flavor)?;
929    }
930
931    // Closing bracket
932    write_indent(w, depth, opts)?;
933    if let Some(prefix) = change.prefix() {
934        opts.backend
935            .write_prefix(w, prefix, element_change_to_semantic(change))?;
936        write!(w, " ")?;
937    }
938
939    // Close with optional field name
940    if let Some(name) = field_name {
941        let close = flavor.format_seq_field_close(name);
942        opts.backend.write_styled(w, &close, tag_color)?;
943    } else {
944        let close = flavor.seq_close();
945        opts.backend.write_styled(w, &close, tag_color)?;
946    }
947
948    // Trailing comma for fields (context-aware)
949    if field_name.is_some() {
950        opts.backend
951            .write_styled(w, flavor.trailing_separator(), SemanticColor::Whitespace)?;
952    }
953    writeln!(w)?;
954
955    Ok(())
956}
957
958fn render_changed_group<W: Write, B: ColorBackend, F: DiffFlavor>(
959    layout: &Layout,
960    w: &mut W,
961    depth: usize,
962    opts: &RenderOptions<B>,
963    flavor: &F,
964    attrs: &[super::Attr],
965    group: &ChangedGroup,
966) -> fmt::Result {
967    // Before line - use ← for "changed from" (prefix uses indent gutter)
968    write_indent_minus_prefix(w, depth, opts)?;
969    opts.backend.write_prefix(w, '←', SemanticColor::Deleted)?;
970    write!(w, " ")?;
971
972    let last_idx = group.attr_indices.len().saturating_sub(1);
973    for (i, &idx) in group.attr_indices.iter().enumerate() {
974        if i > 0 {
975            write!(w, "{}", flavor.field_separator())?;
976        }
977        let attr = &attrs[idx];
978        if let AttrStatus::Changed { old, new } = &attr.status {
979            // Each field padded to max of its own old/new value width
980            let field_max_width = old.width.max(new.width);
981            // Use context-aware key color for field prefix (line bg)
982            opts.backend.write_styled(
983                w,
984                &flavor.format_field_prefix(&attr.name),
985                SemanticColor::DeletedKey,
986            )?;
987            // Changed value uses highlight background for contrast
988            let old_str = layout.get_string(old.span);
989            let color = value_color_highlight(old.value_type, ElementChange::Deleted);
990            opts.backend.write_styled(w, old_str, color)?;
991            // Use context-aware structure color for field suffix (line bg)
992            opts.backend.write_styled(
993                w,
994                flavor.format_field_suffix(),
995                SemanticColor::DeletedStructure,
996            )?;
997            // Pad to align with the + line's value (only between fields, not at end)
998            if i < last_idx {
999                let value_padding = field_max_width.saturating_sub(old.width);
1000                for _ in 0..value_padding {
1001                    write!(w, " ")?;
1002                }
1003            }
1004        }
1005    }
1006    writeln!(w)?;
1007
1008    // After line - use → for "changed to" (prefix uses indent gutter)
1009    write_indent_minus_prefix(w, depth, opts)?;
1010    opts.backend.write_prefix(w, '→', SemanticColor::Inserted)?;
1011    write!(w, " ")?;
1012
1013    for (i, &idx) in group.attr_indices.iter().enumerate() {
1014        if i > 0 {
1015            write!(w, "{}", flavor.field_separator())?;
1016        }
1017        let attr = &attrs[idx];
1018        if let AttrStatus::Changed { old, new } = &attr.status {
1019            // Each field padded to max of its own old/new value width
1020            let field_max_width = old.width.max(new.width);
1021            // Use context-aware key color for field prefix (line bg)
1022            opts.backend.write_styled(
1023                w,
1024                &flavor.format_field_prefix(&attr.name),
1025                SemanticColor::InsertedKey,
1026            )?;
1027            // Changed value uses highlight background for contrast
1028            let new_str = layout.get_string(new.span);
1029            let color = value_color_highlight(new.value_type, ElementChange::Inserted);
1030            opts.backend.write_styled(w, new_str, color)?;
1031            // Use context-aware structure color for field suffix (line bg)
1032            opts.backend.write_styled(
1033                w,
1034                flavor.format_field_suffix(),
1035                SemanticColor::InsertedStructure,
1036            )?;
1037            // Pad to align with the - line's value (only between fields, not at end)
1038            if i < last_idx {
1039                let value_padding = field_max_width.saturating_sub(new.width);
1040                for _ in 0..value_padding {
1041                    write!(w, " ")?;
1042                }
1043            }
1044        }
1045    }
1046    writeln!(w)?;
1047
1048    Ok(())
1049}
1050
1051fn render_attr_unchanged<W: Write, B: ColorBackend, F: DiffFlavor>(
1052    layout: &Layout,
1053    w: &mut W,
1054    opts: &RenderOptions<B>,
1055    flavor: &F,
1056    name: &str,
1057    value: &super::FormattedValue,
1058) -> fmt::Result {
1059    let value_str = layout.get_string(value.span);
1060    let formatted = flavor.format_field(name, value_str);
1061    opts.backend
1062        .write_styled(w, &formatted, SemanticColor::Unchanged)
1063}
1064
1065fn render_attr_deleted<W: Write, B: ColorBackend, F: DiffFlavor>(
1066    layout: &Layout,
1067    w: &mut W,
1068    opts: &RenderOptions<B>,
1069    flavor: &F,
1070    name: &str,
1071    value: &super::FormattedValue,
1072) -> fmt::Result {
1073    let value_str = layout.get_string(value.span);
1074    // Entire field uses highlight background for deleted (better contrast)
1075    let formatted = flavor.format_field(name, value_str);
1076    opts.backend
1077        .write_styled(w, &formatted, SemanticColor::DeletedHighlight)
1078}
1079
1080fn render_attr_inserted<W: Write, B: ColorBackend, F: DiffFlavor>(
1081    layout: &Layout,
1082    w: &mut W,
1083    opts: &RenderOptions<B>,
1084    flavor: &F,
1085    name: &str,
1086    value: &super::FormattedValue,
1087) -> fmt::Result {
1088    let value_str = layout.get_string(value.span);
1089    // Entire field uses highlight background for inserted (better contrast)
1090    let formatted = flavor.format_field(name, value_str);
1091    opts.backend
1092        .write_styled(w, &formatted, SemanticColor::InsertedHighlight)
1093}
1094
1095fn write_indent<W: Write, B: ColorBackend>(
1096    w: &mut W,
1097    depth: usize,
1098    opts: &RenderOptions<B>,
1099) -> fmt::Result {
1100    for _ in 0..depth {
1101        write!(w, "{}", opts.indent)?;
1102    }
1103    Ok(())
1104}
1105
1106/// Write indent minus 2 characters for the prefix gutter.
1107/// The "- " or "+ " prefix will occupy those 2 characters.
1108fn write_indent_minus_prefix<W: Write, B: ColorBackend>(
1109    w: &mut W,
1110    depth: usize,
1111    opts: &RenderOptions<B>,
1112) -> fmt::Result {
1113    let total_indent = depth * opts.indent.len();
1114    let gutter_indent = total_indent.saturating_sub(2);
1115    for _ in 0..gutter_indent {
1116        write!(w, " ")?;
1117    }
1118    Ok(())
1119}
1120
1121#[cfg(test)]
1122mod tests {
1123    use indextree::Arena;
1124
1125    use super::*;
1126    use crate::layout::{Attr, FormatArena, FormattedValue, Layout, LayoutNode, XmlFlavor};
1127
1128    fn make_test_layout() -> Layout {
1129        let mut strings = FormatArena::new();
1130        let tree = Arena::new();
1131
1132        // Create a simple element with one changed attribute
1133        let (red_span, red_width) = strings.push_str("red");
1134        let (blue_span, blue_width) = strings.push_str("blue");
1135
1136        let fill_attr = Attr::changed(
1137            "fill",
1138            4,
1139            FormattedValue::new(red_span, red_width),
1140            FormattedValue::new(blue_span, blue_width),
1141        );
1142
1143        let attrs = vec![fill_attr];
1144        let changed_groups = super::super::group_changed_attrs(&attrs, 80, 0);
1145
1146        let root = LayoutNode::Element {
1147            tag: "rect",
1148            field_name: None,
1149            attrs,
1150            changed_groups,
1151            change: ElementChange::None,
1152        };
1153
1154        Layout::new(strings, tree, root)
1155    }
1156
1157    #[test]
1158    fn test_render_simple_change() {
1159        let layout = make_test_layout();
1160        let opts = RenderOptions::plain();
1161        let output = render_to_string(&layout, &opts, &XmlFlavor);
1162
1163        // With inline element diff, the format uses ← / → for changed state:
1164        // ← <rect fill="red"  />
1165        // → <rect fill="blue" />
1166        assert!(output.contains("← <rect fill=\"red\""));
1167        assert!(output.contains("→ <rect fill=\"blue\""));
1168        assert!(output.contains("/>"));
1169    }
1170
1171    #[test]
1172    fn test_render_collapsed() {
1173        let strings = FormatArena::new();
1174        let tree = Arena::new();
1175
1176        let root = LayoutNode::collapsed(5);
1177        let layout = Layout::new(strings, tree, root);
1178
1179        let opts = RenderOptions::plain();
1180        let output = render_to_string(&layout, &opts, &XmlFlavor);
1181
1182        assert!(output.contains("<!-- 5 unchanged -->"));
1183    }
1184
1185    #[test]
1186    fn test_render_with_children() {
1187        let mut strings = FormatArena::new();
1188        let mut tree = Arena::new();
1189
1190        // Parent element
1191        let parent = tree.new_node(LayoutNode::Element {
1192            tag: "svg",
1193            field_name: None,
1194            attrs: vec![],
1195            changed_groups: vec![],
1196            change: ElementChange::None,
1197        });
1198
1199        // Child element with change
1200        let (red_span, red_width) = strings.push_str("red");
1201        let (blue_span, blue_width) = strings.push_str("blue");
1202
1203        let fill_attr = Attr::changed(
1204            "fill",
1205            4,
1206            FormattedValue::new(red_span, red_width),
1207            FormattedValue::new(blue_span, blue_width),
1208        );
1209        let attrs = vec![fill_attr];
1210        let changed_groups = super::super::group_changed_attrs(&attrs, 80, 0);
1211
1212        let child = tree.new_node(LayoutNode::Element {
1213            tag: "rect",
1214            field_name: None,
1215            attrs,
1216            changed_groups,
1217            change: ElementChange::None,
1218        });
1219
1220        parent.append(child, &mut tree);
1221
1222        let layout = Layout {
1223            strings,
1224            tree,
1225            root: parent,
1226        };
1227
1228        let opts = RenderOptions::plain();
1229        let output = render_to_string(&layout, &opts, &XmlFlavor);
1230
1231        assert!(output.contains("<svg>"));
1232        assert!(output.contains("</svg>"));
1233        assert!(output.contains("<rect"));
1234    }
1235
1236    #[test]
1237    fn test_ansi_backend_produces_escapes() {
1238        let layout = make_test_layout();
1239        let opts = RenderOptions::default();
1240        let output = render_to_string(&layout, &opts, &XmlFlavor);
1241
1242        // Should contain ANSI escape codes
1243        assert!(
1244            output.contains("\x1b["),
1245            "output should contain ANSI escapes"
1246        );
1247    }
1248}