diff --git a/pyproject.toml b/pyproject.toml index 90662655d..2e225e0a1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -308,6 +308,7 @@ markers = [ "zig: language server running for Zig", "lua: language server running for Lua", "luau: language server running for Luau", + "nim: language server running for Nim", "nix: language server running for Nix", "dart: language server running for Dart", "erlang: language server running for Erlang", diff --git a/src/solidlsp/language_servers/nim_language_server.py b/src/solidlsp/language_servers/nim_language_server.py new file mode 100644 index 000000000..dc6fdf1ac --- /dev/null +++ b/src/solidlsp/language_servers/nim_language_server.py @@ -0,0 +1,147 @@ +import logging +import os +import pathlib +from typing import cast + +from solidlsp.ls import SolidLanguageServer +from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo +from solidlsp.settings import SolidLSPSettings + +from ..ls_config import LanguageServerConfig +from ..lsp_protocol_handler.lsp_types import InitializeParams +from .common import RuntimeDependency, RuntimeDependencyCollection + +log = logging.getLogger(__name__) + + +class NimLanguageServer(SolidLanguageServer): + """ + Provides Nim specific instantiation of the LanguageServer class. + Contains various configurations and settings specific to Nim. + + Uses nimlangserver from https://github.com/nim-lang/langserver + """ + + NIMLANGSERVER_VERSION = "1.12.0" + + def is_ignored_dirname(self, dirname: str) -> bool: + nim_ignored_dirs = {"nimcache", "nimblecache", "htmldocs", "nimbledeps"} + return dirname in nim_ignored_dirs or super().is_ignored_dirname(dirname) + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings) -> None: + """ + Creates a NimLanguageServer instance. This class is not meant to be instantiated directly. + Use LanguageServer.create() instead. + """ + executable_path = self._setup_runtime_dependencies(solidlsp_settings) + super().__init__( + config, repository_root_path, ProcessLaunchInfo(cmd=executable_path, cwd=repository_root_path), "nim", solidlsp_settings + ) + + @classmethod + def _setup_runtime_dependencies(cls, solidlsp_settings: SolidLSPSettings) -> str: + v = cls.NIMLANGSERVER_VERSION + base_url = f"https://github.com/nim-lang/langserver/releases/download/v{v}" + deps = RuntimeDependencyCollection( + [ + RuntimeDependency( + id="NimLanguageServer", + description="Nim Language Server for Linux (x64)", + url=f"{base_url}/nimlangserver-linux-amd64.tar.gz", + platform_id="linux-x64", + archive_type="gztar", + binary_name="nimlangserver", + ), + RuntimeDependency( + id="NimLanguageServer", + description="Nim Language Server for Linux (arm64)", + url=f"{base_url}/nimlangserver-linux-arm64.tar.gz", + platform_id="linux-arm64", + archive_type="gztar", + binary_name="nimlangserver", + ), + RuntimeDependency( + id="NimLanguageServer", + description="Nim Language Server for macOS (x64)", + url=f"{base_url}/nimlangserver-macos-amd64.zip", + platform_id="osx-x64", + archive_type="zip", + binary_name="nimlangserver", + ), + RuntimeDependency( + id="NimLanguageServer", + description="Nim Language Server for macOS (arm64)", + url=f"{base_url}/nimlangserver-macos-arm64.zip", + platform_id="osx-arm64", + archive_type="zip", + binary_name="nimlangserver", + ), + RuntimeDependency( + id="NimLanguageServer", + description="Nim Language Server for Windows (x64)", + url=f"{base_url}/nimlangserver-windows-amd64.zip", + platform_id="win-x64", + archive_type="zip", + binary_name="nimlangserver.exe", + ), + ] + ) + + nim_ls_dir = cls.ls_resources_dir(solidlsp_settings) + executable_path = deps.binary_path(nim_ls_dir) + + if not os.path.exists(executable_path): + deps.install(nim_ls_dir) + + assert os.path.exists(executable_path) + os.chmod(executable_path, 0o755) + + return executable_path + + @staticmethod + def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + """ + Returns the initialize params for the Nim Language Server. + """ + root_uri = pathlib.Path(repository_absolute_path).as_uri() + initialize_params = { + "capabilities": {}, + "initializationOptions": {}, + "trace": "verbose", + "processId": os.getpid(), + "rootPath": repository_absolute_path, + "rootUri": root_uri, + "workspaceFolders": [ + { + "uri": root_uri, + "name": os.path.basename(repository_absolute_path), + } + ], + } + + return cast(InitializeParams, initialize_params) + + def _start_server(self) -> None: + """ + Start the Nim language server and yield when the server is ready. + """ + + def do_nothing(params: dict) -> None: + return + + def window_log_message(msg: dict) -> None: + log.info(f"LSP: window/logMessage: {msg}") + + self.server.on_request("client/registerCapability", do_nothing) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + + log.info("Starting nimlangserver server process") + self.server.start() + initialize_params = self._get_initialize_params(self.repository_root_path) + log.debug("Sending initialize request to nimlangserver") + init_response = self.server.send_request("initialize", initialize_params) # type: ignore + log.info(f"Received initialize response from nimlangserver: {init_response}") + + self.server.notify.initialized({}) diff --git a/src/solidlsp/ls_config.py b/src/solidlsp/ls_config.py index 2633e62f0..dcc243595 100644 --- a/src/solidlsp/ls_config.py +++ b/src/solidlsp/ls_config.py @@ -58,6 +58,7 @@ class Language(str, Enum): Uses luau-lsp by JohnnyMorganz. Automatically downloads the binary if not found. Supports .luau files. Configure via .luaurc in the project root. """ + NIM = "nim" NIX = "nix" ERLANG = "erlang" OCAML = "ocaml" @@ -243,6 +244,8 @@ def get_source_fn_matcher(self) -> FilenameMatcher: return FilenameMatcher("*.lua") case self.LUAU: return FilenameMatcher("*.luau") + case self.NIM: + return FilenameMatcher("*.nim", "*.nims") case self.NIX: return FilenameMatcher("*.nix") case self.ERLANG: @@ -434,6 +437,11 @@ def get_ls_class(self) -> type["SolidLanguageServer"]: return LuaLanguageServer + case self.NIM: + from solidlsp.language_servers.nim_language_server import NimLanguageServer + + return NimLanguageServer + case self.LUAU: from solidlsp.language_servers.luau_lsp import LuauLanguageServer diff --git a/test/resources/repos/nim/test_repo/main.nim b/test/resources/repos/nim/test_repo/main.nim new file mode 100644 index 000000000..7163a95d3 --- /dev/null +++ b/test/resources/repos/nim/test_repo/main.nim @@ -0,0 +1,35 @@ +## Main entry point demonstrating cross-module usage. + +import src/calculator +import src/utils + +proc printBanner*() = + ## Print a welcome banner. + echo repeat_string("=", 40) + echo " Nim Calculator Demo" + echo repeat_string("=", 40) + +proc testCalculator*() = + ## Run calculator tests. + echo "Testing calculator..." + echo "add(2, 3) = ", add(2.0, 3.0) + echo "subtract(10, 4) = ", subtract(10.0, 4.0) + echo "multiply(3, 7) = ", multiply(3.0, 7.0) + echo "divide(15, 3) = ", divide(15.0, 3.0) + echo "factorial(5) = ", factorial(5) + echo "mean(@[1.0, 2.0, 3.0, 4.0, 5.0]) = ", mean(@[1.0, 2.0, 3.0, 4.0, 5.0]) + +proc testUtils*() = + ## Run utility tests. + let logger = newLogger("test") + logger.log("Testing utilities...") + echo "trim(' hello ') = '", trim(" hello "), "'" + echo "split_words('hello world') = ", split_words("hello world") + echo "starts_with('hello', 'he') = ", starts_with("hello", "he") + echo "ends_with('hello', 'lo') = ", ends_with("hello", "lo") + logger.info("Utility tests complete") + +when isMainModule: + printBanner() + testCalculator() + testUtils() diff --git a/test/resources/repos/nim/test_repo/src/calculator.nim b/test/resources/repos/nim/test_repo/src/calculator.nim new file mode 100644 index 000000000..8424010b4 --- /dev/null +++ b/test/resources/repos/nim/test_repo/src/calculator.nim @@ -0,0 +1,36 @@ +## Calculator module with basic and advanced arithmetic operations. + +proc add*(a, b: float): float = + ## Add two numbers. + result = a + b + +proc subtract*(a, b: float): float = + ## Subtract b from a. + result = a - b + +proc multiply*(a, b: float): float = + ## Multiply two numbers. + result = a * b + +proc divide*(a, b: float): float = + ## Divide a by b. Raises DivByZeroDefect if b is zero. + if b == 0.0: + raise newException(DivByZeroDefect, "Cannot divide by zero") + result = a / b + +proc factorial*(n: int): int = + ## Compute factorial of a non-negative integer. + if n < 0: + raise newException(ValueError, "Factorial not defined for negative numbers") + if n <= 1: + return 1 + result = n * factorial(n - 1) + +proc mean*(values: seq[float]): float = + ## Compute the arithmetic mean of a sequence of floats. + if values.len == 0: + raise newException(ValueError, "Cannot compute mean of empty sequence") + var total = 0.0 + for v in values: + total += v + result = total / float(values.len) diff --git a/test/resources/repos/nim/test_repo/src/utils.nim b/test/resources/repos/nim/test_repo/src/utils.nim new file mode 100644 index 000000000..da48309be --- /dev/null +++ b/test/resources/repos/nim/test_repo/src/utils.nim @@ -0,0 +1,48 @@ +## Utility functions for string and sequence operations. + +import std/strutils + +proc trim*(s: string): string = + ## Remove leading and trailing whitespace. + result = s.strip() + +proc split_words*(s: string): seq[string] = + ## Split a string into words by whitespace. + result = s.splitWhitespace() + +proc starts_with*(s, prefix: string): bool = + ## Check if s starts with the given prefix. + result = s.startsWith(prefix) + +proc ends_with*(s, suffix: string): bool = + ## Check if s ends with the given suffix. + result = s.endsWith(suffix) + +proc repeat_string*(s: string, count: int): string = + ## Repeat a string count times. + result = s.repeat(count) + +type + Logger* = object + ## A simple logger with a name and level. + name*: string + level*: int + +proc newLogger*(name: string, level: int = 0): Logger = + ## Create a new Logger instance. + result = Logger(name: name, level: level) + +proc log*(logger: Logger, message: string) = + ## Log a message if level is sufficient. + if logger.level >= 0: + echo "[" & logger.name & "] " & message + +proc debug*(logger: Logger, message: string) = + ## Log a debug message. + if logger.level <= 0: + echo "[DEBUG " & logger.name & "] " & message + +proc info*(logger: Logger, message: string) = + ## Log an info message. + if logger.level <= 1: + echo "[INFO " & logger.name & "] " & message diff --git a/test/resources/repos/nim/test_repo/tests/test_calculator.nim b/test/resources/repos/nim/test_repo/tests/test_calculator.nim new file mode 100644 index 000000000..af6ece54f --- /dev/null +++ b/test/resources/repos/nim/test_repo/tests/test_calculator.nim @@ -0,0 +1,27 @@ +## Tests for the calculator module. + +import ../src/calculator + +proc testBasicOperations() = + assert add(2.0, 3.0) == 5.0 + assert subtract(10.0, 4.0) == 6.0 + assert multiply(3.0, 7.0) == 21.0 + assert divide(15.0, 3.0) == 5.0 + echo "Basic operations: PASSED" + +proc testAdvancedOperations() = + assert factorial(0) == 1 + assert factorial(1) == 1 + assert factorial(5) == 120 + echo "Advanced operations: PASSED" + +proc testMean() = + assert mean(@[1.0, 2.0, 3.0]) == 2.0 + assert mean(@[10.0]) == 10.0 + echo "Mean operations: PASSED" + +when isMainModule: + testBasicOperations() + testAdvancedOperations() + testMean() + echo "All tests passed!" diff --git a/test/solidlsp/nim/__init__.py b/test/solidlsp/nim/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/test/solidlsp/nim/test_nim_basic.py b/test/solidlsp/nim/test_nim_basic.py new file mode 100644 index 000000000..1f988b884 --- /dev/null +++ b/test/solidlsp/nim/test_nim_basic.py @@ -0,0 +1,175 @@ +""" +Tests for the Nim language server implementation. + +These tests validate symbol finding and cross-file reference capabilities +for Nim modules and functions. +""" + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from solidlsp.ls_types import SymbolKind + + +@pytest.mark.nim +class TestNimLanguageServer: + """Test Nim language server symbol finding and cross-file references.""" + + @pytest.mark.parametrize("language_server", [Language.NIM], indirect=True) + def test_find_symbols_in_calculator(self, language_server: SolidLanguageServer) -> None: + """Test finding specific functions in calculator.nim.""" + symbols = language_server.request_document_symbols("src/calculator.nim").get_all_symbols_and_roots() + + assert symbols is not None + assert len(symbols) > 0 + + symbol_list = symbols[0] if isinstance(symbols, tuple) else symbols + function_names = set() + for symbol in symbol_list: + if isinstance(symbol, dict): + name = symbol.get("name", "") + if symbol.get("kind") == SymbolKind.Function: + function_names.add(name) + + expected_functions = {"add", "subtract", "multiply", "divide", "factorial", "mean"} + found_functions = function_names & expected_functions + assert found_functions == expected_functions, f"Expected {expected_functions}, found {found_functions}" + + @pytest.mark.parametrize("language_server", [Language.NIM], indirect=True) + def test_find_symbols_in_utils(self, language_server: SolidLanguageServer) -> None: + """Test finding specific functions and types in utils.nim.""" + symbols = language_server.request_document_symbols("src/utils.nim").get_all_symbols_and_roots() + + assert symbols is not None + assert len(symbols) > 0 + + symbol_list = symbols[0] if isinstance(symbols, tuple) else symbols + function_names = set() + all_names = set() + + for symbol in symbol_list: + if isinstance(symbol, dict): + name = symbol.get("name", "") + all_names.add(name) + if symbol.get("kind") == SymbolKind.Function: + function_names.add(name) + + expected_utils = {"trim", "split_words", "starts_with", "ends_with", "repeat_string"} + found_utils = function_names & expected_utils + assert found_utils == expected_utils, f"Expected {expected_utils}, found {found_utils}" + + # Check for Logger type + assert "Logger" in all_names or any("Logger" in s for s in all_names), "Logger type not found in symbols" + + @pytest.mark.parametrize("language_server", [Language.NIM], indirect=True) + def test_find_symbols_in_main(self, language_server: SolidLanguageServer) -> None: + """Test finding functions in main.nim.""" + symbols = language_server.request_document_symbols("main.nim").get_all_symbols_and_roots() + + assert symbols is not None + assert len(symbols) > 0 + + symbol_list = symbols[0] if isinstance(symbols, tuple) else symbols + function_names = set() + + for symbol in symbol_list: + if isinstance(symbol, dict) and symbol.get("kind") == SymbolKind.Function: + function_names.add(symbol.get("name", "")) + + expected_funcs = {"printBanner", "testCalculator", "testUtils"} + found_funcs = function_names & expected_funcs + assert found_funcs == expected_funcs, f"Expected {expected_funcs}, found {found_funcs}" + + @pytest.mark.parametrize("language_server", [Language.NIM], indirect=True) + def test_cross_file_references_calculator_add(self, language_server: SolidLanguageServer) -> None: + """Test finding cross-file references to calculator.add function.""" + symbols = language_server.request_document_symbols("src/calculator.nim").get_all_symbols_and_roots() + + assert symbols is not None + symbol_list = symbols[0] if isinstance(symbols, tuple) else symbols + + add_symbol = None + for sym in symbol_list: + if isinstance(sym, dict) and sym.get("name") == "add": + add_symbol = sym + break + + assert add_symbol is not None, "add function not found in calculator.nim" + + range_info = add_symbol.get("selectionRange", add_symbol.get("range")) + assert range_info is not None, "add function has no range information" + + range_start = range_info["start"] + refs = language_server.request_references("src/calculator.nim", range_start["line"], range_start["character"]) + + assert refs is not None + assert isinstance(refs, list) + assert len(refs) >= 2, f"Should find at least 2 references to add (declaration + usage), found {len(refs)}" + + ref_files: dict[str, list[int]] = {} + for ref in refs: + filename = ref.get("uri", "").split("/")[-1] + if filename not in ref_files: + ref_files[filename] = [] + ref_files[filename].append(ref["range"]["start"]["line"]) + + # main.nim should reference calculator.add + assert "main.nim" in ref_files, "Should find add usages in main.nim" + + @pytest.mark.parametrize("language_server", [Language.NIM], indirect=True) + def test_cross_file_references_utils_trim(self, language_server: SolidLanguageServer) -> None: + """Test finding cross-file references to utils.trim function.""" + symbols = language_server.request_document_symbols("src/utils.nim").get_all_symbols_and_roots() + + assert symbols is not None + symbol_list = symbols[0] if isinstance(symbols, tuple) else symbols + + trim_symbol = None + for sym in symbol_list: + if isinstance(sym, dict) and sym.get("name") == "trim": + trim_symbol = sym + break + + assert trim_symbol is not None, "trim function not found in utils.nim" + + range_info = trim_symbol.get("selectionRange", trim_symbol.get("range")) + assert range_info is not None + + range_start = range_info["start"] + refs = language_server.request_references("src/utils.nim", range_start["line"], range_start["character"]) + + assert refs is not None + assert isinstance(refs, list) + assert len(refs) >= 1, f"Should find at least 1 reference to trim, found {len(refs)}" + + ref_files: dict[str, list[int]] = {} + for ref in refs: + filename = ref.get("uri", "").split("/")[-1] + if filename not in ref_files: + ref_files[filename] = [] + ref_files[filename].append(ref["range"]["start"]["line"]) + + assert "main.nim" in ref_files, "Should find trim usage in main.nim" + + @pytest.mark.parametrize("language_server", [Language.NIM], indirect=True) + def test_hover_information(self, language_server: SolidLanguageServer) -> None: + """Test hover information for symbols.""" + hover_info = language_server.request_hover("src/calculator.nim", 2, 5) + + assert hover_info is not None, "Should provide hover information" + + if isinstance(hover_info, dict): + assert "contents" in hover_info or "value" in hover_info, "Hover should have contents" + + @pytest.mark.parametrize("language_server", [Language.NIM], indirect=True) + def test_full_symbol_tree(self, language_server: SolidLanguageServer) -> None: + """Test that full symbol tree is not empty.""" + symbols = language_server.request_full_symbol_tree() + + assert symbols is not None + assert len(symbols) > 0, "Symbol tree should not be empty" + + root = symbols[0] + assert isinstance(root, dict), "Root should be a dict" + assert "name" in root, "Root should have a name"