Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Test the lsp interaction #210

Merged
merged 1 commit into from
Jun 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions project.ncl
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions tests/lsp/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
__pycache__
21 changes: 21 additions & 0 deletions tests/lsp/conftest.py
Original file line number Diff line number Diff line change
@@ -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

1 change: 1 addition & 0 deletions tests/lsp/template/nickel.lock.ncl
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{ organist = import "../../../lib/organist.ncl" }
109 changes: 109 additions & 0 deletions tests/lsp/test_completion.py
Original file line number Diff line number Diff line change
@@ -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 != ""
90 changes: 90 additions & 0 deletions tests/lsp/test_hover.py
Original file line number Diff line number Diff line change
@@ -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


33 changes: 33 additions & 0 deletions tests/lsp/testlib.py
Original file line number Diff line number Diff line change
@@ -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

Loading