From e9a89fe920e9c50abba3cc7dd03694b9581f399a Mon Sep 17 00:00:00 2001 From: Loris Leiva Date: Thu, 14 May 2026 16:27:21 +0100 Subject: [PATCH] Generate `@codama/visitors-core`'s `identityVisitor` and `mergeVisitor` from `@codama/spec` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends `@codama-internal/spec-generators` with a third generator that produces the `identityVisitor` and `mergeVisitor` of `@codama/visitors-core` from the encoded `@codama/spec` description. Both functions previously lived as ~1100 lines of hand-written `if (keys.includes('xxxNode'))` dispatch and were a known drift hazard whenever the spec gained a node kind. The mechanical walk now lives under `packages/visitors-core/src/generated/`; `src/identityVisitor.ts` survives as a thin wrapper layering six semantic transforms via `extendVisitor` (the empty-payload downgrades on enum struct/tuple variants, the empty-prefix/suffix bypass on hidden-prefix/suffix type nodes, the both-arms-absent null-collapse on `conditionalValueNode`, and the empty-`dependsOn` collapse on `resolverValueNode`); `src/mergeVisitor.ts` is gone — its export ships directly from the generated tree. A new generated `nodeTestPaths.ts` plus a hand-written `test/nodes/coverage.test.ts` gate per-node fixture coverage: every spec-registered kind must have a matching fixture under `test/nodes/` or the suite fails. Four previously-missing fixtures were added so the gate passes today. --- .changeset/tough-cats-strive.md | 11 + packages/spec-generators/package.json | 2 +- packages/spec-generators/src/index.ts | 18 + .../visitorsCore/fragments/identityVisitor.ts | 96 +++ .../fragments/identityWalkStep.ts | 135 ++++ .../src/visitorsCore/fragments/index.ts | 7 + .../visitorsCore/fragments/mergeCollect.ts | 37 + .../visitorsCore/fragments/mergeVisitor.ts | 98 +++ .../visitorsCore/fragments/nodeTestPaths.ts | 43 + .../src/visitorsCore/fragments/rebuildCall.ts | 140 ++++ .../visitorsCore/fragments/unionConstant.ts | 67 ++ .../spec-generators/src/visitorsCore/index.ts | 68 ++ .../src/visitorsCore/options.ts | 237 ++++++ .../src/visitorsCore/visitorFunctionName.ts | 14 + .../src/visitorsCore/walkStep.ts | 109 +++ .../fragments/unionConstant.test.ts | 67 ++ .../test/visitorsCore/generate.test.ts | 105 +++ .../test/visitorsCore/scope.test.ts | 50 ++ .../src/generated/identityVisitor.ts | 737 +++++++++++++++++ packages/visitors-core/src/generated/index.ts | 3 + .../src/{ => generated}/mergeVisitor.ts | 395 +++++----- .../src/generated/nodeTestPaths.ts | 92 +++ .../visitors-core/src/getByteSizeVisitor.ts | 2 +- .../src/getDebugStringVisitor.ts | 2 +- .../src/getMaxByteSizeVisitor.ts | 2 +- packages/visitors-core/src/identityVisitor.ts | 738 ++---------------- packages/visitors-core/src/index.ts | 8 +- packages/visitors-core/src/voidVisitor.ts | 2 +- .../visitors-core/test/NodeSelector.test.ts | 1 + .../test/nodes/InstructionStatusNode.test.ts | 27 + .../nodes/countNodes/FixedCountNode.test.ts | 27 + .../countNodes/PrefixedCountNode.test.ts | 33 + .../countNodes/RemainderCountNode.test.ts | 27 + .../visitors-core/test/nodes/coverage.test.ts | 37 + packages/visitors-core/test/types/global.d.ts | 6 + 35 files changed, 2580 insertions(+), 863 deletions(-) create mode 100644 .changeset/tough-cats-strive.md create mode 100644 packages/spec-generators/src/visitorsCore/fragments/identityVisitor.ts create mode 100644 packages/spec-generators/src/visitorsCore/fragments/identityWalkStep.ts create mode 100644 packages/spec-generators/src/visitorsCore/fragments/index.ts create mode 100644 packages/spec-generators/src/visitorsCore/fragments/mergeCollect.ts create mode 100644 packages/spec-generators/src/visitorsCore/fragments/mergeVisitor.ts create mode 100644 packages/spec-generators/src/visitorsCore/fragments/nodeTestPaths.ts create mode 100644 packages/spec-generators/src/visitorsCore/fragments/rebuildCall.ts create mode 100644 packages/spec-generators/src/visitorsCore/fragments/unionConstant.ts create mode 100644 packages/spec-generators/src/visitorsCore/index.ts create mode 100644 packages/spec-generators/src/visitorsCore/options.ts create mode 100644 packages/spec-generators/src/visitorsCore/visitorFunctionName.ts create mode 100644 packages/spec-generators/src/visitorsCore/walkStep.ts create mode 100644 packages/spec-generators/test/visitorsCore/fragments/unionConstant.test.ts create mode 100644 packages/spec-generators/test/visitorsCore/generate.test.ts create mode 100644 packages/spec-generators/test/visitorsCore/scope.test.ts create mode 100644 packages/visitors-core/src/generated/identityVisitor.ts create mode 100644 packages/visitors-core/src/generated/index.ts rename packages/visitors-core/src/{ => generated}/mergeVisitor.ts (85%) create mode 100644 packages/visitors-core/src/generated/nodeTestPaths.ts create mode 100644 packages/visitors-core/test/nodes/InstructionStatusNode.test.ts create mode 100644 packages/visitors-core/test/nodes/countNodes/FixedCountNode.test.ts create mode 100644 packages/visitors-core/test/nodes/countNodes/PrefixedCountNode.test.ts create mode 100644 packages/visitors-core/test/nodes/countNodes/RemainderCountNode.test.ts create mode 100644 packages/visitors-core/test/nodes/coverage.test.ts create mode 100644 packages/visitors-core/test/types/global.d.ts diff --git a/.changeset/tough-cats-strive.md b/.changeset/tough-cats-strive.md new file mode 100644 index 000000000..462a4e53e --- /dev/null +++ b/.changeset/tough-cats-strive.md @@ -0,0 +1,11 @@ +--- +'@codama/visitors-core': minor +--- + +Regenerate `identityVisitor` and `mergeVisitor` from `@codama/spec` via the new `visitorsCore` generator in `@codama-internal/spec-generators`. Both visitors previously lived as ~1100 lines of hand-written per-node dispatch; the mechanical walk now lives under `src/generated/`. `src/identityVisitor.ts` is now a thin wrapper layering six semantic overrides (enum-variant empty-downgrade, hidden-prefix/suffix empty-bypass, conditional-value null-collapse, resolver empty-dependsOn collapse) via `extendVisitor`; `src/mergeVisitor.ts` ships directly from the generated tree. + +Three behaviour changes shake out: + +- **`enumTypeNode.size` and `pdaValueNode.programId` are now actually walked by `identityVisitor`.** The hand-written code passed both through unchanged, silently dropping any caller-applied transforms. +- **Every required-array child attribute now uniformly tolerates `undefined` at runtime.** The hand-written guard previously applied only to `programNode.events` and `programNode.constants`. Making it uniform lets `identityVisitor` safely normalise a partial IDL JSON parsed via `createFromJson`. A follow-up PR will promote the affected `programNode` children to `optionalAttribute(...)` on the spec side, after which the guard becomes naturally derivable from the spec. +- **`enumStructVariantTypeNode.discriminator` and `enumTupleVariantTypeNode.discriminator` are now preserved** when the variant survives the wrapper's empty-downgrade. diff --git a/packages/spec-generators/package.json b/packages/spec-generators/package.json index 907aa2392..65d6dadf4 100644 --- a/packages/spec-generators/package.json +++ b/packages/spec-generators/package.json @@ -6,7 +6,7 @@ "type": "module", "scripts": { "build": "rimraf dist && tsup", - "generate": "pnpm build && node ./dist/generate.mjs && pnpm --filter @codama/node-types lint:fix && pnpm --filter @codama/nodes lint:fix", + "generate": "pnpm build && node ./dist/generate.mjs && pnpm --filter @codama/node-types --filter @codama/nodes --filter @codama/visitors-core lint:fix", "lint": "eslint . && prettier --check .", "lint:fix": "eslint --fix . && prettier --write .", "test": "pnpm test:types && pnpm test:unit", diff --git a/packages/spec-generators/src/index.ts b/packages/spec-generators/src/index.ts index f2159ee3d..21d9d3d58 100644 --- a/packages/spec-generators/src/index.ts +++ b/packages/spec-generators/src/index.ts @@ -4,6 +4,12 @@ import { getSpec } from '@codama/spec'; import { generateNodes, NODE_CONFIGS } from './nodes'; import { generateNodeTypes } from './nodeTypes'; import { CATEGORY_DIRECTORIES, GENERIC_PARAM_ORDER, getRepoDirectory, NARROWABLE_DATA_ATTRIBUTES } from './shared'; +import { + generateVisitorsCore, + IDENTITY_VISITOR_WALK_ORDER, + MERGE_VISITOR_WALK_ORDER, + UNION_ALIAS_NAMES, +} from './visitorsCore'; export interface GenerateResult { /** One entry per generator that ran, in the order they ran. */ @@ -44,5 +50,17 @@ export function generate(): GenerateResult { outputs.push({ generator: 'nodes', outputDir }); } + { + const outputDir = joinPath(getRepoDirectory(), 'packages', 'visitors-core', 'src', 'generated'); + generateVisitorsCore(spec, { + identityVisitorWalkOrder: IDENTITY_VISITOR_WALK_ORDER, + mergeVisitorWalkOrder: MERGE_VISITOR_WALK_ORDER, + outputDir, + targetSpecMajor: 1, + unionAliasNames: UNION_ALIAS_NAMES, + }); + outputs.push({ generator: 'visitorsCore', outputDir }); + } + return { outputs }; } diff --git a/packages/spec-generators/src/visitorsCore/fragments/identityVisitor.ts b/packages/spec-generators/src/visitorsCore/fragments/identityVisitor.ts new file mode 100644 index 000000000..085d9dcad --- /dev/null +++ b/packages/spec-generators/src/visitorsCore/fragments/identityVisitor.ts @@ -0,0 +1,96 @@ +import { type Fragment, fragment, mergeFragments, use } from '@codama/fragments/javascript'; +import { isChildAttribute, type NodeSpec, type Spec } from '@codama/spec'; + +import { type NodeConstructorConfig } from '../../nodes'; +import { type ResolvedRenderOptions } from '../options'; +import { getVisitorFunctionName } from '../visitorFunctionName'; +import { getIdentityWalkOrder } from '../walkStep'; +import { getIdentityWalkStep } from './identityWalkStep'; +import { getRebuildCallFragment } from './rebuildCall'; + +type IdentityScope = Pick< + ResolvedRenderOptions, + 'identityVisitorWalkOrder' | 'mergeVisitorWalkOrder' | 'unionAliasNames' +>; + +/** + * Render the full `generated/identityVisitor.ts` body: the function + * declaration plus one `if (keys.includes()) { … }` block per + * spec node that has at least one child attribute. + * + * Nodes with no child attributes are handled by `staticVisitor`'s + * leaf function and don't appear in the dispatch table. + */ +export function getIdentityVisitorFragment( + spec: Spec, + nodeConfigs: ReadonlyMap, + scope: IdentityScope, +): Fragment { + const dispatchBranches = spec.categories + .flatMap(c => c.nodes) + .filter(node => node.attributes.some(attr => isChildAttribute(attr.type))) + .map(node => getIdentityDispatchBranch(node, nodeConfigs.get(node.kind), spec, scope)); + + const nodeType = use('type Node', '@codama/nodes'); + const nodeKindType = use('type NodeKind', '@codama/nodes'); + const registeredKinds = use('REGISTERED_NODE_KINDS', '@codama/nodes'); + const visitorType = use('type Visitor', 'helper:Visitor'); + const visit = use('visit as baseVisit', 'helper:visit'); + const staticVisitor = use('staticVisitor', 'helper:staticVisitor'); + + const branchesBlock = mergeFragments(dispatchBranches, ps => ps.join('\n\n')); + + return fragment`/** + * Identity visitor: rebuilds the tree node-by-node so callers can + * intercept individual nodes via override hooks while leaving the + * rest untouched. Returns \`null\` to drop a node (and its parents + * that required it). + */ +export function identityVisitor( + options: { keys?: TNodeKind[] } = {}, +): ${visitorType}<${nodeType} | null, TNodeKind> { + const keys: ${nodeKindType}[] = options.keys ?? (${registeredKinds} as TNodeKind[]); + const visitor = ${staticVisitor}(node => Object.freeze({ ...node }), { keys }) as ${visitorType}<${nodeType} | null>; + const visit = + (v: ${visitorType}<${nodeType} | null>) => + (node: ${nodeType}): ${nodeType} | null => + keys.includes(node.kind) ? ${visit}(node, v) : Object.freeze({ ...node }); + + ${branchesBlock} + + return visitor as ${visitorType}<${nodeType}, TNodeKind>; +} +`; +} + +function getIdentityDispatchBranch( + node: NodeSpec, + config: NodeConstructorConfig | undefined, + spec: Spec, + scope: IdentityScope, +): Fragment { + const visitorFnName = getVisitorFunctionName(node.kind); + // Compute one walk step per spec attribute. The rebuildExprs map + // captures the per-attribute rebuild expression (data attrs → + // bare `node.`; children → either a local name or an + // inline visit-and-filter chain). + const stepByName = new Map>(); + for (const attr of node.attributes) { + stepByName.set(attr.name, getIdentityWalkStep(attr, config, spec, scope)); + } + // Emit pre-statements in identity walk order. The identity + // visitor's traversal sequence is observable from outside via + // `recordNodeStackVisitor` + selector matching. + const orderedChildren = getIdentityWalkOrder(node, scope); + const preStatements = orderedChildren + .map(attr => stepByName.get(attr.name)!.preStatement) + .filter((p): p is Fragment => p !== undefined); + const rebuildExprs = new Map(); + for (const attr of node.attributes) { + rebuildExprs.set(attr.name, stepByName.get(attr.name)!.rebuildExpr); + } + const rebuildCall = getRebuildCallFragment(node, config, rebuildExprs, scope); + const bodyBlock = mergeFragments([...preStatements, rebuildCall], ps => ps.join('\n')); + + return fragment`if (keys.includes('${node.kind}')) { visitor.${visitorFnName} = function ${visitorFnName}(node) { ${bodyBlock} }; }`; +} diff --git a/packages/spec-generators/src/visitorsCore/fragments/identityWalkStep.ts b/packages/spec-generators/src/visitorsCore/fragments/identityWalkStep.ts new file mode 100644 index 000000000..909cca1d2 --- /dev/null +++ b/packages/spec-generators/src/visitorsCore/fragments/identityWalkStep.ts @@ -0,0 +1,135 @@ +import { type Fragment, fragment, use } from '@codama/fragments/javascript'; +import { type AttributeSpec, type Spec } from '@codama/spec'; + +import { type NodeConstructorConfig } from '../../nodes'; +import { paramIdentifier } from '../../nodes/paramIdentifier'; +import { type ResolvedRenderOptions } from '../options'; +import { getChildShape } from '../walkStep'; +import { getUnionKindListFragment } from './unionConstant'; + +/** + * The two pieces an identity-visitor body needs for one attribute: + * + * - `preStatement`: a hoisted statement (or block of statements) that + * visits the child and asserts its kind. Single-node visits return + * `null` from the surrounding visitor when the visit yields `null`; + * optional single-node visits collapse `null` into `undefined`. + * Array visits have no pre-statement (the work lives inline in the + * rebuild expression). + * - `rebuildExpr`: the JS expression that names the visited value in + * the constructor call's rebuild block. For data attributes this is + * `node.`; for visited single-node children it's the local + * name introduced by `preStatement`; for visited arrays it's the + * `.map(...).filter(...)` chain (or its optional `?: undefined` + * wrapper) inlined. + * + * Required arrays uniformly emit `(node. ?? [])` rather than + * `node.` — defensive against partial IDL JSON, matching the + * identity-visitor contract that any required-array child reference + * tolerates `undefined` at runtime. + */ +interface IdentityWalkStep { + readonly preStatement: Fragment | undefined; + readonly rebuildExpr: Fragment; +} + +/** Build the walk step fragments for one attribute. */ +export function getIdentityWalkStep( + attr: AttributeSpec, + config: NodeConstructorConfig | undefined, + spec: Spec, + options: Pick, +): IdentityWalkStep { + const localName = getSafeLocalName(attr, config); + const fieldAccess = `node.${attr.name}`; + const shape = getChildShape(attr.type); + const optional = attr.optional === true; + const visitThis = fragment`visit(this)`; + + switch (shape.kind) { + case 'data': + return { preStatement: undefined, rebuildExpr: fragment`${fieldAccess}` }; + + case 'node': + return buildSingleNodeStep(localName, fieldAccess, optional, { + assertCall: use('assertIsNode', '@codama/nodes'), + kindArg: fragment`'${shape.nodeKind}'`, + visitThis, + }); + + case 'nestedNode': + return buildSingleNodeStep(localName, fieldAccess, optional, { + assertCall: use('assertIsNestedTypeNode', '@codama/nodes'), + kindArg: fragment`'${shape.nodeKind}'`, + visitThis, + }); + + case 'union': + return buildSingleNodeStep(localName, fieldAccess, optional, { + assertCall: use('assertIsNode', '@codama/nodes'), + kindArg: getUnionKindListFragment(shape.unionName, spec, options), + visitThis, + }); + + case 'arrayNode': { + const filterCall = use('removeNullAndAssertIsNodeFilter', '@codama/nodes'); + return buildArrayStep(fieldAccess, optional, { + filterArg: fragment`'${shape.nodeKind}'`, + filterCall, + visitThis, + }); + } + + case 'arrayUnion': { + const filterCall = use('removeNullAndAssertIsNodeFilter', '@codama/nodes'); + return buildArrayStep(fieldAccess, optional, { + filterArg: getUnionKindListFragment(shape.unionName, spec, options), + filterCall, + visitThis, + }); + } + } +} + +/** + * The safe TS identifier for an attribute's local in the visitor body. + * Defaults to the attribute name; if that collides with a TS reserved + * word, looks up the `paramName` override in the node's config (the + * same field that the `@codama/nodes` constructor generator uses for + * its positional parameter renaming). + */ +function getSafeLocalName(attr: AttributeSpec, config: NodeConstructorConfig | undefined): string { + return paramIdentifier(attr, config?.attributes?.[attr.name]); +} + +function buildSingleNodeStep( + localName: string, + fieldAccess: string, + optional: boolean, + deps: { readonly assertCall: Fragment; readonly kindArg: Fragment; readonly visitThis: Fragment }, +): IdentityWalkStep { + const { assertCall, kindArg, visitThis } = deps; + if (optional) { + const preStatement = fragment`const ${localName} = ${fieldAccess} ? (${visitThis}(${fieldAccess}) ?? undefined) : undefined;\nif (${localName}) ${assertCall}(${localName}, ${kindArg});`; + return { preStatement, rebuildExpr: fragment`${localName}` }; + } + const preStatement = fragment`const ${localName} = ${visitThis}(${fieldAccess});\nif (${localName} === null) return null;\n${assertCall}(${localName}, ${kindArg});`; + return { preStatement, rebuildExpr: fragment`${localName}` }; +} + +function buildArrayStep( + fieldAccess: string, + optional: boolean, + deps: { readonly filterArg: Fragment; readonly filterCall: Fragment; readonly visitThis: Fragment }, +): IdentityWalkStep { + const { filterArg, filterCall, visitThis } = deps; + if (optional) { + const expr = fragment`${fieldAccess} ? ${fieldAccess}.map(${visitThis}).filter(${filterCall}(${filterArg})) : undefined`; + return { preStatement: undefined, rebuildExpr: expr }; + } + // Defensive `?? []` on every required-array child attribute — the + // identity visitor tolerates `undefined` at runtime so it can + // safely normalise a partial IDL JSON parsed via `createFromJson`. + const expr = fragment`(${fieldAccess} ?? []).map(${visitThis}).filter(${filterCall}(${filterArg}))`; + return { preStatement: undefined, rebuildExpr: expr }; +} diff --git a/packages/spec-generators/src/visitorsCore/fragments/index.ts b/packages/spec-generators/src/visitorsCore/fragments/index.ts new file mode 100644 index 000000000..785e09215 --- /dev/null +++ b/packages/spec-generators/src/visitorsCore/fragments/index.ts @@ -0,0 +1,7 @@ +export * from './identityVisitor'; +export * from './identityWalkStep'; +export * from './mergeCollect'; +export * from './mergeVisitor'; +export * from './nodeTestPaths'; +export * from './rebuildCall'; +export * from './unionConstant'; diff --git a/packages/spec-generators/src/visitorsCore/fragments/mergeCollect.ts b/packages/spec-generators/src/visitorsCore/fragments/mergeCollect.ts new file mode 100644 index 000000000..81d9ddd71 --- /dev/null +++ b/packages/spec-generators/src/visitorsCore/fragments/mergeCollect.ts @@ -0,0 +1,37 @@ +import { type Fragment, fragment } from '@codama/fragments/javascript'; +import { type AttributeSpec } from '@codama/spec'; + +import { getChildShape } from '../walkStep'; + +/** + * Build the merge-visitor collect fragment for one attribute. Returns + * `undefined` for data attributes (no contribution to the merge list) + * and a `...spread` fragment for child references — either a direct + * `...visit(this)(node.)` for required single-node attrs, a + * `...(node. ? visit(this)(node.) : [])` guard for + * optional ones, or a `...(node. ?? []).flatMap(visit(this))` + * spread for arrays. + */ +export function getMergeCollectFragment(attr: AttributeSpec): Fragment | undefined { + const fieldAccess = `node.${attr.name}`; + const shape = getChildShape(attr.type); + const optional = attr.optional === true; + const visitThis = fragment`visit(this)`; + + switch (shape.kind) { + case 'data': + return undefined; + + case 'node': + case 'nestedNode': + case 'union': + if (optional) { + return fragment`...(${fieldAccess} ? ${visitThis}(${fieldAccess}) : [])`; + } + return fragment`...${visitThis}(${fieldAccess})`; + + case 'arrayNode': + case 'arrayUnion': + return fragment`...(${fieldAccess} ?? []).flatMap(${visitThis})`; + } +} diff --git a/packages/spec-generators/src/visitorsCore/fragments/mergeVisitor.ts b/packages/spec-generators/src/visitorsCore/fragments/mergeVisitor.ts new file mode 100644 index 000000000..5907d9489 --- /dev/null +++ b/packages/spec-generators/src/visitorsCore/fragments/mergeVisitor.ts @@ -0,0 +1,98 @@ +import { type Fragment, fragment, mergeFragments, use } from '@codama/fragments/javascript'; +import { isChildAttribute, type NodeSpec, type Spec } from '@codama/spec'; + +import { type ResolvedRenderOptions } from '../options'; +import { getVisitorFunctionName } from '../visitorFunctionName'; +import { getMergeWalkOrder } from '../walkStep'; +import { getMergeCollectFragment } from './mergeCollect'; + +type MergeScope = Pick; + +/** + * Render the full `generated/mergeVisitor.ts` body: the function + * declaration plus one `if (keys.includes()) { … }` block per + * spec node that has at least one child attribute. + * + * Nodes with no child attributes are handled by `staticVisitor`'s + * leaf function (returning `leafValue(node)` directly) and don't + * appear in the dispatch table. + */ +export function getMergeVisitorFragment(spec: Spec, scope: MergeScope): Fragment { + const dispatchBranches = spec.categories + .flatMap(c => c.nodes) + .filter(node => node.attributes.some(attr => isChildAttribute(attr.type))) + .map(node => getMergeDispatchBranch(node, scope)); + + const nodeType = use('type Node', '@codama/nodes'); + const nodeKindType = use('type NodeKind', '@codama/nodes'); + const registeredKinds = use('REGISTERED_NODE_KINDS', '@codama/nodes'); + const visitorType = use('type Visitor', 'helper:Visitor'); + const visit = use('visit as baseVisit', 'helper:visit'); + const staticVisitor = use('staticVisitor', 'helper:staticVisitor'); + + const branchesBlock = mergeFragments(dispatchBranches, ps => ps.join('\n\n')); + + return fragment`/** + * Merge visitor: traverses the tree collecting per-node values into + * a single result via a user-supplied \`merge\` function. Leaf nodes + * (or nodes outside \`keys\`) yield \`leafValue(node)\`; every other + * visited node's value is \`merge(node, [])\`. + */ +export function mergeVisitor( + leafValue: (node: ${nodeType}) => TReturn, + merge: (node: ${nodeType}, values: TReturn[]) => TReturn, + options: { keys?: TNodeKind[] } = {}, +): ${visitorType} { + const keys: ${nodeKindType}[] = options.keys ?? ${registeredKinds}; + const visitor = ${staticVisitor}(leafValue, { keys }) as ${visitorType}; + const visit = + (v: ${visitorType}) => + (node: ${nodeType}): TReturn[] => + keys.includes(node.kind) ? [${visit}(node, v)] : []; + + ${branchesBlock} + + return visitor as ${visitorType}; +} +`; +} + +function getMergeDispatchBranch(node: NodeSpec, scope: MergeScope): Fragment { + const visitorFnName = getVisitorFunctionName(node.kind); + // Walk children in the merge-visitor walk order — observable + // from outside via `getDebugStringVisitor`. + const orderedChildren = getMergeWalkOrder(node, scope); + const collectFragments = orderedChildren + .map(attr => getMergeCollectFragment(attr)) + .filter((f): f is Fragment => f !== undefined); + + const mergeArg = renderMergeArgument(collectFragments); + return fragment`if (keys.includes('${node.kind}')) { visitor.${visitorFnName} = function ${visitorFnName}(node) { return merge(node, ${mergeArg}); }; }`; +} + +/** + * Compose the second argument to `merge(node, …)`: + * + * - Zero contributions → `[]`. + * - Exactly one contribution → drop the array wrapper and the + * leading `...` for readability. + * - Two or more → wrap them in an array literal. + */ +function renderMergeArgument(collectFragments: readonly Fragment[]): Fragment { + if (collectFragments.length === 0) return fragment`[]`; + if (collectFragments.length === 1) return stripLeadingSpread(collectFragments[0]); + return fragment`[${mergeFragments(collectFragments, ps => ps.join(', '))}]`; +} + +/** + * Remove the leading `...` from a collect fragment so it can be + * used as a bare expression (instead of inside an array literal). + * The imports are preserved. + */ +function stripLeadingSpread(spread: Fragment): Fragment { + if (!spread.content.startsWith('...')) return spread; + return Object.freeze({ + ...spread, + content: spread.content.slice(3), + }); +} diff --git a/packages/spec-generators/src/visitorsCore/fragments/nodeTestPaths.ts b/packages/spec-generators/src/visitorsCore/fragments/nodeTestPaths.ts new file mode 100644 index 000000000..a47f1048a --- /dev/null +++ b/packages/spec-generators/src/visitorsCore/fragments/nodeTestPaths.ts @@ -0,0 +1,43 @@ +import { type Fragment, fragment, joinPath, mergeFragments, pascalCase, use } from '@codama/fragments/javascript'; +import type { Spec } from '@codama/spec'; + +import { type ResolvedRenderOptions } from '../options'; + +/** + * Render the `generated/nodeTestPaths.ts` body — a map from each spec + * node kind to the expected `test/nodes/` fixture path (no + * `.test.ts` suffix, relative to `test/nodes/`). + * + * Path derivation mirrors the `@codama/nodes` generator: top-level + * nodes land at the root (`AccountNode`), category-bucketed nodes go + * under the matching folder (`typeNodes/ArrayTypeNode`). The same + * `categoryDirectories` lookup feeds both. + */ +export function getNodeTestPathsFragment(spec: Spec, options: ResolvedRenderOptions): Fragment { + const nodeKindType = use('type NodeKind', '@codama/nodes'); + const entries: Fragment[] = []; + + for (const category of spec.categories) { + const folder = options.categoryDirectories.get(category.name); + if (folder === undefined) { + throw new Error(`unknown category "${category.name}". Extend categoryDirectories.`); + } + for (const node of category.nodes) { + const path = folder === '' ? pascalCase(node.kind) : joinPath(folder, pascalCase(node.kind)); + entries.push(fragment`${node.kind}: '${path}',`); + } + } + + entries.sort((a, b) => a.content.localeCompare(b.content)); + const entriesBlock = mergeFragments(entries, ps => ps.join('\n')); + + return fragment`/** + * The expected \`test/nodes/\` file path for each node kind, relative + * to \`test/nodes/\` and without the \`.test.ts\` suffix. Used by the + * per-node test fixture coverage gate. + */ +export const NODE_TEST_PATHS: Readonly> = { +${entriesBlock} +}; +`; +} diff --git a/packages/spec-generators/src/visitorsCore/fragments/rebuildCall.ts b/packages/spec-generators/src/visitorsCore/fragments/rebuildCall.ts new file mode 100644 index 000000000..a49c92628 --- /dev/null +++ b/packages/spec-generators/src/visitorsCore/fragments/rebuildCall.ts @@ -0,0 +1,140 @@ +import { type Fragment, fragment, mergeFragments, use } from '@codama/fragments/javascript'; +import { type AttributeSpec, isChildAttribute, type NodeSpec } from '@codama/spec'; + +import { type NodeConstructorConfig } from '../../nodes'; +import { type ResolvedRenderOptions } from '../options'; +import { getIdentityWalkOrder } from '../walkStep'; + +/** + * Render the constructor call that rebuilds a node at the end of an + * identity-visitor body. + * + * The shape depends on `config.positionalArgs`: + * + * - **Object-input form** (no `positionalArgs`): + * `return ({ ...node, : , … });` + * Only visited (child) attributes are listed explicitly; data + * attributes flow through the `...node` spread. + * + * - **Pure-positional form** (every spec attribute either appears in + * `positionalArgs` or is a `hidden` default): + * `return (, , …);` + * Each positional argument is either the visited rebuild expression + * (when the attribute is a child reference) or `node.` (for + * data). + * + * - **Hybrid form** (some positional args + remaining attributes): + * `return (, , …, { ...node, : , … });` + * Positionals as above; remaining attributes ride along in an + * options bag whose `{ ...node }` spread carries through any data + * fields the visitor doesn't touch. + * + * The mapping from attribute → rebuild expression is supplied via + * `rebuildExprs`, keyed by attribute name. Data attributes always + * resolve to `node.` (the walk-step helper emits that for them). + */ +export function getRebuildCallFragment( + node: NodeSpec, + config: NodeConstructorConfig | undefined, + rebuildExprs: ReadonlyMap, + options: Pick, +): Fragment { + const constructorRef = use(node.kind, '@codama/nodes'); + if (config === undefined || config.positionalArgs === undefined) { + return buildObjectInputCall(node, constructorRef, rebuildExprs, options); + } + return buildPositionalCall(node, config, config.positionalArgs, constructorRef, rebuildExprs, options); +} + +function buildObjectInputCall( + node: NodeSpec, + constructorRef: Fragment, + rebuildExprs: ReadonlyMap, + options: Pick, +): Fragment { + // Order matters: JS evaluates object-literal field assignments in + // source order, so the rebuild's field order IS the visitor's + // walk order for inline visit-and-filter chains. Use the identity + // walk order so the visitor's traversal sequence matches what + // `recordNodeStackVisitor` + selector matching observe. + const visitedChildFields = getIdentityWalkOrder(node, options).map(attr => + buildField(attr, rebuildExprs.get(attr.name)), + ); + + if (visitedChildFields.length === 0) { + return fragment`return ${constructorRef}({ ...node });`; + } + + const fieldsBlock = mergeFragments(visitedChildFields, ps => ps.join(', ')); + return fragment`return ${constructorRef}({ ...node, ${fieldsBlock} });`; +} + +function buildPositionalCall( + node: NodeSpec, + config: NodeConstructorConfig, + positionalArgs: readonly string[], + constructorRef: Fragment, + rebuildExprs: ReadonlyMap, + options: Pick, +): Fragment { + const positionalSet = new Set(positionalArgs); + const positionalFragments = positionalArgs.map(name => { + const expr = rebuildExprs.get(name); + if (!expr) { + throw new Error( + `positional arg "${name}" on node "${node.kind}" has no rebuild expression — is it declared in the spec?`, + ); + } + return expr; + }); + + // Remaining attributes = every spec attribute NOT in positionalArgs + // and NOT marked `hidden: true`. Hidden defaults are baked into the + // constructor and the visitor doesn't expose them, so they're left + // off the options bag. + const remainingAttrs = node.attributes.filter(attr => { + if (positionalSet.has(attr.name)) return false; + const override = config.attributes?.[attr.name]; + if (override && 'default' in override && override.hidden) return false; + return true; + }); + + if (remainingAttrs.length === 0) { + const argsBlock = mergeFragments(positionalFragments, ps => ps.join(', ')); + return fragment`return ${constructorRef}(${argsBlock});`; + } + + // Hybrid: positionals + options bag. Only **visited child** attrs + // need explicit field mentions in the options bag; data attrs flow + // through `...node`. If no visited child sits in the options bag, + // it's just `{ ...node }`. Walk order matters: JS evaluates the + // options-bag literal's fields in source order, so honour the + // identity walk order for the children in the bag. + const identityOrder = getIdentityWalkOrder(node, options); + const remainingChildren = new Set(remainingAttrs.filter(attr => isChildAttribute(attr.type)).map(a => a.name)); + const visitedRemainingFields = identityOrder + .filter(attr => remainingChildren.has(attr.name)) + .map(attr => buildField(attr, rebuildExprs.get(attr.name))); + + const optionsBag = + visitedRemainingFields.length === 0 + ? fragment`{ ...node }` + : fragment`{ ...node, ${mergeFragments(visitedRemainingFields, ps => ps.join(', '))} }`; + + const argsBlock = mergeFragments([...positionalFragments, optionsBag], ps => ps.join(', ')); + return fragment`return ${constructorRef}(${argsBlock});`; +} + +function buildField(attr: AttributeSpec, rebuildExpr: Fragment | undefined): Fragment { + if (!rebuildExpr) { + throw new Error(`attribute "${attr.name}" has no rebuild expression.`); + } + // When the rebuild expression is exactly the attribute's local + // name (single-node visit produces a local with the same name as + // the attribute), emit object shorthand `{ name }` instead of + // `{ name: name }`. + if (rebuildExpr.content === attr.name) { + return fragment`${rebuildExpr}`; + } + return fragment`${attr.name}: ${rebuildExpr}`; +} diff --git a/packages/spec-generators/src/visitorsCore/fragments/unionConstant.ts b/packages/spec-generators/src/visitorsCore/fragments/unionConstant.ts new file mode 100644 index 000000000..9b45369f5 --- /dev/null +++ b/packages/spec-generators/src/visitorsCore/fragments/unionConstant.ts @@ -0,0 +1,67 @@ +import { type Fragment, fragment, mergeFragments, use } from '@codama/fragments/javascript'; +import type { Spec, UnionSpec } from '@codama/spec'; + +import { type ResolvedRenderOptions } from '../options'; + +/** + * Render the kind-list expression for a `union('')` reference. + * + * If `` is listed in `options.unionAliasNames`, the result is a + * `use(, '@codama/nodes')` fragment — emitting `` in + * the generated body. Otherwise the union is expanded inline: each + * member leaf kind becomes a `'kind'` literal, and any nested + * `union('X')` member is rendered as a `...spread` of `X`'s alias + * (the nested union must itself have an alias entry). + * + * Returns the bare expression — e.g. `TYPE_NODES`, + * `['pdaLinkNode', 'pdaNode']`, or `[...VALUE_NODES, 'programIdValueNode']`. + */ +export function getUnionKindListFragment( + unionName: string, + spec: Spec, + options: Pick, +): Fragment { + const alias = options.unionAliasNames.get(unionName); + if (alias !== undefined) { + return use(alias, '@codama/nodes'); + } + + const union = findUnion(spec, unionName); + if (!union) { + throw new Error(`union "${unionName}" referenced from a child attribute is not declared in the spec.`); + } + + return renderInlineUnionList(union, options); +} + +/** + * Render a union as an inline `[…]` literal — one entry per member. + * `kind: 'node'` members become `'kind'` string literals; `kind: + * 'union'` members become a `...spread` of the alias for the nested + * union (which must be present in `options.unionAliasNames`). + */ +function renderInlineUnionList(union: UnionSpec, options: Pick): Fragment { + const parts = union.members.map((member): Fragment => { + if (member.kind === 'node') { + return fragment`'${member.name}'`; + } + const alias = options.unionAliasNames.get(member.name); + if (alias === undefined) { + throw new Error( + `union "${union.name}" contains a nested union "${member.name}" with no entry in unionAliasNames; ` + + `extend the table or flatten the union manually.`, + ); + } + const aliasFragment = use(alias, '@codama/nodes'); + return fragment`...${aliasFragment}`; + }); + return mergeFragments(parts, ps => `[${ps.join(', ')}]`); +} + +function findUnion(spec: Spec, name: string): UnionSpec | undefined { + for (const category of spec.categories) { + const found = category.unions.find(u => u.name === name); + if (found) return found; + } + return undefined; +} diff --git a/packages/spec-generators/src/visitorsCore/index.ts b/packages/spec-generators/src/visitorsCore/index.ts new file mode 100644 index 000000000..795d4a0e1 --- /dev/null +++ b/packages/spec-generators/src/visitorsCore/index.ts @@ -0,0 +1,68 @@ +import { + createRenderMap, + deleteDirectory, + type Fragment, + mergeRenderMaps, + type Path, + type RenderMap, + writeRenderMap, +} from '@codama/fragments/javascript'; +import type { Spec } from '@codama/spec'; + +import { NODE_CONFIGS } from '../nodes'; +import { getIndexPagesRenderMap, getPageFragment, resolveEntryPath, type SymbolicModule } from '../shared'; +import { getIdentityVisitorFragment, getMergeVisitorFragment, getNodeTestPathsFragment } from './fragments'; +import { + buildRenderScope, + type GenerateOptions, + type RenderOptions, + type RenderScope, + validateRenderOptions, +} from './options'; + +export { + type GenerateOptions, + IDENTITY_VISITOR_WALK_ORDER, + MERGE_VISITOR_WALK_ORDER, + type RenderOptions, + UNION_ALIAS_NAMES, + validateRenderOptions, +} from './options'; + +/** + * Build the render map and write it to disk under `options.outputDir`. + * The target directory is wiped before each run so stale files cannot + * survive. No formatter is applied — chain `lint:fix` afterwards. + */ +export function generateVisitorsCore(spec: Spec, options: GenerateOptions): void { + const renderMap = getRenderMap(spec, options); + deleteDirectory(options.outputDir); + writeRenderMap(renderMap, options.outputDir); +} + +/** Pure-and-sync render-map entry point. Tests can call this directly without touching the filesystem. */ +export function getRenderMap(spec: Spec, options: RenderOptions): RenderMap { + validateRenderOptions(spec, options); + const scope = buildRenderScope(options); + const specPages = getSpecPagesRenderMap(spec, scope); + const indexPages = getIndexPagesRenderMap(specPages, scope.symbolicModules); + return mergeRenderMaps([specPages, indexPages]); +} + +/** + * Emit one page per visitor plus the test-path coverage map. Three + * files in total, all at the root of the generator's output directory. + */ +function getSpecPagesRenderMap(spec: Spec, scope: RenderScope): RenderMap { + const entries: Record = {}; + const emit = (symbolicKey: SymbolicModule, body: Fragment): void => { + const path = resolveEntryPath(scope.symbolicModules, symbolicKey); + entries[`${path}.ts`] = getPageFragment(body, scope.symbolicModules, path); + }; + + emit('visitor:identityVisitor', getIdentityVisitorFragment(spec, NODE_CONFIGS, scope)); + emit('visitor:mergeVisitor', getMergeVisitorFragment(spec, scope)); + emit('visitor:nodeTestPaths', getNodeTestPathsFragment(spec, scope)); + + return createRenderMap(entries); +} diff --git a/packages/spec-generators/src/visitorsCore/options.ts b/packages/spec-generators/src/visitorsCore/options.ts new file mode 100644 index 000000000..d9a96f0d2 --- /dev/null +++ b/packages/spec-generators/src/visitorsCore/options.ts @@ -0,0 +1,237 @@ +import { type Path } from '@codama/fragments/javascript'; +import { isChildAttribute, type Spec } from '@codama/spec'; + +import { + resolveSharedRenderOptions, + type SharedRenderOptions, + type SymbolicModule, + type SymbolicModuleMap, + validateSharedRenderOptions, +} from '../shared'; + +export { + CATEGORY_DIRECTORIES, + GENERIC_PARAM_ORDER, + getNodeTypeParameterAttributes, + isNodeTypeParameterAttribute, + NARROWABLE_DATA_ATTRIBUTES, +} from '../shared'; + +/** + * Map from a spec union name to the `@codama/nodes` plural-noun alias + * the visitors should reference in `assertIsNode` / + * `removeNullAndAssertIsNodeFilter` calls (e.g. `'TypeNode'` → + * `'TYPE_NODES'`). Unions absent from this map are expanded inline as + * a literal kind array. + */ +export const UNION_ALIAS_NAMES: ReadonlyMap = new Map([ + ['ContextualValueNode', 'CONTEXTUAL_VALUE_NODES'], + ['CountNode', 'COUNT_NODES'], + ['DiscriminatorNode', 'DISCRIMINATOR_NODES'], + ['EnumVariantTypeNode', 'ENUM_VARIANT_TYPE_NODES'], + ['InstructionInputValueNode', 'INSTRUCTION_INPUT_VALUE_NODES'], + ['LinkNode', 'LINK_NODES'], + ['PdaSeedNode', 'PDA_SEED_NODES'], + ['TypeNode', 'TYPE_NODES'], + ['ValueNode', 'VALUE_NODES'], +]); + +/** + * Per-node override of the **merge visitor**'s child-attribute walk + * order. The visitor walks children in spec-declaration order by + * default; entries in this map pin a different order for nodes where + * the historic hand-written merge visitor diverged from spec order + * (size / count / prefix attributes typically walked first). The walk + * order is observable through `getDebugStringVisitor`. + * + * Each entry must enumerate exactly the node's child attributes — no + * missing, no extras — otherwise generation fails. + */ +export const MERGE_VISITOR_WALK_ORDER: ReadonlyMap = new Map([ + ['programNode', ['pdas', 'accounts', 'events', 'instructions', 'definedTypes', 'errors', 'constants']], + [ + 'instructionNode', + [ + 'status', + 'accounts', + 'arguments', + 'extraArguments', + 'remainingAccounts', + 'byteDeltas', + 'discriminators', + 'subInstructions', + ], + ], + ['arrayTypeNode', ['count', 'item']], + ['enumTypeNode', ['size', 'variants']], + ['mapTypeNode', ['count', 'key', 'value']], + ['optionTypeNode', ['prefix', 'item']], + ['setTypeNode', ['count', 'item']], + ['sizePrefixTypeNode', ['prefix', 'type']], + ['sentinelTypeNode', ['sentinel', 'type']], + ['hiddenPrefixTypeNode', ['prefix', 'type']], +]); + +/** + * Per-node override of the **identity visitor**'s child-attribute walk + * order. The identity visitor's walk order matches the merge visitor's + * for every node by default; entries in this map override it for the + * handful of nodes where the historic hand-written identity visitor + * relied on JS object-literal evaluation order (effectively + * alphabetical) and diverges from the merge visitor's authored order. + * The walk order is observable through `recordNodeStackVisitor` + + * selector matching. + * + * Each entry must enumerate exactly the node's child attributes. + */ +export const IDENTITY_VISITOR_WALK_ORDER: ReadonlyMap = new Map([ + ['programNode', ['accounts', 'constants', 'definedTypes', 'errors', 'events', 'instructions', 'pdas']], + [ + 'instructionNode', + [ + 'status', + 'accounts', + 'arguments', + 'byteDeltas', + 'discriminators', + 'extraArguments', + 'remainingAccounts', + 'subInstructions', + ], + ], +]); + +/** User-facing options for the `@codama/visitors-core` generator. */ +export interface RenderOptions extends SharedRenderOptions { + /** + * Per-node override of the identity visitor's child-attribute walk + * order. Omitted means "use the v1 defaults" + * ({@link IDENTITY_VISITOR_WALK_ORDER}). + */ + readonly identityVisitorWalkOrder?: ReadonlyMap; + /** + * Per-node override of the merge visitor's child-attribute walk + * order. Omitted means "use the v1 defaults" + * ({@link MERGE_VISITOR_WALK_ORDER}). + */ + readonly mergeVisitorWalkOrder?: ReadonlyMap; + /** + * Map from spec union names to the `@codama/nodes` plural-noun + * alias constants. Omitted means "use the v1 defaults" + * ({@link UNION_ALIAS_NAMES}). + */ + readonly unionAliasNames?: ReadonlyMap; +} + +/** Options consumed by {@link generateVisitorsCore}, the disk-writing entry point. */ +export interface GenerateOptions extends RenderOptions { + readonly outputDir: Path; +} + +/** {@link RenderOptions} with every defaultable field resolved. */ +export type ResolvedRenderOptions = Required; + +/** + * Runtime context threaded through every fragment renderer. Carries + * the resolved options plus the symbolic-module lookup table. + * + * Symbolic-module flavours emitted by this generator: + * + * - `visitor:` — a sibling visitor file under `generated/`. + * - `helper:` — a hand-written sibling above `generated/` + * (`staticVisitor`, `visit`, `Visitor`). + * + * Imports from `@codama/nodes` (node constructors, kind aliases, + * assertion helpers) do NOT use this resolver: renderers call + * `use(, '@codama/nodes')` directly and the fragment + * pipeline passes the bare specifier through. + */ +export interface RenderScope extends ResolvedRenderOptions { + readonly symbolicModules: SymbolicModuleMap; +} + +export function resolveRenderOptions(options: RenderOptions): ResolvedRenderOptions { + return { + ...resolveSharedRenderOptions(options), + identityVisitorWalkOrder: options.identityVisitorWalkOrder ?? IDENTITY_VISITOR_WALK_ORDER, + mergeVisitorWalkOrder: options.mergeVisitorWalkOrder ?? MERGE_VISITOR_WALK_ORDER, + unionAliasNames: options.unionAliasNames ?? UNION_ALIAS_NAMES, + }; +} + +/** + * Cross-check the caller-supplied options against the spec at + * generation time. Catches stale `unionAliasNames` keys, stale walk- + * order overrides, and walk-order entries that don't match a node's + * child-attribute set exactly. + */ +export function validateRenderOptions(spec: Spec, options: RenderOptions): void { + validateSharedRenderOptions(spec, options); + + const allNodes = spec.categories.flatMap(c => c.nodes); + const nodeByKind = new Map(allNodes.map(n => [n.kind, n])); + const validUnionNames = new Set(spec.categories.flatMap(c => c.unions).map(u => u.name)); + + if (options.unionAliasNames) { + for (const unionName of options.unionAliasNames.keys()) { + if (!validUnionNames.has(unionName)) { + throw new Error(`unionAliasNames references unknown union "${unionName}".`); + } + } + } + + const validateWalkOrder = (table: ReadonlyMap | undefined, tableName: string): void => { + if (!table) return; + for (const [kind, order] of table) { + const node = nodeByKind.get(kind); + if (!node) { + throw new Error(`${tableName} references unknown node kind "${kind}".`); + } + const childAttrs = new Set(node.attributes.filter(a => isChildAttribute(a.type)).map(a => a.name)); + const overrideSet = new Set(order); + const missing = [...childAttrs].filter(n => !overrideSet.has(n)); + const unknown = order.filter(n => !childAttrs.has(n)); + if (missing.length > 0 || unknown.length > 0) { + const parts: string[] = []; + if (missing.length > 0) parts.push(`missing child attribute(s) ${JSON.stringify(missing)}`); + if (unknown.length > 0) parts.push(`unknown attribute(s) ${JSON.stringify(unknown)}`); + throw new Error(`${tableName} for "${kind}" is out of sync with the spec: ${parts.join('; ')}.`); + } + } + }; + + validateWalkOrder(options.mergeVisitorWalkOrder, 'mergeVisitorWalkOrder'); + validateWalkOrder(options.identityVisitorWalkOrder, 'identityVisitorWalkOrder'); +} + +/** + * Hand-written sibling files at `@codama/visitors-core/src/`, one + * directory above `generated/`. Each entry's leading `../` makes the + * relative import resolve to `'../'` from a top-level generated + * file. + */ +const HELPER_PATHS: Readonly> = Object.freeze({ + Visitor: '../visitor', + staticVisitor: '../staticVisitor', + visit: '../visitor', +}); + +export function buildRenderScope(options: RenderOptions): RenderScope { + const resolved = resolveRenderOptions(options); + const symbolicModules = new Map(); + + // The visitor generator emits one file per visitor; the visitor + // symbolic flavour resolves to its sibling file under `generated/`. + symbolicModules.set('visitor:identityVisitor', 'identityVisitor'); + symbolicModules.set('visitor:mergeVisitor', 'mergeVisitor'); + symbolicModules.set('visitor:nodeTestPaths', 'nodeTestPaths'); + + for (const [name, path] of Object.entries(HELPER_PATHS)) { + symbolicModules.set(`helper:${name}`, path); + } + + return Object.freeze({ + ...resolved, + symbolicModules: Object.freeze(symbolicModules), + }); +} diff --git a/packages/spec-generators/src/visitorsCore/visitorFunctionName.ts b/packages/spec-generators/src/visitorsCore/visitorFunctionName.ts new file mode 100644 index 000000000..51be9dab2 --- /dev/null +++ b/packages/spec-generators/src/visitorsCore/visitorFunctionName.ts @@ -0,0 +1,14 @@ +/** + * Derive the visitor function name for a spec node kind: + * `accountNode` → `visitAccount`, `arrayTypeNode` → `visitArrayType`. + * + * Mirrors the runtime `getVisitFunctionName` helper exported by + * `@codama/visitors-core/src/visitor.ts`. The runtime helper can't be + * imported here directly — the generator must not depend on the + * package it generates into — so this is a quiet copy of the same + * naming rule. + */ +export function getVisitorFunctionName(nodeKind: string): string { + const stem = nodeKind.endsWith('Node') ? nodeKind.slice(0, -4) : nodeKind; + return `visit${stem.charAt(0).toUpperCase()}${stem.slice(1)}`; +} diff --git a/packages/spec-generators/src/visitorsCore/walkStep.ts b/packages/spec-generators/src/visitorsCore/walkStep.ts new file mode 100644 index 000000000..35951beb3 --- /dev/null +++ b/packages/spec-generators/src/visitorsCore/walkStep.ts @@ -0,0 +1,109 @@ +import { type AttributeSpec, isChildAttribute, type NodeSpec, type TypeExpr } from '@codama/spec'; + +import { type ResolvedRenderOptions } from './options'; + +/** + * Visitor-side classification of an attribute type. Drives both the + * identity-visitor walk (visit + assert + rebuild) and the merge-visitor + * collect (spread visited children into the merge list). + * + * - `data` — not a child reference; pass through unchanged. + * - `node` — `node('')`. Walk once, assert ``. + * - `nestedNode` — `nestedUnion('', '')`. Walk once, + * assert nested ``. + * - `union` — `union('')`. Walk once, assert against the + * resolved kind-list. + * - `arrayNode` — `array(node(''))`. Walk each, filter. + * - `arrayUnion` — `array(union(''))`. Walk each, filter + * against the resolved kind-list. + * + * Anything else (nested arrays of arrays, tuples-of-nodes) is not + * expected in the v1 spec; extending the visitor for such shapes + * would need a new variant here. + */ +export type ChildShape = + | { readonly kind: 'arrayNode'; readonly nodeKind: string } + | { readonly kind: 'arrayUnion'; readonly unionName: string } + | { readonly kind: 'data' } + | { readonly kind: 'nestedNode'; readonly nodeKind: string } + | { readonly kind: 'node'; readonly nodeKind: string } + | { readonly kind: 'union'; readonly unionName: string }; + +/** Classify a {@link TypeExpr} into the matching {@link ChildShape}. */ +export function getChildShape(typeExpr: TypeExpr): ChildShape { + switch (typeExpr.kind) { + case 'node': + return { kind: 'node', nodeKind: typeExpr.name }; + case 'nestedUnion': + return { kind: 'nestedNode', nodeKind: typeExpr.name }; + case 'union': + return { kind: 'union', unionName: typeExpr.name }; + case 'array': { + const inner = typeExpr.of; + if (inner.kind === 'node') return { kind: 'arrayNode', nodeKind: inner.name }; + if (inner.kind === 'union') return { kind: 'arrayUnion', unionName: inner.name }; + return { kind: 'data' }; + } + default: + return { kind: 'data' }; + } +} + +/** + * Return the node's child attributes in the order the merge visitor + * should walk them. Falls back to spec-declaration order; nodes with + * an explicit override in `options.mergeVisitorWalkOrder` use that + * instead. + */ +export function getMergeWalkOrder( + node: NodeSpec, + options: Pick, +): readonly AttributeSpec[] { + return resolveWalkOrder(node, options.mergeVisitorWalkOrder, 'mergeVisitorWalkOrder'); +} + +/** + * Return the node's child attributes in the order the identity visitor + * should walk them. The identity visitor's order is independent from + * the merge visitor's — for `programNode` and `instructionNode` the + * historic hand-written rebuilds walked attributes in JS object- + * literal evaluation order (effectively alphabetical), and that order + * is observable through `recordNodeStackVisitor` + selector matching. + * + * Nodes without an entry in `options.identityVisitorWalkOrder` fall + * back to the merge visitor's walk order, then to spec-declaration + * order. Identity-specific overrides only need to list the genuinely + * divergent cases. + */ +export function getIdentityWalkOrder( + node: NodeSpec, + options: Pick, +): readonly AttributeSpec[] { + if (options.identityVisitorWalkOrder.has(node.kind)) { + return resolveWalkOrder(node, options.identityVisitorWalkOrder, 'identityVisitorWalkOrder'); + } + return getMergeWalkOrder(node, options); +} + +function resolveWalkOrder( + node: NodeSpec, + table: ReadonlyMap, + tableName: string, +): readonly AttributeSpec[] { + const children = node.attributes.filter(attr => isChildAttribute(attr.type)); + const override = table.get(node.kind); + if (!override) return children; + + const byName = new Map(children.map(c => [c.name, c])); + const declared = new Set(byName.keys()); + const overrideSet = new Set(override); + const missing = [...declared].filter(n => !overrideSet.has(n)); + const unknown = override.filter(n => !declared.has(n)); + if (missing.length > 0 || unknown.length > 0) { + const parts: string[] = []; + if (missing.length > 0) parts.push(`missing child attribute(s) ${JSON.stringify(missing)}`); + if (unknown.length > 0) parts.push(`unknown attribute(s) ${JSON.stringify(unknown)}`); + throw new Error(`${tableName} for "${node.kind}" is out of sync with the spec: ${parts.join('; ')}.`); + } + return override.map(name => byName.get(name)!); +} diff --git a/packages/spec-generators/test/visitorsCore/fragments/unionConstant.test.ts b/packages/spec-generators/test/visitorsCore/fragments/unionConstant.test.ts new file mode 100644 index 000000000..d9efa3b2e --- /dev/null +++ b/packages/spec-generators/test/visitorsCore/fragments/unionConstant.test.ts @@ -0,0 +1,67 @@ +import type { Spec } from '@codama/spec'; +import { defineCategory, defineNode, defineUnion, node, union } from '@codama/spec/api'; +import { describe, expect, it } from 'vitest'; + +import { getUnionKindListFragment } from '../../../src/visitorsCore/fragments/unionConstant'; +import { UNION_ALIAS_NAMES } from '../../../src/visitorsCore/options'; + +const spec: Spec = { + categories: [ + defineCategory('topLevel', { + nodes: [defineNode('aNode', { attributes: [] }), defineNode('bNode', { attributes: [] })], + unions: [ + // Resolves to a known alias. + defineUnion('TypeNode', { members: ['aNode'] }), + // No alias — gets inlined as a literal. + defineUnion('AbleNode', { members: ['aNode', 'bNode'] }), + // Composite: one nested union with alias plus extra leaf nodes. + defineUnion('AbleOrTypeNode', { members: [union('TypeNode'), 'bNode'] }), + ], + }), + ], + version: '1.0.0', +}; + +const options = { unionAliasNames: UNION_ALIAS_NAMES }; + +describe('getUnionKindListFragment', () => { + it('resolves a known union name to its plural-noun alias constant', () => { + const result = getUnionKindListFragment('TypeNode', spec, options); + expect(result.content).toBe('TYPE_NODES'); + expect([...result.imports.keys()]).toContain('@codama/nodes'); + }); + + it('inlines an unknown union as a literal kind array', () => { + const result = getUnionKindListFragment('AbleNode', spec, options); + expect(result.content).toBe(`['aNode', 'bNode']`); + }); + + it('mixes alias spreads and inline kinds for a composite union', () => { + const result = getUnionKindListFragment('AbleOrTypeNode', spec, options); + expect(result.content).toBe(`[...TYPE_NODES, 'bNode']`); + }); + + it('throws when the union name is not declared in the spec', () => { + expect(() => getUnionKindListFragment('GhostNode', spec, options)).toThrow( + /union "GhostNode" referenced from a child attribute is not declared in the spec/, + ); + }); + + it('throws when an inline union contains a nested union without an alias', () => { + const composedWithoutAlias: Spec = { + categories: [ + defineCategory('topLevel', { + nodes: [defineNode('aNode', { attributes: [] })], + unions: [ + defineUnion('LeafNode', { members: ['aNode'] }), + defineUnion('OuterNode', { members: [union('LeafNode'), node('aNode')] }), + ], + }), + ], + version: '1.0.0', + }; + expect(() => getUnionKindListFragment('OuterNode', composedWithoutAlias, options)).toThrow( + /OuterNode" contains a nested union "LeafNode" with no entry in unionAliasNames/, + ); + }); +}); diff --git a/packages/spec-generators/test/visitorsCore/generate.test.ts b/packages/spec-generators/test/visitorsCore/generate.test.ts new file mode 100644 index 000000000..806d9b873 --- /dev/null +++ b/packages/spec-generators/test/visitorsCore/generate.test.ts @@ -0,0 +1,105 @@ +import { getFromRenderMap } from '@codama/fragments'; +import { getSpec, type Spec } from '@codama/spec'; +import { attribute, defineCategory, defineNode, defineUnion, union } from '@codama/spec/api'; +import { describe, expect, it } from 'vitest'; + +import { type GenerateOptions, getRenderMap, validateRenderOptions } from '../../src/visitorsCore'; + +// Minimal spec for option-validation tests. +const spec: Spec = { + categories: [ + defineCategory('topLevel', { + nodes: [ + defineNode('wrappingTypeNode', { + attributes: [attribute('payload', union('TypeNode'))], + }), + ], + unions: [defineUnion('TypeNode', { members: ['wrappingTypeNode'] })], + }), + ], + version: '1.0.0', +}; + +function options(overrides: Partial = {}): GenerateOptions { + return { + outputDir: '/tmp/unused', + targetSpecMajor: 1, + ...overrides, + }; +} + +describe('validateRenderOptions', () => { + it('accepts valid options with no overrides', () => { + expect(() => validateRenderOptions(spec, options())).not.toThrow(); + }); + + it('throws when targetSpecMajor does not match the spec version', () => { + expect(() => validateRenderOptions(spec, options({ targetSpecMajor: 2 }))).toThrow( + /targetSpecMajor=2.*version "1\.0\.0".*major 1/, + ); + }); + + it('throws on a malformed spec version', () => { + const broken = { ...spec, version: 'not-a-version' }; + expect(() => validateRenderOptions(broken, options())).toThrow(/unable to parse spec version "not-a-version"/); + }); +}); + +describe('getRenderMap', () => { + const map = getRenderMap(getSpec(), { targetSpecMajor: 1 }); + + it('emits one page per visitor plus the test-paths map and an index', () => { + expect(map.has('identityVisitor.ts')).toBe(true); + expect(map.has('mergeVisitor.ts')).toBe(true); + expect(map.has('nodeTestPaths.ts')).toBe(true); + expect(map.has('index.ts')).toBe(true); + }); + + it('keys every entry with a .ts suffix', () => { + for (const key of map.keys()) { + expect(key).toMatch(/\.ts$/); + } + }); + + it('emits the merge visitor function declaration', () => { + const entry = getFromRenderMap(map, 'mergeVisitor.ts'); + expect(entry.content).toContain('export function mergeVisitor'); + // Body shape: one `if (keys.includes()) { … merge(node, [...]) … }` per child-bearing node. + expect(entry.content).toMatch(/if \(keys\.includes\('accountNode'\)\)/); + expect(entry.content).toContain('return merge(node, ['); + }); + + it('emits the identity visitor function declaration', () => { + const entry = getFromRenderMap(map, 'identityVisitor.ts'); + expect(entry.content).toContain('export function identityVisitor'); + // Self-rebuild via the matching node constructor (e.g. `return accountNode({ ...node, … })`). + expect(entry.content).toContain('return accountNode('); + // Required-array attributes uniformly emit `(node. ?? [])` per the + // A.2 defensive-walk decision — every required array child is + // tolerant of partial IDL JSON at the visitor level. + expect(entry.content).toContain('(node.accounts ?? []).map(visit(this))'); + }); + + it('emits the per-kind test-path map, keyed by kind', () => { + const entry = getFromRenderMap(map, 'nodeTestPaths.ts'); + expect(entry.content).toContain('export const NODE_TEST_PATHS'); + expect(entry.content).toMatch(/accountNode:\s*'AccountNode'/); + expect(entry.content).toMatch(/arrayTypeNode:\s*'typeNodes\/ArrayTypeNode'/); + }); + + it('resolves the relative helper imports for the merge visitor', () => { + const entry = getFromRenderMap(map, 'mergeVisitor.ts'); + const importKeys = [...entry.imports.keys()]; + // `visit`/`Visitor`/`staticVisitor` resolve to the hand-written + // sibling files above `generated/` via `helper:` symbolic + // flavours. + expect(importKeys).toContain('../visitor'); + expect(importKeys).toContain('../staticVisitor'); + // `@codama/nodes` package specifier passes through unchanged. + expect(importKeys).toContain('@codama/nodes'); + }); + + it('emits a frozen render map', () => { + expect(Object.isFrozen(map)).toBe(true); + }); +}); diff --git a/packages/spec-generators/test/visitorsCore/scope.test.ts b/packages/spec-generators/test/visitorsCore/scope.test.ts new file mode 100644 index 000000000..6de7b3a61 --- /dev/null +++ b/packages/spec-generators/test/visitorsCore/scope.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; + +import { buildRenderScope, type RenderOptions } from '../../src/visitorsCore/options'; + +const options: RenderOptions = { targetSpecMajor: 1 }; + +describe('buildRenderScope', () => { + it('routes visitor:identityVisitor to its sibling file under generated/', () => { + const scope = buildRenderScope(options); + expect(scope.symbolicModules.get('visitor:identityVisitor')).toBe('identityVisitor'); + }); + + it('routes visitor:mergeVisitor to its sibling file under generated/', () => { + const scope = buildRenderScope(options); + expect(scope.symbolicModules.get('visitor:mergeVisitor')).toBe('mergeVisitor'); + }); + + it('routes visitor:nodeTestPaths to its sibling file under generated/', () => { + const scope = buildRenderScope(options); + expect(scope.symbolicModules.get('visitor:nodeTestPaths')).toBe('nodeTestPaths'); + }); + + it('points helper symbols at the hand-written siblings above generated/', () => { + const scope = buildRenderScope(options); + expect(scope.symbolicModules.get('helper:staticVisitor')).toBe('../staticVisitor'); + expect(scope.symbolicModules.get('helper:visit')).toBe('../visitor'); + expect(scope.symbolicModules.get('helper:Visitor')).toBe('../visitor'); + }); + + it('defaults the walk-order and union-alias tables when omitted from options', () => { + const scope = buildRenderScope(options); + // The defaulted maps carry the live v1 spec content; assert + // a representative key from each rather than the full set. + expect(scope.unionAliasNames.get('TypeNode')).toBe('TYPE_NODES'); + expect(scope.mergeVisitorWalkOrder.get('arrayTypeNode')).toEqual(['count', 'item']); + expect(scope.identityVisitorWalkOrder.get('programNode')?.[0]).toBe('accounts'); + }); + + it('honours caller-supplied unionAliasNames overrides', () => { + const custom = new Map([['TypeNode', 'CUSTOM_TYPE_NODES']]); + const scope = buildRenderScope({ ...options, unionAliasNames: custom }); + expect(scope.unionAliasNames.get('TypeNode')).toBe('CUSTOM_TYPE_NODES'); + }); + + it('emits a frozen scope', () => { + const scope = buildRenderScope(options); + expect(Object.isFrozen(scope)).toBe(true); + expect(Object.isFrozen(scope.symbolicModules)).toBe(true); + }); +}); diff --git a/packages/visitors-core/src/generated/identityVisitor.ts b/packages/visitors-core/src/generated/identityVisitor.ts new file mode 100644 index 000000000..499d94374 --- /dev/null +++ b/packages/visitors-core/src/generated/identityVisitor.ts @@ -0,0 +1,737 @@ +import { + accountLinkNode, + accountNode, + amountTypeNode, + arrayTypeNode, + arrayValueNode, + assertIsNestedTypeNode, + assertIsNode, + booleanTypeNode, + conditionalValueNode, + constantDiscriminatorNode, + constantNode, + constantPdaSeedNode, + constantValueNode, + COUNT_NODES, + dateTimeTypeNode, + definedTypeLinkNode, + definedTypeNode, + DISCRIMINATOR_NODES, + ENUM_VARIANT_TYPE_NODES, + enumStructVariantTypeNode, + enumTupleVariantTypeNode, + enumTypeNode, + enumValueNode, + eventNode, + fixedSizeTypeNode, + hiddenPrefixTypeNode, + hiddenSuffixTypeNode, + INSTRUCTION_INPUT_VALUE_NODES, + instructionAccountLinkNode, + instructionAccountNode, + instructionArgumentLinkNode, + instructionArgumentNode, + instructionByteDeltaNode, + instructionLinkNode, + instructionNode, + instructionRemainingAccountsNode, + mapEntryValueNode, + mapTypeNode, + mapValueNode, + optionTypeNode, + PDA_SEED_NODES, + pdaLinkNode, + pdaNode, + pdaSeedValueNode, + pdaValueNode, + postOffsetTypeNode, + prefixedCountNode, + preOffsetTypeNode, + programNode, + REGISTERED_NODE_KINDS, + remainderOptionTypeNode, + removeNullAndAssertIsNodeFilter, + resolverValueNode, + rootNode, + sentinelTypeNode, + setTypeNode, + setValueNode, + sizePrefixTypeNode, + solAmountTypeNode, + someValueNode, + structFieldTypeNode, + structFieldValueNode, + structTypeNode, + structValueNode, + tupleTypeNode, + tupleValueNode, + type Node, + type NodeKind, + TYPE_NODES, + VALUE_NODES, + variablePdaSeedNode, + zeroableOptionTypeNode, +} from '@codama/nodes'; +import { staticVisitor } from '../staticVisitor'; +import { type Visitor, visit as baseVisit } from '../visitor'; + +/** + * Identity visitor: rebuilds the tree node-by-node so callers can + * intercept individual nodes via override hooks while leaving the + * rest untouched. Returns `null` to drop a node (and its parents + * that required it). + */ +export function identityVisitor( + options: { keys?: TNodeKind[] } = {}, +): Visitor { + const keys: NodeKind[] = options.keys ?? (REGISTERED_NODE_KINDS as TNodeKind[]); + const visitor = staticVisitor(node => Object.freeze({ ...node }), { keys }) as Visitor; + const visit = + (v: Visitor) => + (node: Node): Node | null => + keys.includes(node.kind) ? baseVisit(node, v) : Object.freeze({ ...node }); + + if (keys.includes('amountTypeNode')) { + visitor.visitAmountType = function visitAmountType(node) { + const number = visit(this)(node.number); + if (number === null) return null; + assertIsNestedTypeNode(number, 'numberTypeNode'); + return amountTypeNode(number, node.decimals, node.unit); + }; + } + + if (keys.includes('arrayTypeNode')) { + visitor.visitArrayType = function visitArrayType(node) { + const count = visit(this)(node.count); + if (count === null) return null; + assertIsNode(count, COUNT_NODES); + const item = visit(this)(node.item); + if (item === null) return null; + assertIsNode(item, TYPE_NODES); + return arrayTypeNode(item, count); + }; + } + + if (keys.includes('booleanTypeNode')) { + visitor.visitBooleanType = function visitBooleanType(node) { + const size = visit(this)(node.size); + if (size === null) return null; + assertIsNestedTypeNode(size, 'numberTypeNode'); + return booleanTypeNode(size); + }; + } + + if (keys.includes('dateTimeTypeNode')) { + visitor.visitDateTimeType = function visitDateTimeType(node) { + const number = visit(this)(node.number); + if (number === null) return null; + assertIsNestedTypeNode(number, 'numberTypeNode'); + return dateTimeTypeNode(number); + }; + } + + if (keys.includes('enumStructVariantTypeNode')) { + visitor.visitEnumStructVariantType = function visitEnumStructVariantType(node) { + const struct = visit(this)(node.struct); + if (struct === null) return null; + assertIsNestedTypeNode(struct, 'structTypeNode'); + return enumStructVariantTypeNode(node.name, struct, node.discriminator); + }; + } + + if (keys.includes('enumTupleVariantTypeNode')) { + visitor.visitEnumTupleVariantType = function visitEnumTupleVariantType(node) { + const tuple = visit(this)(node.tuple); + if (tuple === null) return null; + assertIsNestedTypeNode(tuple, 'tupleTypeNode'); + return enumTupleVariantTypeNode(node.name, tuple, node.discriminator); + }; + } + + if (keys.includes('enumTypeNode')) { + visitor.visitEnumType = function visitEnumType(node) { + const size = visit(this)(node.size); + if (size === null) return null; + assertIsNestedTypeNode(size, 'numberTypeNode'); + return enumTypeNode( + (node.variants ?? []).map(visit(this)).filter(removeNullAndAssertIsNodeFilter(ENUM_VARIANT_TYPE_NODES)), + { ...node, size }, + ); + }; + } + + if (keys.includes('fixedSizeTypeNode')) { + visitor.visitFixedSizeType = function visitFixedSizeType(node) { + const type = visit(this)(node.type); + if (type === null) return null; + assertIsNode(type, TYPE_NODES); + return fixedSizeTypeNode(type, node.size); + }; + } + + if (keys.includes('hiddenPrefixTypeNode')) { + visitor.visitHiddenPrefixType = function visitHiddenPrefixType(node) { + const type = visit(this)(node.type); + if (type === null) return null; + assertIsNode(type, TYPE_NODES); + return hiddenPrefixTypeNode( + type, + (node.prefix ?? []).map(visit(this)).filter(removeNullAndAssertIsNodeFilter('constantValueNode')), + ); + }; + } + + if (keys.includes('hiddenSuffixTypeNode')) { + visitor.visitHiddenSuffixType = function visitHiddenSuffixType(node) { + const type = visit(this)(node.type); + if (type === null) return null; + assertIsNode(type, TYPE_NODES); + return hiddenSuffixTypeNode( + type, + (node.suffix ?? []).map(visit(this)).filter(removeNullAndAssertIsNodeFilter('constantValueNode')), + ); + }; + } + + if (keys.includes('mapTypeNode')) { + visitor.visitMapType = function visitMapType(node) { + const count = visit(this)(node.count); + if (count === null) return null; + assertIsNode(count, COUNT_NODES); + const key = visit(this)(node.key); + if (key === null) return null; + assertIsNode(key, TYPE_NODES); + const value = visit(this)(node.value); + if (value === null) return null; + assertIsNode(value, TYPE_NODES); + return mapTypeNode(key, value, count); + }; + } + + if (keys.includes('optionTypeNode')) { + visitor.visitOptionType = function visitOptionType(node) { + const prefix = visit(this)(node.prefix); + if (prefix === null) return null; + assertIsNestedTypeNode(prefix, 'numberTypeNode'); + const item = visit(this)(node.item); + if (item === null) return null; + assertIsNode(item, TYPE_NODES); + return optionTypeNode(item, { ...node, prefix }); + }; + } + + if (keys.includes('postOffsetTypeNode')) { + visitor.visitPostOffsetType = function visitPostOffsetType(node) { + const type = visit(this)(node.type); + if (type === null) return null; + assertIsNode(type, TYPE_NODES); + return postOffsetTypeNode(type, node.offset, node.strategy); + }; + } + + if (keys.includes('preOffsetTypeNode')) { + visitor.visitPreOffsetType = function visitPreOffsetType(node) { + const type = visit(this)(node.type); + if (type === null) return null; + assertIsNode(type, TYPE_NODES); + return preOffsetTypeNode(type, node.offset, node.strategy); + }; + } + + if (keys.includes('remainderOptionTypeNode')) { + visitor.visitRemainderOptionType = function visitRemainderOptionType(node) { + const item = visit(this)(node.item); + if (item === null) return null; + assertIsNode(item, TYPE_NODES); + return remainderOptionTypeNode(item); + }; + } + + if (keys.includes('sentinelTypeNode')) { + visitor.visitSentinelType = function visitSentinelType(node) { + const sentinel = visit(this)(node.sentinel); + if (sentinel === null) return null; + assertIsNode(sentinel, 'constantValueNode'); + const type = visit(this)(node.type); + if (type === null) return null; + assertIsNode(type, TYPE_NODES); + return sentinelTypeNode(type, sentinel); + }; + } + + if (keys.includes('setTypeNode')) { + visitor.visitSetType = function visitSetType(node) { + const count = visit(this)(node.count); + if (count === null) return null; + assertIsNode(count, COUNT_NODES); + const item = visit(this)(node.item); + if (item === null) return null; + assertIsNode(item, TYPE_NODES); + return setTypeNode(item, count); + }; + } + + if (keys.includes('sizePrefixTypeNode')) { + visitor.visitSizePrefixType = function visitSizePrefixType(node) { + const prefix = visit(this)(node.prefix); + if (prefix === null) return null; + assertIsNestedTypeNode(prefix, 'numberTypeNode'); + const type = visit(this)(node.type); + if (type === null) return null; + assertIsNode(type, TYPE_NODES); + return sizePrefixTypeNode(type, prefix); + }; + } + + if (keys.includes('solAmountTypeNode')) { + visitor.visitSolAmountType = function visitSolAmountType(node) { + const number = visit(this)(node.number); + if (number === null) return null; + assertIsNestedTypeNode(number, 'numberTypeNode'); + return solAmountTypeNode(number); + }; + } + + if (keys.includes('structFieldTypeNode')) { + visitor.visitStructFieldType = function visitStructFieldType(node) { + const type = visit(this)(node.type); + if (type === null) return null; + assertIsNode(type, TYPE_NODES); + const defaultValue = node.defaultValue ? (visit(this)(node.defaultValue) ?? undefined) : undefined; + if (defaultValue) assertIsNode(defaultValue, VALUE_NODES); + return structFieldTypeNode({ ...node, type, defaultValue }); + }; + } + + if (keys.includes('structTypeNode')) { + visitor.visitStructType = function visitStructType(node) { + return structTypeNode( + (node.fields ?? []).map(visit(this)).filter(removeNullAndAssertIsNodeFilter('structFieldTypeNode')), + ); + }; + } + + if (keys.includes('tupleTypeNode')) { + visitor.visitTupleType = function visitTupleType(node) { + return tupleTypeNode( + (node.items ?? []).map(visit(this)).filter(removeNullAndAssertIsNodeFilter(TYPE_NODES)), + ); + }; + } + + if (keys.includes('zeroableOptionTypeNode')) { + visitor.visitZeroableOptionType = function visitZeroableOptionType(node) { + const item = visit(this)(node.item); + if (item === null) return null; + assertIsNode(item, TYPE_NODES); + const zeroValue = node.zeroValue ? (visit(this)(node.zeroValue) ?? undefined) : undefined; + if (zeroValue) assertIsNode(zeroValue, 'constantValueNode'); + return zeroableOptionTypeNode(item, zeroValue); + }; + } + + if (keys.includes('arrayValueNode')) { + visitor.visitArrayValue = function visitArrayValue(node) { + return arrayValueNode( + (node.items ?? []).map(visit(this)).filter(removeNullAndAssertIsNodeFilter(VALUE_NODES)), + ); + }; + } + + if (keys.includes('constantValueNode')) { + visitor.visitConstantValue = function visitConstantValue(node) { + const type = visit(this)(node.type); + if (type === null) return null; + assertIsNode(type, TYPE_NODES); + const value = visit(this)(node.value); + if (value === null) return null; + assertIsNode(value, VALUE_NODES); + return constantValueNode(type, value); + }; + } + + if (keys.includes('enumValueNode')) { + visitor.visitEnumValue = function visitEnumValue(node) { + const enumLink = visit(this)(node.enum); + if (enumLink === null) return null; + assertIsNode(enumLink, 'definedTypeLinkNode'); + const value = node.value ? (visit(this)(node.value) ?? undefined) : undefined; + if (value) assertIsNode(value, ['structValueNode', 'tupleValueNode']); + return enumValueNode(enumLink, node.variant, value); + }; + } + + if (keys.includes('mapEntryValueNode')) { + visitor.visitMapEntryValue = function visitMapEntryValue(node) { + const key = visit(this)(node.key); + if (key === null) return null; + assertIsNode(key, VALUE_NODES); + const value = visit(this)(node.value); + if (value === null) return null; + assertIsNode(value, VALUE_NODES); + return mapEntryValueNode(key, value); + }; + } + + if (keys.includes('mapValueNode')) { + visitor.visitMapValue = function visitMapValue(node) { + return mapValueNode( + (node.entries ?? []).map(visit(this)).filter(removeNullAndAssertIsNodeFilter('mapEntryValueNode')), + ); + }; + } + + if (keys.includes('setValueNode')) { + visitor.visitSetValue = function visitSetValue(node) { + return setValueNode( + (node.items ?? []).map(visit(this)).filter(removeNullAndAssertIsNodeFilter(VALUE_NODES)), + ); + }; + } + + if (keys.includes('someValueNode')) { + visitor.visitSomeValue = function visitSomeValue(node) { + const value = visit(this)(node.value); + if (value === null) return null; + assertIsNode(value, VALUE_NODES); + return someValueNode(value); + }; + } + + if (keys.includes('structFieldValueNode')) { + visitor.visitStructFieldValue = function visitStructFieldValue(node) { + const value = visit(this)(node.value); + if (value === null) return null; + assertIsNode(value, VALUE_NODES); + return structFieldValueNode(node.name, value); + }; + } + + if (keys.includes('structValueNode')) { + visitor.visitStructValue = function visitStructValue(node) { + return structValueNode( + (node.fields ?? []).map(visit(this)).filter(removeNullAndAssertIsNodeFilter('structFieldValueNode')), + ); + }; + } + + if (keys.includes('tupleValueNode')) { + visitor.visitTupleValue = function visitTupleValue(node) { + return tupleValueNode( + (node.items ?? []).map(visit(this)).filter(removeNullAndAssertIsNodeFilter(VALUE_NODES)), + ); + }; + } + + if (keys.includes('accountLinkNode')) { + visitor.visitAccountLink = function visitAccountLink(node) { + const program = node.program ? (visit(this)(node.program) ?? undefined) : undefined; + if (program) assertIsNode(program, 'programLinkNode'); + return accountLinkNode(node.name, program); + }; + } + + if (keys.includes('definedTypeLinkNode')) { + visitor.visitDefinedTypeLink = function visitDefinedTypeLink(node) { + const program = node.program ? (visit(this)(node.program) ?? undefined) : undefined; + if (program) assertIsNode(program, 'programLinkNode'); + return definedTypeLinkNode(node.name, program); + }; + } + + if (keys.includes('instructionAccountLinkNode')) { + visitor.visitInstructionAccountLink = function visitInstructionAccountLink(node) { + const instruction = node.instruction ? (visit(this)(node.instruction) ?? undefined) : undefined; + if (instruction) assertIsNode(instruction, 'instructionLinkNode'); + return instructionAccountLinkNode(node.name, instruction); + }; + } + + if (keys.includes('instructionArgumentLinkNode')) { + visitor.visitInstructionArgumentLink = function visitInstructionArgumentLink(node) { + const instruction = node.instruction ? (visit(this)(node.instruction) ?? undefined) : undefined; + if (instruction) assertIsNode(instruction, 'instructionLinkNode'); + return instructionArgumentLinkNode(node.name, instruction); + }; + } + + if (keys.includes('instructionLinkNode')) { + visitor.visitInstructionLink = function visitInstructionLink(node) { + const program = node.program ? (visit(this)(node.program) ?? undefined) : undefined; + if (program) assertIsNode(program, 'programLinkNode'); + return instructionLinkNode(node.name, program); + }; + } + + if (keys.includes('pdaLinkNode')) { + visitor.visitPdaLink = function visitPdaLink(node) { + const program = node.program ? (visit(this)(node.program) ?? undefined) : undefined; + if (program) assertIsNode(program, 'programLinkNode'); + return pdaLinkNode(node.name, program); + }; + } + + if (keys.includes('constantPdaSeedNode')) { + visitor.visitConstantPdaSeed = function visitConstantPdaSeed(node) { + const type = visit(this)(node.type); + if (type === null) return null; + assertIsNode(type, TYPE_NODES); + const value = visit(this)(node.value); + if (value === null) return null; + assertIsNode(value, ['programIdValueNode', ...VALUE_NODES]); + return constantPdaSeedNode(type, value); + }; + } + + if (keys.includes('variablePdaSeedNode')) { + visitor.visitVariablePdaSeed = function visitVariablePdaSeed(node) { + const type = visit(this)(node.type); + if (type === null) return null; + assertIsNode(type, TYPE_NODES); + return variablePdaSeedNode(node.name, type, node.docs); + }; + } + + if (keys.includes('prefixedCountNode')) { + visitor.visitPrefixedCount = function visitPrefixedCount(node) { + const prefix = visit(this)(node.prefix); + if (prefix === null) return null; + assertIsNestedTypeNode(prefix, 'numberTypeNode'); + return prefixedCountNode(prefix); + }; + } + + if (keys.includes('constantDiscriminatorNode')) { + visitor.visitConstantDiscriminator = function visitConstantDiscriminator(node) { + const constant = visit(this)(node.constant); + if (constant === null) return null; + assertIsNode(constant, 'constantValueNode'); + return constantDiscriminatorNode(constant, node.offset); + }; + } + + if (keys.includes('conditionalValueNode')) { + visitor.visitConditionalValue = function visitConditionalValue(node) { + const condition = visit(this)(node.condition); + if (condition === null) return null; + assertIsNode(condition, ['accountValueNode', 'argumentValueNode', 'resolverValueNode']); + const value = node.value ? (visit(this)(node.value) ?? undefined) : undefined; + if (value) assertIsNode(value, VALUE_NODES); + const ifTrue = node.ifTrue ? (visit(this)(node.ifTrue) ?? undefined) : undefined; + if (ifTrue) assertIsNode(ifTrue, INSTRUCTION_INPUT_VALUE_NODES); + const ifFalse = node.ifFalse ? (visit(this)(node.ifFalse) ?? undefined) : undefined; + if (ifFalse) assertIsNode(ifFalse, INSTRUCTION_INPUT_VALUE_NODES); + return conditionalValueNode({ ...node, condition, value, ifTrue, ifFalse }); + }; + } + + if (keys.includes('pdaSeedValueNode')) { + visitor.visitPdaSeedValue = function visitPdaSeedValue(node) { + const value = visit(this)(node.value); + if (value === null) return null; + assertIsNode(value, ['accountValueNode', 'argumentValueNode', ...VALUE_NODES]); + return pdaSeedValueNode(node.name, value); + }; + } + + if (keys.includes('pdaValueNode')) { + visitor.visitPdaValue = function visitPdaValue(node) { + const pda = visit(this)(node.pda); + if (pda === null) return null; + assertIsNode(pda, ['pdaLinkNode', 'pdaNode']); + const programId = node.programId ? (visit(this)(node.programId) ?? undefined) : undefined; + if (programId) assertIsNode(programId, ['accountValueNode', 'argumentValueNode']); + return pdaValueNode( + pda, + (node.seeds ?? []).map(visit(this)).filter(removeNullAndAssertIsNodeFilter('pdaSeedValueNode')), + programId, + ); + }; + } + + if (keys.includes('resolverValueNode')) { + visitor.visitResolverValue = function visitResolverValue(node) { + return resolverValueNode(node.name, { + ...node, + dependsOn: node.dependsOn + ? node.dependsOn + .map(visit(this)) + .filter(removeNullAndAssertIsNodeFilter(['accountValueNode', 'argumentValueNode'])) + : undefined, + }); + }; + } + + if (keys.includes('accountNode')) { + visitor.visitAccount = function visitAccount(node) { + const data = visit(this)(node.data); + if (data === null) return null; + assertIsNestedTypeNode(data, 'structTypeNode'); + const pda = node.pda ? (visit(this)(node.pda) ?? undefined) : undefined; + if (pda) assertIsNode(pda, 'pdaLinkNode'); + return accountNode({ + ...node, + data, + pda, + discriminators: node.discriminators + ? node.discriminators.map(visit(this)).filter(removeNullAndAssertIsNodeFilter(DISCRIMINATOR_NODES)) + : undefined, + }); + }; + } + + if (keys.includes('constantNode')) { + visitor.visitConstant = function visitConstant(node) { + const type = visit(this)(node.type); + if (type === null) return null; + assertIsNode(type, TYPE_NODES); + const value = visit(this)(node.value); + if (value === null) return null; + assertIsNode(value, VALUE_NODES); + return constantNode(node.name, type, value, node.docs); + }; + } + + if (keys.includes('definedTypeNode')) { + visitor.visitDefinedType = function visitDefinedType(node) { + const type = visit(this)(node.type); + if (type === null) return null; + assertIsNode(type, TYPE_NODES); + return definedTypeNode({ ...node, type }); + }; + } + + if (keys.includes('eventNode')) { + visitor.visitEvent = function visitEvent(node) { + const data = visit(this)(node.data); + if (data === null) return null; + assertIsNode(data, TYPE_NODES); + return eventNode({ + ...node, + data, + discriminators: node.discriminators + ? node.discriminators.map(visit(this)).filter(removeNullAndAssertIsNodeFilter(DISCRIMINATOR_NODES)) + : undefined, + }); + }; + } + + if (keys.includes('instructionAccountNode')) { + visitor.visitInstructionAccount = function visitInstructionAccount(node) { + const defaultValue = node.defaultValue ? (visit(this)(node.defaultValue) ?? undefined) : undefined; + if (defaultValue) assertIsNode(defaultValue, INSTRUCTION_INPUT_VALUE_NODES); + return instructionAccountNode({ ...node, defaultValue }); + }; + } + + if (keys.includes('instructionArgumentNode')) { + visitor.visitInstructionArgument = function visitInstructionArgument(node) { + const type = visit(this)(node.type); + if (type === null) return null; + assertIsNode(type, TYPE_NODES); + const defaultValue = node.defaultValue ? (visit(this)(node.defaultValue) ?? undefined) : undefined; + if (defaultValue) assertIsNode(defaultValue, INSTRUCTION_INPUT_VALUE_NODES); + return instructionArgumentNode({ ...node, type, defaultValue }); + }; + } + + if (keys.includes('instructionByteDeltaNode')) { + visitor.visitInstructionByteDelta = function visitInstructionByteDelta(node) { + const value = visit(this)(node.value); + if (value === null) return null; + assertIsNode(value, ['accountLinkNode', 'argumentValueNode', 'numberValueNode', 'resolverValueNode']); + return instructionByteDeltaNode(value, { ...node }); + }; + } + + if (keys.includes('instructionNode')) { + visitor.visitInstruction = function visitInstruction(node) { + const status = node.status ? (visit(this)(node.status) ?? undefined) : undefined; + if (status) assertIsNode(status, 'instructionStatusNode'); + return instructionNode({ + ...node, + status, + accounts: (node.accounts ?? []) + .map(visit(this)) + .filter(removeNullAndAssertIsNodeFilter('instructionAccountNode')), + arguments: (node.arguments ?? []) + .map(visit(this)) + .filter(removeNullAndAssertIsNodeFilter('instructionArgumentNode')), + byteDeltas: node.byteDeltas + ? node.byteDeltas + .map(visit(this)) + .filter(removeNullAndAssertIsNodeFilter('instructionByteDeltaNode')) + : undefined, + discriminators: node.discriminators + ? node.discriminators.map(visit(this)).filter(removeNullAndAssertIsNodeFilter(DISCRIMINATOR_NODES)) + : undefined, + extraArguments: node.extraArguments + ? node.extraArguments + .map(visit(this)) + .filter(removeNullAndAssertIsNodeFilter('instructionArgumentNode')) + : undefined, + remainingAccounts: node.remainingAccounts + ? node.remainingAccounts + .map(visit(this)) + .filter(removeNullAndAssertIsNodeFilter('instructionRemainingAccountsNode')) + : undefined, + subInstructions: node.subInstructions + ? node.subInstructions.map(visit(this)).filter(removeNullAndAssertIsNodeFilter('instructionNode')) + : undefined, + }); + }; + } + + if (keys.includes('instructionRemainingAccountsNode')) { + visitor.visitInstructionRemainingAccounts = function visitInstructionRemainingAccounts(node) { + const value = visit(this)(node.value); + if (value === null) return null; + assertIsNode(value, ['argumentValueNode', 'resolverValueNode']); + return instructionRemainingAccountsNode(value, { ...node }); + }; + } + + if (keys.includes('pdaNode')) { + visitor.visitPda = function visitPda(node) { + return pdaNode({ + ...node, + seeds: (node.seeds ?? []).map(visit(this)).filter(removeNullAndAssertIsNodeFilter(PDA_SEED_NODES)), + }); + }; + } + + if (keys.includes('programNode')) { + visitor.visitProgram = function visitProgram(node) { + return programNode({ + ...node, + accounts: (node.accounts ?? []).map(visit(this)).filter(removeNullAndAssertIsNodeFilter('accountNode')), + constants: (node.constants ?? []) + .map(visit(this)) + .filter(removeNullAndAssertIsNodeFilter('constantNode')), + definedTypes: (node.definedTypes ?? []) + .map(visit(this)) + .filter(removeNullAndAssertIsNodeFilter('definedTypeNode')), + errors: (node.errors ?? []).map(visit(this)).filter(removeNullAndAssertIsNodeFilter('errorNode')), + events: (node.events ?? []).map(visit(this)).filter(removeNullAndAssertIsNodeFilter('eventNode')), + instructions: (node.instructions ?? []) + .map(visit(this)) + .filter(removeNullAndAssertIsNodeFilter('instructionNode')), + pdas: (node.pdas ?? []).map(visit(this)).filter(removeNullAndAssertIsNodeFilter('pdaNode')), + }); + }; + } + + if (keys.includes('rootNode')) { + visitor.visitRoot = function visitRoot(node) { + const program = visit(this)(node.program); + if (program === null) return null; + assertIsNode(program, 'programNode'); + return rootNode( + program, + (node.additionalPrograms ?? []).map(visit(this)).filter(removeNullAndAssertIsNodeFilter('programNode')), + ); + }; + } + + return visitor as Visitor; +} diff --git a/packages/visitors-core/src/generated/index.ts b/packages/visitors-core/src/generated/index.ts new file mode 100644 index 000000000..21e2efebd --- /dev/null +++ b/packages/visitors-core/src/generated/index.ts @@ -0,0 +1,3 @@ +export * from './identityVisitor'; +export * from './mergeVisitor'; +export * from './nodeTestPaths'; diff --git a/packages/visitors-core/src/mergeVisitor.ts b/packages/visitors-core/src/generated/mergeVisitor.ts similarity index 85% rename from packages/visitors-core/src/mergeVisitor.ts rename to packages/visitors-core/src/generated/mergeVisitor.ts index cafe32f48..9e5e48db2 100644 --- a/packages/visitors-core/src/mergeVisitor.ts +++ b/packages/visitors-core/src/generated/mergeVisitor.ts @@ -1,8 +1,13 @@ -import { getAllPrograms, Node, NodeKind, REGISTERED_NODE_KINDS } from '@codama/nodes'; - -import { staticVisitor } from './staticVisitor'; -import { visit as baseVisit, Visitor } from './visitor'; - +import { REGISTERED_NODE_KINDS, type Node, type NodeKind } from '@codama/nodes'; +import { staticVisitor } from '../staticVisitor'; +import { type Visitor, visit as baseVisit } from '../visitor'; + +/** + * Merge visitor: traverses the tree collecting per-node values into + * a single result via a user-supplied `merge` function. Leaf nodes + * (or nodes outside `keys`) yield `leafValue(node)`; every other + * visited node's value is `merge(node, [])`. + */ export function mergeVisitor( leafValue: (node: Node) => TReturn, merge: (node: Node, values: TReturn[]) => TReturn, @@ -15,129 +20,63 @@ export function mergeVisitor( (node: Node): TReturn[] => keys.includes(node.kind) ? [baseVisit(node, v)] : []; - if (keys.includes('rootNode')) { - visitor.visitRoot = function visitRoot(node) { - return merge(node, getAllPrograms(node).flatMap(visit(this))); - }; - } - - if (keys.includes('programNode')) { - visitor.visitProgram = function visitProgram(node) { - return merge(node, [ - ...node.pdas.flatMap(visit(this)), - ...node.accounts.flatMap(visit(this)), - ...(node.events ?? []).flatMap(visit(this)), - ...node.instructions.flatMap(visit(this)), - ...node.definedTypes.flatMap(visit(this)), - ...node.errors.flatMap(visit(this)), - ...(node.constants ?? []).flatMap(visit(this)), - ]); - }; - } - - if (keys.includes('pdaNode')) { - visitor.visitPda = function visitPda(node) { - return merge(node, node.seeds.flatMap(visit(this))); - }; - } - - if (keys.includes('accountNode')) { - visitor.visitAccount = function visitAccount(node) { - return merge(node, [ - ...visit(this)(node.data), - ...(node.pda ? visit(this)(node.pda) : []), - ...(node.discriminators ?? []).flatMap(visit(this)), - ]); - }; - } - - if (keys.includes('eventNode')) { - visitor.visitEvent = function visitEvent(node) { - return merge(node, [...visit(this)(node.data), ...(node.discriminators ?? []).flatMap(visit(this))]); - }; - } - - if (keys.includes('instructionNode')) { - visitor.visitInstruction = function visitInstruction(node) { - return merge(node, [ - ...(node.status ? visit(this)(node.status) : []), - ...node.accounts.flatMap(visit(this)), - ...node.arguments.flatMap(visit(this)), - ...(node.extraArguments ?? []).flatMap(visit(this)), - ...(node.remainingAccounts ?? []).flatMap(visit(this)), - ...(node.byteDeltas ?? []).flatMap(visit(this)), - ...(node.discriminators ?? []).flatMap(visit(this)), - ...(node.subInstructions ?? []).flatMap(visit(this)), - ]); + if (keys.includes('amountTypeNode')) { + visitor.visitAmountType = function visitAmountType(node) { + return merge(node, visit(this)(node.number)); }; } - if (keys.includes('instructionAccountNode')) { - visitor.visitInstructionAccount = function visitInstructionAccount(node) { - return merge(node, [...(node.defaultValue ? visit(this)(node.defaultValue) : [])]); + if (keys.includes('arrayTypeNode')) { + visitor.visitArrayType = function visitArrayType(node) { + return merge(node, [...visit(this)(node.count), ...visit(this)(node.item)]); }; } - if (keys.includes('instructionArgumentNode')) { - visitor.visitInstructionArgument = function visitInstructionArgument(node) { - return merge(node, [ - ...visit(this)(node.type), - ...(node.defaultValue ? visit(this)(node.defaultValue) : []), - ]); + if (keys.includes('booleanTypeNode')) { + visitor.visitBooleanType = function visitBooleanType(node) { + return merge(node, visit(this)(node.size)); }; } - if (keys.includes('instructionRemainingAccountsNode')) { - visitor.visitInstructionRemainingAccounts = function visitInstructionRemainingAccounts(node) { - return merge(node, visit(this)(node.value)); + if (keys.includes('dateTimeTypeNode')) { + visitor.visitDateTimeType = function visitDateTimeType(node) { + return merge(node, visit(this)(node.number)); }; } - if (keys.includes('instructionByteDeltaNode')) { - visitor.visitInstructionByteDelta = function visitInstructionByteDelta(node) { - return merge(node, visit(this)(node.value)); + if (keys.includes('enumStructVariantTypeNode')) { + visitor.visitEnumStructVariantType = function visitEnumStructVariantType(node) { + return merge(node, visit(this)(node.struct)); }; } - if (keys.includes('instructionStatusNode')) { - visitor.visitInstructionStatus = function visitInstructionStatus(node) { - return merge(node, []); + if (keys.includes('enumTupleVariantTypeNode')) { + visitor.visitEnumTupleVariantType = function visitEnumTupleVariantType(node) { + return merge(node, visit(this)(node.tuple)); }; } - if (keys.includes('constantNode')) { - visitor.visitConstant = function visitConstant(node) { - return merge(node, [...visit(this)(node.type), ...visit(this)(node.value)]); + if (keys.includes('enumTypeNode')) { + visitor.visitEnumType = function visitEnumType(node) { + return merge(node, [...visit(this)(node.size), ...(node.variants ?? []).flatMap(visit(this))]); }; } - if (keys.includes('definedTypeNode')) { - visitor.visitDefinedType = function visitDefinedType(node) { + if (keys.includes('fixedSizeTypeNode')) { + visitor.visitFixedSizeType = function visitFixedSizeType(node) { return merge(node, visit(this)(node.type)); }; } - if (keys.includes('arrayTypeNode')) { - visitor.visitArrayType = function visitArrayType(node) { - return merge(node, [...visit(this)(node.count), ...visit(this)(node.item)]); - }; - } - - if (keys.includes('enumTypeNode')) { - visitor.visitEnumType = function visitEnumType(node) { - return merge(node, [...visit(this)(node.size), ...node.variants.flatMap(visit(this))]); - }; - } - - if (keys.includes('enumStructVariantTypeNode')) { - visitor.visitEnumStructVariantType = function visitEnumStructVariantType(node) { - return merge(node, visit(this)(node.struct)); + if (keys.includes('hiddenPrefixTypeNode')) { + visitor.visitHiddenPrefixType = function visitHiddenPrefixType(node) { + return merge(node, [...(node.prefix ?? []).flatMap(visit(this)), ...visit(this)(node.type)]); }; } - if (keys.includes('enumTupleVariantTypeNode')) { - visitor.visitEnumTupleVariantType = function visitEnumTupleVariantType(node) { - return merge(node, visit(this)(node.tuple)); + if (keys.includes('hiddenSuffixTypeNode')) { + visitor.visitHiddenSuffixType = function visitHiddenSuffixType(node) { + return merge(node, [...visit(this)(node.type), ...(node.suffix ?? []).flatMap(visit(this))]); }; } @@ -153,9 +92,15 @@ export function mergeVisitor( }; } - if (keys.includes('zeroableOptionTypeNode')) { - visitor.visitZeroableOptionType = function visitZeroableOptionType(node) { - return merge(node, [...visit(this)(node.item), ...(node.zeroValue ? visit(this)(node.zeroValue) : [])]); + if (keys.includes('postOffsetTypeNode')) { + visitor.visitPostOffsetType = function visitPostOffsetType(node) { + return merge(node, visit(this)(node.type)); + }; + } + + if (keys.includes('preOffsetTypeNode')) { + visitor.visitPreOffsetType = function visitPreOffsetType(node) { + return merge(node, visit(this)(node.type)); }; } @@ -165,9 +110,9 @@ export function mergeVisitor( }; } - if (keys.includes('booleanTypeNode')) { - visitor.visitBooleanType = function visitBooleanType(node) { - return merge(node, visit(this)(node.size)); + if (keys.includes('sentinelTypeNode')) { + visitor.visitSentinelType = function visitSentinelType(node) { + return merge(node, [...visit(this)(node.sentinel), ...visit(this)(node.type)]); }; } @@ -177,9 +122,15 @@ export function mergeVisitor( }; } - if (keys.includes('structTypeNode')) { - visitor.visitStructType = function visitStructType(node) { - return merge(node, node.fields.flatMap(visit(this))); + if (keys.includes('sizePrefixTypeNode')) { + visitor.visitSizePrefixType = function visitSizePrefixType(node) { + return merge(node, [...visit(this)(node.prefix), ...visit(this)(node.type)]); + }; + } + + if (keys.includes('solAmountTypeNode')) { + visitor.visitSolAmountType = function visitSolAmountType(node) { + return merge(node, visit(this)(node.number)); }; } @@ -192,39 +143,27 @@ export function mergeVisitor( }; } - if (keys.includes('tupleTypeNode')) { - visitor.visitTupleType = function visitTupleType(node) { - return merge(node, node.items.flatMap(visit(this))); - }; - } - - if (keys.includes('amountTypeNode')) { - visitor.visitAmountType = function visitAmountType(node) { - return merge(node, visit(this)(node.number)); - }; - } - - if (keys.includes('dateTimeTypeNode')) { - visitor.visitDateTimeType = function visitDateTimeType(node) { - return merge(node, visit(this)(node.number)); + if (keys.includes('structTypeNode')) { + visitor.visitStructType = function visitStructType(node) { + return merge(node, (node.fields ?? []).flatMap(visit(this))); }; } - if (keys.includes('solAmountTypeNode')) { - visitor.visitSolAmountType = function visitSolAmountType(node) { - return merge(node, visit(this)(node.number)); + if (keys.includes('tupleTypeNode')) { + visitor.visitTupleType = function visitTupleType(node) { + return merge(node, (node.items ?? []).flatMap(visit(this))); }; } - if (keys.includes('prefixedCountNode')) { - visitor.visitPrefixedCount = function visitPrefixedCount(node) { - return merge(node, visit(this)(node.prefix)); + if (keys.includes('zeroableOptionTypeNode')) { + visitor.visitZeroableOptionType = function visitZeroableOptionType(node) { + return merge(node, [...visit(this)(node.item), ...(node.zeroValue ? visit(this)(node.zeroValue) : [])]); }; } if (keys.includes('arrayValueNode')) { visitor.visitArrayValue = function visitArrayValue(node) { - return merge(node, node.items.flatMap(visit(this))); + return merge(node, (node.items ?? []).flatMap(visit(this))); }; } @@ -240,21 +179,21 @@ export function mergeVisitor( }; } - if (keys.includes('mapValueNode')) { - visitor.visitMapValue = function visitMapValue(node) { - return merge(node, node.entries.flatMap(visit(this))); - }; - } - if (keys.includes('mapEntryValueNode')) { visitor.visitMapEntryValue = function visitMapEntryValue(node) { return merge(node, [...visit(this)(node.key), ...visit(this)(node.value)]); }; } + if (keys.includes('mapValueNode')) { + visitor.visitMapValue = function visitMapValue(node) { + return merge(node, (node.entries ?? []).flatMap(visit(this))); + }; + } + if (keys.includes('setValueNode')) { visitor.visitSetValue = function visitSetValue(node) { - return merge(node, node.items.flatMap(visit(this))); + return merge(node, (node.items ?? []).flatMap(visit(this))); }; } @@ -264,21 +203,57 @@ export function mergeVisitor( }; } - if (keys.includes('structValueNode')) { - visitor.visitStructValue = function visitStructValue(node) { - return merge(node, node.fields.flatMap(visit(this))); - }; - } - if (keys.includes('structFieldValueNode')) { visitor.visitStructFieldValue = function visitStructFieldValue(node) { return merge(node, visit(this)(node.value)); }; } + if (keys.includes('structValueNode')) { + visitor.visitStructValue = function visitStructValue(node) { + return merge(node, (node.fields ?? []).flatMap(visit(this))); + }; + } + if (keys.includes('tupleValueNode')) { visitor.visitTupleValue = function visitTupleValue(node) { - return merge(node, node.items.flatMap(visit(this))); + return merge(node, (node.items ?? []).flatMap(visit(this))); + }; + } + + if (keys.includes('accountLinkNode')) { + visitor.visitAccountLink = function visitAccountLink(node) { + return merge(node, node.program ? visit(this)(node.program) : []); + }; + } + + if (keys.includes('definedTypeLinkNode')) { + visitor.visitDefinedTypeLink = function visitDefinedTypeLink(node) { + return merge(node, node.program ? visit(this)(node.program) : []); + }; + } + + if (keys.includes('instructionAccountLinkNode')) { + visitor.visitInstructionAccountLink = function visitInstructionAccountLink(node) { + return merge(node, node.instruction ? visit(this)(node.instruction) : []); + }; + } + + if (keys.includes('instructionArgumentLinkNode')) { + visitor.visitInstructionArgumentLink = function visitInstructionArgumentLink(node) { + return merge(node, node.instruction ? visit(this)(node.instruction) : []); + }; + } + + if (keys.includes('instructionLinkNode')) { + visitor.visitInstructionLink = function visitInstructionLink(node) { + return merge(node, node.program ? visit(this)(node.program) : []); + }; + } + + if (keys.includes('pdaLinkNode')) { + visitor.visitPdaLink = function visitPdaLink(node) { + return merge(node, node.program ? visit(this)(node.program) : []); }; } @@ -294,9 +269,15 @@ export function mergeVisitor( }; } - if (keys.includes('resolverValueNode')) { - visitor.visitResolverValue = function visitResolverValue(node) { - return merge(node, (node.dependsOn ?? []).flatMap(visit(this))); + if (keys.includes('prefixedCountNode')) { + visitor.visitPrefixedCount = function visitPrefixedCount(node) { + return merge(node, visit(this)(node.prefix)); + }; + } + + if (keys.includes('constantDiscriminatorNode')) { + visitor.visitConstantDiscriminator = function visitConstantDiscriminator(node) { + return merge(node, visit(this)(node.constant)); }; } @@ -311,99 +292,121 @@ export function mergeVisitor( }; } - if (keys.includes('pdaValueNode')) { - visitor.visitPdaValue = function visitPdaValue(node) { - return merge(node, [...visit(this)(node.pda), ...node.seeds.flatMap(visit(this))]); - }; - } - if (keys.includes('pdaSeedValueNode')) { visitor.visitPdaSeedValue = function visitPdaSeedValue(node) { return merge(node, visit(this)(node.value)); }; } - if (keys.includes('fixedSizeTypeNode')) { - visitor.visitFixedSizeType = function visitFixedSizeType(node) { - return merge(node, visit(this)(node.type)); + if (keys.includes('pdaValueNode')) { + visitor.visitPdaValue = function visitPdaValue(node) { + return merge(node, [ + ...visit(this)(node.pda), + ...(node.seeds ?? []).flatMap(visit(this)), + ...(node.programId ? visit(this)(node.programId) : []), + ]); }; } - if (keys.includes('sizePrefixTypeNode')) { - visitor.visitSizePrefixType = function visitSizePrefixType(node) { - return merge(node, [...visit(this)(node.prefix), ...visit(this)(node.type)]); + if (keys.includes('resolverValueNode')) { + visitor.visitResolverValue = function visitResolverValue(node) { + return merge(node, (node.dependsOn ?? []).flatMap(visit(this))); }; } - if (keys.includes('preOffsetTypeNode')) { - visitor.visitPreOffsetType = function visitPreOffsetType(node) { - return merge(node, visit(this)(node.type)); + if (keys.includes('accountNode')) { + visitor.visitAccount = function visitAccount(node) { + return merge(node, [ + ...visit(this)(node.data), + ...(node.pda ? visit(this)(node.pda) : []), + ...(node.discriminators ?? []).flatMap(visit(this)), + ]); }; } - if (keys.includes('postOffsetTypeNode')) { - visitor.visitPostOffsetType = function visitPostOffsetType(node) { - return merge(node, visit(this)(node.type)); + if (keys.includes('constantNode')) { + visitor.visitConstant = function visitConstant(node) { + return merge(node, [...visit(this)(node.type), ...visit(this)(node.value)]); }; } - if (keys.includes('sentinelTypeNode')) { - visitor.visitSentinelType = function visitSentinelType(node) { - return merge(node, [...visit(this)(node.sentinel), ...visit(this)(node.type)]); + if (keys.includes('definedTypeNode')) { + visitor.visitDefinedType = function visitDefinedType(node) { + return merge(node, visit(this)(node.type)); }; } - if (keys.includes('hiddenPrefixTypeNode')) { - visitor.visitHiddenPrefixType = function visitHiddenPrefixType(node) { - return merge(node, [...node.prefix.flatMap(visit(this)), ...visit(this)(node.type)]); + if (keys.includes('eventNode')) { + visitor.visitEvent = function visitEvent(node) { + return merge(node, [...visit(this)(node.data), ...(node.discriminators ?? []).flatMap(visit(this))]); }; } - if (keys.includes('hiddenSuffixTypeNode')) { - visitor.visitHiddenSuffixType = function visitHiddenSuffixType(node) { - return merge(node, [...visit(this)(node.type), ...node.suffix.flatMap(visit(this))]); + if (keys.includes('instructionAccountNode')) { + visitor.visitInstructionAccount = function visitInstructionAccount(node) { + return merge(node, node.defaultValue ? visit(this)(node.defaultValue) : []); }; } - if (keys.includes('constantDiscriminatorNode')) { - visitor.visitConstantDiscriminator = function visitConstantDiscriminator(node) { - return merge(node, visit(this)(node.constant)); + if (keys.includes('instructionArgumentNode')) { + visitor.visitInstructionArgument = function visitInstructionArgument(node) { + return merge(node, [ + ...visit(this)(node.type), + ...(node.defaultValue ? visit(this)(node.defaultValue) : []), + ]); }; } - if (keys.includes('accountLinkNode')) { - visitor.visitAccountLink = function visitAccountLink(node) { - return merge(node, node.program ? visit(this)(node.program) : []); + if (keys.includes('instructionByteDeltaNode')) { + visitor.visitInstructionByteDelta = function visitInstructionByteDelta(node) { + return merge(node, visit(this)(node.value)); }; } - if (keys.includes('definedTypeLinkNode')) { - visitor.visitDefinedTypeLink = function visitDefinedTypeLink(node) { - return merge(node, node.program ? visit(this)(node.program) : []); + if (keys.includes('instructionNode')) { + visitor.visitInstruction = function visitInstruction(node) { + return merge(node, [ + ...(node.status ? visit(this)(node.status) : []), + ...(node.accounts ?? []).flatMap(visit(this)), + ...(node.arguments ?? []).flatMap(visit(this)), + ...(node.extraArguments ?? []).flatMap(visit(this)), + ...(node.remainingAccounts ?? []).flatMap(visit(this)), + ...(node.byteDeltas ?? []).flatMap(visit(this)), + ...(node.discriminators ?? []).flatMap(visit(this)), + ...(node.subInstructions ?? []).flatMap(visit(this)), + ]); }; } - if (keys.includes('instructionLinkNode')) { - visitor.visitInstructionLink = function visitInstructionLink(node) { - return merge(node, node.program ? visit(this)(node.program) : []); + if (keys.includes('instructionRemainingAccountsNode')) { + visitor.visitInstructionRemainingAccounts = function visitInstructionRemainingAccounts(node) { + return merge(node, visit(this)(node.value)); }; } - if (keys.includes('instructionAccountLinkNode')) { - visitor.visitInstructionAccountLink = function visitInstructionAccountLink(node) { - return merge(node, node.instruction ? visit(this)(node.instruction) : []); + if (keys.includes('pdaNode')) { + visitor.visitPda = function visitPda(node) { + return merge(node, (node.seeds ?? []).flatMap(visit(this))); }; } - if (keys.includes('instructionArgumentLinkNode')) { - visitor.visitInstructionArgumentLink = function visitInstructionArgumentLink(node) { - return merge(node, node.instruction ? visit(this)(node.instruction) : []); + if (keys.includes('programNode')) { + visitor.visitProgram = function visitProgram(node) { + return merge(node, [ + ...(node.pdas ?? []).flatMap(visit(this)), + ...(node.accounts ?? []).flatMap(visit(this)), + ...(node.events ?? []).flatMap(visit(this)), + ...(node.instructions ?? []).flatMap(visit(this)), + ...(node.definedTypes ?? []).flatMap(visit(this)), + ...(node.errors ?? []).flatMap(visit(this)), + ...(node.constants ?? []).flatMap(visit(this)), + ]); }; } - if (keys.includes('pdaLinkNode')) { - visitor.visitPdaLink = function visitPdaLink(node) { - return merge(node, node.program ? visit(this)(node.program) : []); + if (keys.includes('rootNode')) { + visitor.visitRoot = function visitRoot(node) { + return merge(node, [...visit(this)(node.program), ...(node.additionalPrograms ?? []).flatMap(visit(this))]); }; } diff --git a/packages/visitors-core/src/generated/nodeTestPaths.ts b/packages/visitors-core/src/generated/nodeTestPaths.ts new file mode 100644 index 000000000..08493d86c --- /dev/null +++ b/packages/visitors-core/src/generated/nodeTestPaths.ts @@ -0,0 +1,92 @@ +import type { NodeKind } from '@codama/nodes'; + +/** + * The expected `test/nodes/` file path for each node kind, relative + * to `test/nodes/` and without the `.test.ts` suffix. Used by the + * per-node test fixture coverage gate. + */ +export const NODE_TEST_PATHS: Readonly> = { + accountBumpValueNode: 'contextualValueNodes/AccountBumpValueNode', + accountLinkNode: 'linkNodes/AccountLinkNode', + accountNode: 'AccountNode', + accountValueNode: 'contextualValueNodes/AccountValueNode', + amountTypeNode: 'typeNodes/AmountTypeNode', + argumentValueNode: 'contextualValueNodes/ArgumentValueNode', + arrayTypeNode: 'typeNodes/ArrayTypeNode', + arrayValueNode: 'valueNodes/ArrayValueNode', + booleanTypeNode: 'typeNodes/BooleanTypeNode', + booleanValueNode: 'valueNodes/BooleanValueNode', + bytesTypeNode: 'typeNodes/BytesTypeNode', + bytesValueNode: 'valueNodes/BytesValueNode', + conditionalValueNode: 'contextualValueNodes/ConditionalValueNode', + constantDiscriminatorNode: 'discriminatorNodes/ConstantDiscriminatorNode', + constantNode: 'ConstantNode', + constantPdaSeedNode: 'pdaSeedNodes/ConstantPdaSeedNode', + constantValueNode: 'valueNodes/ConstantValueNode', + dateTimeTypeNode: 'typeNodes/DateTimeTypeNode', + definedTypeLinkNode: 'linkNodes/DefinedTypeLinkNode', + definedTypeNode: 'DefinedTypeNode', + enumEmptyVariantTypeNode: 'typeNodes/EnumEmptyVariantTypeNode', + enumStructVariantTypeNode: 'typeNodes/EnumStructVariantTypeNode', + enumTupleVariantTypeNode: 'typeNodes/EnumTupleVariantTypeNode', + enumTypeNode: 'typeNodes/EnumTypeNode', + enumValueNode: 'valueNodes/EnumValueNode', + errorNode: 'ErrorNode', + eventNode: 'EventNode', + fieldDiscriminatorNode: 'discriminatorNodes/FieldDiscriminatorNode', + fixedCountNode: 'countNodes/FixedCountNode', + fixedSizeTypeNode: 'typeNodes/FixedSizeTypeNode', + hiddenPrefixTypeNode: 'typeNodes/HiddenPrefixTypeNode', + hiddenSuffixTypeNode: 'typeNodes/HiddenSuffixTypeNode', + identityValueNode: 'contextualValueNodes/IdentityValueNode', + instructionAccountLinkNode: 'linkNodes/InstructionAccountLinkNode', + instructionAccountNode: 'InstructionAccountNode', + instructionArgumentLinkNode: 'linkNodes/InstructionArgumentLinkNode', + instructionArgumentNode: 'InstructionArgumentNode', + instructionByteDeltaNode: 'InstructionByteDeltaNode', + instructionLinkNode: 'linkNodes/InstructionLinkNode', + instructionNode: 'InstructionNode', + instructionRemainingAccountsNode: 'InstructionRemainingAccountsNode', + instructionStatusNode: 'InstructionStatusNode', + mapEntryValueNode: 'valueNodes/MapEntryValueNode', + mapTypeNode: 'typeNodes/MapTypeNode', + mapValueNode: 'valueNodes/MapValueNode', + noneValueNode: 'valueNodes/NoneValueNode', + numberTypeNode: 'typeNodes/NumberTypeNode', + numberValueNode: 'valueNodes/NumberValueNode', + optionTypeNode: 'typeNodes/OptionTypeNode', + payerValueNode: 'contextualValueNodes/PayerValueNode', + pdaLinkNode: 'linkNodes/PdaLinkNode', + pdaNode: 'PdaNode', + pdaSeedValueNode: 'contextualValueNodes/PdaSeedValueNode', + pdaValueNode: 'contextualValueNodes/PdaValueNode', + postOffsetTypeNode: 'typeNodes/PostOffsetTypeNode', + prefixedCountNode: 'countNodes/PrefixedCountNode', + preOffsetTypeNode: 'typeNodes/PreOffsetTypeNode', + programIdValueNode: 'contextualValueNodes/ProgramIdValueNode', + programLinkNode: 'linkNodes/ProgramLinkNode', + programNode: 'ProgramNode', + publicKeyTypeNode: 'typeNodes/PublicKeyTypeNode', + publicKeyValueNode: 'valueNodes/PublicKeyValueNode', + remainderCountNode: 'countNodes/RemainderCountNode', + remainderOptionTypeNode: 'typeNodes/RemainderOptionTypeNode', + resolverValueNode: 'contextualValueNodes/ResolverValueNode', + rootNode: 'RootNode', + sentinelTypeNode: 'typeNodes/SentinelTypeNode', + setTypeNode: 'typeNodes/SetTypeNode', + setValueNode: 'valueNodes/SetValueNode', + sizeDiscriminatorNode: 'discriminatorNodes/SizeDiscriminatorNode', + sizePrefixTypeNode: 'typeNodes/SizePrefixTypeNode', + solAmountTypeNode: 'typeNodes/SolAmountTypeNode', + someValueNode: 'valueNodes/SomeValueNode', + stringTypeNode: 'typeNodes/StringTypeNode', + stringValueNode: 'valueNodes/StringValueNode', + structFieldTypeNode: 'typeNodes/StructFieldTypeNode', + structFieldValueNode: 'valueNodes/StructFieldValueNode', + structTypeNode: 'typeNodes/StructTypeNode', + structValueNode: 'valueNodes/StructValueNode', + tupleTypeNode: 'typeNodes/TupleTypeNode', + tupleValueNode: 'valueNodes/TupleValueNode', + variablePdaSeedNode: 'pdaSeedNodes/VariablePdaSeedNode', + zeroableOptionTypeNode: 'typeNodes/ZeroableOptionTypeNode', +}; diff --git a/packages/visitors-core/src/getByteSizeVisitor.ts b/packages/visitors-core/src/getByteSizeVisitor.ts index 5c6c0d6b0..c699922c0 100644 --- a/packages/visitors-core/src/getByteSizeVisitor.ts +++ b/packages/visitors-core/src/getByteSizeVisitor.ts @@ -1,8 +1,8 @@ import { CountNode, isNode, isScalarEnum, REGISTERED_TYPE_NODE_KINDS, RegisteredTypeNode } from '@codama/nodes'; import { extendVisitor } from './extendVisitor'; +import { mergeVisitor } from './generated/mergeVisitor'; import { LinkableDictionary } from './LinkableDictionary'; -import { mergeVisitor } from './mergeVisitor'; import { getLastNodeFromPath } from './NodePath'; import { NodeStack } from './NodeStack'; import { pipe } from './pipe'; diff --git a/packages/visitors-core/src/getDebugStringVisitor.ts b/packages/visitors-core/src/getDebugStringVisitor.ts index 8e412ca4f..c8fbd86c9 100644 --- a/packages/visitors-core/src/getDebugStringVisitor.ts +++ b/packages/visitors-core/src/getDebugStringVisitor.ts @@ -1,7 +1,7 @@ import { Node } from '@codama/nodes'; +import { mergeVisitor } from './generated/mergeVisitor'; import { interceptVisitor } from './interceptVisitor'; -import { mergeVisitor } from './mergeVisitor'; import { pipe } from './pipe'; import { Visitor } from './visitor'; diff --git a/packages/visitors-core/src/getMaxByteSizeVisitor.ts b/packages/visitors-core/src/getMaxByteSizeVisitor.ts index 651d5842c..669d0cf8c 100644 --- a/packages/visitors-core/src/getMaxByteSizeVisitor.ts +++ b/packages/visitors-core/src/getMaxByteSizeVisitor.ts @@ -1,9 +1,9 @@ import { CountNode, isNode, isScalarEnum, REGISTERED_TYPE_NODE_KINDS } from '@codama/nodes'; import { extendVisitor } from './extendVisitor'; +import { mergeVisitor } from './generated/mergeVisitor'; import { ByteSizeVisitorKeys } from './getByteSizeVisitor'; import { LinkableDictionary } from './LinkableDictionary'; -import { mergeVisitor } from './mergeVisitor'; import { getLastNodeFromPath } from './NodePath'; import { NodeStack } from './NodeStack'; import { pipe } from './pipe'; diff --git a/packages/visitors-core/src/identityVisitor.ts b/packages/visitors-core/src/identityVisitor.ts index fc0faf600..214744b33 100644 --- a/packages/visitors-core/src/identityVisitor.ts +++ b/packages/visitors-core/src/identityVisitor.ts @@ -1,287 +1,64 @@ import { - accountLinkNode, - accountNode, - amountTypeNode, - arrayTypeNode, - arrayValueNode, - assertIsNestedTypeNode, assertIsNode, - booleanTypeNode, - conditionalValueNode, - constantDiscriminatorNode, - constantNode, - constantPdaSeedNode, - constantValueNode, - COUNT_NODES, - dateTimeTypeNode, - definedTypeLinkNode, - definedTypeNode, - DISCRIMINATOR_NODES, - ENUM_VARIANT_TYPE_NODES, enumEmptyVariantTypeNode, enumStructVariantTypeNode, enumTupleVariantTypeNode, - enumTypeNode, - enumValueNode, - eventNode, - fixedSizeTypeNode, hiddenPrefixTypeNode, hiddenSuffixTypeNode, - INSTRUCTION_INPUT_VALUE_NODES, - instructionAccountLinkNode, - instructionAccountNode, - instructionArgumentLinkNode, - instructionArgumentNode, - instructionByteDeltaNode, - instructionLinkNode, - instructionNode, - instructionRemainingAccountsNode, - instructionStatusNode, - mapEntryValueNode, - mapTypeNode, - mapValueNode, - Node, - NodeKind, - optionTypeNode, - PDA_SEED_NODES, - pdaLinkNode, - pdaNode, - pdaSeedValueNode, - pdaValueNode, - postOffsetTypeNode, - prefixedCountNode, - preOffsetTypeNode, - programNode, + type Node, + type NodeKind, REGISTERED_NODE_KINDS, - remainderOptionTypeNode, removeNullAndAssertIsNodeFilter, resolverValueNode, - rootNode, - sentinelTypeNode, - setTypeNode, - setValueNode, - sizePrefixTypeNode, - solAmountTypeNode, - someValueNode, - structFieldTypeNode, - structFieldValueNode, - structTypeNode, - structValueNode, - tupleTypeNode, - tupleValueNode, TYPE_NODES, - VALUE_NODES, - variablePdaSeedNode, - zeroableOptionTypeNode, } from '@codama/nodes'; -import { staticVisitor } from './staticVisitor'; -import { visit as baseVisit, Visitor } from './visitor'; - +import { extendVisitor, type VisitorOverrides } from './extendVisitor'; +import { identityVisitor as identityVisitorCore } from './generated/identityVisitor'; +import { visit as baseVisit, type Visitor } from './visitor'; + +/** + * Identity visitor: rebuilds the tree node-by-node so callers can + * intercept individual nodes via override hooks while leaving the rest + * untouched. Returns `null` to drop a node (and its parents that + * required it). + * + * The mechanical walk lives in `./generated/identityVisitor` (one + * branch per spec node, derived from the attribute structure of + * `@codama/spec`). This wrapper layers a handful of *semantic* + * overrides on top — transformations that aren't derivable from the + * spec alone: + * + * - `enumStructVariantTypeNode` / `enumTupleVariantTypeNode`: + * downgrade to `enumEmptyVariantTypeNode` when the payload is + * empty (no fields / no items). + * - `hiddenPrefixTypeNode` / `hiddenSuffixTypeNode`: drop the + * wrapper when the prefix/suffix array is empty. + * - `conditionalValueNode`: return `null` when both `ifTrue` and + * `ifFalse` are absent post-walk. + * - `resolverValueNode`: collapse an empty `dependsOn` array back + * to `undefined` so equality checks remain stable. + */ export function identityVisitor( options: { keys?: TNodeKind[] } = {}, ): Visitor { const keys: NodeKind[] = options.keys ?? (REGISTERED_NODE_KINDS as TNodeKind[]); - const visitor = staticVisitor(node => Object.freeze({ ...node }), { keys }) as Visitor; + const base = identityVisitorCore(options); + // Build overrides against the broad `NodeKind` shape; the cast + // back to the narrowed visitor happens at `extendVisitor`'s + // return. This mirrors the pattern the hand-written visitor used + // for years: every override is type-checked against the full Node + // union, then `extendVisitor` ignores any override whose kind + // isn't actually in `keys` at runtime. + const overrides: VisitorOverrides = {}; const visit = (v: Visitor) => (node: Node): Node | null => keys.includes(node.kind) ? baseVisit(node, v) : Object.freeze({ ...node }); - if (keys.includes('rootNode')) { - visitor.visitRoot = function visitRoot(node) { - const program = visit(this)(node.program); - if (program === null) return null; - assertIsNode(program, 'programNode'); - return rootNode( - program, - node.additionalPrograms.map(visit(this)).filter(removeNullAndAssertIsNodeFilter('programNode')), - ); - }; - } - - if (keys.includes('programNode')) { - visitor.visitProgram = function visitProgram(node) { - return programNode({ - ...node, - accounts: node.accounts.map(visit(this)).filter(removeNullAndAssertIsNodeFilter('accountNode')), - constants: (node.constants ?? []) - .map(visit(this)) - .filter(removeNullAndAssertIsNodeFilter('constantNode')), - definedTypes: node.definedTypes - .map(visit(this)) - .filter(removeNullAndAssertIsNodeFilter('definedTypeNode')), - errors: node.errors.map(visit(this)).filter(removeNullAndAssertIsNodeFilter('errorNode')), - events: (node.events ?? []).map(visit(this)).filter(removeNullAndAssertIsNodeFilter('eventNode')), - instructions: node.instructions - .map(visit(this)) - .filter(removeNullAndAssertIsNodeFilter('instructionNode')), - pdas: node.pdas.map(visit(this)).filter(removeNullAndAssertIsNodeFilter('pdaNode')), - }); - }; - } - - if (keys.includes('pdaNode')) { - visitor.visitPda = function visitPda(node) { - return pdaNode({ - ...node, - seeds: node.seeds.map(visit(this)).filter(removeNullAndAssertIsNodeFilter(PDA_SEED_NODES)), - }); - }; - } - - if (keys.includes('accountNode')) { - visitor.visitAccount = function visitAccount(node) { - const data = visit(this)(node.data); - if (data === null) return null; - assertIsNode(data, 'structTypeNode'); - const pda = node.pda ? (visit(this)(node.pda) ?? undefined) : undefined; - if (pda) assertIsNode(pda, 'pdaLinkNode'); - return accountNode({ ...node, data, pda }); - }; - } - - if (keys.includes('eventNode')) { - visitor.visitEvent = function visitEvent(node) { - const data = visit(this)(node.data); - if (data === null) return null; - assertIsNode(data, TYPE_NODES); - return eventNode({ - ...node, - data, - discriminators: node.discriminators - ? node.discriminators.map(visit(this)).filter(removeNullAndAssertIsNodeFilter(DISCRIMINATOR_NODES)) - : undefined, - }); - }; - } - - if (keys.includes('instructionNode')) { - visitor.visitInstruction = function visitInstruction(node) { - const status = node.status ? (visit(this)(node.status) ?? undefined) : undefined; - if (status) assertIsNode(status, 'instructionStatusNode'); - return instructionNode({ - ...node, - accounts: node.accounts - .map(visit(this)) - .filter(removeNullAndAssertIsNodeFilter('instructionAccountNode')), - arguments: node.arguments - .map(visit(this)) - .filter(removeNullAndAssertIsNodeFilter('instructionArgumentNode')), - byteDeltas: node.byteDeltas - ? node.byteDeltas - .map(visit(this)) - .filter(removeNullAndAssertIsNodeFilter('instructionByteDeltaNode')) - : undefined, - discriminators: node.discriminators - ? node.discriminators.map(visit(this)).filter(removeNullAndAssertIsNodeFilter(DISCRIMINATOR_NODES)) - : undefined, - extraArguments: node.extraArguments - ? node.extraArguments - .map(visit(this)) - .filter(removeNullAndAssertIsNodeFilter('instructionArgumentNode')) - : undefined, - remainingAccounts: node.remainingAccounts - ? node.remainingAccounts - .map(visit(this)) - .filter(removeNullAndAssertIsNodeFilter('instructionRemainingAccountsNode')) - : undefined, - status, - subInstructions: node.subInstructions - ? node.subInstructions.map(visit(this)).filter(removeNullAndAssertIsNodeFilter('instructionNode')) - : undefined, - }); - }; - } - - if (keys.includes('instructionAccountNode')) { - visitor.visitInstructionAccount = function visitInstructionAccount(node) { - const defaultValue = node.defaultValue ? (visit(this)(node.defaultValue) ?? undefined) : undefined; - if (defaultValue) assertIsNode(defaultValue, INSTRUCTION_INPUT_VALUE_NODES); - return instructionAccountNode({ ...node, defaultValue }); - }; - } - - if (keys.includes('instructionArgumentNode')) { - visitor.visitInstructionArgument = function visitInstructionArgument(node) { - const type = visit(this)(node.type); - if (type === null) return null; - assertIsNode(type, TYPE_NODES); - const defaultValue = node.defaultValue ? (visit(this)(node.defaultValue) ?? undefined) : undefined; - if (defaultValue) assertIsNode(defaultValue, INSTRUCTION_INPUT_VALUE_NODES); - return instructionArgumentNode({ ...node, defaultValue, type }); - }; - } - - if (keys.includes('instructionRemainingAccountsNode')) { - visitor.visitInstructionRemainingAccounts = function visitInstructionRemainingAccounts(node) { - const value = visit(this)(node.value); - if (value === null) return null; - assertIsNode(value, ['argumentValueNode', 'resolverValueNode']); - return instructionRemainingAccountsNode(value, { ...node }); - }; - } - - if (keys.includes('instructionByteDeltaNode')) { - visitor.visitInstructionByteDelta = function visitInstructionByteDelta(node) { - const value = visit(this)(node.value); - if (value === null) return null; - assertIsNode(value, ['numberValueNode', 'accountLinkNode', 'argumentValueNode', 'resolverValueNode']); - return instructionByteDeltaNode(value, { ...node }); - }; - } - - if (keys.includes('instructionStatusNode')) { - visitor.visitInstructionStatus = function visitInstructionStatus(node) { - return instructionStatusNode(node.lifecycle, node.message); - }; - } - - if (keys.includes('constantNode')) { - visitor.visitConstant = function visitConstant(node) { - const type = visit(this)(node.type); - if (type === null) return null; - assertIsNode(type, TYPE_NODES); - const value = visit(this)(node.value); - if (value === null) return null; - assertIsNode(value, VALUE_NODES); - return constantNode(node.name, type, value, node.docs); - }; - } - - if (keys.includes('definedTypeNode')) { - visitor.visitDefinedType = function visitDefinedType(node) { - const type = visit(this)(node.type); - if (type === null) return null; - assertIsNode(type, TYPE_NODES); - return definedTypeNode({ ...node, type }); - }; - } - - if (keys.includes('arrayTypeNode')) { - visitor.visitArrayType = function visitArrayType(node) { - const size = visit(this)(node.count); - if (size === null) return null; - assertIsNode(size, COUNT_NODES); - const item = visit(this)(node.item); - if (item === null) return null; - assertIsNode(item, TYPE_NODES); - return arrayTypeNode(item, size); - }; - } - - if (keys.includes('enumTypeNode')) { - visitor.visitEnumType = function visitEnumType(node) { - return enumTypeNode( - node.variants.map(visit(this)).filter(removeNullAndAssertIsNodeFilter(ENUM_VARIANT_TYPE_NODES)), - { size: node.size }, - ); - }; - } - - if (keys.includes('enumStructVariantTypeNode')) { - visitor.visitEnumStructVariantType = function visitEnumStructVariantType(node) { - const newStruct = visit(this)(node.struct); + if (keys.includes('enumStructVariantTypeNode' as TNodeKind)) { + overrides.visitEnumStructVariantType = function visitEnumStructVariantType(node, { self }) { + const newStruct = visit(self)(node.struct); if (!newStruct) { return enumEmptyVariantTypeNode(node.name); } @@ -289,13 +66,13 @@ export function identityVisitor( if (newStruct.fields.length === 0) { return enumEmptyVariantTypeNode(node.name); } - return enumStructVariantTypeNode(node.name, newStruct); + return enumStructVariantTypeNode(node.name, newStruct, node.discriminator); }; } - if (keys.includes('enumTupleVariantTypeNode')) { - visitor.visitEnumTupleVariantType = function visitEnumTupleVariantType(node) { - const newTuple = visit(this)(node.tuple); + if (keys.includes('enumTupleVariantTypeNode' as TNodeKind)) { + overrides.visitEnumTupleVariantType = function visitEnumTupleVariantType(node, { self }) { + const newTuple = visit(self)(node.tuple); if (!newTuple) { return enumEmptyVariantTypeNode(node.name); } @@ -303,251 +80,54 @@ export function identityVisitor( if (newTuple.items.length === 0) { return enumEmptyVariantTypeNode(node.name); } - return enumTupleVariantTypeNode(node.name, newTuple); - }; - } - - if (keys.includes('mapTypeNode')) { - visitor.visitMapType = function visitMapType(node) { - const size = visit(this)(node.count); - if (size === null) return null; - assertIsNode(size, COUNT_NODES); - const key = visit(this)(node.key); - if (key === null) return null; - assertIsNode(key, TYPE_NODES); - const value = visit(this)(node.value); - if (value === null) return null; - assertIsNode(value, TYPE_NODES); - return mapTypeNode(key, value, size); - }; - } - - if (keys.includes('optionTypeNode')) { - visitor.visitOptionType = function visitOptionType(node) { - const prefix = visit(this)(node.prefix); - if (prefix === null) return null; - assertIsNestedTypeNode(prefix, 'numberTypeNode'); - const item = visit(this)(node.item); - if (item === null) return null; - assertIsNode(item, TYPE_NODES); - return optionTypeNode(item, { ...node, prefix }); + return enumTupleVariantTypeNode(node.name, newTuple, node.discriminator); }; } - if (keys.includes('zeroableOptionTypeNode')) { - visitor.visitZeroableOptionType = function visitZeroableOptionType(node) { - const item = visit(this)(node.item); - if (item === null) return null; - assertIsNode(item, TYPE_NODES); - const zeroValue = node.zeroValue ? (visit(this)(node.zeroValue) ?? undefined) : undefined; - if (zeroValue) assertIsNode(zeroValue, 'constantValueNode'); - return zeroableOptionTypeNode(item, zeroValue); - }; - } - - if (keys.includes('remainderOptionTypeNode')) { - visitor.visitRemainderOptionType = function visitRemainderOptionType(node) { - const item = visit(this)(node.item); - if (item === null) return null; - assertIsNode(item, TYPE_NODES); - return remainderOptionTypeNode(item); - }; - } - - if (keys.includes('booleanTypeNode')) { - visitor.visitBooleanType = function visitBooleanType(node) { - const size = visit(this)(node.size); - if (size === null) return null; - assertIsNestedTypeNode(size, 'numberTypeNode'); - return booleanTypeNode(size); - }; - } - - if (keys.includes('setTypeNode')) { - visitor.visitSetType = function visitSetType(node) { - const size = visit(this)(node.count); - if (size === null) return null; - assertIsNode(size, COUNT_NODES); - const item = visit(this)(node.item); - if (item === null) return null; - assertIsNode(item, TYPE_NODES); - return setTypeNode(item, size); - }; - } - - if (keys.includes('structTypeNode')) { - visitor.visitStructType = function visitStructType(node) { - const fields = node.fields.map(visit(this)).filter(removeNullAndAssertIsNodeFilter('structFieldTypeNode')); - return structTypeNode(fields); - }; - } - - if (keys.includes('structFieldTypeNode')) { - visitor.visitStructFieldType = function visitStructFieldType(node) { - const type = visit(this)(node.type); + if (keys.includes('hiddenPrefixTypeNode' as TNodeKind)) { + overrides.visitHiddenPrefixType = function visitHiddenPrefixType(node, { self }) { + const type = visit(self)(node.type); if (type === null) return null; assertIsNode(type, TYPE_NODES); - const defaultValue = node.defaultValue ? (visit(this)(node.defaultValue) ?? undefined) : undefined; - if (defaultValue) assertIsNode(defaultValue, VALUE_NODES); - return structFieldTypeNode({ ...node, defaultValue, type }); - }; - } - - if (keys.includes('tupleTypeNode')) { - visitor.visitTupleType = function visitTupleType(node) { - const items = node.items.map(visit(this)).filter(removeNullAndAssertIsNodeFilter(TYPE_NODES)); - return tupleTypeNode(items); - }; - } - - if (keys.includes('amountTypeNode')) { - visitor.visitAmountType = function visitAmountType(node) { - const number = visit(this)(node.number); - if (number === null) return null; - assertIsNestedTypeNode(number, 'numberTypeNode'); - return amountTypeNode(number, node.decimals, node.unit); - }; - } - - if (keys.includes('dateTimeTypeNode')) { - visitor.visitDateTimeType = function visitDateTimeType(node) { - const number = visit(this)(node.number); - if (number === null) return null; - assertIsNestedTypeNode(number, 'numberTypeNode'); - return dateTimeTypeNode(number); - }; - } - - if (keys.includes('solAmountTypeNode')) { - visitor.visitSolAmountType = function visitSolAmountType(node) { - const number = visit(this)(node.number); - if (number === null) return null; - assertIsNestedTypeNode(number, 'numberTypeNode'); - return solAmountTypeNode(number); - }; - } - - if (keys.includes('prefixedCountNode')) { - visitor.visitPrefixedCount = function visitPrefixedCount(node) { - const prefix = visit(this)(node.prefix); - if (prefix === null) return null; - assertIsNestedTypeNode(prefix, 'numberTypeNode'); - return prefixedCountNode(prefix); - }; - } - - if (keys.includes('arrayValueNode')) { - visitor.visitArrayValue = function visitArrayValue(node) { - return arrayValueNode(node.items.map(visit(this)).filter(removeNullAndAssertIsNodeFilter(VALUE_NODES))); - }; - } - - if (keys.includes('constantValueNode')) { - visitor.visitConstantValue = function visitConstantValue(node) { - const type = visit(this)(node.type); - if (type === null) return null; - assertIsNode(type, TYPE_NODES); - const value = visit(this)(node.value); - if (value === null) return null; - assertIsNode(value, VALUE_NODES); - return constantValueNode(type, value); - }; - } - - if (keys.includes('enumValueNode')) { - visitor.visitEnumValue = function visitEnumValue(node) { - const enumLink = visit(this)(node.enum); - if (enumLink === null) return null; - assertIsNode(enumLink, ['definedTypeLinkNode']); - const value = node.value ? (visit(this)(node.value) ?? undefined) : undefined; - if (value) assertIsNode(value, ['structValueNode', 'tupleValueNode']); - return enumValueNode(enumLink, node.variant, value); - }; - } - - if (keys.includes('mapValueNode')) { - visitor.visitMapValue = function visitMapValue(node) { - return mapValueNode( - node.entries.map(visit(this)).filter(removeNullAndAssertIsNodeFilter('mapEntryValueNode')), - ); - }; - } - - if (keys.includes('mapEntryValueNode')) { - visitor.visitMapEntryValue = function visitMapEntryValue(node) { - const key = visit(this)(node.key); - if (key === null) return null; - assertIsNode(key, VALUE_NODES); - const value = visit(this)(node.value); - if (value === null) return null; - assertIsNode(value, VALUE_NODES); - return mapEntryValueNode(key, value); - }; - } - - if (keys.includes('setValueNode')) { - visitor.visitSetValue = function visitSetValue(node) { - return setValueNode(node.items.map(visit(this)).filter(removeNullAndAssertIsNodeFilter(VALUE_NODES))); - }; - } - - if (keys.includes('someValueNode')) { - visitor.visitSomeValue = function visitSomeValue(node) { - const value = visit(this)(node.value); - if (value === null) return null; - assertIsNode(value, VALUE_NODES); - return someValueNode(value); - }; - } - - if (keys.includes('structValueNode')) { - visitor.visitStructValue = function visitStructValue(node) { - return structValueNode( - node.fields.map(visit(this)).filter(removeNullAndAssertIsNodeFilter('structFieldValueNode')), - ); - }; - } - - if (keys.includes('structFieldValueNode')) { - visitor.visitStructFieldValue = function visitStructFieldValue(node) { - const value = visit(this)(node.value); - if (value === null) return null; - assertIsNode(value, VALUE_NODES); - return structFieldValueNode(node.name, value); - }; - } - - if (keys.includes('tupleValueNode')) { - visitor.visitTupleValue = function visitTupleValue(node) { - return tupleValueNode(node.items.map(visit(this)).filter(removeNullAndAssertIsNodeFilter(VALUE_NODES))); + const prefix = (node.prefix ?? []) + .map(visit(self)) + .filter(removeNullAndAssertIsNodeFilter('constantValueNode')); + if (prefix.length === 0) return type; + return hiddenPrefixTypeNode(type, prefix); }; } - if (keys.includes('constantPdaSeedNode')) { - visitor.visitConstantPdaSeed = function visitConstantPdaSeed(node) { - const type = visit(this)(node.type); + if (keys.includes('hiddenSuffixTypeNode' as TNodeKind)) { + overrides.visitHiddenSuffixType = function visitHiddenSuffixType(node, { self }) { + const type = visit(self)(node.type); if (type === null) return null; assertIsNode(type, TYPE_NODES); - const value = visit(this)(node.value); - if (value === null) return null; - assertIsNode(value, [...VALUE_NODES, 'programIdValueNode']); - return constantPdaSeedNode(type, value); + const suffix = (node.suffix ?? []) + .map(visit(self)) + .filter(removeNullAndAssertIsNodeFilter('constantValueNode')); + if (suffix.length === 0) return type; + return hiddenSuffixTypeNode(type, suffix); }; } - if (keys.includes('variablePdaSeedNode')) { - visitor.visitVariablePdaSeed = function visitVariablePdaSeed(node) { - const type = visit(this)(node.type); - if (type === null) return null; - assertIsNode(type, TYPE_NODES); - return variablePdaSeedNode(node.name, type, node.docs); + if (keys.includes('conditionalValueNode' as TNodeKind)) { + overrides.visitConditionalValue = function visitConditionalValue(node, { next }) { + // Walk via the generated branch first, then enforce the + // "both arms absent → drop the node" rule. The generated + // visitor preserves all visited attrs, so we can inspect + // the result post-walk rather than re-implementing the + // walk here. + const visited = next(node); + if (visited === null || visited.kind !== 'conditionalValueNode') return visited; + if (visited.ifTrue === undefined && visited.ifFalse === undefined) return null; + return visited; }; } - if (keys.includes('resolverValueNode')) { - visitor.visitResolverValue = function visitResolverValue(node) { + if (keys.includes('resolverValueNode' as TNodeKind)) { + overrides.visitResolverValue = function visitResolverValue(node, { self }) { const dependsOn = (node.dependsOn ?? []) - .map(visit(this)) + .map(visit(self)) .filter(removeNullAndAssertIsNodeFilter(['accountValueNode', 'argumentValueNode'])); return resolverValueNode(node.name, { ...node, @@ -556,170 +136,8 @@ export function identityVisitor( }; } - if (keys.includes('conditionalValueNode')) { - visitor.visitConditionalValue = function visitConditionalValue(node) { - const condition = visit(this)(node.condition); - if (condition === null) return null; - assertIsNode(condition, ['resolverValueNode', 'accountValueNode', 'argumentValueNode']); - const value = node.value ? (visit(this)(node.value) ?? undefined) : undefined; - if (value) assertIsNode(value, VALUE_NODES); - const ifTrue = node.ifTrue ? (visit(this)(node.ifTrue) ?? undefined) : undefined; - if (ifTrue) assertIsNode(ifTrue, INSTRUCTION_INPUT_VALUE_NODES); - const ifFalse = node.ifFalse ? (visit(this)(node.ifFalse) ?? undefined) : undefined; - if (ifFalse) assertIsNode(ifFalse, INSTRUCTION_INPUT_VALUE_NODES); - if (!ifTrue && !ifFalse) return null; - return conditionalValueNode({ condition, ifFalse, ifTrue, value }); - }; - } - - if (keys.includes('pdaValueNode')) { - visitor.visitPdaValue = function visitPdaValue(node) { - const pda = visit(this)(node.pda); - if (pda === null) return null; - assertIsNode(pda, ['pdaLinkNode', 'pdaNode']); - const seeds = node.seeds.map(visit(this)).filter(removeNullAndAssertIsNodeFilter('pdaSeedValueNode')); - return pdaValueNode(pda, seeds); - }; - } - - if (keys.includes('pdaSeedValueNode')) { - visitor.visitPdaSeedValue = function visitPdaSeedValue(node) { - const value = visit(this)(node.value); - if (value === null) return null; - assertIsNode(value, [...VALUE_NODES, 'accountValueNode', 'argumentValueNode']); - return pdaSeedValueNode(node.name, value); - }; - } - - if (keys.includes('fixedSizeTypeNode')) { - visitor.visitFixedSizeType = function visitFixedSizeType(node) { - const type = visit(this)(node.type); - if (type === null) return null; - assertIsNode(type, TYPE_NODES); - return fixedSizeTypeNode(type, node.size); - }; - } - - if (keys.includes('sizePrefixTypeNode')) { - visitor.visitSizePrefixType = function visitSizePrefixType(node) { - const prefix = visit(this)(node.prefix); - if (prefix === null) return null; - assertIsNestedTypeNode(prefix, 'numberTypeNode'); - const type = visit(this)(node.type); - if (type === null) return null; - assertIsNode(type, TYPE_NODES); - return sizePrefixTypeNode(type, prefix); - }; - } - - if (keys.includes('preOffsetTypeNode')) { - visitor.visitPreOffsetType = function visitPreOffsetType(node) { - const type = visit(this)(node.type); - if (type === null) return null; - assertIsNode(type, TYPE_NODES); - return preOffsetTypeNode(type, node.offset, node.strategy); - }; - } - - if (keys.includes('postOffsetTypeNode')) { - visitor.visitPostOffsetType = function visitPostOffsetType(node) { - const type = visit(this)(node.type); - if (type === null) return null; - assertIsNode(type, TYPE_NODES); - return postOffsetTypeNode(type, node.offset, node.strategy); - }; - } - - if (keys.includes('sentinelTypeNode')) { - visitor.visitSentinelType = function visitSentinelType(node) { - const sentinel = visit(this)(node.sentinel); - if (sentinel === null) return null; - assertIsNode(sentinel, 'constantValueNode'); - const type = visit(this)(node.type); - if (type === null) return null; - assertIsNode(type, TYPE_NODES); - return sentinelTypeNode(type, sentinel); - }; - } - - if (keys.includes('hiddenPrefixTypeNode')) { - visitor.visitHiddenPrefixType = function visitHiddenPrefixType(node) { - const type = visit(this)(node.type); - if (type === null) return null; - assertIsNode(type, TYPE_NODES); - const prefix = node.prefix.map(visit(this)).filter(removeNullAndAssertIsNodeFilter('constantValueNode')); - if (prefix.length === 0) return type; - return hiddenPrefixTypeNode(type, prefix); - }; - } - - if (keys.includes('hiddenSuffixTypeNode')) { - visitor.visitHiddenSuffixType = function visitHiddenSuffixType(node) { - const type = visit(this)(node.type); - if (type === null) return null; - assertIsNode(type, TYPE_NODES); - const suffix = node.suffix.map(visit(this)).filter(removeNullAndAssertIsNodeFilter('constantValueNode')); - if (suffix.length === 0) return type; - return hiddenSuffixTypeNode(type, suffix); - }; - } - - if (keys.includes('constantDiscriminatorNode')) { - visitor.visitConstantDiscriminator = function visitConstantDiscriminator(node) { - const constant = visit(this)(node.constant); - if (constant === null) return null; - assertIsNode(constant, 'constantValueNode'); - return constantDiscriminatorNode(constant, node.offset); - }; - } - - if (keys.includes('accountLinkNode')) { - visitor.visitAccountLink = function visitAccountLink(node) { - const program = node.program ? (visit(this)(node.program) ?? undefined) : undefined; - if (program) assertIsNode(program, 'programLinkNode'); - return accountLinkNode(node.name, program); - }; - } - - if (keys.includes('definedTypeLinkNode')) { - visitor.visitDefinedTypeLink = function visitDefinedTypeLink(node) { - const program = node.program ? (visit(this)(node.program) ?? undefined) : undefined; - if (program) assertIsNode(program, 'programLinkNode'); - return definedTypeLinkNode(node.name, program); - }; - } - - if (keys.includes('instructionLinkNode')) { - visitor.visitInstructionLink = function visitInstructionLink(node) { - const program = node.program ? (visit(this)(node.program) ?? undefined) : undefined; - if (program) assertIsNode(program, 'programLinkNode'); - return instructionLinkNode(node.name, program); - }; - } - - if (keys.includes('instructionAccountLinkNode')) { - visitor.visitInstructionAccountLink = function visitInstructionAccountLink(node) { - const instruction = node.instruction ? (visit(this)(node.instruction) ?? undefined) : undefined; - if (instruction) assertIsNode(instruction, 'instructionLinkNode'); - return instructionAccountLinkNode(node.name, instruction); - }; - } - - if (keys.includes('instructionArgumentLinkNode')) { - visitor.visitInstructionArgumentLink = function visitInstructionArgumentLink(node) { - const instruction = node.instruction ? (visit(this)(node.instruction) ?? undefined) : undefined; - if (instruction) assertIsNode(instruction, 'instructionLinkNode'); - return instructionArgumentLinkNode(node.name, instruction); - }; - } - - if (keys.includes('pdaLinkNode')) { - visitor.visitPdaLink = function visitPdaLink(node) { - const program = node.program ? (visit(this)(node.program) ?? undefined) : undefined; - if (program) assertIsNode(program, 'programLinkNode'); - return pdaLinkNode(node.name, program); - }; - } - - return visitor as Visitor; + return extendVisitor(base as unknown as Visitor, overrides) as unknown as Visitor< + Node | null, + TNodeKind + >; } diff --git a/packages/visitors-core/src/index.ts b/packages/visitors-core/src/index.ts index 88b5b954a..6e81e7145 100644 --- a/packages/visitors-core/src/index.ts +++ b/packages/visitors-core/src/index.ts @@ -2,17 +2,21 @@ export * from './bottomUpTransformerVisitor'; export * from './consoleLogVisitor'; export * from './deleteNodesVisitor'; export * from './extendVisitor'; +// Generated visitors (mergeVisitor, raw identityVisitor, NODE_TEST_PATHS). +// The raw `identityVisitor` is shadowed by the wrapper below. +export * from './generated'; export * from './getByteSizeVisitor'; export * from './getDebugStringVisitor'; export * from './getMaxByteSizeVisitor'; export * from './getResolvedInstructionInputsVisitor'; export * from './getUniqueHashStringVisitor'; -export * from './identityVisitor'; +// Wrapper layering semantic overrides on top of the generated raw +// `identityVisitor`; shadows the barrel re-export above. +export { identityVisitor } from './identityVisitor'; export * from './interceptFirstVisitVisitor'; export * from './interceptVisitor'; export * from './LinkableDictionary'; export * from './mapVisitor'; -export * from './mergeVisitor'; export * from './NodePath'; export * from './NodeSelector'; export * from './NodeStack'; diff --git a/packages/visitors-core/src/voidVisitor.ts b/packages/visitors-core/src/voidVisitor.ts index 2ca193e15..16337fca2 100644 --- a/packages/visitors-core/src/voidVisitor.ts +++ b/packages/visitors-core/src/voidVisitor.ts @@ -1,6 +1,6 @@ import type { NodeKind } from '@codama/nodes'; -import { mergeVisitor } from './mergeVisitor'; +import { mergeVisitor } from './generated/mergeVisitor'; import { Visitor } from './visitor'; export function voidVisitor( diff --git a/packages/visitors-core/test/NodeSelector.test.ts b/packages/visitors-core/test/NodeSelector.test.ts index cb9ac4efa..e56ca3505 100644 --- a/packages/visitors-core/test/NodeSelector.test.ts +++ b/packages/visitors-core/test/NodeSelector.test.ts @@ -289,6 +289,7 @@ macro('christmasProgram.wrappingPaper.gold.owner', [wrappingPaperEnumGold.struct macro('wrappingPaper.*', [ giftAccount.data.fields[3].type, wrappingPaperEnum, + wrappingPaperEnum.size, wrappingPaperEnum.variants[0], wrappingPaperEnum.variants[1], wrappingPaperEnum.variants[2], diff --git a/packages/visitors-core/test/nodes/InstructionStatusNode.test.ts b/packages/visitors-core/test/nodes/InstructionStatusNode.test.ts new file mode 100644 index 000000000..5da8bdce5 --- /dev/null +++ b/packages/visitors-core/test/nodes/InstructionStatusNode.test.ts @@ -0,0 +1,27 @@ +import { instructionStatusNode } from '@codama/nodes'; +import { test } from 'vitest'; + +import { + expectDebugStringVisitor, + expectDeleteNodesVisitor, + expectIdentityVisitor, + expectMergeVisitorCount, +} from './_setup'; + +const node = instructionStatusNode('live', 'Public release.'); + +test('mergeVisitor', () => { + expectMergeVisitorCount(node, 1); +}); + +test('identityVisitor', () => { + expectIdentityVisitor(node); +}); + +test('deleteNodesVisitor', () => { + expectDeleteNodesVisitor(node, '[instructionStatusNode]', null); +}); + +test('debugStringVisitor', () => { + expectDebugStringVisitor(node, `instructionStatusNode [live.Public release.]`); +}); diff --git a/packages/visitors-core/test/nodes/countNodes/FixedCountNode.test.ts b/packages/visitors-core/test/nodes/countNodes/FixedCountNode.test.ts new file mode 100644 index 000000000..1a0e7cf4d --- /dev/null +++ b/packages/visitors-core/test/nodes/countNodes/FixedCountNode.test.ts @@ -0,0 +1,27 @@ +import { fixedCountNode } from '@codama/nodes'; +import { test } from 'vitest'; + +import { + expectDebugStringVisitor, + expectDeleteNodesVisitor, + expectIdentityVisitor, + expectMergeVisitorCount, +} from '../_setup'; + +const node = fixedCountNode(8); + +test('mergeVisitor', () => { + expectMergeVisitorCount(node, 1); +}); + +test('identityVisitor', () => { + expectIdentityVisitor(node); +}); + +test('deleteNodesVisitor', () => { + expectDeleteNodesVisitor(node, '[fixedCountNode]', null); +}); + +test('debugStringVisitor', () => { + expectDebugStringVisitor(node, `fixedCountNode [8]`); +}); diff --git a/packages/visitors-core/test/nodes/countNodes/PrefixedCountNode.test.ts b/packages/visitors-core/test/nodes/countNodes/PrefixedCountNode.test.ts new file mode 100644 index 000000000..7c73c831e --- /dev/null +++ b/packages/visitors-core/test/nodes/countNodes/PrefixedCountNode.test.ts @@ -0,0 +1,33 @@ +import { numberTypeNode, prefixedCountNode } from '@codama/nodes'; +import { test } from 'vitest'; + +import { + expectDebugStringVisitor, + expectDeleteNodesVisitor, + expectIdentityVisitor, + expectMergeVisitorCount, +} from '../_setup'; + +const node = prefixedCountNode(numberTypeNode('u32')); + +test('mergeVisitor', () => { + expectMergeVisitorCount(node, 2); +}); + +test('identityVisitor', () => { + expectIdentityVisitor(node); +}); + +test('deleteNodesVisitor', () => { + expectDeleteNodesVisitor(node, '[prefixedCountNode]', null); + expectDeleteNodesVisitor(node, '[numberTypeNode]', null); +}); + +test('debugStringVisitor', () => { + expectDebugStringVisitor( + node, + ` +prefixedCountNode +| numberTypeNode [u32]`, + ); +}); diff --git a/packages/visitors-core/test/nodes/countNodes/RemainderCountNode.test.ts b/packages/visitors-core/test/nodes/countNodes/RemainderCountNode.test.ts new file mode 100644 index 000000000..c320b1379 --- /dev/null +++ b/packages/visitors-core/test/nodes/countNodes/RemainderCountNode.test.ts @@ -0,0 +1,27 @@ +import { remainderCountNode } from '@codama/nodes'; +import { test } from 'vitest'; + +import { + expectDebugStringVisitor, + expectDeleteNodesVisitor, + expectIdentityVisitor, + expectMergeVisitorCount, +} from '../_setup'; + +const node = remainderCountNode(); + +test('mergeVisitor', () => { + expectMergeVisitorCount(node, 1); +}); + +test('identityVisitor', () => { + expectIdentityVisitor(node); +}); + +test('deleteNodesVisitor', () => { + expectDeleteNodesVisitor(node, '[remainderCountNode]', null); +}); + +test('debugStringVisitor', () => { + expectDebugStringVisitor(node, `remainderCountNode`); +}); diff --git a/packages/visitors-core/test/nodes/coverage.test.ts b/packages/visitors-core/test/nodes/coverage.test.ts new file mode 100644 index 000000000..f1b5c9502 --- /dev/null +++ b/packages/visitors-core/test/nodes/coverage.test.ts @@ -0,0 +1,37 @@ +import { existsSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; + +import { type NodeKind, REGISTERED_NODE_KINDS } from '@codama/nodes'; +import { describe, expect, test } from 'vitest'; + +import { NODE_TEST_PATHS } from '../../src/generated/nodeTestPaths'; + +/** + * Drift gate: every spec-registered node kind must have a corresponding + * `test/nodes/.test.ts` fixture file. The {@link NODE_TEST_PATHS} + * map is generated from the spec — when a new node kind ships, this + * test fails until a matching fixture file exists. + * + * Runs under the `node` vitest project only (it touches `node:fs`); a + * no-op under `browser` and `react-native`. + */ +describe.runIf(__NODEJS__)('per-node test fixture coverage', () => { + test('NODE_TEST_PATHS lists every registered node kind', () => { + const mappedKinds = new Set(Object.keys(NODE_TEST_PATHS)); + const missing = REGISTERED_NODE_KINDS.filter(kind => !mappedKinds.has(kind)); + const extra = [...mappedKinds].filter(kind => !(REGISTERED_NODE_KINDS as readonly string[]).includes(kind)); + expect({ extra, missing }).toEqual({ extra: [], missing: [] }); + }); + + // `__filename` is provided by vitest under the `node` project; the + // surrounding `runIf(__NODEJS__)` guards out browser/RN runs where + // `node:fs` and `node:path` are unavailable. + const testNodesDir = dirname(__filename); + test.for(Object.entries(NODE_TEST_PATHS))('has a fixture file for %s', ([kind, relativePath]) => { + const fixturePath = resolve(testNodesDir, `${relativePath}.test.ts`); + expect( + existsSync(fixturePath), + `${kind as NodeKind} → ${relativePath}.test.ts is missing (resolved to ${fixturePath})`, + ).toBe(true); + }); +}); diff --git a/packages/visitors-core/test/types/global.d.ts b/packages/visitors-core/test/types/global.d.ts new file mode 100644 index 000000000..13de8a7ce --- /dev/null +++ b/packages/visitors-core/test/types/global.d.ts @@ -0,0 +1,6 @@ +declare const __BROWSER__: boolean; +declare const __ESM__: boolean; +declare const __NODEJS__: boolean; +declare const __REACTNATIVE__: boolean; +declare const __TEST__: boolean; +declare const __VERSION__: string;