diff --git a/src/fragments/constantPage.ts b/src/fragments/constantPage.ts new file mode 100644 index 0000000..22aa2d4 --- /dev/null +++ b/src/fragments/constantPage.ts @@ -0,0 +1,42 @@ +import { ConstantNode, isNode } from '@codama/nodes'; +import { visit } from '@codama/visitors-core'; + +import { Fragment, fragment, getDocblockFragment, mergeFragments, RenderScope } from '../utils'; + +/** + * Renders program constants as TypeScript value declarations. + * + * @param scope - The rendering scope and constants to render. + * @returns The generated constants page fragment, if constants exist. + */ +export function getConstantsPageFragment( + scope: Pick & { nodes: ConstantNode[] }, +): Fragment | undefined { + if (scope.nodes.length === 0) return; + + return mergeFragments( + [...scope.nodes].sort((a, b) => a.name.localeCompare(b.name)).map(node => getConstantFragment(node, scope)), + constants => constants.join('\n\n'), + ); +} + +function getConstantFragment( + node: ConstantNode, + scope: Pick, +): Fragment { + const typeManifest = visit(node.type, scope.typeManifestVisitor); + const rawValue = visit(node.value, scope.typeManifestVisitor).value; + const isNumberValue = isNode(node.value, 'numberValueNode'); + const isNumberType = isNode(node.type, 'numberTypeNode'); + const isSafeNumberType = isNumberType && ['u8', 'u16', 'u32'].includes(node.type.format); + const useBigInt = isNumberValue && isNumberType && !isSafeNumberType; + const value = useBigInt ? fragment`${rawValue}n` : rawValue; + const valueType = isNode(node.value, 'stringValueNode') + ? fragment`string` + : useBigInt + ? fragment`bigint` + : typeManifest.strictType; + const docs = getDocblockFragment(node.docs ?? [], true); + + return fragment`${docs}export const ${scope.nameApi.constant(node.name)}: ${valueType} = ${value};`; +} diff --git a/src/fragments/index.ts b/src/fragments/index.ts index 94de96e..fc10a2c 100644 --- a/src/fragments/index.ts +++ b/src/fragments/index.ts @@ -3,6 +3,7 @@ export * from './accountPage'; export * from './accountPdaHelpers'; export * from './accountSizeHelpers'; export * from './accountType'; +export * from './constantPage'; export * from './discriminatorCondition'; export * from './discriminatorConstants'; export * from './errorPage'; diff --git a/src/fragments/rootIndexPage.ts b/src/fragments/rootIndexPage.ts index d7052db..d4b528b 100644 --- a/src/fragments/rootIndexPage.ts +++ b/src/fragments/rootIndexPage.ts @@ -1,9 +1,10 @@ -import { AccountNode, DefinedTypeNode, InstructionNode, PdaNode, ProgramNode } from '@codama/nodes'; +import { AccountNode, ConstantNode, DefinedTypeNode, InstructionNode, PdaNode, ProgramNode } from '@codama/nodes'; import { Fragment, fragment, getExportAllFragment, mergeFragments } from '../utils'; export function getRootIndexPageFragment(scope: { accountsToExport: AccountNode[]; + constantsToExport: ConstantNode[]; definedTypesToExport: DefinedTypeNode[]; instructionsToExport: InstructionNode[]; pdasToExport: PdaNode[]; @@ -12,6 +13,7 @@ export function getRootIndexPageFragment(scope: { const hasAnythingToExport = scope.programsToExport.length > 0 || scope.accountsToExport.length > 0 || + scope.constantsToExport.length > 0 || scope.instructionsToExport.length > 0 || scope.definedTypesToExport.length > 0; @@ -24,6 +26,7 @@ export function getRootIndexPageFragment(scope: { return mergeFragments( [ scope.accountsToExport.length > 0 ? getExportAllFragment('./accounts') : undefined, + scope.constantsToExport.length > 0 ? getExportAllFragment('./constants') : undefined, programsWithErrorsToExport.length > 0 ? getExportAllFragment('./errors') : undefined, scope.instructionsToExport.length > 0 ? getExportAllFragment('./instructions') : undefined, scope.pdasToExport.length > 0 ? getExportAllFragment('./pdas') : undefined, diff --git a/src/visitors/getRenderMapVisitor.ts b/src/visitors/getRenderMapVisitor.ts index 331c115..366acf1 100644 --- a/src/visitors/getRenderMapVisitor.ts +++ b/src/visitors/getRenderMapVisitor.ts @@ -2,6 +2,7 @@ import { camelCase, CamelCaseString, getAllAccounts, + getAllConstants, getAllDefinedTypes, getAllInstructionsWithSubs, getAllPdas, @@ -24,6 +25,7 @@ import { import { getAccountPageFragment, + getConstantsPageFragment, getErrorPageFragment, getIndexPageFragment, getInstructionPageFragment, @@ -166,6 +168,7 @@ export function getRenderMapVisitor( const programsWithErrorsToExport = programsToExport.filter(p => (p.errors ?? []).length > 0); const pdasToExport = getAllPdas(node); const accountsToExport = getAllAccounts(node).filter(isNotInternal); + const constantsToExport = getAllConstants(node).filter(isNotInternal); const instructionsToExport = getAllInstructionsWithSubs(node, { leavesOnly: !renderScope.renderParentInstructions, }).filter(isNotInternal); @@ -174,6 +177,7 @@ export function getRenderMapVisitor( const scope = { ...renderScope, accountsToExport, + constantsToExport, definedTypesToExport, instructionsToExport, pdasToExport, @@ -183,6 +187,10 @@ export function getRenderMapVisitor( return mergeRenderMaps([ createRenderMap({ ['accounts/index.ts']: asPage(getIndexPageFragment(accountsToExport)), + ['constants.ts']: asPage( + getConstantsPageFragment({ ...renderScope, nodes: constantsToExport }), + { generatedTypes: './types' }, + ), ['errors/index.ts']: asPage(getIndexPageFragment(programsWithErrorsToExport)), ['index.ts']: asPage(getRootIndexPageFragment(scope)), ['instructions/index.ts']: asPage(getIndexPageFragment(instructionsToExport)), diff --git a/test/constantsPage.test.ts b/test/constantsPage.test.ts new file mode 100644 index 0000000..17d9da1 --- /dev/null +++ b/test/constantsPage.test.ts @@ -0,0 +1,76 @@ +import { + constantNode, + definedTypeLinkNode, + definedTypeNode, + numberTypeNode, + numberValueNode, + programNode, + rootNode, + stringValueNode, +} from '@codama/nodes'; +import { visit } from '@codama/visitors-core'; +import { expect, test } from 'vitest'; + +import { getRenderMapVisitor } from '../src'; +import { renderMapContains, renderMapContainsImports, renderMapDoesNotContainImports } from './_setup'; + +test('it renders program constants in a top-level constants page', async () => { + const node = rootNode( + programNode({ + constants: [ + constantNode('maxOption', numberTypeNode('u8'), numberValueNode(10), ['Maximum options.']), + constantNode('minOption', numberTypeNode('u8'), numberValueNode(1)), + constantNode('signedOption', numberTypeNode('i32'), numberValueNode(2)), + ], + name: 'governance', + publicKey: 'GovER5Lthms3bLBqWub97yVrQm9WLZ7YgRrxYQYy2P', + }), + ); + + const renderMap = visit(node, getRenderMapVisitor()); + + await renderMapContains(renderMap, 'constants.ts', [ + '/** Maximum options. */', + 'export const MAX_OPTION: number = 10;', + 'export const MIN_OPTION: number = 1;', + 'export const SIGNED_OPTION: bigint = 2n;', + ]); + await renderMapContains(renderMap, 'index.ts', "export * from './constants';"); + + expect(renderMap.has('constants/index.ts')).toBe(false); + expect(renderMap.size).toBeGreaterThan(0); +}); + +test('it imports linked types from the top-level types directory', async () => { + const node = rootNode( + programNode({ + constants: [constantNode('optionCount', definedTypeLinkNode('optionCountType'), numberValueNode(10))], + definedTypes: [definedTypeNode({ name: 'optionCountType', type: numberTypeNode('u8') })], + name: 'governance', + publicKey: 'GovER5Lthms3bLBqWub97yVrQm9WLZ7YgRrxYQYy2P', + }), + ); + + const renderMap = visit(node, getRenderMapVisitor()); + + await renderMapContainsImports(renderMap, 'constants.ts', { + './types': ['OptionCountType'], + }); +}); + +test('it renders string constants without importing their declared type', async () => { + const node = rootNode( + programNode({ + constants: [constantNode('abstainVoteIndex', definedTypeLinkNode('usize'), stringValueNode('0'))], + name: 'governance', + publicKey: 'GovER5Lthms3bLBqWub97yVrQm9WLZ7YgRrxYQYy2P', + }), + ); + + const renderMap = visit(node, getRenderMapVisitor()); + + await renderMapContains(renderMap, 'constants.ts', "export const ABSTAIN_VOTE_INDEX: string = '0';"); + await renderMapDoesNotContainImports(renderMap, 'constants.ts', { + './types': ['Usize'], + }); +});