From 055f109eb23719d8e6a49e50435f21b38ce1338e Mon Sep 17 00:00:00 2001 From: Christian Kissig Date: Wed, 3 Jun 2026 17:58:46 +0100 Subject: [PATCH] Add base protocol Message types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the LSP 3.17 base protocol abstract Message and its concrete forms: - Message — abstract base carrying `jsonrpc`; BaseRequest and BaseNotification now subclass it. - ResponseMessage / ResponseError — the response side of the protocol, with a nullable id and exclusive result/error fields. - ErrorCodes — JSON-RPC and LSP-defined error code constants (IntEnum). - RequestMessage / NotificationMessage — spec-named aliases for the existing BaseRequest / BaseNotification. Exports the new types from the package root and adds protocol tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- lsp_client/__init__.py | 12 +++++++ lsp_client/protocol.py | 75 +++++++++++++++++++++++++++++++++++++++--- tests/test_protocol.py | 59 +++++++++++++++++++++++++++++++++ 3 files changed, 142 insertions(+), 4 deletions(-) diff --git a/lsp_client/__init__.py b/lsp_client/__init__.py index cebcae7..37d7a0e 100644 --- a/lsp_client/__init__.py +++ b/lsp_client/__init__.py @@ -8,15 +8,21 @@ CompletionRequest, ContentChange, DefinitionRequest, + ErrorCodes, ExitNotification, HoverRequest, InitializeParams, InitializeRequest, InitializedNotification, + Message, + NotificationMessage, Position, ProgressNotification, ProgressParams, Range, + RequestMessage, + ResponseError, + ResponseMessage, ShutdownRequest, TextDocumentDidChangeNotification, TextDocumentDidCloseNotification, @@ -51,14 +57,20 @@ "CompletionRequest", "ContentChange", "DefinitionRequest", + "ErrorCodes", "ExitNotification", "HoverRequest", "InitializeParams", "InitializeRequest", "InitializedNotification", "LSPClient", + "Message", + "NotificationMessage", "ProgressNotification", "ProgressParams", + "RequestMessage", + "ResponseError", + "ResponseMessage", "ShutdownRequest", "TextDocumentDidChangeNotification", "TextDocumentDidCloseNotification", diff --git a/lsp_client/protocol.py b/lsp_client/protocol.py index 86c4f21..e3e04dc 100644 --- a/lsp_client/protocol.py +++ b/lsp_client/protocol.py @@ -5,26 +5,89 @@ for reference, and what a correct and complete implementation should look like. """ +from enum import IntEnum from typing import Any, List, Literal, Optional from pydantic import BaseModel, Field -class BaseNotification(BaseModel): - """LSP notification — like a request but without an id field.""" +# Base Protocol — abstract Message +# See https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#abstractMessage # noqa: E501 + + +class Message(BaseModel): + """Base of every LSP message; carries only the JSON-RPC version.""" jsonrpc: str = Field(default="2.0") + + +class BaseNotification(Message): + """LSP notification — like a request but without an id field.""" + method: str params: dict | None = Field(default=None) -class BaseRequest(BaseModel): - jsonrpc: str = Field(default="2.0") +class BaseRequest(Message): id: int | None = Field(default=None) method: str params: dict | None = Field(default=None) +class ResponseError(BaseModel): + """The error object returned on a failed request.""" + + code: int + message: str + data: Any | None = None + + +class ResponseMessage(Message): + """A response to a request. + + ``id`` may be ``null`` when the request id could not be determined (e.g. a + parse error). Exactly one of ``result`` / ``error`` is present per the spec. + """ + + id: int | str | None = None + result: Any | None = None + error: ResponseError | None = None + + +class ErrorCodes(IntEnum): + """JSON-RPC and LSP-defined error codes. + + Codes that share a value (e.g. ``serverErrorStart`` / + ``jsonrpcReservedErrorRangeStart``) resolve to enum aliases. + """ + + # Defined by JSON-RPC + ParseError = -32700 + InvalidRequest = -32600 + MethodNotFound = -32601 + InvalidParams = -32602 + InternalError = -32603 + + jsonrpcReservedErrorRangeStart = -32099 + serverErrorStart = -32099 + + ServerNotInitialized = -32002 + UnknownErrorCode = -32001 + + jsonrpcReservedErrorRangeEnd = -32000 + serverErrorEnd = -32000 + + # Defined by LSP + lspReservedErrorRangeStart = -32899 + + RequestFailed = -32803 + ServerCancelled = -32802 + ContentModified = -32801 + RequestCancelled = -32800 + + lspReservedErrorRangeEnd = -32800 + + class ProtocolError(Exception): def __init__(self, message: str, errors: Optional[List[str]] = None) -> None: """ @@ -348,6 +411,10 @@ def __init__(self, **kwargs: Any) -> None: super(WorkDoneProgressCancelNotification, self).__init__(**kwargs) +# Spec-named aliases for the base protocol message types. +RequestMessage = BaseRequest +NotificationMessage = BaseNotification + # Backwards-compatible aliases for renamed classes TextDocumentDidOpenRequest = TextDocumentDidOpenNotification TextDocumentDidChangeRequest = TextDocumentDidChangeNotification diff --git a/tests/test_protocol.py b/tests/test_protocol.py index 9f5dde5..4e1975f 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -1,17 +1,25 @@ from lsp_client.protocol import ( + BaseNotification, + BaseRequest, CancelRequest, ClientInfo, CompletionRequest, ContentChange, DefinitionRequest, + ErrorCodes, ExitNotification, HoverRequest, InitializeParams, InitializeRequest, InitializedNotification, + Message, + NotificationMessage, Position, ProgressNotification, Range, + RequestMessage, + ResponseError, + ResponseMessage, ShutdownRequest, TextDocumentDidChangeNotification, TextDocumentDidCloseNotification, @@ -206,3 +214,54 @@ def test_work_done_progress_cancel_notification(): assert data["method"] == "window/workDoneProgress/cancel" assert "id" not in data assert data["params"] == {"token": 42} + + +def test_message_base_defaults(): + message = Message() + assert message.jsonrpc == "2.0" + assert message.model_dump() == {"jsonrpc": "2.0"} + + +def test_requests_and_notifications_are_messages(): + # The base protocol abstract Message is the root of every message type. + assert issubclass(BaseRequest, Message) + assert issubclass(BaseNotification, Message) + assert RequestMessage is BaseRequest + assert NotificationMessage is BaseNotification + + +def test_response_message_success(): + response = ResponseMessage(id=1, result={"capabilities": {}}) + data = response.model_dump(exclude_none=True) + assert data == {"jsonrpc": "2.0", "id": 1, "result": {"capabilities": {}}} + assert "error" not in data + + +def test_response_message_error(): + response = ResponseMessage( + id=1, + error=ResponseError(code=ErrorCodes.MethodNotFound, message="no such method"), + ) + data = response.model_dump(exclude_none=True) + assert data["error"] == {"code": -32601, "message": "no such method"} + assert "result" not in data + + +def test_response_message_null_id(): + # id may be null when it cannot be determined (e.g. a parse error). + response = ResponseMessage( + id=None, + error=ResponseError(code=ErrorCodes.ParseError, message="parse error"), + ) + data = response.model_dump() + assert data["id"] is None + + +def test_error_codes_values(): + assert ErrorCodes.ParseError == -32700 + assert ErrorCodes.InvalidRequest == -32600 + assert ErrorCodes.InternalError == -32603 + assert ErrorCodes.RequestCancelled == -32800 + assert ErrorCodes.ContentModified == -32801 + # Shared-value codes resolve to aliases of the canonical member. + assert ErrorCodes.serverErrorStart is ErrorCodes.jsonrpcReservedErrorRangeStart