From 7a3038b982c4d318e146698527070f096591aa4f Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Fri, 16 Feb 2024 09:39:14 -0400 Subject: sugar: move many functions into new #aggregate import --- package.json | 1 + src/content-function.js | 9 +- src/content/dependencies/generatePageLayout.js | 3 +- src/content/dependencies/index.js | 7 +- src/data/checks.js | 5 +- src/data/composite.js | 17 +- src/data/language.js | 4 +- src/data/things/index.js | 2 +- src/data/things/language.js | 3 +- src/data/validators.js | 11 +- src/data/yaml.js | 9 +- src/listing-spec.js | 3 +- src/upd8.js | 3 +- src/util/aggregate.js | 646 +++++++++++++++++++++++++ src/util/html.js | 3 +- src/util/sugar.js | 644 ------------------------ test/lib/content-function.js | 3 +- 17 files changed, 678 insertions(+), 695 deletions(-) create mode 100644 src/util/aggregate.js diff --git a/package.json b/package.json index 48ffa46c..bc97e547 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "dev": "eslint src && node src/upd8.js" }, "imports": { + "#aggregate": "./src/util/aggregate.js", "#cacheable-object": "./src/data/cacheable-object.js", "#colors": "./src/util/colors.js", "#composite": "./src/data/composite.js", diff --git a/src/content-function.js b/src/content-function.js index 8c7c4e2e..16b18641 100644 --- a/src/content-function.js +++ b/src/content-function.js @@ -1,14 +1,9 @@ import {inspect as nodeInspect} from 'node:util'; +import {decorateError} from '#aggregate'; import {colors, ENABLE_COLOR} from '#cli'; import {Template} from '#html'; - -import { - annotateFunction, - decorateError, - empty, - setIntersection, -} from '#sugar'; +import {annotateFunction, empty, setIntersection} from '#sugar'; function inspect(value, opts = {}) { return nodeInspect(value, {colors: ENABLE_COLOR, ...opts}); diff --git a/src/content/dependencies/generatePageLayout.js b/src/content/dependencies/generatePageLayout.js index 0d2ce557..76d07c40 100644 --- a/src/content/dependencies/generatePageLayout.js +++ b/src/content/dependencies/generatePageLayout.js @@ -1,4 +1,5 @@ -import {empty, openAggregate} from '#sugar'; +import {openAggregate} from '#aggregate'; +import {empty} from '#sugar'; function sidebarSlots(side) { return { diff --git a/src/content/dependencies/index.js b/src/content/dependencies/index.js index 28755f7b..a5009804 100644 --- a/src/content/dependencies/index.js +++ b/src/content/dependencies/index.js @@ -6,13 +6,10 @@ import {fileURLToPath} from 'node:url'; import chokidar from 'chokidar'; import {ESLint} from 'eslint'; +import {showAggregate as _showAggregate} from '#aggregate'; import {colors, logWarn} from '#cli'; import contentFunction, {ContentFunctionSpecError} from '#content-function'; - -import { - annotateFunction, - showAggregate as _showAggregate -} from '#sugar'; +import {annotateFunction} from '#sugar'; function cachebust(filePath) { if (filePath in cachebust.cache) { diff --git a/src/data/checks.js b/src/data/checks.js index 25e94aa9..ad86087b 100644 --- a/src/data/checks.js +++ b/src/data/checks.js @@ -4,19 +4,18 @@ import {inspect as nodeInspect} from 'node:util'; import {colors, ENABLE_COLOR} from '#cli'; import CacheableObject from '#cacheable-object'; +import {compareArrays, empty} from '#sugar'; import Thing from '#thing'; import thingConstructors from '#things'; import {commentaryRegexCaseSensitive} from '#wiki-data'; import { - compareArrays, conditionallySuppressError, decorateErrorWithIndex, - empty, filterAggregate, openAggregate, withAggregate, -} from '#sugar'; +} from '#aggregate'; function inspect(value, opts = {}) { return nodeInspect(value, {colors: ENABLE_COLOR, ...opts}); diff --git a/src/data/composite.js b/src/data/composite.js index 4f89d887..7a98c424 100644 --- a/src/data/composite.js +++ b/src/data/composite.js @@ -1,19 +1,12 @@ import {inspect} from 'node:util'; +import {decorateErrorWithIndex, openAggregate, withAggregate} + from '#aggregate'; import {colors} from '#cli'; -import {TupleMap} from '#wiki-data'; +import {empty, filterProperties, stitchArrays, typeAppearance, unique} + from '#sugar'; import {a} from '#validators'; - -import { - decorateErrorWithIndex, - empty, - filterProperties, - openAggregate, - stitchArrays, - typeAppearance, - unique, - withAggregate, -} from '#sugar'; +import {TupleMap} from '#wiki-data'; const globalCompositeCache = {}; diff --git a/src/data/language.js b/src/data/language.js index 6f774f27..96d39d81 100644 --- a/src/data/language.js +++ b/src/data/language.js @@ -7,10 +7,10 @@ import chokidar from 'chokidar'; import he from 'he'; // It stands for "HTML Entities", apparently. Cursed. import yaml from 'js-yaml'; +import {annotateError, annotateErrorWithFile, showAggregate, withAggregate} + from '#aggregate'; import {externalLinkSpec} from '#external-links'; import {colors, logWarn} from '#cli'; -import {annotateError, annotateErrorWithFile, showAggregate, withAggregate} - from '#sugar'; import T from '#things'; const {Language} = T; diff --git a/src/data/things/index.js b/src/data/things/index.js index 9a36eaae..3bf84091 100644 --- a/src/data/things/index.js +++ b/src/data/things/index.js @@ -1,10 +1,10 @@ import * as path from 'node:path'; import {fileURLToPath} from 'node:url'; +import {openAggregate, showAggregate} from '#aggregate'; import {logError} from '#cli'; import {compositeFrom} from '#composite'; import * as serialize from '#serialize'; -import {openAggregate, showAggregate} from '#sugar'; import Thing from '#thing'; diff --git a/src/data/things/language.js b/src/data/things/language.js index 6bd5a78a..93ed40b6 100644 --- a/src/data/things/language.js +++ b/src/data/things/language.js @@ -1,8 +1,9 @@ import { Temporal, toTemporalInstant } from '@js-temporal/polyfill'; +import {withAggregate} from '#aggregate'; import CacheableObject from '#cacheable-object'; import * as html from '#html'; -import {empty, withAggregate} from '#sugar'; +import {empty} from '#sugar'; import {isLanguageCode} from '#validators'; import Thing from '#thing'; diff --git a/src/data/validators.js b/src/data/validators.js index 7eabe720..4fc2ac65 100644 --- a/src/data/validators.js +++ b/src/data/validators.js @@ -1,18 +1,11 @@ import {inspect as nodeInspect} from 'node:util'; +import {openAggregate, withAggregate} from '#aggregate'; import {colors, ENABLE_COLOR} from '#cli'; +import {cut, empty, matchMultiline, typeAppearance} from '#sugar'; import {commentaryRegexCaseInsensitive, commentaryRegexCaseSensitiveOneShot} from '#wiki-data'; -import { - cut, - empty, - matchMultiline, - openAggregate, - typeAppearance, - withAggregate, -} from '#sugar'; - function inspect(value) { return nodeInspect(value, {colors: ENABLE_COLOR}); } diff --git a/src/data/yaml.js b/src/data/yaml.js index 7a0643e8..100e07b9 100644 --- a/src/data/yaml.js +++ b/src/data/yaml.js @@ -10,23 +10,20 @@ import yaml from 'js-yaml'; import {colors, ENABLE_COLOR, logInfo, logWarn} from '#cli'; import {reportDuplicateDirectories, filterReferenceErrors} from '#data-checks'; +import {atOffset, empty, filterProperties, typeAppearance, withEntries} + from '#sugar'; import Thing from '#thing'; import thingConstructors from '#things'; import {sortByName} from '#wiki-data'; import { annotateErrorWithFile, - atOffset, decorateErrorWithIndex, decorateErrorWithAnnotation, - empty, - filterProperties, openAggregate, showAggregate, - typeAppearance, withAggregate, - withEntries, -} from '#sugar'; +} from '#aggregate'; function inspect(value, opts = {}) { return nodeInspect(value, {colors: ENABLE_COLOR, ...opts}); diff --git a/src/listing-spec.js b/src/listing-spec.js index 9433ee68..73fbee6d 100644 --- a/src/listing-spec.js +++ b/src/listing-spec.js @@ -1,4 +1,5 @@ -import {empty, showAggregate} from '#sugar'; +import {showAggregate} from '#aggregate'; +import {empty} from '#sugar'; const listingSpec = []; diff --git a/src/upd8.js b/src/upd8.js index 04c0ce2e..13c625a3 100755 --- a/src/upd8.js +++ b/src/upd8.js @@ -38,6 +38,7 @@ import {fileURLToPath} from 'node:url'; import wrap from 'word-wrap'; +import {showAggregate} from '#aggregate'; import CacheableObject from '#cacheable-object'; import {displayCompositeCacheAnalysis} from '#composite'; import {filterReferenceErrors, reportDuplicateDirectories} @@ -46,7 +47,7 @@ import {bindFind, getAllFindSpecs} from '#find'; import {processLanguageFile, watchLanguageFile, internalDefaultStringsFile} from '#language'; import {isMain, traverse} from '#node-utils'; -import {empty, showAggregate, withEntries} from '#sugar'; +import {empty, withEntries} from '#sugar'; import {generateURLs, urlSpec} from '#urls'; import {sortByName} from '#wiki-data'; import {linkWikiDataArrays, loadAndProcessDataDocuments, sortWikiDataArrays} diff --git a/src/util/aggregate.js b/src/util/aggregate.js new file mode 100644 index 00000000..c5d4198f --- /dev/null +++ b/src/util/aggregate.js @@ -0,0 +1,646 @@ +import {colors} from './cli.js'; +import {empty, typeAppearance} from './sugar.js'; + +// Utility function for providing useful interfaces to the JS AggregateError +// class. +// +// Generally, this works by returning a set of interfaces which operate on +// functions: wrap() takes a function and returns a new function which passes +// its arguments through and appends any resulting error to the internal error +// list; call() simplifies this process by wrapping the provided function and +// then calling it immediately. Once the process for which errors should be +// aggregated is complete, close() constructs and throws an AggregateError +// object containing all caught errors (or doesn't throw anything if there were +// no errors). +export function openAggregate({ + // Constructor to use, defaulting to the builtin AggregateError class. + // Anything passed here should probably extend from that! May be used for + // letting callers programatically distinguish between multiple aggregate + // errors. + // + // This should be provided using the aggregateThrows utility function. + [openAggregate.errorClassSymbol]: errorClass = AggregateError, + + // Optional human-readable message to describe the aggregate error, if + // constructed. + message = '', + + // Optional flag to indicate that this layer of the aggregate error isn't + // generally useful outside of developer debugging purposes - it will be + // skipped by default when using showAggregate, showing contained errors + // inline with other children of this aggregate's parent. + // + // If set to 'single', it'll be hidden only if there's a single error in the + // aggregate (so it's not grouping multiple errors together). + translucent = false, + + // Value to return when a provided function throws an error. If this is a + // function, it will be called with the arguments given to the function. + // (This is primarily useful when wrapping a function and then providing it + // to another utility, e.g. array.map().) + returnOnFail = null, +} = {}) { + const errors = []; + + const aggregate = {}; + + aggregate.wrap = + (fn) => + (...args) => { + try { + return fn(...args); + } catch (error) { + errors.push(error); + return typeof returnOnFail === 'function' + ? returnOnFail(...args) + : returnOnFail; + } + }; + + aggregate.wrapAsync = + (fn) => + (...args) => { + return fn(...args).then( + (value) => value, + (error) => { + errors.push(error); + return typeof returnOnFail === 'function' + ? returnOnFail(...args) + : returnOnFail; + } + ); + }; + + aggregate.push = (error) => { + errors.push(error); + }; + + aggregate.call = (fn, ...args) => { + return aggregate.wrap(fn)(...args); + }; + + aggregate.callAsync = (fn, ...args) => { + return aggregate.wrapAsync(fn)(...args); + }; + + aggregate.nest = (...args) => { + return aggregate.call(() => withAggregate(...args)); + }; + + aggregate.nestAsync = (...args) => { + return aggregate.callAsync(() => withAggregateAsync(...args)); + }; + + aggregate.map = (...args) => { + const parent = aggregate; + const {result, aggregate: child} = mapAggregate(...args); + parent.call(child.close); + return result; + }; + + aggregate.mapAsync = async (...args) => { + const parent = aggregate; + const {result, aggregate: child} = await mapAggregateAsync(...args); + parent.call(child.close); + return result; + }; + + aggregate.filter = (...args) => { + const parent = aggregate; + const {result, aggregate: child} = filterAggregate(...args); + parent.call(child.close); + return result; + }; + + aggregate.throws = aggregateThrows; + + aggregate.close = () => { + if (errors.length) { + const error = Reflect.construct(errorClass, [errors, message]); + + if (translucent) { + error[Symbol.for('hsmusic.aggregate.translucent')] = translucent; + } + + throw error; + } + }; + + return aggregate; +} + +openAggregate.errorClassSymbol = Symbol('error class'); + +// Utility function for providing {errorClass} parameter to aggregate functions. +export function aggregateThrows(errorClass) { + return {[openAggregate.errorClassSymbol]: errorClass}; +} + +// Helper function for allowing both (fn, aggregateOpts) and (aggregateOpts, fn) +// in aggregate utilities. +function _reorganizeAggregateArguments(arg1, arg2) { + if (typeof arg1 === 'function') { + return {fn: arg1, opts: arg2 ?? {}}; + } else if (typeof arg2 === 'function') { + return {fn: arg2, opts: arg1 ?? {}}; + } else { + throw new Error(`Expected a function`); + } +} + +// Performs an ordinary array map with the given function, collating into a +// results array (with errored inputs filtered out) and an error aggregate. +// +// Optionally, override returnOnFail to disable filtering and map errored inputs +// to a particular output. +// +// Note the aggregate property is the result of openAggregate(), still unclosed; +// use aggregate.close() to throw the error. (This aggregate may be passed to a +// parent aggregate: `parent.call(aggregate.close)`!) +export function mapAggregate(array, arg1, arg2) { + const {fn, opts} = _reorganizeAggregateArguments(arg1, arg2); + return _mapAggregate('sync', null, array, fn, opts); +} + +export function mapAggregateAsync(array, arg1, arg2) { + const {fn, opts} = _reorganizeAggregateArguments(arg1, arg2); + const {promiseAll = Promise.all.bind(Promise), ...remainingOpts} = opts; + return _mapAggregate('async', promiseAll, array, fn, remainingOpts); +} + +// Helper function for mapAggregate which holds code common between sync and +// async versions. +export function _mapAggregate(mode, promiseAll, array, fn, aggregateOpts) { + const failureSymbol = Symbol(); + + const aggregate = openAggregate({ + returnOnFail: failureSymbol, + ...aggregateOpts, + }); + + if (mode === 'sync') { + const result = array + .map(aggregate.wrap(fn)) + .filter((value) => value !== failureSymbol); + return {result, aggregate}; + } else { + return promiseAll(array.map(aggregate.wrapAsync(fn))) + .then((values) => { + const result = values.filter((value) => value !== failureSymbol); + return {result, aggregate}; + }); + } +} + +// Performs an ordinary array filter with the given function, collating into a +// results array (with errored inputs filtered out) and an error aggregate. +// +// Optionally, override returnOnFail to disable filtering errors and map errored +// inputs to a particular output. +// +// As with mapAggregate, the returned aggregate property is not yet closed. +export function filterAggregate(array, arg1, arg2) { + const {fn, opts} = _reorganizeAggregateArguments(arg1, arg2); + return _filterAggregate('sync', null, array, fn, opts); +} + +export async function filterAggregateAsync(array, arg1, arg2) { + const {fn, opts} = _reorganizeAggregateArguments(arg1, arg2); + const {promiseAll = Promise.all.bind(Promise), ...remainingOpts} = opts; + return _filterAggregate('async', promiseAll, array, fn, remainingOpts); +} + +// Helper function for filterAggregate which holds code common between sync and +// async versions. +function _filterAggregate(mode, promiseAll, array, fn, aggregateOpts) { + const failureSymbol = Symbol(); + + const aggregate = openAggregate({ + returnOnFail: failureSymbol, + ...aggregateOpts, + }); + + function filterFunction(value) { + // Filter out results which match the failureSymbol, i.e. errored + // inputs. + if (value === failureSymbol) return false; + + // Always keep results which match the overridden returnOnFail + // value, if provided. + if (value === aggregateOpts.returnOnFail) return true; + + // Otherwise, filter according to the returned value of the wrapped + // function. + return value.output; + } + + function mapFunction(value) { + // Then turn the results back into their corresponding input, or, if + // provided, the overridden returnOnFail value. + return value === aggregateOpts.returnOnFail ? value : value.input; + } + + if (mode === 'sync') { + const result = array + .map(aggregate.wrap((input, index, array) => { + const output = fn(input, index, array); + return {input, output}; + })) + .filter(filterFunction) + .map(mapFunction); + + return {result, aggregate}; + } else { + return promiseAll( + array.map(aggregate.wrapAsync(async (input, index, array) => { + const output = await fn(input, index, array); + return {input, output}; + })) + ).then((values) => { + const result = values.filter(filterFunction).map(mapFunction); + + return {result, aggregate}; + }); + } +} + +// Totally sugar function for opening an aggregate, running the provided +// function with it, then closing the function and returning the result (if +// there's no throw). +export function withAggregate(arg1, arg2) { + const {fn, opts} = _reorganizeAggregateArguments(arg1, arg2); + return _withAggregate('sync', opts, fn); +} + +export function withAggregateAsync(arg1, arg2) { + const {fn, opts} = _reorganizeAggregateArguments(arg1, arg2); + return _withAggregate('async', opts, fn); +} + +export function _withAggregate(mode, aggregateOpts, fn) { + const aggregate = openAggregate(aggregateOpts); + + if (mode === 'sync') { + const result = fn(aggregate); + aggregate.close(); + return result; + } else { + return fn(aggregate).then((result) => { + aggregate.close(); + return result; + }); + } +} + +export const unhelpfulTraceLines = [ + /sugar/, + /node:/, + //, +]; + +export function getUsefulTraceLine(trace, {helpful, unhelpful}) { + if (!trace) return ''; + + for (const traceLine of trace.split('\n')) { + if (!traceLine.trim().startsWith('at')) { + continue; + } + + if (!empty(unhelpful)) { + if (unhelpful.some(regex => regex.test(traceLine))) { + continue; + } + } + + if (!empty(helpful)) { + for (const regex of helpful) { + const match = traceLine.match(regex); + + if (match) { + return match[1] ?? traceLine; + } + } + + continue; + } + + return traceLine; + } + + return ''; +} + +export function showAggregate(topError, { + pathToFileURL = f => f, + showTraces = true, + showTranslucent = showTraces, + print = true, +} = {}) { + const getTranslucency = error => + error[Symbol.for('hsmusic.aggregate.translucent')] ?? false; + + const determineCauseHelper = cause => { + if (!cause) { + return null; + } + + const translucency = getTranslucency(cause); + + if (!translucency) { + return cause; + } + + if (translucency === 'single') { + if (cause.errors?.length === 1) { + return determineCauseHelper(cause.errors[0]); + } else { + return cause; + } + } + + return determineCauseHelper(cause.cause); + }; + + const determineCause = error => + (showTranslucent + ? error.cause ?? null + : determineCauseHelper(error.cause)); + + const determineErrorsHelper = error => { + const translucency = getTranslucency(error); + + if (!translucency) { + return [error]; + } + + if (translucency === 'single' && error.errors?.length >= 2) { + return [error]; + } + + const errors = []; + + if (error.cause) { + errors.push(...determineErrorsHelper(error.cause)); + } + + if (error.errors) { + errors.push(...error.errors.flatMap(determineErrorsHelper)); + } + + return errors; + }; + + const determineErrors = error => + (showTranslucent + ? error.errors ?? null + : error.errors?.flatMap(determineErrorsHelper) ?? null); + + const flattenErrorStructure = (error, level = 0) => { + const cause = determineCause(error); + const errors = determineErrors(error); + + return { + level, + + kind: error.constructor.name, + message: error.message, + + trace: + (error[Symbol.for(`hsmusic.aggregate.traceFrom`)] + ? error[Symbol.for(`hsmusic.aggregate.traceFrom`)].stack + : error.stack), + + cause: + (cause + ? flattenErrorStructure(cause, level + 1) + : null), + + errors: + (errors + ? errors.map(error => flattenErrorStructure(error, level + 1)) + : null), + + options: { + alwaysTrace: + error[Symbol.for(`hsmusic.aggregate.alwaysTrace`)], + + helpfulTraceLines: + error[Symbol.for(`hsmusic.aggregate.helpfulTraceLines`)], + + unhelpfulTraceLines: + error[Symbol.for(`hsmusic.aggregate.unhelpfulTraceLines`)], + } + }; + }; + + const recursive = ({ + level, + kind, + message, + trace, + cause, + errors, + options: { + alwaysTrace, + helpfulTraceLines: ownHelpfulTraceLines, + unhelpfulTraceLines: ownUnhelpfulTraceLines, + }, + }, index, apparentSiblings) => { + const subApparentSiblings = + (cause && errors + ? [cause, ...errors] + : cause + ? [cause] + : errors + ? errors + : []); + + const anythingHasErrorsThisLayer = + apparentSiblings.some(({errors}) => !empty(errors)); + + const messagePart = + message || `(no message)`; + + const kindPart = + kind || `unnamed kind`; + + let headerPart = + (showTraces + ? `[${kindPart}] ${messagePart}` + : errors + ? `[${messagePart}]` + : anythingHasErrorsThisLayer + ? ` ${messagePart}` + : messagePart); + + if (showTraces || alwaysTrace) { + const traceLine = + getUsefulTraceLine(trace, { + unhelpful: + (ownUnhelpfulTraceLines + ? unhelpfulTraceLines.concat(ownUnhelpfulTraceLines) + : unhelpfulTraceLines), + + helpful: + (ownHelpfulTraceLines + ? ownHelpfulTraceLines + : null), + }); + + const tracePart = + (traceLine + ? '- ' + + traceLine + .trim() + .replace(/file:\/\/.*\.js/, (match) => pathToFileURL(match)) + : '(no stack trace)'); + + headerPart += ` ${colors.dim(tracePart)}`; + } + + const head1 = level % 2 === 0 ? '\u21aa' : colors.dim('\u21aa'); + const bar1 = ' '; + + const causePart = + (cause + ? recursive(cause, 0, subApparentSiblings) + .split('\n') + .map((line, i) => i === 0 ? ` ${head1} ${line}` : ` ${bar1} ${line}`) + .join('\n') + : ''); + + const head2 = level % 2 === 0 ? '\u257f' : colors.dim('\u257f'); + const bar2 = level % 2 === 0 ? '\u2502' : colors.dim('\u254e'); + + const errorsPart = + (errors + ? errors + .map((error, index) => recursive(error, index + 1, subApparentSiblings)) + .flatMap(str => str.split('\n')) + .map((line, i) => i === 0 ? ` ${head2} ${line}` : ` ${bar2} ${line}`) + .join('\n') + : ''); + + return [headerPart, errorsPart, causePart].filter(Boolean).join('\n'); + }; + + const structure = flattenErrorStructure(topError); + const message = recursive(structure, 0, [structure]); + + if (print) { + console.error(message); + } else { + return message; + } +} + +export function annotateError(error, ...callbacks) { + for (const callback of callbacks) { + error = callback(error) ?? error; + } + + return error; +} + +export function annotateErrorWithIndex(error, index) { + return Object.assign(error, { + [Symbol.for('hsmusic.annotateError.indexInSourceArray')]: + index, + + message: + `(${colors.yellow(`#${index + 1}`)}) ` + + error.message, + }); +} + +export function annotateErrorWithFile(error, file) { + return Object.assign(error, { + [Symbol.for('hsmusic.annotateError.file')]: + file, + + message: + error.message + + (error.message.includes('\n') ? '\n' : ' ') + + `(file: ${colors.bright(colors.blue(file))})`, + }); +} + +export function asyncAdaptiveDecorateError(fn, callback) { + if (typeof callback !== 'function') { + throw new Error(`Expected callback to be a function, got ${typeAppearance(callback)}`); + } + + const syncDecorated = function (...args) { + try { + return fn(...args); + } catch (caughtError) { + throw callback(caughtError, ...args); + } + }; + + const asyncDecorated = async function(...args) { + try { + return await fn(...args); + } catch (caughtError) { + throw callback(caughtError, ...args); + } + }; + + syncDecorated.async = asyncDecorated; + + return syncDecorated; +} + +export function decorateError(fn, callback) { + return asyncAdaptiveDecorateError(fn, callback); +} + +export function asyncDecorateError(fn, callback) { + return asyncAdaptiveDecorateError(fn, callback).async; +} + +export function decorateErrorWithAnnotation(fn, ...annotationCallbacks) { + return asyncAdaptiveDecorateError(fn, + (caughtError, ...args) => + annotateError(caughtError, + ...annotationCallbacks + .map(callback => error => callback(error, ...args)))); +} + +export function decorateErrorWithIndex(fn) { + return decorateErrorWithAnnotation(fn, + (caughtError, _value, index) => + annotateErrorWithIndex(caughtError, index)); +} + +export function decorateErrorWithCause(fn, cause) { + return asyncAdaptiveDecorateError(fn, + (caughtError) => + Object.assign(caughtError, {cause})); +} + +export function asyncDecorateErrorWithAnnotation(fn, ...annotationCallbacks) { + return decorateErrorWithAnnotation(fn, ...annotationCallbacks).async; +} + +export function asyncDecorateErrorWithIndex(fn) { + return decorateErrorWithIndex(fn).async; +} + +export function asyncDecorateErrorWithCause(fn, cause) { + return decorateErrorWithCause(fn, cause).async; +} + +export function conditionallySuppressError(conditionFn, callbackFn) { + return (...args) => { + try { + return callbackFn(...args); + } catch (error) { + if (conditionFn(error, ...args) === true) { + return; + } + + throw error; + } + }; +} diff --git a/src/util/html.js b/src/util/html.js index ddc0277a..49816990 100644 --- a/src/util/html.js +++ b/src/util/html.js @@ -2,8 +2,9 @@ import {inspect} from 'node:util'; +import {withAggregate} from '#aggregate'; import {colors} from '#cli'; -import {empty, typeAppearance, unique, withAggregate} from '#sugar'; +import {empty, typeAppearance, unique} from '#sugar'; import * as commonValidators from '#validators'; const { diff --git a/src/util/sugar.js b/src/util/sugar.js index 2a028f30..70749d8a 100644 --- a/src/util/sugar.js +++ b/src/util/sugar.js @@ -406,650 +406,6 @@ export function bindOpts(fn, bind) { bindOpts.bindIndex = Symbol(); -// Utility function for providing useful interfaces to the JS AggregateError -// class. -// -// Generally, this works by returning a set of interfaces which operate on -// functions: wrap() takes a function and returns a new function which passes -// its arguments through and appends any resulting error to the internal error -// list; call() simplifies this process by wrapping the provided function and -// then calling it immediately. Once the process for which errors should be -// aggregated is complete, close() constructs and throws an AggregateError -// object containing all caught errors (or doesn't throw anything if there were -// no errors). -export function openAggregate({ - // Constructor to use, defaulting to the builtin AggregateError class. - // Anything passed here should probably extend from that! May be used for - // letting callers programatically distinguish between multiple aggregate - // errors. - // - // This should be provided using the aggregateThrows utility function. - [openAggregate.errorClassSymbol]: errorClass = AggregateError, - - // Optional human-readable message to describe the aggregate error, if - // constructed. - message = '', - - // Optional flag to indicate that this layer of the aggregate error isn't - // generally useful outside of developer debugging purposes - it will be - // skipped by default when using showAggregate, showing contained errors - // inline with other children of this aggregate's parent. - // - // If set to 'single', it'll be hidden only if there's a single error in the - // aggregate (so it's not grouping multiple errors together). - translucent = false, - - // Value to return when a provided function throws an error. If this is a - // function, it will be called with the arguments given to the function. - // (This is primarily useful when wrapping a function and then providing it - // to another utility, e.g. array.map().) - returnOnFail = null, -} = {}) { - const errors = []; - - const aggregate = {}; - - aggregate.wrap = - (fn) => - (...args) => { - try { - return fn(...args); - } catch (error) { - errors.push(error); - return typeof returnOnFail === 'function' - ? returnOnFail(...args) - : returnOnFail; - } - }; - - aggregate.wrapAsync = - (fn) => - (...args) => { - return fn(...args).then( - (value) => value, - (error) => { - errors.push(error); - return typeof returnOnFail === 'function' - ? returnOnFail(...args) - : returnOnFail; - } - ); - }; - - aggregate.push = (error) => { - errors.push(error); - }; - - aggregate.call = (fn, ...args) => { - return aggregate.wrap(fn)(...args); - }; - - aggregate.callAsync = (fn, ...args) => { - return aggregate.wrapAsync(fn)(...args); - }; - - aggregate.nest = (...args) => { - return aggregate.call(() => withAggregate(...args)); - }; - - aggregate.nestAsync = (...args) => { - return aggregate.callAsync(() => withAggregateAsync(...args)); - }; - - aggregate.map = (...args) => { - const parent = aggregate; - const {result, aggregate: child} = mapAggregate(...args); - parent.call(child.close); - return result; - }; - - aggregate.mapAsync = async (...args) => { - const parent = aggregate; - const {result, aggregate: child} = await mapAggregateAsync(...args); - parent.call(child.close); - return result; - }; - - aggregate.filter = (...args) => { - const parent = aggregate; - const {result, aggregate: child} = filterAggregate(...args); - parent.call(child.close); - return result; - }; - - aggregate.throws = aggregateThrows; - - aggregate.close = () => { - if (errors.length) { - const error = Reflect.construct(errorClass, [errors, message]); - - if (translucent) { - error[Symbol.for('hsmusic.aggregate.translucent')] = translucent; - } - - throw error; - } - }; - - return aggregate; -} - -openAggregate.errorClassSymbol = Symbol('error class'); - -// Utility function for providing {errorClass} parameter to aggregate functions. -export function aggregateThrows(errorClass) { - return {[openAggregate.errorClassSymbol]: errorClass}; -} - -// Helper function for allowing both (fn, aggregateOpts) and (aggregateOpts, fn) -// in aggregate utilities. -function _reorganizeAggregateArguments(arg1, arg2) { - if (typeof arg1 === 'function') { - return {fn: arg1, opts: arg2 ?? {}}; - } else if (typeof arg2 === 'function') { - return {fn: arg2, opts: arg1 ?? {}}; - } else { - throw new Error(`Expected a function`); - } -} - -// Performs an ordinary array map with the given function, collating into a -// results array (with errored inputs filtered out) and an error aggregate. -// -// Optionally, override returnOnFail to disable filtering and map errored inputs -// to a particular output. -// -// Note the aggregate property is the result of openAggregate(), still unclosed; -// use aggregate.close() to throw the error. (This aggregate may be passed to a -// parent aggregate: `parent.call(aggregate.close)`!) -export function mapAggregate(array, arg1, arg2) { - const {fn, opts} = _reorganizeAggregateArguments(arg1, arg2); - return _mapAggregate('sync', null, array, fn, opts); -} - -export function mapAggregateAsync(array, arg1, arg2) { - const {fn, opts} = _reorganizeAggregateArguments(arg1, arg2); - const {promiseAll = Promise.all.bind(Promise), ...remainingOpts} = opts; - return _mapAggregate('async', promiseAll, array, fn, remainingOpts); -} - -// Helper function for mapAggregate which holds code common between sync and -// async versions. -export function _mapAggregate(mode, promiseAll, array, fn, aggregateOpts) { - const failureSymbol = Symbol(); - - const aggregate = openAggregate({ - returnOnFail: failureSymbol, - ...aggregateOpts, - }); - - if (mode === 'sync') { - const result = array - .map(aggregate.wrap(fn)) - .filter((value) => value !== failureSymbol); - return {result, aggregate}; - } else { - return promiseAll(array.map(aggregate.wrapAsync(fn))) - .then((values) => { - const result = values.filter((value) => value !== failureSymbol); - return {result, aggregate}; - }); - } -} - -// Performs an ordinary array filter with the given function, collating into a -// results array (with errored inputs filtered out) and an error aggregate. -// -// Optionally, override returnOnFail to disable filtering errors and map errored -// inputs to a particular output. -// -// As with mapAggregate, the returned aggregate property is not yet closed. -export function filterAggregate(array, arg1, arg2) { - const {fn, opts} = _reorganizeAggregateArguments(arg1, arg2); - return _filterAggregate('sync', null, array, fn, opts); -} - -export async function filterAggregateAsync(array, arg1, arg2) { - const {fn, opts} = _reorganizeAggregateArguments(arg1, arg2); - const {promiseAll = Promise.all.bind(Promise), ...remainingOpts} = opts; - return _filterAggregate('async', promiseAll, array, fn, remainingOpts); -} - -// Helper function for filterAggregate which holds code common between sync and -// async versions. -function _filterAggregate(mode, promiseAll, array, fn, aggregateOpts) { - const failureSymbol = Symbol(); - - const aggregate = openAggregate({ - returnOnFail: failureSymbol, - ...aggregateOpts, - }); - - function filterFunction(value) { - // Filter out results which match the failureSymbol, i.e. errored - // inputs. - if (value === failureSymbol) return false; - - // Always keep results which match the overridden returnOnFail - // value, if provided. - if (value === aggregateOpts.returnOnFail) return true; - - // Otherwise, filter according to the returned value of the wrapped - // function. - return value.output; - } - - function mapFunction(value) { - // Then turn the results back into their corresponding input, or, if - // provided, the overridden returnOnFail value. - return value === aggregateOpts.returnOnFail ? value : value.input; - } - - if (mode === 'sync') { - const result = array - .map(aggregate.wrap((input, index, array) => { - const output = fn(input, index, array); - return {input, output}; - })) - .filter(filterFunction) - .map(mapFunction); - - return {result, aggregate}; - } else { - return promiseAll( - array.map(aggregate.wrapAsync(async (input, index, array) => { - const output = await fn(input, index, array); - return {input, output}; - })) - ).then((values) => { - const result = values.filter(filterFunction).map(mapFunction); - - return {result, aggregate}; - }); - } -} - -// Totally sugar function for opening an aggregate, running the provided -// function with it, then closing the function and returning the result (if -// there's no throw). -export function withAggregate(arg1, arg2) { - const {fn, opts} = _reorganizeAggregateArguments(arg1, arg2); - return _withAggregate('sync', opts, fn); -} - -export function withAggregateAsync(arg1, arg2) { - const {fn, opts} = _reorganizeAggregateArguments(arg1, arg2); - return _withAggregate('async', opts, fn); -} - -export function _withAggregate(mode, aggregateOpts, fn) { - const aggregate = openAggregate(aggregateOpts); - - if (mode === 'sync') { - const result = fn(aggregate); - aggregate.close(); - return result; - } else { - return fn(aggregate).then((result) => { - aggregate.close(); - return result; - }); - } -} - -export const unhelpfulTraceLines = [ - /sugar/, - /node:/, - //, -]; - -export function getUsefulTraceLine(trace, {helpful, unhelpful}) { - if (!trace) return ''; - - for (const traceLine of trace.split('\n')) { - if (!traceLine.trim().startsWith('at')) { - continue; - } - - if (!empty(unhelpful)) { - if (unhelpful.some(regex => regex.test(traceLine))) { - continue; - } - } - - if (!empty(helpful)) { - for (const regex of helpful) { - const match = traceLine.match(regex); - - if (match) { - return match[1] ?? traceLine; - } - } - - continue; - } - - return traceLine; - } - - return ''; -} - -export function showAggregate(topError, { - pathToFileURL = f => f, - showTraces = true, - showTranslucent = showTraces, - print = true, -} = {}) { - const getTranslucency = error => - error[Symbol.for('hsmusic.aggregate.translucent')] ?? false; - - const determineCauseHelper = cause => { - if (!cause) { - return null; - } - - const translucency = getTranslucency(cause); - - if (!translucency) { - return cause; - } - - if (translucency === 'single') { - if (cause.errors?.length === 1) { - return determineCauseHelper(cause.errors[0]); - } else { - return cause; - } - } - - return determineCauseHelper(cause.cause); - }; - - const determineCause = error => - (showTranslucent - ? error.cause ?? null - : determineCauseHelper(error.cause)); - - const determineErrorsHelper = error => { - const translucency = getTranslucency(error); - - if (!translucency) { - return [error]; - } - - if (translucency === 'single' && error.errors?.length >= 2) { - return [error]; - } - - const errors = []; - - if (error.cause) { - errors.push(...determineErrorsHelper(error.cause)); - } - - if (error.errors) { - errors.push(...error.errors.flatMap(determineErrorsHelper)); - } - - return errors; - }; - - const determineErrors = error => - (showTranslucent - ? error.errors ?? null - : error.errors?.flatMap(determineErrorsHelper) ?? null); - - const flattenErrorStructure = (error, level = 0) => { - const cause = determineCause(error); - const errors = determineErrors(error); - - return { - level, - - kind: error.constructor.name, - message: error.message, - - trace: - (error[Symbol.for(`hsmusic.aggregate.traceFrom`)] - ? error[Symbol.for(`hsmusic.aggregate.traceFrom`)].stack - : error.stack), - - cause: - (cause - ? flattenErrorStructure(cause, level + 1) - : null), - - errors: - (errors - ? errors.map(error => flattenErrorStructure(error, level + 1)) - : null), - - options: { - alwaysTrace: - error[Symbol.for(`hsmusic.aggregate.alwaysTrace`)], - - helpfulTraceLines: - error[Symbol.for(`hsmusic.aggregate.helpfulTraceLines`)], - - unhelpfulTraceLines: - error[Symbol.for(`hsmusic.aggregate.unhelpfulTraceLines`)], - } - }; - }; - - const recursive = ({ - level, - kind, - message, - trace, - cause, - errors, - options: { - alwaysTrace, - helpfulTraceLines: ownHelpfulTraceLines, - unhelpfulTraceLines: ownUnhelpfulTraceLines, - }, - }, index, apparentSiblings) => { - const subApparentSiblings = - (cause && errors - ? [cause, ...errors] - : cause - ? [cause] - : errors - ? errors - : []); - - const anythingHasErrorsThisLayer = - apparentSiblings.some(({errors}) => !empty(errors)); - - const messagePart = - message || `(no message)`; - - const kindPart = - kind || `unnamed kind`; - - let headerPart = - (showTraces - ? `[${kindPart}] ${messagePart}` - : errors - ? `[${messagePart}]` - : anythingHasErrorsThisLayer - ? ` ${messagePart}` - : messagePart); - - if (showTraces || alwaysTrace) { - const traceLine = - getUsefulTraceLine(trace, { - unhelpful: - (ownUnhelpfulTraceLines - ? unhelpfulTraceLines.concat(ownUnhelpfulTraceLines) - : unhelpfulTraceLines), - - helpful: - (ownHelpfulTraceLines - ? ownHelpfulTraceLines - : null), - }); - - const tracePart = - (traceLine - ? '- ' + - traceLine - .trim() - .replace(/file:\/\/.*\.js/, (match) => pathToFileURL(match)) - : '(no stack trace)'); - - headerPart += ` ${colors.dim(tracePart)}`; - } - - const head1 = level % 2 === 0 ? '\u21aa' : colors.dim('\u21aa'); - const bar1 = ' '; - - const causePart = - (cause - ? recursive(cause, 0, subApparentSiblings) - .split('\n') - .map((line, i) => i === 0 ? ` ${head1} ${line}` : ` ${bar1} ${line}`) - .join('\n') - : ''); - - const head2 = level % 2 === 0 ? '\u257f' : colors.dim('\u257f'); - const bar2 = level % 2 === 0 ? '\u2502' : colors.dim('\u254e'); - - const errorsPart = - (errors - ? errors - .map((error, index) => recursive(error, index + 1, subApparentSiblings)) - .flatMap(str => str.split('\n')) - .map((line, i) => i === 0 ? ` ${head2} ${line}` : ` ${bar2} ${line}`) - .join('\n') - : ''); - - return [headerPart, errorsPart, causePart].filter(Boolean).join('\n'); - }; - - const structure = flattenErrorStructure(topError); - const message = recursive(structure, 0, [structure]); - - if (print) { - console.error(message); - } else { - return message; - } -} - -export function annotateError(error, ...callbacks) { - for (const callback of callbacks) { - error = callback(error) ?? error; - } - - return error; -} - -export function annotateErrorWithIndex(error, index) { - return Object.assign(error, { - [Symbol.for('hsmusic.annotateError.indexInSourceArray')]: - index, - - message: - `(${colors.yellow(`#${index + 1}`)}) ` + - error.message, - }); -} - -export function annotateErrorWithFile(error, file) { - return Object.assign(error, { - [Symbol.for('hsmusic.annotateError.file')]: - file, - - message: - error.message + - (error.message.includes('\n') ? '\n' : ' ') + - `(file: ${colors.bright(colors.blue(file))})`, - }); -} - -export function asyncAdaptiveDecorateError(fn, callback) { - if (typeof callback !== 'function') { - throw new Error(`Expected callback to be a function, got ${typeAppearance(callback)}`); - } - - const syncDecorated = function (...args) { - try { - return fn(...args); - } catch (caughtError) { - throw callback(caughtError, ...args); - } - }; - - const asyncDecorated = async function(...args) { - try { - return await fn(...args); - } catch (caughtError) { - throw callback(caughtError, ...args); - } - }; - - syncDecorated.async = asyncDecorated; - - return syncDecorated; -} - -export function decorateError(fn, callback) { - return asyncAdaptiveDecorateError(fn, callback); -} - -export function asyncDecorateError(fn, callback) { - return asyncAdaptiveDecorateError(fn, callback).async; -} - -export function decorateErrorWithAnnotation(fn, ...annotationCallbacks) { - return asyncAdaptiveDecorateError(fn, - (caughtError, ...args) => - annotateError(caughtError, - ...annotationCallbacks - .map(callback => error => callback(error, ...args)))); -} - -export function decorateErrorWithIndex(fn) { - return decorateErrorWithAnnotation(fn, - (caughtError, _value, index) => - annotateErrorWithIndex(caughtError, index)); -} - -export function decorateErrorWithCause(fn, cause) { - return asyncAdaptiveDecorateError(fn, - (caughtError) => - Object.assign(caughtError, {cause})); -} - -export function asyncDecorateErrorWithAnnotation(fn, ...annotationCallbacks) { - return decorateErrorWithAnnotation(fn, ...annotationCallbacks).async; -} - -export function asyncDecorateErrorWithIndex(fn) { - return decorateErrorWithIndex(fn).async; -} - -export function asyncDecorateErrorWithCause(fn, cause) { - return decorateErrorWithCause(fn, cause).async; -} - -export function conditionallySuppressError(conditionFn, callbackFn) { - return (...args) => { - try { - return callbackFn(...args); - } catch (error) { - if (conditionFn(error, ...args) === true) { - return; - } - - throw error; - } - }; -} - // Delicious function annotations, such as: // // (*bound) soWeAreBackInTheMine diff --git a/test/lib/content-function.js b/test/lib/content-function.js index aa8c66cb..7bc62139 100644 --- a/test/lib/content-function.js +++ b/test/lib/content-function.js @@ -4,12 +4,13 @@ import {inspect} from 'node:util'; import chroma from 'chroma-js'; +import {showAggregate} from '#aggregate'; import {getColors} from '#colors'; import {quickLoadContentDependencies} from '#content-dependencies'; import {quickEvaluate} from '#content-function'; import * as html from '#html'; import {internalDefaultStringsFile, processLanguageFile} from '#language'; -import {empty, showAggregate} from '#sugar'; +import {empty} from '#sugar'; import {generateURLs, thumb, urlSpec} from '#urls'; import mock from './generic-mock.js'; -- cgit 1.3.0-6-gf8a5