Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We鈥檒l occasionally send you account related emails.

Already on GitHub? Sign in to your account

Parse YAPF diffs into TextEdits (instead of sending the full doc) #136

Merged
merged 22 commits into from
Jun 6, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pylsp/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
JEDI_VERSION = jedi.__version__

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

Expand Down
1 change: 1 addition & 0 deletions pylsp/config/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ def __init__(self, root_uri, init_opts, process_id, capabilities):
if plugin is not None:
log.info("Loaded pylsp plugin %s from %s", name, plugin)

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

Expand Down
199 changes: 145 additions & 54 deletions pylsp/plugins/yapf_format.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
from yapf.yapflib import file_resources, style
from yapf.yapflib.yapf_api import FormatCode

import whatthepatch

from pylsp import hookimpl
from pylsp._utils import get_eol_chars

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


def _format(document, lines=None, options=None):
# Yapf doesn't work with CRLF/CR line endings, so we replace them by '\n'
# and restore them below.
replace_eols = False
source = document.source
eol_chars = get_eol_chars(source)
if eol_chars in ['\r', '\r\n']:
replace_eols = True
source = source.replace(eol_chars, '\n')

def get_style_config(document_path, options=None):
# Get the default styles as a string
# for a preset configuration, i.e. "pep8"
style_config = file_resources.GetDefaultStyleForDir(
os.path.dirname(document.path)
os.path.dirname(document_path)
)
if options is not None:
# We have options passed from LSP format request
# let's pass them to the formatter.
# First we want to get a dictionary of the preset style
# to pass instead of a string so that we can modify it
style_config = style.CreateStyleFromConfig(style_config)

use_tabs = style_config['USE_TABS']
indent_width = style_config['INDENT_WIDTH']

if options.get('tabSize') is not None:
indent_width = max(int(options.get('tabSize')), 1)

if options.get('insertSpaces') is not None:
# TODO is it guaranteed to be a boolean, or can it be a string?
use_tabs = not options.get('insertSpaces')

if use_tabs:
# Indent width doesn't make sense when using tabs
# the specifications state: "Size of a tab in spaces"
indent_width = 1
if options is None:
return style_config

# We have options passed from LSP format request
# let's pass them to the formatter.
# First we want to get a dictionary of the preset style
# to pass instead of a string so that we can modify it
style_config = style.CreateStyleFromConfig(style_config)

use_tabs = style_config['USE_TABS']
indent_width = style_config['INDENT_WIDTH']

if options.get('tabSize') is not None:
indent_width = max(int(options.get('tabSize')), 1)

if options.get('insertSpaces') is not None:
# TODO is it guaranteed to be a boolean, or can it be a string?
use_tabs = not options.get('insertSpaces')

if use_tabs:
# Indent width doesn't make sense when using tabs
# the specifications state: "Size of a tab in spaces"
indent_width = 1

style_config['USE_TABS'] = use_tabs
style_config['INDENT_WIDTH'] = indent_width
style_config['CONTINUATION_INDENT_WIDTH'] = indent_width

for style_option, value in options.items():
# Apply arbitrary options passed as formatter options
if style_option not in style_config:
# ignore if it's not a known yapf config
continue

style_config[style_option] = value

return style_config


def diff_to_text_edits(diff, eol_chars):
# To keep things simple our text edits will be line based.
# We will also return the edits uncompacted, meaning a
# line replacement will come in as a line remove followed
# by a line add instead of a line replace.
text_edits = []
# keep track of line number since additions
# don't include the line number it's being added
# to in diffs. lsp is 0-indexed so we'll start with -1
prev_line_no = -1

for change in diff.changes:
if change.old and change.new:
# old and new are the same line, no change
# diffs are 1-indexed
prev_line_no = change.old - 1
elif change.new:
# addition
text_edits.append({
'range': {
'start': {
'line': prev_line_no + 1,
'character': 0
},
'end': {
'line': prev_line_no + 1,
'character': 0
}
},
'newText': change.line + eol_chars
})
elif change.old:
# remove
lsp_line_no = change.old - 1
text_edits.append({
'range': {
'start': {
'line': lsp_line_no,
'character': 0
},
'end': {
# From LSP spec:
# If you want to specify a range that contains a line
# including the line ending character(s) then use an
# end position denoting the start of the next line.
'line': lsp_line_no + 1,
'character': 0
}
},
'newText': ''
})
prev_line_no = lsp_line_no

return text_edits


def ensure_eof_new_line(document, eol_chars, text_edits):
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a bit of a hack, see: google/yapf#1008

# diffs don't include EOF newline https://github.com/google/yapf/issues/1008
# we'll add it ourselves if our document doesn't already have it and the diff
# does not change the last line.
if document.source.endswith(eol_chars):
return

lines = document.lines
last_line_number = len(lines) - 1

if text_edits and text_edits[-1]['range']['start']['line'] >= last_line_number:
return

text_edits.append({
'range': {
'start': {
'line': last_line_number,
'character': 0
},
'end': {
'line': last_line_number + 1,
'character': 0
}
},
'newText': lines[-1] + eol_chars
})

style_config['USE_TABS'] = use_tabs
style_config['INDENT_WIDTH'] = indent_width
style_config['CONTINUATION_INDENT_WIDTH'] = indent_width

for style_option, value in options.items():
# Apply arbitrary options passed as formatter options
if style_option not in style_config:
# ignore if it's not a known yapf config
continue
def _format(document, lines=None, options=None):
source = document.source
# Yapf doesn't work with CRLF/CR line endings, so we replace them by '\n'
# and restore them below when adding new lines
eol_chars = get_eol_chars(source)
if eol_chars in ['\r', '\r\n']:
source = source.replace(eol_chars, '\n')
else:
eol_chars = '\n'

