Fix compiler failing with nested with expression

The previous implementation of `WithParser` used regex, which struggles
with parsing nested structures correctly. This commit improves
`WithParser` to track and parse all nested `with` expressions.

Other improvements:

- Throw meaningful errors when syntax is wrong. Replacing the prior
  behavior of silently ignoring such issues.
- Remove `I` prefix from related interfaces to align with newer code
  conventions.
- Add more unit tests for `with` expression.
- Improve documentation for templating.
- `ExpressionRegexBuilder`:
  - Use words `capture` and `match` correctly.
  - Fix minor issues revealed by new and improved tests:
     - Change regex for matching anything except surrounding
       whitespaces. The new regex ensures that it works even without
       having any preceeding text.
     - Change regex for capturing pipelines. The old regex was only
       matching (non-greedy) first character of the pipeline in tests,
       new regex matches the full pipeline.
- `ExpressionRegexBuilder.spec.ts`:
  - Ensure consistent way to define `describe` and `it` blocks.
  - Replace `expectRegex` tests, regex expectations test internal
    behavior of the class, not the external.
  - Simplified tests by eliminating the need for UUID suffixes/prefixes.
This commit is contained in:
undergroundwires
2023-10-25 19:39:12 +02:00
parent dfd4451561
commit 80821fca07
7 changed files with 976 additions and 421 deletions

View File

