From 9b6c605beb55dbf0cb8d5d53db90e92b32946c74 Mon Sep 17 00:00:00 2001 From: Valya Bullions Date: Tue, 13 Aug 2024 14:04:15 +0100 Subject: [PATCH 1/4] feat: Add AST transform hooks --- src/Analysis.ts | 2 +- src/ExpressionBuilder.ts | 25 ++++++++++++++++++++----- src/ast.ts | 16 ++++++++++++++++ src/index.ts | 11 ++++++++++- test/ASTHooks.test.ts | 25 +++++++++++++++++++++++++ 5 files changed, 72 insertions(+), 7 deletions(-) create mode 100644 src/ast.ts create mode 100644 test/ASTHooks.test.ts diff --git a/src/Analysis.ts b/src/Analysis.ts index 00b0d1b..cde85a9 100644 --- a/src/Analysis.ts +++ b/src/Analysis.ts @@ -33,7 +33,7 @@ export const getTmplDifference = (expr: string, dataNodeName: string): TmplDiffe let tmplParsed: string | null; let analysis: ExpressionAnalysis | null; try { - [tournParsed, analysis] = getExpressionCode(expr, dataNodeName); + [tournParsed, analysis] = getExpressionCode(expr, dataNodeName, { before: [], after: [] }); } catch (e) { tournParsed = null; analysis = null; diff --git a/src/ExpressionBuilder.ts b/src/ExpressionBuilder.ts index 137d553..36bccca 100644 --- a/src/ExpressionBuilder.ts +++ b/src/ExpressionBuilder.ts @@ -10,6 +10,7 @@ import type { DataNode } from './VariablePolyfill'; import { splitExpression } from './ExpressionSplitter'; import type { ExpressionCode, ExpressionText } from './ExpressionSplitter'; import { parseWithEsprimaNext } from './Parser'; +import type { TournamentHooks } from './'; export interface ExpressionAnalysis { has: { @@ -171,6 +172,7 @@ export const getParsedExpression = (expr: string): Array { const chunks = getParsedExpression(expr); @@ -215,11 +217,19 @@ export const getExpressionCode = ( parts.push(b.literal(chunk.text)); // This is a code chunk so do some magic } else { - const parsed = jsVariablePolyfill(fixStringNewLines(chunk.parsed), dataNode)?.[0]; + const fixed = fixStringNewLines(chunk.parsed); + for (const hook of hooks.before) { + hook(fixed, dataNode); + } + const parsed = jsVariablePolyfill(fixed, dataNode)?.[0]; if (!parsed || parsed.type !== 'ExpressionStatement') { throw new SyntaxError('Not a expression statement'); } + for (const hook of hooks.after) { + hook(parsed, dataNode); + } + const functionBody = buildFunctionBody(parsed.expression); if (shouldWrapInTry(parsed)) { @@ -263,14 +273,19 @@ export const getExpressionCode = ( ); } } else { - const parsed = jsVariablePolyfill( - fixStringNewLines((chunks[1] as ParsedCode).parsed), - dataNode, - )?.[0]; + const fixed = fixStringNewLines((chunks[1] as ParsedCode).parsed); + for (const hook of hooks.before) { + hook(fixed, dataNode); + } + const parsed = jsVariablePolyfill(fixed, dataNode)?.[0]; if (!parsed || parsed.type !== 'ExpressionStatement') { throw new SyntaxError('Not a expression statement'); } + for (const hook of hooks.after) { + hook(parsed, dataNode); + } + let retData: StatementKind = b.returnStatement(parsed.expression); if (shouldWrapInTry(parsed)) { retData = wrapInErrorHandler(retData); diff --git a/src/ast.ts b/src/ast.ts new file mode 100644 index 0000000..0704d1b --- /dev/null +++ b/src/ast.ts @@ -0,0 +1,16 @@ +export type { types as astTypes } from 'recast'; +export { visit as astVisit } from 'recast'; +export { builders as astBuilders, type namedTypes as astNamedTypes } from 'ast-types'; + +import type { types } from 'recast'; +import type { namedTypes } from 'ast-types'; + +export type ASTAfterHook = ( + ast: namedTypes.ExpressionStatement, + dataNode: namedTypes.ThisExpression | namedTypes.Identifier, +) => void; + +export type ASTBeforeHook = ( + ast: types.namedTypes.File, + dataNode: namedTypes.ThisExpression | namedTypes.Identifier, +) => void; diff --git a/src/index.ts b/src/index.ts index d25abb5..ec19468 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,12 +3,20 @@ import type { ExpressionAnalysis } from './ExpressionBuilder'; import { getTmplDifference } from './Analysis'; import type { ExpressionEvaluator, ExpressionEvaluatorClass } from './Evaluator'; import { FunctionEvaluator } from './FunctionEvaluator'; +import type { ASTBeforeHook, ASTAfterHook } from './ast'; + export type { TmplDifference } from './Analysis'; export type { ExpressionEvaluator, ExpressionEvaluatorClass } from './Evaluator'; +export * from './ast'; const DATA_NODE_NAME = '___n8n_data'; export type ReturnValue = string | null | (() => unknown); +export interface TournamentHooks { + before: ASTBeforeHook[]; + after: ASTAfterHook[]; +} + export class Tournament { private evaluator!: ExpressionEvaluator; @@ -16,6 +24,7 @@ export class Tournament { public errorHandler: (error: Error) => void = () => {}, private _dataNodeName: string = DATA_NODE_NAME, Evaluator: ExpressionEvaluatorClass = FunctionEvaluator, + private astHooks: TournamentHooks = { before: [], after: [] }, ) { this.setEvaluator(Evaluator); } @@ -25,7 +34,7 @@ export class Tournament { } getExpressionCode(expr: string): [string, ExpressionAnalysis] { - return getExpressionCode(expr, this._dataNodeName); + return getExpressionCode(expr, this._dataNodeName, this.astHooks); } tmplDiff(expr: string) { diff --git a/test/ASTHooks.test.ts b/test/ASTHooks.test.ts new file mode 100644 index 0000000..e1e7857 --- /dev/null +++ b/test/ASTHooks.test.ts @@ -0,0 +1,25 @@ +import type { ExpressionStatement } from 'esprima-next'; +import { type ASTAfterHook, type ASTBeforeHook, Tournament } from '../src'; + +describe('AST Hooks', () => { + test('Before hooks are called before variable expansion', () => { + const hook: ASTBeforeHook = (ast) => { + expect(ast.program.body).toHaveLength(1); + expect(ast.program.body[0].type).toBe('ExpressionStatement'); + expect((ast.program.body[0] as ExpressionStatement).expression.type).toBe('Identifier'); + }; + + const t = new Tournament(() => {}, undefined, undefined, { before: [hook], after: [] }); + expect(t.execute('{{ test }}', { test: 1 })).toBe(1); + }); + + test('After hooks are called after variable expansion', () => { + const hook: ASTAfterHook = (ast) => { + expect(ast.type).toBe('ExpressionStatement'); + expect((ast as ExpressionStatement).expression.type).toBe('MemberExpression'); + }; + + const t = new Tournament(() => {}, undefined, undefined, { before: [], after: [hook] }); + expect(t.execute('{{ test }}', { test: 1 })).toBe(1); + }); +}); From ebdc01af155ce1cd831df1233797085d7bb953e1 Mon Sep 17 00:00:00 2001 From: Valya Bullions Date: Tue, 13 Aug 2024 15:31:32 +0100 Subject: [PATCH 2/4] review changes --- src/index.ts | 2 +- test/ASTHooks.test.ts | 10 ++++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/index.ts b/src/index.ts index ec19468..705b470 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,7 +24,7 @@ export class Tournament { public errorHandler: (error: Error) => void = () => {}, private _dataNodeName: string = DATA_NODE_NAME, Evaluator: ExpressionEvaluatorClass = FunctionEvaluator, - private astHooks: TournamentHooks = { before: [], after: [] }, + private readonly astHooks: TournamentHooks = { before: [], after: [] }, ) { this.setEvaluator(Evaluator); } diff --git a/test/ASTHooks.test.ts b/test/ASTHooks.test.ts index e1e7857..b934946 100644 --- a/test/ASTHooks.test.ts +++ b/test/ASTHooks.test.ts @@ -6,7 +6,10 @@ describe('AST Hooks', () => { const hook: ASTBeforeHook = (ast) => { expect(ast.program.body).toHaveLength(1); expect(ast.program.body[0].type).toBe('ExpressionStatement'); - expect((ast.program.body[0] as ExpressionStatement).expression.type).toBe('Identifier'); + if (ast.program.body[0].type !== 'ExpressionStatement') { + fail('Expected ExpressionStatement'); + } + expect(ast.program.body[0].expression.type).toBe('Identifier'); }; const t = new Tournament(() => {}, undefined, undefined, { before: [hook], after: [] }); @@ -16,7 +19,10 @@ describe('AST Hooks', () => { test('After hooks are called after variable expansion', () => { const hook: ASTAfterHook = (ast) => { expect(ast.type).toBe('ExpressionStatement'); - expect((ast as ExpressionStatement).expression.type).toBe('MemberExpression'); + if (ast.type !== 'ExpressionStatement') { + fail('Expected ExpressionStatement'); + } + expect(ast.expression.type).toBe('MemberExpression'); }; const t = new Tournament(() => {}, undefined, undefined, { before: [], after: [hook] }); From 31001cbc1d161a23a0c1fcc8224f50c44f0e4582 Mon Sep 17 00:00:00 2001 From: Valya Bullions Date: Tue, 13 Aug 2024 15:34:58 +0100 Subject: [PATCH 3/4] lint fix --- test/ASTHooks.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/test/ASTHooks.test.ts b/test/ASTHooks.test.ts index b934946..d4ced35 100644 --- a/test/ASTHooks.test.ts +++ b/test/ASTHooks.test.ts @@ -1,4 +1,3 @@ -import type { ExpressionStatement } from 'esprima-next'; import { type ASTAfterHook, type ASTBeforeHook, Tournament } from '../src'; describe('AST Hooks', () => { From 26e0ef9b06b605dcad6cfe9ad0f31c99566595d2 Mon Sep 17 00:00:00 2001 From: Valya Bullions Date: Tue, 13 Aug 2024 15:37:45 +0100 Subject: [PATCH 4/4] move hooks type --- src/ExpressionBuilder.ts | 2 +- src/ast.ts | 5 +++++ src/index.ts | 7 +------ 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/ExpressionBuilder.ts b/src/ExpressionBuilder.ts index 36bccca..8f7c394 100644 --- a/src/ExpressionBuilder.ts +++ b/src/ExpressionBuilder.ts @@ -10,7 +10,7 @@ import type { DataNode } from './VariablePolyfill'; import { splitExpression } from './ExpressionSplitter'; import type { ExpressionCode, ExpressionText } from './ExpressionSplitter'; import { parseWithEsprimaNext } from './Parser'; -import type { TournamentHooks } from './'; +import type { TournamentHooks } from './ast'; export interface ExpressionAnalysis { has: { diff --git a/src/ast.ts b/src/ast.ts index 0704d1b..db3c1d5 100644 --- a/src/ast.ts +++ b/src/ast.ts @@ -5,6 +5,11 @@ export { builders as astBuilders, type namedTypes as astNamedTypes } from 'ast-t import type { types } from 'recast'; import type { namedTypes } from 'ast-types'; +export interface TournamentHooks { + before: ASTBeforeHook[]; + after: ASTAfterHook[]; +} + export type ASTAfterHook = ( ast: namedTypes.ExpressionStatement, dataNode: namedTypes.ThisExpression | namedTypes.Identifier, diff --git a/src/index.ts b/src/index.ts index 705b470..09f037d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,7 +3,7 @@ import type { ExpressionAnalysis } from './ExpressionBuilder'; import { getTmplDifference } from './Analysis'; import type { ExpressionEvaluator, ExpressionEvaluatorClass } from './Evaluator'; import { FunctionEvaluator } from './FunctionEvaluator'; -import type { ASTBeforeHook, ASTAfterHook } from './ast'; +import type { TournamentHooks } from './ast'; export type { TmplDifference } from './Analysis'; export type { ExpressionEvaluator, ExpressionEvaluatorClass } from './Evaluator'; @@ -12,11 +12,6 @@ export * from './ast'; const DATA_NODE_NAME = '___n8n_data'; export type ReturnValue = string | null | (() => unknown); -export interface TournamentHooks { - before: ASTBeforeHook[]; - after: ASTAfterHook[]; -} - export class Tournament { private evaluator!: ExpressionEvaluator;