PY-90293 inlay-hints: make types clickable

 Conflicts:
	community/python/src/com/jetbrains/python/inlayHints/PyTypeInlayHintsProvider.kt
	community/python/testSrc/com/jetbrains/python/inlayHints/PyTypeInlayHintsProviderTest.kt

(cherry picked from commit 066a0701502abe27e6fa4ef32c6c5296aeb82f7b)

GitOrigin-RevId: 1a4e9f40de1f2e25e09f1b4940f3c331f38d85a1
This commit is contained in:
Morgan Bartholomew
2026-07-08 03:10:59 +00:00
committed by intellij-monorepo-bot
parent 637f091859
commit dfa8ca325e
3 changed files with 178 additions and 7 deletions
@@ -0,0 +1,121 @@
// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.jetbrains.python.inlayHints
import com.intellij.codeInsight.hints.declarative.InlayActionData
import com.intellij.codeInsight.hints.declarative.PresentationTreeBuilder
import com.intellij.codeInsight.hints.declarative.PsiPointerInlayActionNavigationHandler
import com.intellij.codeInsight.hints.declarative.PsiPointerInlayActionPayload
import com.intellij.codeInsight.hints.declarative.impl.PresentationTreeBuilderImpl.Companion.MAX_SEGMENT_TEXT_LENGTH
import com.intellij.psi.createSmartPointer
import com.jetbrains.python.codeInsight.typing.PyTypingTypeProvider
import com.jetbrains.python.documentation.PythonDocumentationProvider
import com.jetbrains.python.getEffectiveLanguageLevel
import com.jetbrains.python.psi.LanguageLevel
import com.jetbrains.python.psi.PyClass
import com.jetbrains.python.psi.types.PyClassType
import com.jetbrains.python.psi.types.PyCollectionType
import com.jetbrains.python.psi.types.PyLiteralStringType
import com.jetbrains.python.psi.types.PyLiteralType
import com.jetbrains.python.psi.types.PyNamedTupleType
import com.jetbrains.python.psi.types.PyTupleType
import com.jetbrains.python.psi.types.PyType
import com.jetbrains.python.psi.types.PyUnionType
import com.jetbrains.python.psi.types.TypeEvalContext
import com.jetbrains.python.psi.types.isNoneType
/**
* Renders [type] as a PEP 484-compliant type hint into a declarative inlay, making class names clickable so that
* Ctrl/Cmd-clicking a name navigates to the corresponding class definition.
*
* For the type shapes handled explicitly, the produced text matches [PythonDocumentationProvider.getTypeHint]; for
* everything else the rendering falls back to that method's plain (non-navigable) text, so the inlay text always stays
* in sync with the canonical renderer.
*/
internal fun PresentationTreeBuilder.printPyTypeHint(type: PyType?, context: TypeEvalContext) {
when {
type == null || type.isNoneType -> plainText(PythonDocumentationProvider.getTypeHint(type, context))
// Literal[...] renders its expression text rather than a class name.
type is PyLiteralType || type is PyLiteralStringType -> fallbackText(type, context)
// tuple[...] (homogeneous/empty forms) and NamedTuple have dedicated formatting.
type is PyTupleType || type is PyNamedTupleType -> fallbackText(type, context)
type is PyUnionType -> printUnion(type, context)
type is PyCollectionType && !type.isDefinition -> printGenericType(type, context)
type is PyClassType && !type.isDefinition -> printClassType(type, context)
else -> fallbackText(type, context)
}
}
private fun PresentationTreeBuilder.printUnion(union: PyUnionType, context: TypeEvalContext) {
// Without PEP 604 `X | Y` syntax, unions are rendered as Union[...]/Optional[...]; defer those to the canonical renderer.
if (!PyTypingTypeProvider.isBitwiseOrUnionAvailable(context)) {
fallbackText(union, context)
return
}
val members = union.members
// Optional: `X | None`, with the non-None member always rendered first (as PythonDocumentationProvider does).
if (members.size == 2 && members.any { it.isNoneType }) {
val nonNone = members.firstOrNull { !it.isNoneType }
if (nonNone != null) {
printPyTypeHint(nonNone, context)
plainText(" | ")
printPyTypeHint(members.first { it.isNoneType }, context)
return
}
}
// Plain `A | B | C`. Unions with the unknown type (null) or 2+ literals are formatted specially downstream.
if (members.none { it == null || it.isNoneType } && members.count { it is PyLiteralType } < 2) {
members.forEachIndexed { index, member ->
if (index > 0) plainText(" | ")
printPyTypeHint(member, context)
}
return
}
fallbackText(union, context)
}
private fun PresentationTreeBuilder.printGenericType(type: PyCollectionType, context: TypeEvalContext) {
val name = type.name
val origin = context.origin
val builtinGenericsAvailable = origin == null || getEffectiveLanguageLevel(origin).isAtLeast(LanguageLevel.PYTHON39)
// On older language levels typing.List/Dict/... are substituted for list[]/dict[]/...; defer that to the canonical renderer.
if (name == null || (!builtinGenericsAvailable && PyTypingTypeProvider.TYPING_COLLECTION_CLASSES.containsKey(name))) {
fallbackText(type, context)
return
}
clickableClassName(name, type.pyClass)
plainText("[")
type.elementTypes.forEachIndexed { index, element ->
if (index > 0) plainText(", ")
printPyTypeHint(element, context)
}
plainText("]")
}
private fun PresentationTreeBuilder.printClassType(type: PyClassType, context: TypeEvalContext) {
val name = type.name
if (name == null) {
fallbackText(type, context)
return
}
clickableClassName(name, type.pyClass)
}
private fun PresentationTreeBuilder.clickableClassName(name: String, pyClass: PyClass) {
val actionData = InlayActionData(PsiPointerInlayActionPayload(pyClass.createSmartPointer()),
PsiPointerInlayActionNavigationHandler.HANDLER_ID)
// The platform forbids a single text segment longer than MAX_SEGMENT_TEXT_LENGTH characters.
for (segment in name.chunked(MAX_SEGMENT_TEXT_LENGTH)) {
text(segment, actionData)
}
}
private fun PresentationTreeBuilder.fallbackText(type: PyType?, context: TypeEvalContext) {
plainText(PythonDocumentationProvider.getTypeHint(type, context))
}
private fun PresentationTreeBuilder.plainText(text: String) {
// The platform forbids a single text segment longer than MAX_SEGMENT_TEXT_LENGTH characters.
for (segment in text.chunked(MAX_SEGMENT_TEXT_LENGTH)) {
text(segment)
}
}
@@ -116,11 +116,10 @@ class PyTypeInlayHintsProvider : InlayHintsProvider {
else -> type
}
val typeHint = PythonDocumentationProvider.getTypeHint(type, typeEvalContext)
sink.addPresentation(position = InlineInlayPosition(function.parameterList.textRange.endOffset, true),
hintFormat = returnTypeHintFormat) {
text("-> ")
appendTypeHint(typeHint)
printPyTypeHint(type, typeEvalContext)
}
}
}
@@ -175,16 +174,14 @@ class PyTypeInlayHintsProvider : InlayHintsProvider {
else -> rawParameterType
} ?: return
val typeHint = PythonDocumentationProvider.getTypeHint(parameterType, typeEvalContext)
val offset = parameter.nameIdentifier?.textRange?.endOffset ?: parameter.textRange.endOffset
sink.addPresentation(
position = InlineInlayPosition(offset, true),
hintFormat = HintFormat.default
) {
text(": $typeHint")
text(": ")
printPyTypeHint(parameterType, typeEvalContext)
}
}
@@ -12,6 +12,7 @@ import com.jetbrains.python.inlayHints.PyTypeInlayHintsProvider.Companion.SOLVED
import com.jetbrains.python.inlayHints.PyTypeInlayHintsProvider.Companion.SOLVED_FUNCTION_TYPE_PARAMETERS_OPTION_ID
import com.jetbrains.python.inlayHints.PyTypeInlayHintsProvider.Companion.VARIANCE_OPTION_ID
import com.jetbrains.python.psi.LanguageLevel
import com.jetbrains.python.psi.PyClass
class PyTypeInlayHintsProviderTest : DeclarativeInlayHintsProviderTestCase() {
@@ -35,7 +36,7 @@ class PyTypeInlayHintsProviderTest : DeclarativeInlayHintsProviderTestCase() {
def bar(a: int)/*<# -> Literal["Hi!"] #>*/:
return "Hi!"
def gen(a: int)/*<# -> Generator[Literal[42, "str"] | float, Any, Literal["Hi!", 42… #>*/:
def gen(a: int)/*<# -> Generator[Literal[42, "str"] | float, Any, Literal["Hi!", 42]] #>*/:
yield 42
yield "str"
yield 42.5
@@ -322,6 +323,38 @@ class PyTypeInlayHintsProviderTest : DeclarativeInlayHintsProviderTestCase() {
""", false, SOLVED_CLASS_TYPE_PARAMETERS_OPTION_ID)
}
@TestFor(issues = ["PY-90293"])
fun `test return type name is navigable`() {
doTestNavigable("""
def f(x: int)/*<# -> |<int>int #>*/:
return x
""", FUNCTION_RETURN_TYPE_OPTION_ID)
}
@TestFor(issues = ["PY-90293"])
fun `test return type names in union are navigable`() {
doTestNavigable("""
def f(x: int, y: float)/*<# -> |<float>float| | |<int>int #>*/:
return x + y
""", FUNCTION_RETURN_TYPE_OPTION_ID)
}
@TestFor(issues = ["PY-90293"])
fun `test return type names in generic are navigable`() {
doTestNavigable("""
def f()/*<# -> |<list>list|[|<int>int|] #>*/:
return [1]
""", FUNCTION_RETURN_TYPE_OPTION_ID)
}
@TestFor(issues = ["PY-90293"])
fun `test parameter type name is navigable`() {
doTestNavigable("""
def f(a/*<# : |<int>int #>*/=1):
pass
""", PARAMETER_TYPE_ANNOTATION)
}
private val allOptions = mapOf(
REVEAL_TYPE_OPTION_ID to true,
FUNCTION_RETURN_TYPE_OPTION_ID to true,
@@ -345,6 +378,26 @@ class PyTypeInlayHintsProviderTest : DeclarativeInlayHintsProviderTestCase() {
testMode = ProviderTestMode.SIMPLE)
}
/**
* Renders hints in [ProviderTestMode.DETAILED] mode, where each navigable name carries the resolved class wrapped in
* `<>` so the expected text can assert both the rendered text and the navigation target of each clickable name.
*/
private fun doTestNavigable(text: String, vararg enabledOptions: String) {
customToStringProvider = { element -> "<${(element as PyClass).name}>" }
try {
val testOptions = allOptions.mapValues { (key, _) -> key in enabledOptions }
doTestProvider("A.py",
text.trimIndent(),
PyTypeInlayHintsProvider(),
testOptions,
verifyHintsPresence = true,
testMode = ProviderTestMode.DETAILED)
}
finally {
customToStringProvider = null
}
}
override fun getProjectDescriptor(): LightProjectDescriptor {
return PyLightProjectDescriptor(LanguageLevel.getLatest())
}