Skip to main content
Glama
al_language_server.py41.8 kB
"""AL Language Server implementation for Microsoft Dynamics 365 Business Central.""" import logging import os import pathlib import platform import stat import time import zipfile from pathlib import Path import requests from overrides import override from solidlsp.language_servers.common import quote_windows_path from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.ls_types import SymbolKind, UnifiedSymbolInformation from solidlsp.lsp_protocol_handler.lsp_types import Definition, DefinitionParams, LocationLink from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) class ALLanguageServer(SolidLanguageServer): """ Language server implementation for AL (Microsoft Dynamics 365 Business Central). This implementation uses the AL Language Server from the VS Code AL extension (ms-dynamics-smb.al). The extension must be installed or available locally. Key Features: - Automatic download of AL extension from VS Code marketplace if not present - Platform-specific executable detection (Windows/Linux/macOS) - Special initialization sequence required by AL Language Server - Custom AL-specific LSP commands (al/gotodefinition, al/setActiveWorkspace) - File opening requirement before symbol retrieval """ def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): """ Initialize the AL Language Server. Args: config: Language server configuration logger: Logger instance for debugging repository_root_path: Root path of the AL project (must contain app.json) solidlsp_settings: Solid LSP settings Note: The initialization process will automatically: 1. Check for AL extension in the resources directory 2. Download it from VS Code marketplace if not found 3. Extract and configure the platform-specific executable """ # Setup runtime dependencies and get the language server command # This will download the AL extension if needed cmd = self._setup_runtime_dependencies(config, solidlsp_settings) self._project_load_check_supported: bool = True """Whether the AL server supports the project load status check request. Some AL server versions don't support the 'al/hasProjectClosureLoadedRequest' custom LSP request. This flag starts as True and is set to False if the request fails, preventing repeated unsuccessful attempts. """ super().__init__(config, repository_root_path, ProcessLaunchInfo(cmd=cmd, cwd=repository_root_path), "al", solidlsp_settings) @classmethod def _download_al_extension(cls, url: str, target_dir: str) -> bool: """ Download and extract the AL 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 binaries. Args: logger: Logger for tracking download progress url: VS Code marketplace URL for the AL extension target_dir: Directory where the extension will be extracted Returns: True if successful, False otherwise Note: The download includes progress tracking and proper user-agent headers to ensure compatibility with the VS Code marketplace. """ try: log.info(f"Downloading AL 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 # These headers are required for the marketplace to serve the VSIX file 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 (will be deleted after extraction) temp_file = os.path.join(target_dir, "al_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: # Log progress every 10MB progress = (downloaded / total_size) * 100 log.info(f"Download progress: {progress:.1f}%") log.info("Download complete, extracting...") # Extract VSIX file (VSIX files are just ZIP archives with a different extension) # This will extract the extension folder containing the language server binaries with zipfile.ZipFile(temp_file, "r") as zip_ref: zip_ref.extractall(target_dir) # Clean up temp file os.remove(temp_file) log.info("AL extension extracted successfully") return True except Exception as e: log.error(f"Error downloading/extracting AL extension: {e}") return False @classmethod def _setup_runtime_dependencies(cls, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings) -> str: """ Setup runtime dependencies for AL Language Server and return the command to start the server. This method handles the complete setup process: 1. Checks for existing AL extension installations 2. Downloads from VS Code marketplace if not found 3. Configures executable permissions on Unix systems 4. Returns the properly formatted command string The AL Language Server executable is located in different paths based on the platform: - Windows: bin/win32/Microsoft.Dynamics.Nav.EditorServices.Host.exe - Linux: bin/linux/Microsoft.Dynamics.Nav.EditorServices.Host - macOS: bin/darwin/Microsoft.Dynamics.Nav.EditorServices.Host """ system = platform.system() # Find existing extension or download if needed extension_path = cls._find_al_extension(solidlsp_settings) if extension_path is None: log.info("AL extension not found on disk, attempting to download...") extension_path = cls._download_and_install_al_extension(solidlsp_settings) if extension_path is None: raise RuntimeError( "Failed to locate or download AL Language Server. Please either:\n" "1. Set AL_EXTENSION_PATH environment variable to the AL extension directory\n" "2. Install the AL extension in VS Code (ms-dynamics-smb.al)\n" "3. Ensure internet connection for automatic download" ) # Build executable path based on platform executable_path = cls._get_executable_path(extension_path, system) if not os.path.exists(executable_path): raise RuntimeError(f"AL Language Server executable not found at: {executable_path}") # Prepare and return the executable command return cls._prepare_executable(executable_path, system) @classmethod def _find_al_extension(cls, solidlsp_settings: SolidLSPSettings) -> str | None: """ Find AL extension in various locations. Search order: 1. Environment variable (AL_EXTENSION_PATH) 2. Default download location (~/.serena/ls_resources/al-extension) 3. VS Code installed extensions Returns: Path to AL extension directory or None if not found """ # Check environment variable env_path = os.environ.get("AL_EXTENSION_PATH") if env_path and os.path.exists(env_path): log.debug(f"Found AL extension via AL_EXTENSION_PATH: {env_path}") return env_path elif env_path: log.warning(f"AL_EXTENSION_PATH set but directory not found: {env_path}") # Check default download location default_path = os.path.join(cls.ls_resources_dir(solidlsp_settings), "al-extension", "extension") if os.path.exists(default_path): log.debug(f"Found AL extension in default location: {default_path}") return default_path # Search VS Code extensions vscode_path = cls._find_al_extension_in_vscode() if vscode_path: log.debug(f"Found AL extension in VS Code: {vscode_path}") return vscode_path log.debug("AL extension not found in any known location") return None @classmethod def _download_and_install_al_extension(cls, solidlsp_settings: SolidLSPSettings) -> str | None: """ Download and install AL extension from VS Code marketplace. Returns: Path to installed extension or None if download failed """ al_extension_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), "al-extension") # AL extension version - using latest stable version AL_VERSION = "latest" url = f"https://marketplace.visualstudio.com/_apis/public/gallery/publishers/ms-dynamics-smb/vsextensions/al/{AL_VERSION}/vspackage" log.info(f"Downloading AL extension from: {url}") if cls._download_al_extension(url, al_extension_dir): extension_path = os.path.join(al_extension_dir, "extension") if os.path.exists(extension_path): log.info("AL 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 AL extension from marketplace") return None @classmethod def _get_executable_path(cls, extension_path: str, system: str) -> str: """ Build platform-specific executable path. Args: extension_path: Path to AL extension directory system: Operating system name Returns: Full path to executable """ if system == "Windows": return os.path.join(extension_path, "bin", "win32", "Microsoft.Dynamics.Nav.EditorServices.Host.exe") elif system == "Linux": return os.path.join(extension_path, "bin", "linux", "Microsoft.Dynamics.Nav.EditorServices.Host") elif system == "Darwin": return os.path.join(extension_path, "bin", "darwin", "Microsoft.Dynamics.Nav.EditorServices.Host") else: raise RuntimeError(f"Unsupported platform: {system}") @classmethod def _prepare_executable(cls, executable_path: str, system: str) -> str: """ Prepare the executable by setting permissions and handling path quoting. Args: executable_path: Path to the executable system: Operating system name logger: Logger instance Returns: Properly formatted command string """ # Make sure executable has proper permissions on Unix-like systems if system in ["Linux", "Darwin"]: st = os.stat(executable_path) os.chmod(executable_path, st.st_mode | stat.S_IEXEC) log.debug(f"Set execute permission on: {executable_path}") log.info(f"Using AL Language Server executable: {executable_path}") # The AL Language Server uses stdio for LSP communication by default # Use the utility function to handle Windows path quoting return quote_windows_path(executable_path) @classmethod def _get_language_server_command_fallback(cls) -> str: """ Get the command to start the AL language server. Returns: Command string to launch the AL language server Raises: RuntimeError: If AL extension cannot be found """ # Check if AL extension path is configured via environment variable al_extension_path = os.environ.get("AL_EXTENSION_PATH") if not al_extension_path: # Try to find the extension in the current working directory # (for development/testing when extension is in the serena repo) cwd_path = Path.cwd() potential_extension = None # Look for ms-dynamics-smb.al-* directories for item in cwd_path.iterdir(): if item.is_dir() and item.name.startswith("ms-dynamics-smb.al-"): potential_extension = item break if potential_extension: al_extension_path = str(potential_extension) log.debug(f"Found AL extension in current directory: {al_extension_path}") else: # Try to find in common VS Code extension locations al_extension_path = cls._find_al_extension_in_vscode() if not al_extension_path: raise RuntimeError( "AL Language Server not found. Please either:\n" "1. Set AL_EXTENSION_PATH environment variable to the VS Code AL extension directory\n" "2. Install the AL extension in VS Code (ms-dynamics-smb.al)\n" "3. Place the extension directory in the current working directory" ) # Determine platform-specific executable system = platform.system() if system == "Windows": executable = os.path.join(al_extension_path, "bin", "win32", "Microsoft.Dynamics.Nav.EditorServices.Host.exe") elif system == "Linux": executable = os.path.join(al_extension_path, "bin", "linux", "Microsoft.Dynamics.Nav.EditorServices.Host") elif system == "Darwin": executable = os.path.join(al_extension_path, "bin", "darwin", "Microsoft.Dynamics.Nav.EditorServices.Host") else: raise RuntimeError(f"Unsupported platform: {system}") # Verify executable exists if not os.path.exists(executable): raise RuntimeError( f"AL Language Server executable not found at: {executable}\nPlease ensure the AL extension is properly installed." ) # Make sure executable has proper permissions on Unix-like systems if system in ["Linux", "Darwin"]: st = os.stat(executable) os.chmod(executable, st.st_mode | stat.S_IEXEC) log.info(f"Using AL Language Server executable: {executable}") # The AL Language Server uses stdio for LSP communication (no --stdio flag needed) # Use the utility function to handle Windows path quoting return quote_windows_path(executable) @classmethod def _find_al_extension_in_vscode(cls) -> str | None: """ Try to find AL extension in common VS Code extension locations. Returns: Path to AL extension directory or None if not found """ home = Path.home() possible_paths = [] # Common VS Code extension paths if platform.system() == "Windows": possible_paths.extend( [ home / ".vscode" / "extensions", home / ".vscode-insiders" / "extensions", Path(os.environ.get("APPDATA", "")) / "Code" / "User" / "extensions", Path(os.environ.get("APPDATA", "")) / "Code - Insiders" / "User" / "extensions", ] ) else: possible_paths.extend( [ home / ".vscode" / "extensions", home / ".vscode-server" / "extensions", home / ".vscode-insiders" / "extensions", ] ) for base_path in possible_paths: if base_path.exists(): log.debug(f"Searching for AL extension in: {base_path}") # Look for AL extension directories for item in base_path.iterdir(): if item.is_dir() and item.name.startswith("ms-dynamics-smb.al-"): log.debug(f"Found AL extension at: {item}") return str(item) return None @staticmethod def _get_initialize_params(repository_absolute_path: str) -> dict: """ Returns the initialize params for the AL Language Server. """ # Ensure we have an absolute path for URI generation repository_path = pathlib.Path(repository_absolute_path).resolve() root_uri = repository_path.as_uri() # AL requires extensive capabilities based on VS Code trace initialize_params = { "processId": os.getpid(), "rootPath": str(repository_path), "rootUri": root_uri, "capabilities": { "workspace": { "applyEdit": True, "workspaceEdit": { "documentChanges": True, "resourceOperations": ["create", "rename", "delete"], "failureHandling": "textOnlyTransactional", "normalizesLineEndings": True, }, "configuration": True, "didChangeWatchedFiles": {"dynamicRegistration": True}, "symbol": {"dynamicRegistration": True, "symbolKind": {"valueSet": list(range(1, 27))}}, "executeCommand": {"dynamicRegistration": True}, "didChangeConfiguration": {"dynamicRegistration": True}, "workspaceFolders": True, }, "textDocument": { "synchronization": {"dynamicRegistration": True, "willSave": True, "willSaveWaitUntil": True, "didSave": True}, "completion": { "dynamicRegistration": True, "contextSupport": True, "completionItem": { "snippetSupport": True, "commitCharactersSupport": True, "documentationFormat": ["markdown", "plaintext"], "deprecatedSupport": True, "preselectSupport": True, }, }, "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, "definition": {"dynamicRegistration": True, "linkSupport": True}, "references": {"dynamicRegistration": True}, "documentHighlight": {"dynamicRegistration": True}, "documentSymbol": { "dynamicRegistration": True, "symbolKind": {"valueSet": list(range(1, 27))}, "hierarchicalDocumentSymbolSupport": True, }, "codeAction": {"dynamicRegistration": True}, "formatting": {"dynamicRegistration": True}, "rangeFormatting": {"dynamicRegistration": True}, "rename": {"dynamicRegistration": True, "prepareSupport": True}, }, "window": { "showMessage": {"messageActionItem": {"additionalPropertiesSupport": True}}, "showDocument": {"support": True}, "workDoneProgress": True, }, }, "trace": "verbose", "workspaceFolders": [{"uri": root_uri, "name": repository_path.name}], } return initialize_params @override def _start_server(self) -> None: """ Starts the AL Language Server process and initializes it. This method sets up custom notification handlers for AL-specific messages before starting the server. The AL server sends various notifications during initialization and project loading that need to be handled. """ # Set up event handlers def do_nothing(params: str) -> None: return def window_log_message(msg: dict) -> None: log.info(f"AL LSP: window/logMessage: {msg}") def publish_diagnostics(params: dict) -> None: # AL server publishes diagnostics during initialization uri = params.get("uri", "") diagnostics = params.get("diagnostics", []) log.debug(f"AL LSP: Diagnostics for {uri}: {len(diagnostics)} issues") def handle_al_notifications(params: dict) -> None: # AL server sends custom notifications during project loading log.debug("AL LSP: Notification received") # Register handlers for AL-specific notifications # These notifications are sent by the AL server during initialization and operation self.server.on_notification("window/logMessage", window_log_message) # Server log messages self.server.on_notification("textDocument/publishDiagnostics", publish_diagnostics) # Compilation diagnostics self.server.on_notification("$/progress", do_nothing) # Progress notifications during loading self.server.on_notification("al/refreshExplorerObjects", handle_al_notifications) # AL-specific object updates # Start the server process log.info("Starting AL Language Server process") self.server.start() # Send initialize request initialize_params = self._get_initialize_params(self.repository_root_path) log.info("Sending initialize request from LSP client to AL LSP server and awaiting response") # Send initialize and wait for response resp = self.server.send_request("initialize", initialize_params) if resp is None: raise RuntimeError("AL Language Server initialization failed - no response") log.info("AL Language Server initialized successfully") # Send initialized notification self.server.send_notification("initialized", {}) log.info("Sent initialized notification") @override def start(self) -> "ALLanguageServer": """ Start the AL Language Server with special initialization. """ # Call parent start method super().start() # AL-specific post-initialization self._post_initialize_al_workspace() # Note: set_active_workspace() can be called manually if needed for multi-workspace scenarios # We don't call it automatically to avoid issues during single-workspace initialization return self def _post_initialize_al_workspace(self) -> None: """ Post-initialization setup for AL Language Server. The AL server requires additional setup after initialization: 1. Send workspace configuration - provides AL settings and paths 2. Open app.json to trigger project loading - AL uses app.json to identify project structure 3. Optionally wait for project to be loaded if supported This special initialization sequence is unique to AL and necessary for proper symbol resolution and navigation features. """ # No sleep needed - server is already initialized # Send workspace configuration first # This tells AL about assembly paths, package caches, and code analysis settings try: self.server.send_notification( "workspace/didChangeConfiguration", { "settings": { "workspacePath": self.repository_root_path, "alResourceConfigurationSettings": { "assemblyProbingPaths": ["./.netpackages"], "codeAnalyzers": [], "enableCodeAnalysis": False, "backgroundCodeAnalysis": "Project", "packageCachePaths": ["./.alpackages"], "ruleSetPath": None, "enableCodeActions": True, "incrementalBuild": False, "outputAnalyzerStatistics": True, "enableExternalRulesets": True, }, "setActiveWorkspace": True, "expectedProjectReferenceDefinitions": [], "activeWorkspaceClosure": [self.repository_root_path], } }, ) log.debug("Sent workspace configuration") except Exception as e: log.warning(f"Failed to send workspace config: {e}") # Check if app.json exists and open it # app.json is the AL project manifest file (similar to package.json for Node.js) # Opening it triggers AL to load the project and index all AL files app_json_path = Path(self.repository_root_path) / "app.json" if app_json_path.exists(): try: with open(app_json_path, encoding="utf-8") as f: app_json_content = f.read() # Use forward slashes for URI app_json_uri = app_json_path.as_uri() # Send textDocument/didOpen for app.json self.server.send_notification( "textDocument/didOpen", {"textDocument": {"uri": app_json_uri, "languageId": "json", "version": 1, "text": app_json_content}}, ) log.debug(f"Opened app.json: {app_json_uri}") except Exception as e: log.warning(f"Failed to open app.json: {e}") # Try to set active workspace (AL-specific custom LSP request) # This is optional and may not be supported by all AL server versions workspace_uri = Path(self.repository_root_path).resolve().as_uri() try: result = self.server.send_request( "al/setActiveWorkspace", { "currentWorkspaceFolderPath": {"uri": workspace_uri, "name": Path(self.repository_root_path).name, "index": 0}, "settings": { "workspacePath": self.repository_root_path, "setActiveWorkspace": True, }, "timeout": 2, # Quick timeout since this is optional }, ) log.debug(f"Set active workspace result: {result}") except Exception as e: # This is a custom AL request, not critical if it fails log.debug(f"Failed to set active workspace (non-critical): {e}") # Check if project supports load status check (optional) # Many AL server versions don't support this, so we use a short timeout # and continue regardless of the result self._wait_for_project_load(timeout=3) @override def is_ignored_dirname(self, dirname: str) -> bool: """ Define AL-specific directories to ignore during file scanning. These directories contain generated files, dependencies, or cache data that should not be analyzed for symbols. Args: dirname: Directory name to check Returns: True if directory should be ignored """ al_ignore_dirs = { ".alpackages", # AL package cache - downloaded dependencies ".alcache", # AL compiler cache - intermediate compilation files ".altemplates", # AL templates - code generation templates ".snapshots", # Test snapshots - test result snapshots "out", # Compiled output - generated .app files ".vscode", # VS Code settings - editor configuration "Reference", # Reference assemblies - .NET dependencies ".netpackages", # .NET packages - NuGet packages for AL "bin", # Binary output - compiled binaries "obj", # Object files - intermediate build artifacts } # Check parent class ignore list first, then AL-specific return super().is_ignored_dirname(dirname) or dirname in al_ignore_dirs @override def request_full_symbol_tree(self, within_relative_path: str | None = None) -> list[UnifiedSymbolInformation]: """ Override to handle AL's requirement of opening files before requesting symbols. The AL Language Server requires files to be explicitly opened via textDocument/didOpen before it can provide meaningful symbols. Without this, it only returns directory symbols. This is different from most language servers which can provide symbols for unopened files. This method: 1. Scans the repository for all AL files (.al and .dal extensions) 2. Opens each file with the AL server 3. Requests symbols for each file 4. Combines all symbols into a hierarchical tree structure 5. Closes the files to free resources Args: within_relative_path: Restrict search to this file or directory path include_body: Whether to include symbol body content Returns: Full symbol tree with all AL symbols from opened files organized by directory """ log.debug("AL: Starting request_full_symbol_tree with file opening") # Determine the root path for scanning if within_relative_path is not None: within_abs_path = os.path.join(self.repository_root_path, within_relative_path) if not os.path.exists(within_abs_path): raise FileNotFoundError(f"File or directory not found: {within_abs_path}") if os.path.isfile(within_abs_path): # Single file case - use parent class implementation root_nodes = self.request_document_symbols(within_relative_path).root_symbols return root_nodes # Directory case - scan within this directory scan_root = Path(within_abs_path) else: # Scan entire repository scan_root = Path(self.repository_root_path) # For AL, we always need to open files to get symbols al_files = [] # Walk through the repository to find all AL files for root, dirs, files in os.walk(scan_root): # Skip ignored directories dirs[:] = [d for d in dirs if not self.is_ignored_dirname(d)] # Find AL files for file in files: if file.endswith((".al", ".dal")): file_path = Path(root) / file # Use forward slashes for consistent paths try: relative_path = str(file_path.relative_to(self.repository_root_path)).replace("\\", "/") al_files.append((file_path, relative_path)) except ValueError: # File is outside repository root, skip it continue log.debug(f"AL: Found {len(al_files)} AL files") if not al_files: log.warning("AL: No AL files found in repository") return [] # Collect all symbols from all files all_file_symbols: list[UnifiedSymbolInformation] = [] file_symbol: UnifiedSymbolInformation for file_path, relative_path in al_files: try: # Use our overridden request_document_symbols which handles opening log.debug(f"AL: Getting symbols for {relative_path}") all_syms, root_syms = self.request_document_symbols(relative_path).get_all_symbols_and_roots() if root_syms: # Create a file-level symbol containing the document symbols file_symbol = { "name": file_path.stem, # Just the filename without extension "kind": SymbolKind.File, "children": root_syms, "location": { "uri": file_path.as_uri(), "relativePath": relative_path, "absolutePath": str(file_path), "range": {"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 0}}, }, } all_file_symbols.append(file_symbol) log.debug(f"AL: Added {len(root_syms)} symbols from {relative_path}") elif all_syms: # If we only got all_syms but not root, use all_syms file_symbol = { "name": file_path.stem, "kind": SymbolKind.File, "children": all_syms, "location": { "uri": file_path.as_uri(), "relativePath": relative_path, "absolutePath": str(file_path), "range": {"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 0}}, }, } all_file_symbols.append(file_symbol) log.debug(f"AL: Added {len(all_syms)} symbols from {relative_path}") except Exception as e: log.warning(f"AL: Failed to get symbols for {relative_path}: {e}") if all_file_symbols: log.debug(f"AL: Returning symbols from {len(all_file_symbols)} files") # Group files by directory directory_structure: dict[str, list] = {} for file_symbol in all_file_symbols: rel_path = file_symbol["location"]["relativePath"] assert rel_path is not None path_parts = rel_path.split("/") if len(path_parts) > 1: # File is in a subdirectory dir_path = "/".join(path_parts[:-1]) if dir_path not in directory_structure: directory_structure[dir_path] = [] directory_structure[dir_path].append(file_symbol) else: # File is in root if "." not in directory_structure: directory_structure["."] = [] directory_structure["."].append(file_symbol) # Build hierarchical structure result = [] repo_path = Path(self.repository_root_path) for dir_path, file_symbols in directory_structure.items(): if dir_path == ".": # Root level files result.extend(file_symbols) else: # Create directory symbol dir_symbol = { "name": Path(dir_path).name, "kind": SymbolKind.Package, # Package/Directory "children": file_symbols, "location": { "relativePath": dir_path, "absolutePath": str(repo_path / dir_path), "range": {"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 0}}, }, } result.append(dir_symbol) return result else: log.warning("AL: No symbols found in any files") return [] # ===== Phase 1: Custom AL Command Implementations ===== @override def _send_definition_request(self, definition_params: DefinitionParams) -> Definition | list[LocationLink] | None: """ Override to use AL's custom gotodefinition command. AL Language Server uses 'al/gotodefinition' instead of the standard 'textDocument/definition' request. This custom command provides better navigation for AL-specific constructs like table extensions, page extensions, and codeunit references. If the custom command fails, we fall back to the standard LSP method. """ # Convert standard params to AL format (same structure, different method) al_params = {"textDocument": definition_params["textDocument"], "position": definition_params["position"]} try: # Use custom AL command instead of standard LSP response = self.server.send_request("al/gotodefinition", al_params) log.debug(f"AL gotodefinition response: {response}") return response # type: ignore[return-value] except Exception as e: log.warning(f"Failed to use al/gotodefinition, falling back to standard: {e}") # Fallback to standard LSP method if custom command fails return super()._send_definition_request(definition_params) def check_project_loaded(self) -> bool: """ Check if AL project closure is fully loaded. Uses AL's custom 'al/hasProjectClosureLoadedRequest' to determine if the project and all its dependencies have been fully loaded and indexed. This is important because AL operations may fail or return incomplete results if the project is still loading. Returns: bool: True if project is loaded, False otherwise """ if not hasattr(self, "server") or not self.server_started: log.debug("Cannot check project load - server not started") return False # Check if we've already determined this request isn't supported if not self._project_load_check_supported: return True # Assume loaded if check isn't supported try: # Use a very short timeout since this is just a status check response = self.server.send_request("al/hasProjectClosureLoadedRequest", {"timeout": 1}) # Response can be boolean directly, dict with 'loaded' field, or None if isinstance(response, bool): return response elif isinstance(response, dict): return response.get("loaded", False) elif response is None: # None typically means the project is still loading log.debug("Project load check returned None") return False else: log.debug(f"Unexpected response type for project load check: {type(response)}") return False except Exception as e: # Mark as unsupported to avoid repeated failed attempts self._project_load_check_supported = False log.debug(f"Project load check not supported by this AL server version: {e}") # Assume loaded if we can't check return True def _wait_for_project_load(self, timeout: int = 3) -> bool: """ Wait for project to be fully loaded. Polls the AL server to check if the project is loaded. This is optional as not all AL server versions support this check. We use a short timeout and continue regardless of the result. Args: timeout: Maximum time to wait in seconds (default 3s) Returns: bool: True if project loaded within timeout, False otherwise """ start_time = time.time() log.debug(f"Checking AL project load status (timeout: {timeout}s)...") while time.time() - start_time < timeout: if self.check_project_loaded(): elapsed = time.time() - start_time log.info(f"AL project fully loaded after {elapsed:.1f}s") return True time.sleep(0.5) log.debug(f"Project load check timed out after {timeout}s (non-critical)") return False def set_active_workspace(self, workspace_uri: str | None = None) -> None: """ Set the active AL workspace. This is important when multiple workspaces exist to ensure operations target the correct workspace. The AL server can handle multiple projects simultaneously, but only one can be "active" at a time for operations like symbol search and navigation. This uses the custom 'al/setActiveWorkspace' LSP command. Args: workspace_uri: URI of workspace to set as active, or None to use repository root """ if not hasattr(self, "server") or not self.server_started: log.debug("Cannot set active workspace - server not started") return if workspace_uri is None: workspace_uri = Path(self.repository_root_path).resolve().as_uri() params = {"workspaceUri": workspace_uri} try: self.server.send_request("al/setActiveWorkspace", params) log.info(f"Set active workspace to: {workspace_uri}") except Exception as e: log.warning(f"Failed to set active workspace: {e}") # Non-critical error, continue operation

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