Skip to content

Commit a9d503c

Browse files
authored
Parse YAPF diffs into TextEdits (instead of sending the full doc) (#136)
1 parent 66c7cca commit a9d503c

File tree

8 files changed

+618
-69
lines changed

8 files changed

+618
-69
lines changed

pylsp/_utils.py

+1
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
JEDI_VERSION = jedi.__version__
1515

1616
# Eol chars accepted by the LSP protocol
17+
# the ordering affects performance
1718
EOL_CHARS = ['\r\n', '\r', '\n']
1819
EOL_REGEX = re.compile(f'({"|".join(EOL_CHARS)})')
1920

pylsp/config/config.py

+1
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ def __init__(self, root_uri, init_opts, process_id, capabilities):
8181
if plugin is not None:
8282
log.info("Loaded pylsp plugin %s from %s", name, plugin)
8383

84+
# pylint: disable=no-member
8485
for plugin_conf in self._pm.hook.pylsp_settings(config=self):
8586
self._plugin_settings = _utils.merge_dicts(self._plugin_settings, plugin_conf)
8687

pylsp/plugins/yapf_format.py

+145-54
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
from yapf.yapflib import file_resources, style
88
from yapf.yapflib.yapf_api import FormatCode
99

10+
import whatthepatch
11+
1012
from pylsp import hookimpl
1113
from pylsp._utils import get_eol_chars
1214

@@ -36,75 +38,164 @@ def pylsp_format_range(document, range, options=None): # pylint: disable=redefi
3638
return _format(document, lines=lines, options=options)
3739

3840

39-
def _format(document, lines=None, options=None):
40-
# Yapf doesn't work with CRLF/CR line endings, so we replace them by '\n'
41-
# and restore them below.
42-
replace_eols = False
43-
source = document.source
44-
eol_chars = get_eol_chars(source)
45-
if eol_chars in ['\r', '\r\n']:
46-
replace_eols = True
47-
source = source.replace(eol_chars, '\n')
48-
41+
def get_style_config(document_path, options=None):
4942
# Get the default styles as a string
5043
# for a preset configuration, i.e. "pep8"
5144
style_config = file_resources.GetDefaultStyleForDir(
52-
os.path.dirname(document.path)
45+
os.path.dirname(document_path)
5346
)
54-
if options is not None:
55-
# We have options passed from LSP format request
56-
# let's pass them to the formatter.
57-
# First we want to get a dictionary of the preset style
58-
# to pass instead of a string so that we can modify it
59-
style_config = style.CreateStyleFromConfig(style_config)
60-
61-
use_tabs = style_config['USE_TABS']
62-
indent_width = style_config['INDENT_WIDTH']
63-
64-
if options.get('tabSize') is not None:
65-
indent_width = max(int(options.get('tabSize')), 1)
66-
67-
if options.get('insertSpaces') is not None:
68-
# TODO is it guaranteed to be a boolean, or can it be a string?
69-
use_tabs = not options.get('insertSpaces')
70-
71-
if use_tabs:
72-
# Indent width doesn't make sense when using tabs
73-
# the specifications state: "Size of a tab in spaces"
74-
indent_width = 1
47+
if options is None:
48+
return style_config
49+
50+
# We have options passed from LSP format request
51+
# let's pass them to the formatter.
52+
# First we want to get a dictionary of the preset style
53+
# to pass instead of a string so that we can modify it
54+
style_config = style.CreateStyleFromConfig(style_config)
55+
56+
use_tabs = style_config['USE_TABS']
57+
indent_width = style_config['INDENT_WIDTH']
58+
59+
if options.get('tabSize') is not None:
60+
indent_width = max(int(options.get('tabSize')), 1)
61+
62+
if options.get('insertSpaces') is not None:
63+
# TODO is it guaranteed to be a boolean, or can it be a string?
64+
use_tabs = not options.get('insertSpaces')
65+
66+
if use_tabs:
67+
# Indent width doesn't make sense when using tabs
68+
# the specifications state: "Size of a tab in spaces"
69+
indent_width = 1
70+
71+
style_config['USE_TABS'] = use_tabs
72+
style_config['INDENT_WIDTH'] = indent_width
73+
style_config['CONTINUATION_INDENT_WIDTH'] = indent_width
74+
75+
for style_option, value in options.items():
76+
# Apply arbitrary options passed as formatter options
77+
if style_option not in style_config:
78+
# ignore if it's not a known yapf config
79+
continue
80+
81+
style_config[style_option] = value
82+
83+
return style_config
84+
85+
86+
def diff_to_text_edits(diff, eol_chars):
87+
# To keep things simple our text edits will be line based.
88+
# We will also return the edits uncompacted, meaning a
89+
# line replacement will come in as a line remove followed
90+
# by a line add instead of a line replace.
91+
text_edits = []
92+
# keep track of line number since additions
93+
# don't include the line number it's being added
94+
# to in diffs. lsp is 0-indexed so we'll start with -1
95+
prev_line_no = -1
96+
97+
for change in diff.changes:
98+
if change.old and change.new:
99+
# old and new are the same line, no change
100+
# diffs are 1-indexed
101+
prev_line_no = change.old - 1
102+
elif change.new:
103+
# addition
104+
text_edits.append({
105+
'range': {
106+
'start': {
107+
'line': prev_line_no + 1,
108+
'character': 0
109+
},
110+
'end': {
111+
'line': prev_line_no + 1,
112+
'character': 0
113+
}
114+
},
115+
'newText': change.line + eol_chars
116+
})
117+
elif change.old:
118+
# remove
119+
lsp_line_no = change.old - 1
120+
text_edits.append({
121+
'range': {
122+
'start': {
123+
'line': lsp_line_no,
124+
'character': 0
125+
},
126+
'end': {
127+
# From LSP spec:
128+
# If you want to specify a range that contains a line
129+
# including the line ending character(s) then use an
130+
# end position denoting the start of the next line.
131+
'line': lsp_line_no + 1,
132+
'character': 0
133+
}
134+
},
135+
'newText': ''
136+
})
137+
prev_line_no = lsp_line_no
138+
139+
return text_edits
140+
141+
142+
def ensure_eof_new_line(document, eol_chars, text_edits):
143+
# diffs don't include EOF newline https://github.com/google/yapf/issues/1008
144+
# we'll add it ourselves if our document doesn't already have it and the diff
145+
# does not change the last line.
146+
if document.source.endswith(eol_chars):
147+
return
148+
149+
lines = document.lines
150+
last_line_number = len(lines) - 1
151+
152+
if text_edits and text_edits[-1]['range']['start']['line'] >= last_line_number:
153+
return
154+
155+
text_edits.append({
156+
'range': {
157+
'start': {
158+
'line': last_line_number,
159+
'character': 0
160+
},
161+
'end': {
162+
'line': last_line_number + 1,
163+
'character': 0
164+
}
165+
},
166+
'newText': lines[-1] + eol_chars
167+
})
75168

76-
style_config['USE_TABS'] = use_tabs
77-
style_config['INDENT_WIDTH'] = indent_width
78-
style_config['CONTINUATION_INDENT_WIDTH'] = indent_width
79169

80-
for style_option, value in options.items():
81-
# Apply arbitrary options passed as formatter options
82-
if style_option not in style_config:
83-
# ignore if it's not a known yapf config
84-
continue
170+
def _format(document, lines=None, options=None):
171+
source = document.source
172+
# Yapf doesn't work with CRLF/CR line endings, so we replace them by '\n'
173+
# and restore them below when adding new lines
174+
eol_chars = get_eol_chars(source)
175+
if eol_chars in ['\r', '\r\n']:
176+
source = source.replace(eol_chars, '\n')
177+
else:
178+
eol_chars = '\n'
85179

86-
style_config[style_option] = value
180+
style_config = get_style_config(document_path=document.path, options=options)
87181

88-
new_source, changed = FormatCode(
182+
diff_txt, changed = FormatCode(
89183
source,
90184
lines=lines,
91185
filename=document.filename,
186+
print_diff=True,
92187
style_config=style_config
93188
)
94189

95190
if not changed:
96191
return []
97192

98-
if replace_eols:
99-
new_source = new_source.replace('\n', eol_chars)
193+
patch_generator = whatthepatch.parse_patch(diff_txt)
194+
diff = next(patch_generator)
195+
patch_generator.close()
100196

101-
# I'm too lazy at the moment to parse diffs into TextEdit items
102-
# So let's just return the entire file...
103-
return [{
104-
'range': {
105-
'start': {'line': 0, 'character': 0},
106-
# End char 0 of the line after our document
107-
'end': {'line': len(document.lines), 'character': 0}
108-
},
109-
'newText': new_source
110-
}]
197+
text_edits = diff_to_text_edits(diff=diff, eol_chars=eol_chars)
198+
199+
ensure_eof_new_line(document=document, eol_chars=eol_chars, text_edits=text_edits)
200+
201+
return text_edits

pylsp/python_lsp.py

+4
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ class _StreamHandlerWrapper(socketserver.StreamRequestHandler):
3434

3535
def setup(self):
3636
super().setup()
37+
# pylint: disable=no-member
3738
self.delegate = self.DELEGATE_CLASS(self.rfile, self.wfile)
3839

3940
def handle(self):
@@ -47,6 +48,7 @@ def handle(self):
4748
if isinstance(e, WindowsError) and e.winerror == 10054:
4849
pass
4950

51+
# pylint: disable=no-member
5052
self.SHUTDOWN_CALL()
5153

5254

@@ -121,6 +123,7 @@ async def pylsp_ws(websocket):
121123
async for message in websocket:
122124
try:
123125
log.debug("consuming payload and feeding it to LSP handler")
126+
# pylint: disable=c-extension-no-member
124127
request = json.loads(message)
125128
loop = asyncio.get_running_loop()
126129
await loop.run_in_executor(tpool, pylsp_handler.consume, request)
@@ -130,6 +133,7 @@ async def pylsp_ws(websocket):
130133
def send_message(message, websocket):
131134
"""Handler to send responses of processed requests to respective web socket clients"""
132135
try:
136+
# pylint: disable=c-extension-no-member
133137
payload = json.dumps(message, ensure_ascii=False)
134138
asyncio.run(websocket.send(payload))
135139
except Exception as e: # pylint: disable=broad-except

pylsp/text_edit.py

+94
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
# Copyright 2017-2020 Palantir Technologies, Inc.
2+
# Copyright 2021- Python Language Server Contributors.
3+
4+
def get_well_formatted_range(lsp_range):
5+
start = lsp_range['start']
6+
end = lsp_range['end']
7+
8+
if start['line'] > end['line'] or (start['line'] == end['line'] and start['character'] > end['character']):
9+
return {'start': end, 'end': start}
10+
11+
return lsp_range
12+
13+
14+
def get_well_formatted_edit(text_edit):
15+
lsp_range = get_well_formatted_range(text_edit['range'])
16+
if lsp_range != text_edit['range']:
17+
return {'newText': text_edit['newText'], 'range': lsp_range}
18+
19+
return text_edit
20+
21+
22+
def compare_text_edits(a, b):
23+
diff = a['range']['start']['line'] - b['range']['start']['line']
24+
if diff == 0:
25+
return a['range']['start']['character'] - b['range']['start']['character']
26+
27+
return diff
28+
29+
30+
def merge_sort_text_edits(text_edits):
31+
if len(text_edits) <= 1:
32+
return text_edits
33+
34+
p = len(text_edits) // 2
35+
left = text_edits[:p]
36+
right = text_edits[p:]
37+
38+
merge_sort_text_edits(left)
39+
merge_sort_text_edits(right)
40+
41+
left_idx = 0
42+
right_idx = 0
43+
i = 0
44+
while left_idx < len(left) and right_idx < len(right):
45+
ret = compare_text_edits(left[left_idx], right[right_idx])
46+
if ret <= 0:
47+
# smaller_equal -> take left to preserve order
48+
text_edits[i] = left[left_idx]
49+
i += 1
50+
left_idx += 1
51+
else:
52+
# greater -> take right
53+
text_edits[i] = right[right_idx]
54+
i += 1
55+
right_idx += 1
56+
while left_idx < len(left):
57+
text_edits[i] = left[left_idx]
58+
i += 1
59+
left_idx += 1
60+
while right_idx < len(right):
61+
text_edits[i] = right[right_idx]
62+
i += 1
63+
right_idx += 1
64+
return text_edits
65+
66+
67+
class OverLappingTextEditException(Exception):
68+
"""
69+
Text edits are expected to be sorted
70+
and compressed instead of overlapping.
71+
This error is raised when two edits
72+
are overlapping.
73+
"""
74+
75+
76+
def apply_text_edits(doc, text_edits):
77+
text = doc.source
78+
sorted_edits = merge_sort_text_edits(list(map(get_well_formatted_edit, text_edits)))
79+
last_modified_offset = 0
80+
spans = []
81+
for e in sorted_edits:
82+
start_offset = doc.offset_at_position(e['range']['start'])
83+
if start_offset < last_modified_offset:
84+
raise OverLappingTextEditException('overlapping edit')
85+
86+
if start_offset > last_modified_offset:
87+
spans.append(text[last_modified_offset:start_offset])
88+
89+
if len(e['newText']):
90+
spans.append(e['newText'])
91+
last_modified_offset = doc.offset_at_position(e['range']['end'])
92+
93+
spans.append(text[last_modified_offset:])
94+
return ''.join(spans)

pyproject.toml

+2-1
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ all = [
3535
"pylint>=2.5.0",
3636
"rope>=0.10.5",
3737
"yapf",
38+
"whatthepatch"
3839
]
3940
autopep8 = ["autopep8>=1.6.0,<1.7.0"]
4041
flake8 = ["flake8>=4.0.0,<4.1.0"]
@@ -44,7 +45,7 @@ pydocstyle = ["pydocstyle>=2.0.0"]
4445
pyflakes = ["pyflakes>=2.4.0,<2.5.0"]
4546
pylint = ["pylint>=2.5.0"]
4647
rope = ["rope>0.10.5"]
47-
yapf = ["yapf"]
48+
yapf = ["yapf", "whatthepatch>=1.0.2,<2.0.0"]
4849
websockets = ["websockets>=10.3"]
4950
test = [
5051
"pylint>=2.5.0",

0 commit comments

Comments
 (0)