Skip to content

Commit f5179fa

Browse files
committed
Add resolveTypePath helper to visitors-core
1 parent b694ad7 commit f5179fa

12 files changed

Lines changed: 481 additions & 53 deletions

File tree

‎packages/dynamic-parsers/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ Note that it uses the instruction's `programAddress` to restrict the search to t
102102
Accounts, events and instructions are identified using their `discriminators`, which must all match the provided bytes.
103103

104104
- `constantDiscriminatorNode`: the bytes contain the encoded constant at the given offset.
105-
- `fieldDiscriminatorNode`: the bytes contain the encoded default value of the field at the given `path` and offset. The path is relative to the node's `data` and follows linked types, e.g. `header.kind`. Injected default values are resolved using the `provides` of the instruction.
105+
- `fieldDiscriminatorNode`: the bytes contain the encoded default value of the field at the given `path` and offset. The path is relative to the node's `data`, follows linked types, and must point to a struct field, e.g. `header.kind` or `entries[0].kind`. Injected default values are resolved using the `provides` of the instruction.
106106
- `sizeDiscriminatorNode`: the bytes have exactly the given size.
107107

108108
## Program selection

‎packages/dynamic-parsers/src/discriminators.ts‎

Lines changed: 27 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,30 @@
11
import { CodecAndValueVisitors, containsBytes, ReadonlyUint8Array } from '@codama/dynamic-codecs';
22
import {
3+
CODAMA_ERROR__CANNOT_RESOLVE_PATH,
34
CODAMA_ERROR__DISCRIMINATOR_FIELD_HAS_NO_DEFAULT_VALUE,
45
CODAMA_ERROR__DISCRIMINATOR_FIELD_NOT_FOUND,
56
CodamaError,
7+
isCodamaError,
68
} from '@codama/errors';
79
import {
810
ConstantDiscriminatorNode,
911
constantDiscriminatorNode,
1012
constantValueNode,
1113
DiscriminatorNode,
1214
FieldDiscriminatorNode,
13-
isNode,
1415
SizeDiscriminatorNode,
1516
StructFieldTypeNode,
1617
TypeNode,
1718
} from '@codama/nodes';
1819
import {
1920
getLastNodeFromPath,
21+
isNodePath,
2022
LinkableDictionary,
2123
NodePath,
2224
NodeStack,
2325
ProvidedScope,
26+
resolveTypePath,
27+
TypePathNode,
2428
visit,
2529
} from '@codama/visitors-core';
2630

