Skip to main content
Glama
solargraph.py16.5 kB
""" Provides Ruby specific instantiation of the LanguageServer class using Solargraph. Contains various configurations and settings specific to Ruby. """ import json import logging import os import pathlib import re import shutil import subprocess import threading from overrides import override from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) class Solargraph(SolidLanguageServer): """ Provides Ruby specific instantiation of the LanguageServer class using Solargraph. Contains various configurations and settings specific to Ruby. """ def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): """ Creates a Solargraph instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. """ solargraph_executable_path = self._setup_runtime_dependencies(config, repository_root_path) super().__init__( config, repository_root_path, ProcessLaunchInfo(cmd=f"{solargraph_executable_path} stdio", cwd=repository_root_path), "ruby", solidlsp_settings, ) # Override internal language enum for file matching (excludes .erb files) # while keeping LSP languageId as "ruby" for protocol compliance from solidlsp.ls_config import Language self.language = Language.RUBY_SOLARGRAPH self.analysis_complete = threading.Event() self.service_ready_event = threading.Event() self.initialize_searcher_command_available = threading.Event() self.resolve_main_method_available = threading.Event() # Set timeout for Solargraph requests - Bundler environments may need more time self.set_request_timeout(120.0) # 120 seconds for initialization and requests @override def is_ignored_dirname(self, dirname: str) -> bool: ruby_ignored_dirs = [ "vendor", # Ruby vendor directory ".bundle", # Bundler cache "tmp", # Temporary files "log", # Log files "coverage", # Test coverage reports ".yardoc", # YARD documentation cache "doc", # Generated documentation "node_modules", # Node modules (for Rails with JS) "storage", # Active Storage files (Rails) ] return super().is_ignored_dirname(dirname) or dirname in ruby_ignored_dirs @staticmethod def _setup_runtime_dependencies(config: LanguageServerConfig, repository_root_path: str) -> str: """ Setup runtime dependencies for Solargraph and return the command to start the server. """ # Check if Ruby is installed try: result = subprocess.run(["ruby", "--version"], check=True, capture_output=True, cwd=repository_root_path, text=True) ruby_version = result.stdout.strip() log.info(f"Ruby version: {ruby_version}") # Extract version number for compatibility checks version_match = re.search(r"ruby (\d+)\.(\d+)\.(\d+)", ruby_version) if version_match: major, minor, patch = map(int, version_match.groups()) if major < 2 or (major == 2 and minor < 6): log.warning(f"Warning: Ruby {major}.{minor}.{patch} detected. Solargraph works best with Ruby 2.6+") except subprocess.CalledProcessError as e: error_msg = e.stderr.decode() if e.stderr else "Unknown error" raise RuntimeError( f"Error checking Ruby installation: {error_msg}. Please ensure Ruby is properly installed and in PATH." ) from e except FileNotFoundError as e: raise RuntimeError( "Ruby is not installed or not found in PATH. Please install Ruby using one of these methods:\n" " - Using rbenv: rbenv install 3.0.0 && rbenv global 3.0.0\n" " - Using RVM: rvm install 3.0.0 && rvm use 3.0.0 --default\n" " - Using asdf: asdf install ruby 3.0.0 && asdf global ruby 3.0.0\n" " - System package manager (brew install ruby, apt install ruby, etc.)" ) from e # Helper function for Windows-compatible executable search def find_executable_with_extensions(executable_name: str) -> str | None: """Find executable with Windows-specific extensions if on Windows.""" import platform if platform.system() == "Windows": for ext in [".bat", ".cmd", ".exe"]: path = shutil.which(f"{executable_name}{ext}") if path: return path return shutil.which(executable_name) else: return shutil.which(executable_name) # Check for Bundler project (Gemfile exists) gemfile_path = os.path.join(repository_root_path, "Gemfile") gemfile_lock_path = os.path.join(repository_root_path, "Gemfile.lock") is_bundler_project = os.path.exists(gemfile_path) if is_bundler_project: log.info("Detected Bundler project (Gemfile found)") # Check if bundle command is available bundle_path = find_executable_with_extensions("bundle") if not bundle_path: # Try common bundle executables for bundle_cmd in ["bin/bundle", "bundle"]: if bundle_cmd.startswith("bin/"): bundle_full_path = os.path.join(repository_root_path, bundle_cmd) else: bundle_full_path = find_executable_with_extensions(bundle_cmd) # type: ignore[assignment] if bundle_full_path and os.path.exists(bundle_full_path): bundle_path = bundle_full_path if bundle_cmd.startswith("bin/") else bundle_cmd break if not bundle_path: raise RuntimeError( "Bundler project detected but 'bundle' command not found. Please install Bundler:\n" " - gem install bundler\n" " - Or use your Ruby version manager's bundler installation\n" " - Ensure the bundle command is in your PATH" ) # Check if solargraph is in Gemfile.lock solargraph_in_bundle = False if os.path.exists(gemfile_lock_path): try: with open(gemfile_lock_path) as f: content = f.read() solargraph_in_bundle = "solargraph" in content.lower() except Exception as e: log.warning(f"Warning: Could not read Gemfile.lock: {e}") if solargraph_in_bundle: log.info("Found solargraph in Gemfile.lock") return f"{bundle_path} exec solargraph" else: log.warning( "solargraph not found in Gemfile.lock. Please add 'gem \"solargraph\"' to your Gemfile and run 'bundle install'", ) # Fall through to global installation check # Check if solargraph is installed globally # First, try to find solargraph in PATH (includes asdf shims) with Windows support solargraph_path = find_executable_with_extensions("solargraph") if solargraph_path: log.info(f"Found solargraph at: {solargraph_path}") return solargraph_path # Fallback to gem exec (for non-Bundler projects or when global solargraph not found) if not is_bundler_project: runtime_dependencies = [ { "url": "https://rubygems.org/downloads/solargraph-0.51.1.gem", "installCommand": "gem install solargraph -v 0.51.1", "binaryName": "solargraph", "archiveType": "gem", } ] dependency = runtime_dependencies[0] try: result = subprocess.run( ["gem", "list", "^solargraph$", "-i"], check=False, capture_output=True, text=True, cwd=repository_root_path ) if result.stdout.strip() == "false": log.info("Installing Solargraph...") subprocess.run(dependency["installCommand"].split(), check=True, capture_output=True, cwd=repository_root_path) return "gem exec solargraph" except subprocess.CalledProcessError as e: error_msg = e.stderr.decode() if e.stderr else str(e) raise RuntimeError( f"Failed to check or install Solargraph: {error_msg}\nPlease try installing manually: gem install solargraph" ) from e else: raise RuntimeError( "This appears to be a Bundler project, but solargraph is not available. " "Please add 'gem \"solargraph\"' to your Gemfile and run 'bundle install'." ) @staticmethod def _detect_rails_project(repository_root_path: str) -> bool: """ Detect if this is a Rails project by checking for Rails-specific files. """ rails_indicators = [ "config/application.rb", "config/environment.rb", "app/controllers/application_controller.rb", "Rakefile", ] for indicator in rails_indicators: if os.path.exists(os.path.join(repository_root_path, indicator)): return True # Check for Rails in Gemfile gemfile_path = os.path.join(repository_root_path, "Gemfile") if os.path.exists(gemfile_path): try: with open(gemfile_path) as f: content = f.read().lower() if "gem 'rails'" in content or 'gem "rails"' in content: return True except Exception: pass return False @staticmethod def _get_ruby_exclude_patterns(repository_root_path: str) -> list[str]: """ Get Ruby and Rails-specific exclude patterns for better performance. """ base_patterns = [ "**/vendor/**", # Ruby vendor directory (similar to node_modules) "**/.bundle/**", # Bundler cache "**/tmp/**", # Temporary files "**/log/**", # Log files "**/coverage/**", # Test coverage reports "**/.yardoc/**", # YARD documentation cache "**/doc/**", # Generated documentation "**/.git/**", # Git directory "**/node_modules/**", # Node modules (for Rails with JS) "**/public/assets/**", # Rails compiled assets ] # Add Rails-specific patterns if this is a Rails project if Solargraph._detect_rails_project(repository_root_path): rails_patterns = [ "**/public/packs/**", # Webpacker output "**/public/webpack/**", # Webpack output "**/storage/**", # Active Storage files "**/tmp/cache/**", # Rails cache "**/tmp/pids/**", # Process IDs "**/tmp/sessions/**", # Session files "**/tmp/sockets/**", # Socket files "**/db/*.sqlite3", # SQLite databases ] base_patterns.extend(rails_patterns) return base_patterns @staticmethod def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: """ Returns the initialize params for the Solargraph Language Server. """ root_uri = pathlib.Path(repository_absolute_path).as_uri() exclude_patterns = Solargraph._get_ruby_exclude_patterns(repository_absolute_path) initialize_params: InitializeParams = { # type: ignore "processId": os.getpid(), "rootPath": repository_absolute_path, "rootUri": root_uri, "initializationOptions": { "exclude": exclude_patterns, # type: ignore[dict-item] }, "capabilities": { "workspace": { "workspaceEdit": {"documentChanges": True}, }, "textDocument": { "documentSymbol": { "hierarchicalDocumentSymbolSupport": True, "symbolKind": {"valueSet": list(range(1, 27))}, # type: ignore[arg-type] }, }, }, "trace": "verbose", # type: ignore[typeddict-item] "workspaceFolders": [ { "uri": root_uri, "name": os.path.basename(repository_absolute_path), } ], } return initialize_params # type: ignore[return-value] def _start_server(self) -> None: """ Starts the Solargraph Language Server for Ruby """ 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() self.resolve_main_method_available.set() return def lang_status_handler(params: dict) -> None: log.info(f"LSP: language/status: {params}") if params.get("type") == "ServiceReady" and params.get("message") == "Service is ready.": log.info("Solargraph service is ready.") self.analysis_complete.set() self.completions_available.set() def execute_client_command_handler(params: dict) -> list: return [] def do_nothing(params: dict) -> None: return def window_log_message(msg: dict) -> None: log.info(f"LSP: window/logMessage: {msg}") self.server.on_request("client/registerCapability", register_capability_handler) self.server.on_notification("language/status", lang_status_handler) self.server.on_notification("window/logMessage", window_log_message) self.server.on_request("workspace/executeClientCommand", execute_client_command_handler) self.server.on_notification("$/progress", do_nothing) self.server.on_notification("textDocument/publishDiagnostics", do_nothing) self.server.on_notification("language/actionableNotification", do_nothing) log.info("Starting solargraph 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") log.info(f"Sending init params: {json.dumps(initialize_params, indent=4)}") init_response = self.server.send.initialize(initialize_params) log.info(f"Received init response: {init_response}") assert init_response["capabilities"]["textDocumentSync"] == 2 assert "completionProvider" in init_response["capabilities"] assert init_response["capabilities"]["completionProvider"] == { "resolveProvider": True, "triggerCharacters": [".", ":", "@"], } self.server.notify.initialized({}) # Wait for Solargraph to complete its initial workspace analysis # This prevents issues by ensuring background tasks finish log.info("Waiting for Solargraph to complete initial workspace analysis...") if self.analysis_complete.wait(timeout=60.0): log.info("Solargraph initial analysis complete, server ready") else: log.warning("Timeout waiting for Solargraph analysis completion, proceeding anyway") # Fallback: assume analysis is complete after timeout self.analysis_complete.set() self.completions_available.set()

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/oraios/serena'

If you have feedback or need assistance with the MCP directory API, please join our Discord server