From bcb08116d3431f7daf0bed07992374f0a6831002 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9ophane=20Hufschmitt?= Date: Wed, 19 Jun 2024 11:35:11 +0200 Subject: [PATCH] Add tests for the lsp integration Ensure that autocompletion works fine for simple things --- project.ncl | 21 ++++++ tests/lsp/.gitignore | 1 + tests/lsp/conftest.py | 21 ++++++ tests/lsp/template/nickel.lock.ncl | 1 + tests/lsp/test_completion.py | 109 +++++++++++++++++++++++++++++ tests/lsp/test_hover.py | 90 ++++++++++++++++++++++++ tests/lsp/testlib.py | 33 +++++++++ 7 files changed, 276 insertions(+) create mode 100644 tests/lsp/.gitignore create mode 100644 tests/lsp/conftest.py create mode 100644 tests/lsp/template/nickel.lock.ncl create mode 100644 tests/lsp/test_completion.py create mode 100644 tests/lsp/test_hover.py create mode 100644 tests/lsp/testlib.py diff --git a/project.ncl b/project.ncl index 1ba551e4..1a040fac 100644 --- a/project.ncl +++ b/project.ncl @@ -70,6 +70,27 @@ organist.OrganistExpression touch $out "%, }, + + lsp = { + name = "organist-lsp-integration", + version = "0.0", + env.buildInputs = { + nls = import_nix "nixpkgs#nls", + python3 = import_nix "nixpkgs#python3", + pygls = import_nix "nixpkgs#python3Packages.pygls", + pytest = import_nix "nixpkgs#python3Packages.pytest", + pytest-asyncio = import_nix "nixpkgs#python3Packages.pytest-asyncio", + }, + env = { + src = import_nix "self", + phases = ["unpackPhase", "testPhase", "installPhase"], + testPhase = nix-s%" + cd tests/lsp + pytest | tee $out + "%, + installPhase = "touch $out", + }, + }, }, flake.checks = import "tests/main.ncl", diff --git a/tests/lsp/.gitignore b/tests/lsp/.gitignore new file mode 100644 index 00000000..bee8a64b --- /dev/null +++ b/tests/lsp/.gitignore @@ -0,0 +1 @@ +__pycache__ diff --git a/tests/lsp/conftest.py b/tests/lsp/conftest.py new file mode 100644 index 00000000..b7f72c58 --- /dev/null +++ b/tests/lsp/conftest.py @@ -0,0 +1,21 @@ +import testlib +import asyncio +import pytest +import pytest_asyncio +from lsprotocol import types as lsp + +@pytest_asyncio.fixture +async def client(): + # Setup + client = testlib.LanguageClient("organist-test-suite", "v1") + await client.start_io("nls") + response = await client.initialize_async( + lsp.InitializeParams( + capabilities=lsp.ClientCapabilities(), + root_uri="." + ) + ) + assert response is not None + client.initialized(lsp.InitializedParams()) + return client + diff --git a/tests/lsp/template/nickel.lock.ncl b/tests/lsp/template/nickel.lock.ncl new file mode 100644 index 00000000..585b5cd4 --- /dev/null +++ b/tests/lsp/template/nickel.lock.ncl @@ -0,0 +1 @@ +{ organist = import "../../../lib/organist.ncl" } diff --git a/tests/lsp/test_completion.py b/tests/lsp/test_completion.py new file mode 100644 index 00000000..e85b97a4 --- /dev/null +++ b/tests/lsp/test_completion.py @@ -0,0 +1,109 @@ +import pytest +import pytest_asyncio +from lsprotocol import types as lsp +from testlib import LanguageClient, open_file + +async def complete(client: LanguageClient, file_uri: str, pos: lsp.Position): + """ + Trigger an autocompletion in the given file at the given position + """ + results = await client.text_document_completion_async( + params=lsp.CompletionParams( + text_document=lsp.TextDocumentIdentifier(file_uri), + position=pos, + ) + ) + assert results is not None + + if isinstance(results, lsp.CompletionList): + items = results.items + else: + items = results + return items + +@pytest.mark.asyncio +async def test_completion_at_toplevel(client): + """ + Test that getting an autocompletion at toplevel shows the available fields + """ + + test_file = 'template/project.ncl' + with open('../../templates/default/project.ncl') as template_file: + test_file_content = template_file.read() + + test_uri = open_file(client, test_file, test_file_content) + + completion_items = await complete( + client, + test_uri, + lsp.Position(line=12, character=0) # Empty line in the `config` record + ) + + labels = [item.label for item in completion_items] + assert "files" in labels + files_item = [item for item in completion_items if item.label == "files"][0] + assert files_item.documentation.value != "" + +@pytest.mark.asyncio +async def test_completion_sub_field(client: LanguageClient): + """ + Test that completing on an option shows the available sub-options + """ + test_file = 'template/projectxx.ncl' + test_file_content = """ +let inputs = import "./nickel.lock.ncl" in +let organist = inputs.organist in + +organist.OrganistExpression +& { + Schema, + config | Schema = { + files.foo.c + }, +} +| organist.modules.T + """ + test_uri = open_file(client, test_file, test_file_content) + completion_items = await complete( + client, + test_uri, + lsp.Position(line=8, character=17) # The `c` in `files.foo.c` + ) + + labels = [item.label for item in completion_items] + assert "content" in labels + content_item = [item for item in completion_items if item.label == "content"][0] + assert content_item.documentation.value != "" + +@pytest.mark.asyncio +async def test_completion_with_custom_module(client: LanguageClient): + """ + Test that completing takes into account extra modules + """ + test_file = 'template/projectxx.ncl' + test_file_content = """ +let inputs = import "./nickel.lock.ncl" in +let organist = inputs.organist in + +organist.OrganistExpression & organist.tools.direnv +& { + Schema, + config | Schema = { + + }, +} +| organist.modules.T + """ + test_uri = open_file(client, test_file, test_file_content) + completion_items = await complete( + client, + test_uri, + lsp.Position(line=8, character=0) # Empty line in the `config` record + ) + + labels = [item.label for item in completion_items] + assert "direnv" in labels + + ## No documentation for direnv yet + # content_item = [item for item in completion_items if item.label == "direnv"][0] + # assert content_item.documentation.value != "" diff --git a/tests/lsp/test_hover.py b/tests/lsp/test_hover.py new file mode 100644 index 00000000..8d8946e7 --- /dev/null +++ b/tests/lsp/test_hover.py @@ -0,0 +1,90 @@ +import pytest +import pytest_asyncio +from lsprotocol import types as lsp +from testlib import LanguageClient, open_file +from dataclasses import dataclass +from typing import Callable, List + +async def hover(client: LanguageClient, file_uri: str, pos: lsp.Position): + """ + Trigger a hover in the given file at the given position + """ + results = await client.text_document_hover_async( + params=lsp.HoverParams( + text_document=lsp.TextDocumentIdentifier(file_uri), + position=pos, + ) + ) + return results + +@dataclass +class HoverTest: + file: str + position: lsp.Position + checks: Callable[[lsp.Hover], List[bool]] + + +@pytest.mark.asyncio +async def test_hover_on_option(client: LanguageClient): + """ + Test that hovering over an option shows the right thing™ + """ + test_file = 'template/projectxx.ncl' + test_file_content = """ +let inputs = import "./nickel.lock.ncl" in +let organist = inputs.organist in + +organist.OrganistExpression & organist.tools.direnv +& { + Schema, + config | Schema = { + files."foo.ncl".content = "1", + + shells = organist.shells.Bash, + }, +} +| organist.modules.T + """ + test_uri = open_file(client, test_file, test_file_content) + + tests = [ + HoverTest( + file=test_uri, + position=lsp.Position(line=8, character=11), # `files` + checks= lambda hover_info: [ + lsp.MarkedString_Type1(language='nickel', value='Files') in hover_info.contents, + # Test that the contents contain a plain string (the documentation), and that it's non empty + next(content for content in hover_info.contents if type(content) is str) != "", + ] + ), + HoverTest( + file=test_uri, + position=lsp.Position(line=8, character=28), # `content` + checks= lambda hover_info: [ + lsp.MarkedString_Type1(language='nickel', value='nix.derivation.NullOr nix.derivation.NixString') in hover_info.contents, + # Test that the contents contain a plain string (the documentation), and that it's non empty + next(content for content in hover_info.contents if type(content) is str) != "", + ] + ), + HoverTest( + file=test_uri, + position=lsp.Position(line=10, character=11), # `shells( =)` + checks= lambda hover_info: [ + lsp.MarkedString_Type1(language='nickel', value='OrganistShells') in hover_info.contents, + # Test that the contents contain a plain string (the documentation), and that it's non empty + next(content for content in hover_info.contents if type(content) is str) != "", + ] + ), + ] + + for test in tests: + hover_info = await hover( + client, + test.file, + test.position, + ) + print(hover_info.contents) + for check in test.checks(hover_info): + assert check + + diff --git a/tests/lsp/testlib.py b/tests/lsp/testlib.py new file mode 100644 index 00000000..1c5d46b9 --- /dev/null +++ b/tests/lsp/testlib.py @@ -0,0 +1,33 @@ +from pygls.lsp.client import BaseLanguageClient +from typing import Optional +import os +from lsprotocol import types as lsp + +class LanguageClient(BaseLanguageClient): + pass + +def open_file(client: LanguageClient, file_path: str, file_content: Optional[str] = None): + """ + Open the given file in the LSP. + + If `file_content` is non `None`, then it will be used as the content sent to the LSP. + Otherwise, the actual file content will be read from disk. + """ + file_uri = f"file://{os.path.abspath(file_path)}" + actual_file_content = file_content + if file_content is None: + with open(file_path) as content: + actual_file_content = content.read() + + client.text_document_did_open( + lsp.DidOpenTextDocumentParams( + text_document=lsp.TextDocumentItem( + uri=file_uri, + language_id="nickel", + version=1, + text=actual_file_content + ) + ) + ) + return file_uri +