Skip to content

Commit 449d11a

Browse files
authored
Migrate to MarkupContent and convert docstrings to Markdown (python-lsp#80)
1 parent 7cad321 commit 449d11a

10 files changed

+176
-60
lines changed

pylsp/_utils.py

+74-5
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@
88
import pathlib
99
import re
1010
import threading
11+
from typing import List, Optional
1112

13+
import docstring_to_markdown
1214
import jedi
1315

1416
JEDI_VERSION = jedi.__version__
@@ -144,17 +146,84 @@ def _merge_dicts_(a, b):
144146
return dict(_merge_dicts_(dict_a, dict_b))
145147

146148

147-
def format_docstring(contents):
148-
"""Python doc strings come in a number of formats, but LSP wants markdown.
149-
150-
Until we can find a fast enough way of discovering and parsing each format,
151-
we can do a little better by at least preserving indentation.
149+
def escape_plain_text(contents: str) -> str:
150+
"""
151+
Format plain text to display nicely in environments which do not respect whitespaces.
152152
"""
153153
contents = contents.replace('\t', '\u00A0' * 4)
154154
contents = contents.replace(' ', '\u00A0' * 2)
155155
return contents
156156

157157

158+
def escape_markdown(contents: str) -> str:
159+
"""
160+
Format plain text to display nicely in Markdown environment.
161+
"""
162+
# escape markdown syntax
163+
contents = re.sub(r'([\\*_#[\]])', r'\\\1', contents)
164+
# preserve white space characters
165+
contents = escape_plain_text(contents)
166+
return contents
167+
168+
169+
def wrap_signature(signature):
170+
return '```python\n' + signature + '\n```\n'
171+
172+
173+
SERVER_SUPPORTED_MARKUP_KINDS = {'markdown', 'plaintext'}
174+
175+
176+
def choose_markup_kind(client_supported_markup_kinds: List[str]):
177+
"""Choose a markup kind supported by both client and the server.
178+
179+
This gives priority to the markup kinds provided earlier on the client preference list.
180+
"""
181+
for kind in client_supported_markup_kinds:
182+
if kind in SERVER_SUPPORTED_MARKUP_KINDS:
183+
return kind
184+
return 'markdown'
185+
186+
187+
def format_docstring(contents: str, markup_kind: str, signatures: Optional[List[str]] = None):
188+
"""Transform the provided docstring into a MarkupContent object.
189+
190+
If `markup_kind` is 'markdown' the docstring will get converted to
191+
markdown representation using `docstring-to-markdown`; if it is
192+
`plaintext`, it will be returned as plain text.
193+
Call signatures of functions (or equivalent code summaries)
194+
provided in optional `signatures` argument will be prepended
195+
to the provided contents of the docstring if given.
196+
"""
197+
if not isinstance(contents, str):
198+
contents = ''
199+
200+
if markup_kind == 'markdown':
201+
try:
202+
value = docstring_to_markdown.convert(contents)
203+
return {
204+
'kind': 'markdown',
205+
'value': value
206+
}
207+
except docstring_to_markdown.UnknownFormatError:
208+
# try to escape the Markdown syntax instead:
209+
value = escape_markdown(contents)
210+
211+
if signatures:
212+
value = wrap_signature('\n'.join(signatures)) + '\n\n' + value
213+
214+
return {
215+
'kind': 'markdown',
216+
'value': value
217+
}
218+
value = contents
219+
if signatures:
220+
value = '\n'.join(signatures) + '\n\n' + value
221+
return {
222+
'kind': 'plaintext',
223+
'value': escape_plain_text(value)
224+
}
225+
226+
158227
def clip_column(column, lines, line_number):
159228
"""
160229
Normalise the position as per the LSP that accepts character positions > line length

pylsp/plugins/hover.py

+12-17
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010

1111
@hookimpl
12-
def pylsp_hover(document, position):
12+
def pylsp_hover(config, document, position):
1313
code_position = _utils.position_to_jedi_linecolumn(document, position)
1414
definitions = document.jedi_script(use_document_path=True).infer(**code_position)
1515
word = document.word_at_position(position)
@@ -26,24 +26,19 @@ def pylsp_hover(document, position):
2626
if not definition:
2727
return {'contents': ''}
2828

29-
# raw docstring returns only doc, without signature
30-
doc = _utils.format_docstring(definition.docstring(raw=True))
29+
hover_capabilities = config.capabilities.get('textDocument', {}).get('hover', {})
30+
supported_markup_kinds = hover_capabilities.get('contentFormat', ['markdown'])
31+
preferred_markup_kind = _utils.choose_markup_kind(supported_markup_kinds)
3132

3233
# Find first exact matching signature
3334
signature = next((x.to_string() for x in definition.get_signatures()
3435
if x.name == word), '')
3536

36-
contents = []
37-
if signature:
38-
contents.append({
39-
'language': 'python',
40-
'value': signature,
41-
})
42-
43-
if doc:
44-
contents.append(doc)
45-
46-
if not contents:
47-
return {'contents': ''}
48-
49-
return {'contents': contents}
37+
return {
38+
'contents': _utils.format_docstring(
39+
# raw docstring returns only doc, without signature
40+
definition.docstring(raw=True),
41+
preferred_markup_kind,
42+
signatures=[signature] if signature else None
43+
)
44+
}

pylsp/plugins/jedi_completion.py

+27-9
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,10 @@ def pylsp_completions(config, document, position):
5050
return None
5151

5252
completion_capabilities = config.capabilities.get('textDocument', {}).get('completion', {})
53-
snippet_support = completion_capabilities.get('completionItem', {}).get('snippetSupport')
53+
item_capabilities = completion_capabilities.get('completionItem', {})
54+
snippet_support = item_capabilities.get('snippetSupport')
55+
supported_markup_kinds = item_capabilities.get('documentationFormat', ['markdown'])
56+
preferred_markup_kind = _utils.choose_markup_kind(supported_markup_kinds)
5457

5558
should_include_params = settings.get('include_params')
5659
should_include_class_objects = settings.get('include_class_objects', True)
@@ -69,7 +72,8 @@ def pylsp_completions(config, document, position):
6972
ready_completions = [
7073
_format_completion(
7174
c,
72-
include_params,
75+
markup_kind=preferred_markup_kind,
76+
include_params=include_params,
7377
resolve=resolve_eagerly,
7478
resolve_label_or_snippet=(i < max_to_resolve)
7579
)
@@ -82,7 +86,8 @@ def pylsp_completions(config, document, position):
8286
if c.type == 'class':
8387
completion_dict = _format_completion(
8488
c,
85-
False,
89+
markup_kind=preferred_markup_kind,
90+
include_params=False,
8691
resolve=resolve_eagerly,
8792
resolve_label_or_snippet=(i < max_to_resolve)
8893
)
@@ -119,12 +124,18 @@ def pylsp_completions(config, document, position):
119124

120125

121126
@hookimpl
122-
def pylsp_completion_item_resolve(completion_item, document):
127+
def pylsp_completion_item_resolve(config, completion_item, document):
123128
"""Resolve formatted completion for given non-resolved completion"""
124129
shared_data = document.shared_data['LAST_JEDI_COMPLETIONS'].get(completion_item['label'])
130+
131+
completion_capabilities = config.capabilities.get('textDocument', {}).get('completion', {})
132+
item_capabilities = completion_capabilities.get('completionItem', {})
133+
supported_markup_kinds = item_capabilities.get('documentationFormat', ['markdown'])
134+
preferred_markup_kind = _utils.choose_markup_kind(supported_markup_kinds)
135+
125136
if shared_data:
126137
completion, data = shared_data
127-
return _resolve_completion(completion, data)
138+
return _resolve_completion(completion, data, markup_kind=preferred_markup_kind)
128139
return completion_item
129140

130141

@@ -178,18 +189,25 @@ def use_snippets(document, position):
178189
not (expr_type in _ERRORS and 'import' in code))
179190

180191

181-
def _resolve_completion(completion, d):
192+
def _resolve_completion(completion, d, markup_kind: str):
182193
# pylint: disable=broad-except
183194
completion['detail'] = _detail(d)
184195
try:
185-
docs = _utils.format_docstring(d.docstring())
196+
docs = _utils.format_docstring(
197+
d.docstring(raw=True),
198+
signatures=[
199+
signature.to_string()
200+
for signature in d.get_signatures()
201+
],
202+
markup_kind=markup_kind
203+
)
186204
except Exception:
187205
docs = ''
188206
completion['documentation'] = docs
189207
return completion
190208

191209

192-
def _format_completion(d, include_params=True, resolve=False, resolve_label_or_snippet=False):
210+
def _format_completion(d, markup_kind: str, include_params=True, resolve=False, resolve_label_or_snippet=False):
193211
completion = {
194212
'label': _label(d, resolve_label_or_snippet),
195213
'kind': _TYPE_MAP.get(d.type),
@@ -198,7 +216,7 @@ def _format_completion(d, include_params=True, resolve=False, resolve_label_or_s
198216
}
199217

200218
if resolve:
201-
completion = _resolve_completion(completion, d)
219+
completion = _resolve_completion(completion, d, markup_kind)
202220

203221
if d.type == 'path':
204222
path = osp.normpath(d.name)

pylsp/plugins/rope_completion.py

+20-6
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import logging
55
from rope.contrib.codeassist import code_assist, sorted_proposals
66

7-
from pylsp import hookimpl, lsp
7+
from pylsp import _utils, hookimpl, lsp
88

99

1010
log = logging.getLogger(__name__)
@@ -16,10 +16,13 @@ def pylsp_settings():
1616
return {'plugins': {'rope_completion': {'enabled': False, 'eager': False}}}
1717

1818

19-
def _resolve_completion(completion, data):
19+
def _resolve_completion(completion, data, markup_kind):
2020
# pylint: disable=broad-except
2121
try:
22-
doc = data.get_doc()
22+
doc = _utils.format_docstring(
23+
data.get_doc(),
24+
markup_kind=markup_kind
25+
)
2326
except Exception as e:
2427
log.debug("Failed to resolve Rope completion: %s", e)
2528
doc = ""
@@ -49,6 +52,11 @@ def pylsp_completions(config, workspace, document, position):
4952
rope_project = workspace._rope_project_builder(rope_config)
5053
document_rope = document._rope_resource(rope_config)
5154

55+
completion_capabilities = config.capabilities.get('textDocument', {}).get('completion', {})
56+
item_capabilities = completion_capabilities.get('completionItem', {})
57+
supported_markup_kinds = item_capabilities.get('documentationFormat', ['markdown'])
58+
preferred_markup_kind = _utils.choose_markup_kind(supported_markup_kinds)
59+
5260
try:
5361
definitions = code_assist(rope_project, document.source, offset, document_rope, maxfixes=3)
5462
except Exception as e: # pylint: disable=broad-except
@@ -67,7 +75,7 @@ def pylsp_completions(config, workspace, document, position):
6775
}
6876
}
6977
if resolve_eagerly:
70-
item = _resolve_completion(item, d)
78+
item = _resolve_completion(item, d, preferred_markup_kind)
7179
new_definitions.append(item)
7280

