diff --git a/lsp_client/__init__.py b/lsp_client/__init__.py index ca6af74..e05ba96 100644 --- a/lsp_client/__init__.py +++ b/lsp_client/__init__.py @@ -16,6 +16,7 @@ InitializeRequest, InitializedNotification, LanguageKind, + LSPErrorCodes, Message, NotificationMessage, Position, @@ -69,6 +70,7 @@ "InitializeRequest", "InitializedNotification", "LSPClient", + "LSPErrorCodes", "LanguageKind", "Message", "NotificationMessage", diff --git a/lsp_client/protocol.py b/lsp_client/protocol.py index 1a439d0..3cf98b9 100644 --- a/lsp_client/protocol.py +++ b/lsp_client/protocol.py @@ -8,7 +8,7 @@ from enum import Enum, IntEnum from typing import Annotated, Any, List, Literal, Optional -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, model_validator # Position Encoding @@ -60,7 +60,12 @@ class BaseRequest(Message): class ResponseError(BaseModel): - """The error object returned on a failed request.""" + """The error object returned on a failed request. + + ``code`` is a number indicating the error type (see :class:`ErrorCodes` and + :class:`LSPErrorCodes`), ``message`` a short human-readable description, and + ``data`` an optional primitive or structured value with extra detail. + """ code: int message: str @@ -71,45 +76,75 @@ 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. + parse error). Per the spec ``result`` is required on success and must not be + present on error, so a response carries at most one of ``result`` / ``error``. """ id: int | str | None = None result: Any | None = None error: ResponseError | None = None + @model_validator(mode="after") + def _check_result_xor_error(self) -> "ResponseMessage": + if self.result is not None and self.error is not None: + raise ValueError( + "a ResponseMessage must not carry both 'result' and 'error'" + ) + return self + class ErrorCodes(IntEnum): - """JSON-RPC and LSP-defined error codes. + """Error codes defined by JSON-RPC. Codes that share a value (e.g. ``serverErrorStart`` / - ``jsonrpcReservedErrorRangeStart``) resolve to enum aliases. + ``jsonrpcReservedErrorRangeStart``) resolve to enum aliases. LSP-defined + codes live in their own range; see :class:`LSPErrorCodes`. """ - # Defined by JSON-RPC ParseError = -32700 InvalidRequest = -32600 MethodNotFound = -32601 InvalidParams = -32602 InternalError = -32603 + # Start range of JSON-RPC reserved error codes. Does not denote a real + # error code. ``ServerNotInitialized`` / ``UnknownErrorCode`` are kept in + # this range for backwards compatibility. @since 3.16.0 jsonrpcReservedErrorRangeStart = -32099 + #: @deprecated use ``jsonrpcReservedErrorRangeStart`` serverErrorStart = -32099 ServerNotInitialized = -32002 UnknownErrorCode = -32001 + # End range of JSON-RPC reserved error codes. Does not denote a real error + # code. @since 3.16.0 jsonrpcReservedErrorRangeEnd = -32000 + #: @deprecated use ``jsonrpcReservedErrorRangeEnd`` serverErrorEnd = -32000 - # Defined by LSP + +class LSPErrorCodes(IntEnum): + """Error codes defined by the Language Server Protocol itself.""" + + # Start range of LSP reserved error codes. Does not denote a real error + # code. @since 3.16.0 lspReservedErrorRangeStart = -32899 + #: A request failed but was syntactically correct (known method, valid + #: params); the message should explain why. @since 3.17.0 RequestFailed = -32803 + #: The server cancelled the request; only for explicitly server-cancellable + #: requests. @since 3.17.0 ServerCancelled = -32802 + #: The document content was modified outside normal conditions, so the + #: result may be stale. ContentModified = -32801 + #: The client cancelled a request and the server detected the cancellation. RequestCancelled = -32800 + # End range of LSP reserved error codes. Does not denote a real error code. + # @since 3.16.0 (aliases ``RequestCancelled``) lspReservedErrorRangeEnd = -32800 diff --git a/tests/test_protocol.py b/tests/test_protocol.py index 9e7944c..9b76dbb 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -18,6 +18,7 @@ InitializeRequest, InitializedNotification, LanguageKind, + LSPErrorCodes, Message, NotificationMessage, Position, @@ -426,10 +427,52 @@ def test_server_capabilities_position_encoding_optional(): def test_error_codes_values(): + # ErrorCodes carries only the JSON-RPC defined codes and reserved markers. assert ErrorCodes.ParseError == -32700 assert ErrorCodes.InvalidRequest == -32600 + assert ErrorCodes.MethodNotFound == -32601 + assert ErrorCodes.InvalidParams == -32602 assert ErrorCodes.InternalError == -32603 - assert ErrorCodes.RequestCancelled == -32800 - assert ErrorCodes.ContentModified == -32801 + assert ErrorCodes.ServerNotInitialized == -32002 + assert ErrorCodes.UnknownErrorCode == -32001 # Shared-value codes resolve to aliases of the canonical member. assert ErrorCodes.serverErrorStart is ErrorCodes.jsonrpcReservedErrorRangeStart + assert ErrorCodes.serverErrorEnd is ErrorCodes.jsonrpcReservedErrorRangeEnd + + +def test_lsp_error_codes_values(): + # LSP-defined codes live in their own enum, separate from JSON-RPC codes. + assert LSPErrorCodes.RequestFailed == -32803 + assert LSPErrorCodes.ServerCancelled == -32802 + assert LSPErrorCodes.ContentModified == -32801 + assert LSPErrorCodes.RequestCancelled == -32800 + assert LSPErrorCodes.lspReservedErrorRangeStart == -32899 + # The reserved range end aliases RequestCancelled (shared value). + assert LSPErrorCodes.lspReservedErrorRangeEnd is LSPErrorCodes.RequestCancelled + + +def test_error_codes_namespaces_are_separate(): + # JSON-RPC and LSP codes are distinct enums per the spec. + assert not hasattr(ErrorCodes, "RequestCancelled") + assert not hasattr(LSPErrorCodes, "ParseError") + + +def test_response_error_with_lsp_code(): + err = ResponseError( + code=LSPErrorCodes.RequestFailed, message="boom", data={"detail": 1} + ) + assert err.model_dump() == { + "code": -32803, + "message": "boom", + "data": {"detail": 1}, + } + + +def test_response_message_rejects_result_and_error(): + # The spec forbids carrying both a result and an error. + with pytest.raises(ValidationError): + ResponseMessage( + id=1, + result={"ok": True}, + error=ResponseError(code=ErrorCodes.InternalError, message="bad"), + )