• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

facet-rs / facet / 20108512964

10 Dec 2025 06:03PM UTC coverage: 57.724% (-0.8%) from 58.573%
20108512964

Pull #1220

github

web-flow
Merge c9c41f0b0 into fe1531898
Pull Request #1220: feat(facet & cinereus): improve edit scripts, syntax highlighting, and diff visualization

1769 of 3627 new or added lines in 18 files covered. (48.77%)

12 existing lines in 1 file now uncovered.

28569 of 49492 relevant lines covered (57.72%)

808.56 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

43.08
/facet-diff-core/src/layout/render.rs
1
//! Layout rendering to output.
2

3
use std::fmt::{self, Write};
4

5
use super::backend::{AnsiBackend, ColorBackend, PlainBackend, SemanticColor};
6
use super::flavor::DiffFlavor;
7
use super::{AttrStatus, ChangedGroup, ElementChange, Layout, LayoutNode, ValueType};
8
use crate::DiffSymbols;
9

10
/// Syntax element type for context-aware coloring.
11
#[derive(Clone, Copy)]
12
#[allow(dead_code)]
13
enum SyntaxElement {
14
    Key,
15
    Structure,
16
    Comment,
17
}
18

19
/// Get the appropriate semantic color for a syntax element in a given context.
NEW
20
fn syntax_color(base: SyntaxElement, context: ElementChange) -> SemanticColor {
×
NEW
21
    match (base, context) {
×
NEW
22
        (SyntaxElement::Key, ElementChange::Deleted) => SemanticColor::DeletedKey,
×
NEW
23
        (SyntaxElement::Key, ElementChange::Inserted) => SemanticColor::InsertedKey,
×
NEW
24
        (SyntaxElement::Key, _) => SemanticColor::Key,
×
25

NEW
26
        (SyntaxElement::Structure, ElementChange::Deleted) => SemanticColor::DeletedStructure,
×
NEW
27
        (SyntaxElement::Structure, ElementChange::Inserted) => SemanticColor::InsertedStructure,
×
NEW
28
        (SyntaxElement::Structure, _) => SemanticColor::Structure,
×
29

NEW
30
        (SyntaxElement::Comment, ElementChange::Deleted) => SemanticColor::DeletedComment,
×
NEW
31
        (SyntaxElement::Comment, ElementChange::Inserted) => SemanticColor::InsertedComment,
×
NEW
32
        (SyntaxElement::Comment, _) => SemanticColor::Comment,
×
33
    }
NEW
34
}
×
35

36
/// Get the appropriate semantic color for a value based on its type and context.
37
fn value_color(value_type: ValueType, context: ElementChange) -> SemanticColor {
2✔
38
    match (value_type, context) {
2✔
NEW
39
        (ValueType::String, ElementChange::Deleted) => SemanticColor::DeletedString,
×
NEW
40
        (ValueType::String, ElementChange::Inserted) => SemanticColor::InsertedString,
×
NEW
41
        (ValueType::String, _) => SemanticColor::String,
×
42

43
        (ValueType::Number, ElementChange::Deleted) => SemanticColor::DeletedNumber,
1✔
44
        (ValueType::Number, ElementChange::Inserted) => SemanticColor::InsertedNumber,
1✔
NEW
45
        (ValueType::Number, _) => SemanticColor::Number,
×
46

NEW
47
        (ValueType::Boolean, ElementChange::Deleted) => SemanticColor::DeletedBoolean,
×
NEW
48
        (ValueType::Boolean, ElementChange::Inserted) => SemanticColor::InsertedBoolean,
×
NEW
49
        (ValueType::Boolean, _) => SemanticColor::Boolean,
×
50

NEW
51
        (ValueType::Null, ElementChange::Deleted) => SemanticColor::DeletedNull,
×
NEW
52
        (ValueType::Null, ElementChange::Inserted) => SemanticColor::InsertedNull,
×
NEW
53
        (ValueType::Null, _) => SemanticColor::Null,
×
54

55
        // Other/unknown types use accent colors
NEW
56
        (ValueType::Other, ElementChange::Deleted) => SemanticColor::Deleted,
×
NEW
57
        (ValueType::Other, ElementChange::Inserted) => SemanticColor::Inserted,
×
58
        (ValueType::Other, ElementChange::MovedFrom)
NEW
59
        | (ValueType::Other, ElementChange::MovedTo) => SemanticColor::Moved,
×
NEW
60
        (ValueType::Other, ElementChange::None) => SemanticColor::Unchanged,
×
61
    }
62
}
2✔
63

64
/// Get semantic color for highlight background (changed values).
65
fn value_color_highlight(value_type: ValueType, context: ElementChange) -> SemanticColor {
6✔
66
    match (value_type, context) {
6✔
NEW
67
        (ValueType::String, ElementChange::Deleted) => SemanticColor::DeletedString,
×
NEW
68
        (ValueType::String, ElementChange::Inserted) => SemanticColor::InsertedString,
×
69

NEW
70
        (ValueType::Number, ElementChange::Deleted) => SemanticColor::DeletedNumber,
×
NEW
71
        (ValueType::Number, ElementChange::Inserted) => SemanticColor::InsertedNumber,
×
72

NEW
73
        (ValueType::Boolean, ElementChange::Deleted) => SemanticColor::DeletedBoolean,
×
NEW
74
        (ValueType::Boolean, ElementChange::Inserted) => SemanticColor::InsertedBoolean,
×
75

NEW
76
        (ValueType::Null, ElementChange::Deleted) => SemanticColor::DeletedNull,
×
NEW
77
        (ValueType::Null, ElementChange::Inserted) => SemanticColor::InsertedNull,
×
78

79
        // Highlight uses generic highlights for Other/unchanged
80
        (_, ElementChange::Deleted) => SemanticColor::DeletedHighlight,
3✔
81
        (_, ElementChange::Inserted) => SemanticColor::InsertedHighlight,
3✔
82
        (_, ElementChange::MovedFrom) | (_, ElementChange::MovedTo) => {
NEW
83
            SemanticColor::MovedHighlight
×
84
        }
NEW
85
        _ => SemanticColor::Unchanged,
×
86
    }
87
}
6✔
88

89
/// Information for inline element diff rendering.
90
/// When all attributes fit on one line, we render the full element on each -/+ line.
91
struct InlineElementInfo {
92
    /// Width of each attr slot (padded to max of old/new values)
93
    slot_widths: Vec<usize>,
94
}
95