7381
# most recently retrieved completion items, used for resolution
@@ -83,12 +91,18 @@ def pylsp_completions(config, workspace, document, position):
8391

8492

8593
@hookimpl
86-
def pylsp_completion_item_resolve(completion_item, document):
94+
def pylsp_completion_item_resolve(config, completion_item, document):
8795
"""Resolve formatted completion for given non-resolved completion"""
8896
shared_data = document.shared_data['LAST_ROPE_COMPLETIONS'].get(completion_item['label'])
97+
98+
completion_capabilities = config.capabilities.get('textDocument', {}).get('completion', {})
99+
item_capabilities = completion_capabilities.get('completionItem', {})
100+
supported_markup_kinds = item_capabilities.get('documentationFormat', ['markdown'])
101+
preferred_markup_kind = _utils.choose_markup_kind(supported_markup_kinds)
102+
89103
if shared_data:
90104
completion, data = shared_data
91-
return _resolve_completion(completion, data)
105+
return _resolve_completion(completion, data, preferred_markup_kind)
92106
return completion_item
93107

94108

pylsp/plugins/signature.py

+17-4
Original file line numberDiff line numberDiff line change
@@ -15,28 +15,41 @@
1515

1616

1717
@hookimpl
18-
def pylsp_signature_help(document, position):
18+
def pylsp_signature_help(config, document, position):
1919
code_position = _utils.position_to_jedi_linecolumn(document, position)
2020
signatures = document.jedi_script().get_signatures(**code_position)
2121

