diff --git a/lsp_client/__init__.py b/lsp_client/__init__.py index e05ba96..f69be8d 100644 --- a/lsp_client/__init__.py +++ b/lsp_client/__init__.py @@ -5,9 +5,14 @@ CancelRequest, ClientCapabilities, ClientInfo, + CodeDescription, CompletionRequest, ContentChange, DefinitionRequest, + Diagnostic, + DiagnosticRelatedInformation, + DiagnosticSeverity, + DiagnosticTag, ErrorCodes, ExitNotification, GeneralClientCapabilities, @@ -16,6 +21,7 @@ InitializeRequest, InitializedNotification, LanguageKind, + Location, LSPErrorCodes, Message, NotificationMessage, @@ -59,9 +65,14 @@ "CancelRequest", "ClientCapabilities", "ClientInfo", + "CodeDescription", "CompletionRequest", "ContentChange", "DefinitionRequest", + "Diagnostic", + "DiagnosticRelatedInformation", + "DiagnosticSeverity", + "DiagnosticTag", "ErrorCodes", "ExitNotification", "GeneralClientCapabilities", @@ -72,6 +83,7 @@ "LSPClient", "LSPErrorCodes", "LanguageKind", + "Location", "Message", "NotificationMessage", "PositionEncodingKind", diff --git a/lsp_client/protocol.py b/lsp_client/protocol.py index 251196a..5d392cb 100644 --- a/lsp_client/protocol.py +++ b/lsp_client/protocol.py @@ -432,6 +432,85 @@ class Range(BaseModel): end: Position +# Location +# See https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#location # noqa: E501 + + +class Location(BaseModel): + """A range inside a text document, identified by its URI.""" + + uri: str + range: Range + + +# Diagnostic +# See https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#diagnostic # noqa: E501 + + +class DiagnosticSeverity(IntEnum): + """How severe a diagnostic is.""" + + Error = 1 + Warning = 2 + Information = 3 + Hint = 4 + + +class DiagnosticTag(IntEnum): + """Additional metadata about a diagnostic. @since 3.15.0""" + + #: Unused or unnecessary code — clients may render this faded out. + Unnecessary = 1 + #: Deprecated or obsolete code — clients may render this struck through. + Deprecated = 2 + + +class CodeDescription(BaseModel): + """A structure describing a diagnostic's error code. @since 3.16.0""" + + #: A URI to open with more information about the diagnostic error. + href: str + + +class DiagnosticRelatedInformation(BaseModel): + """A related message and source location for a diagnostic. + + Used e.g. when symbol names within a scope collide, to point at every + colliding definition. + """ + + location: Location + message: str + + +class Diagnostic(BaseModel): + """A diagnostic, such as a compiler error or warning. + + Diagnostic objects are only valid in the scope of a resource. + """ + + #: The range at which the message applies. + range: Range + #: The diagnostic's severity. If omitted the client interprets it. + severity: DiagnosticSeverity | None = None + #: The diagnostic's code, which may appear in the user interface. + code: int | str | None = None + #: An optional structure describing the error code. @since 3.16.0 + codeDescription: CodeDescription | None = None + #: A human-readable description of the diagnostic's source, e.g. + #: ``"typescript"`` or ``"super lint"``. + source: str | None = None + #: The diagnostic's message. + message: str + #: Additional metadata about the diagnostic. @since 3.15.0 + tags: list[DiagnosticTag] | None = None + #: An array of related diagnostic information. + relatedInformation: list[DiagnosticRelatedInformation] | None = None + #: A data entry preserved between a ``textDocument/publishDiagnostics`` + #: notification and a ``textDocument/codeAction`` request. @since 3.16.0 + data: Any | None = None + + class ContentChange(BaseModel): text: str range: Optional[Range] = None diff --git a/tests/test_protocol.py b/tests/test_protocol.py index f927047..03a0c0a 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -7,9 +7,14 @@ CancelRequest, ClientCapabilities, ClientInfo, + CodeDescription, CompletionRequest, ContentChange, DefinitionRequest, + Diagnostic, + DiagnosticRelatedInformation, + DiagnosticSeverity, + DiagnosticTag, ErrorCodes, ExitNotification, GeneralClientCapabilities, @@ -18,6 +23,7 @@ InitializeRequest, InitializedNotification, LanguageKind, + Location, LSPErrorCodes, Message, NotificationMessage, @@ -456,6 +462,79 @@ def test_server_capabilities_position_encoding_optional(): assert ServerCapabilities().model_dump(exclude_none=True) == {} +def _range() -> Range: + return Range(start=Position(line=1, character=0), end=Position(line=1, character=8)) + + +def test_diagnostic_severity_values(): + assert DiagnosticSeverity.Error == 1 + assert DiagnosticSeverity.Warning == 2 + assert DiagnosticSeverity.Information == 3 + assert DiagnosticSeverity.Hint == 4 + + +def test_diagnostic_tag_values(): + assert DiagnosticTag.Unnecessary == 1 + assert DiagnosticTag.Deprecated == 2 + + +def test_location_structure(): + loc = Location(uri="file:///tmp/a.py", range=_range()) + assert loc.model_dump() == { + "uri": "file:///tmp/a.py", + "range": { + "start": {"line": 1, "character": 0}, + "end": {"line": 1, "character": 8}, + }, + } + + +def test_diagnostic_minimal(): + diag = Diagnostic(range=_range(), message="undefined name 'x'") + data = diag.model_dump(exclude_none=True) + # Only range and message are required; optional fields are omitted. + assert set(data) == {"range", "message"} + assert data["message"] == "undefined name 'x'" + + +def test_diagnostic_full(): + diag = Diagnostic( + range=_range(), + severity=DiagnosticSeverity.Warning, + code="F821", + codeDescription=CodeDescription(href="https://example.com/F821"), + source="flake8", + message="undefined name 'x'", + tags=[DiagnosticTag.Unnecessary], + relatedInformation=[ + DiagnosticRelatedInformation( + location=Location(uri="file:///tmp/b.py", range=_range()), + message="first defined here", + ) + ], + data={"fixable": True}, + ) + data = diag.model_dump(exclude_none=True) + assert data["severity"] == 2 + assert data["code"] == "F821" + assert data["codeDescription"] == {"href": "https://example.com/F821"} + assert data["source"] == "flake8" + assert data["tags"] == [1] + assert data["relatedInformation"][0]["location"]["uri"] == "file:///tmp/b.py" + assert data["relatedInformation"][0]["message"] == "first defined here" + assert data["data"] == {"fixable": True} + + +def test_diagnostic_integer_code(): + diag = Diagnostic(range=_range(), message="boom", code=42) + assert diag.model_dump(exclude_none=True)["code"] == 42 + + +def test_diagnostic_rejects_invalid_severity(): + with pytest.raises(ValidationError): + Diagnostic(range=_range(), message="boom", severity=5) + + def test_error_codes_values(): # ErrorCodes carries only the JSON-RPC defined codes and reserved markers. assert ErrorCodes.ParseError == -32700