Added data validator and support for quotes

This commit is contained in:
Knut Sveidqvist
2025-04-24 15:50:34 +02:00
parent 4977cdb1f4
commit 0bbfa8e602
6 changed files with 105 additions and 13 deletions

View File

@@ -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<MermaidAstType> = {
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;
}
}
}
}
}

View File

@@ -27,7 +27,7 @@ CircleNode:
// id=ID '((' desc=(ID|STRING) '))'; // id=ID '((' desc=(ID|STRING) '))';
RoundedNode: RoundedNode:
(id=ID)? desc=(ROUNDED_STR); (id=ID)? desc=(ROUNDED_STR_QUOTES|ROUNDED_STR);
// Handle other complex node variants // Handle other complex node variants
OtherComplex: OtherComplex:
@@ -58,11 +58,12 @@ terminal ICON_KEYWORD: '::icon(';
terminal CLASS_KEYWORD: ':::'; terminal CLASS_KEYWORD: ':::';
// Basic token types // Basic token types
terminal ID: /[a-zA-Z0-9_\-\.\/]+/;
// terminal CIRCLE_STR: /[\s\S]*?\)\)/; // terminal CIRCLE_STR: /[\s\S]*?\)\)/;
terminal CIRCLE_STR: /\(\(([\s\S]*?)\)\)/; terminal CIRCLE_STR: /\(\(([\s\S]*?)\)\)/;
terminal ROUNDED_STR_QUOTES: /\(\"([\s\S]*?)\"\)/;
terminal ROUNDED_STR: /\(([\s\S]*?)\)/; terminal ROUNDED_STR: /\(([\s\S]*?)\)/;
// terminal CIRCLE_STR: /(?!\(\()[\s\S]+?(?!\(\()/; // terminal CIRCLE_STR: /(?!\(\()[\s\S]+?(?!\(\()/;
terminal ID: /[a-zA-Z0-9_\-\.\/]+/;
terminal STRING: /"[^"]*"|'[^']*'/; terminal STRING: /"[^"]*"|'[^']*'/;
// Modified indentation rule to have higher priority than WS // Modified indentation rule to have higher priority than WS

View File

