diff options
Diffstat (limited to 'src/find.js')
-rw-r--r-- | src/find.js | 479 |
1 files changed, 352 insertions, 127 deletions
diff --git a/src/find.js b/src/find.js index afe34dd9..8f2170d4 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,25 +27,62 @@ 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] : []), + results = Object.create(null), + multipleNameMatches = Object.create(null), +}) { + for (const thing of data) { + if (!include(thing, thingConstructors)) continue; + + for (const name of getMatchableNames(thing)) { + if (typeof name !== 'string') { + logWarn`Unexpected ${typeAppearance(name)} returned in names for ${inspect(thing)}`; + continue; + } + + const normalizedName = name.toLowerCase(); + + if (normalizedName in results) { + if (normalizedName in multipleNameMatches) { + multipleNameMatches[normalizedName].push(thing); + } else { + multipleNameMatches[normalizedName] = [ + results[normalizedName].thing, + thing, + ]; + results[normalizedName] = null; + } + } else { + results[normalizedName] = {thing, name}; + } + } + } + + return {results, multipleNameMatches}; +} + +export function processAvailableMatchesByDirectory(data, { + include = _thing => true, + getMatchableDirectories = thing => - (Object.hasOwn(thing, 'directory') + (thing.constructor.hasPropertyDescriptor('directory') ? [thing.directory] : [null]), -} = {}) { - const byName = Object.create(null); - const byDirectory = Object.create(null); - const multipleNameMatches = Object.create(null); + results = Object.create(null), +}) { for (const thing of data) { - if (!include(thing)) continue; + if (!include(thing, thingConstructors)) continue; for (const directory of getMatchableDirectories(thing)) { if (typeof directory !== 'string') { @@ -42,32 +90,143 @@ export function processAllAvailableMatches(data, { continue; } - byDirectory[directory] = thing; + results[directory] = {thing, directory}; } + } - for (const name of getMatchableNames(thing)) { - if (typeof name !== 'string') { - logWarn`Unexpected ${typeAppearance(name)} returned in names for ${inspect(thing)}`; - continue; + 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.`); +} + +function oopsNameCapitalizationMismatch(mode, { + matchingName, + matchedName, +}) { + if (matchingName.length === matchedName.length) { + let a = '', b = ''; + for (let i = 0; i < matchingName.length; i++) { + if ( + matchingName[i] === matchedName[i] || + matchingName[i].toLowerCase() !== matchingName[i].toLowerCase() + ) { + a += matchingName[i]; + b += matchedName[i]; + } else { + a += colors.bright(colors.red(matchingName[i])); + b += colors.bright(colors.green(matchedName[i])); } + } - const normalizedName = name.toLowerCase(); + matchingName = a; + matchedName = b; + } - if (normalizedName in byName) { - const alreadyMatchesByName = byName[normalizedName]; - byName[normalizedName] = null; - if (normalizedName in multipleNameMatches) { - multipleNameMatches[normalizedName].push(thing); - } else { - multipleNameMatches[normalizedName] = [alreadyMatchesByName, thing]; - } + return warnOrThrow(mode, + `Provided capitalization differs from the matched name. Please resolve:\n` + + `- provided: ${matchingName}\n` + + `- should be: ${matchedName}\n` + + `Returning null for this reference.`); +} + +export function prepareMatchByName(mode, {byName, multipleNameMatches}) { + return (name) => { + const normalizedName = name.toLowerCase(); + const match = byName[normalizedName]; + + if (match) { + if (name === match.name) { + return match.thing; } else { - byName[normalizedName] = thing; + return oopsNameCapitalizationMismatch(mode, { + matchingName: name, + matchedName: match.name, + }); } + } 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, + }); + } + + const match = byDirectory[directory]; + + if (match) { + return match.thing; + } else { + return null; } + }; +} + +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}"`); } - return {byName, byDirectory, multipleNameMatches}; + 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({ @@ -77,9 +236,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 +248,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 +269,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 +296,168 @@ 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, + }); + } + + const match = byDirectory[referenceType][directory]; + + if (match) { + return match.thing; + } else { + return null; + } + }, + + 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 +466,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; } |