Skip to content

Commit 05bb685

Browse files
committed
fix(java): avoid identifier collisions between distinct wordless names
The wordless-name fallback added in the previous commit collapsed any input with no legal Java-identifier characters left (e.g. "-", "@") to the same placeholder ("_"). Two such discriminants as siblings in one union therefore generated identically-named methods and a duplicate nested class - generation reported success but emitted non-compiling Java. Encode the stripped characters' code points instead of collapsing to a shared placeholder, so distinct inputs always produce distinct identifiers (e.g. "-" -> "__2d", "@" -> "__40").
1 parent d7b67eb commit 05bb685

3 files changed

Lines changed: 40 additions & 14 deletions

File tree

generators/java/generator-utils/src/main/java/com/fern/java/utils/CasingConfiguration.java

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -195,12 +195,9 @@ private NameParts computeNameInternal(String inputName) {
195195
// splitWords() only recognizes letter/digit runs, so an input made entirely of
196196
// separators/symbols (e.g. "_", "-", "@", "-_-") produces zero words and every casing
197197
// variant above collapses to "". Recover a usable identifier instead of losing the name
198-
// entirely: strip characters that aren't legal in a Java identifier and keep whatever's
199-
// left (e.g. "$$" stays "$$", "-_-" becomes "_"); if nothing legal remains, fall back to
200-
// "_" (which sanitizeName further escapes to "__", since "_" alone is reserved).
198+
// entirely.
201199
if (camelCaseName.isEmpty() && !name.isEmpty()) {
202-
String legal = ILLEGAL_IDENTIFIER_CHARS.matcher(name).replaceAll("");
203-
String fallback = legal.isEmpty() ? "_" : legal;
200+
String fallback = wordlessFallback(name);
204201
camelCaseName = fallback;
205202
pascalCaseName = fallback;
206203
snakeCaseName = fallback;
@@ -223,6 +220,23 @@ private String preprocessName(String name) {
223220
return name.replace("[]", "Array");
224221
}
225222

223+
/**
224+
* Fallback identifier for names that produce zero words via splitWords() (e.g. "_", "-", "@", "-_-"). Strips
225+
* characters that aren't legal in a Java identifier and keeps whatever's left (e.g. "$$" stays "$$", "-_-" becomes
226+
* "_"). If nothing legal remains, encodes each stripped character's code point instead of collapsing to a single
227+
* shared placeholder - otherwise distinct inputs like "-" and "@" would both fall back to the same identifier and
228+
* silently collide (two identically-named methods/classes) if used as sibling discriminants in the same union.
229+
*/
230+
private static String wordlessFallback(String name) {
231+
String legal = ILLEGAL_IDENTIFIER_CHARS.matcher(name).replaceAll("");
232+
if (!legal.isEmpty()) {
233+
return legal;
234+
}
235+
StringBuilder encoded = new StringBuilder("_");
236+
name.codePoints().forEach(cp -> encoded.append('_').append(Integer.toHexString(cp)));
237+
return encoded.toString();
238+
}
239+
226240
private String sanitizeName(String name) {
227241
Set<String> effectiveKeywords = getEffectiveKeywords();
228242
if (effectiveKeywords == null) {

generators/java/generator-utils/src/test/java/com/fern/java/utils/CasingConfigurationTest.java

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -998,19 +998,29 @@ void computeName_ordinaryUnderscoreSeparatedName_unaffected() {
998998
class WordlessNameTests {
999999

10001000
@Test
1001-
void computeName_dash_fallsBackToUnderscore() {
1001+
void computeName_dash_encodesCodePoint() {
1002+
// No legal characters remain, so the code point (0x2d) is encoded instead of
1003+
// collapsing to a shared placeholder - see computeName_dashAndAt_dontCollide below.
10021004
CasingConfiguration.NameParts parts =
10031005
buildConfig(true, "java", null).computeName("-");
1004-
assertThat(parts.camelSafe).isEqualTo("__");
1005-
assertThat(parts.pascalSafe).isEqualTo("__");
1006-
assertThat(parts.snakeSafe).isEqualTo("__");
1007-
assertThat(parts.screamingSnakeSafe).isEqualTo("__");
1006+
assertThat(parts.camelSafe).isEqualTo("__2d");
1007+
assertThat(parts.pascalSafe).isEqualTo("__2d");
1008+
assertThat(parts.snakeSafe).isEqualTo("__2d");
1009+
assertThat(parts.screamingSnakeSafe).isEqualTo("__2D");
10081010
}
10091011

10101012
@Test
1011-
void computeName_at_fallsBackToUnderscore() {
1013+
void computeName_at_encodesCodePoint() {
10121014
assertThat(buildConfig(true, "java", null).computeName("@").camelSafe)
1013-
.isEqualTo("__");
1015+
.isEqualTo("__40");
1016+
}
1017+
1018+
@Test
1019+
void computeName_dashAndAt_dontCollide() {
1020+
// Distinct wordless inputs must not fall back to the same identifier - that would
1021+
// silently generate two identically-named methods/classes for sibling discriminants.
1022+
CasingConfiguration config = buildConfig(true, "java", null);
1023+
assertThat(config.computeName("-").camelSafe).isNotEqualTo(config.computeName("@").camelSafe);
10141024
}
10151025

10161026
@Test

generators/java/sdk/changes/unreleased/fix-underscore-discriminator.yml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
casing logic (used to reconstruct Java identifiers from the IR) collapsed such
77
values to an empty string before it could reach the reserved-keyword check, so
88
JavaPoet rejected the resulting empty method name. These values now fall back
9-
to their legal Java-identifier characters (or `_`, escaped to `__`, if none
10-
remain), while the serialized discriminator value is unchanged.
9+
to their legal Java-identifier characters, or - if none remain - an encoding of
10+
their code points (e.g. `-` becomes `__2d`, `@` becomes `__40`) so that distinct
11+
values never collide on the same generated identifier, while the serialized
12+
discriminator value is unchanged.
1113
type: fix

0 commit comments

Comments
 (0)