Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion docs/01-about/020_programming-languages.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
246 changes: 246 additions & 0 deletions src/solidlsp/language_servers/wolfram_language_server.py
Original file line number Diff line number Diff line change
@@ -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
11 changes: 11 additions & 0 deletions src/solidlsp/ls_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"""
Expand Down Expand Up @@ -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 _:
Expand Down Expand Up @@ -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

Expand Down
8 changes: 4 additions & 4 deletions src/solidlsp/util/subprocess_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
19 changes: 19 additions & 0 deletions test/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
3 changes: 3 additions & 0 deletions test/resources/repos/wolfram/test_repo/lib/helper.wl
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
sayHello[name_String] := StringJoin["Hello, ", name, "!"]

formatResult[value_] := StringJoin["Result: ", ToString[value]]
17 changes: 17 additions & 0 deletions test/resources/repos/wolfram/test_repo/main.wl
Original file line number Diff line number Diff line change
@@ -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[]
Empty file.
Loading
Loading