diff --git a/packages/parser/langium-config.json b/packages/parser/langium-config.json index 4ffaaf372..999df51b0 100644 --- a/packages/parser/langium-config.json +++ b/packages/parser/langium-config.json @@ -5,6 +5,11 @@ "id": "info", "grammar": "src/language/info/info.langium", "fileExtensions": [".mmd", ".mermaid"] + }, + { + "id": "pie", + "grammar": "src/language/pie/pie.langium", + "fileExtensions": [".mmd", ".mermaid"] } ], "mode": "production", diff --git a/packages/parser/src/index.ts b/packages/parser/src/index.ts index 9dded54fa..149e7a21d 100644 --- a/packages/parser/src/index.ts +++ b/packages/parser/src/index.ts @@ -1,3 +1,3 @@ -export type { Info } from './language/index.js'; +export type { Info, Pie, PieSection } from './language/index.js'; export type { DiagramAST } from './parse.js'; export { parse, MermaidParseError } from './parse.js'; diff --git a/packages/parser/src/language/index.ts b/packages/parser/src/language/index.ts index b6685a07f..4af6091de 100644 --- a/packages/parser/src/language/index.ts +++ b/packages/parser/src/language/index.ts @@ -4,3 +4,4 @@ export * from './generated/module.js'; export * from './common/index.js'; export * from './info/index.js'; +export * from './pie/index.js'; diff --git a/packages/parser/src/language/pie/index.ts b/packages/parser/src/language/pie/index.ts new file mode 100644 index 000000000..aa111ca70 --- /dev/null +++ b/packages/parser/src/language/pie/index.ts @@ -0,0 +1 @@ +export * from './pieModule.js'; diff --git a/packages/parser/src/language/pie/pie.langium b/packages/parser/src/language/pie/pie.langium new file mode 100644 index 000000000..7c66b702b --- /dev/null +++ b/packages/parser/src/language/pie/pie.langium @@ -0,0 +1,20 @@ +grammar Pie +import "../common/common"; + +entry Pie: + NEWLINE* + "pie" showData?="showData"? + ( + NEWLINE* TitleAndAccessibilities sections+=PieSection* + | NEWLINE+ sections+=PieSection+ + | NEWLINE* + ) +; + +PieSection: + label=PIE_SECTION_LABEL ":" value=PIE_SECTION_VALUE + NEWLINE+ +; + +terminal PIE_SECTION_LABEL: /"[^"]+"/; +terminal PIE_SECTION_VALUE returns number: /(0|[1-9][0-9]*)(\.[0-9]+)?/; diff --git a/packages/parser/src/language/pie/pieModule.ts b/packages/parser/src/language/pie/pieModule.ts new file mode 100644 index 000000000..ba6f550cc --- /dev/null +++ b/packages/parser/src/language/pie/pieModule.ts @@ -0,0 +1,68 @@ +import type { + DefaultSharedModuleContext, + LangiumServices, + LangiumSharedServices, + Module, + PartialLangiumServices, +} from 'langium'; +import { EmptyFileSystem, createDefaultModule, createDefaultSharedModule, inject } from 'langium'; + +import { MermaidGeneratedSharedModule, PieGeneratedModule } from '../generated/module.js'; +import { CommonLexer } from '../common/commonLexer.js'; +import { PieTokenBuilder } from './pieTokenBuilder.js'; +import { PieValueConverter } from './pieValueConverter.js'; + +/** + * Declaration of `Pie` services. + */ +type PieAddedServices = { + parser: { + Lexer: CommonLexer; + TokenBuilder: PieTokenBuilder; + ValueConverter: PieValueConverter; + }; +}; + +/** + * Union of Langium default services and `Pie` services. + */ +export type PieServices = LangiumServices & PieAddedServices; + +/** + * Dependency injection module that overrides Langium default services and + * contributes the declared `Pie` services. + */ +const PieModule: Module = { + parser: { + Lexer: (services) => new CommonLexer(services), + TokenBuilder: () => new PieTokenBuilder(), + ValueConverter: () => new PieValueConverter(), + }, +}; + +/** + * 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 createPieServices(context: DefaultSharedModuleContext = EmptyFileSystem): { + shared: LangiumSharedServices; + Pie: PieServices; +} { + const shared: LangiumSharedServices = inject( + createDefaultSharedModule(context), + MermaidGeneratedSharedModule + ); + const Pie: PieServices = inject(createDefaultModule({ shared }), PieGeneratedModule, PieModule); + shared.ServiceRegistry.register(Pie); + return { shared, Pie }; +} diff --git a/packages/parser/src/language/pie/pieTokenBuilder.ts b/packages/parser/src/language/pie/pieTokenBuilder.ts new file mode 100644 index 000000000..2004ed50b --- /dev/null +++ b/packages/parser/src/language/pie/pieTokenBuilder.ts @@ -0,0 +1,23 @@ +import type { GrammarAST, Stream, TokenBuilderOptions } from 'langium'; +import { DefaultTokenBuilder } from 'langium'; + +import type { TokenType } from '../chevrotainWrapper.js'; + +export class PieTokenBuilder extends DefaultTokenBuilder { + protected override buildKeywordTokens( + rules: Stream, + terminalTokens: TokenType[], + options?: TokenBuilderOptions + ): TokenType[] { + const tokenTypes: TokenType[] = super.buildKeywordTokens(rules, terminalTokens, options); + tokenTypes.forEach((tokenType: TokenType): void => { + if ( + (tokenType.name === 'pie' || tokenType.name === 'showData') && + tokenType.PATTERN !== undefined + ) { + tokenType.PATTERN = new RegExp(tokenType.PATTERN.toString() + '(?!\\S)'); + } + }); + return tokenTypes; + } +} diff --git a/packages/parser/src/language/pie/pieValueConverter.ts b/packages/parser/src/language/pie/pieValueConverter.ts new file mode 100644 index 000000000..aa322cadd --- /dev/null +++ b/packages/parser/src/language/pie/pieValueConverter.ts @@ -0,0 +1,50 @@ +import type { CstNode, GrammarAST, ValueType } from 'langium'; +import { DefaultValueConverter } from 'langium'; + +import { CommonValueConverter } from '../common/commonValueConverters.js'; + +export class PieValueConverter extends DefaultValueConverter { + protected override runConverter( + rule: GrammarAST.AbstractRule, + input: string, + cstNode: CstNode + ): ValueType { + let value: ValueType | undefined = CommonValueConverter.customRunConverter( + rule, + input, + cstNode + ); + if (value === undefined) { + value = PieValueConverter.customRunConverter(rule, input, cstNode); + } + + if (value === undefined) { + return super.runConverter(rule, input, cstNode); + } else { + return value; + } + } + + /** + * A method contains convert logic to be used by class itself or `MermaidValueConverter`. + * + * @param rule - Parsed rule. + * @param input - Matched string. + * @param _cstNode - Node in the Concrete Syntax Tree (CST). + * @returns converted the value if it's pie rule or `null` if it's not. + */ + public static customRunConverter( + rule: GrammarAST.AbstractRule, + input: string, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _cstNode: CstNode + ): ValueType | undefined { + if (rule.name === 'PIE_SECTION_LABEL') { + return input + .replace(/"/g, '') + .trim() + .replaceAll(/[\t ]{2,}/gm, ' '); + } + return undefined; + } +} diff --git a/packages/parser/src/parse.ts b/packages/parser/src/parse.ts index 90358bbf1..6eadb69ab 100644 --- a/packages/parser/src/parse.ts +++ b/packages/parser/src/parse.ts @@ -1,8 +1,8 @@ import type { LangiumParser, ParseResult } from 'langium'; -import type { Info } from './index.js'; -import { createInfoServices } from './language/index.js'; +import type { Info, Pie } from './index.js'; +import { createInfoServices, createPieServices } from './language/index.js'; -export type DiagramAST = Info; +export type DiagramAST = Info | Pie; const parsers: Record = {}; @@ -13,8 +13,14 @@ const initializers = { const parser = createInfoServices().Info.parser.LangiumParser; parsers['info'] = parser; }, + pie: () => { + const parser = createPieServices().Pie.parser.LangiumParser; + parsers['pie'] = parser; + }, } as const; + export function parse(diagramType: 'info', text: string): Info; +export function parse(diagramType: 'pie', text: string): Pie; export function parse( diagramType: keyof typeof initializers, text: string diff --git a/packages/parser/tests/pie.test.ts b/packages/parser/tests/pie.test.ts new file mode 100644 index 000000000..27f73a627 --- /dev/null +++ b/packages/parser/tests/pie.test.ts @@ -0,0 +1,294 @@ +import { describe, expect, it } from 'vitest'; +import type { LangiumParser, ParseResult } from 'langium'; + +import type { PieServices } from '../src/language/index.js'; +import { Pie, createPieServices } from '../src/language/index.js'; + +const services: PieServices = createPieServices().Pie; +const parser: LangiumParser = services.parser.LangiumParser; +export function createPieTestServices(): { + services: PieServices; + parse: (input: string) => ParseResult; +} { + const parse = (input: string) => { + return parser.parse(input); + }; + + return { services, parse }; +} + +describe('pie', () => { + const { parse } = createPieTestServices(); + + it.each([ + `pie`, + ` pie `, + `\tpie\t`, + ` + \tpie + `, + ])('should handle regular pie', (context: string) => { + const result = parse(context); + expect(result.parserErrors).toHaveLength(0); + expect(result.lexerErrors).toHaveLength(0); + + const value = result.value; + expect(value.$type).toBe(Pie); + }); + + it.each([ + `pie showData`, + ` pie showData `, + `\tpie\tshowData\t`, + ` + pie\tshowData + `, + ])('should handle regular showData', (context: string) => { + const result = parse(context); + expect(result.parserErrors).toHaveLength(0); + expect(result.lexerErrors).toHaveLength(0); + + const value = result.value; + expect(value.$type).toBe(Pie); + expect(value.showData).toBeTruthy(); + }); + + it.each([ + `pie title sample title`, + ` pie title sample title `, + `\tpie\ttitle sample title\t`, + `pie + \ttitle sample title + `, + ])('should handle regular pie + title in same line', (context: string) => { + const result = parse(context); + expect(result.parserErrors).toHaveLength(0); + expect(result.lexerErrors).toHaveLength(0); + + const value = result.value; + expect(value.$type).toBe(Pie); + expect(value.title).toBe('sample title'); + }); + + it.each([ + `pie + title sample title`, + `pie + title sample title + `, + `pie + title sample title`, + `pie + title sample title + `, + ])('should handle regular pie + title in different line', (context: string) => { + const result = parse(context); + expect(result.parserErrors).toHaveLength(0); + expect(result.lexerErrors).toHaveLength(0); + + const value = result.value; + expect(value.$type).toBe(Pie); + expect(value.title).toBe('sample title'); + }); + + it.each([ + `pie showData title sample title`, + `pie showData title sample title + `, + ])('should handle regular pie + showData + title', (context: string) => { + const result = parse(context); + expect(result.parserErrors).toHaveLength(0); + expect(result.lexerErrors).toHaveLength(0); + + const value = result.value; + expect(value.$type).toBe(Pie); + expect(value.showData).toBeTruthy(); + expect(value.title).toBe('sample title'); + }); + + it.each([ + `pie showData + title sample title`, + `pie showData + title sample title + `, + `pie showData + title sample title`, + `pie showData + title sample title + `, + ])('should handle regular showData + title in different line', (context: string) => { + const result = parse(context); + expect(result.parserErrors).toHaveLength(0); + expect(result.lexerErrors).toHaveLength(0); + + const value = result.value; + expect(value.$type).toBe(Pie); + expect(value.showData).toBeTruthy(); + expect(value.title).toBe('sample title'); + }); + + describe('sections', () => { + describe('normal', () => { + it.each([ + `pie + "GitHub":100 + "GitLab":50`, + `pie + "GitHub" : 100 + "GitLab" : 50`, + `pie + "GitHub"\t:\t100 + "GitLab"\t:\t50`, + `pie + \t"GitHub" \t : \t 100 + \t"GitLab" \t : \t 50 + `, + ])('should handle regular secions', (context: string) => { + const result = parse(context); + expect(result.parserErrors).toHaveLength(0); + expect(result.lexerErrors).toHaveLength(0); + + const value = result.value; + expect(value.$type).toBe(Pie); + + const section0 = value.sections[0]; + expect(section0?.label).toBe('GitHub'); + expect(section0?.value).toBe(100); + + const section1 = value.sections[1]; + expect(section1?.label).toBe('GitLab'); + expect(section1?.value).toBe(50); + }); + + it('should handle sections with showData', () => { + const context = `pie showData + "GitHub": 100 + "GitLab": 50`; + const result = parse(context); + expect(result.parserErrors).toHaveLength(0); + expect(result.lexerErrors).toHaveLength(0); + + const value = result.value; + expect(value.$type).toBe(Pie); + expect(value.showData).toBeTruthy(); + + const section0 = value.sections[0]; + expect(section0?.label).toBe('GitHub'); + expect(section0?.value).toBe(100); + + const section1 = value.sections[1]; + expect(section1?.label).toBe('GitLab'); + expect(section1?.value).toBe(50); + }); + + it('should handle sections with title', () => { + const context = `pie title sample wow + "GitHub": 100 + "GitLab": 50`; + const result = parse(context); + expect(result.parserErrors).toHaveLength(0); + expect(result.lexerErrors).toHaveLength(0); + + const value = result.value; + expect(value.$type).toBe(Pie); + expect(value.title).toBe('sample wow'); + + const section0 = value.sections[0]; + expect(section0?.label).toBe('GitHub'); + expect(section0?.value).toBe(100); + + const section1 = value.sections[1]; + expect(section1?.label).toBe('GitLab'); + expect(section1?.value).toBe(50); + }); + + it('should handle sections with accTitle', () => { + const context = `pie accTitle: sample wow + "GitHub": 100 + "GitLab": 50`; + const result = parse(context); + expect(result.parserErrors).toHaveLength(0); + expect(result.lexerErrors).toHaveLength(0); + + const value = result.value; + expect(value.$type).toBe(Pie); + expect(value.accTitle).toBe('sample wow'); + + const section0 = value.sections[0]; + expect(section0?.label).toBe('GitHub'); + expect(section0?.value).toBe(100); + + const section1 = value.sections[1]; + expect(section1?.label).toBe('GitLab'); + expect(section1?.value).toBe(50); + }); + + it('should handle sections with single line accDescr', () => { + const context = `pie accDescr: sample wow + "GitHub": 100 + "GitLab": 50`; + const result = parse(context); + expect(result.parserErrors).toHaveLength(0); + expect(result.lexerErrors).toHaveLength(0); + + const value = result.value; + expect(value.$type).toBe(Pie); + expect(value.accDescr).toBe('sample wow'); + + const section0 = value.sections[0]; + expect(section0?.label).toBe('GitHub'); + expect(section0?.value).toBe(100); + + const section1 = value.sections[1]; + expect(section1?.label).toBe('GitLab'); + expect(section1?.value).toBe(50); + }); + + it('should handle sections with multi line accDescr', () => { + const context = `pie accDescr { + sample wow + } + "GitHub": 100 + "GitLab": 50`; + const result = parse(context); + expect(result.parserErrors).toHaveLength(0); + expect(result.lexerErrors).toHaveLength(0); + + const value = result.value; + expect(value.$type).toBe(Pie); + expect(value.accDescr).toBe('sample wow'); + + const section0 = value.sections[0]; + expect(section0?.label).toBe('GitHub'); + expect(section0?.value).toBe(100); + + const section1 = value.sections[1]; + expect(section1?.label).toBe('GitLab'); + expect(section1?.value).toBe(50); + }); + }); + + describe('duplicate', () => { + it('should handle duplicate sections', () => { + const context = `pie + "GitHub": 100 + "GitHub": 50`; + const result = parse(context); + expect(result.parserErrors).toHaveLength(0); + expect(result.lexerErrors).toHaveLength(0); + + const value = result.value; + expect(value.$type).toBe(Pie); + + const section0 = value.sections[0]; + expect(section0?.label).toBe('GitHub'); + expect(section0?.value).toBe(100); + + const section1 = value.sections[1]; + expect(section1?.label).toBe('GitHub'); + expect(section1?.value).toBe(50); + }); + }); + }); +});