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

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,59 @@
export class ExpressionRegexBuilder {
private readonly parts = new Array<string>();
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;
}
}

View File

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

View File

@@ -0,0 +1,4 @@
export interface IPipe {
readonly name: string;
apply(input: string): string;
}

View File

@@ -0,0 +1,3 @@
export interface IPipelineCompiler {
compile(value: string, pipeline: string): string;
}

View File

@@ -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<string, IPipe>();
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}"`);
}
}

View File

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

View File

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

View File

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