diff --git a/CHANGELOG.md b/CHANGELOG.md index 03941763..9280692c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/src/solidlsp/language_servers/al_language_server.py b/src/solidlsp/language_servers/al_language_server.py index 021cacf1..3e1f0190 100644 --- a/src/solidlsp/language_servers/al_language_server.py +++ b/src/solidlsp/language_servers/al_language_server.py @@ -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: diff --git a/test/solidlsp/al/test_al_executable_layout.py b/test/solidlsp/al/test_al_executable_layout.py new file mode 100644 index 00000000..999bafa1 --- /dev/null +++ b/test/solidlsp/al/test_al_executable_layout.py @@ -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")