class diagram support added

This commit is contained in:
Knut Sveidqvist
2025-09-18 09:46:46 +02:00
parent e344c81557
commit 32896b8020
10 changed files with 1347 additions and 5 deletions

View File

@@ -50,6 +50,8 @@
"checkCircle": "npx madge --circular ./src",
"antlr:sequence:clean": "rimraf src/diagrams/sequence/parser/antlr/generated",
"antlr:sequence": "pnpm run antlr:sequence:clean && antlr4ng -Dlanguage=TypeScript -Xexact-output-dir -o src/diagrams/sequence/parser/antlr/generated src/diagrams/sequence/parser/antlr/SequenceLexer.g4 src/diagrams/sequence/parser/antlr/SequenceParser.g4",
"antlr:class:clean": "rimraf src/diagrams/class/parser/antlr/generated",
"antlr:class": "pnpm run antlr:class:clean && antlr4ng -Dlanguage=TypeScript -Xexact-output-dir -o src/diagrams/class/parser/antlr/generated src/diagrams/class/parser/antlr/ClassLexer.g4 src/diagrams/class/parser/antlr/ClassParser.g4",
"prepublishOnly": "pnpm docs:verify-version"
},
"repository": {

View File

@@ -0,0 +1,147 @@
## ANTLR migration plan for Class Diagrams (parity with Sequence)
This guide summarizes how to migrate the Class diagram parser from Jison to ANTLR (antlr4ng), following the approach used for Sequence diagrams. The goal is full feature parity and 100% test pass rate, while keeping the Jison implementation as the reference until the ANTLR path is green.
### Objectives
- Keep the existing Jison parser as the authoritative reference until parity is achieved
- Add an ANTLR parser behind a runtime flag (`USE_ANTLR_PARSER=true`), mirroring Sequence
- Achieve 100% test compatibility with the current Jison behavior, including error cases
- Keep the public DB and rendering contracts unchanged
---
## 1) Prep and references
- Use the Sequence migration as a template for structure, scripts, and patterns:
- antlr4ng grammar files: `SequenceLexer.g4`, `SequenceParser.g4`
- wrapper: `antlr-parser.ts` providing a Jison-compatible `parse()` and `yy`
- generation script: `pnpm --filter mermaid run antlr:sequence`
- For Class diagrams, identify analogous files:
- Jison grammar: `packages/mermaid/src/diagrams/class/parser/classDiagram.jison`
- DB: `packages/mermaid/src/diagrams/class/classDb.ts`
- Tests: `packages/mermaid/src/diagrams/class/classDiagram.spec.js`
- Confirm Class diagram features in the Jison grammar and tests: classes, interfaces, enums, relationships (e.g., `--`, `*--`, `o--`, `<|--`, `--|>`), visibility markers (`+`, `-`, `#`, `~`), generics (`<T>`, nested), static/abstract indicators, fields/properties, methods (with parameters and return types), stereotypes (`<< >>`), notes, direction, style/config lines, and titles/accessibility lines if supported.
---
## 2) Create ANTLR grammars
- Create `ClassLexer.g4` and `ClassParser.g4` under `packages/mermaid/src/diagrams/class/parser/antlr/`
- Lexer design guidelines (mirror Sequence approach):
- Implement stateful lexing with modes to replicate Jison behavior (e.g., default, line/rest-of-line, config/title/acc modes if used)
- Ensure token precedence resolves conflicts between relation arrows and generics (`<|--` vs `<T>`). Prefer longest-match arrow tokens and handle generics in parser context
- Accept identifiers that include special characters that Jison allowed (quotes, underscores, digits, unicode as applicable)
- Provide tokens for core keywords and symbols: `class`, `interface`, `enum`, relationship operators, visibility markers, `<< >>` stereotypes, `{ }` blocks, `:` type separators, `,` parameter separators, `[` `]` arrays, `<` `>` generics
- Reuse common tokens shared across diagrams where appropriate (e.g., `TITLE`, `ACC_...`) if Class supports them
- Parser design guidelines:
- Follow the Jison grammar structure closely to minimize semantic drift
- Allow the final statement in the file to omit a trailing newline (to avoid EOF vs NEWLINE mismatches)
- Keep non-ambiguous rules for:
- Class declarations and bodies (members split into fields/properties vs methods)
- Modifiers (visibility, static, abstract)
- Types (simple, namespaced, generic with nesting)
- Relationships with labels (left->right/right->left forms) and multiplicities
- Stereotypes and notes
- Optional global lines (title, accTitle, accDescr) if supported by class diagrams
---
## 3) Add the wrapper and flag switch
- Add `packages/mermaid/src/diagrams/class/parser/antlr/antlr-parser.ts`:
- Export an object `{ parse, parser, yy }` that mirrors the Jison parser shape
- `parse(input)` should:
- `this.yy.clear()` to reset DB (same as Sequence)
- Build ANTLR's lexer/parser, set `BailErrorStrategy` to fail-fast on syntax errors
- Walk the tree with a listener that calls classDb methods
- Implement no-op bodies for `visitTerminal`, `visitErrorNode`, `enterEveryRule`, `exitEveryRule` (required by ParseTreeWalker)
- Avoid `require()`; import from `antlr4ng`
- Use minimal `any`; when casting is unavoidable, add clear comments
- Add `packages/mermaid/src/diagrams/class/parser/classParser.ts` similar to Sequence `sequenceParser.ts`:
- Import both the Jison parser and the ANTLR wrapper
- Gate on `process.env.USE_ANTLR_PARSER === 'true'`
- Normalize whitespace if Jison relies on specific newlines (keep parity with Sequence patterns)
---
## 4) Implement the listener (semantic actions)
Map parsed constructs to classDb calls. Typical handlers include:
- Class-like declarations
- `db.addClass(id, { type: 'class'|'interface'|'enum', ... })`
- `db.addClassMember(id, member)` for fields/properties/methods (capture visibility, static/abstract, types, params)
- Stereotypes, annotations, notes: `db.addAnnotation(...)`, `db.addNote(...)` if applicable
- Relationships
- Parse arrow/operator to relation type; map to db constants (composition/aggregation/inheritance/realization/association)
- `db.addRelation(lhs, rhs, { type, label, multiplicity })`
- Title/Accessibility (if supported in Class diagrams)
- `db.setDiagramTitle(...)`, `db.setAccTitle(...)`, `db.setAccDescription(...)`
- Styles/Directives/Config lines as supported by the Jison grammar
Error handling:
- Use BailErrorStrategy; let invalid constructs throw where Jison tests expect failure
- For robustness parity, only swallow exceptions in places where Jison tolerated malformed content without aborting
---
## 5) Scripts and generation
- Add package scripts similar to Sequence in `packages/mermaid/package.json`:
- `antlr:class:clean`: remove generated TS
- `antlr:class`: run antlr4ng to generate TS into `parser/antlr/generated`
- Example command (once scripts exist):
- `pnpm --filter mermaid run antlr:class`
---
## 6) Tests (Vitest)
- Run existing Class tests with the ANTLR parser enabled:
- `USE_ANTLR_PARSER=true pnpm vitest packages/mermaid/src/diagrams/class/classDiagram.spec.js --run`
- Start by making a small focused subset pass, then expand to the full suite
- Add targeted tests for areas where the ANTLR grammar needs extra coverage (e.g., nested generics, tricky arrow/operator precedence, stereotypes, notes)
- Keep test expectations identical to Jisons behavior; only adjust if Jisons behavior was explicitly flaky and already tolerated in the repo
---
## 7) Linting and quality
- Satisfy ESLint rules enforced in the repo:
- Prefer imports over `require()`; no empty methods, avoid untyped `any` where reasonable
- If `@ts-ignore` is necessary, include a descriptive reason (≥10 chars)
- Provide minimal types for listener contexts where helpful; keep casts localized and commented
- Prefix diagnostic debug logs with the projects preferred prefix if temporary logging is needed (and clean up before commit)
---
## 8) Common pitfalls and tips
- NEWLINE vs EOF: allow the last statement without a trailing newline to prevent InputMismatch
- Token conflicts: order matters; ensure relationship operators (e.g., `<|--`, `--|>`, `*--`, `o--`) win over generic `<`/`>` in the right contexts
- Identifiers: match Jisons permissiveness (quoted names, digits where allowed) and avoid over-greedy tokens that eat operators
- Listener resilience: ensure classes and endpoints exist before adding relations (create implicitly if Jison did so)
- Error parity: do not swallow exceptions for cases where tests expect failure
---
## 9) Rollout checklist
- [ ] Grammar compiles and generated files are committed
- [ ] `USE_ANTLR_PARSER=true` passes all Class diagram tests
- [ ] Sequence and other diagram suites remain green
- [ ] No new ESLint errors; warnings minimized
- [ ] PR includes notes on parity and how to run the ANTLR tests
---
## 10) Quick command reference
- Generate ANTLR targets (after adding scripts):
- `pnpm --filter mermaid run antlr:class`
- Run Class tests with ANTLR parser:
- `USE_ANTLR_PARSER=true pnpm vitest packages/mermaid/src/diagrams/class/classDiagram.spec.js --run`
- Run a single test:
- `USE_ANTLR_PARSER=true pnpm vitest packages/mermaid/src/diagrams/class/classDiagram.spec.js -t "some test name" --run`

View File

@@ -1,4 +1,4 @@
import { parser } from './parser/classDiagram.jison';
import { parser } from './parser/classParser.ts';
import { ClassDB } from './classDb.js';
describe('class diagram, ', function () {

View File

@@ -1,6 +1,6 @@
import type { DiagramDefinition } from '../../diagram-api/types.js';
// @ts-ignore: JISON doesn't support types
import parser from './parser/classDiagram.jison';
import parser from './parser/classParser.ts';
import { ClassDB } from './classDb.js';
import styles from './styles.js';
import renderer from './classRenderer-v3-unified.js';

View File

@@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/unbound-method -- Broken for Vitest mocks, see https://github.com/vitest-dev/eslint-plugin-vitest/pull/286 */
// @ts-expect-error Jison doesn't export types
import { parser } from './parser/classDiagram.jison';
// @ts-expect-error Parser exposes mutable yy property without typings
import { parser } from './parser/classParser.ts';
import { ClassDB } from './classDb.js';
import { vi, describe, it, expect } from 'vitest';
import type { ClassMap, NamespaceNode } from './classTypes.js';

View File

@@ -1,6 +1,6 @@
import type { DiagramDefinition } from '../../diagram-api/types.js';
// @ts-ignore: JISON doesn't support types
import parser from './parser/classDiagram.jison';
import parser from './parser/classParser.ts';
import { ClassDB } from './classDb.js';
import styles from './styles.js';
import renderer from './classRenderer-v3-unified.js';

View File

@@ -0,0 +1,229 @@
lexer grammar ClassLexer;
tokens {
ACC_TITLE_VALUE,
ACC_DESCR_VALUE,
ACC_DESCR_MULTILINE_VALUE,
ACC_DESCR_MULTI_END,
OPEN_IN_STRUCT,
MEMBER
}
@members {
private pendingClassBody = false;
private pendingNamespaceBody = false;
private clearPendingScopes(): void {
this.pendingClassBody = false;
this.pendingNamespaceBody = false;
}
}
// Common fragments
fragment WS_INLINE: [ \t]+;
fragment DIGIT: [0-9];
fragment LETTER: [A-Za-z_];
fragment IDENT_PART: [A-Za-z0-9_\-];
fragment NOT_DQUOTE: ~[""];
// Comments and whitespace
COMMENT: '%%' ~[\r\n]* -> skip;
NEWLINE: ('\r'? '\n')+ { this.clearPendingScopes(); };
WS: [ \t]+ -> skip;
// Diagram title declaration
CLASS_DIAGRAM_V2: 'classDiagram-v2' -> type(CLASS_DIAGRAM);
CLASS_DIAGRAM: 'classDiagram';
// Directions
DIRECTION_TB: 'direction' WS_INLINE+ 'TB';
DIRECTION_BT: 'direction' WS_INLINE+ 'BT';
DIRECTION_LR: 'direction' WS_INLINE+ 'LR';
DIRECTION_RL: 'direction' WS_INLINE+ 'RL';
// Accessibility tokens
ACC_TITLE: 'accTitle' WS_INLINE* ':' WS_INLINE* -> pushMode(ACC_TITLE_MODE);
ACC_DESCR: 'accDescr' WS_INLINE* ':' WS_INLINE* -> pushMode(ACC_DESCR_MODE);
ACC_DESCR_MULTI: 'accDescr' WS_INLINE* '{' -> pushMode(ACC_DESCR_MULTILINE_MODE);
// Statements captured as raw lines for semantic handling in listener
STYLE_LINE: 'style' WS_INLINE+ ~[\r\n]*;
CLASSDEF_LINE: 'classDef' ~[\r\n]*;
CSSCLASS_LINE: 'cssClass' ~[\r\n]*;
CALLBACK_LINE: 'callback' ~[\r\n]*;
CLICK_LINE: 'click' ~[\r\n]*;
LINK_LINE: 'link' ~[\r\n]*;
CALL_LINE: 'call' ~[\r\n]*;
// Notes
NOTE_FOR: 'note' WS_INLINE+ 'for';
NOTE: 'note';
// Keywords that affect block handling
CLASS: 'class' { this.pendingClassBody = true; };
NAMESPACE: 'namespace' { this.pendingNamespaceBody = true; };
// Structural tokens
STYLE_SEPARATOR: ':::';
ANNOTATION_START: '<<';
ANNOTATION_END: '>>';
LBRACKET: '[';
RBRACKET: ']';
COMMA: ',';
DOT: '.';
EDGE_STATE: '[*]';
GENERIC: '~' (~[~\r\n])+ '~';
// Match strings without escape semantics to mirror Jison behavior
// Allow any chars except an unescaped closing double-quote; permit newlines
STRING: '"' NOT_DQUOTE* '"';
BACKTICK_ID: '`' (~[`])* '`';
LABEL: ':' (~[':\r\n;])*;
RELATION_ARROW
: (LEFT_HEAD)? LINE_BODY (RIGHT_HEAD)?
;
fragment LEFT_HEAD
: '<|'
| '<'
| 'o'
| '*'
| '()'
;
fragment RIGHT_HEAD
: '|>'
| '>'
| 'o'
| '*'
| '()'
;
fragment LINE_BODY
: '--'
| '..'
;
// Identifiers and numbers
IDENTIFIER
: (LETTER | DIGIT) IDENT_PART*
;
NUMBER: DIGIT+;
PLUS: '+';
MINUS: '-';
HASH: '#';
PERCENT: '%';
STAR: '*';
SLASH: '/';
LPAREN: '(';
RPAREN: ')';
// Structural braces with mode management
STRUCT_START
: '{'
{
if (this.pendingClassBody) {
this.pendingClassBody = false;
this.pushMode(ClassLexer.CLASS_BODY);
} else {
if (this.pendingNamespaceBody) {
this.pendingNamespaceBody = false;
}
this.pushMode(ClassLexer.BLOCK);
}
}
;
STRUCT_END: '}' { /* default mode only */ };
// Default fallback (should not normally trigger)
UNKNOWN: .;
// ===== Mode: ACC_TITLE =====
mode ACC_TITLE_MODE;
ACC_TITLE_MODE_WS: [ \t]+ -> skip;
ACC_TITLE_VALUE: ~[\r\n;#]+ -> type(ACC_TITLE_VALUE), popMode;
ACC_TITLE_MODE_NEWLINE: ('\r'? '\n')+ { this.popMode(); this.clearPendingScopes(); } -> type(NEWLINE);
// ===== Mode: ACC_DESCR =====
mode ACC_DESCR_MODE;
ACC_DESCR_MODE_WS: [ \t]+ -> skip;
ACC_DESCR_VALUE: ~[\r\n;#]+ -> type(ACC_DESCR_VALUE), popMode;
ACC_DESCR_MODE_NEWLINE: ('\r'? '\n')+ { this.popMode(); this.clearPendingScopes(); } -> type(NEWLINE);
// ===== Mode: ACC_DESCR_MULTILINE =====
mode ACC_DESCR_MULTILINE_MODE;
ACC_DESCR_MULTILINE_VALUE: (~[}])+ -> type(ACC_DESCR_MULTILINE_VALUE);
ACC_DESCR_MULTI_END: '}' -> popMode, type(ACC_DESCR_MULTI_END);
// ===== Mode: CLASS_BODY =====
mode CLASS_BODY;
CLASS_BODY_WS: [ \t]+ -> skip;
CLASS_BODY_COMMENT: '%%' ~[\r\n]* -> skip;
CLASS_BODY_NEWLINE: ('\r'? '\n')+ -> type(NEWLINE);
CLASS_BODY_STRUCT_END: '}' -> popMode, type(STRUCT_END);
CLASS_BODY_OPEN_BRACE: '{' -> type(OPEN_IN_STRUCT);
CLASS_BODY_EDGE_STATE: '[*]' -> type(EDGE_STATE);
CLASS_BODY_MEMBER: ~[{}\r\n]+ -> type(MEMBER);
// ===== Mode: BLOCK =====
mode BLOCK;
BLOCK_WS: [ \t]+ -> skip;
BLOCK_COMMENT: '%%' ~[\r\n]* -> skip;
BLOCK_NEWLINE: ('\r'? '\n')+ -> type(NEWLINE);
BLOCK_CLASS: 'class' { this.pendingClassBody = true; } -> type(CLASS);
BLOCK_NAMESPACE: 'namespace' { this.pendingNamespaceBody = true; } -> type(NAMESPACE);
BLOCK_STYLE_LINE: 'style' WS_INLINE+ ~[\r\n]* -> type(STYLE_LINE);
BLOCK_CLASSDEF_LINE: 'classDef' ~[\r\n]* -> type(CLASSDEF_LINE);
BLOCK_CSSCLASS_LINE: 'cssClass' ~[\r\n]* -> type(CSSCLASS_LINE);
BLOCK_CALLBACK_LINE: 'callback' ~[\r\n]* -> type(CALLBACK_LINE);
BLOCK_CLICK_LINE: 'click' ~[\r\n]* -> type(CLICK_LINE);
BLOCK_LINK_LINE: 'link' ~[\r\n]* -> type(LINK_LINE);
BLOCK_CALL_LINE: 'call' ~[\r\n]* -> type(CALL_LINE);
BLOCK_NOTE_FOR: 'note' WS_INLINE+ 'for' -> type(NOTE_FOR);
BLOCK_NOTE: 'note' -> type(NOTE);
BLOCK_ACC_TITLE: 'accTitle' WS_INLINE* ':' WS_INLINE* -> type(ACC_TITLE), pushMode(ACC_TITLE_MODE);
BLOCK_ACC_DESCR: 'accDescr' WS_INLINE* ':' WS_INLINE* -> type(ACC_DESCR), pushMode(ACC_DESCR_MODE);
BLOCK_ACC_DESCR_MULTI: 'accDescr' WS_INLINE* '{' -> type(ACC_DESCR_MULTI), pushMode(ACC_DESCR_MULTILINE_MODE);
BLOCK_STRUCT_START
: '{'
{
if (this.pendingClassBody) {
this.pendingClassBody = false;
this.pushMode(ClassLexer.CLASS_BODY);
} else {
if (this.pendingNamespaceBody) {
this.pendingNamespaceBody = false;
}
this.pushMode(ClassLexer.BLOCK);
}
}
-> type(STRUCT_START)
;
BLOCK_STRUCT_END: '}' -> popMode, type(STRUCT_END);
BLOCK_STYLE_SEPARATOR: ':::' -> type(STYLE_SEPARATOR);
BLOCK_ANNOTATION_START: '<<' -> type(ANNOTATION_START);
BLOCK_ANNOTATION_END: '>>' -> type(ANNOTATION_END);
BLOCK_LBRACKET: '[' -> type(LBRACKET);
BLOCK_RBRACKET: ']' -> type(RBRACKET);
BLOCK_COMMA: ',' -> type(COMMA);
BLOCK_DOT: '.' -> type(DOT);
BLOCK_EDGE_STATE: '[*]' -> type(EDGE_STATE);
BLOCK_GENERIC: '~' (~[~\r\n])+ '~' -> type(GENERIC);
// Mirror Jison: no escape semantics inside strings in BLOCK mode as well
BLOCK_STRING: '"' NOT_DQUOTE* '"' -> type(STRING);
BLOCK_BACKTICK_ID: '`' (~[`])* '`' -> type(BACKTICK_ID);
BLOCK_LABEL: ':' (~[':\r\n;])* -> type(LABEL);
BLOCK_RELATION_ARROW
: (LEFT_HEAD)? LINE_BODY (RIGHT_HEAD)?
-> type(RELATION_ARROW)
;
BLOCK_IDENTIFIER: (LETTER | DIGIT) IDENT_PART* -> type(IDENTIFIER);
BLOCK_NUMBER: DIGIT+ -> type(NUMBER);
BLOCK_PLUS: '+' -> type(PLUS);
BLOCK_MINUS: '-' -> type(MINUS);
BLOCK_HASH: '#' -> type(HASH);
BLOCK_PERCENT: '%' -> type(PERCENT);
BLOCK_STAR: '*' -> type(STAR);
BLOCK_SLASH: '/' -> type(SLASH);
BLOCK_LPAREN: '(' -> type(LPAREN);
BLOCK_RPAREN: ')' -> type(RPAREN);
BLOCK_UNKNOWN: . -> type(UNKNOWN);

View File

@@ -0,0 +1,204 @@
parser grammar ClassParser;
options {
tokenVocab = ClassLexer;
}
start
: (NEWLINE)* classDiagramSection EOF
;
classDiagramSection
: CLASS_DIAGRAM (NEWLINE)+ document
;
document
: (line)* statement?
;
line
: statement? NEWLINE
;
statement
: classStatement
| namespaceStatement
| relationStatement
| noteStatement
| annotationStatement
| memberStatement
| classDefStatement
| styleStatement
| cssClassStatement
| directionStatement
| accTitleStatement
| accDescrStatement
| accDescrMultilineStatement
| callbackStatement
| clickStatement
| linkStatement
| callStatement
;
classStatement
: classIdentifier classStatementTail?
;
classStatementTail
: STRUCT_START classMembers? STRUCT_END
| STYLE_SEPARATOR cssClassRef classStatementCssTail?
;
classStatementCssTail
: STRUCT_START classMembers? STRUCT_END
;
classIdentifier
: CLASS className classLabel?
;
classLabel
: LBRACKET stringLiteral RBRACKET
;
cssClassRef
: className
| IDENTIFIER
;
classMembers
: (NEWLINE | classMember)*
;
classMember
: MEMBER
| EDGE_STATE
;
namespaceStatement
: namespaceIdentifier namespaceBlock
;
namespaceIdentifier
: NAMESPACE namespaceName
;
namespaceName
: className
;
namespaceBlock
: STRUCT_START (NEWLINE)* namespaceBody? STRUCT_END
;
namespaceBody
: namespaceLine+
;
namespaceLine
: (classStatement | namespaceStatement)? NEWLINE
| classStatement
| namespaceStatement
;
relationStatement
: className relation className relationLabel?
| className stringLiteral relation className relationLabel?
| className relation stringLiteral className relationLabel?
| className stringLiteral relation stringLiteral className relationLabel?
;
relation
: RELATION_ARROW
;
relationLabel
: LABEL
;
noteStatement
: NOTE_FOR className noteBody
| NOTE noteBody
;
noteBody
: stringLiteral
;
annotationStatement
: ANNOTATION_START annotationName ANNOTATION_END className
;
annotationName
: IDENTIFIER
| stringLiteral
;
memberStatement
: className LABEL
;
classDefStatement
: CLASSDEF_LINE
;
styleStatement
: STYLE_LINE
;
cssClassStatement
: CSSCLASS_LINE
;
directionStatement
: DIRECTION_TB
| DIRECTION_BT
| DIRECTION_LR
| DIRECTION_RL
;
accTitleStatement
: ACC_TITLE ACC_TITLE_VALUE
;
accDescrStatement
: ACC_DESCR ACC_DESCR_VALUE
;
accDescrMultilineStatement
: ACC_DESCR_MULTI ACC_DESCR_MULTILINE_VALUE ACC_DESCR_MULTI_END
;
callbackStatement
: CALLBACK_LINE
;
clickStatement
: CLICK_LINE
;
linkStatement
: LINK_LINE
;
callStatement
: CALL_LINE
;
stringLiteral
: STRING
;
className
: classNameSegment (DOT classNameSegment)*
;
classNameSegment
: IDENTIFIER genericSuffix?
| BACKTICK_ID genericSuffix?
| EDGE_STATE
;
genericSuffix
: GENERIC
;

View File

@@ -0,0 +1,729 @@
import type { ParseTreeListener } from 'antlr4ng';
import {
BailErrorStrategy,
CharStream,
CommonTokenStream,
ParseCancellationException,
ParseTreeWalker,
RecognitionException,
type Token,
} from 'antlr4ng';
import {
ClassParser,
type ClassIdentifierContext,
type ClassMembersContext,
type ClassNameContext,
type ClassNameSegmentContext,
type ClassStatementContext,
type NamespaceIdentifierContext,
type RelationStatementContext,
type NoteStatementContext,
type AnnotationStatementContext,
type MemberStatementContext,
type ClassDefStatementContext,
type StyleStatementContext,
type CssClassStatementContext,
type DirectionStatementContext,
type AccTitleStatementContext,
type AccDescrStatementContext,
type AccDescrMultilineStatementContext,
type CallbackStatementContext,
type ClickStatementContext,
type LinkStatementContext,
type CallStatementContext,
type CssClassRefContext,
type StringLiteralContext,
} from './generated/ClassParser.js';
import { ClassParserListener } from './generated/ClassParserListener.js';
import { ClassLexer } from './generated/ClassLexer.js';
type ClassDbLike = Record<string, any>;
const stripQuotes = (value: string): string => {
const trimmed = value.trim();
if (trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"')) {
try {
return JSON.parse(trimmed.replace(/\r?\n/g, '\\n')) as string;
} catch {
return trimmed.slice(1, -1).replace(/\\"/g, '"');
}
}
return trimmed;
};
const stripBackticks = (value: string): string => {
const trimmed = value.trim();
if (trimmed.length >= 2 && trimmed.startsWith('`') && trimmed.endsWith('`')) {
return trimmed.slice(1, -1);
}
return trimmed;
};
const splitCommaSeparated = (text: string): string[] =>
text
.split(',')
.map((part) => part.trim())
.filter((part) => part.length > 0);
const getStringFromLiteral = (ctx: StringLiteralContext | undefined | null): string | undefined => {
if (!ctx) {
return undefined;
}
return stripQuotes(ctx.getText());
};
const getClassNameText = (ctx: ClassNameContext): string => {
const segments = ctx.classNameSegment();
const parts: string[] = [];
for (const segment of segments) {
parts.push(getClassNameSegmentText(segment));
}
return parts.join('.');
};
const getClassNameSegmentText = (ctx: ClassNameSegmentContext): string => {
if (ctx.BACKTICK_ID()) {
return stripBackticks(ctx.BACKTICK_ID()!.getText());
}
if (ctx.EDGE_STATE()) {
return ctx.EDGE_STATE()!.getText();
}
return ctx.getText();
};
const parseRelationArrow = (arrow: string, db: ClassDbLike) => {
const relation = {
type1: 'none',
type2: 'none',
lineType: db.lineType?.LINE ?? 0,
};
const trimmed = arrow.trim();
if (trimmed.includes('..')) {
relation.lineType = db.lineType?.DOTTED_LINE ?? relation.lineType;
}
const leftHeads: [string, keyof typeof db.relationType][] = [
['<|', 'EXTENSION'],
['()', 'LOLLIPOP'],
['o', 'AGGREGATION'],
['*', 'COMPOSITION'],
['<', 'DEPENDENCY'],
];
for (const [prefix, key] of leftHeads) {
if (trimmed.startsWith(prefix)) {
relation.type1 = db.relationType?.[key] ?? relation.type1;
break;
}
}
const rightHeads: [string, keyof typeof db.relationType][] = [
['|>', 'EXTENSION'],
['()', 'LOLLIPOP'],
['o', 'AGGREGATION'],
['*', 'COMPOSITION'],
['>', 'DEPENDENCY'],
];
for (const [suffix, key] of rightHeads) {
if (trimmed.endsWith(suffix)) {
relation.type2 = db.relationType?.[key] ?? relation.type2;
break;
}
}
return relation;
};
const parseStyleLine = (db: ClassDbLike, line: string) => {
const trimmed = line.trim();
const body = trimmed.slice('style'.length).trim();
if (!body) {
return;
}
const match = /^(\S+)(\s+.+)?$/.exec(body);
if (!match) {
return;
}
const classId = match[1];
const styleBody = match[2]?.trim() ?? '';
if (!styleBody) {
return;
}
const styles = splitCommaSeparated(styleBody);
if (styles.length) {
db.setCssStyle?.(classId, styles);
}
};
const parseClassDefLine = (db: ClassDbLike, line: string) => {
const trimmed = line.trim();
const body = trimmed.slice('classDef'.length).trim();
if (!body) {
return;
}
const match = /^(\S+)(\s+.+)?$/.exec(body);
if (!match) {
return;
}
const idPart = match[1];
const stylePart = match[2]?.trim() ?? '';
const ids = splitCommaSeparated(idPart);
const styles = stylePart ? splitCommaSeparated(stylePart) : [];
db.defineClass?.(ids, styles);
};
const parseCssClassLine = (db: ClassDbLike, line: string) => {
const trimmed = line.trim();
const body = trimmed.slice('cssClass'.length).trim();
if (!body) {
return;
}
const match = /^("[^"]*"|\S+)\s+(\S+)/.exec(body);
if (!match) {
return;
}
const idsRaw = stripQuotes(match[1]);
const className = match[2];
db.setCssClass?.(idsRaw, className);
};
const parseCallbackLine = (db: ClassDbLike, line: string) => {
const trimmed = line.trim();
const match = /^callback\s+(\S+)\s+("[^"]*")(?:\s+("[^"]*"))?\s*$/.exec(trimmed);
if (!match) {
return;
}
const target = match[1];
const fn = stripQuotes(match[2]);
const tooltip = match[3] ? stripQuotes(match[3]) : undefined;
db.setClickEvent?.(target, fn);
if (tooltip) {
db.setTooltip?.(target, tooltip);
}
};
const parseClickLine = (db: ClassDbLike, line: string) => {
const trimmed = line.trim();
const callMatch = /^click\s+(\S+)\s+call\s+([^(]+)\(([^)]*)\)(?:\s+("[^"]*"))?\s*$/.exec(trimmed);
if (callMatch) {
const target = callMatch[1];
const fnName = callMatch[2].trim();
const args = callMatch[3].trim();
const tooltip = callMatch[4] ? stripQuotes(callMatch[4]) : undefined;
if (args.length > 0) {
db.setClickEvent?.(target, fnName, args);
} else {
db.setClickEvent?.(target, fnName);
}
if (tooltip) {
db.setTooltip?.(target, tooltip);
}
return target;
}
const hrefMatch = /^click\s+(\S+)\s+href\s+("[^"]*")(?:\s+("[^"]*"))?(?:\s+(\S+))?\s*$/.exec(
trimmed
);
if (hrefMatch) {
const target = hrefMatch[1];
const url = stripQuotes(hrefMatch[2]);
const tooltip = hrefMatch[3] ? stripQuotes(hrefMatch[3]) : undefined;
const targetWindow = hrefMatch[4];
if (targetWindow) {
db.setLink?.(target, url, targetWindow);
} else {
db.setLink?.(target, url);
}
if (tooltip) {
db.setTooltip?.(target, tooltip);
}
return target;
}
const genericMatch = /^click\s+(\S+)\s+("[^"]*")(?:\s+("[^"]*"))?\s*$/.exec(trimmed);
if (genericMatch) {
const target = genericMatch[1];
const link = stripQuotes(genericMatch[2]);
const tooltip = genericMatch[3] ? stripQuotes(genericMatch[3]) : undefined;
db.setLink?.(target, link);
if (tooltip) {
db.setTooltip?.(target, tooltip);
}
return target;
}
return undefined;
};
const parseLinkLine = (db: ClassDbLike, line: string) => {
const trimmed = line.trim();
const match = /^link\s+(\S+)\s+("[^"]*")(?:\s+("[^"]*"))?(?:\s+(\S+))?\s*$/.exec(trimmed);
if (!match) {
return;
}
const target = match[1];
const href = stripQuotes(match[2]);
const tooltip = match[3] ? stripQuotes(match[3]) : undefined;
const targetWindow = match[4];
if (targetWindow) {
db.setLink?.(target, href, targetWindow);
} else {
db.setLink?.(target, href);
}
if (tooltip) {
db.setTooltip?.(target, tooltip);
}
};
const parseCallLine = (db: ClassDbLike, lastTarget: string | undefined, line: string) => {
if (!lastTarget) {
return;
}
const trimmed = line.trim();
const match = /^call\s+([^(]+)\(([^)]*)\)\s*("[^"]*")?\s*$/.exec(trimmed);
if (!match) {
return;
}
const fnName = match[1].trim();
const args = match[2].trim();
const tooltip = match[3] ? stripQuotes(match[3]) : undefined;
if (args.length > 0) {
db.setClickEvent?.(lastTarget, fnName, args);
} else {
db.setClickEvent?.(lastTarget, fnName);
}
if (tooltip) {
db.setTooltip?.(lastTarget, tooltip);
}
};
interface NamespaceFrame {
name?: string;
classes: string[];
}
class ClassDiagramParseListener extends ClassParserListener implements ParseTreeListener {
private readonly classNames = new WeakMap<ClassIdentifierContext, string>();
private readonly memberLists = new WeakMap<ClassMembersContext, string[]>();
private readonly namespaceStack: NamespaceFrame[] = [];
private lastClickTarget?: string;
constructor(private readonly db: ClassDbLike) {
super();
}
private recordClassInCurrentNamespace(name: string) {
const current = this.namespaceStack[this.namespaceStack.length - 1];
if (current?.name) {
current.classes.push(name);
}
}
override enterNamespaceStatement = (): void => {
this.namespaceStack.push({ classes: [] });
};
override exitNamespaceIdentifier = (ctx: NamespaceIdentifierContext): void => {
const frame = this.namespaceStack[this.namespaceStack.length - 1];
if (!frame) {
return;
}
const classNameCtx = ctx.namespaceName()?.className();
if (!classNameCtx) {
return;
}
const name = getClassNameText(classNameCtx);
frame.name = name;
this.db.addNamespace?.(name);
};
override exitNamespaceStatement = (): void => {
const frame = this.namespaceStack.pop();
if (!frame?.name) {
return;
}
if (frame.classes.length) {
this.db.addClassesToNamespace?.(frame.name, frame.classes);
}
};
override exitClassIdentifier = (ctx: ClassIdentifierContext): void => {
const id = getClassNameText(ctx.className());
this.classNames.set(ctx, id);
this.db.addClass?.(id);
this.recordClassInCurrentNamespace(id);
const labelCtx = ctx.classLabel?.();
if (labelCtx) {
const label = getStringFromLiteral(labelCtx.stringLiteral());
if (label !== undefined) {
this.db.setClassLabel?.(id, label);
}
}
};
override exitClassMembers = (ctx: ClassMembersContext): void => {
const members: string[] = [];
for (const memberCtx of ctx.classMember() ?? []) {
if (memberCtx.MEMBER()) {
members.push(memberCtx.MEMBER()!.getText());
} else if (memberCtx.EDGE_STATE()) {
members.push(memberCtx.EDGE_STATE()!.getText());
}
}
members.reverse();
this.memberLists.set(ctx, members);
};
override exitClassStatement = (ctx: ClassStatementContext): void => {
const identifierCtx = ctx.classIdentifier();
if (!identifierCtx) {
return;
}
const classId = this.classNames.get(identifierCtx);
if (!classId) {
return;
}
const tailCtx = ctx.classStatementTail?.();
const cssRefCtx = tailCtx?.cssClassRef?.();
if (cssRefCtx) {
const cssTarget = this.resolveCssClassRef(cssRefCtx);
if (cssTarget) {
this.db.setCssClass?.(classId, cssTarget);
}
}
const memberContexts: ClassMembersContext[] = [];
const cm1 = tailCtx?.classMembers();
if (cm1) {
memberContexts.push(cm1);
}
const cssTailCtx = tailCtx?.classStatementCssTail?.();
const cm2 = cssTailCtx?.classMembers();
if (cm2) {
memberContexts.push(cm2);
}
for (const membersCtx of memberContexts) {
const members = this.memberLists.get(membersCtx) ?? [];
if (members.length) {
this.db.addMembers?.(classId, members);
}
}
};
private resolveCssClassRef(ctx: CssClassRefContext): string | undefined {
if (ctx.className()) {
return getClassNameText(ctx.className()!);
}
if (ctx.IDENTIFIER()) {
return ctx.IDENTIFIER()!.getText();
}
return undefined;
}
override exitRelationStatement = (ctx: RelationStatementContext): void => {
const classNames = ctx.className();
if (classNames.length < 2) {
return;
}
const id1 = getClassNameText(classNames[0]);
const id2 = getClassNameText(classNames[classNames.length - 1]);
const arrow = ctx.relation()?.getText() ?? '';
const relation = parseRelationArrow(arrow, this.db);
let relationTitle1 = 'none';
let relationTitle2 = 'none';
const stringLiterals = ctx.stringLiteral();
if (stringLiterals.length === 1 && ctx.children) {
const stringCtx = stringLiterals[0];
const children = ctx.children as unknown[];
const stringIndex = children.indexOf(stringCtx);
const relationCtx = ctx.relation();
const relationIndex = relationCtx ? children.indexOf(relationCtx) : -1;
if (relationIndex >= 0 && stringIndex >= 0 && stringIndex < relationIndex) {
relationTitle1 = getStringFromLiteral(stringCtx) ?? 'none';
} else {
relationTitle2 = getStringFromLiteral(stringCtx) ?? 'none';
}
} else if (stringLiterals.length >= 2) {
relationTitle1 = getStringFromLiteral(stringLiterals[0]) ?? 'none';
relationTitle2 = getStringFromLiteral(stringLiterals[1]) ?? 'none';
}
let title = 'none';
const labelCtx = ctx.relationLabel?.();
if (labelCtx?.LABEL()) {
title = this.db.cleanupLabel?.(labelCtx.LABEL().getText()) ?? 'none';
}
this.db.addRelation?.({
id1,
id2,
relation,
relationTitle1,
relationTitle2,
title,
});
};
override exitNoteStatement = (ctx: NoteStatementContext): void => {
const noteCtx = ctx.noteBody();
const literalText = noteCtx?.getText?.();
const text = literalText !== undefined ? stripQuotes(literalText) : undefined;
if (text === undefined) {
return;
}
if (ctx.NOTE_FOR()) {
const className = getClassNameText(ctx.className()!);
this.db.addNote?.(text, className);
} else {
this.db.addNote?.(text);
}
};
override exitAnnotationStatement = (ctx: AnnotationStatementContext): void => {
const className = getClassNameText(ctx.className());
const nameCtx = ctx.annotationName();
let annotation: string | undefined;
if (nameCtx.IDENTIFIER()) {
annotation = nameCtx.IDENTIFIER()!.getText();
} else {
annotation = getStringFromLiteral(nameCtx.stringLiteral());
}
if (annotation !== undefined) {
this.db.addAnnotation?.(className, annotation);
}
};
override exitMemberStatement = (ctx: MemberStatementContext): void => {
const className = getClassNameText(ctx.className());
const labelToken = ctx.LABEL();
if (!labelToken) {
return;
}
const cleaned = this.db.cleanupLabel?.(labelToken.getText()) ?? labelToken.getText();
this.db.addMember?.(className, cleaned);
};
override exitClassDefStatement = (ctx: ClassDefStatementContext): void => {
const token = ctx.CLASSDEF_LINE()?.getSymbol()?.text;
if (token) {
parseClassDefLine(this.db, token);
}
};
override exitStyleStatement = (ctx: StyleStatementContext): void => {
const token = ctx.STYLE_LINE()?.getSymbol()?.text;
if (token) {
parseStyleLine(this.db, token);
}
};
override exitCssClassStatement = (ctx: CssClassStatementContext): void => {
const token = ctx.CSSCLASS_LINE()?.getSymbol()?.text;
if (token) {
parseCssClassLine(this.db, token);
}
};
override exitDirectionStatement = (ctx: DirectionStatementContext): void => {
if (ctx.DIRECTION_TB()) {
this.db.setDirection?.('TB');
} else if (ctx.DIRECTION_BT()) {
this.db.setDirection?.('BT');
} else if (ctx.DIRECTION_LR()) {
this.db.setDirection?.('LR');
} else if (ctx.DIRECTION_RL()) {
this.db.setDirection?.('RL');
}
};
override exitAccTitleStatement = (ctx: AccTitleStatementContext): void => {
const value = ctx.ACC_TITLE_VALUE()?.getText();
if (value !== undefined) {
this.db.setAccTitle?.(value.trim());
}
};
override exitAccDescrStatement = (ctx: AccDescrStatementContext): void => {
const value = ctx.ACC_DESCR_VALUE()?.getText();
if (value !== undefined) {
this.db.setAccDescription?.(value.trim());
}
};
override exitAccDescrMultilineStatement = (ctx: AccDescrMultilineStatementContext): void => {
const value = ctx.ACC_DESCR_MULTILINE_VALUE()?.getText();
if (value !== undefined) {
this.db.setAccDescription?.(value.trim());
}
};
override exitCallbackStatement = (ctx: CallbackStatementContext): void => {
const token = ctx.CALLBACK_LINE()?.getSymbol()?.text;
if (token) {
parseCallbackLine(this.db, token);
}
};
override exitClickStatement = (ctx: ClickStatementContext): void => {
const token = ctx.CLICK_LINE()?.getSymbol()?.text;
if (!token) {
return;
}
const target = parseClickLine(this.db, token);
if (target) {
this.lastClickTarget = target;
}
};
override exitLinkStatement = (ctx: LinkStatementContext): void => {
const token = ctx.LINK_LINE()?.getSymbol()?.text;
if (token) {
parseLinkLine(this.db, token);
}
};
override exitCallStatement = (ctx: CallStatementContext): void => {
const token = ctx.CALL_LINE()?.getSymbol()?.text;
if (token) {
parseCallLine(this.db, this.lastClickTarget, token);
}
};
}
class ANTLRClassParser {
yy: ClassDbLike | null = null;
parse(input: string): unknown {
if (!this.yy) {
throw new Error('Class ANTLR parser missing yy (database).');
}
this.yy.clear?.();
const inputStream = CharStream.fromString(input);
const lexer = new ClassLexer(inputStream);
const tokenStream = new CommonTokenStream(lexer);
const parser = new ClassParser(tokenStream);
const anyParser = parser as unknown as {
getErrorHandler?: () => unknown;
setErrorHandler?: (handler: unknown) => void;
errorHandler?: unknown;
};
const currentHandler = anyParser.getErrorHandler?.() ?? anyParser.errorHandler;
const handlerName = (currentHandler as { constructor?: { name?: string } } | undefined)
?.constructor?.name;
if (!currentHandler || handlerName !== 'BailErrorStrategy') {
if (typeof anyParser.setErrorHandler === 'function') {
anyParser.setErrorHandler(new BailErrorStrategy());
} else {
(parser as unknown as { errorHandler: unknown }).errorHandler = new BailErrorStrategy();
}
}
try {
const tree = parser.start();
const listener = new ClassDiagramParseListener(this.yy);
ParseTreeWalker.DEFAULT.walk(listener, tree);
return tree;
} catch (error) {
throw this.transformParseError(error, parser);
}
}
private transformParseError(error: unknown, parser: ClassParser): Error {
const recognitionError = this.unwrapRecognitionError(error);
const offendingToken = this.resolveOffendingToken(recognitionError, parser);
const line = offendingToken?.line ?? 0;
const column = offendingToken?.column ?? 0;
const message = `Parse error on line ${line}: Expecting 'STR'`;
const cause = error instanceof Error ? error : undefined;
const formatted = cause ? new Error(message, { cause }) : new Error(message);
Object.assign(formatted, {
hash: {
line,
loc: {
first_line: line,
last_line: line,
first_column: column,
last_column: column,
},
text: offendingToken?.text ?? '',
},
});
return formatted;
}
private unwrapRecognitionError(error: unknown): RecognitionException | undefined {
if (!error) {
return undefined;
}
if (error instanceof RecognitionException) {
return error;
}
if (error instanceof ParseCancellationException) {
const cause = (error as { cause?: unknown }).cause;
if (cause instanceof RecognitionException) {
return cause;
}
}
if (typeof error === 'object' && error !== null && 'cause' in error) {
const cause = (error as { cause?: unknown }).cause;
if (cause instanceof RecognitionException) {
return cause;
}
}
return undefined;
}
private resolveOffendingToken(
error: RecognitionException | undefined,
parser: ClassParser
): Token | undefined {
const candidate = (error as { offendingToken?: Token })?.offendingToken;
if (candidate) {
return candidate;
}
const current = (
parser as unknown as { getCurrentToken?: () => Token | undefined }
).getCurrentToken?.();
if (current) {
return current;
}
const stream = (
parser as unknown as { _input?: { LT?: (offset: number) => Token | undefined } }
)._input;
return stream?.LT?.(1);
}
}
const parserInstance = new ANTLRClassParser();
const exportedParser = {
parse: (text: string) => parserInstance.parse(text),
parser: parserInstance,
yy: null as ClassDbLike | null,
};
Object.defineProperty(exportedParser, 'yy', {
get() {
return parserInstance.yy;
},
set(value: ClassDbLike | null) {
parserInstance.yy = value;
},
});
export default exportedParser;

View File

@@ -0,0 +1,31 @@
// @ts-ignore: JISON parser lacks type definitions
import jisonParser from './classDiagram.jison';
import antlrParser from './antlr/antlr-parser.js';
const USE_ANTLR_PARSER = process.env.USE_ANTLR_PARSER === 'true';
const baseParser: any = USE_ANTLR_PARSER ? antlrParser : jisonParser;
const selectedParser: any = Object.create(baseParser);
selectedParser.parse = (source: string): unknown => {
const normalized = source.replace(/\r\n/g, '\n');
if (USE_ANTLR_PARSER) {
return antlrParser.parse(normalized);
}
return jisonParser.parse(normalized);
};
Object.defineProperty(selectedParser, 'yy', {
get() {
return baseParser.yy;
},
set(value) {
baseParser.yy = value;
},
enumerable: true,
configurable: true,
});
export default selectedParser;
export const parser = selectedParser;