File size: 7,359 Bytes
d1ceb73 |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 |
# 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
|