PY-33261 Inlay parameter hints for Python method and function calls

Merge-request: IJ-MR-108471
Merged-by: Daniil Kalinin <Daniil.Kalinin@jetbrains.com>

GitOrigin-RevId: 1d9451020a755c0aaf57c12b16829f8b31291e5d
This commit is contained in:
Daniil Kalinin
2023-06-20 05:27:45 +00:00
committed by intellij-monorepo-bot
parent a61dc8fee2
commit 157b23ce53
6 changed files with 361 additions and 0 deletions

View File

@@ -257,6 +257,7 @@ runcfg.error.message.python.interpreter.is.invalid.configure=Please <a href="#">
# Consoles messages
python.console=Python Console
python.console.history.root=Python Consoles
python.console.run.anything.provider=Runs Python Console
python.console.not.supported=Python console for {0} interpreter is not supported
python.console.toolbar.action.available.non.interactive=The action is not available for non-interactive shell
@@ -1433,3 +1434,20 @@ black.sdk.not.configured.error=No project SDK configured for the project {0}
black.sdk.not.configured.error.title=SDK not configured
black.remote.sdk.exception.text=Black formatter invocation in Package mode is not allowed on remote SDKs
black.sdk.selection.combobox.label=Select Python SDK:
inlay.parameters.python.show.class.constructor.call.parameter.names=Class constructor calls
inlay.parameters.python.show.hints.for.non-literal.arguments=Non-literal arguments
inlay.parameters.python.hints.blacklist.explanation=\
<p>To disable hints for a method or a function, use one of the following patterns:</p><br>\
<p style="margin-left: 5px">\
<code><b>(*info)</b></code> - all single parameter functions where the parameter name ends with <em>info</em><br>\
<code><b>(key, value)</b></code> - all functions with parameters <em>key</em> and <em>value</em><br>\
<code><b>*.put(self, key, value)</b></code> - all <em>put</em> methods with <em>key</em> and <em>value</em> parameters<br>\
<code><b>Clazz.foo(self, a, b)</b></code> - method <em>foo</em> of class <em>Clazz</em> with parameters <em>a</em> and <em>b</em><br>\
<code><b>foo(\\&lt;star\\&gt;\\&lt;star\\&gt;kwargs)</b></code> - function <em>foo</em> with parameter <em>**kwargs</em><br>\
</p><br>\
<p>\
Names or placeholders must be provided for all parameters, including the optional parameters.<br>\
Qualified method names must include class names, or a placeholder for them.<br>\
Use the "Do not show hints for current method" {0} action to add patterns when editing code.\
</p>

View File

@@ -225,6 +225,9 @@
<localInspection language="Python" shortName="PyPep8NamingInspection" suppressId="PyPep8Naming" bundle="messages.PyPsiBundle" key="INSP.NAME.pep8.naming" groupKey="INSP.GROUP.python" enabledByDefault="true" level="WEAK WARNING" implementationClass="com.jetbrains.python.inspections.PyPep8NamingInspection"/>
<localInspection language="Python" shortName="PyShadowingBuiltinsInspection" suppressId="PyShadowingBuiltins" bundle="messages.PyPsiBundle" key="INSP.NAME.shadowing.builtins" groupKey="INSP.GROUP.python" enabledByDefault="true" level="WEAK WARNING" implementationClass="com.jetbrains.python.inspections.PyShadowingBuiltinsInspection"/>
<codeInsight.parameterNameHints language="Python"
implementationClass="com.jetbrains.python.inlayHints.PythonInlayParameterHintsProvider"/>
<intentionAction>
<className>com.jetbrains.python.codeInsight.intentions.PyConvertMethodToPropertyIntention</className>
<bundleName>messages.PyPsiBundle</bundleName>

View File

@@ -0,0 +1,6 @@
class Rectangle:
def __init__(self, length, width):
self.length = length
self.width = width
Rectangle(10, 4)

View File

@@ -0,0 +1,7 @@
def foo(a, b):
print(a + b)
arg1 = 1
arg2 = 2
foo(arg1, arg2)

View File

