LS: support for configuring ignored paths

Introduced per-language always ignored paths
This commit is contained in:
Michael Panchenko committed 2025-04-07 00:54:34 +02:00
1 parent ba03ec431f
commit 78b43d2a63
13 files changed
+203 -29

No files matched your search

+1
View File
@@ -3,6 +3,7 @@
"agno",
"asyncio",
"genai",
"Gopls",
"langsrv",
"multilspy",
"pixi",
+77 -22
View File
@@ -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)
@@ -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:
"""
@@ -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():
@@ -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:
"""
@@ -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:
"""
@@ -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:
"""
@@ -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:
"""
@@ -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:
"""
@@ -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:
"""
+3 -1
View File
@@ -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):
@@ -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)
@@ -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()