diff --git a/doc/filegen.md b/doc/filegen.md index 13a486e7..3ce356a9 100644 --- a/doc/filegen.md +++ b/doc/filegen.md @@ -29,7 +29,10 @@ This has two main advantages: ## Usage -To re-generate the files run: +The files specified in `files.*` are regenerated automatically when entering +the devshell (unless `filegen_hook` is set to false). + +They can also be regenerated manually with: ```bash nix run .#regenerate-files diff --git a/lib/files.ncl b/lib/files.ncl index 15414367..277c2ff5 100644 --- a/lib/files.ncl +++ b/lib/files.ncl @@ -11,7 +11,18 @@ let File = { | doc m%" The content of the file. "% - | nix.derivation.NixString, + | nix.derivation.NullOr nix.derivation.NixString + | default + = null, + file + | doc "File from which to read the body of the script" + | nix.derivation.NullOr nix.derivation.NixString + | default + = + if content == null then + null + else + nix.builtins.to_file "generated-content" content, materialisation_method : [| 'Symlink, 'Copy |] | doc m%" @@ -35,7 +46,6 @@ let regenerate_files | Files -> nix.derivation.Derivation = fun files_to_generate => let regnerate_one | String -> File -> nix.derivation.NixString = fun key file_descr => - let file_content = file_descr.content in let target = file_descr.target in let copy_command = match { @@ -44,17 +54,17 @@ let regenerate_files | Files -> nix.derivation.Derivation } file_descr.materialisation_method in - let file_in_store = - nix.builtins.to_file - (nix.utils.escape_drv_name key) - file_content - in nix-s%" - rm -f %{target} - echo "Regenerating %{target}" - target_dir=$(dirname %{target}) - test "${target_dir}" != "." && mkdir -p "${target_dir}" - %{copy_command} %{file_in_store} %{target} + if [[ ! -f "%{target}" ]] || [[ $(cat "%{target}") != $(cat "%{file_descr.file}") ]]; then + rm -f %{target} + echo "Regenerating %{target}" + target_dir=$(dirname "%{target}") + test "${target_dir}" != "." && mkdir -p "${target_dir}" + # XXX: If `source.file` is set explicitely to a relative path + # and `materialisation_method` is `'Symlink`, this will link to the + # original file, not one in the store. Not sure that's what we want. + %{copy_command} "%{file_descr.file}" "%{target}" + fi "% in { @@ -84,11 +94,32 @@ let regenerate_files | Files -> nix.derivation.Derivation Set of files that should be generated in the project's directory. "% = {}, + filegen_hook.enable + | Bool + | doc m%" + Enable a hook that will automatically regenerate the files managed by + Nickel when entering the shell. + "% + | default + = true, flake.apps, # Forward declaration. Not great but would need some refactor to fix + shells, }, config | Schema = { files, + filegen_hook, + shells, flake.apps.regenerate-files.program = nix-s%"%{regenerate_files files}/bin/regenerate-files"%, + + shells.build.hooks = + if filegen_hook.enable then + { + filegen_hook = nix-s%" + %{regenerate_files files}/bin/regenerate-files + "% + } + else + {}, }, } diff --git a/lib/lib.nix b/lib/lib.nix index 3eaa119a..5fa469e3 100644 --- a/lib/lib.nix +++ b/lib/lib.nix @@ -169,23 +169,19 @@ passAsFile = ["expectedLockfileContents"]; } ( if needNewLockfile - then - lib.warn '' - Lockfile contents are outdated. Please run "nix run .#regenerate-lockfile" to update them. - '' - '' - cp -r "${sources}" sources - if [ -f sources/nickel.lock.ncl ]; then - chmod +w sources sources/nickel.lock.ncl - else - chmod +w sources - fi - cp $expectedLockfileContentsPath sources/nickel.lock.ncl - cat > eval.ncl < $out - '' + then '' + cp -r "${sources}" sources + if [ -f sources/nickel.lock.ncl ]; then + chmod +w sources sources/nickel.lock.ncl + else + chmod +w sources + fi + cp $expectedLockfileContentsPath sources/nickel.lock.ncl + cat > eval.ncl < $out + '' else '' cat > eval.ncl <&2 + echo '{}' > nickel.lock.ncl nix develop --accept-flake-config --print-build-logs --command bash <<<"$TEST_SCRIPT" if [[ $isFull == false ]]; then @@ -62,17 +62,13 @@ test_one_template () ( fi echo "Running without nickel.lock.ncl" 1>&2 - rm nickel.lock.ncl + rm -f nickel.lock.ncl nix develop --accept-flake-config --print-build-logs --command bash <<<"$TEST_SCRIPT" echo "Run with proper nickel.lock.ncl" 1>&2 - nix run .\#regenerate-lockfile - PROPER_LOCKFILE_CONTENTS="$(cat nickel.lock.ncl)" nix develop --accept-flake-config --print-build-logs --command bash <<<"$TEST_SCRIPT" echo "Testing without flakes" 1>&2 - # restore lockfile - cat > nickel.lock.ncl <<<"$STORED_LOCKFILE_CONTENTS" # pretend it's not flake anymore rm flake.* cat > shell.nix <&2 + rm -f nickel.lock.ncl + echo '{}' > nickel.lock.ncl nix develop --impure -f shell.nix -I nixpkgs="$NIXPKGS_PATH" --command bash <<<"$TEST_SCRIPT" echo "Running without nickel.lock.ncl" 1>&2 - rm nickel.lock.ncl + rm -f nickel.lock.ncl nix develop --impure -f shell.nix -I nixpkgs="$NIXPKGS_PATH" --command bash <<<"$TEST_SCRIPT" echo "Run with proper nickel.lock.ncl" 1>&2 - cat > nickel.lock.ncl <<<"$PROPER_LOCKFILE_CONTENTS" nix develop --impure -f shell.nix -I nixpkgs="$NIXPKGS_PATH" --command bash <<<"$TEST_SCRIPT" popd @@ -120,7 +117,6 @@ test_example () ( cp -r "$examplePath" ./example pushd ./example prepare_shell - nix run .\#regenerate-files --print-build-logs nix develop --print-build-logs --command bash test.sh popd popd 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..fc80b80d --- /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=10, 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 +