feat: Adding support for the new Usecase diagram type

on-behalf-of: @Mermaid-Chart <hello@mermaidchart.com>
This commit is contained in:
omkarht
2025-09-15 20:52:53 +05:30
parent 2972bf25bf
commit 58042f1596
29 changed files with 4458 additions and 11 deletions

View File

@@ -28,6 +28,7 @@ const MERMAID_CONFIG_DIAGRAM_KEYS = [
'packet', 'packet',
'architecture', 'architecture',
'radar', 'radar',
'usecase',
] as const; ] as const;
/** /**

View File

@@ -143,6 +143,9 @@ typeof
typestr typestr
unshift unshift
urlsafe urlsafe
usecase
Usecase
USECASE
verifymethod verifymethod
VERIFYMTHD VERIFYMTHD
WARN_DOCSDIR_DOESNT_MATCH WARN_DOCSDIR_DOESNT_MATCH

5
.gitignore vendored
View File

@@ -50,5 +50,8 @@ demos/dev/**
tsx-0/** tsx-0/**
vite.config.ts.timestamp-* vite.config.ts.timestamp-*
# autogenereated by langium-cli # autogenereated by langium-cli and antlr-cli
generated/ generated/
# autogenereated by antlr-cli
.antlr/

View File

@@ -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
`
);
});
});

View File

@@ -12,4 +12,4 @@
> `const` **configKeys**: `Set`<`string`> > `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)

View File

@@ -105,7 +105,7 @@ You can set this attribute to base the seed on a static string.
> `optional` **dompurifyConfig**: `Config` > `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 <https://developer.mozilla.org/en-US/docs/Web/CSS/font-family>
> `optional` **fontSize**: `number` > `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` > `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` > `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. Suppresses inserting 'Syntax error' diagram in the DOM.
This is useful when you want to control how to handle syntax errors in your application. 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? ### wrap?
> `optional` **wrap**: `boolean` > `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)
--- ---

View File

@@ -210,6 +210,7 @@ export interface MermaidConfig {
packet?: PacketDiagramConfig; packet?: PacketDiagramConfig;
block?: BlockDiagramConfig; block?: BlockDiagramConfig;
radar?: RadarDiagramConfig; radar?: RadarDiagramConfig;
usecase?: UsecaseDiagramConfig;
dompurifyConfig?: DOMPurifyConfiguration; dompurifyConfig?: DOMPurifyConfiguration;
wrap?: boolean; wrap?: boolean;
fontSize?: number; fontSize?: number;
@@ -1619,6 +1620,50 @@ export interface RadarDiagramConfig extends BaseDiagramConfig {
*/ */
curveTension?: number; 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 * This interface was referenced by `MermaidConfig`'s JSON-Schema
* via the `definition` "FontConfig". * via the `definition` "FontConfig".

View File

@@ -264,6 +264,9 @@ const config: RequiredDeep<MermaidConfig> = {
radar: { radar: {
...defaultConfigJson.radar, ...defaultConfigJson.radar,
}, },
usecase: {
...defaultConfigJson.usecase,
},
treemap: { treemap: {
useMaxWidth: true, useMaxWidth: true,
padding: 10, padding: 10,

View File

@@ -28,6 +28,7 @@ import architecture from '../diagrams/architecture/architectureDetector.js';
import { registerLazyLoadedDiagrams } from './detectType.js'; import { registerLazyLoadedDiagrams } from './detectType.js';
import { registerDiagram } from './diagramAPI.js'; import { registerDiagram } from './diagramAPI.js';
import { treemap } from '../diagrams/treemap/detector.js'; import { treemap } from '../diagrams/treemap/detector.js';
import { usecase } from '../diagrams/usecase/usecaseDetector.js';
import '../type.d.ts'; import '../type.d.ts';
let hasLoadedDiagrams = false; let hasLoadedDiagrams = false;
@@ -101,6 +102,7 @@ export const addDiagrams = () => {
xychart, xychart,
block, block,
radar, radar,
treemap treemap,
usecase
); );
}; };

View File

@@ -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;

View File

