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.
@@ -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}})
@@ -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)
@@ -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)
@@ -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)
@@ -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