96
impl 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>(
3✔
100
        attrs: &[super::Attr],
3✔
101
        tag: &str,
3✔
102
        flavor: &F,
3✔
103
        max_line_width: usize,
3✔
104
        indent_width: usize,
3✔
105
    ) -> Option<Self> {
3✔
106
        if attrs.is_empty() {
3✔
NEW
107
            return None;
×
108
        }
3✔
109

110
        let mut slot_widths = Vec::with_capacity(attrs.len());
3✔
111
        let mut total_width = 0usize;
3✔
112

113
        // struct_open (e.g., "<Point" or "Point {")
114
        total_width += flavor.struct_open(tag).len();
3✔
115

116
        for (i, attr) in attrs.iter().enumerate() {
3✔
117
            // Space or separator before attr
118
            if i > 0 {
3✔
NEW
119
                total_width += flavor.field_separator().len();
×
120
            } else {
3✔
121
                total_width += 1; // space after opening
3✔
122
            }
3✔
123

124
            // Calculate slot width for this attr (max of old/new/both)
125
            let slot_width = match &attr.status {
3✔
NEW
126
                AttrStatus::Unchanged { value } => {
×
127
                    // name="value" -> prefix + value + suffix
NEW
128
                    flavor.format_field_prefix(&attr.name).len()
×
NEW
129
                        + value.width
×
NEW
130
                        + flavor.format_field_suffix().len()
×
131
                }
132
                AttrStatus::Changed { old, new } => {
3✔
133
                    let max_val = old.width.max(new.width);
3✔
134
                    flavor.format_field_prefix(&attr.name).len()
3✔
135
                        + max_val
3✔
136
                        + flavor.format_field_suffix().len()
3✔
137
                }
NEW
138
                AttrStatus::Deleted { value } => {
×
NEW
139
                    flavor.format_field_prefix(&attr.name).len()
×
NEW
140
                        + value.width
×
NEW
141
                        + flavor.format_field_suffix().len()
×
142
                }
NEW
143
                AttrStatus::Inserted { value } => {
×
NEW
144
                    flavor.format_field_prefix(&attr.name).len()
×
NEW
145
                        + value.width
×
NEW
146
                        + flavor.format_field_suffix().len()
×
147
                }
148
            };
149

150
            slot_widths.push(slot_width);
3✔
151
            total_width += slot_width;
3✔
152
        }
153

154
        // struct_close (e.g., "/>" or "}")
155
        total_width += 1; // space before close for XML
3✔
156
        total_width += flavor.struct_close(tag, true).len();
3✔
157

158
        // Check if it fits (account for "- " prefix and indent)
159
        let available = max_line_width.saturating_sub(indent_width + 2);
3✔
160
        if total_width > available {
3✔
NEW
161
            return None;
×
162
        }
3✔
163

164
        Some(Self { slot_widths })
3✔
165
    }
3✔
166
}
167

168
/// Options for rendering a layout.
169
#[derive(Clone, Debug)]
170
pub 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

179
impl Default for RenderOptions<AnsiBackend> {
180
    fn default() -> Self {
1✔
181
        Self {
1✔
182
            symbols: DiffSymbols::default(),
1✔
183
            backend: AnsiBackend::default(),
1✔
184
            indent: "    ",
1✔
185
        }
1✔
186
    }
1✔
187
}
188

189
impl RenderOptions<PlainBackend> {
190
    /// Create options with plain backend (no colors).
191
    pub fn plain() -> Self {
4✔
192
        Self {
4✔
193
            symbols: DiffSymbols::default(),
4✔
194
            backend: PlainBackend,
4✔
195
            indent: "    ",
4✔
196
        }
4✔
197
    }
4✔
198
}
199

200
impl<B: ColorBackend> RenderOptions<B> {
201
    /// Create options with a custom backend.
NEW
202
    pub fn with_backend(backend: B) -> Self {
×
NEW
203
        Self {
×
NEW
204
            symbols: DiffSymbols::default(),
×
NEW
205
            backend,
×
NEW
206
            indent: "    ",
×
NEW
207
        }
×
NEW
208
    }
×
209
}
210

211
/// Render a layout to a writer.
212
///
213
/// Starts at depth 1 to provide a gutter for change prefixes (- / +).
214
pub fn render<W: Write, B: ColorBackend, F: DiffFlavor>(
5✔
215
    layout: &Layout,
5✔
216
    w: &mut W,
5✔
217
    opts: &RenderOptions<B>,
5✔
218
    flavor: &F,
5✔
219
) -> fmt::Result {
5✔
220
    render_node(layout, w, layout.root, 1, opts, flavor)
5✔
221
}
5✔
222

223
/// Render a layout to a String.
224
pub fn render_to_string<B: ColorBackend, F: DiffFlavor>(
5✔
225
    layout: &Layout,
5✔
226
    opts: &RenderOptions<B>,
5✔
227
    flavor: &F,
5✔
228
) -> String {
5✔
229
    let mut out = String::new();
5✔
230
    render(layout, &mut out, opts, flavor).expect("writing to String cannot fail");
5✔
231
    out
5✔
232
}
5✔
233

234
fn element_change_to_semantic(change: ElementChange) -> SemanticColor {
2✔
235
    match change {
2✔
NEW
236
        ElementChange::None => SemanticColor::Unchanged,
×
237
        ElementChange::Deleted => SemanticColor::Deleted,
1✔
238
        ElementChange::Inserted => SemanticColor::Inserted,
1✔
NEW
239
        ElementChange::MovedFrom | ElementChange::MovedTo => SemanticColor::Moved,
×
240
    }
241
}
2✔
242

