diff --git a/.esbuild/server.ts b/.esbuild/server.ts index 6e1bcb460..e041effcd 100644 --- a/.esbuild/server.ts +++ b/.esbuild/server.ts @@ -4,6 +4,7 @@ import cors from 'cors'; import { context } from 'esbuild'; import type { Request, Response } from 'express'; import express from 'express'; +import { execSync } from 'child_process'; import { packageOptions } from '../.build/common.js'; import { generateLangium } from '../.build/generateLangium.js'; import { defaultOptions, getBuildConfig } from './util.js'; @@ -64,6 +65,28 @@ function eventsHandler(request: Request, response: Response) { } let timeoutID: NodeJS.Timeout | undefined = undefined; +let isGeneratingAntlr = false; + +/** + * Generate ANTLR parser files from grammar files + */ +function generateAntlr(): void { + if (isGeneratingAntlr) { + console.log('โณ ANTLR generation already in progress, skipping...'); + return; + } + + try { + isGeneratingAntlr = true; + console.log('๐ŸŽฏ ANTLR: Generating parser files...'); + execSync('tsx scripts/antlr-generate.mts', { stdio: 'inherit' }); + console.log('โœ… ANTLR: Parser files generated successfully\n'); + } catch (error) { + console.error('โŒ ANTLR: Failed to generate parser files:', error); + } finally { + isGeneratingAntlr = false; + } +} /** * Debounce file change events to avoid rebuilding multiple times. @@ -89,7 +112,7 @@ async function createServer() { handleFileChange(); const app = express(); chokidar - .watch('**/src/**/*.{js,ts,langium,yaml,json}', { + .watch('**/src/**/*.{js,ts,g4,langium,yaml,json}', { ignoreInitial: true, ignored: [/node_modules/, /dist/, /docs/, /coverage/], }) @@ -103,6 +126,9 @@ async function createServer() { if (path.endsWith('.langium')) { await generateLangium(); } + if (path.endsWith('.g4')) { + generateAntlr(); + } handleFileChange(); }); diff --git a/package.json b/package.json index e970240d1..e42f4367b 100644 --- a/package.json +++ b/package.json @@ -15,8 +15,10 @@ "git graph" ], "scripts": { - "build": "pnpm build:esbuild && pnpm build:types", + "build": "pnpm antlr:generate && pnpm build:esbuild && pnpm build:types", "build:esbuild": "pnpm run -r clean && tsx .esbuild/build.ts", + "antlr:generate": "tsx scripts/antlr-generate.mts", + "antlr:watch": "tsx scripts/antlr-watch.mts", "build:mermaid": "pnpm build:esbuild --mermaid", "build:viz": "pnpm build:esbuild --visualize", "build:types": "pnpm --filter mermaid types:build-config && tsx .build/types.ts", diff --git a/packages/mermaid/package.json b/packages/mermaid/package.json index 7bacb71bb..73e281d04 100644 --- a/packages/mermaid/package.json +++ b/packages/mermaid/package.json @@ -34,6 +34,8 @@ "scripts": { "clean": "rimraf dist", "dev": "pnpm -w dev", + "antlr:generate": "tsx ../../scripts/antlr-generate.mts", + "antlr:watch": "tsx ../../scripts/antlr-watch.mts", "docs:code": "typedoc src/defaultConfig.ts src/config.ts src/mermaid.ts && prettier --write ./src/docs/config/setup", "docs:build": "rimraf ../../docs && pnpm docs:code && pnpm docs:spellcheck && tsx scripts/docs.cli.mts", "docs:verify": "pnpm docs:code && pnpm docs:spellcheck && tsx scripts/docs.cli.mts --verify", @@ -71,6 +73,7 @@ "@iconify/utils": "^3.0.2", "@mermaid-js/parser": "workspace:^", "@types/d3": "^7.4.3", + "antlr4ng": "^3.0.7", "cytoscape": "^3.33.1", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", diff --git a/packages/mermaid/src/diagrams/usecase/parser/antlr/UsecaseErrorListener.ts b/packages/mermaid/src/diagrams/usecase/parser/antlr/UsecaseErrorListener.ts new file mode 100644 index 000000000..578d7103a --- /dev/null +++ b/packages/mermaid/src/diagrams/usecase/parser/antlr/UsecaseErrorListener.ts @@ -0,0 +1,90 @@ +import { BaseErrorListener } from 'antlr4ng'; +import type { RecognitionException, Recognizer } from 'antlr4ng'; + +/** + * Custom error listener for ANTLR usecase parser + * Captures syntax errors and provides detailed error messages + */ +export class UsecaseErrorListener extends BaseErrorListener { + private errors: { line: number; column: number; message: string; offendingSymbol?: any }[] = []; + + syntaxError( + _recognizer: Recognizer, + offendingSymbol: any, + line: number, + charPositionInLine: number, + message: string, + _e: RecognitionException | null + ): void { + this.errors.push({ + line, + column: charPositionInLine, + message, + offendingSymbol, + }); + } + + reportAmbiguity(): void { + // Optional: handle ambiguity reports + } + + reportAttemptingFullContext(): void { + // Optional: handle full context attempts + } + + reportContextSensitivity(): void { + // Optional: handle context sensitivity reports + } + + getErrors(): { line: number; column: number; message: string; offendingSymbol?: any }[] { + return this.errors; + } + + hasErrors(): boolean { + return this.errors.length > 0; + } + + clear(): void { + this.errors = []; + } + + /** + * Create a detailed error with JISON-compatible hash property + */ + createDetailedError(): Error { + if (this.errors.length === 0) { + return new Error('Unknown parsing error'); + } + + const firstError = this.errors[0]; + const message = `Parse error on line ${firstError.line}: ${firstError.message}`; + const error = new Error(message); + + // Add hash property for JISON compatibility + Object.assign(error, { + hash: { + line: firstError.line, + loc: { + first_line: firstError.line, + last_line: firstError.line, + first_column: firstError.column, + last_column: firstError.column, + }, + text: firstError.offendingSymbol?.text ?? '', + token: firstError.offendingSymbol?.text ?? '', + expected: [], + }, + }); + + return error; + } + + /** + * Get all error messages as a single string + */ + getErrorMessages(): string { + return this.errors + .map((error) => `Line ${error.line}:${error.column} - ${error.message}`) + .join('\n'); + } +} diff --git a/packages/mermaid/src/diagrams/usecase/parser/antlr/UsecaseLexer.g4 b/packages/mermaid/src/diagrams/usecase/parser/antlr/UsecaseLexer.g4 new file mode 100644 index 000000000..924e032a8 --- /dev/null +++ b/packages/mermaid/src/diagrams/usecase/parser/antlr/UsecaseLexer.g4 @@ -0,0 +1,65 @@ +lexer grammar UsecaseLexer; + +// Keywords +ACTOR: 'actor'; +SYSTEM_BOUNDARY: 'systemBoundary'; +END: 'end'; +DIRECTION: 'direction'; +CLASS_DEF: 'classDef'; +CLASS: 'class'; +STYLE: 'style'; +USECASE: 'usecase'; + +// Direction keywords +TB: 'TB'; +TD: 'TD'; +BT: 'BT'; +RL: 'RL'; +LR: 'LR'; + +// System boundary types +PACKAGE: 'package'; +RECT: 'rect'; +TYPE: 'type'; + +// Arrow types (order matters - longer patterns first) +SOLID_ARROW: '-->'; +BACK_ARROW: '<--'; +CIRCLE_ARROW: '--o'; +CIRCLE_ARROW_REVERSED: 'o--'; +CROSS_ARROW: '--x'; +CROSS_ARROW_REVERSED: 'x--'; +LINE_SOLID: '--'; + +// Symbols +COMMA: ','; +AT: '@'; +LBRACE: '{'; +RBRACE: '}'; +COLON: ':'; +LPAREN: '('; +RPAREN: ')'; +CLASS_SEPARATOR: ':::'; + +// Hash color (must come before HASH to avoid conflicts) +HASH_COLOR: '#' [a-fA-F0-9]+; + +// Number with optional unit +NUMBER: [0-9]+ ('.' [0-9]+)? ([a-zA-Z]+)?; + +// Identifier +IDENTIFIER: [a-zA-Z_][a-zA-Z0-9_]*; + +// String literals +STRING: '"' (~["\r\n])* '"' | '\'' (~['\r\n])* '\''; + +// These tokens are defined last so they have lowest priority +// This ensures arrow tokens like '-->' are matched before DASH +DASH: '-'; +DOT: '.'; +PERCENT: '%'; + +// Whitespace and newlines +NEWLINE: [\r\n]+; +WS: [ \t]+ -> skip; + diff --git a/packages/mermaid/src/diagrams/usecase/parser/antlr/UsecaseListener.ts b/packages/mermaid/src/diagrams/usecase/parser/antlr/UsecaseListener.ts new file mode 100644 index 000000000..9b9aa345a --- /dev/null +++ b/packages/mermaid/src/diagrams/usecase/parser/antlr/UsecaseListener.ts @@ -0,0 +1,429 @@ +import type { ParseTreeListener } from 'antlr4ng'; +import { UsecaseParserCore } from './UsecaseParserCore.js'; +import { log } from '../../../../logger.js'; +import type { UsecaseDB } from '../../usecaseTypes.js'; + +/** + * Listener implementation that builds the usecase model + * Extends the core logic to ensure consistency with Visitor pattern + */ +export class UsecaseListener extends UsecaseParserCore implements ParseTreeListener { + constructor(db: UsecaseDB) { + super(db); + log.debug('๐Ÿ‘‚ UsecaseListener: Constructor called'); + } + + // Standard ParseTreeListener methods + enterEveryRule = (ctx: any) => { + if (this.getEnvVar('ANTLR_DEBUG') === 'true') { + const ruleName = ctx.constructor.name; + log.debug('๐Ÿ” UsecaseListener: Entering rule:', ruleName); + } + }; + + exitEveryRule = (ctx: any) => { + if (this.getEnvVar('ANTLR_DEBUG') === 'true') { + const ruleName = ctx.constructor.name; + log.debug('๐Ÿ” UsecaseListener: Exiting rule:', ruleName); + } + }; + + visitTerminal = (_node: any) => { + // Optional: Handle terminal nodes + }; + + visitErrorNode = (_node: any) => { + log.debug('โŒ UsecaseListener: Error node encountered'); + }; + + // Actor statement + exitActorName = (ctx: any) => { + let actorName = ''; + + if (ctx.IDENTIFIER()) { + actorName = ctx.IDENTIFIER().getText(); + } else if (ctx.STRING()) { + actorName = this.extractString(ctx.STRING().getText()); + } + + const actorId = this.generateId(actorName); + + // Process metadata if present + let metadata: Record | undefined; + if (ctx.metadata()) { + metadata = this.extractMetadata(ctx.metadata()); + } + + this.processActorStatement(actorId, actorName, metadata); + }; + + // Relationship statement + exitRelationshipStatement = (ctx: any) => { + let from = ''; + let to = ''; + let arrowType = 0; + let label: string | undefined; + + // Get entity names + const entityNames = ctx.entityName(); + if (entityNames && entityNames.length >= 2) { + from = this.extractEntityName(entityNames[0]); + to = this.extractEntityName(entityNames[1]); + } else if (ctx.actorDeclaration()) { + from = this.extractActorDeclaration(ctx.actorDeclaration()); + if (entityNames && entityNames.length >= 1) { + to = this.extractEntityName(entityNames[0]); + } + } + + // Get arrow type + const arrow = ctx.arrow(); + if (arrow) { + const arrowResult = this.extractArrow(arrow); + arrowType = arrowResult.type; + label = arrowResult.label; + } + + this.processRelationship(from, to, arrowType, label); + }; + + // System boundary statement + enterSystemBoundaryStatement = (ctx: any) => { + const boundaryName = ctx.systemBoundaryName(); + let boundaryId = ''; + let boundaryNameText = ''; + + if (boundaryName) { + if (boundaryName.IDENTIFIER()) { + boundaryNameText = boundaryName.IDENTIFIER().getText(); + } else if (boundaryName.STRING()) { + boundaryNameText = this.extractString(boundaryName.STRING().getText()); + } + boundaryId = this.generateId(boundaryNameText); + } + + this.processSystemBoundaryStart(boundaryId, boundaryNameText); + }; + + exitSystemBoundaryStatement = (_ctx: any) => { + this.processSystemBoundaryEnd(); + }; + + exitUsecaseInBoundary = (ctx: any) => { + let useCaseId = ''; + let useCaseName = ''; + let classes: string[] | undefined; + + if (ctx.usecaseWithClass()) { + const withClass = ctx.usecaseWithClass(); + if (withClass.IDENTIFIER()) { + const identifiers = withClass.IDENTIFIER(); + if (Array.isArray(identifiers) && identifiers.length >= 2) { + useCaseId = identifiers[0].getText(); + useCaseName = useCaseId; + classes = [identifiers[1].getText()]; + } + } else if (withClass.STRING()) { + useCaseName = this.extractString(withClass.STRING().getText()); + useCaseId = this.generateId(useCaseName); + const identifiers = withClass.IDENTIFIER(); + if (identifiers) { + classes = [identifiers.getText()]; + } + } + } else if (ctx.IDENTIFIER()) { + useCaseId = ctx.IDENTIFIER().getText(); + useCaseName = useCaseId; + } else if (ctx.STRING()) { + useCaseName = this.extractString(ctx.STRING().getText()); + useCaseId = this.generateId(useCaseName); + } + + if (useCaseId && useCaseName) { + this.processUseCaseStatement(useCaseId, useCaseName, undefined, classes); + } + }; + + // System boundary type statement + exitSystemBoundaryTypeStatement = (ctx: any) => { + const boundaryName = ctx.systemBoundaryName(); + let boundaryId = ''; + + if (boundaryName) { + if (boundaryName.IDENTIFIER()) { + boundaryId = boundaryName.IDENTIFIER().getText(); + } else if (boundaryName.STRING()) { + boundaryId = this.generateId(this.extractString(boundaryName.STRING().getText())); + } + } + + const typeContent = ctx.systemBoundaryTypeContent(); + if (typeContent) { + const properties = typeContent.systemBoundaryTypeProperty(); + const props = Array.isArray(properties) ? properties : [properties]; + + for (const prop of props) { + const type = prop.systemBoundaryType(); + if (type) { + let typeValue: 'package' | 'rect' = 'rect'; + if (type.PACKAGE()) { + typeValue = 'package'; + } else if (type.RECT()) { + typeValue = 'rect'; + } + this.processSystemBoundaryType(boundaryId, typeValue); + } + } + } + }; + + // Direction statement + exitDirectionStatement = (ctx: any) => { + const direction = ctx.direction(); + if (direction) { + let directionText = ''; + if (direction.TB()) { + directionText = 'TB'; + } else if (direction.TD()) { + directionText = 'TD'; + } else if (direction.BT()) { + directionText = 'BT'; + } else if (direction.RL()) { + directionText = 'RL'; + } else if (direction.LR()) { + directionText = 'LR'; + } + this.processDirectionStatement(directionText); + } + }; + + // Class definition statement + exitClassDefStatement = (ctx: any) => { + let classId = ''; + if (ctx.IDENTIFIER()) { + classId = ctx.IDENTIFIER().getText(); + } + + const styles: string[] = []; + const stylesOpt = ctx.stylesOpt(); + if (stylesOpt) { + this.collectStyles(stylesOpt, styles); + } + + this.processClassDefStatement(classId, styles); + }; + + // Class statement + exitClassStatement = (ctx: any) => { + const nodeList = ctx.nodeList(); + const nodeIds: string[] = []; + + if (nodeList) { + const identifiers = nodeList.IDENTIFIER(); + const ids = Array.isArray(identifiers) ? identifiers : [identifiers]; + for (const id of ids) { + nodeIds.push(id.getText()); + } + } + + let classId = ''; + const identifiers = ctx.IDENTIFIER(); + if (identifiers) { + const ids = Array.isArray(identifiers) ? identifiers : [identifiers]; + if (ids.length > 0) { + classId = ids[ids.length - 1].getText(); + } + } + + this.processClassStatement(nodeIds, classId); + }; + + // Style statement + exitStyleStatement = (ctx: any) => { + let nodeId = ''; + if (ctx.IDENTIFIER()) { + nodeId = ctx.IDENTIFIER().getText(); + } + + const styles: string[] = []; + const stylesOpt = ctx.stylesOpt(); + if (stylesOpt) { + this.collectStyles(stylesOpt, styles); + } + + this.processStyleStatement(nodeId, styles); + }; + + // Usecase statement + exitUsecaseStatement = (ctx: any) => { + const entityName = ctx.entityName(); + if (entityName) { + const useCaseId = this.extractEntityName(entityName); + this.processUseCaseStatement(useCaseId, useCaseId); + } + }; + + // Helper methods + private extractMetadata(ctx: any): Record { + const metadata: Record = {}; + const content = ctx.metadataContent(); + if (content) { + const properties = content.metadataProperty(); + const props = Array.isArray(properties) ? properties : [properties]; + + for (const prop of props) { + const strings = prop.STRING(); + if (strings && strings.length >= 2) { + const key = this.extractString(strings[0].getText()); + const value = this.extractString(strings[1].getText()); + metadata[key] = value; + } + } + } + return metadata; + } + + private extractEntityName(ctx: any): string { + if (ctx.nodeIdWithLabel()) { + const nodeId = ctx.nodeIdWithLabel(); + if (nodeId.IDENTIFIER()) { + return nodeId.IDENTIFIER().getText(); + } + } + + if (ctx.IDENTIFIER()) { + const identifiers = ctx.IDENTIFIER(); + if (Array.isArray(identifiers) && identifiers.length >= 2) { + return identifiers[0].getText(); + } + return identifiers.getText ? identifiers.getText() : identifiers[0].getText(); + } + + if (ctx.STRING()) { + const strings = ctx.STRING(); + const text = strings.getText ? strings.getText() : strings[0].getText(); + return this.extractString(text); + } + + return ''; + } + + private extractActorDeclaration(ctx: any): string { + const actorName = ctx.actorName(); + if (actorName) { + if (actorName.IDENTIFIER()) { + return actorName.IDENTIFIER().getText(); + } else if (actorName.STRING()) { + return this.extractString(actorName.STRING().getText()); + } + } + return ''; + } + + private extractArrow(ctx: any): { type: number; label?: string } { + let arrowText = ''; + let label: string | undefined; + + if (ctx.labeledArrow()) { + const labeledArrow = ctx.labeledArrow(); + const edgeLabel = labeledArrow.edgeLabel(); + if (edgeLabel) { + if (edgeLabel.IDENTIFIER()) { + label = edgeLabel.IDENTIFIER().getText(); + } else if (edgeLabel.STRING()) { + label = this.extractString(edgeLabel.STRING().getText()); + } + } + + if (labeledArrow.SOLID_ARROW()) { + arrowText = '-->'; + } else if (labeledArrow.BACK_ARROW()) { + arrowText = '<--'; + } else if (labeledArrow.CIRCLE_ARROW()) { + arrowText = '--o'; + } else if (labeledArrow.CROSS_ARROW()) { + arrowText = '--x'; + } else if (labeledArrow.CIRCLE_ARROW_REVERSED()) { + arrowText = 'o--'; + } else if (labeledArrow.CROSS_ARROW_REVERSED()) { + arrowText = 'x--'; + } else { + arrowText = '--'; + } + } else { + if (ctx.SOLID_ARROW()) { + arrowText = '-->'; + } else if (ctx.BACK_ARROW()) { + arrowText = '<--'; + } else if (ctx.LINE_SOLID()) { + arrowText = '--'; + } else if (ctx.CIRCLE_ARROW()) { + arrowText = '--o'; + } else if (ctx.CROSS_ARROW()) { + arrowText = '--x'; + } else if (ctx.CIRCLE_ARROW_REVERSED()) { + arrowText = 'o--'; + } else if (ctx.CROSS_ARROW_REVERSED()) { + arrowText = 'x--'; + } + } + + return { + type: this.parseArrowType(arrowText), + label, + }; + } + + private collectStyles(ctx: any, styles: string[]): void { + if (!ctx) { + return; + } + + const styleComponents = this.getAllStyleComponents(ctx); + for (const component of styleComponents) { + styles.push(component.getText()); + } + } + + private getAllStyleComponents(ctx: any): any[] { + const components: any[] = []; + + if (ctx.style) { + const styleCtx = ctx.style(); + if (styleCtx) { + this.collectStyleComponents(styleCtx, components); + } + } + + if (ctx.stylesOpt) { + const stylesOptList = Array.isArray(ctx.stylesOpt()) ? ctx.stylesOpt() : [ctx.stylesOpt()]; + for (const opt of stylesOptList) { + if (opt) { + this.collectStyleComponents(opt, components); + } + } + } + + return components; + } + + private collectStyleComponents(ctx: any, components: any[]): void { + if (!ctx) { + return; + } + + if (ctx.styleComponent) { + const comp = ctx.styleComponent(); + if (comp) { + components.push(comp); + } + } + + if (ctx.style) { + const styleCtx = ctx.style(); + if (styleCtx) { + this.collectStyleComponents(styleCtx, components); + } + } + } +} diff --git a/packages/parser/src/language/usecase/Usecase.g4 b/packages/mermaid/src/diagrams/usecase/parser/antlr/UsecaseParser.g4 similarity index 56% rename from packages/parser/src/language/usecase/Usecase.g4 rename to packages/mermaid/src/diagrams/usecase/parser/antlr/UsecaseParser.g4 index ba11cd782..7af514f30 100644 --- a/packages/parser/src/language/usecase/Usecase.g4 +++ b/packages/mermaid/src/diagrams/usecase/parser/antlr/UsecaseParser.g4 @@ -1,10 +1,13 @@ -grammar Usecase; +parser grammar UsecaseParser; -// Parser rules -usecaseDiagram - : 'usecase' NEWLINE* statement* EOF - ; +options { + tokenVocab = UsecaseLexer; +} +// Entry point +start: USECASE NEWLINE* statement* EOF; + +// Statement types statement : actorStatement | relationshipStatement @@ -18,16 +21,18 @@ statement | NEWLINE ; +// Usecase statement (standalone entity) usecaseStatement : entityName NEWLINE* ; +// Actor statement actorStatement - : 'actor' actorList NEWLINE* + : ACTOR actorList NEWLINE* ; actorList - : actorName (',' actorName)* + : actorName (COMMA actorName)* ; actorName @@ -35,26 +40,26 @@ actorName ; metadata - : '@' '{' metadataContent '}' + : AT LBRACE metadataContent RBRACE ; metadataContent - : metadataProperty (',' metadataProperty)* + : metadataProperty (COMMA metadataProperty)* ; metadataProperty - : STRING ':' STRING + : STRING COLON STRING ; - - +// Relationship statement relationshipStatement : entityName arrow entityName NEWLINE* | actorDeclaration arrow entityName NEWLINE* ; +// System boundary statement systemBoundaryStatement - : 'systemBoundary' systemBoundaryName NEWLINE* systemBoundaryContent* 'end' NEWLINE* + : SYSTEM_BOUNDARY systemBoundaryName NEWLINE* systemBoundaryContent* END NEWLINE* ; systemBoundaryName @@ -78,23 +83,25 @@ usecaseWithClass | STRING CLASS_SEPARATOR IDENTIFIER ; +// System boundary type statement systemBoundaryTypeStatement - : systemBoundaryName '@' '{' systemBoundaryTypeContent '}' NEWLINE* + : systemBoundaryName AT LBRACE systemBoundaryTypeContent RBRACE NEWLINE* ; systemBoundaryTypeContent - : systemBoundaryTypeProperty (',' systemBoundaryTypeProperty)* + : systemBoundaryTypeProperty (COMMA systemBoundaryTypeProperty)* ; systemBoundaryTypeProperty - : 'type' ':' systemBoundaryType + : TYPE COLON systemBoundaryType ; systemBoundaryType - : 'package' - | 'rect' + : PACKAGE + | RECT ; +// Entity name (node reference) entityName : IDENTIFIER CLASS_SEPARATOR IDENTIFIER | STRING CLASS_SEPARATOR IDENTIFIER @@ -103,12 +110,14 @@ entityName | nodeIdWithLabel ; +// Actor declaration (inline) actorDeclaration - : 'actor' actorName + : ACTOR actorName ; +// Node with label nodeIdWithLabel - : IDENTIFIER '(' nodeLabel ')' + : IDENTIFIER LPAREN nodeLabel RPAREN ; nodeLabel @@ -118,6 +127,7 @@ nodeLabel | nodeLabel STRING ; +// Arrow types arrow : SOLID_ARROW | BACK_ARROW @@ -144,20 +154,22 @@ edgeLabel | STRING ; +// Direction statement directionStatement - : 'direction' direction NEWLINE* + : DIRECTION direction NEWLINE* ; direction - : 'TB' - | 'TD' - | 'BT' - | 'RL' - | 'LR' + : TB + | TD + | BT + | RL + | LR ; +// Class definition statement classDefStatement - : 'classDef' IDENTIFIER stylesOpt NEWLINE* + : CLASS_DEF IDENTIFIER stylesOpt NEWLINE* ; stylesOpt @@ -181,105 +193,18 @@ styleComponent | PERCENT ; +// Class statement classStatement - : 'class' nodeList IDENTIFIER NEWLINE* + : CLASS nodeList IDENTIFIER NEWLINE* ; +// Style statement styleStatement - : 'style' IDENTIFIER stylesOpt NEWLINE* + : STYLE IDENTIFIER stylesOpt NEWLINE* ; +// Node list nodeList - : IDENTIFIER (',' IDENTIFIER)* + : IDENTIFIER (COMMA IDENTIFIER)* ; -// Lexer rules -SOLID_ARROW - : '-->' - ; - -BACK_ARROW - : '<--' - ; - -CIRCLE_ARROW - : '--o' - ; -CIRCLE_ARROW_REVERSED - : 'o--' - ; - -CROSS_ARROW - : '--x' - ; - -CROSS_ARROW_REVERSED - : 'x--' - ; - -LINE_SOLID - : '--' - ; - -COMMA - : ',' - ; - -AT - : '@' - ; - -LBRACE - : '{' - ; - -RBRACE - : '}' - ; - -COLON - : ':' - ; - -CLASS_SEPARATOR - : ':::' - ; - -IDENTIFIER - : [a-zA-Z_][a-zA-Z0-9_]* - ; - -STRING - : '"' (~["\r\n])* '"' - | '\'' (~['\r\n])* '\'' - ; - -HASH_COLOR - : '#' [a-fA-F0-9]+ - ; - -NUMBER - : [0-9]+ ('.' [0-9]+)? ([a-zA-Z]+)? - ; - -// These tokens are defined last so they have lowest priority -// This ensures arrow tokens like '-->' are matched before DASH -DASH - : '-' - ; - -DOT - : '.' - ; - -PERCENT - : '%' - ; - -NEWLINE - : [\r\n]+ - ; - -WS - : [ \t]+ -> skip - ; diff --git a/packages/mermaid/src/diagrams/usecase/parser/antlr/UsecaseParserCore.ts b/packages/mermaid/src/diagrams/usecase/parser/antlr/UsecaseParserCore.ts new file mode 100644 index 000000000..3b9ee89c7 --- /dev/null +++ b/packages/mermaid/src/diagrams/usecase/parser/antlr/UsecaseParserCore.ts @@ -0,0 +1,292 @@ +import type { + UsecaseDB, + Actor, + UseCase, + SystemBoundary, + Relationship, + ClassDef, + ArrowType, +} from '../../usecaseTypes.js'; +import { ARROW_TYPE } from '../../usecaseTypes.js'; +import { log } from '../../../../logger.js'; + +/** + * Core shared logic for both Listener and Visitor patterns + * Contains all the proven parsing logic for usecase diagrams + */ +export class UsecaseParserCore { + protected db: UsecaseDB; + protected relationshipCounter = 0; + protected currentSystemBoundary: string | null = null; + protected currentSystemBoundaryUseCases: string[] = []; + + constructor(db: UsecaseDB) { + this.db = db; + } + + /** + * Browser-safe environment variable access + */ + protected getEnvVar(name: string): string | undefined { + try { + if (typeof process !== 'undefined' && process.env) { + return process.env[name]; + } + } catch (_e) { + // process is not defined in browser + } + + // Browser fallback + if (typeof window !== 'undefined' && (window as any).MERMAID_CONFIG) { + return (window as any).MERMAID_CONFIG[name]; + } + return undefined; + } + + /** + * Process actor statement + */ + protected processActorStatement( + actorId: string, + actorName: string, + metadata?: Record + ): void { + const actor: Actor = { + id: actorId, + name: actorName, + metadata, + }; + + this.db.addActor(actor); + log.debug(`Processed actor: ${actorId} (${actorName})`); + } + + /** + * Process use case statement + */ + protected processUseCaseStatement( + useCaseId: string, + useCaseName: string, + nodeId?: string, + classes?: string[] + ): void { + const useCase: UseCase = { + id: useCaseId, + name: useCaseName, + nodeId, + classes, + systemBoundary: this.currentSystemBoundary ?? undefined, + }; + + this.db.addUseCase(useCase); + + // Add to current system boundary if we're inside one + if (this.currentSystemBoundary) { + this.currentSystemBoundaryUseCases.push(useCaseId); + } + + log.debug(`Processed use case: ${useCaseId} (${useCaseName})`); + } + + /** + * Process relationship statement + */ + protected processRelationship(from: string, to: string, arrowType: number, label?: string): void { + // Generate IDs for checking if entities exist + const fromId = this.generateId(from); + const toId = this.generateId(to); + + // Ensure entities exist - if they're not actors, create them as use cases + if (!this.db.getActor(fromId) && !this.db.getUseCase(fromId)) { + this.db.addUseCase({ id: fromId, name: from }); + log.debug(`Auto-created use case: ${fromId} (${from})`); + } + if (!this.db.getActor(toId) && !this.db.getUseCase(toId)) { + this.db.addUseCase({ id: toId, name: to }); + log.debug(`Auto-created use case: ${toId} (${to})`); + } + + const relationshipId = `rel_${this.relationshipCounter++}`; + + // Determine relationship type based on arrow type and label + let type: 'association' | 'include' | 'extend' = 'association'; + if (label) { + const lowerLabel = label.toLowerCase(); + if (lowerLabel.includes('include')) { + type = 'include'; + } else if (lowerLabel.includes('extend')) { + type = 'extend'; + } + } + + const relationship: Relationship = { + id: relationshipId, + from: fromId, + to: toId, + type, + arrowType: arrowType as ArrowType, + label, + }; + + this.db.addRelationship(relationship); + log.debug(`Processed relationship: ${fromId} -> ${toId} (${type})`); + } + + /** + * Process system boundary start + */ + protected processSystemBoundaryStart(boundaryId: string, boundaryName: string): void { + this.currentSystemBoundary = boundaryId; + this.currentSystemBoundaryUseCases = []; + log.debug(`Started system boundary: ${boundaryId} (${boundaryName})`); + } + + /** + * Process system boundary end + */ + protected processSystemBoundaryEnd(): void { + if (this.currentSystemBoundary) { + const systemBoundary: SystemBoundary = { + id: this.currentSystemBoundary, + name: this.currentSystemBoundary, + useCases: [...this.currentSystemBoundaryUseCases], + }; + + this.db.addSystemBoundary(systemBoundary); + log.debug(`Ended system boundary: ${this.currentSystemBoundary}`); + + this.currentSystemBoundary = null; + this.currentSystemBoundaryUseCases = []; + } + } + + /** + * Process system boundary type + */ + protected processSystemBoundaryType(boundaryId: string, type: 'package' | 'rect'): void { + const boundary = this.db.getSystemBoundary(boundaryId); + if (boundary) { + boundary.type = type; + log.debug(`Set system boundary type: ${boundaryId} -> ${type}`); + } + } + + /** + * Process direction statement + */ + protected processDirectionStatement(direction: string): void { + const normalizedDirection = this.normalizeDirection(direction); + this.db.setDirection(normalizedDirection as any); + log.debug(`Set direction: ${normalizedDirection}`); + } + + /** + * Normalize direction + */ + protected normalizeDirection(dir: string): string { + switch (dir) { + case 'TD': + return 'TB'; + default: + return dir; + } + } + + /** + * Process class definition statement + */ + protected processClassDefStatement(classId: string, styles: string[]): void { + const classDef: ClassDef = { + id: classId, + styles, + }; + + this.db.addClassDef(classDef); + log.debug(`Processed class definition: ${classId}`); + } + + /** + * Process class statement (apply class to nodes) + */ + protected processClassStatement(nodeIds: string[], classId: string): void { + for (const nodeId of nodeIds) { + const useCase = this.db.getUseCase(nodeId); + if (useCase) { + if (!useCase.classes) { + useCase.classes = []; + } + if (!useCase.classes.includes(classId)) { + useCase.classes.push(classId); + } + log.debug(`Applied class ${classId} to use case ${nodeId}`); + } + } + } + + /** + * Process style statement (apply styles directly to node) + */ + protected processStyleStatement(nodeId: string, styles: string[]): void { + const useCase = this.db.getUseCase(nodeId); + if (useCase) { + useCase.styles = styles; + log.debug(`Applied styles to use case ${nodeId}`); + } + + const actor = this.db.getActor(nodeId); + if (actor) { + actor.styles = styles; + log.debug(`Applied styles to actor ${nodeId}`); + } + } + + /** + * Extract text from string (remove quotes) + */ + protected extractString(text: string): string { + if (!text) { + return ''; + } + + // Remove surrounding quotes + if ( + (text.startsWith('"') && text.endsWith('"')) || + (text.startsWith("'") && text.endsWith("'")) + ) { + return text.slice(1, -1); + } + + return text; + } + + /** + * Parse arrow type from token + */ + protected parseArrowType(arrowText: string): number { + switch (arrowText) { + case '-->': + return ARROW_TYPE.SOLID_ARROW; + case '<--': + return ARROW_TYPE.BACK_ARROW; + case '--': + return ARROW_TYPE.LINE_SOLID; + case '--o': + return ARROW_TYPE.CIRCLE_ARROW; + case '--x': + return ARROW_TYPE.CROSS_ARROW; + case 'o--': + return ARROW_TYPE.CIRCLE_ARROW_REVERSED; + case 'x--': + return ARROW_TYPE.CROSS_ARROW_REVERSED; + default: + return ARROW_TYPE.SOLID_ARROW; + } + } + + /** + * Generate unique ID from name + */ + protected generateId(name: string): string { + return name.replace(/\W/g, '_'); + } +} diff --git a/packages/mermaid/src/diagrams/usecase/parser/antlr/UsecaseVisitor.ts b/packages/mermaid/src/diagrams/usecase/parser/antlr/UsecaseVisitor.ts new file mode 100644 index 000000000..be652d16c --- /dev/null +++ b/packages/mermaid/src/diagrams/usecase/parser/antlr/UsecaseVisitor.ts @@ -0,0 +1,533 @@ +import { UsecaseParserCore } from './UsecaseParserCore.js'; +import { log } from '../../../../logger.js'; +import type { UsecaseDB } from '../../usecaseTypes.js'; + +/** + * Visitor implementation that builds the usecase model + * Uses the same core logic as the Listener for consistency + */ +export class UsecaseVisitor extends UsecaseParserCore { + private visitCount = 0; + + constructor(db: UsecaseDB) { + super(db); + + if (this.getEnvVar('ANTLR_DEBUG') === 'true') { + log.debug('๐ŸŽฏ UsecaseVisitor: Constructor called'); + } + } + + // Default visitor methods + visit(tree: any): any { + const shouldLog = this.getEnvVar('ANTLR_DEBUG') === 'true'; + + this.visitCount++; + + if (shouldLog) { + log.debug(`๐Ÿ” UsecaseVisitor: Visiting node type: ${tree.constructor.name}`); + } + + let result; + try { + result = tree.accept(this); + if (shouldLog) { + log.debug(`โœ… UsecaseVisitor: Successfully visited ${tree.constructor.name}`); + } + } catch (error) { + log.error(`โŒ UsecaseVisitor: Error visiting ${tree.constructor.name}:`, error); + throw error; + } + + return result; + } + + visitChildren(node: any): any { + if (!node) { + return null; + } + + let result = null; + const n = node.getChildCount(); + for (let i = 0; i < n; i++) { + const child = node.getChild(i); + if (child) { + const childResult = child.accept(this); + if (childResult !== null) { + result = childResult; + } + } + } + + return result; + } + + visitTerminal(_node: any): any { + return null; + } + + visitErrorNode(_node: any): any { + log.error('โŒ UsecaseVisitor: Error node encountered'); + return null; + } + + // Start rule + visitStart(ctx: any): any { + return this.visitChildren(ctx); + } + + // Statement rule + visitStatement(ctx: any): any { + return this.visitChildren(ctx); + } + + // Actor statement + visitActorStatement(ctx: any): any { + const actorList = ctx.actorList(); + if (actorList) { + this.visitActorList(actorList); + } + return null; + } + + visitActorList(ctx: any): any { + const actorNames = ctx.actorName(); + if (actorNames) { + const names = Array.isArray(actorNames) ? actorNames : [actorNames]; + for (const actorName of names) { + this.visitActorName(actorName); + } + } + return null; + } + + visitActorName(ctx: any): any { + let actorName = ''; + + const identifier = ctx.IDENTIFIER(); + if (identifier) { + actorName = identifier.getText(); + } else { + const stringToken = ctx.STRING(); + if (stringToken) { + actorName = this.extractString(stringToken.getText()); + } + } + + const actorId = this.generateId(actorName); + + // Process metadata if present + let metadata: Record | undefined; + const metadataCtx = ctx.metadata(); + if (metadataCtx) { + metadata = this.visitMetadata(metadataCtx); + } + + this.processActorStatement(actorId, actorName, metadata); + return null; + } + + visitMetadata(ctx: any): Record { + const metadata: Record = {}; + const content = ctx.metadataContent(); + if (content) { + const properties = content.metadataProperty(); + const props = Array.isArray(properties) ? properties : [properties]; + + for (const prop of props) { + const strings = prop.STRING(); + if (strings && strings.length >= 2) { + const key = this.extractString(strings[0].getText()); + const value = this.extractString(strings[1].getText()); + metadata[key] = value; + } + } + } + return metadata; + } + + // Relationship statement + visitRelationshipStatement(ctx: any): any { + let from = ''; + let to = ''; + let arrowType = 0; + let label: string | undefined; + + // Get entity names + const entityNames = ctx.entityName(); + if (entityNames && entityNames.length >= 2) { + from = this.visitEntityName(entityNames[0]); + to = this.visitEntityName(entityNames[1]); + } else if (ctx.actorDeclaration()) { + from = this.visitActorDeclaration(ctx.actorDeclaration()); + if (entityNames && entityNames.length >= 1) { + to = this.visitEntityName(entityNames[0]); + } + } + + // Get arrow type + const arrow = ctx.arrow(); + if (arrow) { + const arrowResult = this.visitArrow(arrow); + arrowType = arrowResult.type; + label = arrowResult.label; + } + + this.processRelationship(from, to, arrowType, label); + return null; + } + + visitEntityName(ctx: any): string { + if (!ctx) { + return ''; + } + + const nodeIdWithLabel = ctx.nodeIdWithLabel(); + if (nodeIdWithLabel) { + return this.visitNodeIdWithLabel(nodeIdWithLabel); + } + + const identifiers = ctx.IDENTIFIER(); + if (identifiers) { + if (Array.isArray(identifiers) && identifiers.length >= 2) { + // Has class separator (:::) + return identifiers[0].getText(); + } else if (Array.isArray(identifiers) && identifiers.length === 1) { + return identifiers[0].getText(); + } + } + + const stringToken = ctx.STRING(); + if (stringToken) { + return this.extractString(stringToken.getText()); + } + + return ''; + } + + visitActorDeclaration(ctx: any): string { + const actorName = ctx.actorName(); + if (actorName) { + return this.visitActorName(actorName); + } + return ''; + } + + visitNodeIdWithLabel(ctx: any): string { + if (ctx.IDENTIFIER()) { + return ctx.IDENTIFIER().getText(); + } + return ''; + } + + visitArrow(ctx: any): { type: number; label?: string } { + let arrowText = ''; + let label: string | undefined; + + if (ctx.labeledArrow()) { + const labeledArrow = ctx.labeledArrow(); + const edgeLabel = labeledArrow.edgeLabel(); + if (edgeLabel) { + if (edgeLabel.IDENTIFIER()) { + label = edgeLabel.IDENTIFIER().getText(); + } else if (edgeLabel.STRING()) { + label = this.extractString(edgeLabel.STRING().getText()); + } + } + + // Determine arrow type from labeled arrow structure + if (labeledArrow.SOLID_ARROW()) { + arrowText = '-->'; + } else if (labeledArrow.BACK_ARROW()) { + arrowText = '<--'; + } else if (labeledArrow.CIRCLE_ARROW()) { + arrowText = '--o'; + } else if (labeledArrow.CROSS_ARROW()) { + arrowText = '--x'; + } else if (labeledArrow.CIRCLE_ARROW_REVERSED()) { + arrowText = 'o--'; + } else if (labeledArrow.CROSS_ARROW_REVERSED()) { + arrowText = 'x--'; + } else { + arrowText = '--'; + } + } else { + // Simple arrow + if (ctx.SOLID_ARROW()) { + arrowText = '-->'; + } else if (ctx.BACK_ARROW()) { + arrowText = '<--'; + } else if (ctx.LINE_SOLID()) { + arrowText = '--'; + } else if (ctx.CIRCLE_ARROW()) { + arrowText = '--o'; + } else if (ctx.CROSS_ARROW()) { + arrowText = '--x'; + } else if (ctx.CIRCLE_ARROW_REVERSED()) { + arrowText = 'o--'; + } else if (ctx.CROSS_ARROW_REVERSED()) { + arrowText = 'x--'; + } + } + + return { + type: this.parseArrowType(arrowText), + label, + }; + } + + // System boundary statement + visitSystemBoundaryStatement(ctx: any): any { + const boundaryName = ctx.systemBoundaryName(); + let boundaryId = ''; + let boundaryNameText = ''; + + if (boundaryName) { + if (boundaryName.IDENTIFIER()) { + boundaryNameText = boundaryName.IDENTIFIER().getText(); + } else if (boundaryName.STRING()) { + boundaryNameText = this.extractString(boundaryName.STRING().getText()); + } + boundaryId = this.generateId(boundaryNameText); + } + + this.processSystemBoundaryStart(boundaryId, boundaryNameText); + + // Visit boundary content + const contents = ctx.systemBoundaryContent(); + if (contents) { + const contentList = Array.isArray(contents) ? contents : [contents]; + for (const content of contentList) { + this.visitSystemBoundaryContent(content); + } + } + + this.processSystemBoundaryEnd(); + return null; + } + + visitSystemBoundaryContent(ctx: any): any { + const usecaseInBoundary = ctx.usecaseInBoundary(); + if (usecaseInBoundary) { + this.visitUsecaseInBoundary(usecaseInBoundary); + } + return null; + } + + visitUsecaseInBoundary(ctx: any): any { + let useCaseId = ''; + let useCaseName = ''; + let classes: string[] | undefined; + + if (ctx.usecaseWithClass()) { + const withClass = ctx.usecaseWithClass(); + if (withClass.IDENTIFIER()) { + const identifiers = withClass.IDENTIFIER(); + if (Array.isArray(identifiers) && identifiers.length >= 2) { + useCaseId = identifiers[0].getText(); + useCaseName = useCaseId; + classes = [identifiers[1].getText()]; + } + } else if (withClass.STRING()) { + useCaseName = this.extractString(withClass.STRING().getText()); + useCaseId = this.generateId(useCaseName); + const identifiers = withClass.IDENTIFIER(); + if (identifiers) { + classes = [identifiers.getText()]; + } + } + } else if (ctx.IDENTIFIER()) { + useCaseId = ctx.IDENTIFIER().getText(); + useCaseName = useCaseId; + } else if (ctx.STRING()) { + useCaseName = this.extractString(ctx.STRING().getText()); + useCaseId = this.generateId(useCaseName); + } + + if (useCaseId && useCaseName) { + this.processUseCaseStatement(useCaseId, useCaseName, undefined, classes); + } + + return null; + } + + // System boundary type statement + visitSystemBoundaryTypeStatement(ctx: any): any { + const boundaryName = ctx.systemBoundaryName(); + let boundaryId = ''; + + if (boundaryName) { + if (boundaryName.IDENTIFIER()) { + boundaryId = boundaryName.IDENTIFIER().getText(); + } else if (boundaryName.STRING()) { + boundaryId = this.generateId(this.extractString(boundaryName.STRING().getText())); + } + } + + const typeContent = ctx.systemBoundaryTypeContent(); + if (typeContent) { + const properties = typeContent.systemBoundaryTypeProperty(); + const props = Array.isArray(properties) ? properties : [properties]; + + for (const prop of props) { + const type = prop.systemBoundaryType(); + if (type) { + let typeValue: 'package' | 'rect' = 'rect'; + if (type.PACKAGE()) { + typeValue = 'package'; + } else if (type.RECT()) { + typeValue = 'rect'; + } + this.processSystemBoundaryType(boundaryId, typeValue); + } + } + } + + return null; + } + + // Direction statement + visitDirectionStatement(ctx: any): any { + const direction = ctx.direction(); + if (direction) { + let directionText = ''; + if (direction.TB()) { + directionText = 'TB'; + } else if (direction.TD()) { + directionText = 'TD'; + } else if (direction.BT()) { + directionText = 'BT'; + } else if (direction.RL()) { + directionText = 'RL'; + } else if (direction.LR()) { + directionText = 'LR'; + } + this.processDirectionStatement(directionText); + } + return null; + } + + // Class definition statement + visitClassDefStatement(ctx: any): any { + let classId = ''; + if (ctx.IDENTIFIER()) { + classId = ctx.IDENTIFIER().getText(); + } + + const styles: string[] = []; + const stylesOpt = ctx.stylesOpt(); + if (stylesOpt) { + this.collectStyles(stylesOpt, styles); + } + + this.processClassDefStatement(classId, styles); + return null; + } + + // Class statement + visitClassStatement(ctx: any): any { + const nodeList = ctx.nodeList(); + const nodeIds: string[] = []; + + if (nodeList) { + const identifiers = nodeList.IDENTIFIER(); + const ids = Array.isArray(identifiers) ? identifiers : [identifiers]; + for (const id of ids) { + nodeIds.push(id.getText()); + } + } + + let classId = ''; + const identifiers = ctx.IDENTIFIER(); + if (identifiers) { + const ids = Array.isArray(identifiers) ? identifiers : [identifiers]; + if (ids.length > 0) { + classId = ids[ids.length - 1].getText(); + } + } + + this.processClassStatement(nodeIds, classId); + return null; + } + + // Style statement + visitStyleStatement(ctx: any): any { + let nodeId = ''; + if (ctx.IDENTIFIER()) { + nodeId = ctx.IDENTIFIER().getText(); + } + + const styles: string[] = []; + const stylesOpt = ctx.stylesOpt(); + if (stylesOpt) { + this.collectStyles(stylesOpt, styles); + } + + this.processStyleStatement(nodeId, styles); + return null; + } + + // Usecase statement + visitUsecaseStatement(ctx: any): any { + const entityName = ctx.entityName(); + if (entityName) { + const useCaseId = this.visitEntityName(entityName); + this.processUseCaseStatement(useCaseId, useCaseId); + } + return null; + } + + // Helper method to collect styles + private collectStyles(ctx: any, styles: string[]): void { + if (!ctx) { + return; + } + + // Visit all style components + const styleComponents = this.getAllStyleComponents(ctx); + for (const component of styleComponents) { + styles.push(component.getText()); + } + } + + private getAllStyleComponents(ctx: any): any[] { + const components: any[] = []; + + if (ctx.style) { + const styleCtx = ctx.style(); + if (styleCtx) { + this.collectStyleComponents(styleCtx, components); + } + } + + if (ctx.stylesOpt) { + const stylesOptList = Array.isArray(ctx.stylesOpt()) ? ctx.stylesOpt() : [ctx.stylesOpt()]; + for (const opt of stylesOptList) { + if (opt) { + this.collectStyleComponents(opt, components); + } + } + } + + return components; + } + + private collectStyleComponents(ctx: any, components: any[]): void { + if (!ctx) { + return; + } + + if (ctx.styleComponent) { + const comp = ctx.styleComponent(); + if (comp) { + components.push(comp); + } + } + + if (ctx.style) { + const styleCtx = ctx.style(); + if (styleCtx) { + this.collectStyleComponents(styleCtx, components); + } + } + } +} diff --git a/packages/mermaid/src/diagrams/usecase/parser/antlr/antlr-parser.ts b/packages/mermaid/src/diagrams/usecase/parser/antlr/antlr-parser.ts new file mode 100644 index 000000000..4d52a9e01 --- /dev/null +++ b/packages/mermaid/src/diagrams/usecase/parser/antlr/antlr-parser.ts @@ -0,0 +1,181 @@ +/** + * ANTLR-based Usecase Diagram Parser + * + * This is a proper ANTLR implementation using antlr-ng generated parser code. + * It provides the same interface as the existing parser for 100% compatibility. + * + * Follows the same structure as flowchart and sequence ANTLR parsers with both + * listener and visitor pattern support. + */ + +import { CharStream, CommonTokenStream, ParseTreeWalker } from 'antlr4ng'; +import { UsecaseLexer } from './generated/UsecaseLexer.js'; +import { UsecaseParser } from './generated/UsecaseParser.js'; +import { UsecaseListener } from './UsecaseListener.js'; +import { UsecaseVisitor } from './UsecaseVisitor.js'; +import { UsecaseErrorListener } from './UsecaseErrorListener.js'; +import type { UsecaseDB } from '../../usecaseTypes.js'; +import { log } from '../../../../logger.js'; + +/** + * Main ANTLR parser class that provides the same interface as the existing parser + */ +export class ANTLRUsecaseParser { + yy: UsecaseDB | null; + + constructor() { + this.yy = null; + } + + /** + * Parse usecase diagram input using ANTLR + * + * @param input - The usecase diagram text to parse + * @returns The database instance populated with parsed data + */ + parse(input: string): UsecaseDB { + if (!this.yy) { + throw new Error('Usecase ANTLR parser missing yy (database).'); + } + + const startTime = performance.now(); + + // Get environment variable helper + const getEnvVar = (name: string): string | undefined => { + try { + if (typeof process !== 'undefined' && process.env) { + return process.env[name]; + } + } catch (_e) { + // process is not defined in browser + } + + // Browser fallback + if (typeof window !== 'undefined' && (window as any).MERMAID_CONFIG) { + return (window as any).MERMAID_CONFIG[name]; + } + return undefined; + }; + + const shouldLog = getEnvVar('ANTLR_DEBUG') === 'true'; + + if (shouldLog) { + log.debug('๐ŸŽฏ ANTLR Usecase Parser: Starting parse'); + log.debug(`๐Ÿ“ Input length: ${input.length} characters`); + } + + try { + // Reset database state + if (this.yy.clear) { + this.yy.clear(); + } + + // Create input stream and lexer + const inputStream = CharStream.fromString(input); + const lexer = new UsecaseLexer(inputStream); + + // Add custom error listener to lexer + const lexerErrorListener = new UsecaseErrorListener(); + lexer.removeErrorListeners(); + lexer.addErrorListener(lexerErrorListener); + + const tokenStream = new CommonTokenStream(lexer); + + // Create parser + const parser = new UsecaseParser(tokenStream); + + // Add custom error listener to parser + const parserErrorListener = new UsecaseErrorListener(); + parser.removeErrorListeners(); + parser.addErrorListener(parserErrorListener); + + // Generate parse tree + if (shouldLog) { + log.debug('๐ŸŒณ ANTLR Usecase Parser: Starting parse tree generation'); + } + const tree = parser.start(); + + // Check for syntax errors + if (lexerErrorListener.hasErrors()) { + throw lexerErrorListener.createDetailedError(); + } + if (parserErrorListener.hasErrors()) { + throw parserErrorListener.createDetailedError(); + } + + if (shouldLog) { + log.debug('โœ… ANTLR Usecase Parser: Parse tree generated successfully'); + } + + // Check if we should use Visitor or Listener pattern + // Default to Visitor pattern (true) unless explicitly set to false + const useVisitorPattern = getEnvVar('USE_ANTLR_VISITOR') !== 'false'; + + if (shouldLog) { + log.debug('๐Ÿ”ง Usecase Parser: Pattern =', useVisitorPattern ? 'Visitor' : 'Listener'); + } + + if (useVisitorPattern) { + const visitor = new UsecaseVisitor(this.yy); + try { + visitor.visit(tree); + if (shouldLog) { + log.debug('โœ… ANTLR Usecase Parser: Visitor completed successfully'); + } + } catch (error) { + log.error( + 'โŒ ANTLR Usecase Parser: Visitor failed:', + error instanceof Error ? error.message : String(error) + ); + log.error( + 'โŒ ANTLR Usecase Parser: Visitor stack:', + error instanceof Error ? error.stack : undefined + ); + throw error; + } + } else { + const listener = new UsecaseListener(this.yy); + try { + ParseTreeWalker.DEFAULT.walk(listener, tree); + if (shouldLog) { + log.debug('โœ… ANTLR Usecase Parser: Listener completed successfully'); + } + } catch (error) { + log.error( + 'โŒ ANTLR Usecase Parser: Listener failed:', + error instanceof Error ? error.message : String(error) + ); + log.error( + 'โŒ ANTLR Usecase Parser: Listener stack:', + error instanceof Error ? error.stack : undefined + ); + throw error; + } + } + + const totalTime = performance.now() - startTime; + + if (shouldLog) { + log.debug(`โฑ๏ธ Total parse time: ${totalTime.toFixed(2)}ms`); + log.debug('โœ… ANTLR Usecase Parser: Parse completed successfully'); + } + + return this.yy; + } catch (error) { + const totalTime = performance.now() - startTime; + log.error(`โŒ ANTLR usecase parsing error after ${totalTime.toFixed(2)}ms:`, error); + log.error('๐Ÿ“ Input that caused error (first 500 chars):', input.substring(0, 500)); + throw error; + } + } + + // Provide the same interface as existing parser + setYY(yy: UsecaseDB) { + this.yy = yy; + } +} + +// Export for compatibility with existing code +export const parser = new ANTLRUsecaseParser(); + +export default parser; diff --git a/packages/mermaid/src/diagrams/usecase/usecase.spec.ts b/packages/mermaid/src/diagrams/usecase/usecase.spec.ts index 00b5715fe..1a6a02838 100644 --- a/packages/mermaid/src/diagrams/usecase/usecase.spec.ts +++ b/packages/mermaid/src/diagrams/usecase/usecase.spec.ts @@ -1,8 +1,7 @@ -import { vi } from 'vitest'; -import { setSiteConfig } from '../../diagram-api/diagramAPI.js'; -import mermaidAPI from '../../mermaidAPI.js'; +import { vi, describe, it, expect, beforeEach, beforeAll } from 'vitest'; import { Diagram } from '../../Diagram.js'; import { addDiagrams } from '../../diagram-api/diagram-orchestration.js'; +import { db } from './usecaseDb.js'; beforeAll(async () => { // Is required to load the useCase diagram @@ -70,29 +69,433 @@ vi.mock('d3', () => { addDiagrams(); -/** - * @param conf - Configuration object - * @param key - Configuration key - * @param value - Configuration value - */ -function addConf(conf: any, key: any, value: any) { - if (value !== undefined) { - conf[key] = value; - } - return conf; -} - describe('UseCase diagram with ANTLR parser', () => { - it('should parse actors and use cases correctly', async () => { - const diagram = await Diagram.fromText( - `usecase - actor Developer1 - actor Developer2 - Developer1 --> a("Login System") - Developer2 --> b(Authentication)` - ); + beforeEach(() => { + db.clear(); + }); - expect(diagram).toBeDefined(); - expect(diagram.type).toBe('usecase'); + describe('when parsing basic actors', () => { + it('should parse a single actor', async () => { + const diagram = await Diagram.fromText( + `usecase + actor User` + ); + + expect(diagram).toBeDefined(); + expect(diagram.type).toBe('usecase'); + + const actors = db.getActors(); + expect(actors.size).toBe(1); + expect(actors.has('User')).toBe(true); + expect(actors.get('User')?.name).toBe('User'); + }); + + it('should parse multiple actors', async () => { + await Diagram.fromText( + `usecase + actor User + actor Admin + actor Guest` + ); + + const actors = db.getActors(); + expect(actors.size).toBe(3); + expect(actors.has('User')).toBe(true); + expect(actors.has('Admin')).toBe(true); + expect(actors.has('Guest')).toBe(true); + }); + + it('should parse actor with simple name', async () => { + await Diagram.fromText( + `usecase + actor SystemUser` + ); + + const actors = db.getActors(); + expect(actors.size).toBe(1); + expect(actors.has('SystemUser')).toBe(true); + }); + }); + + describe('when parsing use cases', () => { + it('should parse use cases from relationships', async () => { + await Diagram.fromText( + `usecase + actor User + User --> Login` + ); + + const useCases = db.getUseCases(); + expect(useCases.size).toBe(1); + expect(useCases.has('Login')).toBe(true); + }); + + it('should parse multiple use cases from relationships', async () => { + await Diagram.fromText( + `usecase + actor User + User --> Login + User --> Logout + User --> Register` + ); + + const useCases = db.getUseCases(); + expect(useCases.size).toBe(3); + expect(useCases.has('Login')).toBe(true); + expect(useCases.has('Logout')).toBe(true); + expect(useCases.has('Register')).toBe(true); + }); + + it('should parse use case from relationship', async () => { + await Diagram.fromText( + `usecase + actor User + User --> UserLoginProcess` + ); + + const useCases = db.getUseCases(); + expect(useCases.size).toBe(1); + expect(useCases.has('UserLoginProcess')).toBe(true); + }); + + it('should parse use cases with quoted names', async () => { + await Diagram.fromText( + `usecase + actor "Customer Service" + actor "System Administrator" + "Customer Service" --> "Handle Tickets" + "System Administrator" --> "Manage System"` + ); + + const actors = db.getActors(); + expect(actors.size).toBe(2); + // IDs are generated with underscores replacing spaces + expect(actors.has('Customer_Service')).toBe(true); + expect(actors.has('System_Administrator')).toBe(true); + // But names should preserve the original text + expect(actors.get('Customer_Service')?.name).toBe('Customer Service'); + expect(actors.get('System_Administrator')?.name).toBe('System Administrator'); + + const useCases = db.getUseCases(); + expect(useCases.size).toBe(2); + expect(useCases.has('Handle_Tickets')).toBe(true); + expect(useCases.has('Manage_System')).toBe(true); + expect(useCases.get('Handle_Tickets')?.name).toBe('Handle Tickets'); + expect(useCases.get('Manage_System')?.name).toBe('Manage System'); + }); + }); + + describe('when parsing relationships', () => { + it('should parse actor to use case relationship', async () => { + await Diagram.fromText( + `usecase + actor User + User --> Login` + ); + + const relationships = db.getRelationships(); + expect(relationships.length).toBe(1); + expect(relationships[0].from).toBe('User'); + expect(relationships[0].to).toBe('Login'); + expect(relationships[0].type).toBe('association'); + }); + + it('should parse multiple relationships', async () => { + await Diagram.fromText( + `usecase + actor User + User --> Login + User --> Logout` + ); + + const relationships = db.getRelationships(); + expect(relationships.length).toBe(2); + expect(relationships[0].from).toBe('User'); + expect(relationships[0].to).toBe('Login'); + expect(relationships[1].from).toBe('User'); + expect(relationships[1].to).toBe('Logout'); + }); + + it('should parse relationship with label', async () => { + await Diagram.fromText( + `usecase + actor Developer + Developer --important--> WriteCode` + ); + + const relationships = db.getRelationships(); + expect(relationships.length).toBe(1); + expect(relationships[0].label).toBe('important'); + }); + + it('should parse different arrow types', async () => { + await Diagram.fromText( + `usecase + actor User + actor Admin + User --> Login + Admin <-- Logout + User -- ViewData` + ); + + const relationships = db.getRelationships(); + expect(relationships.length).toBe(3); + }); + }); + + describe('when parsing system boundaries', () => { + it('should parse a system boundary', async () => { + await Diagram.fromText( + `usecase + actor Admin, User + systemBoundary "Authentication" + Login + Logout + end + Admin --> Login + User --> Login` + ); + + const boundaries = db.getSystemBoundaries(); + expect(boundaries.size).toBeGreaterThan(0); + }); + + it('should parse use cases within system boundary', async () => { + await Diagram.fromText( + `usecase + actor User + systemBoundary "Authentication System" + Login + Logout + end + User --> Login` + ); + + const useCases = db.getUseCases(); + expect(useCases.size).toBe(2); + expect(useCases.has('Login')).toBe(true); + expect(useCases.has('Logout')).toBe(true); + }); + }); + + describe('when parsing direction', () => { + it('should handle TB direction', async () => { + await Diagram.fromText( + `usecase + direction TB + actor User` + ); + + expect(db.getDirection()).toBe('TB'); + }); + + it('should handle LR direction', async () => { + await Diagram.fromText( + `usecase + direction LR + actor User` + ); + + expect(db.getDirection()).toBe('LR'); + }); + + it('should normalize TD to TB', async () => { + await Diagram.fromText( + `usecase + direction TD + actor User` + ); + + expect(db.getDirection()).toBe('TB'); + }); + }); + + describe('when parsing actor metadata', () => { + it('should parse actor with metadata', async () => { + await Diagram.fromText( + `usecase + actor User@{ "type" : "primary", "icon" : "user" } + User --> Login` + ); + + const actors = db.getActors(); + expect(actors.size).toBe(1); + const user = actors.get('User'); + expect(user).toBeDefined(); + expect(user?.metadata).toBeDefined(); + }); + + it('should parse multiple actors with different metadata', async () => { + await Diagram.fromText( + `usecase + actor User@{ "type" : "primary", "icon" : "user" } + actor Admin@{ "type" : "secondary", "icon" : "admin" } + User --> Login + Admin --> ManageUsers` + ); + + const actors = db.getActors(); + expect(actors.size).toBe(2); + }); + }); + + describe('when parsing complex diagrams', () => { + it('should parse a complete authentication system', async () => { + await Diagram.fromText( + `usecase + actor User + actor Admin + + systemBoundary "Authentication System" + Login + Logout + Register + ResetPassword + end + + User --> Login + User --> Register + User --> Logout + Admin --> Login` + ); + + const actors = db.getActors(); + const useCases = db.getUseCases(); + const relationships = db.getRelationships(); + + expect(actors.size).toBe(2); + expect(useCases.size).toBe(4); + expect(relationships.length).toBe(4); + }); + + it('should parse diagram with multiple arrow types', async () => { + await Diagram.fromText( + `usecase + actor User + actor Admin + User --> Login + Admin <-- Logout + User -- ViewData` + ); + + const relationships = db.getRelationships(); + expect(relationships.length).toBe(3); + }); + + it('should handle use case creation from relationships', async () => { + await Diagram.fromText( + `usecase + actor Developer + Developer --> LoginSystem + Developer --> Authentication` + ); + + const useCases = db.getUseCases(); + expect(useCases.size).toBe(2); + expect(useCases.has('LoginSystem')).toBe(true); + expect(useCases.has('Authentication')).toBe(true); + }); + }); + + describe('when parsing class definitions', () => { + it('should handle classDef', async () => { + await Diagram.fromText( + `usecase + actor User + User --> Login + classDef important fill:#f96,stroke:#333,stroke-width:4px + class Login important` + ); + + const classDefs = db.getClassDefs(); + expect(classDefs.size).toBeGreaterThan(0); + }); + }); + + describe('database methods', () => { + it('should clear all data', async () => { + await Diagram.fromText( + `usecase + actor User + User --> Login` + ); + + expect(db.getActors().size).toBe(1); + expect(db.getUseCases().size).toBe(1); + expect(db.getRelationships().length).toBe(1); + + db.clear(); + + expect(db.getActors().size).toBe(0); + expect(db.getUseCases().size).toBe(0); + expect(db.getRelationships().length).toBe(0); + }); + + it('should get specific actor by id', async () => { + await Diagram.fromText( + `usecase + actor User + actor Admin` + ); + + const user = db.getActor('User'); + expect(user).toBeDefined(); + expect(user?.id).toBe('User'); + expect(user?.name).toBe('User'); + }); + + it('should get specific use case by id', async () => { + await Diagram.fromText( + `usecase + actor User + User --> Login + User --> Logout` + ); + + const login = db.getUseCase('Login'); + expect(login).toBeDefined(); + expect(login?.id).toBe('Login'); + expect(login?.name).toBe('Login'); + }); + + it('should return undefined for non-existent actor', () => { + const actor = db.getActor('NonExistent'); + expect(actor).toBeUndefined(); + }); + + it('should return undefined for non-existent use case', () => { + const useCase = db.getUseCase('NonExistent'); + expect(useCase).toBeUndefined(); + }); + }); + + describe('getData method', () => { + it('should convert diagram data to LayoutData format', async () => { + await Diagram.fromText( + `usecase + actor User + User --> Login` + ); + + const data = db.getData(); + + expect(data).toBeDefined(); + expect(data.nodes).toBeDefined(); + expect(data.edges).toBeDefined(); + expect(data.nodes.length).toBe(2); + expect(data.edges.length).toBe(1); + expect(data.type).toBe('usecase'); + }); + + it('should include direction in layout data', async () => { + await Diagram.fromText( + `usecase + direction LR + actor User` + ); + + const data = db.getData(); + expect(data.direction).toBe('LR'); + }); }); }); diff --git a/packages/mermaid/src/diagrams/usecase/usecaseParser.ts b/packages/mermaid/src/diagrams/usecase/usecaseParser.ts index 621966092..9524725da 100644 --- a/packages/mermaid/src/diagrams/usecase/usecaseParser.ts +++ b/packages/mermaid/src/diagrams/usecase/usecaseParser.ts @@ -1,160 +1,30 @@ -// Import ANTLR parser from the parser package -import { parse } from '@mermaid-js/parser'; +// Import local ANTLR parser import { log } from '../../logger.js'; import type { ParserDefinition } from '../../diagram-api/types.js'; -import { populateCommonDb } from '../common/populateCommonDb.js'; -import type { - UsecaseDB, - Actor, - UseCase, - SystemBoundary, - Relationship, - ArrowType, - ClassDef, -} from './usecaseTypes.js'; import { db } from './usecaseDb.js'; -// ANTLR parser result interface -interface UsecaseParseResult { - actors: { id: string; name: string; metadata?: Record; styles?: string[] }[]; - useCases: { - id: string; - name: string; - nodeId?: string; - systemBoundary?: string; - classes?: string[]; - styles?: string[]; - }[]; - systemBoundaries: { - id: string; - name: string; - useCases: string[]; - type?: 'package' | 'rect'; - styles?: string[]; - }[]; - relationships: { - id: string; - from: string; - to: string; - type: 'association' | 'include' | 'extend'; - arrowType: number; - label?: string; - }[]; - classDefs?: Map; - direction?: string; - accDescr?: string; - accTitle?: string; - title?: string; -} +// Import local ANTLR parser implementation +import antlrParser from './parser/antlr/antlr-parser.js'; /** - * Parse usecase diagram using ANTLR parser + * Parse usecase diagram using local ANTLR parser */ -const parseUsecaseWithAntlr = async (input: string): Promise => { - // Use the ANTLR parser from @mermaid-js/parser - const result = (await parse('usecase', input)) as UsecaseParseResult; - return result; -}; +const parseUsecaseWithLocalAntlr = (input: string) => { + // Set the database instance + antlrParser.yy = db; -/** - * Populate the database with parsed ANTLR results - */ -const populateDb = (ast: UsecaseParseResult, db: UsecaseDB) => { - // Clear existing data - db.clear(); - - // Add actors (ANTLR result already has id, name, and metadata) - ast.actors.forEach((actorData) => { - const actor: Actor = { - id: actorData.id, - name: actorData.name, - metadata: actorData.metadata, - styles: actorData.styles, - }; - db.addActor(actor); - }); - - // Add use cases (ANTLR result already has id, name, nodeId, systemBoundary, and classes) - ast.useCases.forEach((useCaseData) => { - const useCase: UseCase = { - id: useCaseData.id, - name: useCaseData.name, - nodeId: useCaseData.nodeId, - systemBoundary: useCaseData.systemBoundary, - classes: useCaseData.classes, - styles: useCaseData.styles, - }; - db.addUseCase(useCase); - }); - - // Add system boundaries - if (ast.systemBoundaries) { - ast.systemBoundaries.forEach((boundaryData) => { - const systemBoundary: SystemBoundary = { - id: boundaryData.id, - name: boundaryData.name, - useCases: boundaryData.useCases, - type: boundaryData.type || 'rect', // default to 'rect' if not specified - styles: boundaryData.styles, - }; - db.addSystemBoundary(systemBoundary); - }); - } - - // Add relationships (ANTLR result already has proper structure) - ast.relationships.forEach((relationshipData) => { - const relationship: Relationship = { - id: relationshipData.id, - from: relationshipData.from, - to: relationshipData.to, - type: relationshipData.type, - arrowType: relationshipData.arrowType as ArrowType, - label: relationshipData.label, - }; - db.addRelationship(relationship); - }); - - // Add class definitions - if (ast.classDefs) { - ast.classDefs.forEach((classDefData) => { - const classDef: ClassDef = { - id: classDefData.id, - styles: classDefData.styles, - }; - db.addClassDef(classDef); - }); - } - - // Set direction if provided - if (ast.direction) { - db.setDirection(ast.direction as any); - } - - log.debug('Populated usecase database:', { - actors: ast.actors.length, - useCases: ast.useCases.length, - relationships: ast.relationships.length, - classDefs: ast.classDefs?.size ?? 0, - direction: ast.direction, - }); + // Parse and return the populated database + return antlrParser.parse(input); }; export const parser: ParserDefinition = { - parse: async (input: string): Promise => { - log.debug('Parsing usecase diagram with ANTLR:', input); + parse: (input: string): void => { + log.debug('Parsing usecase diagram with local ANTLR parser:', input); try { - // Use our ANTLR parser - const ast: UsecaseParseResult = await parseUsecaseWithAntlr(input); - log.debug('ANTLR parsing result:', ast); - - // Populate common database fields - populateCommonDb(ast as any, db); - - // Populate the database with validation - populateDb(ast, db); - - log.debug('Usecase diagram parsing completed successfully'); + // Use local ANTLR parser + parseUsecaseWithLocalAntlr(input); + log.debug('ANTLR parsing completed successfully'); } catch (error) { log.error('Error parsing usecase diagram:', error); diff --git a/packages/parser/antlr-config.json b/packages/parser/antlr-config.json index 4e9b2ab20..3abeb5f9d 100644 --- a/packages/parser/antlr-config.json +++ b/packages/parser/antlr-config.json @@ -1,14 +1,5 @@ { "projectName": "Mermaid", - "grammars": [ - { - "id": "usecase", - "grammar": "src/language/usecase/Usecase.g4", - "outputDir": "src/language/usecase/generated", - "language": "TypeScript", - "generateVisitor": true, - "generateListener": true - } - ], + "grammars": [], "mode": "production" } diff --git a/packages/parser/src/language/index.ts b/packages/parser/src/language/index.ts index f4a41fc40..9b31af5ba 100644 --- a/packages/parser/src/language/index.ts +++ b/packages/parser/src/language/index.ts @@ -45,4 +45,3 @@ export * from './pie/index.js'; export * from './architecture/index.js'; export * from './radar/index.js'; export * from './treemap/index.js'; -export * from './usecase/index.js'; diff --git a/packages/parser/src/language/usecase/index.ts b/packages/parser/src/language/usecase/index.ts deleted file mode 100644 index 70bcc557d..000000000 --- a/packages/parser/src/language/usecase/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from './module.js'; -export * from './types.js'; -export * from './parser.js'; -export * from './visitor.js'; diff --git a/packages/parser/src/language/usecase/module.ts b/packages/parser/src/language/usecase/module.ts deleted file mode 100644 index 922439157..000000000 --- a/packages/parser/src/language/usecase/module.ts +++ /dev/null @@ -1,50 +0,0 @@ -/** - * ANTLR UseCase Module - * - * This module provides dependency injection and service creation - * for the ANTLR-based UseCase parser, following the Langium pattern. - */ - -import type { AntlrUsecaseServices } from './types.js'; -import { UsecaseAntlrParser } from './parser.js'; -import { UsecaseAntlrVisitor } from './visitor.js'; - -/** - * ANTLR UseCase Module for dependency injection - */ -export const AntlrUsecaseModule = { - parser: () => new UsecaseAntlrParser(), - visitor: () => new UsecaseAntlrVisitor(), -}; - -/** - * Create the full set of ANTLR UseCase services - * - * This follows the Langium pattern but for ANTLR services - * - * @returns An object with ANTLR UseCase services - */ -export function createAntlrUsecaseServices(): AntlrUsecaseServices { - const parser = new UsecaseAntlrParser(); - const visitor = new UsecaseAntlrVisitor(); - - return { - parser, - visitor, - }; -} - -/** - * Singleton instance of ANTLR UseCase services - */ -let antlrUsecaseServices: AntlrUsecaseServices | undefined; - -/** - * Get or create the singleton ANTLR UseCase services - */ -export function getAntlrUsecaseServices(): AntlrUsecaseServices { - if (!antlrUsecaseServices) { - antlrUsecaseServices = createAntlrUsecaseServices(); - } - return antlrUsecaseServices; -} diff --git a/packages/parser/src/language/usecase/parser.ts b/packages/parser/src/language/usecase/parser.ts deleted file mode 100644 index 2ddc47a0a..000000000 --- a/packages/parser/src/language/usecase/parser.ts +++ /dev/null @@ -1,194 +0,0 @@ -/** - * True ANTLR Parser Implementation for UseCase Diagrams - * - * This parser uses the actual ANTLR-generated files from Usecase.g4 - * and implements the visitor pattern to build the AST. - */ - -import { CharStream, CommonTokenStream, BaseErrorListener } from 'antlr4ng'; -import type { RecognitionException, Recognizer } from 'antlr4ng'; -import { UsecaseLexer } from './generated/UsecaseLexer.js'; -import { UsecaseParser } from './generated/UsecaseParser.js'; -import { UsecaseAntlrVisitor } from './visitor.js'; -import type { AntlrUsecaseParser, UsecaseParseResult } from './types.js'; - -/** - * Custom error listener for ANTLR parser to capture syntax errors - */ -class UsecaseErrorListener extends BaseErrorListener { - private errors: string[] = []; - - syntaxError( - _recognizer: Recognizer, - _offendingSymbol: any, - line: number, - charPositionInLine: number, - message: string, - _e: RecognitionException | null - ): void { - const errorMsg = `Syntax error at line ${line}:${charPositionInLine} - ${message}`; - this.errors.push(errorMsg); - } - - reportAmbiguity(): void { - // Optional: handle ambiguity reports - } - - reportAttemptingFullContext(): void { - // Optional: handle full context attempts - } - - reportContextSensitivity(): void { - // Optional: handle context sensitivity reports - } - - getErrors(): string[] { - return this.errors; - } - - hasErrors(): boolean { - return this.errors.length > 0; - } - - clear(): void { - this.errors = []; - } -} - -/** - * Custom error class for usecase parsing errors - */ -export class UsecaseParseError extends Error { - public line?: number; - public column?: number; - public token?: string; - public expected?: string[]; - public hash?: Record; - - constructor( - message: string, - details?: { - line?: number; - column?: number; - token?: string; - expected?: string[]; - } - ) { - super(message); - this.name = 'UsecaseParseError'; - this.line = details?.line; - this.column = details?.column; - this.token = details?.token; - this.expected = details?.expected; - - // Create hash object similar to other diagram types - this.hash = { - text: details?.token ?? '', - token: details?.token ?? '', - line: details?.line?.toString() ?? '1', - loc: { - first_line: details?.line ?? 1, - last_line: details?.line ?? 1, - first_column: details?.column ?? 1, - last_column: (details?.column ?? 1) + (details?.token?.length ?? 0), - }, - expected: details?.expected ?? [], - }; - } -} - -/** - * ANTLR-based UseCase parser implementation - */ -export class UsecaseAntlrParser implements AntlrUsecaseParser { - private visitor: UsecaseAntlrVisitor; - private errorListener: UsecaseErrorListener; - - constructor() { - this.visitor = new UsecaseAntlrVisitor(); - this.errorListener = new UsecaseErrorListener(); - } - - /** - * Parse UseCase diagram input using true ANTLR parsing - * - * @param input - The UseCase diagram text to parse - * @returns Parsed result with actors, use cases, and relationships - * @throws UsecaseParseError when syntax errors are encountered - */ - parse(input: string): UsecaseParseResult { - // Clear previous errors - this.errorListener.clear(); - - try { - // Step 1: Create ANTLR input stream - const chars = CharStream.fromString(input); - - // Step 2: Create lexer from generated ANTLR lexer - const lexer = new UsecaseLexer(chars); - - // Add error listener to lexer - lexer.removeErrorListeners(); - lexer.addErrorListener(this.errorListener); - - // Step 3: Create token stream - const tokens = new CommonTokenStream(lexer); - - // Step 4: Create parser from generated ANTLR parser - const parser = new UsecaseParser(tokens); - - // Add error listener to parser - parser.removeErrorListeners(); - parser.addErrorListener(this.errorListener); - - // Step 5: Parse using the grammar rule: usecaseDiagram - const tree = parser.usecaseDiagram(); - - // Check for syntax errors before proceeding - if (this.errorListener.hasErrors()) { - const errors = this.errorListener.getErrors(); - throw new UsecaseParseError(`Syntax error in usecase diagram: ${errors.join('; ')}`, { - token: 'unknown', - expected: ['valid usecase syntax'], - }); - } - - // Step 6: Visit the parse tree using our visitor - this.visitor.visitUsecaseDiagram!(tree); - - // Step 7: Get the parse result - return this.visitor.getParseResult(); - } catch (error) { - if (error instanceof UsecaseParseError) { - throw error; - } - - // Handle other types of errors - throw new UsecaseParseError( - `Failed to parse usecase diagram: ${error instanceof Error ? error.message : 'Unknown error'}`, - { - token: 'unknown', - expected: ['valid usecase syntax'], - } - ); - } - } -} - -/** - * Factory function to create a new ANTLR UseCase parser - */ -export function createUsecaseAntlrParser(): AntlrUsecaseParser { - return new UsecaseAntlrParser(); -} - -/** - * Convenience function for parsing UseCase diagrams - * - * @param input - The UseCase diagram text to parse - * @returns Parsed result with actors, use cases, and relationships - */ -export function parseUsecaseWithAntlr(input: string): UsecaseParseResult { - const parser = createUsecaseAntlrParser(); - return parser.parse(input); -} diff --git a/packages/parser/src/language/usecase/types.ts b/packages/parser/src/language/usecase/types.ts deleted file mode 100644 index 5b4046e77..000000000 --- a/packages/parser/src/language/usecase/types.ts +++ /dev/null @@ -1,85 +0,0 @@ -/** - * Type definitions for ANTLR UseCase parser - */ - -// Arrow types for usecase diagrams (similar to sequence diagram LINETYPE) -export const ARROW_TYPE = { - SOLID_ARROW: 0, // --> - BACK_ARROW: 1, // <-- - LINE_SOLID: 2, // -- - CIRCLE_ARROW: 3, // --o - CROSS_ARROW: 4, // --x - CIRCLE_ARROW_REVERSED: 5, // o-- - CROSS_ARROW_REVERSED: 6, // x-- -} as const; - -export type ArrowType = (typeof ARROW_TYPE)[keyof typeof ARROW_TYPE]; - -export type ActorMetadata = Record; - -export interface Actor { - id: string; - name: string; - metadata?: ActorMetadata; - styles?: string[]; // Direct CSS styles applied to this actor -} - -export interface UseCase { - id: string; - name: string; - nodeId?: string; // Optional node ID (e.g., 'a' in 'a(Go through code)') - systemBoundary?: string; // Optional reference to system boundary - classes?: string[]; // CSS classes applied to this use case - styles?: string[]; // Direct CSS styles applied to this use case -} - -export type SystemBoundaryType = 'package' | 'rect'; - -export interface SystemBoundary { - id: string; - name: string; - useCases: string[]; // Array of use case IDs within this boundary - type?: SystemBoundaryType; // Type of boundary rendering (default: 'rect') - styles?: string[]; // Direct CSS styles applied to this system boundary -} - -export interface Relationship { - id: string; - from: string; - to: string; - type: 'association' | 'include' | 'extend'; - arrowType: ArrowType; - label?: string; -} - -export interface ClassDef { - id: string; - styles: string[]; -} - -export interface UsecaseParseResult { - actors: Actor[]; - useCases: UseCase[]; - systemBoundaries: SystemBoundary[]; - relationships: Relationship[]; - classDefs?: Map; - direction?: string; - accDescr?: string; - accTitle?: string; - title?: string; -} - -/** - * ANTLR Parser Services interface - */ -export interface AntlrUsecaseServices { - parser: AntlrUsecaseParser; - visitor: any; // UsecaseAntlrVisitor - using any to avoid circular dependency -} - -/** - * ANTLR Parser interface - */ -export interface AntlrUsecaseParser { - parse(input: string): UsecaseParseResult; -} diff --git a/packages/parser/src/language/usecase/visitor.ts b/packages/parser/src/language/usecase/visitor.ts deleted file mode 100644 index a3cd0732e..000000000 --- a/packages/parser/src/language/usecase/visitor.ts +++ /dev/null @@ -1,894 +0,0 @@ -/** - * ANTLR Visitor Implementation for UseCase Diagrams - * - * This visitor traverses the ANTLR parse tree and builds the AST - * according to the grammar rules defined in Usecase.g4 - */ - -import { UsecaseVisitor } from './generated/UsecaseVisitor.js'; -import type { - UsecaseDiagramContext, - StatementContext, - ActorStatementContext, - ActorListContext, - RelationshipStatementContext, - SystemBoundaryStatementContext, - SystemBoundaryTypeStatementContext, - SystemBoundaryNameContext, - SystemBoundaryTypeContentContext, - SystemBoundaryTypePropertyContext, - SystemBoundaryTypeContext, - UsecaseInBoundaryContext, - UsecaseWithClassContext, - UsecaseStatementContext, - ActorNameContext, - ActorDeclarationContext, - NodeIdWithLabelContext, - NodeLabelContext, - MetadataContext, - MetadataContentContext, - MetadataPropertyContext, - EntityNameContext, - ArrowContext, - LabeledArrowContext, - EdgeLabelContext, - DirectionStatementContext, - DirectionContext, - ClassDefStatementContext, - ClassStatementContext, - NodeListContext, -} from './generated/UsecaseParser.js'; -import { ARROW_TYPE } from './types.js'; -import type { - Actor, - UseCase, - SystemBoundary, - Relationship, - UsecaseParseResult, - ArrowType, - ClassDef, -} from './types.js'; - -export class UsecaseAntlrVisitor extends UsecaseVisitor { - private actors: Actor[] = []; - private useCases: UseCase[] = []; - private systemBoundaries: SystemBoundary[] = []; - private relationships: Relationship[] = []; - private relationshipCounter = 0; - private direction = 'TB'; // Default direction - private classDefs = new Map(); - - constructor() { - super(); - - // Assign visitor functions as properties - this.visitUsecaseDiagram = this.visitUsecaseDiagramImpl.bind(this); - this.visitStatement = this.visitStatementImpl.bind(this); - this.visitActorStatement = this.visitActorStatementImpl.bind(this); - this.visitRelationshipStatement = this.visitRelationshipStatementImpl.bind(this); - this.visitSystemBoundaryStatement = this.visitSystemBoundaryStatementImpl.bind(this); - this.visitSystemBoundaryTypeStatement = this.visitSystemBoundaryTypeStatementImpl.bind(this); - this.visitDirectionStatement = this.visitDirectionStatementImpl.bind(this); - this.visitClassDefStatement = this.visitClassDefStatementImpl.bind(this); - this.visitClassStatement = this.visitClassStatementImpl.bind(this); - this.visitStyleStatement = this.visitStyleStatementImpl.bind(this); - this.visitUsecaseStatement = this.visitUsecaseStatementImpl.bind(this); - this.visitActorName = this.visitActorNameImpl.bind(this); - this.visitArrow = this.visitArrowImpl.bind(this); - } - - /** - * Visit the root usecaseDiagram rule - * Grammar: usecaseDiagram : 'usecase' statement* EOF ; - */ - visitUsecaseDiagramImpl(ctx: UsecaseDiagramContext): void { - // Reset state - this.actors = []; - this.useCases = []; - this.relationships = []; - this.relationshipCounter = 0; - this.direction = 'TB'; // Reset direction to default - - // Visit all statement children - if (ctx.statement) { - const statements = Array.isArray(ctx.statement()) ? ctx.statement() : [ctx.statement()]; - for (const statementCtx of statements) { - if (Array.isArray(statementCtx)) { - for (const stmt of statementCtx) { - this.visitStatementImpl(stmt); - } - } else { - this.visitStatementImpl(statementCtx); - } - } - } - } - - /** - * Visit statement rule - * Grammar: statement : actorStatement | relationshipStatement | systemBoundaryStatement | systemBoundaryTypeStatement | directionStatement | classDefStatement | classStatement | usecaseStatement | NEWLINE ; - */ - private visitStatementImpl(ctx: StatementContext): void { - if (ctx.actorStatement?.()) { - this.visitActorStatementImpl(ctx.actorStatement()!); - } else if (ctx.relationshipStatement?.()) { - this.visitRelationshipStatementImpl(ctx.relationshipStatement()!); - } else if (ctx.systemBoundaryStatement?.()) { - this.visitSystemBoundaryStatementImpl(ctx.systemBoundaryStatement()!); - } else if (ctx.systemBoundaryTypeStatement?.()) { - this.visitSystemBoundaryTypeStatementImpl(ctx.systemBoundaryTypeStatement()!); - } else if (ctx.directionStatement?.()) { - this.visitDirectionStatementImpl(ctx.directionStatement()!); - } else if (ctx.classDefStatement?.()) { - const classDefStmt = ctx.classDefStatement(); - if (classDefStmt) { - this.visitClassDefStatementImpl(classDefStmt); - } - } else if (ctx.classStatement?.()) { - const classStmt = ctx.classStatement(); - if (classStmt) { - this.visitClassStatementImpl(classStmt); - } - } else if (ctx.styleStatement?.()) { - this.visitStyleStatementImpl(ctx.styleStatement()); - } else if (ctx.usecaseStatement?.()) { - const usecaseStmt = ctx.usecaseStatement(); - if (usecaseStmt) { - this.visitUsecaseStatementImpl(usecaseStmt); - } - } - // NEWLINE is ignored - } - - /** - * Visit actorStatement rule - * Grammar: actorStatement : 'actor' actorList NEWLINE* ; - */ - visitActorStatementImpl(ctx: ActorStatementContext): void { - if (ctx.actorList?.()) { - this.visitActorListImpl(ctx.actorList()); - } - } - - /** - * Visit actorList rule - * Grammar: actorList : actorName (',' actorName)* ; - */ - visitActorListImpl(ctx: ActorListContext): void { - // Get all actorName contexts from the list - const actorNameContexts = ctx.actorName(); - - for (const actorNameCtx of actorNameContexts) { - const actorResult = this.visitActorNameImpl(actorNameCtx); - this.actors.push({ - id: actorResult.name, - name: actorResult.name, - metadata: actorResult.metadata, - }); - } - } - - /** - * Visit relationshipStatement rule - * Grammar: relationshipStatement : entityName arrow entityName NEWLINE* | actorDeclaration arrow entityName NEWLINE* ; - */ - visitRelationshipStatementImpl(ctx: RelationshipStatementContext): void { - let from = ''; - let to = ''; - - // Handle different relationship patterns - if (ctx.actorDeclaration?.()) { - // Pattern: actor ActorName --> entityName - from = this.visitActorDeclarationImpl(ctx.actorDeclaration()!); - to = this.visitEntityNameImpl(ctx.entityName(0)!); - } else if (ctx.entityName && ctx.entityName().length >= 2) { - // Pattern: entityName --> entityName - from = this.visitEntityNameImpl(ctx.entityName(0)!); - to = this.visitEntityNameImpl(ctx.entityName(1)!); - } - - // Get arrow information (type and optional label) - const arrowInfo = this.visitArrowImpl(ctx.arrow()); - - // Auto-create use cases for entities that are not actors - this.ensureUseCaseExists(from); - this.ensureUseCaseExists(to); - - const relationship: Relationship = { - id: `rel_${this.relationshipCounter++}`, - from, - to, - type: 'association', - arrowType: arrowInfo.arrowType, - }; - - // Add label if present - if (arrowInfo.label) { - relationship.label = arrowInfo.label; - } - - this.relationships.push(relationship); - } - - /** - * Ensure a use case exists for the given entity name if it's not an actor - */ - private ensureUseCaseExists(entityName: string): void { - // Check if it's already an actor - const isActor = this.actors.some((actor) => actor.id === entityName); - - // If it's not an actor, create it as a use case (if not already exists) - if (!isActor) { - const existingUseCase = this.useCases.some((useCase) => useCase.id === entityName); - if (!existingUseCase) { - this.useCases.push({ - id: entityName, - name: entityName, - }); - } - } - } - - /** - * Visit systemBoundaryStatement rule - * Grammar: systemBoundaryStatement : 'systemBoundary' systemBoundaryName NEWLINE* systemBoundaryContent* 'end' NEWLINE* ; - */ - visitSystemBoundaryStatementImpl(ctx: SystemBoundaryStatementContext): void { - let boundaryName = ''; - - // Get the system boundary name - if (ctx.systemBoundaryName?.()) { - boundaryName = this.visitSystemBoundaryNameImpl(ctx.systemBoundaryName()); - } - - // Collect use cases within this boundary - const useCasesInBoundary: string[] = []; - - if (ctx.systemBoundaryContent?.()?.length > 0) { - for (const contentCtx of ctx.systemBoundaryContent()) { - const usecaseInBoundary = contentCtx.usecaseInBoundary?.(); - if (usecaseInBoundary) { - const useCaseName = this.visitUsecaseInBoundaryImpl(usecaseInBoundary); - useCasesInBoundary.push(useCaseName); - - // Create the use case and mark it as being in this boundary - const existingUseCase = this.useCases.find((uc) => uc.id === useCaseName); - if (existingUseCase) { - existingUseCase.systemBoundary = boundaryName; - } else { - this.useCases.push({ - id: useCaseName, - name: useCaseName, - systemBoundary: boundaryName, - }); - } - } - } - } - - // Create the system boundary with default type - this.systemBoundaries.push({ - id: boundaryName, - name: boundaryName, - useCases: useCasesInBoundary, - type: 'rect', // default type - }); - } - - /** - * Visit systemBoundaryName rule - * Grammar: systemBoundaryName : IDENTIFIER | STRING ; - */ - private visitSystemBoundaryNameImpl(ctx: SystemBoundaryNameContext): string { - const identifier = ctx.IDENTIFIER?.(); - if (identifier) { - return identifier.getText(); - } - - const string = ctx.STRING?.(); - if (string) { - const text = string.getText(); - // Remove quotes from string - return text.slice(1, -1); - } - - return ''; - } - - /** - * Visit usecaseInBoundary rule - * Grammar: usecaseInBoundary : usecaseWithClass | IDENTIFIER | STRING ; - */ - private visitUsecaseInBoundaryImpl(ctx: UsecaseInBoundaryContext): string { - // Check for usecaseWithClass (e.g., "debugging:::case1") - const usecaseWithClass = ctx.usecaseWithClass?.(); - if (usecaseWithClass) { - return this.visitUsecaseWithClassImpl(usecaseWithClass); - } - - const identifier = ctx.IDENTIFIER?.(); - if (identifier) { - return identifier.getText(); - } - - const string = ctx.STRING?.(); - if (string) { - const text = string.getText(); - // Remove quotes from string - return text.slice(1, -1); - } - - return ''; - } - - /** - * Visit usecaseWithClass rule - * Grammar: usecaseWithClass : IDENTIFIER CLASS_SEPARATOR IDENTIFIER | STRING CLASS_SEPARATOR IDENTIFIER ; - */ - private visitUsecaseWithClassImpl(ctx: UsecaseWithClassContext): string { - let usecaseName = ''; - let className = ''; - - const identifier0 = ctx.IDENTIFIER(0); - const identifier1 = ctx.IDENTIFIER(1); - const string = ctx.STRING(); - - if (identifier0 && identifier1) { - // IDENTIFIER:::IDENTIFIER - usecaseName = identifier0.getText(); - className = identifier1.getText(); - } else if (string && identifier0) { - // STRING:::IDENTIFIER - const text = string.getText(); - usecaseName = text.slice(1, -1); // Remove quotes - className = identifier0.getText(); - } - - // Apply class to the use case - if (usecaseName && className) { - this.applyClassToEntity(usecaseName, className); - } - - return usecaseName; - } - - /** - * Visit systemBoundaryTypeStatement rule - * Grammar: systemBoundaryTypeStatement : systemBoundaryName '\@' '\{' systemBoundaryTypeContent '\}' NEWLINE* ; - */ - visitSystemBoundaryTypeStatementImpl(ctx: SystemBoundaryTypeStatementContext): void { - let boundaryName = ''; - - // Get the system boundary name - const systemBoundaryName = ctx.systemBoundaryName?.(); - if (systemBoundaryName) { - boundaryName = this.visitSystemBoundaryNameImpl(systemBoundaryName); - } - - // Get the type configuration - let boundaryType: 'package' | 'rect' = 'rect'; // default - const systemBoundaryTypeContent = ctx.systemBoundaryTypeContent?.(); - if (systemBoundaryTypeContent) { - boundaryType = this.visitSystemBoundaryTypeContentImpl(systemBoundaryTypeContent); - } - - // Find the existing system boundary and update its type - const existingBoundary = this.systemBoundaries.find((b) => b.id === boundaryName); - if (existingBoundary) { - existingBoundary.type = boundaryType; - } - } - - /** - * Visit systemBoundaryTypeContent rule - * Grammar: systemBoundaryTypeContent : systemBoundaryTypeProperty (',' systemBoundaryTypeProperty)* ; - */ - private visitSystemBoundaryTypeContentImpl( - ctx: SystemBoundaryTypeContentContext - ): 'package' | 'rect' { - // Get all type properties - const typeProperties = ctx.systemBoundaryTypeProperty(); - - for (const propCtx of typeProperties) { - const type = this.visitSystemBoundaryTypePropertyImpl(propCtx); - if (type) { - return type; - } - } - - return 'rect'; // default - } - - /** - * Visit systemBoundaryTypeProperty rule - * Grammar: systemBoundaryTypeProperty : 'type' ':' systemBoundaryType ; - */ - private visitSystemBoundaryTypePropertyImpl( - ctx: SystemBoundaryTypePropertyContext - ): 'package' | 'rect' | null { - const systemBoundaryType = ctx.systemBoundaryType?.(); - if (systemBoundaryType) { - return this.visitSystemBoundaryTypeImpl(systemBoundaryType); - } - return null; - } - - /** - * Visit systemBoundaryType rule - * Grammar: systemBoundaryType : 'package' | 'rect' ; - */ - private visitSystemBoundaryTypeImpl(ctx: SystemBoundaryTypeContext): 'package' | 'rect' { - const text = ctx.getText(); - if (text === 'package') { - return 'package'; - } else if (text === 'rect') { - return 'rect'; - } - return 'rect'; // default - } - - /** - * Visit actorName rule - * Grammar: actorName : (IDENTIFIER | STRING) metadata? ; - */ - private visitActorNameImpl(ctx: ActorNameContext): { - name: string; - metadata?: Record; - } { - let name = ''; - - if (ctx.IDENTIFIER?.()) { - name = ctx.IDENTIFIER()!.getText(); - } else if (ctx.STRING?.()) { - const text = ctx.STRING()!.getText(); - // Remove quotes from string - name = text.slice(1, -1); - } - - let metadata = undefined; - if (ctx.metadata?.()) { - metadata = this.visitMetadataImpl(ctx.metadata()!); - } - - return { name, metadata }; - } - - /** - * Visit metadata rule - * Grammar: metadata : '\@' '\{' metadataContent '\}' ; - */ - private visitMetadataImpl(ctx: MetadataContext): Record { - const metadataContent = ctx.metadataContent?.(); - if (metadataContent) { - return this.visitMetadataContentImpl(metadataContent); - } - return {}; - } - - /** - * Visit metadataContent rule - * Grammar: metadataContent : metadataProperty (',' metadataProperty)* ; - */ - private visitMetadataContentImpl(ctx: MetadataContentContext): Record { - const metadata: Record = {}; - const properties = ctx.metadataProperty(); - - for (const property of properties) { - const { key, value } = this.visitMetadataPropertyImpl(property); - metadata[key] = value; - } - - return metadata; - } - - /** - * Visit metadataProperty rule - * Grammar: metadataProperty : STRING ':' STRING ; - */ - private visitMetadataPropertyImpl(ctx: MetadataPropertyContext): { key: string; value: string } { - const strings = ctx.STRING(); - if (strings.length >= 2) { - const key = strings[0].getText().slice(1, -1); // Remove quotes - const value = strings[1].getText().slice(1, -1); // Remove quotes - return { key, value }; - } - return { key: '', value: '' }; - } - - /** - * Visit entityName rule - * Grammar: entityName : IDENTIFIER CLASS_SEPARATOR IDENTIFIER | STRING CLASS_SEPARATOR IDENTIFIER | IDENTIFIER | STRING | nodeIdWithLabel ; - */ - private visitEntityNameImpl(ctx: EntityNameContext): string { - const classSeparator = ctx.CLASS_SEPARATOR?.(); - - // Check for class application syntax (e.g., "debugging:::case1") - if (classSeparator) { - let entityName = ''; - let className = ''; - - const identifier0 = ctx.IDENTIFIER(0); - const identifier1 = ctx.IDENTIFIER(1); - const string0 = ctx.STRING(); - - if (identifier0 && identifier1) { - // IDENTIFIER:::IDENTIFIER - entityName = identifier0.getText(); - className = identifier1.getText(); - } else if (string0 && identifier0) { - // STRING:::IDENTIFIER - const text = string0.getText(); - entityName = text.slice(1, -1); // Remove quotes - className = identifier0.getText(); - } - - // Apply class to the entity - if (entityName && className) { - this.applyClassToEntity(entityName, className); - } - - return entityName; - } - - // Regular entity name without class - const identifier = ctx.IDENTIFIER(0); - if (identifier) { - return identifier.getText(); - } - - const string = ctx.STRING(); - if (string) { - const text = string.getText(); - // Remove quotes from string - return text.slice(1, -1); - } - - const nodeIdWithLabel = ctx.nodeIdWithLabel?.(); - if (nodeIdWithLabel) { - return this.visitNodeIdWithLabelImpl(nodeIdWithLabel); - } - - return ''; - } - - /** - * Apply a class to an entity (use case) - */ - private applyClassToEntity(entityName: string, className: string): void { - // Find or create the use case - let useCase = this.useCases.find((uc) => uc.id === entityName); - if (!useCase) { - useCase = { - id: entityName, - name: entityName, - classes: [], - }; - this.useCases.push(useCase); - } - - // Add the class if not already present - if (!useCase.classes) { - useCase.classes = []; - } - if (!useCase.classes.includes(className)) { - useCase.classes.push(className); - } - } - - /** - * Visit actorDeclaration rule - * Grammar: actorDeclaration : 'actor' actorName ; - */ - private visitActorDeclarationImpl(ctx: ActorDeclarationContext): string { - const actorName = ctx.actorName?.(); - if (actorName) { - const actorResult = this.visitActorNameImpl(actorName); - - // Add the actor if it doesn't already exist - const existingActor = this.actors.find((actor) => actor.id === actorResult.name); - if (!existingActor) { - this.actors.push({ - id: actorResult.name, - name: actorResult.name, - metadata: actorResult.metadata, - }); - } - - return actorResult.name; - } - return ''; - } - - /** - * Visit nodeIdWithLabel rule - * Grammar: nodeIdWithLabel : IDENTIFIER '(' nodeLabel ')' ; - */ - private visitNodeIdWithLabelImpl(ctx: NodeIdWithLabelContext): string { - let nodeId = ''; - let nodeLabel = ''; - - const identifier = ctx.IDENTIFIER?.(); - if (identifier) { - nodeId = identifier.getText(); - } - - const nodeLabelCtx = ctx.nodeLabel?.(); - if (nodeLabelCtx) { - nodeLabel = this.visitNodeLabelImpl(nodeLabelCtx); - } - - // Create or update the use case with nodeId and label - const existingUseCase = this.useCases.find((uc) => uc.id === nodeLabel || uc.nodeId === nodeId); - if (existingUseCase) { - // Update existing use case with nodeId if not already set - existingUseCase.nodeId ??= nodeId; - } else { - // Create new use case with nodeId and label - this.useCases.push({ - id: nodeLabel, - name: nodeLabel, - nodeId: nodeId, - }); - } - - return nodeLabel; // Return the label as the entity name for relationships - } - - /** - * Visit nodeLabel rule - * Grammar: nodeLabel : IDENTIFIER | STRING | nodeLabel IDENTIFIER | nodeLabel STRING ; - */ - private visitNodeLabelImpl(ctx: NodeLabelContext): string { - const parts: string[] = []; - - // Handle recursive nodeLabel structure - const nodeLabel = ctx.nodeLabel?.(); - if (nodeLabel) { - parts.push(this.visitNodeLabelImpl(nodeLabel)); - } - - const identifier = ctx.IDENTIFIER?.(); - if (identifier) { - parts.push(identifier.getText()); - } else { - const string = ctx.STRING?.(); - if (string) { - const text = string.getText(); - // Remove quotes from string - parts.push(text.slice(1, -1)); - } - } - - return parts.join(' '); - } - - /** - * Visit arrow rule - * Grammar: arrow : SOLID_ARROW | BACK_ARROW | LINE_SOLID | CIRCLE_ARROW | CROSS_ARROW | CIRCLE_ARROW_REVERSED | CROSS_ARROW_REVERSED | labeledArrow ; - */ - private visitArrowImpl(ctx: ArrowContext): { arrowType: ArrowType; label?: string } { - // Check if this is a labeled arrow - if (ctx.labeledArrow()) { - return this.visitLabeledArrowImpl(ctx.labeledArrow()!); - } - - // Regular arrow without label - determine type from token - if (ctx.SOLID_ARROW()) { - return { arrowType: ARROW_TYPE.SOLID_ARROW }; - } else if (ctx.BACK_ARROW()) { - return { arrowType: ARROW_TYPE.BACK_ARROW }; - } else if (ctx.LINE_SOLID()) { - return { arrowType: ARROW_TYPE.LINE_SOLID }; - } else if (ctx.CIRCLE_ARROW()) { - return { arrowType: ARROW_TYPE.CIRCLE_ARROW }; - } else if (ctx.CROSS_ARROW()) { - return { arrowType: ARROW_TYPE.CROSS_ARROW }; - } else if (ctx.CIRCLE_ARROW_REVERSED()) { - return { arrowType: ARROW_TYPE.CIRCLE_ARROW_REVERSED }; - } else if (ctx.CROSS_ARROW_REVERSED()) { - return { arrowType: ARROW_TYPE.CROSS_ARROW_REVERSED }; - } - - // Fallback (should not happen with proper grammar) - return { arrowType: ARROW_TYPE.SOLID_ARROW }; - } - - /** - * Visit labeled arrow rule - * Grammar: labeledArrow : LINE_SOLID edgeLabel SOLID_ARROW | BACK_ARROW edgeLabel LINE_SOLID | LINE_SOLID edgeLabel LINE_SOLID | LINE_SOLID edgeLabel CIRCLE_ARROW | LINE_SOLID edgeLabel CROSS_ARROW | CIRCLE_ARROW_REVERSED edgeLabel LINE_SOLID | CROSS_ARROW_REVERSED edgeLabel LINE_SOLID ; - */ - private visitLabeledArrowImpl(ctx: LabeledArrowContext): { arrowType: ArrowType; label: string } { - const label = this.visitEdgeLabelImpl(ctx.edgeLabel()); - - // Determine arrow type based on the tokens present - if (ctx.SOLID_ARROW()) { - return { arrowType: ARROW_TYPE.SOLID_ARROW, label }; - } else if (ctx.BACK_ARROW()) { - return { arrowType: ARROW_TYPE.BACK_ARROW, label }; - } else if (ctx.CIRCLE_ARROW()) { - return { arrowType: ARROW_TYPE.CIRCLE_ARROW, label }; - } else if (ctx.CROSS_ARROW()) { - return { arrowType: ARROW_TYPE.CROSS_ARROW, label }; - } else if (ctx.CIRCLE_ARROW_REVERSED()) { - return { arrowType: ARROW_TYPE.CIRCLE_ARROW_REVERSED, label }; - } else if (ctx.CROSS_ARROW_REVERSED()) { - return { arrowType: ARROW_TYPE.CROSS_ARROW_REVERSED, label }; - } else { - return { arrowType: ARROW_TYPE.LINE_SOLID, label }; - } - } - - /** - * Visit edge label rule - * Grammar: edgeLabel : IDENTIFIER | STRING ; - */ - private visitEdgeLabelImpl(ctx: EdgeLabelContext): string { - const text = ctx.getText(); - // Remove quotes if it's a string - if ( - (text.startsWith('"') && text.endsWith('"')) || - (text.startsWith("'") && text.endsWith("'")) - ) { - return text.slice(1, -1); - } - return text; - } - - /** - * Visit directionStatement rule - * Grammar: directionStatement : 'direction' direction NEWLINE* ; - */ - visitDirectionStatementImpl(ctx: DirectionStatementContext): void { - const directionCtx = ctx.direction?.(); - if (directionCtx) { - this.direction = this.visitDirectionImpl(directionCtx); - } - } - - /** - * Visit direction rule - * Grammar: direction : 'TB' | 'TD' | 'BT' | 'RL' | 'LR' ; - */ - private visitDirectionImpl(ctx: DirectionContext): string { - const text = ctx.getText(); - // Normalize TD to TB (same as flowchart) - if (text === 'TD') { - return 'TB'; - } - return text; - } - - /** - * Visit classDefStatement rule - * Grammar: classDefStatement : 'classDef' IDENTIFIER stylesOpt NEWLINE* ; - */ - visitClassDefStatementImpl(ctx: ClassDefStatementContext): void { - const className = ctx.IDENTIFIER().getText(); - const stylesOptCtx = ctx.stylesOpt(); - - // Get all style properties as an array of strings - const styles = this.visitStylesOptImpl(stylesOptCtx); - - this.classDefs.set(className, { - id: className, - styles, - }); - } - - /** - * Visit stylesOpt rule - * Grammar: stylesOpt : style | stylesOpt COMMA style ; - * Returns an array of style strings like ['stroke:#f00', 'fill:#ff0'] - */ - private visitStylesOptImpl(ctx: any): string[] { - const styles: string[] = []; - - // Check if this is a recursive stylesOpt (stylesOpt COMMA style) - const stylesOptCtx = ctx.stylesOpt?.(); - if (stylesOptCtx) { - styles.push(...this.visitStylesOptImpl(stylesOptCtx)); - } - - // Get the style context - const styleCtx = ctx.style(); - if (styleCtx) { - const styleText = this.visitStyleImpl(styleCtx); - styles.push(styleText); - } - - return styles; - } - - /** - * Visit style rule - * Grammar: style : styleComponent | style styleComponent ; - * Returns a single style string like 'stroke:#f00' - */ - private visitStyleImpl(ctx: any): string { - // Get all text from the style context - return ctx.getText(); - } - - /** - * Visit classStatement rule - * Grammar: classStatement : 'class' nodeList IDENTIFIER NEWLINE* ; - */ - visitClassStatementImpl(ctx: ClassStatementContext): void { - const nodeIds = this.visitNodeListImpl(ctx.nodeList()); - const className = ctx.IDENTIFIER().getText(); - - // Apply class to each node - nodeIds.forEach((nodeId) => { - this.applyClassToEntity(nodeId, className); - }); - } - - /** - * Visit styleStatement rule - * Grammar: styleStatement : 'style' IDENTIFIER stylesOpt NEWLINE* ; - */ - visitStyleStatementImpl(ctx: any): void { - const nodeId = ctx.IDENTIFIER().getText(); - const stylesOptCtx = ctx.stylesOpt(); - - // Get all style properties as an array of strings - const styles = this.visitStylesOptImpl(stylesOptCtx); - - // Apply styles directly to the entity - let entity = this.useCases.find((uc) => uc.id === nodeId); - if (!entity) { - entity = this.actors.find((a) => a.id === nodeId); - } - if (!entity) { - entity = this.systemBoundaries.find((sb) => sb.id === nodeId); - } - - if (entity) { - // Initialize styles array if it doesn't exist - if (!entity.styles) { - entity.styles = []; - } - // Add the new styles - entity.styles.push(...styles); - } - } - - /** - * Visit nodeList rule - * Grammar: nodeList : IDENTIFIER (',' IDENTIFIER)* ; - */ - private visitNodeListImpl(ctx: NodeListContext): string[] { - const identifiers = ctx.IDENTIFIER(); - return identifiers.map((id) => id.getText()); - } - - /** - * Visit usecaseStatement rule - * Grammar: usecaseStatement : entityName NEWLINE* ; - */ - visitUsecaseStatementImpl(ctx: UsecaseStatementContext): void { - const entityName = this.visitEntityNameImpl(ctx.entityName()); - - // Create a standalone use case if it doesn't already exist - if (!this.useCases.some((uc) => uc.id === entityName)) { - this.useCases.push({ - id: entityName, - name: entityName, - }); - } - } - - /** - * Get the parse result after visiting the diagram - */ - getParseResult(): UsecaseParseResult { - return { - actors: this.actors, - useCases: this.useCases, - systemBoundaries: this.systemBoundaries, - relationships: this.relationships, - classDefs: this.classDefs, - direction: this.direction, - }; - } -} diff --git a/packages/parser/src/parse.ts b/packages/parser/src/parse.ts index 7e3bbccb5..3ee5b7b6f 100644 --- a/packages/parser/src/parse.ts +++ b/packages/parser/src/parse.ts @@ -1,10 +1,9 @@ import type { LangiumParser, ParseResult } from 'langium'; import type { Info, Packet, Pie, Architecture, GitGraph, Radar, Treemap } from './index.js'; -import type { UsecaseParseResult } from './language/usecase/types.js'; -export type DiagramAST = Info | Packet | Pie | Architecture | GitGraph | Radar | UsecaseParseResult; -export type LangiumDiagramAST = Info | Packet | Pie | Architecture | GitGraph | Radar; +export type DiagramAST = Info | Packet | Pie | Architecture | GitGraph | Radar | Treemap; +export type LangiumDiagramAST = Info | Packet | Pie | Architecture | GitGraph | Radar | Treemap; const parsers: Record = {}; const initializers = { @@ -43,9 +42,6 @@ const initializers = { const parser = createTreemapServices().Treemap.parser.LangiumParser; parsers.treemap = parser; }, - usecase: () => { - // ANTLR-based parser - no Langium parser needed - }, } as const; export async function parse(diagramType: 'info', text: string): Promise; @@ -55,13 +51,8 @@ export async function parse(diagramType: 'architecture', text: string): Promise< export async function parse(diagramType: 'gitGraph', text: string): Promise; export async function parse(diagramType: 'radar', text: string): Promise; export async function parse(diagramType: 'treemap', text: string): Promise; -export async function parse(diagramType: 'usecase', text: string): Promise; export async function parse( - diagramType: Exclude, - text: string -): Promise; -export async function parse( diagramType: keyof typeof initializers, text: string ): Promise { @@ -70,18 +61,11 @@ export async function parse( throw new Error(`Unknown diagram type: ${diagramType}`); } - // Handle ANTLR-based parsers separately - if (diagramType === 'usecase') { - const { parseUsecaseWithAntlr } = await import('./language/usecase/index.js'); - return parseUsecaseWithAntlr(text) as T; - } - if (!parsers[diagramType]) { await initializer(); } const parser: LangiumParser = parsers[diagramType]; - const result: ParseResult = - parser.parse(text); + const result: ParseResult = parser.parse(text); if (result.lexerErrors.length > 0 || result.parserErrors.length > 0) { throw new MermaidParseError(result); } diff --git a/packages/parser/tests/usecase.test.ts b/packages/parser/tests/usecase.test.ts deleted file mode 100644 index 11ef43c6e..000000000 --- a/packages/parser/tests/usecase.test.ts +++ /dev/null @@ -1,1949 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { parseUsecaseWithAntlr, UsecaseParseError } from '../src/language/usecase/index.js'; -import { ARROW_TYPE } from '../src/language/usecase/types.js'; -import type { UsecaseParseResult } from '../src/language/usecase/types.js'; - -describe('usecase ANTLR parser', () => { - const parse = (input: string): UsecaseParseResult => { - return parseUsecaseWithAntlr(input); - }; - - it('should parse basic usecase diagram with actors', () => { - const input = `usecase - actor Developer1 - actor Developer2 - actor Developer3`; - - const result = parse(input); - - expect(result.actors).toHaveLength(3); - expect(result.actors[0]).toEqual({ - id: 'Developer1', - name: 'Developer1', - }); - expect(result.actors[1]).toEqual({ - id: 'Developer2', - name: 'Developer2', - }); - expect(result.actors[2]).toEqual({ - id: 'Developer3', - name: 'Developer3', - }); - expect(result.useCases).toHaveLength(0); - expect(result.relationships).toHaveLength(0); - }); - - it('should parse actors with quoted names', () => { - const input = `usecase - actor "User Admin" - actor 'System User'`; - - const result = parse(input); - - expect(result.actors).toHaveLength(2); - expect(result.actors[0]).toEqual({ - id: 'User Admin', - name: 'User Admin', - }); - expect(result.actors[1]).toEqual({ - id: 'System User', - name: 'System User', - }); - }); - - it('should create use cases implicitly from relationships', () => { - const input = `usecase - actor User - User --> Login - User --> "Manage Users" - User --> 'View Reports'`; - - const result = parse(input); - - expect(result.useCases).toHaveLength(3); - expect(result.useCases[0]).toEqual({ - id: 'Login', - name: 'Login', - }); - expect(result.useCases[1]).toEqual({ - id: 'Manage Users', - name: 'Manage Users', - }); - expect(result.useCases[2]).toEqual({ - id: 'View Reports', - name: 'View Reports', - }); - expect(result.actors).toHaveLength(1); - expect(result.relationships).toHaveLength(3); - }); - - it('should parse relationships between actors and implicit use cases', () => { - const input = `usecase - actor User - actor Admin - User --> Login - Admin --> "Manage System"`; - - const result = parse(input); - - expect(result.actors).toHaveLength(2); - expect(result.useCases).toHaveLength(2); - expect(result.relationships).toHaveLength(2); - - expect(result.relationships[0]).toEqual({ - id: 'rel_0', - from: 'User', - to: 'Login', - type: 'association', - arrowType: ARROW_TYPE.SOLID_ARROW, - }); - expect(result.relationships[1]).toEqual({ - id: 'rel_1', - from: 'Admin', - to: 'Manage System', - type: 'association', - arrowType: ARROW_TYPE.SOLID_ARROW, - }); - }); - - it('should parse actor to actor relationships', () => { - const input = `usecase - actor User - actor Admin - User --> Admin`; - - const result = parse(input); - - expect(result.actors).toHaveLength(2); - expect(result.relationships).toHaveLength(1); - expect(result.relationships[0]).toEqual({ - id: 'rel_0', - from: 'User', - to: 'Admin', - type: 'association', - arrowType: ARROW_TYPE.SOLID_ARROW, - }); - }); - - it('should handle empty usecase diagram', () => { - const input = `usecase`; - - const result = parse(input); - - expect(result.actors).toHaveLength(0); - expect(result.useCases).toHaveLength(0); - expect(result.relationships).toHaveLength(0); - }); - - it('should handle usecase diagram with newlines and whitespace', () => { - const input = `usecase - - actor Developer1 - - actor Developer2 - - Developer1 --> Login - - `; - - const result = parse(input); - - expect(result.actors).toHaveLength(2); - expect(result.useCases).toHaveLength(1); - expect(result.relationships).toHaveLength(1); - }); - - it('should handle complex usecase diagram with implicit use cases', () => { - const input = `usecase - actor "System Admin" - actor User - actor Guest - User --> "Login System" - "System Admin" --> "User Management" - Guest --> "View Content" - User --> "View Content"`; - - const result = parse(input); - - expect(result.actors).toHaveLength(3); - expect(result.useCases).toHaveLength(3); - expect(result.relationships).toHaveLength(4); - - // Verify specific relationships - const loginRel = result.relationships.find((r) => r.from === 'User' && r.to === 'Login System'); - expect(loginRel).toBeDefined(); - expect(loginRel?.type).toBe('association'); - }); -}); - -describe('Enhanced ANTLR usecase parser features', () => { - const parse = (input: string): UsecaseParseResult => { - return parseUsecaseWithAntlr(input); - }; - test('should handle different arrow types with implicit use cases', () => { - const input = `usecase - actor User - actor Admin - User --> Login - Admin <-- Manage - User -- Login - `; - - const result = parse(input); - expect(result.actors).toHaveLength(2); - expect(result.useCases).toHaveLength(2); - expect(result.relationships).toHaveLength(3); - - // Check relationships with different arrow types - expect(result.relationships).toEqual( - expect.arrayContaining([ - expect.objectContaining({ from: 'User', to: 'Login', type: 'association' }), - expect.objectContaining({ from: 'Admin', to: 'Manage', type: 'association' }), - expect.objectContaining({ from: 'User', to: 'Login', type: 'association' }), - ]) - ); - }); - - test('should handle mixed entity types in relationships with implicit use cases', () => { - const input = `usecase - actor Manager - actor Employee - Manager --> Employee - Employee --> "Submit Report" - Manager --> "Submit Report" - `; - - const result = parse(input); - expect(result.actors).toHaveLength(2); - expect(result.useCases).toHaveLength(1); - expect(result.relationships).toHaveLength(3); - - // Check mixed relationships (actor-to-actor and actor-to-usecase) - expect(result.relationships).toEqual( - expect.arrayContaining([ - expect.objectContaining({ from: 'Manager', to: 'Employee', type: 'association' }), - expect.objectContaining({ from: 'Employee', to: 'Submit Report', type: 'association' }), - expect.objectContaining({ from: 'Manager', to: 'Submit Report', type: 'association' }), - ]) - ); - }); - - test('should handle comprehensive usecase diagram with implicit use cases', () => { - const input = `usecase - actor Customer - actor "Bank Employee" - actor "System Admin" - Customer --> "Withdraw Money" - Customer --> "Check Balance" - Customer --> "Transfer Funds" - "Bank Employee" --> "Check Balance" - "Bank Employee" --> "Transfer Funds" - "Bank Employee" --> "Manage Accounts" - "System Admin" --> "Manage Accounts" - `; - - const result = parse(input); - expect(result.actors).toHaveLength(3); - expect(result.useCases).toHaveLength(4); - expect(result.relationships).toHaveLength(7); - - // Check actors - expect(result.actors).toEqual( - expect.arrayContaining([ - { id: 'Customer', name: 'Customer' }, - { id: 'Bank Employee', name: 'Bank Employee' }, - { id: 'System Admin', name: 'System Admin' }, - ]) - ); - - // Check use cases - expect(result.useCases).toEqual( - expect.arrayContaining([ - { id: 'Withdraw Money', name: 'Withdraw Money' }, - { id: 'Check Balance', name: 'Check Balance' }, - { id: 'Transfer Funds', name: 'Transfer Funds' }, - { id: 'Manage Accounts', name: 'Manage Accounts' }, - ]) - ); - - // Check relationships - expect(result.relationships).toEqual( - expect.arrayContaining([ - expect.objectContaining({ from: 'Customer', to: 'Withdraw Money', type: 'association' }), - expect.objectContaining({ from: 'Customer', to: 'Check Balance', type: 'association' }), - expect.objectContaining({ from: 'Customer', to: 'Transfer Funds', type: 'association' }), - expect.objectContaining({ - from: 'Bank Employee', - to: 'Check Balance', - type: 'association', - }), - expect.objectContaining({ - from: 'Bank Employee', - to: 'Transfer Funds', - type: 'association', - }), - expect.objectContaining({ - from: 'Bank Employee', - to: 'Manage Accounts', - type: 'association', - }), - expect.objectContaining({ - from: 'System Admin', - to: 'Manage Accounts', - type: 'association', - }), - ]) - ); - }); -}); - -describe('Comma-separated actor syntax', () => { - it('should parse comma-separated actors', () => { - const input = `usecase -actor Developer1, Developer2, Developer3`; - - const result = parseUsecaseWithAntlr(input); - - expect(result.actors).toHaveLength(3); - expect(result.actors).toEqual([ - { id: 'Developer1', name: 'Developer1' }, - { id: 'Developer2', name: 'Developer2' }, - { id: 'Developer3', name: 'Developer3' }, - ]); - }); - - it('should parse quoted names with commas', () => { - const input = `usecase -actor "User Admin", "System Admin", "Database Admin"`; - - const result = parseUsecaseWithAntlr(input); - - expect(result.actors).toHaveLength(3); - expect(result.actors).toEqual([ - { id: 'User Admin', name: 'User Admin' }, - { id: 'System Admin', name: 'System Admin' }, - { id: 'Database Admin', name: 'Database Admin' }, - ]); - }); - - it('should handle mixed single and comma-separated actors', () => { - const input = `usecase -actor SingleActor -actor Group1, Group2, Group3 -actor AnotherSingle`; - - const result = parseUsecaseWithAntlr(input); - - expect(result.actors).toHaveLength(5); - expect(result.actors).toEqual([ - { id: 'SingleActor', name: 'SingleActor' }, - { id: 'Group1', name: 'Group1' }, - { id: 'Group2', name: 'Group2' }, - { id: 'Group3', name: 'Group3' }, - { id: 'AnotherSingle', name: 'AnotherSingle' }, - ]); - }); - - it('should handle comma-separated actors with implicit use cases from relationships', () => { - const input = `usecase -actor User, Admin, Guest -User --> Login -Admin --> Login -Guest --> Login -User --> Logout -Admin --> Logout`; - - const result = parseUsecaseWithAntlr(input); - - expect(result.actors).toHaveLength(3); - expect(result.useCases).toHaveLength(2); - expect(result.relationships).toHaveLength(5); - - expect(result.actors).toEqual([ - { id: 'User', name: 'User' }, - { id: 'Admin', name: 'Admin' }, - { id: 'Guest', name: 'Guest' }, - ]); - - expect(result.useCases).toEqual([ - { id: 'Login', name: 'Login' }, - { id: 'Logout', name: 'Logout' }, - ]); - }); - - it('should maintain backward compatibility with original syntax', () => { - const input = `usecase -actor Developer1 -actor Developer2 -actor Developer3`; - - const result = parseUsecaseWithAntlr(input); - - expect(result.actors).toHaveLength(3); - expect(result.actors).toEqual([ - { id: 'Developer1', name: 'Developer1' }, - { id: 'Developer2', name: 'Developer2' }, - { id: 'Developer3', name: 'Developer3' }, - ]); - }); - - it('should handle single actor in comma syntax', () => { - const input = `usecase -actor SingleActor`; - - const result = parseUsecaseWithAntlr(input); - - expect(result.actors).toHaveLength(1); - expect(result.actors).toEqual([{ id: 'SingleActor', name: 'SingleActor' }]); - }); - - it('should handle complex comma-separated scenario with implicit use cases', () => { - const input = `usecase - actor "Customer Service", "Technical Support", "Sales Team" - actor SystemAdmin - "Customer Service" --> "Handle Tickets" - "Technical Support" --> "Handle Tickets" - "Sales Team" --> "Process Orders" - SystemAdmin --> "Handle Tickets" - SystemAdmin --> "Process Orders" -`; - - const result = parseUsecaseWithAntlr(input); - - expect(result.actors).toHaveLength(4); - expect(result.useCases).toHaveLength(2); - expect(result.relationships).toHaveLength(5); - - expect(result.actors).toEqual( - expect.arrayContaining([ - { id: 'Customer Service', name: 'Customer Service' }, - { id: 'Technical Support', name: 'Technical Support' }, - { id: 'Sales Team', name: 'Sales Team' }, - { id: 'SystemAdmin', name: 'SystemAdmin' }, - ]) - ); - }); -}); - -describe('Actor metadata syntax', () => { - it('should parse actor with metadata', () => { - const input = `usecase -actor Developer1@{ "icon" : "icon_name", "type" : "hollow", "name": "Sample Name" }`; - - const result = parseUsecaseWithAntlr(input); - - expect(result.actors).toHaveLength(1); - expect(result.actors[0]).toEqual({ - id: 'Developer1', - name: 'Developer1', - metadata: { - icon: 'icon_name', - type: 'hollow', - name: 'Sample Name', - }, - }); - }); - - it('should parse simple metadata', () => { - const input = `usecase -actor User@{ "role" : "admin" }`; - - const result = parseUsecaseWithAntlr(input); - - expect(result.actors).toHaveLength(1); - expect(result.actors[0]).toEqual({ - id: 'User', - name: 'User', - metadata: { - role: 'admin', - }, - }); - }); - - it('should parse comma-separated actors with metadata', () => { - const input = `usecase -actor Admin@{ "role" : "admin" }, User@{ "role" : "user" }`; - - const result = parseUsecaseWithAntlr(input); - - expect(result.actors).toHaveLength(2); - expect(result.actors).toEqual([ - { - id: 'Admin', - name: 'Admin', - metadata: { role: 'admin' }, - }, - { - id: 'User', - name: 'User', - metadata: { role: 'user' }, - }, - ]); - }); - - it('should handle mixed actors with and without metadata', () => { - const input = `usecase -actor SimpleActor, MetaActor@{ "type" : "special" }`; - - const result = parseUsecaseWithAntlr(input); - - expect(result.actors).toHaveLength(2); - expect(result.actors).toEqual([ - { - id: 'SimpleActor', - name: 'SimpleActor', - metadata: undefined, - }, - { - id: 'MetaActor', - name: 'MetaActor', - metadata: { type: 'special' }, - }, - ]); - }); - - it('should handle quoted actor names with metadata', () => { - const input = `usecase -actor "System Admin"@{ "level" : "high", "department" : "IT" }`; - - const result = parseUsecaseWithAntlr(input); - - expect(result.actors).toHaveLength(1); - expect(result.actors[0]).toEqual({ - id: 'System Admin', - name: 'System Admin', - metadata: { - level: 'high', - department: 'IT', - }, - }); - }); - - it('should handle metadata with relationships and implicit use cases', () => { - const input = `usecase -actor Admin@{ "role" : "admin" }, User@{ "role" : "user" } -Admin --> Login -User --> Login`; - - const result = parseUsecaseWithAntlr(input); - - expect(result.actors).toHaveLength(2); - expect(result.useCases).toHaveLength(1); - expect(result.relationships).toHaveLength(2); - - expect(result.actors).toEqual([ - { - id: 'Admin', - name: 'Admin', - metadata: { role: 'admin' }, - }, - { - id: 'User', - name: 'User', - metadata: { role: 'user' }, - }, - ]); - - expect(result.useCases).toEqual([{ id: 'Login', name: 'Login' }]); - }); - - it('should maintain backward compatibility without metadata', () => { - const input = `usecase -actor Developer1, Developer2, Developer3`; - - const result = parseUsecaseWithAntlr(input); - - expect(result.actors).toHaveLength(3); - expect(result.actors).toEqual([ - { id: 'Developer1', name: 'Developer1', metadata: undefined }, - { id: 'Developer2', name: 'Developer2', metadata: undefined }, - { id: 'Developer3', name: 'Developer3', metadata: undefined }, - ]); - }); - - it('should handle complex metadata scenario with implicit use cases', () => { - const input = `usecase -actor "Customer Service"@{ "icon" : "user", "type" : "primary" }, "Technical Support"@{ "icon" : "wrench", "type" : "secondary" } -actor SystemAdmin@{ "role" : "admin", "level" : "high" } -"Customer Service" --> "Handle Tickets" -"Technical Support" --> "Handle Tickets" -SystemAdmin --> "Handle Tickets" -SystemAdmin --> "Process Orders"`; - - const result = parseUsecaseWithAntlr(input); - - expect(result.actors).toHaveLength(3); - expect(result.useCases).toHaveLength(2); - expect(result.relationships).toHaveLength(4); - - expect(result.actors).toEqual( - expect.arrayContaining([ - { - id: 'Customer Service', - name: 'Customer Service', - metadata: { icon: 'user', type: 'primary' }, - }, - { - id: 'Technical Support', - name: 'Technical Support', - metadata: { icon: 'wrench', type: 'secondary' }, - }, - { - id: 'SystemAdmin', - name: 'SystemAdmin', - metadata: { role: 'admin', level: 'high' }, - }, - ]) - ); - - expect(result.useCases).toEqual( - expect.arrayContaining([ - { id: 'Handle Tickets', name: 'Handle Tickets' }, - { id: 'Process Orders', name: 'Process Orders' }, - ]) - ); - }); -}); - -describe('Implicit use case creation', () => { - it('should create use cases implicitly from relationships', () => { - const input = `usecase -actor developer1 -actor developer2 -developer1 --> Login -developer2 --> "Handle Tickets" -developer1 --> "System Maintenance"`; - - const result = parseUsecaseWithAntlr(input); - - expect(result.actors).toHaveLength(2); - expect(result.useCases).toHaveLength(3); - expect(result.relationships).toHaveLength(3); - - expect(result.actors).toEqual([ - { id: 'developer1', name: 'developer1', metadata: undefined }, - { id: 'developer2', name: 'developer2', metadata: undefined }, - ]); - - expect(result.useCases).toEqual( - expect.arrayContaining([ - { id: 'Login', name: 'Login' }, - { id: 'Handle Tickets', name: 'Handle Tickets' }, - { id: 'System Maintenance', name: 'System Maintenance' }, - ]) - ); - }); - - it('should not create use cases for actor-to-actor relationships', () => { - const input = `usecase -actor Manager, Developer -Manager --> Developer`; - - const result = parseUsecaseWithAntlr(input); - - expect(result.actors).toHaveLength(2); - expect(result.useCases).toHaveLength(0); - expect(result.relationships).toHaveLength(1); - - expect(result.actors).toEqual([ - { id: 'Manager', name: 'Manager', metadata: undefined }, - { id: 'Developer', name: 'Developer', metadata: undefined }, - ]); - }); - - it('should handle mixed actor-to-usecase and actor-to-actor relationships', () => { - const input = `usecase -actor Manager, Developer, Tester -Manager --> Developer -Developer --> "Code Review" -Tester --> "Testing" -Manager --> "Project Planning"`; - - const result = parseUsecaseWithAntlr(input); - - expect(result.actors).toHaveLength(3); - expect(result.useCases).toHaveLength(3); - expect(result.relationships).toHaveLength(4); - - expect(result.useCases).toEqual( - expect.arrayContaining([ - { id: 'Code Review', name: 'Code Review' }, - { id: 'Testing', name: 'Testing' }, - { id: 'Project Planning', name: 'Project Planning' }, - ]) - ); - }); -}); - -describe('System Boundary functionality', () => { - it('should parse basic system boundary syntax', () => { - const input = `usecase -actor Developer -actor Tester -systemBoundary Tasks - coding - testing -end -Developer --> coding -Tester --> testing`; - - const result = parseUsecaseWithAntlr(input); - - expect(result.actors).toHaveLength(2); - expect(result.useCases).toHaveLength(2); - expect(result.systemBoundaries).toHaveLength(1); - expect(result.relationships).toHaveLength(2); - - expect(result.actors).toEqual([ - { id: 'Developer', name: 'Developer', metadata: undefined }, - { id: 'Tester', name: 'Tester', metadata: undefined }, - ]); - - expect(result.useCases).toEqual( - expect.arrayContaining([ - { id: 'coding', name: 'coding', systemBoundary: 'Tasks' }, - { id: 'testing', name: 'testing', systemBoundary: 'Tasks' }, - ]) - ); - - expect(result.systemBoundaries).toEqual([ - { id: 'Tasks', name: 'Tasks', useCases: ['coding', 'testing'], type: 'rect' }, - ]); - }); - - it('should handle system boundary with quoted names', () => { - const input = `usecase -actor User -systemBoundary "User Management" - "Create User" - "Delete User" - "Update Profile" -end -User --> "Create User" -User --> "Update Profile"`; - - const result = parseUsecaseWithAntlr(input); - - expect(result.actors).toHaveLength(1); - expect(result.useCases).toHaveLength(3); - expect(result.systemBoundaries).toHaveLength(1); - expect(result.relationships).toHaveLength(2); - - expect(result.systemBoundaries).toEqual([ - { - id: 'User Management', - name: 'User Management', - useCases: ['Create User', 'Delete User', 'Update Profile'], - type: 'rect', - }, - ]); - - expect(result.useCases).toEqual( - expect.arrayContaining([ - { id: 'Create User', name: 'Create User', systemBoundary: 'User Management' }, - { id: 'Delete User', name: 'Delete User', systemBoundary: 'User Management' }, - { id: 'Update Profile', name: 'Update Profile', systemBoundary: 'User Management' }, - ]) - ); - }); - - it('should handle multiple system boundaries', () => { - const input = `usecase -actor Admin, User -systemBoundary Authentication - Login - Logout -end -systemBoundary "User Management" - "Manage Users" - "View Reports" -end -Admin --> Login -User --> Login -Admin --> "Manage Users" -User --> "View Reports"`; - - const result = parseUsecaseWithAntlr(input); - - expect(result.actors).toHaveLength(2); - expect(result.useCases).toHaveLength(4); - expect(result.systemBoundaries).toHaveLength(2); - expect(result.relationships).toHaveLength(4); - - expect(result.systemBoundaries).toEqual( - expect.arrayContaining([ - { - id: 'Authentication', - name: 'Authentication', - useCases: ['Login', 'Logout'], - type: 'rect', - }, - { - id: 'User Management', - name: 'User Management', - useCases: ['Manage Users', 'View Reports'], - type: 'rect', - }, - ]) - ); - }); - - it('should handle system boundary with actors having metadata', () => { - const input = `usecase -actor Admin@{ "icon" : "admin" }, User@{ "icon" : "user" } -systemBoundary "Core Features" - Login - Dashboard -end -Admin --> Login -User --> Dashboard`; - - const result = parseUsecaseWithAntlr(input); - - expect(result.actors).toHaveLength(2); - expect(result.useCases).toHaveLength(2); - expect(result.systemBoundaries).toHaveLength(1); - expect(result.relationships).toHaveLength(2); - - expect(result.actors).toEqual( - expect.arrayContaining([ - { id: 'Admin', name: 'Admin', metadata: { icon: 'admin' } }, - { id: 'User', name: 'User', metadata: { icon: 'user' } }, - ]) - ); - - expect(result.systemBoundaries).toEqual([ - { - id: 'Core Features', - name: 'Core Features', - useCases: ['Login', 'Dashboard'], - type: 'rect', - }, - ]); - }); - - it('should handle mixed use cases (some in boundaries, some not)', () => { - const input = `usecase -actor Developer, Manager -systemBoundary "Development Tasks" - coding - testing -end -Developer --> coding -Developer --> testing -Manager --> "Project Planning" -Developer --> "Code Review"`; - - const result = parseUsecaseWithAntlr(input); - - expect(result.actors).toHaveLength(2); - expect(result.useCases).toHaveLength(4); - expect(result.systemBoundaries).toHaveLength(1); - expect(result.relationships).toHaveLength(4); - - // Use cases in boundary should have systemBoundary property - const codingUseCase = result.useCases.find((uc) => uc.id === 'coding'); - const testingUseCase = result.useCases.find((uc) => uc.id === 'testing'); - expect(codingUseCase?.systemBoundary).toBe('Development Tasks'); - expect(testingUseCase?.systemBoundary).toBe('Development Tasks'); - - // Use cases not in boundary should not have systemBoundary property - const planningUseCase = result.useCases.find((uc) => uc.id === 'Project Planning'); - const reviewUseCase = result.useCases.find((uc) => uc.id === 'Code Review'); - expect(planningUseCase?.systemBoundary).toBeUndefined(); - expect(reviewUseCase?.systemBoundary).toBeUndefined(); - }); - - it('should handle empty system boundary', () => { - const input = `usecase -actor Developer -systemBoundary EmptyBoundary -end -Developer --> "Some Task"`; - - const result = parseUsecaseWithAntlr(input); - - expect(result.actors).toHaveLength(1); - expect(result.useCases).toHaveLength(1); - expect(result.systemBoundaries).toHaveLength(1); - expect(result.relationships).toHaveLength(1); - - expect(result.systemBoundaries).toEqual([ - { id: 'EmptyBoundary', name: 'EmptyBoundary', useCases: [], type: 'rect' }, - ]); - - // Use case created from relationship should not be in boundary - const someTaskUseCase = result.useCases.find((uc) => uc.id === 'Some Task'); - expect(someTaskUseCase?.systemBoundary).toBeUndefined(); - }); -}); - -describe('System Boundary Type Configuration', () => { - it('should parse system boundary with package type', () => { - const input = `usecase -actor Developer1 -systemBoundary Tasks - coding -end -Tasks@{ type: package } -Developer1 --> coding`; - - const result = parseUsecaseWithAntlr(input); - - expect(result.actors).toHaveLength(1); - expect(result.useCases).toHaveLength(1); - expect(result.systemBoundaries).toHaveLength(1); - expect(result.relationships).toHaveLength(1); - - expect(result.systemBoundaries[0]).toEqual({ - id: 'Tasks', - name: 'Tasks', - useCases: ['coding'], - type: 'package', - }); - }); - - it('should parse system boundary with rect type', () => { - const input = `usecase -actor Developer1 -systemBoundary Tasks - coding -end -Tasks@{ type: rect } -Developer1 --> coding`; - - const result = parseUsecaseWithAntlr(input); - - expect(result.systemBoundaries[0]).toEqual({ - id: 'Tasks', - name: 'Tasks', - useCases: ['coding'], - type: 'rect', - }); - }); - - it('should default to rect type when no type specified', () => { - const input = `usecase -actor Developer1 -systemBoundary Tasks - coding -end -Developer1 --> coding`; - - const result = parseUsecaseWithAntlr(input); - - expect(result.systemBoundaries[0]).toEqual({ - id: 'Tasks', - name: 'Tasks', - useCases: ['coding'], - type: 'rect', // Should default to rect - }); - }); - - it('should handle multiple boundaries with different types', () => { - const input = `usecase -actor Admin, User -systemBoundary Authentication - Login - Logout -end -systemBoundary "User Management" - "Manage Users" - "View Reports" -end -Authentication@{ type: package } -"User Management"@{ type: rect } -Admin --> Login -User --> "Manage Users"`; - - const result = parseUsecaseWithAntlr(input); - - expect(result.systemBoundaries).toHaveLength(2); - - const authBoundary = result.systemBoundaries.find((b) => b.id === 'Authentication'); - const userManagementBoundary = result.systemBoundaries.find((b) => b.id === 'User Management'); - - expect(authBoundary).toEqual({ - id: 'Authentication', - name: 'Authentication', - useCases: ['Login', 'Logout'], - type: 'package', - }); - - expect(userManagementBoundary).toEqual({ - id: 'User Management', - name: 'User Management', - useCases: ['Manage Users', 'View Reports'], - type: 'rect', - }); - }); - - it('should handle quoted boundary names with type configuration', () => { - const input = `usecase -actor User -systemBoundary "Core Features" - Login - Dashboard -end -"Core Features"@{ type: package } -User --> Login`; - - const result = parseUsecaseWithAntlr(input); - - expect(result.systemBoundaries[0]).toEqual({ - id: 'Core Features', - name: 'Core Features', - useCases: ['Login', 'Dashboard'], - type: 'package', - }); - }); - - it('should work with actor metadata and system boundary types', () => { - const input = `usecase -actor Admin@{ "icon" : "admin" }, User@{ "icon" : "user" } -systemBoundary "Core System" - Login - Dashboard -end -"Core System"@{ type: package } -Admin --> Login -User --> Dashboard`; - - const result = parseUsecaseWithAntlr(input); - - expect(result.actors).toHaveLength(2); - expect(result.systemBoundaries).toHaveLength(1); - expect(result.relationships).toHaveLength(2); - - expect(result.actors).toEqual( - expect.arrayContaining([ - { id: 'Admin', name: 'Admin', metadata: { icon: 'admin' } }, - { id: 'User', name: 'User', metadata: { icon: 'user' } }, - ]) - ); - - expect(result.systemBoundaries[0]).toEqual({ - id: 'Core System', - name: 'Core System', - useCases: ['Login', 'Dashboard'], - type: 'package', - }); - }); - - it('should maintain backward compatibility with existing system boundaries', () => { - const input = `usecase -actor Developer, Tester -systemBoundary Tasks - coding - testing -end -Developer --> coding -Tester --> testing`; - - const result = parseUsecaseWithAntlr(input); - - expect(result.systemBoundaries[0]).toEqual({ - id: 'Tasks', - name: 'Tasks', - useCases: ['coding', 'testing'], - type: 'rect', // Should default to rect for backward compatibility - }); - }); -}); - -describe('Node ID with Label Syntax', () => { - it('should parse basic node ID with label syntax', () => { - const input = `usecase -actor Developer1 -Developer1 --> a(Go through code)`; - - const result = parseUsecaseWithAntlr(input); - - expect(result.actors).toHaveLength(1); - expect(result.useCases).toHaveLength(1); - expect(result.relationships).toHaveLength(1); - - expect(result.actors[0]).toEqual({ - id: 'Developer1', - name: 'Developer1', - metadata: undefined, - }); - - expect(result.useCases[0]).toEqual({ - id: 'Go through code', - name: 'Go through code', - nodeId: 'a', - }); - - expect(result.relationships[0]).toEqual({ - id: 'rel_0', - from: 'Developer1', - to: 'Go through code', - type: 'association', - arrowType: ARROW_TYPE.SOLID_ARROW, - }); - }); - - it('should parse your exact requested syntax', () => { - const input = `usecase -actor Developer1 -actor Developer2 -Developer1 --> a(Go through code) -Developer2 --> b(Go through implementation) -actor tester --> c(Go through testing)`; - - const result = parseUsecaseWithAntlr(input); - - expect(result.actors).toHaveLength(3); - expect(result.useCases).toHaveLength(3); - expect(result.relationships).toHaveLength(3); - - // Check actors - expect(result.actors).toEqual( - expect.arrayContaining([ - { id: 'Developer1', name: 'Developer1', metadata: undefined }, - { id: 'Developer2', name: 'Developer2', metadata: undefined }, - { id: 'tester', name: 'tester', metadata: undefined }, - ]) - ); - - // Check use cases with node IDs - expect(result.useCases).toEqual( - expect.arrayContaining([ - { id: 'Go through code', name: 'Go through code', nodeId: 'a' }, - { id: 'Go through implementation', name: 'Go through implementation', nodeId: 'b' }, - { id: 'Go through testing', name: 'Go through testing', nodeId: 'c' }, - ]) - ); - - // Check relationships - expect(result.relationships).toHaveLength(3); - expect(result.relationships).toEqual( - expect.arrayContaining([ - expect.objectContaining({ from: 'Developer1', to: 'Go through code' }), - expect.objectContaining({ from: 'Developer2', to: 'Go through implementation' }), - expect.objectContaining({ from: 'tester', to: 'Go through testing' }), - ]) - ); - }); - - it('should handle quoted labels in node ID syntax', () => { - const input = `usecase -actor Admin -Admin --> x("Create User") -Admin --> y("Delete User")`; - - const result = parseUsecaseWithAntlr(input); - - expect(result.actors).toHaveLength(1); - expect(result.useCases).toHaveLength(2); - expect(result.relationships).toHaveLength(2); - - expect(result.useCases).toEqual( - expect.arrayContaining([ - { id: 'Create User', name: 'Create User', nodeId: 'x' }, - { id: 'Delete User', name: 'Delete User', nodeId: 'y' }, - ]) - ); - }); - - it('should handle multi-word labels in node ID syntax', () => { - const input = `usecase -actor Developer -Developer --> task1(Review code changes) -Developer --> task2(Run unit tests)`; - - const result = parseUsecaseWithAntlr(input); - - expect(result.actors).toHaveLength(1); - expect(result.useCases).toHaveLength(2); - expect(result.relationships).toHaveLength(2); - - expect(result.useCases).toEqual( - expect.arrayContaining([ - { id: 'Review code changes', name: 'Review code changes', nodeId: 'task1' }, - { id: 'Run unit tests', name: 'Run unit tests', nodeId: 'task2' }, - ]) - ); - }); - - it('should handle inline actor declarations with node ID syntax', () => { - const input = `usecase -actor Developer1 -actor tester --> c(Go through testing) -Developer1 --> a(Go through code)`; - - const result = parseUsecaseWithAntlr(input); - - expect(result.actors).toHaveLength(2); - expect(result.useCases).toHaveLength(2); - expect(result.relationships).toHaveLength(2); - - // Both actors should be created (one explicit, one inline) - expect(result.actors).toEqual( - expect.arrayContaining([ - { id: 'Developer1', name: 'Developer1', metadata: undefined }, - { id: 'tester', name: 'tester', metadata: undefined }, - ]) - ); - - // Use cases should have node IDs - expect(result.useCases).toEqual( - expect.arrayContaining([ - { id: 'Go through testing', name: 'Go through testing', nodeId: 'c' }, - { id: 'Go through code', name: 'Go through code', nodeId: 'a' }, - ]) - ); - }); - - it('should maintain backward compatibility with regular syntax', () => { - const input = `usecase -actor Developer1 -actor Developer2 -Developer1 --> "Regular Use Case" -Developer2 --> a(Node ID Use Case)`; - - const result = parseUsecaseWithAntlr(input); - - expect(result.actors).toHaveLength(2); - expect(result.useCases).toHaveLength(2); - expect(result.relationships).toHaveLength(2); - - // Regular use case without node ID - const regularUseCase = result.useCases.find((uc) => uc.id === 'Regular Use Case'); - expect(regularUseCase).toEqual({ - id: 'Regular Use Case', - name: 'Regular Use Case', - nodeId: undefined, - }); - - // Use case with node ID - const nodeIdUseCase = result.useCases.find((uc) => uc.id === 'Node ID Use Case'); - expect(nodeIdUseCase).toEqual({ - id: 'Node ID Use Case', - name: 'Node ID Use Case', - nodeId: 'a', - }); - }); - - it('should work with actor metadata and node ID syntax', () => { - const input = `usecase -actor Admin@{ "icon" : "admin" } -actor User@{ "icon" : "user" } -Admin --> x(Create User) -User --> y(View Profile)`; - - const result = parseUsecaseWithAntlr(input); - - expect(result.actors).toHaveLength(2); - expect(result.useCases).toHaveLength(2); - expect(result.relationships).toHaveLength(2); - - expect(result.actors).toEqual( - expect.arrayContaining([ - { id: 'Admin', name: 'Admin', metadata: { icon: 'admin' } }, - { id: 'User', name: 'User', metadata: { icon: 'user' } }, - ]) - ); - - expect(result.useCases).toEqual( - expect.arrayContaining([ - { id: 'Create User', name: 'Create User', nodeId: 'x' }, - { id: 'View Profile', name: 'View Profile', nodeId: 'y' }, - ]) - ); - }); -}); - -describe('Edge Label Syntax', () => { - const parse = (input: string): UsecaseParseResult => { - return parseUsecaseWithAntlr(input); - }; - - it('should parse basic edge label syntax', () => { - const input = `usecase -actor Developer1 -Developer1 --important--> a(coding)`; - - const result = parse(input); - expect(result.relationships).toHaveLength(1); - expect(result.relationships[0]).toEqual({ - id: 'rel_0', - from: 'Developer1', - to: 'coding', - type: 'association', - arrowType: ARROW_TYPE.SOLID_ARROW, - label: 'important', - }); - }); - - it('should parse your exact requested syntax', () => { - const input = `usecase - actor Developer1 - Developer1 --important--> a(coding)`; - - const result = parse(input); - expect(result.actors).toHaveLength(1); - expect(result.actors[0]).toEqual({ - id: 'Developer1', - name: 'Developer1', - }); - expect(result.useCases).toHaveLength(1); - expect(result.useCases[0]).toEqual({ - id: 'coding', - name: 'coding', - nodeId: 'a', - }); - expect(result.relationships).toHaveLength(1); - expect(result.relationships[0]).toEqual({ - id: 'rel_0', - from: 'Developer1', - to: 'coding', - type: 'association', - arrowType: ARROW_TYPE.SOLID_ARROW, - label: 'important', - }); - }); - - it('should parse edge labels with string values', () => { - const input = `usecase -actor User -User --"very important"--> Login`; - - const result = parse(input); - expect(result.relationships[0]).toEqual({ - id: 'rel_0', - from: 'User', - to: 'Login', - type: 'association', - arrowType: ARROW_TYPE.SOLID_ARROW, - label: 'very important', - }); - }); - - it('should parse multiple edge labels', () => { - const input = `usecase -actor Developer -actor Tester -Developer --primary--> "Code Review" -Tester --secondary--> "Bug Testing"`; - - const result = parse(input); - expect(result.relationships).toHaveLength(2); - expect(result.relationships[0]).toEqual({ - id: 'rel_0', - from: 'Developer', - to: 'Code Review', - type: 'association', - arrowType: ARROW_TYPE.SOLID_ARROW, - label: 'primary', - }); - expect(result.relationships[1]).toEqual({ - id: 'rel_1', - from: 'Tester', - to: 'Bug Testing', - type: 'association', - arrowType: ARROW_TYPE.SOLID_ARROW, - label: 'secondary', - }); - }); - - it('should parse edge labels with different arrow types', () => { - const input = `usecase -actor User -actor Admin -User -- important --> Login -Admin <-- critical -- Manage -User -- optional -- Dashboard`; - - const result = parse(input); - expect(result.relationships).toHaveLength(3); - expect(result.relationships[0].label).toBe('important'); - expect(result.relationships[1].label).toBe('critical'); - expect(result.relationships[2].label).toBe('optional'); - }); - - it('should maintain backward compatibility with unlabeled arrows', () => { - const input = `usecase -actor User -User --> Login -User -- important --> Manage`; - - const result = parse(input); - expect(result.relationships).toHaveLength(2); - expect(result.relationships[0]).toEqual({ - id: 'rel_0', - from: 'User', - to: 'Login', - type: 'association', - arrowType: ARROW_TYPE.SOLID_ARROW, - }); - expect(result.relationships[1]).toEqual({ - id: 'rel_1', - from: 'User', - to: 'Manage', - type: 'association', - arrowType: ARROW_TYPE.SOLID_ARROW, - label: 'important', - }); - }); - - it('should work with node ID syntax and edge labels', () => { - const input = `usecase -actor Developer -Developer -- critical --> a(Code Review) -Developer -- optional --> b(Documentation)`; - - const result = parse(input); - expect(result.relationships).toHaveLength(2); - expect(result.relationships[0]).toEqual({ - id: 'rel_0', - from: 'Developer', - to: 'Code Review', - type: 'association', - arrowType: ARROW_TYPE.SOLID_ARROW, - label: 'critical', - }); - expect(result.relationships[1]).toEqual({ - id: 'rel_1', - from: 'Developer', - to: 'Documentation', - type: 'association', - arrowType: ARROW_TYPE.SOLID_ARROW, - label: 'optional', - }); - expect(result.useCases[0].nodeId).toBe('a'); - expect(result.useCases[1].nodeId).toBe('b'); - }); - - it('should work with inline actor declarations and edge labels', () => { - const input = `usecase -actor Developer --important--> a(coding) -actor Tester --critical--> b(testing)`; - - const result = parse(input); - expect(result.actors).toHaveLength(2); - expect(result.relationships).toHaveLength(2); - expect(result.relationships[0]).toEqual({ - id: 'rel_0', - from: 'Developer', - to: 'coding', - type: 'association', - arrowType: ARROW_TYPE.SOLID_ARROW, - label: 'important', - }); - expect(result.relationships[1]).toEqual({ - id: 'rel_1', - from: 'Tester', - to: 'testing', - type: 'association', - arrowType: ARROW_TYPE.SOLID_ARROW, - label: 'critical', - }); - }); -}); - -describe('New Arrow Types (--o and --x)', () => { - const parse = (input: string): UsecaseParseResult => { - return parseUsecaseWithAntlr(input); - }; - - it('should parse circle arrow (--o) without label', () => { - const input = `usecase -actor Developer -Developer --o coding`; - - const result = parse(input); - expect(result.relationships).toHaveLength(1); - expect(result.relationships[0]).toEqual({ - id: 'rel_0', - from: 'Developer', - to: 'coding', - type: 'association', - arrowType: ARROW_TYPE.CIRCLE_ARROW, - }); - }); - - it('should parse circle arrow (--o) with label', () => { - const input = `usecase -actor Developer -Developer --"performs"--o coding`; - - const result = parse(input); - expect(result.relationships).toHaveLength(1); - expect(result.relationships[0]).toEqual({ - id: 'rel_0', - from: 'Developer', - to: 'coding', - type: 'association', - arrowType: ARROW_TYPE.CIRCLE_ARROW, - label: 'performs', - }); - }); - - it('should parse cross arrow (--x) without label', () => { - const input = `usecase -actor Developer -Developer --x testing`; - - const result = parse(input); - expect(result.relationships).toHaveLength(1); - expect(result.relationships[0]).toEqual({ - id: 'rel_0', - from: 'Developer', - to: 'testing', - type: 'association', - arrowType: ARROW_TYPE.CROSS_ARROW, - }); - }); - - it('should parse cross arrow (--x) with label', () => { - const input = `usecase -actor Developer -Developer --"executes"--x testing`; - - const result = parse(input); - expect(result.relationships).toHaveLength(1); - expect(result.relationships[0]).toEqual({ - id: 'rel_0', - from: 'Developer', - to: 'testing', - type: 'association', - arrowType: ARROW_TYPE.CROSS_ARROW, - label: 'executes', - }); - }); - - it('should parse mixed arrow types in same diagram', () => { - const input = `usecase -actor Developer -Developer --> debugging -Developer --o coding -Developer --x testing`; - - const result = parse(input); - expect(result.relationships).toHaveLength(3); - expect(result.relationships[0].arrowType).toBe(ARROW_TYPE.SOLID_ARROW); - expect(result.relationships[1].arrowType).toBe(ARROW_TYPE.CIRCLE_ARROW); - expect(result.relationships[2].arrowType).toBe(ARROW_TYPE.CROSS_ARROW); - }); - - it('should parse all arrow types with labels', () => { - const input = `usecase -actor Developer -Developer --"works on"--> debugging -Developer --"performs"--o coding -Developer --"executes"--x testing`; - - const result = parse(input); - expect(result.relationships).toHaveLength(3); - - expect(result.relationships[0]).toEqual({ - id: 'rel_0', - from: 'Developer', - to: 'debugging', - type: 'association', - arrowType: ARROW_TYPE.SOLID_ARROW, - label: 'works on', - }); - - expect(result.relationships[1]).toEqual({ - id: 'rel_1', - from: 'Developer', - to: 'coding', - type: 'association', - arrowType: ARROW_TYPE.CIRCLE_ARROW, - label: 'performs', - }); - - expect(result.relationships[2]).toEqual({ - id: 'rel_2', - from: 'Developer', - to: 'testing', - type: 'association', - arrowType: ARROW_TYPE.CROSS_ARROW, - label: 'executes', - }); - }); - - it('should parse reversed circle arrow (o--) without label', () => { - const input = `usecase -actor Developer -Developer o-- coding`; - - const result = parse(input); - expect(result.relationships).toHaveLength(1); - expect(result.relationships[0]).toEqual({ - id: 'rel_0', - from: 'Developer', - to: 'coding', - type: 'association', - arrowType: ARROW_TYPE.CIRCLE_ARROW_REVERSED, - }); - }); - - it('should parse reversed circle arrow (o--) with label', () => { - const input = `usecase -actor Developer -Developer o--"performs"-- coding`; - - const result = parse(input); - expect(result.relationships).toHaveLength(1); - expect(result.relationships[0]).toEqual({ - id: 'rel_0', - from: 'Developer', - to: 'coding', - type: 'association', - arrowType: ARROW_TYPE.CIRCLE_ARROW_REVERSED, - label: 'performs', - }); - }); - - it('should parse reversed cross arrow (x--) without label', () => { - const input = `usecase -actor Developer -Developer x-- testing`; - - const result = parse(input); - expect(result.relationships).toHaveLength(1); - expect(result.relationships[0]).toEqual({ - id: 'rel_0', - from: 'Developer', - to: 'testing', - type: 'association', - arrowType: ARROW_TYPE.CROSS_ARROW_REVERSED, - }); - }); - - it('should parse reversed cross arrow (x--) with label', () => { - const input = `usecase -actor Developer -Developer x--"executes"-- testing`; - - const result = parse(input); - expect(result.relationships).toHaveLength(1); - expect(result.relationships[0]).toEqual({ - id: 'rel_0', - from: 'Developer', - to: 'testing', - type: 'association', - arrowType: ARROW_TYPE.CROSS_ARROW_REVERSED, - label: 'executes', - }); - }); - - it('should parse all arrow types including reversed arrows', () => { - const input = `usecase -actor Developer -Developer --> UC1 -Developer --o UC2 -Developer --x UC3 -Developer o-- UC4 -Developer x-- UC5`; - - const result = parse(input); - expect(result.relationships).toHaveLength(5); - expect(result.relationships[0].arrowType).toBe(ARROW_TYPE.SOLID_ARROW); - expect(result.relationships[1].arrowType).toBe(ARROW_TYPE.CIRCLE_ARROW); - expect(result.relationships[2].arrowType).toBe(ARROW_TYPE.CROSS_ARROW); - expect(result.relationships[3].arrowType).toBe(ARROW_TYPE.CIRCLE_ARROW_REVERSED); - expect(result.relationships[4].arrowType).toBe(ARROW_TYPE.CROSS_ARROW_REVERSED); - }); -}); - -describe('Class Definition and Application', () => { - it('should parse classDef statement', () => { - const input = `usecase - debugging - classDef case1 stroke:#f00 - `; - - const result = parseUsecaseWithAntlr(input); - - expect(result.classDefs).toBeDefined(); - expect(result.classDefs?.size).toBe(1); - expect(result.classDefs?.has('case1')).toBe(true); - const classDef = result.classDefs?.get('case1'); - expect(classDef?.id).toBe('case1'); - expect(classDef?.styles).toEqual(['stroke:#f00']); - }); - - it('should parse multiple classDef statements', () => { - const input = `usecase - debugging - coding - classDef case1 stroke:#f00 - classDef case2 stroke:#0f0 - classDef case3 stroke:#00f - `; - - const result = parseUsecaseWithAntlr(input); - - expect(result.classDefs?.size).toBe(3); - expect(result.classDefs?.has('case1')).toBe(true); - expect(result.classDefs?.has('case2')).toBe(true); - expect(result.classDefs?.has('case3')).toBe(true); - }); - - it('should parse classDef with multiple style properties', () => { - const input = `usecase - debugging - classDef case1 stroke:#f00, fill:#ff0, stroke-width:2px - `; - - const result = parseUsecaseWithAntlr(input); - - expect(result.classDefs?.size).toBe(1); - const classDef = result.classDefs?.get('case1'); - expect(classDef?.styles).toEqual(['stroke:#f00', 'fill:#ff0', 'stroke-width:2px']); - }); - - it('should parse inline class application with ::: syntax', () => { - const input = `usecase - debugging:::case1 - classDef case1 stroke:#f00 - `; - - const result = parseUsecaseWithAntlr(input); - - expect(result.useCases.length).toBe(1); - expect(result.useCases[0].id).toBe('debugging'); - expect(result.useCases[0].classes).toEqual(['case1']); - }); - - it('should parse class statement', () => { - const input = `usecase - debugging - coding - class debugging,coding case1 - classDef case1 stroke:#f00 - `; - - const result = parseUsecaseWithAntlr(input); - - expect(result.useCases.length).toBe(2); - const debugging = result.useCases.find((uc) => uc.id === 'debugging'); - const coding = result.useCases.find((uc) => uc.id === 'coding'); - expect(debugging?.classes).toEqual(['case1']); - expect(coding?.classes).toEqual(['case1']); - }); - - it('should parse inline class application within system boundary', () => { - const input = `usecase - systemBoundary tasks - debugging:::case1 - coding:::case2 - end - classDef case1 stroke:#f00 - classDef case2 stroke:#0f0 - `; - - const result = parseUsecaseWithAntlr(input); - - expect(result.useCases.length).toBe(2); - const debugging = result.useCases.find((uc) => uc.id === 'debugging'); - const coding = result.useCases.find((uc) => uc.id === 'coding'); - expect(debugging?.classes).toEqual(['case1']); - expect(coding?.classes).toEqual(['case2']); - }); - - it('should parse complete example with classes and relationships', () => { - const input = `usecase - actor Developer1 - actor Developer2 - - systemBoundary tasks - debugging:::case1 - coding:::case2 - testing:::case3 - end - - Developer1 --> debugging - Developer1 --> coding - Developer1 --> testing - Developer2 --> coding - Developer2 --> debugging - - classDef case1 stroke:#f00 - classDef case2 stroke:#0f0 - classDef case3 stroke:#00f - `; - - const result = parseUsecaseWithAntlr(input); - - expect(result.actors.length).toBe(2); - expect(result.useCases.length).toBe(3); - expect(result.systemBoundaries.length).toBe(1); - expect(result.relationships.length).toBe(5); - expect(result.classDefs?.size).toBe(3); - - const debugging = result.useCases.find((uc) => uc.id === 'debugging'); - const coding = result.useCases.find((uc) => uc.id === 'coding'); - const testing = result.useCases.find((uc) => uc.id === 'testing'); - - expect(debugging?.classes).toEqual(['case1']); - expect(coding?.classes).toEqual(['case2']); - expect(testing?.classes).toEqual(['case3']); - }); - - it('should handle multiple classes on same use case', () => { - const input = `usecase - debugging:::case1 - class debugging case2 - classDef case1 stroke:#f00 - classDef case2 fill:#ff0 - `; - - const result = parseUsecaseWithAntlr(input); - - expect(result.useCases.length).toBe(1); - const debugging = result.useCases.find((uc) => uc.id === 'debugging'); - expect(debugging?.classes).toContain('case1'); - expect(debugging?.classes).toContain('case2'); - }); -}); - -describe('Error Handling', () => { - describe('Syntax Error Handling', () => { - it('should throw UsecaseParseError for incomplete relationships', () => { - const incompleteSyntax = `usecase - actor User - User --> - `; - - expect(() => parseUsecaseWithAntlr(incompleteSyntax)).toThrow(UsecaseParseError); - expect(() => parseUsecaseWithAntlr(incompleteSyntax)).toThrow(/mismatched input/); - }); - - it('should throw UsecaseParseError for malformed actor declarations', () => { - const malformedSyntax = `usecase - actor - actor User - `; - - expect(() => parseUsecaseWithAntlr(malformedSyntax)).toThrow(UsecaseParseError); - expect(() => parseUsecaseWithAntlr(malformedSyntax)).toThrow(/no viable alternative/); - }); - - it('should throw UsecaseParseError for invalid arrow syntax', () => { - const invalidArrowSyntax = `usecase - actor User - User -invalid-> Login - `; - - expect(() => parseUsecaseWithAntlr(invalidArrowSyntax)).toThrow(UsecaseParseError); - expect(() => parseUsecaseWithAntlr(invalidArrowSyntax)).toThrow(/token recognition error/); - }); - - it('should throw UsecaseParseError for empty input', () => { - const emptyInput = ''; - - expect(() => parseUsecaseWithAntlr(emptyInput)).toThrow(UsecaseParseError); - expect(() => parseUsecaseWithAntlr(emptyInput)).toThrow(/missing 'usecase'/); - }); - - it('should throw UsecaseParseError for only whitespace input', () => { - const whitespaceInput = ' \n \t \n '; - - expect(() => parseUsecaseWithAntlr(whitespaceInput)).toThrow(UsecaseParseError); - expect(() => parseUsecaseWithAntlr(whitespaceInput)).toThrow(/missing 'usecase'/); - }); - - it('should throw UsecaseParseError for missing usecase keyword', () => { - const missingKeyword = ` - actor User - User --> Login - `; - - expect(() => parseUsecaseWithAntlr(missingKeyword)).toThrow(UsecaseParseError); - expect(() => parseUsecaseWithAntlr(missingKeyword)).toThrow(/missing 'usecase'/); - }); - }); - - describe('Validation Error Handling', () => { - it('should handle duplicate actor IDs by keeping both', () => { - const duplicateActors = `usecase - actor User - actor User - User --> Login - `; - - const result = parseUsecaseWithAntlr(duplicateActors); - expect(result).toBeDefined(); - expect(result.actors).toHaveLength(2); - expect(result.actors[0].id).toBe('User'); - expect(result.actors[1].id).toBe('User'); - }); - - it('should handle self-referencing relationships', () => { - const selfReference = `usecase - actor User - User --> User - `; - - const result = parseUsecaseWithAntlr(selfReference); - expect(result).toBeDefined(); - expect(result.actors).toHaveLength(1); - expect(result.relationships).toHaveLength(1); - expect(result.relationships[0].from).toBe('User'); - expect(result.relationships[0].to).toBe('User'); - }); - - it('should handle very long entity names', () => { - const longName = 'A'.repeat(1000); - const longNameSyntax = `usecase - actor "${longName}" - "${longName}" --> Login - `; - - const result = parseUsecaseWithAntlr(longNameSyntax); - expect(result).toBeDefined(); - expect(result.actors).toHaveLength(1); - expect(result.actors[0].id).toBe(longName); - }); - - it('should handle special characters in names', () => { - const specialCharsSyntax = `usecase - actor "User@Domain.com" - "User@Domain.com" --> "Login/Logout" - `; - - const result = parseUsecaseWithAntlr(specialCharsSyntax); - expect(result).toBeDefined(); - expect(result.actors).toHaveLength(1); - expect(result.actors[0].id).toBe('User@Domain.com'); - expect(result.useCases).toHaveLength(1); - expect(result.useCases[0].id).toBe('Login/Logout'); - }); - }); - - describe('Edge Cases', () => { - it('should handle Unicode characters', () => { - const unicodeSyntax = `usecase - actor "็”จๆˆท" - "็”จๆˆท" --> "็™ปๅฝ•" - `; - - const result = parseUsecaseWithAntlr(unicodeSyntax); - expect(result).toBeDefined(); - expect(result.actors).toHaveLength(1); - expect(result.actors[0].id).toBe('็”จๆˆท'); - expect(result.useCases).toHaveLength(1); - expect(result.useCases[0].id).toBe('็™ปๅฝ•'); - }); - - it('should handle very large diagrams', () => { - let largeDiagram = 'usecase\n'; - for (let i = 0; i < 100; i++) { - largeDiagram += ` actor User${i}\n`; - largeDiagram += ` User${i} --> UseCase${i}\n`; - } - - const result = parseUsecaseWithAntlr(largeDiagram); - expect(result).toBeDefined(); - expect(result.actors).toHaveLength(100); - expect(result.useCases).toHaveLength(100); - expect(result.relationships).toHaveLength(100); - }); - }); -}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a85e46ae5..3db29da34 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -235,6 +235,9 @@ importers: '@types/d3': specifier: ^7.4.3 version: 7.4.3 + antlr4ng: + specifier: ^3.0.7 + version: 3.0.16 cytoscape: specifier: ^3.33.1 version: 3.33.1 @@ -533,67 +536,6 @@ importers: specifier: ^7.3.0 version: 7.3.0 - packages/mermaid/src/vitepress: - dependencies: - '@mdi/font': - specifier: ^7.4.47 - version: 7.4.47 - '@vueuse/core': - specifier: ^13.1.0 - version: 13.9.0(vue@3.5.21(typescript@5.9.2)) - font-awesome: - specifier: ^4.7.0 - version: 4.7.0 - jiti: - specifier: ^2.4.2 - version: 2.5.1 - mermaid: - specifier: workspace:^ - version: link:../.. - vue: - specifier: ^3.4.38 - version: 3.5.21(typescript@5.9.2) - devDependencies: - '@iconify-json/carbon': - specifier: ^1.1.37 - version: 1.2.13 - '@unocss/reset': - specifier: ^66.0.0 - version: 66.5.1 - '@vite-pwa/vitepress': - specifier: ^1.0.0 - version: 1.0.0(vite-plugin-pwa@1.0.3(vite@6.3.6(@types/node@22.18.6)(jiti@2.5.1)(terser@5.44.0)(tsx@4.20.5)(yaml@2.8.1))(workbox-build@7.3.0(@types/babel__core@7.20.5))(workbox-window@7.3.0)) - '@vitejs/plugin-vue': - specifier: ^6.0.0 - version: 6.0.1(vite@6.3.6(@types/node@22.18.6)(jiti@2.5.1)(terser@5.44.0)(tsx@4.20.5)(yaml@2.8.1))(vue@3.5.21(typescript@5.9.2)) - fast-glob: - specifier: ^3.3.3 - version: 3.3.3 - https-localhost: - specifier: ^4.7.1 - version: 4.7.1 - pathe: - specifier: ^2.0.3 - version: 2.0.3 - unocss: - specifier: ^66.4.2 - version: 66.5.1(postcss@8.5.6)(vite@6.3.6(@types/node@22.18.6)(jiti@2.5.1)(terser@5.44.0)(tsx@4.20.5)(yaml@2.8.1)) - unplugin-vue-components: - specifier: ^28.4.0 - version: 28.8.0(@babel/parser@7.28.4)(vue@3.5.21(typescript@5.9.2)) - vite: - specifier: ^6.1.1 - version: 6.3.6(@types/node@22.18.6)(jiti@2.5.1)(terser@5.44.0)(tsx@4.20.5)(yaml@2.8.1) - vite-plugin-pwa: - specifier: ^1.0.0 - version: 1.0.3(vite@6.3.6(@types/node@22.18.6)(jiti@2.5.1)(terser@5.44.0)(tsx@4.20.5)(yaml@2.8.1))(workbox-build@7.3.0(@types/babel__core@7.20.5))(workbox-window@7.3.0) - vitepress: - specifier: 1.6.3 - version: 1.6.3(@algolia/client-search@5.37.0)(@types/node@22.18.6)(axios@1.12.2)(postcss@8.5.6)(search-insights@2.17.3)(terser@5.44.0)(typescript@5.9.2) - workbox-window: - specifier: ^7.3.0 - version: 7.3.0 - packages/parser: dependencies: antlr4ng: @@ -9519,46 +9461,6 @@ packages: terser: optional: true - vite@6.3.6: - resolution: {integrity: sha512-0msEVHJEScQbhkbVTb/4iHZdJ6SXp/AvxL2sjwYQFfBqleHtnCqv1J3sa9zbWz/6kW1m9Tfzn92vW+kZ1WV6QA==} - engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} - hasBin: true - peerDependencies: - '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 - jiti: '>=1.21.0' - less: '*' - lightningcss: ^1.21.0 - sass: '*' - sass-embedded: '*' - stylus: '*' - sugarss: '*' - terser: ^5.16.0 - tsx: ^4.8.1 - yaml: ^2.4.2 - peerDependenciesMeta: - '@types/node': - optional: true - jiti: - optional: true - less: - optional: true - lightningcss: - optional: true - sass: - optional: true - sass-embedded: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - tsx: - optional: true - yaml: - optional: true - vite@7.1.5: resolution: {integrity: sha512-4cKBO9wR75r0BeIWWWId9XK9Lj6La5X846Zw9dFfzMRw38IlTk2iCcUt6hsyiDRcPidc55ZParFYDXi0nXOeLQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -9607,18 +9509,6 @@ packages: vitepress: ^1.0.0-rc.35 vue: '3' - vitepress@1.6.3: - resolution: {integrity: sha512-fCkfdOk8yRZT8GD9BFqusW3+GggWYZ/rYncOfmgcDtP3ualNHCAg+Robxp2/6xfH1WwPHtGpPwv7mbA3qomtBw==} - hasBin: true - peerDependencies: - markdown-it-mathjax3: ^4 - postcss: ^8 - peerDependenciesMeta: - markdown-it-mathjax3: - optional: true - postcss: - optional: true - vitepress@1.6.4: resolution: {integrity: sha512-+2ym1/+0VVrbhNyRoFFesVvBvHAVMZMK0rw60E3X/5349M1GuVdKeazuksqopEdvkKwKGs21Q729jX81/bkBJg==} hasBin: true @@ -13374,14 +13264,6 @@ snapshots: '@ungap/structured-clone@1.3.0': {} - '@unocss/astro@66.5.1(vite@6.3.6(@types/node@22.18.6)(jiti@2.5.1)(terser@5.44.0)(tsx@4.20.5)(yaml@2.8.1))': - dependencies: - '@unocss/core': 66.5.1 - '@unocss/reset': 66.5.1 - '@unocss/vite': 66.5.1(vite@6.3.6(@types/node@22.18.6)(jiti@2.5.1)(terser@5.44.0)(tsx@4.20.5)(yaml@2.8.1)) - optionalDependencies: - vite: 6.3.6(@types/node@22.18.6)(jiti@2.5.1)(terser@5.44.0)(tsx@4.20.5)(yaml@2.8.1) - '@unocss/astro@66.5.1(vite@7.1.5(@types/node@22.18.6)(jiti@2.5.1)(terser@5.44.0)(tsx@4.20.5)(yaml@2.8.1))': dependencies: '@unocss/core': 66.5.1 @@ -13518,19 +13400,6 @@ snapshots: dependencies: '@unocss/core': 66.5.1 - '@unocss/vite@66.5.1(vite@6.3.6(@types/node@22.18.6)(jiti@2.5.1)(terser@5.44.0)(tsx@4.20.5)(yaml@2.8.1))': - dependencies: - '@jridgewell/remapping': 2.3.5 - '@unocss/config': 66.5.1 - '@unocss/core': 66.5.1 - '@unocss/inspector': 66.5.1 - chokidar: 3.6.0 - magic-string: 0.30.19 - pathe: 2.0.3 - tinyglobby: 0.2.15 - unplugin-utils: 0.3.0 - vite: 6.3.6(@types/node@22.18.6)(jiti@2.5.1)(terser@5.44.0)(tsx@4.20.5)(yaml@2.8.1) - '@unocss/vite@66.5.1(vite@7.1.5(@types/node@22.18.6)(jiti@2.5.1)(terser@5.44.0)(tsx@4.20.5)(yaml@2.8.1))': dependencies: '@jridgewell/remapping': 2.3.5 @@ -13603,10 +13472,6 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.11.1': optional: true - '@vite-pwa/vitepress@1.0.0(vite-plugin-pwa@1.0.3(vite@6.3.6(@types/node@22.18.6)(jiti@2.5.1)(terser@5.44.0)(tsx@4.20.5)(yaml@2.8.1))(workbox-build@7.3.0(@types/babel__core@7.20.5))(workbox-window@7.3.0))': - dependencies: - vite-plugin-pwa: 1.0.3(vite@6.3.6(@types/node@22.18.6)(jiti@2.5.1)(terser@5.44.0)(tsx@4.20.5)(yaml@2.8.1))(workbox-build@7.3.0(@types/babel__core@7.20.5))(workbox-window@7.3.0) - '@vite-pwa/vitepress@1.0.0(vite-plugin-pwa@1.0.3(vite@7.1.5(@types/node@22.18.6)(jiti@2.5.1)(terser@5.44.0)(tsx@4.20.5)(yaml@2.8.1))(workbox-build@7.3.0(@types/babel__core@7.20.5))(workbox-window@7.3.0))': dependencies: vite-plugin-pwa: 1.0.3(vite@7.1.5(@types/node@22.18.6)(jiti@2.5.1)(terser@5.44.0)(tsx@4.20.5)(yaml@2.8.1))(workbox-build@7.3.0(@types/babel__core@7.20.5))(workbox-window@7.3.0) @@ -13621,12 +13486,6 @@ snapshots: vite: 5.4.20(@types/node@22.18.6)(terser@5.44.0) vue: 3.5.21(typescript@5.9.2) - '@vitejs/plugin-vue@6.0.1(vite@6.3.6(@types/node@22.18.6)(jiti@2.5.1)(terser@5.44.0)(tsx@4.20.5)(yaml@2.8.1))(vue@3.5.21(typescript@5.9.2))': - dependencies: - '@rolldown/pluginutils': 1.0.0-beta.29 - vite: 6.3.6(@types/node@22.18.6)(jiti@2.5.1)(terser@5.44.0)(tsx@4.20.5)(yaml@2.8.1) - vue: 3.5.21(typescript@5.9.2) - '@vitejs/plugin-vue@6.0.1(vite@7.1.5(@types/node@22.18.6)(jiti@2.5.1)(terser@5.44.0)(tsx@4.20.5)(yaml@2.8.1))(vue@3.5.21(typescript@5.9.2))': dependencies: '@rolldown/pluginutils': 1.0.0-beta.29 @@ -20608,33 +20467,6 @@ snapshots: universalify@2.0.1: {} - unocss@66.5.1(postcss@8.5.6)(vite@6.3.6(@types/node@22.18.6)(jiti@2.5.1)(terser@5.44.0)(tsx@4.20.5)(yaml@2.8.1)): - dependencies: - '@unocss/astro': 66.5.1(vite@6.3.6(@types/node@22.18.6)(jiti@2.5.1)(terser@5.44.0)(tsx@4.20.5)(yaml@2.8.1)) - '@unocss/cli': 66.5.1 - '@unocss/core': 66.5.1 - '@unocss/postcss': 66.5.1(postcss@8.5.6) - '@unocss/preset-attributify': 66.5.1 - '@unocss/preset-icons': 66.5.1 - '@unocss/preset-mini': 66.5.1 - '@unocss/preset-tagify': 66.5.1 - '@unocss/preset-typography': 66.5.1 - '@unocss/preset-uno': 66.5.1 - '@unocss/preset-web-fonts': 66.5.1 - '@unocss/preset-wind': 66.5.1 - '@unocss/preset-wind3': 66.5.1 - '@unocss/preset-wind4': 66.5.1 - '@unocss/transformer-attributify-jsx': 66.5.1 - '@unocss/transformer-compile-class': 66.5.1 - '@unocss/transformer-directives': 66.5.1 - '@unocss/transformer-variant-group': 66.5.1 - '@unocss/vite': 66.5.1(vite@6.3.6(@types/node@22.18.6)(jiti@2.5.1)(terser@5.44.0)(tsx@4.20.5)(yaml@2.8.1)) - optionalDependencies: - vite: 6.3.6(@types/node@22.18.6)(jiti@2.5.1)(terser@5.44.0)(tsx@4.20.5)(yaml@2.8.1) - transitivePeerDependencies: - - postcss - - supports-color - unocss@66.5.1(postcss@8.5.6)(vite@7.1.5(@types/node@22.18.6)(jiti@2.5.1)(terser@5.44.0)(tsx@4.20.5)(yaml@2.8.1)): dependencies: '@unocss/astro': 66.5.1(vite@7.1.5(@types/node@22.18.6)(jiti@2.5.1)(terser@5.44.0)(tsx@4.20.5)(yaml@2.8.1)) @@ -20806,17 +20638,6 @@ snapshots: transitivePeerDependencies: - supports-color - vite-plugin-pwa@1.0.3(vite@6.3.6(@types/node@22.18.6)(jiti@2.5.1)(terser@5.44.0)(tsx@4.20.5)(yaml@2.8.1))(workbox-build@7.3.0(@types/babel__core@7.20.5))(workbox-window@7.3.0): - dependencies: - debug: 4.4.3(supports-color@8.1.1) - pretty-bytes: 6.1.1 - tinyglobby: 0.2.15 - vite: 6.3.6(@types/node@22.18.6)(jiti@2.5.1)(terser@5.44.0)(tsx@4.20.5)(yaml@2.8.1) - workbox-build: 7.3.0(@types/babel__core@7.20.5) - workbox-window: 7.3.0 - transitivePeerDependencies: - - supports-color - vite-plugin-pwa@1.0.3(vite@7.1.5(@types/node@22.18.6)(jiti@2.5.1)(terser@5.44.0)(tsx@4.20.5)(yaml@2.8.1))(workbox-build@7.3.0(@types/babel__core@7.20.5))(workbox-window@7.3.0): dependencies: debug: 4.4.3(supports-color@8.1.1) @@ -20838,22 +20659,6 @@ snapshots: fsevents: 2.3.3 terser: 5.44.0 - vite@6.3.6(@types/node@22.18.6)(jiti@2.5.1)(terser@5.44.0)(tsx@4.20.5)(yaml@2.8.1): - dependencies: - esbuild: 0.25.10 - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 - postcss: 8.5.6 - rollup: 4.50.2 - tinyglobby: 0.2.15 - optionalDependencies: - '@types/node': 22.18.6 - fsevents: 2.3.3 - jiti: 2.5.1 - terser: 5.44.0 - tsx: 4.20.5 - yaml: 2.8.1 - vite@7.1.5(@types/node@22.18.6)(jiti@2.5.1)(terser@5.44.0)(tsx@4.20.5)(yaml@2.8.1): dependencies: esbuild: 0.25.10 @@ -20880,55 +20685,6 @@ snapshots: vitepress: 1.6.4(@algolia/client-search@5.37.0)(@types/node@22.18.6)(axios@1.12.2)(postcss@8.5.6)(search-insights@2.17.3)(terser@5.44.0)(typescript@5.7.3) vue: 3.5.21(typescript@5.7.3) - vitepress@1.6.3(@algolia/client-search@5.37.0)(@types/node@22.18.6)(axios@1.12.2)(postcss@8.5.6)(search-insights@2.17.3)(terser@5.44.0)(typescript@5.9.2): - dependencies: - '@docsearch/css': 3.8.2 - '@docsearch/js': 3.8.2(@algolia/client-search@5.37.0)(search-insights@2.17.3) - '@iconify-json/simple-icons': 1.2.52 - '@shikijs/core': 2.5.0 - '@shikijs/transformers': 2.5.0 - '@shikijs/types': 2.5.0 - '@types/markdown-it': 14.1.2 - '@vitejs/plugin-vue': 5.2.4(vite@5.4.20(@types/node@22.18.6)(terser@5.44.0))(vue@3.5.21(typescript@5.9.2)) - '@vue/devtools-api': 7.7.7 - '@vue/shared': 3.5.21 - '@vueuse/core': 12.8.2(typescript@5.9.2) - '@vueuse/integrations': 12.8.2(axios@1.12.2)(focus-trap@7.6.5)(typescript@5.9.2) - focus-trap: 7.6.5 - mark.js: 8.11.1 - minisearch: 7.1.2 - shiki: 2.5.0 - vite: 5.4.20(@types/node@22.18.6)(terser@5.44.0) - vue: 3.5.21(typescript@5.9.2) - optionalDependencies: - postcss: 8.5.6 - transitivePeerDependencies: - - '@algolia/client-search' - - '@types/node' - - '@types/react' - - async-validator - - axios - - change-case - - drauu - - fuse.js - - idb-keyval - - jwt-decode - - less - - lightningcss - - nprogress - - qrcode - - react - - react-dom - - sass - - sass-embedded - - search-insights - - sortablejs - - stylus - - sugarss - - terser - - typescript - - universal-cookie - vitepress@1.6.4(@algolia/client-search@5.37.0)(@types/node@22.18.6)(axios@1.12.2)(postcss@8.5.6)(search-insights@2.17.3)(terser@5.44.0)(typescript@5.7.3): dependencies: '@docsearch/css': 3.8.2 diff --git a/scripts/antlr-generate.mts b/scripts/antlr-generate.mts new file mode 100644 index 000000000..536c8fccb --- /dev/null +++ b/scripts/antlr-generate.mts @@ -0,0 +1,230 @@ +#!/usr/bin/env tsx +/* eslint-disable no-console */ +/* cspell:disable */ + +import { execSync } from 'child_process'; +import { existsSync, mkdirSync, readdirSync, statSync } from 'fs'; +import { join, dirname, basename } from 'path'; + +/** + * Generic ANTLR generator script that finds all .g4 files and generates parsers + * Automatically creates generated folders and runs antlr4ng for each diagram type + */ + +interface GrammarInfo { + lexerFile: string; + parserFile: string; + outputDir: string; + diagramType: string; +} + +/** + * Recursively find all .g4 files in a directory + */ +function findG4Files(dir: string): string[] { + const files: string[] = []; + + if (!existsSync(dir)) { + return files; + } + + const entries = readdirSync(dir); + + for (const entry of entries) { + const fullPath = join(dir, entry); + const stat = statSync(fullPath); + + if (stat.isDirectory()) { + files.push(...findG4Files(fullPath)); + } else if (entry.endsWith('.g4')) { + files.push(fullPath); + } + } + + return files; +} + +/** + * Find all ANTLR grammar files in the diagrams directory + */ +function findGrammarFiles(): GrammarInfo[] { + const grammarFiles: GrammarInfo[] = []; + + // Determine the correct path based on current working directory + const cwd = process.cwd(); + let diagramsPath: string; + + if (cwd.endsWith('/packages/mermaid')) { + // Running from mermaid package directory + diagramsPath = 'src/diagrams'; + } else { + // Running from project root + diagramsPath = 'packages/mermaid/src/diagrams'; + } + + // Find all .g4 files + const g4Files = findG4Files(diagramsPath); + + // Group by directory (each diagram should have a Lexer and Parser pair) + const grammarDirs = new Map(); + + for (const file of g4Files) { + const dir = dirname(file); + if (!grammarDirs.has(dir)) { + grammarDirs.set(dir, []); + } + grammarDirs.get(dir)!.push(file); + } + + // Process each directory + for (const [dir, files] of grammarDirs) { + const lexerFile = files.find((f) => f.includes('Lexer.g4')); + const parserFile = files.find((f) => f.includes('Parser.g4')); + + if (lexerFile && parserFile) { + // Extract diagram type from path + const pathParts = dir.split('/'); + const diagramIndex = pathParts.indexOf('diagrams'); + const diagramType = diagramIndex >= 0 ? pathParts[diagramIndex + 1] : 'unknown'; + + grammarFiles.push({ + lexerFile, + parserFile, + outputDir: join(dir, 'generated'), + diagramType, + }); + } else { + console.warn(`โš ๏ธ Incomplete grammar pair in ${dir}:`); + console.warn(` Lexer: ${lexerFile ?? 'MISSING'}`); + console.warn(` Parser: ${parserFile ?? 'MISSING'}`); + } + } + + return grammarFiles; +} + +/** + * Clean the generated directory + */ +function cleanGeneratedDir(outputDir: string): void { + try { + execSync(`rimraf "${outputDir}"`, { stdio: 'inherit' }); + console.log(`๐Ÿงน Cleaned: ${outputDir}`); + } catch (error) { + console.warn(`โš ๏ธ Failed to clean ${outputDir}:`, error); + } +} + +/** + * Create the generated directory if it doesn't exist + */ +function ensureGeneratedDir(outputDir: string): void { + if (!existsSync(outputDir)) { + mkdirSync(outputDir, { recursive: true }); + console.log(`๐Ÿ“ Created: ${outputDir}`); + } +} + +/** + * Generate ANTLR files for a grammar pair + */ +function generateAntlrFiles(grammar: GrammarInfo): void { + const { lexerFile, parserFile, outputDir, diagramType } = grammar; + + console.log(`\n๐ŸŽฏ Generating ANTLR files for ${diagramType} diagram...`); + console.log(` Lexer: ${basename(lexerFile)}`); + console.log(` Parser: ${basename(parserFile)}`); + console.log(` Output: ${outputDir}`); + + try { + // Clean and create output directory + cleanGeneratedDir(outputDir); + ensureGeneratedDir(outputDir); + + // Determine common header lib path for imported grammars + const cwd = process.cwd(); + const commonLibPath = cwd.endsWith('/packages/mermaid') + ? 'src/diagrams/common/parser/antlr' + : 'packages/mermaid/src/diagrams/common/parser/antlr'; + + // TODO: Use JS api instead of CLI + // Generate ANTLR files + const command = [ + 'pnpm', + 'dlx', + 'antlr-ng', + '-Dlanguage=TypeScript', + '-l', + '-v', + `--lib "${commonLibPath}"`, + `-o "${outputDir}"`, + `"${lexerFile}"`, + `"${parserFile}"`, + ].join(' '); + + console.log(` Command: ${command}`); + execSync(command, { stdio: 'inherit' }); + + console.log(`โœ… Successfully generated ANTLR files for ${diagramType}`); + } catch (error) { + console.error(`โŒ Failed to generate ANTLR files for ${diagramType}:`, error); + throw error; + } +} + +/** + * Main function + */ +export function generateAntlr(): void { + console.log('๐Ÿš€ ANTLR Generator - Finding and generating all grammar files...\n'); + + try { + // Find all grammar files + const grammarFiles = findGrammarFiles(); + + if (grammarFiles.length === 0) { + console.log('โ„น๏ธ No ANTLR grammar files found.'); + return; + } + + console.log(`๐Ÿ“‹ Found ${grammarFiles.length} diagram(s) with ANTLR grammars:`); + for (const grammar of grammarFiles) { + console.log(` โ€ข ${grammar.diagramType}`); + } + + // Generate files for each grammar + let successCount = 0; + let failureCount = 0; + + for (const grammar of grammarFiles) { + try { + generateAntlrFiles(grammar); + successCount++; + } catch (error) { + failureCount++; + console.error(`Failed to process ${grammar.diagramType}:`, error); + } + } + + // Summary + console.log('\n๐Ÿ“Š Generation Summary:'); + console.log(` โœ… Successful: ${successCount}`); + console.log(` โŒ Failed: ${failureCount}`); + console.log(` ๐Ÿ“ Total: ${grammarFiles.length}`); + + if (failureCount > 0) { + console.error('\nโŒ Some ANTLR generations failed. Check the errors above.'); + process.exit(1); + } else { + console.log('\n๐ŸŽ‰ All ANTLR files generated successfully!'); + } + } catch (error) { + console.error('โŒ Fatal error during ANTLR generation:', error); + process.exit(1); + } +} + +// Run the script +if (import.meta.url === `file://${process.argv[1]}`) { + generateAntlr(); +} diff --git a/scripts/antlr-watch.mts b/scripts/antlr-watch.mts new file mode 100755 index 000000000..60b6be99e --- /dev/null +++ b/scripts/antlr-watch.mts @@ -0,0 +1,119 @@ +#!/usr/bin/env tsx +/* eslint-disable no-console */ + +import chokidar from 'chokidar'; +import { execSync } from 'child_process'; + +/** + * ANTLR Watch Script + * + * This script generates ANTLR files and then watches for changes to .g4 grammar files, + * automatically regenerating the corresponding parsers when changes are detected. + * + * Features: + * - Initial generation of all ANTLR files + * - Watch .g4 files for changes + * - Debounced regeneration to avoid multiple builds + * - Clear logging and progress reporting + * - Graceful shutdown handling + */ + +let isGenerating = false; +let timeoutID: NodeJS.Timeout | undefined = undefined; + +/** + * Generate ANTLR parser files from grammar files + */ +function generateAntlr(): void { + if (isGenerating) { + console.log('โณ ANTLR generation already in progress, skipping...'); + return; + } + + try { + isGenerating = true; + console.log('๐ŸŽฏ ANTLR: Generating parser files...'); + execSync('tsx scripts/antlr-generate.mts', { stdio: 'inherit' }); + console.log('โœ… ANTLR: Parser files generated successfully\n'); + } catch (error) { + console.error('โŒ ANTLR: Failed to generate parser files:', error); + } finally { + isGenerating = false; + } +} + +/** + * Handle file change events with debouncing + */ +function handleFileChange(path: string): void { + if (timeoutID !== undefined) { + clearTimeout(timeoutID); + } + + console.log(`๐ŸŽฏ Grammar file changed: ${path}`); + + // Debounce file changes to avoid multiple regenerations + timeoutID = setTimeout(() => { + console.log('๐Ÿ”„ Regenerating ANTLR files...\n'); + generateAntlr(); + timeoutID = undefined; + }, 500); // 500ms debounce +} + +/** + * Setup graceful shutdown + */ +function setupGracefulShutdown(): void { + const shutdown = () => { + console.log('\n๐Ÿ›‘ Shutting down ANTLR watch...'); + if (timeoutID) { + clearTimeout(timeoutID); + } + process.exit(0); + }; + + process.on('SIGINT', shutdown); + process.on('SIGTERM', shutdown); +} + +/** + * Main function + */ +function main(): void { + console.log('๐Ÿš€ ANTLR Watch - Generate and watch grammar files for changes\n'); + + // Setup graceful shutdown + setupGracefulShutdown(); + + // Initial generation + generateAntlr(); + + // Setup file watcher + console.log('๐Ÿ‘€ Watching for .g4 file changes...'); + console.log('๐Ÿ“ Pattern: **/src/**/parser/antlr/*.g4'); + console.log('๐Ÿ›‘ Press Ctrl+C to stop watching\n'); + + const watcher = chokidar.watch('**/src/**/parser/antlr/*.g4', { + ignoreInitial: true, + ignored: [/node_modules/, /dist/, /docs/, /coverage/], + persistent: true, + }); + + watcher + .on('change', handleFileChange) + .on('add', handleFileChange) + .on('error', (error) => { + console.error('โŒ Watcher error:', error); + }) + .on('ready', () => { + console.log('โœ… Watcher ready - monitoring grammar files for changes...\n'); + }); + + // Keep the process alive + process.stdin.resume(); +} + +// Run the script +if (import.meta.url === `file://${process.argv[1]}`) { + main(); +}