Skip to content

Commit 1ea46ae

Browse files
andfoyccordoba12
authored andcommitted
Handle textDocument/foldingRange call (#665)
1 parent 59fd33c commit 1ea46ae

File tree

5 files changed

+347
-1
lines changed

5 files changed

+347
-1
lines changed

pyls/hookspecs.py

+5
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,11 @@ def pyls_experimental_capabilities(config, workspace):
6767
pass
6868

6969

70+
@hookspec(firstresult=True)
71+
def pyls_folding_range(config, workspace, document):
72+
pass
73+
74+
7075
@hookspec(firstresult=True)
7176
def pyls_format_document(config, workspace, document):
7277
pass

pyls/plugins/folding.py

+169
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
# pylint: disable=len-as-condition
2+
# Copyright 2019 Palantir Technologies, Inc.
3+
4+
import re
5+
6+
import parso
7+
import parso.python.tree as tree_nodes
8+
9+
from pyls import hookimpl
10+
11+
SKIP_NODES = (tree_nodes.Module, tree_nodes.IfStmt, tree_nodes.TryStmt)
12+
IDENTATION_REGEX = re.compile(r'(\s+).+')
13+
14+
15+
@hookimpl
16+
def pyls_folding_range(document):
17+
program = document.source + '\n'
18+
lines = program.splitlines()
19+
tree = parso.parse(program)
20+
ranges = __compute_folding_ranges(tree, lines)
21+
22+
results = []
23+
for (start_line, end_line) in ranges:
24+
start_line -= 1
25+
end_line -= 1
26+
# If start/end character is not defined, then it defaults to the
27+
# corresponding line last character
28+
results.append({
29+
'startLine': start_line,
30+
'endLine': end_line,
31+
})
32+
return results
33+
34+
35+
def __merge_folding_ranges(left, right):
36+
for start in list(left.keys()):
37+
right_start = right.pop(start, None)
38+
if right_start is not None:
39+
left[start] = max(right_start, start)
40+
left.update(right)
41+
return left
42+
43+
44+
def __empty_identation_stack(identation_stack, level_limits,
45+
current_line, folding_ranges):
46+
while identation_stack != []:
47+
upper_level = identation_stack.pop(0)
48+
level_start = level_limits.pop(upper_level)
49+
folding_ranges.append((level_start, current_line))
50+
return folding_ranges
51+
52+
53+
def __match_identation_stack(identation_stack, level, level_limits,
54+
folding_ranges, current_line):
55+
upper_level = identation_stack.pop(0)
56+
while upper_level >= level:
57+
level_start = level_limits.pop(upper_level)
58+
folding_ranges.append((level_start, current_line))
59+
upper_level = identation_stack.pop(0)
60+
identation_stack.insert(0, upper_level)
61+
return identation_stack, folding_ranges
62+
63+
64+
def __compute_folding_ranges_identation(text):
65+
lines = text.splitlines()
66+
folding_ranges = []
67+
identation_stack = []
68+
level_limits = {}
69+
current_level = 0
70+
current_line = 0
71+
while lines[current_line] == '':
72+
current_line += 1
73+
for i, line in enumerate(lines):
74+
if i < current_line:
75+
continue
76+
i += 1
77+
identation_match = IDENTATION_REGEX.match(line)
78+
if identation_match is not None:
79+
whitespace = identation_match.group(1)
80+
level = len(whitespace)
81+
if level > current_level:
82+
level_limits[current_level] = current_line
83+
identation_stack.insert(0, current_level)
84+
current_level = level
85+
elif level < current_level:
86+
identation_stack, folding_ranges = __match_identation_stack(
87+
identation_stack, level, level_limits, folding_ranges,
88+
current_line)
89+
current_level = level
90+
else:
91+
folding_ranges = __empty_identation_stack(
92+
identation_stack, level_limits, current_line, folding_ranges)
93+
current_level = 0
94+
if line.strip() != '':
95+
current_line = i
96+
folding_ranges = __empty_identation_stack(
97+
identation_stack, level_limits, current_line, folding_ranges)
98+
return dict(folding_ranges)
99+
100+
101+
def __check_if_node_is_valid(node):
102+
valid = True
103+
if isinstance(node, tree_nodes.PythonNode):
104+
kind = node.type
105+
valid = kind not in {'decorated', 'parameters'}
106+
if kind == 'suite':
107+
if isinstance(node.parent, tree_nodes.Function):
108+
valid = False
109+
return valid
110+
111+
112+
def __compute_start_end_lines(node, stack):
113+
start_line, _ = node.start_pos
114+
end_line, _ = node.end_pos
115+
116+
last_leaf = node.get_last_leaf()
117+
last_newline = isinstance(last_leaf, tree_nodes.Newline)
118+
last_operator = isinstance(last_leaf, tree_nodes.Operator)
119+
node_is_operator = isinstance(node, tree_nodes.Operator)
120+
last_operator = last_operator or not node_is_operator
121+
122+
end_line -= 1
123+
124+
modified = False
125+
if isinstance(node.parent, tree_nodes.PythonNode):
126+
kind = node.type
127+
if kind in {'suite', 'atom', 'atom_expr', 'arglist'}:
128+
if len(stack) > 0:
129+
next_node = stack[0]
130+
next_line, _ = next_node.start_pos
131+
if next_line > end_line:
132+
end_line += 1
133+
modified = True
134+
if not last_newline and not modified and not last_operator:
135+
end_line += 1
136+
return start_line, end_line
137+
138+
139+
def __compute_folding_ranges(tree, lines):
140+
folding_ranges = {}
141+
stack = [tree]
142+
143+
while len(stack) > 0:
144+
node = stack.pop(0)
145+
if isinstance(node, tree_nodes.Newline):
146+
# Skip newline nodes
147+
continue
148+
elif isinstance(node, tree_nodes.PythonErrorNode):
149+
# Fallback to identation-based (best-effort) folding
150+
start_line, _ = node.start_pos
151+
start_line -= 1
152+
padding = [''] * start_line
153+
text = '\n'.join(padding + lines[start_line:]) + '\n'
154+
identation_ranges = __compute_folding_ranges_identation(text)
155+
folding_ranges = __merge_folding_ranges(
156+
folding_ranges, identation_ranges)
157+
break
158+
elif not isinstance(node, SKIP_NODES):
159+
valid = __check_if_node_is_valid(node)
160+
if valid:
161+
start_line, end_line = __compute_start_end_lines(node, stack)
162+
if end_line > start_line:
163+
current_end = folding_ranges.get(start_line, -1)
164+
folding_ranges[start_line] = max(current_end, end_line)
165+
if hasattr(node, 'children'):
166+
stack = node.children + stack
167+
168+
folding_ranges = sorted(folding_ranges.items())
169+
return folding_ranges

pyls/python_ls.py

+7
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,7 @@ def capabilities(self):
173173
'hoverProvider': True,
174174
'referencesProvider': True,
175175
'renameProvider': True,
176+
'foldingRangeProvider': True,
176177
'signatureHelpProvider': {
177178
'triggerCharacters': ['(', ',', '=']
178179
},
@@ -282,6 +283,9 @@ def rename(self, doc_uri, position, new_name):
282283
def signature_help(self, doc_uri, position):
283284
return self._hook('pyls_signature_help', doc_uri, position=position)
284285

286+
def folding(self, doc_uri):
287+
return self._hook('pyls_folding_range', doc_uri)
288+
285289
def m_text_document__did_close(self, textDocument=None, **_kwargs):
286290
workspace = self._match_uri_to_workspace(textDocument['uri'])
287291
workspace.rm_document(textDocument['uri'])
@@ -333,6 +337,9 @@ def m_text_document__formatting(self, textDocument=None, _options=None, **_kwarg
333337
def m_text_document__rename(self, textDocument=None, position=None, newName=None, **_kwargs):
334338
return self.rename(textDocument['uri'], position, newName)
335339

340+
def m_text_document__folding_range(self, textDocument=None, **_kwargs):
341+
return self.folding(textDocument['uri'])
342+
336343
def m_text_document__range_formatting(self, textDocument=None, range=None, _options=None, **_kwargs):
337344
# Again, we'll ignore formatting options for now.
338345
return self.format_range(textDocument['uri'], range)

setup.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@
7878
],
7979
'pyls': [
8080
'autopep8 = pyls.plugins.autopep8_format',
81+
'folding = pyls.plugins.folding',
8182
'flake8 = pyls.plugins.flake8_lint',
8283
'jedi_completion = pyls.plugins.jedi_completion',
8384
'jedi_definition = pyls.plugins.definition',
@@ -94,7 +95,7 @@
9495
'pylint = pyls.plugins.pylint_lint',
9596
'rope_completion = pyls.plugins.rope_completion',
9697
'rope_rename = pyls.plugins.rope_rename',
97-
'yapf = pyls.plugins.yapf_format',
98+
'yapf = pyls.plugins.yapf_format'
9899
]
99100
},
100101
)

