diff --git a/CHANGELOG.md b/CHANGELOG.md index defdf57b..98f7044b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,12 @@ Status of the `main` branch. Changes prior to the next official version change w (e.g. JDTLS reporting Lombok-generated classes under `target/classes`). Missing paths are now classified by the usual ignore rules, and symbol locations resolving to non-existent files are skipped. - Solidity: fix diagnostics intermittently coming back empty on slow or cold environments (macOS/Windows CI). + - Perl: expose `file_filter` and `ignore_dirs` via `ls_specific_settings["perl"]`, so projects with + non-standard extensions (e.g. `.cgi`, `.psgi`) can make those files visible to Perl::LanguageServer. + Configured extensions are also synced into the Perl source-file matcher (now a cached singleton), + keeping `find_symbol` / symbol indexing consistent with the LS; the matcher is reset on every + language server activation so one project's reconfiguration does not leak into the next. + `FilenameMatcher` gains `add_extensions` / `reset` methods. Defaults are unchanged. #1449 - C/C++ (clangd): improve support and documentation for Unreal Engine 5 projects. - HLSL (`shader-language-server`): pass `--locked` to `cargo install` when building from source on macOS (and in the manual-install instructions), honoring the crate's packaged `Cargo.lock`. diff --git a/docs/02-usage/050_configuration.md b/docs/02-usage/050_configuration.md index ec7e5f95..21813640 100644 --- a/docs/02-usage/050_configuration.md +++ b/docs/02-usage/050_configuration.md @@ -871,6 +871,34 @@ Notes: - Use the FPC compiler driver (`fpc`/`fpc.exe`), not backend compilers like `ppc386.exe`. - These settings are passed as environment variables to the pasls process. +#### Perl + +Serena uses [Perl::LanguageServer](https://metacpan.org/pod/Perl::LanguageServer) for Perl support. Install Perl and the server with `cpanm Perl::LanguageServer`; Linux and macOS only (the server does not run on Windows). + +Perl::LanguageServer only indexes files whose extension is in its `perl.fileFilter` and skips directories listed in `perl.ignoreDirs`. Both are exposed below so projects with non-standard extensions (e.g. `.cgi` / `.psgi` web handlers) can make those files visible (#1449). + +**Configuration:** + +Configure the language server via `ls_specific_settings.perl` in `serena_config.yml`: + +| Setting | Default | Description | +| -------------- | ------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | +| `file_filter` | `[".pm", ".pl", ".t"]` | File extensions (with leading dot) that Perl::LanguageServer should index, e.g. `[".pm", ".pl", ".t", ".cgi"]`. | +| `ignore_dirs` | `[".git", ".svn", "blib", "local", ".carton", "vendor", "_build", "cover_db"]` | Directory names Perl::LanguageServer should skip when indexing. | + +Example configuration: + +```yaml +ls_specific_settings: + perl: + file_filter: [".pm", ".pl", ".t", ".cgi", ".psgi"] + ignore_dirs: [".git", "blib", "local", "vendor", "cover_db"] +``` + +Notes: +- Extensions added via `file_filter` are also synced into Serena's Perl source-file matcher, so `find_symbol` and symbol indexing treat the same files as the language server. Defaults are unchanged when these keys are omitted. +- The matcher is reset on every language server activation, so one project's `file_filter` does not leak into another. + #### PHP (`Intelephense`) Serena uses Intelephense for the `php` language key. diff --git a/src/solidlsp/language_servers/perl_language_server.py b/src/solidlsp/language_servers/perl_language_server.py index 9b785ded..7e3744b6 100644 --- a/src/solidlsp/language_servers/perl_language_server.py +++ b/src/solidlsp/language_servers/perl_language_server.py @@ -12,7 +12,7 @@ from typing import Any from overrides import override from solidlsp.ls import SolidLanguageServer -from solidlsp.ls_config import LanguageServerConfig +from solidlsp.ls_config import Language, LanguageServerConfig from solidlsp.ls_utils import PlatformId, PlatformUtils from solidlsp.lsp_protocol_handler.lsp_types import DidChangeConfigurationParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo @@ -20,15 +20,26 @@ from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) +# Defaults handed to Perl::LanguageServer via its `perl.fileFilter` / `perl.ignoreDirs` settings. +# Exposed for override via `ls_specific_settings["perl"]`. Extensions added via `file_filter` are +# also synced into Language.PERL.get_source_fn_matcher() (a cached singleton) so Serena's symbol +# index and the language server agree on which files are Perl sources (#1449). +_DEFAULT_FILE_FILTER: list[str] = [".pm", ".pl", ".t"] +_DEFAULT_IGNORE_DIRS: list[str] = [".git", ".svn", "blib", "local", ".carton", "vendor", "_build", "cover_db"] + class PerlLanguageServer(SolidLanguageServer): """ Provides Perl specific instantiation of the LanguageServer class using Perl::LanguageServer. - """ - # Keep in sync with Language.PERL.get_source_fn_matcher() in solidlsp/ls_config.py: - # extensions missing here are invisible to Perl::LanguageServer's project index. - _FILE_FILTER: list = [".pm", ".pl", ".t"] + You can pass the following entries in ``ls_specific_settings["perl"]``: + - file_filter: List of file extensions (with leading dot) that Perl::LanguageServer should + index, e.g. ``[".pm", ".pl", ".t", ".cgi"]``. Defaults to ``[".pm", ".pl", ".t"]``. + The same extensions are added to Serena's Perl source-file matcher, so ``find_symbol`` + and symbol indexing treat them consistently (see #1449). + - ignore_dirs: Directory names Perl::LanguageServer should skip when indexing. Defaults to + ``[".git", ".svn", "blib", "local", ".carton", "vendor", "_build", "cover_db"]``. + """ @staticmethod def _get_perl_version() -> str | None: @@ -101,6 +112,31 @@ class PerlLanguageServer(SolidLanguageServer): return "perl -MPerl::LanguageServer -e 'Perl::LanguageServer::run'" + @staticmethod + def _resolve_filter_settings(solidlsp_settings: SolidLSPSettings) -> tuple[list[str], list[str]]: + """Resolve the ``fileFilter`` / ``ignoreDirs`` to hand to Perl::LanguageServer. + + Reads optional overrides from ``ls_specific_settings["perl"]`` (keys ``file_filter`` and + ``ignore_dirs``); falls back to the defaults otherwise. Extracted as a pure function so the + configuration plumbing can be unit-tested without starting the language server. + """ + perl_settings = solidlsp_settings.get_ls_specific_settings(Language.PERL) + file_filter = perl_settings.get("file_filter", list(_DEFAULT_FILE_FILTER)) + ignore_dirs = perl_settings.get("ignore_dirs", list(_DEFAULT_IGNORE_DIRS)) + return file_filter, ignore_dirs + + @staticmethod + def _sync_source_fn_matcher(file_filter: list[str]) -> None: + """Keep Serena's Perl source-file matcher in sync with the LS ``fileFilter``. + + ``Language.PERL.get_source_fn_matcher()`` is a ``@cache``d per-language singleton, so adding + the configured extensions here propagates to every consumer of the matcher — symbol index + traversal (``project.is_ignored_path``), the LS ignore check (``ls.is_ignored_path``), and + language composition detection. Without this, ``find_symbol`` would not surface symbols in + files whose extensions were added to ``file_filter`` (#1449). + """ + Language.PERL.get_source_fn_matcher().add_extensions(*file_filter) + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): # Setup runtime dependencies before initializing perl_ls_cmd = self._setup_runtime_dependencies() @@ -109,6 +145,10 @@ class PerlLanguageServer(SolidLanguageServer): config, repository_root_path, ProcessLaunchInfo(cmd=perl_ls_cmd, cwd=repository_root_path), "perl", solidlsp_settings ) self.request_id = 0 + self._file_filter, self._ignore_dirs = self._resolve_filter_settings(solidlsp_settings) + # Sync Serena's source-file matcher with the configured extensions so find_symbol and the + # language server agree on which files are Perl sources (see #1449). + self._sync_source_fn_matcher(self._file_filter) def _create_base_initialize_params(self) -> dict: """ @@ -153,8 +193,8 @@ class PerlLanguageServer(SolidLanguageServer): perl_config = { "perlInc": [self.repository_root_path, "."], - "fileFilter": self._FILE_FILTER, - "ignoreDirs": [".git", ".svn", "blib", "local", ".carton", "vendor", "_build", "cover_db"], + "fileFilter": self._file_filter, + "ignoreDirs": self._ignore_dirs, } return [perl_config] @@ -189,8 +229,8 @@ class PerlLanguageServer(SolidLanguageServer): "settings": { "perl": { "perlInc": [self.repository_root_path, "."], - "fileFilter": self._FILE_FILTER, - "ignoreDirs": [".git", ".svn", "blib", "local", ".carton", "vendor", "_build", "cover_db"], + "fileFilter": self._file_filter, + "ignoreDirs": self._ignore_dirs, } } } diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index 57e44489..f296fee6 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -699,6 +699,11 @@ class SolidLanguageServer(ABC): """ identifies the language server (not to be confused with the language id passed to the language server) """ + # The source filename matcher is a @cache'd per-language singleton. A previous project may + # have extended it (e.g. Perl's file_filter adding .cgi); reset it here so every activation + # starts from the language's default extensions, then language-server subclasses re-apply + # their own settings during the rest of __init__. + self.language.get_source_fn_matcher().reset() self._published_diagnostics: dict[str, list[ls_types.Diagnostic]] = {} self._published_diagnostics_generation_by_uri: dict[str, int] = {} self._published_diagnostics_generation = 0 diff --git a/src/solidlsp/ls_config.py b/src/solidlsp/ls_config.py index c1e02d7e..8d27c6e3 100644 --- a/src/solidlsp/ls_config.py +++ b/src/solidlsp/ls_config.py @@ -26,6 +26,38 @@ class FilenameMatcher: """ self._file_extensions = list(set(file_extensions)) if case_sensitive else list(set(ext.lower() for ext in file_extensions)) self._case_sensitive = case_sensitive + # Snapshot of the initial configuration, used by ``reset``. Relevant for matchers that are + # per-language singletons (``Language.get_source_fn_matcher`` is ``@cache``d): extensions added + # via ``add_extensions`` for one project must not leak into the next, so the singleton is reset + # to this snapshot at every language server initialisation. + self._initial_file_extensions = list(self._file_extensions) + + def reset(self) -> None: + """ + Restore the matcher to its initial set of extensions (as provided at construction). + + Undoes any extensions added via :meth:`add_extensions`. Intended for the per-language + singleton matchers, which are reset at the start of every language server initialisation so + that a previous project's reconfiguration does not leak into a newly activated one. + """ + self._file_extensions = list(self._initial_file_extensions) + + def add_extensions(self, *file_extensions: str) -> None: + """ + Add further file extensions to this matcher (idempotent). + + This is intended for matchers that are per-language singletons, i.e. those returned by + :meth:`Language.get_source_fn_matcher` (which is ``@cache``d): extensions that a user + configures for a language server (e.g. ``.cgi`` for Perl) can be added here so that every + consumer of the matcher — symbol index traversal, ignore checks, language composition — + treats the same set of files as sources, staying in sync with the language server. + + :param file_extensions: the additional file extensions, e.g. ``.cgi`` + """ + for ext in file_extensions: + norm = ext if self._case_sensitive else ext.lower() + if norm not in self._file_extensions: + self._file_extensions.append(norm) def is_relevant_filename(self, fn: str) -> bool: if not self._case_sensitive: @@ -311,6 +343,7 @@ class Language(str, Enum): """ return self.get_ls_class().supports_implementation_request() + # NOTE: Caching results in a singleton per enum item, which is a precondition for persistent configuration of the matcher. @cache def get_source_fn_matcher(self) -> FilenameMatcher: match self: diff --git a/test/solidlsp/perl/__init__.py b/test/solidlsp/perl/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test/solidlsp/perl/test_perl_config.py b/test/solidlsp/perl/test_perl_config.py new file mode 100644 index 00000000..3b954d9d --- /dev/null +++ b/test/solidlsp/perl/test_perl_config.py @@ -0,0 +1,220 @@ +"""Configuration plumbing for ``PerlLanguageServer``'s fileFilter / ignoreDirs. + +``Perl::LanguageServer`` only indexes files whose extension is in ``perl.fileFilter`` and skips +directories listed in ``perl.ignoreDirs``; both are pushed to the LS at startup (see +``_start_server``). The defaults must stay backward-compatible, but projects with non-standard +layouts — e.g. ``.cgi`` / ``.psgi`` Perl web handlers that can dominate a mature codebase — need +a way to extend visibility (#1449). + +These tests pin the configuration plumbing without starting the language server (which would +require a Perl runtime), so they run in every environment. They also cover the source-file matcher +sync that keeps ``find_symbol`` consistent with the LS (#1449). +""" + +from pathlib import Path + +from solidlsp.language_servers.perl_language_server import ( + _DEFAULT_FILE_FILTER, + _DEFAULT_IGNORE_DIRS, + PerlLanguageServer, +) +from solidlsp.ls_config import FilenameMatcher, Language +from solidlsp.settings import SolidLSPSettings + + +def _settings(tmp_path: Path, ls_specific_settings: dict | None = None) -> SolidLSPSettings: + """A SolidLSPSettings rooted under ``tmp_path`` with optional perl overrides.""" + return SolidLSPSettings( + solidlsp_dir=str(tmp_path / ".solidlsp"), + ls_specific_settings=ls_specific_settings or {}, + ) + + +class TestResolveFilterSettings: + def test_defaults_when_no_ls_specific_settings(self, tmp_path: Path) -> None: + # Behaviour unchanged for projects that don't set ls_specific_settings at all. + file_filter, ignore_dirs = PerlLanguageServer._resolve_filter_settings(_settings(tmp_path)) + + assert file_filter == _DEFAULT_FILE_FILTER + assert ignore_dirs == _DEFAULT_IGNORE_DIRS + + def test_defaults_when_perl_key_absent(self, tmp_path: Path) -> None: + # ls_specific_settings configured for another language must not leak into Perl. + settings = _settings(tmp_path, {Language.PYTHON: {"something": "unrelated"}}) + + file_filter, ignore_dirs = PerlLanguageServer._resolve_filter_settings(settings) + + assert file_filter == _DEFAULT_FILE_FILTER + assert ignore_dirs == _DEFAULT_IGNORE_DIRS + + def test_custom_file_filter_makes_extra_extensions_visible(self, tmp_path: Path) -> None: + # #1449: a Perl web backend must be able to surface .cgi / .psgi handlers. + custom = [".pm", ".pl", ".t", ".cgi", ".psgi"] + settings = _settings(tmp_path, {Language.PERL: {"file_filter": custom}}) + + file_filter, _ = PerlLanguageServer._resolve_filter_settings(settings) + + assert file_filter == custom + assert ".cgi" in file_filter + + def test_custom_ignore_dirs(self, tmp_path: Path) -> None: + custom = [".git", "blib", "local", "cover_db", "t"] + settings = _settings(tmp_path, {Language.PERL: {"ignore_dirs": custom}}) + + _, ignore_dirs = PerlLanguageServer._resolve_filter_settings(settings) + + assert ignore_dirs == custom + + def test_both_overrides_apply_independently(self, tmp_path: Path) -> None: + file_filter = [".pm", ".pl", ".cgi"] + ignore_dirs = [".git", "vendor"] + settings = _settings( + tmp_path, + {Language.PERL: {"file_filter": file_filter, "ignore_dirs": ignore_dirs}}, + ) + + resolved_filter, resolved_dirs = PerlLanguageServer._resolve_filter_settings(settings) + + assert resolved_filter == file_filter + assert resolved_dirs == ignore_dirs + + def test_default_lists_are_not_mutated_across_instances(self, tmp_path: Path) -> None: + # The resolver must copy the module-level defaults, otherwise one instance mutating its + # returned list (or the LS handlers appending to self._file_filter) would corrupt every + # subsequently created default-configured instance. + file_filter, _ = PerlLanguageServer._resolve_filter_settings(_settings(tmp_path)) + file_filter.append(".cgi") + + next_filter, _ = PerlLanguageServer._resolve_filter_settings(_settings(tmp_path)) + + assert next_filter == _DEFAULT_FILE_FILTER + assert ".cgi" not in _DEFAULT_FILE_FILTER + + +class TestFilenameMatcherAddExtensions: + def test_adds_new_extension(self) -> None: + matcher = FilenameMatcher(".pm", ".pl") + + matcher.add_extensions(".cgi") + + assert matcher.is_relevant_filename("lib/Foo.pm") + assert matcher.is_relevant_filename("web/handler.cgi") + + def test_is_idempotent(self) -> None: + # Re-adding an existing extension must not duplicate it; calling sync twice (e.g. across + # two PerlLanguageServer instances) must be a no-op. + matcher = FilenameMatcher(".pm", ".pl") + baseline = len(matcher._file_extensions) + + matcher.add_extensions(".pm", ".pl") + matcher.add_extensions(".cgi") + matcher.add_extensions(".cgi") + + assert len(matcher._file_extensions) == baseline + 1 + assert matcher.is_relevant_filename("foo.cgi") + + def test_respects_case_insensitivity(self) -> None: + matcher = FilenameMatcher(".pm", case_sensitive=False) + + matcher.add_extensions(".CGI") + + # case-insensitive matcher normalises to lower case + assert matcher.is_relevant_filename("Handler.Cgi") + assert ".cgi" in matcher._file_extensions + assert ".CGI" not in matcher._file_extensions + + +class TestFilenameMatcherReset: + def test_reset_undoes_added_extensions(self) -> None: + matcher = FilenameMatcher(".pm", ".pl") + + matcher.add_extensions(".cgi", ".psgi") + assert matcher.is_relevant_filename("h.cgi") + + matcher.reset() + + assert not matcher.is_relevant_filename("h.cgi") + assert not matcher.is_relevant_filename("h.psgi") + assert matcher.is_relevant_filename("h.pm") + + def test_reset_restores_original_after_case_insensitive_add(self) -> None: + matcher = FilenameMatcher(".PM", case_sensitive=False) + + matcher.add_extensions(".cgi") + matcher.reset() + + # original extension survives the round-trip (normalised to lower case at construction) + assert matcher._file_extensions == [".pm"] + assert not matcher.is_relevant_filename("h.cgi") + + def test_reset_is_idempotent(self) -> None: + # SolidLanguageServer.__init__ calls reset on every activation; repeated resets must be safe + # and must not shrink below the original configuration. + matcher = FilenameMatcher(".pm", ".pl") + baseline = len(matcher._file_extensions) + + matcher.add_extensions(".cgi") + matcher.reset() + matcher.reset() + + assert len(matcher._file_extensions) == baseline + + +class TestSourceFnMatcherSync: + def test_custom_file_filter_extends_perl_matcher(self, tmp_path: Path) -> None: + # #1449: find_symbol relies on Language.PERL.get_source_fn_matcher(); unless the configured + # extensions are synced into it, symbols in .cgi/.psgi files stay invisible even though the + # LS indexes them. get_source_fn_matcher() is a @cache singleton, so reset() afterwards. + matcher = Language.PERL.get_source_fn_matcher() + try: + assert not matcher.is_relevant_filename("handler.cgi") # guard: not matched by default + + file_filter, _ = PerlLanguageServer._resolve_filter_settings( + _settings(tmp_path, {Language.PERL: {"file_filter": [".pm", ".pl", ".t", ".cgi", ".psgi"]}}) + ) + PerlLanguageServer._sync_source_fn_matcher(file_filter) + + assert matcher.is_relevant_filename("lib/Foo.pm") + assert matcher.is_relevant_filename("web/handler.cgi") + assert matcher.is_relevant_filename("app.psgi") + finally: + matcher.reset() + + def test_default_file_filter_leaves_matcher_unchanged(self, tmp_path: Path) -> None: + # The default file_filter matches the existing Perl matcher extensions, so syncing it must + # be a no-op (no duplicate entries, no new matches). + matcher = Language.PERL.get_source_fn_matcher() + try: + initial = list(matcher._file_extensions) + file_filter, _ = PerlLanguageServer._resolve_filter_settings(_settings(tmp_path)) + PerlLanguageServer._sync_source_fn_matcher(file_filter) + + assert sorted(matcher._file_extensions) == sorted(initial) + finally: + matcher.reset() + + def test_reset_prevents_cross_project_leak(self, tmp_path: Path) -> None: + # The matcher is a per-language singleton shared across projects. Project A reconfigures + # file_filter (adds .cgi); when project B is activated, SolidLanguageServer.__init__ resets + # the matcher first, so project B must NOT see .cgi even though project A added it. + # Mirrors the reset-then-sync ordering of PerlLanguageServer.__init__. + matcher = Language.PERL.get_source_fn_matcher() + try: + # project A: custom file_filter with .cgi + filter_a, _ = PerlLanguageServer._resolve_filter_settings( + _settings(tmp_path, {Language.PERL: {"file_filter": [".pm", ".pl", ".t", ".cgi"]}}) + ) + PerlLanguageServer._sync_source_fn_matcher(filter_a) + assert matcher.is_relevant_filename("handler.cgi") + + # project B activates: base __init__ resets, then default config is applied (no .cgi) + matcher.reset() + filter_b, _ = PerlLanguageServer._resolve_filter_settings(_settings(tmp_path / "proj_b")) + PerlLanguageServer._sync_source_fn_matcher(filter_b) + + assert not matcher.is_relevant_filename("handler.cgi"), ( + "project B inherited project A's .cgi — reset did not undo the reconfiguration" + ) + assert matcher.is_relevant_filename("lib/Foo.pm") + finally: + matcher.reset()