Skip to content

Commit

Permalink
Improve context for errors thrown by compiler
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
undergroundwires committed May 25, 2024
1 parent 7794846 commit 4212c7b
Show file tree
Hide file tree
Showing 78 changed files with 3,323 additions and 1,245 deletions.
6 changes: 3 additions & 3 deletions src/application/Common/CustomError.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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);
Expand Down
158 changes: 98 additions & 60 deletions src/application/Parser/CategoryParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,107 +3,121 @@ 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;

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

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

interface CategoryParserUtilities {
readonly createCategory: CategoryFactory;
readonly wrapError: ErrorWithContextWrapper;
readonly createValidator: NodeDataValidatorFactory;
readonly parseScript: ScriptParser;
readonly parseDocs: DocsParser;
}

const CategoryFactory: CategoryFactoryType = (...parameters) => new Category(...parameters);
const DefaultCategoryParserUtilities: CategoryParserUtilities = {
createCategory: (...parameters) => new Category(...parameters),
wrapError: wrapErrorWithAdditionalContext,
createValidator: createNodeDataValidator,
parseScript,
parseDocs,
};
42 changes: 42 additions & 0 deletions src/application/Parser/ContextualError.ts
Original file line number Diff line number Diff line change
@@ -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');
}
}
8 changes: 7 additions & 1 deletion src/application/Parser/DocumentationParser.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
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 [];
}
let result = new DocumentationContainer();
result = addDocs(docs, result);
return result.getAll();
};

export interface DocsParser {
(
documentable: DocumentableData,
): readonly string[];
}

function addDocs(
Expand Down
34 changes: 0 additions & 34 deletions src/application/Parser/NodeValidation/NodeDataError.ts

This file was deleted.

25 changes: 25 additions & 0 deletions src/application/Parser/NodeValidation/NodeDataErrorContext.ts
Original file line number Diff line number Diff line change
@@ -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;
};
Original file line number Diff line number Diff line change
@@ -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);
}
Loading

0 comments on commit 4212c7b

Please sign in to comment.