allow functions to call other functions #53

This commit is contained in:
undergroundwires
2021-01-16 13:26:41 +01:00
parent f1abd7682f
commit 7661575573
38 changed files with 1507 additions and 645 deletions

View File

@@ -101,11 +101,15 @@
### `Function` ### `Function`
- Functions allow re-usable code throughout the defined scripts. - Functions allow re-usable code throughout the defined scripts.
- Functions are templates compiled by privacy.sexy and uses special expressions. - Functions are templates compiled by privacy.sexy and uses special [expressions](#expressions).
- Expressions are defined inside mustaches (double brackets, `{{` and `}}`) - Functions can call other functions by defining `call` property instead of `code`
- 👀 See [parameter substitution](#parameter-substitution) for an example usage - 👀 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 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 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 #### `Function` syntax
- `name`: *`string`* (**required**) - `name`: *`string`* (**required**)
@@ -135,15 +155,20 @@ It would print "Hello world" if it's called in a [script](#script) as following:
- `parameters`: `[` *`string`* `, ... ]` - `parameters`: `[` *`string`* `, ... ]`
- Name of the parameters that the function has. - Name of the parameters that the function has.
- Parameter values are provided by a [Script](#script) through a [FunctionCall](#FunctionCall) - 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 - ❗ Parameter names must be unique
`code`: *`string`* (**required**) `code`: *`string`* (**required** if `call` is undefined)
- Batch file commands that will be executed - Batch file commands that will be executed
- 💡 If defined, best practice to also define `revertCode` - 💡 If defined, best practice to also define `revertCode`
- ❗ If not defined `call` must be defined
- `revertCode`: *`string`* - `revertCode`: *`string`*
- Code that'll undo the change done by `code` property. - 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` - 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` - 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` ### `ScriptingDefinition`

29
docs/tests.md Normal file
View File

@@ -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

View File

@@ -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('", "')}"`;
}

View File

@@ -0,0 +1,5 @@
export interface ParameterValueDictionary { [parameterName: string]: string; }
export interface IExpressionsCompiler {
compileExpressions(code: string, parameters?: ParameterValueDictionary): string;
}

View File

@@ -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);
}

View File

@@ -0,0 +1,6 @@
import { FunctionData } from 'js-yaml-loader!*';
import { ISharedFunctionCollection } from './ISharedFunctionCollection';
export interface IFunctionCompiler {
compileFunctions(functions: readonly FunctionData[]): ISharedFunctionCollection;
}

View File

@@ -0,0 +1,6 @@
export interface ISharedFunction {
readonly name: string;
readonly parameters?: readonly string[];
readonly code: string;
readonly revertCode?: string;
}

View File

@@ -0,0 +1,5 @@
import { ISharedFunction } from './ISharedFunction';
export interface ISharedFunctionCollection {
getFunctionByName(name: string): ISharedFunction;
}

View File

@@ -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 || [];
}
}

View File

@@ -0,0 +1,23 @@
import { ISharedFunction } from './ISharedFunction';
import { ISharedFunctionCollection } from './ISharedFunctionCollection';
export class SharedFunctionCollection implements ISharedFunctionCollection {
private readonly functionsByName = new Map<string, ISharedFunction>();
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;
}
}

View File

@@ -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<ICompiledCode>();
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),
};
}

View File

@@ -0,0 +1,4 @@
export interface ICompiledCode {
readonly code: string;
readonly revertCode?: string;
}

View File

@@ -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;
}

View File

