From 78b43d2a631a7626f020a484cb7e8455cbaf88d5 Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Mon, 7 Apr 2025 00:54:34 +0200 Subject: [PATCH] LS: support for configuring ignored paths Introduced per-language always ignored paths --- .vscode/settings.json | 1 + src/multilspy/language_server.py | 99 ++++++++++++++----- .../eclipse_jdtls/eclipse_jdtls.py | 20 ++++ src/multilspy/language_servers/gopls/gopls.py | 10 ++ .../jedi_language_server/jedi_server.py | 8 +- .../language_servers/omnisharp/omnisharp.py | 8 +- .../pyright_language_server/pyright_server.py | 9 +- .../rust_analyzer/rust_analyzer.py | 6 ++ .../language_servers/solargraph/solargraph.py | 6 +- .../typescript_language_server.py | 13 ++- src/multilspy/multilspy_config.py | 4 +- .../test_retrieval_with_ignored_dirs.py | 46 +++++++++ test/resources/test_repo/scripts/run_app.py | 2 + 13 files changed, 203 insertions(+), 29 deletions(-) create mode 100644 test/multilspy/test_retrieval_with_ignored_dirs.py diff --git a/.vscode/settings.json b/.vscode/settings.json index 93348cb8..8a290211 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,6 +3,7 @@ "agno", "asyncio", "genai", + "Gopls", "langsrv", "multilspy", "pixi", diff --git a/src/multilspy/language_server.py b/src/multilspy/language_server.py index ae5fa594..9af94fb3 100644 --- a/src/multilspy/language_server.py +++ b/src/multilspy/language_server.py @@ -16,8 +16,10 @@ import pickle import re import threading from collections import defaultdict +from urllib.parse import urlparse from contextlib import asynccontextmanager, contextmanager from copy import copy +from typing import Any, AsyncGenerator, Dict, Generator, List, Optional, Set, Tuple, Union from fnmatch import fnmatch from pathlib import Path, PurePath from typing import AsyncIterator, Dict, Iterator, List, Optional, Tuple, Union, cast @@ -78,6 +80,12 @@ class LSPFileBuffer: class LanguageServer: + + # To be overridden and extended by subclasses + def should_always_ignore(self, dirname: str) -> bool: + return dirname.startswith('.') + + """ The LanguageServer class provides a language agnostic interface to the Language Server Protocol. It is used to communicate with Language Servers of different programming languages. @@ -172,6 +180,14 @@ class LanguageServer: self.server_started = False self.repository_root_path: str = repository_root_path self.completions_available = asyncio.Event() + + # for all absolute paths in ignored_dirs, convert them to relative paths + self.ignored_paths = [] + for path in set(config.ignored_paths): + if os.path.isabs(path): + path = str(Path(path).resolve().relative_to(self.repository_root_path)) + self.ignored_paths.append(path) + if config.trace_lsp_communication: @@ -196,6 +212,27 @@ class LanguageServer: self.load_cache() self._cache_has_changed = bool self.language = Language(language_id) + + # Helper function to check if a relative path should be ignored + def should_ignore_path(self, relative_path: str) -> bool: + fn_matcher = self.language.get_source_fn_matcher() + if os.path.isfile(relative_path) and not fn_matcher.is_relevant_filename(relative_path): + return True + + # Normalize path separators for consistent comparison + rel_path = Path(relative_path) + for ignored_path in self.ignored_paths: + if rel_path.is_relative_to(ignored_path): + return True + + # Check each part of the path against always fulfilled ignore conditions + for part in rel_path.parts: + if not part: # Skip empty parts (e.g., from leading '/') + continue + # Check standard ignores + if self.should_always_ignore(part): + return True + return False @asynccontextmanager async def start_server(self) -> AsyncIterator["LanguageServer"]: @@ -463,21 +500,23 @@ class LanguageServer: """ Raise a [textDocument/references](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_references) request to the Language Server to find references to the symbol at the given line and column in the given file. Wait for the response and return the result. + Filters out references located in ignored directories. :param relative_file_path: The relative path of the file that has the symbol for which references should be looked up :param line: The line number of the symbol :param column: The column number of the symbol - :return List[multilspy_types.Location]: A list of locations where the symbol is referenced + :return: A list of locations where the symbol is referenced (excluding ignored directories) """ if not self.server_started: self.logger.log( - "find_all_callers_of_function called before Language Server started", + "request_references called before Language Server started", logging.ERROR, ) raise MultilspyException("Language Server not started") + with self.open_file(relative_file_path): # sending request to the language server and waiting for response response = await self.server.send.references( @@ -496,13 +535,17 @@ class LanguageServer: assert isinstance(item, dict) assert LSPConstants.URI in item assert LSPConstants.RANGE in item + + abs_path = PathUtils.uri_to_path(item[LSPConstants.URI]) + rel_path = Path(abs_path).relative_to(self.repository_root_path) + if self.should_ignore_path(str(rel_path)): + self.logger.log(f"Ignoring reference in {rel_path} since it should be ignored", logging.DEBUG) + continue new_item: multilspy_types.Location = {} new_item.update(item) - new_item["absolutePath"] = PathUtils.uri_to_path(new_item["uri"]) - new_item["relativePath"] = str( - PurePath(os.path.relpath(new_item["absolutePath"], self.repository_root_path)) - ) + new_item["absolutePath"] = str(abs_path) + new_item["relativePath"] = str(rel_path) ret.append(multilspy_types.Location(**new_item)) return ret @@ -749,12 +792,13 @@ class LanguageServer: async def request_full_symbol_tree(self, within_relative_path: str | None = None, include_body: bool = False) -> List[multilspy_types.UnifiedSymbolInformation]: """ Will go through all files in the project and build a tree of symbols. Note: this may be slow the first time it is called. - + For each file, a symbol of kind Module (3) will be created. For directories, a symbol of kind Package (4) will be created. - All symbols will have a children attribute, thereby representing the tree structure of all symbols in the project + All symbols will have a children attribute, thereby representing the tree structure of all symbols in the project that are within the repository. - Will ignore all directories that start with a dot (.) and __pycache__ directories. - + Will ignore directories starting with '.', '__pycache__', language-specific defaults (self.default_ignored_dirs), + and user-configured directories (self.config.ignored_dirs). + :param within_relative_path: pass a relative path to only consider symbols within this path. If a file is passed, only the symbols within this file will be considered. If a directory is passed, all files within this directory will be considered. @@ -763,22 +807,20 @@ class LanguageServer: :return: A list of root symbols representing the top-level packages/modules in the project. """ if within_relative_path is not None and os.path.isfile(within_relative_path): + if self.should_ignore_path(within_relative_path): + self.logger.log(f"You passed a file explicitly, but it is ignored. This is probably an error. File: {within_relative_path}", logging.ERROR) + return [] + _, root_nodes = await self.request_document_symbols(within_relative_path, include_body=include_body) return root_nodes - - # Helper function to check if a path should be ignored - def should_ignore_dir(path: str) -> bool: - parts = path.split(os.sep) - return any(part.startswith('.') or part == '__pycache__' for part in parts) - - fn_matcher = self.language.get_source_fn_matcher() - + # Helper function to recursively process directories async def process_directory(dir_path: str) -> List[multilspy_types.UnifiedSymbolInformation]: abs_dir_path = self.repository_root_path if dir_path == "." else os.path.join(self.repository_root_path, dir_path) abs_dir_path = os.path.realpath(abs_dir_path) - if should_ignore_dir(abs_dir_path): + if self.should_ignore_path(str(Path(abs_dir_path).relative_to(self.repository_root_path))): + self.logger.log(f"Skipping directory: {dir_path}\n(because it should be ignored)", logging.DEBUG) return [] result = [] @@ -804,15 +846,16 @@ class LanguageServer: for item in items: item_path = os.path.join(abs_dir_path, item) abs_item_path = os.path.join(self.repository_root_path, item_path) + rel_item_path = str(Path(abs_item_path).resolve().relative_to(self.repository_root_path)) + if self.should_ignore_path(rel_item_path): + self.logger.log(f"Skipping item: {rel_item_path}\n(because it should be ignored)", logging.DEBUG) + continue if os.path.isdir(abs_item_path): child_symbols = await process_directory(item_path) package_symbol["children"].extend(child_symbols) elif os.path.isfile(abs_item_path): - if not fn_matcher.is_relevant_filename(item): - continue - _, root_nodes = await self.request_document_symbols(item_path, include_body=include_body) def fix_relative_path(nodes: List[multilspy_types.UnifiedSymbolInformation]): @@ -1851,3 +1894,15 @@ class SyncLanguageServer: Load the cache from a file. """ self.language_server.load_cache() + + def should_always_ignore(self, dirname: str) -> bool: + """ + Whether the given directory should be ignored. + """ + return self.language_server.should_always_ignore(dirname) + + def should_ignore_path(self, relative_path: str) -> bool: + """ + Whether the given path should be ignored. + """ + return self.language_server.should_ignore_path(relative_path) diff --git a/src/multilspy/language_servers/eclipse_jdtls/eclipse_jdtls.py b/src/multilspy/language_servers/eclipse_jdtls/eclipse_jdtls.py index 247f0558..e71b3042 100644 --- a/src/multilspy/language_servers/eclipse_jdtls/eclipse_jdtls.py +++ b/src/multilspy/language_servers/eclipse_jdtls/eclipse_jdtls.py @@ -14,6 +14,8 @@ import uuid from contextlib import asynccontextmanager from typing import AsyncIterator +from overrides import override + from multilspy.multilspy_logger import MultilspyLogger from multilspy.language_server import LanguageServer from multilspy.lsp_protocol_handler.server import ProcessLaunchInfo @@ -138,6 +140,24 @@ class EclipseJDTLS(LanguageServer): self.initialize_searcher_command_available = asyncio.Event() super().__init__(config, logger, repository_root_path, ProcessLaunchInfo(cmd, proc_env, proc_cwd), "java") + + @override + def should_always_ignore(self, dirname: str) -> bool: + # Ignore common Java build directories from different build tools: + # - Maven: target + # - Gradle: build, .gradle + # - Eclipse: bin, .settings + # - IntelliJ IDEA: out, .idea + # - General: classes, dist, lib + return super().should_always_ignore(dirname) or dirname in [ + "target", # Maven + "build", # Gradle + "bin", # Eclipse + "out", # IntelliJ IDEA + "classes", # General + "dist", # General + "lib" # General + ] def setupRuntimeDependencies(self, logger: MultilspyLogger, config: MultilspyConfig) -> RuntimeDependencyPaths: """ diff --git a/src/multilspy/language_servers/gopls/gopls.py b/src/multilspy/language_servers/gopls/gopls.py index 40031db4..89a16e5d 100644 --- a/src/multilspy/language_servers/gopls/gopls.py +++ b/src/multilspy/language_servers/gopls/gopls.py @@ -7,6 +7,8 @@ import subprocess from contextlib import asynccontextmanager from typing import AsyncIterator +from overrides import override + from multilspy.multilspy_logger import MultilspyLogger from multilspy.language_server import LanguageServer from multilspy.lsp_protocol_handler.server import ProcessLaunchInfo @@ -18,6 +20,14 @@ class Gopls(LanguageServer): """ Provides Go specific instantiation of the LanguageServer class using gopls. """ + + @override + def should_always_ignore(self, dirname: str) -> bool: + # For Go projects, we should ignore: + # - vendor: third-party dependencies vendored into the project + # - node_modules: if the project has JavaScript components + # - dist/build: common output directories + return super().should_always_ignore(dirname) or dirname in ["vendor", "node_modules", "dist", "build"] @staticmethod def _get_go_version(): diff --git a/src/multilspy/language_servers/jedi_language_server/jedi_server.py b/src/multilspy/language_servers/jedi_language_server/jedi_server.py index d49ace2c..1f1dc883 100644 --- a/src/multilspy/language_servers/jedi_language_server/jedi_server.py +++ b/src/multilspy/language_servers/jedi_language_server/jedi_server.py @@ -7,7 +7,9 @@ import logging import os import pathlib from contextlib import asynccontextmanager -from typing import AsyncIterator +from typing import AsyncIterator, Tuple + +from overrides import override from multilspy.multilspy_logger import MultilspyLogger from multilspy.language_server import LanguageServer @@ -32,6 +34,10 @@ class JediServer(LanguageServer): ProcessLaunchInfo(cmd="jedi-language-server", cwd=repository_root_path), "python", ) + + @override + def should_always_ignore(self, dirname: str) -> bool: + return super().should_always_ignore(dirname) or dirname in ["venv", "__pycache__"] def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: """ diff --git a/src/multilspy/language_servers/omnisharp/omnisharp.py b/src/multilspy/language_servers/omnisharp/omnisharp.py index d290af2a..21174b29 100644 --- a/src/multilspy/language_servers/omnisharp/omnisharp.py +++ b/src/multilspy/language_servers/omnisharp/omnisharp.py @@ -11,6 +11,8 @@ import stat from contextlib import asynccontextmanager from typing import AsyncIterator, Iterable +from overrides import override + from multilspy.multilspy_logger import MultilspyLogger from multilspy.language_server import LanguageServer from multilspy.lsp_protocol_handler.server import ProcessLaunchInfo @@ -46,7 +48,7 @@ def breadth_first_file_scan(root) -> Iterable[str]: dirs = next_dirs -def find_least_depth_sln_file(root_dir) -> str: +def find_least_depth_sln_file(root_dir) -> str | None: for filename in breadth_first_file_scan(root_dir): if filename.endswith(".sln"): return filename @@ -107,6 +109,10 @@ class OmniSharp(LanguageServer): self.definition_available = asyncio.Event() self.references_available = asyncio.Event() + + @override + def should_always_ignore(self, dirname: str) -> bool: + return super().should_always_ignore(dirname) or dirname in ["bin", "obj"] def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: """ diff --git a/src/multilspy/language_servers/pyright_language_server/pyright_server.py b/src/multilspy/language_servers/pyright_language_server/pyright_server.py index 09e301f3..5cffb059 100644 --- a/src/multilspy/language_servers/pyright_language_server/pyright_server.py +++ b/src/multilspy/language_servers/pyright_language_server/pyright_server.py @@ -7,7 +7,9 @@ import logging import os import pathlib from contextlib import asynccontextmanager -from typing import AsyncIterator +from typing import AsyncIterator, Tuple + +from overrides import override from multilspy.multilspy_logger import MultilspyLogger from multilspy.language_server import LanguageServer @@ -21,7 +23,6 @@ class PyrightServer(LanguageServer): Provides Python specific instantiation of the LanguageServer class using Pyright. Contains various configurations and settings specific to Python. """ - def __init__(self, config: MultilspyConfig, logger: MultilspyLogger, repository_root_path: str): """ Creates a PyrightServer instance. This class is not meant to be instantiated directly. @@ -36,6 +37,10 @@ class PyrightServer(LanguageServer): ProcessLaunchInfo(cmd="python -m pyright.langserver --stdio", cwd=repository_root_path), "python", ) + + @override + def should_always_ignore(self, dirname: str) -> bool: + return super().should_always_ignore(dirname) or dirname in ["venv", "__pycache__"] def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: """ diff --git a/src/multilspy/language_servers/rust_analyzer/rust_analyzer.py b/src/multilspy/language_servers/rust_analyzer/rust_analyzer.py index 1a95eec6..0acdddf5 100644 --- a/src/multilspy/language_servers/rust_analyzer/rust_analyzer.py +++ b/src/multilspy/language_servers/rust_analyzer/rust_analyzer.py @@ -11,6 +11,8 @@ import pathlib from contextlib import asynccontextmanager from typing import AsyncIterator +from overrides import override + from multilspy.multilspy_logger import MultilspyLogger from multilspy.language_server import LanguageServer from multilspy.lsp_protocol_handler.server import ProcessLaunchInfo @@ -38,6 +40,10 @@ class RustAnalyzer(LanguageServer): "rust", ) self.server_ready = asyncio.Event() + + @override + def should_always_ignore(self, dirname: str) -> bool: + return super().should_always_ignore(dirname) or dirname in ["target"] def setup_runtime_dependencies(self, logger: MultilspyLogger, config: MultilspyConfig) -> str: """ diff --git a/src/multilspy/language_servers/solargraph/solargraph.py b/src/multilspy/language_servers/solargraph/solargraph.py index a0a62b8f..4dadb735 100644 --- a/src/multilspy/language_servers/solargraph/solargraph.py +++ b/src/multilspy/language_servers/solargraph/solargraph.py @@ -11,7 +11,7 @@ import stat import subprocess import pathlib from contextlib import asynccontextmanager -from typing import AsyncIterator +from typing import AsyncIterator, override from multilspy.multilspy_logger import MultilspyLogger from multilspy.language_server import LanguageServer @@ -42,6 +42,10 @@ class Solargraph(LanguageServer): "ruby", ) self.server_ready = asyncio.Event() + + @override + def should_always_ignore(self, dirname: str) -> bool: + return super().should_always_ignore(dirname) or dirname in ["vendor"] def setup_runtime_dependencies(self, logger: MultilspyLogger, config: MultilspyConfig, repository_root_path: str) -> str: """ diff --git a/src/multilspy/language_servers/typescript_language_server/typescript_language_server.py b/src/multilspy/language_servers/typescript_language_server/typescript_language_server.py index eb823ae2..85b1a18e 100644 --- a/src/multilspy/language_servers/typescript_language_server/typescript_language_server.py +++ b/src/multilspy/language_servers/typescript_language_server/typescript_language_server.py @@ -10,7 +10,9 @@ import os import subprocess import pathlib from contextlib import asynccontextmanager -from typing import AsyncIterator +from typing import AsyncIterator, Tuple + +from overrides import override from multilspy.multilspy_logger import MultilspyLogger from multilspy.language_server import LanguageServer @@ -48,6 +50,15 @@ class TypeScriptLanguageServer(LanguageServer): "typescript", ) self.server_ready = asyncio.Event() + + @override + def should_always_ignore(self, dirname: str) -> bool: + return super().should_always_ignore(dirname) or dirname in [ + "node_modules", + "dist", + "build", + "coverage", + ] def setup_runtime_dependencies(self, logger: MultilspyLogger, config: MultilspyConfig) -> str: """ diff --git a/src/multilspy/multilspy_config.py b/src/multilspy/multilspy_config.py index ac2ff976..db18a209 100644 --- a/src/multilspy/multilspy_config.py +++ b/src/multilspy/multilspy_config.py @@ -3,7 +3,8 @@ Configuration parameters for Multilspy. """ import fnmatch from enum import Enum -from dataclasses import dataclass +from typing import List +from dataclasses import dataclass, field class FilenameMatcher: @@ -66,6 +67,7 @@ class MultilspyConfig: """ code_language: Language trace_lsp_communication: bool = False + ignored_paths: List[str] = field(default_factory=list) @classmethod def from_dict(cls, env: dict): diff --git a/test/multilspy/test_retrieval_with_ignored_dirs.py b/test/multilspy/test_retrieval_with_ignored_dirs.py new file mode 100644 index 00000000..9d39f83a --- /dev/null +++ b/test/multilspy/test_retrieval_with_ignored_dirs.py @@ -0,0 +1,46 @@ +from collections.abc import Generator +from pathlib import Path + +import pytest + +from multilspy.language_server import SyncLanguageServer +from multilspy.multilspy_config import Language, MultilspyConfig +from multilspy.multilspy_logger import MultilspyLogger + + +@pytest.fixture(scope="module") +def ls_with_ignored_dirs(repo_path: Path) -> Generator[SyncLanguageServer, None, None]: + """Fixture to set up an LS for the python test repo with the 'scripts' directory ignored.""" + config = MultilspyConfig( + code_language=Language.PYTHON, + trace_lsp_communication=False, + ignored_paths=["scripts", "custom_test"], # Configure the relative path to be ignored + ) + logger = MultilspyLogger() + ls = SyncLanguageServer.create(config, logger, str(repo_path)) + ls.start() + try: + yield ls + finally: + ls.stop() + + +def test_symbol_tree_ignores_dir(ls_with_ignored_dirs: SyncLanguageServer): + """Tests that request_full_symbol_tree ignores the configured directory.""" + root = ls_with_ignored_dirs.request_full_symbol_tree()[0] + root_children = root["children"] + children_names = {child["name"] for child in root_children} + assert children_names == {"test_repo", "examples"} + + +def test_find_references_ignores_dir(ls_with_ignored_dirs: SyncLanguageServer): + """Tests that find_references ignores the configured directory.""" + # Location of Item, which is referenced in scripts + definition_file = "test_repo/models.py" + definition_line = 56 + definition_col = 6 + + references = ls_with_ignored_dirs.request_references(definition_file, definition_line, definition_col) + + # assert that scripts does not appear in the references + assert not any("scripts" in ref["relativePath"] for ref in references) diff --git a/test/resources/test_repo/scripts/run_app.py b/test/resources/test_repo/scripts/run_app.py index 8e97f1cf..40c76c6d 100644 --- a/test/resources/test_repo/scripts/run_app.py +++ b/test/resources/test_repo/scripts/run_app.py @@ -158,5 +158,7 @@ def main(): logger.info("Application completed successfully") +item_reference = Item(id="1", name="Item 1", price=10.0, category="Electronics") + if __name__ == "__main__": main()