Add support for nested templates
Add support for expressions inside expressions.
Add support for templating where the output of one expression results in
another template part with expressions.
E.g., this did not work before, but compilation will now evaluate both
with expression with `$condition` and parameter substitution with
`$text`:
```
{{ with $condition }}
echo '{{ $text }}'
{{ end }}
```
Add also more sanity checks (validation logic) when compiling
expressions to reveal problems quickly.
This commit is contained in:
@@ -10,10 +10,19 @@
|
|||||||
|
|
||||||
- Expressions start and end with mustaches (double brackets, `{{` and `}}`).
|
- Expressions start and end with mustaches (double brackets, `{{` and `}}`).
|
||||||
- E.g. `Hello {{ $name }} !`
|
- E.g. `Hello {{ $name }} !`
|
||||||
- Syntax is close to [Go Templates ❤️](https://pkg.go.dev/text/template) that has inspired this templating language.
|
- Syntax is close to [Go Templates ❤️](https://pkg.go.dev/text/template) but not the same.
|
||||||
- Functions enables usage of expressions.
|
- Functions enables usage of expressions.
|
||||||
- In script definition parts of a function, see [`Function`](./collection-files.md#Function).
|
- In script definition parts of a function, see [`Function`](./collection-files.md#Function).
|
||||||
- When doing a call as argument values, see [`FunctionCall`](./collection-files.md#Function).
|
- When doing a call as argument values, see [`FunctionCall`](./collection-files.md#Function).
|
||||||
|
- Expressions inside expressions (nested templates) are supported.
|
||||||
|
- An expression can output another expression that will also be compiled.
|
||||||
|
- E.g. following would compile first [with expression](#with), and then [parameter substitution](#parameter-substitution) in its output.
|
||||||
|
|
||||||
|
```go
|
||||||
|
{{ with $condition }}
|
||||||
|
echo {{ $text }}
|
||||||
|
{{ end }}
|
||||||
|
```
|
||||||
|
|
||||||
### Parameter substitution
|
### Parameter substitution
|
||||||
|
|
||||||
@@ -74,6 +83,14 @@ It supports multiline text inside the block. You can have something like:
|
|||||||
{{ end }}
|
{{ end }}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
You can also use other expressions inside its block, such as [parameter substitution](#parameter-substitution):
|
||||||
|
|
||||||
|
```go
|
||||||
|
{{ with $condition }}
|
||||||
|
This is a different parameter: {{ $text }}
|
||||||
|
{{ end }}
|
||||||
|
```
|
||||||
|
|
||||||
💡 Declare parameters used for `with` condition as optional. Set `optional: true` for the argument if you use it like `{{ with $argument }} .. {{ end }}`.
|
💡 Declare parameters used for `with` condition as optional. Set `optional: true` for the argument if you use it like `{{ with $argument }} .. {{ end }}`.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
|||||||
@@ -13,4 +13,22 @@ export class ExpressionPosition {
|
|||||||
throw Error(`negative start position: ${start}`);
|
throw Error(`negative start position: ${start}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public isInInsideOf(potentialParent: ExpressionPosition): boolean {
|
||||||
|
if (this.isSame(potentialParent)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return potentialParent.start <= this.start
|
||||||
|
&& potentialParent.end >= this.end;
|
||||||
|
}
|
||||||
|
|
||||||
|
public isSame(other: ExpressionPosition): boolean {
|
||||||
|
return other.start === this.start
|
||||||
|
&& other.end === this.end;
|
||||||
|
}
|
||||||
|
|
||||||
|
public isIntersecting(other: ExpressionPosition): boolean {
|
||||||
|
return (other.start < this.end && other.end > this.start)
|
||||||
|
|| (this.end > other.start && other.start >= this.start);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,21 +20,59 @@ export class ExpressionsCompiler implements IExpressionsCompiler {
|
|||||||
if (!code) {
|
if (!code) {
|
||||||
return code;
|
return code;
|
||||||
}
|
}
|
||||||
const expressions = this.extractor.findExpressions(code);
|
|
||||||
ensureParamsUsedInCodeHasArgsProvided(expressions, args);
|
|
||||||
const context = new ExpressionEvaluationContext(args);
|
const context = new ExpressionEvaluationContext(args);
|
||||||
const compiledCode = compileExpressions(expressions, code, context);
|
const compiledCode = compileRecursively(code, context, this.extractor);
|
||||||
return compiledCode;
|
return compiledCode;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function compileRecursively(
|
||||||
|
code: string,
|
||||||
|
context: IExpressionEvaluationContext,
|
||||||
|
extractor: IExpressionParser,
|
||||||
|
): string {
|
||||||
|
/*
|
||||||
|
Instead of compiling code at once and returning we compile expressions from the code.
|
||||||
|
And recompile expressions from resulting code recursively.
|
||||||
|
This allows using expressions inside expressions blocks. E.g.:
|
||||||
|
```
|
||||||
|
{{ with $condition }}
|
||||||
|
echo '{{ $text }}'
|
||||||
|
{{ end }}
|
||||||
|
```
|
||||||
|
Without recursing parameter substitution for '{{ $text }}' is skipped once the outer
|
||||||
|
{{ with $condition }} is rendered.
|
||||||
|
A more optimized alternative to recursion would be to a parse an expression tree
|
||||||
|
instead of linear expression lists.
|
||||||
|
*/
|
||||||
|
if (!code) {
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
const expressions = extractor.findExpressions(code);
|
||||||
|
if (expressions.length === 0) {
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
const compiledCode = compileExpressions(expressions, code, context);
|
||||||
|
return compileRecursively(compiledCode, context, extractor);
|
||||||
|
}
|
||||||
|
|
||||||
function compileExpressions(
|
function compileExpressions(
|
||||||
expressions: readonly IExpression[],
|
expressions: readonly IExpression[],
|
||||||
code: string,
|
code: string,
|
||||||
context: IExpressionEvaluationContext,
|
context: IExpressionEvaluationContext,
|
||||||
) {
|
) {
|
||||||
|
ensureValidExpressions(expressions, code, context);
|
||||||
let compiledCode = '';
|
let compiledCode = '';
|
||||||
const sortedExpressions = expressions
|
const outerExpressions = expressions.filter(
|
||||||
|
(expression) => expressions
|
||||||
|
.filter((otherExpression) => otherExpression !== expression)
|
||||||
|
.every((otherExpression) => !expression.position.isInInsideOf(otherExpression.position)),
|
||||||
|
);
|
||||||
|
/*
|
||||||
|
This logic will only compile outer expressions if there were nested expressions.
|
||||||
|
So the output of this compilation may result in new uncompiled expressions.
|
||||||
|
*/
|
||||||
|
const sortedExpressions = outerExpressions
|
||||||
.slice() // copy the array to not mutate the parameter
|
.slice() // copy the array to not mutate the parameter
|
||||||
.sort((a, b) => b.position.start - a.position.start);
|
.sort((a, b) => b.position.start - a.position.start);
|
||||||
let index = 0;
|
let index = 0;
|
||||||
@@ -65,6 +103,43 @@ function extractRequiredParameterNames(
|
|||||||
.filter((name, index, array) => array.indexOf(name) === index); // Remove duplicates
|
.filter((name, index, array) => array.indexOf(name) === index); // Remove duplicates
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function printList(list: readonly string[]): string {
|
||||||
|
return `"${list.join('", "')}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureValidExpressions(
|
||||||
|
expressions: readonly IExpression[],
|
||||||
|
code: string,
|
||||||
|
context: IExpressionEvaluationContext,
|
||||||
|
) {
|
||||||
|
ensureParamsUsedInCodeHasArgsProvided(expressions, context.args);
|
||||||
|
ensureExpressionsDoesNotExtendCodeLength(expressions, code);
|
||||||
|
ensureNoExpressionsAtSamePosition(expressions);
|
||||||
|
ensureNoInvalidIntersections(expressions);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureExpressionsDoesNotExtendCodeLength(
|
||||||
|
expressions: readonly IExpression[],
|
||||||
|
code: string,
|
||||||
|
) {
|
||||||
|
const expectedMax = code.length;
|
||||||
|
const expressionsOutOfRange = expressions
|
||||||
|
.filter((expression) => expression.position.end > expectedMax);
|
||||||
|
if (expressionsOutOfRange.length > 0) {
|
||||||
|
throw new Error(`Expressions out of range:\n${JSON.stringify(expressionsOutOfRange)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureNoExpressionsAtSamePosition(expressions: readonly IExpression[]) {
|
||||||
|
const instructionsAtSamePosition = expressions.filter(
|
||||||
|
(expression) => expressions
|
||||||
|
.filter((other) => expression.position.isSame(other.position)).length > 1,
|
||||||
|
);
|
||||||
|
if (instructionsAtSamePosition.length > 0) {
|
||||||
|
throw new Error(`Instructions at same position:\n${JSON.stringify(instructionsAtSamePosition)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function ensureParamsUsedInCodeHasArgsProvided(
|
function ensureParamsUsedInCodeHasArgsProvided(
|
||||||
expressions: readonly IExpression[],
|
expressions: readonly IExpression[],
|
||||||
providedArgs: IReadOnlyFunctionCallArgumentCollection,
|
providedArgs: IReadOnlyFunctionCallArgumentCollection,
|
||||||
@@ -80,6 +155,16 @@ function ensureParamsUsedInCodeHasArgsProvided(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function printList(list: readonly string[]): string {
|
function ensureNoInvalidIntersections(expressions: readonly IExpression[]) {
|
||||||
return `"${list.join('", "')}"`;
|
const intersectingInstructions = expressions.filter(
|
||||||
|
(expression) => expressions
|
||||||
|
.filter((other) => expression.position.isIntersecting(other.position))
|
||||||
|
.filter((other) => !expression.position.isSame(other.position))
|
||||||
|
.filter((other) => !expression.position.isInInsideOf(other.position))
|
||||||
|
.filter((other) => !other.position.isInInsideOf(expression.position))
|
||||||
|
.length > 0,
|
||||||
|
);
|
||||||
|
if (intersectingInstructions.length > 0) {
|
||||||
|
throw new Error(`Instructions intersecting unexpectedly:\n${JSON.stringify(intersectingInstructions)}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,4 +31,184 @@ describe('ExpressionPosition', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
describe('isInInsideOf', () => {
|
||||||
|
// arrange
|
||||||
|
const testCases: readonly {
|
||||||
|
name: string,
|
||||||
|
sut: ExpressionPosition,
|
||||||
|
potentialParent: ExpressionPosition,
|
||||||
|
expectedResult: boolean,
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
name: 'true; when other contains sut inside boundaries',
|
||||||
|
sut: new ExpressionPosition(4, 8),
|
||||||
|
potentialParent: new ExpressionPosition(0, 10),
|
||||||
|
expectedResult: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'true; when other contains sut with same upper boundary',
|
||||||
|
sut: new ExpressionPosition(4, 10),
|
||||||
|
potentialParent: new ExpressionPosition(0, 10),
|
||||||
|
expectedResult: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'true; when other contains sut with same lower boundary',
|
||||||
|
sut: new ExpressionPosition(0, 8),
|
||||||
|
potentialParent: new ExpressionPosition(0, 10),
|
||||||
|
expectedResult: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'false; when other is same as sut',
|
||||||
|
sut: new ExpressionPosition(0, 10),
|
||||||
|
potentialParent: new ExpressionPosition(0, 10),
|
||||||
|
expectedResult: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'false; when sut contains other',
|
||||||
|
sut: new ExpressionPosition(0, 10),
|
||||||
|
potentialParent: new ExpressionPosition(4, 8),
|
||||||
|
expectedResult: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'false; when sut starts and ends before other',
|
||||||
|
sut: new ExpressionPosition(0, 10),
|
||||||
|
potentialParent: new ExpressionPosition(15, 25),
|
||||||
|
expectedResult: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'false; when sut starts before other but ends inside other',
|
||||||
|
sut: new ExpressionPosition(0, 10),
|
||||||
|
potentialParent: new ExpressionPosition(5, 10),
|
||||||
|
expectedResult: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'false; when sut starts inside other but ends after other',
|
||||||
|
sut: new ExpressionPosition(5, 11),
|
||||||
|
potentialParent: new ExpressionPosition(0, 10),
|
||||||
|
expectedResult: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'false; when sut starts at same position but end after other',
|
||||||
|
sut: new ExpressionPosition(0, 11),
|
||||||
|
potentialParent: new ExpressionPosition(0, 10),
|
||||||
|
expectedResult: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'false; when sut ends at same positions but start before other',
|
||||||
|
sut: new ExpressionPosition(0, 10),
|
||||||
|
potentialParent: new ExpressionPosition(1, 10),
|
||||||
|
expectedResult: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
for (const testCase of testCases) {
|
||||||
|
it(testCase.name, () => {
|
||||||
|
// act
|
||||||
|
const actual = testCase.sut.isInInsideOf(testCase.potentialParent);
|
||||||
|
// assert
|
||||||
|
expect(actual).to.equal(testCase.expectedResult);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
describe('isSame', () => {
|
||||||
|
// arrange
|
||||||
|
const testCases: readonly {
|
||||||
|
name: string,
|
||||||
|
sut: ExpressionPosition,
|
||||||
|
other: ExpressionPosition,
|
||||||
|
expectedResult: boolean,
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
name: 'true; when positions are same',
|
||||||
|
sut: new ExpressionPosition(0, 10),
|
||||||
|
other: new ExpressionPosition(0, 10),
|
||||||
|
expectedResult: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'false; when start position is different',
|
||||||
|
sut: new ExpressionPosition(0, 10),
|
||||||
|
other: new ExpressionPosition(1, 10),
|
||||||
|
expectedResult: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'false; when end position is different',
|
||||||
|
sut: new ExpressionPosition(0, 10),
|
||||||
|
other: new ExpressionPosition(0, 11),
|
||||||
|
expectedResult: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'false; when both start and end positions are different',
|
||||||
|
sut: new ExpressionPosition(0, 10),
|
||||||
|
other: new ExpressionPosition(20, 30),
|
||||||
|
expectedResult: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
for (const testCase of testCases) {
|
||||||
|
it(testCase.name, () => {
|
||||||
|
// act
|
||||||
|
const actual = testCase.sut.isSame(testCase.other);
|
||||||
|
// assert
|
||||||
|
expect(actual).to.equal(testCase.expectedResult);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
describe('isIntersecting', () => {
|
||||||
|
// arrange
|
||||||
|
const testCases: readonly {
|
||||||
|
name: string,
|
||||||
|
first: ExpressionPosition,
|
||||||
|
second: ExpressionPosition,
|
||||||
|
expectedResult: boolean,
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
name: 'true; when one contains other inside boundaries',
|
||||||
|
first: new ExpressionPosition(4, 8),
|
||||||
|
second: new ExpressionPosition(0, 10),
|
||||||
|
expectedResult: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'true; when one starts inside other\'s ending boundary without being contained',
|
||||||
|
first: new ExpressionPosition(0, 10),
|
||||||
|
second: new ExpressionPosition(9, 15),
|
||||||
|
expectedResult: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'true; when positions are the same',
|
||||||
|
first: new ExpressionPosition(0, 5),
|
||||||
|
second: new ExpressionPosition(0, 5),
|
||||||
|
expectedResult: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'true; when one starts inside other\'s starting boundary without being contained',
|
||||||
|
first: new ExpressionPosition(5, 10),
|
||||||
|
second: new ExpressionPosition(5, 11),
|
||||||
|
expectedResult: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'false; when one starts directly after other',
|
||||||
|
first: new ExpressionPosition(0, 10),
|
||||||
|
second: new ExpressionPosition(10, 20),
|
||||||
|
expectedResult: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'false; when one starts after other with margin',
|
||||||
|
first: new ExpressionPosition(0, 10),
|
||||||
|
second: new ExpressionPosition(100, 200),
|
||||||
|
expectedResult: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
for (const testCase of testCases) {
|
||||||
|
it(testCase.name, () => {
|
||||||
|
// act
|
||||||
|
const actual = testCase.first.isIntersecting(testCase.second);
|
||||||
|
// assert
|
||||||
|
expect(actual).to.equal(testCase.expectedResult);
|
||||||
|
});
|
||||||
|
it(`reversed: ${testCase.name}`, () => {
|
||||||
|
// act
|
||||||
|
const actual = testCase.second.isIntersecting(testCase.first);
|
||||||
|
// assert
|
||||||
|
expect(actual).to.equal(testCase.expectedResult);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,10 +6,11 @@ import { ExpressionStub } from '@tests/unit/shared/Stubs/ExpressionStub';
|
|||||||
import { ExpressionParserStub } from '@tests/unit/shared/Stubs/ExpressionParserStub';
|
import { ExpressionParserStub } from '@tests/unit/shared/Stubs/ExpressionParserStub';
|
||||||
import { FunctionCallArgumentCollectionStub } from '@tests/unit/shared/Stubs/FunctionCallArgumentCollectionStub';
|
import { FunctionCallArgumentCollectionStub } from '@tests/unit/shared/Stubs/FunctionCallArgumentCollectionStub';
|
||||||
import { itEachAbsentObjectValue, itEachAbsentStringValue } from '@tests/unit/shared/TestCases/AbsentTests';
|
import { itEachAbsentObjectValue, itEachAbsentStringValue } from '@tests/unit/shared/TestCases/AbsentTests';
|
||||||
|
import { IExpression } from '@/application/Parser/Script/Compiler/Expressions/Expression/IExpression';
|
||||||
|
|
||||||
describe('ExpressionsCompiler', () => {
|
describe('ExpressionsCompiler', () => {
|
||||||
describe('compileExpressions', () => {
|
describe('compileExpressions', () => {
|
||||||
describe('returns code when it is absent', () => {
|
describe('returns code when code is absent', () => {
|
||||||
itEachAbsentStringValue((absentValue) => {
|
itEachAbsentStringValue((absentValue) => {
|
||||||
// arrange
|
// arrange
|
||||||
const expected = absentValue;
|
const expected = absentValue;
|
||||||
@@ -21,6 +22,99 @@ describe('ExpressionsCompiler', () => {
|
|||||||
expect(value).to.equal(expected);
|
expect(value).to.equal(expected);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
describe('can compile nested expressions', () => {
|
||||||
|
it('when one expression is evaluated to a text that contains another expression', () => {
|
||||||
|
// arrange
|
||||||
|
const expectedResult = 'hello world!';
|
||||||
|
const rawCode = 'hello {{ firstExpression }}!';
|
||||||
|
const outerExpressionResult = '{{ secondExpression }}';
|
||||||
|
const expectedCodeAfterFirstCompilationRound = 'hello {{ secondExpression }}!';
|
||||||
|
const innerExpressionResult = 'world';
|
||||||
|
const expressionParserMock = new ExpressionParserStub()
|
||||||
|
.withResult(rawCode, [
|
||||||
|
new ExpressionStub()
|
||||||
|
// {{ firstExpression }
|
||||||
|
.withPosition(6, 27)
|
||||||
|
// Parser would hit the outer expression
|
||||||
|
.withEvaluatedResult(outerExpressionResult),
|
||||||
|
])
|
||||||
|
.withResult(expectedCodeAfterFirstCompilationRound, [
|
||||||
|
new ExpressionStub()
|
||||||
|
// {{ secondExpression }}
|
||||||
|
.withPosition(6, 28)
|
||||||
|
// once the outer expression parser, compiler now parses its evaluated result
|
||||||
|
.withEvaluatedResult(innerExpressionResult),
|
||||||
|
]);
|
||||||
|
const sut = new SystemUnderTest(expressionParserMock);
|
||||||
|
const args = new FunctionCallArgumentCollectionStub();
|
||||||
|
// act
|
||||||
|
const actual = sut.compileExpressions(rawCode, args);
|
||||||
|
// assert
|
||||||
|
expect(actual).to.equal(expectedResult);
|
||||||
|
});
|
||||||
|
describe('when one expression contains another hardcoded expression', () => {
|
||||||
|
it('when hardcoded expression is does not contain the hardcoded expression', () => {
|
||||||
|
// arrange
|
||||||
|
const expectedResult = 'hi !';
|
||||||
|
const rawCode = 'hi {{ outerExpressionStart }}delete {{ innerExpression }} me{{ outerExpressionEnd }}!';
|
||||||
|
const outerExpressionResult = '';
|
||||||
|
const innerExpressionResult = 'should not be there';
|
||||||
|
const expressionParserMock = new ExpressionParserStub()
|
||||||
|
.withResult(rawCode, [
|
||||||
|
new ExpressionStub()
|
||||||
|
// {{ outerExpressionStart }}delete {{ innerExpression }} me{{ outerExpressionEnd }}
|
||||||
|
.withPosition(3, 84)
|
||||||
|
.withEvaluatedResult(outerExpressionResult),
|
||||||
|
new ExpressionStub()
|
||||||
|
// {{ innerExpression }}
|
||||||
|
.withPosition(36, 57)
|
||||||
|
// Parser would hit both expressions as one is hardcoded in other
|
||||||
|
.withEvaluatedResult(innerExpressionResult),
|
||||||
|
])
|
||||||
|
.withResult(expectedResult, []);
|
||||||
|
const sut = new SystemUnderTest(expressionParserMock);
|
||||||
|
const args = new FunctionCallArgumentCollectionStub();
|
||||||
|
// act
|
||||||
|
const actual = sut.compileExpressions(rawCode, args);
|
||||||
|
// assert
|
||||||
|
expect(actual).to.equal(expectedResult);
|
||||||
|
});
|
||||||
|
it('when hardcoded expression contains the hardcoded expression', () => {
|
||||||
|
// arrange
|
||||||
|
const expectedResult = 'hi game of thrones!';
|
||||||
|
const rawCode = 'hi {{ outerExpressionStart }} game {{ innerExpression }} {{ outerExpressionEnd }}!';
|
||||||
|
const expectedCodeAfterFirstCompilationRound = 'hi game {{ innerExpression }}!'; // outer is compiled first
|
||||||
|
const outerExpressionResult = 'game {{ innerExpression }}';
|
||||||
|
const innerExpressionResult = 'of thrones';
|
||||||
|
const expressionParserMock = new ExpressionParserStub()
|
||||||
|
.withResult(rawCode, [
|
||||||
|
new ExpressionStub()
|
||||||
|
// {{ outerExpressionStart }} game {{ innerExpression }} {{ outerExpressionEnd }}
|
||||||
|
.withPosition(3, 81)
|
||||||
|
// Parser would hit the outer expression
|
||||||
|
.withEvaluatedResult(outerExpressionResult),
|
||||||
|
new ExpressionStub()
|
||||||
|
// {{ innerExpression }}
|
||||||
|
.withPosition(35, 57)
|
||||||
|
// Parser would hit both expressions as one is hardcoded in other
|
||||||
|
.withEvaluatedResult(innerExpressionResult),
|
||||||
|
])
|
||||||
|
.withResult(expectedCodeAfterFirstCompilationRound, [
|
||||||
|
new ExpressionStub()
|
||||||
|
// {{ innerExpression }}
|
||||||
|
.withPosition(8, 29)
|
||||||
|
// once the outer expression parser, compiler now parses its evaluated result
|
||||||
|
.withEvaluatedResult(innerExpressionResult),
|
||||||
|
]);
|
||||||
|
const sut = new SystemUnderTest(expressionParserMock);
|
||||||
|
const args = new FunctionCallArgumentCollectionStub();
|
||||||
|
// act
|
||||||
|
const actual = sut.compileExpressions(rawCode, args);
|
||||||
|
// assert
|
||||||
|
expect(actual).to.equal(expectedResult);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
describe('combines expressions as expected', () => {
|
describe('combines expressions as expected', () => {
|
||||||
// arrange
|
// arrange
|
||||||
const code = 'part1 {{ a }} part2 {{ b }} part3';
|
const code = 'part1 {{ a }} part2 {{ b }} part3';
|
||||||
@@ -60,7 +154,7 @@ describe('ExpressionsCompiler', () => {
|
|||||||
for (const testCase of testCases) {
|
for (const testCase of testCases) {
|
||||||
it(testCase.name, () => {
|
it(testCase.name, () => {
|
||||||
const expressionParserMock = new ExpressionParserStub()
|
const expressionParserMock = new ExpressionParserStub()
|
||||||
.withResult(testCase.expressions);
|
.withResult(code, testCase.expressions);
|
||||||
const args = new FunctionCallArgumentCollectionStub();
|
const args = new FunctionCallArgumentCollectionStub();
|
||||||
const sut = new SystemUnderTest(expressionParserMock);
|
const sut = new SystemUnderTest(expressionParserMock);
|
||||||
// act
|
// act
|
||||||
@@ -75,21 +169,26 @@ describe('ExpressionsCompiler', () => {
|
|||||||
// arrange
|
// arrange
|
||||||
const expected = new FunctionCallArgumentCollectionStub()
|
const expected = new FunctionCallArgumentCollectionStub()
|
||||||
.withArgument('test-arg', 'test-value');
|
.withArgument('test-arg', 'test-value');
|
||||||
const code = 'non-important';
|
const code = 'longer than 6 characters';
|
||||||
const expressions = [
|
const expressions = [
|
||||||
new ExpressionStub(),
|
new ExpressionStub().withPosition(0, 3),
|
||||||
new ExpressionStub(),
|
new ExpressionStub().withPosition(3, 6),
|
||||||
];
|
];
|
||||||
const expressionParserMock = new ExpressionParserStub()
|
const expressionParserMock = new ExpressionParserStub()
|
||||||
.withResult(expressions);
|
.withResult(code, expressions);
|
||||||
const sut = new SystemUnderTest(expressionParserMock);
|
const sut = new SystemUnderTest(expressionParserMock);
|
||||||
// act
|
// act
|
||||||
sut.compileExpressions(code, expected);
|
sut.compileExpressions(code, expected);
|
||||||
// assert
|
// assert
|
||||||
expect(expressions[0].callHistory).to.have.lengthOf(1);
|
const actualArgs = expressions
|
||||||
expect(expressions[0].callHistory[0].args).to.equal(expected);
|
.flatMap((expression) => expression.callHistory)
|
||||||
expect(expressions[1].callHistory).to.have.lengthOf(1);
|
.map((context) => context.args);
|
||||||
expect(expressions[1].callHistory[0].args).to.equal(expected);
|
expect(
|
||||||
|
actualArgs.every((arg) => arg === expected),
|
||||||
|
`Expected: ${JSON.stringify(expected)}\n`
|
||||||
|
+ `Actual: ${JSON.stringify(actualArgs)}\n`
|
||||||
|
+ `Not equal: ${actualArgs.filter((arg) => arg !== expected)}`,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
describe('throws if arguments is missing', () => {
|
describe('throws if arguments is missing', () => {
|
||||||
itEachAbsentObjectValue((absentValue) => {
|
itEachAbsentObjectValue((absentValue) => {
|
||||||
@@ -105,66 +204,122 @@ describe('ExpressionsCompiler', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
describe('throws when expected argument is not provided but used in code', () => {
|
describe('throws when expressions are invalid', () => {
|
||||||
// arrange
|
describe('throws when expected argument is not provided but used in code', () => {
|
||||||
const testCases = [
|
// arrange
|
||||||
{
|
const testCases = [
|
||||||
name: 'empty parameters',
|
{
|
||||||
expressions: [
|
name: 'empty parameters',
|
||||||
new ExpressionStub().withParameterNames(['parameter'], false),
|
expressions: [
|
||||||
],
|
new ExpressionStub().withParameterNames(['parameter'], false),
|
||||||
args: new FunctionCallArgumentCollectionStub(),
|
],
|
||||||
expectedError: 'parameter value(s) not provided for: "parameter" but used in code',
|
args: new FunctionCallArgumentCollectionStub(),
|
||||||
},
|
expectedError: 'parameter value(s) not provided for: "parameter" but used in code',
|
||||||
{
|
},
|
||||||
name: 'unnecessary parameter is provided',
|
{
|
||||||
expressions: [
|
name: 'unnecessary parameter is provided',
|
||||||
new ExpressionStub().withParameterNames(['parameter'], false),
|
expressions: [
|
||||||
],
|
new ExpressionStub().withParameterNames(['parameter'], false),
|
||||||
args: new FunctionCallArgumentCollectionStub()
|
],
|
||||||
.withArgument('unnecessaryParameter', 'unnecessaryValue'),
|
args: new FunctionCallArgumentCollectionStub()
|
||||||
expectedError: 'parameter value(s) not provided for: "parameter" but used in code',
|
.withArgument('unnecessaryParameter', 'unnecessaryValue'),
|
||||||
},
|
expectedError: 'parameter value(s) not provided for: "parameter" but used in code',
|
||||||
{
|
},
|
||||||
name: 'multiple values are not provided',
|
{
|
||||||
expressions: [
|
name: 'multiple values are not provided',
|
||||||
new ExpressionStub().withParameterNames(['parameter1'], false),
|
expressions: [
|
||||||
new ExpressionStub().withParameterNames(['parameter2', 'parameter3'], false),
|
new ExpressionStub().withParameterNames(['parameter1'], false),
|
||||||
],
|
new ExpressionStub().withParameterNames(['parameter2', 'parameter3'], false),
|
||||||
args: new FunctionCallArgumentCollectionStub(),
|
],
|
||||||
expectedError: 'parameter value(s) not provided for: "parameter1", "parameter2", "parameter3" but used in code',
|
args: new FunctionCallArgumentCollectionStub(),
|
||||||
},
|
expectedError: 'parameter value(s) not provided for: "parameter1", "parameter2", "parameter3" but used in code',
|
||||||
{
|
},
|
||||||
name: 'some values are provided',
|
{
|
||||||
expressions: [
|
name: 'some values are provided',
|
||||||
new ExpressionStub().withParameterNames(['parameter1'], false),
|
expressions: [
|
||||||
new ExpressionStub().withParameterNames(['parameter2', 'parameter3'], false),
|
new ExpressionStub().withParameterNames(['parameter1'], false),
|
||||||
],
|
new ExpressionStub().withParameterNames(['parameter2', 'parameter3'], false),
|
||||||
args: new FunctionCallArgumentCollectionStub()
|
],
|
||||||
.withArgument('parameter2', 'value'),
|
args: new FunctionCallArgumentCollectionStub()
|
||||||
expectedError: 'parameter value(s) not provided for: "parameter1", "parameter3" but used in code',
|
.withArgument('parameter2', 'value'),
|
||||||
},
|
expectedError: 'parameter value(s) not provided for: "parameter1", "parameter3" but used in code',
|
||||||
{
|
},
|
||||||
name: 'parameter names are not repeated in error message',
|
{
|
||||||
expressions: [
|
name: 'parameter names are not repeated in error message',
|
||||||
new ExpressionStub().withParameterNames(['parameter1', 'parameter1', 'parameter2', 'parameter2'], false),
|
expressions: [
|
||||||
],
|
new ExpressionStub().withParameterNames(['parameter1', 'parameter1', 'parameter2', 'parameter2'], false),
|
||||||
args: new FunctionCallArgumentCollectionStub(),
|
],
|
||||||
expectedError: 'parameter value(s) not provided for: "parameter1", "parameter2" but used in code',
|
args: new FunctionCallArgumentCollectionStub(),
|
||||||
},
|
expectedError: 'parameter value(s) not provided for: "parameter1", "parameter2" but used in code',
|
||||||
];
|
},
|
||||||
for (const testCase of testCases) {
|
];
|
||||||
it(testCase.name, () => {
|
for (const testCase of testCases) {
|
||||||
const code = 'non-important-code';
|
it(testCase.name, () => {
|
||||||
const expressionParserMock = new ExpressionParserStub()
|
const code = 'non-important-code';
|
||||||
.withResult(testCase.expressions);
|
const expressionParserMock = new ExpressionParserStub()
|
||||||
const sut = new SystemUnderTest(expressionParserMock);
|
.withResult(code, testCase.expressions);
|
||||||
// act
|
const sut = new SystemUnderTest(expressionParserMock);
|
||||||
const act = () => sut.compileExpressions(code, testCase.args);
|
// act
|
||||||
// assert
|
const act = () => sut.compileExpressions(code, testCase.args);
|
||||||
expect(act).to.throw(testCase.expectedError);
|
// assert
|
||||||
});
|
expect(act).to.throw(testCase.expectedError);
|
||||||
}
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
describe('throws when expression positions are unexpected', () => {
|
||||||
|
// arrange
|
||||||
|
const code = 'c'.repeat(30);
|
||||||
|
const testCases: readonly {
|
||||||
|
name: string,
|
||||||
|
expressions: readonly IExpression[],
|
||||||
|
expectedError: string,
|
||||||
|
expectedResult: boolean,
|
||||||
|
}[] = [
|
||||||
|
(() => {
|
||||||
|
const badExpression = new ExpressionStub().withPosition(0, code.length + 5);
|
||||||
|
const goodExpression = new ExpressionStub().withPosition(0, code.length - 1);
|
||||||
|
return {
|
||||||
|
name: 'an expression has out-of-range position',
|
||||||
|
expressions: [badExpression, goodExpression],
|
||||||
|
expectedError: `Expressions out of range:\n${JSON.stringify([badExpression])}`,
|
||||||
|
expectedResult: true,
|
||||||
|
};
|
||||||
|
})(),
|
||||||
|
(() => {
|
||||||
|
const duplicatedExpression = new ExpressionStub().withPosition(0, code.length - 1);
|
||||||
|
const uniqueExpression = new ExpressionStub().withPosition(0, code.length - 2);
|
||||||
|
return {
|
||||||
|
name: 'two expressions at the same position',
|
||||||
|
expressions: [duplicatedExpression, duplicatedExpression, uniqueExpression],
|
||||||
|
expectedError: `Instructions at same position:\n${JSON.stringify([duplicatedExpression, duplicatedExpression])}`,
|
||||||
|
expectedResult: true,
|
||||||
|
};
|
||||||
|
})(),
|
||||||
|
(() => {
|
||||||
|
const goodExpression = new ExpressionStub().withPosition(0, 5);
|
||||||
|
const intersectingExpression = new ExpressionStub().withPosition(5, 10);
|
||||||
|
const intersectingExpressionOther = new ExpressionStub().withPosition(7, 12);
|
||||||
|
return {
|
||||||
|
name: 'intersecting expressions',
|
||||||
|
expressions: [goodExpression, intersectingExpression, intersectingExpressionOther],
|
||||||
|
expectedError: `Instructions intersecting unexpectedly:\n${JSON.stringify([intersectingExpression, intersectingExpressionOther])}`,
|
||||||
|
expectedResult: true,
|
||||||
|
};
|
||||||
|
})(),
|
||||||
|
];
|
||||||
|
for (const testCase of testCases) {
|
||||||
|
it(testCase.name, () => {
|
||||||
|
const expressionParserMock = new ExpressionParserStub()
|
||||||
|
.withResult(code, testCase.expressions);
|
||||||
|
const sut = new SystemUnderTest(expressionParserMock);
|
||||||
|
const args = new FunctionCallArgumentCollectionStub();
|
||||||
|
// act
|
||||||
|
const act = () => sut.compileExpressions(code, args);
|
||||||
|
// assert
|
||||||
|
expect(act).to.throw(testCase.expectedError);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
it('calls parser with expected code', () => {
|
it('calls parser with expected code', () => {
|
||||||
// arrange
|
// arrange
|
||||||
|
|||||||
@@ -4,15 +4,25 @@ import { IExpressionParser } from '@/application/Parser/Script/Compiler/Expressi
|
|||||||
export class ExpressionParserStub implements IExpressionParser {
|
export class ExpressionParserStub implements IExpressionParser {
|
||||||
public callHistory = new Array<string>();
|
public callHistory = new Array<string>();
|
||||||
|
|
||||||
private result: IExpression[] = [];
|
private results = new Map<string, readonly IExpression[]>();
|
||||||
|
|
||||||
public withResult(result: IExpression[]) {
|
public withResult(code: string, result: readonly IExpression[]) {
|
||||||
this.result = result;
|
if (this.results.has(code)) {
|
||||||
|
throw new Error(
|
||||||
|
'Result for code is already registered.'
|
||||||
|
+ `\nCode: ${code}`
|
||||||
|
+ `\nResult: ${JSON.stringify(result)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.results.set(code, result);
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public findExpressions(code: string): IExpression[] {
|
public findExpressions(code: string): IExpression[] {
|
||||||
this.callHistory.push(code);
|
this.callHistory.push(code);
|
||||||
return this.result;
|
if (this.results.has(code)) {
|
||||||
|
return [...this.results.get(code)];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export class ExpressionStub implements IExpression {
|
|||||||
|
|
||||||
public parameters: IReadOnlyFunctionParameterCollection = new FunctionParameterCollectionStub();
|
public parameters: IReadOnlyFunctionParameterCollection = new FunctionParameterCollectionStub();
|
||||||
|
|
||||||
private result: string;
|
private result: string = undefined;
|
||||||
|
|
||||||
public withParameters(parameters: IReadOnlyFunctionParameterCollection) {
|
public withParameters(parameters: IReadOnlyFunctionParameterCollection) {
|
||||||
this.parameters = parameters;
|
this.parameters = parameters;
|
||||||
@@ -35,9 +35,11 @@ export class ExpressionStub implements IExpression {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public evaluate(context: IExpressionEvaluationContext): string {
|
public evaluate(context: IExpressionEvaluationContext): string {
|
||||||
const { args } = context;
|
|
||||||
this.callHistory.push(context);
|
this.callHistory.push(context);
|
||||||
const result = this.result || `[expression-stub] args: ${args ? Object.keys(args).map((key) => `${key}: ${args[key]}`).join('", "') : 'none'}`;
|
if (this.result === undefined /* not empty string */) {
|
||||||
return result;
|
const { args } = context;
|
||||||
|
return `[expression-stub] args: ${args ? Object.keys(args).map((key) => `${key}: ${args[key]}`).join('", "') : 'none'}`;
|
||||||
|
}
|
||||||
|
return this.result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user