diff --git a/.gitignore b/.gitignore index 3356e338..2235c912 100644 --- a/.gitignore +++ b/.gitignore @@ -140,3 +140,6 @@ pylint.html /*.yml !myproject.demo.yml + +# Cache is in .multilspy +**/.multilspy/** \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..9e686107 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "cSpell.words": [ + "multilspy" + ] +} \ No newline at end of file diff --git a/scripts/mcp_server.py b/scripts/mcp_server.py index 03da4a40..9a67c76a 100644 --- a/scripts/mcp_server.py +++ b/scripts/mcp_server.py @@ -144,8 +144,7 @@ def read_file(ctx: Context, relative_path: str) -> str: class ReadFileTool(Tool): def _execute(self): - with self.langsrv.open_file(relative_path): - return self.langsrv.get_open_file_text(relative_path) + return self.langsrv.retrieve_full_file_content(relative_path) return ReadFileTool(ctx).execute() diff --git a/src/multilspy/language_server.py b/src/multilspy/language_server.py index b27a6f6c..22a5e1af 100644 --- a/src/multilspy/language_server.py +++ b/src/multilspy/language_server.py @@ -11,12 +11,13 @@ import dataclasses import hashlib import json import pickle -import time import logging import os import pathlib import threading from contextlib import asynccontextmanager, contextmanager + +from serena.text_utils import MatchedConsecutiveLines, LineType, TextLine from .lsp_protocol_handler.lsp_constants import LSPConstants from .lsp_protocol_handler import lsp_types as LSPTypes @@ -34,6 +35,15 @@ from typing import AsyncIterator, Iterator, List, Dict, Optional, Union, Tuple from .type_helpers import ensure_all_methods_implemented +# Serena dependencies +# We will need to watch out for circular imports, but it's probably better to not +# move all generic util code from serena into multilspy. +# It does however make sense to integrate many text-related utils into the language server +# since it caches (in-memory) file contents, so we can avoid reading from disk. +# Moreover, the way we want to use the language server (for retrieving actual content), +# it makes sense to have more content-related utils directly in it. + + @dataclasses.dataclass class LSPFileBuffer: """ @@ -68,7 +78,6 @@ class LanguageServer: 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. """ - @classmethod def create(cls, config: MultilspyConfig, logger: MultilspyLogger, repository_root_path: str) -> "LanguageServer": """ @@ -347,28 +356,6 @@ class LanguageServer: ) return deleted_text - def get_open_file_text(self, relative_file_path: str) -> str: - """ - Get the contents of the given opened file as per the Language Server. - - :param relative_file_path: The relative path of the file to open. - """ - if not self.server_started: - self.logger.log( - "get_open_file_text called before Language Server started", - logging.ERROR, - ) - raise MultilspyException("Language Server not started") - - absolute_file_path = str(PurePath(self.repository_root_path, relative_file_path)) - uri = pathlib.Path(absolute_file_path).as_uri() - - # Ensure the file is open - assert uri in self.open_file_buffers - - file_buffer = self.open_file_buffers[uri] - return file_buffer.contents - async def request_definition( self, relative_file_path: str, line: int, column: int ) -> List[multilspy_types.Location]: @@ -514,7 +501,62 @@ class LanguageServer: ret.append(multilspy_types.Location(**new_item)) return ret + + async def request_references_with_content( + self, relative_file_path: str, line: int, column: int, context_lines_before: int = 0, context_lines_after: int = 0 + ) -> List[MatchedConsecutiveLines]: + """ + Like request_references, but returns the content of the lines containing the references, not just the locations. + :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 + :param context_lines_before: The number of lines to include in the context before the line containing the reference + :param context_lines_after: The number of lines to include in the context after the line containing the reference + + :return: A list of MatchedConsecutiveLines objects, one for each reference. + """ + references = await self.request_references(relative_file_path, line, column) + return [self.retrieve_content_around_line(ref["relativePath"], ref["range"]["start"]["line"], context_lines_before, context_lines_after) for ref in references] + + def retrieve_full_file_content(self, relative_file_path: str) -> str: + """ + Retrieve the full content of the given file. + """ + with self.open_file(relative_file_path) as file_data: + return file_data.contents + + def retrieve_content_around_line(self, relative_file_path: str, line: int, context_lines_before: int = 0, context_lines_after: int = 0) -> MatchedConsecutiveLines: + """ + Retrieve the content of the given file around the given line. + + :param relative_file_path: The relative path of the file to retrieve the content from + :param line: The line number to retrieve the content around + :param context_lines_before: The number of lines to retrieve before the given line + :param context_lines_after: The number of lines to retrieve after the given line + + :return MatchedConsecutiveLines: A container with the desired lines. + """ + with self.open_file(relative_file_path) as file_data: + file_contents = file_data.contents + + line_contents = file_contents.split("\n") + start_lineno = max(0, line - context_lines_before) + end_lineno = min(len(line_contents) - 1, line + context_lines_after) + # instantiate TextLines with the write LineType + text_lines: list[TextLine] = [] + # before the line + for lineno in range(start_lineno, line): + text_lines.append(TextLine(line_number=lineno, line_content=line_contents[lineno], match_type=LineType.BEFORE_MATCH)) + # the line + text_lines.append(TextLine(line_number=line, line_content=line_contents[line], match_type=LineType.MATCH)) + # after the line + for lineno in range(line + 1, end_lineno + 1): + text_lines.append(TextLine(line_number=lineno, line_content=line_contents[lineno], match_type=LineType.AFTER_MATCH)) + + return MatchedConsecutiveLines(lines=text_lines, source_file_path=relative_file_path) + + async def request_completions( self, relative_file_path: str, line: int, column: int, allow_incomplete: bool = False ) -> List[multilspy_types.CompletionItem]: @@ -702,6 +744,24 @@ class LanguageServer: # ----------------------------- FROM HERE ON MODIFICATIONS BY MISCHA -------------------- + def retrieve_symbol_body(self, symbol: multilspy_types.UnifiedSymbolInformation) -> str: + """ + Load the body of the given symbol. If the body is already contained in the symbol, just return it. + """ + existing_body = symbol.get("body", None) + if existing_body: + return existing_body + + assert "location" in symbol + symbol_start_line = symbol["location"]["range"]["start"]["line"] + symbol_end_line = symbol["location"]["range"]["end"]["line"] + assert "relativePath" in symbol["location"] + symbol_file = self.retrieve_full_file_content(symbol["location"]["relativePath"]) + symbol_lines = symbol_file.split("\n") + symbol_body = "\n".join(symbol_lines[symbol_start_line:symbol_end_line]) + return symbol_body + + async def request_parsed_files(self) -> list[str]: """This is slow, as it finds all files by finding all symbols. @@ -724,6 +784,7 @@ class LanguageServer: column: int, include_imports: bool = True, include_self: bool = False, + include_body: bool = False, ) -> List[multilspy_types.UnifiedSymbolInformation]: """ Finds all symbols that reference the symbol at the given location. @@ -738,6 +799,7 @@ class LanguageServer: will not be easily distinguishable from definitions. :param include_self: whether to include the references that is the "input symbol" itself. Only has an effect if the relative_file_path, line and column point to a symbol, for example a definition. + :param include_body: whether to include the body of the symbols in the result. :return: List of symbols that reference the target symbol. """ if not self.server_started: @@ -763,7 +825,7 @@ class LanguageServer: with self.open_file(ref_path) as file_data: # Get the containing symbol for this reference containing_symbol = await self.request_containing_symbol( - ref_path, ref_line, ref_col + ref_path, ref_line, ref_col, include_body=include_body ) if containing_symbol is None: # TODO: HORRIBLE HACK! I don't know how to do it better for now... @@ -833,6 +895,7 @@ class LanguageServer: line: int, column: Optional[int] = None, strict: bool = False, + include_body: bool = False, ) -> multilspy_types.UnifiedSymbolInformation | None: """ Finds the first symbol containing the position for the given file. @@ -857,6 +920,7 @@ class LanguageServer: Setting to True is useful for example for finding the parent of a symbol, as with strict=False, and the line pointing to a symbol itself, the containing symbol will be the symbol itself (and not the parent). + :param include_body: Whether to include the body of the symbol in the result. :return: The container symbol (if found) or None. """ # checking if the line is empty, unfortunately ugly and duplicating code, but I don't want to refactor @@ -938,13 +1002,19 @@ class LanguageServer: if containing_symbols: # Return the one with the greatest starting position (i.e. the innermost container). - return max(containing_symbols, key=lambda s: s["location"]["range"]["start"]["line"]) + containing_symbol = max(containing_symbols, key=lambda s: s["location"]["range"]["start"]["line"]) + if include_body: + containing_symbol["body"] = self.retrieve_symbol_body(containing_symbol) + return containing_symbol else: return None - async def request_container_of_symbol(self, symbol: multilspy_types.UnifiedSymbolInformation) -> multilspy_types.UnifiedSymbolInformation | None: + async def request_container_of_symbol(self, symbol: multilspy_types.UnifiedSymbolInformation, include_body: bool = False) -> multilspy_types.UnifiedSymbolInformation | None: """ Finds the container of the given symbol if there is one. + + :param symbol: The symbol to find the container of. + :param include_body: whether to include the body of the symbol in the result. """ assert "location" in symbol return await self.request_containing_symbol( @@ -952,6 +1022,7 @@ class LanguageServer: symbol["location"]["range"]["start"]["line"], symbol["location"]["range"]["start"]["character"], strict=True, + include_body=include_body, ) async def request_defining_symbol( @@ -959,6 +1030,7 @@ class LanguageServer: relative_file_path: str, line: int, column: int, + include_body: bool = False, ) -> Optional[multilspy_types.UnifiedSymbolInformation]: """ Finds the symbol that defines the symbol at the given location. @@ -969,6 +1041,7 @@ class LanguageServer: :param relative_file_path: The relative path to the file. :param line: The 0-indexed line number. :param column: The 0-indexed column number. + :param include_body: whether to include the body of the symbol in the result. :return: The symbol information for the definition, or None if not found. """ if not self.server_started: @@ -991,7 +1064,7 @@ class LanguageServer: # Find the symbol at or containing this location defining_symbol = await self.request_containing_symbol( - def_path, def_line, def_col, strict=False + def_path, def_line, def_col, strict=False, include_body=include_body ) return defining_symbol @@ -1080,14 +1153,6 @@ class SyncLanguageServer: Delete text between the given start and end positions in the given file and return the deleted text. """ return self.language_server.delete_text_between_positions(relative_file_path, start, end) - - def get_open_file_text(self, relative_file_path: str) -> str: - """ - Get the contents of the given opened file as per the Language Server. - - :param relative_file_path: The relative path of the file to open. - """ - return self.language_server.get_open_file_text(relative_file_path) @contextmanager def start_server(self) -> Iterator["SyncLanguageServer"]: @@ -1137,7 +1202,26 @@ class SyncLanguageServer: self.language_server.request_references(file_path, line, column), self.loop ).result() return result + + def request_references_with_content( + self, relative_file_path: str, line: int, column: int, context_lines_before: int = 0, context_lines_after: int = 0 + ) -> List[MatchedConsecutiveLines]: + """ + Like request_references, but returns the content of the lines containing the references, not just the locations. + + :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 + :param context_lines_before: The number of lines to include in the context before the line containing the reference + :param context_lines_after: The number of lines to include in the context after the line containing the reference + :return: A list of MatchedConsecutiveLines objects, one for each reference. + """ + result = asyncio.run_coroutine_threadsafe( + self.language_server.request_references_with_content(relative_file_path, line, column, context_lines_before, context_lines_after), self.loop + ).result() + return result + def request_completions( self, relative_file_path: str, line: int, column: int, allow_incomplete: bool = False ) -> List[multilspy_types.CompletionItem]: @@ -1189,6 +1273,15 @@ class SyncLanguageServer: # ----------------------------- FROM HERE ON MODIFICATIONS BY MISCHA -------------------- + def retrieve_symbol_body(self, symbol: multilspy_types.UnifiedSymbolInformation) -> str: + """ + Load the body of the given symbol. If the body is already contained in the symbol, just return it. + + :param symbol: The symbol to retrieve the body of. + :return: The body of the symbol. + """ + return self.language_server.retrieve_symbol_body(symbol) + def request_parsed_files(self) -> list[str]: """This is slow, as it finds all files by finding all symbols. @@ -1202,6 +1295,7 @@ class SyncLanguageServer: def request_referencing_symbols( self, relative_file_path: str, line: int, column: int, include_imports: bool = True, include_self: bool = False, + include_body: bool = False, ) -> List[multilspy_types.UnifiedSymbolInformation]: """ Finds all symbols that reference the symbol at the given location. @@ -1216,6 +1310,7 @@ class SyncLanguageServer: will not be easily distinguishable from definitions. :param include_self: whether to include the references that is the "input symbol" itself. Only has an effect if the relative_file_path, line and column point to a symbol, for example a definition. + :param include_body: whether to include the body of the symbols in the result. :return: List of symbols that reference the target symbol. """ assert self.loop @@ -1225,7 +1320,8 @@ class SyncLanguageServer: line, column, include_imports=include_imports, - include_self=include_self + include_self=include_self, + include_body=include_body, ), self.loop ).result() @@ -1233,7 +1329,8 @@ class SyncLanguageServer: def request_containing_symbol( self, relative_file_path: str, line: int, - column: Optional[int] = None, strict: bool = False + column: Optional[int] = None, strict: bool = False, + include_body: bool = False, ) -> multilspy_types.UnifiedSymbolInformation | None: """ Finds the first symbol containing the position for the given file. @@ -1258,26 +1355,31 @@ class SyncLanguageServer: Setting to true is useful for example for finding the parent of a symbol, as with strict=False, and the line pointing to a symbol itself, the containing symbol will be the symbol itself (and not the parent). + :param include_body: whether to include the body of the symbol in the result. :return: The container symbol (if found) or None. """ assert self.loop result = asyncio.run_coroutine_threadsafe( - self.language_server.request_containing_symbol(relative_file_path, line, column=column, strict=strict), self.loop + self.language_server.request_containing_symbol(relative_file_path, line, column=column, strict=strict, include_body=include_body), self.loop ).result() return result - def request_container_of_symbol(self, symbol: multilspy_types.UnifiedSymbolInformation) -> multilspy_types.UnifiedSymbolInformation | None: + def request_container_of_symbol(self, symbol: multilspy_types.UnifiedSymbolInformation, include_body: bool = False) -> multilspy_types.UnifiedSymbolInformation | None: """ Finds the container of the given symbol if there is one. + + :param symbol: The symbol to find the container of. + :param include_body: whether to include the body of the symbol in the result. """ assert self.loop result = asyncio.run_coroutine_threadsafe( - self.language_server.request_container_of_symbol(symbol), self.loop + self.language_server.request_container_of_symbol(symbol, include_body=include_body), self.loop ).result() return result def request_defining_symbol( - self, relative_file_path: str, line: int, column: int + self, relative_file_path: str, line: int, column: int, + include_body: bool = False, ) -> Optional[multilspy_types.UnifiedSymbolInformation]: """ Finds the symbol that defines the symbol at the given location. @@ -1288,14 +1390,33 @@ class SyncLanguageServer: :param relative_file_path: The relative path to the file. :param line: The 0-indexed line number. :param column: The 0-indexed column number. + :param include_body: whether to include the body of the symbol in the result. :return: The symbol information for the definition, or None if not found. """ assert self.loop result = asyncio.run_coroutine_threadsafe( - self.language_server.request_defining_symbol(relative_file_path, line, column), self.loop + self.language_server.request_defining_symbol(relative_file_path, line, column, include_body=include_body), self.loop ).result() return result + def retrieve_full_file_content(self, relative_file_path: str) -> str: + """ + Retrieve the full content of the given file. + """ + return self.language_server.retrieve_full_file_content(relative_file_path) + + def retrieve_content_around_line(self, relative_file_path: str, line: int, context_lines_before: int = 0, context_lines_after: int = 0) -> MatchedConsecutiveLines: + """ + Retrieve the content of the given file around the given line. + + :param relative_file_path: The relative path of the file to retrieve the content from + :param line: The line number to retrieve the content around + :param context_lines_before: The number of lines to retrieve before the given line + :param context_lines_after: The number of lines to retrieve after the given line + :return MatchedConsecutiveLines: A container with the desired lines. + """ + return self.language_server.retrieve_content_around_line(relative_file_path, line, context_lines_before, context_lines_after) + def start(self) -> "SyncLanguageServer": """ Starts the language server process and connects to it. Call shutdown when ready. diff --git a/src/multilspy/multilspy_types.py b/src/multilspy/multilspy_types.py index 936dd0eb..eae28088 100644 --- a/src/multilspy/multilspy_types.py +++ b/src/multilspy/multilspy_types.py @@ -211,6 +211,9 @@ class UnifiedSymbolInformation(TypedDict): selectionRange: NotRequired[Range] """ The range that should be selected and revealed when this symbol is being picked, e.g the name of a function. Must be contained by the `range`. """ + + body: NotRequired[str] + """ The body of the symbol. """ TreeRepr = Dict[int, List['TreeRepr']] diff --git a/src/serena/sample_module.py b/src/serena/sample_module.py deleted file mode 100644 index 3565e64b..00000000 --- a/src/serena/sample_module.py +++ /dev/null @@ -1,25 +0,0 @@ -"""Top-level module.""" - - -class SampleClass: - """Sample docstring. Note that init docstrings should be underneath the class and not the init method itself - (this looks prettier in sphinx). They still will be rendered correctly in pycharm's quick documentation. - - :param param: some parameter - """ - - def __init__(self, param: str | None = None): - self.hello = "hello " - self.param = param - - def sample_method(self, name: str) -> str: - """>>> from serena.sample_module import SampleClass - >>> - >>> greeter = SampleClass() - >>> greeter.sample_method("Oraios AI") - 'hello Oraios AI'. - - :param name: - :return: - """ - return self.hello + name diff --git a/src/serena/sample_package/__init__.py b/src/serena/sample_package/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/serena/sample_package/sample_module.py b/src/serena/sample_package/sample_module.py deleted file mode 100644 index 8902797c..00000000 --- a/src/serena/sample_package/sample_module.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Module in a separate package.""" - - -def hello_stranger() -> None: - """Hello.""" - print("Hello from a separate package!") diff --git a/src/serena/text_utils.py b/src/serena/text_utils.py index 93cd5f79..7bc118db 100644 --- a/src/serena/text_utils.py +++ b/src/serena/text_utils.py @@ -1,5 +1,5 @@ import re -from dataclasses import dataclass +from dataclasses import dataclass, field from enum import StrEnum @@ -37,27 +37,46 @@ class TextLine: @dataclass(kw_only=True) -class TextSearchMatch: - """Represents a match found in a text file or a string.""" +class MatchedConsecutiveLines: + """Represents a collection of consecutive lines found through some criterion in a text file or a string. + May include lines before, after, and matched. + """ - context_lines: list[TextLine] + lines: list[TextLine] + """All lines in the context of the match. At least one of them should be of match_type MATCH.""" source_file_path: str | None = None - """Path to the file where the match was found.""" + """Path to the file where the match was found (Metadata).""" + + # set in post-init + lines_before_matched: list[TextLine] = field(default_factory=list) + matched_lines: list[TextLine] = field(default_factory=list) + lines_after_matched: list[TextLine] = field(default_factory=list) + + def __post_init__(self): + for line in self.lines: + if line.match_type == LineType.BEFORE_MATCH: + self.lines_before_matched.append(line) + elif line.match_type == LineType.MATCH: + self.matched_lines.append(line) + elif line.match_type == LineType.AFTER_MATCH: + self.lines_after_matched.append(line) + + assert len(self.matched_lines) > 0, "At least one matched line is required" @property def start_line(self) -> int: - return self.context_lines[0].line_number + return self.lines[0].line_number @property def end_line(self) -> int: - return self.context_lines[-1].line_number + return self.lines[-1].line_number @property def num_matched_lines(self) -> int: - return len([line for line in self.context_lines if line.match_type == LineType.MATCH]) + return len(self.matched_lines) def to_display_string(self) -> str: - return "\n".join([line.format_line() for line in self.context_lines]) + return "\n".join([line.format_line() for line in self.lines]) def search_text( @@ -68,7 +87,7 @@ def search_text( context_lines_before: int = 0, context_lines_after: int = 0, is_glob: bool = False, -) -> list[TextSearchMatch]: +) -> list[MatchedConsecutiveLines]: """ Search for a pattern in text content. Supports both regex and glob-like patterns. @@ -160,7 +179,7 @@ def search_text( context_lines.append(TextLine(line_number=line_num, line_content=lines[i], match_type=match_type)) - matches.append(TextSearchMatch(context_lines=context_lines, source_file_path=source_file_path)) + matches.append(MatchedConsecutiveLines(lines=context_lines, source_file_path=source_file_path)) else: # Search line by line for i, line in enumerate(lines): @@ -183,6 +202,6 @@ def search_text( context_lines.append(TextLine(line_number=context_line_num, line_content=lines[j], match_type=match_type)) - matches.append(TextSearchMatch(context_lines=context_lines, source_file_path=source_file_path)) + matches.append(MatchedConsecutiveLines(lines=context_lines, source_file_path=source_file_path)) return matches diff --git a/test/conftest.py b/test/conftest.py index 2e0cbf78..59cb70e0 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -2,6 +2,10 @@ 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="session") def resources_dir() -> Path: @@ -13,3 +17,22 @@ def resources_dir() -> Path: @pytest.fixture(scope="session") def repo_path() -> Path: return Path(__file__).parent / "resources" / "test_repo" + + +@pytest.fixture(scope="session") +def language_server(repo_path: Path): + """Create a SyncLanguageServer instance configured to use the test repository.""" + config = MultilspyConfig(code_language=Language.PYTHON) + logger = MultilspyLogger() + + # Create a language server instance + server = SyncLanguageServer.create(config, logger, str(repo_path)) + + # Start the server + server.start() + + try: + yield server + finally: + # Ensure server is shut down + server.stop() diff --git a/test/multilspy/test_basic.py b/test/multilspy/test_basic.py new file mode 100644 index 00000000..c6b300d5 --- /dev/null +++ b/test/multilspy/test_basic.py @@ -0,0 +1,167 @@ +""" +Basic integration tests for the language server functionality. + +These tests validate the functionality of the language server APIs +like request_references using the test repository. +""" + +from pathlib import Path + +from multilspy.language_server import SyncLanguageServer +from serena.text_utils import LineType + + +class TestLanguageServerBasics: + """Test basic functionality of the language server.""" + + def test_request_references_user_class(self, language_server: SyncLanguageServer, repo_path: Path): + """Test request_references on the User class.""" + # Get references to the User class in models.py + file_path = str(repo_path / "test_repo" / "models.py") + # Line 31 contains the User class definition + references = language_server.request_references(file_path, 31, 6) + + # User class should be referenced in multiple files + assert len(references) > 0 + + # At least two references should be found (one for the class definition itself) + assert len(references) > 1 + + def test_request_references_item_class(self, language_server: SyncLanguageServer, repo_path: Path): + """Test request_references on the Item class.""" + # Get references to the Item class in models.py + file_path = str(repo_path / "test_repo" / "models.py") + # Line 56 contains the Item class definition + references = language_server.request_references(file_path, 56, 6) + + # Item class should be referenced in multiple places + assert len(references) > 0 + + # At least one reference should be in services.py (ItemService class) + services_references = [ref for ref in references if "services.py" in ref["uri"]] + assert len(services_references) > 0 + + def test_request_references_function_parameter(self, language_server: SyncLanguageServer, repo_path: Path): + """Test request_references on a function parameter.""" + # Get references to the id parameter in get_user method + file_path = str(repo_path / "test_repo" / "services.py") + # Line 24 contains the get_user method with id parameter + references = language_server.request_references(file_path, 24, 16) + + # id parameter should be referenced within the method + assert len(references) > 0 + + def test_request_references_create_user_method(self, language_server: SyncLanguageServer, repo_path: Path): + # Get references to the create_user method in UserService + file_path = str(repo_path / "test_repo" / "services.py") + # Line 15 contains the create_user method definition + references = language_server.request_references(file_path, 15, 9) + + # Verify that we get valid references + assert len(references) > 1 + + def test_retrieve_content_around_line(self, language_server: SyncLanguageServer, repo_path: Path): + """Test retrieve_content_around_line functionality with various scenarios.""" + file_path = str(repo_path / "test_repo" / "models.py") + + # Scenario 1: Just a single line (User class definition) + line_31 = language_server.retrieve_content_around_line(file_path, 31) + assert len(line_31.lines) == 1 + assert "class User(BaseModel):" in line_31.lines[0].line_content + assert line_31.lines[0].line_number == 31 + assert line_31.lines[0].match_type == LineType.MATCH + + # Scenario 2: Context above and below + with_context_around_user = language_server.retrieve_content_around_line(file_path, 31, 2, 2) + assert len(with_context_around_user.lines) == 5 + # Check line content + assert "class User(BaseModel):" in with_context_around_user.matched_lines[0].line_content + assert with_context_around_user.num_matched_lines == 1 + assert " User model representing a system user." in with_context_around_user.lines[4].line_content + # Check line numbers + assert with_context_around_user.lines[0].line_number == 29 + assert with_context_around_user.lines[1].line_number == 30 + assert with_context_around_user.lines[2].line_number == 31 + assert with_context_around_user.lines[3].line_number == 32 + assert with_context_around_user.lines[4].line_number == 33 + # Check match types + assert with_context_around_user.lines[0].match_type == LineType.BEFORE_MATCH + assert with_context_around_user.lines[1].match_type == LineType.BEFORE_MATCH + assert with_context_around_user.lines[2].match_type == LineType.MATCH + assert with_context_around_user.lines[3].match_type == LineType.AFTER_MATCH + assert with_context_around_user.lines[4].match_type == LineType.AFTER_MATCH + + # Scenario 3a: Only context above + with_context_above = language_server.retrieve_content_around_line(file_path, 31, 3, 0) + assert len(with_context_above.lines) == 4 + assert "return cls(id=id, name=name)" in with_context_above.lines[0].line_content + assert "class User(BaseModel):" in with_context_above.matched_lines[0].line_content + assert with_context_above.num_matched_lines == 1 + # Check line numbers + assert with_context_above.lines[0].line_number == 28 + assert with_context_above.lines[1].line_number == 29 + assert with_context_above.lines[2].line_number == 30 + assert with_context_above.lines[3].line_number == 31 + # Check match types + assert with_context_above.lines[0].match_type == LineType.BEFORE_MATCH + assert with_context_above.lines[1].match_type == LineType.BEFORE_MATCH + assert with_context_above.lines[2].match_type == LineType.BEFORE_MATCH + assert with_context_above.lines[3].match_type == LineType.MATCH + + # Scenario 3b: Only context below + with_context_below = language_server.retrieve_content_around_line(file_path, 31, 0, 3) + assert len(with_context_below.lines) == 4 + assert "class User(BaseModel):" in with_context_below.matched_lines[0].line_content + assert with_context_below.num_matched_lines == 1 + assert with_context_below.lines[0].line_number == 31 + assert with_context_below.lines[1].line_number == 32 + assert with_context_below.lines[2].line_number == 33 + assert with_context_below.lines[3].line_number == 34 + # Check match types + assert with_context_below.lines[0].match_type == LineType.MATCH + assert with_context_below.lines[1].match_type == LineType.AFTER_MATCH + assert with_context_below.lines[2].match_type == LineType.AFTER_MATCH + assert with_context_below.lines[3].match_type == LineType.AFTER_MATCH + + # Scenario 4a: Edge case - context above but line is at 0 + first_line_with_context_around = language_server.retrieve_content_around_line(file_path, 0, 2, 1) + assert len(first_line_with_context_around.lines) <= 4 # Should have at most 4 lines (line 0 + 1 below + up to 2 above) + assert first_line_with_context_around.lines[0].line_number <= 2 # First line should be at most line 2 + # Check match type for the target line + for line in first_line_with_context_around.lines: + if line.line_number == 0: + assert line.match_type == LineType.MATCH + elif line.line_number < 0: + assert line.match_type == LineType.BEFORE_MATCH + else: + assert line.match_type == LineType.AFTER_MATCH + + # Scenario 4b: Edge case - context above but line is at 1 + second_line_with_context_above = language_server.retrieve_content_around_line(file_path, 1, 3, 1) + assert len(second_line_with_context_above.lines) <= 5 # Should have at most 5 lines (line 1 + 1 below + up to 3 above) + assert second_line_with_context_above.lines[0].line_number <= 1 # First line should be at most line 1 + # Check match type for the target line + for line in second_line_with_context_above.lines: + if line.line_number == 1: + assert line.match_type == LineType.MATCH + elif line.line_number < 1: + assert line.match_type == LineType.BEFORE_MATCH + else: + assert line.match_type == LineType.AFTER_MATCH + + # Scenario 4c: Edge case - context below but line is at the end of file + # First get the total number of lines in the file + all_content = language_server.retrieve_full_file_content(file_path) + total_lines = len(all_content.split("\n")) + + last_line_with_context_around = language_server.retrieve_content_around_line(file_path, total_lines - 1, 1, 3) + assert len(last_line_with_context_around.lines) <= 5 # Should have at most 5 lines (last line + 1 above + up to 3 below) + assert last_line_with_context_around.lines[-1].line_number >= total_lines - 4 # Last line should be at least total_lines - 4 + # Check match type for the target line + for line in last_line_with_context_around.lines: + if line.line_number == total_lines - 1: + assert line.match_type == LineType.MATCH + elif line.line_number < total_lines - 1: + assert line.match_type == LineType.BEFORE_MATCH + else: + assert line.match_type == LineType.AFTER_MATCH diff --git a/test/multilspy/test_language_server_basic.py b/test/multilspy/test_language_server_basic.py deleted file mode 100644 index b61ec701..00000000 --- a/test/multilspy/test_language_server_basic.py +++ /dev/null @@ -1,83 +0,0 @@ -""" -Basic integration tests for the language server functionality. - -These tests validate the functionality of the language server APIs -like request_references using the test repository. -""" - -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="session") -def language_server(repo_path: Path): - """Create a SyncLanguageServer instance configured to use the test repository.""" - config = MultilspyConfig(code_language=Language.PYTHON) - logger = MultilspyLogger() - - # Create a language server instance - server = SyncLanguageServer.create(config, logger, str(repo_path)) - - # Start the server - server.start() - - try: - yield server - finally: - # Ensure server is shut down - server.stop() - - -class TestLanguageServerBasics: - """Test basic functionality of the language server.""" - - def test_request_references_user_class(self, language_server: SyncLanguageServer, repo_path: Path): - """Test request_references on the User class.""" - # Get references to the User class in models.py - file_path = str(repo_path / "test_repo" / "models.py") - # Line 31 contains the User class definition - references = language_server.request_references(file_path, 31, 6) - - # User class should be referenced in multiple files - assert len(references) > 0 - - # At least two references should be found (one for the class definition itself) - assert len(references) > 1 - - def test_request_references_item_class(self, language_server: SyncLanguageServer, repo_path: Path): - """Test request_references on the Item class.""" - # Get references to the Item class in models.py - file_path = str(repo_path / "test_repo" / "models.py") - # Line 56 contains the Item class definition - references = language_server.request_references(file_path, 56, 6) - - # Item class should be referenced in multiple places - assert len(references) > 0 - - # At least one reference should be in services.py (ItemService class) - services_references = [ref for ref in references if "services.py" in ref["uri"]] - assert len(services_references) > 0 - - def test_request_references_function_parameter(self, language_server: SyncLanguageServer, repo_path: Path): - """Test request_references on a function parameter.""" - # Get references to the id parameter in get_user method - file_path = str(repo_path / "test_repo" / "services.py") - # Line 24 contains the get_user method with id parameter - references = language_server.request_references(file_path, 24, 16) - - # id parameter should be referenced within the method - assert len(references) > 0 - - def test_request_references_create_user_method(self, language_server: SyncLanguageServer, repo_path: Path): - # Get references to the create_user method in UserService - file_path = str(repo_path / "test_repo" / "services.py") - # Line 15 contains the create_user method definition - references = language_server.request_references(file_path, 15, 9) - - # Verify that we get valid references - assert len(references) > 1 diff --git a/test/multilspy/test_language_server_symbols.py b/test/multilspy/test_symbol_retrieval.py similarity index 96% rename from test/multilspy/test_language_server_symbols.py rename to test/multilspy/test_symbol_retrieval.py index 33d48199..1ef68fe9 100644 --- a/test/multilspy/test_language_server_symbols.py +++ b/test/multilspy/test_symbol_retrieval.py @@ -8,33 +8,10 @@ These tests focus on the following methods: 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 from multilspy.multilspy_types import SymbolKind -@pytest.fixture(scope="session") -def language_server(repo_path: Path): - """Create a SyncLanguageServer instance configured to use the test repository.""" - config = MultilspyConfig(code_language=Language.PYTHON) - logger = MultilspyLogger() - - # Create a language server instance - server = SyncLanguageServer.create(config, logger, str(repo_path)) - - # Start the server - server.start() - - try: - yield server - finally: - # Ensure server is shut down - server.stop() - - class TestLanguageServerSymbols: """Test the language server's symbol-related functionality.""" @@ -43,12 +20,13 @@ class TestLanguageServerSymbols: # Test for a position inside the create_user method file_path = str(repo_path / "test_repo" / "services.py") # Line 17 is inside the create_user method body - containing_symbol = language_server.request_containing_symbol(file_path, 17, 20) + containing_symbol = language_server.request_containing_symbol(file_path, 17, 20, include_body=True) # Verify that we found the containing symbol assert containing_symbol is not None assert containing_symbol["name"] == "create_user" assert containing_symbol["kind"] == SymbolKind.Method + assert containing_symbol["body"].strip().startswith("def create_user(self") def test_references_to_variables(self, language_server: SyncLanguageServer, repo_path: Path): """Test request_referencing_symbols for a variable.""" diff --git a/test/resources/test_repo/.multilspy/cache/document_symbols_cache.pkl b/test/resources/test_repo/.multilspy/cache/document_symbols_cache.pkl deleted file mode 100644 index d77dd969..00000000 Binary files a/test/resources/test_repo/.multilspy/cache/document_symbols_cache.pkl and /dev/null differ diff --git a/test/serena/test_sample_class.py b/test/serena/test_sample_class.py deleted file mode 100644 index 25698277..00000000 --- a/test/serena/test_sample_class.py +++ /dev/null @@ -1,8 +0,0 @@ -from serena.sample_module import SampleClass - - -# the suggested naming convention for unit tests is test_method_name_testDescriptionInCamelCase -# this leads to a nicely readable output of pytest -def test_sample_class_attributes_greeterSaysHello(): - greeter = SampleClass() - assert greeter.hello == "hello " diff --git a/test/serena/test_text_utils.py b/test/serena/test_text_utils.py index 373e95ba..0092eb96 100644 --- a/test/serena/test_text_utils.py +++ b/test/serena/test_text_utils.py @@ -21,7 +21,7 @@ class TestTextUtils: assert matches[0].num_matched_lines == 1 assert matches[0].start_line == 3 assert matches[0].end_line == 3 - assert matches[0].context_lines[0].line_content.strip() == 'print("Hello, World!")' + assert matches[0].lines[0].line_content.strip() == 'print("Hello, World!")' def test_search_text_with_regex_pattern(self): """Test searching with a regex pattern.""" @@ -42,10 +42,10 @@ class TestTextUtils: matches = search_text(pattern, content=content) assert len(matches) == 3 - assert matches[0].context_lines[0].match_type == LineType.MATCH - assert "def __init__" in matches[0].context_lines[0].line_content - assert "def process" in matches[1].context_lines[0].line_content - assert "def filter" in matches[2].context_lines[0].line_content + assert matches[0].lines[0].match_type == LineType.MATCH + assert "def __init__" in matches[0].lines[0].line_content + assert "def process" in matches[1].lines[0].line_content + assert "def filter" in matches[2].lines[0].line_content def test_search_text_with_compiled_regex(self): """Test searching with a pre-compiled regex pattern.""" @@ -68,8 +68,8 @@ class TestTextUtils: matches = search_text(pattern, content=content) assert len(matches) == 2 - assert "DEBUG = True" in matches[0].context_lines[0].line_content - assert "MAX_RETRIES = 3" in matches[1].context_lines[0].line_content + assert "DEBUG = True" in matches[0].lines[0].line_content + assert "MAX_RETRIES = 3" in matches[1].lines[0].line_content def test_search_text_with_context_lines(self): """Test searching with context lines before and after the match.""" @@ -91,15 +91,15 @@ class TestTextUtils: # Check the first match with context first_match = matches[0] - assert len(first_match.context_lines) == 3 - assert first_match.context_lines[0].match_type == LineType.BEFORE_MATCH - assert first_match.context_lines[1].match_type == LineType.MATCH - assert first_match.context_lines[2].match_type == LineType.AFTER_MATCH + assert len(first_match.lines) == 3 + assert first_match.lines[0].match_type == LineType.BEFORE_MATCH + assert first_match.lines[1].match_type == LineType.MATCH + assert first_match.lines[2].match_type == LineType.AFTER_MATCH # Verify the content of lines - assert "if a > b:" in first_match.context_lines[0].line_content - assert "return a * c" in first_match.context_lines[1].line_content - assert "elif b > a:" in first_match.context_lines[2].line_content + assert "if a > b:" in first_match.lines[0].line_content + assert "return a * c" in first_match.lines[1].line_content + assert "elif b > a:" in first_match.lines[2].line_content def test_search_text_with_multiline_match(self): """Test searching with multiline pattern matching.""" @@ -120,10 +120,10 @@ class TestTextUtils: assert len(matches) == 1 multiline_match = matches[0] assert multiline_match.num_matched_lines >= 3 - assert "if n <= 1:" in multiline_match.context_lines[0].line_content + assert "if n <= 1:" in multiline_match.lines[0].line_content # All matched lines should have match_type == LineType.MATCH - match_lines = [line for line in multiline_match.context_lines if line.match_type == LineType.MATCH] + match_lines = [line for line in multiline_match.lines if line.match_type == LineType.MATCH] assert len(match_lines) >= 3 def test_search_text_with_glob_pattern(self): @@ -146,9 +146,9 @@ class TestTextUtils: matches = search_text("*_user*", content=content, is_glob=True) assert len(matches) == 3 - assert "get_user" in matches[0].context_lines[0].line_content - assert "create_user" in matches[1].context_lines[0].line_content - assert "update_user" in matches[2].context_lines[0].line_content + assert "get_user" in matches[0].lines[0].line_content + assert "create_user" in matches[1].lines[0].line_content + assert "update_user" in matches[2].lines[0].line_content def test_search_text_with_complex_glob_pattern(self): """Test searching with more complex glob patterns.""" @@ -175,7 +175,7 @@ class TestTextUtils: instance_matches = [ line.line_content for match in matches - for line in match.context_lines + for line in match.lines if line.match_type == LineType.MATCH and "isinstance(item," in line.line_content ] assert len(instance_matches) >= 2