mirror of
https://github.com/tiennm99/serena.git
synced 2026-10-04 20:13:29 +00:00
* feat: add mSL (mIRC Scripting Language) support Add language server support for mIRC Scripting Language (.mrc files). mSL is used in mIRC and AdiIRC IRC clients for scripting bots, games, and automation. The implementation uses a custom Python-based LSP server (pygls) that parses aliases, events, menus, dialogs, and CTCP handlers. Dependencies (pygls, lsprotocol) are installed in an isolated venv on first use. Includes test repo, integration tests, and documentation updates. * style: apply ruff formatting to msl_language_server.py * fix: remove INITIALIZE handler that crashes pygls 2.x pygls 2.x handles the initialize request internally. Overriding it via @server.feature(lsp.INITIALIZE) causes the LSP subprocess to crash. Removing the handler lets pygls auto-advertise capabilities based on registered features (document_symbol, workspace_symbol). * fix: update embedded LSP script for pygls 2.x pygls 2.x moved LanguageServer from pygls.server to pygls.lsp.server. Update import and bump requirement from pygls>=1.3.0 to pygls>=2.0.0. * refactor: ship mSL LSP as package module, not runtime disk write Per maintainer feedback: the mSL LSP is a Python script, so it should ship as a module inside the package rather than being written to disk at runtime. Changes: - Extract embedded LSP script to msl_lsp_server.py (proper module) - Simplify MslLanguageServer: remove venv creation, disk writes, _create_msl_lsp_files(). DependencyProvider now just returns sys.executable and launches the sibling module directly. - Add pygls>=2.0.0 and lsprotocol>=2023.0.0 to pyproject.toml deps * fix: regenerate uv.lock after upstream v1.1.0 merge * feat(msl): add references, definitions, and expand test coverage - Add textDocument/references handler for cross-file alias reference finding - Add textDocument/definition handler for go-to-definition support - Handle $ prefix on cursor position for mSL identifiers - Fix pre-existing mypy error in agent.py (dict[str, object] -> dict[str, str | int]) - Add MSL to _LANGUAGE_PYTEST_MARKERS in conftest.py - Add MSL parametrized cases to test_find_symbol_stable and test_find_symbol_references_stable - Expand test repo with cross-file reference examples (main.mrc <-> utils.mrc) - Add within-file and cross-file reference tests to test_msl_basic.py - Update docs and CHANGELOG to reflect expanded capabilities Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * style: apply ruff formatting to msl_lsp_server.py Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(msl): add hover handler, fix cross-file references via filesystem scan Root causes of 4 CI test failures: - textDocument/hover was not implemented (test_find_symbol_stable needs it) - references/workspace_symbol/definition only searched opened documents, missing files not explicitly opened by the client - pygls.uris.to_fs_path returns lowercase drive letters on Windows, causing URI mismatch with the framework's repository path comparison Fixes: - Add textDocument/hover handler returning definition snippets in Markdown - Add _get_all_mrc_files() to scan workspace filesystem for all .mrc files - Read workspace roots from server.workspace (pygls 2.x API) instead of broken monkey-patching of server.lsp - Use pathlib.Path.resolve() to normalize drive letter casing on Windows - Simplify __main__ to just server.start_io() All 12 tests pass locally (10 solidlsp + 2 serena_agent integration). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * MSL language: removed unnecessary Dep. provider * Changelog * deps --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Michael Panchenko <michael.panchenko@oraios-ai.de>
332 lines
11 KiB
Python
332 lines
11 KiB
Python
import logging
|
|
import os
|
|
import platform
|
|
import shutil as _sh
|
|
from collections.abc import Iterator
|
|
from contextlib import contextmanager
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import pytest
|
|
from _pytest.mark import Mark, MarkDecorator
|
|
from sensai.util.logging import configure
|
|
|
|
from serena.config.serena_config import SerenaConfig, SerenaPaths
|
|
from serena.constants import SERENA_MANAGED_DIR_NAME
|
|
from serena.project import Project
|
|
from serena.util.file_system import GitignoreParser
|
|
from solidlsp.ls import SolidLanguageServer
|
|
from solidlsp.ls_config import Language, LanguageServerConfig
|
|
from solidlsp.settings import SolidLSPSettings
|
|
|
|
from .solidlsp.clojure import is_clojure_cli_available
|
|
|
|
configure(level=logging.INFO)
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def resources_dir() -> Path:
|
|
"""Path to the test resources directory."""
|
|
current_dir = Path(__file__).parent
|
|
return current_dir / "resources"
|
|
|
|
|
|
class LanguageParamRequest:
|
|
param: Language
|
|
|
|
|
|
_LANGUAGE_REPO_ALIASES: dict[Language, Language] = {
|
|
Language.CPP_CCLS: Language.CPP,
|
|
Language.PHP_PHPACTOR: Language.PHP,
|
|
Language.PYTHON_JEDI: Language.PYTHON,
|
|
Language.RUBY_SOLARGRAPH: Language.RUBY,
|
|
Language.PYTHON_TY: Language.PYTHON,
|
|
}
|
|
|
|
|
|
def get_repo_path(language: Language) -> Path:
|
|
repo_language = _LANGUAGE_REPO_ALIASES.get(language, language)
|
|
return Path(__file__).parent / "resources" / "repos" / repo_language / "test_repo"
|
|
|
|
|
|
def _create_ls(
|
|
language: Language,
|
|
repo_path: str | None = None,
|
|
ignored_paths: list[str] | None = None,
|
|
trace_lsp_communication: bool = False,
|
|
ls_specific_settings: dict[Language, dict[str, Any]] | None = None,
|
|
solidlsp_dir: Path | None = None,
|
|
) -> SolidLanguageServer:
|
|
ignored_paths = ignored_paths or []
|
|
if repo_path is None:
|
|
repo_path = str(get_repo_path(language))
|
|
gitignore_parser = GitignoreParser(str(repo_path))
|
|
for spec in gitignore_parser.get_ignore_specs():
|
|
ignored_paths.extend(spec.patterns)
|
|
config = LanguageServerConfig(
|
|
code_language=language,
|
|
ignored_paths=ignored_paths,
|
|
trace_lsp_communication=trace_lsp_communication,
|
|
)
|
|
effective_solidlsp_dir = solidlsp_dir if solidlsp_dir is not None else SerenaPaths().serena_user_home_dir
|
|
project_data_path = os.path.join(repo_path, SERENA_MANAGED_DIR_NAME)
|
|
return SolidLanguageServer.create(
|
|
config,
|
|
repo_path,
|
|
solidlsp_settings=SolidLSPSettings(
|
|
solidlsp_dir=effective_solidlsp_dir,
|
|
project_data_path=project_data_path,
|
|
ls_specific_settings=ls_specific_settings or {},
|
|
),
|
|
)
|
|
|
|
|
|
@contextmanager
|
|
def start_ls_context(
|
|
language: Language,
|
|
repo_path: str | None = None,
|
|
ignored_paths: list[str] | None = None,
|
|
trace_lsp_communication: bool = False,
|
|
ls_specific_settings: dict[Language, dict[str, Any]] | None = None,
|
|
solidlsp_dir: Path | None = None,
|
|
) -> Iterator[SolidLanguageServer]:
|
|
ls = _create_ls(language, repo_path, ignored_paths, trace_lsp_communication, ls_specific_settings, solidlsp_dir)
|
|
log.info(f"Starting language server for {language} {repo_path}")
|
|
ls.start()
|
|
try:
|
|
log.info(f"Language server started for {language} {repo_path}")
|
|
yield ls
|
|
finally:
|
|
log.info(f"Stopping language server for {language} {repo_path}")
|
|
try:
|
|
ls.stop(shutdown_timeout=5)
|
|
except Exception as e:
|
|
log.warning(f"Warning: Error stopping language server: {e}")
|
|
# try to force cleanup
|
|
if hasattr(ls, "server") and hasattr(ls.server, "process"):
|
|
try:
|
|
ls.server.process.terminate()
|
|
except:
|
|
pass
|
|
|
|
|
|
@contextmanager
|
|
def start_default_ls_context(language: Language) -> Iterator[SolidLanguageServer]:
|
|
with start_ls_context(language) as ls:
|
|
yield ls
|
|
|
|
|
|
def create_default_serena_config():
|
|
return SerenaConfig(gui_log_window=False, web_dashboard=False)
|
|
|
|
|
|
def _create_default_project(language: Language, repo_root_override: str | None = None) -> Project:
|
|
repo_path = str(get_repo_path(language)) if repo_root_override is None else repo_root_override
|
|
return Project.load(repo_path, serena_config=create_default_serena_config())
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def repo_path(request: LanguageParamRequest) -> Path:
|
|
"""Get the repository path for a specific language.
|
|
|
|
This fixture requires a language parameter via pytest.mark.parametrize:
|
|
|
|
Example:
|
|
```
|
|
@pytest.mark.parametrize("repo_path", [Language.PYTHON], indirect=True)
|
|
def test_python_repo(repo_path):
|
|
assert (repo_path / "src").exists()
|
|
```
|
|
|
|
"""
|
|
if not hasattr(request, "param"):
|
|
raise ValueError("Language parameter must be provided via pytest.mark.parametrize")
|
|
|
|
language = request.param
|
|
return get_repo_path(language)
|
|
|
|
|
|
# Note: using module scope here to avoid restarting LS for each test function but still terminate between test modules
|
|
@pytest.fixture(scope="module")
|
|
def language_server(request: LanguageParamRequest):
|
|
"""Create a language server instance configured for the specified language.
|
|
|
|
This fixture requires a language parameter via pytest.mark.parametrize:
|
|
|
|
Example:
|
|
```
|
|
@pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True)
|
|
def test_python_server(language_server: SyncLanguageServer) -> None:
|
|
# Use the Python language server
|
|
pass
|
|
```
|
|
|
|
You can also test multiple languages in a single test:
|
|
```
|
|
@pytest.mark.parametrize("language_server", [Language.PYTHON, Language.TYPESCRIPT], indirect=True)
|
|
def test_multiple_languages(language_server: SyncLanguageServer) -> None:
|
|
# This test will run once for each language
|
|
pass
|
|
```
|
|
|
|
"""
|
|
if not hasattr(request, "param"):
|
|
raise ValueError("Language parameter must be provided via pytest.mark.parametrize")
|
|
|
|
language = request.param
|
|
with start_default_ls_context(language) as ls:
|
|
yield ls
|
|
|
|
|
|
@contextmanager
|
|
def project_context(language: Language, repo_root_override: str | None = None) -> Iterator[Project]:
|
|
"""Context manager that creates a Project for the specified language and ensures proper cleanup."""
|
|
project = _create_default_project(language, repo_root_override)
|
|
try:
|
|
yield project
|
|
finally:
|
|
project.shutdown(timeout=5)
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def project(request: LanguageParamRequest, repo_root_override: str | None = None) -> Iterator[Project]:
|
|
"""Create a Project for the specified language.
|
|
|
|
This fixture requires a language parameter via pytest.mark.parametrize:
|
|
|
|
Example:
|
|
```
|
|
@pytest.mark.parametrize("project", [Language.PYTHON], indirect=True)
|
|
def test_python_project(project: Project) -> None:
|
|
# Use the Python project to test something
|
|
pass
|
|
```
|
|
|
|
You can also test multiple languages in a single test:
|
|
```
|
|
@pytest.mark.parametrize("project", [Language.PYTHON, Language.TYPESCRIPT], indirect=True)
|
|
def test_multiple_languages(project: SyncLanguageServer) -> None:
|
|
# This test will run once for each language
|
|
pass
|
|
```
|
|
|
|
"""
|
|
if not hasattr(request, "param"):
|
|
raise ValueError("Language parameter must be provided via pytest.mark.parametrize")
|
|
language = request.param
|
|
with project_context(language, repo_root_override) as project:
|
|
yield project
|
|
|
|
|
|
@contextmanager
|
|
def project_with_ls_context(language: Language, repo_root_override: str | None = None) -> Iterator[Project]:
|
|
"""Context manager that creates a Project with an active language server for the specified language."""
|
|
with project_context(language, repo_root_override) as project:
|
|
project.create_language_server_manager()
|
|
yield project
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def project_with_ls(request: LanguageParamRequest) -> Iterator[Project]:
|
|
if not hasattr(request, "param"):
|
|
raise ValueError("Language parameter must be provided via pytest.mark.parametrize")
|
|
language = request.param
|
|
with project_with_ls_context(language) as project:
|
|
yield project
|
|
|
|
|
|
is_ci = os.getenv("CI") == "true" or os.getenv("GITHUB_ACTIONS") == "true"
|
|
"""
|
|
Flag indicating whether the tests are running in the GitHub CI environment.
|
|
"""
|
|
|
|
is_windows = platform.system() == "Windows"
|
|
|
|
|
|
_LANGUAGE_PYTEST_MARKERS: dict[Language, list[MarkDecorator | Mark]] = {
|
|
Language.CLOJURE: [
|
|
pytest.mark.clojure,
|
|
pytest.mark.skipif(not is_clojure_cli_available(), reason="clojure CLI is not installed"),
|
|
],
|
|
Language.CPP: [pytest.mark.cpp],
|
|
Language.CPP_CCLS: [pytest.mark.cpp],
|
|
Language.CSHARP: [pytest.mark.csharp],
|
|
Language.FSHARP: [pytest.mark.fsharp],
|
|
Language.GO: [pytest.mark.go],
|
|
Language.HAXE: [pytest.mark.haxe],
|
|
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],
|
|
Language.PYTHON: [pytest.mark.python],
|
|
Language.PYTHON_JEDI: [pytest.mark.python],
|
|
Language.PYTHON_TY: [pytest.mark.python],
|
|
Language.RUST: [pytest.mark.rust],
|
|
Language.TYPESCRIPT: [pytest.mark.typescript],
|
|
}
|
|
|
|
|
|
def get_pytest_markers(language: Language) -> list[MarkDecorator | Mark]:
|
|
"""Pytest markers for a language.
|
|
|
|
The returned list contains the primary language marker and any
|
|
environment-dependent skip markers shared across the test suite.
|
|
"""
|
|
return _LANGUAGE_PYTEST_MARKERS[language]
|
|
|
|
|
|
def _determine_disabled_languages() -> list[Language]:
|
|
"""
|
|
Determine which language tests should be disabled (based on the environment)
|
|
|
|
:return: the list of disabled languages
|
|
"""
|
|
result: list[Language] = []
|
|
|
|
java_tests_enabled = True
|
|
if not java_tests_enabled:
|
|
result.append(Language.JAVA)
|
|
|
|
clojure_tests_enabled = is_clojure_cli_available()
|
|
if not clojure_tests_enabled:
|
|
result.append(Language.CLOJURE)
|
|
|
|
# Disable CPP_CCLS tests if ccls is not available
|
|
ccls_tests_enabled = _sh.which("ccls") is not None
|
|
if not ccls_tests_enabled:
|
|
result.append(Language.CPP_CCLS)
|
|
|
|
# Disable CPP (clangd) tests if clangd is not available
|
|
clangd_tests_enabled = _sh.which("clangd") is not None
|
|
if not clangd_tests_enabled:
|
|
result.append(Language.CPP)
|
|
|
|
# Disable PHP_PHPACTOR tests if php is not available
|
|
php_tests_enabled = _sh.which("php") is not None
|
|
if not php_tests_enabled:
|
|
result.append(Language.PHP_PHPACTOR)
|
|
|
|
al_tests_enabled = True
|
|
if not al_tests_enabled:
|
|
result.append(Language.AL)
|
|
|
|
return result
|
|
|
|
|
|
_disabled_languages = _determine_disabled_languages()
|
|
|
|
|
|
def language_tests_enabled(language: Language) -> bool:
|
|
"""
|
|
Check if tests for the given language are enabled in the current environment.
|
|
|
|
:param language: the language to check
|
|
:return: True if tests for the language are enabled, False otherwise
|
|
"""
|
|
return language not in _disabled_languages
|