Improve context for errors thrown by compiler

This commit introduces a custom error object to provide additional
context for errors throwing during parsing and compiling operations,
improving troubleshooting.

By integrating error context handling, the error messages become more
informative and user-friendly, providing sequence of trace with context
to aid in troubleshooting.

Changes include:

- Introduce custom error object that extends errors with contextual
  information. This replaces previous usages of `AggregateError` which
  is not displayed well by browsers when logged.
- Improve parsing functions to encapsulate error context with more
  details.
- Increase unit test coverage and refactor the related code to be more
  testable.
This commit is contained in:
undergroundwires
2024-05-25 13:55:30 +02:00
parent 7794846185
commit 4212c7b9e0
78 changed files with 3346 additions and 1268 deletions

View File

@@ -1,4 +1,4 @@
import { isFunction } from '@/TypeHelpers';
import { isFunction, type ConstructorArguments } from '@/TypeHelpers';
/*
Provides a unified and resilient way to extend errors across platforms.
@@ -12,8 +12,8 @@ import { isFunction } from '@/TypeHelpers';
> https://web.archive.org/web/20230810014143/https://github.com/Microsoft/TypeScript-wiki/blob/main/Breaking-Changes.md#extending-built-ins-like-error-array-and-map-may-no-longer-work
*/
export abstract class CustomError extends Error {
constructor(message?: string, options?: ErrorOptions) {
super(message, options);
constructor(...args: ConstructorArguments<typeof Error>) {
super(...args);
fixPrototype(this, new.target.prototype);
ensureStackTrace(this);

View File

@@ -3,10 +3,12 @@ import type {
} from '@/application/collections/';
import { Script } from '@/domain/Script';
import { Category } from '@/domain/Category';
import { NodeValidator } from '@/application/Parser/NodeValidation/NodeValidator';
import { NodeType } from '@/application/Parser/NodeValidation/NodeType';
import { parseDocs } from './DocumentationParser';
import { parseScript } from './Script/ScriptParser';
import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/ContextualError';
import type { ICategory } from '@/domain/ICategory';
import { parseDocs, type DocsParser } from './DocumentationParser';
import { parseScript, type ScriptParser } from './Script/ScriptParser';
import { createNodeDataValidator, type NodeDataValidator, type NodeDataValidatorFactory } from './NodeValidation/NodeDataValidator';
import { NodeDataType } from './NodeValidation/NodeDataType';
import type { ICategoryCollectionParseContext } from './Script/ICategoryCollectionParseContext';
let categoryIdCounter = 0;
@@ -14,96 +16,108 @@ let categoryIdCounter = 0;
export function parseCategory(
category: CategoryData,
context: ICategoryCollectionParseContext,
factory: CategoryFactoryType = CategoryFactory,
utilities: CategoryParserUtilities = DefaultCategoryParserUtilities,
): Category {
return parseCategoryRecursively({
categoryData: category,
context,
factory,
utilities,
});
}
interface ICategoryParseContext {
readonly categoryData: CategoryData,
readonly context: ICategoryCollectionParseContext,
readonly factory: CategoryFactoryType,
readonly parentCategory?: CategoryData,
interface CategoryParseContext {
readonly categoryData: CategoryData;
readonly context: ICategoryCollectionParseContext;
readonly parentCategory?: CategoryData;
readonly utilities: CategoryParserUtilities;
}
function parseCategoryRecursively(context: ICategoryParseContext): Category | never {
ensureValidCategory(context.categoryData, context.parentCategory);
const children: ICategoryChildren = {
subCategories: new Array<Category>(),
subScripts: new Array<Script>(),
function parseCategoryRecursively(
context: CategoryParseContext,
): Category | never {
const validator = ensureValidCategory(context);
const children: CategoryChildren = {
subcategories: new Array<Category>(),
subscripts: new Array<Script>(),
};
for (const data of context.categoryData.children) {
parseNode({
nodeData: data,
children,
parent: context.categoryData,
factory: context.factory,
utilities: context.utilities,
context: context.context,
});
}
try {
return context.factory(
/* id: */ categoryIdCounter++,
/* name: */ context.categoryData.category,
/* docs: */ parseDocs(context.categoryData),
/* categories: */ children.subCategories,
/* scripts: */ children.subScripts,
return context.utilities.createCategory({
id: categoryIdCounter++,
name: context.categoryData.category,
docs: context.utilities.parseDocs(context.categoryData),
subcategories: children.subcategories,
scripts: children.subscripts,
});
} catch (error) {
throw context.utilities.wrapError(
error,
validator.createContextualErrorMessage('Failed to parse category.'),
);
} catch (err) {
return new NodeValidator({
type: NodeType.Category,
selfNode: context.categoryData,
parentNode: context.parentCategory,
}).throw(err.message);
}
}
function ensureValidCategory(category: CategoryData, parentCategory?: CategoryData) {
new NodeValidator({
type: NodeType.Category,
selfNode: category,
parentNode: parentCategory,
})
.assertDefined(category)
.assertValidName(category.category)
.assert(
() => category.children.length > 0,
`"${category.category}" has no children.`,
);
function ensureValidCategory(
context: CategoryParseContext,
): NodeDataValidator {
const category = context.categoryData;
const validator: NodeDataValidator = context.utilities.createValidator({
type: NodeDataType.Category,
selfNode: context.categoryData,
parentNode: context.parentCategory,
});
validator.assertDefined(category);
validator.assertValidName(category.category);
validator.assert(
() => Boolean(category.children) && category.children.length > 0,
`"${category.category}" has no children.`,
);
return validator;
}
interface ICategoryChildren {
subCategories: Category[];
subScripts: Script[];
interface CategoryChildren {
readonly subcategories: Category[];
readonly subscripts: Script[];
}
interface INodeParseContext {
interface NodeParseContext {
readonly nodeData: CategoryOrScriptData;
readonly children: ICategoryChildren;
readonly children: CategoryChildren;
readonly parent: CategoryData;
readonly factory: CategoryFactoryType;
readonly context: ICategoryCollectionParseContext;
readonly utilities: CategoryParserUtilities;
}
function parseNode(context: INodeParseContext) {
const validator = new NodeValidator({ selfNode: context.nodeData, parentNode: context.parent });
function parseNode(context: NodeParseContext) {
const validator: NodeDataValidator = context.utilities.createValidator({
selfNode: context.nodeData,
parentNode: context.parent,
});
validator.assertDefined(context.nodeData);
validator.assert(
() => isCategory(context.nodeData) || isScript(context.nodeData),
'Node is neither a category or a script.',
);
if (isCategory(context.nodeData)) {
const subCategory = parseCategoryRecursively({
categoryData: context.nodeData,
context: context.context,
factory: context.factory,
parentCategory: context.parent,
utilities: context.utilities,
});
context.children.subCategories.push(subCategory);
} else if (isScript(context.nodeData)) {
const script = parseScript(context.nodeData, context.context);
context.children.subScripts.push(script);
} else {
validator.throw('Node is neither a category or a script.');
context.children.subcategories.push(subCategory);
} else { // A script
const script = context.utilities.parseScript(context.nodeData, context.context);
context.children.subscripts.push(script);
}
}
@@ -123,11 +137,35 @@ function hasCall(data: unknown) {
return hasProperty(data, 'call');
}
function hasProperty(object: unknown, propertyName: string) {
function hasProperty(
object: unknown,
propertyName: string,
): object is NonNullable<object> {
if (typeof object !== 'object') {
return false;
}
if (object === null) { // `typeof object` is `null`
return false;
}
return Object.prototype.hasOwnProperty.call(object, propertyName);
}
export type CategoryFactoryType = (
...parameters: ConstructorParameters<typeof Category>) => Category;
export type CategoryFactory = (
...parameters: ConstructorParameters<typeof Category>
) => ICategory;
const CategoryFactory: CategoryFactoryType = (...parameters) => new Category(...parameters);
interface CategoryParserUtilities {
readonly createCategory: CategoryFactory;
readonly wrapError: ErrorWithContextWrapper;
readonly createValidator: NodeDataValidatorFactory;
readonly parseScript: ScriptParser;
readonly parseDocs: DocsParser;
}
const DefaultCategoryParserUtilities: CategoryParserUtilities = {
createCategory: (...parameters) => new Category(...parameters),
wrapError: wrapErrorWithAdditionalContext,
createValidator: createNodeDataValidator,
parseScript,
parseDocs,
};

View File

@@ -0,0 +1,42 @@
import { CustomError } from '@/application/Common/CustomError';
export interface ErrorWithContextWrapper {
(
error: Error,
additionalContext: string,
): Error;
}
export const wrapErrorWithAdditionalContext: ErrorWithContextWrapper = (
error: Error,
additionalContext: string,
) => {
return (error instanceof ContextualError ? error : new ContextualError(error))
.withAdditionalContext(additionalContext);
};
/* AggregateError is similar but isn't well-serialized or displayed by browsers */
class ContextualError extends CustomError {
private readonly additionalContext = new Array<string>();
constructor(
public readonly innerError: Error,
) {
super();
}
public withAdditionalContext(additionalContext: string): this {
this.additionalContext.push(additionalContext);
return this;
}
public get message(): string { // toString() is not used when Chromium logs it on console
return [
'\n',
this.innerError.message,
'\n',
'Additional context:',
...this.additionalContext.map((context, index) => `${index + 1}: ${context}`),
].join('\n');
}
}

View File

@@ -1,7 +1,7 @@
import type { DocumentableData, DocumentationData } from '@/application/collections/';
import { isString, isArray } from '@/TypeHelpers';
export function parseDocs(documentable: DocumentableData): readonly string[] {
export const parseDocs: DocsParser = (documentable) => {
const { docs } = documentable;
if (!docs) {
return [];
@@ -9,6 +9,12 @@ export function parseDocs(documentable: DocumentableData): readonly string[] {
let result = new DocumentationContainer();
result = addDocs(docs, result);
return result.getAll();
};
export interface DocsParser {
(
documentable: DocumentableData,
): readonly string[];
}
function addDocs(

View File

@@ -1,34 +0,0 @@
import { CustomError } from '@/application/Common/CustomError';
import { NodeType } from './NodeType';
import type { NodeData } from './NodeData';
export class NodeDataError extends CustomError {
constructor(message: string, public readonly context: INodeDataErrorContext) {
super(createMessage(message, context));
}
}
export interface INodeDataErrorContext {
readonly type?: NodeType;
readonly selfNode: NodeData;
readonly parentNode?: NodeData;
}
function createMessage(errorMessage: string, context: INodeDataErrorContext) {
let message = '';
if (context.type !== undefined) {
message += `${NodeType[context.type]}: `;
}
message += errorMessage;
message += `\n${dump(context)}`;
return message;
}
function dump(context: INodeDataErrorContext): string {
const printJson = (obj: unknown) => JSON.stringify(obj, undefined, 2);
let output = `Self: ${printJson(context.selfNode)}`;
if (context.parentNode) {
output += `\nParent: ${printJson(context.parentNode)}`;
}
return output;
}

View File

@@ -0,0 +1,25 @@
import type { CategoryData, ScriptData } from '@/application/collections/';
import { NodeDataType } from './NodeDataType';
import type { NodeData } from './NodeData';
export type NodeDataErrorContext = {
readonly parentNode?: CategoryData;
} & (CategoryNodeErrorContext | ScriptNodeErrorContext | UnknownNodeErrorContext);
export type CategoryNodeErrorContext = {
readonly type: NodeDataType.Category;
readonly selfNode: CategoryData;
readonly parentNode?: CategoryData;
};
export type ScriptNodeErrorContext = {
readonly type: NodeDataType.Script;
readonly selfNode: ScriptData;
readonly parentNode?: CategoryData;
};
export type UnknownNodeErrorContext = {
readonly type?: undefined;
readonly selfNode: NodeData;
readonly parentNode?: CategoryData;
};

View File

@@ -0,0 +1,35 @@
import { NodeDataType } from './NodeDataType';
import type { NodeDataErrorContext } from './NodeDataErrorContext';
import type { NodeData } from './NodeData';
export interface NodeContextErrorMessageCreator {
(
errorMessage: string,
context: NodeDataErrorContext,
): string;
}
export const createNodeContextErrorMessage: NodeContextErrorMessageCreator = (
errorMessage,
context,
) => {
let message = '';
if (context.type !== undefined) {
message += `${NodeDataType[context.type]}: `;
}
message += errorMessage;
message += `\n${getErrorContextDetails(context)}`;
return message;
};
function getErrorContextDetails(context: NodeDataErrorContext): string {
let output = `Self: ${printNodeDataAsJson(context.selfNode)}`;
if (context.parentNode) {
output += `\nParent: ${printNodeDataAsJson(context.parentNode)}`;
}
return output;
}
function printNodeDataAsJson(node: NodeData): string {
return JSON.stringify(node, undefined, 2);
}

View File

@@ -0,0 +1,4 @@
export enum NodeDataType {
Script,
Category,
}

View File

@@ -0,0 +1,69 @@
import { isString } from '@/TypeHelpers';
import { type NodeDataErrorContext } from './NodeDataErrorContext';
import { createNodeContextErrorMessage, type NodeContextErrorMessageCreator } from './NodeDataErrorContextMessage';
import type { NodeData } from './NodeData';
export interface NodeDataValidatorFactory {
(context: NodeDataErrorContext): NodeDataValidator;
}
export interface NodeDataValidator {
assertValidName(nameValue: string): void;
assertDefined(
node: NodeData | undefined,
): asserts node is NonNullable<NodeData> & void;
assert(
validationPredicate: () => boolean,
errorMessage: string,
): asserts validationPredicate is (() => true);
createContextualErrorMessage(errorMessage: string): string;
}
export const createNodeDataValidator
: NodeDataValidatorFactory = (context) => new ContextualNodeDataValidator(context);
export class ContextualNodeDataValidator implements NodeDataValidator {
constructor(
private readonly context: NodeDataErrorContext,
private readonly createErrorMessage
: NodeContextErrorMessageCreator = createNodeContextErrorMessage,
) {
}
public assertValidName(nameValue: string): void {
this.assert(() => Boolean(nameValue), 'missing name');
this.assert(
() => isString(nameValue),
`Name (${JSON.stringify(nameValue)}) is not a string but ${typeof nameValue}.`,
);
}
public assertDefined(
node: NodeData,
): asserts node is NonNullable<NodeData> {
this.assert(
() => node !== undefined && node !== null && Object.keys(node).length > 0,
'missing node data',
);
}
public assert(
validationPredicate: () => boolean,
errorMessage: string,
): asserts validationPredicate is (() => true) {
if (!validationPredicate()) {
this.throw(errorMessage);
}
}
public createContextualErrorMessage(errorMessage: string): string {
return this.createErrorMessage(errorMessage, this.context);
}
private throw(errorMessage: string): never {
throw new Error(
this.createContextualErrorMessage(errorMessage),
);
}
}

View File

@@ -1,4 +0,0 @@
export enum NodeType {
Script,
Category,
}

View File

@@ -1,39 +0,0 @@
import { isString } from '@/TypeHelpers';
import { type INodeDataErrorContext, NodeDataError } from './NodeDataError';
import type { NodeData } from './NodeData';
export class NodeValidator {
constructor(private readonly context: INodeDataErrorContext) {
}
public assertValidName(nameValue: string) {
return this
.assert(
() => Boolean(nameValue),
'missing name',
)
.assert(
() => isString(nameValue),
`Name (${JSON.stringify(nameValue)}) is not a string but ${typeof nameValue}.`,
);
}
public assertDefined(node: NodeData) {
return this.assert(
() => node !== undefined && node !== null && Object.keys(node).length > 0,
'missing node data',
);
}
public assert(validationPredicate: () => boolean, errorMessage: string) {
if (!validationPredicate()) {
this.throw(errorMessage);
}
return this;
}
public throw(errorMessage: string): never {
throw new NodeDataError(errorMessage, this.context);
}
}

View File

@@ -7,15 +7,18 @@ import type { IReadOnlyFunctionParameterCollection } from '../../Function/Parame
import type { IExpression } from './IExpression';
export type ExpressionEvaluator = (context: IExpressionEvaluationContext) => string;
export class Expression implements IExpression {
public readonly parameters: IReadOnlyFunctionParameterCollection;
constructor(
public readonly position: ExpressionPosition,
public readonly evaluator: ExpressionEvaluator,
parameters?: IReadOnlyFunctionParameterCollection,
) {
this.parameters = parameters ?? new FunctionParameterCollection();
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 {
@@ -26,6 +29,12 @@ export class Expression implements IExpression {
}
}
export interface ExpressionInitParameters {
readonly position: ExpressionPosition,
readonly evaluator: ExpressionEvaluator,
readonly parameters?: IReadOnlyFunctionParameterCollection,
}
function validateThatAllRequiredParametersAreSatisfied(
parameters: IReadOnlyFunctionParameterCollection,
args: IReadOnlyFunctionCallArgumentCollection,

View File

@@ -1,8 +1,13 @@
import { ExpressionPosition } from './ExpressionPosition';
export function createPositionFromRegexFullMatch(
match: RegExpMatchArray,
): 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)}`);
@@ -13,4 +18,4 @@ export function createPositionFromRegexFullMatch(
}
const endPos = startPos + fullMatch.length;
return new ExpressionPosition(startPos, endPos);
}
};

View File

@@ -3,10 +3,10 @@ import { WithParser } from '../SyntaxParsers/WithParser';
import type { IExpression } from '../Expression/IExpression';
import type { IExpressionParser } from './IExpressionParser';
const Parsers = [
const Parsers: readonly IExpressionParser[] = [
new ParameterSubstitutionParser(),
new WithParser(),
];
] as const;
export class CompositeExpressionParser implements IExpressionParser {
public constructor(private readonly leafs: readonly IExpressionParser[] = Parsers) {

View File

@@ -1,53 +1,127 @@
import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/ContextualError';
import { Expression, type ExpressionEvaluator } from '../../Expression/Expression';
import { FunctionParameterCollection } from '../../../Function/Parameter/FunctionParameterCollection';
import { createPositionFromRegexFullMatch } from '../../Expression/ExpressionPositionFactory';
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): IPrimitiveExpression;
protected abstract buildExpression(match: RegExpMatchArray): PrimitiveExpression;
private* findRegexExpressions(code: string): Iterable<IExpression> {
if (!code) {
throw new Error('missing code');
throw new Error(
this.buildErrorMessageWithContext({ errorMessage: 'missing code', code: 'EMPTY' }),
);
}
const matches = code.matchAll(this.regex);
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.buildExpression(match);
const position = this.doOrRethrow(() => createPositionFromRegexFullMatch(match), 'invalid script position', code);
const parameters = createParameters(primitiveExpression);
const expression = new Expression(position, primitiveExpression.evaluator, parameters);
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, errorText: string, code: string): T {
private doOrRethrow<T>(
action: () => T,
context: ErrorContext,
): T {
try {
return action();
} catch (error) {
throw new Error(`[${this.constructor.name}] ${errorText}: ${error.message}\nRegex: ${this.regex}\nCode: ${code}`);
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: IPrimitiveExpression,
): FunctionParameterCollection {
expression: PrimitiveExpression,
parameterCollection: IFunctionParameterCollection,
): IReadOnlyFunctionParameterCollection {
return (expression.parameters || [])
.reduce((parameters, parameter) => {
parameters.addParameter(parameter);
return parameters;
}, new FunctionParameterCollection());
}, parameterCollection);
}
export interface IPrimitiveExpression {
evaluator: ExpressionEvaluator;
parameters?: readonly IFunctionParameter[];
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,
};

View File

@@ -1,5 +1,5 @@
import { FunctionParameter } from '@/application/Parser/Script/Compiler/Function/Parameter/FunctionParameter';
import { RegexParser, type IPrimitiveExpression } from '../Parser/Regex/RegexParser';
import { RegexParser, type PrimitiveExpression } from '../Parser/Regex/RegexParser';
import { ExpressionRegexBuilder } from '../Parser/Regex/ExpressionRegexBuilder';
export class ParameterSubstitutionParser extends RegexParser {
@@ -12,7 +12,7 @@ export class ParameterSubstitutionParser extends RegexParser {
.expectExpressionEnd()
.buildRegExp();
protected buildExpression(match: RegExpMatchArray): IPrimitiveExpression {
protected buildExpression(match: RegExpMatchArray): PrimitiveExpression {
const parameterName = match[1];
const pipeline = match[2];
return {

View File

@@ -8,7 +8,7 @@ export class FunctionCallArgument implements IFunctionCallArgument {
) {
ensureValidParameterName(parameterName);
if (!argumentValue) {
throw new Error(`missing argument value for "${parameterName}"`);
throw new Error(`Missing argument value for the parameter "${parameterName}".`);
}
}
}

View File

@@ -72,7 +72,7 @@ function throwIfUnexpectedParametersExist(
// eslint-disable-next-line prefer-template
`Function "${functionName}" has unexpected parameter(s) provided: `
+ `"${unexpectedParameters.join('", "')}"`
+ '. Expected parameter(s): '
+ (expectedParameters.length ? `"${expectedParameters.join('", "')}"` : 'none'),
+ '.\nExpected parameter(s): '
+ (expectedParameters.length ? `"${expectedParameters.join('", "')}".` : 'none'),
);
}

View File

@@ -6,11 +6,14 @@ import type { IExpressionsCompiler } from '@/application/Parser/Script/Compiler/
import type { FunctionCall } from '@/application/Parser/Script/Compiler/Function/Call/FunctionCall';
import type { FunctionCallCompilationContext } from '@/application/Parser/Script/Compiler/Function/Call/Compiler/FunctionCallCompilationContext';
import { ParsedFunctionCall } from '@/application/Parser/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(
@@ -22,18 +25,26 @@ export class NestedFunctionArgumentCompiler implements ArgumentCompiler {
nestedFunction,
parentFunction.args,
context,
this.expressionsCompiler,
{
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,
expressionsCompiler: IExpressionsCompiler,
utilities: ArgumentCompilationUtilities,
): IReadOnlyFunctionCallArgumentCollection {
const requiredParameterNames = context
.allFunctions
@@ -47,7 +58,7 @@ function compileNestedFunctionArguments(
paramName,
nestedFunction,
parentFunctionArgs,
expressionsCompiler,
utilities,
),
}))
// Filter out arguments with absent values
@@ -89,13 +100,13 @@ function compileArgument(
parameterName: string,
nestedFunction: FunctionCall,
parentFunctionArgs: IReadOnlyFunctionCallArgumentCollection,
expressionsCompiler: IExpressionsCompiler,
utilities: ArgumentCompilationUtilities,
): string {
try {
const { argumentValue: codeInArgument } = nestedFunction.args.getArgument(parameterName);
return expressionsCompiler.compileExpressions(codeInArgument, parentFunctionArgs);
} catch (err) {
throw new AggregateError([err], `Error when compiling argument for "${parameterName}"`);
return utilities.expressionsCompiler.compileExpressions(codeInArgument, parentFunctionArgs);
} catch (error) {
throw utilities.wrapError(error, `Error when compiling argument for "${parameterName}"`);
}
}

View File

@@ -1,14 +1,21 @@
import { type CallFunctionBody, FunctionBodyType, type ISharedFunction } from '@/application/Parser/Script/Compiler/Function/ISharedFunction';
import {
type CallFunctionBody, FunctionBodyType,
type ISharedFunction,
} from '@/application/Parser/Script/Compiler/Function/ISharedFunction';
import type { FunctionCall } from '@/application/Parser/Script/Compiler/Function/Call/FunctionCall';
import type { FunctionCallCompilationContext } from '@/application/Parser/Script/Compiler/Function/Call/Compiler/FunctionCallCompilationContext';
import type { CompiledCode } from '@/application/Parser/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 argumentCompiler: ArgumentCompiler
= new NestedFunctionArgumentCompiler(),
private readonly wrapError: ErrorWithContextWrapper
= wrapErrorWithAdditionalContext,
) {
}
@@ -29,8 +36,11 @@ export class NestedFunctionCallCompiler implements SingleCallCompilerStrategy {
const compiledNestedCall = context.singleCallCompiler
.compileSingleCall(compiledParentCall, context);
return compiledNestedCall;
} catch (err) {
throw new AggregateError([err], `Error with call to "${nestedCall.functionName}" function from "${callToFunction.functionName}" function`);
} catch (error) {
throw this.wrapError(
error,
`Failed to call '${nestedCall.functionName}' (callee function) from '${callToFunction.functionName}' (caller function).`,
);
}
}).flat();
}

View File

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

View File

@@ -15,7 +15,7 @@ export class SharedFunctionCollection implements ISharedFunctionCollection {
if (!name) { throw Error('missing function name'); }
const func = this.functionsByName.get(name);
if (!func) {
throw new Error(`called function is not defined "${name}"`);
throw new Error(`Called function is not defined: "${name}"`);
}
return func;
}

View File

@@ -1,5 +1,6 @@
import type {
FunctionData, CodeInstruction, CodeFunctionData, CallFunctionData, CallInstruction,
FunctionData, CodeInstruction, CodeFunctionData, CallFunctionData,
CallInstruction, ParameterDefinitionData,
} from '@/application/collections/';
import type { ILanguageSyntax } from '@/application/Parser/Script/Validation/Syntax/ILanguageSyntax';
import { CodeValidator } from '@/application/Parser/Script/Validation/CodeValidator';
@@ -7,20 +8,30 @@ import { NoEmptyLines } from '@/application/Parser/Script/Validation/Rules/NoEmp
import { NoDuplicatedLines } from '@/application/Parser/Script/Validation/Rules/NoDuplicatedLines';
import type { ICodeValidator } from '@/application/Parser/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 { FunctionParameterCollection } from './Parameter/FunctionParameterCollection';
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 codeValidator: ICodeValidator = CodeValidator.instance) { }
constructor(
private readonly utilities = DefaultSharedFunctionsParsingUtilities,
) { }
public parseFunctions(
functions: readonly FunctionData[],
@@ -32,7 +43,7 @@ export class SharedFunctionsParser implements ISharedFunctionsParser {
}
ensureValidFunctions(functions);
return functions
.map((func) => parseFunction(func, syntax, this.codeValidator))
.map((func) => parseFunction(func, syntax, this.utilities))
.reduce((acc, func) => {
acc.addFunction(func);
return acc;
@@ -40,15 +51,26 @@ export class SharedFunctionsParser implements ISharedFunctionsParser {
}
}
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,
validator: ICodeValidator,
utilities: SharedFunctionsParsingUtilities,
): ISharedFunction {
const { name } = data;
const parameters = parseParameters(data);
const parameters = parseParameters(data, utilities);
if (hasCode(data)) {
validateCode(data, syntax, validator);
validateCode(data, syntax, utilities.codeValidator);
return createFunctionWithInlineCode(name, parameters, data.code, data.revertCode);
}
// Has call
@@ -71,22 +93,38 @@ function validateCode(
);
}
function parseParameters(data: FunctionData): IReadOnlyFunctionParameterCollection {
function parseParameters(
data: FunctionData,
utilities: SharedFunctionsParsingUtilities,
): IReadOnlyFunctionParameterCollection {
return (data.parameters || [])
.map((parameter) => {
try {
return new FunctionParameter(
parameter.name,
parameter.optional || false,
);
} catch (err) {
throw new Error(`"${data.name}": ${err.message}`);
}
})
.map((parameter) => createFunctionParameter(
data.name,
parameter,
utilities,
))
.reduce((parameters, parameter) => {
parameters.addParameter(parameter);
return parameters;
}, new FunctionParameterCollection());
}, 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 {

View File

@@ -1,10 +1,11 @@
import type { FunctionData, ScriptData, CallInstruction } from '@/application/collections/';
import type { IScriptCode } from '@/domain/IScriptCode';
import { ScriptCode } from '@/domain/ScriptCode';
import type { ILanguageSyntax } from '@/application/Parser/Script/Validation/Syntax/ILanguageSyntax';
import { CodeValidator } from '@/application/Parser/Script/Validation/CodeValidator';
import { NoEmptyLines } from '@/application/Parser/Script/Validation/Rules/NoEmptyLines';
import type { ICodeValidator } from '@/application/Parser/Script/Validation/ICodeValidator';
import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/ContextualError';
import { createScriptCode, type ScriptCodeFactory } from '@/domain/ScriptCodeFactory';
import { SharedFunctionsParser } from './Function/SharedFunctionsParser';
import { FunctionCallSequenceCompiler } from './Function/Call/Compiler/FunctionCallSequenceCompiler';
import { parseFunctionCalls } from './Function/Call/FunctionCallParser';
@@ -23,6 +24,8 @@ export class ScriptCompiler implements IScriptCompiler {
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);
}
@@ -39,12 +42,12 @@ export class ScriptCompiler implements IScriptCompiler {
const calls = parseFunctionCalls(script.call);
const compiledCode = this.callCompiler.compileFunctionCalls(calls, this.functions);
validateCompiledCode(compiledCode, this.codeValidator);
return new ScriptCode(
return this.scriptCodeFactory(
compiledCode.code,
compiledCode.revertCode,
);
} catch (error) {
throw Error(`Script "${script.name}" ${error.message}`);
throw this.wrapError(error, `Failed to compile script: ${script.name}`);
}
}
}

View File

@@ -4,37 +4,52 @@ import type { ILanguageSyntax } from '@/application/Parser/Script/Validation/Syn
import { Script } from '@/domain/Script';
import { RecommendationLevel } from '@/domain/RecommendationLevel';
import type { IScriptCode } from '@/domain/IScriptCode';
import { ScriptCode } from '@/domain/ScriptCode';
import type { ICodeValidator } from '@/application/Parser/Script/Validation/ICodeValidator';
import { parseDocs } from '../DocumentationParser';
import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/ContextualError';
import type { ScriptCodeFactory } from '@/domain/ScriptCodeFactory';
import { createScriptCode } from '@/domain/ScriptCodeFactory';
import type { IScript } from '@/domain/IScript';
import { parseDocs, type DocsParser } from '../DocumentationParser';
import { createEnumParser, type IEnumParser } from '../../Common/Enum';
import { NodeType } from '../NodeValidation/NodeType';
import { NodeValidator } from '../NodeValidation/NodeValidator';
import { NodeDataType } from '../NodeValidation/NodeDataType';
import { createNodeDataValidator, type NodeDataValidator, type NodeDataValidatorFactory } from '../NodeValidation/NodeDataValidator';
import { CodeValidator } from './Validation/CodeValidator';
import { NoDuplicatedLines } from './Validation/Rules/NoDuplicatedLines';
import type { ICategoryCollectionParseContext } from './ICategoryCollectionParseContext';
export function parseScript(
data: ScriptData,
context: ICategoryCollectionParseContext,
levelParser = createEnumParser(RecommendationLevel),
scriptFactory: ScriptFactoryType = ScriptFactory,
codeValidator: ICodeValidator = CodeValidator.instance,
): Script {
const validator = new NodeValidator({ type: NodeType.Script, selfNode: data });
export interface ScriptParser {
(
data: ScriptData,
context: ICategoryCollectionParseContext,
utilities?: ScriptParserUtilities,
): IScript;
}
export const parseScript: ScriptParser = (
data,
context,
utilities = DefaultScriptParserUtilities,
) => {
const validator = utilities.createValidator({
type: NodeDataType.Script,
selfNode: data,
});
validateScript(data, validator);
try {
const script = scriptFactory(
/* name: */ data.name,
/* code: */ parseCode(data, context, codeValidator),
/* docs: */ parseDocs(data),
/* level: */ parseLevel(data.recommend, levelParser),
);
const script = utilities.createScript({
name: data.name,
code: parseCode(data, context, utilities.codeValidator, utilities.createCode),
docs: utilities.parseDocs(data),
level: parseLevel(data.recommend, utilities.levelParser),
});
return script;
} catch (err) {
return validator.throw(err.message);
} catch (error) {
throw utilities.wrapError(
error,
validator.createContextualErrorMessage('Failed to parse script.'),
);
}
}
};
function parseLevel(
level: string | undefined,
@@ -50,18 +65,19 @@ function parseCode(
script: ScriptData,
context: ICategoryCollectionParseContext,
codeValidator: ICodeValidator,
createCode: ScriptCodeFactory,
): IScriptCode {
if (context.compiler.canCompile(script)) {
return context.compiler.compile(script);
}
const codeScript = script as CodeScriptData; // Must be inline code if it cannot be compiled
const code = new ScriptCode(codeScript.code, codeScript.revertCode);
const code = createCode(codeScript.code, codeScript.revertCode);
validateHardcodedCodeWithoutCalls(code, codeValidator, context.syntax);
return code;
}
function validateHardcodedCodeWithoutCalls(
scriptCode: ScriptCode,
scriptCode: IScriptCode,
validator: ICodeValidator,
syntax: ILanguageSyntax,
) {
@@ -77,25 +93,48 @@ function validateHardcodedCodeWithoutCalls(
function validateScript(
script: ScriptData,
validator: NodeValidator,
validator: NodeDataValidator,
): asserts script is NonNullable<ScriptData> {
validator
.assertDefined(script)
.assertValidName(script.name)
.assert(
() => Boolean((script as CodeScriptData).code || (script as CallScriptData).call),
'Neither "call" or "code" is defined.',
)
.assert(
() => !((script as CodeScriptData).code && (script as CallScriptData).call),
'Both "call" and "code" are defined.',
)
.assert(
() => !((script as CodeScriptData).revertCode && (script as CallScriptData).call),
'Both "call" and "revertCode" are defined.',
);
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.',
);
}
export type ScriptFactoryType = (...parameters: ConstructorParameters<typeof Script>) => Script;
interface ScriptParserUtilities {
readonly levelParser: IEnumParser<RecommendationLevel>;
readonly createScript: ScriptFactory;
readonly codeValidator: ICodeValidator;
readonly wrapError: ErrorWithContextWrapper;
readonly createValidator: NodeDataValidatorFactory;
readonly createCode: ScriptCodeFactory;
readonly parseDocs: DocsParser;
}
const ScriptFactory: ScriptFactoryType = (...parameters) => new Script(...parameters);
export type ScriptFactory = (
...parameters: ConstructorParameters<typeof Script>
) => IScript;
const createScript: ScriptFactory = (...parameters) => {
return new Script(...parameters);
};
const DefaultScriptParserUtilities: ScriptParserUtilities = {
levelParser: createEnumParser(RecommendationLevel),
createScript,
codeValidator: CodeValidator.instance,
wrapError: wrapErrorWithAdditionalContext,
createValidator: createNodeDataValidator,
createCode: createScriptCode,
parseDocs,
};

View File

@@ -5,6 +5,7 @@ import { ExpressionsCompiler } from '@/application/Parser/Script/Compiler/Expres
import type { ProjectDetails } from '@/domain/Project/ProjectDetails';
import { FunctionCallArgumentCollection } from '@/application/Parser/Script/Compiler/Function/Call/Argument/FunctionCallArgumentCollection';
import { FunctionCallArgument } from '@/application/Parser/Script/Compiler/Function/Call/Argument/FunctionCallArgument';
import type { IExpressionParser } from '@/application/Parser/Script/Compiler/Expressions/Parser/IExpressionParser';
import type { ICodeSubstituter } from './ICodeSubstituter';
export class CodeSubstituter implements ICodeSubstituter {
@@ -29,7 +30,9 @@ export class CodeSubstituter implements ICodeSubstituter {
}
function createSubstituteCompiler(): IExpressionsCompiler {
const parsers = [new ParameterSubstitutionParser()];
const parsers: readonly IExpressionParser[] = [
new ParameterSubstitutionParser(),
] as const;
const parser = new CompositeExpressionParser(parsers);
const expressionCompiler = new ExpressionsCompiler(parser);
return expressionCompiler;