diff --git a/packages/errors/src/codes.ts b/packages/errors/src/codes.ts
index 9537576e9..c7f209241 100644
--- a/packages/errors/src/codes.ts
+++ b/packages/errors/src/codes.ts
@@ -41,6 +41,7 @@ export const CODAMA_ERROR__UNSUPPORTED_VERSION = 12;
export const CODAMA_ERROR__INVALID_BRANDED_STRING = 13;
export const CODAMA_ERROR__INJECTED_VALUE_NOT_PROVIDED = 14;
export const CODAMA_ERROR__CANNOT_RESOLVE_PATH = 15;
+export const CODAMA_ERROR__NODE_PATH_PROGRAM_MISSING = 16;
// Visitors-related errors.
// Reserve error codes in the range [1200000-1200999].
@@ -163,6 +164,7 @@ export type CodamaErrorCode =
| typeof CODAMA_ERROR__INVALID_BRANDED_STRING
| typeof CODAMA_ERROR__LINKED_NODE_NOT_FOUND
| typeof CODAMA_ERROR__NODE_FILESYSTEM_FUNCTION_UNAVAILABLE
+ | typeof CODAMA_ERROR__NODE_PATH_PROGRAM_MISSING
| typeof CODAMA_ERROR__RENDERERS__MISSING_DEPENDENCY_VERSIONS
| typeof CODAMA_ERROR__RENDERERS__UNSUPPORTED_NODE
| typeof CODAMA_ERROR__UNEXPECTED_NESTED_NODE_KIND
diff --git a/packages/errors/src/context.ts b/packages/errors/src/context.ts
index e2205ac69..371c54986 100644
--- a/packages/errors/src/context.ts
+++ b/packages/errors/src/context.ts
@@ -63,6 +63,7 @@ import {
CODAMA_ERROR__INVALID_BRANDED_STRING,
CODAMA_ERROR__LINKED_NODE_NOT_FOUND,
CODAMA_ERROR__NODE_FILESYSTEM_FUNCTION_UNAVAILABLE,
+ CODAMA_ERROR__NODE_PATH_PROGRAM_MISSING,
CODAMA_ERROR__RENDERERS__MISSING_DEPENDENCY_VERSIONS,
CODAMA_ERROR__RENDERERS__UNSUPPORTED_NODE,
CODAMA_ERROR__UNEXPECTED_NESTED_NODE_KIND,
@@ -259,6 +260,10 @@ export type CodamaErrorContext = DefaultUnspecifiedErrorContextToUndefined<{
[CODAMA_ERROR__NODE_FILESYSTEM_FUNCTION_UNAVAILABLE]: {
fsFunction: string;
};
+ [CODAMA_ERROR__NODE_PATH_PROGRAM_MISSING]: {
+ /** The path that should contain a program node, e.g. to resolve links from. */
+ path: readonly Node[];
+ };
[CODAMA_ERROR__RENDERERS__MISSING_DEPENDENCY_VERSIONS]: {
dependencies: readonly string[];
message: string;
diff --git a/packages/errors/src/messages.ts b/packages/errors/src/messages.ts
index 117072697..405772053 100644
--- a/packages/errors/src/messages.ts
+++ b/packages/errors/src/messages.ts
@@ -42,6 +42,7 @@ import {
CODAMA_ERROR__INVALID_BRANDED_STRING,
CODAMA_ERROR__LINKED_NODE_NOT_FOUND,
CODAMA_ERROR__NODE_FILESYSTEM_FUNCTION_UNAVAILABLE,
+ CODAMA_ERROR__NODE_PATH_PROGRAM_MISSING,
CODAMA_ERROR__RENDERERS__MISSING_DEPENDENCY_VERSIONS,
CODAMA_ERROR__RENDERERS__UNSUPPORTED_NODE,
CODAMA_ERROR__UNEXPECTED_NESTED_NODE_KIND,
@@ -135,6 +136,8 @@ export const CodamaErrorMessages: Readonly<{
[CODAMA_ERROR__LINKED_NODE_NOT_FOUND]: 'Could not find linked node [$name] from [$kind].',
[CODAMA_ERROR__NODE_FILESYSTEM_FUNCTION_UNAVAILABLE]:
'Node.js filesystem function [$fsFunction] is not available in your environment.',
+ [CODAMA_ERROR__NODE_PATH_PROGRAM_MISSING]:
+ 'Expected a node path containing a program node, e.g. to resolve the links of its last node.',
[CODAMA_ERROR__RENDERERS__MISSING_DEPENDENCY_VERSIONS]:
'No version specified for the following dependencies: [$dependencies]. $message',
[CODAMA_ERROR__RENDERERS__UNSUPPORTED_NODE]: 'Cannot render the encountered node of kind [$kind].',
diff --git a/packages/visitors-core/README.md b/packages/visitors-core/README.md
index e41f30bb9..75fc6b4ce 100644
--- a/packages/visitors-core/README.md
+++ b/packages/visitors-core/README.md
@@ -856,6 +856,47 @@ const size = visit(tupleTypeNode([numberTypeNode('u32'), publicKeyTypeNode()]),
By default, this visitor will keep track of its own `NodeStack` but you may provide your own via the `stack` option in order to share the same `NodeStack` across multiple visitors.
+### `getDefinedTypeLinksVisitor`
+
+The `getDefinedTypeLinksVisitor` lists the paths of the defined types a node links to, without following these links. Links that cannot be resolved are skipped, and so are the defined types of `EnumValueNodes`, which only reference them. It powers `isDefinedTypeCyclic`.
+
+```ts
+// node = struct { value: link(amount), next: option }
+const stack = new NodeStack();
+stack.visitPath([root, program, node], getDefinedTypeLinksVisitor(linkables, { stack }));
+// ^ [[root, program, amount], [root, program, node]]
+```
+
+### `getHasFiniteValueVisitor`
+
+The `getHasFiniteValueVisitor` tells whether a type has at least one finite value, following links. Options, enums with another variant and collections without a fixed positive count stop a recursion, whereas a struct field or a fixed-size array linking back to its own type never does. It powers `hasDefinedTypeFiniteValue`.
+
+```ts
+// list = struct { next: option }, loop = struct { next: link(loop) }
+const stack = new NodeStack();
+stack.visitPath([root, program, list], getHasFiniteValueVisitor(linkables, { stack })); // true
+stack.visitPath([root, program, loop], getHasFiniteValueVisitor(linkables, { stack })); // false
+```
+
+### `isDefinedTypeCyclic`
+
+The `isDefinedTypeCyclic` helper tells whether a defined type links back to itself, directly or through other defined types, following the links recorded in a `LinkableDictionary`. The path must contain the program defining the type, since links are resolved from it.
+
+```ts
+// node = struct { value: u8, next: option }
+isDefinedTypeCyclic([root, program, node], linkables); // true
+```
+
+### `hasDefinedTypeFiniteValue`
+
+The `hasDefinedTypeFiniteValue` helper tells whether a defined type has at least one finite value, using the `getHasFiniteValueVisitor`. Types lack one when they are cyclic without a way out, or when they contain an enum without variants. As above, the path must contain the program defining the type.
+
+```ts
+// list = struct { next: option }, loop = struct { next: link(loop) }
+hasDefinedTypeFiniteValue([root, program, list], linkables); // true
+hasDefinedTypeFiniteValue([root, program, loop], linkables); // false
+```
+
### `getResolvedInstructionInputsVisitor`
The `getResolvedInstructionInputsVisitor` visits `InstructionNodes` only and returns an array of instruction accounts and arguments in the order they should be rendered for their default values to be resolved.
diff --git a/packages/visitors-core/src/NodePath.ts b/packages/visitors-core/src/NodePath.ts
index 9e7da8ec5..811d3cb45 100644
--- a/packages/visitors-core/src/NodePath.ts
+++ b/packages/visitors-core/src/NodePath.ts
@@ -1,3 +1,4 @@
+import { CODAMA_ERROR__NODE_PATH_PROGRAM_MISSING, CodamaError } from '@codama/errors';
import { assertIsNode, GetNodeFromKind, InstructionNode, isNode, Node, NodeKind, ProgramNode } from '@codama/nodes';
/**
@@ -48,6 +49,18 @@ export function findProgramNodeFromPath(path: NodePath): ProgramNode | undefined
return findLastNodeFromPath(path, 'programNode');
}
+/**
+ * Assert that the path contains a program node, e.g. to resolve the links of
+ * its last node, which are resolved from the closest program of their path.
+ *
+ * @throws `CODAMA_ERROR__NODE_PATH_PROGRAM_MISSING` when it does not.
+ */
+export function assertNodePathHasProgram(path: NodePath): void {
+ if (!findProgramNodeFromPath(path)) {
+ throw new CodamaError(CODAMA_ERROR__NODE_PATH_PROGRAM_MISSING, { path });
+ }
+}
+
export function findInstructionNodeFromPath(path: NodePath): InstructionNode | undefined {
return findLastNodeFromPath(path, 'instructionNode');
}
diff --git a/packages/visitors-core/src/getByteSizeVisitor.ts b/packages/visitors-core/src/getByteSizeVisitor.ts
index 76fe788e6..a7ea8e733 100644
--- a/packages/visitors-core/src/getByteSizeVisitor.ts
+++ b/packages/visitors-core/src/getByteSizeVisitor.ts
@@ -1,4 +1,11 @@
-import { CountNode, isNode, isScalarEnum, REGISTERED_TYPE_NODE_KINDS, RegisteredTypeNode } from '@codama/nodes';
+import {
+ CountNode,
+ DefinedTypeNode,
+ isNode,
+ isScalarEnum,
+ REGISTERED_TYPE_NODE_KINDS,
+ RegisteredTypeNode,
+} from '@codama/nodes';
import { applyByteSizeTransforms, nodeHasTransforms } from './applyByteSizeTransforms';
import { extendVisitor } from './extendVisitor';
@@ -25,8 +32,9 @@ export function getByteSizeVisitor(
): Visitor {
const stack = options.stack ?? new NodeStack();
- const visitedDefinedTypes = new Map();
- const definedTypeStack: string[] = [];
+ // Keyed by node rather than identifier: same-named types of different programs are distinct.
+ const visitedDefinedTypes = new Map();
+ const visitingDefinedTypes = new Set();
const sumSizes = (values: (number | null)[]): number | null =>
values.reduce((all, one) => (all === null || one === null ? null : all + one), 0 as number | null);
@@ -81,13 +89,11 @@ export function getByteSizeVisitor(
},
visitDefinedType(node, { self }) {
- if (visitedDefinedTypes.has(node.identifier)) {
- return visitedDefinedTypes.get(node.identifier)!;
- }
- definedTypeStack.push(node.identifier);
+ if (visitedDefinedTypes.has(node)) return visitedDefinedTypes.get(node)!;
+ visitingDefinedTypes.add(node);
const child = visit(node.type, self);
- definedTypeStack.pop();
- visitedDefinedTypes.set(node.identifier, child);
+ visitingDefinedTypes.delete(node);
+ visitedDefinedTypes.set(node, child);
return child;
},
@@ -95,10 +101,9 @@ export function getByteSizeVisitor(
// Fetch the linked type and return null if not found.
const linkedDefinedPath = linkables.getPath(stack.getPath(node.kind));
if (!linkedDefinedPath) return null;
- const linkedDefinedType = getLastNodeFromPath(linkedDefinedPath);
- // This prevents infinite recursion by assuming cyclic types don't have a fixed size.
- if (definedTypeStack.includes(linkedDefinedType.identifier)) return null;
+ // Cyclic types have no bounded size, and following them would never end.
+ if (visitingDefinedTypes.has(getLastNodeFromPath(linkedDefinedPath))) return null;
return stack.visitPath(linkedDefinedPath, self);
},
diff --git a/packages/visitors-core/src/getDefinedTypeLinksVisitor.ts b/packages/visitors-core/src/getDefinedTypeLinksVisitor.ts
new file mode 100644
index 000000000..bba5a12bc
--- /dev/null
+++ b/packages/visitors-core/src/getDefinedTypeLinksVisitor.ts
@@ -0,0 +1,53 @@
+import { DefinedTypeNode } from '@codama/nodes';
+
+import { extendVisitor } from './extendVisitor';
+import { mergeVisitor } from './generated/mergeVisitor';
+import type { LinkableDictionary } from './LinkableDictionary';
+import { NodePath } from './NodePath';
+import { NodeStack } from './NodeStack';
+import { pipe } from './pipe';
+import { recordNodeStackVisitor } from './recordNodeStackVisitor';
+import { visit, Visitor } from './visitor';
+
+/**
+ * List the paths of the defined types the visited node links to, without
+ * following these links. Links that cannot be resolved are skipped.
+ *
+ * Only links shaping a type are listed: the defined type of an
+ * `enumValueNode` merely references it, so it is left out.
+ *
+ * Links are resolved from the closest program of the current path, so visit
+ * nodes with their full path, e.g. using `stack.visitPath(path, visitor)` with
+ * the visitor's own stack. Links visited without a program are not resolved.
+ *
+ * @example
+ * ```ts
+ * // node = struct { value: link(amount), next: option }
+ * stack.visitPath([root, program, node], getDefinedTypeLinksVisitor(linkables, { stack }));
+ * // [[root, program, amount], [root, program, node]]
+ * ```
+ */
+export function getDefinedTypeLinksVisitor(
+ linkables: LinkableDictionary,
+ options: { stack?: NodeStack } = {},
+): Visitor[]> {
+ const stack = options.stack ?? new NodeStack();
+
+ return pipe(
+ mergeVisitor(
+ () => [] as NodePath[],
+ (_, values) => values.flat(),
+ ),
+ v =>
+ extendVisitor(v, {
+ visitDefinedTypeLink(node, { next }) {
+ const linkedPath = linkables.getPath(stack.getPath(node.kind));
+ return [...(linkedPath ? [linkedPath] : []), ...next(node)];
+ },
+ visitEnumValue(node, { self }) {
+ return node.value ? visit(node.value, self) : [];
+ },
+ }),
+ v => recordNodeStackVisitor(v, stack),
+ );
+}
diff --git a/packages/visitors-core/src/getHasFiniteValueVisitor.ts b/packages/visitors-core/src/getHasFiniteValueVisitor.ts
new file mode 100644
index 000000000..a758144c2
--- /dev/null
+++ b/packages/visitors-core/src/getHasFiniteValueVisitor.ts
@@ -0,0 +1,109 @@
+import { CountNode, DefinedTypeNode, isNode } from '@codama/nodes';
+
+import { extendVisitor } from './extendVisitor';
+import { mergeVisitor } from './generated/mergeVisitor';
+import type { LinkableDictionary } from './LinkableDictionary';
+import { getLastNodeFromPath } from './NodePath';
+import { NodeStack } from './NodeStack';
+import { pipe } from './pipe';
+import { recordNodeStackVisitor } from './recordNodeStackVisitor';
+import { visit, Visitor } from './visitor';
+
+/**
+ * Whether the visited type has at least one finite value, following links.
+ *
+ * Only the shape of values matters: options may be `None`, enums may pick any
+ * variant and collections without a fixed positive count may be empty, so all
+ * of them stop a recursion. Transforms, prefixes and sizes only prevent a
+ * value from existing when their constants link back to a type being visited,
+ * since those constants could then never be created. Links that cannot be
+ * resolved count as finite.
+ *
+ * A link back to a defined type being visited has no finite value on its own:
+ * a finite value never needs to nest a type within itself, so another branch
+ * must provide one. Enums without variants have no value at all.
+ *
+ * Links are resolved from the closest program of the current path, so visit
+ * nodes with their full path, e.g. using `stack.visitPath(path, visitor)` with
+ * the visitor's own stack. Links visited without a program are not resolved.
+ *
+ * @example
+ * ```ts
+ * // list = struct { next: option }, loop = struct { next: link(loop) }
+ * stack.visitPath([root, program, list], getHasFiniteValueVisitor(linkables, { stack })); // true
+ * stack.visitPath([root, program, loop], getHasFiniteValueVisitor(linkables, { stack })); // false
+ * ```
+ */
+export function getHasFiniteValueVisitor(
+ linkables: LinkableDictionary,
+ options: { stack?: NodeStack } = {},
+): Visitor {
+ const stack = options.stack ?? new NodeStack();
+ const visitingDefinedTypes = new Set();
+ // Only `true` is cached: a `false` may only hold because of the defined types being
+ // visited, whereas a `true` holds whichever are, so it never needs computing again.
+ const finiteDefinedTypes = new Set();
+
+ return pipe(
+ mergeVisitor(
+ () => true,
+ (_, values) => values.every(Boolean),
+ ),
+ v =>
+ extendVisitor(v, {
+ visitArrayType(node, { self }) {
+ return !hasFixedPositiveCount(node.count) || visit(node.item, self);
+ },
+
+ visitDefinedType(node, { next }) {
+ if (finiteDefinedTypes.has(node)) return true;
+ visitingDefinedTypes.add(node);
+ const hasFiniteValue = next(node);
+ visitingDefinedTypes.delete(node);
+ if (hasFiniteValue) finiteDefinedTypes.add(node);
+ return hasFiniteValue;
+ },
+
+ visitDefinedTypeLink(node, { self }) {
+ const linkedPath = linkables.getPath(stack.getPath(node.kind));
+ if (!linkedPath) return true;
+ if (visitingDefinedTypes.has(getLastNodeFromPath(linkedPath))) return false;
+ return stack.visitPath(linkedPath, self);
+ },
+
+ visitEnumType(node, { self }) {
+ return (node.variants ?? []).some(variant => visit(variant, self));
+ },
+
+ // The defined type of an enum value only references it.
+ visitEnumValue() {
+ return true;
+ },
+
+ visitMapType(node, { self }) {
+ return !hasFixedPositiveCount(node.count) || (visit(node.key, self) && visit(node.value, self));
+ },
+
+ visitOptionType() {
+ return true;
+ },
+
+ visitRemainderOptionType() {
+ return true;
+ },
+
+ visitSetType(node, { self }) {
+ return !hasFixedPositiveCount(node.count) || visit(node.item, self);
+ },
+
+ visitZeroableOptionType() {
+ return true;
+ },
+ }),
+ v => recordNodeStackVisitor(v, stack),
+ );
+}
+
+function hasFixedPositiveCount(count: CountNode): boolean {
+ return isNode(count, 'fixedCountNode') && count.value > 0;
+}
diff --git a/packages/visitors-core/src/getInstructionDataFields.ts b/packages/visitors-core/src/getInstructionDataFields.ts
index c323b8e0c..843662748 100644
--- a/packages/visitors-core/src/getInstructionDataFields.ts
+++ b/packages/visitors-core/src/getInstructionDataFields.ts
@@ -17,7 +17,8 @@ export type InstructionDataField = {
*
* Fields are only addressable where the data type resolves to a struct, so
* nested fields are listed for struct-typed (or struct-linked) fields only.
- * Each defined type is followed at most once, which guards against cycles.
+ * Each defined type is followed at most once per branch, which guards against
+ * cycles while still expanding sibling fields linking the same type.
*
* @param instructionPath - The path to the instruction. It must contain the
* instruction's `programNode` for linked data to be followed; otherwise a
@@ -37,8 +38,9 @@ export function getInstructionDataFields(
): InstructionDataField[] {
const fields: InstructionDataField[] = [];
const stack = new NodeStack(instructionPath);
- // Keyed by node rather than identifier: same-named types of different programs are distinct.
- const walkedDefinedTypes = new Set();
+ // The defined types followed on the current branch, keyed by node rather than
+ // identifier: same-named types of different programs are distinct.
+ const followedDefinedTypes = new Set();
const walk = (type: TypeNode | undefined, prefix: string): void => {
if (!type) return;
@@ -46,9 +48,10 @@ export function getInstructionDataFields(
const linkedPath = linkables.getPath([...stack.getPath(), type]);
if (!linkedPath) return;
const definedType = getLastNodeFromPath(linkedPath);
- if (walkedDefinedTypes.has(definedType)) return;
- walkedDefinedTypes.add(definedType);
+ if (followedDefinedTypes.has(definedType)) return;
+ followedDefinedTypes.add(definedType);
stack.withPath(linkedPath, () => walk(definedType.type, prefix));
+ followedDefinedTypes.delete(definedType);
return;
}
if (!isNode(type, 'structTypeNode')) return;
diff --git a/packages/visitors-core/src/getMaxByteSizeVisitor.ts b/packages/visitors-core/src/getMaxByteSizeVisitor.ts
index 2c1734b1c..8d6c38aea 100644
--- a/packages/visitors-core/src/getMaxByteSizeVisitor.ts
+++ b/packages/visitors-core/src/getMaxByteSizeVisitor.ts
@@ -1,4 +1,4 @@
-import { CountNode, isNode, isScalarEnum, REGISTERED_TYPE_NODE_KINDS } from '@codama/nodes';
+import { CountNode, DefinedTypeNode, isNode, isScalarEnum, REGISTERED_TYPE_NODE_KINDS } from '@codama/nodes';
import { applyByteSizeTransforms, nodeHasTransforms } from './applyByteSizeTransforms';
import { extendVisitor } from './extendVisitor';
@@ -18,8 +18,9 @@ export function getMaxByteSizeVisitor(
): Visitor {
const stack = options.stack ?? new NodeStack();
- const visitedDefinedTypes = new Map();
- const definedTypeStack: string[] = [];
+ // Keyed by node rather than identifier: same-named types of different programs are distinct.
+ const visitedDefinedTypes = new Map();
+ const visitingDefinedTypes = new Set();
const sumSizes = (values: (number | null)[]): number | null =>
values.reduce((all, one) => (all === null || one === null ? null : all + one), 0 as number | null);
@@ -74,13 +75,11 @@ export function getMaxByteSizeVisitor(
},
visitDefinedType(node, { self }) {
- if (visitedDefinedTypes.has(node.identifier)) {
- return visitedDefinedTypes.get(node.identifier)!;
- }
- definedTypeStack.push(node.identifier);
+ if (visitedDefinedTypes.has(node)) return visitedDefinedTypes.get(node)!;
+ visitingDefinedTypes.add(node);
const child = visit(node.type, self);
- definedTypeStack.pop();
- visitedDefinedTypes.set(node.identifier, child);
+ visitingDefinedTypes.delete(node);
+ visitedDefinedTypes.set(node, child);
return child;
},
@@ -88,10 +87,9 @@ export function getMaxByteSizeVisitor(
// Fetch the linked type and return null if not found.
const linkedDefinedPath = linkables.getPath(stack.getPath(node.kind));
if (!linkedDefinedPath) return null;
- const linkedDefinedType = getLastNodeFromPath(linkedDefinedPath);
- // This prevents infinite recursion by assuming cyclic types don't have a fixed size.
- if (definedTypeStack.includes(linkedDefinedType.identifier)) return null;
+ // Cyclic types have no bounded size, and following them would never end.
+ if (visitingDefinedTypes.has(getLastNodeFromPath(linkedDefinedPath))) return null;
return stack.visitPath(linkedDefinedPath, self);
},
diff --git a/packages/visitors-core/src/hasDefinedTypeFiniteValue.ts b/packages/visitors-core/src/hasDefinedTypeFiniteValue.ts
new file mode 100644
index 000000000..62d87c4a4
--- /dev/null
+++ b/packages/visitors-core/src/hasDefinedTypeFiniteValue.ts
@@ -0,0 +1,29 @@
+import { DefinedTypeNode } from '@codama/nodes';
+
+import { getHasFiniteValueVisitor } from './getHasFiniteValueVisitor';
+import { LinkableDictionary } from './LinkableDictionary';
+import { assertNodePathHasProgram, NodePath } from './NodePath';
+import { NodeStack } from './NodeStack';
+
+/**
+ * Whether the defined type at the given path has at least one finite value,
+ * following the recorded links (see `getHasFiniteValueVisitor`). Types lack
+ * one when they are cyclic without a way out, e.g. `a = link(a)` or
+ * `node = struct { next: link(node) }`, or when they contain an enum without
+ * variants.
+ *
+ * The path must contain the program defining the type, since links are
+ * resolved from it; otherwise, `CODAMA_ERROR__NODE_PATH_PROGRAM_MISSING` is thrown.
+ *
+ * @example
+ * ```ts
+ * // list = struct { next: option }, loop = struct { next: link(loop) }
+ * hasDefinedTypeFiniteValue([root, program, list], linkables); // true
+ * hasDefinedTypeFiniteValue([root, program, loop], linkables); // false
+ * ```
+ */
+export function hasDefinedTypeFiniteValue(path: NodePath, linkables: LinkableDictionary): boolean {
+ assertNodePathHasProgram(path);
+ const stack = new NodeStack();
+ return stack.visitPath(path, getHasFiniteValueVisitor(linkables, { stack }));
+}
diff --git a/packages/visitors-core/src/index.ts b/packages/visitors-core/src/index.ts
index 5863446ed..3b4e429a2 100644
--- a/packages/visitors-core/src/index.ts
+++ b/packages/visitors-core/src/index.ts
@@ -7,15 +7,19 @@ export * from './extendVisitor';
export * from './generated';
export * from './getByteSizeVisitor';
export * from './getDebugStringVisitor';
+export * from './getDefinedTypeLinksVisitor';
+export * from './getHasFiniteValueVisitor';
export * from './getInstructionDataFields';
export * from './getMaxByteSizeVisitor';
export * from './getResolvedInstructionInputsVisitor';
export * from './getUniqueHashStringVisitor';
+export * from './hasDefinedTypeFiniteValue';
// 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 './isDefinedTypeCyclic';
export * from './LinkableDictionary';
export * from './mapVisitor';
export * from './NodePath';
diff --git a/packages/visitors-core/src/isDefinedTypeCyclic.ts b/packages/visitors-core/src/isDefinedTypeCyclic.ts
new file mode 100644
index 000000000..7e4a0b337
--- /dev/null
+++ b/packages/visitors-core/src/isDefinedTypeCyclic.ts
@@ -0,0 +1,39 @@
+import { DefinedTypeNode } from '@codama/nodes';
+
+import { getDefinedTypeLinksVisitor } from './getDefinedTypeLinksVisitor';
+import { LinkableDictionary } from './LinkableDictionary';
+import { assertNodePathHasProgram, getLastNodeFromPath, NodePath } from './NodePath';
+import { NodeStack } from './NodeStack';
+
+/**
+ * Whether the defined type at the given path links back to itself, directly
+ * or through other defined types, following the recorded links (see
+ * `getDefinedTypeLinksVisitor`).
+ *
+ * The path must contain the program defining the type, since links are
+ * resolved from it; otherwise, `CODAMA_ERROR__NODE_PATH_PROGRAM_MISSING` is thrown.
+ *
+ * @example
+ * ```ts
+ * // node = struct { value: u8, next: option }
+ * isDefinedTypeCyclic([root, program, node], linkables); // true
+ * ```
+ */
+export function isDefinedTypeCyclic(path: NodePath, linkables: LinkableDictionary): boolean {
+ assertNodePathHasProgram(path);
+ const target = getLastNodeFromPath(path);
+ const stack = new NodeStack();
+ const linksVisitor = getDefinedTypeLinksVisitor(linkables, { stack });
+ const followed = new Set();
+ const pending = [path];
+ while (pending.length > 0) {
+ for (const linkedPath of stack.visitPath(pending.pop()!, linksVisitor)) {
+ const linkedType = getLastNodeFromPath(linkedPath);
+ if (linkedType === target) return true;
+ if (followed.has(linkedType)) continue;
+ followed.add(linkedType);
+ pending.push(linkedPath);
+ }
+ }
+ return false;
+}
diff --git a/packages/visitors-core/test/NodePath.test.ts b/packages/visitors-core/test/NodePath.test.ts
index 602650d5c..e877e7c50 100644
--- a/packages/visitors-core/test/NodePath.test.ts
+++ b/packages/visitors-core/test/NodePath.test.ts
@@ -1,3 +1,4 @@
+import { CODAMA_ERROR__NODE_PATH_PROGRAM_MISSING, CodamaError } from '@codama/errors';
import {
accountNode,
type AccountNode,
@@ -10,7 +11,7 @@ import {
} from '@codama/nodes';
import { expect, expectTypeOf, test } from 'vitest';
-import { getLastNodeFromPath, type NodePath } from '../src';
+import { assertNodePathHasProgram, getLastNodeFromPath, type NodePath } from '../src';
const program = programNode({ identifier: 'myProgram', publicKey: '1111' });
const root = rootNode(program);
@@ -48,3 +49,15 @@ test('it keeps optional node types as either kind of path', () => {
readonly Node[] | readonly [...(readonly Node[]), PdaNode]
>();
});
+
+test('it accepts paths containing a program node', () => {
+ const account = accountNode({ identifier: 'myAccount' });
+ expect(() => assertNodePathHasProgram([root, program, account])).not.toThrow();
+});
+
+test('it rejects paths without a program node', () => {
+ const account = accountNode({ identifier: 'myAccount' });
+ expect(() => assertNodePathHasProgram([account])).toThrow(
+ new CodamaError(CODAMA_ERROR__NODE_PATH_PROGRAM_MISSING, { path: [account] }),
+ );
+});
diff --git a/packages/visitors-core/test/getByteSizeVisitor.test.ts b/packages/visitors-core/test/getByteSizeVisitor.test.ts
index 4ca591ebc..c5960135d 100644
--- a/packages/visitors-core/test/getByteSizeVisitor.test.ts
+++ b/packages/visitors-core/test/getByteSizeVisitor.test.ts
@@ -209,6 +209,41 @@ describe('definedTypeLinkNode', () => {
expectSizeWithContext([context, definedTypeLinkNode('myType')], null);
});
+ test('it follows links to same-named types of other programs', () => {
+ const typeA = definedTypeNode({
+ identifier: 'myType',
+ type: definedTypeLinkNode('myType', { program: programLinkNode('programB') }),
+ });
+ const programA = programNode({ definedTypes: [typeA], identifier: 'programA', publicKey: '1111' });
+ const programB = programNode({
+ definedTypes: [definedTypeNode({ identifier: 'myType', type: integerTypeNode('u32') })],
+ identifier: 'programB',
+ publicKey: '2222',
+ });
+ const context = rootNode(programA, { additionalPrograms: [programB] });
+
+ expectSizeWithContext([context, programA, typeA], 4);
+ });
+ test('it returns null if the linked type is recursive', () => {
+ const context = programNode({
+ definedTypes: [
+ definedTypeNode({
+ identifier: 'list',
+ type: structTypeNode([
+ structFieldTypeNode({ identifier: 'value', type: integerTypeNode('u8') }),
+ structFieldTypeNode({
+ identifier: 'next',
+ type: zeroableOptionTypeNode(definedTypeLinkNode('list')),
+ }),
+ ]),
+ }),
+ ],
+ identifier: 'myProgram',
+ publicKey: '1111',
+ });
+
+ expectSizeWithContext([context, definedTypeLinkNode('list')], null);
+ });
test('it follows linked nodes using the correct paths when jumping between programs', () => {
const typeA = definedTypeNode({
identifier: 'typeA',
diff --git a/packages/visitors-core/test/getDefinedTypeLinksVisitor.test.ts b/packages/visitors-core/test/getDefinedTypeLinksVisitor.test.ts
new file mode 100644
index 000000000..cc0818427
--- /dev/null
+++ b/packages/visitors-core/test/getDefinedTypeLinksVisitor.test.ts
@@ -0,0 +1,145 @@
+import {
+ constantValueNode,
+ definedTypeLinkNode,
+ definedTypeNode,
+ enumTypeNode,
+ enumValueNode,
+ enumVariantTypeNode,
+ hiddenPrefixTransformNode,
+ integerTypeNode,
+ integerValueNode,
+ optionTypeNode,
+ programLinkNode,
+ programNode,
+ rootNode,
+ stringTypeNode,
+ structFieldTypeNode,
+ structTypeNode,
+ tupleTypeNode,
+} from '@codama/nodes';
+import { expect, test } from 'vitest';
+
+import { getDefinedTypeLinksVisitor, getRecordLinkablesVisitor, LinkableDictionary, NodeStack, visit } from '../src';
+
+test('it lists the defined types linked by a type without following them', () => {
+ // Given a type linking to itself, to an alias, and to a type of another program within a transform.
+ const amount = definedTypeNode({ identifier: 'amount', type: integerTypeNode('u64') });
+ const length = definedTypeNode({ identifier: 'length', type: integerTypeNode('u32') });
+ const node = definedTypeNode({
+ identifier: 'node',
+ type: structTypeNode([
+ structFieldTypeNode({ identifier: 'value', type: definedTypeLinkNode('amount') }),
+ structFieldTypeNode({
+ identifier: 'label',
+ type: stringTypeNode('utf8', {
+ transforms: [
+ hiddenPrefixTransformNode([
+ constantValueNode(
+ definedTypeLinkNode('length', { program: programLinkNode('other') }),
+ integerValueNode('0'),
+ ),
+ ]),
+ ],
+ }),
+ }),
+ structFieldTypeNode({
+ identifier: 'next',
+ type: optionTypeNode(definedTypeLinkNode('node'), { prefix: integerTypeNode('u8') }),
+ }),
+ ]),
+ });
+ const program = programNode({ definedTypes: [node, amount], identifier: 'test', publicKey: '1111' });
+ const otherProgram = programNode({ definedTypes: [length], identifier: 'other', publicKey: '2222' });
+ const root = rootNode(program, { additionalPrograms: [otherProgram] });
+ const linkables = new LinkableDictionary();
+ visit(root, getRecordLinkablesVisitor(linkables));
+
+ // When we list its links.
+ const stack = new NodeStack();
+ const paths = stack.visitPath([root, program, node], getDefinedTypeLinksVisitor(linkables, { stack }));
+
+ // Then we get the path of each linked type, in order.
+ expect(paths).toStrictEqual([
+ [root, program, amount],
+ [root, otherProgram, length],
+ [root, program, node],
+ ]);
+});
+
+test('it ignores the defined types of enum values and unresolved links', () => {
+ // Given an enum whose variant defaults to one of its own values, next to a missing link.
+ const direction = definedTypeNode({
+ identifier: 'direction',
+ type: enumTypeNode([
+ enumVariantTypeNode('up'),
+ enumVariantTypeNode('down', {
+ data: structTypeNode([
+ structFieldTypeNode({
+ defaultValue: enumValueNode('direction', 'up'),
+ identifier: 'then',
+ type: integerTypeNode('u8'),
+ }),
+ structFieldTypeNode({ identifier: 'missing', type: definedTypeLinkNode('missing') }),
+ ]),
+ }),
+ ]),
+ });
+ const program = programNode({ definedTypes: [direction], identifier: 'test', publicKey: '1111' });
+ const root = rootNode(program);
+ const linkables = new LinkableDictionary();
+ visit(root, getRecordLinkablesVisitor(linkables));
+
+ // When we list its links, then there are none.
+ const stack = new NodeStack();
+ expect(stack.visitPath([root, program, direction], getDefinedTypeLinksVisitor(linkables, { stack }))).toStrictEqual(
+ [],
+ );
+});
+
+test('it lists links nested deep within a type', () => {
+ // Given a link nested within an option, a tuple, an enum variant and a struct.
+ const amount = definedTypeNode({ identifier: 'amount', type: integerTypeNode('u64') });
+ const payment = definedTypeNode({
+ identifier: 'payment',
+ type: structTypeNode([
+ structFieldTypeNode({
+ identifier: 'kind',
+ type: enumTypeNode([
+ enumVariantTypeNode('free'),
+ enumVariantTypeNode('paid', {
+ data: tupleTypeNode([
+ integerTypeNode('u8'),
+ optionTypeNode(definedTypeLinkNode('amount'), { prefix: integerTypeNode('u8') }),
+ ]),
+ }),
+ ]),
+ }),
+ ]),
+ });
+ const program = programNode({ definedTypes: [payment, amount], identifier: 'test', publicKey: '1111' });
+ const root = rootNode(program);
+ const linkables = new LinkableDictionary();
+ visit(root, getRecordLinkablesVisitor(linkables));
+
+ // When we list its links.
+ const stack = new NodeStack();
+ const paths = stack.visitPath([root, program, payment], getDefinedTypeLinksVisitor(linkables, { stack }));
+
+ // Then the nested link is listed.
+ expect(paths).toStrictEqual([[root, program, amount]]);
+});
+
+test('it cannot resolve links when visited without the path of the type', () => {
+ // Given a type linking to an alias, both recorded.
+ const amount = definedTypeNode({ identifier: 'amount', type: integerTypeNode('u64') });
+ const wrapper = definedTypeNode({ identifier: 'wrapper', type: definedTypeLinkNode('amount') });
+ const program = programNode({ definedTypes: [wrapper, amount], identifier: 'test', publicKey: '1111' });
+ const linkables = new LinkableDictionary();
+ visit(rootNode(program), getRecordLinkablesVisitor(linkables));
+
+ // When we visit it on its own, without its program on the path.
+ const paths = visit(wrapper, getDefinedTypeLinksVisitor(linkables));
+
+ // Then its link cannot be resolved, which is why it must be visited with its full path.
+ expect(paths).toStrictEqual([]);
+});
diff --git a/packages/visitors-core/test/getHasFiniteValueVisitor.test.ts b/packages/visitors-core/test/getHasFiniteValueVisitor.test.ts
new file mode 100644
index 000000000..eb2e70384
--- /dev/null
+++ b/packages/visitors-core/test/getHasFiniteValueVisitor.test.ts
@@ -0,0 +1,208 @@
+import {
+ arrayTypeNode,
+ definedTypeLinkNode,
+ definedTypeNode,
+ enumTypeNode,
+ enumVariantTypeNode,
+ fixedCountNode,
+ integerTypeNode,
+ mapTypeNode,
+ optionTypeNode,
+ prefixedCountNode,
+ programNode,
+ remainderCountNode,
+ remainderOptionTypeNode,
+ rootNode,
+ setTypeNode,
+ structFieldTypeNode,
+ structTypeNode,
+ tupleTypeNode,
+ TypeNode,
+ zeroableOptionTypeNode,
+} from '@codama/nodes';
+import { expect, test } from 'vitest';
+
+import { getHasFiniteValueVisitor, getRecordLinkablesVisitor, LinkableDictionary, NodeStack, visit } from '../src';
+
+const u8 = integerTypeNode('u8');
+const self = definedTypeLinkNode('self');
+const field = (type: TypeNode) => structTypeNode([structFieldTypeNode({ identifier: 'value', type })]);
+
+test.each<[string, TypeNode]>([
+ ['non-cyclic types', field(u8)],
+ ['options', field(optionTypeNode(self, { prefix: u8 }))],
+ ['remainder options', field(remainderOptionTypeNode(self))],
+ ['zeroable options', field(zeroableOptionTypeNode(self))],
+ ['prefixed arrays', arrayTypeNode(self, prefixedCountNode(u8))],
+ ['remainder sets', setTypeNode(self, remainderCountNode())],
+ ['empty fixed arrays', arrayTypeNode(self, fixedCountNode(0))],
+ ['prefixed maps', mapTypeNode(u8, self, prefixedCountNode(u8))],
+ [
+ 'enums with another variant',
+ enumTypeNode([enumVariantTypeNode('nil'), enumVariantTypeNode('cons', { data: field(self) })]),
+ ],
+ ['links it cannot resolve', field(definedTypeLinkNode('missing'))],
+])('it finds a finite value through %s', (_, type) => {
+ // Given a defined type named `self` with the given type.
+ const definedType = definedTypeNode({ identifier: 'self', type });
+ const program = programNode({ definedTypes: [definedType], identifier: 'test', publicKey: '1111' });
+ const root = rootNode(program);
+ const linkables = new LinkableDictionary();
+ visit(root, getRecordLinkablesVisitor(linkables));
+
+ // When we visit it, then it has a finite value.
+ const stack = new NodeStack();
+ expect(stack.visitPath([root, program, definedType], getHasFiniteValueVisitor(linkables, { stack }))).toBe(true);
+});
+
+test.each<[string, TypeNode]>([
+ ['aliases of themselves', self],
+ ['struct fields', field(self)],
+ ['tuple items', tupleTypeNode([u8, self])],
+ ['fixed arrays', arrayTypeNode(self, fixedCountNode(2))],
+ ['fixed maps', mapTypeNode(self, u8, fixedCountNode(1))],
+ ['enums whose every variant recurses', enumTypeNode([enumVariantTypeNode('cons', { data: field(self) })])],
+ ['enums without variants', field(enumTypeNode([]))],
+])('it finds no finite value through %s', (_, type) => {
+ // Given a defined type named `self` with the given type.
+ const definedType = definedTypeNode({ identifier: 'self', type });
+ const program = programNode({ definedTypes: [definedType], identifier: 'test', publicKey: '1111' });
+ const root = rootNode(program);
+ const linkables = new LinkableDictionary();
+ visit(root, getRecordLinkablesVisitor(linkables));
+
+ // When we visit it, then it has no finite value.
+ const stack = new NodeStack();
+ expect(stack.visitPath([root, program, definedType], getHasFiniteValueVisitor(linkables, { stack }))).toBe(false);
+});
+
+test('it finds no finite value through a type linking to one without', () => {
+ // Given a type that is not cyclic itself, but links to a loop.
+ const loop = definedTypeNode({
+ identifier: 'loop',
+ type: structTypeNode([structFieldTypeNode({ identifier: 'next', type: definedTypeLinkNode('loop') })]),
+ });
+ const usesLoop = definedTypeNode({ identifier: 'usesLoop', type: definedTypeLinkNode('loop') });
+ const program = programNode({ definedTypes: [loop, usesLoop], identifier: 'test', publicKey: '1111' });
+ const root = rootNode(program);
+ const linkables = new LinkableDictionary();
+ visit(root, getRecordLinkablesVisitor(linkables));
+
+ // When we visit it, then it has no finite value.
+ const stack = new NodeStack();
+ expect(stack.visitPath([root, program, usesLoop], getHasFiniteValueVisitor(linkables, { stack }))).toBe(false);
+});
+
+test('it finds no finite value through a cycle across defined types without a way out', () => {
+ // Given `a -> b -> a` through struct fields only.
+ const a = definedTypeNode({ identifier: 'a', type: field(definedTypeLinkNode('b')) });
+ const b = definedTypeNode({ identifier: 'b', type: field(definedTypeLinkNode('a')) });
+ const program = programNode({ definedTypes: [a, b], identifier: 'test', publicKey: '1111' });
+ const root = rootNode(program);
+ const linkables = new LinkableDictionary();
+ visit(root, getRecordLinkablesVisitor(linkables));
+
+ // When we visit one of them, then it has no finite value.
+ const stack = new NodeStack();
+ expect(stack.visitPath([root, program, a], getHasFiniteValueVisitor(linkables, { stack }))).toBe(false);
+});
+
+test('it finds a finite value through another branch of a cycle across defined types', () => {
+ // Given an enum whose first variant recurses through another type, and whose second ends.
+ const tree = definedTypeNode({
+ identifier: 'tree',
+ type: enumTypeNode([
+ enumVariantTypeNode('node', { data: field(definedTypeLinkNode('branch')) }),
+ enumVariantTypeNode('leaf'),
+ ]),
+ });
+ const branch = definedTypeNode({ identifier: 'branch', type: field(definedTypeLinkNode('tree')) });
+ const program = programNode({ definedTypes: [tree, branch], identifier: 'test', publicKey: '1111' });
+ const root = rootNode(program);
+ const linkables = new LinkableDictionary();
+ visit(root, getRecordLinkablesVisitor(linkables));
+
+ // When we visit the type that only reaches the way out through the other one, then it has a finite value.
+ const stack = new NodeStack();
+ expect(stack.visitPath([root, program, branch], getHasFiniteValueVisitor(linkables, { stack }))).toBe(true);
+});
+
+test('it finds a finite value through types shared by sibling branches', () => {
+ // Given a list whose two fields link to the same list type, each of them able to end.
+ const list = definedTypeNode({
+ identifier: 'list',
+ type: structTypeNode([
+ structFieldTypeNode({
+ identifier: 'left',
+ type: optionTypeNode(definedTypeLinkNode('list'), { prefix: u8 }),
+ }),
+ structFieldTypeNode({ identifier: 'right', type: definedTypeLinkNode('leaf') }),
+ ]),
+ });
+ const leaf = definedTypeNode({
+ identifier: 'leaf',
+ type: structTypeNode([
+ structFieldTypeNode({ identifier: 'a', type: definedTypeLinkNode('shared') }),
+ structFieldTypeNode({ identifier: 'b', type: definedTypeLinkNode('shared') }),
+ ]),
+ });
+ const shared = definedTypeNode({
+ identifier: 'shared',
+ type: optionTypeNode(definedTypeLinkNode('list'), { prefix: u8 }),
+ });
+ const program = programNode({ definedTypes: [list, leaf, shared], identifier: 'test', publicKey: '1111' });
+ const root = rootNode(program);
+ const linkables = new LinkableDictionary();
+ visit(root, getRecordLinkablesVisitor(linkables));
+
+ // When we visit the list, then it has a finite value.
+ const stack = new NodeStack();
+ expect(stack.visitPath([root, program, list], getHasFiniteValueVisitor(linkables, { stack }))).toBe(true);
+});
+
+test('it finds no finite value through types shared by sibling branches without a way out', () => {
+ // Given a loop whose two fields link to the same type, which links back to the loop.
+ const loop = definedTypeNode({
+ identifier: 'loop',
+ type: structTypeNode([
+ structFieldTypeNode({ identifier: 'a', type: definedTypeLinkNode('shared') }),
+ structFieldTypeNode({ identifier: 'b', type: definedTypeLinkNode('shared') }),
+ ]),
+ });
+ const shared = definedTypeNode({ identifier: 'shared', type: field(definedTypeLinkNode('loop')) });
+ const program = programNode({ definedTypes: [loop, shared], identifier: 'test', publicKey: '1111' });
+ const root = rootNode(program);
+ const linkables = new LinkableDictionary();
+ visit(root, getRecordLinkablesVisitor(linkables));
+
+ // When we visit the loop, then it has no finite value.
+ const stack = new NodeStack();
+ expect(stack.visitPath([root, program, loop], getHasFiniteValueVisitor(linkables, { stack }))).toBe(false);
+});
+
+test('it finds a finite value for a type first reached within a cycle it does not need', () => {
+ // Given a tree whose recursive variant reaches a branch first, and a root using both.
+ const tree = definedTypeNode({
+ identifier: 'tree',
+ type: enumTypeNode([
+ enumVariantTypeNode('node', { data: field(definedTypeLinkNode('branch')) }),
+ enumVariantTypeNode('leaf'),
+ ]),
+ });
+ const branch = definedTypeNode({ identifier: 'branch', type: field(definedTypeLinkNode('tree')) });
+ const forest = definedTypeNode({
+ identifier: 'forest',
+ type: structTypeNode([
+ structFieldTypeNode({ identifier: 'tree', type: definedTypeLinkNode('tree') }),
+ structFieldTypeNode({ identifier: 'branch', type: definedTypeLinkNode('branch') }),
+ ]),
+ });
+ const program = programNode({ definedTypes: [tree, branch, forest], identifier: 'test', publicKey: '1111' });
+ const root = rootNode(program);
+ const linkables = new LinkableDictionary();
+ visit(root, getRecordLinkablesVisitor(linkables));
+
+ // When we visit the root, then the branch is not mistaken for having no finite value.
+ const stack = new NodeStack();
+ expect(stack.visitPath([root, program, forest], getHasFiniteValueVisitor(linkables, { stack }))).toBe(true);
+});
diff --git a/packages/visitors-core/test/getInstructionDataFields.test.ts b/packages/visitors-core/test/getInstructionDataFields.test.ts
index 5191589db..a201e3064 100644
--- a/packages/visitors-core/test/getInstructionDataFields.test.ts
+++ b/packages/visitors-core/test/getInstructionDataFields.test.ts
@@ -47,7 +47,7 @@ test('it lists nested data fields with their paths, following links', () => {
expect(paths).toStrictEqual(['amount', 'config', 'config.fee', 'extra', 'extra.flag']);
});
-test('it follows each defined type at most once', () => {
+test('it does not follow cycles', () => {
// Given an instruction whose data is a self-referencing defined type.
const instruction = instructionNode({ data: definedTypeLinkNode('node'), identifier: 'myInstruction' });
const program = programNode({
@@ -68,6 +68,38 @@ test('it follows each defined type at most once', () => {
expect(getInstructionDataFields([program, instruction], linkables).map(({ path }) => path)).toStrictEqual(['next']);
});
+test('it follows a defined type once per branch', () => {
+ // Given sibling fields linking to the same defined type.
+ const instruction = instructionNode({
+ data: structTypeNode([
+ structFieldTypeNode({ identifier: 'a', type: definedTypeLinkNode('config') }),
+ structFieldTypeNode({ identifier: 'b', type: definedTypeLinkNode('config') }),
+ ]),
+ identifier: 'myInstruction',
+ });
+ const program = programNode({
+ definedTypes: [
+ definedTypeNode({
+ identifier: 'config',
+ type: structTypeNode([structFieldTypeNode({ identifier: 'fee', type: integerTypeNode('u16') })]),
+ }),
+ ],
+ identifier: 'myProgram',
+ instructions: [instruction],
+ publicKey: '1111',
+ });
+ const linkables = new LinkableDictionary();
+ visit(program, getRecordLinkablesVisitor(linkables));
+
+ // When we list its data fields, then both siblings are expanded.
+ expect(getInstructionDataFields([program, instruction], linkables).map(({ path }) => path)).toStrictEqual([
+ 'a',
+ 'a.fee',
+ 'b',
+ 'b.fee',
+ ]);
+});
+
test('it follows same-named defined types from different programs', () => {
// Given instruction data linking to a type that links to a same-named type of another program.
const instruction = instructionNode({ data: definedTypeLinkNode('args'), identifier: 'myInstruction' });
diff --git a/packages/visitors-core/test/getMaxByteSizeVisitor.test.ts b/packages/visitors-core/test/getMaxByteSizeVisitor.test.ts
index 5d59c5caa..ac01c5dea 100644
--- a/packages/visitors-core/test/getMaxByteSizeVisitor.test.ts
+++ b/packages/visitors-core/test/getMaxByteSizeVisitor.test.ts
@@ -212,6 +212,41 @@ describe('definedTypeLinkNode', () => {
expectMaxSizeWithContext([context, definedTypeLinkNode('myType')], null);
});
+ test('it follows links to same-named types of other programs', () => {
+ const typeA = definedTypeNode({
+ identifier: 'myType',
+ type: definedTypeLinkNode('myType', { program: programLinkNode('programB') }),
+ });
+ const programA = programNode({ definedTypes: [typeA], identifier: 'programA', publicKey: '1111' });
+ const programB = programNode({
+ definedTypes: [definedTypeNode({ identifier: 'myType', type: integerTypeNode('u32') })],
+ identifier: 'programB',
+ publicKey: '2222',
+ });
+ const context = rootNode(programA, { additionalPrograms: [programB] });
+
+ expectMaxSizeWithContext([context, programA, typeA], 4);
+ });
+ test('it returns null if the linked type is recursive', () => {
+ const context = programNode({
+ definedTypes: [
+ definedTypeNode({
+ identifier: 'list',
+ type: structTypeNode([
+ structFieldTypeNode({ identifier: 'value', type: integerTypeNode('u8') }),
+ structFieldTypeNode({
+ identifier: 'next',
+ type: zeroableOptionTypeNode(definedTypeLinkNode('list')),
+ }),
+ ]),
+ }),
+ ],
+ identifier: 'myProgram',
+ publicKey: '1111',
+ });
+
+ expectMaxSizeWithContext([context, definedTypeLinkNode('list')], null);
+ });
test('it follows linked nodes using the correct paths when jumping between programs', () => {
const typeA = definedTypeNode({
identifier: 'typeA',
diff --git a/packages/visitors-core/test/hasDefinedTypeFiniteValue.test.ts b/packages/visitors-core/test/hasDefinedTypeFiniteValue.test.ts
new file mode 100644
index 000000000..f61e6d9d5
--- /dev/null
+++ b/packages/visitors-core/test/hasDefinedTypeFiniteValue.test.ts
@@ -0,0 +1,79 @@
+import { CODAMA_ERROR__NODE_PATH_PROGRAM_MISSING, CodamaError } from '@codama/errors';
+import {
+ definedTypeLinkNode,
+ definedTypeNode,
+ integerTypeNode,
+ optionTypeNode,
+ programNode,
+ rootNode,
+ structFieldTypeNode,
+ structTypeNode,
+} from '@codama/nodes';
+import { expect, test } from 'vitest';
+
+import { getRecordLinkablesVisitor, hasDefinedTypeFiniteValue, LinkableDictionary, visit } from '../src';
+
+test('it finds a finite value for types whose recursion may stop', () => {
+ // Given a list that may end with `None`.
+ const list = definedTypeNode({
+ identifier: 'list',
+ type: structTypeNode([
+ structFieldTypeNode({
+ identifier: 'next',
+ type: optionTypeNode(definedTypeLinkNode('list'), { prefix: integerTypeNode('u8') }),
+ }),
+ ]),
+ });
+ const program = programNode({ definedTypes: [list], identifier: 'test', publicKey: '1111' });
+ const root = rootNode(program);
+ const linkables = new LinkableDictionary();
+ visit(root, getRecordLinkablesVisitor(linkables));
+
+ // When we check whether it has a finite value, then it does.
+ expect(hasDefinedTypeFiniteValue([root, program, list], linkables)).toBe(true);
+});
+
+test('it finds no finite value for types whose recursion never stops', () => {
+ // Given a loop whose every value nests another one.
+ const loop = definedTypeNode({
+ identifier: 'loop',
+ type: structTypeNode([structFieldTypeNode({ identifier: 'next', type: definedTypeLinkNode('loop') })]),
+ });
+ const program = programNode({ definedTypes: [loop], identifier: 'test', publicKey: '1111' });
+ const root = rootNode(program);
+ const linkables = new LinkableDictionary();
+ visit(root, getRecordLinkablesVisitor(linkables));
+
+ // When we check whether it has a finite value, then it does not.
+ expect(hasDefinedTypeFiniteValue([root, program, loop], linkables)).toBe(false);
+});
+
+test('it sees types recorded after a previous call', () => {
+ // Given a type linking to a loop that is not recorded yet.
+ const usesLoop = definedTypeNode({ identifier: 'usesLoop', type: definedTypeLinkNode('loop') });
+ const loop = definedTypeNode({ identifier: 'loop', type: definedTypeLinkNode('loop') });
+ const program = programNode({ definedTypes: [usesLoop, loop], identifier: 'test', publicKey: '1111' });
+ const root = rootNode(program);
+ const linkables = new LinkableDictionary();
+ linkables.recordPath([root, program, usesLoop]);
+ expect(hasDefinedTypeFiniteValue([root, program, usesLoop], linkables)).toBe(true);
+
+ // When we record the loop.
+ linkables.recordPath([root, program, loop]);
+
+ // Then it no longer has a finite value.
+ expect(hasDefinedTypeFiniteValue([root, program, usesLoop], linkables)).toBe(false);
+});
+
+test('it throws when the path has no program', () => {
+ // Given a loop whose program is recorded.
+ const loop = definedTypeNode({ identifier: 'loop', type: definedTypeLinkNode('loop') });
+ const program = programNode({ definedTypes: [loop], identifier: 'test', publicKey: '1111' });
+ const linkables = new LinkableDictionary();
+ visit(rootNode(program), getRecordLinkablesVisitor(linkables));
+
+ // When we check it from a path without its program, then it throws rather than missing the loop.
+ expect(() => hasDefinedTypeFiniteValue([loop], linkables)).toThrow(
+ new CodamaError(CODAMA_ERROR__NODE_PATH_PROGRAM_MISSING, { path: [loop] }),
+ );
+});
diff --git a/packages/visitors-core/test/isDefinedTypeCyclic.test.ts b/packages/visitors-core/test/isDefinedTypeCyclic.test.ts
new file mode 100644
index 000000000..6e9934e4c
--- /dev/null
+++ b/packages/visitors-core/test/isDefinedTypeCyclic.test.ts
@@ -0,0 +1,190 @@
+import { CODAMA_ERROR__NODE_PATH_PROGRAM_MISSING, CodamaError } from '@codama/errors';
+import {
+ arrayTypeNode,
+ constantValueNode,
+ definedTypeLinkNode,
+ definedTypeNode,
+ enumTypeNode,
+ enumValueNode,
+ enumVariantTypeNode,
+ hiddenPrefixTransformNode,
+ integerTypeNode,
+ optionTypeNode,
+ prefixedCountNode,
+ programLinkNode,
+ programNode,
+ rootNode,
+ stringTypeNode,
+ stringValueNode,
+ structFieldTypeNode,
+ structTypeNode,
+} from '@codama/nodes';
+import { expect, test } from 'vitest';
+
+import { getRecordLinkablesVisitor, isDefinedTypeCyclic, LinkableDictionary, visit } from '../src';
+
+test('it detects defined types linking to themselves', () => {
+ // Given a linked list.
+ const list = definedTypeNode({
+ identifier: 'list',
+ type: structTypeNode([
+ structFieldTypeNode({ identifier: 'value', type: integerTypeNode('u8') }),
+ structFieldTypeNode({
+ identifier: 'next',
+ type: optionTypeNode(definedTypeLinkNode('list'), { prefix: integerTypeNode('u8') }),
+ }),
+ ]),
+ });
+ const program = programNode({ definedTypes: [list], identifier: 'test', publicKey: '1111' });
+ const root = rootNode(program);
+ const linkables = new LinkableDictionary();
+ visit(root, getRecordLinkablesVisitor(linkables));
+
+ // When we check whether it is cyclic, then it is.
+ expect(isDefinedTypeCyclic([root, program, list], linkables)).toBe(true);
+});
+
+test('it detects cycles through other defined types', () => {
+ // Given a tree linking to a forest that links back to the tree.
+ const tree = definedTypeNode({
+ identifier: 'tree',
+ type: structTypeNode([structFieldTypeNode({ identifier: 'children', type: definedTypeLinkNode('forest') })]),
+ });
+ const forest = definedTypeNode({
+ identifier: 'forest',
+ type: arrayTypeNode(definedTypeLinkNode('tree'), prefixedCountNode(integerTypeNode('u32'))),
+ });
+ const program = programNode({ definedTypes: [tree, forest], identifier: 'test', publicKey: '1111' });
+ const root = rootNode(program);
+ const linkables = new LinkableDictionary();
+ visit(root, getRecordLinkablesVisitor(linkables));
+
+ // When we check whether the tree is cyclic, then it is.
+ expect(isDefinedTypeCyclic([root, program, tree], linkables)).toBe(true);
+});
+
+test('it does not mark types linking to a cycle as cyclic', () => {
+ // Given a leaf linking to a list, which links to itself.
+ const list = definedTypeNode({
+ identifier: 'list',
+ type: optionTypeNode(definedTypeLinkNode('list'), { prefix: integerTypeNode('u8') }),
+ });
+ const leaf = definedTypeNode({
+ identifier: 'leaf',
+ type: structTypeNode([structFieldTypeNode({ identifier: 'list', type: definedTypeLinkNode('list') })]),
+ });
+ const program = programNode({ definedTypes: [list, leaf], identifier: 'test', publicKey: '1111' });
+ const root = rootNode(program);
+ const linkables = new LinkableDictionary();
+ visit(root, getRecordLinkablesVisitor(linkables));
+
+ // When we check whether the leaf is cyclic, then it is not.
+ expect(isDefinedTypeCyclic([root, program, leaf], linkables)).toBe(false);
+});
+
+test('it detects cycles through transforms', () => {
+ // Given a string hiding a constant prefix of its own type.
+ const tagged = definedTypeNode({
+ identifier: 'tagged',
+ type: stringTypeNode('utf8', {
+ transforms: [
+ hiddenPrefixTransformNode([constantValueNode(definedTypeLinkNode('tagged'), stringValueNode('x'))]),
+ ],
+ }),
+ });
+ const program = programNode({ definedTypes: [tagged], identifier: 'test', publicKey: '1111' });
+ const root = rootNode(program);
+ const linkables = new LinkableDictionary();
+ visit(root, getRecordLinkablesVisitor(linkables));
+
+ // When we check whether it is cyclic, then it is.
+ expect(isDefinedTypeCyclic([root, program, tagged], linkables)).toBe(true);
+});
+
+test('it ignores links within values', () => {
+ // Given an enum whose struct variant defaults to one of its own values.
+ const direction = definedTypeNode({
+ identifier: 'direction',
+ type: enumTypeNode([
+ enumVariantTypeNode('up'),
+ enumVariantTypeNode('down', {
+ data: structTypeNode([
+ structFieldTypeNode({
+ defaultValue: enumValueNode('direction', 'up'),
+ identifier: 'then',
+ type: integerTypeNode('u8'),
+ }),
+ ]),
+ }),
+ ]),
+ });
+ const program = programNode({ definedTypes: [direction], identifier: 'test', publicKey: '1111' });
+ const root = rootNode(program);
+ const linkables = new LinkableDictionary();
+ visit(root, getRecordLinkablesVisitor(linkables));
+
+ // When we check whether it is cyclic, then it is not, since values do not shape the type.
+ expect(isDefinedTypeCyclic([root, program, direction], linkables)).toBe(false);
+});
+
+test('it does not confuse same-named defined types of different programs', () => {
+ // Given a type of program A linking to a same-named type of program B.
+ const typeA = definedTypeNode({
+ identifier: 'config',
+ type: definedTypeLinkNode('config', { program: programLinkNode('b') }),
+ });
+ const typeB = definedTypeNode({ identifier: 'config', type: integerTypeNode('u32') });
+ const programA = programNode({ definedTypes: [typeA], identifier: 'a', publicKey: '1111' });
+ const programB = programNode({ definedTypes: [typeB], identifier: 'b', publicKey: '2222' });
+ const root = rootNode(programA, { additionalPrograms: [programB] });
+ const linkables = new LinkableDictionary();
+ visit(root, getRecordLinkablesVisitor(linkables));
+
+ // When we check whether the type of program A is cyclic, then it is not.
+ expect(isDefinedTypeCyclic([root, programA, typeA], linkables)).toBe(false);
+});
+
+test('it ignores links it cannot resolve', () => {
+ // Given a type linking to a missing type.
+ const orphan = definedTypeNode({ identifier: 'orphan', type: definedTypeLinkNode('missing') });
+ const program = programNode({ definedTypes: [orphan], identifier: 'test', publicKey: '1111' });
+ const root = rootNode(program);
+ const linkables = new LinkableDictionary();
+ visit(root, getRecordLinkablesVisitor(linkables));
+
+ // When we check whether it is cyclic, then it is not.
+ expect(isDefinedTypeCyclic([root, program, orphan], linkables)).toBe(false);
+});
+
+test('it sees types recorded after a previous call', () => {
+ // Given a type linking to a type that is not recorded yet.
+ const a = definedTypeNode({ identifier: 'a', type: definedTypeLinkNode('b') });
+ const b = definedTypeNode({ identifier: 'b', type: definedTypeLinkNode('a') });
+ const program = programNode({ definedTypes: [a, b], identifier: 'test', publicKey: '1111' });
+ const root = rootNode(program);
+ const linkables = new LinkableDictionary();
+ linkables.recordPath([root, program, a]);
+ expect(isDefinedTypeCyclic([root, program, a], linkables)).toBe(false);
+
+ // When we record the missing type.
+ linkables.recordPath([root, program, b]);
+
+ // Then the cycle is found.
+ expect(isDefinedTypeCyclic([root, program, a], linkables)).toBe(true);
+});
+
+test('it throws when the path has no program', () => {
+ // Given a cyclic type whose program is recorded.
+ const list = definedTypeNode({
+ identifier: 'list',
+ type: optionTypeNode(definedTypeLinkNode('list'), { prefix: integerTypeNode('u8') }),
+ });
+ const program = programNode({ definedTypes: [list], identifier: 'test', publicKey: '1111' });
+ const linkables = new LinkableDictionary();
+ visit(rootNode(program), getRecordLinkablesVisitor(linkables));
+
+ // When we check it from a path without its program, then it throws rather than missing the cycle.
+ expect(() => isDefinedTypeCyclic([list], linkables)).toThrow(
+ new CodamaError(CODAMA_ERROR__NODE_PATH_PROGRAM_MISSING, { path: [list] }),
+ );
+});
diff --git a/packages/visitors/README.md b/packages/visitors/README.md
index c629626f5..ef0a61719 100644
--- a/packages/visitors/README.md
+++ b/packages/visitors/README.md
@@ -313,6 +313,8 @@ This visitor replaces any `DefinedTypeLinkNode` with the type of the `DefinedTyp
Note that if multiple link nodes point to the same defined type, each link node will be replaced by a copy of the defined type. The `transforms` of each link are applied on top of the inlined type.
+Defined types in a cycle made only of types to inline, such as `node = struct { next: option }`, are not inlined since inlining them would never end: they are kept in their programs, and so are the links to them. A cycle going through a type that is not inlined ends at its link, so its other types are inlined as usual.
+
```ts
codama.update(unwrapDefinedTypesVisitor(['counter', 'splToken.escrow']));
```
diff --git a/packages/visitors/src/unwrapDefinedTypesVisitor.ts b/packages/visitors/src/unwrapDefinedTypesVisitor.ts
index e800f79ad..534e95143 100644
--- a/packages/visitors/src/unwrapDefinedTypesVisitor.ts
+++ b/packages/visitors/src/unwrapDefinedTypesVisitor.ts
@@ -1,9 +1,11 @@
-import { assertIsNode, IdentifierString, programNode, TYPE_NODES } from '@codama/nodes';
+import { assertIsNode, DefinedTypeNode, IdentifierString, programNode, TYPE_NODES } from '@codama/nodes';
import {
extendVisitor,
findProgramNodeFromPath,
+ getDefinedTypeLinksVisitor,
getLastNodeFromPath,
LinkableDictionary,
+ NodePath,
NodeStack,
nonNullableIdentityVisitor,
pipe,
@@ -25,6 +27,12 @@ import { inlineDefinedType } from './inlineDefinedTypeHelpers';
* The link's own `transforms` are layered on top of the inlined type, and
* links inside a type inlined into another program are qualified with the
* type's original program.
+ *
+ * Defined types in a cycle made only of types to inline (e.g.
+ * `node = struct { next: option }`) cannot be inlined, since
+ * inlining would never end: they are kept, and so are the links to them.
+ * Cycles going through a type that is not inlined end at its link, so their
+ * other types are inlined as usual.
*/
export function unwrapDefinedTypesVisitor(typesToInline: string[] | '*' = '*') {
const linkables = new LinkableDictionary();
@@ -35,6 +43,34 @@ export function unwrapDefinedTypesVisitor(typesToInline: string[] | '*' = '*') {
return typesToInline.includes(typeName);
};
+ // Whether the defined type links back to itself through types to inline only, in which case
+ // inlining it would never end. Linkables are recorded once, on the first visit, so it is cached.
+ const linksStack = new NodeStack();
+ const linksVisitor = getDefinedTypeLinksVisitor(linkables, { stack: linksStack });
+ const inlinedCycles = new Map();
+ const isInInlinedCycle = (path: NodePath): boolean => {
+ const target = getLastNodeFromPath(path);
+ if (inlinedCycles.has(target)) return inlinedCycles.get(target)!;
+ let cyclic = false;
+ const followed = new Set();
+ const pending = [path];
+ while (!cyclic && pending.length > 0) {
+ for (const linkedPath of linksStack.visitPath(pending.pop()!, linksVisitor)) {
+ const linkedType = getLastNodeFromPath(linkedPath);
+ if (!shouldInline(linkedType.identifier, findProgramNodeFromPath(linkedPath)?.identifier)) continue;
+ if (linkedType === target) {
+ cyclic = true;
+ break;
+ }
+ if (followed.has(linkedType)) continue;
+ followed.add(linkedType);
+ pending.push(linkedPath);
+ }
+ }
+ inlinedCycles.set(target, cyclic);
+ return cyclic;
+ };
+
return pipe(
nonNullableIdentityVisitor(),
v =>
@@ -46,6 +82,7 @@ export function unwrapDefinedTypesVisitor(typesToInline: string[] | '*' = '*') {
return link;
}
const definedTypePath = linkables.getPathOrThrow(stack.getPath('definedTypeLinkNode'));
+ if (isInInlinedCycle(definedTypePath)) return link;
const definedType = getLastNodeFromPath(definedTypePath);
const type = stack.withPath(definedTypePath, () => visit(definedType.type, self));
@@ -58,11 +95,14 @@ export function unwrapDefinedTypesVisitor(typesToInline: string[] | '*' = '*') {
},
visitProgram(program, { next }) {
+ const programPath = stack.getPath('programNode');
return next(
programNode({
...program,
definedTypes: (program.definedTypes ?? []).filter(
- definedType => !shouldInline(definedType.identifier, program.identifier),
+ definedType =>
+ !shouldInline(definedType.identifier, program.identifier) ||
+ isInInlinedCycle([...programPath, definedType]),
),
}),
);
diff --git a/packages/visitors/test/unwrapDefinedTypesVisitor.test.ts b/packages/visitors/test/unwrapDefinedTypesVisitor.test.ts
index fe67efc30..5ee7f4907 100644
--- a/packages/visitors/test/unwrapDefinedTypesVisitor.test.ts
+++ b/packages/visitors/test/unwrapDefinedTypesVisitor.test.ts
@@ -1,5 +1,6 @@
import {
accountNode,
+ arrayTypeNode,
assertIsNode,
constantNode,
definedTypeLinkNode,
@@ -8,9 +9,11 @@ import {
fixedSizeTransformNode,
integerTypeNode,
integerValueNode,
+ optionTypeNode,
pdaNode,
programLinkNode,
programNode,
+ remainderCountNode,
rootNode,
sizePrefixTransformNode,
stringTypeNode,
@@ -246,3 +249,94 @@ test('it qualifies links inside types inlined into another program', () => {
]),
);
});
+
+test('it keeps cyclic defined types and the links to them', () => {
+ // Given a linked list used by an account, next to a plain alias.
+ const list = definedTypeNode({
+ identifier: 'list',
+ type: structTypeNode([
+ structFieldTypeNode({ identifier: 'value', type: definedTypeLinkNode('amount') }),
+ structFieldTypeNode({
+ identifier: 'next',
+ type: optionTypeNode(definedTypeLinkNode('list'), { prefix: integerTypeNode('u8') }),
+ }),
+ ]),
+ });
+ const node = programNode({
+ accounts: [accountNode({ data: definedTypeLinkNode('list'), identifier: 'myAccount' })],
+ definedTypes: [list, definedTypeNode({ identifier: 'amount', type: integerTypeNode('u64') })],
+ identifier: 'myProgram',
+ publicKey: '1111',
+ });
+
+ // When we unwrap every defined type.
+ const result = visit(node, unwrapDefinedTypesVisitor('*'));
+
+ // Then only the alias is inlined, including within the kept cyclic type.
+ expect(result).toStrictEqual(
+ programNode({
+ accounts: [accountNode({ data: definedTypeLinkNode('list'), identifier: 'myAccount' })],
+ definedTypes: [
+ definedTypeNode({
+ identifier: 'list',
+ type: structTypeNode([
+ structFieldTypeNode({ identifier: 'value', type: integerTypeNode('u64') }),
+ structFieldTypeNode({
+ identifier: 'next',
+ type: optionTypeNode(definedTypeLinkNode('list'), { prefix: integerTypeNode('u8') }),
+ }),
+ ]),
+ }),
+ ],
+ identifier: 'myProgram',
+ publicKey: '1111',
+ }),
+ );
+});
+
+test('it keeps defined types in a cycle made only of types to inline', () => {
+ // Given two types linking to each other.
+ const node = programNode({
+ definedTypes: [
+ definedTypeNode({ identifier: 'a', type: arrayTypeNode(definedTypeLinkNode('b'), remainderCountNode()) }),
+ definedTypeNode({ identifier: 'b', type: arrayTypeNode(definedTypeLinkNode('a'), remainderCountNode()) }),
+ ],
+ identifier: 'myProgram',
+ publicKey: '1111',
+ });
+
+ // When we unwrap both of them, then nothing changes.
+ expect(visit(node, unwrapDefinedTypesVisitor(['a', 'b']))).toStrictEqual(node);
+});
+
+test('it inlines defined types in a cycle going through a type not to inline', () => {
+ // Given two types linking to each other.
+ const node = programNode({
+ definedTypes: [
+ definedTypeNode({ identifier: 'a', type: arrayTypeNode(definedTypeLinkNode('b'), remainderCountNode()) }),
+ definedTypeNode({ identifier: 'b', type: arrayTypeNode(definedTypeLinkNode('a'), remainderCountNode()) }),
+ ],
+ identifier: 'myProgram',
+ publicKey: '1111',
+ });
+
+ // When we unwrap one of them.
+ const result = visit(node, unwrapDefinedTypesVisitor(['a']));
+
+ // Then it is inlined, and the cycle ends at the link to the other one.
+ expect(result).toStrictEqual(
+ programNode({
+ definedTypes: [
+ definedTypeNode({
+ identifier: 'b',
+ type: arrayTypeNode(
+ arrayTypeNode(definedTypeLinkNode('b'), remainderCountNode()),
+ remainderCountNode(),
+ ),
+ }),
+ ],
+ identifier: 'myProgram',
+ publicKey: '1111',
+ }),
+ );
+});