diff --git a/examples/servers/publish_diagnostics.py b/examples/servers/publish_diagnostics.py new file mode 100644 index 00000000..b97454da --- /dev/null +++ b/examples/servers/publish_diagnostics.py @@ -0,0 +1,97 @@ +############################################################################ +# Copyright(c) Open Law Library. All rights reserved. # +# See ThirdPartyNotices.txt in the project root for additional notices. # +# # +# Licensed under the Apache License, Version 2.0 (the "License") # +# you may not use this file except in compliance with the License. # +# You may obtain a copy of the License at # +# # +# http: // www.apache.org/licenses/LICENSE-2.0 # +# # +# Unless required by applicable law or agreed to in writing, software # +# distributed under the License is distributed on an "AS IS" BASIS, # +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # +# See the License for the specific language governing permissions and # +# limitations under the License. # +############################################################################ +import logging +import re + +from lsprotocol import types + +from pygls.server import LanguageServer +from pygls.workspace import TextDocument + +ADDITION = re.compile(r"^\s*(\d+)\s*\+\s*(\d+)\s*=\s*(\d+)?$") + + +class PublishDiagnosticServer(LanguageServer): + """Language server demonstrating "push-model" diagnostics.""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.diagnostics = {} + + def parse(self, document: TextDocument): + diagnostics = [] + + for idx, line in enumerate(document.lines): + match = ADDITION.match(line) + if match is not None: + left = int(match.group(1)) + right = int(match.group(2)) + + expected_answer = left + right + actual_answer = match.group(3) + + if actual_answer is not None and expected_answer == int(actual_answer): + continue + + if actual_answer is None: + message = "Missing answer" + severity = types.DiagnosticSeverity.Warning + else: + message = f"Incorrect answer: {actual_answer}" + severity = types.DiagnosticSeverity.Error + + diagnostics.append( + types.Diagnostic( + message=message, + severity=severity, + range=types.Range( + start=types.Position(line=idx, character=0), + end=types.Position(line=idx, character=len(line) - 1), + ), + ) + ) + + self.diagnostics[document.uri] = (document.version, diagnostics) + # logging.info("%s", self.diagnostics) + + +server = PublishDiagnosticServer("diagnostic-server", "v1") + + +@server.feature(types.TEXT_DOCUMENT_DID_OPEN) +def did_open(ls: PublishDiagnosticServer, params: types.DidOpenTextDocumentParams): + """Parse each document when it is opened""" + doc = ls.workspace.get_text_document(params.text_document.uri) + ls.parse(doc) + + for uri, (version, diagnostics) in ls.diagnostics.items(): + ls.publish_diagnostics(uri=uri, version=version, diagnostics=diagnostics) + + +@server.feature(types.TEXT_DOCUMENT_DID_CHANGE) +def did_change(ls: PublishDiagnosticServer, params: types.DidOpenTextDocumentParams): + """Parse each document when it is changed""" + doc = ls.workspace.get_text_document(params.text_document.uri) + ls.parse(doc) + + for uri, (version, diagnostics) in ls.diagnostics.items(): + ls.publish_diagnostics(uri=uri, version=version, diagnostics=diagnostics) + + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO, format="%(message)s") + server.start_io() diff --git a/examples/servers/pull_diagnostics.py b/examples/servers/pull_diagnostics.py new file mode 100644 index 00000000..0c6f1470 --- /dev/null +++ b/examples/servers/pull_diagnostics.py @@ -0,0 +1,150 @@ +############################################################################ +# Copyright(c) Open Law Library. All rights reserved. # +# See ThirdPartyNotices.txt in the project root for additional notices. # +# # +# Licensed under the Apache License, Version 2.0 (the "License") # +# you may not use this file except in compliance with the License. # +# You may obtain a copy of the License at # +# # +# http: // www.apache.org/licenses/LICENSE-2.0 # +# # +# Unless required by applicable law or agreed to in writing, software # +# distributed under the License is distributed on an "AS IS" BASIS, # +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # +# See the License for the specific language governing permissions and # +# limitations under the License. # +############################################################################ +import logging +import re + +from lsprotocol import types + +from pygls.server import LanguageServer +from pygls.workspace import TextDocument + +ADDITION = re.compile(r"^\s*(\d+)\s*\+\s*(\d+)\s*=\s*(\d+)?$") + + +class PublishDiagnosticServer(LanguageServer): + """Language server demonstrating "push-model" diagnostics.""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.diagnostics = {} + + def parse(self, document: TextDocument): + _, previous = self.diagnostics.get(document.uri, (0, [])) + diagnostics = [] + + for idx, line in enumerate(document.lines): + match = ADDITION.match(line) + if match is not None: + left = int(match.group(1)) + right = int(match.group(2)) + + expected_answer = left + right + actual_answer = match.group(3) + + if actual_answer is not None and expected_answer == int(actual_answer): + continue + + if actual_answer is None: + message = "Missing answer" + severity = types.DiagnosticSeverity.Warning + else: + message = f"Incorrect answer: {actual_answer}" + severity = types.DiagnosticSeverity.Error + + diagnostics.append( + types.Diagnostic( + message=message, + severity=severity, + range=types.Range( + start=types.Position(line=idx, character=0), + end=types.Position(line=idx, character=len(line) - 1), + ), + ) + ) + + # Only update if the list has changed + if previous != diagnostics: + self.diagnostics[document.uri] = (document.version, diagnostics) + + # logging.info("%s", self.diagnostics) + + +server = PublishDiagnosticServer("diagnostic-server", "v1") + + +@server.feature(types.TEXT_DOCUMENT_DID_OPEN) +def did_open(ls: PublishDiagnosticServer, params: types.DidOpenTextDocumentParams): + """Parse each document when it is opened""" + doc = ls.workspace.get_text_document(params.text_document.uri) + ls.parse(doc) + + +@server.feature(types.TEXT_DOCUMENT_DID_CHANGE) +def did_change(ls: PublishDiagnosticServer, params: types.DidOpenTextDocumentParams): + """Parse each document when it is changed""" + doc = ls.workspace.get_text_document(params.text_document.uri) + ls.parse(doc) + + +@server.feature( + types.TEXT_DOCUMENT_DIAGNOSTIC, + types.DiagnosticOptions( + identifier="pull-diagnostics", + inter_file_dependencies=False, + workspace_diagnostics=True, + ), +) +def document_diagnostic( + ls: PublishDiagnosticServer, params: types.DocumentDiagnosticParams +): + """Return diagnostics for the requested document""" + # logging.info("%s", params) + + if (uri := params.text_document.uri) not in ls.diagnostics: + return + + version, diagnostics = ls.diagnostics[uri] + result_id = f"{uri}@{version}" + + if result_id == params.previous_result_id: + return types.UnchangedDocumentDiagnosticReport(result_id) + + return types.FullDocumentDiagnosticReport(items=diagnostics, result_id=result_id) + + +@server.feature(types.WORKSPACE_DIAGNOSTIC) +def workspace_diagnostic( + ls: PublishDiagnosticServer, params: types.WorkspaceDiagnosticParams +): + """Return diagnostics for the workspace.""" + # logging.info("%s", params) + items = [] + previous_ids = {result.value for result in params.previous_result_ids} + + for uri, (version, diagnostics) in ls.diagnostics.items(): + result_id = f"{uri}@{version}" + if result_id in previous_ids: + items.append( + types.WorkspaceUnchangedDocumentDiagnosticReport( + uri=uri, result_id=result_id, version=version + ) + ) + else: + items.append( + types.WorkspaceFullDocumentDiagnosticReport( + uri=uri, + version=version, + items=diagnostics, + ) + ) + + return types.WorkspaceDiagnosticReport(items=items) + + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO, format="%(message)s") + server.start_io() diff --git a/tests/e2e/test_publish_diagnostics.py b/tests/e2e/test_publish_diagnostics.py new file mode 100644 index 00000000..043c3935 --- /dev/null +++ b/tests/e2e/test_publish_diagnostics.py @@ -0,0 +1,146 @@ +############################################################################ +# Copyright(c) Open Law Library. All rights reserved. # +# See ThirdPartyNotices.txt in the project root for additional notices. # +# # +# Licensed under the Apache License, Version 2.0 (the "License") # +# you may not use this file except in compliance with the License. # +# You may obtain a copy of the License at # +# # +# http: // www.apache.org/licenses/LICENSE-2.0 # +# # +# Unless required by applicable law or agreed to in writing, software # +# distributed under the License is distributed on an "AS IS" BASIS, # +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # +# See the License for the specific language governing permissions and # +# limitations under the License. # +############################################################################ +from __future__ import annotations + +import asyncio +import typing + +import pytest_asyncio +from lsprotocol import types + +if typing.TYPE_CHECKING: + from typing import Tuple + + from pygls.lsp.client import BaseLanguageClient + + +@pytest_asyncio.fixture() +async def push_diagnostics(get_client_for): + async for client, response in get_client_for("publish_diagnostics.py"): + # Setup a diagnostics handler + client.diagnostics = {} + + @client.feature(types.TEXT_DOCUMENT_PUBLISH_DIAGNOSTICS) + def publish_diagnostics(params: types.PublishDiagnosticsParams): + client.diagnostics[params.uri] = params.diagnostics + + yield client, response + + +async def test_publish_diagnostics( + push_diagnostics: Tuple[BaseLanguageClient, types.InitializeResult], + path_for, + uri_for, +): + """Ensure that the publish diagnostics server is working as expected.""" + client, initialize_result = push_diagnostics + + test_uri = uri_for("sums.txt") + test_path = path_for("sums.txt") + + client.text_document_did_open( + types.DidOpenTextDocumentParams( + types.TextDocumentItem( + uri=test_uri, + language_id="plaintext", + version=0, + text=test_path.read_text(), + ) + ) + ) + + await asyncio.sleep(0.5) + assert test_uri in client.diagnostics + + expected = [ + types.Diagnostic( + message="Missing answer", + severity=types.DiagnosticSeverity.Warning, + range=types.Range( + start=types.Position(line=0, character=0), + end=types.Position(line=0, character=7), + ), + ), + types.Diagnostic( + message="Missing answer", + severity=types.DiagnosticSeverity.Warning, + range=types.Range( + start=types.Position(line=3, character=0), + end=types.Position(line=3, character=7), + ), + ), + types.Diagnostic( + message="Missing answer", + severity=types.DiagnosticSeverity.Warning, + range=types.Range( + start=types.Position(line=6, character=0), + end=types.Position(line=6, character=7), + ), + ), + ] + + assert expected == client.diagnostics[test_uri] + + # Write an incorrect answer... + client.text_document_did_change( + types.DidChangeTextDocumentParams( + text_document=types.VersionedTextDocumentIdentifier( + uri=test_uri, version=1 + ), + content_changes=[ + types.TextDocumentContentChangeEvent_Type1( + text=" 12", + range=types.Range( + start=types.Position(line=0, character=7), + end=types.Position(line=0, character=7), + ), + ) + ], + ) + ) + + await asyncio.sleep(0.5) + assert test_uri in client.diagnostics + + expected = [ + types.Diagnostic( + message="Incorrect answer: 12", + severity=types.DiagnosticSeverity.Error, + range=types.Range( + start=types.Position(line=0, character=0), + end=types.Position(line=0, character=10), + ), + ), + types.Diagnostic( + message="Missing answer", + severity=types.DiagnosticSeverity.Warning, + range=types.Range( + start=types.Position(line=3, character=0), + end=types.Position(line=3, character=7), + ), + ), + types.Diagnostic( + message="Missing answer", + severity=types.DiagnosticSeverity.Warning, + range=types.Range( + start=types.Position(line=6, character=0), + end=types.Position(line=6, character=7), + ), + ), + ] + + assert expected == client.diagnostics[test_uri] diff --git a/tests/e2e/test_pull_diagnostics.py b/tests/e2e/test_pull_diagnostics.py new file mode 100644 index 00000000..4847cf10 --- /dev/null +++ b/tests/e2e/test_pull_diagnostics.py @@ -0,0 +1,271 @@ +############################################################################ +# Copyright(c) Open Law Library. All rights reserved. # +# See ThirdPartyNotices.txt in the project root for additional notices. # +# # +# Licensed under the Apache License, Version 2.0 (the "License") # +# you may not use this file except in compliance with the License. # +# You may obtain a copy of the License at # +# # +# http: // www.apache.org/licenses/LICENSE-2.0 # +# # +# Unless required by applicable law or agreed to in writing, software # +# distributed under the License is distributed on an "AS IS" BASIS, # +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # +# See the License for the specific language governing permissions and # +# limitations under the License. # +############################################################################ +from __future__ import annotations + +import typing + +import pytest_asyncio +from lsprotocol import types + +if typing.TYPE_CHECKING: + from typing import Tuple + + from pygls.lsp.client import BaseLanguageClient + + +@pytest_asyncio.fixture() +async def pull_diagnostics(get_client_for): + async for result in get_client_for("pull_diagnostics.py"): + yield result + + +async def test_document_diagnostics( + pull_diagnostics: Tuple[BaseLanguageClient, types.InitializeResult], + path_for, + uri_for, +): + """Ensure that the pull diagnostics server is working as expected.""" + client, initialize_result = pull_diagnostics + + diagnostic_options = initialize_result.capabilities.diagnostic_provider + assert diagnostic_options.identifier == "pull-diagnostics" + assert diagnostic_options.workspace_diagnostics is True + + test_uri = uri_for("sums.txt") + test_path = path_for("sums.txt") + + client.text_document_did_open( + types.DidOpenTextDocumentParams( + types.TextDocumentItem( + uri=test_uri, + language_id="plaintext", + version=0, + text=test_path.read_text(), + ) + ) + ) + + result = await client.text_document_diagnostic_async( + types.DocumentDiagnosticParams( + text_document=types.TextDocumentIdentifier(test_uri) + ) + ) + + expected = [ + types.Diagnostic( + message="Missing answer", + severity=types.DiagnosticSeverity.Warning, + range=types.Range( + start=types.Position(line=0, character=0), + end=types.Position(line=0, character=7), + ), + ), + types.Diagnostic( + message="Missing answer", + severity=types.DiagnosticSeverity.Warning, + range=types.Range( + start=types.Position(line=3, character=0), + end=types.Position(line=3, character=7), + ), + ), + types.Diagnostic( + message="Missing answer", + severity=types.DiagnosticSeverity.Warning, + range=types.Range( + start=types.Position(line=6, character=0), + end=types.Position(line=6, character=7), + ), + ), + ] + + assert result.result_id == f"{test_uri}@{0}" + assert result.items == expected + assert result.kind == types.DocumentDiagnosticReportKind.Full + + # Write a correct answer... + client.text_document_did_change( + types.DidChangeTextDocumentParams( + text_document=types.VersionedTextDocumentIdentifier( + uri=test_uri, version=1 + ), + content_changes=[ + types.TextDocumentContentChangeEvent_Type1( + text=" 2", + range=types.Range( + start=types.Position(line=0, character=7), + end=types.Position(line=0, character=7), + ), + ) + ], + ) + ) + + result = await client.text_document_diagnostic_async( + types.DocumentDiagnosticParams( + text_document=types.TextDocumentIdentifier(test_uri) + ) + ) + + expected = [ + types.Diagnostic( + message="Missing answer", + severity=types.DiagnosticSeverity.Warning, + range=types.Range( + start=types.Position(line=3, character=0), + end=types.Position(line=3, character=7), + ), + ), + types.Diagnostic( + message="Missing answer", + severity=types.DiagnosticSeverity.Warning, + range=types.Range( + start=types.Position(line=6, character=0), + end=types.Position(line=6, character=7), + ), + ), + ] + + assert result.result_id == f"{test_uri}@{1}" + assert result.items == expected + assert result.kind == types.DocumentDiagnosticReportKind.Full + + +async def test_document_diagnostic_unchanged( + pull_diagnostics: Tuple[BaseLanguageClient, types.InitializeResult], + path_for, + uri_for, +): + """Ensure that the pull diagnostics server is working as expected.""" + client, initialize_result = pull_diagnostics + + diagnostic_options = initialize_result.capabilities.diagnostic_provider + assert diagnostic_options.identifier == "pull-diagnostics" + assert diagnostic_options.workspace_diagnostics is True + + test_uri = uri_for("sums.txt") + test_path = path_for("sums.txt") + + client.text_document_did_open( + types.DidOpenTextDocumentParams( + types.TextDocumentItem( + uri=test_uri, + language_id="plaintext", + version=0, + text=test_path.read_text(), + ) + ) + ) + + result = await client.text_document_diagnostic_async( + types.DocumentDiagnosticParams( + text_document=types.TextDocumentIdentifier(test_uri) + ) + ) + + expected_id = f"{test_uri}@{0}" + + assert result.result_id == expected_id + assert len(result.items) > 0 + assert result.kind == types.DocumentDiagnosticReportKind.Full + + # Making second request should result in an unchanged response + result = await client.text_document_diagnostic_async( + types.DocumentDiagnosticParams( + text_document=types.TextDocumentIdentifier(test_uri), + previous_result_id=expected_id, + ) + ) + + assert result.result_id == expected_id + assert result.kind == types.DocumentDiagnosticReportKind.Unchanged + + +async def test_workspace_diagnostic( + pull_diagnostics: Tuple[BaseLanguageClient, types.InitializeResult], + path_for, + uri_for, +): + """Ensure that the pull diagnostics server is working as expected.""" + client, initialize_result = pull_diagnostics + + diagnostic_options = initialize_result.capabilities.diagnostic_provider + assert diagnostic_options.identifier == "pull-diagnostics" + assert diagnostic_options.workspace_diagnostics is True + + test_uri = uri_for("sums.txt") + test_path = path_for("sums.txt") + + client.text_document_did_open( + types.DidOpenTextDocumentParams( + types.TextDocumentItem( + uri=test_uri, + language_id="plaintext", + version=0, + text=test_path.read_text(), + ) + ) + ) + + result = await client.workspace_diagnostic_async( + types.WorkspaceDiagnosticParams(previous_result_ids=[]) + ) + + expected = [ + types.Diagnostic( + message="Missing answer", + severity=types.DiagnosticSeverity.Warning, + range=types.Range( + start=types.Position(line=0, character=0), + end=types.Position(line=0, character=7), + ), + ), + types.Diagnostic( + message="Missing answer", + severity=types.DiagnosticSeverity.Warning, + range=types.Range( + start=types.Position(line=3, character=0), + end=types.Position(line=3, character=7), + ), + ), + types.Diagnostic( + message="Missing answer", + severity=types.DiagnosticSeverity.Warning, + range=types.Range( + start=types.Position(line=6, character=0), + end=types.Position(line=6, character=7), + ), + ), + ] + + report = result.items[0] + assert report.uri == test_uri + assert report.version == 0 + assert report.items == expected + assert report.kind == types.DocumentDiagnosticReportKind.Full + + result = await client.workspace_diagnostic_async( + types.WorkspaceDiagnosticParams( + previous_result_ids=[ + types.PreviousResultId(uri=test_uri, value=f"{test_uri}@{0}") + ] + ) + ) + + report = result.items[0] + assert report.uri == test_uri + assert report.version == 0 + assert report.kind == types.DocumentDiagnosticReportKind.Unchanged diff --git a/tests/lsp/test_diagnostics.py b/tests/lsp/test_diagnostics.py deleted file mode 100644 index c420942a..00000000 --- a/tests/lsp/test_diagnostics.py +++ /dev/null @@ -1,68 +0,0 @@ -############################################################################ -# Copyright(c) Open Law Library. All rights reserved. # -# See ThirdPartyNotices.txt in the project root for additional notices. # -# # -# Licensed under the Apache License, Version 2.0 (the "License") # -# you may not use this file except in compliance with the License. # -# You may obtain a copy of the License at # -# # -# http: // www.apache.org/licenses/LICENSE-2.0 # -# # -# Unless required by applicable law or agreed to in writing, software # -# distributed under the License is distributed on an "AS IS" BASIS, # -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # -# See the License for the specific language governing permissions and # -# limitations under the License. # -############################################################################ -import json -from typing import Tuple - -from lsprotocol import types - - -from ..client import LanguageClient - - -async def test_diagnostics( - json_server_client: Tuple[LanguageClient, types.InitializeResult], - uri_for, -): - """Ensure that diagnostics are working as expected.""" - client, _ = json_server_client - - test_uri = uri_for("example.json") - assert test_uri is not None - - # Get the expected error message - document_content = "text" - try: - json.loads(document_content) - except json.JSONDecodeError as err: - expected_message = err.msg - - client.text_document_did_open( - types.DidOpenTextDocumentParams( - text_document=types.TextDocumentItem( - uri=test_uri, language_id="json", version=1, text=document_content - ) - ) - ) - - await client.wait_for_notification(types.TEXT_DOCUMENT_PUBLISH_DIAGNOSTICS) - - diagnostics = client.diagnostics[test_uri] - assert diagnostics[0].message == expected_message - - result = await client.text_document_diagnostic_async( - types.DocumentDiagnosticParams( - text_document=types.TextDocumentIdentifier(test_uri) - ) - ) - diagnostics = result.items - assert diagnostics[0].message == expected_message - - workspace_result = await client.workspace_diagnostic_async( - types.WorkspaceDiagnosticParams(previous_result_ids=[]) - ) - diagnostics = workspace_result.items[0].items - assert diagnostics[0].message == expected_message