Skip to content

Commit

Permalink
✨ better hover docs
Browse files Browse the repository at this point in the history
ripped from python-lsp#452
  • Loading branch information
PeterCardenas committed Jan 14, 2024
1 parent df1e459 commit 6d7fb65
Show file tree
Hide file tree
Showing 2 changed files with 180 additions and 44 deletions.
90 changes: 64 additions & 26 deletions pylsp/plugins/hover.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,43 +8,81 @@
log = logging.getLogger(__name__)


def _find_docstring(definitions):
if len(definitions) != 1:
# Either no definitions or multiple definitions
# If we have multiple definitions the element can be multiple things and we
# do not know which one

# TODO(Review)
# We could also concatenate all docstrings we find in the definitions
# I am against this because
# - If just one definition has a docstring, it gives a false impression of the hover element
# - If multiple definitions have a docstring, the user will probably not realize
# that he can scroll to see the other options
return ""

# The single true definition
definition = definitions[0]
docstring = definition.docstring(
raw=True
) # raw docstring returns only doc, without signature
if docstring != "":
return docstring

# If the definition has no docstring, try to infer the type
types = definition.infer()

if len(types) != 1:
# If we have multiple types the element can be multiple things and we
# do not know which one
return ""

# Use the docstring of the single true type (possibly empty)
return types[0].docstring(raw=True)


def _find_signatures(definitions, word):
# Get the signatures of all definitions
signatures = [
signature.to_string()
for definition in definitions
for signature in definition.get_signatures()
if signature.type not in ["module"]
]

if len(signatures) != 0:
return signatures

# If we did not find a signature, infer the possible types of all definitions
types = [
t.name
for d in sorted(definitions, key=lambda d: d.line)
for t in sorted(d.infer(), key=lambda t: t.line)
]
if len(types) == 1:
return [types[0]]
elif len(types) > 1:
return [f"Union[{', '.join(types)}]"]


@hookimpl
def pylsp_hover(config, document, position):
code_position = _utils.position_to_jedi_linecolumn(document, position)
definitions = document.jedi_script(use_document_path=True).infer(**code_position)
word = document.word_at_position(position)

# Find first exact matching definition
definition = next((x for x in definitions if x.name == word), None)

# Ensure a definition is used if only one is available
# even if the word doesn't match. An example of this case is 'np'
# where 'numpy' doesn't match with 'np'. Same for NumPy ufuncs
if len(definitions) == 1:
definition = definitions[0]

if not definition:
return {"contents": ""}
# TODO(Review)
# We could also use Script.help here. It would not resolve keywords
definitions = document.jedi_script(use_document_path=True).help(**code_position)
word = document.word_at_position(position)

hover_capabilities = config.capabilities.get("textDocument", {}).get("hover", {})
supported_markup_kinds = hover_capabilities.get("contentFormat", ["markdown"])
preferred_markup_kind = _utils.choose_markup_kind(supported_markup_kinds)

# Find first exact matching signature
signature = next(
(
x.to_string()
for x in definition.get_signatures()
if (x.name == word and x.type not in ["module"])
),
"",
)

return {
"contents": _utils.format_docstring(
# raw docstring returns only doc, without signature
definition.docstring(raw=True),
_find_docstring(definitions),
preferred_markup_kind,
signatures=[signature] if signature else None,
signatures=_find_signatures(definitions, word),
)
}
134 changes: 116 additions & 18 deletions test/plugins/test_hover.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,44 @@

DOC_URI = uris.from_fs_path(__file__)
DOC = """
from random import randint
from typing import overload
def main():
\"\"\"hello world\"\"\"
class A:
\"\"\"Docstring for class A\"\"\"
b = 42
\"\"\"Docstring for the class property A.b\"\"\"
def foo(self):
\"\"\"Docstring for A.foo\"\"\"
pass
if randint(0, 1) == 0:
int_or_string_value = 10
else:
int_or_string_value = "10"
@overload
def overload_function(s: int) -> int:
...
@overload
def overload_function(s: str) -> str:
...
def overload_function(s):
\"\"\"Docstring of overload function\"\"\"
pass
int_value = 10
string_value = "foo"
instance_of_a = A()
copy_of_class_a = A
copy_of_property_b = A.b
int_or_string_value
overload_function
"""

