Add support for pipes in templates #53

The goal is to be able to modify values of variables used in templates.
It enables future functionality such as escaping, inlining etc.

It adds support applying predefined pipes to variables. Pipes
can be applied to variable substitution in with and parameter
substitution expressions. They work in similar way to piping in Unix
where each pipe applied to the compiled result of pipe before.

It adds support for using pipes in `with` and parameter substitution
expressions. It also refactors how their regex is build to reuse more of
the logic by abstracting regex building into a new class.

Finally, it separates and extends documentation for templating.
This commit is contained in:
undergroundwires
2021-09-08 18:58:30 +01:00
parent 862914b06e
commit 4d7ff7edc5
30 changed files with 1112 additions and 207 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -57,4 +57,11 @@ describe('ParameterSubstitutionParser', () => {
},
);
});
describe('compiles pipes as expected', () => {
runner.expectPipeHits({
codeBuilder: (pipeline) => `{{ $argument${pipeline}}}`,
parameterName: 'argument',
parameterValue: 'value',
});
});
});

View File

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

View File

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