Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Proof of concept language client #83

Merged
merged 6 commits into from
Oct 6, 2023
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: 2 additions & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,9 @@ repos:
- aiosqlite
- attrs
- importlib-resources
- platformdirs
- pygls
- stamina
- textual
- types-appdirs
- websockets
files: 'lib/lsp-devtools/lsp_devtools/.*\.py'
1 change: 1 addition & 0 deletions lib/lsp-devtools/changes/83.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
**Experimental** Add proof of concept ``lsp-devtools client`` command that builds on textual's ``TextArea`` widget to offer an interactive language server client.
3 changes: 3 additions & 0 deletions lib/lsp-devtools/changes/83.misc.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
The ``lsp-devtools capabilities`` command has been removed in favour of ``lsp-devtools record``

The ``lsp-devtools tui`` command has been renamed to ``lsp-devtools inspect``
2 changes: 1 addition & 1 deletion lib/lsp-devtools/lsp_devtools/agent/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ async def start_tcp(self, host: str, port: int):
with attempt:
reader, writer = await asyncio.open_connection(host, port)

self.protocol.connection_made(writer)
self.protocol.connection_made(writer) # type: ignore[arg-type]
connection = asyncio.create_task(
aio_readline(self._stop_event, reader, self.protocol.data_received)
)
Expand Down
6 changes: 5 additions & 1 deletion lib/lsp-devtools/lsp_devtools/agent/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

from lsp_devtools.agent.protocol import AgentProtocol
from lsp_devtools.agent.protocol import MessageText
from lsp_devtools.database import Database


class AgentServer(Server):
Expand All @@ -27,6 +28,9 @@ def __init__(self, *args, **kwargs):
kwargs["converter_factory"] = default_converter

super().__init__(*args, **kwargs)

self.db: Optional[Database] = None

self._client_buffer = []
self._server_buffer = []
self._stop_event = threading.Event()
Expand All @@ -35,7 +39,7 @@ def __init__(self, *args, **kwargs):
def feature(self, feature_name: str, options: Optional[Any] = None):
return self.lsp.fm.feature(feature_name, options)

async def start_tcp(self, host: str, port: int) -> None:
async def start_tcp(self, host: str, port: int) -> None: # type: ignore[override]
async def handle_client(reader, writer):
self.lsp.connection_made(writer)
await aio_readline(self._stop_event, reader, self.lsp.data_received)
Expand Down
6 changes: 3 additions & 3 deletions lib/lsp-devtools/lsp_devtools/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@

BUILTIN_COMMANDS = [
"lsp_devtools.agent",
"lsp_devtools.cmds.capabilities", # TODO: Remove in favour of record + cli args
"lsp_devtools.client",
"lsp_devtools.inspector",
"lsp_devtools.record",
"lsp_devtools.tui",
]


Expand All @@ -37,7 +37,7 @@ def load_command(commands: argparse._SubParsersAction, name: str):

def main():
cli = argparse.ArgumentParser(
prog="lsp-devtools", description="Development tooling for language servers"
prog="lsp-devtools", description="Developer tooling for language servers"
)
cli.add_argument("--version", action="version", version=f"%(prog)s v{__version__}")
commands = cli.add_subparsers(title="commands")
Expand Down
175 changes: 175 additions & 0 deletions lib/lsp-devtools/lsp_devtools/client/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import argparse
import asyncio
import logging
import os
import pathlib
from typing import List
from uuid import uuid4

import platformdirs
from lsprotocol import types
from pygls import uris as uri
from textual import events
from textual import on
from textual.app import App
from textual.app import ComposeResult
from textual.containers import ScrollableContainer
from textual.containers import Vertical
from textual.widgets import DirectoryTree
from textual.widgets import Footer
from textual.widgets import Header

from lsp_devtools.agent import logger
from lsp_devtools.database import Database
from lsp_devtools.database import DatabaseLogHandler
from lsp_devtools.inspector import MessagesTable
from lsp_devtools.inspector import MessageViewer

