Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/nested-generics-fix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'mermaid': patch
---

fix: support nested generic types in class diagram definitions (e.g. `List~List~Person~~`)
9 changes: 5 additions & 4 deletions packages/mermaid/src/diagrams/class/classDb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,10 +80,11 @@ export class ClassDB implements DiagramDB {
let genericType = '';
let className = id;

if (id.indexOf('~') > 0) {
const split = id.split('~');
className = sanitizeText(split[0]);
genericType = sanitizeText(split[1]);
const firstTilde = id.indexOf('~');
if (firstTilde > 0) {
const lastTilde = id.lastIndexOf('~');
className = sanitizeText(id.substring(0, firstTilde));
genericType = sanitizeText(id.substring(firstTilde + 1, lastTilde));
}

return { className: className, type: genericType };
Expand Down
56 changes: 56 additions & 0 deletions packages/mermaid/src/diagrams/class/classDiagram.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1223,6 +1223,62 @@ describe('given a class diagram with generics, ', function () {
parser.parse(str);
});

it('should handle nested generic class', function () {
const str = 'classDiagram\n' + 'class People~List~Person~~';

parser.parse(str);

const classNode = classDb.getClass('People');
expect(classNode.type).toBe('List~Person~');
});

it('should handle deeply nested generic class', function () {
const str = 'classDiagram\n' + 'class Container~List~List~String~~~';

parser.parse(str);

const classNode = classDb.getClass('Container');
expect(classNode.type).toBe('List~List~String~~');
});

it('should handle nested generic class with relationships', function () {
const str =
'classDiagram\n' + 'class People~List~Person~~\n' + 'People -- Person : contains >';

parser.parse(str);

const relations = classDb.getRelations();
expect(relations).toHaveLength(1);
expect(relations[0].id1).toBe('People');
expect(relations[0].id2).toBe('Person');
});

it('should handle nested generic class with brackets', function () {
const str =
'classDiagram\n' +
'class People~List~Person~~ {\n' +
'String name\n' +
'void add()\n' +
'}';

parser.parse(str);

const classNode = classDb.getClass('People');
expect(classNode.type).toBe('List~Person~');
});

it('should handle empty generic class (no type parameter)', function () {
const str = 'classDiagram\n' + 'class Empty~~';

parser.parse(str);

// Empty generics (`~~`) produce no GENERICTYPE token, so the class
// is parsed without a type parameter β€” matching pre-existing behavior
const classNode = classDb.getClass('Empty');
expect(classNode).toBeDefined();
expect(classNode.type).toBe('');
});

it('should handle "namespace"', function () {
const str = `classDiagram
namespace Namespace1 { class Class1 }
Expand Down
38 changes: 35 additions & 3 deletions packages/mermaid/src/diagrams/class/parser/classDiagram.jison
Original file line number Diff line number Diff line change
Expand Up @@ -102,9 +102,41 @@ line was introduced with 'click'.
*/
<*>"href" return 'HREF';

<generic>[~] this.popState();
<generic>[^~]* return "GENERICTYPE";
<*>"~" this.begin("generic");
/*
Nested generic type support: track depth so `List~List~Person~~` is captured
as a single GENERICTYPE token with content `List~Person~`.

On each `~` inside the generic state, peek at the next character to decide:
- word char (\w) β†’ opening a nested generic, increment depth
- anything else β†’ closing current level, decrement depth
When depth hits 0, pop state and emit the accumulated content.

This heuristic assumes generic type parameters contain identifier-like content.
Leading whitespace or punctuation inside generics (e.g. `Map~ Key~`) will close
the type prematurely β€” matching pre-existing behavior.

Empty generics (`~~`) intentionally skip emitting a GENERICTYPE token since the
grammar's className rule requires content between the tildes. An empty generic
simply produces no token and the class name is parsed without a type parameter.
*/
<generic>[^~]+ %{ this._gContent += yytext; %}
<generic>"~" %{
var nextCh = this._input.length > 0 ? this._input.charAt(0) : '';
if (/\w/.test(nextCh)) {
this._gDepth++;
this._gContent += '~';
} else {
this._gDepth--;
if (this._gDepth > 0) {
this._gContent += '~';
} else {
this.popState();
yytext = this._gContent;
if (this._gContent.length > 0) return "GENERICTYPE";
}
}
%}
<*>"~" %{ this.begin("generic"); this._gDepth = 1; this._gContent = ''; %}

<bqstring>[`] this.popState();
<bqstring>[^`]+ return "BQUOTE_STR";
Expand Down
Loading