Merge branch 'main' of github.com:oraios/serena

This commit is contained in:
Dominik Jain committed 2025-03-25 00:21:20 +01:00
commit 3dc5a25d77
16 files changed
+419 -223

No files matched your search

+3
View File
@@ -140,3 +140,6 @@ pylint.html
/*.yml
!myproject.demo.yml
# Cache is in .multilspy
**/.multilspy/**
+5
View File
@@ -0,0 +1,5 @@
{
"cSpell.words": [
"multilspy"
]
}
+1 -2
View File
@@ -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()
+164 -43
View File
@@ -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.
+3
View File
@@ -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']]
-25
View File
@@ -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
Whitespace-only changes.
@@ -1,6 +0,0 @@
"""Module in a separate package."""
def hello_stranger() -> None:
"""Hello."""
print("Hello from a separate package!")
+31 -12
View File
@@ -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
+23
View File
@@ -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()
+167
View File
@@ -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
@@ -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
@@ -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."""
-8
View File
@@ -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 "
+20 -20
View File
@@ -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