diff --git a/packages/parser/src/language/mindmap/mindmap-validator.ts b/packages/parser/src/language/mindmap/mindmap-validator.ts new file mode 100644 index 000000000..06c7c94e1 --- /dev/null +++ b/packages/parser/src/language/mindmap/mindmap-validator.ts @@ -0,0 +1,70 @@ +import type { ValidationAcceptor, ValidationChecks } from 'langium'; +import type { MermaidAstType, MindmapDoc } from '../generated/ast.js'; +import type { MindmapServices } from './module.js'; + +/** + * Register custom validation checks. + */ +export function registerValidationChecks(services: MindmapServices) { + console.debug('MindmapValidator registerValidationChecks'); + const validator = services.validation.MindmapValidator; + const registry = services.validation.ValidationRegistry; + if (registry) { + console.debug('MindmapValidator registerValidationChecks registry'); + // Use any to bypass type checking since we know MindmapDoc is part of the AST + // but the type system is having trouble with it + const checks: ValidationChecks = { + MindmapDoc: validator.checkSingleRoot, + MindmapRow: validator.checkSingleRootRow, + }; + registry.register(checks, validator); + } +} + +/** + * Implementation of custom validations. + */ +export class MindmapValidator { + constructor() { + // eslint-disable-next-line no-console + console.debug('MindmapValidator constructor'); + } + checkSingleRootRow(_doc: MindmapDoc, _accept: ValidationAcceptor): void { + // eslint-disable-next-line no-console + console.debug('CHECKING SINGLE ROOT Row'); + } + + /** + * Validates that a mindmap has only one root node. + * A root node is defined as a node that has no indentation. + */ + checkSingleRoot(doc: MindmapDoc, accept: ValidationAcceptor): void { + // eslint-disable-next-line no-console + console.debug('CHECKING SINGLE ROOT'); + let rootNodeFound = false; + + for (const row of doc.MindmapRows) { + // Skip non-node items (e.g., class decorations, icon decorations) + if ( + !row.item || + row.item.$type === 'ClassDecoration' || + row.item.$type === 'IconDecoration' + ) { + continue; + } + + // Check if this is a root node (no indentation) + if (row.indent === undefined) { + if (rootNodeFound) { + // If we've already found a root node, report an error + accept('error', 'Multiple root nodes are not allowed in a mindmap.', { + node: row, + property: 'item', + }); + } else { + rootNodeFound = true; + } + } + } + } +} diff --git a/packages/parser/src/language/mindmap/mindmap.langium b/packages/parser/src/language/mindmap/mindmap.langium index 5819914af..dd8e78275 100644 --- a/packages/parser/src/language/mindmap/mindmap.langium +++ b/packages/parser/src/language/mindmap/mindmap.langium @@ -27,7 +27,7 @@ CircleNode: // id=ID '((' desc=(ID|STRING) '))'; RoundedNode: - (id=ID)? desc=(ROUNDED_STR); + (id=ID)? desc=(ROUNDED_STR_QUOTES|ROUNDED_STR); // Handle other complex node variants OtherComplex: @@ -58,11 +58,12 @@ terminal ICON_KEYWORD: '::icon('; terminal CLASS_KEYWORD: ':::'; // Basic token types -terminal ID: /[a-zA-Z0-9_\-\.\/]+/; // terminal CIRCLE_STR: /[\s\S]*?\)\)/; terminal CIRCLE_STR: /\(\(([\s\S]*?)\)\)/; +terminal ROUNDED_STR_QUOTES: /\(\"([\s\S]*?)\"\)/; terminal ROUNDED_STR: /\(([\s\S]*?)\)/; // terminal CIRCLE_STR: /(?!\(\()[\s\S]+?(?!\(\()/; +terminal ID: /[a-zA-Z0-9_\-\.\/]+/; terminal STRING: /"[^"]*"|'[^']*'/; // Modified indentation rule to have higher priority than WS diff --git a/packages/parser/src/language/mindmap/module.ts b/packages/parser/src/language/mindmap/module.ts index 48ca72e6d..9824d378a 100644 --- a/packages/parser/src/language/mindmap/module.ts +++ b/packages/parser/src/language/mindmap/module.ts @@ -15,6 +15,7 @@ import { import { MermaidGeneratedSharedModule, MindmapGeneratedModule } from '../generated/module.js'; import { MindmapTokenBuilder } from './tokenBuilder.js'; import { MindmapValueConverter } from './valueConverter.js'; +import { MindmapValidator, registerValidationChecks } from './mindmap-validator.js'; /** * Declaration of `Mindmap` services. @@ -24,6 +25,9 @@ interface MindmapAddedServices { TokenBuilder: MindmapTokenBuilder; ValueConverter: MindmapValueConverter; }; + validation: { + MindmapValidator: MindmapValidator; + }; } /** @@ -43,6 +47,9 @@ export const MindmapModule: Module< TokenBuilder: () => new MindmapTokenBuilder(), ValueConverter: () => new MindmapValueConverter(), }, + validation: { + MindmapValidator: () => new MindmapValidator(), + }, }; /** @@ -73,5 +80,9 @@ export function createMindmapServices(context: DefaultSharedCoreModuleContext = MindmapModule ); shared.ServiceRegistry.register(Mindmap); + + // Register validation checks + registerValidationChecks(Mindmap); + return { shared, Mindmap }; } diff --git a/packages/parser/src/language/mindmap/valueConverter.ts b/packages/parser/src/language/mindmap/valueConverter.ts index b7ac7cce5..414b6514a 100644 --- a/packages/parser/src/language/mindmap/valueConverter.ts +++ b/packages/parser/src/language/mindmap/valueConverter.ts @@ -13,6 +13,8 @@ export class MindmapValueConverter extends AbstractMermaidValueConverter { return input.replace('((', '').replace('))', '').trim(); } else if (rule.name === 'ROUNDED_STR') { return input.replace('(', '').replace(')', '').trim(); + } else if (rule.name === 'ROUNDED_STR_QUOTES') { + return input.replace('("', '').replace('")', '').trim(); } else if (rule.name === 'ARCH_TEXT_ICON') { return input.replace(/["()]/g, ''); } else if (rule.name === 'ARCH_TITLE') { diff --git a/packages/parser/tests/mindmap.test.ts b/packages/parser/tests/mindmap.test.ts index 3c61d33f1..ec06e3e86 100644 --- a/packages/parser/tests/mindmap.test.ts +++ b/packages/parser/tests/mindmap.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { mindmapParse as parse } from './test-util.js'; +import { validatedMindmapParse as validatedParse, mindmapParse as parse } from './test-util.js'; import type { CircleNode, SimpleNode, OtherComplex } from '../src/language/generated/ast.js'; // import { MindmapRow, Item } from '../src/language/generated/ast'; @@ -97,7 +97,7 @@ describe('Hierarchy (ported from mindmap.spec.ts)', () => { expect(child2Node.id).toBe('child2'); }); - it.only('MMP-3 should handle a simple root definition with a shape and without an id', () => { + it('MMP-3 should handle a simple root definition with a shape and without an id', () => { const result = parse('mindmap\n(root)\n'); expect(result.lexerErrors).toHaveLength(0); console.debug('RESULT:', result.parserErrors); @@ -111,12 +111,12 @@ describe('Hierarchy (ported from mindmap.spec.ts)', () => { it('MMP-3.5 should handle a simple root definition with a shape and without an id', () => { const result = parse('mindmap\n("r(oo)t")\n'); expect(result.lexerErrors).toHaveLength(0); - console.debug('RESULT:', result.parserErrors); + console.debug('RESULT-', result.parserErrors); expect(result.parserErrors).toHaveLength(0); // The content should be 'root', shape info may not be present in AST const rootNode = result.value.MindmapRows[0].item as OtherComplex; expect(rootNode.id).toBe(undefined); - expect(rootNode.desc).toBe('root'); + expect(rootNode.desc).toBe('r(oo)t'); }); it('MMP-4 should handle a deeper hierarchical mindmap definition', () => { @@ -133,11 +133,16 @@ describe('Hierarchy (ported from mindmap.spec.ts)', () => { expect(child2Node.id).toBe('child2'); }); - it('MMP-5 Multiple roots are illegal', () => { + it.only('MMP-5 Multiple roots are illegal', async () => { const str = 'mindmap\nroot\nfakeRoot'; - const result = parse(str); + const result = await validatedParse(str, { validation: true }); // Langium parser may not throw, but should have parserErrors - expect(result.parserErrors.length).toBeGreaterThan(0); + expect(result.diagnostics![0].message).toBe( + 'Multiple root nodes are not allowed in a mindmap.' + ); + const str2 = 'mindmap\nroot\n notAFakeRoot'; + const result2 = await validatedParse(str2, { validation: true }); + expect(result2.diagnostics?.length).toBe(0); }); it('MMP-6 real root in wrong place', () => { diff --git a/packages/parser/tests/test-util.ts b/packages/parser/tests/test-util.ts index cd1842ff9..12bdbbd3e 100644 --- a/packages/parser/tests/test-util.ts +++ b/packages/parser/tests/test-util.ts @@ -1,4 +1,4 @@ -import type { LangiumParser, ParseResult } from 'langium'; +import type { LangiumParser, ParseResult, ParserOptions } from 'langium'; import { expect, vi } from 'vitest'; import type { Architecture, @@ -25,6 +25,7 @@ import { createGitGraphServices, createMindmapServices, } from '../src/language/index.js'; +import { parseHelper } from 'langium/test'; const consoleMock = vi.spyOn(console, 'log').mockImplementation(() => undefined); @@ -111,10 +112,12 @@ export const gitGraphParse = createGitGraphTestServices().parse; const mindmapServices: MindmapServices = createMindmapServices().Mindmap; const mindmapParser: LangiumParser = mindmapServices.parser.LangiumParser; export function createMindmapTestServices() { - const parse = (input: string) => { - return mindmapParser.parse(input); + const parse = (input: string, options?: ParserOptions) => { + return mindmapParser.parse(input, options); }; + const validatedParse = parseHelper(mindmapServices); - return { services: mindmapServices, parse }; + return { services: mindmapServices, parse, validatedParse }; } export const mindmapParse = createMindmapTestServices().parse; +export const validatedMindmapParse = createMindmapTestServices().validatedParse;