diff --git a/README.md b/README.md index ba46292f..b6af4ce9 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,8 @@ - Have full visibility into what the tweaks do as you enable them - Ability to revert (undo) applied scripts - Everything is transparent: both application and its infrastructure are open-source and automated -- Easily extendable +- Easily extendable with [own powerful templating language](./docs/templating.md) +- Each script is independently executable without cross-dependencies ## Extend scripts diff --git a/docs/collection-files.md b/docs/collection-files.md index e8855dec..55ca8d2a 100644 --- a/docs/collection-files.md +++ b/docs/collection-files.md @@ -80,7 +80,7 @@ ### `FunctionCall` - Describes a single call to a function by optionally providing values to its parameters. -- 👀 See [parameter substitution](#parameter-substitution) for an example usage +- 👀 See [parameter substitution](./templating.md#parameter-substitution) for an example usage #### `FunctionCall` syntax @@ -100,73 +100,10 @@ ### `Function` -- Functions allow re-usable code throughout the defined scripts. -- Functions are templates compiled by privacy.sexy and uses special [expressions](#expressions). +- Functions allow re-usable code throughout the defined scripts +- Functions are templates compiled by privacy.sexy and uses special expression expressions - Functions can call other functions by defining `call` property instead of `code` -- 👀 See [parameter substitution](#parameter-substitution) for an example usage - -#### Expressions - -- Expressions are defined inside mustaches (double brackets, `{{` and `}}`) -- Expression syntax is inspired by [Go Templates](https://pkg.go.dev/text/template) - -##### Parameter substitution - -A simple function example - -```yaml - function: EchoArgument - parameters: - - name: 'argument' - code: Hello {{ $argument }} ! -``` - -It would print "Hello world" if it's called in a [script](#script) as following: - -```yaml - script: Echo script - call: - function: EchoArgument - parameters: - argument: World -``` - -A function can call other functions such as: - -```yaml - - - function: CallerFunction - parameters: - - name: 'value' - call: - function: EchoArgument - parameters: - argument: {{ $value }} - - - function: EchoArgument - parameters: - - name: 'argument' - code: Hello {{ $argument }} ! -``` - -##### with - -- Skips the block if the variable is absent or empty. -- Binds its context (`.`) value of provided argument for the parameter only if its value is provided. -- A block is defined as `{{ with $parameterName }} Parameter value is {{ . }} here {{ end }}` -- The parameters used for `with` condition should be declared as optional, otherwise `with` block becomes redundant. -- Example: - - ```yaml - function: FunctionThatOutputsConditionally - parameters: - - name: 'argument' - optional: true - code: |- - {{ with $argument }} - $argument's value is: {{ . }} - {{ end }} - ``` +- 👀 Read more on [Templating](./templating.md) for function expressions and [example usages](./templating.md#parameter-substitution) #### `Function` syntax @@ -177,7 +114,7 @@ A function can call other functions such as: - ❗ Function names must be unique - `parameters`: `[` ***[`FunctionParameter`](#FunctionParameter)*** `, ... ]` - List of parameters that function code refers to. - - ❗ Must be defined to be able use in [`FunctionCall`](#functioncall) or [expressions](#expressions) + - ❗ Must be defined to be able use in [`FunctionCall`](#functioncall) or [expressions](./templating.md#expressions) `code`: *`string`* (**required** if `call` is undefined) - Batch file commands that will be executed - 💡 If defined, best practice to also define `revertCode` @@ -188,7 +125,7 @@ A function can call other functions such as: - 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) + - The parameter values that are sent can use [expressions](./templating.md#expressions) - ❗ If not defined `code` must be defined ### `FunctionParameter` @@ -200,7 +137,7 @@ A function can call other functions such as: - `name`: *`string`* (**required**) - Name of the parameters that the function has. - - Parameter names must be defined to be used in [expressions](#expressions). + - Parameter names must be defined to be used in [expressions](./templating.md#expressions). - ❗ Parameter names must be unique and include alphanumeric characters only. - `optional`: *`boolean`* (default: `false`) - Specifies whether the caller [Script](#script) must provide any value for the parameter. @@ -208,7 +145,7 @@ A function can call other functions such as: - Otherwise it throws. - 💡 Set it to `true` if a parameter is used conditionally; - Or else set it to `false` for verbosity or do not define it as default value is `false` anyway. - - 💡 Can be used in conjunction with [`with` expression](#with). + - 💡 Can be used in conjunction with [`with` expression](./templating.md#with). ### `ScriptingDefinition` @@ -220,7 +157,7 @@ A function can call other functions such as: - 📖 See [ScriptingLanguage.ts](./../src/domain/ScriptingLanguage.ts) enumeration for allowed values. - `startCode:` *`string`* (**required**) - Code that'll be inserted on top of user created script. - - Global variables such as `$homepage`, `$version`, `$date` can be used using [parameter substitution](#parameter-substitution) code syntax such as `Welcome to {{ $homepage }}!` + - Global variables such as `$homepage`, `$version`, `$date` can be used using [parameter substitution](./templating.md#parameter-substitution) code syntax such as `Welcome to {{ $homepage }}!` - `endCode:` *`string`* (**required**) - Code that'll be inserted at the end of user created script. - - Global variables such as `$homepage`, `$version`, `$date` can be used using [parameter substitution](#parameter-substitution) code syntax such as `Welcome to {{ $homepage }}!` + - Global variables such as `$homepage`, `$version`, `$date` can be used using [parameter substitution](./templating.md#parameter-substitution) code syntax such as `Welcome to {{ $homepage }}!` diff --git a/docs/templating.md b/docs/templating.md new file mode 100644 index 00000000..b875ecbb --- /dev/null +++ b/docs/templating.md @@ -0,0 +1,81 @@ +# Templating + +## Benefits of templating + +- Generating scripts by sharing code to increase best-practice usage and maintainability. +- Creating self-contained scripts without depending on each other that can be easily shared. +- Use of pipes for writing cleaner code and letting pipes do dirty work. + +## Expressions + +- Expressions in the language are defined inside mustaches (double brackets, `{{` and `}}`). +- Expression syntax is inspired mainly by [Go Templates](https://pkg.go.dev/text/template). + +## Syntax + +### Parameter substitution + +A simple function example: + +```yaml + function: EchoArgument + parameters: + - name: 'argument' + code: Hello {{ $argument }} ! +``` + +It would print "Hello world" if it's called in a [script](./collection-files.md#script) as following: + +```yaml + script: Echo script + call: + function: EchoArgument + parameters: + argument: World +``` + +A function can call other functions such as: + +```yaml + - + function: CallerFunction + parameters: + - name: 'value' + call: + function: EchoArgument + parameters: + argument: {{ $value }} + - + function: EchoArgument + parameters: + - name: 'argument' + code: Hello {{ $argument }} ! +``` + +### with + +- Skips the block if the variable is absent or empty. +- Binds its context (`.`) value of provided argument for the parameter if provided one. +- A block is defined as `{{ with $parameterName }} Parameter value is {{ . }} here {{ end }}`. +- The parameters used for `with` condition should be declared as optional, otherwise `with` block becomes redundant. +- Example: + + ```yaml + function: FunctionThatOutputsConditionally + parameters: + - name: 'argument' + optional: true + code: |- + {{ with $argument }} + Value is: {{ . }} + {{ end }} + ``` + +### Pipes + +- Pipes are set of functions available for handling text in privacy.sexy. +- Allows stacking actions one after another also known as "chaining". +- Just like [Unix pipelines](https://en.wikipedia.org/wiki/Pipeline_(Unix)), the concept is simple: each pipeline's output becomes the input of the following pipe. +- Pipes are provided and defined by the compiler and consumed by collection files. +- Pipes can be combined with [parameter substitution](#parameter-substitution) and [with](#with). +- ❗ Pipe names must be camelCase without any space or special characters. diff --git a/src/application/Parser/Script/Compiler/Expressions/Expression/Expression.ts b/src/application/Parser/Script/Compiler/Expressions/Expression/Expression.ts index 3c1247bb..281276b6 100644 --- a/src/application/Parser/Script/Compiler/Expressions/Expression/Expression.ts +++ b/src/application/Parser/Script/Compiler/Expressions/Expression/Expression.ts @@ -4,8 +4,10 @@ import { IReadOnlyFunctionCallArgumentCollection } from '../../FunctionCall/Argu import { IReadOnlyFunctionParameterCollection } from '../../Function/Parameter/IFunctionParameterCollection'; import { FunctionParameterCollection } from '@/application/Parser/Script/Compiler/Function/Parameter/FunctionParameterCollection'; import { FunctionCallArgumentCollection } from '../../FunctionCall/Argument/FunctionCallArgumentCollection'; +import { IExpressionEvaluationContext } from './ExpressionEvaluationContext'; +import { ExpressionEvaluationContext } from '@/application/Parser/Script/Compiler/Expressions/Expression/ExpressionEvaluationContext'; -export type ExpressionEvaluator = (args: IReadOnlyFunctionCallArgumentCollection) => string; +export type ExpressionEvaluator = (context: IExpressionEvaluationContext) => string; export class Expression implements IExpression { constructor( public readonly position: ExpressionPosition, @@ -18,13 +20,14 @@ export class Expression implements IExpression { throw new Error('undefined evaluator'); } } - public evaluate(args: IReadOnlyFunctionCallArgumentCollection): string { - if (!args) { - throw new Error('undefined args, send empty collection instead'); + public evaluate(context: IExpressionEvaluationContext): string { + if (!context) { + throw new Error('undefined context'); } - validateThatAllRequiredParametersAreSatisfied(this.parameters, args); - args = filterUnusedArguments(this.parameters, args); - return this.evaluator(args); + validateThatAllRequiredParametersAreSatisfied(this.parameters, context.args); + const args = filterUnusedArguments(this.parameters, context.args); + context = new ExpressionEvaluationContext(args, context.pipelineCompiler); + return this.evaluator(context); } } diff --git a/src/application/Parser/Script/Compiler/Expressions/Expression/ExpressionEvaluationContext.ts b/src/application/Parser/Script/Compiler/Expressions/Expression/ExpressionEvaluationContext.ts new file mode 100644 index 00000000..7b5f6700 --- /dev/null +++ b/src/application/Parser/Script/Compiler/Expressions/Expression/ExpressionEvaluationContext.ts @@ -0,0 +1,18 @@ +import { IReadOnlyFunctionCallArgumentCollection } from '../../FunctionCall/Argument/IFunctionCallArgumentCollection'; +import { IPipelineCompiler } from '../Pipes/IPipelineCompiler'; +import { PipelineCompiler } from '../Pipes/PipelineCompiler'; + +export interface IExpressionEvaluationContext { + readonly args: IReadOnlyFunctionCallArgumentCollection; + readonly pipelineCompiler: IPipelineCompiler; +} + +export class ExpressionEvaluationContext implements IExpressionEvaluationContext { + constructor( + public readonly args: IReadOnlyFunctionCallArgumentCollection, + public readonly pipelineCompiler: IPipelineCompiler = new PipelineCompiler()) { + if (!args) { + throw new Error('undefined args, send empty collection instead'); + } + } +} diff --git a/src/application/Parser/Script/Compiler/Expressions/Expression/IExpression.ts b/src/application/Parser/Script/Compiler/Expressions/Expression/IExpression.ts index e10c7369..5c57de6c 100644 --- a/src/application/Parser/Script/Compiler/Expressions/Expression/IExpression.ts +++ b/src/application/Parser/Script/Compiler/Expressions/Expression/IExpression.ts @@ -1,9 +1,9 @@ import { ExpressionPosition } from './ExpressionPosition'; -import { IReadOnlyFunctionCallArgumentCollection } from '../../FunctionCall/Argument/IFunctionCallArgumentCollection'; import { IReadOnlyFunctionParameterCollection } from '../../Function/Parameter/IFunctionParameterCollection'; +import { IExpressionEvaluationContext } from './ExpressionEvaluationContext'; export interface IExpression { readonly position: ExpressionPosition; readonly parameters: IReadOnlyFunctionParameterCollection; - evaluate(args: IReadOnlyFunctionCallArgumentCollection): string; + evaluate(context: IExpressionEvaluationContext): string; } diff --git a/src/application/Parser/Script/Compiler/Expressions/ExpressionsCompiler.ts b/src/application/Parser/Script/Compiler/Expressions/ExpressionsCompiler.ts index 71284517..6f852f4a 100644 --- a/src/application/Parser/Script/Compiler/Expressions/ExpressionsCompiler.ts +++ b/src/application/Parser/Script/Compiler/Expressions/ExpressionsCompiler.ts @@ -3,6 +3,8 @@ import { IExpression } from './Expression/IExpression'; import { IExpressionParser } from './Parser/IExpressionParser'; import { CompositeExpressionParser } from './Parser/CompositeExpressionParser'; import { IReadOnlyFunctionCallArgumentCollection } from '../FunctionCall/Argument/IFunctionCallArgumentCollection'; +import { ExpressionEvaluationContext } from './Expression/ExpressionEvaluationContext'; +import { IExpressionEvaluationContext } from '@/application/Parser/Script/Compiler/Expressions/Expression/ExpressionEvaluationContext'; export class ExpressionsCompiler implements IExpressionsCompiler { public constructor( @@ -15,7 +17,8 @@ export class ExpressionsCompiler implements IExpressionsCompiler { } const expressions = this.extractor.findExpressions(code); ensureParamsUsedInCodeHasArgsProvided(expressions, args); - const compiledCode = compileExpressions(expressions, code, args); + const context = new ExpressionEvaluationContext(args); + const compiledCode = compileExpressions(expressions, code, context); return compiledCode; } } @@ -23,7 +26,7 @@ export class ExpressionsCompiler implements IExpressionsCompiler { function compileExpressions( expressions: readonly IExpression[], code: string, - args: IReadOnlyFunctionCallArgumentCollection): string { + context: IExpressionEvaluationContext) { let compiledCode = ''; const sortedExpressions = expressions .slice() // copy the array to not mutate the parameter @@ -33,7 +36,7 @@ function compileExpressions( const nextExpression = sortedExpressions.pop(); if (nextExpression) { compiledCode += code.substring(index, nextExpression.position.start); - const expressionCode = nextExpression.evaluate(args); + const expressionCode = nextExpression.evaluate(context); compiledCode += expressionCode; index = nextExpression.position.end; } else { diff --git a/src/application/Parser/Script/Compiler/Expressions/Parser/Regex/ExpressionRegexBuilder.ts b/src/application/Parser/Script/Compiler/Expressions/Parser/Regex/ExpressionRegexBuilder.ts new file mode 100644 index 00000000..a29d4a4f --- /dev/null +++ b/src/application/Parser/Script/Compiler/Expressions/Parser/Regex/ExpressionRegexBuilder.ts @@ -0,0 +1,59 @@ +export class ExpressionRegexBuilder { + private readonly parts = new Array(); + + public expectCharacters(characters: string) { + return this.addRawRegex( + characters + .replaceAll('$', '\\$') + .replaceAll('.', '\\.'), + ); + } + + public expectOneOrMoreWhitespaces() { + return this + .addRawRegex('\\s+'); + } + + public matchPipeline() { + return this + .expectZeroOrMoreWhitespaces() + .addRawRegex('(\\|\\s*.+?)?'); + } + + public matchUntilFirstWhitespace() { + return this + .addRawRegex('([^|\\s]+)'); + } + + public matchAnythingExceptSurroundingWhitespaces() { + return this + .expectZeroOrMoreWhitespaces() + .addRawRegex('(.+?)') + .expectZeroOrMoreWhitespaces(); + } + + public expectExpressionStart() { + return this + .expectCharacters('{{') + .expectZeroOrMoreWhitespaces(); + } + + public expectExpressionEnd() { + return this + .expectZeroOrMoreWhitespaces() + .expectCharacters('}}'); + } + + public buildRegExp(): RegExp { + return new RegExp(this.parts.join(''), 'g'); + } + + private expectZeroOrMoreWhitespaces() { + return this + .addRawRegex('\\s*'); + } + private addRawRegex(regex: string) { + this.parts.push(regex); + return this; + } +} diff --git a/src/application/Parser/Script/Compiler/Expressions/Parser/RegexParser.ts b/src/application/Parser/Script/Compiler/Expressions/Parser/Regex/RegexParser.ts similarity index 77% rename from src/application/Parser/Script/Compiler/Expressions/Parser/RegexParser.ts rename to src/application/Parser/Script/Compiler/Expressions/Parser/Regex/RegexParser.ts index 165e718e..7e3eb178 100644 --- a/src/application/Parser/Script/Compiler/Expressions/Parser/RegexParser.ts +++ b/src/application/Parser/Script/Compiler/Expressions/Parser/Regex/RegexParser.ts @@ -1,9 +1,9 @@ -import { IExpressionParser } from './IExpressionParser'; -import { ExpressionPosition } from '../Expression/ExpressionPosition'; -import { IExpression } from '../Expression/IExpression'; -import { Expression, ExpressionEvaluator } from '../Expression/Expression'; -import { IFunctionParameter } from '../../Function/Parameter/IFunctionParameter'; -import { FunctionParameterCollection } from '../../Function/Parameter/FunctionParameterCollection'; +import { IExpressionParser } from '../IExpressionParser'; +import { ExpressionPosition } from '../../Expression/ExpressionPosition'; +import { IExpression } from '../../Expression/IExpression'; +import { Expression, ExpressionEvaluator } from '../../Expression/Expression'; +import { IFunctionParameter } from '../../../Function/Parameter/IFunctionParameter'; +import { FunctionParameterCollection } from '../../../Function/Parameter/FunctionParameterCollection'; export abstract class RegexParser implements IExpressionParser { protected abstract readonly regex: RegExp; diff --git a/src/application/Parser/Script/Compiler/Expressions/Pipes/IPipe.ts b/src/application/Parser/Script/Compiler/Expressions/Pipes/IPipe.ts new file mode 100644 index 00000000..46adee4c --- /dev/null +++ b/src/application/Parser/Script/Compiler/Expressions/Pipes/IPipe.ts @@ -0,0 +1,4 @@ +export interface IPipe { + readonly name: string; + apply(input: string): string; +} diff --git a/src/application/Parser/Script/Compiler/Expressions/Pipes/IPipelineCompiler.ts b/src/application/Parser/Script/Compiler/Expressions/Pipes/IPipelineCompiler.ts new file mode 100644 index 00000000..6c911dcf --- /dev/null +++ b/src/application/Parser/Script/Compiler/Expressions/Pipes/IPipelineCompiler.ts @@ -0,0 +1,3 @@ +export interface IPipelineCompiler { + compile(value: string, pipeline: string): string; +} diff --git a/src/application/Parser/Script/Compiler/Expressions/Pipes/PipeFactory.ts b/src/application/Parser/Script/Compiler/Expressions/Pipes/PipeFactory.ts new file mode 100644 index 00000000..6e4fd53f --- /dev/null +++ b/src/application/Parser/Script/Compiler/Expressions/Pipes/PipeFactory.ts @@ -0,0 +1,42 @@ +import { IPipe } from './IPipe'; + +const RegisteredPipes = [ ]; + +export interface IPipeFactory { + get(pipeName: string): IPipe; +} + +export class PipeFactory implements IPipeFactory { + private readonly pipes = new Map(); + constructor(pipes: readonly IPipe[] = RegisteredPipes) { + if (pipes.some((pipe) => !pipe)) { + throw new Error('undefined pipe in list'); + } + for (const pipe of pipes) { + this.registerPipe(pipe); + } + } + public get(pipeName: string): IPipe { + validatePipeName(pipeName); + if (!this.pipes.has(pipeName)) { + throw new Error(`Unknown pipe: "${pipeName}"`); + } + return this.pipes.get(pipeName); + } + private registerPipe(pipe: IPipe): void { + validatePipeName(pipe.name); + if (this.pipes.has(pipe.name)) { + throw new Error(`Pipe name must be unique: "${pipe.name}"`); + } + this.pipes.set(pipe.name, pipe); + } +} + +function validatePipeName(name: string) { + if (!name) { + throw new Error('empty pipe name'); + } + if (!/^[a-z][A-Za-z]*$/.test(name)) { + throw new Error(`Pipe name should be camelCase: "${name}"`); + } +} diff --git a/src/application/Parser/Script/Compiler/Expressions/Pipes/PipelineCompiler.ts b/src/application/Parser/Script/Compiler/Expressions/Pipes/PipelineCompiler.ts new file mode 100644 index 00000000..f3facb7a --- /dev/null +++ b/src/application/Parser/Script/Compiler/Expressions/Pipes/PipelineCompiler.ts @@ -0,0 +1,31 @@ +import { IPipeFactory, PipeFactory } from './PipeFactory'; +import { IPipelineCompiler } from './IPipelineCompiler'; + +export class PipelineCompiler implements IPipelineCompiler { + constructor(private readonly factory: IPipeFactory = new PipeFactory()) { } + public compile(value: string, pipeline: string): string { + ensureValidArguments(value, pipeline); + const pipeNames = extractPipeNames(pipeline); + const pipes = pipeNames.map((pipeName) => this.factory.get(pipeName)); + for (const pipe of pipes) { + value = pipe.apply(value); + } + return value; + } +} + +function extractPipeNames(pipeline: string): string[] { + return pipeline + .trim() + .split('|') + .slice(1) + .map((p) => p.trim()); +} + +function ensureValidArguments(value: string, pipeline: string) { + if (!value) { throw new Error('undefined value'); } + if (!pipeline) { throw new Error('undefined pipeline'); } + if (!pipeline.trimStart().startsWith('|')) { + throw new Error('pipeline does not start with pipe'); + } +} diff --git a/src/application/Parser/Script/Compiler/Expressions/SyntaxParsers/ParameterSubstitutionParser.ts b/src/application/Parser/Script/Compiler/Expressions/SyntaxParsers/ParameterSubstitutionParser.ts index 9c50a593..9783580c 100644 --- a/src/application/Parser/Script/Compiler/Expressions/SyntaxParsers/ParameterSubstitutionParser.ts +++ b/src/application/Parser/Script/Compiler/Expressions/SyntaxParsers/ParameterSubstitutionParser.ts @@ -1,13 +1,28 @@ -import { RegexParser, IPrimitiveExpression } from '../Parser/RegexParser'; +import { RegexParser, IPrimitiveExpression } from '../Parser/Regex/RegexParser'; import { FunctionParameter } from '@/application/Parser/Script/Compiler/Function/Parameter/FunctionParameter'; +import { ExpressionRegexBuilder } from '../Parser/Regex/ExpressionRegexBuilder'; export class ParameterSubstitutionParser extends RegexParser { - protected readonly regex = /{{\s*\$([^}| ]+)\s*}}/g; + protected readonly regex = new ExpressionRegexBuilder() + .expectExpressionStart() + .expectCharacters('$') + .matchUntilFirstWhitespace() // First match: Parameter name + .matchPipeline() // Second match: Pipeline + .expectExpressionEnd() + .buildRegExp(); + protected buildExpression(match: RegExpMatchArray): IPrimitiveExpression { const parameterName = match[1]; + const pipeline = match[2]; return { parameters: [ new FunctionParameter(parameterName, false) ], - evaluator: (args) => args.getArgument(parameterName).argumentValue, + evaluator: (context) => { + const argumentValue = context.args.getArgument(parameterName).argumentValue; + if (!pipeline) { + return argumentValue; + } + return context.pipelineCompiler.compile(argumentValue, pipeline); + }, }; } } diff --git a/src/application/Parser/Script/Compiler/Expressions/SyntaxParsers/WithParser.ts b/src/application/Parser/Script/Compiler/Expressions/SyntaxParsers/WithParser.ts index 4afb3b1a..973f3503 100644 --- a/src/application/Parser/Script/Compiler/Expressions/SyntaxParsers/WithParser.ts +++ b/src/application/Parser/Script/Compiler/Expressions/SyntaxParsers/WithParser.ts @@ -1,24 +1,58 @@ -import { RegexParser, IPrimitiveExpression } from '../Parser/RegexParser'; +import { RegexParser, IPrimitiveExpression } from '../Parser/Regex/RegexParser'; import { FunctionParameter } from '@/application/Parser/Script/Compiler/Function/Parameter/FunctionParameter'; +import { ExpressionRegexBuilder } from '../Parser/Regex/ExpressionRegexBuilder'; export class WithParser extends RegexParser { - protected readonly regex = /{{\s*with\s+\$([^}| ]+)\s*}}\s*([^)]+?)\s*{{\s*end\s*}}/g; + protected readonly regex = new ExpressionRegexBuilder() + // {{ with $parameterName }} + .expectExpressionStart() + .expectCharacters('with') + .expectOneOrMoreWhitespaces() + .expectCharacters('$') + .matchUntilFirstWhitespace() // First match: parameter name + .expectExpressionEnd() + // ... + .matchAnythingExceptSurroundingWhitespaces() // Second match: Scope text + // {{ end }} + .expectExpressionStart() + .expectCharacters('end') + .expectExpressionEnd() + .buildRegExp(); + protected buildExpression(match: RegExpMatchArray): IPrimitiveExpression { const parameterName = match[1]; - const innerText = match[2]; + const scopeText = match[2]; return { parameters: [ new FunctionParameter(parameterName, true) ], - evaluator: (args) => { - const argumentValue = args.hasArgument(parameterName) ? - args.getArgument(parameterName).argumentValue + evaluator: (context) => { + const argumentValue = context.args.hasArgument(parameterName) ? + context.args.getArgument(parameterName).argumentValue : undefined; if (!argumentValue) { return ''; } - const substitutionRegex = /{{\s*.\s*}}/g; - const newText = innerText.replace(substitutionRegex, argumentValue); - return newText; + return replaceEachScopeSubstitution(scopeText, (pipeline) => { + if (!pipeline) { + return argumentValue; + } + return context.pipelineCompiler.compile(argumentValue, pipeline); + }); }, }; } } + +const ScopeSubstitutionRegEx = new ExpressionRegexBuilder() + // {{ . | pipeName }} + .expectExpressionStart() + .expectCharacters('.') + .matchPipeline() // First match: pipeline + .expectExpressionEnd() + .buildRegExp(); + +function replaceEachScopeSubstitution(scopeText: string, replacer: (pipeline: string) => string) { + // Not using /{{\s*.\s*(?:(\|\s*[^{}]*?)\s*)?}}/g for not matching brackets, but let pipeline compiler fail on those + return scopeText.replaceAll(ScopeSubstitutionRegEx, (_$, match1 ) => { + return replacer(match1); + }); +} diff --git a/tests/unit/application/Parser/Script/Compiler/Expressions/Expression/Expression.spec.ts b/tests/unit/application/Parser/Script/Compiler/Expressions/Expression/Expression.spec.ts index 9800566b..85d112f8 100644 --- a/tests/unit/application/Parser/Script/Compiler/Expressions/Expression/Expression.spec.ts +++ b/tests/unit/application/Parser/Script/Compiler/Expressions/Expression/Expression.spec.ts @@ -1,13 +1,16 @@ import 'mocha'; import { expect } from 'chai'; import { ExpressionPosition } from '@/application/Parser/Script/Compiler/Expressions/Expression/ExpressionPosition'; -import { ExpressionEvaluator } from '@/application/Parser/Script/Compiler/Expressions/Expression/Expression'; -import { Expression } from '@/application/Parser/Script/Compiler/Expressions/Expression/Expression'; -import { IReadOnlyFunctionParameterCollection } from '@/application/Parser/Script/Compiler/Function/Parameter/IFunctionParameterCollection'; +// tslint:disable-next-line:max-line-length +import { ExpressionEvaluator, Expression } from '@/application/Parser/Script/Compiler/Expressions/Expression/Expression'; import { IReadOnlyFunctionCallArgumentCollection } from '@/application/Parser/Script/Compiler/FunctionCall/Argument/IFunctionCallArgumentCollection'; import { FunctionCallArgumentCollectionStub } from '@tests/unit/stubs/FunctionCallArgumentCollectionStub'; import { FunctionParameterCollectionStub } from '@tests/unit/stubs/FunctionParameterCollectionStub'; import { FunctionCallArgumentStub } from '@tests/unit/stubs/FunctionCallArgumentStub'; +import { ExpressionEvaluationContextStub } from '@tests/unit/stubs/ExpressionEvaluationContextStub'; +import { IPipelineCompiler } from '@/application/Parser/Script/Compiler/Expressions/Pipes/IPipelineCompiler'; +import { PipelineCompilerStub } from '@tests/unit/stubs/PipelineCompilerStub'; +import { IReadOnlyFunctionParameterCollection } from '@/application/Parser/Script/Compiler/Function/Parameter/IFunctionParameterCollection'; describe('Expression', () => { describe('ctor', () => { @@ -79,19 +82,21 @@ describe('Expression', () => { const testCases = [ { name: 'throws if arguments is undefined', - args: undefined, - expectedError: 'undefined args, send empty collection instead', + context: undefined, + expectedError: 'undefined context', }, { name: 'throws when some of the required args are not provided', sut: (i: ExpressionBuilder) => i.withParameterNames(['a', 'b', 'c'], false), - args: new FunctionCallArgumentCollectionStub().withArgument('b', 'provided'), + context: new ExpressionEvaluationContextStub() + .withArgs(new FunctionCallArgumentCollectionStub().withArgument('b', 'provided')), expectedError: 'argument values are provided for required parameters: "a", "c"', }, { name: 'throws when none of the required args are not provided', sut: (i: ExpressionBuilder) => i.withParameterNames(['a', 'b'], false), - args: new FunctionCallArgumentCollectionStub().withArgument('c', 'unrelated'), + context: new ExpressionEvaluationContextStub() + .withArgs(new FunctionCallArgumentCollectionStub().withArgument('c', 'unrelated')), expectedError: 'argument values are provided for required parameters: "a", "b"', }, ]; @@ -104,7 +109,7 @@ describe('Expression', () => { } const sut = sutBuilder.build(); // act - const act = () => sut.evaluate(testCase.args); + const act = () => sut.evaluate(testCase.context); // assert expect(act).to.throw(testCase.expectedError); }); @@ -112,29 +117,50 @@ describe('Expression', () => { }); it('returns result from evaluator', () => { // arrange - const evaluatorMock: ExpressionEvaluator = (args) => - `"${args + const evaluatorMock: ExpressionEvaluator = (c) => + `"${c + .args .getAllParameterNames() - .map((name) => args.getArgument(name)) + .map((name) => context.args.getArgument(name)) .map((arg) => `${arg.parameterName}': '${arg.argumentValue}'`) .join('", "')}"`; const givenArguments = new FunctionCallArgumentCollectionStub() .withArgument('parameter1', 'value1') .withArgument('parameter2', 'value2'); const expectedParameterNames = givenArguments.getAllParameterNames(); - const expected = evaluatorMock(givenArguments); + const context = new ExpressionEvaluationContextStub() + .withArgs(givenArguments); + const expected = evaluatorMock(context); const sut = new ExpressionBuilder() .withEvaluator(evaluatorMock) .withParameterNames(expectedParameterNames) .build(); // arrange - const actual = sut.evaluate(givenArguments); + const actual = sut.evaluate(context); // assert expect(expected).to.equal(actual, `\nGiven arguments: ${JSON.stringify(givenArguments)}\n` + `\nExpected parameter names: ${JSON.stringify(expectedParameterNames)}\n`, ); }); + it('sends pipeline compiler as it is', () => { + // arrange + const expected = new PipelineCompilerStub(); + const context = new ExpressionEvaluationContextStub() + .withPipelineCompiler(expected); + let actual: IPipelineCompiler; + const evaluatorMock: ExpressionEvaluator = (c) => { + actual = c.pipelineCompiler; + return ''; + }; + const sut = new ExpressionBuilder() + .withEvaluator(evaluatorMock) + .build(); + // arrange + sut.evaluate(context); + // assert + expect(expected).to.equal(actual); + }); describe('filters unused parameters', () => { // arrange const testCases = [ @@ -166,16 +192,18 @@ describe('Expression', () => { for (const testCase of testCases) { it(testCase.name, () => { let actual: IReadOnlyFunctionCallArgumentCollection; - const evaluatorMock: ExpressionEvaluator = (providedArgs) => { - actual = providedArgs; + const evaluatorMock: ExpressionEvaluator = (c) => { + actual = c.args; return ''; }; + const context = new ExpressionEvaluationContextStub() + .withArgs(testCase.arguments); const sut = new ExpressionBuilder() .withEvaluator(evaluatorMock) .withParameters(testCase.expressionParameters) .build(); // act - sut.evaluate(testCase.arguments); + sut.evaluate(context); // assert const actualArguments = actual.getAllParameterNames().map((name) => actual.getArgument(name)); expect(actualArguments).to.deep.equal(testCase.expectedArguments); diff --git a/tests/unit/application/Parser/Script/Compiler/Expressions/Expression/ExpressionEvaluationContext.spec.ts b/tests/unit/application/Parser/Script/Compiler/Expressions/Expression/ExpressionEvaluationContext.spec.ts new file mode 100644 index 00000000..ea099ffa --- /dev/null +++ b/tests/unit/application/Parser/Script/Compiler/Expressions/Expression/ExpressionEvaluationContext.spec.ts @@ -0,0 +1,65 @@ +import 'mocha'; +import { expect } from 'chai'; +import { ExpressionEvaluationContext, IExpressionEvaluationContext } from '@/application/Parser/Script/Compiler/Expressions/Expression/ExpressionEvaluationContext'; +import { IReadOnlyFunctionCallArgumentCollection } from '@/application/Parser/Script/Compiler/FunctionCall/Argument/IFunctionCallArgumentCollection'; +import { IPipelineCompiler } from '@/application/Parser/Script/Compiler/Expressions/Pipes/IPipelineCompiler'; +import { FunctionCallArgumentCollectionStub } from '@tests/unit/stubs/FunctionCallArgumentCollectionStub'; +import { PipelineCompilerStub } from '@tests/unit/stubs/PipelineCompilerStub'; + + +describe('ExpressionEvaluationContext', () => { + describe('ctor', () => { + describe('args', () => { + it('throws if args are undefined', () => { + // arrange + const expectedError = 'undefined args'; + const builder = new ExpressionEvaluationContextBuilder() + .withArgs(undefined); + // act + const act = () => builder.build(); + // assert + expect(act).throw(expectedError); + }); + it('sets as expected', () => { + // arrange + const expected = new FunctionCallArgumentCollectionStub() + .withArgument('expectedParameter', 'expectedValue'); + const builder = new ExpressionEvaluationContextBuilder() + .withArgs(expected); + // act + const sut = builder.build(); + // assert + const actual = sut.args; + expect(actual).to.equal(expected); + }); + }); + describe('pipelineCompiler', () => { + it('sets as expected', () => { + // arrange + const expected = new PipelineCompilerStub(); + const builder = new ExpressionEvaluationContextBuilder() + .withPipelineCompiler(expected); + // act + const sut = builder.build(); + // assert + expect(sut.pipelineCompiler).to.equal(expected); + }); + }); + }); +}); + +class ExpressionEvaluationContextBuilder { + private args: IReadOnlyFunctionCallArgumentCollection = new FunctionCallArgumentCollectionStub(); + private pipelineCompiler: IPipelineCompiler = new PipelineCompilerStub(); + public withArgs(args: IReadOnlyFunctionCallArgumentCollection) { + this.args = args; + return this; + } + public withPipelineCompiler(pipelineCompiler: IPipelineCompiler) { + this.pipelineCompiler = pipelineCompiler; + return this; + } + public build(): IExpressionEvaluationContext { + return new ExpressionEvaluationContext(this.args, this.pipelineCompiler); + } +} diff --git a/tests/unit/application/Parser/Script/Compiler/Expressions/ExpressionsCompiler.spec.ts b/tests/unit/application/Parser/Script/Compiler/Expressions/ExpressionsCompiler.spec.ts index cf75c414..d89bcc77 100644 --- a/tests/unit/application/Parser/Script/Compiler/Expressions/ExpressionsCompiler.spec.ts +++ b/tests/unit/application/Parser/Script/Compiler/Expressions/ExpressionsCompiler.spec.ts @@ -74,9 +74,9 @@ describe('ExpressionsCompiler', () => { sut.compileExpressions(code, expected); // assert expect(expressions[0].callHistory).to.have.lengthOf(1); - expect(expressions[0].callHistory[0]).to.equal(expected); + expect(expressions[0].callHistory[0].args).to.equal(expected); expect(expressions[1].callHistory).to.have.lengthOf(1); - expect(expressions[1].callHistory[0]).to.equal(expected); + expect(expressions[1].callHistory[0].args).to.equal(expected); }); it('throws if arguments is undefined', () => { // arrange diff --git a/tests/unit/application/Parser/Script/Compiler/Expressions/Parser/Regex/ExpressionRegexBuilder.spec.ts b/tests/unit/application/Parser/Script/Compiler/Expressions/Parser/Regex/ExpressionRegexBuilder.spec.ts new file mode 100644 index 00000000..0bd8555e --- /dev/null +++ b/tests/unit/application/Parser/Script/Compiler/Expressions/Parser/Regex/ExpressionRegexBuilder.spec.ts @@ -0,0 +1,135 @@ +import 'mocha'; +import { expect } from 'chai'; +import { ExpressionRegexBuilder } from '@/application/Parser/Script/Compiler/Expressions/Parser/Regex/ExpressionRegexBuilder'; + +describe('ExpressionRegexBuilder', () => { + describe('expectCharacters', () => { + describe('escape single as expected', () => { + const charactersToEscape = [ '.', '$' ]; + for (const character of charactersToEscape) { + it(character, () => { + runRegExTest( + // act + (act) => act.expectCharacters(character), + // assert + `\\${character}`); + }); + } + }); + it('escapes multiple as expected', () => { + runRegExTest( + // act + (act) => act.expectCharacters('.I have no $$.'), + // assert + '\\.I have no \\$\\\$\\.'); + }); + it('adds as expected', () => { + runRegExTest( + // act + (act) => act.expectCharacters('return as it is'), + // assert + 'return as it is'); + }); + }); + it('expectOneOrMoreWhitespaces', () => { + runRegExTest( + // act + (act) => act.expectOneOrMoreWhitespaces(), + // assert + '\\s+'); + }); + it('matchPipeline', () => { + runRegExTest( + // act + (act) => act.matchPipeline(), + // assert + '\\s*(\\|\\s*.+?)?'); + }); + it('matchUntilFirstWhitespace', () => { + runRegExTest( + // act + (act) => act.matchUntilFirstWhitespace(), + // assert + '([^|\\s]+)'); + }); + it('matchAnythingExceptSurroundingWhitespaces', () => { + runRegExTest( + // act + (act) => act.matchAnythingExceptSurroundingWhitespaces(), + // assert + '\\s*(.+?)\\s*'); + }); + it('expectExpressionStart', () => { + runRegExTest( + // act + (act) => act.expectExpressionStart(), + // assert + '{{\\s*'); + }); + it('expectExpressionEnd', () => { + runRegExTest( + // act + (act) => act.expectExpressionEnd(), + // assert + '\\s*}}'); + }); + describe('buildRegExp', () => { + it('sets global flag', () => { + // arrange + const expected = 'g'; + const sut = new ExpressionRegexBuilder() + .expectOneOrMoreWhitespaces(); + // act + const actual = sut.buildRegExp().flags; + // assert + expect(actual).to.equal(expected); + }); + describe('can combine multiple parts', () => { + it('with', () => { + runRegExTest((sut) => sut + // act + .expectExpressionStart().expectCharacters('with').expectOneOrMoreWhitespaces().expectCharacters('$') + .matchUntilFirstWhitespace() + .expectExpressionEnd() + .matchAnythingExceptSurroundingWhitespaces() + .expectExpressionStart().expectCharacters('end').expectExpressionEnd(), + // assert + '{{\\s*with\\s+\\$([^|\\s]+)\\s*}}\\s*(.+?)\\s*{{\\s*end\\s*}}', + ); + }); + it('scoped substitution', () => { + runRegExTest((sut) => sut + // act + .expectExpressionStart().expectCharacters('.') + .matchPipeline() + .expectExpressionEnd(), + // assert + '{{\\s*\\.\\s*(\\|\\s*.+?)?\\s*}}', + ); + }); + it('parameter substitution', () => { + runRegExTest((sut) => sut + // act + .expectExpressionStart().expectCharacters('$') + .matchUntilFirstWhitespace() + .matchPipeline() + .expectExpressionEnd(), + // assert + '{{\\s*\\$([^|\\s]+)\\s*(\\|\\s*.+?)?\\s*}}', + ); + }); + }); + }); +}); + +function runRegExTest( + act: (sut: ExpressionRegexBuilder) => ExpressionRegexBuilder, + expected: string, + ) { + // arrange + const sut = new ExpressionRegexBuilder(); + // act + const actual = act(sut).buildRegExp().source; + // assert + expect(actual).to.equal(expected); +} diff --git a/tests/unit/application/Parser/Script/Compiler/Expressions/Parser/RegexParser.spec.ts b/tests/unit/application/Parser/Script/Compiler/Expressions/Parser/Regex/RegexParser.spec.ts similarity index 98% rename from tests/unit/application/Parser/Script/Compiler/Expressions/Parser/RegexParser.spec.ts rename to tests/unit/application/Parser/Script/Compiler/Expressions/Parser/Regex/RegexParser.spec.ts index 816415f8..e7f47a84 100644 --- a/tests/unit/application/Parser/Script/Compiler/Expressions/Parser/RegexParser.spec.ts +++ b/tests/unit/application/Parser/Script/Compiler/Expressions/Parser/Regex/RegexParser.spec.ts @@ -1,7 +1,7 @@ import 'mocha'; import { expect } from 'chai'; import { ExpressionEvaluator } from '@/application/Parser/Script/Compiler/Expressions/Expression/Expression'; -import { IPrimitiveExpression, RegexParser } from '@/application/Parser/Script/Compiler/Expressions/Parser/RegexParser'; +import { IPrimitiveExpression, RegexParser } from '@/application/Parser/Script/Compiler/Expressions/Parser/Regex/RegexParser'; import { ExpressionPosition } from '@/application/Parser/Script/Compiler/Expressions/Expression/ExpressionPosition'; import { FunctionParameterStub } from '@tests/unit/stubs/FunctionParameterStub'; diff --git a/tests/unit/application/Parser/Script/Compiler/Expressions/Pipes/PipeFactory.spec.ts b/tests/unit/application/Parser/Script/Compiler/Expressions/Pipes/PipeFactory.spec.ts new file mode 100644 index 00000000..71e57ee5 --- /dev/null +++ b/tests/unit/application/Parser/Script/Compiler/Expressions/Pipes/PipeFactory.spec.ts @@ -0,0 +1,113 @@ +import 'mocha'; +import { expect } from 'chai'; +import { PipeFactory } from '@/application/Parser/Script/Compiler/Expressions/Pipes/PipeFactory'; +import { PipeStub } from '@tests/unit/stubs/PipeStub'; + +describe('PipeFactory', () => { + describe('ctor', () => { + it('throws when instances with same name is registered', () => { + // arrange + const duplicateName = 'duplicateName'; + const expectedError = `Pipe name must be unique: "${duplicateName}"`; + const pipes = [ + new PipeStub().withName(duplicateName), + new PipeStub().withName('uniqueName'), + new PipeStub().withName(duplicateName), + ]; + // act + const act = () => new PipeFactory(pipes); + // expect + expect(act).to.throw(expectedError); + }); + it('throws when a pipe is undefined', () => { + // arrange + const expectedError = 'undefined pipe in list'; + const pipes = [ new PipeStub(), undefined ]; + // act + const act = () => new PipeFactory(pipes); + // expect + expect(act).to.throw(expectedError); + }); + describe('throws when name is invalid', () => { + // act + const act = (invalidName: string) => new PipeFactory([ new PipeStub().withName(invalidName) ]); + // assert + testPipeNameValidation(act); + }); + }); + describe('get', () => { + describe('throws when name is invalid', () => { + // arrange + const sut = new PipeFactory(); + // act + const act = (invalidName: string) => sut.get(invalidName); + // assert + testPipeNameValidation(act); + }); + it('gets registered instance when it exists', () => { + // arrange + const expected = new PipeStub().withName('expectedName'); + const pipes = [ expected, new PipeStub().withName('instanceToConfuse') ]; + const sut = new PipeFactory(pipes); + // act + const actual = sut.get(expected.name); + // expect + expect(actual).to.equal(expected); + }); + it('throws when instance does not exist', () => { + // arrange + const missingName = 'missingName'; + const expectedError = `Unknown pipe: "${missingName}"`; + const pipes = [ ]; + const sut = new PipeFactory(pipes); + // act + const act = () => sut.get(missingName); + // expect + expect(act).to.throw(expectedError); + }); + }); +}); + +function testPipeNameValidation(testRunner: (invalidName: string) => void) { + const testCases = [ + { + exceptionBuilder: () => 'empty pipe name', + values: [ null, undefined , ''], + }, + { + exceptionBuilder: (name: string) => `Pipe name should be camelCase: "${name}"`, + values: [ + 'PascalCase', + 'snake-case', + 'includesNumb3rs', + 'includes Whitespace', + 'noSpec\'ial', + ], + }, + ]; + for (const testCase of testCases) { + for (const invalidName of testCase.values) { + it(`invalid name (${printValue(invalidName)}) throws`, () => { + // arrange + const expectedError = testCase.exceptionBuilder(invalidName); + // act + const act = () => testRunner(invalidName); + // expect + expect(act).to.throw(expectedError); + }); + } + } +} + +function printValue(value: string) { + switch (value) { + case undefined: + return 'undefined'; + case null: + return 'null'; + case '': + return 'empty'; + default: + return value; + } +} diff --git a/tests/unit/application/Parser/Script/Compiler/Expressions/Pipes/PipelineCompiler.spec.ts b/tests/unit/application/Parser/Script/Compiler/Expressions/Pipes/PipelineCompiler.spec.ts new file mode 100644 index 00000000..8347df28 --- /dev/null +++ b/tests/unit/application/Parser/Script/Compiler/Expressions/Pipes/PipelineCompiler.spec.ts @@ -0,0 +1,138 @@ +import 'mocha'; +import { expect } from 'chai'; +import { PipelineCompiler } from '@/application/Parser/Script/Compiler/Expressions/Pipes/PipelineCompiler'; +import { IPipelineCompiler } from '@/application/Parser/Script/Compiler/Expressions/Pipes/IPipelineCompiler'; +import { IPipeFactory } from '@/application/Parser/Script/Compiler/Expressions/Pipes/PipeFactory'; +import { PipeStub } from '@tests/unit/stubs/PipeStub'; +import { PipeFactoryStub } from '@tests/unit/stubs/PipeFactoryStub'; + +describe('PipelineCompiler', () => { + describe('compile', () => { + describe('throws for invalid arguments', () => { + interface ITestCase { + name: string; + act: (test: PipelineTestRunner) => PipelineTestRunner; + expectedError: string; + } + const testCases: ITestCase[] = [ + { + name: '"value" is empty', + act: (test) => test.withValue(''), + expectedError: 'undefined value', + }, + { + name: '"value" is undefined', + act: (test) => test.withValue(undefined), + expectedError: 'undefined value', + }, + { + name: '"pipeline" is empty', + act: (test) => test.withPipeline(''), + expectedError: 'undefined pipeline', + }, + { + name: '"pipeline" is undefined', + act: (test) => test.withPipeline(undefined), + expectedError: 'undefined pipeline', + }, + { + name: '"pipeline" does not start with pipe', + act: (test) => test.withPipeline('pipeline |'), + expectedError: 'pipeline does not start with pipe', + }, + ]; + for (const testCase of testCases) { + it(testCase.name, () => { + // act + const runner = new PipelineTestRunner(); + testCase.act(runner); + const act = () => runner.compile(); + // assert + expect(act).to.throw(testCase.expectedError); + }); + } + }); + describe('compiles pipeline as expected', () => { + const testCases = [ + { + name: 'compiles single pipe as expected', + pipes: [ + new PipeStub().withName('doublePrint').withApplier((value) => `${value}-${value}`), + ], + pipeline: '| doublePrint', + value: 'value', + expected: 'value-value', + }, + { + name: 'compiles multiple pipes as expected', + pipes: [ + new PipeStub().withName('prependLetterA').withApplier((value) => `A-${value}`), + new PipeStub().withName('prependLetterB').withApplier((value) => `B-${value}`), + ], + pipeline: '| prependLetterA | prependLetterB', + value: 'value', + expected: 'B-A-value', + }, + { + name: 'compiles with relaxed whitespace placing', + pipes: [ + new PipeStub().withName('appendNumberOne').withApplier((value) => `${value}1`), + new PipeStub().withName('appendNumberTwo').withApplier((value) => `${value}2`), + new PipeStub().withName('appendNumberThree').withApplier((value) => `${value}3`), + ], + pipeline: ' | appendNumberOne|appendNumberTwo| appendNumberThree', + value: 'value', + expected: 'value123', + }, + { + name: 'can reuse same pipe', + pipes: [ + new PipeStub().withName('removeFirstChar').withApplier((value) => `${value.slice(1)}`), + ], + pipeline: ' | removeFirstChar | removeFirstChar | removeFirstChar', + value: 'value', + expected: 'ue', + }, + ]; + for (const testCase of testCases) { + it(testCase.name, () => { + // arrange + const runner = + new PipelineTestRunner() + .withValue(testCase.value) + .withPipeline(testCase.pipeline) + .withFactory(new PipeFactoryStub().withPipes(testCase.pipes)); + // act + const actual = runner.compile(); + // expect + expect(actual).to.equal(testCase.expected); + }); + } + }); + }); +}); + + +class PipelineTestRunner implements IPipelineCompiler { + private value: string = 'non-empty-value'; + private pipeline: string = '| validPipeline'; + private factory: IPipeFactory = new PipeFactoryStub(); + + public withValue(value: string) { + this.value = value; + return this; + } + public withPipeline(pipeline: string) { + this.pipeline = pipeline; + return this; + } + public withFactory(factory: IPipeFactory) { + this.factory = factory; + return this; + } + + public compile(): string { + const sut = new PipelineCompiler(this.factory); + return sut.compile(this.value, this.pipeline); + } +} diff --git a/tests/unit/application/Parser/Script/Compiler/Expressions/SyntaxParsers/ParameterSubstitutionParser.spec.ts b/tests/unit/application/Parser/Script/Compiler/Expressions/SyntaxParsers/ParameterSubstitutionParser.spec.ts index e383db36..d5559c19 100644 --- a/tests/unit/application/Parser/Script/Compiler/Expressions/SyntaxParsers/ParameterSubstitutionParser.spec.ts +++ b/tests/unit/application/Parser/Script/Compiler/Expressions/SyntaxParsers/ParameterSubstitutionParser.spec.ts @@ -57,4 +57,11 @@ describe('ParameterSubstitutionParser', () => { }, ); }); + describe('compiles pipes as expected', () => { + runner.expectPipeHits({ + codeBuilder: (pipeline) => `{{ $argument${pipeline}}}`, + parameterName: 'argument', + parameterValue: 'value', + }); + }); }); diff --git a/tests/unit/application/Parser/Script/Compiler/Expressions/SyntaxParsers/SyntaxParserTestsRunner.ts b/tests/unit/application/Parser/Script/Compiler/Expressions/SyntaxParsers/SyntaxParserTestsRunner.ts index 19ac5848..35308188 100644 --- a/tests/unit/application/Parser/Script/Compiler/Expressions/SyntaxParsers/SyntaxParserTestsRunner.ts +++ b/tests/unit/application/Parser/Script/Compiler/Expressions/SyntaxParsers/SyntaxParserTestsRunner.ts @@ -3,6 +3,8 @@ import { expect } from 'chai'; import { ExpressionPosition } from '@/application/Parser/Script/Compiler/Expressions/Expression/ExpressionPosition'; import { IExpressionParser } from '@/application/Parser/Script/Compiler/Expressions/Parser/IExpressionParser'; import { FunctionCallArgumentCollectionStub } from '@tests/unit/stubs/FunctionCallArgumentCollectionStub'; +import { ExpressionEvaluationContextStub } from '@tests/unit/stubs/ExpressionEvaluationContextStub'; +import { PipelineCompilerStub } from '@tests/unit/stubs/PipelineCompilerStub'; export class SyntaxParserTestsRunner { constructor(private readonly sut: IExpressionParser) { @@ -24,17 +26,66 @@ export class SyntaxParserTestsRunner { it(testCase.name, () => { // arrange const args = testCase.args(new FunctionCallArgumentCollectionStub()); + const context = new ExpressionEvaluationContextStub() + .withArgs(args); // act const expressions = this.sut.findExpressions(testCase.code); // assert - const actual = expressions.map((e) => e.evaluate(args)); + const actual = expressions.map((e) => e.evaluate(context)); expect(actual).to.deep.equal(testCase.expected); }); } return this; } + public expectPipeHits(data: IExpectPipeHitTestData) { + for (const validPipePart of PipeTestCases.ValidValues) { + this.expectHitPipePart(validPipePart, data); + } + for (const invalidPipePart of PipeTestCases.InvalidValues) { + this.expectMissPipePart(invalidPipePart, data); + } + } + private expectHitPipePart(pipeline: string, data: IExpectPipeHitTestData) { + it(`"${pipeline}" hits`, () => { + // arrange + const expectedPipePart = pipeline.trim(); + const code = data.codeBuilder(pipeline); + const args = new FunctionCallArgumentCollectionStub() + .withArgument(data.parameterName, data.parameterValue); + const pipelineCompiler = new PipelineCompilerStub(); + const context = new ExpressionEvaluationContextStub() + .withPipelineCompiler(pipelineCompiler) + .withArgs(args); + // act + const expressions = this.sut.findExpressions(code); + expressions[0].evaluate(context); + // assert + expect(expressions).has.lengthOf(1); + expect(pipelineCompiler.compileHistory).has.lengthOf(1); + const actualPipeNames = pipelineCompiler.compileHistory[0].pipeline; + const actualValue = pipelineCompiler.compileHistory[0].value; + expect(actualPipeNames).to.equal(expectedPipePart); + expect(actualValue).to.equal(data.parameterValue); + }); + } + private expectMissPipePart(pipeline: string, data: IExpectPipeHitTestData) { + it(`"${pipeline}" misses`, () => { + // arrange + const args = new FunctionCallArgumentCollectionStub() + .withArgument(data.parameterName, data.parameterValue); + const pipelineCompiler = new PipelineCompilerStub(); + const context = new ExpressionEvaluationContextStub() + .withPipelineCompiler(pipelineCompiler) + .withArgs(args); + const code = data.codeBuilder(pipeline); + // act + const expressions = this.sut.findExpressions(code); + expressions[0]?.evaluate(context); // Because an expression may include another with pipes + // assert + expect(pipelineCompiler.compileHistory).has.lengthOf(0); + }); + } } - interface IExpectResultTestCase { name: string; code: string; @@ -47,3 +98,25 @@ interface IExpectPositionTestCase { code: string; expected: readonly ExpressionPosition[]; } + +interface IExpectPipeHitTestData { + codeBuilder: (pipeline: string) => string; + parameterName: string; + parameterValue: string; +} + +const PipeTestCases = { + ValidValues: [ + // Single pipe with different whitespace combinations + ' | pipe1', ' |pipe1', '|pipe1', ' |pipe1', ' | pipe1', + + // Double pipes with different whitespace combinations + ' | pipe1 | pipe2', '| pipe1|pipe2', '|pipe1|pipe2', ' |pipe1 |pipe2', '| pipe1 | pipe2| pipe3 |pipe4', + + // Wrong cases, but should match anyway and let pipelineCompiler throw errors + '| pip€', '| pip{e} ', + ], + InvalidValues: [ + ' pipe1 |pipe2', ' pipe1', + ], +}; diff --git a/tests/unit/application/Parser/Script/Compiler/Expressions/SyntaxParsers/WithParser.spec.ts b/tests/unit/application/Parser/Script/Compiler/Expressions/SyntaxParsers/WithParser.spec.ts index b88944a5..3d1f5b45 100644 --- a/tests/unit/application/Parser/Script/Compiler/Expressions/SyntaxParsers/WithParser.spec.ts +++ b/tests/unit/application/Parser/Script/Compiler/Expressions/SyntaxParsers/WithParser.spec.ts @@ -16,8 +16,8 @@ describe('WithParser', () => { }, { name: 'when scope is used', - code: 'used here ({{ with $parameter }}value: {{ . }}{{ end }})', - expected: [ new ExpressionPosition(11, 55) ], + code: 'used here ({{ with $parameter }}value: {{.}}{{ end }})', + expected: [ new ExpressionPosition(11, 53) ], }, { name: 'when used twice', @@ -25,30 +25,121 @@ describe('WithParser', () => { expected: [ new ExpressionPosition(7, 51), new ExpressionPosition(61, 99) ], }, { - name: 'tolerates lack of spaces around brackets', - code: 'no whitespaces {{with $parameter}}value: {{.}}{{end}}', - expected: [ new ExpressionPosition(15, 53) ], - }, - { - name: 'does not tolerate space after dollar sign', - code: 'used here ({{ with $ parameter }}value: {{ . }}{{ end }})', - expected: [ ], + name: 'tolerate lack of whitespaces', + code: 'no whitespaces {{with $parameter}}value: {{ . }}{{end}}', + expected: [ new ExpressionPosition(15, 55) ], }, ); }); - describe('ignores when syntax is unexpected', () => { - runner.expectPosition( - { - name: 'does not tolerate whitespace after with', - code: '{{with $ parameter}}value: {{ . }}{{ end }}', - expected: [ ], - }, - { - name: 'does not tolerate whitespace before dollar', - code: '{{ with$parameter}}value: {{ . }}{{ end }}', - expected: [ ], - }, - ); + describe('ignores when syntax is wrong', () => { + describe('ignores expression if "with" syntax is wrong', () => { + runner.expectPosition( + { + name: 'does not tolerate whitespace after with', + code: '{{with $ parameter}}value: {{ . }}{{ end }}', + expected: [ ], + }, + { + name: 'does not tolerate whitespace before dollar', + code: '{{ with$parameter}}value: {{ . }}{{ end }}', + expected: [ ], + }, + { + name: 'wrong text at scope end', + code: '{{ with$parameter}}value: {{ . }}{{ fin }}', + expected: [ ], + }, + { + name: 'wrong text at expression start', + code: '{{ when $parameter}}value: {{ . }}{{ end }}', + expected: [ ], + }, + ); + + }); + describe('does not render argument if substitution syntax is wrong', () => { + runner.expectResults( + { + name: 'comma used instead of dot', + code: '{{ with $parameter }}Hello {{ , }}{{ end }}', + args: (args) => args + .withArgument('parameter', 'world!'), + expected: [ 'Hello {{ , }}' ], + }, + { + name: 'single brackets instead of double', + code: '{{ with $parameter }}Hello { . }{{ end }}', + args: (args) => args + .withArgument('parameter', 'world!'), + expected: [ 'Hello { . }' ], + }, + { + name: 'double dots instead of single', + code: '{{ with $parameter }}Hello {{ .. }}{{ end }}', + args: (args) => args + .withArgument('parameter', 'world!'), + expected: [ 'Hello {{ .. }}' ], + }, + ); + }); + }); + describe('renders scope conditionally', () => { + describe('does not render scope if argument is undefined', () => { + runner.expectResults( + { + name: 'does not render when value is undefined', + code: '{{ with $parameter }}dark{{ end }} ', + args: (args) => args + .withArgument('parameter', undefined), + expected: [ '' ], + }, + { + name: 'does not render when value is empty', + code: '{{ with $parameter }}dark {{.}}{{ end }}', + args: (args) => args + .withArgument('parameter', ''), + expected: [ '' ], + }, + { + name: 'does not render when argument is not provided', + code: '{{ with $parameter }}dark{{ end }}', + args: (args) => args, + expected: [ '' ], + }, + ); + }); + describe('render scope when variable has value', () => { + runner.expectResults( + { + name: 'renders scope even if value is not used', + code: '{{ with $parameter }}Hello world!{{ end }}', + args: (args) => args + .withArgument('parameter', 'Hello'), + expected: [ 'Hello world!' ], + }, + { + name: 'renders value when it has value', + code: '{{ with $parameter }}{{ . }} world!{{ end }}', + args: (args) => args + .withArgument('parameter', 'Hello'), + expected: [ 'Hello world!' ], + }, + { + name: 'renders value when whitespaces around brackets are missing', + code: '{{ with $parameter }}{{.}} world!{{ end }}', + args: (args) => args + .withArgument('parameter', 'Hello'), + expected: [ 'Hello world!' ], + }, + { + name: 'renders value multiple times when it\'s used multiple times', + code: '{{ with $letterL }}He{{ . }}{{ . }}o wor{{ . }}d!{{ end }}', + args: (args) => args + .withArgument('letterL', 'l'), + expected: [ 'Hello world!' ], + }, + ); + }); }); describe('ignores trailing and leading whitespaces and newlines inside scope', () => { runner.expectResults( @@ -82,60 +173,11 @@ describe('WithParser', () => { }, ); }); - describe('does not render scope if argument is undefined', () => { - runner.expectResults( - { - name: 'does not render when value is undefined', - code: '{{ with $parameter }}dark{{ end }} ', - args: (args) => args - .withArgument('parameter', undefined), - expected: [ '' ], - }, - { - name: 'does not render when value is empty', - code: '{{ with $parameter }}dark {{.}}{{ end }}', - args: (args) => args - .withArgument('parameter', ''), - expected: [ '' ], - }, - { - name: 'does not render when argument is not provided', - code: '{{ with $parameter }}dark{{ end }}', - args: (args) => args, - expected: [ '' ], - }, - ); - }); - describe('renders scope as expected', () => { - runner.expectResults( - { - name: 'renders scope even if value is not used', - code: '{{ with $parameter }}Hello world!{{ end }}', - args: (args) => args - .withArgument('parameter', 'Hello'), - expected: [ 'Hello world!' ], - }, - { - name: 'renders value when it has value', - code: '{{ with $parameter }}{{ . }} world!{{ end }}', - args: (args) => args - .withArgument('parameter', 'Hello'), - expected: [ 'Hello world!' ], - }, - { - name: 'renders value when whitespaces around brackets are missing', - code: '{{ with $parameter }}{{.}} world!{{ end }}', - args: (args) => args - .withArgument('parameter', 'Hello'), - expected: [ 'Hello world!' ], - }, - { - name: 'renders value multiple times when it\'s used multiple times', - code: '{{ with $letterL }}He{{ . }}{{ . }}o wor{{ . }}d!{{ end }}', - args: (args) => args - .withArgument('letterL', 'l'), - expected: [ 'Hello world!' ], - }, - ); + describe('compiles pipes in scope as expected', () => { + runner.expectPipeHits({ + codeBuilder: (pipeline) => `{{ with $argument }} {{ .${pipeline}}} {{ end }}`, + parameterName: 'argument', + parameterValue: 'value', + }); }); }); diff --git a/tests/unit/stubs/ExpressionEvaluationContextStub.ts b/tests/unit/stubs/ExpressionEvaluationContextStub.ts new file mode 100644 index 00000000..bacc512f --- /dev/null +++ b/tests/unit/stubs/ExpressionEvaluationContextStub.ts @@ -0,0 +1,19 @@ +import { IExpressionEvaluationContext } from '@/application/Parser/Script/Compiler/Expressions/Expression/ExpressionEvaluationContext'; +import { IPipelineCompiler } from '@/application/Parser/Script/Compiler/Expressions/Pipes/IPipelineCompiler'; +import { IReadOnlyFunctionCallArgumentCollection } from '@/application/Parser/Script/Compiler/FunctionCall/Argument/IFunctionCallArgumentCollection'; +import { FunctionCallArgumentCollectionStub } from './FunctionCallArgumentCollectionStub'; +import { PipelineCompilerStub } from './PipelineCompilerStub'; + +export class ExpressionEvaluationContextStub implements IExpressionEvaluationContext { + public args: IReadOnlyFunctionCallArgumentCollection = new FunctionCallArgumentCollectionStub() + .withArgument('test-arg', 'test-value'); + public pipelineCompiler: IPipelineCompiler = new PipelineCompilerStub(); + public withArgs(args: IReadOnlyFunctionCallArgumentCollection) { + this.args = args; + return this; + } + public withPipelineCompiler(pipelineCompiler: IPipelineCompiler) { + this.pipelineCompiler = pipelineCompiler; + return this; + } +} diff --git a/tests/unit/stubs/ExpressionStub.ts b/tests/unit/stubs/ExpressionStub.ts index bd187f40..7913aa5d 100644 --- a/tests/unit/stubs/ExpressionStub.ts +++ b/tests/unit/stubs/ExpressionStub.ts @@ -1,11 +1,11 @@ import { ExpressionPosition } from '@/application/Parser/Script/Compiler/Expressions/Expression/ExpressionPosition'; import { IExpression } from '@/application/Parser/Script/Compiler/Expressions/Expression/IExpression'; -import { IReadOnlyFunctionCallArgumentCollection } from '@/application/Parser/Script/Compiler/FunctionCall/Argument/IFunctionCallArgumentCollection'; import { IReadOnlyFunctionParameterCollection } from '@/application/Parser/Script/Compiler/Function/Parameter/IFunctionParameterCollection'; import { FunctionParameterCollectionStub } from './FunctionParameterCollectionStub'; +import { IExpressionEvaluationContext } from '@/application/Parser/Script/Compiler/Expressions/Expression/ExpressionEvaluationContext'; export class ExpressionStub implements IExpression { - public callHistory = new Array(); + public callHistory = new Array(); public position = new ExpressionPosition(0, 5); public parameters: IReadOnlyFunctionParameterCollection = new FunctionParameterCollectionStub(); private result: string; @@ -26,8 +26,9 @@ export class ExpressionStub implements IExpression { this.result = result; return this; } - public evaluate(args: IReadOnlyFunctionCallArgumentCollection): string { - this.callHistory.push(args); + public evaluate(context: IExpressionEvaluationContext): string { + const args = context.args; + this.callHistory.push(context); const result = this.result || `[expression-stub] args: ${args ? Object.keys(args).map((key) => `${key}: ${args[key]}`).join('", "') : 'none'}`; return result; } diff --git a/tests/unit/stubs/PipeFactoryStub.ts b/tests/unit/stubs/PipeFactoryStub.ts new file mode 100644 index 00000000..8f855427 --- /dev/null +++ b/tests/unit/stubs/PipeFactoryStub.ts @@ -0,0 +1,28 @@ +import { IPipe } from '@/application/Parser/Script/Compiler/Expressions/Pipes/IPipe'; +import { IPipeFactory } from '@/application/Parser/Script/Compiler/Expressions/Pipes/PipeFactory'; + +export class PipeFactoryStub implements IPipeFactory { + private readonly pipes = new Array(); + + public get(pipeName: string): IPipe { + const result = this.pipes.find((pipe) => pipe.name === pipeName); + if (!result) { + throw new Error(`pipe not registered: "${pipeName}"`); + } + return result; + } + + public withPipe(pipe: IPipe) { + if (!pipe) { + throw new Error('undefined pipe'); + } + this.pipes.push(pipe); + return this; + } + public withPipes(pipes: IPipe[]) { + for (const pipe of pipes) { + this.withPipe(pipe); + } + return this; + } +} diff --git a/tests/unit/stubs/PipeStub.ts b/tests/unit/stubs/PipeStub.ts new file mode 100644 index 00000000..f07f9dbc --- /dev/null +++ b/tests/unit/stubs/PipeStub.ts @@ -0,0 +1,16 @@ +import { IPipe } from '@/application/Parser/Script/Compiler/Expressions/Pipes/IPipe'; + +export class PipeStub implements IPipe { + public name: string = 'pipeStub'; + public apply(raw: string): string { + return raw; + } + public withName(name: string): PipeStub { + this.name = name; + return this; + } + public withApplier(applier: (input: string) => string): PipeStub { + this.apply = applier; + return this; + } +} diff --git a/tests/unit/stubs/PipelineCompilerStub.ts b/tests/unit/stubs/PipelineCompilerStub.ts new file mode 100644 index 00000000..2c006095 --- /dev/null +++ b/tests/unit/stubs/PipelineCompilerStub.ts @@ -0,0 +1,9 @@ +import { IPipelineCompiler } from '@/application/Parser/Script/Compiler/Expressions/Pipes/IPipelineCompiler'; + +export class PipelineCompilerStub implements IPipelineCompiler { + public compileHistory: Array<{ value: string, pipeline: string }> = []; + public compile(value: string, pipeline: string): string { + this.compileHistory.push({value, pipeline}); + return `value: ${value}"\n${pipeline}: ${pipeline}`; + } +}