You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
212 lines
7.2 KiB
Python
212 lines
7.2 KiB
Python
# flake8: noqa: W503
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from pathlib import Path
|
|
from shutil import rmtree
|
|
from typing import List
|
|
|
|
from tornado.concurrent import run_on_executor
|
|
from tornado.gen import convert_yielded
|
|
|
|
from .manager import lsp_message_listener
|
|
from .paths import file_uri_to_path, is_relative
|
|
from .types import LanguageServerManagerAPI
|
|
|
|
# TODO: make configurable
|
|
MAX_WORKERS = 4
|
|
|
|
|
|
def extract_or_none(obj, path):
|
|
for crumb in path:
|
|
try:
|
|
obj = obj[crumb]
|
|
except (KeyError, TypeError):
|
|
return None
|
|
return obj
|
|
|
|
|
|
class EditableFile:
|
|
executor = ThreadPoolExecutor(max_workers=MAX_WORKERS)
|
|
|
|
def __init__(self, path):
|
|
# Python 3.5 relict:
|
|
self.path = Path(path) if isinstance(path, str) else path
|
|
|
|
async def read(self):
|
|
self.lines = await convert_yielded(self.read_lines())
|
|
|
|
async def write(self):
|
|
return await convert_yielded(self.write_lines())
|
|
|
|
@run_on_executor
|
|
def read_lines(self):
|
|
# empty string required by the assumptions of the gluing algorithm
|
|
lines = [""]
|
|
try:
|
|
# TODO: what to do about bad encoding reads?
|
|
lines = self.path.read_text(encoding="utf-8").splitlines()
|
|
except FileNotFoundError:
|
|
pass
|
|
return lines
|
|
|
|
@run_on_executor
|
|
def write_lines(self):
|
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
self.path.write_text("\n".join(self.lines), encoding="utf-8")
|
|
|
|
@staticmethod
|
|
def trim(lines: list, character: int, side: int):
|
|
needs_glue = False
|
|
if lines:
|
|
trimmed = lines[side][character:]
|
|
if lines[side] != trimmed:
|
|
needs_glue = True
|
|
lines[side] = trimmed
|
|
return needs_glue
|
|
|
|
@staticmethod
|
|
def join(left, right, glue: bool):
|
|
if not glue:
|
|
return []
|
|
return [(left[-1] if left else "") + (right[0] if right else "")]
|
|
|
|
def apply_change(self, text: str, start, end):
|
|
before = self.lines[: start["line"]]
|
|
after = self.lines[end["line"] :]
|
|
|
|
needs_glue_left = self.trim(lines=before, character=start["character"], side=0)
|
|
needs_glue_right = self.trim(lines=after, character=end["character"], side=-1)
|
|
|
|
inner = text.split("\n")
|
|
|
|
self.lines = (
|
|
before[: -1 if needs_glue_left else None]
|
|
+ self.join(before, inner, needs_glue_left)
|
|
+ inner[1 if needs_glue_left else None : -1 if needs_glue_right else None]
|
|
+ self.join(inner, after, needs_glue_right)
|
|
+ after[1 if needs_glue_right else None :]
|
|
) or [""]
|
|
|
|
@property
|
|
def full_range(self):
|
|
start = {"line": 0, "character": 0}
|
|
end = {
|
|
"line": len(self.lines),
|
|
"character": len(self.lines[-1]) if self.lines else 0,
|
|
}
|
|
return {"start": start, "end": end}
|
|
|
|
|
|
WRITE_ONE = ["textDocument/didOpen", "textDocument/didChange", "textDocument/didSave"]
|
|
|
|
|
|
class ShadowFilesystemError(ValueError):
|
|
"""Error in the shadow file system."""
|
|
|
|
|
|
def setup_shadow_filesystem(virtual_documents_uri: str):
|
|
if not virtual_documents_uri.startswith("file:/"):
|
|
raise ShadowFilesystemError( # pragma: no cover
|
|
'Virtual documents URI has to start with "file:/", got '
|
|
+ virtual_documents_uri
|
|
)
|
|
|
|
initialized = False
|
|
failures: List[Exception] = []
|
|
|
|
shadow_filesystem = Path(file_uri_to_path(virtual_documents_uri))
|
|
|
|
@lsp_message_listener("client")
|
|
async def shadow_virtual_documents(scope, message, language_server, manager):
|
|
"""Intercept a message with document contents creating a shadow file for it.
|
|
|
|
Only create the shadow file if the URI matches the virtual documents URI.
|
|
Returns the path on filesystem where the content was stored.
|
|
"""
|
|
nonlocal initialized
|
|
|
|
# short-circut if language server does not require documents on disk
|
|
server_spec = manager.language_servers[language_server]
|
|
if not server_spec.get("requires_documents_on_disk", True):
|
|
return
|
|
|
|
if not message.get("method") in WRITE_ONE:
|
|
return
|
|
|
|
document = extract_or_none(message, ["params", "textDocument"])
|
|
if document is None:
|
|
raise ShadowFilesystemError(
|
|
"Could not get textDocument from: {}".format(message)
|
|
)
|
|
|
|
uri = extract_or_none(document, ["uri"])
|
|
if not uri:
|
|
raise ShadowFilesystemError("Could not get URI from: {}".format(message))
|
|
|
|
if not uri.startswith(virtual_documents_uri):
|
|
return
|
|
|
|
# initialization (/any file system operations) delayed until needed
|
|
if not initialized:
|
|
if len(failures) == 3:
|
|
return
|
|
try:
|
|
# create if does no exist (so that removal does not raise)
|
|
shadow_filesystem.mkdir(parents=True, exist_ok=True)
|
|
# remove with contents
|
|
rmtree(str(shadow_filesystem))
|
|
# create again
|
|
shadow_filesystem.mkdir(parents=True, exist_ok=True)
|
|
except (OSError, PermissionError, FileNotFoundError) as e:
|
|
failures.append(e)
|
|
if len(failures) == 3:
|
|
manager.log.warn(
|
|
"[lsp] initialization of shadow filesystem failed three times"
|
|
" check if the path set by `LanguageServerManager.virtual_documents_dir`"
|
|
" or `JP_LSP_VIRTUAL_DIR` is correct; if this is happening with a server"
|
|
" for which you control (or wish to override) jupyter-lsp specification"
|
|
" you can try switching `requires_documents_on_disk` off. The errors were: %s",
|
|
failures,
|
|
)
|
|
return
|
|
initialized = True
|
|
|
|
path = file_uri_to_path(uri)
|
|
if not is_relative(shadow_filesystem, path):
|
|
raise ShadowFilesystemError(
|
|
f"Path {path} is not relative to shadow filesystem root"
|
|
)
|
|
|
|
editable_file = EditableFile(path)
|
|
|
|
await editable_file.read()
|
|
|
|
text = extract_or_none(document, ["text"])
|
|
|
|
if text is not None:
|
|
# didOpen and didSave may provide text within the document
|
|
changes = [{"text": text}]
|
|
else:
|
|
# didChange is the only one which can also provide it in params (as contentChanges)
|
|
if message["method"] != "textDocument/didChange":
|
|
return
|
|
if "contentChanges" not in message["params"]:
|
|
raise ShadowFilesystemError(
|
|
"textDocument/didChange is missing contentChanges"
|
|
)
|
|
changes = message["params"]["contentChanges"]
|
|
|
|
if len(changes) > 1:
|
|
manager.log.warn( # pragma: no cover
|
|
"LSP warning: up to one change supported for textDocument/didChange"
|
|
)
|
|
|
|
for change in changes[:1]:
|
|
change_range = change.get("range", editable_file.full_range)
|
|
editable_file.apply_change(change["text"], **change_range)
|
|
|
|
await editable_file.write()
|
|
|
|
return path
|
|
|
|
return shadow_virtual_documents
|