|
| 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