diff --git a/.github/workflows/sc-language-server.yml b/.github/workflows/sc-language-server.yml new file mode 100644 index 0000000..e5550f8 --- /dev/null +++ b/.github/workflows/sc-language-server.yml @@ -0,0 +1,31 @@ +name: sc_language_server + +on: + push: + branches: [ main, develop ] + paths: + - 'sc_language_server/**' + pull_request: + branches: [ main, develop ] + paths: + - 'sc_language_server/**' + +jobs: + test: + runs-on: ubuntu-latest + defaults: + run: + working-directory: sc_language_server + + steps: + - uses: actions/checkout@v3 + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.9' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.dev.txt + - name: Run pytest + run: pytest diff --git a/LSP.sc b/LSP.sc index 6c18720..a47f807 100644 --- a/LSP.sc +++ b/LSP.sc @@ -298,12 +298,16 @@ LSPConnection { prHandleResponse { |id, result| - var response = ( - id: id, - result: result ?? { NilResponse() } - ); - - this.prSendMessage(response); + // Don't sent empty messages as this produces response + // validation errors in some LSP clients (neovim). + if (id.notNil or: { result.notNil } ) { + var response = ( + id: id, + result: result ?? { NilResponse() } + ); + + this.prSendMessage(response); + } } prHandleRequest { @@ -347,7 +351,8 @@ LSPConnection { prSendMessage { |dict| - var maxSize = 6000; + // Reccomended max UDP packet size (probably doesn't matter so much if its localhost) + var maxSize = 508; var offset = 0; var packetSize; var message = this.prEncodeMessage(dict); @@ -359,7 +364,7 @@ LSPConnection { socket.sendRaw(message); } { while { offset < messageSize } { - packetSize = min(messageSize, maxSize); + packetSize = min(messageSize - offset, maxSize); socket.sendRaw(message[offset..(offset + packetSize - 1)]); offset = offset + packetSize; } diff --git a/LSPDatabase.sc b/LSPDatabase.sc index a49b910..a74f036 100644 --- a/LSPDatabase.sc +++ b/LSPDatabase.sc @@ -265,7 +265,7 @@ LSPDatabase { detail: LSPDatabase.methodArgString(method), description: method.ownerClass.name.asString ), - kind: 1, // CompletionItemKind.Method + kind: 2, // CompletionItemKind.Method // deprecated: false, // mark this as deprecated - no way to use this? // detail: // @TODO: additional detail // documentation: // @TODO: method documentation diff --git a/README.md b/README.md new file mode 100644 index 0000000..ce9989e --- /dev/null +++ b/README.md @@ -0,0 +1,14 @@ +# SuperCollider LanguageServer.Quark + +A SuperCollider Language Server following the [LSP specification](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/). + +## Setup + +### vscode + +For use with vscode please see the [vscode extension](https://github.com/scztt/vscode-supercollider). + +### neovim / stdio + +For use with neovim (or another editor that communicates with language servers over stdio), please see +[sc_language_server](sc_language_server/README.md). diff --git a/sc_language_server/.gitignore b/sc_language_server/.gitignore new file mode 100644 index 0000000..68bc17f --- /dev/null +++ b/sc_language_server/.gitignore @@ -0,0 +1,160 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ diff --git a/sc_language_server/README.md b/sc_language_server/README.md new file mode 100644 index 0000000..afd206e --- /dev/null +++ b/sc_language_server/README.md @@ -0,0 +1,114 @@ +# sc-language-server + +This python script is a stdio wrapper for the SuperCollider Language Server. Since the LSP Quark currently communicates +over UDP, this program exists to support LSP clients that support stdio, such as neovim. + +See this github [issue](https://github.com/scztt/LanguageServer.quark/issues/9) for background info. + +## Development + +Create a new venv and install dependencies + + python -m venv .venv + source .venv/bin/activate + python -m pip install -r requirements.dev.txt + +## User Installation + +Download the Quark from within SuperCollider: + + Quarks.install("https://github.com/scztt/LanguageServer.quark"); + thisProcess.recompile; + +After downloading/installing the LanguageServer Quark, locate the directory. + +e.g. on MacOs this might be: + + ~/Library/Application\ Support/SuperCollider/downloaded-quarks/LanguageServer + +Navigate to the sc_language_server directory within that: + + cd sc_language_server + + +### Global installation + +Install the python program to give your system the `sc-language-server` command. This allows you to simply specify +the `sc-language-server` command itself (plus any arguments) in your editor's LSP configuration rather than a full path to this directory. + +Two options for this are: + +#### Pip Install + +Run: + + python -m pip install . + +This might not work with an externally managed installation (e.g. managed by homebrew). If that is the case, please try installing with [pipx](#using-pipx). + +#### Using pipx + +Follow the instructions to install [pipx](https://github.com/pypa/pipx), and then run: + + `pipx install .` + +### Post installation + +Once installed, the command will be available globally, but you will need to set this up to be executed by your editor. + +As an example see the setup for [Neovim](#neovim-lsp-configuration) + +## Command-line Arguments + +The script accepts the following command-line arguments: + +- `--sclang-path`: Path to the SuperCollider language executable (sclang). + - Default: default value currently only provided for MacOS. + +- `--config-path`: Path to the configuration file. + - Default: default value currently only provided for MacOS. + - Depending on how many Quarks your regular sclang config contains, it may be beneficial to point sc-language-server + to a minimal sclang config which only loads LanguageServer (and its dependencies). + +- `--send-port`: Port number for sending data. + - Optional + - If not set (along with an unset --receive-port), a free port will be found. + +- `--receive-port`: Port number for receiving data. + - Optional + - If not set (along with an unset --send-port), a free port will be found. + +- `--ide-name`: Name of the IDE. + - NOTE: currently this must be set to 'vscode' (the default) + +- `-v, --verbose`: Enable verbose output. + +- `-l, --log-file`: Specify a log file to write output. + - Optional + +## Neovim LSP Configuration + +An example neovim lsp configuration: + +```lua +local configs = require('lspconfig.configs') + +configs.supercollider = { + default_config = { + cmd = { + "sc-language-server", + "--log-file", + "/tmp/sc_lsp_output.log", + "--verbose", + "--", -- indicates the args that follow are to be passed to sclang + "-u", "57300", -- e.g. custom UDP listening port for sclang + "-l", "/Users/me/sclang_conf_lsp.yaml", -- e.g. full path to config file + }, + filetypes = {'supercollider'}, + root_dir = function(fname) + return "/" + end, + settings = {}, + }, +} +``` diff --git a/sc_language_server/pyproject.toml b/sc_language_server/pyproject.toml new file mode 100644 index 0000000..5d1e372 --- /dev/null +++ b/sc_language_server/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "sc-language-server" +version = "0.1.0" +description = "A sdtio wrapper for the LanguageServer.quark LSP server for SuperCollider" +requires-python = ">= 3.9" + +[project.scripts] +sc-language-server = "sc_language_server:main" + +[tool.isort] +profile = 'black' + +[tool.black] +line-length = 110 diff --git a/sc_language_server/requirements.dev.txt b/sc_language_server/requirements.dev.txt new file mode 100644 index 0000000..cc9cd14 --- /dev/null +++ b/sc_language_server/requirements.dev.txt @@ -0,0 +1,6 @@ +black==23.9.1 +mypy==1.5.1 +flake8==6.1.0 +isort==5.12.0 +pytest==8.3.2 +pytest-asyncio==0.24.0 diff --git a/sc_language_server/sc_language_server.py b/sc_language_server/sc_language_server.py new file mode 100644 index 0000000..83395c2 --- /dev/null +++ b/sc_language_server/sc_language_server.py @@ -0,0 +1,443 @@ +""" +A sdtio wrapper for the LanguageServer.quark LSP server for SuperCollider. + +See: + https://github.com/scztt/LanguageServer.quark + +It allows the language server to be used via stdin/stdout streams for +LSP clients that don't support UDP transport. +""" + +from __future__ import annotations + +import argparse +import asyncio +import fcntl +import json +import logging +import os +import re +import selectors +import signal +import socket +import sys +import time +from asyncio.streams import StreamReader +from contextlib import closing +from threading import Event, Thread + +LOCALHOST = "127.0.0.1" +MAX_UDP_PACKET_SIZE = 508 + + +def _get_free_ports() -> tuple[int, int]: + """ + Determines two free localhost ports. + """ + with ( + closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s, + closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as c, + ): + s.bind(("", 0)) + c.bind(("", 0)) + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + c.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + return s.getsockname()[1], c.getsockname()[1] + + +class UDPSender: + def __init__(self, transport, logger, remote_addr, remote_port): + self.transport = transport + self.logger = logger + self.remote_addr = remote_addr + self.remote_port = remote_port + self._closed = False + + def send(self, msg): + if self._closed: + self.logger.warning("Attempted to send data on a closed UDPSender") + return + + packet_size = len(msg) + + for offset in range(0, packet_size, MAX_UDP_PACKET_SIZE): + chunk = msg[offset : offset + MAX_UDP_PACKET_SIZE] + chunk = chunk.encode("utf-8") if isinstance(chunk, str) else chunk + try: + self._send_chunk(chunk) + except Exception as e: + self.logger.error(f"Error sending chunk: {e}") + + def _send_chunk(self, chunk): + self.transport.sendto(chunk, (self.remote_addr, self.remote_port)) + + def close(self): + if not self._closed: + self._closed = True + self.transport.close() + self.logger.info("UDPSender closed") + + +class StdinThread(Thread): + """ + A small thread that reads stdin and calls a function when data is + received. + """ + + def __init__(self, on_stdin_received): + super().__init__() + self._stop_event = Event() + self._on_received = on_stdin_received + self._selector = selectors.DefaultSelector() + + def run(self): + self._selector.register(sys.stdin, selectors.EVENT_READ, self.__read) + + while not self._stop_event.is_set(): + try: + # Timeout ensures we go back around the while loop + # and check the stop event, but at a slow enough + # rate we don't eat CPU. + events = self._selector.select(5) + for key, mask in events: + callback = key.data + callback(key.fileobj, mask) + except KeyboardInterrupt: + break + except Exception: + break + + def __read(self, fileobj, mask): + if not mask & selectors.EVENT_READ: + return + data = fileobj.read() + if data: + self._on_received(data) + else: + # Sleep briefly to avoid pegging the CPU + time.sleep(0.1) + + def close(self): + """ + Stops the threads main run loop. + """ + self._stop_event.set() + + +class UDPReceiveToStdoutProtocol(asyncio.DatagramProtocol): + """ + A UDP protocol handler that buffers, reconstructs, and writes LSP messages to stdout + """ + + def __init__(self, logger: logging.Logger) -> None: + super().__init__() + self.__logger = logger + self.__buffer = bytearray() + self.__content_length = None + + def connection_made(self, transport): + self.__logger.info("UDP connection made") + + def datagram_received(self, data, addr): + try: + self.__buffer.extend(data) + self.__process_buffer() + except Exception as e: + self.__logger.error(f"Error processing datagram: {e}") + + def error_received(self, exc): + self.__logger.info("Error %s", exc) + sys.stderr.write(f"UDP error: {exc}\n") + + def __process_buffer(self): + while True: + if self.__content_length is None: + if b"\r\n\r\n" not in self.__buffer: + # Not enough data to read the header + return + + # Extract the Content-Length + header, self.__buffer = self.__buffer.split(b"\r\n\r\n", 1) + header = header.decode("ascii") + match = re.search(r"Content-Length: (\d+)", header) + if not match: + self.__logger.error("Invalid header received") + return + self.__content_length = int(match.group(1)) + + if len(self.__buffer) < self.__content_length: + # Not enough data for the full message, wait for another packet to arrive + return + + # Extract the full message body from the buffer + message = self.__buffer[: self.__content_length].decode("utf-8") + self.__buffer = self.__buffer[self.__content_length :] + self.__content_length = None + + # Process the message + try: + full_message = f"Content-Length: {len(message)}\r\n\r\n{message}" + self.__write_message(full_message) + except json.JSONDecodeError: + self.__logger.error(f"Invalid JSON received: {message}") + + if not self.__buffer: + # No more data to process + return + + def __write_message(self, message): + sys.stdout.write(message) + sys.stdout.flush() + + +class SCRunner: + """ + A class to manage a sclang suprocess, and connect stdin/out to it + via UDP. + """ + + ##pylint: disable=too-many-instance-attributes + + defaults = { + "darwin": "/Applications/SuperCollider.app/Contents/MacOS/sclang", + "linux": "sclang", + } + + sclang_path = defaults.get(sys.platform, "") + ide_name = "vscode" + server_log_level = "warning" + receive_port: int + send_port: int + ready_message = "***LSP READY***" + + __logger: logging.Logger + __udp_receiver: asyncio.DatagramTransport | None = None + __udp_sender: UDPSender | None = None + __subprocess = None + __stdin_thread = None + + def __init__( + self, + logger: logging.Logger, + server_log_level: str, + send_port: int, + receive_port: int, + sclang_path: str | None, + ide_name: str | None, + ): + """ + Constructs a new LSPRunner, defaults will be configured for the + host platform, and can be changed prior to calling start. + """ + self.__logger = logger + self.server_log_level = server_log_level + self.send_port = send_port + self.receive_port = receive_port + + # Set the optional attributes if provided + self.sclang_path = sclang_path or self.sclang_path + self.ide_name = ide_name or self.ide_name + + async def start(self, extra_args: list[str] = []) -> int: + """ + Starts a sclang subprocess, enabling the LSP server. + Stdin/out are connected to the server via UDP. + """ + if self.__subprocess: + self.__stop_subprocess() + + my_env = os.environ.copy() + + additional_vars = { + "SCLANG_LSP_ENABLE": "1", + "SCLANG_LSP_LOGLEVEL": self.server_log_level, + "SCLANG_LSP_CLIENTPORT": str(self.send_port), + "SCLANG_LSP_SERVERPORT": str(self.receive_port), + } + + self.__logger.info("SC env vars: %s", repr(additional_vars)) + + command = [self.sclang_path, "-i", self.ide_name, *extra_args] + + self.__logger.info(f"RUNNER: Launching SC with cmd: '{command}'") + + try: + self.__subprocess = await asyncio.create_subprocess_exec( + *command, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + # stdin must be set to PIPE so stdin flows to the main program + stdin=asyncio.subprocess.PIPE, + env={**my_env, **additional_vars}, + ) + except FileNotFoundError as e: + e.strerror = ("The specified sclang path does not exist") + raise + + # receive stdout and stderr from sclang + if self.__subprocess.stdout and self.__subprocess.stderr: + self.__logger.info("stdout, stderr, gather") + await asyncio.gather( + self.__receive_output(self.__subprocess.stdout, "SC:STDOUT"), + self.__receive_output(self.__subprocess.stderr, "SC:STDERR"), + ) + + # Await subprocess termination + sc_exit_code = await self.__subprocess.wait() + + self.__logger.info("calling stop from sc runner start") + self.stop() + return sc_exit_code + + def stop(self): + self.__logger.info("Stopping SCRunner") + """ + Stops the running sclang process and UDP relay. + """ + + if self.__udp_sender: + self.__udp_sender.close() + + if self.__udp_receiver: + self.__udp_receiver.close() + + if self.__stdin_thread: + self.__stdin_thread.join(timeout=5) + self.__stdin_thread.close() + + self.__stop_subprocess() + + async def __start_communication_to_sc(self): + transport, _ = await asyncio.get_running_loop().create_datagram_endpoint( + asyncio.DatagramProtocol, + remote_addr=(LOCALHOST, self.send_port), + ) + self.__udp_sender = UDPSender(transport, self.__logger, LOCALHOST, self.send_port) + + def on_stdin_received(text): + self.__udp_sender.send(text) + + self.__stdin_thread = StdinThread(on_stdin_received) + self.__stdin_thread.start() + self.__logger.info("UDP Sender running on %s:%d", LOCALHOST, self.send_port) + + async def __start_communication_from_sc(self): + """ + Starts a UDP server to listen to messages from SC. Passes these + messages to stdout. + """ + transport, _ = await asyncio.get_running_loop().create_datagram_endpoint( + lambda: UDPReceiveToStdoutProtocol(self.__logger.getChild("UDP receive")), + local_addr=(LOCALHOST, self.receive_port), + ) + self.__udp_receiver = transport + self.__logger.info("UDP receiver running on %s:%d", LOCALHOST, self.receive_port) + + def __stop_subprocess(self): + """ + Terminates the sclang subprocess if running. + """ + if self.__subprocess and self.__subprocess.returncode is None: + self.__subprocess.terminate() + + async def __receive_output(self, stream: StreamReader, prefix: str): + """ + Handles stdout/stderr from the sclang subprocess + """ + async for line in stream: + output = line.decode().rstrip() + + if output: + self.__logger.info(f"{prefix}: {output}") + + if self.ready_message in output: + self.__logger.info("ready message received") + asyncio.create_task(self.__start_communication_from_sc()) + asyncio.create_task(self.__start_communication_to_sc()) + + +def create_arg_parser(sc_runner: type[SCRunner]): + """ + Creates an argument parser for the CLI representing the supplied + runner. + """ + parser = argparse.ArgumentParser( + description="Runs the SuperCollider LSP server and provides stdin/stdout access to it", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=''' +example with extra sclang args (custom langPort and libraryConfig): + %(prog)s --sclang-path /path/to/sclang -v --log-file /path/to/logfile -- -u 57300 -l custom_sclang_conf.yaml +''' + ) + + print_default = '(default: %(default)s)' + + parser.add_argument( + "--sclang-path", + required=not sc_runner.sclang_path, + default=sc_runner.sclang_path, + help=print_default, + ) + parser.add_argument("--send-port", type=int) + parser.add_argument("--receive-port", type=int) + parser.add_argument("--ide-name", default=sc_runner.ide_name, help=print_default) + parser.add_argument("-v", "--verbose", action="store_true") + parser.add_argument("-l", "--log-file") + parser.add_argument("extra_sclang_args", nargs="*", help="cli arguments for sclang (see example below)") + + return parser + + +def main(): + """ + CLI entry point + """ + logger = logging.getLogger("lsp_runner") + + parser = create_arg_parser(SCRunner) + args = parser.parse_args() + + if args.log_file: + formatter = logging.Formatter("%(asctime)s [%(name)s] %(levelname)s: %(message)s") + handler = logging.FileHandler(args.log_file, mode="w") + handler.setFormatter(formatter) + logger.addHandler(handler) + logger.setLevel(logging.DEBUG if args.verbose else logging.WARNING) + else: + logger.setLevel(logging.ERROR) + + if (args.send_port is None) != (args.receive_port is None): + raise ValueError("Both server and client port must specified (or neither)") + + if args.send_port and args.receive_port: + receive_port, send_port = args.send_port, args.receive_port + else: + receive_port, send_port = _get_free_ports() + logger.info("Found free ports (receive: %s), (send: %s)", receive_port, send_port) + + sc_runner = SCRunner( + logger=logger, + server_log_level="debug" if args.verbose else "warning", + send_port=send_port, + receive_port=receive_port, + sclang_path=args.sclang_path, + ide_name=args.ide_name, + ) + + def signal_handler(signum, _): + logger.info("Received termination signal %d", signum) + sc_runner.stop() + + # Register signal handlers for termination signals + signal.signal(signal.SIGINT, signal_handler) + signal.signal(signal.SIGTERM, signal_handler) + + # Add O_NONBLOCK to the stdin descriptor flags + flags = fcntl.fcntl(0, fcntl.F_GETFL) + fcntl.fcntl(0, fcntl.F_SETFL, flags | os.O_NONBLOCK) + + sys.exit(asyncio.run(sc_runner.start(args.extra_sclang_args))) + + +if __name__ == "__main__": + main() diff --git a/sc_language_server/setup.cfg b/sc_language_server/setup.cfg new file mode 100644 index 0000000..cb5c4fd --- /dev/null +++ b/sc_language_server/setup.cfg @@ -0,0 +1,3 @@ +[flake8] +max-line-length = 110 +extend-ignore = E203 diff --git a/sc_language_server/tests/conftest.py b/sc_language_server/tests/conftest.py new file mode 100644 index 0000000..2dd3d51 --- /dev/null +++ b/sc_language_server/tests/conftest.py @@ -0,0 +1,6 @@ +import os +import sys + +# Modifies Python's import system to include the project's root directory in sys.path, +# allowing tests to import package modules as if running from the root directory. +sys.path.insert(0, os.path.abspath(os.path.dirname(os.path.dirname(__file__)))) diff --git a/sc_language_server/tests/test_sc_runner.py b/sc_language_server/tests/test_sc_runner.py new file mode 100644 index 0000000..17ed5d6 --- /dev/null +++ b/sc_language_server/tests/test_sc_runner.py @@ -0,0 +1,133 @@ +import os +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from sc_language_server import SCRunner + +# Mock with the current test file since SCRunner will complain if the sclang_path does not exist! +current_file_path = os.path.abspath(__file__) + + +@pytest.fixture +def mock_logger(): + return MagicMock() + + +@pytest.fixture +def sc_runner(mock_logger): + return SCRunner( + logger=mock_logger, + server_log_level="warning", + send_port=57120, + receive_port=57121, + sclang_path=current_file_path, + ide_name="vscode", + ) + + +@pytest.mark.asyncio +async def test_SCRunner_initialization(sc_runner): + assert sc_runner.server_log_level == "warning" + assert sc_runner.send_port == 57120 + assert sc_runner.receive_port == 57121 + assert sc_runner.sclang_path == current_file_path + assert sc_runner.ide_name == "vscode" + + +@pytest.mark.asyncio +async def test_SCRunner_start(sc_runner): + with patch("asyncio.create_subprocess_exec", new_callable=AsyncMock) as mock_subprocess: + mock_subprocess.return_value = AsyncMock() + mock_subprocess.return_value.stdout = AsyncMock() + mock_subprocess.return_value.stderr = AsyncMock() + mock_subprocess.return_value.wait = AsyncMock(return_value=0) + + with patch.object( + sc_runner, "_SCRunner__receive_output", new_callable=AsyncMock + ) as mock_receive_output: + exit_code = await sc_runner.start() + + assert exit_code == 0 + mock_subprocess.assert_called_once() + assert mock_receive_output.call_count == 2 + + +@pytest.mark.asyncio +async def test_SCRunner_stop(sc_runner): + sc_runner._SCRunner__udp_sender = MagicMock() + sc_runner._SCRunner__udp_receiver = MagicMock() + sc_runner._SCRunner__stdin_thread = MagicMock() + + mock_subprocess = MagicMock() + mock_subprocess.returncode = None + + sc_runner._SCRunner__subprocess = mock_subprocess + + sc_runner.stop() + + sc_runner._SCRunner__udp_sender.close.assert_called_once() + sc_runner._SCRunner__udp_receiver.close.assert_called_once() + sc_runner._SCRunner__stdin_thread.join.assert_called_once() + mock_subprocess.terminate.assert_called_once() + + +@pytest.mark.asyncio +async def test_SCRunner_start_communication_to_sc(sc_runner): + with patch("asyncio.get_running_loop") as mock_loop: + mock_transport = MagicMock() + mock_loop.return_value.create_datagram_endpoint = AsyncMock(return_value=(mock_transport, None)) + + with patch("sc_language_server.UDPSender") as mock_udp_sender, patch( + "sc_language_server.StdinThread" + ) as mock_stdin_thread: + await sc_runner._SCRunner__start_communication_to_sc() + + mock_loop.return_value.create_datagram_endpoint.assert_called_once() + mock_udp_sender.assert_called_once() + mock_stdin_thread.assert_called_once() + mock_stdin_thread.return_value.start.assert_called_once() + + +@pytest.mark.asyncio +async def test_SCRunner_start_communication_from_sc(sc_runner): + with patch("asyncio.get_running_loop") as mock_loop: + mock_transport = MagicMock() + mock_loop.return_value.create_datagram_endpoint = AsyncMock(return_value=(mock_transport, None)) + + await sc_runner._SCRunner__start_communication_from_sc() + + mock_loop.return_value.create_datagram_endpoint.assert_called_once() + assert sc_runner._SCRunner__udp_receiver == mock_transport + + +@pytest.mark.asyncio +async def test_SCRunner_receive_output_ready(sc_runner): + mock_stream = AsyncMock() + mock_stream.__aiter__.return_value = [b"test output\n", b"***LSP READY***\n"] + + with patch.object( + sc_runner, "_SCRunner__start_communication_from_sc", new_callable=AsyncMock + ) as mock_start_from_sc, patch.object( + sc_runner, "_SCRunner__start_communication_to_sc", new_callable=AsyncMock + ) as mock_start_to_sc: + await sc_runner._SCRunner__receive_output(mock_stream, "PREFIX_") + + mock_start_from_sc.assert_called_once() + mock_start_to_sc.assert_called_once() + + +@pytest.mark.asyncio +async def test_SCRunner_receive_output_not_ready(sc_runner): + mock_stream = AsyncMock() + mock_stream.__aiter__.return_value = [b"test output\n", b"***LOGGED INFO***\n"] + + with patch.object( + sc_runner, "_SCRunner__start_communication_from_sc", new_callable=AsyncMock + ) as mock_start_from_sc, patch.object( + sc_runner, "_SCRunner__start_communication_to_sc", new_callable=AsyncMock + ) as mock_start_to_sc: + await sc_runner._SCRunner__receive_output(mock_stream, "PREFIX_") + + mock_start_from_sc.assert_not_called() + mock_start_to_sc.assert_not_called() diff --git a/sc_language_server/tests/test_udp_receiver.py b/sc_language_server/tests/test_udp_receiver.py new file mode 100644 index 0000000..f5b9543 --- /dev/null +++ b/sc_language_server/tests/test_udp_receiver.py @@ -0,0 +1,105 @@ +import json +import logging +from unittest.mock import Mock, patch + +import pytest + +from sc_language_server import MAX_UDP_PACKET_SIZE, UDPReceiveToStdoutProtocol + + +@pytest.fixture +def logger(): + return Mock(spec=logging.Logger) + + +@pytest.fixture +def protocol(logger): + return UDPReceiveToStdoutProtocol(logger) + + +def test_connection_made(protocol, logger): + transport = Mock() + protocol.connection_made(transport) + logger.info.assert_called_once_with("UDP connection made") + + +def test_error_received(protocol, logger): + exc = Exception("Test error") + with patch("sys.stderr.write") as mock_stderr_write: + protocol.error_received(exc) + logger.info.assert_called_once_with("Error %s", exc) + mock_stderr_write.assert_called_once_with("UDP error: Test error\n") + + +@pytest.mark.parametrize( + "content_length,content", + [ + (59, {"command": "initialize", "arguments": {"cliVersion": 1.0}}), + ( + 225, + { + "method": "textDocument/completion", + "params": { + "textDocument": {"uri": "file:///path/to/file.py"}, + "position": {"line": 10, "character": 15}, + "context": {"triggerKind": 1, "triggerCharacter": "."}, + }, + "jsonrpc": 2.0, + "id": 1, + }, + ), + ( + 510, + { + "method": "workspace/symbol", + "params": { + "query": "myFunction", + "symbols": [ + { + "name": "myFunction", + "kind": 12, + "location": { + "uri": "file:///path/to/file1.py", + "range": { + "start": {"line": 5, "character": 4}, + "end": {"line": 5, "character": 14}, + }, + }, + "containerName": "MyClass", + }, + { + "name": "myFunction", + "kind": 12, + "location": { + "uri": "file:///path/to/file2.py", + "range": { + "start": {"line": 15, "character": 4}, + "end": {"line": 15, "character": 14}, + }, + }, + "containerName": "AnotherClass", + }, + ], + }, + "jsonrpc": "2.0", + "id": 2, + }, + ), + ], +) +def test_udp_buffer_and_stdout_messages(protocol, content_length, content): + """Tests that the messages received over UDP are buffered correctly and sent to stdout in their reconstructed form""" + address = ("127.0.0.1", 12345) + json_content = json.dumps(content) + full_message = f"Content-Length: {content_length}\r\n\r\n{json_content}" + byte_string = full_message.encode("utf-8") + + with patch("sys.stdout.write") as mock_stdout_write, patch("sys.stdout.flush") as mock_stdout_flush: + # Send the message over UDP in chunks + for i in range(0, len(byte_string), MAX_UDP_PACKET_SIZE): + chunk = byte_string[i : i + MAX_UDP_PACKET_SIZE] + protocol.datagram_received(chunk, address) + + # Ensure only the full message was sent over stdout + mock_stdout_write.assert_called_once_with(full_message) + mock_stdout_flush.assert_called_once() diff --git a/sc_language_server/tests/test_udp_send.py b/sc_language_server/tests/test_udp_send.py new file mode 100644 index 0000000..60b7a42 --- /dev/null +++ b/sc_language_server/tests/test_udp_send.py @@ -0,0 +1,68 @@ +from unittest.mock import Mock, call + +import pytest + +from sc_language_server import MAX_UDP_PACKET_SIZE, UDPSender + +LOCALHOST = "127.0.0.1" +PORT = 8888 + + +@pytest.fixture +def mock_transport(): + return Mock() + + +@pytest.fixture +def mock_logger(): + return Mock() + + +@pytest.fixture +def udp_sender(mock_transport, mock_logger): + return UDPSender(mock_transport, mock_logger, LOCALHOST, PORT) + + +def test_init(udp_sender): + assert udp_sender.remote_addr == LOCALHOST + assert udp_sender.remote_port == PORT + assert not udp_sender._closed + + +def test_send_single_chunk(udp_sender, mock_transport): + msg = "Hello, World!" + udp_sender.send(msg) + mock_transport.sendto.assert_called_once_with(b"Hello, World!", (LOCALHOST, PORT)) + + +def test_send_multiple_chunks(udp_sender, mock_transport): + msg = "A" * (MAX_UDP_PACKET_SIZE + 10) + udp_sender.send(msg) + expected_calls = [call(b"A" * MAX_UDP_PACKET_SIZE, (LOCALHOST, PORT)), call(b"A" * 10, (LOCALHOST, PORT))] + mock_transport.sendto.assert_has_calls(expected_calls) + + +def test_send_closed(udp_sender, mock_logger): + udp_sender.close() + udp_sender.send("Test message") + mock_logger.warning.assert_called_once_with("Attempted to send data on a closed UDPSender") + + +def test_send_exception(udp_sender, mock_transport, mock_logger): + mock_transport.sendto.side_effect = Exception("Test exception") + udp_sender.send("Test message") + mock_logger.error.assert_called_once_with("Error sending chunk: Test exception") + + +def test_close(udp_sender, mock_transport, mock_logger): + udp_sender.close() + assert udp_sender._closed + mock_transport.close.assert_called_once() + mock_logger.info.assert_called_once_with("UDPSender closed") + + +def test_close_idempotent(udp_sender, mock_transport, mock_logger): + udp_sender.close() + udp_sender.close() + mock_transport.close.assert_called_once() + mock_logger.info.assert_called_once_with("UDPSender closed")