Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/tough-cats-strive.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion packages/spec-generators/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
18 changes: 18 additions & 0 deletions packages/spec-generators/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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 };
}
Original file line number Diff line number Diff line change
@@ -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(<kind>)) { … }` 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<string, NodeConstructorConfig>,
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<TNodeKind extends ${nodeKindType} = ${nodeKindType}>(
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.<name>`; children → either a local name or an
// inline visit-and-filter chain).
const stepByName = new Map<string, ReturnType<typeof getIdentityWalkStep>>();
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<string, Fragment>();
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} }; }`;
}
Original file line number Diff line number Diff line change
@@ -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.<name>`; 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.<x> ?? [])` rather than
* `node.<x>` — 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<ResolvedRenderOptions, 'unionAliasNames'>,
): 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 };
}
7 changes: 7 additions & 0 deletions packages/spec-generators/src/visitorsCore/fragments/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Original file line number Diff line number Diff line change
@@ -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.<name>)` for required single-node attrs, a
* `...(node.<name> ? visit(this)(node.<name>) : [])` guard for
* optional ones, or a `...(node.<name> ?? []).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})`;
}
}
Loading
Loading