From c4384682f281c344b404f726349626b0e84fc2e3 Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Mon, 7 Apr 2025 15:43:41 +0200 Subject: [PATCH] LS: support glob ignores using pathspec --- pyproject.toml | 1 + src/multilspy/language_server.py | 54 +++++++++++++------ src/multilspy/multilspy_utils.py | 5 ++ .../test_retrieval_with_ignored_dirs.py | 25 +++++++++ uv.lock | 2 + 5 files changed, 71 insertions(+), 16 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 8b48ffa2..7a92262b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,7 @@ dependencies = [ "pyyaml>=6.0.2", "jinja2>=3.1.6", "dotenv>=0.9.9", + "pathspec>=0.12.1", ] [project.scripts] diff --git a/src/multilspy/language_server.py b/src/multilspy/language_server.py index 9af94fb3..4f661f9e 100644 --- a/src/multilspy/language_server.py +++ b/src/multilspy/language_server.py @@ -16,14 +16,15 @@ 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 typing import Dict, List, Optional, Tuple, Union from fnmatch import fnmatch from pathlib import Path, PurePath from typing import AsyncIterator, Dict, Iterator, List, Optional, Tuple, Union, cast +import pathspec + from serena.text_utils import LineType, MatchedConsecutiveLines, TextLine, search_text from . import multilspy_types from .lsp_protocol_handler import lsp_types as LSPTypes @@ -181,14 +182,6 @@ class LanguageServer: 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: def logging_fn(source, target, msg): @@ -213,27 +206,56 @@ class LanguageServer: self._cache_has_changed = bool self.language = Language(language_id) - # Helper function to check if a relative path should be ignored + # Set up the pathspec matcher for the ignored paths + # for all absolute paths in ignored_paths, convert them to relative paths + processed_patterns = [] + for pattern in set(config.ignored_paths): + # Normalize separators (pathspec expects forward slashes) + pattern = pattern.replace(os.path.sep, '/') + processed_patterns.append(pattern) + # Create a pathspec matcher from the processed patterns + self.ignore_spec = pathspec.PathSpec.from_lines( + pathspec.patterns.GitWildMatchPattern, + processed_patterns + ) + + def should_ignore_path(self, relative_path: str) -> bool: + """ + Determine if a path should be ignored based on file type + and ignore patterns. + + :param relative_path: Relative path to check + + :return: True if the path should be ignored, False otherwise + """ + # Check file extension if it's a file 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 + # Create normalized path for consistent handling 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 '/') + if not part: # Skip empty parts (e.g., from leading '/') continue # Check standard ignores if self.should_always_ignore(part): return True + + # Use pathspec for gitignore-style pattern matching + # Normalize path separators for pathspec (it expects forward slashes) + normalized_path = str(rel_path).replace(os.path.sep, '/') + + # Use the pathspec matcher to check if the path matches any ignore pattern + if self.ignore_spec.match_file(normalized_path): + return True + return False + @asynccontextmanager async def start_server(self) -> AsyncIterator["LanguageServer"]: """ diff --git a/src/multilspy/multilspy_utils.py b/src/multilspy/multilspy_utils.py index f1dc8dc1..6e42aa55 100644 --- a/src/multilspy/multilspy_utils.py +++ b/src/multilspy/multilspy_utils.py @@ -88,6 +88,11 @@ class PathUtils: parsed = urlparse(uri) host = "{0}{0}{mnt}{0}".format(os.path.sep, mnt=parsed.netloc) return os.path.normpath(os.path.join(host, url2pathname(unquote(parsed.path)))) + + @staticmethod + def is_glob_pattern(pattern: str) -> bool: + """Check if a pattern contains glob-specific characters.""" + return any(c in pattern for c in '*?[]!') class FileUtils: """ diff --git a/test/multilspy/test_retrieval_with_ignored_dirs.py b/test/multilspy/test_retrieval_with_ignored_dirs.py index 9d39f83a..09e4f538 100644 --- a/test/multilspy/test_retrieval_with_ignored_dirs.py +++ b/test/multilspy/test_retrieval_with_ignored_dirs.py @@ -44,3 +44,28 @@ def test_find_references_ignores_dir(ls_with_ignored_dirs: SyncLanguageServer): # assert that scripts does not appear in the references assert not any("scripts" in ref["relativePath"] for ref in references) + + +def test_refs_and_symbols_with_glob_patterns(repo_path: Path) -> None: + """Tests that refs and symbols with glob patterns are ignored.""" + config = MultilspyConfig( + code_language=Language.PYTHON, + trace_lsp_communication=False, + ignored_paths=["*ipts", "custom_t*"], + ) + logger = MultilspyLogger() + ls = SyncLanguageServer.create(config, logger, str(repo_path)) + ls.start() + # same as in the above tests + root = ls.request_full_symbol_tree()[0] + root_children = root["children"] + children_names = {child["name"] for child in root_children} + assert children_names == {"test_repo", "examples"} + + # test that the refs and symbols with glob patterns are ignored + definition_file = "test_repo/models.py" + definition_line = 56 + definition_col = 6 + + references = ls.request_references(definition_file, definition_line, definition_col) + assert not any("scripts" in ref["relativePath"] for ref in references) diff --git a/uv.lock b/uv.lock index 2535003c..10c6be61 100644 --- a/uv.lock +++ b/uv.lock @@ -950,6 +950,7 @@ dependencies = [ { name = "jinja2" }, { name = "mcp" }, { name = "overrides" }, + { name = "pathspec" }, { name = "pydantic" }, { name = "pyright" }, { name = "python-dotenv" }, @@ -997,6 +998,7 @@ requires-dist = [ { name = "mcp", specifier = ">=1.5.0" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.4.1" }, { name = "overrides", specifier = ">=7.7.0,<8" }, + { name = "pathspec", specifier = ">=0.12.1" }, { name = "poethepoet", marker = "extra == 'dev'", specifier = ">=0.20.0" }, { name = "pydantic", specifier = ">=2.10.6" }, { name = "pyright", specifier = ">=1.1.396,<2" },