python-fragments 0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fragments/__init__.py +0 -0
- fragments/ast_nodes.py +237 -0
- fragments/cli.py +20 -0
- fragments/grammar.py +206 -0
- fragments/html/__init__.py +0 -0
- fragments/html/elements.py +54 -0
- fragments/loader.py +32 -0
- fragments/lsp/__init__.py +0 -0
- fragments/lsp/completion.py +79 -0
- fragments/lsp/definition.py +49 -0
- fragments/lsp/file_state.py +109 -0
- fragments/lsp/hover.py +44 -0
- fragments/lsp/lifecycle.py +116 -0
- fragments/lsp/pyright.py +94 -0
- fragments/lsp/rename.py +106 -0
- fragments/lsp/semantic_tokens.py +42 -0
- fragments/lsp/server.py +129 -0
- fragments/source.py +26 -0
- fragments/transpiler.py +10 -0
- python_fragments-0.1.dist-info/METADATA +55 -0
- python_fragments-0.1.dist-info/RECORD +24 -0
- python_fragments-0.1.dist-info/WHEEL +5 -0
- python_fragments-0.1.dist-info/entry_points.txt +3 -0
- python_fragments-0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import bisect
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
|
|
6
|
+
from lsprotocol import types
|
|
7
|
+
|
|
8
|
+
from fragments.ast_nodes import ASTHTMLElement, ASTHTMLText, ASTInterpolation, ASTModule, ASTPython
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _build_line_starts(source: str) -> list[int]:
|
|
12
|
+
starts = [0]
|
|
13
|
+
for i, character in enumerate(source):
|
|
14
|
+
if character == "\n":
|
|
15
|
+
starts.append(i + 1)
|
|
16
|
+
return starts
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _position_to_offset(line_starts: list[int], line: int, character: int) -> int:
|
|
20
|
+
return line_starts[line] + character
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _offset_to_position(line_starts: list[int], offset: int) -> types.Position:
|
|
24
|
+
line = bisect.bisect_right(line_starts, offset) - 1
|
|
25
|
+
return types.Position(line=line, character=offset - line_starts[line])
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(slots=True)
|
|
29
|
+
class _FileState:
|
|
30
|
+
original: str
|
|
31
|
+
transpiled: str
|
|
32
|
+
module: ASTModule
|
|
33
|
+
original_line_starts: list[int] = field(init=False)
|
|
34
|
+
transpiled_line_starts: list[int] = field(init=False)
|
|
35
|
+
|
|
36
|
+
def __post_init__(self) -> None:
|
|
37
|
+
self.original_line_starts = _build_line_starts(self.original)
|
|
38
|
+
self.transpiled_line_starts = _build_line_starts(self.transpiled)
|
|
39
|
+
|
|
40
|
+
def _interpolation_expression_start(self, interpolation: ASTInterpolation) -> int:
|
|
41
|
+
after = self.original[interpolation.source_start + 2 :]
|
|
42
|
+
return interpolation.source_start + 2 + (len(after) - len(after.lstrip()))
|
|
43
|
+
|
|
44
|
+
def _original_offset_to_transpiled_offset(self, original_offset: int) -> int | None:
|
|
45
|
+
for child in self.module.children:
|
|
46
|
+
if isinstance(child, ASTPython):
|
|
47
|
+
if child.source_start <= original_offset < child.source_end:
|
|
48
|
+
return child.transpiled_start + (original_offset - child.source_start)
|
|
49
|
+
elif child.source_start <= original_offset < child.source_end:
|
|
50
|
+
return self._original_offset_in_nodes(original_offset, child.children)
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
def _original_offset_in_nodes(self, original_offset: int, nodes: list[ASTHTMLElement | ASTHTMLText | ASTInterpolation]) -> int | None:
|
|
54
|
+
for node in nodes:
|
|
55
|
+
if not (node.source_start <= original_offset < node.source_end):
|
|
56
|
+
continue
|
|
57
|
+
if isinstance(node, ASTInterpolation):
|
|
58
|
+
expression_start = self._interpolation_expression_start(node)
|
|
59
|
+
return node.transpiled_start + (original_offset - expression_start) if original_offset >= expression_start else None
|
|
60
|
+
if isinstance(node, ASTHTMLElement):
|
|
61
|
+
for interpolation in [node.if_attribute, node.for_attribute, *(a.interpolation for a in node.attributes.values())]:
|
|
62
|
+
if interpolation is not None and interpolation.source_start <= original_offset < interpolation.source_end:
|
|
63
|
+
expression_start = self._interpolation_expression_start(interpolation)
|
|
64
|
+
return interpolation.transpiled_start + (original_offset - expression_start) if original_offset >= expression_start else None
|
|
65
|
+
return self._original_offset_in_nodes(original_offset, list(node.children))
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
def _transpiled_offset_to_original_offset(self, transpiled_offset: int) -> int | None:
|
|
69
|
+
for child in self.module.children:
|
|
70
|
+
if isinstance(child, ASTPython):
|
|
71
|
+
if child.transpiled_start <= transpiled_offset < child.transpiled_end:
|
|
72
|
+
return child.source_start + (transpiled_offset - child.transpiled_start)
|
|
73
|
+
elif child.transpiled_start <= transpiled_offset < child.transpiled_end:
|
|
74
|
+
return self._transpiled_offset_in_nodes(transpiled_offset, child.children)
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
def _transpiled_offset_in_nodes(self, transpiled_offset: int, nodes: list[ASTHTMLElement | ASTHTMLText | ASTInterpolation]) -> int | None:
|
|
78
|
+
for node in nodes:
|
|
79
|
+
if not (node.transpiled_start <= transpiled_offset < node.transpiled_end):
|
|
80
|
+
continue
|
|
81
|
+
if isinstance(node, ASTInterpolation):
|
|
82
|
+
return self._interpolation_expression_start(node) + (transpiled_offset - node.transpiled_start)
|
|
83
|
+
if isinstance(node, ASTHTMLElement):
|
|
84
|
+
for interpolation in [node.if_attribute, node.for_attribute, *(a.interpolation for a in node.attributes.values())]:
|
|
85
|
+
if interpolation is not None and interpolation.transpiled_start <= transpiled_offset < interpolation.transpiled_end:
|
|
86
|
+
return self._interpolation_expression_start(interpolation) + (transpiled_offset - interpolation.transpiled_start)
|
|
87
|
+
return self._transpiled_offset_in_nodes(transpiled_offset, list(node.children))
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
def original_to_transpiled_position(self, position: types.Position) -> types.Position | None:
|
|
91
|
+
original_offset = _position_to_offset(self.original_line_starts, position.line, position.character)
|
|
92
|
+
transpiled_offset = self._original_offset_to_transpiled_offset(original_offset)
|
|
93
|
+
if transpiled_offset is None:
|
|
94
|
+
return None
|
|
95
|
+
return _offset_to_position(self.transpiled_line_starts, transpiled_offset)
|
|
96
|
+
|
|
97
|
+
def transpiled_to_original_position(self, position: types.Position) -> types.Position | None:
|
|
98
|
+
transpiled_offset = _position_to_offset(self.transpiled_line_starts, position.line, position.character)
|
|
99
|
+
original_offset = self._transpiled_offset_to_original_offset(transpiled_offset)
|
|
100
|
+
if original_offset is None:
|
|
101
|
+
return None
|
|
102
|
+
return _offset_to_position(self.original_line_starts, original_offset)
|
|
103
|
+
|
|
104
|
+
def transpiled_to_original_range(self, range_: types.Range) -> types.Range | None:
|
|
105
|
+
start = self.transpiled_to_original_position(range_.start)
|
|
106
|
+
end = self.transpiled_to_original_position(range_.end)
|
|
107
|
+
if start is None or end is None:
|
|
108
|
+
return None
|
|
109
|
+
return types.Range(start=start, end=end)
|
fragments/lsp/hover.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from lsprotocol import types
|
|
4
|
+
|
|
5
|
+
from fragments.lsp.server import FragmentsServer, _converter, server
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@server.feature(types.TEXT_DOCUMENT_HOVER)
|
|
9
|
+
async def hover(language_server: FragmentsServer, params: types.HoverParams) -> types.Hover | None:
|
|
10
|
+
if language_server._pyright is None or params.text_document.uri not in language_server._files:
|
|
11
|
+
return None
|
|
12
|
+
state = language_server._files[params.text_document.uri]
|
|
13
|
+
|
|
14
|
+
if state is None:
|
|
15
|
+
result = await language_server._pyright.request(
|
|
16
|
+
"textDocument/hover",
|
|
17
|
+
{
|
|
18
|
+
"textDocument": {"uri": params.text_document.uri},
|
|
19
|
+
"position": {"line": params.position.line, "character": params.position.character},
|
|
20
|
+
},
|
|
21
|
+
)
|
|
22
|
+
raw_hover = result.get("result")
|
|
23
|
+
return _converter.structure(raw_hover, types.Hover) if raw_hover else None
|
|
24
|
+
|
|
25
|
+
transpiled_position = state.original_to_transpiled_position(params.position)
|
|
26
|
+
if transpiled_position is None:
|
|
27
|
+
return None
|
|
28
|
+
|
|
29
|
+
result = await language_server._pyright.request(
|
|
30
|
+
"textDocument/hover",
|
|
31
|
+
{
|
|
32
|
+
"textDocument": {"uri": params.text_document.uri},
|
|
33
|
+
"position": {"line": transpiled_position.line, "character": transpiled_position.character},
|
|
34
|
+
},
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
raw_hover = result.get("result")
|
|
38
|
+
if not raw_hover:
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
hover_response = _converter.structure(raw_hover, types.Hover)
|
|
42
|
+
if hover_response.range is not None:
|
|
43
|
+
hover_response.range = state.transpiled_to_original_range(hover_response.range)
|
|
44
|
+
return hover_response
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
|
|
5
|
+
from lsprotocol import types
|
|
6
|
+
|
|
7
|
+
from fragments import grammar
|
|
8
|
+
from fragments.lsp.pyright import PyrightClient
|
|
9
|
+
from fragments.lsp.server import FragmentsServer, _TOKEN_MODIFIERS, _TOKEN_TYPES, _build_file_state, _parse_error_to_diagnostic, server
|
|
10
|
+
|
|
11
|
+
_DEBOUNCE_SECONDS = 0.15
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@server.feature(types.INITIALIZED)
|
|
15
|
+
async def initialized(language_server: FragmentsServer, _params: types.InitializedParams) -> None:
|
|
16
|
+
root_uri = language_server.workspace.root_uri
|
|
17
|
+
workspace_folders = [{"uri": folder.uri, "name": folder.name} for folder in language_server.workspace.folders.values()]
|
|
18
|
+
|
|
19
|
+
pyright = PyrightClient(language_server._on_pyright_notification, language_server._on_pyright_request)
|
|
20
|
+
language_server._pyright = pyright
|
|
21
|
+
await pyright.start()
|
|
22
|
+
await pyright.request(
|
|
23
|
+
"initialize",
|
|
24
|
+
{
|
|
25
|
+
"processId": None,
|
|
26
|
+
"rootUri": root_uri,
|
|
27
|
+
"workspaceFolders": workspace_folders or None,
|
|
28
|
+
"capabilities": {
|
|
29
|
+
"workspace": {"configuration": True},
|
|
30
|
+
"textDocument": {
|
|
31
|
+
"hover": {"contentFormat": ["markdown", "plaintext"]},
|
|
32
|
+
"rename": {"prepareSupport": True},
|
|
33
|
+
"semanticTokens": {
|
|
34
|
+
"requests": {"full": True},
|
|
35
|
+
"tokenTypes": _TOKEN_TYPES,
|
|
36
|
+
"tokenModifiers": _TOKEN_MODIFIERS,
|
|
37
|
+
"formats": ["relative"],
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
)
|
|
43
|
+
pyright.notify("initialized", {})
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@server.feature(types.TEXT_DOCUMENT_DID_OPEN)
|
|
47
|
+
async def did_open(language_server: FragmentsServer, params: types.DidOpenTextDocumentParams) -> None:
|
|
48
|
+
document = params.text_document
|
|
49
|
+
try:
|
|
50
|
+
state, content_for_pyright = await asyncio.get_running_loop().run_in_executor(None, _build_file_state, document.text)
|
|
51
|
+
language_server._parse_errors[document.uri] = None
|
|
52
|
+
except grammar.ParsingError as error:
|
|
53
|
+
state = None
|
|
54
|
+
content_for_pyright = document.text
|
|
55
|
+
language_server._parse_errors[document.uri] = _parse_error_to_diagnostic(document.text, error)
|
|
56
|
+
language_server._republish_diagnostics(document.uri)
|
|
57
|
+
language_server._files[document.uri] = state
|
|
58
|
+
|
|
59
|
+
if language_server._pyright:
|
|
60
|
+
language_server._pyright.notify(
|
|
61
|
+
"textDocument/didOpen",
|
|
62
|
+
{
|
|
63
|
+
"textDocument": {
|
|
64
|
+
"uri": document.uri,
|
|
65
|
+
"languageId": document.language_id,
|
|
66
|
+
"version": document.version,
|
|
67
|
+
"text": content_for_pyright,
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@server.feature(types.TEXT_DOCUMENT_DID_CHANGE)
|
|
74
|
+
def did_change(language_server: FragmentsServer, params: types.DidChangeTextDocumentParams) -> None:
|
|
75
|
+
uri = params.text_document.uri
|
|
76
|
+
text = params.content_changes[-1].text
|
|
77
|
+
|
|
78
|
+
existing = language_server._debounce_tasks.pop(uri, None)
|
|
79
|
+
if existing:
|
|
80
|
+
existing.cancel()
|
|
81
|
+
|
|
82
|
+
async def _apply_change() -> None:
|
|
83
|
+
await asyncio.sleep(_DEBOUNCE_SECONDS)
|
|
84
|
+
language_server._debounce_tasks.pop(uri, None)
|
|
85
|
+
try:
|
|
86
|
+
state, content_for_pyright = await asyncio.get_running_loop().run_in_executor(None, _build_file_state, text)
|
|
87
|
+
language_server._parse_errors[uri] = None
|
|
88
|
+
language_server._files[uri] = state
|
|
89
|
+
if language_server._pyright:
|
|
90
|
+
language_server._pyright.notify(
|
|
91
|
+
"textDocument/didChange",
|
|
92
|
+
{
|
|
93
|
+
"textDocument": {"uri": uri, "version": params.text_document.version},
|
|
94
|
+
"contentChanges": [{"text": content_for_pyright}],
|
|
95
|
+
},
|
|
96
|
+
)
|
|
97
|
+
except grammar.ParsingError as error:
|
|
98
|
+
language_server._parse_errors[uri] = _parse_error_to_diagnostic(text, error)
|
|
99
|
+
language_server._republish_diagnostics(uri)
|
|
100
|
+
|
|
101
|
+
language_server._debounce_tasks[uri] = asyncio.ensure_future(_apply_change())
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@server.feature(types.TEXT_DOCUMENT_DID_CLOSE)
|
|
105
|
+
def did_close(language_server: FragmentsServer, params: types.DidCloseTextDocumentParams) -> None:
|
|
106
|
+
uri = params.text_document.uri
|
|
107
|
+
language_server._files.pop(uri, None)
|
|
108
|
+
language_server._parse_errors.pop(uri, None)
|
|
109
|
+
language_server._pyright_diagnostics.pop(uri, None)
|
|
110
|
+
|
|
111
|
+
existing = language_server._debounce_tasks.pop(uri, None)
|
|
112
|
+
if existing:
|
|
113
|
+
existing.cancel()
|
|
114
|
+
|
|
115
|
+
if language_server._pyright:
|
|
116
|
+
language_server._pyright.notify("textDocument/didClose", {"textDocument": {"uri": uri}})
|
fragments/lsp/pyright.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
from collections.abc import Awaitable, Callable
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class PyrightClient:
|
|
10
|
+
def __init__(
|
|
11
|
+
self,
|
|
12
|
+
on_notification: Callable[[dict[str, Any]], None],
|
|
13
|
+
on_request: Callable[[dict[str, Any]], Awaitable[object]] | None = None,
|
|
14
|
+
) -> None:
|
|
15
|
+
self._on_notification = on_notification
|
|
16
|
+
self._on_request = on_request
|
|
17
|
+
self._proc: asyncio.subprocess.Process | None = None
|
|
18
|
+
self._pending: dict[int | str, asyncio.Future[dict[str, Any]]] = {}
|
|
19
|
+
self._next_id = 1
|
|
20
|
+
|
|
21
|
+
async def start(self) -> None:
|
|
22
|
+
bin_directory = os.path.dirname(sys.executable)
|
|
23
|
+
environment = dict(os.environ)
|
|
24
|
+
if sys.prefix != sys.base_prefix:
|
|
25
|
+
environment["VIRTUAL_ENV"] = sys.prefix
|
|
26
|
+
self._proc = await asyncio.create_subprocess_exec(
|
|
27
|
+
os.path.join(bin_directory, "basedpyright-langserver"),
|
|
28
|
+
"--stdio",
|
|
29
|
+
stdin=asyncio.subprocess.PIPE,
|
|
30
|
+
stdout=asyncio.subprocess.PIPE,
|
|
31
|
+
stderr=sys.stderr.buffer,
|
|
32
|
+
env=environment,
|
|
33
|
+
)
|
|
34
|
+
asyncio.create_task(self._read_loop())
|
|
35
|
+
|
|
36
|
+
async def _read(self) -> dict[str, Any] | None:
|
|
37
|
+
assert self._proc and self._proc.stdout
|
|
38
|
+
headers: dict[str, str] = {}
|
|
39
|
+
while True:
|
|
40
|
+
line = await self._proc.stdout.readline()
|
|
41
|
+
if not line:
|
|
42
|
+
return None
|
|
43
|
+
line = line.decode().rstrip("\r\n")
|
|
44
|
+
if not line:
|
|
45
|
+
break
|
|
46
|
+
key, _, value = line.partition(":")
|
|
47
|
+
headers[key.strip()] = value.strip()
|
|
48
|
+
length = headers.get("Content-Length")
|
|
49
|
+
if length is None:
|
|
50
|
+
return None
|
|
51
|
+
body = await self._proc.stdout.readexactly(int(length))
|
|
52
|
+
return json.loads(body)
|
|
53
|
+
|
|
54
|
+
async def _read_once(self) -> None:
|
|
55
|
+
message = await self._read()
|
|
56
|
+
if message is None:
|
|
57
|
+
return
|
|
58
|
+
if "id" in message and "method" not in message:
|
|
59
|
+
future = self._pending.pop(message["id"], None)
|
|
60
|
+
if future and not future.done():
|
|
61
|
+
future.set_result(message)
|
|
62
|
+
elif "id" in message and "method" in message:
|
|
63
|
+
_ = asyncio.ensure_future(self._handle_request(message))
|
|
64
|
+
else:
|
|
65
|
+
self._on_notification(message)
|
|
66
|
+
|
|
67
|
+
async def _read_loop(self) -> None:
|
|
68
|
+
try:
|
|
69
|
+
while True:
|
|
70
|
+
await self._read_once()
|
|
71
|
+
except Exception as e:
|
|
72
|
+
print(f"[pyright] {e}", file=sys.stderr, flush=True)
|
|
73
|
+
|
|
74
|
+
async def _handle_request(self, message: dict[str, Any]) -> None:
|
|
75
|
+
result = await self._on_request(message) if self._on_request else None
|
|
76
|
+
self._send({"jsonrpc": "2.0", "id": message["id"], "result": result})
|
|
77
|
+
|
|
78
|
+
async def request(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
|
|
79
|
+
assert self._proc and self._proc.stdin
|
|
80
|
+
message_id = self._next_id
|
|
81
|
+
self._next_id += 1
|
|
82
|
+
future: asyncio.Future[dict[str, Any]] = asyncio.get_event_loop().create_future()
|
|
83
|
+
self._pending[message_id] = future
|
|
84
|
+
self._send({"jsonrpc": "2.0", "id": message_id, "method": method, "params": params})
|
|
85
|
+
await self._proc.stdin.drain()
|
|
86
|
+
return await future
|
|
87
|
+
|
|
88
|
+
def notify(self, method: str, params: dict[str, Any]) -> None:
|
|
89
|
+
self._send({"jsonrpc": "2.0", "method": method, "params": params})
|
|
90
|
+
|
|
91
|
+
def _send(self, message: dict[str, Any]) -> None:
|
|
92
|
+
assert self._proc and self._proc.stdin
|
|
93
|
+
body = json.dumps(message, separators=(",", ":")).encode()
|
|
94
|
+
self._proc.stdin.write(f"Content-Length: {len(body)}\r\n\r\n".encode() + body)
|
fragments/lsp/rename.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from lsprotocol import types
|
|
4
|
+
|
|
5
|
+
from fragments.lsp.server import FragmentsServer, _converter, _remap_text_edits, server
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@server.feature(types.TEXT_DOCUMENT_PREPARE_RENAME)
|
|
9
|
+
async def prepare_rename(language_server: FragmentsServer, params: types.PrepareRenameParams) -> types.Range | None:
|
|
10
|
+
if language_server._pyright is None or params.text_document.uri not in language_server._files:
|
|
11
|
+
return None
|
|
12
|
+
state = language_server._files[params.text_document.uri]
|
|
13
|
+
|
|
14
|
+
if state is None:
|
|
15
|
+
result = await language_server._pyright.request(
|
|
16
|
+
"textDocument/prepareRename",
|
|
17
|
+
{
|
|
18
|
+
"textDocument": {"uri": params.text_document.uri},
|
|
19
|
+
"position": {"line": params.position.line, "character": params.position.character},
|
|
20
|
+
},
|
|
21
|
+
)
|
|
22
|
+
raw_result = result.get("result")
|
|
23
|
+
if not raw_result:
|
|
24
|
+
return None
|
|
25
|
+
range_dict = raw_result.get("range", raw_result) if isinstance(raw_result, dict) else raw_result
|
|
26
|
+
if not isinstance(range_dict, dict) or "start" not in range_dict:
|
|
27
|
+
return None
|
|
28
|
+
return _converter.structure(range_dict, types.Range)
|
|
29
|
+
|
|
30
|
+
transpiled_position = state.original_to_transpiled_position(params.position)
|
|
31
|
+
if transpiled_position is None:
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
result = await language_server._pyright.request(
|
|
35
|
+
"textDocument/prepareRename",
|
|
36
|
+
{
|
|
37
|
+
"textDocument": {"uri": params.text_document.uri},
|
|
38
|
+
"position": {"line": transpiled_position.line, "character": transpiled_position.character},
|
|
39
|
+
},
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
raw_result = result.get("result")
|
|
43
|
+
if not raw_result:
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
range_dict = raw_result.get("range", raw_result) if isinstance(raw_result, dict) else raw_result
|
|
47
|
+
if not isinstance(range_dict, dict) or "start" not in range_dict:
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
return state.transpiled_to_original_range(_converter.structure(range_dict, types.Range))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@server.feature(types.TEXT_DOCUMENT_RENAME)
|
|
54
|
+
async def rename(language_server: FragmentsServer, params: types.RenameParams) -> types.WorkspaceEdit | None:
|
|
55
|
+
if language_server._pyright is None or params.text_document.uri not in language_server._files:
|
|
56
|
+
return None
|
|
57
|
+
state = language_server._files[params.text_document.uri]
|
|
58
|
+
|
|
59
|
+
if state is None:
|
|
60
|
+
result = await language_server._pyright.request(
|
|
61
|
+
"textDocument/rename",
|
|
62
|
+
{
|
|
63
|
+
"textDocument": {"uri": params.text_document.uri},
|
|
64
|
+
"position": {"line": params.position.line, "character": params.position.character},
|
|
65
|
+
"newName": params.new_name,
|
|
66
|
+
},
|
|
67
|
+
)
|
|
68
|
+
else:
|
|
69
|
+
transpiled_position = state.original_to_transpiled_position(params.position)
|
|
70
|
+
if transpiled_position is None:
|
|
71
|
+
return None
|
|
72
|
+
result = await language_server._pyright.request(
|
|
73
|
+
"textDocument/rename",
|
|
74
|
+
{
|
|
75
|
+
"textDocument": {"uri": params.text_document.uri},
|
|
76
|
+
"position": {"line": transpiled_position.line, "character": transpiled_position.character},
|
|
77
|
+
"newName": params.new_name,
|
|
78
|
+
},
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
raw_result = result.get("result")
|
|
82
|
+
if not raw_result:
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
edit = _converter.structure(raw_result, types.WorkspaceEdit)
|
|
86
|
+
|
|
87
|
+
if edit.changes:
|
|
88
|
+
for uri in list(edit.changes):
|
|
89
|
+
file_state = language_server._files.get(uri)
|
|
90
|
+
if file_state is not None:
|
|
91
|
+
edit.changes[uri] = _remap_text_edits(edit.changes[uri], file_state)
|
|
92
|
+
|
|
93
|
+
if edit.document_changes:
|
|
94
|
+
for change in edit.document_changes:
|
|
95
|
+
if isinstance(change, types.TextDocumentEdit):
|
|
96
|
+
file_state = language_server._files.get(change.text_document.uri)
|
|
97
|
+
if file_state is not None:
|
|
98
|
+
remapped_edits: list[types.TextEdit | types.AnnotatedTextEdit] = []
|
|
99
|
+
for text_edit in change.edits:
|
|
100
|
+
remapped = file_state.transpiled_to_original_range(text_edit.range)
|
|
101
|
+
if remapped is not None:
|
|
102
|
+
text_edit.range = remapped
|
|
103
|
+
remapped_edits.append(text_edit)
|
|
104
|
+
change.edits = remapped_edits
|
|
105
|
+
|
|
106
|
+
return edit
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from lsprotocol import types
|
|
4
|
+
|
|
5
|
+
from fragments.lsp.server import FragmentsServer, _TOKEN_MODIFIERS, _TOKEN_TYPES, server
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@server.feature(
|
|
9
|
+
types.TEXT_DOCUMENT_SEMANTIC_TOKENS_FULL,
|
|
10
|
+
types.SemanticTokensLegend(token_types=_TOKEN_TYPES, token_modifiers=_TOKEN_MODIFIERS),
|
|
11
|
+
)
|
|
12
|
+
async def semantic_tokens_full(language_server: FragmentsServer, params: types.SemanticTokensParams) -> types.SemanticTokens:
|
|
13
|
+
if language_server._pyright is None or params.text_document.uri not in language_server._files:
|
|
14
|
+
return types.SemanticTokens(data=[])
|
|
15
|
+
state = language_server._files[params.text_document.uri]
|
|
16
|
+
|
|
17
|
+
result = await language_server._pyright.request(
|
|
18
|
+
"textDocument/semanticTokens/full",
|
|
19
|
+
{"textDocument": {"uri": params.text_document.uri}},
|
|
20
|
+
)
|
|
21
|
+
raw_data = (result.get("result") or {}).get("data") or []
|
|
22
|
+
|
|
23
|
+
if state is None:
|
|
24
|
+
return types.SemanticTokens(data=raw_data)
|
|
25
|
+
|
|
26
|
+
output: list[int] = []
|
|
27
|
+
transpiled_line = transpiled_character = previous_line = previous_character = 0
|
|
28
|
+
for i in range(0, len(raw_data), 5):
|
|
29
|
+
delta_line, delta_character, length, token_type, token_modifiers = raw_data[i : i + 5]
|
|
30
|
+
transpiled_line += delta_line
|
|
31
|
+
transpiled_character = delta_character if delta_line > 0 else transpiled_character + delta_character
|
|
32
|
+
if transpiled_line >= len(state.transpiled_line_starts):
|
|
33
|
+
continue
|
|
34
|
+
original_position = state.transpiled_to_original_position(types.Position(line=transpiled_line, character=transpiled_character))
|
|
35
|
+
if original_position is None:
|
|
36
|
+
continue
|
|
37
|
+
line, character = original_position.line, original_position.character
|
|
38
|
+
delta_line = line - previous_line
|
|
39
|
+
output.extend([delta_line, character if delta_line > 0 else character - previous_character, length, token_type, token_modifiers])
|
|
40
|
+
previous_line, previous_character = line, character
|
|
41
|
+
|
|
42
|
+
return types.SemanticTokens(data=output)
|
fragments/lsp/server.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import sys
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from lsprotocol import types
|
|
8
|
+
from lsprotocol.converters import get_converter
|
|
9
|
+
from pygls.server import LanguageServer
|
|
10
|
+
|
|
11
|
+
from fragments import grammar
|
|
12
|
+
from fragments.ast_nodes import ASTFragment
|
|
13
|
+
from fragments.lsp.file_state import _FileState, _build_line_starts, _offset_to_position
|
|
14
|
+
from fragments.lsp.pyright import PyrightClient
|
|
15
|
+
from fragments.source import Source
|
|
16
|
+
|
|
17
|
+
_converter = get_converter()
|
|
18
|
+
|
|
19
|
+
_TOKEN_TYPES = ["namespace", "type", "class", "enum", "typeParameter", "parameter", "variable", "property", "enumMember", "function", "method", "keyword", "decorator", "selfParameter", "clsParameter"]
|
|
20
|
+
_TOKEN_MODIFIERS = ["declaration", "definition", "readonly", "static", "async", "defaultLibrary", "builtin", "classMember", "parameter"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class FragmentsServer(LanguageServer):
|
|
24
|
+
def __init__(self) -> None:
|
|
25
|
+
super().__init__(
|
|
26
|
+
"fragments-lsp",
|
|
27
|
+
"v0.1",
|
|
28
|
+
text_document_sync_kind=types.TextDocumentSyncKind.Full,
|
|
29
|
+
)
|
|
30
|
+
self._pyright: PyrightClient | None = None
|
|
31
|
+
self._files: dict[str, _FileState | None] = {}
|
|
32
|
+
self._debounce_tasks: dict[str, asyncio.Task[None]] = {}
|
|
33
|
+
self._parse_errors: dict[str, types.Diagnostic | None] = {}
|
|
34
|
+
self._pyright_diagnostics: dict[str, list[types.Diagnostic]] = {}
|
|
35
|
+
|
|
36
|
+
def _on_pyright_notification(self, message: dict[str, Any]) -> None:
|
|
37
|
+
if message.get("method") == "textDocument/publishDiagnostics":
|
|
38
|
+
_ = asyncio.ensure_future(self._publish_diagnostics(message["params"]))
|
|
39
|
+
|
|
40
|
+
async def _on_pyright_request(self, message: dict[str, Any]) -> object:
|
|
41
|
+
if message["method"] == "workspace/configuration":
|
|
42
|
+
items = message["params"]["items"]
|
|
43
|
+
editor_configs = await self.get_configuration_async(
|
|
44
|
+
types.ConfigurationParams(items=[types.ConfigurationItem(scope_uri=item.get("scopeUri"), section=item.get("section", "")) for item in items])
|
|
45
|
+
)
|
|
46
|
+
result: list[dict[str, object] | None] = []
|
|
47
|
+
for item, config in zip(items, editor_configs):
|
|
48
|
+
if item.get("section") == "python":
|
|
49
|
+
config = {**(config or {}), "pythonPath": sys.executable, "defaultInterpreterPath": sys.executable}
|
|
50
|
+
result.append(config) # type: ignore[arg-type]
|
|
51
|
+
return result
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
async def _publish_diagnostics(self, params: dict[str, Any]) -> None:
|
|
55
|
+
uri = params["uri"]
|
|
56
|
+
state = self._files.get(uri)
|
|
57
|
+
remapped: list[types.Diagnostic] = []
|
|
58
|
+
|
|
59
|
+
for raw_diagnostic in params["diagnostics"]:
|
|
60
|
+
diagnostic = _converter.structure(raw_diagnostic, types.Diagnostic)
|
|
61
|
+
if state is not None:
|
|
62
|
+
remapped_range = state.transpiled_to_original_range(diagnostic.range)
|
|
63
|
+
if remapped_range is None:
|
|
64
|
+
continue
|
|
65
|
+
diagnostic = types.Diagnostic(
|
|
66
|
+
range=remapped_range,
|
|
67
|
+
message=diagnostic.message,
|
|
68
|
+
severity=diagnostic.severity,
|
|
69
|
+
code=diagnostic.code,
|
|
70
|
+
source=diagnostic.source,
|
|
71
|
+
)
|
|
72
|
+
remapped.append(diagnostic)
|
|
73
|
+
|
|
74
|
+
self._pyright_diagnostics[uri] = remapped
|
|
75
|
+
self._republish_diagnostics(uri)
|
|
76
|
+
|
|
77
|
+
def _republish_diagnostics(self, uri: str) -> None:
|
|
78
|
+
diagnostics = list(self._pyright_diagnostics.get(uri, []))
|
|
79
|
+
parse_error = self._parse_errors.get(uri)
|
|
80
|
+
if parse_error is not None:
|
|
81
|
+
diagnostics.append(parse_error)
|
|
82
|
+
self.publish_diagnostics(uri, diagnostics)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
server = FragmentsServer()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _remap_text_edits(text_edits: list[types.TextEdit], state: _FileState) -> list[types.TextEdit]:
|
|
89
|
+
result: list[types.TextEdit] = []
|
|
90
|
+
for edit in text_edits:
|
|
91
|
+
remapped = state.transpiled_to_original_range(edit.range)
|
|
92
|
+
if remapped is not None:
|
|
93
|
+
edit.range = remapped
|
|
94
|
+
result.append(edit)
|
|
95
|
+
return result
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _parse_error_to_diagnostic(text: str, error: grammar.ParsingError) -> types.Diagnostic:
|
|
99
|
+
line_starts = _build_line_starts(text)
|
|
100
|
+
position = _offset_to_position(line_starts, min(error.source_start, max(0, len(text) - 1)))
|
|
101
|
+
return types.Diagnostic(
|
|
102
|
+
range=types.Range(start=position, end=position),
|
|
103
|
+
message=str(error),
|
|
104
|
+
severity=types.DiagnosticSeverity.Error,
|
|
105
|
+
source="fragments",
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _build_file_state(text: str) -> tuple[_FileState | None, str]:
|
|
110
|
+
"""Parse text and return (state, content_for_pyright).
|
|
111
|
+
|
|
112
|
+
Returns (None, text) for pure Python files and (_FileState, transpiled) for fragment files.
|
|
113
|
+
"""
|
|
114
|
+
if "<>" not in text:
|
|
115
|
+
return None, text
|
|
116
|
+
_, module = grammar.expect_module(Source.from_string(text))
|
|
117
|
+
if not any(isinstance(child, ASTFragment) for child in module.children):
|
|
118
|
+
return None, text
|
|
119
|
+
module.transpile()
|
|
120
|
+
return _FileState(text, module.transpiled_content, module), module.transpiled_content
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def main() -> None:
|
|
124
|
+
from fragments.lsp import completion, definition, hover, lifecycle, rename, semantic_tokens # noqa: F401
|
|
125
|
+
server.start_io()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
if __name__ == "__main__":
|
|
129
|
+
main()
|
fragments/source.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
@dataclass(slots=True, frozen=True)
|
|
5
|
+
class Source:
|
|
6
|
+
content: str
|
|
7
|
+
offset: int
|
|
8
|
+
|
|
9
|
+
def remaining(self) -> str:
|
|
10
|
+
return self.content[self.offset :]
|
|
11
|
+
|
|
12
|
+
def eat(self, chars: int) -> "Source":
|
|
13
|
+
return Source(self.content, self.offset + chars)
|
|
14
|
+
|
|
15
|
+
def eat_whitespace(self) -> "tuple[Source, str]":
|
|
16
|
+
new_content = self.remaining().lstrip()
|
|
17
|
+
num_whitespace_chars = len(self.remaining()) - len(new_content)
|
|
18
|
+
whitespace_chars = self.remaining()[:num_whitespace_chars]
|
|
19
|
+
return self.eat(num_whitespace_chars), whitespace_chars
|
|
20
|
+
|
|
21
|
+
def at_end(self) -> bool:
|
|
22
|
+
return self.offset == len(self.content)
|
|
23
|
+
|
|
24
|
+
@classmethod
|
|
25
|
+
def from_string(cls, string: str) -> "Source":
|
|
26
|
+
return Source(string, 0)
|
fragments/transpiler.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from fragments import grammar
|
|
2
|
+
from fragments.source import Source
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def transpile(source_string: str) -> str:
|
|
6
|
+
"""Python code up to a fragment."""
|
|
7
|
+
source: Source = Source.from_string(source_string)
|
|
8
|
+
source, module = grammar.expect_module(source)
|
|
9
|
+
module.transpile()
|
|
10
|
+
return module.transpiled_content
|