Skip to main content

mermaid_text/parser/
git_graph.rs

1//! Parser for Mermaid `gitGraph` diagrams.
2//!
3//! Accepted syntax (Phase 1):
4//!
5//! ```text
6//! gitGraph
7//!     commit
8//!     commit id: "second"
9//!     branch develop
10//!     checkout develop
11//!     commit
12//!     commit id: "feature-x"
13//!     checkout main
14//!     merge develop
15//!     commit
16//!     commit tag: "v1.0"
17//!     cherry-pick id: "feature-x"
18//! ```
19//!
20//! **Parsing strategy.** The parser walks the source line by line, maintaining
21//! mutable state for the current branch and an auto-increment commit counter
22//! used to generate short ids (`c0`, `c1`, …) when none is supplied. Each
23//! token is dispatched to a dedicated handler that appends to `GitGraph`.
24//!
25//! **Silently ignored.** The direction modifier on `gitGraph LR` and extended
26//! commit attributes (`type: REVERSE`, `type: HIGHLIGHT`, `message:`,
27//! `accTitle`, `accDescr`) are silently ignored for forward compatibility.
28//!
29//! # Examples
30//!
31//! ```
32//! use mermaid_text::parser::git_graph::parse;
33//!
34//! let src = "gitGraph\n  commit\n  commit id: \"hello\"";
35//! let g = parse(src).unwrap();
36//! assert_eq!(g.commits.len(), 2);
37//! assert_eq!(g.commits[1].id, "hello");
38//! ```
39
40use crate::Error;
41use crate::git_graph::{Branch, Commit, CommitKind, Event, GitGraph};
42use crate::parser::common::strip_inline_comment;
43
44// ---------------------------------------------------------------------------
45// Public entry point
46// ---------------------------------------------------------------------------
47
48/// Parse a `gitGraph` source string into a [`GitGraph`].
49///
50/// # Errors
51///
52/// - [`Error::ParseError`] — missing `gitGraph` header, `checkout` of an
53///   unknown branch, `cherry-pick` of an unknown commit id, or a `merge` of
54///   an unknown branch.
55pub fn parse(src: &str) -> Result<GitGraph, Error> {
56    let mut graph = GitGraph::default();
57    let mut header_seen = false;
58    // Auto-increment counter for generating commit ids `c0`, `c1`, …
59    let mut commit_counter: usize = 0;
60    // The name of the current branch (the branch commits land on).
61    let mut current_branch = "main".to_string();
62
63    // main always exists from the start.
64    graph.branches.push(Branch {
65        name: "main".to_string(),
66        created_after_commit: None,
67    });
68
69    for raw_line in src.lines() {
70        let line = strip_inline_comment(raw_line).trim();
71        if line.is_empty() {
72            continue;
73        }
74
75        if !header_seen {
76            // First non-blank line must begin with "gitGraph" (case-sensitive
77            // per Mermaid spec — camelCase matters here).
78            if !line.starts_with("gitGraph") {
79                return Err(Error::ParseError(format!(
80                    "expected `gitGraph` header, got {line:?}"
81                )));
82            }
83            header_seen = true;
84            continue;
85        }
86
87        // Dispatch on the first token of the line.
88        let first = line.split_whitespace().next().unwrap_or("");
89        match first {
90            "commit" => {
91                handle_commit(line, &mut graph, &current_branch, &mut commit_counter)?;
92            }
93            "branch" => {
94                // Clone current_branch so we can pass it immutably while also
95                // passing &mut current_branch. The clone is cheap (short branch
96                // names); this is the Rust-idiomatic way to break the aliasing.
97                let cb = current_branch.clone();
98                handle_branch(line, &mut graph, &cb, &mut current_branch)?;
99            }
100            "checkout" => {
101                handle_checkout(line, &mut graph, &mut current_branch)?;
102            }
103            "merge" => {
104                handle_merge(line, &mut graph, &current_branch, &mut commit_counter)?;
105            }
106            "cherry-pick" => {
107                handle_cherry_pick(line, &mut graph, &current_branch, &mut commit_counter)?;
108            }
109            // Silently ignore accessibility metadata and other unknown directives
110            // so real-world diagrams with extra annotations don't fail.
111            _ => {}
112        }
113    }
114
115    if !header_seen {
116        return Err(Error::ParseError(
117            "missing `gitGraph` header line".to_string(),
118        ));
119    }
120
121    Ok(graph)
122}
123
124// ---------------------------------------------------------------------------
125// Token handlers
126// ---------------------------------------------------------------------------
127
128/// Handle a `commit` line.
129///
130/// Supported optional attributes (parsed from `key: "value"` pairs on the line):
131/// - `id: "..."` — explicit commit id
132/// - `tag: "..."` — annotation tag
133/// - `type: ...` — silently ignored (REVERSE, HIGHLIGHT, etc.)
134/// - `message:` — silently ignored
135fn handle_commit(
136    line: &str,
137    graph: &mut GitGraph,
138    current_branch: &str,
139    counter: &mut usize,
140) -> Result<(), Error> {
141    // Extract optional id and tag from the line.
142    let id = extract_quoted_attr(line, "id").unwrap_or_else(|| {
143        // Auto-generate a short id when none is provided.
144        let auto = format!("c{counter}");
145        auto
146    });
147    let tag = extract_quoted_attr(line, "tag");
148
149    // Increment counter regardless of whether we used it — this keeps the
150    // auto-id sequence monotonically increasing even when explicit ids appear.
151    *counter += 1;
152
153    // Parent is the last commit on this branch. If no commits exist on this
154    // branch yet (first commit after a `branch X` + `checkout X`), inherit
155    // the fork point: the commit that was HEAD when the branch was created.
156    // This matches git's model where the first commit on a new branch has the
157    // fork-point commit as its parent.
158    let parent = graph.head_of(current_branch).or_else(|| {
159        graph
160            .branches
161            .iter()
162            .find(|b| b.name == current_branch)
163            .and_then(|b| b.created_after_commit)
164    });
165    let commit_idx = graph.commits.len();
166
167    graph.commits.push(Commit {
168        id,
169        branch: current_branch.to_string(),
170        tag,
171        kind: CommitKind::Normal,
172        parent,
173        merge_parent: None,
174    });
175    graph.events.push(Event::Commit(commit_idx));
176    Ok(())
177}
178
179/// Handle a `branch <name>` line.
180///
181/// Creates the branch from the current branch's HEAD and switches to it.
182/// The new branch becomes the current branch after creation, matching Mermaid's
183/// semantics (a `branch X` implicitly checks out X).
184fn handle_branch(
185    line: &str,
186    graph: &mut GitGraph,
187    current_branch: &str,
188    new_current: &mut String,
189) -> Result<(), Error> {
190    let name = rest_after_keyword(line, "branch").trim().to_string();
191    if name.is_empty() {
192        return Err(Error::ParseError("branch: missing branch name".to_string()));
193    }
194
195    // If the branch already exists, treat as a no-op (idempotent).
196    if graph.lane_of(&name).is_some() {
197        *new_current = name;
198        return Ok(());
199    }
200
201    let created_after = graph.head_of(current_branch);
202    let branch_idx = graph.branches.len();
203    graph.branches.push(Branch {
204        name: name.clone(),
205        created_after_commit: created_after,
206    });
207    graph.events.push(Event::BranchCreated(branch_idx));
208    // Mermaid's `branch X` implicitly checks out the new branch.
209    graph.events.push(Event::Checkout(name.clone()));
210    *new_current = name;
211    Ok(())
212}
213
214/// Handle a `checkout <name>` line.
215///
216/// Switches the current branch; errors if the branch does not exist.
217fn handle_checkout(
218    line: &str,
219    graph: &mut GitGraph,
220    current_branch: &mut String,
221) -> Result<(), Error> {
222    let name = rest_after_keyword(line, "checkout").trim().to_string();
223    if name.is_empty() {
224        return Err(Error::ParseError(
225            "checkout: missing branch name".to_string(),
226        ));
227    }
228    if graph.lane_of(&name).is_none() {
229        return Err(Error::ParseError(format!(
230            "checkout: branch {name:?} does not exist"
231        )));
232    }
233    graph.events.push(Event::Checkout(name.clone()));
234    *current_branch = name;
235    Ok(())
236}
237
238/// Handle a `merge <branch>` line.
239///
240/// Creates a merge commit on the current branch with `merge_parent` pointing
241/// to the HEAD of the merged branch. Errors if the branch does not exist or
242/// has no commits.
243fn handle_merge(
244    line: &str,
245    graph: &mut GitGraph,
246    current_branch: &str,
247    counter: &mut usize,
248) -> Result<(), Error> {
249    let source_name = rest_after_keyword(line, "merge").trim().to_string();
250    if source_name.is_empty() {
251        return Err(Error::ParseError("merge: missing branch name".to_string()));
252    }
253    if graph.lane_of(&source_name).is_none() {
254        return Err(Error::ParseError(format!(
255            "merge: branch {source_name:?} does not exist"
256        )));
257    }
258
259    // The merge-source HEAD: the last commit on the source branch.
260    let merge_parent = graph.head_of(&source_name).ok_or_else(|| {
261        Error::ParseError(format!(
262            "merge: branch {source_name:?} has no commits to merge"
263        ))
264    })?;
265
266    let id = extract_quoted_attr(line, "id").unwrap_or_else(|| {
267        let auto = format!("c{counter}");
268        auto
269    });
270    let tag = extract_quoted_attr(line, "tag");
271    *counter += 1;
272
273    let parent = graph.head_of(current_branch).or_else(|| {
274        graph
275            .branches
276            .iter()
277            .find(|b| b.name == current_branch)
278            .and_then(|b| b.created_after_commit)
279    });
280    let commit_idx = graph.commits.len();
281
282    graph.commits.push(Commit {
283        id,
284        branch: current_branch.to_string(),
285        tag,
286        kind: CommitKind::Merge,
287        parent,
288        merge_parent: Some(merge_parent),
289    });
290    graph.events.push(Event::Merge(commit_idx));
291    Ok(())
292}
293
294/// Handle a `cherry-pick id: "..."` line.
295///
296/// Copies a commit by id to the current branch. Errors if the id is not found
297/// in `commits`.
298fn handle_cherry_pick(
299    line: &str,
300    graph: &mut GitGraph,
301    current_branch: &str,
302    counter: &mut usize,
303) -> Result<(), Error> {
304    let source_id = extract_quoted_attr(line, "id")
305        .ok_or_else(|| Error::ParseError("cherry-pick: missing id attribute".to_string()))?;
306
307    // Verify the commit id exists.
308    if !graph.commits.iter().any(|c| c.id == source_id) {
309        return Err(Error::ParseError(format!(
310            "cherry-pick: commit id {source_id:?} not found"
311        )));
312    }
313
314    let id = format!("c{counter}");
315    *counter += 1;
316
317    let parent = graph.head_of(current_branch).or_else(|| {
318        graph
319            .branches
320            .iter()
321            .find(|b| b.name == current_branch)
322            .and_then(|b| b.created_after_commit)
323    });
324    let commit_idx = graph.commits.len();
325
326    graph.commits.push(Commit {
327        id,
328        branch: current_branch.to_string(),
329        tag: None,
330        kind: CommitKind::CherryPick,
331        parent,
332        merge_parent: None,
333    });
334    graph.events.push(Event::CherryPick(commit_idx));
335    Ok(())
336}
337
338// ---------------------------------------------------------------------------
339// Attribute parsing helpers
340// ---------------------------------------------------------------------------
341
342/// Extract a quoted attribute value from a line: `key: "value"`.
343///
344/// Returns the content between the quotes, or `None` if the attribute is
345/// not present or has no quoted value. The search is case-sensitive.
346///
347/// Example: `extract_quoted_attr("commit id: \"hello\"", "id")` → `Some("hello")`.
348fn extract_quoted_attr(line: &str, key: &str) -> Option<String> {
349    // Look for `key:` (with optional space before the quote).
350    let needle = format!("{key}:");
351    let start = line.find(needle.as_str())?;
352    let after_colon = &line[start + needle.len()..];
353    // Find the opening quote.
354    let open = after_colon.find('"')?;
355    let rest = &after_colon[open + 1..];
356    // Find the closing quote.
357    let close = rest.find('"')?;
358    Some(rest[..close].to_string())
359}
360
361/// Return the remainder of `line` after `keyword` and at least one space.
362///
363/// If the keyword is not matched (e.g. the line is exactly the keyword with
364/// no trailing content), returns an empty string.
365fn rest_after_keyword<'a>(line: &'a str, keyword: &str) -> &'a str {
366    let klen = keyword.len();
367    if line.len() > klen && line.as_bytes()[klen].is_ascii_whitespace() {
368        &line[klen + 1..]
369    } else {
370        ""
371    }
372}
373
374// ---------------------------------------------------------------------------
375// Tests
376// ---------------------------------------------------------------------------
377
378#[cfg(test)]
379mod tests {
380    use super::*;
381    use crate::git_graph::CommitKind;
382
383    // ---- (1) minimal: just gitGraph + 2 commits → 2 commits on main -------
384
385    #[test]
386    fn minimal_two_commits_on_main() {
387        let src = "gitGraph\n  commit\n  commit";
388        let g = parse(src).unwrap();
389        assert_eq!(g.commits.len(), 2);
390        assert_eq!(g.commits[0].branch, "main");
391        assert_eq!(g.commits[1].branch, "main");
392        assert_eq!(g.commits[0].parent, None);
393        assert_eq!(g.commits[1].parent, Some(0));
394    }
395
396    // ---- (2) branch + checkout + commit on new branch ---------------------
397
398    #[test]
399    fn branch_checkout_commit_on_new_branch() {
400        let src = "gitGraph\n  commit\n  branch dev\n  checkout dev\n  commit";
401        let g = parse(src).unwrap();
402        // The second commit should be on dev.
403        assert_eq!(g.commits[1].branch, "dev");
404        // Its parent is the first commit on main (index 0).
405        assert_eq!(g.commits[1].parent, Some(0));
406        assert_eq!(g.branches.len(), 2);
407        assert_eq!(g.branches[1].name, "dev");
408    }
409
410    // ---- (3) merge creates a merge commit with merge_parent set ----------
411
412    #[test]
413    fn merge_creates_merge_commit_with_merge_parent() {
414        let src = "gitGraph\n  commit\n  branch dev\n  checkout dev\n  commit id: \"feat\"\n  checkout main\n  merge dev";
415        let g = parse(src).unwrap();
416        let merge = g.commits.last().unwrap();
417        assert_eq!(merge.kind, CommitKind::Merge);
418        assert_eq!(merge.branch, "main");
419        // merge_parent must point to the HEAD of dev (commit with id "feat").
420        let feat_idx = g.commits.iter().position(|c| c.id == "feat").unwrap();
421        assert_eq!(merge.merge_parent, Some(feat_idx));
422    }
423
424    // ---- (4) explicit commit id honours the id ---------------------------
425
426    #[test]
427    fn explicit_commit_id_is_used() {
428        let src = "gitGraph\n  commit id: \"my-commit\"";
429        let g = parse(src).unwrap();
430        assert_eq!(g.commits[0].id, "my-commit");
431    }
432
433    // ---- (5) tag populates the commit's tag field -------------------------
434
435    #[test]
436    fn tag_populates_commit_tag() {
437        let src = "gitGraph\n  commit tag: \"v1.0\"";
438        let g = parse(src).unwrap();
439        assert_eq!(g.commits[0].tag.as_deref(), Some("v1.0"));
440    }
441
442    // ---- (6) cherry-pick creates a CherryPick commit ---------------------
443
444    #[test]
445    fn cherry_pick_creates_cherry_pick_commit() {
446        let src = "gitGraph\n  commit id: \"feat\"\n  branch dev\n  checkout dev\n  cherry-pick id: \"feat\"";
447        let g = parse(src).unwrap();
448        let cp = g.commits.last().unwrap();
449        assert_eq!(cp.kind, CommitKind::CherryPick);
450        assert_eq!(cp.branch, "dev");
451    }
452
453    // ---- (7) branching off a non-main branch -----------------------------
454
455    #[test]
456    fn branch_off_non_main_branch() {
457        let src = "gitGraph\n  commit\n  branch dev\n  checkout dev\n  commit id: \"d1\"\n  branch feature\n  checkout feature\n  commit id: \"f1\"";
458        let g = parse(src).unwrap();
459        // feature branch was created when dev HEAD was "d1" (index 1).
460        let feature = g.branches.iter().find(|b| b.name == "feature").unwrap();
461        let d1_idx = g.commits.iter().position(|c| c.id == "d1").unwrap();
462        assert_eq!(feature.created_after_commit, Some(d1_idx));
463        // f1 commit's parent is d1 (since feature was checked out from dev).
464        let f1 = g.commits.iter().find(|c| c.id == "f1").unwrap();
465        assert_eq!(f1.parent, Some(d1_idx));
466    }
467
468    // ---- (8) %% comments stripped -----------------------------------------
469
470    #[test]
471    fn comments_stripped() {
472        let src = "%% header comment\ngitGraph\n  %% inline comment line\n  commit %% trailing";
473        let g = parse(src).unwrap();
474        assert_eq!(g.commits.len(), 1);
475    }
476
477    // ---- (9) multiple branches with interleaved commits ------------------
478
479    #[test]
480    fn multiple_branches_interleaved_commits() {
481        let src = "gitGraph\n  commit id: \"m1\"\n  branch dev\n  checkout dev\n  commit id: \"d1\"\n  checkout main\n  commit id: \"m2\"\n  checkout dev\n  commit id: \"d2\"";
482        let g = parse(src).unwrap();
483        // m1, d1, m2, d2 in order.
484        let ids: Vec<&str> = g.commits.iter().map(|c| c.id.as_str()).collect();
485        assert_eq!(ids, vec!["m1", "d1", "m2", "d2"]);
486        assert_eq!(g.commits[3].parent, Some(1)); // d2's parent is d1
487    }
488
489    // ---- (10) merge of a branch that has its own branch ------------------
490
491    #[test]
492    fn merge_picks_correct_head_when_branch_has_sub_branches() {
493        // develop branches off main, feature branches off develop,
494        // feature gets a commit, then main merges develop (not feature).
495        let src = "gitGraph\n  commit id: \"m1\"\n  branch develop\n  checkout develop\n  commit id: \"dev1\"\n  branch feature\n  checkout feature\n  commit id: \"feat1\"\n  checkout main\n  merge develop";
496        let g = parse(src).unwrap();
497        let merge = g.commits.last().unwrap();
498        assert_eq!(merge.kind, CommitKind::Merge);
499        // merge_parent must be the HEAD of develop, which is dev1 (not feat1).
500        let dev1_idx = g.commits.iter().position(|c| c.id == "dev1").unwrap();
501        assert_eq!(merge.merge_parent, Some(dev1_idx));
502    }
503
504    // ---- (11) auto-generated ids are sequential --------------------------
505
506    #[test]
507    fn auto_generated_ids_are_sequential() {
508        let src = "gitGraph\n  commit\n  commit\n  commit id: \"explicit\"\n  commit";
509        let g = parse(src).unwrap();
510        // First two and last get auto-ids c0, c1, c3 (counter increments even
511        // for explicit-id commits, keeping the sequence monotone).
512        assert_eq!(g.commits[0].id, "c0");
513        assert_eq!(g.commits[1].id, "c1");
514        assert_eq!(g.commits[2].id, "explicit");
515        assert_eq!(g.commits[3].id, "c3");
516    }
517
518    // ---- (12) missing header returns parse error -------------------------
519
520    #[test]
521    fn missing_header_returns_error() {
522        let err = parse("commit").unwrap_err();
523        assert!(err.to_string().contains("gitGraph"));
524    }
525
526    // ---- (13) checkout of unknown branch returns parse error ------------
527
528    #[test]
529    fn checkout_unknown_branch_returns_error() {
530        let src = "gitGraph\n  checkout ghost";
531        let err = parse(src).unwrap_err();
532        assert!(err.to_string().contains("ghost"), "unexpected error: {err}");
533    }
534}