From 1b8b45fc0b3a3737ba814f040ad2215717c20c1b Mon Sep 17 00:00:00 2001 From: Danny Keig <39764493+dannyZyg@users.noreply.github.com> Date: Thu, 5 Sep 2024 08:50:43 +1000 Subject: [PATCH 1/6] Add python LSP wrapper script for stdio Provides a python script to support lsp clients which can't use UDP but can use stdio (e.g. neovim) - as described in this issue https://github.com/scztt/LanguageServer.quark/issues/9 The script launches sclang in the background and while communicating with the language server quark over UDP, relays this IO over stdio to the client. Special thanks to Tom Cowland (themissingcow) for getting this working properly! Co-authored-by: Tom Cowland --- .github/workflows/sc-language-server.yml | 31 ++ LSP.sc | 21 +- LSPDatabase.sc | 2 +- README.md | 14 + sc_language_server/.gitignore | 160 +++++++ sc_language_server/README.md | 89 ++++ sc_language_server/pyproject.toml | 14 + sc_language_server/requirements.dev.txt | 6 + sc_language_server/sc_language_server.py | 453 ++++++++++++++++++ sc_language_server/setup.cfg | 3 + sc_language_server/tests/conftest.py | 6 + sc_language_server/tests/test_sc_runner.py | 135 ++++++ sc_language_server/tests/test_udp_receiver.py | 105 ++++ sc_language_server/tests/test_udp_send.py | 68 +++ 14 files changed, 1098 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/sc-language-server.yml create mode 100644 README.md create mode 100644 sc_language_server/.gitignore create mode 100644 sc_language_server/README.md create mode 100644 sc_language_server/pyproject.toml create mode 100644 sc_language_server/requirements.dev.txt create mode 100644 sc_language_server/sc_language_server.py create mode 100644 sc_language_server/setup.cfg create mode 100644 sc_language_server/tests/conftest.py create mode 100644 sc_language_server/tests/test_sc_runner.py create mode 100644 sc_language_server/tests/test_udp_receiver.py create mode 100644 sc_language_server/tests/test_udp_send.py 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 7267380..894ca6c 100644 --- a/LSP.sc +++ b/LSP.sc @@ -277,12 +277,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 { @@ -326,7 +330,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); @@ -338,7 +343,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 e59843d..7d339a5 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..f07a0ad --- /dev/null +++ b/sc_language_server/README.md @@ -0,0 +1,89 @@ +# 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 + +Pip install to give your system the `sc-language-server` command. + + python -m pip install . + +Once installed, the command will be available, but you will need to set this up to be executed by your editor. + + +## 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", + } + 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..fd85e30 --- /dev/null +++ b/sc_language_server/sc_language_server.py @@ -0,0 +1,453 @@ +""" +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() + """ self.__logger.info("RECEIVED MESSAGE FROM SERVER: %s", message) """ + + +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": { + "sclang": "/Applications/SuperCollider.app/Contents/MacOS/sclang", + "config": "~/Library/Application Support/SuperCollider/sclang_conf.yaml", + } + } + + sys_defaults = defaults.get(sys.platform, {}) + sclang_path = sys_defaults.get("sclang", "") + config_path = sys_defaults.get("config", "") + + 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, + config_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.config_path = config_path or self.config_path + self.ide_name = ide_name or self.ide_name + + async def start(self) -> 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() + + if not os.path.exists(self.sclang_path): + raise RuntimeError(f"The specified sclang path does not exist: {self.sclang_path}") + + 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] + if self.config_path: + config = os.path.expanduser(os.path.expandvars(self.config_path)) + if not os.path.exists(config): + raise RuntimeError(f"The specified config file does not exist: '{config}'") + command.extend(["-l", config]) + + self.__logger.info(f"RUNNER: Launching SC with cmd: {command}") + + 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}, + ) + + # 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( + prog="sclsp_runner", + description="Runs the SuperCollider LSP server and provides stdin/stdout access to it", + ) + + parser.add_argument( + "--sclang-path", + required=not sc_runner.sclang_path, + default=sc_runner.sclang_path, + ) + parser.add_argument( + "--config-path", + required=not sc_runner.config_path, + default=sc_runner.config_path, + ) + parser.add_argument("--send-port", type=int) + parser.add_argument("--receive-port", type=int) + parser.add_argument("--ide-name", default=sc_runner.ide_name) + parser.add_argument("-v", "--verbose", action="store_true") + parser.add_argument("-l", "--log-file") + + 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, + config_path=args.config_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())) + + +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..147bf74 --- /dev/null +++ b/sc_language_server/tests/test_sc_runner.py @@ -0,0 +1,135 @@ +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/config_path do 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, + config_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.config_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") From 7daac286279081f75bc407e07da674cd0e4dff4b Mon Sep 17 00:00:00 2001 From: Danny Keig <39764493+dannyZyg@users.noreply.github.com> Date: Mon, 23 Sep 2024 15:08:46 +1000 Subject: [PATCH 2/6] Update installation steps in readme --- sc_language_server/README.md | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/sc_language_server/README.md b/sc_language_server/README.md index f07a0ad..e39e79f 100644 --- a/sc_language_server/README.md +++ b/sc_language_server/README.md @@ -30,12 +30,33 @@ Navigate to the sc_language_server directory within that: cd sc_language_server -Pip install to give your system the `sc-language-server` command. + +### 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 . -Once installed, the command will be available, but you will need to set this up to be executed by your editor. +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 From ad84de8a82f07ce5db1feaf73bc21f47aa32dfe3 Mon Sep 17 00:00:00 2001 From: Danny Keig <39764493+dannyZyg@users.noreply.github.com> Date: Thu, 5 Dec 2024 18:57:24 +1100 Subject: [PATCH 3/6] Add argparse changes - pass additional args to sclang --- sc_language_server/README.md | 6 +- sc_language_server/sc_language_server.py | 72 ++++++++++-------------- 2 files changed, 36 insertions(+), 42 deletions(-) diff --git a/sc_language_server/README.md b/sc_language_server/README.md index e39e79f..afd206e 100644 --- a/sc_language_server/README.md +++ b/sc_language_server/README.md @@ -99,7 +99,11 @@ configs.supercollider = { "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 "/" diff --git a/sc_language_server/sc_language_server.py b/sc_language_server/sc_language_server.py index fd85e30..83395c2 100644 --- a/sc_language_server/sc_language_server.py +++ b/sc_language_server/sc_language_server.py @@ -188,7 +188,6 @@ def __process_buffer(self): def __write_message(self, message): sys.stdout.write(message) sys.stdout.flush() - """ self.__logger.info("RECEIVED MESSAGE FROM SERVER: %s", message) """ class SCRunner: @@ -200,16 +199,11 @@ class SCRunner: ##pylint: disable=too-many-instance-attributes defaults = { - "darwin": { - "sclang": "/Applications/SuperCollider.app/Contents/MacOS/sclang", - "config": "~/Library/Application Support/SuperCollider/sclang_conf.yaml", - } + "darwin": "/Applications/SuperCollider.app/Contents/MacOS/sclang", + "linux": "sclang", } - sys_defaults = defaults.get(sys.platform, {}) - sclang_path = sys_defaults.get("sclang", "") - config_path = sys_defaults.get("config", "") - + sclang_path = defaults.get(sys.platform, "") ide_name = "vscode" server_log_level = "warning" receive_port: int @@ -229,7 +223,6 @@ def __init__( send_port: int, receive_port: int, sclang_path: str | None, - config_path: str | None, ide_name: str | None, ): """ @@ -243,10 +236,9 @@ def __init__( # Set the optional attributes if provided self.sclang_path = sclang_path or self.sclang_path - self.config_path = config_path or self.config_path self.ide_name = ide_name or self.ide_name - async def start(self) -> int: + 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. @@ -256,9 +248,6 @@ async def start(self) -> int: my_env = os.environ.copy() - if not os.path.exists(self.sclang_path): - raise RuntimeError(f"The specified sclang path does not exist: {self.sclang_path}") - additional_vars = { "SCLANG_LSP_ENABLE": "1", "SCLANG_LSP_LOGLEVEL": self.server_log_level, @@ -268,23 +257,22 @@ async def start(self) -> int: self.__logger.info("SC env vars: %s", repr(additional_vars)) - command = [self.sclang_path, "-i", self.ide_name] - if self.config_path: - config = os.path.expanduser(os.path.expandvars(self.config_path)) - if not os.path.exists(config): - raise RuntimeError(f"The specified config file does not exist: '{config}'") - command.extend(["-l", config]) - - self.__logger.info(f"RUNNER: Launching SC with cmd: {command}") - - 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}, - ) + 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: @@ -374,25 +362,28 @@ def create_arg_parser(sc_runner: type[SCRunner]): runner. """ parser = argparse.ArgumentParser( - prog="sclsp_runner", 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, - ) - parser.add_argument( - "--config-path", - required=not sc_runner.config_path, - default=sc_runner.config_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) + 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 @@ -430,7 +421,6 @@ def main(): send_port=send_port, receive_port=receive_port, sclang_path=args.sclang_path, - config_path=args.config_path, ide_name=args.ide_name, ) @@ -446,7 +436,7 @@ def signal_handler(signum, _): flags = fcntl.fcntl(0, fcntl.F_GETFL) fcntl.fcntl(0, fcntl.F_SETFL, flags | os.O_NONBLOCK) - sys.exit(asyncio.run(sc_runner.start())) + sys.exit(asyncio.run(sc_runner.start(args.extra_sclang_args))) if __name__ == "__main__": From 415e7df5ff27eddd03917f68959bf7dae74ce9b5 Mon Sep 17 00:00:00 2001 From: Danny Keig <39764493+dannyZyg@users.noreply.github.com> Date: Thu, 5 Dec 2024 20:42:04 +1100 Subject: [PATCH 4/6] Fix tests after changing args --- sc_language_server/tests/test_sc_runner.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/sc_language_server/tests/test_sc_runner.py b/sc_language_server/tests/test_sc_runner.py index 147bf74..17ed5d6 100644 --- a/sc_language_server/tests/test_sc_runner.py +++ b/sc_language_server/tests/test_sc_runner.py @@ -5,7 +5,7 @@ from sc_language_server import SCRunner -# Mock with the current test file since SCRunner will complain if the sclang_path/config_path do not exist! +# Mock with the current test file since SCRunner will complain if the sclang_path does not exist! current_file_path = os.path.abspath(__file__) @@ -22,7 +22,6 @@ def sc_runner(mock_logger): send_port=57120, receive_port=57121, sclang_path=current_file_path, - config_path=current_file_path, ide_name="vscode", ) @@ -33,7 +32,6 @@ async def test_SCRunner_initialization(sc_runner): assert sc_runner.send_port == 57120 assert sc_runner.receive_port == 57121 assert sc_runner.sclang_path == current_file_path - assert sc_runner.config_path == current_file_path assert sc_runner.ide_name == "vscode" From b3976a5ab0ae318f843f289ea5746af558e00502 Mon Sep 17 00:00:00 2001 From: Danny Keig <39764493+dannyZyg@users.noreply.github.com> Date: Mon, 16 Feb 2026 12:28:18 +1100 Subject: [PATCH 5/6] Merge upstream (#6) * User prefix prompt for multi-user sessions * fix for windows - need to test on other OSes * add platform-independent URI handling * fix range * fix backtrace formatting * Fix issues when using sc with LSP installed but NOT connected We need to run regular startup things in case LSP is not enabled * send errors as notifications when id.isNil (#32) * handleMessage: don't reply to notification a Notification is a message without an id, and shouldn't generate a Response. LSP.sc currently calls handleResponse on every message it receives, generating invalid responses, lacking both "id" and "jsonrpc". NOTE: Neovim errors with `"INVALID_SERVER_MESSAGE"` when receiving a Response without id. This PR avoids replying to messages that lack the "id" field. * refactor SystemOverwrites * format * Make improve error reports optional These are experimental, and differ from traditional error reports, so make this optional. * Make improved error reports optional * send errors as notifications when id.isNil --------- Co-authored-by: scott carver * Add name to command arguments in CodeLensProvider * Update kind value for document regions in DocumentSymbolProvider --------- Co-authored-by: scott carver Co-authored-by: chairbender Co-authored-by: Kyle Hipke Co-authored-by: gianlucaelia --- LSP.sc | 31 +++++++++++++++++++++++------ LSPDatabase.sc | 4 ++-- Providers/CodeLensProvider.sc | 6 +++++- Providers/DocumentSymbolProvider.sc | 2 +- Providers/EvaluateProvider.sc | 14 ++++++++++--- Providers/InitializeProvider.sc | 4 ++-- SystemOverwrites/extMain.sc | 5 +++++ extString.sc | 25 +++++++++++++++++++++++ scide_vscode/LSPDocument.sc | 6 +++--- 9 files changed, 79 insertions(+), 18 deletions(-) diff --git a/LSP.sc b/LSP.sc index 7653743..a47f807 100644 --- a/LSP.sc +++ b/LSP.sc @@ -82,6 +82,10 @@ LSPConnection { }) } + *enabled { + ^this.envirSettings[\enabled] + } + start { // @TODO: What do we do before start / after stop? Errors? Log('LanguageServer.quark').info("Starting language server, inPort: % outPort:%", inPort, outPort); @@ -234,12 +238,22 @@ LSPConnection { |error| // @TODO handle error error.reportError; - this.prHandleErrorResponse( - id: id, - code: error.class.identityHash, - message: error.what, - // data: error.getBacktrace // @TODO Render backtrace as JSON? - ); + if (id.isNil) { + this.prHandleNotification( + method: method, + params: ( + error: (code: error.class.identityHash, message:error.what), + methodParams: params + ), + ); + } { + this.prHandleErrorResponse( + id: id, + code: error.class.identityHash, + message: error.what, + // data: error.getBacktrace // @TODO Render backtrace as JSON? + ); + } }); } } @@ -275,6 +289,11 @@ LSPConnection { this.prSendMessage(response); } + + prHandleNotification { + |method, params| + this.prSendMessage((method:method, params:params)) + } prHandleResponse { |id, result| diff --git a/LSPDatabase.sc b/LSPDatabase.sc index 58f622f..a74f036 100644 --- a/LSPDatabase.sc +++ b/LSPDatabase.sc @@ -232,7 +232,7 @@ LSPDatabase { *renderMethodLocation { |method| ^( - uri: "file://%".format(method.filenameSymbol), + uri: method.filenameSymbol.asString.pathToFileURI, range: this.renderMethodRange(method) ) } @@ -240,7 +240,7 @@ LSPDatabase { *renderClassLocation { |class| ^( - uri: "file://%".format(class.filenameSymbol), + uri: class.filenameSymbol.asString.pathToFileURI, range: this.renderClassRange(class) ) } diff --git a/Providers/CodeLensProvider.sc b/Providers/CodeLensProvider.sc index 79ed0d8..9bc2220 100644 --- a/Providers/CodeLensProvider.sc +++ b/Providers/CodeLensProvider.sc @@ -27,7 +27,11 @@ CodeLensProvider : LSPProvider { command: ( title: "▶ EVALUATE ———————————————————————————————", command: "supercollider.evaluateSelection", - arguments: [region[\range]] + name: region[\text], + arguments: [ + params["textDocument"]["uri"], + region[\range], + ] ) ) } diff --git a/Providers/DocumentSymbolProvider.sc b/Providers/DocumentSymbolProvider.sc index 22a3e1f..33cc77d 100644 --- a/Providers/DocumentSymbolProvider.sc +++ b/Providers/DocumentSymbolProvider.sc @@ -29,7 +29,7 @@ DocumentSymbolProvider : LSPProvider { |region| ( name: region[\text], - kind: 0, + kind: 2, range: region[\range], selectionRange: region[\range] ) diff --git a/Providers/EvaluateProvider.sc b/Providers/EvaluateProvider.sc index dd89728..a8cb989 100644 --- a/Providers/EvaluateProvider.sc +++ b/Providers/EvaluateProvider.sc @@ -5,6 +5,7 @@ EvaluateProvider : LSPProvider { <>sourceCodeLineLimit=6, <>skipErrorConstructors=true; var resultPrefix="> "; + var guestUserPrefix="[%|> "; var postResult=true, improvedErrorReports=false; var <>postBeforeEvaluate="", <>postAfterEvaluate=""; @@ -22,6 +23,7 @@ EvaluateProvider : LSPProvider { |server, message, value| if (message == \clientOptions) { resultPrefix = value['sclang.evaluateResultPrefix'] ?? {"> "}; + guestUserPrefix = value['sclang.guestEvaluateResultPrefix'] ?? {"[%|> "}; postResult = value['sclang.postEvaluateResults'] !? (_ == "true") ?? true; improvedErrorReports = value['sclang.improvedErrorReports'] !? (_ == "true") ?? false; } @@ -44,9 +46,10 @@ EvaluateProvider : LSPProvider { onReceived { |method, params| - var source, document, function, result, deferredResult; + var source, document, function, guestUser, result, deferredResult; source = params["sourceCode"]; + guestUser = params["user"]; document = LSPDocument.findByQUuid(params["textDocument"]["uri"].urlDecode); deferredResult = Deferred(); @@ -71,7 +74,12 @@ EvaluateProvider : LSPProvider { if (resultStringLimit.size >= resultStringLimit, { ^(result ++ "...etc..."); }); if (postResult) { - resultPrefix.post; + if (guestUser.notNil) { + guestUserPrefix.format(guestUser).post; + } { + resultPrefix.post; + }; + result.postln; }; deferredResult.value = (result: result); @@ -168,7 +176,7 @@ EvaluateProvider : LSPProvider { }; out << "\t%:%\t".format(ownerClass, methodName).padRight(30) - << "(file://%)".format(def.filenameSymbol) + << "(%)".format(def.filenameSymbol.asString.pathToFileURI) << Char.nl; } { out << "\ta FunctionDef\t%\n".format(currentFrame.address); diff --git a/Providers/InitializeProvider.sc b/Providers/InitializeProvider.sc index c53b0ae..45df77a 100644 --- a/Providers/InitializeProvider.sc +++ b/Providers/InitializeProvider.sc @@ -63,12 +63,12 @@ InitializeProvider : LSPProvider { |folders| folders.do { |folder| - server.workspaceFolders.add(folder["uri"].copy.replace("file://", "").urlDecode) + server.workspaceFolders.add(folder["uri"].copy.fileURIToPath) }; } ?? { initializeParams["rootUri"] ?? initializeParams["rootPath"] !? { |root| - server.workspaceFolders.add(root.copy.replace("file://", "").urlDecode) + server.workspaceFolders.add(root.copy.fileURIToPath) }; }; diff --git a/SystemOverwrites/extMain.sc b/SystemOverwrites/extMain.sc index 4f320ec..908fc29 100644 --- a/SystemOverwrites/extMain.sc +++ b/SystemOverwrites/extMain.sc @@ -28,6 +28,11 @@ .postf(if(this.platform.name == \windows) { ".exe" } { "" }); }; + if (LSPConnection.enabled.not) { + this.platform.startup; + StartUp.run; + }; + Main.overwriteMsg.split(Char.nl).drop(-1).collect(_.split(Char.tab)).do {|x| if(x[2].beginsWith(Platform.classLibraryDir) and: {x[1].contains(""+/+"SystemOverwrites"+/+"").not} ) { diff --git a/extString.sc b/extString.sc index 0d944cf..a071e62 100644 --- a/extString.sc +++ b/extString.sc @@ -33,4 +33,29 @@ ^(currentIndex + character) } + + + // Given an absolute file path in OSX, Windows, or Linux format, return a file URI + // that will be understood by the LSP client. + pathToFileURI { + ^Platform.case( + \osx, { "file://" ++ this }, + \windows, { "file:///" ++ this.replace("\\", "/") }, + \linux, { "file://" ++ this }, + { "file://" ++ this } + ) + } + + /* + Reverse of pathToFileURI - given a file URI, returns a path in the format of the + current OS. + */ + fileURIToPath { + ^(Platform.case( + \osx, { this.replace("file://", "") }, + \windows, { this.replace("file:///", "").replace("/", "\\") }, + \linux, { this.replace("file://", "") }, + { this.replace("file://", "") } + ).urlDecode) + } } diff --git a/scide_vscode/LSPDocument.sc b/scide_vscode/LSPDocument.sc index 4572276..d0f1d6b 100644 --- a/scide_vscode/LSPDocument.sc +++ b/scide_vscode/LSPDocument.sc @@ -41,7 +41,7 @@ LSPDocument : Document { *open { | path, selectionStart=0, selectionLength=0, envir | LSPConnection.connection.request('window/showDocument', ( - uri: "file://%".format(path.standardizePath), + uri: path.standardizePath.pathToFileURI, takeFocus: true, // selection: )) @@ -55,7 +55,7 @@ LSPDocument : Document { uri, string, false, - uri.copy.replace("file://", "").urlDecode, + uri.copy.fileURIToPath, 0, 0 ); @@ -179,7 +179,7 @@ LSPDocument : Document { path { if (quuid.contains("file://")) { - ^quuid.copy.replace("file://", "").urlDecode + ^quuid.copy.fileURIToPath } { ^nil } From dd99dedd40d95d24772be7da7d81d967a90e911d Mon Sep 17 00:00:00 2001 From: Danny Keig <39764493+dannyZyg@users.noreply.github.com> Date: Mon, 16 Feb 2026 12:31:23 +1100 Subject: [PATCH 6/6] Revert "Merge upstream (#6)" This reverts commit b3976a5ab0ae318f843f289ea5746af558e00502. --- LSP.sc | 31 ++++++----------------------- LSPDatabase.sc | 4 ++-- Providers/CodeLensProvider.sc | 6 +----- Providers/DocumentSymbolProvider.sc | 2 +- Providers/EvaluateProvider.sc | 14 +++---------- Providers/InitializeProvider.sc | 4 ++-- SystemOverwrites/extMain.sc | 5 ----- extString.sc | 25 ----------------------- scide_vscode/LSPDocument.sc | 6 +++--- 9 files changed, 18 insertions(+), 79 deletions(-) diff --git a/LSP.sc b/LSP.sc index a47f807..7653743 100644 --- a/LSP.sc +++ b/LSP.sc @@ -82,10 +82,6 @@ LSPConnection { }) } - *enabled { - ^this.envirSettings[\enabled] - } - start { // @TODO: What do we do before start / after stop? Errors? Log('LanguageServer.quark').info("Starting language server, inPort: % outPort:%", inPort, outPort); @@ -238,22 +234,12 @@ LSPConnection { |error| // @TODO handle error error.reportError; - if (id.isNil) { - this.prHandleNotification( - method: method, - params: ( - error: (code: error.class.identityHash, message:error.what), - methodParams: params - ), - ); - } { - this.prHandleErrorResponse( - id: id, - code: error.class.identityHash, - message: error.what, - // data: error.getBacktrace // @TODO Render backtrace as JSON? - ); - } + this.prHandleErrorResponse( + id: id, + code: error.class.identityHash, + message: error.what, + // data: error.getBacktrace // @TODO Render backtrace as JSON? + ); }); } } @@ -289,11 +275,6 @@ LSPConnection { this.prSendMessage(response); } - - prHandleNotification { - |method, params| - this.prSendMessage((method:method, params:params)) - } prHandleResponse { |id, result| diff --git a/LSPDatabase.sc b/LSPDatabase.sc index a74f036..58f622f 100644 --- a/LSPDatabase.sc +++ b/LSPDatabase.sc @@ -232,7 +232,7 @@ LSPDatabase { *renderMethodLocation { |method| ^( - uri: method.filenameSymbol.asString.pathToFileURI, + uri: "file://%".format(method.filenameSymbol), range: this.renderMethodRange(method) ) } @@ -240,7 +240,7 @@ LSPDatabase { *renderClassLocation { |class| ^( - uri: class.filenameSymbol.asString.pathToFileURI, + uri: "file://%".format(class.filenameSymbol), range: this.renderClassRange(class) ) } diff --git a/Providers/CodeLensProvider.sc b/Providers/CodeLensProvider.sc index 9bc2220..79ed0d8 100644 --- a/Providers/CodeLensProvider.sc +++ b/Providers/CodeLensProvider.sc @@ -27,11 +27,7 @@ CodeLensProvider : LSPProvider { command: ( title: "▶ EVALUATE ———————————————————————————————", command: "supercollider.evaluateSelection", - name: region[\text], - arguments: [ - params["textDocument"]["uri"], - region[\range], - ] + arguments: [region[\range]] ) ) } diff --git a/Providers/DocumentSymbolProvider.sc b/Providers/DocumentSymbolProvider.sc index 33cc77d..22a3e1f 100644 --- a/Providers/DocumentSymbolProvider.sc +++ b/Providers/DocumentSymbolProvider.sc @@ -29,7 +29,7 @@ DocumentSymbolProvider : LSPProvider { |region| ( name: region[\text], - kind: 2, + kind: 0, range: region[\range], selectionRange: region[\range] ) diff --git a/Providers/EvaluateProvider.sc b/Providers/EvaluateProvider.sc index a8cb989..dd89728 100644 --- a/Providers/EvaluateProvider.sc +++ b/Providers/EvaluateProvider.sc @@ -5,7 +5,6 @@ EvaluateProvider : LSPProvider { <>sourceCodeLineLimit=6, <>skipErrorConstructors=true; var resultPrefix="> "; - var guestUserPrefix="[%|> "; var postResult=true, improvedErrorReports=false; var <>postBeforeEvaluate="", <>postAfterEvaluate=""; @@ -23,7 +22,6 @@ EvaluateProvider : LSPProvider { |server, message, value| if (message == \clientOptions) { resultPrefix = value['sclang.evaluateResultPrefix'] ?? {"> "}; - guestUserPrefix = value['sclang.guestEvaluateResultPrefix'] ?? {"[%|> "}; postResult = value['sclang.postEvaluateResults'] !? (_ == "true") ?? true; improvedErrorReports = value['sclang.improvedErrorReports'] !? (_ == "true") ?? false; } @@ -46,10 +44,9 @@ EvaluateProvider : LSPProvider { onReceived { |method, params| - var source, document, function, guestUser, result, deferredResult; + var source, document, function, result, deferredResult; source = params["sourceCode"]; - guestUser = params["user"]; document = LSPDocument.findByQUuid(params["textDocument"]["uri"].urlDecode); deferredResult = Deferred(); @@ -74,12 +71,7 @@ EvaluateProvider : LSPProvider { if (resultStringLimit.size >= resultStringLimit, { ^(result ++ "...etc..."); }); if (postResult) { - if (guestUser.notNil) { - guestUserPrefix.format(guestUser).post; - } { - resultPrefix.post; - }; - + resultPrefix.post; result.postln; }; deferredResult.value = (result: result); @@ -176,7 +168,7 @@ EvaluateProvider : LSPProvider { }; out << "\t%:%\t".format(ownerClass, methodName).padRight(30) - << "(%)".format(def.filenameSymbol.asString.pathToFileURI) + << "(file://%)".format(def.filenameSymbol) << Char.nl; } { out << "\ta FunctionDef\t%\n".format(currentFrame.address); diff --git a/Providers/InitializeProvider.sc b/Providers/InitializeProvider.sc index 45df77a..c53b0ae 100644 --- a/Providers/InitializeProvider.sc +++ b/Providers/InitializeProvider.sc @@ -63,12 +63,12 @@ InitializeProvider : LSPProvider { |folders| folders.do { |folder| - server.workspaceFolders.add(folder["uri"].copy.fileURIToPath) + server.workspaceFolders.add(folder["uri"].copy.replace("file://", "").urlDecode) }; } ?? { initializeParams["rootUri"] ?? initializeParams["rootPath"] !? { |root| - server.workspaceFolders.add(root.copy.fileURIToPath) + server.workspaceFolders.add(root.copy.replace("file://", "").urlDecode) }; }; diff --git a/SystemOverwrites/extMain.sc b/SystemOverwrites/extMain.sc index 908fc29..4f320ec 100644 --- a/SystemOverwrites/extMain.sc +++ b/SystemOverwrites/extMain.sc @@ -28,11 +28,6 @@ .postf(if(this.platform.name == \windows) { ".exe" } { "" }); }; - if (LSPConnection.enabled.not) { - this.platform.startup; - StartUp.run; - }; - Main.overwriteMsg.split(Char.nl).drop(-1).collect(_.split(Char.tab)).do {|x| if(x[2].beginsWith(Platform.classLibraryDir) and: {x[1].contains(""+/+"SystemOverwrites"+/+"").not} ) { diff --git a/extString.sc b/extString.sc index a071e62..0d944cf 100644 --- a/extString.sc +++ b/extString.sc @@ -33,29 +33,4 @@ ^(currentIndex + character) } - - - // Given an absolute file path in OSX, Windows, or Linux format, return a file URI - // that will be understood by the LSP client. - pathToFileURI { - ^Platform.case( - \osx, { "file://" ++ this }, - \windows, { "file:///" ++ this.replace("\\", "/") }, - \linux, { "file://" ++ this }, - { "file://" ++ this } - ) - } - - /* - Reverse of pathToFileURI - given a file URI, returns a path in the format of the - current OS. - */ - fileURIToPath { - ^(Platform.case( - \osx, { this.replace("file://", "") }, - \windows, { this.replace("file:///", "").replace("/", "\\") }, - \linux, { this.replace("file://", "") }, - { this.replace("file://", "") } - ).urlDecode) - } } diff --git a/scide_vscode/LSPDocument.sc b/scide_vscode/LSPDocument.sc index d0f1d6b..4572276 100644 --- a/scide_vscode/LSPDocument.sc +++ b/scide_vscode/LSPDocument.sc @@ -41,7 +41,7 @@ LSPDocument : Document { *open { | path, selectionStart=0, selectionLength=0, envir | LSPConnection.connection.request('window/showDocument', ( - uri: path.standardizePath.pathToFileURI, + uri: "file://%".format(path.standardizePath), takeFocus: true, // selection: )) @@ -55,7 +55,7 @@ LSPDocument : Document { uri, string, false, - uri.copy.fileURIToPath, + uri.copy.replace("file://", "").urlDecode, 0, 0 ); @@ -179,7 +179,7 @@ LSPDocument : Document { path { if (quuid.contains("file://")) { - ^quuid.copy.fileURIToPath + ^quuid.copy.replace("file://", "").urlDecode } { ^nil }