From a07cdd8b1141f5d8dc766c37a4a4e0a9b3f8fd52 Mon Sep 17 00:00:00 2001 From: Knut Sveidqvist Date: Fri, 8 Aug 2025 17:00:46 +0200 Subject: [PATCH] WIP --- instructions.md | 4 + .../diagrams/flowchart/parser/flowParser.ts | 373 +++++++++++++++++- .../src/diagrams/sequence/demo.spec.js | 114 ++++++ 3 files changed, 488 insertions(+), 3 deletions(-) create mode 100644 packages/mermaid/src/diagrams/sequence/demo.spec.js diff --git a/instructions.md b/instructions.md index cdd9057f3..ffdacf92b 100644 --- a/instructions.md +++ b/instructions.md @@ -370,3 +370,7 @@ This document contains important guidelines and standards for working on the Mer - Documentation for diagram types is located in packages/mermaid/src/docs/ - Add links to the sidenav when adding new diagram documentation - Use classDiagram.spec.js as a reference for writing diagram test files + +Run the tests using: `vitest run packages/mermaid/src/diagrams/flowchart/parser/lezer-*.spec.ts` + + diff --git a/packages/mermaid/src/diagrams/flowchart/parser/flowParser.ts b/packages/mermaid/src/diagrams/flowchart/parser/flowParser.ts index c21446b55..81e7443ee 100644 --- a/packages/mermaid/src/diagrams/flowchart/parser/flowParser.ts +++ b/packages/mermaid/src/diagrams/flowchart/parser/flowParser.ts @@ -211,6 +211,46 @@ class LezerFlowParser { // Look for patterns like: // 1. A--text including URL space and send-->B // 2. A-- text including URL space and send -->B + // 3. A---|text|B (pipe-delimited) + + // Check for simple edge pattern first (A---B, A--xB, etc.) + // But only if it's not part of a pipe-delimited pattern + if ( + this.isSimpleEdgePattern(tokens[startIndex]) && + !this.isPartOfPipeDelimitedPattern(tokens, startIndex) + ) { + const patternTokens = [tokens[startIndex]]; + console.log( + `UIO DEBUG: Analyzing simple edge pattern: ${patternTokens.map((t) => t.value).join(' ')}` + ); + + const merged = this.detectAndMergeEdgePattern(patternTokens, tokens, startIndex); + if (merged) { + return { + mergedTokens: merged, + nextIndex: startIndex + 1, + }; + } + } + + // Check for pipe-delimited pattern (A---|text|B) + if (this.isPipeDelimitedEdgePattern(tokens, startIndex)) { + const endIndex = this.findPipeDelimitedPatternEnd(tokens, startIndex); + if (endIndex > startIndex) { + const patternTokens = tokens.slice(startIndex, endIndex); + console.log( + `UIO DEBUG: Analyzing pipe-delimited edge pattern: ${patternTokens.map((t) => t.value).join(' ')}` + ); + + const merged = this.detectAndMergeEdgePattern(patternTokens, tokens, startIndex); + if (merged) { + return { + mergedTokens: merged, + nextIndex: endIndex, + }; + } + } + } // Find the end of this potential edge pattern let endIndex = startIndex; @@ -260,6 +300,97 @@ class LezerFlowParser { return null; } + /** + * Check if tokens starting at index form a pipe-delimited edge pattern + */ + private isPipeDelimitedEdgePattern( + tokens: { type: string; value: string; from: number; to: number }[], + startIndex: number + ): boolean { + if (startIndex + 3 >= tokens.length) { + return false; + } + + const first = tokens[startIndex]; + const second = tokens[startIndex + 1]; + + return ( + first.type === 'NODE_STRING' && this.endsWithArrow(first.value) && second.type === 'PIPE' + ); + } + + /** + * Check if a single NODE_STRING token contains a simple edge pattern + */ + private isSimpleEdgePattern(token: { type: string; value: string }): boolean { + if (token.type !== 'NODE_STRING') { + return false; + } + + // Check for patterns like A---B, A--xB, A--oB, A-->B, etc. + const simpleEdgePatterns = [ + /^(.+?)(---?)([ox]?)(.+)$/, // A---B, A--B, A---xB, A--oB + /^(.+?)(==+)([ox]?)(.+)$/, // A===B, A==B, A===xB, A==oB + /^(.+?)(-\.-?)([ox]?)(.+)$/, // A-.-B, A-.B, A-.-xB, A-.oB + /^(.+?)(--+>)(.+)$/, // A-->B, A--->B + /^(.+?)(==+>)(.+)$/, // A==>B, A===>B + /^(.+?)(-\.->)(.+)$/, // A-.->B + ]; + + return simpleEdgePatterns.some((pattern) => pattern.test(token.value)); + } + + /** + * Check if a token is part of a pipe-delimited pattern (should not be treated as simple edge) + */ + private isPartOfPipeDelimitedPattern( + tokens: { type: string; value: string; from: number; to: number }[], + startIndex: number + ): boolean { + // Check if the current token could be the start of a pipe-delimited pattern + // This includes tokens that end with arrows (A---) or arrow+ending (A--x) + if (startIndex + 1 < tokens.length) { + const currentToken = tokens[startIndex]; + const nextToken = tokens[startIndex + 1]; + + if (currentToken.type === 'NODE_STRING' && nextToken.type === 'PIPE') { + // Check if current token ends with arrow patterns that could be pipe-delimited + const arrowPatterns = [ + /---?[ox]?$/, // ---, --, --x, --o, ---x, ---o + /==+[ox]?$/, // ==, ===, ==x, ==o, ===x, ===o + /-\.-?[ox]?$/, // -., -.-, -.x, -.o, -.-x, -.-o + ]; + + return arrowPatterns.some((pattern) => pattern.test(currentToken.value)); + } + } + + return false; + } + + /** + * Find the end index of a pipe-delimited pattern + */ + private findPipeDelimitedPatternEnd( + tokens: { type: string; value: string; from: number; to: number }[], + startIndex: number + ): number { + // Look for the closing pipe and target node + for (let i = startIndex + 2; i < tokens.length; i++) { + if ( + tokens[i].type === 'PIPE' && + i + 1 < tokens.length && + tokens[i + 1].type === 'NODE_STRING' + ) { + return i + 2; // Include the target node + } + if (tokens[i].type === 'SEMI') { + break; // End of statement + } + } + return startIndex; + } + /** * Helper: does a NODE_STRING like "A-.-" followed by TagEnd '>' and a NODE_STRING target * represent a dotted simple edge A-.->B? If so, merge into canonical tokens. @@ -309,6 +440,231 @@ class LezerFlowParser { ]; } + /** + * Check if tokens match pipe-delimited pattern: A---|text|B + */ + private matchesPipeDelimitedPattern( + tokens: { type: string; value: string; from: number; to: number }[] + ): boolean { + if (tokens.length < 4) { + return false; + } + + // First token should be NODE_STRING ending with arrow (like "A---", "A==>", "A-.-") + const first = tokens[0]; + if (first.type !== 'NODE_STRING' || !this.endsWithArrow(first.value)) { + return false; + } + + // Second token should be PIPE + if (tokens[1].type !== 'PIPE') { + return false; + } + + // Find the closing pipe and target + let closingPipeIndex = -1; + for (let i = 2; i < tokens.length; i++) { + if (tokens[i].type === 'PIPE') { + closingPipeIndex = i; + break; + } + } + + if (closingPipeIndex === -1 || closingPipeIndex >= tokens.length - 1) { + return false; + } + + // Last token should be NODE_STRING (target) + const last = tokens[tokens.length - 1]; + if (last.type !== 'NODE_STRING') { + return false; + } + + // All tokens between pipes should be text tokens + const textTokens = tokens.slice(2, closingPipeIndex); + return textTokens.every((t) => this.isTextToken(t.type)); + } + + /** + * Merge pipe-delimited pattern tokens into proper edge format + */ + private mergePipeDelimitedPattern( + tokens: { type: string; value: string; from: number; to: number }[] + ): { type: string; value: string; from: number; to: number }[] { + const firstToken = tokens[0]; + + // Extract source node ID and arrow from first token (e.g., "A---" -> "A" + "---") + const { sourceId, arrow } = this.extractSourceAndArrow(firstToken.value); + + // Find the closing pipe + let closingPipeIndex = -1; + for (let i = 2; i < tokens.length; i++) { + if (tokens[i].type === 'PIPE') { + closingPipeIndex = i; + break; + } + } + + // Extract text from tokens between pipes + const textTokens = tokens.slice(2, closingPipeIndex); + const edgeText = textTokens + .map((t) => t.value) + .join(' ') + .trim(); + + const targetToken = tokens[tokens.length - 1]; + + console.log( + `UIO DEBUG: Pipe-delimited merge - source: ${sourceId}, arrow: ${arrow}, text: "${edgeText}", target: ${targetToken.value}` + ); + + return [ + { + type: 'NODE_STRING', + value: sourceId, + from: firstToken.from, + to: firstToken.from + sourceId.length, + }, + { + type: 'LINK', + value: arrow, + from: firstToken.from + sourceId.length, + to: firstToken.from + sourceId.length + arrow.length, + }, + { + type: 'PIPE', + value: '|', + from: firstToken.from + sourceId.length + arrow.length, + to: firstToken.from + sourceId.length + arrow.length + 1, + }, + { + type: 'NODE_STRING', + value: edgeText, + from: firstToken.from + sourceId.length + arrow.length + 1, + to: firstToken.from + sourceId.length + arrow.length + 1 + edgeText.length, + }, + { + type: 'PIPE', + value: '|', + from: firstToken.from + sourceId.length + arrow.length + 1 + edgeText.length, + to: firstToken.from + sourceId.length + arrow.length + 2 + edgeText.length, + }, + { + type: 'NODE_STRING', + value: targetToken.value, + from: targetToken.from, + to: targetToken.to, + }, + ]; + } + + /** + * Check if a string ends with an arrow pattern + */ + private endsWithArrow(value: string): boolean { + return ( + value.endsWith('---') || + value.endsWith('-->') || + value.endsWith('==>') || + value.endsWith('===') || + value.endsWith('-.-') || + value.endsWith('-.->') || + value.endsWith('--') || + value.endsWith('==') + ); + } + + /** + * Extract source node ID and arrow from a combined string + */ + private extractSourceAndArrow(value: string): { sourceId: string; arrow: string } { + // Try different arrow patterns, longest first + const patterns = ['--->', '===>', '-.->', '---', '===', '-.-', '-->', '==>', '--', '==']; + + for (const pattern of patterns) { + if (value.endsWith(pattern)) { + const sourceId = value.substring(0, value.length - pattern.length); + return { sourceId, arrow: pattern }; + } + } + + // Fallback - shouldn't happen if endsWithArrow returned true + return { sourceId: value, arrow: '' }; + } + + /** + * Merge a simple edge pattern token (A---B) into proper edge format + */ + private mergeSimpleEdgePattern(token: { + type: string; + value: string; + from: number; + to: number; + }): { type: string; value: string; from: number; to: number }[] { + const value = token.value; + + // Try to match different simple edge patterns + const patterns = [ + { regex: /^(.+?)(---?)([ox])(.+)$/, hasEnding: true }, // A---xB, A--oB + { regex: /^(.+?)(---?)(.+)$/, hasEnding: false }, // A---B, A--B + { regex: /^(.+?)(==+)([ox])(.+)$/, hasEnding: true }, // A===xB, A==oB + { regex: /^(.+?)(==+)(.+)$/, hasEnding: false }, // A===B, A==B + { regex: /^(.+?)(-\.-?)([ox])(.+)$/, hasEnding: true }, // A-.-xB, A-.oB + { regex: /^(.+?)(-\.-?)(.+)$/, hasEnding: false }, // A-.-B, A-.B + { regex: /^(.+?)(--+>)(.+)$/, hasEnding: false }, // A-->B, A--->B + { regex: /^(.+?)(==+>)(.+)$/, hasEnding: false }, // A==>B, A===>B + { regex: /^(.+?)(-\.->)(.+)$/, hasEnding: false }, // A-.->B + ]; + + for (const pattern of patterns) { + const match = value.match(pattern.regex); + if (match) { + const sourceId = match[1]; + let arrow: string; + let targetId: string; + + if (pattern.hasEnding) { + // Pattern with ending: source, arrow, ending, target + arrow = match[2] + match[3]; // arrow + ending (x, o) + targetId = match[4]; + } else { + // Pattern without ending: source, arrow, target + arrow = match[2]; + targetId = match[3]; + } + + console.log( + `UIO DEBUG: Simple edge merge - source: ${sourceId}, arrow: ${arrow}, target: ${targetId}` + ); + + return [ + { + type: 'NODE_STRING', + value: sourceId, + from: token.from, + to: token.from + sourceId.length, + }, + { + type: 'LINK', + value: arrow, + from: token.from + sourceId.length, + to: token.from + sourceId.length + arrow.length, + }, + { + type: 'NODE_STRING', + value: targetId, + from: token.from + sourceId.length + arrow.length, + to: token.to, + }, + ]; + } + } + + // Fallback - return original token if no pattern matches + console.log(`UIO DEBUG: No simple edge pattern matched for: ${value}`); + return [token]; + } + /** * Detect and merge specific edge patterns * @param patternTokens - The tokens that form the potential edge pattern @@ -320,6 +676,17 @@ class LezerFlowParser { allTokens: { type: string; value: string; from: number; to: number }[], startIndex: number ): { type: string; value: string; from: number; to: number }[] | null { + // Pattern 0: Simple edge pattern A---B, A--xB, A-->B (single token) + if (patternTokens.length === 1 && this.isSimpleEdgePattern(patternTokens[0])) { + return this.mergeSimpleEdgePattern(patternTokens[0]); + } + + // Pattern 3: Pipe-delimited edge text A---|text|B + // Tokens: [A---, |, text, tokens..., |, B] + if (this.matchesPipeDelimitedPattern(patternTokens)) { + return this.mergePipeDelimitedPattern(patternTokens); + } + // Pattern 1: A--text including URL space and send-->B // Tokens: [A--text, including, URL, space, and, send--, >, B] if (this.matchesPattern1(patternTokens)) { @@ -2964,7 +3331,7 @@ class LezerFlowParser { ); } else if (hasEmbeddedArrowWithNode) { // Extract the actual node ID and target node from the combined token (e.g., "A--xv" -> "A" and "v") - const match = /^(.+?)--[xo](.+)$/.exec(sourceToken.value); + const match = /^(.+?)--[ox](.+)$/.exec(sourceToken.value); if (match) { sourceId = match[1]; // "A" const targetNodeId = match[2]; // "v" @@ -3016,7 +3383,7 @@ class LezerFlowParser { let edgeInfo; if (hasEmbeddedArrowWithPipe) { // For embedded arrows like "A--x", extract the arrow and handle pipe-delimited text - const arrowMatch = /--([xo])$/.exec(sourceToken.value); + const arrowMatch = /--([ox])$/.exec(sourceToken.value); if (arrowMatch) { const arrowType = arrowMatch[1]; // 'x' or 'o' const arrow = `--${arrowType}`; @@ -3034,7 +3401,7 @@ class LezerFlowParser { } } else if (hasEmbeddedArrowWithNode) { // For embedded arrows like "A--xv", extract the arrow and target node - const match = /^(.+?)--([xo])(.+)$/.exec(sourceToken.value); + const match = /^(.+?)--([ox])(.+)$/.exec(sourceToken.value); if (match) { const arrowType = match[2]; // 'x' or 'o' const arrow = `--${arrowType}`; diff --git a/packages/mermaid/src/diagrams/sequence/demo.spec.js b/packages/mermaid/src/diagrams/sequence/demo.spec.js new file mode 100644 index 000000000..67b90159d --- /dev/null +++ b/packages/mermaid/src/diagrams/sequence/demo.spec.js @@ -0,0 +1,114 @@ +import { vi } from 'vitest'; +import { setSiteConfig } from '../../diagram-api/diagramAPI.js'; +import mermaidAPI from '../../mermaidAPI.js'; +import { Diagram } from '../../Diagram.js'; +import { addDiagrams } from '../../diagram-api/diagram-orchestration.js'; +import { SequenceDB } from './sequenceDb.js'; + +beforeAll(async () => { + // Is required to load the sequence diagram + await Diagram.fromText('sequenceDiagram'); +}); + +/** + * Sequence diagrams require their own very special version of a mocked d3 module + * diagrams/sequence/svgDraw uses statements like this with d3 nodes: (note the [0][0]) + * + * // in drawText(...) + * textHeight += (textElem._groups || textElem)[0][0].getBBox().height; + */ +vi.mock('d3', () => { + const NewD3 = function () { + function returnThis() { + return this; + } + return { + append: function () { + return NewD3(); + }, + lower: returnThis, + attr: returnThis, + style: returnThis, + text: returnThis, + // [0][0] (below) is required by drawText() in packages/mermaid/src/diagrams/sequence/svgDraw.js + 0: { + 0: { + getBBox: function () { + return { + height: 10, + width: 20, + }; + }, + }, + }, + }; + }; + + return { + select: function () { + return new NewD3(); + }, + + selectAll: function () { + return new NewD3(); + }, + + // TODO: In d3 these are CurveFactory types, not strings + curveBasis: 'basis', + curveBasisClosed: 'basisClosed', + curveBasisOpen: 'basisOpen', + curveBumpX: 'bumpX', + curveBumpY: 'bumpY', + curveBundle: 'bundle', + curveCardinalClosed: 'cardinalClosed', + curveCardinalOpen: 'cardinalOpen', + curveCardinal: 'cardinal', + curveCatmullRomClosed: 'catmullRomClosed', + curveCatmullRomOpen: 'catmullRomOpen', + curveCatmullRom: 'catmullRom', + curveLinear: 'linear', + curveLinearClosed: 'linearClosed', + curveMonotoneX: 'monotoneX', + curveMonotoneY: 'monotoneY', + curveNatural: 'natural', + curveStep: 'step', + curveStepAfter: 'stepAfter', + curveStepBefore: 'stepBefore', + }; +}); +// ------------------------------- + +addDiagrams(); + +/** + * @param conf + * @param key + * @param value + */ +function addConf(conf, key, value) { + if (value !== undefined) { + conf[key] = value; + } + return conf; +} + +// const parser = sequence.parser; + +describe('when parsing a sequenceDiagram', function () { + let diagram; + beforeEach(async function () { + diagram = await Diagram.fromText(` +sequenceDiagram +Alice->Bob:Hello Bob, how are you? +Note right of Bob: Bob thinks +Bob-->Alice: I am good thanks!`); + }); + it('should parse', async () => { + const diagram = await Diagram.fromText(` + sequenceDiagram + participant Alice@{ type : database } + Bob->>Alice: Hi Alice + + `); + }); +});