243
fn render_node<W: Write, B: ColorBackend, F: DiffFlavor>(
8✔
244
    layout: &Layout,
8✔
245
    w: &mut W,
8✔
246
    node_id: indextree::NodeId,
8✔
247
    depth: usize,
8✔
248
    opts: &RenderOptions<B>,
8✔
249
    flavor: &F,
8✔
250
) -> fmt::Result {
8✔
251
    let node = layout.get(node_id).expect("node exists");
8✔
252

253
    match node {
8✔
254
        LayoutNode::Element {
255
            tag,
5✔
256
            field_name,
5✔
257
            attrs,
5✔
258
            changed_groups,
5✔
259
            change,
5✔
260
        } => {
261
            let tag = *tag;
5✔
262
            let field_name = *field_name;
5✔
263
            let change = *change;
5✔
264
            let attrs = attrs.clone();
5✔
265
            let changed_groups = changed_groups.clone();
5✔
266

267
            render_element(
5✔
268
                layout,
5✔
269
                w,
5✔
270
                node_id,
5✔
271
                depth,
5✔
272
                opts,
5✔
273
                flavor,
5✔
274
                tag,
5✔
275
                field_name,
5✔
276
                &attrs,
5✔
277
                &changed_groups,
5✔
278
                change,
5✔
279
            )
280
        }
281

282
        LayoutNode::Sequence {
NEW
283
            change,
×
NEW
284
            item_type,
×
NEW
285
            field_name,
×
286
        } => {
NEW
287
            let change = *change;
×
NEW
288
            let item_type = *item_type;
×
NEW
289
            let field_name = *field_name;
×
NEW
290
            render_sequence(
×
NEW
291
                layout, w, node_id, depth, opts, flavor, change, item_type, field_name,
×
292
            )
293
        }
294

295
        LayoutNode::Collapsed { count } => {
1✔
296
            let count = *count;
1✔
297
            write_indent(w, depth, opts)?;
1✔
298
            let comment = flavor.comment(&format!("{} unchanged", count));
1✔
299
            opts.backend
1✔
300
                .write_styled(w, &comment, SemanticColor::Comment)?;
1✔
301
            writeln!(w)
1✔
302
        }
303

304
        LayoutNode::Text { value, change } => {
2✔
305
            let text = layout.get_string(value.span);
2✔
306
            let change = *change;
2✔
307

308
            write_indent(w, depth, opts)?;
2✔
309
            if let Some(prefix) = change.prefix() {
2✔
310
                opts.backend
2✔
311
                    .write_prefix(w, prefix, element_change_to_semantic(change))?;
2✔
312
                write!(w, " ")?;
2✔
NEW
313
            }
×
314

315
            let semantic = value_color(value.value_type, change);
2✔
316
            opts.backend.write_styled(w, text, semantic)?;
2✔
317
            writeln!(w)
2✔
318
        }
319

320
        LayoutNode::ItemGroup {
NEW
321
            items,
×
NEW
322
            change,
×
NEW
323
            collapsed_suffix,
×
NEW
324
            item_type,
×
325
        } => {
NEW
326
            let items = items.clone();
×
NEW
327
            let change = *change;
×
NEW
328
            let collapsed_suffix = *collapsed_suffix;
×
NEW
329
            let item_type = *item_type;
×
330

331
            // For changed items, the prefix eats into the indent (goes in the "gutter")
NEW
332
            if let Some(prefix) = change.prefix() {
×
333
                // Write indent minus 2 chars, then prefix + space
NEW
334
                write_indent_minus_prefix(w, depth, opts)?;
×
NEW
335
                opts.backend
×
NEW
336
                    .write_prefix(w, prefix, element_change_to_semantic(change))?;
×
NEW
337
                write!(w, " ")?;
×
338
            } else {
NEW
339
                write_indent(w, depth, opts)?;
×
340
            }
341

342
            // Render items with flavor separator and optional wrapping
NEW
343
            for (i, item) in items.iter().enumerate() {
×
NEW
344
                if i > 0 {
×
NEW
345
                    write!(w, "{}", flavor.item_separator())?;
×
NEW
346
                }
×
NEW
347
                let raw_value = layout.get_string(item.span);
×
NEW
348
                let formatted = flavor.format_seq_item(item_type, raw_value);
×
NEW
349
                let semantic = value_color(item.value_type, change);
×
NEW
350
                opts.backend.write_styled(w, &formatted, semantic)?;
×
351
            }
352

353
            // Render collapsed suffix if present (context-aware)
NEW
354
            if let Some(count) = collapsed_suffix {
×
NEW
355
                let suffix = flavor.comment(&format!("{} more", count));
×
NEW
356
                write!(w, " ")?;
×
NEW
357
                opts.backend.write_styled(
×
NEW
358
                    w,
×
NEW
359
                    &suffix,
×
NEW
360
                    syntax_color(SyntaxElement::Comment, change),
×
NEW
361
                )?;
×
NEW
362
            }
×
363

NEW
364
            writeln!(w)
×
365
        }
366
    }
367
}
8✔
368

