mirror of
https://github.com/tiennm99/serena.git
synced 2026-10-13 00:15:05 +00:00
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:
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
|
||||
@@ -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
|
||||
Reference in new issue
Block a user