From 49380fade0c1cac59261e6f8bb77235aa47b87b9 Mon Sep 17 00:00:00 2001 From: Christian Kissig Date: Wed, 3 Jun 2026 18:06:53 +0100 Subject: [PATCH] Add position encoding negotiation support LSP 3.17 lets the client and server negotiate how positions are encoded (what column offsets mean), rather than always assuming UTF-16. - Add PositionEncodingKind (utf-8 / utf-16 / utf-32) per the spec's base type. - Add GeneralClientCapabilities so clients can advertise supported encodings via general.positionEncodings, wired into ClientCapabilities. - Add ServerCapabilities carrying the server-chosen positionEncoding. Co-Authored-By: Claude Opus 4.8 (1M context) --- lsp_client/__init__.py | 6 ++++ lsp_client/protocol.py | 55 ++++++++++++++++++++++++++++++++++-- tests/test_protocol.py | 64 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 123 insertions(+), 2 deletions(-) diff --git a/lsp_client/__init__.py b/lsp_client/__init__.py index 37d7a0e..eabe3fb 100644 --- a/lsp_client/__init__.py +++ b/lsp_client/__init__.py @@ -10,6 +10,7 @@ DefinitionRequest, ErrorCodes, ExitNotification, + GeneralClientCapabilities, HoverRequest, InitializeParams, InitializeRequest, @@ -17,12 +18,14 @@ Message, NotificationMessage, Position, + PositionEncodingKind, ProgressNotification, ProgressParams, Range, RequestMessage, ResponseError, ResponseMessage, + ServerCapabilities, ShutdownRequest, TextDocumentDidChangeNotification, TextDocumentDidCloseNotification, @@ -59,6 +62,7 @@ "DefinitionRequest", "ErrorCodes", "ExitNotification", + "GeneralClientCapabilities", "HoverRequest", "InitializeParams", "InitializeRequest", @@ -66,11 +70,13 @@ "LSPClient", "Message", "NotificationMessage", + "PositionEncodingKind", "ProgressNotification", "ProgressParams", "RequestMessage", "ResponseError", "ResponseMessage", + "ServerCapabilities", "ShutdownRequest", "TextDocumentDidChangeNotification", "TextDocumentDidCloseNotification", diff --git a/lsp_client/protocol.py b/lsp_client/protocol.py index e3e04dc..9089844 100644 --- a/lsp_client/protocol.py +++ b/lsp_client/protocol.py @@ -5,12 +5,37 @@ for reference, and what a correct and complete implementation should look like. """ -from enum import IntEnum +from enum import Enum, IntEnum from typing import Any, List, Literal, Optional from pydantic import BaseModel, Field +# Position Encoding +# See https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocuments # noqa: E501 + + +class PositionEncodingKind(str, Enum): + """How positions are encoded — i.e. what column offsets mean. + + Prior to 3.17 offsets were always based on a UTF-16 string representation. + Since 3.17 client and server can negotiate a different encoding via the + ``general.positionEncodings`` client capability and the ``positionEncoding`` + server capability. + + @since 3.17.0 + """ + + #: Character offsets count UTF-8 code units (e.g. bytes). + UTF8 = "utf-8" + #: Character offsets count UTF-16 code units. This is the default and must + #: always be supported by servers. + UTF16 = "utf-16" + #: Character offsets count UTF-32 code units. These are the same as Unicode + #: code points, so this may also be used for an encoding-agnostic offset. + UTF32 = "utf-32" + + # Base Protocol — abstract Message # See https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#abstractMessage # noqa: E501 @@ -114,15 +139,41 @@ class ClientInfo(BaseModel): version: str | None = None +class GeneralClientCapabilities(BaseModel): + """General client capabilities — capabilities not tied to a single feature. + + @since 3.16.0 + """ + + staleRequestSupport: dict | None = None + regularExpressions: dict | None = None + markdown: dict | None = None + #: The position encodings supported by the client, in order of decreasing + #: preference. The server picks the first one it also supports; if none + #: match it must default to ``utf-16``. @since 3.17.0 + positionEncodings: list[PositionEncodingKind] | None = None + + class ClientCapabilities(BaseModel): workspace: dict | None = None textDocument: dict | None = None notebook: dict | None = None window: dict | None = None - general: dict | None = None + general: GeneralClientCapabilities | None = None experimental: dict | None = None +class ServerCapabilities(BaseModel): + """Subset of server capabilities relevant to position encoding negotiation. + + Returned by the server in the ``InitializeResult``. + """ + + #: The position encoding the server picked from the client's advertised + #: ``positionEncodings``. If absent, ``utf-16`` is assumed. @since 3.17.0 + positionEncoding: PositionEncodingKind | None = None + + class TextDocumentClientCapabilities(BaseModel): synchronization: dict | None completion: dict | None diff --git a/tests/test_protocol.py b/tests/test_protocol.py index 4e1975f..d4ce644 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -2,12 +2,14 @@ BaseNotification, BaseRequest, CancelRequest, + ClientCapabilities, ClientInfo, CompletionRequest, ContentChange, DefinitionRequest, ErrorCodes, ExitNotification, + GeneralClientCapabilities, HoverRequest, InitializeParams, InitializeRequest, @@ -15,11 +17,13 @@ Message, NotificationMessage, Position, + PositionEncodingKind, ProgressNotification, Range, RequestMessage, ResponseError, ResponseMessage, + ServerCapabilities, ShutdownRequest, TextDocumentDidChangeNotification, TextDocumentDidCloseNotification, @@ -257,6 +261,66 @@ def test_response_message_null_id(): assert data["id"] is None +def test_position_encoding_kind_values(): + assert PositionEncodingKind.UTF8 == "utf-8" + assert PositionEncodingKind.UTF16 == "utf-16" + assert PositionEncodingKind.UTF32 == "utf-32" + + +def test_general_client_capabilities_position_encodings(): + general = GeneralClientCapabilities( + positionEncodings=[ + PositionEncodingKind.UTF8, + PositionEncodingKind.UTF16, + ] + ) + data = general.model_dump(exclude_none=True) + assert data == {"positionEncodings": ["utf-8", "utf-16"]} + # Unset general sub-capabilities are excluded. + assert "markdown" not in data + + +def test_client_capabilities_advertise_position_encodings(): + caps = ClientCapabilities( + general=GeneralClientCapabilities( + positionEncodings=[PositionEncodingKind.UTF32] + ) + ) + data = caps.model_dump(exclude_none=True) + assert data == {"general": {"positionEncodings": ["utf-32"]}} + + +def test_initialize_request_with_position_encodings(): + params = InitializeParams( + rootUri="file:///tmp", + capabilities=ClientCapabilities( + general=GeneralClientCapabilities( + positionEncodings=[ + PositionEncodingKind.UTF8, + PositionEncodingKind.UTF16, + ] + ) + ), + ) + request = InitializeRequest(id=1, params=params) + data = request.model_dump(exclude_none=True) + assert data["params"]["capabilities"]["general"]["positionEncodings"] == [ + "utf-8", + "utf-16", + ] + + +def test_server_capabilities_position_encoding(): + caps = ServerCapabilities(positionEncoding=PositionEncodingKind.UTF8) + data = caps.model_dump(exclude_none=True) + assert data == {"positionEncoding": "utf-8"} + + +def test_server_capabilities_position_encoding_optional(): + # Absent positionEncoding means utf-16 is assumed; nothing is serialised. + assert ServerCapabilities().model_dump(exclude_none=True) == {} + + def test_error_codes_values(): assert ErrorCodes.ParseError == -32700 assert ErrorCodes.InvalidRequest == -32600