server: implement rename (tougher!)

Heavily borrows code from jedi-language-server.
This commit is contained in:
dece 2022-12-04 16:32:14 +01:00
parent c7b38e7998
commit 9c3f8d95f1
7 changed files with 221 additions and 90 deletions

2
.gitignore vendored
View file

@ -1,2 +1,2 @@
__pycache__/
*.egg-info/

View file

@ -6,17 +6,18 @@ Minimal Python language server, based on [Jedi][jedi] and [pygls][pygls].
[jedi]: https://jedi.readthedocs.io/en/latest/index.html
[pygls]: https://pygls.readthedocs.io/en/latest/index.html
WIP! ⚠ 👷🚧
Still in development but works on my machine. ✨
Supported features:
| LSP method | Description |
|-------------------------------|-----------------------|
| `textDocument/completion` | Complete |
| `textDocument/definition` | Go to definition |
| `textDocument/typeDefinition` | Go to type definition |
| `textDocument/hover` | Show documentation |
| `textDocument/references` | Show references |
| LSP method | Description |
|-------------------------------|----------------------------|
| `textDocument/completion` | Complete |
| `textDocument/definition` | Go to definition |
| `textDocument/typeDefinition` | Go to type definition |
| `textDocument/hover` | Show documentation |
| `textDocument/references` | Show references |
| `textDocument/rename` | Renaming symbols and files |
@ -32,19 +33,21 @@ About
### Why?
General-purpose servers (pyls, py-lsp) try to do too much and break stuff too
often for me. Locking Neovim when I press tab, crashes of all kind, LspRestart
failing. Also I like my linting and formatting done by dedicated tools such as
[nvim-lint][nvim-lint] and [formatter][formatter].
General-purpose servers (e.g. pyls, py-lsp) try to do too much and break stuff
too often for me. Locking Neovim when I press tab, crashes of all kind,
LspRestart failing. Also I like my linting and formatting done by dedicated
tools such as [nvim-lint][nvim-lint] and [formatter][formatter].
[nvim-lint]: https://github.com/mfussenegger/nvim-lint
[formatter]: https://github.com/mhartington/formatter.nvim
Other Jedi-based servers seem to focus on coc-nvim and frequently fail on
Neovim's native LSP client for me. I tried to fix jedi-language-server several
times when it failed me but thought it could be fun to try pygls to redo it as
small and simple as I can. And running a Node server to get Python completions?
HA!
Other Jedi-based servers (e.g. jedi-language-server) seem to focus on coc-nvim
and frequently fail on Neovim's native LSP client for me. I tried to fix
jedi-language-server several times when it failed me but I thought it could be
fun to try pygls to redo it as small and simple as I can. And running a Node
server to get Python completions? No way. That said, jedi-language-server is a
good project and if you're fine with coc-nvim you should definitely check it
out. Lots of the code here is ~~stolen~~ inspired from this project.
### Why the name?

View file

@ -0,0 +1,8 @@
from italianswirls.server import LS
if __name__ == "__main__":
import logging
root_logger = logging.getLogger()
root_logger.setLevel(logging.DEBUG)
root_logger.addHandler(logging.FileHandler("/tmp/a.log"))
LS.start_io()

157
italianswirls/glue.py Normal file
View file

