Merge pull request #4727 from Yokozuna59/add-info-langium-parser

feat: add `@mermaid-js/parser` package and `info` langium parser
This commit is contained in:
Sidharth Vinod 2023-08-28 08:10:30 +00:00 committed by GitHub
commit 44b93c039a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
50 changed files with 4194 additions and 3145 deletions

View File

@ -2,6 +2,11 @@
* Shared common options for both ESBuild and Vite
*/
export const packageOptions = {
parser: {
name: 'mermaid-parser',
packageName: 'parser',
file: 'index.ts',
},
mermaid: {
name: 'mermaid',
packageName: 'mermaid',

View File

@ -0,0 +1,5 @@
import { generate } from 'langium-cli';
export async function generateLangium() {
await generate({ file: `./packages/parser/langium-config.json` });
}

9
.build/langium-cli.d.ts vendored Normal file
View File

@ -0,0 +1,9 @@
declare module 'langium-cli' {
export interface GenerateOptions {
file?: string;
mode?: 'development' | 'production';
watch?: boolean;
}
export function generate(options: GenerateOptions): Promise<boolean>;
}

View File

@ -2,6 +2,7 @@ import { build } from 'esbuild';
import { mkdir, writeFile } from 'node:fs/promises';
import { MermaidBuildOptions, defaultOptions, getBuildConfig } from './util.js';
import { packageOptions } from '../.build/common.js';
import { generateLangium } from '../.build/generateLangium.js';
const shouldVisualize = process.argv.includes('--visualize');
@ -52,9 +53,13 @@ const handler = (e) => {
};
const main = async () => {
await generateLangium();
await mkdir('stats').catch(() => {});
const packageNames = Object.keys(packageOptions) as (keyof typeof packageOptions)[];
await Promise.allSettled(packageNames.map((pkg) => buildPackage(pkg).catch(handler)));
// it should build `parser` before `mermaid` because it's a dependecy
for (const pkg of packageNames) {
await buildPackage(pkg).catch(handler);
}
};
void main();

View File

@ -4,7 +4,11 @@ import cors from 'cors';
import { getBuildConfig, defaultOptions } from './util.js';
import { context } from 'esbuild';
import chokidar from 'chokidar';
import { generateLangium } from '../.build/generateLangium.js';
const parserCtx = await context(
getBuildConfig({ ...defaultOptions, minify: false, core: false, entryName: 'parser' })
);
const mermaidCtx = await context(
getBuildConfig({ ...defaultOptions, minify: false, core: false, entryName: 'mermaid' })
);
@ -28,7 +32,7 @@ const externalCtx = await context(
const zenumlCtx = await context(
getBuildConfig({ ...defaultOptions, minify: false, core: false, entryName: 'mermaid-zenuml' })
);
const contexts = [mermaidCtx, mermaidIIFECtx, externalCtx, zenumlCtx];
const contexts = [parserCtx, mermaidCtx, mermaidIIFECtx, externalCtx, zenumlCtx];
const rebuildAll = async () => {
console.time('Rebuild time');
@ -75,10 +79,11 @@ function sendEventsToAll() {
}
async function createServer() {
await generateLangium();
handleFileChange();
const app = express();
chokidar
.watch('**/src/**/*.{js,ts,yaml,json}', {
.watch('**/src/**/*.{js,ts,langium,yaml,json}', {
ignoreInitial: true,
ignored: [/node_modules/, /dist/, /docs/, /coverage/],
})
@ -87,12 +92,16 @@ async function createServer() {
if (!['add', 'change'].includes(event)) {
return;
}
if (/\.langium$/.test(path)) {
await generateLangium();
}
console.log(`${path} changed. Rebuilding...`);
handleFileChange();
});
app.use(cors());
app.get('/events', eventsHandler);
app.use(express.static('./packages/parser/dist'));
app.use(express.static('./packages/mermaid/dist'));
app.use(express.static('./packages/mermaid-zenuml/dist'));
app.use(express.static('./packages/mermaid-example-diagram/dist'));

View File

@ -6,3 +6,6 @@ cypress/plugins/index.js
coverage
*.json
node_modules
# autogenereated by langium-cli
generated/

3
.gitignore vendored
View File

@ -47,3 +47,6 @@ stats/
demos/dev/**
!/demos/dev/example.html
!/demos/dev/reload.js
# autogenereated by langium-cli
generated/

View File

@ -10,3 +10,6 @@ stats
.nyc_output
# Autogenerated by `pnpm run --filter mermaid types:build-config`
packages/mermaid/src/config.type.ts
# autogenereated by langium-cli
generated/

View File

@ -8,6 +8,7 @@ import { visualizer } from 'rollup-plugin-visualizer';
import type { TemplateType } from 'rollup-plugin-visualizer/dist/plugin/template-types.js';
import istanbul from 'vite-plugin-istanbul';
import { packageOptions } from '../.build/common.js';
import { generateLangium } from '../.build/generateLangium.js';
const visualize = process.argv.includes('--visualize');
const watch = process.argv.includes('--watch');
@ -82,7 +83,7 @@ export const getBuildConfig = ({ minify, core, watch, entryName }: BuildOptions)
// @ts-expect-error According to the type definitions, rollup plugins are incompatible with vite
typescript({ compilerOptions: { declaration: false } }),
istanbul({
exclude: ['node_modules', 'test/', '__mocks__'],
exclude: ['node_modules', 'test/', '__mocks__', 'generated'],
extension: ['.js', '.ts'],
requireEnv: true,
forceBuildInstrument: coverage,
@ -106,18 +107,24 @@ const buildPackage = async (entryName: keyof typeof packageOptions) => {
const main = async () => {
const packageNames = Object.keys(packageOptions) as (keyof typeof packageOptions)[];
for (const pkg of packageNames.filter((pkg) => !mermaidOnly || pkg === 'mermaid')) {
for (const pkg of packageNames.filter(
(pkg) => !mermaidOnly || pkg === 'mermaid' || pkg === 'parser'
)) {
await buildPackage(pkg);
}
};
await generateLangium();
if (watch) {
await build(getBuildConfig({ minify: false, watch, core: false, entryName: 'parser' }));
build(getBuildConfig({ minify: false, watch, core: false, entryName: 'mermaid' }));
if (!mermaidOnly) {
build(getBuildConfig({ minify: false, watch, entryName: 'mermaid-example-diagram' }));
build(getBuildConfig({ minify: false, watch, entryName: 'mermaid-zenuml' }));
}
} else if (visualize) {
await build(getBuildConfig({ minify: false, watch, core: false, entryName: 'parser' }));
await build(getBuildConfig({ minify: false, core: true, entryName: 'mermaid' }));
await build(getBuildConfig({ minify: false, core: false, entryName: 'mermaid' }));
} else {

View File

@ -14,6 +14,7 @@ async function createServer() {
});
app.use(cors());
app.use(express.static('./packages/parser/dist'));
app.use(express.static('./packages/mermaid/dist'));
app.use(express.static('./packages/mermaid-zenuml/dist'));
app.use(express.static('./packages/mermaid-example-diagram/dist'));

View File

@ -71,6 +71,7 @@
"knut",
"knutsveidqvist",
"laganeckas",
"langium",
"linetype",
"lintstagedrc",
"logmsg",

View File

@ -15,11 +15,11 @@
"git graph"
],
"scripts": {
"build": "pnpm run -r clean && pnpm build:esbuild && pnpm build:types",
"build": "pnpm build:esbuild && pnpm build:types",
"build:esbuild": "pnpm run -r clean && ts-node-esm --transpileOnly .esbuild/build.ts",
"build:mermaid": "pnpm build:esbuild --mermaid",
"build:viz": "pnpm build:esbuild --visualize",
"build:types": "tsc -p ./packages/mermaid/tsconfig.json --emitDeclarationOnly && tsc -p ./packages/mermaid-zenuml/tsconfig.json --emitDeclarationOnly && tsc -p ./packages/mermaid-example-diagram/tsconfig.json --emitDeclarationOnly",
"build:types": "tsc -p ./packages/parser/tsconfig.json --emitDeclarationOnly && tsc -p ./packages/mermaid/tsconfig.json --emitDeclarationOnly && tsc -p ./packages/mermaid-zenuml/tsconfig.json --emitDeclarationOnly && tsc -p ./packages/mermaid-example-diagram/tsconfig.json --emitDeclarationOnly",
"dev": "ts-node-esm --transpileOnly .esbuild/server.ts",
"dev:vite": "ts-node-esm --transpileOnly .vite/server.ts",
"dev:coverage": "pnpm coverage:cypress:clean && VITE_COVERAGE=true pnpm dev:vite",
@ -107,6 +107,7 @@
"jison": "^0.4.18",
"js-yaml": "^4.1.0",
"jsdom": "^22.0.0",
"langium-cli": "2.0.1",
"lint-staged": "^13.2.1",
"nyc": "^15.1.0",
"path-browserify": "^1.0.1",

View File

@ -5,7 +5,6 @@
* This is a dummy parser that satisfies the mermaid API logic.
*/
export default {
parser: { yy: {} },
parse: () => {
// no op
},

View File

@ -72,6 +72,7 @@
"khroma": "^2.0.0",
"lodash-es": "^4.17.21",
"mdast-util-from-markdown": "^1.3.0",
"mermaid-parser": "workspace:^",
"stylis": "^4.1.3",
"ts-dedent": "^2.2.0",
"uuid": "^9.0.0"

View File

@ -50,7 +50,10 @@ export class Diagram {
this.parser.parse = (text: string) =>
originalParse(cleanupComments(extractFrontMatter(text, this.db, configApi.addDirective)));
this.parser.parser.yy = this.db;
if (this.parser.parser !== undefined) {
// The parser.parser.yy is only present in JISON parsers. So, we'll only set if required.
this.parser.parser.yy = this.db;
}
this.init = diagram.init;
this.parse();
}

View File

@ -45,7 +45,6 @@ export const addDiagrams = () => {
styles: {}, // should never be used
renderer: {}, // should never be used
parser: {
parser: { yy: {} },
parse: () => {
throw new Error(
'Diagrams beginning with --- are not valid. ' +

View File

@ -39,7 +39,6 @@ describe('DiagramAPI', () => {
parse: (_text) => {
return;
},
parser: { yy: {} },
},
renderer: {},
styles: {},

View File

@ -80,7 +80,7 @@ export type DrawDefinition = (
export interface ParserDefinition {
parse: (text: string) => void;
parser: { yy: DiagramDB };
parser?: { yy: DiagramDB };
}
/**

View File

@ -30,9 +30,6 @@ describe('diagram detection', () => {
parse: () => {
// no-op
},
parser: {
yy: {},
},
},
renderer: {},
styles: {},

View File

@ -0,0 +1,15 @@
import type { DiagramAST } from 'mermaid-parser';
import type { DiagramDB } from '../../diagram-api/types.js';
export function populateCommonDb(ast: DiagramAST, db: DiagramDB) {
if (ast.accDescr) {
db.setAccDescription?.(ast.accDescr);
}
if (ast.accTitle) {
db.setAccTitle?.(ast.accTitle);
}
if (ast.title) {
db.setDiagramTitle?.(ast.title);
}
}

View File

@ -5,7 +5,6 @@ const diagram: DiagramDefinition = {
db: {},
renderer,
parser: {
parser: { yy: {} },
parse: (): void => {
return;
},

View File

@ -1,24 +1,31 @@
// @ts-ignore - jison doesn't export types
import { parser } from './parser/info.jison';
import { db } from './infoDb.js';
describe('info diagram', () => {
beforeEach(() => {
parser.yy = db;
parser.yy.clear();
});
import { parser } from './infoParser.js';
describe('info', () => {
it('should handle an info definition', () => {
const str = `info`;
parser.parse(str);
expect(db.getInfo()).toBeFalsy();
expect(() => {
parser.parse(str);
}).not.toThrow();
});
it('should handle an info definition with showInfo', () => {
const str = `info showInfo`;
parser.parse(str);
expect(() => {
parser.parse(str);
}).not.toThrow();
});
expect(db.getInfo()).toBeTruthy();
it('should throw because of unsupported info grammar', () => {
const str = `info unsupported`;
expect(() => {
parser.parse(str);
}).toThrow('Parsing failed: unexpected character: ->u<- at offset: 5, skipped 11 characters.');
});
it('should throw because of unsupported info grammar', () => {
const str = `info unsupported`;
expect(() => {
parser.parse(str);
}).toThrow('Parsing failed: unexpected character: ->u<- at offset: 5, skipped 11 characters.');
});
});

View File

@ -1,23 +1,10 @@
import type { InfoFields, InfoDB } from './infoTypes.js';
import { version } from '../../../package.json';
export const DEFAULT_INFO_DB: InfoFields = {
info: false,
} as const;
export const DEFAULT_INFO_DB: InfoFields = { version } as const;
let info: boolean = DEFAULT_INFO_DB.info;
export const setInfo = (toggle: boolean): void => {
info = toggle;
};
export const getInfo = (): boolean => info;
const clear = (): void => {
info = DEFAULT_INFO_DB.info;
};
export const getVersion = (): string => DEFAULT_INFO_DB.version;
export const db: InfoDB = {
clear,
setInfo,
getInfo,
getVersion,
};

View File

@ -1,6 +1,5 @@
import type { DiagramDefinition } from '../../diagram-api/types.js';
// @ts-ignore - jison doesn't export types
import parser from './parser/info.jison';
import { parser } from './infoParser.js';
import { db } from './infoDb.js';
import { renderer } from './infoRenderer.js';

View File

@ -0,0 +1,12 @@
import type { Info } from 'mermaid-parser';
import { parse } from 'mermaid-parser';
import { log } from '../../logger.js';
import type { ParserDefinition } from '../../diagram-api/types.js';
export const parser: ParserDefinition = {
parse: (input: string): void => {
const ast: Info = parse('info', input);
log.debug(ast);
},
};

View File

@ -1,11 +1,9 @@
import type { DiagramDB } from '../../diagram-api/types.js';
export interface InfoFields {
info: boolean;
version: string;
}
export interface InfoDB extends DiagramDB {
clear: () => void;
setInfo: (info: boolean) => void;
getInfo: () => boolean;
getVersion: () => string;
}

View File

@ -1,48 +0,0 @@
/** mermaid
* https://knsv.github.io/mermaid
* (c) 2015 Knut Sveidqvist
* MIT license.
*/
%lex
%options case-insensitive
%{
// Pre-lexer code can go here
%}
%%
"info" return 'info' ;
[\s\n\r]+ return 'NL' ;
[\s]+ return 'space';
"showInfo" return 'showInfo';
<<EOF>> return 'EOF' ;
. return 'TXT' ;
/lex
%start start
%% /* language grammar */
start
// %{ : info document 'EOF' { return yy; } }
: info document 'EOF' { return yy; }
;
document
: /* empty */
| document line
;
line
: statement { }
| 'NL'
;
statement
: showInfo { yy.setInfo(true); }
;
%%

View File

@ -102,7 +102,6 @@ describe('when using mermaid and ', () => {
parse: (_text) => {
return;
},
parser: { yy: {} },
},
styles: () => {
// do nothing

21
packages/parser/LICENSE Normal file
View File

@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2023 Yokozuna59
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

63
packages/parser/README.md Normal file
View File

@ -0,0 +1,63 @@
<p align="center">
<img src="https://raw.githubusercontent.com/mermaid-js/mermaid/develop/docs/public/favicon.svg" height="150">
</p>
<h1 align="center">
Mermaid Parser
</h1>
<p align="center">
Mermaid parser package
<p>
[![NPM](https://img.shields.io/npm/v/mermaid-parser)](https://www.npmjs.com/package/mermaid-parser)
## How the package works
The package exports a `parse` function that has two parameters:
```ts
declare function parse<T extends DiagramAST>(
diagramType: keyof typeof initializers,
text: string
): T;
```
## How does a Langium-based parser work?
```mermaid
sequenceDiagram
actor Package
participant Module
participant TokenBuilder
participant Lexer
participant Parser
participant ValueConverter
Package ->> Module: Create services
Module ->> TokenBuilder: Override or/and<br>reorder rules
TokenBuilder ->> Lexer: Read the string and transform<br>it into a token stream
Lexer ->> Parser: Parse token<br>stream into AST
Parser ->> ValueConverter: Clean/modify tokenized<br>rules returned value
ValueConverter -->> Package: Return AST
```
- When to override `TokenBuilder`?
- To override keyword rules.
- To override terminal rules that need a custom function.
- To manually reorder the list of rules.
- When to override `Lexer`?
- To modify input before tokenizing.
- To insert/modify tokens that cannot or have not been parsed.
- When to override `LangiumParser`?
- To insert or modify attributes that can't be parsed.
- When to override `ValueConverter`?
- To modify the returned value from the parser.

View File

@ -0,0 +1,13 @@
{
"projectName": "Mermaid",
"languages": [
{
"id": "info",
"grammar": "src/language/info/info.langium",
"fileExtensions": [".mmd", ".mermaid"]
}
],
"mode": "production",
"importExtension": ".js",
"out": "src/language/generated"
}

View File

@ -0,0 +1,48 @@
{
"name": "mermaid-parser",
"version": "0.2.0",
"description": "MermaidJS parser",
"author": "Yokozuna59",
"contributors": [
"Yokozuna59",
"Sidharth Vinod (https://sidharth.dev)"
],
"homepage": "https://github.com/mermaid-js/mermaid/tree/develop/packages/mermaid/parser/#readme",
"types": "dist/src/index.d.ts",
"type": "module",
"exports": {
".": {
"import": "./dist/mermaid-parser.esm.mjs",
"types": "./dist/src/index.d.ts"
}
},
"scripts": {
"clean": "rimraf dist src/language/generated",
"langium:generate": "langium generate",
"langium:watch": "langium generate --watch",
"prepublishOnly": "pnpm -w run build"
},
"repository": {
"type": "git",
"url": "https://github.com/mermaid-js/mermaid.git",
"directory": "packages/parser"
},
"license": "MIT",
"keywords": [
"mermaid",
"parser",
"ast"
],
"dependencies": {
"langium": "2.0.1"
},
"devDependencies": {
"langium-cli": "2.0.1"
},
"files": [
"dist/"
],
"publishConfig": {
"access": "public"
}
}

View File

@ -0,0 +1,3 @@
export type { Info } from './language/index.js';
export type { DiagramAST } from './parse.js';
export { parse, MermaidParseError } from './parse.js';

View File

@ -0,0 +1,51 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
type CustomPatternMatcherReturn = [string] & { payload?: any };
export type CustomPatternMatcherFunc = (
text: string,
offset: number,
tokens: IToken[],
groups: {
[groupName: string]: IToken[];
}
) => CustomPatternMatcherReturn | RegExpExecArray | null;
interface ICustomPattern {
exec: CustomPatternMatcherFunc;
}
type TokenPattern = RegExp | string | CustomPatternMatcherFunc | ICustomPattern;
export interface IToken {
image: string;
startOffset: number;
startLine?: number;
startColumn?: number;
endOffset?: number;
endLine?: number;
endColumn?: number;
isInsertedInRecovery?: boolean;
tokenTypeIdx: number;
tokenType: TokenType;
payload?: any;
}
export interface TokenType {
name: string;
GROUP?: string;
PATTERN?: TokenPattern;
LABEL?: string;
LONGER_ALT?: TokenType | TokenType[];
POP_MODE?: boolean;
PUSH_MODE?: string;
LINE_BREAKS?: boolean;
CATEGORIES?: TokenType[];
tokenTypeIdx?: number;
categoryMatches?: number[];
categoryMatchesMap?: {
[tokType: number]: boolean;
};
isParent?: boolean;
START_CHARS_HINT?: (string | number)[];
}

View File

@ -0,0 +1,14 @@
fragment TitleAndAccessibilities:
((accDescr=ACC_DESCR | accTitle=ACC_TITLE | title=TITLE) NEWLINE+)+
;
terminal NEWLINE: /\r?\n/;
terminal ACC_DESCR: /accDescr(?:[\t ]*:[\t ]*[^\n\r]*?(?=%%)|\s*{[^}]*})|accDescr(?:[\t ]*:[\t ]*[^\n\r]*|\s*{[^}]*})/;
terminal ACC_TITLE: /accTitle[\t ]*:[\t ]*[^\n\r]*?(?=%%)|accTitle[\t ]*:[\t ]*[^\n\r]*/;
terminal TITLE: /title(?:[\t ]+[^\n\r]*?|)(?=%%)|title(?:[\t ]+[^\n\r]*|)/;
hidden terminal WHITESPACE: /[\t ]+/;
// TODO: add YAML_COMMENT hidden rule without interfere actual grammar
hidden terminal YAML: /---[\t ]*\r?\n[\S\s]*?---[\t ]*(?!.)/;
hidden terminal DIRECTIVE: /[\t ]*%%{[\S\s]*?}%%\s*/;
hidden terminal SINGLE_LINE_COMMENT: /[\t ]*%%[^\n\r]*/;

View File

@ -0,0 +1,8 @@
import type { LexerResult } from 'langium';
import { DefaultLexer } from 'langium';
export class CommonLexer extends DefaultLexer {
public override tokenize(text: string): LexerResult {
return super.tokenize(text + '\n');
}
}

View File

@ -0,0 +1,14 @@
/**
* Matches single and multiline accessible description
*/
export const accessibilityDescrRegex = /accDescr(?:[\t ]*:[\t ]*([^\n\r]*)|\s*{([^}]*)})/;
/**
* Matches single line accessible title
*/
export const accessibilityTitleRegex = /accTitle[\t ]*:[\t ]*([^\n\r]*)/;
/**
* Matches a single line title
*/
export const titleRegex = /title([\t ]+([^\n\r]*)|)/;

View File

@ -0,0 +1,74 @@
import type { CstNode, GrammarAST, ValueType } from 'langium';
import { DefaultValueConverter } from 'langium';
import { accessibilityDescrRegex, accessibilityTitleRegex, titleRegex } from './commonMatcher.js';
export class CommonValueConverter extends DefaultValueConverter {
protected override runConverter(
rule: GrammarAST.AbstractRule,
input: string,
cstNode: CstNode
): ValueType {
const value: ValueType | undefined = CommonValueConverter.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 common rule or `undefined` 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 {
let regex: RegExp | undefined;
switch (rule.name) {
case 'ACC_DESCR': {
regex = new RegExp(accessibilityDescrRegex.source);
break;
}
case 'ACC_TITLE': {
regex = new RegExp(accessibilityTitleRegex.source);
break;
}
case 'TITLE': {
regex = new RegExp(titleRegex.source);
break;
}
}
if (regex === undefined) {
return undefined;
}
const match = regex.exec(input);
if (match === null) {
return undefined;
}
// single line title, accTitle, accDescr
if (match[1] !== undefined) {
return match[1].trim().replaceAll(/[\t ]{2,}/gm, ' ');
}
// multi line accDescr
if (match[2] !== undefined) {
return match[2]
.replaceAll(/^\s*/gm, '')
.replaceAll(/\s+$/gm, '')
.replaceAll(/[\t ]{2,}/gm, ' ')
.replaceAll(/[\n\r]{2,}/gm, '\n');
}
return undefined;
}
}

View File

@ -0,0 +1,2 @@
export * from './commonLexer.js';
export * from './commonValueConverters.js';

View File

@ -0,0 +1,6 @@
export * from './generated/ast.js';
export * from './generated/grammar.js';
export * from './generated/module.js';
export * from './common/index.js';
export * from './info/index.js';

View File

@ -0,0 +1 @@
export * from './infoModule.js';

View File

@ -0,0 +1,9 @@
grammar Info
import "../common/common";
entry Info:
NEWLINE*
"info" NEWLINE*
("showInfo" NEWLINE*)?
TitleAndAccessibilities?
;

View File

@ -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.
*/
export const InfoModule: Module<InfoServices, PartialLangiumServices & InfoAddedServices> = {
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 };
}

View File

@ -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<GrammarAST.AbstractRule>,
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;
}
}

View File

@ -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<string, LangiumParser> = {};
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<T extends DiagramAST>(
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<T> = parser.parse<T>(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<DiagramAST>) {
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}`);
}
}

View File

@ -0,0 +1,57 @@
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';
import { noErrorsOrAlternatives } from './test-util.js';
const services: InfoServices = createInfoServices().Info;
const parser: LangiumParser = services.parser.LangiumParser;
function createInfoTestServices(): {
services: InfoServices;
parse: (input: string) => ParseResult<Info>;
} {
const parse = (input: string) => {
return parser.parse<Info>(input);
};
return { services, parse };
}
describe('info', () => {
const { parse } = createInfoTestServices();
it.each([
`info`,
`
info`,
`info
`,
`
info
`,
])('should handle empty info', (context: string) => {
const result = parse(context);
noErrorsOrAlternatives(result);
expect(result.value.$type).toBe(Info);
});
it.each([
`info showInfo`,
`
info showInfo`,
`info
showInfo
`,
`
info
showInfo
`,
])('should handle showInfo', (context: string) => {
const result = parse(context);
noErrorsOrAlternatives(result);
expect(result.value.$type).toBe(Info);
});
});

View File

@ -0,0 +1,18 @@
import { expect, vi } from 'vitest';
import type { ParseResult } from 'langium';
const consoleMock = vi.spyOn(console, 'log').mockImplementation(() => undefined);
/**
* A helper test function that validate that the result doesn't have errors
* or any ambiguous alternatives from chevrotain.
*
* @param result - the result `parse` function.
*/
export function noErrorsOrAlternatives(result: ParseResult) {
expect(result.lexerErrors).toHaveLength(0);
expect(result.parserErrors).toHaveLength(0);
expect(consoleMock).not.toHaveBeenCalled();
consoleMock.mockReset();
}

View File

@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "./dist",
"allowJs": false,
"preserveSymlinks": false,
"strictPropertyInitialization": false
},
"include": ["./src/**/*.ts", "./tests/**/*.ts"],
"typeRoots": ["./src/types"]
}

6560
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,7 @@
import jison from './.vite/jisonPlugin.js';
import jsonSchemaPlugin from './.vite/jsonSchemaPlugin.js';
import typescript from '@rollup/plugin-typescript';
import { defineConfig } from 'vitest/config';
import { defaultExclude, defineConfig } from 'vitest/config';
export default defineConfig({
resolve: {
@ -22,7 +22,7 @@ export default defineConfig({
provider: 'v8',
reporter: ['text', 'json', 'html', 'lcov'],
reportsDirectory: './coverage/vitest',
exclude: ['**/node_modules/**', '**/tests/**', '**/__mocks__/**'],
exclude: [...defaultExclude, './tests/**', '**/__mocks__/**', '**/generated/'],
},
},
build: {