Merge pull request #960 from SShadowS/al-display-name-normalization

feat(al): apply symbol name normalization in AL language server
This commit is contained in:
Dominik Jain authored and GitHub committed 2026-01-20 12:37:09 +01:00
commit d73d9951fd
2 files changed
+558 -37

No files matched your search

@@ -4,6 +4,7 @@ import logging
import os
import pathlib
import platform
import re
import stat
import time
import zipfile
@@ -12,8 +13,9 @@ 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 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
@@ -38,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: <ObjectType> [<ID>] (<QuotedName>|<UnquotedName>)
_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.
@@ -69,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:
"""
@@ -957,3 +1006,78 @@ 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.
"""
# 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)
# Normalize names by stripping AL object metadata, storing originals for hover
def normalize_name(symbol: UnifiedSymbolInformation) -> None:
original_name = symbol["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)
if original_name != normalized_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"]
self._al_original_names[(relative_file_path, line, char)] = original_name
symbol["name"] = normalized_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
@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.
"""
# 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
original_name = self._al_original_names.get((relative_file_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
+433 -36
View File
@@ -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 ALLanguageServer
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 ALLanguageServer._extract_al_display_name method."""
def test_table_with_quoted_name(self) -> None:
"""Test extraction from Table with quoted name."""
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 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 ALLanguageServer._extract_al_display_name("Codeunit 50000 CustomerMgt") == "CustomerMgt"
def test_enum_unquoted(self) -> None:
"""Test extraction from Enum with unquoted name."""
assert ALLanguageServer._extract_al_display_name("Enum 50000 CustomerType") == "CustomerType"
def test_interface_no_id(self) -> None:
"""Test extraction from Interface (no ID)."""
assert ALLanguageServer._extract_al_display_name("Interface IPaymentProcessor") == "IPaymentProcessor"
def test_table_extension(self) -> None:
"""Test extraction from TableExtension."""
assert ALLanguageServer._extract_al_display_name('TableExtension 50000 "Ext Customer"') == "Ext Customer"
def test_page_extension(self) -> None:
"""Test extraction from PageExtension."""
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 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 ALLanguageServer._extract_al_display_name('Report 50000 "Sales Invoice"') == "Sales Invoice"
def test_query_unquoted(self) -> None:
"""Test extraction from Query."""
assert ALLanguageServer._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,335 @@ 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}"
# 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}"
# 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}"
@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