diff --git a/.changesets/1790962042-cf634029.yaml b/.changesets/1790962042-cf634029.yaml new file mode 100644 index 00000000..a8f120ac --- /dev/null +++ b/.changesets/1790962042-cf634029.yaml @@ -0,0 +1,18 @@ +id: 1790962042-cf634029 +features: + - enums +targets: + - csharp + - javav2 + - mcp-typescript + - mockserver + - php + - pythonv2 + - ruby + - typescriptv2 + - unity +type: fix +bump: patch +description: derive valid enum member names for values that start with an underscore followed by digits; TypeScript and MCP TypeScript add a `numericEnumMemberNames` option that spells out digits-only values such as `_1` as `One` for new SDKs and keeps the numeric key for existing SDKs with union enums (native enums always spell digits out), Java applies the same derivation to `x-speakeasy-enums` overrides and OAuth2 scope names and detects member collisions on the emitted upper-snake name, the mockserver detects them on the emitted field name, values that share a derived name receive suffixes that are checked against every other member name in the enum, and `x-speakeasy-enums` override names are disambiguated as a group the same way +author: AshGodfrey +date: "2026-10-02" diff --git a/templates/templates/csharp/includes/sanitization.ts b/templates/templates/csharp/includes/sanitization.ts index e1ef72fc..f95f2345 100644 --- a/templates/templates/csharp/includes/sanitization.ts +++ b/templates/templates/csharp/includes/sanitization.ts @@ -621,45 +621,46 @@ function getEnumName(value: string): string { } name = sanitizeName(name); - return caser().ToPascal(name); + name = caser().ToPascal(name); + if (/^[0-9]/.test(name)) { + name = caser().ToPascal(sanitizeName(name)); + } + return name; } registerTemplateFunc("getEnumName", getEnumName); // @ts-ignore function getEnumNamesFromValues(values: string[]): string[] { - const enumNames = []; - - const names = {}; - if (!values) return enumNames; - values.forEach((value) => { - const name = getEnumName(value); - if (!names[name]) { - names[name] = 0; - } + if (!values) return []; + return disambiguateEnumNames(values, getEnumName).map((name) => + sanitizeFieldName(name), + ); +} - names[name] += 1; - }); +function disambiguateEnumNames( + values: string[], + deriveName: (value: string) => string, +): string[] { + const baseNames = values.map(deriveName); + const counts = new Map(); + for (const name of baseNames) { + counts.set(name, (counts.get(name) || 0) + 1); + } - const seen = {}; - values.forEach((value) => { - let name = getEnumName(value); - if (names[name] > 1) { - let candidate = `${name}${caser().ToPascal(getCasing(value))}`; - if (seen[candidate]) { - let suffix = seen[candidate]; - seen[candidate] += 1; - candidate = `${candidate}${suffix}`; - } else { - seen[candidate] = 1; - } + const used = new Set(baseNames.filter((name) => counts.get(name) === 1)); + return values.map((value, i) => { + let name = baseNames[i]; + if (counts.get(name) > 1) { + const candidate = `${name}${caser().ToPascal(getCasing(value))}`; name = candidate; + for (let suffix = 1; used.has(name); suffix++) { + name = `${candidate}${suffix}`; + } + used.add(name); } - - enumNames.push(sanitizeFieldName(name)); + return name; }); - - return enumNames; } registerTemplateFunc("getEnumNamesFromValues", getEnumNamesFromValues); diff --git a/templates/templates/csharp/includes/templating.ts b/templates/templates/csharp/includes/templating.ts index eb11e611..98156d84 100644 --- a/templates/templates/csharp/includes/templating.ts +++ b/templates/templates/csharp/includes/templating.ts @@ -859,7 +859,7 @@ function templateFloatValue( function getEnumNames(t: TypeDef): string[] { let names; if (t.Enum?.Names.length > 0) { - names = t.Enum.Names.map((n) => getEnumName(n)); + names = disambiguateEnumNames(t.Enum.Names, getEnumName); } else { names = getEnumNamesFromValues(t.Enum?.Values); } diff --git a/templates/templates/javav2/includes/enums.ts b/templates/templates/javav2/includes/enums.ts index ab74ce56..1bd83843 100644 --- a/templates/templates/javav2/includes/enums.ts +++ b/templates/templates/javav2/includes/enums.ts @@ -70,7 +70,7 @@ function sanitizeEnumValue(value: any, type: GojaEnum): string { function getEnumNames(t: TypeDef): string[] { if (t.Enum?.Names.length > 0) { - return t.Enum.Names.map((n) => getEnumName(n)); + return disambiguateEnumNames(t.Enum.Names, getEnumName); } else { return getEnumNamesFromValues(t.Enum?.Values); } diff --git a/templates/templates/javav2/includes/sanitization.ts b/templates/templates/javav2/includes/sanitization.ts index 15b54362..312a2cd4 100644 --- a/templates/templates/javav2/includes/sanitization.ts +++ b/templates/templates/javav2/includes/sanitization.ts @@ -773,37 +773,44 @@ function findFieldExample(example: any, field: FieldDef): any { // @ts-ignore function getEnumNamesFromValues(values: string[]): string[] { - let enumNames = []; - - let names = {}; - for (const value of values) { - let name = getEnumName(value); - if (!names[name]) { - names[name] = 0; - } + // Count collisions on the emitted upper-snake member, not the intermediate + // Pascal name: `_1ST` and `1ST` derive `OneSt` and `OneST`, which differ, + // but both become `ONE_ST` once snake-cased. + return disambiguateEnumNames( + values, + (value) => sanitizeEnumMember(getEnumName(value)), + (value) => + sanitizeEnumMember( + `${getEnumName(value)}${caser().ToPascal(getCasing(value))}`, + ), + ); +} - names[name] += 1; +function disambiguateEnumNames( + values: string[], + deriveName: (value: string) => string, + deriveCandidate: (value: string, name: string) => string = (value, name) => + `${name}${caser().ToPascal(getCasing(value))}`, +): string[] { + const baseNames = values.map(deriveName); + const counts = new Map(); + for (const name of baseNames) { + counts.set(name, (counts.get(name) || 0) + 1); } - let seen = {}; - for (const value of values) { - let name = getEnumName(value); - if (names[name] > 1) { - let candidate = `${name}${caser().ToPascal(getCasing(value))}`; - if (seen[candidate]) { - let suffix = seen[candidate]; - seen[candidate] += 1; - candidate = `${candidate}${suffix}`; - } else { - seen[candidate] = 1; - } + const used = new Set(baseNames.filter((name) => counts.get(name) === 1)); + return values.map((value, i) => { + let name = baseNames[i]; + if (counts.get(name) > 1) { + const candidate = deriveCandidate(value, name); name = candidate; + for (let suffix = 1; used.has(name); suffix++) { + name = `${candidate}${suffix}`; + } + used.add(name); } - - enumNames.push(sanitizeEnumMember(name)); - } - - return enumNames; + return name; + }); } // Java-specific scope dedup that produces PascalCase names @@ -854,9 +861,12 @@ function getEnumName(value: string): string { name = "Unknown"; } - name = sanitizeName(name); + name = caser().ToPascal(sanitizeName(name)); + if (/^[0-9]/.test(name)) { + name = caser().ToPascal(sanitizeName(name)); + } - return caser().ToPascal(name); + return name; } // @ts-ignore diff --git a/templates/templates/mcp-typescript/config.ts b/templates/templates/mcp-typescript/config.ts index 8869e281..5b8fee09 100644 --- a/templates/templates/mcp-typescript/config.ts +++ b/templates/templates/mcp-typescript/config.ts @@ -422,6 +422,15 @@ function getConfigFields( ValidationRegex: /^(true|false)$/.source, ValidationMessage: "true or false only", }, + numericEnumMemberNames: { + Name: "numericEnumMemberNames", + Required: false, + DefaultValue: newSDK ? "words" : "legacy", + Description: + 'Member names for enum values that are only digits once a leading underscore is removed, such as `_1` or `_2024`. "words" spells the number out (`One`, `TwoThousandAndTwentyFour`); "legacy" (deprecated) keeps the numeric key (`1`, `2024`). Values with letters after the digits (`_1ST`) always become words.', + ValidationRegex: /^(words|legacy)$/.source, + ValidationMessage: "words or legacy only", + }, validateResponse: { Name: "validateResponse", Required: false, diff --git a/templates/templates/mcp-typescript/includes/sanitization.ts b/templates/templates/mcp-typescript/includes/sanitization.ts index 810a2a71..6ff8a62a 100644 --- a/templates/templates/mcp-typescript/includes/sanitization.ts +++ b/templates/templates/mcp-typescript/includes/sanitization.ts @@ -164,7 +164,11 @@ function sanitizeEnumName(value: string): string { name = sanitizeName(name); - return caser().ToPascal(name); + name = caser().ToPascal(name); + if (/^[0-9]/.test(name) && !keepsNumericEnumMemberName(name)) { + name = caser().ToPascal(sanitizeName(name)); + } + return name; } // @ts-ignore @@ -401,18 +405,36 @@ function getEnumName(value) { name = sanitizeName(name); - return caser().ToPascal(name); + name = caser().ToPascal(name); + if (/^[0-9]/.test(name) && !keepsNumericEnumMemberName(name)) { + name = caser().ToPascal(sanitizeName(name)); + } + return name; } registerTemplateFunc("getEnumName", getEnumName); +// Matches a cased name that is a valid numeric literal property key in strict +// mode: a decimal integer with an optional exponent, or a hex, binary or octal +// literal. Leading zeros (`007`, `08`) are excluded because strict mode rejects +// them, so those values are spelled out even under `legacy`. +const numericLiteralKey = + /^(?:(?:0|[1-9][0-9]*)(?:[eE][0-9]+)?|0[xX][0-9a-fA-F]+|0[bB][01]+|0[oO][0-7]+)$/; + +function keepsNumericEnumMemberName(name: string): boolean { + return ( + context.Global.Config.NumericEnumMemberNames === "legacy" && + numericLiteralKey.test(name) + ); +} + // @ts-ignore function getEnumNames(t: TypeDef): string[] { if (t.Enum?.Names.length > 0) { if (context.Global.Config.FixEnumNameSanitization === true) { return t.Enum.Names.map((n) => sanitizeName(n.trim() || "Unknown")); } - return t.Enum.Names.map((n) => getEnumName(n)); + return getEnumNamesFromValues(t.Enum.Names); } return getEnumNamesFromValues(t.Enum?.Values); diff --git a/templates/templates/mcp-typescript/includes/types.ts b/templates/templates/mcp-typescript/includes/types.ts index cf95fd2e..5b8f8625 100644 --- a/templates/templates/mcp-typescript/includes/types.ts +++ b/templates/templates/mcp-typescript/includes/types.ts @@ -116,28 +116,32 @@ function enumLiteralFromValue( } function getEnumNamesFromValues(values: string[]): string[] { - let enumNames = []; - - let names = {}; - for (const value of values) { - let name = sanitizeEnumName(value); - if (!names[name]) { - names[name] = 0; - } + return disambiguateEnumNames(values, sanitizeEnumName); +} - names[name] += 1; +function disambiguateEnumNames( + values: string[], + deriveName: (value: string) => string, +): string[] { + const baseNames = values.map(deriveName); + const counts = new Map(); + for (const name of baseNames) { + counts.set(name, (counts.get(name) || 0) + 1); } - for (const value of values) { - let name = sanitizeEnumName(value); - if (names[name] > 1) { - name = `${name}${caser().ToPascal(getCasing(value))}`; + const used = new Set(baseNames.filter((name) => counts.get(name) === 1)); + return values.map((value, i) => { + let name = baseNames[i]; + if (counts.get(name) > 1) { + const candidate = `${name}${caser().ToPascal(getCasing(value))}`; + name = candidate; + for (let suffix = 1; used.has(name); suffix++) { + name = `${candidate}${suffix}`; + } + used.add(name); } - - enumNames.push(name); - } - - return enumNames; + return name; + }); } function getEnumDataType(typeDef: TypeDef): "string" | "number" { diff --git a/templates/templates/mockserver/includes/sanitization.ts b/templates/templates/mockserver/includes/sanitization.ts index da62c604..aa707bbc 100644 --- a/templates/templates/mockserver/includes/sanitization.ts +++ b/templates/templates/mockserver/includes/sanitization.ts @@ -750,28 +750,43 @@ function templateFloatValue( // @ts-ignore function getEnumNamesFromValues(values: string[]): string[] { - let enumNames = []; - - let names = {}; - for (const value of values) { - let name = getEnumName(value); - if (!names[name]) { - names[name] = 0; - } + // Count collisions on the emitted field name, not the cased name: + // `_1ST` and `1ST` case to `1St` and `OneSt`, which differ, but both + // become `OneSt` once sanitizeFieldName rewrites the leading digit. + return disambiguateEnumNames( + values, + (value) => sanitizeFieldName(getEnumName(value)), + (value) => + sanitizeFieldName( + `${getEnumName(value)}${caser().ToGoPascal(getCasing(value))}`, + ), + ); +} - names[name] += 1; +function disambiguateEnumNames( + values: string[], + deriveName: (value: string) => string, + deriveCandidate: (value: string, name: string) => string, +): string[] { + const baseNames = values.map(deriveName); + const counts = new Map(); + for (const name of baseNames) { + counts.set(name, (counts.get(name) || 0) + 1); } - for (const value of values) { - let name = getEnumName(value); - if (names[name] > 1) { - name = `${name}${caser().ToGoPascal(getCasing(value))}`; + const used = new Set(baseNames.filter((name) => counts.get(name) === 1)); + return values.map((value, i) => { + let name = baseNames[i]; + if (counts.get(name) > 1) { + const candidate = deriveCandidate(value, name); + name = candidate; + for (let suffix = 1; used.has(name); suffix++) { + name = `${candidate}${suffix}`; + } + used.add(name); } - - enumNames.push(sanitizeFieldName(name)); - } - - return enumNames; + return name; + }); } // @ts-ignore diff --git a/templates/templates/php/includes/enums.ts b/templates/templates/php/includes/enums.ts index eb224361..0a539d07 100644 --- a/templates/templates/php/includes/enums.ts +++ b/templates/templates/php/includes/enums.ts @@ -55,7 +55,7 @@ function sanitizeEnumValue(value: any, type: GojaEnum): string { function getEnumNames(t: TypeDef): string[] { if (t.Enum?.Names.length > 0) { - return t.Enum.Names.map((n) => getEnumName(n)); + return getEnumNamesFromValues(t.Enum.Names); } else { return getEnumNamesFromValues(t.Enum.Values); } diff --git a/templates/templates/php/includes/sanitization.ts b/templates/templates/php/includes/sanitization.ts index a4c5ee7a..9526ded0 100644 --- a/templates/templates/php/includes/sanitization.ts +++ b/templates/templates/php/includes/sanitization.ts @@ -622,37 +622,32 @@ function templateFloatValue( // @ts-ignore function getEnumNamesFromValues(values: string[]): string[] { - let enumNames = []; - - let names = {}; - for (const value of values) { - let name = getEnumName(value); - if (!names[name]) { - names[name] = 0; - } + return disambiguateEnumNames(values, getEnumName); +} - names[name] += 1; +function disambiguateEnumNames( + values: string[], + deriveName: (value: string) => string, +): string[] { + const baseNames = values.map(deriveName); + const counts = new Map(); + for (const name of baseNames) { + counts.set(name, (counts.get(name) || 0) + 1); } - let seen = {}; - for (const value of values) { - let name = getEnumName(value); - if (names[name] > 1) { - let candidate = `${name}${caser().ToPascal(getCasing(value))}`; - if (seen[candidate]) { - let suffix = seen[candidate]; - seen[candidate] += 1; - candidate = `${candidate}${suffix}`; - } else { - seen[candidate] = 1; - } + const used = new Set(baseNames.filter((name) => counts.get(name) === 1)); + return values.map((value, i) => { + let name = baseNames[i]; + if (counts.get(name) > 1) { + const candidate = `${name}${caser().ToPascal(getCasing(value))}`; name = candidate; + for (let suffix = 1; used.has(name); suffix++) { + name = `${candidate}${suffix}`; + } + used.add(name); } - - enumNames.push(name); - } - - return enumNames; + return name; + }); } // @ts-ignore @@ -664,6 +659,9 @@ function getEnumName(value: string): string { } name = caser().ToPascal(sanitizeName(name)); + if (/^[0-9]/.test(name)) { + name = caser().ToPascal(sanitizeName(name)); + } if (name === "Class") { name = "Class_"; } diff --git a/templates/templates/pythonv2/includes/enums.ts b/templates/templates/pythonv2/includes/enums.ts index 7f00c5dd..353b9f71 100644 --- a/templates/templates/pythonv2/includes/enums.ts +++ b/templates/templates/pythonv2/includes/enums.ts @@ -71,7 +71,7 @@ function sanitizeEnumValue(value: any, type: GojaEnum): string { function getEnumNames(t: TypeDef): string[] { if (t.Enum?.Names.length > 0) { - return t.Enum.Names.map((n) => getEnumName(n)); + return getEnumNamesFromValues(t.Enum.Names); } else { return getEnumNamesFromValues(t.Enum?.Values); } diff --git a/templates/templates/pythonv2/includes/sanitization.ts b/templates/templates/pythonv2/includes/sanitization.ts index 8380924f..74309517 100644 --- a/templates/templates/pythonv2/includes/sanitization.ts +++ b/templates/templates/pythonv2/includes/sanitization.ts @@ -2297,37 +2297,32 @@ function templateOptionalSymbol() { // @ts-ignore function getEnumNamesFromValues(values: string[]): string[] { - let enumNames = []; - - let names = {}; - for (const value of values) { - let name = getEnumName(value); - if (!names[name]) { - names[name] = 0; - } + return disambiguateEnumNames(values, getEnumName); +} - names[name] += 1; +function disambiguateEnumNames( + values: string[], + deriveName: (value: string) => string, +): string[] { + const baseNames = values.map(deriveName); + const counts = new Map(); + for (const name of baseNames) { + counts.set(name, (counts.get(name) || 0) + 1); } - let seen = {}; - for (const value of values) { - let name = getEnumName(value); - if (names[name] > 1) { - let candidate = `${name}_${getCasing(value).toUpperCase()}`; - if (seen[candidate]) { - let suffix = seen[candidate]; - seen[candidate] += 1; - candidate = `${candidate}_${suffix}`; - } else { - seen[candidate] = 1; - } + const used = new Set(baseNames.filter((name) => counts.get(name) === 1)); + return values.map((value, i) => { + let name = baseNames[i]; + if (counts.get(name) > 1) { + const candidate = `${name}_${getCasing(value).toUpperCase()}`; name = candidate; + for (let suffix = 1; used.has(name); suffix++) { + name = `${candidate}_${suffix}`; + } + used.add(name); } - - enumNames.push(name); - } - - return enumNames; + return name; + }); } // @ts-ignore @@ -2340,7 +2335,11 @@ function getEnumName(value: string): string { name = sanitizeName(name); - return caser().ToSNAKE(name); + name = caser().ToSNAKE(name); + if (/^[0-9]/.test(name)) { + name = caser().ToSNAKE(sanitizeName(name)); + } + return name; } registerTemplateFunc("getEnumName", getEnumName); diff --git a/templates/templates/ruby/includes/enums.ts b/templates/templates/ruby/includes/enums.ts index 04398001..cb9b73d0 100644 --- a/templates/templates/ruby/includes/enums.ts +++ b/templates/templates/ruby/includes/enums.ts @@ -48,7 +48,7 @@ function sanitizeEnumValue(value: any, type: GojaEnum): string { // @ts-ignore function getEnumNames(t: TypeDef): string[] { if (t.Enum?.Names.length > 0) { - return t.Enum.Names.map((n) => getEnumName(n)); + return getEnumNamesFromValues(t.Enum.Names); } else { return getEnumNamesFromValues(t.Enum?.Values); } diff --git a/templates/templates/ruby/includes/sanitization.ts b/templates/templates/ruby/includes/sanitization.ts index 6f730db1..0ae07b59 100644 --- a/templates/templates/ruby/includes/sanitization.ts +++ b/templates/templates/ruby/includes/sanitization.ts @@ -786,37 +786,32 @@ function templateOptionalSymbol(): string { // @ts-ignore function getEnumNamesFromValues(values: string[]): string[] { - let enumNames = []; - - let names = {}; - for (const value of values) { - let name = getEnumName(value); - if (!names[name]) { - names[name] = 0; - } + return disambiguateEnumNames(values, getEnumName); +} - names[name] += 1; +function disambiguateEnumNames( + values: string[], + deriveName: (value: string) => string, +): string[] { + const baseNames = values.map(deriveName); + const counts = new Map(); + for (const name of baseNames) { + counts.set(name, (counts.get(name) || 0) + 1); } - let seen = {}; - for (const value of values) { - let name = getEnumName(value); - if (names[name] > 1) { - let candidate = `${name}_${getCasing(value).toUpperCase()}`; - if (seen[candidate]) { - let suffix = seen[candidate]; - seen[candidate] += 1; - candidate = `${candidate}_${suffix}`; - } else { - seen[candidate] = 1; - } + const used = new Set(baseNames.filter((name) => counts.get(name) === 1)); + return values.map((value, i) => { + let name = baseNames[i]; + if (counts.get(name) > 1) { + const candidate = `${name}_${getCasing(value).toUpperCase()}`; name = candidate; + for (let suffix = 1; used.has(name); suffix++) { + name = `${candidate}_${suffix}`; + } + used.add(name); } - - enumNames.push(name); - } - - return enumNames; + return name; + }); } // @ts-ignore @@ -829,6 +824,9 @@ function getEnumName(value: string): string { name = sanitizeName(name); name = caser().ToSNAKE(name); + if (/^[0-9]/.test(name)) { + name = caser().ToSNAKE(sanitizeName(name)); + } if (rubyReservedKeywords.includes(name)) { name += "_VALUE"; diff --git a/templates/templates/typescriptv2/config.ts b/templates/templates/typescriptv2/config.ts index 85b71d67..28263ac9 100644 --- a/templates/templates/typescriptv2/config.ts +++ b/templates/templates/typescriptv2/config.ts @@ -844,6 +844,15 @@ function getConfigFields( ValidationRegex: /^(true|false)$/.source, ValidationMessage: "true or false only", }, + numericEnumMemberNames: { + Name: "numericEnumMemberNames", + Required: false, + DefaultValue: newSDK ? "words" : "legacy", + Description: + 'Member names for enum values that are only digits once a leading underscore is removed, such as `_1` or `_2024`. "words" spells the number out (`One`, `TwoThousandAndTwentyFour`); "legacy" (deprecated) keeps the numeric key (`1`, `2024`) for union enums. Native enums (`enumFormat: enum`) and values with letters after the digits (`_1ST`) always become words.', + ValidationRegex: /^(words|legacy)$/.source, + ValidationMessage: "words or legacy only", + }, }; } diff --git a/templates/templates/typescriptv2/includes/enums.ts b/templates/templates/typescriptv2/includes/enums.ts index 1173ab2e..2de597bf 100644 --- a/templates/templates/typescriptv2/includes/enums.ts +++ b/templates/templates/typescriptv2/includes/enums.ts @@ -117,12 +117,13 @@ function sanitizeEnumValue(value: any, type: GojaEnum): string { // @ts-ignore function getEnumNames(t: TypeDef): string[] { + const format = t.Enum ? getEnumFormat(t) : "union"; if (t.Enum?.Names.length > 0) { if (context.Global.Config.FixEnumNameSanitization === true) { return t.Enum.Names.map((n) => sanitizeName(n.trim() || "Unknown")); } - return t.Enum.Names.map((n) => getEnumName(n)); + return getEnumNamesFromValues(t.Enum.Names, format); } else { - return getEnumNamesFromValues(t.Enum?.Values); + return getEnumNamesFromValues(t.Enum?.Values, format); } } diff --git a/templates/templates/typescriptv2/includes/sanitization.ts b/templates/templates/typescriptv2/includes/sanitization.ts index e9fe309c..b9a0340e 100644 --- a/templates/templates/typescriptv2/includes/sanitization.ts +++ b/templates/templates/typescriptv2/includes/sanitization.ts @@ -1096,33 +1096,37 @@ function sanitizeMap(typeDef: TypeDef, usageLocation: string): string { } // @ts-ignore -function getEnumNamesFromValues(values) { - let enumNames = []; - - let names = {}; - for (const value of values) { - let name = getEnumName(value); - if (!names[name]) { - names[name] = 0; - } - - names[name] += 1; - } - - for (const value of values) { - let name = getEnumName(value); - if (names[name] > 1) { - name = `${name}${caser().ToPascal(getCasing(value))}`; +function getEnumNamesFromValues(values, format: "enum" | "union" = "union") { + return disambiguateEnumNames(values, (value) => getEnumName(value, format)); +} + +function disambiguateEnumNames( + values: string[], + deriveName: (value: string) => string, +): string[] { + const baseNames = values.map(deriveName); + const counts = new Map(); + for (const name of baseNames) { + counts.set(name, (counts.get(name) || 0) + 1); + } + + const used = new Set(baseNames.filter((name) => counts.get(name) === 1)); + return values.map((value, i) => { + let name = baseNames[i]; + if (counts.get(name) > 1) { + const candidate = `${name}${caser().ToPascal(getCasing(value))}`; + name = candidate; + for (let suffix = 1; used.has(name); suffix++) { + name = `${candidate}${suffix}`; + } + used.add(name); } - - enumNames.push(name); - } - - return enumNames; + return name; + }); } // @ts-ignore -function getEnumName(value) { +function getEnumName(value, format: "enum" | "union" = "union") { let name = value.trim(); if (name === "") { @@ -1131,11 +1135,33 @@ function getEnumName(value) { name = sanitizeName(name); - return caser().ToPascal(name); + name = caser().ToPascal(name); + if (/^[0-9]/.test(name) && !keepsNumericEnumMemberName(name, format)) { + name = caser().ToPascal(sanitizeName(name)); + } + return name; } registerTemplateFunc("getEnumName", getEnumName); +// Matches a cased name that is a valid numeric literal property key in strict +// mode: a decimal integer with an optional exponent, or a hex, binary or octal +// literal. Leading zeros (`007`, `08`) are excluded because strict mode rejects +// them, so those values are spelled out even under `legacy`. +const numericLiteralKey = + /^(?:(?:0|[1-9][0-9]*)(?:[eE][0-9]+)?|0[xX][0-9a-fA-F]+|0[bB][01]+|0[oO][0-7]+)$/; + +function keepsNumericEnumMemberName( + name: string, + format: "enum" | "union", +): boolean { + return ( + format === "union" && + context.Global.Config.NumericEnumMemberNames === "legacy" && + numericLiteralKey.test(name) + ); +} + function sanitizeZodName(name: string) { return sanitizeClassName(name) + "$"; } diff --git a/templates/templates/unity/includes/sanitization.ts b/templates/templates/unity/includes/sanitization.ts index e8c20adc..bca3f0e7 100644 --- a/templates/templates/unity/includes/sanitization.ts +++ b/templates/templates/unity/includes/sanitization.ts @@ -372,36 +372,46 @@ function getEnumName(value: string): string { } name = sanitizeName(name); - return caser().ToPascal(name); + name = caser().ToPascal(name); + if (/^[0-9]/.test(name)) { + name = caser().ToPascal(sanitizeName(name)); + } + return name; } registerTemplateFunc("getEnumName", getEnumName); // @ts-ignore function getEnumNamesFromValues(values: string[]): string[] { - const enumNames = []; - - const names = {}; - if (!values) return enumNames; - values.forEach((value) => { - const name = getEnumName(value); - if (!names[name]) { - names[name] = 0; - } + if (!values) return []; + return disambiguateEnumNames(values, getEnumName).map((name) => + sanitizeFieldName(name), + ); +} - names[name] += 1; - }); +function disambiguateEnumNames( + values: string[], + deriveName: (value: string) => string, +): string[] { + const baseNames = values.map(deriveName); + const counts = new Map(); + for (const name of baseNames) { + counts.set(name, (counts.get(name) || 0) + 1); + } - values.forEach((value) => { - let name = getEnumName(value); - if (names[name] > 1) { - name = `${name}${caser().ToPascal(getCasing(value))}`; + const used = new Set(baseNames.filter((name) => counts.get(name) === 1)); + return values.map((value, i) => { + let name = baseNames[i]; + if (counts.get(name) > 1) { + const candidate = `${name}${caser().ToPascal(getCasing(value))}`; + name = candidate; + for (let suffix = 1; used.has(name); suffix++) { + name = `${candidate}${suffix}`; + } + used.add(name); } - - enumNames.push(sanitizeFieldName(name)); + return name; }); - - return enumNames; } registerTemplateFunc("getEnumNamesFromValues", getEnumNamesFromValues); diff --git a/templates/templates/unity/includes/templating.ts b/templates/templates/unity/includes/templating.ts index 9c3c1dfc..5ecf4ae4 100644 --- a/templates/templates/unity/includes/templating.ts +++ b/templates/templates/unity/includes/templating.ts @@ -464,7 +464,7 @@ function templateFloatValue( // @ts-ignore function getEnumNames(t: TypeDef): string[] { if (t.Enum?.Names.length > 0) { - return t.Enum.Names.map((n) => getEnumName(n)); + return disambiguateEnumNames(t.Enum.Names, getEnumName); } else { return getEnumNamesFromValues(t.Enum?.Values); } diff --git a/tests/config/review/typescriptv2/.speakeasy/gen.yaml b/tests/config/review/typescriptv2/.speakeasy/gen.yaml index 3acbceb8..70a6ccff 100644 --- a/tests/config/review/typescriptv2/.speakeasy/gen.yaml +++ b/tests/config/review/typescriptv2/.speakeasy/gen.yaml @@ -86,6 +86,7 @@ typescript: modelPropertyCasing: camel moduleFormat: dual multipartArrayFormat: standard + numericEnumMemberNames: legacy outputModelSuffix: output packageName: openapi preApplyUnionDiscriminators: true diff --git a/tests/specs/fragments/uber/enum-leading-underscore-digit.yaml b/tests/specs/fragments/uber/enum-leading-underscore-digit.yaml new file mode 100644 index 00000000..aa956379 --- /dev/null +++ b/tests/specs/fragments/uber/enum-leading-underscore-digit.yaml @@ -0,0 +1,95 @@ +openapi: 3.1.0 +security: + - apiKeyAuth: [] + - oauth2: [] + - clientCredentials: [read, write] + - customSchemeAppId: [] + - basicHttp: [] + - accessToken: [] + - {} +paths: + /enums/leading-underscore-digit: + get: + operationId: getEnumLeadingUnderscoreDigit + description: >- + Returns an object whose enum property has values that start with an + underscore followed by digits (`_1ST`, `_2FA`, `_3D`, `_10`), the shape + protobuf-derived documents use for numeric-looking identifiers. Checks + that every target derives a valid member name for such values instead + of emitting a digit-leading identifier that fails to compile or format. + `_10` is digits-only, which TypeScript may keep as a numeric key in a + union enum but must spell out in a native enum. A second property + supplies the same shape of name through `x-speakeasy-enums`, which + must also yield a valid member name. A third property pairs `_1ST` + with `1ST`: both derive `OneSt`-shaped names that differ only by + case, so targets that upper-snake their members (Java, Python, + Ruby) must detect the collision on the emitted member and suffix + it instead of declaring `ONE_ST` twice. A fourth property lists + `_1`, `1` and `OneUpper`: the first two derive the same name and + receive a casing suffix, which lands on `OneUpper`, the untouched + third member. Targets must reserve every emitted member name and + keep advancing the numeric suffix until the candidate is unused. A + fifth property supplies `_1` and `One` through `x-speakeasy-enums`, + which derive the same name once the digit is spelled out, so + override names must be disambiguated as a group the same way values + are. + tags: + - generation + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + required: + - accountType + properties: + accountType: + $ref: "#/components/schemas/LeadingUnderscoreDigitEnum" + overriddenAccountType: + $ref: "#/components/schemas/LeadingUnderscoreDigitOverrideEnum" + collidingAccountType: + $ref: "#/components/schemas/LeadingUnderscoreDigitCollisionEnum" + suffixCollidingAccountType: + $ref: "#/components/schemas/LeadingUnderscoreDigitSuffixCollisionEnum" + overrideCollidingAccountType: + $ref: "#/components/schemas/LeadingUnderscoreDigitOverrideCollisionEnum" +components: + schemas: + LeadingUnderscoreDigitEnum: + type: string + enum: + - UNSPECIFIED + - _1ST + - _2FA + - _3D + - _10 + LeadingUnderscoreDigitOverrideEnum: + type: string + enum: + - UNSPECIFIED + - _1ST + x-speakeasy-enums: + - UNSPECIFIED + - _1ST + LeadingUnderscoreDigitCollisionEnum: + type: string + enum: + - UNSPECIFIED + - _1ST + - 1ST + LeadingUnderscoreDigitSuffixCollisionEnum: + type: string + enum: + - _1 + - "1" + - OneUpper + LeadingUnderscoreDigitOverrideCollisionEnum: + type: string + enum: + - first + - second + x-speakeasy-enums: + - _1 + - One diff --git a/zSDKs/mcp-typescript/.speakeasy/gen.lock b/zSDKs/mcp-typescript/.speakeasy/gen.lock index 7c6134f7..b72c1920 100644 --- a/zSDKs/mcp-typescript/.speakeasy/gen.lock +++ b/zSDKs/mcp-typescript/.speakeasy/gen.lock @@ -6,7 +6,7 @@ management: speakeasyVersion: internal generationVersion: internal releaseVersion: 0.0.1 - configChecksum: 7f1f041501e08ba8657f7296ff840fca + configChecksum: f5747e95ee98dab53dac436151673342 repoURL: https://github.com/speakeasy-sdks/test-sdk installationURL: https://github.com/speakeasy-sdks/test-sdk features: diff --git a/zSDKs/mcp-typescript/.speakeasy/gen.yaml b/zSDKs/mcp-typescript/.speakeasy/gen.yaml index 2f0c8a2b..24f74cf9 100644 --- a/zSDKs/mcp-typescript/.speakeasy/gen.yaml +++ b/zSDKs/mcp-typescript/.speakeasy/gen.yaml @@ -65,6 +65,7 @@ mcp-typescript: title: overlayParam1 type: string multipartArrayFormat: standard + numericEnumMemberNames: legacy outputModelSuffix: output packageName: openapi responseFormat: envelope diff --git a/zSDKs/sdk-typescriptv2/.speakeasy/gen.lock b/zSDKs/sdk-typescriptv2/.speakeasy/gen.lock index c5264dbb..d52a7377 100644 --- a/zSDKs/sdk-typescriptv2/.speakeasy/gen.lock +++ b/zSDKs/sdk-typescriptv2/.speakeasy/gen.lock @@ -6,7 +6,7 @@ management: speakeasyVersion: internal generationVersion: internal releaseVersion: 0.0.1 - configChecksum: d1039dff198a55d1d3e6d19e2e882fb7 + configChecksum: b9e582790d42dfeb89b905bb940389e2 repoURL: https://github.com/speakeasy-sdks/test-sdk installationURL: https://github.com/speakeasy-sdks/test-sdk features: diff --git a/zSDKs/sdk-typescriptv2/.speakeasy/gen.yaml b/zSDKs/sdk-typescriptv2/.speakeasy/gen.yaml index 3acbceb8..70a6ccff 100644 --- a/zSDKs/sdk-typescriptv2/.speakeasy/gen.yaml +++ b/zSDKs/sdk-typescriptv2/.speakeasy/gen.yaml @@ -86,6 +86,7 @@ typescript: modelPropertyCasing: camel moduleFormat: dual multipartArrayFormat: standard + numericEnumMemberNames: legacy outputModelSuffix: output packageName: openapi preApplyUnionDiscriminators: true