From 9560ee651def61c4363363df9fc80be31b92468d Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Tue, 13 Jan 2026 22:14:42 +0100 Subject: [PATCH] Introduce LanguageServerDependencyProvider abstraction * Add specialisation LanguageServerDependencyProviderSinglePath, where the core path can be overridden via ls_specific_settings #917 * Implement in MatLabLanguageServer and TypeScriptLanguageServer * Backwards compatibility is retained; the new approach is optional --- .../matlab_language_server.py | 339 +++++++++--------- .../typescript_language_server.py | 174 ++++----- src/solidlsp/ls.py | 94 ++++- 3 files changed, 350 insertions(+), 257 deletions(-) diff --git a/src/solidlsp/language_servers/matlab_language_server.py b/src/solidlsp/language_servers/matlab_language_server.py index 74928ceb..9be9390b 100644 --- a/src/solidlsp/language_servers/matlab_language_server.py +++ b/src/solidlsp/language_servers/matlab_language_server.py @@ -38,14 +38,13 @@ import platform import shutil import threading import zipfile -from typing import Any, cast +from typing import Any, Union, cast import requests -from solidlsp.ls import LSPFileBuffer, SolidLanguageServer +from solidlsp.ls import LanguageServerDependencyProvider, 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__) @@ -151,228 +150,230 @@ class MatlabLanguageServer(SolidLanguageServer): 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), + None, "matlab", solidlsp_settings, ) + + assert isinstance(self._dependency_provider, self.DependencyProvider) + self._matlab_path = self._dependency_provider.get_matlab_path() + 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. + def _create_dependency_provider(self) -> LanguageServerDependencyProvider: + return self.DependencyProvider(self._custom_settings, self._ls_resources_dir) - 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. + class DependencyProvider(LanguageServerDependencyProvider): + def __init__(self, custom_settings: SolidLSPSettings.CustomLSSettings, ls_resources_dir: str): + super().__init__(custom_settings, ls_resources_dir) + self._matlab_path: str | None = None - Args: - url: VS Code marketplace URL for the MATLAB extension - target_dir: Directory where the extension will be extracted + @classmethod + def _download_matlab_extension(cls, url: str, target_dir: str) -> bool: + """ + Download and extract the MATLAB extension from VS Code marketplace. - Returns: - True if successful, False otherwise + 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. - """ - try: - log.info(f"Downloading MATLAB extension from {url}") + Args: + url: VS Code marketplace URL for the MATLAB extension + target_dir: Directory where the extension will be extracted - # Create target directory for the extension - os.makedirs(target_dir, exist_ok=True) + Returns: + True if successful, False otherwise - # 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, */*", - } + """ + try: + log.info(f"Downloading MATLAB extension from {url}") - response = requests.get(url, headers=headers, stream=True, timeout=300) - response.raise_for_status() + # Create target directory for the extension + os.makedirs(target_dir, exist_ok=True) - # 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)) + # 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, */*", + } - log.info(f"Downloading {total_size / 1024 / 1024:.1f} MB...") + response = requests.get(url, headers=headers, stream=True, timeout=300) + response.raise_for_status() - 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}%") + # 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("Download complete, extracting...") + log.info(f"Downloading {total_size / 1024 / 1024:.1f} MB...") - # Extract VSIX file (VSIX files are ZIP archives) - with zipfile.ZipFile(temp_file, "r") as zip_ref: - zip_ref.extractall(target_dir) + 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}%") - # Clean up temp file - os.remove(temp_file) + log.info("Download complete, extracting...") - log.info("MATLAB extension extracted successfully") - return True + # Extract VSIX file (VSIX files are ZIP archives) + with zipfile.ZipFile(temp_file, "r") as zip_ref: + zip_ref.extractall(target_dir) - except Exception as e: - log.error(f"Error downloading/extracting MATLAB extension: {e}") - return False + # Clean up temp file + os.remove(temp_file) - @classmethod - def _find_matlab_extension(cls, solidlsp_settings: SolidLSPSettings) -> str | None: - """ - Find MATLAB extension in various locations. + log.info("MATLAB extension extracted successfully") + return True - Search order: - 1. Environment variable (MATLAB_EXTENSION_PATH) - 2. Default download location (~/.serena/ls_resources/matlab-extension) - 3. VS Code installed extensions + except Exception as e: + log.error(f"Error downloading/extracting MATLAB extension: {e}") + return False - Returns: - Path to MATLAB extension directory or None if not found + def _find_matlab_extension(self) -> str | None: + """ + Find MATLAB extension in various locations. - """ - # 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}") + Search order: + 1. Environment variable (MATLAB_EXTENSION_PATH) + 2. Default download location (~/.serena/ls_resources/matlab-extension) + 3. VS Code installed extensions - # 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 + Returns: + Path to MATLAB extension directory or None if not found - # 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 + """ + # 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}") - log.debug("MATLAB extension not found in any known location") - return None + # Check default download location + default_path = os.path.join(self._ls_resources_dir, "matlab-extension", "extension") + if os.path.exists(default_path): + log.debug(f"Found MATLAB extension in default location: {default_path}") + return default_path - @classmethod - def _download_and_install_matlab_extension(cls, solidlsp_settings: SolidLSPSettings) -> str | None: - """ - Download and install MATLAB extension from VS Code marketplace. + # 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 - Returns: - Path to installed extension or None if download failed + log.debug("MATLAB extension not found in any known location") + return None - """ - matlab_extension_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), "matlab-extension") + def _download_and_install_matlab_extension(self) -> str | None: + """ + Download and install MATLAB extension from VS Code marketplace. - log.info(f"Downloading MATLAB extension from: {MATLAB_EXTENSION_URL}") + Returns: + Path to installed extension or None if download failed - 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 + """ + matlab_extension_dir = os.path.join(self._ls_resources_dir, "matlab-extension") + + log.info(f"Downloading MATLAB extension from: {MATLAB_EXTENSION_URL}") + + if self._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(f"Download completed but extension not found at: {extension_path}") - else: - log.error("Failed to download MATLAB extension from marketplace") + log.error("Failed to download MATLAB extension from marketplace") - return None + 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. + @classmethod + def _get_executable_path(cls, extension_path: 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") + 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 + 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 + # 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}") + 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. + def get_matlab_path(self) -> str: + """Get MATLAB path from settings or auto-detect.""" + if self._matlab_path is not None: + return self._matlab_path - Returns: - Tuple of (command to start the server, MATLAB installation path) + matlab_path = self._custom_settings.get("matlab_path") - """ - system = platform.system() + if not matlab_path: + matlab_path = MatlabLanguageServer._find_matlab_installation() # Raises RuntimeError if not found - # 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.") + # Verify MATLAB path exists + if not os.path.isdir(matlab_path): + raise RuntimeError(f"MATLAB installation directory does not exist: {matlab_path}") - # 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") + log.info(f"Using MATLAB installation: {matlab_path}") - if not matlab_path: - matlab_path = cls._find_matlab_installation() # Raises RuntimeError if not found + self._matlab_path = matlab_path + return matlab_path - # Verify MATLAB path exists - if not os.path.isdir(matlab_path): - raise RuntimeError(f"MATLAB installation directory does not exist: {matlab_path}") + def create_launch_command(self) -> Union[str, list[str]]: + # 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.") - log.info(f"Using MATLAB installation: {matlab_path}") + # Find existing extension or download if needed + extension_path = self._find_matlab_extension() + if extension_path is None: + log.info("MATLAB extension not found on disk, attempting to download...") + extension_path = self._download_and_install_matlab_extension() - # 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" + ) - 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 = self._get_executable_path(extension_path) - # 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}") - 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 - # 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 + def create_process_env(self) -> dict[str, str]: + return { + "MATLAB_INSTALL_PATH": self.get_matlab_path(), + } @staticmethod def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: diff --git a/src/solidlsp/language_servers/typescript_language_server.py b/src/solidlsp/language_servers/typescript_language_server.py index 4734da45..779ed4e5 100644 --- a/src/solidlsp/language_servers/typescript_language_server.py +++ b/src/solidlsp/language_servers/typescript_language_server.py @@ -13,11 +13,10 @@ from overrides import override from sensai.util.logging import LogTime from solidlsp import ls_types -from solidlsp.ls import SolidLanguageServer +from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.ls_utils import PlatformId, PlatformUtils from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams -from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings from .common import RuntimeDependency, RuntimeDependencyCollection @@ -72,17 +71,19 @@ class TypeScriptLanguageServer(SolidLanguageServer): """ Creates a TypeScriptLanguageServer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. """ - ts_lsp_executable_path = self._setup_runtime_dependencies(config, solidlsp_settings) super().__init__( config, repository_root_path, - ProcessLaunchInfo(cmd=ts_lsp_executable_path, cwd=repository_root_path), + None, "typescript", solidlsp_settings, ) self.server_ready = threading.Event() self.initialize_searcher_command_available = threading.Event() + def _create_dependency_provider(self) -> LanguageServerDependencyProvider: + return self.DependencyProvider(self._custom_settings, self._ls_resources_dir) + @override def is_ignored_dirname(self, dirname: str) -> bool: return super().is_ignored_dirname(dirname) or dirname in [ @@ -97,91 +98,96 @@ class TypeScriptLanguageServer(SolidLanguageServer): """Classify typescript-language-server stderr output to avoid false-positive errors.""" return SolidLanguageServer._determine_log_level(line) - @classmethod - def _setup_runtime_dependencies(cls, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings) -> list[str]: - """ - Setup runtime dependencies for TypeScript Language Server and return the command to start the server. - """ - platform_id = PlatformUtils.get_platform_id() + class DependencyProvider(LanguageServerDependencyProviderSinglePath): + def _get_or_install_core_dependency(self) -> str: + """ + Setup runtime dependencies for TypeScript Language Server and return the path to the executable. + """ + platform_id = PlatformUtils.get_platform_id() - valid_platforms = [ - PlatformId.LINUX_x64, - PlatformId.LINUX_arm64, - PlatformId.OSX, - PlatformId.OSX_x64, - PlatformId.OSX_arm64, - PlatformId.WIN_x64, - PlatformId.WIN_arm64, - ] - assert platform_id in valid_platforms, f"Platform {platform_id} is not supported for multilspy javascript/typescript at the moment" - - # Get version settings from ls_specific_settings or use defaults - language_specific_config = solidlsp_settings.get_ls_specific_settings(cls.get_language_enum_instance()) - typescript_version = language_specific_config.get("typescript_version", "5.9.3") - typescript_language_server_version = language_specific_config.get("typescript_language_server_version", "5.1.3") - - deps = RuntimeDependencyCollection( - [ - RuntimeDependency( - id="typescript", - description="typescript package", - command=["npm", "install", "--prefix", "./", f"typescript@{typescript_version}"], - platform_id="any", - ), - RuntimeDependency( - id="typescript-language-server", - description="typescript-language-server package", - command=["npm", "install", "--prefix", "./", f"typescript-language-server@{typescript_language_server_version}"], - platform_id="any", - ), + valid_platforms = [ + PlatformId.LINUX_x64, + PlatformId.LINUX_arm64, + PlatformId.OSX, + PlatformId.OSX_x64, + PlatformId.OSX_arm64, + PlatformId.WIN_x64, + PlatformId.WIN_arm64, ] - ) + assert ( + platform_id in valid_platforms + ), f"Platform {platform_id} is not supported for multilspy javascript/typescript at the moment" - # Verify both node and npm are installed - is_node_installed = shutil.which("node") is not None - assert is_node_installed, "node is not installed or isn't in PATH. Please install NodeJS and try again." - is_npm_installed = shutil.which("npm") is not None - assert is_npm_installed, "npm is not installed or isn't in PATH. Please install npm and try again." + # Get version settings from ls_specific_settings or use defaults + language_specific_config = self._custom_settings + typescript_version = language_specific_config.get("typescript_version", "5.9.3") + typescript_language_server_version = language_specific_config.get("typescript_language_server_version", "5.1.3") - # Install typescript and typescript-language-server if not already installed or version mismatch - tsserver_ls_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), "ts-lsp") - tsserver_executable_path = os.path.join(tsserver_ls_dir, "node_modules", ".bin", "typescript-language-server") - - # Check if installation is needed based on executable AND version - version_file = os.path.join(tsserver_ls_dir, ".installed_version") - expected_version = f"{typescript_version}_{typescript_language_server_version}" - - needs_install = False - if not os.path.exists(tsserver_executable_path): - log.info(f"Typescript Language Server executable not found at {tsserver_executable_path}.") - needs_install = True - elif os.path.exists(version_file): - with open(version_file) as f: - installed_version = f.read().strip() - if installed_version != expected_version: - log.info( - f"TypeScript Language Server version mismatch: installed={installed_version}, expected={expected_version}. Reinstalling..." - ) - needs_install = True - else: - # No version file exists, assume old installation needs refresh - log.info("TypeScript Language Server version file not found. Reinstalling to ensure correct version...") - needs_install = True - - if needs_install: - log.info("Installing TypeScript Language Server dependencies...") - with LogTime("Installation of TypeScript language server dependencies", logger=log): - deps.install(tsserver_ls_dir) - # Write version marker file - with open(version_file, "w") as f: - f.write(expected_version) - log.info("TypeScript language server dependencies installed successfully") - - if not os.path.exists(tsserver_executable_path): - raise FileNotFoundError( - f"typescript-language-server executable not found at {tsserver_executable_path}, something went wrong with the installation." + deps = RuntimeDependencyCollection( + [ + RuntimeDependency( + id="typescript", + description="typescript package", + command=["npm", "install", "--prefix", "./", f"typescript@{typescript_version}"], + platform_id="any", + ), + RuntimeDependency( + id="typescript-language-server", + description="typescript-language-server package", + command=["npm", "install", "--prefix", "./", f"typescript-language-server@{typescript_language_server_version}"], + platform_id="any", + ), + ] ) - return [tsserver_executable_path, "--stdio"] + + # Verify both node and npm are installed + is_node_installed = shutil.which("node") is not None + assert is_node_installed, "node is not installed or isn't in PATH. Please install NodeJS and try again." + is_npm_installed = shutil.which("npm") is not None + assert is_npm_installed, "npm is not installed or isn't in PATH. Please install npm and try again." + + # Install typescript and typescript-language-server if not already installed or version mismatch + tsserver_ls_dir = os.path.join(self._ls_resources_dir, "ts-lsp") + tsserver_executable_path = os.path.join(tsserver_ls_dir, "node_modules", ".bin", "typescript-language-server") + + # Check if installation is needed based on executable AND version + version_file = os.path.join(tsserver_ls_dir, ".installed_version") + expected_version = f"{typescript_version}_{typescript_language_server_version}" + + needs_install = False + if not os.path.exists(tsserver_executable_path): + log.info(f"Typescript Language Server executable not found at {tsserver_executable_path}.") + needs_install = True + elif os.path.exists(version_file): + with open(version_file) as f: + installed_version = f.read().strip() + if installed_version != expected_version: + log.info( + f"TypeScript Language Server version mismatch: installed={installed_version}, expected={expected_version}. Reinstalling..." + ) + needs_install = True + else: + # No version file exists, assume old installation needs refresh + log.info("TypeScript Language Server version file not found. Reinstalling to ensure correct version...") + needs_install = True + + if needs_install: + log.info("Installing TypeScript Language Server dependencies...") + with LogTime("Installation of TypeScript language server dependencies", logger=log): + deps.install(tsserver_ls_dir) + # Write version marker file + with open(version_file, "w") as f: + f.write(expected_version) + log.info("TypeScript language server dependencies installed successfully") + + if not os.path.exists(tsserver_executable_path): + raise FileNotFoundError( + f"typescript-language-server executable not found at {tsserver_executable_path}, something went wrong with the installation." + ) + return tsserver_executable_path + + def _create_launch_command(self, core_path: str) -> list[str]: + return [core_path, "--stdio"] def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: """ diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index e5e9e785..445705a7 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -130,6 +130,68 @@ class DocumentSymbols: return self._all_symbols, self.root_symbols +class LanguageServerDependencyProvider(ABC): + """ + Prepares dependencies for a language server (if any), ultimately enabling the launch command to be constructed + and optionally providing environment variables that are necessary for the execution. + """ + + def __init__(self, custom_settings: SolidLSPSettings.CustomLSSettings, ls_resources_dir: str): + self._custom_settings = custom_settings + self._ls_resources_dir = ls_resources_dir + + @abstractmethod + def create_launch_command(self) -> list[str] | str: + """ + Creates the launch command for this language server, potentially downloading and installing dependencies + beforehand. + + :return: the launch command as a list containing the executable and its arguments (preferred for robustness) + or the entire command in a single string. + """ + + def create_process_env(self) -> dict[str, str]: + """ + Provides environment variables to be set when executing the launch command. + + This method is intended to be overridden by subclasses that need to set variables. + + :return: a mapping for variable names to values + """ + return {} + + +class LanguageServerDependencyProviderSinglePath(LanguageServerDependencyProvider, ABC): + """ + Special case of a dependency provider, where there is a single core dependency which provides + the basis for the launch command. + """ + + @abstractmethod + def _get_or_install_core_dependency(self) -> str: + """ + Gets the language server's core path, potentially installing dependencies beforehand. + + :return: the core dependency's path (e.g. executable, jar, etc.) + """ + + def create_launch_command(self) -> Union[str, list[str]]: + path = self._custom_settings.get("ls_path", None) + if path is not None: + core_path = path + else: + core_path = self._get_or_install_core_dependency() + return self._create_launch_command(core_path) + + @abstractmethod + def _create_launch_command(self, core_path: str) -> list[str] | str: + """ + :param core_path: path to the core dependency + :return: the launch command as a list containing the executable and its arguments (preferred for robustness) + or the entire command in a single string. + """ + + class SolidLanguageServer(ABC): """ The LanguageServer class provides a language agnostic interface to the Language Server Protocol. @@ -242,7 +304,7 @@ class SolidLanguageServer(ABC): self, config: LanguageServerConfig, repository_root_path: str, - process_launch_info: ProcessLaunchInfo, + process_launch_info: ProcessLaunchInfo | None, language_id: str, solidlsp_settings: SolidLSPSettings, cache_version_raw_document_symbols: Hashable = 1, @@ -254,7 +316,8 @@ class SolidLanguageServer(ABC): :param config: the global SolidLSP configuration. :param repository_root_path: the root path of the repository. - :param process_launch_info: the command used to start the actual language server. + :param process_launch_info: (DEPRECATED - implement _create_dependency_provider instead) + the command used to start the actual language server. The command must pass appropriate flags to the binary, so that it runs in the stdio mode, as opposed to HTTP, TCP modes supported by some language servers. :param cache_version_raw_document_symbols: the version, for caching, of the raw document symbols coming @@ -265,9 +328,11 @@ class SolidLanguageServer(ABC): self._solidlsp_settings = solidlsp_settings lang = self.get_language_enum_instance() self._custom_settings = solidlsp_settings.get_ls_specific_settings(lang) + self._ls_resources_dir = self.ls_resources_dir(solidlsp_settings) log.debug(f"Custom config (LS-specific settings) for {lang}: {self._custom_settings}") self._encoding = config.encoding self.repository_root_path: str = repository_root_path + log.debug( f"Creating language server instance for {repository_root_path=} with {language_id=} and process launch info: {process_launch_info}" ) @@ -303,8 +368,12 @@ class SolidLanguageServer(ABC): else: logging_fn = None # type: ignore - # cmd is obtained from the child classes, which provide the language specific command to start the language server - # LanguageServerHandler provides the functionality to start the language server and communicate with it + # create the LanguageServerHandler, which provides the functionality to start the language server and communicate with it, + # preparing the launch command beforehand + self._dependency_provider: LanguageServerDependencyProvider | None = None + if process_launch_info is None: + self._dependency_provider = self._create_dependency_provider() + process_launch_info = self._create_process_launch_info() log.debug(f"Creating language server instance with {language_id=} and process launch info: {process_launch_info}") self.server = SolidLanguageServerHandler( process_launch_info, @@ -330,6 +399,23 @@ class SolidLanguageServer(ABC): self._has_waited_for_cross_file_references = False + def _create_dependency_provider(self) -> LanguageServerDependencyProvider: + """ + Creates the dependency provider for this language server. + + Subclasses should override this method to provide their specific dependency provider. + This method is only called if process_launch_info is not passed to __init__. + """ + raise NotImplementedError( + f"{self.__class__.__name__} must implement _create_dependency_provider() or pass process_launch_info to __init__()" + ) + + def _create_process_launch_info(self) -> ProcessLaunchInfo: + assert self._dependency_provider is not None + cmd = self._dependency_provider.create_launch_command() + env = self._dependency_provider.create_process_env() + return ProcessLaunchInfo(cmd=cmd, cwd=self.repository_root_path, env=env) + def _get_wait_time_for_cross_file_referencing(self) -> float: """Meant to be overridden by subclasses for LS that don't have a reliable "finished initializing" signal.