@@ -1,184 +1,42 @@
import { generateIlCode, IILCode } from './ILCode';
import { IScriptCode } from '@/domain/IScriptCode'; import { IScriptCode } from '@/domain/IScriptCode';
import { ScriptCode } from '@/domain/ScriptCode'; 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 { IScriptCompiler } from './IScriptCompiler';
import { ILanguageSyntax } from '@/domain/ScriptCode'; import { ILanguageSyntax } from '@/domain/ScriptCode';
import { ISharedFunctionCollection } from './Function/ISharedFunctionCollection';
interface ICompiledCode { import { IFunctionCallCompiler } from './FunctionCall/IFunctionCallCompiler';
readonly code: string; import { FunctionCallCompiler } from './FunctionCall/FunctionCallCompiler';
readonly revertCode: string; import { IFunctionCompiler } from './Function/IFunctionCompiler';
} import { FunctionCompiler } from './Function/FunctionCompiler';
export class ScriptCompiler implements IScriptCompiler { export class ScriptCompiler implements IScriptCompiler {
private readonly functions: ISharedFunctionCollection;
constructor( constructor(
private readonly functions: readonly FunctionData[] | undefined, functions: readonly FunctionData[] | undefined,
private syntax: ILanguageSyntax) { private readonly syntax: ILanguageSyntax,
ensureValidFunctions(functions); functionCompiler: IFunctionCompiler = FunctionCompiler.instance,
private readonly callCompiler: IFunctionCallCompiler = FunctionCallCompiler.instance,
) {
if (!syntax) { throw new Error('undefined syntax'); } if (!syntax) { throw new Error('undefined syntax'); }
this.functions = functionCompiler.compileFunctions(functions);
} }
public canCompile(script: ScriptData): boolean { public canCompile(script: ScriptData): boolean {
if (!script) { throw new Error('undefined script'); }
if (!script.call) { if (!script.call) {
return false; return false;
} }
return true; return true;
} }
public compile(script: ScriptData): IScriptCode { public compile(script: ScriptData): IScriptCode {
this.ensureCompilable(script.call); if (!script) { throw new Error('undefined script'); }
const compiledCodes = new Array<ICompiledCode>(); try {
const calls = getCallSequence(script.call); const compiledCode = this.callCompiler.compileCall(script.call, this.functions);
calls.forEach((currentCall, currentCallIndex) => { return new ScriptCode(
ensureValidCall(currentCall, script.name); compiledCode.code,
const commonFunction = this.getFunctionByName(currentCall.function); compiledCode.revertCode,
ensureExpectedParameters(commonFunction, currentCall); this.syntax);
let functionCode = compileCode(commonFunction, currentCall.parameters); } catch (error) {
if (currentCallIndex !== calls.length - 1) { throw Error(`Script "${script.name}" ${error.message}`);
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');
} }
} }
} }
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 ];
}

View File

