diff --git a/packages/errors/src/codes.ts b/packages/errors/src/codes.ts index 2ca79f33f..ae0e02e59 100644 --- a/packages/errors/src/codes.ts +++ b/packages/errors/src/codes.ts @@ -27,6 +27,7 @@ */ export const CODAMA_ERROR__UNRECOGNIZED_NODE_KIND = 1; export const CODAMA_ERROR__UNEXPECTED_NODE_KIND = 2; +/** @deprecated No longer thrown as of Codama v2, which removed nested type node wrappers. */ export const CODAMA_ERROR__UNEXPECTED_NESTED_NODE_KIND = 3; export const CODAMA_ERROR__LINKED_NODE_NOT_FOUND = 4; export const CODAMA_ERROR__NODE_FILESYSTEM_FUNCTION_UNAVAILABLE = 5; @@ -50,11 +51,12 @@ export const CODAMA_ERROR__VISITORS__ACCOUNT_FIELD_NOT_FOUND = 1200005; export const CODAMA_ERROR__VISITORS__INVALID_NUMBER_WRAPPER = 1200006; export const CODAMA_ERROR__VISITORS__CANNOT_EXTEND_MISSING_VISIT_FUNCTION = 1200007; export const CODAMA_ERROR__VISITORS__FAILED_TO_VALIDATE_NODE = 1200008; -export const CODAMA_ERROR__VISITORS__INSTRUCTION_ENUM_ARGUMENT_NOT_FOUND = 1200009; +export const CODAMA_ERROR__VISITORS__INSTRUCTION_ENUM_DATA_FIELD_NOT_FOUND = 1200009; export const CODAMA_ERROR__VISITORS__CANNOT_FLATTEN_STRUCT_WITH_CONFLICTING_ATTRIBUTES = 1200010; export const CODAMA_ERROR__VISITORS__RENDER_MAP_KEY_NOT_FOUND = 1200011; export const CODAMA_ERROR__VISITORS__CANNOT_REMOVE_LAST_PATH_IN_NODE_STACK = 1200012; export const CODAMA_ERROR__VISITORS__INVALID_PROVIDED_VALUE = 1200013; +export const CODAMA_ERROR__VISITORS__CANNOT_FLATTEN_STRUCT_WITH_PLUGINS = 1200014; // Anchor-related errors. // Reserve error codes in the range [2100000-2100999]. @@ -158,11 +160,12 @@ export type CodamaErrorCode = | typeof CODAMA_ERROR__VISITORS__CANNOT_ADD_DUPLICATED_PDA_NAMES | typeof CODAMA_ERROR__VISITORS__CANNOT_EXTEND_MISSING_VISIT_FUNCTION | typeof CODAMA_ERROR__VISITORS__CANNOT_FLATTEN_STRUCT_WITH_CONFLICTING_ATTRIBUTES + | typeof CODAMA_ERROR__VISITORS__CANNOT_FLATTEN_STRUCT_WITH_PLUGINS | typeof CODAMA_ERROR__VISITORS__CANNOT_REMOVE_LAST_PATH_IN_NODE_STACK | typeof CODAMA_ERROR__VISITORS__CANNOT_USE_OPTIONAL_ACCOUNT_AS_PDA_SEED_VALUE | typeof CODAMA_ERROR__VISITORS__CYCLIC_DEPENDENCY_DETECTED_WHEN_RESOLVING_INSTRUCTION_DEFAULT_VALUES | typeof CODAMA_ERROR__VISITORS__FAILED_TO_VALIDATE_NODE - | typeof CODAMA_ERROR__VISITORS__INSTRUCTION_ENUM_ARGUMENT_NOT_FOUND + | typeof CODAMA_ERROR__VISITORS__INSTRUCTION_ENUM_DATA_FIELD_NOT_FOUND | typeof CODAMA_ERROR__VISITORS__INVALID_INSTRUCTION_DEFAULT_VALUE_DEPENDENCY | typeof CODAMA_ERROR__VISITORS__INVALID_NUMBER_WRAPPER | typeof CODAMA_ERROR__VISITORS__INVALID_PDA_SEED_VALUES diff --git a/packages/errors/src/context.ts b/packages/errors/src/context.ts index 6272f4dca..6c80d0b1a 100644 --- a/packages/errors/src/context.ts +++ b/packages/errors/src/context.ts @@ -70,11 +70,12 @@ import { CODAMA_ERROR__VISITORS__CANNOT_ADD_DUPLICATED_PDA_NAMES, CODAMA_ERROR__VISITORS__CANNOT_EXTEND_MISSING_VISIT_FUNCTION, CODAMA_ERROR__VISITORS__CANNOT_FLATTEN_STRUCT_WITH_CONFLICTING_ATTRIBUTES, + CODAMA_ERROR__VISITORS__CANNOT_FLATTEN_STRUCT_WITH_PLUGINS, CODAMA_ERROR__VISITORS__CANNOT_REMOVE_LAST_PATH_IN_NODE_STACK, CODAMA_ERROR__VISITORS__CANNOT_USE_OPTIONAL_ACCOUNT_AS_PDA_SEED_VALUE, CODAMA_ERROR__VISITORS__CYCLIC_DEPENDENCY_DETECTED_WHEN_RESOLVING_INSTRUCTION_DEFAULT_VALUES, CODAMA_ERROR__VISITORS__FAILED_TO_VALIDATE_NODE, - CODAMA_ERROR__VISITORS__INSTRUCTION_ENUM_ARGUMENT_NOT_FOUND, + CODAMA_ERROR__VISITORS__INSTRUCTION_ENUM_DATA_FIELD_NOT_FOUND, CODAMA_ERROR__VISITORS__INVALID_INSTRUCTION_DEFAULT_VALUE_DEPENDENCY, CODAMA_ERROR__VISITORS__INVALID_NUMBER_WRAPPER, CODAMA_ERROR__VISITORS__INVALID_PDA_SEED_VALUES, @@ -271,6 +272,10 @@ export type CodamaErrorContext = DefaultUnspecifiedErrorContextToUndefined<{ [CODAMA_ERROR__VISITORS__CANNOT_FLATTEN_STRUCT_WITH_CONFLICTING_ATTRIBUTES]: { conflictingAttributes: IdentifierString[]; }; + [CODAMA_ERROR__VISITORS__CANNOT_FLATTEN_STRUCT_WITH_PLUGINS]: { + field: StructFieldTypeNode; + fieldName: IdentifierString; + }; [CODAMA_ERROR__VISITORS__CANNOT_REMOVE_LAST_PATH_IN_NODE_STACK]: { path: readonly Node[]; }; @@ -293,8 +298,8 @@ export type CodamaErrorContext = DefaultUnspecifiedErrorContextToUndefined<{ formattedHistogram: string; validationItems: readonly ValidationItem[]; }; - [CODAMA_ERROR__VISITORS__INSTRUCTION_ENUM_ARGUMENT_NOT_FOUND]: { - argumentName: IdentifierString; + [CODAMA_ERROR__VISITORS__INSTRUCTION_ENUM_DATA_FIELD_NOT_FOUND]: { + fieldName: IdentifierString; instruction: InstructionNode; instructionName: IdentifierString; }; diff --git a/packages/errors/src/messages.ts b/packages/errors/src/messages.ts index 616618d82..2f11f2fec 100644 --- a/packages/errors/src/messages.ts +++ b/packages/errors/src/messages.ts @@ -51,11 +51,12 @@ import { CODAMA_ERROR__VISITORS__CANNOT_ADD_DUPLICATED_PDA_NAMES, CODAMA_ERROR__VISITORS__CANNOT_EXTEND_MISSING_VISIT_FUNCTION, CODAMA_ERROR__VISITORS__CANNOT_FLATTEN_STRUCT_WITH_CONFLICTING_ATTRIBUTES, + CODAMA_ERROR__VISITORS__CANNOT_FLATTEN_STRUCT_WITH_PLUGINS, CODAMA_ERROR__VISITORS__CANNOT_REMOVE_LAST_PATH_IN_NODE_STACK, CODAMA_ERROR__VISITORS__CANNOT_USE_OPTIONAL_ACCOUNT_AS_PDA_SEED_VALUE, CODAMA_ERROR__VISITORS__CYCLIC_DEPENDENCY_DETECTED_WHEN_RESOLVING_INSTRUCTION_DEFAULT_VALUES, CODAMA_ERROR__VISITORS__FAILED_TO_VALIDATE_NODE, - CODAMA_ERROR__VISITORS__INSTRUCTION_ENUM_ARGUMENT_NOT_FOUND, + CODAMA_ERROR__VISITORS__INSTRUCTION_ENUM_DATA_FIELD_NOT_FOUND, CODAMA_ERROR__VISITORS__INVALID_INSTRUCTION_DEFAULT_VALUE_DEPENDENCY, CODAMA_ERROR__VISITORS__INVALID_NUMBER_WRAPPER, CODAMA_ERROR__VISITORS__INVALID_PDA_SEED_VALUES, @@ -139,14 +140,16 @@ export const CodamaErrorMessages: Readonly<{ 'Cannot extend visitor with function [$visitFunction] as the base visitor does not support it.', [CODAMA_ERROR__VISITORS__CANNOT_FLATTEN_STRUCT_WITH_CONFLICTING_ATTRIBUTES]: 'Cannot flatten struct since this would cause the following attributes to conflict [$conflictingAttributes].', + [CODAMA_ERROR__VISITORS__CANNOT_FLATTEN_STRUCT_WITH_PLUGINS]: + 'Cannot flatten the struct of field [$fieldName] since it carries plugins that would be lost.', [CODAMA_ERROR__VISITORS__CANNOT_REMOVE_LAST_PATH_IN_NODE_STACK]: 'Cannot remove the last path in the node stack.', [CODAMA_ERROR__VISITORS__CANNOT_USE_OPTIONAL_ACCOUNT_AS_PDA_SEED_VALUE]: 'Cannot use optional account [$seedValueName] as the [$seedName] PDA seed for the [$instructionAccountName] account of the [$instructionName] instruction.', [CODAMA_ERROR__VISITORS__CYCLIC_DEPENDENCY_DETECTED_WHEN_RESOLVING_INSTRUCTION_DEFAULT_VALUES]: 'Circular dependency detected when resolving the default values of the [$instructionName] instruction. Got the following dependency cycle [$formattedCycle].', [CODAMA_ERROR__VISITORS__FAILED_TO_VALIDATE_NODE]: 'Failed to validate the given node [$formattedHistogram].', - [CODAMA_ERROR__VISITORS__INSTRUCTION_ENUM_ARGUMENT_NOT_FOUND]: - 'Could not find an enum argument named [$argumentName] for instruction [$instructionName].', + [CODAMA_ERROR__VISITORS__INSTRUCTION_ENUM_DATA_FIELD_NOT_FOUND]: + 'Could not find an enum data field named [$fieldName] for instruction [$instructionName].', [CODAMA_ERROR__VISITORS__INVALID_INSTRUCTION_DEFAULT_VALUE_DEPENDENCY]: 'Dependency [$dependencyName] of kind [$dependencyKind] is not a valid dependency of [$parentName] of kind [$parentKind] in the [$instructionName] instruction.', [CODAMA_ERROR__VISITORS__INVALID_NUMBER_WRAPPER]: 'Invalid number wrapper kind [$wrapper].', diff --git a/packages/nodes-from-anchor/src/defaultVisitor.ts b/packages/nodes-from-anchor/src/defaultVisitor.ts index bf53d2fa3..0e57d3cc3 100644 --- a/packages/nodes-from-anchor/src/defaultVisitor.ts +++ b/packages/nodes-from-anchor/src/defaultVisitor.ts @@ -1,13 +1,13 @@ import { assertIsNode, Node, RootNode } from '@codama/nodes'; import { deduplicateIdenticalDefinedTypesVisitor, - flattenInstructionDataArgumentsVisitor, + flattenInstructionDataVisitor, getCommonInstructionAccountDefaultRules, rootNodeVisitor, setFixedAccountSizesVisitor, setInstructionAccountDefaultValuesVisitor, transformU8ArraysToBytesVisitor, - unwrapInstructionArgsDefinedTypesVisitor, + unwrapInstructionDataDefinedTypesVisitor, visit, Visitor, } from '@codama/visitors'; @@ -34,8 +34,8 @@ export function defaultVisitor() { // Instructions. updateRoot(setInstructionAccountDefaultValuesVisitor(getCommonInstructionAccountDefaultRules())); - updateRoot(unwrapInstructionArgsDefinedTypesVisitor()); - updateRoot(flattenInstructionDataArgumentsVisitor()); + updateRoot(unwrapInstructionDataDefinedTypesVisitor()); + updateRoot(flattenInstructionDataVisitor()); // Extras. updateRoot(transformU8ArraysToBytesVisitor()); diff --git a/packages/visitors/README.md b/packages/visitors/README.md index 68bb99e64..768671a65 100644 --- a/packages/visitors/README.md +++ b/packages/visitors/README.md @@ -38,7 +38,7 @@ Let's go through all of them alphabetically. ### `addPdasVisitor` -This visitor adds `PdaNodes` to the desired `ProgramNodes`. It accepts an object where the keys are the program names and the values are the `PdaNodes` to add within these programs. +This visitor adds `PdaNodes` to the desired `ProgramNodes`. It accepts an object where the keys are the program identifiers (matched exactly) and the values are the `PdaNodes` to add within these programs. It throws if a new PDA shares a camelCase form with another PDA of the same program, since they would collide under the spec's casing-collision rule. ```ts codama.update( @@ -46,7 +46,7 @@ codama.update( // Add a PDA to the 'token' program. token: [ { - name: 'associatedToken', + identifier: 'associatedToken', seeds: [ variablePdaSeedNode('mint', publicKeyTypeNode()), constantPdaSeedNode( @@ -60,11 +60,11 @@ codama.update( // Add two PDAs to the 'counter' program. counter: [ { - name: 'counter', + identifier: 'counter', seeds: [variablePdaSeedNode('authority', publicKeyTypeNode())], }, { - name: 'counterConfig', + identifier: 'counterConfig', seeds: [variablePdaSeedNode('counter', publicKeyTypeNode())], }, ], @@ -87,13 +87,15 @@ It is mainly useful at IDL-ingestion boundaries that bypass `createFromRoot`, su Note that older IDLs can be upgraded instead of rejected by using the [`@codama/upgrade`](../upgrade) package as a `before` visitor. -### `createSubInstructionsFromEnumArgsVisitor` +### `createSubInstructionsFromEnumDataVisitor` -This visitor splits an instruction into multiple sub-instructions by using an enum argument such that each of its variants creates a different sub-instruction. It accepts an object where the keys are the instruction names and the values are the enum argument names that will be used to split the instruction. +This visitor splits an instruction into multiple sub-instructions by using an enum field of its data such that each of its variants creates a different sub-instruction. It accepts an object where the keys are the instruction names and the values are the identifiers of the enum data fields that will be used to split the instruction. + +Each sub-instruction is named `${instruction}_${variant}`. In it, the enum field is replaced by a `${instruction}_${variant}_discriminator` field (using the enum's `size` and defaulting to the variant's discriminator) followed by the variant's payload, whose fields are inlined when it is a struct. ```ts codama.update( - createSubInstructionsFromEnumArgsVisitor({ + createSubInstructionsFromEnumDataVisitor({ mint: 'mintArgs', transfer: 'transferArgs', burn: 'burnArgs', @@ -103,7 +105,7 @@ codama.update( ### `deduplicateIdenticalDefinedTypesVisitor` -This visitor goes through the `DefinedTypeNodes` of all `ProgramNodes` inside the Codama IDL and removes any duplicates. A `DefinedTypeNode` is considered a duplicate if it has the same name and data structure as another `DefinedTypeNode`. This is useful when you have multiple programs that share the same types. +This visitor goes through the `DefinedTypeNodes` of all `ProgramNodes` inside the Codama IDL and removes any duplicates, keeping the copy from the first program and repointing links to the removed copies at it. A `DefinedTypeNode` is considered a duplicate if it has the same identifier and data structure (ignoring docs) as another `DefinedTypeNode`, and if every defined type it links to without an explicit program is itself deduplicated across the same programs. This is useful when you have multiple programs that share the same types. ```ts codama.update(deduplicateIdenticalDefinedTypesVisitor()); @@ -125,17 +127,19 @@ Note that this visitor is mainly used for internal purposes. codama.update(fillDefaultPdaSeedValuesVisitor(instructionPath, linkables, strictMode)); ``` -### `flattenInstructionDataArgumentsVisitor` +### `flattenInstructionDataVisitor` -This visitor flattens any instruction arguments of type `StructTypeNode` such that their fields are no longer nested. This can be useful to simplify the data structure of an instruction. +This visitor flattens the fields of type `StructTypeNode` inside the `data` of every instruction such that their fields are no longer nested. This can be useful to simplify the data structure of an instruction. Linked data is left untouched since the defined type may be shared; use `unwrapInstructionDataDefinedTypesVisitor` first to flatten it. ```ts -codama.update(flattenInstructionDataArgumentsVisitor()); +codama.update(flattenInstructionDataVisitor()); ``` ### `flattenStructVisitor` -This visitor flattens any struct fields that are also structs such that their fields are no longer nested. It accepts an object such that the keys are the struct names and the values are the field names to flatten or `"*"` to flatten all struct fields. +This visitor flattens any struct fields that are also structs such that their fields are no longer nested. It accepts an object such that the keys are the struct names and the values are the field identifiers to flatten (matched exactly) or `"*"` to flatten all struct fields. + +Structs carrying `transforms` are not inlined, since that would change their wire format. Flattening throws if a struct to inline carries `plugins`, since they would be lost, or if two resulting fields share a camelCase form. ```ts codama.update( @@ -154,22 +158,22 @@ This visitor goes through all `DefinedTypeNodes` and outputs a histogram of how const histogram = codama.accept(getDefinedTypeHistogramVisitor()); ``` -The returned histogram is an object such that the keys are the names of visited `DefinedTypeNodes` and the values are objects with properties described below. +The returned histogram is an object such that the keys are the identifiers of the linked `DefinedTypeNodes`, prefixed by the identifier of their program, and the values are objects with properties described below. ```ts export type DefinedTypeHistogram = { - [key: CamelCaseString | `${CamelCaseString}.${CamelCaseString}`]: { - // The number of times the type is used as a direct instruction argument. - directlyAsInstructionArgs: number; + [key: IdentifierString | `${IdentifierString}.${IdentifierString}`]: { + // The number of times the type is used as an instruction's data or as the type of one of its top-level data fields. + directlyAsInstructionData: number; // The number of times the type is used in account data. inAccounts: number; // The number of times the type is used in other defined types. inDefinedTypes: number; // The number of times the type is used in event payloads. inEvents: number; - // The number of times the type is used in instruction arguments. - inInstructionArgs: number; - // The number of times the type is used in total. + // The number of times the type is used in instruction data. + inInstructionData: number; + // The number of times the type is used in total, including default values, PDA seeds and constants. total: number; }; }; @@ -179,14 +183,14 @@ This histogram is used internally in other visitors to understand how types are ### `setAccountDiscriminatorFromFieldVisitor` -This visitor helps set account discriminators based on a field in the account data and the value it should take. This is typically used on the very first field of the account data which usually refers to a discriminator value that helps distinguish between multiple accounts in a program. +This visitor helps set account discriminators based on a field in the account data and the value it should take. This is typically used on the very first field of the account data which usually refers to a discriminator value that helps distinguish between multiple accounts in a program. The account data must be an inline `StructTypeNode`, since changing a linked defined type would affect every node that uses it. ```ts codama.update( setAccountDiscriminatorFromFieldVisitor({ - counter: { field: 'discriminator', value: k.enumValueNode('accountState', 'counter') }, - escrow: { field: 'discriminator', value: k.enumValueNode('accountState', 'escrow') }, - vault: { field: 'discriminator', value: k.enumValueNode('accountState', 'vault') }, + counter: { field: 'discriminator', value: enumValueNode(definedTypeLinkNode('accountState'), 'counter') }, + escrow: { field: 'discriminator', value: enumValueNode(definedTypeLinkNode('accountState'), 'escrow') }, + vault: { field: 'discriminator', value: enumValueNode(definedTypeLinkNode('accountState'), 'vault') }, }), ); ``` @@ -250,25 +254,27 @@ codama.update( ### `setStructDefaultValuesVisitor` -This visitor sets default values for all provided fields of a struct. It accepts an object where the keys are the struct names and the values are objects that map field names to their new default values. +This visitor sets default values for all provided fields of a struct. It accepts an object where the keys select the nodes containing the structs (e.g. defined types, accounts or instructions with inline `data`) and the values are objects that map field identifiers (matched exactly) to their new default values. A `null` value removes the default value of a field. ```ts codama.update( setStructDefaultValuesVisitor({ person: { - age: numberValueNode(42), + age: integerValueNode('42'), dateOfBirth: noneValueNode(), }, counter: { - count: numberValueNode(0), + count: { strategy: 'omitted', value: integerValueNode('0') }, }, }), ); ``` +Contextual default values of instruction data fields, such as the bump of an account, are expressed with an `injectedValueNode` whose key is provided by the instruction's `provides` attribute. + ### `transformDefinedTypesIntoAccountsVisitor` -This visitor transforms `DefinedTypeNodes` matching the provided names into `AccountNodes` within the same `ProgramNode`. +This visitor transforms `DefinedTypeNodes` matching the provided identifiers into `AccountNodes` within the same `ProgramNode`, using the type of each `DefinedTypeNode` as the account data. ```ts codama.update(transformDefinedTypesIntoAccountsVisitor(['counter', 'escrow'])); @@ -276,33 +282,33 @@ codama.update(transformDefinedTypesIntoAccountsVisitor(['counter', 'escrow'])); ### `transformU8ArraysToBytesVisitor` -This visitor transforms any fixed-size array of `u8` numbers into a fixed-size `BytesTypeNode`. +This visitor transforms any fixed-size array of plain `u8` integers into a `BytesTypeNode` with a `FixedSizeTransformNode`. By default, it transforms arrays of any size, but you can provide an array of sizes to only transform specific ones. ```ts -codama.update(transformU8ArraysToBytesVisitor()); +codama.update(transformU8ArraysToBytesVisitor([32, 64])); ``` ### `unwrapDefinedTypesVisitor` -This visitor replaces any `DefinedTypeLinkNode` with the actual `DefinedTypeNode` it points to. By default, it unwraps all defined types, but you can provide an array of names to only unwrap specific types. +This visitor replaces any `DefinedTypeLinkNode` with the type of the `DefinedTypeNode` it points to and removes the inlined `DefinedTypeNodes` from their programs. By default, it unwraps all defined types, but you can provide an array of identifiers, optionally prefixed by a program identifier, to only unwrap specific types. -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. +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. ```ts -codama.update(unwrapDefinedTypesVisitor(['counter', 'escrow'])); +codama.update(unwrapDefinedTypesVisitor(['counter', 'splToken.escrow'])); ``` -### `unwrapInstructionArgsDefinedTypesVisitor` +### `unwrapInstructionDataDefinedTypesVisitor` -This visitor replaces `DefinedTypeLinkNodes` used only once inside an instruction argument with the actual `DefinedTypeNodes` they refer to. +This visitor inlines the `DefinedTypeNodes` that are used exactly once in the Codama IDL, either as the `data` of an instruction or as the type of one of its top-level data fields. Enums are kept as defined types. ```ts -codama.update(unwrapInstructionArgsDefinedTypesVisitor()); +codama.update(unwrapInstructionDataDefinedTypesVisitor()); ``` ### `unwrapTupleEnumWithSingleStructVisitor` -This visitor transforms `EnumTupleVariantTypeNodes` with a single `StructTypeNode` item into `EnumStructVariantTypeNodes`. By default, it will unwrap all tuple variants matching that criteria, but you can provide an array of names to only unwrap specific variants. +This visitor transforms `EnumVariantTypeNodes` whose data is a `TupleTypeNode` with a single `StructTypeNode` item (or a link to one) such that their data becomes the struct itself. By default, it will unwrap all variants matching that criteria, but you can provide an array of `NodeSelectors` to only unwrap specific variants. Linked defined types that are no longer used afterwards are removed. ```ts codama.update(unwrapTupleEnumWithSingleStructVisitor()); @@ -310,7 +316,7 @@ codama.update(unwrapTupleEnumWithSingleStructVisitor()); ### `unwrapTypeDefinedLinksVisitor` -This visitor replaces any `DefinedTypeLinkNode` matching the provided `NodeSelectors` with the actual `DefinedTypeNode` it points to. +This visitor replaces any `DefinedTypeLinkNode` matching the provided `NodeSelectors` with the type of the `DefinedTypeNode` it points to, applying the `transforms` of the link on top of it. Contrary to the `unwrapDefinedTypesVisitor` though, it only replaces the requested `DefinedTypeLinkNodes` and does not remove the associated `DefinedTypeNode` from its `ProgramNode`. diff --git a/packages/visitors/src/addPdasVisitor.ts b/packages/visitors/src/addPdasVisitor.ts index 2e5b72f08..0e1915732 100644 --- a/packages/visitors/src/addPdasVisitor.ts +++ b/packages/visitors/src/addPdasVisitor.ts @@ -1,35 +1,39 @@ import { CODAMA_ERROR__VISITORS__CANNOT_ADD_DUPLICATED_PDA_NAMES, CodamaError } from '@codama/errors'; import { camelCase } from '@codama/fragments/casing'; -import { assertIsNode, pdaNode, PdaNodeInput, programNode } from '@codama/nodes'; +import { assertIsNode, IdentifierString, pdaNode, PdaNodeInput, programNode } from '@codama/nodes'; import { bottomUpTransformerVisitor } from '@codama/visitors-core'; -export function addPdasVisitor(pdas: Record[]>) { +/** + * Add PDAs to programs, keyed by program identifier (matched exactly). + * + * @throws {CODAMA_ERROR__VISITORS__CANNOT_ADD_DUPLICATED_PDA_NAMES} if a new + * PDA shares a camelCase form with an existing or another new PDA of the + * same program, as they would then collide under the spec's + * casing-collision rule. + */ +export function addPdasVisitor(pdas: Record) { return bottomUpTransformerVisitor( - Object.entries(pdas).map(([uncasedProgramName, newPdas]) => { - const programName = camelCase(uncasedProgramName); - return { - select: `[programNode]${programName}`, - transform: node => { - assertIsNode(node, 'programNode'); - const existingPdaNames = new Set((node.pdas ?? []).map(pda => pda.identifier)); - const newPdaNames = new Set(newPdas.map(pda => pda.identifier)); - const overlappingPdaNames = new Set([...existingPdaNames].filter(name => newPdaNames.has(name))); - if (overlappingPdaNames.size > 0) { - throw new CodamaError(CODAMA_ERROR__VISITORS__CANNOT_ADD_DUPLICATED_PDA_NAMES, { - duplicatedPdaNames: [...overlappingPdaNames], - program: node, - programName: node.identifier, - }); - } - return programNode({ - ...node, - pdas: [ - ...(node.pdas ?? []), - ...newPdas.map(({ identifier, seeds, docs }) => pdaNode({ docs, identifier, seeds })), - ], + Object.entries(pdas).map(([programName, newPdas]) => ({ + select: ['[programNode]', programName], + transform: node => { + assertIsNode(node, 'programNode'); + const newPdaNodes = newPdas.map(pda => pdaNode(pda)); + const usedNames = new Set((node.pdas ?? []).map(pda => camelCase(pda.identifier))); + const duplicatedPdaNames = new Set(); + newPdaNodes.forEach(pda => { + const key = camelCase(pda.identifier); + if (usedNames.has(key)) duplicatedPdaNames.add(pda.identifier); + usedNames.add(key); + }); + if (duplicatedPdaNames.size > 0) { + throw new CodamaError(CODAMA_ERROR__VISITORS__CANNOT_ADD_DUPLICATED_PDA_NAMES, { + duplicatedPdaNames: [...duplicatedPdaNames], + program: node, + programName: node.identifier, }); - }, - }; - }), + } + return programNode({ ...node, pdas: [...(node.pdas ?? []), ...newPdaNodes] }); + }, + })), ); } diff --git a/packages/visitors/src/createSubInstructionsFromEnumArgsVisitor.ts b/packages/visitors/src/createSubInstructionsFromEnumArgsVisitor.ts deleted file mode 100644 index 38dd7912c..000000000 --- a/packages/visitors/src/createSubInstructionsFromEnumArgsVisitor.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { CODAMA_ERROR__VISITORS__INSTRUCTION_ENUM_ARGUMENT_NOT_FOUND, CodamaError } from '@codama/errors'; -import { camelCase } from '@codama/fragments/casing'; -import { - assertIsNode, - EnumTypeNode, - instructionArgumentNode, - InstructionNode, - instructionNode, - isNode, - numberTypeNode, - numberValueNode, -} from '@codama/nodes'; -import { - BottomUpNodeTransformerWithSelector, - bottomUpTransformerVisitor, - LinkableDictionary, - pipe, - recordLinkablesOnFirstVisitVisitor, -} from '@codama/visitors-core'; - -import { flattenInstructionArguments } from './flattenInstructionDataArgumentsVisitor'; - -export function createSubInstructionsFromEnumArgsVisitor(map: Record) { - const linkables = new LinkableDictionary(); - - const visitor = bottomUpTransformerVisitor( - Object.entries(map).map(([selector, argNameInput]): BottomUpNodeTransformerWithSelector => ({ - select: ['[instructionNode]', selector], - transform: (node, stack) => { - assertIsNode(node, 'instructionNode'); - - const argFields = node.arguments ?? []; - const argName = camelCase(argNameInput); - const argFieldIndex = argFields.findIndex(field => field.identifier === argName); - const argField = argFieldIndex >= 0 ? argFields[argFieldIndex] : null; - if (!argField) { - throw new CodamaError(CODAMA_ERROR__VISITORS__INSTRUCTION_ENUM_ARGUMENT_NOT_FOUND, { - argumentName: argName, - instruction: node, - instructionName: node.identifier, - }); - } - - let argType: EnumTypeNode; - if (isNode(argField.type, 'enumTypeNode')) { - argType = argField.type; - } else if ( - isNode(argField.type, 'definedTypeLinkNode') && - linkables.has([...stack.getPath(), argField.type]) - ) { - const linkedType = linkables.get([...stack.getPath(), argField.type])?.type; - assertIsNode(linkedType, 'enumTypeNode'); - argType = linkedType; - } else { - throw new CodamaError(CODAMA_ERROR__VISITORS__INSTRUCTION_ENUM_ARGUMENT_NOT_FOUND, { - argumentName: argName, - instruction: node, - instructionName: node.identifier, - }); - } - - const subInstructions = (argType.variants ?? []).map((variant, index): InstructionNode => { - const subName = camelCase(`${node.identifier} ${variant.identifier}`); - const subFields = argFields.slice(0, argFieldIndex); - subFields.push( - instructionArgumentNode({ - defaultValue: numberValueNode(index), - defaultValueStrategy: 'omitted', - identifier: `${subName}Discriminator`, - type: numberTypeNode('u8'), - }), - ); - if (isNode(variant, 'enumStructVariantTypeNode')) { - subFields.push( - instructionArgumentNode({ - ...argField, - type: variant.struct, - }), - ); - } else if (isNode(variant, 'enumTupleVariantTypeNode')) { - subFields.push( - instructionArgumentNode({ - ...argField, - type: variant.tuple, - }), - ); - } - subFields.push(...argFields.slice(argFieldIndex + 1)); - - return instructionNode({ - ...node, - arguments: flattenInstructionArguments(subFields), - identifier: subName, - }); - }); - - return instructionNode({ - ...node, - subInstructions: [...(node.subInstructions ?? []), ...subInstructions], - }); - }, - })), - ); - - return pipe(visitor, v => recordLinkablesOnFirstVisitVisitor(v, linkables)); -} diff --git a/packages/visitors/src/createSubInstructionsFromEnumDataVisitor.ts b/packages/visitors/src/createSubInstructionsFromEnumDataVisitor.ts new file mode 100644 index 000000000..64d942a9b --- /dev/null +++ b/packages/visitors/src/createSubInstructionsFromEnumDataVisitor.ts @@ -0,0 +1,148 @@ +import { CODAMA_ERROR__VISITORS__INSTRUCTION_ENUM_DATA_FIELD_NOT_FOUND, CodamaError } from '@codama/errors'; +import { + assertIsNode, + EnumTypeNode, + EnumVariantTypeNode, + identifierString, + InstructionNode, + instructionNode, + integerValueNode, + isNode, + structFieldTypeNode, + StructTypeNode, + structTypeNode, + TypeNode, +} from '@codama/nodes'; +import { + BottomUpNodeTransformerWithSelector, + bottomUpTransformerVisitor, + findProgramNodeFromPath, + getLastNodeFromPath, + LinkableDictionary, + NodePath, + pipe, + recordLinkablesOnFirstVisitVisitor, +} from '@codama/visitors-core'; + +import { flattenStruct } from './flattenStructVisitor'; +import { inlineDefinedType } from './inlineDefinedTypeHelpers'; + +/** + * Create one sub-instruction per variant of an enum field of an + * instruction's data. + * + * The map's keys select instructions and its values are the identifiers of + * their enum data fields (matched exactly). The enum may be inline or + * linked, and so may the instruction data. Each sub-instruction is named + * `${instruction}_${variant}`, without any casing transformation, and + * replaces the enum field with: + * - a `${instruction}_${variant}_discriminator` field using the enum's + * `size`, whose omitted default value is the variant's discriminator; + * - the variant's payload under the enum field's identifier, inlined when + * it is a struct. + * + * When the enum carries transforms, the discriminator and payload stay + * grouped in a struct carrying them, so the wire format is preserved. + * + * @throws {CODAMA_ERROR__VISITORS__INSTRUCTION_ENUM_DATA_FIELD_NOT_FOUND} if + * the instruction has no data, if its data has no such field or if that + * field is not an enum. + * @throws {CODAMA_ERROR__UNEXPECTED_NODE_KIND} if the instruction data is + * not a struct. + * @throws {CODAMA_ERROR__LINKED_NODE_NOT_FOUND} if the instruction data or + * the enum field links to a missing defined type. + */ +export function createSubInstructionsFromEnumDataVisitor(map: Record) { + const linkables = new LinkableDictionary(); + + const visitor = bottomUpTransformerVisitor( + Object.entries(map).map(([selector, fieldName]): BottomUpNodeTransformerWithSelector => ({ + select: ['[instructionNode]', selector], + transform: (node, stack) => { + assertIsNode(node, 'instructionNode'); + const notFound = () => + new CodamaError(CODAMA_ERROR__VISITORS__INSTRUCTION_ENUM_DATA_FIELD_NOT_FOUND, { + fieldName: identifierString(fieldName), + instruction: node, + instructionName: node.identifier, + }); + + if (!node.data) throw notFound(); + const data = resolveType(node.data, stack.getPath(), linkables); + assertIsNode(data, 'structTypeNode'); + + const fields = data.fields ?? []; + const fieldIndex = fields.findIndex(field => field.identifier === fieldName); + if (fieldIndex < 0) throw notFound(); + const field = fields[fieldIndex]; + const enumType = resolveType(field.type, stack.getPath(), linkables); + if (!isNode(enumType, 'enumTypeNode')) throw notFound(); + + const subInstructions = (enumType.variants ?? []).map((variant, index): InstructionNode => { + const subName = `${node.identifier}_${variant.identifier}`; + const subData = getSubInstructionData(data, fieldIndex, enumType, variant, index, subName); + return instructionNode({ ...node, data: subData, identifier: subName, subInstructions: undefined }); + }); + + return instructionNode({ + ...node, + subInstructions: [...(node.subInstructions ?? []), ...subInstructions], + }); + }, + })), + ); + + return pipe(visitor, v => recordLinkablesOnFirstVisitVisitor(v, linkables)); +} + +/** Follow a defined type link, if any, without mutating the linked defined type. */ +function resolveType(type: TypeNode, path: NodePath, linkables: LinkableDictionary): TypeNode { + if (!isNode(type, 'definedTypeLinkNode')) return type; + const definedTypePath = linkables.getPathOrThrow([...path, type]); + const definedType = getLastNodeFromPath(definedTypePath); + return inlineDefinedType(type, definedType.type, { + definedTypeProgram: findProgramNodeFromPath(definedTypePath)?.identifier, + linkProgram: findProgramNodeFromPath(path)?.identifier, + }); +} + +function getSubInstructionData( + data: StructTypeNode, + fieldIndex: number, + enumType: EnumTypeNode, + variant: EnumVariantTypeNode, + variantIndex: number, + subName: string, +): StructTypeNode { + const fields = data.fields ?? []; + const field = fields[fieldIndex]; + const discriminatorField = structFieldTypeNode({ + defaultValue: integerValueNode(String(variant.discriminator ?? variantIndex)), + defaultValueStrategy: 'omitted', + identifier: `${subName}_discriminator`, + type: enumType.size, + }); + const payloadField = variant.data + ? structFieldTypeNode({ + docs: field.docs, + identifier: field.identifier, + plugins: field.plugins, + type: variant.data, + }) + : undefined; + const variantStruct = structTypeNode(payloadField ? [discriminatorField, payloadField] : [discriminatorField], { + transforms: enumType.transforms, + }); + + const subData = structTypeNode( + [ + ...fields.slice(0, fieldIndex), + structFieldTypeNode({ identifier: field.identifier, type: variantStruct }), + ...fields.slice(fieldIndex + 1), + ], + { ...data }, + ); + + // Inline the discriminator and payload, then the payload's fields when it is a struct. + return flattenStruct(flattenStruct(subData, [field.identifier]), [field.identifier]); +} diff --git a/packages/visitors/src/deduplicateIdenticalDefinedTypesVisitor.ts b/packages/visitors/src/deduplicateIdenticalDefinedTypesVisitor.ts index f3d707fd5..676d4b386 100644 --- a/packages/visitors/src/deduplicateIdenticalDefinedTypesVisitor.ts +++ b/packages/visitors/src/deduplicateIdenticalDefinedTypesVisitor.ts @@ -1,6 +1,17 @@ -import { assertIsNode, DefinedTypeNode, getAllPrograms, ProgramNode } from '@codama/nodes'; import { + assertIsNode, + DefinedTypeNode, + definedTypeLinkNode, + getAllPrograms, + IdentifierString, + Node, + programLinkNode, + ProgramNode, +} from '@codama/nodes'; +import { + bottomUpTransformerVisitor, deleteNodesVisitor, + findProgramNodeFromPath, getUniqueHashStringVisitor, NodeSelector, rootNodeVisitor, @@ -12,59 +23,114 @@ type DefinedTypeWithProgram = { type: DefinedTypeNode; }; +/** + * Remove defined types that are identical (ignoring docs) across programs, + * keeping the copy from the first program they appear in, and repoint the + * links to removed copies at the kept one. + * + * Two same-named types only count as identical if every defined type they + * reference without an explicit program is itself deduplicated across the + * same programs, since those references resolve per program. + */ export function deduplicateIdenticalDefinedTypesVisitor() { return rootNodeVisitor(root => { - const typeMap = new Map(); + const allPrograms = getAllPrograms(root); + const typeMap = new Map(); // Fill the type map with all defined types. - const allPrograms = getAllPrograms(root); allPrograms.forEach(program => { (program.definedTypes ?? []).forEach(type => { - const typeWithProgram = { program, type }; const list = typeMap.get(type.identifier) ?? []; - typeMap.set(type.identifier, [...list, typeWithProgram]); + typeMap.set(type.identifier, [...list, { program, type }]); }); }); // Remove all types that are not duplicated. typeMap.forEach((list, name) => { - if (list.length <= 1) { - typeMap.delete(name); - } + if (list.length <= 1) typeMap.delete(name); }); // Remove duplicates whose types are not equal. const hashVisitor = getUniqueHashStringVisitor({ removeDocs: true }); typeMap.forEach((list, name) => { - const types = list.map(item => visit(item.type, hashVisitor)); - const typesAreEqual = types.every((type, _, arr) => type === arr[0]); - if (!typesAreEqual) { - typeMap.delete(name); - } + const hashes = list.map(item => visit(item.type, hashVisitor)); + if (!hashes.every(hash => hash === hashes[0])) typeMap.delete(name); }); - // Get the selectors for all defined types that needs deleting. - // Thus, we must select all but the first duplicate of each list. - const deleteSelectors = Array.from(typeMap.values()) - // Order lists by program index, get their tails and flatten. - .flatMap(list => { - const sortedList = list.sort((a, b) => allPrograms.indexOf(a.program) - allPrograms.indexOf(b.program)); - const [, ...sortedListTail] = sortedList; - return sortedListTail; - }) - // Get selectors from the defined types and their programs. - .map( - ({ program, type }): NodeSelector => - `[programNode]${program.identifier}.[definedTypeNode]${type.identifier}`, + // Remove duplicates referencing, without an explicit program, a type + // that is not itself deduplicated across the same programs. + let changed = true; + while (changed) { + changed = false; + typeMap.forEach((list, name) => { + const programs = list.map(item => item.program.identifier); + const isSafe = getUnqualifiedDefinedTypeLinks(list[0].type).every(reference => { + const referencePrograms = (typeMap.get(reference) ?? []).map(item => item.program.identifier); + return programs.every(program => referencePrograms.includes(program)); + }); + if (!isSafe) { + typeMap.delete(name); + changed = true; + } + }); + } + + if (typeMap.size === 0) return root; + + // Order each list by program index: the first item is kept, the others are removed. + const keptPrograms = new Map<`${IdentifierString}.${IdentifierString}`, IdentifierString>(); + const deleteSelectors: NodeSelector[] = []; + typeMap.forEach((list, name) => { + const [kept, ...removed] = [...list].sort( + (a, b) => allPrograms.indexOf(a.program) - allPrograms.indexOf(b.program), ); + removed.forEach(({ program }) => { + keptPrograms.set(`${program.identifier}.${name}`, kept.program.identifier); + deleteSelectors.push(`[programNode]${program.identifier}.[definedTypeNode]${name}`); + }); + }); - // Delete the identified nodes if any. - if (deleteSelectors.length > 0) { - const newRoot = visit(root, deleteNodesVisitor(deleteSelectors)); - assertIsNode(newRoot, 'rootNode'); - return newRoot; - } + // Repoint links to removed types at the kept ones. + const repointedRoot = visit( + root, + bottomUpTransformerVisitor([ + { + select: '[definedTypeLinkNode]', + transform: (link, stack) => { + assertIsNode(link, 'definedTypeLinkNode'); + const program = + link.program?.identifier ?? findProgramNodeFromPath(stack.getPath())?.identifier; + if (!program) return link; + const keptProgram = keptPrograms.get(`${program}.${link.identifier}`); + if (!keptProgram) return link; + return definedTypeLinkNode(link.identifier, { ...link, program: programLinkNode(keptProgram) }); + }, + }, + ]), + ); - return root; + // Delete the removed types. + assertIsNode(repointedRoot, 'rootNode'); + const newRoot = visit(repointedRoot, deleteNodesVisitor(deleteSelectors)); + assertIsNode(newRoot, 'rootNode'); + return newRoot; }); } + +function getUnqualifiedDefinedTypeLinks(node: Node): IdentifierString[] { + const identifiers = new Set(); + visit( + node, + bottomUpTransformerVisitor([ + { + select: '[definedTypeLinkNode]', + transform: link => { + assertIsNode(link, 'definedTypeLinkNode'); + if (!link.program) identifiers.add(link.identifier); + return link; + }, + }, + ]), + ); + return [...identifiers]; +} diff --git a/packages/visitors/src/flattenInstructionDataArgumentsVisitor.ts b/packages/visitors/src/flattenInstructionDataArgumentsVisitor.ts deleted file mode 100644 index f64aa64e9..000000000 --- a/packages/visitors/src/flattenInstructionDataArgumentsVisitor.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { CODAMA_ERROR__VISITORS__CANNOT_FLATTEN_STRUCT_WITH_CONFLICTING_ATTRIBUTES, CodamaError } from '@codama/errors'; -import { camelCase } from '@codama/fragments/casing'; -import { assertIsNode, InstructionArgumentNode, instructionArgumentNode, instructionNode, isNode } from '@codama/nodes'; -import { bottomUpTransformerVisitor } from '@codama/visitors-core'; - -export function flattenInstructionDataArgumentsVisitor() { - return bottomUpTransformerVisitor([ - { - select: '[instructionNode]', - transform: instruction => { - assertIsNode(instruction, 'instructionNode'); - return instructionNode({ - ...instruction, - arguments: flattenInstructionArguments(instruction.arguments ?? []), - }); - }, - }, - ]); -} - -export type FlattenInstructionArgumentsConfig = string[] | '*'; - -export const flattenInstructionArguments = ( - nodes: InstructionArgumentNode[], - options: FlattenInstructionArgumentsConfig = '*', -): InstructionArgumentNode[] => { - const camelCaseOptions = options === '*' ? options : options.map(camelCase); - const shouldInline = (node: InstructionArgumentNode): boolean => - options === '*' || camelCaseOptions.includes(camelCase(node.identifier)); - const inlinedArguments = nodes.flatMap(node => { - if (isNode(node.type, 'structTypeNode') && shouldInline(node)) { - return (node.type.fields ?? []).map(field => instructionArgumentNode({ ...field })); - } - return node; - }); - - const inlinedFieldsNames = inlinedArguments.map(arg => arg.identifier); - const duplicates = inlinedFieldsNames.filter((e, i, a) => a.indexOf(e) !== i); - const uniqueDuplicates = [...new Set(duplicates)]; - const hasConflictingNames = uniqueDuplicates.length > 0; - - if (hasConflictingNames) { - throw new CodamaError(CODAMA_ERROR__VISITORS__CANNOT_FLATTEN_STRUCT_WITH_CONFLICTING_ATTRIBUTES, { - conflictingAttributes: uniqueDuplicates, - }); - } - - return hasConflictingNames ? nodes : inlinedArguments; -}; diff --git a/packages/visitors/src/flattenInstructionDataVisitor.ts b/packages/visitors/src/flattenInstructionDataVisitor.ts new file mode 100644 index 000000000..117b53361 --- /dev/null +++ b/packages/visitors/src/flattenInstructionDataVisitor.ts @@ -0,0 +1,25 @@ +import { assertIsNode, instructionNode, isNode } from '@codama/nodes'; +import { bottomUpTransformerVisitor } from '@codama/visitors-core'; + +import { flattenStruct } from './flattenStructVisitor'; + +/** + * Inline the fields of the struct-typed fields of every instruction's + * `data`, when that data is a struct. + * + * Linked data (`definedTypeLinkNode`) is left untouched, since the defined + * type may be shared; unwrap it first (e.g. with + * `unwrapInstructionDataDefinedTypesVisitor`) to flatten it. + */ +export function flattenInstructionDataVisitor() { + return bottomUpTransformerVisitor([ + { + select: '[instructionNode]', + transform: instruction => { + assertIsNode(instruction, 'instructionNode'); + if (!isNode(instruction.data, 'structTypeNode')) return instruction; + return instructionNode({ ...instruction, data: flattenStruct(instruction.data) }); + }, + }, + ]); +} diff --git a/packages/visitors/src/flattenStructVisitor.ts b/packages/visitors/src/flattenStructVisitor.ts index a4879fab9..650a35894 100644 --- a/packages/visitors/src/flattenStructVisitor.ts +++ b/packages/visitors/src/flattenStructVisitor.ts @@ -1,10 +1,26 @@ -import { CODAMA_ERROR__VISITORS__CANNOT_FLATTEN_STRUCT_WITH_CONFLICTING_ATTRIBUTES, CodamaError } from '@codama/errors'; +import { + CODAMA_ERROR__VISITORS__CANNOT_FLATTEN_STRUCT_WITH_CONFLICTING_ATTRIBUTES, + CODAMA_ERROR__VISITORS__CANNOT_FLATTEN_STRUCT_WITH_PLUGINS, + CodamaError, +} from '@codama/errors'; import { camelCase } from '@codama/fragments/casing'; import { assertIsNode, isNode, Node, StructFieldTypeNode, StructTypeNode, structTypeNode } from '@codama/nodes'; import { BottomUpNodeTransformerWithSelector, bottomUpTransformerVisitor } from '@codama/visitors-core'; export type FlattenStructOptions = string[] | '*'; +/** + * Inline the fields of struct-typed fields into their parent struct, for + * every struct matching the given selectors. + * + * @example + * ```ts + * // Flatten all struct fields of the `myAccount` account's data. + * flattenStructVisitor({ myAccount: '*' }); + * // Only flatten its `config` field. + * flattenStructVisitor({ myAccount: ['config'] }); + * ``` + */ export function flattenStructVisitor(map: Record) { return bottomUpTransformerVisitor( Object.entries(map).map(([stack, options]): BottomUpNodeTransformerWithSelector => ({ @@ -14,28 +30,51 @@ export function flattenStructVisitor(map: Record) ); } +/** + * Inline the fields of the struct-typed fields of `node`, either all of + * them (`'*'`) or those whose identifiers are listed (matched exactly). + * + * A field whose struct carries `transforms` is kept as is, since inlining + * its fields would drop the transforms and change the wire format. + * + * @throws {CODAMA_ERROR__VISITORS__CANNOT_FLATTEN_STRUCT_WITH_PLUGINS} if + * a struct to inline carries `plugins`, since they would be lost. + * @throws {CODAMA_ERROR__VISITORS__CANNOT_FLATTEN_STRUCT_WITH_CONFLICTING_ATTRIBUTES} + * if two resulting fields share a camelCase form, as they would then collide + * under the spec's casing-collision rule. + */ export const flattenStruct = (node: Node, options: FlattenStructOptions = '*'): StructTypeNode => { assertIsNode(node, 'structTypeNode'); - const camelCaseOptions = options === '*' ? options : options.map(camelCase); const shouldInline = (field: StructFieldTypeNode): boolean => - options === '*' || camelCaseOptions.includes(camelCase(field.identifier)); + (options === '*' || options.includes(field.identifier)) && + isNode(field.type, 'structTypeNode') && + (field.type.transforms ?? []).length === 0; const inlinedFields = (node.fields ?? []).flatMap(field => { - if (isNode(field.type, 'structTypeNode') && shouldInline(field)) { - return field.type.fields ?? []; + if (!shouldInline(field)) return [field]; + const struct = field.type as StructTypeNode; + if ((struct.plugins ?? []).length > 0) { + throw new CodamaError(CODAMA_ERROR__VISITORS__CANNOT_FLATTEN_STRUCT_WITH_PLUGINS, { + field, + fieldName: field.identifier, + }); } - return [field]; + return struct.fields ?? []; }); - const inlinedFieldsNames = inlinedFields.map(arg => arg.identifier); - const duplicates = inlinedFieldsNames.filter((e, i, a) => a.indexOf(e) !== i); - const uniqueDuplicates = [...new Set(duplicates)]; - const hasConflictingNames = uniqueDuplicates.length > 0; + const fieldsByCamelCase = new Map(); + inlinedFields.forEach(field => { + const key = camelCase(field.identifier); + fieldsByCamelCase.set(key, [...(fieldsByCamelCase.get(key) ?? []), field]); + }); + const conflictingAttributes = [...fieldsByCamelCase.values()] + .filter(fields => fields.length > 1) + .flatMap(fields => [...new Set(fields.map(field => field.identifier))]); - if (hasConflictingNames) { + if (conflictingAttributes.length > 0) { throw new CodamaError(CODAMA_ERROR__VISITORS__CANNOT_FLATTEN_STRUCT_WITH_CONFLICTING_ATTRIBUTES, { - conflictingAttributes: uniqueDuplicates, + conflictingAttributes, }); } - return hasConflictingNames ? node : structTypeNode(inlinedFields); + return structTypeNode(inlinedFields, { ...node }); }; diff --git a/packages/visitors/src/getDefinedTypeHistogramVisitor.ts b/packages/visitors/src/getDefinedTypeHistogramVisitor.ts index 3809d46e1..fed2cb7e3 100644 --- a/packages/visitors/src/getDefinedTypeHistogramVisitor.ts +++ b/packages/visitors/src/getDefinedTypeHistogramVisitor.ts @@ -1,13 +1,12 @@ -import type { IdentifierString } from '@codama/nodes'; +import { IdentifierString, isNode, Node } from '@codama/nodes'; import { extendVisitor, findProgramNodeFromPath, - interceptVisitor, mergeVisitor, + NodePath, NodeStack, pipe, recordNodeStackVisitor, - visit, Visitor, } from '@codama/visitors-core'; @@ -15,30 +14,32 @@ type DefinedTypeHistogramKey = IdentifierString | `${IdentifierString}.${Identif export type DefinedTypeHistogram = { [key: DefinedTypeHistogramKey]: { - directlyAsInstructionArgs: number; + /** Uses as an instruction's `data` itself or as the type of one of its top-level data fields. */ + directlyAsInstructionData: number; inAccounts: number; inDefinedTypes: number; inEvents: number; - inInstructionArgs: number; + inInstructionData: number; total: number; }; }; +type LinkUsage = { direct: boolean; mode: 'account' | 'definedType' | 'event' | 'instruction' | null }; + function mergeHistograms(histograms: DefinedTypeHistogram[]): DefinedTypeHistogram { const result: DefinedTypeHistogram = {}; histograms.forEach(histogram => { - Object.keys(histogram).forEach(key => { - const mainCaseKey = key as IdentifierString; - if (result[mainCaseKey] === undefined) { - result[mainCaseKey] = histogram[mainCaseKey]; + (Object.keys(histogram) as DefinedTypeHistogramKey[]).forEach(key => { + if (result[key] === undefined) { + result[key] = { ...histogram[key] }; } else { - result[mainCaseKey].total += histogram[mainCaseKey].total; - result[mainCaseKey].inAccounts += histogram[mainCaseKey].inAccounts; - result[mainCaseKey].inDefinedTypes += histogram[mainCaseKey].inDefinedTypes; - result[mainCaseKey].inEvents += histogram[mainCaseKey].inEvents; - result[mainCaseKey].inInstructionArgs += histogram[mainCaseKey].inInstructionArgs; - result[mainCaseKey].directlyAsInstructionArgs += histogram[mainCaseKey].directlyAsInstructionArgs; + result[key].total += histogram[key].total; + result[key].inAccounts += histogram[key].inAccounts; + result[key].inDefinedTypes += histogram[key].inDefinedTypes; + result[key].inEvents += histogram[key].inEvents; + result[key].inInstructionData += histogram[key].inInstructionData; + result[key].directlyAsInstructionData += histogram[key].directlyAsInstructionData; } }); }); @@ -46,74 +47,73 @@ function mergeHistograms(histograms: DefinedTypeHistogram[]): DefinedTypeHistogr return result; } +/** + * Count the uses of every defined type, keyed by + * `programIdentifier.typeIdentifier` (or `typeIdentifier` outside of a + * program). + * + * Every `definedTypeLinkNode` counts towards `total`, including those in + * default values, PDA seeds or constants. The `in*` counters only track + * links inside an account's data, a defined type's type, an event's data or + * an instruction's data respectively. + */ export function getDefinedTypeHistogramVisitor(): Visitor { const stack = new NodeStack(); - let mode: 'account' | 'definedType' | 'event' | 'instruction' | null = null; - let stackLevel = 0; return pipe( mergeVisitor( () => ({}) as DefinedTypeHistogram, (_, histograms) => mergeHistograms(histograms), ), - v => - interceptVisitor(v, (node, next) => { - stackLevel += 1; - const newNode = next(node); - stackLevel -= 1; - return newNode; - }), v => extendVisitor(v, { - visitAccount(node, { self }) { - mode = 'account'; - stackLevel = 0; - const histogram = visit(node.data, self); - mode = null; - return histogram; - }, - - visitDefinedType(node, { self }) { - mode = 'definedType'; - stackLevel = 0; - const histogram = visit(node.type, self); - mode = null; - return histogram; - }, - visitDefinedTypeLink(node) { - const program = findProgramNodeFromPath(stack.getPath()); + const path = stack.getPath(); + const program = node.program ?? findProgramNodeFromPath(path); const key = program ? `${program.identifier}.${node.identifier}` : node.identifier; + const { direct, mode } = getLinkUsage(path); return { [key]: { - directlyAsInstructionArgs: Number(mode === 'instruction' && stackLevel <= 1), + directlyAsInstructionData: Number(direct), inAccounts: Number(mode === 'account'), inDefinedTypes: Number(mode === 'definedType'), inEvents: Number(mode === 'event'), - inInstructionArgs: Number(mode === 'instruction'), + inInstructionData: Number(mode === 'instruction'), total: 1, }, }; }, - - visitEvent(node, { self }) { - mode = 'event'; - stackLevel = 0; - const histogram = visit(node.data, self); - mode = null; - return histogram; - }, - - visitInstruction(node, { self }) { - mode = 'instruction'; - stackLevel = 0; - const dataHistograms = (node.arguments ?? []).map(arg => visit(arg, self)); - const extraHistograms = (node.extraArguments ?? []).map(arg => visit(arg, self)); - mode = null; - const subHistograms = (node.subInstructions ?? []).map(ix => visit(ix, self)); - return mergeHistograms([...dataHistograms, ...extraHistograms, ...subHistograms]); - }, }), v => recordNodeStackVisitor(v, stack), ); } + +/** + * Locate a link, given its path, relative to its closest account, event, + * defined type or instruction: whether it sits under that node's data (or + * type) and, for instructions, whether it is used directly. + */ +function getLinkUsage(path: NodePath): LinkUsage { + const link = path[path.length - 1]; + for (let index = path.length - 2; index >= 0; index--) { + const owner = path[index]; + const child: Node | undefined = path[index + 1]; + if (isNode(owner, 'accountNode')) return { direct: false, mode: child === owner.data ? 'account' : null }; + if (isNode(owner, 'eventNode')) return { direct: false, mode: child === owner.data ? 'event' : null }; + if (isNode(owner, 'definedTypeNode')) { + return { direct: false, mode: child === owner.type ? 'definedType' : null }; + } + if (isNode(owner, 'instructionNode')) { + if (child !== owner.data) return { direct: false, mode: null }; + const isData = path.length === index + 2; + const field = path[index + 2]; + const isTopLevelFieldType = + isNode(child, 'structTypeNode') && + path.length === index + 4 && + isNode(field, 'structFieldTypeNode') && + field.type === link; + return { direct: isData || isTopLevelFieldType, mode: 'instruction' }; + } + } + return { direct: false, mode: null }; +} diff --git a/packages/visitors/src/index.ts b/packages/visitors/src/index.ts index 454a889c0..83abac29a 100644 --- a/packages/visitors/src/index.ts +++ b/packages/visitors/src/index.ts @@ -2,10 +2,10 @@ export * from '@codama/visitors-core'; export * from './addPdasVisitor'; export * from './checkCodamaVersionVisitor'; -export * from './createSubInstructionsFromEnumArgsVisitor'; +export * from './createSubInstructionsFromEnumDataVisitor'; export * from './deduplicateIdenticalDefinedTypesVisitor'; export * from './fillDefaultPdaSeedValuesVisitor'; -export * from './flattenInstructionDataArgumentsVisitor'; +export * from './flattenInstructionDataVisitor'; export * from './flattenStructVisitor'; export * from './getDefinedTypeHistogramVisitor'; export * from './setAccountDiscriminatorFromFieldVisitor'; @@ -17,7 +17,7 @@ export * from './setStructDefaultValuesVisitor'; export * from './transformDefinedTypesIntoAccountsVisitor'; export * from './transformU8ArraysToBytesVisitor'; export * from './unwrapDefinedTypesVisitor'; -export * from './unwrapInstructionArgsDefinedTypesVisitor'; +export * from './unwrapInstructionDataDefinedTypesVisitor'; export * from './unwrapTupleEnumWithSingleStructVisitor'; export * from './unwrapTypeDefinedLinksVisitor'; export * from './updateAccountsVisitor'; diff --git a/packages/visitors/src/inlineDefinedTypeHelpers.ts b/packages/visitors/src/inlineDefinedTypeHelpers.ts new file mode 100644 index 000000000..f7e60b8f8 --- /dev/null +++ b/packages/visitors/src/inlineDefinedTypeHelpers.ts @@ -0,0 +1,57 @@ +import { + addTypeNodeTransforms, + assertIsNode, + DefinedTypeLinkNode, + definedTypeLinkNode, + IdentifierString, + Node, + programLinkNode, + TypeNode, +} from '@codama/nodes'; +import { bottomUpTransformerVisitor, visit } from '@codama/visitors-core'; + +/** + * Give every `definedTypeLinkNode` inside `node` that has no explicit + * `program` the given program, so the links keep resolving to the same + * defined types once the subtree is moved into another program. + */ +export function qualifyDefinedTypeLinks(node: TNode, program: IdentifierString): TNode { + const result = visit( + node, + bottomUpTransformerVisitor([ + { + select: '[definedTypeLinkNode]', + transform: link => { + assertIsNode(link, 'definedTypeLinkNode'); + if (link.program) return link; + return definedTypeLinkNode(link.identifier, { ...link, program: programLinkNode(program) }); + }, + }, + ]), + ); + assertIsNode(result, node.kind); + return result as TNode; +} + +/** + * Return the type that replaces `link` when inlining the defined type it + * points to. + * + * The link's own `transforms` are layered on top of the type's, since they + * apply to the link's position. When the defined type lives in another + * program than the one the link is inlined into, links inside the type are + * qualified with the defined type's program so they keep resolving to the + * same nodes. + */ +export function inlineDefinedType( + link: DefinedTypeLinkNode, + type: TypeNode, + options: { definedTypeProgram: IdentifierString | undefined; linkProgram: IdentifierString | undefined }, +): TypeNode { + const { definedTypeProgram, linkProgram } = options; + const qualifiedType = + definedTypeProgram !== undefined && definedTypeProgram !== linkProgram + ? qualifyDefinedTypeLinks(type, definedTypeProgram) + : type; + return addTypeNodeTransforms(qualifiedType, link.transforms ?? []); +} diff --git a/packages/visitors/src/renameHelpers.ts b/packages/visitors/src/renameHelpers.ts index 783301a5b..87c6b7fc7 100644 --- a/packages/visitors/src/renameHelpers.ts +++ b/packages/visitors/src/renameHelpers.ts @@ -1,39 +1,41 @@ import { - enumEmptyVariantTypeNode, - enumStructVariantTypeNode, - enumTupleVariantTypeNode, EnumTypeNode, enumTypeNode, - EnumVariantTypeNode, - isNode, + enumVariantTypeNode, structFieldTypeNode, StructTypeNode, structTypeNode, } from '@codama/nodes'; +/** + * Rename the fields of a struct using a map from current to new + * identifiers. Identifiers are matched exactly; the struct's transforms and + * plugins are preserved. + */ export function renameStructNode(node: StructTypeNode, map: Record): StructTypeNode { + const renames = new Map(Object.entries(map)); return structTypeNode( - (node.fields ?? []).map(field => - map[field.identifier] ? structFieldTypeNode({ ...field, identifier: map[field.identifier] }) : field, - ), + (node.fields ?? []).map(field => { + const newIdentifier = renames.get(field.identifier); + return newIdentifier ? structFieldTypeNode({ ...field, identifier: newIdentifier }) : field; + }), + { ...node }, ); } +/** + * Rename the variants of an enum using a map from current to new + * identifiers. Identifiers are matched exactly; every other attribute of + * the enum and its variants (discriminators, data, transforms, etc.) is + * preserved. + */ export function renameEnumNode(node: EnumTypeNode, map: Record): EnumTypeNode { + const renames = new Map(Object.entries(map)); return enumTypeNode( - (node.variants ?? []).map(variant => - map[variant.identifier] ? renameEnumVariant(variant, map[variant.identifier]) : variant, - ), + (node.variants ?? []).map(variant => { + const newIdentifier = renames.get(variant.identifier); + return newIdentifier ? enumVariantTypeNode(newIdentifier, { ...variant }) : variant; + }), { ...node }, ); } - -function renameEnumVariant(variant: EnumVariantTypeNode, newName: string) { - if (isNode(variant, 'enumStructVariantTypeNode')) { - return enumStructVariantTypeNode(newName, variant.struct); - } - if (isNode(variant, 'enumTupleVariantTypeNode')) { - return enumTupleVariantTypeNode(newName, variant.tuple); - } - return enumEmptyVariantTypeNode(newName); -} diff --git a/packages/visitors/src/setAccountDiscriminatorFromFieldVisitor.ts b/packages/visitors/src/setAccountDiscriminatorFromFieldVisitor.ts index 9e02b47aa..5424eb38c 100644 --- a/packages/visitors/src/setAccountDiscriminatorFromFieldVisitor.ts +++ b/packages/visitors/src/setAccountDiscriminatorFromFieldVisitor.ts @@ -1,17 +1,31 @@ import { CODAMA_ERROR__VISITORS__ACCOUNT_FIELD_NOT_FOUND, CodamaError } from '@codama/errors'; -import { camelCase } from '@codama/fragments/casing'; import { accountNode, assertIsNode, fieldDiscriminatorNode, - resolveNestedTypeNode, + identifierString, structFieldTypeNode, structTypeNode, - transformNestedTypeNode, ValueNode, } from '@codama/nodes'; import { BottomUpNodeTransformerWithSelector, bottomUpTransformerVisitor } from '@codama/visitors-core'; +/** + * Use one of an account's data fields as its discriminator: the field's + * default value is set to `value` (with the `omitted` strategy) and a + * `fieldDiscriminatorNode` pointing to it is prepended to the account's + * discriminators. + * + * The account's data must be an inline struct: linked data would change a + * defined type that other nodes may share. + * + * @example + * ```ts + * setAccountDiscriminatorFromFieldVisitor({ + * mint: { field: 'accountType', offset: 0, value: integerValueNode('1') }, + * }); + * ``` + */ export function setAccountDiscriminatorFromFieldVisitor( map: Record, ) { @@ -20,14 +34,14 @@ export function setAccountDiscriminatorFromFieldVisitor( select: ['[accountNode]', selector], transform: node => { assertIsNode(node, 'accountNode'); + assertIsNode(node.data, 'structTypeNode'); - const accountData = resolveNestedTypeNode(node.data); - const accountFields = accountData.fields ?? []; + const accountFields = node.data.fields ?? []; const fieldIndex = accountFields.findIndex(f => f.identifier === field); if (fieldIndex < 0) { throw new CodamaError(CODAMA_ERROR__VISITORS__ACCOUNT_FIELD_NOT_FOUND, { account: node, - missingField: camelCase(field), + missingField: identifierString(field), name: node.identifier, }); } @@ -35,18 +49,15 @@ export function setAccountDiscriminatorFromFieldVisitor( const fieldNode = accountFields[fieldIndex]; return accountNode({ ...node, - data: transformNestedTypeNode(node.data, () => - structTypeNode([ + data: structTypeNode( + [ ...accountFields.slice(0, fieldIndex), - structFieldTypeNode({ - ...fieldNode, - defaultValue: value, - defaultValueStrategy: 'omitted', - }), + structFieldTypeNode({ ...fieldNode, defaultValue: value, defaultValueStrategy: 'omitted' }), ...accountFields.slice(fieldIndex + 1), - ]), + ], + { ...node.data }, ), - discriminators: [fieldDiscriminatorNode(field, offset), ...(node.discriminators ?? [])], + discriminators: [fieldDiscriminatorNode(field, { offset }), ...(node.discriminators ?? [])], }); }, })), diff --git a/packages/visitors/src/setStructDefaultValuesVisitor.ts b/packages/visitors/src/setStructDefaultValuesVisitor.ts index cbcb71cd3..c794c4f53 100644 --- a/packages/visitors/src/setStructDefaultValuesVisitor.ts +++ b/packages/visitors/src/setStructDefaultValuesVisitor.ts @@ -1,80 +1,56 @@ -import { camelCase } from '@codama/fragments/casing'; -import { - assertIsNode, - InstructionArgumentNode, - instructionArgumentNode, - instructionNode, - StructFieldTypeNode, - structFieldTypeNode, - structTypeNode, - ValueNode, -} from '@codama/nodes'; +import { assertIsNode, StructFieldTypeNode, structFieldTypeNode, structTypeNode, ValueNode } from '@codama/nodes'; import { BottomUpNodeTransformerWithSelector, bottomUpTransformerVisitor } from '@codama/visitors-core'; type StructDefaultValueMap = Record>; type StructDefaultValue = ValueNode | { strategy?: 'omitted' | 'optional'; value: ValueNode } | null; +/** + * Set, override or remove (`null`) the default values of struct fields. + * + * Keys of the outer map select the nodes whose structs are updated (e.g. a + * defined type, an account or an instruction, whose inline `data` struct is + * matched too). Keys of the inner map are field identifiers, matched + * exactly. + * + * Contextual defaults for instruction data (e.g. an account's bump) are + * expressed with an `injectedValueNode` and a matching entry in the + * instruction's `provides`. + * + * @example + * ```ts + * setStructDefaultValuesVisitor({ + * myAccount: { count: integerValueNode('0') }, + * transfer: { amount: { strategy: 'optional', value: integerValueNode('1') }, memo: null }, + * }); + * ``` + */ export function setStructDefaultValuesVisitor(map: StructDefaultValueMap) { return bottomUpTransformerVisitor( - Object.entries(map).flatMap(([stack, defaultValues]): BottomUpNodeTransformerWithSelector[] => { - const camelCasedDefaultValues = Object.fromEntries( - Object.entries(defaultValues).map(([key, value]) => [camelCase(key), value]), - ); - - return [ - { - select: `${stack}.[structTypeNode]`, - transform: node => { - assertIsNode(node, 'structTypeNode'); - const fields = (node.fields ?? []).map((field): StructFieldTypeNode => { - const defaultValue = camelCasedDefaultValues[field.identifier]; - if (defaultValue === undefined) return field; - if (defaultValue === null) { - return structFieldTypeNode({ - ...field, - defaultValue: undefined, - defaultValueStrategy: undefined, - }); - } + Object.entries(map).map(([stack, defaultValues]): BottomUpNodeTransformerWithSelector => { + const defaultValuesByField = new Map(Object.entries(defaultValues)); + return { + select: `${stack}.[structTypeNode]`, + transform: node => { + assertIsNode(node, 'structTypeNode'); + const fields = (node.fields ?? []).map((field): StructFieldTypeNode => { + const defaultValue = defaultValuesByField.get(field.identifier); + if (defaultValue === undefined) return field; + if (defaultValue === null) { return structFieldTypeNode({ ...field, - defaultValue: 'kind' in defaultValue ? defaultValue : defaultValue.value, - defaultValueStrategy: 'kind' in defaultValue ? undefined : defaultValue.strategy, - }); - }); - return structTypeNode(fields); - }, - }, - { - select: ['[instructionNode]', stack], - transform: node => { - assertIsNode(node, 'instructionNode'); - const transformArguments = (arg: InstructionArgumentNode): InstructionArgumentNode => { - const defaultValue = camelCasedDefaultValues[arg.identifier]; - if (defaultValue === undefined) return arg; - if (defaultValue === null) { - return instructionArgumentNode({ - ...arg, - defaultValue: undefined, - defaultValueStrategy: undefined, - }); - } - return instructionArgumentNode({ - ...arg, - defaultValue: 'kind' in defaultValue ? defaultValue : defaultValue.value, - defaultValueStrategy: 'kind' in defaultValue ? undefined : defaultValue.strategy, + defaultValue: undefined, + defaultValueStrategy: undefined, }); - }; - return instructionNode({ - ...node, - arguments: (node.arguments ?? []).map(transformArguments), - extraArguments: node.extraArguments - ? node.extraArguments.map(transformArguments) - : undefined, + } + return structFieldTypeNode({ + ...field, + defaultValue: 'kind' in defaultValue ? defaultValue : defaultValue.value, + defaultValueStrategy: 'kind' in defaultValue ? undefined : defaultValue.strategy, }); - }, + }); + return structTypeNode(fields, { ...node }); }, - ]; + }; }), ); } diff --git a/packages/visitors/src/transformDefinedTypesIntoAccountsVisitor.ts b/packages/visitors/src/transformDefinedTypesIntoAccountsVisitor.ts index 1b31e081c..85f833143 100644 --- a/packages/visitors/src/transformDefinedTypesIntoAccountsVisitor.ts +++ b/packages/visitors/src/transformDefinedTypesIntoAccountsVisitor.ts @@ -1,6 +1,11 @@ -import { accountNode, assertIsNode, programNode } from '@codama/nodes'; +import { accountNode, programNode } from '@codama/nodes'; import { extendVisitor, nonNullableIdentityVisitor, pipe } from '@codama/visitors-core'; +/** + * Move the given defined types (matched exactly by identifier) out of + * their programs' `definedTypes` and into new accounts whose data is the + * defined type's type. + */ export function transformDefinedTypesIntoAccountsVisitor(definedTypes: string[]) { return pipe(nonNullableIdentityVisitor({ keys: ['rootNode', 'programNode'] }), v => extendVisitor(v, { @@ -13,15 +18,14 @@ export function transformDefinedTypesIntoAccountsVisitor(definedTypes: string[]) node => !definedTypes.includes(node.identifier), ); - const newAccounts = typesToExtract.map(node => { - assertIsNode(node.type, 'structTypeNode'); - return accountNode({ - ...node, + const newAccounts = typesToExtract.map(node => + accountNode({ data: node.type, - discriminators: [], - size: undefined, - }); - }); + docs: node.docs, + identifier: node.identifier, + plugins: node.plugins, + }), + ); return programNode({ ...program, diff --git a/packages/visitors/src/transformU8ArraysToBytesVisitor.ts b/packages/visitors/src/transformU8ArraysToBytesVisitor.ts index 280d707df..9570e38c1 100644 --- a/packages/visitors/src/transformU8ArraysToBytesVisitor.ts +++ b/packages/visitors/src/transformU8ArraysToBytesVisitor.ts @@ -1,37 +1,48 @@ -import { - ArrayTypeNode, - arrayTypeNode, - assertIsNode, - bytesTypeNode, - fixedSizeTypeNode, - isNode, - TYPE_NODES, -} from '@codama/nodes'; -import { extendVisitor, nonNullableIdentityVisitor, pipe, visit } from '@codama/visitors-core'; +import { assertIsNode, bytesTypeNode, fixedSizeTransformNode, isNode, TypeNode } from '@codama/nodes'; +import { extendVisitor, nonNullableIdentityVisitor, pipe } from '@codama/visitors-core'; +/** + * Turn fixed-size arrays of `u8` into fixed-size bytes, optionally only for + * the given sizes. + * + * Only plain `u8` items are converted: an item carrying a `unit`, `display`, + * `transforms` or `plugins` has semantics that bytes cannot express. The + * array's own `transforms` and `plugins` are carried over, outside the + * fixed size. + * + * @example + * ```ts + * // arrayTypeNode(integerTypeNode('u8'), fixedCountNode(32)) + * // becomes bytesTypeNode({ transforms: [fixedSizeTransformNode(32)] }) + * transformU8ArraysToBytesVisitor([32]); + * ``` + */ export function transformU8ArraysToBytesVisitor(sizes: number[] | '*' = '*') { - const hasRequiredSize = (count: ArrayTypeNode['count']): boolean => { - if (!isNode(count, 'fixedCountNode')) return false; - return sizes === '*' || sizes.includes(count.value); - }; - return pipe(nonNullableIdentityVisitor(), v => extendVisitor(v, { - visitArrayType(node, { self }) { - const child = visit(node.item, self); - assertIsNode(child, TYPE_NODES); - - if ( - isNode(child, 'numberTypeNode') && - child.format === 'u8' && - isNode(node.count, 'fixedCountNode') && - hasRequiredSize(node.count) - ) { - return fixedSizeTypeNode(bytesTypeNode(), node.count.value); - } + visitArrayType(node, { next }) { + const array = next(node); + assertIsNode(array, 'arrayTypeNode'); + if (!isPlainU8(array.item) || !isNode(array.count, 'fixedCountNode')) return array; + const size = array.count.value; + if (sizes !== '*' && !sizes.includes(size)) return array; - return arrayTypeNode(child, node.count); + return bytesTypeNode({ + plugins: array.plugins, + transforms: [fixedSizeTransformNode(size), ...(array.transforms ?? [])], + }); }, }), ); } + +function isPlainU8(node: TypeNode): boolean { + return ( + isNode(node, 'integerTypeNode') && + node.format === 'u8' && + node.unit === undefined && + node.display === undefined && + (node.transforms ?? []).length === 0 && + (node.plugins ?? []).length === 0 + ); +} diff --git a/packages/visitors/src/unwrapDefinedTypesVisitor.ts b/packages/visitors/src/unwrapDefinedTypesVisitor.ts index 90589d322..1faea68a8 100644 --- a/packages/visitors/src/unwrapDefinedTypesVisitor.ts +++ b/packages/visitors/src/unwrapDefinedTypesVisitor.ts @@ -1,5 +1,4 @@ -import { camelCase } from '@codama/fragments/casing'; -import { assertIsNodeFilter, IdentifierString, programNode } from '@codama/nodes'; +import { assertIsNode, IdentifierString, programNode, TYPE_NODES } from '@codama/nodes'; import { extendVisitor, findProgramNodeFromPath, @@ -13,55 +12,63 @@ import { visit, } from '@codama/visitors-core'; +import { inlineDefinedType } from './inlineDefinedTypeHelpers'; + +/** + * Replace links to the given defined types with the types themselves and + * remove the inlined defined types from their programs. + * + * Types are identified by `typeIdentifier` (in any program) or + * `programIdentifier.typeIdentifier`, matched exactly. Use `'*'` to inline + * every defined type. + * + * 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. + */ export function unwrapDefinedTypesVisitor(typesToInline: string[] | '*' = '*') { const linkables = new LinkableDictionary(); const stack = new NodeStack(); - const typesToInlineCamelCased = (typesToInline === '*' ? [] : typesToInline).map(fullPath => { - if (!fullPath.includes('.')) return camelCase(fullPath); - const [programName, typeName] = fullPath.split('.'); - return `${camelCase(programName)}.${camelCase(typeName)}`; - }); const shouldInline = (typeName: IdentifierString, programName: IdentifierString | undefined): boolean => { if (typesToInline === '*') return true; - const fullPath = `${programName}.${typeName}`; - if (!!programName && typesToInlineCamelCased.includes(fullPath)) return true; - return typesToInlineCamelCased.includes(typeName); + if (!!programName && typesToInline.includes(`${programName}.${typeName}`)) return true; + return typesToInline.includes(typeName); }; return pipe( nonNullableIdentityVisitor(), v => extendVisitor(v, { - visitDefinedTypeLink(linkType, { self }) { - const programName = - linkType.program?.identifier ?? findProgramNodeFromPath(stack.getPath())?.identifier; - if (!shouldInline(linkType.identifier, programName)) { - return linkType; + visitDefinedTypeLink(link, { self }) { + const linkProgram = findProgramNodeFromPath(stack.getPath())?.identifier; + const definedTypeProgram = link.program?.identifier ?? linkProgram; + if (!shouldInline(link.identifier, definedTypeProgram)) { + return link; } const definedTypePath = linkables.getPathOrThrow(stack.getPath('definedTypeLinkNode')); const definedType = getLastNodeFromPath(definedTypePath); stack.pushPath(definedTypePath); - const result = visit(definedType.type, self); + const type = visit(definedType.type, self); stack.popPath(); - return result; - }, + assertIsNode(type, TYPE_NODES); - visitProgram(program, { self }) { - return programNode({ - ...program, - accounts: (program.accounts ?? []) - .map(account => visit(account, self)) - .filter(assertIsNodeFilter('accountNode')), - definedTypes: (program.definedTypes ?? []) - .filter(definedType => !shouldInline(definedType.identifier, program.identifier)) - .map(type => visit(type, self)) - .filter(assertIsNodeFilter('definedTypeNode')), - instructions: (program.instructions ?? []) - .map(instruction => visit(instruction, self)) - .filter(assertIsNodeFilter('instructionNode')), + return inlineDefinedType(link, type, { + definedTypeProgram: findProgramNodeFromPath(definedTypePath)?.identifier, + linkProgram, }); }, + + visitProgram(program, { next }) { + return next( + programNode({ + ...program, + definedTypes: (program.definedTypes ?? []).filter( + definedType => !shouldInline(definedType.identifier, program.identifier), + ), + }), + ); + }, }), v => recordNodeStackVisitor(v, stack), v => recordLinkablesOnFirstVisitVisitor(v, linkables), diff --git a/packages/visitors/src/unwrapInstructionArgsDefinedTypesVisitor.ts b/packages/visitors/src/unwrapInstructionDataDefinedTypesVisitor.ts similarity index 50% rename from packages/visitors/src/unwrapInstructionArgsDefinedTypesVisitor.ts rename to packages/visitors/src/unwrapInstructionDataDefinedTypesVisitor.ts index 3a07c7591..d6ef8dee1 100644 --- a/packages/visitors/src/unwrapInstructionArgsDefinedTypesVisitor.ts +++ b/packages/visitors/src/unwrapInstructionDataDefinedTypesVisitor.ts @@ -1,30 +1,44 @@ -import { assertIsNode, definedTypeLinkNode, IdentifierString, isNode } from '@codama/nodes'; +import { + assertIsNode, + DefinedTypeLinkNode, + definedTypeLinkNode, + IdentifierString, + isNode, + programLinkNode, +} from '@codama/nodes'; import { getRecordLinkablesVisitor, LinkableDictionary, rootNodeVisitor, visit } from '@codama/visitors-core'; import { getDefinedTypeHistogramVisitor } from './getDefinedTypeHistogramVisitor'; import { unwrapDefinedTypesVisitor } from './unwrapDefinedTypesVisitor'; -export function unwrapInstructionArgsDefinedTypesVisitor() { +/** + * Inline the defined types that are used exactly once in the whole IDL, + * either as an instruction's `data` or as the type of one of its top-level + * data fields. Enums are kept as defined types. + */ +export function unwrapInstructionDataDefinedTypesVisitor() { return rootNodeVisitor(root => { const histogram = visit(root, getDefinedTypeHistogramVisitor()); const linkables = new LinkableDictionary(); visit(root, getRecordLinkablesVisitor(linkables)); const definedTypesToInline = (Object.keys(histogram) as IdentifierString[]) - // Get all defined types used exactly once as an instruction argument. - .filter(key => (histogram[key].total ?? 0) === 1 && (histogram[key].directlyAsInstructionArgs ?? 0) === 1) + // Get all defined types used exactly once, directly as instruction data. + .filter(key => histogram[key].total === 1 && histogram[key].directlyAsInstructionData === 1) // Filter out enums which are better defined as external types. .filter(key => { - const names = key.split('.'); - const link = names.length == 2 ? definedTypeLinkNode(names[1], names[0]) : definedTypeLinkNode(key); + const [programName, typeName] = key.split('.'); + const link: DefinedTypeLinkNode = + typeName === undefined + ? definedTypeLinkNode(programName) + : definedTypeLinkNode(typeName, { program: programLinkNode(programName) }); const found = linkables.get([link]); return found && !isNode(found.type, 'enumTypeNode'); }); // Inline the identified defined types if any. if (definedTypesToInline.length > 0) { - const inlineVisitor = unwrapDefinedTypesVisitor(definedTypesToInline); - const newRoot = visit(root, inlineVisitor); + const newRoot = visit(root, unwrapDefinedTypesVisitor(definedTypesToInline)); assertIsNode(newRoot, 'rootNode'); return newRoot; } diff --git a/packages/visitors/src/unwrapTupleEnumWithSingleStructVisitor.ts b/packages/visitors/src/unwrapTupleEnumWithSingleStructVisitor.ts index b15f63ffe..394e4761a 100644 --- a/packages/visitors/src/unwrapTupleEnumWithSingleStructVisitor.ts +++ b/packages/visitors/src/unwrapTupleEnumWithSingleStructVisitor.ts @@ -1,18 +1,18 @@ import { + addTypeNodeTransforms, assertIsNode, - DefinedTypeNode, - enumStructVariantTypeNode, - getAllDefinedTypes, - IdentifierString, + enumVariantTypeNode, isNode, REGISTERED_NODE_KINDS, - resolveNestedTypeNode, StructTypeNode, - transformNestedTypeNode, } from '@codama/nodes'; import { bottomUpTransformerVisitor, + findProgramNodeFromPath, + getLastNodeFromPath, getNodeSelectorFunction, + getRecordLinkablesVisitor, + LinkableDictionary, NodeSelectorFunction, NodeStack, rootNodeVisitor, @@ -20,8 +20,23 @@ import { } from '@codama/visitors-core'; import { getDefinedTypeHistogramVisitor } from './getDefinedTypeHistogramVisitor'; +import { inlineDefinedType } from './inlineDefinedTypeHelpers'; import { unwrapDefinedTypesVisitor } from './unwrapDefinedTypesVisitor'; +/** + * Turn enum variants whose data is a tuple with a single struct item (or a + * link to a struct) into variants whose data is that struct. + * + * The tuple's `transforms` are layered on top of the struct. Defined types + * inlined this way are removed when nothing else uses them. + * + * @example + * ```ts + * // Before: enumVariantTypeNode('move', { data: tupleTypeNode([structTypeNode([...])]) }) + * // After: enumVariantTypeNode('move', { data: structTypeNode([...]) }) + * unwrapTupleEnumWithSingleStructVisitor(['myEnum.move']); + * ``` + */ export function unwrapTupleEnumWithSingleStructVisitor(enumsOrVariantsToUnwrap: string[] | '*' = '*') { const selectorFunctions: NodeSelectorFunction[] = enumsOrVariantsToUnwrap === '*' @@ -32,47 +47,61 @@ export function unwrapTupleEnumWithSingleStructVisitor(enumsOrVariantsToUnwrap: selectorFunctions.some(selector => selector(stack.getPath(REGISTERED_NODE_KINDS))); return rootNodeVisitor(root => { - const typesToPotentiallyUnwrap: string[] = []; - const definedTypes: Map = new Map( - getAllDefinedTypes(root).map(definedType => [definedType.identifier, definedType]), - ); + const linkables = new LinkableDictionary(); + visit(root, getRecordLinkablesVisitor(linkables)); + const typesToPotentiallyUnwrap = new Set(); let newRoot = visit( root, bottomUpTransformerVisitor([ { - select: '[enumTupleVariantTypeNode]', + select: '[enumVariantTypeNode]', transform: (node, stack) => { - assertIsNode(node, 'enumTupleVariantTypeNode'); + assertIsNode(node, 'enumVariantTypeNode'); if (!shouldUnwrap(stack)) return node; - const tupleNode = resolveNestedTypeNode(node.tuple); - const tupleItems = tupleNode.items ?? []; + const tuple = node.data; + if (!tuple || !isNode(tuple, 'tupleTypeNode')) return node; + const tupleItems = tuple.items ?? []; if (tupleItems.length !== 1) return node; - let item = tupleItems[0]; + const item = tupleItems[0]; + + let struct: StructTypeNode; if (isNode(item, 'definedTypeLinkNode')) { - const definedType = definedTypes.get(item.identifier); - if (!definedType) return node; + const definedTypePath = linkables.getPath([...stack.getPath(), item]); + if (!definedTypePath) return node; + const definedType = getLastNodeFromPath(definedTypePath); if (!isNode(definedType.type, 'structTypeNode')) return node; - typesToPotentiallyUnwrap.push(item.identifier); - item = definedType.type; + const definedTypeProgram = findProgramNodeFromPath(definedTypePath)?.identifier; + typesToPotentiallyUnwrap.add(`${definedTypeProgram}.${definedType.identifier}`); + struct = inlineDefinedType(item, definedType.type, { + definedTypeProgram, + linkProgram: findProgramNodeFromPath(stack.getPath())?.identifier, + }) as StructTypeNode; + } else if (isNode(item, 'structTypeNode')) { + struct = item; + } else { + return node; } - if (!isNode(item, 'structTypeNode')) return node; - const nestedStruct = transformNestedTypeNode(node.tuple, () => item as StructTypeNode); - return enumStructVariantTypeNode(node.identifier, nestedStruct); + + return enumVariantTypeNode(node.identifier, { + ...node, + data: addTypeNodeTransforms(struct, tuple.transforms ?? []), + }); }, }, ]), ); assertIsNode(newRoot, 'rootNode'); + // Remove the inlined defined types that are no longer used. const histogram = visit(newRoot, getDefinedTypeHistogramVisitor()); - const typesToUnwrap = typesToPotentiallyUnwrap.filter( - type => !histogram[type as IdentifierString] || histogram[type as IdentifierString].total === 0, + const typesToUnwrap = [...typesToPotentiallyUnwrap].filter( + key => !(key in histogram) || histogram[key as keyof typeof histogram].total === 0, ); + if (typesToUnwrap.length === 0) return newRoot; newRoot = visit(newRoot, unwrapDefinedTypesVisitor(typesToUnwrap)); assertIsNode(newRoot, 'rootNode'); - return newRoot; }); } diff --git a/packages/visitors/src/unwrapTypeDefinedLinksVisitor.ts b/packages/visitors/src/unwrapTypeDefinedLinksVisitor.ts index 61c354ae0..6bee9ed3a 100644 --- a/packages/visitors/src/unwrapTypeDefinedLinksVisitor.ts +++ b/packages/visitors/src/unwrapTypeDefinedLinksVisitor.ts @@ -1,19 +1,36 @@ +import { assertIsNode } from '@codama/nodes'; import { BottomUpNodeTransformerWithSelector, bottomUpTransformerVisitor, + findProgramNodeFromPath, + getLastNodeFromPath, LinkableDictionary, pipe, recordLinkablesOnFirstVisitVisitor, } from '@codama/visitors-core'; +import { inlineDefinedType } from './inlineDefinedTypeHelpers'; + +/** + * Replace the `definedTypeLinkNode`s matching the given selectors with the + * types they point to, keeping the defined types themselves. + * + * 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. + */ export function unwrapTypeDefinedLinksVisitor(definedLinksType: string[]) { const linkables = new LinkableDictionary(); const transformers: BottomUpNodeTransformerWithSelector[] = definedLinksType.map(selector => ({ select: ['[definedTypeLinkNode]', selector], - transform: (_, stack) => { - const definedType = linkables.getOrThrow(stack.getPath('definedTypeLinkNode')); - return definedType.type; + transform: (link, stack) => { + assertIsNode(link, 'definedTypeLinkNode'); + const definedTypePath = linkables.getPathOrThrow(stack.getPath('definedTypeLinkNode')); + return inlineDefinedType(link, getLastNodeFromPath(definedTypePath).type, { + definedTypeProgram: findProgramNodeFromPath(definedTypePath)?.identifier, + linkProgram: findProgramNodeFromPath(stack.getPath())?.identifier, + }); }, })); diff --git a/packages/visitors/test/addPdasVisitor.test.ts b/packages/visitors/test/addPdasVisitor.test.ts index f0a500945..30ce9194e 100644 --- a/packages/visitors/test/addPdasVisitor.test.ts +++ b/packages/visitors/test/addPdasVisitor.test.ts @@ -1,9 +1,10 @@ import { CODAMA_ERROR__VISITORS__CANNOT_ADD_DUPLICATED_PDA_NAMES, CodamaError } from '@codama/errors'; -import { camelCase } from '@codama/fragments/casing'; import { constantPdaSeedNodeFromProgramId, constantPdaSeedNodeFromString, + identifierString, pdaNode, + pluginNode, programNode, publicKeyTypeNode, variablePdaSeedNode, @@ -16,10 +17,10 @@ import { addPdasVisitor } from '../src'; test('it adds PDA nodes to a program', () => { // Given a program with a single PDA. const node = programNode({ - name: 'myProgram', + identifier: 'myProgram', pdas: [ pdaNode({ - name: 'associatedToken', + identifier: 'associatedToken', seeds: [ variablePdaSeedNode('owner', publicKeyTypeNode()), constantPdaSeedNodeFromProgramId(), @@ -33,7 +34,7 @@ test('it adds PDA nodes to a program', () => { // When we add two more PDAs. const newPdas = [ pdaNode({ - name: 'metadata', + identifier: 'metadata', seeds: [ constantPdaSeedNodeFromString('utf8', 'metadata'), constantPdaSeedNodeFromProgramId(), @@ -41,7 +42,7 @@ test('it adds PDA nodes to a program', () => { ], }), pdaNode({ - name: 'masterEdition', + identifier: 'masterEdition', seeds: [ constantPdaSeedNodeFromString('utf8', 'metadata'), constantPdaSeedNodeFromProgramId(), @@ -59,10 +60,10 @@ test('it adds PDA nodes to a program', () => { test('it fails to add a PDA if its name conflicts with an existing PDA on the program', () => { // Given a program with a PDA named "myPda". const node = programNode({ - name: 'myProgram', + identifier: 'myProgram', pdas: [ pdaNode({ - name: 'myPda', + identifier: 'myPda', seeds: [ variablePdaSeedNode('owner', publicKeyTypeNode()), constantPdaSeedNodeFromProgramId(), @@ -80,7 +81,7 @@ test('it fails to add a PDA if its name conflicts with an existing PDA on the pr addPdasVisitor({ myProgram: [ pdaNode({ - name: 'myPda', + identifier: 'myPda', seeds: [ constantPdaSeedNodeFromString('utf8', 'metadata'), constantPdaSeedNodeFromProgramId(), @@ -94,9 +95,9 @@ test('it fails to add a PDA if its name conflicts with an existing PDA on the pr // Then we expect the following error to be thrown. expect(fn).toThrow( new CodamaError(CODAMA_ERROR__VISITORS__CANNOT_ADD_DUPLICATED_PDA_NAMES, { - duplicatedPdaNames: [camelCase('myPda')], + duplicatedPdaNames: [identifierString('myPda')], program: node, - programName: camelCase('myProgram'), + programName: identifierString('myProgram'), }), ); }); @@ -104,10 +105,10 @@ test('it fails to add a PDA if its name conflicts with an existing PDA on the pr test('it adds PDA nodes to a program with docs', () => { // Given a program with a single PDA. const node = programNode({ - name: 'myProgram', + identifier: 'myProgram', pdas: [ pdaNode({ - name: 'associatedToken', + identifier: 'associatedToken', seeds: [ variablePdaSeedNode('owner', publicKeyTypeNode()), constantPdaSeedNodeFromProgramId(), @@ -122,7 +123,7 @@ test('it adds PDA nodes to a program with docs', () => { const newPdas = [ pdaNode({ docs: 'Metadata for a token.', - name: 'metadata', + identifier: 'metadata', seeds: [ constantPdaSeedNodeFromString('utf8', 'metadata'), constantPdaSeedNodeFromProgramId(), @@ -131,7 +132,7 @@ test('it adds PDA nodes to a program with docs', () => { }), pdaNode({ docs: 'The master edition.', - name: 'masterEdition', + identifier: 'masterEdition', seeds: [ constantPdaSeedNodeFromString('utf8', 'metadata'), constantPdaSeedNodeFromProgramId(), @@ -145,3 +146,59 @@ test('it adds PDA nodes to a program with docs', () => { // Then we expect the following program to be returned. expect(result).toEqual({ ...node, pdas: [...(node.pdas ?? []), ...newPdas] }); }); + +test('it fails to add a PDA whose identifier collides in camelCase with an existing PDA', () => { + // Given a program with a PDA named "my_pda". + const node = programNode({ + identifier: 'myProgram', + pdas: [pdaNode({ identifier: 'my_pda', seeds: [constantPdaSeedNodeFromProgramId()] })], + publicKey: 'Epo9rxh99jpeeWabRZi4tpgUVxZQeVn9vbbDjUztJtu4', + }); + + // When we try to add a PDA named "myPda". + const fn = () => + visit( + node, + addPdasVisitor({ myProgram: [{ identifier: 'myPda', seeds: [constantPdaSeedNodeFromProgramId()] }] }), + ); + + // Then we expect a duplicated PDA error. + expect(fn).toThrow( + new CodamaError(CODAMA_ERROR__VISITORS__CANNOT_ADD_DUPLICATED_PDA_NAMES, { + duplicatedPdaNames: [identifierString('myPda')], + program: node, + programName: identifierString('myProgram'), + }), + ); +}); + +test('it matches program identifiers exactly', () => { + // Given a program with a snake_case identifier. + const node = programNode({ identifier: 'my_program', publicKey: 'Epo9rxh99jpeeWabRZi4tpgUVxZQeVn9vbbDjUztJtu4' }); + const newPda = pdaNode({ identifier: 'myPda', seeds: [constantPdaSeedNodeFromProgramId()] }); + + // When we add a PDA using a different casing, then nothing changes. + expect(visit(node, addPdasVisitor({ myProgram: [newPda] }))).toStrictEqual(node); + + // When we add a PDA using the exact identifier, then it is added. + expect(visit(node, addPdasVisitor({ my_program: [newPda] }))).toStrictEqual( + programNode({ ...node, pdas: [newPda] }), + ); +}); + +test('it keeps the program ID and plugins of the new PDAs', () => { + // Given a program and a PDA derived from another program, carrying plugins. + const node = programNode({ identifier: 'myProgram', publicKey: 'Epo9rxh99jpeeWabRZi4tpgUVxZQeVn9vbbDjUztJtu4' }); + const newPda = pdaNode({ + identifier: 'associatedToken', + plugins: [pluginNode('my.plugin', { answer: 42 })], + programId: 'ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL', + seeds: [variablePdaSeedNode('owner', publicKeyTypeNode())], + }); + + // When we add the PDA. + const result = visit(node, addPdasVisitor({ myProgram: [newPda] })); + + // Then it is added as is. + expect(result).toStrictEqual(programNode({ ...node, pdas: [newPda] })); +}); diff --git a/packages/visitors/test/checkCodamaVersionVisitor.test.ts b/packages/visitors/test/checkCodamaVersionVisitor.test.ts index 851b750d9..4da252346 100644 --- a/packages/visitors/test/checkCodamaVersionVisitor.test.ts +++ b/packages/visitors/test/checkCodamaVersionVisitor.test.ts @@ -7,9 +7,9 @@ import { checkCodamaVersionVisitor } from '../src'; test('it accepts IDLs sharing the spec major and returns them unchanged', () => { // Given IDLs whose versions share the spec major. - const program = programNode({ name: 'myProgram', publicKey: '1111' }); + const program = programNode({ identifier: 'myProgram', publicKey: '1111' }); const latest = rootNode(program); - const oldest = { ...rootNode(program), version: '1.0.0' as CodamaVersion }; + const oldest = { ...rootNode(program), version: '2.0.0' as CodamaVersion }; // When we visit them with the check visitor, then they are returned unchanged. expect(visit(latest, checkCodamaVersionVisitor())).toBe(latest); @@ -18,22 +18,22 @@ test('it accepts IDLs sharing the spec major and returns them unchanged', () => test('it rejects IDLs from another spec major', () => { // Given IDLs whose versions belong to other spec majors. - const program = programNode({ name: 'myProgram', publicKey: '1111' }); + const program = programNode({ identifier: 'myProgram', publicKey: '1111' }); const newer = { ...rootNode(program), version: '99.0.0' as CodamaVersion }; - const older = { ...rootNode(program), version: '0.21.3' as CodamaVersion }; + const older = { ...rootNode(program), version: '1.3.0' as CodamaVersion }; // When we visit them with the check visitor, then we expect version mismatch errors. expect(() => visit(newer, checkCodamaVersionVisitor())).toThrow( new CodamaError(CODAMA_ERROR__VERSION_MISMATCH, { codamaVersion: CODAMA_VERSION, rootVersion: '99.0.0' }), ); expect(() => visit(older, checkCodamaVersionVisitor())).toThrow( - new CodamaError(CODAMA_ERROR__VERSION_MISMATCH, { codamaVersion: CODAMA_VERSION, rootVersion: '0.21.3' }), + new CodamaError(CODAMA_ERROR__VERSION_MISMATCH, { codamaVersion: CODAMA_VERSION, rootVersion: '1.3.0' }), ); }); test('it rejects IDLs with unparsable versions', () => { // Given an IDL whose version cannot be parsed. - const program = programNode({ name: 'myProgram', publicKey: '1111' }); + const program = programNode({ identifier: 'myProgram', publicKey: '1111' }); const root = { ...rootNode(program), version: 'not-a-version' as CodamaVersion }; // When we visit it with the check visitor, then we expect a version mismatch error. diff --git a/packages/visitors/test/createSubInstructionsFromEnumDataVisitor.test.ts b/packages/visitors/test/createSubInstructionsFromEnumDataVisitor.test.ts new file mode 100644 index 000000000..3e8a87db3 --- /dev/null +++ b/packages/visitors/test/createSubInstructionsFromEnumDataVisitor.test.ts @@ -0,0 +1,218 @@ +import { + CODAMA_ERROR__LINKED_NODE_NOT_FOUND, + CODAMA_ERROR__VISITORS__INSTRUCTION_ENUM_DATA_FIELD_NOT_FOUND, + CodamaError, + isCodamaError, +} from '@codama/errors'; +import { + assertIsNode, + definedTypeLinkNode, + definedTypeNode, + enumTypeNode, + enumVariantTypeNode, + fixedSizeTransformNode, + identifierString, + instructionNode, + IntegerTypeNode, + integerTypeNode, + integerValueNode, + pluginNode, + programNode, + structFieldTypeNode, + structTypeNode, +} from '@codama/nodes'; +import { visit } from '@codama/visitors-core'; +import { expect, test } from 'vitest'; + +import { createSubInstructionsFromEnumDataVisitor } from '../src'; + +const u8Field = (identifier: string) => structFieldTypeNode({ identifier, type: integerTypeNode('u8') }); +const discriminatorField = (identifier: string, value: string, size: IntegerTypeNode = integerTypeNode('u8')) => + structFieldTypeNode({ + defaultValue: integerValueNode(value), + defaultValueStrategy: 'omitted', + identifier, + type: size, + }); + +const actionEnum = enumTypeNode( + [ + enumVariantTypeNode('stop'), + enumVariantTypeNode('move', { data: structTypeNode([u8Field('x'), u8Field('y')]) }), + enumVariantTypeNode('wait', { data: integerTypeNode('u64'), discriminator: 5 }), + ], + { size: integerTypeNode('u16') }, +); + +const program = (instruction: ReturnType) => + programNode({ + definedTypes: [definedTypeNode({ identifier: 'action', type: actionEnum })], + identifier: 'myProgram', + instructions: [instruction], + publicKey: '1111', + }); + +test('it creates a sub-instruction per enum variant', () => { + // Given an instruction whose data contains a linked enum between two fields. + const node = program( + instructionNode({ + data: structTypeNode([ + u8Field('before'), + structFieldTypeNode({ identifier: 'action', type: definedTypeLinkNode('action') }), + u8Field('after'), + ]), + identifier: 'act', + }), + ); + + // When we create sub-instructions from the enum field. + const result = visit(node, createSubInstructionsFromEnumDataVisitor({ act: 'action' })); + + // Then we get one sub-instruction per variant, discriminated using the enum's size. + assertIsNode(result, 'programNode'); + const subInstructions = result.instructions?.[0].subInstructions ?? []; + expect(subInstructions.map(ix => ix.identifier)).toStrictEqual(['act_stop', 'act_move', 'act_wait']); + expect(subInstructions.map(ix => ix.data)).toStrictEqual([ + structTypeNode([ + u8Field('before'), + discriminatorField('act_stop_discriminator', '0', integerTypeNode('u16')), + u8Field('after'), + ]), + structTypeNode([ + u8Field('before'), + discriminatorField('act_move_discriminator', '1', integerTypeNode('u16')), + u8Field('x'), + u8Field('y'), + u8Field('after'), + ]), + structTypeNode([ + u8Field('before'), + discriminatorField('act_wait_discriminator', '5', integerTypeNode('u16')), + structFieldTypeNode({ identifier: 'action', type: integerTypeNode('u64') }), + u8Field('after'), + ]), + ]); +}); + +test('it keeps the discriminator and payload grouped when the enum has transforms', () => { + // Given an instruction whose enum field is fixed-size. + const node = instructionNode({ + data: structTypeNode([ + structFieldTypeNode({ + identifier: 'action', + type: enumTypeNode([enumVariantTypeNode('move', { data: integerTypeNode('u8') })], { + size: integerTypeNode('u8'), + transforms: [fixedSizeTransformNode(4)], + }), + }), + ]), + identifier: 'act', + }); + + // When we create sub-instructions from the enum field. + const result = visit(node, createSubInstructionsFromEnumDataVisitor({ act: 'action' })); + + // Then the sub-instruction keeps them in a fixed-size struct. + assertIsNode(result, 'instructionNode'); + expect(result.subInstructions?.[0].data).toStrictEqual( + structTypeNode([ + structFieldTypeNode({ + identifier: 'action', + type: structTypeNode( + [ + discriminatorField('act_move_discriminator', '0'), + structFieldTypeNode({ identifier: 'action', type: integerTypeNode('u8') }), + ], + { transforms: [fixedSizeTransformNode(4)] }, + ), + }), + ]), + ); +}); + +test('it does not copy existing sub-instructions into the new ones', () => { + // Given an instruction that already has a sub-instruction. + const existing = instructionNode({ identifier: 'existing' }); + const node = program( + instructionNode({ + data: structTypeNode([structFieldTypeNode({ identifier: 'action', type: definedTypeLinkNode('action') })]), + identifier: 'act', + subInstructions: [existing], + }), + ); + + // When we create sub-instructions from the enum field. + const result = visit(node, createSubInstructionsFromEnumDataVisitor({ act: 'action' })); + + // Then the existing sub-instruction is kept on the parent only. + assertIsNode(result, 'programNode'); + const subInstructions = result.instructions?.[0].subInstructions ?? []; + expect(subInstructions[0]).toStrictEqual(existing); + expect(subInstructions.slice(1).every(ix => ix.subInstructions === undefined)).toBe(true); +}); + +test('it throws when the field is missing or not an enum', () => { + // Given an instruction without an enum field. + const node = instructionNode({ data: structTypeNode([u8Field('amount')]), identifier: 'act' }); + + // When we create sub-instructions from a missing or non-enum field, then we expect errors. + ['action', 'amount'].forEach(fieldName => + expect(() => visit(node, createSubInstructionsFromEnumDataVisitor({ act: fieldName }))).toThrow( + new CodamaError(CODAMA_ERROR__VISITORS__INSTRUCTION_ENUM_DATA_FIELD_NOT_FOUND, { + fieldName: identifierString(fieldName), + instruction: node, + instructionName: identifierString('act'), + }), + ), + ); +}); + +test('it keeps the plugins of the enum field on the payload field', () => { + // Given an enum field carrying plugins. + const node = instructionNode({ + data: structTypeNode([ + structFieldTypeNode({ + identifier: 'action', + plugins: [pluginNode('my.plugin')], + type: enumTypeNode([enumVariantTypeNode('wait', { data: integerTypeNode('u64') })]), + }), + ]), + identifier: 'act', + }); + + // When we create sub-instructions from the enum field. + const result = visit(node, createSubInstructionsFromEnumDataVisitor({ act: 'action' })); + + // Then the payload field keeps its plugins. + assertIsNode(result, 'instructionNode'); + expect(result.subInstructions?.[0].data).toStrictEqual( + structTypeNode([ + discriminatorField('act_wait_discriminator', '0'), + structFieldTypeNode({ + identifier: 'action', + plugins: [pluginNode('my.plugin')], + type: integerTypeNode('u64'), + }), + ]), + ); +}); + +test('it throws when the instruction data links to a missing defined type', () => { + // Given an instruction whose data links to a defined type that does not exist. + const node = programNode({ + identifier: 'myProgram', + instructions: [instructionNode({ data: definedTypeLinkNode('missing'), identifier: 'act' })], + publicKey: '1111', + }); + + // When we create sub-instructions from it. + let error: unknown; + try { + visit(node, createSubInstructionsFromEnumDataVisitor({ act: 'action' })); + } catch (e) { + error = e; + } + + // Then we expect a linked node error rather than a missing field error. + expect(isCodamaError(error, CODAMA_ERROR__LINKED_NODE_NOT_FOUND)).toBe(true); +}); diff --git a/packages/visitors/test/deduplicateIdenticalDefinedTypesVisitor.test.ts b/packages/visitors/test/deduplicateIdenticalDefinedTypesVisitor.test.ts new file mode 100644 index 000000000..ead7c04ca --- /dev/null +++ b/packages/visitors/test/deduplicateIdenticalDefinedTypesVisitor.test.ts @@ -0,0 +1,141 @@ +import { + accountNode, + definedTypeLinkNode, + definedTypeNode, + integerTypeNode, + programLinkNode, + programNode, + rootNode, + structFieldTypeNode, + structTypeNode, +} from '@codama/nodes'; +import { visit } from '@codama/visitors-core'; +import { expect, test } from 'vitest'; + +import { deduplicateIdenticalDefinedTypesVisitor } from '../src'; + +test('it removes identical defined types from later programs', () => { + // Given two programs defining the same type, with different docs. + const programA = programNode({ + definedTypes: [definedTypeNode({ docs: 'In A.', identifier: 'myType', type: integerTypeNode('u8') })], + identifier: 'programA', + publicKey: '1111', + }); + const programB = programNode({ + definedTypes: [definedTypeNode({ docs: 'In B.', identifier: 'myType', type: integerTypeNode('u8') })], + identifier: 'programB', + publicKey: '2222', + }); + + // When we deduplicate identical defined types. + const result = visit( + rootNode(programA, { additionalPrograms: [programB] }), + deduplicateIdenticalDefinedTypesVisitor(), + ); + + // Then only the first copy is kept. + expect(result).toStrictEqual( + rootNode(programA, { additionalPrograms: [programNode({ identifier: 'programB', publicKey: '2222' })] }), + ); +}); + +test('it keeps same-named defined types that differ', () => { + // Given two programs defining different types under the same name. + const programA = programNode({ + definedTypes: [definedTypeNode({ identifier: 'myType', type: integerTypeNode('u8') })], + identifier: 'programA', + publicKey: '1111', + }); + const programB = programNode({ + definedTypes: [definedTypeNode({ identifier: 'myType', type: integerTypeNode('u16') })], + identifier: 'programB', + publicKey: '2222', + }); + const node = rootNode(programA, { additionalPrograms: [programB] }); + + // When we deduplicate identical defined types, then nothing changes. + expect(visit(node, deduplicateIdenticalDefinedTypesVisitor())).toStrictEqual(node); +}); + +test('it repoints links to removed types at the kept ones', () => { + // Given two programs defining the same type, used by an account of the second program. + const programA = programNode({ + definedTypes: [definedTypeNode({ identifier: 'myType', type: integerTypeNode('u8') })], + identifier: 'programA', + publicKey: '1111', + }); + const programB = programNode({ + accounts: [accountNode({ data: definedTypeLinkNode('myType'), identifier: 'myAccount' })], + definedTypes: [definedTypeNode({ identifier: 'myType', type: integerTypeNode('u8') })], + identifier: 'programB', + publicKey: '2222', + }); + + // When we deduplicate identical defined types. + const result = visit( + rootNode(programA, { additionalPrograms: [programB] }), + deduplicateIdenticalDefinedTypesVisitor(), + ); + + // Then the account now links to the type of the first program. + expect(result).toStrictEqual( + rootNode(programA, { + additionalPrograms: [ + programNode({ + accounts: [ + accountNode({ + data: definedTypeLinkNode('myType', { program: programLinkNode('programA') }), + identifier: 'myAccount', + }), + ], + identifier: 'programB', + publicKey: '2222', + }), + ], + }), + ); +}); + +test('it keeps identical types whose unqualified links resolve to different types', () => { + // Given two programs defining a "wrapper" type that links to their own, different, "inner" type. + const wrapper = definedTypeNode({ + identifier: 'wrapper', + type: structTypeNode([structFieldTypeNode({ identifier: 'inner', type: definedTypeLinkNode('inner') })]), + }); + const programA = programNode({ + definedTypes: [wrapper, definedTypeNode({ identifier: 'inner', type: integerTypeNode('u8') })], + identifier: 'programA', + publicKey: '1111', + }); + const programB = programNode({ + definedTypes: [wrapper, definedTypeNode({ identifier: 'inner', type: integerTypeNode('u16') })], + identifier: 'programB', + publicKey: '2222', + }); + const node = rootNode(programA, { additionalPrograms: [programB] }); + + // When we deduplicate identical defined types, then nothing changes. + expect(visit(node, deduplicateIdenticalDefinedTypesVisitor())).toStrictEqual(node); +}); + +test('it deduplicates identical types whose unqualified links are deduplicated too', () => { + // Given two programs defining the same "wrapper" and "inner" types. + const wrapper = definedTypeNode({ + identifier: 'wrapper', + type: structTypeNode([structFieldTypeNode({ identifier: 'inner', type: definedTypeLinkNode('inner') })]), + }); + const inner = definedTypeNode({ identifier: 'inner', type: integerTypeNode('u8') }); + const programA = programNode({ definedTypes: [wrapper, inner], identifier: 'programA', publicKey: '1111' }); + const programB = programNode({ definedTypes: [wrapper, inner], identifier: 'programB', publicKey: '2222' }); + + // When we deduplicate identical defined types. + const result = visit( + rootNode(programA, { additionalPrograms: [programB] }), + deduplicateIdenticalDefinedTypesVisitor(), + ); + + // Then both are removed from the second program. + expect(result).toStrictEqual( + rootNode(programA, { additionalPrograms: [programNode({ identifier: 'programB', publicKey: '2222' })] }), + ); +}); diff --git a/packages/visitors/test/flattenInstructionDataVisitor.test.ts b/packages/visitors/test/flattenInstructionDataVisitor.test.ts new file mode 100644 index 000000000..0d5690a0c --- /dev/null +++ b/packages/visitors/test/flattenInstructionDataVisitor.test.ts @@ -0,0 +1,69 @@ +import { + definedTypeLinkNode, + instructionNode, + integerTypeNode, + structFieldTypeNode, + structTypeNode, +} from '@codama/nodes'; +import { visit } from '@codama/visitors-core'; +import { expect, test } from 'vitest'; + +import { flattenInstructionDataVisitor } from '../src'; + +const u8Field = (identifier: string) => structFieldTypeNode({ identifier, type: integerTypeNode('u8') }); + +test('it flattens the struct fields of instruction data', () => { + // Given an instruction whose data contains a nested struct. + const node = instructionNode({ + data: structTypeNode([ + u8Field('a'), + structFieldTypeNode({ identifier: 'args', type: structTypeNode([u8Field('b'), u8Field('c')]) }), + ]), + identifier: 'myInstruction', + }); + + // When we flatten its data. + const result = visit(node, flattenInstructionDataVisitor()); + + // Then the nested fields are inlined. + expect(result).toStrictEqual( + instructionNode({ + data: structTypeNode([u8Field('a'), u8Field('b'), u8Field('c')]), + identifier: 'myInstruction', + }), + ); +}); + +test('it flattens the data of sub-instructions', () => { + // Given an instruction with a sub-instruction whose data contains a nested struct. + const node = instructionNode({ + identifier: 'parent', + subInstructions: [ + instructionNode({ + data: structTypeNode([ + structFieldTypeNode({ identifier: 'args', type: structTypeNode([u8Field('a')]) }), + ]), + identifier: 'child', + }), + ], + }); + + // When we flatten the instruction data. + const result = visit(node, flattenInstructionDataVisitor()); + + // Then the sub-instruction data is flattened too. + expect(result).toStrictEqual( + instructionNode({ + identifier: 'parent', + subInstructions: [instructionNode({ data: structTypeNode([u8Field('a')]), identifier: 'child' })], + }), + ); +}); + +test('it leaves linked instruction data untouched', () => { + // Given an instruction whose data is a link. + const node = instructionNode({ data: definedTypeLinkNode('myArgs'), identifier: 'myInstruction' }); + + // When we flatten its data, then nothing changes. + expect(visit(node, flattenInstructionDataVisitor())).toStrictEqual(node); +}); diff --git a/packages/visitors/test/flattenStructVisitor.test.ts b/packages/visitors/test/flattenStructVisitor.test.ts new file mode 100644 index 000000000..28a97508d --- /dev/null +++ b/packages/visitors/test/flattenStructVisitor.test.ts @@ -0,0 +1,122 @@ +import { + CODAMA_ERROR__VISITORS__CANNOT_FLATTEN_STRUCT_WITH_CONFLICTING_ATTRIBUTES, + CODAMA_ERROR__VISITORS__CANNOT_FLATTEN_STRUCT_WITH_PLUGINS, + CodamaError, +} from '@codama/errors'; +import { + accountNode, + fixedSizeTransformNode, + identifierString, + integerTypeNode, + pluginNode, + structFieldTypeNode, + structTypeNode, +} from '@codama/nodes'; +import { visit } from '@codama/visitors-core'; +import { expect, test } from 'vitest'; + +import { flattenStruct, flattenStructVisitor } from '../src'; + +const u8Field = (identifier: string) => structFieldTypeNode({ identifier, type: integerTypeNode('u8') }); + +test('it inlines the fields of struct fields', () => { + // Given an account whose data contains a nested struct. + const node = accountNode({ + data: structTypeNode([ + u8Field('a'), + structFieldTypeNode({ identifier: 'nested', type: structTypeNode([u8Field('b'), u8Field('c')]) }), + ]), + identifier: 'myAccount', + }); + + // When we flatten its data. + const result = visit(node, flattenStructVisitor({ myAccount: '*' })); + + // Then the nested fields are inlined. + expect(result).toStrictEqual( + accountNode({ data: structTypeNode([u8Field('a'), u8Field('b'), u8Field('c')]), identifier: 'myAccount' }), + ); +}); + +test('it only inlines the listed fields, matched exactly', () => { + // Given a struct with two nested structs. + const node = structTypeNode([ + structFieldTypeNode({ identifier: 'first_group', type: structTypeNode([u8Field('a')]) }), + structFieldTypeNode({ identifier: 'secondGroup', type: structTypeNode([u8Field('b')]) }), + ]); + + // When we flatten the first one using another casing, then nothing is inlined. + expect(flattenStruct(node, ['firstGroup'])).toStrictEqual(node); + + // When we flatten the first one using its exact identifier, then only that one is inlined. + expect(flattenStruct(node, ['first_group'])).toStrictEqual( + structTypeNode([ + u8Field('a'), + structFieldTypeNode({ identifier: 'secondGroup', type: structTypeNode([u8Field('b')]) }), + ]), + ); +}); + +test('it keeps the transforms and plugins of the flattened struct', () => { + // Given a fixed-size struct with plugins, containing a nested struct. + const node = structTypeNode([structFieldTypeNode({ identifier: 'nested', type: structTypeNode([u8Field('a')]) })], { + plugins: [pluginNode('my.plugin')], + transforms: [fixedSizeTransformNode(32)], + }); + + // When we flatten it, then its transforms and plugins are preserved. + expect(flattenStruct(node)).toStrictEqual( + structTypeNode([u8Field('a')], { + plugins: [pluginNode('my.plugin')], + transforms: [fixedSizeTransformNode(32)], + }), + ); +}); + +test('it does not inline structs that carry transforms', () => { + // Given a struct field whose struct is fixed-size. + const node = structTypeNode([ + structFieldTypeNode({ + identifier: 'nested', + type: structTypeNode([u8Field('a')], { transforms: [fixedSizeTransformNode(32)] }), + }), + ]); + + // When we flatten it, then the field is kept since inlining would drop the fixed size. + expect(flattenStruct(node)).toStrictEqual(node); +}); + +test('it throws when inlined fields collide in camelCase', () => { + // Given a struct whose nested struct has a field colliding with a sibling field. + const node = structTypeNode([ + u8Field('max_supply'), + structFieldTypeNode({ identifier: 'nested', type: structTypeNode([u8Field('maxSupply')]) }), + ]); + + // When we flatten it, then we expect a conflict error listing both identifiers. + expect(() => flattenStruct(node)).toThrow( + new CodamaError(CODAMA_ERROR__VISITORS__CANNOT_FLATTEN_STRUCT_WITH_CONFLICTING_ATTRIBUTES, { + conflictingAttributes: [identifierString('max_supply'), identifierString('maxSupply')], + }), + ); +}); + +test('it throws when a struct to inline carries plugins', () => { + // Given a struct field whose struct carries plugins. + const field = structFieldTypeNode({ + identifier: 'nested', + type: structTypeNode([u8Field('a')], { plugins: [pluginNode('my.plugin')] }), + }); + const node = structTypeNode([u8Field('b'), field]); + + // When we flatten it, then we expect an error since the plugins would be lost. + expect(() => flattenStruct(node)).toThrow( + new CodamaError(CODAMA_ERROR__VISITORS__CANNOT_FLATTEN_STRUCT_WITH_PLUGINS, { + field, + fieldName: identifierString('nested'), + }), + ); + + // Unless that field is not selected for flattening. + expect(flattenStruct(node, ['other'])).toStrictEqual(node); +}); diff --git a/packages/visitors/test/getDefinedTypeHistogramVisitor.test.ts b/packages/visitors/test/getDefinedTypeHistogramVisitor.test.ts index f9f17b641..6acf942fb 100644 --- a/packages/visitors/test/getDefinedTypeHistogramVisitor.test.ts +++ b/packages/visitors/test/getDefinedTypeHistogramVisitor.test.ts @@ -1,12 +1,16 @@ import { accountNode, + constantPdaSeedNode, definedTypeLinkNode, definedTypeNode, enumTypeNode, + enumValueNode, eventNode, - instructionArgumentNode, + instructionAccountNode, instructionNode, - numberTypeNode, + integerTypeNode, + pdaNode, + programLinkNode, programNode, rootNode, structFieldTypeNode, @@ -17,150 +21,204 @@ import { expect, test } from 'vitest'; import { getDefinedTypeHistogramVisitor } from '../src'; +const counts = (overrides: Partial>) => ({ + directlyAsInstructionData: 0, + inAccounts: 0, + inDefinedTypes: 0, + inEvents: 0, + inInstructionData: 0, + total: 0, + ...overrides, +}); + test('it counts the amount of times defined types are used within the tree', () => { // Given the following tree. const node = programNode({ accounts: [ accountNode({ data: structTypeNode([ - structFieldTypeNode({ - name: 'field1', - type: definedTypeLinkNode('myStruct'), - }), - structFieldTypeNode({ - name: 'field2', - type: definedTypeLinkNode('myEnum'), - }), + structFieldTypeNode({ identifier: 'field1', type: definedTypeLinkNode('myStruct') }), + structFieldTypeNode({ identifier: 'field2', type: definedTypeLinkNode('myEnum') }), ]), - name: 'myAccount', + identifier: 'myAccount', }), ], definedTypes: [ - definedTypeNode({ - name: 'myStruct', - type: structTypeNode([]), - }), - definedTypeNode({ - name: 'myEnum', - type: enumTypeNode([]), - }), + definedTypeNode({ identifier: 'myStruct', type: structTypeNode([]) }), + definedTypeNode({ identifier: 'myEnum', type: enumTypeNode([]) }), ], - errors: [], + identifier: 'customProgram', instructions: [ instructionNode({ - accounts: [], - arguments: [ - instructionArgumentNode({ - name: 'arg1', - type: definedTypeLinkNode('myStruct'), - }), - ], - name: 'myInstruction', + data: structTypeNode([ + structFieldTypeNode({ identifier: 'arg1', type: definedTypeLinkNode('myStruct') }), + ]), + identifier: 'myInstruction', }), ], - name: 'customProgram', publicKey: '1111', - version: '1.0.0', }); // When we get its defined type histogram. const histogram = visit(node, getDefinedTypeHistogramVisitor()); // Then we expect the following histogram. - expect(histogram).toEqual({ - 'customProgram.myEnum': { - directlyAsInstructionArgs: 0, - inAccounts: 1, - inDefinedTypes: 0, - inEvents: 0, - inInstructionArgs: 0, - total: 1, - }, - 'customProgram.myStruct': { - directlyAsInstructionArgs: 1, + expect(histogram).toStrictEqual({ + 'customProgram.myEnum': counts({ inAccounts: 1, total: 1 }), + 'customProgram.myStruct': counts({ + directlyAsInstructionData: 1, inAccounts: 1, - inDefinedTypes: 0, - inEvents: 0, - inInstructionArgs: 1, + inInstructionData: 1, total: 2, - }, + }), }); }); test('it counts defined types used inside event payloads', () => { + // Given an event whose data uses a defined type. const node = programNode({ - definedTypes: [definedTypeNode({ name: 'eventPayload', type: structTypeNode([]) })], + definedTypes: [definedTypeNode({ identifier: 'eventPayload', type: structTypeNode([]) })], events: [ eventNode({ + data: structTypeNode([ + structFieldTypeNode({ identifier: 'payload', type: definedTypeLinkNode('eventPayload') }), + ]), + identifier: 'payloadCreated', + }), + ], + identifier: 'customProgram', + publicKey: '1111', + }); + + // When we get its defined type histogram, then the use is counted as an event use. + expect(visit(node, getDefinedTypeHistogramVisitor())).toStrictEqual({ + 'customProgram.eventPayload': counts({ inEvents: 1, total: 1 }), + }); +}); + +test('it counts instruction data that is itself a link as a direct use', () => { + // Given an instruction whose data is a link, and a link nested deeper in another instruction's data. + const node = programNode({ + definedTypes: [ + definedTypeNode({ identifier: 'argsA', type: structTypeNode([]) }), + definedTypeNode({ identifier: 'argsB', type: structTypeNode([]) }), + ], + identifier: 'customProgram', + instructions: [ + instructionNode({ data: definedTypeLinkNode('argsA'), identifier: 'instructionA' }), + instructionNode({ data: structTypeNode([ structFieldTypeNode({ - name: 'payload', - type: definedTypeLinkNode('eventPayload'), + identifier: 'nested', + type: structTypeNode([ + structFieldTypeNode({ identifier: 'args', type: definedTypeLinkNode('argsB') }), + ]), }), ]), - name: 'payloadCreated', + identifier: 'instructionB', }), ], - name: 'customProgram', publicKey: '1111', }); + // When we get its defined type histogram. const histogram = visit(node, getDefinedTypeHistogramVisitor()); - expect(histogram).toEqual({ - 'customProgram.eventPayload': { - directlyAsInstructionArgs: 0, - inAccounts: 0, - inDefinedTypes: 0, - inEvents: 1, - inInstructionArgs: 0, - total: 1, - }, + // Then only the first link is a direct use. + expect(histogram).toStrictEqual({ + 'customProgram.argsA': counts({ directlyAsInstructionData: 1, inInstructionData: 1, total: 1 }), + 'customProgram.argsB': counts({ inInstructionData: 1, total: 1 }), + }); +}); + +test('it counts uses outside of data in the total only', () => { + // Given a defined type used in an instruction account default value and a PDA seed. + const node = programNode({ + definedTypes: [definedTypeNode({ identifier: 'myEnum', type: enumTypeNode([]) })], + identifier: 'customProgram', + instructions: [ + instructionNode({ + accounts: [ + instructionAccountNode({ + defaultValue: enumValueNode(definedTypeLinkNode('myEnum'), 'a'), + identifier: 'myAccount', + isSigner: false, + isWritable: false, + }), + ], + identifier: 'myInstruction', + }), + ], + pdas: [ + pdaNode({ + identifier: 'myPda', + seeds: [ + constantPdaSeedNode( + definedTypeLinkNode('myEnum'), + enumValueNode(definedTypeLinkNode('myEnum'), 'a'), + ), + ], + }), + ], + publicKey: '1111', + }); + + // When we get its defined type histogram, then every use counts towards the total. + expect(visit(node, getDefinedTypeHistogramVisitor())).toStrictEqual({ + 'customProgram.myEnum': counts({ total: 3 }), + }); +}); + +test('it keys links by the program they point to', () => { + // Given a program linking to a defined type of another program. + const programA = programNode({ + accounts: [ + accountNode({ + data: definedTypeLinkNode('myType', { program: programLinkNode('programB') }), + identifier: 'myAccount', + }), + ], + identifier: 'programA', + publicKey: '1111', }); + const programB = programNode({ + definedTypes: [definedTypeNode({ identifier: 'myType', type: integerTypeNode('u8') })], + identifier: 'programB', + publicKey: '2222', + }); + + // When we get its defined type histogram. + const histogram = visit(rootNode(programA, { additionalPrograms: [programB] }), getDefinedTypeHistogramVisitor()); + + // Then the use is keyed by the target program. + expect(histogram).toStrictEqual({ 'programB.myType': counts({ inAccounts: 1, total: 1 }) }); }); test('it counts links from different programs separately', () => { - // Given a program node with a defined type used in another type. + // Given two programs with same-named defined types used in other types. const programA = programNode({ definedTypes: [ - definedTypeNode({ name: 'myType', type: numberTypeNode('u8') }), - definedTypeNode({ name: 'myCopyType', type: definedTypeLinkNode('myType') }), + definedTypeNode({ identifier: 'myType', type: integerTypeNode('u8') }), + definedTypeNode({ identifier: 'myCopyType', type: definedTypeLinkNode('myType') }), ], - name: 'programA', + identifier: 'programA', publicKey: '1111', }); - - // And another program with a defined type sharing the same name. const programB = programNode({ definedTypes: [ - definedTypeNode({ name: 'myType', type: numberTypeNode('u16') }), - definedTypeNode({ name: 'myCopyType', type: definedTypeLinkNode('myType') }), + definedTypeNode({ identifier: 'myType', type: integerTypeNode('u16') }), + definedTypeNode({ identifier: 'myCopyType', type: definedTypeLinkNode('myType') }), ], - name: 'programB', + identifier: 'programB', publicKey: '2222', }); - // When we unwrap the defined type from programA. - const node = rootNode(programA, [programB]); - const histogram = visit(node, getDefinedTypeHistogramVisitor()); + // When we get the defined type histogram of both. + const histogram = visit(rootNode(programA, { additionalPrograms: [programB] }), getDefinedTypeHistogramVisitor()); - // Then we expect programA to have been modified but not programB. + // Then we expect them to be counted separately. expect(histogram).toStrictEqual({ - 'programA.myType': { - directlyAsInstructionArgs: 0, - inAccounts: 0, - inDefinedTypes: 1, - inEvents: 0, - inInstructionArgs: 0, - total: 1, - }, - 'programB.myType': { - directlyAsInstructionArgs: 0, - inAccounts: 0, - inDefinedTypes: 1, - inEvents: 0, - inInstructionArgs: 0, - total: 1, - }, + 'programA.myType': counts({ inDefinedTypes: 1, total: 1 }), + 'programB.myType': counts({ inDefinedTypes: 1, total: 1 }), }); }); diff --git a/packages/visitors/test/renameHelpers.test.ts b/packages/visitors/test/renameHelpers.test.ts new file mode 100644 index 000000000..827e67256 --- /dev/null +++ b/packages/visitors/test/renameHelpers.test.ts @@ -0,0 +1,61 @@ +import { + enumTypeNode, + enumVariantTypeNode, + fixedSizeTransformNode, + integerTypeNode, + structFieldTypeNode, + structTypeNode, +} from '@codama/nodes'; +import { expect, test } from 'vitest'; + +import { renameEnumNode, renameStructNode } from '../src/renameHelpers'; + +test('it renames struct fields and keeps the struct transforms', () => { + // Given a fixed-size struct. + const node = structTypeNode( + [ + structFieldTypeNode({ identifier: 'a', type: integerTypeNode('u8') }), + structFieldTypeNode({ identifier: 'constructor', type: integerTypeNode('u8') }), + ], + { transforms: [fixedSizeTransformNode(4)] }, + ); + + // When we rename one of its fields, including one named after an object prototype key. + const result = renameStructNode(node, { a: 'b' }); + + // Then only that field is renamed and the transforms are kept. + expect(result).toStrictEqual( + structTypeNode( + [ + structFieldTypeNode({ identifier: 'b', type: integerTypeNode('u8') }), + structFieldTypeNode({ identifier: 'constructor', type: integerTypeNode('u8') }), + ], + { transforms: [fixedSizeTransformNode(4)] }, + ), + ); +}); + +test('it renames enum variants and keeps their attributes', () => { + // Given an enum with explicit discriminators and a data variant. + const node = enumTypeNode( + [ + enumVariantTypeNode('a', { discriminator: 5 }), + enumVariantTypeNode('b', { data: integerTypeNode('u32'), discriminator: 7 }), + ], + { size: integerTypeNode('u16') }, + ); + + // When we rename both variants. + const result = renameEnumNode(node, { a: 'x', b: 'y' }); + + // Then their discriminators and data are preserved, as is the enum size. + expect(result).toStrictEqual( + enumTypeNode( + [ + enumVariantTypeNode('x', { discriminator: 5 }), + enumVariantTypeNode('y', { data: integerTypeNode('u32'), discriminator: 7 }), + ], + { size: integerTypeNode('u16') }, + ), + ); +}); diff --git a/packages/visitors/test/setAccountDiscriminatorFromFieldVisitor.test.ts b/packages/visitors/test/setAccountDiscriminatorFromFieldVisitor.test.ts new file mode 100644 index 000000000..1531d9c3c --- /dev/null +++ b/packages/visitors/test/setAccountDiscriminatorFromFieldVisitor.test.ts @@ -0,0 +1,105 @@ +import { + CODAMA_ERROR__UNEXPECTED_NODE_KIND, + CODAMA_ERROR__VISITORS__ACCOUNT_FIELD_NOT_FOUND, + CodamaError, +} from '@codama/errors'; +import { + accountNode, + definedTypeLinkNode, + fieldDiscriminatorNode, + fixedSizeTransformNode, + identifierString, + integerTypeNode, + integerValueNode, + sizeDiscriminatorNode, + structFieldTypeNode, + structTypeNode, +} from '@codama/nodes'; +import { visit } from '@codama/visitors-core'; +import { expect, test } from 'vitest'; + +import { setAccountDiscriminatorFromFieldVisitor } from '../src'; + +test('it uses a data field as the account discriminator', () => { + // Given a fixed-size account with a "key" field and an existing discriminator. + const node = accountNode({ + data: structTypeNode( + [ + structFieldTypeNode({ identifier: 'key', type: integerTypeNode('u8') }), + structFieldTypeNode({ identifier: 'count', type: integerTypeNode('u64') }), + ], + { transforms: [fixedSizeTransformNode(16)] }, + ), + discriminators: [sizeDiscriminatorNode(16)], + identifier: 'myAccount', + }); + + // When we use the "key" field as its discriminator. + const result = visit( + node, + setAccountDiscriminatorFromFieldVisitor({ + myAccount: { field: 'key', offset: 0, value: integerValueNode('3') }, + }), + ); + + // Then the field gets an omitted default value, the discriminator is prepended and the transforms are kept. + expect(result).toStrictEqual( + accountNode({ + data: structTypeNode( + [ + structFieldTypeNode({ + defaultValue: integerValueNode('3'), + defaultValueStrategy: 'omitted', + identifier: 'key', + type: integerTypeNode('u8'), + }), + structFieldTypeNode({ identifier: 'count', type: integerTypeNode('u64') }), + ], + { transforms: [fixedSizeTransformNode(16)] }, + ), + discriminators: [fieldDiscriminatorNode('key', { offset: 0 }), sizeDiscriminatorNode(16)], + identifier: 'myAccount', + }), + ); +}); + +test('it throws when the field does not exist', () => { + // Given an account without a "key" field. + const node = accountNode({ + data: structTypeNode([structFieldTypeNode({ identifier: 'count', type: integerTypeNode('u64') })]), + identifier: 'myAccount', + }); + + // When we try to use the "key" field as its discriminator, then we expect an error. + expect(() => + visit( + node, + setAccountDiscriminatorFromFieldVisitor({ myAccount: { field: 'key', value: integerValueNode('3') } }), + ), + ).toThrow( + new CodamaError(CODAMA_ERROR__VISITORS__ACCOUNT_FIELD_NOT_FOUND, { + account: node, + missingField: identifierString('key'), + name: identifierString('myAccount'), + }), + ); +}); + +test('it throws when the account data is linked', () => { + // Given an account whose data links to a defined type. + const node = accountNode({ data: definedTypeLinkNode('myData'), identifier: 'myAccount' }); + + // When we try to set its discriminator from a field, then we expect an error. + expect(() => + visit( + node, + setAccountDiscriminatorFromFieldVisitor({ myAccount: { field: 'key', value: integerValueNode('3') } }), + ), + ).toThrow( + new CodamaError(CODAMA_ERROR__UNEXPECTED_NODE_KIND, { + expectedKinds: ['structTypeNode'], + kind: 'definedTypeLinkNode', + node: node.data, + }), + ); +}); diff --git a/packages/visitors/test/setFixedAccountSizesVisitor.test.ts b/packages/visitors/test/setFixedAccountSizesVisitor.test.ts new file mode 100644 index 000000000..703d92a87 --- /dev/null +++ b/packages/visitors/test/setFixedAccountSizesVisitor.test.ts @@ -0,0 +1,61 @@ +import { + accountNode, + definedTypeLinkNode, + definedTypeNode, + integerTypeNode, + programNode, + stringTypeNode, + structFieldTypeNode, + structTypeNode, +} from '@codama/nodes'; +import { visit } from '@codama/visitors-core'; +import { expect, test } from 'vitest'; + +import { setFixedAccountSizesVisitor } from '../src'; + +test('it sets the size of fixed-size accounts, following links', () => { + // Given a program with a fixed-size account, a linked fixed-size account and a variable-size account. + const node = programNode({ + accounts: [ + accountNode({ + data: structTypeNode([ + structFieldTypeNode({ identifier: 'a', type: integerTypeNode('u64') }), + structFieldTypeNode({ identifier: 'b', type: integerTypeNode('u8') }), + ]), + identifier: 'fixed', + }), + accountNode({ data: definedTypeLinkNode('myType'), identifier: 'linked' }), + accountNode({ data: stringTypeNode('utf8'), identifier: 'variable' }), + ], + definedTypes: [definedTypeNode({ identifier: 'myType', type: integerTypeNode('u32') })], + identifier: 'myProgram', + publicKey: '1111', + }); + + // When we set fixed account sizes. + const result = visit(node, setFixedAccountSizesVisitor()); + + // Then only fixed-size accounts get a size. + expect(result).toStrictEqual( + programNode({ + ...node, + accounts: [ + accountNode({ ...node.accounts![0], size: 9 }), + accountNode({ ...node.accounts![1], size: 4 }), + node.accounts![2], + ], + }), + ); +}); + +test('it does not override existing sizes', () => { + // Given an account with an explicit size. + const node = programNode({ + accounts: [accountNode({ data: integerTypeNode('u8'), identifier: 'myAccount', size: 100 })], + identifier: 'myProgram', + publicKey: '1111', + }); + + // When we set fixed account sizes, then nothing changes. + expect(visit(node, setFixedAccountSizesVisitor())).toStrictEqual(node); +}); diff --git a/packages/visitors/test/setStructDefaultValuesVisitor.test.ts b/packages/visitors/test/setStructDefaultValuesVisitor.test.ts index f8627fd46..38ea36bfe 100644 --- a/packages/visitors/test/setStructDefaultValuesVisitor.test.ts +++ b/packages/visitors/test/setStructDefaultValuesVisitor.test.ts @@ -1,15 +1,16 @@ import { accountNode, assertIsNode, + definedTypeLinkNode, definedTypeNode, - instructionArgumentNode, + fixedSizeTransformNode, + injectedValueNode, instructionNode, + integerTypeNode, + integerValueNode, noneValueNode, - numberTypeNode, - numberValueNode, optionTypeNode, publicKeyTypeNode, - resolveNestedTypeNode, structFieldTypeNode, structTypeNode, } from '@codama/nodes'; @@ -21,53 +22,47 @@ import { setStructDefaultValuesVisitor } from '../src'; test('it adds new default values to struct fields', () => { // Given the following person type with no default values. const node = definedTypeNode({ - name: 'person', + identifier: 'person', type: structTypeNode([ - structFieldTypeNode({ - name: 'age', - type: numberTypeNode('u32'), - }), - structFieldTypeNode({ - name: 'dateOfBirth', - type: optionTypeNode(numberTypeNode('i64')), - }), + structFieldTypeNode({ identifier: 'age', type: integerTypeNode('u32') }), + structFieldTypeNode({ identifier: 'dateOfBirth', type: optionTypeNode(integerTypeNode('i64')) }), ]), }); // When we set default values for the age and dateOfBirth fields of the person type. const result = visit( node, - setStructDefaultValuesVisitor({ - person: { - age: numberValueNode(42), - dateOfBirth: noneValueNode(), - }, - }), + setStructDefaultValuesVisitor({ person: { age: integerValueNode('42'), dateOfBirth: noneValueNode() } }), ); // Then we expect the following tree changes. - assertIsNode(result, 'definedTypeNode'); - assertIsNode(result.type, 'structTypeNode'); - expect((result.type.fields ?? [])[0].defaultValue).toEqual(numberValueNode(42)); - expect((result.type.fields ?? [])[0].defaultValueStrategy).toBeUndefined(); - expect((result.type.fields ?? [])[1].defaultValue).toEqual(noneValueNode()); - expect((result.type.fields ?? [])[1].defaultValueStrategy).toBeUndefined(); + expect(result).toStrictEqual( + definedTypeNode({ + identifier: 'person', + type: structTypeNode([ + structFieldTypeNode({ + defaultValue: integerValueNode('42'), + identifier: 'age', + type: integerTypeNode('u32'), + }), + structFieldTypeNode({ + defaultValue: noneValueNode(), + identifier: 'dateOfBirth', + type: optionTypeNode(integerTypeNode('i64')), + }), + ]), + }), + ); }); test('it adds new default values with custom strategies to struct fields', () => { // Given the following token account with no default values. const node = accountNode({ data: structTypeNode([ - structFieldTypeNode({ - name: 'discriminator', - type: numberTypeNode('u8'), - }), - structFieldTypeNode({ - name: 'delegateAuthority', - type: optionTypeNode(publicKeyTypeNode()), - }), + structFieldTypeNode({ identifier: 'discriminator', type: integerTypeNode('u8') }), + structFieldTypeNode({ identifier: 'delegateAuthority', type: optionTypeNode(publicKeyTypeNode()) }), ]), - name: 'token', + identifier: 'token', }); // When we set default values of that account with custom strategies. @@ -76,51 +71,107 @@ test('it adds new default values with custom strategies to struct fields', () => setStructDefaultValuesVisitor({ token: { delegateAuthority: { strategy: 'optional', value: noneValueNode() }, - discriminator: { strategy: 'omitted', value: numberValueNode(42) }, + discriminator: { strategy: 'omitted', value: integerValueNode('42') }, }, }), ); // Then we expect the following tree changes. assertIsNode(result, 'accountNode'); - const data = resolveNestedTypeNode(result.data); - expect((data.fields ?? [])[0].defaultValue).toEqual(numberValueNode(42)); - expect((data.fields ?? [])[0].defaultValueStrategy).toBe('omitted'); - expect((data.fields ?? [])[1].defaultValue).toEqual(noneValueNode()); - expect((data.fields ?? [])[1].defaultValueStrategy).toBe('optional'); + assertIsNode(result.data, 'structTypeNode'); + const [discriminator, delegateAuthority] = result.data.fields ?? []; + expect(discriminator.defaultValue).toStrictEqual(integerValueNode('42')); + expect(discriminator.defaultValueStrategy).toBe('omitted'); + expect(delegateAuthority.defaultValue).toStrictEqual(noneValueNode()); + expect(delegateAuthority.defaultValueStrategy).toBe('optional'); }); -test('it adds new default values to instruction arguments', () => { - // Given the following instruction node with no default values for its arguments +test('it adds new default values to instruction data fields', () => { + // Given an instruction with no default values for its data fields. const node = instructionNode({ - arguments: [ - instructionArgumentNode({ - name: 'discriminator', - type: numberTypeNode('u8'), - }), - instructionArgumentNode({ - name: 'amount', - type: numberTypeNode('u64'), - }), - ], - name: 'transferTokens', + data: structTypeNode([ + structFieldTypeNode({ identifier: 'discriminator', type: integerTypeNode('u8') }), + structFieldTypeNode({ identifier: 'amount', type: integerTypeNode('u64') }), + ]), + identifier: 'transferTokens', }); - // When we set default values for its arguments. + // When we set default values for its data fields, including an injected one. const result = visit( node, setStructDefaultValuesVisitor({ transferTokens: { - amount: numberValueNode(1), - discriminator: { strategy: 'omitted', value: numberValueNode(42) }, + amount: injectedValueNode({ fallback: integerValueNode('1'), key: 'amount' }), + discriminator: { strategy: 'omitted', value: integerValueNode('42') }, }, }), ); // Then we expect the following tree changes. - assertIsNode(result, 'instructionNode'); - expect((result.arguments ?? [])[0].defaultValue).toEqual(numberValueNode(42)); - expect((result.arguments ?? [])[0].defaultValueStrategy).toBe('omitted'); - expect((result.arguments ?? [])[1].defaultValue).toEqual(numberValueNode(1)); - expect((result.arguments ?? [])[1].defaultValueStrategy).toBeUndefined(); + expect(result).toStrictEqual( + instructionNode({ + data: structTypeNode([ + structFieldTypeNode({ + defaultValue: integerValueNode('42'), + defaultValueStrategy: 'omitted', + identifier: 'discriminator', + type: integerTypeNode('u8'), + }), + structFieldTypeNode({ + defaultValue: injectedValueNode({ fallback: integerValueNode('1'), key: 'amount' }), + identifier: 'amount', + type: integerTypeNode('u64'), + }), + ]), + identifier: 'transferTokens', + }), + ); +}); + +test('it removes default values and keeps the struct transforms', () => { + // Given a fixed-size struct whose field has a default value. + const node = definedTypeNode({ + identifier: 'config', + type: structTypeNode( + [ + structFieldTypeNode({ + defaultValue: integerValueNode('1'), + defaultValueStrategy: 'optional', + identifier: 'version', + type: integerTypeNode('u8'), + }), + ], + { transforms: [fixedSizeTransformNode(8)] }, + ), + }); + + // When we remove its default value. + const result = visit(node, setStructDefaultValuesVisitor({ config: { version: null } })); + + // Then the default value is gone and the transforms are preserved. + expect(result).toStrictEqual( + definedTypeNode({ + identifier: 'config', + type: structTypeNode([structFieldTypeNode({ identifier: 'version', type: integerTypeNode('u8') })], { + transforms: [fixedSizeTransformNode(8)], + }), + }), + ); +}); + +test('it matches field identifiers exactly and does not follow links', () => { + // Given an account with a snake_case field and an instruction with linked data. + const account = accountNode({ + data: structTypeNode([structFieldTypeNode({ identifier: 'max_supply', type: integerTypeNode('u64') })]), + identifier: 'mint', + }); + const instruction = instructionNode({ data: definedTypeLinkNode('transferArgs'), identifier: 'transfer' }); + + // When we set default values using another casing or through the link, then nothing changes. + expect(visit(account, setStructDefaultValuesVisitor({ mint: { maxSupply: integerValueNode('0') } }))).toStrictEqual( + account, + ); + expect( + visit(instruction, setStructDefaultValuesVisitor({ transfer: { amount: integerValueNode('0') } })), + ).toStrictEqual(instruction); }); diff --git a/packages/visitors/test/transformDefinedTypesIntoAccountsVisitor.test.ts b/packages/visitors/test/transformDefinedTypesIntoAccountsVisitor.test.ts new file mode 100644 index 000000000..ce47c170d --- /dev/null +++ b/packages/visitors/test/transformDefinedTypesIntoAccountsVisitor.test.ts @@ -0,0 +1,64 @@ +import { + accountNode, + definedTypeNode, + integerTypeNode, + pluginNode, + programNode, + structFieldTypeNode, + structTypeNode, +} from '@codama/nodes'; +import { visit } from '@codama/visitors-core'; +import { expect, test } from 'vitest'; + +import { transformDefinedTypesIntoAccountsVisitor } from '../src'; + +test('it moves the given defined types into accounts', () => { + // Given a program with two defined types. + const data = structTypeNode([structFieldTypeNode({ identifier: 'count', type: integerTypeNode('u64') })]); + const node = programNode({ + definedTypes: [ + definedTypeNode({ + docs: 'A counter.', + identifier: 'counter', + plugins: [pluginNode('my.plugin')], + type: data, + }), + definedTypeNode({ identifier: 'other', type: integerTypeNode('u8') }), + ], + identifier: 'myProgram', + publicKey: '1111', + }); + + // When we transform one of them into an account. + const result = visit(node, transformDefinedTypesIntoAccountsVisitor(['counter'])); + + // Then it becomes an account keeping its docs and plugins. + expect(result).toStrictEqual( + programNode({ + accounts: [ + accountNode({ data, docs: 'A counter.', identifier: 'counter', plugins: [pluginNode('my.plugin')] }), + ], + definedTypes: [definedTypeNode({ identifier: 'other', type: integerTypeNode('u8') })], + identifier: 'myProgram', + publicKey: '1111', + }), + ); +}); + +test('it accepts defined types that are not structs', () => { + // Given a defined type that is a plain integer. + const node = programNode({ + definedTypes: [definedTypeNode({ identifier: 'counter', type: integerTypeNode('u64') })], + identifier: 'myProgram', + publicKey: '1111', + }); + + // When we transform it into an account, then its type becomes the account's data. + expect(visit(node, transformDefinedTypesIntoAccountsVisitor(['counter']))).toStrictEqual( + programNode({ + accounts: [accountNode({ data: integerTypeNode('u64'), identifier: 'counter' })], + identifier: 'myProgram', + publicKey: '1111', + }), + ); +}); diff --git a/packages/visitors/test/transformU8ArraysToBytesVisitor.test.ts b/packages/visitors/test/transformU8ArraysToBytesVisitor.test.ts new file mode 100644 index 000000000..6bc13b98c --- /dev/null +++ b/packages/visitors/test/transformU8ArraysToBytesVisitor.test.ts @@ -0,0 +1,83 @@ +import { + arrayTypeNode, + bytesTypeNode, + fixedCountNode, + fixedSizeTransformNode, + integerTypeNode, + pluginNode, + prefixedCountNode, + sizePrefixTransformNode, +} from '@codama/nodes'; +import { visit } from '@codama/visitors-core'; +import { expect, test } from 'vitest'; + +import { transformU8ArraysToBytesVisitor } from '../src'; + +test('it transforms fixed-size u8 arrays into fixed-size bytes', () => { + // Given a fixed-size array of u8. + const node = arrayTypeNode(integerTypeNode('u8'), fixedCountNode(32)); + + // When we transform u8 arrays into bytes. + const result = visit(node, transformU8ArraysToBytesVisitor()); + + // Then we get fixed-size bytes. + expect(result).toStrictEqual(bytesTypeNode({ transforms: [fixedSizeTransformNode(32)] })); +}); + +test('it only transforms arrays of the given sizes', () => { + // Given two fixed-size arrays of u8 of different sizes. + const small = arrayTypeNode(integerTypeNode('u8'), fixedCountNode(8)); + const large = arrayTypeNode(integerTypeNode('u8'), fixedCountNode(32)); + + // When we only transform arrays of size 32. + const visitor = transformU8ArraysToBytesVisitor([32]); + + // Then only the large array is transformed. + expect(visit(small, visitor)).toStrictEqual(small); + expect(visit(large, visitor)).toStrictEqual(bytesTypeNode({ transforms: [fixedSizeTransformNode(32)] })); +}); + +test('it keeps the transforms and plugins of the array outside the fixed size', () => { + // Given a fixed-size array of u8 with transforms and plugins. + const node = arrayTypeNode(integerTypeNode('u8'), fixedCountNode(32), { + plugins: [pluginNode('my.plugin')], + transforms: [sizePrefixTransformNode(integerTypeNode('u32'))], + }); + + // When we transform it. + const result = visit(node, transformU8ArraysToBytesVisitor()); + + // Then the fixed size is innermost, followed by the array's transforms. + expect(result).toStrictEqual( + bytesTypeNode({ + plugins: [pluginNode('my.plugin')], + transforms: [fixedSizeTransformNode(32), sizePrefixTransformNode(integerTypeNode('u32'))], + }), + ); +}); + +test('it does not transform arrays whose items are not plain u8', () => { + // Given arrays whose u8 items carry a unit or transforms, or are not u8. + const nodes = [ + arrayTypeNode(integerTypeNode('u8', { unit: 'bps' }), fixedCountNode(4)), + arrayTypeNode(integerTypeNode('u8', { transforms: [fixedSizeTransformNode(2)] }), fixedCountNode(4)), + arrayTypeNode(integerTypeNode('u16'), fixedCountNode(4)), + arrayTypeNode(integerTypeNode('u8'), prefixedCountNode(integerTypeNode('u32'))), + ]; + + // When we transform them, then nothing changes. + nodes.forEach(node => expect(visit(node, transformU8ArraysToBytesVisitor())).toStrictEqual(node)); +}); + +test('it transforms nested arrays', () => { + // Given an array of fixed-size u8 arrays. + const node = arrayTypeNode(arrayTypeNode(integerTypeNode('u8'), fixedCountNode(32)), fixedCountNode(2)); + + // When we transform u8 arrays into bytes. + const result = visit(node, transformU8ArraysToBytesVisitor()); + + // Then the inner array is transformed. + expect(result).toStrictEqual( + arrayTypeNode(bytesTypeNode({ transforms: [fixedSizeTransformNode(32)] }), fixedCountNode(2)), + ); +}); diff --git a/packages/visitors/test/unwrapDefinedTypesVisitor.test.ts b/packages/visitors/test/unwrapDefinedTypesVisitor.test.ts index 30bc1ed31..fe67efc30 100644 --- a/packages/visitors/test/unwrapDefinedTypesVisitor.test.ts +++ b/packages/visitors/test/unwrapDefinedTypesVisitor.test.ts @@ -1,14 +1,22 @@ import { accountNode, assertIsNode, + constantNode, definedTypeLinkNode, definedTypeNode, - numberTypeNode, + eventNode, + fixedSizeTransformNode, + integerTypeNode, + integerValueNode, + pdaNode, programLinkNode, programNode, rootNode, + sizePrefixTransformNode, + stringTypeNode, structFieldTypeNode, structTypeNode, + variablePdaSeedNode, } from '@codama/nodes'; import { visit } from '@codama/visitors-core'; import { expect, test } from 'vitest'; @@ -20,22 +28,32 @@ test('it unwraps defined types by following links', () => { const node = programNode({ accounts: [ accountNode({ - data: structTypeNode([structFieldTypeNode({ name: 'value', type: definedTypeLinkNode('myType') })]), - name: 'myAccount', + data: structTypeNode([ + structFieldTypeNode({ identifier: 'value', type: definedTypeLinkNode('myType') }), + ]), + identifier: 'myAccount', }), ], - definedTypes: [definedTypeNode({ name: 'myType', type: numberTypeNode('u64') })], - name: 'myProgram', + definedTypes: [definedTypeNode({ identifier: 'myType', type: integerTypeNode('u64') })], + identifier: 'myProgram', publicKey: '1111', }); // When we unwrap the defined types. const result = visit(node, unwrapDefinedTypesVisitor(['myType'])); - // Then we expect the following tree. - assertIsNode(result, 'programNode'); - expect((result.accounts ?? [])[0].data).toStrictEqual( - structTypeNode([structFieldTypeNode({ name: 'value', type: numberTypeNode('u64') })]), + // Then we expect the link to be replaced and the defined type to be removed. + expect(result).toStrictEqual( + programNode({ + accounts: [ + accountNode({ + data: structTypeNode([structFieldTypeNode({ identifier: 'value', type: integerTypeNode('u64') })]), + identifier: 'myAccount', + }), + ], + identifier: 'myProgram', + publicKey: '1111', + }), ); }); @@ -45,31 +63,30 @@ test('it follows linked nodes using the correct paths', () => { const programA = programNode({ definedTypes: [ definedTypeNode({ - name: 'typeA', - type: definedTypeLinkNode('typeB1', programLinkNode('programB')), + identifier: 'typeA', + type: definedTypeLinkNode('typeB1', { program: programLinkNode('programB') }), }), ], - name: 'programA', + identifier: 'programA', publicKey: '1111', }); const programB = programNode({ definedTypes: [ - definedTypeNode({ name: 'typeB1', type: definedTypeLinkNode('typeB2') }), - definedTypeNode({ name: 'typeB2', type: numberTypeNode('u64') }), + definedTypeNode({ identifier: 'typeB1', type: definedTypeLinkNode('typeB2') }), + definedTypeNode({ identifier: 'typeB2', type: integerTypeNode('u64') }), ], - name: 'programB', + identifier: 'programB', publicKey: '2222', }); - const root = rootNode(programA, [programB]); + const root = rootNode(programA, { additionalPrograms: [programB] }); // When we unwrap the defined types in programB. - const visitor = unwrapDefinedTypesVisitor(['typeB1', 'typeB2']); - const result = visit(root, visitor); + const result = visit(root, unwrapDefinedTypesVisitor(['typeB1', 'typeB2'])); // Then we expect the final linkable to be resolved in programA. assertIsNode(result, 'rootNode'); expect((result.program.definedTypes ?? [])[0]).toStrictEqual( - definedTypeNode({ name: 'typeA', type: numberTypeNode('u64') }), + definedTypeNode({ identifier: 'typeA', type: integerTypeNode('u64') }), ); }); @@ -77,37 +94,155 @@ test('it does not unwrap types from the wrong programs', () => { // Given a program node with a defined type used in another type. const programA = programNode({ definedTypes: [ - definedTypeNode({ name: 'myType', type: numberTypeNode('u8') }), - definedTypeNode({ name: 'myCopyType', type: definedTypeLinkNode('myType') }), + definedTypeNode({ identifier: 'myType', type: integerTypeNode('u8') }), + definedTypeNode({ identifier: 'myCopyType', type: definedTypeLinkNode('myType') }), ], - name: 'programA', + identifier: 'programA', publicKey: '1111', }); // And another program with a defined type sharing the same name. const programB = programNode({ definedTypes: [ - definedTypeNode({ name: 'myType', type: numberTypeNode('u16') }), - definedTypeNode({ name: 'myCopyType', type: definedTypeLinkNode('myType') }), + definedTypeNode({ identifier: 'myType', type: integerTypeNode('u16') }), + definedTypeNode({ identifier: 'myCopyType', type: definedTypeLinkNode('myType') }), ], - name: 'programB', + identifier: 'programB', publicKey: '2222', }); // When we unwrap the defined type from programA. - const node = rootNode(programA, [programB]); + const node = rootNode(programA, { additionalPrograms: [programB] }); const result = visit(node, unwrapDefinedTypesVisitor(['programA.myType'])); // Then we expect programA to have been modified but not programB. - assertIsNode(result, 'rootNode'); expect(result).toStrictEqual( rootNode( programNode({ - definedTypes: [definedTypeNode({ name: 'myCopyType', type: numberTypeNode('u8') })], - name: 'programA', + definedTypes: [definedTypeNode({ identifier: 'myCopyType', type: integerTypeNode('u8') })], + identifier: 'programA', publicKey: '1111', }), - [programB], + { additionalPrograms: [programB] }, ), ); }); + +test('it matches type identifiers exactly', () => { + // Given a snake_case defined type used by an account. + const node = programNode({ + accounts: [accountNode({ data: definedTypeLinkNode('my_type'), identifier: 'myAccount' })], + definedTypes: [definedTypeNode({ identifier: 'my_type', type: integerTypeNode('u64') })], + identifier: 'myProgram', + publicKey: '1111', + }); + + // When we unwrap it using a different casing, then nothing changes. + expect(visit(node, unwrapDefinedTypesVisitor(['myType']))).toStrictEqual(node); + + // When we unwrap it using its exact identifier, then it is inlined. + const result = visit(node, unwrapDefinedTypesVisitor(['my_type'])); + assertIsNode(result, 'programNode'); + expect(result.accounts?.[0].data).toStrictEqual(integerTypeNode('u64')); +}); + +test('it layers the transforms of the link on top of the inlined type', () => { + // Given a link with transforms pointing to a type that has its own transforms. + const node = programNode({ + accounts: [ + accountNode({ + data: definedTypeLinkNode('myString', { transforms: [fixedSizeTransformNode(64)] }), + identifier: 'myAccount', + }), + ], + definedTypes: [ + definedTypeNode({ + identifier: 'myString', + type: stringTypeNode('utf8', { transforms: [sizePrefixTransformNode(integerTypeNode('u32'))] }), + }), + ], + identifier: 'myProgram', + publicKey: '1111', + }); + + // When we unwrap the defined type. + const result = visit(node, unwrapDefinedTypesVisitor(['myString'])); + + // Then we expect the type's transforms to stay innermost and the link's to be appended. + assertIsNode(result, 'programNode'); + expect(result.accounts?.[0].data).toStrictEqual( + stringTypeNode('utf8', { + transforms: [sizePrefixTransformNode(integerTypeNode('u32')), fixedSizeTransformNode(64)], + }), + ); +}); + +test('it unwraps links inside PDAs, events and constants', () => { + // Given a defined type used by a PDA seed, an event and a constant. + const node = programNode({ + constants: [constantNode('myConstant', definedTypeLinkNode('myType'), integerValueNode('1'))], + definedTypes: [definedTypeNode({ identifier: 'myType', type: integerTypeNode('u16') })], + events: [eventNode({ data: definedTypeLinkNode('myType'), identifier: 'myEvent' })], + identifier: 'myProgram', + pdas: [pdaNode({ identifier: 'myPda', seeds: [variablePdaSeedNode('seed', definedTypeLinkNode('myType'))] })], + publicKey: '1111', + }); + + // When we unwrap the defined type. + const result = visit(node, unwrapDefinedTypesVisitor(['myType'])); + + // Then we expect every link to be replaced, leaving none dangling. + expect(result).toStrictEqual( + programNode({ + constants: [constantNode('myConstant', integerTypeNode('u16'), integerValueNode('1'))], + events: [eventNode({ data: integerTypeNode('u16'), identifier: 'myEvent' })], + identifier: 'myProgram', + pdas: [pdaNode({ identifier: 'myPda', seeds: [variablePdaSeedNode('seed', integerTypeNode('u16'))] })], + publicKey: '1111', + }), + ); +}); + +test('it qualifies links inside types inlined into another program', () => { + // Given programA using a type from programB that links to another type of programB. + const programA = programNode({ + accounts: [ + accountNode({ + data: definedTypeLinkNode('wrapper', { program: programLinkNode('programB') }), + identifier: 'myAccount', + }), + ], + identifier: 'programA', + publicKey: '1111', + }); + const programB = programNode({ + definedTypes: [ + definedTypeNode({ + identifier: 'wrapper', + type: structTypeNode([ + structFieldTypeNode({ identifier: 'inner', type: definedTypeLinkNode('inner') }), + ]), + }), + definedTypeNode({ identifier: 'inner', type: integerTypeNode('u8') }), + ], + identifier: 'programB', + publicKey: '2222', + }); + + // When we only unwrap the outer type. + const result = visit( + rootNode(programA, { additionalPrograms: [programB] }), + unwrapDefinedTypesVisitor(['programB.wrapper']), + ); + + // Then the inner link now explicitly points to programB. + assertIsNode(result, 'rootNode'); + expect(result.program.accounts?.[0].data).toStrictEqual( + structTypeNode([ + structFieldTypeNode({ + identifier: 'inner', + type: definedTypeLinkNode('inner', { program: programLinkNode('programB') }), + }), + ]), + ); +}); diff --git a/packages/visitors/test/unwrapInstructionArgsDefinedTypesVisitor.test.ts b/packages/visitors/test/unwrapInstructionArgsDefinedTypesVisitor.test.ts deleted file mode 100644 index 38d884f25..000000000 --- a/packages/visitors/test/unwrapInstructionArgsDefinedTypesVisitor.test.ts +++ /dev/null @@ -1,183 +0,0 @@ -import { - arrayTypeNode, - definedTypeLinkNode, - definedTypeNode, - fixedCountNode, - instructionArgumentNode, - instructionNode, - numberTypeNode, - programNode, - rootNode, - structFieldTypeNode, - structTypeNode, -} from '@codama/nodes'; -import { visit } from '@codama/visitors-core'; -import { expect, test } from 'vitest'; - -import { unwrapInstructionArgsDefinedTypesVisitor } from '../src'; - -test('it unwraps defined type link nodes used as instruction arguments', () => { - // Given a program with a type used only once as a direct instruction argument. - const node = rootNode( - programNode({ - definedTypes: [ - definedTypeNode({ - name: 'typeA', - type: structTypeNode([structFieldTypeNode({ name: 'foo', type: numberTypeNode('u8') })]), - }), - definedTypeNode({ - name: 'typeB', - type: structTypeNode([structFieldTypeNode({ name: 'bar', type: numberTypeNode('u8') })]), - }), - ], - instructions: [ - instructionNode({ - arguments: [instructionArgumentNode({ name: 'argA', type: definedTypeLinkNode('typeA') })], - name: 'myInstruction', - }), - ], - name: 'MyProgram', - publicKey: '1111', - }), - ); - - // When the defined type link nodes are unwrapped. - const result = visit(node, unwrapInstructionArgsDefinedTypesVisitor()); - - // Then we expect the following node. - expect(result).toStrictEqual( - rootNode( - programNode({ - definedTypes: [ - definedTypeNode({ - name: 'typeB', - type: structTypeNode([structFieldTypeNode({ name: 'bar', type: numberTypeNode('u8') })]), - }), - ], - instructions: [ - instructionNode({ - arguments: [ - instructionArgumentNode({ - name: 'argA', - type: structTypeNode([ - structFieldTypeNode({ name: 'foo', type: numberTypeNode('u8') }), - ]), - }), - ], - name: 'myInstruction', - }), - ], - name: 'MyProgram', - publicKey: '1111', - }), - ), - ); -}); - -test('it does not unwrap defined type link nodes that are used in more than one place.', () => { - // Given a link node used in an instruction argument and in another place. - const node = rootNode( - programNode({ - definedTypes: [ - definedTypeNode({ - name: 'myType', - type: structTypeNode([structFieldTypeNode({ name: 'foo', type: numberTypeNode('u8') })]), - }), - definedTypeNode({ name: 'myCopyType', type: definedTypeLinkNode('myType') }), - ], - instructions: [ - instructionNode({ - arguments: [instructionArgumentNode({ name: 'myArg', type: definedTypeLinkNode('myType') })], - name: 'myInstruction', - }), - ], - name: 'MyProgram', - publicKey: '1111', - }), - ); - - // When we try to unwrap defined type link nodes for instruction arguments. - const result = visit(node, unwrapInstructionArgsDefinedTypesVisitor()); - - // Then we expect the same node. - expect(result).toStrictEqual(node); -}); - -test('it only unwraps defined type link nodes if they are direct instruction arguments', () => { - // Given a link node used in an instruction argument but not as a direct argument. - const node = rootNode( - programNode({ - definedTypes: [ - definedTypeNode({ - name: 'myType', - type: structTypeNode([structFieldTypeNode({ name: 'foo', type: numberTypeNode('u8') })]), - }), - ], - instructions: [ - instructionNode({ - arguments: [ - instructionArgumentNode({ - name: 'myArg', - type: arrayTypeNode(definedTypeLinkNode('myType'), fixedCountNode(3)), - }), - ], - name: 'myInstruction', - }), - ], - name: 'MyProgram', - publicKey: '1111', - }), - ); - - // When we try to unwrap defined type link nodes for instruction arguments. - const result = visit(node, unwrapInstructionArgsDefinedTypesVisitor()); - - // Then we expect the same node. - expect(result).toStrictEqual(node); -}); - -test('it does not unwrap defined type link nodes from other programs', () => { - // Given a program that defines the - const programA = programNode({ - definedTypes: [definedTypeNode({ name: 'myType', type: numberTypeNode('u8') })], - instructions: [ - instructionNode({ - arguments: [instructionArgumentNode({ name: 'myArg', type: definedTypeLinkNode('myType') })], - name: 'myInstruction', - }), - ], - name: 'programA', - publicKey: '1111', - }); - - // And another program with a defined type sharing the same name. - const programB = programNode({ - definedTypes: [ - definedTypeNode({ name: 'myType', type: numberTypeNode('u16') }), - definedTypeNode({ name: 'myCopyType', type: definedTypeLinkNode('myType') }), - ], - name: 'programB', - publicKey: '2222', - }); - - // When we unwrap defined type link nodes for instruction arguments for both of them. - const node = rootNode(programA, [programB]); - const result = visit(node, unwrapInstructionArgsDefinedTypesVisitor()); - - // Then we expect program A to have been modified but not program B. - expect(result).toStrictEqual( - rootNode( - programNode({ - instructions: [ - instructionNode({ - arguments: [instructionArgumentNode({ name: 'myArg', type: numberTypeNode('u8') })], - name: 'myInstruction', - }), - ], - name: 'programA', - publicKey: '1111', - }), - [programB], - ), - ); -}); diff --git a/packages/visitors/test/unwrapInstructionDataDefinedTypesVisitor.test.ts b/packages/visitors/test/unwrapInstructionDataDefinedTypesVisitor.test.ts new file mode 100644 index 000000000..656726f13 --- /dev/null +++ b/packages/visitors/test/unwrapInstructionDataDefinedTypesVisitor.test.ts @@ -0,0 +1,227 @@ +import { + arrayTypeNode, + definedTypeLinkNode, + definedTypeNode, + enumTypeNode, + enumVariantTypeNode, + fixedCountNode, + instructionNode, + integerTypeNode, + programNode, + rootNode, + structFieldTypeNode, + structTypeNode, +} from '@codama/nodes'; +import { visit } from '@codama/visitors-core'; +import { expect, test } from 'vitest'; + +import { unwrapInstructionDataDefinedTypesVisitor } from '../src'; + +const structA = structTypeNode([structFieldTypeNode({ identifier: 'foo', type: integerTypeNode('u8') })]); +const structB = structTypeNode([structFieldTypeNode({ identifier: 'bar', type: integerTypeNode('u8') })]); + +test('it unwraps defined types used once as a top-level instruction data field', () => { + // Given a program with a type used only once as the type of a top-level data field. + const node = rootNode( + programNode({ + definedTypes: [ + definedTypeNode({ identifier: 'typeA', type: structA }), + definedTypeNode({ identifier: 'typeB', type: structB }), + ], + identifier: 'MyProgram', + instructions: [ + instructionNode({ + data: structTypeNode([ + structFieldTypeNode({ identifier: 'argA', type: definedTypeLinkNode('typeA') }), + ]), + identifier: 'myInstruction', + }), + ], + publicKey: '1111', + }), + ); + + // When the defined type link nodes are unwrapped. + const result = visit(node, unwrapInstructionDataDefinedTypesVisitor()); + + // Then we expect the following node. + expect(result).toStrictEqual( + rootNode( + programNode({ + definedTypes: [definedTypeNode({ identifier: 'typeB', type: structB })], + identifier: 'MyProgram', + instructions: [ + instructionNode({ + data: structTypeNode([structFieldTypeNode({ identifier: 'argA', type: structA })]), + identifier: 'myInstruction', + }), + ], + publicKey: '1111', + }), + ), + ); +}); + +test('it unwraps defined types used once as the instruction data itself', () => { + // Given an instruction whose data is a link to a type used nowhere else. + const node = rootNode( + programNode({ + definedTypes: [definedTypeNode({ identifier: 'myInstructionArgs', type: structA })], + identifier: 'MyProgram', + instructions: [ + instructionNode({ data: definedTypeLinkNode('myInstructionArgs'), identifier: 'myInstruction' }), + ], + publicKey: '1111', + }), + ); + + // When the defined type link nodes are unwrapped. + const result = visit(node, unwrapInstructionDataDefinedTypesVisitor()); + + // Then we expect the data to be inlined. + expect(result).toStrictEqual( + rootNode( + programNode({ + identifier: 'MyProgram', + instructions: [instructionNode({ data: structA, identifier: 'myInstruction' })], + publicKey: '1111', + }), + ), + ); +}); + +test('it does not unwrap defined type link nodes that are used in more than one place', () => { + // Given a link node used in an instruction data field and in another place. + const node = rootNode( + programNode({ + definedTypes: [ + definedTypeNode({ identifier: 'myType', type: structA }), + definedTypeNode({ identifier: 'myCopyType', type: definedTypeLinkNode('myType') }), + ], + identifier: 'MyProgram', + instructions: [ + instructionNode({ + data: structTypeNode([ + structFieldTypeNode({ identifier: 'myArg', type: definedTypeLinkNode('myType') }), + ]), + identifier: 'myInstruction', + }), + ], + publicKey: '1111', + }), + ); + + // When we try to unwrap defined type link nodes for instruction data. + const result = visit(node, unwrapInstructionDataDefinedTypesVisitor()); + + // Then we expect the same node. + expect(result).toStrictEqual(node); +}); + +test('it only unwraps defined type link nodes if they are used directly', () => { + // Given a link node nested inside an array in the instruction data. + const node = rootNode( + programNode({ + definedTypes: [definedTypeNode({ identifier: 'myType', type: structA })], + identifier: 'MyProgram', + instructions: [ + instructionNode({ + data: structTypeNode([ + structFieldTypeNode({ + identifier: 'myArg', + type: arrayTypeNode(definedTypeLinkNode('myType'), fixedCountNode(3)), + }), + ]), + identifier: 'myInstruction', + }), + ], + publicKey: '1111', + }), + ); + + // When we try to unwrap defined type link nodes for instruction data. + const result = visit(node, unwrapInstructionDataDefinedTypesVisitor()); + + // Then we expect the same node. + expect(result).toStrictEqual(node); +}); + +test('it does not unwrap enums', () => { + // Given an enum used once as a top-level data field. + const node = rootNode( + programNode({ + definedTypes: [ + definedTypeNode({ + identifier: 'myEnum', + type: enumTypeNode([enumVariantTypeNode('a'), enumVariantTypeNode('b')]), + }), + ], + identifier: 'MyProgram', + instructions: [ + instructionNode({ + data: structTypeNode([ + structFieldTypeNode({ identifier: 'kind', type: definedTypeLinkNode('myEnum') }), + ]), + identifier: 'myInstruction', + }), + ], + publicKey: '1111', + }), + ); + + // When we try to unwrap defined type link nodes for instruction data. + const result = visit(node, unwrapInstructionDataDefinedTypesVisitor()); + + // Then we expect the same node. + expect(result).toStrictEqual(node); +}); + +test('it does not unwrap defined type link nodes from other programs', () => { + // Given a program with a type used once as instruction data. + const programA = programNode({ + definedTypes: [definedTypeNode({ identifier: 'myType', type: integerTypeNode('u8') })], + identifier: 'programA', + instructions: [ + instructionNode({ + data: structTypeNode([ + structFieldTypeNode({ identifier: 'myArg', type: definedTypeLinkNode('myType') }), + ]), + identifier: 'myInstruction', + }), + ], + publicKey: '1111', + }); + + // And another program with a defined type sharing the same name. + const programB = programNode({ + definedTypes: [ + definedTypeNode({ identifier: 'myType', type: integerTypeNode('u16') }), + definedTypeNode({ identifier: 'myCopyType', type: definedTypeLinkNode('myType') }), + ], + identifier: 'programB', + publicKey: '2222', + }); + + // When we unwrap defined type link nodes for instruction data for both of them. + const node = rootNode(programA, { additionalPrograms: [programB] }); + const result = visit(node, unwrapInstructionDataDefinedTypesVisitor()); + + // Then we expect program A to have been modified but not program B. + expect(result).toStrictEqual( + rootNode( + programNode({ + identifier: 'programA', + instructions: [ + instructionNode({ + data: structTypeNode([ + structFieldTypeNode({ identifier: 'myArg', type: integerTypeNode('u8') }), + ]), + identifier: 'myInstruction', + }), + ], + publicKey: '1111', + }), + { additionalPrograms: [programB] }, + ), + ); +}); diff --git a/packages/visitors/test/unwrapTupleEnumWithSingleStructVisitor.test.ts b/packages/visitors/test/unwrapTupleEnumWithSingleStructVisitor.test.ts new file mode 100644 index 000000000..50874a11e --- /dev/null +++ b/packages/visitors/test/unwrapTupleEnumWithSingleStructVisitor.test.ts @@ -0,0 +1,124 @@ +import { + definedTypeLinkNode, + DefinedTypeNode, + definedTypeNode, + enumTypeNode, + EnumVariantTypeNode, + enumVariantTypeNode, + fixedSizeTransformNode, + integerTypeNode, + programNode, + rootNode, + structFieldTypeNode, + structTypeNode, + tupleTypeNode, +} from '@codama/nodes'; +import { visit } from '@codama/visitors-core'; +import { expect, test } from 'vitest'; + +import { unwrapTupleEnumWithSingleStructVisitor } from '../src'; + +const struct = structTypeNode([structFieldTypeNode({ identifier: 'x', type: integerTypeNode('u32') })]); + +const programWithEnum = (variants: EnumVariantTypeNode[], extraTypes: DefinedTypeNode[] = []) => + rootNode( + programNode({ + definedTypes: [definedTypeNode({ identifier: 'myEnum', type: enumTypeNode(variants) }), ...extraTypes], + identifier: 'myProgram', + publicKey: '1111', + }), + ); + +test('it unwraps tuple variants containing a single struct', () => { + // Given an enum variant whose data is a tuple with a single struct. + const node = programWithEnum([enumVariantTypeNode('move', { data: tupleTypeNode([struct]) })]); + + // When we unwrap tuple enums with a single struct. + const result = visit(node, unwrapTupleEnumWithSingleStructVisitor()); + + // Then the variant's data is the struct itself. + expect(result).toStrictEqual(programWithEnum([enumVariantTypeNode('move', { data: struct })])); +}); + +test('it keeps the discriminator of the variant and the transforms of the tuple', () => { + // Given a variant with an explicit discriminator and a tuple with transforms. + const node = programWithEnum([ + enumVariantTypeNode('move', { + data: tupleTypeNode([struct], { transforms: [fixedSizeTransformNode(8)] }), + discriminator: 5, + }), + ]); + + // When we unwrap it. + const result = visit(node, unwrapTupleEnumWithSingleStructVisitor()); + + // Then both are preserved. + expect(result).toStrictEqual( + programWithEnum([ + enumVariantTypeNode('move', { + data: structTypeNode(struct.fields, { transforms: [fixedSizeTransformNode(8)] }), + discriminator: 5, + }), + ]), + ); +}); + +test('it unwraps links to structs and removes the unused defined types', () => { + // Given a variant whose tuple links to a struct type used nowhere else. + const node = programWithEnum( + [enumVariantTypeNode('move', { data: tupleTypeNode([definedTypeLinkNode('moveArgs')]) })], + [definedTypeNode({ identifier: 'moveArgs', type: struct })], + ); + + // When we unwrap it. + const result = visit(node, unwrapTupleEnumWithSingleStructVisitor()); + + // Then the struct is inlined and the defined type removed. + expect(result).toStrictEqual(programWithEnum([enumVariantTypeNode('move', { data: struct })])); +}); + +test('it keeps linked defined types that are still used elsewhere', () => { + // Given a variant whose tuple links to a struct type that is also used by another type. + const moveArgs = definedTypeNode({ identifier: 'moveArgs', type: struct }); + const alias = definedTypeNode({ identifier: 'alias', type: definedTypeLinkNode('moveArgs') }); + const node = programWithEnum( + [enumVariantTypeNode('move', { data: tupleTypeNode([definedTypeLinkNode('moveArgs')]) })], + [moveArgs, alias], + ); + + // When we unwrap it. + const result = visit(node, unwrapTupleEnumWithSingleStructVisitor()); + + // Then the struct is inlined in the variant but the defined type is kept. + expect(result).toStrictEqual(programWithEnum([enumVariantTypeNode('move', { data: struct })], [moveArgs, alias])); +}); + +test('it only unwraps the selected variants', () => { + // Given two variants that could be unwrapped. + const node = programWithEnum([ + enumVariantTypeNode('move', { data: tupleTypeNode([struct]) }), + enumVariantTypeNode('jump', { data: tupleTypeNode([struct]) }), + ]); + + // When we only select one of them. + const result = visit(node, unwrapTupleEnumWithSingleStructVisitor(['myEnum.move'])); + + // Then only that one is unwrapped. + expect(result).toStrictEqual( + programWithEnum([ + enumVariantTypeNode('move', { data: struct }), + enumVariantTypeNode('jump', { data: tupleTypeNode([struct]) }), + ]), + ); +}); + +test('it ignores tuples that do not contain exactly one struct', () => { + // Given variants with a two-item tuple and a single non-struct item. + const node = programWithEnum([ + enumVariantTypeNode('pair', { data: tupleTypeNode([struct, struct]) }), + enumVariantTypeNode('number', { data: tupleTypeNode([integerTypeNode('u8')]) }), + ]); + + // When we unwrap them, then nothing changes. + expect(visit(node, unwrapTupleEnumWithSingleStructVisitor())).toStrictEqual(node); +}); diff --git a/packages/visitors/test/unwrapTypeDefinedLinksVisitor.test.ts b/packages/visitors/test/unwrapTypeDefinedLinksVisitor.test.ts new file mode 100644 index 000000000..123aa5bf1 --- /dev/null +++ b/packages/visitors/test/unwrapTypeDefinedLinksVisitor.test.ts @@ -0,0 +1,111 @@ +import { + accountNode, + assertIsNode, + definedTypeLinkNode, + definedTypeNode, + fixedSizeTransformNode, + integerTypeNode, + programLinkNode, + programNode, + rootNode, + structFieldTypeNode, + structTypeNode, +} from '@codama/nodes'; +import { visit } from '@codama/visitors-core'; +import { expect, test } from 'vitest'; + +import { unwrapTypeDefinedLinksVisitor } from '../src'; + +test('it replaces the selected links with their types and keeps the defined types', () => { + // Given an account using a defined type. + const definedType = definedTypeNode({ identifier: 'myType', type: integerTypeNode('u64') }); + const node = programNode({ + accounts: [ + accountNode({ + data: structTypeNode([ + structFieldTypeNode({ identifier: 'value', type: definedTypeLinkNode('myType') }), + ]), + identifier: 'myAccount', + }), + ], + definedTypes: [definedType], + identifier: 'myProgram', + publicKey: '1111', + }); + + // When we unwrap the links inside the account. + const result = visit(node, unwrapTypeDefinedLinksVisitor(['myAccount.value.[definedTypeLinkNode]myType'])); + + // Then the link is replaced and the defined type is kept. + expect(result).toStrictEqual( + programNode({ + accounts: [ + accountNode({ + data: structTypeNode([structFieldTypeNode({ identifier: 'value', type: integerTypeNode('u64') })]), + identifier: 'myAccount', + }), + ], + definedTypes: [definedType], + identifier: 'myProgram', + publicKey: '1111', + }), + ); +}); + +test('it layers the transforms of the link on top of the inlined type', () => { + // Given a link with transforms. + const node = programNode({ + accounts: [ + accountNode({ + data: definedTypeLinkNode('myType', { transforms: [fixedSizeTransformNode(16)] }), + identifier: 'myAccount', + }), + ], + definedTypes: [definedTypeNode({ identifier: 'myType', type: integerTypeNode('u64') })], + identifier: 'myProgram', + publicKey: '1111', + }); + + // When we unwrap it. + const result = visit(node, unwrapTypeDefinedLinksVisitor(['myType'])); + + // Then the link's transforms are carried over. + assertIsNode(result, 'programNode'); + expect(result.accounts?.[0].data).toStrictEqual( + integerTypeNode('u64', { transforms: [fixedSizeTransformNode(16)] }), + ); +}); + +test('it qualifies links inside types inlined into another program', () => { + // Given programA linking to a type of programB that links to another type of programB. + const programA = programNode({ + accounts: [ + accountNode({ + data: definedTypeLinkNode('wrapper', { program: programLinkNode('programB') }), + identifier: 'myAccount', + }), + ], + identifier: 'programA', + publicKey: '1111', + }); + const programB = programNode({ + definedTypes: [ + definedTypeNode({ identifier: 'wrapper', type: definedTypeLinkNode('inner') }), + definedTypeNode({ identifier: 'inner', type: integerTypeNode('u8') }), + ], + identifier: 'programB', + publicKey: '2222', + }); + + // When we unwrap the link in programA. + const result = visit( + rootNode(programA, { additionalPrograms: [programB] }), + unwrapTypeDefinedLinksVisitor(['myAccount.[definedTypeLinkNode]wrapper']), + ); + + // Then the inner link now explicitly points to programB. + assertIsNode(result, 'rootNode'); + expect(result.program.accounts?.[0].data).toStrictEqual( + definedTypeLinkNode('inner', { program: programLinkNode('programB') }), + ); +});