mirror of
https://github.com/tiennm99/serena.git
synced 2026-10-13 10:16:00 +00:00
feat: Add Julia language server support (#691)
Implements JuliaLanguageServer with LanguageServer.jl integration. Includes comprehensive tests and auto-detection of Julia installations. Checks for LanguageServer.jl on startup and installs via Pkg.add if not found. Only runs once on first use.
This commit is contained in:
1 parent
64ac699ac6
commit
fd410a4638
8 files changed
+267
No files matched your search
@@ -147,6 +147,13 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
Rscript -e "install.packages('languageserver', repos='https://cloud.r-project.org')"
|
||||
- name: Set up Julia
|
||||
uses: julia-actions/setup-julia@v2
|
||||
with:
|
||||
version: '1.10'
|
||||
- name: Install Julia LanguageServer
|
||||
shell: bash
|
||||
run: julia -e 'using Pkg; Pkg.add("LanguageServer")'
|
||||
- name: Install Zig
|
||||
uses: goto-bus-stop/setup-zig@v2
|
||||
with:
|
||||
|
||||
@@ -231,6 +231,9 @@ tmp/
|
||||
# Exception: Don't ignore Nix test repository lib directory (contains source code)
|
||||
!/test/resources/repos/nix/test_repo/lib
|
||||
|
||||
# Exception: Don't ignore Julia test repository lib directory (contains source code)
|
||||
!/test/resources/repos/julia/test_repo/lib
|
||||
|
||||
# Swift
|
||||
/test/resources/repos/swift/test_repo/.build
|
||||
/test/resources/repos/swift/test_repo/.swiftpm
|
||||
|
||||
@@ -280,6 +280,7 @@ markers = [
|
||||
"al: language server running for AL (Microsoft Dynamics 365 Business Central)",
|
||||
"rego: language server running for Rego",
|
||||
"markdown: language server running for Markdown",
|
||||
"julia: Julia language server tests",
|
||||
]
|
||||
|
||||
[tool.codespell]
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import logging
|
||||
import os
|
||||
import pathlib
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
from overrides import override
|
||||
|
||||
from solidlsp.ls import SolidLanguageServer
|
||||
from solidlsp.ls_config import LanguageServerConfig
|
||||
from solidlsp.ls_logger import LanguageServerLogger
|
||||
from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams
|
||||
from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo
|
||||
from solidlsp.settings import SolidLSPSettings
|
||||
|
||||
|
||||
class JuliaLanguageServer(SolidLanguageServer):
|
||||
"""
|
||||
Language server implementation for Julia using LanguageServer.jl.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: LanguageServerConfig,
|
||||
logger: LanguageServerLogger,
|
||||
repository_root_path: str,
|
||||
solidlsp_settings: SolidLSPSettings,
|
||||
):
|
||||
julia_executable = self._setup_runtime_dependency(logger) # PASS LOGGER
|
||||
julia_code = "using LanguageServer; runserver()"
|
||||
|
||||
if platform.system() == "Windows":
|
||||
# On Windows, pass as list (Serena handles shell=True differently)
|
||||
julia_ls_cmd = [julia_executable, "--startup-file=no", "--history-file=no", "-e", julia_code, repository_root_path]
|
||||
else:
|
||||
# On Linux/macOS, build shell-escaped string
|
||||
import shlex
|
||||
|
||||
julia_ls_cmd = (
|
||||
f"{shlex.quote(julia_executable)} "
|
||||
f"--startup-file=no "
|
||||
f"--history-file=no "
|
||||
f"-e {shlex.quote(julia_code)} "
|
||||
f"{shlex.quote(repository_root_path)}"
|
||||
)
|
||||
|
||||
logger.log(f"[JULIA DEBUG] Command: {julia_ls_cmd}", logging.INFO)
|
||||
|
||||
super().__init__(
|
||||
config,
|
||||
logger,
|
||||
repository_root_path,
|
||||
ProcessLaunchInfo(cmd=julia_ls_cmd, cwd=repository_root_path),
|
||||
"julia",
|
||||
solidlsp_settings,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _setup_runtime_dependency(logger: LanguageServerLogger) -> str:
|
||||
"""
|
||||
Check if the Julia runtime is available and return its full path.
|
||||
Raises RuntimeError with a helpful message if the dependency is missing.
|
||||
"""
|
||||
# First check if julia is in PATH
|
||||
julia_path = shutil.which("julia")
|
||||
|
||||
# If not found in PATH, check common installation locations
|
||||
if julia_path is None:
|
||||
common_locations = [
|
||||
os.path.expanduser("~/.juliaup/bin/julia"),
|
||||
os.path.expanduser("~/.julia/bin/julia"),
|
||||
"/usr/local/bin/julia",
|
||||
"/usr/bin/julia",
|
||||
]
|
||||
|
||||
for location in common_locations:
|
||||
if os.path.isfile(location) and os.access(location, os.X_OK):
|
||||
julia_path = location
|
||||
break
|
||||
|
||||
if julia_path is None:
|
||||
raise RuntimeError(
|
||||
"Julia is not installed or not in your PATH. "
|
||||
"Please install Julia from https://julialang.org/downloads/ and ensure it is accessible. "
|
||||
f"Checked locations: {common_locations}"
|
||||
)
|
||||
|
||||
# Check if LanguageServer.jl is installed
|
||||
check_cmd = [julia_path, "-e", "using LanguageServer"]
|
||||
try:
|
||||
result = subprocess.run(check_cmd, check=False, capture_output=True, text=True, timeout=10)
|
||||
if result.returncode != 0:
|
||||
# LanguageServer.jl not found, install it
|
||||
JuliaLanguageServer._install_language_server(julia_path, logger)
|
||||
except subprocess.TimeoutExpired:
|
||||
# Assume it needs installation
|
||||
JuliaLanguageServer._install_language_server(julia_path, logger)
|
||||
|
||||
return julia_path
|
||||
|
||||
@staticmethod
|
||||
def _install_language_server(julia_path: str, logger: LanguageServerLogger) -> None:
|
||||
"""Install LanguageServer.jl package."""
|
||||
logger.log("LanguageServer.jl not found. Installing... (this may take a minute)", logging.INFO)
|
||||
|
||||
install_cmd = [julia_path, "-e", 'using Pkg; Pkg.add("LanguageServer")']
|
||||
|
||||
try:
|
||||
result = subprocess.run(install_cmd, check=False, capture_output=True, text=True, timeout=300) # 5 minutes for installation
|
||||
|
||||
if result.returncode == 0:
|
||||
logger.log("LanguageServer.jl installed successfully!", logging.INFO)
|
||||
else:
|
||||
raise RuntimeError(f"Failed to install LanguageServer.jl: {result.stderr}")
|
||||
except subprocess.TimeoutExpired:
|
||||
raise RuntimeError(
|
||||
"LanguageServer.jl installation timed out. Please install manually: julia -e 'using Pkg; Pkg.add(\"LanguageServer\")'"
|
||||
)
|
||||
|
||||
@override
|
||||
def is_ignored_dirname(self, dirname: str) -> bool:
|
||||
"""Define language-specific directories to ignore for Julia projects."""
|
||||
return super().is_ignored_dirname(dirname) or dirname in [".julia", "build", "dist"]
|
||||
|
||||
def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams:
|
||||
"""
|
||||
Returns the initialize params for the Julia Language Server.
|
||||
"""
|
||||
root_uri = pathlib.Path(repository_absolute_path).as_uri()
|
||||
initialize_params: InitializeParams = { # type: ignore
|
||||
"processId": os.getpid(),
|
||||
"rootPath": repository_absolute_path,
|
||||
"rootUri": root_uri,
|
||||
"capabilities": {
|
||||
"workspace": {"workspaceFolders": True},
|
||||
"textDocument": {
|
||||
"definition": {"dynamicRegistration": True},
|
||||
"references": {"dynamicRegistration": True},
|
||||
"documentSymbol": {"dynamicRegistration": True},
|
||||
},
|
||||
},
|
||||
"workspaceFolders": [
|
||||
{
|
||||
"uri": root_uri,
|
||||
"name": os.path.basename(repository_absolute_path),
|
||||
}
|
||||
],
|
||||
}
|
||||
return initialize_params
|
||||
|
||||
def _start_server(self):
|
||||
"""Start the LanguageServer.jl server process."""
|
||||
|
||||
def do_nothing(params):
|
||||
return
|
||||
|
||||
def window_log_message(msg):
|
||||
self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO)
|
||||
|
||||
self.server.on_notification("window/logMessage", window_log_message)
|
||||
self.server.on_notification("$/progress", do_nothing)
|
||||
self.server.on_notification("textDocument/publishDiagnostics", do_nothing)
|
||||
|
||||
self.logger.log("Starting LanguageServer.jl server process", logging.INFO)
|
||||
self.server.start()
|
||||
|
||||
initialize_params = self._get_initialize_params(self.repository_root_path)
|
||||
self.logger.log(
|
||||
"Sending initialize request to Julia Language Server",
|
||||
logging.INFO,
|
||||
)
|
||||
|
||||
init_response = self.server.send.initialize(initialize_params)
|
||||
assert "definitionProvider" in init_response["capabilities"]
|
||||
assert "referencesProvider" in init_response["capabilities"]
|
||||
assert "documentSymbolProvider" in init_response["capabilities"]
|
||||
|
||||
self.server.notify.initialized({})
|
||||
self.completions_available.set()
|
||||
self.logger.log("Julia Language Server is initialized and ready.", logging.INFO)
|
||||
@@ -57,6 +57,7 @@ class Language(str, Enum):
|
||||
AL = "al"
|
||||
REGO = "rego"
|
||||
SCALA = "scala"
|
||||
JULIA = "julia"
|
||||
# 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"""
|
||||
@@ -155,6 +156,8 @@ class Language(str, Enum):
|
||||
return FilenameMatcher("*.md", "*.markdown")
|
||||
case self.SCALA:
|
||||
return FilenameMatcher("*.scala", "*.sbt")
|
||||
case self.JULIA:
|
||||
return FilenameMatcher("*.jl")
|
||||
case _:
|
||||
raise ValueError(f"Unhandled language: {self}")
|
||||
|
||||
@@ -284,6 +287,10 @@ class Language(str, Enum):
|
||||
from solidlsp.language_servers.scala_language_server import ScalaLanguageServer
|
||||
|
||||
return ScalaLanguageServer
|
||||
case self.JULIA:
|
||||
from solidlsp.language_servers.julia_server import JuliaLanguageServer
|
||||
|
||||
return JuliaLanguageServer
|
||||
case _:
|
||||
raise ValueError(f"Unhandled language: {self}")
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
module Helper
|
||||
function say_hello()
|
||||
println("Hello from the helper module!")
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,13 @@
|
||||
include("lib/helper.jl")
|
||||
|
||||
function calculate_sum(a, b)
|
||||
return a + b
|
||||
end
|
||||
|
||||
function main()
|
||||
result = calculate_sum(5, 3) # A within-file reference
|
||||
println(result)
|
||||
Helper.say_hello() # A cross-file reference
|
||||
end
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,50 @@
|
||||
import pytest
|
||||
|
||||
from solidlsp.ls import SolidLanguageServer
|
||||
from solidlsp.ls_config import Language
|
||||
|
||||
|
||||
@pytest.mark.julia
|
||||
class TestJuliaLanguageServer:
|
||||
@pytest.mark.parametrize("language_server", [Language.JULIA], indirect=True)
|
||||
def test_julia_symbols(self, language_server: SolidLanguageServer):
|
||||
"""
|
||||
Test if we can find the top-level symbols in the main.jl file.
|
||||
"""
|
||||
all_symbols, _ = language_server.request_document_symbols("main.jl")
|
||||
symbol_names = {s["name"] for s in all_symbols}
|
||||
assert "calculate_sum" in symbol_names
|
||||
assert "main" in symbol_names
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.JULIA], indirect=True)
|
||||
def test_julia_within_file_references(self, language_server: SolidLanguageServer):
|
||||
"""
|
||||
Test finding references to a function within the same file.
|
||||
"""
|
||||
# Find references to 'calculate_sum' - the function name starts at line 2, column 9
|
||||
# LSP uses 0-based indexing
|
||||
references = language_server.request_references("main.jl", line=2, column=9)
|
||||
|
||||
# Should find at least the definition and the call site
|
||||
assert len(references) >= 1, f"Expected at least 1 reference, got {len(references)}"
|
||||
|
||||
# Verify at least one reference is in main.jl
|
||||
reference_paths = [ref["relativePath"] for ref in references]
|
||||
assert "main.jl" in reference_paths
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.JULIA], indirect=True)
|
||||
def test_julia_cross_file_references(self, language_server: SolidLanguageServer):
|
||||
"""
|
||||
Test finding references to a function defined in another file.
|
||||
"""
|
||||
# The 'say_hello' function name starts at line 1, column 13 in lib/helper.jl
|
||||
# LSP uses 0-based indexing
|
||||
references = language_server.request_references("lib/helper.jl", line=1, column=13)
|
||||
|
||||
# Should find at least the call site in main.jl
|
||||
assert len(references) >= 1, f"Expected at least 1 reference, got {len(references)}"
|
||||
|
||||
# Verify at least one reference points to the usage
|
||||
reference_paths = [ref["relativePath"] for ref in references]
|
||||
# The reference might be in either file (definition or usage)
|
||||
assert "main.jl" in reference_paths or "lib/helper.jl" in reference_paths
|
||||
Reference in new issue
Block a user