Ferrigura / Diagrams
GitGraph diagram
A gitGraph diagram draws the history of a repository: branches as lanes, commits along them, and the merges and cherry-picks between them, with commit ids, messages and tags. It starts with the keyword gitGraph, optionally with a direction (gitGraph TB:, gitGraph BT:; LR is the default). ferrigura parses it with a hand-written lexer and recursive-descent parser that port the Langium grammar gitGraph.langium (crates/ferrigura-syntax/src/git_graph/), with a DB that follows gitGraphAst.ts, and draws it with a renderer of its own (crates/ferrigura-render/src/git_graph/) that ports gitGraphRenderer.ts and styles.js. There is no layout engine: the positions are arithmetic over the commit order and the branch order.
Model
GitGraphDiagram (crates/ferrigura-model/src/git_graph.rs), version 1, is the DB's state after parsing.
title,accTitle,accDescr: the header texts after the sanitizer,""when absent. The grammar'stitlewins when it is not empty, else the front matter's title.direction:LR,TBorBT.currentBranch: the branch that commits go to.branches: in the DB map's insertion order, the main branch first, each{ name, order, head }.orderis a number (for the main branch, themainBranchOrdersetting) andheadis the id of the branch's head commit, ornullwhile it has none.commits: in the order their ids were first stored, each{ id, seq, branch, type, customType, customId, autoId, message, tags, parents }.typeisNORMAL,REVERSE,HIGHLIGHT,MERGEorCHERRY_PICK.customTypeis thetype:option of a merge andnullelsewhere;customIdistrueorfalseon a merge andnullelsewhere, because the DB writes it only on merges.messageis as the input gives it, not trimmed.
autoId marks an id that ferrigura generated because the input gave none. The reference draws such ids from Math.random, so the differential run replaces each with a placeholder (see Checking it).
The model leaves out the DB's global seq counter (each commit keeps its seq), head (always the head of the current branch, so derivable), the options, the warnings and the render state. Texts are the encoded text the DB holds, and ids, messages, tags, branch names and the like are sanitized (canonical-model.md describes the shared conventions).
Parsing
Lexer. scanner.rs follows Chevrotain's rules: the first terminal that matches, in the grammar's order, wins, and a keyword is overtaken by REFERENCE only when REFERENCE is strictly longer (longer_alt). Whitespace, comments, directives and front matter are hidden. The terminals are NEWLINE, the keywords (gitGraph also needs (?=%%) or (?!\S) after it), accDescr, accTitle, title, INT, STRING (single or double quoted, escapes take one character, a line break is allowed inside) and REFERENCE (\w([-./\w]*[-\w])?, ASCII \w).
Grammar. grammar.rs is recursive descent. Statements end with an end of line, a line break or the end of the text. The header does not need one (gitGraph commit is valid), and gitGraph TB without the colon is an error. Options come in any order; the last = assignment wins, and tag: accumulates.
commit:id:STRING, an optionalmsg:then STRING,tag:STRING,type:NORMAL, REVERSE or HIGHLIGHT.branch: a REFERENCE or STRING name,order:INT.merge: a branch, thenid:,tag:andtype:.checkoutorswitch: a branch.cherry-pick:id:,tag:andparent:.
DB. db.rs follows gitGraphAst.ts with the reference's checks and messages, which are part of the behavior. The maps keep insertion order, so branches and commits are vectors with an index, and a repeated commit id replaces the commit at its first position. The error position is the start of the statement that caused the error; the reference gives none.
Pipeline. The steps of Diagram.fromText: front matter, directives, comment lines, entity encoding and a final line break. The DB reads gitGraph.mainBranchName (main) and gitGraph.mainBranchOrder (0) from the text's configuration when it is cleared (options_of).
Sanitizing. There are two paths in sanitize.rs. Ids, messages, tags, branch names and the merge and cherry-pick ids and parents use the gitGraph section, which has no securityLevel, so the reference takes the escape branch of sanitizeMore: <br> to a placeholder, <, > and = escaped, the placeholder back to <br/>, then DOMPurify (sanitize_section). The title and the accessibility texts use the top-level configuration, with securityLevel pinned to strict (sanitize_text). Text without < comes back unchanged, so a=b in a section path becomes a=b and AT&T stays.
Auto ids. auto_id(seq) is <seq>- and seven hex digits of an FNV-1a hash of gitgraph-auto-id:<seq>, read before the increment, so the first id is 0-…. It is deterministic where the reference's is random. For the geometry harness only, Options::auto_id_seed (RenderOptions::git_graph_seed in the facade, --seed in the render example) draws the ids from the mulberry32 generator the reference's page uses, seven values per id, in the order the DB makes them, so the ids are the reference's.
Quirks of the reference that the parser and DB keep on purpose:
branch 1is a parse error (INTwins);branch 1.0is aREFERENCE;branch 1-0is a lexer error;branch commitis a parse error andbranch "commit"is accepted;checkout02is aREFERENCEandcheckout 02is a lexer error;commit id:abcis a parse error, becauseid:takes only a STRING;commit;is a lexer error.titleXistitleand then aREFERENCE, so it is a parse error.- A merge is refused when the head commit of the current branch belongs to the other branch (the check compares the commit's
branch, not the current branch name):commit; branch b; checkout b; merge mainfails with "Cannot merge branch 'main' into itself.".merge bon branchbis refused by the next check, before the no-commits check. - The no-commits messages have no space before "has":
Current branch (main)has no commits. - A duplicate commit id only warns, replaces the entry and keeps the commit's own parent, which makes a self-loop. A merge with a duplicate
id:is an error. - Cherry-pick parents are
[head, source]even when they are equal, and its tags drop empty strings, while a commit's tags keep them. The tag defaults tocherry-pick:<source>, plus|parent:<p>for a merge source. The target of a cherry-pick is always empty, so the reference's "cherry-pick to another commit" branch is unreachable and not ported. branchsanitizes the name, andcheckoutsanitizes it again.
Layout and rendering
The drawing is built as a tree of elements (tree.rs) that knows the box getBBox gives each one, because the reference places the label boxes and the title from boxes and takes the viewBox from the box of the root. layout.rs has the positions and the elements, text.rs the rows and the line boxes, palette.rs the colors of one theme set, style.rs the style sheet, and mod.rs the root, the title and the viewBox.
- Passes. The reference draws the commits twice: first without the elements, to place them, then with them.
drawmeasures the branch names, runsdrawCommitswithout drawing, draws the branches (which fills the lanes), draws the arrows, and runsdrawCommitsagain with drawing. The root therefore has twocommit-bulletsand twocommit-labelsgroups; the first of each is empty. - Branch positions.
setBranchPositiongives each branch a lanepos, advancing by 50, plus 40 withrotateCommitLabel, plus half the label's width inTBandBT. The width is that of the same branch's own name, measured before the increment. - Commit positions. Time runs along x in
LRand along y inTBandBT. Commitiis at10 + 50 iwithoutparallelCommits; with it, each commit is placed 40 beyond its closest parent.BTreverses the commit order, withsetParallelBTPosplacing the commits first whenparallelCommitsis set; it runs in both passes. - Bullets. A commit is a circle of radius 7 (the redux geometry); a highlight is a square; a reverse commit has a cross; a merge has an inner circle; a cherry-pick has a circle with two eyes and two lines. The color index comes from the branch's index, skipping color 0 in the color themes.
- Labels and tags. A label is drawn for every commit but a cherry-pick, and for a merge only with its own id, when
showCommitLabelis set. It is a text at 10 px with a background rectangle, rotated by -45° whenrotateCommitLabelis set. Tags are polygons with a hole and a text, stacked 20 apart, and are reversed before drawing, so the last tag is nearest the commit. - Arrows. For every commit and every parent,
drawArrowdraws a path with arcs of radius 20 (10 when rerouted) from the parent to the commit. An arrow is rerouted around a commit of the same branch between the two inseqorder. The lane of a rerouted arrow comes fromfindLane, which is shared withdrawBranches: the branch lines are drawn before the arrows, so the reroute positions depend on the lanes of the branches. The color is the destination branch's, or the source branch's in the reroute'selsebranch and for a merge whose arrow comes from a branch other than its first parent's. - Branches. With
showBranches, each branch has a line from 0 tomaxPosand a label with a background rectangle. The branch order is the stable sort byorder. - Title and root. The title is a text at 18 px at the middle of the box of what is drawn so far,
titleTopMarginabove it. The viewBox is the box of the root plusdiagramPadding; withuseMaxWidththe root haswidth="100%"and amax-width. - Style sheet. The rules of
styles.jsfor a color theme, scoped to the diagram's id, with every color a custom property. The reference's rule order is kept: the color rules first, then the common rules, so.commit-highlight-innerand.commit-mergewin over the color of their index. Two dark-only differences are custom properties: the cherry-pick eyes (--gitEye) and the branch label fill (--gitLabelFill{i}).
Differences from Mermaid
- Only the redux themes are drawn. The reference branches on the theme's name in the renderer and in
styles.js(redux geometry, color themes, dark themes, neo themes). ferrigura shipsredux-colorandredux-dark-color; the other names fall back by darkness, so a diagram that setstheme: defaultis drawn with the redux geometry, where the reference uses its default theme's geometry and root font (0011). The harness applies the same fallback to the reference. - Auto ids are deterministic (see Parsing), except under the harness seed.
- Labels are SVG text in the bundled face (0009, 0010). The root text is the theme's
fontSize(14 px in the redux themes), the commit labels and tags 10 px, the title 18 px. securityLevelis pinned tostrictfor the title path; alooselevel from front matter or a directive, which skips sanitizing in the reference, is not read.- The id of the drop-shadow filter in a tag background is
url(#drop-shadow), as the reference writes it. That id names no element, so no shadow is drawn there.
Known gaps
- A theme that is not one of the two shipped themes is drawn with a shipped one (see Differences).
- Fonts the bundled faces do not cover need a registered fallback font (0010).
Checking it
pnpm -C tools/port port:diff gitgraphruns the reference's parser and DB over every input (export-gitgraph.ts: the fixtures undere2e/diagrams/git, the string literals and docs of the reference, and the cases ofcases.ts) and compares the canonical models with the generated ids normalized: every commit withautoIdgets the placeholderauto#k(kcounts the distinct auto ids in model order), and every parent and branch head that equals such an id gets the same one. The JS exporter setsautoIdfrom the DB, when the count ofMath.randomcalls rose during the commit's call. The same rule is incanonical.rs.pnpm -C tools/port port:diff configcovers thegitGraphsection.pnpm -C tools/port port:geometry gitgraphcompares every element within 0.05 px, with the reference's clock andMath.randomseeded, and ferrigura given the same seed (--seed). A merge or cherry-pick without anid:shows its id in its class name, which is part of the geometry key, so the seed decides whether those elements match.- The Rust specs:
crates/ferrigura-syntax/tests/git_graph_spec.rs(gitGraph.spec.ts),git_graph_parser.rs(packages/parser/tests/gitGraph.test.ts),git_graph_populate.rsand the sanitizing tests insanitize.rs. The ignored tests are those the reference skips (reset, fast-forward merge) and the ones that read renderer keys fromgetConfig(). The in-source tests ofgitGraphRenderer.tsare unit tests inlayout.rsandtext.rs, with the LR cases checking the LR geometry: the reference's LR block expects the TB numbers, because its direction is shared state that itsdescribeblocks set while they are collected.
Learnings
- Branch positions measure the regular face, the label box the bold one. The lane step (
setBranchPosition) measures a label with the classbranch-labeland no index, which the style sheet does not set bold; the label box indrawBrancheshas the index, so it gets the weight 600 of.branch-label0. In a vertical graph the two widths differ. Commit labels are bold as well (weight 600 fromnoteFontWeight), and the bold face is the one bundled for 600, as Chromium's matching picks the heavier face above 500. - An empty box counts in the union. A vertical branch line has zero width and reaches
maxPos, and the viewBox's bottom is the end of that line. Arcs use their tight box; a transformed box is the box of its four corners. getBBoxwidth is the sum of rounded advances, height the line box. Chromium rounds each advance up to 1/64 px and the ascent and descent each to a whole pixel.- The expression order matters. JavaScript evaluates
((w + 10) / 25) * 9.5left to right in doubles;translate(-21.939999999999998, 19.520000000000003)for a 28 px label is what the reference writes, so the Rust arithmetic keeps the order. - Reverse and merge classes win by rule order, not by specificity.
- Colors wrap differently for commits and branches. Bullets and arrows use a limit of 8 and, in the color themes, skip index 0; branch lines and labels use the theme's
THEME_COLOR_LIMIT(12) for the redux geometry, sobranch8tobranch11have classes of their own while their commits wrap into 1 to 7. - Tags are reversed in place. The reference reverses the commit's tag list while drawing, so the last tag is the one nearest the commit.
- The reference's
getBranchesAsObjArrayfallback is dead: every branch has a number as its order, so the order is the stable sort byorder.