Skip to content
Merged
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/fair-swans-allow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'mermaid': patch
---

fix: Prevent browser hang when using multiline accDescr in XY charts
5 changes: 5 additions & 0 deletions .changeset/slow-bees-mate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@mermaid-js/parser': patch
---

fix: enhanced parser error messages to include line and column numbers for better debugging experience
5 changes: 5 additions & 0 deletions .changeset/tender-guests-unite.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'mermaid': patch
---

fix: Allow quoted string labels in architecture-beta diagrams
4 changes: 2 additions & 2 deletions packages/mermaid/src/diagrams/info/info.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,14 @@ describe('info', () => {
it('should throw because of unsupported info grammar', async () => {
const str = `info unsupported`;
await expect(parser.parse(str)).rejects.toThrow(
'Parsing failed: unexpected character: ->u<- at offset: 5, skipped 11 characters.'
'Parsing failed: Lexer error on line 1, column 6: unexpected character: ->u<- at offset: 5, skipped 11 characters.'
);
});

it('should throw because of unsupported info grammar', async () => {
const str = `info unsupported`;
await expect(parser.parse(str)).rejects.toThrow(
'Parsing failed: unexpected character: ->u<- at offset: 5, skipped 11 characters.'
'Parsing failed: Lexer error on line 1, column 6: unexpected character: ->u<- at offset: 5, skipped 11 characters.'
);
});
});
2 changes: 1 addition & 1 deletion packages/mermaid/src/diagrams/xychart/parser/xychart.jison
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
"accDescr"\s*":"\s* { this.pushState("acc_descr");return 'acc_descr'; }
<acc_descr>(?!\n|;|#)*[^\n]* { this.popState(); return "acc_descr_value"; }
"accDescr"\s*"{"\s* { this.pushState("acc_descr_multiline");}
<acc_descr_multiline>"{" { this.popState(); }
<acc_descr_multiline>"}" { this.popState(); }
<acc_descr_multiline>[^\}]* { return "acc_descr_multiline_value"; }

"xychart-beta" {return 'XYCHART';}
Expand Down
41 changes: 41 additions & 0 deletions packages/mermaid/src/diagrams/xychart/parser/xychart.jison.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ const mockDB: Record<string, Mock<any>> = {
setYAxisRangeData: vi.fn(),
setLineData: vi.fn(),
setBarData: vi.fn(),
setAccTitle: vi.fn(),
setAccDescription: vi.fn(),
};

function clearMocks() {
Expand Down Expand Up @@ -440,4 +442,43 @@ describe('Testing xychart jison file', () => {
[45, 99, 12]
);
});

