diff --git a/src/multilspy/language_server.py b/src/multilspy/language_server.py index 270a571..e9364bf 100644 --- a/src/multilspy/language_server.py +++ b/src/multilspy/language_server.py @@ -100,6 +100,12 @@ def create(cls, config: MultilspyConfig, logger: MultilspyLogger, repository_roo TypeScriptLanguageServer, ) return TypeScriptLanguageServer(config, logger, repository_root_path) + elif config.code_language == Language.GO: + from multilspy.language_servers.gopls.gopls import ( + Gopls, + ) + + return Gopls(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") diff --git a/src/multilspy/language_servers/gopls/gopls.py b/src/multilspy/language_servers/gopls/gopls.py new file mode 100644 index 0000000..9e73391 --- /dev/null +++ b/src/multilspy/language_servers/gopls/gopls.py @@ -0,0 +1,196 @@ +""" +Provides Go specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Go. +""" + +import asyncio +import json +import logging +import os +import pwd +import shutil +import stat +import pathlib +from contextlib import asynccontextmanager +import subprocess +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 PlatformId +from multilspy.multilspy_utils import PlatformUtils + + +class Gopls(LanguageServer): + """ + Provides Go specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Go. + """ + + def __init__(self, config: MultilspyConfig, logger: MultilspyLogger, repository_root_path: str): + """ + Creates a gopls instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. + """ + gopls_executable_path = self.setup_runtime_dependencies(logger, config) + super().__init__( + config, + logger, + repository_root_path, + ProcessLaunchInfo(cmd=gopls_executable_path, cwd=repository_root_path), + "go", + ) + self.server_ready = asyncio.Event() + + def setup_runtime_dependencies(self, logger: MultilspyLogger, config: MultilspyConfig) -> str: + """ + Setup runtime dependencies for gopls. + """ + platform_id = PlatformUtils.get_platform_id() + + valid_platforms = [ + PlatformId.LINUX_x64, + PlatformId.LINUX_arm64, + PlatformId.OSX, + PlatformId.OSX_x64, + PlatformId.OSX_arm64, + PlatformId.WIN_x64, + PlatformId.WIN_arm64, + ] + assert platform_id in valid_platforms, f"Platform {platform_id} is not supported for multilspy gopls at the moment" + + 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 = d.get("runtimeDependencies", []) + assert len(runtime_dependencies) == 1 + dependency = runtime_dependencies[0] + + # Require Go sdk to be installed + assert shutil.which('go') is not None, "go is not installed or isn't in PATH. Please install go and try again." + + # Install gopls + gopls_dir = os.path.join(os.path.dirname(__file__), "static", "go", "bin") + gopls_path = os.path.join(gopls_dir, dependency["binaryName"]) + if not os.path.exists(gopls_path): + os.makedirs(gopls_dir, exist_ok=True) + user = pwd.getpwuid(os.getuid()).pw_name + new_env = os.environ.copy() + new_env["GOBIN"] = gopls_dir + subprocess.run( + dependency["command"], + shell=True, + check=True, + user=user, + cwd=gopls_dir, + env=new_env, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL + ) + + gopls_path = os.path.join(gopls_dir, dependency["binaryName"]) + + return gopls_path + + def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: + """ + Returns the initialize params for the Go 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["Gopls"]: + """ + Starts gopls, 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 register_capability_handler(params): + assert "registrations" in params + for registration in params["registrations"]: + if registration["method"] == "workspace/executeCommand": + self.initialize_searcher_command_available.set() + self.resolve_main_method_available.set() + return + + async def lang_status_handler(params): + # TODO: Should we wait for + # server -> client: {'jsonrpc': '2.0', 'method': 'language/status', 'params': {'type': 'ProjectStatus', 'message': 'OK'}} + # Before proceeding? + if params["type"] == "ServiceReady" and params["message"] == "ServiceReady": + self.service_ready_event.set() + + 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) + + + + self.server.on_request("client/registerCapability", register_capability_handler) + self.server.on_notification("language/status", lang_status_handler) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_request("workspace/executeClientCommand", execute_client_command_handler) + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + self.server.on_notification("language/actionableNotification", do_nothing) + self.server.on_request("window/workDoneProgress/create", do_nothing) + self.server.on_request("workspace/configuration", do_nothing) + + async with super().start_server(): + self.logger.log("Starting gopls 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) + assert init_response["capabilities"]["textDocumentSync"]["change"] == 2 + assert "completionProvider" in init_response["capabilities"] + assert init_response["capabilities"]["completionProvider"] == { + "triggerCharacters": ['.'], + } + self.server.notify.initialized({}) + self.completions_available.set() + + self.server_ready.set() + await self.server_ready.wait() + + yield self + + await self.server.shutdown() + await self.server.stop() diff --git a/src/multilspy/language_servers/gopls/initialize_params.json b/src/multilspy/language_servers/gopls/initialize_params.json new file mode 100644 index 0000000..0af26e6 --- /dev/null +++ b/src/multilspy/language_servers/gopls/initialize_params.json @@ -0,0 +1,480 @@ +{ + "_description": "The parameters sent by the client when initializing the language server with the \"initialize\" request. More details at https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#initialize", + "processId": "os.getpid()", + "clientInfo": { + "name": "Visual Studio Code", + "version": "1.95.3" + }, + "locale": "en", + "rootPath": "$rootPath", + "rootUri": "$rootUri", + "capabilities": { + "workspace": { + "applyEdit": true, + "workspaceEdit": { + "documentChanges": true, + "resourceOperations": [ + "create", + "rename", + "delete" + ], + "failureHandling": "textOnlyTransactional", + "normalizesLineEndings": true, + "changeAnnotationSupport": { + "groupsOnLabel": true + } + }, + "configuration": 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 + }, + "didChangeConfiguration": { + "dynamicRegistration": 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": true, + "commitCharactersSupport": true, + "documentationFormat": [ + "markdown", + "plaintext" + ], + "deprecatedSupport": true, + "preselectSupport": true, + "tagSupport": { + "valueSet": [ + 1 + ] + }, + "insertReplaceSupport": true, + "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 + } + }, + "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": { + "ui.inlayhint.hints": { + "assignVariableTypes": false, + "compositeLiteralFields": false, + "compositeLiteralTypes": false, + "constantValues": false, + "functionTypeParameters": false, + "parameterNames": false, + "rangeVariableTypes": false + }, + "ui.vulncheck": "Off", + "linkifyShowMessage": true + }, + "trace": "off", + "workspaceFolders": [ + { + "uri": "$uri", + "name": "$name" + } + ] +} \ No newline at end of file diff --git a/src/multilspy/language_servers/gopls/runtime_dependencies.json b/src/multilspy/language_servers/gopls/runtime_dependencies.json new file mode 100644 index 0000000..842ffe3 --- /dev/null +++ b/src/multilspy/language_servers/gopls/runtime_dependencies.json @@ -0,0 +1,11 @@ +{ + "_description": "Used to download the runtime dependencies for running gopls. Obtained from golang.org/x/tools/gopls@latest", + "runtimeDependencies": [ + { + "id": "gopls", + "description": "gopls Go Language server package for all platforms. Both x64 and arm64 are supported.", + "command": "go install golang.org/x/tools/gopls@latest", + "binaryName": "gopls" + } + ] +} \ No newline at end of file diff --git a/src/multilspy/multilspy_config.py b/src/multilspy/multilspy_config.py index 573cc5e..221389d 100644 --- a/src/multilspy/multilspy_config.py +++ b/src/multilspy/multilspy_config.py @@ -16,6 +16,7 @@ class Language(str, Enum): JAVA = "java" TYPESCRIPT = "typescript" JAVASCRIPT = "javascript" + GO = "go" def __str__(self) -> str: return self.value diff --git a/tests/multilspy/test_multilspy_go.py b/tests/multilspy/test_multilspy_go.py new file mode 100644 index 0000000..445dec0 --- /dev/null +++ b/tests/multilspy/test_multilspy_go.py @@ -0,0 +1,54 @@ +""" +This file contains tests for running the Golang Language Server: gopls +""" + +import pytest +from multilspy import LanguageServer +from multilspy.multilspy_config import Language +from tests.test_utils import create_test_context +from pathlib import PurePath + +pytest_plugins = ("pytest_asyncio",) + +@pytest.mark.asyncio +async def test_multilspy_golang_bbolt(): + """ + Test the working of multilspy with golang repository - bbolt + """ + code_language = Language.GO + params = { + "code_language": code_language, + "repo_url": "https://github.com/etcd-io/bbolt/", + "repo_commit": "7b2d3609bf79d1810d6454b22205dfe4ab754991" + } + with create_test_context(params) as context: + lsp = LanguageServer.create(context.config, context.logger, context.source_directory) + + # All the communication with the language server must be performed inside the context manager + # The server process is started when the context manager is entered and is terminated when the context manager is exited. + # The context manager is an asynchronous context manager, so it must be used with async with. + async with lsp.start_server(): + path = str(PurePath("cmd/bbolt/main.go")) + result = await lsp.request_definition(path, 214, 18) + assert isinstance(result, list) + assert len(result) == 1 + + item = result[0] + assert item["relativePath"] == str(PurePath("db.go")) + assert item["range"] == { + "start": {"line": 177, "character": 5}, + "end": {"line": 177, "character": 9}, + } + + result = await lsp.request_references(path, 1275, 18) + assert isinstance(result, list) + assert len(result) == 2 + + for item in result: + del item["uri"] + del item["absolutePath"] + + assert result == [ + {'range': {'start': {'line': 1128, 'character': 6}, 'end': {'line': 1128, 'character': 20}}, 'relativePath': path}, + {'range': {'start': {'line': 1275, 'character': 6}, 'end': {'line': 1275, 'character': 20}}, 'relativePath': path}, + ] \ No newline at end of file