diff --git a/.gitignore b/.gitignore index ba056b11..ec9fae59 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ .env .tox uv.lock +*.db *.pyc dist diff --git a/lib/lsp-devtools/lsp_devtools/agent/__init__.py b/lib/lsp-devtools/lsp_devtools/agent/__init__.py index 4b662205..e4282b3f 100644 --- a/lib/lsp-devtools/lsp_devtools/agent/__init__.py +++ b/lib/lsp-devtools/lsp_devtools/agent/__init__.py @@ -1,13 +1,5 @@ from __future__ import annotations -import argparse -import asyncio -import logging -import subprocess -import sys - -from lsp_devtools.cli.utils import get_log_level - from .agent import Agent from .agent import MessageHeader from .agent import MessageSource @@ -21,125 +13,3 @@ "MessageHeader", "MessageSource", ] - - -async def forward_stderr(server: asyncio.subprocess.Process): - """Forward the server's stderr to the agent's stderr.""" - if server.stderr is None: - return - - # EOF is signalled with an empty bytestring - while (line := await server.stderr.readline()) != b"": - sys.stderr.buffer.write(line) - - -class AgentClientHandler(logging.Handler): - """Forwards log messages through the client - server connection.""" - - def __init__(self, client: AgentClient, level: int = 0) -> None: - super().__init__(level) - self.client = client - - def emit(self, record: logging.LogRecord) -> None: - msg = (self.format(record) + "\n").encode() - - source = MessageSource.AGENT - length = len(msg) - data = b"".join([MessageHeader.pack(source, length), msg]) - - self.client.forward_message(data) - - -async def main(args, cmd: list[str]): - client = AgentClient() - - log_level = get_log_level(args.verbose) - logger = logging.getLogger("lsp_devtools") - logger.setLevel(log_level) - - handler = AgentClientHandler(client) - handler.setFormatter(logging.Formatter("[%(name)s]: %(message)s")) - handler.setLevel(log_level) - - logger.addHandler(handler) - - command, *arguments = cmd - server = await asyncio.create_subprocess_exec( - command, - *arguments, - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - - agent = Agent(server, sys.stdin.buffer, sys.stdout.buffer, client.forward_message) - - await asyncio.gather( - client.start_tcp(args.host, args.port), - agent.start(), - forward_stderr(server), - ) - - -def run_agent(args, extra: list[str] | None): - if extra is None: - print("Missing server start command", file=sys.stderr) - return 1 - - try: - asyncio.run(main(args, extra)) - except asyncio.CancelledError: - pass - - -def cli(commands: argparse._SubParsersAction): - cmd: argparse.ArgumentParser = commands.add_parser( - "agent", - help="instrument an LSP session", - formatter_class=argparse.RawDescriptionHelpFormatter, - description="""\ -This command runs the given JSON-RPC server as a subprocess, wrapping it in a -an "AgentClient" which will capture all messages sent to/from the wrapped -server, forwarding them onto an "AgentServer" to be processed. - -To wrap a server, supply its start command after all other agent options and -preceeded by a `--`, for example: - - lsp-devtools agent -p 1234 -- python -m esbonio - -Wrapping a JSON-RPC server with this command is required to enable the -majority of the lsp-devtools suite of tools. - - ┌─ RPC Client ─┐ ┌──── Agent Client ────┐ ┌─ RPC Server ─┐ - │ │ │ ┌──────────────┐ │ │ │ - │ stdout│─────│───│ │───│────│stdin │ - │ │ │ │ Agent │ │ │ │ - │ stdin│─────│───│ │───│────│stdout │ - │ │ │ └──────────────┘ │ │ │ - │ │ │ │ │ │ - └──────────────┘ └──────────────────────┘ └──────────────┘ - │ - │ tcp - │ - ┌──────────────┐ - │ │ - │ Agent Server │ - │ │ - └──────────────┘ - -""", - ) - - cmd.add_argument( - "--host", - help="the host to connect to.", - default="localhost", - ) - cmd.add_argument( - "-p", - "--port", - help="the port to connect to", - default=8765, - ) - - cmd.set_defaults(run=run_agent) diff --git a/lib/lsp-devtools/lsp_devtools/cli/__init__.py b/lib/lsp-devtools/lsp_devtools/cli/__init__.py index 21bbdeb7..28c4c196 100644 --- a/lib/lsp-devtools/lsp_devtools/cli/__init__.py +++ b/lib/lsp-devtools/lsp_devtools/cli/__init__.py @@ -10,10 +10,10 @@ BUILTIN_COMMANDS = [ - "lsp_devtools.agent", - # "lsp_devtools.client", - "lsp_devtools.inspector", - "lsp_devtools.record", + "lsp_devtools.cli.agent", + "lsp_devtools.cli.client", + "lsp_devtools.cli.inspector", + "lsp_devtools.cli.record", ] @@ -56,12 +56,16 @@ def main(): idx = sys.argv.index("--") args, extra = sys.argv[1:idx], sys.argv[idx + 1 :] except ValueError: - args, extra = sys.argv[1:], None + args, extra = sys.argv[1:], [] parsed_args = cli.parse_args(args) if hasattr(parsed_args, "run"): - return parsed_args.run(parsed_args, extra) + try: + return parsed_args.run(parsed_args, extra) + except Exception as exc: + print(f"Error: {exc}") + return -1 cli.print_help() return 0 diff --git a/lib/lsp-devtools/lsp_devtools/cli/agent.py b/lib/lsp-devtools/lsp_devtools/cli/agent.py new file mode 100644 index 00000000..5a7888f7 --- /dev/null +++ b/lib/lsp-devtools/lsp_devtools/cli/agent.py @@ -0,0 +1,136 @@ +from __future__ import annotations + +import argparse +import asyncio +import logging +import subprocess +import sys + +from lsp_devtools.agent import Agent +from lsp_devtools.agent import AgentClient +from lsp_devtools.agent import MessageHeader +from lsp_devtools.agent import MessageSource + +from .utils import get_log_level + + +async def forward_stderr(server: asyncio.subprocess.Process): + """Forward the server's stderr to the agent's stderr.""" + if server.stderr is None: + return + + # EOF is signalled with an empty bytestring + while (line := await server.stderr.readline()) != b"": + sys.stderr.buffer.write(line) + + +class AgentClientHandler(logging.Handler): + """Forwards log messages through the client - server connection.""" + + def __init__(self, client: AgentClient, level: int = 0) -> None: + super().__init__(level) + self.client = client + + def emit(self, record: logging.LogRecord) -> None: + msg = (self.format(record) + "\n").encode() + + source = MessageSource.AGENT + length = len(msg) + data = b"".join([MessageHeader.pack(source, length), msg]) + + self.client.forward_message(data) + + +async def main(args, cmd: list[str]): + client = AgentClient() + + log_level = get_log_level(args.verbose) + logger = logging.getLogger("lsp_devtools") + logger.setLevel(log_level) + + handler = AgentClientHandler(client) + handler.setFormatter(logging.Formatter("[%(name)s]: %(message)s")) + handler.setLevel(log_level) + + logger.addHandler(handler) + + command, *arguments = cmd + server = await asyncio.create_subprocess_exec( + command, + *arguments, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + + agent = Agent(server, sys.stdin.buffer, sys.stdout.buffer, client.forward_message) + + await asyncio.gather( + client.start_tcp(args.host, args.port), + agent.start(), + forward_stderr(server), + ) + + +def run_agent(args, extra: list[str] | None): + if extra is None: + print("Missing server start command", file=sys.stderr) + return 1 + + try: + asyncio.run(main(args, extra)) + except asyncio.CancelledError: + pass + + +def cli(commands: argparse._SubParsersAction): + cmd: argparse.ArgumentParser = commands.add_parser( + "agent", + help="instrument an LSP session", + formatter_class=argparse.RawDescriptionHelpFormatter, + description="""\ +This command runs the given JSON-RPC server as a subprocess, wrapping it in a +an "AgentClient" which will capture all messages sent to/from the wrapped +server, forwarding them onto an "AgentServer" to be processed. + +To wrap a server, supply its start command after all other agent options and +preceeded by a `--`, for example: + + lsp-devtools agent -p 1234 -- python -m esbonio + +Wrapping a JSON-RPC server with this command is required to enable the +majority of the lsp-devtools suite of tools. + + ┌─ RPC Client ─┐ ┌──── Agent Client ────┐ ┌─ RPC Server ─┐ + │ │ │ ┌──────────────┐ │ │ │ + │ stdout│─────│───│ │───│────│stdin │ + │ │ │ │ Agent │ │ │ │ + │ stdin│─────│───│ │───│────│stdout │ + │ │ │ └──────────────┘ │ │ │ + │ │ │ │ │ │ + └──────────────┘ └──────────────────────┘ └──────────────┘ + │ + │ tcp + │ + ┌──────────────┐ + │ │ + │ Agent Server │ + │ │ + └──────────────┘ + +""", + ) + + cmd.add_argument( + "--host", + help="the host to connect to.", + default="localhost", + ) + cmd.add_argument( + "-p", + "--port", + help="the port to connect to", + default=8765, + ) + + cmd.set_defaults(run=run_agent) diff --git a/lib/lsp-devtools/lsp_devtools/cli/client.py b/lib/lsp-devtools/lsp_devtools/cli/client.py new file mode 100644 index 00000000..1ceb59ba --- /dev/null +++ b/lib/lsp-devtools/lsp_devtools/cli/client.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +import argparse +import importlib.metadata +import os +import typing + +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.widgets import Footer + +from lsp_devtools.cli.utils import LiveSqlHandler +from lsp_devtools.client import LanguageClient +from lsp_devtools.editor import TextEditorView +from lsp_devtools.inspector.message_browser import MessageBrowser + +if typing.TYPE_CHECKING: + from textual.app import ComposeResult + + +@typing.final +class LSPClient(App[None]): + """A simple LSP client.""" + + DEFAULT_CSS = """ + .hidden { + display: none; + } + + MessageBrowser { + dock: right; + width: 30%; + } + """ + + BINDINGS = [ + ("ctrl+c", "quit"), + ("f12", "toggle_devtools", "Devtools"), + ] + + def __init__(self, *args, server_command: list[str], **kwargs): + super().__init__(*args, **kwargs) + self.server_command = server_command + + def compose(self) -> ComposeResult: + yield TextEditorView() + + browser = MessageBrowser() + browser.add_class("hidden") + yield browser + + yield Footer() + + 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") + + else: + devtools.remove_class("hidden") + self.screen.set_focus(devtools) + + def on_ready(self, event: events.Ready): + self.run_worker(self.start_server(), name="lsp-connection", thread=True) + + @on(LiveSqlHandler.MessageReceived) + def on_message_received(self, event: LiveSqlHandler.MessageReceived): + browser = self.query_one(MessageBrowser) + browser.reload(follow=True) + + async def start_server(self): + self.db = LiveSqlHandler() + self.db.app = self + + client = LanguageClient( + self.db, + name="lsp-devtools", + version=importlib.metadata.version("lsp-devtools"), + ) + await client.start_io(*self.server_command) + + result = await client.initialize_async( + types.InitializeParams( + capabilities=types.ClientCapabilities(), + process_id=os.getpid(), + root_uri=uri.from_fs_path(os.getcwd()), + ) + ) + + if info := result.server_info: + name = info.name + version = info.version or "" + self.log(f"Connected to server: {name} {version}") + + client.initialized(types.InitializedParams()) + + +def client(args, extra: list[str]): + if len(extra) == 0: + raise ValueError( + "Missing server command. (e.g. lsp-devtools client -- server-cmd --stdio)" + ) + + app = LSPClient(server_command=extra) + app.run() + + +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. +""", + ) + + cmd.set_defaults(run=client) diff --git a/lib/lsp-devtools/lsp_devtools/cli/inspector.py b/lib/lsp-devtools/lsp_devtools/cli/inspector.py new file mode 100644 index 00000000..7c092161 --- /dev/null +++ b/lib/lsp-devtools/lsp_devtools/cli/inspector.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +import argparse +import pathlib +import typing + +from textual import on +from textual.app import App +from textual.app import ComposeResult +from textual.events import Ready +from textual.widgets import DataTable +from textual.widgets import Footer +from textual.widgets import Header + +from lsp_devtools.agent import AgentServer +from lsp_devtools.agent import MessageSource +from lsp_devtools.cli.utils import LiveSqlHandler +from lsp_devtools.handlers.sql import SqlHandler +from lsp_devtools.inspector.message_browser import MessageBrowser + + +@typing.final +class LSPInspector(App[None]): + """A textual app for inspecting an LSP session, either a live one or one that has + been pre-recorded.""" + + BINDINGS = [ + ("ctrl+c", "quit", "Quit"), + ] + + DEFAULT_CSS = """ + MessageBrowser { + height: 1fr; + } + """ + + def __init__( + self, + db: SqlHandler, + server: AgentServer | None = None, + *args, + **kwargs, + ): + super().__init__(*args, **kwargs) + + self.db = db + """Holds recorded messages""" + + self.server = server + """If set, expect to receive a live connection from the lsp-devtools agent.""" + + def compose(self) -> ComposeResult: + yield Header() + yield MessageBrowser() + + yield Footer() + + @on(LiveSqlHandler.MessageReceived) + def on_message_received(self, event: LiveSqlHandler.MessageReceived): + browser = self.query_one(MessageBrowser) + browser.reload(follow=True) + + async def on_ready(self, event: Ready): + browser = self.query_one(MessageBrowser) + browser.reload() + + table = browser.query_one(DataTable) + table.focus() + + if self.server is not None: + self.run_worker(self.server.start_tcp(), name="lsp-connection", thread=True) + + async def action_quit(self): + if self.server is not None: + self.server.stop() + await super().action_quit() + + +def inspector(args, extra: list[str]): + server = None + + if args.session is not None: + sql_handler = SqlHandler(dbpath=args.session) + app = LSPInspector(db=sql_handler) + + # Assume a live connection + else: + sql_handler = LiveSqlHandler() + server = AgentServer( + handlers={ + MessageSource.CLIENT: sql_handler, + MessageSource.SERVER: sql_handler, + } + ) + app = LSPInspector(server=server, db=sql_handler) + sql_handler.app = app + + app.run() + + +def cli(commands: argparse._SubParsersAction): + cmd: argparse.ArgumentParser = commands.add_parser( + "inspect", + help="launch an interactive LSP session inspector", + description="""\ +This command opens a text user interface that can be used to inspect a LSP session +interactively. +""", + ) + + cmd.add_argument( + "session", + nargs="?", + default=None, + type=pathlib.Path, + metavar="DB", + help="inspect the pre-recorded session in the given SQLite DB", + ) + + connect = cmd.add_argument_group( + title="connection options", + description="options that control the connection to the LSP Agent.", + ) + connect.add_argument( + "--host", + type=str, + default="localhost", + help="the host that is hosting the agent.", + ) + connect.add_argument( + "-p", "--port", type=int, default=8765, help="the port to connect to." + ) + cmd.set_defaults(run=inspector) diff --git a/lib/lsp-devtools/lsp_devtools/cli/record.py b/lib/lsp-devtools/lsp_devtools/cli/record.py new file mode 100644 index 00000000..6e145cce --- /dev/null +++ b/lib/lsp-devtools/lsp_devtools/cli/record.py @@ -0,0 +1,170 @@ +from __future__ import annotations + +import argparse +import pathlib + +from lsp_devtools.record.raw import record_raw +from lsp_devtools.record.rpc import record_rpc + + +def start_recording(args, extra: list[str] | None): + if args.capture_raw: + return record_raw(args) + else: + return record_rpc(args) + + +def setup_filter_args(cmd: argparse.ArgumentParser): + """Add arguments that can be used to filter messages.""" + + filter_ = cmd.add_argument_group( + title="filter options", + description=( + "select which messages to record, mutliple options will be ANDed together. " + "Does not apply to raw message capture" + ), + ) + filter_.add_argument( + "--message-source", + default="both", + choices=["client", "server", "both"], + help="only include messages from the given source", + ) + filter_.add_argument( + "--include-message-type", + action="append", + default=[], + dest="include_message_types", + choices=["request", "response", "result", "error", "notification"], + help="only include the given message type(s)", + ) + filter_.add_argument( + "--exclude-message-type", + action="append", + dest="exclude_message_types", + default=[], + choices=["request", "response", "result", "error", "notification"], + help="omit the given message type(s)", + ) + filter_.add_argument( + "--include-method", + action="append", + dest="include_methods", + default=[], + metavar="METHOD", + help="only include the given messages for the given method(s)", + ) + filter_.add_argument( + "--exclude-method", + action="append", + dest="exclude_methods", + default=[], + metavar="METHOD", + help="omit messages for the given method(s)", + ) + + +def cli(commands: argparse._SubParsersAction): + cmd: argparse.ArgumentParser = commands.add_parser( + "record", + help="record a JSON-RPC session.", + description="""\ +Listen for a connection from the lsp-devtools agent and record the traffic it captures""", + ) + + connect = cmd.add_argument_group( + title="server options", + description="how and where the server should listen for connections", + ) + connect.add_argument( + "--bind", + dest="host", + type=str, + default="localhost", + help="where to listen for connections from", + ) + connect.add_argument( + "-p", "--port", type=int, default=8765, help="the port to listen on" + ) + connect.add_argument( + "--on-disconnect", + default="continue", + choices=["continue", "exit"], + help="how should the server react to a client disconnect (default: continue)", + ) + + capture = cmd.add_mutually_exclusive_group() + capture.add_argument( + "--capture-raw", + action="store_true", + help="capture the raw data send between LSP client and server.", + ) + capture.add_argument( + "--capture-rpc", + default=True, + action="store_true", + help="capture and parse the rpc messages sent between LSP client and server.", + ) + + setup_filter_args(cmd) + format_ = cmd.add_argument_group( + title="formatting options", + description=( + "control how the recorded messages are formatted " + "(does not apply to SQLite output or raw message capture)" + ), + ) + format_.add_argument( + "-f", + "--format-message", + action="append", + help=( + "format messages according to given format string, " + "can be given multiple times, in which case the first valid string will be " + "applied. " + "By default, messages which fail to format will be excluded, " + "see --keep-unformatted" + ), + ) + + format_.add_argument( + "--keep-unformatted", + action="store_true", + help=( + "preserve messages that fail to format using any supplied format string. " + "These will be rendered with the default format string" + ), + ) + + output = cmd.add_argument_group( + title="output options", + description="control where the captured messages are sent to", + ) + output.add_argument( + "--to-file", + default=None, + metavar="FILE", + type=pathlib.Path, + help="save messages to a file", + ) + output.add_argument( + "--to-sqlite", + default=None, + metavar="FILE", + type=pathlib.Path, + help="save messages to a SQLite DB", + ) + output.add_argument( + "--save-output", + default=None, + metavar="DEST", + type=pathlib.Path, + help=( + "only applies when printing messages to the console. " + "This makes use of the rich.Console's export feature to save its output in " + "HTML, SVG or plain text format. The format used will be picked " + "automatically based on the desintation's file extension." + ), + ) + + cmd.set_defaults(run=start_recording) diff --git a/lib/lsp-devtools/lsp_devtools/cli/utils.py b/lib/lsp-devtools/lsp_devtools/cli/utils.py index 095ab784..8845b654 100644 --- a/lib/lsp-devtools/lsp_devtools/cli/utils.py +++ b/lib/lsp-devtools/lsp_devtools/cli/utils.py @@ -1,6 +1,20 @@ from __future__ import annotations import logging +import pathlib +import tempfile +import typing + +from textual.message import Message + +from lsp_devtools.handlers.sql import SqlHandler + +if typing.TYPE_CHECKING: + from typing import Any + + from textual.app import App + + from lsp_devtools.handlers.jsonrpc import JsonRPCMessage LOG_LEVELS = [logging.WARNING, logging.INFO, logging.DEBUG] @@ -8,3 +22,33 @@ def get_log_level(verbosity: int) -> int: """Get the logging level corresponding to the given verbosity""" return LOG_LEVELS[min(max(0, verbosity), len(LOG_LEVELS) - 1)] + + +class LiveSqlHandler(SqlHandler): + """A SqlHandler with a textual app reference so it can trigger a refresh when + messages are received.""" + + class MessageReceived(Message): + def __init__(self, message: JsonRPCMessage): + self.message = message + super().__init__() + + def __init__(self, dbpath: None | pathlib.Path = None, *args, **kwargs): + if dbpath is None: + # In order to have concurrent access to a SQLite db, it must be backed by a file + # https://sqlite.org/pragma.html#pragma_locking_mode + self._dbdir = tempfile.TemporaryDirectory() + dbpath = pathlib.Path(self._dbdir.name, "session.db") + + super().__init__(*args, dbpath=dbpath, **kwargs) + self.app: App[Any] | None = None + + def __del__(self): + super().__del__() + self._dbdir.cleanup() + + def handle(self, message: JsonRPCMessage): + super().handle(message) + + if self.app is not None: + self.app.post_message(self.MessageReceived(message)) diff --git a/lib/lsp-devtools/lsp_devtools/client/__init__.py b/lib/lsp-devtools/lsp_devtools/client/__init__.py index eb9f4cef..4571234e 100644 --- a/lib/lsp-devtools/lsp_devtools/client/__init__.py +++ b/lib/lsp-devtools/lsp_devtools/client/__init__.py @@ -1,184 +1,3 @@ -from __future__ import annotations +from .lsp_client import LanguageClient -import argparse -import asyncio -import logging -import os -import pathlib -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 MessageBrowser -from lsp_devtools.inspector import MessagesTable - -from .editor import EditorView -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(EditorView) - 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"), - ] - - 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 = MessageBrowser("") - messages_table = MessagesTable( - self.db, message_viewer, session=self.lsp_client.session_id - ) - - yield Header() - yield Explorer(".") - yield EditorView(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): - # Start the lsp server. - self.run_worker(self.start_lsp_server()) - - async def start_lsp_server(self): - """Initialize the lsp server session.""" - - 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()), - ) - ) - - if info := result.server_info: - name = info.name - version = info.version or "" - self.log(f"Connected to server: {name} {version}") - - 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) - 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) +__all__ = ("LanguageClient",) diff --git a/lib/lsp-devtools/lsp_devtools/client/app.css b/lib/lsp-devtools/lsp_devtools/client/app.css deleted file mode 100644 index df160f19..00000000 --- a/lib/lsp-devtools/lsp_devtools/client/app.css +++ /dev/null @@ -1,35 +0,0 @@ -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/__init__.py b/lib/lsp-devtools/lsp_devtools/client/editor/__init__.py deleted file mode 100644 index 48fa1db3..00000000 --- a/lib/lsp-devtools/lsp_devtools/client/editor/__init__.py +++ /dev/null @@ -1,72 +0,0 @@ -from __future__ import annotations - -import pathlib -import typing - -from lsprotocol import types -from textual import on -from textual.app import ComposeResult -from textual.containers import Vertical -from textual.widgets import OptionList - -from .completion import CompletionList -from .text_editor import TextEditor - -if typing.TYPE_CHECKING: - from lsp_devtools.client.lsp import LanguageClient - - -class EditorView(Vertical): - """A container to manage all the widgets that make up a single text editor.""" - - def __init__(self, lsp_client: LanguageClient, *args, **kwargs): - super().__init__(*args, **kwargs) - self.lsp_client = lsp_client - - def compose(self) -> ComposeResult: - yield TextEditor(self.lsp_client) - - def open_file(self, path: pathlib.Path): - editor = self.query_one(TextEditor) - editor.open_file(path) - editor.focus() - - def on_text_editor_completion(self, completion: TextEditor.Completion): - """Render textDocument/completion results.""" - candidates = CompletionList.fromresult(completion.result) - if candidates is None: - return - - editor = self.query_one(TextEditor) - row, col = editor.cursor_location - - gutter_width = 2 # TODO: How to get actual gutter width? - first_line = 0 # TODO: How to get the first visible line number? - candidates.offset = (col + gutter_width, row - first_line + 1) - - self.mount(candidates) - self.app.set_focus(candidates) - - @on(OptionList.OptionSelected) - def insert_selected_completion(self, event: CompletionList.OptionSelected): - """Insert the completion item selected by the user into the editor.""" - selected: types.CompletionItem = event.option.prompt # type: ignore - event.option_list.action_dismiss() # type: ignore - - editor = self.query_one(TextEditor) - if (edit := selected.text_edit) is not None: - # TODO: Support InsertReplaceEdit - if isinstance(edit, types.InsertReplaceEdit): - return - - # TextEdit support. - start = edit.range.start.line, edit.range.start.character - end = edit.range.end.line, edit.range.end.character - - with editor.set_state(suppress_completion=True): - editor.replace( - edit.new_text, start, end, maintain_selection_offset=False - ) - - # TODO: Support insert_text - # TODO: Fallback to label diff --git a/lib/lsp-devtools/lsp_devtools/client/editor/completion.py b/lib/lsp-devtools/lsp_devtools/client/editor/completion.py deleted file mode 100644 index 53631d9d..00000000 --- a/lib/lsp-devtools/lsp_devtools/client/editor/completion.py +++ /dev/null @@ -1,57 +0,0 @@ -from lsprotocol import types -from textual import events -from textual.binding import Binding -from textual.widgets import OptionList - - -class CompletionList(OptionList): - BINDINGS = [ - Binding("escape", "dismiss", "Dismiss", show=False), - ] - - @classmethod - def fromresult(cls, result): - """Build a list of completion candidates based on a response from the - language server.""" - - if result is None: - return None - - if isinstance(result, types.CompletionList): - items = result.items - else: - items = result - - if len(items) == 0: - return None - - candidates = cls() - candidates.add_options( - sorted( - [CompletionItem(i) for i in items], - key=lambda i: i.item.label, # type: ignore - ) - ) - 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 CompletionItem: - """Renders a completion item for display in a completion list.""" - - def __init__(self, item: types.CompletionItem): - self.item = item - - def __rich__(self): - # TODO: Make pretty - return self.item.label - - def __getattr__(self, key): - return getattr(self.item, key) diff --git a/lib/lsp-devtools/lsp_devtools/client/editor/text_editor.py b/lib/lsp-devtools/lsp_devtools/client/editor/text_editor.py deleted file mode 100644 index 38c32fff..00000000 --- a/lib/lsp-devtools/lsp_devtools/client/editor/text_editor.py +++ /dev/null @@ -1,142 +0,0 @@ -from __future__ import annotations - -import contextlib -import pathlib -import typing -from typing import Union - -from lsprotocol import types -from pygls import uris as uri -from pygls.capabilities import get_capability -from textual.message import Message -from textual.widgets import TextArea - -if typing.TYPE_CHECKING: - from lsp_devtools.client.lsp import LanguageClient - -CompletionResult = Union[list[types.CompletionItem], types.CompletionList, None] - - -# TODO: Refactor to -# - emit relevent events. -# - split handlers out into multiple features that can listen and respond -# to these events.. -class TextEditor(TextArea): - """A wrapper around textual's ``TextArea`` widget.""" - - class Completion(Message): - """Emitted when completion results are received.""" - - def __init__(self, result: CompletionResult): - self.result = result - super().__init__() - - def __init__(self, lsp_client: LanguageClient, *args, **kwargs): - super().__init__(*args, **kwargs) - self.uri = None - self.version = 0 - - self.lsp_client = lsp_client - self.suppress_completion = False - - @contextlib.contextmanager - def set_state(self, **kwargs): - """Temporarily override a value on the editor.""" - old_values = {} - for key, value in kwargs.items(): - old_values[key] = getattr(self, key) - setattr(self, key, value) - - yield - - for key, value in old_values.items(): - setattr(self, key, value) - - @property - def completion_triggers(self): - """Return the completion trigger characters registered by the server.""" - return get_capability( - self.lsp_client.server_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): - """Extend the base ``edit()`` method to. - - - Ensure that any edits that are made to the document are syncronised with the - server. - - Completions are triggered if necessary. - """ - 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.TextDocumentContentChangePartial( - 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 not self.suppress_completion and char in self.completion_triggers: - # TODO: How to send $/cancelRequest if a worker is cancelled? - self.run_worker( - self.trigger_completion(end_line, end_col), - group="lsp-completion", - exclusive=True, - ) - - async def trigger_completion(self, line: int, character: int): - """Trigger completion at the given location.""" - - if self.uri is None: - return - - result = await self.lsp_client.text_document_completion_async( - types.CompletionParams( - text_document=types.TextDocumentIdentifier(uri=self.uri), - position=types.Position(line=line, character=character), - ) - ) - - self.post_message(self.Completion(result)) diff --git a/lib/lsp-devtools/lsp_devtools/client/lsp.py b/lib/lsp-devtools/lsp_devtools/client/lsp.py deleted file mode 100644 index 2809f19f..00000000 --- a/lib/lsp-devtools/lsp_devtools/client/lsp.py +++ /dev/null @@ -1,74 +0,0 @@ -import importlib.metadata -import json -from datetime import datetime -from datetime import timezone -from uuid import uuid4 - -from lsprotocol import types -from pygls.lsp.client import BaseLanguageClient -from pygls.protocol import LanguageServerProtocol - -from lsp_devtools.agent import logger - -UTC = timezone.utc -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) - self.session_id = "" - - def _procedure_handler(self, message): - logger.info( - "%s", - json.dumps(message, default=self._serialize_message), - extra={ - "Message-Source": "server", - "Message-Session": self.session_id, - "Message-Timestamp": datetime.now(tz=UTC).isoformat(), - }, - ) - return super()._procedure_handler(message) - - def _send_data(self, data): - logger.info( - "%s", - json.dumps(data, default=self._serialize_message), - extra={ - "Message-Source": "client", - "Message-Session": self.session_id, - "Message-Timestamp": datetime.now(tz=UTC).isoformat(), - }, - ) - 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) - - self.session_id = str(uuid4()) - self.protocol.session_id = self.session_id # type: ignore[attr-defined] - self._server_capabilities: types.ServerCapabilities | None = None - - @property - def server_capabilities(self) -> types.ServerCapabilities: - if self._server_capabilities is None: - raise RuntimeError( - "sever_capabilities is None - has the server been initialized?" - ) - - return self._server_capabilities - - async def initialize_async( - self, params: types.InitializeParams - ) -> types.InitializeResult: - result = await super().initialize_async(params) - self._server_capabilities = result.capabilities - - return result diff --git a/lib/lsp-devtools/lsp_devtools/client/lsp_client.py b/lib/lsp-devtools/lsp_devtools/client/lsp_client.py new file mode 100644 index 00000000..a54664a9 --- /dev/null +++ b/lib/lsp-devtools/lsp_devtools/client/lsp_client.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +import asyncio +import typing + +from pygls.lsp.client import LanguageClient as BaseLanguageClient + +from lsp_devtools.agent.agent import MessageSource +from lsp_devtools.handlers.jsonrpc import JsonRPCHandler + +if typing.TYPE_CHECKING: + from collections.abc import Awaitable + + from pygls.protocol import JsonRPCProtocol + + from lsp_devtools.handlers.jsonrpc import JsonRPCMessage + + +class MessageWriter: + """A writer compatible with pygls' AsyncWriter interface.""" + + def __init__(self, handler: JsonRPCHandler, dest: asyncio.StreamWriter): + self.handler = handler + self.dest = dest + + def close(self) -> Awaitable[None]: + self.dest.close() + return self.dest.wait_closed() + + def write(self, data: bytes) -> Awaitable[None]: + self.handler.feed(data, MessageSource.CLIENT) + self.dest.write(data) + return self.dest.drain() + + +class MessageHandler(JsonRPCHandler): + """A message handler that forwards messages to both pygls and the given message handler.""" + + def __init__(self, handler: JsonRPCHandler, protocol: JsonRPCProtocol): + super().__init__() + self.handler: JsonRPCHandler = handler + self.protocol: JsonRPCProtocol = protocol + + def handle(self, message: JsonRPCMessage): + self.handler.handle(message) + + typed_message = self.protocol.structure_message(message.body) + self.protocol.handle_message(typed_message) + + +class LanguageClient(BaseLanguageClient): + """A modified version of pygls' built-in language client that integrates with our + message handling infrastructure.""" + + def __init__(self, handler: JsonRPCHandler, *args, **kwargs): + super().__init__(*args, **kwargs) + self.handler: JsonRPCHandler = handler + + async def start_io(self, cmd: str, *args, **kwargs): + """Start the given server and communicate with it over stdio. + + This overrides the version of this method upstream so that bytes are pushed + through the given JsonRPCHandler. + """ + + # logger.debug("Starting server process: %s", " ".join([cmd, *args])) + server = await asyncio.create_subprocess_exec( + cmd, + *args, + stdout=asyncio.subprocess.PIPE, + stdin=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + **kwargs, + ) + + # Keep mypy happy + if server.stdout is None: + raise RuntimeError("Server process is missing a stdout stream") + + # Keep mypy happy + if server.stdin is None: + raise RuntimeError("Server process is missing a stdin stream") + + self.protocol.set_writer(MessageWriter(self.handler, server.stdin)) + connection = asyncio.create_task( + self.connect_streams( + source=server.stdout, + dest=MessageHandler(self.handler, self.protocol), + origin=MessageSource.SERVER, + ) + ) + notify_exit = asyncio.create_task(self._server_exit()) + + self._server = server + self._async_tasks.extend([connection, notify_exit]) + + async def connect_streams( + self, source: asyncio.StreamReader, dest: MessageHandler, origin: MessageSource + ): + """Forward bytes from the source to the destination, while simultaneously + passing them to the handler function""" + + while (data := await source.read(1024)) != b"": + dest.feed(data, origin) diff --git a/lib/lsp-devtools/lsp_devtools/editor/__init__.py b/lib/lsp-devtools/lsp_devtools/editor/__init__.py new file mode 100644 index 00000000..4f77d0b9 --- /dev/null +++ b/lib/lsp-devtools/lsp_devtools/editor/__init__.py @@ -0,0 +1,3 @@ +from .view import TextEditorView + +__all__ = ("TextEditorView",) diff --git a/lib/lsp-devtools/lsp_devtools/editor/view.py b/lib/lsp-devtools/lsp_devtools/editor/view.py new file mode 100644 index 00000000..8d95f5ce --- /dev/null +++ b/lib/lsp-devtools/lsp_devtools/editor/view.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +import typing + +from textual.containers import Container +from textual.widgets import TextArea + +if typing.TYPE_CHECKING: + from textual.app import ComposeResult + + +class TextEditorView(Container): + """A view containing a text editor""" + + def compose(self) -> ComposeResult: + text_area = TextArea() + text_area.show_line_numbers = True + yield text_area diff --git a/lib/lsp-devtools/lsp_devtools/inspector/__init__.py b/lib/lsp-devtools/lsp_devtools/inspector/__init__.py index 8ee602c1..e69de29b 100644 --- a/lib/lsp-devtools/lsp_devtools/inspector/__init__.py +++ b/lib/lsp-devtools/lsp_devtools/inspector/__init__.py @@ -1,167 +0,0 @@ -from __future__ import annotations - -import argparse -import pathlib -import tempfile -import typing - -from textual import on -from textual.app import App -from textual.app import ComposeResult -from textual.events import Ready -from textual.message import Message -from textual.widgets import DataTable -from textual.widgets import Footer -from textual.widgets import Header - -from lsp_devtools.agent import AgentServer -from lsp_devtools.agent import MessageSource -from lsp_devtools.handlers.jsonrpc import JsonRPCMessage -from lsp_devtools.handlers.sql import SqlHandler - -from .message_browser import MessageBrowser - - -class MessageReceived(Message): - def __init__(self, message: JsonRPCMessage): - self.message = message - super().__init__() - - -@typing.final -class LSPInspector(App): - """A textual app for inspecting an LSP session, either a live one or one that has - been pre-recorded.""" - - BINDINGS = [ - ("ctrl+c", "quit", "Quit"), - ] - - DEFAULT_CSS = """ - MessageBrowser { - height: 1fr; - } - """ - - def __init__( - self, - db: SqlHandler, - server: AgentServer | None = None, - *args, - **kwargs, - ): - super().__init__(*args, **kwargs) - - self.db = db - """Holds recorded messages""" - - self.server = server - """If set, expect to receive a live connection from the lsp-devtools agent.""" - - def compose(self) -> ComposeResult: - yield Header() - yield MessageBrowser() - - yield Footer() - - @on(MessageReceived) - def on_message_received(self, event: MessageReceived): - browser = self.query_one(MessageBrowser) - browser.reload(follow=True) - - async def on_ready(self, event: Ready): - browser = self.query_one(MessageBrowser) - browser.reload() - - table = browser.query_one(DataTable) - table.focus() - - if self.server is not None: - self.run_worker(self.server.start_tcp(), name="lsp-connection", thread=True) - - async def action_quit(self): - if self.server is not None: - self.server.stop() - await super().action_quit() - - -class LiveSqlHandler(SqlHandler): - """A SqlHandler with a textual app reference so it can trigger a refresh when - messages are received.""" - - def __init__(self, dbpath: None | pathlib.Path = None, *args, **kwargs): - if dbpath is None: - # In order to have concurrent access to a SQLite db, it must be backed by a file - # https://sqlite.org/pragma.html#pragma_locking_mode - self._dbdir = tempfile.TemporaryDirectory() - dbpath = pathlib.Path(self._dbdir.name, "session.db") - - super().__init__(*args, dbpath=dbpath, **kwargs) - self.app: App | None = None - - def __del__(self): - super().__del__() - self._dbdir.cleanup() - - def handle(self, message: JsonRPCMessage): - super().handle(message) - - if self.app is not None: - self.app.post_message(MessageReceived(message)) - - -def inspector(args, extra: list[str]): - server = None - - if args.session is not None: - sql_handler = SqlHandler(dbpath=args.session) - app = LSPInspector(db=sql_handler) - - # Assume a live connection - else: - sql_handler = LiveSqlHandler() - server = AgentServer( - handlers={ - MessageSource.CLIENT: sql_handler, - MessageSource.SERVER: sql_handler, - } - ) - app = LSPInspector(server=server, db=sql_handler) - sql_handler.app = app - - app.run() - - -def cli(commands: argparse._SubParsersAction): - cmd: argparse.ArgumentParser = commands.add_parser( - "inspect", - help="launch an interactive LSP session inspector", - description="""\ -This command opens a text user interface that can be used to inspect a LSP session -interactively. -""", - ) - - cmd.add_argument( - "session", - nargs="?", - default=None, - type=pathlib.Path, - metavar="DB", - help="inspect the pre-recorded session in the given SQLite DB", - ) - - connect = cmd.add_argument_group( - title="connection options", - description="options that control the connection to the LSP Agent.", - ) - connect.add_argument( - "--host", - type=str, - default="localhost", - help="the host that is hosting the agent.", - ) - connect.add_argument( - "-p", "--port", type=int, default=8765, help="the port to connect to." - ) - cmd.set_defaults(run=inspector) diff --git a/lib/lsp-devtools/lsp_devtools/record/__init__.py b/lib/lsp-devtools/lsp_devtools/record/__init__.py index e7014909..e69de29b 100644 --- a/lib/lsp-devtools/lsp_devtools/record/__init__.py +++ b/lib/lsp-devtools/lsp_devtools/record/__init__.py @@ -1,170 +0,0 @@ -from __future__ import annotations - -import argparse -import pathlib - -from .raw import record_raw -from .rpc import record_rpc - - -def start_recording(args, extra: list[str] | None): - if args.capture_raw: - return record_raw(args) - else: - return record_rpc(args) - - -def setup_filter_args(cmd: argparse.ArgumentParser): - """Add arguments that can be used to filter messages.""" - - filter_ = cmd.add_argument_group( - title="filter options", - description=( - "select which messages to record, mutliple options will be ANDed together. " - "Does not apply to raw message capture" - ), - ) - filter_.add_argument( - "--message-source", - default="both", - choices=["client", "server", "both"], - help="only include messages from the given source", - ) - filter_.add_argument( - "--include-message-type", - action="append", - default=[], - dest="include_message_types", - choices=["request", "response", "result", "error", "notification"], - help="only include the given message type(s)", - ) - filter_.add_argument( - "--exclude-message-type", - action="append", - dest="exclude_message_types", - default=[], - choices=["request", "response", "result", "error", "notification"], - help="omit the given message type(s)", - ) - filter_.add_argument( - "--include-method", - action="append", - dest="include_methods", - default=[], - metavar="METHOD", - help="only include the given messages for the given method(s)", - ) - filter_.add_argument( - "--exclude-method", - action="append", - dest="exclude_methods", - default=[], - metavar="METHOD", - help="omit messages for the given method(s)", - ) - - -def cli(commands: argparse._SubParsersAction): - cmd: argparse.ArgumentParser = commands.add_parser( - "record", - help="record a JSON-RPC session.", - description="""\ -Listen for a connection from the lsp-devtools agent and record the traffic it captures""", - ) - - connect = cmd.add_argument_group( - title="server options", - description="how and where the server should listen for connections", - ) - connect.add_argument( - "--bind", - dest="host", - type=str, - default="localhost", - help="where to listen for connections from", - ) - connect.add_argument( - "-p", "--port", type=int, default=8765, help="the port to listen on" - ) - connect.add_argument( - "--on-disconnect", - default="continue", - choices=["continue", "exit"], - help="how should the server react to a client disconnect (default: continue)", - ) - - capture = cmd.add_mutually_exclusive_group() - capture.add_argument( - "--capture-raw", - action="store_true", - help="capture the raw data send between LSP client and server.", - ) - capture.add_argument( - "--capture-rpc", - default=True, - action="store_true", - help="capture and parse the rpc messages sent between LSP client and server.", - ) - - setup_filter_args(cmd) - format_ = cmd.add_argument_group( - title="formatting options", - description=( - "control how the recorded messages are formatted " - "(does not apply to SQLite output or raw message capture)" - ), - ) - format_.add_argument( - "-f", - "--format-message", - action="append", - help=( - "format messages according to given format string, " - "can be given multiple times, in which case the first valid string will be " - "applied. " - "By default, messages which fail to format will be excluded, " - "see --keep-unformatted" - ), - ) - - format_.add_argument( - "--keep-unformatted", - action="store_true", - help=( - "preserve messages that fail to format using any supplied format string. " - "These will be rendered with the default format string" - ), - ) - - output = cmd.add_argument_group( - title="output options", - description="control where the captured messages are sent to", - ) - output.add_argument( - "--to-file", - default=None, - metavar="FILE", - type=pathlib.Path, - help="save messages to a file", - ) - output.add_argument( - "--to-sqlite", - default=None, - metavar="FILE", - type=pathlib.Path, - help="save messages to a SQLite DB", - ) - output.add_argument( - "--save-output", - default=None, - metavar="DEST", - type=pathlib.Path, - help=( - "only applies when printing messages to the console. " - "This makes use of the rich.Console's export feature to save its output in " - "HTML, SVG or plain text format. The format used will be picked " - "automatically based on the desintation's file extension." - ), - ) - - cmd.set_defaults(run=start_recording)