mirror of
https://github.com/tiennm99/serena.git
synced 2026-10-13 22:16:23 +00:00
LS: support for configuring ignored paths
Introduced per-language always ignored paths
This commit is contained in:
1 parent
ba03ec431f
commit
78b43d2a63
13 files changed
+203
-29
No files matched your search
Vendored
+1
@@ -3,6 +3,7 @@
|
||||
"agno",
|
||||
"asyncio",
|
||||
"genai",
|
||||
"Gopls",
|
||||
"langsrv",
|
||||
"multilspy",
|
||||
"pixi",
|
||||
|
||||
@@ -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:
|
||||
"""
|
||||
|
||||
+12
-1
@@ -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,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()
|
||||
Reference in new issue
Block a user