2222
if not signatures:
2323
return {'signatures': []}
2424

25+
signature_capabilities = config.capabilities.get('textDocument', {}).get('signatureHelp', {})
26+
signature_information_support = signature_capabilities.get('signatureInformation', {})
27+
supported_markup_kinds = signature_information_support.get('documentationFormat', ['markdown'])
28+
preferred_markup_kind = _utils.choose_markup_kind(supported_markup_kinds)
29+
2530
s = signatures[0]
2631

32+
docstring = s.docstring()
33+
2734
# Docstring contains one or more lines of signature, followed by empty line, followed by docstring
28-
function_sig_lines = (s.docstring().split('\n\n') or [''])[0].splitlines()
35+
function_sig_lines = (docstring.split('\n\n') or [''])[0].splitlines()
2936
function_sig = ' '.join([line.strip() for line in function_sig_lines])
3037
sig = {
3138
'label': function_sig,
32-
'documentation': _utils.format_docstring(s.docstring(raw=True))
39+
'documentation': _utils.format_docstring(
40+
s.docstring(raw=True),
41+
markup_kind=preferred_markup_kind
42+
)
3343
}
3444

3545
# If there are params, add those
3646
if s.params:
3747
sig['parameters'] = [{
3848
'label': p.name,
39-
'documentation': _param_docs(s.docstring(), p.name)
49+
'documentation': _utils.format_docstring(
50+
_param_docs(docstring, p.name),
51+
markup_kind=preferred_markup_kind
52+
)
4053
} for p in s.params]
4154

