From b3a6045f7e3f52024f8330f1e79aeb13c34417b0 Mon Sep 17 00:00:00 2001 From: StayBlue Date: Thu, 12 Mar 2026 03:58:45 -0700 Subject: [PATCH] Add Luau language support (#1152) --- README.md | 2 +- docs/01-about/020_programming-languages.md | 1 + docs/02-usage/050_configuration.md | 23 ++ pyproject.toml | 1 + src/serena/project.py | 2 +- src/solidlsp/language_servers/luau_lsp.py | 363 ++++++++++++++++++ src/solidlsp/ls_config.py | 13 + test/resources/repos/luau/test_repo/.luaurc | 9 + .../repos/luau/test_repo/src/init.luau | 26 ++ .../repos/luau/test_repo/src/module.luau | 12 + test/solidlsp/luau/__init__.py | 0 test/solidlsp/luau/test_luau_basic.py | 153 ++++++++ .../luau/test_luau_dependency_provider.py | 164 ++++++++ 13 files changed, 767 insertions(+), 2 deletions(-) create mode 100644 src/solidlsp/language_servers/luau_lsp.py create mode 100644 test/resources/repos/luau/test_repo/.luaurc create mode 100644 test/resources/repos/luau/test_repo/src/init.luau create mode 100644 test/resources/repos/luau/test_repo/src/module.luau create mode 100644 test/solidlsp/luau/__init__.py create mode 100644 test/solidlsp/luau/test_luau_basic.py create mode 100644 test/solidlsp/luau/test_luau_dependency_provider.py diff --git a/README.md b/README.md index 2b65ea9d..d99fab56 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ that implement the language server protocol (LSP). The underlying language servers are typically open-source projects (like Serena) or at least freely available for use. With Serena's LSP library, we provide **support for over 30 programming languages**, including -AL, Bash, C#, C/C++, Clojure, Dart, Elixir, Elm, Erlang, Fortran, GLSL, Go, Groovy (partial support), Haskell, HLSL, Java, Javascript, Julia, Kotlin, Lean 4, Lua, Markdown, MATLAB, Nix, OCaml, Perl, PHP, PowerShell, Python, R, Ruby, Rust, Scala, Swift, TOML, TypeScript, WGSL, YAML, and Zig. +AL, Bash, C#, C/C++, Clojure, Dart, Elixir, Elm, Erlang, Fortran, GLSL, Go, Groovy (partial support), Haskell, HLSL, Java, Javascript, Julia, Kotlin, Lean 4, Lua, Luau, Markdown, MATLAB, Nix, OCaml, Perl, PHP, PowerShell, Python, R, Ruby, Rust, Scala, Swift, TOML, TypeScript, WGSL, YAML, and Zig. > [!IMPORTANT] > Some language servers require additional dependencies to be installed; see the [Language Support](https://oraios.github.io/serena/01-about/020_programming-languages.html) page for details. diff --git a/docs/01-about/020_programming-languages.md b/docs/01-about/020_programming-languages.md index e70fd450..cf5a861c 100644 --- a/docs/01-about/020_programming-languages.md +++ b/docs/01-about/020_programming-languages.md @@ -68,6 +68,7 @@ Some languages require additional installations or setup steps, as noted. (requires `lean` and `lake` installed via [elan](https://github.com/leanprover/elan); uses the built-in Lean 4 LSP; the project must be a Lake project with `lake build` run before use) * **Lua** +* **Luau** * **Markdown** (must explicitly enable language `markdown`, primarily useful for documentation-heavy projects) * **Nix** diff --git a/docs/02-usage/050_configuration.md b/docs/02-usage/050_configuration.md index 67d8fec0..0c64233e 100644 --- a/docs/02-usage/050_configuration.md +++ b/docs/02-usage/050_configuration.md @@ -352,6 +352,29 @@ ls_specific_settings: jvm_options: "-Xmx4G -XX:+UseG1GC" # JVM options (default: -Xmx2G). Set to "" to disable. ``` +#### Luau + +Serena uses [`luau-lsp`](https://github.com/JohnnyMorganz/luau-lsp) for Luau support. + +**Runtime Requirements:** + +- `luau-lsp` is used from PATH if available. +- Otherwise, Serena downloads the pinned `luau-lsp` release for the current platform. + +**Configuration:** + +```yaml +ls_specific_settings: + luau: + ls_path: "/path/to/luau-lsp" # Optional: override the language server executable + platform: "roblox" # "roblox" (default) or "standard" + roblox_security_level: "PluginSecurity" # Roblox only: None, PluginSecurity, LocalUserSecurity, RobloxScriptSecurity +``` + +Notes: +- In `roblox` mode, Serena downloads Roblox definitions and Roblox API docs and passes them to `luau-lsp`. +- In `standard` mode, Serena skips Roblox definitions and only downloads the standard Luau docs bundle. + #### Pascal (`pasls`) Serena uses [pasls](https://github.com/genericptr/pascal-language-server) (Pascal Language Server) for Pascal/Free Pascal support. diff --git a/pyproject.toml b/pyproject.toml index a204b050..b462e4e7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -307,6 +307,7 @@ markers = [ "ruby: language server running for Ruby (uses ruby-lsp)", "zig: language server running for Zig", "lua: language server running for Lua", + "luau: language server running for Luau", "nix: language server running for Nix", "dart: language server running for Dart", "erlang: language server running for Erlang", diff --git a/src/serena/project.py b/src/serena/project.py index faf52ef3..48a624e4 100644 --- a/src/serena/project.py +++ b/src/serena/project.py @@ -337,7 +337,7 @@ class Project(ToStringMixin): return self._serena_data_folder def path_to_project_yml(self) -> str: - return self._serena_config.get_project_yml_location(self.project_root) + return self.serena_config.get_project_yml_location(self.project_root) def get_activation_message(self) -> str: """ diff --git a/src/solidlsp/language_servers/luau_lsp.py b/src/solidlsp/language_servers/luau_lsp.py new file mode 100644 index 00000000..9aaf254a --- /dev/null +++ b/src/solidlsp/language_servers/luau_lsp.py @@ -0,0 +1,363 @@ +""" +Provides Luau specific instantiation of the LanguageServer class using luau-lsp. + +Luau is the programming language used by Roblox, derived from Lua 5.1 with +additional features like type annotations, string interpolation, and more. +This uses JohnnyMorganz/luau-lsp as the language server backend. + +Requirements: + - luau-lsp binary must be installed and available in PATH, + or it will be automatically downloaded from GitHub releases. + +Advanced settings via ls_specific_settings["luau"]: + - platform: "roblox" (default) or "standard" + - roblox_security_level: "None", "PluginSecurity" (default), + "LocalUserSecurity", or "RobloxScriptSecurity" + +See: https://github.com/JohnnyMorganz/luau-lsp +""" + +import logging +import os +import pathlib +import platform +import shutil +import threading +import zipfile +from pathlib import Path + +import requests +from overrides import override + +from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer +from solidlsp.ls_config import LanguageServerConfig +from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams +from solidlsp.settings import SolidLSPSettings + +log = logging.getLogger(__name__) + +# Pin to a known stable release +LUAU_LSP_VERSION = "1.63.0" + +# Luau built-in docs CDN +LUAU_DOCS_URL = "https://luau-lsp.pages.dev/api-docs/luau-en-us.json" + +# Roblox type definitions and API docs CDN +ROBLOX_DOCS_URL = "https://luau-lsp.pages.dev/api-docs/en-us.json" +SUPPORTED_PLATFORMS = {"roblox", "standard"} +SUPPORTED_ROBLOX_SECURITY_LEVELS = { + "None", + "PluginSecurity", + "LocalUserSecurity", + "RobloxScriptSecurity", +} + + +class LuauLanguageServer(SolidLanguageServer): + """ + Provides Luau specific instantiation of the LanguageServer class using luau-lsp. + Luau is the programming language used by Roblox (a typed superset of Lua 5.1). + """ + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + return super().is_ignored_dirname(dirname) or dirname in [ + "node_modules", + "Packages", # Wally packages + "DevPackages", # Wally dev packages + "roblox_packages", # Some Rojo projects + "build", + "dist", + ".cache", + ] + + class DependencyProvider(LanguageServerDependencyProviderSinglePath): + def _get_or_install_core_dependency(self) -> str: + luau_lsp_path = shutil.which("luau-lsp") + if luau_lsp_path is not None: + return luau_lsp_path + return self._download_luau_lsp() + + def _create_launch_command(self, core_path: str) -> list[str]: + definitions_path, docs_path = self._resolve_support_files() + + cmd = [core_path, "lsp"] + if definitions_path is not None: + cmd.append(f"--definitions:@roblox={definitions_path}") + if docs_path is not None: + cmd.append(f"--docs={docs_path}") + return cmd + + def _download_luau_lsp(self) -> str: + install_dir = Path(self._ls_resources_dir) + install_dir.mkdir(parents=True, exist_ok=True) + + binary_path = self._find_existing_binary(install_dir) + if binary_path is not None: + return binary_path + + asset_name = self._get_luau_lsp_asset_name() + download_url = f"https://github.com/JohnnyMorganz/luau-lsp/releases/download/{LUAU_LSP_VERSION}/{asset_name}" + download_path = install_dir / asset_name + + log.info("Downloading luau-lsp %s from %s", LUAU_LSP_VERSION, download_url) + with requests.get(download_url, stream=True, timeout=60) as response: + response.raise_for_status() + with open(download_path, "wb") as f: + for chunk in response.iter_content(chunk_size=8192): + if chunk: + f.write(chunk) + + log.info("Extracting luau-lsp to %s", install_dir) + with zipfile.ZipFile(download_path, "r") as zip_ref: + zip_ref.extractall(install_dir) + + if download_path.exists(): + download_path.unlink() + + binary_path = self._find_existing_binary(install_dir) + if binary_path is None: + raise RuntimeError("Failed to find luau-lsp executable after extraction") + + return binary_path + + def _resolve_support_files(self) -> tuple[str | None, str | None]: + platform_type = LuauLanguageServer._get_platform_type(self._custom_settings) + if platform_type == "standard": + return None, self._download_standard_docs() + + security_level = LuauLanguageServer._get_roblox_security_level(self._custom_settings) + return self._download_roblox_support_files(security_level) + + def _download_standard_docs(self) -> str | None: + install_dir = Path(self._ls_resources_dir) + install_dir.mkdir(parents=True, exist_ok=True) + + return self._download_auxiliary_file( + install_dir / "luau-en-us.json", + LUAU_DOCS_URL, + "Luau API docs", + ) + + def _download_roblox_support_files(self, security_level: str) -> tuple[str | None, str | None]: + install_dir = Path(self._ls_resources_dir) + install_dir.mkdir(parents=True, exist_ok=True) + + definitions_filename = f"globalTypes.{security_level}.d.luau" + definitions_path = self._download_auxiliary_file( + install_dir / definitions_filename, + f"https://luau-lsp.pages.dev/type-definitions/{definitions_filename}", + "Roblox type definitions", + ) + docs_path = self._download_auxiliary_file( + install_dir / "en-us.json", + ROBLOX_DOCS_URL, + "Roblox API docs", + ) + + return definitions_path, docs_path + + @staticmethod + def _download_auxiliary_file(path: Path, url: str, description: str) -> str | None: + if path.exists(): + return str(path) + + try: + log.info("Downloading %s from %s", description, url) + response = requests.get(url, timeout=30) + response.raise_for_status() + path.write_bytes(response.content) + return str(path) + except Exception as exc: + log.warning("Failed to download %s: %s", description, exc) + return None + + @classmethod + def _find_existing_binary(cls, install_dir: Path) -> str | None: + binary_name = cls._get_binary_name() + direct_path = install_dir / binary_name + if direct_path.exists(): + cls._ensure_executable_bit(direct_path) + return str(direct_path) + + for candidate in install_dir.rglob(binary_name): + if candidate.is_file(): + cls._ensure_executable_bit(candidate) + return str(candidate) + + return None + + @staticmethod + def _ensure_executable_bit(binary_path: Path) -> None: + if platform.system() != "Windows": + binary_path.chmod(0o755) + + @staticmethod + def _get_binary_name() -> str: + return "luau-lsp.exe" if platform.system() == "Windows" else "luau-lsp" + + @staticmethod + def _get_luau_lsp_asset_name() -> str: + system = platform.system() + machine = platform.machine().lower() + + if system == "Linux": + if machine in ["x86_64", "amd64"]: + return "luau-lsp-linux-x86_64.zip" + if machine in ["aarch64", "arm64"]: + return "luau-lsp-linux-arm64.zip" + raise RuntimeError( + f"Unsupported Linux architecture: {machine}. " + "luau-lsp only provides linux-x86_64 and linux-arm64 binaries. " + "Please build from source: https://github.com/JohnnyMorganz/luau-lsp" + ) + if system == "Darwin": + return "luau-lsp-macos.zip" + if system == "Windows": + return "luau-lsp-win64.zip" + raise RuntimeError(f"Unsupported operating system: {system}") + + @staticmethod + def _get_platform_type(custom_settings: SolidLSPSettings.CustomLSSettings) -> str: + platform_type = custom_settings.get("platform", "roblox") + if platform_type not in SUPPORTED_PLATFORMS: + raise ValueError(f"Unsupported Luau platform: {platform_type}. Expected one of: {', '.join(sorted(SUPPORTED_PLATFORMS))}") + return platform_type + + @staticmethod + def _get_roblox_security_level(custom_settings: SolidLSPSettings.CustomLSSettings) -> str: + security_level = custom_settings.get("roblox_security_level", "PluginSecurity") + if security_level not in SUPPORTED_ROBLOX_SECURITY_LEVELS: + raise ValueError( + f"Unsupported Luau Roblox security level: {security_level}. " + f"Expected one of: {', '.join(sorted(SUPPORTED_ROBLOX_SECURITY_LEVELS))}" + ) + return security_level + + @classmethod + def _get_workspace_configuration(cls, custom_settings: SolidLSPSettings.CustomLSSettings) -> dict[str, dict[str, str]]: + return {"platform": {"type": cls._get_platform_type(custom_settings)}} + + def _create_dependency_provider(self) -> LanguageServerDependencyProvider: + return self.DependencyProvider(self._custom_settings, self._ls_resources_dir) + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + super().__init__(config, repository_root_path, None, "luau", solidlsp_settings) + self.server_ready = threading.Event() + + @staticmethod + def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + """ + Returns the initialize params for the Luau Language Server. + """ + root_uri = pathlib.Path(repository_absolute_path).as_uri() + initialize_params = { + "locale": "en", + "capabilities": { + "textDocument": { + "synchronization": {"didSave": True, "dynamicRegistration": True}, + "definition": {"dynamicRegistration": True}, + "references": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "hierarchicalDocumentSymbolSupport": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + "completion": { + "dynamicRegistration": True, + "completionItem": { + "snippetSupport": True, + "commitCharactersSupport": True, + "documentationFormat": ["markdown", "plaintext"], + "deprecatedSupport": True, + "preselectSupport": True, + }, + }, + "hover": { + "dynamicRegistration": True, + "contentFormat": ["markdown", "plaintext"], + }, + "signatureHelp": { + "dynamicRegistration": True, + "signatureInformation": { + "documentationFormat": ["markdown", "plaintext"], + "parameterInformation": {"labelOffsetSupport": True}, + }, + }, + "rename": {"dynamicRegistration": True, "prepareSupport": True}, + "callHierarchy": {"dynamicRegistration": True}, + }, + "workspace": { + "workspaceFolders": True, + "didChangeConfiguration": {"dynamicRegistration": True}, + "configuration": True, + "symbol": { + "dynamicRegistration": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + }, + }, + "processId": os.getpid(), + "rootPath": repository_absolute_path, + "rootUri": root_uri, + "workspaceFolders": [ + { + "uri": root_uri, + "name": os.path.basename(repository_absolute_path), + } + ], + # luau-lsp initialization options + # These can be overridden via .luaurc in the project root + "initializationOptions": {}, + } + return initialize_params # type: ignore[return-value] + + def _start_server(self) -> None: + """Start Luau Language Server process""" + + def register_capability_handler(params: dict) -> None: + return + + def workspace_configuration_handler(params: dict) -> list: + items = params.get("items", []) + config = self._get_workspace_configuration(self._custom_settings) + return [config for _ in items] + + def window_log_message(msg: dict) -> None: + message_text = msg.get("message", "") + log.info("LSP: window/logMessage: %s", message_text) + if "workspace ready" in message_text.lower() or "initialized" in message_text.lower(): + log.info("Luau language server signaled readiness") + self.server_ready.set() + + def do_nothing(params: dict) -> None: + return + + self.server.on_request("client/registerCapability", register_capability_handler) + self.server.on_request("workspace/configuration", workspace_configuration_handler) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + + log.info("Starting Luau Language Server process") + self.server.start() + initialize_params = self._get_initialize_params(self.repository_root_path) + + log.info("Sending initialize request from LSP client to LSP server and awaiting response") + init_response = self.server.send.initialize(initialize_params) + + # Verify server capabilities + assert "textDocumentSync" in init_response["capabilities"] + assert "definitionProvider" in init_response["capabilities"] + assert "documentSymbolProvider" in init_response["capabilities"] + assert "referencesProvider" in init_response["capabilities"] + + self.server.notify.initialized({}) + + # Wait for luau-lsp to complete initial setup + log.info("Waiting for Luau language server to become ready...") + if self.server_ready.wait(timeout=5.0): + log.info("Luau language server ready") + else: + log.warning("Timeout waiting for Luau language server readiness, proceeding anyway") + self.server_ready.set() diff --git a/src/solidlsp/ls_config.py b/src/solidlsp/ls_config.py index 00be0c7c..13af28ae 100644 --- a/src/solidlsp/ls_config.py +++ b/src/solidlsp/ls_config.py @@ -53,6 +53,11 @@ class Language(str, Enum): BASH = "bash" ZIG = "zig" LUA = "lua" + LUAU = "luau" + """Luau Language Server for Roblox's Luau language (typed Lua 5.1 superset). + Uses luau-lsp by JohnnyMorganz. Automatically downloads the binary if not found. + Supports .luau files. Configure via .luaurc in the project root. + """ NIX = "nix" ERLANG = "erlang" OCAML = "ocaml" @@ -221,6 +226,8 @@ class Language(str, Enum): return FilenameMatcher("*.zig", "*.zon") case self.LUA: return FilenameMatcher("*.lua") + case self.LUAU: + return FilenameMatcher("*.luau") case self.NIX: return FilenameMatcher("*.nix") case self.ERLANG: @@ -407,6 +414,12 @@ class Language(str, Enum): from solidlsp.language_servers.lua_ls import LuaLanguageServer return LuaLanguageServer + + case self.LUAU: + from solidlsp.language_servers.luau_lsp import LuauLanguageServer + + return LuauLanguageServer + case self.ERLANG: from solidlsp.language_servers.erlang_language_server import ErlangLanguageServer diff --git a/test/resources/repos/luau/test_repo/.luaurc b/test/resources/repos/luau/test_repo/.luaurc new file mode 100644 index 00000000..70eed5e8 --- /dev/null +++ b/test/resources/repos/luau/test_repo/.luaurc @@ -0,0 +1,9 @@ +{ + "languageMode": "strict", + "lint": { + "*": true + }, + "aliases": { + "Packages": "Packages/" + } +} \ No newline at end of file diff --git a/test/resources/repos/luau/test_repo/src/init.luau b/test/resources/repos/luau/test_repo/src/init.luau new file mode 100644 index 00000000..e8085945 --- /dev/null +++ b/test/resources/repos/luau/test_repo/src/init.luau @@ -0,0 +1,26 @@ +local module = require("./module") + +export type Config = { + name: string, + value: number, + enabled: boolean, +} + +local function createConfig(name: string, value: number): Config + return { + name = name, + value = value, + enabled = true, + } +end + +local function main() + local config = createConfig("test", 42) + local result = module.process(config) + print(result) +end + +return { + createConfig = createConfig, + main = main, +} \ No newline at end of file diff --git a/test/resources/repos/luau/test_repo/src/module.luau b/test/resources/repos/luau/test_repo/src/module.luau new file mode 100644 index 00000000..09170f3d --- /dev/null +++ b/test/resources/repos/luau/test_repo/src/module.luau @@ -0,0 +1,12 @@ +local function process(data: { name: string, value: number }): string + return `Processing {data.name} with value {data.value}` +end + +local function helper(x: number, y: number): number + return x + y +end + +return { + process = process, + helper = helper, +} \ No newline at end of file diff --git a/test/solidlsp/luau/__init__.py b/test/solidlsp/luau/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test/solidlsp/luau/test_luau_basic.py b/test/solidlsp/luau/test_luau_basic.py new file mode 100644 index 00000000..264ace92 --- /dev/null +++ b/test/solidlsp/luau/test_luau_basic.py @@ -0,0 +1,153 @@ +""" +Tests for the Luau language server implementation. + +These tests validate symbol finding, within-file references, +and cross-file reference capabilities for Luau modules and functions. +""" + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language + + +@pytest.mark.luau +class TestLuauLanguageServer: + """Test Luau language server symbol finding and cross-file references.""" + + @pytest.mark.parametrize("language_server", [Language.LUAU], indirect=True) + def test_find_symbols_in_init(self, language_server: SolidLanguageServer) -> None: + """Test finding specific functions in init.luau.""" + symbols = language_server.request_document_symbols("src/init.luau").get_all_symbols_and_roots() + + assert symbols is not None + assert len(symbols) > 0 + + symbol_list = symbols[0] if isinstance(symbols, tuple) else symbols + symbol_names = set() + for symbol in symbol_list: + if isinstance(symbol, dict): + name = symbol.get("name", "") + symbol_names.add(name) + + assert "createConfig" in symbol_names, f"createConfig not found in symbols: {symbol_names}" + assert "main" in symbol_names, f"main not found in symbols: {symbol_names}" + + @pytest.mark.parametrize("language_server", [Language.LUAU], indirect=True) + def test_find_symbols_in_module(self, language_server: SolidLanguageServer) -> None: + """Test finding specific functions in module.luau.""" + symbols = language_server.request_document_symbols("src/module.luau").get_all_symbols_and_roots() + + assert symbols is not None + assert len(symbols) > 0 + + symbol_list = symbols[0] if isinstance(symbols, tuple) else symbols + symbol_names = set() + for symbol in symbol_list: + if isinstance(symbol, dict): + name = symbol.get("name", "") + symbol_names.add(name) + + assert "process" in symbol_names, f"process not found in symbols: {symbol_names}" + assert "helper" in symbol_names, f"helper not found in symbols: {symbol_names}" + + @pytest.mark.parametrize("language_server", [Language.LUAU], indirect=True) + def test_find_references_within_file(self, language_server: SolidLanguageServer) -> None: + """Test finding within-file references to createConfig in init.luau. + + createConfig is defined at line 8 (0-indexed) and referenced at lines 17 and 23. + """ + symbols = language_server.request_document_symbols("src/init.luau").get_all_symbols_and_roots() + + assert symbols is not None + symbol_list = symbols[0] if isinstance(symbols, tuple) else symbols + + # Find the createConfig function symbol + create_config_symbol = None + for sym in symbol_list: + if isinstance(sym, dict) and sym.get("name") == "createConfig": + create_config_symbol = sym + break + + assert create_config_symbol is not None, "createConfig function not found in init.luau" + + range_info = create_config_symbol.get("selectionRange", create_config_symbol.get("range")) + assert range_info is not None, "createConfig has no range information" + + range_start = range_info["start"] + refs = language_server.request_references("src/init.luau", range_start["line"], range_start["character"]) + + assert refs is not None + assert isinstance(refs, list) + # createConfig appears multiple times within init.luau: + # definition (line 8), usage in main (line 17), and return table (line 23) + assert len(refs) >= 2, f"Should find at least 2 references to createConfig, found {len(refs)}" + + # Verify that references are in init.luau + ref_files = set() + for ref in refs: + filename = ref.get("uri", "").split("/")[-1] + ref_files.add(filename) + + assert "init.luau" in ref_files, f"Expected references in init.luau, found in: {ref_files}" + + @pytest.mark.parametrize("language_server", [Language.LUAU], indirect=True) + def test_find_references_across_files(self, language_server: SolidLanguageServer) -> None: + """Test finding cross-file references to process function. + + process is defined in module.luau and used in init.luau via module.process(). + """ + symbols = language_server.request_document_symbols("src/module.luau").get_all_symbols_and_roots() + + assert symbols is not None + symbol_list = symbols[0] if isinstance(symbols, tuple) else symbols + + # Find the process function symbol + process_symbol = None + for sym in symbol_list: + if isinstance(sym, dict) and sym.get("name") == "process": + process_symbol = sym + break + + assert process_symbol is not None, "process function not found in module.luau" + + range_info = process_symbol.get("selectionRange", process_symbol.get("range")) + assert range_info is not None, "process function has no range information" + + range_start = range_info["start"] + refs = language_server.request_references("src/module.luau", range_start["line"], range_start["character"]) + + assert refs is not None + assert isinstance(refs, list) + assert len(refs) >= 1, f"Should find at least 1 reference to process, found {len(refs)}" + + # Collect reference files and lines + ref_info: dict[str, list[int]] = {} + for ref in refs: + filename = ref.get("uri", "").split("/")[-1] + if filename not in ref_info: + ref_info[filename] = [] + ref_info[filename].append(ref["range"]["start"]["line"]) + + # The definition in module.luau may or may not be included + # We expect at least the reference in module.luau return table (line 9) + assert "module.luau" in ref_info, f"Expected references in module.luau, found in: {set(ref_info.keys())}" + + @pytest.mark.parametrize("language_server", [Language.LUAU], indirect=True) + def test_find_definition(self, language_server: SolidLanguageServer) -> None: + """Test finding definition of createConfig from its usage in main(). + + createConfig is used at line 17, column 20 (0-indexed) in init.luau. + Its definition should be at line 8 in init.luau. + """ + # Line 17 (0-indexed): ` local config = createConfig("test", 42)` + # createConfig starts at column 20 + definition_locations = language_server.request_definition("src/init.luau", 17, 20) + + assert definition_locations, f"Expected non-empty definition list but got {definition_locations}" + assert len(definition_locations) >= 1 + + definition = definition_locations[0] + assert definition["uri"].endswith("init.luau"), f"Definition should be in init.luau, got: {definition['uri']}" + # createConfig is defined at line 8 (0-indexed): `local function createConfig(...)` + assert definition["range"]["start"]["line"] == 8, f"Definition should be at line 8, got line {definition['range']['start']['line']}" diff --git a/test/solidlsp/luau/test_luau_dependency_provider.py b/test/solidlsp/luau/test_luau_dependency_provider.py new file mode 100644 index 00000000..35ad9de3 --- /dev/null +++ b/test/solidlsp/luau/test_luau_dependency_provider.py @@ -0,0 +1,164 @@ +"""Tests for the Luau language server dependency provider.""" + +import io +import zipfile +from pathlib import Path +from unittest.mock import patch + +import pytest + +from solidlsp.language_servers.luau_lsp import LuauLanguageServer +from solidlsp.settings import SolidLSPSettings + + +def _make_provider( + tmp_path: Path, + custom_settings: dict[str, str] | None = None, +) -> LuauLanguageServer.DependencyProvider: + return LuauLanguageServer.DependencyProvider( + custom_settings=SolidLSPSettings.CustomLSSettings(custom_settings or {}), + ls_resources_dir=str(tmp_path), + ) + + +class _FakeResponse: + def __init__(self, content: bytes) -> None: + self.content = content + + def raise_for_status(self) -> None: + return + + def iter_content(self, chunk_size: int = 8192): + yield self.content + + def __enter__(self) -> "_FakeResponse": + return self + + def __exit__(self, exc_type, exc, tb) -> None: + return None + + +@pytest.mark.luau +class TestLuauDependencyProvider: + def test_create_launch_command_uses_ls_path_override_and_adds_assets(self, tmp_path: Path) -> None: + provider = _make_provider(tmp_path, {"ls_path": "/custom/luau-lsp"}) + + with patch.object( + provider, + "_get_or_install_core_dependency", + side_effect=AssertionError("_get_or_install_core_dependency should not be called when ls_path is set"), + ): + with patch.object( + provider, + "_resolve_support_files", + return_value=("/tmp/globalTypes.d.luau", "/tmp/en-us.json"), + ): + assert provider.create_launch_command() == [ + "/custom/luau-lsp", + "lsp", + "--definitions:@roblox=/tmp/globalTypes.d.luau", + "--docs=/tmp/en-us.json", + ] + + def test_resolve_support_files_defaults_to_roblox_mode(self, tmp_path: Path) -> None: + provider = _make_provider(tmp_path) + + with patch.object( + provider, + "_download_roblox_support_files", + return_value=("/tmp/globalTypes.PluginSecurity.d.luau", "/tmp/en-us.json"), + ) as download_roblox_support_files: + with patch.object( + provider, + "_download_standard_docs", + side_effect=AssertionError("_download_standard_docs should not be called in roblox mode"), + ): + assert provider._resolve_support_files() == ( + "/tmp/globalTypes.PluginSecurity.d.luau", + "/tmp/en-us.json", + ) + + download_roblox_support_files.assert_called_once_with("PluginSecurity") + + def test_resolve_support_files_uses_standard_mode_docs_only(self, tmp_path: Path) -> None: + provider = _make_provider(tmp_path, {"platform": "standard"}) + + with patch.object(provider, "_download_standard_docs", return_value="/tmp/luau-en-us.json") as download_standard_docs: + with patch.object( + provider, + "_download_roblox_support_files", + side_effect=AssertionError("_download_roblox_support_files should not be called in standard mode"), + ): + assert provider._resolve_support_files() == ( + None, + "/tmp/luau-en-us.json", + ) + + download_standard_docs.assert_called_once_with() + + def test_get_or_install_core_dependency_uses_system_binary(self, tmp_path: Path) -> None: + provider = _make_provider(tmp_path) + + with patch("solidlsp.language_servers.luau_lsp.shutil.which", return_value="/usr/bin/luau-lsp"): + with patch.object( + provider, + "_download_luau_lsp", + side_effect=AssertionError("_download_luau_lsp should not be called when luau-lsp is on PATH"), + ): + assert provider._get_or_install_core_dependency() == "/usr/bin/luau-lsp" + + def test_download_luau_lsp_extracts_binary_into_ls_resources_dir(self, tmp_path: Path) -> None: + provider = _make_provider(tmp_path) + + archive = io.BytesIO() + with zipfile.ZipFile(archive, "w") as zip_file: + zip_file.writestr("nested/luau-lsp", "#!/bin/sh\n") + + with patch("solidlsp.language_servers.luau_lsp.platform.system", return_value="Linux"): + with patch("solidlsp.language_servers.luau_lsp.platform.machine", return_value="aarch64"): + with patch("solidlsp.language_servers.luau_lsp.requests.get", return_value=_FakeResponse(archive.getvalue())): + binary_path = provider._download_luau_lsp() + + resolved_binary = Path(binary_path) + assert resolved_binary.exists() + assert resolved_binary.name == "luau-lsp" + assert str(resolved_binary).startswith(str(tmp_path)) + + def test_download_roblox_support_files_writes_into_ls_resources_dir(self, tmp_path: Path) -> None: + provider = _make_provider(tmp_path) + + with patch( + "solidlsp.language_servers.luau_lsp.requests.get", + side_effect=[_FakeResponse(b"types"), _FakeResponse(b"docs")], + ): + definitions_path, docs_path = provider._download_roblox_support_files("LocalUserSecurity") + + assert definitions_path == str(tmp_path / "globalTypes.LocalUserSecurity.d.luau") + assert docs_path == str(tmp_path / "en-us.json") + assert (tmp_path / "globalTypes.LocalUserSecurity.d.luau").read_bytes() == b"types" + assert (tmp_path / "en-us.json").read_bytes() == b"docs" + + def test_download_standard_docs_writes_into_ls_resources_dir(self, tmp_path: Path) -> None: + provider = _make_provider(tmp_path, {"platform": "standard"}) + + with patch("solidlsp.language_servers.luau_lsp.requests.get", return_value=_FakeResponse(b"docs")): + docs_path = provider._download_standard_docs() + + assert docs_path == str(tmp_path / "luau-en-us.json") + assert (tmp_path / "luau-en-us.json").read_bytes() == b"docs" + + def test_workspace_configuration_uses_configured_platform(self) -> None: + config = LuauLanguageServer._get_workspace_configuration(SolidLSPSettings.CustomLSSettings({"platform": "standard"})) + assert config == {"platform": {"type": "standard"}} + + def test_invalid_platform_raises(self, tmp_path: Path) -> None: + provider = _make_provider(tmp_path, {"platform": "invalid"}) + + with pytest.raises(ValueError, match="Unsupported Luau platform"): + provider._resolve_support_files() + + def test_invalid_roblox_security_level_raises(self, tmp_path: Path) -> None: + provider = _make_provider(tmp_path, {"roblox_security_level": "invalid"}) + + with pytest.raises(ValueError, match="Unsupported Luau Roblox security level"): + provider._resolve_support_files()