added support for Perl via Perl::LanguageServer (#656)

This commit is contained in:
Ryuhei Nakano authored and GitHub committed 2025-10-12 16:07:34 +02:00
1 parent dd239b9f98
commit 9b622bcfd5
11 files changed
+368 -2

No files matched your search

+17
View File
@@ -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
+1
View File
@@ -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)
+1 -1
View File
@@ -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:**
+1
View File
@@ -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)
+1
View File
@@ -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",
@@ -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)
+7
View File
@@ -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
+8 -1
View File
@@ -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"])
@@ -0,0 +1,9 @@
#!/usr/bin/env perl
use strict;
use warnings;
sub helper_function {
print "Helper function was called.\n";
}
1;
@@ -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();
}
+76
View File
@@ -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}"