@@ -84,11 +88,7 @@ function matchFieldDiscriminator(
8488
dataType: TypeNode | undefined,
8589
context: DiscriminatorContext,
8690
): boolean {
87-
const segments = discriminator.path.split('.');
88-
const resolved = findDataField(dataType, segments, context.stack.getPath(), context.linkables);
89-
if (!resolved) {
90-
throw new CodamaError(CODAMA_ERROR__DISCRIMINATOR_FIELD_NOT_FOUND, { field: discriminator.path });
91-
}
91+
const resolved = resolveDiscriminatorField(discriminator, dataType, context);
9292
const field = getLastNodeFromPath(resolved);
9393
if (!field.defaultValue) {
9494
throw new CodamaError(CODAMA_ERROR__DISCRIMINATOR_FIELD_HAS_NO_DEFAULT_VALUE, { field: discriminator.path });
@@ -99,29 +99,27 @@ function matchFieldDiscriminator(
9999
return context.stack.withPath(resolved, () => matchConstantDiscriminator(bytes, constantDiscriminator, context));
100100
}
101101

102-
function matchSizeDiscriminator(bytes: ReadonlyUint8Array, discriminator: SizeDiscriminatorNode): boolean {
103-
return bytes.length === discriminator.size;
102+
/** Resolve the struct field a field discriminator points to, within the given data type. */
103+
function resolveDiscriminatorField(
104+
discriminator: FieldDiscriminatorNode,
105+
dataType: TypeNode | undefined,
106+
{ linkables, stack }: DiscriminatorContext,
107+
): NodePath<StructFieldTypeNode> {
108+
const notFound = (cause?: unknown) =>
109+
new CodamaError(CODAMA_ERROR__DISCRIMINATOR_FIELD_NOT_FOUND, { cause, field: discriminator.path });
110+
if (!dataType) throw notFound();
111+
let resolved: NodePath<TypePathNode>;
112+
try {
113+
resolved = resolveTypePath([...stack.getPath(), dataType], discriminator.path, linkables);
114+
} catch (error) {
115+
if (isCodamaError(error, CODAMA_ERROR__CANNOT_RESOLVE_PATH)) throw notFound(error);
116+
throw error;
117+
}
118+
// Only struct fields carry default values, e.g. a tuple item (`[0]`) cannot be a discriminator field.
119+
if (!isNodePath(resolved, 'structFieldTypeNode')) throw notFound();
120+
return resolved;
104121
}
105122

106-
/**
107-
* Find the struct field at the given path segments within `type`, following
108-
* `definedTypeLinkNode`s, and return its full path.
109-
*/
110-
function findDataField(
111-
type: TypeNode | undefined,
112-
segments: string[],
113-
path: NodePath,
114-
linkables: LinkableDictionary,
115-
): NodePath<StructFieldTypeNode> | undefined {
116-
if (!type) return undefined;
117-
if (isNode(type, 'definedTypeLinkNode')) {
118-
const linkedPath = linkables.getPathOrThrow([...path, type]);
119-
return findDataField(getLastNodeFromPath(linkedPath).type, segments, linkedPath, linkables);
120-
}
121-
if (!isNode(type, 'structTypeNode')) return undefined;
122-
const [identifier, ...rest] = segments;
123-
const field = (type.fields ?? []).find(field => field.identifier === identifier);
124-
if (!field) return undefined;
125-
const fieldPath = [...path, type, field] as unknown as NodePath<StructFieldTypeNode>;
126-
return rest.length === 0 ? fieldPath : findDataField(field.type, rest, fieldPath, linkables);
123+
function matchSizeDiscriminator(bytes: ReadonlyUint8Array, discriminator: SizeDiscriminatorNode): boolean {
124+
return bytes.length === discriminator.size;
127125
}

‎packages/dynamic-parsers/test/discriminators.test.ts‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import { getCodecAndValueVisitors } from '@codama/dynamic-codecs';
22
import {
3+
CODAMA_ERROR__CANNOT_RESOLVE_PATH,
34
CODAMA_ERROR__DISCRIMINATOR_FIELD_HAS_NO_DEFAULT_VALUE,
45
CODAMA_ERROR__DISCRIMINATOR_FIELD_NOT_FOUND,
56
CODAMA_ERROR__LINKED_NODE_NOT_FOUND,
67
CodamaError,
8+
isCodamaError,
79
} from '@codama/errors';
810
import {
911
accountNode,
@@ -24,6 +26,7 @@ import {
2426
stringTypeNode,
2527
structFieldTypeNode,
2628
structTypeNode,
29+
tupleTypeNode,
2730
} from '@codama/nodes';
2831
import { getRecordLinkablesVisitor, LinkableDictionary, NodeStack, ProvidedScope, visit } from '@codama/visitors-core';
2932
import { beforeEach, describe, expect, test } from 'vitest';
@@ -370,6 +373,33 @@ describe('matchDiscriminators', () => {
370373
});
371374
});
372375

376+
describe('field discriminator errors', () => {
377+
test('it reports the unresolvable segment as the cause of a missing field', () => {
378+
const discriminator = fieldDiscriminatorNode('header.kind');
379+
const data = structTypeNode([structFieldTypeNode({ identifier: 'header', type: integerTypeNode('u8') })]);
380+
let error: unknown;
381+
try {
382+
matchDiscriminators(hex('00'), [discriminator], data, context);
383+
} catch (caught) {
384+
error = caught;
385+
}
386+
expect(isCodamaError(error, CODAMA_ERROR__DISCRIMINATOR_FIELD_NOT_FOUND)).toBe(true);
387+
const cause = (error as Error).cause;
388+
expect(isCodamaError(cause, CODAMA_ERROR__CANNOT_RESOLVE_PATH)).toBe(true);
389+
expect((cause as CodamaError<typeof CODAMA_ERROR__CANNOT_RESOLVE_PATH>).context.segment).toBe('kind');
390+
});
391+
392+
test('it throws when the path points to a tuple item rather than a struct field', () => {
393+
const discriminator = fieldDiscriminatorNode('pair[0]');
394+
const data = structTypeNode([
395+
structFieldTypeNode({ identifier: 'pair', type: tupleTypeNode([integerTypeNode('u8')]) }),
396+
]);
397+
expect(() => matchDiscriminators(hex('00'), [discriminator], data, context)).toThrow(
398+
new CodamaError(CODAMA_ERROR__DISCRIMINATOR_FIELD_NOT_FOUND, { field: pathString('pair[0]') }),
399+
);
400+
});
401+
});
402+
373403
describe('multiple discriminators', () => {
374404
test('it returns true if all discriminators match', () => {
375405
const result = matchDiscriminators(

‎packages/errors/src/codes.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export const CODAMA_ERROR__DISCRIMINATOR_FIELD_HAS_NO_DEFAULT_VALUE = 11;
4040
export const CODAMA_ERROR__UNSUPPORTED_VERSION = 12;
4141
export const CODAMA_ERROR__INVALID_BRANDED_STRING = 13;
4242
export const CODAMA_ERROR__INJECTED_VALUE_NOT_PROVIDED = 14;
43+
export const CODAMA_ERROR__CANNOT_RESOLVE_PATH = 15;
4344

4445
// Visitors-related errors.
4546
// Reserve error codes in the range [1200000-1200999].
@@ -127,6 +128,7 @@ export type CodamaErrorCode =
127128
| typeof CODAMA_ERROR__ANCHOR__SEED_KIND_UNIMPLEMENTED
128129
| typeof CODAMA_ERROR__ANCHOR__TYPE_PATH_MISSING
129130
| typeof CODAMA_ERROR__ANCHOR__UNRECOGNIZED_IDL_TYPE
131+
| typeof CODAMA_ERROR__CANNOT_RESOLVE_PATH
130132
| typeof CODAMA_ERROR__DISCRIMINATOR_FIELD_HAS_NO_DEFAULT_VALUE
131133
| typeof CODAMA_ERROR__DISCRIMINATOR_FIELD_NOT_FOUND
132134
| typeof CODAMA_ERROR__DYNAMIC_CLIENT__ACCOUNT_MISSING

‎packages/errors/src/context.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import {
3333
CODAMA_ERROR__ANCHOR__SEED_KIND_UNIMPLEMENTED,
3434
CODAMA_ERROR__ANCHOR__TYPE_PATH_MISSING,
3535
CODAMA_ERROR__ANCHOR__UNRECOGNIZED_IDL_TYPE,
36+
CODAMA_ERROR__CANNOT_RESOLVE_PATH,
3637
CODAMA_ERROR__DISCRIMINATOR_FIELD_HAS_NO_DEFAULT_VALUE,
3738
CODAMA_ERROR__DISCRIMINATOR_FIELD_NOT_FOUND,
3839
CODAMA_ERROR__DYNAMIC_CLIENT__ACCOUNT_MISSING,
@@ -126,6 +127,14 @@ export type CodamaErrorContext = DefaultUnspecifiedErrorContextToUndefined<{
126127
[CODAMA_ERROR__ANCHOR__UNRECOGNIZED_IDL_TYPE]: {
127128
idlType: string;
128129
};
130+
[CODAMA_ERROR__CANNOT_RESOLVE_PATH]: {
131+
/** The path of the node the segment could not be applied to, from the root. */
132+
nodePath: readonly Node[];
133+
/** The path expression being resolved, e.g. `config.fees[0]`. */
134+
path: PathString;
135+
/** The segment that could not be followed, e.g. `fees` or `[0]`. */
136+
segment: string;
137+
};
129138
[CODAMA_ERROR__DISCRIMINATOR_FIELD_HAS_NO_DEFAULT_VALUE]: {
130139
field: PathString;
131140
};

‎packages/errors/src/messages.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
CODAMA_ERROR__ANCHOR__SEED_KIND_UNIMPLEMENTED,
1313
CODAMA_ERROR__ANCHOR__TYPE_PATH_MISSING,
1414
CODAMA_ERROR__ANCHOR__UNRECOGNIZED_IDL_TYPE,
15+
CODAMA_ERROR__CANNOT_RESOLVE_PATH,
1516
CODAMA_ERROR__DISCRIMINATOR_FIELD_HAS_NO_DEFAULT_VALUE,
1617
CODAMA_ERROR__DISCRIMINATOR_FIELD_NOT_FOUND,
1718
CODAMA_ERROR__DYNAMIC_CLIENT__ACCOUNT_MISSING,
@@ -88,6 +89,7 @@ export const CodamaErrorMessages: Readonly<{
8889
[CODAMA_ERROR__ANCHOR__SEED_KIND_UNIMPLEMENTED]: 'Seed kind [$kind] is not implemented.',
8990
[CODAMA_ERROR__ANCHOR__TYPE_PATH_MISSING]: 'Field type is missing for path [$path] in [$idlType].',
9091
[CODAMA_ERROR__ANCHOR__UNRECOGNIZED_IDL_TYPE]: 'Unrecognized Anchor IDL type [$idlType].',
92+
[CODAMA_ERROR__CANNOT_RESOLVE_PATH]: 'Cannot resolve segment [$segment] of path [$path].',
9193
[CODAMA_ERROR__DISCRIMINATOR_FIELD_HAS_NO_DEFAULT_VALUE]: 'Discriminator field [$field] has no default value.',
9294
[CODAMA_ERROR__DISCRIMINATOR_FIELD_NOT_FOUND]: 'Could not find discriminator field [$field]',
9395
[CODAMA_ERROR__DYNAMIC_CLIENT__ACCOUNT_MISSING]:

‎packages/visitors-core/README.md‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -808,6 +808,38 @@ const visitor = pipe(
808808
);
809809
```
810810

811+
## Resolving path expressions
812+
813+
Some nodes point into nested data using a path expression, such as a `dataValueNode('config.fees[0]')` or a `fieldDiscriminatorNode('header.kind')`. A path is made of segments where `.identifier` selects a struct field and `[n]` selects the n-th item of a tuple, or the item of an array or set.
814+
815+
### `resolveTypePath`
816+
817+
The `resolveTypePath` function follows a path expression from a source type and returns the full `NodePath` of the node it points to. The source may be any type node, struct field, enum variant or `definedTypeLinkNode`. It requires a `LinkableDictionary` to follow links along the way.
818+
819+
```ts
820+
// Given instruction data such as `struct { config: link(config) }`
821+
// where the `config` defined type is `struct { fees: array(u16) }`.
822+
const source = [root, program, instruction, instruction.data];
823+
const path = resolveTypePath(source, pathString('config.fees[0]'), linkables);
824+
// ^ [root, program, configType, configStruct, feesField, feesArray, u16]
825+
826+
const node = getLastNodeFromPath(path);
827+
// ^ integerTypeNode('u16')
828+
```
829+
830+
Note that:
831+
832+
- Links are followed through the path of their definitions, so any further links resolve in the program that defines them. Here, the returned path goes through `configType` rather than the instruction.
833+
- The last node is returned as addressed. For instance, resolving `config` above returns the struct field whose type is the link, without following it.
834+
- If a segment cannot be followed, a `CODAMA_ERROR__CANNOT_RESOLVE_PATH` error is thrown, naming the segment and the path of the node it was applied to. Dangling links throw a `CODAMA_ERROR__LINKED_NODE_NOT_FOUND` error.
835+
836+
The `parsePath` helper splits a path expression into its segments.
837+
838+
```ts
839+
parsePath('config.fees[0]');
840+
// ^ [{ kind: 'field', identifier: 'config' }, { kind: 'field', identifier: 'fees' }, { kind: 'index', index: 0 }]
841+
```
842+
811843
## Other useful visitors
812844

813845
This package provides a few other visitors that may help build more complex visitors.

‎packages/visitors-core/src/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export * from './recordLinkablesVisitor';
2828
export * from './recordNodeStackVisitor';
2929
export * from './recordProvidedScopeVisitor';
3030
export * from './removeDocsVisitor';
31+
export * from './resolveTypePath';
3132
export * from './singleNodeVisitor';
3233
export * from './staticVisitor';
3334
export * from './tapVisitor';
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import { CODAMA_ERROR__CANNOT_RESOLVE_PATH, CodamaError } from '@codama/errors';
2+
import { DefinedTypeLinkNode, DefinedTypeNode, isNode, Node, PathString, RegisteredTypeNode } from '@codama/nodes';
3+
4+
import { LinkableDictionary } from './LinkableDictionary';
5+
import { getLastNodeFromPath, NodePath } from './NodePath';
6+
7+
/** A segment of a path expression, e.g. `config` or `[0]` in `config.fees[0]`. */
8+
export type PathSegment = { identifier: string; kind: 'field' } | { index: number; kind: 'index' };
9+
10+
/** A node a type path can start from or resolve to. */
11+
export type TypePathNode = DefinedTypeLinkNode | RegisteredTypeNode;
12+
13+
/**
14+
* The path to a {@link TypePathNode}. Unlike `NodePath<TypePathNode>`, which
15+
* distributes over each node kind, it accepts paths ending with any of them,
16+
* e.g. `[...instructionPath, instruction.data]`.
17+
*/
18+
export type TypePath = readonly [...(readonly Node[]), TypePathNode];
19+
20+
const PATH_SEGMENT_REGEX = /(?:^|\.)([A-Za-z_][A-Za-z0-9_]*)|\[(0|[1-9][0-9]*)\]/g;
21+
22+
/**
23+
* Split a path expression into its field and index segments.
24+
*
25+
* @example
26+
* ```ts
27+
* parsePath('config.fees[0]');
28+
* // [{ kind: 'field', identifier: 'config' }, { kind: 'field', identifier: 'fees' }, { kind: 'index', index: 0 }]
29+
* ```
30+
*/
31+
export function parsePath(path: string): PathSegment[] {
32+
return [...path.matchAll(PATH_SEGMENT_REGEX)].map(([, identifier, index]) =>
33+
identifier !== undefined ? { identifier, kind: 'field' } : { index: Number(index), kind: 'index' },
34+
);
35+
}
36+
37+
/**
38+
* Resolve a path expression against the type at the end of `source` and
39+
* return the full path of the node it points to.
40+
*
41+
* A `.identifier` segment selects a struct field and an `[n]` segment
42+
* selects the n-th item of a tuple, or the item of an array or set. Struct
43+
* fields, enum variants and `definedTypeLinkNode`s along the way resolve to
44+
* their types, following links to their definitions so the returned path
45+
* reflects where each node is defined. The last node is returned as
46+
* addressed, e.g. a struct field whose type is a link. Indices into arrays
47+
* and sets are not checked against their count, as they select the item type.
48+
*
49+
* @param source - The path to the type the path expression is relative to,
50+
* e.g. `[root, program, instruction, instruction.data]`.
51+
* @param path - The path expression, e.g. `config.fees[0]`.
52+
* @param linkables - Used to follow `definedTypeLinkNode`s.
53+
* @throws `CODAMA_ERROR__CANNOT_RESOLVE_PATH` when a segment cannot be followed.
54+
* @throws `CODAMA_ERROR__LINKED_NODE_NOT_FOUND` when a link cannot be resolved.
55+
*
56+
* @example
57+
* ```ts
58+
* // instruction.data: struct { config: link(config) } — config: struct { fees: array(u16) }
59+
* resolveTypePath([root, program, instruction, instruction.data], 'config.fees[0]', linkables);
60+
* // [root, program, configType, configStruct, feesField, feesArray, u16]
61+
* ```
62+
*/
63+
export function resolveTypePath(
64+
source: TypePath,
65+
path: PathString,
66+
linkables: LinkableDictionary,
67+
): NodePath<TypePathNode> {
68+
const resolved = parsePath(path).reduce((current, segment) => {
69+
const container = resolveContainer(current, linkables, path, segment);
70+
const type = getLastTypePathNode(container);
71+
const withChild = (child: TypePathNode): TypePath => [...container, child];
72+
73+
if (segment.kind === 'field') {
74+
const field = isNode(type, 'structTypeNode')
75+
? (type.fields ?? []).find(field => field.identifier === segment.identifier)
76+
: undefined;
77+
if (field) return withChild(field);
78+
} else if (isNode(type, 'tupleTypeNode')) {
79+
const item = (type.items ?? [])[segment.index];
80+
if (item) return withChild(item);
81+
} else if (isNode(type, ['arrayTypeNode', 'setTypeNode'])) {
82+
return withChild(type.item);
83+
}
84+
throw getCannotResolvePathError(container, path, segment);
85+
}, source);
86+
return resolved as NodePath<TypePathNode>;
87+
}
88+
89+
/**
90+
* Resolve struct fields, enum variants and links to the type they describe,
91+
* so the next segment can be applied to it.
92+
*/
93+
function resolveContainer(
94+
source: TypePath,
95+
linkables: LinkableDictionary,
96+
path: PathString,
97+
segment: PathSegment,
98+
): TypePath {
99+
let current = source;
100+
const followedTypes = new Set<DefinedTypeNode>();
101+
for (;;) {
102+
const node = getLastTypePathNode(current);
103+
if (isNode(node, 'structFieldTypeNode')) {
104+
current = [...current, node.type];
105+
} else if (isNode(node, 'enumVariantTypeNode')) {
106+
if (!node.data) throw getCannotResolvePathError(current, path, segment);
107+
current = [...current, node.data];
108+
} else if (isNode(node, 'definedTypeLinkNode')) {
109+
const definedTypePath = linkables.getPathOrThrow(current as NodePath<DefinedTypeLinkNode>);
110+
const definedType = getLastNodeFromPath(definedTypePath);
111+
// An alias cycle (e.g. `a = b`, `b = a`) never reaches a type to apply the segment to.
112+
if (followedTypes.has(definedType)) throw getCannotResolvePathError(current, path, segment);
113+
followedTypes.add(definedType);
114+
current = [...definedTypePath, definedType.type];
115+
} else {
116+
return current;
117+
}
118+
}
119+
}
120+
121+
function getLastTypePathNode(path: TypePath): TypePathNode {
122+
return path[path.length - 1] as TypePathNode;
123+
}
124+
125+
function getCannotResolvePathError(nodePath: NodePath, path: PathString, segment: PathSegment): CodamaError {
126+
return new CodamaError(CODAMA_ERROR__CANNOT_RESOLVE_PATH, {
127+
nodePath,
128+
path,
129+
segment: segment.kind === 'field' ? segment.identifier : `[${segment.index}]`,
130+
});
131+
}

0 commit comments

Comments
 (0)