@@ -31,7 +31,7 @@ function parseCode(script: ScriptData, context: ICategoryCollectionParseContext)
if (context.compiler.canCompile(script)) { if (context.compiler.canCompile(script)) {
return context.compiler.compile(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) { function ensureNotBothCallAndCode(script: ScriptData) {

View File

@@ -4,7 +4,7 @@ import { ScriptingDefinition } from '@/domain/ScriptingDefinition';
import { ScriptingLanguage } from '@/domain/ScriptingLanguage'; import { ScriptingLanguage } from '@/domain/ScriptingLanguage';
import { IProjectInformation } from '@/domain/IProjectInformation'; import { IProjectInformation } from '@/domain/IProjectInformation';
import { createEnumParser } from '../Common/Enum'; import { createEnumParser } from '../Common/Enum';
import { generateIlCode } from './Script/Compiler/ILCode'; import { generateIlCode } from './Script/Compiler/Expressions/ILCode';
export function parseScriptingDefinition( export function parseScriptingDefinition(
definition: ScriptingDefinitionData, definition: ScriptingDefinitionData,

View File

@@ -18,30 +18,33 @@ declare module 'js-yaml-loader!*' {
readonly docs?: DocumentationUrlsData; readonly docs?: DocumentationUrlsData;
} }
export interface FunctionData { export interface InstructionHolder {
name: string; readonly name: string;
code: string;
revertCode?: string; readonly code?: string;
parameters?: readonly string[]; readonly revertCode?: string;
readonly call?: ScriptFunctionCallData;
}
export interface FunctionData extends InstructionHolder {
readonly parameters?: readonly string[];
} }
export interface FunctionCallParametersData { export interface FunctionCallParametersData {
[index: string]: string; readonly [index: string]: string;
} }
export interface FunctionCallData { export interface FunctionCallData {
function: string; readonly function: string;
parameters?: FunctionCallParametersData; readonly parameters?: FunctionCallParametersData;
} }
export type ScriptFunctionCallData = readonly FunctionCallData[] | FunctionCallData | undefined; export type ScriptFunctionCallData = readonly FunctionCallData[] | FunctionCallData | undefined;
export interface ScriptData extends DocumentableData { export interface ScriptData extends InstructionHolder, DocumentableData {
name: string; readonly name: string;
code?: string; readonly recommend?: string;
revertCode?: string;
call: ScriptFunctionCallData;
recommend?: string;
} }
export interface ScriptingDefinitionData { export interface ScriptingDefinitionData {

View File

@@ -2719,8 +2719,19 @@ actions:
- -
name: Disable NetBios for all interfaces name: Disable NetBios for all interfaces
docs: https://10dsecurity.com/saying-goodbye-netbios/ 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}" call:
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}" 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 category: Remove bloatware
children: children:
@@ -4168,64 +4179,72 @@ functions:
- -
name: UninstallStoreApp name: UninstallStoreApp
parameters: [ packageName ] parameters: [ packageName ]
code: PowerShell -Command "Get-AppxPackage '{{ $packageName }}' | Remove-AppxPackage" call:
revertCode: function: RunPowerShell
PowerShell -ExecutionPolicy Unrestricted -Command " parameters:
$package = Get-AppxPackage -AllUsers '{{ $packageName }}'; code: Get-AppxPackage '{{ $packageName }}' | Remove-AppxPackage
if (!$package) { revertCode:
Write-Error \"Cannot reinstall '{{ $packageName }}'\" -ErrorAction Stop $package = Get-AppxPackage -AllUsers '{{ $packageName }}';
} if (!$package) {
$manifest = $package.InstallLocation + '\AppxManifest.xml'; Write-Error \"Cannot reinstall '{{ $packageName }}'\" -ErrorAction Stop
Add-AppxPackage -DisableDevelopmentMode -Register \"$manifest\" " }
$manifest = $package.InstallLocation + '\AppxManifest.xml';
Add-AppxPackage -DisableDevelopmentMode -Register \"$manifest\"
- -
name: UninstallSystemApp name: UninstallSystemApp
parameters: [ packageName ] parameters: [ packageName ]
# It simply renames files # It simply renames files
# Because system apps are non removable (check: (Get-AppxPackage -AllUsers 'Windows.CBSPreview').NonRemovable) # Because system apps are non removable (check: (Get-AppxPackage -AllUsers 'Windows.CBSPreview').NonRemovable)
# Otherwise they throw 0x80070032 when trying to uninstall them # Otherwise they throw 0x80070032 when trying to uninstall them
code: call:
PowerShell -Command " function: RunPowerShell
$package = (Get-AppxPackage -AllUsers '{{ $packageName }}'); parameters:
if (!$package) { code:
Write-Host 'Not installed'; $package = (Get-AppxPackage -AllUsers '{{ $packageName }}');
exit 0; if (!$package) {
} Write-Host 'Not installed';
$directories = @($package.InstallLocation, \"$env:LOCALAPPDATA\Packages\$($package.PackageFamilyName)\"); exit 0;
foreach($dir in $directories) { }
if ( !$dir -Or !(Test-Path \"$dir\") ) { continue; } $directories = @($package.InstallLocation, \"$env:LOCALAPPDATA\Packages\$($package.PackageFamilyName)\");
cmd /c ('takeown /f \"' + $dir + '\" /r /d y 1> nul'); if($LASTEXITCODE) { throw 'Failed to take ownership'; } foreach($dir in $directories) {
cmd /c ('icacls \"' + $dir + '\" /grant administrators:F /t 1> nul'); if($LASTEXITCODE) { throw 'Failed to take ownership'; } if ( !$dir -Or !(Test-Path \"$dir\") ) { continue; }
$files = Get-ChildItem -File -Path $dir -Recurse -Force; cmd /c ('takeown /f \"' + $dir + '\" /r /d y 1> nul'); if($LASTEXITCODE) { throw 'Failed to take ownership'; }
foreach($file in $files) { cmd /c ('icacls \"' + $dir + '\" /grant administrators:F /t 1> nul'); if($LASTEXITCODE) { throw 'Failed to take ownership'; }
if($file.Name.EndsWith('.OLD')) { continue; } $files = Get-ChildItem -File -Path $dir -Recurse -Force;
$newName = $file.FullName + '.OLD'; foreach($file in $files) {
Write-Host \"Rename '$($file.FullName)' to '$newName'\"; if($file.Name.EndsWith('.OLD')) { continue; }
Move-Item -LiteralPath \"$($file.FullName)\" -Destination \"$newName\" -Force; $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 }}'); revertCode:
if (!$package) { $package = (Get-AppxPackage -AllUsers '{{ $packageName }}');
Write-Error 'App could not be found' -ErrorAction Stop; if (!$package) {
} Write-Error 'App could not be found' -ErrorAction Stop;
$directories = @($package.InstallLocation, \"$env:LOCALAPPDATA\Packages\$($package.PackageFamilyName)\"); }
foreach($dir in $directories) { $directories = @($package.InstallLocation, \"$env:LOCALAPPDATA\Packages\$($package.PackageFamilyName)\");
if ( !$dir -Or !(Test-Path \"$dir\") ) { continue; } foreach($dir in $directories) {
cmd /c ('takeown /f \"' + $dir + '\" /r /d y 1> nul'); if($LASTEXITCODE) { throw 'Failed to take ownership'; } if ( !$dir -Or !(Test-Path \"$dir\") ) { continue; }
cmd /c ('icacls \"' + $dir + '\" /grant administrators:F /t 1> nul'); if($LASTEXITCODE) { throw 'Failed to take ownership'; } cmd /c ('takeown /f \"' + $dir + '\" /r /d y 1> nul'); if($LASTEXITCODE) { throw 'Failed to take ownership'; }
$files = Get-ChildItem -File -Path \"$dir\*.OLD\" -Recurse -Force; cmd /c ('icacls \"' + $dir + '\" /grant administrators:F /t 1> nul'); if($LASTEXITCODE) { throw 'Failed to take ownership'; }
foreach($file in $files) { $files = Get-ChildItem -File -Path \"$dir\*.OLD\" -Recurse -Force;
$newName = $file.FullName.Substring(0, $file.FullName.Length - 4); foreach($file in $files) {
Write-Host \"Rename '$($file.FullName)' to '$newName'\"; $newName = $file.FullName.Substring(0, $file.FullName.Length - 4);
Move-Item -LiteralPath \"$($file.FullName)\" -Destination \"$newName\" -Force; Write-Host \"Rename '$($file.FullName)' to '$newName'\";
} Move-Item -LiteralPath \"$($file.FullName)\" -Destination \"$newName\" -Force;
};" }
}
- -
name: UninstallCapability name: UninstallCapability
parameters: [ capabilityName ] parameters: [ capabilityName ]
code: PowerShell -Command "Get-WindowsCapability -Online -Name '{{ $capabilityName }}*' | Remove-WindowsCapability -Online" call:
revertCode: PowerShell -Command "$capability = Get-WindowsCapability -Online -Name '{{ $capabilityName }}*'; Add-WindowsCapability -Name \"$capability.Name\" -Online" 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 name: RenameSystemFile
parameters: [ filePath ] parameters: [ filePath ]
@@ -4250,15 +4269,21 @@ functions:
- -
name: SetVsCodeSetting name: SetVsCodeSetting
parameters: [ setting, powerShellValue ] parameters: [ setting, powerShellValue ]
code: call:
Powershell -Command " function: RunPowerShell
$jsonfile = \"$env:APPDATA\Code\User\settings.json\"; parameters:
$json = Get-Content $jsonfile | Out-String | ConvertFrom-Json; code:
$json | Add-Member -Type NoteProperty -Name '{{ $setting }}' -Value {{ $powerShellValue }} -Force; $jsonfile = \"$env:APPDATA\Code\User\settings.json\";
$json | ConvertTo-Json | Set-Content $jsonfile;" $json = Get-Content $jsonfile | Out-String | ConvertFrom-Json;
revertCode: $json | Add-Member -Type NoteProperty -Name '{{ $setting }}' -Value {{ $powerShellValue }} -Force;
Powershell -Command " $json | ConvertTo-Json | Set-Content $jsonfile;
$jsonfile = \"$env:APPDATA\Code\User\settings.json\"; revertCode:
$json = Get-Content $jsonfile | ConvertFrom-Json; $jsonfile = \"$env:APPDATA\Code\User\settings.json\";
$json.PSObject.Properties.Remove('{{ $setting }}'); $json = Get-Content $jsonfile | ConvertFrom-Json;
$json | ConvertTo-Json | Set-Content $jsonfile;" $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 }}"

View File

@@ -4,16 +4,17 @@ export class ScriptCode implements IScriptCode {
constructor( constructor(
public readonly execute: string, public readonly execute: string,
public readonly revert: string, public readonly revert: string,
scriptName: string,
syntax: ILanguageSyntax) { syntax: ILanguageSyntax) {
if (!scriptName) { throw new Error('script name is undefined'); } if (!syntax) { throw new Error('undefined syntax'); }
if (!syntax) { throw new Error('syntax is undefined'); } validateCode(execute, syntax);
validateCode(scriptName, execute, syntax);
if (revert) { if (revert) {
scriptName = `${scriptName} (revert)`; try {
validateCode(scriptName, revert, syntax); validateCode(revert, syntax);
if (execute === revert) { if (execute === revert) {
throw new Error(`${scriptName}: Code itself and its reverting code cannot be the same`); 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[]; readonly commonCodeParts: string[];
} }
function validateCode(name: string, code: string, syntax: ILanguageSyntax): void { function validateCode(code: string, syntax: ILanguageSyntax): void {
if (!code || code.length === 0) { 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); ensureNoEmptyLines(code);
ensureCodeHasUniqueLines(name, code, syntax); 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)) { 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') const lines = code.split('\n')
.filter((line) => !shouldIgnoreLine(line, syntax)); .filter((line) => !shouldIgnoreLine(line, syntax));
if (lines.length === 0) { 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); const duplicateLines = lines.filter((e, i, a) => a.indexOf(e) !== i);
if (duplicateLines.length !== 0) { 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')}`);
} }
} }

View File

@@ -105,7 +105,7 @@ describe('CategoryCollectionParser', () => {
const scriptName = 'script-name'; const scriptName = 'script-name';
const script = ScriptDataStub.createWithCall({ function: functionName }) const script = ScriptDataStub.createWithCall({ function: functionName })
.withName(scriptName); .withName(scriptName);
const func = new FunctionDataStub() const func = FunctionDataStub.createWithCode()
.withName(functionName) .withName(functionName)
.withCode(expectedCode); .withCode(expectedCode);
const category = new CategoryDataStub() const category = new CategoryDataStub()

View File

@@ -29,7 +29,7 @@ describe('CategoryCollectionParseContext', () => {
// arrange // arrange
const expectedError = 'undefined scripting'; const expectedError = 'undefined scripting';
const scripting = undefined; const scripting = undefined;
const functionsData = [ new FunctionDataStub() ]; const functionsData = [ FunctionDataStub.createWithCode() ];
// act // act
const act = () => new CategoryCollectionParseContext(functionsData, scripting); const act = () => new CategoryCollectionParseContext(functionsData, scripting);
// assert // assert
@@ -39,7 +39,7 @@ describe('CategoryCollectionParseContext', () => {
describe('compiler', () => { describe('compiler', () => {
it('constructed as expected', () => { it('constructed as expected', () => {
// arrange // arrange
const functionsData = [ new FunctionDataStub() ]; const functionsData = [ FunctionDataStub.createWithCode() ];
const syntax = new LanguageSyntaxStub(); const syntax = new LanguageSyntaxStub();
const expected = new ScriptCompiler(functionsData, syntax); const expected = new ScriptCompiler(functionsData, syntax);
const language = ScriptingLanguage.shellscript; const language = ScriptingLanguage.shellscript;

View File

@@ -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();
}
}

View File

@@ -1,6 +1,6 @@
import 'mocha'; import 'mocha';
import { expect } from 'chai'; import { expect } from 'chai';
import { generateIlCode } from '@/application/Parser/Script/Compiler/ILCode'; import { generateIlCode } from '@/application/Parser/Script/Compiler/Expressions/ILCode';
describe('ILCode', () => { describe('ILCode', () => {
describe('getUniqueParameterNames', () => { describe('getUniqueParameterNames', () => {

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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);
});
});
});

View File

@@ -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);
}
}

View File

@@ -1,13 +1,17 @@
import 'mocha'; import 'mocha';
import { expect } from 'chai'; import { expect } from 'chai';
import { ScriptCompiler } from '@/application/Parser/Script/Compiler/ScriptCompiler'; import { ScriptCompiler } from '@/application/Parser/Script/Compiler/ScriptCompiler';
import { FunctionData, ScriptData, FunctionCallData, ScriptFunctionCallData, FunctionCallParametersData } from 'js-yaml-loader!@/*'; import { FunctionData } from 'js-yaml-loader!@/*';
import { IScriptCode } from '@/domain/IScriptCode';
import { ILanguageSyntax } from '@/domain/ScriptCode'; 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 { LanguageSyntaxStub } from '../../../../stubs/LanguageSyntaxStub';
import { ScriptDataStub } from '../../../../stubs/ScriptDataStub'; import { ScriptDataStub } from '../../../../stubs/ScriptDataStub';
import { FunctionDataStub } from '../../../../stubs/FunctionDataStub'; import { FunctionDataStub } from '../../../../stubs/FunctionDataStub';
import { FunctionCallCompilerStub } from '../../../../stubs/FunctionCallCompilerStub';
import { FunctionCompilerStub } from '../../../../stubs/FunctionCompilerStub';
import { SharedFunctionCollectionStub } from '../../../../stubs/SharedFunctionCollectionStub';
describe('ScriptCompiler', () => { describe('ScriptCompiler', () => {
describe('ctor', () => { describe('ctor', () => {
@@ -22,88 +26,20 @@ describe('ScriptCompiler', () => {
// assert // assert
expect(act).to.throw(expectedError); 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', () => { 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', () => { it('returns true if "call" is defined', () => {
// arrange // arrange
const sut = new ScriptCompilerBuilder() const sut = new ScriptCompilerBuilder()
@@ -128,274 +64,97 @@ describe('ScriptCompiler', () => {
}); });
}); });
describe('compile', () => { describe('compile', () => {
describe('invalid state', () => { it('throws if script is undefined', () => {
it('throws if functions are empty', () => { // arrange
// arrange const expectedError = 'undefined script';
const expectedError = 'cannot compile without shared functions'; const argument = undefined;
const sut = new ScriptCompilerBuilder() const builder = new ScriptCompilerBuilder()
.withEmptyFunctions() .withEmptyFunctions()
.build(); .build();
const script = ScriptDataStub.createWithCall(); // act
// act const act = () => builder.compile(argument);
const act = () => sut.compile(script); // assert
// assert expect(act).to.throw(expectedError);
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);
});
}); });
describe('builds code as expected', () => { it('returns code as expected', () => {
it('creates code with expected syntax', () => { // test through script validation logic // arrange
// act const expected: ICompiledCode = {
const commentDelimiter = 'should not throw'; code: 'expected-code',
const syntax = new LanguageSyntaxStub().withCommentDelimiters(commentDelimiter); revertCode: 'expected-revert-code',
const func = new FunctionDataStub() };
.withCode(`${commentDelimiter} duplicate-line\n${commentDelimiter} duplicate-line`); const script = ScriptDataStub.createWithCall();
const sut = new ScriptCompilerBuilder() const functions = [ FunctionDataStub.createWithCode().withName('existing-func') ];
.withFunctions(func) const compiledFunctions = new SharedFunctionCollectionStub();
.withSyntax(syntax) const compilerMock = new FunctionCompilerStub();
.build(); compilerMock.setup(functions, compiledFunctions);
const call: FunctionCallData = { function: func.name }; const callCompilerMock = new FunctionCallCompilerStub();
const script = ScriptDataStub.createWithCall(call); callCompilerMock.setup(script.call, compiledFunctions, expected);
// act const sut = new ScriptCompilerBuilder()
const act = () => sut.compile(script); .withFunctions(...functions)
// assert .withFunctionCompiler(compilerMock)
expect(act).to.not.throw(); .withFunctionCallCompiler(callCompilerMock)
}); .build();
it('builds single call as expected', () => { // act
// arrange const code = sut.compile(script);
const functionName = 'testSharedFunction'; // assert
const expectedExecute = `expected-execute`; expect(code.execute).to.equal(expected.code);
const expectedRevert = `expected-revert`; expect(code.revert).to.equal(expected.revertCode);
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);
});
}); });
describe('parameter substitution', () => { it('creates with expected syntax', () => {
describe('substitutes as expected', () => { // arrange
it('with different parameters', () => { let isUsed = false;
// arrange const syntax: ILanguageSyntax = {
const env = new TestEnvironment({ get commentDelimiters() {
code: 'He{{ $firstParameter }} {{ $secondParameter }}!', isUsed = true;
parameters: { return [];
firstParameter: 'llo', },
secondParameter: 'world', get commonCodeParts() {
}, isUsed = true;
}); return [];
const expected = env.expect('Hello world!'); },
// act };
const actual = env.sut.compile(env.script); const sut = new ScriptCompilerBuilder()
// assert .withSomeFunctions()
expect(actual).to.deep.equal(expected); .withSyntax(syntax)
}); .build();
it('with single parameter', () => { const scriptData = ScriptDataStub.createWithCall();
// arrange // act
const env = new TestEnvironment({ sut.compile(scriptData);
code: '{{ $parameter }}!', // assert
parameters: { expect(isUsed).to.equal(true);
parameter: 'Hodor', });
}, it('rethrows error from ScriptCode with script name', () => {
}); // arrange
const expected = env.expect('Hodor!'); const scriptName = 'scriptName'; // // arrange
// act const innerError = 'innerError';
const actual = env.sut.compile(env.script); const expectedError = `Script "${scriptName}" ${innerError}`;
// assert const callCompiler: IFunctionCallCompiler = {
expect(actual).to.deep.equal(expected); compileCall: () => { throw new Error(innerError); },
}); };
}); const scriptData = ScriptDataStub.createWithCall()
it('throws when parameters are undefined', () => { .withName(scriptName);
// arrange const sut = new ScriptCompilerBuilder()
const env = new TestEnvironment({ .withSomeFunctions()
code: '{{ $parameter }} {{ $parameter }}!', .withFunctionCallCompiler(callCompiler)
}); .build();
const expectedError = 'no parameters defined, expected: "parameter"'; // act
// act const act = () => sut.compile(scriptData);
const act = () => env.sut.compile(env.script); // assert
// assert expect(act).to.throw(expectedError);
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);
});
}); });
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 { class ScriptCompilerBuilder {
private static createFunctions(...names: string[]): FunctionData[] { private static createFunctions(...names: string[]): FunctionData[] {
return names.map((functionName) => { return names.map((functionName) => {
return new FunctionDataStub().withName(functionName); return FunctionDataStub.createWithCode().withName(functionName);
}); });
} }
private functions: FunctionData[]; private functions: FunctionData[];
private syntax: ILanguageSyntax = new LanguageSyntaxStub(); private syntax: ILanguageSyntax = new LanguageSyntaxStub();
private functionCompiler: IFunctionCompiler = new FunctionCompilerStub();
private callCompiler: IFunctionCallCompiler = new FunctionCallCompilerStub();
public withFunctions(...functions: FunctionData[]): ScriptCompilerBuilder { public withFunctions(...functions: FunctionData[]): ScriptCompilerBuilder {
this.functions = functions; this.functions = functions;
return this; return this;
@@ -416,10 +175,18 @@ class ScriptCompilerBuilder {
this.syntax = syntax; this.syntax = syntax;
return this; 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 { public build(): ScriptCompiler {
if (!this.functions) { if (!this.functions) {
throw new Error('Function behavior not defined'); 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);
} }
} }

View File

@@ -112,7 +112,7 @@ describe('ScriptParser', () => {
}); });
}); });
describe('code', () => { describe('code', () => {
it('parses code as expected', () => { it('parses "execute" as expected', () => {
// arrange // arrange
const expected = 'expected-code'; const expected = 'expected-code';
const script = ScriptDataStub const script = ScriptDataStub
@@ -125,7 +125,7 @@ describe('ScriptParser', () => {
const actual = parsed.code.execute; const actual = parsed.code.execute;
expect(actual).to.equal(expected); expect(actual).to.equal(expected);
}); });
it('parses revertCode as expected', () => { it('parses "revert" as expected', () => {
// arrange // arrange
const expected = 'expected-revert-code'; const expected = 'expected-revert-code';
const script = ScriptDataStub const script = ScriptDataStub

View File

@@ -6,23 +6,9 @@ import { ILanguageSyntax } from '@/domain/ScriptCode';
import { LanguageSyntaxStub } from '../stubs/LanguageSyntaxStub'; import { LanguageSyntaxStub } from '../stubs/LanguageSyntaxStub';
describe('ScriptCode', () => { 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('code', () => {
describe('throws with invalid code', () => { describe('throws with invalid code', () => {
// arrange // arrange
const scriptName = 'test-script';
const testCases = [ const testCases = [
{ {
name: 'throws when "execute" and "revert" are same', name: 'throws when "execute" and "revert" are same',
@@ -30,7 +16,7 @@ describe('ScriptCode', () => {
execute: 'same', execute: 'same',
revert: '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"', name: 'cannot construct with undefined "execute"',
@@ -38,7 +24,7 @@ describe('ScriptCode', () => {
execute: undefined, execute: undefined,
revert: 'code', revert: 'code',
}, },
expectedError: `code of ${scriptName} is empty or undefined`, expectedError: `code is empty or undefined`,
}, },
{ {
name: 'cannot construct with empty "execute"', name: 'cannot construct with empty "execute"',
@@ -46,14 +32,13 @@ describe('ScriptCode', () => {
execute: '', execute: '',
revert: 'code', revert: 'code',
}, },
expectedError: `code of ${scriptName} is empty or undefined`, expectedError: `code is empty or undefined`,
}, },
]; ];
for (const testCase of testCases) { for (const testCase of testCases) {
it(testCase.name, () => { it(testCase.name, () => {
// act // act
const act = () => new ScriptCodeBuilder() const act = () => new ScriptCodeBuilder()
.withName(scriptName)
.withExecute( testCase.code.execute) .withExecute( testCase.code.execute)
.withRevert(testCase.code.revert) .withRevert(testCase.code.revert)
.build(); .build();
@@ -64,39 +49,35 @@ describe('ScriptCode', () => {
}); });
describe('throws with invalid code in both "execute" or "revert"', () => { describe('throws with invalid code in both "execute" or "revert"', () => {
// arrange // arrange
const scriptName = 'script-name';
const testCases = [ const testCases = [
{ {
testName: 'cannot construct with duplicate lines', testName: 'cannot construct with duplicate lines',
code: 'duplicate\nduplicate\ntest\nduplicate', 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', testName: 'cannot construct with empty lines',
code: 'line1\n\n\nline2', code: 'line1\n\n\nline2',
expectedMessage: 'script has empty lines "$scriptName"', expectedMessage: 'script has empty lines',
}, },
]; ];
// act // act
const actions = []; const actions = [];
for (const testCase of testCases) { for (const testCase of testCases) {
const substituteScriptName = (name: string) => testCase.expectedMessage.replace('$scriptName', name);
actions.push(...[ actions.push(...[
{ {
act: () => new ScriptCodeBuilder() act: () => new ScriptCodeBuilder()
.withName(scriptName)
.withExecute(testCase.code) .withExecute(testCase.code)
.build(), .build(),
testName: `execute: ${testCase.testName}`, testName: `execute: ${testCase.testName}`,
expectedMessage: substituteScriptName(scriptName), expectedMessage: testCase.expectedMessage,
}, },
{ {
act: () => new ScriptCodeBuilder() act: () => new ScriptCodeBuilder()
.withName(scriptName)
.withRevert(testCase.code) .withRevert(testCase.code)
.build(), .build(),
testName: `revert: ${testCase.testName}`, 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 { class ScriptCodeBuilder {
public execute = 'default-execute-code'; public execute = 'default-execute-code';
public revert = ''; public revert = '';
public scriptName = 'default-script-name';
public syntax: ILanguageSyntax = new LanguageSyntaxStub(); public syntax: ILanguageSyntax = new LanguageSyntaxStub();
public withName(name: string) {
this.scriptName = name;
return this;
}
public withExecute(execute: string) { public withExecute(execute: string) {
this.execute = execute; this.execute = execute;
return this; return this;
@@ -197,7 +186,6 @@ class ScriptCodeBuilder {
return new ScriptCode( return new ScriptCode(
this.execute, this.execute,
this.revert, this.revert,
this.scriptName,
this.syntax); this.syntax);
} }
} }

View File

@@ -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<Scenario>();
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]);
}

View File

@@ -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<Scenario>();
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,
};
}
}

View File

@@ -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<T>(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();
}
}

View File

@@ -1,10 +1,31 @@
import { FunctionData } from 'js-yaml-loader!*'; import { FunctionData, ScriptFunctionCallData } from 'js-yaml-loader!*';
export class FunctionDataStub implements FunctionData { 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 name = 'function data stub';
public code = 'function data stub code'; public code: string;
public revertCode = 'function data stub revertCode'; public revertCode: string;
public parameters?: readonly string[]; public parameters?: readonly string[];
public call?: ScriptFunctionCallData;
private constructor() { }
public withName(name: string) { public withName(name: string) {
this.name = name; this.name = name;
@@ -22,4 +43,12 @@ export class FunctionDataStub implements FunctionData {
this.revertCode = revertCode; this.revertCode = revertCode;
return this; return this;
} }
public withCall(call: ScriptFunctionCallData) {
this.call = call;
return this;
}
public withMockCall() {
this.call = { function: 'func' };
return this;
}
} }

View File

@@ -27,6 +27,8 @@ export class ScriptDataStub implements ScriptData {
public recommend = RecommendationLevel[RecommendationLevel.Standard].toLowerCase(); public recommend = RecommendationLevel[RecommendationLevel.Standard].toLowerCase();
public docs = ['hello.com']; public docs = ['hello.com'];
private constructor() { }
public withName(name: string): ScriptDataStub { public withName(name: string): ScriptDataStub {
this.name = name; this.name = name;
return this; return this;

View File

@@ -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<string, ISharedFunction>();
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',
};
}
}

View File

@@ -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;
}
}