diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1256056..c906bc0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -54,8 +54,9 @@ repos: - aiosqlite - attrs - importlib-resources + - platformdirs - pygls + - stamina - textual - - types-appdirs - websockets files: 'lib/lsp-devtools/lsp_devtools/.*\.py' diff --git a/lib/lsp-devtools/changes/83.feature.rst b/lib/lsp-devtools/changes/83.feature.rst new file mode 100644 index 0000000..a755fc1 --- /dev/null +++ b/lib/lsp-devtools/changes/83.feature.rst @@ -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. diff --git a/lib/lsp-devtools/changes/83.misc.rst b/lib/lsp-devtools/changes/83.misc.rst new file mode 100644 index 0000000..864e34e --- /dev/null +++ b/lib/lsp-devtools/changes/83.misc.rst @@ -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`` diff --git a/lib/lsp-devtools/lsp_devtools/agent/client.py b/lib/lsp-devtools/lsp_devtools/agent/client.py index f36c8f9..03f5662 100644 --- a/lib/lsp-devtools/lsp_devtools/agent/client.py +++ b/lib/lsp-devtools/lsp_devtools/agent/client.py @@ -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) ) diff --git a/lib/lsp-devtools/lsp_devtools/agent/server.py b/lib/lsp-devtools/lsp_devtools/agent/server.py index 41f20f8..f9633a2 100644 --- a/lib/lsp-devtools/lsp_devtools/agent/server.py +++ b/lib/lsp-devtools/lsp_devtools/agent/server.py @@ -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): @@ -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() @@ -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) diff --git a/lib/lsp-devtools/lsp_devtools/cli.py b/lib/lsp-devtools/lsp_devtools/cli.py index 24cc1d0..3ec1ff6 100644 --- a/lib/lsp-devtools/lsp_devtools/cli.py +++ b/lib/lsp-devtools/lsp_devtools/cli.py @@ -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", ] @@ -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") diff --git a/lib/lsp-devtools/lsp_devtools/client/__init__.py b/lib/lsp-devtools/lsp_devtools/client/__init__.py new file mode 100644 index 0000000..23f6024 --- /dev/null +++ b/lib/lsp-devtools/lsp_devtools/client/__init__.py @@ -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) diff --git a/lib/lsp-devtools/lsp_devtools/client/app.css b/lib/lsp-devtools/lsp_devtools/client/app.css new file mode 100644 index 0000000..df160f1 --- /dev/null +++ b/lib/lsp-devtools/lsp_devtools/client/app.css @@ -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; +} diff --git a/lib/lsp-devtools/lsp_devtools/client/editor.py b/lib/lsp-devtools/lsp_devtools/client/editor.py new file mode 100644 index 0000000..fff312d --- /dev/null +++ b/lib/lsp-devtools/lsp_devtools/client/editor.py @@ -0,0 +1,161 @@ +import asyncio +import pathlib +from typing import Optional +from typing import Set + +from lsprotocol import types +from pygls import uris as uri +from pygls.capabilities import get_capability +from textual import events +from textual import log +from textual import on +from textual.binding import Binding +from textual.widgets import OptionList +from textual.widgets import TextArea + +from .lsp import LanguageClient + + +class CompletionList(OptionList): + BINDINGS = [ + Binding("escape", "dismiss", "Dismiss", show=False), + Binding("ctrl+j", "dismiss", "Dismiss", show=False), + ] + + @classmethod + def fromresult(cls, result): + """Build a list of completion candidates based on a response from the + language server.""" + candidates = cls() + + if result is None: + return candidates + + if isinstance(result, types.CompletionList): + items = result.items + else: + items = result + + if len(items) == 0: + return candidates + + candidates.add_options(sorted([i.label for i in items])) + return candidates + + def on_blur(self, event: events.Blur): + self.action_dismiss() + + def action_dismiss(self): + self.remove() + if self.parent: + self.app.set_focus(self.parent) # type: ignore + + +class TextEditor(TextArea): + def __init__(self, lsp_client: LanguageClient, *args, **kwargs): + super().__init__(*args, **kwargs) + self.uri = None + self.version = 0 + + self.lsp_client = lsp_client + self.capabilities: Optional[types.ServerCapabilities] = None + + self._tasks: Set[asyncio.Task] = set() + + @property + def completion_triggers(self): + return get_capability( + self.capabilities, # type: ignore + "completion_provider.trigger_characters", + set(), + ) + + def open_file(self, path: pathlib.Path): + self.uri = uri.from_fs_path(str(path.resolve())) + if self.uri is None: + return + + content = path.read_text() + self.version = 0 + self.load_text(content) + + self.lsp_client.text_document_did_open( + types.DidOpenTextDocumentParams( + text_document=types.TextDocumentItem( + uri=self.uri, + language_id="restructuredtext", + version=self.version, + text=content, + ) + ) + ) + + def edit(self, edit): + super().edit(edit) + + if self.uri is None: + return + + self.version += 1 + start_line, start_col = edit.from_location + end_line, end_col = edit.to_location + + self.lsp_client.text_document_did_change( + types.DidChangeTextDocumentParams( + text_document=types.VersionedTextDocumentIdentifier( + version=self.version, uri=self.uri + ), + content_changes=[ + types.TextDocumentContentChangeEvent_Type1( + text=edit.text, + range=types.Range( + start=types.Position(line=start_line, character=start_col), + end=types.Position(line=end_line, character=end_col), + ), + ) + ], + ) + ) + + if len(edit.text) == 0: + return + + char = edit.text[-1] + if char in self.completion_triggers: + self.trigger_completion(end_line, end_col) + + def trigger_completion(self, line: int, character: int): + """Trigger completion at the given location.""" + + if self.uri is None: + return + + task = asyncio.create_task( + self.lsp_client.text_document_completion_async( + types.CompletionParams( + text_document=types.TextDocumentIdentifier(uri=self.uri), + position=types.Position(line=line, character=character), + ) + ) + ) + + self._tasks.add(task) + task.add_done_callback(self.show_completions) + + def show_completions(self, task: asyncio.Task): + self._tasks.discard(task) + + candidates = CompletionList.fromresult(task.result()) + if candidates.option_count == 0: + return + + row, col = self.cursor_location + candidates.offset = (col + 2, row + 1) + + self.mount(candidates) + self.app.set_focus(candidates) + + @on(OptionList.OptionSelected) + def completion_selected(self, event: OptionList.OptionSelected): + log(f"{event.option} was selected!") + event.option_list.action_dismiss() # type: ignore diff --git a/lib/lsp-devtools/lsp_devtools/client/lsp.py b/lib/lsp-devtools/lsp_devtools/client/lsp.py new file mode 100644 index 0000000..897505f --- /dev/null +++ b/lib/lsp-devtools/lsp_devtools/client/lsp.py @@ -0,0 +1,39 @@ +import importlib.metadata +import json + +from pygls.lsp.client import BaseLanguageClient +from pygls.protocol import LanguageServerProtocol + +from lsp_devtools.agent import logger + +VERSION = importlib.metadata.version("lsp-devtools") + + +class RecordingLSProtocol(LanguageServerProtocol): + """A version of the LanguageServerProtocol that also records all the traffic.""" + + def __init__(self, server, converter): + super().__init__(server, converter) + + def _procedure_handler(self, message): + logger.info( + "%s", + json.dumps(message, default=self._serialize_message), + extra={"source": "server"}, + ) + return super()._procedure_handler(message) + + def _send_data(self, data): + logger.info( + "%s", + json.dumps(data, default=self._serialize_message), + extra={"source": "client"}, + ) + return super()._send_data(data) + + +class LanguageClient(BaseLanguageClient): + """A language client for integrating with a textual text edit.""" + + def __init__(self): + super().__init__("lsp-devtools", VERSION, protocol_cls=RecordingLSProtocol) diff --git a/lib/lsp-devtools/lsp_devtools/cmds/__init__.py b/lib/lsp-devtools/lsp_devtools/cmds/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/lib/lsp-devtools/lsp_devtools/cmds/capabilities.py b/lib/lsp-devtools/lsp_devtools/cmds/capabilities.py deleted file mode 100644 index d520ed3..0000000 --- a/lib/lsp-devtools/lsp_devtools/cmds/capabilities.py +++ /dev/null @@ -1,35 +0,0 @@ -import argparse -import json - -from lsprotocol.types import INITIALIZE -from lsprotocol.types import InitializeParams -from pygls.server import LanguageServer - - -def capabilities(args, extra): - server = LanguageServer(name="capabilities-dumper", version="v1.0") - - @server.feature(INITIALIZE) - def on_initialize(ls: LanguageServer, params: InitializeParams): - client_info = params.client_info - if client_info: - client_name = client_info.name.lower().replace(" ", "_") - client_version = client_info.version or "unknown" - else: - client_name = "unknown" - client_version = "unknown" - - filename = f"{client_name}_v{client_version}.json" - with open(filename, "w") as f: - obj = params.capabilities - json.dump(ls.lsp._converter.unstructure(obj), f, indent=2) - - server.start_io() - - -def cli(commands: argparse._SubParsersAction): - cmd = commands.add_parser( - "capabilities", - help="dummy lsp server for recording a client's capabilities.", - ) - cmd.set_defaults(run=capabilities) diff --git a/lib/lsp-devtools/lsp_devtools/database.py b/lib/lsp-devtools/lsp_devtools/database.py new file mode 100644 index 0000000..f3eff5d --- /dev/null +++ b/lib/lsp-devtools/lsp_devtools/database.py @@ -0,0 +1,167 @@ +import asyncio +import json +import logging +import pathlib +import sys +from contextlib import asynccontextmanager +from typing import Any +from typing import Dict +from typing import List +from typing import Optional +from typing import Set +from uuid import uuid4 + +import aiosqlite +from textual.app import App +from textual.message import Message + +from lsp_devtools.handlers import LspMessage + +if sys.version_info.minor < 9: + import importlib_resources as resources +else: + import importlib.resources as resources # type: ignore[no-redef] + + +class Database: + """Controls access to the backing sqlite database.""" + + class Update(Message): + """Sent when there are updates to the database""" + + def __init__(self, dbpath: Optional[pathlib.Path] = None): + self.dbpath = dbpath or ":memory:" + self.db: Optional[aiosqlite.Connection] = None + self.app: Optional[App] = None + self._handlers: Dict[str, set] = {} + + async def close(self): + if self.db: + await self.db.close() + + @asynccontextmanager + async def cursor(self): + """Get a connection to the database.""" + + if self.db is None: + if ( + isinstance(self.dbpath, pathlib.Path) + and not self.dbpath.parent.exists() + ): + self.dbpath.parent.mkdir(parents=True) + + resource = resources.files("lsp_devtools.handlers").joinpath("dbinit.sql") + schema = resource.read_text(encoding="utf8") + + self.db = await aiosqlite.connect(self.dbpath) + await self.db.executescript(schema) + await self.db.commit() + + cursor = await self.db.cursor() + yield cursor + + await self.db.commit() + + async def add_message(self, session: str, timestamp: float, source: str, rpc: dict): + """Add a new rpc message to the database.""" + + msg_id = rpc.get("id", None) + method = rpc.get("method", None) + params = rpc.get("params", None) + result = rpc.get("result", None) + error = rpc.get("error", None) + + async with self.cursor() as cursor: + await cursor.execute( + "INSERT INTO protocol VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + ( + session, + timestamp, + source, + msg_id, + method, + json.dumps(params) if params else None, + json.dumps(result) if result else None, + json.dumps(error) if error else None, + ), + ) + + if self.app is not None: + self.app.post_message(Database.Update()) + + async def get_messages( + self, + *, + session: str = "", + max_row: Optional[int] = None, + ): + """Get messages from the database + + Parameters + ---------- + session + If set, only return messages with the given session id + + max_row + If set, only return messages with a row id greater than ``max_row`` + """ + + base_query = "SELECT rowid, * FROM protocol" + where: List[str] = [] + parameters: List[Any] = [] + + if session: + where.append("session = ?") + parameters.append(session) + + if max_row: + where.append("rowid > ?") + parameters.append(max_row) + + if where: + conditions = " AND ".join(where) + query = " ".join([base_query, "WHERE", conditions]) + else: + query = base_query + + async with self.cursor() as cursor: + await cursor.execute(query, tuple(parameters)) + + rows = await cursor.fetchall() + results = [] + for row in rows: + message = LspMessage( + session=row[1], + timestamp=row[2], + source=row[3], + id=row[4], + method=row[5], + params=row[6], + result=row[7], + error=row[8], + ) + + results.append((row[0], message)) + + return results + + +class DatabaseLogHandler(logging.Handler): + """A logging handler that records messages in the given database.""" + + def __init__(self, db: Database, *args, session=None, **kwargs): + super().__init__(*args, **kwargs) + self.db = db + self.session = session or str(uuid4()) + self._tasks: Set[asyncio.Task] = set() + + def emit(self, record: logging.LogRecord): + body = json.loads(record.args[0]) # type: ignore + task = asyncio.create_task( + self.db.add_message( + self.session, record.created, record.__dict__["source"], body + ) + ) + + self._tasks.add(task) + task.add_done_callback(self._tasks.discard) diff --git a/lib/lsp-devtools/lsp_devtools/handlers/dbinit.sql b/lib/lsp-devtools/lsp_devtools/handlers/dbinit.sql index 18604d1..fcadb11 100644 --- a/lib/lsp-devtools/lsp_devtools/handlers/dbinit.sql +++ b/lib/lsp-devtools/lsp_devtools/handlers/dbinit.sql @@ -58,6 +58,7 @@ SELECT json_extract(params, "$.clientInfo.name") as client_name, json_extract(params, "$.clientInfo.version") as client_version, json_extract(params, "$.rootUri") as root_uri, + json_extract(params, "$.workspaceFolders") as workspace_folders, params, result FROM requests WHERE method = 'initialize'; diff --git a/lib/lsp-devtools/lsp_devtools/tui/__init__.py b/lib/lsp-devtools/lsp_devtools/inspector/__init__.py similarity index 82% rename from lib/lsp-devtools/lsp_devtools/tui/__init__.py rename to lib/lsp-devtools/lsp_devtools/inspector/__init__.py index 069036e..a531c00 100644 --- a/lib/lsp-devtools/lsp_devtools/tui/__init__.py +++ b/lib/lsp-devtools/lsp_devtools/inspector/__init__.py @@ -8,12 +8,11 @@ from typing import Any from typing import Dict from typing import List +from typing import Optional -import appdirs +import platformdirs from rich.highlighter import ReprHighlighter from rich.text import Text -from textual import events -from textual import log from textual import on from textual.app import App from textual.app import ComposeResult @@ -29,11 +28,9 @@ from lsp_devtools.agent import MESSAGE_TEXT_NOTIFICATION from lsp_devtools.agent import AgentServer from lsp_devtools.agent import MessageText +from lsp_devtools.database import Database from lsp_devtools.handlers import LspMessage -from .database import Database -from .database import PingMessage - logger = logging.getLogger(__name__) @@ -71,12 +68,14 @@ def walk_object(self, label: str, node: TreeNode, obj: Any): class MessagesTable(DataTable): """Datatable used to display all messages between client and server""" - def __init__(self, db: Database, viewer: MessageViewer): + def __init__(self, db: Database, viewer: MessageViewer, session=None): super().__init__() self.db = db + self.rpcdata: Dict[int, LspMessage] = {} self.max_row = 0 + self.session: Optional[str] = session self.viewer = viewer @@ -88,12 +87,16 @@ def __init__(self, db: Database, viewer: MessageViewer): self.add_column("ID") self.add_column("Method") - def on_key(self, event: events.Key): - if event.key != "enter": + @on(DataTable.RowHighlighted) + def show_object(self, event: DataTable.RowHighlighted): + """Show the message object on the currently highlighted row.""" + if event.cursor_row < 0: + return + + rowid = int(self.get_row_at(event.cursor_row)[0]) + if (message := self.rpcdata.get(rowid, None)) is None: return - rowid = int(self.get_row_at(self.cursor_row)[0]) - message = self.rpcdata[rowid] name = "" obj = {} @@ -111,24 +114,32 @@ def on_key(self, event: events.Key): self.viewer.set_object(name, obj) + def _get_query_params(self): + """Return the set of query parameters to use when populating the table.""" + query: Dict[str, Any] = dict(max_row=self.max_row) + + if self.session is not None: + query["session"] = self.session + + return query + async def update(self): """Trigger a re-run of the query to pull in new data.""" - messages = await self.db.get_messages(self.max_row - 1) - for message in messages: - self.max_row += 1 - self.rpcdata[self.max_row] = message + query_params = self._get_query_params() + messages = await self.db.get_messages(**query_params) + for idx, message in messages: + self.max_row = idx + self.rpcdata[idx] = message # Surely there's a more direct way to do this? dt = datetime.fromtimestamp(message.timestamp) time = dt.isoformat(timespec="milliseconds") time = time[time.find("T") + 1 :] - self.add_row( - str(self.max_row), time, message.source, message.id, message.method - ) + self.add_row(str(idx), time, message.source, message.id, message.method) - self.move_cursor(row=self.max_row, animate=True) + self.move_cursor(row=self.row_count, animate=True) class Sidebar(Container): @@ -146,14 +157,14 @@ class LSPInspector(App): def __init__(self, db: Database, server: AgentServer, *args, **kwargs): super().__init__(*args, **kwargs) + db.app = self self.db = db - self.db.app = self """Where the data for the app is being held""" self.server = server """Server used to manage connections to lsp servers.""" - self._async_tasks = [] + self._async_tasks: List[asyncio.Task] = [] def compose(self) -> ComposeResult: yield Header() @@ -178,10 +189,6 @@ def action_toggle_sidebar(self) -> None: self.screen.set_focus(None) sidebar.add_class("-hidden") - @on(PingMessage) - async def on_ping(self, message: PingMessage): - await self.update_table() - async def on_ready(self, event: Ready): self._async_tasks.append( asyncio.create_task(self.server.start_tcp("localhost", 8765)) @@ -235,7 +242,10 @@ async def handle_message(ls: AgentServer, message: MessageText): message_buf.clear() rpc = json.loads(body) - await ls.db.add_message(message.session, message.timestamp, message.source, rpc) + if ls.db is not None: + await ls.db.add_message( + message.session, message.timestamp, message.source, rpc + ) def setup_server(db: Database): @@ -255,8 +265,8 @@ def tui(args, extra: List[str]): def cli(commands: argparse._SubParsersAction): cmd: argparse.ArgumentParser = commands.add_parser( - "tui", - help="launch TUI", + "inspect", + help="launch an interactive LSP session inspector", description="""\ This command opens a text user interface that can be used to inspect and manipulate an LSP session interactively. @@ -264,7 +274,7 @@ def cli(commands: argparse._SubParsersAction): ) default_db = pathlib.Path( - appdirs.user_cache_dir(appname="lsp-devtools", appauthor="swyddfa"), + platformdirs.user_cache_dir(appname="lsp-devtools", appauthor="swyddfa"), "sessions.db", ) cmd.add_argument( diff --git a/lib/lsp-devtools/lsp_devtools/tui/app.css b/lib/lsp-devtools/lsp_devtools/inspector/app.css similarity index 100% rename from lib/lsp-devtools/lsp_devtools/tui/app.css rename to lib/lsp-devtools/lsp_devtools/inspector/app.css diff --git a/lib/lsp-devtools/lsp_devtools/tui/database.py b/lib/lsp-devtools/lsp_devtools/tui/database.py deleted file mode 100644 index 998c00d..0000000 --- a/lib/lsp-devtools/lsp_devtools/tui/database.py +++ /dev/null @@ -1,109 +0,0 @@ -import json -import pathlib -import sys -from contextlib import asynccontextmanager -from typing import Optional - -import aiosqlite -from textual import log -from textual.message import Message - -from lsp_devtools.handlers import LspMessage - -if sys.version_info.minor < 9: - import importlib_resources as resources -else: - import importlib.resources as resources # type: ignore[no-redef] - - -class PingMessage(Message): - """Sent when there are updates in the db.""" - - -class Database: - """Controls access to the backing sqlite database.""" - - def __init__(self, dbpath: Optional[pathlib.Path] = None, app=None): - self.dbpath = dbpath or ":memory:" - self.db: Optional[aiosqlite.Connection] = None - self.app = app - - async def close(self): - if self.db: - await self.db.close() - - @asynccontextmanager - async def cursor(self): - """Get a connection to the database.""" - - if self.db is None: - if ( - isinstance(self.dbpath, pathlib.Path) - and not self.dbpath.parent.exists() - ): - self.dbpath.parent.mkdir(parents=True) - - resource = resources.files("lsp_devtools.handlers").joinpath("dbinit.sql") - schema = resource.read_text(encoding="utf8") - - self.db = await aiosqlite.connect(self.dbpath) - await self.db.executescript(schema) - await self.db.commit() - - cursor = await self.db.cursor() - yield cursor - - await self.db.commit() - - async def add_message(self, session: str, timestamp: float, source: str, rpc: dict): - """Add a new rpc message to the database.""" - - msg_id = rpc.get("id", None) - method = rpc.get("method", None) - params = rpc.get("params", None) - result = rpc.get("result", None) - error = rpc.get("error", None) - - async with self.cursor() as cursor: - await cursor.execute( - "INSERT INTO protocol VALUES (?, ?, ?, ?, ?, ?, ?, ?)", - ( - session, - timestamp, - source, - msg_id, - method, - json.dumps(params) if params else None, - json.dumps(result) if result else None, - json.dumps(error) if error else None, - ), - ) - - if self.app is not None: - self.app.post_message(PingMessage()) - - async def get_messages(self, max_row=-1): - """Get messages from the databse""" - - query = "SELECT * FROM protocol WHERE rowid > ?" - - async with self.cursor() as cursor: - await cursor.execute(query, (max_row,)) - - rows = await cursor.fetchall() - results = [] - for row in rows: - results.append( - LspMessage( - session=row[0], - timestamp=row[1], - source=row[2], - id=row[3], - method=row[4], - params=row[5], - result=row[6], - error=row[7], - ) - ) - - return results diff --git a/lib/lsp-devtools/pyproject.toml b/lib/lsp-devtools/pyproject.toml index 7bd8185..8e25117 100644 --- a/lib/lsp-devtools/pyproject.toml +++ b/lib/lsp-devtools/pyproject.toml @@ -22,12 +22,13 @@ classifiers = [ "Programming Language :: Python :: 3.11", ] dependencies = [ - "appdirs", "aiosqlite", "importlib-resources; python_version<\"3.9\"", - "pygls", + "platformdirs", + "pygls>=1.1.0", "stamina", - "textual>=0.14.0", + "textual>=0.38.0", + "typing-extensions; python_version<\"3.8\"", ] [project.urls] @@ -45,7 +46,6 @@ dev = [ typecheck=[ "mypy", "importlib_resources", - "types-appdirs", "types-setuptools", ] prometheus = ["prometheus_client"] @@ -75,32 +75,11 @@ title_format = "v{version} - {project_date}" issue_format = "`#{issue} `_" underlines = ["-", "^", "\""] -[[tool.towncrier.type]] -directory = "feature" -name = "Features" -showcontent = true - -[[tool.towncrier.type]] -directory = "fix" -name = "Fixes" -showcontent = true - -[[tool.towncrier.type]] -directory = "doc" -name = "Docs" -showcontent = true - -[[tool.towncrier.type]] -directory = "breaking" -name = "Breaking Changes" -showcontent = true - -[[tool.towncrier.type]] -directory = "deprecated" -name = "Deprecated" -showcontent = true - -[[tool.towncrier.type]] -directory = "misc" -name = "Misc" -showcontent = true +type = [ + { name = "Features", directory = "feature", showcontent = true }, + { name = "Fixes", directory = "fix", showcontent = true }, + { name = "Docs", directory = "doc", showcontent = true }, + { name = "Breaking Changes", directory = "breaking", showcontent = true }, + { name = "Deprecated", directory = "deprecated", showcontent = true }, + { name = "Misc", directory = "misc", showcontent = true }, +]