369
#[allow(clippy::too_many_arguments)]
370
fn render_element<W: Write, B: ColorBackend, F: DiffFlavor>(
5✔
371
    layout: &Layout,
5✔
372
    w: &mut W,
5✔
373
    node_id: indextree::NodeId,
5✔
374
    depth: usize,
5✔
375
    opts: &RenderOptions<B>,
5✔
376
    flavor: &F,
5✔
377
    tag: &str,
5✔
378
    field_name: Option<&str>,
5✔
379
    attrs: &[super::Attr],
5✔
380
    changed_groups: &[ChangedGroup],
5✔
381
    change: ElementChange,
5✔
382
) -> fmt::Result {
5✔
383
    let has_attr_changes = !changed_groups.is_empty()
5✔
384
        || attrs.iter().any(|a| {
2✔
NEW
385
            matches!(
×
NEW
386
                a.status,
×
387
                AttrStatus::Deleted { .. } | AttrStatus::Inserted { .. }
388
            )
NEW
389
        });
×
390

391
    let children: Vec<_> = layout.children(node_id).collect();
5✔
392
    let has_children = !children.is_empty();
5✔
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 {
5✔
400
        let indent_width = depth * opts.indent.len();
3✔
401
        if let Some(info) = InlineElementInfo::calculate(attrs, tag, flavor, 80, indent_width) {
3✔
402
            return render_inline_element(
3✔
403
                layout, w, depth, opts, flavor, tag, field_name, attrs, &info,
3✔
404
            );
NEW
405
        }
×
406
    }
2✔
407

408
    let tag_color = match change {
2✔
409
        ElementChange::None => SemanticColor::Structure,
2✔
NEW
410
        ElementChange::Deleted => SemanticColor::DeletedStructure,
×
NEW
411
        ElementChange::Inserted => SemanticColor::InsertedStructure,
×
NEW
412
        ElementChange::MovedFrom | ElementChange::MovedTo => SemanticColor::Moved,
×
413
    };
414

415
    // Opening tag/struct
416
    write_indent(w, depth, opts)?;
2✔
417
    if let Some(prefix) = change.prefix() {
2✔
NEW
418
        opts.backend
×
NEW
419
            .write_prefix(w, prefix, element_change_to_semantic(change))?;
×
NEW
420
        write!(w, " ")?;
×
421
    }
2✔
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 {
2✔
NEW
428
        let prefix = flavor.format_child_open(name);
×
NEW
429
        if !prefix.is_empty() {
×
NEW
430
            opts.backend
×
NEW
431
                .write_styled(w, &prefix, SemanticColor::Unchanged)?;
×
NEW
432
        }
×
433
    }
2✔
434

435
    let open = flavor.struct_open(tag);
2✔
436
    opts.backend.write_styled(w, &open, tag_color)?;
2✔
437

438
    // Render type comment in muted color if present (context-aware)
439
    if let Some(comment) = flavor.type_comment(tag) {
2✔
NEW
440
        write!(w, " ")?;
×
NEW
441
        opts.backend
×
NEW
442
            .write_styled(w, &comment, syntax_color(SyntaxElement::Comment, change))?;
×
443
    }
2✔
444

445
    if has_attr_changes {
2✔
446
        // Multi-line attribute format
NEW
447
        writeln!(w)?;
×
448

449
        // Render changed groups as -/+ line pairs
NEW
450
        for group in changed_groups {
×
NEW
451
            render_changed_group(layout, w, depth + 1, opts, flavor, attrs, group)?;
×
452
        }
453

454
        // Render deleted attributes (prefix uses indent gutter)
NEW
455
        for (i, attr) in attrs.iter().enumerate() {
×
NEW
456
            if let AttrStatus::Deleted { value } = &attr.status {
×
457
                // Skip if already in a changed group
NEW
458
                if changed_groups.iter().any(|g| g.attr_indices.contains(&i)) {
×
NEW
459
                    continue;
×
NEW
460
                }
×
NEW
461
                write_indent_minus_prefix(w, depth + 1, opts)?;
×
NEW
462
                opts.backend.write_prefix(w, '-', SemanticColor::Deleted)?;
×
NEW
463
                write!(w, " ")?;
×
NEW
464
                render_attr_deleted(layout, w, opts, flavor, &attr.name, value)?;
×
465
                // Trailing comma (no highlight background)
NEW
466
                opts.backend.write_styled(
×
NEW
467
                    w,
×
NEW
468
                    flavor.trailing_separator(),
×
NEW
469
                    SemanticColor::Whitespace,
×
NEW
470
                )?;
×
NEW
471
                writeln!(w)?;
×
NEW
472
            }
×
473
        }
474

475
        // Render inserted attributes (prefix uses indent gutter)
NEW
476
        for (i, attr) in attrs.iter().enumerate() {
×
NEW
477
            if let AttrStatus::Inserted { value } = &attr.status {
×
NEW
478
                if changed_groups.iter().any(|g| g.attr_indices.contains(&i)) {
×
NEW
479
                    continue;
×
NEW
480
                }
×
NEW
481
                write_indent_minus_prefix(w, depth + 1, opts)?;
×
NEW
482
                opts.backend.write_prefix(w, '+', SemanticColor::Inserted)?;
×
NEW
483
                write!(w, " ")?;
×
NEW
484
                render_attr_inserted(layout, w, opts, flavor, &attr.name, value)?;
×
485
                // Trailing comma (no highlight background)
NEW
486
                opts.backend.write_styled(
×
NEW
487
                    w,
×
NEW
488
                    flavor.trailing_separator(),
×
NEW
489
                    SemanticColor::Whitespace,
×
NEW
490
                )?;
×
NEW
491
                writeln!(w)?;
×
NEW
492
            }
×
493
        }
494

495
        // Render unchanged attributes on one line
NEW
496
        let unchanged: Vec<_> = attrs
×
NEW
497
            .iter()
×
NEW
498
            .filter(|a| matches!(a.status, AttrStatus::Unchanged { .. }))
×
NEW
499
            .collect();
×
NEW
500
        if !unchanged.is_empty() {
×
NEW
501
            write_indent(w, depth + 1, opts)?;
×
NEW
502
            for (i, attr) in unchanged.iter().enumerate() {
×
NEW
503
                if i > 0 {
×
NEW
504
                    write!(w, "{}", flavor.field_separator())?;
×
NEW
505
                }
×
NEW
506
                if let AttrStatus::Unchanged { value } = &attr.status {
×
NEW
507
                    render_attr_unchanged(layout, w, opts, flavor, &attr.name, value)?;
×
NEW
508
                }
×
509
            }
510
            // Trailing comma (no background)
NEW
511
            opts.backend
×
NEW
512
                .write_styled(w, flavor.trailing_separator(), SemanticColor::Whitespace)?;
×
NEW
513
            writeln!(w)?;
×
NEW
514
        }
×
515

516
        // Closing bracket
NEW
517
        write_indent(w, depth, opts)?;
×
NEW
518
        if has_children {
×
NEW
519
            let open_close = flavor.struct_open_close();
×
NEW
520
            opts.backend.write_styled(w, open_close, tag_color)?;
×
521
        } else {
NEW
522
            let close = flavor.struct_close(tag, true);
×
NEW
523
            opts.backend.write_styled(w, &close, tag_color)?;
×
524
        }
NEW
525
        writeln!(w)?;
×
526
    } else if has_children && !attrs.is_empty() {
2✔
527
        // Unchanged attributes with children: put attrs on their own lines
NEW
528
        writeln!(w)?;
×
NEW
529
        for attr in attrs.iter() {
×
NEW
530
            write_indent(w, depth + 1, opts)?;
×
NEW
531
            if let AttrStatus::Unchanged { value } = &attr.status {
×
NEW
532
                render_attr_unchanged(layout, w, opts, flavor, &attr.name, value)?;
×
NEW
533
            }
×
534
            // Trailing comma (no background)
NEW
535
            opts.backend
×
NEW
536
                .write_styled(w, flavor.trailing_separator(), SemanticColor::Whitespace)?;
×
NEW
537
            writeln!(w)?;
×
538
        }
539
        // Close the opening (e.g., ">" for XML) - only if non-empty
NEW
540
        let open_close = flavor.struct_open_close();
×
NEW
541
        if !open_close.is_empty() {
×
NEW
542
            write_indent(w, depth, opts)?;
×
NEW
543
            opts.backend.write_styled(w, open_close, tag_color)?;
×
NEW
544
            writeln!(w)?;
×
NEW
545
        }
×
546
    } else {
547
        // Inline attributes (no changes, no children) or no attrs
548
        for (i, attr) in attrs.iter().enumerate() {
2✔
NEW
549
            if i > 0 {
×
NEW
550
                write!(w, "{}", flavor.field_separator())?;
×
551
            } else {
NEW
552
                write!(w, " ")?;
×
553
            }
NEW
554
            if let AttrStatus::Unchanged { value } = &attr.status {
×
NEW
555
                render_attr_unchanged(layout, w, opts, flavor, &attr.name, value)?;
×
NEW
556
            }
×
557
        }
558

559
        if has_children {
2✔
560
            // Close the opening tag (e.g., ">" for XML)
561
            let open_close = flavor.struct_open_close();
2✔
562
            opts.backend.write_styled(w, open_close, tag_color)?;
2✔
563
        } else {
564
            // Self-closing
NEW
565
            let close = flavor.struct_close(tag, true);
×
NEW
566
            opts.backend.write_styled(w, &close, tag_color)?;
×
567
        }
568
        writeln!(w)?;
2✔
569
    }
570

571
    // Children
572
    for child_id in children {
3✔
573
        render_node(layout, w, child_id, depth + 1, opts, flavor)?;
3✔
574
    }
575

576
    // Closing tag (if we have children, we already printed opening part above)
577
    if has_children {
2✔
578
        write_indent(w, depth, opts)?;
2✔
579
        if let Some(prefix) = change.prefix() {
2✔
NEW
580
            opts.backend
×
NEW
581
                .write_prefix(w, prefix, element_change_to_semantic(change))?;
×
NEW
582
            write!(w, " ")?;
×
583
        }
2✔
584
        let close = flavor.struct_close(tag, false);
2✔
585
        opts.backend.write_styled(w, &close, tag_color)?;
2✔
586
        writeln!(w)?;
2✔
NEW
587
    }
×
588

589
    Ok(())
2✔
590
}
5✔
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)]
595
fn render_inline_element<W: Write, B: ColorBackend, F: DiffFlavor>(
3✔
596
    layout: &Layout,
3✔
597
    w: &mut W,
3✔
598
    depth: usize,
3✔
599
    opts: &RenderOptions<B>,
3✔
600
    flavor: &F,
3✔
601
    tag: &str,
3✔
602
    field_name: Option<&str>,
3✔
603
    attrs: &[super::Attr],
3✔
604
    info: &InlineElementInfo,
3✔
605
) -> fmt::Result {
3✔
606
    // Render field name prefix if present (for nested struct fields)
607
    let field_prefix = field_name.map(|name| flavor.format_child_open(name));
3✔
608
    let open = flavor.struct_open(tag);
3✔
609
    let close = flavor.struct_close(tag, true);
3✔
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)?;
3✔
615
    opts.backend.write_prefix(w, '←', SemanticColor::Deleted)?;
3✔
616
    opts.backend.write_styled(w, " ", SemanticColor::Deleted)?;
3✔
617

618
    // Field name prefix (line bg)
619
    if let Some(ref prefix) = field_prefix
3✔
NEW
620
        && !prefix.is_empty()
