diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 7936dbc9..6261a79e 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -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: diff --git a/.gitignore b/.gitignore index 3809efdb..32386b39 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/pyproject.toml b/pyproject.toml index b8e62d81..d6d222bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] diff --git a/src/solidlsp/language_servers/julia_server.py b/src/solidlsp/language_servers/julia_server.py new file mode 100644 index 00000000..05a0cbbb --- /dev/null +++ b/src/solidlsp/language_servers/julia_server.py @@ -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) diff --git a/src/solidlsp/ls_config.py b/src/solidlsp/ls_config.py index ca76ae72..8c667cba 100644 --- a/src/solidlsp/ls_config.py +++ b/src/solidlsp/ls_config.py @@ -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}") diff --git a/test/resources/repos/julia/test_repo/lib/helper.jl b/test/resources/repos/julia/test_repo/lib/helper.jl new file mode 100644 index 00000000..c6a7e120 --- /dev/null +++ b/test/resources/repos/julia/test_repo/lib/helper.jl @@ -0,0 +1,5 @@ +module Helper + function say_hello() + println("Hello from the helper module!") + end +end diff --git a/test/resources/repos/julia/test_repo/main.jl b/test/resources/repos/julia/test_repo/main.jl new file mode 100644 index 00000000..fa317977 --- /dev/null +++ b/test/resources/repos/julia/test_repo/main.jl @@ -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() diff --git a/test/solidlsp/julia/test_julia_basic.py b/test/solidlsp/julia/test_julia_basic.py new file mode 100644 index 00000000..e411f3d8 --- /dev/null +++ b/test/solidlsp/julia/test_julia_basic.py @@ -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