From 5c0110f9233fd705bd8e33708e13e2e4c102b407 Mon Sep 17 00:00:00 2001 From: Torben Leth Date: Tue, 2 Sep 2025 20:09:14 +0200 Subject: [PATCH] Add AL Language Server support for Business Central development --- pyproject.toml | 1 + .../language_servers/al_language_server.py | 888 ++++++++++++++++++ src/solidlsp/ls.py | 5 + src/solidlsp/ls_config.py | 3 + test/resources/repos/al/test_repo/app.json | 31 + .../src/Codeunits/CustomerMgt.Codeunit.al | 199 ++++ .../PaymentProcessorImpl.Codeunit.al | 73 ++ .../test_repo/src/Enums/CustomerType.Enum.al | 35 + .../Interfaces/IPaymentProcessor.Interface.al | 7 + .../test_repo/src/Pages/CustomerCard.Page.al | 271 ++++++ .../test_repo/src/Pages/CustomerList.Page.al | 205 ++++ .../src/TableExtensions/Item.TableExt.al | 79 ++ .../al/test_repo/src/Tables/Customer.Table.al | 210 +++++ test/solidlsp/al/test_al_basic.py | 151 +++ 14 files changed, 2158 insertions(+) create mode 100644 src/solidlsp/language_servers/al_language_server.py create mode 100644 test/resources/repos/al/test_repo/app.json create mode 100644 test/resources/repos/al/test_repo/src/Codeunits/CustomerMgt.Codeunit.al create mode 100644 test/resources/repos/al/test_repo/src/Codeunits/PaymentProcessorImpl.Codeunit.al create mode 100644 test/resources/repos/al/test_repo/src/Enums/CustomerType.Enum.al create mode 100644 test/resources/repos/al/test_repo/src/Interfaces/IPaymentProcessor.Interface.al create mode 100644 test/resources/repos/al/test_repo/src/Pages/CustomerCard.Page.al create mode 100644 test/resources/repos/al/test_repo/src/Pages/CustomerList.Page.al create mode 100644 test/resources/repos/al/test_repo/src/TableExtensions/Item.TableExt.al create mode 100644 test/resources/repos/al/test_repo/src/Tables/Customer.Table.al create mode 100644 test/solidlsp/al/test_al_basic.py diff --git a/pyproject.toml b/pyproject.toml index 69559c22..65628e6f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] diff --git a/src/solidlsp/language_servers/al_language_server.py b/src/solidlsp/language_servers/al_language_server.py new file mode 100644 index 00000000..5d5c39da --- /dev/null +++ b/src/solidlsp/language_servers/al_language_server.py @@ -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 diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index 2de9cd20..2b09d98e 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -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") diff --git a/src/solidlsp/ls_config.py b/src/solidlsp/ls_config.py index fb266d67..c07e3b34 100644 --- a/src/solidlsp/ls_config.py +++ b/src/solidlsp/ls_config.py @@ -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}") diff --git a/test/resources/repos/al/test_repo/app.json b/test/resources/repos/al/test_repo/app.json new file mode 100644 index 00000000..d797c8da --- /dev/null +++ b/test/resources/repos/al/test_repo/app.json @@ -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" +} \ No newline at end of file diff --git a/test/resources/repos/al/test_repo/src/Codeunits/CustomerMgt.Codeunit.al b/test/resources/repos/al/test_repo/src/Codeunits/CustomerMgt.Codeunit.al new file mode 100644 index 00000000..d7c8f460 --- /dev/null +++ b/test/resources/repos/al/test_repo/src/Codeunits/CustomerMgt.Codeunit.al @@ -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; +} \ No newline at end of file diff --git a/test/resources/repos/al/test_repo/src/Codeunits/PaymentProcessorImpl.Codeunit.al b/test/resources/repos/al/test_repo/src/Codeunits/PaymentProcessorImpl.Codeunit.al new file mode 100644 index 00000000..16be0a04 --- /dev/null +++ b/test/resources/repos/al/test_repo/src/Codeunits/PaymentProcessorImpl.Codeunit.al @@ -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; +} \ No newline at end of file diff --git a/test/resources/repos/al/test_repo/src/Enums/CustomerType.Enum.al b/test/resources/repos/al/test_repo/src/Enums/CustomerType.Enum.al new file mode 100644 index 00000000..2eaee493 --- /dev/null +++ b/test/resources/repos/al/test_repo/src/Enums/CustomerType.Enum.al @@ -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'; + } +} \ No newline at end of file diff --git a/test/resources/repos/al/test_repo/src/Interfaces/IPaymentProcessor.Interface.al b/test/resources/repos/al/test_repo/src/Interfaces/IPaymentProcessor.Interface.al new file mode 100644 index 00000000..d518846d --- /dev/null +++ b/test/resources/repos/al/test_repo/src/Interfaces/IPaymentProcessor.Interface.al @@ -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; +} \ No newline at end of file diff --git a/test/resources/repos/al/test_repo/src/Pages/CustomerCard.Page.al b/test/resources/repos/al/test_repo/src/Pages/CustomerCard.Page.al new file mode 100644 index 00000000..3f266efb --- /dev/null +++ b/test/resources/repos/al/test_repo/src/Pages/CustomerCard.Page.al @@ -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; +} \ No newline at end of file diff --git a/test/resources/repos/al/test_repo/src/Pages/CustomerList.Page.al b/test/resources/repos/al/test_repo/src/Pages/CustomerList.Page.al new file mode 100644 index 00000000..15b5046a --- /dev/null +++ b/test/resources/repos/al/test_repo/src/Pages/CustomerList.Page.al @@ -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; +} \ No newline at end of file diff --git a/test/resources/repos/al/test_repo/src/TableExtensions/Item.TableExt.al b/test/resources/repos/al/test_repo/src/TableExtensions/Item.TableExt.al new file mode 100644 index 00000000..3d9eeefc --- /dev/null +++ b/test/resources/repos/al/test_repo/src/TableExtensions/Item.TableExt.al @@ -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; +} \ No newline at end of file diff --git a/test/resources/repos/al/test_repo/src/Tables/Customer.Table.al b/test/resources/repos/al/test_repo/src/Tables/Customer.Table.al new file mode 100644 index 00000000..a6c90430 --- /dev/null +++ b/test/resources/repos/al/test_repo/src/Tables/Customer.Table.al @@ -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; +} \ No newline at end of file diff --git a/test/solidlsp/al/test_al_basic.py b/test/solidlsp/al/test_al_basic.py new file mode 100644 index 00000000..4ea513a6 --- /dev/null +++ b/test/solidlsp/al/test_al_basic.py @@ -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}" \ No newline at end of file