test/plugins/test_folding.py

+164
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
# Copyright 2019 Palantir Technologies, Inc.
2+
3+
from textwrap import dedent
4+
5+
from pyls import uris
6+
from pyls.workspace import Document
7+
from pyls.plugins.folding import pyls_folding_range
8+
9+
10+
DOC_URI = uris.from_fs_path(__file__)
11+
DOC = dedent("""
12+
def func(arg1, arg2, arg3,
13+
arg4, arg5, default=func(
14+
2, 3, 4
15+
)):
16+
return (2, 3,
17+
4, 5)
18+
19+
@decorator(
20+
param1,
21+
param2
22+
)
23+
def decorated_func(x, y, z):
24+
if x:
25+
return y
26+
elif y:
27+
return z
28+
elif x + y > z:
29+
return True
30+
else:
31+
return x
32+
33+
class A():
34+
def method(self, x1):
35+
def inner():
36+
return x1
37+
38+
if x2:
39+
func(3, 4, 5, 6,
40+
7)
41+
elif x3 < 2:
42+
pass
43+
else:
44+
more_complex_func(2, 3, 4, 5, 6,
45+
8)
46+
return inner
47+
48+
a = 2
49+
operation = (a_large_variable_that_fills_all_space +
50+
other_embarrasingly_long_variable - 2 * 3 / 5)
51+
52+
(a, b, c,
53+
d, e, f) = func(3, 4, 5, 6,
54+
7, 8, 9, 10)
55+
56+
for i in range(0, 3):
57+
i += 1
58+
while x < i:
59+
expr = (2, 4)
60+
a = func(expr + i, arg2, arg3, arg4,
61+
arg5, var(2, 3, 4,
62+
5))
63+
for j in range(0, i):
64+
if i % 2 == 1:
65+
pass
66+
67+
compren = [x for x in range(0, 3)
68+
if x == 2]
69+
70+
with open('doc', 'r') as f:
71+
try:
72+
f / 0
73+
except:
74+
pass
75+
finally:
76+
raise SomeException()
77+
""")
78+
79+
SYNTAX_ERR = dedent("""
80+
def func(arg1, arg2, arg3,
81+
arg4, arg5, default=func(
82+
2, 3, 4
83+
)):
84+
return (2, 3,
85+
4, 5)
86+
87+
class A(:
88+
pass
89+
90+
a = 2
91+
operation = (a_large_variable_that_fills_all_space +
92+
other_embarrasingly_long_variable - 2 * 3 /
93+
94+
(a, b, c,
95+
d, e, f) = func(3, 4, 5, 6,
96+
7, 8, 9, 10
97+
a = 2
98+
for i in range(0, 3)
99+
i += 1
100+
while x < i:
101+
expr = (2, 4)
102+
a = func(expr + i, arg2, arg3, arg4,
103+
arg5, var(2, 3, 4,
104+
5))
105+
for j in range(0, i):
106+
if i % 2 == 1:
107+
pass
108+
""")
109+
110+
111+
def test_folding():
112+
doc = Document(DOC_URI, DOC)
113+
ranges = pyls_folding_range(doc)
114+
expected = [{'startLine': 1, 'endLine': 6},
115+
{'startLine': 2, 'endLine': 3},
116+
{'startLine': 5, 'endLine': 6},
117+
{'startLine': 8, 'endLine': 11},
118+
{'startLine': 12, 'endLine': 20},
119+
{'startLine': 13, 'endLine': 14},
120+
{'startLine': 15, 'endLine': 16},
121+
{'startLine': 17, 'endLine': 18},
122+
{'startLine': 19, 'endLine': 20},
123+
{'startLine': 22, 'endLine': 35},
124+
{'startLine': 23, 'endLine': 35},
125+
{'startLine': 24, 'endLine': 25},
126+
{'startLine': 27, 'endLine': 29},
127+
{'startLine': 28, 'endLine': 29},
128+
{'startLine': 30, 'endLine': 31},
129+
{'startLine': 32, 'endLine': 34},
130+
{'startLine': 33, 'endLine': 34},
131+
{'startLine': 38, 'endLine': 39},
132+
{'startLine': 41, 'endLine': 43},
133+
{'startLine': 42, 'endLine': 43},
134+
{'startLine': 45, 'endLine': 54},
135+
{'startLine': 47, 'endLine': 51},
136+
{'startLine': 49, 'endLine': 51},
137+
{'startLine': 50, 'endLine': 51},
138+
{'startLine': 52, 'endLine': 54},
139+
{'startLine': 53, 'endLine': 54},
140+
{'startLine': 56, 'endLine': 57},
141+
{'startLine': 59, 'endLine': 65},
142+
{'startLine': 60, 'endLine': 61},
143+
{'startLine': 62, 'endLine': 63},
144+
{'startLine': 64, 'endLine': 65}]
145+
assert ranges == expected
146+
147+
148+
def test_folding_syntax_error():
149+
doc = Document(DOC_URI, SYNTAX_ERR)
150+
ranges = pyls_folding_range(doc)
151+
expected = [{'startLine': 1, 'endLine': 6},
152+
{'startLine': 2, 'endLine': 3},
153+
{'startLine': 5, 'endLine': 6},
154+
{'startLine': 8, 'endLine': 9},
155+
{'startLine': 12, 'endLine': 13},
156+
{'startLine': 15, 'endLine': 17},
157+
{'startLine': 16, 'endLine': 17},
158+
{'startLine': 19, 'endLine': 28},
159+
{'startLine': 21, 'endLine': 25},
160+
{'startLine': 23, 'endLine': 25},
161+
{'startLine': 24, 'endLine': 25},
162+
{'startLine': 26, 'endLine': 28},
163+
{'startLine': 27, 'endLine': 28}]
164+
assert ranges == expected

0 commit comments

Comments
 (0)