Skip to main content

diffler_core/
loggraph.rs

1//! The lanes `git log --graph` draws beside history, one row per commit so a
2//! row stays one entry: a merge or a fork draws its connector on the commit's
3//! own row.
4
5use std::collections::{HashMap, HashSet};
6
7use crate::vcs::LogEntry;
8
9/// A lane's identity, which keeps one colour from where it starts to where it
10/// ends. `head` marks the lane walking HEAD's first-parent line.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub struct Lane {
13    pub id: usize,
14    pub head: bool,
15}
16
17/// One lane's two columns on a row: its glyph, then the character joining it
18/// to the next lane, each in its own lane's colour.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub struct Cell {
21    pub glyph: char,
22    pub glyph_lane: Option<Lane>,
23    pub fill: char,
24    pub fill_lane: Option<Lane>,
25}
26
27#[derive(Clone)]
28struct Slot {
29    awaits: String,
30    id: usize,
31}
32
33#[derive(Clone, Copy, PartialEq, Eq)]
34enum Join {
35    /// A lane ending here because it was waiting for this same commit.
36    Ends,
37    /// A merge's later parent that opens a new lane below.
38    Opens,
39    /// A merge's later parent already waited for in a lane of its own.
40    Meets,
41}
42
43/// The graph cells for each entry, in `entries`' order, which must list a
44/// commit before its parents. `head` is HEAD's full oid.
45pub fn layout(entries: &[LogEntry], head: Option<&str>) -> Vec<Vec<Cell>> {
46    let chain = first_parent_chain(entries, head);
47    // Several lanes can wait for one commit on HEAD's line; we accent only the
48    // lane HEAD's line itself walks down.
49    let mut head_lane = None;
50    let mut slots: Vec<Option<Slot>> = Vec::new();
51    let mut next_id = 0;
52    let mut rows = Vec::with_capacity(entries.len());
53    for entry in entries {
54        let col = slots
55            .iter()
56            .position(|slot| slot.as_ref().is_some_and(|s| s.awaits == entry.oid))
57            .or_else(|| slots.iter().position(Option::is_none))
58            .unwrap_or_else(|| {
59                slots.push(None);
60                slots.len() - 1
61            });
62        let id = slots.get(col).and_then(Option::as_ref).map_or_else(
63            || {
64                next_id += 1;
65                next_id - 1
66            },
67            |slot| slot.id,
68        );
69        let own = Lane {
70            id,
71            head: chain.contains(entry.oid.as_str()),
72        };
73        let walked = head_lane;
74        if own.head {
75            head_lane = Some(id);
76        }
77        let lane = |slot: &Slot| Lane {
78            id: slot.id,
79            head: [walked, head_lane].contains(&Some(slot.id))
80                && chain.contains(slot.awaits.as_str()),
81        };
82        let before = slots.clone();
83        let mut joins: Vec<(usize, Join, Lane)> = Vec::new();
84        for (index, slot) in slots.iter_mut().enumerate() {
85            if index != col
86                && let Some(waiting) = slot.take_if(|s| s.awaits == entry.oid)
87            {
88                joins.push((index, Join::Ends, lane(&waiting)));
89            }
90        }
91        if let Some(own_slot) = slots.get_mut(col) {
92            *own_slot = entry.parents.first().map(|parent| Slot {
93                awaits: parent.clone(),
94                id,
95            });
96        }
97        for parent in entry.parents.iter().skip(1) {
98            if let Some(index) = slots
99                .iter()
100                .position(|slot| slot.as_ref().is_some_and(|s| &s.awaits == parent))
101            {
102                let met = slots.get(index).and_then(Option::as_ref).map(lane);
103                joins.extend(met.map(|met| (index, Join::Meets, met)));
104                continue;
105            }
106            // We skip a column whose lane ends on this row, so the row still draws that end.
107            let index = (0..slots.len())
108                .find(|&index| {
109                    slots.get(index).is_some_and(Option::is_none)
110                        && !joins.iter().any(|(joined, ..)| *joined == index)
111                })
112                .unwrap_or_else(|| {
113                    slots.push(None);
114                    slots.len() - 1
115                });
116            let slot = Slot {
117                awaits: parent.clone(),
118                id: next_id,
119            };
120            next_id += 1;
121            joins.push((index, Join::Opens, lane(&slot)));
122            if let Some(free) = slots.get_mut(index) {
123                *free = Some(slot);
124            }
125        }
126        rows.push(draw_row(col, own, &before, &joins, &lane));
127        while slots.last().is_some_and(Option::is_none) {
128            slots.pop();
129        }
130    }
131    rows
132}
133
134fn draw_row(
135    col: usize,
136    own: Lane,
137    before: &[Option<Slot>],
138    joins: &[(usize, Join, Lane)],
139    lane: &impl Fn(&Slot) -> Lane,
140) -> Vec<Cell> {
141    let width = joins
142        .iter()
143        .map(|(index, ..)| index + 1)
144        .chain([before.len(), col + 1])
145        .max()
146        .unwrap_or(0);
147    let run_crossing_gap_after = |index: usize| {
148        joins.iter().find(|(target, ..)| {
149            let (lo, hi) = (col.min(*target), col.max(*target));
150            index >= lo && index < hi
151        })
152    };
153    let passing = |index: usize| before.get(index).and_then(Option::as_ref).map(lane);
154    (0..width)
155        .map(|index| {
156            let crossing = index
157                .checked_sub(1)
158                .and_then(run_crossing_gap_after)
159                .filter(|_| run_crossing_gap_after(index).is_some());
160            let (glyph, glyph_lane) = if index == col {
161                ('●', Some(own))
162            } else if let Some((_, join, joined)) = joins.iter().find(|(t, ..)| *t == index) {
163                let right = index > col;
164                // We draw a tee when the run continues to a farther join.
165                let beyond = if right {
166                    run_crossing_gap_after(index).is_some()
167                } else {
168                    index
169                        .checked_sub(1)
170                        .and_then(run_crossing_gap_after)
171                        .is_some()
172                };
173                let glyph = match (join, right, beyond) {
174                    (Join::Ends, _, true) => '┴',
175                    (Join::Opens, _, true) => '┬',
176                    (Join::Meets, _, true) => '┼',
177                    (Join::Ends, true, false) => '╯',
178                    (Join::Ends, false, false) => '╰',
179                    (Join::Opens, true, false) => '╮',
180                    (Join::Opens, false, false) => '╭',
181                    (Join::Meets, true, false) => '┤',
182                    (Join::Meets, false, false) => '├',
183                };
184                (glyph, Some(*joined))
185            } else if let Some(through) = passing(index) {
186                (if crossing.is_some() { '┼' } else { '│' }, Some(through))
187            } else if let Some((.., run)) = crossing {
188                ('─', Some(*run))
189            } else {
190                (' ', None)
191            };
192            let (fill, fill_lane) =
193                run_crossing_gap_after(index).map_or((' ', None), |(.., run)| ('─', Some(*run)));
194            Cell {
195                glyph,
196                glyph_lane,
197                fill,
198                fill_lane,
199            }
200        })
201        .collect()
202}
203
204/// HEAD and its first parents, the line a reader means by "this branch".
205fn first_parent_chain<'a>(entries: &'a [LogEntry], head: Option<&'a str>) -> HashSet<&'a str> {
206    let parents: HashMap<&str, Option<&str>> = entries
207        .iter()
208        .map(|entry| {
209            (
210                entry.oid.as_str(),
211                entry.parents.first().map(String::as_str),
212            )
213        })
214        .collect();
215    let mut chain = HashSet::new();
216    let mut at = head;
217    while let Some(oid) = at
218        && chain.insert(oid)
219    {
220        at = parents.get(oid).copied().flatten();
221    }
222    chain
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    fn entry(oid: &str, parents: &[&str]) -> LogEntry {
230        LogEntry {
231            oid: oid.to_owned(),
232            oid7: oid.to_owned(),
233            refs: Vec::new(),
234            parents: parents.iter().map(|p| (*p).to_owned()).collect(),
235            subject: String::new(),
236            author: String::new(),
237            time_unix: 0,
238            change_id: None,
239        }
240    }
241
242    fn text(rows: &[Vec<Cell>]) -> Vec<String> {
243        rows.iter()
244            .map(|row| {
245                row.iter()
246                    .flat_map(|cell| [cell.glyph, cell.fill])
247                    .collect::<String>()
248                    .trim_end()
249                    .to_owned()
250            })
251            .collect()
252    }
253
254    #[test]
255    fn a_straight_history_is_one_lane() {
256        let rows = layout(
257            &[entry("c", &["b"]), entry("b", &["a"]), entry("a", &[])],
258            Some("c"),
259        );
260        assert_eq!(text(&rows), ["●", "●", "●"]);
261        assert!(
262            rows.iter()
263                .all(|row| row[0].glyph_lane.is_some_and(|l| l.head))
264        );
265    }
266
267    #[test]
268    fn a_merge_opens_a_lane_that_ends_at_the_fork() {
269        // m merges f into main; f and b both come from a
270        let rows = layout(
271            &[
272                entry("m", &["b", "f"]),
273                entry("f", &["a"]),
274                entry("b", &["a"]),
275                entry("a", &[]),
276            ],
277            Some("m"),
278        );
279        assert_eq!(text(&rows), ["●─╮", "│ ●", "● │", "●─╯"]);
280        let side = rows[1][1].glyph_lane.expect("the merged branch has a lane");
281        assert!(!side.head, "only the first-parent line is HEAD's");
282        assert!(rows[2][0].glyph_lane.is_some_and(|l| l.head));
283    }
284
285    #[test]
286    fn two_branch_tips_share_their_fork() {
287        let rows = layout(
288            &[entry("x", &["a"]), entry("y", &["a"]), entry("a", &[])],
289            Some("y"),
290        );
291        assert_eq!(text(&rows), ["●", "│ ●", "●─╯"]);
292        assert_eq!(rows[0][0].glyph_lane.map(|l| l.head), Some(false));
293        assert_eq!(
294            rows[1][0].glyph_lane.map(|l| l.head),
295            Some(false),
296            "x's lane stays its own colour down to the fork"
297        );
298        assert_eq!(rows[2][1].glyph_lane.map(|l| l.head), Some(true));
299    }
300
301    #[test]
302    fn a_branch_forked_from_a_merge_meets_it_while_the_merge_opens_a_lane() {
303        let rows = layout(
304            &[
305                entry("x", &["a"]),
306                entry("y", &["a"]),
307                entry("a", &["b", "c"]),
308                entry("c", &["b"]),
309                entry("b", &[]),
310            ],
311            Some("y"),
312        );
313        assert_eq!(text(&rows), ["●", "│ ●", "●─┴─╮", "│   ●", "●───╯"]);
314    }
315
316    #[test]
317    fn lanes_meeting_one_commit_close_into_it_in_one_run() {
318        let rows = layout(
319            &[
320                entry("x", &["a"]),
321                entry("y", &["a"]),
322                entry("z", &["a"]),
323                entry("a", &[]),
324            ],
325            None,
326        );
327        assert_eq!(text(&rows).last().map(String::as_str), Some("●─┴─╯"));
328    }
329
330    #[test]
331    fn a_run_crossing_a_lane_draws_a_crossing() {
332        // a closes lane 2 while lane 1 still waits for r
333        let rows = layout(
334            &[
335                entry("x", &["a"]),
336                entry("y", &["p"]),
337                entry("z", &["a"]),
338                entry("a", &[]),
339                entry("p", &["r"]),
340                entry("r", &[]),
341            ],
342            None,
343        );
344        assert_eq!(text(&rows), ["●", "│ ●", "│ │ ●", "●─┼─╯", "  ●", "  ●"]);
345    }
346}