diff --git a/.gitignore b/.gitignore index 372c13e..9c882b7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,2 @@ __pycache__/ - +*.egg-info/ diff --git a/README.md b/README.md index b6b3e73..0f27b52 100644 --- a/README.md +++ b/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? diff --git a/italianswirls/__main__.py b/italianswirls/__main__.py new file mode 100644 index 0000000..b36d648 --- /dev/null +++ b/italianswirls/__main__.py @@ -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() diff --git a/italianswirls/glue.py b/italianswirls/glue.py new file mode 100644 index 0000000..2c4050f --- /dev/null +++ b/italianswirls/glue.py @@ -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) + ) diff --git a/italianswirls/server.py b/italianswirls/server.py index 8e94c44..bcfec6e 100644 --- a/italianswirls/server.py +++ b/italianswirls/server.py @@ -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 diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 0000000..410ba98 --- /dev/null +++ b/mypy.ini @@ -0,0 +1,6 @@ +[mypy] +warn_return_any = True +warn_unused_configs = True + +zsh:1: command not found: a +ignore_missing_imports = True diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9787c3b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["setuptools", "wheel"] +build-backend = "setuptools.build_meta"