diff --git a/CHANGELOG.md b/CHANGELOG.md index be4d3c2d..bb3c8397 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,8 @@ Status of the `main` branch. Changes prior to the next official version change will appear here. * General: - - Added `serena init` command + - Added `serena init` command + - Added Crystal language support (uses [Crystalline](https://github.com/elbywan/crystalline) language server) * Dashboard: - Add version display diff --git a/README.md b/README.md index bea385e7..b63f3e78 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ Serena incorporates a powerful abstraction layer for the integration of language The underlying language servers are typically open-source projects or at least freely available for use. When using Serena's language server backend, we provide **support for over 40 programming languages**, including -AL, Ansible, Bash, C#, C/C++, Clojure, Dart, Elixir, Elm, Erlang, Fortran, F#, GLSL, Go, Groovy, Haskell, HLSL, Java, JavaScript, Julia, Kotlin, Lean 4, Lua, Luau, Markdown, MATLAB, Nix, OCaml, Perl, PHP, PowerShell, Python, R, Ruby, Rust, Scala, Solidity, Swift, TOML, TypeScript, WGSL, YAML, and Zig. +AL, Ansible, Bash, C#, C/C++, Clojure, Crystal, Dart, Elixir, Elm, Erlang, Fortran, F#, GLSL, Go, Groovy, Haskell, HLSL, Java, JavaScript, Julia, Kotlin, Lean 4, Lua, Luau, Markdown, MATLAB, Nix, OCaml, Perl, PHP, PowerShell, Python, R, Ruby, Rust, Scala, Solidity, Swift, TOML, TypeScript, WGSL, YAML, and Zig. ### The Serena JetBrains Plugin diff --git a/docs/01-about/020_programming-languages.md b/docs/01-about/020_programming-languages.md index 5e74e71d..65abbd47 100644 --- a/docs/01-about/020_programming-languages.md +++ b/docs/01-about/020_programming-languages.md @@ -44,6 +44,9 @@ Some languages require additional installations or setup steps, as noted. for best results, provide a `compile_commands.json` at the repository root; see the [C/C++ Setup Guide](../03-special-guides/cpp_setup) for details.) * **Clojure** +* **Crystal** + (requires [Crystalline](https://github.com/elbywan/crystalline) language server to be installed and available on PATH; + note: Crystalline has limited go-to-definition support and does not support find-references) * **Dart** * **Elixir** (requires Elixir installation; Expert language server is downloaded automatically) diff --git a/pyproject.toml b/pyproject.toml index 0cc71524..66e4cf76 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -297,6 +297,7 @@ max-complexity = 20 addopts = "--snapshot-patch-pycharm-diff" markers = [ "clojure: language server running for Clojure", + "crystal: language server running for Crystal", "python: language server running for Python", "go: language server running for Go", "java: language server running for Java", diff --git a/src/serena/mcp.py b/src/serena/mcp.py index 75325668..a8252846 100644 --- a/src/serena/mcp.py +++ b/src/serena/mcp.py @@ -332,7 +332,14 @@ class SerenaMCPFactory: # retain only FASTMCP_ prefix for already set environment variables. Settings.model_config = SettingsConfigDict(env_prefix="FASTMCP_") instructions = self._get_initial_instructions() - mcp = FastMCP(lifespan=self.server_lifespan, host=host, port=port, instructions=instructions) + mcp = FastMCP( + name="Serena", + lifespan=self.server_lifespan, + website_url="https://oraios.github.io/serena", + host=host, + port=port, + instructions=instructions, + ) return mcp @asynccontextmanager diff --git a/src/serena/resources/dashboard/dashboard.js b/src/serena/resources/dashboard/dashboard.js index 784d8238..df53868e 100644 --- a/src/serena/resources/dashboard/dashboard.js +++ b/src/serena/resources/dashboard/dashboard.js @@ -704,10 +704,6 @@ class Dashboard { html += '
File Encoding:
'; html += '
' + (config.encoding || 'N/A') + '
'; - // Current Client info - html += '
Current Client:
'; - html += '
' + (config.current_client || 'None') + '
'; - html += ''; // Active tools - collapsible diff --git a/src/solidlsp/language_servers/common.py b/src/solidlsp/language_servers/common.py index da814f78..20017630 100644 --- a/src/solidlsp/language_servers/common.py +++ b/src/solidlsp/language_servers/common.py @@ -119,6 +119,7 @@ class RuntimeDependencyCollection: shell=True, check=False, cwd=cwd, + stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, **kwargs, diff --git a/src/solidlsp/language_servers/crystal_language_server.py b/src/solidlsp/language_servers/crystal_language_server.py new file mode 100644 index 00000000..75ce704f --- /dev/null +++ b/src/solidlsp/language_servers/crystal_language_server.py @@ -0,0 +1,166 @@ +""" +Provides Crystal specific instantiation of the LanguageServer class using Crystalline. +""" + +import logging +import os +import pathlib +import shutil +import time + +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__) + +# Minimum time (seconds) to wait after initialization before sending +# definition requests. Crystalline needs to compile the project before +# it can resolve definitions. +_MIN_COMPILATION_DELAY = 10 + + +class CrystalLanguageServer(SolidLanguageServer): + """ + Provides Crystal specific instantiation of the LanguageServer class using Crystalline. + + Crystalline is a language server for the Crystal programming language, + implementing the Language Server Protocol. It must be installed separately; + see https://github.com/elbywan/crystalline for installation instructions. + + Known limitations of Crystalline: + + * Only the first ``textDocument/definition`` request per session returns results. + Subsequent requests return empty. This is a Crystalline issue, not a Serena issue. + * ``textDocument/references`` is not functional (documented as partial support). + * Document symbols work reliably for all requests. + """ + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + crystal_ls_path = self._find_crystalline() + + super().__init__( + config, + repository_root_path, + ProcessLaunchInfo(cmd=crystal_ls_path, cwd=repository_root_path), + "crystal", + solidlsp_settings, + ) + self._initialization_timestamp: float | None = None + + @staticmethod + def _find_crystalline() -> str: + """ + Find the Crystalline executable on PATH. + + :return: path to the Crystalline executable + :raises RuntimeError: if Crystalline is not found + """ + path = shutil.which("crystalline") + if path is None: + raise RuntimeError( + "Crystalline (Crystal language server) is not installed or not in PATH.\n" + "Please install it from https://github.com/elbywan/crystalline\n" + "and make sure the 'crystalline' binary is available on your PATH." + ) + return path + + def _wait_for_compilation(self) -> None: + """ + Wait for Crystalline to finish its initial compilation. + + Crystalline compiles the project on startup using the Crystal compiler. + Definition requests will fail if sent before compilation completes. + """ + if self._initialization_timestamp is None: + return + + elapsed = time.time() - self._initialization_timestamp + remaining_delay = max(0, _MIN_COMPILATION_DELAY - elapsed) + if remaining_delay > 0: + log.info(f"Waiting {remaining_delay:.1f}s for Crystalline to compile the project") + time.sleep(remaining_delay) + + @staticmethod + def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + """ + Return the initialize params for the Crystal language server. + """ + root_uri = pathlib.Path(repository_absolute_path).as_uri() + initialize_params = { + "locale": "en", + "capabilities": { + "textDocument": { + "synchronization": {"didSave": True, "dynamicRegistration": True}, + "definition": {"dynamicRegistration": True, "linkSupport": True}, + "references": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "hierarchicalDocumentSymbolSupport": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + "completion": { + "dynamicRegistration": True, + "completionItem": { + "snippetSupport": True, + "documentationFormat": ["markdown", "plaintext"], + }, + }, + "hover": { + "dynamicRegistration": True, + "contentFormat": ["markdown", "plaintext"], + }, + }, + "workspace": { + "workspaceFolders": True, + "didChangeConfiguration": {"dynamicRegistration": True}, + "configuration": 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[return-value] + + def _start_server(self) -> None: + """Start the Crystal language server process.""" + + def register_capability_handler(_params: dict) -> None: + return + + def window_log_message(msg: dict) -> None: + log.info(f"LSP: window/logMessage: {msg}") + + def do_nothing(_params: dict) -> None: + return + + self.server.on_request("client/registerCapability", register_capability_handler) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + + log.info("Starting Crystal language server (Crystalline) process") + self.server.start() + initialize_params = self._get_initialize_params(self.repository_root_path) + + log.info("Sending initialize request from LSP client to LSP server and awaiting response") + init_response = self.server.send.initialize(initialize_params) + + # verify server capabilities + capabilities = init_response["capabilities"] + log.info(f"Crystal language server capabilities: {list(capabilities.keys())}") + assert "textDocumentSync" in capabilities, "textDocumentSync capability missing" + + self.server.notify.initialized({}) + + # record initialization timestamp for compilation delay calculation + self._initialization_timestamp = time.time() diff --git a/src/solidlsp/ls_config.py b/src/solidlsp/ls_config.py index a9f97ed3..4ec425c1 100644 --- a/src/solidlsp/ls_config.py +++ b/src/solidlsp/ls_config.py @@ -51,6 +51,7 @@ class Language(str, Enum): TERRAFORM = "terraform" SWIFT = "swift" BASH = "bash" + CRYSTAL = "crystal" ZIG = "zig" LUA = "lua" LUAU = "luau" @@ -236,6 +237,8 @@ class Language(str, Enum): return FilenameMatcher("*.swift") case self.BASH: return FilenameMatcher("*.sh", "*.bash") + case self.CRYSTAL: + return FilenameMatcher("*.cr") case self.YAML: return FilenameMatcher("*.yaml", "*.yml") case self.TOML: @@ -420,6 +423,10 @@ class Language(str, Enum): from solidlsp.language_servers.bash_language_server import BashLanguageServer return BashLanguageServer + case self.CRYSTAL: + from solidlsp.language_servers.crystal_language_server import CrystalLanguageServer + + return CrystalLanguageServer case self.YAML: from solidlsp.language_servers.yaml_language_server import YamlLanguageServer diff --git a/test/resources/repos/crystal/test_repo/shard.yml b/test/resources/repos/crystal/test_repo/shard.yml new file mode 100644 index 00000000..3ac87e1e --- /dev/null +++ b/test/resources/repos/crystal/test_repo/shard.yml @@ -0,0 +1,8 @@ +name: test_repo +version: 0.1.0 + +targets: + test_repo: + main: src/main.cr + +crystal: ">= 1.0.0" diff --git a/test/resources/repos/crystal/test_repo/src/main.cr b/test/resources/repos/crystal/test_repo/src/main.cr new file mode 100644 index 00000000..d50955bd --- /dev/null +++ b/test/resources/repos/crystal/test_repo/src/main.cr @@ -0,0 +1,43 @@ +require "./utils" + +class Calculator + def add(a : Int32, b : Int32) : Int32 + a + b + end + + def multiply(a : Int32, b : Int32) : Int32 + a * b + end +end + +struct User + getter name : String + getter age : Int32 + + def initialize(@name : String, @age : Int32) + end + + def greet : String + "Hello, my name is #{name} and I am #{age} years old." + end + + def adult? : Bool + age >= 18 + end +end + +module Status + Active = 0 + Inactive = 1 + Pending = 2 +end + +calculator = Calculator.new +result = calculator.add(5, 3) +puts "Result: #{result}" + +user = User.new("Alice", 30) +puts user.greet + +area = Utils.calculate_area(5.0) +puts "Circle area: #{area}" diff --git a/test/resources/repos/crystal/test_repo/src/utils.cr b/test/resources/repos/crystal/test_repo/src/utils.cr new file mode 100644 index 00000000..68164250 --- /dev/null +++ b/test/resources/repos/crystal/test_repo/src/utils.cr @@ -0,0 +1,9 @@ +module Utils + def self.format_date(time : Time) : String + time.to_s("%Y-%m-%d") + end + + def self.calculate_area(radius : Float64) : Float64 + Math::PI * radius * radius + end +end diff --git a/test/solidlsp/crystal/test_crystal_basic.py b/test/solidlsp/crystal/test_crystal_basic.py new file mode 100644 index 00000000..71102949 --- /dev/null +++ b/test/solidlsp/crystal/test_crystal_basic.py @@ -0,0 +1,104 @@ +""" +Basic integration tests for the Crystal language server (Crystalline) functionality. + +These tests validate document symbols, go-to-definition, and find-references +using the Crystal test repository. + +Known Crystalline limitations: +- Only the first textDocument/definition request per server session returns results. +- textDocument/references is not functional (documented as partial support upstream). +""" + +import os +import shutil + +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.crystal, + pytest.mark.skipif(shutil.which("crystalline") is None, reason="Crystalline is not installed"), +] + + +class TestCrystalDocumentSymbols: + """Test document symbol retrieval, which works reliably in Crystalline.""" + + @pytest.mark.parametrize("language_server", [Language.CRYSTAL], 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.CRYSTAL], indirect=True) + def test_document_symbols_main(self, language_server: SolidLanguageServer) -> None: + """Test that document symbols are returned for the main file.""" + file_path = os.path.join("src", "main.cr") + doc_symbols = language_server.request_document_symbols(file_path) + 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 "Calculator" in symbol_names, f"Calculator not found in symbols. Found: {symbol_names}" + assert "User" in symbol_names, f"User not found in symbols. Found: {symbol_names}" + + @pytest.mark.parametrize("language_server", [Language.CRYSTAL], indirect=True) + def test_document_symbols_utils(self, language_server: SolidLanguageServer) -> None: + """Test that document symbols are returned for the utils file.""" + file_path = os.path.join("src", "utils.cr") + doc_symbols = language_server.request_document_symbols(file_path) + 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 "Utils" in symbol_names, f"Utils not found in symbols. Found: {symbol_names}" + + @pytest.mark.parametrize("language_server", [Language.CRYSTAL], indirect=True) + def test_find_symbol(self, language_server: SolidLanguageServer) -> None: + """Test that the full symbol tree contains expected symbols.""" + from solidlsp.ls_utils import SymbolUtils + + symbols = language_server.request_full_symbol_tree() + assert SymbolUtils.symbol_tree_contains_name(symbols, "Calculator"), "Calculator not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "User"), "User not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "Utils"), "Utils not found in symbol tree" + + @pytest.mark.parametrize("language_server", [Language.CRYSTAL], 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: + if has_malformed_name(s): + 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, + ) + + +class TestCrystalDefinition: + """Test go-to-definition. + + Crystalline only supports one definition request per server session, so + only a single test is included. A separate test class with its own + module-scoped ``language_server`` fixture ensures we get a fresh server. + """ + + @pytest.mark.parametrize("language_server", [Language.CRYSTAL], indirect=True) + def test_goto_definition_within_file(self, language_server: SolidLanguageServer) -> None: + """Test goto_definition for a symbol defined within the same file.""" + file_path = os.path.join("src", "main.cr") + + # wait for Crystalline to compile the project + language_server.language_server._wait_for_compilation() + + # Calculator.new on line 35 (0-indexed: 34), col 13 -> Calculator class on line 3 (0-indexed: 2) + definitions = language_server.request_definition(file_path, 34, 13) + assert isinstance(definitions, list), "Definitions should be a list" + assert len(definitions) > 0, "Should find definition for Calculator" + + calculator_def = definitions[0] + assert calculator_def.get("uri", "").endswith("main.cr"), "Definition should be in main.cr" + assert calculator_def["range"]["start"]["line"] == 2, "Calculator class should be defined at line 3 (0-indexed: 2)"