@@ -0,0 +1,157 @@
// Copyright 2000-2023 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.HintInfo
import com.intellij.codeInsight.hints.InlayInfo
import com.intellij.codeInsight.hints.InlayParameterHintsProvider
import com.intellij.codeInsight.hints.Option
import com.intellij.openapi.actionSystem.IdeActions
import com.intellij.openapi.keymap.KeymapUtil
import com.intellij.psi.PsiElement
import com.intellij.psi.PsiFile
import com.intellij.psi.util.QualifiedName
import com.jetbrains.python.PyBundle
import com.jetbrains.python.psi.*
import com.jetbrains.python.psi.PyCallExpression.PyArgumentsMapping
import com.jetbrains.python.psi.impl.PyBuiltinCache
import com.jetbrains.python.psi.resolve.PyResolveContext
import com.jetbrains.python.psi.types.TypeEvalContext
class PythonInlayParameterHintsProvider : InlayParameterHintsProvider {
companion object {
val showForClassConstructorCalls: Option = Option("python.show.class.constructor.call.parameter.names",
PyBundle.messagePointer(
"inlay.parameters.python.show.class.constructor.call.parameter.names"),
true)
val showForNonLiteralArguments: Option = Option("python.show.hints.for.non-literal.arguments",
PyBundle.messagePointer(
"inlay.parameters.python.show.hints.for.non-literal.arguments"),
true)
}
private fun getInlayInfoForArgumentList(node: PyArgumentList): List<InlayInfo> {
if (node.parent is PyClass || node.arguments.size == 1) {
return emptyList()
}
val context = TypeEvalContext.codeAnalysis(node.project, node.containingFile)
val resolveContext = PyResolveContext.defaultContext(context)
val callExpression = node.callExpression ?: return emptyList()
val argumentMappings = callExpression.multiMapArguments(resolveContext)
val mapping = if (argumentMappings.isParameterHintSafeOverloads()) argumentMappings.first() else return emptyList()
val callable = mapping.callableType?.callable
if (callable == null || (PyUtil.isInitOrNewMethod(callable) && !showForClassConstructorCalls.isEnabled())) {
return emptyList()
}
val info = mutableListOf<InlayInfo>()
val mappedParameters = mapping.mappedParameters
mappedParameters.forEach { (argument, parameter) ->
if (parameter != null && argument != null) {
if (parameter.isPositionalContainer) {
info.add(InlayInfo("*${parameter.name}", argument.textOffset))
return info
}
if (parameter.isKeywordContainer) {
return info
}
if (argument !is PyKeywordArgument) {
if (argument is PyLiteralExpression || showForNonLiteralArguments.isEnabled()) {
info.add(InlayInfo("${parameter.name}", argument.textOffset))
}
}
}
}
return info
}
/**
* Determines whether it is safe to show hints for a method.
*
* @return {@code true} if the method does not have overloads or all the overloads have the same signature, i.e.,
* all parameters have the same names and are placed in the same order.
* Otherwise, return {@code false}.
*/
private fun List<PyArgumentsMapping>.isParameterHintSafeOverloads(): Boolean {
if (this.size == 1) return true
return this
.asSequence()
.map { mapping -> mapping.mappedParameters }
.map { parameters -> parameters.values }
.map { listOfParameters ->
listOfParameters.map { parameter ->
parameter.getPresentableText(false)
}
}
.distinct()
.count() == 1
}
override fun getHintInfo(element: PsiElement): HintInfo? {
if (element is PyArgumentList) {
val parent = element.parent
if (parent is PyCallExpression) {
val callee = parent.callee
if (callee == null) return null
val context = TypeEvalContext.codeAnalysis(element.project, element.containingFile)
val resolveContext = PyResolveContext.defaultContext(context)
val callableType = parent.multiResolveCallee(resolveContext).firstOrNull() ?: return null
val callable = callableType.callable
if (callable is PyQualifiedNameOwner) {
val qName = callable.qualifiedName
val fullQName = if (PyBuiltinCache.isInBuiltins(callee)) qName?.toQNameForBuiltins() else qName
val parameterList = callable.parameterList
if (fullQName != null) {
return HintInfo.MethodInfo(fullQName, parameterList.parameters
.map { parameter ->
parameter.asNamed?.getRepr(false)?.replace("*", "<star>")
?: if (parameter is PySlashParameter) "/" else "*"
})
}
}
}
}
return null
}
private fun String.toQNameForBuiltins(): String {
val components = mutableListOf("builtins")
components.addAll(this.split("."))
return QualifiedName.fromComponents(components).toString()
}
override fun getParameterHints(element: PsiElement, file: PsiFile) =
if (element is PyArgumentList) {
getInlayInfoForArgumentList(element)
}
else emptyList()
override fun getDefaultBlackList() =
setOf("builtins.*",
"typing.*")
override fun getBlacklistExplanationHTML(): String {
return PyBundle.message("inlay.parameters.python.hints.blacklist.explanation",
KeymapUtil.getFirstKeyboardShortcutText(IdeActions.ACTION_SHOW_INTENTION_ACTIONS))
}
override fun getSupportedOptions() =
listOf(showForClassConstructorCalls,
showForNonLiteralArguments)
override fun isBlackListSupported(): Boolean = true
}