describe('accessibility', () => {
it('should handle accTitle', () => {
const str = 'xychart\naccTitle: Accessible Title\nx-axis [Q1, Q2]\nline [1, 2]';
expect(parserFnConstructor(str)).not.toThrow();
expect(mockDB.setAccTitle).toHaveBeenCalledWith('Accessible Title');
});

it('should handle single-line accDescr', () => {
const str = 'xychart\naccDescr: This is a description\nx-axis [Q1, Q2]\nline [1, 2]';
expect(parserFnConstructor(str)).not.toThrow();
expect(mockDB.setAccDescription).toHaveBeenCalledWith('This is a description');
});

it('should handle multiline accDescr', () => {
const str = `xychart
accDescr {
This is a multiline
description
}
x-axis [Q1, Q2]
line [1, 2]`;
expect(parserFnConstructor(str)).not.toThrow();
expect(mockDB.setAccDescription).toHaveBeenCalledWith('This is a multiline\n description');
});

it('should handle both accTitle and accDescr', () => {
const str = `xychart
accTitle: Sales Overview
accDescr: Revenue report for Q1-Q4
title "Revenue Report"
x-axis [Q1, Q2, Q3, Q4]
line [45, 67, 89, 55]`;
expect(parserFnConstructor(str)).not.toThrow();
expect(mockDB.setAccTitle).toHaveBeenCalledWith('Sales Overview');
expect(mockDB.setAccDescription).toHaveBeenCalledWith('Revenue report for Q1-Q4');
expect(mockDB.setDiagramTitle).toHaveBeenCalledWith('Revenue Report');
});
});
});
2 changes: 1 addition & 1 deletion packages/parser/src/language/architecture/arch.langium
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
terminal ARCH_ICON: /\([\w-:]+\)/;
terminal ARCH_TITLE: /\[[\w ]+\]/;
terminal ARCH_TITLE: /\[(?:"([^"\\]|\\.)*"|'([^'\\]|\\.)*'|[\w ]+)\]/;
12 changes: 11 additions & 1 deletion packages/parser/src/language/architecture/valueConverter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,17 @@ export class ArchitectureValueConverter extends AbstractMermaidValueConverter {
} else if (rule.name === 'ARCH_TEXT_ICON') {
return input.replace(/["()]/g, '');
} else if (rule.name === 'ARCH_TITLE') {
return input.replace(/[[\]]/g, '').trim();
let result = input.replace(/^\[|]$/g, '').trim();
// Check if wrapped in quotes and remove only outer quotes
if (
(result.startsWith('"') && result.endsWith('"')) ||
(result.startsWith("'") && result.endsWith("'"))
) {
result = result.slice(1, -1);
// Unescape escaped quotes
result = result.replace(/\\"/g, '"').replace(/\\'/g, "'");
}
return result.trim();
}
return undefined;
}
Expand Down
22 changes: 20 additions & 2 deletions packages/parser/src/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,26 @@ export async function parse<T extends DiagramAST>(

export class MermaidParseError extends Error {
constructor(public result: ParseResult<DiagramAST>) {
const lexerErrors: string = result.lexerErrors.map((err) => err.message).join('\n');
const parserErrors: string = result.parserErrors.map((err) => err.message).join('\n');
const lexerErrors: string = result.lexerErrors
.map((err) => {
const line = err.line !== undefined && !isNaN(err.line) ? err.line : '?';
const column = err.column !== undefined && !isNaN(err.column) ? err.column : '?';
return `Lexer error on line ${line}, column ${column}: ${err.message}`;
})
.join('\n');
const parserErrors: string = result.parserErrors
.map((err) => {
const line =
err.token.startLine !== undefined && !isNaN(err.token.startLine)
? err.token.startLine
: '?';
const column =
err.token.startColumn !== undefined && !isNaN(err.token.startColumn)
? err.token.startColumn
: '?';
return `Parse error on line ${line}, column ${column}: ${err.message}`;
})
.join('\n');
super(`Parsing failed: ${lexerErrors} ${parserErrors}`);
}
}
79 changes: 79 additions & 0 deletions packages/parser/tests/architecture.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,4 +85,83 @@ describe('architecture', () => {
expect(accDescr).toBe('sample accDescr');
});
});

describe('should handle service titles with quotes', () => {
it('should handle service with quoted title using double quotes', () => {
const context = `architecture-beta
service db(database)["Database"] in api
`;
const result = parse(context);
expectNoErrorsOrAlternatives(result);
expect(result.value.$type).toBe(Architecture);

const service = result.value.services?.[0];
expect(service).toBeDefined();
expect(service?.title).toBe('Database');
});

it('should handle service with quoted title using single quotes', () => {
const context = `architecture-beta
service db(database)['Database'] in api
`;
const result = parse(context);
expectNoErrorsOrAlternatives(result);
expect(result.value.$type).toBe(Architecture);

const service = result.value.services?.[0];
expect(service).toBeDefined();
expect(service?.title).toBe('Database');
});

it('should handle service with unquoted title (backward compatibility)', () => {
const context = `architecture-beta
service db(database)[Database] in api
`;
const result = parse(context);
expectNoErrorsOrAlternatives(result);
expect(result.value.$type).toBe(Architecture);

const service = result.value.services?.[0];
expect(service).toBeDefined();
expect(service?.title).toBe('Database');
});

it('should handle group with quoted title', () => {
const context = `architecture-beta
group api(cloud)["API"]
`;
const result = parse(context);
expectNoErrorsOrAlternatives(result);
expect(result.value.$type).toBe(Architecture);

const group = result.value.groups?.[0];
expect(group).toBeDefined();
expect(group?.title).toBe('API');
});
it('should preserve apostrophes in service titles', () => {
const context = `architecture-beta
service db(database)["John's Database"] in api
`;
const result = parse(context);
expectNoErrorsOrAlternatives(result);
expect(result.value.$type).toBe(Architecture);

const service = result.value.services?.[0];
expect(service).toBeDefined();
expect(service?.title).toBe("John's Database");
});

it('should preserve inner quotes in service titles when escaped', () => {
const context = `architecture-beta
service api(server)["The \\"Main\\" API"] in cloud
`;
const result = parse(context);
expectNoErrorsOrAlternatives(result);
expect(result.value.$type).toBe(Architecture);

const service = result.value.services?.[0];
expect(service).toBeDefined();
expect(service?.title).toBe('The "Main" API');
});
});
});
82 changes: 82 additions & 0 deletions packages/parser/tests/radar.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';