@@ -15,6 +15,7 @@ import {
import { MermaidGeneratedSharedModule, MindmapGeneratedModule } from '../generated/module.js'; import { MermaidGeneratedSharedModule, MindmapGeneratedModule } from '../generated/module.js';
import { MindmapTokenBuilder } from './tokenBuilder.js'; import { MindmapTokenBuilder } from './tokenBuilder.js';
import { MindmapValueConverter } from './valueConverter.js'; import { MindmapValueConverter } from './valueConverter.js';
import { MindmapValidator, registerValidationChecks } from './mindmap-validator.js';
/** /**
* Declaration of `Mindmap` services. * Declaration of `Mindmap` services.
@@ -24,6 +25,9 @@ interface MindmapAddedServices {
TokenBuilder: MindmapTokenBuilder; TokenBuilder: MindmapTokenBuilder;
ValueConverter: MindmapValueConverter; ValueConverter: MindmapValueConverter;
}; };
validation: {
MindmapValidator: MindmapValidator;
};
} }
/** /**
@@ -43,6 +47,9 @@ export const MindmapModule: Module<
TokenBuilder: () => new MindmapTokenBuilder(), TokenBuilder: () => new MindmapTokenBuilder(),
ValueConverter: () => new MindmapValueConverter(), ValueConverter: () => new MindmapValueConverter(),
}, },
validation: {
MindmapValidator: () => new MindmapValidator(),
},
}; };
/** /**
@@ -73,5 +80,9 @@ export function createMindmapServices(context: DefaultSharedCoreModuleContext =
MindmapModule MindmapModule
); );
shared.ServiceRegistry.register(Mindmap); shared.ServiceRegistry.register(Mindmap);
// Register validation checks
registerValidationChecks(Mindmap);
return { shared, Mindmap }; return { shared, Mindmap };
} }

View File

@@ -13,6 +13,8 @@ export class MindmapValueConverter extends AbstractMermaidValueConverter {
return input.replace('((', '').replace('))', '').trim(); return input.replace('((', '').replace('))', '').trim();
} else if (rule.name === 'ROUNDED_STR') { } else if (rule.name === 'ROUNDED_STR') {
return input.replace('(', '').replace(')', '').trim(); return input.replace('(', '').replace(')', '').trim();
} else if (rule.name === 'ROUNDED_STR_QUOTES') {
return input.replace('("', '').replace('")', '').trim();
} else if (rule.name === 'ARCH_TEXT_ICON') { } else if (rule.name === 'ARCH_TEXT_ICON') {
return input.replace(/["()]/g, ''); return input.replace(/["()]/g, '');
} else if (rule.name === 'ARCH_TITLE') { } else if (rule.name === 'ARCH_TITLE') {

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'; 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 type { CircleNode, SimpleNode, OtherComplex } from '../src/language/generated/ast.js';
// import { MindmapRow, Item } from '../src/language/generated/ast'; // import { MindmapRow, Item } from '../src/language/generated/ast';
@@ -97,7 +97,7 @@ describe('Hierarchy (ported from mindmap.spec.ts)', () => {
expect(child2Node.id).toBe('child2'); 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'); const result = parse('mindmap\n(root)\n');
expect(result.lexerErrors).toHaveLength(0); expect(result.lexerErrors).toHaveLength(0);
console.debug('RESULT:', result.parserErrors); 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', () => { 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'); const result = parse('mindmap\n("r(oo)t")\n');
expect(result.lexerErrors).toHaveLength(0); expect(result.lexerErrors).toHaveLength(0);
console.debug('RESULT:', result.parserErrors); console.debug('RESULT-', result.parserErrors);
expect(result.parserErrors).toHaveLength(0); expect(result.parserErrors).toHaveLength(0);
// The content should be 'root', shape info may not be present in AST // The content should be 'root', shape info may not be present in AST
const rootNode = result.value.MindmapRows[0].item as OtherComplex; const rootNode = result.value.MindmapRows[0].item as OtherComplex;
expect(rootNode.id).toBe(undefined); 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', () => { 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'); 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 str = 'mindmap\nroot\nfakeRoot';
const result = parse(str); const result = await validatedParse(str, { validation: true });
// Langium parser may not throw, but should have parserErrors // 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', () => { it('MMP-6 real root in wrong place', () => {

View File

@@ -1,4 +1,4 @@
import type { LangiumParser, ParseResult } from 'langium'; import type { LangiumParser, ParseResult, ParserOptions } from 'langium';
import { expect, vi } from 'vitest'; import { expect, vi } from 'vitest';
import type { import type {
Architecture, Architecture,
@@ -25,6 +25,7 @@ import {
createGitGraphServices, createGitGraphServices,
createMindmapServices, createMindmapServices,
} from '../src/language/index.js'; } from '../src/language/index.js';
import { parseHelper } from 'langium/test';
const consoleMock = vi.spyOn(console, 'log').mockImplementation(() => undefined); const consoleMock = vi.spyOn(console, 'log').mockImplementation(() => undefined);
@@ -111,10 +112,12 @@ export const gitGraphParse = createGitGraphTestServices().parse;
const mindmapServices: MindmapServices = createMindmapServices().Mindmap; const mindmapServices: MindmapServices = createMindmapServices().Mindmap;
const mindmapParser: LangiumParser = mindmapServices.parser.LangiumParser; const mindmapParser: LangiumParser = mindmapServices.parser.LangiumParser;
export function createMindmapTestServices() { export function createMindmapTestServices() {
const parse = (input: string) => { const parse = (input: string, options?: ParserOptions) => {
return mindmapParser.parse<Mindmap>(input); return mindmapParser.parse<MindmapDoc>(input, options);
}; };
const validatedParse = parseHelper<Mindmap>(mindmapServices);
return { services: mindmapServices, parse }; return { services: mindmapServices, parse, validatedParse };
} }
export const mindmapParse = createMindmapTestServices().parse; export const mindmapParse = createMindmapTestServices().parse;
export const validatedMindmapParse = createMindmapTestServices().validatedParse;