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__/
|
__pycache__/
|
||||||
|
*.egg-info/
|
||||||
|
|
25
README.md
25
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
|
[jedi]: https://jedi.readthedocs.io/en/latest/index.html
|
||||||
[pygls]: https://pygls.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:
|
Supported features:
|
||||||
|
|
||||||
| LSP method | Description |
|
| LSP method | Description |
|
||||||
|-------------------------------|-----------------------|
|
|-------------------------------|----------------------------|
|
||||||
| `textDocument/completion` | Complete |
|
| `textDocument/completion` | Complete |
|
||||||
| `textDocument/definition` | Go to definition |
|
| `textDocument/definition` | Go to definition |
|
||||||
| `textDocument/typeDefinition` | Go to type definition |
|
| `textDocument/typeDefinition` | Go to type definition |
|
||||||
| `textDocument/hover` | Show documentation |
|
| `textDocument/hover` | Show documentation |
|
||||||
| `textDocument/references` | Show references |
|
| `textDocument/references` | Show references |
|
||||||
|
| `textDocument/rename` | Renaming symbols and files |
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@ -32,19 +33,21 @@ About
|
||||||
|
|
||||||
### Why?
|
### Why?
|
||||||
|
|
||||||
General-purpose servers (pyls, py-lsp) try to do too much and break stuff too
|
General-purpose servers (e.g. pyls, py-lsp) try to do too much and break stuff
|
||||||
often for me. Locking Neovim when I press tab, crashes of all kind, LspRestart
|
too often for me. Locking Neovim when I press tab, crashes of all kind,
|
||||||
failing. Also I like my linting and formatting done by dedicated tools such as
|
LspRestart failing. Also I like my linting and formatting done by dedicated
|
||||||
[nvim-lint][nvim-lint] and [formatter][formatter].
|
tools such as [nvim-lint][nvim-lint] and [formatter][formatter].
|
||||||
|
|
||||||
[nvim-lint]: https://github.com/mfussenegger/nvim-lint
|
[nvim-lint]: https://github.com/mfussenegger/nvim-lint
|
||||||
[formatter]: https://github.com/mhartington/formatter.nvim
|
[formatter]: https://github.com/mhartington/formatter.nvim
|
||||||
|
|
||||||
Other Jedi-based servers seem to focus on coc-nvim and frequently fail on
|
Other Jedi-based servers (e.g. jedi-language-server) seem to focus on coc-nvim
|
||||||
Neovim's native LSP client for me. I tried to fix jedi-language-server several
|
and frequently fail on Neovim's native LSP client for me. I tried to fix
|
||||||
times when it failed me but thought it could be fun to try pygls to redo it as
|
jedi-language-server several times when it failed me but I thought it could be
|
||||||
small and simple as I can. And running a Node server to get Python completions?
|
fun to try pygls to redo it as small and simple as I can. And running a Node
|
||||||
HA!
|
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?
|
### 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 typing import Optional
|
||||||
|
|
||||||
from jedi import Script
|
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,
|
from pygls.lsp.methods import (COMPLETION, DEFINITION, HOVER, REFERENCES,
|
||||||
TYPE_DEFINITION)
|
RENAME, TYPE_DEFINITION)
|
||||||
from pygls.lsp.types import (CompletionItem, CompletionItemKind,
|
from pygls.lsp.types import (CompletionItem, CompletionList, CompletionOptions,
|
||||||
CompletionList, CompletionOptions,
|
|
||||||
CompletionParams, DefinitionParams, Hover,
|
CompletionParams, DefinitionParams, Hover,
|
||||||
HoverParams, InsertTextFormat,
|
HoverParams, InsertTextFormat, Location,
|
||||||
Location, Position, Range, ReferenceParams,
|
ReferenceParams, RenameParams,
|
||||||
TextDocumentPositionParams, TypeDefinitionParams)
|
TextDocumentPositionParams, TypeDefinitionParams,
|
||||||
|
WorkspaceEdit)
|
||||||
from pygls.server import LanguageServer
|
from pygls.server import LanguageServer
|
||||||
from pygls.workspace import Document
|
from pygls.workspace import Document
|
||||||
|
|
||||||
LOG = logging.getLogger()
|
from italianswirls.glue import (gen_document_edits, get_jedi_position,
|
||||||
LOG.setLevel(logging.DEBUG)
|
get_lsp_completion_kind, get_lsp_locations)
|
||||||
LOG.addHandler(logging.FileHandler("/tmp/a.log"))
|
|
||||||
|
|
||||||
LS = LanguageServer('italianswirls', 'v0.0.1')
|
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:
|
def get_jedi_script(document: Document) -> Script:
|
||||||
"""Get Jedi Script object from this document."""
|
"""Get Jedi Script object from this document."""
|
||||||
|
@ -49,54 +36,6 @@ def get_jedi_script_from_params(
|
||||||
return script
|
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=["."]))
|
@LS.feature(COMPLETION, CompletionOptions(trigger_characters=["."]))
|
||||||
async def do_completion(
|
async def do_completion(
|
||||||
server: LanguageServer,
|
server: LanguageServer,
|
||||||
|
@ -203,5 +142,20 @@ async def do_hover(
|
||||||
return Hover(contents=hover_text)
|
return Hover(contents=hover_text)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
@LS.feature(RENAME)
|
||||||
LS.start_io()
|
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