diff --git a/src/solidlsp/language_servers/al_language_server.py b/src/solidlsp/language_servers/al_language_server.py index d6052a2a..4a304b21 100644 --- a/src/solidlsp/language_servers/al_language_server.py +++ b/src/solidlsp/language_servers/al_language_server.py @@ -4,6 +4,7 @@ import logging import os import pathlib import platform +import re import stat import time import zipfile @@ -13,7 +14,7 @@ import requests from overrides import override from solidlsp.language_servers.common import quote_windows_path -from solidlsp.ls import SolidLanguageServer +from solidlsp.ls import DocumentSymbols, LSPFileBuffer, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.ls_types import SymbolKind, UnifiedSymbolInformation from solidlsp.lsp_protocol_handler.lsp_types import Definition, DefinitionParams, LocationLink @@ -22,6 +23,45 @@ from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) +# Regex pattern to match AL object names like: +# - 'Table 50000 "TEST Customer"' -> captures 'TEST Customer' +# - 'Codeunit 50000 CustomerMgt' -> captures 'CustomerMgt' +# - 'Interface IPaymentProcessor' -> captures 'IPaymentProcessor' +# - 'Enum 50000 CustomerType' -> captures 'CustomerType' +# Pattern: [] (|) +_AL_OBJECT_NAME_PATTERN = re.compile( + r"^(?:Table|Page|Codeunit|Enum|Interface|Report|Query|XMLPort|PermissionSet|" + r"PermissionSetExtension|Profile|PageExtension|TableExtension|EnumExtension|" + r"PageCustomization|ReportExtension|ControlAddin|DotNetPackage)" # Object type + r"(?:\s+\d+)?" # Optional object ID + r"\s+" # Required space before name + r'(?:"([^"]+)"|(\S+))$' # Quoted name (group 1) or unquoted identifier (group 2) +) + + +def extract_al_display_name(full_name: str) -> str: + """ + Extract the display name from an AL symbol's full name. + + AL Language Server returns symbol names in format: + - 'Table 50000 "TEST Customer"' -> 'TEST Customer' + - 'Codeunit 50000 CustomerMgt' -> 'CustomerMgt' + - 'Interface IPaymentProcessor' -> 'IPaymentProcessor' + - 'fields' -> 'fields' (non-AL-object symbols pass through unchanged) + + Args: + full_name: The full symbol name as returned by AL Language Server + + Returns: + The extracted display name for matching, or the original name if not an AL object + + """ + match = _AL_OBJECT_NAME_PATTERN.match(full_name) + if match: + # Return quoted name (group 1) or unquoted name (group 2) + return match.group(1) or match.group(2) or full_name + return full_name + class ALLanguageServer(SolidLanguageServer): """ @@ -957,3 +997,33 @@ class ALLanguageServer(SolidLanguageServer): except Exception as e: log.warning(f"Failed to set active workspace: {e}") # Non-critical error, continue operation + + @override + def request_document_symbols(self, relative_file_path: str, file_buffer: LSPFileBuffer | None = None) -> DocumentSymbols: + """ + Override to normalize AL symbol names by stripping object type and ID metadata. + + AL Language Server returns symbol names with full object format like + 'Table 50000 "TEST Customer"', but symbol names should be pure without metadata. + This follows the same pattern as Java LS which strips type information from names. + + Metadata (object type, ID) is available via the hover LSP method when using + include_info=True in find_symbol. + """ + # Get symbols from parent implementation + document_symbols = super().request_document_symbols(relative_file_path, file_buffer=file_buffer) + + # Normalize names by stripping AL object metadata + def normalize_name(symbol: UnifiedSymbolInformation) -> None: + symbol["name"] = extract_al_display_name(symbol["name"]) + + # Process children recursively + if symbol.get("children"): + for child in symbol["children"]: + normalize_name(child) + + # Apply to all root symbols + for sym in document_symbols.root_symbols: + normalize_name(sym) + + return document_symbols diff --git a/test/solidlsp/al/test_al_basic.py b/test/solidlsp/al/test_al_basic.py index 8b4fb7a7..bbcdff76 100644 --- a/test/solidlsp/al/test_al_basic.py +++ b/test/solidlsp/al/test_al_basic.py @@ -2,7 +2,9 @@ import os import pytest +from serena.symbol import LanguageServerSymbol from solidlsp import SolidLanguageServer +from solidlsp.language_servers.al_language_server import extract_al_display_name from solidlsp.ls_config import Language from solidlsp.ls_utils import SymbolUtils from test.conftest import language_tests_enabled @@ -10,37 +12,127 @@ from test.conftest import language_tests_enabled pytestmark = [pytest.mark.al, pytest.mark.skipif(not language_tests_enabled(Language.AL), reason="AL tests are disabled")] +class TestExtractALDisplayName: + """Tests for the extract_al_display_name function.""" + + def test_table_with_quoted_name(self) -> None: + """Test extraction from Table with quoted name.""" + assert extract_al_display_name('Table 50000 "TEST Customer"') == "TEST Customer" + + def test_page_with_quoted_name(self) -> None: + """Test extraction from Page with quoted name.""" + assert extract_al_display_name('Page 50001 "TEST Customer Card"') == "TEST Customer Card" + + def test_codeunit_unquoted(self) -> None: + """Test extraction from Codeunit with unquoted name.""" + assert extract_al_display_name("Codeunit 50000 CustomerMgt") == "CustomerMgt" + + def test_enum_unquoted(self) -> None: + """Test extraction from Enum with unquoted name.""" + assert extract_al_display_name("Enum 50000 CustomerType") == "CustomerType" + + def test_interface_no_id(self) -> None: + """Test extraction from Interface (no ID).""" + assert extract_al_display_name("Interface IPaymentProcessor") == "IPaymentProcessor" + + def test_table_extension(self) -> None: + """Test extraction from TableExtension.""" + assert extract_al_display_name('TableExtension 50000 "Ext Customer"') == "Ext Customer" + + def test_page_extension(self) -> None: + """Test extraction from PageExtension.""" + assert extract_al_display_name('PageExtension 50000 "My Page Ext"') == "My Page Ext" + + def test_non_al_object_unchanged(self) -> None: + """Test that non-AL-object names pass through unchanged.""" + assert extract_al_display_name("fields") == "fields" + assert extract_al_display_name("CreateCustomer") == "CreateCustomer" + assert extract_al_display_name("Name") == "Name" + + def test_report_with_quoted_name(self) -> None: + """Test extraction from Report.""" + assert extract_al_display_name('Report 50000 "Sales Invoice"') == "Sales Invoice" + + def test_query_unquoted(self) -> None: + """Test extraction from Query.""" + assert extract_al_display_name("Query 50000 CustomerQuery") == "CustomerQuery" + + @pytest.mark.al class TestALLanguageServer: + @pytest.mark.parametrize("language_server", [Language.AL], indirect=True) + def test_symbol_names_are_normalized(self, language_server: SolidLanguageServer) -> None: + """Test that AL symbol names are normalized (metadata stripped).""" + file_path = os.path.join("src", "Tables", "Customer.Table.al") + symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + + _all_symbols, root_symbols = symbols + customer_table = None + for sym in root_symbols: + if sym.get("name") == "TEST Customer": + customer_table = sym + break + + assert customer_table is not None, "Could not find 'TEST Customer' table symbol (name should be normalized)" + # Name should be just "TEST Customer", not "Table 50000 'TEST Customer'" + assert customer_table["name"] == "TEST Customer", f"Expected normalized name 'TEST Customer', got '{customer_table['name']}'" + + @pytest.mark.parametrize("language_server", [Language.AL], indirect=True) + def test_find_symbol_exact_match(self, language_server: SolidLanguageServer) -> None: + """Test that find_symbol can match AL symbols by normalized name without substring_matching.""" + file_path = os.path.join("src", "Tables", "Customer.Table.al") + symbols = language_server.request_document_symbols(file_path) + + # Find symbols that match 'TEST Customer' using LanguageServerSymbol.find() + for root in symbols.root_symbols: + ls_symbol = LanguageServerSymbol(root) + matches = ls_symbol.find("TEST Customer", substring_matching=False) + if matches: + assert len(matches) >= 1, "Should find at least one match for 'TEST Customer'" + assert matches[0].name == "TEST Customer", f"Expected 'TEST Customer', got '{matches[0].name}'" + return + + pytest.fail("Could not find 'TEST Customer' symbol by exact name match") + + @pytest.mark.parametrize("language_server", [Language.AL], indirect=True) + def test_find_codeunit_exact_match(self, language_server: SolidLanguageServer) -> None: + """Test finding a codeunit by its normalized name.""" + file_path = os.path.join("src", "Codeunits", "CustomerMgt.Codeunit.al") + symbols = language_server.request_document_symbols(file_path) + + for root in symbols.root_symbols: + ls_symbol = LanguageServerSymbol(root) + matches = ls_symbol.find("CustomerMgt", substring_matching=False) + if matches: + assert len(matches) >= 1 + assert matches[0].name == "CustomerMgt" + return + + pytest.fail("Could not find 'CustomerMgt' symbol by exact name match") + @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.""" + """Test that AL Language Server can find symbols in the test repository with normalized names.""" 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 table symbols - names should be normalized (no "Table 50000" prefix) + assert SymbolUtils.symbol_tree_contains_name(symbols, "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" + assert SymbolUtils.symbol_tree_contains_name(symbols, "TEST Customer Card"), "TEST Customer Card page not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "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, "CustomerMgt"), "CustomerMgt codeunit not found in symbol tree" assert SymbolUtils.symbol_tree_contains_name( - symbols, "Codeunit 50001 PaymentProcessorImpl" + symbols, "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" + assert SymbolUtils.symbol_tree_contains_name(symbols, "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" + assert SymbolUtils.symbol_tree_contains_name(symbols, "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: @@ -52,7 +144,7 @@ class TestALLanguageServer: customer_table = None _all_symbols, root_symbols = symbols for sym in root_symbols: - if "TEST Customer" in sym.get("name", ""): + if sym.get("name") == "TEST Customer": customer_table = sym break @@ -81,11 +173,11 @@ class TestALLanguageServer: file_path = os.path.join("src", "Codeunits", "CustomerMgt.Codeunit.al") symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() - # Find the codeunit symbol - AL returns 'Codeunit 50000 CustomerMgt' + # Find the codeunit symbol - name should be normalized to 'CustomerMgt' codeunit_symbol = None _all_symbols, root_symbols = symbols for sym in root_symbols: - if "CustomerMgt" in sym.get("name", ""): + if sym.get("name") == "CustomerMgt": codeunit_symbol = sym break @@ -95,7 +187,6 @@ class TestALLanguageServer: 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) @@ -105,11 +196,11 @@ class TestALLanguageServer: table_file = os.path.join("src", "Tables", "Customer.Table.al") symbols = language_server.request_document_symbols(table_file).get_all_symbols_and_roots() - # Find the Customer table symbol + # Find the Customer table symbol (name is normalized) customer_symbol = None _all_symbols, root_symbols = symbols for sym in root_symbols: - if "TEST Customer" in sym.get("name", ""): + if sym.get("name") == "TEST Customer": customer_symbol = sym break @@ -133,29 +224,21 @@ class TestALLanguageServer: # 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 = [] + # Count how many AL object symbols we found (names are now normalized) + al_object_names = [] - def collect_symbols(syms): + def collect_symbols(syms: list) -> None: 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) + # These are normalized names now, so just collect them + al_object_names.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}" + # We should find expected normalized names + assert "TEST Customer" in al_object_names, f"TEST Customer not found in: {al_object_names}" + assert "CustomerMgt" in al_object_names, f"CustomerMgt not found in: {al_object_names}" + assert "CustomerType" in al_object_names, f"CustomerType not found in: {al_object_names}"