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)"