feat: Elm language support (#667)

Also added Node.js setup to GitHub Actions workflow

---------

Co-authored-by: Jordan Munch O'Hare <git@jordan.munchohare.com>
This commit is contained in:
Jordan Munch O'HareandJordan Munch O'Hare authored and GitHub committed 2025-10-14 10:15:53 +02:00
1 parent 21af8bf451
commit 675a421287
11 files changed
+388

No files matched your search

+7
View File
@@ -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
+4
View File
@@ -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/
+1
View File
@@ -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
+1
View File
@@ -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
+1
View File
@@ -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",
@@ -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
+7
View File
@@ -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
@@ -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
@@ -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
@@ -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": {}
}
}
+54
View File
@@ -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"