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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface IPipe {
|
||||
readonly name: string;
|
||||
apply(input: string): string;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface IPipelineCompiler {
|
||||
compile(value: string, pipeline: string): string;
|
||||
}
|
||||
@@ -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}"`);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user