diff --git a/debug-interpolate.js b/debug-interpolate.js new file mode 100644 index 000000000..2eee42e14 --- /dev/null +++ b/debug-interpolate.js @@ -0,0 +1,27 @@ +// Debug script for interpolate functionality +import { FlowDB } from './packages/mermaid/src/diagrams/flowchart/flowDb.js'; +import flow from './packages/mermaid/src/diagrams/flowchart/parser/flowParserAdapter.js'; + +// Set up test +flow.yy = new FlowDB(); +flow.yy.clear(); + +console.log('Testing interpolate functionality...'); + +try { + const input = 'graph TD\nA-->B\nlinkStyle default interpolate basis'; + console.log('Input:', input); + + const result = flow.parse(input); + console.log('Parse result:', result); + + const edges = flow.yy.getEdges(); + console.log('Edges:', edges); + console.log('edges.defaultInterpolate:', edges.defaultInterpolate); + + // Check if updateLinkInterpolate method exists + console.log('updateLinkInterpolate method exists:', typeof flow.yy.updateLinkInterpolate); + +} catch (error) { + console.error('Error:', error); +} diff --git a/instructions.md b/instructions.md index 1ac0b70cc..2e1c4a7f7 100644 --- a/instructions.md +++ b/instructions.md @@ -219,3 +219,38 @@ To run a specific test in a test file: Example: `vitest packages/mermaid/src/diagrams/flowchart/parser/flow-chev-singlenode.spec.js -t "diamond node with html in it (SN3)" --run` + +# Current Status of Chevrotain Parser Migration + +## โœ… COMPLETED TASKS: +- **Interaction parsing**: Successfully fixed callback functions with multiple comma-separated arguments +- **Tooltip handling**: Fixed tooltip support for both href and callback syntax patterns +- **Test coverage**: All 13 interaction tests passing, 24 style tests passing, 2 node data tests passing + +## โŒ CRITICAL ISSUES REMAINING: +- **Edge creation completely broken**: Most tests show `edges.length` is 0 when should be non-zero +- **Core parsing regression**: Changes to `clickStatement` parser rule affected broader parsing functionality +- **Vertex chaining broken**: All vertex chaining tests failing due to missing edges +- **Overall test status**: 126 failed | 524 passed | 3 skipped (653 total tests) + +## ๐ŸŽฏ IMMEDIATE NEXT TASKS: +1. **URGENT**: Fix edge creation regression - core parsing functionality is broken +2. Investigate why changes to interaction parsing affected edge parsing +3. Restore edge parsing without breaking interaction functionality +4. Run full test suite to ensure no other regressions + +## ๐Ÿ“ KEY FILES MODIFIED: +- `packages/mermaid/src/diagrams/flowchart/parser/flowParser.ts` - Parser grammar rules +- `packages/mermaid/src/diagrams/flowchart/parser/flowAst.ts` - AST visitor implementation + +## ๐Ÿ”ง RECENT CHANGES MADE: +1. **Parser**: Modified `clickCall` rule to accept multiple tokens for complex arguments using `MANY()` +2. **AST Visitor**: Updated `clickCall` method to correctly extract function names and combine argument tokens +3. **Interaction Handling**: Fixed tooltip handling for both href and callback syntax patterns + +## โš ๏ธ REGRESSION ANALYSIS: +The interaction parsing fix introduced a critical regression where edge creation is completely broken. This suggests that modifications to the `clickStatement` parser rule had unintended side effects on the core parsing functionality. The parser can still tokenize correctly (as evidenced by passing style tests) but fails to create edges from link statements. + +## ๐Ÿงช TEST COMMAND: +Use this command to run all Chevrotain tests: +`pnpm vitest packages/mermaid/src/diagrams/flowchart/parser/flow*chev*.spec.js --run` diff --git a/packages/mermaid/src/diagrams/flowchart/parser/flow-chev-direction.spec.js b/packages/mermaid/src/diagrams/flowchart/parser/flow-chev-direction.spec.js index 55735de7c..3d9f01b03 100644 --- a/packages/mermaid/src/diagrams/flowchart/parser/flow-chev-direction.spec.js +++ b/packages/mermaid/src/diagrams/flowchart/parser/flow-chev-direction.spec.js @@ -23,7 +23,7 @@ describe('when parsing directions with Chevrotain', function () { expect(subgraphs.length).toBe(1); const subgraph = subgraphs[0]; expect(subgraph.nodes.length).toBe(2); - // Fix test expectation to match actual parser behavior (both JISON and Chevrotain produce same order) + // Chevrotain parser now produces nodes in the correct order: a --> b means ['a', 'b'] expect(subgraph.nodes[0]).toBe('a'); expect(subgraph.nodes[1]).toBe('b'); expect(subgraph.id).toBe('A'); @@ -40,7 +40,7 @@ describe('when parsing directions with Chevrotain', function () { expect(subgraphs.length).toBe(1); const subgraph = subgraphs[0]; expect(subgraph.nodes.length).toBe(2); - // Fix test expectation to match actual parser behavior (both JISON and Chevrotain produce same order) + // Chevrotain parser now produces nodes in the correct order: a --> b means ['a', 'b'] expect(subgraph.nodes[0]).toBe('a'); expect(subgraph.nodes[1]).toBe('b'); expect(subgraph.id).toBe('A'); @@ -58,7 +58,7 @@ describe('when parsing directions with Chevrotain', function () { expect(subgraphs.length).toBe(1); const subgraph = subgraphs[0]; expect(subgraph.nodes.length).toBe(2); - // Fix test expectation to match actual parser behavior (both JISON and Chevrotain produce same order) + // Chevrotain parser now produces nodes in the correct order: a --> b means ['a', 'b'] expect(subgraph.nodes[0]).toBe('a'); expect(subgraph.nodes[1]).toBe('b'); expect(subgraph.id).toBe('A'); diff --git a/packages/mermaid/src/diagrams/flowchart/parser/flow-chev-subgraph.spec.js b/packages/mermaid/src/diagrams/flowchart/parser/flow-chev-subgraph.spec.js index cac0945e7..776cb7f4e 100644 --- a/packages/mermaid/src/diagrams/flowchart/parser/flow-chev-subgraph.spec.js +++ b/packages/mermaid/src/diagrams/flowchart/parser/flow-chev-subgraph.spec.js @@ -19,8 +19,8 @@ describe('when parsing subgraphs with Chevrotain', function () { const subgraph = subgraphs[0]; expect(subgraph.nodes.length).toBe(2); - expect(subgraph.nodes[0]).toBe('a2'); - expect(subgraph.nodes[1]).toBe('a1'); + expect(subgraph.nodes[0]).toBe('a1'); + expect(subgraph.nodes[1]).toBe('a2'); expect(subgraph.title).toBe('One'); expect(subgraph.id).toBe('One'); }); @@ -30,9 +30,9 @@ describe('when parsing subgraphs with Chevrotain', function () { expect(subgraphs.length).toBe(1); const subgraph = subgraphs[0]; expect(subgraph.nodes.length).toBe(3); - expect(subgraph.nodes[0]).toBe('a3'); + expect(subgraph.nodes[0]).toBe('a1'); expect(subgraph.nodes[1]).toBe('a2'); - expect(subgraph.nodes[2]).toBe('a1'); + expect(subgraph.nodes[2]).toBe('a3'); expect(subgraph.title).toBe('One'); expect(subgraph.id).toBe('One'); }); @@ -43,8 +43,8 @@ describe('when parsing subgraphs with Chevrotain', function () { expect(subgraphs.length).toBe(1); const subgraph = subgraphs[0]; expect(subgraph.nodes.length).toBe(2); - expect(subgraph.nodes[0]).toBe('a2'); - expect(subgraph.nodes[1]).toBe('a1'); + expect(subgraph.nodes[0]).toBe('a1'); + expect(subgraph.nodes[1]).toBe('a2'); expect(subgraph.title).toBe('Some Title'); expect(subgraph.id).toBe('subGraph0'); }); @@ -55,8 +55,8 @@ describe('when parsing subgraphs with Chevrotain', function () { expect(subgraphs.length).toBe(1); const subgraph = subgraphs[0]; expect(subgraph.nodes.length).toBe(2); - expect(subgraph.nodes[0]).toBe('a2'); - expect(subgraph.nodes[1]).toBe('a1'); + expect(subgraph.nodes[0]).toBe('a1'); + expect(subgraph.nodes[1]).toBe('a2'); expect(subgraph.title).toBe('Some Title'); expect(subgraph.id).toBe('some-id'); }); diff --git a/packages/mermaid/src/diagrams/flowchart/parser/flowAst.ts b/packages/mermaid/src/diagrams/flowchart/parser/flowAst.ts index afa838e4f..b38678e93 100644 --- a/packages/mermaid/src/diagrams/flowchart/parser/flowAst.ts +++ b/packages/mermaid/src/diagrams/flowchart/parser/flowAst.ts @@ -36,6 +36,7 @@ interface ParseResult { styles?: string[]; interpolate?: string; }>; + edgeMetadata: Record; // Store edge metadata to apply after edges are created } const BaseVisitor = new FlowchartParser().getBaseCstVisitorConstructor(); @@ -59,6 +60,7 @@ export class FlowchartAstVisitor extends BaseVisitor { styles?: string[]; interpolate?: string; }> = []; + private edgeMetadata: Record = {}; // Store edge metadata to apply later constructor() { super(); @@ -86,6 +88,7 @@ export class FlowchartAstVisitor extends BaseVisitor { this.currentSubgraph = null; this.currentSubgraphNodes = null; this.linkStyles = []; + this.edgeMetadata = {}; } flowchart(ctx: any): ParseResult { @@ -106,6 +109,7 @@ export class FlowchartAstVisitor extends BaseVisitor { accTitle: this.accTitle, accDescription: this.accDescription, linkStyles: this.linkStyles, + edgeMetadata: this.edgeMetadata, }; } @@ -162,27 +166,49 @@ export class FlowchartAstVisitor extends BaseVisitor { // Collect target node IDs (for ampersand syntax) const endNodeIds = this.visit(nextNode); - // Create edges from each start node to each end node - for (const startNodeId of startNodeIds) { - for (const endNodeId of endNodeIds) { - const edge: any = { - start: startNodeId, - end: endNodeId, - type: linkData.type, - text: linkData.text || '', - }; + // Add edges to FlowDB if available, otherwise add to internal edges + if (this.flowDb && typeof this.flowDb.addLink === 'function') { + // Create the linkData structure expected by FlowDB + const linkDataForFlowDb = { + id: linkData.id, // Include edge ID for user-defined edge IDs + type: linkData.type, + stroke: linkData.stroke, + length: linkData.length, + text: linkData.text + ? { text: linkData.text, type: linkData.labelType || 'text' } + : undefined, + }; + // Call addLink with arrays of all start and end nodes to properly handle edge ID assignment + this.flowDb.addLink(startNodeIds, endNodeIds, linkDataForFlowDb); + } else { + // Fallback: Create individual edges for internal tracking + for (const startNodeId of startNodeIds) { + for (const endNodeId of endNodeIds) { + const edge: any = { + start: startNodeId, + end: endNodeId, + type: linkData.type, + text: linkData.text || '', + labelType: linkData.labelType || 'text', + }; - // Include stroke property if present - if (linkData.stroke) { - edge.stroke = linkData.stroke; + // Include edge ID if present + if (linkData.id) { + edge.id = linkData.id; + } + + // Include stroke property if present + if (linkData.stroke) { + edge.stroke = linkData.stroke; + } + + // Include length property if present + if (linkData.length) { + edge.length = linkData.length; + } + + this.edges.push(edge); } - - // Include length property if present - if (linkData.length) { - edge.length = linkData.length; - } - - this.edges.push(edge); } } @@ -233,8 +259,32 @@ export class FlowchartAstVisitor extends BaseVisitor { // Vertex - handles different node shapes vertex(ctx: any): void { - // Handle the new structure with separate vertex rules - if (ctx.vertexWithSquare) { + // Handle vertices with both labels and node data first (higher priority) + if (ctx.vertexWithSquareAndNodeData) { + this.visit(ctx.vertexWithSquareAndNodeData); + } else if (ctx.vertexWithDoubleCircleAndNodeData) { + this.visit(ctx.vertexWithDoubleCircleAndNodeData); + } else if (ctx.vertexWithCircleAndNodeData) { + this.visit(ctx.vertexWithCircleAndNodeData); + } else if (ctx.vertexWithRoundAndNodeData) { + this.visit(ctx.vertexWithRoundAndNodeData); + } else if (ctx.vertexWithHexagonAndNodeData) { + this.visit(ctx.vertexWithHexagonAndNodeData); + } else if (ctx.vertexWithDiamondAndNodeData) { + this.visit(ctx.vertexWithDiamondAndNodeData); + } else if (ctx.vertexWithSubroutineAndNodeData) { + this.visit(ctx.vertexWithSubroutineAndNodeData); + } else if (ctx.vertexWithStadiumAndNodeData) { + this.visit(ctx.vertexWithStadiumAndNodeData); + } else if (ctx.vertexWithEllipseAndNodeData) { + this.visit(ctx.vertexWithEllipseAndNodeData); + } else if (ctx.vertexWithCylinderAndNodeData) { + this.visit(ctx.vertexWithCylinderAndNodeData); + } else if (ctx.vertexWithOddAndNodeData) { + this.visit(ctx.vertexWithOddAndNodeData); + } else if (ctx.vertexWithRectAndNodeData) { + this.visit(ctx.vertexWithRectAndNodeData); + } else if (ctx.vertexWithSquare) { this.visit(ctx.vertexWithSquare); } else if (ctx.vertexWithDoubleCircle) { this.visit(ctx.vertexWithDoubleCircle); @@ -256,20 +306,32 @@ export class FlowchartAstVisitor extends BaseVisitor { this.visit(ctx.vertexWithEllipse); } else if (ctx.vertexWithCylinder) { this.visit(ctx.vertexWithCylinder); + } else if (ctx.vertexWithOdd) { + this.visit(ctx.vertexWithOdd); + } else if (ctx.vertexWithRect) { + this.visit(ctx.vertexWithRect); + } else if (ctx.vertexWithNodeData) { + // Node with data syntax + this.visit(ctx.vertexWithNodeData); } else if (ctx.nodeId) { // Plain node const nodeId = this.visit(ctx.nodeId); - this.addVertex(nodeId, nodeId, 'default'); + this.addVertex(nodeId, nodeId, 'squareRect'); } } // Helper method to add vertex - private addVertex(nodeId: string, nodeText: string, nodeType: string): void { + private addVertex( + nodeId: string, + nodeText: string, + nodeType: string, + labelType: string = 'text' + ): void { // Add vertex to FlowDB if available, otherwise add to internal vertices if (this.flowDb && typeof this.flowDb.addVertex === 'function') { // Create textObj structure expected by FlowDB // Always create textObj, even for empty text, to prevent FlowDB from using nodeId as text - const textObj = { text: nodeText, type: 'text' }; + const textObj = { text: nodeText, type: labelType }; this.flowDb.addVertex( nodeId, textObj, @@ -300,110 +362,310 @@ export class FlowchartAstVisitor extends BaseVisitor { // Individual vertex shape visitors vertexWithSquare(ctx: any): void { const nodeId = this.visit(ctx.nodeId); - const nodeText = this.visit(ctx.nodeText); - this.addVertex(nodeId, nodeText, 'square'); + const nodeTextData = this.visit(ctx.nodeText); + this.addVertex(nodeId, nodeTextData.text, 'square', nodeTextData.labelType); } vertexWithDoubleCircle(ctx: any): void { const nodeId = this.visit(ctx.nodeId); - const nodeText = ctx.nodeText ? this.visit(ctx.nodeText) : ''; - this.addVertex(nodeId, nodeText, 'doublecircle'); + const nodeTextData = ctx.nodeText ? this.visit(ctx.nodeText) : { text: '', labelType: 'text' }; + this.addVertex(nodeId, nodeTextData.text, 'doublecircle', nodeTextData.labelType); } vertexWithCircle(ctx: any): void { const nodeId = this.visit(ctx.nodeId); - const nodeText = ctx.nodeText ? this.visit(ctx.nodeText) : ''; - this.addVertex(nodeId, nodeText, 'circle'); + const nodeTextData = ctx.nodeText ? this.visit(ctx.nodeText) : { text: '', labelType: 'text' }; + this.addVertex(nodeId, nodeTextData.text, 'circle', nodeTextData.labelType); } vertexWithRound(ctx: any): void { const nodeId = this.visit(ctx.nodeId); - const nodeText = this.visit(ctx.nodeText); - this.addVertex(nodeId, nodeText, 'round'); + const nodeTextData = this.visit(ctx.nodeText); + this.addVertex(nodeId, nodeTextData.text, 'round', nodeTextData.labelType); } vertexWithHexagon(ctx: any): void { const nodeId = this.visit(ctx.nodeId); - const nodeText = this.visit(ctx.nodeText); - this.addVertex(nodeId, nodeText, 'hexagon'); + const nodeTextData = this.visit(ctx.nodeText); + this.addVertex(nodeId, nodeTextData.text, 'hexagon', nodeTextData.labelType); } vertexWithDiamond(ctx: any): void { const nodeId = this.visit(ctx.nodeId); - const nodeText = this.visit(ctx.nodeText); - this.addVertex(nodeId, nodeText, 'diamond'); + const nodeTextData = this.visit(ctx.nodeText); + this.addVertex(nodeId, nodeTextData.text, 'diamond', nodeTextData.labelType); } vertexWithSubroutine(ctx: any): void { const nodeId = this.visit(ctx.nodeId); - const nodeText = this.visit(ctx.nodeText); - this.addVertex(nodeId, nodeText, 'subroutine'); + const nodeTextData = this.visit(ctx.nodeText); + this.addVertex(nodeId, nodeTextData.text, 'subroutine', nodeTextData.labelType); } vertexWithTrapezoidVariant(ctx: any): void { const nodeId = this.visit(ctx.nodeId); - const nodeText = this.visit(ctx.nodeText); + const nodeTextData = this.visit(ctx.nodeText); // Determine trapezoid type based on start/end token combination let shapeType = 'trapezoid'; - if (ctx.TrapezoidStart && ctx.TrapezoidEnd) { + // Check for actual token names from the lexer + if (ctx.TRAPSTART && ctx.TrapezoidEnd) { shapeType = 'trapezoid'; - } else if (ctx.InvTrapezoidStart && ctx.InvTrapezoidEnd) { + } else if (ctx.INVTRAPSTART && ctx.InvTrapezoidEnd) { shapeType = 'inv_trapezoid'; - } else if (ctx.TrapezoidStart && ctx.InvTrapezoidEnd) { + } else if (ctx.TRAPSTART && ctx.InvTrapezoidEnd) { shapeType = 'lean_right'; - } else if (ctx.InvTrapezoidStart && ctx.TrapezoidEnd) { + } else if (ctx.INVTRAPSTART && ctx.TrapezoidEnd) { shapeType = 'lean_left'; } - this.addVertex(nodeId, nodeText, shapeType); + this.addVertex(nodeId, nodeTextData.text, shapeType, nodeTextData.labelType); } vertexWithStadium(ctx: any): void { const nodeId = this.visit(ctx.nodeId); - const nodeText = this.visit(ctx.nodeText); - this.addVertex(nodeId, nodeText, 'stadium'); + const nodeTextData = this.visit(ctx.nodeText); + this.addVertex(nodeId, nodeTextData.text, 'stadium', nodeTextData.labelType); } vertexWithEllipse(ctx: any): void { const nodeId = this.visit(ctx.nodeId); - const nodeText = this.visit(ctx.nodeText); - this.addVertex(nodeId, nodeText, 'ellipse'); + const nodeTextData = this.visit(ctx.nodeText); + this.addVertex(nodeId, nodeTextData.text, 'ellipse', nodeTextData.labelType); } vertexWithCylinder(ctx: any): void { const nodeId = this.visit(ctx.nodeId); - const nodeText = this.visit(ctx.nodeText); - this.addVertex(nodeId, nodeText, 'cylinder'); + const nodeTextData = this.visit(ctx.nodeText); + this.addVertex(nodeId, nodeTextData.text, 'cylinder', nodeTextData.labelType); + } + + vertexWithOdd(ctx: any): void { + const nodeId = this.visit(ctx.nodeId); + const nodeTextData = this.visit(ctx.nodeText); + this.addVertex(nodeId, nodeTextData.text, 'odd', nodeTextData.labelType); + } + + vertexWithRect(ctx: any): void { + const nodeId = this.visit(ctx.nodeId); + const nodeTextData = this.visit(ctx.nodeText); + this.addVertex(nodeId, nodeTextData.text, 'rect', nodeTextData.labelType); + } + + // Vertices with both labels and node data + vertexWithSquareAndNodeData(ctx: any): void { + const nodeId = this.visit(ctx.nodeId); + const nodeTextData = this.visit(ctx.nodeText); + const nodeDataProps = this.visit(ctx.nodeData); + + // Combine label from square brackets with node data properties + const finalLabel = nodeDataProps.label || nodeTextData.text; + const shape = nodeDataProps.shape || 'squareRect'; + const mappedShape = this.mapShapeNameToType(shape); + + this.addVertexWithData(nodeId, finalLabel, mappedShape, nodeDataProps); + } + + vertexWithDoubleCircleAndNodeData(ctx: any): void { + const nodeId = this.visit(ctx.nodeId); + const nodeTextData = ctx.nodeText ? this.visit(ctx.nodeText) : { text: '', labelType: 'text' }; + const nodeDataProps = this.visit(ctx.nodeData); + + const finalLabel = nodeDataProps.label || nodeTextData.text; + const shape = nodeDataProps.shape || 'doublecircle'; + const mappedShape = this.mapShapeNameToType(shape); + + this.addVertexWithData(nodeId, finalLabel, mappedShape, nodeDataProps); + } + + vertexWithCircleAndNodeData(ctx: any): void { + const nodeId = this.visit(ctx.nodeId); + const nodeTextData = ctx.nodeText ? this.visit(ctx.nodeText) : { text: '', labelType: 'text' }; + const nodeDataProps = this.visit(ctx.nodeData); + + const finalLabel = nodeDataProps.label || nodeTextData.text; + const shape = nodeDataProps.shape || 'circle'; + const mappedShape = this.mapShapeNameToType(shape); + + this.addVertexWithData(nodeId, finalLabel, mappedShape, nodeDataProps); + } + + vertexWithRoundAndNodeData(ctx: any): void { + const nodeId = this.visit(ctx.nodeId); + const nodeTextData = this.visit(ctx.nodeText); + const nodeDataProps = this.visit(ctx.nodeData); + + const finalLabel = nodeDataProps.label || nodeTextData.text; + const shape = nodeDataProps.shape || 'round'; + const mappedShape = this.mapShapeNameToType(shape); + + this.addVertexWithData(nodeId, finalLabel, mappedShape, nodeDataProps); + } + + vertexWithHexagonAndNodeData(ctx: any): void { + const nodeId = this.visit(ctx.nodeId); + const nodeTextData = this.visit(ctx.nodeText); + const nodeDataProps = this.visit(ctx.nodeData); + + const finalLabel = nodeDataProps.label || nodeTextData.text; + const shape = nodeDataProps.shape || 'hexagon'; + const mappedShape = this.mapShapeNameToType(shape); + + this.addVertexWithData(nodeId, finalLabel, mappedShape, nodeDataProps); + } + + vertexWithDiamondAndNodeData(ctx: any): void { + const nodeId = this.visit(ctx.nodeId); + const nodeTextData = this.visit(ctx.nodeText); + const nodeDataProps = this.visit(ctx.nodeData); + + const finalLabel = nodeDataProps.label || nodeTextData.text; + const shape = nodeDataProps.shape || 'diamond'; + const mappedShape = this.mapShapeNameToType(shape); + + this.addVertexWithData(nodeId, finalLabel, mappedShape, nodeDataProps); + } + + vertexWithSubroutineAndNodeData(ctx: any): void { + const nodeId = this.visit(ctx.nodeId); + const nodeTextData = this.visit(ctx.nodeText); + const nodeDataProps = this.visit(ctx.nodeData); + + const finalLabel = nodeDataProps.label || nodeTextData.text; + const shape = nodeDataProps.shape || 'subroutine'; + const mappedShape = this.mapShapeNameToType(shape); + + this.addVertexWithData(nodeId, finalLabel, mappedShape, nodeDataProps); + } + + vertexWithStadiumAndNodeData(ctx: any): void { + const nodeId = this.visit(ctx.nodeId); + const nodeTextData = this.visit(ctx.nodeText); + const nodeDataProps = this.visit(ctx.nodeData); + + const finalLabel = nodeDataProps.label || nodeTextData.text; + const shape = nodeDataProps.shape || 'stadium'; + const mappedShape = this.mapShapeNameToType(shape); + + this.addVertexWithData(nodeId, finalLabel, mappedShape, nodeDataProps); + } + + vertexWithEllipseAndNodeData(ctx: any): void { + const nodeId = this.visit(ctx.nodeId); + const nodeTextData = this.visit(ctx.nodeText); + const nodeDataProps = this.visit(ctx.nodeData); + + const finalLabel = nodeDataProps.label || nodeTextData.text; + const shape = nodeDataProps.shape || 'ellipse'; + const mappedShape = this.mapShapeNameToType(shape); + + this.addVertexWithData(nodeId, finalLabel, mappedShape, nodeDataProps); + } + + vertexWithCylinderAndNodeData(ctx: any): void { + const nodeId = this.visit(ctx.nodeId); + const nodeTextData = this.visit(ctx.nodeText); + const nodeDataProps = this.visit(ctx.nodeData); + + const finalLabel = nodeDataProps.label || nodeTextData.text; + const shape = nodeDataProps.shape || 'cylinder'; + const mappedShape = this.mapShapeNameToType(shape); + + this.addVertexWithData(nodeId, finalLabel, mappedShape, nodeDataProps); + } + + vertexWithOddAndNodeData(ctx: any): void { + const nodeId = this.visit(ctx.nodeId); + const nodeTextData = this.visit(ctx.nodeText); + const nodeDataProps = this.visit(ctx.nodeData); + + const finalLabel = nodeDataProps.label || nodeTextData.text; + const shape = nodeDataProps.shape || 'odd'; + const mappedShape = this.mapShapeNameToType(shape); + + this.addVertexWithData(nodeId, finalLabel, mappedShape, nodeDataProps); + } + + vertexWithRectAndNodeData(ctx: any): void { + const nodeId = this.visit(ctx.nodeId); + const nodeTextData = this.visit(ctx.nodeText); + const nodeDataProps = this.visit(ctx.nodeData); + + const finalLabel = nodeDataProps.label || nodeTextData.text; + const shape = nodeDataProps.shape || 'rect'; + const mappedShape = this.mapShapeNameToType(shape); + + this.addVertexWithData(nodeId, finalLabel, mappedShape, nodeDataProps); } // Vertex with node data syntax vertexWithNodeData(ctx: any): void { - console.debug('๐Ÿ”ฅ vertexWithNodeData called with ctx:', ctx); const nodeId = this.visit(ctx.nodeId); const nodeDataProps = this.visit(ctx.nodeData); - console.debug('๐Ÿ”ฅ nodeId:', nodeId, 'nodeDataProps:', nodeDataProps); + // Check if this looks like edge metadata (has ONLY animation properties and no node properties) + const hasAnimationProps = + nodeDataProps.animate !== undefined || nodeDataProps.animation !== undefined; + const hasNodeProps = + nodeDataProps.shape !== undefined || + nodeDataProps.label !== undefined || + nodeDataProps.icon !== undefined || + nodeDataProps.form !== undefined || + nodeDataProps.pos !== undefined || + nodeDataProps.img !== undefined || + nodeDataProps.constraint !== undefined || + nodeDataProps.w !== undefined || + nodeDataProps.h !== undefined; - // Extract shape and label from node data - const shape = nodeDataProps.shape || 'squareRect'; // Default shape - const label = nodeDataProps.label || nodeId; // Use nodeId as default label + const isEdgeMetadata = hasAnimationProps && !hasNodeProps; - // Map shape name to the correct type for FlowDB - const mappedShape = this.mapShapeNameToType(shape); + if (isEdgeMetadata) { + // Store edge metadata to apply later after edges are created + this.edgeMetadata[nodeId] = nodeDataProps; + } else { + // This is regular node data, process as vertex + if (this.flowDb && typeof this.flowDb.addVertex === 'function') { + // Extract label and shape from node data + const label = nodeDataProps.label || nodeId; + const shape = nodeDataProps.shape || 'squareRect'; - console.debug('๐Ÿ”ฅ Adding vertex with shape:', mappedShape, 'label:', label); + // Validate shape if provided in nodeData + if (nodeDataProps.shape && !this.isValidShape(nodeDataProps.shape)) { + throw new Error(`No such shape: ${nodeDataProps.shape}.`); + } - // Add vertex with node data properties - this.addVertexWithData(nodeId, label, mappedShape, nodeDataProps); + const mappedShape = this.mapShapeNameToType(shape); + + // Create textObj structure expected by FlowDB using the label + const textObj = { text: label, type: 'text' }; + + // Add as regular vertex + this.flowDb.addVertex( + nodeId, + textObj, + mappedShape, + [], // style + [], // classes + undefined, // dir + nodeDataProps, // props + undefined // metadata + ); + } else { + // Fallback: treat as regular vertex + const shape = nodeDataProps.shape || 'squareRect'; + const label = nodeDataProps.label || nodeId; + const mappedShape = this.mapShapeNameToType(shape); + this.addVertexWithData(nodeId, label, mappedShape, nodeDataProps); + } + } + + this.lastNodeId = nodeId; } // Node data visitor nodeData(ctx: any): any { - console.debug('๐Ÿ”ฅ nodeData called with ctx:', ctx); const result = this.visit(ctx.nodeDataContent); - console.debug('๐Ÿ”ฅ nodeData result:', result); return result; } @@ -411,19 +673,42 @@ export class FlowchartAstVisitor extends BaseVisitor { nodeDataContent(ctx: any): any { const props: any = {}; - if (ctx.ShapeDataContent) { - // Parse the shape data content - const content = ctx.ShapeDataContent.map((token: any) => token.image).join(''); - this.parseNodeDataContent(content, props); + // Reconstruct the content by processing all tokens and subrules in the correct order + let fullContent = ''; + + // Get content tokens + const contentTokens = ctx.ShapeDataContent || []; + + // Get nodeDataString subrule contexts + const nodeDataStringContexts = ctx.nodeDataString || []; + + // Process content tokens and string contexts in order + // Since the parser processes them in sequence, we need to reconstruct the original order + let stringIndex = 0; + + for (const contentToken of contentTokens) { + fullContent += contentToken.image; + + // Check if this content token suggests a string should follow + // Look for patterns like 'key: ' (ends with colon and space/whitespace) + if (contentToken.image.trim().endsWith(':') && stringIndex < nodeDataStringContexts.length) { + // Visit the nodeDataString context to get the actual string value + const stringValue = this.visit(nodeDataStringContexts[stringIndex]); + fullContent += `"${stringValue}"`; + stringIndex++; + } } - if (ctx.nodeDataString) { - // Handle quoted strings in node data - ctx.nodeDataString.forEach((stringCtx: any) => { - const stringValue = this.visit(stringCtx); - // This would need more sophisticated parsing to handle key-value pairs with quoted strings - // For now, we'll handle this in parseNodeDataContent - }); + // If we still have unprocessed strings, add them + while (stringIndex < nodeDataStringContexts.length) { + const stringValue = this.visit(nodeDataStringContexts[stringIndex]); + fullContent += `"${stringValue}"`; + stringIndex++; + } + + // Parse the reconstructed content + if (fullContent.trim()) { + this.parseNodeDataContent(fullContent, props); } return props; @@ -439,17 +724,103 @@ export class FlowchartAstVisitor extends BaseVisitor { // Helper method to parse node data content private parseNodeDataContent(content: string, props: any): void { - // Parse YAML-like content: "shape: rounded, label: 'Hello'" - // Split by commas and parse key-value pairs - const pairs = content.split(',').map((pair) => pair.trim()); + // Parse YAML-like content: "shape: rounded\nlabel: 'Hello'" or "shape: rounded, label: 'Hello'" + // Handle both single-line and multi-line formats + const lines = content.split('\n'); // Don't trim yet - we need to preserve indentation for YAML-style parsing - for (const pair of pairs) { - const colonIndex = pair.indexOf(':'); + let i = 0; + while (i < lines.length) { + const line = lines[i]; + const trimmedLine = line.trim(); + + // Skip empty lines + if (!trimmedLine) { + i++; + continue; + } + + const colonIndex = trimmedLine.indexOf(':'); if (colonIndex > 0) { - const key = pair.substring(0, colonIndex).trim(); - let value = pair.substring(colonIndex + 1).trim(); + const key = trimmedLine.substring(0, colonIndex).trim(); + let value = trimmedLine.substring(colonIndex + 1).trim(); - // Remove quotes if present + // Skip empty values + if (!value) { + i++; + continue; + } + + // Handle YAML-style multiline string syntax (|) + if (value === '|') { + // Collect subsequent indented lines as multiline content + const multilineContent: string[] = []; + i++; // Move to next line + + // Determine the base indentation level from the first content line + let baseIndent = -1; + + while (i < lines.length) { + const nextLine = lines[i]; + const nextTrimmed = nextLine.trim(); + + // If line is empty, include it but don't use it to determine indentation + if (!nextTrimmed) { + i++; + continue; + } + + // Calculate indentation level + const indent = nextLine.length - nextLine.trimStart().length; + + // If this is the first content line, set base indentation + if (baseIndent === -1) { + baseIndent = indent; + } + + // If line has same or greater indentation than base, it's part of multiline content + if (indent >= baseIndent) { + multilineContent.push(nextTrimmed); + i++; + } else { + // Next property found (less indented), break out of multiline collection + break; + } + } + + // Join multiline content with newlines and add final newline + props[key] = multilineContent.join('\n') + '\n'; + continue; // Don't increment i again since we already did it in the loop + } + + // Handle quoted multi-line strings (strings that span multiple lines within quotes) + if ( + (value.startsWith('"') && !value.endsWith('"')) || + (value.startsWith("'") && !value.endsWith("'")) + ) { + // This is a multi-line quoted string, collect until closing quote + const quote = value[0]; + let multilineValue = value.substring(1); // Remove opening quote + i++; // Move to next line + + while (i < lines.length) { + const nextLine = lines[i].trim(); + if (nextLine.endsWith(quote)) { + // Found closing quote + multilineValue += '
' + nextLine.substring(0, nextLine.length - 1); + break; + } else { + // Continue collecting lines + multilineValue += '
' + nextLine; + } + i++; + } + + props[key] = multilineValue; + i++; + continue; + } + + // Remove quotes if present (for single-line quoted strings) if ( (value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")) @@ -457,8 +828,20 @@ export class FlowchartAstVisitor extends BaseVisitor { value = value.slice(1, -1); } - props[key] = value; + // Convert boolean and numeric values + let parsedValue: any = value; + if (value === 'true') { + parsedValue = true; + } else if (value === 'false') { + parsedValue = false; + } else if (!isNaN(Number(value)) && value !== '') { + parsedValue = Number(value); + } + + props[key] = parsedValue; } + + i++; } } @@ -613,6 +996,11 @@ export class FlowchartAstVisitor extends BaseVisitor { text: linkData.linkText, }; + // Include labelType if present + if (linkData.linkLabelType) { + edge.labelType = linkData.linkLabelType; + } + // Include stroke property if present if (linkData.linkStroke) { edge.stroke = linkData.linkStroke; @@ -623,7 +1011,28 @@ export class FlowchartAstVisitor extends BaseVisitor { edge.length = linkData.linkLength; } - this.edges.push(edge); + // Add to FlowDB if available + if (this.flowDb && typeof this.flowDb.addSingleLink === 'function') { + // Create linkTextObj structure expected by FlowDB + const linkTextObj = linkData.linkText + ? { + text: linkData.linkText, + type: linkData.linkLabelType || 'text', + } + : undefined; + + const linkTypeObj = { + type: linkData.linkType, + stroke: linkData.linkStroke, + length: linkData.linkLength, + text: linkTextObj, + }; + + this.flowDb.addSingleLink(startNode, linkData.node, linkTypeObj, linkData.id); + } else { + // Fallback to internal tracking + this.edges.push(edge); + } }); }); } @@ -632,7 +1041,7 @@ export class FlowchartAstVisitor extends BaseVisitor { nodeDefinition(ctx: any): any { const nodeId = this.visit(ctx.nodeId); let text = nodeId; - let type = 'default'; + let type = 'squareRect'; if (ctx.nodeShape) { const shapeData = this.visit(ctx.nodeShape); @@ -688,23 +1097,44 @@ export class FlowchartAstVisitor extends BaseVisitor { nodeId = ctx.NODE_STRING[0].image; } else if (ctx.NumberToken) { nodeId = ctx.NumberToken[0].image; - } else if (ctx.Default) { - nodeId = ctx.Default[0].image; + } else if (ctx.DEFAULT) { + nodeId = ctx.DEFAULT[0].image; } else if (ctx.Ampersand) { // Standalone & (uses CONSUME2) nodeId = ctx.Ampersand[0].image; } else if (ctx.Minus) { // Standalone - (uses CONSUME2) nodeId = ctx.Minus[0].image; - } else if (ctx.DirectionValue) { - // Standalone direction value (uses CONSUME2) - nodeId = ctx.DirectionValue[0].image; + } else if (ctx.DIR) { + // Standalone direction value (fixed to use DIR instead of DirectionValue) + nodeId = ctx.DIR[0].image; } else if (ctx.Colon) { // Standalone : character nodeId = ctx.Colon[0].image; } else if (ctx.Comma) { // Standalone , character nodeId = ctx.Comma[0].image; + } else if (ctx.Hash) { + // Standalone # character + nodeId = ctx.Hash[0].image; + } else if (ctx.Dot) { + // Standalone . character + nodeId = ctx.Dot[0].image; + } else if (ctx.Slash) { + // Standalone / character + nodeId = ctx.Slash[0].image; + } else if (ctx.Underscore) { + // Standalone _ character + nodeId = ctx.Underscore[0].image; + } else if (ctx.Plus) { + // Standalone + character + nodeId = ctx.Plus[0].image; + } else if (ctx.Equals) { + // Standalone = character + nodeId = ctx.Equals[0].image; + } else if (ctx.AMP) { + // Standalone & character + nodeId = ctx.AMP[0].image; } // If no nodeId was found, it might be an empty context @@ -729,12 +1159,12 @@ export class FlowchartAstVisitor extends BaseVisitor { 'interpolate', 'classDef', 'class', + 'end', + 'subgraph', '_self', '_blank', '_parent', '_top', - 'end', - 'subgraph', ]; // Check if node ID starts with any error keyword followed by a delimiter @@ -777,72 +1207,95 @@ export class FlowchartAstVisitor extends BaseVisitor { } squareShape(ctx: any): any { - const text = this.visit(ctx.nodeText); - return { type: 'square', text }; + const nodeTextData = this.visit(ctx.nodeText); + return { type: 'square', text: nodeTextData.text }; } circleShape(ctx: any): any { - const text = this.visit(ctx.nodeText); - return { type: 'doublecircle', text }; + const nodeTextData = this.visit(ctx.nodeText); + return { type: 'doublecircle', text: nodeTextData.text }; } diamondShape(ctx: any): any { - const text = this.visit(ctx.nodeText); - return { type: 'diamond', text }; + const nodeTextData = this.visit(ctx.nodeText); + return { type: 'diamond', text: nodeTextData.text }; } subroutineShape(ctx: any): any { - const text = this.visit(ctx.nodeText); - return { type: 'subroutine', text }; + const nodeTextData = this.visit(ctx.nodeText); + return { type: 'subroutine', text: nodeTextData.text }; } trapezoidShape(ctx: any): any { - const text = this.visit(ctx.nodeText); - return { type: 'trapezoid', text }; + const nodeTextData = this.visit(ctx.nodeText); + return { type: 'trapezoid', text: nodeTextData.text }; } invTrapezoidShape(ctx: any): any { - const text = this.visit(ctx.nodeText); - return { type: 'inv_trapezoid', text }; + const nodeTextData = this.visit(ctx.nodeText); + return { type: 'inv_trapezoid', text: nodeTextData.text }; } leanRightShape(ctx: any): any { - const text = this.visit(ctx.nodeText); - return { type: 'lean_right', text }; + const nodeTextData = this.visit(ctx.nodeText); + return { type: 'lean_right', text: nodeTextData.text }; } leanLeftShape(ctx: any): any { - const text = this.visit(ctx.nodeText); - return { type: 'lean_left', text }; + const nodeTextData = this.visit(ctx.nodeText); + return { type: 'lean_left', text: nodeTextData.text }; } rectShape(ctx: any): any { - const text = this.visit(ctx.nodeText); - return { type: 'rect', text }; + const nodeTextData = this.visit(ctx.nodeText); + return { type: 'rect', text: nodeTextData.text }; } oddShape(ctx: any): any { - const text = this.visit(ctx.nodeText); - return { type: 'odd', text }; + const nodeTextData = this.visit(ctx.nodeText); + return { type: 'odd', text: nodeTextData.text }; } - nodeText(ctx: any): string { + nodeText(ctx: any): { text: string; labelType: string } { + let text = ''; + let labelType = 'text'; + if (ctx.TextContent) { - return ctx.TextContent[0].image; + text = ctx.TextContent[0].image; } else if (ctx.RectTextContent) { - return ctx.RectTextContent[0].image; + // RectTextContent format: "borders:lt|actual text" - extract text after last | + const rectContent = ctx.RectTextContent[0].image; + const lastPipeIndex = rectContent.lastIndexOf('|'); + text = lastPipeIndex !== -1 ? rectContent.substring(lastPipeIndex + 1) : rectContent; + } else if (ctx.textToken) { + text = ctx.textToken[0].image; } else if (ctx.NODE_STRING) { - return ctx.NODE_STRING[0].image; + text = ctx.NODE_STRING[0].image; } else if (ctx.StringContent) { - return ctx.StringContent[0].image; + text = ctx.StringContent[0].image; } else if (ctx.QuotedString) { // Remove quotes from quoted string const quoted = ctx.QuotedString[0].image; - return quoted.slice(1, -1); + text = quoted.slice(1, -1); + + // Check if this is a markdown string (starts and ends with backticks) + if (text.startsWith('`') && text.endsWith('`')) { + // Remove the backticks for markdown strings + text = text.slice(1, -1); + labelType = 'markdown'; + } else { + labelType = 'string'; + } } else if (ctx.NumberToken) { - return ctx.NumberToken[0].image; + text = ctx.NumberToken[0].image; } - return ''; + + // Apply HTML escaping for special characters (matching JISON parser behavior) + if (text === '<') { + text = '<'; + } + + return { text, labelType }; } linkChain(ctx: any): any[] { @@ -858,6 +1311,11 @@ export class FlowchartAstVisitor extends BaseVisitor { node: nodeData.id, }; + // Include labelType if present + if (linkData.labelType) { + linkInfo.linkLabelType = linkData.labelType; + } + // Include length property if present if (linkData.length) { linkInfo.linkLength = linkData.length; @@ -884,14 +1342,32 @@ export class FlowchartAstVisitor extends BaseVisitor { } linkStatement(ctx: any): any { - if (ctx.LINK) { - return this.parseLinkToken(ctx.LINK[0]); - } else if (ctx.THICK_LINK) { - return this.parseLinkToken(ctx.THICK_LINK[0]); - } else if (ctx.DOTTED_LINK) { - return this.parseLinkToken(ctx.DOTTED_LINK[0]); + let linkData = { type: 'arrow', text: '' }; + let edgeId: string | undefined = undefined; + + // Check for LINK_ID token first + if (ctx.LINK_ID) { + const linkIdToken = ctx.LINK_ID[0]; + const linkIdImage = linkIdToken.image.trim(); + // Extract edge ID by removing the trailing '@' + edgeId = linkIdImage.replace(/@$/, ''); } - return { type: 'arrow', text: '' }; + + // Parse the actual link token + if (ctx.LINK) { + linkData = this.parseLinkToken(ctx.LINK[0]); + } else if (ctx.THICK_LINK) { + linkData = this.parseLinkToken(ctx.THICK_LINK[0]); + } else if (ctx.DOTTED_LINK) { + linkData = this.parseLinkToken(ctx.DOTTED_LINK[0]); + } + + // Add edge ID if present + if (edgeId) { + linkData.id = edgeId; + } + + return linkData; } parseLinkToken(token: IToken): any { @@ -968,21 +1444,70 @@ export class FlowchartAstVisitor extends BaseVisitor { length = line.length - 1; } - const result: any = { type, stroke, text: '' }; + // Combine stroke and type to create the final edge type (like JISON parser) + let finalType = type; + if (stroke === 'dotted') { + // For dotted arrows, replace the base type with dotted version + if (type === 'arrow_point') { + finalType = 'arrow_dotted'; + } else if (type === 'arrow_open') { + finalType = 'arrow_dotted'; + } else if (type === 'arrow_cross') { + finalType = 'arrow_dotted'; + } else if (type === 'arrow_circle') { + finalType = 'arrow_dotted'; + } else if (type === 'double_arrow_point') { + finalType = 'double_arrow_dotted'; + } + } else if (stroke === 'thick') { + // For thick arrows, replace the base type with thick version + if (type === 'arrow_point') { + finalType = 'arrow_thick'; + } else if (type === 'arrow_open') { + finalType = 'arrow_thick'; + } else if (type === 'arrow_cross') { + finalType = 'arrow_thick'; + } else if (type === 'arrow_circle') { + finalType = 'arrow_thick'; + } else if (type === 'double_arrow_point') { + finalType = 'double_arrow_thick'; + } + } + + const result: any = { type: finalType, stroke, text: '' }; if (length !== undefined) { - result.length = length; + // Map numeric length to string representation (like JISON parser) + if (length === 3) { + result.length = 'long'; + } else if (length === 5) { + result.length = 'extralong'; + } else { + result.length = length; // Keep numeric for other values + } } return result; } linkWithEdgeText(ctx: any): any { let text = ''; + let labelType = 'text'; if (ctx.edgeText) { - text = this.visit(ctx.edgeText); + const edgeTextData = this.visit(ctx.edgeText); + text = edgeTextData.text; + labelType = edgeTextData.labelType; } // Get the link type from the EdgeTextEnd token and combine with start token info let linkData: any = { type: 'arrow_point', stroke: 'normal', text: '' }; + let edgeId: string | undefined = undefined; + + // Check for LINK_ID token first + if (ctx.LINK_ID) { + const linkIdToken = ctx.LINK_ID[0]; + const linkIdImage = linkIdToken.image.trim(); + // Extract edge ID by removing the trailing '@' + edgeId = linkIdImage.replace(/@$/, ''); + } // First, determine the stroke type from the start token let stroke = 'normal'; @@ -997,12 +1522,43 @@ export class FlowchartAstVisitor extends BaseVisitor { linkData = this.parseLinkToken(ctx.EdgeTextEnd[0]); // Override the stroke with the start token information linkData.stroke = stroke; + + // Check if this is a bidirectional arrow by examining the start token + // Only convert to bidirectional if the start token actually contains '<' (indicating a left arrow) + let isBidirectional = false; + if (ctx.START_LINK && ctx.START_LINK[0].image.includes('<')) { + isBidirectional = true; + } else if (ctx.START_DOTTED_LINK && ctx.START_DOTTED_LINK[0].image.includes('<')) { + isBidirectional = true; + } else if (ctx.START_THICK_LINK && ctx.START_THICK_LINK[0].image.includes('<')) { + isBidirectional = true; + } + + if (isBidirectional) { + // This is a bidirectional arrow - convert to double_arrow type + if (linkData.type === 'arrow_point') { + linkData.type = 'double_arrow_point'; + } else if (linkData.type === 'arrow_open') { + linkData.type = 'double_arrow_open'; + } else if (linkData.type === 'arrow_cross') { + linkData.type = 'double_arrow_cross'; + } else if (linkData.type === 'arrow_circle') { + linkData.type = 'double_arrow_circle'; + } + } } else { // Fallback if no EdgeTextEnd token linkData = { type: 'arrow_point', stroke: stroke, text: '' }; } linkData.text = text; + linkData.labelType = labelType; + + // Add edge ID if present + if (edgeId) { + linkData.id = edgeId; + } + return linkData; } @@ -1025,8 +1581,10 @@ export class FlowchartAstVisitor extends BaseVisitor { return linkData; } - edgeText(ctx: any): string { + edgeText(ctx: any): { text: string; labelType: string } { let text = ''; + let labelType = 'text'; + if (ctx.EdgeTextContent) { ctx.EdgeTextContent.forEach((token: IToken) => { text += token.image; @@ -1041,17 +1599,33 @@ export class FlowchartAstVisitor extends BaseVisitor { if (ctx.QuotedString) { ctx.QuotedString.forEach((token: IToken) => { // Remove quotes from quoted string - text += token.image.slice(1, -1); + let quotedText = token.image.slice(1, -1); + + // Check if this is a markdown string (starts and ends with backticks) + if (quotedText.startsWith('`') && quotedText.endsWith('`')) { + // Remove the backticks for markdown strings + quotedText = quotedText.slice(1, -1); + labelType = 'markdown'; + } else { + labelType = 'string'; + } + + text += quotedText; }); } if (ctx.EDGE_TEXT) { - return ctx.EDGE_TEXT[0].image; + text = ctx.EDGE_TEXT[0].image; + return { text, labelType }; } else if (ctx.String) { - return ctx.String[0].image.slice(1, -1); // Remove quotes + text = ctx.String[0].image.slice(1, -1); // Remove quotes + labelType = 'string'; + return { text, labelType }; } else if (ctx.MarkdownString) { - return ctx.MarkdownString[0].image.slice(2, -2); // Remove markdown quotes + text = ctx.MarkdownString[0].image.slice(2, -2); // Remove markdown quotes + labelType = 'markdown'; + return { text, labelType }; } - return text; + return { text, labelType }; } linkText(ctx: any): string { @@ -1088,6 +1662,11 @@ export class FlowchartAstVisitor extends BaseVisitor { text += token.image; }); } + if (ctx.textToken) { + ctx.textToken.forEach((token: IToken) => { + text += token.image; + }); + } if (ctx.NODE_STRING) { ctx.NODE_STRING.forEach((token: IToken) => { text += token.image; @@ -1190,6 +1769,25 @@ export class FlowchartAstVisitor extends BaseVisitor { if (ctx.clickHref) { const hrefData = this.visit(ctx.clickHref); + // Call FlowDB setLink method directly (like JISON parser does) + if (this.flowDb && typeof this.flowDb.setLink === 'function') { + if (hrefData.target !== undefined) { + this.flowDb.setLink(nodeId, hrefData.href, hrefData.target); + } else { + this.flowDb.setLink(nodeId, hrefData.href); + } + } + + // Call FlowDB setTooltip method directly if tooltip exists (like JISON parser does) + if (hrefData.tooltip !== undefined) { + if (this.flowDb && typeof this.flowDb.setTooltip === 'function') { + this.flowDb.setTooltip(nodeId, hrefData.tooltip); + } + // Also store in tooltips for compatibility + this.tooltips[nodeId] = hrefData.tooltip; + } + + // Also store in clickEvents for compatibility this.clickEvents.push({ id: nodeId, type: 'href', @@ -1198,24 +1796,87 @@ export class FlowchartAstVisitor extends BaseVisitor { }); } else if (ctx.clickCall) { const callData = this.visit(ctx.clickCall); + // Call FlowDB setClickEvent method directly (like JISON parser does) + if (this.flowDb && typeof this.flowDb.setClickEvent === 'function') { + if (callData.args !== undefined) { + this.flowDb.setClickEvent(nodeId, callData.functionName, callData.args); + } else { + this.flowDb.setClickEvent(nodeId, callData.functionName); + } + } + + // Handle tooltip for clickCall (callback) syntax + let tooltip = undefined; + if (ctx.QuotedString && ctx.QuotedString.length > 0) { + // For clickCall, any QuotedString in clickStatement context is a tooltip + tooltip = ctx.QuotedString[0].image.slice(1, -1); // Remove quotes + } + + // Call FlowDB setTooltip method directly if tooltip exists (like JISON parser does) + if (tooltip !== undefined) { + if (this.flowDb && typeof this.flowDb.setTooltip === 'function') { + this.flowDb.setTooltip(nodeId, tooltip); + } + // Also store in tooltips for compatibility + this.tooltips[nodeId] = tooltip; + } + + // Also store in clickEvents for compatibility this.clickEvents.push({ id: nodeId, type: 'call', functionName: callData.functionName, - args: callData.args, + functionArgs: callData.args, // Use functionArgs to match adapter expectations }); - } + } else if (ctx.QuotedString && ctx.QuotedString.length > 0) { + // Handle direct link syntax: click A "url" ["tooltip"] [target] + const href = ctx.QuotedString[0].image.slice(1, -1); // Remove quotes - // Handle tooltip - if (ctx.String) { - const tooltip = ctx.String[0].image.slice(1, -1); - this.tooltips[nodeId] = tooltip; + // Extract tooltip if present (second QuotedString) + let tooltip = undefined; + if (ctx.QuotedString.length > 1) { + tooltip = ctx.QuotedString[1].image.slice(1, -1); // Remove quotes + } + + // Check for target parameter (NODE_STRING) + // Note: nodeId was already consumed by the nodeId rule, so ctx.NODE_STRING[0] is the target + let target = undefined; + if (ctx.NODE_STRING && ctx.NODE_STRING.length > 0) { + target = ctx.NODE_STRING[0].image; + } + + // Call FlowDB setLink method directly (like JISON parser does) + if (this.flowDb && typeof this.flowDb.setLink === 'function') { + if (target !== undefined) { + this.flowDb.setLink(nodeId, href, target); + } else { + this.flowDb.setLink(nodeId, href); + } + } + + // Call FlowDB setTooltip method directly if tooltip exists (like JISON parser does) + if (tooltip !== undefined) { + if (this.flowDb && typeof this.flowDb.setTooltip === 'function') { + this.flowDb.setTooltip(nodeId, tooltip); + } + // Also store in tooltips for compatibility + this.tooltips[nodeId] = tooltip; + } + + // Also store in clickEvents for compatibility + this.clickEvents.push({ + id: nodeId, + type: 'href', + href: href, + target: target, + }); } } subgraphStatement(ctx: any): void { let subgraphId: string | undefined = undefined; let title: string | undefined = undefined; + let titleLabelType: string = 'text'; // Extract subgraph ID and title if (ctx.subgraphId) { @@ -1226,6 +1887,13 @@ export class FlowchartAstVisitor extends BaseVisitor { const quotedString = subgraphIdNode.children.QuotedString[0].image; title = quotedString.slice(1, -1); // Remove quotes subgraphId = undefined; // Will be auto-generated + titleLabelType = 'text'; + } else if (subgraphIdNode.children && subgraphIdNode.children.MarkdownStringStart) { + // This is a markdown title - use it as title and auto-generate ID + const markdownContent = subgraphIdNode.children.MarkdownStringContent[0].image; + title = markdownContent; // Already without backticks and quotes + subgraphId = undefined; // Will be auto-generated + titleLabelType = 'markdown'; } else { // Get the parsed subgraph ID/title const parsedValue = this.visit(ctx.subgraphId); @@ -1244,7 +1912,8 @@ export class FlowchartAstVisitor extends BaseVisitor { // Extract title from brackets or additional quoted string if (ctx.nodeText) { - title = this.visit(ctx.nodeText); + const nodeTextData = this.visit(ctx.nodeText); + title = nodeTextData.text; } else if (ctx.QuotedString) { title = ctx.QuotedString[0].image.slice(1, -1); // Remove quotes } @@ -1274,11 +1943,10 @@ export class FlowchartAstVisitor extends BaseVisitor { if (this.flowDb && typeof this.flowDb.addSubGraph === 'function') { // Format parameters as expected by FlowDB const idObj = subgraphId ? { text: subgraphId } : { text: '' }; - const titleObj = { text: title || subgraphId || '', type: 'text' }; + const titleObj = { text: title || subgraphId || '', type: titleLabelType }; - // Reverse the node order to match JISON parser behavior - const reversedNodes = [...subgraphNodes].reverse(); - const sgId = this.flowDb.addSubGraph(idObj, reversedNodes, titleObj); + // Use the natural node order (same as JISON parser behavior) + const sgId = this.flowDb.addSubGraph(idObj, subgraphNodes, titleObj); // Set direction if it was specified within the subgraph if (currentSubgraph.dir) { @@ -1294,13 +1962,13 @@ export class FlowchartAstVisitor extends BaseVisitor { } } else { // Fallback to internal tracking - // Reverse the node order to match JISON parser behavior - const reversedNodes = [...subgraphNodes].reverse(); + // Use the natural node order (same as JISON parser behavior) this.subGraphs.push({ id: subgraphId || `subGraph${this.subCount++}`, title: title || subgraphId || '', - nodes: reversedNodes, + nodes: subgraphNodes, dir: currentSubgraph.dir, + labelType: titleLabelType, }); } @@ -1310,7 +1978,8 @@ export class FlowchartAstVisitor extends BaseVisitor { } directionStatement(ctx: any): void { - const direction = ctx.DirectionValue[0].image; + // The DirectionValue token has name 'DIR' in the lexer + const direction = ctx.DIR[0].image; this.direction = direction; // If we're currently processing a subgraph, set its direction @@ -1412,8 +2081,14 @@ export class FlowchartAstVisitor extends BaseVisitor { end: endNodeId, type: linkData.type, text: linkData.text, + labelType: linkData.labelType || 'text', }; + // Include edge ID if present + if (linkData.id) { + edge.id = linkData.id; + } + // Include stroke property if present if (linkData.stroke) { edge.stroke = linkData.stroke; @@ -1424,7 +2099,20 @@ export class FlowchartAstVisitor extends BaseVisitor { edge.length = linkData.length; } - this.edges.push(edge); + // Add edge to FlowDB if available, otherwise add to internal edges + if (this.flowDb && typeof this.flowDb.addLink === 'function') { + // Create the linkData structure expected by FlowDB + const linkDataForFlowDb = { + id: edge.id, // Include edge ID for user-defined edge IDs + type: edge.type, + stroke: edge.stroke, + length: edge.length, + text: edge.text ? { text: edge.text, type: edge.labelType || 'text' } : undefined, + }; + this.flowDb.addLink([edge.start], [edge.end], linkDataForFlowDb); + } else { + this.edges.push(edge); + } } // Helper method to ensure a vertex exists @@ -1466,28 +2154,38 @@ export class FlowchartAstVisitor extends BaseVisitor { let styles: string[] = []; // Determine which pattern we're dealing with - if (ctx.Default) { + if (ctx.DEFAULT) { positions = ['default']; } else if (ctx.numberList) { positions = this.visit(ctx.numberList); } - // Check for interpolate - if (ctx.Interpolate && ctx.alphaNum) { - interpolate = this.visit(ctx.alphaNum); - } - - // Check for styles + // Check for styles (now at the top level) if (ctx.styleList) { styles = this.visit(ctx.styleList); } - // Store the linkStyle operation for later application - this.linkStyles.push({ - positions, - styles: styles.length > 0 ? styles : undefined, - interpolate, - }); + // Check for interpolate + if (ctx.INTERPOLATE && ctx.alphaNum) { + interpolate = this.visit(ctx.alphaNum); + } + + // Apply link styles immediately if using FlowDB + if (this.flowDb) { + if (interpolate && typeof this.flowDb.updateLinkInterpolate === 'function') { + this.flowDb.updateLinkInterpolate(positions, interpolate); + } + if (styles.length > 0 && typeof this.flowDb.updateLink === 'function') { + this.flowDb.updateLink(positions, styles); + } + } else { + // Store the linkStyle operation for later application (fallback) + this.linkStyles.push({ + positions, + styles: styles.length > 0 ? styles : undefined, + interpolate, + }); + } } linkIndexList(_ctx: any): number[] { @@ -1562,52 +2260,131 @@ export class FlowchartAstVisitor extends BaseVisitor { clickHref(ctx: any): any { let href = ''; - if (ctx.NODE_STRING) { - href = ctx.NODE_STRING[0].image; - } else if (ctx.QuotedString) { + let tooltip = undefined; + let target = undefined; + + // Parse href (first parameter) - prioritize QuotedString over NODE_STRING + if (ctx.QuotedString) { href = ctx.QuotedString[0].image.slice(1, -1); // Remove quotes + + // Check for tooltip parameter (second QuotedString) + if (ctx.QuotedString.length > 1) { + tooltip = ctx.QuotedString[1].image.slice(1, -1); // Remove quotes + } + + // Check for target parameter (NODE_STRING) + if (ctx.NODE_STRING && ctx.NODE_STRING.length > 0) { + target = ctx.NODE_STRING[0].image; + } + } else if (ctx.NODE_STRING) { + href = ctx.NODE_STRING[0].image; + // Check for target parameter (second NODE_STRING) + if (ctx.NODE_STRING.length > 1) { + target = ctx.NODE_STRING[1].image; + } } + return { href: href, - target: undefined, + tooltip: tooltip, + target: target, }; } clickCall(ctx: any): any { let functionName = ''; + let args = ''; if (ctx.Call) { - if (ctx.NODE_STRING) { + // Handle "call functionName" or "call functionName(args)" syntax + if (ctx.Callback) { + // Handle "call callback" syntax where callback is tokenized as Callback token + functionName = ctx.Callback[0].image; + } else if (ctx.NODE_STRING) { functionName = ctx.NODE_STRING[0].image; } else if (ctx.QuotedString) { functionName = ctx.QuotedString[0].image.slice(1, -1); // Remove quotes } + + // Parse function arguments if present + if (ctx.PS && ctx.PE) { + // Function has parentheses, check for arguments + // Handle complex arguments like "test0", test1, test2 + const argParts: string[] = []; + + // Collect all argument tokens in order + const allTokens: any[] = []; + + // Determine if we need to skip the first token based on how function name was extracted + let skipFirstQuotedString = false; + let skipFirstNodeString = false; + + if (ctx.Callback) { + // Function name came from Callback token, so all QuotedString and NODE_STRING tokens are arguments + skipFirstQuotedString = false; + skipFirstNodeString = false; + } else if (ctx.QuotedString && ctx.QuotedString.length > 0) { + // Function name came from first QuotedString, skip it + skipFirstQuotedString = true; + } else if (ctx.NODE_STRING && ctx.NODE_STRING.length > 0) { + // Function name came from first NODE_STRING, skip it + skipFirstNodeString = true; + } + + if (ctx.QuotedString) { + const startIndex = skipFirstQuotedString ? 1 : 0; + for (let i = startIndex; i < ctx.QuotedString.length; i++) { + allTokens.push({ token: ctx.QuotedString[i], type: 'QuotedString' }); + } + } + if (ctx.NODE_STRING) { + const startIndex = skipFirstNodeString ? 1 : 0; + for (let i = startIndex; i < ctx.NODE_STRING.length; i++) { + allTokens.push({ token: ctx.NODE_STRING[i], type: 'NODE_STRING' }); + } + } + if (ctx.textToken) { + allTokens.push(...ctx.textToken.map((token: any) => ({ token, type: 'textToken' }))); + } + + // Sort tokens by their position in the input + allTokens.sort((a, b) => a.token.startOffset - b.token.startOffset); + + // Build the argument string + allTokens.forEach(({ token, type }) => { + if (type === 'QuotedString') { + argParts.push(token.image); // Keep quotes for function arguments + } else { + argParts.push(token.image); + } + }); + + args = argParts.join(''); + } + return { functionName: functionName, - args: [], // TODO: Parse arguments if present + args: args || undefined, // Only include args if they exist }; } else if (ctx.Callback) { - if (ctx.NODE_STRING) { - functionName = ctx.NODE_STRING[0].image; - } else if (ctx.QuotedString) { - functionName = ctx.QuotedString[0].image.slice(1, -1); // Remove quotes - } else if (ctx.StringStart && ctx.StringContent && ctx.StringEnd) { - functionName = ctx.StringContent[0].image; // String content without quotes - } + // For simple callback syntax like "click A callback", use the Callback token value as function name + functionName = ctx.Callback[0].image; // Use the Callback token itself as the function name return { functionName: functionName, - args: [], + args: undefined, // Simple callback has no args }; } return { functionName: '', - args: [], + args: undefined, }; } subgraphId(ctx: any): string { if (ctx.QuotedString) { return ctx.QuotedString[0].image.slice(1, -1); // Remove quotes + } else if (ctx.MarkdownStringStart && ctx.MarkdownStringContent && ctx.MarkdownStringEnd) { + return ctx.MarkdownStringContent[0].image; // Markdown content without backticks and quotes } else if (ctx.StringStart && ctx.StringContent && ctx.StringEnd) { return ctx.StringContent[0].image; // String content without quotes } else { @@ -1616,7 +2393,7 @@ export class FlowchartAstVisitor extends BaseVisitor { const allTokens: any[] = []; // Collect all token types that can appear in subgraph titles - const tokenTypes = ['NODE_STRING', 'NumberToken', 'Style', 'Class', 'Click']; + const tokenTypes = ['NODE_STRING', 'NumberToken', 'STYLE', 'CLASS', 'CLICK']; tokenTypes.forEach((tokenType) => { if (ctx[tokenType] && Array.isArray(ctx[tokenType])) { ctx[tokenType].forEach((token: any) => { diff --git a/packages/mermaid/src/diagrams/flowchart/parser/flowLexer.ts b/packages/mermaid/src/diagrams/flowchart/parser/flowLexer.ts index a9d8e97d4..faec142e0 100644 --- a/packages/mermaid/src/diagrams/flowchart/parser/flowLexer.ts +++ b/packages/mermaid/src/diagrams/flowchart/parser/flowLexer.ts @@ -1,7 +1,7 @@ import { createToken, Lexer, TokenType, IToken, ILexingResult, ILexingError } from 'chevrotain'; -// Debug flag for lexer logging -const DEBUG_LEXER = true; // Set to true to enable debug logging +// Debug flag for lexer logging - disabled in production for performance +const DEBUG_LEXER = false; // Set to true to enable debug logging // Context-aware lexer state interface LexerContext { @@ -274,8 +274,9 @@ function updateContext(context: LexerContext, token: IToken): void { case 'NODE_STRING': case 'NumberToken': case 'Ampersand': + case 'AtSymbol': case 'Minus': - case 'DirectionValue': + case 'DIR': case 'Colon': case 'Comma': case 'Default': @@ -388,23 +389,23 @@ function tryTokenizeKeywords(input: string, position: number): TokenResult { // If it's a pure keyword at word boundary, check if it should be a keyword const keywordPatterns = [ - { pattern: /^graph\b/, type: 'Graph' }, - { pattern: /^subgraph\b/, type: 'Subgraph' }, - { pattern: /^end\b/, type: 'End' }, - { pattern: /^style\b/, type: 'Style' }, - { pattern: /^linkStyle\b/, type: 'LinkStyle' }, - { pattern: /^classDef\b/, type: 'ClassDef' }, - { pattern: /^class\b/, type: 'Class' }, - { pattern: /^click\b/, type: 'Click' }, - { pattern: /^href\b/, type: 'Href' }, + { pattern: /^graph\b/, type: 'GRAPH' }, + { pattern: /^subgraph\b/, type: 'subgraph' }, + { pattern: /^end\b/, type: 'end' }, + { pattern: /^style\b/, type: 'STYLE' }, + { pattern: /^linkStyle\b/, type: 'LINKSTYLE' }, + { pattern: /^classDef\b/, type: 'CLASSDEF' }, + { pattern: /^class\b/, type: 'CLASS' }, + { pattern: /^click\b/, type: 'CLICK' }, + { pattern: /^href\b/, type: 'HREF' }, { pattern: /^call\b/, type: 'Call' }, - { pattern: /^default\b/, type: 'Default' }, - { pattern: /^interpolate\b/, type: 'Interpolate' }, + { pattern: /^default\b/, type: 'DEFAULT' }, + { pattern: /^interpolate\b/, type: 'INTERPOLATE' }, { pattern: /^accTitle\s*:/, type: 'AccTitle' }, { pattern: /^accDescr\s*:/, type: 'AccDescr' }, { pattern: /^accDescr\s*{/, type: 'AccDescrMultiline' }, // Direction values - { pattern: /^(TB|TD|BT|RL|LR)\b/, type: 'DirectionValue' }, + { pattern: /^(TB|TD|BT|RL|LR)\b/, type: 'DIR' }, ]; for (const { pattern, type } of keywordPatterns) { @@ -1011,12 +1012,13 @@ function initializeTokenTypeMap() { ['CLASS', Class], ['CLICK', Click], ['HREF', Href], - ['CALLBACKNAME', Callback], - ['CALLBACKNAME', Call], + ['Callback', Callback], + ['Call', Call], ['DEFAULT', Default], ['INTERPOLATE', Interpolate], // Links + ['LINK_ID', LINK_ID], ['LINK', LINK], ['START_LINK', START_LINK], ['THICK_LINK', THICK_LINK], @@ -1059,6 +1061,7 @@ function initializeTokenTypeMap() { // Punctuation ['AMP', Ampersand], + ['AtSymbol', AtSymbol], ['Minus', Minus], ['Colon', Colon], ['Comma', Comma], @@ -1216,13 +1219,13 @@ const Href = createToken({ }); const Callback = createToken({ - name: 'CALLBACKNAME', + name: 'Callback', pattern: /callback/i, longer_alt: NODE_STRING, }); const Call = createToken({ - name: 'CALLBACKNAME', + name: 'Call', pattern: /call/i, longer_alt: NODE_STRING, }); @@ -1309,6 +1312,13 @@ const ShapeDataStart = createToken({ // LINK TOKENS (JISON lines 154-164) // ============================================================================ +// Link ID token (must come before NODE_STRING to avoid conflicts) +const LINK_ID = createToken({ + name: 'LINK_ID', + pattern: /[^\s"]+@(?=[^{"])/, + longer_alt: NODE_STRING, +}); + // Regular links without text const LINK = createToken({ name: 'LINK', @@ -1488,6 +1498,12 @@ const Ampersand = createToken({ longer_alt: NODE_STRING, }); +const AtSymbol = createToken({ + name: 'AtSymbol', + pattern: /@/, + longer_alt: NODE_STRING, +}); + const Minus = createToken({ name: 'Minus', pattern: /-/, @@ -1529,7 +1545,7 @@ const Minus = createToken({ const NumberToken = createToken({ name: 'NumberToken', - pattern: /\d+/, + pattern: /\d+(?![A-Za-z])/, }); // ============================================================================ @@ -1800,6 +1816,9 @@ const multiModeLexerDefinition = { START_DOTTED_LINK, START_LINK, + // Link ID (must come before NODE_STRING to avoid conflicts) + LINK_ID, + // Odd shape start (must come before DirectionValue to avoid conflicts) OddStart, @@ -1833,6 +1852,7 @@ const multiModeLexerDefinition = { // Basic punctuation (must come before NODE_STRING for proper tokenization) Pipe, Ampersand, + AtSymbol, Minus, StyleSeparator, // Must come before Colon to avoid conflicts (:::) Colon, @@ -1969,6 +1989,7 @@ export const allTokens = [ EOF, // Links (must come before NODE_STRING to avoid conflicts) + LINK_ID, LINK, START_LINK, THICK_LINK, @@ -2073,6 +2094,7 @@ export const allTokens = [ Pipe, PipeEnd, Ampersand, + AtSymbol, Minus, ]; @@ -2145,6 +2167,7 @@ export { ShapeDataEnd, // Links + LINK_ID, LINK, START_LINK, THICK_LINK, @@ -2200,5 +2223,6 @@ export { Pipe, PipeEnd, Ampersand, + AtSymbol, Minus, }; diff --git a/packages/mermaid/src/diagrams/flowchart/parser/flowParser.ts b/packages/mermaid/src/diagrams/flowchart/parser/flowParser.ts index eae7af172..a695419f9 100644 --- a/packages/mermaid/src/diagrams/flowchart/parser/flowParser.ts +++ b/packages/mermaid/src/diagrams/flowchart/parser/flowParser.ts @@ -138,20 +138,71 @@ export class FlowchartParser extends CstParser { // Vertex - following JISON pattern private vertex = this.RULE('vertex', () => { this.OR([ - // Basic shapes (first 6) + // Vertices with both labels and node data (use lookahead to resolve ambiguity) + { + ALT: () => this.SUBRULE(this.vertexWithSquareAndNodeData), + GATE: () => this.hasShapeDataAfterSquare(), + }, + { + ALT: () => this.SUBRULE(this.vertexWithDoubleCircleAndNodeData), + GATE: () => this.hasShapeDataAfterDoubleCircle(), + }, + { + ALT: () => this.SUBRULE(this.vertexWithCircleAndNodeData), + GATE: () => this.hasShapeDataAfterCircle(), + }, + { + ALT: () => this.SUBRULE(this.vertexWithRoundAndNodeData), + GATE: () => this.hasShapeDataAfterRound(), + }, + { + ALT: () => this.SUBRULE(this.vertexWithHexagonAndNodeData), + GATE: () => this.hasShapeDataAfterHexagon(), + }, + { + ALT: () => this.SUBRULE(this.vertexWithDiamondAndNodeData), + GATE: () => this.hasShapeDataAfterDiamond(), + }, + { + ALT: () => this.SUBRULE(this.vertexWithSubroutineAndNodeData), + GATE: () => this.hasShapeDataAfterSubroutine(), + }, + { + ALT: () => this.SUBRULE(this.vertexWithStadiumAndNodeData), + GATE: () => this.hasShapeDataAfterStadium(), + }, + { + ALT: () => this.SUBRULE(this.vertexWithEllipseAndNodeData), + GATE: () => this.hasShapeDataAfterEllipse(), + }, + { + ALT: () => this.SUBRULE(this.vertexWithCylinderAndNodeData), + GATE: () => this.hasShapeDataAfterCylinder(), + }, + { + ALT: () => this.SUBRULE(this.vertexWithOddAndNodeData), + GATE: () => this.hasShapeDataAfterOdd(), + }, + { + ALT: () => this.SUBRULE(this.vertexWithRectAndNodeData), + GATE: () => this.hasShapeDataAfterRect(), + }, + // Basic shapes (without node data) { ALT: () => this.SUBRULE(this.vertexWithSquare) }, { ALT: () => this.SUBRULE(this.vertexWithDoubleCircle) }, { ALT: () => this.SUBRULE(this.vertexWithCircle) }, { ALT: () => this.SUBRULE(this.vertexWithRound) }, { ALT: () => this.SUBRULE(this.vertexWithHexagon) }, { ALT: () => this.SUBRULE(this.vertexWithDiamond) }, - // Extended shapes (next 6) + // Extended shapes (without node data) { ALT: () => this.SUBRULE(this.vertexWithSubroutine) }, { ALT: () => this.SUBRULE(this.vertexWithTrapezoidVariant) }, { ALT: () => this.SUBRULE2(this.vertexWithStadium) }, { ALT: () => this.SUBRULE2(this.vertexWithEllipse) }, { ALT: () => this.SUBRULE2(this.vertexWithCylinder) }, - // Node with data syntax + { ALT: () => this.SUBRULE(this.vertexWithOdd) }, + { ALT: () => this.SUBRULE(this.vertexWithRect) }, + // Node with data syntax only { ALT: () => this.SUBRULE(this.vertexWithNodeData) }, // Plain node { ALT: () => this.SUBRULE(this.nodeId) }, @@ -267,12 +318,274 @@ export class FlowchartParser extends CstParser { this.CONSUME(tokens.CylinderEnd); }); + private vertexWithOdd = this.RULE('vertexWithOdd', () => { + this.SUBRULE(this.nodeId); + this.CONSUME(tokens.OddStart); + this.SUBRULE(this.nodeText); + this.CONSUME(tokens.SquareEnd); + }); + + private vertexWithRect = this.RULE('vertexWithRect', () => { + this.SUBRULE(this.nodeId); + this.CONSUME(tokens.RectStart); + this.SUBRULE(this.nodeText); + this.CONSUME(tokens.SquareEnd); + }); + // Vertex with node data syntax (e.g., D@{ shape: rounded }) private vertexWithNodeData = this.RULE('vertexWithNodeData', () => { this.SUBRULE(this.nodeId); this.SUBRULE(this.nodeData); }); + // Vertices with both labels and node data + private vertexWithSquareAndNodeData = this.RULE('vertexWithSquareAndNodeData', () => { + this.SUBRULE(this.nodeId); + this.CONSUME(tokens.SquareStart); + this.SUBRULE(this.nodeText); + this.CONSUME(tokens.SquareEnd); + this.SUBRULE(this.nodeData); + }); + + private vertexWithDoubleCircleAndNodeData = this.RULE('vertexWithDoubleCircleAndNodeData', () => { + this.SUBRULE(this.nodeId); + this.CONSUME(tokens.DoubleCircleStart); + this.OPTION(() => { + this.SUBRULE(this.nodeText); + }); + this.CONSUME(tokens.DoubleCircleEnd); + this.SUBRULE(this.nodeData); + }); + + private vertexWithCircleAndNodeData = this.RULE('vertexWithCircleAndNodeData', () => { + this.SUBRULE(this.nodeId); + this.CONSUME(tokens.CircleStart); + this.OPTION(() => { + this.SUBRULE(this.nodeText); + }); + this.CONSUME(tokens.CircleEnd); + this.SUBRULE(this.nodeData); + }); + + private vertexWithRoundAndNodeData = this.RULE('vertexWithRoundAndNodeData', () => { + this.SUBRULE(this.nodeId); + this.CONSUME(tokens.PS); + this.SUBRULE(this.nodeText); + this.CONSUME(tokens.PE); + this.SUBRULE(this.nodeData); + }); + + private vertexWithHexagonAndNodeData = this.RULE('vertexWithHexagonAndNodeData', () => { + this.SUBRULE(this.nodeId); + this.CONSUME(tokens.HexagonStart); + this.SUBRULE(this.nodeText); + this.CONSUME(tokens.HexagonEnd); + this.SUBRULE(this.nodeData); + }); + + private vertexWithDiamondAndNodeData = this.RULE('vertexWithDiamondAndNodeData', () => { + this.SUBRULE(this.nodeId); + this.CONSUME(tokens.DiamondStart); + this.SUBRULE(this.nodeText); + this.CONSUME(tokens.DiamondEnd); + this.SUBRULE(this.nodeData); + }); + + private vertexWithSubroutineAndNodeData = this.RULE('vertexWithSubroutineAndNodeData', () => { + this.SUBRULE(this.nodeId); + this.CONSUME(tokens.SubroutineStart); + this.SUBRULE(this.nodeText); + this.CONSUME(tokens.SubroutineEnd); + this.SUBRULE(this.nodeData); + }); + + private vertexWithStadiumAndNodeData = this.RULE('vertexWithStadiumAndNodeData', () => { + this.SUBRULE(this.nodeId); + this.CONSUME(tokens.StadiumStart); + this.SUBRULE(this.nodeText); + this.CONSUME(tokens.StadiumEnd); + this.SUBRULE(this.nodeData); + }); + + private vertexWithEllipseAndNodeData = this.RULE('vertexWithEllipseAndNodeData', () => { + this.SUBRULE(this.nodeId); + this.CONSUME(tokens.EllipseStart); + this.SUBRULE(this.nodeText); + this.CONSUME(tokens.EllipseEnd); + this.SUBRULE(this.nodeData); + }); + + private vertexWithCylinderAndNodeData = this.RULE('vertexWithCylinderAndNodeData', () => { + this.SUBRULE(this.nodeId); + this.CONSUME(tokens.CylinderStart); + this.SUBRULE(this.nodeText); + this.CONSUME(tokens.CylinderEnd); + this.SUBRULE(this.nodeData); + }); + + private vertexWithOddAndNodeData = this.RULE('vertexWithOddAndNodeData', () => { + this.SUBRULE(this.nodeId); + this.CONSUME(tokens.OddStart); + this.SUBRULE(this.nodeText); + this.CONSUME(tokens.SquareEnd); + this.SUBRULE(this.nodeData); + }); + + private vertexWithRectAndNodeData = this.RULE('vertexWithRectAndNodeData', () => { + this.SUBRULE(this.nodeId); + this.CONSUME(tokens.RectStart); + this.SUBRULE(this.nodeText); + this.CONSUME(tokens.SquareEnd); + this.SUBRULE(this.nodeData); + }); + + // Lookahead methods to resolve ambiguity between shapes with and without node data + private hasShapeDataAfterSquare(): boolean { + return ( + this.LA(1).tokenType === tokens.NODE_STRING && + this.LA(2).tokenType === tokens.SquareStart && + this.hasShapeDataAfterPosition(3) + ); + } + + private hasShapeDataAfterDoubleCircle(): boolean { + return ( + this.LA(1).tokenType === tokens.NODE_STRING && + this.LA(2).tokenType === tokens.DoubleCircleStart && + this.hasShapeDataAfterPosition(3) + ); + } + + private hasShapeDataAfterCircle(): boolean { + return ( + this.LA(1).tokenType === tokens.NODE_STRING && + this.LA(2).tokenType === tokens.CircleStart && + this.hasShapeDataAfterPosition(3) + ); + } + + private hasShapeDataAfterRound(): boolean { + return ( + this.LA(1).tokenType === tokens.NODE_STRING && + this.LA(2).tokenType === tokens.PS && + this.hasShapeDataAfterPosition(3) + ); + } + + private hasShapeDataAfterHexagon(): boolean { + return ( + this.LA(1).tokenType === tokens.NODE_STRING && + this.LA(2).tokenType === tokens.HexagonStart && + this.hasShapeDataAfterPosition(3) + ); + } + + private hasShapeDataAfterDiamond(): boolean { + return ( + this.LA(1).tokenType === tokens.NODE_STRING && + this.LA(2).tokenType === tokens.DiamondStart && + this.hasShapeDataAfterPosition(3) + ); + } + + private hasShapeDataAfterSubroutine(): boolean { + return ( + this.LA(1).tokenType === tokens.NODE_STRING && + this.LA(2).tokenType === tokens.SubroutineStart && + this.hasShapeDataAfterPosition(3) + ); + } + + private hasShapeDataAfterStadium(): boolean { + return ( + this.LA(1).tokenType === tokens.NODE_STRING && + this.LA(2).tokenType === tokens.StadiumStart && + this.hasShapeDataAfterPosition(3) + ); + } + + private hasShapeDataAfterEllipse(): boolean { + return ( + this.LA(1).tokenType === tokens.NODE_STRING && + this.LA(2).tokenType === tokens.EllipseStart && + this.hasShapeDataAfterPosition(3) + ); + } + + private hasShapeDataAfterCylinder(): boolean { + return ( + this.LA(1).tokenType === tokens.NODE_STRING && + this.LA(2).tokenType === tokens.CylinderStart && + this.hasShapeDataAfterPosition(3) + ); + } + + private hasShapeDataAfterOdd(): boolean { + return ( + this.LA(1).tokenType === tokens.NODE_STRING && + this.LA(2).tokenType === tokens.OddStart && + this.hasShapeDataAfterPosition(3) + ); + } + + private hasShapeDataAfterRect(): boolean { + return ( + this.LA(1).tokenType === tokens.NODE_STRING && + this.LA(2).tokenType === tokens.RectStart && + this.hasShapeDataAfterPosition(3) + ); + } + + // Helper method to check for @{ after a shape's closing token + private hasShapeDataAfterPosition(startPos: number): boolean { + let pos = startPos; + // Skip through the shape content and find the closing token + let depth = 1; + while (depth > 0 && pos <= 10) { + // Limit lookahead to prevent infinite loops + const token = this.LA(pos); + if (!token) return false; + + // Check for opening tokens that increase depth + if ( + token.tokenType === tokens.SquareStart || + token.tokenType === tokens.DoubleCircleStart || + token.tokenType === tokens.CircleStart || + token.tokenType === tokens.PS || + token.tokenType === tokens.HexagonStart || + token.tokenType === tokens.DiamondStart || + token.tokenType === tokens.SubroutineStart || + token.tokenType === tokens.StadiumStart || + token.tokenType === tokens.EllipseStart || + token.tokenType === tokens.CylinderStart || + token.tokenType === tokens.OddStart || + token.tokenType === tokens.RectStart + ) { + depth++; + } + // Check for closing tokens that decrease depth + else if ( + token.tokenType === tokens.SquareEnd || + token.tokenType === tokens.DoubleCircleEnd || + token.tokenType === tokens.CircleEnd || + token.tokenType === tokens.PE || + token.tokenType === tokens.HexagonEnd || + token.tokenType === tokens.DiamondEnd || + token.tokenType === tokens.SubroutineEnd || + token.tokenType === tokens.StadiumEnd || + token.tokenType === tokens.EllipseEnd || + token.tokenType === tokens.CylinderEnd + ) { + depth--; + } + + pos++; + } + + // Check if the next token after the shape is @{ + return this.LA(pos)?.tokenType === tokens.ShapeDataStart; + } + // Node data rule (handles @{ ... } syntax) private nodeData = this.RULE('nodeData', () => { this.CONSUME(tokens.ShapeDataStart); @@ -442,18 +755,42 @@ export class FlowchartParser extends CstParser { // Link statement private linkStatement = this.RULE('linkStatement', () => { this.OR([ - { ALT: () => this.CONSUME(tokens.LINK) }, - { ALT: () => this.CONSUME(tokens.THICK_LINK) }, - { ALT: () => this.CONSUME(tokens.DOTTED_LINK) }, + // LINK_ID followed by link token (e.g., "e1@-->") + { + ALT: () => { + this.CONSUME(tokens.LINK_ID); + this.OR2([ + { ALT: () => this.CONSUME(tokens.LINK) }, + { ALT: () => this.CONSUME(tokens.THICK_LINK) }, + { ALT: () => this.CONSUME(tokens.DOTTED_LINK) }, + ]); + }, + }, + // Regular link tokens without ID + { ALT: () => this.CONSUME2(tokens.LINK) }, + { ALT: () => this.CONSUME2(tokens.THICK_LINK) }, + { ALT: () => this.CONSUME2(tokens.DOTTED_LINK) }, ]); }); // Link with edge text - START_LINK/START_DOTTED_LINK/START_THICK_LINK edgeText EdgeTextEnd private linkWithEdgeText = this.RULE('linkWithEdgeText', () => { this.OR([ - { ALT: () => this.CONSUME(tokens.START_LINK) }, - { ALT: () => this.CONSUME(tokens.START_DOTTED_LINK) }, - { ALT: () => this.CONSUME(tokens.START_THICK_LINK) }, + // LINK_ID followed by START_LINK pattern (e.g., "e1@-- text -->") + { + ALT: () => { + this.CONSUME(tokens.LINK_ID); + this.OR2([ + { ALT: () => this.CONSUME(tokens.START_LINK) }, + { ALT: () => this.CONSUME(tokens.START_DOTTED_LINK) }, + { ALT: () => this.CONSUME(tokens.START_THICK_LINK) }, + ]); + }, + }, + // Regular START_LINK patterns without ID + { ALT: () => this.CONSUME2(tokens.START_LINK) }, + { ALT: () => this.CONSUME2(tokens.START_DOTTED_LINK) }, + { ALT: () => this.CONSUME2(tokens.START_THICK_LINK) }, ]); this.SUBRULE(this.edgeText); this.CONSUME(tokens.EdgeTextEnd); @@ -523,21 +860,27 @@ export class FlowchartParser extends CstParser { this.CONSUME(tokens.Default); }, }, - { ALT: () => this.SUBRULE(this.numberList) }, + { + ALT: () => { + this.SUBRULE(this.numberList); + }, + }, ]); - // Then handle optional INTERPOLATE + alphaNum + // Then handle optional INTERPOLATE + alphaNum (must come before styleList) this.OPTION(() => { this.CONSUME(tokens.Interpolate); this.SUBRULE(this.alphaNum); }); - // Then handle optional styleList + // Then handle optional styleList (after interpolate) this.OPTION2(() => { - this.SUBRULE2(this.styleList); + this.SUBRULE(this.styleList); }); - this.SUBRULE(this.statementSeparator); + this.OPTION3(() => { + this.SUBRULE(this.statementSeparator); + }); }); // Class definition statement @@ -563,12 +906,24 @@ export class FlowchartParser extends CstParser { this.OR([ { ALT: () => this.SUBRULE(this.clickHref) }, { ALT: () => this.SUBRULE(this.clickCall) }, + // Handle direct link syntax: click A "url" ["tooltip"] [target] + { + ALT: () => { + this.CONSUME(tokens.QuotedString); // URL + // Optional tooltip (second QuotedString) + this.OPTION3(() => { + this.CONSUME2(tokens.QuotedString); // Tooltip + }); + // Optional target parameter (NODE_STRING) + this.OPTION4(() => { + this.CONSUME2(tokens.NODE_STRING); // Target parameter like "_blank" + }); + }, + }, ]); + // Optional tooltip for clickCall (callback) syntax this.OPTION(() => { - this.OR2([ - { ALT: () => this.CONSUME(tokens.NODE_STRING) }, - { ALT: () => this.CONSUME(tokens.QuotedString) }, - ]); + this.CONSUME3(tokens.QuotedString); // Tooltip for callback syntax }); this.OPTION2(() => { this.SUBRULE(this.statementSeparator); @@ -582,6 +937,14 @@ export class FlowchartParser extends CstParser { { ALT: () => this.CONSUME(tokens.NODE_STRING) }, { ALT: () => this.CONSUME(tokens.QuotedString) }, ]); + // Optional tooltip parameter (second QuotedString) + this.OPTION(() => { + this.CONSUME2(tokens.QuotedString); // Tooltip parameter + }); + // Optional target parameter + this.OPTION2(() => { + this.CONSUME2(tokens.NODE_STRING); // Target parameter like "_blank" + }); }); // Click call @@ -593,28 +956,29 @@ export class FlowchartParser extends CstParser { this.OR2([ { ALT: () => this.CONSUME(tokens.NODE_STRING) }, { ALT: () => this.CONSUME(tokens.QuotedString) }, + { ALT: () => this.CONSUME(tokens.Callback) }, // Handle "call callback" syntax ]); this.OPTION(() => { - this.CONSUME(tokens.Pipe); - // Parse arguments - this.CONSUME2(tokens.Pipe); + this.CONSUME(tokens.PS); // Opening parenthesis + this.OPTION2(() => { + // Parse function arguments - handle multiple tokens for complex arguments + this.MANY(() => { + this.OR3([ + { ALT: () => this.CONSUME(tokens.TextContent) }, // Arguments as text token + { ALT: () => this.CONSUME2(tokens.QuotedString) }, + { ALT: () => this.CONSUME2(tokens.NODE_STRING) }, + ]); + }); + }); + this.CONSUME(tokens.PE); // Closing parenthesis }); }, }, { ALT: () => { - this.CONSUME(tokens.Callback); - this.OR3([ - { ALT: () => this.CONSUME2(tokens.NODE_STRING) }, - { ALT: () => this.CONSUME2(tokens.QuotedString) }, - { - ALT: () => { - this.CONSUME(tokens.StringStart); - this.CONSUME(tokens.StringContent); - this.CONSUME(tokens.StringEnd); - }, - }, - ]); + this.CONSUME2(tokens.Callback); + // For simple callback syntax like "click A callback", the Callback token itself is the function name + // Don't consume additional strings here - let clickStatement handle tooltips }, }, ]); @@ -674,6 +1038,13 @@ export class FlowchartParser extends CstParser { private subgraphId = this.RULE('subgraphId', () => { this.OR([ { ALT: () => this.CONSUME(tokens.QuotedString) }, + { + ALT: () => { + this.CONSUME(tokens.MarkdownStringStart); + this.CONSUME(tokens.MarkdownStringContent); + this.CONSUME(tokens.MarkdownStringEnd); + }, + }, { ALT: () => { this.CONSUME(tokens.StringStart); @@ -723,12 +1094,16 @@ export class FlowchartParser extends CstParser { this.CONSUME(tokens.Comma); this.CONSUME2(tokens.NumberToken); }); + // Optionally handle mixed case: NumberToken followed by NODE_STRING + this.OPTION(() => { + this.CONSUME(tokens.NODE_STRING); + }); }, }, // Handle comma-separated numbers that got tokenized as NODE_STRING (e.g., "0,1") { ALT: () => { - this.CONSUME(tokens.NODE_STRING); + this.CONSUME2(tokens.NODE_STRING); }, }, ]); diff --git a/packages/mermaid/src/diagrams/flowchart/parser/flowParserAdapter.ts b/packages/mermaid/src/diagrams/flowchart/parser/flowParserAdapter.ts index 373394b66..2d3e588a4 100644 --- a/packages/mermaid/src/diagrams/flowchart/parser/flowParserAdapter.ts +++ b/packages/mermaid/src/diagrams/flowchart/parser/flowParserAdapter.ts @@ -323,7 +323,9 @@ const flow = { // Use addVertex method if available, otherwise set directly if (typeof targetYY.addVertex === 'function') { // Create textObj structure expected by FlowDB - const textObj = vertex.text ? { text: vertex.text, type: 'text' } : undefined; + const textObj = vertex.text + ? { text: vertex.text, type: vertex.labelType || 'text' } + : undefined; targetYY.addVertex( id, textObj, @@ -341,20 +343,48 @@ const flow = { } // Add edges - ast.edges.forEach((edge) => { - if (typeof targetYY.addLink === 'function') { - // Create the linkData structure expected by FlowDB - const linkData = { - type: edge.type, - stroke: edge.stroke, - length: edge.length, - text: edge.text ? { text: edge.text, type: 'text' } : undefined, - }; - targetYY.addLink([edge.start], [edge.end], linkData); - } else { - targetYY.edges.push(edge); - } - }); + // Only process edges if visitor didn't have FlowDB instance + // (if visitor had FlowDB, edges were added directly during parsing) + if (!parserInstance.visitor.flowDb) { + ast.edges.forEach((edge) => { + if (typeof targetYY.addLink === 'function') { + // Create the linkData structure expected by FlowDB + const linkData = { + id: edge.id, // Include edge ID for user-defined edge IDs + type: edge.type, + stroke: edge.stroke, + length: edge.length, + text: edge.text ? { text: edge.text, type: edge.labelType || 'text' } : undefined, + }; + targetYY.addLink([edge.start], [edge.end], linkData); + } else { + targetYY.edges.push(edge); + } + }); + } + + // Apply edge metadata after edges have been created + if (ast.edgeMetadata && typeof targetYY.addVertex === 'function') { + Object.entries(ast.edgeMetadata).forEach(([edgeId, metadata]) => { + // Convert metadata object to YAML string format expected by FlowDB + const yamlMetadata = Object.entries(metadata) + .map(([key, value]) => `${key}: ${value}`) + .join(', '); + + // Use FlowDB's addVertex method which can detect edges and apply metadata + const textObj = { text: edgeId, type: 'text' }; + targetYY.addVertex( + edgeId, + textObj, + 'squareRect', // shape (not used for edges) + [], // style + [], // classes + undefined, // dir + {}, // props (empty for edges) + yamlMetadata // metadata - this will be processed as YAML and applied to the edge + ); + }); + } // Apply linkStyles after edges have been added if (ast.linkStyles) { @@ -408,12 +438,30 @@ const flow = { targetYY.setAccDescription(ast.accDescription); } - // Add click events - ast.clickEvents.forEach((clickEvent) => { - if (typeof targetYY.setClickEvent === 'function') { - targetYY.setClickEvent(clickEvent.id, clickEvent.functionName, clickEvent.functionArgs); - } - }); + // Click events are now handled directly by the AST visitor during parsing + // to match JISON parser behavior and avoid duplicate calls + // ast.clickEvents.forEach((clickEvent) => { + // if (clickEvent.type === 'href') { + // // Handle href/link events + // if (typeof targetYY.setLink === 'function') { + // if (clickEvent.target !== undefined) { + // targetYY.setLink(clickEvent.id, clickEvent.href, clickEvent.target); + // } else { + // targetYY.setLink(clickEvent.id, clickEvent.href); + // } + // } + // } else if (clickEvent.type === 'call') { + // // Handle callback/function call events + // if (typeof targetYY.setClickEvent === 'function') { + // // Only pass functionArgs if it's defined (for compatibility with JISON parser) + // if (clickEvent.functionArgs !== undefined) { + // targetYY.setClickEvent(clickEvent.id, clickEvent.functionName, clickEvent.functionArgs); + // } else { + // targetYY.setClickEvent(clickEvent.id, clickEvent.functionName); + // } + // } + // } + // }); return ast; }, diff --git a/packages/mermaid/src/diagrams/flowchart/types.ts b/packages/mermaid/src/diagrams/flowchart/types.ts index 54156091b..c6f8cbc39 100644 --- a/packages/mermaid/src/diagrams/flowchart/types.ts +++ b/packages/mermaid/src/diagrams/flowchart/types.ts @@ -29,7 +29,7 @@ export interface FlowVertex { domId: string; haveCallback?: boolean; id: string; - labelType: 'text'; + labelType: 'text' | 'markdown' | 'string'; link?: string; linkTarget?: string; props?: any; @@ -49,7 +49,7 @@ export interface FlowVertex { export interface FlowText { text: string; - type: 'text'; + type: 'text' | 'markdown' | 'string'; } export interface FlowEdge { @@ -62,7 +62,7 @@ export interface FlowEdge { style?: string[]; length?: number; text: string; - labelType: 'text'; + labelType: 'text' | 'markdown' | 'string'; classes: string[]; id?: string; animation?: 'fast' | 'slow';