Merge branch 'refs/heads/main' into feature/docstring_and_signature

# Conflicts:
#	src/serena/tools/jetbrains_tools.py
This commit is contained in:
Michael Panchenko committed 2026-01-03 18:29:14 +07:00
commit 4f5d894bcd
14 files changed
+932 -17

No files matched your search

+1
View File
@@ -56,6 +56,7 @@ Status of the `main` branch. Changes prior to the next official version change w
* **Add support for Erlang** experimental, may hang or be slow, uses the recently archived [erlang_ls](https://github.com/erlang-ls/erlang_ls)
* **Ruby dual language server support**: Added ruby-lsp as the modern primary Ruby language server. Solargraph remains available as an experimental legacy option. ruby-lsp supports both .rb and .erb files, while Solargraph supports .rb files only.
* **Add support for PowerShell** via PowerShell Editor Services (PSES). Requires `pwsh` (PowerShell Core) to be installed and available in PATH. Supports symbol navigation, go-to-definition, and within-file references for .ps1 files.
* **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.
# 0.1.4
+1 -1
View File
@@ -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, 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, 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.
+1
View File
@@ -306,6 +306,7 @@ markers = [
"powershell: language server running for PowerShell",
"slow: tests that require additional Expert instances and have long startup times (~60-90s each)",
"toml: language server running for TOML",
"matlab: language server running for MATLAB (requires MATLAB R2021b+)",
]
[tool.codespell]
+11 -2
View File
@@ -221,7 +221,16 @@ class SerenaMCPFactory:
def execute_fn(**kwargs) -> str: # type: ignore
return tool.apply_ex(log_call=True, catch_exceptions=True, **kwargs)
annotations = ToolAnnotations(readOnlyHint=not tool.can_edit())
# Generate human-readable title from snake_case tool name
tool_title = " ".join(word.capitalize() for word in func_name.split("_"))
# Create annotations with appropriate hints based on tool capabilities
can_edit = tool.can_edit()
annotations = ToolAnnotations(
title=tool_title,
readOnlyHint=not can_edit,
destructiveHint=can_edit,
)
return MCPTool(
fn=execute_fn,
@@ -232,7 +241,7 @@ class SerenaMCPFactory:
is_async=is_async,
context_kwarg=None,
annotations=annotations,
title=None,
title=tool_title,
)
def _iter_tools(self) -> Iterator[Tool]:
+3 -2
View File
@@ -178,11 +178,12 @@ class JetBrainsPluginClient(ToStringMixin):
request_data = {"namePath": name_path, "relativePath": relative_path}
return self._make_request("POST", "/findReferences", request_data)
def get_symbols_overview(self, relative_path: str) -> dict[str, Any]:
def get_symbols_overview(self, relative_path: str, depth: int) -> dict[str, Any]:
"""
:param relative_path: the relative path to a source file
:param depth: the depth of children to include (0 = no children)
"""
request_data = {"relativePath": relative_path}
request_data = {"relativePath": relative_path, "depth": depth}
return self._make_request("POST", "/getSymbolsOverview", request_data)
def get_supertypes(
+40 -6
View File
@@ -1,4 +1,5 @@
from typing import Literal
from collections import defaultdict
from typing import Any
from serena.tools import Tool, ToolMarkerOptional, ToolMarkerSymbolicRead
from serena.tools.jetbrains_plugin_client import JetBrainsPluginClient
@@ -100,9 +101,41 @@ class JetBrainsGetSymbolsOverviewTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOp
Retrieves an overview of the top-level symbols within a specified file using the JetBrains backend
"""
@staticmethod
def _transform_symbols_to_compact_format(symbols: list[dict[str, Any]]) -> dict[str, list]:
"""
Transform symbol overview from verbose format to compact grouped format.
Groups symbols by kind and uses names instead of full symbol objects.
For symbols with children, creates nested dictionaries.
The name_path can be inferred from the hierarchical structure:
- Top-level symbols: name_path = name
- Nested symbols: name_path = parent_name + "/" + name
For example, "convert" under class "ProjectType" has name_path "ProjectType/convert".
"""
result = defaultdict(list)
for symbol in symbols:
kind = symbol.get("type", "Unknown")
name_path = symbol["name_path"]
name = name_path.split("/")[-1]
children = symbol.get("children", [])
if children:
# Symbol has children: create nested dict {name: children_dict}
children_dict = JetBrainsGetSymbolsOverviewTool._transform_symbols_to_compact_format(children)
result[kind].append({name: children_dict})
else:
# Symbol has no children: just add the name
result[kind].append(name)
return result
def apply(
self,
relative_path: str,
depth: int = 0,
max_answer_chars: int = -1,
) -> str:
"""
@@ -113,15 +146,16 @@ class JetBrainsGetSymbolsOverviewTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOp
or by using the `list_dir` and `find_file` tools (or similar).
:param relative_path: the relative path to the file to get the overview of
:param depth: depth up to which descendants shall be retrieved (e.g., use 1 to also retrieve immediate children).
:param max_answer_chars: max characters for the JSON result. If exceeded, no content is returned.
-1 means the default value from the config will be used.
:return: a JSON object containing the symbols
:return: a JSON object containing the symbols grouped by kind in a compact format.
"""
with JetBrainsPluginClient.from_project(self.project) as client:
response_dict = client.get_symbols_overview(
relative_path=relative_path,
)
result = self._to_json(response_dict)
response_dict = client.get_symbols_overview(relative_path=relative_path, depth=depth)
symbols = response_dict["symbols"]
compact_symbols = self._transform_symbols_to_compact_format(symbols)
result = self._to_json(compact_symbols)
return self._limit_length(result, max_answer_chars)
+4 -4
View File
@@ -1,9 +1,9 @@
from typing import Literal
from serena.tools import ReplaceContentTool, Tool
from serena.tools import ReplaceContentTool, Tool, ToolMarkerCanEdit
class WriteMemoryTool(Tool):
class WriteMemoryTool(Tool, ToolMarkerCanEdit):
"""
Writes a named memory (for future reference) to Serena's project-specific memory store.
"""
@@ -52,7 +52,7 @@ class ListMemoriesTool(Tool):
return self._to_json(self.memories_manager.list_memories())
class DeleteMemoryTool(Tool):
class DeleteMemoryTool(Tool, ToolMarkerCanEdit):
"""
Deletes a memory from Serena's project-specific memory store.
"""
@@ -66,7 +66,7 @@ class DeleteMemoryTool(Tool):
return self.memories_manager.delete_memory(memory_file_name)
class EditMemoryTool(Tool):
class EditMemoryTool(Tool, ToolMarkerCanEdit):
def apply(
self,
memory_file_name: str,
+35 -2
View File
@@ -3,6 +3,7 @@ Language server-related tools
"""
import os
from collections import defaultdict
from collections.abc import Sequence
from copy import copy
from typing import Any
@@ -61,7 +62,7 @@ class GetSymbolsOverviewTool(Tool, ToolMarkerSymbolicRead):
:param max_answer_chars: if the overview is longer than this number of characters,
no content will be returned. -1 means the default value from the config will be used.
Don't adjust unless there is really no other way to get the content required for the task.
:return: a JSON object containing info about top-level symbols in the file
:return: a JSON object containing symbols grouped by kind in a compact format.
"""
symbol_retriever = self.create_language_server_symbol_retriever()
file_path = os.path.join(self.project.project_root, relative_path)
@@ -73,9 +74,41 @@ class GetSymbolsOverviewTool(Tool, ToolMarkerSymbolicRead):
if os.path.isdir(file_path):
raise ValueError(f"Expected a file path, but got a directory path: {relative_path}. ")
result = symbol_retriever.get_symbol_overview(relative_path, depth=depth)[relative_path]
result_json_str = self._to_json(result)
# Transform to compact format
compact_result = self._transform_symbols_to_compact_format(result)
result_json_str = self._to_json(compact_result)
return self._limit_length(result_json_str, max_answer_chars)
@staticmethod
def _transform_symbols_to_compact_format(symbols: list[dict[str, Any]]) -> dict[str, list]:
"""
Transform symbol overview from verbose format to compact grouped format.
Groups symbols by kind and uses names instead of full symbol objects.
For symbols with children, creates nested dictionaries.
The name_path can be inferred from the hierarchical structure:
- Top-level symbols: name_path = name
- Nested symbols: name_path = parent_name + "/" + name
For example, "convert" under class "ProjectType" has name_path "ProjectType/convert".
"""
result = defaultdict(list)
for symbol in symbols:
kind = symbol.get("kind", "Unknown")
name = symbol.get("name", "unknown")
children = symbol.get("children", [])
if children:
# Symbol has children: create nested dict {name: children_dict}
children_dict = GetSymbolsOverviewTool._transform_symbols_to_compact_format(children)
result[kind].append({name: children_dict})
else:
# Symbol has no children: just add the name
result[kind].append(name)
return result
class FindSymbolTool(Tool, ToolMarkerSymbolicRead):
"""
@@ -0,0 +1,559 @@
"""
MATLAB language server integration using the official MathWorks MATLAB Language Server.
Architecture:
This module uses the MathWorks MATLAB VS Code extension (mathworks.language-matlab)
which contains a Node.js-based language server. The extension is downloaded from the
VS Code Marketplace and extracted locally. The language server spawns a real MATLAB
process to provide code intelligence - it is NOT a standalone static analyzer.
Flow: Serena -> Node.js LSP Server -> MATLAB Process -> Code Analysis
Why MATLAB installation is required:
The language server launches an actual MATLAB session (via MatlabSession.js) to perform
code analysis, diagnostics, and other features. Without MATLAB, the LSP cannot function.
This is different from purely static analyzers that parse code without execution.
Requirements:
- MATLAB R2021b or later must be installed and licensed
- Node.js must be installed (for running the language server)
- MATLAB path can be specified via MATLAB_PATH environment variable or auto-detected
The MATLAB language server provides:
- Code diagnostics (publishDiagnostics)
- Code completions (completionProvider)
- Go to definition (definitionProvider)
- Find references (referencesProvider)
- Document symbols (documentSymbol)
- Document formatting (documentFormattingProvider)
- Function signature help (signatureHelpProvider)
- Symbol rename (renameProvider)
"""
import glob
import logging
import os
import pathlib
import platform
import shutil
import threading
import zipfile
from typing import Any, cast
import requests
from solidlsp.ls import LSPFileBuffer, SolidLanguageServer
from solidlsp.ls_config import LanguageServerConfig
from solidlsp.lsp_protocol_handler.lsp_types import DocumentSymbol, InitializeParams, SymbolInformation
from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo
from solidlsp.settings import SolidLSPSettings
log = logging.getLogger(__name__)
# Environment variable for MATLAB installation path
MATLAB_PATH_ENV_VAR = "MATLAB_PATH"
# VS Code Marketplace URL for MATLAB extension
MATLAB_EXTENSION_URL = (
"https://marketplace.visualstudio.com/_apis/public/gallery/publishers/MathWorks/vsextensions/language-matlab/latest/vspackage"
)
class MatlabLanguageServer(SolidLanguageServer):
"""
Provides MATLAB specific instantiation of the LanguageServer class using the official
MathWorks MATLAB Language Server.
The MATLAB language server requires:
- MATLAB R2021b or later installed on the system
- Node.js for running the language server
The language server is automatically downloaded from the VS Code marketplace
(MathWorks.language-matlab extension) and extracted.
You can pass the following entries in ls_specific_settings["matlab"]:
- matlab_path: Path to MATLAB installation (overrides MATLAB_PATH env var)
"""
@staticmethod
def _find_matlab_installation() -> str:
"""
Find MATLAB installation path.
Search order:
1. MATLAB_PATH environment variable
2. Common installation locations based on platform
Returns:
Path to MATLAB installation directory.
Raises:
RuntimeError: If MATLAB installation is not found.
"""
# Check environment variable first
matlab_path = os.environ.get(MATLAB_PATH_ENV_VAR)
if matlab_path and os.path.isdir(matlab_path):
log.info(f"Using MATLAB from environment variable {MATLAB_PATH_ENV_VAR}: {matlab_path}")
return matlab_path
system = platform.system()
if system == "Darwin": # macOS
# Check common macOS locations
search_patterns = [
"/Applications/MATLAB_*.app",
"/Volumes/*/Applications/MATLAB_*.app",
os.path.expanduser("~/Applications/MATLAB_*.app"),
]
for pattern in search_patterns:
matches = sorted(glob.glob(pattern), reverse=True) # Newest version first
for match in matches:
if os.path.isdir(match):
log.info(f"Found MATLAB installation: {match}")
return match
elif system == "Windows":
# Check common Windows locations
search_patterns = [
"C:\\Program Files\\MATLAB\\R*",
"C:\\Program Files (x86)\\MATLAB\\R*",
]
for pattern in search_patterns:
matches = sorted(glob.glob(pattern), reverse=True)
for match in matches:
if os.path.isdir(match):
log.info(f"Found MATLAB installation: {match}")
return match
elif system == "Linux":
# Check common Linux locations
search_patterns = [
"/usr/local/MATLAB/R*",
"/opt/MATLAB/R*",
os.path.expanduser("~/MATLAB/R*"),
]
for pattern in search_patterns:
matches = sorted(glob.glob(pattern), reverse=True)
for match in matches:
if os.path.isdir(match):
log.info(f"Found MATLAB installation: {match}")
return match
raise RuntimeError(
f"MATLAB installation not found. Set the {MATLAB_PATH_ENV_VAR} environment variable "
"to your MATLAB installation directory (e.g., /Applications/MATLAB_R2024b.app on macOS, "
"C:\\Program Files\\MATLAB\\R2024b on Windows, or /usr/local/MATLAB/R2024b on Linux)."
)
def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings):
"""
Creates a MatlabLanguageServer instance. This class is not meant to be instantiated directly.
Use LanguageServer.create() instead.
"""
matlab_lsp_command, matlab_path = self._setup_runtime_dependencies(config, solidlsp_settings)
self._matlab_path = matlab_path
# Set environment for MATLAB
proc_env = {
"MATLAB_INSTALL_PATH": matlab_path,
}
super().__init__(
config,
repository_root_path,
ProcessLaunchInfo(cmd=matlab_lsp_command, cwd=repository_root_path, env=proc_env),
"matlab",
solidlsp_settings,
)
self.server_ready = threading.Event()
self.initialize_searcher_command_available = threading.Event()
@classmethod
def _download_matlab_extension(cls, url: str, target_dir: str) -> bool:
"""
Download and extract the MATLAB extension from VS Code marketplace.
The VS Code marketplace packages extensions as .vsix files (which are ZIP archives).
This method downloads the VSIX file and extracts it to get the language server.
Args:
url: VS Code marketplace URL for the MATLAB extension
target_dir: Directory where the extension will be extracted
Returns:
True if successful, False otherwise
"""
try:
log.info(f"Downloading MATLAB extension from {url}")
# Create target directory for the extension
os.makedirs(target_dir, exist_ok=True)
# Download with proper headers to mimic VS Code marketplace client
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"Accept": "application/octet-stream, application/vsix, */*",
}
response = requests.get(url, headers=headers, stream=True, timeout=300)
response.raise_for_status()
# Save to temporary VSIX file
temp_file = os.path.join(target_dir, "matlab_extension_temp.vsix")
total_size = int(response.headers.get("content-length", 0))
log.info(f"Downloading {total_size / 1024 / 1024:.1f} MB...")
with open(temp_file, "wb") as f:
downloaded = 0
for chunk in response.iter_content(chunk_size=8192):
if chunk:
f.write(chunk)
downloaded += len(chunk)
if total_size > 0 and downloaded % (10 * 1024 * 1024) == 0:
progress = (downloaded / total_size) * 100
log.info(f"Download progress: {progress:.1f}%")
log.info("Download complete, extracting...")
# Extract VSIX file (VSIX files are ZIP archives)
with zipfile.ZipFile(temp_file, "r") as zip_ref:
zip_ref.extractall(target_dir)
# Clean up temp file
os.remove(temp_file)
log.info("MATLAB extension extracted successfully")
return True
except Exception as e:
log.error(f"Error downloading/extracting MATLAB extension: {e}")
return False
@classmethod
def _find_matlab_extension(cls, solidlsp_settings: SolidLSPSettings) -> str | None:
"""
Find MATLAB extension in various locations.
Search order:
1. Environment variable (MATLAB_EXTENSION_PATH)
2. Default download location (~/.serena/ls_resources/matlab-extension)
3. VS Code installed extensions
Returns:
Path to MATLAB extension directory or None if not found
"""
# Check environment variable
env_path = os.environ.get("MATLAB_EXTENSION_PATH")
if env_path and os.path.exists(env_path):
log.debug(f"Found MATLAB extension via MATLAB_EXTENSION_PATH: {env_path}")
return env_path
elif env_path:
log.warning(f"MATLAB_EXTENSION_PATH set but directory not found: {env_path}")
# Check default download location
default_path = os.path.join(cls.ls_resources_dir(solidlsp_settings), "matlab-extension", "extension")
if os.path.exists(default_path):
log.debug(f"Found MATLAB extension in default location: {default_path}")
return default_path
# Search VS Code extensions
vscode_extensions_dir = os.path.expanduser("~/.vscode/extensions")
if os.path.exists(vscode_extensions_dir):
for entry in os.listdir(vscode_extensions_dir):
if entry.startswith("mathworks.language-matlab"):
ext_path = os.path.join(vscode_extensions_dir, entry)
if os.path.isdir(ext_path):
log.debug(f"Found MATLAB extension in VS Code: {ext_path}")
return ext_path
log.debug("MATLAB extension not found in any known location")
return None
@classmethod
def _download_and_install_matlab_extension(cls, solidlsp_settings: SolidLSPSettings) -> str | None:
"""
Download and install MATLAB extension from VS Code marketplace.
Returns:
Path to installed extension or None if download failed
"""
matlab_extension_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), "matlab-extension")
log.info(f"Downloading MATLAB extension from: {MATLAB_EXTENSION_URL}")
if cls._download_matlab_extension(MATLAB_EXTENSION_URL, matlab_extension_dir):
extension_path = os.path.join(matlab_extension_dir, "extension")
if os.path.exists(extension_path):
log.info("MATLAB extension downloaded and installed successfully")
return extension_path
else:
log.error(f"Download completed but extension not found at: {extension_path}")
else:
log.error("Failed to download MATLAB extension from marketplace")
return None
@classmethod
def _get_executable_path(cls, extension_path: str, system: str) -> str:
"""
Get the path to the MATLAB language server executable based on platform.
The language server is a Node.js script located in the extension's server directory.
"""
# The MATLAB extension bundles the language server in the 'server' directory
server_dir = os.path.join(extension_path, "server", "out")
main_script = os.path.join(server_dir, "index.js")
if os.path.exists(main_script):
return main_script
# Alternative location
alt_script = os.path.join(extension_path, "out", "index.js")
if os.path.exists(alt_script):
return alt_script
raise RuntimeError(f"MATLAB language server script not found in extension at {extension_path}")
@classmethod
def _setup_runtime_dependencies(cls, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings) -> tuple[list[str], str]:
"""
Setup runtime dependencies for MATLAB Language Server and return the command to start the server.
Returns:
Tuple of (command to start the server, MATLAB installation path)
"""
system = platform.system()
# Verify node is installed
node_path = shutil.which("node")
if node_path is None:
raise RuntimeError("Node.js is not installed or isn't in PATH. Please install Node.js and try again.")
# Get MATLAB path from settings or auto-detect
language_specific_config = solidlsp_settings.get_ls_specific_settings(cls.get_language_enum_instance())
matlab_path = language_specific_config.get("matlab_path")
if not matlab_path:
matlab_path = cls._find_matlab_installation() # Raises RuntimeError if not found
# Verify MATLAB path exists
if not os.path.isdir(matlab_path):
raise RuntimeError(f"MATLAB installation directory does not exist: {matlab_path}")
log.info(f"Using MATLAB installation: {matlab_path}")
# Find existing extension or download if needed
extension_path = cls._find_matlab_extension(solidlsp_settings)
if extension_path is None:
log.info("MATLAB extension not found on disk, attempting to download...")
extension_path = cls._download_and_install_matlab_extension(solidlsp_settings)
if extension_path is None:
raise RuntimeError(
"Failed to locate or download MATLAB Language Server. Please either:\n"
"1. Set MATLAB_EXTENSION_PATH environment variable to the MATLAB extension directory\n"
"2. Install the MATLAB extension in VS Code (MathWorks.language-matlab)\n"
"3. Ensure internet connection for automatic download"
)
# Get the language server script path
server_script = cls._get_executable_path(extension_path, system)
if not os.path.exists(server_script):
raise RuntimeError(f"MATLAB Language Server script not found at: {server_script}")
# Build the command to run the language server
# The MATLAB language server is run via Node.js with the --stdio flag
cmd = [node_path, server_script, "--stdio"]
return cmd, matlab_path
@staticmethod
def _get_initialize_params(repository_absolute_path: str) -> InitializeParams:
"""Return the initialize params for the MATLAB 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"]},
"signatureHelp": {"dynamicRegistration": True},
"codeAction": {"dynamicRegistration": True},
"formatting": {"dynamicRegistration": True},
"rename": {"dynamicRegistration": True, "prepareSupport": True},
"publishDiagnostics": {"relatedInformation": True},
},
"workspace": {
"workspaceFolders": True,
"didChangeConfiguration": {"dynamicRegistration": True},
"symbol": {"dynamicRegistration": True},
},
},
"processId": os.getpid(),
"rootPath": repository_absolute_path,
"rootUri": root_uri,
"workspaceFolders": [
{
"uri": root_uri,
"name": os.path.basename(repository_absolute_path),
}
],
}
return cast(InitializeParams, initialize_params)
def _start_server(self) -> None:
"""Start the MATLAB Language Server and wait for it to be ready."""
root_uri = pathlib.Path(self.repository_root_path).as_uri()
def register_capability_handler(params: dict) -> None:
assert "registrations" in params
for registration in params["registrations"]:
if registration["method"] == "workspace/executeCommand":
self.initialize_searcher_command_available.set()
return
def execute_client_command_handler(params: dict) -> list:
return []
def workspace_folders_handler(params: dict) -> list:
"""Handle workspace/workspaceFolders request from the server."""
return [{"uri": root_uri, "name": os.path.basename(self.repository_root_path)}]
def workspace_configuration_handler(params: dict) -> list:
"""Handle workspace/configuration request from the server."""
items = params.get("items", [])
result = []
for item in items:
section = item.get("section", "")
if section == "MATLAB":
# Return MATLAB configuration
result.append({"installPath": self._matlab_path, "matlabConnectionTiming": "onStart"})
else:
result.append({})
return result
def do_nothing(params: dict) -> None:
return
def window_log_message(msg: dict) -> None:
log.info(f"LSP: window/logMessage: {msg}")
message_text = msg.get("message", "")
# Check for MATLAB language server ready signals
# Wait for "MVM attach success" or "Adding workspace folder" which indicates MATLAB is fully ready
# Note: "connected to" comes earlier but the server isn't fully ready at that point
if "mvm attach success" in message_text.lower() or "adding workspace folder" in message_text.lower():
log.info("MATLAB language server ready signal detected (MVM attached)")
self.server_ready.set()
self.completions_available.set()
self.server.on_request("client/registerCapability", register_capability_handler)
self.server.on_notification("window/logMessage", window_log_message)
self.server.on_request("workspace/executeClientCommand", execute_client_command_handler)
self.server.on_request("workspace/workspaceFolders", workspace_folders_handler)
self.server.on_request("workspace/configuration", workspace_configuration_handler)
self.server.on_notification("$/progress", do_nothing)
self.server.on_notification("textDocument/publishDiagnostics", do_nothing)
log.info("Starting MATLAB server 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)
log.debug(f"Received initialize response from MATLAB server: {init_response}")
# Verify basic capabilities
capabilities = init_response.get("capabilities", {})
assert capabilities.get("textDocumentSync") in [1, 2], "Expected Full or Incremental text sync"
# Log available capabilities
if "completionProvider" in capabilities:
log.info("MATLAB server supports completions")
if "definitionProvider" in capabilities:
log.info("MATLAB server supports go-to-definition")
if "referencesProvider" in capabilities:
log.info("MATLAB server supports find-references")
if "documentSymbolProvider" in capabilities:
log.info("MATLAB server supports document symbols")
if "documentFormattingProvider" in capabilities:
log.info("MATLAB server supports document formatting")
if "renameProvider" in capabilities:
log.info("MATLAB server supports rename")
self.server.notify.initialized({})
# Wait for server readiness with timeout
# MATLAB takes longer to start than most language servers (typically 10-30 seconds)
log.info("Waiting for MATLAB language server to be ready (this may take up to 60 seconds)...")
if not self.server_ready.wait(timeout=60.0):
# Fallback: assume server is ready after timeout
log.info("Timeout waiting for MATLAB server ready signal, proceeding anyway")
self.server_ready.set()
self.completions_available.set()
else:
log.info("MATLAB server initialization complete")
def is_ignored_dirname(self, dirname: str) -> bool:
"""Define MATLAB-specific directories to ignore."""
return super().is_ignored_dirname(dirname) or dirname in [
"slprj", # Simulink project files
"codegen", # Code generation output
"sldemo_cache", # Simulink demo cache
"helperFiles", # Common helper file directories
]
def _request_document_symbols(
self, relative_file_path: str, file_data: LSPFileBuffer | None
) -> list[SymbolInformation] | list[DocumentSymbol] | None:
"""
Override to normalize MATLAB symbol names.
The MATLAB LSP sometimes returns symbol names as lists instead of strings,
particularly for script sections (cell mode markers like %%). This method
normalizes the names to strings for compatibility with the unified symbol format.
"""
symbols = super()._request_document_symbols(relative_file_path, file_data)
if symbols is None or len(symbols) == 0:
return symbols
self._normalize_matlab_symbols(symbols)
return symbols
def _normalize_matlab_symbols(self, symbols: list[SymbolInformation] | list[DocumentSymbol]) -> None:
"""
Normalize MATLAB symbol names in-place.
MATLAB LSP returns section names as lists like ["Section Name"] instead of
strings. This converts them to plain strings.
"""
for symbol in symbols:
# MATLAB LSP returns names as lists for script sections, violating LSP spec
# Cast to Any to handle runtime type that differs from spec
name: Any = symbol.get("name")
if isinstance(name, list):
symbol["name"] = name[0] if name else ""
log.debug("Normalized MATLAB symbol name from list to string")
# Recursively normalize children if present
children: Any = symbol.get("children")
if children and isinstance(children, list):
self._normalize_matlab_symbols(children)
+11
View File
@@ -64,6 +64,11 @@ class Language(str, Enum):
GROOVY = "groovy"
VUE = "vue"
POWERSHELL = "powershell"
MATLAB = "matlab"
"""MATLAB language server using 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.
"""
# 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"""
@@ -225,6 +230,8 @@ class Language(str, Enum):
return FilenameMatcher("*.ps1", "*.psm1", "*.psd1")
case self.GROOVY:
return FilenameMatcher("*.groovy", "*.gvy")
case self.MATLAB:
return FilenameMatcher("*.m", "*.mlx", "*.mlapp")
case _:
raise ValueError(f"Unhandled language: {self}")
@@ -390,6 +397,10 @@ class Language(str, Enum):
from solidlsp.language_servers.groovy_language_server import GroovyLanguageServer
return GroovyLanguageServer
case self.MATLAB:
from solidlsp.language_servers.matlab_language_server import MatlabLanguageServer
return MatlabLanguageServer
case _:
raise ValueError(f"Unhandled language: {self}")
@@ -0,0 +1,81 @@
classdef Calculator < handle
% Calculator A simple calculator class for testing MATLAB LSP
%
% This class provides basic arithmetic operations and demonstrates
% MATLAB class structure for LSP testing purposes.
properties
LastResult double = 0
History cell = {}
end
properties (Access = private)
OperationCount uint32 = 0
end
methods
function obj = Calculator()
% Constructor for Calculator class
obj.LastResult = 0;
obj.History = {};
obj.OperationCount = 0;
end
function result = add(obj, a, b)
% ADD Add two numbers
% result = add(obj, a, b) returns the sum of a and b
result = a + b;
obj.updateState(result, 'add');
end
function result = subtract(obj, a, b)
% SUBTRACT Subtract b from a
% result = subtract(obj, a, b) returns a - b
result = a - b;
obj.updateState(result, 'subtract');
end
function result = multiply(obj, a, b)
% MULTIPLY Multiply two numbers
% result = multiply(obj, a, b) returns a * b
result = a * b;
obj.updateState(result, 'multiply');
end
function result = divide(obj, a, b)
% DIVIDE Divide a by b
% result = divide(obj, a, b) returns a / b
% Throws error if b is zero
if b == 0
error('Calculator:DivisionByZero', 'Cannot divide by zero');
end
result = a / b;
obj.updateState(result, 'divide');
end
function displayHistory(obj)
% DISPLAYHISTORY Display the calculation history
fprintf('Calculation History:\n');
for i = 1:length(obj.History)
fprintf(' %d: %s = %.4f\n', i, obj.History{i}.operation, obj.History{i}.result);
end
end
end
methods (Access = private)
function updateState(obj, result, operation)
% Update internal state after an operation
obj.LastResult = result;
obj.OperationCount = obj.OperationCount + 1;
obj.History{end+1} = struct('operation', operation, 'result', result);
end
end
methods (Static)
function result = power(base, exponent)
% POWER Compute base raised to exponent
% result = Calculator.power(base, exponent) returns base^exponent
result = base ^ exponent;
end
end
end
@@ -0,0 +1,67 @@
% MAIN Main script demonstrating Calculator and mathUtils usage
%
% This script shows how to use the Calculator class and mathUtils
% functions together for various mathematical operations.
% Add lib folder to path
addpath('lib');
%% Section 1: Basic Calculator Operations
% Create a calculator instance and perform basic operations
calc = Calculator();
% Perform some calculations
sum_result = calc.add(10, 5);
fprintf('10 + 5 = %d\n', sum_result);
diff_result = calc.subtract(10, 3);
fprintf('10 - 3 = %d\n', diff_result);
prod_result = calc.multiply(4, 7);
fprintf('4 * 7 = %d\n', prod_result);
quot_result = calc.divide(20, 4);
fprintf('20 / 4 = %d\n', quot_result);
%% Section 2: Static Method Usage
% Use the static power method
power_result = Calculator.power(2, 10);
fprintf('2^10 = %d\n', power_result);
%% Section 3: Math Utilities
% Test the mathUtils functions
% Factorial
fact5 = mathUtils('factorial', 5);
fprintf('5! = %d\n', fact5);
% Fibonacci
fib10 = mathUtils('fibonacci', 10);
fprintf('Fibonacci(10) = %d\n', fib10);
% Prime check
is17prime = mathUtils('isPrime', 17);
fprintf('Is 17 prime? %s\n', mat2str(is17prime));
% Statistics
data = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
[dataMean, dataStd] = mathUtils('stats', data);
fprintf('Mean: %.2f, Std: %.2f\n', dataMean, dataStd);
%% Section 4: Display History
% Show all calculations performed by the calculator
calc.displayHistory();
%% Section 5: Error Handling
% Demonstrate error handling with division by zero
try
calc.divide(10, 0);
catch ME
fprintf('Caught expected error: %s\n', ME.message);
end
fprintf('\nAll tests completed successfully!\n');
+1
View File
@@ -0,0 +1 @@
# MATLAB language server tests
+117
View File
@@ -0,0 +1,117 @@
"""
Basic integration tests for the MATLAB language server functionality.
These tests validate the functionality of the language server APIs
like request_document_symbols using the MATLAB test repository.
Requirements:
- MATLAB R2021b or later must be installed
- MATLAB_PATH environment variable should be set to MATLAB installation directory
- Node.js and npm must be installed
"""
import os
import pytest
from solidlsp import SolidLanguageServer
from solidlsp.ls_config import Language
# Skip all tests if MATLAB is not available
pytestmark = pytest.mark.matlab
# Check if MATLAB is available
MATLAB_AVAILABLE = os.environ.get("MATLAB_PATH") is not None or any(
os.path.exists(p)
for p in [
"/Applications/MATLAB_R2024b.app",
"/Applications/MATLAB_R2025b.app",
"/Volumes/S1/Applications/MATLAB_R2024b.app",
"/Volumes/S1/Applications/MATLAB_R2025b.app",
]
)
@pytest.mark.skipif(not MATLAB_AVAILABLE, reason="MATLAB installation not found")
class TestMatlabLanguageServerBasics:
"""Test basic functionality of the MATLAB language server."""
@pytest.mark.parametrize("language_server", [Language.MATLAB], indirect=True)
def test_matlab_language_server_initialization(self, language_server: SolidLanguageServer) -> None:
"""Test that MATLAB language server can be initialized successfully."""
assert language_server is not None
assert language_server.language == Language.MATLAB
@pytest.mark.parametrize("language_server", [Language.MATLAB], indirect=True)
def test_matlab_request_document_symbols_class(self, language_server: SolidLanguageServer) -> None:
"""Test request_document_symbols for MATLAB class file."""
# Test getting symbols from Calculator.m (class file)
all_symbols, _root_symbols = language_server.request_document_symbols("Calculator.m").get_all_symbols_and_roots()
# Extract class symbols (LSP Symbol Kind 5 for class)
class_symbols = [symbol for symbol in all_symbols if symbol.get("kind") == 5]
class_names = [symbol["name"] for symbol in class_symbols]
# Should find the Calculator class
assert "Calculator" in class_names, "Should find Calculator class"
# Extract method symbols (LSP Symbol Kind 6 for method or 12 for function)
method_symbols = [symbol for symbol in all_symbols if symbol.get("kind") in [6, 12]]
method_names = [symbol["name"] for symbol in method_symbols]
# Should find key methods
expected_methods = ["add", "subtract", "multiply", "divide"]
for method in expected_methods:
assert method in method_names, f"Should find {method} method in Calculator class"
@pytest.mark.parametrize("language_server", [Language.MATLAB], indirect=True)
def test_matlab_request_document_symbols_function(self, language_server: SolidLanguageServer) -> None:
"""Test request_document_symbols for MATLAB function file."""
# Test getting symbols from lib/mathUtils.m (function file)
all_symbols, _root_symbols = language_server.request_document_symbols("lib/mathUtils.m").get_all_symbols_and_roots()
# Extract function symbols (LSP Symbol Kind 12 for function)
function_symbols = [symbol for symbol in all_symbols if symbol.get("kind") == 12]
function_names = [symbol["name"] for symbol in function_symbols]
# Should find the main mathUtils function
assert "mathUtils" in function_names, "Should find mathUtils function"
# Should also find nested/local functions
expected_local_functions = ["computeFactorial", "computeFibonacci", "checkPrime", "computeStats"]
for func in expected_local_functions:
assert func in function_names, f"Should find {func} local function"
@pytest.mark.parametrize("language_server", [Language.MATLAB], indirect=True)
def test_matlab_request_document_symbols_script(self, language_server: SolidLanguageServer) -> None:
"""Test request_document_symbols for MATLAB script file."""
# Test getting symbols from main.m (script file)
all_symbols, _root_symbols = language_server.request_document_symbols("main.m").get_all_symbols_and_roots()
# Scripts may have variables and sections, but less structured symbols
# Just verify we can get symbols without errors
assert all_symbols is not None
@pytest.mark.skipif(not MATLAB_AVAILABLE, reason="MATLAB installation not found")
class TestMatlabLanguageServerReferences:
"""Test find references functionality of the MATLAB language server."""
@pytest.mark.parametrize("language_server", [Language.MATLAB], indirect=True)
def test_matlab_find_references_within_file(self, language_server: SolidLanguageServer) -> None:
"""Test finding references within a single MATLAB file."""
# Find references to 'result' variable in Calculator.m
# This is a basic test to verify references work
references = language_server.request_references("Calculator.m", 25, 12) # 'result' in add method
# Should find at least the definition
assert references is not None
@pytest.mark.parametrize("language_server", [Language.MATLAB], indirect=True)
def test_matlab_find_references_cross_file(self, language_server: SolidLanguageServer) -> None:
"""Test finding references across MATLAB files."""
# Find references to Calculator class used in main.m
references = language_server.request_references("main.m", 11, 8) # 'Calculator' reference
# Should find references in both main.m and Calculator.m
assert references is not None