@@ -1,59 +1,222 @@
// eslint-disable-next-line max-classes-per-file
import { IExpressionParser } from '@/application/Parser/Script/Compiler/Expressions/Parser/IExpressionParser';
import { FunctionParameterCollection } from '@/application/Parser/Script/Compiler/Function/Parameter/FunctionParameterCollection';
import { FunctionParameter } from '@/application/Parser/Script/Compiler/Function/Parameter/FunctionParameter';
import { RegexParser, IPrimitiveExpression } from '../Parser/Regex/RegexParser';
import { IExpression } from '../Expression/IExpression';
import { ExpressionPosition } from '../Expression/ExpressionPosition';
import { ExpressionRegexBuilder } from '../Parser/Regex/ExpressionRegexBuilder';
export class WithParser extends RegexParser {
protected readonly regex = new ExpressionRegexBuilder()
// {{ with $parameterName }}
.expectExpressionStart()
.expectCharacters('with')
.expectOneOrMoreWhitespaces()
.expectCharacters('$')
.matchUntilFirstWhitespace() // First match: parameter name
.expectExpressionEnd()
// ...
.matchMultilineAnythingExceptSurroundingWhitespaces() // Second match: Scope text
// {{ end }}
.expectExpressionStart()
.expectCharacters('end')
.expectExpressionEnd()
.buildRegExp();
export class WithParser implements IExpressionParser {
public findExpressions(code: string): IExpression[] {
if (!code) {
throw new Error('missing code');
}
return parseWithExpressions(code);
}
}
protected buildExpression(match: RegExpMatchArray): IPrimitiveExpression {
const parameterName = match[1];
const scopeText = match[2];
enum WithStatementType {
Start,
End,
ContextVariable,
}
type WithStatement = {
readonly type: WithStatementType.Start;
readonly parameterName: string;
readonly position: ExpressionPosition;
} | {
readonly type: WithStatementType.End;
readonly position: ExpressionPosition;
} | {
readonly type: WithStatementType.ContextVariable;
readonly position: ExpressionPosition;
readonly pipeline: string | undefined;
};
function parseAllWithExpressions(
input: string,
): WithStatement[] {
const expressions = new Array<WithStatement>();
for (const match of input.matchAll(WithStatementStartRegEx)) {
expressions.push({
type: WithStatementType.Start,
parameterName: match[1],
position: createPosition(match),
});
}
for (const match of input.matchAll(WithStatementEndRegEx)) {
expressions.push({
type: WithStatementType.End,
position: createPosition(match),
});
}
for (const match of input.matchAll(ContextVariableWithPipelineRegEx)) {
expressions.push({
type: WithStatementType.ContextVariable,
position: createPosition(match),
pipeline: match[1],
});
}
return expressions;
}
function createPosition(match: RegExpMatchArray): ExpressionPosition {
const startPos = match.index;
const endPos = startPos + match[0].length;
return new ExpressionPosition(startPos, endPos);
}
class WithStatementBuilder {
private readonly contextVariables = new Array<{
readonly positionInScope: ExpressionPosition;
readonly pipeline: string | undefined;
}>();
public addContextVariable(
absolutePosition: ExpressionPosition,
pipeline: string | undefined,
): void {
const positionInScope = new ExpressionPosition(
absolutePosition.start - this.startExpressionPosition.end,
absolutePosition.end - this.startExpressionPosition.end,
);
this.contextVariables.push({
positionInScope,
pipeline,
});
}
public buildExpression(endExpressionPosition: ExpressionPosition, input: string): IExpression {
const parameters = new FunctionParameterCollection();
parameters.addParameter(new FunctionParameter(this.parameterName, true));
const position = new ExpressionPosition(
this.startExpressionPosition.start,
endExpressionPosition.end,
);
const scope = input.substring(this.startExpressionPosition.end, endExpressionPosition.start);
return {
parameters: [new FunctionParameter(parameterName, true)],
evaluator: (context) => {
const argumentValue = context.args.hasArgument(parameterName)
? context.args.getArgument(parameterName).argumentValue
parameters,
position,
evaluate: (context) => {
const argumentValue = context.args.hasArgument(this.parameterName)
? context.args.getArgument(this.parameterName).argumentValue
: undefined;
if (!argumentValue) {
return '';
}
return replaceEachScopeSubstitution(scopeText, (pipeline) => {
const substitutedScope = this.substituteContextVariables(scope, (pipeline) => {
if (!pipeline) {
return argumentValue;
}
return context.pipelineCompiler.compile(argumentValue, pipeline);
});
return substitutedScope;
},
};
}
constructor(
private readonly startExpressionPosition: ExpressionPosition,
private readonly parameterName: string,
) {
}
private substituteContextVariables(
scope: string,
substituter: (pipeline: string) => string,
): string {
if (!this.contextVariables.length) {
return scope;
}
let substitutedScope = '';
let scopeSubstrIndex = 0;
for (const contextVariable of this.contextVariables) {
substitutedScope += scope.substring(scopeSubstrIndex, contextVariable.positionInScope.start);
substitutedScope += substituter(contextVariable.pipeline);
scopeSubstrIndex = contextVariable.positionInScope.end;
}
substitutedScope += scope.substring(scopeSubstrIndex, scope.length);
return substitutedScope;
}
}
const ScopeSubstitutionRegEx = new ExpressionRegexBuilder()
function buildErrorContext(code: string, statements: readonly WithStatement[]): string {
const formattedStatements = statements.map((s) => `- [${s.position.start}, ${s.position.end}] ${WithStatementType[s.type]}`).join('\n');
return [
'Code:', '---', code, '---',
'nStatements:', '---', formattedStatements, '---',
].join('\n');
}
function parseWithExpressions(input: string): IExpression[] {
const allStatements = parseAllWithExpressions(input);
const sortedStatements = allStatements
.slice()
.sort((a, b) => b.position.start - a.position.start);
const expressions = new Array<IExpression>();
const builders = new Array<WithStatementBuilder>();
const throwWithContext = (message: string) => {
throw new Error(`${message}\n${buildErrorContext(input, allStatements)}}`);
};
while (sortedStatements.length > 0) {
const statement = sortedStatements.pop();
if (!statement) {
break;
}
switch (statement.type) { // eslint-disable-line default-case
case WithStatementType.Start:
builders.push(new WithStatementBuilder(
statement.position,
statement.parameterName,
));
break;
case WithStatementType.ContextVariable:
if (builders.length === 0) {
throwWithContext('Context variable before `with` statement.');
}
builders[builders.length - 1].addContextVariable(statement.position, statement.pipeline);
break;
case WithStatementType.End:
if (builders.length === 0) {
throwWithContext('Redundant `end` statement, missing `with`?');
}
expressions.push(builders.pop().buildExpression(statement.position, input));
break;
}
}
if (builders.length > 0) {
throwWithContext('Missing `end` statement, forgot `{{ end }}?');
}
return expressions;
}
const ContextVariableWithPipelineRegEx = new ExpressionRegexBuilder()
// {{ . | pipeName }}
.expectExpressionStart()
.expectCharacters('.')
.matchPipeline() // First match: pipeline
.expectOptionalWhitespaces()
.captureOptionalPipeline() // First capture: pipeline
.expectExpressionEnd()
.buildRegExp();
function replaceEachScopeSubstitution(scopeText: string, replacer: (pipeline: string) => string) {
// Not using /{{\s*.\s*(?:(\|\s*[^{}]*?)\s*)?}}/g for not matching brackets,
// but instead letting the pipeline compiler to fail on those.
return scopeText.replaceAll(ScopeSubstitutionRegEx, (_$, match1) => {
return replacer(match1);
});
}
const WithStatementStartRegEx = new ExpressionRegexBuilder()
// {{ with $parameterName }}
.expectExpressionStart()
.expectCharacters('with')
.expectOneOrMoreWhitespaces()
.expectCharacters('$')
.captureUntilWhitespaceOrPipe() // First capture: parameter name
.expectExpressionEnd()
.expectOptionalWhitespaces()
.buildRegExp();
const WithStatementEndRegEx = new ExpressionRegexBuilder()
// {{ end }}
.expectOptionalWhitespaces()
.expectExpressionStart()
.expectCharacters('end')
.expectOptionalWhitespaces()
.expectExpressionEnd()
.buildRegExp();