diff --git a/.gitignore b/.gitignore index 73c25571..dbc94920 100644 --- a/.gitignore +++ b/.gitignore @@ -216,7 +216,7 @@ tmp/ .vscode/ # Claude settings -.claude/settings.local.json +/.claude # Elixir /test/resources/repos/elixir/test_repo/deps diff --git a/CHANGELOG.md b/CHANGELOG.md index b8471139..35f4a3d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,11 +2,29 @@ Status of the `main` branch. Changes prior to the next official version change will appear here. +* General: + - Support environment variable `SERENA_USAGE_REPORTING` (set to `false` to disable usage reporting) + +* Language Servers: + - Add mSL (mIRC Scripting Language) support (custom pygls-based language server; symbols, references, definitions) + +# 1.1.1 + +* General: + - Enable cert verification for HTTPS request to oraios-software.de #1320 + +* JetBrains: + - `JetBrainsRenameTool` can now also rename occurrences in comments and text. + +* Language Servers: + - Fix Dart LSP returning only symbol name as body instead of full method body. + + # 1.1.0 * General: - - Add `serena init` and `serena setup` commands - **Major**: Add commands for hooks and documentation of recommended setup. Consider setting up the [recommended hooks](https://oraios.github.io/serena/02-usage/030_clients.html) ! + - Add `serena init` and `serena setup` commands - Rework installation instructions, switching to releases on pypi for distribution. Please update your mcp startup commands! - Add minimal usage data collection on startup (only Serena version, language backend, OS, dashboard enabled status; no personally identifiable information) - Fix: git commit id in Serena version strings was incorrect diff --git a/README.md b/README.md index 15a5733d..194f5793 100644 --- a/README.md +++ b/README.md @@ -7,10 +7,17 @@ Serena is the IDE for your coding agent. +
+- For more detailed information, see our changelog. + For more detailed information, see our changelog.
\ No newline at end of file diff --git a/news/20260303.html b/news/20260303.html index ca91377f..c42233d7 100644 --- a/news/20260303.html +++ b/news/20260303.html @@ -7,6 +7,6 @@ into topics and sharing them across projects.- For more detailed information, see the documentation. + For more detailed information, see the documentation.
\ No newline at end of file diff --git a/news/20260330.html b/news/20260330.html index c78c4be7..3eca0574 100644 --- a/news/20260330.html +++ b/news/20260330.html @@ -8,6 +8,6 @@ to other projects.- Find out more about this feature in our documentation. + Find out more about this feature in our documentation.
\ No newline at end of file diff --git a/news/20260411.html b/news/20260411.html index f337c07f..c0da1719 100644 --- a/news/20260411.html +++ b/news/20260411.html @@ -2,21 +2,21 @@April 11, 2026
- Installation. Serena has matured and we are switching to a PyPI-based release and installation mechanism. - The recommended uv-tool-based installation decreases startup times and facilitates CLI usage. - See updated installation instructions - for more details. + Installation. Serena has matured, and we are switching to a PyPI-based release and installation mechanism. + The recommended uv tool-based installation decreases startup times and facilitates CLI usage. + See our updated installation instructions + for more details, + and update your client configurations after having switched + to the new installation method.
Improving Tool Usage via Hooks. To address issues with agents not using Serena's tools (agent drift), we now provide the `serena-hooks` commands and a recommended setup for Claude Code and VSCode. - Please read the updated documentation for more details. + Please read the updated documentation for more details. We strongly recommend using the hooks, since recent updates in Claude Code and VSCode resulted in reduced performance, which our hook commands compensate for.
-- Community. We opened a Discord server for our community. + Community. We opened a Discord server for our community.
- \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index e1638adb..7ab83e26 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,14 +4,17 @@ requires = ["hatchling"] [project] name = "serena-agent" -version = "1.1.0" -description = "" +version = "1.1.1" +description = "A powerful MCP toolkit for coding, providing semantic retrieval and editing capabilities - the IDE for your agent" authors = [{ name = "Oraios AI", email = "info@oraios-ai.de" }] readme = "README.md" requires-python = ">=3.11, <3.15" classifiers = [ "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.13", ] dependencies = [ "requests==2.33.0", @@ -38,6 +41,8 @@ dependencies = [ "beautifulsoup4==4.14.2", "oraios-pywebview==6.2", "pystray==0.19.5", + "pygls==2.1.1", # used to implement an msl language server + "lsprotocol==2025.0.0", # Transitive deps pinned for security (dependabot alerts). # Exact pins because uvx installs from git, ignoring the lock file. "urllib3==2.6.3", @@ -344,6 +349,7 @@ markers = [ "lean4: language server running for Lean 4", "solidity: language server running for Solidity (uses @nomicfoundation/solidity-language-server)", "ansible: language server running for Ansible (uses @ansible/ansible-language-server)", + "msl: language server running for mSL (mIRC Scripting Language)", ] [tool.codespell] diff --git a/scripts/bump_version.py b/scripts/bump_version.py index 2fdd213d..e3426baa 100644 --- a/scripts/bump_version.py +++ b/scripts/bump_version.py @@ -44,6 +44,7 @@ def bump_version(major: bool, minor: bool, patch: bool, target_version: str | No if dry_run: click.echo(f"Dry run complete. Version would be bumped to {new_version}") else: + os.system("uv lock") click.echo(f"Bumped version to {new_version}") os.system("git add -u") os.system(f'git commit -m "Release v{new_version}"') diff --git a/src/serena/__init__.py b/src/serena/__init__.py index 00137aad..151d3432 100644 --- a/src/serena/__init__.py +++ b/src/serena/__init__.py @@ -1,4 +1,4 @@ -__version__ = "1.1.0" +__version__ = "1.1.1" import logging diff --git a/src/serena/agent.py b/src/serena/agent.py index ab14239f..322db7c8 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -410,17 +410,16 @@ class SerenaAgent: self._send_usage_info() def _send_usage_info(self) -> None: - is_ci = os.getenv("CI") == "true" or os.getenv("GITHUB_ACTIONS") == "true" - if is_ci: + if os.getenv("CI") == "true" or os.getenv("GITHUB_ACTIONS") == "true" or os.getenv("SERENA_USAGE_REPORTING") == "false": return - params = { + params: dict[str, str | int] = { "os": platform.system(), "dashboard": int(self.serena_config.web_dashboard), "version": self.version, "backend": self._language_backend.value, } try: - requests.get("https://www.oraios-software.de/serena_usage.php", params=params, timeout=1, verify=False) + requests.get("https://oraios-software.de/serena_usage.php", params=params, timeout=1) except Exception as e: log.debug(f"Failed to send usage info: {e}") @@ -720,7 +719,7 @@ class SerenaAgent: if self._language_backend == LanguageBackend.LSP: languages_str = ", ".join([lang.value for lang in proj.project_config.languages]) msg += f"\nProgramming languages: {languages_str}." - msg += "File encoding: {proj.project_config.encoding}." + msg += f"File encoding: {proj.project_config.encoding}." include_memories = self._active_tools.contains_tool_class(ReadMemoryTool) if include_memories: diff --git a/src/serena/task_executor.py b/src/serena/task_executor.py index 256fbf02..5e624296 100644 --- a/src/serena/task_executor.py +++ b/src/serena/task_executor.py @@ -80,7 +80,7 @@ class TaskExecutor: :param timeout: the maximum time to wait in seconds; if None, use the task's own timeout (which may be None to wait indefinitely) - :return: True if the task is done, False if the timeout was reached + :return: the result of the task """ return self.future.result(timeout=timeout) diff --git a/src/serena/tools/jetbrains_tools.py b/src/serena/tools/jetbrains_tools.py index 6db26e0b..a94d5c99 100644 --- a/src/serena/tools/jetbrains_tools.py +++ b/src/serena/tools/jetbrains_tools.py @@ -512,16 +512,33 @@ class JetBrainsRenameTool(Tool, ToolMarkerSymbolicEdit, ToolMarkerOptional): Renames a symbol, file or directory throughout the codebase using the JetBrains backend. """ - def apply(self, relative_path: str, new_name: str, name_path: str | None = None) -> str: + def apply( + self, + relative_path: str, + new_name: str, + name_path: str | None = None, + rename_in_comments: bool = False, + rename_in_text_occurrences: bool = False, + ) -> str: """ Renames a symbol, file or directory throughout the codebase. + Note: renaming in comments/text is on a best-effort basis by the IDE; if the symbol name is non-unique, further + verification is recommended. :param relative_path: if `name_path` is passed, the relative path of the file containing the symbol. Otherwise, the path to the directory or file to rename. :param new_name: the new name :param name_path: the name path of the symbol to rename or None if renaming a file or directory. + :param rename_in_comments: whether to also rename occurrences in comments. Default True. + :param rename_in_text_occurrences: whether to also rename occurrences in text. Default True. :return: a status message """ code_editor = JetBrainsCodeEditor(self.project) - result = code_editor.rename_symbol(name_path=name_path, relative_path=relative_path, new_name=new_name) + result = code_editor.rename_symbol( + name_path=name_path, + relative_path=relative_path, + new_name=new_name, + rename_in_comments=rename_in_comments, + rename_in_text_occurrences=rename_in_text_occurrences, + ) return self._to_json(result) diff --git a/src/serena/tools/tools_base.py b/src/serena/tools/tools_base.py index bb474349..18badd67 100644 --- a/src/serena/tools/tools_base.py +++ b/src/serena/tools/tools_base.py @@ -430,7 +430,6 @@ tool_packages = ["serena.tools"] @singleton class ToolRegistry: _deleted_tools: list[str] = [ - "think_about_collected_information", "think_about_collected_information", "prepare_for_new_conversation", "summarize_changes", diff --git a/src/solidlsp/language_servers/dart_language_server.py b/src/solidlsp/language_servers/dart_language_server.py index 11f78106..f82627c6 100644 --- a/src/solidlsp/language_servers/dart_language_server.py +++ b/src/solidlsp/language_servers/dart_language_server.py @@ -123,7 +123,13 @@ class DartLanguageServer(SolidLanguageServer): """ root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params = { - "capabilities": {}, + "capabilities": { + "textDocument": { + "documentSymbol": { + "hierarchicalDocumentSymbolSupport": True, + } + } + }, "initializationOptions": { "onlyAnalyzeProjectsWithOpenFiles": False, "closingLabels": False, diff --git a/src/solidlsp/language_servers/hlsl_language_server.py b/src/solidlsp/language_servers/hlsl_language_server.py index 2857055b..b6eb7e05 100644 --- a/src/solidlsp/language_servers/hlsl_language_server.py +++ b/src/solidlsp/language_servers/hlsl_language_server.py @@ -7,9 +7,6 @@ import logging import os import pathlib import shutil -from collections.abc import Iterator -from contextlib import contextmanager -from pathlib import Path from typing import Any, cast import psutil @@ -18,11 +15,9 @@ from overrides import override from solidlsp.ls import ( LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, - LSPFileBuffer, SolidLanguageServer, ) from solidlsp.ls_config import LanguageServerConfig -from solidlsp.ls_exceptions import SolidLSPException from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.settings import SolidLSPSettings @@ -31,13 +26,13 @@ from .common import RuntimeDependency, RuntimeDependencyCollection log = logging.getLogger(__name__) # GitHub release version to download when not installed locally -_DEFAULT_VERSION = "1.3.0" +_DEFAULT_VERSION = "1.3.1" _GITHUB_RELEASE_BASE = "https://github.com/antaalt/shader-sense/releases/download" _HLSL_ALLOWED_HOSTS = ("github.com", "release-assets.githubusercontent.com", "objects.githubusercontent.com") _HLSL_SHA256_BY_ASSET = { - "shader-language-server-x86_64-pc-windows-msvc.zip": "a945b000c296cdeebb9ee2d4452cec2a0f26544dd076bb08bfdcade2278296a6", - "shader-language-server-x86_64-unknown-linux-gnu.zip": "8c0a7b36f51cc58593762db3592ae13e21ca3cb982b2526cfaaf7c82e92ca089", - "shader-language-server-aarch64-pc-windows-msvc.zip": "cdbd7b41e71cf6040d5cdb7e211ba4b76671a404ee0f7add281d72d3ab8dfa65", + "shader-language-server-x86_64-pc-windows-msvc.zip": "49081c5547ddde1b8b3b17295282a80ddacbca1d6f5dcd834e2788c02bafa997", + "shader-language-server-x86_64-unknown-linux-gnu.zip": "61710df7ca17a2d063b598936c57c56c49fbf837707a1aa886f9b0193a35be3c", + "shader-language-server-aarch64-pc-windows-msvc.zip": "a3b3799affe2cad27652e788376b46fe76e1a6c2ce45946a486dcb26c9091412", } @@ -277,92 +272,6 @@ class HlslLanguageServer(SolidLanguageServer): log.debug(f"Error cleaning up shader-language-server process tree: {e}") super().stop(shutdown_timeout) - @contextmanager - def open_file(self, relative_file_path: str, open_in_ls: bool = True) -> Iterator[LSPFileBuffer]: - """Open a file for LSP, preserving on-disk CRLF line endings. - - Workaround for an upstream bug in shader-language-server - (antaalt/shader-sense) where `watch_main_file` replaces an already-cached - module's content without re-parsing the tree-sitter tree. When a file is - first pulled into the server's cache via an `#include` from another - shader (where it's read via `std::fs::read_to_string`, preserving CRLF), - and then later opened directly via `textDocument/didOpen` with the - client-normalized LF text, the stored tree still references byte offsets - into the longer CRLF content. The next symbol query slices the new - (shorter) content with stale offsets and panics with - `byte index N is out of bounds` in `shader-sense/src/symbols/symbol_parser.rs`. - - The root-cause fix belongs upstream (the server should call - `update_module` instead of assigning `content` raw). Until then, we - ensure the text we send in `didOpen` matches byte-for-byte what the - server reads from disk by preloading the file buffer with a - CRLF-preserving read before the LSP notification is sent. - - This is the only place in Serena that overrides `open_file`; the fix is - deliberately scoped to the HLSL language server. It mirrors the base - class logic in `SolidLanguageServer.open_file` verbatim except for the - buffer construction branch, where creation is deferred (`open_in_ls=False`) - so the buffer's contents can be preloaded before `ensure_open_in_ls` runs. - """ - if not self.server_started: - log.error("open_file called before Language Server started") - raise SolidLSPException("Language Server not started") - - absolute_file_path = Path(self.repository_root_path, relative_file_path) - uri = absolute_file_path.as_uri() - - if uri in self.open_file_buffers: - fb = self.open_file_buffers[uri] - assert fb.uri == uri - assert fb.ref_count >= 1 - - fb.ref_count += 1 - if open_in_ls: - fb.ensure_open_in_ls() - yield fb - fb.ref_count -= 1 - else: - version = 0 - language_id = self._get_language_id_for_file(relative_file_path) - # Defer the didOpen so we can preload CRLF-preserved content first. - fb = LSPFileBuffer( - abs_path=absolute_file_path, - uri=uri, - encoding=self._encoding, - version=version, - language_id=language_id, - ref_count=1, - language_server=self, - open_in_ls=False, - ) - self._preload_crlf_content(fb) - self.open_file_buffers[uri] = fb - if open_in_ls: - fb.ensure_open_in_ls() - yield fb - fb.ref_count -= 1 - - if self.open_file_buffers[uri].ref_count == 0: - self.open_file_buffers[uri].close() - del self.open_file_buffers[uri] - - def _preload_crlf_content(self, fb: LSPFileBuffer) -> None: - """Populate an LSPFileBuffer with a CRLF-preserving read of its backing file. - - Python's default text-mode open applies universal-newlines translation - (CRLF -> LF), which would desync the client's `didOpen` text from the - server-side `std::fs::read_to_string` view that parsed the dependency - tree. Passing `newline=""` disables the translation so bytes match. - """ - with open(fb.abs_path, encoding=fb.encoding, newline="") as f: - raw = f.read() - # Set the buffer's cached state directly: the contents, the mtime - # (required by the contents property's staleness check), and clear the - # hash so it's recomputed against the new bytes. - fb._contents = raw - fb._read_file_modified_date = fb.abs_path.stat().st_mtime - fb._content_hash = None - @override def is_ignored_dirname(self, dirname: str) -> bool: """Ignore Unity-specific directories that contain no user-authored shaders.""" diff --git a/src/solidlsp/language_servers/msl_language_server.py b/src/solidlsp/language_servers/msl_language_server.py new file mode 100644 index 00000000..e2596a05 --- /dev/null +++ b/src/solidlsp/language_servers/msl_language_server.py @@ -0,0 +1,107 @@ +""" +Provides mSL (mIRC Scripting Language) specific instantiation of the LanguageServer class. +Uses a custom Python-based LSP server (pygls) for parsing .mrc files. + +The LSP server script is shipped as ``msl_lsp_server.py`` alongside this module +and launched as a subprocess using the current Python interpreter. +""" + +import logging +import os +import pathlib +import sys +import threading + +from solidlsp.ls import ( + SolidLanguageServer, +) +from solidlsp.ls_config import LanguageServerConfig +from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams +from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo +from solidlsp.settings import SolidLSPSettings + +log = logging.getLogger(__name__) + +_MSL_LSP_SCRIPT = os.path.join(os.path.dirname(__file__), "msl_lsp_server.py") + + +class MslLanguageServer(SolidLanguageServer): + """ + Provides mSL (mIRC Scripting Language) specific instantiation of the LanguageServer class. + Uses a Python-based LSP server for parsing .mrc files (aliases, events, menus, dialogs). + """ + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + """ + Creates an MslLanguageServer instance. This class is not meant to be instantiated directly. + Use LanguageServer.create() instead. + """ + process_launch_info = ProcessLaunchInfo(cmd=[sys.executable, _MSL_LSP_SCRIPT], cwd=repository_root_path) + super().__init__( + config, + repository_root_path, + process_launch_info=process_launch_info, + language_id="msl", + solidlsp_settings=solidlsp_settings, + ) + self.server_ready = threading.Event() + + @staticmethod + def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + """Returns the initialize params for the mSL Language Server.""" + root_uri = pathlib.Path(repository_absolute_path).as_uri() + initialize_params = { + "locale": "en", + "capabilities": { + "textDocument": { + "synchronization": {"didSave": True, "dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "hierarchicalDocumentSymbolSupport": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, + "references": {"dynamicRegistration": True}, + "definition": {"dynamicRegistration": True}, + }, + "workspace": { + "workspaceFolders": True, + "symbol": {"dynamicRegistration": True}, + }, + }, + "processId": os.getpid(), + "rootPath": repository_absolute_path, + "rootUri": root_uri, + "workspaceFolders": [{"uri": root_uri, "name": os.path.basename(repository_absolute_path)}], + } + return initialize_params # type: ignore + + def _start_server(self) -> None: + """Starts the mSL Language Server.""" + + def window_log_message(msg: dict) -> None: + log.info(f"LSP: window/logMessage: {msg}") + self.server_ready.set() + + def do_nothing(params: dict) -> None: + pass + + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + + log.info("Starting mSL server process") + self.server.start() + initialize_params = self._get_initialize_params(self.repository_root_path) + + log.info("Sending initialize request to mSL LSP server") + init_response = self.server.send.initialize(initialize_params) + log.debug(f"Received initialize response: {init_response}") + + self.server.notify.initialized({}) + + # Wait briefly for server readiness + if not self.server_ready.wait(timeout=2.0): + log.info("Timeout waiting for mSL server ready signal, proceeding anyway") + self.server_ready.set() + + log.info("mSL server initialization complete") diff --git a/src/solidlsp/language_servers/msl_lsp_server.py b/src/solidlsp/language_servers/msl_lsp_server.py new file mode 100644 index 00000000..e87d3d16 --- /dev/null +++ b/src/solidlsp/language_servers/msl_lsp_server.py @@ -0,0 +1,439 @@ +"""mSL (mIRC Scripting Language) Language Server. + +A minimal LSP implementation for mIRC scripting language (.mrc files). +Provides document symbols, hover, references, go-to-definition, and workspace symbols +for aliases, events, menus, dialogs, and CTCP handlers. + +Launched as a subprocess by MslLanguageServer. Communicates via stdio. +""" + +import logging +import os +import pathlib +import re + +from lsprotocol import types as lsp +from pygls.lsp.server import LanguageServer + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +server = LanguageServer("msl-lsp", "0.1.0") + +# mSL top-level construct patterns +ALIAS_PATTERN = re.compile( + r"^[ \t]*alias\s+(?:-l\s+)?([a-zA-Z_][\w.]*)\s*(?:\{|$)", + re.MULTILINE | re.IGNORECASE, +) +EVENT_PATTERN = re.compile( + r"^[ \t]*on\s+(\*|\d+):(\w+):([^{]*?)(?:\{|$)", + re.MULTILINE | re.IGNORECASE, +) +RAW_EVENT_PATTERN = re.compile( + r"^[ \t]*raw\s+(\d+):([^{]*?)(?:\{|$)", + re.MULTILINE | re.IGNORECASE, +) +MENU_PATTERN = re.compile( + r"^[ \t]*menu\s+([^\s{]+)\s*\{", + re.MULTILINE | re.IGNORECASE, +) +DIALOG_PATTERN = re.compile( + r"^[ \t]*dialog\s+(-l\s+)?([a-zA-Z_][\w]*)\s*\{", + re.MULTILINE | re.IGNORECASE, +) +CTCP_PATTERN = re.compile( + r"^[ \t]*ctcp\s+(\*|\d+):(\w+):([^{]*?)(?:\{|$)", + re.MULTILINE | re.IGNORECASE, +) + + +def _get_line_col(text: str, pos: int) -> tuple[int, int]: + lines = text[:pos].split("\n") + return len(lines) - 1, len(lines[-1]) if lines else 0 + + +def _find_block_end(text: str, start: int) -> int: + count, i = 0, start + while i < len(text): + ch = text[i] + if ch == ";" and count > 0: + while i < len(text) and text[i] != "\n": + i += 1 + continue + if ch == "{": + count += 1 + elif ch == "}": + count -= 1 + if count == 0: + return i + i += 1 + return len(text) - 1 + + +def _make_symbol( + name: str, + kind: lsp.SymbolKind, + detail: str, + text: str, + match_start: int, + match_end: int, + match_text: str, +) -> lsp.DocumentSymbol: + sl, sc = _get_line_col(text, match_start) + bs = text.find("{", match_start) + if bs != -1: + el, ec = _get_line_col(text, _find_block_end(text, bs)) + else: + el, ec = _get_line_col(text, match_end) + return lsp.DocumentSymbol( + name=name, + kind=kind, + range=lsp.Range(lsp.Position(sl, 0), lsp.Position(el, ec + 1)), + selection_range=lsp.Range(lsp.Position(sl, sc), lsp.Position(sl, sc + len(match_text))), + detail=detail, + ) + + +def parse_symbols(text: str) -> list[lsp.DocumentSymbol]: + """Parse mSL source code and return document symbols.""" + symbols: list[lsp.DocumentSymbol] = [] + + for m in ALIAS_PATTERN.finditer(text): + symbols.append(_make_symbol(m.group(1), lsp.SymbolKind.Function, "alias", text, m.start(), m.end(), m.group(0))) + + for m in EVENT_PATTERN.finditer(text): + pat = m.group(3).strip().rstrip(":") + name = f"on {m.group(1)}:{m.group(2)}" + (f":{pat}" if pat else "") + symbols.append(_make_symbol(name, lsp.SymbolKind.Event, f"event:{m.group(2)}", text, m.start(), m.end(), m.group(0))) + + for m in RAW_EVENT_PATTERN.finditer(text): + pat = m.group(2).strip().rstrip(":") + name = f"raw {m.group(1)}" + (f":{pat}" if pat else "") + symbols.append(_make_symbol(name, lsp.SymbolKind.Event, "raw event", text, m.start(), m.end(), m.group(0))) + + for m in MENU_PATTERN.finditer(text): + symbols.append(_make_symbol(f"menu {m.group(1)}", lsp.SymbolKind.Module, "menu", text, m.start(), m.end(), m.group(0))) + + for m in DIALOG_PATTERN.finditer(text): + symbols.append(_make_symbol(f"dialog {m.group(2)}", lsp.SymbolKind.Class, "dialog", text, m.start(), m.end(), m.group(0))) + + for m in CTCP_PATTERN.finditer(text): + pat = m.group(3).strip().rstrip(":") + name = f"ctcp {m.group(1)}:{m.group(2)}" + (f":{pat}" if pat else "") + symbols.append(_make_symbol(name, lsp.SymbolKind.Event, "ctcp event", text, m.start(), m.end(), m.group(0))) + + symbols.sort(key=lambda s: s.range.start.line) + return symbols + + +def _get_workspace_roots() -> list[str]: + """Get workspace root paths from the server's workspace object. + + After initialization, pygls populates ``server.workspace`` with the + root URI/path and any workspace folders sent by the client. + """ + from pygls.uris import to_fs_path + + roots: list[str] = [] + try: + ws = server.workspace + except (RuntimeError, AttributeError): + return roots + + # workspace.folders is a dict of {uri_string: WorkspaceFolder} + if hasattr(ws, "folders") and ws.folders: + for folder_uri in ws.folders: + fs_path = to_fs_path(folder_uri) + if fs_path: + # resolve() normalizes drive letter casing on Windows + fs_path = str(pathlib.Path(fs_path).resolve()) + if os.path.isdir(fs_path): + roots.append(fs_path) + + # Fall back to root_path + if not roots and hasattr(ws, "root_path") and ws.root_path: + root = str(pathlib.Path(ws.root_path).resolve()) + if os.path.isdir(root): + roots.append(root) + + return roots + + +def _path_to_uri(path: str) -> str: + """Convert a filesystem path to a file URI.""" + return pathlib.Path(path).as_uri() + + +def _get_all_mrc_files() -> list[tuple[str, str, str]]: + """Scan workspace roots for all .mrc files. + + Returns list of (uri, file_path, source_text) tuples. + Prefers content from already-opened documents over disk reads. + """ + results: list[tuple[str, str, str]] = [] + seen_paths: set[str] = set() + + # First, include all files currently open in the workspace + try: + for uri, doc in server.workspace.text_documents.items(): + if uri.endswith(".mrc"): + # Resolve the filesystem path from the URI + from pygls.uris import to_fs_path + + file_path = to_fs_path(uri) or uri + norm = os.path.normcase(os.path.normpath(file_path)) + seen_paths.add(norm) + results.append((uri, file_path, doc.source)) + except (RuntimeError, AttributeError): + pass + + # Then scan workspace roots for any .mrc files not yet opened + for root in _get_workspace_roots(): + for dirpath, _dirnames, filenames in os.walk(root): + for fname in filenames: + if not fname.endswith(".mrc"): + continue + full_path = os.path.join(dirpath, fname) + norm = os.path.normcase(os.path.normpath(full_path)) + if norm in seen_paths: + continue + seen_paths.add(norm) + try: + with open(full_path, encoding="utf-8", errors="replace") as f: + source = f.read() + uri = _path_to_uri(full_path) + results.append((uri, full_path, source)) + except OSError: + pass + + return results + + +def _find_symbol_at_position(text: str, line: int, character: int) -> str | None: + """Find the symbol name at the given position in the text.""" + lines = text.split("\n") + if line >= len(lines): + return None + line_text = lines[line] + + # Check if position is within an alias definition + for m in ALIAS_PATTERN.finditer(text): + sl, _ = _get_line_col(text, m.start()) + if sl == line: + return m.group(1) + + # Check if position is within an event definition + for m in EVENT_PATTERN.finditer(text): + sl, _ = _get_line_col(text, m.start()) + if sl == line: + pat = m.group(3).strip().rstrip(":") + return f"on {m.group(1)}:{m.group(2)}" + (f":{pat}" if pat else "") + + # Check if position is within a raw event + for m in RAW_EVENT_PATTERN.finditer(text): + sl, _ = _get_line_col(text, m.start()) + if sl == line: + pat = m.group(2).strip().rstrip(":") + return f"raw {m.group(1)}" + (f":{pat}" if pat else "") + + # Check if position is within a menu + for m in MENU_PATTERN.finditer(text): + sl, _ = _get_line_col(text, m.start()) + if sl == line: + return f"menu {m.group(1)}" + + # Check if position is within a dialog + for m in DIALOG_PATTERN.finditer(text): + sl, _ = _get_line_col(text, m.start()) + if sl == line: + return f"dialog {m.group(2)}" + + # Check if position is within a CTCP handler + for m in CTCP_PATTERN.finditer(text): + sl, _ = _get_line_col(text, m.start()) + if sl == line: + return f"ctcp {m.group(1)}:{m.group(2)}" + + # Fall back: extract word at position (for alias call sites) + if character < len(line_text): + pos = character + # Skip leading $ if cursor is on it (e.g., $format.coins) + if pos < len(line_text) and line_text[pos] == "$": + pos += 1 + start = pos + while start > 0 and (line_text[start - 1].isalnum() or line_text[start - 1] in "_."): + start -= 1 + end = pos + while end < len(line_text) and (line_text[end].isalnum() or line_text[end] in "_."): + end += 1 + word = line_text[start:end] + if word: + return word + return None + + +def _get_symbol_detail(text: str, symbol_name: str) -> str | None: + """Get detail/documentation text for a symbol by finding its definition and extracting context.""" + for sym in parse_symbols(text): + if sym.name == symbol_name: + # Extract the definition line and a few lines of body for hover + text_lines = text.split("\n") + start_line = sym.range.start.line + end_line = min(sym.range.end.line, start_line + 5) + snippet_lines = text_lines[start_line : end_line + 1] + snippet = "\n".join(snippet_lines) + return f"```msl\n{snippet}\n```\n\n**Kind**: {sym.detail}" + return None + + +def _build_call_pattern(alias_name: str) -> re.Pattern[str]: + """Build a regex that matches calls to an alias (both as command and as $identifier).""" + escaped = re.escape(alias_name) + return re.compile( + rf"(? None: + pass + + +@server.feature(lsp.TEXT_DOCUMENT_DID_CHANGE) +def did_change(params: lsp.DidChangeTextDocumentParams) -> None: + pass + + +@server.feature(lsp.TEXT_DOCUMENT_DID_CLOSE) +def did_close(params: lsp.DidCloseTextDocumentParams) -> None: + pass + + +@server.feature(lsp.TEXT_DOCUMENT_DOCUMENT_SYMBOL) +def document_symbol(params: lsp.DocumentSymbolParams) -> list[lsp.DocumentSymbol]: + """Return document symbols for the given document.""" + try: + doc = server.workspace.get_text_document(params.text_document.uri) + return parse_symbols(doc.source) + except Exception as e: + logger.error(f"Error: {e}") + return [] + + +@server.feature(lsp.TEXT_DOCUMENT_HOVER) +def hover(params: lsp.HoverParams) -> lsp.Hover | None: + """Return hover information for the symbol at the given position.""" + try: + doc = server.workspace.get_text_document(params.text_document.uri) + symbol_name = _find_symbol_at_position(doc.source, params.position.line, params.position.character) + if not symbol_name: + return None + + # Search all files for the symbol definition to get its detail + for _uri, _path, source in _get_all_mrc_files(): + detail = _get_symbol_detail(source, symbol_name) + if detail: + return lsp.Hover( + contents=lsp.MarkupContent(kind=lsp.MarkupKind.Markdown, value=detail), + ) + + # Fallback: return the symbol name itself + return lsp.Hover( + contents=lsp.MarkupContent(kind=lsp.MarkupKind.Markdown, value=f"**{symbol_name}**"), + ) + except Exception as e: + logger.error(f"Error in hover: {e}") + return None + + +@server.feature(lsp.TEXT_DOCUMENT_REFERENCES) +def references(params: lsp.ReferenceParams) -> list[lsp.Location]: + """Find all references to the symbol at the given position across the workspace.""" + try: + doc = server.workspace.get_text_document(params.text_document.uri) + symbol_name = _find_symbol_at_position( + doc.source, + params.position.line, + params.position.character, + ) + if not symbol_name: + return [] + + results: list[lsp.Location] = [] + + # For alias symbols, search for call sites across all .mrc files + # For events/menus/dialogs, only return the definition location + is_alias = not any(symbol_name.startswith(prefix) for prefix in ("on ", "raw ", "menu ", "dialog ", "ctcp ")) + + for uri, _path, source in _get_all_mrc_files(): + if is_alias: + call_pattern = _build_call_pattern(symbol_name) + for m in call_pattern.finditer(source): + ref_line, ref_col = _get_line_col(source, m.start()) + results.append( + lsp.Location( + uri=uri, + range=lsp.Range( + lsp.Position(ref_line, ref_col), + lsp.Position(ref_line, ref_col + len(m.group(0))), + ), + ) + ) + else: + # For non-alias symbols, find the definition + for sym in parse_symbols(source): + if sym.name == symbol_name: + results.append(lsp.Location(uri=uri, range=sym.range)) + + return results + except Exception as e: + logger.error(f"Error finding references: {e}") + return [] + + +@server.feature(lsp.TEXT_DOCUMENT_DEFINITION) +def definition(params: lsp.DefinitionParams) -> list[lsp.Location]: + """Go to definition of the symbol at the given position.""" + try: + doc = server.workspace.get_text_document(params.text_document.uri) + symbol_name = _find_symbol_at_position( + doc.source, + params.position.line, + params.position.character, + ) + if not symbol_name: + return [] + + results: list[lsp.Location] = [] + for uri, _path, source in _get_all_mrc_files(): + for sym in parse_symbols(source): + if sym.name == symbol_name: + results.append(lsp.Location(uri=uri, range=sym.selection_range)) + return results + except Exception as e: + logger.error(f"Error finding definition: {e}") + return [] + + +@server.feature(lsp.WORKSPACE_SYMBOL) +def workspace_symbol(params: lsp.WorkspaceSymbolParams) -> list[lsp.SymbolInformation]: + """Search for symbols across the workspace.""" + query = params.query.lower() + results = [] + for uri, _path, source in _get_all_mrc_files(): + for sym in parse_symbols(source): + if query in sym.name.lower(): + results.append( + lsp.SymbolInformation( + name=sym.name, + kind=sym.kind, + location=lsp.Location(uri=uri, range=sym.range), + container_name=sym.detail, + ) + ) + return results + + +if __name__ == "__main__": + server.start_io() diff --git a/src/solidlsp/ls_config.py b/src/solidlsp/ls_config.py index ab1a4038..ff137894 100644 --- a/src/solidlsp/ls_config.py +++ b/src/solidlsp/ls_config.py @@ -88,6 +88,12 @@ class Language(str, Enum): Requires MATLAB R2021b or later and Node.js. Set MATLAB_PATH environment variable or configure matlab_path in ls_specific_settings. """ + MSL = "msl" + """mIRC Scripting Language (mSL) language server. + Supports .mrc files used in mIRC and AdiIRC IRC clients. + Uses a custom LSP server based on pygls. Automatically sets up + a virtual environment with pygls dependencies on first use. + """ # Experimental or deprecated Language Servers TYPESCRIPT_VTS = "typescript_vts" """Use the typescript language server through the natively bundled vscode extension via https://github.com/yioneko/vtsls""" @@ -321,6 +327,8 @@ class Language(str, Enum): return FilenameMatcher("*.sol") case self.ANSIBLE: return FilenameMatcher("*.yaml", "*.yml") + case self.MSL: + return FilenameMatcher("*.mrc") case _: raise ValueError(f"Unhandled language: {self}") @@ -544,6 +552,10 @@ class Language(str, Enum): from solidlsp.language_servers.ansible_language_server import AnsibleLanguageServer return AnsibleLanguageServer + case self.MSL: + from solidlsp.language_servers.msl_language_server import MslLanguageServer + + return MslLanguageServer case _: raise ValueError(f"Unhandled language: {self}") diff --git a/test/conftest.py b/test/conftest.py index ce5761c6..4d3f1154 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -259,6 +259,7 @@ _LANGUAGE_PYTEST_MARKERS: dict[Language, list[MarkDecorator | Mark]] = { Language.JAVA: [pytest.mark.java], Language.KOTLIN: [pytest.mark.kotlin, pytest.mark.skipif(is_ci, reason="Kotlin LSP JVM crashes on restart in CI")], Language.LEAN4: [pytest.mark.lean4, pytest.mark.skipif(_sh.which("lean") is None, reason="Lean is not installed")], + Language.MSL: [pytest.mark.msl], Language.PHP: [pytest.mark.php], Language.PHP_PHPACTOR: [pytest.mark.php], Language.POWERSHELL: [pytest.mark.powershell], diff --git a/test/resources/repos/msl/test_repo/main.mrc b/test/resources/repos/msl/test_repo/main.mrc new file mode 100644 index 00000000..04385ee1 --- /dev/null +++ b/test/resources/repos/msl/test_repo/main.mrc @@ -0,0 +1,38 @@ +; Main game module - tests alias, event, and menu parsing + +alias greet { + msg $chan Hello, $1 $+ ! Welcome aboard. +} + +alias -l calculate.doubloons { + var %base = 100 + var %bonus = $rand(1,50) + return $calc(%base + %bonus) +} + +on *:TEXT:!hello*:#: { + greet $nick +} + +on *:JOIN:#: { + .timer 1 3 greet $nick + var %coins = $format.coins(100) + msg $chan You have %coins +} + +raw 319:*: { + ; Handle channel list numeric + echo -a Channels: $3- +} + +menu channel { + Pirates Game + .Start Game:/start + .End Game:/stop +} + +alias show.player.info { + if ($is.admin($nick)) { + msg $chan $nick is an admin with $format.coins(500) + } +} diff --git a/test/resources/repos/msl/test_repo/utils.mrc b/test/resources/repos/msl/test_repo/utils.mrc new file mode 100644 index 00000000..c7a12266 --- /dev/null +++ b/test/resources/repos/msl/test_repo/utils.mrc @@ -0,0 +1,26 @@ +; Utility aliases and handlers + +alias format.coins { + return $bytes($1,b) doubloons +} + +alias is.admin { + if ($1 isop $chan) return $true + return $false +} + +alias welcome.message { + greet $1 + msg $chan You have $format.coins(100) to start with! +} + +dialog settings { + title "Game Settings" + size -1 -1 200 150 + edit "", 1, 10 10 180 20 + button "Save", 2, 60 40 80 25 +} + +ctcp *:VERSION:*: { + ctcpreply $nick VERSION PiratesIRC v1.0 +} diff --git a/test/serena/test_serena_agent.py b/test/serena/test_serena_agent.py index 01cd29d1..be078eae 100644 --- a/test/serena/test_serena_agent.py +++ b/test/serena/test_serena_agent.py @@ -47,6 +47,7 @@ def serena_config(): Language.CPP_CCLS, Language.HAXE, Language.LEAN4, + Language.MSL, ]: repo_path = get_repo_path(language) if repo_path.exists(): @@ -199,6 +200,7 @@ class TestSerenaAgent: pytest.param(Language.CPP_CCLS, "add", "Function", "b.cpp", marks=pytest.mark.cpp), pytest.param(Language.HAXE, "Main", "Class", "Main.hx", marks=pytest.mark.haxe), pytest.param(Language.LEAN4, "add", "Method", "Helper.lean", marks=pytest.mark.lean4), + pytest.param(Language.MSL, "greet", "Function", "main.mrc", marks=pytest.mark.msl), ], indirect=["serena_agent"], ) @@ -307,6 +309,7 @@ class TestSerenaAgent: marks=pytest.mark.haxe, ), pytest.param(Language.LEAN4, "add", "Helper.lean", "Main.lean", marks=pytest.mark.lean4), + pytest.param(Language.MSL, "format.coins", "utils.mrc", "main.mrc", marks=pytest.mark.msl), ], indirect=["serena_agent"], ) diff --git a/test/solidlsp/dart/test_dart_basic.py b/test/solidlsp/dart/test_dart_basic.py index 0a5b69cf..7dab1fe9 100644 --- a/test/solidlsp/dart/test_dart_basic.py +++ b/test/solidlsp/dart/test_dart_basic.py @@ -375,3 +375,52 @@ class TestDartLanguageServer: f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", pytrace=False, ) + + @pytest.mark.parametrize("language_server", [Language.DART], indirect=True) + def test_symbol_body_contains_full_method(self, language_server: SolidLanguageServer) -> None: + """Test that document symbols return the full method body range, not just the identifier. + + Regression test: when hierarchicalDocumentSymbolSupport was not declared in client + capabilities, the Dart LSP returned SymbolInformation[] (flat format) where + location.range only covered the identifier name (single line), causing find_symbol + with include_body=True to return just the symbol name instead of its implementation. + """ + file_path = os.path.join("lib", "main.dart") + symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + symbol_list = symbols[0] if symbols and isinstance(symbols[0], list) else symbols + + # Find the 'add' method — defined across multiple lines in main.dart: + # int add(int a, int b) { + # final result = a + b; + # _history.add('$a + $b = $result'); + # return result; + # } + add_symbol = None + for sym in symbol_list: + if sym.get("name") == "add": + add_symbol = sym + break + if sym.get("name") == "Calculator" and "children" in sym: + for child in sym["children"]: + if child.get("name") == "add": + add_symbol = child + break + if add_symbol: + break + + assert add_symbol is not None, "Could not find 'add' method symbol in main.dart" + + # The body range must span multiple lines (not just the identifier line). + # With hierarchicalDocumentSymbolSupport declared, the Dart LSP returns + # DocumentSymbol[] where range covers the full method body. + body_start = add_symbol["location"]["range"]["start"]["line"] + body_end = add_symbol["location"]["range"]["end"]["line"] + assert body_end > body_start, ( + f"Expected multi-line body range for 'add' method, got start={body_start}, end={body_end}. " + f"This likely means hierarchicalDocumentSymbolSupport is not declared in client capabilities." + ) + + # The body text must contain the method implementation, not just the name. + if add_symbol.get("body"): + body_text = add_symbol["body"].get_text() + assert "return result" in body_text, f"Expected method body to contain implementation, got: {body_text!r}" diff --git a/test/solidlsp/msl/__init__.py b/test/solidlsp/msl/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test/solidlsp/msl/test_msl_basic.py b/test/solidlsp/msl/test_msl_basic.py new file mode 100644 index 00000000..92a4cd41 --- /dev/null +++ b/test/solidlsp/msl/test_msl_basic.py @@ -0,0 +1,161 @@ +""" +Basic integration tests for the mSL (mIRC Scripting Language) language server. + +Tests validate document symbols, references, and definitions for aliases, events, +raw events, menus, dialogs, and CTCP handlers using the mSL test repository. +""" + +import os + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols + +pytestmark = [pytest.mark.msl] + + +class TestMslDocumentSymbols: + """Test document symbol retrieval for mSL constructs.""" + + @pytest.mark.parametrize("language_server", [Language.MSL], indirect=True) + def test_ls_is_running(self, language_server: SolidLanguageServer) -> None: + """Test that the language server starts successfully.""" + assert language_server.is_running() + + @pytest.mark.parametrize("language_server", [Language.MSL], indirect=True) + def test_document_symbols_main(self, language_server: SolidLanguageServer) -> None: + """Test that document symbols are returned for the main file.""" + doc_symbols = language_server.request_document_symbols("main.mrc") + all_symbols, root_symbols = doc_symbols.get_all_symbols_and_roots() + + symbol_names = [s.get("name") for s in all_symbols if s.get("name")] + assert "greet" in symbol_names, f"greet alias not found. Found: {symbol_names}" + assert "calculate.doubloons" in symbol_names, f"calculate.doubloons alias not found. Found: {symbol_names}" + assert "show.player.info" in symbol_names, f"show.player.info alias not found. Found: {symbol_names}" + + @pytest.mark.parametrize("language_server", [Language.MSL], indirect=True) + def test_document_symbols_events(self, language_server: SolidLanguageServer) -> None: + """Test that event handlers, raw events, and menus are detected in the main file.""" + doc_symbols = language_server.request_document_symbols("main.mrc") + all_symbols, root_symbols = doc_symbols.get_all_symbols_and_roots() + + symbol_names = [s.get("name") for s in all_symbols if s.get("name")] + # Check for on *:TEXT and on *:JOIN events + on_events = [n for n in symbol_names if n.startswith("on ")] + assert len(on_events) >= 2, f"Expected at least 2 event handlers. Found: {on_events}" + # Check for raw event + raw_events = [n for n in symbol_names if n.startswith("raw ")] + assert len(raw_events) >= 1, f"Expected at least 1 raw event handler. Found: {raw_events}" + # Check for menu + menus = [n for n in symbol_names if n.startswith("menu ")] + assert len(menus) >= 1, f"Expected at least 1 menu. Found: {menus}" + + @pytest.mark.parametrize("language_server", [Language.MSL], indirect=True) + def test_document_symbols_utils(self, language_server: SolidLanguageServer) -> None: + """Test that document symbols are returned for the utils file.""" + doc_symbols = language_server.request_document_symbols("utils.mrc") + all_symbols, root_symbols = doc_symbols.get_all_symbols_and_roots() + + symbol_names = [s.get("name") for s in all_symbols if s.get("name")] + assert "format.coins" in symbol_names, f"format.coins alias not found. Found: {symbol_names}" + assert "is.admin" in symbol_names, f"is.admin alias not found. Found: {symbol_names}" + assert "welcome.message" in symbol_names, f"welcome.message alias not found. Found: {symbol_names}" + + @pytest.mark.parametrize("language_server", [Language.MSL], indirect=True) + def test_document_symbols_dialog_and_ctcp(self, language_server: SolidLanguageServer) -> None: + """Test that dialog and CTCP handler definitions are detected.""" + doc_symbols = language_server.request_document_symbols("utils.mrc") + all_symbols, root_symbols = doc_symbols.get_all_symbols_and_roots() + + symbol_names = [s.get("name") for s in all_symbols if s.get("name")] + assert "dialog settings" in symbol_names, f"dialog settings not found. Found: {symbol_names}" + # Check for CTCP handler + ctcp_events = [n for n in symbol_names if n.startswith("ctcp ")] + assert len(ctcp_events) >= 1, f"Expected at least 1 ctcp handler. Found: {ctcp_events}" + + @pytest.mark.parametrize("language_server", [Language.MSL], indirect=True) + def test_find_symbol(self, language_server: SolidLanguageServer) -> None: + """Test that the full symbol tree contains expected symbols from both files.""" + from solidlsp.ls_utils import SymbolUtils + + symbols = language_server.request_full_symbol_tree() + assert SymbolUtils.symbol_tree_contains_name(symbols, "greet"), "greet not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "format.coins"), "format.coins not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "show.player.info"), "show.player.info not found in symbol tree" + + @pytest.mark.parametrize("language_server", [Language.MSL], indirect=True) + def test_bare_symbol_names(self, language_server: SolidLanguageServer) -> None: + """Test that symbol names do not contain unexpected formatting characters.""" + all_symbols = request_all_symbols(language_server) + malformed_symbols = [] + for s in all_symbols: + # mSL symbols can contain periods (e.g., calculate.doubloons) and + # colons/spaces in event names (e.g., "on *:TEXT"), so allow those + if has_malformed_name(s, period_allowed=True, colon_allowed=True, whitespace_allowed=True): + malformed_symbols.append(s) + if malformed_symbols: + pytest.fail( + f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", + pytrace=False, + ) + + @pytest.mark.parametrize("language_server", [Language.MSL], indirect=True) + def test_find_references_within_file(self, language_server: SolidLanguageServer) -> None: + """Test that references to 'greet' are found within main.mrc.""" + file_path = "main.mrc" + all_symbols, _ = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + greet_symbol = next((s for s in all_symbols if s.get("name") == "greet"), None) + assert greet_symbol is not None, "Could not find 'greet' symbol in main.mrc" + + sel_start = greet_symbol["selectionRange"]["start"] + refs = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) + + assert refs, f"Expected non-empty references for greet but got {refs=}" + + actual_locations = [ + { + "uri_suffix": os.path.basename(ref.get("relativePath", ref.get("uri", ""))), + "line": ref["range"]["start"]["line"], + } + for ref in refs + ] + + # greet is called on line 13 (0-indexed) in main.mrc: `greet $nick` + call_site = {"uri_suffix": "main.mrc", "line": 13} + assert call_site in actual_locations, f"Expected reference to greet at line 13 in main.mrc, got {actual_locations}" + + @pytest.mark.parametrize("language_server", [Language.MSL], indirect=True) + def test_find_references_across_files(self, language_server: SolidLanguageServer) -> None: + """Test that references to 'format.coins' are found across main.mrc and utils.mrc.""" + # format.coins is defined in utils.mrc but called in both main.mrc and utils.mrc + file_path = "utils.mrc" + all_symbols, _ = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + fc_symbol = next((s for s in all_symbols if s.get("name") == "format.coins"), None) + assert fc_symbol is not None, "Could not find 'format.coins' symbol in utils.mrc" + + sel_start = fc_symbol["selectionRange"]["start"] + refs = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) + + assert refs, f"Expected non-empty references for format.coins but got {refs=}" + + actual_locations = [ + { + "uri_suffix": os.path.basename(ref.get("relativePath", ref.get("uri", ""))), + "line": ref["range"]["start"]["line"], + } + for ref in refs + ] + + # Verify cross-file: at least one reference is in main.mrc (different file from definition) + main_refs = [loc for loc in actual_locations if loc["uri_suffix"] == "main.mrc"] + assert len(main_refs) >= 1, f"Expected at least 1 reference in main.mrc, got {main_refs}" + + @pytest.mark.parametrize("language_server", [Language.MSL], indirect=True) + def test_workspace_symbol(self, language_server: SolidLanguageServer) -> None: + """Test that workspace symbol search returns results.""" + result = language_server.request_workspace_symbol("greet") + assert result is not None, "Workspace symbol search returned None" + assert len(result) > 0, "Workspace symbol search returned no results" + assert any("greet" in str(s.get("name", "")) for s in result), f"Expected at least one result containing 'greet', got {result}" diff --git a/uv.lock b/uv.lock index b0f37c6c..c89fecb4 100644 --- a/uv.lock +++ b/uv.lock @@ -243,6 +243,19 @@ filecache = [ { name = "filelock" }, ] +[[package]] +name = "cattrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a0/ec/ba18945e7d6e55a58364d9fb2e46049c1c2998b3d805f19b703f14e81057/cattrs-26.1.0.tar.gz", hash = "sha256:fa239e0f0ec0715ba34852ce813986dfed1e12117e209b816ab87401271cdd40", size = 495672, upload-time = "2026-02-18T22:15:19.406Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/56/60547f7801b97c67e97491dc3d9ade9fbccbd0325058fd3dfcb2f5d98d90/cattrs-26.1.0-py3-none-any.whl", hash = "sha256:d1e0804c42639494d469d08d4f26d6b9de9b8ab26b446db7b5f8c2e97f7c3096", size = 73054, upload-time = "2026-02-18T22:15:17.958Z" }, +] + [[package]] name = "certifi" version = "2026.2.25" @@ -1291,6 +1304,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b4/de/88b3be5c31b22333b3ca2f6ff1de4e863d8fe45aaea7485f591970ec1d3e/linkify_it_py-2.1.0-py3-none-any.whl", hash = "sha256:0d252c1594ecba2ecedc444053db5d3a9b7ec1b0dd929c8f1d74dce89f86c05e", size = 19878, upload-time = "2026-03-01T07:48:46.098Z" }, ] +[[package]] +name = "lsprotocol" +version = "2025.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "cattrs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/26/67b84e6ec1402f0e6764ef3d2a0aaf9a79522cc1d37738f4e5bb0b21521a/lsprotocol-2025.0.0.tar.gz", hash = "sha256:e879da2b9301e82cfc3e60d805630487ac2f7ab17492f4f5ba5aaba94fe56c29", size = 74896, upload-time = "2025-06-17T21:30:18.156Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/f0/92f2d609d6642b5f30cb50a885d2bf1483301c69d5786286500d15651ef2/lsprotocol-2025.0.0-py3-none-any.whl", hash = "sha256:f9d78f25221f2a60eaa4a96d3b4ffae011b107537facee61d3da3313880995c7", size = 76250, upload-time = "2025-06-17T21:30:19.455Z" }, +] + [[package]] name = "markdown-it-py" version = "3.0.0" @@ -2143,6 +2169,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e7/d3/c622950d87a2ffd1654208733b5bd1c5645930014abed8f4c0d74863988b/pydata_sphinx_theme-0.15.4-py3-none-any.whl", hash = "sha256:2136ad0e9500d0949f96167e63f3e298620040aea8f9c74621959eda5d4cf8e6", size = 4640157, upload-time = "2024-06-25T19:28:42.383Z" }, ] +[[package]] +name = "pygls" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "cattrs" }, + { name = "lsprotocol" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/2e/7bbe061d175c0baddde8fc9edb908a4c31ba5d9165b8c68e3439c3a9f138/pygls-2.1.1.tar.gz", hash = "sha256:1da03ba9053201bb337dcdd8d121df70feb2a91e1a0dcc74de5da79755b1a201", size = 55091, upload-time = "2026-03-25T11:19:10.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/1a/208293b6c350f5abea6941d5606080d4a492644052504f5312e5de30a902/pygls-2.1.1-py3-none-any.whl", hash = "sha256:510a6dea2476177230c7d851125e5948efdf3fdb9ebfd8543fc434972f8faed4", size = 68975, upload-time = "2026-03-25T11:19:11.374Z" }, +] + [[package]] name = "pygments" version = "2.19.2" @@ -2911,7 +2951,7 @@ wheels = [ [[package]] name = "serena-agent" -version = "1.1.0" +version = "1.1.1" source = { editable = "." } dependencies = [ { name = "anthropic" }, @@ -2924,12 +2964,14 @@ dependencies = [ { name = "fortls" }, { name = "jinja2" }, { name = "joblib" }, + { name = "lsprotocol" }, { name = "mcp" }, { name = "oraios-pywebview" }, { name = "overrides" }, { name = "pathspec" }, { name = "psutil" }, { name = "pydantic" }, + { name = "pygls" }, { name = "pyright" }, { name = "pystray" }, { name = "python-dotenv" }, @@ -2998,6 +3040,7 @@ requires-dist = [ { name = "jinja2", marker = "extra == 'dev'", specifier = "==3.1.6" }, { name = "joblib", specifier = "==1.5.1" }, { name = "jupyter-book", marker = "extra == 'dev'", specifier = "==1.0.4.post1" }, + { name = "lsprotocol", specifier = "==2025.0.0" }, { name = "mcp", specifier = "==1.26.0" }, { name = "mypy", marker = "extra == 'dev'", specifier = "==1.17.0" }, { name = "nbconvert", marker = "extra == 'dev'", specifier = "==7.17.0" }, @@ -3009,6 +3052,7 @@ requires-dist = [ { name = "psutil", specifier = "==7.0.0" }, { name = "pyasn1", marker = "extra == 'dev'", specifier = "==0.6.3" }, { name = "pydantic", specifier = "==2.12.5" }, + { name = "pygls", specifier = "==2.1.1" }, { name = "pyinstrument", marker = "extra == 'dev'", specifier = "==5.1.1" }, { name = "pyjwt", marker = "extra == 'dev'", specifier = "==2.12.0" }, { name = "pyright", specifier = "==1.1.403" },