NUMPY_DOC = """
Expand All @@ -23,6 +57,83 @@ def main():
"""


def _hover_result_in_doc(workspace, position):
doc = Document(DOC_URI, workspace, DOC)
return pylsp_hover(
doc._config, doc, {"line": position[0], "character": position[1]}
)["contents"]["value"]


def test_hover_over_nothing(workspace):
# Over blank line
assert "" == _hover_result_in_doc(workspace, (3, 0))


def test_hover_on_keyword(workspace):
# Over "class" in "class A:"
res = _hover_result_in_doc(workspace, (4, 1))
assert "Class definitions" in res


def test_hover_on_variables(workspace):
# Over "int_value" in "int_value = 10"
res = _hover_result_in_doc(workspace, (31, 2))
assert "int" in res # type

# Over "string_value" in "string_value = "foo""
res = _hover_result_in_doc(workspace, (32, 2))
assert "string" in res # type


def test_hover_on_class(workspace):
# Over "A" in "class A:"
res = _hover_result_in_doc(workspace, (4, 7))
assert "A()" in res # signature
assert "Docstring for class A" in res # docstring

# Over "A" in "instance_of_a = A()"
res = _hover_result_in_doc(workspace, (33, 17))
assert "A()" in res # signature
assert "Docstring for class A" in res # docstring

# Over "copy_of_class_a" in "copy_of_class_a = A" - needs infer
res = _hover_result_in_doc(workspace, (34, 4))
assert "A()" in res # signature
assert "Docstring for class A" in res # docstring


def test_hover_on_property(workspace):
# Over "b" in "b = 42"
res = _hover_result_in_doc(workspace, (7, 5))
assert "int" in res # type
assert "Docstring for the class property A.b" in res # docstring

# Over "b" in "A.b"
res = _hover_result_in_doc(workspace, (35, 24))
assert "int" in res # type
assert "Docstring for the class property A.b" in res # docstring


def test_hover_on_method(workspace):
# Over "foo" in "def foo(self):"
res = _hover_result_in_doc(workspace, (10, 10))
assert "foo(self)" in res # signature
assert "Docstring for A.foo" in res # docstring


def test_hover_multiple_definitions(workspace):
# Over "int_or_string_value"
res = _hover_result_in_doc(workspace, (36, 5))
assert "```python\nUnion[int, str]\n```" == res.strip() # only type

# Over "overload_function"
res = _hover_result_in_doc(workspace, (37, 5))
assert (
"overload_function(s: int) -> int\noverload_function(s: str) -> str" in res
) # signature
assert "Docstring of overload function" in res # docstring


def test_numpy_hover(workspace):
# Over the blank line
no_hov_position = {"line": 1, "character": 0}
Expand All @@ -38,7 +149,9 @@ def test_numpy_hover(workspace):
doc = Document(DOC_URI, workspace, NUMPY_DOC)

contents = ""
assert contents in pylsp_hover(doc._config, doc, no_hov_position)["contents"]
assert (
contents in pylsp_hover(doc._config, doc, no_hov_position)["contents"]["value"]
)

contents = "NumPy\n=====\n\nProvides\n"
assert (
Expand Down Expand Up @@ -72,21 +185,6 @@ def test_numpy_hover(workspace):
)


def test_hover(workspace):
# Over 'main' in def main():
hov_position = {"line": 2, "character": 6}
# Over the blank second line
no_hov_position = {"line": 1, "character": 0}

doc = Document(DOC_URI, workspace, DOC)

contents = {"kind": "markdown", "value": "```python\nmain()\n```\n\n\nhello world"}

assert {"contents": contents} == pylsp_hover(doc._config, doc, hov_position)

assert {"contents": ""} == pylsp_hover(doc._config, doc, no_hov_position)


def test_document_path_hover(workspace_other_root_path, tmpdir):
# Create a dummy module out of the workspace's root_path and try to get
# a definition on it in another file placed next to it.
Expand Down

0 comments on commit 6d7fb65

Please sign in to comment.