Add AL Language Server support for Business Central development

This commit is contained in:
Torben Leth committed 2025-09-02 20:09:14 +02:00
1 parent aa8c5c6ff6
commit 5c0110f923
14 files changed
+2158

No files matched your search

+1
View File
@@ -271,6 +271,7 @@ markers = [
"nix: language server running for Nix",
"dart: language server running for Dart",
"erlang: language server running for Erlang",
"al: language server running for AL (Microsoft Dynamics 365 Business Central)",
]
[tool.codespell]
@@ -0,0 +1,888 @@
"""AL Language Server implementation for Microsoft Dynamics 365 Business Central."""
import logging
import os
import platform
import time
from pathlib import Path
from typing import Any
from solidlsp.ls import SolidLanguageServer
from solidlsp.ls_config import LanguageServerConfig
from solidlsp.ls_logger import LanguageServerLogger
from solidlsp.ls_types import Location
from solidlsp.lsp_protocol_handler.lsp_types import Definition, DefinitionParams, LocationLink
from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo
from solidlsp.settings import SolidLSPSettings
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.
"""
def __init__(
self,
config: LanguageServerConfig,
logger: LanguageServerLogger,
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
solidlsp_settings: Solid LSP settings
"""
# Get the language server command
cmd = self._get_language_server_command(logger)
super().__init__(
config,
logger,
repository_root_path,
ProcessLaunchInfo(cmd=cmd, cwd=repository_root_path),
"al", # Language ID for LSP
solidlsp_settings,
)
def _get_language_server_command(self, logger: LanguageServerLogger) -> 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)
logger.log(f"Found AL extension in current directory: {al_extension_path}", level=5)
else:
# Try to find in common VS Code extension locations
al_extension_path = self._find_al_extension_in_vscode(logger)
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}\n"
f"Please ensure the AL extension is properly installed."
)
# 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
def _find_al_extension_in_vscode(self, logger: LanguageServerLogger) -> 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():
logger.log(f"Searching for AL extension in: {base_path}", level=10)
# Look for AL extension directories
for item in base_path.iterdir():
if item.is_dir() and item.name.startswith("ms-dynamics-smb.al-"):
logger.log(f"Found AL extension at: {item}", level=5)
return str(item)
return None
@staticmethod
def _get_initialize_params(repository_absolute_path: str) -> dict:
"""
Returns the initialize params for the AL Language Server.
"""
import pathlib
import os
# 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
def _start_server(self):
"""
Starts the AL Language Server process and initializes it.
"""
import logging
# Set up event handlers
def do_nothing(params):
return
def window_log_message(msg):
self.logger.log(f"AL LSP: window/logMessage: {msg}", logging.INFO)
def publish_diagnostics(params):
# AL server publishes diagnostics during initialization
uri = params.get("uri", "")
diagnostics = params.get("diagnostics", [])
self.logger.log(f"AL LSP: Diagnostics for {uri}: {len(diagnostics)} issues", logging.DEBUG)
def handle_al_notifications(params):
# AL server sends custom notifications during project loading
self.logger.log(f"AL LSP: Notification received", logging.DEBUG)
# Register handlers for AL-specific notifications
self.server.on_notification("window/logMessage", window_log_message)
self.server.on_notification("textDocument/publishDiagnostics", publish_diagnostics)
self.server.on_notification("$/progress", do_nothing)
self.server.on_notification("al/refreshExplorerObjects", handle_al_notifications)
# Start the server process
self.logger.log("Starting AL Language Server process", logging.INFO)
self.server.start()
# Send initialize request
initialize_params = self._get_initialize_params(self.repository_root_path)
self.logger.log(
"Sending initialize request from LSP client to AL LSP server and awaiting response",
logging.INFO,
)
# 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")
self.logger.log("AL Language Server initialized successfully", logging.INFO)
# Send initialized notification
self.server.send_notification("initialized", {})
self.logger.log("Sent initialized notification", logging.INFO)
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. Set the active workspace
2. Send workspace configuration
3. Open app.json to trigger project loading
4. Wait for project to be loaded
"""
import json
import time
from pathlib import Path
# Give the server a moment to fully initialize
time.sleep(1)
# Send workspace configuration first
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]
}
})
self.logger.log("Sent workspace configuration", level=5)
except Exception as e:
self.logger.log(f"Failed to send workspace config: {e}", level=3)
# Check if app.json exists and open it
app_json_path = Path(self.repository_root_path) / "app.json"
if app_json_path.exists():
try:
with open(app_json_path, 'r', 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
}
})
self.logger.log(f"Opened app.json: {app_json_uri}", level=5)
except Exception as e:
self.logger.log(f"Failed to open app.json: {e}", level=3)
# Try to set active workspace (AL-specific request)
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,
"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]
}
},
timeout=10
)
self.logger.log(f"Set active workspace result: {result}", level=5)
except Exception as e:
# This is a custom AL request, might not be critical
self.logger.log(f"Failed to set active workspace: {e}", level=3)
# Wait for project to be loaded using our new method
if not self._wait_for_project_load(timeout=30):
# Even if not confirmed loaded, give it extra time to index
self.logger.log("Project load not confirmed, waiting additional time for AL workspace indexing", level=5)
time.sleep(5)
else:
# Even when loaded, give a bit more time for symbol indexing
self.logger.log("Project loaded, waiting for symbol indexing", level=5)
time.sleep(2)
def is_ignored_dirname(self, dirname: str) -> bool:
"""
Define AL-specific directories to ignore during file scanning.
Args:
dirname: Directory name to check
Returns:
True if directory should be ignored
"""
al_ignore_dirs = {
".alpackages", # AL package cache
".alcache", # AL compiler cache
".altemplates", # AL templates
".snapshots", # Test snapshots
"out", # Compiled output
".vscode", # VS Code settings
"Reference", # Reference assemblies
".netpackages", # .NET packages
"bin", # Binary output
"obj", # Object files
}
# Check parent class ignore list first, then AL-specific
return super().is_ignored_dirname(dirname) or dirname in al_ignore_dirs
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.
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.
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
"""
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
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, include_body=include_body)
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
self.logger.log(f"AL: Found {len(al_files)} AL files", level=5)
if not al_files:
self.logger.log("AL: No AL files found in repository", level=3)
return []
# Collect all symbols from all files
all_file_symbols = []
for file_path, relative_path in al_files:
try:
# Use our overridden request_document_symbols which handles opening
self.logger.log(f"AL: Getting symbols for {relative_path}", level=8)
all_syms, root_syms = self.request_document_symbols(relative_path, include_body=include_body)
if root_syms:
# Create a file-level symbol containing the document symbols
file_symbol = {
"name": file_path.stem, # Just the filename without extension
"kind": 1, # 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)
self.logger.log(f"AL: Added {len(root_syms)} symbols from {relative_path}", level=8)
elif all_syms:
# If we only got all_syms but not root, use all_syms
file_symbol = {
"name": file_path.stem,
"kind": 1, # 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)
self.logger.log(f"AL: Added {len(all_syms)} symbols from {relative_path}", level=8)
except Exception as e:
self.logger.log(f"AL: Failed to get symbols for {relative_path}: {e}", level=5)
if all_file_symbols:
self.logger.log(f"AL: Returning symbols from {len(all_file_symbols)} files", level=5)
# Group files by directory
directory_structure = {}
for file_symbol in all_file_symbols:
rel_path = file_symbol["location"]["relativePath"]
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": 4, # 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:
self.logger.log("AL: No symbols found in any files", level=3)
return []
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.
Uses direct LSP request to ensure exact URI matching.
Args:
relative_file_path: Relative path to the file within the repository
include_body: Whether to include the body of symbols
Returns:
Tuple of (all symbols, root level symbols)
"""
from pathlib import Path
import pathlib
self.logger.log(f"AL: Requesting document symbols for {relative_file_path}", level=5)
# Convert relative path to absolute, handling both forward and backslashes
relative_file_path = relative_file_path.replace('\\', '/')
abs_path = Path(self.repository_root_path) / relative_file_path
# Check if file exists
if not abs_path.exists():
self.logger.log(f"AL: File does not exist: {abs_path}", level=3)
return ([], [])
try:
# Read file content
with open(abs_path, 'r', encoding='utf-8') as f:
content = f.read()
# Create URI - ensure consistent format
# Use pathlib to get proper URI format
file_uri = pathlib.Path(abs_path).as_uri()
self.logger.log(f"AL: Opening file with URI: {file_uri}", level=5)
# Open the file first with exact URI we'll use for symbols
self.server.send_notification("textDocument/didOpen", {
"textDocument": {
"uri": file_uri,
"languageId": "al",
"version": 1,
"text": content
}
})
self.logger.log(f"AL: File opened, waiting for processing", level=5)
# Give server time to process the file
import time
time.sleep(0.5) # Increased wait time
# Now request symbols using direct LSP request with exact same URI
self.logger.log(f"AL: Requesting symbols with URI: {file_uri}", level=5)
try:
# Direct LSP request to ensure URI matches exactly
response = self.server.send_request(
"textDocument/documentSymbol",
{"textDocument": {"uri": file_uri}},
timeout=5
)
self.logger.log(f"AL: Got symbol response: {response is not None}", level=5)
if response:
# Process the response to match expected format
all_symbols = []
root_symbols = []
if isinstance(response, list):
# Response is a list of symbols
for symbol in response:
symbol_info = self._convert_lsp_symbol(symbol)
all_symbols.append(symbol_info)
root_symbols.append(symbol_info)
# Process children if hierarchical
if "children" in symbol:
self._process_child_symbols(symbol["children"], all_symbols)
self.logger.log(f"AL: Processed {len(all_symbols)} total symbols, {len(root_symbols)} root symbols", level=5)
# Close the file
try:
self.server.send_notification("textDocument/didClose", {
"textDocument": {
"uri": file_uri
}
})
except Exception:
pass
return (all_symbols, root_symbols)
else:
self.logger.log("AL: No symbols returned from language server", level=3)
except Exception as e:
self.logger.log(f"AL: Error requesting symbols: {e}", level=3)
# Close file even if symbol request failed
try:
self.server.send_notification("textDocument/didClose", {
"textDocument": {
"uri": file_uri
}
})
except Exception:
pass
# If direct request failed, try parent method as fallback
self.logger.log("AL: Falling back to parent method", level=5)
return super().request_document_symbols(relative_file_path, include_body)
except Exception as e:
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."""
import lsprotocol.types as lsp_types
# Extract basic info
name = lsp_symbol.get("name", "")
kind = lsp_symbol.get("kind", 0)
# Convert range/location
range_info = lsp_symbol.get("range", {})
location = lsp_symbol.get("location", {})
symbol_info = {
"name": name,
"kind": kind,
"location": location if location else {
"range": range_info,
"uri": "" # Will be filled by parent
}
}
# Add detail if present
if "detail" in lsp_symbol:
symbol_info["detail"] = lsp_symbol["detail"]
# Add children if present
if "children" in lsp_symbol and lsp_symbol["children"]:
symbol_info["children"] = [
self._convert_lsp_symbol(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)
all_symbols.append(child_info)
if "children" in child:
self._process_child_symbols(child["children"], all_symbols)
# ===== Phase 1: Custom AL Command Implementations =====
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.
"""
# 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)
self.logger.log(f"AL gotodefinition response: {response}", logging.DEBUG)
return response
except Exception as e:
self.logger.log(f"Failed to use al/gotodefinition, falling back to standard: {e}", logging.WARNING)
# 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.
Returns:
bool: True if project is loaded, False otherwise
"""
if not hasattr(self, 'server') or not self.server_started:
self.logger.log("Cannot check project load - server not started", logging.DEBUG)
return False
try:
response = self.server.send_request("al/hasProjectClosureLoadedRequest", {})
# 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 or the request isn't supported
self.logger.log("Project load check returned None - project likely still loading", logging.DEBUG)
return False
else:
self.logger.log(f"Unexpected response type for project load check: {type(response)}", logging.WARNING)
return False
except Exception as e:
self.logger.log(f"Failed to check project load status: {e}", logging.WARNING)
# Assume loaded if we can't check
return True
def _wait_for_project_load(self, timeout: int = 30) -> bool:
"""
Wait for project to be fully loaded.
Args:
timeout: Maximum time to wait in seconds
Returns:
bool: True if project loaded within timeout, False otherwise
"""
start_time = time.time()
self.logger.log(f"Waiting for AL project to load (timeout: {timeout}s)...", logging.INFO)
while time.time() - start_time < timeout:
if self.check_project_loaded():
elapsed = time.time() - start_time
self.logger.log(f"AL project fully loaded after {elapsed:.1f}s", logging.INFO)
return True
time.sleep(0.5)
self.logger.log(f"Timeout waiting for AL project to load after {timeout}s", logging.WARNING)
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.
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:
self.logger.log("Cannot set active workspace - server not started", logging.DEBUG)
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)
self.logger.log(f"Set active workspace to: {workspace_uri}", logging.INFO)
except Exception as e:
self.logger.log(f"Failed to set active workspace: {e}", logging.WARNING)
# Non-critical error, continue operation
+5
View File
@@ -270,6 +270,11 @@ class SolidLanguageServer(ABC):
ls = ErlangLanguageServer(config, logger, repository_root_path, solidlsp_settings=solidlsp_settings)
elif config.code_language == Language.AL:
from solidlsp.language_servers.al_language_server import ALLanguageServer
ls = ALLanguageServer(config, logger, repository_root_path, solidlsp_settings=solidlsp_settings)
else:
logger.log(f"Language {config.code_language} is not supported", logging.ERROR)
raise SolidLSPException(f"Language {config.code_language} is not supported")
+3
View File
@@ -49,6 +49,7 @@ class Language(str, Enum):
LUA = "lua"
NIX = "nix"
ERLANG = "erlang"
AL = "al"
# Experimental or deprecated Language Servers
TYPESCRIPT_VTS = "typescript_vts"
"""Use the typescript language server through the natively bundled vscode extension via https://github.com/yioneko/vtsls"""
@@ -130,6 +131,8 @@ class Language(str, Enum):
return FilenameMatcher("*.nix")
case self.ERLANG:
return FilenameMatcher("*.erl", "*.hrl", "*.escript", "*.config", "*.app", "*.app.src")
case self.AL:
return FilenameMatcher("*.al", "*.dal")
case _:
raise ValueError(f"Unhandled language: {self}")
@@ -0,0 +1,31 @@
{
"id": "00000001-0000-0000-0000-000000000001",
"name": "Test AL Project",
"publisher": "Serena Test Publisher",
"version": "1.0.0.0",
"brief": "Test project for AL Language Server in Serena",
"description": "This project contains AL code samples for testing language server features",
"privacyStatement": "",
"EULA": "",
"help": "",
"url": "https://github.com/oraios/serena",
"logo": "",
"dependencies": [],
"screenshots": [],
"platform": "1.0.0.0",
"application": "26.0.0.0",
"idRanges": [
{
"from": 50000,
"to": 50100
}
],
"resourceExposurePolicy": {
"allowDebugging": true,
"allowDownloadingSource": true,
"includeSourceInSymbolFile": true
},
"runtime": "15.0",
"features": ["NoImplicitWith"],
"target": "Cloud"
}
@@ -0,0 +1,199 @@
codeunit 50000 CustomerMgt
{
Permissions = tabledata "TEST Customer" = rimd;
trigger OnRun()
begin
Message('Customer Management Codeunit');
end;
procedure CreateNewCustomer()
var
Customer: Record "TEST Customer";
CustomerCard: Page "TEST Customer Card";
begin
Customer.Init();
Customer.Insert(true);
CustomerCard.SetRecord(Customer);
CustomerCard.Run();
end;
procedure CreateCustomer(CustomerNo: Code[20]; CustomerName: Text[100]; CustomerType: Enum CustomerType): Boolean
var
Customer: Record "TEST Customer";
begin
if Customer.Get(CustomerNo) then
exit(false);
Customer.Init();
Customer."No." := CustomerNo;
Customer.Name := CustomerName;
Customer."Customer Type" := CustomerType;
Customer.UpdateSearchName();
Customer.Insert(true);
exit(true);
end;
procedure AssistEdit(var Customer: Record "TEST Customer"): Boolean
var
NoSeriesMgt: Codeunit NoSeriesManagement;
begin
with Customer do begin
if NoSeriesMgt.SelectSeries(GetNoSeriesCode(), '', "No. Series") then begin
NoSeriesMgt.SetSeries("No.");
exit(true);
end;
end;
exit(false);
end;
procedure TestNoSeries()
var
SalesSetup: Record "Sales & Receivables Setup";
begin
SalesSetup.Get();
SalesSetup.TestField("Customer Nos.");
end;
procedure InitNo(var Customer: Record "TEST Customer")
var
NoSeriesMgt: Codeunit NoSeriesManagement;
begin
TestNoSeries();
NoSeriesMgt.InitSeries(GetNoSeriesCode(), Customer."No. Series", 0D, Customer."No.", Customer."No. Series");
end;
procedure GetNoSeriesCode(): Code[20]
var
SalesSetup: Record "Sales & Receivables Setup";
begin
SalesSetup.Get();
exit(SalesSetup."Customer Nos.");
end;
procedure CalculateTotalBalance(): Decimal
var
Customer: Record "TEST Customer";
TotalBalance: Decimal;
begin
TotalBalance := 0;
if Customer.FindSet() then
repeat
Customer.CalcFields(Balance);
TotalBalance += Customer.Balance;
until Customer.Next() = 0;
exit(TotalBalance);
end;
procedure GetCustomerCount(CustomerType: Enum CustomerType): Integer
var
Customer: Record "TEST Customer";
begin
Customer.SetRange("Customer Type", CustomerType);
exit(Customer.Count());
end;
procedure BlockCustomersOverCreditLimit()
var
Customer: Record "TEST Customer";
BlockedCount: Integer;
begin
BlockedCount := 0;
if Customer.FindSet() then
repeat
Customer.CalcFields(Balance);
if (Customer."Credit Limit" > 0) and (Customer.Balance > Customer."Credit Limit") then begin
Customer.Blocked := true;
Customer.Modify(true);
BlockedCount += 1;
end;
until Customer.Next() = 0;
if BlockedCount > 0 then
Message('%1 customers blocked due to credit limit exceeded', BlockedCount);
end;
procedure GetPaymentProcessor(): Interface IPaymentProcessor
var
PaymentProcessorImpl: Codeunit PaymentProcessorImpl;
begin
exit(PaymentProcessorImpl);
end;
procedure SendCustomerStatement(CustomerNo: Code[20])
var
Customer: Record "TEST Customer";
ReportSelections: Record "Report Selections";
begin
if not Customer.Get(CustomerNo) then
Error('Customer %1 not found', CustomerNo);
Customer.SetRecFilter();
ReportSelections.SetRange(Usage, ReportSelections.Usage::"C.Statement");
ReportSelections.PrintForCust(ReportSelections.Usage::"C.Statement", Customer, 1);
end;
procedure ValidateEmail(Email: Text[80]): Boolean
var
MailMgt: Codeunit "Mail Management";
begin
exit(MailMgt.CheckValidEmailAddress(Email));
end;
procedure MergeCustomers(FromCustomerNo: Code[20]; ToCustomerNo: Code[20])
var
FromCustomer: Record "TEST Customer";
ToCustomer: Record "TEST Customer";
CustomerLedgerEntry: Record "Cust. Ledger Entry";
begin
if not FromCustomer.Get(FromCustomerNo) then
Error('Source customer %1 not found', FromCustomerNo);
if not ToCustomer.Get(ToCustomerNo) then
Error('Target customer %1 not found', ToCustomerNo);
// Transfer ledger entries
CustomerLedgerEntry.SetRange("Customer No.", FromCustomerNo);
if CustomerLedgerEntry.FindSet() then
repeat
CustomerLedgerEntry."Customer No." := ToCustomerNo;
CustomerLedgerEntry.Modify();
until CustomerLedgerEntry.Next() = 0;
// Delete source customer
FromCustomer.Delete(true);
Message('Customer %1 merged into %2', FromCustomerNo, ToCustomerNo);
end;
[EventSubscriber(ObjectType::Table, Database::"TEST Customer", OnAfterInsertEvent, '', true, true)]
local procedure OnAfterInsertCustomer(var Rec: Record "TEST Customer")
begin
LogCustomerChange(Rec, 'INSERT');
end;
[EventSubscriber(ObjectType::Table, Database::"TEST Customer", OnAfterModifyEvent, '', true, true)]
local procedure OnAfterModifyCustomer(var Rec: Record "TEST Customer")
begin
LogCustomerChange(Rec, 'MODIFY');
end;
local procedure LogCustomerChange(Customer: Record "TEST Customer"; ChangeType: Text[10])
var
ChangeLogEntry: Record "Change Log Entry";
begin
// Log customer changes for audit purposes
ChangeLogEntry.Init();
ChangeLogEntry."Table No." := Database::"TEST Customer";
ChangeLogEntry."Primary Key Field 1 Value" := Customer."No.";
ChangeLogEntry."Type of Change" := ChangeLogEntry."Type of Change"::Modification;
ChangeLogEntry."User ID" := UserId;
ChangeLogEntry."Date and Time" := CurrentDateTime;
if ChangeLogEntry.Insert() then;
end;
}
@@ -0,0 +1,73 @@
codeunit 50001 PaymentProcessorImpl implements IPaymentProcessor
{
procedure ProcessPayment(Customer: Record "TEST Customer"): Boolean
var
//PaymentEntry: Record "Payment Entry";
begin
// Implementation of payment processing
Customer.CalcFields(Balance);
if Customer.Balance <= 0 then
exit(false);
// PaymentEntry.Init();
// PaymentEntry."Customer No." := Customer."No.";
// PaymentEntry.Amount := Customer.Balance;
// PaymentEntry."Payment Date" := Today();
// PaymentEntry.Status := PaymentEntry.Status::Processed;
// if PaymentEntry.Insert(true) then begin
// Message('Payment processed for customer %1', Customer.Name);
// exit(true);
// end;
exit(false);
end;
procedure ValidatePaymentMethod(PaymentMethodCode: Code[10]): Boolean
var
PaymentMethod: Record "Payment Method";
begin
if PaymentMethodCode = '' then
exit(false);
exit(PaymentMethod.Get(PaymentMethodCode));
end;
procedure GetTransactionFee(Amount: Decimal): Decimal
var
FeePercentage: Decimal;
MinimumFee: Decimal;
begin
FeePercentage := 2.9; // 2.9% transaction fee
MinimumFee := 0.30; // Minimum fee
exit(Maximum(Amount * FeePercentage / 100, MinimumFee));
end;
procedure RefundPayment(TransactionID: Text[50]): Boolean
var
// PaymentEntry: Record "Payment Entry";
begin
// PaymentEntry.SetRange("Transaction ID", TransactionID);
// if PaymentEntry.FindFirst() then begin
// PaymentEntry.Status := PaymentEntry.Status::Refunded;
// PaymentEntry."Refund Date" := Today();
// PaymentEntry.Modify(true);
// Message('Payment refunded for transaction %1', TransactionID);
// exit(true);
// end;
Error('Transaction %1 not found', TransactionID);
end;
local procedure Maximum(Value1: Decimal; Value2: Decimal): Decimal
begin
if Value1 > Value2 then
exit(Value1)
else
exit(Value2);
end;
}
@@ -0,0 +1,35 @@
enum 50000 CustomerType
{
Extensible = true;
Caption = 'Customer Type';
value(0; "")
{
Caption = '';
}
value(1; Regular)
{
Caption = 'Regular';
}
value(2; Premium)
{
Caption = 'Premium';
}
value(3; VIP)
{
Caption = 'VIP';
}
value(4; Corporate)
{
Caption = 'Corporate';
}
value(5; Government)
{
Caption = 'Government';
}
}
@@ -0,0 +1,7 @@
interface IPaymentProcessor
{
procedure ProcessPayment(Customer: Record "TEST Customer"): Boolean;
procedure ValidatePaymentMethod(PaymentMethodCode: Code[10]): Boolean;
procedure GetTransactionFee(Amount: Decimal): Decimal;
procedure RefundPayment(TransactionID: Text[50]): Boolean;
}
@@ -0,0 +1,271 @@
page 50001 "TEST Customer Card"
{
Caption = 'Customer Card';
PageType = Card;
SourceTable = "TEST Customer";
RefreshOnActivate = true;
layout
{
area(Content)
{
group(General)
{
Caption = 'General';
field("No."; Rec."No.")
{
ApplicationArea = All;
ToolTip = 'Specifies the customer number.';
trigger OnAssistEdit()
begin
if CustomerMgt.AssistEdit(Rec) then
CurrPage.Update();
end;
}
field(Name; Rec.Name)
{
ApplicationArea = All;
ToolTip = 'Specifies the customer name.';
ShowMandatory = true;
}
field("Search Name"; Rec."Search Name")
{
ApplicationArea = All;
ToolTip = 'Specifies the search name.';
Visible = false;
}
field("Customer Type"; Rec."Customer Type")
{
ApplicationArea = All;
ToolTip = 'Specifies the type of customer.';
}
field(Blocked; Rec.Blocked)
{
ApplicationArea = All;
ToolTip = 'Specifies if the customer is blocked.';
}
field("Last Date Modified"; Rec."Last Date Modified")
{
ApplicationArea = All;
ToolTip = 'Specifies when the customer was last modified.';
Editable = false;
}
}
group(AddressAndContact)
{
Caption = 'Address & Contact';
field(Address; Rec.Address)
{
ApplicationArea = All;
ToolTip = 'Specifies the customer address.';
}
field("Address 2"; Rec."Address 2")
{
ApplicationArea = All;
ToolTip = 'Specifies additional address information.';
}
field(City; Rec.City)
{
ApplicationArea = All;
ToolTip = 'Specifies the city.';
}
field("Phone No."; Rec."Phone No.")
{
ApplicationArea = All;
ToolTip = 'Specifies the phone number.';
}
field("E-Mail"; Rec."E-Mail")
{
ApplicationArea = All;
ToolTip = 'Specifies the email address.';
ExtendedDatatype = EMail;
}
}
group(Invoicing)
{
Caption = 'Invoicing';
field("Credit Limit"; Rec."Credit Limit")
{
ApplicationArea = All;
ToolTip = 'Specifies the credit limit.';
}
field(Balance; Rec.Balance)
{
ApplicationArea = All;
ToolTip = 'Specifies the customer balance.';
DrillDownPageId = "Customer Ledger Entries";
}
field("Payment Terms Code"; Rec."Payment Terms Code")
{
ApplicationArea = All;
ToolTip = 'Specifies the payment terms.';
}
field("Currency Code"; Rec."Currency Code")
{
ApplicationArea = All;
ToolTip = 'Specifies the currency code.';
}
}
}
area(FactBoxes)
{
part(CustomerPicture; "Customer Picture")
{
ApplicationArea = All;
SubPageLink = "No." = field("No.");
}
systempart(Links; Links)
{
ApplicationArea = RecordLinks;
}
systempart(Notes; Notes)
{
ApplicationArea = Notes;
}
}
}
actions
{
area(Navigation)
{
group(Customer)
{
Caption = '&Customer';
action(LedgerEntries)
{
ApplicationArea = All;
Caption = 'Ledger E&ntries';
Image = CustomerLedger;
RunObject = page "Customer Ledger Entries";
RunPageLink = "Customer No." = field("No.");
RunPageView = sorting("Customer No.");
ShortcutKey = 'Ctrl+F7';
ToolTip = 'View the history of transactions for the customer.';
}
action(Statistics)
{
ApplicationArea = All;
Caption = 'Statistics';
Image = Statistics;
RunObject = page "Customer Statistics";
RunPageLink = "No." = field("No.");
ShortcutKey = 'F7';
ToolTip = 'View statistical information about the customer.';
}
}
}
area(Processing)
{
group(Functions)
{
Caption = 'F&unctions';
action(CheckCreditLimit)
{
ApplicationArea = All;
Caption = 'Check Credit Limit';
Image = Check;
ToolTip = 'Check if the customer has exceeded their credit limit.';
trigger OnAction()
begin
Rec.CheckCreditLimit();
end;
}
action(ProcessPayment)
{
ApplicationArea = All;
Caption = 'Process Payment';
Image = Payment;
ToolTip = 'Process a payment for this customer.';
trigger OnAction()
var
PaymentProcessor: Interface IPaymentProcessor;
begin
PaymentProcessor := CustomerMgt.GetPaymentProcessor();
PaymentProcessor.ProcessPayment(Rec);
end;
}
}
}
area(Promoted)
{
group(Category_Process)
{
Caption = 'Process';
actionref(CheckCreditLimit_Promoted; CheckCreditLimit)
{
}
actionref(ProcessPayment_Promoted; ProcessPayment)
{
}
}
group(Category_Customer)
{
Caption = 'Customer';
actionref(Statistics_Promoted; Statistics)
{
}
actionref(LedgerEntries_Promoted; LedgerEntries)
{
}
}
}
}
var
CustomerMgt: Codeunit CustomerMgt;
trigger OnOpenPage()
begin
Rec.SetRange("Customer Type");
end;
trigger OnAfterGetRecord()
begin
CheckCreditStatus();
end;
local procedure CheckCreditStatus()
begin
if Rec."Credit Limit" = 0 then
exit;
Rec.CalcFields(Balance);
if Rec.Balance > Rec."Credit Limit" then
Message('Warning: Customer has exceeded credit limit');
end;
}
@@ -0,0 +1,205 @@
page 50002 "TEST Customer List"
{
Caption = 'Customer List';
PageType = List;
ApplicationArea = All;
UsageCategory = Lists;
SourceTable = "TEST Customer";
CardPageId = "TEST Customer Card";
Editable = false;
layout
{
area(Content)
{
repeater(Group)
{
field("No."; Rec."No.")
{
ApplicationArea = All;
ToolTip = 'Specifies the customer number.';
}
field(Name; Rec.Name)
{
ApplicationArea = All;
ToolTip = 'Specifies the customer name.';
}
field(City; Rec.City)
{
ApplicationArea = All;
ToolTip = 'Specifies the city.';
}
field("Customer Type"; Rec."Customer Type")
{
ApplicationArea = All;
ToolTip = 'Specifies the type of customer.';
}
field("Phone No."; Rec."Phone No.")
{
ApplicationArea = All;
ToolTip = 'Specifies the phone number.';
}
field("E-Mail"; Rec."E-Mail")
{
ApplicationArea = All;
ToolTip = 'Specifies the email address.';
}
field(Balance; Rec.Balance)
{
ApplicationArea = All;
ToolTip = 'Specifies the customer balance.';
StyleExpr = BalanceStyleExpr;
}
field("Credit Limit"; Rec."Credit Limit")
{
ApplicationArea = All;
ToolTip = 'Specifies the credit limit.';
}
field(Blocked; Rec.Blocked)
{
ApplicationArea = All;
ToolTip = 'Specifies if the customer is blocked.';
}
}
}
area(FactBoxes)
{
systempart(Links; Links)
{
ApplicationArea = RecordLinks;
}
systempart(Notes; Notes)
{
ApplicationArea = Notes;
}
}
}
actions
{
area(Processing)
{
action(NewCustomer)
{
ApplicationArea = All;
Caption = 'New';
Image = NewCustomer;
ToolTip = 'Create a new customer.';
trigger OnAction()
begin
CustomerMgt.CreateNewCustomer();
end;
}
action(ExportToExcel)
{
ApplicationArea = All;
Caption = 'Export to Excel';
Image = ExportToExcel;
ToolTip = 'Export the customer list to Excel.';
trigger OnAction()
begin
ExportCustomersToExcel();
end;
}
}
area(Navigation)
{
action(ViewStatistics)
{
ApplicationArea = All;
Caption = 'Statistics';
Image = Statistics;
RunObject = page "Customer Statistics";
RunPageLink = "No." = field("No.");
ToolTip = 'View customer statistics.';
}
}
area(Promoted)
{
group(Category_New)
{
Caption = 'New';
actionref(NewCustomer_Promoted; NewCustomer)
{
}
}
group(Category_Process)
{
Caption = 'Process';
actionref(ExportToExcel_Promoted; ExportToExcel)
{
}
}
}
}
trigger OnAfterGetRecord()
begin
SetBalanceStyle();
end;
var
CustomerMgt: Codeunit CustomerMgt;
BalanceStyleExpr: Text;
local procedure SetBalanceStyle()
begin
BalanceStyleExpr := '';
Rec.CalcFields(Balance);
if (Rec."Credit Limit" <> 0) and (Rec.Balance > Rec."Credit Limit") then
BalanceStyleExpr := 'Unfavorable';
end;
local procedure ExportCustomersToExcel()
var
ExcelBuffer: Record "Excel Buffer" temporary;
RowNo: Integer;
begin
ExcelBuffer.Reset();
ExcelBuffer.DeleteAll();
// Add headers
RowNo := 1;
ExcelBuffer.AddColumn('Customer No.', false, '', false, false, false, '', ExcelBuffer."Cell Type"::Text);
ExcelBuffer.AddColumn('Name', false, '', false, false, false, '', ExcelBuffer."Cell Type"::Text);
ExcelBuffer.AddColumn('City', false, '', false, false, false, '', ExcelBuffer."Cell Type"::Text);
ExcelBuffer.AddColumn('Balance', false, '', false, false, false, '', ExcelBuffer."Cell Type"::Number);
ExcelBuffer.NewRow();
// Add data
if rec.FindSet() then
repeat
RowNo += 1;
rec.CalcFields(Balance);
ExcelBuffer.AddColumn(rec."No.", false, '', false, false, false, '', ExcelBuffer."Cell Type"::Text);
ExcelBuffer.AddColumn(rec.Name, false, '', false, false, false, '', ExcelBuffer."Cell Type"::Text);
ExcelBuffer.AddColumn(rec.City, false, '', false, false, false, '', ExcelBuffer."Cell Type"::Text);
ExcelBuffer.AddColumn(rec.Balance, false, '', false, false, false, '', ExcelBuffer."Cell Type"::Number);
ExcelBuffer.NewRow();
until rec.Next() = 0;
ExcelBuffer.CreateNewBook('Customers');
ExcelBuffer.WriteSheet('Customer List', CompanyName, UserId);
ExcelBuffer.CloseBook();
ExcelBuffer.OpenExcel();
end;
}
@@ -0,0 +1,79 @@
tableextension 50000 ItemExt extends Item
{
fields
{
field(50000; "Customer No."; Code[20])
{
Caption = 'Preferred Customer No.';
DataClassification = CustomerContent;
TableRelation = "TEST Customer";
trigger OnValidate()
var
Customer: Record "TEST Customer";
begin
if "Customer No." <> '' then begin
Customer.Get("Customer No.");
"Customer Name" := Customer.Name;
end else
"Customer Name" := '';
end;
}
field(50001; "Customer Name"; Text[100])
{
Caption = 'Preferred Customer Name';
DataClassification = CustomerContent;
Editable = false;
}
field(50002; "Special Discount %"; Decimal)
{
Caption = 'Special Discount %';
DataClassification = CustomerContent;
MinValue = 0;
MaxValue = 100;
}
field(50003; "Last Sale Date"; Date)
{
Caption = 'Last Sale Date';
DataClassification = CustomerContent;
Editable = false;
}
field(50004; "Total Sales Qty"; Decimal)
{
Caption = 'Total Sales Quantity';
FieldClass = FlowField;
CalcFormula = sum("Sales Line".Quantity where("No." = field("No."),
Type = const(Item)));
Editable = false;
}
}
keys
{
key(CustomerKey; "Customer No.")
{
}
}
procedure UpdateLastSaleDate()
begin
"Last Sale Date" := Today();
Modify();
end;
procedure GetSpecialPrice(Customer: Record "TEST Customer"): Decimal
var
BasePrice: Decimal;
begin
BasePrice := "Unit Price";
if "Customer No." = Customer."No." then
BasePrice := BasePrice * (1 - "Special Discount %" / 100);
exit(BasePrice);
end;
}
@@ -0,0 +1,210 @@
table 50000 "TEST Customer"
{
Caption = 'Customer';
DataClassification = CustomerContent;
fields
{
field(1; "No."; Code[20])
{
Caption = 'No.';
DataClassification = CustomerContent;
trigger OnValidate()
begin
if "No." <> xRec."No." then begin
CustomerMgt.TestNoSeries();
"No. Series" := '';
end;
end;
}
field(2; Name; Text[100])
{
Caption = 'Name';
DataClassification = CustomerContent;
trigger OnValidate()
begin
if Name <> xRec.Name then
UpdateSearchName();
end;
}
field(3; "Search Name"; Code[100])
{
Caption = 'Search Name';
DataClassification = CustomerContent;
}
field(4; Address; Text[100])
{
Caption = 'Address';
DataClassification = CustomerContent;
}
field(5; "Address 2"; Text[50])
{
Caption = 'Address 2';
DataClassification = CustomerContent;
}
field(6; City; Text[30])
{
Caption = 'City';
DataClassification = CustomerContent;
}
field(7; "Phone No."; Text[30])
{
Caption = 'Phone No.';
DataClassification = CustomerContent;
}
field(8; "E-Mail"; Text[80])
{
Caption = 'E-Mail';
DataClassification = CustomerContent;
trigger OnValidate()
var
MailMgt: Codeunit "Mail Management";
begin
MailMgt.CheckValidEmailAddresses("E-Mail");
end;
}
field(10; "Customer Type"; Enum CustomerType)
{
Caption = 'Customer Type';
DataClassification = CustomerContent;
}
field(11; Balance; Decimal)
{
Caption = 'Balance';
Editable = false;
FieldClass = FlowField;
CalcFormula = sum("Cust. Ledger Entry".Amount where("Customer No." = field("No.")));
}
field(12; "Credit Limit"; Decimal)
{
Caption = 'Credit Limit';
DataClassification = CustomerContent;
}
field(13; Blocked; Boolean)
{
Caption = 'Blocked';
DataClassification = CustomerContent;
}
field(14; "Last Date Modified"; Date)
{
Caption = 'Last Date Modified';
DataClassification = CustomerContent;
Editable = false;
}
field(15; "No. Series"; Code[20])
{
Caption = 'No. Series';
DataClassification = CustomerContent;
}
field(20; "Payment Terms Code"; Code[10])
{
Caption = 'Payment Terms Code';
DataClassification = CustomerContent;
TableRelation = "Payment Terms";
}
field(21; "Currency Code"; Code[10])
{
Caption = 'Currency Code';
DataClassification = CustomerContent;
TableRelation = Currency;
}
}
keys
{
key(PK; "No.")
{
Clustered = true;
}
key(SearchName; "Search Name")
{
}
key(CustomerType; "Customer Type", City)
{
}
}
fieldgroups
{
fieldgroup(DropDown; "No.", Name, City)
{
}
fieldgroup(Brick; "No.", Name, Balance)
{
}
}
trigger OnInsert()
begin
if "No." = '' then begin
CustomerMgt.TestNoSeries();
CustomerMgt.InitNo(Rec);
end;
"Last Date Modified" := Today();
end;
trigger OnModify()
begin
"Last Date Modified" := Today();
end;
trigger OnDelete()
var
CustomerLedgerEntry: Record "Cust. Ledger Entry";
begin
CustomerLedgerEntry.SetRange("Customer No.", "No.");
if not CustomerLedgerEntry.IsEmpty() then
Error('Cannot delete customer %1 with ledger entries', "No.");
end;
trigger OnRename()
begin
"Last Date Modified" := Today();
end;
var
CustomerMgt: Codeunit CustomerMgt;
procedure UpdateSearchName()
begin
"Search Name" := UpperCase(Name);
end;
procedure CheckCreditLimit()
var
CreditLimitExceeded: Boolean;
begin
CalcFields(Balance);
CreditLimitExceeded := (Balance > "Credit Limit") and ("Credit Limit" <> 0);
if CreditLimitExceeded then
Message('Credit limit exceeded for customer %1', "No.");
end;
procedure GetDisplayName(): Text
begin
exit(Name + ' (' + "No." + ')');
end;
}
+151
View File
@@ -0,0 +1,151 @@
import os
import pytest
from solidlsp import SolidLanguageServer
from solidlsp.ls_config import Language
from solidlsp.ls_utils import SymbolUtils
@pytest.mark.al
class TestALLanguageServer:
@pytest.mark.parametrize("language_server", [Language.AL], indirect=True)
def test_find_symbol(self, language_server: SolidLanguageServer) -> None:
"""Test that AL Language Server can find symbols in the test repository."""
symbols = language_server.request_full_symbol_tree()
# Check for table symbols - AL returns full object names like 'Table 50000 "TEST Customer"'
assert SymbolUtils.symbol_tree_contains_name(symbols, 'Table 50000 "TEST Customer"'), "TEST Customer table not found in symbol tree"
# Check for page symbols
assert SymbolUtils.symbol_tree_contains_name(symbols, 'Page 50001 "TEST Customer Card"'), "TEST Customer Card page not found in symbol tree"
assert SymbolUtils.symbol_tree_contains_name(symbols, 'Page 50002 "TEST Customer List"'), "TEST Customer List page not found in symbol tree"
# Check for codeunit symbols
assert SymbolUtils.symbol_tree_contains_name(symbols, "Codeunit 50000 CustomerMgt"), "CustomerMgt codeunit not found in symbol tree"
assert SymbolUtils.symbol_tree_contains_name(symbols, "Codeunit 50001 PaymentProcessorImpl"), "PaymentProcessorImpl codeunit not found in symbol tree"
# Check for enum symbol
assert SymbolUtils.symbol_tree_contains_name(symbols, "Enum 50000 CustomerType"), "CustomerType enum not found in symbol tree"
# Check for interface symbol
assert SymbolUtils.symbol_tree_contains_name(symbols, "Interface IPaymentProcessor"), "IPaymentProcessor interface not found in symbol tree"
@pytest.mark.parametrize("language_server", [Language.AL], indirect=True)
def test_find_table_fields(self, language_server: SolidLanguageServer) -> None:
"""Test that AL Language Server can find fields within a table."""
file_path = os.path.join("src", "Tables", "Customer.Table.al")
symbols = language_server.request_document_symbols(file_path)
# AL tables should have their fields as child symbols
customer_table = None
all_symbols, root_symbols = symbols
for sym in root_symbols:
if "TEST Customer" in sym.get("name", ""):
customer_table = sym
break
assert customer_table is not None, "Could not find TEST Customer table symbol"
# Check for field symbols (AL nests fields under a "fields" group)
if "children" in customer_table:
# Find the fields group
fields_group = None
for child in customer_table.get("children", []):
if child.get("name") == "fields":
fields_group = child
break
assert fields_group is not None, "Fields group not found in Customer table"
# Check actual field names
if "children" in fields_group:
field_names = [child.get("name", "") for child in fields_group.get("children", [])]
assert any("Name" in name for name in field_names), f"Name field not found. Fields: {field_names}"
assert any("Balance" in name for name in field_names), f"Balance field not found. Fields: {field_names}"
@pytest.mark.parametrize("language_server", [Language.AL], indirect=True)
def test_find_procedures(self, language_server: SolidLanguageServer) -> None:
"""Test that AL Language Server can find procedures in codeunits."""
file_path = os.path.join("src", "Codeunits", "CustomerMgt.Codeunit.al")
symbols = language_server.request_document_symbols(file_path)
# Find the codeunit symbol - AL returns 'Codeunit 50000 CustomerMgt'
codeunit_symbol = None
all_symbols, root_symbols = symbols
for sym in root_symbols:
if "CustomerMgt" in sym.get("name", ""):
codeunit_symbol = sym
break
assert codeunit_symbol is not None, "Could not find CustomerMgt codeunit symbol"
# Check for procedure symbols (if hierarchical)
if "children" in codeunit_symbol:
procedure_names = [child.get("name", "") for child in codeunit_symbol.get("children", [])]
assert any("CreateCustomer" in name for name in procedure_names), "CreateCustomer procedure not found"
# Note: UpdateCustomerBalance doesn't exist in our test repo, check for actual procedures
assert any("TestNoSeries" in name for name in procedure_names), "TestNoSeries procedure not found"
@pytest.mark.parametrize("language_server", [Language.AL], indirect=True)
def test_find_referencing_symbols(self, language_server: SolidLanguageServer) -> None:
"""Test that AL Language Server can find references to symbols."""
# Find references to the Customer table from the CustomerMgt codeunit
table_file = os.path.join("src", "Tables", "Customer.Table.al")
symbols = language_server.request_document_symbols(table_file)
# Find the Customer table symbol
customer_symbol = None
all_symbols, root_symbols = symbols
for sym in root_symbols:
if "TEST Customer" in sym.get("name", ""):
customer_symbol = sym
break
if customer_symbol and "selectionRange" in customer_symbol:
sel_start = customer_symbol["selectionRange"]["start"]
refs = language_server.request_references(table_file, sel_start["line"], sel_start["character"])
# The Customer table should be referenced in CustomerMgt.Codeunit.al
assert any(
"CustomerMgt.Codeunit.al" in ref.get("relativePath", "") for ref in refs
), "Customer table should be referenced in CustomerMgt.Codeunit.al"
# It should also be referenced in CustomerCard.Page.al
assert any(
"CustomerCard.Page.al" in ref.get("relativePath", "") for ref in refs
), "Customer table should be referenced in CustomerCard.Page.al"
@pytest.mark.parametrize("language_server", [Language.AL], indirect=True)
def test_cross_file_symbols(self, language_server: SolidLanguageServer) -> None:
"""Test that AL Language Server can handle cross-file symbol relationships."""
# Get all symbols to verify cross-file visibility
symbols = language_server.request_full_symbol_tree()
# Count how many AL-specific symbols we found
al_symbols = []
def collect_symbols(syms):
for sym in syms:
if isinstance(sym, dict):
name = sym.get("name", "")
# Look for AL object names (Table, Page, Codeunit, etc.)
if any(keyword in name for keyword in ["Table", "Page", "Codeunit", "Enum", "Interface"]):
al_symbols.append(name)
if "children" in sym:
collect_symbols(sym["children"])
collect_symbols(symbols)
# We should find symbols from multiple files
assert len(al_symbols) >= 5, f"Expected at least 5 AL object symbols, found {len(al_symbols)}: {al_symbols}"
# Verify we have symbols from different AL object types
has_table = any("Table" in s for s in al_symbols)
has_page = any("Page" in s for s in al_symbols)
has_codeunit = any("Codeunit" in s for s in al_symbols)
has_enum = any("Enum" in s for s in al_symbols)
has_interface = any("Interface" in s for s in al_symbols)
assert has_table, f"No Table symbols found in: {al_symbols}"
assert has_page, f"No Page symbols found in: {al_symbols}"
assert has_codeunit, f"No Codeunit symbols found in: {al_symbols}"