@@ -0,0 +1,112 @@
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
usecase "Login System"
usecase Authentication
Developer1 --> "Login System"
Developer2 --> Authentication
`);
expect(diagram).toBeDefined();
expect(diagram.type).toBe('usecase');
});
it('should handle simple usecase diagram', async () => {
const diagram = await Diagram.fromText(`
usecase
actor User
usecase "View Profile"
User --> "View Profile"
`);
expect(diagram).toBeDefined();
expect(diagram.type).toBe('usecase');
});
});

View File

@@ -0,0 +1,175 @@
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,
} from './usecaseTypes.js';
import type { RequiredDeep } from 'type-fest';
import type { UsecaseDiagramConfig } from '../../config.type.js';
import DEFAULT_CONFIG from '../../defaultConfig.js';
export const DEFAULT_USECASE_CONFIG: Required<UsecaseDiagramConfig> = DEFAULT_CONFIG.usecase;
export const DEFAULT_USECASE_DB: RequiredDeep<UsecaseFields> = {
actors: new Map(),
useCases: new Map(),
systemBoundaries: new Map(),
relationships: [],
config: DEFAULT_USECASE_CONFIG,
} as const;
let actors = new Map<string, Actor>();
let useCases = new Map<string, UseCase>();
let systemBoundaries = new Map<string, SystemBoundary>();
let relationships: Relationship[] = [];
const config: Required<UsecaseDiagramConfig> = structuredClone(DEFAULT_USECASE_CONFIG);
const getConfig = (): Required<UsecaseDiagramConfig> => structuredClone(config);
const clear = (): void => {
actors = new Map();
useCases = new Map();
systemBoundaries = new Map();
relationships = [];
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<string, Actor> => 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<string, UseCase> => 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<string, SystemBoundary> => 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;
export const db: UsecaseDB = {
getConfig,
clear,
setDiagramTitle,
getDiagramTitle,
setAccTitle,
getAccTitle,
setAccDescription,
getAccDescription,
addActor,
getActors,
getActor,
addUseCase,
getUseCases,
getUseCase,
addSystemBoundary,
getSystemBoundaries,
getSystemBoundary,
addRelationship,
getRelationships,
};

View File

@@ -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,
};

View File

@@ -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,
};

View File

@@ -0,0 +1,150 @@
// 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<string, string> }[];
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;
}[];
accDescr?: string;
accTitle?: string;
title?: string;
}
/**
* Parse usecase diagram using ANTLR parser
*/
const parseUsecaseWithAntlr = async (input: string): Promise<UsecaseParseResult> => {
// 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);
});
log.debug('Populated usecase database:', {
actors: ast.actors.length,
useCases: ast.useCases.length,
relationships: ast.relationships.length,
});
};
export const parser: ParserDefinition = {
parse: async (input: string): Promise<void> => {
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;
}
},
};

View File

@@ -0,0 +1,545 @@
import type { DrawDefinition, SVG, SVGGroup } from '../../diagram-api/types.js';
import { log } from '../../logger.js';
import { selectSvgElement } from '../../rendering-util/selectSvgElement.js';
import { setupViewPortForSVG } from '../../rendering-util/setupViewPortForSVG.js';
import { getConfig } from '../../diagram-api/diagramAPI.js';
import {
insertEdgeLabel,
positionEdgeLabel,
} from '../../rendering-util/rendering-elements/edges.js';
import { db } from './usecaseDb.js';
import { ARROW_TYPE } from './usecaseTypes.js';
import type { Actor, UseCase, SystemBoundary, Relationship } from './usecaseTypes.js';
// Layout constants
const ACTOR_WIDTH = 80;
const ACTOR_HEIGHT = 100;
const USECASE_WIDTH = 120;
const USECASE_HEIGHT = 60;
const SYSTEM_BOUNDARY_PADDING = 30;
const MARGIN = 50;
const SPACING_X = 200;
const SPACING_Y = 150;
/**
* Get actor styling based on metadata
*/
const getActorStyling = (metadata?: Record<string, string>) => {
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 an actor (stick figure) with metadata support
*/
const drawActor = (group: SVGGroup, actor: Actor, x: number, y: number): void => {
const actorGroup = group.append('g').attr('class', 'actor').attr('id', actor.id);
const styling = getActorStyling(actor.metadata);
// Add metadata as data attributes for CSS styling
if (actor.metadata) {
Object.entries(actor.metadata).forEach(([key, value]) => {
actorGroup.attr(`data-${key}`, value);
});
}
// Check if we should render an icon instead of stick figure
if (actor.metadata?.icon) {
drawActorWithIcon(actorGroup, actor, x, y, styling);
} else {
drawStickFigure(actorGroup, actor, x, y, styling);
}
// Actor name (always rendered)
const displayName = actor.metadata?.name ?? actor.name;
actorGroup
.append('text')
.attr('x', x)
.attr('y', y + 50)
.attr('text-anchor', 'middle')
.attr('class', 'actor-label')
.text(displayName);
};
/**
* Draw traditional stick figure
*/
const drawStickFigure = (
actorGroup: SVGGroup,
_actor: Actor,
x: number,
y: number,
styling: ReturnType<typeof getActorStyling>
): void => {
// 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 = (
actorGroup: SVGGroup,
actor: Actor,
x: number,
y: number,
styling: ReturnType<typeof getActorStyling>
): void => {
const iconName = actor.metadata?.icon ?? 'user';
// Create a rectangle background for the icon
actorGroup
.append('rect')
.attr('x', x - 20)
.attr('y', y - 35)
.attr('width', 40)
.attr('height', 40)
.attr('rx', 5)
.attr('fill', styling.fillColor === 'none' ? 'white' : styling.fillColor)
.attr('stroke', styling.strokeColor)
.attr('stroke-width', styling.strokeWidth);
// Add icon text (could be enhanced to use actual icons/SVG symbols)
actorGroup
.append('text')
.attr('x', x)
.attr('y', y - 10)
.attr('text-anchor', 'middle')
.attr('class', 'actor-icon')
.attr('font-size', '16px')
.attr('font-weight', 'bold')
.text(getIconSymbol(iconName));
};
/**
* Get symbol representation for common icons
*/
const getIconSymbol = (iconName: string): string => {
const iconMap: Record<string, string> = {
user: '👤',
admin: '👑',
system: '⚙️',
database: '🗄️',
api: '🔌',
service: '🔧',
client: '💻',
server: '🖥️',
mobile: '📱',
web: '🌐',
default: '👤',
};
return iconMap[iconName.toLowerCase()] || iconMap.default;
};
/**
* Draw a use case (oval)
*/
const drawUseCase = (group: SVGGroup, useCase: UseCase, x: number, y: number): void => {
const useCaseGroup = group.append('g').attr('class', 'usecase').attr('id', useCase.id);
// Oval
useCaseGroup
.append('ellipse')
.attr('cx', x)
.attr('cy', y)
.attr('rx', USECASE_WIDTH / 2)
.attr('ry', USECASE_HEIGHT / 2)
.attr('fill', 'white')
.attr('stroke', 'black')
.attr('stroke-width', 2);
// Use case name
useCaseGroup
.append('text')
.attr('x', x)
.attr('y', y + 5)
.attr('text-anchor', 'middle')
.attr('class', 'usecase-label')
.text(useCase.name);
};
/**
* Draw a system boundary (supports both 'rect' and 'package' types)
*/
const drawSystemBoundary = (
group: SVGGroup,
boundary: SystemBoundary,
useCasePositions: Map<string, { x: number; y: number }>
): void => {
// Calculate boundary dimensions based on contained use cases
const containedUseCases = boundary.useCases
.map((ucId) => useCasePositions.get(ucId))
.filter((pos) => pos !== undefined) as { x: number; y: number }[];
if (containedUseCases.length === 0) {
return; // No use cases to bound
}
// Find min/max coordinates of contained use cases
const minX =
Math.min(...containedUseCases.map((pos) => pos.x)) -
USECASE_WIDTH / 2 -
SYSTEM_BOUNDARY_PADDING;
const maxX =
Math.max(...containedUseCases.map((pos) => pos.x)) +
USECASE_WIDTH / 2 +
SYSTEM_BOUNDARY_PADDING;
const minY =
Math.min(...containedUseCases.map((pos) => pos.y)) -
USECASE_HEIGHT / 2 -
SYSTEM_BOUNDARY_PADDING;
const maxY =
Math.max(...containedUseCases.map((pos) => pos.y)) +
USECASE_HEIGHT / 2 +
SYSTEM_BOUNDARY_PADDING;
const boundaryType = boundary.type || 'rect'; // default to 'rect'
const boundaryGroup = group.append('g').attr('class', 'system-boundary').attr('id', boundary.id);
if (boundaryType === 'package') {
drawPackageBoundary(boundaryGroup, boundary, minX, minY, maxX, maxY);
} else {
drawRectBoundary(boundaryGroup, boundary, minX, minY, maxX, maxY);
}
};
/**
* Draw rect-type system boundary (simple dashed rectangle)
*/
const drawRectBoundary = (
boundaryGroup: SVGGroup,
boundary: SystemBoundary,
minX: number,
minY: number,
maxX: number,
maxY: number
): void => {
// Draw dashed rectangle
boundaryGroup
.append('rect')
.attr('x', minX)
.attr('y', minY)
.attr('width', maxX - minX)
.attr('height', maxY - minY)
.attr('fill', 'none')
.attr('stroke', 'black')
.attr('stroke-width', 2)
.attr('stroke-dasharray', '5,5');
// .attr('rx', 10)
// .attr('ry', 10);
// Draw boundary label at top-left
boundaryGroup
.append('text')
.attr('x', minX + 10)
.attr('y', minY + 20)
.attr('class', 'system-boundary-label')
.attr('font-weight', 'bold')
.attr('font-size', '14px')
.text(boundary.name);
};
/**
* Draw package-type system boundary (rectangle with separate name box)
*/
const drawPackageBoundary = (
boundaryGroup: SVGGroup,
boundary: SystemBoundary,
minX: number,
minY: number,
maxX: number,
maxY: number
): void => {
// Calculate name box dimensions
const nameBoxWidth = Math.max(80, boundary.name.length * 8 + 20);
const nameBoxHeight = 25;
// Draw main boundary rectangle
boundaryGroup
.append('rect')
.attr('x', minX)
.attr('y', minY)
.attr('width', maxX - minX)
.attr('height', maxY - minY)
.attr('fill', 'none')
.attr('stroke', 'black')
.attr('stroke-width', 2);
// Draw name box (package tab)
boundaryGroup
.append('rect')
.attr('x', minX)
.attr('y', minY - nameBoxHeight)
.attr('width', nameBoxWidth)
.attr('height', nameBoxHeight)
.attr('fill', 'white')
.attr('stroke', 'black')
.attr('stroke-width', 2);
// .attr('rx', 5)
// .attr('ry', 5);
// Draw boundary label in the name box
boundaryGroup
.append('text')
.attr('x', minX + nameBoxWidth / 2)
.attr('y', minY - nameBoxHeight / 2 + 5)
.attr('text-anchor', 'middle')
.attr('class', 'system-boundary-label')
.attr('font-weight', 'bold')
.attr('font-size', '14px')
.text(boundary.name);
};
/**
* Draw a relationship (line with arrow)
*/
const drawRelationship = async (
group: SVGGroup,
relationship: Relationship,
fromPos: { x: number; y: number },
toPos: { x: number; y: number }
): Promise<void> => {
const relationshipGroup = group
.append('g')
.attr('class', 'relationship')
.attr('id', relationship.id);
// Calculate arrow direction
const dx = toPos.x - fromPos.x;
const dy = toPos.y - fromPos.y;
const length = Math.sqrt(dx * dx + dy * dy);
const unitX = dx / length;
const unitY = dy / length;
// Adjust start and end points to avoid overlapping with shapes
const startX = fromPos.x + unitX * 40;
const startY = fromPos.y + unitY * 40;
const endX = toPos.x - unitX * 60;
const endY = toPos.y - unitY * 30;
// Main line
relationshipGroup
.append('line')
.attr('x1', startX)
.attr('y1', startY)
.attr('x2', endX)
.attr('y2', endY)
.attr('stroke', 'black')
.attr('stroke-width', 2);
// Draw arrow based on arrow type
const arrowSize = 10;
if (relationship.arrowType === ARROW_TYPE.SOLID_ARROW) {
// Forward arrow (-->)
const arrowX1 = endX - arrowSize * unitX - arrowSize * unitY * 0.5;
const arrowY1 = endY - arrowSize * unitY + arrowSize * unitX * 0.5;
const arrowX2 = endX - arrowSize * unitX + arrowSize * unitY * 0.5;
const arrowY2 = endY - arrowSize * unitY - arrowSize * unitX * 0.5;
relationshipGroup
.append('polygon')
.attr('points', `${endX},${endY} ${arrowX1},${arrowY1} ${arrowX2},${arrowY2}`)
.attr('fill', 'black');
} else if (relationship.arrowType === ARROW_TYPE.BACK_ARROW) {
// Backward arrow (<--)
const arrowX1 = startX + arrowSize * unitX - arrowSize * unitY * 0.5;
const arrowY1 = startY + arrowSize * unitY + arrowSize * unitX * 0.5;
const arrowX2 = startX + arrowSize * unitX + arrowSize * unitY * 0.5;
const arrowY2 = startY + arrowSize * unitY - arrowSize * unitX * 0.5;
relationshipGroup
.append('polygon')
.attr('points', `${startX},${startY} ${arrowX1},${arrowY1} ${arrowX2},${arrowY2}`)
.attr('fill', 'black');
}
// For LINE_SOLID (--), no arrow head is drawn
// Enhanced edge label rendering (if present)
if (relationship.label) {
const midX = (startX + endX) / 2;
const midY = (startY + endY) / 2;
// Create edge data structure compatible with the edge label system
const edgeData = {
id: relationship.id,
label: relationship.label,
labelStyle: 'stroke: #333; stroke-width: 1.5px; fill: none;',
x: midX,
y: midY,
width: 0,
height: 0,
};
try {
// Use the proper edge label rendering system
await insertEdgeLabel(relationshipGroup, edgeData);
// Position the edge label at the midpoint
const points = [
{ x: startX, y: startY },
{ x: midX, y: midY },
{ x: endX, y: endY },
];
positionEdgeLabel(edgeData, {
originalPath: points,
});
} catch (error) {
// Fallback to simple text if edge label system fails
log.warn(
'Failed to render edge label with advanced system, falling back to simple text:',
error
);
relationshipGroup
.append('text')
.attr('x', midX)
.attr('y', midY - 5)
.attr('text-anchor', 'middle')
.attr('class', 'relationship-label')
.text(relationship.label);
}
}
};
/**
* Main draw function
*/
const draw: DrawDefinition = async (text, id, _version) => {
log.debug('Rendering usecase diagram\n' + text);
const svg: SVG = selectSvgElement(id);
const group: SVGGroup = svg.append('g');
// Get data from database
const actors = [...db.getActors().values()];
const useCases = [...db.getUseCases().values()];
const systemBoundaries = [...db.getSystemBoundaries().values()];
const relationships = db.getRelationships();
log.debug('Rendering data:', {
actors: actors.length,
useCases: useCases.length,
systemBoundaries: systemBoundaries.length,
relationships: relationships.length,
});
// Calculate layout
const actorPositions = new Map<string, { x: number; y: number }>();
const useCasePositions = new Map<string, { x: number; y: number }>();
// Position actors on the left
actors.forEach((actor, index) => {
const x = MARGIN + ACTOR_WIDTH / 2;
const y = MARGIN + ACTOR_HEIGHT / 2 + index * SPACING_Y;
actorPositions.set(actor.id, { x, y });
});
// Position use cases on the right
useCases.forEach((useCase, index) => {
const x = MARGIN + SPACING_X + USECASE_WIDTH / 2;
const y = MARGIN + USECASE_HEIGHT / 2 + index * SPACING_Y;
useCasePositions.set(useCase.id, { x, y });
});
// Draw actors
actors.forEach((actor) => {
const pos = actorPositions.get(actor.id)!;
drawActor(group, actor, pos.x, pos.y);
});
// Draw use cases
useCases.forEach((useCase) => {
const pos = useCasePositions.get(useCase.id)!;
drawUseCase(group, useCase, pos.x, pos.y);
});
// Draw system boundaries (after use cases, before relationships)
systemBoundaries.forEach((boundary) => {
drawSystemBoundary(group, boundary, useCasePositions);
});
// Draw relationships (async to handle edge labels)
const relationshipPromises = relationships.map(async (relationship) => {
const fromPos =
actorPositions.get(relationship.from) ?? useCasePositions.get(relationship.from);
const toPos = actorPositions.get(relationship.to) ?? useCasePositions.get(relationship.to);
if (fromPos && toPos) {
await drawRelationship(group, relationship, fromPos, toPos);
}
});
// Wait for all relationships to be drawn
await Promise.all(relationshipPromises);
// Setup viewBox and SVG dimensions properly
const { usecase: conf } = getConfig();
const padding = 8; // Standard padding used by other diagrams
setupViewPortForSVG(svg, padding, 'usecaseDiagram', conf?.useMaxWidth ?? true);
};
export const renderer = { draw };

View File

@@ -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;

View File

@@ -0,0 +1,80 @@
import type { DiagramDB } from '../../diagram-api/types.js';
import type { UsecaseDiagramConfig } from '../../config.type.js';
export type ActorMetadata = Record<string, string>;
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;
}
export interface UsecaseFields {
actors: Map<string, Actor>;
useCases: Map<string, UseCase>;
systemBoundaries: Map<string, SystemBoundary>;
relationships: Relationship[];
config: Required<UsecaseDiagramConfig>;
}
export interface UsecaseDB extends DiagramDB {
getConfig: () => Required<UsecaseDiagramConfig>;
// Actor management
addActor: (actor: Actor) => void;
getActors: () => Map<string, Actor>;
getActor: (id: string) => Actor | undefined;
// UseCase management
addUseCase: (useCase: UseCase) => void;
getUseCases: () => Map<string, UseCase>;
getUseCase: (id: string) => UseCase | undefined;
// SystemBoundary management
addSystemBoundary: (systemBoundary: SystemBoundary) => void;
getSystemBoundaries: () => Map<string, SystemBoundary>;
getSystemBoundary: (id: string) => SystemBoundary | undefined;
// Relationship management
addRelationship: (relationship: Relationship) => void;
getRelationships: () => Relationship[];
// Utility methods
clear: () => void;
}

View File

@@ -56,6 +56,7 @@ required:
- block - block
- look - look
- radar - radar
- usecase
properties: properties:
theme: theme:
description: | description: |
@@ -310,6 +311,8 @@ properties:
$ref: '#/$defs/BlockDiagramConfig' $ref: '#/$defs/BlockDiagramConfig'
radar: radar:
$ref: '#/$defs/RadarDiagramConfig' $ref: '#/$defs/RadarDiagramConfig'
usecase:
$ref: '#/$defs/UsecaseDiagramConfig'
dompurifyConfig: dompurifyConfig:
title: DOM Purify Configuration title: DOM Purify Configuration
description: Configuration options to pass to the `dompurify` library. description: Configuration options to pass to the `dompurify` library.
@@ -2324,6 +2327,57 @@ $defs: # JSON Schema definition (maybe we should move these to a separate file)
maximum: 1 maximum: 1
default: 0.17 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: FontCalculator:
title: Font Calculator title: Font Calculator
description: | description: |

View File

@@ -19,7 +19,8 @@
"scripts": { "scripts": {
"clean": "rimraf dist src/language/generated", "clean": "rimraf dist src/language/generated",
"langium:generate": "langium generate", "langium:generate": "langium generate",
"langium:watch": "langium generate --watch" "langium:watch": "langium generate --watch",
"antlr:generate": "cd src/language/usecase && npx antlr-ng -Dlanguage=TypeScript --generate-visitor --generate-listener -o generated Usecase.g4"
}, },
"repository": { "repository": {
"type": "git", "type": "git",
@@ -33,9 +34,11 @@
"ast" "ast"
], ],
"dependencies": { "dependencies": {
"antlr4ng": "^3.0.7",
"langium": "3.3.1" "langium": "3.3.1"
}, },
"devDependencies": { "devDependencies": {
"antlr-ng": "^1.0.10",
"chevrotain": "^11.0.3" "chevrotain": "^11.0.3"
}, },
"files": [ "files": [

View File

@@ -45,3 +45,4 @@ export * from './pie/index.js';
export * from './architecture/index.js'; export * from './architecture/index.js';
export * from './radar/index.js'; export * from './radar/index.js';
export * from './treemap/index.js'; export * from './treemap/index.js';
export * from './usecase/index.js';

View File

@@ -0,0 +1,170 @@
grammar Usecase;
// Parser rules
usecaseDiagram
: 'usecase' NEWLINE* statement* EOF
;
statement
: actorStatement
| relationshipStatement
| systemBoundaryStatement
| systemBoundaryTypeStatement
| 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
;
// 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
;

View File

@@ -0,0 +1,4 @@
export * from './module.js';
export * from './types.js';
export * from './parser.js';
export * from './visitor.js';

View File

@@ -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;
}

View File

@@ -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<any>,
_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<string, any>;
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);
}

View File

@@ -0,0 +1,70 @@
/**
* 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<string, string>;
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[];
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;
}

View File

@@ -0,0 +1,605 @@
/**
* 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,
} 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<void> {
private actors: Actor[] = [];
private useCases: UseCase[] = [];
private systemBoundaries: SystemBoundary[] = [];
private relationships: Relationship[] = [];
private relationshipCounter = 0;
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.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;
// 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 | 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()!);
}
// 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<string, string>;
} {
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<string, string> {
const metadataContent = ctx.metadataContent?.();
if (metadataContent) {
return this.visitMetadataContentImpl(metadataContent);
}
return {};
}
/**
* Visit metadataContent rule
* Grammar: metadataContent : metadataProperty (',' metadataProperty)* ;
*/
private visitMetadataContentImpl(ctx: MetadataContentContext): Record<string, string> {
const metadata: Record<string, string> = {};
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;
}
/**
* Get the parse result after visiting the diagram
*/
getParseResult(): UsecaseParseResult {
return {
actors: this.actors,
useCases: this.useCases,
systemBoundaries: this.systemBoundaries,
relationships: this.relationships,
};
}
}

View File

@@ -1,8 +1,10 @@
import type { LangiumParser, ParseResult } from 'langium'; import type { LangiumParser, ParseResult } from 'langium';
import type { Info, Packet, Pie, Architecture, GitGraph, Radar, Treemap } from './index.js'; 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<string, LangiumParser> = {}; const parsers: Record<string, LangiumParser> = {};
const initializers = { const initializers = {
@@ -41,6 +43,9 @@ const initializers = {
const parser = createTreemapServices().Treemap.parser.LangiumParser; const parser = createTreemapServices().Treemap.parser.LangiumParser;
parsers.treemap = parser; parsers.treemap = parser;
}, },
usecase: () => {
// ANTLR-based parser - no Langium parser needed
},
} as const; } as const;
export async function parse(diagramType: 'info', text: string): Promise<Info>; export async function parse(diagramType: 'info', text: string): Promise<Info>;
@@ -50,7 +55,12 @@ export async function parse(diagramType: 'architecture', text: string): Promise<
export async function parse(diagramType: 'gitGraph', text: string): Promise<GitGraph>; export async function parse(diagramType: 'gitGraph', text: string): Promise<GitGraph>;
export async function parse(diagramType: 'radar', text: string): Promise<Radar>; export async function parse(diagramType: 'radar', text: string): Promise<Radar>;
export async function parse(diagramType: 'treemap', text: string): Promise<Treemap>; export async function parse(diagramType: 'treemap', text: string): Promise<Treemap>;
export async function parse(diagramType: 'usecase', text: string): Promise<UsecaseParseResult>;
export async function parse<T extends LangiumDiagramAST>(
diagramType: Exclude<keyof typeof initializers, 'usecase'>,
text: string
): Promise<T>;
export async function parse<T extends DiagramAST>( export async function parse<T extends DiagramAST>(
diagramType: keyof typeof initializers, diagramType: keyof typeof initializers,
text: string text: string
@@ -59,11 +69,19 @@ export async function parse<T extends DiagramAST>(
if (!initializer) { if (!initializer) {
throw new Error(`Unknown diagram type: ${diagramType}`); 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]) { if (!parsers[diagramType]) {
await initializer(); await initializer();
} }
const parser: LangiumParser = parsers[diagramType]; const parser: LangiumParser = parsers[diagramType];
const result: ParseResult<T> = parser.parse<T>(text); const result: ParseResult<T extends LangiumDiagramAST ? T : never> =
parser.parse<T extends LangiumDiagramAST ? T : never>(text);
if (result.lexerErrors.length > 0 || result.parserErrors.length > 0) { if (result.lexerErrors.length > 0 || result.parserErrors.length > 0) {
throw new MermaidParseError(result); throw new MermaidParseError(result);
} }

File diff suppressed because it is too large Load Diff