From 508f6f270b8dbe0292a4b43824057adda36421a3 Mon Sep 17 00:00:00 2001 From: zerone0x Date: Tue, 10 Mar 2026 03:44:43 +0100 Subject: [PATCH] feat: Add Nim language server support via nimlangserver Adds support for Nim programming language using nimlangserver (https://github.com/nim-lang/langserver). - Add NimLanguageServer class in src/solidlsp/language_servers/nim_language_server.py - Register Language.NIM enum value with .nim and .nims file matchers - Add NIM to the language server factory - Create test repository with calculator and utils modules - Add basic integration tests for symbol finding and cross-file references - Update README.md, docs, CHANGELOG.md Resolves #645 Co-Authored-By: Claude --- CHANGELOG.md | 1 + README.md | 2 +- docs/01-about/020_programming-languages.md | 2 + pyproject.toml | 1 + .../language_servers/nim_language_server.py | 152 ++++++++++++++++++ src/solidlsp/ls_config.py | 7 + test/resources/repos/nim/test_repo/main.nim | 50 ++++++ .../repos/nim/test_repo/src/calculator.nim | 38 +++++ .../repos/nim/test_repo/src/utils.nim | 54 +++++++ .../repos/nim/test_repo/test_repo.nimble | 12 ++ test/solidlsp/nim/__init__.py | 0 test/solidlsp/nim/test_nim_basic.py | 121 ++++++++++++++ 12 files changed, 439 insertions(+), 1 deletion(-) create mode 100644 src/solidlsp/language_servers/nim_language_server.py create mode 100644 test/resources/repos/nim/test_repo/main.nim create mode 100644 test/resources/repos/nim/test_repo/src/calculator.nim create mode 100644 test/resources/repos/nim/test_repo/src/utils.nim create mode 100644 test/resources/repos/nim/test_repo/test_repo.nimble create mode 100644 test/solidlsp/nim/__init__.py create mode 100644 test/solidlsp/nim/test_nim_basic.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e74f715af..0d66d2af8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,7 @@ Status of the `main` branch. Changes prior to the next official version change w * Language support: + * **Add support for Nim** via [nimlangserver](https://github.com/nim-lang/langserver) (requires `nimble install nimlangserver`) * **Add support for OCaml** via ocaml-lsp-server with cross-file reference support on OCaml 5.2+ (requires opam; see [setup guide](docs/03-special-guides/ocaml_setup_guide_for_serena.md)) * **Add Phpactor as alternative PHP language server** (specify `php_phpactor` as language; requires PHP 8.1+) * **Add support for Fortran** via fortls language server (requires `pip install fortls`) diff --git a/README.md b/README.md index be511138d..328d9d354 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ that implement the language server protocol (LSP). The underlying language servers are typically open-source projects (like Serena) or at least freely available for use. With Serena's LSP library, we provide **support for over 30 programming languages**, including -AL, Bash, C#, C/C++, Clojure, Dart, Elixir, Elm, Erlang, Fortran, GLSL, Go, Groovy (partial support), Haskell, HLSL, Java, Javascript, Julia, Kotlin, Lua, Markdown, MATLAB, Nix, OCaml, Perl, PHP, PowerShell, Python, R, Ruby, Rust, Scala, Swift, TOML, TypeScript, WGSL, YAML, and Zig. +AL, Bash, C#, C/C++, Clojure, Dart, Elixir, Elm, Erlang, Fortran, GLSL, Go, Groovy (partial support), Haskell, HLSL, Java, Javascript, Julia, Kotlin, Lua, Markdown, MATLAB, Nim, Nix, OCaml, Perl, PHP, PowerShell, Python, R, Ruby, Rust, Scala, Swift, TOML, TypeScript, WGSL, YAML, and Zig. > [!IMPORTANT] > Some language servers require additional dependencies to be installed; see the [Language Support](https://oraios.github.io/serena/01-about/020_programming-languages.html) page for details. diff --git a/docs/01-about/020_programming-languages.md b/docs/01-about/020_programming-languages.md index 94d617e23..940a465d9 100644 --- a/docs/01-about/020_programming-languages.md +++ b/docs/01-about/020_programming-languages.md @@ -67,6 +67,8 @@ Some languages require additional installations or setup steps, as noted. * **Lua** * **Markdown** (must explicitly enable language `markdown`, primarily useful for documentation-heavy projects) +* **Nim** + (requires installation of [nimlangserver](https://github.com/nim-lang/langserver): `nimble install nimlangserver`) * **Nix** (requires nixd installation) * **OCaml** diff --git a/pyproject.toml b/pyproject.toml index b1f9448ed..2e6325557 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -319,6 +319,7 @@ markers = [ "julia: Julia language server tests", "fortran: language server running for Fortran", "haskell: Haskell language server tests", + "nim: language server running for Nim", "yaml: language server running for YAML", "powershell: language server running for PowerShell", "pascal: language server running for Pascal (Free Pascal/Lazarus)", 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..2a7321a17 --- /dev/null +++ b/src/solidlsp/language_servers/nim_language_server.py @@ -0,0 +1,152 @@ +""" +Provides Nim specific instantiation of the LanguageServer class using nimlangserver +(https://github.com/nim-lang/langserver). +""" + +import logging +import os +import pathlib +import platform +import shutil + +from solidlsp.ls import SolidLanguageServer +from solidlsp.ls_config import LanguageServerConfig +from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams +from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo +from solidlsp.settings import SolidLSPSettings + +log = logging.getLogger(__name__) + + +class NimLanguageServer(SolidLanguageServer): + """ + Provides Nim specific instantiation of the LanguageServer class using nimlangserver. + """ + + @staticmethod + def _check_nimlangserver_installed() -> bool: + """Check if nimlangserver is installed in the system.""" + return shutil.which("nimlangserver") is not None + + @staticmethod + def _setup_runtime_dependency() -> str: + """ + Verify that nimlangserver is available and return its path. + Raises RuntimeError with helpful message if missing. + """ + if platform.system() == "Windows": + raise RuntimeError( + "Nim language server support on Windows is experimental. " + "Please install nimlangserver via nimble and add it to your PATH." + ) + + nimlangserver_path = shutil.which("nimlangserver") + if not nimlangserver_path: + raise RuntimeError( + "nimlangserver (Nim Language Server) is not installed.\n" + "Please install it using one of the following methods:\n" + " - Using nimble: nimble install nimlangserver\n" + " - From the GitHub releases: https://github.com/nim-lang/langserver/releases\n" + "After installation, make sure 'nimlangserver' is in your PATH.\n" + "Nim itself can be installed from https://nim-lang.org/install.html" + ) + + return nimlangserver_path + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + nimlangserver_path = self._setup_runtime_dependency() + + super().__init__( + config, + repository_root_path, + ProcessLaunchInfo(cmd=nimlangserver_path, cwd=repository_root_path), + "nim", + solidlsp_settings, + ) + self.request_id = 0 + + @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 = { + "locale": "en", + "capabilities": { + "textDocument": { + "synchronization": {"didSave": True, "dynamicRegistration": True}, + "definition": {"dynamicRegistration": True}, + "references": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "hierarchicalDocumentSymbolSupport": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + "completion": { + "dynamicRegistration": True, + "completionItem": { + "snippetSupport": True, + "commitCharactersSupport": True, + "documentationFormat": ["markdown", "plaintext"], + "deprecatedSupport": True, + "preselectSupport": True, + }, + }, + "hover": { + "dynamicRegistration": True, + "contentFormat": ["markdown", "plaintext"], + }, + }, + "workspace": { + "workspaceFolders": True, + "didChangeConfiguration": {"dynamicRegistration": True}, + "configuration": True, + }, + }, + "processId": os.getpid(), + "rootPath": repository_absolute_path, + "rootUri": root_uri, + "workspaceFolders": [ + { + "uri": root_uri, + "name": os.path.basename(repository_absolute_path), + } + ], + "initializationOptions": {}, + } + return initialize_params # type: ignore[return-value] + + def _start_server(self) -> None: + """Start nimlangserver process.""" + + def register_capability_handler(params: dict) -> None: + return + + def window_log_message(msg: dict) -> None: + log.info(f"LSP: window/logMessage: {msg}") + + def do_nothing(params: dict) -> None: + return + + self.server.on_request("client/registerCapability", register_capability_handler) + 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 process") + self.server.start() + initialize_params = self._get_initialize_params(self.repository_root_path) + + log.info("Sending initialize request from LSP client to LSP server and awaiting response") + init_response = self.server.send.initialize(initialize_params) + + # Verify server capabilities + assert "textDocumentSync" in init_response["capabilities"] + assert "definitionProvider" in init_response["capabilities"] + assert "documentSymbolProvider" in init_response["capabilities"] + assert "referencesProvider" in init_response["capabilities"] + + self.server.notify.initialized({}) + + # nimlangserver is ready after initialization diff --git a/src/solidlsp/ls_config.py b/src/solidlsp/ls_config.py index c4d33522e..5c988dbcf 100644 --- a/src/solidlsp/ls_config.py +++ b/src/solidlsp/ls_config.py @@ -63,6 +63,7 @@ class Language(str, Enum): JULIA = "julia" FORTRAN = "fortran" HASKELL = "haskell" + NIM = "nim" GROOVY = "groovy" VUE = "vue" POWERSHELL = "powershell" @@ -244,6 +245,8 @@ def get_source_fn_matcher(self) -> FilenameMatcher: ) case self.HASKELL: return FilenameMatcher("*.hs", "*.lhs") + case self.NIM: + return FilenameMatcher("*.nim", "*.nims") case self.VUE: path_patterns = ["*.vue"] for prefix in ["c", "m", ""]: @@ -444,6 +447,10 @@ def get_ls_class(self) -> type["SolidLanguageServer"]: from solidlsp.language_servers.haskell_language_server import HaskellLanguageServer return HaskellLanguageServer + case self.NIM: + from solidlsp.language_servers.nim_language_server import NimLanguageServer + + return NimLanguageServer case self.FSHARP: from solidlsp.language_servers.fsharp_language_server import FSharpLanguageServer 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..c1c97863a --- /dev/null +++ b/test/resources/repos/nim/test_repo/main.nim @@ -0,0 +1,50 @@ +## main.nim: Entry point demonstrating usage of calculator and utils modules + +import src/calculator +import src/utils + +proc printBanner() = + ## Print a simple banner + echo "=== Nim Test Project ===" + +proc testCalculator() = + ## Test basic calculator operations + let sum = add(5, 3) + echo "5 + 3 = ", sum + + let diff = subtract(10, 4) + echo "10 - 4 = ", diff + + let prod = multiply(6, 7) + echo "6 * 7 = ", prod + + let quot = divide(15.0, 4.0) + echo "15 / 4 = ", quot + + let fact = factorial(5) + echo "5! = ", fact + + let pw = power(2, 8) + echo "2^8 = ", pw + +proc testUtils() = + ## Test string utility functions + let trimmed = trim(" hello world ") + echo "Trimmed: '", trimmed, "'" + + let hasPrefix = startsWith("hello world", "hello") + echo "Starts with 'hello': ", hasPrefix + + let hasSuffix = endsWith("hello world", "world") + echo "Ends with 'world': ", hasSuffix + + let parts = split("one,two,three", ",") + echo "Split parts: ", parts + + let joined = join(parts, " | ") + echo "Joined: ", joined + +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..ddf8d4518 --- /dev/null +++ b/test/resources/repos/nim/test_repo/src/calculator.nim @@ -0,0 +1,38 @@ +## calculator.nim: A simple calculator module for testing LSP features + +proc add*(a, b: int): int = + ## Add two integers + a + b + +proc subtract*(a, b: int): int = + ## Subtract b from a + a - b + +proc multiply*(a, b: int): int = + ## Multiply two integers + a * b + +proc divide*(a, b: float): float = + ## Divide a by b + if b == 0.0: + raise newException(ValueError, "Division by zero") + a / b + +proc factorial*(n: int): int = + ## Compute factorial of n + if n < 0: + raise newException(ValueError, "Factorial is not defined for negative numbers") + elif n == 0 or n == 1: + 1 + else: + var result = 1 + for i in 2..n: + result = result * i + result + +proc power*(base, exponent: int): int = + ## Compute base raised to exponent + var result = 1 + for _ in 1..exponent: + result = result * base + result 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..2823646f2 --- /dev/null +++ b/test/resources/repos/nim/test_repo/src/utils.nim @@ -0,0 +1,54 @@ +## utils.nim: String and sequence utility functions for testing LSP features + +proc trim*(s: string): string = + ## Remove leading and trailing whitespace + var start = 0 + var stop = s.len - 1 + while start <= stop and s[start] == ' ': + inc start + while stop >= start and s[stop] == ' ': + dec stop + s[start..stop] + +proc startsWith*(s, prefix: string): bool = + ## Check if string s starts with prefix + if prefix.len > s.len: + return false + for i in 0.. s.len: + return false + let offset = s.len - suffix.len + for i in 0.. 0: + result = result & sep + result = result & part + result diff --git a/test/resources/repos/nim/test_repo/test_repo.nimble b/test/resources/repos/nim/test_repo/test_repo.nimble new file mode 100644 index 000000000..0d7aecdcb --- /dev/null +++ b/test/resources/repos/nim/test_repo/test_repo.nimble @@ -0,0 +1,12 @@ +# Package + +version = "0.1.0" +author = "Test" +description = "A minimal Nim test project for LSP testing" +license = "MIT" +srcDir = "." +bin = @["main"] + +# Dependencies + +requires "nim >= 1.6.0" 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..cb7730666 --- /dev/null +++ b/test/solidlsp/nim/test_nim_basic.py @@ -0,0 +1,121 @@ +""" +Basic integration tests for Nim language server functionality. + +These tests validate symbol finding and cross-file reference capabilities +using nimlangserver (https://github.com/nim-lang/langserver). + +Test Repository Structure: +- src/calculator.nim: Basic arithmetic procedures (add, subtract, multiply, divide, factorial, power) +- src/utils.nim: String utility procedures (trim, startsWith, endsWith, split, join) +- main.nim: Entry point using calculator and utils modules +""" + +import shutil + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language + + +@pytest.mark.nim +@pytest.mark.skipif(shutil.which("nimlangserver") is None, reason="nimlangserver not installed") +class TestNimLanguageServer: + """Test Nim language server symbol finding and cross-file reference capabilities.""" + + @pytest.mark.parametrize("language_server", [Language.NIM], indirect=True) + def test_find_symbols_in_calculator(self, language_server: SolidLanguageServer) -> None: + """Test finding procedures in src/calculator.nim.""" + all_symbols, _ = language_server.request_document_symbols("src/calculator.nim").get_all_symbols_and_roots() + + assert all_symbols is not None + assert len(all_symbols) > 0 + + symbol_names = {s["name"] for s in all_symbols if isinstance(s, dict)} + + # Verify exact set of expected procedures + expected_procs = {"add", "subtract", "multiply", "divide", "factorial", "power"} + missing = expected_procs - symbol_names + assert not missing, f"Missing expected procedures in calculator.nim: {missing}" + + @pytest.mark.parametrize("language_server", [Language.NIM], indirect=True) + def test_find_symbols_in_utils(self, language_server: SolidLanguageServer) -> None: + """Test finding procedures in src/utils.nim.""" + all_symbols, _ = language_server.request_document_symbols("src/utils.nim").get_all_symbols_and_roots() + + assert all_symbols is not None + assert len(all_symbols) > 0 + + symbol_names = {s["name"] for s in all_symbols if isinstance(s, dict)} + + # Verify exact set of expected utility functions + expected_procs = {"trim", "startsWith", "endsWith", "split", "join"} + missing = expected_procs - symbol_names + assert not missing, f"Missing expected procedures in utils.nim: {missing}" + + @pytest.mark.parametrize("language_server", [Language.NIM], indirect=True) + def test_find_symbols_in_main(self, language_server: SolidLanguageServer) -> None: + """Test finding procedures in main.nim.""" + all_symbols, _ = language_server.request_document_symbols("main.nim").get_all_symbols_and_roots() + + assert all_symbols is not None + assert len(all_symbols) > 0 + + symbol_names = {s["name"] for s in all_symbols if isinstance(s, dict)} + + # Verify expected procedures in main.nim + expected_procs = {"printBanner", "testCalculator", "testUtils"} + missing = expected_procs - symbol_names + assert not missing, f"Missing expected procedures in main.nim: {missing}" + + @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.""" + all_symbols, _ = language_server.request_document_symbols("src/calculator.nim").get_all_symbols_and_roots() + + assert all_symbols is not None + + # Find the add procedure + add_symbol = next((s for s in all_symbols if isinstance(s, dict) and s.get("name") == "add"), None) + assert add_symbol is not None, "add procedure not found in calculator.nim" + + range_info = add_symbol.get("selectionRange", add_symbol.get("range")) + assert range_info is not None, "add procedure 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) + # add is called in main.nim (testCalculator procedure) + assert len(refs) >= 1, f"Should find at least 1 reference to calculator.add, found {len(refs)}" + + # Verify cross-file references from main.nim + main_refs = [ref for ref in refs if "main.nim" in ref.get("uri", "")] + assert len(main_refs) >= 1, "calculator.add should be referenced 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.""" + all_symbols, _ = language_server.request_document_symbols("src/utils.nim").get_all_symbols_and_roots() + + assert all_symbols is not None + + # Find the trim procedure + trim_symbol = next((s for s in all_symbols if isinstance(s, dict) and s.get("name") == "trim"), None) + assert trim_symbol is not None, "trim procedure not found in utils.nim" + + range_info = trim_symbol.get("selectionRange", trim_symbol.get("range")) + assert range_info is not None, "trim procedure has no range information" + + 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) + # trim is called in main.nim (testUtils procedure) + assert len(refs) >= 1, f"Should find at least 1 reference to utils.trim, found {len(refs)}" + + # Verify cross-file references from main.nim + main_refs = [ref for ref in refs if "main.nim" in ref.get("uri", "")] + assert len(main_refs) >= 1, "utils.trim should be referenced in main.nim"