diff --git a/lib/lsp-devtools/changes/234.feature.md b/lib/lsp-devtools/changes/234.feature.md new file mode 100644 index 0000000..e18f97a --- /dev/null +++ b/lib/lsp-devtools/changes/234.feature.md @@ -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. diff --git a/lib/lsp-devtools/lsp_devtools/cli/client.py b/lib/lsp-devtools/lsp_devtools/cli/client/__init__.py similarity index 51% rename from lib/lsp-devtools/lsp_devtools/cli/client.py rename to lib/lsp-devtools/lsp_devtools/cli/client/__init__.py index ee8c1ac..44a4e60 100644 --- a/lib/lsp-devtools/lsp_devtools/cli/client.py +++ b/lib/lsp-devtools/lsp_devtools/cli/client/__init__.py @@ -2,6 +2,7 @@ import argparse import importlib.metadata +import logging import os import typing @@ -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.""" @@ -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%; @@ -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() @@ -84,20 +91,33 @@ 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: @@ -105,10 +125,10 @@ def action_toggle_devtools(self) -> None: 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: @@ -116,46 +136,74 @@ def action_toggle_panel(self) -> None: 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(), @@ -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() diff --git a/lib/lsp-devtools/lsp_devtools/client/lsp_client.py b/lib/lsp-devtools/lsp_devtools/cli/client/client.py similarity index 60% rename from lib/lsp-devtools/lsp_devtools/client/lsp_client.py rename to lib/lsp-devtools/lsp_devtools/cli/client/client.py index 56ee55c..a74c3c4 100644 --- a/lib/lsp-devtools/lsp_devtools/client/lsp_client.py +++ b/lib/lsp-devtools/lsp_devtools/cli/client/client.py @@ -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 @@ -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.""" @@ -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. @@ -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, @@ -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 @@ -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")) diff --git a/lib/lsp-devtools/lsp_devtools/cli/client/config.py b/lib/lsp-devtools/lsp_devtools/cli/client/config.py new file mode 100644 index 0000000..b54a524 --- /dev/null +++ b/lib/lsp-devtools/lsp_devtools/cli/client/config.py @@ -0,0 +1,129 @@ +from __future__ import annotations + +import shlex +import typing + +import attrs +from textual.containers import Grid +from textual.containers import Horizontal +from textual.screen import ModalScreen +from textual.widgets import Button +from textual.widgets import Input +from textual.widgets import Label +from textual.widgets import TabbedContent +from textual.widgets import TabPane + +if typing.TYPE_CHECKING: + from textual.app import ComposeResult + + +@attrs.define +class ServerConfig: + """Configuration for the server process.""" + + command: list[str] = attrs.field(factory=list) + + +@attrs.define +class AppConfig: + """Represents the configuration for the whole application.""" + + server: ServerConfig = attrs.field(factory=ServerConfig) + + +class ConfigurationScreen(ModalScreen[AppConfig | None]): + """A a screen used for configuration settings.""" + + DEFAULT_CSS = """ + ConfigurationScreen { + align: center middle; + } + + #config-content { + width: 80%; + height: auto; + max-height: 80%; + + background: $panel; + border: round $primary; + + padding: 2; + grid-size: 1; + grid-gutter: 1; + grid-rows: auto 1fr; + } + + #config-actions { + align: right bottom; + column-span: 2; + } + """ + + def __init__(self, *args, config: AppConfig, **kwargs): + super().__init__(*args, **kwargs) + self.config = config + + def compose(self) -> ComposeResult: + with Grid(id="config-content") as content: + content.border_title = "Configuration Settings" + + with TabbedContent(): + yield ServerConfigForm(title="Server", config=self.config.server) + yield ClientConfigForm(title="Client") + + with Horizontal(id="config-actions"): + yield Button("Cancel", flat=True, id="cancel") + yield Button("Save", id="save", flat=True, variant="success") + + def on_button_pressed(self, event: Button.Pressed): + if event.button.id == "save": + server_form = self.query_one(ServerConfigForm) + config = AppConfig(server=server_form.get_config()) + _ = self.dismiss(config) + + return + + _ = self.dismiss(None) + + +class ServerConfigForm(TabPane): + DEFAULT_CSS = """ + #server-settings { + width: 100%; + height: auto; + + padding: 2; + grid-size: 2; + grid-gutter: 1; + grid-columns: auto 1fr; + } + """ + + def __init__(self, *args, config: ServerConfig, **kwargs): + super().__init__(*args, **kwargs) + self.config = config + + def get_config(self) -> ServerConfig: + """Return the configuration as defined by the form""" + command_field = self.query_one("#server-command", Input) + command = shlex.split(command_field.value) + + return ServerConfig(command=command) + + def set_config(self, config: ServerConfig): + """Set the form according to the given config.""" + command_field = self.query_one("#server-command#", Input) + command_field.value = " ".join(config.command) + + def compose(self) -> ComposeResult: + with Grid(id="server-settings"): + yield Label("Command") + + command = None + if len(self.config.command) > 0: + command = " ".join(self.config.command) + + yield Input(id="server-command", value=command) + + +class ClientConfigForm(TabPane): ... diff --git a/lib/lsp-devtools/lsp_devtools/client/__init__.py b/lib/lsp-devtools/lsp_devtools/client/__init__.py deleted file mode 100644 index 4571234..0000000 --- a/lib/lsp-devtools/lsp_devtools/client/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .lsp_client import LanguageClient - -__all__ = ("LanguageClient",) diff --git a/lib/lsp-devtools/lsp_devtools/editor/panel.py b/lib/lsp-devtools/lsp_devtools/editor/panel.py index e1e648c..520c022 100644 --- a/lib/lsp-devtools/lsp_devtools/editor/panel.py +++ b/lib/lsp-devtools/lsp_devtools/editor/panel.py @@ -1,7 +1,10 @@ from __future__ import annotations +import logging +import typing + from textual.containers import Container -from textual.widgets import Log +from textual.widgets import RichLog from textual.widgets import TabbedContent from textual.widgets import TabPane @@ -11,14 +14,42 @@ class Panel(Container): def compose(self): with TabbedContent(): - yield OutputWindow(id="stderr-window", title="Stderr") - yield OutputWindow(id="log-window", title="Log") + yield OutputWindow(id="stderr-window", title="Server") + yield OutputWindow(id="log-window", title="window/logMessage") + + +@typing.final +class OutputWindowHandler(logging.Handler): + """A logging handler that writes into an output window.""" + + def __init__(self, output: OutputWindow, *args, **kwargs): + super().__init__(*args, **kwargs) + self.output = output + + def emit(self, record: logging.LogRecord) -> None: + message = self.format(record) + if record.levelno == logging.DEBUG: + message = f"[dim]{message}[/dim]" + elif record.levelno == logging.ERROR: + message = f"[bold red]{message}[/bold red]" + return self.output.write(message) + + +@typing.final class OutputWindow(TabPane): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.log_handler = OutputWindowHandler(self) + def compose(self): - yield Log() + yield RichLog(markup=True) + + def clear(self): + log = self.query_one(RichLog) + log.clear() - def write(self, data: bytes): - log = self.query_one(Log) - log.write(data.decode("utf8")) + def write(self, text: str): + log = self.query_one(RichLog) + _ = log.write(text) diff --git a/lib/lsp-devtools/lsp_devtools/inspector/__init__.py b/lib/lsp-devtools/lsp_devtools/inspector/__init__.py index e69de29..fb67d1b 100644 --- a/lib/lsp-devtools/lsp_devtools/inspector/__init__.py +++ b/lib/lsp-devtools/lsp_devtools/inspector/__init__.py @@ -0,0 +1,3 @@ +from .message_browser import MessageBrowser + +__all__ = ("MessageBrowser",) diff --git a/lib/lsp-devtools/pyproject.toml b/lib/lsp-devtools/pyproject.toml index ea216ef..86b582a 100644 --- a/lib/lsp-devtools/pyproject.toml +++ b/lib/lsp-devtools/pyproject.toml @@ -30,6 +30,9 @@ dependencies = [ "textual>=8.1.0", ] +[dependency-groups] +dev = ["textual-dev"] + [project.urls] "Bug Tracker" = "https://github.com/swyddfa/lsp-devtools/issues" "Documentation" = "https://lsp-devtools.readthedocs.io/en/latest/"