Skip to content

Commit d197456

Browse files
committed
Generate @codama/visitors-core's identityVisitor and mergeVisitor from @codama/spec
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.
1 parent c116317 commit d197456

35 files changed

Lines changed: 2580 additions & 863 deletions

.changeset/tough-cats-strive.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@codama/visitors-core': minor
3+
---
4+
5+
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.
6+
7+
Three behaviour changes shake out:
8+
9+
- **`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.
10+
- **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.
11+
- **`enumStructVariantTypeNode.discriminator` and `enumTupleVariantTypeNode.discriminator` are now preserved** when the variant survives the wrapper's empty-downgrade.

packages/spec-generators/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
"type": "module",
77
"scripts": {
88
"build": "rimraf dist && tsup",
9-
"generate": "pnpm build && node ./dist/generate.mjs && pnpm --filter @codama/node-types lint:fix && pnpm --filter @codama/nodes lint:fix",
9+
"generate": "pnpm build && node ./dist/generate.mjs && pnpm --filter @codama/node-types --filter @codama/nodes --filter @codama/visitors-core lint:fix",
1010
"lint": "eslint . && prettier --check .",
1111
"lint:fix": "eslint --fix . && prettier --write .",
1212
"test": "pnpm test:types && pnpm test:unit",

packages/spec-generators/src/index.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ import { getSpec } from '@codama/spec';
44
import { generateNodes, NODE_CONFIGS } from './nodes';
55
import { generateNodeTypes } from './nodeTypes';
66
import { CATEGORY_DIRECTORIES, GENERIC_PARAM_ORDER, getRepoDirectory, NARROWABLE_DATA_ATTRIBUTES } from './shared';
7+
import {
8+
generateVisitorsCore,
9+
IDENTITY_VISITOR_WALK_ORDER,
10+
MERGE_VISITOR_WALK_ORDER,
11+
UNION_ALIAS_NAMES,
12+
} from './visitorsCore';
713

814
export interface GenerateResult {
915
/** One entry per generator that ran, in the order they ran. */
@@ -44,5 +50,17 @@ export function generate(): GenerateResult {
4450
outputs.push({ generator: 'nodes', outputDir });
4551
}
4652

53+
{
54+
const outputDir = joinPath(getRepoDirectory(), 'packages', 'visitors-core', 'src', 'generated');
55+
generateVisitorsCore(spec, {
56+
identityVisitorWalkOrder: IDENTITY_VISITOR_WALK_ORDER,
57+
mergeVisitorWalkOrder: MERGE_VISITOR_WALK_ORDER,
58+
outputDir,
59+
targetSpecMajor: 1,
60+
unionAliasNames: UNION_ALIAS_NAMES,
61+
});
62+
outputs.push({ generator: 'visitorsCore', outputDir });
63+
}
64+
4765
return { outputs };
4866
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { type Fragment, fragment, mergeFragments, use } from '@codama/fragments/javascript';
2+
import { isChildAttribute, type NodeSpec, type Spec } from '@codama/spec';
3+
4+
import { type NodeConstructorConfig } from '../../nodes';
5+
import { type ResolvedRenderOptions } from '../options';
6+
import { getVisitorFunctionName } from '../visitorFunctionName';
7+
import { getIdentityWalkOrder } from '../walkStep';
8+
import { getIdentityWalkStep } from './identityWalkStep';
9+
import { getRebuildCallFragment } from './rebuildCall';
10+
11+
type IdentityScope = Pick<
12+
ResolvedRenderOptions,
13+
'identityVisitorWalkOrder' | 'mergeVisitorWalkOrder' | 'unionAliasNames'
14+
>;
15+
16+
/**
17+
* Render the full `generated/identityVisitor.ts` body: the function
18+
* declaration plus one `if (keys.includes(<kind>)) { … }` block per
19+
* spec node that has at least one child attribute.
20+
*
21+
* Nodes with no child attributes are handled by `staticVisitor`'s
22+
* leaf function and don't appear in the dispatch table.
23+
*/
24+
export function getIdentityVisitorFragment(
25+
spec: Spec,
26+
nodeConfigs: ReadonlyMap<string, NodeConstructorConfig>,
27+
scope: IdentityScope,
28+
): Fragment {
29+
const dispatchBranches = spec.categories
30+
.flatMap(c => c.nodes)
31+
.filter(node => node.attributes.some(attr => isChildAttribute(attr.type)))
32+
.map(node => getIdentityDispatchBranch(node, nodeConfigs.get(node.kind), spec, scope));
33+
34+
const nodeType = use('type Node', '@codama/nodes');
35+
const nodeKindType = use('type NodeKind', '@codama/nodes');
36+
const registeredKinds = use('REGISTERED_NODE_KINDS', '@codama/nodes');
37+
const visitorType = use('type Visitor', 'helper:Visitor');
38+
const visit = use('visit as baseVisit', 'helper:visit');
39+
const staticVisitor = use('staticVisitor', 'helper:staticVisitor');
40+
41+
const branchesBlock = mergeFragments(dispatchBranches, ps => ps.join('\n\n'));
42+
43+
return fragment`/**
44+
* Identity visitor: rebuilds the tree node-by-node so callers can
45+
* intercept individual nodes via override hooks while leaving the
46+
* rest untouched. Returns \`null\` to drop a node (and its parents
47+
* that required it).
48+
*/
49+
export function identityVisitor<TNodeKind extends ${nodeKindType} = ${nodeKindType}>(
50+
options: { keys?: TNodeKind[] } = {},
51+
): ${visitorType}<${nodeType} | null, TNodeKind> {
52+
const keys: ${nodeKindType}[] = options.keys ?? (${registeredKinds} as TNodeKind[]);
53+
const visitor = ${staticVisitor}(node => Object.freeze({ ...node }), { keys }) as ${visitorType}<${nodeType} | null>;
54+
const visit =
55+
(v: ${visitorType}<${nodeType} | null>) =>
56+
(node: ${nodeType}): ${nodeType} | null =>
57+
keys.includes(node.kind) ? ${visit}(node, v) : Object.freeze({ ...node });
58+
59+
${branchesBlock}
60+
61+
return visitor as ${visitorType}<${nodeType}, TNodeKind>;
62+
}
63+
`;
64+
}
65+
66+
function getIdentityDispatchBranch(
67+
node: NodeSpec,
68+
config: NodeConstructorConfig | undefined,
69+
spec: Spec,
70+
scope: IdentityScope,
71+
): Fragment {
72+
const visitorFnName = getVisitorFunctionName(node.kind);
73+
// Compute one walk step per spec attribute. The rebuildExprs map
74+
// captures the per-attribute rebuild expression (data attrs →
75+
// bare `node.<name>`; children → either a local name or an
76+
// inline visit-and-filter chain).
77+
const stepByName = new Map<string, ReturnType<typeof getIdentityWalkStep>>();
78+
for (const attr of node.attributes) {
79+
stepByName.set(attr.name, getIdentityWalkStep(attr, config, spec, scope));
80+
}
81+
// Emit pre-statements in identity walk order. The identity
82+
// visitor's traversal sequence is observable from outside via
83+
// `recordNodeStackVisitor` + selector matching.
84+
const orderedChildren = getIdentityWalkOrder(node, scope);
85+
const preStatements = orderedChildren
86+
.map(attr => stepByName.get(attr.name)!.preStatement)
87+
.filter((p): p is Fragment => p !== undefined);
88+
const rebuildExprs = new Map<string, Fragment>();
89+
for (const attr of node.attributes) {
90+
rebuildExprs.set(attr.name, stepByName.get(attr.name)!.rebuildExpr);
91+
}
92+
const rebuildCall = getRebuildCallFragment(node, config, rebuildExprs, scope);
93+
const bodyBlock = mergeFragments([...preStatements, rebuildCall], ps => ps.join('\n'));
94+
95+
return fragment`if (keys.includes('${node.kind}')) { visitor.${visitorFnName} = function ${visitorFnName}(node) { ${bodyBlock} }; }`;
96+
}
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import { type Fragment, fragment, use } from '@codama/fragments/javascript';
2+
import { type AttributeSpec, type Spec } from '@codama/spec';
3+
4+
import { type NodeConstructorConfig } from '../../nodes';
5+
import { paramIdentifier } from '../../nodes/paramIdentifier';
6+
import { type ResolvedRenderOptions } from '../options';
7+
import { getChildShape } from '../walkStep';
8+
import { getUnionKindListFragment } from './unionConstant';
9+
10+
/**
11+
* The two pieces an identity-visitor body needs for one attribute:
12+
*
13+
* - `preStatement`: a hoisted statement (or block of statements) that
14+
* visits the child and asserts its kind. Single-node visits return
15+
* `null` from the surrounding visitor when the visit yields `null`;
16+
* optional single-node visits collapse `null` into `undefined`.
17+
* Array visits have no pre-statement (the work lives inline in the
18+
* rebuild expression).
19+
* - `rebuildExpr`: the JS expression that names the visited value in
20+
* the constructor call's rebuild block. For data attributes this is
21+
* `node.<name>`; for visited single-node children it's the local
22+
* name introduced by `preStatement`; for visited arrays it's the
23+
* `.map(...).filter(...)` chain (or its optional `?: undefined`
24+
* wrapper) inlined.
25+
*
26+
* Required arrays uniformly emit `(node.<x> ?? [])` rather than
27+
* `node.<x>` — defensive against partial IDL JSON, matching the
28+
* identity-visitor contract that any required-array child reference
29+
* tolerates `undefined` at runtime.
30+
*/
31+
interface IdentityWalkStep {
32+
readonly preStatement: Fragment | undefined;
33+
readonly rebuildExpr: Fragment;
34+
}
35+
36+
/** Build the walk step fragments for one attribute. */
37+
export function getIdentityWalkStep(
38+
attr: AttributeSpec,
39+
config: NodeConstructorConfig | undefined,
40+
spec: Spec,
41+
options: Pick<ResolvedRenderOptions, 'unionAliasNames'>,
42+
): IdentityWalkStep {
43+
const localName = getSafeLocalName(attr, config);
44+
const fieldAccess = `node.${attr.name}`;
45+
const shape = getChildShape(attr.type);
46+
const optional = attr.optional === true;
47+
const visitThis = fragment`visit(this)`;
48+
49+
switch (shape.kind) {
50+
case 'data':
51+
return { preStatement: undefined, rebuildExpr: fragment`${fieldAccess}` };
52+
53+
case 'node':
54+
return buildSingleNodeStep(localName, fieldAccess, optional, {
55+
assertCall: use('assertIsNode', '@codama/nodes'),
56+
kindArg: fragment`'${shape.nodeKind}'`,
57+
visitThis,
58+
});
59+
60+
case 'nestedNode':
61+
return buildSingleNodeStep(localName, fieldAccess, optional, {
62+
assertCall: use('assertIsNestedTypeNode', '@codama/nodes'),
63+
kindArg: fragment`'${shape.nodeKind}'`,
64+
visitThis,
65+
});
66+
67+
case 'union':
68+
return buildSingleNodeStep(localName, fieldAccess, optional, {
69+
assertCall: use('assertIsNode', '@codama/nodes'),
70+
kindArg: getUnionKindListFragment(shape.unionName, spec, options),
71+
visitThis,
72+
});
73+
74+
case 'arrayNode': {
75+
const filterCall = use('removeNullAndAssertIsNodeFilter', '@codama/nodes');
76+
return buildArrayStep(fieldAccess, optional, {
77+
filterArg: fragment`'${shape.nodeKind}'`,
78+
filterCall,
79+
visitThis,
80+
});
81+
}
82+
83+
case 'arrayUnion': {
84+
const filterCall = use('removeNullAndAssertIsNodeFilter', '@codama/nodes');
85+
return buildArrayStep(fieldAccess, optional, {
86+
filterArg: getUnionKindListFragment(shape.unionName, spec, options),
87+
filterCall,
88+
visitThis,
89+
});
90+
}
91+
}
92+
}
93+
94+
/**
95+
* The safe TS identifier for an attribute's local in the visitor body.
96+
* Defaults to the attribute name; if that collides with a TS reserved
97+
* word, looks up the `paramName` override in the node's config (the
98+
* same field that the `@codama/nodes` constructor generator uses for
99+
* its positional parameter renaming).
100+
*/
101+
function getSafeLocalName(attr: AttributeSpec, config: NodeConstructorConfig | undefined): string {
102+
return paramIdentifier(attr, config?.attributes?.[attr.name]);
103+
}
104+
105+
function buildSingleNodeStep(
106+
localName: string,
107+
fieldAccess: string,
108+
optional: boolean,
109+
deps: { readonly assertCall: Fragment; readonly kindArg: Fragment; readonly visitThis: Fragment },
110+
): IdentityWalkStep {
111+
const { assertCall, kindArg, visitThis } = deps;
112+
if (optional) {
113+
const preStatement = fragment`const ${localName} = ${fieldAccess} ? (${visitThis}(${fieldAccess}) ?? undefined) : undefined;\nif (${localName}) ${assertCall}(${localName}, ${kindArg});`;
114+
return { preStatement, rebuildExpr: fragment`${localName}` };
115+
}
116+
const preStatement = fragment`const ${localName} = ${visitThis}(${fieldAccess});\nif (${localName} === null) return null;\n${assertCall}(${localName}, ${kindArg});`;
117+
return { preStatement, rebuildExpr: fragment`${localName}` };
118+
}
119+
120+
function buildArrayStep(
121+
fieldAccess: string,
122+
optional: boolean,
123+
deps: { readonly filterArg: Fragment; readonly filterCall: Fragment; readonly visitThis: Fragment },
124+
): IdentityWalkStep {
125+
const { filterArg, filterCall, visitThis } = deps;
126+
if (optional) {
127+
const expr = fragment`${fieldAccess} ? ${fieldAccess}.map(${visitThis}).filter(${filterCall}(${filterArg})) : undefined`;
128+
return { preStatement: undefined, rebuildExpr: expr };
129+
}
130+
// Defensive `?? []` on every required-array child attribute — the
131+
// identity visitor tolerates `undefined` at runtime so it can
132+
// safely normalise a partial IDL JSON parsed via `createFromJson`.
133+
const expr = fragment`(${fieldAccess} ?? []).map(${visitThis}).filter(${filterCall}(${filterArg}))`;
134+
return { preStatement: undefined, rebuildExpr: expr };
135+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export * from './identityVisitor';
2+
export * from './identityWalkStep';
3+
export * from './mergeCollect';
4+
export * from './mergeVisitor';
5+
export * from './nodeTestPaths';
6+
export * from './rebuildCall';
7+
export * from './unionConstant';
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { type Fragment, fragment } from '@codama/fragments/javascript';
2+
import { type AttributeSpec } from '@codama/spec';
3+
4+
import { getChildShape } from '../walkStep';
5+
6+
/**
7+
* Build the merge-visitor collect fragment for one attribute. Returns
8+
* `undefined` for data attributes (no contribution to the merge list)
9+
* and a `...spread` fragment for child references — either a direct
10+
* `...visit(this)(node.<name>)` for required single-node attrs, a
11+
* `...(node.<name> ? visit(this)(node.<name>) : [])` guard for
12+
* optional ones, or a `...(node.<name> ?? []).flatMap(visit(this))`
13+
* spread for arrays.
14+
*/
15+
export function getMergeCollectFragment(attr: AttributeSpec): Fragment | undefined {
16+
const fieldAccess = `node.${attr.name}`;
17+
const shape = getChildShape(attr.type);
18+
const optional = attr.optional === true;
19+
const visitThis = fragment`visit(this)`;
20+
21+
switch (shape.kind) {
22+
case 'data':
23+
return undefined;
24+
25+
case 'node':
26+
case 'nestedNode':
27+
case 'union':
28+
if (optional) {
29+
return fragment`...(${fieldAccess} ? ${visitThis}(${fieldAccess}) : [])`;
30+
}
31+
return fragment`...${visitThis}(${fieldAccess})`;
32+
33+
case 'arrayNode':
34+
case 'arrayUnion':
35+
return fragment`...(${fieldAccess} ?? []).flatMap(${visitThis})`;
36+
}
37+
}

0 commit comments

Comments
 (0)