from .editor import TextEditor
from .lsp import LanguageClient


class Explorer(DirectoryTree):
@on(DirectoryTree.FileSelected)
def open_file(self, event: DirectoryTree.FileSelected):
if not self.parent:
return

editor = self.parent.query_one(TextEditor)
editor.open_file(event.path)
editor.focus()


class Devtools(Vertical):
pass


class LSPClient(App):
"""A simple LSP client for use with language servers."""

CSS_PATH = pathlib.Path(__file__).parent / "app.css"
BINDINGS = [
("f2", "toggle_explorer", "Explorer"),
("f12", "toggle_devtools", "Devtools"),
# ("ctrl+g", "refresh_table", "Refresh table"),
]

def __init__(
self, db: Database, server_command: List[str], session: str, *args, **kwargs
):
super().__init__(*args, **kwargs)

self.db = db
db.app = self

self.session = session
self.server_command = server_command
self.lsp_client = LanguageClient()

self._async_tasks: List[asyncio.Task] = []

def compose(self) -> ComposeResult:
message_viewer = MessageViewer("")
messages_table = MessagesTable(self.db, message_viewer, session=self.session)

yield Header()
yield Explorer(".")
yield TextEditor(self.lsp_client)
devtools = Devtools(ScrollableContainer(messages_table), message_viewer)
devtools.add_class("-hidden")
yield devtools
yield Footer()

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

if is_visible:
self.screen.focus_next()
devtools.add_class("-hidden")

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

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

if is_visible and explorer.has_focus:
self.screen.focus_next()
explorer.add_class("-hidden")

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

async def on_ready(self, event: events.Ready):
editor = self.query_one(TextEditor)

# Start the lsp server.
await self.lsp_client.start_io(self.server_command[0], *self.server_command[1:])
result = await self.lsp_client.initialize_async(
types.InitializeParams(
capabilities=types.ClientCapabilities(),
process_id=os.getpid(),
root_uri=uri.from_fs_path(os.getcwd()),
)
)

editor.capabilities = result.capabilities
self.lsp_client.initialized(types.InitializedParams())

@on(Database.Update)
async def update_table(self, event: Database.Update):
table = self.query_one(MessagesTable)
await table.update()

async def action_quit(self):
await self.lsp_client.shutdown_async(None)
self.lsp_client.exit(None)
await self.lsp_client.stop()
await super().action_quit()


def client(args, extra: List[str]):
if len(extra) == 0:
raise ValueError("Missing server command.")

db = Database(args.dbpath)

session = str(uuid4())
dbhandler = DatabaseLogHandler(db, session=session)
dbhandler.setLevel(logging.INFO)

logger.setLevel(logging.INFO)
logger.addHandler(dbhandler)

app = LSPClient(db, session=session, server_command=extra)
app.run()

asyncio.run(db.close())


def cli(commands: argparse._SubParsersAction):
cmd: argparse.ArgumentParser = commands.add_parser(
"client",
help="launch an LSP client with built in inspector",
description="""\
Open a simple text editor to drive a given language server.
""",
)

default_db = pathlib.Path(
platformdirs.user_cache_dir(appname="lsp-devtools", appauthor="swyddfa"),
"sessions.db",
)
cmd.add_argument(
"--dbpath",
type=pathlib.Path,
metavar="DB",
default=default_db,
help="the database path to use",
)

cmd.set_defaults(run=client)
35 changes: 35 additions & 0 deletions lib/lsp-devtools/lsp_devtools/client/app.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
Screen {
layers: base overlay;
}

Explorer {
width: 30;
dock: left;
transition: offset 300ms out_cubic;
}
Explorer.-hidden {
display: none;
}

Header {
dock: top;
}

Devtools {
width: 40%;
dock: right;
transition: offset 300ms out_cubic;
}
Devtools.-hidden {
display: none;
}

MessagesTable {
height: 100%;
}

CompletionList {
height: 10;
width: 30;
layer: overlay;
}
Loading