Merge pull request #593 from SShadowS/al-support-clean

Add support for AL programming language
This commit is contained in:
Michael Panchenko authored and GitHub committed 2025-09-05 00:10:34 +02:00
commit df9a4e3037
18 files changed
+2837 -402

No files matched your search

+1
View File
@@ -2,6 +2,7 @@
Status of the `main` branch. Changes prior to the next official version change will appear here.
* Language support:
* **Add support for AL (Application Language)** for Microsoft Dynamics 365 Business Central development. Requires VS Code AL extension (ms-dynamics-smb.al).
* **Add support for R** via the R languageserver package with LSP integration, performance optimizations, and fallback symbol extraction
* **Add support for Zig** via ZLS (cross-file references may not fully work on Windows)
* **Add support for Lua** via lua-language-server
+1
View File
@@ -96,6 +96,7 @@ With Serena, we provide direct, out-of-the-box support for:
* Nix (requires nixd installation)
* Elixir (requires installation of NextLS and Elixir; **Windows not supported**)
* Erlang (requires installation of beam and [erlang_ls](https://github.com/erlang-ls/erlang_ls), experimental, might be slow or hang)
* AL
Support for further languages can easily be added by providing a shallow adapter for a new language server implementation,
see Serena's [memory on that](.serena/memories/adding_new_language_support_guide.md).
+2
View File
@@ -63,6 +63,7 @@ dev = [
"toml-sort>=0.24.2",
"types-pyyaml>=6.0.12.20241230",
"syrupy>=4.9.1",
"types-requests>=2.32.4.20241230",
]
agno = ["agno>=1.2.6", "sqlalchemy>=2.0.40"]
google = ["google-genai>=1.8.0"]
@@ -271,6 +272,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]
File diff suppressed because it is too large. Load diff
+23
View File
@@ -112,3 +112,26 @@ class RuntimeDependencyCollection:
FileUtils.download_and_extract_archive(logger, dep.url, dest, dep.archive_type)
else:
FileUtils.download_and_extract_archive(logger, dep.url, target_dir, dep.archive_type or "zip")
def quote_windows_path(path: str) -> str:
"""
Quote a path for Windows command execution if needed.
On Windows, paths need to be quoted for proper command execution.
The function checks if the path is already quoted to avoid double-quoting.
On other platforms, the path is returned unchanged.
Args:
path: The file path to potentially quote
Returns:
The quoted path on Windows (if not already quoted), unchanged path on other platforms
"""
if platform.system() == "Windows":
# Check if already quoted to avoid double-quoting
if path.startswith('"') and path.endswith('"'):
return path
return f'"{path}"'
return path
+5
View File
@@ -270,6 +270,11 @@ class SolidLanguageServer(ABC):
ls = ErlangLanguageServer(config, logger, repository_root_path, solidlsp_settings=solidlsp_settings)
elif config.code_language == Language.AL:
from solidlsp.language_servers.al_language_server import ALLanguageServer
ls = ALLanguageServer(config, logger, repository_root_path, solidlsp_settings=solidlsp_settings)
else:
logger.log(f"Language {config.code_language} is not supported", logging.ERROR)
raise SolidLSPException(f"Language {config.code_language} is not supported")
+3
View File
@@ -49,6 +49,7 @@ class Language(str, Enum):
LUA = "lua"
NIX = "nix"
ERLANG = "erlang"
AL = "al"
# Experimental or deprecated Language Servers
TYPESCRIPT_VTS = "typescript_vts"
"""Use the typescript language server through the natively bundled vscode extension via https://github.com/yioneko/vtsls"""
@@ -130,6 +131,8 @@ class Language(str, Enum):
return FilenameMatcher("*.nix")
case self.ERLANG:
return FilenameMatcher("*.erl", "*.hrl", "*.escript", "*.config", "*.app", "*.app.src")
case self.AL:
return FilenameMatcher("*.al", "*.dal")
case _:
raise ValueError(f"Unhandled language: {self}")
@@ -0,0 +1,31 @@
{
"id": "00000001-0000-0000-0000-000000000001",
"name": "Test AL Project",
"publisher": "Serena Test Publisher",
"version": "1.0.0.0",
"brief": "Test project for AL Language Server in Serena",
"description": "This project contains AL code samples for testing language server features",
"privacyStatement": "",
"EULA": "",
"help": "",
"url": "https://github.com/oraios/serena",
"logo": "",
"dependencies": [],
"screenshots": [],
"platform": "1.0.0.0",
"application": "26.0.0.0",
"idRanges": [
{
"from": 50000,
"to": 50100
}
],
"resourceExposurePolicy": {
"allowDebugging": true,
"allowDownloadingSource": true,
"includeSourceInSymbolFile": true
},
"runtime": "15.0",
"features": ["NoImplicitWith"],
"target": "Cloud"
}
@@ -0,0 +1,199 @@
codeunit 50000 CustomerMgt
{
Permissions = tabledata "TEST Customer" = rimd;
trigger OnRun()
begin
Message('Customer Management Codeunit');
end;
procedure CreateNewCustomer()
var
Customer: Record "TEST Customer";
CustomerCard: Page "TEST Customer Card";
begin
Customer.Init();
Customer.Insert(true);
CustomerCard.SetRecord(Customer);
CustomerCard.Run();
end;
procedure CreateCustomer(CustomerNo: Code[20]; CustomerName: Text[100]; CustomerType: Enum CustomerType): Boolean
var
Customer: Record "TEST Customer";
begin
if Customer.Get(CustomerNo) then
exit(false);
Customer.Init();
Customer."No." := CustomerNo;
Customer.Name := CustomerName;
Customer."Customer Type" := CustomerType;
Customer.UpdateSearchName();
Customer.Insert(true);
exit(true);
end;
procedure AssistEdit(var Customer: Record "TEST Customer"): Boolean
var
NoSeriesMgt: Codeunit NoSeriesManagement;
begin
with Customer do begin
if NoSeriesMgt.SelectSeries(GetNoSeriesCode(), '', "No. Series") then begin
NoSeriesMgt.SetSeries("No.");
exit(true);
end;
end;
exit(false);
end;
procedure TestNoSeries()
var
SalesSetup: Record "Sales & Receivables Setup";
begin
SalesSetup.Get();
SalesSetup.TestField("Customer Nos.");
end;
procedure InitNo(var Customer: Record "TEST Customer")
var
NoSeriesMgt: Codeunit NoSeriesManagement;
begin
TestNoSeries();
NoSeriesMgt.InitSeries(GetNoSeriesCode(), Customer."No. Series", 0D, Customer."No.", Customer."No. Series");
end;
procedure GetNoSeriesCode(): Code[20]
var
SalesSetup: Record "Sales & Receivables Setup";
begin
SalesSetup.Get();
exit(SalesSetup."Customer Nos.");
end;
procedure CalculateTotalBalance(): Decimal
var
Customer: Record "TEST Customer";
TotalBalance: Decimal;
begin
TotalBalance := 0;
if Customer.FindSet() then
repeat
Customer.CalcFields(Balance);
TotalBalance += Customer.Balance;
until Customer.Next() = 0;
exit(TotalBalance);
end;
procedure GetCustomerCount(CustomerType: Enum CustomerType): Integer
var
Customer: Record "TEST Customer";
begin
Customer.SetRange("Customer Type", CustomerType);
exit(Customer.Count());
end;
procedure BlockCustomersOverCreditLimit()
var
Customer: Record "TEST Customer";
BlockedCount: Integer;
begin
BlockedCount := 0;
if Customer.FindSet() then
repeat
Customer.CalcFields(Balance);
if (Customer."Credit Limit" > 0) and (Customer.Balance > Customer."Credit Limit") then begin
Customer.Blocked := true;
Customer.Modify(true);
BlockedCount += 1;
end;
until Customer.Next() = 0;
if BlockedCount > 0 then
Message('%1 customers blocked due to credit limit exceeded', BlockedCount);
end;
procedure GetPaymentProcessor(): Interface IPaymentProcessor
var
PaymentProcessorImpl: Codeunit PaymentProcessorImpl;
begin
exit(PaymentProcessorImpl);
end;
procedure SendCustomerStatement(CustomerNo: Code[20])
var
Customer: Record "TEST Customer";
ReportSelections: Record "Report Selections";
begin
if not Customer.Get(CustomerNo) then
Error('Customer %1 not found', CustomerNo);
Customer.SetRecFilter();
ReportSelections.SetRange(Usage, ReportSelections.Usage::"C.Statement");
ReportSelections.PrintForCust(ReportSelections.Usage::"C.Statement", Customer, 1);
end;
procedure ValidateEmail(Email: Text[80]): Boolean
var
MailMgt: Codeunit "Mail Management";
begin
exit(MailMgt.CheckValidEmailAddress(Email));
end;
procedure MergeCustomers(FromCustomerNo: Code[20]; ToCustomerNo: Code[20])
var
FromCustomer: Record "TEST Customer";
ToCustomer: Record "TEST Customer";
CustomerLedgerEntry: Record "Cust. Ledger Entry";
begin
if not FromCustomer.Get(FromCustomerNo) then
Error('Source customer %1 not found', FromCustomerNo);
if not ToCustomer.Get(ToCustomerNo) then
Error('Target customer %1 not found', ToCustomerNo);
// Transfer ledger entries
CustomerLedgerEntry.SetRange("Customer No.", FromCustomerNo);
if CustomerLedgerEntry.FindSet() then
repeat
CustomerLedgerEntry."Customer No." := ToCustomerNo;
CustomerLedgerEntry.Modify();
until CustomerLedgerEntry.Next() = 0;
// Delete source customer
FromCustomer.Delete(true);
Message('Customer %1 merged into %2', FromCustomerNo, ToCustomerNo);
end;
[EventSubscriber(ObjectType::Table, Database::"TEST Customer", OnAfterInsertEvent, '', true, true)]
local procedure OnAfterInsertCustomer(var Rec: Record "TEST Customer")
begin
LogCustomerChange(Rec, 'INSERT');
end;
[EventSubscriber(ObjectType::Table, Database::"TEST Customer", OnAfterModifyEvent, '', true, true)]
local procedure OnAfterModifyCustomer(var Rec: Record "TEST Customer")
begin
LogCustomerChange(Rec, 'MODIFY');
end;
local procedure LogCustomerChange(Customer: Record "TEST Customer"; ChangeType: Text[10])
var
ChangeLogEntry: Record "Change Log Entry";
begin
// Log customer changes for audit purposes
ChangeLogEntry.Init();
ChangeLogEntry."Table No." := Database::"TEST Customer";
ChangeLogEntry."Primary Key Field 1 Value" := Customer."No.";
ChangeLogEntry."Type of Change" := ChangeLogEntry."Type of Change"::Modification;
ChangeLogEntry."User ID" := UserId;
ChangeLogEntry."Date and Time" := CurrentDateTime;
if ChangeLogEntry.Insert() then;
end;
}
@@ -0,0 +1,73 @@
codeunit 50001 PaymentProcessorImpl implements IPaymentProcessor
{
procedure ProcessPayment(Customer: Record "TEST Customer"): Boolean
var
//PaymentEntry: Record "Payment Entry";
begin
// Implementation of payment processing
Customer.CalcFields(Balance);
if Customer.Balance <= 0 then
exit(false);
// PaymentEntry.Init();
// PaymentEntry."Customer No." := Customer."No.";
// PaymentEntry.Amount := Customer.Balance;
// PaymentEntry."Payment Date" := Today();
// PaymentEntry.Status := PaymentEntry.Status::Processed;
// if PaymentEntry.Insert(true) then begin
// Message('Payment processed for customer %1', Customer.Name);
// exit(true);
// end;
exit(false);
end;
procedure ValidatePaymentMethod(PaymentMethodCode: Code[10]): Boolean
var
PaymentMethod: Record "Payment Method";
begin
if PaymentMethodCode = '' then
exit(false);
exit(PaymentMethod.Get(PaymentMethodCode));
end;
procedure GetTransactionFee(Amount: Decimal): Decimal
var
FeePercentage: Decimal;
MinimumFee: Decimal;
begin
FeePercentage := 2.9; // 2.9% transaction fee
MinimumFee := 0.30; // Minimum fee
exit(Maximum(Amount * FeePercentage / 100, MinimumFee));
end;
procedure RefundPayment(TransactionID: Text[50]): Boolean
var
// PaymentEntry: Record "Payment Entry";
begin
// PaymentEntry.SetRange("Transaction ID", TransactionID);
// if PaymentEntry.FindFirst() then begin
// PaymentEntry.Status := PaymentEntry.Status::Refunded;
// PaymentEntry."Refund Date" := Today();
// PaymentEntry.Modify(true);
// Message('Payment refunded for transaction %1', TransactionID);
// exit(true);
// end;
Error('Transaction %1 not found', TransactionID);
end;
local procedure Maximum(Value1: Decimal; Value2: Decimal): Decimal
begin
if Value1 > Value2 then
exit(Value1)
else
exit(Value2);
end;
}
@@ -0,0 +1,35 @@
enum 50000 CustomerType
{
Extensible = true;
Caption = 'Customer Type';
value(0; "")
{
Caption = '';
}
value(1; Regular)
{
Caption = 'Regular';
}
value(2; Premium)
{
Caption = 'Premium';
}
value(3; VIP)
{
Caption = 'VIP';
}
value(4; Corporate)
{
Caption = 'Corporate';
}
value(5; Government)
{
Caption = 'Government';
}
}
@@ -0,0 +1,7 @@
interface IPaymentProcessor
{
procedure ProcessPayment(Customer: Record "TEST Customer"): Boolean;
procedure ValidatePaymentMethod(PaymentMethodCode: Code[10]): Boolean;
procedure GetTransactionFee(Amount: Decimal): Decimal;
procedure RefundPayment(TransactionID: Text[50]): Boolean;
}
@@ -0,0 +1,271 @@
page 50001 "TEST Customer Card"
{
Caption = 'Customer Card';
PageType = Card;
SourceTable = "TEST Customer";
RefreshOnActivate = true;
layout
{
area(Content)
{
group(General)
{
Caption = 'General';
field("No."; Rec."No.")
{
ApplicationArea = All;
ToolTip = 'Specifies the customer number.';
trigger OnAssistEdit()
begin
if CustomerMgt.AssistEdit(Rec) then
CurrPage.Update();
end;
}
field(Name; Rec.Name)
{
ApplicationArea = All;
ToolTip = 'Specifies the customer name.';
ShowMandatory = true;
}
field("Search Name"; Rec."Search Name")
{
ApplicationArea = All;
ToolTip = 'Specifies the search name.';
Visible = false;
}
field("Customer Type"; Rec."Customer Type")
{
ApplicationArea = All;
ToolTip = 'Specifies the type of customer.';
}
field(Blocked; Rec.Blocked)
{
ApplicationArea = All;
ToolTip = 'Specifies if the customer is blocked.';
}
field("Last Date Modified"; Rec."Last Date Modified")
{
ApplicationArea = All;
ToolTip = 'Specifies when the customer was last modified.';
Editable = false;
}
}
group(AddressAndContact)
{
Caption = 'Address & Contact';
field(Address; Rec.Address)
{
ApplicationArea = All;
ToolTip = 'Specifies the customer address.';
}
field("Address 2"; Rec."Address 2")
{
ApplicationArea = All;
ToolTip = 'Specifies additional address information.';
}
field(City; Rec.City)
{
ApplicationArea = All;
ToolTip = 'Specifies the city.';
}
field("Phone No."; Rec."Phone No.")
{
ApplicationArea = All;
ToolTip = 'Specifies the phone number.';
}
field("E-Mail"; Rec."E-Mail")
{
ApplicationArea = All;
ToolTip = 'Specifies the email address.';
ExtendedDatatype = EMail;
}
}
group(Invoicing)
{
Caption = 'Invoicing';
field("Credit Limit"; Rec."Credit Limit")
{
ApplicationArea = All;
ToolTip = 'Specifies the credit limit.';
}
field(Balance; Rec.Balance)
{
ApplicationArea = All;
ToolTip = 'Specifies the customer balance.';
DrillDownPageId = "Customer Ledger Entries";
}
field("Payment Terms Code"; Rec."Payment Terms Code")
{
ApplicationArea = All;
ToolTip = 'Specifies the payment terms.';
}
field("Currency Code"; Rec."Currency Code")
{
ApplicationArea = All;
ToolTip = 'Specifies the currency code.';
}
}
}
area(FactBoxes)
{
part(CustomerPicture; "Customer Picture")
{
ApplicationArea = All;
SubPageLink = "No." = field("No.");
}
systempart(Links; Links)
{
ApplicationArea = RecordLinks;
}
systempart(Notes; Notes)
{
ApplicationArea = Notes;
}
}
}
actions
{
area(Navigation)
{
group(Customer)
{
Caption = '&Customer';
action(LedgerEntries)
{
ApplicationArea = All;
Caption = 'Ledger E&ntries';
Image = CustomerLedger;
RunObject = page "Customer Ledger Entries";
RunPageLink = "Customer No." = field("No.");
RunPageView = sorting("Customer No.");
ShortcutKey = 'Ctrl+F7';
ToolTip = 'View the history of transactions for the customer.';
}
action(Statistics)
{
ApplicationArea = All;
Caption = 'Statistics';
Image = Statistics;
RunObject = page "Customer Statistics";
RunPageLink = "No." = field("No.");
ShortcutKey = 'F7';
ToolTip = 'View statistical information about the customer.';
}
}
}
area(Processing)
{
group(Functions)
{
Caption = 'F&unctions';
action(CheckCreditLimit)
{
ApplicationArea = All;
Caption = 'Check Credit Limit';
Image = Check;
ToolTip = 'Check if the customer has exceeded their credit limit.';
trigger OnAction()
begin
Rec.CheckCreditLimit();
end;
}
action(ProcessPayment)
{
ApplicationArea = All;
Caption = 'Process Payment';
Image = Payment;
ToolTip = 'Process a payment for this customer.';
trigger OnAction()
var
PaymentProcessor: Interface IPaymentProcessor;
begin
PaymentProcessor := CustomerMgt.GetPaymentProcessor();
PaymentProcessor.ProcessPayment(Rec);
end;
}
}
}
area(Promoted)
{
group(Category_Process)
{
Caption = 'Process';
actionref(CheckCreditLimit_Promoted; CheckCreditLimit)
{
}
actionref(ProcessPayment_Promoted; ProcessPayment)
{
}
}
group(Category_Customer)
{
Caption = 'Customer';
actionref(Statistics_Promoted; Statistics)
{
}
actionref(LedgerEntries_Promoted; LedgerEntries)
{
}
}
}
}
var
CustomerMgt: Codeunit CustomerMgt;
trigger OnOpenPage()
begin
Rec.SetRange("Customer Type");
end;
trigger OnAfterGetRecord()
begin
CheckCreditStatus();
end;
local procedure CheckCreditStatus()
begin
if Rec."Credit Limit" = 0 then
exit;
Rec.CalcFields(Balance);
if Rec.Balance > Rec."Credit Limit" then
Message('Warning: Customer has exceeded credit limit');
end;
}
@@ -0,0 +1,205 @@
page 50002 "TEST Customer List"
{
Caption = 'Customer List';
PageType = List;
ApplicationArea = All;
UsageCategory = Lists;
SourceTable = "TEST Customer";
CardPageId = "TEST Customer Card";
Editable = false;
layout
{
area(Content)
{
repeater(Group)
{
field("No."; Rec."No.")
{
ApplicationArea = All;
ToolTip = 'Specifies the customer number.';
}
field(Name; Rec.Name)
{
ApplicationArea = All;
ToolTip = 'Specifies the customer name.';
}
field(City; Rec.City)
{
ApplicationArea = All;
ToolTip = 'Specifies the city.';
}
field("Customer Type"; Rec."Customer Type")
{
ApplicationArea = All;
ToolTip = 'Specifies the type of customer.';
}
field("Phone No."; Rec."Phone No.")
{
ApplicationArea = All;
ToolTip = 'Specifies the phone number.';
}
field("E-Mail"; Rec."E-Mail")
{
ApplicationArea = All;
ToolTip = 'Specifies the email address.';
}
field(Balance; Rec.Balance)
{
ApplicationArea = All;
ToolTip = 'Specifies the customer balance.';
StyleExpr = BalanceStyleExpr;
}
field("Credit Limit"; Rec."Credit Limit")
{
ApplicationArea = All;
ToolTip = 'Specifies the credit limit.';
}
field(Blocked; Rec.Blocked)
{
ApplicationArea = All;
ToolTip = 'Specifies if the customer is blocked.';
}
}
}
area(FactBoxes)
{
systempart(Links; Links)
{
ApplicationArea = RecordLinks;
}
systempart(Notes; Notes)
{
ApplicationArea = Notes;
}
}
}
actions
{
area(Processing)
{
action(NewCustomer)
{
ApplicationArea = All;
Caption = 'New';
Image = NewCustomer;
ToolTip = 'Create a new customer.';
trigger OnAction()
begin
CustomerMgt.CreateNewCustomer();
end;
}
action(ExportToExcel)
{
ApplicationArea = All;
Caption = 'Export to Excel';
Image = ExportToExcel;
ToolTip = 'Export the customer list to Excel.';
trigger OnAction()
begin
ExportCustomersToExcel();
end;
}
}
area(Navigation)
{
action(ViewStatistics)
{
ApplicationArea = All;
Caption = 'Statistics';
Image = Statistics;
RunObject = page "Customer Statistics";
RunPageLink = "No." = field("No.");
ToolTip = 'View customer statistics.';
}
}
area(Promoted)
{
group(Category_New)
{
Caption = 'New';
actionref(NewCustomer_Promoted; NewCustomer)
{
}
}
group(Category_Process)
{
Caption = 'Process';
actionref(ExportToExcel_Promoted; ExportToExcel)
{
}
}
}
}
trigger OnAfterGetRecord()
begin
SetBalanceStyle();
end;
var
CustomerMgt: Codeunit CustomerMgt;
BalanceStyleExpr: Text;
local procedure SetBalanceStyle()
begin
BalanceStyleExpr := '';
Rec.CalcFields(Balance);
if (Rec."Credit Limit" <> 0) and (Rec.Balance > Rec."Credit Limit") then
BalanceStyleExpr := 'Unfavorable';
end;
local procedure ExportCustomersToExcel()
var
ExcelBuffer: Record "Excel Buffer" temporary;
RowNo: Integer;
begin
ExcelBuffer.Reset();
ExcelBuffer.DeleteAll();
// Add headers
RowNo := 1;
ExcelBuffer.AddColumn('Customer No.', false, '', false, false, false, '', ExcelBuffer."Cell Type"::Text);
ExcelBuffer.AddColumn('Name', false, '', false, false, false, '', ExcelBuffer."Cell Type"::Text);
ExcelBuffer.AddColumn('City', false, '', false, false, false, '', ExcelBuffer."Cell Type"::Text);
ExcelBuffer.AddColumn('Balance', false, '', false, false, false, '', ExcelBuffer."Cell Type"::Number);
ExcelBuffer.NewRow();
// Add data
if rec.FindSet() then
repeat
RowNo += 1;
rec.CalcFields(Balance);
ExcelBuffer.AddColumn(rec."No.", false, '', false, false, false, '', ExcelBuffer."Cell Type"::Text);
ExcelBuffer.AddColumn(rec.Name, false, '', false, false, false, '', ExcelBuffer."Cell Type"::Text);
ExcelBuffer.AddColumn(rec.City, false, '', false, false, false, '', ExcelBuffer."Cell Type"::Text);
ExcelBuffer.AddColumn(rec.Balance, false, '', false, false, false, '', ExcelBuffer."Cell Type"::Number);
ExcelBuffer.NewRow();
until rec.Next() = 0;
ExcelBuffer.CreateNewBook('Customers');
ExcelBuffer.WriteSheet('Customer List', CompanyName, UserId);
ExcelBuffer.CloseBook();
ExcelBuffer.OpenExcel();
end;
}
@@ -0,0 +1,79 @@
tableextension 50000 ItemExt extends Item
{
fields
{
field(50000; "Customer No."; Code[20])
{
Caption = 'Preferred Customer No.';
DataClassification = CustomerContent;
TableRelation = "TEST Customer";
trigger OnValidate()
var
Customer: Record "TEST Customer";
begin
if "Customer No." <> '' then begin
Customer.Get("Customer No.");
"Customer Name" := Customer.Name;
end else
"Customer Name" := '';
end;
}
field(50001; "Customer Name"; Text[100])
{
Caption = 'Preferred Customer Name';
DataClassification = CustomerContent;
Editable = false;
}
field(50002; "Special Discount %"; Decimal)
{
Caption = 'Special Discount %';
DataClassification = CustomerContent;
MinValue = 0;
MaxValue = 100;
}
field(50003; "Last Sale Date"; Date)
{
Caption = 'Last Sale Date';
DataClassification = CustomerContent;
Editable = false;
}
field(50004; "Total Sales Qty"; Decimal)
{
Caption = 'Total Sales Quantity';
FieldClass = FlowField;
CalcFormula = sum("Sales Line".Quantity where("No." = field("No."),
Type = const(Item)));
Editable = false;
}
}
keys
{
key(CustomerKey; "Customer No.")
{
}
}
procedure UpdateLastSaleDate()
begin
"Last Sale Date" := Today();
Modify();
end;
procedure GetSpecialPrice(Customer: Record "TEST Customer"): Decimal
var
BasePrice: Decimal;
begin
BasePrice := "Unit Price";
if "Customer No." = Customer."No." then
BasePrice := BasePrice * (1 - "Special Discount %" / 100);
exit(BasePrice);
end;
}
@@ -0,0 +1,210 @@
table 50000 "TEST Customer"
{
Caption = 'Customer';
DataClassification = CustomerContent;
fields
{
field(1; "No."; Code[20])
{
Caption = 'No.';
DataClassification = CustomerContent;
trigger OnValidate()
begin
if "No." <> xRec."No." then begin
CustomerMgt.TestNoSeries();
"No. Series" := '';
end;
end;
}
field(2; Name; Text[100])
{
Caption = 'Name';
DataClassification = CustomerContent;
trigger OnValidate()
begin
if Name <> xRec.Name then
UpdateSearchName();
end;
}
field(3; "Search Name"; Code[100])
{
Caption = 'Search Name';
DataClassification = CustomerContent;
}
field(4; Address; Text[100])
{
Caption = 'Address';
DataClassification = CustomerContent;
}
field(5; "Address 2"; Text[50])
{
Caption = 'Address 2';
DataClassification = CustomerContent;
}
field(6; City; Text[30])
{
Caption = 'City';
DataClassification = CustomerContent;
}
field(7; "Phone No."; Text[30])
{
Caption = 'Phone No.';
DataClassification = CustomerContent;
}
field(8; "E-Mail"; Text[80])
{
Caption = 'E-Mail';
DataClassification = CustomerContent;
trigger OnValidate()
var
MailMgt: Codeunit "Mail Management";
begin
MailMgt.CheckValidEmailAddresses("E-Mail");
end;
}
field(10; "Customer Type"; Enum CustomerType)
{
Caption = 'Customer Type';
DataClassification = CustomerContent;
}
field(11; Balance; Decimal)
{
Caption = 'Balance';
Editable = false;
FieldClass = FlowField;
CalcFormula = sum("Cust. Ledger Entry".Amount where("Customer No." = field("No.")));
}
field(12; "Credit Limit"; Decimal)
{
Caption = 'Credit Limit';
DataClassification = CustomerContent;
}
field(13; Blocked; Boolean)
{
Caption = 'Blocked';
DataClassification = CustomerContent;
}
field(14; "Last Date Modified"; Date)
{
Caption = 'Last Date Modified';
DataClassification = CustomerContent;
Editable = false;
}
field(15; "No. Series"; Code[20])
{
Caption = 'No. Series';
DataClassification = CustomerContent;
}
field(20; "Payment Terms Code"; Code[10])
{
Caption = 'Payment Terms Code';
DataClassification = CustomerContent;
TableRelation = "Payment Terms";
}
field(21; "Currency Code"; Code[10])
{
Caption = 'Currency Code';
DataClassification = CustomerContent;
TableRelation = Currency;
}
}
keys
{
key(PK; "No.")
{
Clustered = true;
}
key(SearchName; "Search Name")
{
}
key(CustomerType; "Customer Type", City)
{
}
}
fieldgroups
{
fieldgroup(DropDown; "No.", Name, City)
{
}
fieldgroup(Brick; "No.", Name, Balance)
{
}
}
trigger OnInsert()
begin
if "No." = '' then begin
CustomerMgt.TestNoSeries();
CustomerMgt.InitNo(Rec);
end;
"Last Date Modified" := Today();
end;
trigger OnModify()
begin
"Last Date Modified" := Today();
end;
trigger OnDelete()
var
CustomerLedgerEntry: Record "Cust. Ledger Entry";
begin
CustomerLedgerEntry.SetRange("Customer No.", "No.");
if not CustomerLedgerEntry.IsEmpty() then
Error('Cannot delete customer %1 with ledger entries', "No.");
end;
trigger OnRename()
begin
"Last Date Modified" := Today();
end;
var
CustomerMgt: Codeunit CustomerMgt;
procedure UpdateSearchName()
begin
"Search Name" := UpperCase(Name);
end;
procedure CheckCreditLimit()
var
CreditLimitExceeded: Boolean;
begin
CalcFields(Balance);
CreditLimitExceeded := (Balance > "Credit Limit") and ("Credit Limit" <> 0);
if CreditLimitExceeded then
Message('Credit limit exceeded for customer %1', "No.");
end;
procedure GetDisplayName(): Text
begin
exit(Name + ' (' + "No." + ')');
end;
}
+158
View File
@@ -0,0 +1,158 @@
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)
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}"
Generated
+416 -402
View File
File diff suppressed because it is too large. Load diff