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