diff --git a/packages/parser/langium-config.json b/packages/parser/langium-config.json index ad80350c2..182b4c763 100644 --- a/packages/parser/langium-config.json +++ b/packages/parser/langium-config.json @@ -4,32 +4,58 @@ { "id": "info", "grammar": "src/language/info/info.langium", - "fileExtensions": [".mmd", ".mermaid"] + "fileExtensions": [ + ".mmd", + ".mermaid" + ] }, { "id": "packet", "grammar": "src/language/packet/packet.langium", - "fileExtensions": [".mmd", ".mermaid"] + "fileExtensions": [ + ".mmd", + ".mermaid" + ] }, { "id": "pie", "grammar": "src/language/pie/pie.langium", - "fileExtensions": [".mmd", ".mermaid"] + "fileExtensions": [ + ".mmd", + ".mermaid" + ] }, { "id": "architecture", "grammar": "src/language/architecture/architecture.langium", - "fileExtensions": [".mmd", ".mermaid"] + "fileExtensions": [ + ".mmd", + ".mermaid" + ] }, { "id": "gitGraph", "grammar": "src/language/gitGraph/gitGraph.langium", - "fileExtensions": [".mmd", ".mermaid"] + "fileExtensions": [ + ".mmd", + ".mermaid" + ] }, { "id": "radar", "grammar": "src/language/radar/radar.langium", - "fileExtensions": [".mmd", ".mermaid"] + "fileExtensions": [ + ".mmd", + ".mermaid" + ] + }, + { + "id": "treemap", + "grammar": "src/language/treemap/treemap.langium", + "fileExtensions": [ + ".mmd", + ".mermaid" + ] } ], "mode": "production", diff --git a/packages/parser/src/language/treemap/index.ts b/packages/parser/src/language/treemap/index.ts new file mode 100644 index 000000000..fd3c604b0 --- /dev/null +++ b/packages/parser/src/language/treemap/index.ts @@ -0,0 +1 @@ +export * from './module.js'; diff --git a/packages/parser/src/language/treemap/module.ts b/packages/parser/src/language/treemap/module.ts new file mode 100644 index 000000000..aaab7d0e8 --- /dev/null +++ b/packages/parser/src/language/treemap/module.ts @@ -0,0 +1,88 @@ +import type { + DefaultSharedCoreModuleContext, + LangiumCoreServices, + LangiumSharedCoreServices, + Module, + PartialLangiumCoreServices, +} from 'langium'; +import { + EmptyFileSystem, + createDefaultCoreModule, + createDefaultSharedCoreModule, + inject, +} from 'langium'; + +import { MermaidGeneratedSharedModule, TreemapGeneratedModule } from '../generated/module.js'; +import { TreemapTokenBuilder } from './tokenBuilder.js'; +import { TreemapValueConverter } from './valueConverter.js'; +import { TreemapValidator, registerValidationChecks } from './treemap-validator.js'; + +/** + * Declaration of `Treemap` services. + */ +interface TreemapAddedServices { + parser: { + TokenBuilder: TreemapTokenBuilder; + ValueConverter: TreemapValueConverter; + }; + validation: { + TreemapValidator: TreemapValidator; + }; +} + +/** + * Union of Langium default services and `Treemap` services. + */ +export type TreemapServices = LangiumCoreServices & TreemapAddedServices; + +/** + * Dependency injection module that overrides Langium default services and + * contributes the declared `Treemap` services. + */ +export const TreemapModule: Module< + TreemapServices, + PartialLangiumCoreServices & TreemapAddedServices +> = { + parser: { + TokenBuilder: () => new TreemapTokenBuilder(), + ValueConverter: () => new TreemapValueConverter(), + }, + validation: { + TreemapValidator: () => new TreemapValidator(), + }, +}; + +/** + * Create the full set of services required by Langium. + * + * First inject the shared services by merging two modules: + * - Langium default shared services + * - Services generated by langium-cli + * + * Then inject the language-specific services by merging three modules: + * - Langium default language-specific services + * - Services generated by langium-cli + * - Services specified in this file + * @param context - Optional module context with the LSP connection + * @returns An object wrapping the shared services and the language-specific services + */ +export function createTreemapServices(context: DefaultSharedCoreModuleContext = EmptyFileSystem): { + shared: LangiumSharedCoreServices; + Treemap: TreemapServices; +} { + const shared: LangiumSharedCoreServices = inject( + createDefaultSharedCoreModule(context), + MermaidGeneratedSharedModule + ); + const Treemap: TreemapServices = inject( + createDefaultCoreModule({ shared }), + TreemapGeneratedModule, + TreemapModule + ); + shared.ServiceRegistry.register(Treemap); + + // Register validation checks + registerValidationChecks(Treemap); + + return { shared, Treemap }; +} diff --git a/packages/parser/src/language/treemap/tokenBuilder.ts b/packages/parser/src/language/treemap/tokenBuilder.ts new file mode 100644 index 000000000..a51fa7810 --- /dev/null +++ b/packages/parser/src/language/treemap/tokenBuilder.ts @@ -0,0 +1,7 @@ +import { AbstractMermaidTokenBuilder } from '../common/index.js'; + +export class TreemapTokenBuilder extends AbstractMermaidTokenBuilder { + public constructor() { + super(['treemap']); + } +} diff --git a/packages/parser/src/language/treemap/treemap-validator.ts b/packages/parser/src/language/treemap/treemap-validator.ts new file mode 100644 index 000000000..898222753 --- /dev/null +++ b/packages/parser/src/language/treemap/treemap-validator.ts @@ -0,0 +1,77 @@ +import type { ValidationAcceptor, ValidationChecks } from 'langium'; +import type { MermaidAstType, TreemapDoc, TreemapRow } from '../generated/ast.js'; +import type { TreemapServices } from './module.js'; + +/** + * Register custom validation checks. + */ +export function registerValidationChecks(services: TreemapServices) { + const validator = services.validation.TreemapValidator; + const registry = services.validation.ValidationRegistry; + if (registry) { + // Use any to bypass type checking since we know TreemapDoc is part of the AST + // but the type system is having trouble with it + const checks: ValidationChecks = { + TreemapDoc: validator.checkSingleRoot.bind(validator), + TreemapRow: (node: TreemapRow, accept: ValidationAcceptor) => { + validator.checkSingleRootRow(node, accept); + }, + }; + registry.register(checks, validator); + } +} + +/** + * Implementation of custom validations. + */ +export class TreemapValidator { + constructor() { + // eslint-disable-next-line no-console + console.debug('TreemapValidator constructor'); + } + checkSingleRootRow(_node: TreemapRow, _accept: ValidationAcceptor): void { + // eslint-disable-next-line no-console + console.debug('CHECKING SINGLE ROOT Row'); + } + + /** + * Validates that a treemap has only one root node. + * A root node is defined as a node that has no indentation. + */ + checkSingleRoot(doc: TreemapDoc, accept: ValidationAcceptor): void { + // eslint-disable-next-line no-console + console.debug('CHECKING SINGLE ROOT'); + let rootNodeIndentation; + + for (const row of doc.TreemapRows) { + // Skip non-node items (e.g., class decorations, icon decorations) + if ( + !row.item || + row.item.$type === 'ClassDecoration' || + row.item.$type === 'IconDecoration' + ) { + continue; + } + if ( + rootNodeIndentation === undefined && // Check if this is a root node (no indentation) + row.indent === undefined + ) { + rootNodeIndentation = 0; + } else if (row.indent === undefined) { + // If we've already found a root node, report an error + accept('error', 'Multiple root nodes are not allowed in a treemap.', { + node: row, + property: 'item', + }); + } else if ( + rootNodeIndentation !== undefined && + rootNodeIndentation >= parseInt(row.indent, 10) + ) { + accept('error', 'Multiple root nodes are not allowed in a treemap.', { + node: row, + property: 'item', + }); + } + } + } +} diff --git a/packages/parser/src/language/treemap/treemap.langium b/packages/parser/src/language/treemap/treemap.langium new file mode 100644 index 000000000..95078368c --- /dev/null +++ b/packages/parser/src/language/treemap/treemap.langium @@ -0,0 +1,58 @@ +/** + * Treemap grammar for Langium + * Converted from mindmap grammar + * + * The ML_COMMENT and NL hidden terminals handle whitespace, comments, and newlines + * before the treemap keyword, allowing for empty lines and comments before the + * treemap declaration. + */ +grammar Treemap + +// Interface declarations for data types +interface Item {} +interface Section extends Item { + name: string +} +interface Leaf extends Item { + name: string + value: number +} + +entry TreemapDoc: + TREEMAP_KEYWORD + (TreemapRows+=TreemapRow)*; + +terminal SEPARATOR: ':'; +terminal COMMA: ','; + +hidden terminal WS: /[ \t]+/; // One or more spaces or tabs for hidden whitespace +hidden terminal ML_COMMENT: /\%\%[^\n]*/; +hidden terminal NL: /\r?\n/; + +TreemapRow: + indent=INDENTATION? item=Item; + +Item returns Item: + Leaf | Section; + +// Use a special rule order to handle the parsing precedence +Section returns Section: + name=STRING; + +Leaf returns Leaf: + name=STRING INDENTATION? (SEPARATOR | COMMA) INDENTATION? value=MyNumber; + +// This should be processed before whitespace is ignored +terminal INDENTATION: /[ \t]{1,}/; // One or more spaces/tabs for indentation + +// Keywords with fixed text patterns +terminal TREEMAP_KEYWORD: 'treemap'; + +// Define as a terminal rule +terminal NUMBER: /[0-9_\.\,]+/; + +// Then create a data type rule that uses it +MyNumber returns number: NUMBER; + +terminal STRING: /"[^"]*"|'[^']*'/; +// Modified indentation rule to have higher priority than WS diff --git a/packages/parser/src/language/treemap/valueConverter.ts b/packages/parser/src/language/treemap/valueConverter.ts new file mode 100644 index 000000000..54cededd2 --- /dev/null +++ b/packages/parser/src/language/treemap/valueConverter.ts @@ -0,0 +1,28 @@ +import type { CstNode, GrammarAST, ValueType } from 'langium'; +import { AbstractMermaidValueConverter } from '../common/index.js'; + +export class TreemapValueConverter extends AbstractMermaidValueConverter { + protected runCustomConverter( + rule: GrammarAST.AbstractRule, + input: string, + _cstNode: CstNode + ): ValueType | undefined { + if (rule.name === 'NUMBER') { + // console.debug('NUMBER', input); + // Convert to a number by removing any commas and converting to float + return parseFloat(input.replace(/,/g, '')); + } else if (rule.name === 'SEPARATOR') { + // console.debug('SEPARATOR', input); + // Remove quotes + return input.substring(1, input.length - 1); + } else if (rule.name === 'STRING') { + // console.debug('STRING', input); + // Remove quotes + return input.substring(1, input.length - 1); + } else if (rule.name === 'INDENTATION') { + // console.debug('INDENTATION', input); + return input.length; + } + return undefined; + } +} diff --git a/packages/parser/tests/test-util.ts b/packages/parser/tests/test-util.ts index 7a6050016..e6b563823 100644 --- a/packages/parser/tests/test-util.ts +++ b/packages/parser/tests/test-util.ts @@ -32,6 +32,12 @@ const consoleMock = vi.spyOn(console, 'log').mockImplementation(() => undefined) * @param result - the result `parse` function. */ export function expectNoErrorsOrAlternatives(result: ParseResult) { + if (result.lexerErrors.length > 0) { + // console.debug(result.lexerErrors); + } + if (result.parserErrors.length > 0) { + // console.debug(result.parserErrors); + } expect(result.lexerErrors).toHaveLength(0); expect(result.parserErrors).toHaveLength(0); diff --git a/packages/parser/tests/treemap.test.ts b/packages/parser/tests/treemap.test.ts new file mode 100644 index 000000000..bc9ca8408 --- /dev/null +++ b/packages/parser/tests/treemap.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest'; +import { expectNoErrorsOrAlternatives } from './test-util.js'; +import type { TreemapDoc, Section, Leaf } from '../src/language/generated/ast.js'; +import type { LangiumParser } from 'langium'; +import { createTreemapServices } from '../src/language/treemap/module.js'; + +describe('Treemap Parser', () => { + const services = createTreemapServices().Treemap; + const parser: LangiumParser = services.parser.LangiumParser; + + const parse = (input: string) => { + return parser.parse(input); + }; + + describe('Basic Parsing', () => { + it('should parse empty treemap', () => { + const result = parse('treemap'); + expectNoErrorsOrAlternatives(result); + expect(result.value.$type).toBe('TreemapDoc'); + expect(result.value.TreemapRows).toHaveLength(0); + }); + + it('should parse a section node', () => { + const result = parse('treemap\n"Root"'); + expectNoErrorsOrAlternatives(result); + expect(result.value.$type).toBe('TreemapDoc'); + expect(result.value.TreemapRows).toHaveLength(1); + if (result.value.TreemapRows[0].item) { + expect(result.value.TreemapRows[0].item.$type).toBe('Section'); + const section = result.value.TreemapRows[0].item as Section; + expect(section.name).toBe('Root'); + } + }); + + it('should parse a section with leaf nodes', () => { + const result = parse(`treemap +"Root" + "Child1" , 100 + "Child2" : 200 +`); + expectNoErrorsOrAlternatives(result); + expect(result.value.$type).toBe('TreemapDoc'); + expect(result.value.TreemapRows).toHaveLength(3); + + if (result.value.TreemapRows[0].item) { + expect(result.value.TreemapRows[0].item.$type).toBe('Section'); + const section = result.value.TreemapRows[0].item as Section; + expect(section.name).toBe('Root'); + } + + if (result.value.TreemapRows[1].item) { + expect(result.value.TreemapRows[1].item.$type).toBe('Leaf'); + const leaf = result.value.TreemapRows[1].item as Leaf; + expect(leaf.name).toBe('Child1'); + expect(leaf.value).toBe(100); + } + + if (result.value.TreemapRows[2].item) { + expect(result.value.TreemapRows[2].item.$type).toBe('Leaf'); + const leaf = result.value.TreemapRows[2].item as Leaf; + expect(leaf.name).toBe('Child2'); + expect(leaf.value).toBe(200); + } + }); + }); + + describe('Data Types', () => { + it('should correctly parse string values', () => { + const result = parse('treemap\n"My Section"'); + expectNoErrorsOrAlternatives(result); + if (result.value.TreemapRows[0].item) { + expect(result.value.TreemapRows[0].item.$type).toBe('Section'); + const section = result.value.TreemapRows[0].item as Section; + expect(section.name).toBe('My Section'); + } + }); + + it('should correctly parse number values', () => { + const result = parse('treemap\n"Item" : 123.45'); + expectNoErrorsOrAlternatives(result); + if (result.value.TreemapRows[0].item) { + expect(result.value.TreemapRows[0].item.$type).toBe('Leaf'); + const leaf = result.value.TreemapRows[0].item as Leaf; + expect(leaf.name).toBe('Item'); + expect(typeof leaf.value).toBe('number'); + expect(leaf.value).toBe(123.45); + } + }); + }); + + describe('Validation', () => { + it('should parse multiple root nodes', () => { + const result = parse('treemap\n"Root1"\n"Root2"'); + expect(result.parserErrors).toHaveLength(0); + + // We're only checking that the multiple root nodes parse successfully + // The validation errors would be reported by the validator during validation + expect(result.value.$type).toBe('TreemapDoc'); + expect(result.value.TreemapRows).toHaveLength(2); + }); + }); +});