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!("{marker}{indent}{branch_prefix}"),
113            color,
114            key: None,
115            backedge_target: Some(node.key.clone()),
116        });
117        return;
118    }
119
120    match node.status {
121        DiffStatus::Same => lines.push(RenderLine {
122            text: format!(
123                "  {indent}{branch_prefix}{}",
124                node.label.text(options.show_types)
125            ),
126            color: None,
127            key: Some(node.key.clone()),
128            backedge_target: None,
129        }),
130        DiffStatus::Added => lines.push(RenderLine {
131            text: format!(
132                "+ {indent}{branch_prefix}{}",
133                node.label.text(options.show_types)
134            ),
135            color: Some(AnsiColor::Green),
136            key: Some(node.key.clone()),
137            backedge_target: None,
138        }),
139        DiffStatus::Removed => lines.push(RenderLine {
140            text: format!(
141                "- {indent}{branch_prefix}{}",
142                node.label.text(options.show_types)
143            ),
144            color: Some(AnsiColor::Red),
145            key: Some(node.key.clone()),
146            backedge_target: None,
147        }),
148        DiffStatus::Modified => {
149            let before = node
150                .before_label
151                .as_ref()
152                .expect("modified nodes carry their previous label");
153            lines.push(RenderLine {
154                text: format!(
155                    "- {indent}{}{}",
156                    branch(
157                        node.before_relation.unwrap_or(node.relation),
158                        is_last,
159                        is_root
160                    ),
161                    before.text(options.show_types)
162                ),
163                color: Some(AnsiColor::Red),
164                key: Some(node.key.clone()),
165                backedge_target: None,
166            });
167            lines.push(RenderLine {
168                text: format!(
169                    "+ {indent}{branch_prefix}{}",
170                    node.label.text(options.show_types)
171                ),
172                color: Some(AnsiColor::Green),
173                key: Some(node.key.clone()),
174                backedge_target: None,
175            });
176        }
177    }
178
179    let child_indent = if is_root {
180        String::new()
181    } else if is_last {
182        format!("{indent}   ")
183    } else {
184        let continuation = match node.relation {
185            CallRelation::Call => "│  ",
186            CallRelation::DispatchCandidate => "║  ",
187            CallRelation::BackEdge => unreachable!("back edges have no children"),
188        };
189        format!("{indent}{continuation}")
190    };
191    for (index, child) in node.children.iter().enumerate() {
192        render_node(
193            child,
194            &child_indent,
195            index + 1 == node.children.len(),
196            false,
197            options,
198            lines,
199        );
200    }
201}
202
203fn branch(relation: CallRelation, is_last: bool, is_root: bool) -> &'static str {
204    if is_root {
205        ""
206    } else {
207        match (relation, is_last) {
208            (CallRelation::Call, false) => "├─ ",
209            (CallRelation::Call, true) => "└─ ",
210            (CallRelation::DispatchCandidate, false) => "╠═ ",
211            (CallRelation::DispatchCandidate, true) => "╚═ ",
212            (CallRelation::BackEdge, false) => "├─ ",
213            (CallRelation::BackEdge, true) => "└─ ",
214        }
215    }
216}
217
218fn connect_back_edges(lines: &mut [RenderLine]) {
219    let edges = lines
220        .iter()
221        .enumerate()
222        .filter_map(|(end, line)| {
223            let target = line.backedge_target.as_ref()?;
224            let start = lines[..end]
225                .iter()
226                .rposition(|candidate| candidate.key.as_ref() == Some(target))?;
227            Some((start, end))
228        })
229        .collect::<Vec<_>>();
230    if edges.is_empty() {
231        return;
232    }
233
234    let original_lengths = lines
235        .iter()
236        .map(|line| line.text.trim_end().chars().count())
237        .collect::<Vec<_>>();
238    let base_rail = original_lengths.iter().copied().max().unwrap_or(0) + 3;
239    let mut rows = lines
240        .iter()
241        .map(|line| line.text.trim_end().chars().collect::<Vec<_>>())
242        .collect::<Vec<_>>();
243
244    for (edge_index, (start, end)) in edges.into_iter().enumerate() {
245        let rail = base_rail + edge_index * 3;
246        let arrow = original_lengths[start] + 1;
247        set_glyph(&mut rows[start], arrow, '◀');
248        for column in arrow + 1..rail {
249            set_glyph(&mut rows[start], column, '─');
250        }
251        set_glyph(&mut rows[start], rail, '┐');
252
253        for row in rows.iter_mut().take(end).skip(start + 1) {
254            set_glyph(row, rail, '│');
255        }
256        for column in original_lengths[end]..rail {
257            set_glyph(&mut rows[end], column, '─');
258        }
259        set_glyph(&mut rows[end], rail, '┘');
260    }
261
262    for (line, row) in lines.iter_mut().zip(rows) {
263        line.text = row.into_iter().collect::<String>().trim_end().to_owned();
264    }
265}
266
267fn set_glyph(row: &mut Vec<char>, column: usize, glyph: char) {
268    if row.len() <= column {
269        row.resize(column + 1, ' ');
270    }
271    row[column] = merge_glyph(row[column], glyph);
272}
273
274fn merge_glyph(existing: char, next: char) -> char {
275    match (existing, next) {
276        (' ', next) => next,
277        ('─', '│') | ('│', '─') => '┼',
278        (_, next) => next,
279    }
280}
281
282#[derive(Clone, Copy, Debug)]
283enum AnsiColor {
284    Red,
285    Green,
286}
287
288fn color_line(line: String, color: AnsiColor, mode: ColorMode) -> String {
289    if mode == ColorMode::Plain {
290        return line;
291    }
292    let code = match color {
293        AnsiColor::Red => 31,
294        AnsiColor::Green => 32,
295    };
296    format!("\u{1b}[{code}m{line}\u{1b}[0m")
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302    use crate::diff::diff_optional;
303    use crate::model::{CallLabel, CallNode, CallRelation};
304
305    #[test]
306    fn ansi_is_the_default_color_mode() {
307        assert_eq!(ColorMode::default(), ColorMode::Ansi);
308        assert_eq!(RenderOptions::default().color, ColorMode::Ansi);
309    }
310
311    #[test]
312    fn ansi_mode_colors_complete_diff_lines() {
313        assert_eq!(
314            color_line(
315                "+ └─ commit()".to_owned(),
316                AnsiColor::Green,
317                ColorMode::Ansi
318            ),
319            "\u{1b}[32m+ └─ commit()\u{1b}[0m"
320        );
321        assert_eq!(
322            color_line(
323                "- └─ rollback()".to_owned(),
324                AnsiColor::Red,
325                ColorMode::Ansi
326            ),
327            "\u{1b}[31m- └─ rollback()\u{1b}[0m"
328        );
329    }
330
331    #[test]
332    fn plain_mode_has_no_terminal_control_sequences() {
333        assert_eq!(
334            color_line(
335                "+ └─ commit()".to_owned(),
336                AnsiColor::Green,
337                ColorMode::Plain
338            ),
339            "+ └─ commit()"
340        );
341    }
342
343    #[test]
344    fn renders_dispatch_candidates_with_a_complete_double_line_relation() {
345        let tree = CallNode {
346            key: "rust://run".to_owned(),
347            callsite: None,
348            label: CallLabel::new("run(store, order)"),
349            relation: CallRelation::Call,
350            children: vec![CallNode {
351                key: "rust://Store::save".to_owned(),
352                callsite: None,
353                label: CallLabel::new("dyn Store::save(order)"),
354                relation: CallRelation::Call,
355                children: vec![
356                    CallNode {
357                        key: "rust://Postgres::save".to_owned(),
358                        callsite: None,
359                        label: CallLabel::new("Postgres::save(order)"),
360                        relation: CallRelation::DispatchCandidate,
361                        children: vec![CallNode {
362                            key: "rust://sql::insert".to_owned(),
363                            callsite: None,
364                            label: CallLabel::new("sql::insert(order)"),
365                            relation: CallRelation::Call,
366                            children: Vec::new(),
367                        }],
368                    },
369                    CallNode {
370                        key: "rust://S3::save".to_owned(),
371                        callsite: None,
372                        label: CallLabel::new("S3::save(order)"),
373                        relation: CallRelation::DispatchCandidate,
374                        children: vec![CallNode {
375                            key: "rust://aws::put_object".to_owned(),
376                            callsite: None,
377                            label: CallLabel::new("aws::put_object(order)"),
378                            relation: CallRelation::Call,
379                            children: Vec::new(),
380                        }],
381                    },
382                ],
383            }],
384        };
385        let diff = diff_optional(Some(&tree), Some(&tree)).unwrap();
386
387        assert_eq!(
388            render_diff_tree_with_options(
389                &diff,
390                &RenderOptions {
391                    show_types: false,
392                    color: ColorMode::Plain,
393                },
394            ),
395            "  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)"
396        );
397    }
398
399    #[test]
400    fn renders_recursive_calls_as_right_side_back_edges() {
401        let tree = CallNode {
402            key: "rust://a".to_owned(),
403            callsite: None,
404            label: CallLabel::new("a()"),
405            relation: CallRelation::Call,
406            children: vec![CallNode {
407                key: "rust://b".to_owned(),
408                callsite: None,
409                label: CallLabel::new("b()"),
410                relation: CallRelation::Call,
411                children: vec![CallNode {
412                    key: "rust://a".to_owned(),
413                    callsite: None,
414                    label: CallLabel::new(""),
415                    relation: CallRelation::BackEdge,
416                    children: Vec::new(),
417                }],
418            }],
419        };
420
421        assert_eq!(
422            render_call_tree_with_options(
423                &tree,
424                &RenderOptions {
425                    show_types: false,
426                    color: ColorMode::Plain,
427                },
428            ),
429            "  a() ◀────┐\n  └─ b()   │\n     └─────┘"
430        );
431    }
432
433    #[test]
434    fn keeps_partial_and_unresolved_dispatch_wording_distinct() {
435        let tree = CallNode {
436            key: "rust://run".to_owned(),
437            callsite: None,
438            label: CallLabel::new("run(store)"),
439            relation: CallRelation::Call,
440            children: vec![
441                CallNode {
442                    key: "rust://Store::save#partial".to_owned(),
443                    callsite: None,
444                    label: CallLabel::new("dyn Store::save() [partial]"),
445                    relation: CallRelation::Call,
446                    children: vec![
447                        CallNode {
448                            key: "rust://Postgres::save".to_owned(),
449                            callsite: None,
450                            label: CallLabel::new("Postgres::save()"),
451                            relation: CallRelation::DispatchCandidate,
452                            children: Vec::new(),
453                        },
454                        CallNode {
455                            key: "rust://Store::save#unresolved".to_owned(),
456                            callsite: None,
457                            label: CallLabel::new("… unresolved targets"),
458                            relation: CallRelation::DispatchCandidate,
459                            children: Vec::new(),
460                        },
461                    ],
462                },
463                CallNode {
464                    key: "rust://Store::load".to_owned(),
465                    callsite: None,
466                    label: CallLabel::new("dyn Store::load() [unresolved]"),
467                    relation: CallRelation::Call,
468                    children: Vec::new(),
469                },
470            ],
471        };
472
473        assert_eq!(
474            render_call_tree_with_options(
475                &tree,
476                &RenderOptions {
477                    show_types: false,
478                    color: ColorMode::Plain,
479                },
480            ),
481            "  run(store)\n  ├─ dyn Store::save() [partial]\n  │  ╠═ Postgres::save()\n  │  ╚═ … unresolved targets\n  └─ dyn Store::load() [unresolved]"
482        );
483    }
484}