Skip to main content

diffkit/
render.rs

1use crate::diff::{DiffNode, DiffStatus};
2use crate::engine::{DiffReport, TreeReport};
3use crate::model::CallRelation;
4
5pub fn render_report(report: &DiffReport) -> String {
6    render_report_with_options(report, &RenderOptions::default())
7}
8
9#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
10pub enum ColorMode {
11    #[default]
12    Ansi,
13    Plain,
14}
15
16#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
17pub struct RenderOptions {
18    pub show_types: bool,
19    pub color: ColorMode,
20}
21
22pub fn render_report_with_options(report: &DiffReport, options: &RenderOptions) -> String {
23    let mut parts = vec![
24        format!(
25            "{}diff {} → {}",
26            report.language, report.before, report.after
27        ),
28        String::new(),
29    ];
30
31    if let Some(message) = &report.message {
32        parts.push(message.clone());
33        return parts.join("\n");
34    }
35
36    for (index, entry) in report.trees.iter().enumerate() {
37        if index > 0 {
38            parts.push(String::new());
39        }
40        parts.push(render_diff_tree_with_options(&entry.tree, options));
41    }
42    parts.join("\n")
43}
44
45pub fn render_tree_report_with_options(report: &TreeReport, options: &RenderOptions) -> String {
46    let mut parts = vec![report.source.clone(), String::new()];
47    if let Some(message) = &report.message {
48        parts.push(message.clone());
49        return parts.join("\n");
50    }
51    for (index, entry) in report.trees.iter().enumerate() {
52        if index > 0 {
53            parts.push(String::new());
54        }
55        parts.push(render_call_tree_with_options(&entry.tree, options));
56    }
57    parts.join("\n")
58}
59
60pub fn render_call_tree_with_options(
61    root: &crate::model::CallNode,
62    options: &RenderOptions,
63) -> String {
64    let diff = crate::diff::diff_optional(Some(root), Some(root))
65        .expect("a call tree always produces a diff node");
66    render_diff_tree_with_options(&diff, options)
67}
68
69pub fn render_diff_tree(root: &DiffNode) -> String {
70    render_diff_tree_with_options(root, &RenderOptions::default())
71}
72
73pub fn render_diff_tree_with_options(root: &DiffNode, options: &RenderOptions) -> String {
74    let mut lines = Vec::new();
75    render_node(root, "", true, true, options, &mut lines);
76    connect_back_edges(&mut lines);
77    lines
78        .into_iter()
79        .map(|line| match line.color {
80            Some(color) => color_line(line.text, color, options.color),
81            None => line.text,
82        })
83        .collect::<Vec<_>>()
84        .join("\n")
85}
86
87#[derive(Debug)]
88struct RenderLine {
89    text: String,
90    color: Option<AnsiColor>,
91    key: Option<String>,
92    backedge_target: Option<String>,
93}
94
95fn render_node(
96    node: &DiffNode,
97    indent: &str,
98    is_last: bool,
99    is_root: bool,
100    options: &RenderOptions,
101    lines: &mut Vec<RenderLine>,
102) {
103    let branch_prefix = branch(node.relation, is_last, is_root);
104    if node.relation == CallRelation::BackEdge {
105        let (marker, color) = match node.status {
106            DiffStatus::Same => ("  ", None),
107            DiffStatus::Added => ("+ ", Some(AnsiColor::Green)),
108            DiffStatus::Removed => ("- ", Some(AnsiColor::Red)),
109            DiffStatus::Modified => ("+ ", Some(AnsiColor::Green)),
110        };
111        lines.push(RenderLine {
112            text: format!(
113                "{marker}{indent}{branch_prefix}{}",
114                node.label.text(options.show_types)
115            ),
116            color,
117            key: None,
118            backedge_target: Some(node.key.clone()),
119        });
120        return;
121    }
122
123    match node.status {
124        DiffStatus::Same => lines.push(RenderLine {
125            text: format!(
126                "  {indent}{branch_prefix}{}",
127                node.label.text(options.show_types)
128            ),
129            color: None,
130            key: backedge_anchor(node),
131            backedge_target: None,
132        }),
133        DiffStatus::Added => lines.push(RenderLine {
134            text: format!(
135                "+ {indent}{branch_prefix}{}",
136                node.label.text(options.show_types)
137            ),
138            color: Some(AnsiColor::Green),
139            key: backedge_anchor(node),
140            backedge_target: None,
141        }),
142        DiffStatus::Removed => lines.push(RenderLine {
143            text: format!(
144                "- {indent}{branch_prefix}{}",
145                node.label.text(options.show_types)
146            ),
147            color: Some(AnsiColor::Red),
148            key: backedge_anchor(node),
149            backedge_target: None,
150        }),
151        DiffStatus::Modified => {
152            let before = node
153                .before_label
154                .as_ref()
155                .expect("modified nodes carry their previous label");
156            lines.push(RenderLine {
157                text: format!(
158                    "- {indent}{}{}",
159                    branch(
160                        node.before_relation.unwrap_or(node.relation),
161                        is_last,
162                        is_root
163                    ),
164                    before.text(options.show_types)
165                ),
166                color: Some(AnsiColor::Red),
167                key: backedge_anchor(node),
168                backedge_target: None,
169            });
170            lines.push(RenderLine {
171                text: format!(
172                    "+ {indent}{branch_prefix}{}",
173                    node.label.text(options.show_types)
174                ),
175                color: Some(AnsiColor::Green),
176                key: backedge_anchor(node),
177                backedge_target: None,
178            });
179        }
180    }
181
182    let child_indent = if is_root {
183        String::new()
184    } else if is_last {
185        format!("{indent}   ")
186    } else {
187        let continuation = match node.relation {
188            CallRelation::Call => "│  ",
189            CallRelation::DispatchCandidate => "║  ",
190            CallRelation::BackEdge => unreachable!("back edges have no children"),
191        };
192        format!("{indent}{continuation}")
193    };
194    for (index, child) in node.children.iter().enumerate() {
195        render_node(
196            child,
197            &child_indent,
198            index + 1 == node.children.len(),
199            false,
200            options,
201            lines,
202        );
203    }
204}
205
206fn backedge_anchor(node: &DiffNode) -> Option<String> {
207    (!node.children.is_empty()).then(|| node.key.clone())
208}
209
210fn branch(relation: CallRelation, is_last: bool, is_root: bool) -> &'static str {
211    if is_root {
212        ""
213    } else {
214        match (relation, is_last) {
215            (CallRelation::Call, false) => "├─ ",
216            (CallRelation::Call, true) => "└─ ",
217            (CallRelation::DispatchCandidate, false) => "╠═ ",
218            (CallRelation::DispatchCandidate, true) => "╚═ ",
219            (CallRelation::BackEdge, false) => "├─ ",
220            (CallRelation::BackEdge, true) => "└─ ",
221        }
222    }
223}
224
225fn connect_back_edges(lines: &mut [RenderLine]) {
226    let edges = lines
227        .iter()
228        .enumerate()
229        .filter_map(|(end, line)| {
230            let target = line.backedge_target.as_ref()?;
231            let start = lines[..end]
232                .iter()
233                .rposition(|candidate| candidate.key.as_ref() == Some(target))?;
234            Some((start, end))
235        })
236        .collect::<Vec<_>>();
237    if edges.is_empty() {
238        return;
239    }
240
241    let original_lengths = lines
242        .iter()
243        .map(|line| line.text.trim_end().chars().count())
244        .collect::<Vec<_>>();
245    let base_rail = original_lengths.iter().copied().max().unwrap_or(0) + 3;
246    let mut rows = lines
247        .iter()
248        .map(|line| line.text.trim_end().chars().collect::<Vec<_>>())
249        .collect::<Vec<_>>();
250
251    let mut grouped = std::collections::BTreeMap::<usize, Vec<usize>>::new();
252    for (start, end) in edges {
253        grouped.entry(start).or_default().push(end);
254    }
255
256    for (rail_index, (start, ends)) in grouped.into_iter().enumerate() {
257        let rail = base_rail + rail_index * 3;
258        let arrow = original_lengths[start] + 1;
259        set_glyph(&mut rows[start], arrow, '◀');
260        for column in arrow + 1..rail {
261            set_glyph(&mut rows[start], column, '─');
262        }
263        set_glyph(&mut rows[start], rail, '┐');
264
265        let last_end = *ends.last().expect("a back-edge group is never empty");
266        for row in rows.iter_mut().take(last_end).skip(start + 1) {
267            set_glyph(row, rail, '│');
268        }
269        for end in ends {
270            for column in original_lengths[end] + 1..rail {
271                set_glyph(&mut rows[end], column, '─');
272            }
273            set_glyph(
274                &mut rows[end],
275                rail,
276                if end == last_end { '┘' } else { '┤' },
277            );
278        }
279    }
280
281    for (line, row) in lines.iter_mut().zip(rows) {
282        line.text = row.into_iter().collect::<String>().trim_end().to_owned();
283    }
284}
285
286fn set_glyph(row: &mut Vec<char>, column: usize, glyph: char) {
287    if row.len() <= column {
288        row.resize(column + 1, ' ');
289    }
290    row[column] = merge_glyph(row[column], glyph);
291}
292
293fn merge_glyph(existing: char, next: char) -> char {
294    match (existing, next) {
295        (' ', next) => next,
296        ('─', '│') | ('│', '─') => '┼',
297        (_, next) => next,
298    }
299}
300
301#[derive(Clone, Copy, Debug)]
302enum AnsiColor {
303    Red,
304    Green,
305}
306
307fn color_line(line: String, color: AnsiColor, mode: ColorMode) -> String {
308    if mode == ColorMode::Plain {
309        return line;
310    }
311    let code = match color {
312        AnsiColor::Red => 31,
313        AnsiColor::Green => 32,
314    };
315    format!("\u{1b}[{code}m{line}\u{1b}[0m")
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321    use crate::diff::diff_optional;
322    use crate::model::{CallLabel, CallNode, CallRelation};
323
324    #[test]
325    fn ansi_is_the_default_color_mode() {
326        assert_eq!(ColorMode::default(), ColorMode::Ansi);
327        assert_eq!(RenderOptions::default().color, ColorMode::Ansi);
328    }
329
330    #[test]
331    fn ansi_mode_colors_complete_diff_lines() {
332        assert_eq!(
333            color_line(
334                "+ └─ commit()".to_owned(),
335                AnsiColor::Green,
336                ColorMode::Ansi
337            ),
338            "\u{1b}[32m+ └─ commit()\u{1b}[0m"
339        );
340        assert_eq!(
341            color_line(
342                "- └─ rollback()".to_owned(),
343                AnsiColor::Red,
344                ColorMode::Ansi
345            ),
346            "\u{1b}[31m- └─ rollback()\u{1b}[0m"
347        );
348    }
349
350    #[test]
351    fn plain_mode_has_no_terminal_control_sequences() {
352        assert_eq!(
353            color_line(
354                "+ └─ commit()".to_owned(),
355                AnsiColor::Green,
356                ColorMode::Plain
357            ),
358            "+ └─ commit()"
359        );
360    }
361
362    #[test]
363    fn renders_dispatch_candidates_with_a_complete_double_line_relation() {
364        let tree = CallNode {
365            key: "rust://run".to_owned(),
366            callsite: None,
367            label: CallLabel::new("run(store, order)"),
368            relation: CallRelation::Call,
369            children: vec![CallNode {
370                key: "rust://Store::save".to_owned(),
371                callsite: None,
372                label: CallLabel::new("dyn Store::save(order)"),
373                relation: CallRelation::Call,
374                children: vec![
375                    CallNode {
376                        key: "rust://Postgres::save".to_owned(),
377                        callsite: None,
378                        label: CallLabel::new("Postgres::save(order)"),
379                        relation: CallRelation::DispatchCandidate,
380                        children: vec![CallNode {
381                            key: "rust://sql::insert".to_owned(),
382                            callsite: None,
383                            label: CallLabel::new("sql::insert(order)"),
384                            relation: CallRelation::Call,
385                            children: Vec::new(),
386                        }],
387                    },
388                    CallNode {
389                        key: "rust://S3::save".to_owned(),
390                        callsite: None,
391                        label: CallLabel::new("S3::save(order)"),
392                        relation: CallRelation::DispatchCandidate,
393                        children: vec![CallNode {
394                            key: "rust://aws::put_object".to_owned(),
395                            callsite: None,
396                            label: CallLabel::new("aws::put_object(order)"),
397                            relation: CallRelation::Call,
398                            children: Vec::new(),
399                        }],
400                    },
401                ],
402            }],
403        };
404        let diff = diff_optional(Some(&tree), Some(&tree)).unwrap();
405
406        assert_eq!(
407            render_diff_tree_with_options(
408                &diff,
409                &RenderOptions {
410                    show_types: false,
411                    color: ColorMode::Plain,
412                },
413            ),
414            "  run(store, order)\n  └─ dyn Store::save(order)\n     ╠═ Postgres::save(order)\n     ║  └─ sql::insert(order)\n     ╚═ S3::save(order)\n        └─ aws::put_object(order)"
415        );
416    }
417
418    #[test]
419    fn renders_recursive_calls_as_right_side_back_edges() {
420        let tree = CallNode {
421            key: "rust://a".to_owned(),
422            callsite: None,
423            label: CallLabel::new("a()"),
424            relation: CallRelation::Call,
425            children: vec![CallNode {
426                key: "rust://b".to_owned(),
427                callsite: None,
428                label: CallLabel::new("b()"),
429                relation: CallRelation::Call,
430                children: vec![CallNode {
431                    key: "rust://a".to_owned(),
432                    callsite: None,
433                    label: CallLabel::new("a()"),
434                    relation: CallRelation::BackEdge,
435                    children: Vec::new(),
436                }],
437            }],
438        };
439
440        assert_eq!(
441            render_call_tree_with_options(
442                &tree,
443                &RenderOptions {
444                    show_types: false,
445                    color: ColorMode::Plain,
446                },
447            ),
448            "  a() ◀───────┐\n  └─ b()      │\n     └─ a() ──┘"
449        );
450    }
451
452    #[test]
453    fn shares_one_rail_between_calls_to_the_same_recursive_ancestor() {
454        let back_edge = |label: &str| CallNode {
455            key: "ocaml://walk".to_owned(),
456            callsite: None,
457            label: CallLabel::new(label),
458            relation: CallRelation::BackEdge,
459            children: Vec::new(),
460        };
461        let tree = CallNode {
462            key: "ocaml://walk".to_owned(),
463            callsite: None,
464            label: CallLabel::new("walk value"),
465            relation: CallRelation::Call,
466            children: vec![
467                back_edge("walk left"),
468                back_edge("walk right"),
469                back_edge("walk tail"),
470            ],
471        };
472
473        let rendered = render_call_tree_with_options(
474            &tree,
475            &RenderOptions {
476                show_types: false,
477                color: ColorMode::Plain,
478            },
479        );
480        assert_eq!(rendered.matches('┐').count(), 1, "{rendered}");
481        assert_eq!(rendered.matches('┤').count(), 2, "{rendered}");
482        assert_eq!(rendered.matches('┘').count(), 1, "{rendered}");
483        assert!(rendered.lines().all(|line| line.chars().count() < 32));
484    }
485
486    #[test]
487    fn keeps_partial_and_unresolved_dispatch_wording_distinct() {
488        let tree = CallNode {
489            key: "rust://run".to_owned(),
490            callsite: None,
491            label: CallLabel::new("run(store)"),
492            relation: CallRelation::Call,
493            children: vec![
494                CallNode {
495                    key: "rust://Store::save#partial".to_owned(),
496                    callsite: None,
497                    label: CallLabel::new("dyn Store::save() [partial]"),
498                    relation: CallRelation::Call,
499                    children: vec![
500                        CallNode {
501                            key: "rust://Postgres::save".to_owned(),
502                            callsite: None,
503                            label: CallLabel::new("Postgres::save()"),
504                            relation: CallRelation::DispatchCandidate,
505                            children: Vec::new(),
506                        },
507                        CallNode {
508                            key: "rust://Store::save#unresolved".to_owned(),
509                            callsite: None,
510                            label: CallLabel::new("… unresolved targets"),
511                            relation: CallRelation::DispatchCandidate,
512                            children: Vec::new(),
513                        },
514                    ],
515                },
516                CallNode {
517                    key: "rust://Store::load".to_owned(),
518                    callsite: None,
519                    label: CallLabel::new("dyn Store::load() [unresolved]"),
520                    relation: CallRelation::Call,
521                    children: Vec::new(),
522                },
523            ],
524        };
525
526        assert_eq!(
527            render_call_tree_with_options(
528                &tree,
529                &RenderOptions {
530                    show_types: false,
531                    color: ColorMode::Plain,
532                },
533            ),
534            "  run(store)\n  ├─ dyn Store::save() [partial]\n  │  ╠═ Postgres::save()\n  │  ╚═ … unresolved targets\n  └─ dyn Store::load() [unresolved]"
535        );
536    }
537}