@ -0,0 +1,157 @@
"""Glue between Jedi and LSP data structures."""
import bisect
import difflib
from typing import Generator, Optional, Union
from jedi.api.classes import Name
from jedi.api.refactoring import Refactoring
from pygls.lsp.types import (CompletionItemKind, Location, Position, Range,
RenameFile, RenameFileOptions,
ResourceOperationKind, TextDocumentEdit, TextEdit,
VersionedTextDocumentIdentifier)
from pygls.workspace import Workspace
JEDI_COMPLETION_TYPE_MAP = {
"module": CompletionItemKind.Module,
"class": CompletionItemKind.Class,
"instance": CompletionItemKind.Variable,
"function": CompletionItemKind.Function,
"param": CompletionItemKind.Variable,
"path": CompletionItemKind.File,
"keyword": CompletionItemKind.Keyword,
"property": CompletionItemKind.Property,
"statement": CompletionItemKind.Variable,
}
def get_jedi_position(position: Position) -> tuple[int, int]:
"""Translate LSP Position to Jedi position (where line is 1-based)."""
return position.line + 1, position.character
def get_lsp_position(line: int, column: int) -> Position:
"""Translate Jedi position to LSP Position (where line is 0-based)."""
return Position(line=line - 1, character=column)
def get_lsp_range(name: Name) -> Optional[Range]:
"""Get an LSP range for this name, if it has a location."""
if name.line is None or name.column is None:
return None
start_position = get_lsp_position(name.line, name.column)
end_position = get_lsp_position(name.line, name.column + len(name.name))
return Range(start=start_position, end=end_position)
def get_lsp_location(name: Name) -> Optional[Location]:
"""Return an LSP location from this Jedi Name."""
if name.module_path is None:
return None
if (lsp_range := get_lsp_range(name)) is None:
return None
return Location(uri=name.module_path.as_uri(), range=lsp_range)
def get_lsp_locations(names: list[Name]) -> list[Location]:
"""Return a list of LSP locations from this list of Jedi Names.
Names that cannot be converted to a LSP location are discarded.
"""
lsp_locations = []
for name in names:
if lsp_location := get_lsp_location(name):
lsp_locations.append(lsp_location)
return lsp_locations
def get_lsp_completion_kind(jedi_compl_type: str) -> CompletionItemKind:
"""Return an LSP completion item kind from this Jedi completion type."""
return JEDI_COMPLETION_TYPE_MAP.get(
jedi_compl_type,
CompletionItemKind.Text
)
def _build_line_offsets(text: str) -> list[int]:
"""Return a list of indexes where each line of the text starts."""
line_offsets = []
offset = 0
for line in text.splitlines(keepends=True):
line_offsets.append(offset)
offset += len(line)
return line_offsets
def _get_lsp_position_from_offsets(
line_offsets: list[int],
offset: int,
) -> Position:
"""Return an LSP Position for this offset, using these line offsets."""
line_number = bisect.bisect_right(line_offsets, offset) - 1
character_number = offset - line_offsets[line_number]
return Position(line=line_number, character=character_number)
def gen_document_edits(
refactoring: Refactoring,
workspace: Workspace,
) -> Generator[Union[TextDocumentEdit, RenameFile], None, None]:
"""Generate TextDocumentEdit and RenameFile objects from a refactoring."""
yield from _gen_document_text_edits(refactoring, workspace)
yield from _gen_document_renames(refactoring, workspace)
def _gen_document_text_edits(
refactoring: Refactoring,
workspace: Workspace,
) -> Generator[TextDocumentEdit, None, None]:
"""Generate TextDocumentEdit objects for each text modification.
Compare previous code and refactored code using standard difflib. The main
complexity here is to translate difflib's opcode positions into LSP ranges.
This code is 99% taken from the neat jedi-language-server implementation.
"""
for path, changed_file in refactoring.get_changed_files().items():
document_uri = path.as_uri()
document = workspace.get_document(document_uri)
old_code = document.source
new_code = changed_file.get_new_code()
line_offsets = _build_line_offsets(old_code)
edit_operations = (
opcode for opcode in
difflib.SequenceMatcher(a=old_code, b=new_code).get_opcodes()
if opcode[0] != "equal"
)
text_edits: list[TextEdit] = []
for op, old_start, old_end, new_start, new_end in edit_operations:
new_text = new_code[new_start:new_end]
start_pos = _get_lsp_position_from_offsets(line_offsets, old_start)
end_pos = _get_lsp_position_from_offsets(line_offsets, old_end)
edit_range = Range(start=start_pos, end=end_pos)
text_edits.append(TextEdit(range=edit_range, new_text=new_text))
if not text_edits:
continue
document_id = VersionedTextDocumentIdentifier(
uri=document_uri,
version=document.version or 0,
)
yield TextDocumentEdit(
text_document=document_id,
edits=text_edits,
)
def _gen_document_renames(
refactoring: Refactoring,
workspace: Workspace,
) -> Generator[RenameFile, None, None]:
"""Generate RenameFile objects for each renamed file."""
for old_name, new_name in refactoring.get_renames():
yield RenameFile(
kind=ResourceOperationKind.Rename,
old_uri=old_name.as_uri(),
new_uri=new_name.as_uri(),
options=RenameFileOptions(ignore_if_exists=True, overwrite=True)
)

View file

