mirror of
https://github.com/tiennm99/serena.git
synced 2026-10-13 00:15:05 +00:00
Add AL Language Server support for Business Central development
This commit is contained in:
1 parent
aa8c5c6ff6
commit
5c0110f923
14 files changed
+2158
No files matched your search
@@ -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
|
||||
@@ -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")
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}"
|
||||
Reference in new issue
Block a user