diff --git a/.changeset/nested-generics-fix.md b/.changeset/nested-generics-fix.md new file mode 100644 index 00000000000..467a7f7e864 --- /dev/null +++ b/.changeset/nested-generics-fix.md @@ -0,0 +1,5 @@ +--- +'mermaid': patch +--- + +fix: support nested generic types in class diagram definitions (e.g. `List~List~Person~~`) diff --git a/packages/mermaid/src/diagrams/class/classDb.ts b/packages/mermaid/src/diagrams/class/classDb.ts index 663e4a2f4d8..01302264d52 100644 --- a/packages/mermaid/src/diagrams/class/classDb.ts +++ b/packages/mermaid/src/diagrams/class/classDb.ts @@ -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 }; diff --git a/packages/mermaid/src/diagrams/class/classDiagram.spec.ts b/packages/mermaid/src/diagrams/class/classDiagram.spec.ts index aaaf536187a..522806fb2d9 100644 --- a/packages/mermaid/src/diagrams/class/classDiagram.spec.ts +++ b/packages/mermaid/src/diagrams/class/classDiagram.spec.ts @@ -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 } diff --git a/packages/mermaid/src/diagrams/class/parser/classDiagram.jison b/packages/mermaid/src/diagrams/class/parser/classDiagram.jison index a380947a30f..9fa810a7057 100644 --- a/packages/mermaid/src/diagrams/class/parser/classDiagram.jison +++ b/packages/mermaid/src/diagrams/class/parser/classDiagram.jison @@ -102,9 +102,41 @@ line was introduced with 'click'. */ <*>"href" return 'HREF'; -[~] this.popState(); -[^~]* 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. +*/ +[^~]+ %{ this._gContent += yytext; %} +"~" %{ + 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 = ''; %} [`] this.popState(); [^`]+ return "BQUOTE_STR";