From 4a44cba6c818303660d031ad8d9b9e876e8bb92f Mon Sep 17 00:00:00 2001 From: Torben Leth Date: Mon, 19 Jan 2026 21:43:16 +0100 Subject: [PATCH 1/5] feat(al): 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 - uniformly across all languages. 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. Changes: - Add extract_al_display_name() to parse AL object names - Override request_document_symbols() to normalize names at source - Update tests to expect normalized names --- .../language_servers/al_language_server.py | 72 +++++++- test/solidlsp/al/test_al_basic.py | 159 +++++++++++++----- 2 files changed, 192 insertions(+), 39 deletions(-) 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}" From 7af2fe3e2bf788ac2666a42b4184cbb2c6e1c133 Mon Sep 17 00:00:00 2001 From: Torben Leth Date: Tue, 20 Jan 2026 00:24:21 +0100 Subject: [PATCH 2/5] feat(al): inject full object name into hover and add path normalization - Override request_hover() to inject cached original names (e.g. 'Table 50000 "TEST Customer"') into hover responses, preserving object type and ID metadata that the AL LSP doesn't include - Add _normalize_path() for consistent cache keys across Windows/Unix path formats - Add comprehensive tests for hover injection and path normalization --- .../language_servers/al_language_server.py | 51 ++- test/solidlsp/al/test_al_basic.py | 314 ++++++++++++++++++ 2 files changed, 363 insertions(+), 2 deletions(-) diff --git a/src/solidlsp/language_servers/al_language_server.py b/src/solidlsp/language_servers/al_language_server.py index 4a304b21..bb66437c 100644 --- a/src/solidlsp/language_servers/al_language_server.py +++ b/src/solidlsp/language_servers/al_language_server.py @@ -13,6 +13,7 @@ from pathlib import Path import requests from overrides import override +from solidlsp import ls_types from solidlsp.language_servers.common import quote_windows_path from solidlsp.ls import DocumentSymbols, LSPFileBuffer, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig @@ -109,6 +110,14 @@ class ALLanguageServer(SolidLanguageServer): super().__init__(config, repository_root_path, ProcessLaunchInfo(cmd=cmd, cwd=repository_root_path), "al", solidlsp_settings) + # Cache mapping (file_path, line, char) -> original_full_name for hover injection + self._al_original_names: dict[tuple[str, int, int], str] = {} + + @staticmethod + def _normalize_path(path: str) -> str: + """Normalize file path for consistent cache key usage across platforms.""" + return path.replace("\\", "/") + @classmethod def _download_al_extension(cls, url: str, target_dir: str) -> bool: """ @@ -1013,9 +1022,21 @@ class ALLanguageServer(SolidLanguageServer): # 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 + # Normalize names by stripping AL object metadata, storing originals for hover def normalize_name(symbol: UnifiedSymbolInformation) -> None: - symbol["name"] = extract_al_display_name(symbol["name"]) + original_name = symbol["name"] + normalized_name = extract_al_display_name(original_name) + + # Store original name if it was normalized (for hover injection) + if original_name != normalized_name: + sel_range = symbol.get("selectionRange", {}) + start = sel_range.get("start", {}) + line = start.get("line", 0) + char = start.get("character", 0) + normalized_path = self._normalize_path(relative_file_path) + self._al_original_names[(normalized_path, line, char)] = original_name + + symbol["name"] = normalized_name # Process children recursively if symbol.get("children"): @@ -1027,3 +1048,29 @@ class ALLanguageServer(SolidLanguageServer): normalize_name(sym) return document_symbols + + @override + def request_hover(self, relative_file_path: str, line: int, column: int) -> ls_types.Hover | None: + """ + Override to inject original AL object name (with type and ID) into hover responses. + + When hovering over a symbol whose name was normalized, we prepend the original + full name (e.g., 'Table 50000 "TEST Customer"') to the hover content. + """ + hover = super().request_hover(relative_file_path, line, column) + + if hover is None: + return None + + # Check if we have an original name for this position (using normalized path for consistency) + normalized_path = self._normalize_path(relative_file_path) + original_name = self._al_original_names.get((normalized_path, line, column)) + + if original_name and "contents" in hover: + contents = hover["contents"] + if isinstance(contents, dict) and "value" in contents: + # Prepend the original full name to the hover content + prefix = f"**{original_name}**\n\n---\n\n" + contents["value"] = prefix + contents["value"] + + return hover diff --git a/test/solidlsp/al/test_al_basic.py b/test/solidlsp/al/test_al_basic.py index bbcdff76..d103aba2 100644 --- a/test/solidlsp/al/test_al_basic.py +++ b/test/solidlsp/al/test_al_basic.py @@ -242,3 +242,317 @@ class TestALLanguageServer: 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}" + + +@pytest.mark.al +class TestALHoverInjection: + """Tests for hover injection of original AL object names with type and ID.""" + + def _get_symbol_hover(self, language_server: SolidLanguageServer, file_path: str, symbol_name: str) -> tuple[dict | None, str | None]: + """Helper to get hover info for a symbol by name. + + Returns (hover_info, hover_value) tuple. + """ + symbols = language_server.request_document_symbols(file_path) + for sym in symbols.root_symbols: + if sym.get("name") == symbol_name: + sel_range = sym.get("selectionRange", {}) + start = sel_range.get("start", {}) + line = start.get("line", 0) + char = start.get("character", 0) + hover = language_server.request_hover(file_path, line, char) + if hover and "contents" in hover: + return hover, hover["contents"].get("value", "") + return hover, None + return None, None + + def _get_child_symbol_hover( + self, language_server: SolidLanguageServer, file_path: str, parent_name: str, child_name_contains: str + ) -> tuple[dict | None, str | None]: + """Helper to get hover info for a child symbol. + + Returns (hover_info, hover_value) tuple. + """ + symbols = language_server.request_document_symbols(file_path) + for sym in symbols.root_symbols: + if sym.get("name") == parent_name: + for child in sym.get("children", []): + if child_name_contains in child.get("name", ""): + sel_range = child.get("selectionRange", {}) + start = sel_range.get("start", {}) + line = start.get("line", 0) + char = start.get("character", 0) + hover = language_server.request_hover(file_path, line, char) + if hover and "contents" in hover: + return hover, hover["contents"].get("value", "") + return hover, None + return None, None + + @pytest.mark.parametrize("language_server", [Language.AL], indirect=True) + def test_hover_table_injects_full_name(self, language_server: SolidLanguageServer) -> None: + """Test that hovering over a Table symbol shows the full object name with ID.""" + file_path = os.path.join("src", "Tables", "Customer.Table.al") + hover, value = self._get_symbol_hover(language_server, file_path, "TEST Customer") + + assert hover is not None, "Hover should return a result for Table symbol" + assert value is not None, "Hover should have content" + assert '**Table 50000 "TEST Customer"**' in value, f"Hover should contain full Table name with ID. Got: {value[:200]}" + + @pytest.mark.parametrize("language_server", [Language.AL], indirect=True) + def test_hover_page_injects_full_name(self, language_server: SolidLanguageServer) -> None: + """Test that hovering over a Page symbol shows the full object name with ID.""" + file_path = os.path.join("src", "Pages", "CustomerCard.Page.al") + hover, value = self._get_symbol_hover(language_server, file_path, "TEST Customer Card") + + assert hover is not None, "Hover should return a result for Page symbol" + assert value is not None, "Hover should have content" + assert '**Page 50001 "TEST Customer Card"**' in value, f"Hover should contain full Page name with ID. Got: {value[:200]}" + + @pytest.mark.parametrize("language_server", [Language.AL], indirect=True) + def test_hover_codeunit_injects_full_name(self, language_server: SolidLanguageServer) -> None: + """Test that hovering over a Codeunit symbol shows the full object name with ID.""" + file_path = os.path.join("src", "Codeunits", "CustomerMgt.Codeunit.al") + hover, value = self._get_symbol_hover(language_server, file_path, "CustomerMgt") + + assert hover is not None, "Hover should return a result for Codeunit symbol" + assert value is not None, "Hover should have content" + assert "**Codeunit 50000 CustomerMgt**" in value, f"Hover should contain full Codeunit name with ID. Got: {value[:200]}" + + @pytest.mark.parametrize("language_server", [Language.AL], indirect=True) + def test_hover_enum_injects_full_name(self, language_server: SolidLanguageServer) -> None: + """Test that hovering over an Enum symbol shows the full object name with ID.""" + file_path = os.path.join("src", "Enums", "CustomerType.Enum.al") + hover, value = self._get_symbol_hover(language_server, file_path, "CustomerType") + + assert hover is not None, "Hover should return a result for Enum symbol" + assert value is not None, "Hover should have content" + assert "**Enum 50000 CustomerType**" in value, f"Hover should contain full Enum name with ID. Got: {value[:200]}" + + @pytest.mark.parametrize("language_server", [Language.AL], indirect=True) + def test_hover_interface_injects_full_name(self, language_server: SolidLanguageServer) -> None: + """Test that hovering over an Interface symbol shows the full object name (no ID for interfaces).""" + file_path = os.path.join("src", "Interfaces", "IPaymentProcessor.Interface.al") + hover, value = self._get_symbol_hover(language_server, file_path, "IPaymentProcessor") + + assert hover is not None, "Hover should return a result for Interface symbol" + assert value is not None, "Hover should have content" + assert "**Interface IPaymentProcessor**" in value, f"Hover should contain full Interface name. Got: {value[:200]}" + + @pytest.mark.parametrize("language_server", [Language.AL], indirect=True) + def test_hover_procedure_no_injection(self, language_server: SolidLanguageServer) -> None: + """Test that hovering over a procedure does NOT inject object name (procedures are not normalized).""" + file_path = os.path.join("src", "Codeunits", "CustomerMgt.Codeunit.al") + hover, value = self._get_child_symbol_hover(language_server, file_path, "CustomerMgt", "CreateCustomer") + + assert hover is not None, "Hover should return a result for procedure" + assert value is not None, "Hover should have content" + # Procedure hover should NOT start with ** (no injection) + assert not value.startswith("**"), f"Procedure hover should not have injected name. Got: {value[:200]}" + # But should contain procedure info + assert "CreateCustomer" in value, f"Hover should contain procedure name. Got: {value[:200]}" + + @pytest.mark.parametrize("language_server", [Language.AL], indirect=True) + def test_hover_field_no_injection(self, language_server: SolidLanguageServer) -> None: + """Test that hovering over a field does NOT inject object name (fields are not normalized).""" + file_path = os.path.join("src", "Tables", "Customer.Table.al") + symbols = language_server.request_document_symbols(file_path) + + # Navigate to a field: Table -> fields -> specific field + for sym in symbols.root_symbols: + if sym.get("name") == "TEST Customer": + for child in sym.get("children", []): + if child.get("name") == "fields": + for field in child.get("children", []): + if "Name" in field.get("name", ""): + sel_range = field.get("selectionRange", {}) + start = sel_range.get("start", {}) + line = start.get("line", 0) + char = start.get("character", 0) + hover = language_server.request_hover(file_path, line, char) + + assert hover is not None, "Hover should return a result for field" + value = hover.get("contents", {}).get("value", "") + # Field hover should NOT start with ** (no injection) + assert not value.startswith("**"), f"Field hover should not have injected name. Got: {value[:200]}" + return + + pytest.fail("Could not find a field to test hover on") + + @pytest.mark.parametrize("language_server", [Language.AL], indirect=True) + def test_hover_multiple_objects_correct_injection(self, language_server: SolidLanguageServer) -> None: + """Test that multiple AL objects each get their correct full name injected.""" + test_cases = [ + (os.path.join("src", "Tables", "Customer.Table.al"), "TEST Customer", 'Table 50000 "TEST Customer"'), + (os.path.join("src", "Codeunits", "CustomerMgt.Codeunit.al"), "CustomerMgt", "Codeunit 50000 CustomerMgt"), + (os.path.join("src", "Enums", "CustomerType.Enum.al"), "CustomerType", "Enum 50000 CustomerType"), + ] + + for file_path, symbol_name, expected_full_name in test_cases: + hover, value = self._get_symbol_hover(language_server, file_path, symbol_name) + + assert hover is not None, f"Hover should return a result for {symbol_name}" + assert value is not None, f"Hover should have content for {symbol_name}" + assert ( + f"**{expected_full_name}**" in value + ), f"Hover for {symbol_name} should contain '{expected_full_name}'. Got: {value[:200]}" + + @pytest.mark.parametrize("language_server", [Language.AL], indirect=True) + def test_hover_contains_separator_after_injection(self, language_server: SolidLanguageServer) -> None: + """Test that injected hover has a separator between injected name and original content.""" + file_path = os.path.join("src", "Tables", "Customer.Table.al") + hover, value = self._get_symbol_hover(language_server, file_path, "TEST Customer") + + assert hover is not None, "Hover should return a result" + assert value is not None, "Hover should have content" + # Should have the separator after the bold name + assert "---" in value, f"Hover should contain separator. Got: {value[:300]}" + # The separator should come after the injected name + bold_end = value.find("**", 2) # Find closing ** + separator_pos = value.find("---") + assert separator_pos > bold_end, "Separator should come after the injected name" + + @pytest.mark.parametrize("language_server", [Language.AL], indirect=True) + def test_hover_preserves_original_content(self, language_server: SolidLanguageServer) -> None: + """Test that the original hover content is preserved after the injected name.""" + file_path = os.path.join("src", "Tables", "Customer.Table.al") + hover, value = self._get_symbol_hover(language_server, file_path, "TEST Customer") + + assert hover is not None, "Hover should return a result" + assert value is not None, "Hover should have content" + # Original AL hover content should still be present (the table structure) + assert "```al" in value, f"Hover should contain original AL code block. Got: {value[:500]}" + assert 'Table "TEST Customer"' in value, f"Hover should contain original table definition. Got: {value[:500]}" + + +@pytest.mark.al +class TestALPathNormalization: + """Tests for path normalization in hover injection cache.""" + + @pytest.mark.parametrize("language_server", [Language.AL], indirect=True) + def test_hover_with_forward_slash_path(self, language_server: SolidLanguageServer) -> None: + """Test that hover injection works with forward slash paths.""" + file_path = "src/Tables/Customer.Table.al" + symbols = language_server.request_document_symbols(file_path) + + for sym in symbols.root_symbols: + if sym.get("name") == "TEST Customer": + sel_range = sym.get("selectionRange", {}) + start = sel_range.get("start", {}) + line = start.get("line", 0) + char = start.get("character", 0) + + hover = language_server.request_hover(file_path, line, char) + assert hover is not None, "Hover should return a result" + value = hover.get("contents", {}).get("value", "") + assert '**Table 50000 "TEST Customer"**' in value, f"Hover should have injection. Got: {value[:200]}" + return + + pytest.fail("Could not find TEST Customer symbol") + + @pytest.mark.parametrize("language_server", [Language.AL], indirect=True) + def test_hover_with_backslash_path(self, language_server: SolidLanguageServer) -> None: + """Test that hover injection works with backslash paths (Windows style).""" + file_path = "src\\Tables\\Customer.Table.al" + symbols = language_server.request_document_symbols(file_path) + + for sym in symbols.root_symbols: + if sym.get("name") == "TEST Customer": + sel_range = sym.get("selectionRange", {}) + start = sel_range.get("start", {}) + line = start.get("line", 0) + char = start.get("character", 0) + + hover = language_server.request_hover(file_path, line, char) + assert hover is not None, "Hover should return a result" + value = hover.get("contents", {}).get("value", "") + assert '**Table 50000 "TEST Customer"**' in value, f"Hover should have injection. Got: {value[:200]}" + return + + pytest.fail("Could not find TEST Customer symbol") + + @pytest.mark.parametrize("language_server", [Language.AL], indirect=True) + def test_hover_with_mixed_path_formats_symbols_backslash_hover_forward(self, language_server: SolidLanguageServer) -> None: + """Test hover works when symbols requested with backslash but hover with forward slash.""" + file_path_backslash = "src\\Tables\\Customer.Table.al" + file_path_forward = "src/Tables/Customer.Table.al" + + # Request symbols with backslash path + symbols = language_server.request_document_symbols(file_path_backslash) + + for sym in symbols.root_symbols: + if sym.get("name") == "TEST Customer": + sel_range = sym.get("selectionRange", {}) + start = sel_range.get("start", {}) + line = start.get("line", 0) + char = start.get("character", 0) + + # Request hover with forward slash path (different format) + hover = language_server.request_hover(file_path_forward, line, char) + assert hover is not None, "Hover should return a result" + value = hover.get("contents", {}).get("value", "") + assert ( + '**Table 50000 "TEST Customer"**' in value + ), f"Hover injection should work with mixed path formats. Got: {value[:200]}" + return + + pytest.fail("Could not find TEST Customer symbol") + + @pytest.mark.parametrize("language_server", [Language.AL], indirect=True) + def test_hover_with_mixed_path_formats_symbols_forward_hover_backslash(self, language_server: SolidLanguageServer) -> None: + """Test hover works when symbols requested with forward slash but hover with backslash.""" + file_path_forward = "src/Tables/Customer.Table.al" + file_path_backslash = "src\\Tables\\Customer.Table.al" + + # Request symbols with forward slash path + symbols = language_server.request_document_symbols(file_path_forward) + + for sym in symbols.root_symbols: + if sym.get("name") == "TEST Customer": + sel_range = sym.get("selectionRange", {}) + start = sel_range.get("start", {}) + line = start.get("line", 0) + char = start.get("character", 0) + + # Request hover with backslash path (different format) + hover = language_server.request_hover(file_path_backslash, line, char) + assert hover is not None, "Hover should return a result" + value = hover.get("contents", {}).get("value", "") + assert ( + '**Table 50000 "TEST Customer"**' in value + ), f"Hover injection should work with mixed path formats. Got: {value[:200]}" + return + + pytest.fail("Could not find TEST Customer symbol") + + @pytest.mark.parametrize("language_server", [Language.AL], indirect=True) + def test_hover_caching_multiple_files_different_path_formats(self, language_server: SolidLanguageServer) -> None: + """Test that hover injection cache works correctly across multiple files with different path formats.""" + test_cases = [ + ("src/Tables/Customer.Table.al", "src\\Tables\\Customer.Table.al", "TEST Customer", 'Table 50000 "TEST Customer"'), + ( + "src\\Codeunits\\CustomerMgt.Codeunit.al", + "src/Codeunits/CustomerMgt.Codeunit.al", + "CustomerMgt", + "Codeunit 50000 CustomerMgt", + ), + ] + + for symbols_path, hover_path, symbol_name, expected_injection in test_cases: + # Request symbols with one path format + symbols = language_server.request_document_symbols(symbols_path) + + for sym in symbols.root_symbols: + if sym.get("name") == symbol_name: + sel_range = sym.get("selectionRange", {}) + start = sel_range.get("start", {}) + line = start.get("line", 0) + char = start.get("character", 0) + + # Request hover with different path format + hover = language_server.request_hover(hover_path, line, char) + assert hover is not None, f"Hover should return a result for {symbol_name}" + value = hover.get("contents", {}).get("value", "") + assert ( + f"**{expected_injection}**" in value + ), f"Hover for {symbol_name} should have injection with mixed paths. Got: {value[:200]}" + break From b9f82774ce3a73b4d37acc41ad5fed96044a004b Mon Sep 17 00:00:00 2001 From: Torben Leth Date: Tue, 20 Jan 2026 01:08:50 +0100 Subject: [PATCH 3/5] fix(al): guard against missing position in name cache storage Only store the original name in the cache if we have valid position data. This prevents false cache matches at position (0, 0) when selectionRange is missing from a symbol. Addresses PR #960 review comment. --- .../language_servers/al_language_server.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/solidlsp/language_servers/al_language_server.py b/src/solidlsp/language_servers/al_language_server.py index bb66437c..c2bad1e2 100644 --- a/src/solidlsp/language_servers/al_language_server.py +++ b/src/solidlsp/language_servers/al_language_server.py @@ -1028,13 +1028,16 @@ class ALLanguageServer(SolidLanguageServer): normalized_name = extract_al_display_name(original_name) # Store original name if it was normalized (for hover injection) + # Only store if we have valid position data to avoid false matches at (0, 0) if original_name != normalized_name: - sel_range = symbol.get("selectionRange", {}) - start = sel_range.get("start", {}) - line = start.get("line", 0) - char = start.get("character", 0) - normalized_path = self._normalize_path(relative_file_path) - self._al_original_names[(normalized_path, line, char)] = original_name + sel_range = symbol.get("selectionRange") + if sel_range: + start = sel_range.get("start") + if start and "line" in start and "character" in start: + line = start["line"] + char = start["character"] + normalized_path = self._normalize_path(relative_file_path) + self._al_original_names[(normalized_path, line, char)] = original_name symbol["name"] = normalized_name From d05d8b765fca84199d22640492725fc4ca0c95b3 Mon Sep 17 00:00:00 2001 From: Torben Leth Date: Tue, 20 Jan 2026 01:15:48 +0100 Subject: [PATCH 4/5] refactor(al): move extract_al_display_name into ALLanguageServer class Move the AL name extraction pattern and function into the class as a static method since they are only used by the language server. Addresses PR #960 review comment. --- .../language_servers/al_language_server.py | 80 +++++++++---------- test/solidlsp/al/test_al_basic.py | 28 +++---- 2 files changed, 54 insertions(+), 54 deletions(-) diff --git a/src/solidlsp/language_servers/al_language_server.py b/src/solidlsp/language_servers/al_language_server.py index c2bad1e2..30ccbcaa 100644 --- a/src/solidlsp/language_servers/al_language_server.py +++ b/src/solidlsp/language_servers/al_language_server.py @@ -24,45 +24,6 @@ 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): """ @@ -79,6 +40,45 @@ class ALLanguageServer(SolidLanguageServer): - File opening requirement before symbol retrieval """ + # 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) + ) + + @staticmethod + 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 = ALLanguageServer._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 + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): """ Initialize the AL Language Server. @@ -1025,7 +1025,7 @@ class ALLanguageServer(SolidLanguageServer): # Normalize names by stripping AL object metadata, storing originals for hover def normalize_name(symbol: UnifiedSymbolInformation) -> None: original_name = symbol["name"] - normalized_name = extract_al_display_name(original_name) + normalized_name = self._extract_al_display_name(original_name) # Store original name if it was normalized (for hover injection) # Only store if we have valid position data to avoid false matches at (0, 0) diff --git a/test/solidlsp/al/test_al_basic.py b/test/solidlsp/al/test_al_basic.py index d103aba2..51bae0b7 100644 --- a/test/solidlsp/al/test_al_basic.py +++ b/test/solidlsp/al/test_al_basic.py @@ -4,7 +4,7 @@ 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.language_servers.al_language_server import ALLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_utils import SymbolUtils from test.conftest import language_tests_enabled @@ -13,49 +13,49 @@ pytestmark = [pytest.mark.al, pytest.mark.skipif(not language_tests_enabled(Lang class TestExtractALDisplayName: - """Tests for the extract_al_display_name function.""" + """Tests for the ALLanguageServer._extract_al_display_name method.""" 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" + assert ALLanguageServer._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" + assert ALLanguageServer._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" + assert ALLanguageServer._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" + assert ALLanguageServer._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" + assert ALLanguageServer._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" + assert ALLanguageServer._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" + assert ALLanguageServer._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" + assert ALLanguageServer._extract_al_display_name("fields") == "fields" + assert ALLanguageServer._extract_al_display_name("CreateCustomer") == "CreateCustomer" + assert ALLanguageServer._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" + assert ALLanguageServer._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" + assert ALLanguageServer._extract_al_display_name("Query 50000 CustomerQuery") == "CustomerQuery" @pytest.mark.al From 5ec66d81e2772426c2774547a586f6dd30b79501 Mon Sep 17 00:00:00 2001 From: Torben Leth Date: Tue, 20 Jan 2026 02:09:29 +0100 Subject: [PATCH 5/5] fix(al): normalize paths at method entry for cross-platform compatibility Move path normalization to the start of request_document_symbols and request_hover methods. This ensures backslash paths work on Linux/macOS where backslash is a literal character, not a path separator. --- .../language_servers/al_language_server.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/solidlsp/language_servers/al_language_server.py b/src/solidlsp/language_servers/al_language_server.py index 30ccbcaa..5dead90d 100644 --- a/src/solidlsp/language_servers/al_language_server.py +++ b/src/solidlsp/language_servers/al_language_server.py @@ -1019,6 +1019,9 @@ class ALLanguageServer(SolidLanguageServer): Metadata (object type, ID) is available via the hover LSP method when using include_info=True in find_symbol. """ + # Normalize path separators for cross-platform compatibility (backslash → forward slash) + relative_file_path = self._normalize_path(relative_file_path) + # Get symbols from parent implementation document_symbols = super().request_document_symbols(relative_file_path, file_buffer=file_buffer) @@ -1036,8 +1039,7 @@ class ALLanguageServer(SolidLanguageServer): if start and "line" in start and "character" in start: line = start["line"] char = start["character"] - normalized_path = self._normalize_path(relative_file_path) - self._al_original_names[(normalized_path, line, char)] = original_name + self._al_original_names[(relative_file_path, line, char)] = original_name symbol["name"] = normalized_name @@ -1060,14 +1062,16 @@ class ALLanguageServer(SolidLanguageServer): When hovering over a symbol whose name was normalized, we prepend the original full name (e.g., 'Table 50000 "TEST Customer"') to the hover content. """ + # Normalize path separators for cross-platform compatibility (backslash → forward slash) + relative_file_path = self._normalize_path(relative_file_path) + hover = super().request_hover(relative_file_path, line, column) if hover is None: return None - # Check if we have an original name for this position (using normalized path for consistency) - normalized_path = self._normalize_path(relative_file_path) - original_name = self._al_original_names.get((normalized_path, line, column)) + # Check if we have an original name for this position + original_name = self._al_original_names.get((relative_file_path, line, column)) if original_name and "contents" in hover: contents = hover["contents"]