×
621
    {
NEW
622
        opts.backend
×
NEW
623
            .write_styled(w, prefix, SemanticColor::Deleted)?;
×
624
    }
3✔
625

626
    // Opening tag (line bg, with deleted context blending)
627
    opts.backend
3✔
628
        .write_styled(w, &open, SemanticColor::DeletedStructure)?;
3✔
629

630
    // Attributes (old values or spaces for inserted)
631
    for (i, (attr, &slot_width)) in attrs.iter().zip(info.slot_widths.iter()).enumerate() {
3✔
632
        if i > 0 {
3✔
NEW
633
            opts.backend
×
NEW
634
                .write_styled(w, flavor.field_separator(), SemanticColor::Whitespace)?;
×
635
        } else {
636
            opts.backend.write_styled(w, " ", SemanticColor::Deleted)?;
3✔
637
        }
638

639
        let written = match &attr.status {
3✔
NEW
640
            AttrStatus::Unchanged { value } => {
×
641
                // Unchanged: context-aware colors for structural elements
NEW
642
                opts.backend.write_styled(
×
NEW
643
                    w,
×
NEW
644
                    &flavor.format_field_prefix(&attr.name),
×
NEW
645
                    SemanticColor::DeletedKey,
×
NEW
646
                )?;
×
NEW
647
                let val = layout.get_string(value.span);
×
NEW
648
                let color = value_color(value.value_type, ElementChange::Deleted);
×
NEW
649
                opts.backend.write_styled(w, val, color)?;
×
NEW
650
                opts.backend.write_styled(
×
NEW
651
                    w,
×
NEW
652
                    flavor.format_field_suffix(),
×
NEW
653
                    SemanticColor::DeletedStructure,
×
NEW
654
                )?;
×
NEW
655
                flavor.format_field_prefix(&attr.name).len()
×
NEW
656
                    + value.width
×
NEW
657
                    + flavor.format_field_suffix().len()
×
658
            }
659
            AttrStatus::Changed { old, .. } => {
3✔
660
                // Changed: context-aware key color, highlight bg for value only
661
                opts.backend.write_styled(
3✔
662
                    w,
3✔
663
                    &flavor.format_field_prefix(&attr.name),
3✔
664
                    SemanticColor::DeletedKey,
3✔
NEW
665
                )?;
×
666
                let val = layout.get_string(old.span);
3✔
667
                let color = value_color_highlight(old.value_type, ElementChange::Deleted);
3✔
668
                opts.backend.write_styled(w, val, color)?;
3✔
669
                opts.backend.write_styled(
3✔
670
                    w,
3✔
671
                    flavor.format_field_suffix(),
3✔
672
                    SemanticColor::DeletedStructure,
3✔
NEW
673
                )?;
×
674
                flavor.format_field_prefix(&attr.name).len()
3✔
675
                    + old.width
3✔
676
                    + flavor.format_field_suffix().len()
3✔
677
            }
NEW
678
            AttrStatus::Deleted { value } => {
×
679
                // Deleted entirely: highlight bg for key AND value
NEW
680
                opts.backend.write_styled(
×
NEW
681
                    w,
×
NEW
682
                    &flavor.format_field_prefix(&attr.name),
×
NEW
683
                    SemanticColor::DeletedHighlight,
×
NEW
684
                )?;
×
NEW
685
                let val = layout.get_string(value.span);
×
NEW
686
                let color = value_color_highlight(value.value_type, ElementChange::Deleted);
×
NEW
687
                opts.backend.write_styled(w, val, color)?;
×
NEW
688
                opts.backend.write_styled(
×
NEW
689
                    w,
×
NEW
690
                    flavor.format_field_suffix(),
×
NEW
691
                    SemanticColor::DeletedHighlight,
×
NEW
692
                )?;
×
NEW
693
                flavor.format_field_prefix(&attr.name).len()
×
NEW
694
                    + value.width
×
NEW
695
                    + flavor.format_field_suffix().len()
×
696
            }
697
            AttrStatus::Inserted { .. } => {
698
                // Empty slot on minus line - show ∅ placeholder
NEW
699
                opts.backend.write_styled(w, "∅", SemanticColor::Deleted)?;
×
NEW
700
                1 // ∅ is 1 char wide
×
701
            }
702
        };
703

704
        // Pad to slot width (line bg)
705
        let padding = slot_width.saturating_sub(written);
3✔
706
        if padding > 0 {
3✔
707
            let spaces: String = " ".repeat(padding);
3✔
708
            opts.backend
3✔
709
                .write_styled(w, &spaces, SemanticColor::Whitespace)?;
3✔
NEW
710
        }
×
711
    }
712

713
    // Closing (line bg, with deleted context blending)
714
    opts.backend.write_styled(w, " ", SemanticColor::Deleted)?;
3✔
715
    opts.backend
3✔
716
        .write_styled(w, &close, SemanticColor::DeletedStructure)?;
3✔
717
    writeln!(w)?;
3✔
718

719
    // --- After line (new values) ---
720
    // Use → for "changed to" (vs + for "inserted entirely")
721
    write_indent_minus_prefix(w, depth, opts)?;
3✔
722
    opts.backend.write_prefix(w, '→', SemanticColor::Inserted)?;
3✔
723
    opts.backend.write_styled(w, " ", SemanticColor::Inserted)?;
3✔
724

725
    // Field name prefix (line bg)
726
    if let Some(ref prefix) = field_prefix
3✔
NEW
727
        && !prefix.is_empty()
×
728
    {
NEW
729
        opts.backend
×
NEW
730
            .write_styled(w, prefix, SemanticColor::Inserted)?;
×
731
    }
3✔
732

733
    // Opening tag (line bg, with inserted context blending)
734
    opts.backend
3✔
735
        .write_styled(w, &open, SemanticColor::InsertedStructure)?;
3✔
736

737
    // Attributes (new values or spaces for deleted)
