From 7661575573c6d3e8f4bc28bfa7a124a764c72ef9 Mon Sep 17 00:00:00 2001 From: undergroundwires Date: Sat, 16 Jan 2021 13:26:41 +0100 Subject: [PATCH] allow functions to call other functions #53 --- docs/collection-files.md | 35 +- docs/tests.md | 29 ++ .../Expressions/ExpressionsCompiler.ts | 34 ++ .../Expressions/IExpressionsCompiler.ts | 5 + .../Compiler/{ => Expressions}/ILCode.ts | 0 .../Compiler/Function/FunctionCompiler.ts | 114 +++++ .../Compiler/Function/IFunctionCompiler.ts | 6 + .../Compiler/Function/ISharedFunction.ts | 6 + .../Function/ISharedFunctionCollection.ts | 5 + .../Compiler/Function/SharedFunction.ts | 14 + .../Function/SharedFunctionCollection.ts | 23 + .../FunctionCall/FunctionCallCompiler.ts | 88 ++++ .../Compiler/FunctionCall/ICompiledCode.ts | 4 + .../FunctionCall/IFunctionCallCompiler.ts | 9 + .../Parser/Script/Compiler/ScriptCompiler.ts | 188 +------- src/application/Parser/Script/ScriptParser.ts | 2 +- .../Parser/ScriptingDefinitionParser.ts | 2 +- .../collections/collection.yaml.d.ts | 31 +- src/application/collections/windows.yaml | 151 +++--- src/domain/ScriptCode.ts | 33 +- .../Parser/CategoryCollectionParser.spec.ts | 2 +- .../CategoryCollectionParseContext.spec.ts | 4 +- .../Expressions/ExpressionsCompiler.spec.ts | 99 ++++ .../Compiler/{ => Expressions}/ILCode.spec.ts | 2 +- .../Function/FunctionCompiler.spec.ts | 192 ++++++++ .../Compiler/Function/SharedFunction.spec.ts | 128 +++++ .../Function/SharedFunctionCollection.spec.ts | 74 +++ .../FunctionCall/FunctionCallCompiler.spec.ts | 191 ++++++++ .../Script/Compiler/ScriptCompiler.spec.ts | 445 +++++------------- .../Parser/Script/ScriptParser.spec.ts | 4 +- tests/unit/domain/ScriptCode.spec.ts | 52 +- tests/unit/stubs/ExpressionsCompilerStub.ts | 28 ++ tests/unit/stubs/FunctionCallCompilerStub.ts | 26 + tests/unit/stubs/FunctionCompilerStub.ts | 41 ++ tests/unit/stubs/FunctionDataStub.ts | 35 +- tests/unit/stubs/ScriptDataStub.ts | 2 + .../stubs/SharedFunctionCollectionStub.ts | 21 + tests/unit/stubs/SharedFunctionStub.ts | 27 ++ 38 files changed, 1507 insertions(+), 645 deletions(-) create mode 100644 docs/tests.md create mode 100644 src/application/Parser/Script/Compiler/Expressions/ExpressionsCompiler.ts create mode 100644 src/application/Parser/Script/Compiler/Expressions/IExpressionsCompiler.ts rename src/application/Parser/Script/Compiler/{ => Expressions}/ILCode.ts (100%) create mode 100644 src/application/Parser/Script/Compiler/Function/FunctionCompiler.ts create mode 100644 src/application/Parser/Script/Compiler/Function/IFunctionCompiler.ts create mode 100644 src/application/Parser/Script/Compiler/Function/ISharedFunction.ts create mode 100644 src/application/Parser/Script/Compiler/Function/ISharedFunctionCollection.ts create mode 100644 src/application/Parser/Script/Compiler/Function/SharedFunction.ts create mode 100644 src/application/Parser/Script/Compiler/Function/SharedFunctionCollection.ts create mode 100644 src/application/Parser/Script/Compiler/FunctionCall/FunctionCallCompiler.ts create mode 100644 src/application/Parser/Script/Compiler/FunctionCall/ICompiledCode.ts create mode 100644 src/application/Parser/Script/Compiler/FunctionCall/IFunctionCallCompiler.ts create mode 100644 tests/unit/application/Parser/Script/Compiler/Expressions/ExpressionsCompiler.spec.ts rename tests/unit/application/Parser/Script/Compiler/{ => Expressions}/ILCode.spec.ts (99%) create mode 100644 tests/unit/application/Parser/Script/Compiler/Function/FunctionCompiler.spec.ts create mode 100644 tests/unit/application/Parser/Script/Compiler/Function/SharedFunction.spec.ts create mode 100644 tests/unit/application/Parser/Script/Compiler/Function/SharedFunctionCollection.spec.ts create mode 100644 tests/unit/application/Parser/Script/Compiler/FunctionCall/FunctionCallCompiler.spec.ts create mode 100644 tests/unit/stubs/ExpressionsCompilerStub.ts create mode 100644 tests/unit/stubs/FunctionCallCompilerStub.ts create mode 100644 tests/unit/stubs/FunctionCompilerStub.ts create mode 100644 tests/unit/stubs/SharedFunctionCollectionStub.ts create mode 100644 tests/unit/stubs/SharedFunctionStub.ts diff --git a/docs/collection-files.md b/docs/collection-files.md index 6c87855e..d557b61f 100644 --- a/docs/collection-files.md +++ b/docs/collection-files.md @@ -101,11 +101,15 @@ ### `Function` - Functions allow re-usable code throughout the defined scripts. -- Functions are templates compiled by privacy.sexy and uses special expressions. -- Expressions are defined inside mustaches (double brackets, `{{` and `}}`) +- Functions are templates compiled by privacy.sexy and uses special [expressions](#expressions). +- Functions can call other functions by defining `call` property instead of `code` - 👀 See [parameter substitution](#parameter-substitution) for an example usage -#### Parameter substitution +#### Expressions + +- Expressions are defined inside mustaches (double brackets, `{{` and `}}`) + +##### Parameter substitution A simple function example @@ -125,6 +129,22 @@ It would print "Hello world" if it's called in a [script](#script) as following: argument: World ``` +A function can call other functions such as: + +```yaml + - + function: CallerFunction + parameters: [ 'value' ] + call: + function: EchoArgument + parameters: + argument: {{ $value }} + - + function: EchoArgument + parameters: [ 'argument' ] + code: Hello {{ $argument }} ! +``` + #### `Function` syntax - `name`: *`string`* (**required**) @@ -135,15 +155,20 @@ It would print "Hello world" if it's called in a [script](#script) as following: - `parameters`: `[` *`string`* `, ... ]` - Name of the parameters that the function has. - Parameter values are provided by a [Script](#script) through a [FunctionCall](#FunctionCall) - - Parameter names must be defined to be used in expressions such as [parameter substitution](#parameter-substitution) + - Parameter names must be defined to be used in [expressions](#expressions) - ❗ Parameter names must be unique - `code`: *`string`* (**required**) + `code`: *`string`* (**required** if `call` is undefined) - Batch file commands that will be executed - 💡 If defined, best practice to also define `revertCode` + - ❗ If not defined `call` must be defined - `revertCode`: *`string`* - Code that'll undo the change done by `code` property. - E.g. let's say `code` sets an environment variable as `setx POWERSHELL_TELEMETRY_OPTOUT 1` - then `revertCode` should be doing `setx POWERSHELL_TELEMETRY_OPTOUT 0` +- `call`: ***[`FunctionCall`](#FunctionCall)*** | `[` ***[`FunctionCall`](#FunctionCall)*** `, ... ]` (may be **required**) + - A shared function or sequence of functions to call (called in order) + - The parameter values that are sent can use [expressions](#expressions) + - ❗ If not defined `code` must be defined ### `ScriptingDefinition` diff --git a/docs/tests.md b/docs/tests.md new file mode 100644 index 00000000..c5084850 --- /dev/null +++ b/docs/tests.md @@ -0,0 +1,29 @@ +# Unit tests + +- Unit tests are defined in [`./tests`](./../tests) +- They follow same folder structure as [`./src`](./../src) + +## Naming + +- Each test suite first describe the system under test + - E.g. tests for class `Application` is categorized under `Application` +- Tests for specific methods are categorized under method name (if applicable) + - E.g. test for `run()` is categorized under `run` + +## Act, arrange, assert + +- Tests use act, arrange and assert (AAA) pattern when applicable +- **Arrange** + - Should set up the test case + - Starts with comment line `// arrange` +- **Act** + - Should cover the main thing to be tested + - Starts with comment line `// act` +- **Assert** + - Should elicit some sort of response + - Starts with comment line `// assert` + +## Stubs + +- Stubs are defined in [`./tests/stubs`](./../tests/unit/stubs) +- They implement dummy behavior to be functional diff --git a/src/application/Parser/Script/Compiler/Expressions/ExpressionsCompiler.ts b/src/application/Parser/Script/Compiler/Expressions/ExpressionsCompiler.ts new file mode 100644 index 00000000..90a2ae07 --- /dev/null +++ b/src/application/Parser/Script/Compiler/Expressions/ExpressionsCompiler.ts @@ -0,0 +1,34 @@ +import { IExpressionsCompiler, ParameterValueDictionary } from './IExpressionsCompiler'; +import { generateIlCode, IILCode } from './ILCode'; + +export class ExpressionsCompiler implements IExpressionsCompiler { + public static readonly instance: IExpressionsCompiler = new ExpressionsCompiler(); + protected constructor() { } + public compileExpressions(code: string, parameters?: ParameterValueDictionary): string { + let intermediateCode = generateIlCode(code); + intermediateCode = substituteParameters(intermediateCode, parameters); + return intermediateCode.compile(); + } +} + +function substituteParameters(intermediateCode: IILCode, parameters: ParameterValueDictionary): IILCode { + const parameterNames = intermediateCode.getUniqueParameterNames(); + ensureValuesProvided(parameterNames, parameters); + for (const parameterName of parameterNames) { + const parameterValue = parameters[parameterName]; + intermediateCode = intermediateCode.substituteParameter(parameterName, parameterValue); + } + return intermediateCode; +} + +function ensureValuesProvided(names: string[], nameValues: ParameterValueDictionary) { + nameValues = nameValues || {}; + const notProvidedNames = names.filter((name) => !Boolean(nameValues[name])); + if (notProvidedNames.length) { + throw new Error(`parameter value(s) not provided for: ${printList(notProvidedNames)}`); + } +} + +function printList(list: readonly string[]): string { + return `"${list.join('", "')}"`; +} diff --git a/src/application/Parser/Script/Compiler/Expressions/IExpressionsCompiler.ts b/src/application/Parser/Script/Compiler/Expressions/IExpressionsCompiler.ts new file mode 100644 index 00000000..ded7da64 --- /dev/null +++ b/src/application/Parser/Script/Compiler/Expressions/IExpressionsCompiler.ts @@ -0,0 +1,5 @@ +export interface ParameterValueDictionary { [parameterName: string]: string; } + +export interface IExpressionsCompiler { + compileExpressions(code: string, parameters?: ParameterValueDictionary): string; +} diff --git a/src/application/Parser/Script/Compiler/ILCode.ts b/src/application/Parser/Script/Compiler/Expressions/ILCode.ts similarity index 100% rename from src/application/Parser/Script/Compiler/ILCode.ts rename to src/application/Parser/Script/Compiler/Expressions/ILCode.ts diff --git a/src/application/Parser/Script/Compiler/Function/FunctionCompiler.ts b/src/application/Parser/Script/Compiler/Function/FunctionCompiler.ts new file mode 100644 index 00000000..a145d2cc --- /dev/null +++ b/src/application/Parser/Script/Compiler/Function/FunctionCompiler.ts @@ -0,0 +1,114 @@ +import { FunctionData, InstructionHolder } from 'js-yaml-loader!*'; +import { SharedFunction } from './SharedFunction'; +import { SharedFunctionCollection } from './SharedFunctionCollection'; +import { ISharedFunctionCollection } from './ISharedFunctionCollection'; +import { IFunctionCompiler } from './IFunctionCompiler'; +import { IFunctionCallCompiler } from '../FunctionCall/IFunctionCallCompiler'; +import { FunctionCallCompiler } from '../FunctionCall/FunctionCallCompiler'; + +export class FunctionCompiler implements IFunctionCompiler { + public static readonly instance: IFunctionCompiler = new FunctionCompiler(); + protected constructor( + private readonly functionCallCompiler: IFunctionCallCompiler = FunctionCallCompiler.instance) { + } + public compileFunctions(functions: readonly FunctionData[]): ISharedFunctionCollection { + const collection = new SharedFunctionCollection(); + if (!functions || !functions.length) { + return collection; + } + ensureValidFunctions(functions); + functions + .filter((func) => hasCode(func)) + .forEach((func) => { + const shared = new SharedFunction(func.name, func.parameters, func.code, func.revertCode); + collection.addFunction(shared); + }); + functions + .filter((func) => hasCall(func)) + .forEach((func) => { + const code = this.functionCallCompiler.compileCall(func.call, collection); + const shared = new SharedFunction(func.name, func.parameters, code.code, code.revertCode); + collection.addFunction(shared); + }); + return collection; + } +} + +function hasCode(data: FunctionData): boolean { + return Boolean(data.code); +} + +function hasCall(data: FunctionData): boolean { + return Boolean(data.call); +} + + +function ensureValidFunctions(functions: readonly FunctionData[]) { + ensureNoUndefinedItem(functions); + ensureNoDuplicatesInFunctionNames(functions); + ensureNoDuplicatesInParameterNames(functions); + ensureNoDuplicateCode(functions); + ensureEitherCallOrCodeIsDefined(functions); +} + +function printList(list: readonly string[]): string { + return `"${list.join('","')}"`; +} + +function ensureEitherCallOrCodeIsDefined(holders: readonly InstructionHolder[]) { + // Ensure functions do not define both call and code + const withBothCallAndCode = holders.filter((holder) => hasCode(holder) && hasCall(holder)); + if (withBothCallAndCode.length) { + throw new Error(`both "code" and "call" are defined in ${printNames(withBothCallAndCode)}`); + } + // Ensure functions have either code or call + const hasEitherCodeOrCall = holders.filter((holder) => !hasCode(holder) && !hasCall(holder)); + if (hasEitherCodeOrCall.length) { + throw new Error(`neither "code" or "call" is defined in ${printNames(hasEitherCodeOrCall)}`); + } +} +function printNames(holders: readonly InstructionHolder[]) { + return printList(holders.map((holder) => holder.name)); +} + +function ensureNoDuplicatesInFunctionNames(functions: readonly FunctionData[]) { + const duplicateFunctionNames = getDuplicates(functions + .map((func) => func.name.toLowerCase())); + if (duplicateFunctionNames.length) { + throw new Error(`duplicate function name: ${printList(duplicateFunctionNames)}`); + } +} +function ensureNoUndefinedItem(functions: readonly FunctionData[]) { + if (functions.some((func) => !func)) { + throw new Error(`some functions are undefined`); + } +} +function ensureNoDuplicatesInParameterNames(functions: readonly FunctionData[]) { + const functionsWithParameters = functions + .filter((func) => func.parameters && func.parameters.length > 0); + for (const func of functionsWithParameters) { + const duplicateParameterNames = getDuplicates(func.parameters); + if (duplicateParameterNames.length) { + throw new Error(`"${func.name}": duplicate parameter name: ${printList(duplicateParameterNames)}`); + } + } +} +function ensureNoDuplicateCode(functions: readonly FunctionData[]) { + const duplicateCodes = getDuplicates(functions + .map((func) => func.code) + .filter((code) => code), + ); + if (duplicateCodes.length > 0) { + throw new Error(`duplicate "code" in functions: ${printList(duplicateCodes)}`); + } + const duplicateRevertCodes = getDuplicates(functions + .filter((func) => func.revertCode) + .map((func) => func.revertCode)); + if (duplicateRevertCodes.length > 0) { + throw new Error(`duplicate "revertCode" in functions: ${printList(duplicateRevertCodes)}`); + } +} + +function getDuplicates(texts: readonly string[]): string[] { + return texts.filter((item, index) => texts.indexOf(item) !== index); +} diff --git a/src/application/Parser/Script/Compiler/Function/IFunctionCompiler.ts b/src/application/Parser/Script/Compiler/Function/IFunctionCompiler.ts new file mode 100644 index 00000000..c000daea --- /dev/null +++ b/src/application/Parser/Script/Compiler/Function/IFunctionCompiler.ts @@ -0,0 +1,6 @@ +import { FunctionData } from 'js-yaml-loader!*'; +import { ISharedFunctionCollection } from './ISharedFunctionCollection'; + +export interface IFunctionCompiler { + compileFunctions(functions: readonly FunctionData[]): ISharedFunctionCollection; +} diff --git a/src/application/Parser/Script/Compiler/Function/ISharedFunction.ts b/src/application/Parser/Script/Compiler/Function/ISharedFunction.ts new file mode 100644 index 00000000..53c4adc0 --- /dev/null +++ b/src/application/Parser/Script/Compiler/Function/ISharedFunction.ts @@ -0,0 +1,6 @@ +export interface ISharedFunction { + readonly name: string; + readonly parameters?: readonly string[]; + readonly code: string; + readonly revertCode?: string; +} diff --git a/src/application/Parser/Script/Compiler/Function/ISharedFunctionCollection.ts b/src/application/Parser/Script/Compiler/Function/ISharedFunctionCollection.ts new file mode 100644 index 00000000..010107ba --- /dev/null +++ b/src/application/Parser/Script/Compiler/Function/ISharedFunctionCollection.ts @@ -0,0 +1,5 @@ +import { ISharedFunction } from './ISharedFunction'; + +export interface ISharedFunctionCollection { + getFunctionByName(name: string): ISharedFunction; +} diff --git a/src/application/Parser/Script/Compiler/Function/SharedFunction.ts b/src/application/Parser/Script/Compiler/Function/SharedFunction.ts new file mode 100644 index 00000000..ae9240ae --- /dev/null +++ b/src/application/Parser/Script/Compiler/Function/SharedFunction.ts @@ -0,0 +1,14 @@ +import { ISharedFunction } from './ISharedFunction'; + +export class SharedFunction implements ISharedFunction { + constructor( + public readonly name: string, + public readonly parameters: readonly string[], + public readonly code: string, + public readonly revertCode: string, + ) { + if (!name) { throw new Error('undefined function name'); } + if (!code) { throw new Error(`undefined function ("${name}") code`); } + this.parameters = parameters || []; + } +} diff --git a/src/application/Parser/Script/Compiler/Function/SharedFunctionCollection.ts b/src/application/Parser/Script/Compiler/Function/SharedFunctionCollection.ts new file mode 100644 index 00000000..1cae1436 --- /dev/null +++ b/src/application/Parser/Script/Compiler/Function/SharedFunctionCollection.ts @@ -0,0 +1,23 @@ +import { ISharedFunction } from './ISharedFunction'; +import { ISharedFunctionCollection } from './ISharedFunctionCollection'; + +export class SharedFunctionCollection implements ISharedFunctionCollection { + private readonly functionsByName = new Map(); + + public addFunction(func: ISharedFunction): void { + if (!func) { throw new Error('undefined function'); } + if (this.functionsByName.has(func.name)) { + throw new Error(`function with name ${func.name} already exists`); + } + this.functionsByName.set(func.name, func); + } + + public getFunctionByName(name: string): ISharedFunction { + if (!name) { throw Error('undefined function name'); } + const func = this.functionsByName.get(name); + if (!func) { + throw new Error(`called function is not defined "${name}"`); + } + return func; + } +} diff --git a/src/application/Parser/Script/Compiler/FunctionCall/FunctionCallCompiler.ts b/src/application/Parser/Script/Compiler/FunctionCall/FunctionCallCompiler.ts new file mode 100644 index 00000000..cf3ac2a7 --- /dev/null +++ b/src/application/Parser/Script/Compiler/FunctionCall/FunctionCallCompiler.ts @@ -0,0 +1,88 @@ +import { FunctionCallData, FunctionCallParametersData, FunctionData, ScriptFunctionCallData } from 'js-yaml-loader!*'; +import { ICompiledCode } from './ICompiledCode'; +import { ISharedFunctionCollection } from '../Function/ISharedFunctionCollection'; +import { IFunctionCallCompiler } from './IFunctionCallCompiler'; +import { IExpressionsCompiler } from '../Expressions/IExpressionsCompiler'; +import { ExpressionsCompiler } from '../Expressions/ExpressionsCompiler'; + +export class FunctionCallCompiler implements IFunctionCallCompiler { + public static readonly instance: IFunctionCallCompiler = new FunctionCallCompiler(); + protected constructor( + private readonly expressionsCompiler: IExpressionsCompiler = ExpressionsCompiler.instance) { } + public compileCall( + call: ScriptFunctionCallData, + functions: ISharedFunctionCollection): ICompiledCode { + if (!functions) { throw new Error('undefined functions'); } + if (!call) { throw new Error('undefined call'); } + const compiledCodes = new Array(); + const calls = getCallSequence(call); + calls.forEach((currentCall, currentCallIndex) => { + ensureValidCall(currentCall); + const commonFunction = functions.getFunctionByName(currentCall.function); + ensureExpectedParameters(commonFunction, currentCall); + let functionCode = compileCode(commonFunction, currentCall.parameters, this.expressionsCompiler); + if (currentCallIndex !== calls.length - 1) { + functionCode = appendLine(functionCode); + } + compiledCodes.push(functionCode); + }); + const compiledCode = merge(compiledCodes); + return compiledCode; + } +} + +function ensureExpectedParameters(func: FunctionData, call: FunctionCallData) { + if (!func.parameters && !call.parameters) { + return; + } + const unexpectedParameters = Object.keys(call.parameters || {}) + .filter((callParam) => !func.parameters.includes(callParam)); + if (unexpectedParameters.length) { + throw new Error( + `function "${func.name}" has unexpected parameter(s) provided: "${unexpectedParameters.join('", "')}"`); + } +} + +function merge(codes: readonly ICompiledCode[]): ICompiledCode { + return { + code: codes.map((code) => code.code).join(''), + revertCode: codes.map((code) => code.revertCode).join(''), + }; +} + +function compileCode( + func: FunctionData, + parameters: FunctionCallParametersData, + compiler: IExpressionsCompiler): ICompiledCode { + return { + code: compiler.compileExpressions(func.code, parameters), + revertCode: compiler.compileExpressions(func.revertCode, parameters), + }; +} + +function getCallSequence(call: ScriptFunctionCallData): FunctionCallData[] { + if (typeof call !== 'object') { + throw new Error('called function(s) must be an object'); + } + if (call instanceof Array) { + return call as FunctionCallData[]; + } + return [ call as FunctionCallData ]; +} + +function ensureValidCall(call: FunctionCallData) { + if (!call) { + throw new Error(`undefined function call`); + } + if (!call.function) { + throw new Error(`empty function name called`); + } +} + +function appendLine(code: ICompiledCode): ICompiledCode { + const appendLineIfNotEmpty = (str: string) => str ? `${str}\n` : str; + return { + code: appendLineIfNotEmpty(code.code), + revertCode: appendLineIfNotEmpty(code.revertCode), + }; +} diff --git a/src/application/Parser/Script/Compiler/FunctionCall/ICompiledCode.ts b/src/application/Parser/Script/Compiler/FunctionCall/ICompiledCode.ts new file mode 100644 index 00000000..1b30f35b --- /dev/null +++ b/src/application/Parser/Script/Compiler/FunctionCall/ICompiledCode.ts @@ -0,0 +1,4 @@ +export interface ICompiledCode { + readonly code: string; + readonly revertCode?: string; +} diff --git a/src/application/Parser/Script/Compiler/FunctionCall/IFunctionCallCompiler.ts b/src/application/Parser/Script/Compiler/FunctionCall/IFunctionCallCompiler.ts new file mode 100644 index 00000000..950f8b8b --- /dev/null +++ b/src/application/Parser/Script/Compiler/FunctionCall/IFunctionCallCompiler.ts @@ -0,0 +1,9 @@ +import { ScriptFunctionCallData } from 'js-yaml-loader!*'; +import { ICompiledCode } from './ICompiledCode'; +import { ISharedFunctionCollection } from '../Function/ISharedFunctionCollection'; + +export interface IFunctionCallCompiler { + compileCall( + call: ScriptFunctionCallData, + functions: ISharedFunctionCollection): ICompiledCode; +} diff --git a/src/application/Parser/Script/Compiler/ScriptCompiler.ts b/src/application/Parser/Script/Compiler/ScriptCompiler.ts index 7540fa36..75742901 100644 --- a/src/application/Parser/Script/Compiler/ScriptCompiler.ts +++ b/src/application/Parser/Script/Compiler/ScriptCompiler.ts @@ -1,184 +1,42 @@ -import { generateIlCode, IILCode } from './ILCode'; import { IScriptCode } from '@/domain/IScriptCode'; import { ScriptCode } from '@/domain/ScriptCode'; -import { ScriptData, FunctionData, FunctionCallData, ScriptFunctionCallData, FunctionCallParametersData } from 'js-yaml-loader!@/*'; +import { FunctionData, ScriptData } from 'js-yaml-loader!@/*'; import { IScriptCompiler } from './IScriptCompiler'; import { ILanguageSyntax } from '@/domain/ScriptCode'; - -interface ICompiledCode { - readonly code: string; - readonly revertCode: string; -} +import { ISharedFunctionCollection } from './Function/ISharedFunctionCollection'; +import { IFunctionCallCompiler } from './FunctionCall/IFunctionCallCompiler'; +import { FunctionCallCompiler } from './FunctionCall/FunctionCallCompiler'; +import { IFunctionCompiler } from './Function/IFunctionCompiler'; +import { FunctionCompiler } from './Function/FunctionCompiler'; export class ScriptCompiler implements IScriptCompiler { + private readonly functions: ISharedFunctionCollection; constructor( - private readonly functions: readonly FunctionData[] | undefined, - private syntax: ILanguageSyntax) { - ensureValidFunctions(functions); + functions: readonly FunctionData[] | undefined, + private readonly syntax: ILanguageSyntax, + functionCompiler: IFunctionCompiler = FunctionCompiler.instance, + private readonly callCompiler: IFunctionCallCompiler = FunctionCallCompiler.instance, + ) { if (!syntax) { throw new Error('undefined syntax'); } + this.functions = functionCompiler.compileFunctions(functions); } public canCompile(script: ScriptData): boolean { + if (!script) { throw new Error('undefined script'); } if (!script.call) { return false; } return true; } public compile(script: ScriptData): IScriptCode { - this.ensureCompilable(script.call); - const compiledCodes = new Array(); - const calls = getCallSequence(script.call); - calls.forEach((currentCall, currentCallIndex) => { - ensureValidCall(currentCall, script.name); - const commonFunction = this.getFunctionByName(currentCall.function); - ensureExpectedParameters(commonFunction, currentCall); - let functionCode = compileCode(commonFunction, currentCall.parameters); - if (currentCallIndex !== calls.length - 1) { - functionCode = appendLine(functionCode); - } - compiledCodes.push(functionCode); - }); - const scriptCode = merge(compiledCodes); - return new ScriptCode(scriptCode.code, scriptCode.revertCode, script.name, this.syntax); - } - - private getFunctionByName(name: string): FunctionData { - const func = this.functions.find((f) => f.name === name); - if (!func) { - throw new Error(`called function is not defined "${name}"`); - } - return func; - } - private ensureCompilable(call: ScriptFunctionCallData) { - if (!this.functions || this.functions.length === 0) { - throw new Error('cannot compile without shared functions'); - } - if (typeof call !== 'object') { - throw new Error('called function(s) must be an object'); + if (!script) { throw new Error('undefined script'); } + try { + const compiledCode = this.callCompiler.compileCall(script.call, this.functions); + return new ScriptCode( + compiledCode.code, + compiledCode.revertCode, + this.syntax); + } catch (error) { + throw Error(`Script "${script.name}" ${error.message}`); } } } - -function ensureExpectedParameters(func: FunctionData, call: FunctionCallData) { - if (!func.parameters && !call.parameters) { - return; - } - const unexpectedParameters = Object.keys(call.parameters || {}) - .filter((callParam) => !func.parameters.includes(callParam)); - if (unexpectedParameters.length) { - throw new Error( - `function "${func.name}" has unexpected parameter(s) provided: "${unexpectedParameters.join('", "')}"`); - } -} - -function getDuplicates(texts: readonly string[]): string[] { - return texts.filter((item, index) => texts.indexOf(item) !== index); -} - -function printList(list: readonly string[]): string { - return `"${list.join('","')}"`; -} - -function ensureNoDuplicatesInFunctionNames(functions: readonly FunctionData[]) { - const duplicateFunctionNames = getDuplicates(functions - .map((func) => func.name.toLowerCase())); - if (duplicateFunctionNames.length) { - throw new Error(`duplicate function name: ${printList(duplicateFunctionNames)}`); - } -} -function ensureNoUndefinedItem(functions: readonly FunctionData[]) { - if (functions.some((func) => !func)) { - throw new Error(`some functions are undefined`); - } -} -function ensureNoDuplicatesInParameterNames(functions: readonly FunctionData[]) { - const functionsWithParameters = functions - .filter((func) => func.parameters && func.parameters.length > 0); - for (const func of functionsWithParameters) { - const duplicateParameterNames = getDuplicates(func.parameters); - if (duplicateParameterNames.length) { - throw new Error(`"${func.name}": duplicate parameter name: ${printList(duplicateParameterNames)}`); - } - } -} - -function ensureNoDuplicateCode(functions: readonly FunctionData[]) { - const duplicateCodes = getDuplicates(functions.map((func) => func.code)); - if (duplicateCodes.length > 0) { - throw new Error(`duplicate "code" in functions: ${printList(duplicateCodes)}`); - } - const duplicateRevertCodes = getDuplicates(functions - .filter((func) => func.revertCode) - .map((func) => func.revertCode)); - if (duplicateRevertCodes.length > 0) { - throw new Error(`duplicate "revertCode" in functions: ${printList(duplicateRevertCodes)}`); - } -} - -function ensureValidFunctions(functions: readonly FunctionData[]) { - if (!functions || functions.length === 0) { - return; - } - ensureNoUndefinedItem(functions); - ensureNoDuplicatesInFunctionNames(functions); - ensureNoDuplicatesInParameterNames(functions); - ensureNoDuplicateCode(functions); -} - -function appendLine(code: ICompiledCode): ICompiledCode { - const appendLineIfNotEmpty = (str: string) => str ? `${str}\n` : str; - return { - code: appendLineIfNotEmpty(code.code), - revertCode: appendLineIfNotEmpty(code.revertCode), - }; -} - -function merge(codes: readonly ICompiledCode[]): ICompiledCode { - return { - code: codes.map((code) => code.code).join(''), - revertCode: codes.map((code) => code.revertCode).join(''), - }; -} - -function compileCode(func: FunctionData, parameters: FunctionCallParametersData): ICompiledCode { - return { - code: compileExpressions(func.code, parameters), - revertCode: compileExpressions(func.revertCode, parameters), - }; -} - -function compileExpressions(code: string, parameters: FunctionCallParametersData): string { - let intermediateCode = generateIlCode(code); - intermediateCode = substituteParameters(intermediateCode, parameters); - return intermediateCode.compile(); -} - -function substituteParameters(intermediateCode: IILCode, parameters: FunctionCallParametersData): IILCode { - const parameterNames = intermediateCode.getUniqueParameterNames(); - if (parameterNames.length && !parameters) { - throw new Error(`no parameters defined, expected: ${printList(parameterNames)}`); - } - for (const parameterName of parameterNames) { - const parameterValue = parameters[parameterName]; - if (!parameterValue) { - throw Error(`parameter value is not provided for "${parameterName}" in function call`); - } - intermediateCode = intermediateCode.substituteParameter(parameterName, parameterValue); - } - return intermediateCode; -} - -function ensureValidCall(call: FunctionCallData, scriptName: string) { - if (!call) { - throw new Error(`undefined function call in script "${scriptName}"`); - } - if (!call.function) { - throw new Error(`empty function name called in script "${scriptName}"`); - } -} - -function getCallSequence(call: ScriptFunctionCallData): FunctionCallData[] { - if (call instanceof Array) { - return call as FunctionCallData[]; - } - return [ call as FunctionCallData ]; -} diff --git a/src/application/Parser/Script/ScriptParser.ts b/src/application/Parser/Script/ScriptParser.ts index bf1bef09..239c4773 100644 --- a/src/application/Parser/Script/ScriptParser.ts +++ b/src/application/Parser/Script/ScriptParser.ts @@ -31,7 +31,7 @@ function parseCode(script: ScriptData, context: ICategoryCollectionParseContext) if (context.compiler.canCompile(script)) { return context.compiler.compile(script); } - return new ScriptCode(script.code, script.revertCode, script.name, context.syntax); + return new ScriptCode(script.code, script.revertCode, context.syntax); } function ensureNotBothCallAndCode(script: ScriptData) { diff --git a/src/application/Parser/ScriptingDefinitionParser.ts b/src/application/Parser/ScriptingDefinitionParser.ts index c14e06c0..99e6a120 100644 --- a/src/application/Parser/ScriptingDefinitionParser.ts +++ b/src/application/Parser/ScriptingDefinitionParser.ts @@ -4,7 +4,7 @@ import { ScriptingDefinition } from '@/domain/ScriptingDefinition'; import { ScriptingLanguage } from '@/domain/ScriptingLanguage'; import { IProjectInformation } from '@/domain/IProjectInformation'; import { createEnumParser } from '../Common/Enum'; -import { generateIlCode } from './Script/Compiler/ILCode'; +import { generateIlCode } from './Script/Compiler/Expressions/ILCode'; export function parseScriptingDefinition( definition: ScriptingDefinitionData, diff --git a/src/application/collections/collection.yaml.d.ts b/src/application/collections/collection.yaml.d.ts index b7a9850f..7de03da4 100644 --- a/src/application/collections/collection.yaml.d.ts +++ b/src/application/collections/collection.yaml.d.ts @@ -18,30 +18,33 @@ declare module 'js-yaml-loader!*' { readonly docs?: DocumentationUrlsData; } - export interface FunctionData { - name: string; - code: string; - revertCode?: string; - parameters?: readonly string[]; + export interface InstructionHolder { + readonly name: string; + + readonly code?: string; + readonly revertCode?: string; + + readonly call?: ScriptFunctionCallData; + } + + export interface FunctionData extends InstructionHolder { + readonly parameters?: readonly string[]; } export interface FunctionCallParametersData { - [index: string]: string; + readonly [index: string]: string; } export interface FunctionCallData { - function: string; - parameters?: FunctionCallParametersData; + readonly function: string; + readonly parameters?: FunctionCallParametersData; } export type ScriptFunctionCallData = readonly FunctionCallData[] | FunctionCallData | undefined; - export interface ScriptData extends DocumentableData { - name: string; - code?: string; - revertCode?: string; - call: ScriptFunctionCallData; - recommend?: string; + export interface ScriptData extends InstructionHolder, DocumentableData { + readonly name: string; + readonly recommend?: string; } export interface ScriptingDefinitionData { diff --git a/src/application/collections/windows.yaml b/src/application/collections/windows.yaml index fd3aec35..fbf81450 100644 --- a/src/application/collections/windows.yaml +++ b/src/application/collections/windows.yaml @@ -2719,8 +2719,19 @@ actions: - name: Disable NetBios for all interfaces docs: https://10dsecurity.com/saying-goodbye-netbios/ - code: Powershell -Command "$key = 'HKLM:SYSTEM\CurrentControlSet\services\NetBT\Parameters\Interfaces'; Get-ChildItem $key | foreach { Set-ItemProperty -Path \"$key\$($_.pschildname)\" -Name NetbiosOptions -Value 2 -Verbose}" - revertCode: Powershell -Command "$key = 'HKLM:SYSTEM\CurrentControlSet\services\NetBT\Parameters\Interfaces'; Get-ChildItem $key | foreach { Set-ItemProperty -Path \"$key\$($_.pschildname)\" -Name NetbiosOptions -Value 0 -Verbose}" + call: + function: RunPowerShell + parameters: + code: + $key = 'HKLM:SYSTEM\CurrentControlSet\services\NetBT\Parameters\Interfaces'; + Get-ChildItem $key | foreach { + Set-ItemProperty -Path \"$key\$($_.pschildname)\" -Name NetbiosOptions -Value 2 -Verbose + } + revertCode: + $key = 'HKLM:SYSTEM\CurrentControlSet\services\NetBT\Parameters\Interfaces'; + Get-ChildItem $key | foreach { + Set-ItemProperty -Path \"$key\$($_.pschildname)\" -Name NetbiosOptions -Value 0 -Verbose + } - category: Remove bloatware children: @@ -4168,64 +4179,72 @@ functions: - name: UninstallStoreApp parameters: [ packageName ] - code: PowerShell -Command "Get-AppxPackage '{{ $packageName }}' | Remove-AppxPackage" - revertCode: - PowerShell -ExecutionPolicy Unrestricted -Command " - $package = Get-AppxPackage -AllUsers '{{ $packageName }}'; - if (!$package) { - Write-Error \"Cannot reinstall '{{ $packageName }}'\" -ErrorAction Stop - } - $manifest = $package.InstallLocation + '\AppxManifest.xml'; - Add-AppxPackage -DisableDevelopmentMode -Register \"$manifest\" " + call: + function: RunPowerShell + parameters: + code: Get-AppxPackage '{{ $packageName }}' | Remove-AppxPackage + revertCode: + $package = Get-AppxPackage -AllUsers '{{ $packageName }}'; + if (!$package) { + Write-Error \"Cannot reinstall '{{ $packageName }}'\" -ErrorAction Stop + } + $manifest = $package.InstallLocation + '\AppxManifest.xml'; + Add-AppxPackage -DisableDevelopmentMode -Register \"$manifest\" - name: UninstallSystemApp parameters: [ packageName ] # It simply renames files # Because system apps are non removable (check: (Get-AppxPackage -AllUsers 'Windows.CBSPreview').NonRemovable) # Otherwise they throw 0x80070032 when trying to uninstall them - code: - PowerShell -Command " - $package = (Get-AppxPackage -AllUsers '{{ $packageName }}'); - if (!$package) { - Write-Host 'Not installed'; - exit 0; - } - $directories = @($package.InstallLocation, \"$env:LOCALAPPDATA\Packages\$($package.PackageFamilyName)\"); - foreach($dir in $directories) { - if ( !$dir -Or !(Test-Path \"$dir\") ) { continue; } - cmd /c ('takeown /f \"' + $dir + '\" /r /d y 1> nul'); if($LASTEXITCODE) { throw 'Failed to take ownership'; } - cmd /c ('icacls \"' + $dir + '\" /grant administrators:F /t 1> nul'); if($LASTEXITCODE) { throw 'Failed to take ownership'; } - $files = Get-ChildItem -File -Path $dir -Recurse -Force; - foreach($file in $files) { - if($file.Name.EndsWith('.OLD')) { continue; } - $newName = $file.FullName + '.OLD'; - Write-Host \"Rename '$($file.FullName)' to '$newName'\"; - Move-Item -LiteralPath \"$($file.FullName)\" -Destination \"$newName\" -Force; - } - };" - revertCode: - PowerShell -Command " - $package = (Get-AppxPackage -AllUsers '{{ $packageName }}'); - if (!$package) { - Write-Error 'App could not be found' -ErrorAction Stop; - } - $directories = @($package.InstallLocation, \"$env:LOCALAPPDATA\Packages\$($package.PackageFamilyName)\"); - foreach($dir in $directories) { - if ( !$dir -Or !(Test-Path \"$dir\") ) { continue; } - cmd /c ('takeown /f \"' + $dir + '\" /r /d y 1> nul'); if($LASTEXITCODE) { throw 'Failed to take ownership'; } - cmd /c ('icacls \"' + $dir + '\" /grant administrators:F /t 1> nul'); if($LASTEXITCODE) { throw 'Failed to take ownership'; } - $files = Get-ChildItem -File -Path \"$dir\*.OLD\" -Recurse -Force; - foreach($file in $files) { - $newName = $file.FullName.Substring(0, $file.FullName.Length - 4); - Write-Host \"Rename '$($file.FullName)' to '$newName'\"; - Move-Item -LiteralPath \"$($file.FullName)\" -Destination \"$newName\" -Force; - } - };" + call: + function: RunPowerShell + parameters: + code: + $package = (Get-AppxPackage -AllUsers '{{ $packageName }}'); + if (!$package) { + Write-Host 'Not installed'; + exit 0; + } + $directories = @($package.InstallLocation, \"$env:LOCALAPPDATA\Packages\$($package.PackageFamilyName)\"); + foreach($dir in $directories) { + if ( !$dir -Or !(Test-Path \"$dir\") ) { continue; } + cmd /c ('takeown /f \"' + $dir + '\" /r /d y 1> nul'); if($LASTEXITCODE) { throw 'Failed to take ownership'; } + cmd /c ('icacls \"' + $dir + '\" /grant administrators:F /t 1> nul'); if($LASTEXITCODE) { throw 'Failed to take ownership'; } + $files = Get-ChildItem -File -Path $dir -Recurse -Force; + foreach($file in $files) { + if($file.Name.EndsWith('.OLD')) { continue; } + $newName = $file.FullName + '.OLD'; + Write-Host \"Rename '$($file.FullName)' to '$newName'\"; + Move-Item -LiteralPath \"$($file.FullName)\" -Destination \"$newName\" -Force; + } + } + revertCode: + $package = (Get-AppxPackage -AllUsers '{{ $packageName }}'); + if (!$package) { + Write-Error 'App could not be found' -ErrorAction Stop; + } + $directories = @($package.InstallLocation, \"$env:LOCALAPPDATA\Packages\$($package.PackageFamilyName)\"); + foreach($dir in $directories) { + if ( !$dir -Or !(Test-Path \"$dir\") ) { continue; } + cmd /c ('takeown /f \"' + $dir + '\" /r /d y 1> nul'); if($LASTEXITCODE) { throw 'Failed to take ownership'; } + cmd /c ('icacls \"' + $dir + '\" /grant administrators:F /t 1> nul'); if($LASTEXITCODE) { throw 'Failed to take ownership'; } + $files = Get-ChildItem -File -Path \"$dir\*.OLD\" -Recurse -Force; + foreach($file in $files) { + $newName = $file.FullName.Substring(0, $file.FullName.Length - 4); + Write-Host \"Rename '$($file.FullName)' to '$newName'\"; + Move-Item -LiteralPath \"$($file.FullName)\" -Destination \"$newName\" -Force; + } + } - name: UninstallCapability parameters: [ capabilityName ] - code: PowerShell -Command "Get-WindowsCapability -Online -Name '{{ $capabilityName }}*' | Remove-WindowsCapability -Online" - revertCode: PowerShell -Command "$capability = Get-WindowsCapability -Online -Name '{{ $capabilityName }}*'; Add-WindowsCapability -Name \"$capability.Name\" -Online" + call: + function: RunPowerShell + parameters: + code: Get-WindowsCapability -Online -Name '{{ $capabilityName }}*' | Remove-WindowsCapability -Online + revertCode: + $capability = Get-WindowsCapability -Online -Name '{{ $capabilityName }}*'; + Add-WindowsCapability -Name \"$capability.Name\" -Online - name: RenameSystemFile parameters: [ filePath ] @@ -4250,15 +4269,21 @@ functions: - name: SetVsCodeSetting parameters: [ setting, powerShellValue ] - code: - Powershell -Command " - $jsonfile = \"$env:APPDATA\Code\User\settings.json\"; - $json = Get-Content $jsonfile | Out-String | ConvertFrom-Json; - $json | Add-Member -Type NoteProperty -Name '{{ $setting }}' -Value {{ $powerShellValue }} -Force; - $json | ConvertTo-Json | Set-Content $jsonfile;" - revertCode: - Powershell -Command " - $jsonfile = \"$env:APPDATA\Code\User\settings.json\"; - $json = Get-Content $jsonfile | ConvertFrom-Json; - $json.PSObject.Properties.Remove('{{ $setting }}'); - $json | ConvertTo-Json | Set-Content $jsonfile;" \ No newline at end of file + call: + function: RunPowerShell + parameters: + code: + $jsonfile = \"$env:APPDATA\Code\User\settings.json\"; + $json = Get-Content $jsonfile | Out-String | ConvertFrom-Json; + $json | Add-Member -Type NoteProperty -Name '{{ $setting }}' -Value {{ $powerShellValue }} -Force; + $json | ConvertTo-Json | Set-Content $jsonfile; + revertCode: + $jsonfile = \"$env:APPDATA\Code\User\settings.json\"; + $json = Get-Content $jsonfile | ConvertFrom-Json; + $json.PSObject.Properties.Remove('{{ $setting }}'); + $json | ConvertTo-Json | Set-Content $jsonfile; + - + name: RunPowerShell + parameters: [ code, revertCode ] + code: PowerShell -ExecutionPolicy Unrestricted -Command "{{ $code }}" + revertCode: PowerShell -ExecutionPolicy Unrestricted -Command "{{ $revertCode }}" diff --git a/src/domain/ScriptCode.ts b/src/domain/ScriptCode.ts index 3ab52570..cd81b687 100644 --- a/src/domain/ScriptCode.ts +++ b/src/domain/ScriptCode.ts @@ -4,16 +4,17 @@ export class ScriptCode implements IScriptCode { constructor( public readonly execute: string, public readonly revert: string, - scriptName: string, syntax: ILanguageSyntax) { - if (!scriptName) { throw new Error('script name is undefined'); } - if (!syntax) { throw new Error('syntax is undefined'); } - validateCode(scriptName, execute, syntax); + if (!syntax) { throw new Error('undefined syntax'); } + validateCode(execute, syntax); if (revert) { - scriptName = `${scriptName} (revert)`; - validateCode(scriptName, revert, syntax); - if (execute === revert) { - throw new Error(`${scriptName}: Code itself and its reverting code cannot be the same`); + try { + validateCode(revert, syntax); + if (execute === revert) { + throw new Error(`Code itself and its reverting code cannot be the same`); + } + } catch (err) { + throw Error(`(revert): ${err.message}`); } } } @@ -24,21 +25,21 @@ export interface ILanguageSyntax { readonly commonCodeParts: string[]; } -function validateCode(name: string, code: string, syntax: ILanguageSyntax): void { +function validateCode(code: string, syntax: ILanguageSyntax): void { if (!code || code.length === 0) { - throw new Error(`code of ${name} is empty or undefined`); + throw new Error(`code is empty or undefined`); } - ensureNoEmptyLines(name, code); - ensureCodeHasUniqueLines(name, code, syntax); + ensureNoEmptyLines(code); + ensureCodeHasUniqueLines(code, syntax); } -function ensureNoEmptyLines(name: string, code: string): void { +function ensureNoEmptyLines(code: string): void { if (code.split('\n').some((line) => line.trim().length === 0)) { - throw Error(`script has empty lines "${name}"`); + throw Error(`script has empty lines`); } } -function ensureCodeHasUniqueLines(name: string, code: string, syntax: ILanguageSyntax): void { +function ensureCodeHasUniqueLines(code: string, syntax: ILanguageSyntax): void { const lines = code.split('\n') .filter((line) => !shouldIgnoreLine(line, syntax)); if (lines.length === 0) { @@ -46,7 +47,7 @@ function ensureCodeHasUniqueLines(name: string, code: string, syntax: ILanguageS } const duplicateLines = lines.filter((e, i, a) => a.indexOf(e) !== i); if (duplicateLines.length !== 0) { - throw Error(`Duplicates detected in script "${name}":\n ${duplicateLines.join('\n')}`); + throw Error(`Duplicates detected in script :\n ${duplicateLines.join('\n')}`); } } diff --git a/tests/unit/application/Parser/CategoryCollectionParser.spec.ts b/tests/unit/application/Parser/CategoryCollectionParser.spec.ts index bfceeca7..fcb50c6a 100644 --- a/tests/unit/application/Parser/CategoryCollectionParser.spec.ts +++ b/tests/unit/application/Parser/CategoryCollectionParser.spec.ts @@ -105,7 +105,7 @@ describe('CategoryCollectionParser', () => { const scriptName = 'script-name'; const script = ScriptDataStub.createWithCall({ function: functionName }) .withName(scriptName); - const func = new FunctionDataStub() + const func = FunctionDataStub.createWithCode() .withName(functionName) .withCode(expectedCode); const category = new CategoryDataStub() diff --git a/tests/unit/application/Parser/Script/CategoryCollectionParseContext.spec.ts b/tests/unit/application/Parser/Script/CategoryCollectionParseContext.spec.ts index 86fb9ff3..5d3160e6 100644 --- a/tests/unit/application/Parser/Script/CategoryCollectionParseContext.spec.ts +++ b/tests/unit/application/Parser/Script/CategoryCollectionParseContext.spec.ts @@ -29,7 +29,7 @@ describe('CategoryCollectionParseContext', () => { // arrange const expectedError = 'undefined scripting'; const scripting = undefined; - const functionsData = [ new FunctionDataStub() ]; + const functionsData = [ FunctionDataStub.createWithCode() ]; // act const act = () => new CategoryCollectionParseContext(functionsData, scripting); // assert @@ -39,7 +39,7 @@ describe('CategoryCollectionParseContext', () => { describe('compiler', () => { it('constructed as expected', () => { // arrange - const functionsData = [ new FunctionDataStub() ]; + const functionsData = [ FunctionDataStub.createWithCode() ]; const syntax = new LanguageSyntaxStub(); const expected = new ScriptCompiler(functionsData, syntax); const language = ScriptingLanguage.shellscript; diff --git a/tests/unit/application/Parser/Script/Compiler/Expressions/ExpressionsCompiler.spec.ts b/tests/unit/application/Parser/Script/Compiler/Expressions/ExpressionsCompiler.spec.ts new file mode 100644 index 00000000..ccd49fc1 --- /dev/null +++ b/tests/unit/application/Parser/Script/Compiler/Expressions/ExpressionsCompiler.spec.ts @@ -0,0 +1,99 @@ +import 'mocha'; +import { expect } from 'chai'; +import { ExpressionsCompiler } from '@/application/Parser/Script/Compiler/Expressions/ExpressionsCompiler'; + +describe('ExpressionsCompiler', () => { + describe('parameter substitution', () => { + describe('substitutes as expected', () => { + // arrange + const testCases = [ { + name: 'with different parameters', + code: 'He{{ $firstParameter }} {{ $secondParameter }}!', + parameters: { + firstParameter: 'llo', + secondParameter: 'world', + }, + expected: 'Hello world!', + }, { + name: 'with single parameter', + code: '{{ $parameter }}!', + parameters: { + parameter: 'Hodor', + }, + expected: 'Hodor!', + + }]; + for (const testCase of testCases) { + it(testCase.name, () => { + const sut = new MockableExpressionsCompiler(); + // act + const actual = sut.compileExpressions(testCase.code, testCase.parameters); + // assert + expect(actual).to.equal(testCase.expected); + }); + } + }); + describe('throws when expected value is not provided', () => { + // arrange + const noParameterTestCases = [ + { + name: 'empty parameters', + code: '{{ $parameter }}!', + parameters: {}, + expectedError: 'parameter value(s) not provided for: "parameter"', + }, + { + name: 'undefined parameters', + code: '{{ $parameter }}!', + parameters: undefined, + expectedError: 'parameter value(s) not provided for: "parameter"', + }, + { + name: 'unnecessary parameter provided', + code: '{{ $parameter }}!', + parameters: { + unnecessaryParameter: 'unnecessaryValue', + }, + expectedError: 'parameter value(s) not provided for: "parameter"', + }, + { + name: 'undefined value', + code: '{{ $parameter }}!', + parameters: { + parameter: undefined, + }, + expectedError: 'parameter value(s) not provided for: "parameter"', + }, + { + name: 'multiple values are not', + code: '{{ $parameter1 }}, {{ $parameter2 }}, {{ $parameter3 }}', + parameters: {}, + expectedError: 'parameter value(s) not provided for: "parameter1", "parameter2", "parameter3"', + }, + { + name: 'some values are provided', + code: '{{ $parameter1 }}, {{ $parameter2 }}, {{ $parameter3 }}', + parameters: { + parameter2: 'value', + }, + expectedError: 'parameter value(s) not provided for: "parameter1", "parameter3"', + }, + ]; + for (const testCase of noParameterTestCases) { + it(testCase.name, () => { + const sut = new MockableExpressionsCompiler(); + // act + const act = () => sut.compileExpressions(testCase.code, testCase.parameters); + // assert + expect(act).to.throw(testCase.expectedError); + }); + } + }); + }); +}); + +class MockableExpressionsCompiler extends ExpressionsCompiler { + constructor() { + super(); + } +} diff --git a/tests/unit/application/Parser/Script/Compiler/ILCode.spec.ts b/tests/unit/application/Parser/Script/Compiler/Expressions/ILCode.spec.ts similarity index 99% rename from tests/unit/application/Parser/Script/Compiler/ILCode.spec.ts rename to tests/unit/application/Parser/Script/Compiler/Expressions/ILCode.spec.ts index ecacee4e..c59cf15c 100644 --- a/tests/unit/application/Parser/Script/Compiler/ILCode.spec.ts +++ b/tests/unit/application/Parser/Script/Compiler/Expressions/ILCode.spec.ts @@ -1,6 +1,6 @@ import 'mocha'; import { expect } from 'chai'; -import { generateIlCode } from '@/application/Parser/Script/Compiler/ILCode'; +import { generateIlCode } from '@/application/Parser/Script/Compiler/Expressions/ILCode'; describe('ILCode', () => { describe('getUniqueParameterNames', () => { diff --git a/tests/unit/application/Parser/Script/Compiler/Function/FunctionCompiler.spec.ts b/tests/unit/application/Parser/Script/Compiler/Function/FunctionCompiler.spec.ts new file mode 100644 index 00000000..c6960f14 --- /dev/null +++ b/tests/unit/application/Parser/Script/Compiler/Function/FunctionCompiler.spec.ts @@ -0,0 +1,192 @@ +import 'mocha'; +import { expect } from 'chai'; +import { ISharedFunction } from '@/application/Parser/Script/Compiler/Function/ISharedFunction'; +import { FunctionData } from 'js-yaml-loader!*'; +import { IFunctionCallCompiler } from '@/application/Parser/Script/Compiler/FunctionCall/IFunctionCallCompiler'; +import { FunctionCompiler } from '@/application/Parser/Script/Compiler/Function/FunctionCompiler'; +import { FunctionCallCompilerStub } from '../../../../../stubs/FunctionCallCompilerStub'; +import { FunctionDataStub } from '../../../../../stubs/FunctionDataStub'; + +describe('FunctionsCompiler', () => { + describe('compileFunctions', () => { + describe('validates functions', () => { + it('throws if one of the functions is undefined', () => { + // arrange + const expectedError = `some functions are undefined`; + const functions = [ FunctionDataStub.createWithCode(), undefined ]; + const sut = new MockableFunctionCompiler(); + // act + const act = () => sut.compileFunctions(functions); + // assert + expect(act).to.throw(expectedError); + }); + it('throws when functions have same names', () => { + // arrange + const name = 'same-func-name'; + const expectedError = `duplicate function name: "${name}"`; + const functions = [ + FunctionDataStub.createWithCode().withName(name), + FunctionDataStub.createWithCode().withName(name), + ]; + const sut = new MockableFunctionCompiler(); + // act + const act = () => sut.compileFunctions(functions); + // assert + expect(act).to.throw(expectedError); + }); + it('throws when function parameters have same names', () => { + // arrange + const parameterName = 'duplicate-parameter'; + const func = FunctionDataStub.createWithCall() + .withParameters(parameterName, parameterName); + const expectedError = `"${func.name}": duplicate parameter name: "${parameterName}"`; + const sut = new MockableFunctionCompiler(); + // act + const act = () => sut.compileFunctions([ func ]); + // assert + expect(act).to.throw(expectedError); + }); + describe('throws when when function have duplicate code', () => { + it('code', () => { + // arrange + const code = 'duplicate-code'; + const expectedError = `duplicate "code" in functions: "${code}"`; + const functions = [ + FunctionDataStub.createWithoutCallOrCodes().withName('func-1').withCode(code), + FunctionDataStub.createWithoutCallOrCodes().withName('func-2').withCode(code), + ]; + const sut = new MockableFunctionCompiler(); + // act + const act = () => sut.compileFunctions(functions); + // assert + expect(act).to.throw(expectedError); + }); + it('revertCode', () => { + // arrange + const revertCode = 'duplicate-revert-code'; + const expectedError = `duplicate "revertCode" in functions: "${revertCode}"`; + const functions = [ + FunctionDataStub.createWithoutCallOrCodes() + .withName('func-1').withCode('code-1').withRevertCode(revertCode), + FunctionDataStub.createWithoutCallOrCodes() + .withName('func-2').withCode('code-2').withRevertCode(revertCode), + ]; + const sut = new MockableFunctionCompiler(); + // act + const act = () => sut.compileFunctions(functions); + // assert + expect(act).to.throw(expectedError); + }); + }); + it('both code and call are defined', () => { + // arrange + const functionName = 'invalid-function'; + const expectedError = `both "code" and "call" are defined in "${functionName}"`; + const invalidFunction = FunctionDataStub.createWithoutCallOrCodes() + .withName(functionName) + .withCode('code') + .withMockCall(); + const sut = new MockableFunctionCompiler(); + // act + const act = () => sut.compileFunctions([ invalidFunction ]); + // assert + expect(act).to.throw(expectedError); + }); + it('neither code and call is defined', () => { + // arrange + const functionName = 'invalid-function'; + const expectedError = `neither "code" or "call" is defined in "${functionName}"`; + const invalidFunction = FunctionDataStub.createWithoutCallOrCodes() + .withName(functionName); + const sut = new MockableFunctionCompiler(); + // act + const act = () => sut.compileFunctions([ invalidFunction ]); + // assert + expect(act).to.throw(expectedError); + }); + }); + it('returns empty with empty functions', () => { + // arrange + const emptyValues = [ [], undefined ]; + const sut = new MockableFunctionCompiler(); + for (const emptyFunctions of emptyValues) { + // act + const actual = sut.compileFunctions(emptyFunctions); + // assert + expect(actual).to.not.equal(undefined); + } + }); + it('parses single function with code as expected', () => { + // arrange + const name = 'function-name'; + const expected = FunctionDataStub + .createWithoutCallOrCodes() + .withName(name) + .withCode('expected-code') + .withRevertCode('expected-revert-code') + .withParameters('expected-parameter-1', 'expected-parameter-2'); + const sut = new MockableFunctionCompiler(); + // act + const collection = sut.compileFunctions([ expected ]); + // expect + const actual = collection.getFunctionByName(name); + expectEqualFunctions(expected, actual); + }); + it('parses function with call as expected', () => { + // arrange + const calleeName = 'callee-function'; + const caller = FunctionDataStub.createWithoutCallOrCodes() + .withName('caller-function') + .withCall({ function: calleeName }); + const callee = FunctionDataStub.createWithoutCallOrCodes() + .withName(calleeName) + .withCode('expected-code') + .withRevertCode('expected-revert-code'); + const sut = new MockableFunctionCompiler(); + // act + const collection = sut.compileFunctions([ caller, callee ]); + // expect + const actual = collection.getFunctionByName(caller.name); + expectEqualFunctionCode(callee, actual); + }); + it('parses multiple functions with call as expected', () => { + // arrange + const calleeName = 'callee-function'; + const caller1 = FunctionDataStub.createWithoutCallOrCodes() + .withName('caller-function') + .withCall({ function: calleeName }); + const caller2 = FunctionDataStub.createWithoutCallOrCodes() + .withName('caller-function-2') + .withCall({ function: calleeName }); + const callee = FunctionDataStub.createWithoutCallOrCodes() + .withName(calleeName) + .withCode('expected-code') + .withRevertCode('expected-revert-code'); + const sut = new MockableFunctionCompiler(); + // act + const collection = sut.compileFunctions([ caller1, caller2, callee ]); + // expect + const compiledCaller1 = collection.getFunctionByName(caller1.name); + const compiledCaller2 = collection.getFunctionByName(caller2.name); + expectEqualFunctionCode(callee, compiledCaller1); + expectEqualFunctionCode(callee, compiledCaller2); + }); + }); +}); + +function expectEqualFunctions(expected: FunctionData, actual: ISharedFunction) { + expect(actual.name).to.equal(expected.name); + expect(actual.parameters).to.deep.equal(expected.parameters); + expectEqualFunctionCode(expected, actual); +} + +function expectEqualFunctionCode(expected: FunctionData, actual: ISharedFunction) { + expect(actual.code).to.equal(expected.code); + expect(actual.revertCode).to.equal(expected.revertCode); +} + +class MockableFunctionCompiler extends FunctionCompiler { + constructor(functionCallCompiler: IFunctionCallCompiler = new FunctionCallCompilerStub()) { + super(functionCallCompiler); + } +} diff --git a/tests/unit/application/Parser/Script/Compiler/Function/SharedFunction.spec.ts b/tests/unit/application/Parser/Script/Compiler/Function/SharedFunction.spec.ts new file mode 100644 index 00000000..6dcb8052 --- /dev/null +++ b/tests/unit/application/Parser/Script/Compiler/Function/SharedFunction.spec.ts @@ -0,0 +1,128 @@ +import 'mocha'; +import { expect } from 'chai'; +import { SharedFunction } from '@/application/Parser/Script/Compiler/Function/SharedFunction'; + +describe('SharedFunction', () => { + describe('name', () => { + it('sets as expected', () => { + // arrange + const expected = 'expected-function-name'; + // act + const sut = new SharedFunctionBuilder() + .withName(expected) + .build(); + // assert + expect(sut.name).equal(expected); + }); + it('throws if empty or undefined', () => { + // arrange + const expectedError = 'undefined function name'; + const invalidValues = [ undefined, '' ]; + for (const invalidValue of invalidValues) { + // act + const act = () => new SharedFunctionBuilder() + .withName(invalidValue) + .build(); + // assert + expect(act).to.throw(expectedError); + } + }); + }); + describe('parameters', () => { + it('sets as expected', () => { + // arrange + const expected = [ 'expected-parameter' ]; + // act + const sut = new SharedFunctionBuilder() + .withParameters(expected) + .build(); + // assert + expect(sut.parameters).to.deep.equal(expected); + }); + it('returns empty array if undefined', () => { + // arrange + const expected = [ ]; + const value = undefined; + // act + const sut = new SharedFunctionBuilder() + .withParameters(value) + .build(); + // assert + expect(sut.parameters).to.not.equal(undefined); + expect(sut.parameters).to.deep.equal(expected); + }); + }); + describe('code', () => { + it('sets as expected', () => { + // arrange + const expected = 'expected-code'; + // act + const sut = new SharedFunctionBuilder() + .withCode(expected) + .build(); + // assert + expect(sut.code).equal(expected); + }); + it('throws if empty or undefined', () => { + // arrange + const functionName = 'expected-function-name'; + const expectedError = `undefined function ("${functionName}") code`; + const invalidValues = [ undefined, '' ]; + for (const invalidValue of invalidValues) { + // act + const act = () => new SharedFunctionBuilder() + .withName(functionName) + .withCode(invalidValue) + .build(); + // assert + expect(act).to.throw(expectedError); + } + }); + }); + describe('revertCode', () => { + it('sets as expected', () => { + // arrange + const testData = [ 'expected-revert-code', undefined, '' ]; + for (const data of testData) { + // act + const sut = new SharedFunctionBuilder() + .withRevertCode(data) + .build(); + // assert + expect(sut.revertCode).equal(data); + } + }); + }); +}); + +class SharedFunctionBuilder { + private name = 'name'; + private parameters: readonly string[] = [ 'parameter' ]; + private code = 'code'; + private revertCode = 'revert-code'; + + public build(): SharedFunction { + return new SharedFunction( + this.name, + this.parameters, + this.code, + this.revertCode, + ); + } + public withName(name: string) { + this.name = name; + return this; + } + public withParameters(parameters: readonly string[]) { + this.parameters = parameters; + return this; + } + public withCode(code: string) { + this.code = code; + return this; + } + public withRevertCode(revertCode: string) { + this.revertCode = revertCode; + return this; + } +} diff --git a/tests/unit/application/Parser/Script/Compiler/Function/SharedFunctionCollection.spec.ts b/tests/unit/application/Parser/Script/Compiler/Function/SharedFunctionCollection.spec.ts new file mode 100644 index 00000000..165694b3 --- /dev/null +++ b/tests/unit/application/Parser/Script/Compiler/Function/SharedFunctionCollection.spec.ts @@ -0,0 +1,74 @@ +import 'mocha'; +import { expect } from 'chai'; +import { SharedFunctionCollection } from '@/application/Parser/Script/Compiler/Function/SharedFunctionCollection'; +import { SharedFunctionStub } from '../../../../../stubs/SharedFunctionStub'; + +describe('SharedFunctionCollection', () => { + describe('addFunction', () => { + it('throws if function is undefined', () => { + // arrange + const expectedError = 'undefined function'; + const func = undefined; + const sut = new SharedFunctionCollection(); + // act + const act = () => sut.addFunction(func); + // assert + expect(act).to.throw(expectedError); + }); + it('throws if function with same name already exists', () => { + // arrange + const functionName = 'duplicate-function'; + const expectedError = `function with name ${functionName} already exists`; + const func = new SharedFunctionStub() + .withName('duplicate-function'); + const sut = new SharedFunctionCollection(); + sut.addFunction(func); + // act + const act = () => sut.addFunction(func); + // assert + expect(act).to.throw(expectedError); + + }); + }); + describe('getFunctionByName', () => { + it('throws if name is undefined', () => { + // arrange + const expectedError = 'undefined function name'; + const invalidValues = [ undefined, '' ]; + const sut = new SharedFunctionCollection(); + for (const invalidValue of invalidValues) { + const name = invalidValue; + // act + const act = () => sut.getFunctionByName(name); + // assert + expect(act).to.throw(expectedError); + } + }); + it('throws if function does not exist', () => { + // arrange + const name = 'unique-name'; + const expectedError = `called function is not defined "${name}"`; + const func = new SharedFunctionStub() + .withName('unexpected-name'); + const sut = new SharedFunctionCollection(); + sut.addFunction(func); + // act + const act = () => sut.getFunctionByName(name); + // assert + expect(act).to.throw(expectedError); + }); + it('returns existing function', () => { + // arrange + const name = 'expected-function-name'; + const expected = new SharedFunctionStub() + .withName(name); + const sut = new SharedFunctionCollection(); + sut.addFunction(new SharedFunctionStub().withName('another-function-name')); + sut.addFunction(expected); + // act + const actual = sut.getFunctionByName(name); + // assert + expect(actual).to.equal(expected); + }); + }); +}); diff --git a/tests/unit/application/Parser/Script/Compiler/FunctionCall/FunctionCallCompiler.spec.ts b/tests/unit/application/Parser/Script/Compiler/FunctionCall/FunctionCallCompiler.spec.ts new file mode 100644 index 00000000..39c6acc5 --- /dev/null +++ b/tests/unit/application/Parser/Script/Compiler/FunctionCall/FunctionCallCompiler.spec.ts @@ -0,0 +1,191 @@ +import 'mocha'; +import { expect } from 'chai'; +import { FunctionCallData, FunctionCallParametersData } from 'js-yaml-loader!*'; +import { FunctionCallCompiler } from '@/application/Parser/Script/Compiler/FunctionCall/FunctionCallCompiler'; +import { ISharedFunctionCollection } from '@/application/Parser/Script/Compiler/Function/ISharedFunctionCollection'; +import { IExpressionsCompiler } from '@/application/Parser/Script/Compiler/Expressions/IExpressionsCompiler'; +import { ExpressionsCompilerStub } from '../../../../../stubs/ExpressionsCompilerStub'; +import { SharedFunctionCollectionStub } from '../../../../../stubs/SharedFunctionCollectionStub'; +import { SharedFunctionStub } from '../../../../../stubs/SharedFunctionStub'; + +describe('FunctionCallCompiler', () => { + describe('compileCall', () => { + describe('call', () => { + it('throws with undefined call', () => { + // arrange + const expectedError = 'undefined call'; + const call = undefined; + const functions = new SharedFunctionCollectionStub(); + const sut = new MockableFunctionCallCompiler(); + // act + const act = () => sut.compileCall(call, functions); + // assert + expect(act).to.throw(expectedError); + }); + it('throws if call is not an object', () => { + // arrange + const expectedError = 'called function(s) must be an object'; + const invalidCalls: readonly any[] = ['string', 33]; + const sut = new MockableFunctionCallCompiler(); + const functions = new SharedFunctionCollectionStub(); + invalidCalls.forEach((invalidCall) => { + // act + const act = () => sut.compileCall(invalidCall, functions); + // assert + expect(act).to.throw(expectedError); + }); + }); + it('throws if call sequence has undefined call', () => { + // arrange + const expectedError = 'undefined function call'; + const call: FunctionCallData[] = [ + { function: 'function-name' }, + undefined, + ]; + const functions = new SharedFunctionCollectionStub(); + const sut = new MockableFunctionCallCompiler(); + // act + const act = () => sut.compileCall(call, functions); + // assert + expect(act).to.throw(expectedError); + }); + it('throws if call sequence has undefined function name', () => { + // arrange + const expectedError = 'empty function name called'; + const call: FunctionCallData[] = [ + { function: 'function-name' }, + { function: undefined }, + ]; + const functions = new SharedFunctionCollectionStub(); + const sut = new MockableFunctionCallCompiler(); + // act + const act = () => sut.compileCall(call, functions); + // assert + expect(act).to.throw(expectedError); + }); + it('throws if call parameters does not match function parameters', () => { + // arrange + const unexpectedCallParameterName = 'unexpected-parameter-name'; + const func = new SharedFunctionStub() + .withName('test-function-name') + .withParameters('another-parameter'); + const expectedError = `function "${func.name}" has unexpected parameter(s) provided: "${unexpectedCallParameterName}"`; + const sut = new MockableFunctionCallCompiler(); + const params: FunctionCallParametersData = { + [`${unexpectedCallParameterName}`]: 'unexpected-parameter-value', + }; + const call: FunctionCallData = { function: func.name, parameters: params }; + const functions = new SharedFunctionCollectionStub().withFunction(func); + // act + const act = () => sut.compileCall(call, functions); + // assert + expect(act).to.throw(expectedError); + }); + }); + describe('functions', () => { + it('throws with undefined functions', () => { + // arrange + const expectedError = 'undefined functions'; + const call: FunctionCallData = { function: 'function-call' }; + const functions = undefined; + const sut = new MockableFunctionCallCompiler(); + // act + const act = () => sut.compileCall(call, functions); + // assert + expect(act).to.throw(expectedError); + }); + it('throws if function does not exist', () => { + // arrange + const expectedError = 'function does not exist'; + const call: FunctionCallData = { function: 'function-call' }; + const functions: ISharedFunctionCollection = { + getFunctionByName: () => { throw new Error(expectedError); }, + }; + const sut = new MockableFunctionCallCompiler(); + // act + const act = () => sut.compileCall(call, functions); + // assert + expect(act).to.throw(expectedError); + }); + }); + describe('builds code as expected', () => { + describe('builds single call as expected', () => { + // arrange + const parametersTestCases = [ + { + name: 'undefined parameters', + parameters: undefined, + parameterValues: undefined, + }, + { + name: 'empty parameters', + parameters: [], + parameterValues: { }, + }, + { + name: 'non-empty parameters', + parameters: [ 'param1', 'param2' ], + parameterValues: { param1: 'value1', param2: 'value2' }, + }, + ]; + for (const testCase of parametersTestCases) { + it(testCase.name, () => { + const expectedExecute = `expected-execute`; + const expectedRevert = `expected-revert`; + const func = new SharedFunctionStub().withParameters(...testCase.parameters); + const functions = new SharedFunctionCollectionStub().withFunction(func); + const call: FunctionCallData = { function: func.name, parameters: testCase.parameterValues }; + const expressionsCompilerMock = new ExpressionsCompilerStub() + .setup(func.code, testCase.parameterValues, expectedExecute) + .setup(func.revertCode, testCase.parameterValues, expectedRevert); + const sut = new MockableFunctionCallCompiler(expressionsCompilerMock); + // act + const actual = sut.compileCall(call, functions); + // assert + expect(actual.code).to.equal(expectedExecute); + expect(actual.revertCode).to.equal(expectedRevert); + }); + } + }); + it('builds call sequence as expected', () => { + // arrange + const firstFunction = new SharedFunctionStub() + .withName('first-function-name') + .withCode('first-function-code') + .withRevertCode('first-function-revert-code'); + const secondFunction = new SharedFunctionStub() + .withName('second-function-name') + .withParameters('testParameter') + .withCode('second-function-code') + .withRevertCode('second-function-revert-code'); + const secondCallArguments = { testParameter: 'testValue' }; + const call: FunctionCallData[] = [ + { function: firstFunction.name }, + { function: secondFunction.name, parameters: secondCallArguments }, + ]; + const expressionsCompilerMock = new ExpressionsCompilerStub() + .setup(firstFunction.code, {}, firstFunction.code) + .setup(firstFunction.revertCode, {}, firstFunction.revertCode) + .setup(secondFunction.code, secondCallArguments, secondFunction.code) + .setup(secondFunction.revertCode, secondCallArguments, secondFunction.revertCode); + const expectedExecute = `${firstFunction.code}\n${secondFunction.code}`; + const expectedRevert = `${firstFunction.revertCode}\n${secondFunction.revertCode}`; + const functions = new SharedFunctionCollectionStub() + .withFunction(firstFunction) + .withFunction(secondFunction); + const sut = new MockableFunctionCallCompiler(expressionsCompilerMock); + // act + const actual = sut.compileCall(call, functions); + // assert + expect(actual.code).to.equal(expectedExecute); + expect(actual.revertCode).to.equal(expectedRevert); + }); + }); + }); +}); + +class MockableFunctionCallCompiler extends FunctionCallCompiler { + constructor(expressionsCompiler: IExpressionsCompiler = new ExpressionsCompilerStub()) { + super(expressionsCompiler); + } +} diff --git a/tests/unit/application/Parser/Script/Compiler/ScriptCompiler.spec.ts b/tests/unit/application/Parser/Script/Compiler/ScriptCompiler.spec.ts index 94c3da86..1631b890 100644 --- a/tests/unit/application/Parser/Script/Compiler/ScriptCompiler.spec.ts +++ b/tests/unit/application/Parser/Script/Compiler/ScriptCompiler.spec.ts @@ -1,13 +1,17 @@ import 'mocha'; import { expect } from 'chai'; import { ScriptCompiler } from '@/application/Parser/Script/Compiler/ScriptCompiler'; -import { FunctionData, ScriptData, FunctionCallData, ScriptFunctionCallData, FunctionCallParametersData } from 'js-yaml-loader!@/*'; -import { IScriptCode } from '@/domain/IScriptCode'; +import { FunctionData } from 'js-yaml-loader!@/*'; import { ILanguageSyntax } from '@/domain/ScriptCode'; -import { IScriptCompiler } from '@/application/Parser/Script/Compiler/IScriptCompiler'; +import { IFunctionCompiler } from '@/application/Parser/Script/Compiler/Function/IFunctionCompiler'; +import { IFunctionCallCompiler } from '@/application/Parser/Script/Compiler/FunctionCall/IFunctionCallCompiler'; +import { ICompiledCode } from '@/application/Parser/Script/Compiler/FunctionCall/ICompiledCode'; import { LanguageSyntaxStub } from '../../../../stubs/LanguageSyntaxStub'; import { ScriptDataStub } from '../../../../stubs/ScriptDataStub'; import { FunctionDataStub } from '../../../../stubs/FunctionDataStub'; +import { FunctionCallCompilerStub } from '../../../../stubs/FunctionCallCompilerStub'; +import { FunctionCompilerStub } from '../../../../stubs/FunctionCompilerStub'; +import { SharedFunctionCollectionStub } from '../../../../stubs/SharedFunctionCollectionStub'; describe('ScriptCompiler', () => { describe('ctor', () => { @@ -22,88 +26,20 @@ describe('ScriptCompiler', () => { // assert expect(act).to.throw(expectedError); }); - it('throws if one of the functions is undefined', () => { - // arrange - const expectedError = `some functions are undefined`; - const functions = [ new FunctionDataStub(), undefined ]; - // act - const act = () => new ScriptCompilerBuilder() - .withFunctions(...functions) - .build(); - // assert - expect(act).to.throw(expectedError); - }); - it('throws when functions have same names', () => { - // arrange - const name = 'same-func-name'; - const expectedError = `duplicate function name: "${name}"`; - const functions = [ - new FunctionDataStub().withName(name), - new FunctionDataStub().withName(name), - ]; - // act - const act = () => new ScriptCompilerBuilder() - .withFunctions(...functions) - .build(); - // assert - expect(act).to.throw(expectedError); - }); - it('throws when function parameters have same names', () => { - // arrange - const parameterName = 'duplicate-parameter'; - const func = new FunctionDataStub() - .withParameters(parameterName, parameterName); - const expectedError = `"${func.name}": duplicate parameter name: "${parameterName}"`; - // act - const act = () => new ScriptCompilerBuilder() - .withFunctions(func) - .build(); - // assert - expect(act).to.throw(expectedError); - }); - describe('throws when when function have duplicate code', () => { - it('code', () => { - // arrange - const code = 'duplicate-code'; - const expectedError = `duplicate "code" in functions: "${code}"`; - const functions = [ - new FunctionDataStub().withName('func-1').withCode(code), - new FunctionDataStub().withName('func-2').withCode(code), - ]; - // act - const act = () => new ScriptCompilerBuilder() - .withFunctions(...functions) - .build(); - // assert - expect(act).to.throw(expectedError); - }); - it('revertCode', () => { - // arrange - const revertCode = 'duplicate-revert-code'; - const expectedError = `duplicate "revertCode" in functions: "${revertCode}"`; - const functions = [ - new FunctionDataStub().withName('func-1').withCode('code-1').withRevertCode(revertCode), - new FunctionDataStub().withName('func-2').withCode('code-2').withRevertCode(revertCode), - ]; - // act - const act = () => new ScriptCompilerBuilder() - .withFunctions(...functions) - .build(); - // assert - expect(act).to.throw(expectedError); - }); - }); - it('can construct with empty functions', () => { - // arrange - const builder = new ScriptCompilerBuilder() - .withEmptyFunctions(); - // act - const act = () => builder.build(); - // assert - expect(act).to.not.throw(); - }); }); describe('canCompile', () => { + it('throws if script is undefined', () => { + // arrange + const expectedError = 'undefined script'; + const argument = undefined; + const builder = new ScriptCompilerBuilder() + .withEmptyFunctions() + .build(); + // act + const act = () => builder.canCompile(argument); + // assert + expect(act).to.throw(expectedError); + }); it('returns true if "call" is defined', () => { // arrange const sut = new ScriptCompilerBuilder() @@ -128,274 +64,97 @@ describe('ScriptCompiler', () => { }); }); describe('compile', () => { - describe('invalid state', () => { - it('throws if functions are empty', () => { - // arrange - const expectedError = 'cannot compile without shared functions'; - const sut = new ScriptCompilerBuilder() - .withEmptyFunctions() - .build(); - const script = ScriptDataStub.createWithCall(); - // act - const act = () => sut.compile(script); - // assert - expect(act).to.throw(expectedError); - }); - it('throws if call is not an object', () => { - // arrange - const expectedError = 'called function(s) must be an object'; - const invalidValues = [undefined, 'string', 33]; - const sut = new ScriptCompilerBuilder() - .withSomeFunctions() - .build(); - invalidValues.forEach((invalidValue) => { - const script = ScriptDataStub.createWithoutCallOrCodes() // because call ctor overwrites "undefined" - .withCall(invalidValue as any); - // act - const act = () => sut.compile(script); - // assert - expect(act).to.throw(expectedError); - }); - }); - describe('invalid function reference', () => { - it('throws if function does not exist', () => { - // arrange - const sut = new ScriptCompilerBuilder() - .withSomeFunctions() - .build(); - const nonExistingFunctionName = 'non-existing-func'; - const expectedError = `called function is not defined "${nonExistingFunctionName}"`; - const call: ScriptFunctionCallData = { function: nonExistingFunctionName }; - const script = ScriptDataStub.createWithCall(call); - // act - const act = () => sut.compile(script); - // assert - expect(act).to.throw(expectedError); - }); - it('throws if function is undefined', () => { - // arrange - const existingFunctionName = 'existing-func'; - const sut = new ScriptCompilerBuilder() - .withFunctionNames(existingFunctionName) - .build(); - const call: ScriptFunctionCallData = [ - { function: existingFunctionName }, - undefined, - ]; - const script = ScriptDataStub.createWithCall(call); - const expectedError = `undefined function call in script "${script.name}"`; - // act - const act = () => sut.compile(script); - // assert - expect(act).to.throw(expectedError); - }); - it('throws if function name is not given', () => { - // arrange - const existingFunctionName = 'existing-func'; - const sut = new ScriptCompilerBuilder() - .withFunctionNames(existingFunctionName) - .build(); - const call: FunctionCallData[] = [ - { function: existingFunctionName }, - { function: undefined }]; - const script = ScriptDataStub.createWithCall(call); - const expectedError = `empty function name called in script "${script.name}"`; - // act - const act = () => sut.compile(script); - // assert - expect(act).to.throw(expectedError); - }); - }); - it('throws if provided parameters does not match given ones', () => { - // arrange - const unexpectedParameterName = 'unexpected-parameter-name'; - const functionName = 'test-function-name'; - const expectedError = `function "${functionName}" has unexpected parameter(s) provided: "${unexpectedParameterName}"`; - const sut = new ScriptCompilerBuilder() - .withFunctions( - new FunctionDataStub() - .withName(functionName) - .withParameters('another-parameter')) - .build(); - const params: FunctionCallParametersData = {}; - params[unexpectedParameterName] = 'unexpected-parameter-value'; - const call: ScriptFunctionCallData = { function: functionName, parameters: params }; - const script = ScriptDataStub.createWithCall(call); - // act - const act = () => sut.compile(script); - // assert - expect(act).to.throw(expectedError); - }); + it('throws if script is undefined', () => { + // arrange + const expectedError = 'undefined script'; + const argument = undefined; + const builder = new ScriptCompilerBuilder() + .withEmptyFunctions() + .build(); + // act + const act = () => builder.compile(argument); + // assert + expect(act).to.throw(expectedError); }); - describe('builds code as expected', () => { - it('creates code with expected syntax', () => { // test through script validation logic - // act - const commentDelimiter = 'should not throw'; - const syntax = new LanguageSyntaxStub().withCommentDelimiters(commentDelimiter); - const func = new FunctionDataStub() - .withCode(`${commentDelimiter} duplicate-line\n${commentDelimiter} duplicate-line`); - const sut = new ScriptCompilerBuilder() - .withFunctions(func) - .withSyntax(syntax) - .build(); - const call: FunctionCallData = { function: func.name }; - const script = ScriptDataStub.createWithCall(call); - // act - const act = () => sut.compile(script); - // assert - expect(act).to.not.throw(); - }); - it('builds single call as expected', () => { - // arrange - const functionName = 'testSharedFunction'; - const expectedExecute = `expected-execute`; - const expectedRevert = `expected-revert`; - const func = new FunctionDataStub() - .withName(functionName) - .withCode(expectedExecute) - .withRevertCode(expectedRevert); - const sut = new ScriptCompilerBuilder() - .withFunctions(func) - .build(); - const call: FunctionCallData = { function: functionName }; - const script = ScriptDataStub.createWithCall(call); - // act - const actual = sut.compile(script); - // assert - expect(actual.execute).to.equal(expectedExecute); - expect(actual.revert).to.equal(expectedRevert); - }); - it('builds call sequence as expected', () => { - // arrange - const firstFunction = new FunctionDataStub() - .withName('first-function-name') - .withCode('first-function-code') - .withRevertCode('first-function-revert-code'); - const secondFunction = new FunctionDataStub() - .withName('second-function-name') - .withCode('second-function-code') - .withRevertCode('second-function-revert-code'); - const expectedExecute = `${firstFunction.code}\n${secondFunction.code}`; - const expectedRevert = `${firstFunction.revertCode}\n${secondFunction.revertCode}`; - const sut = new ScriptCompilerBuilder() - .withFunctions(firstFunction, secondFunction) - .build(); - const call: FunctionCallData[] = [ - { function: firstFunction.name }, - { function: secondFunction.name }, - ]; - const script = ScriptDataStub.createWithCall(call); - // act - const actual = sut.compile(script); - // assert - expect(actual.execute).to.equal(expectedExecute); - expect(actual.revert).to.equal(expectedRevert); - }); + it('returns code as expected', () => { + // arrange + const expected: ICompiledCode = { + code: 'expected-code', + revertCode: 'expected-revert-code', + }; + const script = ScriptDataStub.createWithCall(); + const functions = [ FunctionDataStub.createWithCode().withName('existing-func') ]; + const compiledFunctions = new SharedFunctionCollectionStub(); + const compilerMock = new FunctionCompilerStub(); + compilerMock.setup(functions, compiledFunctions); + const callCompilerMock = new FunctionCallCompilerStub(); + callCompilerMock.setup(script.call, compiledFunctions, expected); + const sut = new ScriptCompilerBuilder() + .withFunctions(...functions) + .withFunctionCompiler(compilerMock) + .withFunctionCallCompiler(callCompilerMock) + .build(); + // act + const code = sut.compile(script); + // assert + expect(code.execute).to.equal(expected.code); + expect(code.revert).to.equal(expected.revertCode); }); - describe('parameter substitution', () => { - describe('substitutes as expected', () => { - it('with different parameters', () => { - // arrange - const env = new TestEnvironment({ - code: 'He{{ $firstParameter }} {{ $secondParameter }}!', - parameters: { - firstParameter: 'llo', - secondParameter: 'world', - }, - }); - const expected = env.expect('Hello world!'); - // act - const actual = env.sut.compile(env.script); - // assert - expect(actual).to.deep.equal(expected); - }); - it('with single parameter', () => { - // arrange - const env = new TestEnvironment({ - code: '{{ $parameter }}!', - parameters: { - parameter: 'Hodor', - }, - }); - const expected = env.expect('Hodor!'); - // act - const actual = env.sut.compile(env.script); - // assert - expect(actual).to.deep.equal(expected); - }); - }); - it('throws when parameters are undefined', () => { - // arrange - const env = new TestEnvironment({ - code: '{{ $parameter }} {{ $parameter }}!', - }); - const expectedError = 'no parameters defined, expected: "parameter"'; - // act - const act = () => env.sut.compile(env.script); - // assert - expect(act).to.throw(expectedError); - }); - it('throws when parameter value is not provided', () => { - // arrange - const env = new TestEnvironment({ - code: '{{ $parameter }} {{ $parameter }}!', - parameters: { - parameter: undefined, - }, - }); - const expectedError = 'parameter value is not provided for "parameter" in function call'; - // act - const act = () => env.sut.compile(env.script); - // assert - expect(act).to.throw(expectedError); - }); + it('creates with expected syntax', () => { + // arrange + let isUsed = false; + const syntax: ILanguageSyntax = { + get commentDelimiters() { + isUsed = true; + return []; + }, + get commonCodeParts() { + isUsed = true; + return []; + }, + }; + const sut = new ScriptCompilerBuilder() + .withSomeFunctions() + .withSyntax(syntax) + .build(); + const scriptData = ScriptDataStub.createWithCall(); + // act + sut.compile(scriptData); + // assert + expect(isUsed).to.equal(true); + }); + it('rethrows error from ScriptCode with script name', () => { + // arrange + const scriptName = 'scriptName'; // // arrange + const innerError = 'innerError'; + const expectedError = `Script "${scriptName}" ${innerError}`; + const callCompiler: IFunctionCallCompiler = { + compileCall: () => { throw new Error(innerError); }, + }; + const scriptData = ScriptDataStub.createWithCall() + .withName(scriptName); + const sut = new ScriptCompilerBuilder() + .withSomeFunctions() + .withFunctionCallCompiler(callCompiler) + .build(); + // act + const act = () => sut.compile(scriptData); + // assert + expect(act).to.throw(expectedError); }); - interface ITestCase { - code: string; - parameters?: FunctionCallParametersData; - } - class TestEnvironment { - public readonly sut: IScriptCompiler; - public readonly script: ScriptData; - constructor(testCase: ITestCase) { - const functionName = 'testFunction'; - const parameters = testCase.parameters ? Object.keys(testCase.parameters) : []; - const func = new FunctionDataStub() - .withName(functionName) - .withParameters(...parameters) - .withCode(this.getCode(testCase.code, 'execute')) - .withRevertCode(this.getCode(testCase.code, 'revert')); - const syntax = new LanguageSyntaxStub(); - this.sut = new ScriptCompiler([func], syntax); - const call: FunctionCallData = { - function: functionName, - parameters: testCase.parameters, - }; - this.script = ScriptDataStub.createWithCall(call); - } - public expect(code: string): IScriptCode { - return { - execute: this.getCode(code, 'execute'), - revert: this.getCode(code, 'revert'), - }; - } - private getCode(text: string, type: 'execute' | 'revert'): string { - return `${text} (${type})`; - } - } }); }); - -// tslint:disable-next-line:max-classes-per-file class ScriptCompilerBuilder { private static createFunctions(...names: string[]): FunctionData[] { return names.map((functionName) => { - return new FunctionDataStub().withName(functionName); + return FunctionDataStub.createWithCode().withName(functionName); }); } private functions: FunctionData[]; private syntax: ILanguageSyntax = new LanguageSyntaxStub(); + private functionCompiler: IFunctionCompiler = new FunctionCompilerStub(); + private callCompiler: IFunctionCallCompiler = new FunctionCallCompilerStub(); public withFunctions(...functions: FunctionData[]): ScriptCompilerBuilder { this.functions = functions; return this; @@ -416,10 +175,18 @@ class ScriptCompilerBuilder { this.syntax = syntax; return this; } + public withFunctionCompiler(functionCompiler: IFunctionCompiler): ScriptCompilerBuilder { + this.functionCompiler = functionCompiler; + return this; + } + public withFunctionCallCompiler(callCompiler: IFunctionCallCompiler): ScriptCompilerBuilder { + this.callCompiler = callCompiler; + return this; + } public build(): ScriptCompiler { if (!this.functions) { throw new Error('Function behavior not defined'); } - return new ScriptCompiler(this.functions, this.syntax); + return new ScriptCompiler(this.functions, this.syntax, this.functionCompiler, this.callCompiler); } } diff --git a/tests/unit/application/Parser/Script/ScriptParser.spec.ts b/tests/unit/application/Parser/Script/ScriptParser.spec.ts index 060a2916..04146e4c 100644 --- a/tests/unit/application/Parser/Script/ScriptParser.spec.ts +++ b/tests/unit/application/Parser/Script/ScriptParser.spec.ts @@ -112,7 +112,7 @@ describe('ScriptParser', () => { }); }); describe('code', () => { - it('parses code as expected', () => { + it('parses "execute" as expected', () => { // arrange const expected = 'expected-code'; const script = ScriptDataStub @@ -125,7 +125,7 @@ describe('ScriptParser', () => { const actual = parsed.code.execute; expect(actual).to.equal(expected); }); - it('parses revertCode as expected', () => { + it('parses "revert" as expected', () => { // arrange const expected = 'expected-revert-code'; const script = ScriptDataStub diff --git a/tests/unit/domain/ScriptCode.spec.ts b/tests/unit/domain/ScriptCode.spec.ts index c130f0a5..769e926f 100644 --- a/tests/unit/domain/ScriptCode.spec.ts +++ b/tests/unit/domain/ScriptCode.spec.ts @@ -6,23 +6,9 @@ import { ILanguageSyntax } from '@/domain/ScriptCode'; import { LanguageSyntaxStub } from '../stubs/LanguageSyntaxStub'; describe('ScriptCode', () => { - describe('scriptName', () => { - it('throws if undefined', () => { - // arrange - const expectedError = 'name is undefined'; - const name = undefined; - // act - const act = () => new ScriptCodeBuilder() - .withName(name) - .build(); - // assert - expect(act).to.throw(expectedError); - }); - }); describe('code', () => { describe('throws with invalid code', () => { // arrange - const scriptName = 'test-script'; const testCases = [ { name: 'throws when "execute" and "revert" are same', @@ -30,7 +16,7 @@ describe('ScriptCode', () => { execute: 'same', revert: 'same', }, - expectedError: `${scriptName} (revert): Code itself and its reverting code cannot be the same`, + expectedError: `(revert): Code itself and its reverting code cannot be the same`, }, { name: 'cannot construct with undefined "execute"', @@ -38,7 +24,7 @@ describe('ScriptCode', () => { execute: undefined, revert: 'code', }, - expectedError: `code of ${scriptName} is empty or undefined`, + expectedError: `code is empty or undefined`, }, { name: 'cannot construct with empty "execute"', @@ -46,14 +32,13 @@ describe('ScriptCode', () => { execute: '', revert: 'code', }, - expectedError: `code of ${scriptName} is empty or undefined`, + expectedError: `code is empty or undefined`, }, ]; for (const testCase of testCases) { it(testCase.name, () => { // act const act = () => new ScriptCodeBuilder() - .withName(scriptName) .withExecute( testCase.code.execute) .withRevert(testCase.code.revert) .build(); @@ -64,39 +49,35 @@ describe('ScriptCode', () => { }); describe('throws with invalid code in both "execute" or "revert"', () => { // arrange - const scriptName = 'script-name'; const testCases = [ { testName: 'cannot construct with duplicate lines', code: 'duplicate\nduplicate\ntest\nduplicate', - expectedMessage: 'Duplicates detected in script "$scriptName":\n duplicate\nduplicate', + expectedMessage: 'Duplicates detected in script :\n duplicate\nduplicate', }, { testName: 'cannot construct with empty lines', code: 'line1\n\n\nline2', - expectedMessage: 'script has empty lines "$scriptName"', + expectedMessage: 'script has empty lines', }, ]; // act const actions = []; for (const testCase of testCases) { - const substituteScriptName = (name: string) => testCase.expectedMessage.replace('$scriptName', name); actions.push(...[ { act: () => new ScriptCodeBuilder() - .withName(scriptName) .withExecute(testCase.code) .build(), testName: `execute: ${testCase.testName}`, - expectedMessage: substituteScriptName(scriptName), + expectedMessage: testCase.expectedMessage, }, { act: () => new ScriptCodeBuilder() - .withName(scriptName) .withRevert(testCase.code) .build(), testName: `revert: ${testCase.testName}`, - expectedMessage: substituteScriptName(`${scriptName} (revert)`), + expectedMessage: `(revert): ${testCase.expectedMessage}`, }, ]); } @@ -168,18 +149,26 @@ describe('ScriptCode', () => { } }); }); + describe('syntax', () => { + it('throws if undefined', () => { + // arrange + const expectedError = 'undefined syntax'; + const syntax = undefined; + // act + const act = () => new ScriptCodeBuilder() + .withSyntax(syntax) + .build(); + // assert + expect(act).to.throw(expectedError); + }); + }); }); class ScriptCodeBuilder { public execute = 'default-execute-code'; public revert = ''; - public scriptName = 'default-script-name'; public syntax: ILanguageSyntax = new LanguageSyntaxStub(); - public withName(name: string) { - this.scriptName = name; - return this; - } public withExecute(execute: string) { this.execute = execute; return this; @@ -197,7 +186,6 @@ class ScriptCodeBuilder { return new ScriptCode( this.execute, this.revert, - this.scriptName, this.syntax); } } diff --git a/tests/unit/stubs/ExpressionsCompilerStub.ts b/tests/unit/stubs/ExpressionsCompilerStub.ts new file mode 100644 index 00000000..d3fee408 --- /dev/null +++ b/tests/unit/stubs/ExpressionsCompilerStub.ts @@ -0,0 +1,28 @@ +import { IExpressionsCompiler, ParameterValueDictionary } from '@/application/Parser/Script/Compiler/Expressions/IExpressionsCompiler'; + +interface Scenario { code: string; parameters: ParameterValueDictionary; result: string; } + +export class ExpressionsCompilerStub implements IExpressionsCompiler { + private readonly scenarios = new Array(); + public setup(code: string, parameters: ParameterValueDictionary, result: string) { + this.scenarios.push({ code, parameters, result }); + return this; + } + public compileExpressions(code: string, parameters?: ParameterValueDictionary): string { + const scenario = this.scenarios.find((s) => s.code === code && deepEqual(s.parameters, parameters)); + if (scenario) { + return scenario.result; + } + return `[ExpressionsCompilerStub] code: "${code}"` + + `| parameters: ${Object.keys(parameters || {}).map((p) => p + '=' + parameters[p]).join(',')}`; + } +} + +function deepEqual(dict1: ParameterValueDictionary, dict2: ParameterValueDictionary) { + const dict1Keys = Object.keys(dict1 || {}); + const dict2Keys = Object.keys(dict2 || {}); + if (dict1Keys.length !== dict2Keys.length) { + return false; + } + return dict1Keys.every((key) => dict2.hasOwnProperty(key) && dict2[key] === dict1[key]); +} diff --git a/tests/unit/stubs/FunctionCallCompilerStub.ts b/tests/unit/stubs/FunctionCallCompilerStub.ts new file mode 100644 index 00000000..4837705e --- /dev/null +++ b/tests/unit/stubs/FunctionCallCompilerStub.ts @@ -0,0 +1,26 @@ +import { ISharedFunctionCollection } from '@/application/Parser/Script/Compiler/Function/ISharedFunctionCollection'; +import { ICompiledCode } from '@/application/Parser/Script/Compiler/FunctionCall/ICompiledCode'; +import { IFunctionCallCompiler } from '@/application/Parser/Script/Compiler/FunctionCall/IFunctionCallCompiler'; +import { FunctionCallData, ScriptFunctionCallData } from 'js-yaml-loader!*'; + +interface Scenario { call: ScriptFunctionCallData; functions: ISharedFunctionCollection; result: ICompiledCode; } + +export class FunctionCallCompilerStub implements IFunctionCallCompiler { + public scenarios = new Array(); + public setup(call: ScriptFunctionCallData, functions: ISharedFunctionCollection, result: ICompiledCode) { + this.scenarios.push({ call, functions, result }); + } + public compileCall( + call: ScriptFunctionCallData, + functions: ISharedFunctionCollection): ICompiledCode { + const predefined = this.scenarios.find((s) => s.call === call && s.functions === functions); + if (predefined) { + return predefined.result; + } + const callee = functions.getFunctionByName((call as FunctionCallData).function); + return { + code: callee.code, + revertCode: callee.revertCode, + }; + } +} diff --git a/tests/unit/stubs/FunctionCompilerStub.ts b/tests/unit/stubs/FunctionCompilerStub.ts new file mode 100644 index 00000000..aef7e15f --- /dev/null +++ b/tests/unit/stubs/FunctionCompilerStub.ts @@ -0,0 +1,41 @@ +import { IFunctionCompiler } from '@/application/Parser/Script/Compiler/Function/IFunctionCompiler'; +import { ISharedFunctionCollection } from '@/application/Parser/Script/Compiler/Function/ISharedFunctionCollection'; +import { FunctionData } from 'js-yaml-loader!*'; +import { SharedFunctionCollectionStub } from './SharedFunctionCollectionStub'; + +export class FunctionCompilerStub implements IFunctionCompiler { + private setupResults = new Array<{ + functions: readonly FunctionData[], + result: ISharedFunctionCollection, + }>(); + + public setup(functions: readonly FunctionData[], result: ISharedFunctionCollection) { + this.setupResults.push( { functions, result }); + } + + public compileFunctions(functions: readonly FunctionData[]): ISharedFunctionCollection { + const result = this.findResult(functions); + return result || new SharedFunctionCollectionStub(); + } + + private findResult(functions: readonly FunctionData[]): ISharedFunctionCollection { + for (const result of this.setupResults) { + if (sequenceEqual(result.functions, functions)) { + return result.result; + } + } + return undefined; + } +} + +function sequenceEqual(array1: readonly T[], array2: readonly T[]) { + if (array1.length !== array2.length) { + return false; + } + const sortedArray1 = sort(array1); + const sortedArray2 = sort(array2); + return sortedArray1.every((val, index) => val === sortedArray2[index]); + function sort(array: readonly T[]) { + return array.slice().sort(); + } +} diff --git a/tests/unit/stubs/FunctionDataStub.ts b/tests/unit/stubs/FunctionDataStub.ts index 73305108..b0b96020 100644 --- a/tests/unit/stubs/FunctionDataStub.ts +++ b/tests/unit/stubs/FunctionDataStub.ts @@ -1,10 +1,31 @@ -import { FunctionData } from 'js-yaml-loader!*'; +import { FunctionData, ScriptFunctionCallData } from 'js-yaml-loader!*'; export class FunctionDataStub implements FunctionData { + public static createWithCode() { + return new FunctionDataStub() + .withCode('stub-code') + .withRevertCode('stub-revert-code'); + } + public static createWithCall(call?: ScriptFunctionCallData) { + let instance = new FunctionDataStub(); + if (call) { + instance = instance.withCall(call); + } else { + instance = instance.withMockCall(); + } + return instance; + } + public static createWithoutCallOrCodes() { + return new FunctionDataStub(); + } + public name = 'function data stub'; - public code = 'function data stub code'; - public revertCode = 'function data stub revertCode'; + public code: string; + public revertCode: string; public parameters?: readonly string[]; + public call?: ScriptFunctionCallData; + + private constructor() { } public withName(name: string) { this.name = name; @@ -22,4 +43,12 @@ export class FunctionDataStub implements FunctionData { this.revertCode = revertCode; return this; } + public withCall(call: ScriptFunctionCallData) { + this.call = call; + return this; + } + public withMockCall() { + this.call = { function: 'func' }; + return this; + } } diff --git a/tests/unit/stubs/ScriptDataStub.ts b/tests/unit/stubs/ScriptDataStub.ts index 0d0f93de..06cb4596 100644 --- a/tests/unit/stubs/ScriptDataStub.ts +++ b/tests/unit/stubs/ScriptDataStub.ts @@ -27,6 +27,8 @@ export class ScriptDataStub implements ScriptData { public recommend = RecommendationLevel[RecommendationLevel.Standard].toLowerCase(); public docs = ['hello.com']; + private constructor() { } + public withName(name: string): ScriptDataStub { this.name = name; return this; diff --git a/tests/unit/stubs/SharedFunctionCollectionStub.ts b/tests/unit/stubs/SharedFunctionCollectionStub.ts new file mode 100644 index 00000000..8b20e676 --- /dev/null +++ b/tests/unit/stubs/SharedFunctionCollectionStub.ts @@ -0,0 +1,21 @@ +import { ISharedFunction } from '@/application/Parser/Script/Compiler/Function/ISharedFunction'; +import { ISharedFunctionCollection } from '@/application/Parser/Script/Compiler/Function/ISharedFunctionCollection'; + +export class SharedFunctionCollectionStub implements ISharedFunctionCollection { + private readonly functions = new Map(); + public withFunction(func: ISharedFunction) { + this.functions.set(func.name, func); + return this; + } + public getFunctionByName(name: string): ISharedFunction { + if (this.functions.has(name)) { + return this.functions.get(name); + } + return { + name, + parameters: [], + code: 'code by SharedFunctionCollectionStub', + revertCode: 'revert-code by SharedFunctionCollectionStub', + }; + } +} diff --git a/tests/unit/stubs/SharedFunctionStub.ts b/tests/unit/stubs/SharedFunctionStub.ts new file mode 100644 index 00000000..8a1102ea --- /dev/null +++ b/tests/unit/stubs/SharedFunctionStub.ts @@ -0,0 +1,27 @@ +import { ISharedFunction } from '@/application/Parser/Script/Compiler/Function/ISharedFunction'; + +export class SharedFunctionStub implements ISharedFunction { + public name = 'shared-function-stub-name'; + public parameters?: readonly string[] = [ + 'shared-function-stub-parameter', + ]; + public code = 'shared-function-stub-code'; + public revertCode = 'shared-function-stub-revert-code'; + + public withName(name: string) { + this.name = name; + return this; + } + public withCode(code: string) { + this.code = code; + return this; + } + public withRevertCode(revertCode: string) { + this.revertCode = revertCode; + return this; + } + public withParameters(...params: string[]) { + this.parameters = params; + return this; + } +}