diff --git a/__mocks__/sankeyRenderer.ts b/__mocks__/sankeyRenderer.ts new file mode 100644 index 0000000000..4baf832f59 --- /dev/null +++ b/__mocks__/sankeyRenderer.ts @@ -0,0 +1,8 @@ +/** + * Mocked Sankey diagram renderer + */ +import { vi } from 'vitest'; + +export const draw = vi.fn().mockImplementation(() => undefined); + +export const renderer = { draw }; diff --git a/packages/mermaid/src/diagram-api/diagram-orchestration.ts b/packages/mermaid/src/diagram-api/diagram-orchestration.ts index 55d05c9aaa..ff902d640a 100644 --- a/packages/mermaid/src/diagram-api/diagram-orchestration.ts +++ b/packages/mermaid/src/diagram-api/diagram-orchestration.ts @@ -19,7 +19,7 @@ import errorDiagram from '../diagrams/error/errorDiagram.js'; import flowchartElk from '../diagrams/flowchart/elk/detector.js'; import timeline from '../diagrams/timeline/detector.js'; import mindmap from '../diagrams/mindmap/detector.js'; -import sankey from '../diagrams/sankey/sankeyDetector.js'; +import { sankey } from '../diagrams/sankey/sankeyDetector.js'; import { packet } from '../diagrams/packet/detector.js'; import block from '../diagrams/block/blockDetector.js'; import { registerLazyLoadedDiagrams } from './detectType.js'; diff --git a/packages/mermaid/src/diagrams/sankey/parser/sankey.jison b/packages/mermaid/src/diagrams/sankey/parser/sankey.jison deleted file mode 100644 index 9d66b69a43..0000000000 --- a/packages/mermaid/src/diagrams/sankey/parser/sankey.jison +++ /dev/null @@ -1,66 +0,0 @@ -/** mermaid */ - -//--------------------------------------------------------- -// We support csv format as defined here: -// https://www.ietf.org/rfc/rfc4180.txt -// There are some minor changes for compliance with jison -// We also parse only 3 columns: source,target,value -// And allow blank lines for visual purposes -//--------------------------------------------------------- - -%lex - -%options case-insensitive - -%x escaped_text -%x csv - -// as per section 6.1 of RFC 2234 [2] -COMMA \u002C -CR \u000D -LF \u000A -CRLF \u000D\u000A -ESCAPED_QUOTE \u0022 -DQUOTE \u0022 -TEXTDATA [\u0020-\u0021\u0023-\u002B\u002D-\u007E] - -%% - -"sankey-beta" { this.pushState('csv'); return 'SANKEY'; } -<> { return 'EOF' } // match end of file -({CRLF}|{LF}) { return 'NEWLINE' } -{COMMA} { return 'COMMA' } -{DQUOTE} { this.pushState('escaped_text'); return 'DQUOTE'; } -{TEXTDATA}* { return 'NON_ESCAPED_TEXT' } -{DQUOTE}(?!{DQUOTE}) {this.popState('escaped_text'); return 'DQUOTE'; } // unescaped DQUOTE closes string -({TEXTDATA}|{COMMA}|{CR}|{LF}|{DQUOTE}{DQUOTE})* { return 'ESCAPED_TEXT'; } - -/lex - -%start start - -%% // language grammar - -start: SANKEY NEWLINE csv opt_eof; - -csv: record csv_tail; -csv_tail: NEWLINE csv | ; -opt_eof: EOF | ; - -record - : field\[source] COMMA field\[target] COMMA field\[value] { - const source = yy.findOrCreateNode($source.trim().replaceAll('""', '"')); - const target = yy.findOrCreateNode($target.trim().replaceAll('""', '"')); - const value = parseFloat($value.trim()); - yy.addLink(source,target,value); - } // parse only 3 fields, this is not part of CSV standard - ; - -field - : escaped { $$=$escaped; } - | non_escaped { $$=$non_escaped; } - ; - -escaped: DQUOTE ESCAPED_TEXT DQUOTE { $$=$ESCAPED_TEXT; }; - -non_escaped: NON_ESCAPED_TEXT { $$=$NON_ESCAPED_TEXT; }; diff --git a/packages/mermaid/src/diagrams/sankey/parser/sankey.spec.ts b/packages/mermaid/src/diagrams/sankey/parser/sankey.spec.ts deleted file mode 100644 index 169aee8731..0000000000 --- a/packages/mermaid/src/diagrams/sankey/parser/sankey.spec.ts +++ /dev/null @@ -1,33 +0,0 @@ -// @ts-ignore: jison doesn't export types -import sankey from './sankey.jison'; -import db from '../sankeyDB.js'; -import { cleanupComments } from '../../../diagram-api/comments.js'; -import { prepareTextForParsing } from '../sankeyUtils.js'; -import * as fs from 'fs'; -import * as path from 'path'; - -describe('Sankey diagram', function () { - describe('when parsing an info graph it', function () { - beforeEach(function () { - sankey.parser.yy = db; - sankey.parser.yy.clear(); - }); - - it('parses csv', async () => { - const csv = path.resolve(__dirname, './energy.csv'); - const data = fs.readFileSync(csv, 'utf8'); - const graphDefinition = prepareTextForParsing(cleanupComments('sankey-beta\n\n ' + data)); - - sankey.parser.parse(graphDefinition); - }); - - it('allows __proto__ as id', function () { - sankey.parser.parse( - prepareTextForParsing(`sankey-beta - __proto__,A,0.597 - A,__proto__,0.403 - `) - ); - }); - }); -}); diff --git a/packages/mermaid/src/diagrams/sankey/sankey.spec.ts b/packages/mermaid/src/diagrams/sankey/sankey.spec.ts new file mode 100644 index 0000000000..6922c1ced3 --- /dev/null +++ b/packages/mermaid/src/diagrams/sankey/sankey.spec.ts @@ -0,0 +1,25 @@ +import { parser } from './sankeyParser.js'; +import { db } from './sankeyDB.js'; +import * as fs from 'fs'; +import * as path from 'path'; + +describe('sankey', () => { + beforeEach(() => db.clear()); + + it('should parse csv', async () => { + const csv = path.resolve(__dirname, './parser/energy.csv'); + const data = fs.readFileSync(csv, 'utf8'); + const graphDefinition = 'sankey-beta\n\n ' + data; + + void parser.parse(graphDefinition); + }); + + it('allows __proto__ as id', async () => { + void parser.parse( + `sankey-beta + __proto__,A,0.597 + A,__proto__,0.403 + ` + ); + }); +}); diff --git a/packages/mermaid/src/diagrams/sankey/sankeyDB.ts b/packages/mermaid/src/diagrams/sankey/sankeyDB.ts index 735ef045ba..b01278746f 100644 --- a/packages/mermaid/src/diagrams/sankey/sankeyDB.ts +++ b/packages/mermaid/src/diagrams/sankey/sankeyDB.ts @@ -1,5 +1,3 @@ -import { getConfig } from '../../diagram-api/diagramAPI.js'; -import common from '../common/common.js'; import { setAccTitle, getAccTitle, @@ -9,79 +7,63 @@ import { getDiagramTitle, clear as commonClear, } from '../common/commonDb.js'; +import type { SankeyDiagramConfig } from '../../config.type.js'; +import type { SankeyDB, SankeyLink, SankeyFields } from './sankeyTypes.js'; +import DEFAULT_CONFIG from '../../defaultConfig.js'; +import type { RequiredDeep } from 'type-fest'; + +export const DEFAULT_SANKEY_CONFIG: Required = DEFAULT_CONFIG.sankey; + +export const DEFAULT_SANKEY_DB: RequiredDeep = { + links: [] as SankeyLink[], + nodes: [] as string[], + config: DEFAULT_SANKEY_CONFIG, +} as const; // Sankey diagram represented by nodes and links between those nodes -let links: SankeyLink[] = []; +let links: SankeyLink[] = DEFAULT_SANKEY_DB.links; // Array of nodes guarantees their order -let nodes: SankeyNode[] = []; -// We also have to track nodes uniqueness (by ID) -let nodesMap: Map = new Map(); +let nodes: string[] = DEFAULT_SANKEY_DB.nodes; +const config: Required = structuredClone(DEFAULT_SANKEY_CONFIG); + +const getConfig = (): Required => structuredClone(config); const clear = (): void => { links = []; nodes = []; - nodesMap = new Map(); commonClear(); }; -class SankeyLink { - constructor( - public source: SankeyNode, - public target: SankeyNode, - public value: number = 0 - ) {} -} - /** * @param source - Node where the link starts * @param target - Node where the link ends * @param value - Describes the amount to be passed */ -const addLink = (source: SankeyNode, target: SankeyNode, value: number): void => { - links.push(new SankeyLink(source, target, value)); +const addLink = ({ source, target, value }: SankeyLink): void => { + links.push({ source, target, value }); }; -class SankeyNode { - constructor(public ID: string) {} -} - -const findOrCreateNode = (ID: string): SankeyNode => { - ID = common.sanitizeText(ID, getConfig()); +const getLinks = (): SankeyLink[] => links; - let node = nodesMap.get(ID); - if (node === undefined) { - node = new SankeyNode(ID); - nodesMap.set(ID, node); - nodes.push(node); - } - return node; +const addNode = (node: string): void => { + nodes.push(node); }; -const getNodes = () => nodes; -const getLinks = () => links; +const getNodes = (): string[] => nodes; -const getGraph = () => ({ - nodes: nodes.map((node) => ({ id: node.ID })), - links: links.map((link) => ({ - source: link.source.ID, - target: link.target.ID, - value: link.value, - })), -}); +export const db: SankeyDB = { + getConfig, + clear, -export default { - nodesMap, - getConfig: () => getConfig().sankey, - getNodes, - getLinks, - getGraph, - addLink, - findOrCreateNode, - getAccTitle, + setDiagramTitle, + getDiagramTitle, setAccTitle, - getAccDescription, + getAccTitle, setAccDescription, - getDiagramTitle, - setDiagramTitle, - clear, + getAccDescription, + + addLink, + addNode, + getLinks, + getNodes, }; diff --git a/packages/mermaid/src/diagrams/sankey/sankeyDetector.ts b/packages/mermaid/src/diagrams/sankey/sankeyDetector.ts index 73c4d14289..4a4b33875a 100644 --- a/packages/mermaid/src/diagrams/sankey/sankeyDetector.ts +++ b/packages/mermaid/src/diagrams/sankey/sankeyDetector.ts @@ -1,4 +1,8 @@ -import type { DiagramDetector, ExternalDiagramDefinition } from '../../diagram-api/types.js'; +import type { + DiagramDetector, + DiagramLoader, + ExternalDiagramDefinition, +} from '../../diagram-api/types.js'; const id = 'sankey'; @@ -6,15 +10,13 @@ const detector: DiagramDetector = (txt) => { return /^\s*sankey-beta/.test(txt); }; -const loader = async () => { +const loader: DiagramLoader = async () => { const { diagram } = await import('./sankeyDiagram.js'); return { id, diagram }; }; -const plugin: ExternalDiagramDefinition = { +export const sankey: ExternalDiagramDefinition = { id, detector, loader, }; - -export default plugin; diff --git a/packages/mermaid/src/diagrams/sankey/sankeyDiagram.ts b/packages/mermaid/src/diagrams/sankey/sankeyDiagram.ts index 6fed435ac4..5f9ebc1a81 100644 --- a/packages/mermaid/src/diagrams/sankey/sankeyDiagram.ts +++ b/packages/mermaid/src/diagrams/sankey/sankeyDiagram.ts @@ -1,12 +1,7 @@ import type { DiagramDefinition } from '../../diagram-api/types.js'; -// @ts-ignore: jison doesn't export types -import parser from './parser/sankey.jison'; -import db from './sankeyDB.js'; -import renderer from './sankeyRenderer.js'; -import { prepareTextForParsing } from './sankeyUtils.js'; - -const originalParse = parser.parse.bind(parser); -parser.parse = (text: string) => originalParse(prepareTextForParsing(text)); +import { parser } from './sankeyParser.js'; +import { db } from './sankeyDB.js'; +import { renderer } from './sankeyRenderer.js'; export const diagram: DiagramDefinition = { parser, diff --git a/packages/mermaid/src/diagrams/sankey/sankeyParser.ts b/packages/mermaid/src/diagrams/sankey/sankeyParser.ts new file mode 100644 index 0000000000..d139952b98 --- /dev/null +++ b/packages/mermaid/src/diagrams/sankey/sankeyParser.ts @@ -0,0 +1,26 @@ +import type { Sankey, SankeyLink } from '@mermaid-js/parser'; +import { parse } from '@mermaid-js/parser'; + +import { log } from '../../logger.js'; +import type { ParserDefinition } from '../../diagram-api/types.js'; +import { populateCommonDb } from '../common/populateCommonDb.js'; +import type { SankeyDB } from './sankeyTypes.js'; +import { db } from './sankeyDB.js'; + +function populateDb(ast: Sankey, db: SankeyDB) { + populateCommonDb(ast, db); + ast.links.forEach((link: SankeyLink) => { + db.addLink(link); + }); + ast.nodes.forEach((node: string) => { + db.addNode(node); + }); +} + +export const parser: ParserDefinition = { + parse: async (input: string): Promise => { + const ast: Sankey = await parse('sankey', input); + log.debug(ast); + populateDb(ast, db); + }, +}; diff --git a/packages/mermaid/src/diagrams/sankey/sankeyRenderer.ts b/packages/mermaid/src/diagrams/sankey/sankeyRenderer.ts index 51f808ff44..c1045c2a46 100644 --- a/packages/mermaid/src/diagrams/sankey/sankeyRenderer.ts +++ b/packages/mermaid/src/diagrams/sankey/sankeyRenderer.ts @@ -1,12 +1,6 @@ -import type { Diagram } from '../../Diagram.js'; -import { getConfig, defaultConfig } from '../../diagram-api/diagramAPI.js'; -import { - select as d3select, - scaleOrdinal as d3scaleOrdinal, - schemeTableau10 as d3schemeTableau10, -} from 'd3'; - -import type { SankeyNode as d3SankeyNode } from 'd3-sankey'; +import type { ScaleOrdinal } from 'd3'; +import { scaleOrdinal as d3scaleOrdinal, schemeTableau10 as d3schemeTableau10 } from 'd3'; +import type { SankeyGraph as d3SankeyGraph, SankeyNode as d3SankeyNode } from 'd3-sankey'; import { sankey as d3Sankey, sankeyLinkHorizontal as d3SankeyLinkHorizontal, @@ -15,9 +9,26 @@ import { sankeyCenter as d3SankeyCenter, sankeyJustify as d3SankeyJustify, } from 'd3-sankey'; + +import { log } from '../../logger.js'; +import type { DrawDefinition, SVG } from '../../diagram-api/types.js'; +import type { MermaidConfig, SankeyDiagramConfig, SankeyNodeAlignment } from '../../config.type.js'; +import type { + SankeyDB, + SankeyGraph, + SankeyLinkData, + SankeyLinkDatum, + SankeyLinkOverride, + SankeyNodeData, + SankeyNodeDatum, + SankeyNodeOverride, +} from './sankeyTypes.js'; import { setupGraphViewbox } from '../../setupGraphViewbox.js'; import { Uid } from '../../rendering-util/uid.js'; -import type { SankeyNodeAlignment } from '../../config.type.js'; +import { getConfig } from '../../diagram-api/diagramAPI.js'; +import { selectSvgElement } from '../../rendering-util/selectSvgElement.js'; +import { cleanAndMerge } from '../../utils.js'; +import { configureSvgSize } from '../../setupGraphViewbox.js'; // Map config options to alignment functions const alignmentsMap: Record< @@ -28,109 +39,102 @@ const alignmentsMap: Record< right: d3SankeyRight, center: d3SankeyCenter, justify: d3SankeyJustify, -}; +} as const; /** - * Draws Sankey diagram. + * Prepare data for construction based DB. * - * @param text - The text of the diagram - * @param id - The id of the diagram which will be used as a DOM element id¨ - * @param _version - Mermaid version from package.json - * @param diagObj - A standard diagram containing the db and the text and type etc of the diagram + * This must be a mutable object with `nodes` and `links` properties: + * + * ```json + * { + * "nodes": [{ "name": "Alice", "id": "node-1" }, { "name": "Bob", "id": "node-2" }], + * "links": [{ "id": "linearGradient-1", "source": "Alice", "target": "Bob", "value": 23 }] + * } + * ``` + * + * @param db - The sankey db. + * @param config - The required config of sankey diagram. + * @returns The prepared sankey data. */ -export const draw = function (text: string, id: string, _version: string, diagObj: Diagram): void { - // Get Sankey config - const { securityLevel, sankey: conf } = getConfig(); - const defaultSankeyConfig = defaultConfig!.sankey!; - - // TODO: - // This code repeats for every diagram - // Figure out what is happening there, probably it should be separated - // The main thing is svg object that is a d3 wrapper for svg operations - // - let sandboxElement: any; - if (securityLevel === 'sandbox') { - sandboxElement = d3select('#i' + id); - } - const root = - securityLevel === 'sandbox' - ? d3select(sandboxElement.nodes()[0].contentDocument.body) - : d3select('body'); - // @ts-ignore TODO root.select is not callable - const svg = securityLevel === 'sandbox' ? root.select(`[id="${id}"]`) : d3select(`[id="${id}"]`); - - // Establish svg dimensions and get width and height - // - const width = conf?.width ?? defaultSankeyConfig.width!; - const height = conf?.height ?? defaultSankeyConfig.width!; - const useMaxWidth = conf?.useMaxWidth ?? defaultSankeyConfig.useMaxWidth!; - const nodeAlignment = conf?.nodeAlignment ?? defaultSankeyConfig.nodeAlignment!; - const prefix = conf?.prefix ?? defaultSankeyConfig.prefix!; - const suffix = conf?.suffix ?? defaultSankeyConfig.suffix!; - const showValues = conf?.showValues ?? defaultSankeyConfig.showValues!; - - // Prepare data for construction based on diagObj.db - // This must be a mutable object with `nodes` and `links` properties: - // - // { - // "nodes": [ { "id": "Alice" }, { "id": "Bob" }, { "id": "Carol" } ], - // "links": [ { "source": "Alice", "target": "Bob", "value": 23 }, { "source": "Bob", "target": "Carol", "value": 43 } ] - // } - // - // @ts-ignore TODO: db should be coerced to sankey DB type - const graph = diagObj.db.getGraph(); - - // Get alignment function - const nodeAlign = alignmentsMap[nodeAlignment]; - - // Construct and configure a Sankey generator - // That will be a function that calculates nodes and links dimensions - // +const createSankeyGraph = (db: SankeyDB, config: Required) => { + const graph: SankeyGraph = structuredClone({ + nodes: db.getNodes().map((node: string) => { + return { + id: Uid.next('node-').id, + name: node, + }; + }), + links: db.getLinks(), + }); + + const nodeAlign = alignmentsMap[config.nodeAlignment]; const nodeWidth = 10; - const sankey = d3Sankey() - .nodeId((d: any) => d.id) // we use 'id' property to identify node + // eslint-disable-next-line @typescript-eslint/ban-types + const sankey = d3Sankey() + .nodeId((node): string => node.name) .nodeWidth(nodeWidth) - .nodePadding(10 + (showValues ? 15 : 0)) + .nodePadding(10 + (config.showValues ? 15 : 0)) .nodeAlign(nodeAlign) .extent([ [0, 0], - [width, height], + [config.width, config.height], ]); + return sankey(graph) as d3SankeyGraph< + SankeyNodeData & SankeyNodeOverride, + SankeyLinkData & SankeyLinkOverride + >; +}; - // Compute the Sankey layout: calculate nodes and links positions - // Our `graph` object will be mutated by this and enriched with other properties - // - sankey(graph); +export const draw: DrawDefinition = (text, id, _version, diagObj) => { + log.debug('rendering sankey diagram\n' + text); + + const db = diagObj.db as SankeyDB; + const globalConfig: MermaidConfig = getConfig(); + const sankeyConfig: Required = cleanAndMerge( + db.getConfig(), + globalConfig.sankey + ); + + const svg: SVG = selectSvgElement(id); + + const width: number = sankeyConfig.width; + const height: number = sankeyConfig.height; + const useMaxWidth: boolean = sankeyConfig.useMaxWidth; + configureSvgSize(svg, height, width, useMaxWidth); + + const graph = createSankeyGraph(db, sankeyConfig); // Get color scheme for the graph - const colorScheme = d3scaleOrdinal(d3schemeTableau10); + const colorScheme: ScaleOrdinal = d3scaleOrdinal(d3schemeTableau10); // Create rectangles for nodes svg .append('g') .attr('class', 'nodes') .selectAll('.node') - .data(graph.nodes) + .data(graph.nodes) .join('g') .attr('class', 'node') - .attr('id', (d: any) => (d.uid = Uid.next('node-')).id) - .attr('transform', function (d: any) { - return 'translate(' + d.x0 + ',' + d.y0 + ')'; + .attr('id', (d: SankeyNodeDatum) => d.id) + .attr('transform', (d: SankeyNodeDatum) => { + return `translate(${d.x0},${d.y0})`; }) - .attr('x', (d: any) => d.x0) - .attr('y', (d: any) => d.y0) + .attr('x', (d: SankeyNodeDatum): number => d.x0) + .attr('y', (d: SankeyNodeDatum): number => d.y0) .append('rect') - .attr('height', (d: any) => { - return d.y1 - d.y0; - }) - .attr('width', (d: any) => d.x1 - d.x0) - .attr('fill', (d: any) => colorScheme(d.id)); - - const getText = ({ id, value }: { id: string; value: number }) => { + .attr('height', (d: SankeyNodeDatum): number => d.y1 - d.y0) + .attr('width', (d: SankeyNodeDatum): number => d.x1 - d.x0) + .attr('fill', (d: SankeyNodeDatum): string => colorScheme(d.id)); + + const showValues: boolean = sankeyConfig.showValues; + const prefix: string = sankeyConfig.prefix; + const suffix: string = sankeyConfig.suffix; + const getText = ({ name, value }: SankeyNodeDatum): string => { if (!showValues) { - return id; + return name; } - return `${id}\n${prefix}${Math.round(value * 100) / 100}${suffix}`; + return `${name}\n${prefix}${Math.round((value ?? 0) * 100) / 100}${suffix}`; }; // Create labels for nodes @@ -140,71 +144,71 @@ export const draw = function (text: string, id: string, _version: string, diagOb .attr('font-family', 'sans-serif') .attr('font-size', 14) .selectAll('text') - .data(graph.nodes) + .data(graph.nodes) .join('text') - .attr('x', (d: any) => (d.x0 < width / 2 ? d.x1 + 6 : d.x0 - 6)) - .attr('y', (d: any) => (d.y1 + d.y0) / 2) + .attr('x', (d: SankeyNodeDatum) => (d.x0 < width / 2 ? d.x1 + 6 : d.x0 - 6)) + .attr('y', (d: SankeyNodeDatum): number => (d.y1 + d.y0) / 2) .attr('dy', `${showValues ? '0' : '0.35'}em`) - .attr('text-anchor', (d: any) => (d.x0 < width / 2 ? 'start' : 'end')) + .attr('text-anchor', (d: SankeyNodeDatum) => (d.x0 < width / 2 ? 'start' : 'end')) .text(getText); // Creates the paths that represent the links. - const link = svg + const links = svg .append('g') .attr('class', 'links') .attr('fill', 'none') .attr('stroke-opacity', 0.5) .selectAll('.link') - .data(graph.links) + .data(graph.links) .join('g') .attr('class', 'link') .style('mix-blend-mode', 'multiply'); - const linkColor = conf?.linkColor || 'gradient'; - + const linkColor = sankeyConfig.linkColor; if (linkColor === 'gradient') { - const gradient = link + const gradient = links .append('linearGradient') - .attr('id', (d: any) => (d.uid = Uid.next('linearGradient-')).id) + .attr('id', (d: SankeyLinkDatum) => { + // @ts-ignore - figure how to stop using this approach + return (d.id = Uid.next('linearGradient-')).id; + }) .attr('gradientUnits', 'userSpaceOnUse') - .attr('x1', (d: any) => d.source.x1) - .attr('x2', (d: any) => d.target.x0); + .attr('x1', (d: SankeyLinkDatum): number => d.source.x1) + .attr('x2', (d: SankeyLinkDatum): number => d.target.x0); gradient .append('stop') .attr('offset', '0%') - .attr('stop-color', (d: any) => colorScheme(d.source.id)); + .attr('stop-color', (d: SankeyLinkDatum): string => colorScheme(d.source.id)); gradient .append('stop') .attr('offset', '100%') - .attr('stop-color', (d: any) => colorScheme(d.target.id)); + .attr('stop-color', (d: SankeyLinkDatum): string => colorScheme(d.target.id)); } - let coloring: any; + let coloring: (d: SankeyLinkDatum) => string; switch (linkColor) { case 'gradient': - coloring = (d: any) => d.uid; + coloring = (d: SankeyLinkDatum): string => d.id; break; case 'source': - coloring = (d: any) => colorScheme(d.source.id); + coloring = (d: SankeyLinkDatum): string => colorScheme(d.source.id); break; case 'target': - coloring = (d: any) => colorScheme(d.target.id); + coloring = (d: SankeyLinkDatum): string => colorScheme(d.target.id); break; default: - coloring = linkColor; + coloring = (): string => linkColor; } - link + links .append('path') .attr('d', d3SankeyLinkHorizontal()) .attr('stroke', coloring) - .attr('stroke-width', (d: any) => Math.max(1, d.width)); + .attr('stroke-width', (d: SankeyLinkDatum): number => Math.max(1, d.width)); setupGraphViewbox(undefined, svg, 0, useMaxWidth); }; -export default { - draw, -}; +export const renderer = { draw }; diff --git a/packages/mermaid/src/diagrams/sankey/sankeyTypes.ts b/packages/mermaid/src/diagrams/sankey/sankeyTypes.ts new file mode 100644 index 0000000000..3e9b149449 --- /dev/null +++ b/packages/mermaid/src/diagrams/sankey/sankeyTypes.ts @@ -0,0 +1,97 @@ +/* eslint-disable @typescript-eslint/ban-types */ +import type { SankeyNode as d3SankeyNode, SankeyLink as d3SankeyLink } from 'd3-sankey'; + +import type { SankeyDiagramConfig } from '../../config.type.js'; +import type { DiagramDB } from '../../diagram-api/types.js'; + +export interface SankeyFields { + links: SankeyLink[]; + nodes: string[]; + config: Required; +} + +export interface SankeyNodeData { + name: string; + id: string; +} + +export interface SankeyNodeOverride { + // Override optional attributes + value: number; + index: number; + depth: number; + height: number; + x0: number; + x1: number; + y0: number; + y1: number; + + // Add missing attributes + layer: number; +} + +export interface SankeyLinkData { + id: string; +} + +export interface SankeyLinkOverride { + // Override optional attributes + sourceLinks: d3SankeyNode< + SankeyNodeData & SankeyNodeOverride, + SankeyLinkData & SankeyLinkOverride + >[]; + targetLinks: d3SankeyNode< + SankeyNodeData & SankeyNodeOverride, + SankeyLinkData & SankeyLinkOverride + >[]; + source: d3SankeyLink; + target: d3SankeyLink; + value: number; + x0: number; + x1: number; + y0: number; + y1: number; + width: number; + index: number; +} + +export interface SankeyGraph { + nodes: SankeyNodeData[]; + links: SankeyLink[]; +} + +export type SankeyNodeDatum = d3SankeyNode< + SankeyNodeData & SankeyNodeOverride, + SankeyLinkData & SankeyLinkOverride +>; + +export type SankeyLinkDatum = d3SankeyLink< + SankeyNodeData & SankeyNodeOverride, + SankeyLinkData & SankeyLinkOverride +>; + +export interface SankeyLink { + source: string; + target: string; + value: number; +} + +export interface SankeyDB extends DiagramDB { + // config + getConfig: () => Required; + + // common db + clear: () => void; + setDiagramTitle: (title: string) => void; + getDiagramTitle: () => string; + setAccTitle: (title: string) => void; + getAccTitle: () => string; + setAccDescription: (description: string) => void; + getAccDescription: () => string; + + // diagram db + addLink: ({ source, target, value }: SankeyLink) => void; + getLinks: () => SankeyLink[]; + addNode: (node: string) => void; + getNodes: () => string[]; +} diff --git a/packages/mermaid/src/diagrams/sankey/sankeyUtils.ts b/packages/mermaid/src/diagrams/sankey/sankeyUtils.ts deleted file mode 100644 index 45ecf21dda..0000000000 --- a/packages/mermaid/src/diagrams/sankey/sankeyUtils.ts +++ /dev/null @@ -1,8 +0,0 @@ -export const prepareTextForParsing = (text: string): string => { - const textToParse = text - .replaceAll(/^[^\S\n\r]+|[^\S\n\r]+$/g, '') // remove all trailing spaces for each row - .replaceAll(/([\n\r])+/g, '\n') // remove empty lines duplicated - .trim(); - - return textToParse; -}; diff --git a/packages/mermaid/src/mermaidAPI.spec.ts b/packages/mermaid/src/mermaidAPI.spec.ts index 1134f86353..7c0ec4e183 100644 --- a/packages/mermaid/src/mermaidAPI.spec.ts +++ b/packages/mermaid/src/mermaidAPI.spec.ts @@ -29,6 +29,7 @@ vi.mock('./diagrams/pie/pieRenderer.js'); vi.mock('./diagrams/packet/renderer.js'); vi.mock('./diagrams/xychart/xychartRenderer.js'); vi.mock('./diagrams/requirement/requirementRenderer.js'); +vi.mock('./diagrams/sankey/sankeyRenderer.ts'); vi.mock('./diagrams/sequence/sequenceRenderer.js'); vi.mock('./diagrams/state/stateRenderer-v2.js'); diff --git a/packages/parser/langium-config.json b/packages/parser/langium-config.json index c750f049d5..f505232ab2 100644 --- a/packages/parser/langium-config.json +++ b/packages/parser/langium-config.json @@ -15,6 +15,11 @@ "id": "pie", "grammar": "src/language/pie/pie.langium", "fileExtensions": [".mmd", ".mermaid"] + }, + { + "id": "sankey", + "grammar": "src/language/sankey/sankey.langium", + "fileExtensions": [".mmd", ".mermaid"] } ], "mode": "production", diff --git a/packages/parser/src/language/common/tokenBuilder.ts b/packages/parser/src/language/common/tokenBuilder.ts index f997634545..07b949e48d 100644 --- a/packages/parser/src/language/common/tokenBuilder.ts +++ b/packages/parser/src/language/common/tokenBuilder.ts @@ -1,14 +1,45 @@ -import type { GrammarAST, Stream, TokenBuilderOptions } from 'langium'; import type { TokenType } from 'chevrotain'; - -import { DefaultTokenBuilder } from 'langium'; +import type { + CommentProvider, + GrammarAST, + LangiumCoreServices, + Stream, + TokenBuilderOptions, +} from 'langium'; +import { DefaultTokenBuilder, stream } from 'langium'; export abstract class AbstractMermaidTokenBuilder extends DefaultTokenBuilder { private keywords: Set; + private commentProvider: CommentProvider; - public constructor(keywords: string[]) { + public constructor(keywords: string[], services: LangiumCoreServices) { super(); this.keywords = new Set(keywords); + this.commentProvider = services.documentation.CommentProvider; + } + + // TODO: This responsibility might better belong in CommentProvider (e.g. AbstractMermaidCommentProvider that is a subclass of CommentProvider). + private ruleHasGreedyComment(rule: GrammarAST.AbstractRule): boolean { + const comment = this.commentProvider.getComment(rule); + return !!comment && /@greedy/.test(comment); + } + + protected override buildTerminalTokens(rules: Stream): TokenType[] { + if (rules.some((rule: GrammarAST.AbstractRule) => this.ruleHasGreedyComment(rule))) { + const notGreedyRules: GrammarAST.AbstractRule[] = []; + const lastRules: GrammarAST.AbstractRule[] = []; + // put terminal rules with @greedy in their comment at the end of the array + rules.forEach((rule) => { + if (this.ruleHasGreedyComment(rule)) { + lastRules.push(rule); + } else { + notGreedyRules.push(rule); + } + }); + return super.buildTerminalTokens(stream([...notGreedyRules, ...lastRules])); + } else { + return super.buildTerminalTokens(rules); + } } protected override buildKeywordTokens( diff --git a/packages/parser/src/language/index.ts b/packages/parser/src/language/index.ts index 9f1d92ba8a..677908cb39 100644 --- a/packages/parser/src/language/index.ts +++ b/packages/parser/src/language/index.ts @@ -5,21 +5,27 @@ export { PacketBlock, Pie, PieSection, + Sankey, + SankeyLink, isCommon, isInfo, isPacket, isPacketBlock, isPie, isPieSection, + isSankey, + isSankeyLink, } from './generated/ast.js'; export { InfoGeneratedModule, MermaidGeneratedSharedModule, PacketGeneratedModule, PieGeneratedModule, + SankeyGeneratedModule, } from './generated/module.js'; export * from './common/index.js'; export * from './info/index.js'; export * from './packet/index.js'; export * from './pie/index.js'; +export * from './sankey/index.js'; diff --git a/packages/parser/src/language/info/module.ts b/packages/parser/src/language/info/module.ts index 735e161464..c6271843b9 100644 --- a/packages/parser/src/language/info/module.ts +++ b/packages/parser/src/language/info/module.ts @@ -37,7 +37,7 @@ export type InfoServices = LangiumCoreServices & InfoAddedServices; */ export const InfoModule: Module = { parser: { - TokenBuilder: () => new InfoTokenBuilder(), + TokenBuilder: (services) => new InfoTokenBuilder(services), ValueConverter: () => new CommonValueConverter(), }, }; diff --git a/packages/parser/src/language/info/tokenBuilder.ts b/packages/parser/src/language/info/tokenBuilder.ts index 69ad0e689d..260d9ac42d 100644 --- a/packages/parser/src/language/info/tokenBuilder.ts +++ b/packages/parser/src/language/info/tokenBuilder.ts @@ -1,7 +1,8 @@ import { AbstractMermaidTokenBuilder } from '../common/index.js'; +import type { InfoServices } from './module.js'; export class InfoTokenBuilder extends AbstractMermaidTokenBuilder { - public constructor() { - super(['info', 'showInfo']); + public constructor(services: InfoServices) { + super(['info', 'showInfo'], services); } } diff --git a/packages/parser/src/language/packet/module.ts b/packages/parser/src/language/packet/module.ts index 7eb65810f7..c67ffd99b0 100644 --- a/packages/parser/src/language/packet/module.ts +++ b/packages/parser/src/language/packet/module.ts @@ -40,7 +40,7 @@ export const PacketModule: Module< PartialLangiumCoreServices & PacketAddedServices > = { parser: { - TokenBuilder: () => new PacketTokenBuilder(), + TokenBuilder: (services) => new PacketTokenBuilder(services), ValueConverter: () => new CommonValueConverter(), }, }; diff --git a/packages/parser/src/language/packet/tokenBuilder.ts b/packages/parser/src/language/packet/tokenBuilder.ts index accba5675a..c560ba6874 100644 --- a/packages/parser/src/language/packet/tokenBuilder.ts +++ b/packages/parser/src/language/packet/tokenBuilder.ts @@ -1,7 +1,8 @@ import { AbstractMermaidTokenBuilder } from '../common/index.js'; +import type { PacketServices } from './module.js'; export class PacketTokenBuilder extends AbstractMermaidTokenBuilder { - public constructor() { - super(['packet-beta']); + public constructor(services: PacketServices) { + super(['packet-beta'], services); } } diff --git a/packages/parser/src/language/pie/module.ts b/packages/parser/src/language/pie/module.ts index 80fc26f868..2294992d9e 100644 --- a/packages/parser/src/language/pie/module.ts +++ b/packages/parser/src/language/pie/module.ts @@ -37,7 +37,7 @@ export type PieServices = LangiumCoreServices & PieAddedServices; */ export const PieModule: Module = { parser: { - TokenBuilder: () => new PieTokenBuilder(), + TokenBuilder: (services) => new PieTokenBuilder(services), ValueConverter: () => new PieValueConverter(), }, }; diff --git a/packages/parser/src/language/pie/tokenBuilder.ts b/packages/parser/src/language/pie/tokenBuilder.ts index 85aecf96a0..7334d3002d 100644 --- a/packages/parser/src/language/pie/tokenBuilder.ts +++ b/packages/parser/src/language/pie/tokenBuilder.ts @@ -1,7 +1,8 @@ import { AbstractMermaidTokenBuilder } from '../common/index.js'; +import type { PieServices } from './module.js'; export class PieTokenBuilder extends AbstractMermaidTokenBuilder { - public constructor() { - super(['pie', 'showData']); + public constructor(services: PieServices) { + super(['pie', 'showData'], services); } } diff --git a/packages/parser/src/language/sankey/index.ts b/packages/parser/src/language/sankey/index.ts new file mode 100644 index 0000000000..fd3c604b08 --- /dev/null +++ b/packages/parser/src/language/sankey/index.ts @@ -0,0 +1 @@ +export * from './module.js'; diff --git a/packages/parser/src/language/sankey/matcher.ts b/packages/parser/src/language/sankey/matcher.ts new file mode 100644 index 0000000000..af988fa1cc --- /dev/null +++ b/packages/parser/src/language/sankey/matcher.ts @@ -0,0 +1,9 @@ +/** + * Matches sankey link source and target node + */ +export const sankeyLinkNodeRegex = /(?:"((?:""|[^"])+)")|([^\n\r,]+(?=%%)|[^\n\r,]+)/; + +/** + * Matches sankey link value + */ +export const sankeyLinkValueRegex = /("(?:0|[1-9]\d*)(?:\.\d+)?"|[\t ]*(?:0|[1-9]\d*)(?:\.\d+)?)/; diff --git a/packages/parser/src/language/sankey/module.ts b/packages/parser/src/language/sankey/module.ts new file mode 100644 index 0000000000..f58875c79d --- /dev/null +++ b/packages/parser/src/language/sankey/module.ts @@ -0,0 +1,81 @@ +import type { + DefaultSharedCoreModuleContext, + LangiumCoreServices, + LangiumParser, + LangiumSharedCoreServices, + Module, + PartialLangiumCoreServices, +} from 'langium'; +import { + EmptyFileSystem, + createDefaultCoreModule, + createDefaultSharedCoreModule, + inject, +} from 'langium'; + +import { MermaidGeneratedSharedModule, SankeyGeneratedModule } from '../generated/module.js'; +import { SankeyTokenBuilder } from './tokenBuilder.js'; +import { SankeyValueConverter } from './valueConverter.js'; +import { createSankeyParser } from './parser.js'; + +/** + * Declaration of `Sankey` services. + */ +export interface SankeyAddedServices { + parser: { + LangiumParser: LangiumParser; + TokenBuilder: SankeyTokenBuilder; + ValueConverter: SankeyValueConverter; + }; +} + +/** + * Union of Langium default services and `Sankey` services. + */ +export type SankeyServices = LangiumCoreServices & SankeyAddedServices; + +/** + * Dependency injection module that overrides Langium default services and + * contributes the declared `Sankey` services. + */ +export const SankeyModule: Module< + SankeyServices, + PartialLangiumCoreServices & SankeyAddedServices +> = { + parser: { + LangiumParser: (services) => createSankeyParser(services), + TokenBuilder: (services) => new SankeyTokenBuilder(services), + ValueConverter: () => new SankeyValueConverter(), + }, +}; + +/** + * 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 createSankeyServices(context: DefaultSharedCoreModuleContext = EmptyFileSystem): { + shared: LangiumSharedCoreServices; + Sankey: SankeyServices; +} { + const shared: LangiumSharedCoreServices = inject( + createDefaultSharedCoreModule(context), + MermaidGeneratedSharedModule + ); + const Sankey: SankeyServices = inject( + createDefaultCoreModule({ shared }), + SankeyGeneratedModule, + SankeyModule + ); + shared.ServiceRegistry.register(Sankey); + return { shared, Sankey }; +} diff --git a/packages/parser/src/language/sankey/parser.ts b/packages/parser/src/language/sankey/parser.ts new file mode 100644 index 0000000000..a621f1a14e --- /dev/null +++ b/packages/parser/src/language/sankey/parser.ts @@ -0,0 +1,30 @@ +import type { AstNode, LangiumParser, ParseResult } from 'langium'; +import { createLangiumParser } from 'langium'; + +import type { SankeyServices } from './module.js'; +import type { SankeyLink } from '../generated/ast.js'; +import { isSankey } from '../generated/ast.js'; + +export function createSankeyParser(services: SankeyServices): LangiumParser { + const parser: LangiumParser = createLangiumParser(services); + const parse = parser.parse.bind(parser); + parser.parse = (input: string): ParseResult => { + const parseResult: ParseResult = parse(input); + + if (isSankey(parseResult.value)) { + const nodes: Set = new Set(); + parseResult.value.links.forEach((link: SankeyLink) => { + if (!nodes.has(link.source)) { + nodes.add(link.source); + } + if (!nodes.has(link.target)) { + nodes.add(link.target); + } + }); + parseResult.value.nodes = [...nodes]; + } + + return parseResult; + }; + return parser; +} diff --git a/packages/parser/src/language/sankey/sankey.langium b/packages/parser/src/language/sankey/sankey.langium new file mode 100644 index 0000000000..a8af75449c --- /dev/null +++ b/packages/parser/src/language/sankey/sankey.langium @@ -0,0 +1,26 @@ +grammar Sankey +import "../common/common"; + +interface Sankey extends Common { + links: SankeyLink[]; + nodes: string[]; +} + +entry Sankey returns Sankey: + NEWLINE* + "sankey-beta" + ( + NEWLINE* TitleAndAccessibilities links+=SankeyLink+ + | NEWLINE+ links+=SankeyLink+ + ) +; + +SankeyLink: + source=SANKEY_LINK_NODE "," target=SANKEY_LINK_NODE "," value=SANKEY_LINK_VALUE EOL +; + +/** + * @greedy This ensures that this rule is put at the bottom of the list of tokens. + */ +terminal SANKEY_LINK_NODE: /sankey-link-node/; +terminal SANKEY_LINK_VALUE returns number: /"(0|[1-9][0-9]*)(\.[0-9]+)?"|[\t ]*(0|[1-9][0-9]*)(\.[0-9]+)?/; diff --git a/packages/parser/src/language/sankey/tokenBuilder.ts b/packages/parser/src/language/sankey/tokenBuilder.ts new file mode 100644 index 0000000000..1df399e068 --- /dev/null +++ b/packages/parser/src/language/sankey/tokenBuilder.ts @@ -0,0 +1,26 @@ +import type { GrammarAST, Stream } from 'langium'; +import type { TokenType } from 'chevrotain'; + +import { AbstractMermaidTokenBuilder } from '../common/index.js'; +import { sankeyLinkNodeRegex } from './matcher.js'; +import type { SankeyServices } from './module.js'; + +export class SankeyTokenBuilder extends AbstractMermaidTokenBuilder { + public constructor(services: SankeyServices) { + super(['sankey-beta'], services); + } + + protected override buildTerminalTokens(rules: Stream): TokenType[] { + const tokenTypes: TokenType[] = super.buildTerminalTokens(rules); + tokenTypes.forEach((tokenType: TokenType): void => { + switch (tokenType.name) { + case 'SANKEY_LINK_NODE': { + tokenType.LINE_BREAKS = false; + tokenType.PATTERN = super.regexPatternFunction(sankeyLinkNodeRegex); + break; + } + } + }); + return tokenTypes; + } +} diff --git a/packages/parser/src/language/sankey/valueConverter.ts b/packages/parser/src/language/sankey/valueConverter.ts new file mode 100644 index 0000000000..c722d3f04f --- /dev/null +++ b/packages/parser/src/language/sankey/valueConverter.ts @@ -0,0 +1,43 @@ +import type { CstNode, GrammarAST, ValueType } from 'langium'; + +import { AbstractMermaidValueConverter } from '../common/valueConverter.js'; +import { sankeyLinkNodeRegex, sankeyLinkValueRegex } from './matcher.js'; + +const rulesRegexes: Record = { + SANKEY_LINK_NODE: sankeyLinkNodeRegex, + SANKEY_LINK_VALUE: sankeyLinkValueRegex, +}; + +export class SankeyValueConverter extends AbstractMermaidValueConverter { + protected runCustomConverter( + rule: GrammarAST.AbstractRule, + input: string, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _cstNode: CstNode + ): ValueType | undefined { + const regex: RegExp | undefined = rulesRegexes[rule.name]; + if (regex === undefined) { + return undefined; + } + const match = regex.exec(input); + if (match === null) { + return undefined; + } + + // source and target with double quote and value + if (match[1] !== undefined) { + if (rule.name === 'SANKEY_LINK_VALUE') { + return Number(match[1].replaceAll('"', '')); + } + return match[1] + .replaceAll('""', '"') + .trim() + .replaceAll(/[\t ]{2,}/gm, ' '); + } + // source and target without double quote + if (match[2] !== undefined) { + return match[2].trim().replaceAll(/[\t ]{2,}/gm, ' '); + } + return undefined; + } +} diff --git a/packages/parser/src/parse.ts b/packages/parser/src/parse.ts index 577a1cea6f..3fcb82a267 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, Packet, Pie } from './index.js'; +import type { Info, Packet, Pie, Sankey } from './index.js'; -export type DiagramAST = Info | Packet | Pie; +export type DiagramAST = Info | Packet | Pie | Sankey; const parsers: Record = {}; const initializers = { @@ -21,11 +21,17 @@ const initializers = { const parser = createPieServices().Pie.parser.LangiumParser; parsers['pie'] = parser; }, + sankey: async () => { + const { createSankeyServices } = await import('./language/sankey/index.js'); + const parser = createSankeyServices().Sankey.parser.LangiumParser; + parsers['sankey'] = parser; + }, } as const; export async function parse(diagramType: 'info', text: string): Promise; export async function parse(diagramType: 'packet', text: string): Promise; export async function parse(diagramType: 'pie', text: string): Promise; +export async function parse(diagramType: 'sankey', text: string): Promise; export async function parse( diagramType: keyof typeof initializers, text: string diff --git a/packages/parser/tests/greedy.test.ts b/packages/parser/tests/greedy.test.ts new file mode 100644 index 0000000000..9b182cea6c --- /dev/null +++ b/packages/parser/tests/greedy.test.ts @@ -0,0 +1,64 @@ +import { createLangiumGrammarServices, createServicesForGrammar } from 'langium/grammar'; +import { describe, expect, it } from 'vitest'; +import { CommonTokenBuilder } from '../src/index.js'; +import type { TokenType } from 'chevrotain'; +import { EmptyFileSystem } from 'langium'; + +const grammarServices = createLangiumGrammarServices(EmptyFileSystem).grammar; + +async function createServicesFromGrammar(grammar: string) { + const services = await createServicesForGrammar({ + grammar, + module: { + parser: { + TokenBuilder: () => new CommonTokenBuilder([], grammarServices), + }, + }, + }); + + return { + grammar: services.Grammar, + tokenBuilder: services.parser.TokenBuilder, + }; +} + +describe('CommonTokenBuilder', async () => { + it('should handle grammar with one greedy rule', async () => { + const grammar = ` + grammar TestGrammar + entry Rule: + 'rule' value=(LessGreedy | Greedy); + + hidden terminal WS: /\\s+/; + + /** @greedy */ + terminal Greedy: /[\\w\\d]+/; + terminal LessGreedy: /[\\w]+/; + `; + const services = await createServicesFromGrammar(grammar); + const tokens = services.tokenBuilder.buildTokens(services.grammar) as TokenType[]; + + expect(tokens[2].name).toBe('LessGreedy'); + expect(tokens[3].name).toBe('Greedy'); + }); + + it('should handle grammar with more than one greedy rule', async () => { + const grammar = ` + grammar TestGrammar + entry Rule: + 'rule' value=(LessGreedy | Greedy); + + hidden terminal WS: /\\s+/; + + /** @greedy */ + terminal LessGreedy: /[\\w]+/; + /** @greedy */ + terminal Greedy: /[\\w\\d]+/; + `; + const services = await createServicesFromGrammar(grammar); + const tokens = services.tokenBuilder.buildTokens(services.grammar) as TokenType[]; + + expect(tokens[2].name).toBe('LessGreedy'); + expect(tokens[3].name).toBe('Greedy'); + }); +}); diff --git a/packages/parser/tests/sankey.test.ts b/packages/parser/tests/sankey.test.ts new file mode 100644 index 0000000000..aba0cd30ab --- /dev/null +++ b/packages/parser/tests/sankey.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, it } from 'vitest'; + +import { Sankey } from '../src/language/index.js'; +import { expectNoErrorsOrAlternatives, sankeyParse as parse } from './test-util.js'; + +describe('sankey', () => { + it('should handle simple sankey', () => { + const context = `sankey-beta + sourceNode, targetNode, 10`; + const result = parse(context); + expectNoErrorsOrAlternatives(result); + + const value = result.value; + expect(value.$type).toBe(Sankey); + expect(value.links[0].source).toBe('sourceNode'); + expect(value.links[0].target).toBe('targetNode'); + expect(value.links[0].value).toBe(10); + + expect(value.nodes).toStrictEqual(['sourceNode', 'targetNode']); + }); + + it('should handle sankey with double quotes', () => { + const context = `sankey-beta +"source node, with comma","target node, with comma","10.00" + `; + const result = parse(context); + expectNoErrorsOrAlternatives(result); + + const value = result.value; + expect(value.$type).toBe(Sankey); + expect(value.links[0].source).toBe('source node, with comma'); + expect(value.links[0].target).toBe('target node, with comma'); + expect(value.links[0].value).toBe(10); + + expect(value.nodes).toStrictEqual(['source node, with comma', 'target node, with comma']); + }); + + it('should handle sankey with double quotes with newline and doable quotes', () => { + const context = `sankey-beta +"source node +"" double quotes","target node +"" double quotes","10.00" + `; + const result = parse(context); + expectNoErrorsOrAlternatives(result); + + const value = result.value; + expect(value.$type).toBe(Sankey); + expect(value.links[0].source).toBe(`source node +" double quotes`); + expect(value.links[0].target).toBe(`target node +" double quotes`); + expect(value.links[0].value).toBe(10); + + expect(value.nodes).toStrictEqual([ + `source node +" double quotes`, + `target node +" double quotes`, + ]); + }); + + it('should handle sankey with more than one link', () => { + const context = `sankey-beta + source node 1, target node 1, 10 + source node 2, target node 2, 50`; + const result = parse(context); + expectNoErrorsOrAlternatives(result); + + const value = result.value; + expect(value.$type).toBe(Sankey); + expect(value.links[0].source).toBe('source node 1'); + expect(value.links[0].target).toBe('target node 1'); + expect(value.links[0].value).toBe(10); + + expect(value.links[1].source).toBe('source node 2'); + expect(value.links[1].target).toBe('target node 2'); + expect(value.links[1].value).toBe(50); + + expect(value.nodes).toStrictEqual([ + 'source node 1', + 'target node 1', + 'source node 2', + 'target node 2', + ]); + }); + + it('should handle sankey with duplicate nodes', () => { + const context = `sankey-beta + source, target, 10 + target, another target, 20`; + const result = parse(context); + expectNoErrorsOrAlternatives(result); + + const value = result.value; + expect(value.$type).toBe(Sankey); + expect(value.links[0].source).toBe('source'); + expect(value.links[0].target).toBe('target'); + expect(value.links[0].value).toBe(10); + + expect(value.links[1].source).toBe('target'); + expect(value.links[1].target).toBe('another target'); + expect(value.links[1].value).toBe(20); + + expect(value.nodes).toStrictEqual(['source', 'target', 'another target']); + }); + + describe('title and accessibilities', () => { + it('should handle title definition', () => { + const context = `sankey-beta title awesome title + source, target, 10`; + const result = parse(context); + expectNoErrorsOrAlternatives(result); + + const value = result.value; + expect(value.$type).toBe(Sankey); + expect(value.links[0].source).toBe('source'); + expect(value.links[0].target).toBe('target'); + expect(value.links[0].value).toBe(10); + }); + + it('should handle accTitle definition', () => { + const context = `sankey-beta accTitle: awesome accTitle + source, target, 10`; + const result = parse(context); + expectNoErrorsOrAlternatives(result); + + const value = result.value; + expect(value.$type).toBe(Sankey); + expect(value.links[0].source).toBe('source'); + expect(value.links[0].target).toBe('target'); + expect(value.links[0].value).toBe(10); + }); + + it('should handle single line accDescr definition', () => { + const context = `sankey-beta accDescr: awesome accDescr + source, target, 10`; + const result = parse(context); + expectNoErrorsOrAlternatives(result); + + const value = result.value; + expect(value.$type).toBe(Sankey); + expect(value.links[0].source).toBe('source'); + expect(value.links[0].target).toBe('target'); + expect(value.links[0].value).toBe(10); + }); + + it('should handle multi line accDescr definition', () => { + const context = `sankey-beta accDescr { + awesome accDescr + } + source, target, 10`; + const result = parse(context); + expectNoErrorsOrAlternatives(result); + + const value = result.value; + expect(value.$type).toBe(Sankey); + expect(value.links[0].source).toBe('source'); + expect(value.links[0].target).toBe('target'); + expect(value.links[0].value).toBe(10); + }); + + it('should handle title and accessibilities definition', () => { + const context = `sankey-beta title awesome title + accTitle: awesome accTitle + accDescr: awesome accDescr + source, target, 10`; + const result = parse(context); + expectNoErrorsOrAlternatives(result); + + const value = result.value; + expect(value.$type).toBe(Sankey); + expect(value.links[0].source).toBe('source'); + expect(value.links[0].target).toBe('target'); + expect(value.links[0].value).toBe(10); + }); + }); +}); diff --git a/packages/parser/tests/test-util.ts b/packages/parser/tests/test-util.ts index 9bdec348a1..4dad2ac9cd 100644 --- a/packages/parser/tests/test-util.ts +++ b/packages/parser/tests/test-util.ts @@ -1,7 +1,18 @@ import type { LangiumParser, ParseResult } from 'langium'; import { expect, vi } from 'vitest'; -import type { Info, InfoServices, Pie, PieServices } from '../src/language/index.js'; -import { createInfoServices, createPieServices } from '../src/language/index.js'; +import type { + Info, + InfoServices, + Pie, + PieServices, + Sankey, + SankeyServices, +} from '../src/language/index.js'; +import { + createInfoServices, + createPieServices, + createSankeyServices, +} from '../src/language/index.js'; const consoleMock = vi.spyOn(console, 'log').mockImplementation(() => undefined); @@ -40,3 +51,13 @@ export function createPieTestServices() { return { services: pieServices, parse }; } export const pieParse = createPieTestServices().parse; + +export const sankeyServices: SankeyServices = createSankeyServices().Sankey; +const sankeyParser: LangiumParser = sankeyServices.parser.LangiumParser; +export function createSankeyTestServices() { + const parse = (input: string) => { + return sankeyParser.parse(input); + }; + return { services: sankeyServices, parse }; +} +export const sankeyParse = createSankeyTestServices().parse;