diff --git a/graphql-dgs-codegen-core/src/main/kotlin/com/netflix/graphql/dgs/codegen/generators/java/ClientApiGenerator.kt b/graphql-dgs-codegen-core/src/main/kotlin/com/netflix/graphql/dgs/codegen/generators/java/ClientApiGenerator.kt index 198ba06b7..175c3a071 100644 --- a/graphql-dgs-codegen-core/src/main/kotlin/com/netflix/graphql/dgs/codegen/generators/java/ClientApiGenerator.kt +++ b/graphql-dgs-codegen-core/src/main/kotlin/com/netflix/graphql/dgs/codegen/generators/java/ClientApiGenerator.kt @@ -86,6 +86,7 @@ class ClientApiGenerator internal constructor( private val rootType = TypeVariableName.get("ROOT") private val typeUtils = TypeUtils(getDatatypesPackageName(), config, schemaIndex) private val javaReservedKeywordSanitizer = JavaReservedKeywordSanitizer() + private val javaDocEscaper = JavaDocEscaper() private fun projectionType( name: String, @@ -188,7 +189,7 @@ class ClientApiGenerator internal constructor( .superclass(ClassName.get(GraphQLQuery::class.java)) if (it.description != null) { - javaType.addJavadoc("\$L", it.description.content.escapeJavaDocComparisons()) + javaType.addJavadoc("\$L", javaDocEscaper.escape(it.description.content)) } val deprecatedClassDirective = getDeprecateDirective(it) @@ -196,7 +197,7 @@ class ClientApiGenerator internal constructor( javaType.addAnnotation(Deprecated::class.java) val deprecationReason = getDeprecatedReason(deprecatedClassDirective) if (deprecationReason != null) { - javaType.addJavadoc("@deprecated \$L", deprecationReason.escapeJavaDocComparisons()) + javaType.addJavadoc("@deprecated \$L", javaDocEscaper.escape(deprecationReason)) } } @@ -447,13 +448,13 @@ class ClientApiGenerator internal constructor( val javaDoc = CodeBlock.builder() if (inputValue.description != null) { - javaDoc.add("\$L", inputValue.description.content.escapeJavaDocComparisons()) + javaDoc.add("\$L", javaDocEscaper.escape(inputValue.description.content)) } if (deprecationReason != null) { if (!javaDoc.isEmpty) { javaDoc.add("\n\n") } - javaDoc.add("@deprecated \$L", deprecationReason.escapeJavaDocComparisons()) + javaDoc.add("@deprecated \$L", javaDocEscaper.escape(deprecationReason)) } if (!javaDoc.isEmpty) { diff --git a/graphql-dgs-codegen-core/src/main/kotlin/com/netflix/graphql/dgs/codegen/generators/java/DataTypeGenerator.kt b/graphql-dgs-codegen-core/src/main/kotlin/com/netflix/graphql/dgs/codegen/generators/java/DataTypeGenerator.kt index 60cd6e049..adbe2491c 100644 --- a/graphql-dgs-codegen-core/src/main/kotlin/com/netflix/graphql/dgs/codegen/generators/java/DataTypeGenerator.kt +++ b/graphql-dgs-codegen-core/src/main/kotlin/com/netflix/graphql/dgs/codegen/generators/java/DataTypeGenerator.kt @@ -427,6 +427,7 @@ abstract class BaseDataTypeGenerator internal constructor( internal val document = schemaIndex.document internal val typeUtils = TypeUtils(packageName, config, schemaIndex) private val javaReservedKeywordSanitizer = JavaReservedKeywordSanitizer() + private val javaDocEscaper = JavaDocEscaper() internal fun generate( name: String, @@ -459,7 +460,7 @@ abstract class BaseDataTypeGenerator internal constructor( } if (description != null) { - javaType.addJavadoc("\$L", description.content.escapeJavaDocComparisons()) + javaType.addJavadoc("\$L", javaDocEscaper.escape(description.content)) } if (directives.isNotEmpty()) { @@ -468,7 +469,7 @@ abstract class BaseDataTypeGenerator internal constructor( javaType.addAnnotations(annotations[SiteTarget.DEFAULT.name]) } if (!comments.isNullOrBlank()) { - javaType.addJavadoc("\$L", comments.escapeJavaDocComparisons()) + javaType.addJavadoc("\$L", javaDocEscaper.escape(comments)) } } @@ -721,13 +722,13 @@ abstract class BaseDataTypeGenerator internal constructor( } if (fieldDefinition.description != null) { - fieldBuilder.addJavadoc("\$L", fieldDefinition.description.content.escapeJavaDocComparisons()) + fieldBuilder.addJavadoc("\$L", javaDocEscaper.escape(fieldDefinition.description.content)) } if (fieldDefinition.directives.isNotEmpty()) { val (annotations, comments) = applyDirectivesJava(fieldDefinition.directives, config) if (!comments.isNullOrBlank()) { - fieldBuilder.addJavadoc("\$L", comments.escapeJavaDocComparisons()) + fieldBuilder.addJavadoc("\$L", javaDocEscaper.escape(comments)) } for ((key, value) in annotations) { when (SiteTarget.valueOf(key)) { @@ -777,7 +778,7 @@ abstract class BaseDataTypeGenerator internal constructor( } if (fieldDefinition.description != null) { - getterMethodBuilder.addJavadoc("\$L", fieldDefinition.description.content.escapeJavaDocComparisons()) + getterMethodBuilder.addJavadoc("\$L", javaDocEscaper.escape(fieldDefinition.description.content)) } val setterName = @@ -932,7 +933,7 @@ abstract class BaseDataTypeGenerator internal constructor( .returns(builderClassName) if (fieldDefinition.description != null) { - methodBuilder.addJavadoc("\$L", fieldDefinition.description.content.escapeJavaDocComparisons()) + methodBuilder.addJavadoc("\$L", javaDocEscaper.escape(fieldDefinition.description.content)) } if (fieldDefinition.trackFieldSet) { diff --git a/graphql-dgs-codegen-core/src/main/kotlin/com/netflix/graphql/dgs/codegen/generators/java/EnumTypeGenerator.kt b/graphql-dgs-codegen-core/src/main/kotlin/com/netflix/graphql/dgs/codegen/generators/java/EnumTypeGenerator.kt index c813ebaa0..1fa0268ba 100644 --- a/graphql-dgs-codegen-core/src/main/kotlin/com/netflix/graphql/dgs/codegen/generators/java/EnumTypeGenerator.kt +++ b/graphql-dgs-codegen-core/src/main/kotlin/com/netflix/graphql/dgs/codegen/generators/java/EnumTypeGenerator.kt @@ -34,6 +34,7 @@ class EnumTypeGenerator( private val config: CodeGenConfig, ) { private val javaReservedKeywordSanitizer = JavaReservedKeywordSanitizer() + private val javaDocEscaper = JavaDocEscaper() companion object { private val logger: Logger = LoggerFactory.getLogger(EnumTypeGenerator::class.java) @@ -56,7 +57,7 @@ class EnumTypeGenerator( .addOptionalGeneratedAnnotation(config) if (definition.description != null) { - javaType.addJavadoc("\$L", definition.description.content.escapeJavaDocComparisons()) + javaType.addJavadoc("\$L", javaDocEscaper.escape(definition.description.content)) } val mergedEnumDefinitions = definition.enumValueDefinitions + extensions.flatMap { it.enumValueDefinitions } @@ -64,12 +65,12 @@ class EnumTypeGenerator( mergedEnumDefinitions.forEach { val typeSpec = TypeSpec.anonymousClassBuilder("") if (it.description != null && it.description.content.isNotBlank()) { - typeSpec.addJavadoc("\$L", it.description.content.escapeJavaDocComparisons()) + typeSpec.addJavadoc("\$L", javaDocEscaper.escape(it.description.content)) } if (it.directives.isNotEmpty()) { val (annotations, comments) = applyDirectivesJava(it.directives, config) if (!comments.isNullOrBlank()) { - typeSpec.addJavadoc("\$L", comments.escapeJavaDocComparisons()) + typeSpec.addJavadoc("\$L", javaDocEscaper.escape(comments)) } for (entry in annotations) { when (SiteTarget.valueOf(entry.key)) { diff --git a/graphql-dgs-codegen-core/src/main/kotlin/com/netflix/graphql/dgs/codegen/generators/java/InterfaceGenerator.kt b/graphql-dgs-codegen-core/src/main/kotlin/com/netflix/graphql/dgs/codegen/generators/java/InterfaceGenerator.kt index 537fc4b6d..303dba4ff 100644 --- a/graphql-dgs-codegen-core/src/main/kotlin/com/netflix/graphql/dgs/codegen/generators/java/InterfaceGenerator.kt +++ b/graphql-dgs-codegen-core/src/main/kotlin/com/netflix/graphql/dgs/codegen/generators/java/InterfaceGenerator.kt @@ -46,6 +46,7 @@ class InterfaceGenerator internal constructor( private val document = schemaIndex.document private val javaReservedKeywordSanitizer = JavaReservedKeywordSanitizer() + private val javaDocEscaper = JavaDocEscaper() private val packageName = config.packageNameTypes private val typeUtils = TypeUtils(packageName, config, schemaIndex) private val useInterfaceType = config.generateInterfaces @@ -70,7 +71,7 @@ class InterfaceGenerator internal constructor( } if (definition.description != null) { - javaType.addJavadoc("\$L", definition.description.content.escapeJavaDocComparisons()) + javaType.addJavadoc("\$L", javaDocEscaper.escape(definition.description.content)) } definition.implements @@ -175,7 +176,7 @@ class InterfaceGenerator internal constructor( ).addModifiers(Modifier.ABSTRACT, Modifier.PUBLIC) .returns(returnType) if (fieldDefinition.description != null) { - getterBuilder.addJavadoc("\$L", fieldDefinition.description.content.escapeJavaDocComparisons()) + getterBuilder.addJavadoc("\$L", javaDocEscaper.escape(fieldDefinition.description.content)) } // Add @Nullable annotation for nullable return types when JSpecify is enabled @@ -209,7 +210,7 @@ class InterfaceGenerator internal constructor( .addParameter(parameterBuilder.build()) if (fieldDefinition.description != null) { - setterBuilder.addJavadoc("\$L", fieldDefinition.description.content.escapeJavaDocComparisons()) + setterBuilder.addJavadoc("\$L", javaDocEscaper.escape(fieldDefinition.description.content)) } javaType.addMethod(setterBuilder.build()) } diff --git a/graphql-dgs-codegen-core/src/main/kotlin/com/netflix/graphql/dgs/codegen/generators/java/JavaPoetUtils.kt b/graphql-dgs-codegen-core/src/main/kotlin/com/netflix/graphql/dgs/codegen/generators/java/JavaPoetUtils.kt index 5c2b4ac32..00eeeed3a 100644 --- a/graphql-dgs-codegen-core/src/main/kotlin/com/netflix/graphql/dgs/codegen/generators/java/JavaPoetUtils.kt +++ b/graphql-dgs-codegen-core/src/main/kotlin/com/netflix/graphql/dgs/codegen/generators/java/JavaPoetUtils.kt @@ -321,25 +321,41 @@ private fun typeClassBestGuess(name: String): TypeName = else -> ClassName.bestGuess(name) } +/** Reuse descriptions across emission sites without retaining schemas beyond their generator. */ +internal class JavaDocEscaper { + private val escapedDescriptions = mutableMapOf() + + fun escape(description: String): String = escapedDescriptions.getOrPut(description) { description.escapeJavaDocComparisons() } +} + /** Preserve Javadoc markup while escaping prose that would break doclint or end the Java comment. */ -internal fun String.escapeJavaDocComparisons(): String = - buildString { - var offset = 0 - while (offset < this@escapeJavaDocComparisons.length) { - val end = this@escapeJavaDocComparisons.javaDocMarkupEnd(offset) - if (end > offset) { - append(this@escapeJavaDocComparisons.substring(offset, end).escapeInlineJavaDocSourceEscapes()) - offset = end - } else { - val character = this@escapeJavaDocComparisons[offset++] - when { - character == '<' -> append("<") - character == '&' && bareJavaDocAmpersand.matchesAt(this@escapeJavaDocComparisons, offset - 1) -> append("&") - else -> append(character) +internal fun String.escapeJavaDocComparisons(): String { + if (none { it == '<' || it == '&' || it == '\\' } && !contains("*/")) return this + + val escaped = + buildString(length) { + var offset = 0 + while (offset < this@escapeJavaDocComparisons.length) { + val end = + when (this@escapeJavaDocComparisons[offset]) { + '<', '{' -> this@escapeJavaDocComparisons.javaDocMarkupEnd(offset) + else -> -1 + } + if (end > offset) { + append(this@escapeJavaDocComparisons.substring(offset, end).escapeInlineJavaDocSourceEscapes()) + offset = end + } else { + val character = this@escapeJavaDocComparisons[offset++] + when { + character == '<' -> append("<") + character == '&' && bareJavaDocAmpersand.matchesAt(this@escapeJavaDocComparisons, offset - 1) -> append("&") + else -> append(character) + } } } - } - }.replace("*/", "*/").replace(javaDocUnicodeEscape) { "${it.groupValues[1]}\" } + }.replace("*/", "*/") + return if ('\\' in escaped) escaped.replace(javaDocUnicodeEscape) { "${it.groupValues[1]}\" } else escaped +} private val bareJavaDocAmpersand = Regex("&(?!([a-zA-Z][a-zA-Z0-9]*|#[0-9]+|#[xX][0-9a-fA-F]+);)") @@ -349,7 +365,7 @@ private val javaDocUnicodeEscape = Regex("""(? = listOf( + "Plain text with ${'$'}value and a/b" to "Plain text with ${'$'}value and a/b", + "Use **bold** or *emphasis*" to "Use **bold** or *emphasis*", + "{@code plain text} {@literal {value}}" to "{@code plain text} {@literal {value}}", "created_at:2024" to "created_at:<now and created_at:>2024", "x 3" to "x <y then z > 3",