server: implement rename (tougher!)
Heavily borrows code from jedi-language-server.
This commit is contained in:
parent
c7b38e7998
commit
9c3f8d95f1
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -1,2 +1,2 @@
|
|||
__pycache__/
|
||||
|
||||
*.egg-info/
|
||||
|
|
37
README.md
37
README.md
|
@ -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?
|
||||
|
||||
|
|
8
italianswirls/__main__.py
Normal file
8
italianswirls/__main__.py
Normal 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
157
italianswirls/glue.py
Normal 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)
|
||||
)
|
|
@ -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
6
mypy.ini
Normal 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
3
pyproject.toml
Normal file
|
@ -0,0 +1,3 @@
|
|||
[build-system]
|
||||
requires = ["setuptools", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
Loading…
Reference in a new issue