mirror of
https://github.com/tiennm99/serena.git
synced 2026-10-12 16:15:33 +00:00
added support for Perl via Perl::LanguageServer (#656)
This commit is contained in:
1 parent
dd239b9f98
commit
9b622bcfd5
11 files changed
+368
-2
No files matched your search
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:**
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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}"
|
||||
Reference in new issue
Block a user