From 9b622bcfd59827bee8f459fd317a14fb1fa60a42 Mon Sep 17 00:00:00 2001 From: Ryuhei Nakano <57317148+asunaro276@users.noreply.github.com> Date: Sun, 12 Oct 2025 23:07:34 +0900 Subject: [PATCH] added support for Perl via Perl::LanguageServer (#656) --- .github/workflows/pytest.yml | 17 ++ CHANGELOG.md | 1 + CLAUDE.md | 2 +- README.md | 1 + pyproject.toml | 1 + .../language_servers/perl_language_server.py | 225 ++++++++++++++++++ src/solidlsp/ls_config.py | 7 + src/solidlsp/ls_handler.py | 9 +- test/resources/repos/perl/test_repo/helper.pl | 9 + test/resources/repos/perl/test_repo/main.pl | 22 ++ test/solidlsp/perl/test_perl_basic.py | 76 ++++++ 11 files changed, 368 insertions(+), 2 deletions(-) create mode 100644 src/solidlsp/language_servers/perl_language_server.py create mode 100644 test/resources/repos/perl/test_repo/helper.pl create mode 100644 test/resources/repos/perl/test_repo/main.pl create mode 100644 test/solidlsp/perl/test_perl_basic.py diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 0ab82056..e73b76e9 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -213,6 +213,23 @@ jobs: echo "$LUA_LS_DIR/bin" >> $GITHUB_PATH rm lua-ls.zip fi + - name: Install Perl::LanguageServer + if: runner.os != 'Windows' + shell: bash + run: | + if [[ "${{ runner.os }}" == "Linux" ]]; then + sudo apt-get update + sudo apt-get install -y cpanminus build-essential libanyevent-perl libio-aio-perl + elif [[ "${{ runner.os }}" == "macOS" ]]; then + brew install cpanminus + fi + PERL_MM_USE_DEFAULT=1 cpanm --notest --force Perl::LanguageServer + # Set up Perl local::lib environment for subsequent steps + echo "PERL5LIB=$HOME/perl5/lib/perl5${PERL5LIB:+:${PERL5LIB}}" >> $GITHUB_ENV + echo "PERL_LOCAL_LIB_ROOT=$HOME/perl5${PERL_LOCAL_LIB_ROOT:+:${PERL_LOCAL_LIB_ROOT}}" >> $GITHUB_ENV + echo "PERL_MB_OPT=--install_base \"$HOME/perl5\"" >> $GITHUB_ENV + echo "PERL_MM_OPT=INSTALL_BASE=$HOME/perl5" >> $GITHUB_ENV + echo "$HOME/perl5/bin" >> $GITHUB_PATH - name: Install Nix if: runner.os != 'Windows' # Nix doesn't support Windows natively uses: cachix/install-nix-action@v30 diff --git a/CHANGELOG.md b/CHANGELOG.md index cac7b245..11696a9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ Status of the `main` branch. Changes prior to the next official version change will appear here. * Language support: + * **Add support for Perl** via Perl::LanguageServer with LSP integration for .pl, .pm, and .t files * **Add support for AL (Application Language)** for Microsoft Dynamics 365 Business Central development. Requires VS Code AL extension (ms-dynamics-smb.al). * **Add support for R** via the R languageserver package with LSP integration, performance optimizations, and fallback symbol extraction * **Add support for Zig** via ZLS (cross-file references may not fully work on Windows) diff --git a/CLAUDE.md b/CLAUDE.md index 876c16f7..edf91cd1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,7 +13,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co **Test Markers:** Available pytest markers for selective testing: -- `python`, `go`, `java`, `rust`, `typescript`, `php`, `csharp`, `elixir`, `terraform`, `clojure`, `swift`, `bash`, `ruby`, `ruby_solargraph` +- `python`, `go`, `java`, `rust`, `typescript`, `php`, `perl`, `csharp`, `elixir`, `terraform`, `clojure`, `swift`, `bash`, `ruby`, `ruby_solargraph` - `snapshot` - for symbolic editing operation tests **Project Management:** diff --git a/README.md b/README.md index eee4c4df..d1e6c73c 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,7 @@ With Serena, we provide direct, out-of-the-box support for: * Nix (requires nixd installation) * Elixir (requires installation of NextLS and Elixir; **Windows not supported**) * Erlang (requires installation of beam and [erlang_ls](https://github.com/erlang-ls/erlang_ls), experimental, might be slow or hang) + * Perl (requires installation of Perl::LanguageServer) * AL * Markdown (automatically downloads marksman if not installed, experimental, must be explicitly specified via `--language markdown` when generating project config - primarily useful for documentation-heavy projects) diff --git a/pyproject.toml b/pyproject.toml index f82a1f9a..c9147dea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -259,6 +259,7 @@ markers = [ "rust: language server running for Rust", "typescript: language server running for TypeScript", "php: language server running for PHP", + "perl: language server running for Perl", "csharp: language server running for C#", "elixir: language server running for Elixir", "terraform: language server running for Terraform", diff --git a/src/solidlsp/language_servers/perl_language_server.py b/src/solidlsp/language_servers/perl_language_server.py new file mode 100644 index 00000000..0552c834 --- /dev/null +++ b/src/solidlsp/language_servers/perl_language_server.py @@ -0,0 +1,225 @@ +""" +Provides Perl specific instantiation of the LanguageServer class using Perl::LanguageServer. + +Note: Windows is not supported as Nix itself doesn't support Windows natively. +""" + +import logging +import os +import pathlib +import subprocess +import time + +from overrides import override + +from solidlsp.ls import SolidLanguageServer +from solidlsp.ls_config import LanguageServerConfig +from solidlsp.ls_logger import LanguageServerLogger +from solidlsp.ls_utils import PlatformId, PlatformUtils +from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams +from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo +from solidlsp.settings import SolidLSPSettings + + +class PerlLanguageServer(SolidLanguageServer): + """ + Provides Perl specific instantiation of the LanguageServer class using Perl::LanguageServer. + """ + + @staticmethod + def _get_perl_version(): + """Get the installed Perl version or None if not found.""" + try: + result = subprocess.run(["perl", "-v"], capture_output=True, text=True, check=False) + if result.returncode == 0: + return result.stdout.strip() + except FileNotFoundError: + return None + return None + + @staticmethod + def _get_perl_language_server_version(): + """Get the installed Perl::LanguageServer version or None if not found.""" + try: + result = subprocess.run( + ["perl", "-MPerl::LanguageServer", "-e", "print $Perl::LanguageServer::VERSION"], + capture_output=True, + text=True, + check=False, + ) + if result.returncode == 0: + return result.stdout.strip() + except FileNotFoundError: + return None + return None + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + # For Perl projects, we should ignore: + # - blib: build library directory + # - local: local Perl module installation + # - .carton: Carton dependency manager cache + # - vendor: vendored dependencies + # - _build: Module::Build output + return super().is_ignored_dirname(dirname) or dirname in ["blib", "local", ".carton", "vendor", "_build", "cover_db"] + + @classmethod + def _setup_runtime_dependencies(cls) -> str: + """ + Check if required Perl runtime dependencies are available. + Raises RuntimeError with helpful message if dependencies are missing. + """ + platform_id = PlatformUtils.get_platform_id() + + valid_platforms = [ + PlatformId.LINUX_x64, + PlatformId.LINUX_arm64, + PlatformId.OSX, + PlatformId.OSX_x64, + PlatformId.OSX_arm64, + ] + if platform_id not in valid_platforms: + raise RuntimeError(f"Platform {platform_id} is not supported for Perl at the moment") + + perl_version = cls._get_perl_version() + if not perl_version: + raise RuntimeError( + "Perl is not installed. Please install Perl from https://www.perl.org/get.html and make sure it is added to your PATH." + ) + + perl_ls_version = cls._get_perl_language_server_version() + if not perl_ls_version: + raise RuntimeError( + "Found a Perl version but Perl::LanguageServer is not installed.\n" + "Please install Perl::LanguageServer: cpanm Perl::LanguageServer\n" + "See: https://metacpan.org/pod/Perl::LanguageServer" + ) + + return "perl -MPerl::LanguageServer -e 'Perl::LanguageServer::run'" + + def __init__( + self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str, solidlsp_settings: SolidLSPSettings + ): + # Setup runtime dependencies before initializing + perl_ls_cmd = self._setup_runtime_dependencies() + + super().__init__( + config, + logger, + repository_root_path, + ProcessLaunchInfo(cmd=perl_ls_cmd, cwd=repository_root_path), + "perl", + solidlsp_settings, + ) + self.request_id = 0 + + @staticmethod + def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + """ + Returns the initialize params for Perl::LanguageServer. + Based on the expected structure from Perl::LanguageServer::Methods::_rpcreq_initialize. + """ + root_uri = pathlib.Path(repository_absolute_path).as_uri() + initialize_params = { + "processId": os.getpid(), + "rootPath": repository_absolute_path, + "rootUri": root_uri, + "capabilities": { + "textDocument": { + "synchronization": {"didSave": True, "dynamicRegistration": True}, + "definition": {"dynamicRegistration": True}, + "references": {"dynamicRegistration": True}, + "documentSymbol": {"dynamicRegistration": True}, + "hover": {"dynamicRegistration": True}, + }, + "workspace": { + "workspaceFolders": True, + "didChangeConfiguration": {"dynamicRegistration": True}, + "symbol": {"dynamicRegistration": True}, + }, + }, + "initializationOptions": {}, + "workspaceFolders": [ + { + "uri": root_uri, + "name": os.path.basename(repository_absolute_path), + } + ], + } + + return initialize_params + + def _start_server(self): + """Start Perl::LanguageServer process""" + + def register_capability_handler(params): + return + + def window_log_message(msg): + self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) + + def do_nothing(params): + return + + def workspace_configuration_handler(params): + """Handle workspace/configuration request from Perl::LanguageServer.""" + self.logger.log(f"Received workspace/configuration request: {params}", logging.INFO) + + perl_config = { + "perlInc": [self.repository_root_path, "."], + "fileFilter": [".pm", ".pl"], + "ignoreDirs": [".git", ".svn", "blib", "local", ".carton", "vendor", "_build", "cover_db"], + } + + return [perl_config] + + self.server.on_request("client/registerCapability", register_capability_handler) + self.server.on_request("workspace/configuration", workspace_configuration_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) + + self.logger.log("Starting Perl::LanguageServer process", logging.INFO) + self.server.start() + initialize_params = self._get_initialize_params(self.repository_root_path) + + self.logger.log( + "Sending initialize request from LSP client to LSP server and awaiting response", + logging.INFO, + ) + init_response = self.server.send.initialize(initialize_params) + self.logger.log( + "After sent initialize params", + logging.INFO, + ) + + # Verify server capabilities + assert "textDocumentSync" in init_response["capabilities"] + assert "definitionProvider" in init_response["capabilities"] + assert "referencesProvider" in init_response["capabilities"] + + self.server.notify.initialized({}) + + # Send workspace configuration to Perl::LanguageServer + # Perl::LanguageServer requires didChangeConfiguration to set perlInc, fileFilter, and ignoreDirs + # See: Perl::LanguageServer::Methods::workspace::_rpcnot_didChangeConfiguration + perl_config = { + "settings": { + "perl": { + "perlInc": [self.repository_root_path, "."], + "fileFilter": [".pm", ".pl"], + "ignoreDirs": [".git", ".svn", "blib", "local", ".carton", "vendor", "_build", "cover_db"], + } + } + } + self.logger.log(f"Sending workspace/didChangeConfiguration notification with config: {perl_config}", logging.INFO) + self.server.notify.workspace_did_change_configuration(perl_config) + + self.completions_available.set() + + # Perl::LanguageServer needs time to index files and resolve cross-file references + # Without this delay, requests for definitions/references may return empty results + settling_time = 0.5 + self.logger.log(f"Allowing {settling_time} seconds for Perl::LanguageServer to index files...", logging.INFO) + time.sleep(settling_time) + self.logger.log("Perl::LanguageServer settling period complete", logging.INFO) diff --git a/src/solidlsp/ls_config.py b/src/solidlsp/ls_config.py index 11c4e4ff..18f634a7 100644 --- a/src/solidlsp/ls_config.py +++ b/src/solidlsp/ls_config.py @@ -43,6 +43,7 @@ class Language(str, Enum): CPP = "cpp" PHP = "php" R = "r" + PERL = "perl" CLOJURE = "clojure" ELIXIR = "elixir" TERRAFORM = "terraform" @@ -121,6 +122,8 @@ class Language(str, Enum): return FilenameMatcher("*.php") case self.R: return FilenameMatcher("*.R", "*.r", "*.Rmd", "*.Rnw") + case self.PERL: + return FilenameMatcher("*.pl", "*.pm", "*.t") case self.CLOJURE: return FilenameMatcher("*.clj", "*.cljs", "*.cljc", "*.edn") # codespell:ignore edn case self.ELIXIR: @@ -208,6 +211,10 @@ class Language(str, Enum): from solidlsp.language_servers.intelephense import Intelephense return Intelephense + case self.PERL: + from solidlsp.language_servers.perl_language_server import PerlLanguageServer + + return PerlLanguageServer case self.CLOJURE: from solidlsp.language_servers.clojure_lsp import ClojureLSP diff --git a/src/solidlsp/ls_handler.py b/src/solidlsp/ls_handler.py index baa8b860..02363aaf 100644 --- a/src/solidlsp/ls_handler.py +++ b/src/solidlsp/ls_handler.py @@ -517,8 +517,15 @@ class SolidLanguageServerHandler: """ Handle the response received from the server for a request, using the id to determine the request """ + response_id = response["id"] with self._response_handlers_lock: - request = self._pending_requests.pop(response["id"]) + request = self._pending_requests.pop(response_id, None) + if request is None and isinstance(response_id, str) and response_id.isdigit(): + request = self._pending_requests.pop(int(response_id), None) + + if request is None: # need to convert response_id to the right type + log.debug("Request interrupted by user or not found for ID %s", response_id) + return if "result" in response and "error" not in response: request.on_result(response["result"]) diff --git a/test/resources/repos/perl/test_repo/helper.pl b/test/resources/repos/perl/test_repo/helper.pl new file mode 100644 index 00000000..2ee934e6 --- /dev/null +++ b/test/resources/repos/perl/test_repo/helper.pl @@ -0,0 +1,9 @@ +#!/usr/bin/env perl +use strict; +use warnings; + +sub helper_function { + print "Helper function was called.\n"; +} + +1; diff --git a/test/resources/repos/perl/test_repo/main.pl b/test/resources/repos/perl/test_repo/main.pl new file mode 100644 index 00000000..e0e2be7f --- /dev/null +++ b/test/resources/repos/perl/test_repo/main.pl @@ -0,0 +1,22 @@ +#!/usr/bin/env perl +use strict; +use warnings; +use lib '.'; + +require helper; + +sub greet { + my ($name) = @_; + return "Hello, $name!"; +} + +my $user_name = "Perl User"; +my $greeting = greet($user_name); + +print "$greeting\n"; + +helper_function(); + +sub use_helper_function { + helper_function(); +} diff --git a/test/solidlsp/perl/test_perl_basic.py b/test/solidlsp/perl/test_perl_basic.py new file mode 100644 index 00000000..b7b936fb --- /dev/null +++ b/test/solidlsp/perl/test_perl_basic.py @@ -0,0 +1,76 @@ +import platform +from pathlib import Path + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language + + +@pytest.mark.perl +@pytest.mark.skipif(platform.system() == "Windows", reason="Perl::LanguageServer does not support native Windows operation") +class TestPerlLanguageServer: + """ + Tests for Perl::LanguageServer integration. + + Perl::LanguageServer provides comprehensive LSP support for Perl including: + - Document symbols (functions, variables) + - Go to definition (including cross-file) + - Find references (including cross-file) - this was not available in PLS + """ + + @pytest.mark.parametrize("language_server", [Language.PERL], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.PERL], indirect=True) + def test_ls_is_running(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test that the language server starts and stops successfully.""" + # The fixture already handles start and stop + assert language_server.is_running() + assert Path(language_server.language_server.repository_root_path).resolve() == repo_path.resolve() + + @pytest.mark.parametrize("language_server", [Language.PERL], indirect=True) + def test_document_symbols(self, language_server: SolidLanguageServer) -> None: + """Test that document symbols are correctly identified.""" + # Request document symbols + all_symbols, _ = language_server.request_document_symbols("main.pl", include_body=False) + + assert all_symbols, "Expected to find symbols in main.pl" + assert len(all_symbols) > 0, "Expected at least one symbol" + + # DEBUG: Print all symbols + print("\n=== All symbols in main.pl ===") + for s in all_symbols: + line = s.get("range", {}).get("start", {}).get("line", "?") + print(f"Line {line}: {s.get('name')} (kind={s.get('kind')})") + + # Check that we can find function symbols + function_symbols = [s for s in all_symbols if s.get("kind") == 12] # 12 = Function/Method + assert len(function_symbols) >= 2, f"Expected at least 2 functions (greet, use_helper_function), found {len(function_symbols)}" + + function_names = [s.get("name") for s in function_symbols] + assert "greet" in function_names, f"Expected 'greet' function in symbols, found: {function_names}" + assert "use_helper_function" in function_names, f"Expected 'use_helper_function' in symbols, found: {function_names}" + + # @pytest.mark.skip(reason="Perl::LanguageServer cross-file definition tracking needs configuration") + @pytest.mark.parametrize("language_server", [Language.PERL], indirect=True) + def test_find_definition_across_files(self, language_server: SolidLanguageServer) -> None: + definition_location_list = language_server.request_definition("main.pl", 17, 0) + + assert len(definition_location_list) == 1 + definition_location = definition_location_list[0] + print(f"Found definition: {definition_location}") + assert definition_location["uri"].endswith("helper.pl") + assert definition_location["range"]["start"]["line"] == 4 # add method on line 2 (0-indexed 1) + + @pytest.mark.parametrize("language_server", [Language.PERL], indirect=True) + def test_find_references_across_files(self, language_server: SolidLanguageServer) -> None: + """Test finding references to a function across multiple files.""" + reference_locations = language_server.request_references("helper.pl", 4, 5) + + assert len(reference_locations) >= 2, f"Expected at least 2 references to helper_function, found {len(reference_locations)}" + + main_pl_refs = [ref for ref in reference_locations if ref["uri"].endswith("main.pl")] + assert len(main_pl_refs) >= 2, f"Expected at least 2 references in main.pl, found {len(main_pl_refs)}" + + main_pl_lines = sorted([ref["range"]["start"]["line"] for ref in main_pl_refs]) + assert 17 in main_pl_lines, f"Expected reference at line 18 (0-indexed 17), found: {main_pl_lines}" + assert 20 in main_pl_lines, f"Expected reference at line 21 (0-indexed 20), found: {main_pl_lines}"