1use 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#[derive(Clone, Copy)]
12#[allow(dead_code)]
13enum SyntaxElement {
14 Key,
15 Structure,
16 Comment,
17}
18
19fn 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
36fn 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 (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
64fn 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 (_, ElementChange::Deleted) => SemanticColor::DeletedHighlight,
81 (_, ElementChange::Inserted) => SemanticColor::InsertedHighlight,
82 (_, ElementChange::MovedFrom) | (_, ElementChange::MovedTo) => {
83 SemanticColor::MovedHighlight
84 }
85 _ => SemanticColor::Unchanged,
86 }
87}
88
89struct InlineElementInfo {
92 slot_widths: Vec<usize>,
94}
95
96impl InlineElementInfo {
97 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 total_width += flavor.struct_open(tag).len();
115
116 for (i, attr) in attrs.iter().enumerate() {
117 if i > 0 {
119 total_width += flavor.field_separator().len();
120 } else {
121 total_width += 1; }
123
124 let slot_width = match &attr.status {
126 AttrStatus::Unchanged { value } => {
127 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 total_width += 1; total_width += flavor.struct_close(tag, true).len();
157
158 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#[derive(Clone, Debug)]
170pub struct RenderOptions<B: ColorBackend> {
171 pub symbols: DiffSymbols,
173 pub backend: B,
175 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 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 pub fn with_backend(backend: B) -> Self {
203 Self {
204 symbols: DiffSymbols::default(),
205 backend,
206 indent: " ",
207 }
208 }
209}
210
211pub 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
223pub 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 if let Some(prefix) = change.prefix() {
333 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 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 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 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 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 let is_pure_insertion = has_inserted_attrs && !has_changed_attrs && !has_deleted_attrs;
404
405 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 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 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 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 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 writeln!(w)?;
469
470 for group in changed_groups {
472 render_changed_group(layout, w, depth + 1, opts, flavor, attrs, group)?;
473 }
474
475 for (i, attr) in attrs.iter().enumerate() {
477 if let AttrStatus::Deleted { value } = &attr.status {
478 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 opts.backend.write_styled(
488 w,
489 flavor.trailing_separator(),
490 SemanticColor::Whitespace,
491 )?;
492 writeln!(w)?;
493 }
494 }
495
496 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 opts.backend.write_styled(
508 w,
509 flavor.trailing_separator(),
510 SemanticColor::Whitespace,
511 )?;
512 writeln!(w)?;
513 }
514 }
515
516 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 opts.backend
533 .write_styled(w, flavor.trailing_separator(), SemanticColor::Whitespace)?;
534 writeln!(w)?;
535 }
536
537 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 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 opts.backend
557 .write_styled(w, flavor.trailing_separator(), SemanticColor::Whitespace)?;
558 writeln!(w)?;
559 }
560 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 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 let open_close = flavor.struct_open_close();
583 opts.backend.write_styled(w, open_close, tag_color)?;
584 } else {
585 let close = flavor.struct_close(tag, true);
587 opts.backend.write_styled(w, &close, tag_color)?;
588 }
589 writeln!(w)?;
590 }
591
592 for child_id in children {
594 render_node(layout, w, child_id, depth + 1, opts, flavor)?;
595 }
596
597 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#[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 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 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 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 opts.backend
649 .write_styled(w, &open, SemanticColor::DeletedStructure)?;
650
651 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 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 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 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 opts.backend.write_styled(w, "∅", SemanticColor::Deleted)?;
721 1 }
723 };
724
725 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 opts.backend.write_styled(w, " ", SemanticColor::Deleted)?;
736 opts.backend
737 .write_styled(w, &close, SemanticColor::DeletedStructure)?;
738 writeln!(w)?;
739
740 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 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 opts.backend
756 .write_styled(w, &open, SemanticColor::InsertedStructure)?;
757
758 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 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 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 opts.backend.write_styled(w, "∅", SemanticColor::Inserted)?;
809 1 }
811 AttrStatus::Inserted { value } => {
812 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 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 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, 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 if children.is_empty() {
873 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 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 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 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 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 for child_id in children {
928 render_node(layout, w, child_id, depth + 1, opts, flavor)?;
929 }
930
931 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 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 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 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 let field_max_width = old.width.max(new.width);
981 opts.backend.write_styled(
983 w,
984 &flavor.format_field_prefix(&attr.name),
985 SemanticColor::DeletedKey,
986 )?;
987 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 opts.backend.write_styled(
993 w,
994 flavor.format_field_suffix(),
995 SemanticColor::DeletedStructure,
996 )?;
997 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 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 let field_max_width = old.width.max(new.width);
1021 opts.backend.write_styled(
1023 w,
1024 &flavor.format_field_prefix(&attr.name),
1025 SemanticColor::InsertedKey,
1026 )?;
1027 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 opts.backend.write_styled(
1033 w,
1034 flavor.format_field_suffix(),
1035 SemanticColor::InsertedStructure,
1036 )?;
1037 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 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 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
1106fn 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 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 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 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 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 assert!(
1244 output.contains("\x1b["),
1245 "output should contain ANSI escapes"
1246 );
1247 }
1248}