Skip to main content

drawdag/
lib.rs

1/*
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 */
7
8//! # drawdag
9//!
10//! Utilities to parse ASCII revision DAG and create commits from them.
11
12use std::collections::BTreeMap;
13use std::collections::BTreeSet;
14use std::collections::HashSet;
15
16mod succ;
17
18#[derive(Clone, Copy, Debug, Eq, PartialEq)]
19enum Direction {
20    /// From bottom to top. Roots are at the bottom.
21    BottomTop,
22
23    /// From left to right. Roots are at the left.
24    LeftRight,
25}
26
27/// Parse an ASCII DAG. Extract edge information.
28/// Return a map from names to their parents.
29///
30/// The direction of the graph is automatically detected.
31/// If `|` is used, then roots are at the bottom, heads are at the top side.
32/// Otherwise, `-` can be used, and roots are at the left, heads are at the
33/// right. `|` and `-` cannot be used together.
34///
35/// # Example:
36///
37/// ```
38/// use drawdag::parse;
39///
40/// let edges = parse(
41///     r#"
42///             E
43///              \
44///     C----B----A
45///        /
46///      D-
47/// "#,
48/// );
49/// let expected =
50///     "{\"A\": {\"B\", \"E\"}, \"B\": {\"C\", \"D\"}, \"C\": {}, \"D\": {}, \"E\": {}}";
51/// assert_eq!(format!("{:?}", edges), expected);
52///
53/// let edges = parse(
54///     r#"
55///   A
56///  /|
57/// | B
58/// E |
59///   |\
60///   C D
61/// "#,
62/// );
63/// assert_eq!(format!("{:?}", edges), expected);
64/// ```
65pub fn parse(text: &str) -> BTreeMap<String, BTreeSet<String>> {
66    use Direction::BottomTop;
67    use Direction::LeftRight;
68
69    // Detect direction.
70    let direction = if "|:".chars().any(|c| text.contains(c)) {
71        BottomTop
72    } else {
73        LeftRight
74    };
75    let lines: Vec<Vec<char>> = text.lines().map(|line| line.chars().collect()).collect();
76
77    // (y, x) -> char. Return a space if (y, x) is out of range.
78    let get = |y: isize, x: isize| -> char {
79        if y < 0 || x < 0 {
80            ' '
81        } else {
82            lines
83                .get(y as usize)
84                .cloned()
85                .map_or(' ', |line| line.get(x as usize).cloned().unwrap_or(' '))
86        }
87    };
88
89    // Like `get`, but concatenate left and right parts if they look like a word.
90    let get_name = |y: isize, x: isize| -> String {
91        (0..x)
92            .rev()
93            .map(|x| get(y, x))
94            .take_while(|&ch| is_name(ch, direction))
95            .collect::<Vec<_>>()
96            .into_iter()
97            .rev()
98            .chain(
99                (x..)
100                    .map(|x| get(y, x))
101                    .take_while(|&ch| is_name(ch, direction)),
102            )
103            .collect()
104    };
105
106    /// State used to visit the graph.
107    #[derive(Eq, PartialEq, Ord, PartialOrd, Hash, Copy, Clone)]
108    struct State {
109        y: isize,
110        x: isize,
111        expected: &'static str,
112        is_range: bool,
113    }
114
115    // Follow the ASCII edges at the given position.
116    // Return a list of (parent, is_range).
117    let get_parents = |y: isize, x: isize| -> Vec<(String, bool)> {
118        let mut parents = Vec::new();
119        let mut visited = HashSet::new();
120        let mut visit = |state: State, to_visit: &mut Vec<State>| {
121            if visited.insert(state) {
122                let y = state.y;
123                let x = state.x;
124                let expected = state.expected;
125                let ch = get(y, x);
126                if is_name(ch, direction) && expected.contains('t') {
127                    // t: text
128                    parents.push((get_name(y, x), state.is_range));
129                    return;
130                }
131                if !expected.contains(ch) {
132                    return;
133                }
134
135                // Quickly construct a `State`.
136                let is_range = state.is_range || ch == ':' || ch == '.';
137                let s = |y, x, expected| State {
138                    y,
139                    x,
140                    expected,
141                    is_range,
142                };
143
144                match (ch, direction) {
145                    (' ', _) => {}
146                    ('|', BottomTop) | (':', BottomTop) => {
147                        to_visit.push(s(y + 1, x - 1, "/"));
148                        to_visit.push(s(y + 1, x, ":|/\\t"));
149                        to_visit.push(s(y + 1, x + 1, "\\"));
150                    }
151                    ('\\', BottomTop) => {
152                        to_visit.push(s(y + 1, x + 1, ":|\\t"));
153                        to_visit.push(s(y + 1, x, ":|t"));
154                    }
155                    ('/', BottomTop) => {
156                        to_visit.push(s(y + 1, x - 1, ":|/t"));
157                        to_visit.push(s(y + 1, x, ":|t"));
158                    }
159                    ('-', LeftRight) | ('.', LeftRight) => {
160                        to_visit.push(s(y - 1, x - 1, "\\"));
161                        to_visit.push(s(y, x - 1, ".-/\\t"));
162                        to_visit.push(s(y + 1, x - 1, "/"));
163                    }
164                    ('\\', LeftRight) => {
165                        to_visit.push(s(y - 1, x - 1, ".-\\t"));
166                        to_visit.push(s(y, x - 1, ".-t"));
167                    }
168                    ('/', LeftRight) => {
169                        to_visit.push(s(y + 1, x - 1, ".-/t"));
170                        to_visit.push(s(y, x - 1, ".-t"));
171                    }
172                    _ => unreachable!(),
173                }
174            }
175        };
176
177        let s = |y, x, expected| State {
178            y,
179            x,
180            expected,
181            is_range: false,
182        };
183        let mut to_visit: Vec<State> = match direction {
184            BottomTop => [
185                s(y + 1, x - 1, "/"),
186                s(y + 1, x, "|:"),
187                s(y + 1, x + 1, "\\"),
188            ],
189            LeftRight => [
190                s(y - 1, x - 1, "\\"),
191                s(y, x - 1, "-."),
192                s(y + 1, x - 1, "/"),
193            ],
194        }
195        .iter()
196        .cloned()
197        .filter(|state| state.expected.contains(get(state.y, state.x)))
198        .collect();
199        while let Some(state) = to_visit.pop() {
200            visit(state, &mut to_visit);
201        }
202
203        parents
204    };
205
206    // Scan every character
207    let mut edges: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
208    for y in 0..lines.len() as isize {
209        for x in 0..lines[y as usize].len() as isize {
210            let ch = get(y, x);
211            if is_name(ch, direction) {
212                let name = get_name(y, x);
213                edges.entry(name.clone()).or_default();
214                for (parent, is_range) in get_parents(y, x) {
215                    if !is_range {
216                        edges.get_mut(&name).unwrap().insert(parent);
217                    } else {
218                        // Insert a chain of name -> parent. For example,
219                        // name="D", parent="A", insert D -> C -> B -> A.
220
221                        assert!(
222                            parent.len() < name.len()
223                                || (parent.len() == name.len() && parent < name),
224                            "empty range: {:?} to {:?}",
225                            parent,
226                            name
227                        );
228
229                        let mut current: String = parent.clone();
230                        loop {
231                            let next = succ::str_succ(&current);
232                            edges.entry(next.clone()).or_default().insert(current);
233
234                            if next == name {
235                                break;
236                            }
237
238                            assert!(
239                                next.len() < name.len()
240                                    || (next.len() == name.len() && next < name),
241                                "mismatched range endpoints: {:?} to {:?}",
242                                parent,
243                                name
244                            );
245
246                            current = next;
247                        }
248                    }
249                }
250            }
251            // Sanity check
252            match (ch, direction) {
253                ('-', BottomTop) => panic!("'-' is incompatible with BottomTop direction"),
254                ('|', LeftRight) => panic!("'|' is incompatible with LeftRight direction"),
255                _ => {}
256            }
257        }
258    }
259
260    edges
261}
262
263/// Commit the DAG by using the given commit function.
264///
265/// The commit function takes two arguments: Commit identity by the ASCII dag,
266/// and parents defined by the commit function. The commit function returns the
267/// identity of the committed change, and this function will use them as parents
268/// passed into the future `commit_func` calls.
269pub fn commit(
270    dag: &BTreeMap<String, BTreeSet<String>>,
271    mut commit_func: impl FnMut(String, Vec<Box<[u8]>>) -> Box<[u8]>,
272) {
273    let mut committed: BTreeMap<String, Box<[u8]>> = BTreeMap::new();
274
275    while committed.len() < dag.len() {
276        let mut made_progress = false;
277        for (name, parents) in dag.iter() {
278            if !committed.contains_key(name)
279                && parents.iter().all(|name| committed.contains_key(name))
280            {
281                let parent_ids = parents.iter().map(|name| committed[name].clone()).collect();
282                let new_id = commit_func(name.clone(), parent_ids);
283                committed.insert(name.to_string(), new_id);
284                made_progress = true;
285            }
286        }
287        assert!(made_progress, "graph contains cycles");
288    }
289}
290
291/// Parse the ASCII DAG and commit it. See [`parse`] and [`commit`] for details.
292pub fn drawdag(text: &str, commit_func: impl FnMut(String, Vec<Box<[u8]>>) -> Box<[u8]>) {
293    commit(&parse(text), commit_func)
294}
295
296fn is_name(ch: char, direction: Direction) -> bool {
297    match (ch, direction) {
298        ('.', Direction::BottomTop) => true,
299        _ => ch.is_alphanumeric() || ",()_'\"".contains(ch),
300    }
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306
307    struct CommitLog {
308        log: String,
309    }
310
311    impl CommitLog {
312        fn new() -> Self {
313            Self { log: String::new() }
314        }
315
316        fn commit(&mut self, name: String, parents: Vec<Box<[u8]>>) -> Box<[u8]> {
317            let new_id = self.log.chars().filter(|&ch| ch == '\n').count();
318            let parents_str: Vec<String> = parents
319                .into_iter()
320                .map(|p| String::from_utf8(p.into_vec()).unwrap())
321                .collect();
322            self.log += &format!(
323                "{}: {{ parents: {:?}, name: {} }}\n",
324                new_id, parents_str, name
325            );
326            format!("{}", new_id).as_bytes().to_vec().into_boxed_slice()
327        }
328    }
329
330    fn assert_drawdag(text: &str, expected: &str) {
331        let mut log = CommitLog::new();
332        drawdag(text, |n, p| log.commit(n, p));
333        assert_eq!(log.log, expected);
334    }
335
336    /// Parse drawdag text, and return a list of strings as the parse result.
337    /// Unlike `assert_drawdag`, `assert_eq!(d(t), e)` works with `cargo-fixeq`.
338    fn p(text: &str) -> Vec<String> {
339        parse(text)
340            .into_iter()
341            .map(|(k, vs)| {
342                let vs = vs.into_iter().collect::<Vec<_>>().join(", ");
343                format!("{} -> [{}]", k, vs)
344            })
345            .collect()
346    }
347
348    #[test]
349    #[should_panic]
350    fn test_drawdag_cycle1() {
351        let mut log = CommitLog::new();
352        drawdag("A-B B-A", |n, p| log.commit(n, p));
353    }
354
355    #[test]
356    #[should_panic]
357    fn test_drawdag_cycle2() {
358        let mut log = CommitLog::new();
359        drawdag("A-B-C-A", |n, p| log.commit(n, p));
360    }
361
362    #[test]
363    #[should_panic]
364    fn test_drawdag_mismatched_range1() {
365        let mut log = CommitLog::new();
366        drawdag("0..A", |n, p| log.commit(n, p));
367    }
368
369    #[test]
370    #[should_panic]
371    fn test_drawdag_mismatched_range2() {
372        let mut log = CommitLog::new();
373        drawdag("(09)..(A0)", |n, p| log.commit(n, p));
374    }
375
376    #[test]
377    fn test_drawdag() {
378        assert_drawdag(
379            "A-C-B",
380            r#"0: { parents: [], name: A }
3811: { parents: ["0"], name: C }
3822: { parents: ["1"], name: B }
383"#,
384        );
385
386        assert_drawdag(
387            r#"
388    C-D-\     /--I--J--\
389A-B------E-F-G-H--------K--L"#,
390            r#"0: { parents: [], name: A }
3911: { parents: ["0"], name: B }
3922: { parents: [], name: C }
3933: { parents: ["2"], name: D }
3944: { parents: ["1", "3"], name: E }
3955: { parents: ["4"], name: F }
3966: { parents: ["5"], name: G }
3977: { parents: ["6"], name: H }
3988: { parents: ["6"], name: I }
3999: { parents: ["8"], name: J }
40010: { parents: ["7", "9"], name: K }
40111: { parents: ["10"], name: L }
402"#,
403        );
404
405        assert_drawdag(
406            r#"
407      G
408      |
409I D C F
410 \ \| |
411  H B E
412   \|/
413    A
414"#,
415            r#"0: { parents: [], name: A }
4161: { parents: ["0"], name: B }
4172: { parents: ["1"], name: C }
4183: { parents: ["1"], name: D }
4194: { parents: ["0"], name: E }
4205: { parents: ["4"], name: F }
4216: { parents: ["5"], name: G }
4227: { parents: ["0"], name: H }
4238: { parents: ["7"], name: I }
424"#,
425        );
426
427        assert_drawdag(
428            r#"
429    A
430   /|\
431  H B E
432 / /| |
433I D C F
434      |
435      G
436"#,
437            r#"0: { parents: [], name: C }
4381: { parents: [], name: D }
4392: { parents: [], name: G }
4403: { parents: [], name: I }
4414: { parents: ["0", "1"], name: B }
4425: { parents: ["2"], name: F }
4436: { parents: ["3"], name: H }
4447: { parents: ["5"], name: E }
4458: { parents: ["4", "7", "6"], name: A }
446"#,
447        );
448    }
449
450    #[test]
451    fn test_parse_range() {
452        assert_eq!(p("A..D"), ["A -> []", "B -> [A]", "C -> [B]", "D -> [C]"]);
453        assert_eq!(
454            p(r"
455             A1A,B23z,(9z)..A1A,B23z,(10c)
456            "),
457            [
458                "A1A,B23z,(10a) -> [A1A,B23z,(9z)]",
459                "A1A,B23z,(10b) -> [A1A,B23z,(10a)]",
460                "A1A,B23z,(10c) -> [A1A,B23z,(10b)]",
461                "A1A,B23z,(9z) -> []"
462            ]
463        );
464        assert_eq!(
465            p(r"
466            B08
467             :
468            B04"),
469            [
470                "B04 -> []",
471                "B05 -> [B04]",
472                "B06 -> [B05]",
473                "B07 -> [B06]",
474                "B08 -> [B07]"
475            ]
476        );
477        assert_eq!(
478            p(r"
479            B10
480             | \
481             :  C
482             | /
483            B08
484             :
485            B06"),
486            [
487                "B06 -> []",
488                "B07 -> [B06]",
489                "B08 -> [B07]",
490                "B09 -> [B08]",
491                "B10 -> [B09, C]",
492                "C -> [B08]"
493            ]
494        );
495        assert_eq!(
496            p(r"
497             AE
498             | \
499             :  C
500             | /
501             AB
502             :
503             X"),
504            [
505                "AA -> [Z]",
506                "AB -> [AA]",
507                "AC -> [AB]",
508                "AD -> [AC]",
509                "AE -> [AD, C]",
510                "C -> [AB]",
511                "X -> []",
512                "Y -> [X]",
513                "Z -> [Y]"
514            ]
515        );
516    }
517
518    #[test]
519    fn test_parse_special_names() {
520        assert_eq!(
521            p("ancestor(desc(\"D\"),desc('_A'))--B"),
522            [
523                "B -> [ancestor(desc(\"D\"),desc('_A'))]",
524                "ancestor(desc(\"D\"),desc('_A')) -> []"
525            ]
526        );
527        assert_eq!(
528            p(r#"
529                B
530                |
531                .
532              "#),
533            [". -> []", "B -> [.]"]
534        );
535    }
536}