diff --git a/packages/merman/src/detect.ts b/packages/merman/src/detect.ts index 443ae61cda2..e1b86518ccf 100644 --- a/packages/merman/src/detect.ts +++ b/packages/merman/src/detect.ts @@ -1,11 +1,13 @@ import type { MermaidDiagramKind } from "./diagnostics.js" import { isMermaidFlowchartDiagram } from "./flowchart/parser.js" +import { isMermaidGitGraphDiagram } from "./gitgraph/parser.js" import { isMermaidSequenceDiagram } from "./sequence/parser.js" import { isMermaidStateDiagram } from "./state/parser.js" import { isMermaidTimelineDiagram } from "./timeline/parser.js" export function detectMermaidDiagram(content: string): MermaidDiagramKind | undefined { if (isMermaidFlowchartDiagram(content)) return "flowchart" + if (isMermaidGitGraphDiagram(content)) return "gitGraph" if (isMermaidSequenceDiagram(content)) return "sequence" if (isMermaidStateDiagram(content)) return "state" if (isMermaidTimelineDiagram(content)) return "timeline" diff --git a/packages/merman/src/diagnostics.ts b/packages/merman/src/diagnostics.ts index 8a936618880..6bae6003233 100644 --- a/packages/merman/src/diagnostics.ts +++ b/packages/merman/src/diagnostics.ts @@ -1,4 +1,4 @@ -export type MermaidDiagramKind = "flowchart" | "sequence" | "state" | "timeline" +export type MermaidDiagramKind = "flowchart" | "sequence" | "state" | "timeline" | "gitGraph" /** An otherwise valid diagram contains syntax that this renderer does not support. */ export class MermaidSyntaxError extends Error { diff --git a/packages/merman/src/gitgraph/diagram.test.ts b/packages/merman/src/gitgraph/diagram.test.ts new file mode 100644 index 00000000000..a0210658890 --- /dev/null +++ b/packages/merman/src/gitgraph/diagram.test.ts @@ -0,0 +1,183 @@ +import { describe, expect, test } from "bun:test" +import { MermaidSyntaxError } from "../diagnostics.js" +import { renderGitGraphDiagram } from "./diagram.js" +import { drawGitGraphDiagramGrid } from "./drawing.js" +import { isMermaidGitGraphDiagram, parseMermaidGitGraphDiagram } from "./parser.js" +import { renderGitGraphGridText } from "./render-grid.js" +import { resolveGitGraphStyleColors } from "./style.js" + +describe("GitGraphDiagram", () => { + test("detects and parses commits, branches, checkout, tags, types, and merges", () => { + const diagram = parseMermaidGitGraphDiagram(`gitGraph TB: + commit id: "init" + branch feature order: 1 + commit id: "api" msg: "Add API" tag: "ready" + checkout main + commit id: "docs" type: HIGHLIGHT + merge feature id: "merge-feature"`) + + expect(diagram).toEqual({ + direction: "TB", + branches: [ + { name: "main", order: 0, head: "merge-feature" }, + { name: "feature", order: 1, head: "api" }, + ], + commits: [ + { id: "init", tags: [], type: "NORMAL", branch: "main", parents: [] }, + { id: "api", message: "Add API", tags: ["ready"], type: "NORMAL", branch: "feature", parents: ["init"] }, + { id: "docs", tags: [], type: "HIGHLIGHT", branch: "main", parents: ["init"] }, + { + id: "merge-feature", + tags: [], + type: "NORMAL", + branch: "main", + parents: ["docs", "api"], + }, + ], + }) + }) + + test("renders branch and merge transitions beside compact labels", () => { + const source = `gitGraph + commit id: "baseline" + branch refactor + commit id: "extract-seam" msg: "Extract seam" + commit id: "add-tests" tag: "ready" + checkout main + commit id: "unrelated-fix" + merge refactor id: "land-refactor" tag: "v2"` + + expect(renderGitGraphDiagram(source)).toBe(`● baseline +├─╮ +│ ● Extract seam +│ ● add-tests [refactor] [ready] +● │ unrelated-fix +◎─╯ land-refactor [main] [v2]`) + }) + + test("uses deterministic generated ids", () => { + expect(parseMermaidGitGraphDiagram("gitGraph\n commit\n commit").commits.map((commit) => commit.id)).toEqual([ + "commit-1", + "commit-2", + ]) + }) + + test("supports shorthand messages and preserves branch heads without direct commits", () => { + const diagram = parseMermaidGitGraphDiagram(`gitGraph + commit "Initial release" + branch feature + checkout main + commit id: next`) + + expect(diagram.commits[0]?.message).toBe("Initial release") + expect(diagram.branches).toEqual([ + { name: "main", order: 0, head: "next" }, + { name: "feature", head: "commit-1" }, + ]) + expect( + renderGitGraphDiagram(`gitGraph + commit id: base + branch feature + checkout main + commit id: next`), + ).toContain("base [feature]") + }) + + test("places unordered branches before explicitly ordered branches", () => { + const diagram = parseMermaidGitGraphDiagram(`gitGraph + commit id: base + branch later order: 2 + checkout main + branch ordinary + checkout main + branch earlier order: 1`) + + expect(diagram.branches.map((branch) => branch.name)).toEqual(["main", "ordinary", "earlier", "later"]) + }) + + test("keeps comment markers inside quoted labels", () => { + expect(parseMermaidGitGraphDiagram('gitGraph\n commit id: "release%%candidate" %% comment').commits[0]?.id).toBe( + "release%%candidate", + ) + }) + + test("uses rounded routing for wide lane transitions", () => { + expect( + renderGitGraphDiagram(`gitGraph + commit id: base + branch one + branch two + commit id: work`), + ).toBe(`● base [main] [one] +├───╮ + ● work [two]`) + }) + + test("preserves direction semantics while rendering vertically", () => { + const source = "gitGraph BT:\n commit id: one" + const diagram = parseMermaidGitGraphDiagram(source) + expect(diagram.direction).toBe("BT") + expect(renderGitGraphGridText(drawGitGraphDiagramGrid(diagram, { direction: "LR" }))).toBe( + renderGitGraphDiagram(source), + ) + }) + + test("reports semantic failures with source diagnostics", () => { + expect(() => parseMermaidGitGraphDiagram("gitGraph\n checkout missing")).toThrow( + new MermaidSyntaxError("gitGraph", 2, "checkout missing", 'Unknown branch "missing"'), + ) + expect(() => parseMermaidGitGraphDiagram("gitGraph\n cherry-pick id: one")).toThrow( + new MermaidSyntaxError("gitGraph", 2, "cherry-pick id: one", "Cherry-pick is not supported"), + ) + expect(() => parseMermaidGitGraphDiagram("gitGraph\n commit id: same\n commit id: same")).toThrow( + 'Duplicate commit id "same"', + ) + expect(() => parseMermaidGitGraphDiagram("gitGraph\n branch feature\n checkout main\n branch feature")).toThrow( + 'Duplicate branch "feature"', + ) + expect(() => + parseMermaidGitGraphDiagram("gitGraph\n branch feature\n commit id: work\n checkout main\n merge feature"), + ).toThrow('Branch "main" has no commits') + }) + + test("draws semantic styles for rails, commit types, merges, and labels", () => { + const grid = drawGitGraphDiagramGrid( + parseMermaidGitGraphDiagram(`gitGraph + commit id: base + branch feature + commit id: work type: REVERSE + checkout main + commit id: checkpoint type: HIGHLIGHT + merge feature id: done`), + ) + const styles = new Set(grid.rows.flatMap((row) => row.map((cell) => cell.style).filter(Boolean))) + + expect(styles).toEqual(new Set(["branch0", "branch1", "commit", "reverse", "highlight", "merge", "label"])) + expect(Object.keys(resolveGitGraphStyleColors()).sort()).toEqual( + [ + "branch0", + "branch1", + "branch2", + "branch3", + "branch4", + "branch5", + "branch6", + "branch7", + "commit", + "highlight", + "label", + "merge", + "reverse", + ].sort(), + ) + }) + + test("recognizes only GitGraph headers", () => { + expect(isMermaidGitGraphDiagram("%% comment\ngitGraph LR:\n commit")).toBe(true) + expect(isMermaidGitGraphDiagram("graph LR\n A --> B")).toBe(false) + expect(() => parseMermaidGitGraphDiagram("commit id: missing-header")).toThrow("GitGraph header is required") + expect(() => parseMermaidGitGraphDiagram("gitGraph\n commit\n gitGraph")).toThrow( + "GitGraph header can only appear once", + ) + }) +}) diff --git a/packages/merman/src/gitgraph/diagram.ts b/packages/merman/src/gitgraph/diagram.ts new file mode 100644 index 00000000000..81c89bc4073 --- /dev/null +++ b/packages/merman/src/gitgraph/diagram.ts @@ -0,0 +1,8 @@ +import { drawGitGraphDiagramGrid } from "./drawing.js" +import { parseMermaidGitGraphDiagram } from "./parser.js" +import { renderGitGraphGridText } from "./render-grid.js" +import type { GitGraphDiagramRenderOptions } from "./types.js" + +export function renderGitGraphDiagram(content: string, options: GitGraphDiagramRenderOptions = {}): string { + return renderGitGraphGridText(drawGitGraphDiagramGrid(parseMermaidGitGraphDiagram(content), options)) +} diff --git a/packages/merman/src/gitgraph/drawing.ts b/packages/merman/src/gitgraph/drawing.ts new file mode 100644 index 00000000000..8582bf91a1c --- /dev/null +++ b/packages/merman/src/gitgraph/drawing.ts @@ -0,0 +1,234 @@ +import { DiagramCanvas } from "../core/canvas.js" +import { diagramTextWidth } from "../core/text.js" +import type { GitGraphGrid } from "./render-grid.js" +import type { GitGraphCellStyle, GitGraphCommit, GitGraphDiagram, GitGraphDiagramRenderOptions } from "./types.js" + +interface BranchSpan { + first: number + last: number +} + +interface Connections { + up?: boolean + down?: boolean + left?: boolean + right?: boolean + style: GitGraphCellStyle +} + +const LANE_WIDTH = 2 +const LABEL_GAP = 2 + +export function drawGitGraphDiagramGrid( + diagram: GitGraphDiagram, + _options: GitGraphDiagramRenderOptions = {}, +): GitGraphGrid { + if (diagram.commits.length === 0) return new DiagramCanvas(0, 0) + const laneByBranch = new Map(diagram.branches.map((branch, index) => [branch.name, index])) + const commitById = new Map(diagram.commits.map((commit) => [commit.id, commit])) + const spans = branchSpans(diagram, commitById) + const heads = branchHeads(diagram) + const graphWidth = (diagram.branches.length - 1) * LANE_WIDTH + 1 + let labelWidth = 0 + for (const commit of diagram.commits) labelWidth = Math.max(labelWidth, diagramTextWidth(commitLabel(commit, heads))) + const forks = diagram.commits.map((commit) => isFork(commit, laneByBranch, commitById)) + const height = diagram.commits.length + forks.filter(Boolean).length + const grid: GitGraphGrid = new DiagramCanvas(graphWidth + LABEL_GAP + labelWidth, height) + + let row = 0 + diagram.commits.forEach((commit, index) => { + if (forks[index]) { + drawTransitionRow(grid, spans, laneByBranch, commitById, commit, index, row) + row += 1 + } + drawCommitRow(grid, diagram, spans, laneByBranch, commitById, commit, index, row) + grid.setText(graphWidth + LABEL_GAP, row, commitLabel(commit, heads), "label") + row += 1 + }) + return grid +} + +function drawTransitionRow( + grid: GitGraphGrid, + spans: Map, + laneByBranch: Map, + commitById: Map, + commit: GitGraphCommit, + index: number, + y: number, +): void { + const cells = new Map() + for (const [branch, span] of spans) { + if (span.first >= index || span.last < index) continue + const lane = laneByBranch.get(branch)! + connect(cells, lane * LANE_WIDTH, { up: true, down: true }, branchStyle(lane)) + } + + const lane = laneByBranch.get(commit.branch)! + const firstParent = commit.parents[0] === undefined ? undefined : commitById.get(commit.parents[0]) + if (firstParent && firstParent.branch !== commit.branch) { + const parentLane = laneByBranch.get(firstParent.branch)! + connectHorizontal( + cells, + parentLane, + lane, + { sourceUp: true, sourceDown: true, targetDown: true }, + branchStyle(lane), + ) + } + paintConnections(grid, cells, y) +} + +function drawCommitRow( + grid: GitGraphGrid, + diagram: GitGraphDiagram, + spans: Map, + laneByBranch: Map, + commitById: Map, + commit: GitGraphCommit, + index: number, + y: number, +): void { + const cells = new Map() + for (const branch of diagram.branches) { + const span = spans.get(branch.name) + if (!span || span.first > index || (span.last <= index && branch.name !== commit.branch)) continue + const lane = laneByBranch.get(branch.name)! + connect(cells, lane * LANE_WIDTH, { up: index > 0, down: span.last > index }, branchStyle(lane)) + } + + const lane = laneByBranch.get(commit.branch)! + const secondParent = commit.parents[1] === undefined ? undefined : commitById.get(commit.parents[1]) + if (secondParent) { + const parentLane = laneByBranch.get(secondParent.branch)! + connectHorizontal(cells, lane, parentLane, { sourceUp: true, targetUp: true }, branchStyle(parentLane)) + } + paintConnections(grid, cells, y) + grid.setCell(lane * LANE_WIDTH, y, commitGlyph(commit), commitStyle(commit)) +} + +function connectHorizontal( + cells: Map, + sourceLane: number, + targetLane: number, + vertical: { sourceUp?: boolean; sourceDown?: boolean; targetUp?: boolean; targetDown?: boolean }, + style: GitGraphCellStyle, +): void { + if (sourceLane === targetLane) return + const source = sourceLane * LANE_WIDTH + const target = targetLane * LANE_WIDTH + const direction = Math.sign(target - source) + connect( + cells, + source, + { ...verticalAt(vertical.sourceUp, vertical.sourceDown), ...(direction > 0 ? { right: true } : { left: true }) }, + style, + ) + for (let x = source + direction; x !== target; x += direction) { + connect(cells, x, { left: true, right: true }, style) + } + connect( + cells, + target, + { ...verticalAt(vertical.targetUp, vertical.targetDown), ...(direction > 0 ? { left: true } : { right: true }) }, + style, + ) +} + +function verticalAt(up: boolean | undefined, down: boolean | undefined): Pick { + return { ...(up ? { up: true } : {}), ...(down ? { down: true } : {}) } +} + +function connect( + cells: Map, + x: number, + additions: Omit, + style: GitGraphCellStyle, +): void { + const current = cells.get(x) + cells.set(x, { ...current, ...additions, style: current?.style ?? style }) +} + +function paintConnections(grid: GitGraphGrid, cells: Map, y: number): void { + for (const [x, connections] of cells) grid.setCell(x, y, connectionGlyph(connections), connections.style) +} + +function connectionGlyph({ up, down, left, right }: Connections): string { + const mask = `${up ? 1 : 0}${down ? 1 : 0}${left ? 1 : 0}${right ? 1 : 0}` + const glyphs: Record = { + "1100": "│", + "0011": "─", + "0101": "╭", + "0110": "╮", + "1001": "╰", + "1010": "╯", + "1101": "├", + "1110": "┤", + "0111": "┬", + "1011": "┴", + "1111": "┼", + "1000": "│", + "0100": "│", + "0010": "─", + "0001": "─", + } + return glyphs[mask] ?? " " +} + +function branchSpans(diagram: GitGraphDiagram, commitById: Map): Map { + const spans = new Map() + diagram.commits.forEach((commit, index) => { + const span = spans.get(commit.branch) + if (span) span.last = index + else spans.set(commit.branch, { first: index, last: index }) + for (const parentId of commit.parents) { + const parent = commitById.get(parentId) + if (!parent || parent.branch === commit.branch) continue + const parentSpan = spans.get(parent.branch) + if (parentSpan) parentSpan.last = Math.max(parentSpan.last, index) + } + }) + return spans +} + +function branchHeads(diagram: GitGraphDiagram): Map { + const heads = new Map() + for (const branch of diagram.branches) { + if (branch.head === undefined) continue + const names = heads.get(branch.head) ?? [] + names.push(branch.name) + heads.set(branch.head, names) + } + return heads +} + +function isFork( + commit: GitGraphCommit, + laneByBranch: Map, + commitById: Map, +): boolean { + const parent = commit.parents[0] === undefined ? undefined : commitById.get(commit.parents[0]) + return parent !== undefined && laneByBranch.get(parent.branch) !== laneByBranch.get(commit.branch) +} + +function commitGlyph(commit: GitGraphCommit): string { + if (commit.type === "REVERSE") return "⊗" + if (commit.type === "HIGHLIGHT") return "◆" + return commit.parents.length > 1 ? "◎" : "●" +} + +function commitStyle(commit: GitGraphCommit): GitGraphCellStyle { + if (commit.type === "REVERSE") return "reverse" + if (commit.type === "HIGHLIGHT") return "highlight" + return commit.parents.length > 1 ? "merge" : "commit" +} + +function commitLabel(commit: GitGraphCommit, heads: Map): string { + const subject = commit.message ?? commit.id + const decorations = [...(heads.get(commit.id) ?? []), ...commit.tags].map((value) => `[${value}]`) + return decorations.length === 0 ? subject : `${subject} ${decorations.join(" ")}` +} + +function branchStyle(lane: number): GitGraphCellStyle { + return `branch${lane % 8}` as GitGraphCellStyle +} diff --git a/packages/merman/src/gitgraph/parser.ts b/packages/merman/src/gitgraph/parser.ts new file mode 100644 index 00000000000..f77ec4dfa07 --- /dev/null +++ b/packages/merman/src/gitgraph/parser.ts @@ -0,0 +1,219 @@ +import { firstMeaningfulMermaidLine, meaningfulNumberedMermaidLines, stripMermaidQuotes } from "../core/mermaid.js" +import { MermaidSyntaxError } from "../diagnostics.js" +import type { GitGraphBranch, GitGraphCommit, GitGraphCommitType, GitGraphDiagram, GitGraphDirection } from "./types.js" + +const HEADER_RE = /^gitGraph(?:\s+(LR|TB|BT))?\s*:?$/i +const ACCESSIBILITY_RE = /^acc(?:Title|Descr)(?::|\s|$)/i + +export function isMermaidGitGraphDiagram(content: string): boolean { + return HEADER_RE.test(firstMeaningfulMermaidLine(content) ?? "") +} + +export function parseMermaidGitGraphDiagram(content: string): GitGraphDiagram { + const firstLine = firstMeaningfulMermaidLine(content) + if (!HEADER_RE.test(firstLine ?? "")) throw syntaxError(1, firstLine ?? "", "GitGraph header is required") + const branches: GitGraphBranch[] = [{ name: "main", order: 0 }] + const commits: GitGraphCommit[] = [] + const heads = new Map([["main", undefined]]) + const ids = new Set() + let direction: GitGraphDirection = "LR" + let currentBranch = "main" + let generatedId = 1 + let inAccessibilityDescription = false + let headerSeen = false + + for (const source of meaningfulNumberedMermaidLines(content)) { + const line = stripComment(source.text) + if (inAccessibilityDescription) { + if (line === "}") inAccessibilityDescription = false + continue + } + if (/^accDescr\s*\{$/i.test(line)) { + inAccessibilityDescription = true + continue + } + if (!line || ACCESSIBILITY_RE.test(line) || /^title(?:\s|$)/i.test(line)) continue + + const header = line.match(HEADER_RE) + if (header) { + if (headerSeen) throw syntaxError(source.lineNumber, line, "GitGraph header can only appear once") + headerSeen = true + direction = (header[1]?.toUpperCase() as GitGraphDirection | undefined) ?? "LR" + continue + } + + const [command = "", ...rest] = tokenize(line) + const operation = command.toLowerCase() + if (operation === "commit") { + const shorthandMessage = rest[0]?.match(/^(["']).*\1$/) ? stripMermaidQuotes(rest.shift()!) : undefined + const attributes = parseAttributes(rest, source.lineNumber, line, ["id", "msg", "tag", "type"]) + const id = single(attributes, "id", source.lineNumber, line) ?? `commit-${generatedId++}` + if (!id) throw syntaxError(source.lineNumber, line, "GitGraph commit id cannot be empty") + if (ids.has(id)) throw syntaxError(source.lineNumber, line, `Duplicate commit id "${id}"`) + const type = parseCommitType(single(attributes, "type", source.lineNumber, line), source.lineNumber, line) + const parent = heads.get(currentBranch) + const message = single(attributes, "msg", source.lineNumber, line) ?? shorthandMessage + const commit: GitGraphCommit = { + id, + ...(message === undefined ? {} : { message }), + tags: attributes.get("tag") ?? [], + type, + branch: currentBranch, + parents: parent === undefined ? [] : [parent], + } + commits.push(commit) + ids.add(id) + heads.set(currentBranch, id) + continue + } + + if (operation === "branch") { + if (rest.length === 0) throw syntaxError(source.lineNumber, line, "GitGraph branch name cannot be empty") + const name = stripMermaidQuotes(rest[0]!) + if (!name) throw syntaxError(source.lineNumber, line, "GitGraph branch name cannot be empty") + if (heads.has(name)) throw syntaxError(source.lineNumber, line, `Duplicate branch "${name}"`) + const attributes = parseAttributes(rest.slice(1), source.lineNumber, line, ["order"]) + const orderValue = single(attributes, "order", source.lineNumber, line) + const order = orderValue === undefined ? undefined : Number(orderValue) + if (order !== undefined && (!Number.isInteger(order) || order < 0)) { + throw syntaxError(source.lineNumber, line, "GitGraph branch order must be a non-negative integer") + } + branches.push({ name, ...(order === undefined ? {} : { order }) }) + heads.set(name, heads.get(currentBranch)) + currentBranch = name + continue + } + + if (operation === "checkout" || operation === "switch") { + if (rest.length !== 1) throw syntaxError(source.lineNumber, line, `GitGraph ${operation} requires one branch`) + const name = stripMermaidQuotes(rest[0]!) + if (!heads.has(name)) throw syntaxError(source.lineNumber, line, `Unknown branch "${name}"`) + currentBranch = name + continue + } + + if (operation === "merge") { + if (rest.length === 0) throw syntaxError(source.lineNumber, line, "GitGraph merge requires a branch") + const branch = stripMermaidQuotes(rest[0]!) + if (!heads.has(branch)) throw syntaxError(source.lineNumber, line, `Unknown branch "${branch}"`) + if (branch === currentBranch) + throw syntaxError(source.lineNumber, line, "GitGraph cannot merge a branch into itself") + const currentHead = heads.get(currentBranch) + const mergedHead = heads.get(branch) + if (currentHead === undefined) + throw syntaxError(source.lineNumber, line, `Branch "${currentBranch}" has no commits`) + if (mergedHead === undefined) throw syntaxError(source.lineNumber, line, `Branch "${branch}" has no commits`) + if (currentHead === mergedHead) + throw syntaxError(source.lineNumber, line, `Branches already share head "${mergedHead}"`) + const attributes = parseAttributes(rest.slice(1), source.lineNumber, line, ["id", "tag", "type"]) + const id = single(attributes, "id", source.lineNumber, line) ?? `commit-${generatedId++}` + if (ids.has(id)) throw syntaxError(source.lineNumber, line, `Duplicate commit id "${id}"`) + const commit: GitGraphCommit = { + id, + tags: attributes.get("tag") ?? [], + type: parseCommitType(single(attributes, "type", source.lineNumber, line), source.lineNumber, line), + branch: currentBranch, + parents: [currentHead, mergedHead], + } + commits.push(commit) + ids.add(id) + heads.set(currentBranch, id) + continue + } + + if (operation === "cherry-pick") { + throw syntaxError(source.lineNumber, line, "Cherry-pick is not supported") + } + throw syntaxError(source.lineNumber, line) + } + + const resolvedBranches = branches.map((branch) => { + const head = heads.get(branch.name) + return { ...branch, ...(head === undefined ? {} : { head }) } + }) + return { direction, branches: orderBranches(resolvedBranches), commits } +} + +function tokenize(line: string): string[] { + const tokens: string[] = [] + let token = "" + let quote: '"' | "'" | undefined + for (const char of line) { + if ((char === '"' || char === "'") && (quote === undefined || quote === char)) { + quote = quote === char ? undefined : char + token += char + continue + } + if (/\s/.test(char) && quote === undefined) { + if (token) tokens.push(token) + token = "" + continue + } + token += char + } + if (quote !== undefined) return [line] + if (token) tokens.push(token) + return tokens +} + +function parseAttributes( + tokens: string[], + lineNumber: number, + line: string, + allowed: readonly string[], +): Map { + const result = new Map() + for (let index = 0; index < tokens.length; index += 1) { + const keyToken = tokens[index]! + const separator = keyToken.indexOf(":") + const key = (separator < 0 ? keyToken : keyToken.slice(0, separator)).toLowerCase() + if (!allowed.includes(key)) throw syntaxError(lineNumber, line, `Unsupported GitGraph attribute "${key}"`) + const inline = separator < 0 ? "" : keyToken.slice(separator + 1) + const valueToken = inline || tokens[++index] + if (valueToken === undefined) throw syntaxError(lineNumber, line, `GitGraph attribute "${key}" requires a value`) + const values = result.get(key) ?? [] + values.push(stripMermaidQuotes(valueToken)) + result.set(key, values) + } + return result +} + +function single(attributes: Map, key: string, lineNumber: number, line: string): string | undefined { + const values = attributes.get(key) + if (values && values.length > 1) throw syntaxError(lineNumber, line, `GitGraph attribute "${key}" cannot repeat`) + return values?.[0] +} + +function parseCommitType(value: string | undefined, lineNumber: number, line: string): GitGraphCommitType { + if (value === undefined) return "NORMAL" + const type = value.toUpperCase() + if (type === "NORMAL" || type === "REVERSE" || type === "HIGHLIGHT") return type + throw syntaxError(lineNumber, line, `Unknown GitGraph commit type "${value}"`) +} + +function orderBranches(branches: GitGraphBranch[]): GitGraphBranch[] { + const main = branches[0]! + const rest = branches.slice(1).map((branch, index) => ({ branch, index })) + const unordered = rest.filter(({ branch }) => branch.order === undefined) + const ordered = rest + .filter(({ branch }) => branch.order !== undefined) + .sort((left, right) => left.branch.order! - right.branch.order! || left.index - right.index) + return [main, ...unordered.map(({ branch }) => branch), ...ordered.map(({ branch }) => branch)] +} + +function stripComment(value: string): string { + let quote: '"' | "'" | undefined + for (let index = 0; index < value.length - 1; index += 1) { + const char = value[index] + if ((char === '"' || char === "'") && (quote === undefined || quote === char)) { + quote = quote === char ? undefined : char + continue + } + if (quote === undefined && char === "%" && value[index + 1] === "%") return value.slice(0, index).trim() + } + return value.trim() +} + +function syntaxError(lineNumber: number, sourceLine: string, reason?: string): MermaidSyntaxError { + return new MermaidSyntaxError("gitGraph", lineNumber, sourceLine, reason) +} diff --git a/packages/merman/src/gitgraph/render-grid.ts b/packages/merman/src/gitgraph/render-grid.ts new file mode 100644 index 00000000000..90874eaa426 --- /dev/null +++ b/packages/merman/src/gitgraph/render-grid.ts @@ -0,0 +1,17 @@ +import type { StyledText } from "@opentui/core" +import type { DiagramCanvas } from "../core/canvas.js" +import { renderDiagramGridStyledText } from "../core/render-grid.js" +import type { GitGraphStyleColors } from "./style.js" +import type { GitGraphCellStyle } from "./types.js" + +export type GitGraphGrid = DiagramCanvas + +export function renderGitGraphGridText(grid: GitGraphGrid): string { + return grid.toString({ trimBottom: true }) +} + +export function renderGitGraphGridStyledText(grid: GitGraphGrid, colors: GitGraphStyleColors): StyledText { + return renderDiagramGridStyledText(grid, (run) => (run.style ? colors[run.style] : undefined), undefined, { + trimBottom: true, + }) +} diff --git a/packages/merman/src/gitgraph/style.ts b/packages/merman/src/gitgraph/style.ts new file mode 100644 index 00000000000..966a53821d5 --- /dev/null +++ b/packages/merman/src/gitgraph/style.ts @@ -0,0 +1,37 @@ +import { RGBA } from "@opentui/core" +import { rgba, type DiagramRgb } from "../core/color/style.js" +import type { GitGraphCellStyle } from "./types.js" + +const BRANCH_RGB = [ + [134, 225, 200], + [230, 177, 126], + [154, 184, 169], + [198, 160, 246], + [126, 189, 230], + [225, 134, 166], + [190, 210, 120], + [180, 180, 210], +] as const satisfies readonly DiagramRgb[] + +export type GitGraphStyleColors = Required> + +export function resolveGitGraphStyleColors( + colors: Partial> = {}, +): GitGraphStyleColors { + const rail = colors.muted ?? rgba([111, 138, 126]) + return { + branch0: rail, + branch1: rail, + branch2: rail, + branch3: rail, + branch4: rail, + branch5: rail, + branch6: rail, + branch7: rail, + commit: colors.primary ?? rgba(BRANCH_RGB[0]), + merge: colors.secondary ?? rgba(BRANCH_RGB[2]), + highlight: colors.warning ?? rgba(BRANCH_RGB[1]), + reverse: colors.warning ?? rgba(BRANCH_RGB[5]), + label: colors.text ?? rgba([228, 239, 232]), + } +} diff --git a/packages/merman/src/gitgraph/types.ts b/packages/merman/src/gitgraph/types.ts new file mode 100644 index 00000000000..ad858e64bce --- /dev/null +++ b/packages/merman/src/gitgraph/types.ts @@ -0,0 +1,36 @@ +export type GitGraphDirection = "LR" | "TB" | "BT" +export type GitGraphCommitType = "NORMAL" | "REVERSE" | "HIGHLIGHT" + +export interface GitGraphBranch { + name: string + order?: number + head?: string +} + +export interface GitGraphCommit { + id: string + message?: string + tags: string[] + type: GitGraphCommitType + branch: string + parents: string[] +} + +export interface GitGraphDiagram { + direction: GitGraphDirection + branches: GitGraphBranch[] + commits: GitGraphCommit[] +} + +export interface GitGraphDiagramRenderOptions { + /** Parsed for Mermaid compatibility. Git graphs always use a vertical terminal layout. */ + direction?: GitGraphDirection +} + +export type GitGraphCellStyle = + | `branch${0 | 1 | 2 | 3 | 4 | 5 | 6 | 7}` + | "commit" + | "merge" + | "highlight" + | "reverse" + | "label" diff --git a/packages/merman/src/markdown.ts b/packages/merman/src/markdown.ts index e933742869f..5b4dc55e639 100644 --- a/packages/merman/src/markdown.ts +++ b/packages/merman/src/markdown.ts @@ -17,6 +17,10 @@ import { detectMermaidDiagram } from "./detect.js" import { drawFlowchartDiagramGrid } from "./flowchart/drawing.js" import { parseMermaidFlowchartDiagram } from "./flowchart/parser.js" import { renderGridStyledText, resolveFlowchartStyleColors } from "./flowchart/style.js" +import { drawGitGraphDiagramGrid } from "./gitgraph/drawing.js" +import { parseMermaidGitGraphDiagram } from "./gitgraph/parser.js" +import { renderGitGraphGridStyledText } from "./gitgraph/render-grid.js" +import { resolveGitGraphStyleColors } from "./gitgraph/style.js" import { drawSequenceDiagramGrid } from "./sequence/drawing.js" import { parseMermaidSequenceDiagram } from "./sequence/parser.js" import { renderSequenceGridStyledText } from "./sequence/render-grid.js" @@ -137,6 +141,25 @@ function prepareDiagram( height: size.height, } } + case "gitGraph": { + const grid = drawGitGraphDiagramGrid(parseMermaidGitGraphDiagram(source)) + const size = grid.getTextSize({ trimBottom: true }) + return { + kind, + source, + text: renderGitGraphGridStyledText( + grid, + resolveGitGraphStyleColors({ + primary: color(colors.primary), + secondary: color(colors.secondary), + muted: color(colors.muted), + warning: color(colors.warning), + text: color(colors.text), + }), + ), + height: size.height, + } + } case "sequence": { const grid = drawSequenceDiagramGrid(parseMermaidSequenceDiagram(source), { compact: options.compact }) const size = grid.getTextSize() diff --git a/packages/merman/src/test/diagnostics.test.ts b/packages/merman/src/test/diagnostics.test.ts index 5ccebb2ac61..0dfe57bc5e7 100644 --- a/packages/merman/src/test/diagnostics.test.ts +++ b/packages/merman/src/test/diagnostics.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test" import { MermaidSyntaxError } from "../diagnostics.js" +import { renderGitGraphDiagram } from "../gitgraph/diagram.js" import { parseMermaidFlowchartDiagram } from "../flowchart/parser.js" import { parseMermaidSequenceDiagram } from "../sequence/parser.js" import { parseMermaidStateDiagram } from "../state/parser.js" @@ -111,6 +112,12 @@ describe("parser diagnostics", () => { ) }) + test("reports unsupported GitGraph operations with source diagnostics", () => { + expect(() => renderGitGraphDiagram("gitGraph\n cherry-pick id: missing")).toThrow( + 'Cherry-pick is not supported in gitGraph diagram at line 2: "cherry-pick id: missing"', + ) + }) + test("does not attach else through an unclosed nested sequence block", () => { expect(() => parseMermaidSequenceDiagram(`sequenceDiagram diff --git a/packages/merman/src/test/markdown.test.ts b/packages/merman/src/test/markdown.test.ts index 49c3423c2d6..380aa4034a6 100644 --- a/packages/merman/src/test/markdown.test.ts +++ b/packages/merman/src/test/markdown.test.ts @@ -361,3 +361,28 @@ timeline expect(frame).toContain("First release") expect(frame).not.toContain("timeline") }) + +test("renders a Mermaid GitGraph fence inside MarkdownRenderable", async () => { + const testRenderer = await createTestRenderer({ width: 80, height: 18 }) + renderer = testRenderer.renderer + const markdown = new MarkdownRenderable(renderer, { + id: "markdown-gitgraph", + content: `\`\`\`mermaid +gitGraph + commit id: "baseline" + branch feature + commit id: "ship" +\`\`\``, + syntaxStyle, + treeSitterClient, + renderNode: createMermaidMarkdownRenderer(renderer), + }) + + renderer.root.add(markdown) + await renderMarkdown(markdown, testRenderer.renderOnce) + + const frame = testRenderer.captureCharFrame() + expect(frame).toContain("baseline") + expect(frame).toContain("ship") + expect(frame).not.toContain("gitGraph") +})