diff --git a/Jenkinsfile b/Jenkinsfile index 81d532c..12d359d 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -5,7 +5,7 @@ pipeline { VERSION = VersionNumber([ versionNumberString: '${BUILDS_ALL_TIME}', - versionPrefix: '1.0.', + versionPrefix: '1.1.', worstResultForIncrement: 'SUCCESS' ]) } diff --git a/lib/GraphLayouts.ts b/lib/GraphLayouts.ts deleted file mode 100644 index 29ae2ba..0000000 --- a/lib/GraphLayouts.ts +++ /dev/null @@ -1,10 +0,0 @@ -export enum GraphLayouts { - neato = 'neato', - dot = 'dot', - circo = 'circo', - fdp = 'fdp', - sfdp = 'sfdp', - osage = 'osage', - twopi = 'twopi', - patchwork = 'patchwork' -} diff --git a/lib/Helper.ts b/lib/Helper.ts new file mode 100644 index 0000000..987a695 --- /dev/null +++ b/lib/Helper.ts @@ -0,0 +1,10 @@ +function validate_name (name: string): string { + const new_name = name + .replace (/[^a-z0-9]/giu, '') + .replace (/^[0-9]+/iu, ''); + if (new_name === '') + throw new Error (`invalid node name ${name}`); + return new_name; +} + +export { validate_name }; diff --git a/lib/Styles.ts b/lib/Styles.ts deleted file mode 100644 index 262f653..0000000 --- a/lib/Styles.ts +++ /dev/null @@ -1,33 +0,0 @@ -enum EdgeStyles { - default = '', - solid = 'solid', - dashed = 'dashed', - dotted='dotted', - bold='bold' -} - -enum NodeStyles { - default = '', - solid='solid', - dashed='dashed', - dotted='dotted', - bold='bold', - rounded='rounded', - diagonals='diagonals', - filled='filled', - striped='striped', - wedged='wedged', - invisible='invis' -} - -enum GraphStyles { - solid = 'solid', - dashed = 'dashed', - dotted = 'dotted', - bold = 'bold', - rounded = 'rounded', - filled = 'filled', - striped = 'striped' -} - -export { EdgeStyles, NodeStyles, GraphStyles }; diff --git a/lib/Color.ts b/lib/classes/Color.ts similarity index 100% rename from lib/Color.ts rename to lib/classes/Color.ts diff --git a/lib/Edge.ts b/lib/classes/Edge.ts similarity index 95% rename from lib/Edge.ts rename to lib/classes/Edge.ts index 3709f04..e63c0af 100644 --- a/lib/Edge.ts +++ b/lib/classes/Edge.ts @@ -1,4 +1,4 @@ -import { EdgeStyles } from './Styles'; +import { EdgeStyles } from '../enums/Styles'; import { Color } from './Color'; export class Edge { diff --git a/lib/Element.ts b/lib/classes/Element.ts similarity index 76% rename from lib/Element.ts rename to lib/classes/Element.ts index 275b8e5..774bf0e 100644 --- a/lib/Element.ts +++ b/lib/classes/Element.ts @@ -1,3 +1,5 @@ +import { validate_name } from '../Helper'; + export class Element { private _name = ''; protected parent_name: string; @@ -13,11 +15,7 @@ export class Element { } public set name (val: string) { - const new_name = val.replace (/[^a-z0-9]/giu, '') - .replace (/^[0-9]+/iu, ''); - if (new_name === '') - throw new Error (`invalid node name ${val}`); - this._name = new_name; + this._name = validate_name (val); } public get parent (): string { diff --git a/lib/Graph.ts b/lib/classes/Graph.ts similarity index 96% rename from lib/Graph.ts rename to lib/classes/Graph.ts index 24beed0..a2fbb80 100644 --- a/lib/Graph.ts +++ b/lib/classes/Graph.ts @@ -1,9 +1,9 @@ +import { GraphStyles, NodeStyles } from '../enums/Styles'; +import { GraphLayouts } from '../enums/GraphLayouts'; import { Element } from './Element'; import { Edge } from './Edge'; import { Node } from './Node'; -import { GraphStyles, NodeStyles } from './Styles'; import { Color } from './Color'; -import { GraphLayouts } from './GraphLayouts'; interface NodeOptions { name: string; diff --git a/lib/classes/GraphStream.ts b/lib/classes/GraphStream.ts new file mode 100644 index 0000000..edb6ad7 --- /dev/null +++ b/lib/classes/GraphStream.ts @@ -0,0 +1,160 @@ +/* eslint-disable line-comment-position */ +/* eslint-disable no-inline-comments */ +import { Transform } from 'stream'; +import { GraphStreamJSON } from '../interfaces/GraphStreamJSON'; +import { GraphStreamCommand } from '../enums/GraphStreamCommand'; + +interface Stringable { + // eslint-disable-next-line @typescript-eslint/naming-convention + toString(): string; +} + +export class GraphStream extends Transform { + private _path: string[] = []; + private _state = ''; + private _directional = false; + + public get path (): string { + return this._path.join ('_'); + } + + private get level (): string { + return ' '.repeat (this._path.length); + } + + private expect_state (instr: GraphStreamCommand): void { + const states = []; + if ([ + 'cug', + 'cdg' + ].includes (instr)) + states.push (''); + + if ([ + 'eg', + 'cn', + 'csg', + 'ce' + ].includes (instr)) + states.push ('en', 'at', 'eg', 'cug', 'cdg', 'csg', 'ce'); + + switch (instr) { + case 'en': + states.push ('cn', 'at'); + break; + case 'at': + states.push ('cn', 'cug', 'cdg', 'csg'); + break; + default: + break; + } + if (!states.includes (this._state)) { + throw new Error (`invalid state to execute command ${instr} + expected: ${states.join (', ')}`); + } + } + + // eslint-disable-next-line max-len + // eslint-disable-next-line @typescript-eslint/naming-convention, complexity, max-lines-per-function + public _transform ( + chunk: string, + encoding: string, + callback: ((error?: Error) => unknown) + ): void { + const instr = JSON.parse (chunk) as GraphStreamJSON; + this.expect_state (instr.type); + switch (instr.type) { + case 'cug': // create unordered graph + this._path.push (instr.args[0]); + this.push (`graph ${this.path} {\n`); + this._directional = false; + break; + case 'cdg': // create directional graph + this._path.push (instr.args[0]); + this.push (`digraph ${this.path} {\n`); + this._directional = true; + break; + case 'csg': // create subgraph + this.push ( + `${this.level}subgraph cluster_${this.path}_${instr.args[0]} {\n` + ); + this._path.push (instr.args[0]); + break; + case 'eg': // end graph + this._path.pop (); + this.push (`${this.level}}\n\n`); + break; + case 'cn': // create node + this.push (`${this.level}${this.path}_${instr.args[0]}`); + break; + case 'en': // end node + this.push ('\n'); + break; + case 'at': // add attributes + if (this._state === 'cn') { + this.push (` [${instr.args.join (', ')}]`); + } + else { + this.push (`${this.level}${ + instr.args.join (`\n${this.level}`) + .replace (/"/gu, '') + }\n\n`); + } + break; + case 'ce': + this.push (`${this.level}${ + instr.args[0] + } -${this._directional ? '>' : '-'} ${instr.args[1]}\n`); + break; + default: + break; + } + this._state = instr.type; + callback (); + } + + public write ( + instr: GraphStreamJSON + ): boolean { + return super.write (JSON.stringify (instr), 'utf-8'); + } + + public create_node (name: string): boolean { + return this.write ({ type: 'cn', args: [ name ] }); + } + + public end_node (): boolean { + return this.write ({ type: 'en', args: [] }); + } + + public create_graph (name: string, type: 'u'|'d'|'s' = 's'): boolean { + const instr_type = `c${type}g` as GraphStreamCommand; + return this.write ({ type: instr_type, args: [ name ] }); + } + + public end_graph (): boolean { + return this.write ({ type: 'eg', args: [] }); + } + + public attributes (attrs: Record): boolean { + const solved = []; + for (const attr of Object.keys (attrs)) { + const val = attrs[attr].toString (); + if ((/\n/u).test (val)) + solved.push (`${attr} = <${val}>`); + else + solved.push (`${attr} = "${val}"`); + } + return this.write ({ type: 'at', args: solved }); + } + + public create_edge (origin: string, target: string): boolean { + return this.write ({ + type: 'ce', + args: [ + origin, + target + ] + }); + } +} diff --git a/lib/Node.ts b/lib/classes/Node.ts similarity index 97% rename from lib/Node.ts rename to lib/classes/Node.ts index 0f72262..3353ca0 100644 --- a/lib/Node.ts +++ b/lib/classes/Node.ts @@ -1,5 +1,5 @@ +import { NodeStyles } from '../enums/Styles'; import { Element } from './Element'; -import { NodeStyles } from './Styles'; import { Color } from './Color'; export class Node extends Element { diff --git a/lib/enums/GraphLayouts.ts b/lib/enums/GraphLayouts.ts new file mode 100644 index 0000000..4ca366c --- /dev/null +++ b/lib/enums/GraphLayouts.ts @@ -0,0 +1,9 @@ +export type GraphLayouts = + 'neato' + | 'dot' + | 'circo' + | 'fdp' + | 'sfdp' + | 'osage' + | 'twopi' + | 'patchwork' diff --git a/lib/enums/GraphStreamCommand.ts b/lib/enums/GraphStreamCommand.ts new file mode 100644 index 0000000..d5e7eab --- /dev/null +++ b/lib/enums/GraphStreamCommand.ts @@ -0,0 +1,11 @@ +/* eslint-disable line-comment-position */ +/* eslint-disable no-inline-comments */ +export type GraphStreamCommand = + 'cn'| // create node + 'en'| // end node + 'cug'| // create unordered graph + 'cdg'| // create directional graph + 'csg'| // create subgraph + 'eg'| // end graph + 'at'| // add attributes + 'ce' // create edge diff --git a/lib/enums/Styles.ts b/lib/enums/Styles.ts new file mode 100644 index 0000000..2166c09 --- /dev/null +++ b/lib/enums/Styles.ts @@ -0,0 +1,32 @@ +type EdgeStyles = + '' + |'solid' + |'dashed' + |'dotted' + |'bold' + + +type NodeStyles = + '' + |'solid' + |'dashed' + |'dotted' + |'bold' + |'rounded' + |'diagonals' + |'filled' + |'striped' + |'wedged' + |'invis' + + +type GraphStyles = + 'solid' + | 'dashed' + | 'dotted' + | 'bold' + | 'rounded' + | 'filled' + | 'striped' + +export { EdgeStyles, NodeStyles, GraphStyles }; diff --git a/lib/index.ts b/lib/index.ts index 279ac57..b228c00 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -1,8 +1,12 @@ -export { Graph } from './Graph'; -export { Node } from './Node'; -export { Element } from './Element'; -export { Edge } from './Edge'; -export { Color } from './Color'; -export { EdgeStyles, NodeStyles, GraphStyles } from './Styles'; -export { GraphLayouts } from './GraphLayouts'; -export { Graphable } from './Graphable'; +import { Graph } from './classes/Graph'; + +export * from './classes/Color'; +export * from './classes/Edge'; +export * from './classes/Element'; +export { Graph }; +export * from './classes/GraphStream'; +export * from './classes/Node'; +export * from './enums/GraphLayouts'; +export * from './enums/Styles'; +export * from './interfaces/Graphable'; +export default Graph; diff --git a/lib/interfaces/GraphStreamJSON.ts b/lib/interfaces/GraphStreamJSON.ts new file mode 100644 index 0000000..b5f3499 --- /dev/null +++ b/lib/interfaces/GraphStreamJSON.ts @@ -0,0 +1,6 @@ +import { GraphStreamCommand } from '../enums/GraphStreamCommand'; + +export interface GraphStreamJSON { + type: GraphStreamCommand; + args: string[]; +} diff --git a/lib/Graphable.ts b/lib/interfaces/Graphable.ts similarity index 66% rename from lib/Graphable.ts rename to lib/interfaces/Graphable.ts index fc4f3b5..8f58e43 100644 --- a/lib/Graphable.ts +++ b/lib/interfaces/Graphable.ts @@ -1,4 +1,4 @@ -import { Graph } from './Graph'; +import { Graph } from '../classes/Graph'; export interface Graphable { to_graph(g: Graph, ...args: unknown[]): unknown; diff --git a/test/Edge.ts b/test/Edge.ts index faa2610..d921842 100644 --- a/test/Edge.ts +++ b/test/Edge.ts @@ -1,11 +1,11 @@ import test from 'ava'; -import { Edge, Color, EdgeStyles } from '../lib'; +import { Edge, Color } from '../lib'; test ('serialize', (t) => { const e = new Edge ('foo', 'bar', false); e.color = Color.white; - e.style = EdgeStyles.dashed; + e.style = 'dashed'; const serialized = e.toString (); t.is (serialized, 'foo -- bar [style="dashed",color="#ffffff"]'); @@ -15,7 +15,7 @@ test ('serialize directional', (t) => { const e = new Edge ('foo', 'bar', true); e.color = Color.white; - e.style = EdgeStyles.dashed; + e.style = 'dashed'; const serialized = e.toString (); t.is (serialized, 'foo -> bar [style="dashed",color="#ffffff"]'); diff --git a/test/Graph.ts b/test/Graph.ts index 4208017..d200232 100644 --- a/test/Graph.ts +++ b/test/Graph.ts @@ -1,5 +1,5 @@ import test from 'ava'; -import { Graph, GraphStyles, Color, NodeStyles, GraphLayouts } from '../lib'; +import { Graph, Color } from '../lib'; const result = `digraph foo { subgraph cluster_foo_baz { @@ -56,23 +56,23 @@ test ('serialize', (t) => { graph.add_node ('asd'); graph.add_node ((n) => { n.name = 'test'; - n.style = NodeStyles.bold; + n.style = 'bold'; n.color = Color.gray; }); // eslint-disable-next-line no-shadow graph.add_graph ((g) => { - g.style = GraphStyles.dotted; + g.style = 'dotted'; g.color = Color.gray; g.name = 'nested'; // eslint-disable-next-line no-shadow, max-nested-callbacks g.add_graph ((g) => { - g.style = GraphStyles.dotted; + g.style = 'dotted'; g.color = Color.gray; }); }); - graph.style = GraphStyles.bold; + graph.style = 'bold'; graph.color = Color.red; }); @@ -111,11 +111,11 @@ test ('non directional', (t) => { test ('attributes', (t) => { const g = new Graph ('attr'); - g.layout = GraphLayouts.neato; + g.layout = 'neato'; g.overlap = false; g.splines = true; g.color = Color.black; - g.style = GraphStyles.bold; + g.style = 'bold'; t.is (g.toString (), attributes); }); diff --git a/test/Node.ts b/test/Node.ts index 38887c7..32f70de 100644 --- a/test/Node.ts +++ b/test/Node.ts @@ -1,5 +1,5 @@ import test from 'ava'; -import { NodeStyles, Node, Color } from '../lib'; +import { Node, Color } from '../lib'; const serialized_simple = 'bar_foo [label="baz", style="dashed", color="#00ff00"]'; @@ -13,7 +13,7 @@ test ('serialize simple', (t) => { const g = new Node ('foo', 'bar', 'baz'); g.color = Color.green; - g.style = NodeStyles.dashed; + g.style = 'dashed'; const serialized = g.toString (); @@ -25,7 +25,7 @@ test ('serialize table', (t) => { const g = new Node ('foo', 'bar', 'baz'); g.color = Color.green; - g.style = NodeStyles.invisible; + g.style = 'invis'; g.table_contents = [ [ diff --git a/test/Stream.ts b/test/Stream.ts new file mode 100644 index 0000000..ee6ab55 --- /dev/null +++ b/test/Stream.ts @@ -0,0 +1,89 @@ +import test from 'ava'; +import { GraphStream, Color } from '../lib/index'; + +const simple = `graph foo { +} + +`; + +const complex = `digraph foo { + subgraph cluster_foo_baz { + color = #ff0000 + style = bold + + subgraph cluster_foo_baz_nested { + color = #808080 + style = dotted + + subgraph cluster_foo_baz_nested_unnamed { + color = #808080 + style = dotted + + } + + } + + foo_baz_asd [label = "asd"] + foo_baz_test [style = "bold", color = "#808080"] + } + + foo_baz [label = "baz"] + foo_foo [label = "foo"] + foo_foo -> foo_baz +} + +`; + +test ('stream graph', (t) => new Promise ((resolve) => { + let output = ''; + const stream = (new GraphStream); + stream.on ('data', (data) => { + output += data; + }); + stream.on ('end', () => { + t.is (output, simple); + resolve (); + }); + stream.create_graph ('foo', 'u'); + stream.end_graph (); + stream.end (); +})); + +// eslint-disable-next-line max-statements +test ('complex stream graph', (t) => new Promise ((resolve) => { + let output = ''; + const stream = (new GraphStream); + stream.on ('data', (data) => { + output += data; + }); + stream.on ('end', () => { + t.is (output, complex); + t.log (output); + resolve (); + }); + stream.create_graph ('foo', 'd'); + stream.create_graph ('baz'); + stream.attributes ({ color: Color.red, style: 'bold' }); + stream.create_graph ('nested'); + stream.attributes ({ color: Color.gray, style: 'dotted' }); + stream.create_graph ('unnamed'); + stream.attributes ({ color: Color.gray, style: 'dotted' }); + stream.end_graph (); + stream.end_graph (); + stream.create_node ('asd'); + stream.attributes ({ label: 'asd' }); + stream.end_node (); + stream.create_node ('test'); + stream.attributes ({ style: 'bold', color: Color.gray }); + stream.end_node (); + stream.end_graph (); + stream.create_node ('baz'); + stream.attributes ({ label: 'baz' }); + stream.end_node (); + stream.create_node ('foo'); + stream.attributes ({ label: 'foo' }); + stream.end_node (); + stream.create_edge (`${stream.path}_foo`, `${stream.path}_baz`); + stream.end_graph (); + stream.end (); +})); diff --git a/yarn.lock b/yarn.lock index e6b16dc..4ca4d17 100644 --- a/yarn.lock +++ b/yarn.lock @@ -241,18 +241,18 @@ eslint-plugin-import "^2.20.1" "@sapphirecode/eslint-config-ts@^1.0.22": - version "1.0.38" - resolved "https://registry.yarnpkg.com/@sapphirecode/eslint-config-ts/-/eslint-config-ts-1.0.38.tgz#9c2c80be2b6bee6a0c8dc2a9d0504869d46f5882" - integrity sha512-sOzBGUmpc12/j5Z2opMTqpU/XztZOdRQy+/+0wLMLATs9PVHW55oprvWLDQoWttH763v2DwU3PNvoXILq1eHGw== + version "1.0.40" + resolved "https://registry.yarnpkg.com/@sapphirecode/eslint-config-ts/-/eslint-config-ts-1.0.40.tgz#e646383a933ff64d2604bd6ddd2f89e1aa7dfb1a" + integrity sha512-h0m5ohVZB8jKubT567gjykpkVN/ybVkbKNWT9q5GhltECVB9g/Y6Jh6E9Fqg1pTz+DE+iHqr7CO9+stVVLwc4w== dependencies: "@sapphirecode/eslint-config-es6" "^1.0.1" "@typescript-eslint/eslint-plugin" "^2.26.0" "@typescript-eslint/parser" "^2.26.0" "@sapphirecode/eslint-config@^2.0.1": - version "2.0.23" - resolved "https://registry.yarnpkg.com/@sapphirecode/eslint-config/-/eslint-config-2.0.23.tgz#1669742219dab81ac83914d8826d88f06492ba2c" - integrity sha512-GO5KDZ58z59raCUkX7CzlATkqGETsOts2S/kOoxNhZR6+rdXQqkrA8BI4/degzZiWnKYn3KkQhyYvXv7fXKkFg== + version "2.0.24" + resolved "https://registry.yarnpkg.com/@sapphirecode/eslint-config/-/eslint-config-2.0.24.tgz#e9a033767ce0ab1242dc3af700abb58b1c68ab65" + integrity sha512-Fk5opga9EEMT4mXhmcg2KVKV1bnB8JAQWklMQay7ROw3QK5oP7A0KIJMlamYhXmHgctjGjlEEuJeFi10pO5QuA== dependencies: eslint-plugin-node "^11.0.0" eslint-plugin-sort-requires-by-path "^1.0.2"