diff --git a/demos/class-antlr-test.html b/demos/class-antlr-test.html new file mode 100644 index 000000000..1681e9c39 --- /dev/null +++ b/demos/class-antlr-test.html @@ -0,0 +1,331 @@ + + + + + + + Mermaid Class ANTLR Parser Test Page + + + + + +

๐ŸŽฏ Mermaid Class ANTLR Parser Test Page

+ +
+

๐Ÿ”ง Parser Information

+

Environment Variable: Loading...

+

Expected: USE_ANTLR_PARSER=true

+

Status: Checking...

+
+ +
+

Test 1: Simple Class Diagram

+

Basic class diagram to test ANTLR parser functionality:

+
+classDiagram
+  class Animal {
+    +name: string
+    +age: int
+    +makeSound()
+  }
+    
+
+ +
+

Test 2: Class with Relationships

+

Testing class relationships:

+
+classDiagram
+  class Animal {
+    +name: string
+    +makeSound()
+  }
+  class Dog {
+    +breed: string
+    +bark()
+  }
+  Animal <|-- Dog
+    
+
+ +
+

๐Ÿšจ Test 3: BROKEN DIAGRAM - Debug Target

+

This is the problematic diagram that needs debugging:

+
classDiagram + class Person { + +ID : Guid + +FirstName : string + +LastName : string + -privateProperty : string + #ProtectedProperty : string + ~InternalProperty : string + ~AnotherInternalProperty : List~List~string~~ + } + class People List~List~Person~~
+

Expected Error: Parse error on line 11: Expecting 'STR'

+
+classDiagram
+      class Person {
+        +ID : Guid
+        +FirstName : string
+        +LastName : string
+        -privateProperty : string
+        #ProtectedProperty : string
+        ~InternalProperty : string
+        ~AnotherInternalProperty : List~List~string~~
+      }
+      class People List~List~Person~~
+    
+
+ +
+

Test 4: Generic Types (Simplified)

+

Testing simpler generic type syntax:

+
+classDiagram
+  class Person {
+    +ID : Guid
+    +FirstName : string
+    +LastName : string
+  }
+  class People {
+    +items : List~Person~
+  }
+    
+
+ +
+

Test 5: Visibility Modifiers

+

Testing different visibility modifiers:

+
+classDiagram
+  class TestClass {
+    +publicField : string
+    -privateField : string
+    #protectedField : string
+    ~packageField : string
+    +publicMethod()
+    -privateMethod()
+    #protectedMethod()
+    ~packageMethod()
+  }
+    
+
+ + + + + \ No newline at end of file diff --git a/demos/hybrid-sequence-test.html b/demos/hybrid-sequence-test.html new file mode 100644 index 000000000..52731891e --- /dev/null +++ b/demos/hybrid-sequence-test.html @@ -0,0 +1,358 @@ + + + + + + ๐Ÿš€ Hybrid Sequence Editor Test + + + +
+

๐Ÿš€ Hybrid Sequence Editor Test

+

+ Testing the new hybrid approach: AST-based editing + TokenStreamRewriter for optimal performance +

+ + +
+

๐ŸŽฏ Basic Hybrid Editor Test

+
+ + +
+ +
sequenceDiagram + Alice->>Bob: Hello Bob, how are you? + Bob-->>Alice: Great!
+ +
+
+ + +
+

โœ๏ธ CRUD Operations Test

+
+
+

Add Participant

+ + + +
+ +
+

Add Message

+ + + + + +
+ +
+

Add Note

+ + + + +
+ +
+

Move Statement

+ + + +
+
+ +
+ + + +
+ +
+
+ + +
+

โšก Performance Test

+
+ + +
+ +
+
+ + +
+

๐Ÿ” Debug Log

+
+ +
+
+
+
+ + + + diff --git a/demos/sequence-antlr-test.html b/demos/sequence-antlr-test.html index d1562a44f..91f72b57f 100644 --- a/demos/sequence-antlr-test.html +++ b/demos/sequence-antlr-test.html @@ -67,13 +67,17 @@ -

๐ŸŽฏ Mermaid Sequence ANTLR Parser Test Page

+

๐Ÿš€ Hybrid Sequence Parser Test Page

+

+ Testing the new hybrid AST approach: Order-preserving AST + TokenStreamRewriter for optimal performance +

๐Ÿ”ง Parser Information

Environment Variable: Loading...

Expected: USE_ANTLR_PARSER=true

Status: Checking...

+

Hybrid Features: AST Building, Order Preservation, Smart Regeneration

@@ -87,79 +91,102 @@ sequenceDiagram
-

Test 2: Participants and Actors

-

Testing participant and actor declarations:

-
-sequenceDiagram
-  participant A as Alice
-  participant B as Bob
-  actor C as Charlie
-  
-  A->>B: Hello Bob
-  B->>C: Hi Charlie
-  C-->>A: Hello Alice
-    
+

๐Ÿ”„ AST-to-Code Regeneration Test

+

Test the ability to regenerate sequence diagram code from the parsed AST:

+ +
+ + +
+ +
+ + + + + + + +
+ +

+ First click "Debug Globals" to check if the parser is available, then select a test case and click "Test AST + Regeneration". After that, you can test various modifications: +
โ€ข ๐Ÿ”ง Test AST Modification - Change message text with surgical editing +
โ€ข โž• Test Add Participant - Add new participant using HybridSequenceEditor +
โ€ข ๐Ÿท๏ธ Test Update Participant Alias - Add alias to existing participant +
Check the debug logs below for results. +

-

Test 3: Different Arrow Types

-

Testing various arrow types and message formats:

-
-sequenceDiagram
-  Alice->>Bob: Solid arrow
-  Bob-->>Alice: Dotted arrow
-  Alice-xBob: Cross ending
-  Bob--xAlice: Dotted cross
-  Alice-)Bob: Open arrow
-  Bob--)Alice: Dotted open
-    
+

๐ŸŽจ AST-Generated Diagram

+

This diagram is rendered using code generated from the AST (not the original input):

+
+ Click "๐ŸŽจ Render AST-Generated Diagram" to render a diagram from AST-generated code +
-

Test 4: Activation Boxes

-

Testing activation boxes and lifelines:

-
-sequenceDiagram
-  Alice->>+John: Hello John, how are you?
-  Alice->>+John: John, can you hear me?
-  John-->>-Alice: Hi Alice, I can hear you!
-  John-->>-Alice: I feel great!
-    
+

๐Ÿš€ Hybrid AST Features Test

+

Test the new hybrid AST features: structured AST building, order preservation, and enhanced debugging:

+ +
+ + + +
+ +

+ These features demonstrate the enhanced AST capabilities: structured data, validation, and comprehensive + statistics. +

+
-

Test 5: Notes and Comments

-

Testing notes over participants:

-
-sequenceDiagram
-  participant Alice
-  participant Bob
-  
-  Alice->>Bob: Hello Bob
-  Note over Alice,Bob: This is a note over both
-  Note right of Bob: This note is on right of Bob
-  Note left of Alice: This note is on left of Alice
-  Bob-->>Alice: Hi Alice
-    
-
- -
-

Test 6: Loops and Alt

-

Testing control flow structures:

-
-sequenceDiagram
-  Alice->>Bob: Hello Bob
-  
-  loop Every minute
-    Bob-->>Alice: Great!
-  end
-  
-  alt is sick
-    Bob->>Alice: Not so good :(
-  else is well
-    Bob->>Alice: Feeling fresh like a daisy
-  end
-    
+

๐Ÿ” Debug Logs

+
+

๐Ÿ” Debug Logs:

+

Test results and debug information will appear here...

+
- + \ No newline at end of file diff --git a/packages/mermaid/src/diagrams/sequence/parser/antlr/HybridDiagramEditor.ts b/packages/mermaid/src/diagrams/sequence/parser/antlr/HybridDiagramEditor.ts new file mode 100644 index 000000000..263692029 --- /dev/null +++ b/packages/mermaid/src/diagrams/sequence/parser/antlr/HybridDiagramEditor.ts @@ -0,0 +1,317 @@ +import { CommonTokenStream, TokenStreamRewriter } from 'antlr4ng'; + +/** + * Base interfaces for diagram editing + */ +export interface DiagramStatement { + type: string; + originalIndex: number; + data: any; + sourceTokens?: { start: any; stop: any }; // Reference to original tokens +} + +export interface DiagramAST { + header: string; + statements: DiagramStatement[]; + metadata?: any; +} + +export interface EditOperation { + type: 'insert' | 'update' | 'delete' | 'move'; + index: number; + data?: any; + targetIndex?: number; // for move operations + timestamp: number; +} + +/** + * Abstract base class for hybrid diagram editors + * Combines AST-based structural editing with TokenStreamRewriter for performance + */ +export abstract class HybridDiagramEditor { + protected ast: T; + protected tokenRewriter: TokenStreamRewriter; + protected originalTokenStream: CommonTokenStream; + protected pendingOperations: EditOperation[] = []; + protected operationHistory: EditOperation[][] = []; // For undo/redo + + constructor(protected input: string, protected diagramType: string) { + console.log(`๐Ÿ—๏ธ Initializing ${diagramType} hybrid editor`); + this.parseAndBuildAST(); + } + + /** + * Parse input and build both token stream and AST + */ + private parseAndBuildAST(): void { + try { + const { parser, tokenStream } = this.createParser(this.input); + this.originalTokenStream = tokenStream; + this.tokenRewriter = new TokenStreamRewriter(tokenStream); + + console.log(`๐ŸŒณ Building AST for ${this.diagramType}`); + this.ast = this.buildAST(parser); + + console.log(`โœ… ${this.diagramType} AST built successfully:`, { + statements: this.ast.statements.length, + header: this.ast.header + }); + } catch (error) { + console.error(`โŒ Failed to parse ${this.diagramType}:`, error); + throw error; + } + } + + /** + * Abstract methods each diagram type must implement + */ + protected abstract createParser(input: string): { parser: any; tokenStream: CommonTokenStream }; + protected abstract buildAST(parser: any): T; + protected abstract regenerateFromAST(): string; + protected abstract getStatementCount(): number; + + /** + * Get current AST (read-only) + */ + getAST(): Readonly { + return this.ast; + } + + /** + * Get statement by index + */ + getStatement(index: number): DiagramStatement | undefined { + return this.ast.statements.find(stmt => stmt.originalIndex === index); + } + + /** + * Get all statements of a specific type + */ + getStatementsByType(type: string): DiagramStatement[] { + return this.ast.statements.filter(stmt => stmt.type === type); + } + + /** + * Insert a new statement at the specified position + */ + insertStatement(afterIndex: number, statement: Omit): void { + console.log(`๐Ÿ“ Inserting ${statement.type} statement after index ${afterIndex}`); + + // Update indices of statements after insertion point + this.ast.statements.forEach(stmt => { + if (stmt.originalIndex > afterIndex) { + stmt.originalIndex++; + } + }); + + const newStatement: DiagramStatement = { + ...statement, + originalIndex: afterIndex + 1 + }; + + // Find insertion position in array + const insertPos = this.ast.statements.findIndex(stmt => stmt.originalIndex > afterIndex + 1); + if (insertPos === -1) { + this.ast.statements.push(newStatement); + } else { + this.ast.statements.splice(insertPos, 0, newStatement); + } + + // Record operation + this.recordOperation({ + type: 'insert', + index: afterIndex + 1, + data: statement, + timestamp: Date.now() + }); + } + + /** + * Update an existing statement + */ + updateStatement(index: number, newData: Partial): void { + console.log(`โœ๏ธ Updating statement at index ${index}`); + + const statement = this.ast.statements.find(stmt => stmt.originalIndex === index); + if (!statement) { + console.warn(`โš ๏ธ Statement at index ${index} not found`); + return; + } + + const oldData = { ...statement.data }; + statement.data = { ...statement.data, ...newData }; + + // Record operation + this.recordOperation({ + type: 'update', + index, + data: { old: oldData, new: statement.data }, + timestamp: Date.now() + }); + } + + /** + * Remove a statement + */ + removeStatement(index: number): void { + console.log(`๐Ÿ—‘๏ธ Removing statement at index ${index}`); + + const stmtIndex = this.ast.statements.findIndex(stmt => stmt.originalIndex === index); + if (stmtIndex === -1) { + console.warn(`โš ๏ธ Statement at index ${index} not found`); + return; + } + + const removedStatement = this.ast.statements[stmtIndex]; + this.ast.statements.splice(stmtIndex, 1); + + // Update indices of statements after removal + this.ast.statements.forEach(stmt => { + if (stmt.originalIndex > index) { + stmt.originalIndex--; + } + }); + + // Record operation + this.recordOperation({ + type: 'delete', + index, + data: removedStatement, + timestamp: Date.now() + }); + } + + /** + * Move a statement from one position to another + */ + moveStatement(fromIndex: number, toIndex: number): void { + console.log(`๐Ÿ”„ Moving statement from index ${fromIndex} to ${toIndex}`); + + if (fromIndex === toIndex) return; + + const statement = this.ast.statements.find(stmt => stmt.originalIndex === fromIndex); + if (!statement) { + console.warn(`โš ๏ธ Statement at index ${fromIndex} not found`); + return; + } + + // Remove from current position + this.removeStatement(fromIndex); + + // Adjust target index if necessary + const adjustedToIndex = toIndex > fromIndex ? toIndex - 1 : toIndex; + + // Insert at new position + this.insertStatement(adjustedToIndex, { + type: statement.type, + data: statement.data, + sourceTokens: statement.sourceTokens + }); + + // Record operation (override the individual remove/insert operations) + this.pendingOperations.pop(); // Remove insert + this.pendingOperations.pop(); // Remove delete + this.recordOperation({ + type: 'move', + index: fromIndex, + targetIndex: toIndex, + timestamp: Date.now() + }); + } + + /** + * Smart code regeneration with automatic strategy selection + */ + regenerateCode(strategy: 'ast' | 'tokens' | 'auto' = 'auto'): string { + console.log(`๐Ÿ”„ Regenerating code using ${strategy} strategy`); + + if (strategy === 'auto') { + strategy = this.chooseOptimalStrategy(); + console.log(`๐Ÿค– Auto-selected strategy: ${strategy}`); + } + + try { + const result = strategy === 'tokens' + ? this.regenerateUsingTokens() + : this.regenerateFromAST(); + + console.log(`โœ… Code regenerated successfully (${result.split('\n').length} lines)`); + return result; + } catch (error) { + console.error(`โŒ Failed to regenerate code using ${strategy} strategy:`, error); + + // Fallback to AST if tokens fail + if (strategy === 'tokens') { + console.log('๐Ÿ”„ Falling back to AST regeneration'); + return this.regenerateFromAST(); + } + + throw error; + } + } + + /** + * Choose optimal regeneration strategy based on file size and changes + */ + protected chooseOptimalStrategy(): 'ast' | 'tokens' { + const fileSize = this.input.length; + const statementCount = this.getStatementCount(); + const changeRatio = this.pendingOperations.length / Math.max(statementCount, 1); + + const hasStructuralChanges = this.pendingOperations.some(op => + op.type === 'insert' || op.type === 'delete' || op.type === 'move' + ); + + console.log(`๐Ÿ“Š Strategy selection metrics:`, { + fileSize, + statementCount, + pendingOperations: this.pendingOperations.length, + changeRatio: changeRatio.toFixed(2), + hasStructuralChanges + }); + + // Use tokens for large files with minimal text-only changes + if (fileSize > 10000 && changeRatio < 0.1 && !hasStructuralChanges) { + return 'tokens'; + } + + // Use AST for structural changes or smaller files + return 'ast'; + } + + /** + * Regenerate using TokenStreamRewriter (preserves original formatting) + */ + protected regenerateUsingTokens(): string { + // Apply pending token-level operations + // This would be implemented by subclasses for specific token manipulations + return this.tokenRewriter.getText(); + } + + /** + * Record an operation for history/undo functionality + */ + private recordOperation(operation: EditOperation): void { + this.pendingOperations.push(operation); + + // Limit history size to prevent memory issues + if (this.pendingOperations.length > 1000) { + this.pendingOperations = this.pendingOperations.slice(-500); + } + } + + /** + * Get operation history for debugging + */ + getOperationHistory(): ReadonlyArray { + return this.pendingOperations; + } + + /** + * Clear all pending operations (useful after successful save) + */ + clearOperations(): void { + console.log(`๐Ÿงน Clearing ${this.pendingOperations.length} pending operations`); + this.pendingOperations = []; + } +} diff --git a/packages/mermaid/src/diagrams/sequence/parser/antlr/HybridSequenceEditor.ts b/packages/mermaid/src/diagrams/sequence/parser/antlr/HybridSequenceEditor.ts new file mode 100644 index 000000000..73d739f56 --- /dev/null +++ b/packages/mermaid/src/diagrams/sequence/parser/antlr/HybridSequenceEditor.ts @@ -0,0 +1,324 @@ +import { CommonTokenStream } from 'antlr4ng'; +import { HybridDiagramEditor } from './HybridDiagramEditor.js'; +import { + SequenceAST, + SequenceStatement, + ParticipantData, + MessageData, + NoteData, + LoopData, + SequenceASTHelper +} from './SequenceAST.js'; +import { createSequenceParser } from './antlr-parser.js'; + +/** + * Hybrid editor specifically for sequence diagrams + * Combines AST-based editing with TokenStreamRewriter for optimal performance + */ +export class HybridSequenceEditor extends HybridDiagramEditor { + + constructor(input: string) { + super(input, 'sequence'); + } + + /** + * Create ANTLR parser for sequence diagrams + */ + protected createParser(input: string): { parser: any; tokenStream: CommonTokenStream } { + console.log('๐Ÿ”ง Creating sequence diagram parser'); + return createSequenceParser(input); + } + + /** + * Build sequence-specific AST from parse tree + */ + protected buildAST(parser: any): SequenceAST { + console.log('๐ŸŒณ Building sequence AST from parse tree'); + + const builder = new SequenceASTBuilder(); + const parseTree = parser.start(); + + // Visit the parse tree to build our AST + builder.visit(parseTree); + + const ast = builder.getAST(); + console.log('โœ… Sequence AST built:', SequenceASTHelper.getStatistics(ast)); + + return ast; + } + + /** + * Regenerate sequence diagram code from AST + */ + protected regenerateFromAST(): string { + console.log('๐Ÿ”„ Regenerating sequence code from AST'); + + let code = this.ast.header + '\n'; + + // Sort statements by original index to maintain order + const sortedStatements = [...this.ast.statements] + .sort((a, b) => a.originalIndex - b.originalIndex); + + for (const stmt of sortedStatements) { + const line = this.generateStatementCode(stmt); + if (line) { + code += ' ' + line + '\n'; + } + } + + return code.trim(); + } + + /** + * Generate code for a single statement + */ + private generateStatementCode(stmt: SequenceStatement): string { + switch (stmt.type) { + case 'participant': + const p = stmt.data as ParticipantData; + return p.alias ? `participant ${p.id} as ${p.alias}` : `participant ${p.id}`; + + case 'message': + const m = stmt.data as MessageData; + return `${m.from}${m.arrow}${m.to}: ${m.message}`; + + case 'note': + const n = stmt.data as NoteData; + return `Note ${n.position} of ${n.participant}: ${n.message}`; + + case 'activate': + return `activate ${(stmt.data as any).participant}`; + + case 'deactivate': + return `deactivate ${(stmt.data as any).participant}`; + + case 'loop': + const l = stmt.data as LoopData; + // For now, simplified loop handling - would need more complex logic for nested statements + return `loop ${l.condition}`; + + default: + console.warn(`โš ๏ธ Unknown statement type: ${stmt.type}`); + return ''; + } + } + + /** + * Get statement count for strategy selection + */ + protected getStatementCount(): number { + return this.ast.statements.length; + } + + // ======================================== + // High-level sequence diagram operations + // ======================================== + + /** + * Add a new participant + */ + addParticipant(id: string, alias?: string, afterIndex?: number): void { + console.log(`๐Ÿ‘ค Adding participant: ${id}${alias ? ` as ${alias}` : ''}`); + + // Check if participant already exists + if (SequenceASTHelper.findParticipant(this.ast, id)) { + console.warn(`โš ๏ธ Participant ${id} already exists`); + return; + } + + const participantData: ParticipantData = { id, alias }; + + // If no position specified, add at the beginning (common pattern) + const insertIndex = afterIndex ?? -1; + + this.insertStatement(insertIndex, { + type: 'participant', + data: participantData + }); + } + + /** + * Update participant alias + */ + updateParticipantAlias(participantId: string, newAlias: string): void { + console.log(`โœ๏ธ Updating participant ${participantId} alias to: ${newAlias}`); + + const stmt = this.ast.statements.find(s => + s.type === 'participant' && (s.data as ParticipantData).id === participantId + ); + + if (!stmt) { + console.warn(`โš ๏ธ Participant ${participantId} not found`); + return; + } + + this.updateStatement(stmt.originalIndex, { alias: newAlias }); + } + + /** + * Add a new message + */ + addMessage(from: string, to: string, message: string, arrow: string = '->>', afterIndex?: number): void { + console.log(`๐Ÿ’ฌ Adding message: ${from}${arrow}${to}: ${message}`); + + const messageData: MessageData = { from, to, arrow, message }; + + // If no position specified, add at the end + const insertIndex = afterIndex ?? this.getLastStatementIndex(); + + this.insertStatement(insertIndex, { + type: 'message', + data: messageData + }); + } + + /** + * Update message text + */ + updateMessageText(messageIndex: number, newText: string): void { + console.log(`โœ๏ธ Updating message at index ${messageIndex} to: ${newText}`); + + const stmt = this.getStatement(messageIndex); + if (!stmt || stmt.type !== 'message') { + console.warn(`โš ๏ธ Message at index ${messageIndex} not found`); + return; + } + + this.updateStatement(messageIndex, { message: newText }); + } + + /** + * Add a note + */ + addNote(position: 'left' | 'right' | 'over', participant: string, message: string, afterIndex?: number): void { + console.log(`๐Ÿ“ Adding note: Note ${position} of ${participant}: ${message}`); + + const noteData: NoteData = { position, participant, message }; + + const insertIndex = afterIndex ?? this.getLastStatementIndex(); + + this.insertStatement(insertIndex, { + type: 'note', + data: noteData + }); + } + + /** + * Add activation + */ + addActivation(participant: string, afterIndex?: number): void { + console.log(`โšก Adding activation for: ${participant}`); + + const insertIndex = afterIndex ?? this.getLastStatementIndex(); + + this.insertStatement(insertIndex, { + type: 'activate', + data: { participant } + }); + } + + /** + * Add deactivation + */ + addDeactivation(participant: string, afterIndex?: number): void { + console.log(`๐Ÿ’ค Adding deactivation for: ${participant}`); + + const insertIndex = afterIndex ?? this.getLastStatementIndex(); + + this.insertStatement(insertIndex, { + type: 'deactivate', + data: { participant } + }); + } + + /** + * Wrap statements in a loop + */ + wrapInLoop(startIndex: number, endIndex: number, condition: string): void { + console.log(`๐Ÿ”„ Wrapping statements ${startIndex}-${endIndex} in loop: ${condition}`); + + // This is a complex operation that would need careful implementation + // For now, just add a loop statement + const loopData: LoopData = { condition, statements: [] }; + + this.insertStatement(startIndex - 1, { + type: 'loop', + data: loopData + }); + } + + // ======================================== + // Helper methods + // ======================================== + + /** + * Get the index of the last statement + */ + private getLastStatementIndex(): number { + if (this.ast.statements.length === 0) return -1; + return Math.max(...this.ast.statements.map(s => s.originalIndex)); + } + + /** + * Get all participants (declared and mentioned) + */ + getAllParticipants(): Set { + return SequenceASTHelper.getAllMentionedParticipants(this.ast); + } + + /** + * Get sequence diagram statistics + */ + getStatistics() { + return SequenceASTHelper.getStatistics(this.ast); + } + + /** + * Validate the current AST + */ + validate() { + return SequenceASTHelper.validate(this.ast); + } + + /** + * Get a summary of the diagram for debugging + */ + getSummary(): string { + const stats = this.getStatistics(); + const participants = Array.from(this.getAllParticipants()).join(', '); + + return `Sequence Diagram Summary: +- ${stats.totalStatements} total statements +- ${stats.participants} declared participants: ${participants} +- ${stats.messages} messages +- ${stats.notes} notes +- ${stats.loops} loops +- Complexity: ${stats.complexity}`; + } +} + +/** + * AST Builder for sequence diagrams + * Converts ANTLR parse tree to our custom AST format + */ +class SequenceASTBuilder { + private ast: SequenceAST; + private currentIndex = 0; + + constructor() { + this.ast = SequenceASTHelper.createEmpty(); + } + + getAST(): SequenceAST { + return this.ast; + } + + // This would be implemented with proper visitor pattern + // For now, placeholder that would integrate with your existing SequenceCodeGenerator + visit(parseTree: any): void { + // TODO: Implement proper AST building from parse tree + // This would use the visitor pattern to traverse the parse tree + // and build the structured AST + console.log('๐Ÿšง AST building from parse tree - to be implemented'); + } +} diff --git a/packages/mermaid/src/diagrams/sequence/parser/antlr/SequenceAST.ts b/packages/mermaid/src/diagrams/sequence/parser/antlr/SequenceAST.ts new file mode 100644 index 000000000..7dcfbc3cb --- /dev/null +++ b/packages/mermaid/src/diagrams/sequence/parser/antlr/SequenceAST.ts @@ -0,0 +1,279 @@ +import { DiagramAST, DiagramStatement } from './HybridDiagramEditor.js'; + +/** + * Sequence diagram specific AST interfaces + */ + +export interface ParticipantData { + id: string; + alias?: string; + displayName?: string; +} + +export interface MessageData { + from: string; + to: string; + arrow: string; // ->>, -->, ->, etc. + message: string; + activate?: boolean; + deactivate?: boolean; +} + +export interface LoopData { + condition: string; + statements: DiagramStatement[]; +} + +export interface NoteData { + position: 'left' | 'right' | 'over'; + participant: string; + message: string; +} + +export interface ActivateData { + participant: string; +} + +export interface DeactivateData { + participant: string; +} + +export interface AltData { + condition: string; + statements: DiagramStatement[]; + elseStatements?: DiagramStatement[]; +} + +export interface OptData { + condition: string; + statements: DiagramStatement[]; +} + +export interface ParData { + statements: DiagramStatement[][]; +} + +export interface RectData { + color?: string; + statements: DiagramStatement[]; +} + +/** + * Sequence diagram statement types + */ +export type SequenceStatementType = + | 'participant' + | 'message' + | 'note' + | 'activate' + | 'deactivate' + | 'loop' + | 'alt' + | 'opt' + | 'par' + | 'rect' + | 'break' + | 'critical' + | 'autonumber'; + +export interface SequenceStatement extends DiagramStatement { + type: SequenceStatementType; + data: ParticipantData | MessageData | LoopData | NoteData | ActivateData | DeactivateData | AltData | OptData | ParData | RectData; +} + +/** + * Complete sequence diagram AST + */ +export interface SequenceAST extends DiagramAST { + header: 'sequenceDiagram'; + statements: SequenceStatement[]; + metadata?: { + title?: string; + participants?: Map; + theme?: string; + config?: any; + }; +} + +/** + * Helper functions for working with sequence AST + */ +export class SequenceASTHelper { + /** + * Get all participants from the AST + */ + static getParticipants(ast: SequenceAST): ParticipantData[] { + return ast.statements + .filter(stmt => stmt.type === 'participant') + .map(stmt => stmt.data as ParticipantData); + } + + /** + * Get all messages from the AST + */ + static getMessages(ast: SequenceAST): MessageData[] { + return ast.statements + .filter(stmt => stmt.type === 'message') + .map(stmt => stmt.data as MessageData); + } + + /** + * Get all participants mentioned in messages (even if not explicitly declared) + */ + static getAllMentionedParticipants(ast: SequenceAST): Set { + const participants = new Set(); + + // Add explicitly declared participants + this.getParticipants(ast).forEach(p => participants.add(p.id)); + + // Add participants from messages + this.getMessages(ast).forEach(m => { + participants.add(m.from); + participants.add(m.to); + }); + + return participants; + } + + /** + * Find participant by ID + */ + static findParticipant(ast: SequenceAST, id: string): ParticipantData | undefined { + const stmt = ast.statements.find(stmt => + stmt.type === 'participant' && (stmt.data as ParticipantData).id === id + ); + return stmt ? stmt.data as ParticipantData : undefined; + } + + /** + * Get display name for a participant (alias if available, otherwise ID) + */ + static getParticipantDisplayName(ast: SequenceAST, id: string): string { + const participant = this.findParticipant(ast, id); + return participant?.alias || participant?.displayName || id; + } + + /** + * Check if a participant is explicitly declared + */ + static isParticipantDeclared(ast: SequenceAST, id: string): boolean { + return this.findParticipant(ast, id) !== undefined; + } + + /** + * Get the index of the first message involving a participant + */ + static getFirstMessageIndex(ast: SequenceAST, participantId: string): number { + return ast.statements.findIndex(stmt => + stmt.type === 'message' && + ((stmt.data as MessageData).from === participantId || (stmt.data as MessageData).to === participantId) + ); + } + + /** + * Validate AST structure + */ + static validate(ast: SequenceAST): { valid: boolean; errors: string[] } { + const errors: string[] = []; + + // Check for duplicate participant declarations + const participantIds = new Set(); + ast.statements + .filter(stmt => stmt.type === 'participant') + .forEach(stmt => { + const participant = stmt.data as ParticipantData; + if (participantIds.has(participant.id)) { + errors.push(`Duplicate participant declaration: ${participant.id}`); + } + participantIds.add(participant.id); + }); + + // Check for messages with undefined participants + const allMentioned = this.getAllMentionedParticipants(ast); + this.getMessages(ast).forEach(message => { + if (!allMentioned.has(message.from)) { + errors.push(`Message references undefined participant: ${message.from}`); + } + if (!allMentioned.has(message.to)) { + errors.push(`Message references undefined participant: ${message.to}`); + } + }); + + // Check for valid arrow types + const validArrows = ['->', '-->>', '->>', '-->', '-x', '--x', '-)', '--)', '<<->>', '<<-->>']; + this.getMessages(ast).forEach(message => { + if (!validArrows.includes(message.arrow)) { + errors.push(`Invalid arrow type: ${message.arrow}`); + } + }); + + return { + valid: errors.length === 0, + errors + }; + } + + /** + * Get statistics about the AST + */ + static getStatistics(ast: SequenceAST): { + totalStatements: number; + participants: number; + messages: number; + notes: number; + loops: number; + complexity: 'simple' | 'moderate' | 'complex'; + } { + const stats = { + totalStatements: ast.statements.length, + participants: ast.statements.filter(s => s.type === 'participant').length, + messages: ast.statements.filter(s => s.type === 'message').length, + notes: ast.statements.filter(s => s.type === 'note').length, + loops: ast.statements.filter(s => s.type === 'loop').length, + complexity: 'simple' as 'simple' | 'moderate' | 'complex' + }; + + // Determine complexity + if (stats.totalStatements > 50 || stats.loops > 3) { + stats.complexity = 'complex'; + } else if (stats.totalStatements > 20 || stats.loops > 1) { + stats.complexity = 'moderate'; + } + + return stats; + } + + /** + * Create a minimal valid sequence AST + */ + static createEmpty(): SequenceAST { + return { + header: 'sequenceDiagram', + statements: [], + metadata: { + participants: new Map() + } + }; + } + + /** + * Clone an AST (deep copy) + */ + static clone(ast: SequenceAST): SequenceAST { + return { + header: ast.header, + statements: ast.statements.map(stmt => ({ + type: stmt.type, + originalIndex: stmt.originalIndex, + data: { ...stmt.data }, + sourceTokens: stmt.sourceTokens + })), + metadata: ast.metadata ? { + title: ast.metadata.title, + participants: new Map(ast.metadata.participants), + theme: ast.metadata.theme, + config: ast.metadata.config ? { ...ast.metadata.config } : undefined + } : undefined + }; + } +} diff --git a/packages/mermaid/src/diagrams/sequence/parser/antlr/SequenceCodeGenerator.ts b/packages/mermaid/src/diagrams/sequence/parser/antlr/SequenceCodeGenerator.ts new file mode 100644 index 000000000..82efb86db --- /dev/null +++ b/packages/mermaid/src/diagrams/sequence/parser/antlr/SequenceCodeGenerator.ts @@ -0,0 +1,692 @@ +import type { SequenceParserVisitor } from './generated/SequenceParserVisitor.js'; +import { + SequenceAST, + SequenceStatement, + ParticipantData, + MessageData, + NoteData, + SequenceASTHelper, +} from './SequenceAST.js'; + +/** + * AST-to-Code Generator for Sequence Diagrams + * + * This visitor traverses the ANTLR parse tree and reconstructs the original + * sequence diagram code with proper line numbers and formatting. + * + * Main objective: Enable UI editing of rendered diagrams with AST updates + * that can be regenerated back to code. + * + * Now also builds a structured AST for the hybrid editor approach. + */ +export class SequenceCodeGenerator implements SequenceParserVisitor { + private lines: string[] = []; + private currentIndent = 0; + private indentSize = 2; + + // AST building properties + private ast: SequenceAST; + private currentIndex = 0; + + constructor() { + // Initialize with empty lines array + this.lines = []; + // Initialize AST + this.ast = SequenceASTHelper.createEmpty(); + this.currentIndex = 0; + } + + /** + * Generate code from the parse tree + */ + generateCode(tree: any): { code: string; lines: string[]; ast: SequenceAST } { + this.lines = []; + this.currentIndent = 0; + this.ast = SequenceASTHelper.createEmpty(); + this.currentIndex = 0; + + console.log('๐ŸŽฏ Starting code generation with AST building'); + + // Visit the tree to generate code and build AST + this.visit(tree); + + // Join lines and return both full code, line array, and AST + const code = this.lines.join('\n'); + + console.log('โœ… Code generation complete:', { + lines: this.lines.length, + statements: this.ast.statements.length, + }); + + return { + code, + lines: [...this.lines], // Return copy of lines array + ast: this.ast, // Return the built AST + }; + } + + /** + * Get the current AST (for external access) + */ + getAST(): SequenceAST { + return this.ast; + } + + /** + * Add a line with current indentation + */ + private addLine(text: string): void { + const indent = ' '.repeat(this.currentIndent); + this.lines.push(indent + text); + } + + /** + * Add a line without indentation + */ + private addRawLine(text: string): void { + this.lines.push(text); + } + + /** + * Increase indentation level + */ + private indent(): void { + this.currentIndent += this.indentSize; + } + + /** + * Decrease indentation level + */ + private unindent(): void { + this.currentIndent = Math.max(0, this.currentIndent - this.indentSize); + } + + /** + * Extract text from terminal nodes + */ + private getTerminalText(ctx: any): string { + if (!ctx) return ''; + + // If it's a terminal node, return its text + if (ctx.symbol?.text) { + return ctx.symbol.text; + } + + // If it has children, collect text from all terminal children + if (ctx.children) { + return ctx.children + .map((child: any) => this.getTerminalText(child)) + .filter((text: string) => text.trim() !== '') + .join(' '); + } + + return ''; + } + + /** + * Get text content from a context, handling both terminal and non-terminal nodes + */ + private getContextText(ctx: any): string { + if (!ctx) return ''; + + // Use ANTLR's built-in getText() method which is most reliable + if (ctx.getText) { + return ctx.getText(); + } + + return this.getTerminalText(ctx); + } + + /** + * Simple approach: extract all text from the parse tree and reconstruct line by line + * This is more reliable than trying to handle each rule type individually + */ + private extractAllText(ctx: any): string[] { + const lines: string[] = []; + + if (!ctx) return lines; + + // Get the full text content + const fullText = ctx.getText ? ctx.getText() : ''; + + if (fullText) { + // Split by common sequence diagram patterns and clean up + const rawLines = fullText.split(/\n+/); + + for (const line of rawLines) { + const trimmed = line.trim(); + if (trimmed && trimmed !== 'sequenceDiagram') { + lines.push(trimmed); + } + } + } + + return lines; + } + + // Default visit method + visit(tree: any): string { + if (!tree) return ''; + + try { + return tree.accept(this) || ''; + } catch (error) { + console.error('Error visiting node:', error); + return ''; + } + } + + // Default visit methods + visitChildren(node: any): string { + if (!node || !node.children) { + return ''; + } + + const results: string[] = []; + for (const child of node.children) { + const result = child.accept(this); + if (result) { + results.push(result); + } + } + return results.join(' '); + } + + visitTerminal(node: any): string { + return node.symbol?.text || ''; + } + + visitErrorNode(_node: any): string { + return ''; + } + + // Start rule - the root of the parse tree + visitStart(ctx: any): string { + // Proper visitor approach: use the AST structure + console.log('๐ŸŽฏ visitStart: Starting AST traversal'); + + // Add the header + this.addRawLine('sequenceDiagram'); + + // Visit header first (if any) + if (ctx.header?.()) { + this.visit(ctx.header()); + } + + // Visit document content + if (ctx.document?.()) { + this.visit(ctx.document()); + } + + console.log('๐Ÿ“‹ Final generated lines:', this.lines); + return ''; + } + + // Header - handle front matter, comments, etc. + visitHeader(ctx: any): string { + // Process header directives, front matter, etc. + if (ctx.children) { + for (const child of ctx.children) { + const text = this.getContextText(child); + if (text && text.trim() !== '' && text !== '\n') { + this.addRawLine(text); + } + } + } + return ''; + } + + // Document - main content + visitDocument(ctx: any): string { + this.visitChildren(ctx); + return ''; + } + + // Line - individual lines in the document + visitLine(ctx: any): string { + this.visitChildren(ctx); + return ''; + } + + // Statement - individual statements + visitStatement(ctx: any): string { + this.visitChildren(ctx); + return ''; + } + + // Participant statement + visitParticipantStatement(ctx: any): string { + console.log('๐ŸŽฏ visitParticipantStatement:', ctx); + + // Use the simpler approach: get the full text and clean it up + const fullText = ctx.getText ? ctx.getText() : ''; + console.log(' - Full participant text:', fullText); + + if (fullText) { + let id = ''; + let alias = ''; + + // Parse the participant pattern: participant + id + as + alias + const participantMatch = fullText.match(/^participant(\w+)as(.+)$/); + if (participantMatch) { + [, id, alias] = participantMatch; + alias = alias.trim(); + this.addLine(`participant ${id} as ${alias}`); + } else { + // Try simple participant without alias + const simpleMatch = fullText.match(/^participant(\w+)$/); + if (simpleMatch) { + [, id] = simpleMatch; + this.addLine(`participant ${id}`); + } else { + // Fallback: just use the text as-is with proper indentation + this.addLine(fullText); + return ''; + } + } + + // Build AST entry + const participantData: ParticipantData = { id, alias: alias || undefined }; + this.ast.statements.push({ + type: 'participant', + originalIndex: this.currentIndex++, + data: participantData, + sourceTokens: { start: ctx.start, stop: ctx.stop }, + }); + + console.log('๐Ÿ“ Added participant to AST:', participantData); + } + + return ''; + } + + // Create statement + visitCreateStatement(ctx: any): string { + console.log('๐ŸŽฏ visitCreateStatement:', ctx); + const text = this.getContextText(ctx); + this.addLine(text); + return ''; + } + + // Destroy statement + visitDestroyStatement(ctx: any): string { + console.log('๐ŸŽฏ visitDestroyStatement:', ctx); + const text = this.getContextText(ctx); + this.addLine(text); + return ''; + } + + // Signal statement (messages between participants) + visitSignalStatement(ctx: any): string { + console.log('๐ŸŽฏ visitSignalStatement:', ctx); + + // Use the simpler approach: get the full text and clean it up + const fullText = ctx.getText ? ctx.getText() : ''; + console.log(' - Full signal text:', fullText); + + if (fullText) { + // Parse the signal pattern: from + arrow + to + : + message + const signalMatch = fullText.match(/^(\w+)(->|-->>|->>|-->)(\w+):(.+)$/); + if (signalMatch) { + const [, from, arrow, to, message] = signalMatch; + const cleanMessage = message.trim(); + this.addLine(`${from}${arrow}${to}: ${cleanMessage}`); + + // Build AST entry + const messageData: MessageData = { from, arrow, to, message: cleanMessage }; + this.ast.statements.push({ + type: 'message', + originalIndex: this.currentIndex++, + data: messageData, + sourceTokens: { start: ctx.start, stop: ctx.stop }, + }); + + console.log('๐Ÿ“ Added message to AST:', messageData); + } else { + // Fallback: just use the text as-is with proper indentation + this.addLine(fullText); + } + } + + return ''; + } + + // Note statement + visitNoteStatement(ctx: any): string { + console.log('๐ŸŽฏ visitNoteStatement:', ctx); + + // Use the simpler approach: get the full text and clean it up + const fullText = ctx.getText ? ctx.getText() : ''; + console.log(' - Full note text:', fullText); + + if (fullText) { + // Parse the note pattern: Note + position + of + participant + : + message + const noteMatch = fullText.match(/^Note(left|right|over)of(\w+):(.+)$/); + if (noteMatch) { + const [, position, participant, message] = noteMatch; + this.addLine(`Note ${position} of ${participant}: ${message.trim()}`); + } else { + // Fallback: just use the text as-is with proper indentation + this.addLine(fullText); + } + } + + return ''; + } + + // Loop block + visitLoopBlock(ctx: any): string { + console.log('๐ŸŽฏ visitLoopBlock:', ctx); + + // Use the simpler approach: get the full text and extract loop condition + const fullText = ctx.getText ? ctx.getText() : ''; + console.log(' - Full loop text:', fullText); + + if (fullText) { + // Extract the loop condition - everything between "loop" and the first statement + const loopMatch = fullText.match(/^loop([^]*?)(?=\w+(?:->|-->>|->>|-->)|$)/); + if (loopMatch) { + const condition = loopMatch[1].trim(); + this.addLine(`loop ${condition}`); + } else { + this.addLine('loop'); + } + } + + this.indent(); + + // Visit children (content inside loop) + this.visitChildren(ctx); + + this.unindent(); + this.addLine('end'); + return ''; + } + + // Opt block + visitOptBlock(ctx: any): string { + const optText = this.getContextText(ctx); + const optMatch = optText.match(/opt\s+(.+?)(?=\s|$)/); + const condition = optMatch ? optMatch[1] : ''; + + this.addLine(`opt ${condition}`); + this.indent(); + + this.visitChildren(ctx); + + this.unindent(); + this.addLine('end'); + return ''; + } + + // Alt block + visitAltBlock(ctx: any): string { + const altText = this.getContextText(ctx); + const altMatch = altText.match(/alt\s+(.+?)(?=\s|$)/); + const condition = altMatch ? altMatch[1] : ''; + + this.addLine(`alt ${condition}`); + this.indent(); + + this.visitChildren(ctx); + + this.unindent(); + this.addLine('end'); + return ''; + } + + // Else section within alt block + visitElseSection(ctx: any): string { + this.unindent(); + + const elseText = this.getContextText(ctx); + const elseMatch = elseText.match(/else\s+(.+?)(?=\s|$)/); + const condition = elseMatch ? elseMatch[1] : ''; + + this.addLine(`else ${condition}`); + this.indent(); + + this.visitChildren(ctx); + return ''; + } + + // Par block + visitParBlock(ctx: any): string { + const parText = this.getContextText(ctx); + const parMatch = parText.match(/par\s+(.+?)(?=\s|$)/); + const condition = parMatch ? parMatch[1] : ''; + + this.addLine(`par ${condition}`); + this.indent(); + + this.visitChildren(ctx); + + this.unindent(); + this.addLine('end'); + return ''; + } + + // And section within par block + visitAndSection(ctx: any): string { + this.unindent(); + + const andText = this.getContextText(ctx); + const andMatch = andText.match(/and\s+(.+?)(?=\s|$)/); + const condition = andMatch ? andMatch[1] : ''; + + this.addLine(`and ${condition}`); + this.indent(); + + this.visitChildren(ctx); + return ''; + } + + // Rect block + visitRectBlock(ctx: any): string { + const rectText = this.getContextText(ctx); + const rectMatch = rectText.match(/rect\s+(.+?)(?=\s|$)/); + const style = rectMatch ? rectMatch[1] : ''; + + this.addLine(`rect ${style}`); + this.indent(); + + this.visitChildren(ctx); + + this.unindent(); + this.addLine('end'); + return ''; + } + + // Box block + visitBoxBlock(ctx: any): string { + const boxText = this.getContextText(ctx); + const boxMatch = boxText.match(/box\s+(.+?)(?=\s|$)/); + const label = boxMatch ? boxMatch[1] : ''; + + this.addLine(`box ${label}`); + this.indent(); + + this.visitChildren(ctx); + + this.unindent(); + this.addLine('end'); + return ''; + } + + // Break block + visitBreakBlock(ctx: any): string { + const breakText = this.getContextText(ctx); + const breakMatch = breakText.match(/break\s+(.+?)(?=\s|$)/); + const condition = breakMatch ? breakMatch[1] : ''; + + this.addLine(`break ${condition}`); + this.indent(); + + this.visitChildren(ctx); + + this.unindent(); + this.addLine('end'); + return ''; + } + + // Critical block + visitCriticalBlock(ctx: any): string { + const criticalText = this.getContextText(ctx); + const criticalMatch = criticalText.match(/critical\s+(.+?)(?=\s|$)/); + const condition = criticalMatch ? criticalMatch[1] : ''; + + this.addLine(`critical ${condition}`); + this.indent(); + + this.visitChildren(ctx); + + this.unindent(); + this.addLine('end'); + return ''; + } + + // Option section within critical block + visitOptionSection(ctx: any): string { + this.unindent(); + + const optionText = this.getContextText(ctx); + const optionMatch = optionText.match(/option\s+(.+?)(?=\s|$)/); + const condition = optionMatch ? optionMatch[1] : ''; + + this.addLine(`option ${condition}`); + this.indent(); + + this.visitChildren(ctx); + return ''; + } + + // ParOver block + visitParOverBlock(ctx: any): string { + const parOverText = this.getContextText(ctx); + const parOverMatch = parOverText.match(/par\s+over\s+(.+?)(?=\s|$)/); + const participants = parOverMatch ? parOverMatch[1] : ''; + + this.addLine(`par over ${participants}`); + this.indent(); + + this.visitChildren(ctx); + + this.unindent(); + this.addLine('end'); + return ''; + } + + // Links statement + visitLinksStatement(ctx: any): string { + const text = this.getContextText(ctx); + this.addLine(text); + return ''; + } + + // Link statement + visitLinkStatement(ctx: any): string { + const text = this.getContextText(ctx); + this.addLine(text); + return ''; + } + + // Properties statement + visitPropertiesStatement(ctx: any): string { + const text = this.getContextText(ctx); + this.addLine(text); + return ''; + } + + // Details statement + visitDetailsStatement(ctx: any): string { + const text = this.getContextText(ctx); + this.addLine(text); + return ''; + } + + // Activation statement (activate/deactivate) + visitActivationStatement(ctx: any): string { + const text = this.getContextText(ctx); + this.addLine(text); + return ''; + } + + // Autonumber statement + visitAutonumberStatement(ctx: any): string { + const text = this.getContextText(ctx); + this.addLine(text); + return ''; + } + + // Title statement + visitTitleStatement(ctx: any): string { + const text = this.getContextText(ctx); + this.addLine(text); + return ''; + } + + // Legacy title statement + visitLegacyTitleStatement(ctx: any): string { + const text = this.getContextText(ctx); + this.addLine(text); + return ''; + } + + // Accessibility title statement + visitAccTitleStatement(ctx: any): string { + const text = this.getContextText(ctx); + this.addLine(text); + return ''; + } + + // Accessibility description statement + visitAccDescrStatement(ctx: any): string { + const text = this.getContextText(ctx); + this.addLine(text); + return ''; + } + + // Accessibility multiline description statement + visitAccDescrMultilineStatement(ctx: any): string { + const text = this.getContextText(ctx); + this.addLine(text); + return ''; + } + + // Additional visitor methods for completeness + visitActorWithConfig(ctx: any): string { + return this.visitChildren(ctx); + } + + visitConfigObject(ctx: any): string { + return this.visitChildren(ctx); + } + + visitSignaltype(ctx: any): string { + return this.visitChildren(ctx); + } + + visitText2(ctx: any): string { + return this.visitChildren(ctx); + } + + visitRestOfLine(ctx: any): string { + return this.visitChildren(ctx); + } + + visitAltSections(ctx: any): string { + return this.visitChildren(ctx); + } + + visitParSections(ctx: any): string { + return this.visitChildren(ctx); + } + + visitOptionSections(ctx: any): string { + return this.visitChildren(ctx); + } + + visitActor(ctx: any): string { + return this.visitChildren(ctx); + } +} diff --git a/packages/mermaid/src/diagrams/sequence/parser/antlr/antlr-parser.ts b/packages/mermaid/src/diagrams/sequence/parser/antlr/antlr-parser.ts index bcc5f5503..381698a72 100644 --- a/packages/mermaid/src/diagrams/sequence/parser/antlr/antlr-parser.ts +++ b/packages/mermaid/src/diagrams/sequence/parser/antlr/antlr-parser.ts @@ -12,6 +12,8 @@ import { SequenceLexer } from './generated/SequenceLexer.js'; import { SequenceParser } from './generated/SequenceParser.js'; import { SequenceListener } from './SequenceListener.js'; import { SequenceVisitor } from './SequenceVisitor.js'; +import { SequenceCodeGenerator } from './SequenceCodeGenerator.js'; +import { TokenStreamRewriter } from 'antlr4ng'; /** * Main ANTLR parser class that provides the same interface as the Jison parser @@ -190,6 +192,49 @@ export class ANTLRSequenceParser { // eslint-disable-next-line no-console console.log('โœ… ANTLR Sequence Parser: Parse completed successfully'); } + // Store the parse tree for AST-to-code regeneration + this.yy._parseTree = tree; + + // Build and store the AST during initial parsing + const astBuildStart = performance.now(); + if (shouldLog) { + // eslint-disable-next-line no-console + console.log('๐ŸŒณ ANTLR Sequence Parser: Building AST from parse tree'); + } + + try { + // Store the original input and token stream for formatting preservation + this.yy._originalInput = input; + this.yy._tokenStream = tokenStream; + this.yy._tokenRewriter = new TokenStreamRewriter(tokenStream); + this.yy._tokenMap = new Map(); // Map statement indices to token positions + + const generator = new SequenceCodeGenerator(); + const result = generator.generateCode(tree); + + // Store the AST in the parser state + this.yy._ast = result.ast; + this.yy._generatedCode = result.code; + this.yy._generatedLines = result.lines; + + // Store original AST for change detection (only during initial parsing) + this.yy._originalAST = this.createSafeASTCopy(result.ast); + + const astBuildTime = performance.now() - astBuildStart; + if (shouldLog) { + // eslint-disable-next-line no-console + console.log(`โฑ๏ธ AST building took: ${astBuildTime.toFixed(2)}ms`); + // eslint-disable-next-line no-console + console.log(`๐ŸŒณ AST built with ${result.ast.statements.length} statements`); + // eslint-disable-next-line no-console + console.log('โœ… ANTLR Sequence Parser: AST built successfully'); + } + } catch (error) { + // eslint-disable-next-line no-console + console.error('โŒ ANTLR Sequence Parser: AST building failed:', error.message); + // Don't throw - AST building is optional, parsing should still succeed + } + return this.yy; } catch (error) { const totalTime = performance.now() - startTime; @@ -205,8 +250,485 @@ export class ANTLRSequenceParser { setYY(yy: any) { this.yy = yy; } + + /** + * Get the AST that was built during parsing + * This provides immediate access to structured data without regeneration + */ + getAST(): any | null { + return this.yy._ast || null; + } + + /** + * Get the generated code that was built during parsing + */ + getGeneratedCode(): string | null { + return this.yy._generatedCode || null; + } + + /** + * Get the generated lines that were built during parsing + */ + getGeneratedLines(): string[] | null { + return this.yy._generatedLines || null; + } + + /** + * Regenerate code preserving original formatting using smart formatting preservation + * This is the "hybrid" approach that maintains whitespace and indentation + */ + regenerateCodeWithFormatting(): string | null { + if (!this.yy._originalInput) { + console.warn('โš ๏ธ Original input not available - falling back to AST regeneration'); + const astResult = this.generateCodeFromAST(); + return astResult ? astResult.code : null; + } + + try { + // Check if AST has been modified since initial parsing + const currentAST = this.yy._ast; + const hasASTChanges = this.detectASTChanges(currentAST); + + if (hasASTChanges) { + console.log( + '๐Ÿ”„ AST changes detected, applying selective updates with formatting preservation' + ); + return this.applyASTChangesWithFormatting(currentAST); + } else { + console.log( + 'โœ… No AST changes detected, returning original input with preserved formatting' + ); + return this.yy._originalInput; + } + } catch (error) { + console.error('โŒ Error regenerating code with formatting:', error); + // Fallback to AST regeneration + const astResult = this.generateCodeFromAST(); + return astResult ? astResult.code : null; + } + } + + /** + * Detect if the AST has been modified since initial parsing + * Uses safe comparison that avoids circular reference issues + */ + private detectASTChanges(currentAST: any): boolean { + if (!this.yy._originalAST) { + console.warn('โš ๏ธ No original AST stored for comparison - assuming changes detected'); + return true; // If no original AST, assume changes to trigger regeneration + } + + // Compare safe representations to detect changes + const originalSafe = this.createSafeASTCopy(this.yy._originalAST); + const currentSafe = this.createSafeASTCopy(currentAST); + + try { + const originalJSON = JSON.stringify(originalSafe); + const currentJSON = JSON.stringify(currentSafe); + return originalJSON !== currentJSON; + } catch (error) { + console.warn('โš ๏ธ AST comparison failed, assuming changes detected:', error); + return true; // Assume changes if comparison fails + } + } + + /** + * Create a safe copy of AST that can be JSON.stringify'd + * Removes circular references and focuses on the data we care about + */ + private createSafeASTCopy(ast: any): any { + if (!ast || !ast.statements) { + return { statements: [] }; + } + + return { + statements: ast.statements.map((stmt: any) => ({ + type: stmt.type, + originalIndex: stmt.originalIndex, + // Handle both direct properties and data object structure + data: stmt.data + ? { + from: stmt.data.from, + to: stmt.data.to, + message: stmt.data.message, + arrow: stmt.data.arrow, + participant: stmt.data.participant, + id: stmt.data.id, + alias: stmt.data.alias, + position: stmt.data.position, + } + : undefined, + // Legacy direct properties (fallback) + from: stmt.from, + to: stmt.to, + message: stmt.message, + arrow: stmt.arrow, + })), + }; + } + + /** + * Apply AST changes while preserving original formatting using TokenStreamRewriter + * This is where the real hybrid magic happens! + */ + private applyASTChangesWithFormatting(currentAST: any): string { + if (!this.yy._tokenRewriter || !this.yy._originalAST) { + console.log('๐Ÿšง TokenStreamRewriter not available, falling back to AST regeneration'); + const astResult = this.generateCodeFromModifiedAST(currentAST); + return astResult ? astResult.code : this.yy._originalInput; + } + + try { + console.log('๐ŸŽฏ Using TokenStreamRewriter for surgical edits...'); + + // Find specific changes between original and current AST + const changes = this.detectSpecificChanges(this.yy._originalAST, currentAST); + + if (changes.length === 0) { + console.log('โœ… No specific changes detected, returning original input'); + return this.yy._originalInput; + } + + console.log(`๐Ÿ”ง Applying ${changes.length} surgical change(s)...`); + + // Apply each change using surgical string replacement + changes.forEach((change, index) => { + if (change.type === 'add') { + console.log( + ` ${index + 1}. ${change.type}: Added "${change.statement?.type}" statement` + ); + } else { + console.log( + ` ${index + 1}. ${change.type}: "${change.oldValue}" โ†’ "${change.newValue}"` + ); + } + this.applyTokenChange(change); + }); + + // Return the surgically modified original input + console.log('โœ… Surgical edits applied successfully'); + console.log('๐Ÿ“ Modified text:', this.yy._originalInput); + + return this.yy._originalInput; + } catch (error) { + console.warn('โš ๏ธ TokenStreamRewriter failed, falling back to AST regeneration:', error); + const astResult = this.generateCodeFromModifiedAST(currentAST); + return astResult ? astResult.code : this.yy._originalInput; + } + } + + /** + * Detect specific changes between original and current AST + * Returns an array of change objects that can be applied surgically + */ + private detectSpecificChanges(originalAST: any, currentAST: any): any[] { + const changes: any[] = []; + + if (!originalAST?.statements || !currentAST?.statements) { + return changes; + } + + // Compare statements by originalIndex to detect changes + const originalMap = new Map(); + originalAST.statements.forEach((stmt: any) => { + originalMap.set(stmt.originalIndex, stmt); + }); + + currentAST.statements.forEach((currentStmt: any) => { + const originalStmt = originalMap.get(currentStmt.originalIndex); + if (!originalStmt) { + // New statement added + changes.push({ + type: 'add', + statementIndex: currentStmt.originalIndex, + statement: currentStmt, + }); + return; + } + + // Check for message changes in existing statements + if (currentStmt.type === 'message' && originalStmt.type === 'message') { + const currentData = currentStmt.data || currentStmt; + const originalData = originalStmt.data || originalStmt; + + if (currentData.message !== originalData.message) { + changes.push({ + type: 'message_change', + statementIndex: currentStmt.originalIndex, + oldValue: originalData.message, + newValue: currentData.message, + statement: currentStmt, + originalStatement: originalStmt, + }); + } + } + }); + + return changes; + } + + /** + * Apply a specific change using string-based replacement + * This performs surgical edits preserving original formatting + */ + private applyTokenChange(change: any): void { + if (!this.yy._originalInput) { + throw new Error('Original input not available for surgical edits'); + } + + try { + if (change.type === 'message_change') { + // Find the message location in the original input + const messageTokens = this.findMessageTokensForStatement(change.statementIndex); + + if (messageTokens && messageTokens.length > 0) { + const token = messageTokens[0]; + + console.log( + `๐Ÿ”ง Replacing message at line ${token.lineIndex}, pos ${token.messageStart}-${token.messageEnd}: "${token.originalText}" โ†’ "${change.newValue}"` + ); + + // Perform string-based replacement preserving formatting + const lines = this.yy._originalInput.split('\n'); + const targetLine = lines[token.lineIndex]; + + // Replace only the message part, preserving everything else + const newLine = + targetLine.substring(0, token.messageStart) + + change.newValue + + targetLine.substring(token.messageEnd); + + lines[token.lineIndex] = newLine; + + // Update the original input with the surgical change + this.yy._originalInput = lines.join('\n'); + + console.log(`โœ… Surgical edit applied successfully`); + } else { + console.warn(`โš ๏ธ Could not find message tokens for statement ${change.statementIndex}`); + throw new Error('Message tokens not found'); + } + } else if (change.type === 'add') { + // For additions (like participants), surgical editing is complex + // Fall back to full AST regeneration for now + console.log(`๐Ÿ”„ Addition detected, falling back to full AST regeneration`); + throw new Error('Addition requires full AST regeneration'); + } else { + console.warn(`โš ๏ธ Unsupported change type: ${change.type}`); + throw new Error(`Unsupported change type: ${change.type}`); + } + } catch (error) { + console.error('โŒ Error applying surgical change:', error); + throw error; + } + } + + /** + * Find the message tokens for a specific statement in the parse tree + * This maps AST statements back to their original tokens + */ + private findMessageTokensForStatement(statementIndex: number): any[] | null { + // For now, use a simpler approach: find the message text in the original input + // and create pseudo-tokens for replacement + + if (!this.yy._originalInput || !this.yy._originalAST) { + return null; + } + + try { + // Find the original statement + const originalStmt = this.yy._originalAST.statements.find( + (stmt: any) => stmt.originalIndex === statementIndex + ); + + if (!originalStmt || originalStmt.type !== 'message') { + return null; + } + + const originalData = originalStmt.data || originalStmt; + const originalMessage = originalData.message; + + if (!originalMessage) { + return null; + } + + // Find the message text in the original input + const lines = this.yy._originalInput.split('\n'); + let lineIndex = -1; + let messageStart = -1; + let messageEnd = -1; + + // Look for the message in each line + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const messageIndex = line.indexOf(originalMessage); + + if (messageIndex !== -1) { + // Check if this looks like a sequence diagram message line + const beforeMessage = line.substring(0, messageIndex); + if (beforeMessage.includes('>>') || beforeMessage.includes('->')) { + lineIndex = i; + messageStart = messageIndex; + messageEnd = messageIndex + originalMessage.length; + break; + } + } + } + + if (lineIndex === -1) { + console.log(`๐Ÿ” Could not find message "${originalMessage}" in original input`); + return null; + } + + // Create pseudo-tokens for the message text + return [ + { + lineIndex, + messageStart, + messageEnd, + originalText: originalMessage, + }, + ]; + } catch (error) { + console.error('โŒ Error finding message tokens:', error); + return null; + } + } + + /** + * Generate code directly from a modified AST structure + * This bypasses the parse tree and works directly with AST data + */ + private generateCodeFromModifiedAST( + ast: any + ): { code: string; lines: string[]; ast: any } | null { + if (!ast || !ast.statements) { + console.warn('โš ๏ธ No AST statements available for code generation'); + return null; + } + + try { + // Generate code directly from AST statements + const lines: string[] = ['sequenceDiagram']; + + // Sort statements by originalIndex to maintain proper order + const sortedStatements = [...ast.statements].sort((a, b) => { + const aIndex = a.originalIndex ?? 999; + const bIndex = b.originalIndex ?? 999; + return aIndex - bIndex; + }); + + console.log('๐Ÿ” Debug: Processing statements for code generation:'); + console.log('๐Ÿ“Š Total statements:', ast.statements.length); + sortedStatements.forEach((stmt, index) => { + console.log( + ` ${index + 1}. Type: ${stmt.type}, OriginalIndex: ${stmt.originalIndex}, Data:`, + stmt.data || 'N/A' + ); + }); + + // Process each statement in the AST + sortedStatements.forEach((stmt: any) => { + if (stmt.type === 'message') { + const data = stmt.data || stmt; // Handle both data object and direct properties + const from = data.from || ''; + const to = data.to || ''; + const message = data.message || ''; + const arrow = data.arrow || '->>'; + + // Generate the message line + const messageLine = `${from}${arrow}${to}: ${message}`; + lines.push(messageLine); + } else if (stmt.type === 'participant') { + const data = stmt.data || stmt; + const id = data.id || data.participant || ''; + const alias = data.alias || ''; + + // Generate participant line with optional alias + if (alias) { + lines.push(`participant ${id} as "${alias}"`); + } else { + lines.push(`participant ${id}`); + } + } + // Add more statement types as needed + }); + + const code = lines.join('\n'); + + console.log('โœ… Code generated from modified AST:'); + console.log('๐Ÿ“ Generated code:', code); + console.log('๐Ÿ“‹ Generated lines:', lines); + console.log('๐ŸŒณ AST statements:', ast.statements.length); + + return { + code, + lines, + ast, + }; + } catch (error) { + console.error('โŒ Error generating code from modified AST:', error); + return null; + } + } + + /** + * Generate code from the stored parse tree + * This enables AST-to-code regeneration for UI editing scenarios + * Now also returns the structured AST for hybrid editing + */ + generateCodeFromAST(): { code: string; lines: string[]; ast: any } | null { + if (!this.yy._parseTree) { + console.warn('โš ๏ธ No parse tree available for code generation'); + return null; + } + + try { + const generator = new SequenceCodeGenerator(); + const result = generator.generateCode(this.yy._parseTree); + + console.log('โœ… Code generated from AST:'); + console.log('๐Ÿ“ Generated code:', result.code); + console.log('๐Ÿ“‹ Generated lines:', result.lines); + console.log('๐ŸŒณ AST statements:', result.ast.statements.length); + + return { + code: result.code, + lines: result.lines, + ast: result.ast, + }; + } catch (error) { + console.error('โŒ Error generating code from AST:', error); + return null; + } + } } // Export for compatibility with existing code export const parser = new ANTLRSequenceParser(); + +/** + * Helper function to create ANTLR parser components for hybrid editor + */ +export function createSequenceParser(input: string): { + parser: SequenceParser; + tokenStream: CommonTokenStream; +} { + console.log('๐Ÿ”ง Creating ANTLR sequence parser components'); + + // Create lexer + const inputStream = CharStream.fromString(input); + const lexer = new SequenceLexer(inputStream); + + // Create token stream + const tokenStream = new CommonTokenStream(lexer); + + // Create parser + const parser = new SequenceParser(tokenStream); + + // Configure error handling - remove default error listeners for cleaner output + parser.removeErrorListeners(); + + return { parser, tokenStream }; +} export default parser; diff --git a/packages/mermaid/src/diagrams/sequence/parser/sequenceParser.ts b/packages/mermaid/src/diagrams/sequence/parser/sequenceParser.ts index 3f4cbcb3c..d905fb9f1 100644 --- a/packages/mermaid/src/diagrams/sequence/parser/sequenceParser.ts +++ b/packages/mermaid/src/diagrams/sequence/parser/sequenceParser.ts @@ -64,6 +64,50 @@ const newParser = { return jisonParser.parse(newSrc); } }, + // Expose AST-to-code generation functionality for browser access + generateCodeFromAST: () => { + if (USE_ANTLR_PARSER && antlrParser.generateCodeFromAST) { + return antlrParser.generateCodeFromAST(); + } + console.warn('โš ๏ธ AST-to-code generation only available with ANTLR parser'); + return null; + }, + // Expose individual AST access methods for browser access + getAST: () => { + if (USE_ANTLR_PARSER && antlrParser.getAST) { + return antlrParser.getAST(); + } + console.warn('โš ๏ธ AST access only available with ANTLR parser'); + return null; + }, + getGeneratedCode: () => { + if (USE_ANTLR_PARSER && antlrParser.getGeneratedCode) { + return antlrParser.getGeneratedCode(); + } + console.warn('โš ๏ธ Generated code access only available with ANTLR parser'); + return null; + }, + getGeneratedLines: () => { + if (USE_ANTLR_PARSER && antlrParser.getGeneratedLines) { + return antlrParser.getGeneratedLines(); + } + console.warn('โš ๏ธ Generated lines access only available with ANTLR parser'); + return null; + }, + // Expose formatting-preserving regeneration method + regenerateCodeWithFormatting: () => { + if (USE_ANTLR_PARSER && antlrParser.regenerateCodeWithFormatting) { + return antlrParser.regenerateCodeWithFormatting(); + } + console.warn('โš ๏ธ Formatting-preserving regeneration only available with ANTLR parser'); + return null; + }, }; +// Expose parser globally for browser access (for AST regeneration testing) +if (typeof window !== 'undefined') { + (window as any).MERMAID_SEQUENCE_PARSER = newParser; + console.log('๐ŸŒ Sequence parser exposed globally as window.MERMAID_SEQUENCE_PARSER'); +} + export default newParser;