import { ReadonlyPath, stringifyPath } from "@ark/util"; import { ArkError, ArkErrors } from "./errors.js"; import { isNode } from "./utils.js"; export class Traversal { /** * #### the path being validated or morphed * * ✅ array indices represented as numbers * ⚠️ mutated during traversal - use `path.slice(0)` to snapshot * 🔗 use {@link propString} for a stringified version */ path = []; /** * #### {@link ArkErrors} that will be part of this traversal's finalized result * * ✅ will always be an empty array for a valid traversal */ errors = new ArkErrors(this); /** * #### the original value being traversed */ root; /** * #### configuration for this traversal * * ✅ options can affect traversal results and error messages * ✅ defaults < global config < scope config * ✅ does not include options configured on individual types */ config; queuedMorphs = []; branches = []; seen = {}; constructor(root, config) { this.root = root; this.config = config; } /** * #### the data being validated or morphed * * ✅ extracted from {@link root} at {@link path} */ get data() { let result = this.root; for (const segment of this.path) result = result?.[segment]; return result; } /** * #### a string representing {@link path} * * @propString */ get propString() { return stringifyPath(this.path); } /** * #### add an {@link ArkError} and return `false` * * ✅ useful for predicates like `.narrow` */ reject(input) { this.error(input); return false; } /** * #### add an {@link ArkError} from a description and return `false` * * ✅ useful for predicates like `.narrow` * 🔗 equivalent to {@link reject}({ expected }) */ mustBe(expected) { this.error(expected); return false; } error(input) { const errCtx = typeof input === "object" ? input.code ? input : { ...input, code: "predicate" } : { code: "predicate", expected: input }; return this.errorFromContext(errCtx); } /** * #### whether {@link currentBranch} (or the traversal root, outside a union) has one or more errors */ hasError() { return this.currentErrorCount !== 0; } get currentBranch() { return this.branches[this.branches.length - 1]; } queueMorphs(morphs) { const input = { path: new ReadonlyPath(...this.path), morphs }; if (this.currentBranch) this.currentBranch.queuedMorphs.push(input); else this.queuedMorphs.push(input); } finalize(onFail) { if (this.queuedMorphs.length) { if (typeof this.root === "object" && this.root !== null && this.config.clone) this.root = this.config.clone(this.root); this.applyQueuedMorphs(); } if (this.hasError()) return onFail ? onFail(this.errors) : this.errors; return this.root; } get currentErrorCount() { return (this.currentBranch ? this.currentBranch.error ? 1 : 0 : this.errors.count); } get failFast() { return this.branches.length !== 0; } pushBranch() { this.branches.push({ error: undefined, queuedMorphs: [] }); } popBranch() { return this.branches.pop(); } /** * @internal * Convenience for casting from InternalTraversal to Traversal * for cases where the extra methods on the external type are expected, e.g. * a morph or predicate. */ get external() { return this; } errorFromNodeContext(input) { return this.errorFromContext(input); } errorFromContext(errCtx) { const error = new ArkError(errCtx, this); if (this.currentBranch) this.currentBranch.error = error; else this.errors.add(error); return error; } applyQueuedMorphs() { // invoking morphs that are Nodes will reuse this context, potentially // adding additional morphs, so we have to continue looping until // queuedMorphs is empty rather than iterating over the list once while (this.queuedMorphs.length) { const queuedMorphs = this.queuedMorphs; this.queuedMorphs = []; for (const { path, morphs } of queuedMorphs) { // even if we already have an error, apply morphs that are not at a path // with errors to capture potential validation errors if (this.errors.affectsPath(path)) continue; this.applyMorphsAtPath(path, morphs); } } } applyMorphsAtPath(path, morphs) { const key = path[path.length - 1]; let parent; if (key !== undefined) { // find the object on which the key to be morphed exists parent = this.root; for (let pathIndex = 0; pathIndex < path.length - 1; pathIndex++) parent = parent[path[pathIndex]]; } for (const morph of morphs) { // ensure morphs are applied relative to the correct path // in case previous operations modified this.path this.path = [...path]; const morphIsNode = isNode(morph); const result = morph((parent === undefined ? this.root : parent[key]), this); if (result instanceof ArkError) { // if an ArkError was returned, ensure it has been added to errors // (it may have already been added via ctx.error() within the morph) // Only add if it's not already in the errors collection if (!this.errors.includes(result)) this.errors.add(result); // skip any remaining morphs at the current path break; } if (result instanceof ArkErrors) { // if the morph was a direct reference to another node, // errors will have been added directly via this piped context if (!morphIsNode) { // otherwise, we have to ensure each error has been added this.errors.merge(result); } // skip any remaining morphs at the current path this.queuedMorphs = []; break; } // if the morph was successful, assign the result to the // corresponding property, or to root if path is empty if (parent === undefined) this.root = result; else parent[key] = result; // if the current morph queued additional morphs, // applying them before subsequent morphs this.applyQueuedMorphs(); } } } export const traverseKey = (key, fn, // ctx will be undefined if this node isn't context-dependent ctx) => { if (!ctx) return fn(); ctx.path.push(key); const result = fn(); ctx.path.pop(); return result; };