diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index e73b76e9..e1db6ce2 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -28,6 +28,10 @@ jobs: - uses: actions/setup-go@v5 with: go-version: ">=1.17.0" + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20.x' - name: Ensure cached directory exist before calling cache-related actions shell: bash run: | @@ -230,6 +234,9 @@ jobs: echo "PERL_MB_OPT=--install_base \"$HOME/perl5\"" >> $GITHUB_ENV echo "PERL_MM_OPT=INSTALL_BASE=$HOME/perl5" >> $GITHUB_ENV echo "$HOME/perl5/bin" >> $GITHUB_PATH + - name: Install Elm + shell: bash + run: npm install -g elm@0.19.1-6 - name: Install Nix if: runner.os != 'Windows' # Nix doesn't support Windows natively uses: cachix/install-nix-action@v30 diff --git a/.gitignore b/.gitignore index 1ac36b26..196476e6 100644 --- a/.gitignore +++ b/.gitignore @@ -234,3 +234,7 @@ tmp/ # Swift /test/resources/repos/swift/test_repo/.build /test/resources/repos/swift/test_repo/.swiftpm + +# Elm +/test/resources/repos/elm/test_repo/.elm/ +/test/resources/repos/elm/test_repo/elm-stuff/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 11696a9b..d4a464f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ Status of the `main` branch. Changes prior to the next official version change will appear here. * Language support: + * **Add support for Elm** via @elm-tooling/elm-language-server (automatically downloads if not installed; requires Elm compiler) * **Add support for Perl** via Perl::LanguageServer with LSP integration for .pl, .pm, and .t files * **Add support for AL (Application Language)** for Microsoft Dynamics 365 Business Central development. Requires VS Code AL extension (ms-dynamics-smb.al). * **Add support for R** via the R languageserver package with LSP integration, performance optimizations, and fallback symbol extraction diff --git a/README.md b/README.md index d1e6c73c..07c92081 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,7 @@ With Serena, we provide direct, out-of-the-box support for: * Lua (automatically downloads lua-language-server if not installed) * Nix (requires nixd installation) * Elixir (requires installation of NextLS and Elixir; **Windows not supported**) + * Elm (automatically downloads elm-language-server if not installed; requires Elm compiler) * Erlang (requires installation of beam and [erlang_ls](https://github.com/erlang-ls/erlang_ls), experimental, might be slow or hang) * Perl (requires installation of Perl::LanguageServer) * AL diff --git a/pyproject.toml b/pyproject.toml index c9147dea..e6bbdf1e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -262,6 +262,7 @@ markers = [ "perl: language server running for Perl", "csharp: language server running for C#", "elixir: language server running for Elixir", + "elm: language server running for Elm", "terraform: language server running for Terraform", "swift: language server running for Swift", "bash: language server running for Bash", diff --git a/src/solidlsp/language_servers/elm_language_server.py b/src/solidlsp/language_servers/elm_language_server.py new file mode 100644 index 00000000..6f5a6baa --- /dev/null +++ b/src/solidlsp/language_servers/elm_language_server.py @@ -0,0 +1,189 @@ +""" +Provides Elm specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Elm. +""" + +import logging +import os +import pathlib +import shutil +import threading + +from overrides import override +from sensai.util.logging import LogTime + +from solidlsp.ls import SolidLanguageServer +from solidlsp.ls_config import LanguageServerConfig +from solidlsp.ls_logger import LanguageServerLogger +from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams +from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo +from solidlsp.settings import SolidLSPSettings + +from .common import RuntimeDependency, RuntimeDependencyCollection + + +class ElmLanguageServer(SolidLanguageServer): + """ + Provides Elm specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Elm. + """ + + def __init__( + self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str, solidlsp_settings: SolidLSPSettings + ): + """ + Creates an ElmLanguageServer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. + """ + elm_lsp_executable_path = self._setup_runtime_dependencies(logger, config, solidlsp_settings) + super().__init__( + config, + logger, + repository_root_path, + ProcessLaunchInfo(cmd=elm_lsp_executable_path, cwd=repository_root_path), + "elm", + solidlsp_settings, + ) + self.server_ready = threading.Event() + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + return super().is_ignored_dirname(dirname) or dirname in [ + "elm-stuff", + "node_modules", + "dist", + "build", + ] + + @classmethod + def _setup_runtime_dependencies( + cls, logger: LanguageServerLogger, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings + ) -> list[str]: + """ + Setup runtime dependencies for Elm Language Server and return the command to start the server. + """ + # Check if elm-language-server is already installed globally + system_elm_ls = shutil.which("elm-language-server") + if system_elm_ls: + logger.log(f"Found system-installed elm-language-server at {system_elm_ls}", logging.INFO) + return [system_elm_ls, "--stdio"] + + # Verify node and npm are installed + is_node_installed = shutil.which("node") is not None + assert is_node_installed, "node is not installed or isn't in PATH. Please install NodeJS and try again." + is_npm_installed = shutil.which("npm") is not None + assert is_npm_installed, "npm is not installed or isn't in PATH. Please install npm and try again." + + deps = RuntimeDependencyCollection( + [ + RuntimeDependency( + id="elm-language-server", + description="@elm-tooling/elm-language-server package", + command=["npm", "install", "--prefix", "./", "@elm-tooling/elm-language-server@2.8.0"], + platform_id="any", + ), + ] + ) + + # Install elm-language-server if not already installed + elm_ls_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), "elm-lsp") + elm_ls_executable_path = os.path.join(elm_ls_dir, "node_modules", ".bin", "elm-language-server") + if not os.path.exists(elm_ls_executable_path): + logger.log(f"Elm Language Server executable not found at {elm_ls_executable_path}. Installing...", logging.INFO) + with LogTime("Installation of Elm language server dependencies", logger=logger.logger): + deps.install(logger, elm_ls_dir) + + if not os.path.exists(elm_ls_executable_path): + raise FileNotFoundError( + f"elm-language-server executable not found at {elm_ls_executable_path}, something went wrong with the installation." + ) + return [elm_ls_executable_path, "--stdio"] + + @staticmethod + def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + """ + Returns the initialize params for the Elm Language Server. + """ + root_uri = pathlib.Path(repository_absolute_path).as_uri() + + initialize_params = { + "locale": "en", + "capabilities": { + "textDocument": { + "synchronization": {"didSave": True, "dynamicRegistration": True}, + "completion": {"dynamicRegistration": True, "completionItem": {"snippetSupport": True}}, + "definition": {"dynamicRegistration": True}, + "references": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "hierarchicalDocumentSymbolSupport": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, + "codeAction": {"dynamicRegistration": True}, + "rename": {"dynamicRegistration": True}, + }, + "workspace": { + "workspaceFolders": True, + "didChangeConfiguration": {"dynamicRegistration": True}, + "symbol": {"dynamicRegistration": True}, + }, + }, + "initializationOptions": { + "elmPath": "elm", + "elmFormatPath": "elm-format", + "elmTestPath": "elm-test", + "skipInstallPackageConfirmation": True, + "onlyUpdateDiagnosticsOnSave": False, + }, + "processId": os.getpid(), + "rootPath": repository_absolute_path, + "rootUri": root_uri, + "workspaceFolders": [ + { + "uri": root_uri, + "name": os.path.basename(repository_absolute_path), + } + ], + } + return initialize_params + + def _start_server(self): + """ + Starts the Elm Language Server, waits for the server to be ready and yields the LanguageServer instance. + """ + + def do_nothing(params): + return + + def window_log_message(msg): + self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) + + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + + self.logger.log("Starting Elm server process", logging.INFO) + self.server.start() + initialize_params = self._get_initialize_params(self.repository_root_path) + + self.logger.log( + "Sending initialize request from LSP client to LSP server and awaiting response", + logging.INFO, + ) + init_response = self.server.send.initialize(initialize_params) + + # Elm-specific capability checks + assert "textDocumentSync" in init_response["capabilities"] + assert "completionProvider" in init_response["capabilities"] + assert "definitionProvider" in init_response["capabilities"] + assert "referencesProvider" in init_response["capabilities"] + assert "documentSymbolProvider" in init_response["capabilities"] + + self.server.notify.initialized({}) + self.logger.log("Elm server initialized successfully, waiting for workspace scan...", logging.INFO) + + self.server_ready.set() + self.completions_available.set() + self.logger.log("Elm server ready", logging.INFO) + + @override + def _get_wait_time_for_cross_file_referencing(self) -> float: + return 1.0 diff --git a/src/solidlsp/ls_config.py b/src/solidlsp/ls_config.py index 18f634a7..e2dfd970 100644 --- a/src/solidlsp/ls_config.py +++ b/src/solidlsp/ls_config.py @@ -46,6 +46,7 @@ class Language(str, Enum): PERL = "perl" CLOJURE = "clojure" ELIXIR = "elixir" + ELM = "elm" TERRAFORM = "terraform" SWIFT = "swift" BASH = "bash" @@ -128,6 +129,8 @@ class Language(str, Enum): return FilenameMatcher("*.clj", "*.cljs", "*.cljc", "*.edn") # codespell:ignore edn case self.ELIXIR: return FilenameMatcher("*.ex", "*.exs") + case self.ELM: + return FilenameMatcher("*.elm") case self.TERRAFORM: return FilenameMatcher("*.tf", "*.tfvars", "*.tfstate") case self.SWIFT: @@ -223,6 +226,10 @@ class Language(str, Enum): from solidlsp.language_servers.elixir_tools.elixir_tools import ElixirTools return ElixirTools + case self.ELM: + from solidlsp.language_servers.elm_language_server import ElmLanguageServer + + return ElmLanguageServer case self.TERRAFORM: from solidlsp.language_servers.terraform_ls import TerraformLS diff --git a/test/resources/repos/elm/test_repo/Main.elm b/test/resources/repos/elm/test_repo/Main.elm new file mode 100644 index 00000000..76a34781 --- /dev/null +++ b/test/resources/repos/elm/test_repo/Main.elm @@ -0,0 +1,72 @@ +module Main exposing (main, greet, calculateSum) + +{-| Main module for testing Elm language server functionality. + +This module contains basic functions to test: + + - Symbol discovery + - Reference finding + - Cross-file imports + +-} + +import Browser +import Html exposing (Html, div, h1, p, text) +import Utils exposing (formatMessage, addNumbers) + + +{-| The main entry point for the application +-} +main : Program () Model Msg +main = + Browser.sandbox + { init = init + , view = view + , update = update + } + + +type alias Model = + { message : String + , count : Int + } + + +init : Model +init = + { message = greet "World" + , count = calculateSum 5 10 + } + + +type Msg + = NoOp + + +update : Msg -> Model -> Model +update msg model = + case msg of + NoOp -> + model + + +view : Model -> Html Msg +view model = + div [] + [ h1 [] [ text (formatMessage model.message) ] + , p [] [ text ("Count: " ++ String.fromInt model.count) ] + ] + + +{-| Greet someone by name +-} +greet : String -> String +greet name = + "Hello, " ++ name ++ "!" + + +{-| Calculate the sum of two numbers +-} +calculateSum : Int -> Int -> Int +calculateSum a b = + addNumbers a b diff --git a/test/resources/repos/elm/test_repo/Utils.elm b/test/resources/repos/elm/test_repo/Utils.elm new file mode 100644 index 00000000..c54b8a90 --- /dev/null +++ b/test/resources/repos/elm/test_repo/Utils.elm @@ -0,0 +1,28 @@ +module Utils exposing (formatMessage, addNumbers, multiplyNumbers) + +{-| Utility functions for the Elm test application. + +This module provides helper functions used by other modules. + +-} + + +{-| Format a message by adding brackets around it +-} +formatMessage : String -> String +formatMessage msg = + "[ " ++ msg ++ " ]" + + +{-| Add two numbers together +-} +addNumbers : Int -> Int -> Int +addNumbers x y = + x + y + + +{-| Multiply two numbers +-} +multiplyNumbers : Int -> Int -> Int +multiplyNumbers x y = + x * y diff --git a/test/resources/repos/elm/test_repo/elm.json b/test/resources/repos/elm/test_repo/elm.json new file mode 100644 index 00000000..0a668c08 --- /dev/null +++ b/test/resources/repos/elm/test_repo/elm.json @@ -0,0 +1,24 @@ +{ + "type": "application", + "source-directories": [ + "." + ], + "elm-version": "0.19.1", + "dependencies": { + "direct": { + "elm/browser": "1.0.2", + "elm/core": "1.0.5", + "elm/html": "1.0.0" + }, + "indirect": { + "elm/json": "1.1.3", + "elm/time": "1.0.0", + "elm/url": "1.0.0", + "elm/virtual-dom": "1.0.3" + } + }, + "test-dependencies": { + "direct": {}, + "indirect": {} + } +} diff --git a/test/solidlsp/elm/test_elm_basic.py b/test/solidlsp/elm/test_elm_basic.py new file mode 100644 index 00000000..87669d8f --- /dev/null +++ b/test/solidlsp/elm/test_elm_basic.py @@ -0,0 +1,54 @@ +import os + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from solidlsp.ls_utils import SymbolUtils + + +@pytest.mark.elm +class TestElmLanguageServer: + @pytest.mark.parametrize("language_server", [Language.ELM], indirect=True) + def test_find_symbol(self, language_server: SolidLanguageServer) -> None: + symbols = language_server.request_full_symbol_tree() + assert SymbolUtils.symbol_tree_contains_name(symbols, "greet"), "greet function not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "calculateSum"), "calculateSum function not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "formatMessage"), "formatMessage function not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "addNumbers"), "addNumbers function not found in symbol tree" + + @pytest.mark.parametrize("language_server", [Language.ELM], indirect=True) + def test_find_references_within_file(self, language_server: SolidLanguageServer) -> None: + file_path = os.path.join("Main.elm") + symbols = language_server.request_document_symbols(file_path) + greet_symbol = None + for sym in symbols[0]: + if sym.get("name") == "greet": + greet_symbol = sym + break + assert greet_symbol is not None, "Could not find 'greet' symbol in Main.elm" + sel_start = greet_symbol["selectionRange"]["start"] + refs = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) + assert any("Main.elm" in ref.get("relativePath", "") for ref in refs), "Main.elm should reference greet function" + + @pytest.mark.parametrize("language_server", [Language.ELM], indirect=True) + def test_find_references_across_files(self, language_server: SolidLanguageServer) -> None: + # Test formatMessage function which is defined in Utils.elm and used in Main.elm + utils_path = os.path.join("Utils.elm") + symbols = language_server.request_document_symbols(utils_path) + formatMessage_symbol = None + for sym in symbols[0]: + if sym.get("name") == "formatMessage": + formatMessage_symbol = sym + break + assert formatMessage_symbol is not None, "Could not find 'formatMessage' symbol in Utils.elm" + + # Get references from the definition in Utils.elm + sel_start = formatMessage_symbol["selectionRange"]["start"] + refs = language_server.request_references(utils_path, sel_start["line"], sel_start["character"]) + + # Verify that we found references + assert refs, "Expected to find references for formatMessage" + + # Verify that at least one reference is in Main.elm (where formatMessage is used) + assert any("Main.elm" in ref.get("relativePath", "") for ref in refs), "Expected to find usage of formatMessage in Main.elm"