From 967472e44bdb551b64ebde11c7bb837ddac74383 Mon Sep 17 00:00:00 2001 From: weiconghe <13976098570@163.com> Date: Fri, 3 Jul 2026 22:49:28 +0800 Subject: [PATCH] Perl: expose file_filter/ignore_dirs via ls_specific_settings (#1449) Perl::LanguageServer only indexes files whose extension is in its `perl.fileFilter` and skips directories in `perl.ignoreDirs`. Both were hardcoded in PerlLanguageServer, so projects whose Perl source uses non-standard extensions (e.g. .cgi / .psgi web handlers, which can dominate a mature codebase) had no way to make those files visible to symbol/reference queries without forking Serena (#1449). Surface both lists through `ls_specific_settings["perl"]` (keys `file_filter` and `ignore_dirs`), defaulting to the existing values so behaviour is unchanged for current projects. The resolution lives in a pure static method `_resolve_filter_settings` so the config plumbing is covered by unit tests that don't require a Perl runtime. Keep Serena's symbol index in sync with the LS (#1449, per review): `find_symbol` and symbol indexing are driven by `Language.PERL.get_source_fn_matcher()`, so adding an extension (e.g. .cgi) to `file_filter` alone would leave the matcher untouched and the extension's symbols invisible. Since that matcher is now a @cache'd per-language singleton, add a `FilenameMatcher.add_extensions()` mutator and call it from `PerlLanguageServer` so the configured extensions also reach the symbol index, the ignore checks, and language composition. Avoid cross-project leakage (per review): because the matcher is a process-wide singleton, extensions added for one project would otherwise persist into the next. `FilenameMatcher` now snapshots its initial configuration and exposes `reset()`, and `SolidLanguageServer.__init__` resets the matcher right after `self.language` is determined, so every activation starts from the language's defaults before the subclass re-applies its own `file_filter`. This complements 860c5841 (which added .t to the default list as a surface-level fix) by addressing the root configurability gap that the maintainer acknowledged in #1449. --- CHANGELOG.md | 6 + docs/02-usage/050_configuration.md | 28 +++ .../language_servers/perl_language_server.py | 58 ++++- src/solidlsp/ls.py | 5 + src/solidlsp/ls_config.py | 32 +++ test/solidlsp/perl/__init__.py | 0 test/solidlsp/perl/test_perl_config.py | 220 ++++++++++++++++++ 7 files changed, 340 insertions(+), 9 deletions(-) create mode 100644 test/solidlsp/perl/__init__.py create mode 100644 test/solidlsp/perl/test_perl_config.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c951222e5..1b1d1666ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,12 @@ Status of the `main` branch. Changes prior to the next official version change w - Make tool call errors surface explicitly as errors at the MCP protocol level * Language Servers: + - 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. - `typescript_vts`: Add `initialization_options` setting in `ls_specific_settings.typescript_vts`. Enables Yarn PnP setups with `typescript.tsdk` pointing at the Yarn-generated SDK. diff --git a/docs/02-usage/050_configuration.md b/docs/02-usage/050_configuration.md index 03f4476154..abf086169e 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 9b785dedd7..7e3744b62f 100644 --- a/src/solidlsp/language_servers/perl_language_server.py +++ b/src/solidlsp/language_servers/perl_language_server.py @@ -12,7 +12,7 @@ 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 @@ 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 @@ def _setup_runtime_dependencies(cls) -> str: 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 @@ def __init__(self, config: LanguageServerConfig, repository_root_path: str, soli 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 @@ def workspace_configuration_handler(params: Any) -> Any: 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 @@ def workspace_configuration_handler(params: Any) -> Any: "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 73e1bfc3f0..9cf0fa77d1 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -699,6 +699,11 @@ def __init__( """ 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 e46106e2f5..8014370cb6 100644 --- a/src/solidlsp/ls_config.py +++ b/src/solidlsp/ls_config.py @@ -26,6 +26,38 @@ def __init__(self, *file_extensions: str, case_sensitive: bool = True) -> None: """ 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: diff --git a/test/solidlsp/perl/__init__.py b/test/solidlsp/perl/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/solidlsp/perl/test_perl_config.py b/test/solidlsp/perl/test_perl_config.py new file mode 100644 index 0000000000..3b954d9d08 --- /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()