mirror of
https://github.com/tiennm99/serena.git
synced 2026-10-13 10:16:00 +00:00
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
This commit is contained in:
1 parent
a9b345ccfe
commit
4a44cba6c8
2 files changed
+192
-39
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
|
||||
@@ -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: <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)
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
@@ -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}"
|
||||
Reference in new issue
Block a user