View File

@@ -0,0 +1,170 @@
// Copyright 2000-2023 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.Option
import com.jetbrains.python.PythonFileType
import com.jetbrains.python.fixtures.PyTestCase
class PyInlayParameterHintsTest : PyTestCase() {
fun testHintsShownForFunctionCallWithMultipleArguments() {
doTest("""
def foo(a, b, c):
pass
foo(<hint text="a:"/>1, <hint text="b:"/>2, <hint text="c:"/>3)
""".trimIndent())
}
fun testHintsNotShownForFunctionCallWithSingleArgument() {
doTest("""
def foo(a):
pass
foo(1)
""".trimIndent())
}
fun testHintsNotShownForKeywordArguments() {
doTest("""
def foo(a, b):
pass
foo(<hint text="a:"/>1, b=2)
""".trimIndent())
}
fun testHintsShownForClassConstructorCall() {
doTestWithOptions("""
class Clazz:
def __init__(self, a, b, c):
pass
c = Clazz(<hint text="a:"/>1, <hint text="b:"/>2, <hint text="c:"/>3)
""".trimIndent(),
PythonInlayParameterHintsProvider.showForClassConstructorCalls)
}
fun testHintsShownOnlyForLiterals() {
doTestWithOptions("""
def foo(a, b):
pass
x = "variable"
foo(<hint text="a:"/>"literal", x)
""".trimIndent(),
PythonInlayParameterHintsProvider.showForNonLiteralArguments,
enabled = false)
}
fun testHintsForNonKeywordArguments() {
doTest("""
def foo(*args):
pass
foo(<hint text="*args:"/>1, 2, 3, 4, 5)
""".trimIndent())
}
fun testHintsForBuiltinFunctionCallsNotShownByDefault() {
doTest("""
class Clazz:
pass
c = Clazz
isinstance(c, Clazz)
""".trimIndent())
}
fun testHintsForPositionalArgumentAndKwargs() {
doTest("""
def fun(arg, **kwargs):
pass
fun(<hint text="arg:"/>1, a=2, b=3, c=4)
""".trimIndent())
}
fun testsHintsNotShownForOverloads() {
doTest("""
from typing import overload, Any
@overload
def bar(a: int, b: int) -> None:
pass
@overload
def bar(c: str, d: str) -> None:
pass
def bar(*args: Any, **kwargs: Any) -> None:
pass
bar(1, 2)
""".trimIndent())
}
fun testsHintsShownForOverloadsWithSameSignatures() {
doTest("""
from typing import overload, Any
@overload
def bar(a: int, b: int) -> None:
pass
@overload
def bar(a: str, b: str) -> None:
pass
def bar(*args: Any, **kwargs: Any) -> None:
pass
bar(<hint text="a:"/>1, <hint text="b:"/>2)
""".trimIndent())
}
fun testsHintsNotShownForOverloadsWithDifferentSignatures() {
doTest("""
from typing import overload, Any
@overload
def bar(c: int, d: int, e: float) -> None:
pass
@overload
def bar(a: str, b: str) -> None:
pass
def bar(*args: Any, **kwargs: Any) -> None:
pass
bar(1, 2)
""".trimIndent())
}
private fun doTest(text: String) {
enableAllHints()
myFixture.configureByText(PythonFileType.INSTANCE, text)
myFixture.testInlays()
}
private fun doTestWithOptions(text: String, vararg options: Option, enabled: Boolean = true) {
options.forEach { option-> option.set(enabled) }
myFixture.configureByText(PythonFileType.INSTANCE, text)
myFixture.testInlays()
}
private fun enableAllHints() {
PythonInlayParameterHintsProvider().supportedOptions.forEach { option: Option ->
option.set(true)
}
}
}