diff --git a/CHANGELOG.md b/CHANGELOG.md index e0d942104..256ae6c1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,6 +64,7 @@ Status of the `main` branch. Changes prior to the next official version change w * **Add support for MATLAB** via the official MathWorks MATLAB Language Server. Requires MATLAB R2021b or later and Node.js. Set `MATLAB_PATH` environment variable or configure `matlab_path` in `ls_specific_settings`. Supports .m, .mlx, and .mlapp files with code completion, diagnostics, go-to-definition, find references, document symbols, formatting, and rename. * **Add support for Pascal** via the official Pascal Language Server. * **C/C++ alternate LS (ccls)**: Add experimental, opt-in support for ccls as an alternative backend to clangd. Enable via `cpp_ccls` in project configuration. Requires `ccls` installed and ideally a `compile_commands.json` at repo root. + * **Add support for Wolfram Language** via the official [WolframResearch LSPServer](https://github.com/WolframResearch/LSPServer) paclet. Requires Wolfram Mathematica 13.0+ or Wolfram Engine 12.1+. Set `WOLFRAM_PATH` environment variable or configure `wolfram_kernel_path` in `ls_specific_settings`. Supports .wl and .wls files with diagnostics, document symbols, references, hover documentation, formatting, and semantic highlighting. # 0.1.4 diff --git a/README.md b/README.md index b03b07c12..6e7071737 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, Go, Groovy (partial support), Haskell, Java, Javascript, Julia, Kotlin, Lua, Markdown, MATLAB, Nix, Perl, PHP, PowerShell, Python, R, Ruby, Rust, Scala, Swift, TOML, TypeScript, YAML, and Zig. +AL, Bash, C#, C/C++, Clojure, Dart, Elixir, Elm, Erlang, Fortran, Go, Groovy (partial support), Haskell, Java, Javascript, Julia, Kotlin, Lua, Markdown, MATLAB, Nix, Perl, PHP, PowerShell, Python, R, Ruby, Rust, Scala, Swift, TOML, TypeScript, Wolfram Language, 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 97c7fc407..e904dda14 100644 --- a/docs/01-about/020_programming-languages.md +++ b/docs/01-about/020_programming-languages.md @@ -82,8 +82,10 @@ Some languages require additional installations or setup steps, as noted. (requires some [manual setup](../03-special-guides/scala_setup_guide_for_serena); uses Metals LSP) * **Swift** * **TypeScript** -* **Vue** +* **Vue** (3.x with TypeScript; requires Node.js v18+ and npm; supports .vue Single File Components with monorepo detection) +* **Wolfram Language** + (requires Wolfram Mathematica 13.0+ or Wolfram Engine 12.1+; uses the official [WolframResearch LSPServer](https://github.com/WolframResearch/LSPServer) paclet; supports .wl and .wls files) * **YAML** * **Zig** (requires installation of ZLS - Zig Language Server) diff --git a/pyproject.toml b/pyproject.toml index 587bbf2cc..2615b091f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -326,6 +326,7 @@ markers = [ "toml: language server running for TOML", "matlab: language server running for MATLAB (requires MATLAB R2021b+)", "systemverilog: language server running for SystemVerilog (uses verible-verilog-ls)", + "wolfram: language server running for Wolfram Language (requires Mathematica 13.0+ or Wolfram Engine 12.1+)", ] [tool.codespell] diff --git a/src/solidlsp/language_servers/wolfram_language_server.py b/src/solidlsp/language_servers/wolfram_language_server.py new file mode 100644 index 000000000..2d48f01b2 --- /dev/null +++ b/src/solidlsp/language_servers/wolfram_language_server.py @@ -0,0 +1,246 @@ +"""Wolfram Language server integration using the official WolframResearch LSPServer paclet.""" + +import glob +import logging +import os +import pathlib +import platform +import shlex +import shutil +from typing import Any + +from overrides import override + +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__) + +WOLFRAM_PATH_ENV_VAR = "WOLFRAM_PATH" + + +class WolframLanguageServer(SolidLanguageServer): + """ + Wolfram Language server using the official WolframResearch LSPServer paclet. + + Requires Wolfram Mathematica 13.0+ or Wolfram Engine 12.1+. + Configure wolfram_kernel_path in ls_specific_settings or set WOLFRAM_PATH env var. + """ + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + from solidlsp.ls_config import Language + + custom_settings = solidlsp_settings.get_ls_specific_settings(Language.WOLFRAM) + kernel_path = self._find_wolfram_kernel(custom_settings) + + # The command must use shlex.quote because Serena launches processes via shell=True, + # and Wolfram context marks contain backticks (e.g. LSPServer`) which the shell + # would interpret as command substitution. + wolfram_code = 'Needs["LSPServer`"];LSPServer`StartServer[]' + + wolfram_ls_cmd: str | list[str] + if platform.system() == "Windows": + wolfram_ls_cmd = [kernel_path, "-noprompt", "-noinit", "-run", wolfram_code] + else: + wolfram_ls_cmd = f"{shlex.quote(kernel_path)} -noprompt -noinit -run {shlex.quote(wolfram_code)}" + + log.info(f"Wolfram LSP launch command: {wolfram_ls_cmd}") + + super().__init__( + config, + repository_root_path, + ProcessLaunchInfo(cmd=wolfram_ls_cmd, cwd=repository_root_path), + "wolfram", + solidlsp_settings, + ) + + # The Wolfram kernel's LSPServer paclet crashes if the standard `Content-Type` + # header is included in the JSON-RPC message. We monkey-patch `_send_payload` + # locally to cleanly omit it without affecting global message formatting in other servers. + def _wolfram_custom_send_payload(payload: Any) -> None: + if not self.server.process or not self.server.process.stdin: + return + self.server._trace("solidlsp", "ls", payload) + + import json + + from solidlsp.lsp_protocol_handler.server import ENCODING + + body = json.dumps(payload, check_circular=False, ensure_ascii=False, separators=(",", ":")).encode(ENCODING) + + # Note the omission of the Content-Type header! Only Content-Length is sent. + msg = ( + f"Content-Length: {len(body)}\r\n\r\n".encode(ENCODING), + body, + ) + + with self.server._stdin_lock: + self.server.process.stdin.write(b"".join(msg)) + self.server.process.stdin.flush() + + self.server._send_payload = _wolfram_custom_send_payload # type: ignore[method-assign] + + @staticmethod + def _find_wolfram_kernel(custom_settings: SolidLSPSettings.CustomLSSettings) -> str: + """Find the WolframKernel executable.""" + # 1. Custom settings + custom_path = custom_settings.get("wolfram_kernel_path") + if custom_path and os.path.isfile(custom_path) and os.access(custom_path, os.X_OK): + log.info(f"Using WolframKernel from custom settings: {custom_path}") + return custom_path + + # 2. WOLFRAM_PATH environment variable + env_path = os.environ.get(WOLFRAM_PATH_ENV_VAR) + if env_path: + if os.path.isfile(env_path) and os.access(env_path, os.X_OK): + log.info(f"Using WolframKernel from {WOLFRAM_PATH_ENV_VAR}: {env_path}") + return env_path + kernel_in_dir = _find_kernel_in_install_dir(env_path) + if kernel_in_dir: + log.info(f"Using WolframKernel from {WOLFRAM_PATH_ENV_VAR} directory: {kernel_in_dir}") + return kernel_in_dir + + # 3. System PATH + kernel_path = shutil.which("WolframKernel") + if kernel_path: + log.info(f"Using WolframKernel from PATH: {kernel_path}") + return kernel_path + + # 4. Common installation locations + system = platform.system() + search_locations: list[str] = [] + + if system == "Darwin": + search_locations = [ + "/Applications/Mathematica.app/Contents/MacOS/WolframKernel", + "/Applications/Wolfram.app/Contents/MacOS/WolframKernel", + "/Applications/Wolfram Engine.app/Contents/MacOS/WolframKernel", + ] + for pattern in [ + "/Applications/Mathematica*.app/Contents/MacOS/WolframKernel", + "/Applications/Wolfram*.app/Contents/MacOS/WolframKernel", + ]: + search_locations.extend(sorted(glob.glob(pattern), reverse=True)) + + elif system == "Linux": + search_locations = [ + "/usr/local/bin/WolframKernel", + "/usr/bin/WolframKernel", + ] + for pattern in [ + "/usr/local/Wolfram/Mathematica/*/Executables/WolframKernel", + "/usr/local/Wolfram/WolframEngine/*/Executables/WolframKernel", + "/opt/Wolfram/Mathematica/*/Executables/WolframKernel", + ]: + search_locations.extend(sorted(glob.glob(pattern), reverse=True)) + + elif system == "Windows": + for pattern in [ + "C:\\Program Files\\Wolfram Research\\Mathematica\\*\\WolframKernel.exe", + "C:\\Program Files\\Wolfram Research\\Wolfram Engine\\*\\WolframKernel.exe", + ]: + search_locations.extend(sorted(glob.glob(pattern), reverse=True)) + + for location in search_locations: + if os.path.isfile(location) and os.access(location, os.X_OK): + log.info(f"Found WolframKernel at: {location}") + return location + + raise RuntimeError( + "WolframKernel not found. Please either:\n" + f"1. Set the {WOLFRAM_PATH_ENV_VAR} environment variable to your Wolfram installation\n" + "2. Add WolframKernel to your system PATH\n" + "3. Configure wolfram_kernel_path in ls_specific_settings\n" + "4. Install Wolfram Mathematica (13.0+) or Wolfram Engine (12.1+) from https://www.wolfram.com/" + ) + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + return super().is_ignored_dirname(dirname) or dirname in [ + ".Wolfram", + "SystemFiles", + "Documentation", + "FrontEnd", + ] + + @staticmethod + def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + root_uri = pathlib.Path(repository_absolute_path).as_uri() + initialize_params = { + "processId": os.getpid(), + "rootPath": repository_absolute_path, + "rootUri": root_uri, + "capabilities": { + "workspace": {"workspaceFolders": True}, + "textDocument": { + "definition": {"dynamicRegistration": True}, + "references": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "hierarchicalDocumentSymbolSupport": True, + }, + "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, + "formatting": {"dynamicRegistration": True}, + "publishDiagnostics": {"relatedInformation": True}, + }, + }, + "workspaceFolders": [ + { + "uri": root_uri, + "name": os.path.basename(repository_absolute_path), + } + ], + } + return initialize_params # type: ignore + + def _start_server(self) -> None: + def do_nothing(params: Any) -> None: + return + + def window_log_message(msg: dict) -> None: + log.info(f"Wolfram 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 Wolfram LSPServer process") + self.server.start() + + initialize_params = self._get_initialize_params(self.repository_root_path) + init_response = self.server.send.initialize(initialize_params) + + log.info(f"Wolfram LSP capabilities: {list(init_response.get('capabilities', {}).keys())}") + + self.server.notify.initialized({}) + log.info("Wolfram LSPServer initialized and ready.") + + +def _find_kernel_in_install_dir(install_dir: str) -> str | None: + """Try to locate WolframKernel within a Wolfram installation directory.""" + system = platform.system() + + if system == "Darwin": + candidates = [ + os.path.join(install_dir, "Contents", "MacOS", "WolframKernel"), + os.path.join(install_dir, "MacOS", "WolframKernel"), + ] + elif system == "Windows": + candidates = [ + os.path.join(install_dir, "WolframKernel.exe"), + ] + else: + candidates = [ + os.path.join(install_dir, "Executables", "WolframKernel"), + os.path.join(install_dir, "WolframKernel"), + ] + + for candidate in candidates: + if os.path.isfile(candidate) and os.access(candidate, os.X_OK): + return candidate + + return None diff --git a/src/solidlsp/ls_config.py b/src/solidlsp/ls_config.py index 935ce98da..5ca437a6e 100644 --- a/src/solidlsp/ls_config.py +++ b/src/solidlsp/ls_config.py @@ -75,6 +75,11 @@ class Language(str, Enum): Requires MATLAB R2021b or later and Node.js. Set MATLAB_PATH environment variable or configure matlab_path in ls_specific_settings. """ + WOLFRAM = "wolfram" + """Wolfram Language server using the official WolframResearch LSPServer paclet. + Requires Wolfram Mathematica 13.0+ or Wolfram Engine 12.1+. + Set WOLFRAM_PATH environment variable or configure wolfram_kernel_path in ls_specific_settings. + """ # Experimental or deprecated Language Servers TYPESCRIPT_VTS = "typescript_vts" """Use the typescript language server through the natively bundled vscode extension via https://github.com/yioneko/vtsls""" @@ -251,6 +256,8 @@ def get_source_fn_matcher(self) -> FilenameMatcher: return FilenameMatcher("*.groovy", "*.gvy") case self.MATLAB: return FilenameMatcher("*.m", "*.mlx", "*.mlapp") + case self.WOLFRAM: + return FilenameMatcher("*.wl", "*.wls") case self.SYSTEMVERILOG: return FilenameMatcher("*.sv", "*.svh", "*.v", "*.vh") case _: @@ -434,6 +441,10 @@ def get_ls_class(self) -> type["SolidLanguageServer"]: from solidlsp.language_servers.matlab_language_server import MatlabLanguageServer return MatlabLanguageServer + case self.WOLFRAM: + from solidlsp.language_servers.wolfram_language_server import WolframLanguageServer + + return WolframLanguageServer case self.SYSTEMVERILOG: from solidlsp.language_servers.systemverilog_server import SystemVerilogLanguageServer diff --git a/src/solidlsp/util/subprocess_util.py b/src/solidlsp/util/subprocess_util.py index c0735f0c7..785aac791 100644 --- a/src/solidlsp/util/subprocess_util.py +++ b/src/solidlsp/util/subprocess_util.py @@ -15,8 +15,8 @@ def subprocess_kwargs() -> dict: def quote_arg(arg: str) -> str: """ - Adds quotes around an argument if it contains spaces. + Quotes an argument for use in a shell statement. """ - if " " not in arg: - return arg - return f'"{arg}"' + import shlex + + return shlex.quote(arg) diff --git a/test/conftest.py b/test/conftest.py index 565d40b79..98eb65532 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -248,6 +248,25 @@ def _determine_disabled_languages() -> list[Language]: if not al_tests_enabled: result.append(Language.AL) + wolfram_tests_enabled = _sh.which("WolframKernel") is not None + if not wolfram_tests_enabled: + # Also check common installation paths (WolframKernel is often not on PATH) + import glob + + wolfram_candidates = [ + "/Applications/Mathematica.app/Contents/MacOS/WolframKernel", + "/Applications/Wolfram.app/Contents/MacOS/WolframKernel", + "/Applications/Wolfram Engine.app/Contents/MacOS/WolframKernel", + ] + wolfram_candidates.extend(sorted(glob.glob("/Applications/Mathematica*.app/Contents/MacOS/WolframKernel"), reverse=True)) + wolfram_candidates.extend(sorted(glob.glob("/Applications/Wolfram*.app/Contents/MacOS/WolframKernel"), reverse=True)) + for p in wolfram_candidates: + if os.path.isfile(p) and os.access(p, os.X_OK): + wolfram_tests_enabled = True + break + if not wolfram_tests_enabled: + result.append(Language.WOLFRAM) + return result diff --git a/test/resources/repos/wolfram/test_repo/lib/helper.wl b/test/resources/repos/wolfram/test_repo/lib/helper.wl new file mode 100644 index 000000000..8502c91e8 --- /dev/null +++ b/test/resources/repos/wolfram/test_repo/lib/helper.wl @@ -0,0 +1,3 @@ +sayHello[name_String] := StringJoin["Hello, ", name, "!"] + +formatResult[value_] := StringJoin["Result: ", ToString[value]] diff --git a/test/resources/repos/wolfram/test_repo/main.wl b/test/resources/repos/wolfram/test_repo/main.wl new file mode 100644 index 000000000..8b3f28266 --- /dev/null +++ b/test/resources/repos/wolfram/test_repo/main.wl @@ -0,0 +1,17 @@ +Get["lib/helper.wl"] + +calculateSum[a_, b_] := a + b + +processData[data_List] := Module[{result}, + result = Total[data]; + formatResult[result] +] + +main[] := Module[{result, greeting}, + result = calculateSum[5, 3]; + greeting = sayHello["World"]; + Print[greeting]; + Print[result] +] + +main[] diff --git a/test/solidlsp/wolfram/__init__.py b/test/solidlsp/wolfram/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/test/solidlsp/wolfram/test_wolfram_basic.py b/test/solidlsp/wolfram/test_wolfram_basic.py new file mode 100644 index 000000000..b750e3158 --- /dev/null +++ b/test/solidlsp/wolfram/test_wolfram_basic.py @@ -0,0 +1,54 @@ +import pytest + +from solidlsp.ls import SolidLanguageServer +from solidlsp.ls_config import Language +from test.conftest import language_tests_enabled + +pytestmark = [ + pytest.mark.wolfram, + pytest.mark.skipif(not language_tests_enabled(Language.WOLFRAM), reason="Wolfram tests disabled (WolframKernel not available)"), +] + + +class TestWolframLanguageServer: + @pytest.mark.parametrize("language_server", [Language.WOLFRAM], indirect=True) + def test_wolfram_symbols(self, language_server: SolidLanguageServer): + """ + Test if we can find the top-level symbols in the main.wl file. + """ + all_symbols, _ = language_server.request_document_symbols("main.wl").get_all_symbols_and_roots() + symbol_names = {s["name"] for s in all_symbols} + assert "calculateSum" in symbol_names + assert "main" in symbol_names + + @pytest.mark.parametrize("language_server", [Language.WOLFRAM], indirect=True) + def test_wolfram_within_file_references(self, language_server: SolidLanguageServer): + """ + Test finding references to a function within the same file. + """ + # Find references to 'calculateSum' defined at the top of main.wl + # LSP uses 0-based indexing + references = language_server.request_references("main.wl", line=2, column=0) + + # Should find at least the definition and the call site + assert len(references) >= 1, f"Expected at least 1 reference, got {len(references)}" + + # Verify at least one reference is in main.wl + reference_paths = [ref["relativePath"] for ref in references] + assert "main.wl" in reference_paths + + @pytest.mark.parametrize("language_server", [Language.WOLFRAM], indirect=True) + def test_wolfram_cross_file_references(self, language_server: SolidLanguageServer): + """ + Test finding references to a function defined in another file. + """ + # Find references to 'sayHello' defined in lib/helper.wl at line 0 + # LSP uses 0-based indexing + references = language_server.request_references("lib/helper.wl", line=0, column=0) + + # Should find at least the definition or usage + assert len(references) >= 1, f"Expected at least 1 reference, got {len(references)}" + + # Verify references span files + reference_paths = [ref["relativePath"] for ref in references] + assert "main.wl" in reference_paths or "lib/helper.wl" in reference_paths