738
    for (i, (attr, &slot_width)) in attrs.iter().zip(info.slot_widths.iter()).enumerate() {
3✔
739
        if i > 0 {
3✔
NEW
740
            opts.backend
×
NEW
741
                .write_styled(w, flavor.field_separator(), SemanticColor::Whitespace)?;
×
742
        } else {
743
            opts.backend.write_styled(w, " ", SemanticColor::Inserted)?;
3✔
744
        }
745

746
        let written = match &attr.status {
3✔
NEW
747
            AttrStatus::Unchanged { value } => {
×
748
                // Unchanged: context-aware colors for structural elements
NEW
749
                opts.backend.write_styled(
×
NEW
750
                    w,
×
NEW
751
                    &flavor.format_field_prefix(&attr.name),
×
NEW
752
                    SemanticColor::InsertedKey,
×
NEW
753
                )?;
×
NEW
754
                let val = layout.get_string(value.span);
×
NEW
755
                let color = value_color(value.value_type, ElementChange::Inserted);
×
NEW
756
                opts.backend.write_styled(w, val, color)?;
×
NEW
757
                opts.backend.write_styled(
×
NEW
758
                    w,
×
NEW
759
                    flavor.format_field_suffix(),
×
NEW
760
                    SemanticColor::InsertedStructure,
×
NEW
761
                )?;
×
NEW
762
                flavor.format_field_prefix(&attr.name).len()
×
NEW
763
                    + value.width
×
NEW
764
                    + flavor.format_field_suffix().len()
×
765
            }
766
            AttrStatus::Changed { new, .. } => {
3✔
767
                // Changed: context-aware key color, highlight bg for value only
768
                opts.backend.write_styled(
3✔
769
                    w,
3✔
770
                    &flavor.format_field_prefix(&attr.name),
3✔
771
                    SemanticColor::InsertedKey,
3✔
NEW
772
                )?;
×
773
                let val = layout.get_string(new.span);
3✔
774
                let color = value_color_highlight(new.value_type, ElementChange::Inserted);
3✔
775
                opts.backend.write_styled(w, val, color)?;
3✔
776
                opts.backend.write_styled(
3✔
777
                    w,
3✔
778
                    flavor.format_field_suffix(),
3✔
779
                    SemanticColor::InsertedStructure,
3✔
NEW
780
                )?;
×
781
                flavor.format_field_prefix(&attr.name).len()
3✔
782
                    + new.width
3✔
783
                    + flavor.format_field_suffix().len()
3✔
784
            }
785
            AttrStatus::Deleted { .. } => {
786
                // Empty slot on plus line - show ∅ placeholder
NEW
787
                opts.backend.write_styled(w, "∅", SemanticColor::Inserted)?;
×
NEW
788
                1 // ∅ is 1 char wide
×
789
            }
NEW
790
            AttrStatus::Inserted { value } => {
×
791
                // Inserted entirely: highlight bg for key AND value
NEW
792
                opts.backend.write_styled(
×
NEW
793
                    w,
×
NEW
794
                    &flavor.format_field_prefix(&attr.name),
×
NEW
795
                    SemanticColor::InsertedHighlight,
×
NEW
796
                )?;
×
NEW
797
                let val = layout.get_string(value.span);
×
NEW
798
                let color = value_color_highlight(value.value_type, ElementChange::Inserted);
×
NEW
799
                opts.backend.write_styled(w, val, color)?;
×
NEW
800
                opts.backend.write_styled(
×
NEW
801
                    w,
×
NEW
802
                    flavor.format_field_suffix(),
×
NEW
803
                    SemanticColor::InsertedHighlight,
×
NEW
804
                )?;
×
NEW
805
                flavor.format_field_prefix(&attr.name).len()
×
NEW
806
                    + value.width
×
NEW
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);
3✔
813
        if padding > 0 {
3✔
NEW
814
            let spaces: String = " ".repeat(padding);
×
NEW
815
            opts.backend
×
NEW
816
                .write_styled(w, &spaces, SemanticColor::Whitespace)?;
×
817
        }
3✔
818
    }
819

820
    // Closing (line bg, with inserted context blending)
821
    opts.backend.write_styled(w, " ", SemanticColor::Inserted)?;
3✔
822
    opts.backend
3✔
823
        .write_styled(w, &close, SemanticColor::InsertedStructure)?;
3✔
824
    writeln!(w)?;
3✔
825

826
    Ok(())
3✔
827
}
3✔
828

829
#[allow(clippy::too_many_arguments)]
NEW
830
fn render_sequence<W: Write, B: ColorBackend, F: DiffFlavor>(
×
NEW
831
    layout: &Layout,
×
NEW
832
    w: &mut W,
×
NEW
833
    node_id: indextree::NodeId,
×
NEW
834
    depth: usize,
×
NEW
835
    opts: &RenderOptions<B>,
×
NEW
836
    flavor: &F,
×
NEW
837
    change: ElementChange,
×
NEW
838
    _item_type: &str, // Item type available for future use (items use it via ItemGroup)
×
NEW
839
    field_name: Option<&str>,
×
NEW
840
) -> fmt::Result {
×
NEW
841
    let children: Vec<_> = layout.children(node_id).collect();
×
842

NEW
843
    let tag_color = match change {
×
NEW
844
        ElementChange::None => SemanticColor::Structure,
×
NEW
845
        ElementChange::Deleted => SemanticColor::DeletedStructure,
×
NEW
846
        ElementChange::Inserted => SemanticColor::InsertedStructure,
×
NEW
847
        ElementChange::MovedFrom | ElementChange::MovedTo => SemanticColor::Moved,
×
848
    };
849

850
    // Empty sequences: render on single line
NEW
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
NEW
854
        if change == ElementChange::None && field_name.is_none() {
×
NEW
855
            return Ok(());
×
NEW
856
        }
×
857

NEW
858
        write_indent(w, depth, opts)?;
×
NEW
859
        if let Some(prefix) = change.prefix() {
×
NEW
860
            opts.backend
×
NEW
861
                .write_prefix(w, prefix, element_change_to_semantic(change))?;
×
NEW
862
            write!(w, " ")?;
×
NEW
863
        }
×
864

865
        // Open and close with optional field name
NEW
866
        if let Some(name) = field_name {
×
NEW
867
            let open = flavor.format_seq_field_open(name);
×
NEW
868
            let close = flavor.format_seq_field_close(name);
×
NEW
869
            opts.backend.write_styled(w, &open, tag_color)?;
×
NEW
870
            opts.backend.write_styled(w, &close, tag_color)?;
×
871
        } else {
NEW
872
            let open = flavor.seq_open();
×
NEW
873
            let close = flavor.seq_close();
×
NEW
874
            opts.backend.write_styled(w, &open, tag_color)?;
×
NEW
875
            opts.backend.write_styled(w, &close, tag_color)?;
×
876
        }
877

878
        // Trailing comma for fields (context-aware)
NEW
879
        if field_name.is_some() {
×
NEW
880
            opts.backend
×
NEW
881
                .write_styled(w, flavor.trailing_separator(), SemanticColor::Whitespace)?;
×
NEW
882
        }
×
NEW
883
        writeln!(w)?;
×
NEW
884
        return Ok(());
×
NEW
885
    }
×
886

887
    // Opening bracket with optional field name
NEW
888
    write_indent(w, depth, opts)?;
×
NEW
889
    if let Some(prefix) = change.prefix() {
×
NEW
890
        opts.backend
×
NEW
891
            .write_prefix(w, prefix, element_change_to_semantic(change))?;
×
NEW
892
        write!(w, " ")?;
×
NEW
893
    }
×
894

895
    // Open with optional field name
NEW
896
    if let Some(name) = field_name {
×
NEW
897
        let open = flavor.format_seq_field_open(name);
×
NEW
898
        opts.backend.write_styled(w, &open, tag_color)?;
×
899
    } else {
NEW
900
        let open = flavor.seq_open();
×
NEW
901
        opts.backend.write_styled(w, &open, tag_color)?;
×
902
    }
NEW
903
    writeln!(w)?;
×
904

905
    // Children
NEW
906
    for child_id in children {
×
NEW
907
        render_node(layout, w, child_id, depth + 1, opts, flavor)?;
×
908
    }
909

910
    // Closing bracket
NEW
911
    write_indent(w, depth, opts)?;
