Fix AL language server lookup in newer extension layouts (#2087)

The AL adapter built a single executable path with a platform-specific
subdirectory (`bin/win32/...` on Windows) and raised "executable not found"
when it was absent. Compare the two VSIX packages from the marketplace with
the URL the adapter itself uses:

  18.0.2242655 (Serena's pinned version): bin/{win32,linux,darwin}/<host>
  18.0.2732683 (the build reported in #2069):  bin/<host>, no platform subdirectories

decision(al): probe both layouts instead of making the path configurable, since the
  extension build Serena downloads and the one the user has in VS Code differ by design.
constraint(al): keep the platform subdirectory first so existing behaviour is unchanged
  for the pinned version; only a missing executable falls through.
learned(al): neither VSIX declares a targetPlatform, yet the newer package contains no unix or
  darwin binaries at all; why Microsoft changed the packaging is upstream of this fix.

The error message now lists every candidate it tried, so the next layout change upstream is
identifiable from the failure alone rather than by reading the adapter.

Fixes #2069

Co-authored-by: sxh313 <sxh313@users.noreply.github.com>
This commit is contained in:
sxhandsxh313 authored and GitHub committed 2026-09-23 15:43:28 +02:00
1 parent 15502cee56
commit 637ab7b7ee
3 files changed
+117 -14

No files matched your search

+6
View File
@@ -192,6 +192,12 @@ Status of the `main` branch. Changes prior to the next official version change w
symbols, which was applied outside the caches; the processing of language servers that post-process
symbols (e.g. Go, Nix, Fortran, F#, Vue) was therefore repeated on every request or, if it mutated
symbols in place, re-applied to already processed cached results
- Fix: the AL language server executable was only searched for in a platform-specific subdirectory of
the extension's `bin` directory (`bin/win32/...` on Windows). Some AL extension builds (e.g.
18.0.2732683, as opposed to the 18.0.2242655 that Serena pins) have no such subdirectories and
place the executable directly in `bin`, so activating an AL project failed with "AL Language
Server executable not found" for users whose VS Code extension was on such a build. Both layouts
are now probed, the platform subdirectory first (#2069)
CLI:
- Fix `project index-file` command not using only the relevant language server to index the given file (#1965)
@@ -36,6 +36,9 @@ INITIAL_AL_EXTENSION_SHA256 = "3971995e61a59dc4fcce4a65053072a67991ed624a16635c4
DEFAULT_AL_EXTENSION_VERSION = "18.0.2242655"
DEFAULT_AL_EXTENSION_SHA256 = "3971995e61a59dc4fcce4a65053072a67991ed624a16635c4f2911f12564b2b9"
# Base name of the language server executable within the extension; on Windows it is suffixed with ".exe"
AL_HOST_EXECUTABLE_NAME = "Microsoft.Dynamics.Nav.EditorServices.Host"
def _al_extension_sha(version: str) -> str | None:
if version == INITIAL_AL_EXTENSION_VERSION:
@@ -188,10 +191,8 @@ class ALLanguageServer(SolidLanguageServer):
3. Configures executable permissions on Unix systems
4. Returns the properly formatted command string
The AL Language Server executable is located in different paths based on the platform:
- Windows: bin/win32/Microsoft.Dynamics.Nav.EditorServices.Host.exe
- Linux: bin/linux/Microsoft.Dynamics.Nav.EditorServices.Host
- macOS: bin/darwin/Microsoft.Dynamics.Nav.EditorServices.Host
The executable lives in the extension's `bin` directory, whose internal layout depends on
the extension version; see `_get_executable_path_candidates`.
"""
system = platform.system()
@@ -209,11 +210,12 @@ class ALLanguageServer(SolidLanguageServer):
"3. Ensure internet connection for automatic download"
)
# Build executable path based on platform
executable_path = cls._get_executable_path(extension_path, system)
# Build the executable path, tolerating the layouts of the different extension versions
candidates = cls._get_executable_path_candidates(extension_path, system)
executable_path = next((path for path in candidates if os.path.isfile(path)), None)
if not os.path.exists(executable_path):
raise RuntimeError(f"AL Language Server executable not found at: {executable_path}")
if executable_path is None:
raise RuntimeError("AL Language Server executable not found. Looked for:\n" + "\n".join(f" - {path}" for path in candidates))
# Prepare and return the executable command
return cls._prepare_executable(executable_path, system)
@@ -289,26 +291,36 @@ class ALLanguageServer(SolidLanguageServer):
return None
@classmethod
def _get_executable_path(cls, extension_path: str, system: str) -> str:
def _get_executable_path_candidates(cls, extension_path: str, system: str) -> list[str]:
"""
Build platform-specific executable path.
Build the candidate paths of the language server executable for the given platform.
The AL extension has shipped the executable in two layouts: in the builds up to at least
18.0.2242655 it lies in a platform-specific subdirectory of `bin` (`bin/win32/...exe` on
Windows), whereas in build 18.0.2732683 the platform subdirectories are gone and it lies
directly in `bin`. Both are queried because the build that Serena downloads and the build
that the user has installed in VS Code need not be the same.
Args:
extension_path: Path to AL extension directory
system: Operating system name
Returns:
Full path to executable
Candidate paths to the executable, the platform subdirectory layout first
"""
if system == "Windows":
return os.path.join(extension_path, "bin", "win32", "Microsoft.Dynamics.Nav.EditorServices.Host.exe")
platform_dir, executable_name = "win32", AL_HOST_EXECUTABLE_NAME + ".exe"
elif system == "Linux":
return os.path.join(extension_path, "bin", "linux", "Microsoft.Dynamics.Nav.EditorServices.Host")
platform_dir, executable_name = "linux", AL_HOST_EXECUTABLE_NAME
elif system == "Darwin":
return os.path.join(extension_path, "bin", "darwin", "Microsoft.Dynamics.Nav.EditorServices.Host")
platform_dir, executable_name = "darwin", AL_HOST_EXECUTABLE_NAME
else:
raise RuntimeError(f"Unsupported platform: {system}")
return [
os.path.join(extension_path, "bin", platform_dir, executable_name),
os.path.join(extension_path, "bin", executable_name),
]
@classmethod
def _prepare_executable(cls, executable_path: str, system: str) -> str:
@@ -0,0 +1,85 @@
"""Tests for locating the AL language server executable within the VS Code extension.
The AL extension ships the executable in two different directory layouts depending on its build,
so both must be resolved without downloading the extension (see #2069).
"""
from pathlib import Path
from unittest.mock import patch
import pytest
from solidlsp.language_servers.al_language_server import ALLanguageServer
from solidlsp.ls_config import LanguageServerConfig, LanguageServerId
from solidlsp.settings import SolidLSPSettings
pytestmark = pytest.mark.al
# the name the AL extension ships in its `bin` directory, independent of Serena's constant
HOST_EXECUTABLE_NAME = "Microsoft.Dynamics.Nav.EditorServices.Host"
_PLATFORM_SUBDIR = {"Windows": "win32", "Linux": "linux", "Darwin": "darwin"}
def _executable_name(system: str) -> str:
return HOST_EXECUTABLE_NAME + (".exe" if system == "Windows" else "")
def _create_host(extension_root: Path, system: str, *, layout: str) -> Path:
"""Create a stub executable in either the versioned platform subdirectory or the flat `bin`
directory of the given extension root, returning its path.
"""
subdir = _PLATFORM_SUBDIR[system] if layout == "platform-subdir" else ""
host_path = extension_root.joinpath("bin", subdir, _executable_name(system))
host_path.parent.mkdir(parents=True, exist_ok=True)
host_path.write_bytes(b"stub")
return host_path
def _setup(extension_root: Path, system: str) -> str:
with (
patch("solidlsp.language_servers.al_language_server.platform.system", return_value=system),
patch.object(ALLanguageServer, "_find_al_extension", return_value=str(extension_root)),
):
config = LanguageServerConfig(ls_id=LanguageServerId.AL)
return ALLanguageServer._setup_runtime_dependencies(config, SolidLSPSettings())
@pytest.mark.parametrize("system", sorted(_PLATFORM_SUBDIR))
@pytest.mark.parametrize("layout", ["platform-subdir", "flat"])
def test_executable_is_resolved_in_either_extension_layout(tmp_path: Path, system: str, layout: str) -> None:
"""Regression test for #2069: builds >= 18.0.2732683 place the executable directly in `bin`,
while the layout up to 18.0.2242655 keeps it in a platform subdirectory.
"""
host_path = _create_host(tmp_path, system, layout=layout)
command = _setup(tmp_path, system)
assert str(host_path) in command
@pytest.mark.parametrize("system", sorted(_PLATFORM_SUBDIR))
def test_platform_subdir_layout_takes_precedence_when_both_are_present(tmp_path: Path, system: str) -> None:
flat_host = _create_host(tmp_path, system, layout="flat")
versioned_host = _create_host(tmp_path, system, layout="platform-subdir")
command = _setup(tmp_path, system)
assert str(versioned_host) in command
assert str(flat_host) not in command
def test_missing_executable_reports_every_candidate_layout(tmp_path: Path) -> None:
with pytest.raises(RuntimeError, match="AL Language Server executable not found") as exc_info:
_setup(tmp_path, "Windows")
message = str(exc_info.value)
assert str(tmp_path / "bin" / "win32" / f"{HOST_EXECUTABLE_NAME}.exe") in message
assert str(tmp_path / "bin" / f"{HOST_EXECUTABLE_NAME}.exe") in message
def test_unsupported_platform_is_rejected(tmp_path: Path) -> None:
_create_host(tmp_path, "Windows", layout="flat")
with pytest.raises(RuntimeError, match="Unsupported platform: FreeBSD"):
_setup(tmp_path, "FreeBSD")