diff --git a/.gitignore b/.gitignore index ac62ed2..f284172 100644 --- a/.gitignore +++ b/.gitignore @@ -410,4 +410,5 @@ src/multilspy/language_servers/clangd_language_server/static/ venv/ src/multilspy/language_servers/intelephense/static/ -src/multilspy/language_servers/elixir_language_server/static/ \ No newline at end of file +src/multilspy/language_servers/elixir_language_server/static/ +src/multilspy/language_servers/bsl_language_server/static/ \ No newline at end of file diff --git a/README.md b/README.md index e3acb87..8a8929c 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,7 @@ pip install multilspy | kotlin | KotlinLanguageServer | | php | Intelephense | | cpp | clangd | +| bsl | [bsl-language-server](https://github.com/1c-syntax/bsl-language-server) (1C:Enterprise / OneScript — requires Java 17+) | ## Usage @@ -60,7 +61,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", "php", "bsl" logger = MultilspyLogger() lsp = SyncLanguageServer.create(config, logger, "/abs/path/to/project/root/") with lsp.start_server(): diff --git a/src/multilspy/language_server.py b/src/multilspy/language_server.py index 3af094b..5656c14 100644 --- a/src/multilspy/language_server.py +++ b/src/multilspy/language_server.py @@ -131,6 +131,10 @@ def create(cls, config: MultilspyConfig, logger: MultilspyLogger, repository_roo from multilspy.language_servers.elixir_language_server.elixir_language_server import ElixirLanguageServer return ElixirLanguageServer(config, logger, repository_root_path) + elif config.code_language == Language.BSL: + from multilspy.language_servers.bsl_language_server.bsl_language_server import BSLLanguageServer + + return BSLLanguageServer(config, logger, repository_root_path) else: logger.log(f"Language {config.code_language} is not supported", logging.ERROR) diff --git a/src/multilspy/language_servers/bsl_language_server/__init__.py b/src/multilspy/language_servers/bsl_language_server/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/multilspy/language_servers/bsl_language_server/bsl_language_server.py b/src/multilspy/language_servers/bsl_language_server/bsl_language_server.py new file mode 100644 index 0000000..97e03d0 --- /dev/null +++ b/src/multilspy/language_servers/bsl_language_server/bsl_language_server.py @@ -0,0 +1,177 @@ +""" +Provides BSL-specific instantiation of the LanguageServer class. + +BSL (Business Scripting Language) is the programming language of the +1C:Enterprise platform and OneScript. This adapter wraps the bsl-language-server +JAR from https://github.com/1c-syntax/bsl-language-server. + +Prerequisites: + - Java 17+ must be available in PATH. + - The BSL Language Server JAR is downloaded on first use from GitHub releases. +""" + +import asyncio +import hashlib +import json +import logging +import os +import pathlib +import shutil +from contextlib import asynccontextmanager +from typing import AsyncIterator + +from multilspy.language_server import LanguageServer +from multilspy.lsp_protocol_handler.server import ProcessLaunchInfo +from multilspy.multilspy_config import MultilspyConfig +from multilspy.multilspy_logger import MultilspyLogger +from multilspy.multilspy_utils import FileUtils + + +class BSLLanguageServer(LanguageServer): + """ + Provides BSL-specific instantiation of the LanguageServer class. + """ + + def __init__( + self, + config: MultilspyConfig, + logger: MultilspyLogger, + repository_root_path: str, + ): + """ + Creates a BSL Language Server instance. Do not instantiate directly; + use `LanguageServer.create` with `Language.BSL`. + """ + jar_path = self._ensure_jar(logger) + super().__init__( + config, + logger, + repository_root_path, + ProcessLaunchInfo( + cmd=["java", "-jar", str(jar_path), "--lsp"], + cwd=repository_root_path, + ), + "bsl", + ) + + def _ensure_jar(self, logger: MultilspyLogger) -> str: + """Verify Java and the BSL LS JAR are available; download JAR if missing.""" + if shutil.which("java") is None: + raise RuntimeError( + "Java 17+ is required to run BSL Language Server, " + "but `java` was not found in PATH. " + "Install a JDK 17+ from https://adoptium.net/ " + "and ensure `java` is on PATH." + ) + + with open( + os.path.join(os.path.dirname(__file__), "runtime_dependencies.json"), + "r", + encoding="utf-8", + ) as f: + deps = json.load(f) + dep = deps["runtimeDependency"] + + server_dir = os.path.join(os.path.dirname(__file__), "static", dep["version"]) + os.makedirs(server_dir, exist_ok=True) + jar_path = os.path.join(server_dir, dep["binaryName"]) + + if not os.path.exists(jar_path): + logger.log( + f"Downloading BSL Language Server {dep['version']} " + f"from {dep['url']}", + logging.INFO, + ) + FileUtils.download_file(logger, dep["url"], jar_path) + + expected_sha = dep.get("sha256") or "" + if expected_sha: + with open(jar_path, "rb") as jar_f: + actual_sha = hashlib.sha256(jar_f.read()).hexdigest() + if actual_sha != expected_sha: + os.remove(jar_path) + raise RuntimeError( + f"BSL Language Server JAR SHA256 mismatch: " + f"expected {expected_sha}, got {actual_sha}. " + f"Corrupted or tampered download; retry required." + ) + + return jar_path + + def _get_initialize_params(self, repository_absolute_path: str) -> dict: + with open( + os.path.join(os.path.dirname(__file__), "initialize_params.json"), + "r", + encoding="utf-8", + ) as f: + d = json.load(f) + + del d["_description"] + + root = pathlib.Path(repository_absolute_path) + d["processId"] = os.getpid() + d["rootPath"] = repository_absolute_path + d["rootUri"] = root.as_uri() + d["workspaceFolders"][0]["uri"] = root.as_uri() + d["workspaceFolders"][0]["name"] = root.name or "workspace" + return d + + @asynccontextmanager + async def start_server(self) -> AsyncIterator["BSLLanguageServer"]: + async def do_nothing(params): # noqa: ARG001 + return + + async def log_message(msg): + self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) + + self.server.on_request("client/registerCapability", do_nothing) + self.server.on_notification("language/status", do_nothing) + self.server.on_notification("window/logMessage", log_message) + self.server.on_notification("window/showMessage", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + self.server.on_notification("$/progress", do_nothing) + + async with super().start_server(): + self.logger.log( + f"Starting BSL Language Server: {self.server.process_launch_info.cmd}", + logging.INFO, + ) + await self.server.start() + + if self.server.process.returncode is not None: + raise RuntimeError( + f"BSL Language Server failed to start " + f"(exit code {self.server.process.returncode}). " + f"Check Java version (17+ required)." + ) + + initialize_params = self._get_initialize_params(self.repository_root_path) + + init_response = await asyncio.wait_for( + self.server.send.initialize(initialize_params), + timeout=120, + ) + self.logger.log( + f"BSL LS capabilities: " + f"{list(init_response.get('capabilities', {}).keys())}", + logging.INFO, + ) + + self.server.notify.initialized({}) + self.completions_available.set() + + try: + yield self + finally: + try: + await self.server.shutdown() + except Exception as e: + self.logger.log( + f"BSL LS shutdown() raised: {e!r}", logging.WARNING + ) + try: + await self.server.stop() + except Exception as e: + self.logger.log( + f"BSL LS stop() raised: {e!r}", logging.WARNING + ) diff --git a/src/multilspy/language_servers/bsl_language_server/initialize_params.json b/src/multilspy/language_servers/bsl_language_server/initialize_params.json new file mode 100644 index 0000000..9b19063 --- /dev/null +++ b/src/multilspy/language_servers/bsl_language_server/initialize_params.json @@ -0,0 +1,51 @@ +{ + "_description": "Initialization parameters for the BSL Language Server (1C Enterprise / OneScript). See https://github.com/1c-syntax/bsl-language-server.", + "processId": "$processId", + "rootPath": "$rootPath", + "rootUri": "$rootUri", + "workspaceFolders": [ + { + "uri": "$rootUri", + "name": "$rootName" + } + ], + "capabilities": { + "workspace": { + "workspaceFolders": true, + "configuration": true, + "didChangeWatchedFiles": { + "dynamicRegistration": false + }, + "workspaceSymbol": {} + }, + "textDocument": { + "synchronization": { + "didSave": true + }, + "documentSymbol": { + "hierarchicalDocumentSymbolSupport": true + }, + "definition": { + "linkSupport": true + }, + "references": {}, + "rename": { + "prepareSupport": true + }, + "hover": { + "contentFormat": ["markdown", "plaintext"] + }, + "completion": { + "completionItem": { + "snippetSupport": true, + "documentationFormat": ["markdown", "plaintext"] + } + }, + "signatureHelp": {}, + "formatting": {}, + "codeAction": {} + } + }, + "initializationOptions": {}, + "trace": "off" +} diff --git a/src/multilspy/language_servers/bsl_language_server/runtime_dependencies.json b/src/multilspy/language_servers/bsl_language_server/runtime_dependencies.json new file mode 100644 index 0000000..86d28f1 --- /dev/null +++ b/src/multilspy/language_servers/bsl_language_server/runtime_dependencies.json @@ -0,0 +1,12 @@ +{ + "_description": "Used to download the runtime dependencies for BSL Language Server from https://github.com/1c-syntax/bsl-language-server. Java 17+ must be available in PATH.", + "runtimeDependency": { + "id": "BSLLanguageServer", + "description": "BSL Language Server (1C Enterprise / OneScript)", + "version": "0.22.0", + "url": "https://github.com/1c-syntax/bsl-language-server/releases/download/0.22.0/bsl-language-server-0.22.0.jar", + "archiveType": "jar", + "binaryName": "bsl-language-server.jar", + "sha256": "10835036c3b39eba9d1bbef65debc36ea99939a651165cd55ef86bf41c7e28ea" + } +} diff --git a/src/multilspy/multilspy_config.py b/src/multilspy/multilspy_config.py index b12a8ca..d1a7e33 100644 --- a/src/multilspy/multilspy_config.py +++ b/src/multilspy/multilspy_config.py @@ -24,6 +24,7 @@ class Language(str, Enum): CPP = "cpp" PHP = "php" ELIXIR = "elixir" + BSL = "bsl" def __str__(self) -> str: return self.value diff --git a/tests/multilspy/test_multilspy_bsl.py b/tests/multilspy/test_multilspy_bsl.py new file mode 100644 index 0000000..87cff49 --- /dev/null +++ b/tests/multilspy/test_multilspy_bsl.py @@ -0,0 +1,155 @@ +""" +Tests for the BSL Language Server (1C:Enterprise / OneScript). + +BSL LS requires Java 17+ in PATH and downloads the JAR (~94 MB) on first run +from https://github.com/1c-syntax/bsl-language-server/releases. + +Skipped if `java` is not available, so CI without Java will not flake. +""" + +import contextlib +import shutil +import tempfile +from pathlib import Path, PurePath +from typing import Iterator + +import pytest + +from multilspy import LanguageServer +from multilspy.multilspy_config import Language, MultilspyConfig +from multilspy.multilspy_logger import MultilspyLogger + +pytest_plugins = ("pytest_asyncio",) + +pytestmark = pytest.mark.skipif( + shutil.which("java") is None, + reason="BSL LS requires Java 17+ in PATH", +) + + +@contextlib.contextmanager +def create_bsl_test_project() -> Iterator[str]: + """Create a minimal self-contained 1C:Enterprise configuration. + + Two common modules: one declares an exported function, the other calls it. + Configuration.xml provides the workspace root descriptor required by BSL LS. + """ + with tempfile.TemporaryDirectory() as root: + project_dir = Path(root) / "bsl_test_project" + project_dir.mkdir() + + # Minimal Configuration.xml root (BSL LS reads this for project layout). + (project_dir / "Configuration.xml").write_text( + '\n' + '\n' + ' \n' + ' \n' + ' TestConfig\n' + ' \n' + ' \n' + '\n', + encoding="utf-8", + ) + + util_dir = project_dir / "CommonModules" / "TestUtil" / "Ext" + util_dir.mkdir(parents=True) + (util_dir.parent / "TestUtil.xml").write_text( + '\n' + '\n' + ' TestUtil' + 'true\n' + '\n', + encoding="utf-8", + ) + (util_dir / "Module.bsl").write_text( + "Функция ПолучитьЗначение() Экспорт\n" + " Возврат \"default\";\n" + "КонецФункции\n", + encoding="utf-8", + ) + + caller_dir = project_dir / "CommonModules" / "TestCaller" / "Ext" + caller_dir.mkdir(parents=True) + (caller_dir.parent / "TestCaller.xml").write_text( + '\n' + '\n' + ' TestCaller' + 'true\n' + '\n', + encoding="utf-8", + ) + (caller_dir / "Module.bsl").write_text( + "Процедура Вызвать() Экспорт\n" + " Сообщить(TestUtil.ПолучитьЗначение());\n" + "КонецПроцедуры\n", + encoding="utf-8", + ) + + yield str(project_dir) + + +@pytest.mark.asyncio +async def test_multilspy_bsl_start_and_document_symbols() -> None: + """Server starts, initializes, and returns symbols for a .bsl file.""" + config = MultilspyConfig(code_language=Language.BSL) + logger = MultilspyLogger() + + with create_bsl_test_project() as project_dir: + lsp = LanguageServer.create(config, logger, project_dir) + test_file = str( + PurePath("CommonModules/TestUtil/Ext/Module.bsl") + ) + + async with lsp.start_server(): + symbols, _tree = await lsp.request_document_symbols(test_file) + assert isinstance(symbols, list) + # BSL LS exposes functions/procedures as symbols of kind 12 (Function) + # or 6 (Method). We just assert the wiring returns structural data. + if symbols: + names = {s.get("name") for s in symbols} + assert "ПолучитьЗначение" in names + + +@pytest.mark.asyncio +async def test_multilspy_bsl_references_in_same_module() -> None: + """In-file references for a declaration work without workspace preload.""" + config = MultilspyConfig(code_language=Language.BSL) + logger = MultilspyLogger() + + with create_bsl_test_project() as project_dir: + lsp = LanguageServer.create(config, logger, project_dir) + util_rel = str(PurePath("CommonModules/TestUtil/Ext/Module.bsl")) + + async with lsp.start_server(): + with lsp.open_file(util_rel): + refs = await lsp.request_references(util_rel, 0, 10) + assert isinstance(refs, list) + + +@pytest.mark.asyncio +async def test_multilspy_bsl_rename_returns_workspace_edit() -> None: + """Raw rename RPC returns either {changes}, {documentChanges}, or None. + + This is the critical integration contract for downstream refactoring tools. + """ + config = MultilspyConfig(code_language=Language.BSL) + logger = MultilspyLogger() + + with create_bsl_test_project() as project_dir: + lsp = LanguageServer.create(config, logger, project_dir) + util_rel = str(PurePath("CommonModules/TestUtil/Ext/Module.bsl")) + util_uri = (Path(project_dir) / util_rel).resolve().as_uri() + + async with lsp.start_server(): + with lsp.open_file(util_rel): + result = await lsp.server.send.rename( + { + "textDocument": {"uri": util_uri}, + "position": {"line": 0, "character": 10}, + "newName": "ПолучитьЗначениеНовое", + } + ) + # Either a WorkspaceEdit dict or None (LS chose not to rename). + assert result is None or isinstance(result, dict) + if isinstance(result, dict): + assert "changes" in result or "documentChanges" in result