From dfa8ca325ee516ec8934a755fe9c415aeae81279 Mon Sep 17 00:00:00 2001 From: Morgan Bartholomew Date: Mon, 15 Jun 2026 14:11:44 +1000 Subject: [PATCH] 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 --- .../PyTypeHintDeclarativeRenderer.kt | 121 ++++++++++++++++++ .../inlayHints/PyTypeInlayHintsProvider.kt | 9 +- .../PyTypeInlayHintsProviderTest.kt | 55 +++++++- 3 files changed, 178 insertions(+), 7 deletions(-) create mode 100644 python/src/com/jetbrains/python/inlayHints/PyTypeHintDeclarativeRenderer.kt diff --git a/python/src/com/jetbrains/python/inlayHints/PyTypeHintDeclarativeRenderer.kt b/python/src/com/jetbrains/python/inlayHints/PyTypeHintDeclarativeRenderer.kt new file mode 100644 index 000000000000..11a4ac78e815 --- /dev/null +++ b/python/src/com/jetbrains/python/inlayHints/PyTypeHintDeclarativeRenderer.kt @@ -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) + } +} diff --git a/python/src/com/jetbrains/python/inlayHints/PyTypeInlayHintsProvider.kt b/python/src/com/jetbrains/python/inlayHints/PyTypeInlayHintsProvider.kt index 26b83db74e06..15d72b108fd6 100644 --- a/python/src/com/jetbrains/python/inlayHints/PyTypeInlayHintsProvider.kt +++ b/python/src/com/jetbrains/python/inlayHints/PyTypeInlayHintsProvider.kt @@ -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) } } diff --git a/python/testSrc/com/jetbrains/python/inlayHints/PyTypeInlayHintsProviderTest.kt b/python/testSrc/com/jetbrains/python/inlayHints/PyTypeInlayHintsProviderTest.kt index 81310dccba27..d5b143496796 100644 --- a/python/testSrc/com/jetbrains/python/inlayHints/PyTypeInlayHintsProviderTest.kt +++ b/python/testSrc/com/jetbrains/python/inlayHints/PyTypeInlayHintsProviderTest.kt @@ -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 #>*/: + 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| | |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|[|int|] #>*/: + return [1] + """, FUNCTION_RETURN_TYPE_OPTION_ID) + } + + @TestFor(issues = ["PY-90293"]) + fun `test parameter type name is navigable`() { + doTestNavigable(""" + def f(a/*<# : |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()) }