feat: implement AL Language Server runtime dependency setup and improve path quoting for Windows, corrected md's

This commit is contained in:
Torben Leth committed 2025-09-04 23:43:33 +02:00
1 parent 6a21dec0e9
commit dadc1fe8ed
5 files changed
+617 -513

No files matched your search

+34 -36
View File
@@ -96,7 +96,7 @@ With Serena, we provide direct, out-of-the-box support for:
* Nix (requires nixd installation)
* Elixir (requires installation of NextLS and Elixir; **Windows not supported**)
* Erlang (requires installation of beam and [erlang_ls](https://github.com/erlang-ls/erlang_ls), experimental, might be slow or hang)
* AL (requires VS Code AL extension for Business Central development; set `AL_EXTENSION_PATH` environment variable or install ms-dynamics-smb.al extension)
* AL
Support for further languages can easily be added by providing a shallow adapter for a new language server implementation,
see Serena's [memory on that](.serena/memories/adding_new_language_support_guide.md).
@@ -129,46 +129,44 @@ Several videos and blog posts have talked about Serena:
<!-- toc -->
- [Table of Contents](#table-of-contents)
- [Quick Start](#quick-start)
- [Running the Serena MCP Server](#running-the-serena-mcp-server)
- [Usage](#usage)
* [Running the Serena MCP Server](#running-the-serena-mcp-server)
+ [Usage](#usage)
- [Using uvx](#using-uvx)
- [Local Installation](#local-installation)
* [Local Installation](#local-installation)
- [Using Docker (Experimental)](#using-docker-experimental)
- [Using Nix](#using-nix)
- [SSE Mode](#sse-mode)
- [Command-Line Arguments](#command-line-arguments)
- [Configuration](#configuration)
- [Project Activation \& Indexing](#project-activation--indexing)
- [Claude Code](#claude-code)
- [Codex](#codex)
- [Other Terminal-Based Clients](#other-terminal-based-clients)
- [Claude Desktop](#claude-desktop)
- [MCP Coding Clients (Cline, Roo-Code, Cursor, Windsurf, etc.)](#mcp-coding-clients-cline-roo-code-cursor-windsurf-etc)
- [Local GUIs and Frameworks](#local-guis-and-frameworks)
+ [SSE Mode](#sse-mode)
+ [Command-Line Arguments](#command-line-arguments)
* [Configuration](#configuration)
* [Project Activation & Indexing](#project-activation--indexing)
* [Claude Code](#claude-code)
* [Codex](#codex)
* [Other Terminal-Based Clients](#other-terminal-based-clients)
* [Claude Desktop](#claude-desktop)
* [MCP Coding Clients (Cline, Roo-Code, Cursor, Windsurf, etc.)](#mcp-coding-clients-cline-roo-code-cursor-windsurf-etc)
* [Local GUIs and Frameworks](#local-guis-and-frameworks)
- [Detailed Usage and Recommendations](#detailed-usage-and-recommendations)
- [Tool Execution](#tool-execution)
- [Shell Execution and Editing Tools](#shell-execution-and-editing-tools)
- [Modes and Contexts](#modes-and-contexts)
- [Contexts](#contexts)
- [Modes](#modes)
- [Customization](#customization)
- [Onboarding and Memories](#onboarding-and-memories)
- [Prepare Your Project](#prepare-your-project)
- [Structure Your Codebase](#structure-your-codebase)
- [Start from a Clean State](#start-from-a-clean-state)
- [Logging, Linting, and Automated Tests](#logging-linting-and-automated-tests)
- [Prompting Strategies](#prompting-strategies)
- [Potential Issues in Code Editing](#potential-issues-in-code-editing)
- [Running Out of Context](#running-out-of-context)
- [Combining Serena with Other MCP Servers](#combining-serena-with-other-mcp-servers)
- [Serena's Logs: The Dashboard and GUI Tool](#serenas-logs-the-dashboard-and-gui-tool)
- [Troubleshooting](#troubleshooting)
* [Tool Execution](#tool-execution)
+ [Shell Execution and Editing Tools](#shell-execution-and-editing-tools)
* [Modes and Contexts](#modes-and-contexts)
+ [Contexts](#contexts)
+ [Modes](#modes)
+ [Customization](#customization)
* [Onboarding and Memories](#onboarding-and-memories)
* [Prepare Your Project](#prepare-your-project)
+ [Structure Your Codebase](#structure-your-codebase)
+ [Start from a Clean State](#start-from-a-clean-state)
+ [Logging, Linting, and Automated Tests](#logging-linting-and-automated-tests)
* [Prompting Strategies](#prompting-strategies)
* [Potential Issues in Code Editing](#potential-issues-in-code-editing)
* [Running Out of Context](#running-out-of-context)
* [Combining Serena with Other MCP Servers](#combining-serena-with-other-mcp-servers)
* [Serena's Logs: The Dashboard and GUI Tool](#serenas-logs-the-dashboard-and-gui-tool)
* [Troubleshooting](#troubleshooting)
- [Comparison with Other Coding Agents](#comparison-with-other-coding-agents)
- [Subscription-Based Coding Agents](#subscription-based-coding-agents)
- [API-Based Coding Agents](#api-based-coding-agents)
- [Other MCP-Based Coding Agents](#other-mcp-based-coding-agents)
* [Subscription-Based Coding Agents](#subscription-based-coding-agents)
* [API-Based Coding Agents](#api-based-coding-agents)
* [Other MCP-Based Coding Agents](#other-mcp-based-coding-agents)
- [Acknowledgements](#acknowledgements)
- [Customizing and Extending Serena](#customizing-and-extending-serena)
- [List of Tools](#list-of-tools)
+1 -5
View File
@@ -63,6 +63,7 @@ dev = [
"toml-sort>=0.24.2",
"types-pyyaml>=6.0.12.20241230",
"syrupy>=4.9.1",
"types-requests>=2.32.4.20241230",
]
agno = ["agno>=1.2.6", "sqlalchemy>=2.0.40"]
google = ["google-genai>=1.8.0"]
@@ -278,10 +279,5 @@ markers = [
# Ref: https://github.com/codespell-project/codespell#using-a-config-file
skip = '.git*,*.svg,*.lock,*.min.*'
check-hidden = true
[dependency-groups]
dev = [
"types-requests>=2.32.4.20250809",
]
# ignore-regex = ''
# ignore-words-list = ''
@@ -2,13 +2,17 @@
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_logger import LanguageServerLogger
@@ -142,73 +146,154 @@ class ALLanguageServer(SolidLanguageServer):
Setup runtime dependencies for AL Language Server and return the command to start the server.
This method handles the complete setup process:
1. Determines the correct platform-specific executable path
2. Checks if the AL extension is already installed
3. Downloads the extension from VS Code marketplace if needed
4. Configures executable permissions on Unix systems
5. Returns the properly formatted command string
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: extension/bin/win32/Microsoft.Dynamics.Nav.EditorServices.Host.exe
- Linux: extension/bin/linux/Microsoft.Dynamics.Nav.EditorServices.Host
- macOS: extension/bin/darwin/Microsoft.Dynamics.Nav.EditorServices.Host
- 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(logger, solidlsp_settings)
if extension_path is None:
logger.log("AL extension not found on disk, attempting to download...", logging.INFO)
extension_path = cls._download_and_install_al_extension(logger, 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, logger)
@classmethod
def _find_al_extension(cls, logger: LanguageServerLogger, 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):
logger.log(f"Found AL extension via AL_EXTENSION_PATH: {env_path}", level=5)
return env_path
elif env_path:
logger.log(f"AL_EXTENSION_PATH set but directory not found: {env_path}", logging.WARNING)
# Check default download location
default_path = os.path.join(cls.ls_resources_dir(solidlsp_settings), "al-extension", "extension")
if os.path.exists(default_path):
logger.log(f"Found AL extension in default location: {default_path}", level=5)
return default_path
# Search VS Code extensions
vscode_path = cls._find_al_extension_in_vscode(logger)
if vscode_path:
logger.log(f"Found AL extension in VS Code: {vscode_path}", level=5)
return vscode_path
logger.log("AL extension not found in any known location", level=10)
return None
@classmethod
def _download_and_install_al_extension(cls, logger: LanguageServerLogger, solidlsp_settings: SolidLSPSettings) -> str | None:
"""
Download and install AL extension from VS Code marketplace.
Returns:
Path to installed extension or None if download failed
"""
# Directory where the AL extension will be installed
# This is typically ~/.serena/ls_resources/al-extension
al_extension_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), "al-extension")
# Determine platform-specific executable path within the extension
# AL provides different binaries for each platform
system = platform.system()
# 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"
logger.log(f"Downloading AL extension from: {url}", logging.INFO)
if cls._download_al_extension(logger, url, al_extension_dir):
extension_path = os.path.join(al_extension_dir, "extension")
if os.path.exists(extension_path):
logger.log("AL extension downloaded and installed successfully", logging.INFO)
return extension_path
else:
logger.log(f"Download completed but extension not found at: {extension_path}", logging.ERROR)
else:
logger.log("Failed to download AL extension from marketplace", logging.ERROR)
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":
executable_relative_path = os.path.join("extension", "bin", "win32", "Microsoft.Dynamics.Nav.EditorServices.Host.exe")
return os.path.join(extension_path, "bin", "win32", "Microsoft.Dynamics.Nav.EditorServices.Host.exe")
elif system == "Linux":
executable_relative_path = os.path.join("extension", "bin", "linux", "Microsoft.Dynamics.Nav.EditorServices.Host")
return os.path.join(extension_path, "bin", "linux", "Microsoft.Dynamics.Nav.EditorServices.Host")
elif system == "Darwin":
executable_relative_path = os.path.join("extension", "bin", "darwin", "Microsoft.Dynamics.Nav.EditorServices.Host")
return os.path.join(extension_path, "bin", "darwin", "Microsoft.Dynamics.Nav.EditorServices.Host")
else:
raise RuntimeError(f"Unsupported platform: {system}")
executable_path = os.path.join(al_extension_dir, executable_relative_path)
@classmethod
def _prepare_executable(cls, executable_path: str, system: str, logger: LanguageServerLogger) -> str:
"""
Prepare the executable by setting permissions and handling path quoting.
# Check if the extension is already installed
if not os.path.exists(executable_path):
logger.log(f"AL Language Server not found at {executable_path}. Downloading from VS Code marketplace...", logging.INFO)
Args:
executable_path: Path to the executable
system: Operating system name
logger: Logger instance
# 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"
# Try to download the extension
if cls._download_al_extension(logger, url, al_extension_dir):
logger.log("AL Extension downloaded successfully.", logging.INFO)
else:
logger.log("Failed to download AL extension, falling back to manual search.", logging.WARNING)
# Fall back to the old method of finding the extension
return cls._get_language_server_command_fallback(logger)
# Verify executable exists after download
if not os.path.exists(executable_path):
logger.log("AL Language Server executable still not found after download. Falling back to manual search.", logging.WARNING)
return cls._get_language_server_command_fallback(logger)
Returns:
Properly formatted command string
"""
# Make sure executable has proper permissions on Unix-like systems
# This is crucial for Linux/macOS where downloaded files may not be executable
if system in ["Linux", "Darwin"]:
import stat
st = os.stat(executable_path)
os.chmod(executable_path, st.st_mode | stat.S_IEXEC)
logger.log(f"Set execute permission on: {executable_path}", level=10)
logger.log(f"Using AL Language Server executable: {executable_path}", level=5)
# The AL Language Server uses stdio for LSP communication by default
# Unlike many other language servers, it doesn't require a --stdio flag
# On Windows, we need to properly quote the path if it contains spaces to avoid shell issues
if system == "Windows" and " " in executable_path:
return f'"{executable_path}"'
else:
return executable_path
# Use the utility function to handle Windows path quoting
return quote_windows_path(executable_path)
@classmethod
def _get_language_server_command_fallback(cls, logger: LanguageServerLogger) -> str:
@@ -271,19 +356,14 @@ class ALLanguageServer(SolidLanguageServer):
# Make sure executable has proper permissions on Unix-like systems
if system in ["Linux", "Darwin"]:
import stat
st = os.stat(executable)
os.chmod(executable, st.st_mode | stat.S_IEXEC)
logger.log(f"Using AL Language Server executable: {executable}", level=5)
# The AL Language Server uses stdio for LSP communication (no --stdio flag needed)
# On Windows, we need to properly quote the path if it contains spaces
if system == "Windows" and " " in executable:
return f'"{executable}"'
else:
return executable
# Use the utility function to handle Windows path quoting
return quote_windows_path(executable)
@classmethod
def _find_al_extension_in_vscode(cls, logger: LanguageServerLogger) -> str | None:
@@ -332,9 +412,6 @@ class ALLanguageServer(SolidLanguageServer):
"""
Returns the initialize params for the AL Language Server.
"""
import os
import pathlib
# Ensure we have an absolute path for URI generation
repository_path = pathlib.Path(repository_absolute_path).resolve()
root_uri = repository_path.as_uri()
@@ -399,6 +476,7 @@ class ALLanguageServer(SolidLanguageServer):
return initialize_params
@override
def _start_server(self):
"""
Starts the AL Language Server process and initializes it.
@@ -407,7 +485,6 @@ class ALLanguageServer(SolidLanguageServer):
before starting the server. The AL server sends various notifications
during initialization and project loading that need to be handled.
"""
import logging
# Set up event handlers
def do_nothing(params):
@@ -456,6 +533,7 @@ class ALLanguageServer(SolidLanguageServer):
self.server.send_notification("initialized", {})
self.logger.log("Sent initialized notification", logging.INFO)
@override
def start(self) -> "ALLanguageServer":
"""
Start the AL Language Server with special initialization.
@@ -484,9 +562,6 @@ class ALLanguageServer(SolidLanguageServer):
This special initialization sequence is unique to AL and necessary for proper
symbol resolution and navigation features.
"""
import time
from pathlib import Path
# Give the server a moment to fully initialize
time.sleep(1)
@@ -589,6 +664,7 @@ class ALLanguageServer(SolidLanguageServer):
self.logger.log("Project loaded, waiting for symbol indexing", level=5)
time.sleep(2)
@override
def is_ignored_dirname(self, dirname: str) -> bool:
"""
Define AL-specific directories to ignore during file scanning.
@@ -619,6 +695,7 @@ class ALLanguageServer(SolidLanguageServer):
# 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, include_body: bool = False) -> list[dict]:
"""
Override to handle AL's requirement of opening files before requesting symbols.
@@ -642,9 +719,6 @@ class ALLanguageServer(SolidLanguageServer):
Full symbol tree with all AL symbols from opened files organized by directory
"""
import os
from pathlib import Path
self.logger.log("AL: Starting request_full_symbol_tree with file opening", level=5)
# Determine the root path for scanning
@@ -781,6 +855,7 @@ class ALLanguageServer(SolidLanguageServer):
self.logger.log("AL: No symbols found in any files", level=3)
return []
@override
def request_document_symbols(self, relative_file_path: str, include_body: bool = False) -> tuple[list[dict], list[dict]]:
"""
Override to handle AL's requirement of opening files before requesting symbols.
@@ -802,9 +877,6 @@ class ALLanguageServer(SolidLanguageServer):
Tuple of (all symbols including nested, root level symbols only)
"""
import pathlib
from pathlib import Path
self.logger.log(f"AL: Requesting document symbols for {relative_file_path}", level=5)
# Convert relative path to absolute, handling both forward and backslashes
@@ -851,7 +923,7 @@ class ALLanguageServer(SolidLanguageServer):
if isinstance(response, list):
# Response is a list of symbols
for symbol in response:
symbol_info = self._convert_lsp_symbol(symbol)
symbol_info = self._convert_lsp_symbol_to_solidlsp(symbol)
all_symbols.append(symbol_info)
root_symbols.append(symbol_info)
@@ -888,8 +960,8 @@ class ALLanguageServer(SolidLanguageServer):
self.logger.log(f"AL: Error in request_document_symbols: {e}", level=3)
return ([], [])
def _convert_lsp_symbol(self, lsp_symbol: dict) -> dict:
"""Convert LSP symbol format to Serena format."""
def _convert_lsp_symbol_to_solidlsp(self, lsp_symbol: dict) -> dict:
"""Convert standard LSP symbol format to SolidLSP internal format."""
# Extract basic info
name = lsp_symbol.get("name", "")
kind = lsp_symbol.get("kind", 0)
@@ -910,14 +982,14 @@ class ALLanguageServer(SolidLanguageServer):
# Add children if present
if lsp_symbol.get("children"):
symbol_info["children"] = [self._convert_lsp_symbol(child) for child in lsp_symbol["children"]]
symbol_info["children"] = [self._convert_lsp_symbol_to_solidlsp(child) for child in lsp_symbol["children"]]
return symbol_info
def _process_child_symbols(self, children: list, all_symbols: list) -> None:
"""Recursively process child symbols."""
for child in children:
child_info = self._convert_lsp_symbol(child)
child_info = self._convert_lsp_symbol_to_solidlsp(child)
all_symbols.append(child_info)
if "children" in child:
@@ -925,6 +997,7 @@ class ALLanguageServer(SolidLanguageServer):
# ===== 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.
+23
View File
@@ -112,3 +112,26 @@ class RuntimeDependencyCollection:
FileUtils.download_and_extract_archive(logger, dep.url, dest, dep.archive_type)
else:
FileUtils.download_and_extract_archive(logger, dep.url, target_dir, dep.archive_type or "zip")
def quote_windows_path(path: str) -> str:
"""
Quote a path for Windows command execution if needed.
On Windows, paths need to be quoted for proper command execution.
The function checks if the path is already quoted to avoid double-quoting.
On other platforms, the path is returned unchanged.
Args:
path: The file path to potentially quote
Returns:
The quoted path on Windows (if not already quoted), unchanged path on other platforms
"""
if platform.system() == "Windows":
# Check if already quoted to avoid double-quoting
if path.startswith('"') and path.endswith('"'):
return path
return f'"{path}"'
return path
Generated
+416 -402
View File
File diff suppressed because it is too large. Load diff