Refactor to unify scripts/categories as Executable
This commit consolidates scripts and categories under a unified 'Executable' concept. This simplifies the architecture and improves code readability. - Introduce subfolders within `src/domain` to segregate domain elements. - Update class and interface names by removing the 'I' prefix in alignment with new coding standards. - Replace 'Node' with 'Executable' to clarify usage; reserve 'Node' exclusively for the UI's tree component.
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
import { FunctionParameterCollection } from '@/application/Parser/Executable/Script/Compiler/Function/Parameter/FunctionParameterCollection';
|
||||
import { FunctionCallArgumentCollection } from '../../Function/Call/Argument/FunctionCallArgumentCollection';
|
||||
import { ExpressionEvaluationContext, type IExpressionEvaluationContext } from './ExpressionEvaluationContext';
|
||||
import { ExpressionPosition } from './ExpressionPosition';
|
||||
import type { IReadOnlyFunctionCallArgumentCollection } from '../../Function/Call/Argument/IFunctionCallArgumentCollection';
|
||||
import type { IReadOnlyFunctionParameterCollection } from '../../Function/Parameter/IFunctionParameterCollection';
|
||||
import type { IExpression } from './IExpression';
|
||||
|
||||
export type ExpressionEvaluator = (context: IExpressionEvaluationContext) => string;
|
||||
|
||||
export class Expression implements IExpression {
|
||||
public readonly parameters: IReadOnlyFunctionParameterCollection;
|
||||
|
||||
public readonly position: ExpressionPosition;
|
||||
|
||||
public readonly evaluator: ExpressionEvaluator;
|
||||
|
||||
constructor(parameters: ExpressionInitParameters) {
|
||||
this.parameters = parameters.parameters ?? new FunctionParameterCollection();
|
||||
this.evaluator = parameters.evaluator;
|
||||
this.position = parameters.position;
|
||||
}
|
||||
|
||||
public evaluate(context: IExpressionEvaluationContext): string {
|
||||
validateThatAllRequiredParametersAreSatisfied(this.parameters, context.args);
|
||||
const args = filterUnusedArguments(this.parameters, context.args);
|
||||
const filteredContext = new ExpressionEvaluationContext(args, context.pipelineCompiler);
|
||||
return this.evaluator(filteredContext);
|
||||
}
|
||||
}
|
||||
|
||||
export interface ExpressionInitParameters {
|
||||
readonly position: ExpressionPosition,
|
||||
readonly evaluator: ExpressionEvaluator,
|
||||
readonly parameters?: IReadOnlyFunctionParameterCollection,
|
||||
}
|
||||
|
||||
function validateThatAllRequiredParametersAreSatisfied(
|
||||
parameters: IReadOnlyFunctionParameterCollection,
|
||||
args: IReadOnlyFunctionCallArgumentCollection,
|
||||
) {
|
||||
const requiredParameterNames = parameters
|
||||
.all
|
||||
.filter((parameter) => !parameter.isOptional)
|
||||
.map((parameter) => parameter.name);
|
||||
const missingParameterNames = requiredParameterNames
|
||||
.filter((parameterName) => !args.hasArgument(parameterName));
|
||||
if (missingParameterNames.length) {
|
||||
throw new Error(
|
||||
`argument values are provided for required parameters: "${missingParameterNames.join('", "')}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function filterUnusedArguments(
|
||||
parameters: IReadOnlyFunctionParameterCollection,
|
||||
allFunctionArgs: IReadOnlyFunctionCallArgumentCollection,
|
||||
): IReadOnlyFunctionCallArgumentCollection {
|
||||
const specificCallArgs = new FunctionCallArgumentCollection();
|
||||
parameters.all
|
||||
.filter((parameter) => allFunctionArgs.hasArgument(parameter.name))
|
||||
.map((parameter) => allFunctionArgs.getArgument(parameter.name))
|
||||
.forEach((argument) => specificCallArgs.addArgument(argument));
|
||||
return specificCallArgs;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { PipelineCompiler } from '../Pipes/PipelineCompiler';
|
||||
import type { IReadOnlyFunctionCallArgumentCollection } from '../../Function/Call/Argument/IFunctionCallArgumentCollection';
|
||||
import type { IPipelineCompiler } from '../Pipes/IPipelineCompiler';
|
||||
|
||||
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(),
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
export class ExpressionPosition {
|
||||
constructor(
|
||||
public readonly start: number,
|
||||
public readonly end: number,
|
||||
) {
|
||||
if (start === end) {
|
||||
throw new Error(`no length (start = end = ${start})`);
|
||||
}
|
||||
if (start > end) {
|
||||
throw Error(`start (${start}) after end (${end})`);
|
||||
}
|
||||
if (start < 0) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { ExpressionPosition } from './ExpressionPosition';
|
||||
|
||||
export interface ExpressionPositionFactory {
|
||||
(
|
||||
match: RegExpMatchArray,
|
||||
): ExpressionPosition
|
||||
}
|
||||
|
||||
export const createPositionFromRegexFullMatch
|
||||
: ExpressionPositionFactory = (match) => {
|
||||
const startPos = match.index;
|
||||
if (startPos === undefined) {
|
||||
throw new Error(`Regex match did not yield any results: ${JSON.stringify(match)}`);
|
||||
}
|
||||
const fullMatch = match[0];
|
||||
if (!fullMatch.length) {
|
||||
throw new Error(`Regex match is empty: ${JSON.stringify(match)}`);
|
||||
}
|
||||
const endPos = startPos + fullMatch.length;
|
||||
return new ExpressionPosition(startPos, endPos);
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import { ExpressionPosition } from './ExpressionPosition';
|
||||
import type { IReadOnlyFunctionParameterCollection } from '../../Function/Parameter/IFunctionParameterCollection';
|
||||
import type { IExpressionEvaluationContext } from './ExpressionEvaluationContext';
|
||||
|
||||
export interface IExpression {
|
||||
readonly position: ExpressionPosition;
|
||||
readonly parameters: IReadOnlyFunctionParameterCollection;
|
||||
evaluate(context: IExpressionEvaluationContext): string;
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { type IExpressionEvaluationContext, ExpressionEvaluationContext } from '@/application/Parser/Executable/Script/Compiler/Expressions/Expression/ExpressionEvaluationContext';
|
||||
import { CompositeExpressionParser } from './Parser/CompositeExpressionParser';
|
||||
import type { IReadOnlyFunctionCallArgumentCollection } from '../Function/Call/Argument/IFunctionCallArgumentCollection';
|
||||
import type { IExpressionsCompiler } from './IExpressionsCompiler';
|
||||
import type { IExpression } from './Expression/IExpression';
|
||||
import type { IExpressionParser } from './Parser/IExpressionParser';
|
||||
|
||||
export class ExpressionsCompiler implements IExpressionsCompiler {
|
||||
public constructor(
|
||||
private readonly extractor: IExpressionParser = new CompositeExpressionParser(),
|
||||
) { }
|
||||
|
||||
public compileExpressions(
|
||||
code: string,
|
||||
args: IReadOnlyFunctionCallArgumentCollection,
|
||||
): string {
|
||||
if (!code) {
|
||||
return '';
|
||||
}
|
||||
const context = new ExpressionEvaluationContext(args);
|
||||
const compiledCode = compileRecursively(code, context, this.extractor);
|
||||
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(
|
||||
expressions: readonly IExpression[],
|
||||
code: string,
|
||||
context: IExpressionEvaluationContext,
|
||||
) {
|
||||
ensureValidExpressions(expressions, code, context);
|
||||
let compiledCode = '';
|
||||
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
|
||||
.sort((a, b) => b.position.start - a.position.start);
|
||||
let index = 0;
|
||||
while (index !== code.length) {
|
||||
const nextExpression = sortedExpressions.pop();
|
||||
if (nextExpression) {
|
||||
compiledCode += code.substring(index, nextExpression.position.start);
|
||||
const expressionCode = nextExpression.evaluate(context);
|
||||
compiledCode += expressionCode;
|
||||
index = nextExpression.position.end;
|
||||
} else {
|
||||
compiledCode += code.substring(index, code.length);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return compiledCode;
|
||||
}
|
||||
|
||||
function extractRequiredParameterNames(
|
||||
expressions: readonly IExpression[],
|
||||
): string[] {
|
||||
return expressions
|
||||
.map((e) => e.parameters.all
|
||||
.filter((p) => !p.isOptional)
|
||||
.map((p) => p.name))
|
||||
.filter(Boolean) // Remove empty or undefined
|
||||
.flat()
|
||||
.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(
|
||||
expressions: readonly IExpression[],
|
||||
providedArgs: IReadOnlyFunctionCallArgumentCollection,
|
||||
): void {
|
||||
const usedParameterNames = extractRequiredParameterNames(expressions);
|
||||
if (!usedParameterNames.length) {
|
||||
return;
|
||||
}
|
||||
const notProvidedParameters = usedParameterNames
|
||||
.filter((parameterName) => !providedArgs.hasArgument(parameterName));
|
||||
if (notProvidedParameters.length) {
|
||||
throw new Error(`parameter value(s) not provided for: ${printList(notProvidedParameters)} but used in code`);
|
||||
}
|
||||
}
|
||||
|
||||
function ensureNoInvalidIntersections(expressions: readonly IExpression[]) {
|
||||
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)}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { IReadOnlyFunctionCallArgumentCollection } from '../Function/Call/Argument/IFunctionCallArgumentCollection';
|
||||
|
||||
export interface IExpressionsCompiler {
|
||||
compileExpressions(
|
||||
code: string,
|
||||
args: IReadOnlyFunctionCallArgumentCollection,
|
||||
): string;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ParameterSubstitutionParser } from '../SyntaxParsers/ParameterSubstitutionParser';
|
||||
import { WithParser } from '../SyntaxParsers/WithParser';
|
||||
import type { IExpression } from '../Expression/IExpression';
|
||||
import type { IExpressionParser } from './IExpressionParser';
|
||||
|
||||
const Parsers: readonly IExpressionParser[] = [
|
||||
new ParameterSubstitutionParser(),
|
||||
new WithParser(),
|
||||
] as const;
|
||||
|
||||
export class CompositeExpressionParser implements IExpressionParser {
|
||||
public constructor(private readonly leafs: readonly IExpressionParser[] = Parsers) {
|
||||
if (!leafs.length) {
|
||||
throw new Error('missing leafs');
|
||||
}
|
||||
}
|
||||
|
||||
public findExpressions(code: string): IExpression[] {
|
||||
return this.leafs.flatMap(
|
||||
(parser) => parser.findExpressions(code) || [],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { IExpression } from '../Expression/IExpression';
|
||||
|
||||
export interface IExpressionParser {
|
||||
findExpressions(code: string): IExpression[];
|
||||
}
|
||||
@@ -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 captureOptionalPipeline() {
|
||||
return this
|
||||
.addRawRegex('((?:\\|\\s*\\b[a-zA-Z]+\\b\\s*)*)');
|
||||
}
|
||||
|
||||
public captureUntilWhitespaceOrPipe() {
|
||||
return this
|
||||
.addRawRegex('([^|\\s]+)');
|
||||
}
|
||||
|
||||
public captureMultilineAnythingExceptSurroundingWhitespaces() {
|
||||
return this
|
||||
.expectOptionalWhitespaces()
|
||||
.addRawRegex('([\\s\\S]*\\S)')
|
||||
.expectOptionalWhitespaces();
|
||||
}
|
||||
|
||||
public expectExpressionStart() {
|
||||
return this
|
||||
.expectCharacters('{{')
|
||||
.expectOptionalWhitespaces();
|
||||
}
|
||||
|
||||
public expectExpressionEnd() {
|
||||
return this
|
||||
.expectOptionalWhitespaces()
|
||||
.expectCharacters('}}');
|
||||
}
|
||||
|
||||
public expectOptionalWhitespaces() {
|
||||
return this
|
||||
.addRawRegex('\\s*');
|
||||
}
|
||||
|
||||
public buildRegExp(): RegExp {
|
||||
return new RegExp(this.parts.join(''), 'g');
|
||||
}
|
||||
|
||||
private addRawRegex(regex: string) {
|
||||
this.parts.push(regex);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/ContextualError';
|
||||
import { Expression, type ExpressionEvaluator } from '../../Expression/Expression';
|
||||
import { createPositionFromRegexFullMatch, type ExpressionPositionFactory } from '../../Expression/ExpressionPositionFactory';
|
||||
import { createFunctionParameterCollection, type FunctionParameterCollectionFactory } from '../../../Function/Parameter/FunctionParameterCollectionFactory';
|
||||
import type { IExpressionParser } from '../IExpressionParser';
|
||||
import type { IExpression } from '../../Expression/IExpression';
|
||||
import type { IFunctionParameter } from '../../../Function/Parameter/IFunctionParameter';
|
||||
import type { IFunctionParameterCollection, IReadOnlyFunctionParameterCollection } from '../../../Function/Parameter/IFunctionParameterCollection';
|
||||
|
||||
export interface RegexParserUtilities {
|
||||
readonly wrapError: ErrorWithContextWrapper;
|
||||
readonly createPosition: ExpressionPositionFactory;
|
||||
readonly createExpression: ExpressionFactory;
|
||||
readonly createParameterCollection: FunctionParameterCollectionFactory;
|
||||
}
|
||||
|
||||
export abstract class RegexParser implements IExpressionParser {
|
||||
protected abstract readonly regex: RegExp;
|
||||
|
||||
public constructor(
|
||||
private readonly utilities: RegexParserUtilities = DefaultRegexParserUtilities,
|
||||
) {
|
||||
|
||||
}
|
||||
|
||||
public findExpressions(code: string): IExpression[] {
|
||||
return Array.from(this.findRegexExpressions(code));
|
||||
}
|
||||
|
||||
protected abstract buildExpression(match: RegExpMatchArray): PrimitiveExpression;
|
||||
|
||||
private* findRegexExpressions(code: string): Iterable<IExpression> {
|
||||
if (!code) {
|
||||
throw new Error(
|
||||
this.buildErrorMessageWithContext({ errorMessage: 'missing code', code: 'EMPTY' }),
|
||||
);
|
||||
}
|
||||
const createErrorContext = (message: string): ErrorContext => ({ code, errorMessage: message });
|
||||
const matches = this.doOrRethrow(
|
||||
() => code.matchAll(this.regex),
|
||||
createErrorContext('Failed to match regex.'),
|
||||
);
|
||||
for (const match of matches) {
|
||||
const primitiveExpression = this.doOrRethrow(
|
||||
() => this.buildExpression(match),
|
||||
createErrorContext('Failed to build expression.'),
|
||||
);
|
||||
const position = this.doOrRethrow(
|
||||
() => this.utilities.createPosition(match),
|
||||
createErrorContext('Failed to create position.'),
|
||||
);
|
||||
const parameters = this.doOrRethrow(
|
||||
() => createParameters(
|
||||
primitiveExpression,
|
||||
this.utilities.createParameterCollection(),
|
||||
),
|
||||
createErrorContext('Failed to create parameters.'),
|
||||
);
|
||||
const expression = this.doOrRethrow(
|
||||
() => this.utilities.createExpression({
|
||||
position,
|
||||
evaluator: primitiveExpression.evaluator,
|
||||
parameters,
|
||||
}),
|
||||
createErrorContext('Failed to create expression.'),
|
||||
);
|
||||
yield expression;
|
||||
}
|
||||
}
|
||||
|
||||
private doOrRethrow<T>(
|
||||
action: () => T,
|
||||
context: ErrorContext,
|
||||
): T {
|
||||
try {
|
||||
return action();
|
||||
} catch (error) {
|
||||
throw this.utilities.wrapError(
|
||||
error,
|
||||
this.buildErrorMessageWithContext(context),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private buildErrorMessageWithContext(context: ErrorContext): string {
|
||||
return [
|
||||
context.errorMessage,
|
||||
`Class name: ${this.constructor.name}`,
|
||||
`Regex pattern used: ${this.regex}`,
|
||||
`Code: ${context.code}`,
|
||||
].join('\n');
|
||||
}
|
||||
}
|
||||
|
||||
interface ErrorContext {
|
||||
readonly errorMessage: string,
|
||||
readonly code: string,
|
||||
}
|
||||
|
||||
function createParameters(
|
||||
expression: PrimitiveExpression,
|
||||
parameterCollection: IFunctionParameterCollection,
|
||||
): IReadOnlyFunctionParameterCollection {
|
||||
return (expression.parameters || [])
|
||||
.reduce((parameters, parameter) => {
|
||||
parameters.addParameter(parameter);
|
||||
return parameters;
|
||||
}, parameterCollection);
|
||||
}
|
||||
|
||||
export interface PrimitiveExpression {
|
||||
readonly evaluator: ExpressionEvaluator;
|
||||
readonly parameters?: readonly IFunctionParameter[];
|
||||
}
|
||||
|
||||
export interface ExpressionFactory {
|
||||
(
|
||||
...args: ConstructorParameters<typeof Expression>
|
||||
): IExpression;
|
||||
}
|
||||
|
||||
const DefaultRegexParserUtilities: RegexParserUtilities = {
|
||||
wrapError: wrapErrorWithAdditionalContext,
|
||||
createPosition: createPositionFromRegexFullMatch,
|
||||
createExpression: (...args) => new Expression(...args),
|
||||
createParameterCollection: createFunctionParameterCollection,
|
||||
};
|
||||
@@ -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,33 @@
|
||||
import type { IPipe } from '../IPipe';
|
||||
|
||||
export class EscapeDoubleQuotes implements IPipe {
|
||||
public readonly name: string = 'escapeDoubleQuotes';
|
||||
|
||||
public apply(raw: string): string {
|
||||
if (!raw) {
|
||||
return raw;
|
||||
}
|
||||
return raw.replaceAll('"', '"^""');
|
||||
/* eslint-disable vue/max-len */
|
||||
/*
|
||||
"^"" is the most robust and stable choice.
|
||||
Other options:
|
||||
""
|
||||
Breaks, because it is fundamentally unsupported
|
||||
""""
|
||||
Does not work with consecutive double quotes.
|
||||
E.g. `PowerShell -Command "$name='aq'; Write-Host """"Disabled `""""$name`"""""""";"`
|
||||
Works when using: `PowerShell -Command "$name='aq'; Write-Host "^""Disabled `"^""$name`"^"" "^"";"`
|
||||
\"
|
||||
May break as they are interpreted by cmd.exe as metacharacters breaking the command
|
||||
E.g. `PowerShell -Command "Write-Host 'Hello \"w&orld\"'"` does not work due to unescaped "&"
|
||||
Works when using: `PowerShell -Command "Write-Host 'Hello "^""w&orld"^""'"`
|
||||
\""
|
||||
Normalizes interior whitespace
|
||||
E.g. `PowerShell -Command "\""a& c\"".length"`, outputs 4 and discards one of two whitespaces
|
||||
Works when using "^"": `PowerShell -Command ""^""a& c"^"".length"`
|
||||
A good explanation: https://stackoverflow.com/a/31413730
|
||||
*/
|
||||
/* eslint-enable vue/max-len */
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import type { IPipe } from '../IPipe';
|
||||
|
||||
export class InlinePowerShell implements IPipe {
|
||||
public readonly name: string = 'inlinePowerShell';
|
||||
|
||||
public apply(code: string): string {
|
||||
if (!code || !hasLines(code)) {
|
||||
return code;
|
||||
}
|
||||
const processor = new Array<(data: string) => string>(...[ // for broken ESlint "indent"
|
||||
inlineComments,
|
||||
mergeLinesWithBacktick,
|
||||
mergeHereStrings,
|
||||
mergeNewLines,
|
||||
]).reduce((a, b) => (data) => b(a(data)));
|
||||
const newCode = processor(code);
|
||||
return newCode;
|
||||
}
|
||||
}
|
||||
|
||||
function hasLines(text: string) {
|
||||
return text.includes('\n') || text.includes('\r');
|
||||
}
|
||||
|
||||
/*
|
||||
Line comments using "#" are replaced with inline comment syntax <# comment.. #>
|
||||
Otherwise single # comments out rest of the code
|
||||
*/
|
||||
function inlineComments(code: string): string {
|
||||
const makeInlineComment = (comment: string) => {
|
||||
const value = comment.trim();
|
||||
if (!value) {
|
||||
return '<##>';
|
||||
}
|
||||
return `<# ${value} #>`;
|
||||
};
|
||||
return code.replaceAll(/<#.*?#>|#(.*)/g, (match, captureComment) => {
|
||||
if (captureComment === undefined) {
|
||||
return match;
|
||||
}
|
||||
return makeInlineComment(captureComment);
|
||||
});
|
||||
/*
|
||||
Other alternatives considered:
|
||||
--------------------------
|
||||
/#(?<!<#)(?![<>])(.*)$/gm
|
||||
-------------------------
|
||||
✅ Simple, yet matches and captures only what's necessary
|
||||
❌ Fails to match some cases
|
||||
❌ `Write-Host "hi" # Comment ending line inline comment but not one #>`
|
||||
❌ `Write-Host "hi" <#Comment starting like inline comment start but not one`
|
||||
❌ `Write-Host "hi" #>Comment starting like inline comment end but not one`
|
||||
❌ Uses lookbehind
|
||||
Safari does not yet support lookbehind and syntax, leading application to not
|
||||
load and throw "Invalid regular expression: invalid group specifier name"
|
||||
https://caniuse.com/js-regexp-lookbehind
|
||||
⏩ Usage
|
||||
return code.replaceAll(/#(?<!<#)(?![<>])(.*)$/gm, (match, captureComment) => {
|
||||
return makeInlineComment(captureComment)
|
||||
});
|
||||
----------------
|
||||
/<#.*?#>|#(.*)/g
|
||||
----------------
|
||||
✅ Simple yet affective
|
||||
❌ Matches all comments, but only captures dash comments
|
||||
❌ Fails to match some cases
|
||||
❌ `Write-Host "hi" # Comment ending line inline comment but not one #>`
|
||||
❌ `Write-Host "hi" <#Comment starting like inline comment start but not one`
|
||||
⏩ Usage
|
||||
return code.replaceAll(/<#.*?#>|#(.*)/g, (match, captureComment) => {
|
||||
if (captureComment === undefined) {
|
||||
return match;
|
||||
}
|
||||
return makeInlineComment(captureComment);
|
||||
});
|
||||
------------------------------------
|
||||
/(^(?:<#.*?#>|[^#])*)(?:(#)(.*))?/gm
|
||||
------------------------------------
|
||||
✅ Covers all cases
|
||||
❌ Matches every line, three capture groups are used to build result
|
||||
⏩ Usage
|
||||
return code.replaceAll(/(^(?:<#.*?#>|[^#])*)(?:(#)(.*))?/gm,
|
||||
(match, captureLeft, captureDash, captureComment) => {
|
||||
if (!captureDash) {
|
||||
return match;
|
||||
}
|
||||
return captureLeft + makeInlineComment(captureComment);
|
||||
});
|
||||
*/
|
||||
}
|
||||
|
||||
function getLines(code: string): string[] {
|
||||
return (code?.split(/\r\n|\r|\n/) || []);
|
||||
}
|
||||
|
||||
/*
|
||||
Merges inline here-strings to a single lined string with Windows line terminator (\r\n)
|
||||
https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_quoting_rules?view=powershell-7.4#here-strings
|
||||
*/
|
||||
function mergeHereStrings(code: string) {
|
||||
const regex = /@(['"])\s*(?:\r\n|\r|\n)((.|\n|\r)+?)(\r\n|\r|\n)\1@/g;
|
||||
return code.replaceAll(regex, (_$, quotes, scope) => {
|
||||
const newString = getHereStringHandler(quotes);
|
||||
const escaped = scope.replaceAll(quotes, newString.escapedQuotes);
|
||||
const lines = getLines(escaped);
|
||||
const inlined = lines.join(newString.separator);
|
||||
const quoted = `${newString.quotesAround}${inlined}${newString.quotesAround}`;
|
||||
return quoted;
|
||||
});
|
||||
}
|
||||
interface IInlinedHereString {
|
||||
readonly quotesAround: string;
|
||||
readonly escapedQuotes: string;
|
||||
readonly separator: string;
|
||||
}
|
||||
function getHereStringHandler(quotes: string): IInlinedHereString {
|
||||
/*
|
||||
We handle @' and @" differently.
|
||||
Single quotes are interpreted literally and doubles are expandable.
|
||||
*/
|
||||
const expandableNewLine = '`r`n';
|
||||
switch (quotes) {
|
||||
case '\'':
|
||||
return {
|
||||
quotesAround: '\'',
|
||||
escapedQuotes: '\'\'',
|
||||
separator: `'+"${expandableNewLine}"+'`,
|
||||
};
|
||||
case '"':
|
||||
return {
|
||||
quotesAround: '"',
|
||||
escapedQuotes: '`"',
|
||||
separator: expandableNewLine,
|
||||
};
|
||||
default:
|
||||
throw new Error(`expected quotes: ${quotes}`);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
Input ->
|
||||
Get-Service * `
|
||||
Sort-Object StartType `
|
||||
Format-Table Name, ServiceType, Status -AutoSize
|
||||
Output ->
|
||||
Get-Service * | Sort-Object StartType | Format-Table -AutoSize
|
||||
*/
|
||||
function mergeLinesWithBacktick(code: string) {
|
||||
/*
|
||||
The regex actually wraps any whitespace character after backtick and before newline
|
||||
However, this is not always the case for PowerShell.
|
||||
I see two behaviors:
|
||||
1. If inside string, it's accepted (inside " or ')
|
||||
2. If part of a command, PowerShell throws "An empty pipe element is not allowed"
|
||||
However we don't need to be so robust and handle this complexity (yet), so for easier regex
|
||||
we wrap it anyway
|
||||
*/
|
||||
return code.replaceAll(/ +`\s*(?:\r\n|\r|\n)\s*/g, ' ');
|
||||
}
|
||||
|
||||
function mergeNewLines(code: string) {
|
||||
return getLines(code)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0)
|
||||
.join('; ');
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { InlinePowerShell } from './PipeDefinitions/InlinePowerShell';
|
||||
import { EscapeDoubleQuotes } from './PipeDefinitions/EscapeDoubleQuotes';
|
||||
import type { IPipe } from './IPipe';
|
||||
|
||||
const RegisteredPipes = [
|
||||
new EscapeDoubleQuotes(),
|
||||
new InlinePowerShell(),
|
||||
];
|
||||
|
||||
export interface IPipeFactory {
|
||||
get(pipeName: string): IPipe;
|
||||
}
|
||||
|
||||
export class PipeFactory implements IPipeFactory {
|
||||
private readonly pipes = new Map<string, IPipe>();
|
||||
|
||||
constructor(pipes: readonly IPipe[] = RegisteredPipes) {
|
||||
for (const pipe of pipes) {
|
||||
this.registerPipe(pipe);
|
||||
}
|
||||
}
|
||||
|
||||
public get(pipeName: string): IPipe {
|
||||
validatePipeName(pipeName);
|
||||
const pipe = this.pipes.get(pipeName);
|
||||
if (!pipe) {
|
||||
throw new Error(`Unknown pipe: "${pipeName}"`);
|
||||
}
|
||||
return pipe;
|
||||
}
|
||||
|
||||
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 { type IPipeFactory, PipeFactory } from './PipeFactory';
|
||||
import type { 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));
|
||||
return pipes.reduce((previousValue, pipe) => {
|
||||
return pipe.apply(previousValue);
|
||||
}, 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('missing value'); }
|
||||
if (!pipeline) { throw new Error('missing pipeline'); }
|
||||
if (!pipeline.trimStart().startsWith('|')) {
|
||||
throw new Error('pipeline does not start with pipe');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { FunctionParameter } from '@/application/Parser/Executable/Script/Compiler/Function/Parameter/FunctionParameter';
|
||||
import { RegexParser, type PrimitiveExpression } from '../Parser/Regex/RegexParser';
|
||||
import { ExpressionRegexBuilder } from '../Parser/Regex/ExpressionRegexBuilder';
|
||||
|
||||
export class ParameterSubstitutionParser extends RegexParser {
|
||||
protected readonly regex = new ExpressionRegexBuilder()
|
||||
.expectExpressionStart()
|
||||
.expectCharacters('$')
|
||||
.captureUntilWhitespaceOrPipe() // First capture: Parameter name
|
||||
.expectOptionalWhitespaces()
|
||||
.captureOptionalPipeline() // Second capture: Pipeline
|
||||
.expectExpressionEnd()
|
||||
.buildRegExp();
|
||||
|
||||
protected buildExpression(match: RegExpMatchArray): PrimitiveExpression {
|
||||
const parameterName = match[1];
|
||||
const pipeline = match[2];
|
||||
return {
|
||||
parameters: [new FunctionParameter(parameterName, false)],
|
||||
evaluator: (context) => {
|
||||
const { argumentValue } = context.args.getArgument(parameterName);
|
||||
if (!pipeline) {
|
||||
return argumentValue;
|
||||
}
|
||||
return context.pipelineCompiler.compile(argumentValue, pipeline);
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
// eslint-disable-next-line max-classes-per-file
|
||||
import type { IExpressionParser } from '@/application/Parser/Executable/Script/Compiler/Expressions/Parser/IExpressionParser';
|
||||
import { FunctionParameterCollection } from '@/application/Parser/Executable/Script/Compiler/Function/Parameter/FunctionParameterCollection';
|
||||
import { FunctionParameter } from '@/application/Parser/Executable/Script/Compiler/Function/Parameter/FunctionParameter';
|
||||
import { ExpressionPosition } from '../Expression/ExpressionPosition';
|
||||
import { ExpressionRegexBuilder } from '../Parser/Regex/ExpressionRegexBuilder';
|
||||
import { createPositionFromRegexFullMatch } from '../Expression/ExpressionPositionFactory';
|
||||
import type { IExpression } from '../Expression/IExpression';
|
||||
|
||||
export class WithParser implements IExpressionParser {
|
||||
public findExpressions(code: string): IExpression[] {
|
||||
if (!code) {
|
||||
throw new Error('missing code');
|
||||
}
|
||||
return parseWithExpressions(code);
|
||||
}
|
||||
}
|
||||
|
||||
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: createPositionFromRegexFullMatch(match),
|
||||
});
|
||||
}
|
||||
for (const match of input.matchAll(WithStatementEndRegEx)) {
|
||||
expressions.push({
|
||||
type: WithStatementType.End,
|
||||
position: createPositionFromRegexFullMatch(match),
|
||||
});
|
||||
}
|
||||
for (const match of input.matchAll(ContextVariableWithPipelineRegEx)) {
|
||||
expressions.push({
|
||||
type: WithStatementType.ContextVariable,
|
||||
position: createPositionFromRegexFullMatch(match),
|
||||
pipeline: match[1],
|
||||
});
|
||||
}
|
||||
return expressions;
|
||||
}
|
||||
|
||||
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,
|
||||
position,
|
||||
evaluate: (context) => {
|
||||
const argumentValue = context.args.hasArgument(this.parameterName)
|
||||
? context.args.getArgument(this.parameterName).argumentValue
|
||||
: undefined;
|
||||
if (!argumentValue) {
|
||||
return '';
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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): never => {
|
||||
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: {
|
||||
const builder = builders.pop();
|
||||
if (!builder) {
|
||||
throwWithContext('Redundant `end` statement, missing `with`?');
|
||||
break;
|
||||
}
|
||||
expressions.push(builder.buildExpression(statement.position, input));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (builders.length > 0) {
|
||||
throwWithContext('Missing `end` statement, forgot `{{ end }}?');
|
||||
}
|
||||
return expressions;
|
||||
}
|
||||
|
||||
const ContextVariableWithPipelineRegEx = new ExpressionRegexBuilder()
|
||||
// {{ . | pipeName }}
|
||||
.expectExpressionStart()
|
||||
.expectCharacters('.')
|
||||
.expectOptionalWhitespaces()
|
||||
.captureOptionalPipeline() // First capture: pipeline
|
||||
.expectExpressionEnd()
|
||||
.buildRegExp();
|
||||
|
||||
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();
|
||||
@@ -0,0 +1,14 @@
|
||||
import { ensureValidParameterName } from '../../Shared/ParameterNameValidator';
|
||||
import type { IFunctionCallArgument } from './IFunctionCallArgument';
|
||||
|
||||
export class FunctionCallArgument implements IFunctionCallArgument {
|
||||
constructor(
|
||||
public readonly parameterName: string,
|
||||
public readonly argumentValue: string,
|
||||
) {
|
||||
ensureValidParameterName(parameterName);
|
||||
if (!argumentValue) {
|
||||
throw new Error(`Missing argument value for the parameter "${parameterName}".`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { IFunctionCallArgument } from './IFunctionCallArgument';
|
||||
import type { IFunctionCallArgumentCollection } from './IFunctionCallArgumentCollection';
|
||||
|
||||
export class FunctionCallArgumentCollection implements IFunctionCallArgumentCollection {
|
||||
private readonly arguments = new Map<string, IFunctionCallArgument>();
|
||||
|
||||
public addArgument(argument: IFunctionCallArgument): void {
|
||||
if (this.hasArgument(argument.parameterName)) {
|
||||
throw new Error(`argument value for parameter ${argument.parameterName} is already provided`);
|
||||
}
|
||||
this.arguments.set(argument.parameterName, argument);
|
||||
}
|
||||
|
||||
public getAllParameterNames(): string[] {
|
||||
return Array.from(this.arguments.keys());
|
||||
}
|
||||
|
||||
public hasArgument(parameterName: string): boolean {
|
||||
if (!parameterName) {
|
||||
throw new Error('missing parameter name');
|
||||
}
|
||||
return this.arguments.has(parameterName);
|
||||
}
|
||||
|
||||
public getArgument(parameterName: string): IFunctionCallArgument {
|
||||
if (!parameterName) {
|
||||
throw new Error('missing parameter name');
|
||||
}
|
||||
const arg = this.arguments.get(parameterName);
|
||||
if (!arg) {
|
||||
throw new Error(`parameter does not exist: ${parameterName}`);
|
||||
}
|
||||
return arg;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface IFunctionCallArgument {
|
||||
readonly parameterName: string;
|
||||
readonly argumentValue: string;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { IFunctionCallArgument } from './IFunctionCallArgument';
|
||||
|
||||
export interface IReadOnlyFunctionCallArgumentCollection {
|
||||
getArgument(parameterName: string): IFunctionCallArgument;
|
||||
getAllParameterNames(): string[];
|
||||
hasArgument(parameterName: string): boolean;
|
||||
}
|
||||
|
||||
export interface IFunctionCallArgumentCollection extends IReadOnlyFunctionCallArgumentCollection {
|
||||
addArgument(argument: IFunctionCallArgument): void;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { CompiledCode } from '../CompiledCode';
|
||||
|
||||
export interface CodeSegmentMerger {
|
||||
mergeCodeParts(codeSegments: readonly CompiledCode[]): CompiledCode;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { CompiledCode } from '../CompiledCode';
|
||||
import type { CodeSegmentMerger } from './CodeSegmentMerger';
|
||||
|
||||
export class NewlineCodeSegmentMerger implements CodeSegmentMerger {
|
||||
public mergeCodeParts(codeSegments: readonly CompiledCode[]): CompiledCode {
|
||||
if (!codeSegments.length) {
|
||||
throw new Error('missing segments');
|
||||
}
|
||||
return {
|
||||
code: joinCodeParts(codeSegments.map((f) => f.code)),
|
||||
revertCode: joinCodeParts(
|
||||
codeSegments
|
||||
.map((f) => f.revertCode)
|
||||
.filter((code): code is string => Boolean(code)),
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function joinCodeParts(codeSegments: readonly string[]): string {
|
||||
return codeSegments
|
||||
.filter((segment) => segment.length > 0)
|
||||
.join('\n');
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface CompiledCode {
|
||||
readonly code: string;
|
||||
readonly revertCode?: string;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { ISharedFunctionCollection } from '@/application/Parser/Executable/Script/Compiler/Function/ISharedFunctionCollection';
|
||||
import type { FunctionCall } from '../FunctionCall';
|
||||
import type { SingleCallCompiler } from './SingleCall/SingleCallCompiler';
|
||||
|
||||
export interface FunctionCallCompilationContext {
|
||||
readonly allFunctions: ISharedFunctionCollection;
|
||||
readonly rootCallSequence: readonly FunctionCall[];
|
||||
readonly singleCallCompiler: SingleCallCompiler;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { ISharedFunctionCollection } from '@/application/Parser/Executable/Script/Compiler/Function/ISharedFunctionCollection';
|
||||
import type { CompiledCode } from './CompiledCode';
|
||||
import type { FunctionCall } from '../FunctionCall';
|
||||
|
||||
export interface FunctionCallCompiler {
|
||||
compileFunctionCalls(
|
||||
calls: readonly FunctionCall[],
|
||||
functions: ISharedFunctionCollection,
|
||||
): CompiledCode;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { FunctionCall } from '@/application/Parser/Executable/Script/Compiler/Function/Call/FunctionCall';
|
||||
import { NewlineCodeSegmentMerger } from './CodeSegmentJoin/NewlineCodeSegmentMerger';
|
||||
import { AdaptiveFunctionCallCompiler } from './SingleCall/AdaptiveFunctionCallCompiler';
|
||||
import type { ISharedFunctionCollection } from '../../ISharedFunctionCollection';
|
||||
import type { FunctionCallCompiler } from './FunctionCallCompiler';
|
||||
import type { CompiledCode } from './CompiledCode';
|
||||
import type { FunctionCallCompilationContext } from './FunctionCallCompilationContext';
|
||||
import type { SingleCallCompiler } from './SingleCall/SingleCallCompiler';
|
||||
import type { CodeSegmentMerger } from './CodeSegmentJoin/CodeSegmentMerger';
|
||||
|
||||
export class FunctionCallSequenceCompiler implements FunctionCallCompiler {
|
||||
public static readonly instance: FunctionCallCompiler = new FunctionCallSequenceCompiler();
|
||||
|
||||
/* The constructor is protected to enforce the singleton pattern. */
|
||||
protected constructor(
|
||||
private readonly singleCallCompiler: SingleCallCompiler = new AdaptiveFunctionCallCompiler(),
|
||||
private readonly codeSegmentMerger: CodeSegmentMerger = new NewlineCodeSegmentMerger(),
|
||||
) { }
|
||||
|
||||
public compileFunctionCalls(
|
||||
calls: readonly FunctionCall[],
|
||||
functions: ISharedFunctionCollection,
|
||||
): CompiledCode {
|
||||
if (!calls.length) { throw new Error('missing calls'); }
|
||||
const context: FunctionCallCompilationContext = {
|
||||
allFunctions: functions,
|
||||
rootCallSequence: calls,
|
||||
singleCallCompiler: this.singleCallCompiler,
|
||||
};
|
||||
const codeSegments = context.rootCallSequence
|
||||
.flatMap((call) => this.singleCallCompiler.compileSingleCall(call, context));
|
||||
return this.codeSegmentMerger.mergeCodeParts(codeSegments);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { InlineFunctionCallCompiler } from './Strategies/InlineFunctionCallCompiler';
|
||||
import { NestedFunctionCallCompiler } from './Strategies/NestedFunctionCallCompiler';
|
||||
import type { FunctionCall } from '../../FunctionCall';
|
||||
import type { CompiledCode } from '../CompiledCode';
|
||||
import type { FunctionCallCompilationContext } from '../FunctionCallCompilationContext';
|
||||
import type { IReadOnlyFunctionCallArgumentCollection } from '../../Argument/IFunctionCallArgumentCollection';
|
||||
import type { ISharedFunction } from '../../../ISharedFunction';
|
||||
import type { SingleCallCompiler } from './SingleCallCompiler';
|
||||
import type { SingleCallCompilerStrategy } from './SingleCallCompilerStrategy';
|
||||
|
||||
export class AdaptiveFunctionCallCompiler implements SingleCallCompiler {
|
||||
public constructor(
|
||||
private readonly strategies: SingleCallCompilerStrategy[] = [
|
||||
new InlineFunctionCallCompiler(),
|
||||
new NestedFunctionCallCompiler(),
|
||||
],
|
||||
) {
|
||||
}
|
||||
|
||||
public compileSingleCall(
|
||||
call: FunctionCall,
|
||||
context: FunctionCallCompilationContext,
|
||||
): CompiledCode[] {
|
||||
const func = context.allFunctions.getFunctionByName(call.functionName);
|
||||
ensureThatCallArgumentsExistInParameterDefinition(func, call.args);
|
||||
const strategy = this.findStrategy(func);
|
||||
return strategy.compileFunction(func, call, context);
|
||||
}
|
||||
|
||||
private findStrategy(func: ISharedFunction): SingleCallCompilerStrategy {
|
||||
const strategies = this.strategies.filter((strategy) => strategy.canCompile(func));
|
||||
if (strategies.length > 1) {
|
||||
throw new Error('Multiple strategies found to compile the function call.');
|
||||
}
|
||||
if (strategies.length === 0) {
|
||||
throw new Error('No strategies found to compile the function call.');
|
||||
}
|
||||
return strategies[0];
|
||||
}
|
||||
}
|
||||
|
||||
function ensureThatCallArgumentsExistInParameterDefinition(
|
||||
func: ISharedFunction,
|
||||
callArguments: IReadOnlyFunctionCallArgumentCollection,
|
||||
): void {
|
||||
const callArgumentNames = callArguments.getAllParameterNames();
|
||||
const functionParameterNames = func.parameters.all.map((param) => param.name) || [];
|
||||
const unexpectedParameters = findUnexpectedParameters(callArgumentNames, functionParameterNames);
|
||||
throwIfUnexpectedParametersExist(func.name, unexpectedParameters, functionParameterNames);
|
||||
}
|
||||
|
||||
function findUnexpectedParameters(
|
||||
callArgumentNames: string[],
|
||||
functionParameterNames: string[],
|
||||
): string[] {
|
||||
if (!callArgumentNames.length && !functionParameterNames.length) {
|
||||
return [];
|
||||
}
|
||||
return callArgumentNames
|
||||
.filter((callParam) => !functionParameterNames.includes(callParam));
|
||||
}
|
||||
|
||||
function throwIfUnexpectedParametersExist(
|
||||
functionName: string,
|
||||
unexpectedParameters: string[],
|
||||
expectedParameters: string[],
|
||||
) {
|
||||
if (!unexpectedParameters.length) {
|
||||
return;
|
||||
}
|
||||
throw new Error(
|
||||
// eslint-disable-next-line prefer-template
|
||||
`Function "${functionName}" has unexpected parameter(s) provided: `
|
||||
+ `"${unexpectedParameters.join('", "')}"`
|
||||
+ '.\nExpected parameter(s): '
|
||||
+ (expectedParameters.length ? `"${expectedParameters.join('", "')}".` : 'none'),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { FunctionCallCompilationContext } from '../FunctionCallCompilationContext';
|
||||
import type { FunctionCall } from '../../FunctionCall';
|
||||
import type { CompiledCode } from '../CompiledCode';
|
||||
|
||||
export interface SingleCallCompiler {
|
||||
compileSingleCall(
|
||||
call: FunctionCall,
|
||||
context: FunctionCallCompilationContext,
|
||||
): CompiledCode[];
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { ISharedFunction } from '@/application/Parser/Executable/Script/Compiler/Function/ISharedFunction';
|
||||
import type { FunctionCall } from '@/application/Parser/Executable/Script/Compiler/Function/Call/FunctionCall';
|
||||
import type { CompiledCode } from '../CompiledCode';
|
||||
import type { FunctionCallCompilationContext } from '../FunctionCallCompilationContext';
|
||||
|
||||
export interface SingleCallCompilerStrategy {
|
||||
canCompile(func: ISharedFunction): boolean;
|
||||
compileFunction(
|
||||
calledFunction: ISharedFunction,
|
||||
callToFunction: FunctionCall,
|
||||
context: FunctionCallCompilationContext,
|
||||
): CompiledCode[],
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { FunctionCall } from '@/application/Parser/Executable/Script/Compiler/Function/Call/FunctionCall';
|
||||
import type { FunctionCallCompilationContext } from '@/application/Parser/Executable/Script/Compiler/Function/Call/Compiler/FunctionCallCompilationContext';
|
||||
|
||||
export interface ArgumentCompiler {
|
||||
createCompiledNestedCall(
|
||||
nestedFunctionCall: FunctionCall,
|
||||
parentFunctionCall: FunctionCall,
|
||||
context: FunctionCallCompilationContext,
|
||||
): FunctionCall;
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import type { IReadOnlyFunctionCallArgumentCollection } from '@/application/Parser/Executable/Script/Compiler/Function/Call/Argument/IFunctionCallArgumentCollection';
|
||||
import { FunctionCallArgument } from '@/application/Parser/Executable/Script/Compiler/Function/Call/Argument/FunctionCallArgument';
|
||||
import { FunctionCallArgumentCollection } from '@/application/Parser/Executable/Script/Compiler/Function/Call/Argument/FunctionCallArgumentCollection';
|
||||
import { ExpressionsCompiler } from '@/application/Parser/Executable/Script/Compiler/Expressions/ExpressionsCompiler';
|
||||
import type { IExpressionsCompiler } from '@/application/Parser/Executable/Script/Compiler/Expressions/IExpressionsCompiler';
|
||||
import type { FunctionCall } from '@/application/Parser/Executable/Script/Compiler/Function/Call/FunctionCall';
|
||||
import type { FunctionCallCompilationContext } from '@/application/Parser/Executable/Script/Compiler/Function/Call/Compiler/FunctionCallCompilationContext';
|
||||
import { ParsedFunctionCall } from '@/application/Parser/Executable/Script/Compiler/Function/Call/ParsedFunctionCall';
|
||||
import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/ContextualError';
|
||||
import type { ArgumentCompiler } from './ArgumentCompiler';
|
||||
|
||||
export class NestedFunctionArgumentCompiler implements ArgumentCompiler {
|
||||
constructor(
|
||||
private readonly expressionsCompiler: IExpressionsCompiler = new ExpressionsCompiler(),
|
||||
private readonly wrapError: ErrorWithContextWrapper
|
||||
= wrapErrorWithAdditionalContext,
|
||||
) { }
|
||||
|
||||
public createCompiledNestedCall(
|
||||
nestedFunction: FunctionCall,
|
||||
parentFunction: FunctionCall,
|
||||
context: FunctionCallCompilationContext,
|
||||
): FunctionCall {
|
||||
const compiledArgs = compileNestedFunctionArguments(
|
||||
nestedFunction,
|
||||
parentFunction.args,
|
||||
context,
|
||||
{
|
||||
expressionsCompiler: this.expressionsCompiler,
|
||||
wrapError: this.wrapError,
|
||||
},
|
||||
);
|
||||
const compiledCall = new ParsedFunctionCall(nestedFunction.functionName, compiledArgs);
|
||||
return compiledCall;
|
||||
}
|
||||
}
|
||||
|
||||
interface ArgumentCompilationUtilities {
|
||||
readonly expressionsCompiler: IExpressionsCompiler,
|
||||
readonly wrapError: ErrorWithContextWrapper;
|
||||
}
|
||||
|
||||
function compileNestedFunctionArguments(
|
||||
nestedFunction: FunctionCall,
|
||||
parentFunctionArgs: IReadOnlyFunctionCallArgumentCollection,
|
||||
context: FunctionCallCompilationContext,
|
||||
utilities: ArgumentCompilationUtilities,
|
||||
): IReadOnlyFunctionCallArgumentCollection {
|
||||
const requiredParameterNames = context
|
||||
.allFunctions
|
||||
.getRequiredParameterNames(nestedFunction.functionName);
|
||||
const compiledArguments = nestedFunction.args
|
||||
.getAllParameterNames()
|
||||
// Compile each argument value
|
||||
.map((paramName) => ({
|
||||
parameterName: paramName,
|
||||
compiledArgumentValue: compileArgument(
|
||||
paramName,
|
||||
nestedFunction,
|
||||
parentFunctionArgs,
|
||||
utilities,
|
||||
),
|
||||
}))
|
||||
// Filter out arguments with absent values
|
||||
.filter(({
|
||||
parameterName,
|
||||
compiledArgumentValue,
|
||||
}) => isValidNonAbsentArgumentValue(
|
||||
parameterName,
|
||||
compiledArgumentValue,
|
||||
requiredParameterNames,
|
||||
))
|
||||
/*
|
||||
Create argument object with non-absent values.
|
||||
This is done after eliminating absent values because otherwise creating argument object
|
||||
with absent values throws error.
|
||||
*/
|
||||
.map(({
|
||||
parameterName,
|
||||
compiledArgumentValue,
|
||||
}) => new FunctionCallArgument(parameterName, compiledArgumentValue));
|
||||
return buildArgumentCollectionFromArguments(compiledArguments);
|
||||
}
|
||||
|
||||
function isValidNonAbsentArgumentValue(
|
||||
parameterName: string,
|
||||
argumentValue: string | undefined,
|
||||
requiredParameterNames: string[],
|
||||
): boolean {
|
||||
if (argumentValue) {
|
||||
return true;
|
||||
}
|
||||
if (!requiredParameterNames.includes(parameterName)) {
|
||||
return false;
|
||||
}
|
||||
throw new Error(`Compilation resulted in empty value for required parameter: "${parameterName}"`);
|
||||
}
|
||||
|
||||
function compileArgument(
|
||||
parameterName: string,
|
||||
nestedFunction: FunctionCall,
|
||||
parentFunctionArgs: IReadOnlyFunctionCallArgumentCollection,
|
||||
utilities: ArgumentCompilationUtilities,
|
||||
): string {
|
||||
try {
|
||||
const { argumentValue: codeInArgument } = nestedFunction.args.getArgument(parameterName);
|
||||
return utilities.expressionsCompiler.compileExpressions(codeInArgument, parentFunctionArgs);
|
||||
} catch (error) {
|
||||
throw utilities.wrapError(error, `Error when compiling argument for "${parameterName}"`);
|
||||
}
|
||||
}
|
||||
|
||||
function buildArgumentCollectionFromArguments(
|
||||
args: FunctionCallArgument[],
|
||||
): FunctionCallArgumentCollection {
|
||||
return args.reduce((compiledArgs, arg) => {
|
||||
compiledArgs.addArgument(arg);
|
||||
return compiledArgs;
|
||||
}, new FunctionCallArgumentCollection());
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { ExpressionsCompiler } from '@/application/Parser/Executable/Script/Compiler/Expressions/ExpressionsCompiler';
|
||||
import type { IExpressionsCompiler } from '@/application/Parser/Executable/Script/Compiler/Expressions/IExpressionsCompiler';
|
||||
import { FunctionBodyType, type ISharedFunction } from '@/application/Parser/Executable/Script/Compiler/Function/ISharedFunction';
|
||||
import type { FunctionCall } from '@/application/Parser/Executable/Script/Compiler/Function/Call/FunctionCall';
|
||||
import type { CompiledCode } from '@/application/Parser/Executable/Script/Compiler/Function/Call/Compiler/CompiledCode';
|
||||
import type { SingleCallCompilerStrategy } from '../SingleCallCompilerStrategy';
|
||||
|
||||
export class InlineFunctionCallCompiler implements SingleCallCompilerStrategy {
|
||||
public constructor(
|
||||
private readonly expressionsCompiler: IExpressionsCompiler = new ExpressionsCompiler(),
|
||||
) {
|
||||
}
|
||||
|
||||
public canCompile(func: ISharedFunction): boolean {
|
||||
return func.body.type === FunctionBodyType.Code;
|
||||
}
|
||||
|
||||
public compileFunction(
|
||||
calledFunction: ISharedFunction,
|
||||
callToFunction: FunctionCall,
|
||||
): CompiledCode[] {
|
||||
if (calledFunction.body.type !== FunctionBodyType.Code) {
|
||||
throw new Error([
|
||||
'Unexpected function body type.',
|
||||
`\tExpected: "${FunctionBodyType[FunctionBodyType.Code]}"`,
|
||||
`\tActual: "${FunctionBodyType[calledFunction.body.type]}"`,
|
||||
'Function:',
|
||||
`\t${JSON.stringify(callToFunction)}`,
|
||||
].join('\n'));
|
||||
}
|
||||
const { code } = calledFunction.body;
|
||||
const { args } = callToFunction;
|
||||
return [
|
||||
{
|
||||
code: this.expressionsCompiler.compileExpressions(code.execute, args),
|
||||
revertCode: (() => {
|
||||
if (!code.revert) {
|
||||
return undefined;
|
||||
}
|
||||
return this.expressionsCompiler.compileExpressions(code.revert, args);
|
||||
})(),
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import {
|
||||
type CallFunctionBody, FunctionBodyType,
|
||||
type ISharedFunction,
|
||||
} from '@/application/Parser/Executable/Script/Compiler/Function/ISharedFunction';
|
||||
import type { FunctionCall } from '@/application/Parser/Executable/Script/Compiler/Function/Call/FunctionCall';
|
||||
import type { FunctionCallCompilationContext } from '@/application/Parser/Executable/Script/Compiler/Function/Call/Compiler/FunctionCallCompilationContext';
|
||||
import type { CompiledCode } from '@/application/Parser/Executable/Script/Compiler/Function/Call/Compiler/CompiledCode';
|
||||
import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/ContextualError';
|
||||
import { NestedFunctionArgumentCompiler } from './Argument/NestedFunctionArgumentCompiler';
|
||||
import type { SingleCallCompilerStrategy } from '../SingleCallCompilerStrategy';
|
||||
import type { ArgumentCompiler } from './Argument/ArgumentCompiler';
|
||||
|
||||
export class NestedFunctionCallCompiler implements SingleCallCompilerStrategy {
|
||||
public constructor(
|
||||
private readonly argumentCompiler: ArgumentCompiler
|
||||
= new NestedFunctionArgumentCompiler(),
|
||||
private readonly wrapError: ErrorWithContextWrapper
|
||||
= wrapErrorWithAdditionalContext,
|
||||
) {
|
||||
}
|
||||
|
||||
public canCompile(func: ISharedFunction): boolean {
|
||||
return func.body.type === FunctionBodyType.Calls;
|
||||
}
|
||||
|
||||
public compileFunction(
|
||||
calledFunction: ISharedFunction,
|
||||
callToFunction: FunctionCall,
|
||||
context: FunctionCallCompilationContext,
|
||||
): CompiledCode[] {
|
||||
const nestedCalls = (calledFunction.body as CallFunctionBody).calls;
|
||||
return nestedCalls.map((nestedCall) => {
|
||||
try {
|
||||
const compiledParentCall = this.argumentCompiler
|
||||
.createCompiledNestedCall(nestedCall, callToFunction, context);
|
||||
const compiledNestedCall = context.singleCallCompiler
|
||||
.compileSingleCall(compiledParentCall, context);
|
||||
return compiledNestedCall;
|
||||
} catch (error) {
|
||||
throw this.wrapError(
|
||||
error,
|
||||
`Failed to call '${nestedCall.functionName}' (callee function) from '${callToFunction.functionName}' (caller function).`,
|
||||
);
|
||||
}
|
||||
}).flat();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { IReadOnlyFunctionCallArgumentCollection } from './Argument/IFunctionCallArgumentCollection';
|
||||
|
||||
export interface FunctionCall {
|
||||
readonly functionName: string;
|
||||
readonly args: IReadOnlyFunctionCallArgumentCollection;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { FunctionCallData, FunctionCallsData, FunctionCallParametersData } from '@/application/collections/';
|
||||
import { isArray, isPlainObject } from '@/TypeHelpers';
|
||||
import { FunctionCallArgumentCollection } from './Argument/FunctionCallArgumentCollection';
|
||||
import { FunctionCallArgument } from './Argument/FunctionCallArgument';
|
||||
import { ParsedFunctionCall } from './ParsedFunctionCall';
|
||||
import type { FunctionCall } from './FunctionCall';
|
||||
|
||||
export function parseFunctionCalls(calls: FunctionCallsData): FunctionCall[] {
|
||||
const sequence = getCallSequence(calls);
|
||||
return sequence.map((call) => parseFunctionCall(call));
|
||||
}
|
||||
|
||||
function getCallSequence(calls: FunctionCallsData): FunctionCallData[] {
|
||||
if (!isPlainObject(calls) && !isArray(calls)) {
|
||||
throw new Error('called function(s) must be an object or array');
|
||||
}
|
||||
if (isArray(calls)) {
|
||||
return calls as FunctionCallData[];
|
||||
}
|
||||
const singleCall = calls as FunctionCallData;
|
||||
return [singleCall];
|
||||
}
|
||||
|
||||
function parseFunctionCall(call: FunctionCallData): FunctionCall {
|
||||
const callArgs = parseArgs(call.parameters);
|
||||
return new ParsedFunctionCall(call.function, callArgs);
|
||||
}
|
||||
|
||||
function parseArgs(
|
||||
parameters: FunctionCallParametersData | undefined,
|
||||
): FunctionCallArgumentCollection {
|
||||
const parametersMap = parameters ?? {};
|
||||
return Object.keys(parametersMap)
|
||||
.map((parameterName) => new FunctionCallArgument(parameterName, parametersMap[parameterName]))
|
||||
.reduce((args, arg) => {
|
||||
args.addArgument(arg);
|
||||
return args;
|
||||
}, new FunctionCallArgumentCollection());
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { FunctionCall } from './FunctionCall';
|
||||
import type { IReadOnlyFunctionCallArgumentCollection } from './Argument/IFunctionCallArgumentCollection';
|
||||
|
||||
export class ParsedFunctionCall implements FunctionCall {
|
||||
constructor(
|
||||
public readonly functionName: string,
|
||||
public readonly args: IReadOnlyFunctionCallArgumentCollection,
|
||||
) {
|
||||
if (!functionName) {
|
||||
throw new Error('missing function name in function call');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { IReadOnlyFunctionParameterCollection } from './Parameter/IFunctionParameterCollection';
|
||||
import type { FunctionCall } from './Call/FunctionCall';
|
||||
|
||||
export interface ISharedFunction {
|
||||
readonly name: string;
|
||||
readonly parameters: IReadOnlyFunctionParameterCollection;
|
||||
readonly body: SharedFunctionBody;
|
||||
}
|
||||
|
||||
export interface CallFunctionBody {
|
||||
readonly type: FunctionBodyType.Calls,
|
||||
readonly calls: readonly FunctionCall[],
|
||||
}
|
||||
|
||||
export interface CodeFunctionBody {
|
||||
readonly type: FunctionBodyType.Code;
|
||||
readonly code: IFunctionCode,
|
||||
}
|
||||
|
||||
export type SharedFunctionBody = CallFunctionBody | CodeFunctionBody;
|
||||
|
||||
export enum FunctionBodyType {
|
||||
Code,
|
||||
Calls,
|
||||
}
|
||||
|
||||
export interface IFunctionCode {
|
||||
readonly execute: string;
|
||||
readonly revert?: string;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { ISharedFunction } from './ISharedFunction';
|
||||
|
||||
export interface ISharedFunctionCollection {
|
||||
getFunctionByName(name: string): ISharedFunction;
|
||||
getRequiredParameterNames(functionName: string): string[];
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { FunctionData } from '@/application/collections/';
|
||||
import type { ILanguageSyntax } from '@/application/Parser/Executable/Script/Validation/Syntax/ILanguageSyntax';
|
||||
import type { ISharedFunctionCollection } from './ISharedFunctionCollection';
|
||||
|
||||
export interface ISharedFunctionsParser {
|
||||
parseFunctions(
|
||||
functions: readonly FunctionData[],
|
||||
syntax: ILanguageSyntax,
|
||||
): ISharedFunctionCollection;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ensureValidParameterName } from '../Shared/ParameterNameValidator';
|
||||
import type { IFunctionParameter } from './IFunctionParameter';
|
||||
|
||||
export class FunctionParameter implements IFunctionParameter {
|
||||
constructor(
|
||||
public readonly name: string,
|
||||
public readonly isOptional: boolean,
|
||||
) {
|
||||
ensureValidParameterName(name);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { IFunctionParameterCollection } from './IFunctionParameterCollection';
|
||||
import type { IFunctionParameter } from './IFunctionParameter';
|
||||
|
||||
export class FunctionParameterCollection implements IFunctionParameterCollection {
|
||||
private parameters = new Array<IFunctionParameter>();
|
||||
|
||||
public get all(): readonly IFunctionParameter[] {
|
||||
return this.parameters;
|
||||
}
|
||||
|
||||
public addParameter(parameter: IFunctionParameter) {
|
||||
this.ensureValidParameter(parameter);
|
||||
this.parameters.push(parameter);
|
||||
}
|
||||
|
||||
private includesName(name: string) {
|
||||
return this.parameters.find((existingParameter) => existingParameter.name === name);
|
||||
}
|
||||
|
||||
private ensureValidParameter(parameter: IFunctionParameter) {
|
||||
if (this.includesName(parameter.name)) {
|
||||
throw new Error(`duplicate parameter name: "${parameter.name}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { FunctionParameterCollection } from './FunctionParameterCollection';
|
||||
import type { IFunctionParameterCollection } from './IFunctionParameterCollection';
|
||||
|
||||
export interface FunctionParameterCollectionFactory {
|
||||
(
|
||||
...args: ConstructorParameters<typeof FunctionParameterCollection>
|
||||
): IFunctionParameterCollection;
|
||||
}
|
||||
|
||||
export const createFunctionParameterCollection: FunctionParameterCollectionFactory = (...args) => {
|
||||
return new FunctionParameterCollection(...args);
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface IFunctionParameter {
|
||||
readonly name: string;
|
||||
readonly isOptional: boolean;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { IFunctionParameter } from './IFunctionParameter';
|
||||
|
||||
export interface IReadOnlyFunctionParameterCollection {
|
||||
readonly all: readonly IFunctionParameter[];
|
||||
}
|
||||
|
||||
export interface IFunctionParameterCollection extends IReadOnlyFunctionParameterCollection {
|
||||
addParameter(parameter: IFunctionParameter): void;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export function ensureValidParameterName(parameterName: string) {
|
||||
if (!parameterName) {
|
||||
throw new Error('missing parameter name');
|
||||
}
|
||||
if (!parameterName.match(/^[0-9a-zA-Z]+$/)) {
|
||||
throw new Error(`parameter name must be alphanumeric but it was "${parameterName}"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import {
|
||||
FunctionBodyType, type IFunctionCode, type ISharedFunction, type SharedFunctionBody,
|
||||
} from './ISharedFunction';
|
||||
import type { FunctionCall } from './Call/FunctionCall';
|
||||
import type { IReadOnlyFunctionParameterCollection } from './Parameter/IFunctionParameterCollection';
|
||||
|
||||
export function createCallerFunction(
|
||||
name: string,
|
||||
parameters: IReadOnlyFunctionParameterCollection,
|
||||
callSequence: readonly FunctionCall[],
|
||||
): ISharedFunction {
|
||||
if (!callSequence.length) {
|
||||
throw new Error(`missing call sequence in function "${name}"`);
|
||||
}
|
||||
return new SharedFunction(name, parameters, callSequence, FunctionBodyType.Calls);
|
||||
}
|
||||
|
||||
export function createFunctionWithInlineCode(
|
||||
name: string,
|
||||
parameters: IReadOnlyFunctionParameterCollection,
|
||||
code: string,
|
||||
revertCode?: string,
|
||||
): ISharedFunction {
|
||||
if (!code) {
|
||||
throw new Error(`undefined code in function "${name}"`);
|
||||
}
|
||||
const content: IFunctionCode = {
|
||||
execute: code,
|
||||
revert: revertCode,
|
||||
};
|
||||
return new SharedFunction(name, parameters, content, FunctionBodyType.Code);
|
||||
}
|
||||
|
||||
class SharedFunction implements ISharedFunction {
|
||||
public readonly body: SharedFunctionBody;
|
||||
|
||||
constructor(
|
||||
public readonly name: string,
|
||||
public readonly parameters: IReadOnlyFunctionParameterCollection,
|
||||
content: IFunctionCode | readonly FunctionCall[],
|
||||
bodyType: FunctionBodyType,
|
||||
) {
|
||||
if (!name) { throw new Error('missing function name'); }
|
||||
|
||||
switch (bodyType) {
|
||||
case FunctionBodyType.Code:
|
||||
this.body = {
|
||||
type: FunctionBodyType.Code,
|
||||
code: content as IFunctionCode,
|
||||
};
|
||||
break;
|
||||
case FunctionBodyType.Calls:
|
||||
this.body = {
|
||||
type: FunctionBodyType.Calls,
|
||||
calls: content as readonly FunctionCall[],
|
||||
};
|
||||
break;
|
||||
default:
|
||||
throw new Error(`unknown body type: ${FunctionBodyType[bodyType]}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { ISharedFunction } from './ISharedFunction';
|
||||
import type { ISharedFunctionCollection } from './ISharedFunctionCollection';
|
||||
|
||||
export class SharedFunctionCollection implements ISharedFunctionCollection {
|
||||
private readonly functionsByName = new Map<string, ISharedFunction>();
|
||||
|
||||
public addFunction(func: ISharedFunction): void {
|
||||
if (this.has(func.name)) {
|
||||
throw new Error(`function with name ${func.name} already exists`);
|
||||
}
|
||||
this.functionsByName.set(func.name, func);
|
||||
}
|
||||
|
||||
public getFunctionByName(name: string): ISharedFunction {
|
||||
if (!name) { throw Error('missing function name'); }
|
||||
const func = this.functionsByName.get(name);
|
||||
if (!func) {
|
||||
throw new Error(`Called function is not defined: "${name}"`);
|
||||
}
|
||||
return func;
|
||||
}
|
||||
|
||||
public getRequiredParameterNames(functionName: string): string[] {
|
||||
return this
|
||||
.getFunctionByName(functionName)
|
||||
.parameters
|
||||
.all
|
||||
.filter((parameter) => !parameter.isOptional)
|
||||
.map((parameter) => parameter.name);
|
||||
}
|
||||
|
||||
private has(functionName: string) {
|
||||
return this.functionsByName.has(functionName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import type {
|
||||
FunctionData, CodeInstruction, CodeFunctionData, CallFunctionData,
|
||||
CallInstruction, ParameterDefinitionData,
|
||||
} from '@/application/collections/';
|
||||
import type { ILanguageSyntax } from '@/application/Parser/Executable/Script/Validation/Syntax/ILanguageSyntax';
|
||||
import { CodeValidator } from '@/application/Parser/Executable/Script/Validation/CodeValidator';
|
||||
import { NoEmptyLines } from '@/application/Parser/Executable/Script/Validation/Rules/NoEmptyLines';
|
||||
import { NoDuplicatedLines } from '@/application/Parser/Executable/Script/Validation/Rules/NoDuplicatedLines';
|
||||
import type { ICodeValidator } from '@/application/Parser/Executable/Script/Validation/ICodeValidator';
|
||||
import { isArray, isNullOrUndefined, isPlainObject } from '@/TypeHelpers';
|
||||
import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/ContextualError';
|
||||
import { createFunctionWithInlineCode, createCallerFunction } from './SharedFunction';
|
||||
import { SharedFunctionCollection } from './SharedFunctionCollection';
|
||||
import { FunctionParameter } from './Parameter/FunctionParameter';
|
||||
import { parseFunctionCalls } from './Call/FunctionCallParser';
|
||||
import { createFunctionParameterCollection, type FunctionParameterCollectionFactory } from './Parameter/FunctionParameterCollectionFactory';
|
||||
import type { ISharedFunctionCollection } from './ISharedFunctionCollection';
|
||||
import type { ISharedFunctionsParser } from './ISharedFunctionsParser';
|
||||
import type { IReadOnlyFunctionParameterCollection } from './Parameter/IFunctionParameterCollection';
|
||||
import type { ISharedFunction } from './ISharedFunction';
|
||||
|
||||
const DefaultSharedFunctionsParsingUtilities: SharedFunctionsParsingUtilities = {
|
||||
wrapError: wrapErrorWithAdditionalContext,
|
||||
createParameter: (...args) => new FunctionParameter(...args),
|
||||
codeValidator: CodeValidator.instance,
|
||||
createParameterCollection: createFunctionParameterCollection,
|
||||
};
|
||||
|
||||
export class SharedFunctionsParser implements ISharedFunctionsParser {
|
||||
public static readonly instance: ISharedFunctionsParser = new SharedFunctionsParser();
|
||||
|
||||
constructor(
|
||||
private readonly utilities = DefaultSharedFunctionsParsingUtilities,
|
||||
) { }
|
||||
|
||||
public parseFunctions(
|
||||
functions: readonly FunctionData[],
|
||||
syntax: ILanguageSyntax,
|
||||
): ISharedFunctionCollection {
|
||||
const collection = new SharedFunctionCollection();
|
||||
if (!functions.length) {
|
||||
return collection;
|
||||
}
|
||||
ensureValidFunctions(functions);
|
||||
return functions
|
||||
.map((func) => parseFunction(func, syntax, this.utilities))
|
||||
.reduce((acc, func) => {
|
||||
acc.addFunction(func);
|
||||
return acc;
|
||||
}, collection);
|
||||
}
|
||||
}
|
||||
|
||||
interface SharedFunctionsParsingUtilities {
|
||||
readonly wrapError: ErrorWithContextWrapper;
|
||||
readonly createParameter: FunctionParameterFactory;
|
||||
readonly codeValidator: ICodeValidator;
|
||||
readonly createParameterCollection: FunctionParameterCollectionFactory;
|
||||
}
|
||||
|
||||
export type FunctionParameterFactory = (
|
||||
...args: ConstructorParameters<typeof FunctionParameter>
|
||||
) => FunctionParameter;
|
||||
|
||||
function parseFunction(
|
||||
data: FunctionData,
|
||||
syntax: ILanguageSyntax,
|
||||
utilities: SharedFunctionsParsingUtilities,
|
||||
): ISharedFunction {
|
||||
const { name } = data;
|
||||
const parameters = parseParameters(data, utilities);
|
||||
if (hasCode(data)) {
|
||||
validateCode(data, syntax, utilities.codeValidator);
|
||||
return createFunctionWithInlineCode(name, parameters, data.code, data.revertCode);
|
||||
}
|
||||
// Has call
|
||||
const calls = parseFunctionCalls(data.call);
|
||||
return createCallerFunction(name, parameters, calls);
|
||||
}
|
||||
|
||||
function validateCode(
|
||||
data: CodeFunctionData,
|
||||
syntax: ILanguageSyntax,
|
||||
validator: ICodeValidator,
|
||||
): void {
|
||||
[data.code, data.revertCode]
|
||||
.filter((code): code is string => Boolean(code))
|
||||
.forEach(
|
||||
(code) => validator.throwIfInvalid(
|
||||
code,
|
||||
[new NoEmptyLines(), new NoDuplicatedLines(syntax)],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function parseParameters(
|
||||
data: FunctionData,
|
||||
utilities: SharedFunctionsParsingUtilities,
|
||||
): IReadOnlyFunctionParameterCollection {
|
||||
return (data.parameters || [])
|
||||
.map((parameter) => createFunctionParameter(
|
||||
data.name,
|
||||
parameter,
|
||||
utilities,
|
||||
))
|
||||
.reduce((parameters, parameter) => {
|
||||
parameters.addParameter(parameter);
|
||||
return parameters;
|
||||
}, utilities.createParameterCollection());
|
||||
}
|
||||
|
||||
function createFunctionParameter(
|
||||
functionName: string,
|
||||
parameterData: ParameterDefinitionData,
|
||||
utilities: SharedFunctionsParsingUtilities,
|
||||
): FunctionParameter {
|
||||
try {
|
||||
return utilities.createParameter(
|
||||
parameterData.name,
|
||||
parameterData.optional || false,
|
||||
);
|
||||
} catch (err) {
|
||||
throw utilities.wrapError(
|
||||
err,
|
||||
`Failed to create parameter: ${parameterData.name} for function "${functionName}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function hasCode(data: FunctionData): data is CodeFunctionData {
|
||||
return (data as CodeInstruction).code !== undefined;
|
||||
}
|
||||
|
||||
function hasCall(data: FunctionData): data is CallFunctionData {
|
||||
return (data as CallInstruction).call !== undefined;
|
||||
}
|
||||
|
||||
function ensureValidFunctions(functions: readonly FunctionData[]) {
|
||||
ensureNoUnnamedFunctions(functions);
|
||||
ensureNoDuplicatesInFunctionNames(functions);
|
||||
ensureEitherCallOrCodeIsDefined(functions);
|
||||
ensureNoDuplicateCode(functions);
|
||||
ensureExpectedParametersType(functions);
|
||||
}
|
||||
|
||||
function printList(list: readonly string[]): string {
|
||||
return `"${list.join('","')}"`;
|
||||
}
|
||||
|
||||
function ensureNoUnnamedFunctions(functions: readonly FunctionData[]) {
|
||||
const functionsWithoutNames = functions.filter(
|
||||
(func) => !func.name || func.name.trim().length === 0,
|
||||
);
|
||||
if (functionsWithoutNames.length) {
|
||||
const invalidFunctions = functionsWithoutNames.map((f) => JSON.stringify(f));
|
||||
throw new Error(`Some function(s) have no names:\n${invalidFunctions.join('\n')}`);
|
||||
}
|
||||
}
|
||||
|
||||
function ensureEitherCallOrCodeIsDefined(holders: readonly FunctionData[]) {
|
||||
// Ensure functions do not define both call and code
|
||||
const withBothCallAndCode = holders.filter((holder) => hasCode(holder) && hasCall(holder));
|
||||
if (withBothCallAndCode.length) {
|
||||
throw new Error(`both "code" and "call" are defined in ${printNames(withBothCallAndCode)}`);
|
||||
}
|
||||
// Ensure functions have either code or call
|
||||
const hasEitherCodeOrCall = holders.filter((holder) => !hasCode(holder) && !hasCall(holder));
|
||||
if (hasEitherCodeOrCall.length) {
|
||||
throw new Error(`neither "code" or "call" is defined in ${printNames(hasEitherCodeOrCall)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function ensureExpectedParametersType(functions: readonly FunctionData[]) {
|
||||
const hasValidParameters = (
|
||||
func: FunctionData,
|
||||
) => isNullOrUndefined(func.parameters) || isArrayOfObjects(func.parameters);
|
||||
const unexpectedFunctions = functions
|
||||
.filter((func) => !hasValidParameters(func));
|
||||
if (unexpectedFunctions.length) {
|
||||
const errorMessage = `parameters must be an array of objects in function(s) ${printNames(unexpectedFunctions)}`;
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
function isArrayOfObjects(value: unknown): boolean {
|
||||
return isArray(value) && value.every((item) => isPlainObject(item));
|
||||
}
|
||||
|
||||
function printNames(holders: readonly FunctionData[]) {
|
||||
return printList(holders.map((holder) => holder.name));
|
||||
}
|
||||
|
||||
function ensureNoDuplicatesInFunctionNames(functions: readonly FunctionData[]) {
|
||||
const duplicateFunctionNames = getDuplicates(functions
|
||||
.map((func) => func.name.toLowerCase()));
|
||||
if (duplicateFunctionNames.length) {
|
||||
throw new Error(`duplicate function name: ${printList(duplicateFunctionNames)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function ensureNoDuplicateCode(functions: readonly FunctionData[]) {
|
||||
const callFunctions = functions
|
||||
.filter((func) => hasCode(func))
|
||||
.map((func) => func as CodeFunctionData);
|
||||
const duplicateCodes = getDuplicates(callFunctions
|
||||
.map((func) => func.code)
|
||||
.filter((code) => code));
|
||||
if (duplicateCodes.length > 0) {
|
||||
throw new Error(`duplicate "code" in functions: ${printList(duplicateCodes)}`);
|
||||
}
|
||||
const duplicateRevertCodes = getDuplicates(callFunctions
|
||||
.map((func) => func.revertCode)
|
||||
.filter((code): code is string => Boolean(code)));
|
||||
if (duplicateRevertCodes.length > 0) {
|
||||
throw new Error(`duplicate "revertCode" in functions: ${printList(duplicateRevertCodes)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function getDuplicates(texts: readonly string[]): string[] {
|
||||
return texts.filter((item, index) => texts.indexOf(item) !== index);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { ScriptData } from '@/application/collections/';
|
||||
import type { ScriptCode } from '@/domain/Executables/Script/Code/ScriptCode';
|
||||
|
||||
export interface IScriptCompiler {
|
||||
canCompile(script: ScriptData): boolean;
|
||||
compile(script: ScriptData): ScriptCode;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { FunctionData, ScriptData, CallInstruction } from '@/application/collections/';
|
||||
import type { ScriptCode } from '@/domain/Executables/Script/Code/ScriptCode';
|
||||
import type { ILanguageSyntax } from '@/application/Parser/Executable/Script/Validation/Syntax/ILanguageSyntax';
|
||||
import { CodeValidator } from '@/application/Parser/Executable/Script/Validation/CodeValidator';
|
||||
import { NoEmptyLines } from '@/application/Parser/Executable/Script/Validation/Rules/NoEmptyLines';
|
||||
import type { ICodeValidator } from '@/application/Parser/Executable/Script/Validation/ICodeValidator';
|
||||
import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/ContextualError';
|
||||
import { createScriptCode, type ScriptCodeFactory } from '@/domain/Executables/Script/Code/ScriptCodeFactory';
|
||||
import { SharedFunctionsParser } from './Function/SharedFunctionsParser';
|
||||
import { FunctionCallSequenceCompiler } from './Function/Call/Compiler/FunctionCallSequenceCompiler';
|
||||
import { parseFunctionCalls } from './Function/Call/FunctionCallParser';
|
||||
import type { CompiledCode } from './Function/Call/Compiler/CompiledCode';
|
||||
import type { IScriptCompiler } from './IScriptCompiler';
|
||||
import type { ISharedFunctionCollection } from './Function/ISharedFunctionCollection';
|
||||
import type { FunctionCallCompiler } from './Function/Call/Compiler/FunctionCallCompiler';
|
||||
import type { ISharedFunctionsParser } from './Function/ISharedFunctionsParser';
|
||||
|
||||
export class ScriptCompiler implements IScriptCompiler {
|
||||
private readonly functions: ISharedFunctionCollection;
|
||||
|
||||
constructor(
|
||||
functions: readonly FunctionData[],
|
||||
syntax: ILanguageSyntax,
|
||||
sharedFunctionsParser: ISharedFunctionsParser = SharedFunctionsParser.instance,
|
||||
private readonly callCompiler: FunctionCallCompiler = FunctionCallSequenceCompiler.instance,
|
||||
private readonly codeValidator: ICodeValidator = CodeValidator.instance,
|
||||
private readonly wrapError: ErrorWithContextWrapper = wrapErrorWithAdditionalContext,
|
||||
private readonly scriptCodeFactory: ScriptCodeFactory = createScriptCode,
|
||||
) {
|
||||
this.functions = sharedFunctionsParser.parseFunctions(functions, syntax);
|
||||
}
|
||||
|
||||
public canCompile(script: ScriptData): boolean {
|
||||
return hasCall(script);
|
||||
}
|
||||
|
||||
public compile(script: ScriptData): ScriptCode {
|
||||
try {
|
||||
if (!hasCall(script)) {
|
||||
throw new Error('Script does include any calls.');
|
||||
}
|
||||
const calls = parseFunctionCalls(script.call);
|
||||
const compiledCode = this.callCompiler.compileFunctionCalls(calls, this.functions);
|
||||
validateCompiledCode(compiledCode, this.codeValidator);
|
||||
return this.scriptCodeFactory(
|
||||
compiledCode.code,
|
||||
compiledCode.revertCode,
|
||||
);
|
||||
} catch (error) {
|
||||
throw this.wrapError(error, `Failed to compile script: ${script.name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateCompiledCode(compiledCode: CompiledCode, validator: ICodeValidator): void {
|
||||
[compiledCode.code, compiledCode.revertCode]
|
||||
.filter((code): code is string => Boolean(code))
|
||||
.map((code) => code as string)
|
||||
.forEach(
|
||||
(code) => validator.throwIfInvalid(
|
||||
code,
|
||||
[new NoEmptyLines()],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function hasCall(data: ScriptData): data is ScriptData & CallInstruction {
|
||||
return (data as CallInstruction).call !== undefined;
|
||||
}
|
||||
140
src/application/Parser/Executable/Script/ScriptParser.ts
Normal file
140
src/application/Parser/Executable/Script/ScriptParser.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import type { ScriptData, CodeScriptData, CallScriptData } from '@/application/collections/';
|
||||
import { NoEmptyLines } from '@/application/Parser/Executable/Script/Validation/Rules/NoEmptyLines';
|
||||
import type { ILanguageSyntax } from '@/application/Parser/Executable/Script/Validation/Syntax/ILanguageSyntax';
|
||||
import { CollectionScript } from '@/domain/Executables/Script/CollectionScript';
|
||||
import { RecommendationLevel } from '@/domain/Executables/Script/RecommendationLevel';
|
||||
import type { ScriptCode } from '@/domain/Executables/Script/Code/ScriptCode';
|
||||
import type { ICodeValidator } from '@/application/Parser/Executable/Script/Validation/ICodeValidator';
|
||||
import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/ContextualError';
|
||||
import type { ScriptCodeFactory } from '@/domain/Executables/Script/Code/ScriptCodeFactory';
|
||||
import { createScriptCode } from '@/domain/Executables/Script/Code/ScriptCodeFactory';
|
||||
import type { Script } from '@/domain/Executables/Script/Script';
|
||||
import { createEnumParser, type IEnumParser } from '@/application/Common/Enum';
|
||||
import { parseDocs, type DocsParser } from '../DocumentationParser';
|
||||
import { ExecutableType } from '../Validation/ExecutableType';
|
||||
import { createExecutableDataValidator, type ExecutableValidator, type ExecutableValidatorFactory } from '../Validation/ExecutableValidator';
|
||||
import { CodeValidator } from './Validation/CodeValidator';
|
||||
import { NoDuplicatedLines } from './Validation/Rules/NoDuplicatedLines';
|
||||
import type { CategoryCollectionSpecificUtilities } from '../CategoryCollectionSpecificUtilities';
|
||||
|
||||
export interface ScriptParser {
|
||||
(
|
||||
data: ScriptData,
|
||||
collectionUtilities: CategoryCollectionSpecificUtilities,
|
||||
scriptUtilities?: ScriptParserUtilities,
|
||||
): Script;
|
||||
}
|
||||
|
||||
export const parseScript: ScriptParser = (
|
||||
data,
|
||||
collectionUtilities,
|
||||
utilities = DefaultScriptParserUtilities,
|
||||
) => {
|
||||
const validator = utilities.createValidator({
|
||||
type: ExecutableType.Script,
|
||||
self: data,
|
||||
});
|
||||
validateScript(data, validator);
|
||||
try {
|
||||
const script = utilities.createScript({
|
||||
name: data.name,
|
||||
code: parseCode(data, collectionUtilities, utilities.codeValidator, utilities.createCode),
|
||||
docs: utilities.parseDocs(data),
|
||||
level: parseLevel(data.recommend, utilities.levelParser),
|
||||
});
|
||||
return script;
|
||||
} catch (error) {
|
||||
throw utilities.wrapError(
|
||||
error,
|
||||
validator.createContextualErrorMessage('Failed to parse script.'),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
function parseLevel(
|
||||
level: string | undefined,
|
||||
parser: IEnumParser<RecommendationLevel>,
|
||||
): RecommendationLevel | undefined {
|
||||
if (!level) {
|
||||
return undefined;
|
||||
}
|
||||
return parser.parseEnum(level, 'level');
|
||||
}
|
||||
|
||||
function parseCode(
|
||||
script: ScriptData,
|
||||
collectionUtilities: CategoryCollectionSpecificUtilities,
|
||||
codeValidator: ICodeValidator,
|
||||
createCode: ScriptCodeFactory,
|
||||
): ScriptCode {
|
||||
if (collectionUtilities.compiler.canCompile(script)) {
|
||||
return collectionUtilities.compiler.compile(script);
|
||||
}
|
||||
const codeScript = script as CodeScriptData; // Must be inline code if it cannot be compiled
|
||||
const code = createCode(codeScript.code, codeScript.revertCode);
|
||||
validateHardcodedCodeWithoutCalls(code, codeValidator, collectionUtilities.syntax);
|
||||
return code;
|
||||
}
|
||||
|
||||
function validateHardcodedCodeWithoutCalls(
|
||||
scriptCode: ScriptCode,
|
||||
validator: ICodeValidator,
|
||||
syntax: ILanguageSyntax,
|
||||
) {
|
||||
[scriptCode.execute, scriptCode.revert]
|
||||
.filter((code): code is string => Boolean(code))
|
||||
.forEach(
|
||||
(code) => validator.throwIfInvalid(
|
||||
code,
|
||||
[new NoEmptyLines(), new NoDuplicatedLines(syntax)],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function validateScript(
|
||||
script: ScriptData,
|
||||
validator: ExecutableValidator,
|
||||
): asserts script is NonNullable<ScriptData> {
|
||||
validator.assertDefined(script);
|
||||
validator.assertValidName(script.name);
|
||||
validator.assert(
|
||||
() => Boolean((script as CodeScriptData).code || (script as CallScriptData).call),
|
||||
'Neither "call" or "code" is defined.',
|
||||
);
|
||||
validator.assert(
|
||||
() => !((script as CodeScriptData).code && (script as CallScriptData).call),
|
||||
'Both "call" and "code" are defined.',
|
||||
);
|
||||
validator.assert(
|
||||
() => !((script as CodeScriptData).revertCode && (script as CallScriptData).call),
|
||||
'Both "call" and "revertCode" are defined.',
|
||||
);
|
||||
}
|
||||
|
||||
interface ScriptParserUtilities {
|
||||
readonly levelParser: IEnumParser<RecommendationLevel>;
|
||||
readonly createScript: ScriptFactory;
|
||||
readonly codeValidator: ICodeValidator;
|
||||
readonly wrapError: ErrorWithContextWrapper;
|
||||
readonly createValidator: ExecutableValidatorFactory;
|
||||
readonly createCode: ScriptCodeFactory;
|
||||
readonly parseDocs: DocsParser;
|
||||
}
|
||||
|
||||
export type ScriptFactory = (
|
||||
...parameters: ConstructorParameters<typeof CollectionScript>
|
||||
) => Script;
|
||||
|
||||
const createScript: ScriptFactory = (...parameters) => {
|
||||
return new CollectionScript(...parameters);
|
||||
};
|
||||
|
||||
const DefaultScriptParserUtilities: ScriptParserUtilities = {
|
||||
levelParser: createEnumParser(RecommendationLevel),
|
||||
createScript,
|
||||
codeValidator: CodeValidator.instance,
|
||||
wrapError: wrapErrorWithAdditionalContext,
|
||||
createValidator: createExecutableDataValidator,
|
||||
createCode: createScriptCode,
|
||||
parseDocs,
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { ICodeLine } from './ICodeLine';
|
||||
import type { ICodeValidationRule, IInvalidCodeLine } from './ICodeValidationRule';
|
||||
import type { ICodeValidator } from './ICodeValidator';
|
||||
|
||||
export class CodeValidator implements ICodeValidator {
|
||||
public static readonly instance: ICodeValidator = new CodeValidator();
|
||||
|
||||
public throwIfInvalid(
|
||||
code: string,
|
||||
rules: readonly ICodeValidationRule[],
|
||||
): void {
|
||||
if (rules.length === 0) { throw new Error('missing rules'); }
|
||||
if (!code) {
|
||||
return;
|
||||
}
|
||||
const lines = extractLines(code);
|
||||
const invalidLines = rules.flatMap((rule) => rule.analyze(lines));
|
||||
if (invalidLines.length === 0) {
|
||||
return;
|
||||
}
|
||||
const errorText = `Errors with the code.\n${printLines(lines, invalidLines)}`;
|
||||
throw new Error(errorText);
|
||||
}
|
||||
}
|
||||
|
||||
function extractLines(code: string): ICodeLine[] {
|
||||
return code
|
||||
.split(/\r\n|\r|\n/)
|
||||
.map((lineText, lineIndex): ICodeLine => ({
|
||||
index: lineIndex + 1,
|
||||
text: lineText,
|
||||
}));
|
||||
}
|
||||
|
||||
function printLines(
|
||||
lines: readonly ICodeLine[],
|
||||
invalidLines: readonly IInvalidCodeLine[],
|
||||
): string {
|
||||
return lines.map((line) => {
|
||||
const badLine = invalidLines.find((invalidLine) => invalidLine.index === line.index);
|
||||
if (!badLine) {
|
||||
return `[${line.index}] ✅ ${line.text}`;
|
||||
}
|
||||
return `[${badLine.index}] ❌ ${line.text}\n\t⟶ ${badLine.error}`;
|
||||
}).join('\n');
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface ICodeLine {
|
||||
readonly index: number;
|
||||
readonly text: string;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { ICodeLine } from './ICodeLine';
|
||||
|
||||
export interface IInvalidCodeLine {
|
||||
readonly index: number;
|
||||
readonly error: string;
|
||||
}
|
||||
|
||||
export interface ICodeValidationRule {
|
||||
analyze(lines: readonly ICodeLine[]): IInvalidCodeLine[];
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { ICodeValidationRule } from './ICodeValidationRule';
|
||||
|
||||
export interface ICodeValidator {
|
||||
throwIfInvalid(
|
||||
code: string,
|
||||
rules: readonly ICodeValidationRule[],
|
||||
): void;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { ILanguageSyntax } from '@/application/Parser/Executable/Script/Validation/Syntax/ILanguageSyntax';
|
||||
import type { ICodeLine } from '../ICodeLine';
|
||||
import type { ICodeValidationRule, IInvalidCodeLine } from '../ICodeValidationRule';
|
||||
|
||||
export class NoDuplicatedLines implements ICodeValidationRule {
|
||||
constructor(private readonly syntax: ILanguageSyntax) { }
|
||||
|
||||
public analyze(lines: readonly ICodeLine[]): IInvalidCodeLine[] {
|
||||
return lines
|
||||
.map((line): IDuplicateAnalyzedLine => ({
|
||||
index: line.index,
|
||||
isIgnored: shouldIgnoreLine(line.text, this.syntax),
|
||||
occurrenceIndices: lines
|
||||
.filter((other) => other.text === line.text)
|
||||
.map((duplicatedLine) => duplicatedLine.index),
|
||||
}))
|
||||
.filter((line) => hasInvalidDuplicates(line))
|
||||
.map((line): IInvalidCodeLine => ({
|
||||
index: line.index,
|
||||
error: `Line is duplicated at line numbers ${line.occurrenceIndices.join(',')}.`,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
interface IDuplicateAnalyzedLine {
|
||||
readonly index: number;
|
||||
readonly occurrenceIndices: readonly number[];
|
||||
readonly isIgnored: boolean;
|
||||
}
|
||||
|
||||
function hasInvalidDuplicates(line: IDuplicateAnalyzedLine): boolean {
|
||||
return !line.isIgnored && line.occurrenceIndices.length > 1;
|
||||
}
|
||||
|
||||
function shouldIgnoreLine(codeLine: string, syntax: ILanguageSyntax): boolean {
|
||||
const lowerCaseCodeLine = codeLine.toLowerCase();
|
||||
const isCommentLine = () => syntax.commentDelimiters.some(
|
||||
(delimiter) => lowerCaseCodeLine.startsWith(delimiter),
|
||||
);
|
||||
const consistsOfFrequentCommands = () => {
|
||||
const trimmed = lowerCaseCodeLine.trim().split(' ');
|
||||
return trimmed.every((part) => syntax.commonCodeParts.includes(part));
|
||||
};
|
||||
return isCommentLine() || consistsOfFrequentCommands();
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { ICodeValidationRule, IInvalidCodeLine } from '../ICodeValidationRule';
|
||||
import type { ICodeLine } from '../ICodeLine';
|
||||
|
||||
export class NoEmptyLines implements ICodeValidationRule {
|
||||
public analyze(lines: readonly ICodeLine[]): IInvalidCodeLine[] {
|
||||
return lines
|
||||
.filter((line) => (line.text?.trim().length ?? 0) === 0)
|
||||
.map((line): IInvalidCodeLine => ({
|
||||
index: line.index,
|
||||
error: (() => {
|
||||
if (!line.text) {
|
||||
return 'Empty line';
|
||||
}
|
||||
const markedText = line.text
|
||||
.replaceAll(' ', '{whitespace}')
|
||||
.replaceAll('\t', '{tab}');
|
||||
return `Empty line: "${markedText}"`;
|
||||
})(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { ILanguageSyntax } from '@/application/Parser/Executable/Script/Validation/Syntax/ILanguageSyntax';
|
||||
|
||||
const BatchFileCommonCodeParts = ['(', ')', 'else', '||'];
|
||||
const PowerShellCommonCodeParts = ['{', '}'];
|
||||
|
||||
export class BatchFileSyntax implements ILanguageSyntax {
|
||||
public readonly commentDelimiters = ['REM', '::'];
|
||||
|
||||
public readonly commonCodeParts = [...BatchFileCommonCodeParts, ...PowerShellCommonCodeParts];
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface ILanguageSyntax {
|
||||
readonly commentDelimiters: string[];
|
||||
readonly commonCodeParts: string[];
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import type { IScriptingLanguageFactory } from '@/application/Common/ScriptingLanguage/IScriptingLanguageFactory';
|
||||
import type { ILanguageSyntax } from './ILanguageSyntax';
|
||||
|
||||
export type ISyntaxFactory = IScriptingLanguageFactory<ILanguageSyntax>;
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { ILanguageSyntax } from '@/application/Parser/Executable/Script/Validation/Syntax/ILanguageSyntax';
|
||||
|
||||
export class ShellScriptSyntax implements ILanguageSyntax {
|
||||
public readonly commentDelimiters = ['#'];
|
||||
|
||||
public readonly commonCodeParts = ['(', ')', 'else', 'fi', 'done'];
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { ScriptingLanguage } from '@/domain/ScriptingLanguage';
|
||||
import { ScriptingLanguageFactory } from '@/application/Common/ScriptingLanguage/ScriptingLanguageFactory';
|
||||
import type { ILanguageSyntax } from '@/application/Parser/Executable/Script/Validation/Syntax/ILanguageSyntax';
|
||||
import { BatchFileSyntax } from './BatchFileSyntax';
|
||||
import { ShellScriptSyntax } from './ShellScriptSyntax';
|
||||
import type { ISyntaxFactory } from './ISyntaxFactory';
|
||||
|
||||
export class SyntaxFactory
|
||||
extends ScriptingLanguageFactory<ILanguageSyntax>
|
||||
implements ISyntaxFactory {
|
||||
constructor() {
|
||||
super();
|
||||
this.registerGetter(ScriptingLanguage.batchfile, () => new BatchFileSyntax());
|
||||
this.registerGetter(ScriptingLanguage.shellscript, () => new ShellScriptSyntax());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user