×
NEW
912
    if let Some(prefix) = change.prefix() {
×
NEW
913
        opts.backend
×
NEW
914
            .write_prefix(w, prefix, element_change_to_semantic(change))?;
×
NEW
915
        write!(w, " ")?;
×
NEW
916
    }
×
917

918
    // Close with optional field name
NEW
919
    if let Some(name) = field_name {
×
NEW
920
        let close = flavor.format_seq_field_close(name);
×
NEW
921
        opts.backend.write_styled(w, &close, tag_color)?;
×
922
    } else {
NEW
923
        let close = flavor.seq_close();
×
NEW
924
        opts.backend.write_styled(w, &close, tag_color)?;
×
925
    }
926

927
    // Trailing comma for fields (context-aware)
NEW
928
    if field_name.is_some() {
×
NEW
929
        opts.backend
×
NEW
930
            .write_styled(w, flavor.trailing_separator(), SemanticColor::Whitespace)?;
×
NEW
931
    }
×
NEW
932
    writeln!(w)?;
×
933

NEW
934
    Ok(())
×
NEW
935
}
×
936

NEW
937
fn render_changed_group<W: Write, B: ColorBackend, F: DiffFlavor>(
×
NEW
938
    layout: &Layout,
×
NEW
939
    w: &mut W,
×
NEW
940
    depth: usize,
×
NEW
941
    opts: &RenderOptions<B>,
×
NEW
942
    flavor: &F,
×
NEW
943
    attrs: &[super::Attr],
×
NEW
944
    group: &ChangedGroup,
×
NEW
945
) -> fmt::Result {
×
946
    // Before line - use ← for "changed from" (prefix uses indent gutter)
NEW
947
    write_indent_minus_prefix(w, depth, opts)?;
×
NEW
948
    opts.backend.write_prefix(w, '←', SemanticColor::Deleted)?;
×
NEW
949
    write!(w, " ")?;
×
950

NEW
951
    let last_idx = group.attr_indices.len().saturating_sub(1);
×
NEW
952
    for (i, &idx) in group.attr_indices.iter().enumerate() {
×
NEW
953
        if i > 0 {
×
NEW
954
            write!(w, "{}", flavor.field_separator())?;
×
NEW
955
        }
×
NEW
956
        let attr = &attrs[idx];
×
NEW
957
        if let AttrStatus::Changed { old, new } = &attr.status {
×
958
            // Each field padded to max of its own old/new value width
NEW
959
            let field_max_width = old.width.max(new.width);
×
960
            // Use context-aware key color for field prefix (line bg)
NEW
961
            opts.backend.write_styled(
×
NEW
962
                w,
×
NEW
963
                &flavor.format_field_prefix(&attr.name),
×
NEW
964
                SemanticColor::DeletedKey,
×
NEW
965
            )?;
×
966
            // Changed value uses highlight background for contrast
NEW
967
            let old_str = layout.get_string(old.span);
×
NEW
968
            let color = value_color_highlight(old.value_type, ElementChange::Deleted);
×
NEW
969
            opts.backend.write_styled(w, old_str, color)?;
×
970
            // Use context-aware structure color for field suffix (line bg)
NEW
971
            opts.backend.write_styled(
×
NEW
972
                w,
×
NEW
973
                flavor.format_field_suffix(),
×
NEW
974
                SemanticColor::DeletedStructure,
×
NEW
975
            )?;
×
976
            // Pad to align with the + line's value (only between fields, not at end)
NEW
977
            if i < last_idx {
×
NEW
978
                let value_padding = field_max_width.saturating_sub(old.width);
×
NEW
979
                for _ in 0..value_padding {
×
NEW
980
                    write!(w, " ")?;
×
981
                }
NEW
982
            }
×
NEW
983
        }
×
984
    }
NEW
985
    writeln!(w)?;
×
986

987
    // After line - use → for "changed to" (prefix uses indent gutter)
NEW
988
    write_indent_minus_prefix(w, depth, opts)?;
×
NEW
989
    opts.backend.write_prefix(w, '→', SemanticColor::Inserted)?;
×
NEW
990
    write!(w, " ")?;
×
991

NEW
992
    for (i, &idx) in group.attr_indices.iter().enumerate() {
×
NEW
993
        if i > 0 {
×
NEW
994
            write!(w, "{}", flavor.field_separator())?;
×
NEW
995
        }
×
NEW
996
        let attr = &attrs[idx];
×
NEW
997
        if let AttrStatus::Changed { old, new } = &attr.status {
×
998
            // Each field padded to max of its own old/new value width
NEW
999
            let field_max_width = old.width.max(new.width);
×
1000
            // Use context-aware key color for field prefix (line bg)
NEW
1001
            opts.backend.write_styled(
×
NEW
1002
                w,
×
NEW
1003
                &flavor.format_field_prefix(&attr.name),
×
NEW
1004
                SemanticColor::InsertedKey,
×
NEW
1005
            )?;
×
1006
            // Changed value uses highlight background for contrast
NEW
1007
            let new_str = layout.get_string(new.span);
×
NEW
1008
            let color = value_color_highlight(new.value_type, ElementChange::Inserted);
×
NEW
1009
            opts.backend.write_styled(w, new_str, color)?;
×
1010
            // Use context-aware structure color for field suffix (line bg)
NEW
1011
            opts.backend.write_styled(
×
NEW
1012
                w,
×
NEW
1013
                flavor.format_field_suffix(),
×
NEW
1014
                SemanticColor::InsertedStructure,
×
NEW
1015
            )?;
×
1016
            // Pad to align with the - line's value (only between fields, not at end)
NEW
1017
            if i < last_idx {
×
NEW
1018
                let value_padding = field_max_width.saturating_sub(new.width);
×
NEW
1019
                for _ in 0..value_padding {
×
NEW
1020
                    write!(w, " ")?;
×
1021
                }
NEW
1022
            }
×
NEW
1023
        }
×
1024
    }
NEW
1025
    writeln!(w)?;
×
1026

NEW
1027
    Ok(())
×
NEW
1028
}
×
1029

NEW
1030
fn render_attr_unchanged<W: Write, B: ColorBackend, F: DiffFlavor>(
×
NEW
1031
    layout: &Layout,
×
NEW
1032
    w: &mut W,
×
NEW
1033
    opts: &RenderOptions<B>,
×
NEW
1034
    flavor: &F,
×
NEW
1035
    name: &str,
×
NEW
1036
    value: &super::FormattedValue,
×
NEW
1037
) -> fmt::Result {
×
NEW
1038
    let value_str = layout.get_string(value.span);
×
NEW
1039
    let formatted = flavor.format_field(name, value_str);
×
NEW
1040
    opts.backend
×
NEW
1041
        .write_styled(w, &formatted, SemanticColor::Unchanged)
×
NEW
1042
}
×
1043

NEW
1044
fn render_attr_deleted<W: Write, B: ColorBackend, F: DiffFlavor>(
×
NEW
1045
    layout: &Layout,
×
NEW
1046
    w: &mut W,
×
NEW
1047
    opts: &RenderOptions<B>,
×
NEW
1048
    flavor: &F,
×
NEW
1049
    name: &str,
×
NEW
1050
    value: &super::FormattedValue,
×
NEW
1051
) -> fmt::Result {
×
NEW
1052
    let value_str = layout.get_string(value.span);
×
1053
    // Entire field uses highlight background for deleted (better contrast)
NEW
1054
    let formatted = flavor.format_field(name, value_str);
×
NEW
1055
    opts.backend
×
NEW
1056
        .write_styled(w, &formatted, SemanticColor::DeletedHighlight)
×
NEW
1057
}
×
1058

