Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,16 @@ public final class CasingConfiguration {

private static final Pattern STARTS_WITH_NUMBER = Pattern.compile("^[0-9]");

// Characters legal in a Java identifier (the ASCII subset the rest of this class deals in).
private static final Pattern ILLEGAL_IDENTIFIER_CHARS = Pattern.compile("[^A-Za-z0-9_$]");

// Match lodash words() regex: [A-Z]+(?=[A-Z][a-z])|[A-Z]?[a-z]+|[A-Z]+|[0-9]+
private static final Pattern SPLIT_WORDS_PATTERN =
Pattern.compile("[A-Z]+(?=[A-Z][a-z])|[A-Z]?[a-z]+|[A-Z]+|[0-9]+");

// Java reserved keywords for keyword sanitization
private static final Set<String> JAVA_RESERVED_KEYWORDS = Set.of(
"_", // reserved since Java 9 (unused lambda param), a compile error since Java 21 (JEP 456)
"abstract",
"assert",
"boolean",
Expand Down Expand Up @@ -188,6 +192,18 @@ private NameParts computeNameInternal(String inputName) {
}
}

// splitWords() only recognizes letter/digit runs, so an input made entirely of
// separators/symbols (e.g. "_", "-", "@", "-_-") produces zero words and every casing
// variant above collapses to "". Recover a usable identifier instead of losing the name
// entirely.
if (camelCaseName.isEmpty() && !name.isEmpty()) {
String fallback = wordlessFallback(name);
camelCaseName = fallback;
pascalCaseName = fallback;
snakeCaseName = fallback;
screamingSnakeCaseName = fallback.toUpperCase();
}

return new NameParts(
inputName,
camelCaseName,
Expand All @@ -204,6 +220,23 @@ private String preprocessName(String name) {
return name.replace("[]", "Array");
}

/**
* Fallback identifier for names that produce zero words via splitWords() (e.g. "_", "-", "@", "-_-"). Strips
* characters that aren't legal in a Java identifier and keeps whatever's left (e.g. "$$" stays "$$", "-_-" becomes
* "_"). If nothing legal remains, encodes each stripped character's code point instead of collapsing to a single
* shared placeholder - otherwise distinct inputs like "-" and "@" would both fall back to the same identifier and
* silently collide (two identically-named methods/classes) if used as sibling discriminants in the same union.
*/
private static String wordlessFallback(String name) {
String legal = ILLEGAL_IDENTIFIER_CHARS.matcher(name).replaceAll("");
if (!legal.isEmpty()) {
return legal;
}
StringBuilder encoded = new StringBuilder("_");
name.codePoints().forEach(cp -> encoded.append('_').append(Integer.toHexString(cp)));
return encoded.toString();
}

private String sanitizeName(String name) {
Set<String> effectiveKeywords = getEffectiveKeywords();
if (effectiveKeywords == null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -957,4 +957,86 @@ private static CasingConfiguration buildConfig(boolean smartCasing, String langu
root.set("casingsConfig", casingsConfig);
return CasingConfiguration.fromIrJson(root);
}

// ===== keyword sanitization: all-underscore inputs =====

@Nested
class UnderscoreTests {

@Test
void computeName_singleUnderscore_isEscaped() {
CasingConfiguration config = buildConfig(true, "java", null);
CasingConfiguration.NameParts parts = config.computeName("_");
assertThat(parts.camelUnsafe).isEqualTo("_");
assertThat(parts.camelSafe).isEqualTo("__");
}

@Test
void computeName_doubleUnderscore_isAlreadyValid() {
CasingConfiguration config = buildConfig(true, "java", null);
CasingConfiguration.NameParts parts = config.computeName("__");
assertThat(parts.camelUnsafe).isEqualTo("__");
assertThat(parts.camelSafe).isEqualTo("__");
}

@Test
void computeName_tripleUnderscore_isAlreadyValid() {
CasingConfiguration config = buildConfig(true, "java", null);
assertThat(config.computeName("___").camelSafe).isEqualTo("___");
}

@Test
void computeName_ordinaryUnderscoreSeparatedName_unaffected() {
CasingConfiguration config = buildConfig(true, "java", null);
assertThat(config.computeName("user_id").camelSafe).isEqualTo("userId");
}
}

// ===== keyword sanitization: inputs with no letters/digits (wordless names) =====

@Nested
class WordlessNameTests {

@Test
void computeName_dash_encodesCodePoint() {
// No legal characters remain, so the code point (0x2d) is encoded instead of
// collapsing to a shared placeholder - see computeName_dashAndAt_dontCollide below.
CasingConfiguration.NameParts parts =
buildConfig(true, "java", null).computeName("-");
assertThat(parts.camelSafe).isEqualTo("__2d");
assertThat(parts.pascalSafe).isEqualTo("__2d");
assertThat(parts.snakeSafe).isEqualTo("__2d");
assertThat(parts.screamingSnakeSafe).isEqualTo("__2D");
}

@Test
void computeName_at_encodesCodePoint() {
assertThat(buildConfig(true, "java", null).computeName("@").camelSafe)
.isEqualTo("__40");
}

@Test
void computeName_dashAndAt_dontCollide() {
// Distinct wordless inputs must not fall back to the same identifier - that would
// silently generate two identically-named methods/classes for sibling discriminants.
CasingConfiguration config = buildConfig(true, "java", null);
assertThat(config.computeName("-").camelSafe).isNotEqualTo(config.computeName("@").camelSafe);
}

@Test
void computeName_dashUnderscoreDash_keepsLegalUnderscore() {
// "-_-" has its illegal '-' characters stripped, leaving "_", which is itself reserved.
assertThat(buildConfig(true, "java", null).computeName("-_-").camelSafe)
.isEqualTo("__");
}

@Test
void computeName_dollarSign_isAlreadyValid() {
// '$' is a legal Java identifier character, so it's kept as-is (not a reserved word).
CasingConfiguration.NameParts parts =
buildConfig(true, "java", null).computeName("$$");
assertThat(parts.camelUnsafe).isEqualTo("$$");
assertThat(parts.camelSafe).isEqualTo("$$");
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# yaml-language-server: $schema=../../../../../fern-changes-yml.schema.json

- summary: |
Fix generation failure when a discriminated union's discriminator value has no
letters or digits (e.g. `_`, `-`, `@`, `-_-`). Previously the compressed-name
casing logic (used to reconstruct Java identifiers from the IR) collapsed such
values to an empty string before it could reach the reserved-keyword check, so
JavaPoet rejected the resulting empty method name. These values now fall back
to their legal Java-identifier characters, or - if none remain - an encoding of
their code points (e.g. `-` becomes `__2d`, `@` becomes `__40`) so that distinct
values never collide on the same generated identifier, while the serialized
discriminator value is unchanged.
type: fix

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 suggestion

The fix lives in generator-utils, which the model (and other Java generators) also consume. If those generators ship their own changelogs, consider adding matching entries so consumers of fern-java-model etc. see the fix too.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checked — model doesn't have the changes/unreleased/ changelog automation that sdk does (no directory, no template), so there's nowhere equivalent to add an entry there right now. Scoping the changelog to sdk to match what's actually been verified end-to-end.

Loading