diff --git a/tests/conftest.py b/tests/conftest.py index e2538bb..bf9d2e6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,445 +4,34 @@ from typing import TypedDict import lsprotocol.types as lsp +import pytest_lsp +from pytest_lsp import ClientServerConfig, LanguageClient PATCH_DIR = Path(__file__).parent / "patches" TEST_PATCHES = list(PATCH_DIR.glob("*.spn")) assert TEST_PATCHES, "No test patches found in the patches directory." -class DefinitionDict(TypedDict): - """A dictionary track where a symbol is referenced and defined.""" +@pytest_lsp.fixture( + params=["neovim", "visual_studio_code"], + config=ClientServerConfig(server_command=["spinasm-lsp"]), +) +async def client(request, lsp_client: LanguageClient): + """A client fixture for LSP tests.""" + params = lsp.InitializeParams( + capabilities=pytest_lsp.client_capabilities(request.param) + ) - symbol: str - referenced: lsp.Position - defined: lsp.Location - uri: str + await lsp_client.initialize_session(params) + yield + await lsp_client.shutdown_session() -class SymbolDefinitionDict(TypedDict): - """A dictionary to record definition locations for a symbol.""" - symbol: str - range: lsp.Range - kind: lsp.SymbolKind - uri: str +class TestCase(TypedDict): + """The inputs and outputs of a test case.""" + __test__ = False -class HoverDict(TypedDict): - """A dictionary to record hover information for a symbol.""" - - symbol: str - position: lsp.Position - contains: str | None - uri: str - - -class PrepareRenameDict(TypedDict): - """A dictionary to record prepare rename results for a symbol.""" - - symbol: str - position: lsp.Position - result: bool - message: str | None - uri: str - - -class ReferenceDict(TypedDict): - """A dictionary to record reference locations for a symbol.""" - - symbol: str - position: lsp.Position - references: list[lsp.Location] - uri: str - - -class RenameDict(TypedDict): - """A dictionary to record rename results for a symbol.""" - - symbol: str - rename_to: str - position: lsp.Position - changes: list[lsp.TextEdit] - uri: str - - -class SignatureHelpDict(TypedDict): - """A dictionary to record signature help information for at a position.""" - - symbol: str - position: lsp.Position - active_parameter: int | None - param_contains: str | None - doc_contains: str | None - uri: str - - -REFERENCES: list[ReferenceDict] = [ - { - # Variable - "symbol": "apout", - "position": lsp.Position(line=23, character=4), - "uri": f"file:///{PATCH_DIR / 'Basic.spn'}", - "references": [ - lsp.Location( - uri=f"file:///{PATCH_DIR / 'Basic.spn'}", - range=lsp.Range( - start=lsp.Position(line=23, character=4), - end=lsp.Position(line=23, character=9), - ), - ), - lsp.Location( - uri=f"file:///{PATCH_DIR / 'Basic.spn'}", - range=lsp.Range( - start=lsp.Position(line=57, character=5), - end=lsp.Position(line=57, character=10), - ), - ), - lsp.Location( - uri=f"file:///{PATCH_DIR / 'Basic.spn'}", - range=lsp.Range( - start=lsp.Position(line=60, character=5), - end=lsp.Position(line=60, character=10), - ), - ), - lsp.Location( - uri=f"file:///{PATCH_DIR / 'Basic.spn'}", - range=lsp.Range( - start=lsp.Position(line=70, character=5), - end=lsp.Position(line=70, character=10), - ), - ), - ], - }, - { - "symbol": "ap1", - "position": lsp.Position(line=8, character=4), - "uri": f"file:///{PATCH_DIR / 'Basic.spn'}", - "references": [ - lsp.Location( - uri=f"file:///{PATCH_DIR / 'Basic.spn'}", - range=lsp.Range( - start=lsp.Position(line=8, character=4), - end=lsp.Position(line=8, character=7), - ), - ), - lsp.Location( - uri=f"file:///{PATCH_DIR / 'Basic.spn'}", - range=lsp.Range( - start=lsp.Position(line=51, character=4), - end=lsp.Position(line=51, character=7), - ), - ), - lsp.Location( - uri=f"file:///{PATCH_DIR / 'Basic.spn'}", - range=lsp.Range( - start=lsp.Position(line=52, character=5), - end=lsp.Position(line=52, character=8), - ), - ), - ], - }, -] - - -SYMBOL_DEFINITIONS: list[SymbolDefinitionDict] = [ - { - # Variable - "symbol": "apout", - "kind": lsp.SymbolKind.Variable, - "uri": f"file:///{PATCH_DIR / 'Basic.spn'}", - "range": lsp.Range( - start=lsp.Position(line=23, character=4), - end=lsp.Position(line=23, character=9), - ), - }, - { - # Memory - "symbol": "lap2a", - "kind": lsp.SymbolKind.Variable, - "uri": f"file:///{PATCH_DIR / 'Basic.spn'}", - "range": lsp.Range( - start=lsp.Position(line=16, character=4), - end=lsp.Position(line=16, character=9), - ), - }, - { - # Label - "symbol": "endclr", - "kind": lsp.SymbolKind.Module, - "uri": f"file:///{PATCH_DIR / 'Basic.spn'}", - "range": lsp.Range( - start=lsp.Position(line=41, character=0), - end=lsp.Position(line=41, character=6), - ), - }, -] - - -# Example assignments from the "Basic.spn" patch, for testing definition locations -DEFINITIONS: list[DefinitionDict] = [ - { - # Variable - "symbol": "apout", - "referenced": lsp.Position(line=57, character=7), - "uri": f"file:///{PATCH_DIR / 'Basic.spn'}", - "defined": lsp.Location( - uri=f"file:///{PATCH_DIR / 'Basic.spn'}", - range=lsp.Range( - start=lsp.Position(line=23, character=4), - end=lsp.Position(line=23, character=9), - ), - ), - }, - { - # Memory - "symbol": "lap2a", - "referenced": lsp.Position(line=72, character=7), - "uri": f"file:///{PATCH_DIR / 'Basic.spn'}", - "defined": lsp.Location( - uri=f"file:///{PATCH_DIR / 'Basic.spn'}", - range=lsp.Range( - start=lsp.Position(line=16, character=4), - end=lsp.Position(line=16, character=9), - ), - ), - }, - { - # Memory. Note that this has an address modifier, but still points to the - # original definition. - "symbol": "lap2a#", - "referenced": lsp.Position(line=71, character=7), - "uri": f"file:///{PATCH_DIR / 'Basic.spn'}", - "defined": lsp.Location( - uri=f"file:///{PATCH_DIR / 'Basic.spn'}", - range=lsp.Range( - start=lsp.Position(line=16, character=4), - end=lsp.Position(line=16, character=9), - ), - ), - }, - { - # Label - "symbol": "endclr", - "referenced": lsp.Position(line=37, character=9), - "uri": f"file:///{PATCH_DIR / 'Basic.spn'}", - "defined": lsp.Location( - uri=f"file:///{PATCH_DIR / 'Basic.spn'}", - range=lsp.Range( - start=lsp.Position(line=41, character=0), - end=lsp.Position(line=41, character=6), - ), - ), - }, -] - - -# Example hovers from the "Basic.spn" patch, for testing hover info -HOVERS: list[HoverDict] = [ - { - "symbol": "mem", - "position": lsp.Position(line=8, character=0), - "contains": "`MEM`", - "uri": f"file:///{PATCH_DIR / 'Basic.spn'}", - }, - { - "symbol": "skp", - "position": lsp.Position(line=37, character=2), - "contains": "`SKP CMASK, N`", - "uri": f"file:///{PATCH_DIR / 'Basic.spn'}", - }, - { - "symbol": "endclr", - "position": lsp.Position(line=37, character=13), - "contains": "(label) ENDCLR: Offset[4]", - "uri": f"file:///{PATCH_DIR / 'Basic.spn'}", - }, - { - "symbol": "mono", - "position": lsp.Position(line=47, character=5), - "contains": "(variable) MONO: Literal[32]", - "uri": f"file:///{PATCH_DIR / 'Basic.spn'}", - }, - { - "symbol": "reg0", - "position": lsp.Position(line=22, character=9), - "contains": "(constant) REG0: Literal[32]", - "uri": f"file:///{PATCH_DIR / 'Basic.spn'}", - }, - { - "symbol": "lap2b#", - "position": lsp.Position(line=73, character=4), - "contains": "(variable) LAP2B#: Literal[9802]", - "uri": f"file:///{PATCH_DIR / 'Basic.spn'}", - }, - { - # CHO RDA, hovering over CHO - "symbol": "CHO_rda", - "position": lsp.Position(line=85, character=0), - "contains": "`CHO RDA, N, C, D`", - "uri": f"file:///{PATCH_DIR / 'Basic.spn'}", - }, - { - # CHO RDA, hovering over RDA - "symbol": "cho_RDA", - "position": lsp.Position(line=85, character=4), - "contains": "`CHO RDA, N, C, D`", - "uri": f"file:///{PATCH_DIR / 'Basic.spn'}", - }, - { - # Hovering over an int, which should return no hover info - "symbol": "None", - "position": lsp.Position(line=8, character=8), - "contains": None, - "uri": f"file:///{PATCH_DIR / 'Basic.spn'}", - }, -] - - -PREPARE_RENAMES: list[PrepareRenameDict] = [ - { - "symbol": "mem", - "position": lsp.Position(line=8, character=0), - "result": None, - "message": "Can't rename non-user defined token MEM.", - "uri": f"file:///{PATCH_DIR / 'Basic.spn'}", - }, - { - "symbol": "reg0", - "position": lsp.Position(line=22, character=10), - "result": None, - "message": "Can't rename non-user defined token REG0.", - "uri": f"file:///{PATCH_DIR / 'Basic.spn'}", - }, - { - "symbol": "ap1", - "position": lsp.Position(line=8, character=4), - "result": lsp.PrepareRenameResult_Type2(default_behavior=True), - "message": None, - "uri": f"file:///{PATCH_DIR / 'Basic.spn'}", - }, - { - "symbol": "endclr", - "position": lsp.Position(line=37, character=10), - "result": lsp.PrepareRenameResult_Type2(default_behavior=True), - "message": None, - "uri": f"file:///{PATCH_DIR / 'Basic.spn'}", - }, -] - - -RENAMES: list[RenameDict] = [ - { - "symbol": "ap1", - "rename_to": "FOO", - "position": lsp.Position(line=8, character=4), - "uri": f"file:///{PATCH_DIR / 'Basic.spn'}", - "changes": [ - lsp.TextEdit( - range=lsp.Range(start=lsp.Position(8, 4), end=lsp.Position(8, 7)), - new_text="FOO", - ), - # This symbol is `ap1#``, and should be matched when renaming `ap1` - lsp.TextEdit( - range=lsp.Range(start=lsp.Position(51, 4), end=lsp.Position(51, 7)), - new_text="FOO", - ), - lsp.TextEdit( - range=lsp.Range(start=lsp.Position(52, 5), end=lsp.Position(52, 8)), - new_text="FOO", - ), - ], - }, - { - "symbol": "endclr", - "rename_to": "END", - "position": lsp.Position(line=41, character=0), - "uri": f"file:///{PATCH_DIR / 'Basic.spn'}", - "changes": [ - lsp.TextEdit( - range=lsp.Range(start=lsp.Position(37, 8), end=lsp.Position(37, 14)), - new_text="END", - ), - lsp.TextEdit( - range=lsp.Range(start=lsp.Position(41, 0), end=lsp.Position(41, 6)), - new_text="END", - ), - ], - }, - { - "symbol": "lap1a#", - "rename_to": "FOO", - "position": lsp.Position(line=61, character=4), - "uri": f"file:///{PATCH_DIR / 'Basic.spn'}", - "changes": [ - # Renaming `lap1a#` should also rename `lap1a` - lsp.TextEdit( - range=lsp.Range(start=lsp.Position(12, 4), end=lsp.Position(12, 9)), - new_text="FOO", - ), - lsp.TextEdit( - range=lsp.Range(start=lsp.Position(61, 4), end=lsp.Position(61, 9)), - new_text="FOO", - ), - lsp.TextEdit( - range=lsp.Range(start=lsp.Position(62, 5), end=lsp.Position(62, 10)), - new_text="FOO", - ), - ], - }, -] - -SIGNATURE_HELPS: list[SignatureHelpDict] = [ - { - # No opcode on this line, so the signature help should be None - "symbol": "no_opcode", - "position": lsp.Position(line=8, character=3), - "active_parameter": None, - "doc_contains": None, - "param_contains": None, - "uri": f"file:///{PATCH_DIR / 'Basic.spn'}", - }, - { - "symbol": "skp_first_arg", - "position": lsp.Position(line=37, character=3), - "active_parameter": 0, - "doc_contains": "**`SKP CMASK, N`** allows conditional program execution", - "param_contains": "CMASK: Binary | Hex ($00-$1F) | Symbolic", - "uri": f"file:///{PATCH_DIR / 'Basic.spn'}", - }, - { - "symbol": "skp_second_arg", - "position": lsp.Position(line=37, character=8), - "active_parameter": 1, - "doc_contains": "**`SKP CMASK, N`** allows conditional program execution", - "param_contains": "N: Decimal (1-63) | Label", - "uri": f"file:///{PATCH_DIR / 'Basic.spn'}", - }, - { - # You should still get the last argument even if you're well beyond it - "symbol": "skp_out_of_bounds", - "position": lsp.Position(line=37, character=45), - "active_parameter": 1, - "doc_contains": "**`SKP CMASK, N`** allows conditional program execution", - "param_contains": "N: Decimal (1-63) | Label", - "uri": f"file:///{PATCH_DIR / 'Basic.spn'}", - }, - { - # The "first" argument of CHO RDA should be N, not RDA - "symbol": "cho_rda", - "position": lsp.Position(line=85, character=8), - "active_parameter": 0, - "doc_contains": "**`CHO RDA, N, C, D`**, like the `RDA` instruction", - "param_contains": "N: LFO select: SIN0,SIN1,RMP0,RMP1", - "uri": f"file:///{PATCH_DIR / 'Basic.spn'}", - }, - { - # Triggering signature help before finishing the opcode should return None - "symbol": "cho_rda", - "position": lsp.Position(line=85, character=0), - "active_parameter": None, - "doc_contains": None, - "param_contains": None, - "uri": f"file:///{PATCH_DIR / 'Basic.spn'}", - }, -] + name: str + """The name used to identify the test case.""" diff --git a/tests/server_tests/__init__.py b/tests/server_tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/server_tests/test_completion.py b/tests/server_tests/test_completion.py new file mode 100644 index 0000000..f358596 --- /dev/null +++ b/tests/server_tests/test_completion.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +import lsprotocol.types as lsp +import pytest +from pytest_lsp import LanguageClient + +from ..conftest import PATCH_DIR, TestCase + + +class CompletionTestCase(TestCase): + """A dictionary to track an expected completion result.""" + + label: str + detail: str + kind: lsp.CompletionItemKind + doc_contains: str | None + uri: str + + +COMPLETIONS: list[CompletionTestCase] = [ + { + "name": "APOUT", + "label": "APOUT", + "detail": "(variable) APOUT: Literal[33]", + "kind": lsp.CompletionItemKind.Variable, + "doc_contains": None, + "uri": f"file:///{PATCH_DIR / 'Basic.spn'}", + }, + { + "name": "REG0", + "label": "REG0", + "detail": "(constant) REG0: Literal[32]", + "kind": lsp.CompletionItemKind.Constant, + "doc_contains": None, + "uri": f"file:///{PATCH_DIR / 'Basic.spn'}", + }, + { + "name": "CHO RDA", + "label": "CHO RDA", + "detail": "(opcode)", + "kind": lsp.CompletionItemKind.Function, + "doc_contains": "`CHO RDA, N, C, D`", + "uri": f"file:///{PATCH_DIR / 'Basic.spn'}", + }, + { + "name": "EQU", + "label": "EQU", + "detail": "(opcode)", + "kind": lsp.CompletionItemKind.Function, + "doc_contains": "**`EQU`** allows one to define symbolic operands", + "uri": f"file:///{PATCH_DIR / 'Basic.spn'}", + }, +] + + +@pytest.mark.parametrize("test_case", COMPLETIONS, ids=lambda x: x["name"]) +@pytest.mark.asyncio() +async def test_completions(test_case: CompletionTestCase, client: LanguageClient): + """Test that expected completions are shown with details and documentation.""" + results = await client.text_document_completion_async( + params=lsp.CompletionParams( + position=lsp.Position(line=0, character=0), + text_document=lsp.TextDocumentIdentifier(uri=test_case["uri"]), + ) + ) + assert results is not None, "Expected completions" + + matches = [item for item in results.items if item.label == test_case["label"]] + + assert ( + len(matches) == 1 + ), f"Expected 1 matching label `{test_case['label']}, got {len(matches)}." + match = matches[0] + + assert match.detail == test_case["detail"] + assert match.kind == test_case["kind"] + if test_case["doc_contains"] is not None: + assert test_case["doc_contains"] in str(match.documentation) diff --git a/tests/server_tests/test_definition.py b/tests/server_tests/test_definition.py new file mode 100644 index 0000000..5126ce7 --- /dev/null +++ b/tests/server_tests/test_definition.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +import lsprotocol.types as lsp +import pytest +from pytest_lsp import LanguageClient + +from ..conftest import PATCH_DIR, TestCase + + +class DefinitionTestCase(TestCase): + """A dictionary track where a symbol is referenced and defined.""" + + referenced: lsp.Position + defined: lsp.Location + uri: str + + +DEFINITIONS: list[DefinitionTestCase] = [ + { + # Variable + "name": "apout", + "referenced": lsp.Position(line=57, character=7), + "uri": f"file:///{PATCH_DIR / 'Basic.spn'}", + "defined": lsp.Location( + uri=f"file:///{PATCH_DIR / 'Basic.spn'}", + range=lsp.Range( + start=lsp.Position(line=23, character=4), + end=lsp.Position(line=23, character=9), + ), + ), + }, + { + # Memory + "name": "lap2a", + "referenced": lsp.Position(line=72, character=7), + "uri": f"file:///{PATCH_DIR / 'Basic.spn'}", + "defined": lsp.Location( + uri=f"file:///{PATCH_DIR / 'Basic.spn'}", + range=lsp.Range( + start=lsp.Position(line=16, character=4), + end=lsp.Position(line=16, character=9), + ), + ), + }, + { + # Memory. Note that this has an address modifier, but still points to the + # original definition. + "name": "lap2a#", + "referenced": lsp.Position(line=71, character=7), + "uri": f"file:///{PATCH_DIR / 'Basic.spn'}", + "defined": lsp.Location( + uri=f"file:///{PATCH_DIR / 'Basic.spn'}", + range=lsp.Range( + start=lsp.Position(line=16, character=4), + end=lsp.Position(line=16, character=9), + ), + ), + }, + { + # Label + "name": "endclr", + "referenced": lsp.Position(line=37, character=9), + "uri": f"file:///{PATCH_DIR / 'Basic.spn'}", + "defined": lsp.Location( + uri=f"file:///{PATCH_DIR / 'Basic.spn'}", + range=lsp.Range( + start=lsp.Position(line=41, character=0), + end=lsp.Position(line=41, character=6), + ), + ), + }, +] + + +@pytest.mark.asyncio() +@pytest.mark.parametrize("test_case", DEFINITIONS, ids=lambda x: x["name"]) +async def test_definition(test_case: DefinitionTestCase, client: LanguageClient): + """Test that the definition location of different assignments is correct.""" + result = await client.text_document_definition_async( + params=lsp.DefinitionParams( + position=test_case["referenced"], + text_document=lsp.TextDocumentIdentifier(uri=test_case["uri"]), + ) + ) + + assert result == test_case["defined"] diff --git a/tests/server_tests/test_diagnostics.py b/tests/server_tests/test_diagnostics.py new file mode 100644 index 0000000..16610e8 --- /dev/null +++ b/tests/server_tests/test_diagnostics.py @@ -0,0 +1,70 @@ +import lsprotocol.types as lsp +import pytest +from pytest_lsp import LanguageClient + + +@pytest.mark.asyncio() +async def test_diagnostic_parsing_errors(client: LanguageClient): + """Test that parsing errors and warnings are correctly reported by the server.""" + source_with_errors = """ +; Undefined symbol a +SOF 0,a + +; Label REG0 re-defined +REG0 EQU 4 + +; Register out of range +MULX 100 +""" + + # We need a URI to associate with the source, but it doesn't need to be a real file. + test_uri = "dummy_uri" + client.text_document_did_open( + lsp.DidOpenTextDocumentParams( + text_document=lsp.TextDocumentItem( + uri=test_uri, + language_id="spinasm", + version=1, + text=source_with_errors, + ) + ) + ) + + await client.wait_for_notification(lsp.TEXT_DOCUMENT_PUBLISH_DIAGNOSTICS) + + expected = [ + lsp.Diagnostic( + range=lsp.Range( + start=lsp.Position(line=2, character=6), + end=lsp.Position(line=2, character=6), + ), + message="Undefined label a", + severity=lsp.DiagnosticSeverity.Error, + source="SPINAsm", + ), + lsp.Diagnostic( + range=lsp.Range( + start=lsp.Position(line=5, character=9), + end=lsp.Position(line=5, character=9), + ), + message="Label REG0 re-defined", + severity=lsp.DiagnosticSeverity.Warning, + source="SPINAsm", + ), + lsp.Diagnostic( + range=lsp.Range( + start=lsp.Position(line=8, character=0), + end=lsp.Position(line=8, character=0), + ), + message="Register 0x64 out of range for MULX", + severity=lsp.DiagnosticSeverity.Error, + source="SPINAsm", + ), + ] + + returned = client.diagnostics[test_uri] + extra = len(returned) - len(expected) + assert extra == 0, f"Expected {len(expected)} diagnostics, got {len(returned)}." + + for i, diag in enumerate(expected): + assert diag == returned[i], f"Diagnostic {i} does not match expected" diff --git a/tests/server_tests/test_hover.py b/tests/server_tests/test_hover.py new file mode 100644 index 0000000..49ce7dc --- /dev/null +++ b/tests/server_tests/test_hover.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +import lsprotocol.types as lsp +import pytest +from pytest_lsp import LanguageClient + +from ..conftest import PATCH_DIR, TestCase + + +class HoverTestCase(TestCase): + """A dictionary to record hover information for a symbol.""" + + position: lsp.Position + contains: str | None + uri: str + + +HOVERS: list[HoverTestCase] = [ + { + "name": "mem", + "position": lsp.Position(line=8, character=0), + "contains": "`MEM`", + "uri": f"file:///{PATCH_DIR / 'Basic.spn'}", + }, + { + "name": "skp", + "position": lsp.Position(line=37, character=2), + "contains": "`SKP CMASK, N`", + "uri": f"file:///{PATCH_DIR / 'Basic.spn'}", + }, + { + "name": "endclr", + "position": lsp.Position(line=37, character=13), + "contains": "(label) ENDCLR: Offset[4]", + "uri": f"file:///{PATCH_DIR / 'Basic.spn'}", + }, + { + "name": "mono", + "position": lsp.Position(line=47, character=5), + "contains": "(variable) MONO: Literal[32]", + "uri": f"file:///{PATCH_DIR / 'Basic.spn'}", + }, + { + "name": "reg0", + "position": lsp.Position(line=22, character=9), + "contains": "(constant) REG0: Literal[32]", + "uri": f"file:///{PATCH_DIR / 'Basic.spn'}", + }, + { + "name": "lap2b#", + "position": lsp.Position(line=73, character=4), + "contains": "(variable) LAP2B#: Literal[9802]", + "uri": f"file:///{PATCH_DIR / 'Basic.spn'}", + }, + { + # CHO RDA, hovering over CHO + "name": "CHO_rda", + "position": lsp.Position(line=85, character=0), + "contains": "`CHO RDA, N, C, D`", + "uri": f"file:///{PATCH_DIR / 'Basic.spn'}", + }, + { + # CHO RDA, hovering over RDA + "name": "cho_RDA", + "position": lsp.Position(line=85, character=4), + "contains": "`CHO RDA, N, C, D`", + "uri": f"file:///{PATCH_DIR / 'Basic.spn'}", + }, + { + # Hovering over an int, which should return no hover info + "name": "None", + "position": lsp.Position(line=8, character=8), + "contains": None, + "uri": f"file:///{PATCH_DIR / 'Basic.spn'}", + }, +] + + +@pytest.mark.parametrize("test_case", HOVERS, ids=lambda x: x["name"]) +@pytest.mark.asyncio() +async def test_hover(test_case: dict, client: LanguageClient): + result = await client.text_document_hover_async( + params=lsp.CompletionParams( + position=test_case["position"], + text_document=lsp.TextDocumentIdentifier(uri=test_case["uri"]), + ) + ) + + if test_case["contains"] is None: + assert result is None, "Expected no hover result" + else: + msg = f"Hover does not contain `{test_case['contains']}`" + assert test_case["contains"] in result.contents.value, msg diff --git a/tests/server_tests/test_prepare_rename.py b/tests/server_tests/test_prepare_rename.py new file mode 100644 index 0000000..9206c7a --- /dev/null +++ b/tests/server_tests/test_prepare_rename.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +import lsprotocol.types as lsp +import pytest +from pytest_lsp import LanguageClient + +from ..conftest import PATCH_DIR, TestCase + + +class PrepareRenameTestCase(TestCase): + """A dictionary to record prepare rename results for a symbol.""" + + position: lsp.Position + result: bool + message: str | None + uri: str + + +PREPARE_RENAMES: list[PrepareRenameTestCase] = [ + { + "name": "mem", + "position": lsp.Position(line=8, character=0), + "result": None, + "message": "Can't rename non-user defined token MEM.", + "uri": f"file:///{PATCH_DIR / 'Basic.spn'}", + }, + { + "name": "reg0", + "position": lsp.Position(line=22, character=10), + "result": None, + "message": "Can't rename non-user defined token REG0.", + "uri": f"file:///{PATCH_DIR / 'Basic.spn'}", + }, + { + "name": "ap1", + "position": lsp.Position(line=8, character=4), + "result": lsp.PrepareRenameResult_Type2(default_behavior=True), + "message": None, + "uri": f"file:///{PATCH_DIR / 'Basic.spn'}", + }, + { + "name": "endclr", + "position": lsp.Position(line=37, character=10), + "result": lsp.PrepareRenameResult_Type2(default_behavior=True), + "message": None, + "uri": f"file:///{PATCH_DIR / 'Basic.spn'}", + }, +] + + +@pytest.mark.parametrize("test_case", PREPARE_RENAMES, ids=lambda x: x["name"]) +@pytest.mark.asyncio() +async def test_prepare_rename(test_case: PrepareRenameTestCase, client: LanguageClient): + """Test that prepare rename prevents renaming non-user defined tokens.""" + result = await client.text_document_prepare_rename_async( + params=lsp.PrepareRenameParams( + position=test_case["position"], + text_document=lsp.TextDocumentIdentifier(uri=test_case["uri"]), + ) + ) + + assert result == test_case["result"] + + if test_case["message"]: + assert test_case["message"] in client.log_messages[0].message + assert client.log_messages[0].type == lsp.MessageType.Info + else: + assert not client.log_messages diff --git a/tests/server_tests/test_reference.py b/tests/server_tests/test_reference.py new file mode 100644 index 0000000..080028e --- /dev/null +++ b/tests/server_tests/test_reference.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +import lsprotocol.types as lsp +import pytest +from pytest_lsp import LanguageClient + +from ..conftest import PATCH_DIR, TestCase + + +class ReferenceTestCase(TestCase): + """A dictionary to record reference locations for a symbol.""" + + position: lsp.Position + references: list[lsp.Location] + uri: str + + +REFERENCES: list[ReferenceTestCase] = [ + { + # Variable + "name": "apout", + "position": lsp.Position(line=23, character=4), + "uri": f"file:///{PATCH_DIR / 'Basic.spn'}", + "references": [ + lsp.Location( + uri=f"file:///{PATCH_DIR / 'Basic.spn'}", + range=lsp.Range( + start=lsp.Position(line=23, character=4), + end=lsp.Position(line=23, character=9), + ), + ), + lsp.Location( + uri=f"file:///{PATCH_DIR / 'Basic.spn'}", + range=lsp.Range( + start=lsp.Position(line=57, character=5), + end=lsp.Position(line=57, character=10), + ), + ), + lsp.Location( + uri=f"file:///{PATCH_DIR / 'Basic.spn'}", + range=lsp.Range( + start=lsp.Position(line=60, character=5), + end=lsp.Position(line=60, character=10), + ), + ), + lsp.Location( + uri=f"file:///{PATCH_DIR / 'Basic.spn'}", + range=lsp.Range( + start=lsp.Position(line=70, character=5), + end=lsp.Position(line=70, character=10), + ), + ), + ], + }, + { + "name": "ap1", + "position": lsp.Position(line=8, character=4), + "uri": f"file:///{PATCH_DIR / 'Basic.spn'}", + "references": [ + lsp.Location( + uri=f"file:///{PATCH_DIR / 'Basic.spn'}", + range=lsp.Range( + start=lsp.Position(line=8, character=4), + end=lsp.Position(line=8, character=7), + ), + ), + lsp.Location( + uri=f"file:///{PATCH_DIR / 'Basic.spn'}", + range=lsp.Range( + start=lsp.Position(line=51, character=4), + end=lsp.Position(line=51, character=7), + ), + ), + lsp.Location( + uri=f"file:///{PATCH_DIR / 'Basic.spn'}", + range=lsp.Range( + start=lsp.Position(line=52, character=5), + end=lsp.Position(line=52, character=8), + ), + ), + ], + }, +] + + +@pytest.mark.parametrize("test_case", REFERENCES, ids=lambda x: x["name"]) +@pytest.mark.asyncio() +async def test_references(test_case: ReferenceTestCase, client: LanguageClient): + """Test that references to a symbol are correctly found.""" + result = await client.text_document_references_async( + params=lsp.ReferenceParams( + context=lsp.ReferenceContext(include_declaration=False), + position=test_case["position"], + text_document=lsp.TextDocumentIdentifier(uri=test_case["uri"]), + ) + ) + + assert result == test_case["references"] diff --git a/tests/server_tests/test_rename.py b/tests/server_tests/test_rename.py new file mode 100644 index 0000000..3c65ffa --- /dev/null +++ b/tests/server_tests/test_rename.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +import lsprotocol.types as lsp +import pytest +from pytest_lsp import LanguageClient + +from ..conftest import PATCH_DIR, TestCase + + +class RenameTestCase(TestCase): + """A dictionary to record rename results for a symbol.""" + + rename_to: str + position: lsp.Position + changes: list[lsp.TextEdit] + uri: str + + +RENAMES: list[RenameTestCase] = [ + { + "name": "ap1", + "rename_to": "FOO", + "position": lsp.Position(line=8, character=4), + "uri": f"file:///{PATCH_DIR / 'Basic.spn'}", + "changes": [ + lsp.TextEdit( + range=lsp.Range(start=lsp.Position(8, 4), end=lsp.Position(8, 7)), + new_text="FOO", + ), + # This symbol is `ap1#``, and should be matched when renaming `ap1` + lsp.TextEdit( + range=lsp.Range(start=lsp.Position(51, 4), end=lsp.Position(51, 7)), + new_text="FOO", + ), + lsp.TextEdit( + range=lsp.Range(start=lsp.Position(52, 5), end=lsp.Position(52, 8)), + new_text="FOO", + ), + ], + }, + { + "name": "endclr", + "rename_to": "END", + "position": lsp.Position(line=41, character=0), + "uri": f"file:///{PATCH_DIR / 'Basic.spn'}", + "changes": [ + lsp.TextEdit( + range=lsp.Range(start=lsp.Position(37, 8), end=lsp.Position(37, 14)), + new_text="END", + ), + lsp.TextEdit( + range=lsp.Range(start=lsp.Position(41, 0), end=lsp.Position(41, 6)), + new_text="END", + ), + ], + }, + { + "name": "lap1a#", + "rename_to": "FOO", + "position": lsp.Position(line=61, character=4), + "uri": f"file:///{PATCH_DIR / 'Basic.spn'}", + "changes": [ + # Renaming `lap1a#` should also rename `lap1a` + lsp.TextEdit( + range=lsp.Range(start=lsp.Position(12, 4), end=lsp.Position(12, 9)), + new_text="FOO", + ), + lsp.TextEdit( + range=lsp.Range(start=lsp.Position(61, 4), end=lsp.Position(61, 9)), + new_text="FOO", + ), + lsp.TextEdit( + range=lsp.Range(start=lsp.Position(62, 5), end=lsp.Position(62, 10)), + new_text="FOO", + ), + ], + }, +] + + +@pytest.mark.parametrize("test_case", RENAMES, ids=lambda x: x["name"]) +@pytest.mark.asyncio() +async def test_rename(test_case: RenameTestCase, client: LanguageClient): + """Test that renaming a symbol suggests the correct edits.""" + result = await client.text_document_rename_async( + params=lsp.RenameParams( + position=test_case["position"], + new_name=test_case["rename_to"], + text_document=lsp.TextDocumentIdentifier(uri=test_case["uri"]), + ) + ) + + assert result.changes[test_case["uri"]] == test_case["changes"] diff --git a/tests/server_tests/test_signature_help.py b/tests/server_tests/test_signature_help.py new file mode 100644 index 0000000..3dd076b --- /dev/null +++ b/tests/server_tests/test_signature_help.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +import lsprotocol.types as lsp +import pytest +from pytest_lsp import LanguageClient + +from ..conftest import PATCH_DIR, TestCase + + +class SignatureHelpTestCase(TestCase): + """A dictionary to record signature help information for at a position.""" + + position: lsp.Position + active_parameter: int | None + param_contains: str | None + doc_contains: str | None + uri: str + + +SIGNATURE_HELPS: list[SignatureHelpTestCase] = [ + { + # No opcode on this line, so the signature help should be None + "name": "no_opcode", + "position": lsp.Position(line=8, character=3), + "active_parameter": None, + "doc_contains": None, + "param_contains": None, + "uri": f"file:///{PATCH_DIR / 'Basic.spn'}", + }, + { + "name": "skp_first_arg", + "position": lsp.Position(line=37, character=3), + "active_parameter": 0, + "doc_contains": "**`SKP CMASK, N`** allows conditional program execution", + "param_contains": "CMASK: Binary | Hex ($00-$1F) | Symbolic", + "uri": f"file:///{PATCH_DIR / 'Basic.spn'}", + }, + { + "name": "skp_second_arg", + "position": lsp.Position(line=37, character=8), + "active_parameter": 1, + "doc_contains": "**`SKP CMASK, N`** allows conditional program execution", + "param_contains": "N: Decimal (1-63) | Label", + "uri": f"file:///{PATCH_DIR / 'Basic.spn'}", + }, + { + # You should still get the last argument even if you're well beyond it + "name": "skp_out_of_bounds", + "position": lsp.Position(line=37, character=45), + "active_parameter": 1, + "doc_contains": "**`SKP CMASK, N`** allows conditional program execution", + "param_contains": "N: Decimal (1-63) | Label", + "uri": f"file:///{PATCH_DIR / 'Basic.spn'}", + }, + { + # The "first" argument of CHO RDA should be N, not RDA + "name": "cho_rda", + "position": lsp.Position(line=85, character=8), + "active_parameter": 0, + "doc_contains": "**`CHO RDA, N, C, D`**, like the `RDA` instruction", + "param_contains": "N: LFO select: SIN0,SIN1,RMP0,RMP1", + "uri": f"file:///{PATCH_DIR / 'Basic.spn'}", + }, + { + # Triggering signature help before finishing the opcode should return None + "name": "cho_rda", + "position": lsp.Position(line=85, character=0), + "active_parameter": None, + "doc_contains": None, + "param_contains": None, + "uri": f"file:///{PATCH_DIR / 'Basic.spn'}", + }, +] + + +@pytest.mark.parametrize("test_case", SIGNATURE_HELPS, ids=lambda x: x["name"]) +@pytest.mark.asyncio() +async def test_signature_help(test_case: SignatureHelpTestCase, client: LanguageClient): + result = await client.text_document_signature_help_async( + params=lsp.SignatureHelpParams( + context=lsp.SignatureHelpContext( + trigger_kind=lsp.SignatureHelpTriggerKind.Invoked, is_retrigger=False + ), + position=test_case["position"], + text_document=lsp.TextDocumentIdentifier(uri=test_case["uri"]), + ) + ) + + if test_case["active_parameter"] is None: + assert not result + return + + sig: lsp.SignatureInformation = result.signatures[result.active_signature] + param: lsp.ParameterInformation = sig.parameters[result.active_parameter] + + assert test_case["active_parameter"] == result.active_parameter + assert test_case["doc_contains"] in str(sig.documentation) + assert test_case["param_contains"] in param.label diff --git a/tests/server_tests/test_symbol_definition.py b/tests/server_tests/test_symbol_definition.py new file mode 100644 index 0000000..1e469df --- /dev/null +++ b/tests/server_tests/test_symbol_definition.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import lsprotocol.types as lsp +import pytest +from pytest_lsp import LanguageClient + +from ..conftest import PATCH_DIR, TestCase + + +class SymbolDefinitionTestCase(TestCase): + """A dictionary to record definition locations for a symbol.""" + + range: lsp.Range + kind: lsp.SymbolKind + uri: str + + +SYMBOL_DEFINITIONS: list[SymbolDefinitionTestCase] = [ + { + # Variable + "name": "apout", + "kind": lsp.SymbolKind.Variable, + "uri": f"file:///{PATCH_DIR / 'Basic.spn'}", + "range": lsp.Range( + start=lsp.Position(line=23, character=4), + end=lsp.Position(line=23, character=9), + ), + }, + { + # Memory + "name": "lap2a", + "kind": lsp.SymbolKind.Variable, + "uri": f"file:///{PATCH_DIR / 'Basic.spn'}", + "range": lsp.Range( + start=lsp.Position(line=16, character=4), + end=lsp.Position(line=16, character=9), + ), + }, + { + # Label + "name": "endclr", + "kind": lsp.SymbolKind.Module, + "uri": f"file:///{PATCH_DIR / 'Basic.spn'}", + "range": lsp.Range( + start=lsp.Position(line=41, character=0), + end=lsp.Position(line=41, character=6), + ), + }, +] + + +@pytest.mark.parametrize("test_case", SYMBOL_DEFINITIONS, ids=lambda x: x["name"]) +@pytest.mark.asyncio() +async def test_symbol_definitions( + test_case: SymbolDefinitionTestCase, client: LanguageClient +): + """Test that the definitions of all symbols in the document are returned.""" + result = await client.text_document_document_symbol_async( + params=lsp.DocumentSymbolParams( + text_document=lsp.TextDocumentIdentifier(uri=test_case["uri"]), + ) + ) + + matching = [item for item in result if item.name == test_case["name"].upper()] + assert matching, f"Symbol {test_case['name'].upper()} not in document symbols" + + item = matching[0] + assert item.kind == test_case["kind"] + assert item.range == test_case["range"] diff --git a/tests/test_server.py b/tests/test_server.py deleted file mode 100644 index d7e5274..0000000 --- a/tests/test_server.py +++ /dev/null @@ -1,285 +0,0 @@ -import lsprotocol.types as lsp -import pytest -import pytest_lsp -from pytest_lsp import ClientServerConfig, LanguageClient - -from .conftest import ( - DEFINITIONS, - HOVERS, - PATCH_DIR, - PREPARE_RENAMES, - REFERENCES, - RENAMES, - SIGNATURE_HELPS, - SYMBOL_DEFINITIONS, - DefinitionDict, - PrepareRenameDict, - ReferenceDict, - RenameDict, - SignatureHelpDict, - SymbolDefinitionDict, -) - - -@pytest_lsp.fixture( - params=["neovim", "visual_studio_code"], - config=ClientServerConfig(server_command=["spinasm-lsp"]), -) -async def client(request, lsp_client: LanguageClient): - # Setup the server - params = lsp.InitializeParams( - capabilities=pytest_lsp.client_capabilities(request.param) - ) - - await lsp_client.initialize_session(params) - yield - - # Shutdown the server after the test - await lsp_client.shutdown_session() - - -@pytest.mark.asyncio() -@pytest.mark.parametrize("test_case", DEFINITIONS, ids=lambda x: x["symbol"]) -async def test_definition(test_case: DefinitionDict, client: LanguageClient): - """Test that the definition location of different assignments is correct.""" - result = await client.text_document_definition_async( - params=lsp.DefinitionParams( - position=test_case["referenced"], - text_document=lsp.TextDocumentIdentifier(uri=test_case["uri"]), - ) - ) - - assert result == test_case["defined"] - - -@pytest.mark.asyncio() -async def test_completions(client: LanguageClient): - """Test that expected completions are shown with details and documentation.""" - patch = PATCH_DIR / "Basic.spn" - - results = await client.text_document_completion_async( - params=lsp.CompletionParams( - position=lsp.Position(line=0, character=0), - text_document=lsp.TextDocumentIdentifier(uri=f"file:///{patch.absolute()}"), - ) - ) - assert results is not None, "Expected completions" - completions = [item.label for item in results.items] - - expected_completions = [ - # Memory locations - "AP1", - "LAP1A", - "D2", - # Variables - "MONO", - "APOUT", - "KRF", - # Constants - "REG0", - "SIN0", - # Opcodes - "SOF", - "MULX", - "WRAX", - ] - - for completion in expected_completions: - assert completion in completions, f"Expected completion {completion} not found" - - # Completions for defined values should show their literal value - apout_completion = [item for item in results.items if item.label == "APOUT"][0] - assert apout_completion.detail == "(variable) APOUT: Literal[33]" - assert apout_completion.kind == lsp.CompletionItemKind.Variable - assert apout_completion.documentation is None - - # Completions for constant values should show their literal value - reg0_completion = [item for item in results.items if item.label == "REG0"][0] - assert reg0_completion.detail == "(constant) REG0: Literal[32]" - assert reg0_completion.kind == lsp.CompletionItemKind.Constant - assert reg0_completion.documentation is None - - # Completions for opcodes should include their documentation - cho_rda_completion = [item for item in results.items if item.label == "CHO RDA"][0] - assert cho_rda_completion.detail == "(opcode)" - assert cho_rda_completion.kind == lsp.CompletionItemKind.Function - assert "`CHO RDA, N, C, D`" in str(cho_rda_completion.documentation) - - -@pytest.mark.asyncio() -async def test_diagnostic_parsing_errors(client: LanguageClient): - """Test that parsing errors and warnings are correctly reported by the server.""" - source_with_errors = """ -; Undefined symbol a -SOF 0,a - -; Label REG0 re-defined -REG0 EQU 4 - -; Register out of range -MULX 100 -""" - - # We need a URI to associate with the source, but it doesn't need to be a real file. - test_uri = "dummy_uri" - client.text_document_did_open( - lsp.DidOpenTextDocumentParams( - text_document=lsp.TextDocumentItem( - uri=test_uri, - language_id="spinasm", - version=1, - text=source_with_errors, - ) - ) - ) - - await client.wait_for_notification(lsp.TEXT_DOCUMENT_PUBLISH_DIAGNOSTICS) - - expected = [ - lsp.Diagnostic( - range=lsp.Range( - start=lsp.Position(line=2, character=6), - end=lsp.Position(line=2, character=6), - ), - message="Undefined label a", - severity=lsp.DiagnosticSeverity.Error, - source="SPINAsm", - ), - lsp.Diagnostic( - range=lsp.Range( - start=lsp.Position(line=5, character=9), - end=lsp.Position(line=5, character=9), - ), - message="Label REG0 re-defined", - severity=lsp.DiagnosticSeverity.Warning, - source="SPINAsm", - ), - lsp.Diagnostic( - range=lsp.Range( - start=lsp.Position(line=8, character=0), - end=lsp.Position(line=8, character=0), - ), - message="Register 0x64 out of range for MULX", - severity=lsp.DiagnosticSeverity.Error, - source="SPINAsm", - ), - ] - - returned = client.diagnostics[test_uri] - extra = len(returned) - len(expected) - assert extra == 0, f"Expected {len(expected)} diagnostics, got {len(returned)}." - - for i, diag in enumerate(expected): - assert diag == returned[i], f"Diagnostic {i} does not match expected" - - -@pytest.mark.parametrize("test_case", HOVERS, ids=lambda x: x["symbol"]) -@pytest.mark.asyncio() -async def test_hover(test_case: dict, client: LanguageClient): - result = await client.text_document_hover_async( - params=lsp.CompletionParams( - position=test_case["position"], - text_document=lsp.TextDocumentIdentifier(uri=test_case["uri"]), - ) - ) - - if test_case["contains"] is None: - assert result is None, "Expected no hover result" - else: - msg = f"Hover does not contain `{test_case['contains']}`" - assert test_case["contains"] in result.contents.value, msg - - -@pytest.mark.parametrize("test_case", PREPARE_RENAMES, ids=lambda x: x["symbol"]) -@pytest.mark.asyncio() -async def test_prepare_rename(test_case: PrepareRenameDict, client: LanguageClient): - """Test that prepare rename prevents renaming non-user defined tokens.""" - result = await client.text_document_prepare_rename_async( - params=lsp.PrepareRenameParams( - position=test_case["position"], - text_document=lsp.TextDocumentIdentifier(uri=test_case["uri"]), - ) - ) - - assert result == test_case["result"] - - if test_case["message"]: - assert test_case["message"] in client.log_messages[0].message - assert client.log_messages[0].type == lsp.MessageType.Info - else: - assert not client.log_messages - - -@pytest.mark.parametrize("test_case", RENAMES, ids=lambda x: x["symbol"]) -@pytest.mark.asyncio() -async def test_rename(test_case: RenameDict, client: LanguageClient): - """Test that renaming a symbol suggests the correct edits.""" - result = await client.text_document_rename_async( - params=lsp.RenameParams( - position=test_case["position"], - new_name=test_case["rename_to"], - text_document=lsp.TextDocumentIdentifier(uri=test_case["uri"]), - ) - ) - - assert result.changes[test_case["uri"]] == test_case["changes"] - - -@pytest.mark.parametrize("test_case", SYMBOL_DEFINITIONS, ids=lambda x: x["symbol"]) -@pytest.mark.asyncio() -async def test_symbol_definitions( - test_case: SymbolDefinitionDict, client: LanguageClient -): - """Test that the definitions of all symbols in the document are returned.""" - result = await client.text_document_document_symbol_async( - params=lsp.DocumentSymbolParams( - text_document=lsp.TextDocumentIdentifier(uri=test_case["uri"]), - ) - ) - - matching = [item for item in result if item.name == test_case["symbol"].upper()] - assert matching, f"Symbol {test_case['symbol'].upper()} not in document symbols" - - item = matching[0] - assert item.kind == test_case["kind"] - assert item.range == test_case["range"] - - -@pytest.mark.parametrize("test_case", REFERENCES, ids=lambda x: x["symbol"]) -@pytest.mark.asyncio() -async def test_references(test_case: ReferenceDict, client: LanguageClient): - """Test that references to a symbol are correctly found.""" - result = await client.text_document_references_async( - params=lsp.ReferenceParams( - context=lsp.ReferenceContext(include_declaration=False), - position=test_case["position"], - text_document=lsp.TextDocumentIdentifier(uri=test_case["uri"]), - ) - ) - - assert result == test_case["references"] - - -@pytest.mark.parametrize("test_case", SIGNATURE_HELPS, ids=lambda x: x["symbol"]) -@pytest.mark.asyncio() -async def test_signature_help(test_case: SignatureHelpDict, client: LanguageClient): - result = await client.text_document_signature_help_async( - params=lsp.SignatureHelpParams( - context=lsp.SignatureHelpContext( - trigger_kind=lsp.SignatureHelpTriggerKind.Invoked, is_retrigger=False - ), - position=test_case["position"], - text_document=lsp.TextDocumentIdentifier(uri=test_case["uri"]), - ) - ) - - if test_case["active_parameter"] is None: - assert not result - return - - sig: lsp.SignatureInformation = result.signatures[result.active_signature] - param: lsp.ParameterInformation = sig.parameters[result.active_parameter] - - assert test_case["active_parameter"] == result.active_parameter - assert test_case["doc_contains"] in str(sig.documentation) - assert test_case["param_contains"] in param.label