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

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

@@ -0,0 +1,126 @@
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/Regex/RegexParser';
import { ExpressionPosition } from '@/application/Parser/Script/Compiler/Expressions/Expression/ExpressionPosition';
import { FunctionParameterStub } from '@tests/unit/stubs/FunctionParameterStub';
describe('RegexParser', () => {
describe('findExpressions', () => {
describe('matches regex as expected', () => {
// arrange
const testCases = [
{
name: 'returns no result when regex does not match',
regex: /hello/g,
code: 'world',
},
{
name: 'returns expected when regex matches single',
regex: /hello/g,
code: 'hello world',
},
{
name: 'returns expected when regex matches multiple',
regex: /l/g,
code: 'hello world',
},
];
for (const testCase of testCases) {
it(testCase.name, () => {
const expected = Array.from(testCase.code.matchAll(testCase.regex));
const matches = new Array<RegExpMatchArray>();
const builder = (m: RegExpMatchArray): IPrimitiveExpression => {
matches.push(m);
return mockPrimitiveExpression();
};
const sut = new RegexParserConcrete(testCase.regex, builder);
// act
const expressions = sut.findExpressions(testCase.code);
// assert
expect(expressions).to.have.lengthOf(matches.length);
expect(matches).to.deep.equal(expected);
});
}
});
it('sets evaluator as expected', () => {
// arrange
const expected = getEvaluatorStub();
const regex = /hello/g;
const code = 'hello';
const builder = (): IPrimitiveExpression => ({
evaluator: expected,
});
const sut = new RegexParserConcrete(regex, builder);
// act
const expressions = sut.findExpressions(code);
// assert
expect(expressions).to.have.lengthOf(1);
expect(expressions[0].evaluate === expected);
});
it('sets parameters as expected', () => {
// arrange
const expected = [
new FunctionParameterStub().withName('parameter1').withOptionality(true),
new FunctionParameterStub().withName('parameter2').withOptionality(false),
];
const regex = /hello/g;
const code = 'hello';
const builder = (): IPrimitiveExpression => ({
evaluator: getEvaluatorStub(),
parameters: expected,
});
const sut = new RegexParserConcrete(regex, builder);
// act
const expressions = sut.findExpressions(code);
// assert
expect(expressions).to.have.lengthOf(1);
expect(expressions[0].parameters.all).to.deep.equal(expected);
});
it('sets expected position', () => {
// arrange
const code = 'mate date in state is fate';
const regex = /ate/g;
const expected = [
new ExpressionPosition(1, 4),
new ExpressionPosition(6, 9),
new ExpressionPosition(15, 18),
new ExpressionPosition(23, 26),
];
const sut = new RegexParserConcrete(regex);
// act
const expressions = sut.findExpressions(code);
// assert
const actual = expressions.map((e) => e.position);
expect(actual).to.deep.equal(expected);
});
});
});
function mockBuilder(): (match: RegExpMatchArray) => IPrimitiveExpression {
return () => ({
evaluator: getEvaluatorStub(),
});
}
function getEvaluatorStub(): ExpressionEvaluator {
return () => undefined;
}
function mockPrimitiveExpression(): IPrimitiveExpression {
return {
evaluator: getEvaluatorStub(),
};
}
class RegexParserConcrete extends RegexParser {
protected regex: RegExp;
public constructor(
regex: RegExp,
private readonly builder = mockBuilder()) {
super();
this.regex = regex;
}
protected buildExpression(match: RegExpMatchArray): IPrimitiveExpression {
return this.builder(match);
}
}