From 9db03f53444a2fef3d737bfaa7cc6227fc5fb75c Mon Sep 17 00:00:00 2001 From: Alex Malkevich Date: Sun, 25 Sep 2022 21:54:32 +0200 Subject: [PATCH] --wip-- [skip ci] --- angular.json | 7 +- .../src/lib/template/parser.spec.ts | 136 +++++++++++++ .../src/lib/template/parser.ts | 168 +++++++++++++++ .../src/lib/template/tokeniser.spec.ts | 159 +++++++++++++++ .../src/lib/template/tokeniser.ts | 191 ++++++++++++++++++ 5 files changed, 660 insertions(+), 1 deletion(-) create mode 100644 projects/ng-dynamic-component/src/lib/template/parser.spec.ts create mode 100644 projects/ng-dynamic-component/src/lib/template/parser.ts create mode 100644 projects/ng-dynamic-component/src/lib/template/tokeniser.spec.ts create mode 100644 projects/ng-dynamic-component/src/lib/template/tokeniser.ts diff --git a/angular.json b/angular.json index 2e9151a42..4225abf1c 100644 --- a/angular.json +++ b/angular.json @@ -18,7 +18,12 @@ }, "test": { "builder": "@angular-builders/jest:run", - "options": {} + "options": {}, + "configurations": { + "watch": { + "watch": true + } + } }, "lint": { "builder": "@angular-eslint/builder:lint", diff --git a/projects/ng-dynamic-component/src/lib/template/parser.spec.ts b/projects/ng-dynamic-component/src/lib/template/parser.spec.ts new file mode 100644 index 000000000..03c317e99 --- /dev/null +++ b/projects/ng-dynamic-component/src/lib/template/parser.spec.ts @@ -0,0 +1,136 @@ +import { TemplateParser } from './parser'; +import { TemplateTokeniser } from './tokeniser'; + +describe('TemplateParser', () => { + it('should parse IO object from tokens and component', async () => { + const component = { prop: 'val', handler: jest.fn() }; + const tokeniser = new TemplateTokeniser(); + const parser = new TemplateParser(tokeniser, component); + + const io = parser.getIo(); + + tokeniser.feed('[input]=prop (output)=handler($event)'); + + await expect(io).resolves.toMatchObject({ + '[input]': 'val', + '(output)': { + handler: expect.any(Function), + args: ['$event'], + }, + }); + + ((await io)['(output)'] as any).handler('mock-event'); + + expect(component.handler).toHaveBeenCalledWith('mock-event'); + }); + + describe('inputs', () => { + it('should parse plain input', async () => { + const component = { prop: 'val' }; + const tokeniser = new TemplateTokeniser(); + const parser = new TemplateParser(tokeniser, component); + + const io = parser.getIo(); + + tokeniser.feed('input=prop '); + + await expect(io).resolves.toMatchObject({ + input: 'val', + }); + }); + + it('should parse prop input', async () => { + const component = { prop: 'val' }; + const tokeniser = new TemplateTokeniser(); + const parser = new TemplateParser(tokeniser, component); + + const io = parser.getIo(); + + tokeniser.feed('[input]=prop '); + + await expect(io).resolves.toMatchObject({ + '[input]': 'val', + }); + }); + + it('should NOT parse input with quotes', async () => { + const component = { '"prop"': 'val' }; + const tokeniser = new TemplateTokeniser(); + const parser = new TemplateParser(tokeniser, component); + + const io = parser.getIo(); + + tokeniser.feed('[input]="prop" '); + + await expect(io).resolves.toMatchObject({ + '[input]': 'val', + }); + }); + }); + + describe('outputs', () => { + it('should parse output without args', async () => { + const component = { handler: jest.fn() }; + const tokeniser = new TemplateTokeniser(); + const parser = new TemplateParser(tokeniser, component); + + const io = parser.getIo(); + + tokeniser.feed('(output)=handler()'); + + await expect(io).resolves.toMatchObject({ + '(output)': { + handler: expect.any(Function), + args: [], + }, + }); + + ((await io)['(output)'] as any).handler(); + + expect(component.handler).toHaveBeenCalledWith(); + }); + + it('should parse output with one arg', async () => { + const component = { handler: jest.fn() }; + const tokeniser = new TemplateTokeniser(); + const parser = new TemplateParser(tokeniser, component); + + const io = parser.getIo(); + + tokeniser.feed('(output)=handler($event)'); + + await expect(io).resolves.toMatchObject({ + '(output)': { + handler: expect.any(Function), + args: ['$event'], + }, + }); + + ((await io)['(output)'] as any).handler('mock-event'); + + expect(component.handler).toHaveBeenCalledWith('mock-event'); + }); + + // TODO: Implement multiple args parsing + fit('should parse output with multiple args', async () => { + const component = { handler: jest.fn() }; + const tokeniser = new TemplateTokeniser(); + const parser = new TemplateParser(tokeniser, component); + + const io = parser.getIo(); + + tokeniser.feed('(output)=handler($event, prop)'); + + await expect(io).resolves.toMatchObject({ + '(output)': { + handler: expect.any(Function), + args: ['$event', 'prop'], + }, + }); + + ((await io)['(output)'] as any).handler('mock-event', 'val'); + + expect(component.handler).toHaveBeenCalledWith('mock-event', 'val'); + }); + }); +}); diff --git a/projects/ng-dynamic-component/src/lib/template/parser.ts b/projects/ng-dynamic-component/src/lib/template/parser.ts new file mode 100644 index 000000000..7e484fc37 --- /dev/null +++ b/projects/ng-dynamic-component/src/lib/template/parser.ts @@ -0,0 +1,168 @@ +import { OutputWithArgs } from '../io'; +import { + TemplateToken, + TemplateTokenAssignment, + TemplateTokenComma, + TemplateTokenInputPropBindingClose, + TemplateTokenInputPropBindingOpen, + TemplateTokeniser, + TemplateTokenOutputBindingClose, + TemplateTokenOutputBindingOpen, + TemplateTokenString, + TemplateTokenMap, +} from './tokeniser'; + +enum TemplateParserState { + Idle, + InInput, + InOutput, + InValue, + InArgs, +} + +export class TemplateParser { + constructor( + protected tokeniser: TemplateTokeniser, + protected component: Record, + protected tokenMap = TemplateTokenMap, + ) {} + + async getIo() { + const io: Record = {}; + + let state = TemplateParserState.Idle; + let lastState = TemplateParserState.Idle; + let ioBinding = ''; + + for await (const token of this.tokeniser) { + if (token instanceof TemplateTokenInputPropBindingOpen) { + if (state !== TemplateParserState.Idle) { + throw new TemplateParserError('Unexpected input binding', token); + } + + state = TemplateParserState.InInput; + ioBinding += this.tokenMap.InputPropBindingOpen; + continue; + } else if (token instanceof TemplateTokenInputPropBindingClose) { + if (state !== TemplateParserState.InInput) { + throw new TemplateParserError( + 'Unexpected input binding closing', + token, + ); + } + + ioBinding += this.tokenMap.InputPropBindingClose; + io[ioBinding] = undefined; + continue; + } else if (token instanceof TemplateTokenOutputBindingOpen) { + if ( + state !== TemplateParserState.Idle && + state !== TemplateParserState.InOutput + ) { + throw new TemplateParserError('Unexpected output binding', token); + } + + if (state === TemplateParserState.InOutput) { + state = TemplateParserState.InArgs; + } else { + state = TemplateParserState.InOutput; + ioBinding += this.tokenMap.OutputBindingOpen; + } + + continue; + } else if (token instanceof TemplateTokenOutputBindingClose) { + if ( + state !== TemplateParserState.InOutput && + state !== TemplateParserState.InArgs + ) { + throw new TemplateParserError( + 'Unexpected output binding closing', + token, + ); + } + + if (state === TemplateParserState.InArgs) { + state = TemplateParserState.Idle; + ioBinding = ''; + } else { + ioBinding += this.tokenMap.OutputBindingClose; + io[ioBinding] = undefined; + } + + continue; + } else if (token instanceof TemplateTokenAssignment) { + if ( + state !== TemplateParserState.InInput && + (state as any) !== TemplateParserState.InOutput + ) { + throw new TemplateParserError('Unexpected assignment', token); + } + + lastState = state; + state = TemplateParserState.InValue; + continue; + } else if (token instanceof TemplateTokenString) { + if ( + state === TemplateParserState.InInput || + state === TemplateParserState.InOutput + ) { + ioBinding += token.string; + continue; + } else if (state === TemplateParserState.InValue) { + if (lastState === TemplateParserState.InInput) { + delete io[ioBinding]; + Object.defineProperty(io, ioBinding, { + enumerable: true, + configurable: true, + get: () => this.component[token.string], + }); + state = lastState = TemplateParserState.Idle; + ioBinding = ''; + continue; + } else if (lastState === TemplateParserState.InOutput) { + io[ioBinding] = { + handler: this.component[token.string] as any, + args: [], + } as OutputWithArgs; + // state = TemplateParserState.InOutput; + // lastState = TemplateParserState.Idle; + continue; + } + + throw new TemplateParserError('Unexpected identifier', token); + } else if (state === TemplateParserState.InArgs) { + (io[ioBinding] as OutputWithArgs).args!.push(token.string); + continue; + } else if (state === TemplateParserState.Idle) { + state = TemplateParserState.InInput; + ioBinding = token.string; + io[ioBinding] = undefined; + continue; + } + + throw new TemplateParserError('Unexpected identifier', token); + } else if (token instanceof TemplateTokenComma) { + if (state !== TemplateParserState.InArgs) { + throw new TemplateParserError('Unexpected comma', token); + } + continue; + } + + throw new TemplateParserError('Unexpected token', token); + } + + return io; + } +} + +export class TemplateParserError extends Error { + constructor(reason: string, token: TemplateToken) { + super( + `${reason} at ${token.constructor.name}:${JSON.stringify( + token, + null, + 2, + )}`, + ); + } +} diff --git a/projects/ng-dynamic-component/src/lib/template/tokeniser.spec.ts b/projects/ng-dynamic-component/src/lib/template/tokeniser.spec.ts new file mode 100644 index 000000000..13aa7022f --- /dev/null +++ b/projects/ng-dynamic-component/src/lib/template/tokeniser.spec.ts @@ -0,0 +1,159 @@ +import { + TemplateTokenAssignment, + TemplateTokenString, + TemplateTokenInputPropBindingClose, + TemplateTokenInputPropBindingOpen, + TemplateTokeniser, + TemplateTokenOutputBindingClose, + TemplateTokenOutputBindingOpen, + TemplateToken, +} from './tokeniser'; + +describe('TemplateTokeniser', () => { + it('should produce no tokens without template', async () => { + const tokeniser = new TemplateTokeniser(); + + await expect(tokeniser.getAll()).resolves.toEqual([]); + }); + + it('should produce no tokens from empty template', async () => { + const tokeniser = new TemplateTokeniser(); + + tokeniser.feed(''); + + await expect(tokeniser.getAll()).resolves.toEqual([]); + }); + + it('should produce tokens from template', async () => { + const tokeniser = new TemplateTokeniser(); + + tokeniser.feed('[input]=prop (out'); + tokeniser.feed('put)=handler()'); + + await expect(tokeniser.getAll()).resolves.toEqual([ + new TemplateTokenInputPropBindingOpen(0, 1), + new TemplateTokenString('input', 1, 6), + new TemplateTokenInputPropBindingClose(6, 7), + new TemplateTokenAssignment(7, 8), + new TemplateTokenString('prop', 8, 12), + new TemplateTokenOutputBindingOpen(13, 14), + new TemplateTokenString('output', 14, 20), + new TemplateTokenOutputBindingClose(20, 21), + new TemplateTokenAssignment(21, 22), + new TemplateTokenString('handler', 22, 29), + new TemplateTokenOutputBindingOpen(29, 30), + new TemplateTokenOutputBindingClose(30, 31), + ]); + }); + + it('should produce tokens from template stream', async () => { + const tokeniser = new TemplateTokeniser(); + const stream = new ControlledStream(); + + tokeniser.feed(stream); + + const tokenStream = tokeniser.getStream(); + + let actualTokens: Promise>[] = []; + let expectedTokens: IteratorResult[] = []; + + function collectNextToken(expectedToken: TemplateToken | null) { + expectedTokens.push({ + value: expectedToken ?? undefined, + done: !expectedToken, + } as IteratorResult); + actualTokens.push(tokenStream.next()); + } + + collectNextToken(new TemplateTokenInputPropBindingOpen(0, 1)); + collectNextToken(new TemplateTokenString('input', 1, 6)); + collectNextToken(new TemplateTokenInputPropBindingClose(6, 7)); + collectNextToken(new TemplateTokenAssignment(7, 8)); + collectNextToken(new TemplateTokenString('prop', 8, 12)); + collectNextToken(new TemplateTokenOutputBindingOpen(13, 14)); + + await stream.flushBuffer(['[input]=prop', ' (out']); + + await expect(Promise.all(actualTokens)).resolves.toEqual(expectedTokens); + + actualTokens = []; + expectedTokens = []; + + collectNextToken(new TemplateTokenString('output', 14, 20)); + collectNextToken(new TemplateTokenOutputBindingClose(20, 21)); + collectNextToken(new TemplateTokenAssignment(21, 22)); + collectNextToken(new TemplateTokenString('handler', 22, 29)); + collectNextToken(new TemplateTokenOutputBindingOpen(29, 30)); + collectNextToken(new TemplateTokenOutputBindingClose(30, 31)); + collectNextToken(null); + + await stream.flushBuffer(['put)=handler()', null]); + + await expect(Promise.all(actualTokens)).resolves.toEqual(expectedTokens); + }); +}); + +class ControlledStream implements AsyncIterable { + protected finished = false; + protected bufferPromise?: Promise<(T | null)[]>; + protected bufferFlushedPromise?: Promise; + protected _flushBuffer = (buffer: (T | null)[]) => Promise.resolve(); + protected bufferFlushed = () => {}; + + async *[Symbol.asyncIterator](): AsyncIterableIterator { + yield* this.getStream(); + } + + /** + * Flushes the buffer and resolves once buffer has been drained + * by the tokenizer and controls are ready for next setup + * `null` indicates the end of the stream + */ + flushBuffer(buffer: (T | null)[]): Promise { + return this._flushBuffer(buffer); + } + + async *getStream(): AsyncGenerator { + this.resetControls(); + + while (!this.finished) { + const buf = await this.bufferPromise!; + let i = 0; + + for (const template of buf) { + // Final yield will block this function + // so we need to schedule `bufferFlushed` call + // when we are on the last item in current buffer + // and reset controls before `bufferFlushed` call + // so the tests can prepare next buffer once call is done + if (++i >= buf.length) { + setTimeout(() => { + const _bufferFlushed = this.bufferFlushed; + this.resetControls(); + _bufferFlushed(); + }); + } + + if (template) { + yield template; + } else { + this.finished = true; + break; + } + } + } + } + + protected resetControls() { + this.bufferFlushedPromise = new Promise( + (res) => (this.bufferFlushed = res), + ); + this.bufferPromise = new Promise<(T | null)[]>( + (res) => + (this._flushBuffer = (buffer) => { + res(buffer); + return this.bufferFlushedPromise!; + }), + ); + } +} diff --git a/projects/ng-dynamic-component/src/lib/template/tokeniser.ts b/projects/ng-dynamic-component/src/lib/template/tokeniser.ts new file mode 100644 index 000000000..07c8168d9 --- /dev/null +++ b/projects/ng-dynamic-component/src/lib/template/tokeniser.ts @@ -0,0 +1,191 @@ +export class TemplateToken { + constructor(public start: number, public end: number) {} +} + +export class TemplateTokenString extends TemplateToken { + constructor(public string: string, start: number, end: number) { + super(start, end); + } +} +export class TemplateTokenAssignment extends TemplateToken {} +export class TemplateTokenComma extends TemplateToken {} +export class TemplateTokenInputPropBindingOpen extends TemplateToken {} +export class TemplateTokenInputPropBindingClose extends TemplateToken {} +export class TemplateTokenOutputBindingOpen extends TemplateToken {} +export class TemplateTokenOutputBindingClose extends TemplateToken {} + +export enum TemplateTokenMap { + Space = ' ', + Assignment = '=', + Comma = ',', + InputPropBindingOpen = '[', + InputPropBindingClose = ']', + OutputBindingOpen = '(', + OutputBindingClose = ')', +} + +export class TemplateTokeniser implements AsyncIterable { + protected templatesIters: (Iterator | AsyncIterator)[] = []; + protected templatesQueue: string[] = []; + + protected currentTemplate?: string; + protected currentPos = 0; + protected totalPos = 0; + protected nextToken?: TemplateToken; + protected lastToken?: TemplateToken; + + constructor(protected tokenMap = TemplateTokenMap) {} + + async *[Symbol.asyncIterator](): AsyncIterableIterator { + yield* this.getStream(); + } + + feed(template: string | Iterable | AsyncIterable) { + if (typeof template === 'string') { + this.templatesQueue.push(template); + } else if (this.isIterable(template)) { + this.templatesIters.push(template[Symbol.iterator]()); + } else { + this.templatesIters.push(template[Symbol.asyncIterator]()); + } + } + + async getAll(): Promise { + const array: TemplateToken[] = []; + for await (const item of this) { + array.push(item); + } + return array; + } + + async *getStream(): AsyncIterableIterator { + while (await this.nextTemplate()) { + if (this.nextToken) { + yield this.consumeNextToken()!; + } + + const token = this.consumeToken() ?? this.consumeNextToken(); + + if (token) { + yield token; + } + } + + if (this.nextToken) { + yield this.consumeNextToken()!; + } + } + + protected consumeToken() { + let token = this.consumeLastToken(); + let i = this.currentPos; + let tokenEnded = false; + let lastCharIdx = this.currentTemplate!.length - 1; + + for (i; i <= lastCharIdx; i++) { + const char = this.currentTemplate![i]; + const posStart = this.totalPos + i; + const posEnd = posStart + 1; + + switch (char) { + case this.tokenMap.Space: + tokenEnded = true; + break; + case this.tokenMap.Assignment: + this.nextToken = new TemplateTokenAssignment(posStart, posEnd); + break; + case this.tokenMap.Comma: + this.nextToken = new TemplateTokenComma(posStart, posEnd); + break; + case this.tokenMap.InputPropBindingOpen: + this.nextToken = new TemplateTokenInputPropBindingOpen( + posStart, + posEnd, + ); + break; + case this.tokenMap.InputPropBindingClose: + this.nextToken = new TemplateTokenInputPropBindingClose( + posStart, + posEnd, + ); + break; + case this.tokenMap.OutputBindingOpen: + this.nextToken = new TemplateTokenOutputBindingOpen(posStart, posEnd); + break; + case this.tokenMap.OutputBindingClose: + this.nextToken = new TemplateTokenOutputBindingClose( + posStart, + posEnd, + ); + break; + default: + if (!token || token instanceof TemplateTokenString === false) { + token = new TemplateTokenString(char, posStart, posEnd); + } else { + (token as TemplateTokenString).string += char; + token.end++; + } + if (i >= lastCharIdx) { + this.lastToken = token; + token = undefined; + } + break; + } + + if (this.nextToken || (tokenEnded && (token || this.nextToken))) { + i++; + break; + } + } + + this.currentPos = i; + + return token; + } + + protected consumeNextToken() { + const token = this.nextToken; + this.nextToken = undefined; + return token; + } + + protected consumeLastToken() { + const token = this.lastToken; + this.lastToken = undefined; + return token; + } + + protected async nextTemplate() { + if ( + !this.currentTemplate || + this.currentPos >= this.currentTemplate.length + ) { + if (!this.templatesQueue.length) { + await this.drainTemplateIters(); + } + + this.currentTemplate = this.templatesQueue.shift(); + this.totalPos += this.currentPos; + this.currentPos = 0; + } + + return this.currentTemplate; + } + + protected async drainTemplateIters() { + for (const iter of this.templatesIters) { + const result = await iter.next(); + + if (!result.done) { + this.templatesQueue.push(result.value); + break; + } else { + this.templatesIters.shift(); + } + } + } + + protected isIterable(val: unknown | Iterable): val is Iterable { + return typeof val === 'object' && !!val && Symbol.iterator in val; + } +}