style_config[style_option] = value
style_config = get_style_config(document_path=document.path, options=options)

new_source, changed = FormatCode(
diff_txt, changed = FormatCode(
source,
lines=lines,
filename=document.filename,
print_diff=True,
style_config=style_config
)

if not changed:
return []

if replace_eols:
new_source = new_source.replace('\n', eol_chars)
patch_generator = whatthepatch.parse_patch(diff_txt)
diff = next(patch_generator)
patch_generator.close()

# I'm too lazy at the moment to parse diffs into TextEdit items
# So let's just return the entire file...
return [{
'range': {
'start': {'line': 0, 'character': 0},
# End char 0 of the line after our document
'end': {'line': len(document.lines), 'character': 0}
},
'newText': new_source
}]
text_edits = diff_to_text_edits(diff=diff, eol_chars=eol_chars)

ensure_eof_new_line(document=document, eol_chars=eol_chars, text_edits=text_edits)

return text_edits
4 changes: 4 additions & 0 deletions pylsp/python_lsp.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ class _StreamHandlerWrapper(socketserver.StreamRequestHandler):

def setup(self):
super().setup()
# pylint: disable=no-member
self.delegate = self.DELEGATE_CLASS(self.rfile, self.wfile)

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

# pylint: disable=no-member
self.SHUTDOWN_CALL()


Expand Down Expand Up @@ -121,6 +123,7 @@ async def pylsp_ws(websocket):
async for message in websocket:
try:
log.debug("consuming payload and feeding it to LSP handler")
# pylint: disable=c-extension-no-member
request = json.loads(message)
loop = asyncio.get_running_loop()
await loop.run_in_executor(tpool, pylsp_handler.consume, request)
Expand All @@ -130,6 +133,7 @@ async def pylsp_ws(websocket):
def send_message(message, websocket):
"""Handler to send responses of processed requests to respective web socket clients"""
try:
# pylint: disable=c-extension-no-member
payload = json.dumps(message, ensure_ascii=False)
asyncio.run(websocket.send(payload))
except Exception as e: # pylint: disable=broad-except
Expand Down
94 changes: 94 additions & 0 deletions pylsp/text_edit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# Copyright 2017-2020 Palantir Technologies, Inc.
# Copyright 2021- Python Language Server Contributors.

def get_well_formatted_range(lsp_range):
masad-frost marked this conversation as resolved.
Show resolved Hide resolved
start = lsp_range['start']
end = lsp_range['end']

if start['line'] > end['line'] or (start['line'] == end['line'] and start['character'] > end['character']):
return {'start': end, 'end': start}

return lsp_range


def get_well_formatted_edit(text_edit):
lsp_range = get_well_formatted_range(text_edit['range'])
if lsp_range != text_edit['range']:
return {'newText': text_edit['newText'], 'range': lsp_range}

return text_edit


def compare_text_edits(a, b):
diff = a['range']['start']['line'] - b['range']['start']['line']
if diff == 0:
return a['range']['start']['character'] - b['range']['start']['character']

return diff


def merge_sort_text_edits(text_edits):
if len(text_edits) <= 1:
return text_edits

p = len(text_edits) // 2
left = text_edits[:p]
right = text_edits[p:]

merge_sort_text_edits(left)
merge_sort_text_edits(right)

left_idx = 0
right_idx = 0
i = 0
while left_idx < len(left) and right_idx < len(right):
ret = compare_text_edits(left[left_idx], right[right_idx])
if ret <= 0:
# smaller_equal -> take left to preserve order
text_edits[i] = left[left_idx]
i += 1
left_idx += 1
else:
# greater -> take right
text_edits[i] = right[right_idx]
i += 1
right_idx += 1
while left_idx < len(left):
text_edits[i] = left[left_idx]
i += 1
left_idx += 1
while right_idx < len(right):
text_edits[i] = right[right_idx]
i += 1
right_idx += 1
return text_edits


class OverLappingTextEditException(Exception):
"""
Text edits are expected to be sorted
and compressed instead of overlapping.
This error is raised when two edits
are overlapping.
"""


def apply_text_edits(doc, text_edits):
text = doc.source
sorted_edits = merge_sort_text_edits(list(map(get_well_formatted_edit, text_edits)))
last_modified_offset = 0
spans = []
for e in sorted_edits:
start_offset = doc.offset_at_position(e['range']['start'])
if start_offset < last_modified_offset:
raise OverLappingTextEditException('overlapping edit')

if start_offset > last_modified_offset:
spans.append(text[last_modified_offset:start_offset])

if len(e['newText']):
spans.append(e['newText'])
last_modified_offset = doc.offset_at_position(e['range']['end'])

spans.append(text[last_modified_offset:])
return ''.join(spans)
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ all = [
"pylint>=2.5.0",
"rope>=0.10.5",
"yapf",
"whatthepatch"
]
autopep8 = ["autopep8>=1.6.0,<1.7.0"]
flake8 = ["flake8>=4.0.0,<4.1.0"]
Expand All @@ -44,7 +45,7 @@ pydocstyle = ["pydocstyle>=2.0.0"]
pyflakes = ["pyflakes>=2.4.0,<2.5.0"]
pylint = ["pylint>=2.5.0"]
rope = ["rope>0.10.5"]
yapf = ["yapf"]
yapf = ["yapf", "whatthepatch>=1.0.2,<2.0.0"]
websockets = ["websockets>=10.3"]
test = [
"pylint>=2.5.0",
Expand Down
Loading