Skip to content

Commit fb19103

Browse files
committed
WIP LSP based on Ruff
1 parent 10d9a6c commit fb19103

File tree

6 files changed

+347
-1
lines changed

6 files changed

+347
-1
lines changed

.vscode/launch.json

+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
// A launch configuration that launches the extension inside a new window
2+
// Use IntelliSense to learn about possible attributes.
3+
// Hover to view descriptions of existing attributes.
4+
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
5+
{
6+
"version": "0.2.0",
7+
"configurations": [
8+
{
9+
"name": "Extension",
10+
"type": "extensionHost",
11+
"request": "launch",
12+
"args": [
13+
"--extensionDevelopmentPath=${workspaceFolder}"
14+
]
15+
}
16+
]
17+
}

.vscode/settings.json

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"languageServerExample.trace.server": "verbose"
3+
}

pyjsx/server/__init__.py

Whitespace-only changes.

pyjsx/server/server.py

+271
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
from __future__ import annotations
2+
3+
import enum
4+
import json
5+
import logging
6+
import os
7+
from dataclasses import dataclass
8+
9+
from lsprotocol.types import (
10+
INITIALIZE,
11+
TEXT_DOCUMENT_DID_CLOSE,
12+
TEXT_DOCUMENT_DID_OPEN,
13+
DidCloseTextDocumentParams,
14+
DidOpenTextDocumentParams,
15+
InitializeParams,
16+
MessageType,
17+
PositionEncodingKind,
18+
Range,
19+
)
20+
from pygls import server, workspace
21+
from pygls.workspace.position_codec import PositionCodec
22+
from typing_extensions import Self
23+
24+
25+
logger = logging.getLogger(__name__)
26+
27+
28+
MAX_WORKERS = 1
29+
LSP_SERVER = server.LanguageServer(
30+
name="PyJSX",
31+
version="0.0.1",
32+
max_workers=MAX_WORKERS,
33+
)
34+
35+
36+
###
37+
# Document
38+
###
39+
40+
41+
@enum.unique
42+
class DocumentKind(enum.Enum):
43+
"""The kind of document."""
44+
45+
Text = enum.auto()
46+
"""A Python file."""
47+
48+
Notebook = enum.auto()
49+
"""A Notebook Document."""
50+
51+
Cell = enum.auto()
52+
"""A cell in a Notebook Document."""
53+
54+
55+
@dataclass(frozen=True)
56+
class Document:
57+
"""A document representing either a Python file, a Notebook cell, or a Notebook."""
58+
59+
uri: str
60+
path: str
61+
source: str
62+
kind: DocumentKind
63+
version: int | None
64+
65+
@classmethod
66+
def from_text_document(cls, text_document: workspace.TextDocument) -> Self:
67+
"""Create a `Document` from the given Text Document."""
68+
return cls(
69+
uri=text_document.uri,
70+
path=text_document.path,
71+
kind=DocumentKind.Text,
72+
source=text_document.source,
73+
version=text_document.version,
74+
)
75+
76+
@classmethod
77+
def from_cell_or_text_uri(cls, uri: str) -> Self:
78+
"""Create a `Document` representing either a Python file or a Notebook cell from
79+
the given URI.
80+
81+
The function will try to get the Notebook cell first, and if there's no cell
82+
with the given URI, it will fallback to the text document.
83+
"""
84+
notebook_document = LSP_SERVER.workspace.get_notebook_document(cell_uri=uri)
85+
if notebook_document is not None:
86+
notebook_cell = next(
87+
(notebook_cell for notebook_cell in notebook_document.cells if notebook_cell.document == uri),
88+
None,
89+
)
90+
if notebook_cell is not None:
91+
return cls.from_notebook_cell(notebook_cell)
92+
93+
# Fall back to the Text Document representing a Python file.
94+
text_document = LSP_SERVER.workspace.get_text_document(uri)
95+
return cls.from_text_document(text_document)
96+
97+
@classmethod
98+
def from_uri(cls, uri: str) -> Self:
99+
"""Create a `Document` representing either a Python file or a Notebook from
100+
the given URI.
101+
102+
The URI can be a file URI, a notebook URI, or a cell URI. The function will
103+
try to get the notebook document first, and if there's no notebook document
104+
with the given URI, it will fallback to the text document.
105+
"""
106+
# First, try to get the Notebook Document assuming the URI is a Cell URI.
107+
notebook_document = LSP_SERVER.workspace.get_notebook_document(cell_uri=uri)
108+
if notebook_document is None:
109+
# If that fails, try to get the Notebook Document assuming the URI is a
110+
# Notebook URI.
111+
notebook_document = LSP_SERVER.workspace.get_notebook_document(notebook_uri=uri)
112+
if notebook_document:
113+
return cls.from_notebook_document(notebook_document)
114+
115+
# Fall back to the Text Document representing a Python file.
116+
text_document = LSP_SERVER.workspace.get_text_document(uri)
117+
return cls.from_text_document(text_document)
118+
119+
def is_stdlib_file(self) -> bool:
120+
"""Return True if the document belongs to standard library."""
121+
return utils.is_stdlib_file(self.path)
122+
123+
124+
###
125+
# Linting.
126+
###
127+
128+
129+
@LSP_SERVER.feature(TEXT_DOCUMENT_DID_OPEN)
130+
async def did_open(params: DidOpenTextDocumentParams) -> None:
131+
"""LSP handler for textDocument/didOpen request."""
132+
document = Document.from_text_document(LSP_SERVER.workspace.get_text_document(params.text_document.uri))
133+
# diagnostics = await _lint_document_impl(document, settings)
134+
LSP_SERVER.publish_diagnostics(document.uri, [])
135+
136+
137+
@LSP_SERVER.feature(TEXT_DOCUMENT_DID_CLOSE)
138+
def did_close(params: DidCloseTextDocumentParams) -> None:
139+
"""LSP handler for textDocument/didClose request."""
140+
text_document = LSP_SERVER.workspace.get_text_document(params.text_document.uri)
141+
# Publishing empty diagnostics to clear the entries for this file.
142+
LSP_SERVER.publish_diagnostics(text_document.uri, [])
143+
144+
145+
###
146+
# Lifecycle.
147+
###
148+
149+
150+
@LSP_SERVER.feature(INITIALIZE)
151+
def initialize(params: InitializeParams) -> None:
152+
"""LSP handler for initialize request."""
153+
# Extract client capabilities.
154+
# CLIENT_CAPABILITIES[CODE_ACTION_RESOLVE] = _supports_code_action_resolve(params.capabilities)
155+
156+
# Extract `settings` from the initialization options.
157+
workspace_settings: list[WorkspaceSettings] | WorkspaceSettings | None = (params.initialization_options or {}).get(
158+
"settings",
159+
)
160+
global_settings: UserSettings | None = (params.initialization_options or {}).get("globalSettings", {})
161+
162+
log_to_output(f"Workspace settings: " f"{json.dumps(workspace_settings, indent=4, ensure_ascii=False)}")
163+
log_to_output(f"Global settings: " f"{json.dumps(global_settings, indent=4, ensure_ascii=False)}")
164+
165+
# Preserve any "global" settings.
166+
if global_settings:
167+
GLOBAL_SETTINGS.update(global_settings)
168+
elif isinstance(workspace_settings, dict):
169+
# In Sublime Text, Neovim, and probably others, we're passed a single
170+
# `settings`, which we'll treat as defaults for any future files.
171+
GLOBAL_SETTINGS.update(workspace_settings)
172+
173+
# Update workspace settings.
174+
settings: list[WorkspaceSettings]
175+
if isinstance(workspace_settings, dict):
176+
settings = [workspace_settings]
177+
elif isinstance(workspace_settings, list):
178+
# In VS Code, we're passed a list of `settings`, one for each workspace folder.
179+
settings = workspace_settings
180+
else:
181+
settings = []
182+
183+
# _update_workspace_settings(settings)
184+
185+
186+
async def _run_format_on_document(
187+
document: Document, settings: WorkspaceSettings, format_range: Range | None = None
188+
) -> ExecutableResult | None:
189+
"""Runs the Ruff `format` subcommand on the given document source."""
190+
if settings.get("ignoreStandardLibrary", True) and document.is_stdlib_file():
191+
log_warning(f"Skipping standard library file: {document.path}")
192+
return None
193+
194+
version_requirement = (
195+
VERSION_REQUIREMENT_FORMATTER if format_range is None else VERSION_REQUIREMENT_RANGE_FORMATTING
196+
)
197+
executable = _find_ruff_binary(settings, version_requirement)
198+
argv: list[str] = [
199+
"format",
200+
"--force-exclude",
201+
"--quiet",
202+
"--stdin-filename",
203+
document.path,
204+
]
205+
206+
if format_range:
207+
codec = PositionCodec(PositionEncodingKind.Utf16)
208+
format_range = codec.range_from_client_units(document.source.splitlines(True), format_range)
209+
210+
argv.extend(
211+
[
212+
"--range",
213+
f"{format_range.start.line + 1}:{format_range.start.character + 1}-{format_range.end.line + 1}:{format_range.end.character + 1}", # noqa: E501
214+
]
215+
)
216+
217+
for arg in settings.get("format", {}).get("args", []):
218+
if arg in UNSUPPORTED_FORMAT_ARGS:
219+
log_to_output(f"Ignoring unsupported argument: {arg}")
220+
else:
221+
argv.append(arg)
222+
223+
return ExecutableResult(
224+
executable,
225+
*await run_path(
226+
executable.path,
227+
argv,
228+
cwd=settings["cwd"],
229+
source=document.source,
230+
),
231+
)
232+
233+
234+
###
235+
# Logging.
236+
###
237+
238+
239+
def log_to_output(message: str) -> None:
240+
LSP_SERVER.show_message_log(message, MessageType.Log)
241+
242+
243+
def show_error(message: str) -> None:
244+
"""Show a pop-up with an error. Only use for critical errors."""
245+
LSP_SERVER.show_message_log(message, MessageType.Error)
246+
LSP_SERVER.show_message(message, MessageType.Error)
247+
248+
249+
def log_warning(message: str) -> None:
250+
LSP_SERVER.show_message_log(message, MessageType.Warning)
251+
if os.getenv("LS_SHOW_NOTIFICATION", "off") in ["onWarning", "always"]:
252+
LSP_SERVER.show_message(message, MessageType.Warning)
253+
254+
255+
def log_always(message: str) -> None:
256+
LSP_SERVER.show_message_log(message, MessageType.Info)
257+
if os.getenv("LS_SHOW_NOTIFICATION", "off") in ["always"]:
258+
LSP_SERVER.show_message(message, MessageType.Info)
259+
260+
261+
###
262+
# Start up.
263+
###
264+
265+
266+
def start() -> None:
267+
LSP_SERVER.start_io()
268+
269+
270+
if __name__ == "__main__":
271+
start()

pyproject.toml

+2
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ classifiers = [
1414
]
1515
dependencies = [
1616
"click>=8.1.8",
17+
"lsprotocol>=2023.0.1",
18+
"pygls>=1.3.1",
1719
]
1820
dynamic = ["version"]
1921

0 commit comments

Comments
 (0)