@ -2,36 +2,23 @@ import logging
from typing import Optional
from jedi import Script
from jedi.api.classes import Name
from jedi.api.refactoring import RefactoringError
from pygls.lsp.methods import (COMPLETION, DEFINITION, HOVER, REFERENCES,
TYPE_DEFINITION)
from pygls.lsp.types import (CompletionItem, CompletionItemKind,
CompletionList, CompletionOptions,
RENAME, TYPE_DEFINITION)
from pygls.lsp.types import (CompletionItem, CompletionList, CompletionOptions,
CompletionParams, DefinitionParams, Hover,
HoverParams, InsertTextFormat,
Location, Position, Range, ReferenceParams,
TextDocumentPositionParams, TypeDefinitionParams)
HoverParams, InsertTextFormat, Location,
ReferenceParams, RenameParams,
TextDocumentPositionParams, TypeDefinitionParams,
WorkspaceEdit)
from pygls.server import LanguageServer
from pygls.workspace import Document
LOG = logging.getLogger()
LOG.setLevel(logging.DEBUG)
LOG.addHandler(logging.FileHandler("/tmp/a.log"))
from italianswirls.glue import (gen_document_edits, get_jedi_position,
get_lsp_completion_kind, get_lsp_locations)
LS = LanguageServer('italianswirls', 'v0.0.1')
JEDI_COMPLETION_TYPE_MAP = {
"module": CompletionItemKind.Module,
"class": CompletionItemKind.Class,
"instance": CompletionItemKind.Variable,
"function": CompletionItemKind.Function,
"param": CompletionItemKind.Variable,
"path": CompletionItemKind.File,
"keyword": CompletionItemKind.Keyword,
"property": CompletionItemKind.Property,
"statement": CompletionItemKind.Variable,
}
def get_jedi_script(document: Document) -> Script:
"""Get Jedi Script object from this document."""
@ -49,54 +36,6 @@ def get_jedi_script_from_params(
return script
def get_jedi_position(position: Position) -> tuple[int, int]:
"""Translate LSP Position to Jedi position (where line is 1-based)."""
return position.line + 1, position.character
def get_lsp_position(line: int, column: int) -> Position:
"""Translate Jedi position to LSP Position (where line is 0-based)."""
return Position(line=line - 1, character=column)
def get_lsp_range(name: Name) -> Optional[Range]:
"""Get an LSP range for this name, if it has a location."""
if name.line is None or name.column is None:
return None
start_position = get_lsp_position(name.line, name.column)
end_position = get_lsp_position(name.line, name.column + len(name.name))
return Range(start=start_position, end=end_position)
def get_lsp_location(name: Name) -> Optional[Location]:
"""Return an LSP location from this Jedi Name."""
if name.module_path is None:
return None
if (lsp_range := get_lsp_range(name)) is None:
return None
return Location(uri=name.module_path.as_uri(), range=lsp_range)
def get_lsp_locations(names: list[Name]) -> list[Location]:
"""Return a list of LSP locations from this list of Jedi Names.
Names that cannot be converted to a LSP location are discarded.
"""
lsp_locations = []
for name in names:
if lsp_location := get_lsp_location(name):
lsp_locations.append(lsp_location)
return lsp_locations
def get_lsp_completion_kind(jedi_compl_type: str) -> CompletionItemKind:
"""Return an LSP completion item kind from this Jedi completion type."""
return JEDI_COMPLETION_TYPE_MAP.get(
jedi_compl_type,
CompletionItemKind.Text
)
@LS.feature(COMPLETION, CompletionOptions(trigger_characters=["."]))
async def do_completion(
server: LanguageServer,
@ -203,5 +142,20 @@ async def do_hover(
return Hover(contents=hover_text)
if __name__ == "__main__":
LS.start_io()
@LS.feature(RENAME)
async def do_rename(
server: LanguageServer,
params: RenameParams,
) -> Optional[WorkspaceEdit]:
"""Ask Jedi to rename a symbol and return the resulting state."""
script = get_jedi_script_from_params(params, server)
jedi_position = get_jedi_position(params.position)
try:
refactoring = script.rename(*jedi_position, new_name=params.new_name)
except RefactoringError as exc:
logging.error(f"Refactoring failed: {exc}")
return None
changes = list(gen_document_edits(refactoring, server.workspace))
logging.info(f"changes: {changes}")
return WorkspaceEdit(document_changes=changes) if changes else None

6
mypy.ini Normal file
View file

@ -0,0 +1,6 @@
[mypy]
warn_return_any = True
warn_unused_configs = True
zsh:1: command not found: a
ignore_missing_imports = True

3
pyproject.toml Normal file
View file

@ -0,0 +1,3 @@
[build-system]
requires = ["setuptools", "wheel"]
build-backend = "setuptools.build_meta"