« get me outta code hell

hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
path: root/src/data/composite.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/data/composite.js')
-rw-r--r--src/data/composite.js535
1 files changed, 413 insertions, 122 deletions
diff --git a/src/data/composite.js b/src/data/composite.js
index 33d69a68..8ac906c7 100644
--- a/src/data/composite.js
+++ b/src/data/composite.js
@@ -17,7 +17,7 @@ const _valueIntoToken = shape =>
    : typeof value === 'string'
       ? Symbol.for(`hsmusic.composite.${shape}:${value}`)
       : {
-          symbol: Symbol.for(`hsmusic.composite.input`),
+          symbol: Symbol.for(`hsmusic.composite.${shape.split('.')[0]}`),
           shape,
           value,
         });
@@ -36,6 +36,10 @@ input.updateValue = _valueIntoToken('input.updateValue');
 input.staticDependency = _valueIntoToken('input.staticDependency');
 input.staticValue = _valueIntoToken('input.staticValue');
 
+// Only valid in positional inputs. This is replaced with
+// equivalent input.value() token in prepared inputs.
+export const V = _valueIntoToken('V');
+
 function isInputToken(token) {
   if (token === null) {
     return false;
@@ -48,54 +52,58 @@ function isInputToken(token) {
   }
 }
 
+function isConciseInputToken(token) {
+  if (token === null) {
+    return false;
+  } else if (typeof token === 'object') {
+    return token.symbol === Symbol.for('hsmusic.composite.V');
+  } else if (typeof token === 'symbol') {
+    return token.description.startsWith('hsmusic.composite.V');
+  } else {
+    return false;
+  }
+}
+
 function getInputTokenShape(token) {
-  if (!isInputToken(token)) {
+  if (!isInputToken(token) && !isConciseInputToken(token)) {
     throw new TypeError(`Expected an input token, got ${typeAppearance(token)}`);
   }
 
   if (typeof token === 'object') {
     return token.shape;
   } else {
-    return token.description.match(/hsmusic\.composite\.(input.*?)(:|$)/)[1];
+    return token.description.match(/hsmusic\.composite\.(input.*?|V)(:|$)/)[1];
   }
 }
 
 function getInputTokenValue(token) {
-  if (!isInputToken(token)) {
+  if (!isInputToken(token) && !isConciseInputToken(token)) {
     throw new TypeError(`Expected an input token, got ${typeAppearance(token)}`);
   }
 
   if (typeof token === 'object') {
     return token.value;
   } else {
-    return token.description.match(/hsmusic\.composite\.input.*?:(.*)/)?.[1] ?? null;
+    return token.description.match(/hsmusic\.composite\.(?:input.*?|V):(.*)/)?.[1] ?? null;
   }
 }
 
-function getStaticInputMetadata(inputOptions) {
+function getStaticInputMetadata(inputMapping) {
   const metadata = {};
 
-  for (const [name, token] of Object.entries(inputOptions)) {
-    if (typeof token === 'string') {
-      metadata[input.staticDependency(name)] = token;
-      metadata[input.staticValue(name)] = null;
-    } else if (isInputToken(token)) {
-      const tokenShape = getInputTokenShape(token);
-      const tokenValue = getInputTokenValue(token);
-
-      metadata[input.staticDependency(name)] =
-        (tokenShape === 'input.dependency'
-          ? tokenValue
-          : null);
-
-      metadata[input.staticValue(name)] =
-        (tokenShape === 'input.value'
-          ? tokenValue
-          : null);
-    } else {
-      metadata[input.staticDependency(name)] = null;
-      metadata[input.staticValue(name)] = null;
-    }
+  for (const [name, token] of Object.entries(inputMapping)) {
+    const tokenShape = getInputTokenShape(token);
+    const tokenValue = getInputTokenValue(token);
+
+    metadata[input.staticDependency(name)] =
+      (tokenShape === 'input.dependency'
+        ? tokenValue
+        : null);
+
+    metadata[input.staticValue(name)] =
+      (tokenShape === 'input.value'
+        ? tokenValue
+        : null);
   }
 
   return metadata;
@@ -222,76 +230,161 @@ export function templateCompositeFrom(description) {
       ? Object.keys(description.inputs)
       : []);
 
-  const instantiate = (inputOptions = {}) => {
+  const optionalInputNames =
+    expectedInputNames.filter(name => {
+      const inputDescription = getInputTokenValue(description.inputs[name]);
+      if (!inputDescription) return false;
+      if ('defaultValue' in inputDescription) return true;
+      if ('defaultDependency' in inputDescription) return true;
+      return false;
+    });
+
+  const instantiate = (...args) => {
+    const preparedInputs = {};
+
     withAggregate({message: `Errors in input options passed to ${compositionName}`}, ({push}) => {
-      const providedInputNames = Object.keys(inputOptions);
+      const [positionalInputs, namedInputs] =
+        (typeof args.at(-1) === 'object' &&
+         !isInputToken(args.at(-1)) &&
+         !isConciseInputToken(args.at(-1))
+          ? [args.slice(0, -1), args.at(-1)]
+          : [args, {}]);
 
-      const misplacedInputNames =
-        providedInputNames
-          .filter(name => !expectedInputNames.includes(name));
+      const expresslyProvidedInputNames = Object.keys(namedInputs);
+      const positionallyProvidedInputNames = [];
+      const remainingInputNames = expectedInputNames.slice();
 
-      const missingInputNames =
-        expectedInputNames
-          .filter(name => !providedInputNames.includes(name))
-          .filter(name => {
-            const inputDescription = getInputTokenValue(description.inputs[name]);
-            if (!inputDescription) return true;
-            if ('defaultValue' in inputDescription) return false;
-            if ('defaultDependency' in inputDescription) return false;
-            return true;
-          });
+      const apparentInputRoutes = {};
 
-      const wrongTypeInputNames = [];
+      const wrongTypeInputPositions = [];
+      const namedAndPositionalConflictInputPositions = [];
 
-      const expectedStaticValueInputNames = [];
-      const expectedStaticDependencyInputNames = [];
-      const expectedValueProvidingTokenInputNames = [];
+      const maximumPositionalInputs = expectedInputNames.length;
+      const lastPossiblePositionalIndex = maximumPositionalInputs - 1;
 
-      const validateFailedErrors = [];
+      for (const [index, value] of positionalInputs.entries()) {
+        if (!isInputToken(value) && !isConciseInputToken(value)) {
+          if (typeof value === 'object' && value !== null) {
+            wrongTypeInputPositions.push(index);
+            continue;
+          } else if (typeof value !== 'string') {
+            wrongTypeInputPositions.push(index);
+            continue;
+          }
+        }
+
+        if (index > lastPossiblePositionalIndex) {
+          continue;
+        }
+
+        const correspondingName = remainingInputNames.shift();
+        if (expresslyProvidedInputNames.includes(correspondingName)) {
+          namedAndPositionalConflictInputPositions.push(index);
+          continue;
+        }
+
+        preparedInputs[correspondingName] =
+          (isConciseInputToken(value)
+            ? input.value(getInputTokenValue(value))
+            : value);
+
+        apparentInputRoutes[correspondingName] = `${correspondingName} (i = ${index})`;
+        positionallyProvidedInputNames.push(correspondingName);
+      }
+
+      const misplacedInputNames =
+        expresslyProvidedInputNames
+          .filter(name => !expectedInputNames.includes(name));
+
+      const wrongTypeInputNames = [];
+      const skippedInputNames = [];
+      const passedInputNames = [];
+      const nameProvidedInputNames = [];
 
-      for (const [name, value] of Object.entries(inputOptions)) {
+      for (const [name, value] of Object.entries(namedInputs)) {
         if (misplacedInputNames.includes(name)) {
           continue;
         }
 
+        // Concise input tokens, V(...), end up here too.
         if (typeof value !== 'string' && !isInputToken(value)) {
           wrongTypeInputNames.push(name);
           continue;
         }
 
+        const index = remainingInputNames.indexOf(name);
+        if (index === 0) {
+          passedInputNames.push(remainingInputNames.shift());
+        } else if (index === -1) {
+          // This input isn't misplaced, so it's an expected name,
+          // and SHOULD be in the list of remaining input names.
+          // But it isn't if it itself has already been skipped!
+          // And if so, that's already been tracked.
+        } else {
+          const til = remainingInputNames.splice(0, index);
+          passedInputNames.push(...til);
+
+          const skipped =
+            til.filter(name =>
+              !optionalInputNames.includes(name) ||
+              expresslyProvidedInputNames.includes(name));
+
+          if (!empty(skipped)) {
+            skippedInputNames.push({skipped, before: name});
+          }
+
+          passedInputNames.push(remainingInputNames.shift());
+        }
+
+        preparedInputs[name] = value;
+        apparentInputRoutes[name] = name;
+        nameProvidedInputNames.push(name);
+      }
+
+      const totalProvidedInputNames =
+        unique([
+          ...expresslyProvidedInputNames,
+          ...positionallyProvidedInputNames,
+        ]);
+
+      const missingInputNames =
+        expectedInputNames
+          .filter(name => !totalProvidedInputNames.includes(name))
+          .filter(name => !optionalInputNames.includes(name));
+
+      const expectedStaticValueInputNames = [];
+      const expectedStaticDependencyInputNames = [];
+      const expectedValueProvidingTokenInputNames = [];
+      const validateFailedErrors = [];
+
+      for (const [name, value] of Object.entries(preparedInputs)) {
         const descriptionShape = getInputTokenShape(description.inputs[name]);
 
         const tokenShape = (isInputToken(value) ? getInputTokenShape(value) : null);
         const tokenValue = (isInputToken(value) ? getInputTokenValue(value) : null);
 
-        switch (descriptionShape) {
-          case'input.staticValue':
-            if (tokenShape !== 'input.value') {
-              expectedStaticValueInputNames.push(name);
-              continue;
-            }
-            break;
-
-          case 'input.staticDependency':
-            if (typeof value !== 'string' && tokenShape !== 'input.dependency') {
-              expectedStaticDependencyInputNames.push(name);
-              continue;
-            }
-            break;
-
-          case 'input':
-            if (typeof value !== 'string' && ![
-              'input',
-              'input.value',
-              'input.dependency',
-              'input.myself',
-              'input.thisProperty',
-              'input.updateValue',
-            ].includes(tokenShape)) {
-              expectedValueProvidingTokenInputNames.push(name);
-              continue;
-            }
-            break;
+        if (descriptionShape === 'input.staticValue') {
+          if (tokenShape !== 'input.value') {
+            expectedStaticValueInputNames.push(name);
+            continue;
+          }
+        } else if (descriptionShape === 'input.staticDependency') {
+          if (typeof value !== 'string' && tokenShape !== 'input.dependency') {
+            expectedStaticDependencyInputNames.push(name);
+            continue;
+          }
+        } else {
+          if (typeof value !== 'string' && ![
+            'input',
+            'input.value',
+            'input.dependency',
+            'input.myself',
+            'input.thisProperty',
+            'input.updateValue',
+          ].includes(tokenShape)) {
+            expectedValueProvidingTokenInputNames.push(name);
+            continue;
+          }
         }
 
         if (tokenShape === 'input.value') {
@@ -304,6 +397,11 @@ export function templateCompositeFrom(description) {
         }
       }
 
+      const inputAppearance = name =>
+        (isInputToken(preparedInputs[name])
+          ? `${getInputTokenShape(preparedInputs[name])}() call`
+          : `dependency name`);
+
       if (!empty(misplacedInputNames)) {
         push(new Error(`Unexpected input names: ${misplacedInputNames.join(', ')}`));
       }
@@ -312,29 +410,53 @@ export function templateCompositeFrom(description) {
         push(new Error(`Required these inputs: ${missingInputNames.join(', ')}`));
       }
 
-      const inputAppearance = name =>
-        (isInputToken(inputOptions[name])
-          ? `${getInputTokenShape(inputOptions[name])}() call`
-          : `dependency name`);
+      if (positionalInputs.length > maximumPositionalInputs) {
+        push(new Error(`Too many positional inputs provided (${positionalInputs.length} > ${maximumPositionalInputs}`));
+      }
+
+      for (const index of namedAndPositionalConflictInputPositions) {
+        const conflictingName = positionalInputNames[index];
+        push(new Error(`${name}: Provided as both named and positional (i = ${index}) input`));
+      }
+
+      for (const {skipped, before} of skippedInputNames) {
+        push(new Error(`Expected ${skipped.join(', ')} before ${before}`));
+      }
 
       for (const name of expectedStaticDependencyInputNames) {
-        const appearance = inputAppearance(name);
-        push(new Error(`${name}: Expected dependency name, got ${appearance}`));
+        const appearance = inputAppearance(preparedInputs[name]);
+        const route = apparentInputRoutes[name];
+        push(new Error(`${route}: Expected dependency name, got ${appearance}`));
       }
 
       for (const name of expectedStaticValueInputNames) {
-        const appearance = inputAppearance(name)
-        push(new Error(`${name}: Expected input.value() call, got ${appearance}`));
+        const appearance = inputAppearance(preparedInputs[name]);
+        const route = apparentInputRoutes[name];
+        push(new Error(`${route}: Expected input.value() call, got ${appearance}`));
       }
 
       for (const name of expectedValueProvidingTokenInputNames) {
-        const appearance = getInputTokenShape(inputOptions[name]);
-        push(new Error(`${name}: Expected dependency name or value-providing input() call, got ${appearance}`));
+        const appearance = getInputTokenShape(preparedInputs[name]);
+        const route = apparentInputRoutes[name];
+        push(new Error(`${route}: Expected dependency name or value-providing input() call, got ${appearance}`));
       }
 
       for (const name of wrongTypeInputNames) {
-        const type = typeAppearance(inputOptions[name]);
-        push(new Error(`${name}: Expected dependency name or input() call, got ${type}`));
+        if (isConciseInputToken(namedInputs[name])) {
+          push(new Error(`${name}: Use input.value() instead of V() for named inputs`));
+        } else {
+          const type = typeAppearance(namedInputs[name]);
+          push(new Error(`${name}: Expected dependency name or input() call, got ${type}`));
+        }
+      }
+
+      for (const index of wrongTypeInputPositions) {
+        const type = typeAppearance(positionalInputs[index]);
+        if (type === 'object') {
+          push(new Error(`i = ${index}: Got object - all named dependencies must be passed together, in last argument`));
+        } else {
+          push(new Error(`i = ${index}: Expected dependency name or input() call, got ${type}`));
+        }
       }
 
       for (const error of validateFailedErrors) {
@@ -342,7 +464,29 @@ export function templateCompositeFrom(description) {
       }
     });
 
-    const inputMetadata = getStaticInputMetadata(inputOptions);
+    const inputMapping = {};
+    if ('inputs' in description) {
+      for (const [name, token] of Object.entries(description.inputs)) {
+        const tokenValue = getInputTokenValue(token);
+        if (name in preparedInputs) {
+          if (typeof preparedInputs[name] === 'string') {
+            inputMapping[name] = input.dependency(preparedInputs[name]);
+          } else {
+            // This is always an input token, since only a string or
+            // an input token is a valid input option (asserted above).
+            inputMapping[name] = preparedInputs[name];
+          }
+        } else if (tokenValue.defaultValue) {
+          inputMapping[name] = input.value(tokenValue.defaultValue);
+        } else if (tokenValue.defaultDependency) {
+          inputMapping[name] = input.dependency(tokenValue.defaultDependency);
+        } else {
+          inputMapping[name] = input.value(null);
+        }
+      }
+    }
+
+    const inputMetadata = getStaticInputMetadata(inputMapping);
 
     const expectedOutputNames =
       (Array.isArray(description.outputs)
@@ -414,25 +558,6 @@ export function templateCompositeFrom(description) {
         }
 
         if ('inputs' in description) {
-          const inputMapping = {};
-
-          for (const [name, token] of Object.entries(description.inputs)) {
-            const tokenValue = getInputTokenValue(token);
-            if (name in inputOptions) {
-              if (typeof inputOptions[name] === 'string') {
-                inputMapping[name] = input.dependency(inputOptions[name]);
-              } else {
-                inputMapping[name] = inputOptions[name];
-              }
-            } else if (tokenValue.defaultValue) {
-              inputMapping[name] = input.value(tokenValue.defaultValue);
-            } else if (tokenValue.defaultDependency) {
-              inputMapping[name] = input.dependency(tokenValue.defaultDependency);
-            } else {
-              inputMapping[name] = input.value(null);
-            }
-          }
-
           finalDescription.inputMapping = inputMapping;
           finalDescription.inputDescriptions = description.inputs;
         }
@@ -531,7 +656,10 @@ export function compositeFrom(description) {
         ? compositeFrom(step.toResolvedComposition())
         : step));
 
-  const inputMetadata = getStaticInputMetadata(description.inputMapping ?? {});
+  const inputMetadata =
+    (description.inputMapping
+      ? getStaticInputMetadata(description.inputMapping)
+      : {});
 
   function _mapDependenciesToOutputs(providedDependencies) {
     if (!description.outputs) {
@@ -715,8 +843,9 @@ export function compositeFrom(description) {
       stepExposeDescriptions
         .flatMap(expose => expose?.dependencies ?? [])
         .map(dependency => {
-          if (typeof dependency === 'string')
+          if (typeof dependency === 'string') {
             return (dependency.startsWith('#') ? null : dependency);
+          }
 
           const tokenShape = getInputTokenShape(dependency);
           const tokenValue = getInputTokenValue(dependency);
@@ -758,6 +887,9 @@ export function compositeFrom(description) {
     anyStepsUseUpdateValue ||
     anyStepsUpdate;
 
+  const stepsFirstTimeCalling =
+    Array.from({length: steps.length}).fill(true);
+
   const stepEntries = stitchArrays({
     step: steps,
     stepComposes: stepsCompose,
@@ -885,7 +1017,7 @@ export function compositeFrom(description) {
           }
         });
 
-    withAggregate({message: `Errors in input values provided to ${compositionName}`}, ({push}) => {
+    withAggregate({message: `Errors validating input values provided to ${compositionName}`}, ({push}) => {
       for (const {dynamic, name, value, description} of stitchArrays({
         dynamic: inputsMayBeDynamicValue,
         name: inputNames,
@@ -895,9 +1027,10 @@ export function compositeFrom(description) {
         if (!dynamic) continue;
         try {
           validateInputValue(value, description);
-        } catch (error) {
-          error.message = `${name}: ${error.message}`;
-          push(error);
+        } catch (caughtError) {
+          push(new Error(
+            `Error validating input ${name}: ` + inspect(value, {compact: true}),
+            {cause: caughtError}));
         }
       }
     });
@@ -977,8 +1110,16 @@ export function compositeFrom(description) {
           (expectingTransform
             ? {[input.updateValue()]: valueSoFar}
             : {}),
-        [input.myself()]: initialDependencies?.['this'] ?? null,
-        [input.thisProperty()]: initialDependencies?.['thisProperty'] ?? null,
+
+        [input.myself()]:
+          (initialDependencies && Object.hasOwn(initialDependencies, 'this')
+            ? initialDependencies.this
+            : null),
+
+        [input.thisProperty()]:
+          (initialDependencies && Object.hasOwn(initialDependencies, 'thisProperty')
+            ? initialDependencies.thisProperty
+            : null),
       };
 
       const selectDependencies =
@@ -1028,7 +1169,123 @@ export function compositeFrom(description) {
       const naturalEvaluate = () => {
         const [name, ...argsLayout] = getExpectedEvaluation();
 
-        let args;
+        let args = argsLayout;
+
+        let effectiveDependencies;
+        let reviewAccessedDependencies;
+
+        if (stepsFirstTimeCalling[i]) {
+          const expressedDependencies =
+            selectDependencies;
+
+          const remainingDependencies =
+            new Set(expressedDependencies);
+
+          const unavailableDependencies = [];
+          const accessedDependencies = [];
+
+          effectiveDependencies =
+            new Proxy(filteredDependencies, {
+              get(target, key) {
+                accessedDependencies.push(key);
+                remainingDependencies.delete(key);
+
+                const value = target[key];
+
+                if (value === undefined) {
+                  unavailableDependencies.push(key);
+                }
+
+                return value;
+              },
+            });
+
+          reviewAccessedDependencies = () => {
+            const topAggregate =
+              openAggregate({
+                message: `Errors in accessed dependencies`,
+              });
+
+            const showDependency = dependency =>
+              (isInputToken(dependency)
+                ? getInputTokenShape(dependency) +
+                  `(` +
+                  inspect(getInputTokenValue(dependency), {compact: true}) +
+                  ')'
+                : dependency.toString());
+
+            let anyErrors = false;
+
+            for (const dependency of remainingDependencies) {
+              topAggregate.push(new Error(
+                `Expected to access ${showDependency(dependency)}`));
+
+              anyErrors = true;
+            }
+
+            for (const dependency of unavailableDependencies) {
+              const subAggregate =
+                openAggregate({
+                  message:
+                    `Accessed ${showDependency(dependency)}, which is unavailable`,
+                });
+
+              let reason = false;
+
+              if (!expressedDependencies.includes(dependency)) {
+                subAggregate.push(new Error(
+                  `Missing from step's expressed dependencies`));
+                reason = true;
+              }
+
+              if (filterableDependencies[dependency] === undefined) {
+                subAggregate.push(
+                  new Error(
+                    `Not available` +
+                    (isInputToken(dependency)
+                      ? ` in input()-type dependencies`
+                   : dependency.startsWith('#')
+                      ? ` in local dependencies`
+                      : ` on object dependencies`)));
+                reason = true;
+              }
+
+              if (!reason) {
+                subAggregate.push(new Error(
+                  `Not sure why this is unavailable, sorry!`));
+              }
+
+              topAggregate.call(subAggregate.close);
+
+              anyErrors = true;
+            }
+
+            if (anyErrors) {
+              topAggregate.push(new Error(
+                `These dependencies, in total, were accessed:` +
+                (empty(accessedDependencies)
+                  ? ` (none)`
+               : accessedDependencies.length === 1
+                  ? showDependency(accessedDependencies[0])
+                  : `\n` +
+                    accessedDependencies
+                      .map(showDependency)
+                      .map(line => `  - ${line}`)
+                      .join('\n'))));
+            }
+
+            topAggregate.close();
+          };
+        } else {
+          effectiveDependencies = filteredDependencies;
+          reviewAccessedDependencies = null;
+        }
+
+        args =
+          args.map(arg =>
+            (arg === filteredDependencies
+              ? effectiveDependencies
+              : arg));
 
         if (stepComposes) {
           let continuation;
@@ -1037,17 +1294,50 @@ export function compositeFrom(description) {
             _prepareContinuation(callingTransformForThisStep));
 
           args =
-            argsLayout.map(arg =>
+            args.map(arg =>
               (arg === continuationSymbol
                 ? continuation
                 : arg));
         } else {
           args =
-            argsLayout.filter(arg => arg !== continuationSymbol);
+            args.filter(arg => arg !== continuationSymbol);
         }
 
-        return expose[name](...args);
-      }
+        let stepError;
+        try {
+          return expose[name](...args);
+        } catch (error) {
+          stepError = error;
+        } finally {
+          stepsFirstTimeCalling[i] = false;
+
+          let reviewError;
+          if (reviewAccessedDependencies) {
+            try {
+              reviewAccessedDependencies();
+            } catch (error) {
+              reviewError = error;
+            }
+          }
+
+          const stepPart =
+            `step ${i+1}` +
+            (isBase
+              ? ` (base)`
+              : ` of ${steps.length}`) +
+            (step.annotation ? `, ${step.annotation}` : ``);
+
+          if (stepError && reviewError) {
+            throw new AggregateError(
+              [stepError, reviewError],
+              `Errors in ${stepPart}`);
+          } else if (stepError || reviewError) {
+            throw new Error(
+              `Error in ${stepPart}`,
+              {cause: stepError || reviewError});
+          }
+        }
+      };
 
       switch (step.cache) {
         // Warning! Highly WIP!
@@ -1223,6 +1513,7 @@ export function compositeFrom(description) {
           `Error computing composition` +
           (annotation ? ` ${annotation}` : ''));
         error.cause = thrownError;
+        error[Symbol.for('hsmusic.aggregate.translucent')] = true;
         throw error;
       }
     };
@@ -1257,7 +1548,7 @@ export function compositeFrom(description) {
 
 export function displayCompositeCacheAnalysis() {
   const showTimes = (cache, key) => {
-    const times = cache.times[key].slice().sort();
+    const times = cache.times[key].toSorted();
 
     const all = times;
     const worst10pc = times.slice(-times.length / 10);