diff --git a/.build/antlr-cli.ts b/.build/antlr-cli.ts new file mode 100644 index 000000000..53187b8cf --- /dev/null +++ b/.build/antlr-cli.ts @@ -0,0 +1,92 @@ +/* eslint-disable no-console */ + +import { exec } from 'node:child_process'; +import { promisify } from 'node:util'; +import { resolve, dirname } from 'node:path'; +import { readFile, mkdir, access } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; + +const execAsync = promisify(exec); + +interface AntlrGrammarConfig { + id: string; + grammar: string; + outputDir: string; + language: string; + generateVisitor?: boolean; + generateListener?: boolean; +} + +interface AntlrConfig { + projectName: string; + grammars: AntlrGrammarConfig[]; + mode: string; +} + +export async function generateFromConfig(configFile: string): Promise { + const configPath = resolve(configFile); + + if (!existsSync(configPath)) { + throw new Error(`ANTLR config file not found: ${configPath}`); + } + + const configContent = await readFile(configPath, 'utf-8'); + const config: AntlrConfig = JSON.parse(configContent); + + const configDir = dirname(configPath); + + for (const grammarConfig of config.grammars) { + await generateGrammar(grammarConfig, configDir); + } +} + +async function generateGrammar(grammarConfig: AntlrGrammarConfig, baseDir: string): Promise { + const grammarFile = resolve(baseDir, grammarConfig.grammar); + const outputDir = resolve(baseDir, grammarConfig.outputDir); + + // Check if grammar file exists + try { + await access(grammarFile); + } catch { + throw new Error(`Grammar file not found: ${grammarFile}`); + } + + // Ensure output directory exists + await mkdir(outputDir, { recursive: true }); + + // Build ANTLR command arguments + + // eslint-disable-next-line @cspell/spellchecker + const args = ['antlr-ng', `-Dlanguage=${grammarConfig.language}`]; + + if (grammarConfig.generateVisitor) { + args.push('--generate-visitor'); + } + + if (grammarConfig.generateListener) { + args.push('--generate-listener'); + } + + args.push('-o', `"${outputDir}"`, `"${grammarFile}"`); + + const command = `npx ${args.join(' ')}`; + + try { + await execAsync(command); + console.log(`Generated ANTLR files for ${grammarConfig.id}`); + } catch (error) { + console.error(`Failed to generate ANTLR files for ${grammarConfig.id}:`); + throw error; + } +} + +// CLI interface +if (import.meta.url === `file://${process.argv[1]}`) { + const configFile = process.argv[2] || './packages/parser/antlr-config.json'; + try { + await generateFromConfig(configFile); + console.log('ANTLR generation completed successfully!'); + } catch (error) { + console.error('ANTLR generation failed:', error.message); + } +} diff --git a/.build/generateAntlr.ts b/.build/generateAntlr.ts new file mode 100644 index 000000000..4516e09b0 --- /dev/null +++ b/.build/generateAntlr.ts @@ -0,0 +1,5 @@ +import { generateFromConfig } from './antlr-cli.js'; + +export async function generateAntlr() { + await generateFromConfig('./packages/parser/antlr-config.json'); +} diff --git a/.build/jsonSchema.ts b/.build/jsonSchema.ts index 48a9883de..3aecd1a79 100644 --- a/.build/jsonSchema.ts +++ b/.build/jsonSchema.ts @@ -28,6 +28,7 @@ const MERMAID_CONFIG_DIAGRAM_KEYS = [ 'packet', 'architecture', 'radar', + 'usecase', ] as const; /** diff --git a/.cspell/code-terms.txt b/.cspell/code-terms.txt index 8b549f888..98152945f 100644 --- a/.cspell/code-terms.txt +++ b/.cspell/code-terms.txt @@ -143,6 +143,9 @@ typeof typestr unshift urlsafe +usecase +Usecase +USECASE verifymethod VERIFYMTHD WARN_DOCSDIR_DOESNT_MATCH diff --git a/.esbuild/build.ts b/.esbuild/build.ts index 72c0af869..05f370a1a 100644 --- a/.esbuild/build.ts +++ b/.esbuild/build.ts @@ -4,6 +4,7 @@ import { packageOptions } from '../.build/common.js'; import { generateLangium } from '../.build/generateLangium.js'; import type { MermaidBuildOptions } from './util.js'; import { defaultOptions, getBuildConfig } from './util.js'; +import { generateAntlr } from '../.build/generateAntlr.js'; const shouldVisualize = process.argv.includes('--visualize'); @@ -95,6 +96,7 @@ const buildTinyMermaid = async () => { const main = async () => { await generateLangium(); + await generateAntlr(); await mkdir('stats', { recursive: true }); const packageNames = Object.keys(packageOptions) as (keyof typeof packageOptions)[]; // it should build `parser` before `mermaid` because it's a dependency diff --git a/.gitignore b/.gitignore index 7eb55d5cb..52028e42e 100644 --- a/.gitignore +++ b/.gitignore @@ -51,5 +51,8 @@ demos/dev/** tsx-0/** vite.config.ts.timestamp-* -# autogenereated by langium-cli +# autogenereated by langium-cli and antlr-cli generated/ + +# autogenereated by antlr-cli +.antlr/ diff --git a/.vite/build.ts b/.vite/build.ts index 480dd6b30..96bec34c1 100644 --- a/.vite/build.ts +++ b/.vite/build.ts @@ -10,6 +10,7 @@ import type { TemplateType } from 'rollup-plugin-visualizer/dist/plugin/template import istanbul from 'vite-plugin-istanbul'; import { packageOptions } from '../.build/common.js'; import { generateLangium } from '../.build/generateLangium.js'; +import { generateAntlr } from '../.build/generateAntlr.js'; const visualize = process.argv.includes('--visualize'); const watch = process.argv.includes('--watch'); @@ -123,6 +124,7 @@ const main = async () => { }; await generateLangium(); +await generateAntlr(); if (watch) { await build(getBuildConfig({ minify: false, watch, core: false, entryName: 'parser' })); diff --git a/cypress/integration/rendering/usecase.spec.ts b/cypress/integration/rendering/usecase.spec.ts new file mode 100644 index 000000000..5277bb5f1 --- /dev/null +++ b/cypress/integration/rendering/usecase.spec.ts @@ -0,0 +1,425 @@ +import { imgSnapshotTest, renderGraph } from '../../helpers/util.ts'; + +describe('Usecase diagram', () => { + it('should render a simple usecase diagram with actors and use cases', () => { + imgSnapshotTest( + ` + usecase + actor User + actor Admin + User --> Login + Admin --> "Manage Users" + User --> "View Profile" + ` + ); + }); + + it('should render usecase diagram with quoted actor names', () => { + imgSnapshotTest( + `usecase + actor "Customer Service" + actor "System Administrator" + "Customer Service" --> "Handle Tickets" + "System Administrator" --> "Manage System" + ` + ); + }); + + it('should render usecase diagram with different arrow types', () => { + imgSnapshotTest( + `usecase + actor User + actor Admin + User --> Login + Admin <-- Logout + User -- "View Data" + ` + ); + }); + + it('should render usecase diagram with edge labels', () => { + imgSnapshotTest( + `usecase + actor Developer + actor Manager + Developer --important--> "Write Code" + Manager --review--> "Code Review" + Developer --urgent--> Manager + ` + ); + }); + + it('should render usecase diagram with node ID syntax', () => { + imgSnapshotTest( + `usecase + actor User + User --> a(Login) + User --> b("View Profile") + User --> c("Update Settings") + ` + ); + }); + + it('should render usecase diagram with comma-separated actors', () => { + imgSnapshotTest( + `usecase + actor "Customer Service", "Technical Support", "Sales Team" + actor SystemAdmin + "Customer Service" --> "Handle Tickets" + "Technical Support" --> "Resolve Issues" + "Sales Team" --> "Process Orders" + SystemAdmin --> "Manage System" + ` + ); + }); + + it('should render usecase diagram with actor metadata', () => { + imgSnapshotTest( + `usecase + actor User@{ "type" : "primary", "icon" : "user" } + actor Admin@{ "type" : "secondary", "icon" : "admin" } + actor System@{ "type" : "hollow", "icon" : "system" } + User --> Login + Admin --> "Manage Users" + System --> "Process Data" + ` + ); + }); + + it('should render usecase diagram with system boundaries (rect type)', () => { + imgSnapshotTest( + `usecase + actor Admin, User + systemBoundary "Authentication" + Login + Logout + "Reset Password" + end + "Authentication"@{ type: rect } + Admin --> Login + User --> Login + User --> "Reset Password" + ` + ); + }); + + it('should render usecase diagram with system boundaries (package type)', () => { + imgSnapshotTest( + `usecase + actor Admin, User + systemBoundary "Authentication" + Login + Logout + "Reset Password" + end + "Authentication"@{ type: package } + Admin --> Login + User --> Login + User --> "Reset Password" + ` + ); + }); + + it('should render complex usecase diagram with all features', () => { + imgSnapshotTest( + `usecase + actor "Customer Service"@{ "type" : "primary", "icon" : "user" } + actor "System Admin"@{ "type" : "secondary", "icon" : "admin" } + actor "Database"@{ "type" : "hollow", "icon" : "database" } + + systemBoundary "Customer Support System" + "Handle Tickets" + "View Customer Info" + end + "Customer Support System"@{ type: package } + + systemBoundary "Administration" + "User Management" + "System Config" + end + + "Customer Service" --priority--> "Handle Tickets" + "Customer Service" --> "View Customer Info" + "System Admin" --manage--> "User Management" + "System Admin" --> "System Config" + "Database" <-- "Handle Tickets" + "Database" <-- "View Customer Info" + "Database" <-- "User Management" + ` + ); + }); + + it('should render usecase diagram with actor-to-actor relationships', () => { + imgSnapshotTest( + `usecase + actor Manager + actor Developer + actor Tester + + Manager --supervises--> Developer + Manager --coordinates--> Tester + Developer --collaborates--> Tester + + Developer --> "Write Code" + Tester --> "Test Code" + Manager --> "Review Progress" + ` + ); + }); + + it('should render usecase diagram with mixed relationship types', () => { + imgSnapshotTest( + `usecase + actor User + actor Admin + + User --> "Basic Login" + Admin --> "Advanced Login" + User --includes--> "View Profile" + Admin --extends--> "Manage Profiles" + + "Basic Login" <-- "Advanced Login" + ` + ); + }); + + it('should render usecase diagram with long labels and text wrapping', () => { + imgSnapshotTest( + `usecase + actor "Customer Service Representative" + actor "System Administrator with Extended Privileges" + + "Customer Service Representative" --Process--> "Handle Complex Customer Support Tickets" + "System Administrator with Extended Privileges" --> "Manage System Configuration and User Permissions" + ` + ); + }); + + it('should render usecase diagram with special characters in names', () => { + imgSnapshotTest( + `usecase + actor "User@Company.com" + actor "Admin (Level-1)" + "User@Company.com" --> a("Login & Authenticate") + "Admin (Level-1)" --> b("Manage Users & Permissions") + ` + ); + }); + + it('should render usecase diagram when useMaxWidth is true (default)', () => { + renderGraph( + `usecase + actor User + actor Admin + User --> Login + Admin --> "Manage System" + User --> "View Profile" + `, + { usecase: { useMaxWidth: true } } + ); + cy.get('svg').should((svg) => { + expect(svg).to.have.attr('width', '100%'); + const style = svg.attr('style'); + expect(style).to.match(/^max-width: [\d.]+px;$/); + }); + }); + + it('should render usecase diagram when useMaxWidth is false', () => { + renderGraph( + `usecase + actor User + actor Admin + User --> Login + Admin --> "Manage System" + `, + { usecase: { useMaxWidth: false } } + ); + cy.get('svg').should((svg) => { + const width = parseFloat(svg.attr('width')); + expect(width).to.be.greaterThan(200); + expect(svg).to.not.have.attr('style'); + }); + }); + + it('should render empty usecase diagram', () => { + imgSnapshotTest(`usecase`); + }); + + it('should render usecase diagram with only actors', () => { + imgSnapshotTest( + `usecase + actor User + actor Admin + actor Guest + ` + ); + }); + + it('should render usecase diagram with implicit use case creation', () => { + imgSnapshotTest( + `usecase + actor User + User --> Login + User --> Register + User --> "Forgot Password" + ` + ); + }); + + it('should render usecase diagram with nested system boundaries', () => { + imgSnapshotTest( + `usecase + actor User + actor Admin + + systemBoundary "Main System" + Login + Logout + "Create User" + "Delete User" + end + + User --> Login + User --> Logout + Admin --> "Create User" + Admin --> "Delete User" + ` + ); + }); + + it('should render usecase diagram with multiple edge labels on same relationship', () => { + imgSnapshotTest( + `usecase + actor Developer + actor Manager + + Developer --"code review"--> Manager + Developer --"status update"--> Manager + Manager --"feedback"--> Developer + Manager --"approval"--> Developer + ` + ); + }); + + it('should render usecase diagram with various actor icon types', () => { + imgSnapshotTest( + `usecase + actor User@{ "icon": "user" } + actor Admin@{ "icon": "admin" } + actor Database@{ "icon": "database" } + actor API@{ "icon": "api" } + actor Mobile@{ "icon": "mobile" } + actor Web@{ "icon": "web" } + + User --> "Access System" + Admin --> "Manage System" + Database --> "Store Data" + API --> "Provide Services" + Mobile --> "Mobile Access" + Web --> "Web Access" + ` + ); + }); + + it('should render usecase diagram with mixed arrow directions and labels', () => { + imgSnapshotTest( + `usecase + actor User + actor System + actor Admin + + User --request--> System + System --response--> User + System <--monitor-- Admin + Admin --configure--> System + User -- "direct access" -- Admin + ` + ); + }); + + it('should render usecase diagram with boundary-less use cases', () => { + imgSnapshotTest( + `usecase + actor User + actor Admin + + systemBoundary "Secure Area" + "Admin Panel" + "User Management" + end + + User --> "Public Login" + User --> "Guest Access" + Admin --> "Public Login" + Admin --> "Admin Panel" + Admin --> "User Management" + ` + ); + }); + + it('should render usecase diagram with complex metadata combinations', () => { + imgSnapshotTest( + `usecase + actor "Primary User"@{ "type": "primary", "icon": "user", "fillColor": "lightblue" } + actor "Secondary User"@{ "type": "secondary", "icon": "client", "strokeColor": "red" } + actor "System Service"@{ "type": "hollow", "icon": "service", "strokeWidth": "3" } + + "Primary User" --"high priority"--> a("Critical Process") + "Secondary User" --"low priority"--> b("Background Task") + "System Service" --"automated"--> c("System Maintenance") + ` + ); + }); + + it('should render usecase diagram with Unicode characters', () => { + imgSnapshotTest( + `usecase + actor "用户"@{ "icon": "user" } + actor "管理员"@{ "icon": "admin" } + + "用户" --"登录"--> "系统访问" + "管理员" --"管理"--> "用户管理" + "用户" --> "数据查看" + ` + ); + }); + + it('should render large usecase diagram with many elements', () => { + imgSnapshotTest( + `usecase + actor User1, User2, User3, User4 + actor Admin1, Admin2 + actor System1@{ "icon": "system" } + actor System2@{ "icon": "database" } + + systemBoundary "Module A" + "Feature A1" + "Feature A2" + "Admin A1" + end + "Module A"@{ type: package } + + systemBoundary "Module B" + "Feature B1" + "Feature B2" + "Admin B1" + end + + User1 --> "Feature A1" + User2 --> "Feature A2" + Admin1 --> "Admin A1" + User3 --> "Feature B1" + User4 --> "Feature B2" + Admin2 --> "Admin B1" + + System1 <-- "Feature A1" + System1 <-- "Feature B1" + System2 <-- "Admin A1" + System2 <-- "Admin B1" + + User1 --"collaborates"--> User2 + Admin1 --"supervises"--> Admin2 + ` + ); + }); +}); diff --git a/demos/usecase.html b/demos/usecase.html new file mode 100644 index 000000000..485ed9058 --- /dev/null +++ b/demos/usecase.html @@ -0,0 +1,234 @@ + + + + + + + + + + + + + + + + + + + +

Test Diagram

+
+
+usecase
+direction LR
+        actor User1, User2, User3, User4
+        actor Admin1, Admin2
+        actor System1@{ "icon": "bell" }
+        actor System2@{ "icon": "database" }
+
+        systemBoundary "Module A"
+          "Feature A1"
+          "Feature A2"
+          "Admin A1"
+
+        end
+        "Module A"@{ type: package }
+
+        systemBoundary "Module B"
+          "Feature B1"
+          "Feature B2"
+          "Admin B1"
+        end
+
+        User1 --important--> "Feature A1"
+        User2 --> "Feature A2"
+        Admin1 --> "Admin A1"
+        User3 --> "Feature B1"
+        User4 --> "Feature B2"
+        Admin2 --> "Admin B1"
+
+        System1 <-- "Feature A1"
+        System1 <-- "Feature B1"
+        System2 <-- "Admin A1"
+        System2 <-- "Admin B1"
+
+        User1 --"collaborates"--> User2
+        Admin1 --"supervises"--> Admin2
+
+    
+
+ + + + diff --git a/docs/config/setup/defaultConfig/variables/configKeys.md b/docs/config/setup/defaultConfig/variables/configKeys.md index 82b68d780..f5a461b47 100644 --- a/docs/config/setup/defaultConfig/variables/configKeys.md +++ b/docs/config/setup/defaultConfig/variables/configKeys.md @@ -12,4 +12,4 @@ > `const` **configKeys**: `Set`<`string`> -Defined in: [packages/mermaid/src/defaultConfig.ts:292](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/defaultConfig.ts#L292) +Defined in: [packages/mermaid/src/defaultConfig.ts:295](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/defaultConfig.ts#L295) diff --git a/docs/config/setup/mermaid/interfaces/MermaidConfig.md b/docs/config/setup/mermaid/interfaces/MermaidConfig.md index f4c5b0b2b..cbbd43260 100644 --- a/docs/config/setup/mermaid/interfaces/MermaidConfig.md +++ b/docs/config/setup/mermaid/interfaces/MermaidConfig.md @@ -105,7 +105,7 @@ You can set this attribute to base the seed on a static string. > `optional` **dompurifyConfig**: `Config` -Defined in: [packages/mermaid/src/config.type.ts:213](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L213) +Defined in: [packages/mermaid/src/config.type.ts:214](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L214) --- @@ -179,7 +179,7 @@ See > `optional` **fontSize**: `number` -Defined in: [packages/mermaid/src/config.type.ts:215](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L215) +Defined in: [packages/mermaid/src/config.type.ts:216](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L216) --- @@ -292,7 +292,7 @@ Defines which main look to use for the diagram. > `optional` **markdownAutoWrap**: `boolean` -Defined in: [packages/mermaid/src/config.type.ts:216](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L216) +Defined in: [packages/mermaid/src/config.type.ts:217](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L217) --- @@ -424,7 +424,7 @@ Defined in: [packages/mermaid/src/config.type.ts:198](https://github.com/mermaid > `optional` **suppressErrorRendering**: `boolean` -Defined in: [packages/mermaid/src/config.type.ts:222](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L222) +Defined in: [packages/mermaid/src/config.type.ts:223](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L223) Suppresses inserting 'Syntax error' diagram in the DOM. This is useful when you want to control how to handle syntax errors in your application. @@ -466,11 +466,19 @@ Defined in: [packages/mermaid/src/config.type.ts:196](https://github.com/mermaid --- +### usecase? + +> `optional` **usecase**: `UsecaseDiagramConfig` + +Defined in: [packages/mermaid/src/config.type.ts:213](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L213) + +--- + ### wrap? > `optional` **wrap**: `boolean` -Defined in: [packages/mermaid/src/config.type.ts:214](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L214) +Defined in: [packages/mermaid/src/config.type.ts:215](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L215) --- diff --git a/docs/syntax/flowchart.md b/docs/syntax/flowchart.md index 23c34509c..013fc0797 100644 --- a/docs/syntax/flowchart.md +++ b/docs/syntax/flowchart.md @@ -344,6 +344,7 @@ Below is a comprehensive list of the newly introduced shapes and their correspon | Display | Curved Trapezoid | `curv-trap` | Represents a display | `curved-trapezoid`, `display` | | Divided Process | Divided Rectangle | `div-rect` | Divided process shape | `div-proc`, `divided-process`, `divided-rectangle` | | Document | Document | `doc` | Represents a document | `doc`, `document` | +| Ellipse | Ellipse | `ellipse` | Ellipse shape | `oval` | | Event | Rounded Rectangle | `rounded` | Represents an event | `event` | | Extract | Triangle | `tri` | Extraction process | `extract`, `triangle` | | Fork/Join | Filled Rectangle | `fork` | Fork or join in process flow | `join` | diff --git a/packages/examples/src/examples/usecase.ts b/packages/examples/src/examples/usecase.ts new file mode 100644 index 000000000..b689ec164 --- /dev/null +++ b/packages/examples/src/examples/usecase.ts @@ -0,0 +1,51 @@ +import type { DiagramMetadata } from '../types.js'; + +export default { + id: 'usecase', + name: 'Use Case Diagram', + description: 'Visualize system functionality and user interactions', + examples: [ + { + title: 'Basic Use Case', + isDefault: true, + code: `usecase +actor User +actor Admin +User --> (Login) +User --> (View Profile) +Admin --> (Manage Users) +Admin --> (View Reports)`, + }, + { + title: 'System Boundary', + code: `usecase +actor Customer +actor Support + +SystemBoundary@{ type: rect } "E-commerce System" { + Customer --> (Browse Products) + Customer --> (Place Order) + Customer --> (Track Order) +} + +SystemBoundary@{ type: package } "Admin Panel" { + Support --> (Process Orders) + Support --> (Handle Returns) +}`, + }, + { + title: 'Actor Relationships', + code: `usecase +actor Developer1 +actor Developer2 +actor Manager + +Developer1 --> (Write Code) +Developer2 --> (Review Code) +Manager --> (Approve Release) + +Developer1 --> Developer2 +Manager --> Developer1`, + }, + ], +} satisfies DiagramMetadata; diff --git a/packages/examples/src/index.ts b/packages/examples/src/index.ts index eb9160bcf..558b04575 100644 --- a/packages/examples/src/index.ts +++ b/packages/examples/src/index.ts @@ -21,6 +21,7 @@ import quadrantChart from './examples/quadrant-chart.js'; import packetDiagram from './examples/packet.js'; import blockDiagram from './examples/block.js'; import treemapDiagram from './examples/treemap.js'; +import usecaseDiagram from './examples/usecase.js'; export const diagramData: DiagramMetadata[] = [ flowChart, @@ -45,4 +46,5 @@ export const diagramData: DiagramMetadata[] = [ packetDiagram, blockDiagram, treemapDiagram, + usecaseDiagram, ]; diff --git a/packages/mermaid/src/config.type.ts b/packages/mermaid/src/config.type.ts index 79fadd195..c7bdd4341 100644 --- a/packages/mermaid/src/config.type.ts +++ b/packages/mermaid/src/config.type.ts @@ -210,6 +210,7 @@ export interface MermaidConfig { packet?: PacketDiagramConfig; block?: BlockDiagramConfig; radar?: RadarDiagramConfig; + usecase?: UsecaseDiagramConfig; dompurifyConfig?: DOMPurifyConfiguration; wrap?: boolean; fontSize?: number; @@ -1623,6 +1624,50 @@ export interface RadarDiagramConfig extends BaseDiagramConfig { */ curveTension?: number; } +/** + * The object containing configurations specific for usecase diagrams. + * + * This interface was referenced by `MermaidConfig`'s JSON-Schema + * via the `definition` "UsecaseDiagramConfig". + */ +export interface UsecaseDiagramConfig extends BaseDiagramConfig { + /** + * Font size for actor labels + */ + actorFontSize?: number; + /** + * Font family for actor labels + */ + actorFontFamily?: string; + /** + * Font weight for actor labels + */ + actorFontWeight?: string; + /** + * Font size for usecase labels + */ + usecaseFontSize?: number; + /** + * Font family for usecase labels + */ + usecaseFontFamily?: string; + /** + * Font weight for usecase labels + */ + usecaseFontWeight?: string; + /** + * Margin around actors + */ + actorMargin?: number; + /** + * Margin around use cases + */ + usecaseMargin?: number; + /** + * Padding around the entire diagram + */ + diagramPadding?: number; +} /** * This interface was referenced by `MermaidConfig`'s JSON-Schema * via the `definition` "FontConfig". diff --git a/packages/mermaid/src/defaultConfig.ts b/packages/mermaid/src/defaultConfig.ts index 655bf3724..6b9e58de2 100644 --- a/packages/mermaid/src/defaultConfig.ts +++ b/packages/mermaid/src/defaultConfig.ts @@ -264,6 +264,9 @@ const config: RequiredDeep = { radar: { ...defaultConfigJson.radar, }, + usecase: { + ...defaultConfigJson.usecase, + }, treemap: { useMaxWidth: true, padding: 10, diff --git a/packages/mermaid/src/diagram-api/diagram-orchestration.ts b/packages/mermaid/src/diagram-api/diagram-orchestration.ts index 97b9852ff..8a7d20045 100644 --- a/packages/mermaid/src/diagram-api/diagram-orchestration.ts +++ b/packages/mermaid/src/diagram-api/diagram-orchestration.ts @@ -28,6 +28,7 @@ import architecture from '../diagrams/architecture/architectureDetector.js'; import { registerLazyLoadedDiagrams } from './detectType.js'; import { registerDiagram } from './diagramAPI.js'; import { treemap } from '../diagrams/treemap/detector.js'; +import { usecase } from '../diagrams/usecase/usecaseDetector.js'; import '../type.d.ts'; let hasLoadedDiagrams = false; @@ -101,6 +102,7 @@ export const addDiagrams = () => { xychart, block, radar, - treemap + treemap, + usecase ); }; diff --git a/packages/mermaid/src/diagrams/usecase/styles.ts b/packages/mermaid/src/diagrams/usecase/styles.ts new file mode 100644 index 000000000..7657a2c35 --- /dev/null +++ b/packages/mermaid/src/diagrams/usecase/styles.ts @@ -0,0 +1,77 @@ +const getStyles = (options: any) => + ` + .actor { + stroke: ${options.primaryColor}; + fill: ${options.primaryColor}; + } + + .actor-label { + fill: ${options.primaryTextColor}; + font-family: ${options.fontFamily}; + font-size: 14px; + font-weight: normal; + } + + .usecase { + stroke: ${options.primaryColor}; + fill: ${options.primaryColor}; + } + + .usecase-label { + fill: ${options.primaryTextColor}; + font-family: ${options.fontFamily}; + font-size: 12px; + font-weight: normal; + } + + /* Ellipse shape styling for use cases */ + .usecase-element ellipse { + fill: ${options.mainBkg ?? '#ffffff'}; + stroke: ${options.primaryColor}; + stroke-width: 2px; + } + + .usecase-element .label { + fill: ${options.primaryTextColor}; + font-family: ${options.fontFamily}; + font-size: 12px; + font-weight: normal; + text-anchor: middle; + dominant-baseline: central; + } + + /* General ellipse styling */ + .node ellipse { + fill: ${options.mainBkg ?? '#ffffff'}; + stroke: ${options.nodeBorder ?? options.primaryColor}; + stroke-width: 1px; + } + + .relationship { + stroke: ${options.lineColor}; + fill: none; + } + + & .marker { + fill: ${options.lineColor}; + stroke: ${options.lineColor}; + } + + .relationship-label { + fill: ${options.primaryTextColor}; + font-family: ${options.fontFamily}; + font-size: 10px; + font-weight: normal; + } + + .nodeLabel, .edgeLabel { + color: ${options.classText}; + } + .system-boundary { + fill: ${options.clusterBkg}; + stroke: ${options.clusterBorder}; + stroke-width: 1px; + } +`; + +export default getStyles; diff --git a/packages/mermaid/src/diagrams/usecase/usecase.spec.ts b/packages/mermaid/src/diagrams/usecase/usecase.spec.ts new file mode 100644 index 000000000..00b5715fe --- /dev/null +++ b/packages/mermaid/src/diagrams/usecase/usecase.spec.ts @@ -0,0 +1,98 @@ +import { vi } from 'vitest'; +import { setSiteConfig } from '../../diagram-api/diagramAPI.js'; +import mermaidAPI from '../../mermaidAPI.js'; +import { Diagram } from '../../Diagram.js'; +import { addDiagrams } from '../../diagram-api/diagram-orchestration.js'; + +beforeAll(async () => { + // Is required to load the useCase diagram + await Diagram.fromText('usecase\n actor TestActor'); +}); + +/** + * UseCase diagrams require a basic d3 mock for rendering + */ +vi.mock('d3', () => { + const NewD3 = function (this: any) { + function returnThis(this: any) { + return this; + } + return { + append: function () { + return NewD3(); + }, + lower: returnThis, + attr: returnThis, + style: returnThis, + text: returnThis, + getBBox: function () { + return { + height: 10, + width: 20, + }; + }, + }; + }; + + return { + select: function () { + return new (NewD3 as any)(); + }, + + selectAll: function () { + return new (NewD3 as any)(); + }, + + // TODO: In d3 these are CurveFactory types, not strings + curveBasis: 'basis', + curveBasisClosed: 'basisClosed', + curveBasisOpen: 'basisOpen', + curveBumpX: 'bumpX', + curveBumpY: 'bumpY', + curveBundle: 'bundle', + curveCardinalClosed: 'cardinalClosed', + curveCardinalOpen: 'cardinalOpen', + curveCardinal: 'cardinal', + curveCatmullRomClosed: 'catmullRomClosed', + curveCatmullRomOpen: 'catmullRomOpen', + curveCatmullRom: 'catmullRom', + curveLinear: 'linear', + curveLinearClosed: 'linearClosed', + curveMonotoneX: 'monotoneX', + curveMonotoneY: 'monotoneY', + curveNatural: 'natural', + curveStep: 'step', + curveStepAfter: 'stepAfter', + curveStepBefore: 'stepBefore', + }; +}); +// ------------------------------- + +addDiagrams(); + +/** + * @param conf - 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)` + ); + + expect(diagram).toBeDefined(); + expect(diagram.type).toBe('usecase'); + }); +}); diff --git a/packages/mermaid/src/diagrams/usecase/usecaseDb.ts b/packages/mermaid/src/diagrams/usecase/usecaseDb.ts new file mode 100644 index 000000000..e813be6be --- /dev/null +++ b/packages/mermaid/src/diagrams/usecase/usecaseDb.ts @@ -0,0 +1,306 @@ +import { log } from '../../logger.js'; +import { + setAccTitle, + getAccTitle, + setDiagramTitle, + getDiagramTitle, + getAccDescription, + setAccDescription, + clear as commonClear, +} from '../common/commonDb.js'; +import type { + UsecaseFields, + UsecaseDB, + Actor, + UseCase, + SystemBoundary, + Relationship, + ActorMetadata, + Direction, +} from './usecaseTypes.js'; +import { DEFAULT_DIRECTION } from './usecaseTypes.js'; +import type { RequiredDeep } from 'type-fest'; +import type { UsecaseDiagramConfig } from '../../config.type.js'; +import DEFAULT_CONFIG from '../../defaultConfig.js'; +import { getConfig as getGlobalConfig } from '../../diagram-api/diagramAPI.js'; +import type { LayoutData, Node, ClusterNode, Edge } from '../../rendering-util/types.js'; + +export const DEFAULT_USECASE_CONFIG: Required = DEFAULT_CONFIG.usecase; + +export const DEFAULT_USECASE_DB: RequiredDeep = { + actors: new Map(), + useCases: new Map(), + systemBoundaries: new Map(), + relationships: [], + direction: DEFAULT_DIRECTION, + config: DEFAULT_USECASE_CONFIG, +} as const; + +let actors = new Map(); +let useCases = new Map(); +let systemBoundaries = new Map(); +let relationships: Relationship[] = []; +let direction: Direction = DEFAULT_DIRECTION; +const config: Required = structuredClone(DEFAULT_USECASE_CONFIG); + +const getConfig = (): Required => structuredClone(config); + +const clear = (): void => { + actors = new Map(); + useCases = new Map(); + systemBoundaries = new Map(); + relationships = []; + direction = DEFAULT_DIRECTION; + commonClear(); +}; + +// Actor management +const addActor = (actor: Actor): void => { + if (!actor.id || !actor.name) { + throw new Error( + `Invalid actor: Actor must have both id and name. Received: ${JSON.stringify(actor)}` + ); + } + + if (!actors.has(actor.id)) { + actors.set(actor.id, actor); + log.debug(`Added actor: ${actor.id} (${actor.name})`); + } else { + log.debug(`Actor ${actor.id} already exists`); + } +}; + +const getActors = (): Map => actors; + +const getActor = (id: string): Actor | undefined => actors.get(id); + +// UseCase management +const addUseCase = (useCase: UseCase): void => { + if (!useCase.id || !useCase.name) { + throw new Error( + `Invalid use case: Use case must have both id and name. Received: ${JSON.stringify(useCase)}` + ); + } + + if (!useCases.has(useCase.id)) { + useCases.set(useCase.id, useCase); + log.debug(`Added use case: ${useCase.id} (${useCase.name})`); + } else { + log.debug(`Use case ${useCase.id} already exists`); + } +}; + +const getUseCases = (): Map => useCases; + +const getUseCase = (id: string): UseCase | undefined => useCases.get(id); + +// SystemBoundary management +const addSystemBoundary = (systemBoundary: SystemBoundary): void => { + if (!systemBoundary.id || !systemBoundary.name) { + throw new Error( + `Invalid system boundary: System boundary must have both id and name. Received: ${JSON.stringify(systemBoundary)}` + ); + } + + if (!systemBoundaries.has(systemBoundary.id)) { + systemBoundaries.set(systemBoundary.id, systemBoundary); + log.debug(`Added system boundary: ${systemBoundary.name}`); + } else { + log.debug(`System boundary ${systemBoundary.id} already exists`); + } +}; + +const getSystemBoundaries = (): Map => systemBoundaries; + +const getSystemBoundary = (id: string): SystemBoundary | undefined => systemBoundaries.get(id); + +// Relationship management +const addRelationship = (relationship: Relationship): void => { + // Validate relationship structure + if (!relationship.id || !relationship.from || !relationship.to) { + throw new Error( + `Invalid relationship: Relationship must have id, from, and to fields. Received: ${JSON.stringify(relationship)}` + ); + } + + if (!relationship.type) { + throw new Error( + `Invalid relationship: Relationship must have a type. Received: ${JSON.stringify(relationship)}` + ); + } + + // Validate relationship type + const validTypes = ['association', 'include', 'extend']; + if (!validTypes.includes(relationship.type)) { + throw new Error( + `Invalid relationship type: ${relationship.type}. Valid types are: ${validTypes.join(', ')}` + ); + } + + // Validate arrow type if present + if (relationship.arrowType !== undefined) { + const validArrowTypes = [0, 1, 2]; // SOLID_ARROW, BACK_ARROW, LINE_SOLID + if (!validArrowTypes.includes(relationship.arrowType)) { + throw new Error( + `Invalid arrow type: ${relationship.arrowType}. Valid arrow types are: ${validArrowTypes.join(', ')}` + ); + } + } + + relationships.push(relationship); + log.debug( + `Added relationship: ${relationship.from} -> ${relationship.to} (${relationship.type})` + ); +}; + +const getRelationships = (): Relationship[] => relationships; + +// Direction management +const setDirection = (dir: Direction): void => { + // Normalize TD to TB (same as flowchart) + if (dir === 'TD') { + direction = 'TB'; + } else { + direction = dir; + } + log.debug('Direction set to:', direction); +}; + +const getDirection = (): Direction => direction; + +// Convert usecase diagram data to LayoutData format for unified rendering +const getData = (): LayoutData => { + const globalConfig = getGlobalConfig(); + const nodes: Node[] = []; + const edges: Edge[] = []; + + // Convert actors to nodes + for (const actor of actors.values()) { + const node: Node = { + id: actor.id, + label: actor.name, + description: actor.description ? [actor.description] : undefined, + shape: 'usecaseActor', // Use custom actor shape + isGroup: false, + padding: 10, + look: globalConfig.look, + // Add metadata as data attributes for styling + cssClasses: `usecase-actor ${ + actor.metadata && Object.keys(actor.metadata).length > 0 + ? Object.entries(actor.metadata) + .map(([key, value]) => `actor-${key}-${value}`) + .join(' ') + : '' + }`.trim(), + // Pass actor metadata to the shape handler + metadata: actor.metadata, + } as Node & { metadata?: ActorMetadata }; + nodes.push(node); + } + + // Convert use cases to nodes + for (const useCase of useCases.values()) { + const node: Node = { + id: useCase.id, + label: useCase.name, + description: useCase.description ? [useCase.description] : undefined, + shape: 'ellipse', // Use ellipse shape for use cases + isGroup: false, + padding: 10, + look: globalConfig.look, + cssClasses: 'usecase-element', + // If use case belongs to a system boundary, set parentId + ...(useCase.systemBoundary && { parentId: useCase.systemBoundary }), + }; + nodes.push(node); + } + + // Convert system boundaries to group nodes + for (const boundary of systemBoundaries.values()) { + const node: ClusterNode & { boundaryType?: string } = { + id: boundary.id, + label: boundary.name, + shape: 'usecaseSystemBoundary', // Use custom usecase system boundary cluster shape + isGroup: true, // System boundaries are clusters (containers for other nodes) + padding: 20, + look: globalConfig.look, + cssClasses: `system-boundary system-boundary-${boundary.type ?? 'rect'}`, + // Pass boundary type to the shape handler + boundaryType: boundary.type, + }; + nodes.push(node); + } + + // Convert relationships to edges + relationships.forEach((relationship, index) => { + const edge: Edge = { + id: relationship.id || `edge-${index}`, + start: relationship.from, + end: relationship.to, + source: relationship.from, + target: relationship.to, + label: relationship.label, + type: relationship.type, + arrowTypeEnd: + relationship.arrowType === 0 + ? 'arrow_point' // Forward arrow (-->) + : 'none', // No end arrow for back arrow or line + arrowTypeStart: + relationship.arrowType === 1 + ? 'arrow_point' // Back arrow (<--) + : 'none', // No start arrow for forward arrow or line + classes: `relationship relationship-${relationship.type}`, + look: globalConfig.look, + thickness: 'normal', + pattern: 'solid', + }; + edges.push(edge); + }); + + return { + nodes, + edges, + config: globalConfig, + // Additional properties that layout algorithms might expect + type: 'usecase', + layoutAlgorithm: 'dagre', // Default layout algorithm + direction: getDirection(), // Use the current direction setting + nodeSpacing: 50, // Default node spacing + rankSpacing: 50, // Default rank spacing + markers: ['arrow_point'], // Arrow point markers used in usecase diagrams + }; +}; + +export const db: UsecaseDB = { + getConfig, + + clear, + setDiagramTitle, + getDiagramTitle, + setAccTitle, + getAccTitle, + setAccDescription, + getAccDescription, + + addActor, + getActors, + getActor, + + addUseCase, + getUseCases, + getUseCase, + + addSystemBoundary, + getSystemBoundaries, + getSystemBoundary, + + addRelationship, + getRelationships, + + // Direction management + setDirection, + getDirection, + + // Add getData method for unified rendering + getData, +}; diff --git a/packages/mermaid/src/diagrams/usecase/usecaseDetector.ts b/packages/mermaid/src/diagrams/usecase/usecaseDetector.ts new file mode 100644 index 000000000..62acd0654 --- /dev/null +++ b/packages/mermaid/src/diagrams/usecase/usecaseDetector.ts @@ -0,0 +1,22 @@ +import type { + DiagramDetector, + DiagramLoader, + ExternalDiagramDefinition, +} from '../../diagram-api/types.js'; + +const id = 'usecase'; + +const detector: DiagramDetector = (txt) => { + return /^\s*usecase/.test(txt); +}; + +const loader: DiagramLoader = async () => { + const { diagram } = await import('./usecaseDiagram.js'); + return { id, diagram }; +}; + +export const usecase: ExternalDiagramDefinition = { + id, + detector, + loader, +}; diff --git a/packages/mermaid/src/diagrams/usecase/usecaseDiagram.ts b/packages/mermaid/src/diagrams/usecase/usecaseDiagram.ts new file mode 100644 index 000000000..fe99f10f3 --- /dev/null +++ b/packages/mermaid/src/diagrams/usecase/usecaseDiagram.ts @@ -0,0 +1,12 @@ +import type { DiagramDefinition } from '../../diagram-api/types.js'; +import { parser } from './usecaseParser.js'; +import { db } from './usecaseDb.js'; +import { renderer } from './usecaseRenderer.js'; +import styles from './styles.js'; + +export const diagram: DiagramDefinition = { + parser, + db, + renderer, + styles, +}; diff --git a/packages/mermaid/src/diagrams/usecase/usecaseParser.ts b/packages/mermaid/src/diagrams/usecase/usecaseParser.ts new file mode 100644 index 000000000..ab8a59893 --- /dev/null +++ b/packages/mermaid/src/diagrams/usecase/usecaseParser.ts @@ -0,0 +1,157 @@ +// Import ANTLR parser from the parser package +import { parse } from '@mermaid-js/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, +} from './usecaseTypes.js'; +import { db } from './usecaseDb.js'; + +// ANTLR parser result interface +interface UsecaseParseResult { + actors: { id: string; name: string; metadata?: Record }[]; + useCases: { id: string; name: string; nodeId?: string; systemBoundary?: string }[]; + systemBoundaries: { id: string; name: string; useCases: string[]; type?: 'package' | 'rect' }[]; + relationships: { + id: string; + from: string; + to: string; + type: 'association' | 'include' | 'extend'; + arrowType: number; + label?: string; + }[]; + direction?: string; + accDescr?: string; + accTitle?: string; + title?: string; +} + +/** + * Parse usecase diagram using 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; +}; + +/** + * 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, + }; + db.addActor(actor); + }); + + // Add use cases (ANTLR result already has id, name, nodeId, and systemBoundary) + ast.useCases.forEach((useCaseData) => { + const useCase: UseCase = { + id: useCaseData.id, + name: useCaseData.name, + nodeId: useCaseData.nodeId, + systemBoundary: useCaseData.systemBoundary, + }; + 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 + }; + 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); + }); + + // 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, + direction: ast.direction, + }); +}; + +export const parser: ParserDefinition = { + parse: async (input: string): Promise => { + log.debug('Parsing usecase diagram with ANTLR:', 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'); + } catch (error) { + log.error('Error parsing usecase diagram:', error); + + // Check if it's a UsecaseParseError from our ANTLR parser + if ( + error && + typeof error === 'object' && + 'name' in error && + error.name === 'UsecaseParseError' + ) { + // Re-throw the detailed error for better error reporting + throw error; + } + + // For other errors, wrap them in a generic error + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + const wrappedError = new Error(`Failed to parse usecase diagram: ${errorMessage}`); + + // Add hash property for consistency with other diagram types + (wrappedError as any).hash = { + text: input.split('\n')[0] || '', + token: 'unknown', + line: '1', + loc: { first_line: 1, last_line: 1, first_column: 1, last_column: 1 }, + expected: ['valid usecase syntax'], + }; + + throw wrappedError; + } + }, +}; diff --git a/packages/mermaid/src/diagrams/usecase/usecaseRenderer.ts b/packages/mermaid/src/diagrams/usecase/usecaseRenderer.ts new file mode 100644 index 000000000..e23ba6bd5 --- /dev/null +++ b/packages/mermaid/src/diagrams/usecase/usecaseRenderer.ts @@ -0,0 +1,48 @@ +import type { DrawDefinition } from '../../diagram-api/types.js'; +import { log } from '../../logger.js'; +import { getDiagramElement } from '../../rendering-util/insertElementsForSize.js'; +import { getRegisteredLayoutAlgorithm, render } from '../../rendering-util/render.js'; +import { setupViewPortForSVG } from '../../rendering-util/setupViewPortForSVG.js'; +import { getConfig } from '../../diagram-api/diagramAPI.js'; +import utils from '../../utils.js'; +import type { UsecaseDB } from './usecaseTypes.js'; + +/** + * Main draw function using unified rendering system + */ +const draw: DrawDefinition = async (_text, id, _version, diag) => { + log.info('Drawing usecase diagram (unified)', id); + const { securityLevel, usecase: conf, layout } = getConfig(); + + // The getData method provided in all supported diagrams is used to extract the data from the parsed structure + // into the Layout data format + const usecaseDb = diag.db as UsecaseDB; + const data4Layout = usecaseDb.getData(); + + // Create the root SVG - the element is the div containing the SVG element + const svg = getDiagramElement(id, securityLevel); + + data4Layout.type = diag.type; + data4Layout.layoutAlgorithm = getRegisteredLayoutAlgorithm(layout); + + data4Layout.nodeSpacing = 50; // Default node spacing + data4Layout.rankSpacing = 50; // Default rank spacing + data4Layout.markers = ['point']; // Use point markers for usecase diagrams + data4Layout.diagramId = id; + + log.debug('Usecase layout data:', data4Layout); + + // Use the unified rendering system + await render(data4Layout, svg); + + const padding = 8; + utils.insertTitle( + svg, + 'usecaseDiagramTitleText', + 0, // Default title top margin + usecaseDb.getDiagramTitle?.() ?? '' + ); + setupViewPortForSVG(svg, padding, 'usecaseDiagram', conf?.useMaxWidth ?? false); +}; + +export const renderer = { draw }; diff --git a/packages/mermaid/src/diagrams/usecase/usecaseStyles.ts b/packages/mermaid/src/diagrams/usecase/usecaseStyles.ts new file mode 100644 index 000000000..526e80661 --- /dev/null +++ b/packages/mermaid/src/diagrams/usecase/usecaseStyles.ts @@ -0,0 +1,40 @@ +const getStyles = (options: any) => + ` + .actor { + stroke: ${options.primaryColor}; + fill: ${options.primaryColor}; + } + + .actor-label { + fill: ${options.primaryTextColor}; + font-family: ${options.fontFamily}; + font-size: 14px; + font-weight: normal; + } + + .usecase { + stroke: ${options.primaryColor}; + fill: ${options.primaryColor}; + } + + .usecase-label { + fill: ${options.primaryTextColor}; + font-family: ${options.fontFamily}; + font-size: 12px; + font-weight: normal; + } + + .relationship { + stroke: ${options.primaryColor}; + fill: ${options.primaryColor}; + } + + .relationship-label { + fill: ${options.primaryTextColor}; + font-family: ${options.fontFamily}; + font-size: 10px; + font-weight: normal; + } +`; + +export default getStyles; diff --git a/packages/mermaid/src/diagrams/usecase/usecaseTypes.ts b/packages/mermaid/src/diagrams/usecase/usecaseTypes.ts new file mode 100644 index 000000000..2c4bb09ff --- /dev/null +++ b/packages/mermaid/src/diagrams/usecase/usecaseTypes.ts @@ -0,0 +1,94 @@ +import type { DiagramDB } from '../../diagram-api/types.js'; +import type { UsecaseDiagramConfig } from '../../config.type.js'; +import type { LayoutData } from '../../rendering-util/types.js'; + +export type ActorMetadata = Record; + +export interface Actor { + id: string; + name: string; + description?: string; + metadata?: ActorMetadata; +} + +export interface UseCase { + id: string; + name: string; + description?: string; + nodeId?: string; // Optional node ID (e.g., 'a' in 'a(Go through code)') + systemBoundary?: string; // Optional reference to system boundary +} + +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') +} + +// Arrow types for usecase diagrams (matching parser types) +export const ARROW_TYPE = { + SOLID_ARROW: 0, // --> + BACK_ARROW: 1, // <-- + LINE_SOLID: 2, // -- +} as const; + +export type ArrowType = (typeof ARROW_TYPE)[keyof typeof ARROW_TYPE]; + +export interface Relationship { + id: string; + from: string; + to: string; + type: 'association' | 'include' | 'extend'; + arrowType: ArrowType; + label?: string; +} + +// Direction types for usecase diagrams +export type Direction = 'TB' | 'TD' | 'BT' | 'RL' | 'LR'; + +export const DEFAULT_DIRECTION: Direction = 'TB'; + +export interface UsecaseFields { + actors: Map; + useCases: Map; + systemBoundaries: Map; + relationships: Relationship[]; + direction: Direction; + config: Required; +} + +export interface UsecaseDB extends DiagramDB { + getConfig: () => Required; + + // Actor management + addActor: (actor: Actor) => void; + getActors: () => Map; + getActor: (id: string) => Actor | undefined; + + // UseCase management + addUseCase: (useCase: UseCase) => void; + getUseCases: () => Map; + getUseCase: (id: string) => UseCase | undefined; + + // SystemBoundary management + addSystemBoundary: (systemBoundary: SystemBoundary) => void; + getSystemBoundaries: () => Map; + getSystemBoundary: (id: string) => SystemBoundary | undefined; + + // Relationship management + addRelationship: (relationship: Relationship) => void; + getRelationships: () => Relationship[]; + + // Direction management + setDirection: (direction: Direction) => void; + getDirection: () => Direction; + + // Unified rendering support + getData: () => LayoutData; + + // Utility methods + clear: () => void; +} diff --git a/packages/mermaid/src/rendering-util/rendering-elements/clusters.js b/packages/mermaid/src/rendering-util/rendering-elements/clusters.js index 1dd87d438..096d6c7cc 100644 --- a/packages/mermaid/src/rendering-util/rendering-elements/clusters.js +++ b/packages/mermaid/src/rendering-util/rendering-elements/clusters.js @@ -459,6 +459,173 @@ const divider = (parent, node) => { return { cluster: shapeSvg, labelBBox: {} }; }; +/** + * Custom cluster shape for usecase system boundaries + * Supports two types: 'rect' (dashed rectangle) and 'package' (UML package notation) + * @param {any} parent + * @param {any} node + * @returns {any} ShapeSvg + */ +const usecaseSystemBoundary = async (parent, node) => { + log.info('Creating usecase system boundary for ', node.id, node); + const siteConfig = getConfig(); + const { handDrawnSeed } = siteConfig; + + // Add outer g element + const shapeSvg = parent + .insert('g') + .attr('class', 'cluster usecase-system-boundary ' + node.cssClasses) + .attr('id', node.id) + .attr('data-look', node.look); + + // Get boundary type from node metadata (default to 'rect') + const boundaryType = node.boundaryType || 'rect'; + shapeSvg.attr('data-boundary-type', boundaryType); + + const useHtmlLabels = evaluate(siteConfig.flowchart?.htmlLabels); + + // Create the label + const labelEl = shapeSvg.insert('g').attr('class', 'cluster-label'); + const text = await createText(labelEl, node.label, { + style: node.labelStyle, + useHtmlLabels, + isNode: true, + }); + + // Get the size of the label + let bbox = text.getBBox(); + if (evaluate(siteConfig.flowchart?.htmlLabels)) { + const div = text.children[0]; + const dv = select(text); + bbox = div.getBoundingClientRect(); + dv.attr('width', bbox.width); + dv.attr('height', bbox.height); + } + + // Calculate width with padding (similar to rect cluster) + const width = node.width <= bbox.width + node.padding ? bbox.width + node.padding : node.width; + if (node.width <= bbox.width + node.padding) { + node.diff = (width - node.width) / 2 - node.padding; + } else { + node.diff = -node.padding; + } + + const height = node.height; + // Use absolute coordinates from layout engine (like rect cluster does) + const x = node.x - width / 2; + const y = node.y - height / 2; + + let boundaryRect; + const { subGraphTitleTopMargin } = getSubGraphTitleMargins(siteConfig); + + if (boundaryType === 'package') { + // Draw package-type boundary (rectangle with separate name box at top) + const nameBoxWidth = Math.max(80, bbox.width + 20); + const nameBoxHeight = 25; + + if (node.look === 'handDrawn') { + const rc = rough.svg(shapeSvg); + const options = userNodeOverrides(node, { + stroke: 'black', + strokeWidth: 2, + fill: 'none', + seed: handDrawnSeed, + }); + + // Draw main boundary rectangle + const roughRect = rc.rectangle(x, y, width, height, options); + boundaryRect = shapeSvg.insert(() => roughRect, ':first-child'); + + // Draw name box at top-left + const roughNameBox = rc.rectangle(x, y - nameBoxHeight, nameBoxWidth, nameBoxHeight, options); + shapeSvg.insert(() => roughNameBox, ':first-child'); + } else { + // Draw main boundary rectangle + boundaryRect = shapeSvg + .insert('rect', ':first-child') + .attr('x', x) + .attr('y', y) + .attr('width', width) + .attr('height', height) + .attr('fill', 'none') + .attr('stroke', 'black') + .attr('stroke-width', 2); + + // Draw name box at top-left + shapeSvg + .insert('rect', ':first-child') + .attr('x', x) + .attr('y', y - nameBoxHeight) + .attr('width', nameBoxWidth) + .attr('height', nameBoxHeight) + .attr('fill', 'white') + .attr('stroke', 'black') + .attr('stroke-width', 2); + } + + // Position label in the center of the name box (using absolute coordinates) + // The name box is at (x, y - nameBoxHeight), so center the label there + labelEl.attr( + 'transform', + `translate(${x + nameBoxWidth / 2 - bbox.width / 2}, ${y - nameBoxHeight})` + ); + } else { + // Draw rect-type boundary (simple dashed rectangle) + if (node.look === 'handDrawn') { + const rc = rough.svg(shapeSvg); + const options = userNodeOverrides(node, { + stroke: 'black', + strokeWidth: 2, + fill: 'none', + strokeLineDash: [5, 5], + seed: handDrawnSeed, + }); + + const roughRect = rc.rectangle(x, y, width, height, options); + boundaryRect = shapeSvg.insert(() => roughRect, ':first-child'); + } else { + // Draw dashed rectangle + boundaryRect = shapeSvg + .insert('rect', ':first-child') + .attr('x', x) + .attr('y', y) + .attr('width', width) + .attr('height', height) + .attr('fill', 'none') + .attr('stroke', 'black') + .attr('stroke-width', 2) + .attr('stroke-dasharray', '5,5'); + } + + // Position label at top-left (using absolute coordinates, same as rect cluster) + labelEl.attr( + 'transform', + `translate(${node.x - bbox.width / 2}, ${node.y - node.height / 2 + subGraphTitleTopMargin})` + ); + } + + // Get the bounding box of the boundary rectangle + const rectBox = boundaryRect.node().getBBox(); + + // Set node properties required by layout engine (similar to rect cluster) + node.offsetX = 0; + node.width = rectBox.width; + node.height = rectBox.height; + // Used by layout engine to position subgraph in parent + node.offsetY = bbox.height - node.padding / 2; + + // Set intersection function for edge routing + node.intersect = function (point) { + return intersectRect(node, point); + }; + + // Return cluster object + return { + cluster: shapeSvg, + labelBBox: bbox, + }; +}; + const squareRect = rect; const shapes = { rect, @@ -467,6 +634,7 @@ const shapes = { noteGroup, divider, kanbanSection, + usecaseSystemBoundary, }; let clusterElems = new Map(); diff --git a/packages/mermaid/src/rendering-util/rendering-elements/shapes.ts b/packages/mermaid/src/rendering-util/rendering-elements/shapes.ts index 2509dead4..56c0f1c22 100644 --- a/packages/mermaid/src/rendering-util/rendering-elements/shapes.ts +++ b/packages/mermaid/src/rendering-util/rendering-elements/shapes.ts @@ -14,6 +14,7 @@ import { curvedTrapezoid } from './shapes/curvedTrapezoid.js'; import { cylinder } from './shapes/cylinder.js'; import { dividedRectangle } from './shapes/dividedRect.js'; import { doublecircle } from './shapes/doubleCircle.js'; +import { ellipse } from './shapes/ellipse.js'; import { filledCircle } from './shapes/filledCircle.js'; import { flippedTriangle } from './shapes/flippedTriangle.js'; import { forkJoin } from './shapes/forkJoin.js'; @@ -32,6 +33,7 @@ import { lean_right } from './shapes/leanRight.js'; import { lightningBolt } from './shapes/lightningBolt.js'; import { linedCylinder } from './shapes/linedCylinder.js'; import { linedWaveEdgedRect } from './shapes/linedWaveEdgedRect.js'; +import { usecaseActor } from './shapes/usecaseActor.js'; import { multiRect } from './shapes/multiRect.js'; import { multiWaveEdgedRectangle } from './shapes/multiWaveEdgedRectangle.js'; import { note } from './shapes/note.js'; @@ -115,6 +117,14 @@ export const shapesDefs = [ aliases: ['terminal', 'pill'], handler: stadium, }, + { + semanticName: 'Ellipse', + name: 'Ellipse', + shortName: 'ellipse', + description: 'Ellipse shape', + aliases: ['oval'], + handler: ellipse, + }, { semanticName: 'Subprocess', name: 'Framed Rectangle', @@ -507,6 +517,9 @@ const generateShapeMap = () => { // Requirement diagram requirementBox, + + // Usecase diagram + usecaseActor, } as const; const entries = [ diff --git a/packages/mermaid/src/rendering-util/rendering-elements/shapes/ellipse.ts b/packages/mermaid/src/rendering-util/rendering-elements/shapes/ellipse.ts new file mode 100644 index 000000000..6d0028ad7 --- /dev/null +++ b/packages/mermaid/src/rendering-util/rendering-elements/shapes/ellipse.ts @@ -0,0 +1,60 @@ +import rough from 'roughjs'; +import type { Bounds, D3Selection, Point } from '../../../types.js'; +import type { Node } from '../../types.js'; +import intersect from '../intersect/index.js'; +import { styles2String, userNodeOverrides } from './handDrawnShapeStyles.js'; +import { getNodeClasses, labelHelper, updateNodeBounds } from './util.js'; + +export async function ellipse(parent: D3Selection, node: Node) { + const { labelStyles, nodeStyles } = styles2String(node); + node.labelStyle = labelStyles; + const { shapeSvg, bbox, halfPadding } = await labelHelper(parent, node, getNodeClasses(node)); + + // Calculate ellipse dimensions with padding + const padding = halfPadding ?? 10; + const radiusX = bbox.width / 2 + padding * 2; + const radiusY = bbox.height / 2 + padding; + + let ellipseElem; + const { cssStyles } = node; + + if (node.look === 'handDrawn') { + // @ts-expect-error -- Passing a D3.Selection seems to work for some reason + const rc = rough.svg(shapeSvg); + const options = userNodeOverrides(node, {}); + const roughNode = rc.ellipse(0, 0, radiusX * 2, radiusY * 2, options); + + ellipseElem = shapeSvg.insert(() => roughNode, ':first-child'); + ellipseElem.attr('class', 'basic label-container'); + + if (cssStyles) { + ellipseElem.attr('style', cssStyles); + } + } else { + ellipseElem = shapeSvg + .insert('ellipse', ':first-child') + .attr('class', 'basic label-container') + .attr('style', nodeStyles) + .attr('rx', radiusX) + .attr('ry', radiusY) + .attr('cx', 0) + .attr('cy', 0); + } + + node.width = radiusX * 2; + node.height = radiusY * 2; + + updateNodeBounds(node, ellipseElem); + + node.calcIntersect = function (bounds: Bounds, point: Point) { + const rx = bounds.width / 2; + const ry = bounds.height / 2; + return intersect.ellipse(bounds, rx, ry, point); + }; + + node.intersect = function (point) { + return intersect.ellipse(node, radiusX, radiusY, point); + }; + + return shapeSvg; +} diff --git a/packages/mermaid/src/rendering-util/rendering-elements/shapes/usecaseActor.ts b/packages/mermaid/src/rendering-util/rendering-elements/shapes/usecaseActor.ts new file mode 100644 index 000000000..0a37ac457 --- /dev/null +++ b/packages/mermaid/src/rendering-util/rendering-elements/shapes/usecaseActor.ts @@ -0,0 +1,245 @@ +import { labelHelper, updateNodeBounds, getNodeClasses } from './util.js'; +import type { Node } from '../../types.js'; +import { styles2String, userNodeOverrides } from './handDrawnShapeStyles.js'; +import { getIconSVG } from '../../icons.js'; +import rough from 'roughjs'; +import type { D3Selection } from '../../../types.js'; + +/** + * Get actor styling based on metadata + */ +const getActorStyling = (metadata?: Record) => { + const defaults = { + fillColor: 'none', + strokeColor: 'black', + strokeWidth: 2, + type: 'solid', + }; + + if (!metadata) { + return defaults; + } + + return { + fillColor: metadata.type === 'hollow' ? 'none' : metadata.fillColor || defaults.fillColor, + strokeColor: metadata.strokeColor || defaults.strokeColor, + strokeWidth: parseInt(metadata.strokeWidth || '2', 10), + type: metadata.type || defaults.type, + }; +}; + +/** + * Draw traditional stick figure + */ +const drawStickFigure = ( + actorGroup: D3Selection, + styling: ReturnType, + node: Node +): void => { + const x = 0; // Center at origin + const y = -10; // Adjust vertical position + + if (node.look === 'handDrawn') { + // @ts-expect-error -- Passing a D3.Selection seems to work for some reason + const rc = rough.svg(actorGroup); + const options = userNodeOverrides(node, { + stroke: styling.strokeColor, + strokeWidth: styling.strokeWidth, + fill: styling.fillColor, + }); + + // Head (circle) + const head = rc.circle(x, y - 30, 16, options); + actorGroup.insert(() => head, ':first-child'); + + // Body (line) + const body = rc.line(x, y - 22, x, y + 10, options); + actorGroup.insert(() => body, ':first-child'); + + // Arms (line) + const arms = rc.line(x - 15, y - 10, x + 15, y - 10, options); + actorGroup.insert(() => arms, ':first-child'); + + // Left leg + const leftLeg = rc.line(x, y + 10, x - 15, y + 30, options); + actorGroup.insert(() => leftLeg, ':first-child'); + + // Right leg + const rightLeg = rc.line(x, y + 10, x + 15, y + 30, options); + actorGroup.insert(() => rightLeg, ':first-child'); + } else { + // Head (circle) + actorGroup + .append('circle') + .attr('cx', x) + .attr('cy', y - 30) + .attr('r', 8) + .attr('fill', styling.fillColor) + .attr('stroke', styling.strokeColor) + .attr('stroke-width', styling.strokeWidth); + + // Body (line) + actorGroup + .append('line') + .attr('x1', x) + .attr('y1', y - 22) + .attr('x2', x) + .attr('y2', y + 10) + .attr('stroke', styling.strokeColor) + .attr('stroke-width', styling.strokeWidth); + + // Arms (line) + actorGroup + .append('line') + .attr('x1', x - 15) + .attr('y1', y - 10) + .attr('x2', x + 15) + .attr('y2', y - 10) + .attr('stroke', styling.strokeColor) + .attr('stroke-width', styling.strokeWidth); + + // Left leg + actorGroup + .append('line') + .attr('x1', x) + .attr('y1', y + 10) + .attr('x2', x - 15) + .attr('y2', y + 30) + .attr('stroke', styling.strokeColor) + .attr('stroke-width', styling.strokeWidth); + + // Right leg + actorGroup + .append('line') + .attr('x1', x) + .attr('y1', y + 10) + .attr('x2', x + 15) + .attr('y2', y + 30) + .attr('stroke', styling.strokeColor) + .attr('stroke-width', styling.strokeWidth); + } +}; + +/** + * Draw actor with icon representation + */ +const drawActorWithIcon = async ( + actorGroup: D3Selection, + iconName: string, + styling: ReturnType, + node: Node +): Promise => { + const x = 0; // Center at origin + const y = -10; // Adjust vertical position + const iconSize = 50; // Icon size + + if (node.look === 'handDrawn') { + // @ts-expect-error -- Passing a D3.Selection seems to work for some reason + const rc = rough.svg(actorGroup); + const options = userNodeOverrides(node, { + stroke: styling.strokeColor, + strokeWidth: styling.strokeWidth, + fill: styling.fillColor === 'none' ? 'white' : styling.fillColor, + }); + + // Create a rectangle background for the icon + const iconBg = rc.rectangle(x - 35, y - 40, 50, 50, options); + actorGroup.insert(() => iconBg, ':first-child'); + } else { + // Create a rectangle background for the icon + actorGroup + .append('rect') + .attr('x', x - 27.5) + .attr('y', y - 42) + .attr('width', 55) + .attr('height', 55) + .attr('rx', 5) + .attr('fill', styling.fillColor === 'none' ? 'white' : styling.fillColor) + .attr('stroke', styling.strokeColor) + .attr('stroke-width', styling.strokeWidth); + } + + // Add icon using getIconSVG (like iconCircle.ts does) + const iconElem = actorGroup.append('g').attr('class', 'actor-icon'); + iconElem.html( + `${await getIconSVG(iconName, { + height: iconSize, + width: iconSize, + fallbackPrefix: 'fa', + })}` + ); + + // Get icon bounding box for positioning + const iconBBox = iconElem.node()?.getBBox(); + if (iconBBox) { + const iconWidth = iconBBox.width; + const iconHeight = iconBBox.height; + const iconX = iconBBox.x; + const iconY = iconBBox.y; + + // Center the icon in the rectangle + iconElem.attr( + 'transform', + `translate(${-iconWidth / 2 - iconX}, ${y - 15 - iconHeight / 2 - iconY})` + ); + } +}; + +/** + * Custom shape handler for usecase actors + */ +export async function usecaseActor( + parent: D3Selection, + node: Node +) { + const { labelStyles } = styles2String(node); + node.labelStyle = labelStyles; + const { shapeSvg, bbox, label } = await labelHelper(parent, node, getNodeClasses(node)); + + // Actor dimensions + const actorWidth = 80; + const actorHeight = 70; // Height for the stick figure part + + // Get actor metadata from node + const metadata = (node as Node & { metadata?: Record }).metadata; + const styling = getActorStyling(metadata); + + // Create actor group + const actorGroup = shapeSvg.append('g').attr('class', 'usecase-actor-shape'); + + // Add metadata as data attributes for CSS styling + if (metadata) { + Object.entries(metadata).forEach(([key, value]) => { + actorGroup.attr(`data-${key}`, value); + }); + } + + // Check if we should render an icon instead of stick figure + if (metadata?.icon) { + await drawActorWithIcon(actorGroup, metadata.icon, styling, node); + } else { + drawStickFigure(actorGroup, styling, node); + } + + // Actor name (always rendered below the figure) + const labelY = actorHeight / 2 + 15; // Position label below the figure + + // Calculate label height from the actual text element + + const labelBBox = label.node()?.getBBox() ?? { height: 20 }; + const labelHeight = labelBBox.height + 10; // Space for label below + const totalHeight = actorHeight + labelHeight; + label.attr( + 'transform', + `translate(${-bbox.width / 2 - (bbox.x - (bbox.left ?? 0))},${labelY / 2})` + ); + + // Update node bounds for layout + updateNodeBounds(node, actorGroup); + + // Set explicit dimensions for layout algorithm + node.width = actorWidth; + node.height = totalHeight; + + return shapeSvg; +} diff --git a/packages/mermaid/src/schemas/config.schema.yaml b/packages/mermaid/src/schemas/config.schema.yaml index 4b75c9704..0bff25be9 100644 --- a/packages/mermaid/src/schemas/config.schema.yaml +++ b/packages/mermaid/src/schemas/config.schema.yaml @@ -56,6 +56,7 @@ required: - block - look - radar + - usecase properties: theme: description: | @@ -310,6 +311,8 @@ properties: $ref: '#/$defs/BlockDiagramConfig' radar: $ref: '#/$defs/RadarDiagramConfig' + usecase: + $ref: '#/$defs/UsecaseDiagramConfig' dompurifyConfig: title: DOM Purify Configuration description: Configuration options to pass to the `dompurify` library. @@ -2329,6 +2332,57 @@ $defs: # JSON Schema definition (maybe we should move these to a separate file) maximum: 1 default: 0.17 + UsecaseDiagramConfig: + title: Usecase Diagram Config + allOf: [{ $ref: '#/$defs/BaseDiagramConfig' }] + description: The object containing configurations specific for usecase diagrams. + type: object + unevaluatedProperties: false + required: + - useMaxWidth + properties: + actorFontSize: + description: Font size for actor labels + type: number + minimum: 1 + default: 14 + actorFontFamily: + description: Font family for actor labels + type: string + default: '"Open Sans", sans-serif' + actorFontWeight: + description: Font weight for actor labels + type: string + default: 'normal' + usecaseFontSize: + description: Font size for usecase labels + type: number + minimum: 1 + default: 12 + usecaseFontFamily: + description: Font family for usecase labels + type: string + default: '"Open Sans", sans-serif' + usecaseFontWeight: + description: Font weight for usecase labels + type: string + default: 'normal' + actorMargin: + description: Margin around actors + type: number + minimum: 0 + default: 50 + usecaseMargin: + description: Margin around use cases + type: number + minimum: 0 + default: 50 + diagramPadding: + description: Padding around the entire diagram + type: number + minimum: 0 + default: 20 + FontCalculator: title: Font Calculator description: | diff --git a/packages/parser/antlr-config.json b/packages/parser/antlr-config.json new file mode 100644 index 000000000..4e9b2ab20 --- /dev/null +++ b/packages/parser/antlr-config.json @@ -0,0 +1,14 @@ +{ + "projectName": "Mermaid", + "grammars": [ + { + "id": "usecase", + "grammar": "src/language/usecase/Usecase.g4", + "outputDir": "src/language/usecase/generated", + "language": "TypeScript", + "generateVisitor": true, + "generateListener": true + } + ], + "mode": "production" +} diff --git a/packages/parser/package.json b/packages/parser/package.json index 718ecee66..a1eee024f 100644 --- a/packages/parser/package.json +++ b/packages/parser/package.json @@ -19,7 +19,8 @@ "scripts": { "clean": "rimraf dist src/language/generated", "langium:generate": "langium generate", - "langium:watch": "langium generate --watch" + "langium:watch": "langium generate --watch", + "antlr:generate": "tsx ../../.build/antlr-cli.ts antlr-config.json" }, "repository": { "type": "git", @@ -33,9 +34,11 @@ "ast" ], "dependencies": { + "antlr4ng": "^3.0.7", "langium": "3.3.1" }, "devDependencies": { + "antlr-ng": "^1.0.10", "chevrotain": "^11.0.3" }, "files": [ diff --git a/packages/parser/src/language/index.ts b/packages/parser/src/language/index.ts index 9b31af5ba..f4a41fc40 100644 --- a/packages/parser/src/language/index.ts +++ b/packages/parser/src/language/index.ts @@ -45,3 +45,4 @@ 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/Usecase.g4 b/packages/parser/src/language/usecase/Usecase.g4 new file mode 100644 index 000000000..3b54fc0c5 --- /dev/null +++ b/packages/parser/src/language/usecase/Usecase.g4 @@ -0,0 +1,183 @@ +grammar Usecase; + +// Parser rules +usecaseDiagram + : 'usecase' NEWLINE* statement* EOF + ; + +statement + : actorStatement + | relationshipStatement + | systemBoundaryStatement + | systemBoundaryTypeStatement + | directionStatement + | NEWLINE + ; + +actorStatement + : 'actor' actorList NEWLINE* + ; + +actorList + : actorName (',' actorName)* + ; + +actorName + : (IDENTIFIER | STRING) metadata? + ; + +metadata + : '@' '{' metadataContent '}' + ; + +metadataContent + : metadataProperty (',' metadataProperty)* + ; + +metadataProperty + : STRING ':' STRING + ; + + + +relationshipStatement + : entityName arrow entityName NEWLINE* + | actorDeclaration arrow entityName NEWLINE* + ; + +systemBoundaryStatement + : 'systemBoundary' systemBoundaryName NEWLINE* systemBoundaryContent* 'end' NEWLINE* + ; + +systemBoundaryName + : IDENTIFIER + | STRING + ; + +systemBoundaryContent + : usecaseInBoundary NEWLINE* + | NEWLINE + ; + +usecaseInBoundary + : IDENTIFIER + | STRING + ; + +systemBoundaryTypeStatement + : systemBoundaryName '@' '{' systemBoundaryTypeContent '}' NEWLINE* + ; + +systemBoundaryTypeContent + : systemBoundaryTypeProperty (',' systemBoundaryTypeProperty)* + ; + +systemBoundaryTypeProperty + : 'type' ':' systemBoundaryType + ; + +systemBoundaryType + : 'package' + | 'rect' + ; + +entityName + : IDENTIFIER + | STRING + | nodeIdWithLabel + ; + +actorDeclaration + : 'actor' actorName + ; + +nodeIdWithLabel + : IDENTIFIER '(' nodeLabel ')' + ; + +nodeLabel + : IDENTIFIER + | STRING + | nodeLabel IDENTIFIER + | nodeLabel STRING + ; + +arrow + : SOLID_ARROW + | BACK_ARROW + | LINE_SOLID + | labeledArrow + ; + +labeledArrow + : LINE_SOLID edgeLabel SOLID_ARROW + | BACK_ARROW edgeLabel LINE_SOLID + | LINE_SOLID edgeLabel LINE_SOLID + ; + +edgeLabel + : IDENTIFIER + | STRING + ; + +directionStatement + : 'direction' direction NEWLINE* + ; + +direction + : 'TB' + | 'TD' + | 'BT' + | 'RL' + | 'LR' + ; + +// Lexer rules +SOLID_ARROW + : '-->' + ; + +BACK_ARROW + : '<--' + ; + +LINE_SOLID + : '--' + ; + +COMMA + : ',' + ; + +AT + : '@' + ; + +LBRACE + : '{' + ; + +RBRACE + : '}' + ; + +COLON + : ':' + ; + +IDENTIFIER + : [a-zA-Z_][a-zA-Z0-9_]* + ; + +STRING + : '"' (~["\r\n])* '"' + | '\'' (~['\r\n])* '\'' + ; + +NEWLINE + : [\r\n]+ + ; + +WS + : [ \t]+ -> skip + ; diff --git a/packages/parser/src/language/usecase/index.ts b/packages/parser/src/language/usecase/index.ts new file mode 100644 index 000000000..70bcc557d --- /dev/null +++ b/packages/parser/src/language/usecase/index.ts @@ -0,0 +1,4 @@ +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 new file mode 100644 index 000000000..922439157 --- /dev/null +++ b/packages/parser/src/language/usecase/module.ts @@ -0,0 +1,50 @@ +/** + * 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 new file mode 100644 index 000000000..2ddc47a0a --- /dev/null +++ b/packages/parser/src/language/usecase/parser.ts @@ -0,0 +1,194 @@ +/** + * 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 new file mode 100644 index 000000000..31b8b4119 --- /dev/null +++ b/packages/parser/src/language/usecase/types.ts @@ -0,0 +1,71 @@ +/** + * 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, // -- +} 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; +} + +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 +} + +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') +} + +export interface Relationship { + id: string; + from: string; + to: string; + type: 'association' | 'include' | 'extend'; + arrowType: ArrowType; + label?: string; +} + +export interface UsecaseParseResult { + actors: Actor[]; + useCases: UseCase[]; + systemBoundaries: SystemBoundary[]; + relationships: Relationship[]; + 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 new file mode 100644 index 000000000..7d92769a7 --- /dev/null +++ b/packages/parser/src/language/usecase/visitor.ts @@ -0,0 +1,637 @@ +/** + * 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, + ActorNameContext, + ActorDeclarationContext, + NodeIdWithLabelContext, + NodeLabelContext, + MetadataContext, + MetadataContentContext, + MetadataPropertyContext, + EntityNameContext, + ArrowContext, + LabeledArrowContext, + EdgeLabelContext, + DirectionStatementContext, + DirectionContext, +} from './generated/UsecaseParser.js'; +import { ARROW_TYPE } from './types.js'; +import type { + Actor, + UseCase, + SystemBoundary, + Relationship, + UsecaseParseResult, + ArrowType, +} 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 + + 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.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 | 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()!); + } + // 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 : IDENTIFIER | STRING ; + */ + private visitUsecaseInBoundaryImpl(ctx: UsecaseInBoundaryContext): 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 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 | STRING | nodeIdWithLabel ; + */ + private visitEntityNameImpl(ctx: EntityNameContext): 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); + } + + const nodeIdWithLabel = ctx.nodeIdWithLabel?.(); + if (nodeIdWithLabel) { + return this.visitNodeIdWithLabelImpl(nodeIdWithLabel); + } + + return ''; + } + + /** + * 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 | 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 }; + } + + // 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 ; + */ + 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 { + 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; + } + + /** + * Get the parse result after visiting the diagram + */ + getParseResult(): UsecaseParseResult { + return { + actors: this.actors, + useCases: this.useCases, + systemBoundaries: this.systemBoundaries, + relationships: this.relationships, + direction: this.direction, + }; + } +} diff --git a/packages/parser/src/parse.ts b/packages/parser/src/parse.ts index 6f5a94ce6..7e3bbccb5 100644 --- a/packages/parser/src/parse.ts +++ b/packages/parser/src/parse.ts @@ -1,8 +1,10 @@ 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; +export type DiagramAST = Info | Packet | Pie | Architecture | GitGraph | Radar | UsecaseParseResult; +export type LangiumDiagramAST = Info | Packet | Pie | Architecture | GitGraph | Radar; const parsers: Record = {}; const initializers = { @@ -41,6 +43,9 @@ 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; @@ -50,7 +55,12 @@ 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 @@ -59,11 +69,19 @@ export async function parse( if (!initializer) { 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 new file mode 100644 index 000000000..c49ec0f26 --- /dev/null +++ b/packages/parser/tests/usecase.test.ts @@ -0,0 +1,1612 @@ +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('Error Handling', () => { + describe('Syntax Error Handling', () => { + it('should throw UsecaseParseError for invalid syntax', () => { + const invalidSyntax = `usecase + invalid syntax here + actor User + `; + + expect(() => parseUsecaseWithAntlr(invalidSyntax)).toThrow(UsecaseParseError); + expect(() => parseUsecaseWithAntlr(invalidSyntax)).toThrow(/Syntax error in usecase diagram/); + }); + + 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 throw UsecaseParseError for mixed valid and invalid syntax', () => { + const mixedSyntax = `usecase + actor User + invalid line here + User --> Login + another invalid line + actor Admin + `; + + expect(() => parseUsecaseWithAntlr(mixedSyntax)).toThrow(UsecaseParseError); + expect(() => parseUsecaseWithAntlr(mixedSyntax)).toThrow(/no viable alternative/); + }); + + 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 423ab8eac..a85e46ae5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -533,12 +533,79 @@ 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: + specifier: ^3.0.7 + version: 3.0.16 langium: specifier: 3.3.1 version: 3.3.1 devDependencies: + antlr-ng: + specifier: ^1.0.10 + version: 1.0.10 chevrotain: specifier: ^11.0.3 version: 11.0.3 @@ -3908,10 +3975,20 @@ packages: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} + antlr-ng@1.0.10: + resolution: {integrity: sha512-fw3NdsQP3dabuZrDhKAMewrBsY5KSAcMrvhWBVDmHYegv5D51pypzCYK1PpjaRVKcVeP/5xKfqJY31TvXACOdA==} + hasBin: true + antlr4@4.11.0: resolution: {integrity: sha512-GUGlpE2JUjAN+G8G5vY+nOoeyNhHsXoIJwP1XF1oRw89vifA1K46T6SEkwLwr7drihN7I/lf0DIjKc4OZvBX8w==} engines: {node: '>=14'} + antlr4ng@3.0.15: + resolution: {integrity: sha512-VELFqTfcpGI2bj6ScMWuxM3FI6HOsojrgmnw3cCbUtsQ1DNOq32wJsjOt7vLvfIniyyuE1DIYegGcuFmn+jgyw==} + + antlr4ng@3.0.16: + resolution: {integrity: sha512-DQuJkC7kX3xunfF4K2KsWTSvoxxslv+FQp/WHQZTJSsH2Ec3QfFmrxC3Nky2ok9yglXn6nHM4zUaVDxcN5f6kA==} + any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} @@ -4475,6 +4552,10 @@ packages: resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} engines: {node: '>=18'} + commander@13.1.0: + resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} + engines: {node: '>=18'} + commander@14.0.1: resolution: {integrity: sha512-2JkV3gUZUVrbNA+1sjBOYLsMZ5cEEl8GTFP2a4AVz5hvasAMCQ1D2l2le/cX+pV4N6ZU17zjUahLpIXRrnWL8A==} engines: {node: '>=20'} @@ -5608,6 +5689,10 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-printf@1.6.10: + resolution: {integrity: sha512-GwTgG9O4FVIdShhbVF3JxOgSBY2+ePGsu2V/UONgoCPzF9VY6ZdBMKsHKCYQHZwNk3qNouUolRDsgVxcVA5G1w==} + engines: {node: '>=10.0'} + fast-querystring@1.1.2: resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} @@ -6076,6 +6161,10 @@ packages: hast-util-whitespace@3.0.0: resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + he@1.2.0: + resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} + hasBin: true + highlight.js@10.7.3: resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==} @@ -7054,6 +7143,10 @@ packages: lunr@2.3.9: resolution: {integrity: sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==} + luxon@3.5.0: + resolution: {integrity: sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==} + engines: {node: '>=12'} + magic-string@0.25.9: resolution: {integrity: sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==} @@ -7712,6 +7805,9 @@ packages: package-manager-detector@1.3.0: resolution: {integrity: sha512-ZsEbbZORsyHuO00lY1kV3/t72yp6Ysay6Pd17ZAlNGuGwmWDLCJxFpRs0IzfXfj1o4icJOkUEioexFHzyPurSQ==} + pako@0.2.9: + resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==} + pako@1.0.11: resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} @@ -8773,6 +8869,9 @@ packages: resolution: {integrity: sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw==} engines: {node: '>=4'} + stringtemplate4ts@1.0.9: + resolution: {integrity: sha512-KYZm2bJlSjynG5Y+L46fkaKBQG6mhV6hb2RBA8dpx3/Vj6G4u7gwXNKYvaN9+QD5sj68/1srtSNDvqEso7MwsQ==} + strip-ansi@3.0.1: resolution: {integrity: sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==} engines: {node: '>=0.10.0'} @@ -8960,6 +9059,9 @@ packages: thunky@1.1.0: resolution: {integrity: sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==} + tiny-inflate@1.0.3: + resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -9208,10 +9310,16 @@ packages: resolution: {integrity: sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==} engines: {node: '>=4'} + unicode-properties@1.4.1: + resolution: {integrity: sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==} + unicode-property-aliases-ecmascript@2.2.0: resolution: {integrity: sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==} engines: {node: '>=4'} + unicode-trie@2.0.0: + resolution: {integrity: sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==} + unicorn-magic@0.3.0: resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} engines: {node: '>=18'} @@ -9411,6 +9519,46 @@ 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} @@ -9459,6 +9607,18 @@ 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 @@ -13214,6 +13374,14 @@ 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 @@ -13350,6 +13518,19 @@ 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 @@ -13422,6 +13603,10 @@ 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) @@ -13436,6 +13621,12 @@ 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 @@ -13966,8 +14157,19 @@ snapshots: ansi-styles@6.2.3: {} + antlr-ng@1.0.10: + dependencies: + antlr4ng: 3.0.16 + commander: 13.1.0 + stringtemplate4ts: 1.0.9 + unicode-properties: 1.4.1 + antlr4@4.11.0: {} + antlr4ng@3.0.15: {} + + antlr4ng@3.0.16: {} + any-promise@1.3.0: {} anymatch@3.1.3: @@ -14576,6 +14778,8 @@ snapshots: commander@12.1.0: {} + commander@13.1.0: {} + commander@14.0.1: {} commander@2.20.3: {} @@ -16056,6 +16260,8 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-printf@1.6.10: {} + fast-querystring@1.1.2: dependencies: fast-decode-uri-component: 1.0.1 @@ -16611,6 +16817,8 @@ snapshots: dependencies: '@types/hast': 3.0.4 + he@1.2.0: {} + highlight.js@10.7.3: {} hookable@5.5.3: {} @@ -17785,6 +17993,8 @@ snapshots: lunr@2.3.9: {} + luxon@3.5.0: {} + magic-string@0.25.9: dependencies: sourcemap-codec: 1.4.8 @@ -18631,6 +18841,8 @@ snapshots: package-manager-detector@1.3.0: {} + pako@0.2.9: {} + pako@1.0.11: {} pako@2.1.0: {} @@ -19887,6 +20099,13 @@ snapshots: is-obj: 1.0.1 is-regexp: 1.0.0 + stringtemplate4ts@1.0.9: + dependencies: + antlr4ng: 3.0.15 + fast-printf: 1.6.10 + he: 1.2.0 + luxon: 3.5.0 + strip-ansi@3.0.1: dependencies: ansi-regex: 2.1.1 @@ -20099,6 +20318,8 @@ snapshots: thunky@1.1.0: {} + tiny-inflate@1.0.3: {} + tinybench@2.9.0: {} tinyexec@0.3.2: {} @@ -20322,8 +20543,18 @@ snapshots: unicode-match-property-value-ecmascript@2.2.1: {} + unicode-properties@1.4.1: + dependencies: + base64-js: 1.5.1 + unicode-trie: 2.0.0 + unicode-property-aliases-ecmascript@2.2.0: {} + unicode-trie@2.0.0: + dependencies: + pako: 0.2.9 + tiny-inflate: 1.0.3 + unicorn-magic@0.3.0: {} unified@11.0.5: @@ -20377,6 +20608,33 @@ 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)) @@ -20548,6 +20806,17 @@ 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) @@ -20569,6 +20838,22 @@ 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 @@ -20595,6 +20880,55 @@ 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