diff --git a/src/multilspy/language_server.py b/src/multilspy/language_server.py index 7b88b863..93392131 100644 --- a/src/multilspy/language_server.py +++ b/src/multilspy/language_server.py @@ -81,18 +81,17 @@ 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. """ + + # To be overridden and extended by subclasses + def should_always_ignore(self, dirname: str) -> bool: + return dirname.startswith('.') + @classmethod - def create(cls, config: MultilspyConfig, logger: MultilspyLogger, repository_root_path: str) -> "LanguageServer": + def create(cls, config: MultilspyConfig, logger: MultilspyLogger, repository_root_path: str, add_gitignore_content_to_config: bool = True) -> "LanguageServer": """ Creates a language specific LanguageServer instance based on the given configuration, and appropriate settings for the programming language. @@ -102,9 +101,29 @@ class LanguageServer: :param repository_root_path: The root path of the repository. :param config: The Multilspy configuration. :param logger: The logger to use. + :param add_gitignore_content_to_config: whether to add the content of the .gitignore file (if any found) to the config, so that + the paths ignored there are also ignored by the language server :return LanguageServer: A language specific LanguageServer instance. """ + config = copy(config) # prevent mutation + if add_gitignore_content_to_config: + gitignore_path = os.path.join(repository_root_path, ".gitignore") + if not os.path.exists(gitignore_path): + logger.log( + f"Should ignore all files in gitignore not not .gitignore found at {gitignore_path}. Skipping.", + logging.WARNING + ) + gitignore_file_content = None + else: + if config.gitignore_file_content is not None: + raise ValueError( + f"Asked to add gitignore content to the config for {repository_root_path=} but there already is a non-empty entry" + ) + with open(gitignore_path) as f: + gitignore_file_content = f.read() + config.gitignore_file_content = gitignore_file_content + if config.code_language == Language.PYTHON: from multilspy.language_servers.pyright_language_server.pyright_server import ( PyrightServer, @@ -167,7 +186,7 @@ class LanguageServer: :param config: The Multilspy configuration. :param logger: The logger to use. :param repository_root_path: The root path of the repository. - :param cmd: Each language server has a specific command used to start the server. + :param process_launch_info: Each language server has a specific command used to start the server. This parameter is the command to launch the language server process. The command must pass appropriate flags to the binary, so that it runs in the stdio mode, as opposed to HTTP, TCP modes supported by some language servers. @@ -213,13 +232,16 @@ class LanguageServer: # Normalize separators (pathspec expects forward slashes) pattern = pattern.replace(os.path.sep, '/') processed_patterns.append(pattern) + # Combine explicitly passed patterns with + if config.gitignore_file_content is not None: + processed_patterns.extend(config.gitignore_file_content.splitlines()) + # 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 @@ -249,6 +271,11 @@ class LanguageServer: # Normalize path separators for pathspec (it expects forward slashes) normalized_path = str(rel_path).replace(os.path.sep, '/') + # pathspec can't handle the matching of directories if they don't end with a slash! + # see https://github.com/cpburnz/python-pathspec/issues/89 + if os.path.isdir(os.path.join(self.repository_root_path, normalized_path)) and not normalized_path.endswith('/'): + normalized_path = normalized_path + '/' + # Use the pathspec matcher to check if the path matches any ignore pattern if self.ignore_spec.match_file(normalized_path): return True @@ -1500,7 +1527,7 @@ class SyncLanguageServer: @classmethod def create( - cls, config: MultilspyConfig, logger: MultilspyLogger, repository_root_path: str + cls, config: MultilspyConfig, logger: MultilspyLogger, repository_root_path: str, add_gitignore_content_to_config=True, ) -> "SyncLanguageServer": """ Creates a language specific LanguageServer instance based on the given configuration, and appropriate settings for the programming language. @@ -1510,10 +1537,12 @@ class SyncLanguageServer: :param repository_root_path: The root path of the repository. :param config: The Multilspy configuration. :param logger: The logger to use. + :param add_gitignore_content_to_config: whether to add the content of the .gitignore file (if any found) to the config, so that + the paths ignored there are also ignored by the language server :return SyncLanguageServer: A language specific LanguageServer instance. """ - return SyncLanguageServer(LanguageServer.create(config, logger, repository_root_path)) + return SyncLanguageServer(LanguageServer.create(config, logger, repository_root_path, add_gitignore_content_to_config=add_gitignore_content_to_config)) @contextmanager def open_file(self, relative_file_path: str) -> Iterator[LSPFileBuffer]: diff --git a/src/multilspy/multilspy_config.py b/src/multilspy/multilspy_config.py index db18a209..c78759c4 100644 --- a/src/multilspy/multilspy_config.py +++ b/src/multilspy/multilspy_config.py @@ -67,7 +67,10 @@ class MultilspyConfig: """ code_language: Language trace_lsp_communication: bool = False - ignored_paths: List[str] = field(default_factory=list) + ignored_paths: list[str] = field(default_factory=list) + """Paths, dirs or glob-like patterns. The matching will follow the same logic as for .gitignore entries""" + gitignore_file_content: str | None = None + """Optional content of the gitignore file. If passed, will be used in addition to the explicitly passed ignored_paths for deciding which paths to ignore.""" @classmethod def from_dict(cls, env: dict): @@ -78,4 +81,4 @@ class MultilspyConfig: return cls(**{ k: v for k, v in env.items() if k in inspect.signature(cls).parameters - }) \ No newline at end of file + }) diff --git a/test/resources/test_repo/.gitignore b/test/resources/test_repo/.gitignore new file mode 100644 index 00000000..811c627c --- /dev/null +++ b/test/resources/test_repo/.gitignore @@ -0,0 +1 @@ +ignore_this_dir*/ \ No newline at end of file diff --git a/test/resources/test_repo/ignore_this_dir_with_postfix/ignored_module.py b/test/resources/test_repo/ignore_this_dir_with_postfix/ignored_module.py new file mode 100644 index 00000000..1e6da936 --- /dev/null +++ b/test/resources/test_repo/ignore_this_dir_with_postfix/ignored_module.py @@ -0,0 +1,141 @@ +""" +Example demonstrating user management with the test_repo module. + +This example showcases: +- Creating and managing users +- Using various object types and relationships +- Type annotations and complex Python patterns +""" + +import logging +from dataclasses import dataclass +from typing import Any + +from test_repo.models import User, create_user_object +from test_repo.services import UserService + +# Set up logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +@dataclass +class UserStats: + """Statistics about user activity.""" + + user_id: str + login_count: int = 0 + last_active_days: int = 0 + engagement_score: float = 0.0 + + def is_active(self) -> bool: + """Check if the user is considered active.""" + return self.last_active_days < 30 + + +class UserManager: + """Example class demonstrating complex user management.""" + + def __init__(self, service: UserService): + self.service = service + self.active_users: dict[str, User] = {} + self.user_stats: dict[str, UserStats] = {} + + def register_user(self, name: str, email: str, roles: list[str] | None = None) -> User: + """Register a new user.""" + logger.info(f"Registering new user: {name} ({email})") + user = self.service.create_user(name=name, email=email, roles=roles) + self.active_users[user.id] = user + self.user_stats[user.id] = UserStats(user_id=user.id) + return user + + def get_user(self, user_id: str) -> User | None: + """Get a user by ID.""" + if user_id in self.active_users: + return self.active_users[user_id] + + # Try to fetch from service + user = self.service.get_user(user_id) + if user: + self.active_users[user.id] = user + return user + + def update_user_stats(self, user_id: str, login_count: int, days_since_active: int) -> None: + """Update statistics for a user.""" + if user_id not in self.user_stats: + self.user_stats[user_id] = UserStats(user_id=user_id) + + stats = self.user_stats[user_id] + stats.login_count = login_count + stats.last_active_days = days_since_active + + # Calculate engagement score based on activity + engagement = (100 - min(days_since_active, 100)) * 0.8 + engagement += min(login_count, 20) * 0.2 + stats.engagement_score = engagement + + def get_active_users(self) -> list[User]: + """Get all active users.""" + active_user_ids = [user_id for user_id, stats in self.user_stats.items() if stats.is_active()] + return [self.active_users[user_id] for user_id in active_user_ids if user_id in self.active_users] + + def get_user_by_email(self, email: str) -> User | None: + """Find a user by their email address.""" + for user in self.active_users.values(): + if user.email == email: + return user + return None + + +# Example function demonstrating type annotations +def process_user_data(users: list[User], include_inactive: bool = False, transform_func: callable | None = None) -> dict[str, Any]: + """Process user data with optional transformations.""" + result: dict[str, Any] = {"users": [], "total": 0, "admin_count": 0} + + for user in users: + if transform_func: + user_data = transform_func(user.to_dict()) + else: + user_data = user.to_dict() + + result["users"].append(user_data) + result["total"] += 1 + + if "admin" in user.roles: + result["admin_count"] += 1 + + return result + + +def main(): + """Main function demonstrating the usage of UserManager.""" + # Initialize service and manager + service = UserService() + manager = UserManager(service) + + # Register some users + admin = manager.register_user("Admin User", "admin@example.com", ["admin"]) + user1 = manager.register_user("Regular User", "user@example.com", ["user"]) + user2 = manager.register_user("Another User", "another@example.com", ["user"]) + + # Update some stats + manager.update_user_stats(admin.id, 100, 5) + manager.update_user_stats(user1.id, 50, 10) + manager.update_user_stats(user2.id, 10, 45) # Inactive user + + # Get active users + active_users = manager.get_active_users() + logger.info(f"Active users: {len(active_users)}") + + # Process user data + user_data = process_user_data(active_users, transform_func=lambda u: {**u, "full_name": u.get("name", "")}) + + logger.info(f"Processed {user_data['total']} users, {user_data['admin_count']} admins") + + # Example of calling create_user directly + external_user = create_user_object(id="ext123", name="External User", email="external@example.org", roles=["external"]) + logger.info(f"Created external user: {external_user.name}") + + +if __name__ == "__main__": + main()