« get me outta code hell

hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
path: root/src/find.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/find.js')
-rw-r--r--src/find.js431
1 files changed, 302 insertions, 129 deletions
diff --git a/src/find.js b/src/find.js
index afe34dd9..e7f5cda1 100644
--- a/src/find.js
+++ b/src/find.js
@@ -1,8 +1,19 @@
 import {inspect} from 'node:util';
 
 import {colors, logWarn} from '#cli';
+import {compareObjects, stitchArrays, typeAppearance} from '#sugar';
 import thingConstructors from '#things';
-import {typeAppearance} from '#sugar';
+import {isFunction, validateArrayItems} from '#validators';
+
+import * as fr from './find-reverse.js';
+
+import {
+  tokenKey as findTokenKey,
+  boundData as boundFindData,
+  boundOptions as boundFindOptions,
+} from './find-reverse.js';
+
+export {findTokenKey, boundFindData, boundFindOptions};
 
 function warnOrThrow(mode, message) {
   if (mode === 'error') {
@@ -16,34 +27,22 @@ function warnOrThrow(mode, message) {
   return null;
 }
 
-export function processAllAvailableMatches(data, {
+export const keyRefRegex =
+  new RegExp(String.raw`^(?:(?<key>[a-z-]*):(?=\S))?(?<ref>.*)$`);
+
+export function processAvailableMatchesByName(data, {
   include = _thing => true,
 
   getMatchableNames = thing =>
-    (Object.hasOwn(thing, 'name')
+    (thing.constructor.hasPropertyDescriptor('name')
       ? [thing.name]
       : []),
 
-  getMatchableDirectories = thing =>
-    (Object.hasOwn(thing, 'directory')
-      ? [thing.directory]
-      : [null]),
-} = {}) {
-  const byName = Object.create(null);
-  const byDirectory = Object.create(null);
-  const multipleNameMatches = Object.create(null);
-
+  results = Object.create(null),
+  multipleNameMatches = Object.create(null),
+}) {
   for (const thing of data) {
-    if (!include(thing)) continue;
-
-    for (const directory of getMatchableDirectories(thing)) {
-      if (typeof directory !== 'string') {
-        logWarn`Unexpected ${typeAppearance(directory)} returned in directories for ${inspect(thing)}`;
-        continue;
-      }
-
-      byDirectory[directory] = thing;
-    }
+    if (!include(thing, thingConstructors)) continue;
 
     for (const name of getMatchableNames(thing)) {
       if (typeof name !== 'string') {
@@ -53,23 +52,137 @@ export function processAllAvailableMatches(data, {
 
       const normalizedName = name.toLowerCase();
 
-      if (normalizedName in byName) {
-        const alreadyMatchesByName = byName[normalizedName];
-        byName[normalizedName] = null;
+      if (normalizedName in results) {
         if (normalizedName in multipleNameMatches) {
           multipleNameMatches[normalizedName].push(thing);
         } else {
-          multipleNameMatches[normalizedName] = [alreadyMatchesByName, thing];
+          multipleNameMatches[normalizedName] = [results[normalizedName], thing];
+          results[normalizedName] = null;
         }
       } else {
-        byName[normalizedName] = thing;
+        results[normalizedName] = thing;
       }
     }
   }
 
+  return {results, multipleNameMatches};
+}
+
+export function processAvailableMatchesByDirectory(data, {
+  include = _thing => true,
+
+  getMatchableDirectories = thing =>
+    (thing.constructor.hasPropertyDescriptor('directory')
+      ? [thing.directory]
+      : [null]),
+
+  results = Object.create(null),
+}) {
+  for (const thing of data) {
+    if (!include(thing, thingConstructors)) continue;
+
+    for (const directory of getMatchableDirectories(thing)) {
+      if (typeof directory !== 'string') {
+        logWarn`Unexpected ${typeAppearance(directory)} returned in directories for ${inspect(thing)}`;
+        continue;
+      }
+
+      results[directory] = thing;
+    }
+  }
+
+  return {results};
+}
+
+export function processAllAvailableMatches(data, spec) {
+  const {results: byName, multipleNameMatches} =
+    processAvailableMatchesByName(data, spec);
+
+  const {results: byDirectory} =
+    processAvailableMatchesByDirectory(data, spec);
+
   return {byName, byDirectory, multipleNameMatches};
 }
 
+function oopsMultipleNameMatches(mode, {
+  name,
+  normalizedName,
+  multipleNameMatches,
+}) {
+  return warnOrThrow(mode,
+    `Multiple matches for reference "${name}". Please resolve:\n` +
+    multipleNameMatches[normalizedName]
+      .map(match => `- ${inspect(match)}\n`)
+      .join('') +
+    `Returning null for this reference.`);
+}
+
+export function prepareMatchByName(mode, {byName, multipleNameMatches}) {
+  return (name) => {
+    const normalizedName = name.toLowerCase();
+    const match = byName[normalizedName];
+
+    if (match) {
+      return match;
+    } else if (multipleNameMatches[normalizedName]) {
+      return oopsMultipleNameMatches(mode, {
+        name,
+        normalizedName,
+        multipleNameMatches,
+      });
+    } else {
+      return null;
+    }
+  };
+}
+
+function oopsWrongReferenceType(mode, {
+  referenceType,
+  referenceTypes,
+}) {
+  return warnOrThrow(mode,
+    `Reference starts with "${referenceType}:", expected ` +
+    referenceTypes.map(type => `"${type}:"`).join(', '));
+}
+
+export function prepareMatchByDirectory(mode, {referenceTypes, byDirectory}) {
+  return (referenceType, directory) => {
+    if (!referenceTypes.includes(referenceType)) {
+      return oopsWrongReferenceType(mode, {
+        referenceType,
+        referenceTypes,
+      });
+    }
+
+    return byDirectory[directory];
+  };
+}
+
+function matchHelper(fullRef, mode, {
+  matchByDirectory = (_referenceType, _directory) => null,
+  matchByName = (_name) => null,
+}) {
+  const regexMatch = fullRef.match(keyRefRegex);
+  if (!regexMatch) {
+    return warnOrThrow(mode,
+      `Malformed link reference: "${fullRef}"`);
+  }
+
+  const {key: keyPart, ref: refPart} = regexMatch.groups;
+
+  const match =
+    (keyPart
+      ? matchByDirectory(keyPart, refPart)
+      : matchByName(refPart));
+
+  if (match) {
+    return match;
+  } else {
+    return warnOrThrow(mode,
+      `Didn't match anything for ${colors.bright(fullRef)}`);
+  }
+}
+
 function findHelper({
   referenceTypes,
 
@@ -77,9 +190,6 @@ function findHelper({
   getMatchableNames = undefined,
   getMatchableDirectories = undefined,
 }) {
-  const keyRefRegex =
-    new RegExp(String.raw`^(?:(${referenceTypes.join('|')}):(?=\S))?(.*)$`);
-
   // Note: This cache explicitly *doesn't* support mutable data arrays. If the
   // data array is modified, make sure it's actually a new array object, not
   // the original, or the cache here will break and act as though the data
@@ -92,6 +202,7 @@ function findHelper({
   // console.
   return (fullRef, data, {mode = 'warn'} = {}) => {
     if (!fullRef) return null;
+
     if (typeof fullRef !== 'string') {
       throw new TypeError(`Expected a string, got ${typeAppearance(fullRef)}`);
     }
@@ -112,42 +223,21 @@ function findHelper({
       cache.set(data, subcache);
     }
 
-    const regexMatch = fullRef.match(keyRefRegex);
-    if (!regexMatch) {
-      return warnOrThrow(mode,
-        `Malformed link reference: "${fullRef}"`);
-    }
-
-    const typePart = regexMatch[1];
-    const refPart = regexMatch[2];
-
-    const normalizedName =
-      (typePart
-        ? null
-        : refPart.toLowerCase());
-
-    const match =
-      (typePart
-        ? subcache.byDirectory[refPart]
-        : subcache.byName[normalizedName]);
-
-    if (!match && !typePart) {
-      if (subcache.multipleNameMatches[normalizedName]) {
-        return warnOrThrow(mode,
-          `Multiple matches for reference "${fullRef}". Please resolve:\n` +
-          subcache.multipleNameMatches[normalizedName]
-            .map(match => `- ${inspect(match)}\n`)
-            .join('') +
-          `Returning null for this reference.`);
-      }
-    }
-
-    if (!match) {
-      return warnOrThrow(mode,
-        `Didn't match anything for ${colors.bright(fullRef)}`);
-    }
-
-    return match;
+    const {byDirectory, byName, multipleNameMatches} = subcache;
+
+    return matchHelper(fullRef, mode, {
+      matchByDirectory:
+        prepareMatchByDirectory(mode, {
+          referenceTypes,
+          byDirectory,
+        }),
+
+      matchByName:
+        prepareMatchByName(mode, {
+          byName,
+          multipleNameMatches,
+        }),
+    });
   };
 }
 
@@ -160,64 +250,162 @@ const hardcodedFindSpecs = {
   },
 };
 
-export function getAllFindSpecs() {
-  try {
-    thingConstructors;
-  } catch (error) {
-    throw new Error(`Thing constructors aren't ready yet, can't get all find specs`);
-  }
-
-  const findSpecs = {...hardcodedFindSpecs};
+const findReverseHelperConfig = {
+  word: `find`,
+  constructorKey: Symbol.for('Thing.findSpecs'),
 
-  for (const thingConstructor of Object.values(thingConstructors)) {
-    const thingFindSpecs = thingConstructor[Symbol.for('Thing.findSpecs')];
-    if (!thingFindSpecs) continue;
+  hardcodedSpecs: hardcodedFindSpecs,
+  postprocessSpec: postprocessFindSpec,
+};
 
-    Object.assign(findSpecs, thingFindSpecs);
+export function postprocessFindSpec(spec, {thingConstructor}) {
+  const newSpec = {...spec};
+
+  // Default behavior is to find only instances of the constructor.
+  // This symbol field lets a spec opt out.
+  if (spec[Symbol.for('Thing.findThisThingOnly')] !== false) {
+    if (spec.include) {
+      const oldInclude = spec.include;
+      newSpec.include = (thing, ...args) =>
+        thing instanceof thingConstructor &&
+        oldInclude(thing, ...args);
+    } else {
+      newSpec.include = thing =>
+        thing instanceof thingConstructor;
+    }
   }
 
-  return findSpecs;
+  return newSpec;
+}
+
+export function getAllFindSpecs() {
+  return fr.getAllSpecs(findReverseHelperConfig);
 }
 
 export function findFindSpec(key) {
-  if (Object.hasOwn(hardcodedFindSpecs, key)) {
-    return hardcodedFindSpecs[key];
+  return fr.findSpec(key, findReverseHelperConfig);
+}
+
+function findMixedHelper(config) {
+  const
+    keys = Object.keys(config),
+    tokens = Object.values(config),
+    specKeys = tokens.map(token => token[findTokenKey]),
+    specs = specKeys.map(specKey => findFindSpec(specKey));
+
+  const cache = new WeakMap();
+
+  return (fullRef, data, {mode = 'warn'} = {}) => {
+    if (!fullRef) return null;
+
+    if (typeof fullRef !== 'string') {
+      throw new TypeError(`Expected a string, got ${typeAppearance(fullRef)}`);
+    }
+
+    if (!data) {
+      throw new TypeError(`Expected data to be present`);
+    }
+
+    let subcache = cache.get(data);
+    if (!subcache) {
+      const byName = Object.create(null);
+      const multipleNameMatches = Object.create(null);
+
+      for (const spec of specs) {
+        processAvailableMatchesByName(data, {
+          ...spec,
+
+          results: byName,
+          multipleNameMatches,
+        });
+      }
+
+      const byDirectory =
+        Object.fromEntries(
+          stitchArrays({
+            referenceType: keys,
+            spec: specs,
+          }).map(({referenceType, spec}) => [
+              referenceType,
+              processAvailableMatchesByDirectory(data, spec).results,
+            ]));
+
+      subcache = {byName, multipleNameMatches, byDirectory};
+      cache.set(data, subcache);
+    }
+
+    const {byName, multipleNameMatches, byDirectory} = subcache;
+
+    return matchHelper(fullRef, mode, {
+      matchByDirectory: (referenceType, directory) => {
+        if (!keys.includes(referenceType)) {
+          return oopsWrongReferenceType(mode, {
+            referenceType,
+            referenceTypes: keys,
+          });
+        }
+
+        return byDirectory[referenceType][directory];
+      },
+
+      matchByName:
+        prepareMatchByName(mode, {
+          byName,
+          multipleNameMatches,
+        }),
+    });
+  };
+}
+
+const findMixedStore = new Map();
+
+export function findMixed(config) {
+  for (const key of findMixedStore.keys()) {
+    if (compareObjects(key, config)) {
+      return findMixedStore.get(key);
+    }
   }
 
+  // Validate that this is a valid config to begin with - we can do this
+  // before find specs are actually available.
+  const tokens = Object.values(config);
+
   try {
-    thingConstructors;
-  } catch (error) {
-    throw new Error(`Thing constructors aren't ready yet, can't check if "find.${key}" available`);
-  }
+    validateArrayItems(token => {
+      isFunction(token);
 
-  for (const thingConstructor of Object.values(thingConstructors)) {
-    const thingFindSpecs = thingConstructor[Symbol.for('Thing.findSpecs')];
-    if (!thingFindSpecs) continue;
+      if (token[boundFindData])
+        throw new Error(`find.mixed doesn't work with bindFind yet`);
 
-    if (Object.hasOwn(thingFindSpecs, key)) {
-      return thingFindSpecs[key];
-    }
+      if (!token[findTokenKey])
+        throw new Error(`missing findTokenKey, is this actually a find.thing token?`);
+
+      return true;
+    })(tokens);
+  } catch (caughtError) {
+    throw new Error(
+      `Expected find.mixed mapping to include valid find.thing tokens only`,
+      {cause: caughtError});
   }
 
-  throw new Error(`"find.${key}" isn't available`);
-}
+  let behavior = (...args) => {
+    // findMixedHelper will error if find specs aren't available yet,
+    // canceling overwriting `behavior` here.
+    return (behavior = findMixedHelper(config))(...args);
+  };
 
-export default new Proxy({}, {
-  get: (store, key) => {
-    if (!Object.hasOwn(store, key)) {
-      let behavior = (...args) => {
-        // This will error if the find spec isn't available...
-        const findSpec = findFindSpec(key);
+  findMixedStore.set(config, (...args) => behavior(...args));
+  return findMixedStore.get(config);
+}
 
-        // ...or, if it is available, replace this function with the
-        // ready-for-use find function made out of that find spec.
-        return (behavior = findHelper(findSpec))(...args);
-      };
+export default fr.tokenProxy({
+  findSpec: findFindSpec,
+  prepareBehavior: findHelper,
 
-      store[key] = (...args) => behavior(...args);
+  handle(key) {
+    if (key === 'mixed') {
+      return findMixed;
     }
-
-    return store[key];
   },
 });
 
@@ -226,28 +414,13 @@ export default new Proxy({}, {
 // function. Note that this caches the arrays read from wikiData right when it's
 // called, so if their values change, you'll have to continue with a fresh call
 // to bindFind.
-export function bindFind(wikiData, opts1) {
-  const findSpecs = getAllFindSpecs();
-
-  const boundFindFns = {};
-
-  for (const [key, spec] of Object.entries(findSpecs)) {
-    if (!spec.bindTo) continue;
-
-    const findFn = findHelper(spec);
-    const thingData = wikiData[spec.bindTo];
-
-    boundFindFns[key] =
-      (opts1
-        ? (ref, opts2) =>
-            (opts2
-              ? findFn(ref, thingData, {...opts1, ...opts2})
-              : findFn(ref, thingData, opts1))
-        : (ref, opts2) =>
-            (opts2
-              ? findFn(ref, thingData, opts2)
-              : findFn(ref, thingData)));
-  }
+export function bindFind(wikiData, opts) {
+  const boundFind = fr.bind(wikiData, opts, {
+    getAllSpecs: getAllFindSpecs,
+    prepareBehavior: findHelper,
+  });
+
+  boundFind.mixed = findMixed;
 
-  return boundFindFns;
+  return boundFind;
 }