NEW
1059
fn render_attr_inserted<W: Write, B: ColorBackend, F: DiffFlavor>(
×
NEW
1060
    layout: &Layout,
×
NEW
1061
    w: &mut W,
×
NEW
1062
    opts: &RenderOptions<B>,
×
NEW
1063
    flavor: &F,
×
NEW
1064
    name: &str,
×
NEW
1065
    value: &super::FormattedValue,
×
NEW
1066
) -> fmt::Result {
×
NEW
1067
    let value_str = layout.get_string(value.span);
×
1068
    // Entire field uses highlight background for inserted (better contrast)
NEW
1069
    let formatted = flavor.format_field(name, value_str);
×
NEW
1070
    opts.backend
×
NEW
1071
        .write_styled(w, &formatted, SemanticColor::InsertedHighlight)
×
NEW
1072
}
×
1073

1074
fn write_indent<W: Write, B: ColorBackend>(
7✔
1075
    w: &mut W,
7✔
1076
    depth: usize,
7✔
1077
    opts: &RenderOptions<B>,
7✔
1078
) -> fmt::Result {
7✔
1079
    for _ in 0..depth {
7✔
1080
        write!(w, "{}", opts.indent)?;
9✔
1081
    }
1082
    Ok(())
7✔
1083
}
7✔
1084

1085
/// Write indent minus 2 characters for the prefix gutter.
1086
/// The "- " or "+ " prefix will occupy those 2 characters.
1087
fn write_indent_minus_prefix<W: Write, B: ColorBackend>(
6✔
1088
    w: &mut W,
6✔
1089
    depth: usize,
6✔
1090
    opts: &RenderOptions<B>,
6✔
1091
) -> fmt::Result {
6✔
1092
    let total_indent = depth * opts.indent.len();
6✔
1093
    let gutter_indent = total_indent.saturating_sub(2);
6✔
1094
    for _ in 0..gutter_indent {
6✔
1095
        write!(w, " ")?;
20✔
1096
    }
1097
    Ok(())
6✔
1098
}
6✔
1099

1100
#[cfg(test)]
1101
mod 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 {
2✔
1108
        let mut strings = FormatArena::new();
2✔
1109
        let tree = Arena::new();
2✔
1110

1111
        // Create a simple element with one changed attribute
1112
        let (red_span, red_width) = strings.push_str("red");
2✔
1113
        let (blue_span, blue_width) = strings.push_str("blue");
2✔
1114

1115
        let fill_attr = Attr::changed(
2✔
1116
            "fill",
1117
            4,
1118
            FormattedValue::new(red_span, red_width),
2✔
1119
            FormattedValue::new(blue_span, blue_width),
2✔
1120
        );
1121

1122
        let attrs = vec![fill_attr];
2✔
1123
        let changed_groups = super::super::group_changed_attrs(&attrs, 80, 0);
2✔
1124

1125
        let root = LayoutNode::Element {
2✔
1126
            tag: "rect",
2✔
1127
            field_name: None,
2✔
1128
            attrs,
2✔
1129
            changed_groups,
2✔
1130
            change: ElementChange::None,
2✔
1131
        };
2✔
1132

1133
        Layout::new(strings, tree, root)
2✔
1134
    }
2✔
1135

1136
    #[test]
1137
    fn test_render_simple_change() {
1✔
1138
        let layout = make_test_layout();
1✔
1139
        let opts = RenderOptions::plain();
1✔
1140
        let output = render_to_string(&layout, &opts, &XmlFlavor);
1✔
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\""));
1✔
1146
        assert!(output.contains("→ <rect fill=\"blue\""));
1✔
1147
        assert!(output.contains("/>"));
1✔
1148
    }
1✔
1149

1150
    #[test]
1151
    fn test_render_collapsed() {
1✔
1152
        let strings = FormatArena::new();
1✔
1153
        let tree = Arena::new();
1✔
1154

1155
        let root = LayoutNode::collapsed(5);
1✔
1156
        let layout = Layout::new(strings, tree, root);
1✔
1157

1158
        let opts = RenderOptions::plain();
1✔
1159
        let output = render_to_string(&layout, &opts, &XmlFlavor);
1✔
1160

1161
        assert!(output.contains("<!-- 5 unchanged -->"));
1✔
1162
    }
1✔
1163

1164
    #[test]
1165
    fn test_render_with_children() {
1✔
1166
        let mut strings = FormatArena::new();
1✔
1167
        let mut tree = Arena::new();
1✔
1168

1169
        // Parent element
1170
        let parent = tree.new_node(LayoutNode::Element {
1✔
1171
            tag: "svg",
1✔
1172
            field_name: None,
1✔
1173
            attrs: vec![],
1✔
1174
            changed_groups: vec![],
1✔
1175
            change: ElementChange::None,
1✔
1176
        });
1✔
1177

1178
        // Child element with change
1179
        let (red_span, red_width) = strings.push_str("red");
1✔
1180
        let (blue_span, blue_width) = strings.push_str("blue");
1✔
1181

1182
        let fill_attr = Attr::changed(
1✔
1183
            "fill",
1184
            4,
1185
            FormattedValue::new(red_span, red_width),
1✔
1186
            FormattedValue::new(blue_span, blue_width),
1✔
1187
        );
1188
        let attrs = vec![fill_attr];
1✔
1189
        let changed_groups = super::super::group_changed_attrs(&attrs, 80, 0);
1✔
1190

1191
        let child = tree.new_node(LayoutNode::Element {
1✔
1192
            tag: "rect",
1✔
1193
            field_name: None,
1✔
1194
            attrs,
1✔
1195
            changed_groups,
1✔
1196
            change: ElementChange::None,
1✔
1197
        });
1✔
1198

1199
        parent.append(child, &mut tree);
1✔
1200

1201
        let layout = Layout {
1✔
1202
            strings,
1✔
1203
            tree,
1✔
1204
            root: parent,
1✔
1205
        };
1✔
1206

1207
        let opts = RenderOptions::plain();
1✔
1208
        let output = render_to_string(&layout, &opts, &XmlFlavor);
1✔
1209

1210
        assert!(output.contains("<svg>"));
1✔
1211
        assert!(output.contains("</svg>"));
1✔
1212
        assert!(output.contains("<rect"));
1✔
1213
    }
1✔
1214

1215
    #[test]
1216
    fn test_ansi_backend_produces_escapes() {
1✔
1217
        let layout = make_test_layout();
1✔
1218
        let opts = RenderOptions::default();
1✔
1219
        let output = render_to_string(&layout, &opts, &XmlFlavor);
1✔
1220

1221
        // Should contain ANSI escape codes
1222
        assert!(
1✔
1223
            output.contains("\x1b["),
1✔
1224
            "output should contain ANSI escapes"
1225
        );
1226
    }
1✔
1227
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc