Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions lib/lsp-devtools/changes/234.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
The server command used with the `lsp-devtools client` command can now be configured at runtime.

Server process lifecycle information and errors are now also logged to the `Server` panel.
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import argparse
import importlib.metadata
import logging
import os
import typing

Expand All @@ -10,28 +11,28 @@
from textual import events
from textual import on
from textual.app import App
from textual.containers import Vertical
from textual.message import Message
from textual.widgets import Button
from textual.widgets import Footer

from lsp_devtools.cli.utils import LiveSqlHandler
from lsp_devtools.client import LanguageClient
from lsp_devtools.editor import Explorer
from lsp_devtools.editor import OutputWindow
from lsp_devtools.editor import Panel
from lsp_devtools.editor import TextEditorView
from lsp_devtools.inspector.message_browser import MessageBrowser
from lsp_devtools.inspector import MessageBrowser

from .client import ClientState
from .client import LanguageClient
from .config import AppConfig
from .config import ConfigurationScreen

if typing.TYPE_CHECKING:
from textual.app import ComposeResult
from textual.widgets import DirectoryTree


class StderrReceived(Message):
def __init__(self, data: bytes):
super().__init__()
self.data = data


@typing.final
class LSPClient(App[None]):
"""A simple LSP client."""
Expand All @@ -41,11 +42,16 @@ class LSPClient(App[None]):
display: none;
}

Explorer {
#left-sidebar {
dock: left;
width: 15%;
}

#open-settings-btn {
margin: 1;
width: 100%;
}

MessageBrowser {
dock: right;
width: 30%;
Expand All @@ -64,14 +70,15 @@ class LSPClient(App[None]):
BINDINGS = [
("ctrl+c", "quit"),
("f2", "toggle_explorer", "Explorer"),
("f5", "run_server", "Run Server"),
("f8", "toggle_panel", "Panel"),
("f12", "toggle_devtools", "Devtools"),
]

def __init__(self, *args, server_command: list[str], **kwargs):
def __init__(self, *args, config: AppConfig, **kwargs):
super().__init__(*args, **kwargs)

self.server_command = server_command
self.config: AppConfig = config
self.client: LanguageClient | None = None

self.db = LiveSqlHandler()
Expand All @@ -84,78 +91,119 @@ def compose(self) -> ComposeResult:

# Sidebars
yield MessageBrowser()
yield Explorer()

with Vertical(id="left-sidebar"):
yield Explorer()
yield Button("Settings", id="open-settings-btn")

# Footer
yield Footer()

def action_open_settings(self):
def maybe_update_config(new_config: AppConfig | None):
if new_config is not None:
# TODO: Restart server as required.
self.config = new_config

_ = self.push_screen(
ConfigurationScreen(config=self.config), maybe_update_config
)

def action_toggle_explorer(self) -> None:
explorer = self.query_one(Explorer)
explorer = self.query_one("#left-sidebar")
is_visible = not explorer.has_class("hidden")

if is_visible:
explorer.add_class("hidden")
_ = explorer.add_class("hidden")

else:
explorer.remove_class("hidden")
_ = explorer.remove_class("hidden")
self.screen.set_focus(explorer)

def action_toggle_devtools(self) -> None:
devtools = self.query_one(MessageBrowser)
is_visible = not devtools.has_class("hidden")

if is_visible:
devtools.add_class("hidden")
_ = devtools.add_class("hidden")

else:
devtools.remove_class("hidden")
_ = devtools.remove_class("hidden")
self.screen.set_focus(devtools)

def action_toggle_panel(self) -> None:
panel = self.query_one(Panel)
is_visible = not panel.has_class("hidden")

if is_visible:
panel.add_class("hidden")
_ = panel.add_class("hidden")

else:
panel.remove_class("hidden")
_ = panel.remove_class("hidden")
self.screen.set_focus(panel)

def on_ready(self, event: events.Ready):
self.run_worker(self.start_server(), name="lsp-connection")
async def action_run_server(self):
_ = self.run_worker(self.run_server())

async def on_ready(self, event: events.Ready):
# Auto start server if possible.
if len(self.config.server.command) > 0:
await self.action_run_server()

@on(LiveSqlHandler.MessageReceived)
def on_message_received(self, event: LiveSqlHandler.MessageReceived):
browser = self.query_one(MessageBrowser)
browser.reload(follow=True)

@on(StderrReceived)
def on_stderr_received(self, event: StderrReceived):
panel = self.query_one(Panel)
log = panel.query_one("#stderr-window", OutputWindow)
log.write(event.data)
def on_button_pressed(self, event: Button.Pressed):
if event.button.id == "open-settings-btn":
self.action_open_settings()

def on_directory_tree_file_selected(self, event: DirectoryTree.FileSelected):
"""Handle file-open."""
editor = self.query_one(TextEditorView)
editor.open_text_document(event.path)

async def start_server(self):
async def run_server(self):
"""Start, or restart the server."""
if self.client is None:
self.client = await self.start_server()
return

# Don't interfere with a client that is starting up.
if self.client.state in {ClientState.Starting}:
return

if self.client.state in {ClientState.Running}:
await self.stop_server()

self.client = await self.start_server()

async def start_server(self) -> LanguageClient | None:
"""Start the server and connect to it."""

def stderr_handler(data: bytes):
self.app.post_message(StderrReceived(data))
server_config = self.config.server
if len(server_config.command) == 0:
# TODO: Prompt user to set a command.
return

self.client = LanguageClient(
output_window = self.query_one("#stderr-window", OutputWindow)
output_window.clear()

server_logger = logging.getLogger("server")
server_logger.setLevel(logging.DEBUG)
server_logger.addHandler(output_window.log_handler)

client = LanguageClient(
self.db,
stderr_handler=stderr_handler,
logger=server_logger,
name="lsp-devtools",
version=importlib.metadata.version("lsp-devtools"),
)
await self.client.start_io(*self.server_command)

result = await self.client.initialize_async(
await client.start_io(*server_config.command)

result = await client.initialize_async(
types.InitializeParams(
capabilities=types.ClientCapabilities(),
process_id=os.getpid(),
Expand All @@ -167,16 +215,28 @@ def stderr_handler(data: bytes):
],
)
)
self.client.initialized(types.InitializedParams())
client.initialized(types.InitializedParams())
return client

async def stop_server(self):
if self.client is None or self.client.state not in {ClientState.Running}:
return

await self.client.shutdown_async(None)
self.client.exit(None)

await self.client.stop()


def client(args, extra: list[str]):
if len(extra) == 0:
raise ValueError(
"Missing server command. (e.g. lsp-devtools client -- server-cmd --stdio)"
)
# TODO: Read configs from file.
config = AppConfig()

# Allow for a server command to be passed on the cli.
if len(extra) > 0:
config.server.command = extra

app = LSPClient(server_command=extra)
app = LSPClient(config=config)
app.run()


Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
from __future__ import annotations

import asyncio
import inspect
import enum
import logging
import typing

from pygls.lsp.client import LanguageClient as BaseLanguageClient
Expand All @@ -20,6 +21,25 @@
StderrHandler = Callable[[bytes], None | Awaitable[None]]


class ClientState(enum.Enum):
"""The set of possible states the server may be in."""

Starting = enum.auto()
"""The server is starting."""

Restarting = enum.auto()
"""The server is restarting."""

Running = enum.auto()
"""The server is running normally."""

Errored = enum.auto()
"""The server has enountered some unrecoverable error and should not be used."""

Exited = enum.auto()
"""The server is no longer running."""


class MessageWriter:
"""A writer compatible with pygls' AsyncWriter interface."""

Expand Down Expand Up @@ -59,13 +79,14 @@ class LanguageClient(BaseLanguageClient):
def __init__(
self,
message_handler: JsonRPCHandler,
stderr_handler: StderrHandler | None = None,
logger: logging.Logger | None = None,
*args,
**kwargs,
):
super().__init__(*args, **kwargs)
self.logger = logger or logging.getLogger(__name__)
self.state: ClientState | None = None
self.message_handler: JsonRPCHandler = message_handler
self.stderr_handler: StderrHandler | None = stderr_handler

async def start_io(self, cmd: str, *args, **kwargs):
"""Start the given server and communicate with it over stdio.
Expand All @@ -74,6 +95,16 @@ async def start_io(self, cmd: str, *args, **kwargs):
through the given JsonRPCHandler.
"""

try:
await self._start_io(cmd, *args, **kwargs)
self.state = ClientState.Running
except Exception as exc:
self.logger.error("Unable to start server: %s", exc)
self.state = ClientState.Errored

async def _start_io(self, cmd: str, *args, **kwargs):
self.state = ClientState.Starting

server = await asyncio.create_subprocess_exec(
cmd,
*args,
Expand All @@ -95,23 +126,29 @@ async def start_io(self, cmd: str, *args, **kwargs):
if server.stderr is None:
raise RuntimeError("Server process is missing a stderr stream")

self.logger.debug("Started server [%s]: %s %s", server.pid, cmd, " ".join(args))
self.protocol.set_writer(MessageWriter(self.message_handler, server.stdin))
connection = asyncio.create_task(
self.connect_streams(
source=server.stdout,
dest=MessageHandler(self.message_handler, self.protocol),
origin=MessageSource.SERVER,

self._async_tasks.append(
asyncio.create_task(
self.connect_streams(
source=server.stdout,
dest=MessageHandler(self.message_handler, self.protocol),
origin=MessageSource.SERVER,
),
name="server-connection",
)
)
self._async_tasks.append(
asyncio.create_task(self._server_exit(), name="exit-notifier"),
)
self._async_tasks.append(
asyncio.create_task(
forward_stderr(server.stderr, self.logger), name="stderr-stream"
)
)
notify_exit = asyncio.create_task(self._server_exit())

self._server = server
self._async_tasks.extend([connection, notify_exit])

if self.stderr_handler is not None:
self._async_tasks.append(
asyncio.create_task(forward_stderr(server.stderr, self.stderr_handler))
)

async def connect_streams(
self, source: asyncio.StreamReader, dest: MessageHandler, origin: MessageSource
Expand All @@ -122,10 +159,18 @@ async def connect_streams(
while (data := await source.read(1024)) != b"":
dest.feed(data, origin)

async def server_exit(self, server: asyncio.subprocess.Process):
"""Called when the server process exits."""
if server.returncode != 0:
self.logger.error("Server process exited with code: %s", server.returncode)
self.state = ClientState.Errored
else:
self.logger.debug("Server process exited with code: %s", server.returncode)
self.state = ClientState.Exited


async def forward_stderr(stderr: asyncio.StreamReader, handler: StderrHandler):
async def forward_stderr(stderr: asyncio.StreamReader, logger: logging.Logger):
"""Forward stderr output onto the given handler."""

while (data := await stderr.readline()) != b"":
if inspect.isawaitable(res := handler(data)):
await res
logger.info(data.decode("utf-8"))
Loading