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 let has_attr_changes = !changed_groups.is_empty()
384 || attrs.iter().any(|a| {
385 matches!(
386 a.status,
387 AttrStatus::Deleted { .. } | AttrStatus::Inserted { .. }
388 )
389 });
390
391 let children: Vec<_> = layout.children(node_id).collect();
392 let has_children = !children.is_empty();
393
394 if has_attr_changes && !has_children {
400 let indent_width = depth * opts.indent.len();
401 if let Some(info) = InlineElementInfo::calculate(attrs, tag, flavor, 80, indent_width) {
402 return render_inline_element(
403 layout, w, depth, opts, flavor, tag, field_name, attrs, &info,
404 );
405 }
406 }
407
408 let tag_color = match change {
409 ElementChange::None => SemanticColor::Structure,
410 ElementChange::Deleted => SemanticColor::DeletedStructure,
411 ElementChange::Inserted => SemanticColor::InsertedStructure,
412 ElementChange::MovedFrom | ElementChange::MovedTo => SemanticColor::Moved,
413 };
414
415 write_indent(w, depth, opts)?;
417 if let Some(prefix) = change.prefix() {
418 opts.backend
419 .write_prefix(w, prefix, element_change_to_semantic(change))?;
420 write!(w, " ")?;
421 }
422
423 if let Some(name) = field_name {
428 let prefix = flavor.format_child_open(name);
429 if !prefix.is_empty() {
430 opts.backend
431 .write_styled(w, &prefix, SemanticColor::Unchanged)?;
432 }
433 }
434
435 let open = flavor.struct_open(tag);
436 opts.backend.write_styled(w, &open, tag_color)?;
437
438 if let Some(comment) = flavor.type_comment(tag) {
440 write!(w, " ")?;
441 opts.backend
442 .write_styled(w, &comment, syntax_color(SyntaxElement::Comment, change))?;
443 }
444
445 if has_attr_changes {
446 writeln!(w)?;
448
449 for group in changed_groups {
451 render_changed_group(layout, w, depth + 1, opts, flavor, attrs, group)?;
452 }
453
454 for (i, attr) in attrs.iter().enumerate() {
456 if let AttrStatus::Deleted { value } = &attr.status {
457 if changed_groups.iter().any(|g| g.attr_indices.contains(&i)) {
459 continue;
460 }
461 write_indent_minus_prefix(w, depth + 1, opts)?;
462 opts.backend.write_prefix(w, '-', SemanticColor::Deleted)?;
463 write!(w, " ")?;
464 render_attr_deleted(layout, w, opts, flavor, &attr.name, value)?;
465 opts.backend.write_styled(
467 w,
468 flavor.trailing_separator(),
469 SemanticColor::Whitespace,
470 )?;
471 writeln!(w)?;
472 }
473 }
474
475 for (i, attr) in attrs.iter().enumerate() {
477 if let AttrStatus::Inserted { value } = &attr.status {
478 if changed_groups.iter().any(|g| g.attr_indices.contains(&i)) {
479 continue;
480 }
481 write_indent_minus_prefix(w, depth + 1, opts)?;
482 opts.backend.write_prefix(w, '+', SemanticColor::Inserted)?;
483 write!(w, " ")?;
484 render_attr_inserted(layout, w, opts, flavor, &attr.name, value)?;
485 opts.backend.write_styled(
487 w,
488 flavor.trailing_separator(),
489 SemanticColor::Whitespace,
490 )?;
491 writeln!(w)?;
492 }
493 }
494
495 let unchanged: Vec<_> = attrs
497 .iter()
498 .filter(|a| matches!(a.status, AttrStatus::Unchanged { .. }))
499 .collect();
500 if !unchanged.is_empty() {
501 write_indent(w, depth + 1, opts)?;
502 for (i, attr) in unchanged.iter().enumerate() {
503 if i > 0 {
504 write!(w, "{}", flavor.field_separator())?;
505 }
506 if let AttrStatus::Unchanged { value } = &attr.status {
507 render_attr_unchanged(layout, w, opts, flavor, &attr.name, value)?;
508 }
509 }
510 opts.backend
512 .write_styled(w, flavor.trailing_separator(), SemanticColor::Whitespace)?;
513 writeln!(w)?;
514 }
515
516 write_indent(w, depth, opts)?;
518 if has_children {
519 let open_close = flavor.struct_open_close();
520 opts.backend.write_styled(w, open_close, tag_color)?;
521 } else {
522 let close = flavor.struct_close(tag, true);
523 opts.backend.write_styled(w, &close, tag_color)?;
524 }
525 writeln!(w)?;
526 } else if has_children && !attrs.is_empty() {
527 writeln!(w)?;
529 for attr in attrs.iter() {
530 write_indent(w, depth + 1, opts)?;
531 if let AttrStatus::Unchanged { value } = &attr.status {
532 render_attr_unchanged(layout, w, opts, flavor, &attr.name, value)?;
533 }
534 opts.backend
536 .write_styled(w, flavor.trailing_separator(), SemanticColor::Whitespace)?;
537 writeln!(w)?;
538 }
539 let open_close = flavor.struct_open_close();
541 if !open_close.is_empty() {
542 write_indent(w, depth, opts)?;
543 opts.backend.write_styled(w, open_close, tag_color)?;
544 writeln!(w)?;
545 }
546 } else {
547 for (i, attr) in attrs.iter().enumerate() {
549 if i > 0 {
550 write!(w, "{}", flavor.field_separator())?;
551 } else {
552 write!(w, " ")?;
553 }
554 if let AttrStatus::Unchanged { value } = &attr.status {
555 render_attr_unchanged(layout, w, opts, flavor, &attr.name, value)?;
556 }
557 }
558
559 if has_children {
560 let open_close = flavor.struct_open_close();
562 opts.backend.write_styled(w, open_close, tag_color)?;
563 } else {
564 let close = flavor.struct_close(tag, true);
566 opts.backend.write_styled(w, &close, tag_color)?;
567 }
568 writeln!(w)?;
569 }
570
571 for child_id in children {
573 render_node(layout, w, child_id, depth + 1, opts, flavor)?;
574 }
575
576 if has_children {
578 write_indent(w, depth, opts)?;
579 if let Some(prefix) = change.prefix() {
580 opts.backend
581 .write_prefix(w, prefix, element_change_to_semantic(change))?;
582 write!(w, " ")?;
583 }
584 let close = flavor.struct_close(tag, false);
585 opts.backend.write_styled(w, &close, tag_color)?;
586 writeln!(w)?;
587 }
588
589 Ok(())
590}
591
592#[allow(clippy::too_many_arguments)]
595fn render_inline_element<W: Write, B: ColorBackend, F: DiffFlavor>(
596 layout: &Layout,
597 w: &mut W,
598 depth: usize,
599 opts: &RenderOptions<B>,
600 flavor: &F,
601 tag: &str,
602 field_name: Option<&str>,
603 attrs: &[super::Attr],
604 info: &InlineElementInfo,
605) -> fmt::Result {
606 let field_prefix = field_name.map(|name| flavor.format_child_open(name));
608 let open = flavor.struct_open(tag);
609 let close = flavor.struct_close(tag, true);
610
611 write_indent_minus_prefix(w, depth, opts)?;
615 opts.backend.write_prefix(w, '←', SemanticColor::Deleted)?;
616 opts.backend.write_styled(w, " ", SemanticColor::Deleted)?;
617
618 if let Some(ref prefix) = field_prefix
620 && !prefix.is_empty()
621 {
622 opts.backend
623 .write_styled(w, prefix, SemanticColor::Deleted)?;
624 }
625
626 opts.backend
628 .write_styled(w, &open, SemanticColor::DeletedStructure)?;
629
630 for (i, (attr, &slot_width)) in attrs.iter().zip(info.slot_widths.iter()).enumerate() {
632 if i > 0 {
633 opts.backend
634 .write_styled(w, flavor.field_separator(), SemanticColor::Whitespace)?;
635 } else {
636 opts.backend.write_styled(w, " ", SemanticColor::Deleted)?;
637 }
638
639 let written = match &attr.status {
640 AttrStatus::Unchanged { value } => {
641 opts.backend.write_styled(
643 w,
644 &flavor.format_field_prefix(&attr.name),
645 SemanticColor::DeletedKey,
646 )?;
647 let val = layout.get_string(value.span);
648 let color = value_color(value.value_type, ElementChange::Deleted);
649 opts.backend.write_styled(w, val, color)?;
650 opts.backend.write_styled(
651 w,
652 flavor.format_field_suffix(),
653 SemanticColor::DeletedStructure,
654 )?;
655 flavor.format_field_prefix(&attr.name).len()
656 + value.width
657 + flavor.format_field_suffix().len()
658 }
659 AttrStatus::Changed { old, .. } => {
660 opts.backend.write_styled(
662 w,
663 &flavor.format_field_prefix(&attr.name),
664 SemanticColor::DeletedKey,
665 )?;
666 let val = layout.get_string(old.span);
667 let color = value_color_highlight(old.value_type, ElementChange::Deleted);
668 opts.backend.write_styled(w, val, color)?;
669 opts.backend.write_styled(
670 w,
671 flavor.format_field_suffix(),
672 SemanticColor::DeletedStructure,
673 )?;
674 flavor.format_field_prefix(&attr.name).len()
675 + old.width
676 + flavor.format_field_suffix().len()
677 }
678 AttrStatus::Deleted { value } => {
679 opts.backend.write_styled(
681 w,
682 &flavor.format_field_prefix(&attr.name),
683 SemanticColor::DeletedHighlight,
684 )?;
685 let val = layout.get_string(value.span);
686 let color = value_color_highlight(value.value_type, ElementChange::Deleted);
687 opts.backend.write_styled(w, val, color)?;
688 opts.backend.write_styled(
689 w,
690 flavor.format_field_suffix(),
691 SemanticColor::DeletedHighlight,
692 )?;
693 flavor.format_field_prefix(&attr.name).len()
694 + value.width
695 + flavor.format_field_suffix().len()
696 }
697 AttrStatus::Inserted { .. } => {
698 opts.backend.write_styled(w, "∅", SemanticColor::Deleted)?;
700 1 }
702 };
703
704 let padding = slot_width.saturating_sub(written);
706 if padding > 0 {
707 let spaces: String = " ".repeat(padding);
708 opts.backend
709 .write_styled(w, &spaces, SemanticColor::Whitespace)?;
710 }
711 }
712
713 opts.backend.write_styled(w, " ", SemanticColor::Deleted)?;
715 opts.backend
716 .write_styled(w, &close, SemanticColor::DeletedStructure)?;
717 writeln!(w)?;
718
719 write_indent_minus_prefix(w, depth, opts)?;
722 opts.backend.write_prefix(w, '→', SemanticColor::Inserted)?;
723 opts.backend.write_styled(w, " ", SemanticColor::Inserted)?;
724
725 if let Some(ref prefix) = field_prefix
727 && !prefix.is_empty()
728 {
729 opts.backend
730 .write_styled(w, prefix, SemanticColor::Inserted)?;
731 }
732
733 opts.backend
735 .write_styled(w, &open, SemanticColor::InsertedStructure)?;
736
737 for (i, (attr, &slot_width)) in attrs.iter().zip(info.slot_widths.iter()).enumerate() {
739 if i > 0 {
740 opts.backend
741 .write_styled(w, flavor.field_separator(), SemanticColor::Whitespace)?;
742 } else {
743 opts.backend.write_styled(w, " ", SemanticColor::Inserted)?;
744 }
745
746 let written = match &attr.status {
747 AttrStatus::Unchanged { value } => {
748 opts.backend.write_styled(
750 w,
751 &flavor.format_field_prefix(&attr.name),
752 SemanticColor::InsertedKey,
753 )?;
754 let val = layout.get_string(value.span);
755 let color = value_color(value.value_type, ElementChange::Inserted);
756 opts.backend.write_styled(w, val, color)?;
757 opts.backend.write_styled(
758 w,
759 flavor.format_field_suffix(),
760 SemanticColor::InsertedStructure,
761 )?;
762 flavor.format_field_prefix(&attr.name).len()
763 + value.width
764 + flavor.format_field_suffix().len()
765 }
766 AttrStatus::Changed { new, .. } => {
767 opts.backend.write_styled(
769 w,
770 &flavor.format_field_prefix(&attr.name),
771 SemanticColor::InsertedKey,
772 )?;
773 let val = layout.get_string(new.span);
774 let color = value_color_highlight(new.value_type, ElementChange::Inserted);
775 opts.backend.write_styled(w, val, color)?;
776 opts.backend.write_styled(
777 w,
778 flavor.format_field_suffix(),
779 SemanticColor::InsertedStructure,
780 )?;
781 flavor.format_field_prefix(&attr.name).len()
782 + new.width
783 + flavor.format_field_suffix().len()
784 }
785 AttrStatus::Deleted { .. } => {
786 opts.backend.write_styled(w, "∅", SemanticColor::Inserted)?;
788 1 }
790 AttrStatus::Inserted { value } => {
791 opts.backend.write_styled(
793 w,
794 &flavor.format_field_prefix(&attr.name),
795 SemanticColor::InsertedHighlight,
796 )?;
797 let val = layout.get_string(value.span);
798 let color = value_color_highlight(value.value_type, ElementChange::Inserted);
799 opts.backend.write_styled(w, val, color)?;
800 opts.backend.write_styled(
801 w,
802 flavor.format_field_suffix(),
803 SemanticColor::InsertedHighlight,
804 )?;
805 flavor.format_field_prefix(&attr.name).len()
806 + value.width
807 + flavor.format_field_suffix().len()
808 }
809 };
810
811 let padding = slot_width.saturating_sub(written);
813 if padding > 0 {
814 let spaces: String = " ".repeat(padding);
815 opts.backend
816 .write_styled(w, &spaces, SemanticColor::Whitespace)?;
817 }
818 }
819
820 opts.backend.write_styled(w, " ", SemanticColor::Inserted)?;
822 opts.backend
823 .write_styled(w, &close, SemanticColor::InsertedStructure)?;
824 writeln!(w)?;
825
826 Ok(())
827}
828
829#[allow(clippy::too_many_arguments)]
830fn render_sequence<W: Write, B: ColorBackend, F: DiffFlavor>(
831 layout: &Layout,
832 w: &mut W,
833 node_id: indextree::NodeId,
834 depth: usize,
835 opts: &RenderOptions<B>,
836 flavor: &F,
837 change: ElementChange,
838 _item_type: &str, field_name: Option<&str>,
840) -> fmt::Result {
841 let children: Vec<_> = layout.children(node_id).collect();
842
843 let tag_color = match change {
844 ElementChange::None => SemanticColor::Structure,
845 ElementChange::Deleted => SemanticColor::DeletedStructure,
846 ElementChange::Inserted => SemanticColor::InsertedStructure,
847 ElementChange::MovedFrom | ElementChange::MovedTo => SemanticColor::Moved,
848 };
849
850 if children.is_empty() {
852 if change == ElementChange::None && field_name.is_none() {
855 return Ok(());
856 }
857
858 write_indent(w, depth, opts)?;
859 if let Some(prefix) = change.prefix() {
860 opts.backend
861 .write_prefix(w, prefix, element_change_to_semantic(change))?;
862 write!(w, " ")?;
863 }
864
865 if let Some(name) = field_name {
867 let open = flavor.format_seq_field_open(name);
868 let close = flavor.format_seq_field_close(name);
869 opts.backend.write_styled(w, &open, tag_color)?;
870 opts.backend.write_styled(w, &close, tag_color)?;
871 } else {
872 let open = flavor.seq_open();
873 let close = flavor.seq_close();
874 opts.backend.write_styled(w, &open, tag_color)?;
875 opts.backend.write_styled(w, &close, tag_color)?;
876 }
877
878 if field_name.is_some() {
880 opts.backend
881 .write_styled(w, flavor.trailing_separator(), SemanticColor::Whitespace)?;
882 }
883 writeln!(w)?;
884 return Ok(());
885 }
886
887 write_indent(w, depth, opts)?;
889 if let Some(prefix) = change.prefix() {
890 opts.backend
891 .write_prefix(w, prefix, element_change_to_semantic(change))?;
892 write!(w, " ")?;
893 }
894
895 if let Some(name) = field_name {
897 let open = flavor.format_seq_field_open(name);
898 opts.backend.write_styled(w, &open, tag_color)?;
899 } else {
900 let open = flavor.seq_open();
901 opts.backend.write_styled(w, &open, tag_color)?;
902 }
903 writeln!(w)?;
904
905 for child_id in children {
907 render_node(layout, w, child_id, depth + 1, opts, flavor)?;
908 }
909
910 write_indent(w, depth, opts)?;
912 if let Some(prefix) = change.prefix() {
913 opts.backend
914 .write_prefix(w, prefix, element_change_to_semantic(change))?;
915 write!(w, " ")?;
916 }
917
918 if let Some(name) = field_name {
920 let close = flavor.format_seq_field_close(name);
921 opts.backend.write_styled(w, &close, tag_color)?;
922 } else {
923 let close = flavor.seq_close();
924 opts.backend.write_styled(w, &close, tag_color)?;
925 }
926
927 if field_name.is_some() {
929 opts.backend
930 .write_styled(w, flavor.trailing_separator(), SemanticColor::Whitespace)?;
931 }
932 writeln!(w)?;
933
934 Ok(())
935}
936
937fn render_changed_group<W: Write, B: ColorBackend, F: DiffFlavor>(
938 layout: &Layout,
939 w: &mut W,
940 depth: usize,
941 opts: &RenderOptions<B>,
942 flavor: &F,
943 attrs: &[super::Attr],
944 group: &ChangedGroup,
945) -> fmt::Result {
946 write_indent_minus_prefix(w, depth, opts)?;
948 opts.backend.write_prefix(w, '←', SemanticColor::Deleted)?;
949 write!(w, " ")?;
950
951 let last_idx = group.attr_indices.len().saturating_sub(1);
952 for (i, &idx) in group.attr_indices.iter().enumerate() {
953 if i > 0 {
954 write!(w, "{}", flavor.field_separator())?;
955 }
956 let attr = &attrs[idx];
957 if let AttrStatus::Changed { old, new } = &attr.status {
958 let field_max_width = old.width.max(new.width);
960 opts.backend.write_styled(
962 w,
963 &flavor.format_field_prefix(&attr.name),
964 SemanticColor::DeletedKey,
965 )?;
966 let old_str = layout.get_string(old.span);
968 let color = value_color_highlight(old.value_type, ElementChange::Deleted);
969 opts.backend.write_styled(w, old_str, color)?;
970 opts.backend.write_styled(
972 w,
973 flavor.format_field_suffix(),
974 SemanticColor::DeletedStructure,
975 )?;
976 if i < last_idx {
978 let value_padding = field_max_width.saturating_sub(old.width);
979 for _ in 0..value_padding {
980 write!(w, " ")?;
981 }
982 }
983 }
984 }
985 writeln!(w)?;
986
987 write_indent_minus_prefix(w, depth, opts)?;
989 opts.backend.write_prefix(w, '→', SemanticColor::Inserted)?;
990 write!(w, " ")?;
991
992 for (i, &idx) in group.attr_indices.iter().enumerate() {
993 if i > 0 {
994 write!(w, "{}", flavor.field_separator())?;
995 }
996 let attr = &attrs[idx];
997 if let AttrStatus::Changed { old, new } = &attr.status {
998 let field_max_width = old.width.max(new.width);
1000 opts.backend.write_styled(
1002 w,
1003 &flavor.format_field_prefix(&attr.name),
1004 SemanticColor::InsertedKey,
1005 )?;
1006 let new_str = layout.get_string(new.span);
1008 let color = value_color_highlight(new.value_type, ElementChange::Inserted);
1009 opts.backend.write_styled(w, new_str, color)?;
1010 opts.backend.write_styled(
1012 w,
1013 flavor.format_field_suffix(),
1014 SemanticColor::InsertedStructure,
1015 )?;
1016 if i < last_idx {
1018 let value_padding = field_max_width.saturating_sub(new.width);
1019 for _ in 0..value_padding {
1020 write!(w, " ")?;
1021 }
1022 }
1023 }
1024 }
1025 writeln!(w)?;
1026
1027 Ok(())
1028}
1029
1030fn render_attr_unchanged<W: Write, B: ColorBackend, F: DiffFlavor>(
1031 layout: &Layout,
1032 w: &mut W,
1033 opts: &RenderOptions<B>,
1034 flavor: &F,
1035 name: &str,
1036 value: &super::FormattedValue,
1037) -> fmt::Result {
1038 let value_str = layout.get_string(value.span);
1039 let formatted = flavor.format_field(name, value_str);
1040 opts.backend
1041 .write_styled(w, &formatted, SemanticColor::Unchanged)
1042}
1043
1044fn render_attr_deleted<W: Write, B: ColorBackend, F: DiffFlavor>(
1045 layout: &Layout,
1046 w: &mut W,
1047 opts: &RenderOptions<B>,
1048 flavor: &F,
1049 name: &str,
1050 value: &super::FormattedValue,
1051) -> fmt::Result {
1052 let value_str = layout.get_string(value.span);
1053 let formatted = flavor.format_field(name, value_str);
1055 opts.backend
1056 .write_styled(w, &formatted, SemanticColor::DeletedHighlight)
1057}
1058
1059fn render_attr_inserted<W: Write, B: ColorBackend, F: DiffFlavor>(
1060 layout: &Layout,
1061 w: &mut W,
1062 opts: &RenderOptions<B>,
1063 flavor: &F,
1064 name: &str,
1065 value: &super::FormattedValue,
1066) -> fmt::Result {
1067 let value_str = layout.get_string(value.span);
1068 let formatted = flavor.format_field(name, value_str);
1070 opts.backend
1071 .write_styled(w, &formatted, SemanticColor::InsertedHighlight)
1072}
1073
1074fn write_indent<W: Write, B: ColorBackend>(
1075 w: &mut W,
1076 depth: usize,
1077 opts: &RenderOptions<B>,
1078) -> fmt::Result {
1079 for _ in 0..depth {
1080 write!(w, "{}", opts.indent)?;
1081 }
1082 Ok(())
1083}
1084
1085fn write_indent_minus_prefix<W: Write, B: ColorBackend>(
1088 w: &mut W,
1089 depth: usize,
1090 opts: &RenderOptions<B>,
1091) -> fmt::Result {
1092 let total_indent = depth * opts.indent.len();
1093 let gutter_indent = total_indent.saturating_sub(2);
1094 for _ in 0..gutter_indent {
1095 write!(w, " ")?;
1096 }
1097 Ok(())
1098}
1099
1100#[cfg(test)]
1101mod tests {
1102 use indextree::Arena;
1103
1104 use super::*;
1105 use crate::layout::{Attr, FormatArena, FormattedValue, Layout, LayoutNode, XmlFlavor};
1106
1107 fn make_test_layout() -> Layout {
1108 let mut strings = FormatArena::new();
1109 let tree = Arena::new();
1110
1111 let (red_span, red_width) = strings.push_str("red");
1113 let (blue_span, blue_width) = strings.push_str("blue");
1114
1115 let fill_attr = Attr::changed(
1116 "fill",
1117 4,
1118 FormattedValue::new(red_span, red_width),
1119 FormattedValue::new(blue_span, blue_width),
1120 );
1121
1122 let attrs = vec![fill_attr];
1123 let changed_groups = super::super::group_changed_attrs(&attrs, 80, 0);
1124
1125 let root = LayoutNode::Element {
1126 tag: "rect",
1127 field_name: None,
1128 attrs,
1129 changed_groups,
1130 change: ElementChange::None,
1131 };
1132
1133 Layout::new(strings, tree, root)
1134 }
1135
1136 #[test]
1137 fn test_render_simple_change() {
1138 let layout = make_test_layout();
1139 let opts = RenderOptions::plain();
1140 let output = render_to_string(&layout, &opts, &XmlFlavor);
1141
1142 assert!(output.contains("← <rect fill=\"red\""));
1146 assert!(output.contains("→ <rect fill=\"blue\""));
1147 assert!(output.contains("/>"));
1148 }
1149
1150 #[test]
1151 fn test_render_collapsed() {
1152 let strings = FormatArena::new();
1153 let tree = Arena::new();
1154
1155 let root = LayoutNode::collapsed(5);
1156 let layout = Layout::new(strings, tree, root);
1157
1158 let opts = RenderOptions::plain();
1159 let output = render_to_string(&layout, &opts, &XmlFlavor);
1160
1161 assert!(output.contains("<!-- 5 unchanged -->"));
1162 }
1163
1164 #[test]
1165 fn test_render_with_children() {
1166 let mut strings = FormatArena::new();
1167 let mut tree = Arena::new();
1168
1169 let parent = tree.new_node(LayoutNode::Element {
1171 tag: "svg",
1172 field_name: None,
1173 attrs: vec![],
1174 changed_groups: vec![],
1175 change: ElementChange::None,
1176 });
1177
1178 let (red_span, red_width) = strings.push_str("red");
1180 let (blue_span, blue_width) = strings.push_str("blue");
1181
1182 let fill_attr = Attr::changed(
1183 "fill",
1184 4,
1185 FormattedValue::new(red_span, red_width),
1186 FormattedValue::new(blue_span, blue_width),
1187 );
1188 let attrs = vec![fill_attr];
1189 let changed_groups = super::super::group_changed_attrs(&attrs, 80, 0);
1190
1191 let child = tree.new_node(LayoutNode::Element {
1192 tag: "rect",
1193 field_name: None,
1194 attrs,
1195 changed_groups,
1196 change: ElementChange::None,
1197 });
1198
1199 parent.append(child, &mut tree);
1200
1201 let layout = Layout {
1202 strings,
1203 tree,
1204 root: parent,
1205 };
1206
1207 let opts = RenderOptions::plain();
1208 let output = render_to_string(&layout, &opts, &XmlFlavor);
1209
1210 assert!(output.contains("<svg>"));
1211 assert!(output.contains("</svg>"));
1212 assert!(output.contains("<rect"));
1213 }
1214
1215 #[test]
1216 fn test_ansi_backend_produces_escapes() {
1217 let layout = make_test_layout();
1218 let opts = RenderOptions::default();
1219 let output = render_to_string(&layout, &opts, &XmlFlavor);
1220
1221 assert!(
1223 output.contains("\x1b["),
1224 "output should contain ANSI escapes"
1225 );
1226 }
1227}