diff --git a/packages/parser/src/index.ts b/packages/parser/src/index.ts index e69de29bb..9dded54fa 100644 --- a/packages/parser/src/index.ts +++ b/packages/parser/src/index.ts @@ -0,0 +1,3 @@ +export type { Info } 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 c72ed64e5..b6685a07f 100644 --- a/packages/parser/src/language/index.ts +++ b/packages/parser/src/language/index.ts @@ -3,3 +3,4 @@ export * from './generated/grammar.js'; export * from './generated/module.js'; export * from './common/index.js'; +export * from './info/index.js'; diff --git a/packages/parser/src/language/info/index.ts b/packages/parser/src/language/info/index.ts new file mode 100644 index 000000000..197952c4d --- /dev/null +++ b/packages/parser/src/language/info/index.ts @@ -0,0 +1 @@ +export * from './infoModule.js'; diff --git a/packages/parser/src/language/info/info.langium b/packages/parser/src/language/info/info.langium new file mode 100644 index 000000000..8669841d1 --- /dev/null +++ b/packages/parser/src/language/info/info.langium @@ -0,0 +1,9 @@ +grammar Info +import "../common/common"; + +entry Info: + NEWLINE* + "info" NEWLINE* + ("showInfo" NEWLINE*)? + TitleAndAccessibilities? +; diff --git a/packages/parser/src/language/info/infoModule.ts b/packages/parser/src/language/info/infoModule.ts new file mode 100644 index 000000000..64e5d33c8 --- /dev/null +++ b/packages/parser/src/language/info/infoModule.ts @@ -0,0 +1,72 @@ +import type { + DefaultSharedModuleContext, + LangiumServices, + LangiumSharedServices, + Module, + PartialLangiumServices, +} from 'langium'; +import { EmptyFileSystem, createDefaultModule, createDefaultSharedModule, inject } from 'langium'; + +import { MermaidGeneratedSharedModule, InfoGeneratedModule } from '../generated/module.js'; +import { CommonLexer } from '../common/commonLexer.js'; +import { CommonValueConverter } from '../common/commonValueConverters.js'; +import { InfoTokenBuilder } from './infoTokenBuilder.js'; + +/** + * Declaration of `Info` services. + */ +type InfoAddedServices = { + parser: { + Lexer: CommonLexer; + TokenBuilder: InfoTokenBuilder; + ValueConverter: CommonValueConverter; + }; +}; + +/** + * Union of Langium default services and `Info` services. + */ +export type InfoServices = LangiumServices & InfoAddedServices; + +/** + * Dependency injection module that overrides Langium default services and + * contributes the declared `Info` services. + */ +const InfoModule: Module = { + parser: { + Lexer: (services) => new CommonLexer(services), + TokenBuilder: () => new InfoTokenBuilder(), + ValueConverter: () => new CommonValueConverter(), + }, +}; + +/** + * 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 createInfoServices(context: DefaultSharedModuleContext = EmptyFileSystem): { + shared: LangiumSharedServices; + Info: InfoServices; +} { + const shared: LangiumSharedServices = inject( + createDefaultSharedModule(context), + MermaidGeneratedSharedModule + ); + const Info: InfoServices = inject( + createDefaultModule({ shared }), + InfoGeneratedModule, + InfoModule + ); + shared.ServiceRegistry.register(Info); + return { shared, Info }; +} diff --git a/packages/parser/src/language/info/infoTokenBuilder.ts b/packages/parser/src/language/info/infoTokenBuilder.ts new file mode 100644 index 000000000..b71fa30ad --- /dev/null +++ b/packages/parser/src/language/info/infoTokenBuilder.ts @@ -0,0 +1,24 @@ +import type { GrammarAST, Stream, TokenBuilderOptions } from 'langium'; +import { DefaultTokenBuilder } from 'langium'; + +import type { TokenType } from '../chevrotainWrapper.js'; + +export class InfoTokenBuilder extends DefaultTokenBuilder { + protected override buildKeywordTokens( + rules: Stream, + terminalTokens: TokenType[], + options?: TokenBuilderOptions + ): TokenType[] { + const tokenTypes: TokenType[] = super.buildKeywordTokens(rules, terminalTokens, options); + // to restrict users, they mustn't have any non-whitespace characters after the keyword. + tokenTypes.forEach((tokenType: TokenType): void => { + if ( + (tokenType.name === 'info' || tokenType.name === 'showInfo') && + tokenType.PATTERN !== undefined + ) { + tokenType.PATTERN = new RegExp(tokenType.PATTERN.toString() + '(?!\\S)'); + } + }); + return tokenTypes; + } +} diff --git a/packages/parser/src/parse.ts b/packages/parser/src/parse.ts new file mode 100644 index 000000000..90358bbf1 --- /dev/null +++ b/packages/parser/src/parse.ts @@ -0,0 +1,43 @@ +import type { LangiumParser, ParseResult } from 'langium'; +import type { Info } from './index.js'; +import { createInfoServices } from './language/index.js'; + +export type DiagramAST = Info; + +const parsers: Record = {}; + +const initializers = { + info: () => { + // Will have to make parse async to use this. Can try later... + // const { createInfoServices } = await import('./language/info/index.js'); + const parser = createInfoServices().Info.parser.LangiumParser; + parsers['info'] = parser; + }, +} as const; +export function parse(diagramType: 'info', text: string): Info; +export function parse( + diagramType: keyof typeof initializers, + text: string +): T { + const initializer = initializers[diagramType]; + if (!initializer) { + throw new Error(`Unknown diagram type: ${diagramType}`); + } + if (!parsers[diagramType]) { + initializer(); + } + const parser: LangiumParser = parsers[diagramType]; + const result: ParseResult = parser.parse(text); + if (result.lexerErrors.length > 0 || result.parserErrors.length > 0) { + throw new MermaidParseError(result); + } + return result.value; +} + +export class MermaidParseError extends Error { + constructor(public result: ParseResult) { + const lexerErrors: string = result.lexerErrors.map((err) => err.message).join('\n'); + const parserErrors: string = result.parserErrors.map((err) => err.message).join('\n'); + super(`Parsing failed: ${lexerErrors} ${parserErrors}`); + } +} diff --git a/packages/parser/tests/info.test.ts b/packages/parser/tests/info.test.ts new file mode 100644 index 000000000..679c46793 --- /dev/null +++ b/packages/parser/tests/info.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; +import type { LangiumParser, ParseResult } from 'langium'; + +import type { InfoServices } from '../src/language/index.js'; +import { Info, createInfoServices } from '../src/language/index.js'; + +const services: InfoServices = createInfoServices().Info; +const parser: LangiumParser = services.parser.LangiumParser; +export function createInfoTestServices(): { + services: InfoServices; + parse: (input: string) => ParseResult; +} { + const parse = (input: string) => { + return parser.parse(input); + }; + + return { services, parse }; +} + +describe('info', () => { + const { parse } = createInfoTestServices(); + + it.each([ + `info`, + ` + info`, + `info + `, + ` + info + `, + ])('should handle empty info', (context: string) => { + const { parserErrors, lexerErrors, value } = parse(context); + expect(parserErrors).toHaveLength(0); + expect(lexerErrors).toHaveLength(0); + + expect(value.$type).toBe(Info); + }); + + it.each([ + `info showInfo`, + ` + info showInfo`, + `info + showInfo + `, + ` + info + showInfo + `, + ])('should handle showInfo', (context: string) => { + const { parserErrors, lexerErrors, value } = parse(context); + expect(parserErrors).toHaveLength(0); + expect(lexerErrors).toHaveLength(0); + + expect(value.$type).toBe(Info); + }); +});