From f758a3d19cb6aa30964830f6ae683ab9b294dbfd Mon Sep 17 00:00:00 2001 From: "Seyed (Yahya) Shirazi" Date: Thu, 1 Jan 2026 06:56:23 -0800 Subject: [PATCH] Add MATLAB language server support (#893) Requires Matlab and node to be installed, using vscode Matlab extension as LS --- CHANGELOG.md | 1 + README.md | 2 +- pyproject.toml | 1 + .../matlab_language_server.py | 559 ++++++++++++++++++ src/solidlsp/ls_config.py | 11 + .../repos/matlab/test_repo/Calculator.m | 81 +++ test/resources/repos/matlab/test_repo/main.m | 67 +++ test/solidlsp/matlab/__init__.py | 1 + test/solidlsp/matlab/test_matlab_basic.py | 117 ++++ 9 files changed, 839 insertions(+), 1 deletion(-) create mode 100644 src/solidlsp/language_servers/matlab_language_server.py create mode 100644 test/resources/repos/matlab/test_repo/Calculator.m create mode 100644 test/resources/repos/matlab/test_repo/main.m create mode 100644 test/solidlsp/matlab/__init__.py create mode 100644 test/solidlsp/matlab/test_matlab_basic.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e6228cd0..ef7afa61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 48b17305..b03b07c1 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, 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. diff --git a/pyproject.toml b/pyproject.toml index 086dd9c2..53bb740d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] diff --git a/src/solidlsp/language_servers/matlab_language_server.py b/src/solidlsp/language_servers/matlab_language_server.py new file mode 100644 index 00000000..74928ceb --- /dev/null +++ b/src/solidlsp/language_servers/matlab_language_server.py @@ -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) diff --git a/src/solidlsp/ls_config.py b/src/solidlsp/ls_config.py index 57edbb02..7489b4f9 100644 --- a/src/solidlsp/ls_config.py +++ b/src/solidlsp/ls_config.py @@ -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}") diff --git a/test/resources/repos/matlab/test_repo/Calculator.m b/test/resources/repos/matlab/test_repo/Calculator.m new file mode 100644 index 00000000..d0c3aec4 --- /dev/null +++ b/test/resources/repos/matlab/test_repo/Calculator.m @@ -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 diff --git a/test/resources/repos/matlab/test_repo/main.m b/test/resources/repos/matlab/test_repo/main.m new file mode 100644 index 00000000..c95948cc --- /dev/null +++ b/test/resources/repos/matlab/test_repo/main.m @@ -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'); diff --git a/test/solidlsp/matlab/__init__.py b/test/solidlsp/matlab/__init__.py new file mode 100644 index 00000000..cf1fe4a6 --- /dev/null +++ b/test/solidlsp/matlab/__init__.py @@ -0,0 +1 @@ +# MATLAB language server tests diff --git a/test/solidlsp/matlab/test_matlab_basic.py b/test/solidlsp/matlab/test_matlab_basic.py new file mode 100644 index 00000000..a6c423c8 --- /dev/null +++ b/test/solidlsp/matlab/test_matlab_basic.py @@ -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