virtual_documents_shadow.py 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211
  1. # flake8: noqa: W503
  2. from concurrent.futures import ThreadPoolExecutor
  3. from pathlib import Path
  4. from shutil import rmtree
  5. from typing import List
  6. from tornado.concurrent import run_on_executor
  7. from tornado.gen import convert_yielded
  8. from .manager import lsp_message_listener
  9. from .paths import file_uri_to_path, is_relative
  10. from .types import LanguageServerManagerAPI
  11. # TODO: make configurable
  12. MAX_WORKERS = 4
  13. def extract_or_none(obj, path):
  14. for crumb in path:
  15. try:
  16. obj = obj[crumb]
  17. except (KeyError, TypeError):
  18. return None
  19. return obj
  20. class EditableFile:
  21. executor = ThreadPoolExecutor(max_workers=MAX_WORKERS)
  22. def __init__(self, path):
  23. # Python 3.5 relict:
  24. self.path = Path(path) if isinstance(path, str) else path
  25. async def read(self):
  26. self.lines = await convert_yielded(self.read_lines())
  27. async def write(self):
  28. return await convert_yielded(self.write_lines())
  29. @run_on_executor
  30. def read_lines(self):
  31. # empty string required by the assumptions of the gluing algorithm
  32. lines = [""]
  33. try:
  34. # TODO: what to do about bad encoding reads?
  35. lines = self.path.read_text(encoding="utf-8").splitlines()
  36. except FileNotFoundError:
  37. pass
  38. return lines
  39. @run_on_executor
  40. def write_lines(self):
  41. self.path.parent.mkdir(parents=True, exist_ok=True)
  42. self.path.write_text("\n".join(self.lines), encoding="utf-8")
  43. @staticmethod
  44. def trim(lines: list, character: int, side: int):
  45. needs_glue = False
  46. if lines:
  47. trimmed = lines[side][character:]
  48. if lines[side] != trimmed:
  49. needs_glue = True
  50. lines[side] = trimmed
  51. return needs_glue
  52. @staticmethod
  53. def join(left, right, glue: bool):
  54. if not glue:
  55. return []
  56. return [(left[-1] if left else "") + (right[0] if right else "")]
  57. def apply_change(self, text: str, start, end):
  58. before = self.lines[: start["line"]]
  59. after = self.lines[end["line"] :]
  60. needs_glue_left = self.trim(lines=before, character=start["character"], side=0)
  61. needs_glue_right = self.trim(lines=after, character=end["character"], side=-1)
  62. inner = text.split("\n")
  63. self.lines = (
  64. before[: -1 if needs_glue_left else None]
  65. + self.join(before, inner, needs_glue_left)
  66. + inner[1 if needs_glue_left else None : -1 if needs_glue_right else None]
  67. + self.join(inner, after, needs_glue_right)
  68. + after[1 if needs_glue_right else None :]
  69. ) or [""]
  70. @property
  71. def full_range(self):
  72. start = {"line": 0, "character": 0}
  73. end = {
  74. "line": len(self.lines),
  75. "character": len(self.lines[-1]) if self.lines else 0,
  76. }
  77. return {"start": start, "end": end}
  78. WRITE_ONE = ["textDocument/didOpen", "textDocument/didChange", "textDocument/didSave"]
  79. class ShadowFilesystemError(ValueError):
  80. """Error in the shadow file system."""
  81. def setup_shadow_filesystem(virtual_documents_uri: str):
  82. if not virtual_documents_uri.startswith("file:/"):
  83. raise ShadowFilesystemError( # pragma: no cover
  84. 'Virtual documents URI has to start with "file:/", got '
  85. + virtual_documents_uri
  86. )
  87. initialized = False
  88. failures: List[Exception] = []
  89. shadow_filesystem = Path(file_uri_to_path(virtual_documents_uri))
  90. @lsp_message_listener("client")
  91. async def shadow_virtual_documents(scope, message, language_server, manager):
  92. """Intercept a message with document contents creating a shadow file for it.
  93. Only create the shadow file if the URI matches the virtual documents URI.
  94. Returns the path on filesystem where the content was stored.
  95. """
  96. nonlocal initialized
  97. # short-circut if language server does not require documents on disk
  98. server_spec = manager.language_servers[language_server]
  99. if not server_spec.get("requires_documents_on_disk", True):
  100. return
  101. if not message.get("method") in WRITE_ONE:
  102. return
  103. document = extract_or_none(message, ["params", "textDocument"])
  104. if document is None:
  105. raise ShadowFilesystemError(
  106. "Could not get textDocument from: {}".format(message)
  107. )
  108. uri = extract_or_none(document, ["uri"])
  109. if not uri:
  110. raise ShadowFilesystemError("Could not get URI from: {}".format(message))
  111. if not uri.startswith(virtual_documents_uri):
  112. return
  113. # initialization (/any file system operations) delayed until needed
  114. if not initialized:
  115. if len(failures) == 3:
  116. return
  117. try:
  118. # create if does no exist (so that removal does not raise)
  119. shadow_filesystem.mkdir(parents=True, exist_ok=True)
  120. # remove with contents
  121. rmtree(str(shadow_filesystem))
  122. # create again
  123. shadow_filesystem.mkdir(parents=True, exist_ok=True)
  124. except (OSError, PermissionError, FileNotFoundError) as e:
  125. failures.append(e)
  126. if len(failures) == 3:
  127. manager.log.warn(
  128. "[lsp] initialization of shadow filesystem failed three times"
  129. " check if the path set by `LanguageServerManager.virtual_documents_dir`"
  130. " or `JP_LSP_VIRTUAL_DIR` is correct; if this is happening with a server"
  131. " for which you control (or wish to override) jupyter-lsp specification"
  132. " you can try switching `requires_documents_on_disk` off. The errors were: %s",
  133. failures,
  134. )
  135. return
  136. initialized = True
  137. path = file_uri_to_path(uri)
  138. if not is_relative(shadow_filesystem, path):
  139. raise ShadowFilesystemError(
  140. f"Path {path} is not relative to shadow filesystem root"
  141. )
  142. editable_file = EditableFile(path)
  143. await editable_file.read()
  144. text = extract_or_none(document, ["text"])
  145. if text is not None:
  146. # didOpen and didSave may provide text within the document
  147. changes = [{"text": text}]
  148. else:
  149. # didChange is the only one which can also provide it in params (as contentChanges)
  150. if message["method"] != "textDocument/didChange":
  151. return
  152. if "contentChanges" not in message["params"]:
  153. raise ShadowFilesystemError(
  154. "textDocument/didChange is missing contentChanges"
  155. )
  156. changes = message["params"]["contentChanges"]
  157. if len(changes) > 1:
  158. manager.log.warn( # pragma: no cover
  159. "LSP warning: up to one change supported for textDocument/didChange"
  160. )
  161. for change in changes[:1]:
  162. change_range = change.get("range", editable_file.full_range)
  163. editable_file.apply_change(change["text"], **change_range)
  164. await editable_file.write()
  165. return path
  166. return shadow_virtual_documents