diff --git a/CHANGELOG.md b/CHANGELOG.md index 13afe7e3f..22c3e306b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ A breaking change will get clearly marked in this log. +## Unreleased + +### Changed +- Generated bindings handle module-qualified user-defined type names. Upcoming versions of `rs-soroban-sdk` name each `#[contracttype]` by its full Rust path ([rs-soroban-sdk#1970](https://github.com/stellar/rs-soroban-sdk/pull/1970)), so a spec entry reads `token::storage::Balance` rather than `Balance`. `types.ts` now declares every type under its whole spec name (`export interface token_storage_Balance`) and additionally exports it under its bare type name (`export type Balance = token_storage_Balance`), so the fully-qualified name is always available while existing code keeps importing and referencing `Balance` as it does today. Enums and error enums are aliased as values (`export { token_DataKey as DataKey }`) so their members stay reachable through either name. Generated signatures and imports use the bare alias where there is one. A bare name is only skipped when the spec makes it ambiguous — several types share it, or another type is declared under it outright — and the declaration's doc comment says so. Resolution is deterministic, so `types.ts` and `client.ts` always agree. Specs with unqualified names generate exactly as before. +- `contract.Spec.findEntry` accepts a bare type name for a module-qualified entry (`findEntry("Balance")` finds `token::storage::Balance`), and throws `ambiguous entry: …` when more than one entry matches. + ## [v16.2.0](https://github.com/stellar/js-stellar-sdk/compare/v16.1.0...v16.2.0) ### Added diff --git a/docs/reference/contracts-client.md b/docs/reference/contracts-client.md index de2a0b0a5..5b507875e 100644 --- a/docs/reference/contracts-client.md +++ b/docs/reference/contracts-client.md @@ -1168,7 +1168,7 @@ errorCases(): ScSpecUdtErrorEnumCaseV0[]; all contract functions -**Source:** [src/contract/spec.ts:1209](https://github.com/stellar/js-stellar-sdk/blob/main/src/contract/spec.ts#L1209) +**Source:** [src/contract/spec.ts:1229](https://github.com/stellar/js-stellar-sdk/blob/main/src/contract/spec.ts#L1229) ### `spec.events()` @@ -1182,7 +1182,7 @@ events(): ScSpecEventV0[]; all contract events -**Source:** [src/contract/spec.ts:1224](https://github.com/stellar/js-stellar-sdk/blob/main/src/contract/spec.ts#L1224) +**Source:** [src/contract/spec.ts:1244](https://github.com/stellar/js-stellar-sdk/blob/main/src/contract/spec.ts#L1244) ### `spec.eventTopicFilter(name, topicValues, occurrence)` @@ -1219,12 +1219,16 @@ a single topic filter row const topics = contractSpec.eventTopicFilter('transfer', { to: someAddress }); ``` -**Source:** [src/contract/spec.ts:1312](https://github.com/stellar/js-stellar-sdk/blob/main/src/contract/spec.ts#L1312) +**Source:** [src/contract/spec.ts:1332](https://github.com/stellar/js-stellar-sdk/blob/main/src/contract/spec.ts#L1332) ### `spec.findEntry(name)` Finds the XDR spec entry for the given name. +User-defined type names are qualified with the Rust module path they are +declared in (e.g. `token::Balance`), so a bare type name is also accepted +when exactly one entry in the spec ends with it. + ```ts findEntry(name: string): ScSpecEntry; ``` @@ -1239,9 +1243,10 @@ the entry **Throws** -- if no entry with the given name exists +- if no entry with the given name exists, or if a bare type name + matches more than one module-qualified entry -**Source:** [src/contract/spec.ts:658](https://github.com/stellar/js-stellar-sdk/blob/main/src/contract/spec.ts#L658) +**Source:** [src/contract/spec.ts:663](https://github.com/stellar/js-stellar-sdk/blob/main/src/contract/spec.ts#L663) ### `spec.findEvent(name, occurrence)` @@ -1279,7 +1284,7 @@ if (contractSpec.findEvent("transfer")) { } ``` -**Source:** [src/contract/spec.ts:1251](https://github.com/stellar/js-stellar-sdk/blob/main/src/contract/spec.ts#L1251) +**Source:** [src/contract/spec.ts:1271](https://github.com/stellar/js-stellar-sdk/blob/main/src/contract/spec.ts#L1271) ### `spec.funcArgsToScVals(name, args)` @@ -1402,7 +1407,7 @@ the converted JSON schema - if the contract spec is invalid -**Source:** [src/contract/spec.ts:1336](https://github.com/stellar/js-stellar-sdk/blob/main/src/contract/spec.ts#L1336) +**Source:** [src/contract/spec.ts:1356](https://github.com/stellar/js-stellar-sdk/blob/main/src/contract/spec.ts#L1356) ### `spec.nativeToScVal(val, ty)` @@ -1425,7 +1430,7 @@ the converted ScVal - if value cannot be converted to the given type -**Source:** [src/contract/spec.ts:677](https://github.com/stellar/js-stellar-sdk/blob/main/src/contract/spec.ts#L677) +**Source:** [src/contract/spec.ts:697](https://github.com/stellar/js-stellar-sdk/blob/main/src/contract/spec.ts#L697) ### `spec.parseEvent(topics, data)` @@ -1466,7 +1471,7 @@ if (parsed) { } ``` -**Source:** [src/contract/spec.ts:1283](https://github.com/stellar/js-stellar-sdk/blob/main/src/contract/spec.ts#L1283) +**Source:** [src/contract/spec.ts:1303](https://github.com/stellar/js-stellar-sdk/blob/main/src/contract/spec.ts#L1303) ### `spec.scValStrToNative(scv, typeDef)` @@ -1489,7 +1494,7 @@ the converted native JS value - if ScVal cannot be converted to the given type -**Source:** [src/contract/spec.ts:994](https://github.com/stellar/js-stellar-sdk/blob/main/src/contract/spec.ts#L994) +**Source:** [src/contract/spec.ts:1014](https://github.com/stellar/js-stellar-sdk/blob/main/src/contract/spec.ts#L1014) ### `spec.scValToNative(scv, typeDef)` @@ -1512,7 +1517,7 @@ the converted native JS value - if ScVal cannot be converted to the given type -**Source:** [src/contract/spec.ts:1007](https://github.com/stellar/js-stellar-sdk/blob/main/src/contract/spec.ts#L1007) +**Source:** [src/contract/spec.ts:1027](https://github.com/stellar/js-stellar-sdk/blob/main/src/contract/spec.ts#L1027) ## contract.Watcher diff --git a/src/bindings/client.ts b/src/bindings/client.ts index 787e8c8d5..8ed6a7947 100644 --- a/src/bindings/client.ts +++ b/src/bindings/client.ts @@ -8,6 +8,8 @@ import { formatJSDocComment, formatImports, toCamelCase, + createUdtNames, + type UdtNameResolver, } from "./utils.js"; /** @@ -16,6 +18,10 @@ import { export class ClientGenerator { private spec: Spec; + // Spec name (module-qualified, e.g. "token::Balance") -> the TypeScript + // identifier types.ts publishes it under. See createUdtNames(). + private resolveUdtName: UdtNameResolver; + // event index (in declaration order) -> resolved (possibly disambiguated) // filter method name. Keyed by index rather than raw name because a // contract may declare several events with the same name. @@ -24,6 +30,7 @@ export class ClientGenerator { constructor(spec: Spec) { this.spec = spec; + this.resolveUdtName = createUdtNames(spec.entries).reference; } /** @@ -99,6 +106,7 @@ ${eventMethods} const defs = inputs.map((input) => input.type()).concat(outputs); return defs; }), + this.resolveUdtName, ); const events = this.spec.events(); @@ -119,7 +127,10 @@ ${eventMethods} .value, ); topicParams.forEach((param) => { - const nested = generateTypeImports([param.type()]); + const nested = generateTypeImports( + [param.type()], + this.resolveUdtName, + ); nested.typeFileImports.forEach((t) => imports.typeFileImports.add(t)); nested.stellarContractImports.forEach((t) => imports.stellarContractImports.add(t), @@ -293,7 +304,11 @@ ${eventMethods} const fieldName = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(rawParamName) ? rawParamName : `"${escapeStringLiteral(rawParamName)}"`; - const fieldType = parseTypeFromTypeDef(param.type(), true); + const fieldType = parseTypeFromTypeDef( + param.type(), + true, + this.resolveUdtName, + ); return `${fieldName}?: ${fieldType}`; }) .join("; "); @@ -326,11 +341,11 @@ ${eventMethods} const name = sanitizeIdentifier(func.name().toString()); const inputs = func.inputs().map((input: any) => ({ name: sanitizeIdentifier(input.name().toString()), - type: parseTypeFromTypeDef(input.type(), true), + type: parseTypeFromTypeDef(input.type(), true, this.resolveUdtName), })); const outputType = func.outputs().length > 0 - ? parseTypeFromTypeDef(func.outputs()[0]) + ? parseTypeFromTypeDef(func.outputs()[0], false, this.resolveUdtName) : "void"; const docs = formatJSDocComment(func.doc().toString(), 2); const params = this.formatMethodParameters(inputs); @@ -342,7 +357,7 @@ ${eventMethods} const name = sanitizeIdentifier(func.name().toString()); const outputType = func.outputs().length > 0 - ? parseTypeFromTypeDef(func.outputs()[0]) + ? parseTypeFromTypeDef(func.outputs()[0], false, this.resolveUdtName) : "void"; return ` ${name} : this.txFromJSON<${outputType}>`; @@ -362,7 +377,7 @@ ${eventMethods} } const inputs = constructorFunc.inputs().map((input) => ({ name: sanitizeIdentifier(input.name().toString()), - type: parseTypeFromTypeDef(input.type(), true), + type: parseTypeFromTypeDef(input.type(), true, this.resolveUdtName), })); const params = this.formatConstructorParameters(inputs); diff --git a/src/bindings/types.ts b/src/bindings/types.ts index 8c43cad0c..a50fa660c 100644 --- a/src/bindings/types.ts +++ b/src/bindings/types.ts @@ -9,6 +9,10 @@ import { formatImports, isTupleStruct, toPascalCase, + createUdtNames, + udtTypeName, + type UdtNames, + type UdtTypeNames, } from "./utils.js"; /** @@ -38,12 +42,37 @@ export interface EnumCase { value: number; } +/** + * The doc-comment notes appended to a user-defined type's declaration: the + * name it goes by in the contract spec when the declared identifier spells it + * differently, and why no bare-name alias is exported when the spec makes + * that name ambiguous. Empty when neither applies. + */ +function specNameNotes(specName: string, names: UdtTypeNames): string { + const notes: string[] = []; + if (specName !== names.declared) { + notes.push(`Declared in the contract spec as \`${specName}\`.`); + } + if (names.alias === undefined && names.ambiguousWith.length > 0) { + const others = names.ambiguousWith.map((name) => `\`${name}\``).join(", "); + notes.push( + `No \`${udtTypeName(specName)}\` alias is exported; ` + + `the same bare name is used by ${others}.`, + ); + } + return notes.length > 0 ? `\n\n${notes.join("\n\n")}` : ""; +} + /** * Generates TypeScript type definitions from Stellar contract specs */ export class TypeGenerator { private spec: Spec; + // The identifiers declared (and aliased) for the spec's user-defined + // types, keyed by spec name. See createUdtNames(). + private udtNames: UdtNames; + // event index (in event-entry declaration order) -> resolved (possibly // disambiguated) interface name. Keyed by index rather than raw name // because a contract may declare several events with the same name. @@ -52,6 +81,7 @@ export class TypeGenerator { constructor(spec: Spec) { this.spec = spec; + this.udtNames = createUdtNames(spec.entries); } /** @@ -141,6 +171,7 @@ export class TypeGenerator { return []; } }), + this.udtNames.reference, ); return formatImports(imports, { @@ -152,9 +183,12 @@ export class TypeGenerator { * Generate TypeScript interface for a struct */ private generateStruct(struct: xdr.ScSpecUdtStructV0): string { - const name = sanitizeIdentifier(struct.name().toString()); + const specName = struct.name().toString(); + const names = this.udtNames.for(specName); + const name = names.declared; const doc = formatJSDocComment( - struct.doc().toString() || `Struct: ${name}`, + (struct.doc().toString() || `Struct: ${name}`) + + specNameNotes(specName, names), 0, ); @@ -162,7 +196,11 @@ export class TypeGenerator { .fields() .map((field) => { const fieldName = sanitizeIdentifier(field.name().toString()); - const fieldType = parseTypeFromTypeDef(field.type()); + const fieldType = parseTypeFromTypeDef( + field.type(), + false, + this.udtNames.reference, + ); const fieldDoc = formatJSDocComment(field.doc().toString(), 2); return `${fieldDoc} ${fieldName}: ${fieldType};`; @@ -171,16 +209,19 @@ export class TypeGenerator { return `${doc}export interface ${name} { ${fields} -}`; +}${this.aliasExport(specName, names, "type")}`; } /** * Generate TypeScript union type */ private generateUnion(union: xdr.ScSpecUdtUnionV0): string { - const name = sanitizeIdentifier(union.name().toString()); + const specName = union.name().toString(); + const names = this.udtNames.for(specName); + const name = names.declared; const doc = formatJSDocComment( - union.doc().toString() || `Union: ${name}`, + (union.doc().toString() || `Union: ${name}`) + + specNameNotes(specName, names), 0, ); const cases = union @@ -197,16 +238,19 @@ ${fields} .join(" |\n"); return `${doc} export type ${name} = -${caseTypes};`; +${caseTypes};${this.aliasExport(specName, names, "type")}`; } /** * Generate TypeScript enum */ private generateEnum(enumEntry: xdr.ScSpecUdtEnumV0): string { - const name = sanitizeIdentifier(enumEntry.name().toString()); + const specName = enumEntry.name().toString(); + const names = this.udtNames.for(specName); + const name = names.declared; const doc = formatJSDocComment( - enumEntry.doc().toString() || `Enum: ${name}`, + (enumEntry.doc().toString() || `Enum: ${name}`) + + specNameNotes(specName, names), 0, ); @@ -223,16 +267,19 @@ ${caseTypes};`; return `${doc}export enum ${name} { ${members} -}`; +}${this.aliasExport(specName, names, "value")}`; } /** * Generate TypeScript error enum */ private generateErrorEnum(errorEnum: xdr.ScSpecUdtErrorEnumV0): string { - const name = sanitizeIdentifier(errorEnum.name().toString()); + const specName = errorEnum.name().toString(); + const names = this.udtNames.for(specName); + const name = names.declared; const doc = formatJSDocComment( - errorEnum.doc().toString() || `Error Enum: ${name}`, + (errorEnum.doc().toString() || `Error Enum: ${name}`) + + specNameNotes(specName, names), 0, ); const cases = errorEnum @@ -247,7 +294,38 @@ ${members} return `${doc}export const ${name} = { ${members} -}`; +}${this.aliasExport(specName, names, "value")}`; + } + + /** + * Generate the export that additionally publishes a type under its bare + * name, so callers can keep using the name they always have without + * spelling out the Rust module path. Empty when the type has no such alias + * — either its declaration already carries the bare name, or the spec makes + * that name ambiguous. + * + * Aliasing an enum (or an error enum's const) has to re-export the binding + * itself rather than just its type, since callers reference its members as + * values. + */ + private aliasExport( + specName: string, + names: UdtTypeNames, + kind: "type" | "value", + ): string { + if (names.alias === undefined) { + return ""; + } + const doc = formatJSDocComment( + `${names.alias} is an alias of {@link ${names.declared}}, the contract ` + + `spec's \`${specName}\`.`, + 0, + ); + const statement = + kind === "type" + ? `export type ${names.alias} = ${names.declared};` + : `export { ${names.declared} as ${names.alias} };`; + return `\n\n${doc}${statement}`; } /** @@ -268,7 +346,11 @@ ${members} return { doc: tupleCase.doc().toString(), name: tupleCase.name().toString(), - types: tupleCase.type().map((t) => parseTypeFromTypeDef(t)), + types: tupleCase + .type() + .map((t) => + parseTypeFromTypeDef(t, false, this.udtNames.reference), + ), }; } default: @@ -346,33 +428,13 @@ ${members} if (this.eventInterfaceNames !== null) { return this.eventInterfaceNames; } - // Reserve names that are already taken by UDTs and other special entries. ContractEvent is a special entry for the discriminated union of all events. - const reserved = new Set(["ContractEvent"]); - - for (const entry of this.spec.entries) { - switch (entry.switch()) { - case xdr.ScSpecEntryKind.scSpecEntryUdtStructV0(): - reserved.add( - sanitizeIdentifier(entry.udtStructV0().name().toString()), - ); - break; - case xdr.ScSpecEntryKind.scSpecEntryUdtUnionV0(): - reserved.add( - sanitizeIdentifier(entry.udtUnionV0().name().toString()), - ); - break; - case xdr.ScSpecEntryKind.scSpecEntryUdtEnumV0(): - reserved.add(sanitizeIdentifier(entry.udtEnumV0().name().toString())); - break; - case xdr.ScSpecEntryKind.scSpecEntryUdtErrorEnumV0(): - reserved.add( - sanitizeIdentifier(entry.udtErrorEnumV0().name().toString()), - ); - break; - default: - break; - } - } + // Reserve names that are already taken by UDTs — declarations and + // bare-name aliases alike — and other special entries. ContractEvent is a + // special entry for the discriminated union of all events. + const reserved = new Set([ + "ContractEvent", + ...this.udtNames.exported(), + ]); const resolved = new Map(); @@ -434,7 +496,11 @@ ${members} .params() .map((param) => { const fieldName = fieldKey(param.name().toString()); - const fieldType = parseTypeFromTypeDef(param.type()); + const fieldType = parseTypeFromTypeDef( + param.type(), + false, + this.udtNames.reference, + ); const fieldDoc = formatJSDocComment(param.doc().toString(), 4); const optional = dataIsMapFormat && @@ -473,17 +539,22 @@ ${dataFields} } private generateTupleStruct(udtStruct: xdr.ScSpecUdtStructV0): string { - const name = sanitizeIdentifier(udtStruct.name().toString()); + const specName = udtStruct.name().toString(); + const names = this.udtNames.for(specName); + const name = names.declared; const doc = formatJSDocComment( - udtStruct.doc().toString() || `Tuple Struct: ${name}`, + (udtStruct.doc().toString() || `Tuple Struct: ${name}`) + + specNameNotes(specName, names), 0, ); const types = udtStruct .fields() - .map((field) => parseTypeFromTypeDef(field.type())) + .map((field) => + parseTypeFromTypeDef(field.type(), false, this.udtNames.reference), + ) .join(", "); - return `${doc}export type ${name} = readonly [${types}];`; + return `${doc}export type ${name} = readonly [${types}];${this.aliasExport(specName, names, "type")}`; } } diff --git a/src/bindings/utils.ts b/src/bindings/utils.ts index e8e0d8b89..4683f882f 100644 --- a/src/bindings/utils.ts +++ b/src/bindings/utils.ts @@ -82,6 +82,183 @@ export function sanitizeIdentifier(identifier: string): string { return sanitized; } +/** + * Split a spec type name into its path segments. + * + * Since rs-soroban-sdk qualifies user-defined type names with the Rust + * module path they are declared in, a spec name can look like + * `token::storage::Balance` rather than a bare `Balance`. + */ +function typeNameSegments(specName: string): string[] { + return specName.split("::").filter((segment) => segment.length > 0); +} + +/** + * The bare type name of a spec name: the last segment of a module-qualified + * name (`token::storage::Balance` becomes `Balance`), sanitized. + */ +export function udtTypeName(specName: string): string { + const segments = typeNameSegments(specName); + return sanitizeIdentifier( + segments.length > 0 ? segments[segments.length - 1] : specName, + ); +} + +/** + * The whole spec name as a single identifier + * (`token::storage::Balance` becomes `token_storage_Balance`). + */ +export function udtQualifiedTypeName(specName: string): string { + const segments = typeNameSegments(specName); + return sanitizeIdentifier( + segments.length > 0 ? segments.join("_") : specName, + ); +} + +/** + * Maps a user-defined type's spec name to the TypeScript identifier the + * bindings use to refer to it. Shared by the types and client generators so + * both files agree on every name. + */ +export type UdtNameResolver = (specName: string) => string; + +/** + * The identifiers the generated types file uses for one user-defined type. + */ +export type UdtTypeNames = { + /** The identifier the type is declared under: its whole spec name. */ + declared: string; + /** + * The bare type name the declaration is additionally exported under, or + * `undefined` when the bare name is unavailable — either the declaration + * already carries it, or another type in the spec has the same bare name. + */ + alias?: string; + /** + * The other spec names sharing this type's bare name; only non-empty when + * that clash is why no {@link UdtTypeNames.alias} is exported. + */ + ambiguousWith: string[]; +}; + +/** + * The identifiers the generated types file uses for every user-defined type + * in a spec. + */ +export type UdtNames = { + /** The identifiers for the type with the given spec name. */ + for: (specName: string) => UdtTypeNames; + /** How other generated code refers to the type: its alias, else its declared name. */ + reference: UdtNameResolver; + /** Every identifier the types file exports, declarations and aliases alike. */ + exported: () => string[]; +}; + +/** + * The spec name of a user-defined type entry, or `null` for entries that do + * not declare one (functions, events). + */ +function udtEntryName(entry: xdr.ScSpecEntry): string | null { + switch (entry.switch()) { + case xdr.ScSpecEntryKind.scSpecEntryUdtStructV0(): + return entry.udtStructV0().name().toString(); + case xdr.ScSpecEntryKind.scSpecEntryUdtUnionV0(): + return entry.udtUnionV0().name().toString(); + case xdr.ScSpecEntryKind.scSpecEntryUdtEnumV0(): + return entry.udtEnumV0().name().toString(); + case xdr.ScSpecEntryKind.scSpecEntryUdtErrorEnumV0(): + return entry.udtErrorEnumV0().name().toString(); + default: + return null; + } +} + +/** + * Resolve the TypeScript identifiers for every user-defined type in a spec. + * + * Every type is declared under its whole spec name — module path included, + * `_`-joined — so the Rust type it came from is always spelled out and two + * same-named types from different modules never clash. Each is then also + * exported under its bare type name, which is the name generated bindings + * have always used, so the module path stays out of the way. A bare name is + * only skipped when the spec makes it ambiguous: several types share it, or + * another type is declared under it outright. + * + * Names are resolved in spec-entry order, and a declaration whose identifier + * is somehow already taken gets a numeric suffix, so resolution is + * deterministic for a given spec and the types and client files always agree. + */ +export function createUdtNames(entries: xdr.ScSpecEntry[]): UdtNames { + const specNames: string[] = []; + entries.forEach((entry) => { + const specName = udtEntryName(entry); + if (specName !== null && !specNames.includes(specName)) { + specNames.push(specName); + } + }); + + const taken = new Set(); + const names = new Map(); + + // Declarations first: they are the names that must exist. + specNames.forEach((specName) => { + const base = udtQualifiedTypeName(specName); + let declared = base; + let suffix = 2; + while (taken.has(declared)) { + declared = `${base}${suffix}`; + suffix += 1; + } + taken.add(declared); + names.set(specName, { declared, ambiguousWith: [] }); + }); + + // Then the bare-name aliases, for every type whose bare name is its alone. + const claimants = new Map(); + specNames.forEach((specName) => { + const bare = udtTypeName(specName); + claimants.set(bare, [...(claimants.get(bare) ?? []), specName]); + }); + + claimants.forEach((claiming, bare) => { + if (claiming.length === 1 && !taken.has(bare)) { + taken.add(bare); + names.get(claiming[0])!.alias = bare; + return; + } + // Ambiguous: the bare name is shared, or is itself a declared name. + claiming.forEach((specName) => { + if (names.get(specName)!.declared === bare) { + return; + } + names.get(specName)!.ambiguousWith = claiming.filter( + (other) => other !== specName, + ); + }); + }); + + const namesFor = (specName: string): UdtTypeNames => + names.get(specName) ?? { + declared: udtQualifiedTypeName(specName), + ambiguousWith: [], + }; + + return { + for: namesFor, + reference: (specName: string) => { + const resolved = namesFor(specName); + return resolved.alias ?? resolved.declared; + }, + exported: () => + specNames.flatMap((specName) => { + const resolved = names.get(specName)!; + return resolved.alias === undefined + ? [resolved.declared] + : [resolved.declared, resolved.alias]; + }), + }; +} + /** * Escape a string for safe interpolation inside a double-quoted JavaScript string literal. */ @@ -101,6 +278,7 @@ export function escapeStringLiteral(str: string): string { export function parseTypeFromTypeDef( typeDef: xdr.ScSpecTypeDef, isFunctionInput = false, + resolveUdtName: UdtNameResolver = udtTypeName, ): string { switch (typeDef.switch()) { case xdr.ScSpecType.scSpecTypeVal(): @@ -143,6 +321,7 @@ export function parseTypeFromTypeDef( const vecType = parseTypeFromTypeDef( typeDef.vec().elementType(), isFunctionInput, + resolveUdtName, ); return `Array<${vecType}>`; } @@ -150,10 +329,12 @@ export function parseTypeFromTypeDef( const keyType = parseTypeFromTypeDef( typeDef.map().keyType(), isFunctionInput, + resolveUdtName, ); const valueType = parseTypeFromTypeDef( typeDef.map().valueType(), isFunctionInput, + resolveUdtName, ); return `Map<${keyType}, ${valueType}>`; } @@ -162,7 +343,7 @@ export function parseTypeFromTypeDef( .tuple() .valueTypes() .map((t: xdr.ScSpecTypeDef) => - parseTypeFromTypeDef(t, isFunctionInput), + parseTypeFromTypeDef(t, isFunctionInput, resolveUdtName), ); return `[${tupleTypes.join(", ")}]`; } @@ -177,6 +358,7 @@ export function parseTypeFromTypeDef( const optionType = parseTypeFromTypeDef( typeDef.option().valueType(), isFunctionInput, + resolveUdtName, ); return `${optionType} | null`; @@ -185,16 +367,17 @@ export function parseTypeFromTypeDef( const okType = parseTypeFromTypeDef( typeDef.result().okType(), isFunctionInput, + resolveUdtName, ); const errorType = parseTypeFromTypeDef( typeDef.result().errorType(), isFunctionInput, + resolveUdtName, ); return `Result<${okType}, ${errorType}>`; } case xdr.ScSpecType.scSpecTypeUdt(): { - const udtName = sanitizeIdentifier(typeDef.udt().name().toString()); - return udtName; + return resolveUdtName(typeDef.udt().name().toString()); } default: return "unknown"; @@ -246,6 +429,7 @@ function extractNestedTypes(typeDef: xdr.ScSpecTypeDef): xdr.ScSpecTypeDef[] { function visitTypeDef( typeDef: xdr.ScSpecTypeDef, accumulator: BindingImports, + resolveUdtName: UdtNameResolver, ): void { const typeSwitch = typeDef.switch(); @@ -253,7 +437,7 @@ function visitTypeDef( switch (typeSwitch) { case xdr.ScSpecType.scSpecTypeUdt(): accumulator.typeFileImports.add( - sanitizeIdentifier(typeDef.udt().name().toString()), + resolveUdtName(typeDef.udt().name().toString()), ); return; @@ -297,7 +481,9 @@ function visitTypeDef( // Handle container types (have nested types) const nestedTypes = extractNestedTypes(typeDef); - nestedTypes.forEach((nested) => visitTypeDef(nested, accumulator)); + nestedTypes.forEach((nested) => + visitTypeDef(nested, accumulator, resolveUdtName), + ); } /** @@ -305,6 +491,7 @@ function visitTypeDef( */ export function generateTypeImports( typeDefs: xdr.ScSpecTypeDef[], + resolveUdtName: UdtNameResolver = udtTypeName, ): BindingImports { const imports: BindingImports = { typeFileImports: new Set(), @@ -314,7 +501,7 @@ export function generateTypeImports( }; // Visit each type definition - typeDefs.forEach((typeDef) => visitTypeDef(typeDef, imports)); + typeDefs.forEach((typeDef) => visitTypeDef(typeDef, imports, resolveUdtName)); return imports; } diff --git a/src/contract/spec.ts b/src/contract/spec.ts index 5e60d7aad..24d79c8d1 100644 --- a/src/contract/spec.ts +++ b/src/contract/spec.ts @@ -650,19 +650,39 @@ export class Spec { /** * Finds the XDR spec entry for the given name. * + * User-defined type names are qualified with the Rust module path they are + * declared in (e.g. `token::Balance`), so a bare type name is also accepted + * when exactly one entry in the spec ends with it. + * * @param name - the name to find * @returns the entry * - * @throws if no entry with the given name exists + * @throws if no entry with the given name exists, or if a bare type name + * matches more than one module-qualified entry */ findEntry(name: string): xdr.ScSpecEntry { const entry = this.entries.find( (e) => e.value().name().toString() === name, ); - if (!entry) { - throw new Error(`no such entry: ${name}`); + if (entry) { + return entry; + } + if (!name.includes("::")) { + const matches = this.entries.filter((e) => + e.value().name().toString().endsWith(`::${name}`), + ); + if (matches.length === 1) { + return matches[0]; + } + if (matches.length > 1) { + throw new Error( + `ambiguous entry: ${name} matches ${matches + .map((e) => e.value().name().toString()) + .join(", ")}`, + ); + } } - return entry; + throw new Error(`no such entry: ${name}`); } /** diff --git a/test/unit/bindings/qualified_names.test.ts b/test/unit/bindings/qualified_names.test.ts new file mode 100644 index 000000000..775f35675 --- /dev/null +++ b/test/unit/bindings/qualified_names.test.ts @@ -0,0 +1,272 @@ +import { describe, it, expect } from "vitest"; +import { xdr, contract } from "../../../src/index.js"; +import { TypeGenerator } from "../../../src/bindings/types.js"; +import { ClientGenerator } from "../../../src/bindings/client.js"; + +const { Spec } = contract; + +const u32Type = xdr.ScSpecTypeDef.scSpecTypeU32(); + +function udtType(name: string): xdr.ScSpecTypeDef { + return xdr.ScSpecTypeDef.scSpecTypeUdt(new xdr.ScSpecTypeUdt({ name })); +} + +function structEntry(name: string): xdr.ScSpecEntry { + return xdr.ScSpecEntry.scSpecEntryUdtStructV0( + new xdr.ScSpecUdtStructV0({ + doc: "", + lib: "", + name, + fields: [ + new xdr.ScSpecUdtStructFieldV0({ + doc: "", + name: "value", + type: u32Type, + }), + ], + }), + ); +} + +function errorEnumEntry(name: string): xdr.ScSpecEntry { + return xdr.ScSpecEntry.scSpecEntryUdtErrorEnumV0( + new xdr.ScSpecUdtErrorEnumV0({ + doc: "", + lib: "", + name, + cases: [ + new xdr.ScSpecUdtErrorEnumCaseV0({ doc: "", name: "Boom", value: 1 }), + ], + }), + ); +} + +function enumEntry(name: string): xdr.ScSpecEntry { + return xdr.ScSpecEntry.scSpecEntryUdtEnumV0( + new xdr.ScSpecUdtEnumV0({ + doc: "", + lib: "", + name, + cases: [ + new xdr.ScSpecUdtEnumCaseV0({ doc: "", name: "First", value: 0 }), + ], + }), + ); +} + +function funcEntry( + name: string, + inputType: xdr.ScSpecTypeDef, + outputType: xdr.ScSpecTypeDef, +): xdr.ScSpecEntry { + return xdr.ScSpecEntry.scSpecEntryFunctionV0( + new xdr.ScSpecFunctionV0({ + doc: "", + name, + inputs: [ + new xdr.ScSpecFunctionInputV0({ + doc: "", + name: "arg", + type: inputType, + }), + ], + outputs: [outputType], + }), + ); +} + +function eventEntry(name: string): xdr.ScSpecEntry { + return xdr.ScSpecEntry.scSpecEntryEventV0( + new xdr.ScSpecEventV0({ + doc: "", + lib: "", + name, + prefixTopics: [name], + params: [ + new xdr.ScSpecEventParamV0({ + doc: "", + name: "value", + type: u32Type, + location: + xdr.ScSpecEventParamLocationV0.scSpecEventParamLocationTopicList(), + }), + ], + dataFormat: xdr.ScSpecEventDataFormat.scSpecEventDataFormatSingleValue(), + }), + ); +} + +describe("module-qualified user-defined type names", () => { + it("declares a qualified type under its whole spec name, aliased to the bare name", () => { + const spec = new Spec([structEntry("test_udt::UdtStruct")]); + const output = new TypeGenerator(spec).generate(); + + expect(output).toMatch(/export interface test_udt_UdtStruct \{/); + expect(output).toMatch(/export type UdtStruct = test_udt_UdtStruct;/); + expect(output).toMatch( + /Declared in the contract spec as `test_udt::UdtStruct`\./, + ); + expect(output).toMatch( + /UdtStruct is an alias of \{@link test_udt_UdtStruct\}, the contract spec's `test_udt::UdtStruct`\./, + ); + }); + + it("aliases enums and error enums as values, so their members stay reachable", () => { + const spec = new Spec([ + enumEntry("test_udt::UdtEnum"), + errorEnumEntry("test_udt::UdtError"), + ]); + const output = new TypeGenerator(spec).generate(); + + expect(output).toMatch(/export enum test_udt_UdtEnum \{/); + expect(output).toMatch(/export \{ test_udt_UdtEnum as UdtEnum \};/); + expect(output).toMatch(/export const test_udt_UdtError = \{/); + expect(output).toMatch(/export \{ test_udt_UdtError as UdtError \};/); + }); + + it("leaves an unqualified type name untouched, with no alias or notes", () => { + const spec = new Spec([structEntry("UdtStruct")]); + const output = new TypeGenerator(spec).generate(); + + expect(output).toMatch(/export interface UdtStruct \{/); + expect(output).not.toMatch(/Declared in the contract spec as/); + expect(output).not.toMatch(/is an alias of/); + }); + + it("references and imports qualified types by their bare alias in the client", () => { + const spec = new Spec([ + structEntry("test_udt::UdtStruct"), + enumEntry("test_udt::nested::UdtEnum"), + funcEntry( + "convert", + udtType("test_udt::UdtStruct"), + udtType("test_udt::nested::UdtEnum"), + ), + ]); + const output = new ClientGenerator(spec).generate(); + + expect(output).toMatch( + /import \{UdtStruct, UdtEnum\} from '\.\/types\.js'/, + ); + expect(output).toMatch( + /convert\(\{ arg \}: \{ arg: UdtStruct \}, options\?: MethodOptions\): Promise>;/, + ); + }); + + it("exports no bare alias for types that share a bare name, and says why", () => { + const spec = new Spec([ + structEntry("first::Shared"), + structEntry("second::Shared"), + funcEntry("convert", udtType("second::Shared"), udtType("first::Shared")), + ]); + + const types = new TypeGenerator(spec).generate(); + expect(types).toMatch(/export interface first_Shared \{/); + expect(types).toMatch(/export interface second_Shared \{/); + expect(types).not.toMatch(/= first_Shared;/); + expect(types).not.toMatch(/= second_Shared;/); + expect(types).toMatch( + /No `Shared` alias is exported; the same bare name is used by `second::Shared`\./, + ); + expect(types).toMatch( + /No `Shared` alias is exported; the same bare name is used by `first::Shared`\./, + ); + + // The client falls back to the declared names, which types.ts exports. + const client = new ClientGenerator(spec).generate(); + expect(client).toMatch( + /import \{second_Shared, first_Shared\} from '\.\/types\.js'/, + ); + expect(client).toMatch( + /convert\(\{ arg \}: \{ arg: second_Shared \}, options\?: MethodOptions\): Promise>;/, + ); + }); + + it("keeps the bare alias when the clash is with another type's declared name", () => { + const spec = new Spec([ + structEntry("first_Shared"), + structEntry("first::Shared"), + ]); + const types = new TypeGenerator(spec).generate(); + + // Both flatten to first_Shared, so the second declaration is suffixed... + expect(types).toMatch(/export interface first_Shared \{/); + expect(types).toMatch(/export interface first_Shared2 \{/); + // ...but its bare name is unclaimed, so it still gets its alias. + expect(types).toMatch(/export type Shared = first_Shared2;/); + + const declNames = (types.match(/export interface (\w+) \{/g) ?? []).map( + (m: string) => m.replace(/export interface (\w+) \{/, "$1"), + ); + expect(new Set(declNames).size).toBe(declNames.length); + }); + + it("reserves declared names and aliases alike against event interface names", () => { + const spec = new Spec([ + structEntry("test_udt::TransferEvent"), + eventEntry("transfer"), + ]); + const types = new TypeGenerator(spec).generate(); + + // The UDT's alias keeps the bare name; the event is disambiguated around it. + expect(types).toMatch(/export interface test_udt_TransferEvent \{/); + expect(types).toMatch( + /export type TransferEvent = test_udt_TransferEvent;/, + ); + expect(types).toMatch(/export interface TransferEvent2 \{/); + expect(types).toMatch(/export type ContractEvent = TransferEvent2;/); + }); + + it("resolves names deterministically across generator instances", () => { + const spec = new Spec([ + structEntry("first::Shared"), + structEntry("second::Shared"), + ]); + + expect(new TypeGenerator(spec).generate()).toBe( + new TypeGenerator(spec).generate(), + ); + }); +}); + +describe("Spec.findEntry with module-qualified names", () => { + it("finds a qualified entry by its bare type name", () => { + const spec = new Spec([structEntry("test_udt::UdtStruct")]); + + expect( + spec.findEntry("test_udt::UdtStruct").value().name().toString(), + ).toBe("test_udt::UdtStruct"); + expect(spec.findEntry("UdtStruct").value().name().toString()).toBe( + "test_udt::UdtStruct", + ); + }); + + it("rejects a bare name that matches more than one qualified entry", () => { + const spec = new Spec([ + structEntry("first::Shared"), + structEntry("second::Shared"), + ]); + + expect(() => spec.findEntry("Shared")).toThrow( + "ambiguous entry: Shared matches first::Shared, second::Shared", + ); + }); + + it("still throws for an unknown name", () => { + const spec = new Spec([structEntry("test_udt::UdtStruct")]); + + expect(() => spec.findEntry("Missing")).toThrow("no such entry: Missing"); + }); + + it("converts values for a qualified type", () => { + const spec = new Spec([structEntry("test_udt::UdtStruct")]); + const scVal = spec.nativeToScVal( + { value: 1 }, + udtType("test_udt::UdtStruct"), + ); + + expect(spec.scValToNative(scVal, udtType("test_udt::UdtStruct"))).toEqual({ + value: 1, + }); + }); +});