diff --git a/.gitignore b/.gitignore index ac62ed2..0c475b4 100644 --- a/.gitignore +++ b/.gitignore @@ -404,10 +404,15 @@ src/multilspy/language_servers/rust_analyzer/static/ src/multilspy/language_servers/typescript_language_server/static/ src/multilspy/language_servers/dart_language_server/static/ src/multilspy/language_servers/clangd_language_server/static/ +src/multilspy/language_servers/metals/static/ +src/multilspy/language_servers/expert_elixir/static/ +src/multilspy/language_servers/sourcekit_lsp/static/ +src/multilspy/language_servers/intelephense/static/ +src/multilspy/language_servers/elixir_language_server/static/ # Virtual Environment .venv/ venv/ +uv.lock -src/multilspy/language_servers/intelephense/static/ -src/multilspy/language_servers/elixir_language_server/static/ \ No newline at end of file +.DS_Store diff --git a/README.md b/README.md index d9bc451..8ef9b18 100644 --- a/README.md +++ b/README.md @@ -49,8 +49,13 @@ pip install multilspy | dart | Dart | | ruby | Solargraph | | kotlin | KotlinLanguageServer | -| php | Intelephense | | cpp | clangd | +| php | Intelephense | +| scala | Metals | +| swift | SourceKit-LSP | +| elixir | Expert | +| lua | LuaLS | +| bash | bash-language-server | ## Usage @@ -60,7 +65,7 @@ from multilspy import SyncLanguageServer from multilspy.multilspy_config import MultilspyConfig from multilspy.multilspy_logger import MultilspyLogger ... -config = MultilspyConfig.from_dict({"code_language": "java"}) # Also supports "python", "rust", "csharp", "typescript", "javascript", "go", "dart", "ruby", "kotlin", "php" +config = MultilspyConfig.from_dict({"code_language": "java"}) # Also supports "python", "rust", "csharp", "typescript", "javascript", "go", "dart", "ruby", "kotlin", "cpp", "php", "scala", "swift", "elixir", "lua", "bash" logger = MultilspyLogger() lsp = SyncLanguageServer.create(config, logger, "/abs/path/to/project/root/") with lsp.start_server(): diff --git a/src/multilspy/__init__.py b/src/multilspy/__init__.py index a8229e9..4aaa83e 100644 --- a/src/multilspy/__init__.py +++ b/src/multilspy/__init__.py @@ -4,5 +4,6 @@ from . import multilspy_types as Types from .language_server import LanguageServer, SyncLanguageServer +from .path_classifier import PathClassifier, PathClassification -__all__ = ["LanguageServer", "Types", "SyncLanguageServer"] +__all__ = ["LanguageServer", "Types", "SyncLanguageServer", "PathClassifier", "PathClassification"] diff --git a/src/multilspy/language_server.py b/src/multilspy/language_server.py index dbf75b5..72118fb 100644 --- a/src/multilspy/language_server.py +++ b/src/multilspy/language_server.py @@ -123,15 +123,30 @@ def create(cls, config: MultilspyConfig, logger: MultilspyLogger, repository_roo from multilspy.language_servers.clangd_language_server.clangd_language_server import ClangdLanguageServer return ClangdLanguageServer(config, logger, repository_root_path) - elif config.code_language == Language.PHP: - from multilspy.language_servers.intelephense.intelephense import Intelephense + elif config.code_language == Language.SCALA: + from multilspy.language_servers.metals.metals import Metals - return Intelephense(config, logger, repository_root_path) + return Metals(config, logger, repository_root_path) + elif config.code_language == Language.SWIFT: + from multilspy.language_servers.sourcekit_lsp.sourcekit_lsp import SourceKitLSP + + return SourceKitLSP(config, logger, repository_root_path) elif config.code_language == Language.ELIXIR: from multilspy.language_servers.elixir_language_server.elixir_language_server import ElixirLanguageServer return ElixirLanguageServer(config, logger, repository_root_path) + elif config.code_language == Language.PHP: + from multilspy.language_servers.intelephense.intelephense import Intelephense + return Intelephense(config, logger, repository_root_path) + elif config.code_language == Language.LUA: + from multilspy.language_servers.lua_language_server.lua_language_server import LuaLanguageServer + + return LuaLanguageServer(config, logger, repository_root_path) + elif config.code_language == Language.BASH: + from multilspy.language_servers.bash_language_server.bash_language_server import BashLanguageServer + + return BashLanguageServer(config, logger, repository_root_path) else: logger.log(f"Language {config.code_language} is not supported", logging.ERROR) raise MultilspyException(f"Language {config.code_language} is not supported") @@ -162,10 +177,12 @@ def __init__( "LanguageServer is an abstract class and cannot be instantiated directly. Use LanguageServer.create method instead." ) + self.config = config self.logger = logger self.server_started = False self.repository_root_path: str = repository_root_path self.completions_available = asyncio.Event() + self.request_timeout: float = getattr(config, "request_timeout", 30.0) if config.trace_lsp_communication: @@ -188,6 +205,28 @@ def logging_fn(source, target, msg): self.language_id = language_id self.open_file_buffers: Dict[str, LSPFileBuffer] = {} + def is_process_alive(self) -> bool: + """ + Check if the language server process is still running. + """ + if not self.server: + return False + return self.server.is_alive() + + def check_process_health(self) -> bool: + """ + Verify whether the language server process is still alive and responsive. + Logs a structured warning if the process has exited. + """ + alive = self.is_process_alive() + if not alive: + returncode = self.server.process.returncode if self.server and self.server.process else "unknown" + self.logger.log( + f"Language server process is not alive (returncode: {returncode})", + logging.WARNING, + ) + return alive + @asynccontextmanager async def start_server(self) -> AsyncIterator["LanguageServer"]: """ @@ -377,7 +416,7 @@ def get_open_file_text(self, relative_file_path: str) -> str: return file_buffer.contents async def request_definition( - self, relative_file_path: str, line: int, column: int + self, relative_file_path: str, line: int, column: int, timeout: Optional[float] = None ) -> List[multilspy_types.Location]: """ Raise a [textDocument/definition](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_definition) request to the Language Server @@ -386,6 +425,7 @@ async def request_definition( :param relative_file_path: The relative path of the file that has the symbol for which definition should be looked up :param line: The line number of the symbol :param column: The column number of the symbol + :param timeout: Optional per-request timeout in seconds (defaults to config.request_timeout) :return List[multilspy_types.Location]: A list of locations where the symbol is defined """ @@ -397,24 +437,42 @@ async def request_definition( ) raise MultilspyException("Language Server not started") - with self.open_file(relative_file_path): - # sending request to the language server and waiting for response - response = await self.server.send.definition( - { - LSPConstants.TEXT_DOCUMENT: { - LSPConstants.URI: pathlib.Path( - str(PurePath(self.repository_root_path, relative_file_path)) - ).as_uri() - }, - LSPConstants.POSITION: { - LSPConstants.LINE: line, - LSPConstants.CHARACTER: column, - }, - } + effective_timeout = timeout if timeout is not None else self.request_timeout + + async def _execute(): + with self.open_file(relative_file_path): + # sending request to the language server and waiting for response + return await self.server.send.definition( + { + LSPConstants.TEXT_DOCUMENT: { + LSPConstants.URI: pathlib.Path( + str(PurePath(self.repository_root_path, relative_file_path)) + ).as_uri() + }, + LSPConstants.POSITION: { + LSPConstants.LINE: line, + LSPConstants.CHARACTER: column, + }, + } + ) + + try: + if effective_timeout is not None and effective_timeout > 0: + response = await asyncio.wait_for(_execute(), timeout=effective_timeout) + else: + response = await _execute() + except (asyncio.TimeoutError, TimeoutError): + self.logger.log( + f"LSP request timed out: method='textDocument/definition', file='{relative_file_path}', line={line}, column={column}, timeout={effective_timeout}s", + logging.WARNING, ) + self.check_process_health() + return [] ret: List[multilspy_types.Location] = [] - if isinstance(response, list): + if response is None: + return ret + elif isinstance(response, list): # response is either of type Location[] or LocationLink[] for item in response: assert isinstance(item, dict) @@ -457,7 +515,7 @@ async def request_definition( return ret async def request_references( - self, relative_file_path: str, line: int, column: int + self, relative_file_path: str, line: int, column: int, timeout: Optional[float] = None ) -> List[multilspy_types.Location]: """ Raise a [textDocument/references](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_references) request to the Language Server @@ -466,6 +524,7 @@ async def request_references( :param relative_file_path: The relative path of the file that has the symbol for which references should be looked up :param line: The line number of the symbol :param column: The column number of the symbol + :param timeout: Optional per-request timeout in seconds (defaults to config.request_timeout) :return List[multilspy_types.Location]: A list of locations where the symbol is referenced """ @@ -477,19 +536,37 @@ async def request_references( ) raise MultilspyException("Language Server not started") - with self.open_file(relative_file_path): - # sending request to the language server and waiting for response - response = await self.server.send.references( - { - "context": {"includeDeclaration": False}, - "textDocument": { - "uri": pathlib.Path(os.path.join(self.repository_root_path, relative_file_path)).as_uri() - }, - "position": {"line": line, "character": column}, - } + effective_timeout = timeout if timeout is not None else self.request_timeout + + async def _execute(): + with self.open_file(relative_file_path): + # sending request to the language server and waiting for response + return await self.server.send.references( + { + "context": {"includeDeclaration": False}, + "textDocument": { + "uri": pathlib.Path(os.path.join(self.repository_root_path, relative_file_path)).as_uri() + }, + "position": {"line": line, "character": column}, + } + ) + + try: + if effective_timeout is not None and effective_timeout > 0: + response = await asyncio.wait_for(_execute(), timeout=effective_timeout) + else: + response = await _execute() + except (asyncio.TimeoutError, TimeoutError): + self.logger.log( + f"LSP request timed out: method='textDocument/references', file='{relative_file_path}', line={line}, column={column}, timeout={effective_timeout}s", + logging.WARNING, ) + self.check_process_health() + return [] ret: List[multilspy_types.Location] = [] + if response is None: + return ret assert isinstance(response, list), f"Unexpected response from Language Server: {response}" for item in response: assert isinstance(item, dict) @@ -505,7 +582,7 @@ async def request_references( return ret async def request_completions( - self, relative_file_path: str, line: int, column: int, allow_incomplete: bool = False + self, relative_file_path: str, line: int, column: int, allow_incomplete: bool = False, timeout: Optional[float] = None ) -> List[multilspy_types.CompletionItem]: """ Raise a [textDocument/completion](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_completion) request to the Language Server @@ -514,109 +591,146 @@ async def request_completions( :param relative_file_path: The relative path of the file that has the symbol for which completions should be looked up :param line: The line number of the symbol :param column: The column number of the symbol + :param allow_incomplete: Whether to return incomplete completion list + :param timeout: Optional per-request timeout in seconds (defaults to config.request_timeout) :return List[multilspy_types.CompletionItem]: A list of completions """ - with self.open_file(relative_file_path): - open_file_buffer = self.open_file_buffers[ - pathlib.Path(os.path.join(self.repository_root_path, relative_file_path)).as_uri() - ] - completion_params: LSPTypes.CompletionParams = { - "position": {"line": line, "character": column}, - "textDocument": {"uri": open_file_buffer.uri}, - "context": {"triggerKind": LSPTypes.CompletionTriggerKind.Invoked}, - } - response: Union[List[LSPTypes.CompletionItem], LSPTypes.CompletionList, None] = None - - for _ in range(30): - await self.completions_available.wait() - response: Union[ - List[LSPTypes.CompletionItem], LSPTypes.CompletionList, None - ] = await self.server.send.completion(completion_params) - if response is None: - return [] - if isinstance(response, list): - response = {"items": response, "isIncomplete": False} - if not response["isIncomplete"]: - break - - # TODO: Understand how to appropriately handle `isIncomplete` - if response is None or (response["isIncomplete"] and not(allow_incomplete)): - return [] - - if "items" in response: - response = response["items"] - - response: List[LSPTypes.CompletionItem] = response - - # TODO: Handle the case when the completion is a keyword - items = [item for item in response if item["kind"] != LSPTypes.CompletionItemKind.Keyword] - - completions_list: List[multilspy_types.CompletionItem] = [] - - for item in items: - assert "insertText" in item or "textEdit" in item - assert "kind" in item - completion_item = {} - if "detail" in item: - completion_item["detail"] = item["detail"] - - if "label" in item: - completion_item["completionText"] = item["label"] - completion_item["kind"] = item["kind"] - elif "insertText" in item: - completion_item["completionText"] = item["insertText"] - completion_item["kind"] = item["kind"] - elif "textEdit" in item and "newText" in item["textEdit"]: - completion_item["completionText"] = item["textEdit"]["newText"] - completion_item["kind"] = item["kind"] - elif "textEdit" in item and "range" in item["textEdit"]: - new_dot_lineno, new_dot_colno = ( - completion_params["position"]["line"], - completion_params["position"]["character"], - ) - assert all( - ( - item["textEdit"]["range"]["start"]["line"] == new_dot_lineno, - item["textEdit"]["range"]["start"]["character"] == new_dot_colno, - item["textEdit"]["range"]["start"]["line"] == item["textEdit"]["range"]["end"]["line"], - item["textEdit"]["range"]["start"]["character"] - == item["textEdit"]["range"]["end"]["character"], - ) + effective_timeout = timeout if timeout is not None else self.request_timeout + + async def _execute(): + with self.open_file(relative_file_path): + open_file_buffer = self.open_file_buffers[ + pathlib.Path(os.path.join(self.repository_root_path, relative_file_path)).as_uri() + ] + completion_params: LSPTypes.CompletionParams = { + "position": {"line": line, "character": column}, + "textDocument": {"uri": open_file_buffer.uri}, + "context": {"triggerKind": LSPTypes.CompletionTriggerKind.Invoked}, + } + response: Union[List[LSPTypes.CompletionItem], LSPTypes.CompletionList, None] = None + + for _ in range(30): + await self.completions_available.wait() + response = await self.server.send.completion(completion_params) + if response is None: + return None, completion_params + if isinstance(response, list): + response = {"items": response, "isIncomplete": False} + if not response["isIncomplete"]: + break + + return response, completion_params + + try: + if effective_timeout is not None and effective_timeout > 0: + response, completion_params = await asyncio.wait_for(_execute(), timeout=effective_timeout) + else: + response, completion_params = await _execute() + except (asyncio.TimeoutError, TimeoutError): + self.logger.log( + f"LSP request timed out: method='textDocument/completion', file='{relative_file_path}', line={line}, column={column}, timeout={effective_timeout}s", + logging.WARNING, + ) + self.check_process_health() + return [] + + # TODO: Understand how to appropriately handle `isIncomplete` + if response is None or (response["isIncomplete"] and not(allow_incomplete)): + return [] + + if "items" in response: + response = response["items"] + + response: List[LSPTypes.CompletionItem] = response + + # TODO: Handle the case when the completion is a keyword + items = [item for item in response if item["kind"] != LSPTypes.CompletionItemKind.Keyword] + + completions_list: List[multilspy_types.CompletionItem] = [] + + for item in items: + assert "insertText" in item or "textEdit" in item + assert "kind" in item + completion_item = {} + if "detail" in item: + completion_item["detail"] = item["detail"] + + if "label" in item: + completion_item["completionText"] = item["label"] + completion_item["kind"] = item["kind"] + elif "insertText" in item: + completion_item["completionText"] = item["insertText"] + completion_item["kind"] = item["kind"] + elif "textEdit" in item and "newText" in item["textEdit"]: + completion_item["completionText"] = item["textEdit"]["newText"] + completion_item["kind"] = item["kind"] + elif "textEdit" in item and "range" in item["textEdit"]: + new_dot_lineno, new_dot_colno = ( + completion_params["position"]["line"], + completion_params["position"]["character"], + ) + assert all( + ( + item["textEdit"]["range"]["start"]["line"] == new_dot_lineno, + item["textEdit"]["range"]["start"]["character"] == new_dot_colno, + item["textEdit"]["range"]["start"]["line"] == item["textEdit"]["range"]["end"]["line"], + item["textEdit"]["range"]["start"]["character"] + == item["textEdit"]["range"]["end"]["character"], ) - - completion_item["completionText"] = item["textEdit"]["newText"] - completion_item["kind"] = item["kind"] - elif "textEdit" in item and "insert" in item["textEdit"]: - assert False - else: - assert False + ) + + completion_item["completionText"] = item["textEdit"]["newText"] + completion_item["kind"] = item["kind"] + elif "textEdit" in item and "insert" in item["textEdit"]: + assert False + else: + assert False - completion_item = multilspy_types.CompletionItem(**completion_item) - completions_list.append(completion_item) + completion_item = multilspy_types.CompletionItem(**completion_item) + completions_list.append(completion_item) - return [ - json.loads(json_repr) - for json_repr in set([json.dumps(item, sort_keys=True) for item in completions_list]) - ] + return [ + json.loads(json_repr) + for json_repr in set([json.dumps(item, sort_keys=True) for item in completions_list]) + ] - async def request_document_symbols(self, relative_file_path: str) -> Tuple[List[multilspy_types.UnifiedSymbolInformation], Union[List[multilspy_types.TreeRepr], None]]: + async def request_document_symbols( + self, relative_file_path: str, timeout: Optional[float] = None + ) -> Tuple[List[multilspy_types.UnifiedSymbolInformation], Union[List[multilspy_types.TreeRepr], None]]: """ Raise a [textDocument/documentSymbol](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_documentSymbol) request to the Language Server to find symbols in the given file. Wait for the response and return the result. :param relative_file_path: The relative path of the file that has the symbols + :param timeout: Optional per-request timeout in seconds (defaults to config.request_timeout) :return Tuple[List[multilspy_types.UnifiedSymbolInformation], Union[List[multilspy_types.TreeRepr], None]]: A list of symbols in the file, and the tree representation of the symbols """ - with self.open_file(relative_file_path): - response = await self.server.send.document_symbol( - { - "textDocument": { - "uri": pathlib.Path(os.path.join(self.repository_root_path, relative_file_path)).as_uri() + effective_timeout = timeout if timeout is not None else self.request_timeout + + async def _execute(): + with self.open_file(relative_file_path): + return await self.server.send.document_symbol( + { + "textDocument": { + "uri": pathlib.Path(os.path.join(self.repository_root_path, relative_file_path)).as_uri() + } } - } + ) + + try: + if effective_timeout is not None and effective_timeout > 0: + response = await asyncio.wait_for(_execute(), timeout=effective_timeout) + else: + response = await _execute() + except (asyncio.TimeoutError, TimeoutError): + self.logger.log( + f"LSP request timed out: method='textDocument/documentSymbol', file='{relative_file_path}', timeout={effective_timeout}s", + logging.WARNING, ) + self.check_process_health() + return [], None ret: List[multilspy_types.UnifiedSymbolInformation] = [] l_tree = None @@ -645,7 +759,9 @@ def visit_tree_nodes_and_build_tree_repr(tree: LSPTypes.DocumentSymbol) -> List[ return ret, l_tree - async def request_hover(self, relative_file_path: str, line: int, column: int) -> Union[multilspy_types.Hover, None]: + async def request_hover( + self, relative_file_path: str, line: int, column: int, timeout: Optional[float] = None + ) -> Union[multilspy_types.Hover, None]: """ Raise a [textDocument/hover](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_hover) request to the Language Server to find the hover information at the given line and column in the given file. Wait for the response and return the result. @@ -653,21 +769,38 @@ async def request_hover(self, relative_file_path: str, line: int, column: int) - :param relative_file_path: The relative path of the file that has the hover information :param line: The line number of the symbol :param column: The column number of the symbol + :param timeout: Optional per-request timeout in seconds (defaults to config.request_timeout) :return None """ - with self.open_file(relative_file_path): - response = await self.server.send.hover( - { - "textDocument": { - "uri": pathlib.Path(os.path.join(self.repository_root_path, relative_file_path)).as_uri() - }, - "position": { - "line": line, - "character": column, - }, - } + effective_timeout = timeout if timeout is not None else self.request_timeout + + async def _execute(): + with self.open_file(relative_file_path): + return await self.server.send.hover( + { + "textDocument": { + "uri": pathlib.Path(os.path.join(self.repository_root_path, relative_file_path)).as_uri() + }, + "position": { + "line": line, + "character": column, + }, + } + ) + + try: + if effective_timeout is not None and effective_timeout > 0: + response = await asyncio.wait_for(_execute(), timeout=effective_timeout) + else: + response = await _execute() + except (asyncio.TimeoutError, TimeoutError): + self.logger.log( + f"LSP request timed out: method='textDocument/hover', file='{relative_file_path}', line={line}, column={column}, timeout={effective_timeout}s", + logging.WARNING, ) + self.check_process_health() + return None if response is None: return None @@ -676,16 +809,36 @@ async def request_hover(self, relative_file_path: str, line: int, column: int) - return multilspy_types.Hover(**response) - async def request_workspace_symbol(self, query: str) -> Union[List[multilspy_types.UnifiedSymbolInformation], None]: + async def request_workspace_symbol( + self, query: str, timeout: Optional[float] = None + ) -> Union[List[multilspy_types.UnifiedSymbolInformation], None]: """ Raise a [workspace/symbol](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#workspace_symbol) request to the Language Server to find symbols across the whole workspace. Wait for the response and return the result. :param query: The query string to filter symbols by + :param timeout: Optional per-request timeout in seconds (defaults to config.request_timeout) :return Union[List[multilspy_types.UnifiedSymbolInformation], None]: A list of matching symbols """ - response = await self.server.send.workspace_symbol({"query": query}) + effective_timeout = timeout if timeout is not None else self.request_timeout + + async def _execute(): + return await self.server.send.workspace_symbol({"query": query}) + + try: + if effective_timeout is not None and effective_timeout > 0: + response = await asyncio.wait_for(_execute(), timeout=effective_timeout) + else: + response = await _execute() + except (asyncio.TimeoutError, TimeoutError): + self.logger.log( + f"LSP request timed out: method='workspace/symbol', query='{query}', timeout={effective_timeout}s", + logging.WARNING, + ) + self.check_process_health() + return None + if response is None: return None @@ -717,6 +870,18 @@ def __init__(self, language_server: LanguageServer, timeout: Optional[int] = Non self.loop_thread = None self.timeout = timeout + def is_process_alive(self) -> bool: + """ + Check if the language server process is still running. + """ + return self.language_server.is_process_alive() + + def check_process_health(self) -> bool: + """ + Verify whether the language server process is still alive and responsive. + """ + return self.language_server.check_process_health() + @classmethod def create( cls, config: MultilspyConfig, logger: MultilspyLogger, repository_root_path: str, timeout: Optional[int] = None @@ -848,7 +1013,9 @@ async def initialize(ctx): self.loop_thread.join() self.loop.close() - def request_definition(self, file_path: str, line: int, column: int) -> List[multilspy_types.Location]: + def request_definition( + self, file_path: str, line: int, column: int, timeout: Optional[float] = None + ) -> List[multilspy_types.Location]: """ Raise a [textDocument/definition](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_definition) request to the Language Server for the symbol at the given line and column in the given file. Wait for the response and return the result. @@ -856,15 +1023,19 @@ def request_definition(self, file_path: str, line: int, column: int) -> List[mul :param relative_file_path: The relative path of the file that has the symbol for which definition should be looked up :param line: The line number of the symbol :param column: The column number of the symbol + :param timeout: Optional per-request timeout in seconds :return List[multilspy_types.Location]: A list of locations where the symbol is defined """ + req_timeout = timeout if timeout is not None else self.timeout result = asyncio.run_coroutine_threadsafe( - self.language_server.request_definition(file_path, line, column), self.loop + self.language_server.request_definition(file_path, line, column, timeout=req_timeout), self.loop ).result(timeout=self.timeout) return result - def request_references(self, file_path: str, line: int, column: int) -> List[multilspy_types.Location]: + def request_references( + self, file_path: str, line: int, column: int, timeout: Optional[float] = None + ) -> List[multilspy_types.Location]: """ Raise a [textDocument/references](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_references) request to the Language Server to find references to the symbol at the given line and column in the given file. Wait for the response and return the result. @@ -872,16 +1043,18 @@ def request_references(self, file_path: str, line: int, column: int) -> List[mul :param relative_file_path: The relative path of the file that has the symbol for which references should be looked up :param line: The line number of the symbol :param column: The column number of the symbol + :param timeout: Optional per-request timeout in seconds :return List[multilspy_types.Location]: A list of locations where the symbol is referenced """ + req_timeout = timeout if timeout is not None else self.timeout result = asyncio.run_coroutine_threadsafe( - self.language_server.request_references(file_path, line, column), self.loop + self.language_server.request_references(file_path, line, column, timeout=req_timeout), self.loop ).result(timeout=self.timeout) return result def request_completions( - self, relative_file_path: str, line: int, column: int, allow_incomplete: bool = False + self, relative_file_path: str, line: int, column: int, allow_incomplete: bool = False, timeout: Optional[float] = None ) -> List[multilspy_types.CompletionItem]: """ Raise a [textDocument/completion](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_completion) request to the Language Server @@ -890,30 +1063,39 @@ def request_completions( :param relative_file_path: The relative path of the file that has the symbol for which completions should be looked up :param line: The line number of the symbol :param column: The column number of the symbol + :param allow_incomplete: Whether to return incomplete completion list + :param timeout: Optional per-request timeout in seconds :return List[multilspy_types.CompletionItem]: A list of completions """ + req_timeout = timeout if timeout is not None else self.timeout result = asyncio.run_coroutine_threadsafe( - self.language_server.request_completions(relative_file_path, line, column, allow_incomplete), + self.language_server.request_completions(relative_file_path, line, column, allow_incomplete, timeout=req_timeout), self.loop, ).result(timeout=self.timeout) return result - def request_document_symbols(self, relative_file_path: str) -> Tuple[List[multilspy_types.UnifiedSymbolInformation], Union[List[multilspy_types.TreeRepr], None]]: + def request_document_symbols( + self, relative_file_path: str, timeout: Optional[float] = None + ) -> Tuple[List[multilspy_types.UnifiedSymbolInformation], Union[List[multilspy_types.TreeRepr], None]]: """ Raise a [textDocument/documentSymbol](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_documentSymbol) request to the Language Server to find symbols in the given file. Wait for the response and return the result. :param relative_file_path: The relative path of the file that has the symbols + :param timeout: Optional per-request timeout in seconds :return Tuple[List[multilspy_types.UnifiedSymbolInformation], Union[List[multilspy_types.TreeRepr], None]]: A list of symbols in the file, and the tree representation of the symbols """ + req_timeout = timeout if timeout is not None else self.timeout result = asyncio.run_coroutine_threadsafe( - self.language_server.request_document_symbols(relative_file_path), self.loop + self.language_server.request_document_symbols(relative_file_path, timeout=req_timeout), self.loop ).result(timeout=self.timeout) return result - def request_hover(self, relative_file_path: str, line: int, column: int) -> Union[multilspy_types.Hover, None]: + def request_hover( + self, relative_file_path: str, line: int, column: int, timeout: Optional[float] = None + ) -> Union[multilspy_types.Hover, None]: """ Raise a [textDocument/hover](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_hover) request to the Language Server to find the hover information at the given line and column in the given file. Wait for the response and return the result. @@ -921,24 +1103,30 @@ def request_hover(self, relative_file_path: str, line: int, column: int) -> Unio :param relative_file_path: The relative path of the file that has the hover information :param line: The line number of the symbol :param column: The column number of the symbol + :param timeout: Optional per-request timeout in seconds :return None """ + req_timeout = timeout if timeout is not None else self.timeout result = asyncio.run_coroutine_threadsafe( - self.language_server.request_hover(relative_file_path, line, column), self.loop + self.language_server.request_hover(relative_file_path, line, column, timeout=req_timeout), self.loop ).result(timeout=self.timeout) return result - def request_workspace_symbol(self, query: str) -> Union[List[multilspy_types.UnifiedSymbolInformation], None]: + def request_workspace_symbol( + self, query: str, timeout: Optional[float] = None + ) -> Union[List[multilspy_types.UnifiedSymbolInformation], None]: """ Raise a [workspace/symbol](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#workspace_symbol) request to the Language Server to find symbols across the whole workspace. Wait for the response and return the result. :param query: The query string to filter symbols by + :param timeout: Optional per-request timeout in seconds :return Union[List[multilspy_types.UnifiedSymbolInformation], None]: A list of matching symbols """ + req_timeout = timeout if timeout is not None else self.timeout result = asyncio.run_coroutine_threadsafe( - self.language_server.request_workspace_symbol(query), self.loop + self.language_server.request_workspace_symbol(query, timeout=req_timeout), self.loop ).result(timeout=self.timeout) return result diff --git a/src/multilspy/language_servers/bash_language_server/__init__.py b/src/multilspy/language_servers/bash_language_server/__init__.py new file mode 100644 index 0000000..810690f --- /dev/null +++ b/src/multilspy/language_servers/bash_language_server/__init__.py @@ -0,0 +1,7 @@ +""" +Bash language server package. +""" + +from multilspy.language_servers.bash_language_server.bash_language_server import BashLanguageServer + +__all__ = ["BashLanguageServer"] diff --git a/src/multilspy/language_servers/bash_language_server/bash_language_server.py b/src/multilspy/language_servers/bash_language_server/bash_language_server.py new file mode 100644 index 0000000..4b10348 --- /dev/null +++ b/src/multilspy/language_servers/bash_language_server/bash_language_server.py @@ -0,0 +1,201 @@ +""" +Provides Bash (bash-language-server) specific instantiation of the LanguageServer class. +""" + +import asyncio +import json +import logging +import os +import pathlib +import shutil +import stat +import subprocess +from contextlib import asynccontextmanager +from typing import AsyncIterator, List + +from multilspy.multilspy_logger import MultilspyLogger +from multilspy.language_server import LanguageServer +from multilspy.lsp_protocol_handler.server import ProcessLaunchInfo +from multilspy.lsp_protocol_handler.lsp_types import InitializeParams +from multilspy.multilspy_config import MultilspyConfig +from multilspy.multilspy_utils import PlatformUtils +from multilspy.multilspy_settings import MultilspySettings + +# Conditionally import pwd module (Unix-only) +if not PlatformUtils.get_platform_id().value.startswith("win"): + import pwd + + +class BashLanguageServer(LanguageServer): + """ + Provides Bash specific instantiation of the LanguageServer class using bash-language-server. + """ + + def __init__(self, config: MultilspyConfig, logger: MultilspyLogger, repository_root_path: str): + """ + Creates a BashLanguageServer instance. This class is not meant to be instantiated directly. + Use LanguageServer.create() instead. + """ + cmd = self.setup_runtime_dependencies(logger, config) + super().__init__( + config, + logger, + repository_root_path, + ProcessLaunchInfo(cmd=cmd, cwd=repository_root_path), + "shellscript", + ) + self.server_ready = asyncio.Event() + + def setup_runtime_dependencies(self, logger: MultilspyLogger, config: MultilspyConfig) -> List[str]: + """ + Setup runtime dependencies for bash-language-server. + """ + if config.server_binary: + assert os.path.exists(config.server_binary), f"Server binary not found: {config.server_binary}" + return [config.server_binary, "start"] + + is_node_installed = shutil.which("node") is not None + assert is_node_installed, "node is not installed or isn't in PATH. Please install NodeJS and try again." + is_npm_installed = shutil.which("npm") is not None + assert is_npm_installed, "npm is not installed or isn't in PATH. Please install npm and try again." + + with open(os.path.join(os.path.dirname(__file__), "runtime_dependencies.json"), "r") as f: + d = json.load(f) + del d["_description"] + + runtime_dependencies = d.get("runtimeDependencies", []) + bash_ls_dir = config.server_install_dir or MultilspySettings.get_server_install_directory("bash-language-server") + + if PlatformUtils.get_platform_id().value.startswith("win"): + bash_executable_path = os.path.join(bash_ls_dir, "node_modules", ".bin", "bash-language-server.cmd") + else: + bash_executable_path = os.path.join(bash_ls_dir, "node_modules", ".bin", "bash-language-server") + + if not os.path.exists(bash_executable_path): + os.makedirs(bash_ls_dir, exist_ok=True) + for dependency in runtime_dependencies: + if PlatformUtils.get_platform_id().value.startswith("win"): + subprocess.run( + dependency["command"], + shell=True, + check=True, + cwd=bash_ls_dir, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + else: + user = pwd.getpwuid(os.getuid()).pw_name + subprocess.run( + dependency["command"], + shell=True, + check=True, + user=user, + cwd=bash_ls_dir, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + assert os.path.exists(bash_executable_path), "bash-language-server executable not found. Please install bash-language-server and try again." + + if not PlatformUtils.get_platform_id().value.startswith("win"): + os.chmod( + bash_executable_path, + os.stat(bash_executable_path).st_mode + | stat.S_IXUSR + | stat.S_IXGRP + | stat.S_IXOTH, + ) + + return [bash_executable_path, "start"] + + def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: + """ + Returns the initialize params for the Bash Language Server. + """ + with open(os.path.join(os.path.dirname(__file__), "initialize_params.json"), "r") as f: + d = json.load(f) + + del d["_description"] + + d["processId"] = os.getpid() + assert d["rootPath"] == "$rootPath" + d["rootPath"] = repository_absolute_path + + assert d["rootUri"] == "$rootUri" + d["rootUri"] = pathlib.Path(repository_absolute_path).as_uri() + + assert d["workspaceFolders"][0]["uri"] == "$uri" + d["workspaceFolders"][0]["uri"] = pathlib.Path(repository_absolute_path).as_uri() + + assert d["workspaceFolders"][0]["name"] == "$name" + d["workspaceFolders"][0]["name"] = os.path.basename(repository_absolute_path) + + return d + + @asynccontextmanager + async def start_server(self) -> AsyncIterator["BashLanguageServer"]: + """ + Starts the Bash Language Server, waits for the server to be ready and yields the LanguageServer instance. + """ + + async def register_capability_handler(params): + return None + + async def execute_client_command_handler(params): + return [] + + async def workspace_configuration_handler(params): + items = params.get("items", []) if isinstance(params, dict) else [] + return [{} for _ in items] + + async def work_done_progress_create_handler(params): + return None + + async def do_nothing(params): + return None + + async def window_log_message(msg): + self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) + if isinstance(msg, dict) and "BackgroundAnalysis: Completed" in msg.get("message", ""): + self.server_ready.set() + + async def window_show_message(msg): + self.logger.log(f"LSP: window/showMessage: {msg}", logging.INFO) + + self.server.on_request("client/registerCapability", register_capability_handler) + self.server.on_request("workspace/configuration", workspace_configuration_handler) + self.server.on_request("window/workDoneProgress/create", work_done_progress_create_handler) + self.server.on_request("workspace/executeClientCommand", execute_client_command_handler) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_notification("window/showMessage", window_show_message) + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + + async with super().start_server(): + self.logger.log("Starting Bash language server process", logging.INFO) + await self.server.start() + initialize_params = self._get_initialize_params(self.repository_root_path) + + self.logger.log( + "Sending initialize request from LSP client to LSP server and awaiting response", + logging.INFO, + ) + init_response = await self.server.send.initialize(initialize_params) + self.logger.log( + f"Received initialize response from bash-language-server: {init_response.get('serverInfo', {})}", + logging.INFO, + ) + + self.server.notify.initialized({}) + self.completions_available.set() + + try: + await asyncio.wait_for(self.server_ready.wait(), timeout=3.0) + except asyncio.TimeoutError: + self.server_ready.set() + + try: + yield self + finally: + await self.server.shutdown() + await self.server.stop() diff --git a/src/multilspy/language_servers/bash_language_server/initialize_params.json b/src/multilspy/language_servers/bash_language_server/initialize_params.json new file mode 100644 index 0000000..0236537 --- /dev/null +++ b/src/multilspy/language_servers/bash_language_server/initialize_params.json @@ -0,0 +1,51 @@ +{ + "_description": "This file contains the initialization parameters for the Bash Language Server.", + "processId": "$processId", + "rootPath": "$rootPath", + "rootUri": "$rootUri", + "capabilities": { + "workspace": { + "configuration": true, + "workspaceFolders": true + }, + "textDocument": { + "synchronization": { + "dynamicRegistration": true, + "willSave": true, + "didSave": true + }, + "completion": { + "dynamicRegistration": true, + "completionItem": { + "snippetSupport": true + } + }, + "definition": { + "dynamicRegistration": true, + "linkSupport": true + }, + "references": { + "dynamicRegistration": true + }, + "documentSymbol": { + "dynamicRegistration": true, + "hierarchicalDocumentSymbolSupport": true + }, + "hover": { + "dynamicRegistration": true, + "contentFormat": ["markdown", "plaintext"] + } + }, + "window": { + "workDoneProgress": true + } + }, + "initializationOptions": {}, + "trace": "verbose", + "workspaceFolders": [ + { + "uri": "$uri", + "name": "$name" + } + ] +} diff --git a/src/multilspy/language_servers/bash_language_server/runtime_dependencies.json b/src/multilspy/language_servers/bash_language_server/runtime_dependencies.json new file mode 100644 index 0000000..a4689a2 --- /dev/null +++ b/src/multilspy/language_servers/bash_language_server/runtime_dependencies.json @@ -0,0 +1,10 @@ +{ + "_description": "Used to install bash-language-server using npm.", + "runtimeDependencies": [ + { + "id": "bash-language-server", + "description": "bash-language-server package for Linux, macOS, and Windows.", + "command": "npm install --prefix ./ bash-language-server@5.8.0" + } + ] +} diff --git a/src/multilspy/language_servers/lua_language_server/__init__.py b/src/multilspy/language_servers/lua_language_server/__init__.py new file mode 100644 index 0000000..65098bb --- /dev/null +++ b/src/multilspy/language_servers/lua_language_server/__init__.py @@ -0,0 +1,7 @@ +""" +Lua language server package. +""" + +from multilspy.language_servers.lua_language_server.lua_language_server import LuaLanguageServer + +__all__ = ["LuaLanguageServer"] diff --git a/src/multilspy/language_servers/lua_language_server/initialize_params.json b/src/multilspy/language_servers/lua_language_server/initialize_params.json new file mode 100644 index 0000000..ed04c9f --- /dev/null +++ b/src/multilspy/language_servers/lua_language_server/initialize_params.json @@ -0,0 +1,51 @@ +{ + "_description": "This file contains the initialization parameters for the Lua Language Server.", + "processId": "$processId", + "rootPath": "$rootPath", + "rootUri": "$rootUri", + "capabilities": { + "workspace": { + "configuration": true, + "workspaceFolders": true + }, + "textDocument": { + "synchronization": { + "dynamicRegistration": true, + "willSave": true, + "didSave": true + }, + "completion": { + "dynamicRegistration": true, + "completionItem": { + "snippetSupport": true + } + }, + "definition": { + "dynamicRegistration": true, + "linkSupport": true + }, + "references": { + "dynamicRegistration": true + }, + "documentSymbol": { + "dynamicRegistration": true, + "hierarchicalDocumentSymbolSupport": true + }, + "hover": { + "dynamicRegistration": true, + "contentFormat": ["markdown", "plaintext"] + } + }, + "window": { + "workDoneProgress": true + } + }, + "initializationOptions": {}, + "trace": "verbose", + "workspaceFolders": [ + { + "uri": "$uri", + "name": "$name" + } + ] +} diff --git a/src/multilspy/language_servers/lua_language_server/lua_language_server.py b/src/multilspy/language_servers/lua_language_server/lua_language_server.py new file mode 100644 index 0000000..c47ee7f --- /dev/null +++ b/src/multilspy/language_servers/lua_language_server/lua_language_server.py @@ -0,0 +1,171 @@ +""" +Provides Lua (LuaLS) specific instantiation of the LanguageServer class. +""" + +import asyncio +import json +import logging +import os +import pathlib +import stat +from contextlib import asynccontextmanager +from typing import AsyncIterator, List + +from multilspy.multilspy_logger import MultilspyLogger +from multilspy.language_server import LanguageServer +from multilspy.lsp_protocol_handler.server import ProcessLaunchInfo +from multilspy.lsp_protocol_handler.lsp_types import InitializeParams +from multilspy.multilspy_config import MultilspyConfig +from multilspy.multilspy_utils import PlatformUtils, FileUtils +from multilspy.multilspy_settings import MultilspySettings + + +class LuaLanguageServer(LanguageServer): + """ + Provides Lua specific instantiation of the LanguageServer class using LuaLS. + """ + + def __init__(self, config: MultilspyConfig, logger: MultilspyLogger, repository_root_path: str): + """ + Creates a LuaLanguageServer instance. This class is not meant to be instantiated directly. + Use LanguageServer.create() instead. + """ + cmd = self.setup_runtime_dependencies(logger, config) + super().__init__( + config, + logger, + repository_root_path, + ProcessLaunchInfo(cmd=cmd, cwd=repository_root_path), + "lua", + ) + self.server_ready = asyncio.Event() + + def setup_runtime_dependencies(self, logger: MultilspyLogger, config: MultilspyConfig) -> List[str]: + """ + Setup runtime dependencies for LuaLS. + """ + if config.server_binary: + assert os.path.exists(config.server_binary), f"Server binary not found: {config.server_binary}" + return [config.server_binary] + + platform_id = PlatformUtils.get_platform_id() + + with open(os.path.join(os.path.dirname(__file__), "runtime_dependencies.json"), "r") as f: + d = json.load(f) + del d["_description"] + + runtime_dependencies = d["runtimeDependencies"] + matching_dependencies = [ + dependency for dependency in runtime_dependencies if dependency["platformId"] == platform_id.value + ] + assert len(matching_dependencies) == 1, f"Platform {platform_id.value} not supported for LuaLS" + dependency = matching_dependencies[0] + + lua_ls_dir = config.server_install_dir or MultilspySettings.get_server_install_directory("lua-language-server") + lua_executable_path = os.path.join(lua_ls_dir, "bin", dependency["binaryName"]) + + if not os.path.exists(lua_executable_path): + os.makedirs(lua_ls_dir, exist_ok=True) + FileUtils.download_and_extract_archive( + logger, dependency["url"], lua_ls_dir, dependency["archiveType"] + ) + assert os.path.exists(lua_executable_path), f"Failed to extract {dependency['binaryName']} to {lua_executable_path}" + + if not PlatformUtils.get_platform_id().value.startswith("win"): + os.chmod( + lua_executable_path, + os.stat(lua_executable_path).st_mode + | stat.S_IXUSR + | stat.S_IXGRP + | stat.S_IXOTH, + ) + + return [lua_executable_path] + + def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: + """ + Returns the initialize params for the Lua Language Server. + """ + with open(os.path.join(os.path.dirname(__file__), "initialize_params.json"), "r") as f: + d = json.load(f) + + del d["_description"] + + d["processId"] = os.getpid() + assert d["rootPath"] == "$rootPath" + d["rootPath"] = repository_absolute_path + + assert d["rootUri"] == "$rootUri" + d["rootUri"] = pathlib.Path(repository_absolute_path).as_uri() + + assert d["workspaceFolders"][0]["uri"] == "$uri" + d["workspaceFolders"][0]["uri"] = pathlib.Path(repository_absolute_path).as_uri() + + assert d["workspaceFolders"][0]["name"] == "$name" + d["workspaceFolders"][0]["name"] = os.path.basename(repository_absolute_path) + + return d + + @asynccontextmanager + async def start_server(self) -> AsyncIterator["LuaLanguageServer"]: + """ + Starts the Lua Language Server, waits for the server to be ready and yields the LanguageServer instance. + """ + + async def register_capability_handler(params): + return None + + async def execute_client_command_handler(params): + return [] + + async def workspace_configuration_handler(params): + items = params.get("items", []) if isinstance(params, dict) else [] + return [{} for _ in items] + + async def work_done_progress_create_handler(params): + return None + + async def do_nothing(params): + return None + + async def window_log_message(msg): + self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) + + async def window_show_message(msg): + self.logger.log(f"LSP: window/showMessage: {msg}", logging.INFO) + + self.server.on_request("client/registerCapability", register_capability_handler) + self.server.on_request("workspace/configuration", workspace_configuration_handler) + self.server.on_request("window/workDoneProgress/create", work_done_progress_create_handler) + self.server.on_request("workspace/executeClientCommand", execute_client_command_handler) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_notification("window/showMessage", window_show_message) + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + self.server.on_notification("$/hello", do_nothing) + + async with super().start_server(): + self.logger.log("Starting LuaLS server process", logging.INFO) + await self.server.start() + initialize_params = self._get_initialize_params(self.repository_root_path) + + self.logger.log( + "Sending initialize request from LSP client to LSP server and awaiting response", + logging.INFO, + ) + init_response = await self.server.send.initialize(initialize_params) + self.logger.log( + f"Received initialize response from LuaLS: {init_response.get('serverInfo', {})}", + logging.INFO, + ) + + self.server.notify.initialized({}) + self.completions_available.set() + + self.server_ready.set() + await self.server_ready.wait() + try: + yield self + finally: + await self.server.shutdown() + await self.server.stop() diff --git a/src/multilspy/language_servers/lua_language_server/runtime_dependencies.json b/src/multilspy/language_servers/lua_language_server/runtime_dependencies.json new file mode 100644 index 0000000..5f93848 --- /dev/null +++ b/src/multilspy/language_servers/lua_language_server/runtime_dependencies.json @@ -0,0 +1,61 @@ +{ + "_description": "Used to download the runtime dependencies for running LuaLS. Obtained from https://github.com/LuaLS/lua-language-server/releases", + "runtimeDependencies": [ + { + "id": "lua-language-server", + "description": "LuaLS for macOS (arm64)", + "url": "https://github.com/LuaLS/lua-language-server/releases/download/3.19.1/lua-language-server-3.19.1-darwin-arm64.tar.gz", + "platformId": "osx-arm64", + "archiveType": "gztar", + "binaryName": "lua-language-server" + }, + { + "id": "lua-language-server", + "description": "LuaLS for macOS (x64)", + "url": "https://github.com/LuaLS/lua-language-server/releases/download/3.19.1/lua-language-server-3.19.1-darwin-x64.tar.gz", + "platformId": "osx-x64", + "archiveType": "gztar", + "binaryName": "lua-language-server" + }, + { + "id": "lua-language-server", + "description": "LuaLS for macOS (universal fallback)", + "url": "https://github.com/LuaLS/lua-language-server/releases/download/3.19.1/lua-language-server-3.19.1-darwin-x64.tar.gz", + "platformId": "osx", + "archiveType": "gztar", + "binaryName": "lua-language-server" + }, + { + "id": "lua-language-server", + "description": "LuaLS for Linux (x64)", + "url": "https://github.com/LuaLS/lua-language-server/releases/download/3.19.1/lua-language-server-3.19.1-linux-x64.tar.gz", + "platformId": "linux-x64", + "archiveType": "gztar", + "binaryName": "lua-language-server" + }, + { + "id": "lua-language-server", + "description": "LuaLS for Linux (arm64)", + "url": "https://github.com/LuaLS/lua-language-server/releases/download/3.19.1/lua-language-server-3.19.1-linux-arm64.tar.gz", + "platformId": "linux-arm64", + "archiveType": "gztar", + "binaryName": "lua-language-server" + }, + { + "id": "lua-language-server", + "description": "LuaLS for Windows (x64)", + "url": "https://github.com/LuaLS/lua-language-server/releases/download/3.19.1/lua-language-server-3.19.1-win32-x64.zip", + "platformId": "win-x64", + "archiveType": "zip", + "binaryName": "lua-language-server.exe" + }, + { + "id": "lua-language-server", + "description": "LuaLS for Windows (x86)", + "url": "https://github.com/LuaLS/lua-language-server/releases/download/3.19.1/lua-language-server-3.19.1-win32-ia32.zip", + "platformId": "win-x86", + "archiveType": "zip", + "binaryName": "lua-language-server.exe" + } + ] +} diff --git a/src/multilspy/language_servers/metals/initialize_params.json b/src/multilspy/language_servers/metals/initialize_params.json new file mode 100644 index 0000000..f9a0ab9 --- /dev/null +++ b/src/multilspy/language_servers/metals/initialize_params.json @@ -0,0 +1,505 @@ +{ + "_description": "The parameters sent by the client when initializing the Metals Scala Language Server with the \"initialize\" request. More details at https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#initialize and https://scalameta.org/metals/docs/integrations/new-editor/", + "processId": "os.getpid()", + "clientInfo": { + "name": "Multilspy Metals Client", + "version": "1.0.0" + }, + "locale": "en", + "rootPath": "repository_absolute_path", + "rootUri": "pathlib.Path(repository_absolute_path).as_uri()", + "capabilities": { + "workspace": { + "applyEdit": true, + "workspaceEdit": { + "documentChanges": true, + "resourceOperations": [ + "create", + "rename", + "delete" + ], + "failureHandling": "textOnlyTransactional", + "normalizesLineEndings": true, + "changeAnnotationSupport": { + "groupsOnLabel": true + } + }, + "didChangeConfiguration": { + "dynamicRegistration": true + }, + "didChangeWatchedFiles": { + "dynamicRegistration": true, + "relativePatternSupport": true + }, + "symbol": { + "dynamicRegistration": true, + "symbolKind": { + "valueSet": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26 + ] + }, + "tagSupport": { + "valueSet": [ + 1 + ] + }, + "resolveSupport": { + "properties": [ + "location.range" + ] + } + }, + "codeLens": { + "refreshSupport": true + }, + "executeCommand": { + "dynamicRegistration": true + }, + "configuration": true, + "workspaceFolders": true, + "semanticTokens": { + "refreshSupport": true + }, + "fileOperations": { + "dynamicRegistration": true, + "didCreate": true, + "didRename": true, + "didDelete": true, + "willCreate": true, + "willRename": true, + "willDelete": true + }, + "inlineValue": { + "refreshSupport": true + }, + "inlayHint": { + "refreshSupport": true + }, + "diagnostics": { + "refreshSupport": true + } + }, + "textDocument": { + "publishDiagnostics": { + "relatedInformation": true, + "versionSupport": false, + "tagSupport": { + "valueSet": [ + 1, + 2 + ] + }, + "codeDescriptionSupport": true, + "dataSupport": true + }, + "synchronization": { + "dynamicRegistration": true, + "willSave": true, + "willSaveWaitUntil": true, + "didSave": true + }, + "completion": { + "dynamicRegistration": true, + "contextSupport": true, + "completionItem": { + "snippetSupport": false, + "commitCharactersSupport": true, + "documentationFormat": [ + "markdown", + "plaintext" + ], + "deprecatedSupport": true, + "preselectSupport": true, + "tagSupport": { + "valueSet": [ + 1 + ] + }, + "insertReplaceSupport": false, + "resolveSupport": { + "properties": [ + "documentation", + "detail", + "additionalTextEdits" + ] + }, + "insertTextModeSupport": { + "valueSet": [ + 1, + 2 + ] + }, + "labelDetailsSupport": true + }, + "insertTextMode": 2, + "completionItemKind": { + "valueSet": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25 + ] + }, + "completionList": { + "itemDefaults": [ + "commitCharacters", + "editRange", + "insertTextFormat", + "insertTextMode" + ] + } + }, + "hover": { + "dynamicRegistration": true, + "contentFormat": [ + "markdown", + "plaintext" + ] + }, + "signatureHelp": { + "dynamicRegistration": true, + "signatureInformation": { + "documentationFormat": [ + "markdown", + "plaintext" + ], + "parameterInformation": { + "labelOffsetSupport": true + }, + "activeParameterSupport": true + }, + "contextSupport": true + }, + "definition": { + "dynamicRegistration": true, + "linkSupport": true + }, + "references": { + "dynamicRegistration": true + }, + "documentHighlight": { + "dynamicRegistration": true + }, + "documentSymbol": { + "dynamicRegistration": true, + "symbolKind": { + "valueSet": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26 + ] + }, + "hierarchicalDocumentSymbolSupport": true, + "tagSupport": { + "valueSet": [ + 1 + ] + }, + "labelSupport": true + }, + "codeAction": { + "dynamicRegistration": true, + "isPreferredSupport": true, + "disabledSupport": true, + "dataSupport": true, + "resolveSupport": { + "properties": [ + "edit" + ] + }, + "codeActionLiteralSupport": { + "codeActionKind": { + "valueSet": [ + "", + "quickfix", + "refactor", + "refactor.extract", + "refactor.inline", + "refactor.rewrite", + "source", + "source.organizeImports" + ] + } + }, + "honorsChangeAnnotations": false + }, + "codeLens": { + "dynamicRegistration": true + }, + "formatting": { + "dynamicRegistration": true + }, + "rangeFormatting": { + "dynamicRegistration": true + }, + "onTypeFormatting": { + "dynamicRegistration": true + }, + "rename": { + "dynamicRegistration": true, + "prepareSupport": true, + "prepareSupportDefaultBehavior": 1, + "honorsChangeAnnotations": true + }, + "documentLink": { + "dynamicRegistration": true, + "tooltipSupport": true + }, + "typeDefinition": { + "dynamicRegistration": true, + "linkSupport": true + }, + "implementation": { + "dynamicRegistration": true, + "linkSupport": true + }, + "colorProvider": { + "dynamicRegistration": true + }, + "foldingRange": { + "dynamicRegistration": true, + "rangeLimit": 5000, + "lineFoldingOnly": true, + "foldingRangeKind": { + "valueSet": [ + "comment", + "imports", + "region" + ] + }, + "foldingRange": { + "collapsedText": false + } + }, + "declaration": { + "dynamicRegistration": true, + "linkSupport": true + }, + "selectionRange": { + "dynamicRegistration": true + }, + "callHierarchy": { + "dynamicRegistration": true + }, + "semanticTokens": { + "dynamicRegistration": true, + "tokenTypes": [ + "namespace", + "type", + "class", + "enum", + "interface", + "struct", + "typeParameter", + "parameter", + "variable", + "property", + "enumMember", + "event", + "function", + "method", + "macro", + "keyword", + "modifier", + "comment", + "string", + "number", + "regexp", + "operator", + "decorator" + ], + "tokenModifiers": [ + "declaration", + "definition", + "readonly", + "static", + "deprecated", + "abstract", + "async", + "modification", + "documentation", + "defaultLibrary" + ], + "formats": [ + "relative" + ], + "requests": { + "range": true, + "full": { + "delta": true + } + }, + "multilineTokenSupport": false, + "overlappingTokenSupport": false, + "serverCancelSupport": true, + "augmentsSyntaxTokens": true + }, + "linkedEditingRange": { + "dynamicRegistration": true + }, + "typeHierarchy": { + "dynamicRegistration": true + }, + "inlineValue": { + "dynamicRegistration": true + }, + "inlayHint": { + "dynamicRegistration": true, + "resolveSupport": { + "properties": [ + "tooltip", + "textEdits", + "label.tooltip", + "label.location", + "label.command" + ] + } + }, + "diagnostic": { + "dynamicRegistration": true, + "relatedDocumentSupport": false + } + }, + "window": { + "showMessage": { + "messageActionItem": { + "additionalPropertiesSupport": true + } + }, + "showDocument": { + "support": true + }, + "workDoneProgress": true + }, + "general": { + "staleRequestSupport": { + "cancel": true, + "retryOnContentModified": [ + "textDocument/semanticTokens/full", + "textDocument/semanticTokens/range", + "textDocument/semanticTokens/full/delta" + ] + }, + "regularExpressions": { + "engine": "ECMAScript", + "version": "ES2020" + }, + "markdown": { + "parser": "marked", + "version": "1.1.0" + }, + "positionEncodings": [ + "utf-16" + ] + }, + "notebookDocument": { + "synchronization": { + "dynamicRegistration": true, + "executionSummarySupport": true + } + } + }, + "initializationOptions": { + "compilerOptions": { + "completionCommand": "editor.action.triggerSuggest", + "isCompletionItemDetailEnabled": true, + "isCompletionItemDocumentationEnabled": true, + "isCompletionItemResolve": true, + "isHoverDocumentationEnabled": true, + "isSignatureHelpDocumentationEnabled": true, + "overrideDefFormat": "unicode", + "parameterHintsCommand": "editor.action.triggerParameterHints", + "snippetAutoIndent": false + }, + "debuggingProvider": false, + "decorationProvider": false, + "didFocusProvider": false, + "disableColorOutput": false, + "doctorProvider": "none", + "executeClientCommandProvider": false, + "globSyntax": "uri", + "icons": "none", + "inlineDecorationProvider": false, + "inputBoxProvider": false, + "isExitOnShutdown": true, + "isHttpEnabled": false, + "openFilesOnRenameProvider": false, + "quickPickProvider": false, + "renameFileThreshold": 300, + "slowTaskProvider": false, + "statusBarProvider": "log-message", + "treeViewProvider": false, + "workspaceFolders": "[pathlib.Path(repository_absolute_path).as_uri()]" + }, + "trace": "verbose", + "workspaceFolders": "[\n {\n \"uri\": pathlib.Path(repository_absolute_path).as_uri(),\n \"name\": os.path.basename(repository_absolute_path),\n }\n ]" +} diff --git a/src/multilspy/language_servers/metals/metals.py b/src/multilspy/language_servers/metals/metals.py new file mode 100644 index 0000000..e3ab1d1 --- /dev/null +++ b/src/multilspy/language_servers/metals/metals.py @@ -0,0 +1,437 @@ +""" +Provides Scala specific instantiation of the LanguageServer class using Metals. +Contains various configurations and settings specific to Scala. +Metals is the Scala language server from https://scalameta.org/metals/ +""" + +import asyncio +import dataclasses +import json +import logging +import os +import stat +import pathlib +import subprocess +from contextlib import asynccontextmanager +from typing import AsyncIterator + +from multilspy.multilspy_logger import MultilspyLogger +from multilspy.language_server import LanguageServer +from multilspy.lsp_protocol_handler.server import ProcessLaunchInfo +from multilspy.lsp_protocol_handler.lsp_types import InitializeParams +from multilspy.multilspy_config import MultilspyConfig +from multilspy.multilspy_utils import FileUtils +from multilspy.multilspy_utils import PlatformUtils + + +@dataclasses.dataclass +class MetalsRuntimeDependencyPaths: + """ + Stores the paths to the runtime dependencies of Metals + """ + java_path: str + java_home_path: str + metals_executable_path: str + + +class Metals(LanguageServer): + """ + Provides Scala specific instantiation of the LanguageServer class using Metals. + Contains various configurations and settings specific to Scala. + """ + + def __init__(self, config: MultilspyConfig, logger: MultilspyLogger, repository_root_path: str): + """ + Creates a Metals instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. + """ + runtime_dependency_paths = self.setup_runtime_dependencies(logger, config) + self.runtime_dependency_paths = runtime_dependency_paths + + # Create command to execute the Metals launcher + cmd = self.runtime_dependency_paths.metals_executable_path + + # Set environment variables including JAVA_HOME + proc_env = {"JAVA_HOME": self.runtime_dependency_paths.java_home_path} + + # Track active $/progress tokens to know when Metals is quiescent + self._active_progress_tokens: set = set() + self._indexing_complete = asyncio.Event() + self._no_indexing_started = True + + super().__init__( + config, + logger, + repository_root_path, + ProcessLaunchInfo(cmd=cmd, env=proc_env, cwd=repository_root_path), + "scala", + ) + + def setup_runtime_dependencies(self, logger: MultilspyLogger, config: MultilspyConfig) -> MetalsRuntimeDependencyPaths: + """ + Setup runtime dependencies for Metals. + Downloads Java runtime and bootstraps Metals via Coursier. + """ + platform_id = PlatformUtils.get_platform_id() + + # Verify platform support + assert platform_id.value.startswith("win-") or platform_id.value.startswith("linux-") or platform_id.value.startswith("osx-"), \ + "Only Windows, Linux and macOS platforms are supported for Scala in multilspy at the moment" + + # Load dependency information + with open(os.path.join(os.path.dirname(__file__), "runtime_dependencies.json"), "r") as f: + d = json.load(f) + del d["_description"] + + metals_config = d["metals"] + java_dependency = d["java"][platform_id.value] + + # Setup paths for dependencies + static_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "static")) + os.makedirs(static_dir, exist_ok=True) + + # Setup Java paths + java_dir = os.path.join(static_dir, "java") + os.makedirs(java_dir, exist_ok=True) + + java_home_path = os.path.abspath(os.path.join(java_dir, java_dependency["java_home_path"])) + java_path = os.path.abspath(os.path.join(java_dir, java_dependency["java_path"])) + + # Download and extract Java if not exists + if not os.path.exists(java_path): + logger.log(f"Downloading Java for {platform_id.value}...", logging.INFO) + FileUtils.download_and_extract_archive( + logger, java_dependency["url"], java_dir, java_dependency["archiveType"] + ) + # Make Java executable + if not platform_id.value.startswith("win-"): + os.chmod(java_path, 0o755) + + assert os.path.exists(java_path), f"Java executable not found at {java_path}" + + # Setup Metals via Coursier bootstrap + metals_dir = os.path.join(static_dir, "metals") + os.makedirs(metals_dir, exist_ok=True) + + metals_version = metals_config["version"] + metals_artifact = metals_config["mavenArtifact"] + + if platform_id.value.startswith("win-"): + metals_executable_path = os.path.abspath(os.path.join(metals_dir, "metals.bat")) + else: + metals_executable_path = os.path.abspath(os.path.join(metals_dir, "metals")) + + if not os.path.exists(metals_executable_path): + logger.log(f"Bootstrapping Metals {metals_version} via Coursier...", logging.INFO) + self._bootstrap_metals(logger, java_path, metals_artifact, metals_executable_path, platform_id) + + assert os.path.exists(metals_executable_path), f"Metals executable not found at {metals_executable_path}" + + return MetalsRuntimeDependencyPaths( + java_path=java_path, + java_home_path=java_home_path, + metals_executable_path=metals_executable_path, + ) + + def _bootstrap_metals( + self, + logger: MultilspyLogger, + java_path: str, + metals_artifact: str, + metals_executable_path: str, + platform_id, + ) -> None: + """ + Bootstrap Metals using Coursier. + Downloads Coursier if necessary, then uses it to create a Metals launcher. + """ + static_dir = os.path.join(os.path.dirname(__file__), "static") + coursier_dir = os.path.join(static_dir, "coursier") + os.makedirs(coursier_dir, exist_ok=True) + + if platform_id.value.startswith("win-"): + coursier_path = os.path.join(coursier_dir, "cs.exe") + coursier_url = "https://github.com/coursier/launchers/raw/master/cs-x86_64-pc-win32.zip" + coursier_archive_type = "zip" + elif platform_id.value.startswith("osx-"): + if platform_id.value == "osx-arm64": + coursier_url = "https://github.com/coursier/launchers/raw/master/cs-aarch64-apple-darwin.gz" + else: + coursier_url = "https://github.com/coursier/launchers/raw/master/cs-x86_64-apple-darwin.gz" + coursier_path = os.path.join(coursier_dir, "cs") + coursier_archive_type = "gz" + else: + if platform_id.value == "linux-arm64": + coursier_url = "https://github.com/coursier/launchers/raw/master/cs-aarch64-pc-linux.gz" + else: + coursier_url = "https://github.com/coursier/launchers/raw/master/cs-x86_64-pc-linux.gz" + coursier_path = os.path.join(coursier_dir, "cs") + coursier_archive_type = "gz" + + # Download Coursier if not present + if not os.path.exists(coursier_path): + logger.log("Downloading Coursier...", logging.INFO) + if coursier_archive_type == "gz": + FileUtils.download_and_extract_archive( + logger, coursier_url, coursier_path, coursier_archive_type + ) + else: + FileUtils.download_and_extract_archive( + logger, coursier_url, coursier_dir, coursier_archive_type + ) + + if not platform_id.value.startswith("win-"): + os.chmod(coursier_path, stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR | stat.S_IRGRP | stat.S_IXGRP | stat.S_IROTH | stat.S_IXOTH) + + assert os.path.exists(coursier_path), f"Coursier not found at {coursier_path}" + + # Use Coursier to bootstrap Metals + logger.log(f"Running Coursier bootstrap for {metals_artifact}...", logging.INFO) + bootstrap_cmd = [ + coursier_path, + "bootstrap", + "--java-opt", "-XX:+UseG1GC", + "--java-opt", "-XX:+UseStringDeduplication", + "--java-opt", "-Xss4m", + "--java-opt", "-Xms100m", + metals_artifact, + "-o", metals_executable_path, + "-f", + ] + + env = os.environ.copy() + env["JAVA_HOME"] = os.path.dirname(os.path.dirname(java_path)) + env["PATH"] = os.path.dirname(java_path) + os.pathsep + env.get("PATH", "") + + result = subprocess.run( + bootstrap_cmd, + env=env, + capture_output=True, + timeout=300, + ) + + if result.returncode != 0: + logger.log(f"Coursier bootstrap failed: {result.stderr.decode('utf-8', errors='replace')}", logging.ERROR) + raise RuntimeError( + f"Failed to bootstrap Metals: {result.stderr.decode('utf-8', errors='replace')}" + ) + + if not platform_id.value.startswith("win-"): + os.chmod(metals_executable_path, stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR | stat.S_IRGRP | stat.S_IXGRP | stat.S_IROTH | stat.S_IXOTH) + + logger.log("Metals bootstrap complete.", logging.INFO) + + def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: + """ + Returns the initialize params for the Metals Language Server. + """ + with open(str(pathlib.PurePath(os.path.dirname(__file__), "initialize_params.json")), "r") as f: + d: InitializeParams = json.load(f) + + del d["_description"] + + if not os.path.isabs(repository_absolute_path): + repository_absolute_path = os.path.abspath(repository_absolute_path) + + assert d["processId"] == "os.getpid()" + d["processId"] = os.getpid() + + assert d["rootPath"] == "repository_absolute_path" + d["rootPath"] = repository_absolute_path + + assert d["rootUri"] == "pathlib.Path(repository_absolute_path).as_uri()" + d["rootUri"] = pathlib.Path(repository_absolute_path).as_uri() + + assert d["initializationOptions"]["workspaceFolders"] == "[pathlib.Path(repository_absolute_path).as_uri()]" + d["initializationOptions"]["workspaceFolders"] = [pathlib.Path(repository_absolute_path).as_uri()] + + assert ( + d["workspaceFolders"] + == '[\n {\n "uri": pathlib.Path(repository_absolute_path).as_uri(),\n "name": os.path.basename(repository_absolute_path),\n }\n ]' + ) + d["workspaceFolders"] = [ + { + "uri": pathlib.Path(repository_absolute_path).as_uri(), + "name": os.path.basename(repository_absolute_path), + } + ] + + return d + + @asynccontextmanager + async def start_server(self) -> AsyncIterator["Metals"]: + """ + Starts the Metals Language Server, waits for the server to be ready and yields the LanguageServer instance. + + Usage: + ``` + async with lsp.start_server(): + # LanguageServer has been initialized and ready to serve requests + await lsp.request_definition(...) + await lsp.request_references(...) + # Shutdown the LanguageServer on exit from scope + # LanguageServer has been shutdown + ``` + """ + async def execute_client_command_handler(params): + return [] + + async def do_nothing(params): + return + + async def window_log_message(msg): + self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) + + async def work_done_progress_create(params): + self.logger.log(f"LSP: window/workDoneProgress/create token={params.get('token')}", logging.INFO) + return None + + async def handle_progress(params): + """ + Track $/progress tokens to detect when Metals finishes build import, + compilation, and indexing. Metals sends begin/report/end for each task. + When all progress tokens have ended, the server is quiescent. + """ + token = params.get("token", "") + value = params.get("value", {}) + kind = value.get("kind", "") + + if kind == "begin": + self._no_indexing_started = False + self._active_progress_tokens.add(token) + self.logger.log( + f"LSP: $/progress begin: {value.get('title', '')} ({token})", + logging.INFO, + ) + elif kind == "end": + self._active_progress_tokens.discard(token) + self.logger.log( + f"LSP: $/progress end: {value.get('message', '')} ({token})", + logging.INFO, + ) + if not self._active_progress_tokens and not self._indexing_complete.is_set(): + self._indexing_complete.set() + + async def metals_execute_client_command(params): + """ + Handle metals/executeClientCommand notifications. + Auto-accept build import to ensure the project is fully indexed. + """ + command = params.get("command", "") + self.logger.log(f"LSP: metals/executeClientCommand: {command}", logging.INFO) + if command == "metals-build-import": + # Auto-trigger build import so the workspace is fully compiled + self.logger.log("Auto-triggering build import for Metals", logging.INFO) + await self.server.send.execute_command( + {"command": "build-import", "arguments": []} + ) + + async def workspace_configuration_handler(params): + items = params.get("items", []) if isinstance(params, dict) else [] + self.logger.log(f"LSP: workspace/configuration items={items}", logging.INFO) + result = [] + for item in items: + section = item.get("section", "") + if section == "metals" or not section: + result.append({ + "autoImportBuilds": "off", + "fallbackScalaVersion": "2.13.14", + }) + else: + result.append({}) + return result + + async def window_show_message_request(params): + self.logger.log(f"LSP: window/showMessageRequest: {params}", logging.INFO) + return None + + async def semantic_tokens_refresh_handler(params): + return None + + self.server.on_request("client/registerCapability", do_nothing) + self.server.on_request("window/workDoneProgress/create", work_done_progress_create) + self.server.on_request("window/showMessageRequest", window_show_message_request) + self.server.on_request("workspace/semanticTokens/refresh", semantic_tokens_refresh_handler) + self.server.on_notification("language/status", do_nothing) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_request("workspace/executeClientCommand", execute_client_command_handler) + self.server.on_notification("$/progress", handle_progress) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + self.server.on_notification("language/actionableNotification", do_nothing) + self.server.on_notification("metals/status", do_nothing) + self.server.on_notification("metals/executeClientCommand", metals_execute_client_command) + self.server.on_request("workspace/configuration", workspace_configuration_handler) + + async with super().start_server(): + self.logger.log("Starting Metals server process", logging.INFO) + await self.server.start() + initialize_params = self._get_initialize_params(self.repository_root_path) + + self.logger.log( + "Sending initialize request from LSP client to LSP server and awaiting response", + logging.INFO, + ) + init_response = await self.server.send.initialize(initialize_params) + + capabilities = init_response["capabilities"] + assert "textDocumentSync" in capabilities, "Server must support textDocumentSync" + assert "completionProvider" in capabilities, "Server must support code completion" + assert "definitionProvider" in capabilities, "Server must support go to definition" + assert "referencesProvider" in capabilities, "Server must support find references" + assert "documentSymbolProvider" in capabilities, "Server must support document symbols" + + self.server.notify.initialized({}) + self.completions_available.set() + + # NOTE ON READINESS WAIT: + # We wait here for Metals to finish build import, compilation, and + # indexing before yielding. This mirrors how JDTLS waits for + # ServiceReady and Rust Analyzer waits for quiescent=true. + # + # Why: Metals uses "cascade compilation" for textDocument/references. + # If downstream targets haven't been compiled yet, references returns + # immediately with incomplete results and compiles in the background. + # By waiting here, we ensure results are complete on the first call. + # + # Tradeoff: This makes start_server() slower, especially for large + # projects with many build targets. If you only need definition, + # hover, or document symbols (which work without full compilation), + # you could remove this wait and yield immediately after + # initialized() — but references may then be incomplete until + # Metals finishes background compilation. + self.logger.log( + "Waiting for Metals to complete build import and indexing...", + logging.INFO, + ) + try: + await asyncio.wait_for(self._indexing_complete.wait(), timeout=10.0) + except asyncio.TimeoutError: + if self._no_indexing_started: + self.logger.log( + "No background indexing detected from Metals, proceeding immediately", + logging.INFO, + ) + else: + self.logger.log( + "Background indexing in progress for Metals, waiting for completion...", + logging.INFO, + ) + try: + await asyncio.wait_for(self._indexing_complete.wait(), timeout=60.0) + except asyncio.TimeoutError: + self.logger.log( + "Metals indexing wait timed out, proceeding with available index", + logging.WARNING, + ) + self.logger.log("Metals is ready", logging.INFO) + + yield self + + try: + await asyncio.wait_for(self.server.shutdown(), timeout=5.0) + except Exception as e: + self.logger.log(f"Error during Metals server shutdown: {str(e)}", logging.WARNING) + finally: + try: + await asyncio.wait_for(self.server.stop(), timeout=5.0) + except Exception: + pass diff --git a/src/multilspy/language_servers/metals/runtime_dependencies.json b/src/multilspy/language_servers/metals/runtime_dependencies.json new file mode 100644 index 0000000..0f0de6b --- /dev/null +++ b/src/multilspy/language_servers/metals/runtime_dependencies.json @@ -0,0 +1,46 @@ +{ + "_description": "Used to download the runtime dependencies for Metals Scala Language Server. Metals is distributed via Coursier from Maven Central (https://scalameta.org/metals/). Java runtime is obtained from the vscode-java extension (https://github.com/redhat-developer/vscode-java).", + "metals": { + "version": "1.6.6", + "coursierUrl": { + "platform-agnostic": { + "url": "https://github.com/coursier/launchers/raw/master/cs-x86_64-pc-linux.gz", + "archiveType": "gz", + "binaryName": "cs" + } + }, + "mavenArtifact": "org.scalameta:metals_2.13:1.6.6" + }, + "java": { + "win-x64": { + "url": "https://github.com/redhat-developer/vscode-java/releases/download/v1.23.0/java@win32-x64-1.23.0.vsix", + "archiveType": "zip", + "java_home_path": "extension/jre/17.0.8.1-win32-x86_64", + "java_path": "extension/jre/17.0.8.1-win32-x86_64/bin/java.exe" + }, + "linux-x64": { + "url": "https://github.com/redhat-developer/vscode-java/releases/download/v1.23.0/java@linux-x64-1.23.0.vsix", + "archiveType": "zip", + "java_home_path": "extension/jre/17.0.8.1-linux-x86_64", + "java_path": "extension/jre/17.0.8.1-linux-x86_64/bin/java" + }, + "linux-arm64": { + "url": "https://github.com/redhat-developer/vscode-java/releases/download/v1.23.0/java@linux-arm64-1.23.0.vsix", + "archiveType": "zip", + "java_home_path": "extension/jre/17.0.8.1-linux-aarch64", + "java_path": "extension/jre/17.0.8.1-linux-aarch64/bin/java" + }, + "osx-x64": { + "url": "https://github.com/redhat-developer/vscode-java/releases/download/v1.23.0/java@darwin-x64-1.23.0.vsix", + "archiveType": "zip", + "java_home_path": "extension/jre/17.0.8.1-macosx-x86_64", + "java_path": "extension/jre/17.0.8.1-macosx-x86_64/bin/java" + }, + "osx-arm64": { + "url": "https://github.com/redhat-developer/vscode-java/releases/download/v1.23.0/java@darwin-arm64-1.23.0.vsix", + "archiveType": "zip", + "java_home_path": "extension/jre/17.0.8.1-macosx-aarch64", + "java_path": "extension/jre/17.0.8.1-macosx-aarch64/bin/java" + } + } +} diff --git a/src/multilspy/language_servers/sourcekit_lsp/initialize_params.json b/src/multilspy/language_servers/sourcekit_lsp/initialize_params.json new file mode 100644 index 0000000..5250490 --- /dev/null +++ b/src/multilspy/language_servers/sourcekit_lsp/initialize_params.json @@ -0,0 +1,479 @@ +{ + "_description": "The parameters sent by the client when initializing SourceKit-LSP with the \"initialize\" request. More details at https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#initialize and https://github.com/swiftlang/sourcekit-lsp", + "processId": "os.getpid()", + "clientInfo": { + "name": "Multilspy SourceKit-LSP Client", + "version": "1.0.0" + }, + "locale": "en", + "rootPath": "$rootPath", + "rootUri": "$rootUri", + "capabilities": { + "workspace": { + "applyEdit": true, + "workspaceEdit": { + "documentChanges": true, + "resourceOperations": [ + "create", + "rename", + "delete" + ], + "failureHandling": "textOnlyTransactional", + "normalizesLineEndings": true, + "changeAnnotationSupport": { + "groupsOnLabel": true + } + }, + "didChangeConfiguration": { + "dynamicRegistration": true + }, + "didChangeWatchedFiles": { + "dynamicRegistration": true, + "relativePatternSupport": true + }, + "symbol": { + "dynamicRegistration": true, + "symbolKind": { + "valueSet": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26 + ] + }, + "tagSupport": { + "valueSet": [ + 1 + ] + }, + "resolveSupport": { + "properties": [ + "location.range" + ] + } + }, + "codeLens": { + "refreshSupport": true + }, + "executeCommand": { + "dynamicRegistration": true + }, + "configuration": true, + "workspaceFolders": true, + "semanticTokens": { + "refreshSupport": true + }, + "fileOperations": { + "dynamicRegistration": true, + "didCreate": true, + "didRename": true, + "didDelete": true, + "willCreate": true, + "willRename": true, + "willDelete": true + }, + "inlineValue": { + "refreshSupport": true + }, + "inlayHint": { + "refreshSupport": true + }, + "diagnostics": { + "refreshSupport": true + } + }, + "textDocument": { + "publishDiagnostics": { + "relatedInformation": true, + "versionSupport": false, + "tagSupport": { + "valueSet": [ + 1, + 2 + ] + }, + "codeDescriptionSupport": true, + "dataSupport": true + }, + "synchronization": { + "dynamicRegistration": true, + "willSave": true, + "willSaveWaitUntil": true, + "didSave": true + }, + "completion": { + "dynamicRegistration": true, + "contextSupport": true, + "completionItem": { + "snippetSupport": false, + "commitCharactersSupport": true, + "documentationFormat": [ + "markdown", + "plaintext" + ], + "deprecatedSupport": true, + "preselectSupport": true, + "tagSupport": { + "valueSet": [ + 1 + ] + }, + "insertReplaceSupport": false, + "resolveSupport": { + "properties": [ + "documentation", + "detail", + "additionalTextEdits" + ] + }, + "insertTextModeSupport": { + "valueSet": [ + 1, + 2 + ] + }, + "labelDetailsSupport": true + }, + "insertTextMode": 2, + "completionItemKind": { + "valueSet": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25 + ] + }, + "completionList": { + "itemDefaults": [ + "commitCharacters", + "editRange", + "insertTextFormat", + "insertTextMode" + ] + } + }, + "hover": { + "dynamicRegistration": true, + "contentFormat": [ + "markdown", + "plaintext" + ] + }, + "signatureHelp": { + "dynamicRegistration": true, + "signatureInformation": { + "documentationFormat": [ + "markdown", + "plaintext" + ], + "parameterInformation": { + "labelOffsetSupport": true + }, + "activeParameterSupport": true + }, + "contextSupport": true + }, + "definition": { + "dynamicRegistration": true, + "linkSupport": true + }, + "references": { + "dynamicRegistration": true + }, + "documentHighlight": { + "dynamicRegistration": true + }, + "documentSymbol": { + "dynamicRegistration": true, + "symbolKind": { + "valueSet": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26 + ] + }, + "hierarchicalDocumentSymbolSupport": true, + "tagSupport": { + "valueSet": [ + 1 + ] + }, + "labelSupport": true + }, + "codeAction": { + "dynamicRegistration": true, + "isPreferredSupport": true, + "disabledSupport": true, + "dataSupport": true, + "resolveSupport": { + "properties": [ + "edit" + ] + }, + "codeActionLiteralSupport": { + "codeActionKind": { + "valueSet": [ + "", + "quickfix", + "refactor", + "refactor.extract", + "refactor.inline", + "refactor.rewrite", + "source", + "source.organizeImports" + ] + } + }, + "honorsChangeAnnotations": false + }, + "codeLens": { + "dynamicRegistration": true + }, + "formatting": { + "dynamicRegistration": true + }, + "rangeFormatting": { + "dynamicRegistration": true + }, + "onTypeFormatting": { + "dynamicRegistration": true + }, + "rename": { + "dynamicRegistration": true, + "prepareSupport": true, + "prepareSupportDefaultBehavior": 1, + "honorsChangeAnnotations": true + }, + "documentLink": { + "dynamicRegistration": true, + "tooltipSupport": true + }, + "typeDefinition": { + "dynamicRegistration": true, + "linkSupport": true + }, + "implementation": { + "dynamicRegistration": true, + "linkSupport": true + }, + "colorProvider": { + "dynamicRegistration": true + }, + "foldingRange": { + "dynamicRegistration": true, + "rangeLimit": 5000, + "lineFoldingOnly": true, + "foldingRangeKind": { + "valueSet": [ + "comment", + "imports", + "region" + ] + }, + "foldingRange": { + "collapsedText": false + } + }, + "declaration": { + "dynamicRegistration": true, + "linkSupport": true + }, + "selectionRange": { + "dynamicRegistration": true + }, + "callHierarchy": { + "dynamicRegistration": true + }, + "semanticTokens": { + "dynamicRegistration": true, + "tokenTypes": [ + "namespace", + "type", + "class", + "enum", + "interface", + "struct", + "typeParameter", + "parameter", + "variable", + "property", + "enumMember", + "event", + "function", + "method", + "macro", + "keyword", + "modifier", + "comment", + "string", + "number", + "regexp", + "operator", + "decorator" + ], + "tokenModifiers": [ + "declaration", + "definition", + "readonly", + "static", + "deprecated", + "abstract", + "async", + "modification", + "documentation", + "defaultLibrary" + ], + "formats": [ + "relative" + ], + "requests": { + "range": true, + "full": { + "delta": true + } + }, + "multilineTokenSupport": false, + "overlappingTokenSupport": false, + "serverCancelSupport": true, + "augmentsSyntaxTokens": true + }, + "linkedEditingRange": { + "dynamicRegistration": true + }, + "typeHierarchy": { + "dynamicRegistration": true + }, + "inlineValue": { + "dynamicRegistration": true + }, + "inlayHint": { + "dynamicRegistration": true, + "resolveSupport": { + "properties": [ + "tooltip", + "textEdits", + "label.tooltip", + "label.location", + "label.command" + ] + } + }, + "diagnostic": { + "dynamicRegistration": true, + "relatedDocumentSupport": false + } + }, + "window": { + "showMessage": { + "messageActionItem": { + "additionalPropertiesSupport": true + } + }, + "showDocument": { + "support": true + }, + "workDoneProgress": true + }, + "general": { + "staleRequestSupport": { + "cancel": true, + "retryOnContentModified": [ + "textDocument/semanticTokens/full", + "textDocument/semanticTokens/range", + "textDocument/semanticTokens/full/delta" + ] + }, + "regularExpressions": { + "engine": "ECMAScript", + "version": "ES2020" + }, + "markdown": { + "parser": "marked", + "version": "1.1.0" + }, + "positionEncodings": [ + "utf-16" + ] + }, + "notebookDocument": { + "synchronization": { + "dynamicRegistration": true, + "executionSummarySupport": true + } + } + }, + "initializationOptions": {}, + "trace": "verbose", + "workspaceFolders": [ + { + "uri": "$uri", + "name": "$name" + } + ] +} diff --git a/src/multilspy/language_servers/sourcekit_lsp/runtime_dependencies.json b/src/multilspy/language_servers/sourcekit_lsp/runtime_dependencies.json new file mode 100644 index 0000000..fa75ce3 --- /dev/null +++ b/src/multilspy/language_servers/sourcekit_lsp/runtime_dependencies.json @@ -0,0 +1,24 @@ +{ + "_description": "Used to download the runtime dependencies for SourceKit-LSP. On Linux, the Swift toolchain is downloaded from swift.org. On macOS, sourcekit-lsp is discovered from the Xcode toolchain or swift.org toolchain install. See https://www.swift.org/install/ for details.", + "swiftVersion": "6.0.3", + "runtimeDependencies": [ + { + "id": "SwiftToolchain", + "description": "Swift toolchain for Linux (x64, Ubuntu 22.04)", + "url": "https://download.swift.org/swift-6.0.3-release/ubuntu2204/swift-6.0.3-RELEASE/swift-6.0.3-RELEASE-ubuntu22.04.tar.gz", + "platformId": "linux-x64", + "archiveType": "gztar", + "toolchainPath": "swift-6.0.3-RELEASE-ubuntu22.04", + "binaryPath": "usr/bin/sourcekit-lsp" + }, + { + "id": "SwiftToolchain", + "description": "Swift toolchain for Linux (arm64, Ubuntu 22.04)", + "url": "https://download.swift.org/swift-6.0.3-release/ubuntu2204-aarch64/swift-6.0.3-RELEASE/swift-6.0.3-RELEASE-ubuntu22.04-aarch64.tar.gz", + "platformId": "linux-arm64", + "archiveType": "gztar", + "toolchainPath": "swift-6.0.3-RELEASE-ubuntu22.04-aarch64", + "binaryPath": "usr/bin/sourcekit-lsp" + } + ] +} diff --git a/src/multilspy/language_servers/sourcekit_lsp/sourcekit_lsp.py b/src/multilspy/language_servers/sourcekit_lsp/sourcekit_lsp.py new file mode 100644 index 0000000..76f3e50 --- /dev/null +++ b/src/multilspy/language_servers/sourcekit_lsp/sourcekit_lsp.py @@ -0,0 +1,312 @@ +""" +Provides Swift specific instantiation of the LanguageServer class using SourceKit-LSP. Contains various configurations and settings specific to Swift. +""" + +import asyncio +import json +import logging +import os +import pathlib +import stat +import subprocess +from contextlib import asynccontextmanager +from typing import AsyncIterator, Union + +from multilspy.multilspy_logger import MultilspyLogger +from multilspy.language_server import LanguageServer +from multilspy.lsp_protocol_handler.server import ProcessLaunchInfo +from multilspy.lsp_protocol_handler.lsp_types import InitializeParams +from multilspy.multilspy_config import MultilspyConfig +from multilspy.multilspy_utils import FileUtils +from multilspy.multilspy_utils import PlatformUtils + + +class SourceKitLSP(LanguageServer): + """ + Provides Swift specific instantiation of the LanguageServer class using SourceKit-LSP. + Contains various configurations and settings specific to Swift. + """ + + def __init__(self, config: MultilspyConfig, logger: MultilspyLogger, repository_root_path: str): + """ + Creates a SourceKitLSP instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. + """ + sourcekit_lsp_path = self.setup_runtime_dependencies(logger, config) + + super().__init__( + config, + logger, + repository_root_path, + ProcessLaunchInfo(cmd=sourcekit_lsp_path, cwd=repository_root_path), + "swift", + ) + + def setup_runtime_dependencies(self, logger: MultilspyLogger, config: MultilspyConfig) -> str: + """ + Setup runtime dependencies for SourceKit-LSP. + + Strategy: + - On macOS: Discover sourcekit-lsp from Xcode or swift.org toolchain install. + - On Linux: Download the Swift toolchain from swift.org if not already cached. + - On Windows: Discover sourcekit-lsp from a local Swift toolchain install. + + Returns the path to the sourcekit-lsp executable. + """ + platform_id = PlatformUtils.get_platform_id() + + # First, try to discover a locally installed sourcekit-lsp + local_path = self._discover_local_sourcekit_lsp(logger, platform_id) + if local_path is not None: + logger.log(f"Found local sourcekit-lsp at {local_path}", logging.INFO) + return local_path + + # On macOS and Windows, we require a local install + if platform_id.value.startswith("osx-"): + raise RuntimeError( + "sourcekit-lsp not found. Please install Swift via Xcode or from https://www.swift.org/install/macos/. " + "Ensure sourcekit-lsp is available via 'xcrun --find sourcekit-lsp' or on PATH." + ) + elif platform_id.value.startswith("win-"): + raise RuntimeError( + "sourcekit-lsp not found. Please install Swift from https://www.swift.org/install/windows/. " + "Ensure sourcekit-lsp is available on PATH." + ) + + # On Linux, download the Swift toolchain + logger.log("sourcekit-lsp not found locally, downloading Swift toolchain...", logging.INFO) + return self._download_swift_toolchain(logger, platform_id) + + def _discover_local_sourcekit_lsp(self, logger: MultilspyLogger, platform_id) -> Union[str, None]: + """ + Try to find a locally installed sourcekit-lsp binary. + Returns the path if found, None otherwise. + """ + # On macOS, try xcrun first (works with Xcode and command line tools) + if platform_id.value.startswith("osx-"): + try: + result = subprocess.run( + ["xcrun", "--find", "sourcekit-lsp"], + capture_output=True, + timeout=10, + ) + if result.returncode == 0: + path = result.stdout.decode("utf-8").strip() + if os.path.exists(path): + return path + except (subprocess.TimeoutExpired, FileNotFoundError): + pass + + # Try which/where on all platforms + which_cmd = "where" if platform_id.value.startswith("win-") else "which" + try: + result = subprocess.run( + [which_cmd, "sourcekit-lsp"], + capture_output=True, + timeout=10, + ) + if result.returncode == 0: + path = result.stdout.decode("utf-8").strip().split("\n")[0] + if os.path.exists(path): + return path + except (subprocess.TimeoutExpired, FileNotFoundError): + pass + + return None + + def _download_swift_toolchain(self, logger: MultilspyLogger, platform_id) -> str: + """ + Download the Swift toolchain for Linux and return the path to sourcekit-lsp. + """ + with open(os.path.join(os.path.dirname(__file__), "runtime_dependencies.json"), "r") as f: + d = json.load(f) + del d["_description"] + + runtime_dependencies = d["runtimeDependencies"] + runtime_dependencies = [ + dep for dep in runtime_dependencies if dep["platformId"] == platform_id.value + ] + + if len(runtime_dependencies) == 0: + raise RuntimeError( + f"No Swift toolchain download available for platform {platform_id.value}. " + "Please install Swift manually from https://www.swift.org/install/" + ) + + assert len(runtime_dependencies) == 1 + dependency = runtime_dependencies[0] + + static_dir = os.path.join(os.path.dirname(__file__), "static") + os.makedirs(static_dir, exist_ok=True) + + toolchain_dir = os.path.join(static_dir, dependency["toolchainPath"]) + sourcekit_lsp_path = os.path.join(toolchain_dir, dependency["binaryPath"]) + + if not os.path.exists(sourcekit_lsp_path): + logger.log(f"Downloading Swift toolchain for {platform_id.value}...", logging.INFO) + FileUtils.download_and_extract_archive( + logger, dependency["url"], static_dir, dependency["archiveType"] + ) + + assert os.path.exists(sourcekit_lsp_path), f"sourcekit-lsp not found at {sourcekit_lsp_path}" + + if not platform_id.value.startswith("win-"): + os.chmod(sourcekit_lsp_path, stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR | stat.S_IRGRP | stat.S_IXGRP | stat.S_IROTH | stat.S_IXOTH) + + logger.log(f"Swift toolchain ready at {toolchain_dir}", logging.INFO) + return sourcekit_lsp_path + + def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: + """ + Returns the initialize params for SourceKit-LSP. + """ + with open(os.path.join(os.path.dirname(__file__), "initialize_params.json"), "r") as f: + d = json.load(f) + + del d["_description"] + + assert d["processId"] == "os.getpid()" + d["processId"] = os.getpid() + + assert d["rootPath"] == "$rootPath" + d["rootPath"] = repository_absolute_path + + assert d["rootUri"] == "$rootUri" + d["rootUri"] = pathlib.Path(repository_absolute_path).as_uri() + + assert d["workspaceFolders"][0]["uri"] == "$uri" + d["workspaceFolders"][0]["uri"] = pathlib.Path(repository_absolute_path).as_uri() + + assert d["workspaceFolders"][0]["name"] == "$name" + d["workspaceFolders"][0]["name"] = os.path.basename(repository_absolute_path) + + return d + + @asynccontextmanager + async def start_server(self) -> AsyncIterator["SourceKitLSP"]: + """ + Starts the SourceKit-LSP Language Server, waits for the server to be ready and yields the LanguageServer instance. + + Usage: + ``` + async with lsp.start_server(): + # LanguageServer has been initialized and ready to serve requests + await lsp.request_definition(...) + await lsp.request_references(...) + # Shutdown the LanguageServer on exit from scope + # LanguageServer has been shutdown + ``` + """ + # Track $/progress tokens to detect when background indexing completes + active_progress_tokens: set = set() + indexing_complete = asyncio.Event() + # Set immediately in case there is no background indexing (e.g. standalone files) + no_indexing_started = True + + async def execute_client_command_handler(params): + return [] + + async def do_nothing(params): + return + + async def window_log_message(msg): + self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) + + async def handle_progress(params): + nonlocal no_indexing_started + token = params.get("token", "") + value = params.get("value", {}) + kind = value.get("kind", "") + + if kind == "begin": + no_indexing_started = False + active_progress_tokens.add(token) + self.logger.log( + f"LSP: $/progress begin: {value.get('title', '')} ({token})", + logging.INFO, + ) + elif kind == "end": + active_progress_tokens.discard(token) + self.logger.log( + f"LSP: $/progress end: {value.get('message', '')} ({token})", + logging.INFO, + ) + if not active_progress_tokens and not indexing_complete.is_set(): + indexing_complete.set() + + async def work_done_progress_create(params): + self.logger.log(f"LSP: window/workDoneProgress/create token={params.get('token')}", logging.INFO) + return None + + self.server.on_request("client/registerCapability", do_nothing) + self.server.on_request("window/workDoneProgress/create", work_done_progress_create) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_request("workspace/executeClientCommand", execute_client_command_handler) + self.server.on_notification("$/progress", handle_progress) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + + async with super().start_server(): + self.logger.log("Starting SourceKit-LSP server process", logging.INFO) + await self.server.start() + initialize_params = self._get_initialize_params(self.repository_root_path) + + self.logger.log( + "Sending initialize request from LSP client to LSP server and awaiting response", + logging.INFO, + ) + init_response = await self.server.send.initialize(initialize_params) + + capabilities = init_response["capabilities"] + assert "textDocumentSync" in capabilities, "Server must support textDocumentSync" + # Note: Apple's SourceKit-LSP registers completion dynamically on open text buffers + # rather than advertising static completionProvider in initialize response capabilities. + assert "definitionProvider" in capabilities, "Server must support go to definition" + assert "referencesProvider" in capabilities, "Server must support find references" + assert "documentSymbolProvider" in capabilities, "Server must support document symbols" + + self.server.notify.initialized({}) + self.completions_available.set() + + # NOTE ON READINESS WAIT: + # SourceKit-LSP performs background indexing (enabled by default since + # Swift 6.1). We wait for indexing to complete via $/progress tokens + # to ensure definition/references results are complete. + # + # If no $/progress begin is received within a short timeout, the + # project may not need indexing (e.g. standalone files or already + # indexed). In that case we proceed immediately. + self.logger.log( + "Waiting for SourceKit-LSP background indexing to complete...", + logging.INFO, + ) + try: + await asyncio.wait_for(indexing_complete.wait(), timeout=10.0) + except asyncio.TimeoutError: + if no_indexing_started: + self.logger.log( + "No background indexing detected, proceeding immediately", + logging.INFO, + ) + else: + # Indexing started but hasn't finished in 10s, wait up to 60s + self.logger.log( + "Background indexing in progress, waiting for completion...", + logging.INFO, + ) + try: + await asyncio.wait_for(indexing_complete.wait(), timeout=60.0) + except asyncio.TimeoutError: + self.logger.log( + "Background indexing wait timed out, proceeding with available index", + logging.WARNING, + ) + + self.logger.log("SourceKit-LSP is ready", logging.INFO) + + yield self + + try: + await self.server.shutdown() + except Exception as e: + self.logger.log(f"Error during SourceKit-LSP shutdown: {str(e)}", logging.WARNING) + finally: + await self.server.stop() diff --git a/src/multilspy/lsp_protocol_handler/server.py b/src/multilspy/lsp_protocol_handler/server.py index 5c54ef8..e1d7c45 100644 --- a/src/multilspy/lsp_protocol_handler/server.py +++ b/src/multilspy/lsp_protocol_handler/server.py @@ -50,8 +50,8 @@ class ProcessLaunchInfo: This class is used to store the information required to launch a process. """ - # The command to launch the process as a list of arguments (no shell involved) - cmd: List[str] + # The command to launch the process (either as a shell command string or a list of arguments) + cmd: Union[str, List[str]] # The environment variables to set for the process env: Dict[str, str] = dataclasses.field(default_factory=dict) @@ -207,6 +207,14 @@ def __init__( self.loop = None self.start_independent_lsp_process = start_independent_lsp_process + def is_alive(self) -> bool: + """ + Check if the language server process is still running. + """ + if self.process is None: + return False + return self.process.returncode is None + async def start(self) -> None: """ Starts the language server process and creates a task to continuously read from its stdout to handle communications @@ -214,15 +222,26 @@ async def start(self) -> None: """ child_proc_env = os.environ.copy() child_proc_env.update(self.process_launch_info.env) - self.process = await asyncio.create_subprocess_exec( - *self.process_launch_info.cmd, - stdout=asyncio.subprocess.PIPE, - stdin=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - env=child_proc_env, - cwd=self.process_launch_info.cwd, - start_new_session=self.start_independent_lsp_process, - ) + if isinstance(self.process_launch_info.cmd, list): + self.process = await asyncio.create_subprocess_exec( + *self.process_launch_info.cmd, + stdout=asyncio.subprocess.PIPE, + stdin=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + env=child_proc_env, + cwd=self.process_launch_info.cwd, + start_new_session=self.start_independent_lsp_process, + ) + else: + self.process = await asyncio.create_subprocess_shell( + self.process_launch_info.cmd, + stdout=asyncio.subprocess.PIPE, + stdin=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + env=child_proc_env, + cwd=self.process_launch_info.cwd, + start_new_session=self.start_independent_lsp_process, + ) self.loop = asyncio.get_event_loop() self.tasks[self.task_counter] = self.loop.create_task(self.run_forever()) @@ -475,9 +494,19 @@ async def send_request(self, method: str, params: Optional[dict] = None) -> None request_id = self.request_id self.request_id += 1 self._response_handlers[request_id] = request - async with request.cv: - await self._send_payload(make_request(method, request_id, params)) - await request.cv.wait() + try: + async with request.cv: + await self._send_payload(make_request(method, request_id, params)) + await request.cv.wait() + except asyncio.CancelledError: + # If the request is cancelled (e.g. timeout via asyncio.wait_for), notify server + try: + self.notify.cancel_request({"id": request_id}) + except Exception: + pass + raise + finally: + self._response_handlers.pop(request_id, None) if isinstance(request.error, Error): raise request.error return request.result @@ -521,7 +550,10 @@ async def _response_handler(self, response: StringDict) -> None: """ Handle the response received from the server for a request, using the id to determine the request """ - request = self._response_handlers.pop(response["id"]) + request = self._response_handlers.pop(response.get("id"), None) + if request is None: + self._log(f"Received response for unknown or timed-out request id: {response.get('id')}") + return if "result" in response and "error" not in response: await request.on_result(response["result"]) elif "result" not in response and "error" in response: diff --git a/src/multilspy/multilspy_config.py b/src/multilspy/multilspy_config.py index b12a8ca..7a1b157 100644 --- a/src/multilspy/multilspy_config.py +++ b/src/multilspy/multilspy_config.py @@ -22,8 +22,12 @@ class Language(str, Enum): RUBY = "ruby" DART = "dart" CPP = "cpp" - PHP = "php" + SCALA = "scala" + SWIFT = "swift" ELIXIR = "elixir" + PHP = "php" + LUA = "lua" + BASH = "bash" def __str__(self) -> str: return self.value @@ -36,6 +40,7 @@ class MultilspyConfig: code_language: Language trace_lsp_communication: bool = False start_independent_lsp_process: bool = True + request_timeout: float = 30.0 # Path to a pre-installed language server binary. When set, multilspy # skips downloading the server and uses this binary directly. diff --git a/src/multilspy/multilspy_utils.py b/src/multilspy/multilspy_utils.py index e13ea92..4f493d6 100644 --- a/src/multilspy/multilspy_utils.py +++ b/src/multilspy/multilspy_utils.py @@ -256,3 +256,7 @@ def get_dotnet_version() -> DotnetVersion: except (FileNotFoundError, subprocess.CalledProcessError): raise MultilspyException("dotnet or mono not found on the system") + +# Re-export path classifier +from multilspy.path_classifier import PathClassifier, PathClassification + diff --git a/src/multilspy/path_classifier.py b/src/multilspy/path_classifier.py new file mode 100644 index 0000000..e85fd5c --- /dev/null +++ b/src/multilspy/path_classifier.py @@ -0,0 +1,496 @@ +""" +Standardized package coordinate and external path classification for multilspy and Repolex. + +Classifies resolved symbol and definition file paths across all supported language ecosystems: +- Elixir: Hex/Mix dependencies in deps//lib/... and _build/... +- Swift: Swift Package Manager checkouts in .build/checkouts//... and Apple SDK frameworks +- Scala/Java: Coursier cache, Maven local repo (~/.m2), Ivy cache, Metals readonly jars +- PHP: Composer dependencies in vendor///... +- Python: site-packages, dist-packages, stdlib +- JS/TS: node_modules//... and @scope packages +- Rust: Cargo registry (~/.cargo/registry/src/...) and git checkouts +- Go: go/pkg/mod/... and vendor/... +- Ruby: gems/... and vendor/bundle/... +- C#: NuGet packages in ~/.nuget/packages/... +- Dart: Pub cache in ~/.pub-cache/hosted/pub.dev/... +- Lua: LuaRocks packages in ~/.luarocks/... and lua_modules/... +- Bash: Basher packages, Bats libraries, and bash-completion +""" + +import os +import re +from dataclasses import dataclass, field +from pathlib import PurePath +from typing import Optional, Union, Tuple, Iterator, Dict, Any + + +@dataclass(frozen=True) +class PathClassification: + """ + Standardized classification of a file path. + + Supports tuple unpacking: + is_external, package_name, package_relative_path = classification + """ + is_external: bool + package_name: Optional[str] = None + package_relative_path: Optional[str] = None + ecosystem: Optional[str] = None + version: Optional[str] = None + extra: Dict[str, Any] = field(default_factory=dict) + + def __iter__(self) -> Iterator[Union[bool, Optional[str]]]: + """Allows unpacking as (is_external, package_name, package_relative_path).""" + yield self.is_external + yield self.package_name + yield self.package_relative_path + + def as_tuple(self) -> Tuple[bool, Optional[str], Optional[str]]: + """Returns (is_external, package_name, package_relative_path).""" + return self.is_external, self.package_name, self.package_relative_path + + +class PathClassifier: + """ + Inspects resolved file paths to identify external library dependencies and + extract canonical package coordinates. + """ + + @staticmethod + def normalize_path(path: str) -> str: + """Standardize separators and strip URI schemes or jar delimiters.""" + if path.startswith("file://"): + path = path[7:] + elif path.startswith("jar:file:"): + path = path[9:] + return path.replace("\\", "/") + + @classmethod + def classify( + cls, + path: str, + repository_root_path: Optional[str] = None, + language: Optional[str] = None, + ) -> PathClassification: + """ + Classifies a file path into internal vs. external package coordinate. + + :param path: Absolute or relative file path returned by language server. + :param repository_root_path: Optional root directory of the repository being indexed. + :param language: Optional language hint (e.g. 'elixir', 'swift', 'scala', 'php'). + :return: PathClassification object with is_external, package_name, package_relative_path, ecosystem, version. + """ + norm_path = cls.normalize_path(path) + + # ------------------------------------------------------------------------- + # 1. Elixir (Hex / Mix) + # ------------------------------------------------------------------------- + # Workspace dependency: deps//... + m = re.search(r"(?:^|/)deps/([a-zA-Z0-9_]+)/(.*)$", norm_path) + if m: + pkg = m.group(1) + rel = m.group(2) + return PathClassification( + is_external=True, + package_name=pkg, + package_relative_path=rel, + ecosystem="HEX", + ) + + # Build directory: _build//lib//... + m = re.search(r"(?:^|/)_build/[^/]+/lib/([a-zA-Z0-9_]+)/(.*)$", norm_path) + if m: + pkg = m.group(1) + rel = m.group(2) + return PathClassification( + is_external=True, + package_name=pkg, + package_relative_path=rel, + ecosystem="HEX", + ) + + # System Elixir / Erlang lib + m = re.search(r"/(?:lib|opt)/(?:elixir|erlang)/lib/([a-zA-Z0-9_]+)(?:-[\d.]+)?/(.*)$", norm_path) + if m: + return PathClassification( + is_external=True, + package_name=m.group(1), + package_relative_path=m.group(2), + ecosystem="HEX", + ) + + # ------------------------------------------------------------------------- + # 2. Swift (Swift Package Manager & Apple SDKs) + # ------------------------------------------------------------------------- + # SPM checkouts: .build/checkouts//... + m = re.search(r"(?:^|/)\.build/checkouts/([a-zA-Z0-9_.-]+)/(.*)$", norm_path) + if m: + pkg = m.group(1) + rel = m.group(2) + return PathClassification( + is_external=True, + package_name=pkg, + package_relative_path=rel, + ecosystem="SWIFT", + ) + + # SPM repositories cache + m = re.search(r"(?:^|/)\.build/repositories/([a-zA-Z0-9_.-]+)/(.*)$", norm_path) + if m: + return PathClassification( + is_external=True, + package_name=m.group(1), + package_relative_path=m.group(2), + ecosystem="SWIFT", + ) + + # Apple SDK frameworks: /.../System/Library/Frameworks/.framework/... + m = re.search(r"/System/Library/Frameworks/([a-zA-Z0-9_]+)\.framework/(.*)$", norm_path) + if m: + framework = m.group(1) + rel = m.group(2) + return PathClassification( + is_external=True, + package_name=framework, + package_relative_path=rel, + ecosystem="SWIFT", + ) + + # Swift toolchain stdlib: /usr/lib/swift/... or /Developer/.../usr/lib/swift/... + m = re.search(r"/usr/lib/swift/(?:.*\/)?([a-zA-Z0-9_.-]+\.swiftinterface)$", norm_path) + if m: + return PathClassification( + is_external=True, + package_name="apple/swift", + package_relative_path=m.group(1), + ecosystem="SWIFT", + ) + + # ------------------------------------------------------------------------- + # 3. Scala / Java (Coursier, Maven .m2, Ivy, Metals) + # ------------------------------------------------------------------------- + # Coursier cache: .../coursier/.../maven2/{group}/{artifact}/{version}/{artifact}-{version}-sources.jar!/... + m = re.search( + r"maven2/(.+?)/([^/]+)/([\d][^/]*)/(?:[^/]+\.jar!/|[^/]+\.jar)?(.*)$", + norm_path, + ) + if m: + group = m.group(1).replace("/", ".") + raw_artifact = m.group(2) + version = m.group(3) + rel = m.group(4) if m.group(4) else None + # Strip Scala binary version suffix (_2.13, _3, _2.12) + artifact = re.sub(r"_(?:2\.1[0-3]|3)$", "", raw_artifact) + pkg_name = f"{group}:{artifact}" + return PathClassification( + is_external=True, + package_name=pkg_name, + package_relative_path=rel, + ecosystem="MAVEN", + version=version, + ) + + # Maven local repo: .../.m2/repository/{group}/{artifact}/{version}/... + m = re.search( + r"/repository/(.+?)/([^/]+)/([\d][^/]*)/(?:[^/]+\.jar!/|[^/]+\.jar)?(.*)$", + norm_path, + ) + if m: + group = m.group(1).replace("/", ".") + raw_artifact = m.group(2) + version = m.group(3) + rel = m.group(4) if m.group(4) else None + artifact = re.sub(r"_(?:2\.1[0-3]|3)$", "", raw_artifact) + pkg_name = f"{group}:{artifact}" + return PathClassification( + is_external=True, + package_name=pkg_name, + package_relative_path=rel, + ecosystem="MAVEN", + version=version, + ) + + # Ivy cache: .../.ivy2/cache/{org}/{artifact}/... + m = re.search( + r"\.ivy2/cache/(.+?)/([^/]+)/(?:srcs|jars)/[^/]+\.jar(?:!/(.*))?$", + norm_path, + ) + if m: + group = m.group(1).replace("/", ".") + artifact = re.sub(r"_(?:2\.1[0-3]|3)$", "", m.group(2)) + rel = m.group(3) if m.group(3) else None + return PathClassification( + is_external=True, + package_name=f"{group}:{artifact}", + package_relative_path=rel, + ecosystem="MAVEN", + ) + + # Metals readonly jar dependencies: .metals/readonly/dependencies/{artifact}-{version}-sources.jar!/... + m = re.search( + r"\.metals/readonly/dependencies/(?:[^/]+\.jar!/)?(.*)$", + norm_path, + ) + if m: + return PathClassification( + is_external=True, + package_name="metals:dependency", + package_relative_path=m.group(1), + ecosystem="MAVEN", + ) + + # JDK stdlib module: /java.base/java.lang/System.class + m = re.search(r"/(java\.[a-z.]+)/([a-z][a-z0-9_.]+)/(.*)$", norm_path) + if m: + jdk_module = m.group(1) + java_pkg = m.group(2) + rel = m.group(3) + return PathClassification( + is_external=True, + package_name=f"{jdk_module}:{java_pkg}", + package_relative_path=rel, + ecosystem="JDK_STDLIB", + ) + + # ------------------------------------------------------------------------- + # 4. PHP (Composer / Packagist) + # ------------------------------------------------------------------------- + m = re.search(r"(?:^|/)vendor/([a-zA-Z0-9_.-]+)/([a-zA-Z0-9_.-]+)/(.*)$", norm_path) + if m: + vendor_name = m.group(1) + pkg_name = m.group(2) + # Skip bin or composer internal directories + if vendor_name.lower() not in ("bin", "composer"): + canonical_name = f"{vendor_name}/{pkg_name}" + rel = m.group(3) + return PathClassification( + is_external=True, + package_name=canonical_name, + package_relative_path=rel, + ecosystem="PACKAGIST", + ) + + # ------------------------------------------------------------------------- + # 5. JavaScript / TypeScript (NPM) + # ------------------------------------------------------------------------- + m = re.search( + r"(?:^|/)node_modules/(@[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+|[a-zA-Z0-9_.-]+)/(.*)$", + norm_path, + ) + if m: + pkg = m.group(1) + rel = m.group(2) + return PathClassification( + is_external=True, + package_name=pkg, + package_relative_path=rel, + ecosystem="NPM", + ) + + # ------------------------------------------------------------------------- + # 6. Python (PyPI) + # ------------------------------------------------------------------------- + m = re.search( + r"(?:^|/)(?:site-packages|dist-packages)/([a-zA-Z0-9_.-]+)/(.*)$", + norm_path, + ) + if m: + pkg = m.group(1) + rel = m.group(2) + version = None + # Extract version from sibling or parent .dist-info if in path + dist_match = re.search(r"([a-zA-Z0-9_.-]+)-([\d.]+)\.dist-info", norm_path) + if dist_match: + version = dist_match.group(2) + return PathClassification( + is_external=True, + package_name=pkg, + package_relative_path=rel, + ecosystem="PYPI", + version=version, + ) + + # ------------------------------------------------------------------------- + # 7. Rust (Cargo) + # ------------------------------------------------------------------------- + # Registry cache: .cargo/registry/src/.../{pkg}-{version}/... + m = re.search(r"\.cargo/registry/src/[^/]+/([^/]+)/(.*)$", norm_path) + if m: + pkg_with_version = m.group(1) + rel = m.group(2) + parts = pkg_with_version.rsplit("-", 1) + if len(parts) == 2 and re.match(r"^[\d.]+", parts[1]): + pkg = parts[0] + version = parts[1] + else: + pkg = pkg_with_version + version = None + return PathClassification( + is_external=True, + package_name=pkg, + package_relative_path=rel, + ecosystem="CARGO", + version=version, + ) + + # Git checkouts: .cargo/git/checkouts/{repo}/... + m = re.search(r"\.cargo/git/checkouts/([^/]+)/[^/]+/(.*)$", norm_path) + if m: + return PathClassification( + is_external=True, + package_name=m.group(1), + package_relative_path=m.group(2), + ecosystem="CARGO", + ) + + # ------------------------------------------------------------------------- + # 8. Go (Go Modules / Vendor) + # ------------------------------------------------------------------------- + m = re.search(r"/go/pkg/mod/(.+?)@v?([^/]+)/(.*)$", norm_path) + if m: + return PathClassification( + is_external=True, + package_name=m.group(1), + package_relative_path=m.group(3), + ecosystem="GO", + version=m.group(2), + ) + + # Go vendor: vendor/{module}/... + if language == "go" or "/go/" in norm_path: + m = re.search(r"(?:^|/)vendor/(.+)$", norm_path) + if m: + return PathClassification( + is_external=True, + package_name=m.group(1).split("/")[0], + package_relative_path=m.group(1), + ecosystem="GO", + ) + + # ------------------------------------------------------------------------- + # 9. Ruby (RubyGems / Bundler) + # ------------------------------------------------------------------------- + m = re.search(r"(?:^|/)gems/([a-zA-Z0-9_-]+)-([\d.]+)/(.*)$", norm_path) + if m: + return PathClassification( + is_external=True, + package_name=m.group(1), + package_relative_path=m.group(3), + ecosystem="RUBYGEMS", + version=m.group(2), + ) + + # ------------------------------------------------------------------------- + # 10. C# (NuGet) + # ------------------------------------------------------------------------- + m = re.search(r"\.nuget/packages/([^/]+)/([^/]+)/(.*)$", norm_path) + if m: + return PathClassification( + is_external=True, + package_name=m.group(1), + package_relative_path=m.group(3), + ecosystem="NUGET", + version=m.group(2), + ) + + # ------------------------------------------------------------------------- + # 11. Dart (Pub) + # ------------------------------------------------------------------------- + m = re.search(r"\.pub-cache/hosted/pub\.dev/([a-zA-Z0-9_-]+)-([\d.]+)/(.*)$", norm_path) + if m: + return PathClassification( + is_external=True, + package_name=m.group(1), + package_relative_path=m.group(3), + ecosystem="PUB", + version=m.group(2), + ) + + # ------------------------------------------------------------------------- + # 12. Lua (LuaRocks / Modules) + # ------------------------------------------------------------------------- + # Versioned rocks tree: rocks-5.x///... + m = re.search(r"(?:^|/)rocks-5\.[0-9]+/([a-zA-Z0-9_.-]+)/([^/]+)/(.*)$", norm_path) + if m: + return PathClassification( + is_external=True, + package_name=m.group(1), + package_relative_path=m.group(3), + ecosystem="LUAROCKS", + version=m.group(2), + ) + + # Module trees: ~/.luarocks/share/lua/5.x/..., lua_modules/share/lua/5.x/..., share/lua/5.x/... + m = re.search(r"(?:^|/)(?:\.luarocks|lua_modules|share|lib)/.*?lua/5\.[0-9]+/([a-zA-Z0-9_.-]+?)(?:/(.*)|\.lua)$", norm_path) + if m: + pkg = m.group(1) + rel = m.group(2) if m.group(2) else f"{pkg}.lua" + return PathClassification( + is_external=True, + package_name=pkg, + package_relative_path=rel, + ecosystem="LUAROCKS", + ) + + # ------------------------------------------------------------------------- + # 13. Bash / Shell (Basher, Bats, Completions) + # ------------------------------------------------------------------------- + # Basher packages: ~/.basher/cellar/packages///... + m = re.search(r"\.basher/cellar/packages/([^/]+)/([^/]+)/(.*)$", norm_path) + if m: + return PathClassification( + is_external=True, + package_name=f"{m.group(1)}/{m.group(2)}", + package_relative_path=m.group(3), + ecosystem="BASH", + ) + + # Bats test libraries: bats-/... + m = re.search(r"(?:^|/)(bats-[a-zA-Z0-9_-]+)/(.*)$", norm_path) + if m: + return PathClassification( + is_external=True, + package_name=m.group(1), + package_relative_path=m.group(2), + ecosystem="BASH", + ) + + # Bash completion files: bash-completion/completions/ or bash_completion.d/ + m = re.search(r"(?:^|/)(?:bash-completion/completions|bash_completion\.d)/([^/]+)$", norm_path) + if m: + return PathClassification( + is_external=True, + package_name="bash-completion", + package_relative_path=m.group(1), + ecosystem="BASH", + ) + + # ------------------------------------------------------------------------- + # 14. Intra-Repository vs Unclassified External Check + # ------------------------------------------------------------------------- + if repository_root_path: + norm_repo = cls.normalize_path(repository_root_path).rstrip("/") + if norm_path.startswith(norm_repo + "/") or norm_path == norm_repo: + rel = norm_path[len(norm_repo) + 1:] + return PathClassification( + is_external=False, + package_name=None, + package_relative_path=rel, + ecosystem=None, + ) + else: + # Path is outside repository root + return PathClassification( + is_external=True, + package_name=None, + package_relative_path=norm_path, + ecosystem=None, + ) + + # If no repository root provided and no external markers found: + return PathClassification( + is_external=False, + package_name=None, + package_relative_path=norm_path, + ecosystem=None, + ) diff --git a/tests/multilspy/test_multilspy_bash.py b/tests/multilspy/test_multilspy_bash.py new file mode 100644 index 0000000..1925930 --- /dev/null +++ b/tests/multilspy/test_multilspy_bash.py @@ -0,0 +1,104 @@ +""" +This file contains tests for running the Bash (bash-language-server) Language Server. +""" + +import pytest +import tempfile +from pathlib import Path +from multilspy import LanguageServer +from multilspy.multilspy_config import MultilspyConfig, Language +from multilspy.multilspy_logger import MultilspyLogger +from multilspy.language_servers.bash_language_server import BashLanguageServer + +pytest_plugins = ("pytest_asyncio",) + + +@pytest.mark.asyncio +async def test_multilspy_bash_instantiation(): + """ + Test that LanguageServer.create instantiates BashLanguageServer for Language.BASH. + """ + with tempfile.TemporaryDirectory() as tmpdir: + config = MultilspyConfig(code_language=Language.BASH) + logger = MultilspyLogger() + lsp = LanguageServer.create(config, logger, tmpdir) + assert isinstance(lsp, BashLanguageServer) + assert lsp.language_id == "shellscript" + + +@pytest.mark.asyncio +async def test_multilspy_bash_symbols_and_definition(): + """ + Test document symbols, definition resolution, hover, and references in a Bash workspace. + """ + with tempfile.TemporaryDirectory() as tmpdir: + sh_file = Path(tmpdir) / "deploy.sh" + sh_file.write_text("""#!/usr/bin/env bash + +build_project() { + echo "Building..." +} + +deploy_project() { + build_project + echo "Deploying..." +} + +deploy_project +""") + config = MultilspyConfig(code_language=Language.BASH) + logger = MultilspyLogger() + lsp = LanguageServer.create(config, logger, tmpdir) + + async with lsp.start_server(): + # 1. Document Symbols + syms, _ = await lsp.request_document_symbols("deploy.sh") + sym_names = [s["name"] for s in syms] + assert "build_project" in sym_names + assert "deploy_project" in sym_names + + # 2. Definition + defn = await lsp.request_definition("deploy.sh", 7, 5) + assert len(defn) == 1 + assert defn[0]["relativePath"] == "deploy.sh" + assert defn[0]["range"]["start"]["line"] == 2 + + # 3. References + refs = await lsp.request_references("deploy.sh", 2, 2) + assert len(refs) >= 1 + assert any(r["relativePath"] == "deploy.sh" and r["range"]["start"]["line"] == 7 for r in refs) + + # 4. Hover + hover = await lsp.request_hover("deploy.sh", 7, 5) + assert hover is not None + assert "build_project" in str(hover) + + +def test_sync_multilspy_bash(): + """ + Test synchronous SyncLanguageServer with Bash. + """ + from multilspy import SyncLanguageServer + + with tempfile.TemporaryDirectory() as tmpdir: + sh_file = Path(tmpdir) / "deploy.sh" + sh_file.write_text("""#!/usr/bin/env bash + +build_project() { + echo "Building..." +} + +deploy_project() { + build_project +} +""") + config = MultilspyConfig(code_language=Language.BASH) + logger = MultilspyLogger() + lsp = SyncLanguageServer.create(config, logger, tmpdir) + + with lsp.start_server(): + defn = lsp.request_definition("deploy.sh", 7, 5) + assert len(defn) == 1 + assert defn[0]["relativePath"] == "deploy.sh" + assert defn[0]["range"]["start"]["line"] == 2 + diff --git a/tests/multilspy/test_multilspy_lua.py b/tests/multilspy/test_multilspy_lua.py new file mode 100644 index 0000000..9532b86 --- /dev/null +++ b/tests/multilspy/test_multilspy_lua.py @@ -0,0 +1,99 @@ +""" +This file contains tests for running the Lua (LuaLS) Language Server. +""" + +import pytest +import tempfile +from pathlib import Path +from multilspy import LanguageServer +from multilspy.multilspy_config import MultilspyConfig, Language +from multilspy.multilspy_logger import MultilspyLogger +from multilspy.language_servers.lua_language_server import LuaLanguageServer + +pytest_plugins = ("pytest_asyncio",) + + +@pytest.mark.asyncio +async def test_multilspy_lua_instantiation(): + """ + Test that LanguageServer.create instantiates LuaLanguageServer for Language.LUA. + """ + with tempfile.TemporaryDirectory() as tmpdir: + config = MultilspyConfig(code_language=Language.LUA) + logger = MultilspyLogger() + lsp = LanguageServer.create(config, logger, tmpdir) + assert isinstance(lsp, LuaLanguageServer) + assert lsp.language_id == "lua" + + +@pytest.mark.asyncio +async def test_multilspy_lua_symbols_and_definition(): + """ + Test document symbols, definition resolution, hover, and references in a Lua workspace. + """ + with tempfile.TemporaryDirectory() as tmpdir: + lua_file = Path(tmpdir) / "math_utils.lua" + lua_file.write_text("""local function add(a, b) + return a + b +end + +local function multiply(x, y) + return x * y +end + +local result = add(5, 10) +print(result) +""") + config = MultilspyConfig(code_language=Language.LUA) + logger = MultilspyLogger() + lsp = LanguageServer.create(config, logger, tmpdir) + + async with lsp.start_server(): + # 1. Document Symbols + syms, _ = await lsp.request_document_symbols("math_utils.lua") + sym_names = [s["name"] for s in syms] + assert "add" in sym_names + assert "multiply" in sym_names + + # 2. Definition + defn = await lsp.request_definition("math_utils.lua", 8, 16) + assert len(defn) == 1 + assert defn[0]["relativePath"] == "math_utils.lua" + assert defn[0]["range"]["start"]["line"] == 0 + + # 3. References + refs = await lsp.request_references("math_utils.lua", 0, 16) + assert len(refs) >= 1 + assert any(r["relativePath"] == "math_utils.lua" and r["range"]["start"]["line"] == 8 for r in refs) + + # 4. Hover + hover = await lsp.request_hover("math_utils.lua", 8, 16) + assert hover is not None + assert "add" in str(hover) + + +def test_sync_multilspy_lua(): + """ + Test synchronous SyncLanguageServer with Lua. + """ + from multilspy import SyncLanguageServer + + with tempfile.TemporaryDirectory() as tmpdir: + lua_file = Path(tmpdir) / "math_utils.lua" + lua_file.write_text("""local function add(a, b) + return a + b +end + +local result = add(5, 10) +print(result) +""") + config = MultilspyConfig(code_language=Language.LUA) + logger = MultilspyLogger() + lsp = SyncLanguageServer.create(config, logger, tmpdir) + + with lsp.start_server(): + defn = lsp.request_definition("math_utils.lua", 4, 16) + assert len(defn) == 1 + assert defn[0]["relativePath"] == "math_utils.lua" + assert defn[0]["range"]["start"]["line"] == 0 + diff --git a/tests/multilspy/test_multilspy_scala.py b/tests/multilspy/test_multilspy_scala.py new file mode 100644 index 0000000..502431c --- /dev/null +++ b/tests/multilspy/test_multilspy_scala.py @@ -0,0 +1,96 @@ +""" +This file contains tests for running the Metals Scala Language Server. +""" + +import pytest +from pathlib import PurePath + +from multilspy import LanguageServer +from multilspy.multilspy_config import Language +from tests.test_utils import create_test_context + +pytest_plugins = ("pytest_asyncio",) + +@pytest.mark.asyncio +async def test_multilspy_scala_document_symbols() -> None: + params = { + "code_language": Language.SCALA, + "repo_url": "https://github.com/lhartikk/ArnoldC/", + "repo_commit": "3dd905be59525f0b9a04e0baa6fd6acab09db8ea" + } + with create_test_context(params) as context: + lsp = LanguageServer.create(context.config, context.logger, context.source_directory) + test_file = str(PurePath("src/main/scala/org/arnoldc/ArnoldC.scala")) + + async with lsp.start_server(): + result = await lsp.request_document_symbols(test_file) + + symbols = result[0] + assert len(symbols) > 0, "Should find document symbols" + + # Verify the ArnoldC object symbol + arnoldc_symbol = next((s for s in symbols if s["name"] == "ArnoldC"), None) + assert arnoldc_symbol is not None, "ArnoldC object should be present" + +@pytest.mark.asyncio +async def test_multilspy_scala_definition() -> None: + params = { + "code_language": Language.SCALA, + "repo_url": "https://github.com/lhartikk/ArnoldC/", + "repo_commit": "3dd905be59525f0b9a04e0baa6fd6acab09db8ea" + } + with create_test_context(params) as context: + lsp = LanguageServer.create(context.config, context.logger, context.source_directory) + # In ArnoldC.scala, line 11 (0-indexed): "val filename = getFilNameFromArgs(args)" + # "getFilNameFromArgs" starts at column 19 + test_file = str(PurePath("src/main/scala/org/arnoldc/ArnoldC.scala")) + + async with lsp.start_server(): + definition_result = await lsp.request_definition(test_file, 11, 25) + + assert definition_result is not None, "Definition result should not be None" + assert len(definition_result) >= 1, "Should find at least one definition" + + definition = definition_result[0] + assert "ArnoldC.scala" in definition["relativePath"] + +@pytest.mark.asyncio +async def test_multilspy_scala_completions() -> None: + params = { + "code_language": Language.SCALA, + "repo_url": "https://github.com/lhartikk/ArnoldC/", + "repo_commit": "3dd905be59525f0b9a04e0baa6fd6acab09db8ea" + } + with create_test_context(params) as context: + lsp = LanguageServer.create(context.config, context.logger, context.source_directory) + # In ArnoldC.scala, line 7 (0-indexed): "println(\"Usage: ArnoldC [-run|-declaim] [FileToSourceCode]\")" + test_file = str(PurePath("src/main/scala/org/arnoldc/ArnoldC.scala")) + + async with lsp.start_server(): + completions = await lsp.request_completions(test_file, 7, 12) + + assert completions is not None, "Completions result should not be None" + assert len(completions) > 0, "Should find at least one completion item" + + # Verify completion items have required properties + for item in completions: + assert "completionText" in item, "Completion item should have completionText" + assert "kind" in item, "Completion item should have kind" + +@pytest.mark.asyncio +async def test_multilspy_scala_hover() -> None: + params = { + "code_language": Language.SCALA, + "repo_url": "https://github.com/lhartikk/ArnoldC/", + "repo_commit": "3dd905be59525f0b9a04e0baa6fd6acab09db8ea" + } + with create_test_context(params) as context: + lsp = LanguageServer.create(context.config, context.logger, context.source_directory) + # Hover over "getFilNameFromArgs" in ArnoldC.scala, line 11 (0-indexed) + test_file = str(PurePath("src/main/scala/org/arnoldc/ArnoldC.scala")) + + async with lsp.start_server(): + hover_result = await lsp.request_hover(test_file, 11, 25) + + assert hover_result is not None, "Hover result should not be None" + assert "contents" in hover_result, "Hover result should contain contents" diff --git a/tests/multilspy/test_multilspy_swift.py b/tests/multilspy/test_multilspy_swift.py new file mode 100644 index 0000000..63d9bf5 --- /dev/null +++ b/tests/multilspy/test_multilspy_swift.py @@ -0,0 +1,111 @@ +""" +This file contains tests for running the SourceKit-LSP Swift Language Server. +""" + +import pytest +from pathlib import PurePath + +from multilspy import LanguageServer +from multilspy.multilspy_config import Language +from tests.test_utils import create_test_context + +pytest_plugins = ("pytest_asyncio",) + +@pytest.mark.asyncio +async def test_multilspy_swift_document_symbols() -> None: + params = { + "code_language": Language.SWIFT, + "repo_url": "https://github.com/SwiftyJSON/SwiftyJSON/", + "repo_commit": "8805b547079d605f61f9c7964296384ff6fe853e" + } + with create_test_context(params) as context: + lsp = LanguageServer.create(context.config, context.logger, context.source_directory) + test_file = str(PurePath("Source/SwiftyJSON/SwiftyJSON.swift")) + + async with lsp.start_server(): + result = await lsp.request_document_symbols(test_file) + + symbols = result[0] + assert len(symbols) > 0, "Should find document symbols" + + # Verify the SwiftyJSONError enum symbol + error_symbol = next((s for s in symbols if s["name"] == "SwiftyJSONError"), None) + assert error_symbol is not None, "SwiftyJSONError enum should be present" + +@pytest.mark.asyncio +async def test_multilspy_swift_definition() -> None: + params = { + "code_language": Language.SWIFT, + "repo_url": "https://github.com/SwiftyJSON/SwiftyJSON/", + "repo_commit": "8805b547079d605f61f9c7964296384ff6fe853e" + } + with create_test_context(params) as context: + lsp = LanguageServer.create(context.config, context.logger, context.source_directory) + # SwiftyJSON.swift is a single large file, test go-to-definition within it + test_file = str(PurePath("Source/SwiftyJSON/SwiftyJSON.swift")) + + async with lsp.start_server(): + # Line 35 col 15 is SwiftyJSONError in "extension SwiftyJSONError: CustomNSError" + definition_result = await lsp.request_definition(test_file, 35, 15) + + assert definition_result is not None, "Definition result should not be None" + assert len(definition_result) >= 1, "Should find at least one definition" + +@pytest.mark.asyncio +async def test_multilspy_swift_references() -> None: + params = { + "code_language": Language.SWIFT, + "repo_url": "https://github.com/SwiftyJSON/SwiftyJSON/", + "repo_commit": "8805b547079d605f61f9c7964296384ff6fe853e" + } + with create_test_context(params) as context: + lsp = LanguageServer.create(context.config, context.logger, context.source_directory) + # Find references to SwiftyJSONError enum (used throughout the file) + test_file = str(PurePath("Source/SwiftyJSON/SwiftyJSON.swift")) + + async with lsp.start_server(): + references = await lsp.request_references(test_file, 35, 15) + + assert references is not None, "References should not be None" + assert len(references) >= 1, "Should find at least one reference to SwiftyJSONError" + +@pytest.mark.asyncio +async def test_multilspy_swift_completions() -> None: + params = { + "code_language": Language.SWIFT, + "repo_url": "https://github.com/SwiftyJSON/SwiftyJSON/", + "repo_commit": "8805b547079d605f61f9c7964296384ff6fe853e" + } + with create_test_context(params) as context: + lsp = LanguageServer.create(context.config, context.logger, context.source_directory) + test_file = str(PurePath("Source/SwiftyJSON/SwiftyJSON.swift")) + + async with lsp.start_server(): + completions = await lsp.request_completions(test_file, 35, 15, allow_incomplete=True) + + assert completions is not None, "Completions result should not be None" + assert len(completions) > 0, "Should find at least one completion item" + + # Verify completion items have required properties + for item in completions: + assert "completionText" in item, "Completion item should have completionText" + assert "kind" in item, "Completion item should have kind" + +@pytest.mark.asyncio +async def test_multilspy_swift_hover() -> None: + params = { + "code_language": Language.SWIFT, + "repo_url": "https://github.com/SwiftyJSON/SwiftyJSON/", + "repo_commit": "8805b547079d605f61f9c7964296384ff6fe853e" + } + with create_test_context(params) as context: + lsp = LanguageServer.create(context.config, context.logger, context.source_directory) + test_file = str(PurePath("Source/SwiftyJSON/SwiftyJSON.swift")) + + async with lsp.start_server(): + # Hover over SwiftyJSONError enum + hover_result = await lsp.request_hover(test_file, 35, 15) + + assert hover_result is not None, "Hover result should not be None" + assert "contents" in hover_result, "Hover result should contain contents" + diff --git a/tests/multilspy/test_multilspy_timeout.py b/tests/multilspy/test_multilspy_timeout.py new file mode 100644 index 0000000..1e7d9cc --- /dev/null +++ b/tests/multilspy/test_multilspy_timeout.py @@ -0,0 +1,326 @@ +""" +Tests for per-request timeout protection and graceful fallback in multilspy LanguageServer. +""" + +import asyncio +import logging +import pytest +import time +from pathlib import PurePath +from unittest.mock import AsyncMock, MagicMock + +from multilspy import LanguageServer +from multilspy.multilspy_config import Language, MultilspyConfig +from tests.test_utils import create_test_context + +pytest_plugins = ("pytest_asyncio",) + + +@pytest.mark.asyncio +async def test_multilspy_request_definition_timeout_fallback(caplog) -> None: + """ + Test that request_definition times out fast and falls back to [] when server stalls, + logging a structured warning and checking process health. + """ + code_language = Language.PYTHON + params = { + "code_language": code_language, + "repo_url": "https://github.com/psf/black/", + "repo_commit": "f3b50e466969f9142393ec32a4b2a383ffbe5f23", + } + with create_test_context(params) as context: + lsp = LanguageServer.create(context.config, context.logger, context.source_directory) + async with lsp.start_server(): + # First verify normal definition works + res_normal = await lsp.request_definition(str(PurePath("src/black/mode.py")), 163, 4) + assert isinstance(res_normal, list) + assert len(res_normal) >= 1 + + # Simulate a stalled language server by wrapping definition RPC + real_send_def = lsp.server.send.definition + + async def stalled_definition(*args, **kwargs): + await asyncio.sleep(10.0) + return await real_send_def(*args, **kwargs) + + lsp.server.send.definition = stalled_definition + + # Verify that request times out fast and returns empty list [] + start_time = time.time() + with caplog.at_level(logging.WARNING): + result = await lsp.request_definition( + str(PurePath("src/black/mode.py")), 163, 4, timeout=0.2 + ) + elapsed = time.time() - start_time + + assert elapsed < 2.0, f"Expected fast failure under 2.0s, took {elapsed}s" + assert result == [], "Expected empty list fallback on timeout" + assert lsp.is_process_alive() is True, "Expected process to still be alive" + assert lsp.check_process_health() is True + + # Verify structured warning was logged + warning_logs = [r for r in caplog.records if r.levelno == logging.WARNING] + assert any( + "LSP request timed out" in r.message and "textDocument/definition" in r.message + for r in warning_logs + ) + + +@pytest.mark.asyncio +async def test_multilspy_request_references_timeout_fallback(caplog) -> None: + """ + Test that request_references times out fast and falls back to [] when server stalls. + """ + code_language = Language.PYTHON + params = { + "code_language": code_language, + "repo_url": "https://github.com/psf/black/", + "repo_commit": "f3b50e466969f9142393ec32a4b2a383ffbe5f23", + } + with create_test_context(params) as context: + lsp = LanguageServer.create(context.config, context.logger, context.source_directory) + async with lsp.start_server(): + async def stalled_references(*args, **kwargs): + await asyncio.sleep(10.0) + return [] + + lsp.server.send.references = stalled_references + + start_time = time.time() + with caplog.at_level(logging.WARNING): + result = await lsp.request_references( + str(PurePath("src/black/mode.py")), 163, 4, timeout=0.2 + ) + elapsed = time.time() - start_time + + assert elapsed < 2.0 + assert result == [] + assert lsp.is_process_alive() is True + warning_logs = [r for r in caplog.records if r.levelno == logging.WARNING] + assert any( + "LSP request timed out" in r.message and "textDocument/references" in r.message + for r in warning_logs + ) + + +@pytest.mark.asyncio +async def test_multilspy_request_completions_timeout_fallback(caplog) -> None: + """ + Test that request_completions times out fast and falls back to []. + """ + code_language = Language.PYTHON + params = { + "code_language": code_language, + "repo_url": "https://github.com/psf/black/", + "repo_commit": "f3b50e466969f9142393ec32a4b2a383ffbe5f23", + } + with create_test_context(params) as context: + lsp = LanguageServer.create(context.config, context.logger, context.source_directory) + async with lsp.start_server(): + async def stalled_completion(*args, **kwargs): + await asyncio.sleep(10.0) + return [] + + lsp.server.send.completion = stalled_completion + + start_time = time.time() + with caplog.at_level(logging.WARNING): + result = await lsp.request_completions( + str(PurePath("src/black/mode.py")), 163, 4, timeout=0.2 + ) + elapsed = time.time() - start_time + + assert elapsed < 2.0 + assert result == [] + assert lsp.is_process_alive() is True + warning_logs = [r for r in caplog.records if r.levelno == logging.WARNING] + assert any( + "LSP request timed out" in r.message and "textDocument/completion" in r.message + for r in warning_logs + ) + + +@pytest.mark.asyncio +async def test_multilspy_request_document_symbols_timeout_fallback(caplog) -> None: + """ + Test that request_document_symbols times out fast and falls back to ([], None). + """ + code_language = Language.PYTHON + params = { + "code_language": code_language, + "repo_url": "https://github.com/psf/black/", + "repo_commit": "f3b50e466969f9142393ec32a4b2a383ffbe5f23", + } + with create_test_context(params) as context: + lsp = LanguageServer.create(context.config, context.logger, context.source_directory) + async with lsp.start_server(): + async def stalled_document_symbol(*args, **kwargs): + await asyncio.sleep(10.0) + return [] + + lsp.server.send.document_symbol = stalled_document_symbol + + start_time = time.time() + with caplog.at_level(logging.WARNING): + result, tree = await lsp.request_document_symbols( + str(PurePath("src/black/mode.py")), timeout=0.2 + ) + elapsed = time.time() - start_time + + assert elapsed < 2.0 + assert result == [] + assert tree is None + assert lsp.is_process_alive() is True + warning_logs = [r for r in caplog.records if r.levelno == logging.WARNING] + assert any( + "LSP request timed out" in r.message and "textDocument/documentSymbol" in r.message + for r in warning_logs + ) + + +@pytest.mark.asyncio +async def test_multilspy_request_hover_timeout_fallback(caplog) -> None: + """ + Test that request_hover times out fast and falls back to None. + """ + code_language = Language.PYTHON + params = { + "code_language": code_language, + "repo_url": "https://github.com/psf/black/", + "repo_commit": "f3b50e466969f9142393ec32a4b2a383ffbe5f23", + } + with create_test_context(params) as context: + lsp = LanguageServer.create(context.config, context.logger, context.source_directory) + async with lsp.start_server(): + async def stalled_hover(*args, **kwargs): + await asyncio.sleep(10.0) + return None + + lsp.server.send.hover = stalled_hover + + start_time = time.time() + with caplog.at_level(logging.WARNING): + result = await lsp.request_hover( + str(PurePath("src/black/mode.py")), 163, 4, timeout=0.2 + ) + elapsed = time.time() - start_time + + assert elapsed < 2.0 + assert result is None + assert lsp.is_process_alive() is True + warning_logs = [r for r in caplog.records if r.levelno == logging.WARNING] + assert any( + "LSP request timed out" in r.message and "textDocument/hover" in r.message + for r in warning_logs + ) + + +@pytest.mark.asyncio +async def test_multilspy_config_default_timeout() -> None: + """ + Test that MultilspyConfig.request_timeout is respected as the default timeout. + """ + code_language = Language.PYTHON + params = { + "code_language": code_language, + "repo_url": "https://github.com/psf/black/", + "repo_commit": "f3b50e466969f9142393ec32a4b2a383ffbe5f23", + } + with create_test_context(params) as context: + context.config.request_timeout = 0.2 + lsp = LanguageServer.create(context.config, context.logger, context.source_directory) + assert lsp.request_timeout == 0.2 + + async with lsp.start_server(): + async def stalled_definition(*args, **kwargs): + await asyncio.sleep(10.0) + return [] + + lsp.server.send.definition = stalled_definition + + start_time = time.time() + result = await lsp.request_definition(str(PurePath("src/black/mode.py")), 163, 4) + elapsed = time.time() - start_time + + assert elapsed < 2.0 + assert result == [] + + +def test_process_health_check_dead_process(caplog) -> None: + """ + Test that check_process_health correctly detects a dead process and logs a warning. + """ + code_language = Language.PYTHON + params = { + "code_language": code_language, + "repo_url": "https://github.com/psf/black/", + "repo_commit": "f3b50e466969f9142393ec32a4b2a383ffbe5f23", + } + with create_test_context(params) as context: + lsp = LanguageServer.create(context.config, context.logger, context.source_directory) + # Simulate dead process + mock_process = MagicMock() + mock_process.returncode = 137 + lsp.server.process = mock_process + + assert lsp.is_process_alive() is False + with caplog.at_level(logging.WARNING): + healthy = lsp.check_process_health() + + assert healthy is False + warning_logs = [r for r in caplog.records if r.levelno == logging.WARNING] + assert any( + "Language server process is not alive (returncode: 137)" in r.message + for r in warning_logs + ) + + +@pytest.mark.asyncio +async def test_request_timeout_sends_cancel_request() -> None: + """ + Test that when an RPC request times out, LanguageServerHandler sends $/cancelRequest + with the timed-out request id to the LSP server. + """ + code_language = Language.PYTHON + params = { + "code_language": code_language, + "repo_url": "https://github.com/psf/black/", + "repo_commit": "f3b50e466969f9142393ec32a4b2a383ffbe5f23", + } + with create_test_context(params) as context: + lsp = LanguageServer.create(context.config, context.logger, context.source_directory) + async with lsp.start_server(): + # Spy on cancel_request notification + cancel_calls = [] + real_cancel = lsp.server.notify.cancel_request + + def cancel_spy(params): + cancel_calls.append(params) + return real_cancel(params) + + lsp.server.notify.cancel_request = cancel_spy + + # Mock _send_payload to ONLY drop textDocument/definition so the server never responds to it, + # while allowing initialize and shutdown to proceed normally + real_send_payload = lsp.server._send_payload + + async def drop_definition_payload(payload): + if payload.get("method") == "textDocument/definition": + return + return await real_send_payload(payload) + + lsp.server._send_payload = drop_definition_payload + + result = await lsp.request_definition( + str(PurePath("src/black/mode.py")), 163, 4, timeout=0.2 + ) + + assert result == [] + # Verify $/cancelRequest was sent with the request id + assert len(cancel_calls) == 1 + assert "id" in cancel_calls[0] + assert isinstance(cancel_calls[0]["id"], int) + + # Restore original _send_payload for clean shutdown + lsp.server._send_payload = real_send_payload + diff --git a/tests/multilspy/test_path_classifier.py b/tests/multilspy/test_path_classifier.py new file mode 100644 index 0000000..bd56d26 --- /dev/null +++ b/tests/multilspy/test_path_classifier.py @@ -0,0 +1,266 @@ +""" +Unit tests for PathClassifier and external package coordinate extraction. +""" + +from multilspy.path_classifier import PathClassifier, PathClassification +from multilspy.multilspy_utils import PathClassifier as UtilsPathClassifier + + +def test_elixir_hex_classification(): + # Workspace deps + path = "/Users/rob/projects/my_phoenix_app/deps/phoenix/lib/phoenix/endpoint.ex" + result = PathClassifier.classify(path) + assert result.is_external is True + assert result.package_name == "phoenix" + assert result.package_relative_path == "lib/phoenix/endpoint.ex" + assert result.ecosystem == "HEX" + + # Tuple unpacking test + is_ext, pkg, rel = result + assert is_ext is True + assert pkg == "phoenix" + assert rel == "lib/phoenix/endpoint.ex" + + # Jason dep in deps + path = "deps/jason/lib/jason.ex" + result = PathClassifier.classify(path) + assert result.is_external is True + assert result.package_name == "jason" + assert result.package_relative_path == "lib/jason.ex" + assert result.ecosystem == "HEX" + + # Build cache + path = "/app/_build/dev/lib/ecto/ebin/ecto.beam" + result = PathClassifier.classify(path) + assert result.is_external is True + assert result.package_name == "ecto" + assert result.ecosystem == "HEX" + + +def test_swift_spm_and_sdk_classification(): + # SPM checkout + path = "/Users/rob/projects/App/.build/checkouts/swift-algorithms/Sources/Algorithms/Chain.swift" + result = PathClassifier.classify(path) + assert result.is_external is True + assert result.package_name == "swift-algorithms" + assert result.package_relative_path == "Sources/Algorithms/Chain.swift" + assert result.ecosystem == "SWIFT" + + # SwiftyJSON SPM checkout + path = "/repo/.build/checkouts/SwiftyJSON/Source/SwiftyJSON/SwiftyJSON.swift" + result = PathClassifier.classify(path) + assert result.is_external is True + assert result.package_name == "SwiftyJSON" + assert result.package_relative_path == "Source/SwiftyJSON/SwiftyJSON.swift" + assert result.ecosystem == "SWIFT" + + # Apple framework + path = "/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk/System/Library/Frameworks/Foundation.framework/Headers/NSString.h" + result = PathClassifier.classify(path) + assert result.is_external is True + assert result.package_name == "Foundation" + assert result.ecosystem == "SWIFT" + + # Swift stdlib interface + path = "/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/lib/swift/Swift.swiftinterface" + result = PathClassifier.classify(path) + assert result.is_external is True + assert result.package_name == "apple/swift" + assert result.ecosystem == "SWIFT" + + +def test_scala_coursier_and_maven_classification(): + # Coursier cache with Scala binary suffix + path = "/Users/rob/.cache/coursier/v1/https/repo1.maven.org/maven2/org/typelevel/cats-core_2.13/2.9.0/cats-core_2.13-2.9.0-sources.jar!/cats/Functor.scala" + result = PathClassifier.classify(path) + assert result.is_external is True + assert result.package_name == "org.typelevel:cats-core" + assert result.version == "2.9.0" + assert result.package_relative_path == "cats/Functor.scala" + assert result.ecosystem == "MAVEN" + + # Scala 3 library via Coursier + path = "/home/user/.cache/coursier/v1/https/repo1.maven.org/maven2/org/scala-lang/scala3-library_3/3.3.1/scala3-library_3-3.3.1-sources.jar!/scala/Option.scala" + result = PathClassifier.classify(path) + assert result.is_external is True + assert result.package_name == "org.scala-lang:scala3-library" + assert result.version == "3.3.1" + assert result.package_relative_path == "scala/Option.scala" + assert result.ecosystem == "MAVEN" + + # Local Maven repository (.m2) + path = "/home/user/.m2/repository/org/apache/jena/jena-core/4.9.0/jena-core-4.9.0.jar!/org/apache/jena/rdf/model/Model.class" + result = PathClassifier.classify(path) + assert result.is_external is True + assert result.package_name == "org.apache.jena:jena-core" + assert result.version == "4.9.0" + assert result.ecosystem == "MAVEN" + + # JDK stdlib + path = "/java.base/java.lang/System.class" + result = PathClassifier.classify(path) + assert result.is_external is True + assert result.package_name == "java.base:java.lang" + assert result.ecosystem == "JDK_STDLIB" + + +def test_php_composer_classification(): + # Vendor path + path = "/var/www/html/vendor/symfony/console/Command/Command.php" + result = PathClassifier.classify(path) + assert result.is_external is True + assert result.package_name == "symfony/console" + assert result.package_relative_path == "Command/Command.php" + assert result.ecosystem == "PACKAGIST" + + # Monolog vendor path + path = "vendor/monolog/monolog/src/Monolog/Logger.php" + result = PathClassifier.classify(path) + assert result.is_external is True + assert result.package_name == "monolog/monolog" + assert result.package_relative_path == "src/Monolog/Logger.php" + assert result.ecosystem == "PACKAGIST" + + +def test_python_pypi_classification(): + path = "/Users/rob/repos/app/.venv/lib/python3.11/site-packages/pydantic/main.py" + result = PathClassifier.classify(path) + assert result.is_external is True + assert result.package_name == "pydantic" + assert result.package_relative_path == "main.py" + assert result.ecosystem == "PYPI" + + +def test_js_ts_npm_classification(): + # Standard package + path = "/app/node_modules/lodash/map.js" + result = PathClassifier.classify(path) + assert result.is_external is True + assert result.package_name == "lodash" + assert result.package_relative_path == "map.js" + assert result.ecosystem == "NPM" + + # Scoped package + path = "/app/node_modules/@types/node/fs.d.ts" + result = PathClassifier.classify(path) + assert result.is_external is True + assert result.package_name == "@types/node" + assert result.package_relative_path == "fs.d.ts" + assert result.ecosystem == "NPM" + + +def test_rust_cargo_classification(): + path = "/home/user/.cargo/registry/src/index.crates.io-6f17d22bba15001f/serde-1.0.200/src/lib.rs" + result = PathClassifier.classify(path) + assert result.is_external is True + assert result.package_name == "serde" + assert result.version == "1.0.200" + assert result.package_relative_path == "src/lib.rs" + assert result.ecosystem == "CARGO" + + +def test_go_classification(): + path = "/home/user/go/pkg/mod/github.com/gin-gonic/gin@v1.9.1/context.go" + result = PathClassifier.classify(path) + assert result.is_external is True + assert result.package_name == "github.com/gin-gonic/gin" + assert result.version == "1.9.1" + assert result.package_relative_path == "context.go" + assert result.ecosystem == "GO" + + +def test_intra_repository_classification(): + repo_root = "/Users/rob/repos/my-project" + + # File inside repo + path = "/Users/rob/repos/my-project/src/models/user.py" + result = PathClassifier.classify(path, repository_root_path=repo_root) + assert result.is_external is False + assert result.package_name is None + assert result.package_relative_path == "src/models/user.py" + + # External dependency inside repo workspace (e.g. deps/ or node_modules/) + # should STILL be recognized as external! + path = "/Users/rob/repos/my-project/deps/phoenix/lib/phoenix.ex" + result = PathClassifier.classify(path, repository_root_path=repo_root) + assert result.is_external is True + assert result.package_name == "phoenix" + assert result.ecosystem == "HEX" + + # Swift checkout inside repo workspace + path = "/Users/rob/repos/my-project/.build/checkouts/SwiftyJSON/Source/SwiftyJSON/SwiftyJSON.swift" + result = PathClassifier.classify(path, repository_root_path=repo_root) + assert result.is_external is True + assert result.package_name == "SwiftyJSON" + assert result.ecosystem == "SWIFT" + + +def test_reexport_via_multilspy_utils(): + # Verify that importing from multilspy_utils behaves identically + path = "deps/ecto/lib/ecto.ex" + result = UtilsPathClassifier.classify(path) + assert result.is_external is True + assert result.package_name == "ecto" + assert result.ecosystem == "HEX" + + +def test_lua_luarocks_classification(): + # Versioned rocks tree + path = "/Users/rob/.luarocks/lib/luarocks/rocks-5.1/inspect/3.1.3-1/lua/inspect.lua" + result = PathClassifier.classify(path) + assert result.is_external is True + assert result.package_name == "inspect" + assert result.version == "3.1.3-1" + assert result.package_relative_path == "lua/inspect.lua" + assert result.ecosystem == "LUAROCKS" + + # User tree single file + path = "/Users/rob/.luarocks/share/lua/5.1/inspect.lua" + result = PathClassifier.classify(path) + assert result.is_external is True + assert result.package_name == "inspect" + assert result.package_relative_path == "inspect.lua" + assert result.ecosystem == "LUAROCKS" + + # Workspace lua_modules + path = "/repo/lua_modules/share/lua/5.4/cjson/util.lua" + result = PathClassifier.classify(path) + assert result.is_external is True + assert result.package_name == "cjson" + assert result.package_relative_path == "util.lua" + assert result.ecosystem == "LUAROCKS" + + # System share/lua + path = "/usr/local/share/lua/5.3/pl/stringx.lua" + result = PathClassifier.classify(path) + assert result.is_external is True + assert result.package_name == "pl" + assert result.package_relative_path == "stringx.lua" + assert result.ecosystem == "LUAROCKS" + + +def test_bash_classification(): + # Basher package + path = "/Users/rob/.basher/cellar/packages/bats-core/bats-core/bin/bats" + result = PathClassifier.classify(path) + assert result.is_external is True + assert result.package_name == "bats-core/bats-core" + assert result.package_relative_path == "bin/bats" + assert result.ecosystem == "BASH" + + # Bats test library + path = "/usr/lib/bats-assert/load.bash" + result = PathClassifier.classify(path) + assert result.is_external is True + assert result.package_name == "bats-assert" + assert result.package_relative_path == "load.bash" + assert result.ecosystem == "BASH" + + # Bash completion + path = "/usr/share/bash-completion/completions/git" + result = PathClassifier.classify(path) + assert result.is_external is True + assert result.package_name == "bash-completion" + assert result.package_relative_path == "git" + assert result.ecosystem == "BASH" +