import { Radar } from '../src/language/index.js';
import { expectNoErrorsOrAlternatives, radarParse as parse } from './test-util.js';
import { parse as parseAsync, MermaidParseError } from '../src/parse.js';

const mutateGlobalSpacing = (context: string) => {
return [
Expand Down Expand Up @@ -340,4 +341,85 @@ describe('radar', () => {
}
);
});

describe('error messages with line and column numbers', () => {
it('should include line and column numbers in parser errors for radar diagrams', async () => {
const invalidRadar = `radar-beta
title Restaurant Comparison
axis food["Food Quality"], service["Service"], price["Price"]
axis ambiance["Ambiance"],

curve a["Restaurant A"]{4, 3, 2, 4}`;

try {
await parseAsync('radar', invalidRadar);
expect.fail('Should have thrown MermaidParseError');
} catch (error: any) {
expect(error).toBeInstanceOf(MermaidParseError);
expect(error.message).toMatch(/line \d+/);
expect(error.message).toMatch(/column \d+/);
}
});

it('should include line and column numbers for missing curve entries', async () => {
const invalidRadar = `radar-beta
axis my-axis
curve my-curve`;

try {
await parseAsync('radar', invalidRadar);
expect.fail('Should have thrown MermaidParseError');
} catch (error: any) {
expect(error).toBeInstanceOf(MermaidParseError);
// Line and column may be ? if not available
expect(error.message).toMatch(/line (\d+|\?)/);
expect(error.message).toMatch(/column (\d+|\?)/);
}
});

it('should include line and column numbers for invalid axis syntax', async () => {
const invalidRadar = `radar-beta
axis my-axis my-axis2`;

try {
await parseAsync('radar', invalidRadar);
expect.fail('Should have thrown MermaidParseError');
} catch (error: any) {
expect(error).toBeInstanceOf(MermaidParseError);
expect(error.message).toMatch(/line \d+/);
expect(error.message).toMatch(/column \d+/);
}
});

it('should handle lexer errors with line and column numbers', async () => {
const invalidRadar = `radar-beta
axis A
curve B{1}
invalid@symbol`;

try {
await parseAsync('radar', invalidRadar);
expect.fail('Should have thrown MermaidParseError');
} catch (error: any) {
expect(error).toBeInstanceOf(MermaidParseError);
// Should have line and column in the error message
expect(error.message).toMatch(/line (\d+|\?)/);
expect(error.message).toMatch(/column (\d+|\?)/);
}
});

it('should format error message with "Parse error on line X, column Y" prefix', async () => {
const invalidRadar = `radar-beta
axis`;

try {
await parseAsync('radar', invalidRadar);
expect.fail('Should have thrown MermaidParseError');
} catch (error: any) {
expect(error).toBeInstanceOf(MermaidParseError);
// Line and column may be ? if not available
expect(error.message).toMatch(/Parse error on line (\d+|\?), column (\d+|\?):/);
}
});
});
});
Loading