4255
# We only return a single signature because Python doesn't allow overloading

pyproject.toml

+1
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ dependencies = [
1616
"jedi>=0.17.2,<0.19.0",
1717
"python-lsp-jsonrpc>=1.0.0",
1818
"pluggy>=1.0.0",
19+
"docstring-to-markdown",
1920
"ujson>=3.0.0",
2021
"setuptools>=39.0.0",
2122
]

setup.py

+1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from setuptools import setup, find_packages
77

8+
89
if __name__ == "__main__":
910
setup(
1011
name="python-lsp-server", # to allow GitHub dependency tracking work

test/plugins/test_completion.py

+8-3
Original file line numberDiff line numberDiff line change
@@ -160,10 +160,15 @@ def test_jedi_completion_item_resolve(config, workspace):
160160
assert 'detail' not in documented_hello_item
161161

162162
resolved_documented_hello = pylsp_jedi_completion_item_resolve(
163+
doc._config,
163164
completion_item=documented_hello_item,
164165
document=doc
165166
)
166-
assert 'Sends a polite greeting' in resolved_documented_hello['documentation']
167+
expected_doc = {
168+
'kind': 'markdown',
169+
'value': '```python\ndocumented_hello()\n```\n\n\nSends a polite greeting'
170+
}
171+
assert resolved_documented_hello['documentation'] == expected_doc
167172

168173

169174
def test_jedi_completion_with_fuzzy_enabled(config, workspace):
@@ -498,8 +503,8 @@ def test_jedi_completion_environment(workspace):
498503
completions = pylsp_jedi_completions(doc._config, doc, com_position)
499504
assert completions[0]['label'] == 'loghub'
500505

501-
resolved = pylsp_jedi_completion_item_resolve(completions[0], doc)
502-
assert 'changelog generator' in resolved['documentation'].lower()
506+
resolved = pylsp_jedi_completion_item_resolve(doc._config, completions[0], doc)
507+
assert 'changelog generator' in resolved['documentation']['value'].lower()
503508

504509

505510
def test_document_path_completions(tmpdir, workspace_other_root_path):

0 commit comments

Comments
 (0)