From df0f476614a0c7a1242123956f2766dd771e26df Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Wed, 25 Mar 2026 11:03:14 +0100 Subject: [PATCH 01/12] LS: mark LS that support implementation request --- src/solidlsp/language_servers/csharp_language_server.py | 4 ++++ src/solidlsp/language_servers/eclipse_jdtls.py | 4 ++++ src/solidlsp/language_servers/gopls.py | 4 ++++ src/solidlsp/language_servers/rust_analyzer.py | 4 ++++ src/solidlsp/language_servers/typescript_language_server.py | 4 ++++ src/solidlsp/ls_config.py | 6 ++++++ 6 files changed, 26 insertions(+) diff --git a/src/solidlsp/language_servers/csharp_language_server.py b/src/solidlsp/language_servers/csharp_language_server.py index 59b1fc9e..51bed54e 100644 --- a/src/solidlsp/language_servers/csharp_language_server.py +++ b/src/solidlsp/language_servers/csharp_language_server.py @@ -206,6 +206,10 @@ class CSharpLanguageServer(SolidLanguageServer): scripts. If you have a custom .NET installation, ensure 'dotnet' is available in PATH with version 10 or higher. """ + @classmethod + def supports_implementation_request(cls) -> bool: + return True + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): """ Creates a CSharpLanguageServer instance. This class is not meant to be instantiated directly. diff --git a/src/solidlsp/language_servers/eclipse_jdtls.py b/src/solidlsp/language_servers/eclipse_jdtls.py index eb6fbf02..50deb1b0 100644 --- a/src/solidlsp/language_servers/eclipse_jdtls.py +++ b/src/solidlsp/language_servers/eclipse_jdtls.py @@ -152,6 +152,10 @@ class EclipseJDTLS(SolidLanguageServer): ``` """ + @classmethod + def supports_implementation_request(cls) -> bool: + return True + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): """ Creates a new EclipseJDTLS instance initializing the language server settings appropriately. diff --git a/src/solidlsp/language_servers/gopls.py b/src/solidlsp/language_servers/gopls.py index c855b836..3c098178 100644 --- a/src/solidlsp/language_servers/gopls.py +++ b/src/solidlsp/language_servers/gopls.py @@ -23,6 +23,10 @@ class Gopls(SolidLanguageServer): Provides Go specific instantiation of the LanguageServer class using gopls. """ + @classmethod + def supports_implementation_request(cls) -> bool: + return True + @override def is_ignored_dirname(self, dirname: str) -> bool: # For Go projects, we should ignore: diff --git a/src/solidlsp/language_servers/rust_analyzer.py b/src/solidlsp/language_servers/rust_analyzer.py index f0ff00e6..2e222b5a 100644 --- a/src/solidlsp/language_servers/rust_analyzer.py +++ b/src/solidlsp/language_servers/rust_analyzer.py @@ -26,6 +26,10 @@ class RustAnalyzer(SolidLanguageServer): Provides Rust specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Rust. """ + @classmethod + def supports_implementation_request(cls) -> bool: + return True + @staticmethod def _determine_log_level(line: str) -> int: """Classify rust-analyzer stderr output to avoid false-positive errors.""" diff --git a/src/solidlsp/language_servers/typescript_language_server.py b/src/solidlsp/language_servers/typescript_language_server.py index 5b7ec4fe..32e9ac22 100644 --- a/src/solidlsp/language_servers/typescript_language_server.py +++ b/src/solidlsp/language_servers/typescript_language_server.py @@ -67,6 +67,10 @@ class TypeScriptLanguageServer(SolidLanguageServer): - typescript_language_server_version: Version of typescript-language-server to install (default: "5.1.3") """ + @classmethod + def supports_implementation_request(cls) -> bool: + return True + # Safety timeout for $/progress-based indexing wait. Normally the event fires # well within this window; the timeout is only hit if the server never sends progress. INDEXING_PROGRESS_TIMEOUT = 15.0 if os.name == "nt" else 10.0 diff --git a/src/solidlsp/ls_config.py b/src/solidlsp/ls_config.py index eb527dc3..1eb1295a 100644 --- a/src/solidlsp/ls_config.py +++ b/src/solidlsp/ls_config.py @@ -207,6 +207,12 @@ class Language(str, Enum): case _: return 2 + def supports_implementation_request(self) -> bool: + """ + Return whether the default language server for this language supports ``textDocument/implementation``. + """ + return self.get_ls_class().supports_implementation_request() + def get_source_fn_matcher(self) -> FilenameMatcher: match self: case self.PYTHON | self.PYTHON_JEDI | self.PYTHON_TY: From c55e0c900e9b1249ad0bd682fe2dc7407a6e547b Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Wed, 25 Mar 2026 11:03:14 +0100 Subject: [PATCH 02/12] Diagnostics, implementation and definition tools (WIP) --- scripts/demo_diagnostics.py | 170 +++++ scripts/demo_find_defining_symbol.py | 128 ++++ scripts/demo_find_implementing_symbol.py | 76 +++ src/serena/code_editor.py | 53 ++ src/serena/symbol.py | 261 +++++++- src/serena/tools/file_tools.py | 55 +- src/serena/tools/symbol_tools.py | 471 +++++++++++++- src/serena/tools/tools_base.py | 200 +++++- src/solidlsp/ls.py | 275 +++++++- src/solidlsp/ls_process.py | 17 + src/solidlsp/ls_types.py | 2 +- test/conftest.py | 61 ++ test/diagnostics_cases.py | 230 +++++++ .../src/test_app/diagnostics_sample.clj | 9 + .../repos/cpp/test_repo/compile_commands.json | 7 +- .../cpp/test_repo/diagnostics_sample.cpp | 8 + .../csharp/test_repo/DiagnosticsSample.cs | 19 + .../repos/csharp/test_repo/Program.cs | 8 +- .../test_repo/Services/ConsoleGreeter.cs | 10 + .../csharp/test_repo/Services/IGreeter.cs | 7 + .../fsharp/test_repo/DiagnosticsSample.fs | 11 + .../repos/fsharp/test_repo/Formatter.fs | 8 + .../repos/fsharp/test_repo/Program.fs | 10 +- .../repos/fsharp/test_repo/Shapes.fs | 10 + .../repos/fsharp/test_repo/TestProject.fsproj | 5 +- .../repos/go/test_repo/diagnostics_sample.go | 11 + test/resources/repos/go/test_repo/main.go | 12 + .../main/java/test_repo/ConsoleGreeter.java | 8 + .../java/test_repo/DiagnosticsSample.java | 13 + .../src/main/java/test_repo/Greeter.java | 5 + .../src/main/java/test_repo/Main.java | 2 + .../gradle/wrapper/gradle-wrapper.properties | 7 + test/resources/repos/kotlin/test_repo/gradlew | 251 ++++++++ .../repos/kotlin/test_repo/gradlew.bat | 94 +++ .../kotlin/test_repo/DiagnosticsSample.kt | 11 + .../lean4/test_repo/DiagnosticsSample.lean | 6 + test/resources/repos/lua/test_repo/main.lua | 11 +- .../repos/lua/test_repo/src/animals.lua | 37 ++ .../php/test_repo/diagnostics_sample.php | 11 + .../test_repo/diagnostics_sample.ps1 | 9 + .../test_repo/test_repo/diagnostics_sample.py | 11 + test/resources/repos/ruby/test_repo/lib.rb | 12 + test/resources/repos/ruby/test_repo/main.rb | 2 + .../rust/test_repo/src/diagnostics_sample.rs | 9 + .../resources/repos/rust/test_repo/src/lib.rs | 14 + .../repos/rust/test_repo/src/main.rs | 4 +- .../typescript/test_repo/.serena/project.yml | 148 ++++- .../test_repo/diagnostics_sample.ts | 9 + .../repos/typescript/test_repo/formatters.ts | 9 + .../repos/typescript/test_repo/index.ts | 5 + .../__snapshots__/test_symbol_editing.ambr | 32 +- test/serena/test_serena_agent.py | 596 +++++++++++++++++- test/solidlsp/clojure/__init__.py | 4 +- test/solidlsp/csharp/test_csharp_basic.py | 28 + test/solidlsp/fortran/test_fortran_basic.py | 28 + test/solidlsp/fsharp/test_fsharp_basic.py | 29 +- test/solidlsp/go/test_go_basic.py | 28 + test/solidlsp/java/test_java_basic.py | 34 +- test/solidlsp/lua/test_lua_basic.py | 38 +- test/solidlsp/python/test_python_basic.py | 21 + test/solidlsp/rust/test_rust_basic.py | 28 + test/solidlsp/solidity/test_solidity_basic.py | 28 + test/solidlsp/test_defining_symbol_matrix.py | 139 ++++ test/solidlsp/test_diagnostics_matrix.py | 79 +++ test/solidlsp/test_ls_common.py | 4 +- .../typescript/test_typescript_basic.py | 28 + 66 files changed, 3928 insertions(+), 38 deletions(-) create mode 100644 scripts/demo_diagnostics.py create mode 100644 scripts/demo_find_defining_symbol.py create mode 100644 scripts/demo_find_implementing_symbol.py create mode 100644 test/diagnostics_cases.py create mode 100644 test/resources/repos/clojure/test_repo/src/test_app/diagnostics_sample.clj create mode 100644 test/resources/repos/cpp/test_repo/diagnostics_sample.cpp create mode 100644 test/resources/repos/csharp/test_repo/DiagnosticsSample.cs create mode 100644 test/resources/repos/csharp/test_repo/Services/ConsoleGreeter.cs create mode 100644 test/resources/repos/csharp/test_repo/Services/IGreeter.cs create mode 100644 test/resources/repos/fsharp/test_repo/DiagnosticsSample.fs create mode 100644 test/resources/repos/fsharp/test_repo/Formatter.fs create mode 100644 test/resources/repos/fsharp/test_repo/Shapes.fs create mode 100644 test/resources/repos/go/test_repo/diagnostics_sample.go create mode 100644 test/resources/repos/java/test_repo/src/main/java/test_repo/ConsoleGreeter.java create mode 100644 test/resources/repos/java/test_repo/src/main/java/test_repo/DiagnosticsSample.java create mode 100644 test/resources/repos/java/test_repo/src/main/java/test_repo/Greeter.java create mode 100644 test/resources/repos/kotlin/test_repo/gradle/wrapper/gradle-wrapper.properties create mode 100644 test/resources/repos/kotlin/test_repo/gradlew create mode 100644 test/resources/repos/kotlin/test_repo/gradlew.bat create mode 100644 test/resources/repos/kotlin/test_repo/src/main/kotlin/test_repo/DiagnosticsSample.kt create mode 100644 test/resources/repos/lean4/test_repo/DiagnosticsSample.lean create mode 100644 test/resources/repos/lua/test_repo/src/animals.lua create mode 100644 test/resources/repos/php/test_repo/diagnostics_sample.php create mode 100644 test/resources/repos/powershell/test_repo/diagnostics_sample.ps1 create mode 100644 test/resources/repos/python/test_repo/test_repo/diagnostics_sample.py create mode 100644 test/resources/repos/rust/test_repo/src/diagnostics_sample.rs create mode 100644 test/resources/repos/typescript/test_repo/diagnostics_sample.ts create mode 100644 test/resources/repos/typescript/test_repo/formatters.ts create mode 100644 test/solidlsp/test_defining_symbol_matrix.py create mode 100644 test/solidlsp/test_diagnostics_matrix.py diff --git a/scripts/demo_diagnostics.py b/scripts/demo_diagnostics.py new file mode 100644 index 00000000..7aa85361 --- /dev/null +++ b/scripts/demo_diagnostics.py @@ -0,0 +1,170 @@ +""" +Demonstrates diagnostics tools and edit-tool diagnostic reporting on the Serena repo itself. + +The script creates a temporary Python file inside this repository, introduces one warning, +shows file and symbol diagnostics, then introduces another warning and verifies that the +second edit reports only the newly introduced warning. +""" + +import json +import shutil +import tempfile +from pathlib import Path +from pprint import pprint + +from serena.agent import SerenaAgent +from serena.config.serena_config import LanguageBackend, ProjectConfig, RegisteredProject, SerenaConfig +from serena.constants import REPO_ROOT +from serena.project import Project +from serena.tools import CreateTextFileTool, GetDiagnosticsForFileTool, GetDiagnosticsForSymbolTool, ReplaceContentTool, Tool +from solidlsp.ls_config import Language + +SEPARATOR = "=" * 80 +REPO_PATH = Path(REPO_ROOT) +EDIT_RESULT_PREFIX = "Edit introduced new warning-or-higher diagnostics: " + + +def make_agent() -> SerenaAgent: + """Create an LSP-backed Serena agent for the Serena repository.""" + serena_config = SerenaConfig.from_config_file() + serena_config.web_dashboard = False + serena_config.language_backend = LanguageBackend.LSP + + project = Project( + project_root=str(REPO_PATH), + project_config=ProjectConfig( + project_name="demo_serena_repo", + languages=[Language.PYTHON], + ignored_paths=[], + excluded_tools=[], + read_only=False, + ignore_all_files_in_gitignore=True, + initial_prompt="", + encoding="utf-8", + ), + serena_config=serena_config, + ) + serena_config.projects = [RegisteredProject.from_project_instance(project)] + return SerenaAgent(project="demo_serena_repo", serena_config=serena_config) + + +def print_section(title: str) -> None: + """Print a visibly separated section header.""" + print(f"\n{SEPARATOR}") + print(title) + print(SEPARATOR) + + +def parse_json_result(result: str) -> object: + """Parse and pretty-print JSON tool output.""" + parsed = json.loads(result) + pprint(parsed, width=200) + return parsed + + +def parse_edit_diagnostics_result(result: str) -> dict: + """Extract the grouped diagnostics payload from an edit-tool result.""" + assert result.startswith(EDIT_RESULT_PREFIX), result + return json.loads(result[len(EDIT_RESULT_PREFIX) :]) + + +if __name__ == "__main__": + Tool._ENABLE_DIAGNOSTICS = True + + temp_dir = Path(tempfile.mkdtemp(prefix="serena_demo_", dir=REPO_PATH)) + temp_file = temp_dir / "demo_temp_diagnostics.py" + relative_path = temp_file.relative_to(REPO_PATH).as_posix() + + initial_content = """def demo_existing_issue() -> int: + value = 1 + return value +""" + + agent = make_agent() + + try: + # letting the language server finish startup + agent.execute_task(lambda: None) + + create_text_file_tool = agent.get_tool(CreateTextFileTool) + replace_content_tool = agent.get_tool(ReplaceContentTool) + get_diagnostics_for_file_tool = agent.get_tool(GetDiagnosticsForFileTool) + get_diagnostics_for_symbol_tool = agent.get_tool(GetDiagnosticsForSymbolTool) + + # creating a clean temporary file + print_section("Create Temporary File") + create_result = agent.execute_task(lambda: create_text_file_tool.apply(relative_path=relative_path, content=initial_content)) + print(create_result) + + # showing file diagnostics before introducing any warning + print_section("Initial File Diagnostics") + initial_diagnostics_result = agent.execute_task( + lambda: get_diagnostics_for_file_tool.apply(relative_path=relative_path, min_severity=2) + ) + initial_diagnostics = parse_json_result(initial_diagnostics_result) + assert initial_diagnostics == {}, initial_diagnostics + + # introducing the first warning + print_section("First Edit Result") + first_edit_result = agent.execute_task( + lambda: replace_content_tool.apply( + relative_path=relative_path, + needle="value = 1", + repl="value = missing_one", + mode="literal", + ) + ) + print(first_edit_result) + first_edit_diagnostics = parse_edit_diagnostics_result(first_edit_result) + pprint(first_edit_diagnostics, width=200) + assert "missing_one" in json.dumps(first_edit_diagnostics), first_edit_diagnostics + + # showing the file- and symbol-level diagnostics after the first warning + print_section("File Diagnostics After First Edit") + diagnostics_after_first_edit_result = agent.execute_task( + lambda: get_diagnostics_for_file_tool.apply(relative_path=relative_path, min_severity=2) + ) + diagnostics_after_first_edit = parse_json_result(diagnostics_after_first_edit_result) + assert "missing_one" in json.dumps(diagnostics_after_first_edit), diagnostics_after_first_edit + + print_section("Symbol Diagnostics After First Edit") + symbol_diagnostics_result = agent.execute_task( + lambda: get_diagnostics_for_symbol_tool.apply( + name_path="demo_existing_issue", + reference_file=relative_path, + min_severity=2, + ) + ) + symbol_diagnostics = parse_json_result(symbol_diagnostics_result) + assert "missing_one" in json.dumps(symbol_diagnostics), symbol_diagnostics + + # introducing a second warning while keeping the first one unchanged + print_section("Second Edit Result") + second_edit_result = agent.execute_task( + lambda: replace_content_tool.apply( + relative_path=relative_path, + needle=" return value\n", + repl=" other = missing_two\n return value + other\n", + mode="literal", + ) + ) + print(second_edit_result) + second_edit_diagnostics = parse_edit_diagnostics_result(second_edit_result) + pprint(second_edit_diagnostics, width=200) + second_edit_json = json.dumps(second_edit_diagnostics) + assert "missing_two" in second_edit_json, second_edit_diagnostics + assert "missing_one" not in second_edit_json, second_edit_diagnostics + print("\nVerified: the second edit result reports only the newly introduced warning.") + + # showing the complete file diagnostics after both warnings exist + print_section("File Diagnostics After Second Edit") + diagnostics_after_second_edit_result = agent.execute_task( + lambda: get_diagnostics_for_file_tool.apply(relative_path=relative_path, min_severity=2) + ) + diagnostics_after_second_edit = parse_json_result(diagnostics_after_second_edit_result) + diagnostics_after_second_edit_json = json.dumps(diagnostics_after_second_edit) + assert "missing_one" in diagnostics_after_second_edit_json, diagnostics_after_second_edit + assert "missing_two" in diagnostics_after_second_edit_json, diagnostics_after_second_edit + finally: + agent.shutdown() + shutil.rmtree(temp_dir, ignore_errors=True) diff --git a/scripts/demo_find_defining_symbol.py b/scripts/demo_find_defining_symbol.py new file mode 100644 index 00000000..f65b820c --- /dev/null +++ b/scripts/demo_find_defining_symbol.py @@ -0,0 +1,128 @@ +""" +Demonstrates both defining-symbol tools on the Python test repository. +""" + +import json +import re +from pathlib import Path +from pprint import pprint + +from serena.agent import SerenaAgent +from serena.config.serena_config import LanguageBackend, ProjectConfig, RegisteredProject, SerenaConfig +from serena.constants import REPO_ROOT +from serena.project import Project +from serena.tools import FindDefiningSymbolAtLocationTool, FindDefiningSymbolTool +from solidlsp.ls_config import Language + +SEPARATOR = "=" * 80 +PYTHON_TEST_REPO = Path(REPO_ROOT) / "test" / "resources" / "repos" / "python" / "test_repo" +SERVICES_FILE = Path("test_repo") / "services.py" + + +def make_agent(project_root: Path, language: Language, project_name: str) -> SerenaAgent: + """Create an LSP-backed Serena agent for a single explicit project.""" + serena_config = SerenaConfig.from_config_file() + serena_config.web_dashboard = False + serena_config.language_backend = LanguageBackend.LSP + + project = Project( + project_root=str(project_root), + project_config=ProjectConfig( + project_name=project_name, + languages=[language], + ignored_paths=[], + excluded_tools=[], + read_only=False, + ignore_all_files_in_gitignore=True, + initial_prompt="", + encoding="utf-8", + ), + serena_config=serena_config, + ) + serena_config.projects = [RegisteredProject.from_project_instance(project)] + return SerenaAgent(project=project_name, serena_config=serena_config) + + +def print_section(title: str) -> None: + """Print a visibly separated section header.""" + print(f"\n{SEPARATOR}") + print(title) + print(SEPARATOR) + + +def find_identifier_occurrence_position(file_path: Path, identifier: str, occurrence_index: int = 0) -> tuple[int, int]: + """Find the 0-based position of an identifier occurrence in a file.""" + pattern = re.compile(r"\b" + re.escape(identifier) + r"\b") + current_occurrence_index = 0 + with file_path.open(encoding="utf-8") as f: + for line_index, line in enumerate(f): + for match in pattern.finditer(line): + if current_occurrence_index == occurrence_index: + return line_index, match.start() + current_occurrence_index += 1 + raise ValueError(f"Could not find occurrence {occurrence_index} of {identifier!r} in {file_path}") + + +if __name__ == "__main__": + agent = make_agent(PYTHON_TEST_REPO, Language.PYTHON, "demo_python_test_repo") + + try: + # letting the language server finish startup + agent.execute_task(lambda: None) + + relative_path = SERVICES_FILE.as_posix() + services_abs_path = PYTHON_TEST_REPO / SERVICES_FILE + + # resolving via exact location + line, column = find_identifier_occurrence_position(services_abs_path, "User", occurrence_index=1) + find_by_location_tool = agent.get_tool(FindDefiningSymbolAtLocationTool) + location_result = agent.execute_task( + lambda: find_by_location_tool.apply( + relative_path=relative_path, + line=line, + column=column, + include_info=True, + ) + ) + + print_section("FindDefiningSymbolAtLocationTool") + location_symbol = json.loads(location_result) + pprint(location_symbol, width=200) + + # resolving via regex over the full file + find_by_regex_tool = agent.get_tool(FindDefiningSymbolTool) + regex_result = agent.execute_task( + lambda: find_by_regex_tool.apply( + regex=r"from \.models import Item, (User)", + relative_path=relative_path, + include_info=True, + ) + ) + + print_section("FindDefiningSymbolTool (File Regex)") + regex_symbol = json.loads(regex_result) + pprint(regex_symbol, width=200) + + # resolving via regex restricted to one containing symbol body + contained_regex_result = agent.execute_task( + lambda: find_by_regex_tool.apply( + regex=r"=\s+(User)\(", + relative_path=relative_path, + containing_symbol_name_path="UserService/create_user", + include_info=True, + ) + ) + + print_section("FindDefiningSymbolTool (Contained Regex)") + contained_regex_symbol = json.loads(contained_regex_result) + pprint(contained_regex_symbol, width=200) + + # validating the demonstrated result + for symbol in [location_symbol, regex_symbol, contained_regex_symbol]: + assert symbol is not None, "Expected a defining symbol result" + assert symbol.get("relative_path") is not None + assert "models.py" in symbol["relative_path"], symbol + assert "User" in json.dumps(symbol), symbol + print("\nVerified definition target: User in models.py") + finally: + agent.shutdown() diff --git a/scripts/demo_find_implementing_symbol.py b/scripts/demo_find_implementing_symbol.py new file mode 100644 index 00000000..a19d4b94 --- /dev/null +++ b/scripts/demo_find_implementing_symbol.py @@ -0,0 +1,76 @@ +""" +Demonstrates FindImplementationsTool on the Go test repository. +""" + +import json +from pathlib import Path +from pprint import pprint + +from serena.agent import SerenaAgent +from serena.config.serena_config import LanguageBackend, ProjectConfig, RegisteredProject, SerenaConfig +from serena.constants import REPO_ROOT +from serena.project import Project +from serena.tools import FindImplementationsTool +from solidlsp.ls_config import Language + +SEPARATOR = "=" * 80 +GO_TEST_REPO = Path(REPO_ROOT) / "test" / "resources" / "repos" / "go" / "test_repo" + + +def make_agent(project_root: Path, language: Language, project_name: str) -> SerenaAgent: + """Create an LSP-backed Serena agent for a single explicit project.""" + serena_config = SerenaConfig.from_config_file() + serena_config.web_dashboard = False + serena_config.language_backend = LanguageBackend.LSP + + project = Project( + project_root=str(project_root), + project_config=ProjectConfig( + project_name=project_name, + languages=[language], + ignored_paths=[], + excluded_tools=[], + read_only=False, + ignore_all_files_in_gitignore=True, + initial_prompt="", + encoding="utf-8", + ), + serena_config=serena_config, + ) + serena_config.projects = [RegisteredProject.from_project_instance(project)] + return SerenaAgent(project=project_name, serena_config=serena_config) + + +def print_section(title: str) -> None: + """Print a visibly separated section header.""" + print(f"\n{SEPARATOR}") + print(title) + print(SEPARATOR) + + +if __name__ == "__main__": + agent = make_agent(GO_TEST_REPO, Language.GO, "demo_go_test_repo") + + try: + # letting the language server finish startup + agent.execute_task(lambda: None) + + # running the implementation lookup + find_implementations_tool = agent.get_tool(FindImplementationsTool) + result = agent.execute_task( + lambda: find_implementations_tool.apply( + name_path="Greeter/FormatGreeting", + relative_path="main.go", + include_info=True, + ) + ) + + print_section("Find Implementations Result") + implementations = json.loads(result) + pprint(implementations, width=200) + + # validating the demonstrated result + assert any(implementation["name_path"] == "(ConsoleGreeter).FormatGreeting" for implementation in implementations), result + print("\nVerified implementation target: (ConsoleGreeter).FormatGreeting") + finally: + agent.shutdown() diff --git a/src/serena/code_editor.py b/src/serena/code_editor.py index 2392d2ba..7d52f2b8 100644 --- a/src/serena/code_editor.py +++ b/src/serena/code_editor.py @@ -4,6 +4,7 @@ import os from abc import ABC, abstractmethod from collections.abc import Iterable, Iterator, Reversible from contextlib import contextmanager +from dataclasses import dataclass from typing import Generic, TypeVar, cast from serena.jetbrains.jetbrains_plugin_client import JetBrainsPluginClient @@ -18,11 +19,18 @@ log = logging.getLogger(__name__) TSymbol = TypeVar("TSymbol", bound=Symbol) +@dataclass(frozen=True) +class EditedFilePath: + before_relative_path: str + after_relative_path: str + + class CodeEditor(Generic[TSymbol], ABC): def __init__(self, project: Project) -> None: self.project_root = project.project_root self.encoding = project.project_config.encoding self.newline = project.line_ending.newline_str + self._last_edited_file_paths: list[EditedFilePath] = [] class EditedFile(ABC): def __init__(self, relative_path: str) -> None: @@ -83,6 +91,20 @@ class CodeEditor(Generic[TSymbol], ABC): with open(abs_path, "w", encoding=self.encoding, newline=self.newline) as f: f.write(new_contents) + def _set_last_edited_file_paths(self, edited_file_paths: Iterable[EditedFilePath]) -> None: + unique_paths: list[EditedFilePath] = [] + seen_keys: set[tuple[str, str]] = set() + for edited_file_path in edited_file_paths: + key = (edited_file_path.before_relative_path, edited_file_path.after_relative_path) + if key in seen_keys: + continue + seen_keys.add(key) + unique_paths.append(edited_file_path) + self._last_edited_file_paths = unique_paths + + def get_last_edited_file_paths(self) -> list[EditedFilePath]: + return list(self._last_edited_file_paths) + @abstractmethod def _find_unique_symbol(self, name_path: str, relative_file_path: str) -> TSymbol: """ @@ -114,6 +136,8 @@ class CodeEditor(Generic[TSymbol], ABC): edited_file.delete_text_between_positions(start_pos, end_pos) edited_file.insert_text_at_position(start_pos, body) + self._set_last_edited_file_paths([EditedFilePath(relative_file_path, relative_file_path)]) + @staticmethod def _count_leading_newlines(text: Iterable) -> int: cnt = 0 @@ -171,6 +195,8 @@ class CodeEditor(Generic[TSymbol], ABC): with self.edited_file_context(relative_file_path) as edited_file: edited_file.insert_text_at_position(PositionInFile(line, col), body) + self._set_last_edited_file_paths([EditedFilePath(relative_file_path, relative_file_path)]) + def insert_before_symbol(self, name_path: str, relative_file_path: str, body: str) -> None: """ Inserts content before the symbol with the given name in the given file. @@ -199,6 +225,8 @@ class CodeEditor(Generic[TSymbol], ABC): with self.edited_file_context(relative_file_path) as edited_file: edited_file.insert_text_at_position(PositionInFile(line=line, col=col), body) + self._set_last_edited_file_paths([EditedFilePath(relative_file_path, relative_file_path)]) + def insert_at_line(self, relative_path: str, line: int, content: str) -> None: """ Inserts content at the given line in the given file. @@ -210,6 +238,8 @@ class CodeEditor(Generic[TSymbol], ABC): with self.edited_file_context(relative_path) as edited_file: edited_file.insert_text_at_position(PositionInFile(line, 0), content) + self._set_last_edited_file_paths([EditedFilePath(relative_path, relative_path)]) + def delete_lines(self, relative_path: str, start_line: int, end_line: int) -> None: """ Deletes lines in the given file. @@ -226,6 +256,8 @@ class CodeEditor(Generic[TSymbol], ABC): end_pos = PositionInFile(line=end_line_for_delete, col=end_col) edited_file.delete_text_between_positions(start_pos, end_pos) + self._set_last_edited_file_paths([EditedFilePath(relative_path, relative_path)]) + def delete_symbol(self, name_path: str, relative_file_path: str) -> None: """ Deletes the symbol with the given name in the given file. @@ -236,6 +268,8 @@ class CodeEditor(Generic[TSymbol], ABC): with self.edited_file_context(relative_file_path) as edited_file: edited_file.delete_text_between_positions(start_pos, end_pos) + self._set_last_edited_file_paths([EditedFilePath(relative_file_path, relative_file_path)]) + @abstractmethod def rename_symbol(self, name_path: str, relative_path: str, new_name: str) -> str: pass @@ -292,6 +326,10 @@ class LanguageServerCodeEditor(CodeEditor[LanguageServerSymbol]): def apply(self) -> None: pass + @abstractmethod + def get_edited_file_paths(self) -> list[EditedFilePath]: + pass + class EditOperationFileTextEdits(EditOperation): def __init__(self, code_editor: "LanguageServerCodeEditor", file_uri: str, text_edits: list[ls_types.TextEdit]): self._code_editor = code_editor @@ -303,6 +341,9 @@ class LanguageServerCodeEditor(CodeEditor[LanguageServerSymbol]): edited_file = cast(LanguageServerCodeEditor.EditedFile, edited_file) edited_file.apply_text_edits(self._text_edits) + def get_edited_file_paths(self) -> list[EditedFilePath]: + return [EditedFilePath(self._relative_path, self._relative_path)] + class EditOperationRenameFile(EditOperation): def __init__(self, code_editor: "LanguageServerCodeEditor", old_uri: str, new_uri: str): self._code_editor = code_editor @@ -314,6 +355,9 @@ class LanguageServerCodeEditor(CodeEditor[LanguageServerSymbol]): new_abs_path = os.path.join(self._code_editor.project_root, self._new_relative_path) os.rename(old_abs_path, new_abs_path) + def get_edited_file_paths(self) -> list[EditedFilePath]: + return [EditedFilePath(self._old_relative_path, self._new_relative_path)] + def _workspace_edit_to_edit_operations(self, workspace_edit: ls_types.WorkspaceEdit) -> list["LanguageServerCodeEditor.EditOperation"]: operations: list[LanguageServerCodeEditor.EditOperation] = [] @@ -343,6 +387,15 @@ class LanguageServerCodeEditor(CodeEditor[LanguageServerSymbol]): :return: number of edit operations applied """ operations = self._workspace_edit_to_edit_operations(workspace_edit) + + # recording the affected files + edited_file_paths: list[EditedFilePath] = [] + for operation in operations: + edited_file_paths.extend(operation.get_edited_file_paths()) + + self._set_last_edited_file_paths(edited_file_paths) + + # applying the edit operations for operation in operations: operation.apply() return len(operations) diff --git a/src/serena/symbol.py b/src/serena/symbol.py index 4a1b14cc..b44339e1 100644 --- a/src/serena/symbol.py +++ b/src/serena/symbol.py @@ -3,6 +3,7 @@ import json import logging import os from abc import ABC, abstractmethod +from collections import OrderedDict from collections.abc import Callable, Iterable, Iterator, Sequence from dataclasses import asdict, dataclass from time import perf_counter @@ -11,7 +12,7 @@ from typing import Any, Generic, Literal, NotRequired, Self, TypedDict, TypeVar from sensai.util.string import ToStringMixin import serena.jetbrains.jetbrains_types as jb -from solidlsp import SolidLanguageServer +from solidlsp import SolidLanguageServer, ls_types from solidlsp.ls import LSPFileBuffer from solidlsp.ls import ReferenceInSymbol as LSPReferenceInSymbol from solidlsp.ls_types import Position, SymbolKind, UnifiedSymbolInformation @@ -869,6 +870,264 @@ class LanguageServerSymbolRetriever: return [ReferenceInLanguageServerSymbol.from_lsp_reference(r) for r in references] + def find_implementing_symbols( + self, + name_path: str, + relative_file_path: str, + include_body: bool = False, + include_kinds: Sequence[SymbolKind] | None = None, + exclude_kinds: Sequence[SymbolKind] | None = None, + ) -> list[LanguageServerSymbol]: + """ + Find all symbols that implement the specified symbol, which is assumed to be unique. + + :param name_path: the name path of the symbol to find implementations for. While this can be a matching pattern, + it should usually be the full path to ensure uniqueness. + :param relative_file_path: the relative path of the file in which the implemented symbol is defined. + :param include_body: whether to include the body of all symbols in the result. + :param include_kinds: which kinds of symbols to include in the result. + :param exclude_kinds: which kinds of symbols to exclude from the result. + """ + symbol = self.find_unique(name_path, substring_matching=False, within_relative_path=relative_file_path) + return self.find_implementing_symbols_by_location( + symbol.location, + include_body=include_body, + include_kinds=include_kinds, + exclude_kinds=exclude_kinds, + ) + + def find_implementing_symbols_by_location( + self, + symbol_location: LanguageServerSymbolLocation, + include_body: bool = False, + include_kinds: Sequence[SymbolKind] | None = None, + exclude_kinds: Sequence[SymbolKind] | None = None, + ) -> list[LanguageServerSymbol]: + """ + Find all symbols that implement the symbol at the given location. + + :param symbol_location: the location of the symbol for which to find implementations. + Does not need to include an end_line, as it is unused in the search. + :param include_body: whether to include the body of all symbols in the result. + :param include_kinds: an optional sequence of ints representing the LSP symbol kind. + If provided, only symbols of the given kinds will be included in the result. + :param exclude_kinds: If provided, symbols of the given kinds will be excluded from the result. + Takes precedence over include_kinds. + :return: a list of symbols that implement the given symbol + """ + if not symbol_location.has_position_in_file(): + raise ValueError("Symbol location does not contain a valid position in a file") + assert symbol_location.relative_path is not None + assert symbol_location.line is not None + assert symbol_location.column is not None + lang_server = self.get_language_server(symbol_location.relative_path) + implementing_symbols = lang_server.request_implementing_symbols( + relative_file_path=symbol_location.relative_path, + line=symbol_location.line, + column=symbol_location.column, + include_body=include_body, + ) + + if include_kinds is not None: + implementing_symbols = [s for s in implementing_symbols if s["kind"] in include_kinds] + + if exclude_kinds is not None: + implementing_symbols = [s for s in implementing_symbols if s["kind"] not in exclude_kinds] + + return [LanguageServerSymbol(s) for s in implementing_symbols] + + def find_defining_symbol( + self, + relative_file_path: str, + line: int, + column: int, + include_body: bool = False, + ) -> LanguageServerSymbol | None: + """ + Find the symbol that defines the symbol at the given file position. + + :param relative_file_path: the relative path to the file in which the symbol usage occurs. + :param line: the 0-based line number of the symbol usage. + :param column: the 0-based column number of the symbol usage. + :param include_body: whether to include the body of the defining symbol in the result. + :return: the defining symbol, or None if no definition could be resolved. + """ + lang_server = self.get_language_server(relative_file_path) + defining_symbol = lang_server.request_defining_symbol( + relative_file_path=relative_file_path, + line=line, + column=column, + include_body=include_body, + ) + if defining_symbol is None: + return None + return LanguageServerSymbol(defining_symbol) + + def get_file_diagnostics( + self, + relative_file_path: str, + start_line: int = 0, + end_line: int = -1, + min_severity: int = 4, + ) -> list[ls_types.Diagnostic]: + """ + Get diagnostics for a file, optionally restricted to a line range and minimum severity. + + :param relative_file_path: the relative path to the file. + :param start_line: the first 0-based line to include. + :param end_line: the last 0-based line to include. `-1` means until end of file. + :param min_severity: minimum LSP severity to include, where 1=Error, 2=Warning, 3=Information, 4=Hint. + :return: the diagnostics matching the requested constraints. + """ + lang_server = self.get_language_server(relative_file_path) + return lang_server.request_text_document_diagnostics( + relative_file_path=relative_file_path, + start_line=start_line, + end_line=end_line, + min_severity=min_severity, + ) + + @staticmethod + def _symbol_identity(symbol: LanguageServerSymbol) -> tuple[str | None, int | None, int | None, str]: + return (symbol.relative_path, symbol.line, symbol.column, symbol.get_name_path()) + + @staticmethod + def _normalize_symbol_for_diagnostics(symbol: LanguageServerSymbol) -> LanguageServerSymbol: + current_symbol = symbol + while current_symbol.is_low_level(): + parent_symbol = current_symbol.get_parent() + if parent_symbol is None: + break + current_symbol = parent_symbol + return current_symbol + + def find_diagnostic_owner_symbol(self, relative_file_path: str, line: int, column: int) -> LanguageServerSymbol | None: + """ + Find the symbol that should own a diagnostic at the given position. + + This prefers the structural container of the diagnostic over low-level symbols such as + local variables, because diagnostics are typically more meaningful when grouped by the + surrounding function, method, class, or analogous construct. + + :param relative_file_path: the relative path to the file containing the diagnostic. + :param line: the 0-based line of the diagnostic. + :param column: the 0-based column of the diagnostic. + :return: the owning symbol, or None if no symbol could be resolved. + """ + lang_server = self.get_language_server(relative_file_path) + symbol_dict = lang_server.request_symbol_at_location( + relative_file_path=relative_file_path, + line=line, + column=column, + ) + if symbol_dict is None: + return None + return self._normalize_symbol_for_diagnostics(LanguageServerSymbol(symbol_dict)) + + def _get_diagnostics_for_symbol(self, symbol: LanguageServerSymbol, min_severity: int) -> list[ls_types.Diagnostic]: + relative_path = symbol.relative_path + if relative_path is None: + return [] + + start_line, end_line = symbol.get_body_line_numbers() + if start_line is None: + if symbol.line is None: + return [] + start_line = symbol.line + if end_line is None: + end_line = start_line + + return self.get_file_diagnostics( + relative_file_path=relative_path, + start_line=start_line, + end_line=end_line, + min_severity=min_severity, + ) + + def get_symbol_diagnostics( + self, + name_path: str, + reference_file: str | None = None, + check_symbol_references: bool = False, + min_severity: int = 4, + ) -> dict[LanguageServerSymbol, list[ls_types.Diagnostic]]: + """ + Get diagnostics for the specified symbol and, optionally, for all symbols that reference it. + + :param name_path: the name path of the symbol to find. It should usually be unique. + :param reference_file: optional file path used to disambiguate the symbol search. + :param check_symbol_references: whether to additionally collect diagnostics for referencing symbols. + :param min_severity: minimum LSP severity to include, where 1=Error, 2=Warning, 3=Information, 4=Hint. + :return: a mapping from symbols to the diagnostics that overlap their body ranges. + """ + symbol = self.find_unique(name_path, substring_matching=False, within_relative_path=reference_file or None) + return self.get_symbol_diagnostics_by_location( + symbol.location, + check_symbol_references=check_symbol_references, + min_severity=min_severity, + ) + + def get_symbol_diagnostics_by_location( + self, + symbol_location: LanguageServerSymbolLocation, + check_symbol_references: bool = False, + min_severity: int = 4, + ) -> dict[LanguageServerSymbol, list[ls_types.Diagnostic]]: + """ + Get diagnostics for the symbol at the given location and, optionally, for all referencing symbols. + + :param symbol_location: location of the symbol to inspect. + :param check_symbol_references: whether to additionally collect diagnostics for referencing symbols. + :param min_severity: minimum LSP severity to include, where 1=Error, 2=Warning, 3=Information, 4=Hint. + :return: an ordered mapping from symbols to the diagnostics that overlap their body ranges. + """ + if not symbol_location.has_position_in_file(): + raise ValueError("Symbol location does not contain a valid position in a file") + + symbol = self.find_by_location(symbol_location) + if symbol is None: + assert symbol_location.relative_path is not None + assert symbol_location.line is not None + assert symbol_location.column is not None + lang_server = self.get_language_server(symbol_location.relative_path) + symbol_dict = lang_server.request_symbol_at_location( + relative_file_path=symbol_location.relative_path, + line=symbol_location.line, + column=symbol_location.column, + ) + if symbol_dict is None: + return {} + symbol = LanguageServerSymbol(symbol_dict) + + symbols_to_check: "OrderedDict[tuple[str | None, int | None, int | None, str], LanguageServerSymbol]" = OrderedDict() + symbols_to_check[self._symbol_identity(symbol)] = symbol + + if check_symbol_references: + reference_symbols = self.find_referencing_symbols_by_location( + symbol.location, + include_body=False, + exclude_kinds=[SymbolKind.File, SymbolKind.Module, SymbolKind.Package, SymbolKind.Namespace], + ) + for reference in reference_symbols: + reference_relative_path = reference.get_relative_path() + normalized_reference_symbol = None + if reference_relative_path is not None: + normalized_reference_symbol = self.find_diagnostic_owner_symbol( + relative_file_path=reference_relative_path, + line=reference.line, + column=reference.character, + ) + if normalized_reference_symbol is None: + normalized_reference_symbol = self._normalize_symbol_for_diagnostics(reference.symbol) + symbols_to_check.setdefault(self._symbol_identity(normalized_reference_symbol), normalized_reference_symbol) + + result: dict[LanguageServerSymbol, list[ls_types.Diagnostic]] = {} + for current_symbol in symbols_to_check.values(): + diagnostics = self._get_diagnostics_for_symbol(current_symbol, min_severity=min_severity) + if diagnostics: + result[current_symbol] = diagnostics + return result + def get_symbol_overview(self, relative_path: str) -> dict[str, list[LanguageServerSymbol]]: """ :param relative_path: the path of the file for which to get the symbol overview diff --git a/src/serena/tools/file_tools.py b/src/serena/tools/file_tools.py index 4e7268e0..42923a03 100644 --- a/src/serena/tools/file_tools.py +++ b/src/serena/tools/file_tools.py @@ -12,6 +12,7 @@ from fnmatch import fnmatch from pathlib import Path from typing import Literal +from serena.code_editor import EditedFilePath from serena.tools import SUCCESS_RESULT, EditedFileContext, Tool, ToolMarkerCanEdit, ToolMarkerOptional from serena.util.file_system import scan_directory from serena.util.text_utils import ContentReplacer, search_files @@ -61,6 +62,11 @@ class CreateTextFileTool(Tool, ToolMarkerCanEdit): :param content: the (appropriately encoded) content to write to the file :return: a message indicating success or failure """ + # capturing diagnostics before the edit + edited_file_paths = [EditedFilePath(relative_path, relative_path)] + diagnostics_snapshot = self._capture_published_lsp_diagnostics_snapshot(edited_file_paths) + + # validating the destination path project_root = self.get_project_root() abs_path = (Path(project_root) / relative_path).resolve() will_overwrite_existing = abs_path.exists() @@ -72,12 +78,14 @@ class CreateTextFileTool(Tool, ToolMarkerCanEdit): f"Cannot create file outside of the project directory, got {relative_path=}" ) + # writing the file abs_path.parent.mkdir(parents=True, exist_ok=True) abs_path.write_text(content, encoding=self.project.project_config.encoding, newline=self.project.line_ending.newline_str) answer = f"File created: {relative_path}." if will_overwrite_existing: answer += " Overwrote existing file." - return answer + + return self._format_lsp_edit_result_with_new_diagnostics(answer, edited_file_paths, diagnostics_snapshot) class ListDirTool(Tool): @@ -161,6 +169,8 @@ class ReplaceContentTool(Tool, ToolMarkerCanEdit): Replaces content in a file (optionally using regular expressions). """ + _CAPTURE_DIAGNOSTICS: bool = False + def apply( self, relative_path: str, @@ -212,13 +222,19 @@ class ReplaceContentTool(Tool, ToolMarkerCanEdit): Performs the replacement, with additional options not exposed in the tool. This function can be used internally by other tools. """ + # capturing diagnostics before the edit + edited_file_paths = [EditedFilePath(relative_path, relative_path)] + diagnostics_snapshot = self._capture_published_lsp_diagnostics_snapshot(edited_file_paths) + + # applying the content replacement self.project.validate_relative_path(relative_path, require_not_ignored=require_not_ignored) with EditedFileContext(relative_path, self.create_code_editor()) as context: original_content = context.get_original_content() replacer = ContentReplacer(mode=mode, allow_multiple_occurrences=allow_multiple_occurrences) updated_content = replacer.replace(original_content, needle, repl) context.set_updated_content(updated_content) - return SUCCESS_RESULT + + return self._format_lsp_edit_result_with_new_diagnostics(SUCCESS_RESULT, edited_file_paths, diagnostics_snapshot) class DeleteLinesTool(Tool, ToolMarkerCanEdit, ToolMarkerOptional): @@ -241,9 +257,15 @@ class DeleteLinesTool(Tool, ToolMarkerCanEdit, ToolMarkerOptional): :param start_line: the 0-based index of the first line to be deleted :param end_line: the 0-based index of the last line to be deleted """ + # capturing diagnostics before the edit + edited_file_paths = [EditedFilePath(relative_path, relative_path)] + diagnostics_snapshot = self._capture_published_lsp_diagnostics_snapshot(edited_file_paths) + + # applying the edit code_editor = self.create_code_editor() code_editor.delete_lines(relative_path, start_line, end_line) - return SUCCESS_RESULT + + return self._format_lsp_edit_result_with_new_diagnostics(SUCCESS_RESULT, edited_file_paths, diagnostics_snapshot) class ReplaceLinesTool(Tool, ToolMarkerCanEdit, ToolMarkerOptional): @@ -268,13 +290,20 @@ class ReplaceLinesTool(Tool, ToolMarkerCanEdit, ToolMarkerOptional): :param end_line: the 0-based index of the last line to be deleted :param content: the content to insert """ + # normalizing the replacement content if not content.endswith("\n"): content += "\n" - result = self.agent.get_tool(DeleteLinesTool).apply(relative_path, start_line, end_line) - if result != SUCCESS_RESULT: - return result - self.agent.get_tool(InsertAtLineTool).apply(relative_path, start_line, content) - return SUCCESS_RESULT + + # capturing diagnostics before the edit + edited_file_paths = [EditedFilePath(relative_path, relative_path)] + diagnostics_snapshot = self._capture_published_lsp_diagnostics_snapshot(edited_file_paths) + + # applying the replacement + code_editor = self.create_code_editor() + code_editor.delete_lines(relative_path, start_line, end_line) + code_editor.insert_at_line(relative_path, start_line, content) + + return self._format_lsp_edit_result_with_new_diagnostics(SUCCESS_RESULT, edited_file_paths, diagnostics_snapshot) class InsertAtLineTool(Tool, ToolMarkerCanEdit, ToolMarkerOptional): @@ -298,11 +327,19 @@ class InsertAtLineTool(Tool, ToolMarkerCanEdit, ToolMarkerOptional): :param line: the 0-based index of the line to insert content at :param content: the content to be inserted """ + # normalizing the inserted content if not content.endswith("\n"): content += "\n" + + # capturing diagnostics before the edit + edited_file_paths = [EditedFilePath(relative_path, relative_path)] + diagnostics_snapshot = self._capture_published_lsp_diagnostics_snapshot(edited_file_paths) + + # applying the insertion code_editor = self.create_code_editor() code_editor.insert_at_line(relative_path, line, content) - return SUCCESS_RESULT + + return self._format_lsp_edit_result_with_new_diagnostics(SUCCESS_RESULT, edited_file_paths, diagnostics_snapshot) class SearchForPatternTool(Tool): diff --git a/src/serena/tools/symbol_tools.py b/src/serena/tools/symbol_tools.py index a19a99b9..2a44a14e 100644 --- a/src/serena/tools/symbol_tools.py +++ b/src/serena/tools/symbol_tools.py @@ -4,9 +4,12 @@ Language server-related tools import copy import os +import re from collections import Counter, defaultdict from collections.abc import Sequence +from typing import Any +from serena.code_editor import EditedFilePath from serena.symbol import LanguageServerSymbol, LanguageServerSymbolDictGrouper from serena.tools import ( SUCCESS_RESULT, @@ -15,7 +18,72 @@ from serena.tools import ( ToolMarkerSymbolicRead, ) from serena.tools.tools_base import ToolMarkerOptional +from solidlsp import ls_types from solidlsp.ls_types import SymbolKind +from solidlsp.lsp_protocol_handler.lsp_types import DiagnosticSeverity + +FILE_LEVEL_DIAGNOSTIC_BUCKET = "" + + +def _diagnostic_severity_name(severity: int | None) -> str: + if severity is None: + return "Unknown" + try: + return DiagnosticSeverity(severity).name + except ValueError: + return f"Severity_{severity}" + + +def _diagnostic_output_dict(diagnostic: ls_types.Diagnostic) -> dict[str, Any]: + result: dict[str, Any] = { + "message": diagnostic["message"], + "range": diagnostic["range"], + } + if "code" in diagnostic: + result["code"] = diagnostic["code"] + if "source" in diagnostic: + result["source"] = diagnostic["source"] + return result + + +def _add_grouped_diagnostic( + grouped_result: dict[str, dict[str, dict[str, list[dict[str, Any]]]]], + relative_path: str, + severity_name: str, + name_path: str, + diagnostic: ls_types.Diagnostic, +) -> None: + grouped_result.setdefault(relative_path, {}).setdefault(severity_name, {}).setdefault(name_path, []).append( + _diagnostic_output_dict(diagnostic) + ) + + +def _offset_to_line_and_column(text: str, offset: int) -> tuple[int, int]: + if offset < 0 or offset > len(text): + raise ValueError(f"Offset out of range: {offset}") + prefix = text[:offset] + line = prefix.count("\n") + previous_newline_offset = prefix.rfind("\n") + column = offset if previous_newline_offset == -1 else offset - previous_newline_offset - 1 + return line, column + + +def _line_and_column_to_offset(text: str, line: int, column: int) -> int: + if line < 0 or column < 0: + raise ValueError(f"Line and column must be non-negative, got {line=}, {column=}") + + line_start_offsets = [0] + for match in re.finditer("\n", text): + line_start_offsets.append(match.end()) + + if line >= len(line_start_offsets): + raise ValueError(f"Line out of range: {line}") + + line_start_offset = line_start_offsets[line] + line_end_offset = line_start_offsets[line + 1] - 1 if line + 1 < len(line_start_offsets) else len(text) + if column > line_end_offset - line_start_offset: + raise ValueError(f"Column out of range for line {line}: {column}") + return line_start_offset + column class RestartLanguageServerTool(Tool, ToolMarkerOptional): @@ -324,6 +392,385 @@ class FindReferencingSymbolsTool(Tool, ToolMarkerSymbolicRead): return self._limit_length(result_json, max_answer_chars, shortened_result_factories=shortened_results) +class FindImplementationsTool(Tool, ToolMarkerSymbolicRead): + """ + Finds symbols that implement the given symbol using the language server backend. + """ + + # noinspection PyDefaultArgument + def apply( + self, + name_path: str, + relative_path: str, + include_info: bool = False, + include_kinds: list[int] = [], # noqa: B006 + exclude_kinds: list[int] = [], # noqa: B006 + max_answer_chars: int = -1, + ) -> str: + """ + Finds implementations of the symbol at the given `name_path`. + + :param name_path: for finding the symbol to find implementations for, same logic as in the `find_symbol` tool. + :param relative_path: the relative path to the file containing the symbol for which to find implementations. + Note that here you can't pass a directory but must pass a file. + :param include_info: whether to include additional info (hover-like, typically including docstring and signature), + about the implementing symbols. + :param include_kinds: same as in the `find_symbol` tool. + :param exclude_kinds: same as in the `find_symbol` tool. + :param max_answer_chars: same as in the `find_symbol` tool. + :return: a list of JSON objects with the symbols implementing the requested symbol + """ + include_body = False + parsed_include_kinds: Sequence[SymbolKind] | None = [SymbolKind(k) for k in include_kinds] if include_kinds else None + parsed_exclude_kinds: Sequence[SymbolKind] | None = [SymbolKind(k) for k in exclude_kinds] if exclude_kinds else None + symbol_retriever = self.create_language_server_symbol_retriever() + + implementing_symbols = symbol_retriever.find_implementing_symbols( + name_path, + relative_file_path=relative_path, + include_body=include_body, + include_kinds=parsed_include_kinds, + exclude_kinds=parsed_exclude_kinds, + ) + + symbol_dicts = [ + dict(s.to_dict(kind=True, relative_path=True, depth=0, body=include_body, body_location=True)) for s in implementing_symbols + ] + if include_info: + info_by_symbol = symbol_retriever.request_info_for_symbol_batch(implementing_symbols) + for s, s_dict in zip(implementing_symbols, symbol_dicts, strict=True): + if symbol_info := info_by_symbol.get(s): + s_dict["info"] = symbol_info + s_dict.pop("name", None) # name is included in the info + + result = self._to_json(symbol_dicts) + return self._limit_length(result, max_answer_chars) + + +class FindDefiningSymbolAtLocationTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional): + """ + Finds the symbol that defines the symbol at the given file position using the language server backend. + """ + + @staticmethod + def _defining_symbol_to_result_dict( + symbol_retriever: Any, + defining_symbol: LanguageServerSymbol | None, + include_body: bool, + include_info: bool, + ) -> dict[str, Any] | None: + if defining_symbol is None: + return None + + symbol_dict = dict(defining_symbol.to_dict(kind=True, relative_path=True, depth=0, body=include_body, body_location=True)) + if not include_body and include_info: + if symbol_info := symbol_retriever.request_info_for_symbol(defining_symbol): + symbol_dict["info"] = symbol_info + symbol_dict.pop("name", None) + return symbol_dict + + # noinspection PyDefaultArgument + def apply( + self, + relative_path: str, + line: int, + column: int, + include_body: bool = False, + include_info: bool = False, + max_answer_chars: int = -1, + ) -> str: + """ + Finds the defining symbol for the symbol at the given position. + + :param relative_path: the relative path to the file containing the symbol usage. + :param line: the 0-based line number of the symbol usage. + :param column: the 0-based column number of the symbol usage. + :param include_body: whether to include the source code of the defining symbol. + :param include_info: whether to include additional info (hover-like, typically including docstring and signature) + about the defining symbol. Ignored if no defining symbol is found. + :param max_answer_chars: same as in the `find_symbol` tool. + :return: a JSON object representing the defining symbol, or `null` if no definition was found. + """ + symbol_retriever = self.create_language_server_symbol_retriever() + defining_symbol = symbol_retriever.find_defining_symbol( + relative_file_path=relative_path, + line=line, + column=column, + include_body=include_body, + ) + + if defining_symbol is None: + result = self._to_json(None) + return self._limit_length(result, max_answer_chars) + + symbol_dict = self._defining_symbol_to_result_dict(symbol_retriever, defining_symbol, include_body, include_info) + assert symbol_dict is not None + result = self._to_json(symbol_dict) + return self._limit_length(result, max_answer_chars) + + +class FindDefiningSymbolTool(Tool, ToolMarkerSymbolicRead): + """ + Finds the symbol that defines a uniquely captured regex match in a file or containing symbol body. + """ + + @staticmethod + def _describe_search_scope(relative_path: str, containing_symbol_name_path: str | None) -> str: + if containing_symbol_name_path: + return f"symbol body '{containing_symbol_name_path}' in file '{relative_path}'" + return f"file '{relative_path}'" + + @classmethod + def _format_match_preview(cls, match: re.Match[str]) -> str: + matched_text = match.group(0).replace("\n", "\\n") + return matched_text[:120] + + @classmethod + def _get_unique_captured_span(cls, match: re.Match[str], regex: str, search_scope_description: str) -> tuple[int, int] | str: + if match.re.groups == 0: + return ( + f"Error: Regex '{regex}' must contain exactly one capturing group that identifies the symbol usage in " + f"{search_scope_description}." + ) + + matched_capture_spans = [span for span in match.regs[1:] if span != (-1, -1)] + if len(matched_capture_spans) != 1: + return ( + f"Error: Regex '{regex}' must produce exactly one matched capture in {search_scope_description}, " + f"but produced {len(matched_capture_spans)} for match '{cls._format_match_preview(match)}'." + ) + + capture_start_offset, capture_end_offset = matched_capture_spans[0] + if capture_start_offset == capture_end_offset: + return ( + f"Error: Regex '{regex}' produced an empty capture in {search_scope_description}; " + "the capture must select the referenced symbol text." + ) + return capture_start_offset, capture_end_offset + + def _find_unique_captured_location( + self, + relative_path: str, + regex: str, + containing_symbol_name_path: str | None, + ) -> tuple[int, int] | str: + # retrieving the search region + file_content = self.project.read_file(relative_path) + symbol_retriever = self.create_language_server_symbol_retriever() + search_scope_description = self._describe_search_scope(relative_path, containing_symbol_name_path) + search_start_offset = 0 + search_text = file_content + + if containing_symbol_name_path: + try: + containing_symbol = symbol_retriever.find_unique(containing_symbol_name_path, within_relative_path=relative_path) + except Exception as e: + return f"Error: Could not resolve containing symbol '{containing_symbol_name_path}' in file '{relative_path}': {e}" + + body_start_position = containing_symbol.get_body_start_position_or_raise() + body_end_position = containing_symbol.get_body_end_position_or_raise() + search_start_offset = _line_and_column_to_offset(file_content, body_start_position.line, body_start_position.col) + search_end_offset = _line_and_column_to_offset(file_content, body_end_position.line, body_end_position.col) + search_text = file_content[search_start_offset:search_end_offset] + + # finding regex matches + try: + compiled_regex = re.compile(regex, re.MULTILINE) + except re.error as e: + return f"Error: Invalid regex '{regex}': {e}" + + if compiled_regex.groups == 0: + return ( + f"Error: Regex '{regex}' must contain exactly one capturing group that identifies the symbol usage in " + f"{search_scope_description}." + ) + + matches = list(compiled_regex.finditer(search_text)) + if len(matches) != 1: + match_previews = [self._format_match_preview(match) for match in matches[:3]] + preview_suffix = f" Matches: {match_previews}" if match_previews else "" + return ( + f"Error: Expected exactly one regex match for '{regex}' in {search_scope_description}, " + f"but found {len(matches)}.{preview_suffix}" + ) + + capture_span_or_error = self._get_unique_captured_span(matches[0], regex, search_scope_description) + if isinstance(capture_span_or_error, str): + return capture_span_or_error + + capture_start_offset, _ = capture_span_or_error + absolute_capture_start_offset = search_start_offset + capture_start_offset + return _offset_to_line_and_column(file_content, absolute_capture_start_offset) + + # noinspection PyDefaultArgument + def apply( + self, + regex: str, + relative_path: str, + containing_symbol_name_path: str = "", + include_body: bool = False, + include_info: bool = False, + max_answer_chars: int = -1, + ) -> str: + r""" + Finds the defining symbol for a uniquely captured regex match in a file. + + The regex must contain exactly one capturing group, and exactly one overall match must be found. + The capture identifies the symbol usage whose definition should be resolved. + + The regex is compiled with ``re.MULTILINE`` enabled and ``re.DOTALL`` disabled. This keeps common + single-symbol matches predictable. If cross-line matching is needed, opt in explicitly with ``(?s)`` + or ``[\\s\\S]*?``. + + :param regex: a Python regular expression containing exactly one capturing group. + :param relative_path: the relative path to the file containing the symbol usage. + :param containing_symbol_name_path: optional name path of a containing symbol whose body shall be searched instead of the full file. + :param include_body: whether to include the source code of the defining symbol. + :param include_info: whether to include additional info (hover-like, typically including docstring and signature) + about the defining symbol. Ignored if no defining symbol is found. + :param max_answer_chars: same as in the `find_symbol` tool. + :return: a JSON object representing the defining symbol, or ``null`` if no definition was found. + If the regex does not identify a unique captured match, an informative error string is returned. + """ + captured_location_or_error = self._find_unique_captured_location( + relative_path=relative_path, + regex=regex, + containing_symbol_name_path=containing_symbol_name_path or None, + ) + if isinstance(captured_location_or_error, str): + return captured_location_or_error + + line, column = captured_location_or_error + symbol_retriever = self.create_language_server_symbol_retriever() + defining_symbol = symbol_retriever.find_defining_symbol( + relative_file_path=relative_path, + line=line, + column=column, + include_body=include_body, + ) + symbol_dict = FindDefiningSymbolAtLocationTool._defining_symbol_to_result_dict( + symbol_retriever, + defining_symbol, + include_body, + include_info, + ) + result = self._to_json(symbol_dict) + return self._limit_length(result, max_answer_chars) + + +class GetDiagnosticsForFileTool(Tool, ToolMarkerSymbolicRead): + """ + Gets diagnostics for a file, optionally restricted to a line range, grouped by file, severity, and containing symbol. + """ + + _ENABLE_DIAGNOSTICS: bool = True + + def apply( + self, + relative_path: str, + start_line: int = 0, + end_line: int = -1, + min_severity: int = 4, + max_answer_chars: int = -1, + ) -> str: + """ + Gets diagnostics for a file. Diagnostics are grouped as `relative_path -> severity -> name_path -> diagnostics_results`. + If a diagnostic cannot be mapped to a symbol, it is grouped under the special name path ``. + + :param relative_path: the relative path to the file to inspect. + :param start_line: the first 0-based line to include. Defaults to 0. + :param end_line: the last 0-based line to include. Defaults to -1, which means until the end of the file. + :param min_severity: minimum LSP severity to include, where 1=Error, 2=Warning, 3=Information, 4=Hint. + Diagnostics with lower-or-equal numeric severity are returned. + :param max_answer_chars: same as in the `find_symbol` tool. + :return: grouped diagnostics for the requested file. + """ + symbol_retriever = self.create_language_server_symbol_retriever() + diagnostics = symbol_retriever.get_file_diagnostics( + relative_file_path=relative_path, + start_line=start_line, + end_line=end_line, + min_severity=min_severity, + ) + + grouped_result: dict[str, dict[str, dict[str, list[dict[str, Any]]]]] = {} + for diagnostic in diagnostics: + diag_range = diagnostic["range"]["start"] + name_path = FILE_LEVEL_DIAGNOSTIC_BUCKET + owner_symbol = symbol_retriever.find_diagnostic_owner_symbol( + relative_file_path=relative_path, + line=diag_range["line"], + column=diag_range["character"], + ) + if owner_symbol is not None: + name_path = owner_symbol.get_name_path() + _add_grouped_diagnostic( + grouped_result, + relative_path=relative_path, + severity_name=_diagnostic_severity_name(diagnostic.get("severity")), + name_path=name_path, + diagnostic=diagnostic, + ) + + result = self._to_json(grouped_result) + return self._limit_length(result, max_answer_chars) + + +class GetDiagnosticsForSymbolTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional): + """ + Gets diagnostics for a symbol and, optionally, for symbols that reference it. + """ + + _ENABLE_DIAGNOSTICS: bool = True + + def apply( + self, + name_path: str, + reference_file: str = "", + check_symbol_references: bool = False, + min_severity: int = 4, + max_answer_chars: int = -1, + ) -> str: + """ + Gets diagnostics for the specified symbol. When `check_symbol_references` is true, diagnostics for all + referencing symbols are also included. The result is grouped as + `relative_path -> severity -> name_path -> diagnostics_results`. + + :param name_path: the name path of the symbol to inspect. + :param reference_file: optional file path used to disambiguate the symbol search. + :param check_symbol_references: whether to additionally collect diagnostics for symbols that reference the symbol. + :param min_severity: minimum LSP severity to include, where 1=Error, 2=Warning, 3=Information, 4=Hint. + Diagnostics with lower-or-equal numeric severity are returned. + :param max_answer_chars: same as in the `find_symbol` tool. + :return: grouped diagnostics for the requested symbol and, optionally, its referencing symbols. + """ + symbol_retriever = self.create_language_server_symbol_retriever() + diagnostics_by_symbol = symbol_retriever.get_symbol_diagnostics( + name_path=name_path, + reference_file=reference_file or None, + check_symbol_references=check_symbol_references, + min_severity=min_severity, + ) + + grouped_result: dict[str, dict[str, dict[str, list[dict[str, Any]]]]] = {} + for symbol, diagnostics in diagnostics_by_symbol.items(): + relative_path = symbol.relative_path + if relative_path is None: + continue + symbol_name_path = symbol.get_name_path() + for diagnostic in diagnostics: + _add_grouped_diagnostic( + grouped_result, + relative_path=relative_path, + severity_name=_diagnostic_severity_name(diagnostic.get("severity")), + name_path=symbol_name_path, + diagnostic=diagnostic, + ) + + result = self._to_json(grouped_result) + return self._limit_length(result, max_answer_chars) + + class ReplaceSymbolBodyTool(Tool, ToolMarkerSymbolicEdit): """ Replaces the full definition of a symbol using the language server backend. @@ -348,13 +795,19 @@ class ReplaceSymbolBodyTool(Tool, ToolMarkerSymbolicEdit): in the programming language, including e.g. the signature line for functions. IMPORTANT: The body does NOT include any preceding docstrings/comments or imports, in particular. """ + # capturing diagnostics before the edit + edited_file_paths = [EditedFilePath(relative_path, relative_path)] + diagnostics_snapshot = self._capture_published_lsp_diagnostics_snapshot(edited_file_paths) + + # applying the symbol replacement code_editor = self.create_code_editor() code_editor.replace_body( name_path, relative_file_path=relative_path, body=body, ) - return SUCCESS_RESULT + + return self._format_lsp_edit_result_with_new_diagnostics(SUCCESS_RESULT, edited_file_paths, diagnostics_snapshot) class InsertAfterSymbolTool(Tool, ToolMarkerSymbolicEdit): @@ -377,9 +830,15 @@ class InsertAfterSymbolTool(Tool, ToolMarkerSymbolicEdit): :param body: the body/content to be inserted. The inserted code shall begin with the next line after the symbol. """ + # capturing diagnostics before the edit + edited_file_paths = [EditedFilePath(relative_path, relative_path)] + diagnostics_snapshot = self._capture_published_lsp_diagnostics_snapshot(edited_file_paths) + + # applying the insertion code_editor = self.create_code_editor() code_editor.insert_after_symbol(name_path, relative_file_path=relative_path, body=body) - return SUCCESS_RESULT + + return self._format_lsp_edit_result_with_new_diagnostics(SUCCESS_RESULT, edited_file_paths, diagnostics_snapshot) class InsertBeforeSymbolTool(Tool, ToolMarkerSymbolicEdit): @@ -402,9 +861,15 @@ class InsertBeforeSymbolTool(Tool, ToolMarkerSymbolicEdit): :param relative_path: the relative path to the file containing the symbol :param body: the body/content to be inserted before the line in which the referenced symbol is defined """ + # capturing diagnostics before the edit + edited_file_paths = [EditedFilePath(relative_path, relative_path)] + diagnostics_snapshot = self._capture_published_lsp_diagnostics_snapshot(edited_file_paths) + + # applying the insertion code_editor = self.create_code_editor() code_editor.insert_before_symbol(name_path, relative_file_path=relative_path, body=body) - return SUCCESS_RESULT + + return self._format_lsp_edit_result_with_new_diagnostics(SUCCESS_RESULT, edited_file_paths, diagnostics_snapshot) class RenameSymbolTool(Tool, ToolMarkerSymbolicEdit): diff --git a/src/serena/tools/tools_base.py b/src/serena/tools/tools_base.py index c13e4cd4..7ef28c3c 100644 --- a/src/serena/tools/tools_base.py +++ b/src/serena/tools/tools_base.py @@ -18,11 +18,13 @@ from serena.project import MemoriesManager, Project from serena.prompt_factory import PromptFactory from serena.util.class_decorators import singleton from serena.util.inspection import iter_subclasses +from solidlsp import ls_types from solidlsp.ls_exceptions import SolidLSPException +from solidlsp.lsp_protocol_handler.lsp_types import DiagnosticSeverity if TYPE_CHECKING: from serena.agent import SerenaAgent - from serena.code_editor import CodeEditor, LanguageServerCodeEditor + from serena.code_editor import CodeEditor, EditedFilePath, LanguageServerCodeEditor from serena.symbol import LanguageServerSymbolRetriever log = logging.getLogger(__name__) @@ -30,6 +32,24 @@ T = TypeVar("T") SUCCESS_RESULT = "OK" +@dataclass(frozen=True) +class DiagnosticIdentity: + message: str + start_line: int + start_character: int + end_line: int + end_character: int + severity: int | None + code_repr: str | None + source: str | None + + +@dataclass +class PublishedDiagnosticsSnapshot: + generation_by_after_path: dict[str, int] + warning_identities_by_before_path: dict[str, set[DiagnosticIdentity]] + + class Component(ABC): def __init__(self, agent: "SerenaAgent"): self.agent = agent @@ -145,6 +165,8 @@ class Tool(Component): _last_tool_call_client_str: str | None = None """We can only get the client info from within a tool call. Each tool call will update this variable.""" + _ENABLE_DIAGNOSTICS: bool = False + def __init__(self, agent: "SerenaAgent"): super().__init__(agent) @@ -304,6 +326,180 @@ class Tool(Component): def is_symbolic(self) -> bool: return issubclass(self.__class__, ToolMarkerSymbolicRead) or issubclass(self.__class__, ToolMarkerSymbolicEdit) + @staticmethod + def _diagnostic_code_repr(code: Any) -> str | None: + if code is None: + return None + try: + return json.dumps(code, sort_keys=True, ensure_ascii=False) + except TypeError: + return repr(code) + + @classmethod + def _diagnostic_identity(cls, diagnostic: ls_types.Diagnostic) -> DiagnosticIdentity: + diagnostic_range = diagnostic["range"] + start = diagnostic_range["start"] + end = diagnostic_range["end"] + return DiagnosticIdentity( + message=diagnostic["message"], + start_line=start["line"], + start_character=start["character"], + end_line=end["line"], + end_character=end["character"], + severity=diagnostic.get("severity"), + code_repr=cls._diagnostic_code_repr(diagnostic.get("code")), + source=diagnostic.get("source"), + ) + + @staticmethod + def _diagnostic_severity_name(severity: int | None) -> str: + if severity is None: + return "Unknown" + try: + return DiagnosticSeverity(severity).name + except ValueError: + return f"Severity_{severity}" + + @staticmethod + def _diagnostic_output_dict(diagnostic: ls_types.Diagnostic) -> dict[str, Any]: + result: dict[str, Any] = { + "message": diagnostic["message"], + "range": diagnostic["range"], + } + if "code" in diagnostic: + result["code"] = diagnostic["code"] + if "source" in diagnostic: + result["source"] = diagnostic["source"] + return result + + @classmethod + def _add_grouped_diagnostic( + cls, + grouped_result: dict[str, dict[str, dict[str, list[dict[str, Any]]]]], + relative_path: str, + name_path: str, + diagnostic: ls_types.Diagnostic, + ) -> None: + severity_name = cls._diagnostic_severity_name(diagnostic.get("severity")) + grouped_result.setdefault(relative_path, {}).setdefault(severity_name, {}).setdefault(name_path, []).append( + cls._diagnostic_output_dict(diagnostic) + ) + + def _capture_published_lsp_diagnostics_snapshot( + self, + edited_file_paths: Iterable["EditedFilePath"], + ) -> PublishedDiagnosticsSnapshot | None: + if not self._ENABLE_DIAGNOSTICS: + return None + if self.agent.get_language_backend() != LanguageBackend.LSP: + return None + + # collecting diagnostics state before the edit + symbol_retriever = self.create_language_server_symbol_retriever() + generation_by_after_path: dict[str, int] = {} + warning_identities_by_before_path: dict[str, set[DiagnosticIdentity]] = {} + + for edited_file_path in edited_file_paths: + try: + language_server = symbol_retriever.get_language_server(edited_file_path.after_relative_path) + except Exception: + return None + + generation_by_after_path[edited_file_path.after_relative_path] = language_server.get_published_diagnostics_generation( + edited_file_path.after_relative_path + ) + + cached_diagnostics = language_server.get_cached_published_text_document_diagnostics( + edited_file_path.before_relative_path, + min_severity=2, + ) + if cached_diagnostics is None: + try: + cached_diagnostics = language_server.request_text_document_diagnostics( + edited_file_path.before_relative_path, + min_severity=2, + ) + except Exception: + cached_diagnostics = [] + warning_identities_by_before_path[edited_file_path.before_relative_path] = { + self._diagnostic_identity(diagnostic) for diagnostic in cached_diagnostics or [] + } + + return PublishedDiagnosticsSnapshot( + generation_by_after_path=generation_by_after_path, + warning_identities_by_before_path=warning_identities_by_before_path, + ) + + def _format_lsp_edit_result_with_new_diagnostics( + self, + default_result: str, + edited_file_paths: Iterable["EditedFilePath"], + before_edit_diagnostics_snapshot: PublishedDiagnosticsSnapshot | None, + ) -> str: + if not self._ENABLE_DIAGNOSTICS: + return default_result + + # TODO: this is weird, it works because before_edit_diagnostics_snapshot is only None when diagnostics + # are disabled, not when they are empty. A part of the general design flaws introduced by agent-generated code + if before_edit_diagnostics_snapshot is None or self.agent.get_language_backend() != LanguageBackend.LSP: + return default_result + + # collecting diagnostics state after the edit + symbol_retriever = self.create_language_server_symbol_retriever() + grouped_result: dict[str, dict[str, dict[str, list[dict[str, Any]]]]] = {} + saw_diagnostics_result = False + + for edited_file_path in edited_file_paths: + try: + language_server = symbol_retriever.get_language_server(edited_file_path.after_relative_path) + except Exception: + return default_result + + published_diagnostics = language_server.request_published_text_document_diagnostics( + relative_file_path=edited_file_path.after_relative_path, + after_generation=before_edit_diagnostics_snapshot.generation_by_after_path.get(edited_file_path.after_relative_path, -1), + timeout=2.5, + min_severity=2, + allow_cached=True, + ) + if not published_diagnostics: + try: + published_diagnostics = language_server.request_text_document_diagnostics( + edited_file_path.after_relative_path, + min_severity=2, + ) + except Exception: + published_diagnostics = None + if published_diagnostics is None: + continue + + saw_diagnostics_result = True + existing_warning_identities = before_edit_diagnostics_snapshot.warning_identities_by_before_path.get( + edited_file_path.before_relative_path, set() + ) + new_warning_identities: set[DiagnosticIdentity] = set() + + for diagnostic in published_diagnostics: + diagnostic_identity = self._diagnostic_identity(diagnostic) + if diagnostic_identity in existing_warning_identities or diagnostic_identity in new_warning_identities: + continue + new_warning_identities.add(diagnostic_identity) + + diagnostic_start = diagnostic["range"]["start"] + owner_symbol = symbol_retriever.find_diagnostic_owner_symbol( + relative_file_path=edited_file_path.after_relative_path, + line=diagnostic_start["line"], + column=diagnostic_start["character"], + ) + name_path = owner_symbol.get_name_path() if owner_symbol is not None else "" + self._add_grouped_diagnostic(grouped_result, edited_file_path.after_relative_path, name_path, diagnostic) + + # preserving the normal success result when no new diagnostics were introduced + if not saw_diagnostics_result or not grouped_result: + return default_result + + return f"Edit introduced new warning-or-higher diagnostics: {self._to_json(grouped_result)}" + def apply_ex(self, log_call: bool = True, catch_exceptions: bool = True, mcp_ctx: Context | None = None, **kwargs) -> str: # type: ignore """ Applies the tool with logging and exception handling, using the given keyword arguments @@ -319,7 +515,7 @@ class Tool(Component): if client_str != self.get_last_tool_call_client_str(): log.debug(f"Updating client info: {client_info}") self.set_last_tool_call_client_str(client_str) - except BaseException as e: + except Exception as e: log.info(f"Failed to get client info: {e}.") def task() -> str: diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index 6ae69a39..546c6a9f 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -14,7 +14,7 @@ from contextlib import contextmanager from copy import copy from pathlib import Path, PurePath from time import perf_counter, sleep -from typing import Self, Union, cast +from typing import Any, Self, Union, cast import pathspec from sensai.util.pickle import getstate, load_pickle @@ -511,6 +511,10 @@ class SolidLanguageServer(ABC): self.language_id = language_id self.open_file_buffers: dict[str, LSPFileBuffer] = {} self.language = Language(language_id) + self._published_diagnostics: dict[str, list[ls_types.Diagnostic]] = {} + self._published_diagnostics_generation_by_uri: dict[str, int] = {} + self._published_diagnostics_generation = 0 + self._published_diagnostics_condition = threading.Condition() # initialise symbol caches self.cache_dir = Path(self._solidlsp_settings.project_data_path) / self.CACHE_FOLDER_NAME / self.language_id @@ -550,6 +554,7 @@ class SolidLanguageServer(ABC): logger=logging_fn, start_independent_lsp_process=config.start_independent_lsp_process, ) + self.server.on_any_notification(self._observe_server_notification) # Set up the pathspec matcher for the ignored paths # for all absolute paths in ignored_paths, convert them to relative paths @@ -567,6 +572,274 @@ class SolidLanguageServer(ABC): self._has_waited_for_cross_file_references = False + def _observe_server_notification(self, method: str, params: Any) -> None: + """ + Observe notifications sent by the language server. + + This is used for generic cross-language bookkeeping that must work independently of + language-specific notification handlers. + """ + if method == "textDocument/publishDiagnostics": + self._store_published_diagnostics(params) + + def _store_published_diagnostics(self, params: Any) -> None: + """ + Store diagnostics received through ``textDocument/publishDiagnostics``. + """ + if not isinstance(params, dict): + return + + uri = params.get("uri") + diagnostics = params.get("diagnostics") + if not isinstance(uri, str) or not isinstance(diagnostics, list): + return + + normalized_diagnostics: list[ls_types.Diagnostic] = [] + for diagnostic in diagnostics: + if not isinstance(diagnostic, dict): + continue + if "message" not in diagnostic or "range" not in diagnostic: + continue + + normalized_diagnostic: ls_types.Diagnostic = { + "uri": uri, + "message": diagnostic["message"], + "range": diagnostic["range"], + } + severity = diagnostic.get("severity") + if isinstance(severity, int): + normalized_diagnostic["severity"] = ls_types.DiagnosticSeverity(severity) + + code = diagnostic.get("code") + if isinstance(code, int | str): + normalized_diagnostic["code"] = code + + if "source" in diagnostic: + normalized_diagnostic["source"] = diagnostic["source"] + normalized_diagnostics.append(ls_types.Diagnostic(**normalized_diagnostic)) + + with self._published_diagnostics_condition: + self._published_diagnostics_generation += 1 + self._published_diagnostics[uri] = normalized_diagnostics + self._published_diagnostics_generation_by_uri[uri] = self._published_diagnostics_generation + self._published_diagnostics_condition.notify_all() + + def _get_published_diagnostics_generation(self, uri: str) -> int: + with self._published_diagnostics_condition: + return self._published_diagnostics_generation_by_uri.get(uri, -1) + + def _wait_for_published_diagnostics( + self, + uri: str, + after_generation: int, + timeout: float, + ) -> list[ls_types.Diagnostic] | None: + deadline = perf_counter() + timeout + with self._published_diagnostics_condition: + while True: + current_generation = self._published_diagnostics_generation_by_uri.get(uri, -1) + if current_generation > after_generation: + return list(self._published_diagnostics.get(uri, [])) + + remaining_timeout = deadline - perf_counter() + if remaining_timeout <= 0: + return None + self._published_diagnostics_condition.wait(timeout=remaining_timeout) + + def _get_cached_published_diagnostics(self, uri: str) -> list[ls_types.Diagnostic] | None: + with self._published_diagnostics_condition: + diagnostics = self._published_diagnostics.get(uri) + if diagnostics is None: + return None + return list(diagnostics) + + @staticmethod + def _diagnostic_matches_range(diagnostic: ls_types.Diagnostic, start_line: int, end_line: int) -> bool: + diagnostic_start_line = diagnostic["range"]["start"]["line"] + diagnostic_end_line = diagnostic["range"]["end"]["line"] + effective_end_line = end_line if end_line >= 0 else diagnostic_end_line + return diagnostic_start_line <= effective_end_line and diagnostic_end_line >= start_line + + @staticmethod + def _diagnostic_matches_min_severity(diagnostic: ls_types.Diagnostic, min_severity: int) -> bool: + severity = diagnostic.get("severity") + if severity is None: + return True + return int(severity) <= min_severity + + @classmethod + def _filter_diagnostics( + cls, + diagnostics: list[ls_types.Diagnostic], + start_line: int, + end_line: int, + min_severity: int, + ) -> list[ls_types.Diagnostic]: + diagnostics = [d for d in diagnostics if cls._diagnostic_matches_range(d, start_line, end_line)] + diagnostics = [d for d in diagnostics if cls._diagnostic_matches_min_severity(d, min_severity)] + return diagnostics + + def _validate_text_document_diagnostics_request( + self, + relative_file_path: str, + start_line: int, + end_line: int, + min_severity: int, + ) -> str: + if not self.server_started: + log.error("request_text_document_diagnostics called before Language Server started") + raise SolidLSPException("Language Server not started") + if start_line < 0: + raise ValueError(f"start_line must be non-negative, got {start_line}") + if end_line != -1 and end_line < start_line: + raise ValueError(f"end_line must be -1 or >= start_line, got {end_line} < {start_line}") + if min_severity not in {1, 2, 3, 4}: + raise ValueError(f"min_severity must be one of 1, 2, 3, 4, got {min_severity}") + return pathlib.Path(str(PurePath(self.repository_root_path, relative_file_path))).as_uri() + + def get_published_diagnostics_generation(self, relative_file_path: str) -> int: + """ + Get the generation number for the latest published diagnostics of a file. + + :param relative_file_path: The relative path of the file. + :return: the generation number, or ``-1`` if none were published yet. + """ + uri = pathlib.Path(str(PurePath(self.repository_root_path, relative_file_path))).as_uri() + return self._get_published_diagnostics_generation(uri) + + def get_cached_published_text_document_diagnostics( + self, + relative_file_path: str, + start_line: int = 0, + end_line: int = -1, + min_severity: int = 4, + ) -> list[ls_types.Diagnostic] | None: + """ + Get cached diagnostics received through ``textDocument/publishDiagnostics``. + + :param relative_file_path: The relative path of the file to retrieve diagnostics for. + :param start_line: the first 0-based line to include in the result. + :param end_line: the last 0-based line to include in the result. ``-1`` means no upper bound. + :param min_severity: minimum LSP severity to include, where 1=Error, 2=Warning, 3=Information, 4=Hint. + Diagnostics with lower-or-equal numeric severity are returned. + :return: the cached diagnostics, or ``None`` if no diagnostics were published yet. + """ + uri = self._validate_text_document_diagnostics_request(relative_file_path, start_line, end_line, min_severity) + diagnostics = self._get_cached_published_diagnostics(uri) + if diagnostics is None: + return None + return self._filter_diagnostics(diagnostics, start_line, end_line, min_severity) + + def request_published_text_document_diagnostics( + self, + relative_file_path: str, + after_generation: int = -1, + timeout: float = 2.5, + start_line: int = 0, + end_line: int = -1, + min_severity: int = 4, + allow_cached: bool = True, + ) -> list[ls_types.Diagnostic] | None: + """ + Wait for diagnostics received through ``textDocument/publishDiagnostics`` and return them. + + :param relative_file_path: The relative path of the file to retrieve diagnostics for. + :param after_generation: only return diagnostics published after this generation. ``-1`` accepts the next publication. + :param timeout: the maximum time to wait for a newer publication. + :param start_line: the first 0-based line to include in the result. + :param end_line: the last 0-based line to include in the result. ``-1`` means no upper bound. + :param min_severity: minimum LSP severity to include, where 1=Error, 2=Warning, 3=Information, 4=Hint. + Diagnostics with lower-or-equal numeric severity are returned. + :param allow_cached: whether to fall back to the current cached diagnostics if no newer publication arrives in time. + :return: the published diagnostics, or ``None`` if no diagnostics are available. + """ + uri = self._validate_text_document_diagnostics_request(relative_file_path, start_line, end_line, min_severity) + diagnostics: list[ls_types.Diagnostic] | None = None + + # keeping the document open + with self.open_file(relative_file_path): + diagnostics = self._wait_for_published_diagnostics(uri=uri, after_generation=after_generation, timeout=timeout) + + # falling back to cached diagnostics + if diagnostics is None and allow_cached: + diagnostics = self._get_cached_published_diagnostics(uri) + + if diagnostics is None: + return None + + return self._filter_diagnostics(diagnostics, start_line, end_line, min_severity) + + def request_text_document_diagnostics( + self, + relative_file_path: str, + start_line: int = 0, + end_line: int = -1, + min_severity: int = 4, + ) -> list[ls_types.Diagnostic]: + """ + Raise a [textDocument/diagnostic](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_diagnostic) request to the Language Server + to find diagnostics for the given file. Wait for the response and return the result. + + :param relative_file_path: The relative path of the file to retrieve diagnostics for + :param start_line: the first 0-based line to include in the result. + :param end_line: the last 0-based line to include in the result. `-1` means no upper bound. + :param min_severity: minimum LSP severity to include, where 1=Error, 2=Warning, 3=Information, 4=Hint. + Diagnostics with lower-or-equal numeric severity are returned. + + :return: A list of diagnostics for the file + """ + uri = self._validate_text_document_diagnostics_request(relative_file_path, start_line, end_line, min_severity) + diagnostics_before_request = self._get_published_diagnostics_generation(uri) + ret: list[ls_types.Diagnostic] | None = None + pull_diagnostics_failed = False + + with self.open_file(relative_file_path): + try: + response = self.server.send.text_document_diagnostic( + { + LSPConstants.TEXT_DOCUMENT: { # type: ignore + LSPConstants.URI: uri, + } + } + ) + except SolidLSPException as ex: + log.debug("Falling back to published diagnostics for %s due to pull-diagnostics error: %s", relative_file_path, ex) + response = None + pull_diagnostics_failed = True + + if response is not None: + assert isinstance(response, dict), ( + f"Unexpected response from Language Server (expected list, got {type(response)}): {response}" + ) + ret = [] + for item in response["items"]: # type: ignore + new_item: ls_types.Diagnostic = { + "uri": uri, + "severity": item["severity"], + "message": item["message"], + "range": item["range"], + "code": item["code"], # type: ignore + } + if "source" in item: + new_item["source"] = item["source"] + ret.append(ls_types.Diagnostic(**new_item)) + + if not ret: + published_diagnostics = self._wait_for_published_diagnostics( + uri=uri, + after_generation=diagnostics_before_request, + timeout=2.5 if pull_diagnostics_failed else 0.5, + ) + if published_diagnostics is None: + published_diagnostics = self._get_cached_published_diagnostics(uri) + if published_diagnostics is not None: + ret = published_diagnostics + + if ret is None: + return [] + + return self._filter_diagnostics(ret, start_line, end_line, min_severity) + def _create_dependency_provider(self) -> LanguageServerDependencyProvider: """ Creates the dependency provider for this language server. diff --git a/src/solidlsp/ls_process.py b/src/solidlsp/ls_process.py index 6b979a92..6319a9d7 100644 --- a/src/solidlsp/ls_process.py +++ b/src/solidlsp/ls_process.py @@ -150,6 +150,7 @@ class LanguageServerProcess: self._pending_requests: dict[Any, Request] = {} self.on_request_handlers: dict[str, Callable[[Any], Any]] = {} self.on_notification_handlers: dict[str, Callable[[Any], None]] = {} + self._notification_observers: list[Callable[[str, Any], None]] = [] self._trace_log_fn = logger self.tasks: dict[int, Any] = {} self.task_counter = 0 @@ -510,6 +511,12 @@ class LanguageServerProcess: """ self.on_notification_handlers[method] = cb + def on_any_notification(self, cb: Callable[[str, Any], None]) -> None: + """ + Register an observer that is invoked for every notification received from the server. + """ + self._notification_observers.append(cb) + def _response_handler(self, response: StringDict) -> None: """ Handle the response received from the server for a request, using the id to determine the request @@ -561,6 +568,16 @@ class LanguageServerProcess: """ method = response.get("method", "") params = response.get("params") + + for observer in self._notification_observers: + try: + observer(method, params) + except asyncio.CancelledError: + return + except Exception as ex: + if not self._is_shutting_down: + log.error("Error handling notification observer for method '%s': %s", method, ex, exc_info=ex) + handler = self.on_notification_handlers.get(method) if not handler: log.warning("Unhandled method '%s'", method) diff --git a/src/solidlsp/ls_types.py b/src/solidlsp/ls_types.py index 78446bc5..24d51266 100644 --- a/src/solidlsp/ls_types.py +++ b/src/solidlsp/ls_types.py @@ -347,7 +347,7 @@ class Diagnostic(TypedDict): """ The severity of the diagnostic. """ message: str """ The diagnostic message. """ - code: str + code: NotRequired[str | int] """ The code of the diagnostic. """ source: NotRequired[str] """ The source of the diagnostic, e.g. the name of the tool that produced it. """ diff --git a/test/conftest.py b/test/conftest.py index 4d3f1154..5e55b180 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -1,6 +1,7 @@ import logging import os import platform +import re import shutil as _sh from collections.abc import Iterator from contextlib import contextmanager @@ -41,10 +42,13 @@ _LANGUAGE_REPO_ALIASES: dict[Language, Language] = { Language.CPP_CCLS: Language.CPP, Language.PHP_PHPACTOR: Language.PHP, Language.PYTHON_JEDI: Language.PYTHON, + Language.PYTHON_TY: Language.PYTHON, Language.RUBY_SOLARGRAPH: Language.RUBY, Language.PYTHON_TY: Language.PYTHON, } +PYTHON_LANGUAGE_BACKENDS = [Language.PYTHON, Language.PYTHON_TY] + def get_repo_path(language: Language) -> Path: repo_language = _LANGUAGE_REPO_ALIASES.get(language, language) @@ -329,3 +333,60 @@ def language_tests_enabled(language: Language) -> bool: :return: True if tests for the language are enabled, False otherwise """ return language not in _disabled_languages + + +def language_supports_implementation(language: Language) -> bool: + return language.supports_implementation_request() + + +def languages_supporting_implementation(*languages: Language) -> list[Language]: + return [language for language in languages if language_supports_implementation(language)] + + +_VERIFIED_IMPLEMENTATION_LANGUAGES = { + Language.CSHARP, + Language.GO, + Language.JAVA, + Language.RUST, + Language.TYPESCRIPT, +} + + +def language_has_verified_implementation_support(language: Language) -> bool: + """ + True only for languages where the server advertises implementation support and + the repo fixtures contain a verified working go-to-implementation scenario. + """ + return language in _VERIFIED_IMPLEMENTATION_LANGUAGES and language_supports_implementation(language) + + +def find_identifier_position(file_path: Path, identifier: str) -> tuple[int, int] | None: + pattern = re.compile(r"\b" + re.escape(identifier) + r"\b") + with file_path.open(encoding="utf-8") as f: + for line_idx, line in enumerate(f): + match = pattern.search(line) + if match: + return line_idx, match.start() + return None + + +def find_identifier_occurrence_position( + file_path: Path, + identifier: str, + occurrence_index: int = 0, + column_offset: int = 0, +) -> tuple[int, int] | None: + if occurrence_index < 0: + raise ValueError("occurrence_index must be non-negative") + if column_offset < 0: + raise ValueError("column_offset must be non-negative") + + pattern = re.compile(r"\b" + re.escape(identifier) + r"\b") + current_index = 0 + with file_path.open(encoding="utf-8") as f: + for line_idx, line in enumerate(f): + for match in pattern.finditer(line): + if current_index == occurrence_index: + return line_idx, match.start() + column_offset + current_index += 1 + return None diff --git a/test/diagnostics_cases.py b/test/diagnostics_cases.py new file mode 100644 index 00000000..8cfcfacb --- /dev/null +++ b/test/diagnostics_cases.py @@ -0,0 +1,230 @@ +import os +from dataclasses import dataclass +from typing import cast + +import pytest +from _pytest.mark import Mark, MarkDecorator + +from solidlsp.ls_config import Language +from test.conftest import get_pytest_markers + + +@dataclass(frozen=True) +class DiagnosticCase: + language: Language + relative_path: str + primary_symbol_name_path: str + primary_symbol_identifier: str + reference_symbol_name_path: str + reference_symbol_identifier: str + primary_message_fragment: str + reference_message_fragment: str + + +def diagnostic_case_param( + case: DiagnosticCase, + *marks: MarkDecorator | Mark, + id: str, +): + return pytest.param(case.language, case, marks=[*get_pytest_markers(case.language), *marks], id=id) + + +DIAGNOSTIC_CASE_PARAMS = [ + diagnostic_case_param( + DiagnosticCase( + language=Language.PYTHON, + relative_path=os.path.join("test_repo", "diagnostics_sample.py"), + primary_symbol_name_path="broken_factory", + primary_symbol_identifier="broken_factory", + reference_symbol_name_path="broken_consumer", + reference_symbol_identifier="broken_consumer", + primary_message_fragment="missing_user", + reference_message_fragment="undefined_name", + ), + id="python_missing_user", + ), + diagnostic_case_param( + DiagnosticCase( + language=Language.PYTHON_TY, + relative_path=os.path.join("test_repo", "diagnostics_sample.py"), + primary_symbol_name_path="broken_factory", + primary_symbol_identifier="broken_factory", + reference_symbol_name_path="broken_consumer", + reference_symbol_identifier="broken_consumer", + primary_message_fragment="missing_user", + reference_message_fragment="undefined_name", + ), + id="python_ty_missing_user", + ), + diagnostic_case_param( + DiagnosticCase( + language=Language.GO, + relative_path="diagnostics_sample.go", + primary_symbol_name_path="brokenFactory", + primary_symbol_identifier="brokenFactory", + reference_symbol_name_path="brokenConsumer", + reference_symbol_identifier="brokenConsumer", + primary_message_fragment="missingGreeting", + reference_message_fragment="missingConsumerValue", + ), + id="go_missing_greeting", + ), + diagnostic_case_param( + DiagnosticCase( + language=Language.JAVA, + relative_path=os.path.join("src", "main", "java", "test_repo", "DiagnosticsSample.java"), + primary_symbol_name_path="DiagnosticsSample/brokenFactory", + primary_symbol_identifier="brokenFactory", + reference_symbol_name_path="DiagnosticsSample/brokenConsumer", + reference_symbol_identifier="brokenConsumer", + primary_message_fragment="missingGreeting", + reference_message_fragment="missingConsumerValue", + ), + id="java_missing_greeting", + ), + diagnostic_case_param( + DiagnosticCase( + language=Language.KOTLIN, + relative_path=os.path.join("src", "main", "kotlin", "test_repo", "DiagnosticsSample.kt"), + primary_symbol_name_path="brokenFactory", + primary_symbol_identifier="brokenFactory", + reference_symbol_name_path="brokenConsumer", + reference_symbol_identifier="brokenConsumer", + primary_message_fragment="missingGreeting", + reference_message_fragment="missingConsumerValue", + ), + id="kotlin_missing_greeting", + ), + diagnostic_case_param( + DiagnosticCase( + language=Language.RUST, + relative_path=os.path.join("src", "diagnostics_sample.rs"), + primary_symbol_name_path="broken_factory", + primary_symbol_identifier="broken_factory", + reference_symbol_name_path="broken_consumer", + reference_symbol_identifier="broken_consumer", + primary_message_fragment="missing_greeting", + reference_message_fragment="missing_consumer_value", + ), + pytest.mark.xfail(reason="rust-analyzer does not surface diagnostics for this fixture through Serena currently"), + id="rust_missing_greeting", + ), + diagnostic_case_param( + DiagnosticCase( + language=Language.PHP, + relative_path="diagnostics_sample.php", + primary_symbol_name_path="brokenFactory", + primary_symbol_identifier="brokenFactory", + reference_symbol_name_path="brokenConsumer", + reference_symbol_identifier="brokenConsumer", + primary_message_fragment="missingGreeting", + reference_message_fragment="missingConsumerValue", + ), + pytest.mark.xfail(reason="PHP LS integration does not expose document diagnostics in this environment"), + id="php_missing_greeting", + ), + diagnostic_case_param( + DiagnosticCase( + language=Language.CLOJURE, + relative_path=os.path.join("src", "test_app", "diagnostics_sample.clj"), + primary_symbol_name_path="broken-factory", + primary_symbol_identifier="broken-factory", + reference_symbol_name_path="broken-consumer", + reference_symbol_identifier="broken-consumer", + primary_message_fragment="missing-greeting", + reference_message_fragment="missing-consumer-value", + ), + id="clojure_missing_greeting", + ), + diagnostic_case_param( + DiagnosticCase( + language=Language.CSHARP, + relative_path="DiagnosticsSample.cs", + primary_symbol_name_path="TestProject/DiagnosticsSample/BrokenFactory", + primary_symbol_identifier="BrokenFactory", + reference_symbol_name_path="TestProject/DiagnosticsSample/BrokenConsumer", + reference_symbol_identifier="BrokenConsumer", + primary_message_fragment="missingGreeting", + reference_message_fragment="missingConsumerValue", + ), + id="csharp_missing_greeting", + ), + diagnostic_case_param( + DiagnosticCase( + language=Language.POWERSHELL, + relative_path="diagnostics_sample.ps1", + primary_symbol_name_path="function Invoke-BrokenFactory ()", + primary_symbol_identifier="Invoke-BrokenFactory", + reference_symbol_name_path="function Invoke-BrokenConsumer ()", + reference_symbol_identifier="Invoke-BrokenConsumer", + primary_message_fragment="MissingGreeting", + reference_message_fragment="MissingConsumerValue", + ), + pytest.mark.xfail(reason="PowerShell LS does not surface document diagnostics in this environment"), + id="powershell_missing_greeting", + ), + diagnostic_case_param( + DiagnosticCase( + language=Language.CPP_CCLS, + relative_path="diagnostics_sample.cpp", + primary_symbol_name_path="brokenFactory", + primary_symbol_identifier="brokenFactory", + reference_symbol_name_path="brokenConsumer", + reference_symbol_identifier="brokenConsumer", + primary_message_fragment="missingGreeting", + reference_message_fragment="missingConsumerValue", + ), + pytest.mark.xfail(reason="ccls does not expose document diagnostics through this integration"), + id="cpp_missing_greeting", + ), + diagnostic_case_param( + DiagnosticCase( + language=Language.LEAN4, + relative_path="DiagnosticsSample.lean", + primary_symbol_name_path="brokenFactory", + primary_symbol_identifier="brokenFactory", + reference_symbol_name_path="brokenConsumer", + reference_symbol_identifier="brokenConsumer", + primary_message_fragment="missingGreeting", + reference_message_fragment="missingConsumerValue", + ), + pytest.mark.xfail(reason="Lean4 LS does not reliably surface diagnostics in CI"), + id="lean_missing_greeting", + ), + diagnostic_case_param( + DiagnosticCase( + language=Language.TYPESCRIPT, + relative_path="diagnostics_sample.ts", + primary_symbol_name_path="brokenFactory", + primary_symbol_identifier="brokenFactory", + reference_symbol_name_path="brokenConsumer", + reference_symbol_identifier="brokenConsumer", + primary_message_fragment="missingGreeting", + reference_message_fragment="missingConsumerValue", + ), + pytest.mark.typescript, + pytest.mark.xfail(reason="TypeScript LS does not surface document diagnostics through this integration"), + id="typescript_missing_greeting", + ), + diagnostic_case_param( + DiagnosticCase( + language=Language.FSHARP, + relative_path="DiagnosticsSample.fs", + primary_symbol_name_path="brokenFactory", + primary_symbol_identifier="brokenFactory", + reference_symbol_name_path="brokenConsumer", + reference_symbol_identifier="brokenConsumer", + primary_message_fragment="missingGreeting", + reference_message_fragment="missingConsumerValue", + ), + pytest.mark.xfail(reason="F# LS does not expose document diagnostics through this integration"), + id="fsharp_missing_greeting", + ), +] + +WORKING_DIAGNOSTIC_TOOL_CASE_PARAMS = [ + case_param + for case_param in DIAGNOSTIC_CASE_PARAMS + if cast(DiagnosticCase, case_param.values[1]).language + in {Language.PYTHON, Language.PYTHON_TY, Language.GO, Language.JAVA, Language.KOTLIN, Language.CSHARP} +] diff --git a/test/resources/repos/clojure/test_repo/src/test_app/diagnostics_sample.clj b/test/resources/repos/clojure/test_repo/src/test_app/diagnostics_sample.clj new file mode 100644 index 00000000..ecc0c79c --- /dev/null +++ b/test/resources/repos/clojure/test_repo/src/test_app/diagnostics_sample.clj @@ -0,0 +1,9 @@ +(ns test-app.diagnostics-sample) + +(defn broken-factory [] + missing-greeting) + +(defn broken-consumer [] + (let [value (broken-factory)] + (println value) + missing-consumer-value)) diff --git a/test/resources/repos/cpp/test_repo/compile_commands.json b/test/resources/repos/cpp/test_repo/compile_commands.json index 21c02f62..cb096a60 100644 --- a/test/resources/repos/cpp/test_repo/compile_commands.json +++ b/test/resources/repos/cpp/test_repo/compile_commands.json @@ -8,5 +8,10 @@ "directory": ".", "command": "g++ -std=c++17 -I . -c b.cpp", "file": "b.cpp" + }, + { + "directory": ".", + "command": "g++ -std=c++17 -I . -c diagnostics_sample.cpp", + "file": "diagnostics_sample.cpp" } -] \ No newline at end of file +] diff --git a/test/resources/repos/cpp/test_repo/diagnostics_sample.cpp b/test/resources/repos/cpp/test_repo/diagnostics_sample.cpp new file mode 100644 index 00000000..78075fc8 --- /dev/null +++ b/test/resources/repos/cpp/test_repo/diagnostics_sample.cpp @@ -0,0 +1,8 @@ +int brokenFactory() { + return missingGreeting; +} + +int brokenConsumer() { + int value = brokenFactory(); + return value + missingConsumerValue; +} diff --git a/test/resources/repos/csharp/test_repo/DiagnosticsSample.cs b/test/resources/repos/csharp/test_repo/DiagnosticsSample.cs new file mode 100644 index 00000000..1356e26d --- /dev/null +++ b/test/resources/repos/csharp/test_repo/DiagnosticsSample.cs @@ -0,0 +1,19 @@ +using System; + +namespace TestProject +{ + public static class DiagnosticsSample + { + public static string BrokenFactory() + { + return missingGreeting; + } + + public static void BrokenConsumer() + { + string value = BrokenFactory(); + Console.WriteLine(value); + Console.WriteLine(missingConsumerValue); + } + } +} diff --git a/test/resources/repos/csharp/test_repo/Program.cs b/test/resources/repos/csharp/test_repo/Program.cs index 55c8d228..4bcf0f6a 100644 --- a/test/resources/repos/csharp/test_repo/Program.cs +++ b/test/resources/repos/csharp/test_repo/Program.cs @@ -1,4 +1,5 @@ using System; +using TestProject.Services; namespace TestProject { @@ -7,10 +8,13 @@ namespace TestProject static void Main(string[] args) { Console.WriteLine("Hello, World!"); - + var calculator = new Calculator(); int result = calculator.Add(5, 3); Console.WriteLine($"5 + 3 = {result}"); + + IGreeter greeter = new ConsoleGreeter(); + Console.WriteLine(greeter.FormatGreeting("World")); } } @@ -40,4 +44,4 @@ namespace TestProject return (double)a / b; } } -} \ No newline at end of file +} diff --git a/test/resources/repos/csharp/test_repo/Services/ConsoleGreeter.cs b/test/resources/repos/csharp/test_repo/Services/ConsoleGreeter.cs new file mode 100644 index 00000000..d80d8ba8 --- /dev/null +++ b/test/resources/repos/csharp/test_repo/Services/ConsoleGreeter.cs @@ -0,0 +1,10 @@ +namespace TestProject.Services +{ + public class ConsoleGreeter : IGreeter + { + public string FormatGreeting(string name) + { + return $"Hello, {name}!"; + } + } +} diff --git a/test/resources/repos/csharp/test_repo/Services/IGreeter.cs b/test/resources/repos/csharp/test_repo/Services/IGreeter.cs new file mode 100644 index 00000000..2f64ca0e --- /dev/null +++ b/test/resources/repos/csharp/test_repo/Services/IGreeter.cs @@ -0,0 +1,7 @@ +namespace TestProject.Services +{ + public interface IGreeter + { + string FormatGreeting(string name); + } +} diff --git a/test/resources/repos/fsharp/test_repo/DiagnosticsSample.fs b/test/resources/repos/fsharp/test_repo/DiagnosticsSample.fs new file mode 100644 index 00000000..815b844b --- /dev/null +++ b/test/resources/repos/fsharp/test_repo/DiagnosticsSample.fs @@ -0,0 +1,11 @@ +module DiagnosticsSample + +let brokenConsumerValue = 1 + +let brokenFactory () = + missingGreeting + +let brokenConsumer () = + let value = brokenFactory () + printfn "%A" value + missingConsumerValue diff --git a/test/resources/repos/fsharp/test_repo/Formatter.fs b/test/resources/repos/fsharp/test_repo/Formatter.fs new file mode 100644 index 00000000..52085d3f --- /dev/null +++ b/test/resources/repos/fsharp/test_repo/Formatter.fs @@ -0,0 +1,8 @@ +module Formatter + +type IGreeter = + abstract member FormatGreeting: string -> string + +type ConsoleGreeter() = + interface IGreeter with + member _.FormatGreeting(name: string) = sprintf "Hello, %s!" name diff --git a/test/resources/repos/fsharp/test_repo/Program.fs b/test/resources/repos/fsharp/test_repo/Program.fs index e5328ceb..3305598c 100644 --- a/test/resources/repos/fsharp/test_repo/Program.fs +++ b/test/resources/repos/fsharp/test_repo/Program.fs @@ -1,7 +1,9 @@ module Program open Calculator +open Formatter open Models +open Shapes [] let main argv = @@ -24,6 +26,12 @@ let main argv = let calc = CalculatorClass() let classResult = calc.Add(20, 5) printfn "Calculator class: 20 + 5 = %d" classResult + + let greeter: IGreeter = ConsoleGreeter() :> IGreeter + printfn "%s" (greeter.FormatGreeting("World")) + + let shape: Shape = Circle(2.0) :> Shape + printfn "Area: %.2f" (shape.Area()) // Test person module let person = PersonModule.createPerson "Alice Smith" 25 (Some "alice@example.com") @@ -34,4 +42,4 @@ let main argv = let fact5 = factorial 5 printfn "5! = %d" fact5 - 0 // return success \ No newline at end of file + 0 // return success diff --git a/test/resources/repos/fsharp/test_repo/Shapes.fs b/test/resources/repos/fsharp/test_repo/Shapes.fs new file mode 100644 index 00000000..d67a98aa --- /dev/null +++ b/test/resources/repos/fsharp/test_repo/Shapes.fs @@ -0,0 +1,10 @@ +module Shapes + +[] +type Shape() = + abstract member Area: unit -> float + +type Circle(radius: float) = + inherit Shape() + + override _.Area() = System.Math.PI * radius * radius diff --git a/test/resources/repos/fsharp/test_repo/TestProject.fsproj b/test/resources/repos/fsharp/test_repo/TestProject.fsproj index 39b621ff..e44baee0 100644 --- a/test/resources/repos/fsharp/test_repo/TestProject.fsproj +++ b/test/resources/repos/fsharp/test_repo/TestProject.fsproj @@ -7,8 +7,11 @@ + + + - \ No newline at end of file + diff --git a/test/resources/repos/go/test_repo/diagnostics_sample.go b/test/resources/repos/go/test_repo/diagnostics_sample.go new file mode 100644 index 00000000..15f7d6a2 --- /dev/null +++ b/test/resources/repos/go/test_repo/diagnostics_sample.go @@ -0,0 +1,11 @@ +package main + +func brokenFactory() string { + return missingGreeting +} + +func brokenConsumer() { + value := brokenFactory() + _ = value + _ = missingConsumerValue +} diff --git a/test/resources/repos/go/test_repo/main.go b/test/resources/repos/go/test_repo/main.go index a5716358..44412892 100644 --- a/test/resources/repos/go/test_repo/main.go +++ b/test/resources/repos/go/test_repo/main.go @@ -5,6 +5,8 @@ import "fmt" func main() { fmt.Println("Hello, Go!") Helper() + var greeter Greeter = ConsoleGreeter{} + fmt.Println(greeter.FormatGreeting("Go")) } func Helper() { @@ -22,3 +24,13 @@ func (d *DemoStruct) Value() int { func UsingHelper() { Helper() } + +type Greeter interface { + FormatGreeting(name string) string +} + +type ConsoleGreeter struct{} + +func (ConsoleGreeter) FormatGreeting(name string) string { + return "Hello, " + name + "!" +} diff --git a/test/resources/repos/java/test_repo/src/main/java/test_repo/ConsoleGreeter.java b/test/resources/repos/java/test_repo/src/main/java/test_repo/ConsoleGreeter.java new file mode 100644 index 00000000..e8b85c99 --- /dev/null +++ b/test/resources/repos/java/test_repo/src/main/java/test_repo/ConsoleGreeter.java @@ -0,0 +1,8 @@ +package test_repo; + +public class ConsoleGreeter implements Greeter { + @Override + public String formatGreeting(String name) { + return "Hello, " + name + "!"; + } +} diff --git a/test/resources/repos/java/test_repo/src/main/java/test_repo/DiagnosticsSample.java b/test/resources/repos/java/test_repo/src/main/java/test_repo/DiagnosticsSample.java new file mode 100644 index 00000000..53f8406b --- /dev/null +++ b/test/resources/repos/java/test_repo/src/main/java/test_repo/DiagnosticsSample.java @@ -0,0 +1,13 @@ +package test_repo; + +public class DiagnosticsSample { + public static String brokenFactory() { + return missingGreeting; + } + + public static void brokenConsumer() { + String value = brokenFactory(); + System.out.println(value); + System.out.println(missingConsumerValue); + } +} diff --git a/test/resources/repos/java/test_repo/src/main/java/test_repo/Greeter.java b/test/resources/repos/java/test_repo/src/main/java/test_repo/Greeter.java new file mode 100644 index 00000000..569d5006 --- /dev/null +++ b/test/resources/repos/java/test_repo/src/main/java/test_repo/Greeter.java @@ -0,0 +1,5 @@ +package test_repo; + +public interface Greeter { + String formatGreeting(String name); +} diff --git a/test/resources/repos/java/test_repo/src/main/java/test_repo/Main.java b/test/resources/repos/java/test_repo/src/main/java/test_repo/Main.java index ed79b9fe..29c0688e 100644 --- a/test/resources/repos/java/test_repo/src/main/java/test_repo/Main.java +++ b/test/resources/repos/java/test_repo/src/main/java/test_repo/Main.java @@ -6,6 +6,8 @@ public class Main { Model model = new Model("Cascade"); System.out.println(model.getName()); acceptModel(model); + Greeter greeter = new ConsoleGreeter(); + System.out.println(greeter.formatGreeting("Cascade")); } public static void acceptModel(Model m) { // Do nothing, just for LSP reference diff --git a/test/resources/repos/kotlin/test_repo/gradle/wrapper/gradle-wrapper.properties b/test/resources/repos/kotlin/test_repo/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..2a84e188 --- /dev/null +++ b/test/resources/repos/kotlin/test_repo/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.0.0-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/test/resources/repos/kotlin/test_repo/gradlew b/test/resources/repos/kotlin/test_repo/gradlew new file mode 100644 index 00000000..ef07e016 --- /dev/null +++ b/test/resources/repos/kotlin/test_repo/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/test/resources/repos/kotlin/test_repo/gradlew.bat b/test/resources/repos/kotlin/test_repo/gradlew.bat new file mode 100644 index 00000000..db3a6ac2 --- /dev/null +++ b/test/resources/repos/kotlin/test_repo/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/test/resources/repos/kotlin/test_repo/src/main/kotlin/test_repo/DiagnosticsSample.kt b/test/resources/repos/kotlin/test_repo/src/main/kotlin/test_repo/DiagnosticsSample.kt new file mode 100644 index 00000000..93f0b939 --- /dev/null +++ b/test/resources/repos/kotlin/test_repo/src/main/kotlin/test_repo/DiagnosticsSample.kt @@ -0,0 +1,11 @@ +package test_repo + +fun brokenFactory(): String { + return missingGreeting +} + +fun brokenConsumer() { + val value = brokenFactory() + println(value) + println(missingConsumerValue) +} diff --git a/test/resources/repos/lean4/test_repo/DiagnosticsSample.lean b/test/resources/repos/lean4/test_repo/DiagnosticsSample.lean new file mode 100644 index 00000000..a2c570ec --- /dev/null +++ b/test/resources/repos/lean4/test_repo/DiagnosticsSample.lean @@ -0,0 +1,6 @@ +def brokenFactory : Nat := + missingGreeting + +def brokenConsumer : Nat := + let value := brokenFactory + value + missingConsumerValue diff --git a/test/resources/repos/lua/test_repo/main.lua b/test/resources/repos/lua/test_repo/main.lua index d19d5212..cd6ecc97 100644 --- a/test/resources/repos/lua/test_repo/main.lua +++ b/test/resources/repos/lua/test_repo/main.lua @@ -3,6 +3,7 @@ -- main.lua: Entry point for the test application local calculator = require("src.calculator") +local animals = require("src.animals") local utils = require("src.utils") local function print_banner() @@ -50,6 +51,12 @@ local function test_utils() logger:warn("This is a warning") end +local function test_animals() + print("\nTesting Animal Module:") + local dog = animals.Dog:new("Rex") + print(dog:speak()) +end + local function interactive_calculator() print("\nInteractive Calculator (type 'quit' to exit):") while true do @@ -99,11 +106,13 @@ local function main(args) if #args == 0 then test_calculator() test_utils() + test_animals() elseif args[1] == "interactive" then interactive_calculator() elseif args[1] == "test" then test_calculator() test_utils() + test_animals() print("\nAll tests completed!") else print("Usage: lua main.lua [interactive|test]") @@ -111,4 +120,4 @@ local function main(args) end -- Run main function -main(arg or {}) \ No newline at end of file +main(arg or {}) diff --git a/test/resources/repos/lua/test_repo/src/animals.lua b/test/resources/repos/lua/test_repo/src/animals.lua new file mode 100644 index 00000000..92f08f26 --- /dev/null +++ b/test/resources/repos/lua/test_repo/src/animals.lua @@ -0,0 +1,37 @@ +---@class Animal +local Animal = {} +Animal.__index = Animal + +---@param name string +---@return Animal +function Animal:new(name) + local self = setmetatable({}, Animal) + self.name = name + return self +end + +---@return string +function Animal:speak() + return self.name .. " makes a sound" +end + +---@class Dog: Animal +local Dog = setmetatable({}, { __index = Animal }) +Dog.__index = Dog + +---@param name string +---@return Dog +function Dog:new(name) + local self = Animal.new(self, name) + return setmetatable(self, Dog) +end + +---@return string +function Dog:speak() + return self.name .. " says woof" +end + +return { + Animal = Animal, + Dog = Dog, +} diff --git a/test/resources/repos/php/test_repo/diagnostics_sample.php b/test/resources/repos/php/test_repo/diagnostics_sample.php new file mode 100644 index 00000000..b1a6be19 --- /dev/null +++ b/test/resources/repos/php/test_repo/diagnostics_sample.php @@ -0,0 +1,11 @@ + User: + return missing_user + + +def broken_consumer() -> None: + created_user = broken_factory() + print(created_user) + print(undefined_name) diff --git a/test/resources/repos/ruby/test_repo/lib.rb b/test/resources/repos/ruby/test_repo/lib.rb index 5dd8504b..65eba9de 100644 --- a/test/resources/repos/ruby/test_repo/lib.rb +++ b/test/resources/repos/ruby/test_repo/lib.rb @@ -7,3 +7,15 @@ class Calculator a - b end end + +class Greeter + def format_greeting(name) + name + end +end + +class ConsoleGreeter < Greeter + def format_greeting(name) + "Hello, #{name}!" + end +end diff --git a/test/resources/repos/ruby/test_repo/main.rb b/test/resources/repos/ruby/test_repo/main.rb index dcf1b0f2..b9a98c92 100644 --- a/test/resources/repos/ruby/test_repo/main.rb +++ b/test/resources/repos/ruby/test_repo/main.rb @@ -15,6 +15,8 @@ end def helper_function(number = 42) demo = DemoClass.new(number) Calculator.new.add(demo.value, 10) + greeter = ConsoleGreeter.new + puts greeter.format_greeting("Ruby") demo.print_value end diff --git a/test/resources/repos/rust/test_repo/src/diagnostics_sample.rs b/test/resources/repos/rust/test_repo/src/diagnostics_sample.rs new file mode 100644 index 00000000..120e9e36 --- /dev/null +++ b/test/resources/repos/rust/test_repo/src/diagnostics_sample.rs @@ -0,0 +1,9 @@ +pub fn broken_factory() -> String { + missing_greeting +} + +pub fn broken_consumer() { + let value = broken_factory(); + println!("{value}"); + println!("{}", missing_consumer_value); +} diff --git a/test/resources/repos/rust/test_repo/src/lib.rs b/test/resources/repos/rust/test_repo/src/lib.rs index 8ad43ead..178d0165 100644 --- a/test/resources/repos/rust/test_repo/src/lib.rs +++ b/test/resources/repos/rust/test_repo/src/lib.rs @@ -1,3 +1,5 @@ +pub mod diagnostics_sample; + // This function returns the sum of 2 + 2 pub fn add() -> i32 { let res = 2 + 2; @@ -7,3 +9,15 @@ pub fn multiply() -> i32 { 2 * 3 } +pub trait Greeter { + fn format_greeting(&self, name: &str) -> String; +} + +pub struct ConsoleGreeter; + +impl Greeter for ConsoleGreeter { + fn format_greeting(&self, name: &str) -> String { + format!("Hello, {name}!") + } +} + diff --git a/test/resources/repos/rust/test_repo/src/main.rs b/test/resources/repos/rust/test_repo/src/main.rs index 7385c9d9..a5bb8063 100644 --- a/test/resources/repos/rust/test_repo/src/main.rs +++ b/test/resources/repos/rust/test_repo/src/main.rs @@ -1,8 +1,10 @@ -use rsandbox::add; +use rsandbox::{add, ConsoleGreeter, Greeter}; fn main() { println!("Hello, World!"); println!("Good morning!"); println!("add result: {}", add()); + let greeter = ConsoleGreeter; + println!("{}", greeter.format_greeting("Rust")); println!("inserted line"); } diff --git a/test/resources/repos/typescript/test_repo/.serena/project.yml b/test/resources/repos/typescript/test_repo/.serena/project.yml index b9f472b6..942b95a1 100644 --- a/test/resources/repos/typescript/test_repo/.serena/project.yml +++ b/test/resources/repos/typescript/test_repo/.serena/project.yml @@ -1,7 +1,153 @@ + +# list of tool names to exclude. +# This extends the existing exclusions (e.g. from the global configuration) +# +# Below is the complete list of tools for convenience. +# To make sure you have the latest list of tools, and to view their descriptions, +# execute `uv run scripts/print_tool_overview.py`. +# +# * `activate_project`: Activates a project by name. +# * `check_onboarding_performed`: Checks whether project onboarding was already performed. +# * `create_text_file`: Creates/overwrites a file in the project directory. +# * `delete_lines`: Deletes a range of lines within a file. +# * `delete_memory`: Deletes a memory from Serena's project-specific memory store. +# * `execute_shell_command`: Executes a shell command. +# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced. +# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type). +# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type). +# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes. +# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file. +# * `initial_instructions`: Gets the initial instructions for the current project. +# Should only be used in settings where the system prompt cannot be set, +# e.g. in clients you have no control over, like Claude Desktop. +# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol. +# * `insert_at_line`: Inserts content at a given line in a file. +# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol. +# * `list_dir`: Lists files and directories in the given directory (optionally with recursion). +# * `list_memories`: Lists memories in Serena's project-specific memory store. +# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building). +# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context). +# * `read_file`: Reads a file within the project directory. +# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store. +# * `remove_project`: Removes a project from the Serena configuration. +# * `replace_lines`: Replaces a range of lines within a file with new content. +# * `replace_symbol_body`: Replaces the full definition of a symbol. +# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen. +# * `search_for_pattern`: Performs a search for a pattern in the project. +# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase. +# * `switch_modes`: Activates modes by providing a list of their names +# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information. +# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task. +# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed. +# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store. excluded_tools: [] + +# whether to use project's .gitignore files to ignore files ignore_all_files_in_gitignore: true + +# list of additional paths to ignore in this project. +# Same syntax as gitignore, so you can use * and **. +# Note: global ignored_paths from serena_config.yml are also applied additively. ignored_paths: [] + +# initial prompt for the project. It will always be given to the LLM upon activating the project +# (contrary to the memories, which are loaded on demand). initial_prompt: '' -language: typescript +# the name by which the project can be referenced within Serena project_name: test_repo + +# whether the project is in read-only mode +# If set to true, all editing tools will be disabled and attempts to use them will result in an error +# Added on 2025-04-18 read_only: false + +# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default). +# This extends the existing inclusions (e.g. from the global configuration). +included_optional_tools: [] + +# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools. +# This cannot be combined with non-empty excluded_tools or included_optional_tools. +fixed_tools: [] + +# list of mode names to that are always to be included in the set of active modes +# The full set of modes to be activated is base_modes + default_modes. +# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply. +# Otherwise, this setting overrides the global configuration. +# Set this to [] to disable base modes for this project. +# Set this to a list of mode names to always include the respective modes for this project. +base_modes: + +# list of mode names that are to be activated by default. +# The full set of modes to be activated is base_modes + default_modes. +# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply. +# Otherwise, this overrides the setting from the global configuration (serena_config.yml). +# This setting can, in turn, be overridden by CLI parameters (--mode). +default_modes: + +# time budget (seconds) per tool call for the retrieval of additional symbol information +# such as docstrings or parameter information. +# This overrides the corresponding setting in the global configuration; see the documentation there. +# If null or missing, use the setting from the global configuration. +symbol_info_budget: + +# The language backend to use for this project. +# If not set, the global setting from serena_config.yml is used. +# Valid values: LSP, JetBrains +# Note: the backend is fixed at startup. If a project with a different backend +# is activated post-init, an error will be returned. +language_backend: + +# line ending convention to use when writing source files. +# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default) +# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings. +line_ending: + +# list of regex patterns which, when matched, mark a memory entry as read‑only. +# Extends the list from the global configuration, merging the two lists. +read_only_memory_patterns: [] + +# list of regex patterns for memories to completely ignore. +# Matching memories will not appear in list_memories or activate_project output +# and cannot be accessed via read_memory or write_memory. +# To access ignored memory files, use the read_file tool on the raw file path. +# Extends the list from the global configuration, merging the two lists. +# Example: ["_archive/.*", "_episodes/.*"] +ignored_memory_patterns: [] + +# advanced configuration option allowing to configure language server-specific options. +# Maps the language key to the options. +# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available. +# No documentation on options means no options are available. +ls_specific_settings: {} + +# the encoding used by text files in the project +# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings +encoding: utf-8 + + +# list of languages for which language servers are started; choose from: +# al bash clojure cpp csharp +# csharp_omnisharp dart elixir elm erlang +# fortran fsharp go groovy haskell +# java julia kotlin lua markdown +# matlab nix pascal perl php +# php_phpactor powershell python python_jedi python_ty +# r +# rego ruby ruby_solargraph rust scala +# swift terraform toml typescript typescript_vts +# vue yaml zig +# (This list may be outdated. For the current list, see values of Language enum here: +# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py +# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) +# Note: +# - For C, use cpp +# - For JavaScript, use typescript +# - For Free Pascal/Lazarus, use pascal +# Special requirements: +# Some languages require additional setup/installations. +# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers +# When using multiple languages, the first language server that supports a given file will be used for that file. +# The first language is the default language and the respective language server will be used as a fallback. +# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored. +languages: +- typescript diff --git a/test/resources/repos/typescript/test_repo/diagnostics_sample.ts b/test/resources/repos/typescript/test_repo/diagnostics_sample.ts new file mode 100644 index 00000000..566643e1 --- /dev/null +++ b/test/resources/repos/typescript/test_repo/diagnostics_sample.ts @@ -0,0 +1,9 @@ +export function brokenFactory(): string { + return missingGreeting; +} + +export function brokenConsumer(): void { + const value = brokenFactory(); + console.log(value); + console.log(missingConsumerValue); +} diff --git a/test/resources/repos/typescript/test_repo/formatters.ts b/test/resources/repos/typescript/test_repo/formatters.ts new file mode 100644 index 00000000..fe233f93 --- /dev/null +++ b/test/resources/repos/typescript/test_repo/formatters.ts @@ -0,0 +1,9 @@ +export interface Greeter { + formatGreeting(name: string): string; +} + +export class ConsoleGreeter implements Greeter { + formatGreeting(name: string): string { + return `Hello, ${name}!`; + } +} diff --git a/test/resources/repos/typescript/test_repo/index.ts b/test/resources/repos/typescript/test_repo/index.ts index 724a67c6..417e5863 100644 --- a/test/resources/repos/typescript/test_repo/index.ts +++ b/test/resources/repos/typescript/test_repo/index.ts @@ -1,3 +1,5 @@ +import { ConsoleGreeter, Greeter } from "./formatters"; + export class DemoClass { value: number; constructor(value: number) { @@ -11,6 +13,9 @@ export class DemoClass { export function helperFunction() { const demo = new DemoClass(42); demo.printValue(); + + const greeter: Greeter = new ConsoleGreeter(); + console.log(greeter.formatGreeting("World")); } helperFunction(); diff --git a/test/serena/__snapshots__/test_symbol_editing.ambr b/test/serena/__snapshots__/test_symbol_editing.ambr index 6c688345..8fd3a73b 100644 --- a/test/serena/__snapshots__/test_symbol_editing.ambr +++ b/test/serena/__snapshots__/test_symbol_editing.ambr @@ -67,11 +67,16 @@ # --- # name: test_delete_symbol[test_case1] ''' - + import { ConsoleGreeter, Greeter } from "./formatters"; + + export function helperFunction() { const demo = new DemoClass(42); demo.printValue(); + + const greeter: Greeter = new ConsoleGreeter(); + console.log(greeter.formatGreeting("World")); } helperFunction(); @@ -515,6 +520,8 @@ # --- # name: test_insert_in_rel_to_symbol[test_case1-after] ''' + import { ConsoleGreeter, Greeter } from "./formatters"; + export class DemoClass { value: number; constructor(value: number) { @@ -532,6 +539,9 @@ export function helperFunction() { const demo = new DemoClass(42); demo.printValue(); + + const greeter: Greeter = new ConsoleGreeter(); + console.log(greeter.formatGreeting("World")); } helperFunction(); @@ -544,6 +554,8 @@ # --- # name: test_insert_in_rel_to_symbol[test_case1-before] ''' + import { ConsoleGreeter, Greeter } from "./formatters"; + function newFunctionAfterClass(): void { console.log("This function is after DemoClass."); } @@ -561,6 +573,9 @@ export function helperFunction() { const demo = new DemoClass(42); demo.printValue(); + + const greeter: Greeter = new ConsoleGreeter(); + console.log(greeter.formatGreeting("World")); } helperFunction(); @@ -573,6 +588,8 @@ # --- # name: test_insert_in_rel_to_symbol[test_case2-after] ''' + import { ConsoleGreeter, Greeter } from "./formatters"; + export class DemoClass { value: number; constructor(value: number) { @@ -586,6 +603,9 @@ export function helperFunction() { const demo = new DemoClass(42); demo.printValue(); + + const greeter: Greeter = new ConsoleGreeter(); + console.log(greeter.formatGreeting("World")); } function newInsertedFunction(): void { @@ -602,6 +622,8 @@ # --- # name: test_insert_in_rel_to_symbol[test_case2-before] ''' + import { ConsoleGreeter, Greeter } from "./formatters"; + export class DemoClass { value: number; constructor(value: number) { @@ -619,6 +641,9 @@ export function helperFunction() { const demo = new DemoClass(42); demo.printValue(); + + const greeter: Greeter = new ConsoleGreeter(); + console.log(greeter.formatGreeting("World")); } helperFunction(); @@ -1686,6 +1711,8 @@ # --- # name: test_replace_body[test_case1] ''' + import { ConsoleGreeter, Greeter } from "./formatters"; + export class DemoClass { value: number; constructor(value: number) { @@ -1700,6 +1727,9 @@ export function helperFunction() { const demo = new DemoClass(42); demo.printValue(); + + const greeter: Greeter = new ConsoleGreeter(); + console.log(greeter.formatGreeting("World")); } helperFunction(); diff --git a/test/serena/test_serena_agent.py b/test/serena/test_serena_agent.py index 896b030f..62dc1a53 100644 --- a/test/serena/test_serena_agent.py +++ b/test/serena/test_serena_agent.py @@ -2,12 +2,15 @@ import json import logging import os import re +import shutil import time from collections.abc import Iterator from contextlib import contextmanager from typing import Literal import pytest +from test.diagnostics_cases import WORKING_DIAGNOSTIC_TOOL_CASE_PARAMS, DiagnosticCase +from _pytest.mark import Mark, MarkDecorator from serena.agent import SerenaAgent from serena.config.serena_config import ProjectConfig, RegisteredProject, SerenaConfig @@ -15,8 +18,13 @@ from serena.project import Project from serena.tools import ( SUCCESS_RESULT, ActivateProjectTool, + FindDefiningSymbolAtLocationTool, + FindDefiningSymbolTool, + FindImplementationsTool, FindReferencingSymbolsTool, FindSymbolTool, + GetDiagnosticsForFileTool, + GetDiagnosticsForSymbolTool, InitialInstructionsTool, ReplaceContentTool, ReplaceSymbolBodyTool, @@ -25,9 +33,208 @@ from serena.tools import ( ) from solidlsp.ls_config import Language from solidlsp.ls_types import SymbolKind -from test.conftest import get_repo_path, is_ci, language_tests_enabled +from test.conftest import ( + find_identifier_occurrence_position, + get_repo_path, + is_ci, + language_has_verified_implementation_support, + language_tests_enabled, +) +from test.diagnostics_cases import WORKING_DIAGNOSTIC_TOOL_CASE_PARAMS, DiagnosticCase from test.solidlsp import clojure as clj +DEFINING_SYMBOL_TOOL_TEST_CASES = [ + pytest.param( + Language.PYTHON, + os.path.join("test_repo", "services.py"), + "User", + 1, + 1, + "User", + "models.py", + marks=pytest.mark.python, + ), + pytest.param( + Language.PYTHON_TY, + os.path.join("test_repo", "services.py"), + "User", + 1, + 1, + "User", + "models.py", + marks=pytest.mark.python, + ), + pytest.param(Language.GO, "main.go", "Helper", 0, 1, "Helper", "main.go", marks=pytest.mark.go), + pytest.param( + Language.JAVA, + os.path.join("src", "main", "java", "test_repo", "Main.java"), + "Model", + 0, + 1, + "Model", + "Model.java", + marks=pytest.mark.java, + ), + pytest.param( + Language.KOTLIN, + os.path.join("src", "main", "kotlin", "test_repo", "Main.kt"), + "Model", + 0, + 1, + "Model", + "Model.kt", + marks=[pytest.mark.kotlin] + ([pytest.mark.skip(reason="Kotlin LSP JVM crashes on restart in CI")] if is_ci else []), + ), + pytest.param( + Language.RUST, + os.path.join("src", "main.rs"), + "format_greeting", + 0, + 1, + "format_greeting", + "lib.rs", + marks=pytest.mark.rust, + ), + pytest.param(Language.PHP, "index.php", "helperFunction", 0, 5, "helperFunction", "helper.php", marks=pytest.mark.php), + pytest.param( + Language.CLOJURE, + clj.UTILS_PATH, + "multiply", + 0, + 1, + "multiply", + clj.CORE_PATH, + marks=[ + pytest.mark.clojure, + pytest.mark.skipif(not clj.is_clojure_cli_available(), reason="clojure CLI is not installed"), + ], + ), + pytest.param(Language.CSHARP, "Program.cs", "Add", 0, 1, "Add", "Program.cs", marks=pytest.mark.csharp), + pytest.param( + Language.POWERSHELL, + "main.ps1", + "Convert-ToUpperCase", + 0, + 1, + "function Convert-ToUpperCase ()", + "utils.ps1", + marks=pytest.mark.powershell, + ), + pytest.param(Language.CPP_CCLS, "a.cpp", "add", 0, 1, "add", "b.cpp", marks=pytest.mark.cpp), + pytest.param( + Language.LEAN4, + "Main.lean", + "add", + 0, + 1, + "add", + "Helper.lean", + marks=[ + pytest.mark.lean4, + pytest.mark.skipif(shutil.which("lean") is None, reason="Lean is not installed"), + ], + ), + pytest.param(Language.TYPESCRIPT, "index.ts", "helperFunction", 1, 1, "helperFunction", "index.ts", marks=pytest.mark.typescript), + pytest.param( + Language.FSHARP, + "Program.fs", + "add", + 0, + 1, + "add", + "Calculator.fs", + marks=[pytest.mark.fsharp, pytest.mark.xfail(reason="F# language server cannot reliably resolve defining symbols")], + ), +] + +REGEX_DEFINING_SYMBOL_TOOL_TEST_CASES = [ + pytest.param( + Language.PYTHON, + os.path.join("test_repo", "services.py"), + r"from \.models import Item, (User)", + "", + "User", + "models.py", + marks=pytest.mark.python, + ), + pytest.param( + Language.PYTHON, + os.path.join("test_repo", "services.py"), + r"=\s+(User)\(", + "UserService/create_user", + "User", + "models.py", + marks=pytest.mark.python, + ), + pytest.param( + Language.PYTHON_TY, + os.path.join("test_repo", "services.py"), + r"=\s+(User)\(", + "UserService/create_user", + "User", + "models.py", + marks=pytest.mark.python, + ), + pytest.param( + Language.GO, + "main.go", + r"var greeter (Greeter) =", + "main", + "Greeter", + "main.go", + marks=pytest.mark.go, + ), +] + +IMPLEMENTATION_TOOL_TEST_CASE_DATA = [ + ( + Language.CSHARP, + "IGreeter/FormatGreeting", + os.path.join("Services", "IGreeter.cs"), + os.path.join("Services", "ConsoleGreeter.cs"), + "FormatGreeting", + pytest.mark.csharp, + ), + ( + Language.GO, + "Greeter/FormatGreeting", + "main.go", + "main.go", + "(ConsoleGreeter).FormatGreeting", + pytest.mark.go, + ), + ( + Language.JAVA, + "Greeter/formatGreeting", + os.path.join("src", "main", "java", "test_repo", "Greeter.java"), + os.path.join("src", "main", "java", "test_repo", "ConsoleGreeter.java"), + "formatGreeting", + pytest.mark.java, + ), + ( + Language.RUST, + "Greeter/format_greeting", + os.path.join("src", "lib.rs"), + os.path.join("src", "lib.rs"), + "format_greeting", + pytest.mark.rust, + ), + ( + Language.TYPESCRIPT, + "Greeter/formatGreeting", + "formatters.ts", + "formatters.ts", + "formatGreeting", + pytest.mark.typescript, + ), +] + +IMPLEMENTATION_TOOL_TEST_CASES = [ + pytest.param(language, symbol_name, def_file, impl_file, expected_symbol_name, marks=mark) + for language, symbol_name, def_file, impl_file, expected_symbol_name, mark in IMPLEMENTATION_TOOL_TEST_CASE_DATA + if language_has_verified_implementation_support(language) +] + @pytest.fixture def serena_config(): @@ -37,6 +244,7 @@ def serena_config(): test_projects = [] for language in [ Language.PYTHON, + Language.PYTHON_TY, Language.GO, Language.JAVA, Language.KOTLIN, @@ -82,6 +290,13 @@ def read_project_file(project: Project, relative_path: str) -> str: return f.read() +def parse_edit_diagnostics_result(result: str) -> dict: + """Utility function to parse the diagnostic payload returned by edit tools.""" + prefix = "Edit introduced new warning-or-higher diagnostics: " + assert result.startswith(prefix), result + return json.loads(result[len(prefix) :]) + + @contextmanager def project_file_modification_context(serena_agent: SerenaAgent, relative_path: str) -> Iterator[None]: """Context manager to modify a project file and revert the changes after use.""" @@ -267,6 +482,174 @@ class TestSerenaAgent: refs = json.loads(result) assert contains_ref_with_relative_path(refs, ref_file), f"Expected to find reference to {symbol_name} in {ref_file}. refs={refs}" + def _assert_find_symbol_implementations( + self, + serena_agent: SerenaAgent, + symbol_name: str, + def_file: str, + impl_file: str, + expected_symbol_name: str, + ) -> None: + agent = serena_agent + + find_symbol_tool = agent.get_tool(FindSymbolTool) + result = find_symbol_tool.apply(name_path_pattern=symbol_name, relative_path=def_file) + symbols = json.loads(result) + assert symbols, f"Expected to find symbol {symbol_name} in {def_file}" + + def_symbol = symbols[0] + find_impl_tool = agent.get_tool(FindImplementationsTool) + result = find_impl_tool.apply(name_path=def_symbol["name_path"], relative_path=def_symbol["relative_path"], include_info=True) + implementations = json.loads(result) + + assert any( + impl_file in implementation["relative_path"] + and ( + implementation.get("name") == expected_symbol_name + or implementation.get("name_path") == expected_symbol_name + or expected_symbol_name in implementation.get("info", "") + ) + for implementation in implementations + ), f"Expected to find implementation of {symbol_name} in {impl_file}. implementations={implementations}" + + for implementation in implementations: + if implementation["kind"] in (SymbolKind.File.name, SymbolKind.Module.name): + continue + symbol_info = implementation.get("info") + assert symbol_info, f"Expected symbol info to be present for implementation: {implementation}" + + def _assert_find_defining_symbol( + self, + serena_agent: SerenaAgent, + relative_path: str, + identifier: str, + occurrence_index: int, + column_offset: int, + expected_name: str, + expected_definition_file: str, + ) -> None: + project_root = get_repo_path(serena_agent.get_active_lsp_languages()[0]) + position = find_identifier_occurrence_position(project_root / relative_path, identifier, occurrence_index, column_offset) + assert position is not None, f"Could not find occurrence {occurrence_index} of {identifier!r} in {relative_path}" + + find_defining_symbol_tool = serena_agent.get_tool(FindDefiningSymbolAtLocationTool) + result = find_defining_symbol_tool.apply(relative_path=relative_path, line=position[0], column=position[1], include_info=True) + defining_symbol = json.loads(result) + + assert defining_symbol is not None, f"Expected defining symbol for {identifier!r} in {relative_path}" + assert defining_symbol.get("relative_path") is not None + assert expected_definition_file in defining_symbol["relative_path"], ( + f"Expected defining symbol in {expected_definition_file!r}, got: {defining_symbol}" + ) + assert ( + defining_symbol.get("name") == expected_name + or defining_symbol.get("name_path") == expected_name + or expected_name in defining_symbol.get("info", "") + ), f"Expected defining symbol name {expected_name!r}, got: {defining_symbol}" + + if serena_agent.get_active_lsp_languages() == [Language.KOTLIN]: + return + if defining_symbol["kind"] not in (SymbolKind.File.name, SymbolKind.Module.name): + assert defining_symbol.get("info"), f"Expected defining symbol info to be present: {defining_symbol}" + + def _assert_find_defining_symbol_by_regex( + self, + serena_agent: SerenaAgent, + relative_path: str, + regex: str, + containing_symbol_name_path: str, + expected_name: str, + expected_definition_file: str, + ) -> None: + find_defining_symbol_tool = serena_agent.get_tool(FindDefiningSymbolTool) + result = find_defining_symbol_tool.apply( + regex=regex, + relative_path=relative_path, + containing_symbol_name_path=containing_symbol_name_path, + include_info=True, + ) + defining_symbol = json.loads(result) + + assert defining_symbol is not None, f"Expected defining symbol for regex {regex!r} in {relative_path}" + assert defining_symbol.get("relative_path") is not None + assert expected_definition_file in defining_symbol["relative_path"], ( + f"Expected defining symbol in {expected_definition_file!r}, got: {defining_symbol}" + ) + assert ( + defining_symbol.get("name") == expected_name + or defining_symbol.get("name_path") == expected_name + or expected_name in defining_symbol.get("info", "") + ), f"Expected defining symbol name {expected_name!r}, got: {defining_symbol}" + + if serena_agent.get_active_lsp_languages() == [Language.KOTLIN]: + return + if defining_symbol["kind"] not in (SymbolKind.File.name, SymbolKind.Module.name): + assert defining_symbol.get("info"), f"Expected defining symbol info to be present: {defining_symbol}" + + def _assert_diagnostics_for_file( + self, + serena_agent: SerenaAgent, + diagnostic_case: DiagnosticCase, + start_line: int = 0, + end_line: int = -1, + ) -> None: + diagnostics_tool = serena_agent.get_tool(GetDiagnosticsForFileTool) + result = diagnostics_tool.apply( + relative_path=diagnostic_case.relative_path, + start_line=start_line, + end_line=end_line, + min_severity=1, + ) + grouped_diagnostics = json.loads(result) + + assert diagnostic_case.relative_path in grouped_diagnostics, grouped_diagnostics + severity_group = grouped_diagnostics[diagnostic_case.relative_path] + assert "Error" in severity_group, severity_group + name_path_group = severity_group["Error"] + + for expected_name_path in [diagnostic_case.primary_symbol_name_path, diagnostic_case.reference_symbol_name_path]: + assert expected_name_path in name_path_group, name_path_group + + diagnostic_messages = [ + diagnostic["message"] for diagnostics_for_name_path in name_path_group.values() for diagnostic in diagnostics_for_name_path + ] + for expected_fragment in [diagnostic_case.primary_message_fragment, diagnostic_case.reference_message_fragment]: + assert any(expected_fragment in message for message in diagnostic_messages), diagnostic_messages + + def _assert_diagnostics_for_symbol( + self, + serena_agent: SerenaAgent, + diagnostic_case: DiagnosticCase, + check_symbol_references: bool, + ) -> None: + diagnostics_tool = serena_agent.get_tool(GetDiagnosticsForSymbolTool) + result = diagnostics_tool.apply( + name_path=diagnostic_case.primary_symbol_name_path, + reference_file=diagnostic_case.relative_path, + check_symbol_references=check_symbol_references, + min_severity=1, + ) + grouped_diagnostics = json.loads(result) + diagnostics_file = diagnostic_case.relative_path + + assert diagnostics_file in grouped_diagnostics, grouped_diagnostics + severity_group = grouped_diagnostics[diagnostics_file] + assert "Error" in severity_group, severity_group + name_path_group = severity_group["Error"] + + expected_name_paths = [diagnostic_case.primary_symbol_name_path] + expected_message_fragments = [diagnostic_case.primary_message_fragment] + if check_symbol_references: + expected_name_paths.append(diagnostic_case.reference_symbol_name_path) + expected_message_fragments.append(diagnostic_case.reference_message_fragment) + + assert set(expected_name_paths).issubset(name_path_group.keys()), name_path_group + diagnostic_messages = [ + diagnostic["message"] for diagnostics_for_name_path in name_path_group.values() for diagnostic in diagnostics_for_name_path + ] + for expected_fragment in expected_message_fragments: + assert any(expected_fragment in message for message in diagnostic_messages), diagnostic_messages + @pytest.mark.parametrize( "serena_agent,symbol_name,def_file,ref_file", [ @@ -341,6 +724,161 @@ class TestSerenaAgent: def test_find_symbol_references_fsharp(self, serena_agent: SerenaAgent, symbol_name: str, def_file: str, ref_file: str) -> None: self._assert_find_symbol_references(serena_agent, symbol_name, def_file, ref_file) + @pytest.mark.parametrize( + "serena_agent,relative_path,identifier,occurrence_index,column_offset,expected_name,expected_definition_file", + DEFINING_SYMBOL_TOOL_TEST_CASES, + indirect=["serena_agent"], + ) + def test_find_defining_symbol( + self, + serena_agent: SerenaAgent, + relative_path: str, + identifier: str, + occurrence_index: int, + column_offset: int, + expected_name: str, + expected_definition_file: str, + ) -> None: + self._assert_find_defining_symbol( + serena_agent, + relative_path, + identifier, + occurrence_index, + column_offset, + expected_name, + expected_definition_file, + ) + + @pytest.mark.parametrize( + "serena_agent,relative_path,regex,containing_symbol_name_path,expected_name,expected_definition_file", + REGEX_DEFINING_SYMBOL_TOOL_TEST_CASES, + indirect=["serena_agent"], + ) + def test_find_defining_symbol_by_regex( + self, + serena_agent: SerenaAgent, + relative_path: str, + regex: str, + containing_symbol_name_path: str, + expected_name: str, + expected_definition_file: str, + ) -> None: + self._assert_find_defining_symbol_by_regex( + serena_agent, + relative_path, + regex, + containing_symbol_name_path, + expected_name, + expected_definition_file, + ) + + @pytest.mark.parametrize( + "serena_agent,relative_path,regex,containing_symbol_name_path,error_fragment", + [ + pytest.param( + Language.PYTHON, + os.path.join("test_repo", "services.py"), + r"(User)", + "", + "Expected exactly one regex match", + marks=pytest.mark.python, + ), + pytest.param( + Language.PYTHON, + os.path.join("test_repo", "services.py"), + r"User", + "UserService/create_user", + "must contain exactly one capturing group", + marks=pytest.mark.python, + ), + ], + indirect=["serena_agent"], + ) + def test_find_defining_symbol_by_regex_error( + self, + serena_agent: SerenaAgent, + relative_path: str, + regex: str, + containing_symbol_name_path: str, + error_fragment: str, + ) -> None: + find_defining_symbol_tool = serena_agent.get_tool(FindDefiningSymbolTool) + result = find_defining_symbol_tool.apply( + regex=regex, + relative_path=relative_path, + containing_symbol_name_path=containing_symbol_name_path, + ) + assert result.startswith("Error: "), result + assert error_fragment in result, result + + @pytest.mark.parametrize("serena_agent,diagnostic_case", WORKING_DIAGNOSTIC_TOOL_CASE_PARAMS, indirect=["serena_agent"]) + def test_get_diagnostics_for_file(self, serena_agent: SerenaAgent, diagnostic_case: DiagnosticCase) -> None: + self._assert_diagnostics_for_file( + serena_agent, + diagnostic_case=diagnostic_case, + ) + + @pytest.mark.parametrize("serena_agent,diagnostic_case", WORKING_DIAGNOSTIC_TOOL_CASE_PARAMS, indirect=["serena_agent"]) + def test_get_diagnostics_for_file_in_range(self, serena_agent: SerenaAgent, diagnostic_case: DiagnosticCase) -> None: + project_root = get_repo_path(diagnostic_case.language) + primary_position = find_identifier_occurrence_position( + project_root / diagnostic_case.relative_path, diagnostic_case.primary_symbol_identifier + ) + reference_position = find_identifier_occurrence_position( + project_root / diagnostic_case.relative_path, diagnostic_case.reference_symbol_identifier + ) + assert primary_position is not None + assert reference_position is not None + + self._assert_diagnostics_for_file( + serena_agent, + diagnostic_case=DiagnosticCase( + language=diagnostic_case.language, + relative_path=diagnostic_case.relative_path, + primary_symbol_name_path=diagnostic_case.primary_symbol_name_path, + primary_symbol_identifier=diagnostic_case.primary_symbol_identifier, + reference_symbol_name_path=diagnostic_case.primary_symbol_name_path, + reference_symbol_identifier=diagnostic_case.reference_symbol_identifier, + primary_message_fragment=diagnostic_case.primary_message_fragment, + reference_message_fragment=diagnostic_case.primary_message_fragment, + ), + start_line=primary_position[0], + end_line=reference_position[0] - 1, + ) + + @pytest.mark.parametrize("serena_agent,diagnostic_case", WORKING_DIAGNOSTIC_TOOL_CASE_PARAMS, indirect=["serena_agent"]) + def test_get_diagnostics_for_symbol(self, serena_agent: SerenaAgent, diagnostic_case: DiagnosticCase) -> None: + self._assert_diagnostics_for_symbol( + serena_agent, + diagnostic_case=diagnostic_case, + check_symbol_references=False, + ) + + @pytest.mark.parametrize("serena_agent,diagnostic_case", WORKING_DIAGNOSTIC_TOOL_CASE_PARAMS, indirect=["serena_agent"]) + def test_get_diagnostics_for_symbol_with_references(self, serena_agent: SerenaAgent, diagnostic_case: DiagnosticCase) -> None: + self._assert_diagnostics_for_symbol( + serena_agent, + diagnostic_case=diagnostic_case, + check_symbol_references=True, + ) + + if IMPLEMENTATION_TOOL_TEST_CASES: + + @pytest.mark.parametrize( + "serena_agent,symbol_name,def_file,impl_file,expected_symbol_name", + IMPLEMENTATION_TOOL_TEST_CASES, + indirect=["serena_agent"], + ) + def test_find_symbol_implementations( + self, + serena_agent: SerenaAgent, + symbol_name: str, + def_file: str, + impl_file: str, + expected_symbol_name: str, + ) -> None: + self._assert_find_symbol_implementations(serena_agent, symbol_name, def_file, impl_file, expected_symbol_name) + @pytest.mark.parametrize( "serena_agent,name_path,substring_matching,expected_symbol_name,expected_kind,expected_file", [ @@ -588,6 +1126,62 @@ class TestSerenaAgent: new_content = read_project_file(serena_agent.get_active_project(), relative_path) assert repl in new_content + @pytest.mark.parametrize( + "serena_agent", + [ + pytest.param(Language.PYTHON, marks=pytest.mark.python), + pytest.param(Language.PYTHON_TY, marks=pytest.mark.python), + ], + indirect=["serena_agent"], + ) + def test_replace_content_reports_new_diagnostics(self, serena_agent: SerenaAgent): + """Tests that file-level edits report newly introduced diagnostics.""" + relative_path = os.path.join("test_repo", "services.py") + replace_content_tool = serena_agent.get_tool(ReplaceContentTool) + + with project_file_modification_context(serena_agent, relative_path): + result = replace_content_tool.apply( + relative_path=relative_path, + needle="return container", + repl="return missing_container", + mode="literal", + ) + + diagnostics = parse_edit_diagnostics_result(result) + relative_path_result = diagnostics[relative_path] + diagnostic_messages = json.dumps(relative_path_result) + assert "missing_container" in diagnostic_messages + assert "create_service_container" in diagnostic_messages + + @pytest.mark.parametrize( + "serena_agent", + [ + pytest.param(Language.PYTHON, marks=pytest.mark.python), + pytest.param(Language.PYTHON_TY, marks=pytest.mark.python), + ], + indirect=["serena_agent"], + ) + def test_replace_symbol_body_reports_new_diagnostics(self, serena_agent: SerenaAgent): + """Tests that symbol-level edits report newly introduced diagnostics.""" + relative_path = os.path.join("test_repo", "services.py") + replace_symbol_body_tool = serena_agent.get_tool(ReplaceSymbolBodyTool) + + with project_file_modification_context(serena_agent, relative_path): + result = replace_symbol_body_tool.apply( + name_path="create_service_container", + relative_path=relative_path, + body=""" +def create_service_container() -> dict[str, Any]: + return missing_container +""", + ) + + diagnostics = parse_edit_diagnostics_result(result) + relative_path_result = diagnostics[relative_path] + diagnostic_messages = json.dumps(relative_path_result) + assert "missing_container" in diagnostic_messages + assert "create_service_container" in diagnostic_messages + @pytest.mark.parametrize( "serena_agent", [ diff --git a/test/solidlsp/clojure/__init__.py b/test/solidlsp/clojure/__init__.py index f7d9dad6..5e8e1c93 100644 --- a/test/solidlsp/clojure/__init__.py +++ b/test/solidlsp/clojure/__init__.py @@ -13,8 +13,8 @@ def _test_clojure_cli() -> bool: CLI_FAIL = _test_clojure_cli() TEST_APP_PATH = Path("src") / "test_app" -CORE_PATH = str(TEST_APP_PATH / "core.clj") -UTILS_PATH = str(TEST_APP_PATH / "utils.clj") +CORE_PATH = str(TEST_APP_PATH / "core.clj").replace("\\", "/") +UTILS_PATH = str(TEST_APP_PATH / "utils.clj").replace("\\", "/") def is_clojure_cli_available() -> bool: diff --git a/test/solidlsp/csharp/test_csharp_basic.py b/test/solidlsp/csharp/test_csharp_basic.py index 91f67781..97e44e5e 100644 --- a/test/solidlsp/csharp/test_csharp_basic.py +++ b/test/solidlsp/csharp/test_csharp_basic.py @@ -18,6 +18,7 @@ from solidlsp.ls_config import Language, LanguageServerConfig from solidlsp.ls_types import SymbolKind from solidlsp.ls_utils import SymbolUtils from solidlsp.settings import SolidLSPSettings +from test.conftest import find_identifier_position, get_repo_path, language_has_verified_implementation_support from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols @@ -176,6 +177,33 @@ class TestCSharpLanguageServer: assert "bool" in method_hover_text, f"Hover should include 'bool' return type, got: {method_hover_text}" assert "IsAdult" in method_hover_text, f"Hover should include 'IsAdult' method name, got: {method_hover_text}" + if language_has_verified_implementation_support(Language.CSHARP): + + @pytest.mark.parametrize("language_server", [Language.CSHARP], indirect=True) + def test_find_implementations(self, language_server: SolidLanguageServer) -> None: + repo_path = get_repo_path(Language.CSHARP) + pos = find_identifier_position(repo_path / "Services" / "IGreeter.cs", "FormatGreeting") + assert pos is not None, "Could not find IGreeter.FormatGreeting in fixture" + + implementations = language_server.request_implementation("Services/IGreeter.cs", *pos) + assert implementations, "Expected at least one implementation of IGreeter.FormatGreeting" + assert any("ConsoleGreeter.cs" in implementation.get("relativePath", "") for implementation in implementations), ( + f"Expected ConsoleGreeter.FormatGreeting in implementations, got: {implementations}" + ) + + @pytest.mark.parametrize("language_server", [Language.CSHARP], indirect=True) + def test_request_implementing_symbols(self, language_server: SolidLanguageServer) -> None: + repo_path = get_repo_path(Language.CSHARP) + pos = find_identifier_position(repo_path / "Services" / "IGreeter.cs", "FormatGreeting") + assert pos is not None, "Could not find IGreeter.FormatGreeting in fixture" + + implementing_symbols = language_server.request_implementing_symbols("Services/IGreeter.cs", *pos) + assert implementing_symbols, "Expected implementing symbols for IGreeter.FormatGreeting" + assert any( + symbol.get("name") == "FormatGreeting" and "ConsoleGreeter.cs" in symbol["location"].get("relativePath", "") + for symbol in implementing_symbols + ), f"Expected ConsoleGreeter.FormatGreeting symbol, got: {implementing_symbols}" + @pytest.mark.csharp class TestCSharpSolutionProjectOpening: diff --git a/test/solidlsp/fortran/test_fortran_basic.py b/test/solidlsp/fortran/test_fortran_basic.py index e7a45235..9a73ece1 100644 --- a/test/solidlsp/fortran/test_fortran_basic.py +++ b/test/solidlsp/fortran/test_fortran_basic.py @@ -11,6 +11,7 @@ from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_types import SymbolKind from solidlsp.ls_utils import SymbolUtils +from test.conftest import find_identifier_position, get_repo_path, language_has_verified_implementation_support from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols # Mark all tests in this module as fortran tests @@ -38,6 +39,33 @@ class TestFortranLanguageServer: # Verify subroutine symbol assert SymbolUtils.symbol_tree_contains_name(symbols, "print_result"), "print_result subroutine not found in symbol tree" + if language_has_verified_implementation_support(Language.FORTRAN): + + @pytest.mark.parametrize("language_server", [Language.FORTRAN], indirect=True) + def test_find_implementations(self, language_server: SolidLanguageServer) -> None: + repo_path = get_repo_path(Language.FORTRAN) + pos = find_identifier_position(repo_path / "modules" / "geometry.f90", "distance") + assert pos is not None, "Could not find interface distance in geometry.f90" + + implementations = language_server.request_implementation("modules/geometry.f90", *pos) + assert implementations, "Expected implementations for geometry_types.distance" + implementation_files = {implementation.get("relativePath", "") for implementation in implementations} + assert implementation_files == {"modules/geometry.f90"}, f"Unexpected implementation locations: {implementations}" + assert len(implementations) >= 2, f"Expected module procedure implementations, got: {implementations}" + + @pytest.mark.parametrize("language_server", [Language.FORTRAN], indirect=True) + def test_request_implementing_symbols(self, language_server: SolidLanguageServer) -> None: + repo_path = get_repo_path(Language.FORTRAN) + pos = find_identifier_position(repo_path / "modules" / "geometry.f90", "distance") + assert pos is not None, "Could not find interface distance in geometry.f90" + + implementing_symbols = language_server.request_implementing_symbols("modules/geometry.f90", *pos) + assert implementing_symbols, "Expected implementing symbols for geometry_types.distance" + implementing_symbol_names = {symbol.get("name") for symbol in implementing_symbols} + assert {"distance_2d", "distance_3d"}.issubset(implementing_symbol_names), ( + f"Expected distance_2d and distance_3d, got: {implementing_symbols}" + ) + @pytest.mark.parametrize("language_server", [Language.FORTRAN], indirect=True) def test_request_document_symbols(self, language_server: SolidLanguageServer) -> None: """Test that document symbols can be retrieved from Fortran files.""" diff --git a/test/solidlsp/fsharp/test_fsharp_basic.py b/test/solidlsp/fsharp/test_fsharp_basic.py index d0f52443..f58ec0b1 100644 --- a/test/solidlsp/fsharp/test_fsharp_basic.py +++ b/test/solidlsp/fsharp/test_fsharp_basic.py @@ -7,7 +7,7 @@ import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_utils import SymbolUtils -from test.conftest import is_ci +from test.conftest import find_identifier_position, get_repo_path, is_ci, language_has_verified_implementation_support from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols @@ -39,6 +39,33 @@ class TestFSharpLanguageServer: symbol_names = [s.get("name") for s in symbols] assert "main" in symbol_names, "main function not found in Program.fs symbols" + if language_has_verified_implementation_support(Language.FSHARP): + + @pytest.mark.parametrize("language_server", [Language.FSHARP], indirect=True) + def test_find_implementations(self, language_server: SolidLanguageServer) -> None: + repo_path = get_repo_path(Language.FSHARP) + pos = find_identifier_position(repo_path / "Formatter.fs", "FormatGreeting") + assert pos is not None, "Could not find IGreeter.FormatGreeting in fixture" + + implementations = language_server.request_implementation("Formatter.fs", *pos) + assert implementations, "Expected at least one implementation of IGreeter.FormatGreeting" + assert any("Formatter.fs" in implementation.get("relativePath", "") for implementation in implementations), ( + f"Expected ConsoleGreeter.FormatGreeting in implementations, got: {implementations}" + ) + + @pytest.mark.parametrize("language_server", [Language.FSHARP], indirect=True) + def test_request_implementing_symbols(self, language_server: SolidLanguageServer) -> None: + repo_path = get_repo_path(Language.FSHARP) + pos = find_identifier_position(repo_path / "Formatter.fs", "FormatGreeting") + assert pos is not None, "Could not find IGreeter.FormatGreeting in fixture" + + implementing_symbols = language_server.request_implementing_symbols("Formatter.fs", *pos) + assert implementing_symbols, "Expected implementing symbols for IGreeter.FormatGreeting" + assert any( + symbol.get("name") == "FormatGreeting" and "Formatter.fs" in symbol["location"].get("relativePath", "") + for symbol in implementing_symbols + ), f"Expected ConsoleGreeter.FormatGreeting symbol, got: {implementing_symbols}" + @pytest.mark.parametrize("language_server", [Language.FSHARP], indirect=True) def test_get_document_symbols_calculator(self, language_server: SolidLanguageServer) -> None: """Test getting document symbols from Calculator.fs file.""" diff --git a/test/solidlsp/go/test_go_basic.py b/test/solidlsp/go/test_go_basic.py index 93fcd98e..02aa3b9b 100644 --- a/test/solidlsp/go/test_go_basic.py +++ b/test/solidlsp/go/test_go_basic.py @@ -7,6 +7,7 @@ from serena.symbol import LanguageServerSymbol from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_utils import SymbolUtils +from test.conftest import find_identifier_position, get_repo_path, language_has_verified_implementation_support from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols @@ -47,6 +48,33 @@ class TestGoLanguageServer: refs = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) assert any("main.go" in ref.get("uri", "") for ref in refs), "Expected at least one reference result to point at main.go" + if language_has_verified_implementation_support(Language.GO): + + @pytest.mark.parametrize("language_server", [Language.GO], indirect=True) + def test_find_implementations(self, language_server: SolidLanguageServer) -> None: + repo_path = get_repo_path(Language.GO) + pos = find_identifier_position(repo_path / "main.go", "FormatGreeting") + assert pos is not None, "Could not find Greeter.FormatGreeting in fixture" + + implementations = language_server.request_implementation("main.go", *pos) + assert implementations, "Expected at least one implementation of Greeter.FormatGreeting" + assert any("main.go" in implementation.get("relativePath", "") for implementation in implementations), ( + f"Expected ConsoleGreeter.FormatGreeting in implementations, got: {implementations}" + ) + + @pytest.mark.parametrize("language_server", [Language.GO], indirect=True) + def test_request_implementing_symbols(self, language_server: SolidLanguageServer) -> None: + repo_path = get_repo_path(Language.GO) + pos = find_identifier_position(repo_path / "main.go", "FormatGreeting") + assert pos is not None, "Could not find Greeter.FormatGreeting in fixture" + + implementing_symbols = language_server.request_implementing_symbols("main.go", *pos) + assert implementing_symbols, "Expected implementing symbols for Greeter.FormatGreeting" + assert any( + symbol.get("name") == "(ConsoleGreeter).FormatGreeting" and "main.go" in symbol["location"].get("relativePath", "") + for symbol in implementing_symbols + ), f"Expected ConsoleGreeter.FormatGreeting symbol, got: {implementing_symbols}" + def _filter_symbols_by_name_in_repo(symbols: list | None, target_name: str, repo_name: str = "test_repo") -> list: """Filter workspace symbols to exact name matches in the test repo.""" diff --git a/test/solidlsp/java/test_java_basic.py b/test/solidlsp/java/test_java_basic.py index 56b3bbf4..4c50c3c4 100644 --- a/test/solidlsp/java/test_java_basic.py +++ b/test/solidlsp/java/test_java_basic.py @@ -5,7 +5,12 @@ import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_utils import SymbolUtils -from test.conftest import language_tests_enabled +from test.conftest import ( + find_identifier_position, + get_repo_path, + language_has_verified_implementation_support, + language_tests_enabled, +) from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols pytestmark = [pytest.mark.java, pytest.mark.skipif(not language_tests_enabled(Language.JAVA), reason="Java tests disabled")] @@ -52,6 +57,33 @@ class TestJavaLanguageServer: assert SymbolUtils.symbol_tree_contains_name(symbols, "Utils"), "Utils missing from overview" assert SymbolUtils.symbol_tree_contains_name(symbols, "Model"), "Model missing from overview" + if language_has_verified_implementation_support(Language.JAVA): + + @pytest.mark.parametrize("language_server", [Language.JAVA], indirect=True) + def test_find_implementations(self, language_server: SolidLanguageServer) -> None: + repo_path = get_repo_path(Language.JAVA) + pos = find_identifier_position(repo_path / "src/main/java/test_repo/Greeter.java", "formatGreeting") + assert pos is not None, "Could not find Greeter.formatGreeting in fixture" + + implementations = language_server.request_implementation("src/main/java/test_repo/Greeter.java", *pos) + assert implementations, "Expected at least one implementation of Greeter.formatGreeting" + assert any("ConsoleGreeter.java" in implementation.get("relativePath", "") for implementation in implementations), ( + f"Expected ConsoleGreeter.formatGreeting in implementations, got: {implementations}" + ) + + @pytest.mark.parametrize("language_server", [Language.JAVA], indirect=True) + def test_request_implementing_symbols(self, language_server: SolidLanguageServer) -> None: + repo_path = get_repo_path(Language.JAVA) + pos = find_identifier_position(repo_path / "src/main/java/test_repo/Greeter.java", "formatGreeting") + assert pos is not None, "Could not find Greeter.formatGreeting in fixture" + + implementing_symbols = language_server.request_implementing_symbols("src/main/java/test_repo/Greeter.java", *pos) + assert implementing_symbols, "Expected implementing symbols for Greeter.formatGreeting" + assert any( + symbol.get("name") == "formatGreeting" and "ConsoleGreeter.java" in symbol["location"].get("relativePath", "") + for symbol in implementing_symbols + ), f"Expected ConsoleGreeter.formatGreeting symbol, got: {implementing_symbols}" + @pytest.mark.parametrize("language_server", [Language.JAVA], indirect=True) def test_bare_symbol_names(self, language_server) -> None: all_symbols = request_all_symbols(language_server) diff --git a/test/solidlsp/lua/test_lua_basic.py b/test/solidlsp/lua/test_lua_basic.py index f140881b..5465fda1 100644 --- a/test/solidlsp/lua/test_lua_basic.py +++ b/test/solidlsp/lua/test_lua_basic.py @@ -10,6 +10,7 @@ import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_types import SymbolKind +from test.conftest import find_identifier_position, get_repo_path, language_has_verified_implementation_support from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols @@ -82,6 +83,33 @@ class TestLuaLanguageServer: # Check for Logger class/table assert "Logger" in all_symbols or any("Logger" in s for s in all_symbols), "Logger not found in symbols" + if language_has_verified_implementation_support(Language.LUA): + + @pytest.mark.parametrize("language_server", [Language.LUA], indirect=True) + def test_find_implementations(self, language_server: SolidLanguageServer) -> None: + repo_path = get_repo_path(Language.LUA) + pos = find_identifier_position(repo_path / "src" / "animals.lua", "speak") + assert pos is not None, "Could not find Animal:speak in fixture" + + implementations = language_server.request_implementation("src/animals.lua", *pos) + assert implementations, "Expected at least one implementation of Animal:speak" + assert any("animals.lua" in implementation.get("relativePath", "") for implementation in implementations), ( + f"Expected Dog:speak in implementations, got: {implementations}" + ) + + @pytest.mark.parametrize("language_server", [Language.LUA], indirect=True) + def test_request_implementing_symbols(self, language_server: SolidLanguageServer) -> None: + repo_path = get_repo_path(Language.LUA) + pos = find_identifier_position(repo_path / "src" / "animals.lua", "speak") + assert pos is not None, "Could not find Animal:speak in fixture" + + implementing_symbols = language_server.request_implementing_symbols("src/animals.lua", *pos) + assert implementing_symbols, "Expected implementing symbols for Animal:speak" + assert any( + symbol.get("name") == "speak" and "animals.lua" in symbol["location"].get("relativePath", "") + for symbol in implementing_symbols + ), f"Expected Dog:speak symbol, got: {implementing_symbols}" + @pytest.mark.parametrize("language_server", [Language.LUA], indirect=True) def test_find_symbols_in_main(self, language_server: SolidLanguageServer) -> None: """Test finding functions in main.lua.""" @@ -133,7 +161,7 @@ class TestLuaLanguageServer: assert refs is not None assert isinstance(refs, list) - # add function appears in: main.lua (lines 16, 71), test_calculator.lua (lines 22, 23, 24) + # add function appears in: main.lua (lines 17, 78), test_calculator.lua (lines 22, 23, 24) # Note: The declaration itself may or may not be included as a reference assert len(refs) >= 5, f"Should find at least 5 references to calculator.add, found {len(refs)}" @@ -153,7 +181,7 @@ class TestLuaLanguageServer: # Check main.lua has usages assert "main.lua" in ref_files, "Should find add usages in main.lua" - assert 15 in ref_files["main.lua"] or 70 in ref_files["main.lua"], ( + assert 16 in ref_files["main.lua"] or 77 in ref_files["main.lua"], ( f"Should find add usage in main.lua, found at lines {ref_files.get('main.lua', [])}" ) @@ -189,7 +217,7 @@ class TestLuaLanguageServer: assert refs is not None assert isinstance(refs, list) - # trim function appears in: usage (line 32 in main.lua) + # trim function appears in: usage (line 33 in main.lua) # Note: The declaration itself may or may not be included as a reference assert len(refs) >= 1, f"Should find at least 1 reference to utils.trim, found {len(refs)}" @@ -209,8 +237,8 @@ class TestLuaLanguageServer: # Check main.lua has usage assert "main.lua" in ref_files, "Should find trim usage in main.lua" - assert 31 in ref_files["main.lua"], ( - f"Should find trim usage at line 32 (0-indexed: 31) in main.lua, found at lines {ref_files.get('main.lua', [])}" + assert 32 in ref_files["main.lua"], ( + f"Should find trim usage at line 33 (0-indexed: 32) in main.lua, found at lines {ref_files.get('main.lua', [])}" ) # Check for cross-file references from main.lua diff --git a/test/solidlsp/python/test_python_basic.py b/test/solidlsp/python/test_python_basic.py index f57b37bd..f3d46f0f 100644 --- a/test/solidlsp/python/test_python_basic.py +++ b/test/solidlsp/python/test_python_basic.py @@ -12,6 +12,7 @@ import pytest from serena.project import Project from serena.util.text_utils import LineType from solidlsp import SolidLanguageServer +from test.conftest import PYTHON_LANGUAGE_BACKENDS from test.solidlsp.conftest import PYTHON_BACKEND_LANGUAGES, format_symbol_for_assert, has_malformed_name, request_all_symbols @@ -79,6 +80,26 @@ class TestPythonLanguageServerBasics: references = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) assert len(references) > 1, "Should get valid references for create_user (using selectionRange if present)" + @pytest.mark.parametrize("language_server", PYTHON_LANGUAGE_BACKENDS, indirect=True) + def test_request_text_document_diagnostics_with_filters(self, language_server: SolidLanguageServer) -> None: + file_path = os.path.join("test_repo", "diagnostics_sample.py") + + diagnostics = language_server.request_text_document_diagnostics(file_path) + assert len(diagnostics) >= 2 + diagnostic_messages = [diagnostic["message"] for diagnostic in diagnostics] + assert any("missing_user" in message for message in diagnostic_messages), diagnostic_messages + assert any("undefined_name" in message for message in diagnostic_messages), diagnostic_messages + + factory_diagnostics = language_server.request_text_document_diagnostics(file_path, start_line=3, end_line=5, min_severity=1) + factory_messages = [diagnostic["message"] for diagnostic in factory_diagnostics] + assert factory_messages, "Expected diagnostics in broken_factory range" + assert all("missing_user" in message for message in factory_messages), factory_messages + + consumer_diagnostics = language_server.request_text_document_diagnostics(file_path, start_line=7, end_line=10, min_severity=1) + consumer_messages = [diagnostic["message"] for diagnostic in consumer_diagnostics] + assert consumer_messages, "Expected diagnostics in broken_consumer range" + assert all("undefined_name" in message for message in consumer_messages), consumer_messages + class TestProjectBasics: @pytest.mark.parametrize("project", PYTHON_BACKEND_LANGUAGES, indirect=True) diff --git a/test/solidlsp/rust/test_rust_basic.py b/test/solidlsp/rust/test_rust_basic.py index 814892d0..29fce938 100644 --- a/test/solidlsp/rust/test_rust_basic.py +++ b/test/solidlsp/rust/test_rust_basic.py @@ -5,6 +5,7 @@ import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_utils import SymbolUtils +from test.conftest import find_identifier_position, get_repo_path, language_has_verified_implementation_support from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols @@ -57,6 +58,33 @@ class TestRustLanguageServer: assert SymbolUtils.symbol_tree_contains_name(symbols, "main"), "main missing from overview" assert SymbolUtils.symbol_tree_contains_name(symbols, "add"), "add missing from overview" + if language_has_verified_implementation_support(Language.RUST): + + @pytest.mark.parametrize("language_server", [Language.RUST], indirect=True) + def test_find_implementations(self, language_server: SolidLanguageServer) -> None: + repo_path = get_repo_path(Language.RUST) + pos = find_identifier_position(repo_path / os.path.join("src", "lib.rs"), "format_greeting") + assert pos is not None, "Could not find Greeter.format_greeting in fixture" + + implementations = language_server.request_implementation(os.path.join("src", "lib.rs"), *pos) + assert implementations, "Expected at least one implementation of Greeter.format_greeting" + assert any("src/lib.rs" in implementation.get("relativePath", "").replace("\\", "/") for implementation in implementations), ( + f"Expected ConsoleGreeter.format_greeting in implementations, got: {implementations}" + ) + + @pytest.mark.parametrize("language_server", [Language.RUST], indirect=True) + def test_request_implementing_symbols(self, language_server: SolidLanguageServer) -> None: + repo_path = get_repo_path(Language.RUST) + pos = find_identifier_position(repo_path / os.path.join("src", "lib.rs"), "format_greeting") + assert pos is not None, "Could not find Greeter.format_greeting in fixture" + + implementing_symbols = language_server.request_implementing_symbols(os.path.join("src", "lib.rs"), *pos) + assert implementing_symbols, "Expected implementing symbols for Greeter.format_greeting" + assert any( + symbol.get("name") == "format_greeting" and "src/lib.rs" in symbol["location"].get("relativePath", "").replace("\\", "/") + for symbol in implementing_symbols + ), f"Expected ConsoleGreeter.format_greeting symbol, got: {implementing_symbols}" + @pytest.mark.parametrize("language_server", [Language.RUST], indirect=True) def test_bare_symbol_names(self, language_server) -> None: all_symbols = request_all_symbols(language_server) diff --git a/test/solidlsp/solidity/test_solidity_basic.py b/test/solidlsp/solidity/test_solidity_basic.py index e095871f..2279140e 100644 --- a/test/solidlsp/solidity/test_solidity_basic.py +++ b/test/solidlsp/solidity/test_solidity_basic.py @@ -13,6 +13,7 @@ import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language +from test.conftest import language_has_verified_implementation_support from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols @@ -165,6 +166,33 @@ class TestSolidityLanguageServerBasics: ref_files = {ref.get("uri", "") for ref in references} assert any("Token.sol" in uri for uri in ref_files), "IERC20.transfer references should include Token.sol" + if language_has_verified_implementation_support(Language.SOLIDITY): + + @pytest.mark.parametrize("language_server", [Language.SOLIDITY], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.SOLIDITY], indirect=True) + def test_find_implementations(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + pos = _find_identifier_position(repo_path / "contracts/interfaces/IERC20.sol", "transfer") + assert pos is not None, "Should find 'transfer' identifier in IERC20.sol" + + implementations = language_server.request_implementation("contracts/interfaces/IERC20.sol", *pos) + assert implementations, "Expected Token.transfer to be returned as an implementation" + assert any("Token.sol" in implementation.get("relativePath", "") for implementation in implementations), ( + f"Expected Token.transfer implementation, got: {implementations}" + ) + + @pytest.mark.parametrize("language_server", [Language.SOLIDITY], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.SOLIDITY], indirect=True) + def test_request_implementing_symbols(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + pos = _find_identifier_position(repo_path / "contracts/interfaces/IERC20.sol", "transfer") + assert pos is not None, "Should find 'transfer' identifier in IERC20.sol" + + implementing_symbols = language_server.request_implementing_symbols("contracts/interfaces/IERC20.sol", *pos) + assert implementing_symbols, "Expected implementing symbols for IERC20.transfer" + assert any( + symbol.get("name") == "transfer" and "Token.sol" in symbol["location"].get("relativePath", "") + for symbol in implementing_symbols + ), f"Expected Token.transfer symbol, got: {implementing_symbols}" + @pytest.mark.parametrize("language_server", [Language.SOLIDITY], indirect=True) def test_bare_symbol_names(self, language_server) -> None: all_symbols = request_all_symbols(language_server) diff --git a/test/solidlsp/test_defining_symbol_matrix.py b/test/solidlsp/test_defining_symbol_matrix.py new file mode 100644 index 00000000..c0509a2f --- /dev/null +++ b/test/solidlsp/test_defining_symbol_matrix.py @@ -0,0 +1,139 @@ +import os +import shutil +from pathlib import Path + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.conftest import find_identifier_occurrence_position, is_ci +from test.solidlsp import clojure as clj + + +@pytest.mark.parametrize( + "language_server,relative_path,identifier,occurrence_index,column_offset,expected_name,expected_definition_file", + [ + pytest.param( + Language.PYTHON, + os.path.join("test_repo", "services.py"), + "User", + 1, + 1, + "User", + "models.py", + marks=pytest.mark.python, + ), + pytest.param( + Language.PYTHON_TY, + os.path.join("test_repo", "services.py"), + "User", + 1, + 1, + "User", + "models.py", + marks=pytest.mark.python, + ), + pytest.param(Language.GO, "main.go", "Helper", 0, 1, "Helper", "main.go", marks=pytest.mark.go), + pytest.param( + Language.JAVA, + os.path.join("src", "main", "java", "test_repo", "Main.java"), + "Model", + 0, + 1, + "Model", + "Model.java", + marks=pytest.mark.java, + ), + pytest.param( + Language.KOTLIN, + os.path.join("src", "main", "kotlin", "test_repo", "Main.kt"), + "Model", + 0, + 1, + "Model", + "Model.kt", + marks=[pytest.mark.kotlin] + ([pytest.mark.skip(reason="Kotlin LSP JVM crashes on restart in CI")] if is_ci else []), + ), + pytest.param( + Language.RUST, + os.path.join("src", "main.rs"), + "format_greeting", + 0, + 1, + "format_greeting", + "lib.rs", + marks=pytest.mark.rust, + ), + pytest.param(Language.PHP, "index.php", "helperFunction", 0, 5, "helperFunction", "helper.php", marks=pytest.mark.php), + pytest.param( + Language.CLOJURE, + clj.UTILS_PATH, + "multiply", + 0, + 1, + "multiply", + clj.CORE_PATH, + marks=[ + pytest.mark.clojure, + pytest.mark.skipif(not clj.is_clojure_cli_available(), reason="clojure CLI is not installed"), + ], + ), + pytest.param(Language.CSHARP, "Program.cs", "Add", 0, 1, "Add", "Program.cs", marks=pytest.mark.csharp), + pytest.param( + Language.POWERSHELL, + "main.ps1", + "Convert-ToUpperCase", + 0, + 1, + "function Convert-ToUpperCase ()", + "utils.ps1", + marks=pytest.mark.powershell, + ), + pytest.param(Language.CPP_CCLS, "a.cpp", "add", 0, 1, "add", "b.cpp", marks=pytest.mark.cpp), + pytest.param( + Language.LEAN4, + "Main.lean", + "add", + 0, + 1, + "add", + "Helper.lean", + marks=[ + pytest.mark.lean4, + pytest.mark.skipif(shutil.which("lean") is None, reason="Lean is not installed"), + pytest.mark.xfail(reason="Lean4 LS does not reliably resolve cross-file defining symbols in CI"), + ], + ), + pytest.param(Language.TYPESCRIPT, "index.ts", "helperFunction", 1, 1, "helperFunction", "index.ts", marks=pytest.mark.typescript), + pytest.param( + Language.FSHARP, + "Program.fs", + "add", + 0, + 1, + "add", + "Calculator.fs", + marks=[pytest.mark.fsharp, pytest.mark.xfail(reason="F# language server cannot reliably resolve defining symbols")], + ), + ], + indirect=["language_server"], +) +def test_request_defining_symbol_matrix( + language_server: SolidLanguageServer, + relative_path: str, + identifier: str, + occurrence_index: int, + column_offset: int, + expected_name: str, + expected_definition_file: str, +) -> None: + repo_root = Path(language_server.repository_root_path) + position = find_identifier_occurrence_position(repo_root / relative_path, identifier, occurrence_index, column_offset) + assert position is not None, f"Could not find occurrence {occurrence_index} of {identifier!r} in {relative_path}" + + defining_symbol = language_server.request_defining_symbol(relative_path, *position) + assert defining_symbol is not None, f"Expected a defining symbol for {identifier!r} in {relative_path}" + assert defining_symbol.get("name") == expected_name, f"Expected defining symbol name {expected_name!r}, got: {defining_symbol}" + assert expected_definition_file in defining_symbol["location"].get("relativePath", ""), ( + f"Expected defining symbol in {expected_definition_file!r}, got: {defining_symbol}" + ) diff --git a/test/solidlsp/test_diagnostics_matrix.py b/test/solidlsp/test_diagnostics_matrix.py new file mode 100644 index 00000000..2d1b50fe --- /dev/null +++ b/test/solidlsp/test_diagnostics_matrix.py @@ -0,0 +1,79 @@ +from pathlib import Path + +import pytest + +from serena.symbol import LanguageServerSymbolRetriever +from solidlsp import SolidLanguageServer +from test.conftest import find_identifier_position +from test.diagnostics_cases import DIAGNOSTIC_CASE_PARAMS, DiagnosticCase + + +@pytest.mark.parametrize("language_server,diagnostic_case", DIAGNOSTIC_CASE_PARAMS, indirect=["language_server"]) +def test_request_text_document_diagnostics_matrix( + language_server: SolidLanguageServer, + diagnostic_case: DiagnosticCase, +) -> None: + diagnostics = language_server.request_text_document_diagnostics(diagnostic_case.relative_path, min_severity=1) + assert diagnostics, f"Expected diagnostics for {diagnostic_case.language.value}:{diagnostic_case.relative_path}" + + diagnostic_messages = [diagnostic["message"] for diagnostic in diagnostics] + assert any(diagnostic_case.primary_message_fragment in message for message in diagnostic_messages), diagnostic_messages + assert any(diagnostic_case.reference_message_fragment in message for message in diagnostic_messages), diagnostic_messages + + repo_root = language_server.repository_root_path + primary_symbol_position = find_identifier_position( + Path(repo_root) / diagnostic_case.relative_path, diagnostic_case.primary_symbol_identifier + ) + reference_symbol_position = find_identifier_position( + Path(repo_root) / diagnostic_case.relative_path, diagnostic_case.reference_symbol_identifier + ) + assert primary_symbol_position is not None + assert reference_symbol_position is not None + + primary_diagnostics = language_server.request_text_document_diagnostics( + diagnostic_case.relative_path, + start_line=primary_symbol_position[0], + end_line=reference_symbol_position[0] - 1, + min_severity=1, + ) + primary_messages = [diagnostic["message"] for diagnostic in primary_diagnostics] + assert primary_messages, f"Expected range-filtered diagnostics for {diagnostic_case.primary_symbol_identifier}" + assert any(diagnostic_case.primary_message_fragment in message for message in primary_messages), primary_messages + assert all(diagnostic_case.reference_message_fragment not in message for message in primary_messages), primary_messages + + +@pytest.mark.parametrize("project_with_ls,diagnostic_case", DIAGNOSTIC_CASE_PARAMS, indirect=["project_with_ls"]) +def test_get_symbol_diagnostics_matrix(project_with_ls, diagnostic_case: DiagnosticCase) -> None: + symbol_retriever = LanguageServerSymbolRetriever(project_with_ls) + + diagnostics_by_symbol = symbol_retriever.get_symbol_diagnostics( + diagnostic_case.primary_symbol_name_path, + reference_file=diagnostic_case.relative_path, + min_severity=1, + ) + diagnostic_messages_by_symbol = { + symbol.get_name_path(): [diagnostic["message"] for diagnostic in diagnostics] + for symbol, diagnostics in diagnostics_by_symbol.items() + } + assert diagnostic_case.primary_symbol_name_path in diagnostic_messages_by_symbol, diagnostic_messages_by_symbol + assert any( + diagnostic_case.primary_message_fragment in message + for message in diagnostic_messages_by_symbol[diagnostic_case.primary_symbol_name_path] + ), diagnostic_messages_by_symbol + + diagnostics_with_references = symbol_retriever.get_symbol_diagnostics( + diagnostic_case.primary_symbol_name_path, + reference_file=diagnostic_case.relative_path, + check_symbol_references=True, + min_severity=1, + ) + diagnostic_messages_with_references = { + symbol.get_name_path(): [diagnostic["message"] for diagnostic in diagnostics] + for symbol, diagnostics in diagnostics_with_references.items() + } + assert diagnostic_case.primary_symbol_name_path in diagnostic_messages_with_references, diagnostic_messages_with_references + assert diagnostic_case.reference_symbol_name_path in diagnostic_messages_with_references, diagnostic_messages_with_references + assert any( + diagnostic_case.reference_message_fragment in message + for message in diagnostic_messages_with_references[diagnostic_case.reference_symbol_name_path] + ), diagnostic_messages_with_references diff --git a/test/solidlsp/test_ls_common.py b/test/solidlsp/test_ls_common.py index e5e00915..cfa1d11d 100644 --- a/test/solidlsp/test_ls_common.py +++ b/test/solidlsp/test_ls_common.py @@ -3,13 +3,13 @@ import os import pytest from solidlsp import SolidLanguageServer -from solidlsp.ls_config import Language +from test.conftest import PYTHON_LANGUAGE_BACKENDS class TestLanguageServerCommonFunctionality: """Test common functionality of SolidLanguageServer base implementation (not language-specific behaviour).""" - @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) + @pytest.mark.parametrize("language_server", PYTHON_LANGUAGE_BACKENDS, indirect=True) def test_open_file_cache_invalidate(self, language_server: SolidLanguageServer) -> None: """ Tests that the file buffer cache is invalidated when the file is changed on disk. diff --git a/test/solidlsp/typescript/test_typescript_basic.py b/test/solidlsp/typescript/test_typescript_basic.py index ba86ba04..fa9b1566 100644 --- a/test/solidlsp/typescript/test_typescript_basic.py +++ b/test/solidlsp/typescript/test_typescript_basic.py @@ -5,6 +5,7 @@ import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_utils import SymbolUtils +from test.conftest import find_identifier_position, get_repo_path, language_has_verified_implementation_support from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols @@ -33,6 +34,33 @@ class TestTypescriptLanguageServer: "index.ts should reference helperFunction (tried all positions in selectionRange)" ) + if language_has_verified_implementation_support(Language.TYPESCRIPT): + + @pytest.mark.parametrize("language_server", [Language.TYPESCRIPT], indirect=True) + def test_find_implementations(self, language_server: SolidLanguageServer) -> None: + repo_path = get_repo_path(Language.TYPESCRIPT) + pos = find_identifier_position(repo_path / "formatters.ts", "formatGreeting") + assert pos is not None, "Could not find Greeter.formatGreeting in fixture" + + implementations = language_server.request_implementation("formatters.ts", *pos) + assert implementations, "Expected at least one implementation of Greeter.formatGreeting" + assert any("formatters.ts" in implementation.get("relativePath", "") for implementation in implementations), ( + f"Expected ConsoleGreeter.formatGreeting in implementations, got: {implementations}" + ) + + @pytest.mark.parametrize("language_server", [Language.TYPESCRIPT], indirect=True) + def test_request_implementing_symbols(self, language_server: SolidLanguageServer) -> None: + repo_path = get_repo_path(Language.TYPESCRIPT) + pos = find_identifier_position(repo_path / "formatters.ts", "formatGreeting") + assert pos is not None, "Could not find Greeter.formatGreeting in fixture" + + implementing_symbols = language_server.request_implementing_symbols("formatters.ts", *pos) + assert implementing_symbols, "Expected implementing symbols for Greeter.formatGreeting" + assert any( + symbol.get("name") == "formatGreeting" and "formatters.ts" in symbol["location"].get("relativePath", "") + for symbol in implementing_symbols + ), f"Expected ConsoleGreeter.formatGreeting symbol, got: {implementing_symbols}" + @pytest.mark.parametrize("language_server", [Language.TYPESCRIPT], indirect=True) def test_bare_symbol_names(self, language_server) -> None: all_symbols = request_all_symbols(language_server) From 940bbb31def37893426b459a2da4805c3a4848a8 Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Wed, 25 Mar 2026 17:01:23 +0100 Subject: [PATCH 03/12] Restructured serena_agent tests, reifying the cases Removed some cases (haxe, msl) during cherry pick due to conflicts. We don't need to cover these languages in this test --- test/conftest.py | 2 +- test/diagnostics_cases.py | 230 --- test/serena/test_serena_agent.py | 1818 +++++++++--------- test/solidlsp/test_defining_symbol_matrix.py | 139 -- test/solidlsp/test_diagnostics_matrix.py | 79 - 5 files changed, 912 insertions(+), 1356 deletions(-) delete mode 100644 test/diagnostics_cases.py delete mode 100644 test/solidlsp/test_defining_symbol_matrix.py delete mode 100644 test/solidlsp/test_diagnostics_matrix.py diff --git a/test/conftest.py b/test/conftest.py index 5e55b180..7672bde6 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -370,7 +370,7 @@ def find_identifier_position(file_path: Path, identifier: str) -> tuple[int, int return None -def find_identifier_occurrence_position( +def find_identifier_pos( file_path: Path, identifier: str, occurrence_index: int = 0, diff --git a/test/diagnostics_cases.py b/test/diagnostics_cases.py deleted file mode 100644 index 8cfcfacb..00000000 --- a/test/diagnostics_cases.py +++ /dev/null @@ -1,230 +0,0 @@ -import os -from dataclasses import dataclass -from typing import cast - -import pytest -from _pytest.mark import Mark, MarkDecorator - -from solidlsp.ls_config import Language -from test.conftest import get_pytest_markers - - -@dataclass(frozen=True) -class DiagnosticCase: - language: Language - relative_path: str - primary_symbol_name_path: str - primary_symbol_identifier: str - reference_symbol_name_path: str - reference_symbol_identifier: str - primary_message_fragment: str - reference_message_fragment: str - - -def diagnostic_case_param( - case: DiagnosticCase, - *marks: MarkDecorator | Mark, - id: str, -): - return pytest.param(case.language, case, marks=[*get_pytest_markers(case.language), *marks], id=id) - - -DIAGNOSTIC_CASE_PARAMS = [ - diagnostic_case_param( - DiagnosticCase( - language=Language.PYTHON, - relative_path=os.path.join("test_repo", "diagnostics_sample.py"), - primary_symbol_name_path="broken_factory", - primary_symbol_identifier="broken_factory", - reference_symbol_name_path="broken_consumer", - reference_symbol_identifier="broken_consumer", - primary_message_fragment="missing_user", - reference_message_fragment="undefined_name", - ), - id="python_missing_user", - ), - diagnostic_case_param( - DiagnosticCase( - language=Language.PYTHON_TY, - relative_path=os.path.join("test_repo", "diagnostics_sample.py"), - primary_symbol_name_path="broken_factory", - primary_symbol_identifier="broken_factory", - reference_symbol_name_path="broken_consumer", - reference_symbol_identifier="broken_consumer", - primary_message_fragment="missing_user", - reference_message_fragment="undefined_name", - ), - id="python_ty_missing_user", - ), - diagnostic_case_param( - DiagnosticCase( - language=Language.GO, - relative_path="diagnostics_sample.go", - primary_symbol_name_path="brokenFactory", - primary_symbol_identifier="brokenFactory", - reference_symbol_name_path="brokenConsumer", - reference_symbol_identifier="brokenConsumer", - primary_message_fragment="missingGreeting", - reference_message_fragment="missingConsumerValue", - ), - id="go_missing_greeting", - ), - diagnostic_case_param( - DiagnosticCase( - language=Language.JAVA, - relative_path=os.path.join("src", "main", "java", "test_repo", "DiagnosticsSample.java"), - primary_symbol_name_path="DiagnosticsSample/brokenFactory", - primary_symbol_identifier="brokenFactory", - reference_symbol_name_path="DiagnosticsSample/brokenConsumer", - reference_symbol_identifier="brokenConsumer", - primary_message_fragment="missingGreeting", - reference_message_fragment="missingConsumerValue", - ), - id="java_missing_greeting", - ), - diagnostic_case_param( - DiagnosticCase( - language=Language.KOTLIN, - relative_path=os.path.join("src", "main", "kotlin", "test_repo", "DiagnosticsSample.kt"), - primary_symbol_name_path="brokenFactory", - primary_symbol_identifier="brokenFactory", - reference_symbol_name_path="brokenConsumer", - reference_symbol_identifier="brokenConsumer", - primary_message_fragment="missingGreeting", - reference_message_fragment="missingConsumerValue", - ), - id="kotlin_missing_greeting", - ), - diagnostic_case_param( - DiagnosticCase( - language=Language.RUST, - relative_path=os.path.join("src", "diagnostics_sample.rs"), - primary_symbol_name_path="broken_factory", - primary_symbol_identifier="broken_factory", - reference_symbol_name_path="broken_consumer", - reference_symbol_identifier="broken_consumer", - primary_message_fragment="missing_greeting", - reference_message_fragment="missing_consumer_value", - ), - pytest.mark.xfail(reason="rust-analyzer does not surface diagnostics for this fixture through Serena currently"), - id="rust_missing_greeting", - ), - diagnostic_case_param( - DiagnosticCase( - language=Language.PHP, - relative_path="diagnostics_sample.php", - primary_symbol_name_path="brokenFactory", - primary_symbol_identifier="brokenFactory", - reference_symbol_name_path="brokenConsumer", - reference_symbol_identifier="brokenConsumer", - primary_message_fragment="missingGreeting", - reference_message_fragment="missingConsumerValue", - ), - pytest.mark.xfail(reason="PHP LS integration does not expose document diagnostics in this environment"), - id="php_missing_greeting", - ), - diagnostic_case_param( - DiagnosticCase( - language=Language.CLOJURE, - relative_path=os.path.join("src", "test_app", "diagnostics_sample.clj"), - primary_symbol_name_path="broken-factory", - primary_symbol_identifier="broken-factory", - reference_symbol_name_path="broken-consumer", - reference_symbol_identifier="broken-consumer", - primary_message_fragment="missing-greeting", - reference_message_fragment="missing-consumer-value", - ), - id="clojure_missing_greeting", - ), - diagnostic_case_param( - DiagnosticCase( - language=Language.CSHARP, - relative_path="DiagnosticsSample.cs", - primary_symbol_name_path="TestProject/DiagnosticsSample/BrokenFactory", - primary_symbol_identifier="BrokenFactory", - reference_symbol_name_path="TestProject/DiagnosticsSample/BrokenConsumer", - reference_symbol_identifier="BrokenConsumer", - primary_message_fragment="missingGreeting", - reference_message_fragment="missingConsumerValue", - ), - id="csharp_missing_greeting", - ), - diagnostic_case_param( - DiagnosticCase( - language=Language.POWERSHELL, - relative_path="diagnostics_sample.ps1", - primary_symbol_name_path="function Invoke-BrokenFactory ()", - primary_symbol_identifier="Invoke-BrokenFactory", - reference_symbol_name_path="function Invoke-BrokenConsumer ()", - reference_symbol_identifier="Invoke-BrokenConsumer", - primary_message_fragment="MissingGreeting", - reference_message_fragment="MissingConsumerValue", - ), - pytest.mark.xfail(reason="PowerShell LS does not surface document diagnostics in this environment"), - id="powershell_missing_greeting", - ), - diagnostic_case_param( - DiagnosticCase( - language=Language.CPP_CCLS, - relative_path="diagnostics_sample.cpp", - primary_symbol_name_path="brokenFactory", - primary_symbol_identifier="brokenFactory", - reference_symbol_name_path="brokenConsumer", - reference_symbol_identifier="brokenConsumer", - primary_message_fragment="missingGreeting", - reference_message_fragment="missingConsumerValue", - ), - pytest.mark.xfail(reason="ccls does not expose document diagnostics through this integration"), - id="cpp_missing_greeting", - ), - diagnostic_case_param( - DiagnosticCase( - language=Language.LEAN4, - relative_path="DiagnosticsSample.lean", - primary_symbol_name_path="brokenFactory", - primary_symbol_identifier="brokenFactory", - reference_symbol_name_path="brokenConsumer", - reference_symbol_identifier="brokenConsumer", - primary_message_fragment="missingGreeting", - reference_message_fragment="missingConsumerValue", - ), - pytest.mark.xfail(reason="Lean4 LS does not reliably surface diagnostics in CI"), - id="lean_missing_greeting", - ), - diagnostic_case_param( - DiagnosticCase( - language=Language.TYPESCRIPT, - relative_path="diagnostics_sample.ts", - primary_symbol_name_path="brokenFactory", - primary_symbol_identifier="brokenFactory", - reference_symbol_name_path="brokenConsumer", - reference_symbol_identifier="brokenConsumer", - primary_message_fragment="missingGreeting", - reference_message_fragment="missingConsumerValue", - ), - pytest.mark.typescript, - pytest.mark.xfail(reason="TypeScript LS does not surface document diagnostics through this integration"), - id="typescript_missing_greeting", - ), - diagnostic_case_param( - DiagnosticCase( - language=Language.FSHARP, - relative_path="DiagnosticsSample.fs", - primary_symbol_name_path="brokenFactory", - primary_symbol_identifier="brokenFactory", - reference_symbol_name_path="brokenConsumer", - reference_symbol_identifier="brokenConsumer", - primary_message_fragment="missingGreeting", - reference_message_fragment="missingConsumerValue", - ), - pytest.mark.xfail(reason="F# LS does not expose document diagnostics through this integration"), - id="fsharp_missing_greeting", - ), -] - -WORKING_DIAGNOSTIC_TOOL_CASE_PARAMS = [ - case_param - for case_param in DIAGNOSTIC_CASE_PARAMS - if cast(DiagnosticCase, case_param.values[1]).language - in {Language.PYTHON, Language.PYTHON_TY, Language.GO, Language.JAVA, Language.KOTLIN, Language.CSHARP} -] diff --git a/test/serena/test_serena_agent.py b/test/serena/test_serena_agent.py index 62dc1a53..a577b849 100644 --- a/test/serena/test_serena_agent.py +++ b/test/serena/test_serena_agent.py @@ -2,15 +2,15 @@ import json import logging import os import re -import shutil import time from collections.abc import Iterator from contextlib import contextmanager -from typing import Literal +from copy import copy +from dataclasses import dataclass +from typing import Literal, cast import pytest -from test.diagnostics_cases import WORKING_DIAGNOSTIC_TOOL_CASE_PARAMS, DiagnosticCase -from _pytest.mark import Mark, MarkDecorator +from _pytest.mark import Mark, MarkDecorator, ParameterSet from serena.agent import SerenaAgent from serena.config.serena_config import ProjectConfig, RegisteredProject, SerenaConfig @@ -24,7 +24,6 @@ from serena.tools import ( FindReferencingSymbolsTool, FindSymbolTool, GetDiagnosticsForFileTool, - GetDiagnosticsForSymbolTool, InitialInstructionsTool, ReplaceContentTool, ReplaceSymbolBodyTool, @@ -34,205 +33,726 @@ from serena.tools import ( from solidlsp.ls_config import Language from solidlsp.ls_types import SymbolKind from test.conftest import ( - find_identifier_occurrence_position, + find_identifier_pos, + get_pytest_markers, get_repo_path, is_ci, - language_has_verified_implementation_support, language_tests_enabled, ) -from test.diagnostics_cases import WORKING_DIAGNOSTIC_TOOL_CASE_PARAMS, DiagnosticCase -from test.solidlsp import clojure as clj -DEFINING_SYMBOL_TOOL_TEST_CASES = [ - pytest.param( - Language.PYTHON, - os.path.join("test_repo", "services.py"), - "User", - 1, - 1, - "User", - "models.py", - marks=pytest.mark.python, - ), - pytest.param( - Language.PYTHON_TY, - os.path.join("test_repo", "services.py"), - "User", - 1, - 1, - "User", - "models.py", - marks=pytest.mark.python, - ), - pytest.param(Language.GO, "main.go", "Helper", 0, 1, "Helper", "main.go", marks=pytest.mark.go), - pytest.param( - Language.JAVA, - os.path.join("src", "main", "java", "test_repo", "Main.java"), - "Model", - 0, - 1, - "Model", - "Model.java", - marks=pytest.mark.java, - ), - pytest.param( - Language.KOTLIN, - os.path.join("src", "main", "kotlin", "test_repo", "Main.kt"), - "Model", - 0, - 1, - "Model", - "Model.kt", - marks=[pytest.mark.kotlin] + ([pytest.mark.skip(reason="Kotlin LSP JVM crashes on restart in CI")] if is_ci else []), - ), - pytest.param( - Language.RUST, - os.path.join("src", "main.rs"), - "format_greeting", - 0, - 1, - "format_greeting", - "lib.rs", - marks=pytest.mark.rust, - ), - pytest.param(Language.PHP, "index.php", "helperFunction", 0, 5, "helperFunction", "helper.php", marks=pytest.mark.php), - pytest.param( - Language.CLOJURE, - clj.UTILS_PATH, - "multiply", - 0, - 1, - "multiply", - clj.CORE_PATH, - marks=[ - pytest.mark.clojure, - pytest.mark.skipif(not clj.is_clojure_cli_available(), reason="clojure CLI is not installed"), - ], - ), - pytest.param(Language.CSHARP, "Program.cs", "Add", 0, 1, "Add", "Program.cs", marks=pytest.mark.csharp), - pytest.param( - Language.POWERSHELL, - "main.ps1", - "Convert-ToUpperCase", - 0, - 1, - "function Convert-ToUpperCase ()", - "utils.ps1", - marks=pytest.mark.powershell, - ), - pytest.param(Language.CPP_CCLS, "a.cpp", "add", 0, 1, "add", "b.cpp", marks=pytest.mark.cpp), - pytest.param( - Language.LEAN4, - "Main.lean", - "add", - 0, - 1, - "add", - "Helper.lean", - marks=[ - pytest.mark.lean4, - pytest.mark.skipif(shutil.which("lean") is None, reason="Lean is not installed"), - ], - ), - pytest.param(Language.TYPESCRIPT, "index.ts", "helperFunction", 1, 1, "helperFunction", "index.ts", marks=pytest.mark.typescript), - pytest.param( - Language.FSHARP, - "Program.fs", - "add", - 0, - 1, - "add", - "Calculator.fs", - marks=[pytest.mark.fsharp, pytest.mark.xfail(reason="F# language server cannot reliably resolve defining symbols")], + +@dataclass +class BaseCase: + language: Language + id: str + + def to_pytest_param(self, *marks: MarkDecorator | Mark) -> ParameterSet: + return pytest.param(self.language, self, marks=[*get_pytest_markers(self.language), *marks], id=self.id) # type: ignore + + +@dataclass +class FindSymbolCase(BaseCase): + symbol_name: str + expected_kind: str + expected_file: str + + +@dataclass +class FindReferenceCase(BaseCase): + symbol_name: str + definition_file: str + reference_file: str + + +@dataclass +class FindDefiningSymbolCase(BaseCase): + relative_path: str + identifier: str + occurrence_index: int + column_offset: int + expected_name: str + expected_definition_file: str + + +@dataclass +class RegexDefiningSymbolCase(BaseCase): + relative_path: str + regex: str + containing_symbol_name_path: str + expected_name: str + expected_definition_file: str + + +@dataclass +class RegexDefiningSymbolErrorCase(BaseCase): + relative_path: str + regex: str + containing_symbol_name_path: str + error_fragment: str + + +@dataclass +class FindImplementationCase(BaseCase): + symbol_name: str + definition_file: str + implementation_file: str + expected_symbol_name: str + + +@dataclass +class FindSymbolNamePathCase(BaseCase): + name_path: str + substring_matching: bool + expected_symbol_name: str + expected_kind: str + expected_file: str + + +@dataclass +class FindSymbolNoMatchCase(BaseCase): + name_path: str + + +@dataclass +class FindSymbolOverloadedCase(BaseCase): + name_path: str + num_expected: int + + +@dataclass +class NonUniqueSymbolReferenceCase(BaseCase): + name_path: str + relative_path: str + expected_error_fragment: str = "multiple" + + +@dataclass +class SafeDeleteCase(BaseCase): + name_path: str + relative_path: str + + def __post_init__(self) -> None: + object.__setattr__(self, "relative_path", self.relative_path.replace("\\", "/")) + + +@dataclass +class DiagnosticCase(BaseCase): + language: Language + relative_path: str + name_path1: str + name_path2: str | None + message_fragment1: str + message_fragment2: str | None + + @property + def symbol1_id_str(self) -> str: + return self.name_path1.split("/")[-1] + + @property + def symbol2_id_str(self) -> str | None: + if self.name_path2 is None: + return None + return self.name_path2.split("/")[-1] + + def without_second_symbol(self) -> "DiagnosticCase": + result = copy(self) + result.name_path2 = None + result.message_fragment2 = None + return result + + def assert_matches(self, tool_output: dict) -> None: + """ + + :param tool_output: Output of diagnostics tool, representing the mapping `relative_path -> severity -> name_path -> diagnostics_results`. + :return: + """ + assert self.relative_path in tool_output, ( + f"Missing diagnostics for relative path {self.relative_path} in tool output keys: {list(tool_output.keys())}" + ) + severity_group = tool_output[self.relative_path] + assert "Error" in severity_group, severity_group + name_path_group = severity_group["Error"] + + for expected_name_path in [self.name_path1, self.name_path2]: + if expected_name_path is not None: + assert expected_name_path in name_path_group, name_path_group + + diagnostic_messages = [ + diagnostic["message"] for diagnostics_for_name_path in name_path_group.values() for diagnostic in diagnostics_for_name_path + ] + for expected_fragment in [self.message_fragment1, self.message_fragment2]: + if expected_fragment is not None: + assert any(expected_fragment in message for message in diagnostic_messages), diagnostic_messages + + +DIAGNOSTIC_CASES = [ + DiagnosticCase( + language=Language.PYTHON, + id=f"{Language.PYTHON.value}_missing_user", + relative_path=os.path.join("test_repo", "diagnostics_sample.py"), + name_path1="broken_factory", + name_path2="broken_consumer", + message_fragment1="missing_user", + message_fragment2="undefined_name", + ).to_pytest_param(), + DiagnosticCase( + language=Language.CLOJURE, + id=f"{Language.CLOJURE.value}_missing-greeting", + relative_path=os.path.join("src", "test_app", "diagnostics_sample.clj"), + name_path1="broken-factory", + name_path2="broken-consumer", + message_fragment1="missing-greeting", + message_fragment2="missing-consumer-value", + ).to_pytest_param(), + DiagnosticCase( + language=Language.GO, + id=f"{Language.GO.value}_missingGreeting", + relative_path="diagnostics_sample.go", + name_path1="brokenFactory", + name_path2="brokenConsumer", + message_fragment1="missingGreeting", + message_fragment2="missingConsumerValue", + ).to_pytest_param(), + DiagnosticCase( + language=Language.TYPESCRIPT, + id=f"{Language.TYPESCRIPT.value}_missingGreeting", + relative_path="diagnostics_sample.ts", + name_path1="brokenFactory", + name_path2="brokenConsumer", + message_fragment1="missingGreeting", + message_fragment2="missingConsumerValue", + ).to_pytest_param(), +] + + +FIND_DEFINING_SYMBOL_CASES = [ + FindDefiningSymbolCase( + language=Language.PYTHON, + id="python_user_in_services", + relative_path=os.path.join("test_repo", "services.py"), + identifier="User", + occurrence_index=1, + column_offset=1, + expected_name="User", + expected_definition_file="models.py", + ).to_pytest_param(), + FindDefiningSymbolCase( + language=Language.PYTHON_TY, + id="python_ty_user_in_services", + relative_path=os.path.join("test_repo", "services.py"), + identifier="User", + occurrence_index=1, + column_offset=1, + expected_name="User", + expected_definition_file="models.py", + ).to_pytest_param(), + FindDefiningSymbolCase( + language=Language.GO, + id="go_helper_in_main", + relative_path="main.go", + identifier="Helper", + occurrence_index=0, + column_offset=1, + expected_name="Helper", + expected_definition_file="main.go", + ).to_pytest_param(), + FindDefiningSymbolCase( + language=Language.JAVA, + id="java_model_in_main", + relative_path=os.path.join("src", "main", "java", "test_repo", "Main.java"), + identifier="Model", + occurrence_index=0, + column_offset=1, + expected_name="Model", + expected_definition_file="Model.java", + ).to_pytest_param(), + FindDefiningSymbolCase( + language=Language.KOTLIN, + id="kotlin_model_in_main", + relative_path=os.path.join("src", "main", "kotlin", "test_repo", "Main.kt"), + identifier="Model", + occurrence_index=0, + column_offset=1, + expected_name="Model", + expected_definition_file="Model.kt", + ).to_pytest_param(), + FindDefiningSymbolCase( + language=Language.RUST, + id="rust_format_greeting", + relative_path=os.path.join("src", "main.rs"), + identifier="format_greeting", + occurrence_index=0, + column_offset=1, + expected_name="format_greeting", + expected_definition_file="lib.rs", + ).to_pytest_param(), + FindDefiningSymbolCase( + language=Language.PHP, + id="php_helper_function", + relative_path="index.php", + identifier="helperFunction", + occurrence_index=0, + column_offset=5, + expected_name="helperFunction", + expected_definition_file="helper.php", + ).to_pytest_param(), + FindDefiningSymbolCase( + language=Language.CLOJURE, + id="clojure_multiply_in_utils", + relative_path=os.path.join("src", "test_app", "utils.clj"), + identifier="multiply", + occurrence_index=0, + column_offset=1, + expected_name="multiply", + expected_definition_file=os.path.join("src", "test_app", "core.clj"), + ).to_pytest_param(), + FindDefiningSymbolCase( + language=Language.CSHARP, + id="csharp_add_in_program", + relative_path="Program.cs", + identifier="Add", + occurrence_index=0, + column_offset=1, + expected_name="Add", + expected_definition_file="Program.cs", + ).to_pytest_param(), + FindDefiningSymbolCase( + language=Language.POWERSHELL, + id="powershell_convert_to_uppercase", + relative_path="main.ps1", + identifier="Convert-ToUpperCase", + occurrence_index=0, + column_offset=1, + expected_name="function Convert-ToUpperCase ()", + expected_definition_file="utils.ps1", + ).to_pytest_param(), + FindDefiningSymbolCase( + language=Language.CPP_CCLS, + id="cpp_add_in_a", + relative_path="a.cpp", + identifier="add", + occurrence_index=0, + column_offset=1, + expected_name="add", + expected_definition_file="b.cpp", + ).to_pytest_param(), + FindDefiningSymbolCase( + language=Language.LEAN4, + id="lean_add_in_main", + relative_path="Main.lean", + identifier="add", + occurrence_index=1, + column_offset=1, + expected_name="add", + expected_definition_file="Helper.lean", + ).to_pytest_param(), + FindDefiningSymbolCase( + language=Language.TYPESCRIPT, + id="typescript_helper_function", + relative_path="index.ts", + identifier="helperFunction", + occurrence_index=1, + column_offset=1, + expected_name="helperFunction", + expected_definition_file="index.ts", + ).to_pytest_param(), + FindDefiningSymbolCase( + language=Language.FSHARP, + id="fsharp_add_in_program", + relative_path="Program.fs", + identifier="add", + occurrence_index=0, + column_offset=1, + expected_name="add", + expected_definition_file="Calculator.fs", + ).to_pytest_param( + pytest.mark.xfail(reason="F# language server cannot reliably resolve defining symbols"), ), ] -REGEX_DEFINING_SYMBOL_TOOL_TEST_CASES = [ - pytest.param( - Language.PYTHON, - os.path.join("test_repo", "services.py"), - r"from \.models import Item, (User)", - "", - "User", - "models.py", - marks=pytest.mark.python, - ), - pytest.param( - Language.PYTHON, - os.path.join("test_repo", "services.py"), - r"=\s+(User)\(", - "UserService/create_user", - "User", - "models.py", - marks=pytest.mark.python, - ), - pytest.param( - Language.PYTHON_TY, - os.path.join("test_repo", "services.py"), - r"=\s+(User)\(", - "UserService/create_user", - "User", - "models.py", - marks=pytest.mark.python, - ), - pytest.param( - Language.GO, - "main.go", - r"var greeter (Greeter) =", - "main", - "Greeter", - "main.go", - marks=pytest.mark.go, +FIND_DEFINING_SYMBOL_REGEX_CASES = [ + RegexDefiningSymbolCase( + language=Language.PYTHON, + id="python_import_user", + relative_path=os.path.join("test_repo", "services.py"), + regex=r"from \.models import Item, (User)", + containing_symbol_name_path="", + expected_name="User", + expected_definition_file="models.py", + ).to_pytest_param(), + RegexDefiningSymbolCase( + language=Language.PYTHON, + id="python_create_user_call", + relative_path=os.path.join("test_repo", "services.py"), + regex=r"=\s+(User)\(", + containing_symbol_name_path="UserService/create_user", + expected_name="User", + expected_definition_file="models.py", + ).to_pytest_param(), + RegexDefiningSymbolCase( + language=Language.PYTHON_TY, + id="python_ty_create_user_call", + relative_path=os.path.join("test_repo", "services.py"), + regex=r"=\s+(User)\(", + containing_symbol_name_path="UserService/create_user", + expected_name="User", + expected_definition_file="models.py", + ).to_pytest_param(), + RegexDefiningSymbolCase( + language=Language.GO, + id="go_greeter_var", + relative_path="main.go", + regex=r"var greeter (Greeter) =", + containing_symbol_name_path="main", + expected_name="Greeter", + expected_definition_file="main.go", + ).to_pytest_param(), +] + +FIND_IMPLEMENTATION_CASES = [ + FindImplementationCase( + language=Language.CSHARP, + id="csharp_greeter_format", + symbol_name="IGreeter/FormatGreeting", + definition_file=os.path.join("Services", "IGreeter.cs"), + implementation_file=os.path.join("Services", "ConsoleGreeter.cs"), + expected_symbol_name="FormatGreeting", + ).to_pytest_param(), + FindImplementationCase( + language=Language.GO, + id="go_greeter_format", + symbol_name="Greeter/FormatGreeting", + definition_file="main.go", + implementation_file="main.go", + expected_symbol_name="FormatGreeting", + ).to_pytest_param(), + FindImplementationCase( + language=Language.JAVA, + id="java_greeter_format", + symbol_name="Greeter/formatGreeting", + definition_file=os.path.join("src", "main", "java", "test_repo", "Greeter.java"), + implementation_file=os.path.join("src", "main", "java", "test_repo", "ConsoleGreeter.java"), + expected_symbol_name="formatGreeting", + ).to_pytest_param(), + FindImplementationCase( + language=Language.RUST, + id="rust_greeter_format", + symbol_name="Greeter/format_greeting", + definition_file=os.path.join("src", "lib.rs"), + implementation_file=os.path.join("src", "lib.rs"), + expected_symbol_name="format_greeting", + ).to_pytest_param(), + FindImplementationCase( + language=Language.TYPESCRIPT, + id="typescript_greeter_format", + symbol_name="Greeter/formatGreeting", + definition_file="formatters.ts", + implementation_file="formatters.ts", + expected_symbol_name="formatGreeting", + ).to_pytest_param(), +] + + +FIND_SYMBOL_REFERENCES_CASES = [ + FindSymbolCase( + language=Language.PYTHON, id="python_user_class", symbol_name="User", expected_kind="Class", expected_file="models.py" + ).to_pytest_param(), + FindSymbolCase( + language=Language.GO, id="go_helper_function", symbol_name="Helper", expected_kind="Function", expected_file="main.go" + ).to_pytest_param(), + FindSymbolCase( + language=Language.JAVA, id="java_model_class", symbol_name="Model", expected_kind="Class", expected_file="Model.java" + ).to_pytest_param(), + FindSymbolCase( + language=Language.KOTLIN, id="kotlin_model_struct", symbol_name="Model", expected_kind="Struct", expected_file="Model.kt" + ).to_pytest_param(), + FindSymbolCase( + language=Language.TYPESCRIPT, + id="typescript_demo_class", + symbol_name="DemoClass", + expected_kind="Class", + expected_file="index.ts", + ).to_pytest_param(), + FindSymbolCase( + language=Language.PHP, + id="php_helper_function", + symbol_name="helperFunction", + expected_kind="Function", + expected_file="helper.php", + ).to_pytest_param(), + FindSymbolCase( + language=Language.CLOJURE, + id="clojure_greet_function", + symbol_name="greet", + expected_kind="Function", + expected_file=os.path.join("src", "test_app", "core.clj"), + ).to_pytest_param(), + FindSymbolCase( + language=Language.CSHARP, + id="csharp_calculator_class", + symbol_name="Calculator", + expected_kind="Class", + expected_file="Program.cs", + ).to_pytest_param(), + FindSymbolCase( + language=Language.POWERSHELL, + id="powershell_greet_user", + symbol_name="Greet-User", + expected_kind="Function", + expected_file="main.ps1", + ).to_pytest_param(), + FindSymbolCase( + language=Language.CPP_CCLS, id="cpp_add_function", symbol_name="add", expected_kind="Function", expected_file="b.cpp" + ).to_pytest_param(), + FindSymbolCase( + language=Language.LEAN4, id="lean_add_method", symbol_name="add", expected_kind="Method", expected_file="Helper.lean" + ).to_pytest_param(), + FindSymbolCase( + language=Language.FSHARP, + id="fsharp_calculator_module", + symbol_name="Calculator", + expected_kind="Module", + expected_file="Calculator.fs", + ).to_pytest_param(), + FindSymbolCase( + language=Language.RUST, id="rust_add_function", symbol_name="add", expected_kind="Function", expected_file="lib.rs" + ).to_pytest_param(), +] + +FIND_REFERENCE_CASES = [ + FindReferenceCase( + language=Language.PYTHON, + id="python_user_refs", + symbol_name="User", + definition_file=os.path.join("test_repo", "models.py"), + reference_file=os.path.join("test_repo", "services.py"), + ).to_pytest_param(), + FindReferenceCase( + language=Language.GO, id="go_helper_refs", symbol_name="Helper", definition_file="main.go", reference_file="main.go" + ).to_pytest_param(), + FindReferenceCase( + language=Language.JAVA, + id="java_model_refs", + symbol_name="Model", + definition_file=os.path.join("src", "main", "java", "test_repo", "Model.java"), + reference_file=os.path.join("src", "main", "java", "test_repo", "Main.java"), + ).to_pytest_param(), + FindReferenceCase( + language=Language.KOTLIN, + id="kotlin_model_refs", + symbol_name="Model", + definition_file=os.path.join("src", "main", "kotlin", "test_repo", "Model.kt"), + reference_file=os.path.join("src", "main", "kotlin", "test_repo", "Main.kt"), + ).to_pytest_param(), + FindReferenceCase( + language=Language.RUST, + id="rust_add_refs", + symbol_name="add", + definition_file=os.path.join("src", "lib.rs"), + reference_file=os.path.join("src", "main.rs"), + ).to_pytest_param(), + FindReferenceCase( + language=Language.PHP, + id="php_helper_refs", + symbol_name="helperFunction", + definition_file="helper.php", + reference_file="index.php", + ).to_pytest_param(), + FindReferenceCase( + language=Language.CLOJURE, + id="clojure_multiply_refs", + symbol_name="multiply", + definition_file=os.path.join("src", "test_app", "core.clj"), + reference_file=os.path.join("src", "test_app", "utils.clj"), + ).to_pytest_param(), + FindReferenceCase( + language=Language.CSHARP, + id="csharp_calculator_refs", + symbol_name="Calculator", + definition_file="Program.cs", + reference_file="Program.cs", + ).to_pytest_param(), + FindReferenceCase( + language=Language.POWERSHELL, + id="powershell_greet_user_refs", + symbol_name="Greet-User", + definition_file="main.ps1", + reference_file="main.ps1", + ).to_pytest_param(), + FindReferenceCase( + language=Language.CPP_CCLS, id="cpp_add_refs", symbol_name="add", definition_file="b.cpp", reference_file="a.cpp" + ).to_pytest_param(), + FindReferenceCase( + language=Language.LEAN4, id="lean_add_refs", symbol_name="add", definition_file="Helper.lean", reference_file="Main.lean" + ).to_pytest_param(), + FindReferenceCase( + language=Language.TYPESCRIPT, + id="typescript_helper_refs", + symbol_name="helperFunction", + definition_file="index.ts", + reference_file="use_helper.ts", + ).to_pytest_param(pytest.mark.xfail(False, reason="TypeScript language server is unreliable")), + FindReferenceCase( + language=Language.FSHARP, id="fsharp_add_refs", symbol_name="add", definition_file="Calculator.fs", reference_file="Program.fs" + ).to_pytest_param( + pytest.mark.xfail(reason="F# language server is unreliable"), # See issue #1040 ), ] -IMPLEMENTATION_TOOL_TEST_CASE_DATA = [ - ( - Language.CSHARP, - "IGreeter/FormatGreeting", - os.path.join("Services", "IGreeter.cs"), - os.path.join("Services", "ConsoleGreeter.cs"), - "FormatGreeting", - pytest.mark.csharp, - ), - ( - Language.GO, - "Greeter/FormatGreeting", - "main.go", - "main.go", - "(ConsoleGreeter).FormatGreeting", - pytest.mark.go, - ), - ( - Language.JAVA, - "Greeter/formatGreeting", - os.path.join("src", "main", "java", "test_repo", "Greeter.java"), - os.path.join("src", "main", "java", "test_repo", "ConsoleGreeter.java"), - "formatGreeting", - pytest.mark.java, - ), - ( - Language.RUST, - "Greeter/format_greeting", - os.path.join("src", "lib.rs"), - os.path.join("src", "lib.rs"), - "format_greeting", - pytest.mark.rust, - ), - ( - Language.TYPESCRIPT, - "Greeter/formatGreeting", - "formatters.ts", - "formatters.ts", - "formatGreeting", - pytest.mark.typescript, - ), +FIND_DEFINING_SYMBOL_REGEX_ERROR_CASES = [ + RegexDefiningSymbolErrorCase( + language=Language.PYTHON, + id="python_regex_multiple_matches", + relative_path=os.path.join("test_repo", "services.py"), + regex=r"(User)", + containing_symbol_name_path="", + error_fragment="Expected exactly one regex match", + ).to_pytest_param(), + RegexDefiningSymbolErrorCase( + language=Language.PYTHON, + id="python_regex_missing_group", + relative_path=os.path.join("test_repo", "services.py"), + regex=r"User", + containing_symbol_name_path="UserService/create_user", + error_fragment="must contain exactly one capturing group", + ).to_pytest_param(), ] -IMPLEMENTATION_TOOL_TEST_CASES = [ - pytest.param(language, symbol_name, def_file, impl_file, expected_symbol_name, marks=mark) - for language, symbol_name, def_file, impl_file, expected_symbol_name, mark in IMPLEMENTATION_TOOL_TEST_CASE_DATA - if language_has_verified_implementation_support(language) +FIND_SYMBOL_NAME_PATH_CASES = [ + FindSymbolNamePathCase( + language=Language.PYTHON, + id="nested_class_exact", + name_path="OuterClass/NestedClass", + substring_matching=False, + expected_symbol_name="NestedClass", + expected_kind="Class", + expected_file=os.path.join("test_repo", "nested.py"), + ).to_pytest_param(), + FindSymbolNamePathCase( + language=Language.PYTHON, + id="nested_method_exact", + name_path="OuterClass/NestedClass/find_me", + substring_matching=False, + expected_symbol_name="find_me", + expected_kind="Method", + expected_file=os.path.join("test_repo", "nested.py"), + ).to_pytest_param(), + FindSymbolNamePathCase( + language=Language.PYTHON, + id="nested_class_substring", + name_path="OuterClass/NestedCl", + substring_matching=True, + expected_symbol_name="NestedClass", + expected_kind="Class", + expected_file=os.path.join("test_repo", "nested.py"), + ).to_pytest_param(), + FindSymbolNamePathCase( + language=Language.PYTHON, + id="nested_method_substring", + name_path="OuterClass/NestedClass/find_m", + substring_matching=True, + expected_symbol_name="find_me", + expected_kind="Method", + expected_file=os.path.join("test_repo", "nested.py"), + ).to_pytest_param(), + FindSymbolNamePathCase( + language=Language.PYTHON, + id="outer_class_absolute", + name_path="/OuterClass", + substring_matching=False, + expected_symbol_name="OuterClass", + expected_kind="Class", + expected_file=os.path.join("test_repo", "nested.py"), + ).to_pytest_param(), + FindSymbolNamePathCase( + language=Language.PYTHON, + id="nested_method_absolute_substring", + name_path="/OuterClass/NestedClass/find_m", + substring_matching=True, + expected_symbol_name="find_me", + expected_kind="Method", + expected_file=os.path.join("test_repo", "nested.py"), + ).to_pytest_param(), +] + +FIND_SYMBOL_NAME_PATH_NO_MATCH_CASES = [ + FindSymbolNoMatchCase(language=Language.PYTHON, id="nested_class_not_top_level", name_path="/NestedClass").to_pytest_param(), + FindSymbolNoMatchCase( + language=Language.PYTHON, id="nested_class_missing_parent", name_path="/NoSuchParent/NestedClass" + ).to_pytest_param(), +] + +FIND_SYMBOL_OVERLOADED_FUNCTION_CASES = [ + FindSymbolOverloadedCase( + language=Language.JAVA, id="java_overloaded_get_name", name_path="Model/getName", num_expected=2 + ).to_pytest_param(), +] + +NON_UNIQUE_SYMBOL_REFERENCE_ERROR_CASES = [ + NonUniqueSymbolReferenceCase( + language=Language.JAVA, + id="java_overloaded_get_name", + name_path="Model/getName", + relative_path=os.path.join("src", "main", "java", "test_repo", "Model.java"), + ).to_pytest_param(), +] + +SAFE_DELETE_BLOCKED_CASES = [ + SafeDeleteCase( + language=Language.PYTHON, + id="python_user", + name_path="User", + relative_path=os.path.join("test_repo", "models.py"), + ).to_pytest_param(), + SafeDeleteCase( + language=Language.JAVA, + id="java_model", + name_path="Model", + relative_path=os.path.join("src", "main", "java", "test_repo", "Model.java"), + ).to_pytest_param(), + SafeDeleteCase( + language=Language.KOTLIN, + id="kotlin_model", + name_path="Model", + relative_path=os.path.join("src", "main", "kotlin", "test_repo", "Model.kt"), + ).to_pytest_param( + *([pytest.mark.skip(reason="Kotlin LSP JVM crashes on restart in CI")] if is_ci else []), + ), + SafeDeleteCase( + language=Language.TYPESCRIPT, + id="typescript_helper_function", + name_path="helperFunction", + relative_path="index.ts", + ).to_pytest_param(), +] + +SAFE_DELETE_SUCCEEDS_CASES = [ + SafeDeleteCase( + language=Language.PYTHON, + id="python_timer", + name_path="Timer", + relative_path=os.path.join("test_repo", "utils.py"), + ).to_pytest_param(), + SafeDeleteCase( + language=Language.JAVA, + id="java_model_user", + name_path="ModelUser", + relative_path=os.path.join("src", "main", "java", "test_repo", "ModelUser.java"), + ).to_pytest_param(), + SafeDeleteCase( + language=Language.KOTLIN, + id="kotlin_model_user", + name_path="ModelUser", + relative_path=os.path.join("src", "main", "kotlin", "test_repo", "ModelUser.kt"), + ).to_pytest_param( + *([pytest.mark.skip(reason="Kotlin LSP JVM crashes on restart in CI")] if is_ci else []), + ), + SafeDeleteCase( + language=Language.TYPESCRIPT, + id="typescript_unused_standalone_function", + name_path="unusedStandaloneFunction", + relative_path="index.ts", + ).to_pytest_param(), ] @@ -334,7 +854,11 @@ def serena_agent(request: pytest.FixtureRequest, serena_config) -> Iterator[Sere class TestSerenaAgent: - @pytest.mark.parametrize("project", [None, str(get_repo_path(Language.PYTHON)), "non_existent_path"]) + @pytest.mark.parametrize( + "project", + [None, str(get_repo_path(Language.PYTHON)), "non_existent_path"], + ids=["no_project", "python_project_path", "invalid_project_path"], + ) def test_agent_instantiation(self, project: str | None): """ Tests agent instantiation for cases where @@ -346,113 +870,62 @@ class TestSerenaAgent: serena_config = SerenaConfig(gui_log_window=False, web_dashboard=False) SerenaAgent(project=project, serena_config=serena_config) - def _assert_find_symbol(self, serena_agent: SerenaAgent, symbol_name: str, expected_kind: str, expected_file: str) -> None: - agent = serena_agent - find_symbol_tool = agent.get_tool(FindSymbolTool) - result = find_symbol_tool.apply(name_path_pattern=symbol_name, include_info=True) + def _symbol_matches_expected_name(self, symbol: dict, expected_name: str) -> bool: + return ( + symbol.get("name") == expected_name + or symbol.get("name_path", "").split("/")[-1] == expected_name + or expected_name in symbol.get("info", "") + ) - symbols = json.loads(result) - assert any( - symbol_name in s["name_path"] and expected_kind.lower() in s["kind"].lower() and expected_file in s["relative_path"] - for s in symbols - ), f"Expected to find {symbol_name} ({expected_kind}) in {expected_file}" - # testing retrieval of symbol info + def _assert_symbol_info_present( + self, + serena_agent: SerenaAgent, + symbol: dict, + expected_name: str | None = None, + ) -> None: if serena_agent.get_active_lsp_languages() == [Language.KOTLIN]: # kotlin LS doesn't seem to provide hover info right now, at least for the struct we test this on return - for s in symbols: - if s["kind"] in (SymbolKind.File.name, SymbolKind.Module.name): - # we ignore file and module symbols for the info test - continue - symbol_info = s.get("info") - assert symbol_info, f"Expected symbol info to be present for symbol: {s}" - assert symbol_name in s["info"], ( - f"[{serena_agent.get_active_lsp_languages()[0]}] Expected symbol info to contain symbol name {symbol_name}. Info: {s['info']}" + + if symbol["kind"] in (SymbolKind.File.name, SymbolKind.Module.name): + # we ignore file and module symbols for the info test + return + + symbol_info = symbol.get("info") + assert symbol_info, f"Expected symbol info to be present for symbol: {symbol}" + + if expected_name is not None: + assert expected_name in symbol_info, ( + f"[{serena_agent.get_active_lsp_languages()[0]}] Expected symbol info to contain symbol name " + f"{expected_name}. Info: {symbol_info}" ) - # special additional test for Java, since Eclipse returns hover in a complex format and we want to make sure to get it right - if s["kind"] == SymbolKind.Class.name and serena_agent.get_active_lsp_languages() == [Language.JAVA]: - assert "A simple model class" in symbol_info, f"Java class docstring not found in symbol info: {s}" - @pytest.mark.php - @pytest.mark.parametrize("serena_agent", [Language.PHP], indirect=True) - def test_find_symbol_within_php_file(self, serena_agent: SerenaAgent) -> None: - """Verify find_symbol with a PHP file path routes to the PHP language server. + # special additional test for Java, since Eclipse returns hover in a complex format and we want to make sure to get it right + if symbol["kind"] == SymbolKind.Class.name and serena_agent.get_active_lsp_languages() == [Language.JAVA]: + assert "A simple model class" in symbol_info, f"Java class docstring not found in symbol info: {symbol}" - This validates the fix in symbol.py (LanguageServerSymbolRetriever.find_symbols): - when within_relative_path points to a PHP file, the retriever must use - get_language_server() rather than iterating all language servers. Without this - fix, non-PHP servers reject the PHP file and no symbols are returned. - """ - find_symbol_tool = serena_agent.get_tool(FindSymbolTool) - sample_php = "sample.php" - - result = find_symbol_tool.apply(name_path_pattern="Dog/greet", relative_path=sample_php) - symbols = json.loads(result) - - assert len(symbols) > 0, ( - f"Expected to find Dog/greet in {sample_php} but got empty result. " - "This may indicate that find_symbol is not routing to the PHP language server for PHP files." - ) - assert any("greet" in s["name_path"] and sample_php in s["relative_path"] for s in symbols), ( - f"Dog/greet not found in {sample_php}. Symbols: {symbols}" - ) - - @pytest.mark.parametrize( - "serena_agent,symbol_name,expected_kind,expected_file", - [ - pytest.param(Language.PYTHON, "User", "Class", "models.py", marks=pytest.mark.python), - pytest.param(Language.GO, "Helper", "Function", "main.go", marks=pytest.mark.go), - pytest.param(Language.JAVA, "Model", "Class", "Model.java", marks=pytest.mark.java), - pytest.param( - Language.KOTLIN, - "Model", - "Struct", - "Model.kt", - marks=[pytest.mark.kotlin] + ([pytest.mark.skip(reason="Kotlin LSP JVM crashes on restart in CI")] if is_ci else []), - ), - pytest.param(Language.TYPESCRIPT, "DemoClass", "Class", "index.ts", marks=pytest.mark.typescript), - pytest.param(Language.PHP, "helperFunction", "Function", "helper.php", marks=pytest.mark.php), - pytest.param(Language.CLOJURE, "greet", "Function", clj.CORE_PATH, marks=pytest.mark.clojure), - pytest.param(Language.CSHARP, "Calculator", "Class", "Program.cs", marks=pytest.mark.csharp), - pytest.param(Language.POWERSHELL, "Greet-User", "Function", "main.ps1", marks=pytest.mark.powershell), - pytest.param(Language.CPP_CCLS, "add", "Function", "b.cpp", marks=pytest.mark.cpp), - pytest.param(Language.HAXE, "Main", "Class", "Main.hx", marks=pytest.mark.haxe), - pytest.param(Language.LEAN4, "add", "Method", "Helper.lean", marks=pytest.mark.lean4), - pytest.param(Language.MSL, "greet", "Function", "main.mrc", marks=pytest.mark.msl), - ], - indirect=["serena_agent"], - ) - def test_find_symbol_stable(self, serena_agent: SerenaAgent, symbol_name: str, expected_kind: str, expected_file: str) -> None: - self._assert_find_symbol(serena_agent, symbol_name, expected_kind, expected_file) - - @pytest.mark.parametrize( - "serena_agent,symbol_name,expected_kind,expected_file", - [ - pytest.param(Language.FSHARP, "Calculator", "Module", "Calculator.fs", marks=pytest.mark.fsharp), - ], - indirect=["serena_agent"], - ) - @pytest.mark.xfail(reason="F# language server is unreliable") # See issue #1040 - def test_find_symbol_fsharp(self, serena_agent: SerenaAgent, symbol_name: str, expected_kind: str, expected_file: str) -> None: - self._assert_find_symbol(serena_agent, symbol_name, expected_kind, expected_file) - - @pytest.mark.parametrize( - "serena_agent,symbol_name,expected_kind,expected_file", - [ - pytest.param(Language.RUST, "add", "Function", "lib.rs", marks=pytest.mark.rust), - ], - indirect=["serena_agent"], - ) - @pytest.mark.xfail(reason="Rust language server is unreliable") # See issue #1040 - def test_find_symbol_rust(self, serena_agent: SerenaAgent, symbol_name: str, expected_kind: str, expected_file: str) -> None: - self._assert_find_symbol(serena_agent, symbol_name, expected_kind, expected_file) - - def _assert_find_symbol_references(self, serena_agent: SerenaAgent, symbol_name: str, def_file: str, ref_file: str) -> None: + @pytest.mark.parametrize("serena_agent,case", FIND_SYMBOL_REFERENCES_CASES, indirect=["serena_agent"]) + def test_find_symbol(self, serena_agent: SerenaAgent, case: FindSymbolCase) -> None: agent = serena_agent - - # Find the symbol location first find_symbol_tool = agent.get_tool(FindSymbolTool) - result = find_symbol_tool.apply(name_path_pattern=symbol_name, relative_path=def_file) + result = find_symbol_tool.apply(name_path_pattern=case.symbol_name, include_info=True) + symbols = json.loads(result) + assert any( + case.symbol_name in s["name_path"] + and case.expected_kind.lower() in s["kind"].lower() + and case.expected_file in s["relative_path"] + for s in symbols + ), ( + f"Expected to find {case.symbol_name} ({case.expected_kind}) in {case.expected_file}. Found name paths: {[s['name_path'] for s in symbols]}" + ) + for symbol in symbols: + self._assert_symbol_info_present(serena_agent, symbol, case.symbol_name) + + @pytest.mark.parametrize("serena_agent,case", FIND_REFERENCE_CASES, indirect=["serena_agent"]) + def test_find_symbol_references(self, serena_agent: SerenaAgent, case: FindReferenceCase) -> None: + # Find the symbol location first + find_symbol_tool = serena_agent.get_tool(FindSymbolTool) + result = find_symbol_tool.apply(name_path_pattern=case.symbol_name, relative_path=case.definition_file) time.sleep(1) symbols = json.loads(result) @@ -460,12 +933,12 @@ class TestSerenaAgent: def_symbol = symbols[0] # Now find references - find_refs_tool = agent.get_tool(FindReferencingSymbolsTool) + find_refs_tool = serena_agent.get_tool(FindReferencingSymbolsTool) result = find_refs_tool.apply(name_path=def_symbol["name_path"], relative_path=def_symbol["relative_path"]) def contains_ref_with_relative_path(refs, relative_path): """ - Checks for reference to relative path, regardless of output format (grouped an ungrouped) + Checks for reference to relative path, regardless of output format (grouped or ungrouped) """ if isinstance(refs, list): for ref in refs: @@ -480,550 +953,156 @@ class TestSerenaAgent: return False refs = json.loads(result) - assert contains_ref_with_relative_path(refs, ref_file), f"Expected to find reference to {symbol_name} in {ref_file}. refs={refs}" + assert contains_ref_with_relative_path(refs, case.reference_file), ( + f"Expected to find reference to {case.symbol_name} in {case.reference_file}. refs={refs}" + ) - def _assert_find_symbol_implementations( + @pytest.mark.parametrize("serena_agent,case", FIND_DEFINING_SYMBOL_CASES, indirect=["serena_agent"]) + def test_find_defining_symbol(self, serena_agent: SerenaAgent, case: FindDefiningSymbolCase) -> None: + project_root = get_repo_path(case.language) + position = find_identifier_pos( + project_root / case.relative_path, + case.identifier, + case.occurrence_index, + case.column_offset, + ) + assert position is not None, f"Could not find occurrence {case.occurrence_index} of {case.identifier!r} in {case.relative_path}" + find_defining_symbol_tool = serena_agent.get_tool(FindDefiningSymbolAtLocationTool) + result = find_defining_symbol_tool.apply( + relative_path=case.relative_path, + line=position[0], + column=position[1], + include_info=True, + ) + defining_symbol = json.loads(result) + assert defining_symbol is not None, f"Expected defining symbol for {case.identifier!r} in {case.relative_path}" + assert defining_symbol.get("relative_path") is not None + assert case.expected_definition_file in defining_symbol["relative_path"], ( + f"Expected defining symbol in {case.expected_definition_file!r}, got: {defining_symbol}" + ) + assert self._symbol_matches_expected_name(defining_symbol, case.expected_name), ( + f"Expected defining symbol name {case.expected_name!r}, got: {defining_symbol}" + ) + self._assert_symbol_info_present(serena_agent, defining_symbol) + + @pytest.mark.parametrize("serena_agent,case", FIND_DEFINING_SYMBOL_REGEX_CASES, indirect=["serena_agent"]) + def test_find_defining_symbol_by_regex(self, serena_agent: SerenaAgent, case: RegexDefiningSymbolCase) -> None: + find_defining_symbol_tool = serena_agent.get_tool(FindDefiningSymbolTool) + result = find_defining_symbol_tool.apply( + regex=case.regex, + relative_path=case.relative_path, + containing_symbol_name_path=case.containing_symbol_name_path, + include_info=True, + ) + defining_symbol = json.loads(result) + assert defining_symbol is not None, f"Expected defining symbol for regex {case.regex!r} in {case.relative_path}" + assert defining_symbol.get("relative_path") is not None + assert case.expected_definition_file in defining_symbol["relative_path"], ( + f"Expected defining symbol in {case.expected_definition_file!r}, got: {defining_symbol}" + ) + assert self._symbol_matches_expected_name(defining_symbol, case.expected_name), ( + f"Expected defining symbol name {case.expected_name!r}, got: {defining_symbol}" + ) + self._assert_symbol_info_present(serena_agent, defining_symbol) + + @pytest.mark.parametrize("serena_agent,case", FIND_DEFINING_SYMBOL_REGEX_ERROR_CASES, indirect=["serena_agent"]) + def test_find_defining_symbol_by_regex_error( self, serena_agent: SerenaAgent, - symbol_name: str, - def_file: str, - impl_file: str, - expected_symbol_name: str, + case: RegexDefiningSymbolErrorCase, ) -> None: + find_defining_symbol_tool = serena_agent.get_tool(FindDefiningSymbolTool) + result = find_defining_symbol_tool.apply( + regex=case.regex, + relative_path=case.relative_path, + containing_symbol_name_path=case.containing_symbol_name_path, + ) + assert result.startswith("Error: "), result + assert case.error_fragment in result, result + + @pytest.mark.parametrize("serena_agent,diagnostic_case", DIAGNOSTIC_CASES, indirect=["serena_agent"]) + def test_get_diagnostics_for_file(self, serena_agent: SerenaAgent, diagnostic_case: DiagnosticCase) -> None: + diagnostics_tool = serena_agent.get_tool(GetDiagnosticsForFileTool) + result = diagnostics_tool.apply( + relative_path=diagnostic_case.relative_path, + min_severity=1, + ) + full_file_diagnostics = json.loads(result) + diagnostic_case.assert_matches(full_file_diagnostics) + + # testing diagnostics in range by removing second symbol + project_root = get_repo_path(diagnostic_case.language) + pos1 = find_identifier_pos(project_root / diagnostic_case.relative_path, diagnostic_case.symbol1_id_str) + pos2 = find_identifier_pos(project_root / diagnostic_case.relative_path, cast(str, diagnostic_case.symbol2_id_str)) + assert pos1 is not None + assert pos2 is not None + result = diagnostics_tool.apply( + relative_path=diagnostic_case.relative_path, + min_severity=1, + start_line=pos1[0], + end_line=pos2[0] - 1, + ) + diagnostics_in_range = json.loads(result) + diagnostic_case.without_second_symbol().assert_matches(diagnostics_in_range) + + @pytest.mark.parametrize("serena_agent,case", FIND_IMPLEMENTATION_CASES, indirect=["serena_agent"]) + def test_find_symbol_implementations(self, serena_agent: SerenaAgent, case: FindImplementationCase) -> None: agent = serena_agent - find_symbol_tool = agent.get_tool(FindSymbolTool) - result = find_symbol_tool.apply(name_path_pattern=symbol_name, relative_path=def_file) + result = find_symbol_tool.apply(name_path_pattern=case.symbol_name, relative_path=case.definition_file) symbols = json.loads(result) - assert symbols, f"Expected to find symbol {symbol_name} in {def_file}" - + assert symbols, f"Expected to find symbol {case.symbol_name} in {case.definition_file}" def_symbol = symbols[0] find_impl_tool = agent.get_tool(FindImplementationsTool) result = find_impl_tool.apply(name_path=def_symbol["name_path"], relative_path=def_symbol["relative_path"], include_info=True) implementations = json.loads(result) - assert any( - impl_file in implementation["relative_path"] - and ( - implementation.get("name") == expected_symbol_name - or implementation.get("name_path") == expected_symbol_name - or expected_symbol_name in implementation.get("info", "") - ) + case.implementation_file in implementation["relative_path"] + and self._symbol_matches_expected_name(implementation, case.expected_symbol_name) for implementation in implementations - ), f"Expected to find implementation of {symbol_name} in {impl_file}. implementations={implementations}" - + ), f"Expected to find implementation of {case.symbol_name} in {case.implementation_file}. implementations={implementations}" for implementation in implementations: - if implementation["kind"] in (SymbolKind.File.name, SymbolKind.Module.name): - continue - symbol_info = implementation.get("info") - assert symbol_info, f"Expected symbol info to be present for implementation: {implementation}" + self._assert_symbol_info_present(serena_agent, implementation) - def _assert_find_defining_symbol( - self, - serena_agent: SerenaAgent, - relative_path: str, - identifier: str, - occurrence_index: int, - column_offset: int, - expected_name: str, - expected_definition_file: str, - ) -> None: - project_root = get_repo_path(serena_agent.get_active_lsp_languages()[0]) - position = find_identifier_occurrence_position(project_root / relative_path, identifier, occurrence_index, column_offset) - assert position is not None, f"Could not find occurrence {occurrence_index} of {identifier!r} in {relative_path}" - - find_defining_symbol_tool = serena_agent.get_tool(FindDefiningSymbolAtLocationTool) - result = find_defining_symbol_tool.apply(relative_path=relative_path, line=position[0], column=position[1], include_info=True) - defining_symbol = json.loads(result) - - assert defining_symbol is not None, f"Expected defining symbol for {identifier!r} in {relative_path}" - assert defining_symbol.get("relative_path") is not None - assert expected_definition_file in defining_symbol["relative_path"], ( - f"Expected defining symbol in {expected_definition_file!r}, got: {defining_symbol}" - ) - assert ( - defining_symbol.get("name") == expected_name - or defining_symbol.get("name_path") == expected_name - or expected_name in defining_symbol.get("info", "") - ), f"Expected defining symbol name {expected_name!r}, got: {defining_symbol}" - - if serena_agent.get_active_lsp_languages() == [Language.KOTLIN]: - return - if defining_symbol["kind"] not in (SymbolKind.File.name, SymbolKind.Module.name): - assert defining_symbol.get("info"), f"Expected defining symbol info to be present: {defining_symbol}" - - def _assert_find_defining_symbol_by_regex( - self, - serena_agent: SerenaAgent, - relative_path: str, - regex: str, - containing_symbol_name_path: str, - expected_name: str, - expected_definition_file: str, - ) -> None: - find_defining_symbol_tool = serena_agent.get_tool(FindDefiningSymbolTool) - result = find_defining_symbol_tool.apply( - regex=regex, - relative_path=relative_path, - containing_symbol_name_path=containing_symbol_name_path, - include_info=True, - ) - defining_symbol = json.loads(result) - - assert defining_symbol is not None, f"Expected defining symbol for regex {regex!r} in {relative_path}" - assert defining_symbol.get("relative_path") is not None - assert expected_definition_file in defining_symbol["relative_path"], ( - f"Expected defining symbol in {expected_definition_file!r}, got: {defining_symbol}" - ) - assert ( - defining_symbol.get("name") == expected_name - or defining_symbol.get("name_path") == expected_name - or expected_name in defining_symbol.get("info", "") - ), f"Expected defining symbol name {expected_name!r}, got: {defining_symbol}" - - if serena_agent.get_active_lsp_languages() == [Language.KOTLIN]: - return - if defining_symbol["kind"] not in (SymbolKind.File.name, SymbolKind.Module.name): - assert defining_symbol.get("info"), f"Expected defining symbol info to be present: {defining_symbol}" - - def _assert_diagnostics_for_file( - self, - serena_agent: SerenaAgent, - diagnostic_case: DiagnosticCase, - start_line: int = 0, - end_line: int = -1, - ) -> None: - diagnostics_tool = serena_agent.get_tool(GetDiagnosticsForFileTool) - result = diagnostics_tool.apply( - relative_path=diagnostic_case.relative_path, - start_line=start_line, - end_line=end_line, - min_severity=1, - ) - grouped_diagnostics = json.loads(result) - - assert diagnostic_case.relative_path in grouped_diagnostics, grouped_diagnostics - severity_group = grouped_diagnostics[diagnostic_case.relative_path] - assert "Error" in severity_group, severity_group - name_path_group = severity_group["Error"] - - for expected_name_path in [diagnostic_case.primary_symbol_name_path, diagnostic_case.reference_symbol_name_path]: - assert expected_name_path in name_path_group, name_path_group - - diagnostic_messages = [ - diagnostic["message"] for diagnostics_for_name_path in name_path_group.values() for diagnostic in diagnostics_for_name_path - ] - for expected_fragment in [diagnostic_case.primary_message_fragment, diagnostic_case.reference_message_fragment]: - assert any(expected_fragment in message for message in diagnostic_messages), diagnostic_messages - - def _assert_diagnostics_for_symbol( - self, - serena_agent: SerenaAgent, - diagnostic_case: DiagnosticCase, - check_symbol_references: bool, - ) -> None: - diagnostics_tool = serena_agent.get_tool(GetDiagnosticsForSymbolTool) - result = diagnostics_tool.apply( - name_path=diagnostic_case.primary_symbol_name_path, - reference_file=diagnostic_case.relative_path, - check_symbol_references=check_symbol_references, - min_severity=1, - ) - grouped_diagnostics = json.loads(result) - diagnostics_file = diagnostic_case.relative_path - - assert diagnostics_file in grouped_diagnostics, grouped_diagnostics - severity_group = grouped_diagnostics[diagnostics_file] - assert "Error" in severity_group, severity_group - name_path_group = severity_group["Error"] - - expected_name_paths = [diagnostic_case.primary_symbol_name_path] - expected_message_fragments = [diagnostic_case.primary_message_fragment] - if check_symbol_references: - expected_name_paths.append(diagnostic_case.reference_symbol_name_path) - expected_message_fragments.append(diagnostic_case.reference_message_fragment) - - assert set(expected_name_paths).issubset(name_path_group.keys()), name_path_group - diagnostic_messages = [ - diagnostic["message"] for diagnostics_for_name_path in name_path_group.values() for diagnostic in diagnostics_for_name_path - ] - for expected_fragment in expected_message_fragments: - assert any(expected_fragment in message for message in diagnostic_messages), diagnostic_messages - - @pytest.mark.parametrize( - "serena_agent,symbol_name,def_file,ref_file", - [ - pytest.param( - Language.PYTHON, - "User", - os.path.join("test_repo", "models.py"), - os.path.join("test_repo", "services.py"), - marks=pytest.mark.python, - ), - pytest.param(Language.GO, "Helper", "main.go", "main.go", marks=pytest.mark.go), - pytest.param( - Language.JAVA, - "Model", - os.path.join("src", "main", "java", "test_repo", "Model.java"), - os.path.join("src", "main", "java", "test_repo", "Main.java"), - marks=pytest.mark.java, - ), - pytest.param( - Language.KOTLIN, - "Model", - os.path.join("src", "main", "kotlin", "test_repo", "Model.kt"), - os.path.join("src", "main", "kotlin", "test_repo", "Main.kt"), - marks=[pytest.mark.kotlin] + ([pytest.mark.skip(reason="Kotlin LSP JVM crashes on restart in CI")] if is_ci else []), - ), - pytest.param(Language.RUST, "add", os.path.join("src", "lib.rs"), os.path.join("src", "main.rs"), marks=pytest.mark.rust), - pytest.param(Language.PHP, "helperFunction", "helper.php", "index.php", marks=pytest.mark.php), - pytest.param( - Language.CLOJURE, - "multiply", - clj.CORE_PATH, - clj.UTILS_PATH, - marks=pytest.mark.clojure, - ), - pytest.param(Language.CSHARP, "Calculator", "Program.cs", "Program.cs", marks=pytest.mark.csharp), - pytest.param(Language.POWERSHELL, "Greet-User", "main.ps1", "main.ps1", marks=pytest.mark.powershell), - pytest.param(Language.CPP_CCLS, "add", "b.cpp", "a.cpp", marks=pytest.mark.cpp), - pytest.param( - Language.HAXE, - "addNumbers", - os.path.join("src", "utils", "Helper.hx"), - os.path.join("src", "Main.hx"), - marks=pytest.mark.haxe, - ), - pytest.param(Language.LEAN4, "add", "Helper.lean", "Main.lean", marks=pytest.mark.lean4), - pytest.param(Language.MSL, "format.coins", "utils.mrc", "main.mrc", marks=pytest.mark.msl), - ], - indirect=["serena_agent"], - ) - def test_find_symbol_references_stable(self, serena_agent: SerenaAgent, symbol_name: str, def_file: str, ref_file: str) -> None: - self._assert_find_symbol_references(serena_agent, symbol_name, def_file, ref_file) - - @pytest.mark.parametrize( - "serena_agent,symbol_name,def_file,ref_file", - [ - pytest.param(Language.TYPESCRIPT, "helperFunction", "index.ts", "use_helper.ts", marks=pytest.mark.typescript), - ], - indirect=["serena_agent"], - ) - @pytest.mark.xfail(False, reason="TypeScript language server is unreliable") # NOTE: Testing; may be resolved by #1120; See issue #1040 - def test_find_symbol_references_typescript(self, serena_agent: SerenaAgent, symbol_name: str, def_file: str, ref_file: str) -> None: - self._assert_find_symbol_references(serena_agent, symbol_name, def_file, ref_file) - - @pytest.mark.parametrize( - "serena_agent,symbol_name,def_file,ref_file", - [ - pytest.param(Language.FSHARP, "add", "Calculator.fs", "Program.fs", marks=pytest.mark.fsharp), - ], - indirect=["serena_agent"], - ) - @pytest.mark.xfail(reason="F# language server is unreliable") # See issue #1040 - def test_find_symbol_references_fsharp(self, serena_agent: SerenaAgent, symbol_name: str, def_file: str, ref_file: str) -> None: - self._assert_find_symbol_references(serena_agent, symbol_name, def_file, ref_file) - - @pytest.mark.parametrize( - "serena_agent,relative_path,identifier,occurrence_index,column_offset,expected_name,expected_definition_file", - DEFINING_SYMBOL_TOOL_TEST_CASES, - indirect=["serena_agent"], - ) - def test_find_defining_symbol( - self, - serena_agent: SerenaAgent, - relative_path: str, - identifier: str, - occurrence_index: int, - column_offset: int, - expected_name: str, - expected_definition_file: str, - ) -> None: - self._assert_find_defining_symbol( - serena_agent, - relative_path, - identifier, - occurrence_index, - column_offset, - expected_name, - expected_definition_file, - ) - - @pytest.mark.parametrize( - "serena_agent,relative_path,regex,containing_symbol_name_path,expected_name,expected_definition_file", - REGEX_DEFINING_SYMBOL_TOOL_TEST_CASES, - indirect=["serena_agent"], - ) - def test_find_defining_symbol_by_regex( - self, - serena_agent: SerenaAgent, - relative_path: str, - regex: str, - containing_symbol_name_path: str, - expected_name: str, - expected_definition_file: str, - ) -> None: - self._assert_find_defining_symbol_by_regex( - serena_agent, - relative_path, - regex, - containing_symbol_name_path, - expected_name, - expected_definition_file, - ) - - @pytest.mark.parametrize( - "serena_agent,relative_path,regex,containing_symbol_name_path,error_fragment", - [ - pytest.param( - Language.PYTHON, - os.path.join("test_repo", "services.py"), - r"(User)", - "", - "Expected exactly one regex match", - marks=pytest.mark.python, - ), - pytest.param( - Language.PYTHON, - os.path.join("test_repo", "services.py"), - r"User", - "UserService/create_user", - "must contain exactly one capturing group", - marks=pytest.mark.python, - ), - ], - indirect=["serena_agent"], - ) - def test_find_defining_symbol_by_regex_error( - self, - serena_agent: SerenaAgent, - relative_path: str, - regex: str, - containing_symbol_name_path: str, - error_fragment: str, - ) -> None: - find_defining_symbol_tool = serena_agent.get_tool(FindDefiningSymbolTool) - result = find_defining_symbol_tool.apply( - regex=regex, - relative_path=relative_path, - containing_symbol_name_path=containing_symbol_name_path, - ) - assert result.startswith("Error: "), result - assert error_fragment in result, result - - @pytest.mark.parametrize("serena_agent,diagnostic_case", WORKING_DIAGNOSTIC_TOOL_CASE_PARAMS, indirect=["serena_agent"]) - def test_get_diagnostics_for_file(self, serena_agent: SerenaAgent, diagnostic_case: DiagnosticCase) -> None: - self._assert_diagnostics_for_file( - serena_agent, - diagnostic_case=diagnostic_case, - ) - - @pytest.mark.parametrize("serena_agent,diagnostic_case", WORKING_DIAGNOSTIC_TOOL_CASE_PARAMS, indirect=["serena_agent"]) - def test_get_diagnostics_for_file_in_range(self, serena_agent: SerenaAgent, diagnostic_case: DiagnosticCase) -> None: - project_root = get_repo_path(diagnostic_case.language) - primary_position = find_identifier_occurrence_position( - project_root / diagnostic_case.relative_path, diagnostic_case.primary_symbol_identifier - ) - reference_position = find_identifier_occurrence_position( - project_root / diagnostic_case.relative_path, diagnostic_case.reference_symbol_identifier - ) - assert primary_position is not None - assert reference_position is not None - - self._assert_diagnostics_for_file( - serena_agent, - diagnostic_case=DiagnosticCase( - language=diagnostic_case.language, - relative_path=diagnostic_case.relative_path, - primary_symbol_name_path=diagnostic_case.primary_symbol_name_path, - primary_symbol_identifier=diagnostic_case.primary_symbol_identifier, - reference_symbol_name_path=diagnostic_case.primary_symbol_name_path, - reference_symbol_identifier=diagnostic_case.reference_symbol_identifier, - primary_message_fragment=diagnostic_case.primary_message_fragment, - reference_message_fragment=diagnostic_case.primary_message_fragment, - ), - start_line=primary_position[0], - end_line=reference_position[0] - 1, - ) - - @pytest.mark.parametrize("serena_agent,diagnostic_case", WORKING_DIAGNOSTIC_TOOL_CASE_PARAMS, indirect=["serena_agent"]) - def test_get_diagnostics_for_symbol(self, serena_agent: SerenaAgent, diagnostic_case: DiagnosticCase) -> None: - self._assert_diagnostics_for_symbol( - serena_agent, - diagnostic_case=diagnostic_case, - check_symbol_references=False, - ) - - @pytest.mark.parametrize("serena_agent,diagnostic_case", WORKING_DIAGNOSTIC_TOOL_CASE_PARAMS, indirect=["serena_agent"]) - def test_get_diagnostics_for_symbol_with_references(self, serena_agent: SerenaAgent, diagnostic_case: DiagnosticCase) -> None: - self._assert_diagnostics_for_symbol( - serena_agent, - diagnostic_case=diagnostic_case, - check_symbol_references=True, - ) - - if IMPLEMENTATION_TOOL_TEST_CASES: - - @pytest.mark.parametrize( - "serena_agent,symbol_name,def_file,impl_file,expected_symbol_name", - IMPLEMENTATION_TOOL_TEST_CASES, - indirect=["serena_agent"], - ) - def test_find_symbol_implementations( - self, - serena_agent: SerenaAgent, - symbol_name: str, - def_file: str, - impl_file: str, - expected_symbol_name: str, - ) -> None: - self._assert_find_symbol_implementations(serena_agent, symbol_name, def_file, impl_file, expected_symbol_name) - - @pytest.mark.parametrize( - "serena_agent,name_path,substring_matching,expected_symbol_name,expected_kind,expected_file", - [ - pytest.param( - Language.PYTHON, - "OuterClass/NestedClass", - False, - "NestedClass", - "Class", - os.path.join("test_repo", "nested.py"), - id="exact_qualname_class", - marks=pytest.mark.python, - ), - pytest.param( - Language.PYTHON, - "OuterClass/NestedClass/find_me", - False, - "find_me", - "Method", - os.path.join("test_repo", "nested.py"), - id="exact_qualname_method", - marks=pytest.mark.python, - ), - pytest.param( - Language.PYTHON, - "OuterClass/NestedCl", # Substring for NestedClass - True, - "NestedClass", - "Class", - os.path.join("test_repo", "nested.py"), - id="substring_qualname_class", - marks=pytest.mark.python, - ), - pytest.param( - Language.PYTHON, - "OuterClass/NestedClass/find_m", # Substring for find_me - True, - "find_me", - "Method", - os.path.join("test_repo", "nested.py"), - id="substring_qualname_method", - marks=pytest.mark.python, - ), - pytest.param( - Language.PYTHON, - "/OuterClass", # Absolute path - False, - "OuterClass", - "Class", - os.path.join("test_repo", "nested.py"), - id="absolute_qualname_class", - marks=pytest.mark.python, - ), - pytest.param( - Language.PYTHON, - "/OuterClass/NestedClass/find_m", # Absolute path with substring - True, - "find_me", - "Method", - os.path.join("test_repo", "nested.py"), - id="absolute_substring_qualname_method", - marks=pytest.mark.python, - ), - ], - indirect=["serena_agent"], - ) - def test_find_symbol_name_path( - self, - serena_agent, - name_path: str, - substring_matching: bool, - expected_symbol_name: str, - expected_kind: str, - expected_file: str, - ): + @pytest.mark.parametrize("serena_agent,case", FIND_SYMBOL_NAME_PATH_CASES, indirect=["serena_agent"]) + def test_find_symbol_name_path(self, serena_agent: SerenaAgent, case: FindSymbolNamePathCase) -> None: agent = serena_agent find_symbol_tool = agent.get_tool(FindSymbolTool) result = find_symbol_tool.apply_ex( - name_path_pattern=name_path, + name_path_pattern=case.name_path, depth=0, relative_path=None, include_body=False, include_kinds=None, exclude_kinds=None, - substring_matching=substring_matching, + substring_matching=case.substring_matching, ) symbols = json.loads(result) assert any( - expected_symbol_name == s["name_path"].split("/")[-1] - and expected_kind.lower() in s["kind"].lower() - and expected_file in s["relative_path"] + case.expected_symbol_name == s["name_path"].split("/")[-1] + and case.expected_kind.lower() in s["kind"].lower() + and case.expected_file in s["relative_path"] for s in symbols - ), f"Expected to find {name_path} ({expected_kind}) in {expected_file}. Symbols: {symbols}" + ), f"Expected to find {case.name_path} ({case.expected_kind}) in {case.expected_file}. Symbols: {symbols}" - @pytest.mark.parametrize( - "serena_agent,name_path", - [ - pytest.param( - Language.PYTHON, - "/NestedClass", # Absolute path, NestedClass is not top-level - id="absolute_path_non_top_level_no_match", - marks=pytest.mark.python, - ), - pytest.param( - Language.PYTHON, - "/NoSuchParent/NestedClass", # Absolute path with non-existent parent - id="absolute_path_non_existent_parent_no_match", - marks=pytest.mark.python, - ), - ], - indirect=["serena_agent"], - ) - def test_find_symbol_name_path_no_match( - self, - serena_agent, - name_path: str, - ): + @pytest.mark.parametrize("serena_agent,case", FIND_SYMBOL_NAME_PATH_NO_MATCH_CASES, indirect=["serena_agent"]) + def test_find_symbol_name_path_no_match(self, serena_agent: SerenaAgent, case: FindSymbolNoMatchCase) -> None: agent = serena_agent find_symbol_tool = agent.get_tool(FindSymbolTool) result = find_symbol_tool.apply_ex( - name_path_pattern=name_path, + name_path_pattern=case.name_path, depth=0, substring_matching=True, ) symbols = json.loads(result) - assert not symbols, f"Expected to find no symbols for {name_path}. Symbols found: {symbols}" + assert not symbols, f"Expected to find no symbols for {case.name_path}. Symbols found: {symbols}" - @pytest.mark.parametrize( - "serena_agent,name_path,num_expected", - [ - pytest.param( - Language.JAVA, - "Model/getName", - 2, - id="overloaded_java_method", - marks=pytest.mark.java, - ), - ], - indirect=["serena_agent"], - ) - def test_find_symbol_overloaded_function(self, serena_agent: SerenaAgent, name_path: str, num_expected: int): + @pytest.mark.parametrize("serena_agent,case", FIND_SYMBOL_OVERLOADED_FUNCTION_CASES, indirect=["serena_agent"]) + def test_find_symbol_overloaded_function(self, serena_agent: SerenaAgent, case: FindSymbolOverloadedCase) -> None: """ Tests whether the FindSymbolTool can find all overloads of a function/method (provided that the overload id remains unspecified in the name path) @@ -1032,51 +1111,38 @@ class TestSerenaAgent: find_symbol_tool = agent.get_tool(FindSymbolTool) result = find_symbol_tool.apply_ex( - name_path_pattern=name_path, + name_path_pattern=case.name_path, depth=0, substring_matching=False, ) symbols = json.loads(result) - assert len(symbols) == num_expected, ( - f"Expected to find {num_expected} symbols for overloaded function {name_path}. Symbols found: {symbols}" + assert len(symbols) == case.num_expected, ( + f"Expected to find {case.num_expected} symbols for overloaded function {case.name_path}. Symbols found: {symbols}" ) - @pytest.mark.parametrize( - "serena_agent,name_path,relative_path", - [ - pytest.param( - Language.JAVA, - "Model/getName", - os.path.join("src", "main", "java", "test_repo", "Model.java"), - id="overloaded_java_method", - marks=pytest.mark.java, - ), - ], - indirect=["serena_agent"], - ) - def test_non_unique_symbol_reference_error(self, serena_agent: SerenaAgent, name_path: str, relative_path: str): + @pytest.mark.parametrize("serena_agent,case", NON_UNIQUE_SYMBOL_REFERENCE_ERROR_CASES, indirect=["serena_agent"]) + def test_non_unique_symbol_reference_error( + self, + serena_agent: SerenaAgent, + case: NonUniqueSymbolReferenceCase, + ) -> None: """ Tests whether the tools operating on a well-defined symbol raises an error when the symbol reference is non-unique. We exemplarily test a retrieval tool (FindReferencingSymbolsTool) and an editing tool (ReplaceSymbolBodyTool). """ - match_text = "multiple" - find_refs_tool = serena_agent.get_tool(FindReferencingSymbolsTool) - with pytest.raises(ValueError, match=match_text): - find_refs_tool.apply(name_path=name_path, relative_path=relative_path) + with pytest.raises(ValueError, match=case.expected_error_fragment): + find_refs_tool.apply(name_path=case.name_path, relative_path=case.relative_path) replace_symbol_body_tool = serena_agent.get_tool(ReplaceSymbolBodyTool) - with pytest.raises(ValueError, match=match_text): - replace_symbol_body_tool.apply(name_path=name_path, relative_path=relative_path, body="") + with pytest.raises(ValueError, match=case.expected_error_fragment): + replace_symbol_body_tool.apply(name_path=case.name_path, relative_path=case.relative_path, body="") @pytest.mark.parametrize( "serena_agent", [ - pytest.param( - Language.TYPESCRIPT, - marks=pytest.mark.typescript, - ), + pytest.param(Language.TYPESCRIPT, marks=get_pytest_markers(Language.TYPESCRIPT), id="typescript_unique_regex"), ], indirect=["serena_agent"], ) @@ -1089,7 +1155,7 @@ class TestSerenaAgent: replace_content_tool = serena_agent.get_tool(ReplaceContentTool) result = replace_content_tool.apply( needle=r'catch \(error\) \{\s*console.error\("Failed to connect.*?\}', - repl='catch(error) { console.log("Never mind"); }', + repl='catch(error) {console.log("Never mind"); }', relative_path=relative_path, mode="regex", ) @@ -1098,14 +1164,11 @@ class TestSerenaAgent: @pytest.mark.parametrize( "serena_agent", [ - pytest.param( - Language.TYPESCRIPT, - marks=pytest.mark.typescript, - ), + pytest.param(Language.TYPESCRIPT, marks=get_pytest_markers(Language.TYPESCRIPT), id="typescript_backslashes"), ], indirect=["serena_agent"], ) - @pytest.mark.parametrize("mode", ["literal", "regex"]) + @pytest.mark.parametrize("mode", ["literal", "regex"], ids=["literal_mode", "regex_mode"]) def test_replace_content_with_backslashes(self, serena_agent: SerenaAgent, mode: Literal["literal", "regex"]): """ Tests a content replacement where the needle and replacement strings contain backslashes. @@ -1129,8 +1192,8 @@ class TestSerenaAgent: @pytest.mark.parametrize( "serena_agent", [ - pytest.param(Language.PYTHON, marks=pytest.mark.python), - pytest.param(Language.PYTHON_TY, marks=pytest.mark.python), + pytest.param(Language.PYTHON, marks=get_pytest_markers(Language.PYTHON), id="python_services"), + pytest.param(Language.PYTHON_TY, marks=get_pytest_markers(Language.PYTHON_TY), id="python_ty_services"), ], indirect=["serena_agent"], ) @@ -1138,6 +1201,7 @@ class TestSerenaAgent: """Tests that file-level edits report newly introduced diagnostics.""" relative_path = os.path.join("test_repo", "services.py") replace_content_tool = serena_agent.get_tool(ReplaceContentTool) + replace_content_tool._ENABLE_DIAGNOSTICS = True with project_file_modification_context(serena_agent, relative_path): result = replace_content_tool.apply( @@ -1156,8 +1220,8 @@ class TestSerenaAgent: @pytest.mark.parametrize( "serena_agent", [ - pytest.param(Language.PYTHON, marks=pytest.mark.python), - pytest.param(Language.PYTHON_TY, marks=pytest.mark.python), + pytest.param(Language.PYTHON, marks=get_pytest_markers(Language.PYTHON), id="python_container_body"), + pytest.param(Language.PYTHON_TY, marks=get_pytest_markers(Language.PYTHON_TY), id="python_ty_container_body"), ], indirect=["serena_agent"], ) @@ -1165,6 +1229,7 @@ class TestSerenaAgent: """Tests that symbol-level edits report newly introduced diagnostics.""" relative_path = os.path.join("test_repo", "services.py") replace_symbol_body_tool = serena_agent.get_tool(ReplaceSymbolBodyTool) + replace_symbol_body_tool._ENABLE_DIAGNOSTICS = True with project_file_modification_context(serena_agent, relative_path): result = replace_symbol_body_tool.apply( @@ -1185,10 +1250,7 @@ def create_service_container() -> dict[str, Any]: @pytest.mark.parametrize( "serena_agent", [ - pytest.param( - Language.TYPESCRIPT, - marks=pytest.mark.typescript, - ), + pytest.param(Language.TYPESCRIPT, marks=get_pytest_markers(Language.TYPESCRIPT), id="typescript_ambiguous_regex"), ], indirect=["serena_agent"], ) @@ -1201,98 +1263,40 @@ def create_service_container() -> dict[str, Any]: with pytest.raises(ValueError, match="ambiguous"): replace_content_tool.apply( needle=r'catch \(error\) \{.*?this\.updateConnectionStatus\("Connection failed", false\);.*?\}', - repl='catch(error) { console.log("Never mind"); }', + repl='catch(error) {console.log("Never mind"); }', relative_path="ws_manager.js", mode="regex", ) - @pytest.mark.parametrize( - "serena_agent,name_path,relative_path", - [ - pytest.param( - Language.PYTHON, - "User", - os.path.join("test_repo", "models.py"), - marks=pytest.mark.python, - ), - pytest.param( - Language.JAVA, - "Model", - os.path.join("src", "main", "java", "test_repo", "Model.java"), - marks=pytest.mark.java, - ), - pytest.param( - Language.KOTLIN, - "Model", - os.path.join("src", "main", "kotlin", "test_repo", "Model.kt"), - marks=[pytest.mark.kotlin] + ([pytest.mark.skip(reason="Kotlin LSP JVM crashes on restart in CI")] if is_ci else []), - ), - pytest.param( - Language.TYPESCRIPT, - "helperFunction", - "index.ts", - marks=pytest.mark.typescript, - ), - ], - indirect=["serena_agent"], - ) - def test_safe_delete_symbol_blocked_by_references(self, serena_agent: SerenaAgent, name_path: str, relative_path: str): + @pytest.mark.parametrize("serena_agent,case", SAFE_DELETE_BLOCKED_CASES, indirect=["serena_agent"]) + def test_safe_delete_symbol_blocked_by_references(self, serena_agent: SerenaAgent, case: SafeDeleteCase): """ Tests that SafeDeleteSymbol refuses to delete a symbol that is referenced elsewhere and returns a message listing the referencing files. """ # wrap in modification context as a safety net: if the tool has a bug and deletes anyway, # the file will be restored, preventing corruption of test resources - with project_file_modification_context(serena_agent, relative_path): + with project_file_modification_context(serena_agent, case.relative_path): safe_delete_tool = serena_agent.get_tool(SafeDeleteSymbol) - result = safe_delete_tool.apply(name_path_pattern=name_path, relative_path=relative_path) + result = safe_delete_tool.apply(name_path_pattern=case.name_path, relative_path=case.relative_path) assert "Cannot delete" in result, f"Expected deletion to be blocked due to existing references, but got: {result}" assert "referenced in" in result, f"Expected reference information in result, but got: {result}" - @pytest.mark.parametrize( - "serena_agent,name_path,relative_path", - [ - pytest.param( - Language.PYTHON, - "Timer", - os.path.join("test_repo", "utils.py"), - marks=pytest.mark.python, - ), - pytest.param( - Language.JAVA, - "ModelUser", - os.path.join("src", "main", "java", "test_repo", "ModelUser.java"), - marks=pytest.mark.java, - ), - pytest.param( - Language.KOTLIN, - "ModelUser", - os.path.join("src", "main", "kotlin", "test_repo", "ModelUser.kt"), - marks=[pytest.mark.kotlin] + ([pytest.mark.skip(reason="Kotlin LSP JVM crashes on restart in CI")] if is_ci else []), - ), - pytest.param( - Language.TYPESCRIPT, - "unusedStandaloneFunction", - "index.ts", - marks=pytest.mark.typescript, - ), - ], - indirect=["serena_agent"], - ) - def test_safe_delete_symbol_succeeds_when_no_references(self, serena_agent: SerenaAgent, name_path: str, relative_path: str): + @pytest.mark.parametrize("serena_agent,case", SAFE_DELETE_SUCCEEDS_CASES, indirect=["serena_agent"]) + def test_safe_delete_symbol_succeeds_when_no_references(self, serena_agent: SerenaAgent, case: SafeDeleteCase): """ Tests that SafeDeleteSymbol successfully deletes a symbol that has no references and that the symbol is actually removed from the file. """ - with project_file_modification_context(serena_agent, relative_path): + with project_file_modification_context(serena_agent, case.relative_path): safe_delete_tool = serena_agent.get_tool(SafeDeleteSymbol) - result = safe_delete_tool.apply(name_path_pattern=name_path, relative_path=relative_path) + result = safe_delete_tool.apply(name_path_pattern=case.name_path, relative_path=case.relative_path) assert result == SUCCESS_RESULT, f"Expected successful deletion, but got: {result}" # verify the symbol was actually removed from the file - file_content = read_project_file(serena_agent.get_active_project(), relative_path) - assert name_path not in file_content, ( - f"Expected symbol {name_path} to be removed from {relative_path}, but it still appears in the file content" + file_content = read_project_file(serena_agent.get_active_project(), case.relative_path) + assert case.name_path not in file_content, ( + f"Expected symbol {case.name_path} to be removed from {case.relative_path}, but it still appears in the file content" ) diff --git a/test/solidlsp/test_defining_symbol_matrix.py b/test/solidlsp/test_defining_symbol_matrix.py deleted file mode 100644 index c0509a2f..00000000 --- a/test/solidlsp/test_defining_symbol_matrix.py +++ /dev/null @@ -1,139 +0,0 @@ -import os -import shutil -from pathlib import Path - -import pytest - -from solidlsp import SolidLanguageServer -from solidlsp.ls_config import Language -from test.conftest import find_identifier_occurrence_position, is_ci -from test.solidlsp import clojure as clj - - -@pytest.mark.parametrize( - "language_server,relative_path,identifier,occurrence_index,column_offset,expected_name,expected_definition_file", - [ - pytest.param( - Language.PYTHON, - os.path.join("test_repo", "services.py"), - "User", - 1, - 1, - "User", - "models.py", - marks=pytest.mark.python, - ), - pytest.param( - Language.PYTHON_TY, - os.path.join("test_repo", "services.py"), - "User", - 1, - 1, - "User", - "models.py", - marks=pytest.mark.python, - ), - pytest.param(Language.GO, "main.go", "Helper", 0, 1, "Helper", "main.go", marks=pytest.mark.go), - pytest.param( - Language.JAVA, - os.path.join("src", "main", "java", "test_repo", "Main.java"), - "Model", - 0, - 1, - "Model", - "Model.java", - marks=pytest.mark.java, - ), - pytest.param( - Language.KOTLIN, - os.path.join("src", "main", "kotlin", "test_repo", "Main.kt"), - "Model", - 0, - 1, - "Model", - "Model.kt", - marks=[pytest.mark.kotlin] + ([pytest.mark.skip(reason="Kotlin LSP JVM crashes on restart in CI")] if is_ci else []), - ), - pytest.param( - Language.RUST, - os.path.join("src", "main.rs"), - "format_greeting", - 0, - 1, - "format_greeting", - "lib.rs", - marks=pytest.mark.rust, - ), - pytest.param(Language.PHP, "index.php", "helperFunction", 0, 5, "helperFunction", "helper.php", marks=pytest.mark.php), - pytest.param( - Language.CLOJURE, - clj.UTILS_PATH, - "multiply", - 0, - 1, - "multiply", - clj.CORE_PATH, - marks=[ - pytest.mark.clojure, - pytest.mark.skipif(not clj.is_clojure_cli_available(), reason="clojure CLI is not installed"), - ], - ), - pytest.param(Language.CSHARP, "Program.cs", "Add", 0, 1, "Add", "Program.cs", marks=pytest.mark.csharp), - pytest.param( - Language.POWERSHELL, - "main.ps1", - "Convert-ToUpperCase", - 0, - 1, - "function Convert-ToUpperCase ()", - "utils.ps1", - marks=pytest.mark.powershell, - ), - pytest.param(Language.CPP_CCLS, "a.cpp", "add", 0, 1, "add", "b.cpp", marks=pytest.mark.cpp), - pytest.param( - Language.LEAN4, - "Main.lean", - "add", - 0, - 1, - "add", - "Helper.lean", - marks=[ - pytest.mark.lean4, - pytest.mark.skipif(shutil.which("lean") is None, reason="Lean is not installed"), - pytest.mark.xfail(reason="Lean4 LS does not reliably resolve cross-file defining symbols in CI"), - ], - ), - pytest.param(Language.TYPESCRIPT, "index.ts", "helperFunction", 1, 1, "helperFunction", "index.ts", marks=pytest.mark.typescript), - pytest.param( - Language.FSHARP, - "Program.fs", - "add", - 0, - 1, - "add", - "Calculator.fs", - marks=[pytest.mark.fsharp, pytest.mark.xfail(reason="F# language server cannot reliably resolve defining symbols")], - ), - ], - indirect=["language_server"], -) -def test_request_defining_symbol_matrix( - language_server: SolidLanguageServer, - relative_path: str, - identifier: str, - occurrence_index: int, - column_offset: int, - expected_name: str, - expected_definition_file: str, -) -> None: - repo_root = Path(language_server.repository_root_path) - position = find_identifier_occurrence_position(repo_root / relative_path, identifier, occurrence_index, column_offset) - assert position is not None, f"Could not find occurrence {occurrence_index} of {identifier!r} in {relative_path}" - - defining_symbol = language_server.request_defining_symbol(relative_path, *position) - assert defining_symbol is not None, f"Expected a defining symbol for {identifier!r} in {relative_path}" - assert defining_symbol.get("name") == expected_name, f"Expected defining symbol name {expected_name!r}, got: {defining_symbol}" - assert expected_definition_file in defining_symbol["location"].get("relativePath", ""), ( - f"Expected defining symbol in {expected_definition_file!r}, got: {defining_symbol}" - ) diff --git a/test/solidlsp/test_diagnostics_matrix.py b/test/solidlsp/test_diagnostics_matrix.py deleted file mode 100644 index 2d1b50fe..00000000 --- a/test/solidlsp/test_diagnostics_matrix.py +++ /dev/null @@ -1,79 +0,0 @@ -from pathlib import Path - -import pytest - -from serena.symbol import LanguageServerSymbolRetriever -from solidlsp import SolidLanguageServer -from test.conftest import find_identifier_position -from test.diagnostics_cases import DIAGNOSTIC_CASE_PARAMS, DiagnosticCase - - -@pytest.mark.parametrize("language_server,diagnostic_case", DIAGNOSTIC_CASE_PARAMS, indirect=["language_server"]) -def test_request_text_document_diagnostics_matrix( - language_server: SolidLanguageServer, - diagnostic_case: DiagnosticCase, -) -> None: - diagnostics = language_server.request_text_document_diagnostics(diagnostic_case.relative_path, min_severity=1) - assert diagnostics, f"Expected diagnostics for {diagnostic_case.language.value}:{diagnostic_case.relative_path}" - - diagnostic_messages = [diagnostic["message"] for diagnostic in diagnostics] - assert any(diagnostic_case.primary_message_fragment in message for message in diagnostic_messages), diagnostic_messages - assert any(diagnostic_case.reference_message_fragment in message for message in diagnostic_messages), diagnostic_messages - - repo_root = language_server.repository_root_path - primary_symbol_position = find_identifier_position( - Path(repo_root) / diagnostic_case.relative_path, diagnostic_case.primary_symbol_identifier - ) - reference_symbol_position = find_identifier_position( - Path(repo_root) / diagnostic_case.relative_path, diagnostic_case.reference_symbol_identifier - ) - assert primary_symbol_position is not None - assert reference_symbol_position is not None - - primary_diagnostics = language_server.request_text_document_diagnostics( - diagnostic_case.relative_path, - start_line=primary_symbol_position[0], - end_line=reference_symbol_position[0] - 1, - min_severity=1, - ) - primary_messages = [diagnostic["message"] for diagnostic in primary_diagnostics] - assert primary_messages, f"Expected range-filtered diagnostics for {diagnostic_case.primary_symbol_identifier}" - assert any(diagnostic_case.primary_message_fragment in message for message in primary_messages), primary_messages - assert all(diagnostic_case.reference_message_fragment not in message for message in primary_messages), primary_messages - - -@pytest.mark.parametrize("project_with_ls,diagnostic_case", DIAGNOSTIC_CASE_PARAMS, indirect=["project_with_ls"]) -def test_get_symbol_diagnostics_matrix(project_with_ls, diagnostic_case: DiagnosticCase) -> None: - symbol_retriever = LanguageServerSymbolRetriever(project_with_ls) - - diagnostics_by_symbol = symbol_retriever.get_symbol_diagnostics( - diagnostic_case.primary_symbol_name_path, - reference_file=diagnostic_case.relative_path, - min_severity=1, - ) - diagnostic_messages_by_symbol = { - symbol.get_name_path(): [diagnostic["message"] for diagnostic in diagnostics] - for symbol, diagnostics in diagnostics_by_symbol.items() - } - assert diagnostic_case.primary_symbol_name_path in diagnostic_messages_by_symbol, diagnostic_messages_by_symbol - assert any( - diagnostic_case.primary_message_fragment in message - for message in diagnostic_messages_by_symbol[diagnostic_case.primary_symbol_name_path] - ), diagnostic_messages_by_symbol - - diagnostics_with_references = symbol_retriever.get_symbol_diagnostics( - diagnostic_case.primary_symbol_name_path, - reference_file=diagnostic_case.relative_path, - check_symbol_references=True, - min_severity=1, - ) - diagnostic_messages_with_references = { - symbol.get_name_path(): [diagnostic["message"] for diagnostic in diagnostics] - for symbol, diagnostics in diagnostics_with_references.items() - } - assert diagnostic_case.primary_symbol_name_path in diagnostic_messages_with_references, diagnostic_messages_with_references - assert diagnostic_case.reference_symbol_name_path in diagnostic_messages_with_references, diagnostic_messages_with_references - assert any( - diagnostic_case.reference_message_fragment in message - for message in diagnostic_messages_with_references[diagnostic_case.reference_symbol_name_path] - ), diagnostic_messages_with_references From d198d67a9f6d0ae7898cdaddf42e057a7bc3187e Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Tue, 28 Apr 2026 21:07:01 +0200 Subject: [PATCH 04/12] Added JetBrains diagnostics tools, WIP --- .../jetbrains/jetbrains_plugin_client.py | 48 ++++++++++++ src/serena/jetbrains/jetbrains_types.py | 27 +++++++ .../config/internal_modes/jetbrains.yml | 2 + src/serena/tools/jetbrains_tools.py | 73 +++++++++++++++++++ 4 files changed, 150 insertions(+) diff --git a/src/serena/jetbrains/jetbrains_plugin_client.py b/src/serena/jetbrains/jetbrains_plugin_client.py index 263bc8a9..c662ec76 100644 --- a/src/serena/jetbrains/jetbrains_plugin_client.py +++ b/src/serena/jetbrains/jetbrains_plugin_client.py @@ -661,6 +661,54 @@ class JetBrainsPluginClient(ToStringMixin): } return self._make_request("POST", "/debugReplClose", request_data) + def run_inspections( + self, + relative_path: str, + min_severity: str | None = None, + inspection_names: list[str] | None = None, + start_line: int | None = None, + end_line: int | None = None, + ) -> jb.RunInspectionsResponse: + """ + Runs IDE inspections on the given file and returns the results. + + :param relative_path: the relative path to the file to inspect + :param min_severity: minimum severity level to include (e.g. "WARNING", "ERROR") + :param inspection_names: optional list of specific inspection names to run + :param start_line: optional start line to restrict the inspection range + :param end_line: optional end line to restrict the inspection range + """ + request_data: dict[str, Any] = { + "relativePath": relative_path, + } + if min_severity is not None: + request_data["minSeverity"] = min_severity + if inspection_names is not None: + request_data["inspectionNames"] = inspection_names + if start_line is not None: + request_data["startLine"] = start_line + if end_line is not None: + request_data["endLine"] = end_line + return cast(jb.RunInspectionsResponse, self._make_request("POST", "/runInspections", request_data)) + + def list_inspections( + self, + language: str | None = None, + group_path_contains: str | None = None, + ) -> jb.ListInspectionsResponse: + """ + Lists available IDE inspections, optionally filtered by language and/or group path. + + :param language: optional language to filter inspections by (e.g. "Java", "Python") + :param group_path_contains: optional substring to filter inspection group paths + """ + request_data: dict[str, Any] = {} + if language is not None: + request_data["language"] = language + if group_path_contains is not None: + request_data["groupPathContains"] = group_path_contains + return cast(jb.ListInspectionsResponse, self._make_request("POST", "/listInspections", request_data)) + def close(self) -> None: self._session.close() diff --git a/src/serena/jetbrains/jetbrains_types.py b/src/serena/jetbrains/jetbrains_types.py index ead4eb55..ce488ea2 100644 --- a/src/serena/jetbrains/jetbrains_types.py +++ b/src/serena/jetbrains/jetbrains_types.py @@ -89,3 +89,30 @@ class TypeHierarchyNodeDTO(TypedDict): class TypeHierarchyResponse(TypedDict): hierarchy: NotRequired[list[TypeHierarchyNodeDTO]] num_levels_not_included: NotRequired[int] + + +class InspectionResultDTO(TypedDict): + inspection_name: str + severity: str + message: str + relative_path: str + start_line: int + end_line: int + start_col: NotRequired[int] + end_col: NotRequired[int] + quick_fix_available: NotRequired[bool] + + +class RunInspectionsResponse(TypedDict): + inspections: list[InspectionResultDTO] + + +class InspectionInfoDTO(TypedDict): + name: str + group_path: str + language: NotRequired[str] + description: NotRequired[str] + + +class ListInspectionsResponse(TypedDict): + inspections: list[InspectionInfoDTO] diff --git a/src/serena/resources/config/internal_modes/jetbrains.yml b/src/serena/resources/config/internal_modes/jetbrains.yml index 34baf6ce..1932906c 100644 --- a/src/serena/resources/config/internal_modes/jetbrains.yml +++ b/src/serena/resources/config/internal_modes/jetbrains.yml @@ -25,3 +25,5 @@ included_optional_tools: - jet_brains_safe_delete - jet_brains_debug - serena_info + - jet_brains_run_inspections + - jet_brains_list_inspections diff --git a/src/serena/tools/jetbrains_tools.py b/src/serena/tools/jetbrains_tools.py index 0664fb03..74a05359 100644 --- a/src/serena/tools/jetbrains_tools.py +++ b/src/serena/tools/jetbrains_tools.py @@ -595,3 +595,76 @@ class JetBrainsDebugTool(Tool, ToolMarkerOptional, ToolMarkerBeta): else: response = client.debug_close(repl_key=repl_key) return response.get("result", str(response)) + + +class JetBrainsRunInspectionsTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional): + """ + Runs JetBrains IDE inspections on a file and returns the results. + """ + + def apply( + self, + relative_path: str, + min_severity: str | None = None, + inspection_names: list[str] | None = None, + start_line: int | None = None, + end_line: int | None = None, + max_answer_chars: int = -1, + ) -> str: + """ + Runs IDE inspections (code analysis) on the given file and returns the problems found. + This leverages the full power of JetBrains' static analysis engine, including language-specific + inspections, type checking, potential bugs, code style issues, and more. + + :param relative_path: the relative path to the file to inspect. + :param min_severity: minimum severity level to include in results (e.g. "ERROR", "WARNING", "WEAK_WARNING", "INFO"). + If not specified, all severities are returned. + :param inspection_names: optional list of specific inspection names to run (e.g. ["UnusedImport", "TypeMismatch"]). + If not specified, all applicable inspections are run. + :param start_line: optional 1-based start line to restrict the inspection range. + :param end_line: optional 1-based end line to restrict the inspection range. + :param max_answer_chars: max characters for the JSON result. If exceeded, no content is returned. + -1 means the default value from the config will be used. + :return: JSON string with inspection results including severity, message, and location. + """ + with JetBrainsPluginClient.from_project(self.project) as client: + response_dict = client.run_inspections( + relative_path=relative_path, + min_severity=min_severity, + inspection_names=inspection_names, + start_line=start_line, + end_line=end_line, + ) + result = self._to_json(response_dict) + return self._limit_length(result, max_answer_chars) + + +class JetBrainsListInspectionsTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional): + """ + Lists available JetBrains IDE inspections, optionally filtered by language or group. + """ + + def apply( + self, + language: str | None = None, + group_path_contains: str | None = None, + max_answer_chars: int = -1, + ) -> str: + """ + Lists the available IDE inspections. Use this to discover which inspections can be passed + to the run_inspections tool's `inspection_names` parameter. + + :param language: optional language to filter by (e.g. "Java", "Python", "Kotlin"). + :param group_path_contains: optional substring to match against the inspection group path + (e.g. "probable bugs", "code style"). + :param max_answer_chars: max characters for the JSON result. If exceeded, no content is returned. + -1 means the default value from the config will be used. + :return: JSON string with the list of available inspections including name, group path, and language. + """ + with JetBrainsPluginClient.from_project(self.project) as client: + response_dict = client.list_inspections( + language=language, + group_path_contains=group_path_contains, + ) + result = self._to_json(response_dict) + return self._limit_length(result, max_answer_chars) From bd7e64e85a04751f629a2e4094575e0b6e67aa37 Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Tue, 28 Apr 2026 22:06:41 +0200 Subject: [PATCH 05/12] Fixed diagnostics files uri handling, added low-level tests --- .gitignore | 4 +- .serena/project.yml | 1 + scripts/demo_run_tools.py | 6 +- .../jetbrains/jetbrains_plugin_client.py | 2 +- .../language_servers/elm_language_server.py | 28 +++++-- .../fortran_language_server.py | 1 + .../language_servers/lean4_language_server.py | 1 + .../language_servers/pascal_server.py | 9 +++ .../solidity_language_server.py | 1 + .../typescript_language_server.py | 26 +++++- .../language_servers/vue_language_server.py | 13 +++ src/solidlsp/language_servers/zls.py | 1 + src/solidlsp/ls.py | 79 ++++++++++++++++--- .../bash/test_repo/diagnostics_sample.sh | 4 + .../test_repo/src/diagnostics_sample.cr | 5 ++ .../test_repo/lib/diagnostics_sample.dart | 3 + .../test_repo/lib/diagnostics_sample.ex | 10 +++ .../repos/elm/test_repo/DiagnosticsSample.elm | 5 ++ .../test_repo/src/diagnostics_sample.erl | 5 ++ .../fortran/test_repo/diagnostics_sample.f90 | 5 ++ .../com/example/DiagnosticsSample.groovy | 13 +++ .../test_repo/src/DiagnosticsSample.hs | 7 ++ .../haxe/test_repo/src/DiagnosticsSample.hx | 10 +++ .../hlsl/test_repo/diagnostics_sample.hlsl | 3 + .../json/test_repo/diagnostics_sample.json | 4 + .../julia/test_repo/diagnostics_sample.jl | 3 + .../lean4/test_repo/DiagnosticsSample.lean | 5 -- .../matlab/test_repo/diagnostics_sample.m | 3 + .../nix/test_repo/diagnostics_sample.nix | 4 + .../ocaml/test_repo/lib/diagnostics_sample.ml | 1 + test/resources/repos/ocaml/test_repo/lib/dune | 3 +- .../pascal/test_repo/diagnostics_sample.pas | 4 + .../perl/test_repo/diagnostics_sample.pl | 5 ++ .../test_repo/diagnostics_sample.ps1 | 9 +-- .../scala/com/example/DiagnosticsSample.scala | 9 +++ .../test_repo/contracts/DiagnosticsSample.sol | 7 ++ .../repos/swift/test_repo/Package.swift | 5 +- .../test_repo/src/diagnostics_sample.swift | 3 + .../test_repo/diagnostics_sample.sv | 5 ++ .../terraform/test_repo/diagnostics_sample.tf | 4 + .../toml/test_repo/diagnostics_sample.toml | 2 + .../vue/test_repo/src/DiagnosticsSample.vue | 3 + .../yaml/test_repo/diagnostics_sample.yaml | 5 ++ test/resources/repos/zig/test_repo/build.zig | 2 +- .../zig/test_repo/src/diagnostics_sample.zig | 3 + .../repos/zig/test_repo/src/main.zig | 2 +- .../__snapshots__/test_symbol_editing.ambr | 26 +++--- test/solidlsp/bash/test_bash_diagnostics.py | 17 ++++ test/solidlsp/clojure/__init__.py | 4 +- .../clojure/test_clojure_diagnostics.py | 19 +++++ test/solidlsp/conftest.py | 9 +++ test/solidlsp/cpp/test_cpp_diagnostics.py | 17 ++++ .../crystal/test_crystal_diagnostics.py | 23 ++++++ .../csharp/test_csharp_diagnostics.py | 17 ++++ test/solidlsp/dart/test_dart_diagnostics.py | 17 ++++ test/solidlsp/elixir/test_elixir_basic.py | 10 +++ test/solidlsp/elm/test_elm_diagnostics.py | 20 +++++ test/solidlsp/erlang/test_erlang_basic.py | 10 +++ .../fortran/test_fortran_diagnostics.py | 17 ++++ test/solidlsp/fsharp/test_fsharp_basic.py | 23 ++---- test/solidlsp/go/test_go_diagnostics.py | 17 ++++ test/solidlsp/groovy/test_groovy_basic.py | 10 +++ test/solidlsp/haskell/test_haskell_basic.py | 10 +++ test/solidlsp/haxe/test_haxe_diagnostics.py | 17 ++++ test/solidlsp/hlsl/test_hlsl_diagnostics.py | 17 ++++ test/solidlsp/java/test_java_diagnostics.py | 17 ++++ .../solidlsp/json_ls/test_json_diagnostics.py | 17 ++++ test/solidlsp/julia/test_julia_basic.py | 10 +++ .../kotlin/test_kotlin_diagnostics.py | 17 ++++ test/solidlsp/lean4/test_lean4_diagnostics.py | 17 ++++ test/solidlsp/matlab/test_matlab_basic.py | 10 +++ test/solidlsp/nix/test_nix_basic.py | 10 +++ test/solidlsp/ocaml/test_ocaml_diagnostics.py | 17 ++++ .../pascal/test_pascal_diagnostics.py | 25 ++++++ test/solidlsp/perl/test_perl_basic.py | 10 +++ test/solidlsp/php/test_php_diagnostics.py | 17 ++++ .../powershell/test_powershell_diagnostics.py | 17 ++++ .../python/test_python_diagnostics.py | 19 +++++ test/solidlsp/rust/test_rust_basic.py | 2 +- test/solidlsp/rust/test_rust_diagnostics.py | 17 ++++ .../scala/test_scala_language_server.py | 10 +++ .../solidity/test_solidity_diagnostics.py | 24 ++++++ test/solidlsp/swift/test_swift_basic.py | 10 +++ .../test_systemverilog_diagnostics.py | 17 ++++ .../terraform/test_terraform_diagnostics.py | 17 ++++ test/solidlsp/toml/test_toml_diagnostics.py | 17 ++++ .../typescript/test_typescript_diagnostics.py | 17 ++++ test/solidlsp/util/diagnostics.py | 19 +++++ test/solidlsp/vue/test_vue_diagnostics.py | 17 ++++ .../solidlsp/yaml_ls/test_yaml_diagnostics.py | 17 ++++ test/solidlsp/zig/test_zig_basic.py | 10 +++ 91 files changed, 958 insertions(+), 72 deletions(-) create mode 100644 test/resources/repos/bash/test_repo/diagnostics_sample.sh create mode 100644 test/resources/repos/crystal/test_repo/src/diagnostics_sample.cr create mode 100644 test/resources/repos/dart/test_repo/lib/diagnostics_sample.dart create mode 100644 test/resources/repos/elixir/test_repo/lib/diagnostics_sample.ex create mode 100644 test/resources/repos/elm/test_repo/DiagnosticsSample.elm create mode 100644 test/resources/repos/erlang/test_repo/src/diagnostics_sample.erl create mode 100644 test/resources/repos/fortran/test_repo/diagnostics_sample.f90 create mode 100644 test/resources/repos/groovy/test_repo/src/main/groovy/com/example/DiagnosticsSample.groovy create mode 100644 test/resources/repos/haskell/test_repo/src/DiagnosticsSample.hs create mode 100644 test/resources/repos/haxe/test_repo/src/DiagnosticsSample.hx create mode 100644 test/resources/repos/hlsl/test_repo/diagnostics_sample.hlsl create mode 100644 test/resources/repos/json/test_repo/diagnostics_sample.json create mode 100644 test/resources/repos/julia/test_repo/diagnostics_sample.jl create mode 100644 test/resources/repos/matlab/test_repo/diagnostics_sample.m create mode 100644 test/resources/repos/nix/test_repo/diagnostics_sample.nix create mode 100644 test/resources/repos/ocaml/test_repo/lib/diagnostics_sample.ml create mode 100644 test/resources/repos/pascal/test_repo/diagnostics_sample.pas create mode 100644 test/resources/repos/perl/test_repo/diagnostics_sample.pl create mode 100644 test/resources/repos/scala/test_repo/src/main/scala/com/example/DiagnosticsSample.scala create mode 100644 test/resources/repos/solidity/test_repo/contracts/DiagnosticsSample.sol create mode 100644 test/resources/repos/swift/test_repo/src/diagnostics_sample.swift create mode 100644 test/resources/repos/systemverilog/test_repo/diagnostics_sample.sv create mode 100644 test/resources/repos/terraform/test_repo/diagnostics_sample.tf create mode 100644 test/resources/repos/toml/test_repo/diagnostics_sample.toml create mode 100644 test/resources/repos/vue/test_repo/src/DiagnosticsSample.vue create mode 100644 test/resources/repos/yaml/test_repo/diagnostics_sample.yaml create mode 100644 test/resources/repos/zig/test_repo/src/diagnostics_sample.zig create mode 100644 test/solidlsp/bash/test_bash_diagnostics.py create mode 100644 test/solidlsp/clojure/test_clojure_diagnostics.py create mode 100644 test/solidlsp/cpp/test_cpp_diagnostics.py create mode 100644 test/solidlsp/crystal/test_crystal_diagnostics.py create mode 100644 test/solidlsp/csharp/test_csharp_diagnostics.py create mode 100644 test/solidlsp/dart/test_dart_diagnostics.py create mode 100644 test/solidlsp/elm/test_elm_diagnostics.py create mode 100644 test/solidlsp/fortran/test_fortran_diagnostics.py create mode 100644 test/solidlsp/go/test_go_diagnostics.py create mode 100644 test/solidlsp/haxe/test_haxe_diagnostics.py create mode 100644 test/solidlsp/hlsl/test_hlsl_diagnostics.py create mode 100644 test/solidlsp/java/test_java_diagnostics.py create mode 100644 test/solidlsp/json_ls/test_json_diagnostics.py create mode 100644 test/solidlsp/kotlin/test_kotlin_diagnostics.py create mode 100644 test/solidlsp/lean4/test_lean4_diagnostics.py create mode 100644 test/solidlsp/ocaml/test_ocaml_diagnostics.py create mode 100644 test/solidlsp/pascal/test_pascal_diagnostics.py create mode 100644 test/solidlsp/php/test_php_diagnostics.py create mode 100644 test/solidlsp/powershell/test_powershell_diagnostics.py create mode 100644 test/solidlsp/python/test_python_diagnostics.py create mode 100644 test/solidlsp/rust/test_rust_diagnostics.py create mode 100644 test/solidlsp/solidity/test_solidity_diagnostics.py create mode 100644 test/solidlsp/systemverilog/test_systemverilog_diagnostics.py create mode 100644 test/solidlsp/terraform/test_terraform_diagnostics.py create mode 100644 test/solidlsp/toml/test_toml_diagnostics.py create mode 100644 test/solidlsp/typescript/test_typescript_diagnostics.py create mode 100644 test/solidlsp/util/diagnostics.py create mode 100644 test/solidlsp/vue/test_vue_diagnostics.py create mode 100644 test/solidlsp/yaml_ls/test_yaml_diagnostics.py diff --git a/.gitignore b/.gitignore index dbc94920..37c1649c 100644 --- a/.gitignore +++ b/.gitignore @@ -79,6 +79,8 @@ downloads/ eggs/ .eggs/ lib/ +!test/resources/repos/dart/test_repo/lib/ +!test/resources/repos/dart/test_repo/lib/diagnostics_sample.dart lib64/ parts/ sdist/ @@ -271,4 +273,4 @@ cabal.project.local* zz-misc/ vue-implementation/ -news/news.json \ No newline at end of file +news/news.json diff --git a/.serena/project.yml b/.serena/project.yml index 84b6fcf8..fbaead68 100644 --- a/.serena/project.yml +++ b/.serena/project.yml @@ -28,6 +28,7 @@ project_name: "serena" languages: - python - typescript +- clojure # whether to use project's .gitignore files to ignore files ignore_all_files_in_gitignore: true diff --git a/scripts/demo_run_tools.py b/scripts/demo_run_tools.py index b0ed063b..4914201c 100644 --- a/scripts/demo_run_tools.py +++ b/scripts/demo_run_tools.py @@ -16,6 +16,7 @@ from serena.tools import ( JetBrainsFindSymbolTool, JetBrainsGetSymbolsOverviewTool, JetBrainsInlineSymbol, + JetBrainsRunInspectionsTool, JetBrainsSafeDeleteTool, SearchForPatternTool, ) @@ -35,12 +36,13 @@ if __name__ == "__main__": overview_tool = agent.get_tool(JetBrainsGetSymbolsOverviewTool) safe_delete_tool = agent.get_tool(JetBrainsSafeDeleteTool) inline_symbol = agent.get_tool(JetBrainsInlineSymbol) + diagnostics_in_file_tool = agent.get_tool(GetDiagnosticsForFileTool) + jb_inspections_tool = agent.get_tool(JetBrainsRunInspectionsTool) result = agent.execute_task( lambda: overview_tool.apply( # name_path_pattern="SerenaAgent", - relative_path="src/serena/agent.py", - depth=2, + relative_path="test/resources/repos/clojure/test_repo/src/test_app/diagnostics_sample.clj", # keep_definition=True, ) ) diff --git a/src/serena/jetbrains/jetbrains_plugin_client.py b/src/serena/jetbrains/jetbrains_plugin_client.py index c662ec76..c7b440ce 100644 --- a/src/serena/jetbrains/jetbrains_plugin_client.py +++ b/src/serena/jetbrains/jetbrains_plugin_client.py @@ -689,7 +689,7 @@ class JetBrainsPluginClient(ToStringMixin): request_data["startLine"] = start_line if end_line is not None: request_data["endLine"] = end_line - return cast(jb.RunInspectionsResponse, self._make_request("POST", "/runInspections", request_data)) + return cast(jb.RunInspectionsResponse, self._make_request("POST", "/runInspectionsOnFile", request_data)) def list_inspections( self, diff --git a/src/solidlsp/language_servers/elm_language_server.py b/src/solidlsp/language_servers/elm_language_server.py index e1960c32..0c6139c1 100644 --- a/src/solidlsp/language_servers/elm_language_server.py +++ b/src/solidlsp/language_servers/elm_language_server.py @@ -32,9 +32,10 @@ class ElmLanguageServer(SolidLanguageServer): Creates an ElmLanguageServer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. """ elm_lsp_executable_path = self._setup_runtime_dependencies(config, solidlsp_settings) + managed_bin_dir = os.path.join(self.ls_resources_dir(solidlsp_settings), "elm-lsp", "node_modules", ".bin") # Resolve ELM_HOME to absolute path if it's set to a relative path - env = {} + env = {"PATH": managed_bin_dir + os.pathsep + os.environ.get("PATH", "")} elm_home = os.environ.get("ELM_HOME") if elm_home: if not os.path.isabs(elm_home): @@ -65,9 +66,15 @@ class ElmLanguageServer(SolidLanguageServer): """ Setup runtime dependencies for Elm Language Server and return the command to start the server. """ - # Check if elm-language-server is already installed globally + elm_config = solidlsp_settings.get_ls_specific_settings(Language.ELM) + elm_language_server_version = elm_config.get("elm_language_server_version", "2.8.0") + elm_compiler_version = elm_config.get("elm_compiler_version", "0.19.1-6") + npm_registry = elm_config.get("npm_registry") + + # Check if elm-language-server and elm are already installed globally system_elm_ls = shutil.which("elm-language-server") - if system_elm_ls: + system_elm = shutil.which("elm") + if system_elm_ls and system_elm: log.info(f"Found system-installed elm-language-server at {system_elm_ls}") return [system_elm_ls, "--stdio"] @@ -76,10 +83,6 @@ class ElmLanguageServer(SolidLanguageServer): assert is_node_installed, "node is not installed or isn't in PATH. Please install NodeJS and try again." is_npm_installed = shutil.which("npm") is not None assert is_npm_installed, "npm is not installed or isn't in PATH. Please install npm and try again." - elm_config = solidlsp_settings.get_ls_specific_settings(Language.ELM) - elm_language_server_version = elm_config.get("elm_language_server_version", "2.8.0") - npm_registry = elm_config.get("npm_registry") - deps = RuntimeDependencyCollection( [ RuntimeDependency( @@ -88,6 +91,12 @@ class ElmLanguageServer(SolidLanguageServer): command=build_npm_install_command("@elm-tooling/elm-language-server", elm_language_server_version, npm_registry), platform_id="any", ), + RuntimeDependency( + id="elm", + description="Elm compiler package", + command=build_npm_install_command("elm", elm_compiler_version, npm_registry), + platform_id="any", + ), ] ) @@ -98,6 +107,10 @@ class ElmLanguageServer(SolidLanguageServer): log.info(f"Elm Language Server executable not found at {elm_ls_executable_path}. Installing...") with LogTime("Installation of Elm language server dependencies", logger=log): deps.install(elm_ls_dir) + elif not system_elm: + log.info("Elm compiler not found on PATH. Installing a managed Elm compiler...") + with LogTime("Installation of Elm compiler dependency", logger=log): + deps.install(elm_ls_dir) if not os.path.exists(elm_ls_executable_path): raise FileNotFoundError( @@ -128,6 +141,7 @@ class ElmLanguageServer(SolidLanguageServer): "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, "codeAction": {"dynamicRegistration": True}, "rename": {"dynamicRegistration": True}, + "publishDiagnostics": {"relatedInformation": True}, }, "workspace": { "workspaceFolders": True, diff --git a/src/solidlsp/language_servers/fortran_language_server.py b/src/solidlsp/language_servers/fortran_language_server.py index 2cd6b099..a1d5d56d 100644 --- a/src/solidlsp/language_servers/fortran_language_server.py +++ b/src/solidlsp/language_servers/fortran_language_server.py @@ -220,6 +220,7 @@ class FortranLanguageServer(SolidLanguageServer): "formatting": {"dynamicRegistration": True}, "rangeFormatting": {"dynamicRegistration": True}, "codeAction": {"dynamicRegistration": True}, + "publishDiagnostics": {"relatedInformation": True}, }, "workspace": { "workspaceFolders": True, diff --git a/src/solidlsp/language_servers/lean4_language_server.py b/src/solidlsp/language_servers/lean4_language_server.py index 463e4992..961a65b6 100644 --- a/src/solidlsp/language_servers/lean4_language_server.py +++ b/src/solidlsp/language_servers/lean4_language_server.py @@ -124,6 +124,7 @@ class Lean4LanguageServer(SolidLanguageServer): "documentationFormat": ["markdown", "plaintext"], }, }, + "publishDiagnostics": {"relatedInformation": True}, }, "workspace": { "workspaceFolders": True, diff --git a/src/solidlsp/language_servers/pascal_server.py b/src/solidlsp/language_servers/pascal_server.py index 9de2a926..1f2b7de7 100644 --- a/src/solidlsp/language_servers/pascal_server.py +++ b/src/solidlsp/language_servers/pascal_server.py @@ -777,6 +777,14 @@ class PascalLanguageServer(SolidLanguageServer): if value: initialization_options[var] = value + initialization_options.update( + { + "checkSyntax": True, + "publishDiagnostics": True, + "showSyntaxErrors": True, + } + ) + initialize_params = { "locale": "en", "capabilities": { @@ -831,6 +839,7 @@ class PascalLanguageServer(SolidLanguageServer): }, "formatting": {"dynamicRegistration": True}, "rangeFormatting": {"dynamicRegistration": True}, + "publishDiagnostics": {"relatedInformation": True}, }, "workspace": { "workspaceFolders": True, diff --git a/src/solidlsp/language_servers/solidity_language_server.py b/src/solidlsp/language_servers/solidity_language_server.py index 223a6683..1e7ccd2e 100644 --- a/src/solidlsp/language_servers/solidity_language_server.py +++ b/src/solidlsp/language_servers/solidity_language_server.py @@ -148,6 +148,7 @@ class SolidityLanguageServer(SolidLanguageServer): "dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"], # type: ignore[list-item] }, + "publishDiagnostics": {"relatedInformation": True}, }, "workspace": { "workspaceFolders": True, diff --git a/src/solidlsp/language_servers/typescript_language_server.py b/src/solidlsp/language_servers/typescript_language_server.py index 32e9ac22..8b1c04e8 100644 --- a/src/solidlsp/language_servers/typescript_language_server.py +++ b/src/solidlsp/language_servers/typescript_language_server.py @@ -89,7 +89,10 @@ class TypeScriptLanguageServer(SolidLanguageServer): self.server_ready = threading.Event() self.initialize_searcher_command_available = threading.Event() - # Progress tracking for $/progress notifications (project indexing, etc.) + # tracking asynchronous diagnostics publication + self._published_diagnostics_timeout = 5.0 + + # tracking project indexing progress self._progress_lock = threading.Lock() self._active_progress_tokens: set[str] = set() self._indexing_complete = threading.Event() @@ -243,9 +246,11 @@ class TypeScriptLanguageServer(SolidLanguageServer): "signatureHelp": {"dynamicRegistration": True}, "codeAction": {"dynamicRegistration": True}, "rename": {"dynamicRegistration": True, "prepareSupport": True}, + "publishDiagnostics": {"relatedInformation": True}, }, "workspace": { "workspaceFolders": True, + "configuration": True, "didChangeConfiguration": {"dynamicRegistration": True}, "symbol": {"dynamicRegistration": True}, }, @@ -293,6 +298,10 @@ class TypeScriptLanguageServer(SolidLanguageServer): def execute_client_command_handler(params: dict) -> list: return [] + def configuration_handler(params: dict) -> list: + items = params.get("items", []) + return [{} for _ in items] + def do_nothing(params: dict) -> None: return @@ -352,6 +361,7 @@ class TypeScriptLanguageServer(SolidLanguageServer): self._indexing_complete.set() self.server.on_request("client/registerCapability", register_capability_handler) + self.server.on_request("workspace/configuration", configuration_handler) self.server.on_notification("window/logMessage", window_log_message) self.server.on_request("workspace/executeClientCommand", execute_client_command_handler) self.server.on_request("window/workDoneProgress/create", work_done_progress_create) @@ -397,6 +407,20 @@ class TypeScriptLanguageServer(SolidLanguageServer): self.INDEXING_PROGRESS_TIMEOUT, ) + @override + def _get_published_diagnostics_uri(self, request_uri: str) -> str: + if os.name != "nt" or not request_uri.startswith("file:///"): + return request_uri + + path_part = request_uri[len("file:///") :] + if len(path_part) >= 2 and path_part[0].isalpha() and path_part[1] == ":": + return f"file:///{path_part[0].lower()}%3A{path_part[2:]}" + return request_uri + + @override + def _get_published_diagnostics_wait_timeout(self, pull_diagnostics_failed: bool) -> float: + return self._published_diagnostics_timeout + @override def _get_wait_time_for_cross_file_referencing(self) -> float: return 2 diff --git a/src/solidlsp/language_servers/vue_language_server.py b/src/solidlsp/language_servers/vue_language_server.py index 06123623..f99056ae 100644 --- a/src/solidlsp/language_servers/vue_language_server.py +++ b/src/solidlsp/language_servers/vue_language_server.py @@ -426,6 +426,18 @@ class VueLanguageServer(SolidLanguageServer): with self._ts_server.open_file(relative_file_path): return self._ts_server.request_rename_symbol_edit(relative_file_path, line, column, new_name) + @override + def request_text_document_diagnostics( + self, + relative_file_path: str, + start_line: int = 0, + end_line: int = -1, + min_severity: int = 4, + ) -> list[ls_types.Diagnostic]: + self._ensure_ls_operational() + assert self._ts_server is not None + return self._ts_server.request_text_document_diagnostics(relative_file_path, start_line, end_line, min_severity) + def _forward_edit_to_ts_server_if_needed(self, relative_file_path: str, edit_fn: Callable[[], object]) -> None: """ Calls ``edit_fn`` on the TypeScript server if the file is open there. @@ -598,6 +610,7 @@ class VueLanguageServer(SolidLanguageServer): "signatureHelp": {"dynamicRegistration": True}, "codeAction": {"dynamicRegistration": True}, "rename": {"dynamicRegistration": True, "prepareSupport": True}, + "publishDiagnostics": {"relatedInformation": True}, }, "workspace": { "workspaceFolders": True, diff --git a/src/solidlsp/language_servers/zls.py b/src/solidlsp/language_servers/zls.py index 368f57eb..cb5652fc 100644 --- a/src/solidlsp/language_servers/zls.py +++ b/src/solidlsp/language_servers/zls.py @@ -132,6 +132,7 @@ class ZigLanguageServer(SolidLanguageServer): "dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"], }, + "publishDiagnostics": {"relatedInformation": True}, }, "workspace": { "workspaceFolders": True, diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index 546c6a9f..4ea04694 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -13,7 +13,7 @@ from collections.abc import Hashable, Iterator from contextlib import contextmanager from copy import copy from pathlib import Path, PurePath -from time import perf_counter, sleep +from time import monotonic, perf_counter, sleep from typing import Any, Self, Union, cast import pathspec @@ -754,21 +754,77 @@ class SolidLanguageServer(ABC): :return: the published diagnostics, or ``None`` if no diagnostics are available. """ uri = self._validate_text_document_diagnostics_request(relative_file_path, start_line, end_line, min_severity) + published_uri = self._get_published_diagnostics_uri(uri) diagnostics: list[ls_types.Diagnostic] | None = None # keeping the document open with self.open_file(relative_file_path): - diagnostics = self._wait_for_published_diagnostics(uri=uri, after_generation=after_generation, timeout=timeout) - - # falling back to cached diagnostics - if diagnostics is None and allow_cached: - diagnostics = self._get_cached_published_diagnostics(uri) + diagnostics = self._wait_for_relevant_published_diagnostics( + uri=published_uri, + after_generation=after_generation, + timeout=timeout, + allow_cached=allow_cached, + ) if diagnostics is None: return None return self._filter_diagnostics(diagnostics, start_line, end_line, min_severity) + def _get_published_diagnostics_uri(self, request_uri: str) -> str: + """ + Gets the URI under which published diagnostics should be looked up. + """ + return request_uri + + def _get_published_diagnostics_wait_timeout(self, pull_diagnostics_failed: bool) -> float: + """ + Gets the timeout for waiting on published diagnostics after a diagnostics request. + """ + return 2.5 + + def _accept_published_diagnostics(self, diagnostics: list[ls_types.Diagnostic]) -> bool: + """ + Determines whether a published diagnostics payload should satisfy the current wait. + """ + return bool(diagnostics) + + def _wait_for_relevant_published_diagnostics( + self, + uri: str, + after_generation: int, + timeout: float, + allow_cached: bool = True, + ) -> list[ls_types.Diagnostic] | None: + """ + Waits for a published diagnostics payload that is relevant for the current request. + """ + deadline = monotonic() + timeout + current_after_generation = after_generation + + while True: + remaining_timeout = deadline - monotonic() + if remaining_timeout <= 0: + break + + diagnostics = self._wait_for_published_diagnostics( + uri=uri, + after_generation=current_after_generation, + timeout=remaining_timeout, + ) + if diagnostics is None: + break + if self._accept_published_diagnostics(diagnostics): + return diagnostics + current_after_generation = self._get_published_diagnostics_generation(uri) + + if allow_cached: + diagnostics = self._get_cached_published_diagnostics(uri) + if diagnostics is not None and self._accept_published_diagnostics(diagnostics): + return diagnostics + + return None + def request_text_document_diagnostics( self, relative_file_path: str, @@ -789,7 +845,8 @@ class SolidLanguageServer(ABC): :return: A list of diagnostics for the file """ uri = self._validate_text_document_diagnostics_request(relative_file_path, start_line, end_line, min_severity) - diagnostics_before_request = self._get_published_diagnostics_generation(uri) + published_uri = self._get_published_diagnostics_uri(uri) + diagnostics_before_request = self._get_published_diagnostics_generation(published_uri) ret: list[ls_types.Diagnostic] | None = None pull_diagnostics_failed = False @@ -825,13 +882,11 @@ class SolidLanguageServer(ABC): ret.append(ls_types.Diagnostic(**new_item)) if not ret: - published_diagnostics = self._wait_for_published_diagnostics( - uri=uri, + published_diagnostics = self._wait_for_relevant_published_diagnostics( + uri=published_uri, after_generation=diagnostics_before_request, - timeout=2.5 if pull_diagnostics_failed else 0.5, + timeout=self._get_published_diagnostics_wait_timeout(pull_diagnostics_failed), ) - if published_diagnostics is None: - published_diagnostics = self._get_cached_published_diagnostics(uri) if published_diagnostics is not None: ret = published_diagnostics diff --git a/test/resources/repos/bash/test_repo/diagnostics_sample.sh b/test/resources/repos/bash/test_repo/diagnostics_sample.sh new file mode 100644 index 00000000..050a8a99 --- /dev/null +++ b/test/resources/repos/bash/test_repo/diagnostics_sample.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +if then + echo "$missingGreeting" +fi diff --git a/test/resources/repos/crystal/test_repo/src/diagnostics_sample.cr b/test/resources/repos/crystal/test_repo/src/diagnostics_sample.cr new file mode 100644 index 00000000..b408a202 --- /dev/null +++ b/test/resources/repos/crystal/test_repo/src/diagnostics_sample.cr @@ -0,0 +1,5 @@ +def broken_factory : String + missingGreeting +end + +broken_consumer = missingConsumerValue diff --git a/test/resources/repos/dart/test_repo/lib/diagnostics_sample.dart b/test/resources/repos/dart/test_repo/lib/diagnostics_sample.dart new file mode 100644 index 00000000..d309d9fc --- /dev/null +++ b/test/resources/repos/dart/test_repo/lib/diagnostics_sample.dart @@ -0,0 +1,3 @@ +String brokenFactory() { + return 'broken' +} diff --git a/test/resources/repos/elixir/test_repo/lib/diagnostics_sample.ex b/test/resources/repos/elixir/test_repo/lib/diagnostics_sample.ex new file mode 100644 index 00000000..af510437 --- /dev/null +++ b/test/resources/repos/elixir/test_repo/lib/diagnostics_sample.ex @@ -0,0 +1,10 @@ +defmodule DiagnosticsSample do + def broken_factory do + missing_greeting + end + + def broken_consumer do + value = broken_factory() + {value, missing_consumer_value} + end +end diff --git a/test/resources/repos/elm/test_repo/DiagnosticsSample.elm b/test/resources/repos/elm/test_repo/DiagnosticsSample.elm new file mode 100644 index 00000000..2880fa7b --- /dev/null +++ b/test/resources/repos/elm/test_repo/DiagnosticsSample.elm @@ -0,0 +1,5 @@ +module DiagnosticsSample exposing (brokenFactory) + +brokenFactory : String +brokenFactory = + "unterminated diff --git a/test/resources/repos/erlang/test_repo/src/diagnostics_sample.erl b/test/resources/repos/erlang/test_repo/src/diagnostics_sample.erl new file mode 100644 index 00000000..edb18776 --- /dev/null +++ b/test/resources/repos/erlang/test_repo/src/diagnostics_sample.erl @@ -0,0 +1,5 @@ +-module(diagnostics_sample). +-export([broken_factory/0, broken_consumer/0]). + +broken_factory() -> MissingGreeting. +broken_consumer() -> MissingConsumerValue. diff --git a/test/resources/repos/fortran/test_repo/diagnostics_sample.f90 b/test/resources/repos/fortran/test_repo/diagnostics_sample.f90 new file mode 100644 index 00000000..2c23e5df --- /dev/null +++ b/test/resources/repos/fortran/test_repo/diagnostics_sample.f90 @@ -0,0 +1,5 @@ +program diagnostics_sample + implicit none + integer :: duplicated_value + real :: duplicated_value +end program diagnostics_sample diff --git a/test/resources/repos/groovy/test_repo/src/main/groovy/com/example/DiagnosticsSample.groovy b/test/resources/repos/groovy/test_repo/src/main/groovy/com/example/DiagnosticsSample.groovy new file mode 100644 index 00000000..a176203e --- /dev/null +++ b/test/resources/repos/groovy/test_repo/src/main/groovy/com/example/DiagnosticsSample.groovy @@ -0,0 +1,13 @@ +package com.example + +class DiagnosticsSample { + static String brokenFactory() { + missingGreeting + } + + static void brokenConsumer() { + def value = brokenFactory() + println(value) + println(missingConsumerValue) + } +} diff --git a/test/resources/repos/haskell/test_repo/src/DiagnosticsSample.hs b/test/resources/repos/haskell/test_repo/src/DiagnosticsSample.hs new file mode 100644 index 00000000..e5777b1f --- /dev/null +++ b/test/resources/repos/haskell/test_repo/src/DiagnosticsSample.hs @@ -0,0 +1,7 @@ +module DiagnosticsSample where + +brokenFactory :: String +brokenFactory = missingGreeting + +brokenConsumer :: String +brokenConsumer = missingConsumerValue diff --git a/test/resources/repos/haxe/test_repo/src/DiagnosticsSample.hx b/test/resources/repos/haxe/test_repo/src/DiagnosticsSample.hx new file mode 100644 index 00000000..3b2e2c29 --- /dev/null +++ b/test/resources/repos/haxe/test_repo/src/DiagnosticsSample.hx @@ -0,0 +1,10 @@ +class DiagnosticsSample { + static function brokenFactory():String { + return missingGreeting; + } + + static function brokenConsumer():Void { + trace(brokenFactory()); + trace(missingConsumerValue); + } +} diff --git a/test/resources/repos/hlsl/test_repo/diagnostics_sample.hlsl b/test/resources/repos/hlsl/test_repo/diagnostics_sample.hlsl new file mode 100644 index 00000000..ffc8075c --- /dev/null +++ b/test/resources/repos/hlsl/test_repo/diagnostics_sample.hlsl @@ -0,0 +1,3 @@ +float4 main() : SV_Target { + return missingGreeting; +} diff --git a/test/resources/repos/json/test_repo/diagnostics_sample.json b/test/resources/repos/json/test_repo/diagnostics_sample.json new file mode 100644 index 00000000..0cc75efd --- /dev/null +++ b/test/resources/repos/json/test_repo/diagnostics_sample.json @@ -0,0 +1,4 @@ +{ + "name": "diagnostics", + "value": +} diff --git a/test/resources/repos/julia/test_repo/diagnostics_sample.jl b/test/resources/repos/julia/test_repo/diagnostics_sample.jl new file mode 100644 index 00000000..ef7e4a51 --- /dev/null +++ b/test/resources/repos/julia/test_repo/diagnostics_sample.jl @@ -0,0 +1,3 @@ +function broken_factory( + value = 1 +end diff --git a/test/resources/repos/lean4/test_repo/DiagnosticsSample.lean b/test/resources/repos/lean4/test_repo/DiagnosticsSample.lean index a2c570ec..169754f7 100644 --- a/test/resources/repos/lean4/test_repo/DiagnosticsSample.lean +++ b/test/resources/repos/lean4/test_repo/DiagnosticsSample.lean @@ -1,6 +1 @@ def brokenFactory : Nat := - missingGreeting - -def brokenConsumer : Nat := - let value := brokenFactory - value + missingConsumerValue diff --git a/test/resources/repos/matlab/test_repo/diagnostics_sample.m b/test/resources/repos/matlab/test_repo/diagnostics_sample.m new file mode 100644 index 00000000..b0f87a62 --- /dev/null +++ b/test/resources/repos/matlab/test_repo/diagnostics_sample.m @@ -0,0 +1,3 @@ +function value = diagnostics_sample() +value = missingGreeting; +end diff --git a/test/resources/repos/nix/test_repo/diagnostics_sample.nix b/test/resources/repos/nix/test_repo/diagnostics_sample.nix new file mode 100644 index 00000000..320a6ab8 --- /dev/null +++ b/test/resources/repos/nix/test_repo/diagnostics_sample.nix @@ -0,0 +1,4 @@ +{ + greeting = missingGreeting; + consumer = missingConsumerValue; +} diff --git a/test/resources/repos/ocaml/test_repo/lib/diagnostics_sample.ml b/test/resources/repos/ocaml/test_repo/lib/diagnostics_sample.ml new file mode 100644 index 00000000..c23ccefe --- /dev/null +++ b/test/resources/repos/ocaml/test_repo/lib/diagnostics_sample.ml @@ -0,0 +1 @@ +let broken_factory ( diff --git a/test/resources/repos/ocaml/test_repo/lib/dune b/test/resources/repos/ocaml/test_repo/lib/dune index fe41978f..aba011b1 100644 --- a/test/resources/repos/ocaml/test_repo/lib/dune +++ b/test/resources/repos/ocaml/test_repo/lib/dune @@ -1,3 +1,4 @@ (library (public_name test_repo) - (name test_repo)) \ No newline at end of file + (name test_repo) + (modules test_repo)) diff --git a/test/resources/repos/pascal/test_repo/diagnostics_sample.pas b/test/resources/repos/pascal/test_repo/diagnostics_sample.pas new file mode 100644 index 00000000..7a3bf84b --- /dev/null +++ b/test/resources/repos/pascal/test_repo/diagnostics_sample.pas @@ -0,0 +1,4 @@ +program DiagnosticsSample; +begin + writeln( +end. diff --git a/test/resources/repos/perl/test_repo/diagnostics_sample.pl b/test/resources/repos/perl/test_repo/diagnostics_sample.pl new file mode 100644 index 00000000..4d78b686 --- /dev/null +++ b/test/resources/repos/perl/test_repo/diagnostics_sample.pl @@ -0,0 +1,5 @@ +use strict; +use warnings; + +print $missingGreeting; +print $missingConsumerValue; diff --git a/test/resources/repos/powershell/test_repo/diagnostics_sample.ps1 b/test/resources/repos/powershell/test_repo/diagnostics_sample.ps1 index d06165f2..c5ce7600 100644 --- a/test/resources/repos/powershell/test_repo/diagnostics_sample.ps1 +++ b/test/resources/repos/powershell/test_repo/diagnostics_sample.ps1 @@ -1,9 +1,4 @@ function Invoke-BrokenFactory { - Invoke-MissingGreeting -} - -function Invoke-BrokenConsumer { - $value = Invoke-BrokenFactory - Write-Output $value - Invoke-MissingConsumerValue + if ($true) { + Write-Output "broken" } diff --git a/test/resources/repos/scala/test_repo/src/main/scala/com/example/DiagnosticsSample.scala b/test/resources/repos/scala/test_repo/src/main/scala/com/example/DiagnosticsSample.scala new file mode 100644 index 00000000..ca9d929b --- /dev/null +++ b/test/resources/repos/scala/test_repo/src/main/scala/com/example/DiagnosticsSample.scala @@ -0,0 +1,9 @@ +package com.example + +object DiagnosticsSample { + def brokenFactory(): String = missingGreeting + def brokenConsumer(): Unit = { + println(brokenFactory()) + println(missingConsumerValue) + } +} diff --git a/test/resources/repos/solidity/test_repo/contracts/DiagnosticsSample.sol b/test/resources/repos/solidity/test_repo/contracts/DiagnosticsSample.sol new file mode 100644 index 00000000..7b2a0892 --- /dev/null +++ b/test/resources/repos/solidity/test_repo/contracts/DiagnosticsSample.sol @@ -0,0 +1,7 @@ +pragma solidity ^0.8.0; + +contract DiagnosticsSample { + function brokenFactory() pure returns (string memory) { + return "broken"; + } +} diff --git a/test/resources/repos/swift/test_repo/Package.swift b/test/resources/repos/swift/test_repo/Package.swift index 13a02704..5e9b7cff 100644 --- a/test/resources/repos/swift/test_repo/Package.swift +++ b/test/resources/repos/swift/test_repo/Package.swift @@ -11,6 +11,7 @@ let package = Package( targets: [ .target( name: "test_repo", - dependencies: []), + dependencies: [], + path: "src"), ] -) \ No newline at end of file +) diff --git a/test/resources/repos/swift/test_repo/src/diagnostics_sample.swift b/test/resources/repos/swift/test_repo/src/diagnostics_sample.swift new file mode 100644 index 00000000..b70b2c68 --- /dev/null +++ b/test/resources/repos/swift/test_repo/src/diagnostics_sample.swift @@ -0,0 +1,3 @@ +func brokenFactory() -> String { + return +} diff --git a/test/resources/repos/systemverilog/test_repo/diagnostics_sample.sv b/test/resources/repos/systemverilog/test_repo/diagnostics_sample.sv new file mode 100644 index 00000000..b5364262 --- /dev/null +++ b/test/resources/repos/systemverilog/test_repo/diagnostics_sample.sv @@ -0,0 +1,5 @@ +module diagnostics_sample; + initial begin + if ( + end +endmodule diff --git a/test/resources/repos/terraform/test_repo/diagnostics_sample.tf b/test/resources/repos/terraform/test_repo/diagnostics_sample.tf new file mode 100644 index 00000000..15de22c0 --- /dev/null +++ b/test/resources/repos/terraform/test_repo/diagnostics_sample.tf @@ -0,0 +1,4 @@ +resource "aws_instance" "broken" { + ami = + instance_type = "t2.micro" +} diff --git a/test/resources/repos/toml/test_repo/diagnostics_sample.toml b/test/resources/repos/toml/test_repo/diagnostics_sample.toml new file mode 100644 index 00000000..ed4ae4c1 --- /dev/null +++ b/test/resources/repos/toml/test_repo/diagnostics_sample.toml @@ -0,0 +1,2 @@ +[diagnostics] +value = diff --git a/test/resources/repos/vue/test_repo/src/DiagnosticsSample.vue b/test/resources/repos/vue/test_repo/src/DiagnosticsSample.vue new file mode 100644 index 00000000..17bd62e3 --- /dev/null +++ b/test/resources/repos/vue/test_repo/src/DiagnosticsSample.vue @@ -0,0 +1,3 @@ + diff --git a/test/resources/repos/yaml/test_repo/diagnostics_sample.yaml b/test/resources/repos/yaml/test_repo/diagnostics_sample.yaml new file mode 100644 index 00000000..c07e469d --- /dev/null +++ b/test/resources/repos/yaml/test_repo/diagnostics_sample.yaml @@ -0,0 +1,5 @@ +name: diagnostics +value: + - good + - + broken: [ diff --git a/test/resources/repos/zig/test_repo/build.zig b/test/resources/repos/zig/test_repo/build.zig index da7317d0..c9c1ff5a 100644 --- a/test/resources/repos/zig/test_repo/build.zig +++ b/test/resources/repos/zig/test_repo/build.zig @@ -28,4 +28,4 @@ pub fn build(b: *std.Build) void { const run_lib_tests = b.addRunArtifact(lib_tests); const test_step = b.step("test", "Run unit tests"); test_step.dependOn(&run_lib_tests.step); -} \ No newline at end of file +} diff --git a/test/resources/repos/zig/test_repo/src/diagnostics_sample.zig b/test/resources/repos/zig/test_repo/src/diagnostics_sample.zig new file mode 100644 index 00000000..844d6f14 --- /dev/null +++ b/test/resources/repos/zig/test_repo/src/diagnostics_sample.zig @@ -0,0 +1,3 @@ +pub fn brokenFactory() void { + const value = +} diff --git a/test/resources/repos/zig/test_repo/src/main.zig b/test/resources/repos/zig/test_repo/src/main.zig index 326b2cd0..156adabd 100644 --- a/test/resources/repos/zig/test_repo/src/main.zig +++ b/test/resources/repos/zig/test_repo/src/main.zig @@ -29,4 +29,4 @@ pub fn main() !void { pub fn greeting(name: []const u8) []const u8 { return std.fmt.allocPrint(std.heap.page_allocator, "Hello, {s}!", .{name}) catch "Hello!"; -} \ No newline at end of file +} diff --git a/test/serena/__snapshots__/test_symbol_editing.ambr b/test/serena/__snapshots__/test_symbol_editing.ambr index 8fd3a73b..ec23cf33 100644 --- a/test/serena/__snapshots__/test_symbol_editing.ambr +++ b/test/serena/__snapshots__/test_symbol_editing.ambr @@ -68,13 +68,13 @@ # name: test_delete_symbol[test_case1] ''' import { ConsoleGreeter, Greeter } from "./formatters"; - - + + export function helperFunction() { const demo = new DemoClass(42); demo.printValue(); - + const greeter: Greeter = new ConsoleGreeter(); console.log(greeter.formatGreeting("World")); } @@ -521,7 +521,7 @@ # name: test_insert_in_rel_to_symbol[test_case1-after] ''' import { ConsoleGreeter, Greeter } from "./formatters"; - + export class DemoClass { value: number; constructor(value: number) { @@ -539,7 +539,7 @@ export function helperFunction() { const demo = new DemoClass(42); demo.printValue(); - + const greeter: Greeter = new ConsoleGreeter(); console.log(greeter.formatGreeting("World")); } @@ -555,7 +555,7 @@ # name: test_insert_in_rel_to_symbol[test_case1-before] ''' import { ConsoleGreeter, Greeter } from "./formatters"; - + function newFunctionAfterClass(): void { console.log("This function is after DemoClass."); } @@ -573,7 +573,7 @@ export function helperFunction() { const demo = new DemoClass(42); demo.printValue(); - + const greeter: Greeter = new ConsoleGreeter(); console.log(greeter.formatGreeting("World")); } @@ -589,7 +589,7 @@ # name: test_insert_in_rel_to_symbol[test_case2-after] ''' import { ConsoleGreeter, Greeter } from "./formatters"; - + export class DemoClass { value: number; constructor(value: number) { @@ -603,7 +603,7 @@ export function helperFunction() { const demo = new DemoClass(42); demo.printValue(); - + const greeter: Greeter = new ConsoleGreeter(); console.log(greeter.formatGreeting("World")); } @@ -623,7 +623,7 @@ # name: test_insert_in_rel_to_symbol[test_case2-before] ''' import { ConsoleGreeter, Greeter } from "./formatters"; - + export class DemoClass { value: number; constructor(value: number) { @@ -641,7 +641,7 @@ export function helperFunction() { const demo = new DemoClass(42); demo.printValue(); - + const greeter: Greeter = new ConsoleGreeter(); console.log(greeter.formatGreeting("World")); } @@ -1712,7 +1712,7 @@ # name: test_replace_body[test_case1] ''' import { ConsoleGreeter, Greeter } from "./formatters"; - + export class DemoClass { value: number; constructor(value: number) { @@ -1727,7 +1727,7 @@ export function helperFunction() { const demo = new DemoClass(42); demo.printValue(); - + const greeter: Greeter = new ConsoleGreeter(); console.log(greeter.formatGreeting("World")); } diff --git a/test/solidlsp/bash/test_bash_diagnostics.py b/test/solidlsp/bash/test_bash_diagnostics.py new file mode 100644 index 00000000..bd1cc494 --- /dev/null +++ b/test/solidlsp/bash/test_bash_diagnostics.py @@ -0,0 +1,17 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.bash +class TestBashDiagnostics: + @pytest.mark.parametrize("language_server", [Language.BASH], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "diagnostics_sample.sh", + (), + min_count=1, + ) diff --git a/test/solidlsp/clojure/__init__.py b/test/solidlsp/clojure/__init__.py index 5e8e1c93..f7d9dad6 100644 --- a/test/solidlsp/clojure/__init__.py +++ b/test/solidlsp/clojure/__init__.py @@ -13,8 +13,8 @@ def _test_clojure_cli() -> bool: CLI_FAIL = _test_clojure_cli() TEST_APP_PATH = Path("src") / "test_app" -CORE_PATH = str(TEST_APP_PATH / "core.clj").replace("\\", "/") -UTILS_PATH = str(TEST_APP_PATH / "utils.clj").replace("\\", "/") +CORE_PATH = str(TEST_APP_PATH / "core.clj") +UTILS_PATH = str(TEST_APP_PATH / "utils.clj") def is_clojure_cli_available() -> bool: diff --git a/test/solidlsp/clojure/test_clojure_diagnostics.py b/test/solidlsp/clojure/test_clojure_diagnostics.py new file mode 100644 index 00000000..a7bff96b --- /dev/null +++ b/test/solidlsp/clojure/test_clojure_diagnostics.py @@ -0,0 +1,19 @@ +import os + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.clojure +class TestClojureDiagnostics: + @pytest.mark.parametrize("language_server", [Language.CLOJURE], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + os.path.join("src", "test_app", "diagnostics_sample.clj"), + ("missing-greeting", "missing-consumer-value"), + min_count=2, + ) diff --git a/test/solidlsp/conftest.py b/test/solidlsp/conftest.py index 08bb6a01..ee563757 100644 --- a/test/solidlsp/conftest.py +++ b/test/solidlsp/conftest.py @@ -5,6 +5,12 @@ from solidlsp.ls_types import SymbolKind, UnifiedSymbolInformation PYTHON_BACKEND_LANGUAGES = [Language.PYTHON, Language.PYTHON_TY] +def is_diagnostics_test_file(relative_path: str) -> bool: + normalized_path = relative_path.replace("\\", "/") + filename = normalized_path.rsplit("/", 1)[-1].lower() + return filename.startswith(("diagnosticssample.", "diagnostics_sample.")) + + def has_malformed_name( symbol: UnifiedSymbolInformation, whitespace_allowed: bool = False, @@ -36,6 +42,9 @@ def request_all_symbols(language_server: SolidLanguageServer) -> list[UnifiedSym result: list[UnifiedSymbolInformation] = [] def visit(symbol: UnifiedSymbolInformation) -> None: + relative_path = symbol.get("location", {}).get("relativePath", "") + if relative_path and is_diagnostics_test_file(relative_path): + return result.append(symbol) for child in symbol.get("children", []): visit(child) diff --git a/test/solidlsp/cpp/test_cpp_diagnostics.py b/test/solidlsp/cpp/test_cpp_diagnostics.py new file mode 100644 index 00000000..20a58368 --- /dev/null +++ b/test/solidlsp/cpp/test_cpp_diagnostics.py @@ -0,0 +1,17 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.cpp +class TestCppDiagnostics: + @pytest.mark.parametrize("language_server", [Language.CPP], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "diagnostics_sample.cpp", + (), + min_count=1, + ) diff --git a/test/solidlsp/crystal/test_crystal_diagnostics.py b/test/solidlsp/crystal/test_crystal_diagnostics.py new file mode 100644 index 00000000..e650adc1 --- /dev/null +++ b/test/solidlsp/crystal/test_crystal_diagnostics.py @@ -0,0 +1,23 @@ +import shutil + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + +pytestmark = [ + pytest.mark.crystal, + pytest.mark.skipif(shutil.which("crystalline") is None, reason="Crystalline is not installed"), +] + + +class TestCrystalDiagnostics: + @pytest.mark.parametrize("language_server", [Language.CRYSTAL], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "src/diagnostics_sample.cr", + (), + min_count=1, + ) diff --git a/test/solidlsp/csharp/test_csharp_diagnostics.py b/test/solidlsp/csharp/test_csharp_diagnostics.py new file mode 100644 index 00000000..de32fd1d --- /dev/null +++ b/test/solidlsp/csharp/test_csharp_diagnostics.py @@ -0,0 +1,17 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.csharp +class TestCsharpDiagnostics: + @pytest.mark.parametrize("language_server", [Language.CSHARP], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "DiagnosticsSample.cs", + (), + min_count=1, + ) diff --git a/test/solidlsp/dart/test_dart_diagnostics.py b/test/solidlsp/dart/test_dart_diagnostics.py new file mode 100644 index 00000000..ddc5e398 --- /dev/null +++ b/test/solidlsp/dart/test_dart_diagnostics.py @@ -0,0 +1,17 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.dart +class TestDartDiagnostics: + @pytest.mark.parametrize("language_server", [Language.DART], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "lib/diagnostics_sample.dart", + (), + min_count=1, + ) diff --git a/test/solidlsp/elixir/test_elixir_basic.py b/test/solidlsp/elixir/test_elixir_basic.py index 11b983ca..7c44abc4 100644 --- a/test/solidlsp/elixir/test_elixir_basic.py +++ b/test/solidlsp/elixir/test_elixir_basic.py @@ -13,6 +13,7 @@ from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_types import SymbolKind from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols +from test.solidlsp.util.diagnostics import assert_file_diagnostics from . import EXPERT_UNAVAILABLE, EXPERT_UNAVAILABLE_REASON @@ -138,3 +139,12 @@ class TestElixirBasic: f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", pytrace=False, ) + + @pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "lib/diagnostics_sample.ex", + (), + min_count=1, + ) diff --git a/test/solidlsp/elm/test_elm_diagnostics.py b/test/solidlsp/elm/test_elm_diagnostics.py new file mode 100644 index 00000000..744201a2 --- /dev/null +++ b/test/solidlsp/elm/test_elm_diagnostics.py @@ -0,0 +1,20 @@ +import shutil + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.elm +@pytest.mark.skipif(shutil.which("node") is None or shutil.which("npm") is None, reason="Elm diagnostics require Node.js and npm") +class TestElmDiagnostics: + @pytest.mark.parametrize("language_server", [Language.ELM], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "DiagnosticsSample.elm", + (), + min_count=1, + ) diff --git a/test/solidlsp/erlang/test_erlang_basic.py b/test/solidlsp/erlang/test_erlang_basic.py index a1dba06a..dc39535b 100644 --- a/test/solidlsp/erlang/test_erlang_basic.py +++ b/test/solidlsp/erlang/test_erlang_basic.py @@ -10,6 +10,7 @@ import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols +from test.solidlsp.util.diagnostics import assert_file_diagnostics from . import ERLANG_LS_UNAVAILABLE, ERLANG_LS_UNAVAILABLE_REASON @@ -55,3 +56,12 @@ class TestErlangLanguageServerBasics: f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", pytrace=False, ) + + @pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "src/diagnostics_sample.erl", + (), + min_count=1, + ) diff --git a/test/solidlsp/fortran/test_fortran_diagnostics.py b/test/solidlsp/fortran/test_fortran_diagnostics.py new file mode 100644 index 00000000..f607cd0b --- /dev/null +++ b/test/solidlsp/fortran/test_fortran_diagnostics.py @@ -0,0 +1,17 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.fortran +class TestFortranDiagnostics: + @pytest.mark.parametrize("language_server", [Language.FORTRAN], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "diagnostics_sample.f90", + (), + min_count=1, + ) diff --git a/test/solidlsp/fsharp/test_fsharp_basic.py b/test/solidlsp/fsharp/test_fsharp_basic.py index f58ec0b1..f4f49ebc 100644 --- a/test/solidlsp/fsharp/test_fsharp_basic.py +++ b/test/solidlsp/fsharp/test_fsharp_basic.py @@ -9,6 +9,7 @@ from solidlsp.ls_config import Language from solidlsp.ls_utils import SymbolUtils from test.conftest import find_identifier_position, get_repo_path, is_ci, language_has_verified_implementation_support from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols +from test.solidlsp.util.diagnostics import assert_file_diagnostics # Currently, most F# tests fail, there seems to be a regression or instability. @@ -196,21 +197,13 @@ class TestFSharpLanguageServer: assert isinstance(result["value"], list), "Completions should be a list" @pytest.mark.parametrize("language_server", [Language.FSHARP], indirect=True) - def test_diagnostics(self, language_server: SolidLanguageServer) -> None: - """Test getting diagnostics (errors, warnings) from F# files.""" - file_path = os.path.join("Program.fs") - - # FsAutoComplete uses publishDiagnostics notifications instead of textDocument/diagnostic requests - # So we'll test that the language server can handle files without crashing - # In real usage, diagnostics would come through the publishDiagnostics notification handler - - # Test that we can at least work with the file (open/close cycle) - with language_server.open_file(file_path) as _: - # If we can open and close the file without errors, basic diagnostics support is working - pass - - # This is a successful test - FsAutoComplete is working with F# files - assert True, "F# language server can handle files successfully" + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "DiagnosticsSample.fs", + (), + min_count=1, + ) @pytest.mark.parametrize("language_server", [Language.FSHARP], indirect=True) def test_bare_symbol_names(self, language_server) -> None: diff --git a/test/solidlsp/go/test_go_diagnostics.py b/test/solidlsp/go/test_go_diagnostics.py new file mode 100644 index 00000000..00b4cc48 --- /dev/null +++ b/test/solidlsp/go/test_go_diagnostics.py @@ -0,0 +1,17 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.go +class TestGoDiagnostics: + @pytest.mark.parametrize("language_server", [Language.GO], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "diagnostics_sample.go", + ("missingGreeting", "missingConsumerValue"), + min_count=2, + ) diff --git a/test/solidlsp/groovy/test_groovy_basic.py b/test/solidlsp/groovy/test_groovy_basic.py index 47f22ed8..0637e3de 100644 --- a/test/solidlsp/groovy/test_groovy_basic.py +++ b/test/solidlsp/groovy/test_groovy_basic.py @@ -9,6 +9,7 @@ from solidlsp.ls_config import Language, LanguageServerConfig from solidlsp.ls_utils import SymbolUtils from solidlsp.settings import SolidLSPSettings from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols +from test.solidlsp.util.diagnostics import assert_file_diagnostics @pytest.mark.groovy @@ -117,3 +118,12 @@ class TestGroovyLanguageServer: f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", pytrace=False, ) + + def test_file_diagnostics(self) -> None: + assert self.language_server is not None + assert_file_diagnostics( + self.language_server, + "src/main/groovy/com/example/DiagnosticsSample.groovy", + (), + min_count=1, + ) diff --git a/test/solidlsp/haskell/test_haskell_basic.py b/test/solidlsp/haskell/test_haskell_basic.py index 67452151..5b23ad3d 100644 --- a/test/solidlsp/haskell/test_haskell_basic.py +++ b/test/solidlsp/haskell/test_haskell_basic.py @@ -21,6 +21,7 @@ from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_types import SymbolKind from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols +from test.solidlsp.util.diagnostics import assert_file_diagnostics @pytest.mark.haskell @@ -240,3 +241,12 @@ class TestHaskellLanguageServer: for sym in malformed_symbols ] pytest.fail(f"Found malformed symbols: {diagnostics}", pytrace=False) + + @pytest.mark.parametrize("language_server", [Language.HASKELL], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "src/DiagnosticsSample.hs", + (), + min_count=1, + ) diff --git a/test/solidlsp/haxe/test_haxe_diagnostics.py b/test/solidlsp/haxe/test_haxe_diagnostics.py new file mode 100644 index 00000000..2f789b60 --- /dev/null +++ b/test/solidlsp/haxe/test_haxe_diagnostics.py @@ -0,0 +1,17 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.haxe +class TestHaxeDiagnostics: + @pytest.mark.parametrize("language_server", [Language.HAXE], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "src/DiagnosticsSample.hx", + (), + min_count=1, + ) diff --git a/test/solidlsp/hlsl/test_hlsl_diagnostics.py b/test/solidlsp/hlsl/test_hlsl_diagnostics.py new file mode 100644 index 00000000..e356d2da --- /dev/null +++ b/test/solidlsp/hlsl/test_hlsl_diagnostics.py @@ -0,0 +1,17 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.hlsl +class TestHlslDiagnostics: + @pytest.mark.parametrize("language_server", [Language.HLSL], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "diagnostics_sample.hlsl", + (), + min_count=1, + ) diff --git a/test/solidlsp/java/test_java_diagnostics.py b/test/solidlsp/java/test_java_diagnostics.py new file mode 100644 index 00000000..ebd59457 --- /dev/null +++ b/test/solidlsp/java/test_java_diagnostics.py @@ -0,0 +1,17 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.java +class TestJavaDiagnostics: + @pytest.mark.parametrize("language_server", [Language.JAVA], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "src/main/java/test_repo/DiagnosticsSample.java", + (), + min_count=1, + ) diff --git a/test/solidlsp/json_ls/test_json_diagnostics.py b/test/solidlsp/json_ls/test_json_diagnostics.py new file mode 100644 index 00000000..7850083a --- /dev/null +++ b/test/solidlsp/json_ls/test_json_diagnostics.py @@ -0,0 +1,17 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.json +class TestJsonDiagnostics: + @pytest.mark.parametrize("language_server", [Language.JSON], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "diagnostics_sample.json", + (), + min_count=1, + ) diff --git a/test/solidlsp/julia/test_julia_basic.py b/test/solidlsp/julia/test_julia_basic.py index 44adb43d..176587f7 100644 --- a/test/solidlsp/julia/test_julia_basic.py +++ b/test/solidlsp/julia/test_julia_basic.py @@ -6,6 +6,7 @@ from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import Language from test.conftest import is_ci from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols +from test.solidlsp.util.diagnostics import assert_file_diagnostics @pytest.mark.skipif(shutil.which("julia") is None and not is_ci, reason="Julia is not available") @@ -66,3 +67,12 @@ class TestJuliaLanguageServer: f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", pytrace=False, ) + + @pytest.mark.parametrize("language_server", [Language.JULIA], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "diagnostics_sample.jl", + (), + min_count=1, + ) diff --git a/test/solidlsp/kotlin/test_kotlin_diagnostics.py b/test/solidlsp/kotlin/test_kotlin_diagnostics.py new file mode 100644 index 00000000..c189825f --- /dev/null +++ b/test/solidlsp/kotlin/test_kotlin_diagnostics.py @@ -0,0 +1,17 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.kotlin +class TestKotlinDiagnostics: + @pytest.mark.parametrize("language_server", [Language.KOTLIN], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "src/main/kotlin/test_repo/DiagnosticsSample.kt", + (), + min_count=1, + ) diff --git a/test/solidlsp/lean4/test_lean4_diagnostics.py b/test/solidlsp/lean4/test_lean4_diagnostics.py new file mode 100644 index 00000000..964bc2c6 --- /dev/null +++ b/test/solidlsp/lean4/test_lean4_diagnostics.py @@ -0,0 +1,17 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.lean4 +class TestLean4Diagnostics: + @pytest.mark.parametrize("language_server", [Language.LEAN4], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "DiagnosticsSample.lean", + (), + min_count=1, + ) diff --git a/test/solidlsp/matlab/test_matlab_basic.py b/test/solidlsp/matlab/test_matlab_basic.py index 71724ff7..a1582887 100644 --- a/test/solidlsp/matlab/test_matlab_basic.py +++ b/test/solidlsp/matlab/test_matlab_basic.py @@ -17,6 +17,7 @@ import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols +from test.solidlsp.util.diagnostics import assert_file_diagnostics # Skip all tests if MATLAB is not available pytestmark = pytest.mark.matlab @@ -129,3 +130,12 @@ class TestMatlabLanguageServerReferences: f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", pytrace=False, ) + + @pytest.mark.parametrize("language_server", [Language.MATLAB], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "diagnostics_sample.m", + (), + min_count=1, + ) diff --git a/test/solidlsp/nix/test_nix_basic.py b/test/solidlsp/nix/test_nix_basic.py index 23f4311a..e66ddfa5 100644 --- a/test/solidlsp/nix/test_nix_basic.py +++ b/test/solidlsp/nix/test_nix_basic.py @@ -13,6 +13,7 @@ from solidlsp.ls_config import Language from solidlsp.ls_types import SymbolKind from test.conftest import is_ci from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols +from test.solidlsp.util.diagnostics import assert_file_diagnostics # Skip all Nix tests on Windows as Nix doesn't support Windows pytestmark = pytest.mark.skipif(platform.system() == "Windows", reason="Nix and nil are not available on Windows") @@ -250,3 +251,12 @@ class TestNixLanguageServer: f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", pytrace=False, ) + + @pytest.mark.parametrize("language_server", [Language.NIX], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "diagnostics_sample.nix", + (), + min_count=1, + ) diff --git a/test/solidlsp/ocaml/test_ocaml_diagnostics.py b/test/solidlsp/ocaml/test_ocaml_diagnostics.py new file mode 100644 index 00000000..a76f95cb --- /dev/null +++ b/test/solidlsp/ocaml/test_ocaml_diagnostics.py @@ -0,0 +1,17 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.ocaml +class TestOcamlDiagnostics: + @pytest.mark.parametrize("language_server", [Language.OCAML], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "lib/diagnostics_sample.ml", + (), + min_count=1, + ) diff --git a/test/solidlsp/pascal/test_pascal_diagnostics.py b/test/solidlsp/pascal/test_pascal_diagnostics.py new file mode 100644 index 00000000..ad96a1ea --- /dev/null +++ b/test/solidlsp/pascal/test_pascal_diagnostics.py @@ -0,0 +1,25 @@ +import shutil + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.conftest import language_tests_enabled +from test.solidlsp.util.diagnostics import assert_file_diagnostics + +pytestmark = [ + pytest.mark.pascal, + pytest.mark.skipif(not language_tests_enabled(Language.PASCAL), reason="Pascal tests are disabled"), + pytest.mark.skipif(shutil.which("fpc") is None, reason="Pascal diagnostics require the Free Pascal compiler"), +] + + +class TestPascalDiagnostics: + @pytest.mark.parametrize("language_server", [Language.PASCAL], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "diagnostics_sample.pas", + (), + min_count=1, + ) diff --git a/test/solidlsp/perl/test_perl_basic.py b/test/solidlsp/perl/test_perl_basic.py index 41682e3f..efb01d01 100644 --- a/test/solidlsp/perl/test_perl_basic.py +++ b/test/solidlsp/perl/test_perl_basic.py @@ -6,6 +6,7 @@ import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols +from test.solidlsp.util.diagnostics import assert_file_diagnostics @pytest.mark.perl @@ -88,3 +89,12 @@ class TestPerlLanguageServer: f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", pytrace=False, ) + + @pytest.mark.parametrize("language_server", [Language.PERL], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "diagnostics_sample.pl", + (), + min_count=1, + ) diff --git a/test/solidlsp/php/test_php_diagnostics.py b/test/solidlsp/php/test_php_diagnostics.py new file mode 100644 index 00000000..1cfc67de --- /dev/null +++ b/test/solidlsp/php/test_php_diagnostics.py @@ -0,0 +1,17 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.php +class TestPhpDiagnostics: + @pytest.mark.parametrize("language_server", [Language.PHP], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "diagnostics_sample.php", + (), + min_count=1, + ) diff --git a/test/solidlsp/powershell/test_powershell_diagnostics.py b/test/solidlsp/powershell/test_powershell_diagnostics.py new file mode 100644 index 00000000..ac8706cf --- /dev/null +++ b/test/solidlsp/powershell/test_powershell_diagnostics.py @@ -0,0 +1,17 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.powershell +class TestPowershellDiagnostics: + @pytest.mark.parametrize("language_server", [Language.POWERSHELL], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "diagnostics_sample.ps1", + (), + min_count=1, + ) diff --git a/test/solidlsp/python/test_python_diagnostics.py b/test/solidlsp/python/test_python_diagnostics.py new file mode 100644 index 00000000..386d2830 --- /dev/null +++ b/test/solidlsp/python/test_python_diagnostics.py @@ -0,0 +1,19 @@ +import os + +import pytest + +from solidlsp import SolidLanguageServer +from test.conftest import PYTHON_LANGUAGE_BACKENDS +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.python +class TestPythonDiagnostics: + @pytest.mark.parametrize("language_server", PYTHON_LANGUAGE_BACKENDS, indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + os.path.join("test_repo", "diagnostics_sample.py"), + ("missing_user", "undefined_name"), + min_count=2, + ) diff --git a/test/solidlsp/rust/test_rust_basic.py b/test/solidlsp/rust/test_rust_basic.py index 29fce938..4c7ec2c0 100644 --- a/test/solidlsp/rust/test_rust_basic.py +++ b/test/solidlsp/rust/test_rust_basic.py @@ -90,7 +90,7 @@ class TestRustLanguageServer: all_symbols = request_all_symbols(language_server) malformed_symbols = [] for s in all_symbols: - if has_malformed_name(s): + if has_malformed_name(s, whitespace_allowed=True): malformed_symbols.append(s) if malformed_symbols: pytest.fail( diff --git a/test/solidlsp/rust/test_rust_diagnostics.py b/test/solidlsp/rust/test_rust_diagnostics.py new file mode 100644 index 00000000..b08f7cf7 --- /dev/null +++ b/test/solidlsp/rust/test_rust_diagnostics.py @@ -0,0 +1,17 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.rust +class TestRustDiagnostics: + @pytest.mark.parametrize("language_server", [Language.RUST], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "src/diagnostics_sample.rs", + (), + min_count=1, + ) diff --git a/test/solidlsp/scala/test_scala_language_server.py b/test/solidlsp/scala/test_scala_language_server.py index bfc6d8f1..4e60209d 100644 --- a/test/solidlsp/scala/test_scala_language_server.py +++ b/test/solidlsp/scala/test_scala_language_server.py @@ -6,6 +6,7 @@ import pytest from solidlsp.language_servers.scala_language_server import ScalaLanguageServer from solidlsp.ls_config import Language, LanguageServerConfig from solidlsp.settings import SolidLSPSettings +from test.solidlsp.util.diagnostics import assert_file_diagnostics pytest.skip("Scala must be compiled for these tests to run through, which is a huge hassle", allow_module_level=True) @@ -62,3 +63,12 @@ def test_scala_find_definition_and_references_across_files(scala_ls): assert first_def["range"]["start"]["character"] == 6 assert first_def["range"]["end"]["line"] == 7 assert first_def["range"]["end"]["character"] == 14 + + +def test_file_diagnostics(scala_ls) -> None: + assert_file_diagnostics( + scala_ls, + "src/main/scala/com/example/DiagnosticsSample.scala", + (), + min_count=1, + ) diff --git a/test/solidlsp/solidity/test_solidity_diagnostics.py b/test/solidlsp/solidity/test_solidity_diagnostics.py new file mode 100644 index 00000000..fd3cd6ba --- /dev/null +++ b/test/solidlsp/solidity/test_solidity_diagnostics.py @@ -0,0 +1,24 @@ +import sys + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.solidity +class TestSolidityDiagnostics: + @pytest.mark.xfail( + sys.platform == "darwin", + reason="Unknown — passes on Ubuntu but consistently fails on macOS CI; root cause not yet identified.", + strict=False, + ) + @pytest.mark.parametrize("language_server", [Language.SOLIDITY], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "contracts/DiagnosticsSample.sol", + (), + min_count=1, + ) diff --git a/test/solidlsp/swift/test_swift_basic.py b/test/solidlsp/swift/test_swift_basic.py index 5a0d8bd0..30e9759c 100644 --- a/test/solidlsp/swift/test_swift_basic.py +++ b/test/solidlsp/swift/test_swift_basic.py @@ -16,6 +16,7 @@ from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from test.conftest import is_ci from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols +from test.solidlsp.util.diagnostics import assert_file_diagnostics # Skip Swift tests on Windows due to complex GitHub Actions configuration WINDOWS_SKIP = platform.system() == "Windows" @@ -238,3 +239,12 @@ class TestSwiftProjectBasics: f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", pytrace=False, ) + + @pytest.mark.parametrize("language_server", [Language.SWIFT], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "src/diagnostics_sample.swift", + (), + min_count=1, + ) diff --git a/test/solidlsp/systemverilog/test_systemverilog_diagnostics.py b/test/solidlsp/systemverilog/test_systemverilog_diagnostics.py new file mode 100644 index 00000000..a783b828 --- /dev/null +++ b/test/solidlsp/systemverilog/test_systemverilog_diagnostics.py @@ -0,0 +1,17 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.systemverilog +class TestSystemverilogDiagnostics: + @pytest.mark.parametrize("language_server", [Language.SYSTEMVERILOG], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "diagnostics_sample.sv", + (), + min_count=1, + ) diff --git a/test/solidlsp/terraform/test_terraform_diagnostics.py b/test/solidlsp/terraform/test_terraform_diagnostics.py new file mode 100644 index 00000000..318cc7f9 --- /dev/null +++ b/test/solidlsp/terraform/test_terraform_diagnostics.py @@ -0,0 +1,17 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.terraform +class TestTerraformDiagnostics: + @pytest.mark.parametrize("language_server", [Language.TERRAFORM], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "diagnostics_sample.tf", + (), + min_count=1, + ) diff --git a/test/solidlsp/toml/test_toml_diagnostics.py b/test/solidlsp/toml/test_toml_diagnostics.py new file mode 100644 index 00000000..dde2fc83 --- /dev/null +++ b/test/solidlsp/toml/test_toml_diagnostics.py @@ -0,0 +1,17 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.toml +class TestTomlDiagnostics: + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "diagnostics_sample.toml", + (), + min_count=1, + ) diff --git a/test/solidlsp/typescript/test_typescript_diagnostics.py b/test/solidlsp/typescript/test_typescript_diagnostics.py new file mode 100644 index 00000000..5e879e16 --- /dev/null +++ b/test/solidlsp/typescript/test_typescript_diagnostics.py @@ -0,0 +1,17 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.typescript +class TestTypeScriptDiagnostics: + @pytest.mark.parametrize("language_server", [Language.TYPESCRIPT], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "diagnostics_sample.ts", + ("missingGreeting", "missingConsumerValue"), + min_count=2, + ) diff --git a/test/solidlsp/util/diagnostics.py b/test/solidlsp/util/diagnostics.py new file mode 100644 index 00000000..dee66086 --- /dev/null +++ b/test/solidlsp/util/diagnostics.py @@ -0,0 +1,19 @@ +from collections.abc import Sequence + +from solidlsp import SolidLanguageServer + + +def assert_file_diagnostics( + language_server: SolidLanguageServer, + relative_file_path: str, + expected_message_fragments: Sequence[str], + min_count: int = 1, +) -> None: + diagnostics = language_server.request_text_document_diagnostics(relative_file_path, min_severity=1) + + assert isinstance(diagnostics, list), diagnostics + assert len(diagnostics) >= min_count, diagnostics + + diagnostic_messages = [diagnostic["message"] for diagnostic in diagnostics] + for fragment in expected_message_fragments: + assert any(fragment in message for message in diagnostic_messages), diagnostic_messages diff --git a/test/solidlsp/vue/test_vue_diagnostics.py b/test/solidlsp/vue/test_vue_diagnostics.py new file mode 100644 index 00000000..08ac2fed --- /dev/null +++ b/test/solidlsp/vue/test_vue_diagnostics.py @@ -0,0 +1,17 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.vue +class TestVueDiagnostics: + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "src/DiagnosticsSample.vue", + (), + min_count=1, + ) diff --git a/test/solidlsp/yaml_ls/test_yaml_diagnostics.py b/test/solidlsp/yaml_ls/test_yaml_diagnostics.py new file mode 100644 index 00000000..4ba774c7 --- /dev/null +++ b/test/solidlsp/yaml_ls/test_yaml_diagnostics.py @@ -0,0 +1,17 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.yaml +class TestYamlDiagnostics: + @pytest.mark.parametrize("language_server", [Language.YAML], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "diagnostics_sample.yaml", + (), + min_count=1, + ) diff --git a/test/solidlsp/zig/test_zig_basic.py b/test/solidlsp/zig/test_zig_basic.py index 51b23650..e7413482 100644 --- a/test/solidlsp/zig/test_zig_basic.py +++ b/test/solidlsp/zig/test_zig_basic.py @@ -14,6 +14,7 @@ from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_types import SymbolKind from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols +from test.solidlsp.util.diagnostics import assert_file_diagnostics @pytest.mark.zig @@ -355,3 +356,12 @@ class TestZigLanguageServer: f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", pytrace=False, ) + + @pytest.mark.parametrize("language_server", [Language.ZIG], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "src/diagnostics_sample.zig", + (), + min_count=1, + ) From da59a19c5060ba896c73c06a5886e054f9f8ddc7 Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Wed, 29 Apr 2026 09:25:58 +0200 Subject: [PATCH 06/12] Fixes in various LS and in tests to accommodate diagnostics edge behavior Also minor fix in go test expectations of symbol name --- .github/workflows/pytest.yml | 4 +- scripts/demo_run_tools.py | 6 +- .../language_servers/bash_language_server.py | 133 +++++++++++++++--- .../language_servers/elm_language_server.py | 24 ++++ .../fortran_language_server.py | 33 ++++- .../language_servers/haxe_language_server.py | 9 +- src/solidlsp/language_servers/julia_server.py | 49 ++++++- .../language_servers/ocaml_lsp_server.py | 10 +- .../powershell_language_server.py | 62 +++++++- .../language_servers/pyright_server.py | 49 +++++++ .../solidity_language_server.py | 132 ++++++++++++++++- src/solidlsp/ls.py | 99 ++++++++++--- test/serena/test_serena_agent.py | 2 +- test/solidlsp/go/test_go_basic.py | 4 +- 14 files changed, 560 insertions(+), 56 deletions(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 86246a2e..1d2b79a6 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -235,10 +235,10 @@ jobs: - name: Set up Julia uses: julia-actions/setup-julia@v2 with: - version: '1.10' + version: '1.12.6' - name: Install Julia LanguageServer shell: bash - run: julia -e 'using Pkg; Pkg.add("LanguageServer")' + run: julia -e 'using Pkg; Pkg.add(Pkg.PackageSpec(name="LanguageServer", version="5.0.0"))' - name: Setup Haskell toolchain if: runner.os != 'Windows' uses: haskell/ghcup-setup@v1 diff --git a/scripts/demo_run_tools.py b/scripts/demo_run_tools.py index 4914201c..1c95365c 100644 --- a/scripts/demo_run_tools.py +++ b/scripts/demo_run_tools.py @@ -8,11 +8,12 @@ from pathlib import Path from pprint import pprint from serena.agent import SerenaAgent -from serena.config.serena_config import SerenaConfig +from serena.config.serena_config import LanguageBackend, SerenaConfig from serena.constants import REPO_ROOT from serena.tools import ( FindFileTool, FindReferencingSymbolsTool, + GetDiagnosticsForFileTool, JetBrainsFindSymbolTool, JetBrainsGetSymbolsOverviewTool, JetBrainsInlineSymbol, @@ -24,6 +25,7 @@ from serena.tools import ( if __name__ == "__main__": serena_config = SerenaConfig.from_config_file() serena_config.web_dashboard = False + serena_config.language_backend = LanguageBackend.LSP # project = Path(REPO_ROOT).parent / "serena-jetbrains-plugin-copy" project = Path(REPO_ROOT) agent = SerenaAgent(project=str(project), serena_config=serena_config) @@ -40,7 +42,7 @@ if __name__ == "__main__": jb_inspections_tool = agent.get_tool(JetBrainsRunInspectionsTool) result = agent.execute_task( - lambda: overview_tool.apply( + lambda: diagnostics_in_file_tool.apply( # name_path_pattern="SerenaAgent", relative_path="test/resources/repos/clojure/test_repo/src/test_app/diagnostics_sample.clj", # keep_definition=True, diff --git a/src/solidlsp/language_servers/bash_language_server.py b/src/solidlsp/language_servers/bash_language_server.py index c618884b..9dfc9356 100644 --- a/src/solidlsp/language_servers/bash_language_server.py +++ b/src/solidlsp/language_servers/bash_language_server.py @@ -18,12 +18,64 @@ from solidlsp.ls import ( SolidLanguageServer, ) from solidlsp.ls_config import LanguageServerConfig +from solidlsp.ls_utils import FileUtils, PlatformId, PlatformUtils from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) +# ShellCheck binary release info; download directly from upstream GitHub releases rather than +# relying on the `shellcheck` npm wrapper (which lazily downloads on first invocation and +# turned the bash LS install into a fragile, network-dependent step). +_SHELLCHECK_VERSION = "0.10.0" +_SHELLCHECK_RELEASE_BASE = f"https://github.com/koalaman/shellcheck/releases/download/v{_SHELLCHECK_VERSION}" +_SHELLCHECK_ALLOWED_HOSTS = ( + "github.com", + "objects.githubusercontent.com", + "release-assets.githubusercontent.com", +) +# Per-platform archive metadata: tar.xz on POSIX (extracts to shellcheck-v/shellcheck), +# zip on Windows (extracts to shellcheck.exe at archive root). +_SHELLCHECK_DEPENDENCIES: dict[PlatformId, dict[str, str]] = { + PlatformId.LINUX_x64: { + "url": f"{_SHELLCHECK_RELEASE_BASE}/shellcheck-v{_SHELLCHECK_VERSION}.linux.x86_64.tar.xz", + "sha256": "6c881ab0698e4e6ea235245f22832860544f17ba386442fe7e9d629f8cbedf87", + }, + PlatformId.LINUX_arm64: { + "url": f"{_SHELLCHECK_RELEASE_BASE}/shellcheck-v{_SHELLCHECK_VERSION}.linux.aarch64.tar.xz", + "sha256": "324a7e89de8fa2aed0d0c28f3dab59cf84c6d74264022c00c22af665ed1a09bb", + }, + PlatformId.OSX_x64: { + "url": f"{_SHELLCHECK_RELEASE_BASE}/shellcheck-v{_SHELLCHECK_VERSION}.darwin.x86_64.tar.xz", + "sha256": "ef27684f23279d112d8ad84e0823642e43f838993bbb8c0963db9b58a90464c2", + }, + PlatformId.OSX_arm64: { + "url": f"{_SHELLCHECK_RELEASE_BASE}/shellcheck-v{_SHELLCHECK_VERSION}.darwin.aarch64.tar.xz", + "sha256": "bbd2f14826328eee7679da7221f2bc3afb011f6a928b848c80c321f6046ddf81", + }, + PlatformId.WIN_x64: { + "url": f"{_SHELLCHECK_RELEASE_BASE}/shellcheck-v{_SHELLCHECK_VERSION}.zip", + "sha256": "eb6cd53a54ea97a56540e9d296ce7e2fa68715aa507ff23574646c1e12b2e143", + }, +} + + +def _shellcheck_install_dir(bash_ls_dir: str) -> str: + return os.path.join(bash_ls_dir, "shellcheck") + + +def _shellcheck_binary_path(bash_ls_dir: str) -> str: + """ + Returns the path to the extracted ShellCheck binary. POSIX archives extract under + ``shellcheck-v/shellcheck``; the Windows zip drops ``shellcheck.exe`` at archive root. + """ + install_dir = _shellcheck_install_dir(bash_ls_dir) + if os.name == "nt": + return os.path.join(install_dir, "shellcheck.exe") + return os.path.join(install_dir, f"shellcheck-v{_SHELLCHECK_VERSION}", "shellcheck") + + class BashLanguageServer(SolidLanguageServer): """ Provides Bash specific instantiation of the LanguageServer class using bash-language-server. @@ -53,7 +105,7 @@ class BashLanguageServer(SolidLanguageServer): """ Setup runtime dependencies for Bash Language Server and return the command to start the server. """ - # Verify both node and npm are installed + # verify node + npm are available for the bash-language-server install is_node_installed = shutil.which("node") is not None assert is_node_installed, "node is not installed or isn't in PATH. Please install NodeJS and try again." is_npm_installed = shutil.which("npm") is not None @@ -61,29 +113,29 @@ class BashLanguageServer(SolidLanguageServer): bash_language_server_version = self._custom_settings.get("bash_language_server_version", "5.6.0") npm_registry = self._custom_settings.get("npm_registry") - deps = RuntimeDependencyCollection( - [ - RuntimeDependency( - id="bash-language-server", - description="bash-language-server package", - command=build_npm_install_command("bash-language-server", bash_language_server_version, npm_registry), - platform_id="any", - ), - ] - ) - - # Install bash-language-server if not already installed bash_ls_dir = os.path.join(self._ls_resources_dir, "bash-lsp") - bash_executable_path = os.path.join(bash_ls_dir, "node_modules", ".bin", "bash-language-server") - - # Handle Windows executable extension + managed_bin_dir = os.path.join(bash_ls_dir, "node_modules", ".bin") + bash_executable_path = os.path.join(managed_bin_dir, "bash-language-server") if os.name == "nt": bash_executable_path += ".cmd" + # install bash-language-server via npm if not os.path.exists(bash_executable_path): - log.info(f"Bash Language Server executable not found at {bash_executable_path}. Installing...") - deps.install(bash_ls_dir) - log.info("Bash language server dependencies installed successfully") + bls_deps = RuntimeDependencyCollection( + [ + RuntimeDependency( + id="bash-language-server", + description="bash-language-server package", + command=build_npm_install_command("bash-language-server", bash_language_server_version, npm_registry), + platform_id="any", + ), + ] + ) + log.info("Installing bash-language-server...") + bls_deps.install(bash_ls_dir) + + # install ShellCheck binary directly from upstream releases for the current platform + self._install_shellcheck_if_missing(bash_ls_dir) if not os.path.exists(bash_executable_path): raise FileNotFoundError( @@ -92,6 +144,49 @@ class BashLanguageServer(SolidLanguageServer): return bash_executable_path + @staticmethod + def _install_shellcheck_if_missing(bash_ls_dir: str) -> None: + """ + Downloads and extracts the platform-appropriate ShellCheck release into + ``${bash_ls_dir}/shellcheck`` if the binary is not already present. + """ + binary_path = _shellcheck_binary_path(bash_ls_dir) + if os.path.exists(binary_path): + return + + install_dir = _shellcheck_install_dir(bash_ls_dir) + os.makedirs(install_dir, exist_ok=True) + + release = _SHELLCHECK_DEPENDENCIES.get(PlatformUtils.get_platform_id()) + if release is None: + raise RuntimeError(f"ShellCheck has no upstream binary release for platform {PlatformUtils.get_platform_id().value}") + + archive_type = "zip" if os.name == "nt" else "xztar" + log.info(f"Downloading ShellCheck v{_SHELLCHECK_VERSION} for {PlatformUtils.get_platform_id().value}") + FileUtils.download_and_extract_archive_verified( + release["url"], + install_dir, + archive_type, + expected_sha256=release["sha256"], + allowed_hosts=_SHELLCHECK_ALLOWED_HOSTS, + ) + + if not os.path.exists(binary_path): + raise FileNotFoundError(f"ShellCheck binary not found at {binary_path} after extraction; archive layout may have changed.") + + # ensure the binary is executable on POSIX (zip extraction does not preserve perms) + if os.name != "nt": + current = os.stat(binary_path).st_mode + os.chmod(binary_path, current | 0o111) + + def create_launch_command_env(self) -> dict[str, str]: + bash_ls_dir = os.path.join(self._ls_resources_dir, "bash-lsp") + managed_bin_dir = os.path.join(bash_ls_dir, "node_modules", ".bin") + return { + "PATH": managed_bin_dir + os.pathsep + os.environ.get("PATH", ""), + "SHELLCHECK_PATH": _shellcheck_binary_path(bash_ls_dir), + } + def _create_launch_command(self, core_path: str) -> list[str]: return [core_path, "start"] diff --git a/src/solidlsp/language_servers/elm_language_server.py b/src/solidlsp/language_servers/elm_language_server.py index 0c6139c1..a86a1d2d 100644 --- a/src/solidlsp/language_servers/elm_language_server.py +++ b/src/solidlsp/language_servers/elm_language_server.py @@ -11,6 +11,7 @@ import threading from overrides import override from sensai.util.logging import LogTime +from solidlsp import ls_types from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import Language, LanguageServerConfig from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams @@ -217,3 +218,26 @@ class ElmLanguageServer(SolidLanguageServer): @override def _get_wait_time_for_cross_file_referencing(self) -> float: return 1.0 + + @override + def request_text_document_diagnostics( + self, + relative_file_path: str, + start_line: int = 0, + end_line: int = -1, + min_severity: int = 4, + ) -> list[ls_types.Diagnostic]: + uri = self._validate_text_document_diagnostics_request(relative_file_path, start_line, end_line, min_severity) + + with self.open_file(relative_file_path): + diagnostics = self._wait_for_relevant_published_diagnostics( + uri=uri, + after_generation=-1, + timeout=10.0, + allow_cached=True, + ) + + if diagnostics is None: + return [] + + return self._filter_diagnostics(diagnostics, start_line, end_line, min_severity) diff --git a/src/solidlsp/language_servers/fortran_language_server.py b/src/solidlsp/language_servers/fortran_language_server.py index a1d5d56d..a80efc68 100644 --- a/src/solidlsp/language_servers/fortran_language_server.py +++ b/src/solidlsp/language_servers/fortran_language_server.py @@ -11,7 +11,7 @@ import shutil from overrides import override from solidlsp import ls_types -from solidlsp.ls import DocumentSymbols, LSPFileBuffer, SolidLanguageServer +from solidlsp.ls import DocumentSymbols, LSPConstants, LSPFileBuffer, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo @@ -285,3 +285,34 @@ class FortranLanguageServer(SolidLanguageServer): # Fortran Language Server is ready after initialization log.info("Fortran Language Server initialization complete") + + @override + def request_text_document_diagnostics( + self, + relative_file_path: str, + start_line: int = 0, + end_line: int = -1, + min_severity: int = 4, + ) -> list[ls_types.Diagnostic]: + uri = self._validate_text_document_diagnostics_request(relative_file_path, start_line, end_line, min_severity) + diagnostics_before_request = self._get_published_diagnostics_generation(uri) + + with self.open_file(relative_file_path): + self.server.notify.did_save_text_document( + { + LSPConstants.TEXT_DOCUMENT: { # type: ignore + LSPConstants.URI: uri, + } + } + ) + diagnostics = self._wait_for_relevant_published_diagnostics( + uri=uri, + after_generation=diagnostics_before_request, + timeout=self._get_published_diagnostics_wait_timeout(True), + allow_cached=True, + ) + + if diagnostics is None: + return [] + + return self._filter_diagnostics(diagnostics, start_line, end_line, min_severity) diff --git a/src/solidlsp/language_servers/haxe_language_server.py b/src/solidlsp/language_servers/haxe_language_server.py index 41c63a2e..cf48c78a 100644 --- a/src/solidlsp/language_servers/haxe_language_server.py +++ b/src/solidlsp/language_servers/haxe_language_server.py @@ -215,7 +215,14 @@ class HaxeLanguageServer(SolidLanguageServer): "locale": "en", "capabilities": { "textDocument": { - "synchronization": {"didSave": True}, + "publishDiagnostics": { + "relatedInformation": True, + "versionSupport": False, + "tagSupport": {"valueSet": [1, 2]}, + "codeDescriptionSupport": True, + "dataSupport": True, + }, + "synchronization": {"dynamicRegistration": True, "didSave": True}, "completion": {"completionItem": {"snippetSupport": True}}, "definition": {}, "references": {}, diff --git a/src/solidlsp/language_servers/julia_server.py b/src/solidlsp/language_servers/julia_server.py index 41d23c02..ff3acd66 100644 --- a/src/solidlsp/language_servers/julia_server.py +++ b/src/solidlsp/language_servers/julia_server.py @@ -10,7 +10,7 @@ from overrides import override from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams +from solidlsp.lsp_protocol_handler.lsp_types import DiagnosticTag, InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings @@ -115,6 +115,21 @@ class JuliaLanguageServer(SolidLanguageServer): """Define language-specific directories to ignore for Julia projects.""" return super().is_ignored_dirname(dirname) or dirname in [".julia", "build", "dist"] + @override + def _supports_pull_diagnostics(self) -> bool: + # LanguageServer.jl raises an unhandled error on textDocument/diagnostic which crashes + # the server process. Force the published-diagnostics path instead. + return False + + @override + def _get_published_diagnostics_wait_timeout(self, pull_diagnostics_failed: bool) -> float: + # LanguageServer.jl needs significant warm-up after init before it emits the first + # publishDiagnostics: workspace/configuration round-trip + initial linter pass typically + # land 13-15s after the file is opened in a cold LS. Module-scoped fixtures usually have + # the cache pre-populated and return immediately, so this ceiling only kicks in for cold + # runs (standalone test invocations, CI cold start). + return 30.0 + def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: """ Returns the initialize params for the Julia Language Server. @@ -125,8 +140,19 @@ class JuliaLanguageServer(SolidLanguageServer): "rootPath": repository_absolute_path, "rootUri": root_uri, "capabilities": { - "workspace": {"workspaceFolders": True}, + # workspace.configuration MUST be true: LanguageServer.jl pulls all julia.lint.* + # settings via workspace/configuration after initialize. Without it, the server + # skips that codepath and runlinter stays disabled, so no diagnostics ever arrive. + "workspace": {"workspaceFolders": True, "configuration": True}, "textDocument": { + "publishDiagnostics": { + "relatedInformation": True, + "versionSupport": False, + "tagSupport": {"valueSet": [DiagnosticTag.Unnecessary, DiagnosticTag.Deprecated]}, + "codeDescriptionSupport": True, + "dataSupport": True, + }, + "synchronization": {"dynamicRegistration": True, "didSave": True}, "definition": {"dynamicRegistration": True}, "references": {"dynamicRegistration": True}, "documentSymbol": {"dynamicRegistration": True}, @@ -150,6 +176,19 @@ class JuliaLanguageServer(SolidLanguageServer): def window_log_message(msg: dict) -> None: log.info(f"LSP: window/logMessage: {msg}") + def workspace_configuration(params: dict) -> list[Any]: + """ + Respond to LanguageServer.jl's workspace/configuration pull request. + + The server requests a flat list of julia.* settings (in particular julia.lint.run, + position 11 in the response). Returning ``None`` for each entry causes the server to + apply its built-in defaults — including ``runlinter = true`` — which is what we want + for diagnostics to be produced. + """ + items = params.get("items", []) if isinstance(params, dict) else [] + return [None for _ in items] + + self.server.on_request("workspace/configuration", workspace_configuration) self.server.on_notification("window/logMessage", window_log_message) self.server.on_notification("$/progress", do_nothing) self.server.on_notification("textDocument/publishDiagnostics", do_nothing) @@ -166,4 +205,10 @@ class JuliaLanguageServer(SolidLanguageServer): assert "documentSymbolProvider" in init_response["capabilities"] self.server.notify.initialized({}) + + # nudge the server to pull config: LanguageServer.jl only invokes request_julia_config + # from its workspace/didChangeConfiguration handler, so without this notification the + # server never asks us for julia.lint.run and runlinter stays disabled (no diagnostics). + self.server.notify.workspace_did_change_configuration({"settings": {}}) + log.info("Julia Language Server is initialized and ready.") diff --git a/src/solidlsp/language_servers/ocaml_lsp_server.py b/src/solidlsp/language_servers/ocaml_lsp_server.py index 8c9bc356..b2e9511a 100644 --- a/src/solidlsp/language_servers/ocaml_lsp_server.py +++ b/src/solidlsp/language_servers/ocaml_lsp_server.py @@ -242,7 +242,7 @@ class OcamlLanguageServer(SolidLanguageServer): """ ocaml_ok = self._ocaml_version >= (5, 2, 0) lsp_ok = self._lsp_version >= self.MIN_LSP_VERSION_FOR_CROSS_FILE_REFS - return ocaml_ok and lsp_ok + return ocaml_ok and lsp_ok and self._index_built @staticmethod def _build_ocaml_index_static(repository_root_path: str) -> bool: @@ -340,6 +340,14 @@ class OcamlLanguageServer(SolidLanguageServer): "configuration": True, }, "textDocument": { + "publishDiagnostics": { + "relatedInformation": True, + "versionSupport": False, + "tagSupport": {"valueSet": [1, 2]}, + "codeDescriptionSupport": True, + "dataSupport": True, + }, + "diagnostic": {"dynamicRegistration": True, "relatedDocumentSupport": False}, "synchronization": { "dynamicRegistration": True, "willSave": True, diff --git a/src/solidlsp/language_servers/powershell_language_server.py b/src/solidlsp/language_servers/powershell_language_server.py index 72f9ac27..e737b09a 100644 --- a/src/solidlsp/language_servers/powershell_language_server.py +++ b/src/solidlsp/language_servers/powershell_language_server.py @@ -5,6 +5,8 @@ Contains various configurations and settings specific to PowerShell scripting. You can pass the following entries in ``ls_specific_settings["powershell"]``: - pses_version: Override the pinned PowerShell Editor Services version downloaded by Serena (default: the bundled Serena version). + - psscriptanalyzer_version: Override the pinned PSScriptAnalyzer version + saved into the bundled PowerShell Editor Services module path. """ import logging @@ -12,6 +14,7 @@ import os import pathlib import platform import shutil +import subprocess import tempfile import threading from collections.abc import Hashable @@ -19,7 +22,8 @@ from pathlib import Path from overrides import override -from solidlsp.ls import RawDocumentSymbol, SolidLanguageServer +from solidlsp import ls_types +from solidlsp.ls import LSPConstants, RawDocumentSymbol, SolidLanguageServer from solidlsp.ls_config import Language, LanguageServerConfig from solidlsp.ls_types import SymbolKind from solidlsp.ls_utils import FileUtils @@ -33,6 +37,7 @@ log = logging.getLogger(__name__) PSES_VERSION = "4.4.0" PSES_SHA256 = "690b91092989a0f66e6f43986166aaef69d64b559a9fda51feed882e1103fbcc" PSES_ALLOWED_HOSTS = ("github.com", "release-assets.githubusercontent.com", "objects.githubusercontent.com") +PSSCRIPTANALYZER_VERSION = "1.25.0" class PowerShellLanguageServer(SolidLanguageServer): @@ -157,6 +162,29 @@ class PowerShellLanguageServer(SolidLanguageServer): # The bundled modules path is the directory containing PowerShellEditorServices bundled_modules_path = str(Path(pses_path).parent) + psscriptanalyzer_version = solidlsp_settings.get_ls_specific_settings(Language.POWERSHELL).get( + "psscriptanalyzer_version", PSSCRIPTANALYZER_VERSION + ) + psscriptanalyzer_path = Path(bundled_modules_path) / "PSScriptAnalyzer" / psscriptanalyzer_version + if not psscriptanalyzer_path.exists(): + log.info(f"PSScriptAnalyzer {psscriptanalyzer_version} not found. Installing...") + subprocess.run( + [ + pwsh_path, + "-NoLogo", + "-NoProfile", + "-Command", + ( + "Save-Module " + "-Name PSScriptAnalyzer " + f"-RequiredVersion '{psscriptanalyzer_version}' " + f"-Path '{bundled_modules_path}' " + "-Force " + "-ErrorAction Stop" + ), + ], + check=True, + ) return pwsh_path, pses_path, bundled_modules_path @@ -345,3 +373,35 @@ class PowerShellLanguageServer(SolidLanguageServer): self.server_ready.set() else: log.info("PowerShell Editor Services initialization complete") + + @override + def request_text_document_diagnostics( + self, + relative_file_path: str, + start_line: int = 0, + end_line: int = -1, + min_severity: int = 4, + ) -> list[ls_types.Diagnostic]: + uri = self._validate_text_document_diagnostics_request(relative_file_path, start_line, end_line, min_severity) + published_uri = self._get_published_diagnostics_uri(uri) + diagnostics_before_request = self._get_published_diagnostics_generation(published_uri) + + with self.open_file(relative_file_path): + self.server.notify.did_save_text_document( + { + LSPConstants.TEXT_DOCUMENT: { # type: ignore + LSPConstants.URI: uri, + } + } + ) + diagnostics = self._wait_for_relevant_published_diagnostics( + uri=published_uri, + after_generation=diagnostics_before_request, + timeout=self._get_published_diagnostics_wait_timeout(True), + allow_cached=True, + ) + + if diagnostics is None: + return [] + + return self._filter_diagnostics(diagnostics, start_line, end_line, min_severity) diff --git a/src/solidlsp/language_servers/pyright_server.py b/src/solidlsp/language_servers/pyright_server.py index efeefdaa..7f774836 100644 --- a/src/solidlsp/language_servers/pyright_server.py +++ b/src/solidlsp/language_servers/pyright_server.py @@ -155,6 +155,52 @@ class PyrightServer(SolidLanguageServer): self.found_source_files = True self.analysis_complete.set() + def handle_pyright_progress_notification(progress_kind: str, params: object | None) -> None: + """Tracks Pyright-specific progress notifications. + + Pyright can emit custom progress notifications instead of only using + ``$/progress``. Handling them avoids noisy unhandled-method warnings + and provides an additional signal that initial analysis has quiesced. + """ + # normalizing the notification payload + message_text = "" + percentage: object | None = None + if isinstance(params, dict): + raw_message = params.get("message") + message_text = "" if raw_message is None else str(raw_message) + percentage = params.get("percentage") + elif params is not None: + message_text = str(params) + + progress_label = f"{message_text} ({percentage}%)" if percentage is not None else message_text + + # logging the progress transition + if progress_kind == "begin": + log.info("Pyright progress started: %s", progress_label) + return + + if progress_kind == "report": + log.debug("Pyright progress update: %s", progress_label) + return + + log.info("Pyright progress finished: %s", progress_label) + self.analysis_complete.set() + + def pyright_begin_progress(params: object | None) -> None: + """Handles the ``pyright/beginProgress`` notification.""" + # delegating to the shared progress handler + handle_pyright_progress_notification("begin", params) + + def pyright_report_progress(params: object | None) -> None: + """Handles the ``pyright/reportProgress`` notification.""" + # delegating to the shared progress handler + handle_pyright_progress_notification("report", params) + + def pyright_end_progress(params: object | None) -> None: + """Handles the ``pyright/endProgress`` notification.""" + # delegating to the shared progress handler + handle_pyright_progress_notification("end", params) + def check_experimental_status(params: dict) -> None: """ Also listen for experimental/serverStatus as a backup signal @@ -170,6 +216,9 @@ class PyrightServer(SolidLanguageServer): self.server.on_notification("window/logMessage", window_log_message) self.server.on_request("workspace/executeClientCommand", execute_client_command_handler) self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("pyright/beginProgress", pyright_begin_progress) + self.server.on_notification("pyright/reportProgress", pyright_report_progress) + self.server.on_notification("pyright/endProgress", pyright_end_progress) self.server.on_notification("textDocument/publishDiagnostics", do_nothing) self.server.on_notification("language/actionableNotification", do_nothing) self.server.on_notification("experimental/serverStatus", check_experimental_status) diff --git a/src/solidlsp/language_servers/solidity_language_server.py b/src/solidlsp/language_servers/solidity_language_server.py index 1e7ccd2e..a41deb89 100644 --- a/src/solidlsp/language_servers/solidity_language_server.py +++ b/src/solidlsp/language_servers/solidity_language_server.py @@ -7,13 +7,17 @@ import glob import logging import os import pathlib +import platform import shutil import threading from time import sleep from typing import Any +from overrides import override + +from solidlsp import ls_types from solidlsp.language_servers.common import RuntimeDependency, RuntimeDependencyCollection, build_npm_install_command -from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer +from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, LSPConstants, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.settings import SolidLSPSettings @@ -70,6 +74,7 @@ class SolidityLanguageServer(SolidLanguageServer): is_npm_installed = shutil.which("npm") is not None assert is_npm_installed, "npm is not installed or isn't in PATH. Please install npm and try again." solidity_language_server_version = self._custom_settings.get("solidity_language_server_version", "0.8.4") + forge_version = self._custom_settings.get("forge_version", "1.5.1") npm_registry = self._custom_settings.get("npm_registry") deps = RuntimeDependencyCollection( @@ -84,17 +89,29 @@ class SolidityLanguageServer(SolidLanguageServer): ), platform_id="any", ), + RuntimeDependency( + id="forge", + description="Foundry forge CLI", + # the @foundry-rs/forge meta-package's postinstall uses POSIX env-var + # syntax that fails on Windows cmd.exe, so we install the platform-specific + # subpackage directly to bypass it + command=build_npm_install_command(self._get_forge_npm_package(), forge_version, npm_registry), + platform_id="any", + ), ] ) solidity_ls_dir = os.path.join(self._ls_resources_dir, "solidity-lsp") - solidity_executable_path = os.path.join(solidity_ls_dir, "node_modules", ".bin", "nomicfoundation-solidity-language-server") + managed_bin_dir = os.path.join(solidity_ls_dir, "node_modules", ".bin") + solidity_executable_path = os.path.join(managed_bin_dir, "nomicfoundation-solidity-language-server") + forge_executable_path = os.path.join(managed_bin_dir, "forge") if os.name == "nt": solidity_executable_path += ".cmd" + forge_executable_path += ".cmd" - if not os.path.exists(solidity_executable_path): - log.info(f"Solidity Language Server executable not found at {solidity_executable_path}. Installing...") + if not os.path.exists(solidity_executable_path) or not os.path.exists(forge_executable_path): + log.info("Solidity Language Server dependencies missing. Installing...") deps.install(solidity_ls_dir) log.info("Solidity language server dependencies installed successfully.") @@ -106,6 +123,33 @@ class SolidityLanguageServer(SolidLanguageServer): return solidity_executable_path + @staticmethod + def _get_forge_npm_package() -> str: + """ + Returns the platform-specific @foundry-rs forge npm subpackage name. Installing + the meta-package fails on Windows due to a POSIX-only postinstall script. + """ + machine = platform.machine().lower() + if machine in ("x86_64", "amd64"): + arch = "amd64" + elif machine in ("arm64", "aarch64"): + arch = "arm64" + else: + raise RuntimeError(f"Unsupported architecture for foundry forge: {machine}") + + if os.name == "nt": + if arch != "amd64": + raise RuntimeError(f"foundry forge does not publish a Windows {arch} npm package") + return "@foundry-rs/forge-win32-amd64" + if platform.system() == "Darwin": + return f"@foundry-rs/forge-darwin-{arch}" + return f"@foundry-rs/forge-linux-{arch}" + + def create_launch_command_env(self) -> dict[str, str]: + solidity_ls_dir = os.path.join(self._ls_resources_dir, "solidity-lsp") + managed_bin_dir = os.path.join(solidity_ls_dir, "node_modules", ".bin") + return {"PATH": managed_bin_dir + os.pathsep + os.environ.get("PATH", "")} + def _create_launch_command(self, core_path: str) -> list[str]: return [core_path, "--stdio"] @@ -225,3 +269,83 @@ class SolidityLanguageServer(SolidLanguageServer): log.info("No .sol files found; skipping indexing wait") log.info("Solidity language server initialization complete") + + @override + def request_text_document_diagnostics( + self, + relative_file_path: str, + start_line: int = 0, + end_line: int = -1, + min_severity: int = 4, + ) -> list[ls_types.Diagnostic]: + self._validate_text_document_diagnostics_request(relative_file_path, start_line, end_line, min_severity) + uri = self._get_validation_document_uri(relative_file_path) + diagnostics_before_request = self._get_published_diagnostics_generation(uri) + + absolute_path = pathlib.Path(self.repository_root_path, relative_file_path) + contents = absolute_path.read_text(encoding=self._encoding) + lines = contents.split("\n") + end_position = { + "line": len(lines) - 1, + "character": len(lines[-1]), + } + + self.server.notify.did_open_text_document( + { + LSPConstants.TEXT_DOCUMENT: { # type: ignore + LSPConstants.URI: uri, + LSPConstants.LANGUAGE_ID: self.language_id, + LSPConstants.VERSION: 0, + LSPConstants.TEXT: contents, + } + } + ) + try: + self.server.notify.did_change_text_document( + { + LSPConstants.TEXT_DOCUMENT: { # type: ignore + LSPConstants.URI: uri, + LSPConstants.VERSION: 1, + }, + LSPConstants.CONTENT_CHANGES: [ # type: ignore + { + LSPConstants.RANGE: { + "start": {"line": 0, "character": 0}, + "end": end_position, + }, + "text": contents, + } + ], + } + ) + diagnostics = self._wait_for_relevant_published_diagnostics( + uri=uri, + after_generation=diagnostics_before_request, + timeout=self._get_published_diagnostics_wait_timeout(True), + allow_cached=True, + ) + finally: + self.server.notify.did_close_text_document( + { + LSPConstants.TEXT_DOCUMENT: { # type: ignore + LSPConstants.URI: uri, + } + } + ) + + if diagnostics is None: + return [] + + return self._filter_diagnostics(diagnostics, start_line, end_line, min_severity) + + def _get_validation_document_uri(self, relative_file_path: str) -> str: + absolute_path = pathlib.Path(self.repository_root_path, relative_file_path) + + if os.name != "nt": + return absolute_path.as_uri() + + path = absolute_path.as_posix() + if len(path) >= 2 and path[1] == ":": + path = f"{path[0].lower()}%3A{path[2:]}" + + return f"file:///{path.lstrip('/')}" diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index 4ea04694..57a5cdfd 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -618,15 +618,49 @@ class SolidLanguageServer(ABC): normalized_diagnostic["source"] = diagnostic["source"] normalized_diagnostics.append(ls_types.Diagnostic(**normalized_diagnostic)) + # canonicalize the key so lookups using URIs produced by pathlib.Path.as_uri() match + # what servers publish (e.g. file:///c%3A/... or file:///c:/... vs. file:///C:/...) + key = self._canonicalize_published_diagnostics_uri(uri) + with self._published_diagnostics_condition: self._published_diagnostics_generation += 1 - self._published_diagnostics[uri] = normalized_diagnostics - self._published_diagnostics_generation_by_uri[uri] = self._published_diagnostics_generation + self._published_diagnostics[key] = normalized_diagnostics + self._published_diagnostics_generation_by_uri[key] = self._published_diagnostics_generation self._published_diagnostics_condition.notify_all() + @staticmethod + def _canonicalize_published_diagnostics_uri(uri: str) -> str: + """ + Canonicalizes a ``file://`` URI so that diagnostics published by language servers + and lookups based on ``pathlib.Path.as_uri()`` agree on the same key. + + On Windows, servers may publish under ``file:///c%3A/...`` or ``file:///c:/...`` while + ``pathlib.Path.as_uri()`` produces ``file:///C:/...``. The canonical form uses an + upper-case drive letter and a plain colon. + """ + if os.name != "nt" or not uri.startswith("file:///"): + return uri + + # extract the segment after "file:///" up to the next slash and look for a drive letter + prefix = "file:///" + rest = uri[len(prefix) :] + slash = rest.find("/") + head = rest if slash < 0 else rest[:slash] + tail = "" if slash < 0 else rest[slash:] + + if (len(head) >= 2 and head[0].isalpha() and head[1] == ":") or ( + len(head) >= 4 and head[0].isalpha() and head[1:4].lower() == "%3a" + ): + head = head[0].upper() + ":" + else: + return uri + + return prefix + head + tail + def _get_published_diagnostics_generation(self, uri: str) -> int: + key = self._canonicalize_published_diagnostics_uri(uri) with self._published_diagnostics_condition: - return self._published_diagnostics_generation_by_uri.get(uri, -1) + return self._published_diagnostics_generation_by_uri.get(key, -1) def _wait_for_published_diagnostics( self, @@ -634,12 +668,13 @@ class SolidLanguageServer(ABC): after_generation: int, timeout: float, ) -> list[ls_types.Diagnostic] | None: + key = self._canonicalize_published_diagnostics_uri(uri) deadline = perf_counter() + timeout with self._published_diagnostics_condition: while True: - current_generation = self._published_diagnostics_generation_by_uri.get(uri, -1) + current_generation = self._published_diagnostics_generation_by_uri.get(key, -1) if current_generation > after_generation: - return list(self._published_diagnostics.get(uri, [])) + return list(self._published_diagnostics.get(key, [])) remaining_timeout = deadline - perf_counter() if remaining_timeout <= 0: @@ -647,8 +682,9 @@ class SolidLanguageServer(ABC): self._published_diagnostics_condition.wait(timeout=remaining_timeout) def _get_cached_published_diagnostics(self, uri: str) -> list[ls_types.Diagnostic] | None: + key = self._canonicalize_published_diagnostics_uri(uri) with self._published_diagnostics_condition: - diagnostics = self._published_diagnostics.get(uri) + diagnostics = self._published_diagnostics.get(key) if diagnostics is None: return None return list(diagnostics) @@ -657,8 +693,17 @@ class SolidLanguageServer(ABC): def _diagnostic_matches_range(diagnostic: ls_types.Diagnostic, start_line: int, end_line: int) -> bool: diagnostic_start_line = diagnostic["range"]["start"]["line"] diagnostic_end_line = diagnostic["range"]["end"]["line"] - effective_end_line = end_line if end_line >= 0 else diagnostic_end_line - return diagnostic_start_line <= effective_end_line and diagnostic_end_line >= start_line + + # normalize inverted ranges: some servers (e.g. Julia's JuliaSyntax.jl for unterminated + # expressions) emit diagnostics whose end position lies before the start position. + # treat such ranges as a single span covering both lines. + lo = min(diagnostic_start_line, diagnostic_end_line) + hi = max(diagnostic_start_line, diagnostic_end_line) + + # when end_line < 0 the caller imposes no upper bound; only enforce the lower bound. + if end_line < 0: + return hi >= start_line + return lo <= end_line and hi >= start_line @staticmethod def _diagnostic_matches_min_severity(diagnostic: ls_types.Diagnostic, min_severity: int) -> bool: @@ -777,6 +822,16 @@ class SolidLanguageServer(ABC): """ return request_uri + def _supports_pull_diagnostics(self) -> bool: + """ + Whether the language server handles ``textDocument/diagnostic`` (LSP 3.17 pull + diagnostics) gracefully. Subclasses should override and return ``False`` when the + underlying server does not implement pull diagnostics, especially when sending + the request would terminate the server (e.g. LanguageServer.jl raises an error + for unknown methods that crashes the process). + """ + return True + def _get_published_diagnostics_wait_timeout(self, pull_diagnostics_failed: bool) -> float: """ Gets the timeout for waiting on published diagnostics after a diagnostics request. @@ -851,18 +906,22 @@ class SolidLanguageServer(ABC): pull_diagnostics_failed = False with self.open_file(relative_file_path): - try: - response = self.server.send.text_document_diagnostic( - { - LSPConstants.TEXT_DOCUMENT: { # type: ignore - LSPConstants.URI: uri, + response: Any = None + # only send pull diagnostics when the server actually supports it; some servers + # (e.g. Julia's LanguageServer.jl) hard-error and crash the process on unknown methods + if self._supports_pull_diagnostics(): + try: + response = self.server.send.text_document_diagnostic( + { + LSPConstants.TEXT_DOCUMENT: { # type: ignore + LSPConstants.URI: uri, + } } - } - ) - except SolidLSPException as ex: - log.debug("Falling back to published diagnostics for %s due to pull-diagnostics error: %s", relative_file_path, ex) - response = None - pull_diagnostics_failed = True + ) + except SolidLSPException as ex: + log.debug("Falling back to published diagnostics for %s due to pull-diagnostics error: %s", relative_file_path, ex) + response = None + pull_diagnostics_failed = True if response is not None: assert isinstance(response, dict), ( @@ -875,7 +934,7 @@ class SolidLanguageServer(ABC): "severity": item["severity"], "message": item["message"], "range": item["range"], - "code": item["code"], # type: ignore + "code": item.get("code"), # type: ignore } if "source" in item: new_item["source"] = item["source"] diff --git a/test/serena/test_serena_agent.py b/test/serena/test_serena_agent.py index a577b849..974cdc4b 100644 --- a/test/serena/test_serena_agent.py +++ b/test/serena/test_serena_agent.py @@ -513,7 +513,7 @@ FIND_SYMBOL_REFERENCES_CASES = [ symbol_name="Calculator", expected_kind="Module", expected_file="Calculator.fs", - ).to_pytest_param(), + ).to_pytest_param(pytest.mark.xfail(reason="F# language server is unreliable")), FindSymbolCase( language=Language.RUST, id="rust_add_function", symbol_name="add", expected_kind="Function", expected_file="lib.rs" ).to_pytest_param(), diff --git a/test/solidlsp/go/test_go_basic.py b/test/solidlsp/go/test_go_basic.py index 02aa3b9b..347f6022 100644 --- a/test/solidlsp/go/test_go_basic.py +++ b/test/solidlsp/go/test_go_basic.py @@ -71,9 +71,9 @@ class TestGoLanguageServer: implementing_symbols = language_server.request_implementing_symbols("main.go", *pos) assert implementing_symbols, "Expected implementing symbols for Greeter.FormatGreeting" assert any( - symbol.get("name") == "(ConsoleGreeter).FormatGreeting" and "main.go" in symbol["location"].get("relativePath", "") + symbol.get("name") == "FormatGreeting" and "main.go" in symbol["location"].get("relativePath", "") for symbol in implementing_symbols - ), f"Expected ConsoleGreeter.FormatGreeting symbol, got: {implementing_symbols}" + ), f"Expected FormatGreeting symbol, got: {implementing_symbols}" def _filter_symbols_by_name_in_repo(symbols: list | None, target_name: str, repo_name: str = "test_repo") -> list: From 15858e6cd99c30ae26cc5bc038d1be9b58e9b8d4 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Wed, 29 Apr 2026 21:58:10 +0200 Subject: [PATCH 07/12] Refactoring: Introduce base class EditingToolWithDiagnostics --- src/serena/tools/file_tools.py | 14 +- src/serena/tools/symbol_tools.py | 7 +- src/serena/tools/tools_base.py | 212 ++++++++++++++++--------------- 3 files changed, 122 insertions(+), 111 deletions(-) diff --git a/src/serena/tools/file_tools.py b/src/serena/tools/file_tools.py index 42923a03..4572f2fc 100644 --- a/src/serena/tools/file_tools.py +++ b/src/serena/tools/file_tools.py @@ -13,7 +13,7 @@ from pathlib import Path from typing import Literal from serena.code_editor import EditedFilePath -from serena.tools import SUCCESS_RESULT, EditedFileContext, Tool, ToolMarkerCanEdit, ToolMarkerOptional +from serena.tools import SUCCESS_RESULT, EditedFileContext, EditingToolWithDiagnostics, Tool, ToolMarkerOptional from serena.util.file_system import scan_directory from serena.util.text_utils import ContentReplacer, search_files @@ -49,7 +49,7 @@ class ReadFileTool(Tool): return self._limit_length(result, max_answer_chars) -class CreateTextFileTool(Tool, ToolMarkerCanEdit): +class CreateTextFileTool(EditingToolWithDiagnostics): """ Creates/overwrites a file in the project directory. """ @@ -164,13 +164,11 @@ class FindFileTool(Tool): return result -class ReplaceContentTool(Tool, ToolMarkerCanEdit): +class ReplaceContentTool(EditingToolWithDiagnostics): """ Replaces content in a file (optionally using regular expressions). """ - _CAPTURE_DIAGNOSTICS: bool = False - def apply( self, relative_path: str, @@ -237,7 +235,7 @@ class ReplaceContentTool(Tool, ToolMarkerCanEdit): return self._format_lsp_edit_result_with_new_diagnostics(SUCCESS_RESULT, edited_file_paths, diagnostics_snapshot) -class DeleteLinesTool(Tool, ToolMarkerCanEdit, ToolMarkerOptional): +class DeleteLinesTool(EditingToolWithDiagnostics, ToolMarkerOptional): """ Deletes a range of lines within a file. """ @@ -268,7 +266,7 @@ class DeleteLinesTool(Tool, ToolMarkerCanEdit, ToolMarkerOptional): return self._format_lsp_edit_result_with_new_diagnostics(SUCCESS_RESULT, edited_file_paths, diagnostics_snapshot) -class ReplaceLinesTool(Tool, ToolMarkerCanEdit, ToolMarkerOptional): +class ReplaceLinesTool(EditingToolWithDiagnostics, ToolMarkerOptional): """ Replaces a range of lines within a file with new content. """ @@ -306,7 +304,7 @@ class ReplaceLinesTool(Tool, ToolMarkerCanEdit, ToolMarkerOptional): return self._format_lsp_edit_result_with_new_diagnostics(SUCCESS_RESULT, edited_file_paths, diagnostics_snapshot) -class InsertAtLineTool(Tool, ToolMarkerCanEdit, ToolMarkerOptional): +class InsertAtLineTool(EditingToolWithDiagnostics, ToolMarkerOptional): """ Inserts content at a given line in a file. """ diff --git a/src/serena/tools/symbol_tools.py b/src/serena/tools/symbol_tools.py index 2a44a14e..036c1d71 100644 --- a/src/serena/tools/symbol_tools.py +++ b/src/serena/tools/symbol_tools.py @@ -13,6 +13,7 @@ from serena.code_editor import EditedFilePath from serena.symbol import LanguageServerSymbol, LanguageServerSymbolDictGrouper from serena.tools import ( SUCCESS_RESULT, + EditingToolWithDiagnostics, Tool, ToolMarkerSymbolicEdit, ToolMarkerSymbolicRead, @@ -771,7 +772,7 @@ class GetDiagnosticsForSymbolTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOption return self._limit_length(result, max_answer_chars) -class ReplaceSymbolBodyTool(Tool, ToolMarkerSymbolicEdit): +class ReplaceSymbolBodyTool(EditingToolWithDiagnostics): """ Replaces the full definition of a symbol using the language server backend. """ @@ -810,7 +811,7 @@ class ReplaceSymbolBodyTool(Tool, ToolMarkerSymbolicEdit): return self._format_lsp_edit_result_with_new_diagnostics(SUCCESS_RESULT, edited_file_paths, diagnostics_snapshot) -class InsertAfterSymbolTool(Tool, ToolMarkerSymbolicEdit): +class InsertAfterSymbolTool(EditingToolWithDiagnostics): """ Inserts content after the end of the definition of a given symbol. """ @@ -841,7 +842,7 @@ class InsertAfterSymbolTool(Tool, ToolMarkerSymbolicEdit): return self._format_lsp_edit_result_with_new_diagnostics(SUCCESS_RESULT, edited_file_paths, diagnostics_snapshot) -class InsertBeforeSymbolTool(Tool, ToolMarkerSymbolicEdit): +class InsertBeforeSymbolTool(EditingToolWithDiagnostics): """ Inserts content before the beginning of the definition of a given symbol. """ diff --git a/src/serena/tools/tools_base.py b/src/serena/tools/tools_base.py index 7ef28c3c..ca8e5a49 100644 --- a/src/serena/tools/tools_base.py +++ b/src/serena/tools/tools_base.py @@ -165,8 +165,6 @@ class Tool(Component): _last_tool_call_client_str: str | None = None """We can only get the client info from within a tool call. Each tool call will update this variable.""" - _ENABLE_DIAGNOSTICS: bool = False - def __init__(self, agent: "SerenaAgent"): super().__init__(agent) @@ -326,6 +324,118 @@ class Tool(Component): def is_symbolic(self) -> bool: return issubclass(self.__class__, ToolMarkerSymbolicRead) or issubclass(self.__class__, ToolMarkerSymbolicEdit) + def apply_ex(self, log_call: bool = True, catch_exceptions: bool = True, mcp_ctx: Context | None = None, **kwargs) -> str: # type: ignore + """ + Applies the tool with logging and exception handling, using the given keyword arguments + """ + session_id = "global" + if mcp_ctx is not None: + try: + session_id = "%x" % id(mcp_ctx.session) + client_params = mcp_ctx.session.client_params + if client_params is not None: + client_info = cast(Implementation, client_params.clientInfo) + client_str = client_info.title if client_info.title else client_info.name + " " + client_info.version + if client_str != self.get_last_tool_call_client_str(): + log.debug(f"Updating client info: {client_info}") + self.set_last_tool_call_client_str(client_str) + except Exception as e: + log.info(f"Failed to get client info: {e}.") + + def task() -> str: + apply_fn = self.get_apply_fn() + + try: + if not self.is_active(): + return f"Error: Tool '{self.get_name_from_cls()}' is not active. Active tools: {self.agent.get_active_tool_names()}" + except Exception as e: + return f"RuntimeError while checking if tool {self.get_name_from_cls()} is active: {e}" + + if log_call: + self._log_tool_application(inspect.currentframe(), session_id) + try: + # check whether the tool requires an active project and language server + if not isinstance(self, ToolMarkerDoesNotRequireActiveProject): + if self.agent.get_active_project() is None: + return ( + "Error: No active project. Ask the user to provide the project path or to select a project from this list of known projects: " + + f"{self.agent.serena_config.project_names}" + ) + + # construct apply kwargs, adding session_id if the tool is session-aware + apply_kwargs = dict(kwargs) + if self._is_session_aware: + apply_kwargs["session_id"] = session_id + + # apply the actual tool + try: + result = apply_fn(**apply_kwargs) + except SolidLSPException as e: + if e.is_language_server_terminated(): + affected_language = e.get_affected_language() + if affected_language is not None: + log.error( + f"Language server terminated while executing tool ({e}). Restarting the language server and retrying ..." + ) + self.agent.get_language_server_manager_or_raise().restart_language_server(affected_language) + result = apply_fn(**apply_kwargs) + else: + log.error( + f"Language server terminated while executing tool ({e}), but affected language is unknown. Not retrying." + ) + raise + else: + raise + + # record tool usage + self.agent.record_tool_usage(apply_kwargs, result, self) + + except Exception as e: + if not catch_exceptions: + raise + msg = f"Error executing tool: {e.__class__.__name__} - {e}" + log.error(f"Error executing tool: {e}", exc_info=e) + result = msg + + if log_call: + log.info(f"Result: {result}") + + try: + ls_manager = self.agent.get_language_server_manager() + if ls_manager is not None: + ls_manager.save_all_caches() + except Exception as e: + log.error(f"Error saving language server cache: {e}") + + return result + + # execute the tool in the agent's task executor, with timeout + try: + task_exec = self.agent.issue_task(task, name=self.__class__.__name__) + return task_exec.result(timeout=self.agent.serena_config.tool_timeout) + except Exception as e: # typically TimeoutError (other exceptions caught in task) + msg = f"Error: {e.__class__.__name__} - {e}" + log.error(msg) + return msg + + @staticmethod + def _to_json(x: Any) -> str: + return json.dumps(x, ensure_ascii=False) + + +class EditingToolWithDiagnostics(Tool, ToolMarkerCanEdit): + """ + Base class for editing tools that want to capture and report changes in LSP diagnostics before and after the edit. + """ + + _ENABLE_DIAGNOSTICS: bool = False + """ + Global flag to enable/disable diagnostics for LSP-based editing tools derived from this class. + The feature is currently disabled, because per-edit diagnostics are a questionable feature, since individual + edits often intentionally introduce diagnostics (e.g. function signature mismatches or even syntax errors) that + are then resolved in subsequent edits. + """ + @staticmethod def _diagnostic_code_repr(code: Any) -> str | None: if code is None: @@ -500,104 +610,6 @@ class Tool(Component): return f"Edit introduced new warning-or-higher diagnostics: {self._to_json(grouped_result)}" - def apply_ex(self, log_call: bool = True, catch_exceptions: bool = True, mcp_ctx: Context | None = None, **kwargs) -> str: # type: ignore - """ - Applies the tool with logging and exception handling, using the given keyword arguments - """ - session_id = "global" - if mcp_ctx is not None: - try: - session_id = "%x" % id(mcp_ctx.session) - client_params = mcp_ctx.session.client_params - if client_params is not None: - client_info = cast(Implementation, client_params.clientInfo) - client_str = client_info.title if client_info.title else client_info.name + " " + client_info.version - if client_str != self.get_last_tool_call_client_str(): - log.debug(f"Updating client info: {client_info}") - self.set_last_tool_call_client_str(client_str) - except Exception as e: - log.info(f"Failed to get client info: {e}.") - - def task() -> str: - apply_fn = self.get_apply_fn() - - try: - if not self.is_active(): - return f"Error: Tool '{self.get_name_from_cls()}' is not active. Active tools: {self.agent.get_active_tool_names()}" - except Exception as e: - return f"RuntimeError while checking if tool {self.get_name_from_cls()} is active: {e}" - - if log_call: - self._log_tool_application(inspect.currentframe(), session_id) - try: - # check whether the tool requires an active project and language server - if not isinstance(self, ToolMarkerDoesNotRequireActiveProject): - if self.agent.get_active_project() is None: - return ( - "Error: No active project. Ask the user to provide the project path or to select a project from this list of known projects: " - + f"{self.agent.serena_config.project_names}" - ) - - # construct apply kwargs, adding session_id if the tool is session-aware - apply_kwargs = dict(kwargs) - if self._is_session_aware: - apply_kwargs["session_id"] = session_id - - # apply the actual tool - try: - result = apply_fn(**apply_kwargs) - except SolidLSPException as e: - if e.is_language_server_terminated(): - affected_language = e.get_affected_language() - if affected_language is not None: - log.error( - f"Language server terminated while executing tool ({e}). Restarting the language server and retrying ..." - ) - self.agent.get_language_server_manager_or_raise().restart_language_server(affected_language) - result = apply_fn(**apply_kwargs) - else: - log.error( - f"Language server terminated while executing tool ({e}), but affected language is unknown. Not retrying." - ) - raise - else: - raise - - # record tool usage - self.agent.record_tool_usage(apply_kwargs, result, self) - - except Exception as e: - if not catch_exceptions: - raise - msg = f"Error executing tool: {e.__class__.__name__} - {e}" - log.error(f"Error executing tool: {e}", exc_info=e) - result = msg - - if log_call: - log.info(f"Result: {result}") - - try: - ls_manager = self.agent.get_language_server_manager() - if ls_manager is not None: - ls_manager.save_all_caches() - except Exception as e: - log.error(f"Error saving language server cache: {e}") - - return result - - # execute the tool in the agent's task executor, with timeout - try: - task_exec = self.agent.issue_task(task, name=self.__class__.__name__) - return task_exec.result(timeout=self.agent.serena_config.tool_timeout) - except Exception as e: # typically TimeoutError (other exceptions caught in task) - msg = f"Error: {e.__class__.__name__} - {e}" - log.error(msg) - return msg - - @staticmethod - def _to_json(x: Any) -> str: - return json.dumps(x, ensure_ascii=False) - class EditedFileContext: """ From 061020ce226e7f0790ee3baffe97c6f2b4bdef82 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Wed, 29 Apr 2026 23:18:47 +0200 Subject: [PATCH 08/12] Refactor EditingToolWithDiagnostics * Add context manager to simplify usage, improving output handling * Add util module ls_diagnostics, reifying DiagnosticsDiff --- scripts/demo_diagnostics.py | 12 +- src/serena/code_editor.py | 8 +- src/serena/tools/file_tools.py | 100 +++++-------- src/serena/tools/symbol_tools.py | 48 +++---- src/serena/tools/tools_base.py | 227 +++++------------------------- src/serena/util/ls_diagnostics.py | 202 ++++++++++++++++++++++++++ test/serena/test_serena_agent.py | 69 +++++---- 7 files changed, 343 insertions(+), 323 deletions(-) create mode 100644 src/serena/util/ls_diagnostics.py diff --git a/scripts/demo_diagnostics.py b/scripts/demo_diagnostics.py index 7aa85361..2e5cd30e 100644 --- a/scripts/demo_diagnostics.py +++ b/scripts/demo_diagnostics.py @@ -16,7 +16,13 @@ from serena.agent import SerenaAgent from serena.config.serena_config import LanguageBackend, ProjectConfig, RegisteredProject, SerenaConfig from serena.constants import REPO_ROOT from serena.project import Project -from serena.tools import CreateTextFileTool, GetDiagnosticsForFileTool, GetDiagnosticsForSymbolTool, ReplaceContentTool, Tool +from serena.tools import ( + CreateTextFileTool, + EditingToolWithDiagnostics, + GetDiagnosticsForFileTool, + GetDiagnosticsForSymbolTool, + ReplaceContentTool, +) from solidlsp.ls_config import Language SEPARATOR = "=" * 80 @@ -69,7 +75,7 @@ def parse_edit_diagnostics_result(result: str) -> dict: if __name__ == "__main__": - Tool._ENABLE_DIAGNOSTICS = True + EditingToolWithDiagnostics.ENABLE_DIAGNOSTICS = True temp_dir = Path(tempfile.mkdtemp(prefix="serena_demo_", dir=REPO_PATH)) temp_file = temp_dir / "demo_temp_diagnostics.py" @@ -166,5 +172,5 @@ if __name__ == "__main__": assert "missing_one" in diagnostics_after_second_edit_json, diagnostics_after_second_edit assert "missing_two" in diagnostics_after_second_edit_json, diagnostics_after_second_edit finally: - agent.shutdown() shutil.rmtree(temp_dir, ignore_errors=True) + agent.shutdown() diff --git a/src/serena/code_editor.py b/src/serena/code_editor.py index 7d52f2b8..ec834273 100644 --- a/src/serena/code_editor.py +++ b/src/serena/code_editor.py @@ -4,7 +4,6 @@ import os from abc import ABC, abstractmethod from collections.abc import Iterable, Iterator, Reversible from contextlib import contextmanager -from dataclasses import dataclass from typing import Generic, TypeVar, cast from serena.jetbrains.jetbrains_plugin_client import JetBrainsPluginClient @@ -14,17 +13,12 @@ from solidlsp.ls import LSPFileBuffer from solidlsp.ls_utils import PathUtils, TextUtils from .project import Project +from .util.ls_diagnostics import EditedFilePath log = logging.getLogger(__name__) TSymbol = TypeVar("TSymbol", bound=Symbol) -@dataclass(frozen=True) -class EditedFilePath: - before_relative_path: str - after_relative_path: str - - class CodeEditor(Generic[TSymbol], ABC): def __init__(self, project: Project) -> None: self.project_root = project.project_root diff --git a/src/serena/tools/file_tools.py b/src/serena/tools/file_tools.py index 4572f2fc..4b4313eb 100644 --- a/src/serena/tools/file_tools.py +++ b/src/serena/tools/file_tools.py @@ -12,7 +12,6 @@ from fnmatch import fnmatch from pathlib import Path from typing import Literal -from serena.code_editor import EditedFilePath from serena.tools import SUCCESS_RESULT, EditedFileContext, EditingToolWithDiagnostics, Tool, ToolMarkerOptional from serena.util.file_system import scan_directory from serena.util.text_utils import ContentReplacer, search_files @@ -62,30 +61,27 @@ class CreateTextFileTool(EditingToolWithDiagnostics): :param content: the (appropriately encoded) content to write to the file :return: a message indicating success or failure """ - # capturing diagnostics before the edit - edited_file_paths = [EditedFilePath(relative_path, relative_path)] - diagnostics_snapshot = self._capture_published_lsp_diagnostics_snapshot(edited_file_paths) + with self.DiagnosticsContext(self, relative_path) as diagnostics_context: + # validating the destination path + project_root = self.get_project_root() + abs_path = (Path(project_root) / relative_path).resolve() + will_overwrite_existing = abs_path.exists() - # validating the destination path - project_root = self.get_project_root() - abs_path = (Path(project_root) / relative_path).resolve() - will_overwrite_existing = abs_path.exists() + if will_overwrite_existing: + self.project.validate_relative_path(relative_path, require_not_ignored=True) + else: + assert abs_path.is_relative_to(self.get_project_root()), ( + f"Cannot create file outside of the project directory, got {relative_path=}" + ) - if will_overwrite_existing: - self.project.validate_relative_path(relative_path, require_not_ignored=True) - else: - assert abs_path.is_relative_to(self.get_project_root()), ( - f"Cannot create file outside of the project directory, got {relative_path=}" - ) + # writing the file + abs_path.parent.mkdir(parents=True, exist_ok=True) + abs_path.write_text(content, encoding=self.project.project_config.encoding, newline=self.project.line_ending.newline_str) + answer = f"File created: {relative_path}." + if will_overwrite_existing: + answer += " Overwrote existing file." - # writing the file - abs_path.parent.mkdir(parents=True, exist_ok=True) - abs_path.write_text(content, encoding=self.project.project_config.encoding, newline=self.project.line_ending.newline_str) - answer = f"File created: {relative_path}." - if will_overwrite_existing: - answer += " Overwrote existing file." - - return self._format_lsp_edit_result_with_new_diagnostics(answer, edited_file_paths, diagnostics_snapshot) + return diagnostics_context.format_result(answer) class ListDirTool(Tool): @@ -220,19 +216,14 @@ class ReplaceContentTool(EditingToolWithDiagnostics): Performs the replacement, with additional options not exposed in the tool. This function can be used internally by other tools. """ - # capturing diagnostics before the edit - edited_file_paths = [EditedFilePath(relative_path, relative_path)] - diagnostics_snapshot = self._capture_published_lsp_diagnostics_snapshot(edited_file_paths) - - # applying the content replacement - self.project.validate_relative_path(relative_path, require_not_ignored=require_not_ignored) - with EditedFileContext(relative_path, self.create_code_editor()) as context: - original_content = context.get_original_content() - replacer = ContentReplacer(mode=mode, allow_multiple_occurrences=allow_multiple_occurrences) - updated_content = replacer.replace(original_content, needle, repl) - context.set_updated_content(updated_content) - - return self._format_lsp_edit_result_with_new_diagnostics(SUCCESS_RESULT, edited_file_paths, diagnostics_snapshot) + with self.DiagnosticsContext(self, relative_path) as diagnostics_context: + self.project.validate_relative_path(relative_path, require_not_ignored=require_not_ignored) + with EditedFileContext(relative_path, self.create_code_editor()) as context: + original_content = context.get_original_content() + replacer = ContentReplacer(mode=mode, allow_multiple_occurrences=allow_multiple_occurrences) + updated_content = replacer.replace(original_content, needle, repl) + context.set_updated_content(updated_content) + return diagnostics_context.format_result(SUCCESS_RESULT) class DeleteLinesTool(EditingToolWithDiagnostics, ToolMarkerOptional): @@ -255,15 +246,10 @@ class DeleteLinesTool(EditingToolWithDiagnostics, ToolMarkerOptional): :param start_line: the 0-based index of the first line to be deleted :param end_line: the 0-based index of the last line to be deleted """ - # capturing diagnostics before the edit - edited_file_paths = [EditedFilePath(relative_path, relative_path)] - diagnostics_snapshot = self._capture_published_lsp_diagnostics_snapshot(edited_file_paths) - - # applying the edit - code_editor = self.create_code_editor() - code_editor.delete_lines(relative_path, start_line, end_line) - - return self._format_lsp_edit_result_with_new_diagnostics(SUCCESS_RESULT, edited_file_paths, diagnostics_snapshot) + with self.DiagnosticsContext(self, relative_path) as diagnostics_context: + code_editor = self.create_code_editor() + code_editor.delete_lines(relative_path, start_line, end_line) + return diagnostics_context.format_result(SUCCESS_RESULT) class ReplaceLinesTool(EditingToolWithDiagnostics, ToolMarkerOptional): @@ -292,16 +278,12 @@ class ReplaceLinesTool(EditingToolWithDiagnostics, ToolMarkerOptional): if not content.endswith("\n"): content += "\n" - # capturing diagnostics before the edit - edited_file_paths = [EditedFilePath(relative_path, relative_path)] - diagnostics_snapshot = self._capture_published_lsp_diagnostics_snapshot(edited_file_paths) + with self.DiagnosticsContext(self, relative_path) as diagnostics_context: + code_editor = self.create_code_editor() + code_editor.delete_lines(relative_path, start_line, end_line) + code_editor.insert_at_line(relative_path, start_line, content) - # applying the replacement - code_editor = self.create_code_editor() - code_editor.delete_lines(relative_path, start_line, end_line) - code_editor.insert_at_line(relative_path, start_line, content) - - return self._format_lsp_edit_result_with_new_diagnostics(SUCCESS_RESULT, edited_file_paths, diagnostics_snapshot) + return diagnostics_context.format_result(SUCCESS_RESULT) class InsertAtLineTool(EditingToolWithDiagnostics, ToolMarkerOptional): @@ -329,15 +311,11 @@ class InsertAtLineTool(EditingToolWithDiagnostics, ToolMarkerOptional): if not content.endswith("\n"): content += "\n" - # capturing diagnostics before the edit - edited_file_paths = [EditedFilePath(relative_path, relative_path)] - diagnostics_snapshot = self._capture_published_lsp_diagnostics_snapshot(edited_file_paths) + with self.DiagnosticsContext(self, relative_path) as diagnostics_context: + code_editor = self.create_code_editor() + code_editor.insert_at_line(relative_path, line, content) - # applying the insertion - code_editor = self.create_code_editor() - code_editor.insert_at_line(relative_path, line, content) - - return self._format_lsp_edit_result_with_new_diagnostics(SUCCESS_RESULT, edited_file_paths, diagnostics_snapshot) + return diagnostics_context.format_result(SUCCESS_RESULT) class SearchForPatternTool(Tool): diff --git a/src/serena/tools/symbol_tools.py b/src/serena/tools/symbol_tools.py index 036c1d71..7f9c17f5 100644 --- a/src/serena/tools/symbol_tools.py +++ b/src/serena/tools/symbol_tools.py @@ -9,7 +9,6 @@ from collections import Counter, defaultdict from collections.abc import Sequence from typing import Any -from serena.code_editor import EditedFilePath from serena.symbol import LanguageServerSymbol, LanguageServerSymbolDictGrouper from serena.tools import ( SUCCESS_RESULT, @@ -796,19 +795,14 @@ class ReplaceSymbolBodyTool(EditingToolWithDiagnostics): in the programming language, including e.g. the signature line for functions. IMPORTANT: The body does NOT include any preceding docstrings/comments or imports, in particular. """ - # capturing diagnostics before the edit - edited_file_paths = [EditedFilePath(relative_path, relative_path)] - diagnostics_snapshot = self._capture_published_lsp_diagnostics_snapshot(edited_file_paths) - - # applying the symbol replacement - code_editor = self.create_code_editor() - code_editor.replace_body( - name_path, - relative_file_path=relative_path, - body=body, - ) - - return self._format_lsp_edit_result_with_new_diagnostics(SUCCESS_RESULT, edited_file_paths, diagnostics_snapshot) + with self.DiagnosticsContext(self, relative_path) as diagnostics_context: + code_editor = self.create_code_editor() + code_editor.replace_body( + name_path, + relative_file_path=relative_path, + body=body, + ) + return diagnostics_context.format_result(SUCCESS_RESULT) class InsertAfterSymbolTool(EditingToolWithDiagnostics): @@ -831,15 +825,10 @@ class InsertAfterSymbolTool(EditingToolWithDiagnostics): :param body: the body/content to be inserted. The inserted code shall begin with the next line after the symbol. """ - # capturing diagnostics before the edit - edited_file_paths = [EditedFilePath(relative_path, relative_path)] - diagnostics_snapshot = self._capture_published_lsp_diagnostics_snapshot(edited_file_paths) - - # applying the insertion - code_editor = self.create_code_editor() - code_editor.insert_after_symbol(name_path, relative_file_path=relative_path, body=body) - - return self._format_lsp_edit_result_with_new_diagnostics(SUCCESS_RESULT, edited_file_paths, diagnostics_snapshot) + with self.DiagnosticsContext(self, relative_path) as diagnostics_context: + code_editor = self.create_code_editor() + code_editor.insert_after_symbol(name_path, relative_file_path=relative_path, body=body) + return diagnostics_context.format_result(SUCCESS_RESULT) class InsertBeforeSymbolTool(EditingToolWithDiagnostics): @@ -862,15 +851,10 @@ class InsertBeforeSymbolTool(EditingToolWithDiagnostics): :param relative_path: the relative path to the file containing the symbol :param body: the body/content to be inserted before the line in which the referenced symbol is defined """ - # capturing diagnostics before the edit - edited_file_paths = [EditedFilePath(relative_path, relative_path)] - diagnostics_snapshot = self._capture_published_lsp_diagnostics_snapshot(edited_file_paths) - - # applying the insertion - code_editor = self.create_code_editor() - code_editor.insert_before_symbol(name_path, relative_file_path=relative_path, body=body) - - return self._format_lsp_edit_result_with_new_diagnostics(SUCCESS_RESULT, edited_file_paths, diagnostics_snapshot) + with self.DiagnosticsContext(self, relative_path) as diagnostics_context: + code_editor = self.create_code_editor() + code_editor.insert_before_symbol(name_path, relative_file_path=relative_path, body=body) + return diagnostics_context.format_result(SUCCESS_RESULT) class RenameSymbolTool(Tool, ToolMarkerSymbolicEdit): diff --git a/src/serena/tools/tools_base.py b/src/serena/tools/tools_base.py index ca8e5a49..11d92dce 100644 --- a/src/serena/tools/tools_base.py +++ b/src/serena/tools/tools_base.py @@ -5,7 +5,7 @@ from collections.abc import Callable, Iterable from dataclasses import dataclass from functools import cached_property from types import TracebackType -from typing import TYPE_CHECKING, Any, Protocol, Self, TypeVar, cast +from typing import TYPE_CHECKING, Any, Optional, Protocol, Self, TypeVar, cast from mcp import Implementation from mcp.server.fastmcp import Context @@ -18,13 +18,12 @@ from serena.project import MemoriesManager, Project from serena.prompt_factory import PromptFactory from serena.util.class_decorators import singleton from serena.util.inspection import iter_subclasses -from solidlsp import ls_types +from serena.util.ls_diagnostics import DiagnosticsDiff, EditedFilePath, PublishedDiagnosticsSnapshot from solidlsp.ls_exceptions import SolidLSPException -from solidlsp.lsp_protocol_handler.lsp_types import DiagnosticSeverity if TYPE_CHECKING: from serena.agent import SerenaAgent - from serena.code_editor import CodeEditor, EditedFilePath, LanguageServerCodeEditor + from serena.code_editor import CodeEditor, LanguageServerCodeEditor from serena.symbol import LanguageServerSymbolRetriever log = logging.getLogger(__name__) @@ -32,24 +31,6 @@ T = TypeVar("T") SUCCESS_RESULT = "OK" -@dataclass(frozen=True) -class DiagnosticIdentity: - message: str - start_line: int - start_character: int - end_line: int - end_character: int - severity: int | None - code_repr: str | None - source: str | None - - -@dataclass -class PublishedDiagnosticsSnapshot: - generation_by_after_path: dict[str, int] - warning_identities_by_before_path: dict[str, set[DiagnosticIdentity]] - - class Component(ABC): def __init__(self, agent: "SerenaAgent"): self.agent = agent @@ -428,7 +409,7 @@ class EditingToolWithDiagnostics(Tool, ToolMarkerCanEdit): Base class for editing tools that want to capture and report changes in LSP diagnostics before and after the edit. """ - _ENABLE_DIAGNOSTICS: bool = False + ENABLE_DIAGNOSTICS: bool = False """ Global flag to enable/disable diagnostics for LSP-based editing tools derived from this class. The feature is currently disabled, because per-edit diagnostics are a questionable feature, since individual @@ -436,179 +417,47 @@ class EditingToolWithDiagnostics(Tool, ToolMarkerCanEdit): are then resolved in subsequent edits. """ - @staticmethod - def _diagnostic_code_repr(code: Any) -> str | None: - if code is None: - return None - try: - return json.dumps(code, sort_keys=True, ensure_ascii=False) - except TypeError: - return repr(code) + DIAGNOSTICS_KEY = "diagnostics[warning-or-higher]" - @classmethod - def _diagnostic_identity(cls, diagnostic: ls_types.Diagnostic) -> DiagnosticIdentity: - diagnostic_range = diagnostic["range"] - start = diagnostic_range["start"] - end = diagnostic_range["end"] - return DiagnosticIdentity( - message=diagnostic["message"], - start_line=start["line"], - start_character=start["character"], - end_line=end["line"], - end_character=end["character"], - severity=diagnostic.get("severity"), - code_repr=cls._diagnostic_code_repr(diagnostic.get("code")), - source=diagnostic.get("source"), - ) + class DiagnosticsContext: + def __init__(self, tool: "EditingToolWithDiagnostics", *edited_relative_paths: str) -> None: + self._tool = tool + self._is_diagnostics_enabled = tool.ENABLE_DIAGNOSTICS and tool.agent.is_using_language_server() + self._edited_files = [EditedFilePath(path, path) for path in edited_relative_paths] + self._before_edit_diagnostics_snapshot: PublishedDiagnosticsSnapshot | None = None + self._symbol_retriever: Optional["LanguageServerSymbolRetriever"] | None = None + if self._is_diagnostics_enabled: + symbol_retriever = tool.create_language_server_symbol_retriever() + self._before_edit_diagnostics_snapshot = PublishedDiagnosticsSnapshot(self._edited_files, symbol_retriever) - @staticmethod - def _diagnostic_severity_name(severity: int | None) -> str: - if severity is None: - return "Unknown" - try: - return DiagnosticSeverity(severity).name - except ValueError: - return f"Severity_{severity}" + def __enter__(self) -> Self: + return self - @staticmethod - def _diagnostic_output_dict(diagnostic: ls_types.Diagnostic) -> dict[str, Any]: - result: dict[str, Any] = { - "message": diagnostic["message"], - "range": diagnostic["range"], - } - if "code" in diagnostic: - result["code"] = diagnostic["code"] - if "source" in diagnostic: - result["source"] = diagnostic["source"] - return result + def __exit__(self, exc_type, exc_val, exc_tb): # type: ignore + pass - @classmethod - def _add_grouped_diagnostic( - cls, - grouped_result: dict[str, dict[str, dict[str, list[dict[str, Any]]]]], - relative_path: str, - name_path: str, - diagnostic: ls_types.Diagnostic, - ) -> None: - severity_name = cls._diagnostic_severity_name(diagnostic.get("severity")) - grouped_result.setdefault(relative_path, {}).setdefault(severity_name, {}).setdefault(name_path, []).append( - cls._diagnostic_output_dict(diagnostic) - ) + def format_result( + self, + base_result: str, + ) -> str: + if not self._is_diagnostics_enabled: + return base_result - def _capture_published_lsp_diagnostics_snapshot( - self, - edited_file_paths: Iterable["EditedFilePath"], - ) -> PublishedDiagnosticsSnapshot | None: - if not self._ENABLE_DIAGNOSTICS: - return None - if self.agent.get_language_backend() != LanguageBackend.LSP: - return None + if self._before_edit_diagnostics_snapshot is None: + return base_result - # collecting diagnostics state before the edit - symbol_retriever = self.create_language_server_symbol_retriever() - generation_by_after_path: dict[str, int] = {} - warning_identities_by_before_path: dict[str, set[DiagnosticIdentity]] = {} + assert self._symbol_retriever is not None + diagnostics_diff = DiagnosticsDiff(self._before_edit_diagnostics_snapshot, self._edited_files, self._symbol_retriever) + grouped_diagnostics = diagnostics_diff.get_grouped_diagnostics() - for edited_file_path in edited_file_paths: - try: - language_server = symbol_retriever.get_language_server(edited_file_path.after_relative_path) - except Exception: - return None - - generation_by_after_path[edited_file_path.after_relative_path] = language_server.get_published_diagnostics_generation( - edited_file_path.after_relative_path - ) - - cached_diagnostics = language_server.get_cached_published_text_document_diagnostics( - edited_file_path.before_relative_path, - min_severity=2, - ) - if cached_diagnostics is None: - try: - cached_diagnostics = language_server.request_text_document_diagnostics( - edited_file_path.before_relative_path, - min_severity=2, - ) - except Exception: - cached_diagnostics = [] - warning_identities_by_before_path[edited_file_path.before_relative_path] = { - self._diagnostic_identity(diagnostic) for diagnostic in cached_diagnostics or [] - } - - return PublishedDiagnosticsSnapshot( - generation_by_after_path=generation_by_after_path, - warning_identities_by_before_path=warning_identities_by_before_path, - ) - - def _format_lsp_edit_result_with_new_diagnostics( - self, - default_result: str, - edited_file_paths: Iterable["EditedFilePath"], - before_edit_diagnostics_snapshot: PublishedDiagnosticsSnapshot | None, - ) -> str: - if not self._ENABLE_DIAGNOSTICS: - return default_result - - # TODO: this is weird, it works because before_edit_diagnostics_snapshot is only None when diagnostics - # are disabled, not when they are empty. A part of the general design flaws introduced by agent-generated code - if before_edit_diagnostics_snapshot is None or self.agent.get_language_backend() != LanguageBackend.LSP: - return default_result - - # collecting diagnostics state after the edit - symbol_retriever = self.create_language_server_symbol_retriever() - grouped_result: dict[str, dict[str, dict[str, list[dict[str, Any]]]]] = {} - saw_diagnostics_result = False - - for edited_file_path in edited_file_paths: - try: - language_server = symbol_retriever.get_language_server(edited_file_path.after_relative_path) - except Exception: - return default_result - - published_diagnostics = language_server.request_published_text_document_diagnostics( - relative_file_path=edited_file_path.after_relative_path, - after_generation=before_edit_diagnostics_snapshot.generation_by_after_path.get(edited_file_path.after_relative_path, -1), - timeout=2.5, - min_severity=2, - allow_cached=True, - ) - if not published_diagnostics: - try: - published_diagnostics = language_server.request_text_document_diagnostics( - edited_file_path.after_relative_path, - min_severity=2, - ) - except Exception: - published_diagnostics = None - if published_diagnostics is None: - continue - - saw_diagnostics_result = True - existing_warning_identities = before_edit_diagnostics_snapshot.warning_identities_by_before_path.get( - edited_file_path.before_relative_path, set() - ) - new_warning_identities: set[DiagnosticIdentity] = set() - - for diagnostic in published_diagnostics: - diagnostic_identity = self._diagnostic_identity(diagnostic) - if diagnostic_identity in existing_warning_identities or diagnostic_identity in new_warning_identities: - continue - new_warning_identities.add(diagnostic_identity) - - diagnostic_start = diagnostic["range"]["start"] - owner_symbol = symbol_retriever.find_diagnostic_owner_symbol( - relative_file_path=edited_file_path.after_relative_path, - line=diagnostic_start["line"], - column=diagnostic_start["character"], - ) - name_path = owner_symbol.get_name_path() if owner_symbol is not None else "" - self._add_grouped_diagnostic(grouped_result, edited_file_path.after_relative_path, name_path, diagnostic) - - # preserving the normal success result when no new diagnostics were introduced - if not saw_diagnostics_result or not grouped_result: - return default_result - - return f"Edit introduced new warning-or-higher diagnostics: {self._to_json(grouped_result)}" + if not grouped_diagnostics: + return base_result + else: + result_dict = { + "result": base_result, + EditingToolWithDiagnostics.DIAGNOSTICS_KEY: grouped_diagnostics, + } + return self._tool._to_json(result_dict) class EditedFileContext: diff --git a/src/serena/util/ls_diagnostics.py b/src/serena/util/ls_diagnostics.py new file mode 100644 index 00000000..6df5ce34 --- /dev/null +++ b/src/serena/util/ls_diagnostics.py @@ -0,0 +1,202 @@ +import json +from collections.abc import Iterable +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +from solidlsp import ls_types +from solidlsp.lsp_protocol_handler.lsp_types import DiagnosticSeverity + +if TYPE_CHECKING: + from serena.symbol import LanguageServerSymbolRetriever + + +@dataclass(frozen=True) +class EditedFilePath: + before_relative_path: str + after_relative_path: str + + +@dataclass(frozen=True) +class DiagnosticIdentity: + message: str + start_line: int + start_character: int + end_line: int + end_character: int + severity: int | None + code_repr: str | None + source: str | None + + @classmethod + def from_diagnostic(cls, diagnostic: ls_types.Diagnostic) -> "DiagnosticIdentity": + diagnostic_range = diagnostic["range"] + start = diagnostic_range["start"] + end = diagnostic_range["end"] + return DiagnosticIdentity( + message=diagnostic["message"], + start_line=start["line"], + start_character=start["character"], + end_line=end["line"], + end_character=end["character"], + severity=diagnostic.get("severity"), + code_repr=cls._diagnostic_code_repr(diagnostic.get("code")), + source=diagnostic.get("source"), + ) + + @staticmethod + def _diagnostic_code_repr(code: Any) -> str | None: + if code is None: + return None + try: + return json.dumps(code, sort_keys=True, ensure_ascii=False) + except TypeError: + return repr(code) + + +class PublishedDiagnosticsSnapshot: + def __init__(self, edited_file_paths: Iterable[EditedFilePath], symbol_retriever: "LanguageServerSymbolRetriever"): + generation_by_after_path: dict[str, int] = {} + warning_identities_by_before_path: dict[str, set[DiagnosticIdentity]] = {} + + for edited_file_path in edited_file_paths: + try: + language_server = symbol_retriever.get_language_server(edited_file_path.after_relative_path) + except Exception: + continue + + generation_by_after_path[edited_file_path.after_relative_path] = language_server.get_published_diagnostics_generation( + edited_file_path.after_relative_path + ) + + cached_diagnostics = language_server.get_cached_published_text_document_diagnostics( + edited_file_path.before_relative_path, + min_severity=2, + ) + if cached_diagnostics is None: + try: + cached_diagnostics = language_server.request_text_document_diagnostics( + edited_file_path.before_relative_path, + min_severity=2, + ) + except Exception: + cached_diagnostics = [] + warning_identities_by_before_path[edited_file_path.before_relative_path] = { + DiagnosticIdentity.from_diagnostic(diagnostic) for diagnostic in cached_diagnostics or [] + } + + self.generation_by_after_path = generation_by_after_path + self.warning_identities_by_before_path = warning_identities_by_before_path + + +class DiagnosticsDiff: + def __init__( + self, + before_snapshot: PublishedDiagnosticsSnapshot, + edited_files: Iterable[EditedFilePath], + symbol_retriever: "LanguageServerSymbolRetriever", + ): + grouped_result: dict[str, dict[str, dict[str, list[dict[str, Any]]]]] = {} + + for edited_file_path in edited_files: + try: + language_server = symbol_retriever.get_language_server(edited_file_path.after_relative_path) + except: + continue + + published_diagnostics = language_server.request_published_text_document_diagnostics( + relative_file_path=edited_file_path.after_relative_path, + after_generation=before_snapshot.generation_by_after_path.get(edited_file_path.after_relative_path, -1), + timeout=2.5, + min_severity=2, + allow_cached=True, + ) + if not published_diagnostics: + try: + published_diagnostics = language_server.request_text_document_diagnostics( + edited_file_path.after_relative_path, + min_severity=2, + ) + except: + published_diagnostics = None + if published_diagnostics is None: + continue + + existing_warning_identities = before_snapshot.warning_identities_by_before_path.get( + edited_file_path.before_relative_path, set() + ) + new_warning_identities: set[DiagnosticIdentity] = set() + + for diagnostic in published_diagnostics: + diagnostic_identity = DiagnosticIdentity.from_diagnostic(diagnostic) + if diagnostic_identity in existing_warning_identities or diagnostic_identity in new_warning_identities: + continue + new_warning_identities.add(diagnostic_identity) + + diagnostic_start = diagnostic["range"]["start"] + owner_symbol = symbol_retriever.find_diagnostic_owner_symbol( + relative_file_path=edited_file_path.after_relative_path, + line=diagnostic_start["line"], + column=diagnostic_start["character"], + ) + name_path = owner_symbol.get_name_path() if owner_symbol is not None else "" + self._add_grouped_diagnostic(grouped_result, edited_file_path.after_relative_path, name_path, diagnostic) + + self._grouped_result = grouped_result + + def get_grouped_diagnostics(self) -> dict[str, dict[str, dict[str, list[dict[str, Any]]]]]: + """ + Returns a nested dictionary of the form: + { + relative_file_path: { + severity_name: { + name_path: [ + diagnostic_dict, + ... + ], + ... + }, + ... + }, + ... + } + where: + - relative_file_path is the relative path of the file containing the diagnostic + - severity_name is the name of the diagnostic severity (e.g. "Warning", "Error") + - name_path is the name path of the symbol that owns the diagnostic (or "" if no owner symbol was found) + - diagnostic_dict is a dictionary containing the diagnostic's message, range, and optionally code and source + """ + return self._grouped_result + + @classmethod + def _add_grouped_diagnostic( + cls, + grouped_result: dict[str, dict[str, dict[str, list[dict[str, Any]]]]], + relative_path: str, + name_path: str, + diagnostic: ls_types.Diagnostic, + ) -> None: + severity_name = cls._diagnostic_severity_name(diagnostic.get("severity")) + grouped_result.setdefault(relative_path, {}).setdefault(severity_name, {}).setdefault(name_path, []).append( + cls._diagnostic_output_dict(diagnostic) + ) + + @staticmethod + def _diagnostic_severity_name(severity: int | None) -> str: + if severity is None: + return "Unknown" + try: + return DiagnosticSeverity(severity).name + except ValueError: + return f"Severity_{severity}" + + @staticmethod + def _diagnostic_output_dict(diagnostic: ls_types.Diagnostic) -> dict[str, Any]: + result: dict[str, Any] = { + "message": diagnostic["message"], + "range": diagnostic["range"], + } + if "code" in diagnostic: + result["code"] = diagnostic["code"] + if "source" in diagnostic: + result["source"] = diagnostic["source"] + return result diff --git a/test/serena/test_serena_agent.py b/test/serena/test_serena_agent.py index 974cdc4b..b3e2427b 100644 --- a/test/serena/test_serena_agent.py +++ b/test/serena/test_serena_agent.py @@ -18,6 +18,7 @@ from serena.project import Project from serena.tools import ( SUCCESS_RESULT, ActivateProjectTool, + EditingToolWithDiagnostics, FindDefiningSymbolAtLocationTool, FindDefiningSymbolTool, FindImplementationsTool, @@ -812,9 +813,9 @@ def read_project_file(project: Project, relative_path: str) -> str: def parse_edit_diagnostics_result(result: str) -> dict: """Utility function to parse the diagnostic payload returned by edit tools.""" - prefix = "Edit introduced new warning-or-higher diagnostics: " - assert result.startswith(prefix), result - return json.loads(result[len(prefix) :]) + assert EditingToolWithDiagnostics.DIAGNOSTICS_KEY in result + d = json.loads(result) + return d[EditingToolWithDiagnostics.DIAGNOSTICS_KEY] @contextmanager @@ -1201,21 +1202,24 @@ class TestSerenaAgent: """Tests that file-level edits report newly introduced diagnostics.""" relative_path = os.path.join("test_repo", "services.py") replace_content_tool = serena_agent.get_tool(ReplaceContentTool) - replace_content_tool._ENABLE_DIAGNOSTICS = True + try: + replace_content_tool.ENABLE_DIAGNOSTICS = True - with project_file_modification_context(serena_agent, relative_path): - result = replace_content_tool.apply( - relative_path=relative_path, - needle="return container", - repl="return missing_container", - mode="literal", - ) + with project_file_modification_context(serena_agent, relative_path): + result = replace_content_tool.apply( + relative_path=relative_path, + needle="return container", + repl="return missing_container", + mode="literal", + ) - diagnostics = parse_edit_diagnostics_result(result) - relative_path_result = diagnostics[relative_path] - diagnostic_messages = json.dumps(relative_path_result) - assert "missing_container" in diagnostic_messages - assert "create_service_container" in diagnostic_messages + diagnostics = parse_edit_diagnostics_result(result) + relative_path_result = diagnostics[relative_path] + diagnostic_messages = json.dumps(relative_path_result) + assert "missing_container" in diagnostic_messages + assert "create_service_container" in diagnostic_messages + finally: + replace_content_tool.ENABLE_DIAGNOSTICS = False @pytest.mark.parametrize( "serena_agent", @@ -1229,23 +1233,26 @@ class TestSerenaAgent: """Tests that symbol-level edits report newly introduced diagnostics.""" relative_path = os.path.join("test_repo", "services.py") replace_symbol_body_tool = serena_agent.get_tool(ReplaceSymbolBodyTool) - replace_symbol_body_tool._ENABLE_DIAGNOSTICS = True + try: + replace_symbol_body_tool.ENABLE_DIAGNOSTICS = True - with project_file_modification_context(serena_agent, relative_path): - result = replace_symbol_body_tool.apply( - name_path="create_service_container", - relative_path=relative_path, - body=""" -def create_service_container() -> dict[str, Any]: - return missing_container -""", - ) + with project_file_modification_context(serena_agent, relative_path): + result = replace_symbol_body_tool.apply( + name_path="create_service_container", + relative_path=relative_path, + body=""" + def create_service_container() -> dict[str, Any]: + return missing_container + """, + ) - diagnostics = parse_edit_diagnostics_result(result) - relative_path_result = diagnostics[relative_path] - diagnostic_messages = json.dumps(relative_path_result) - assert "missing_container" in diagnostic_messages - assert "create_service_container" in diagnostic_messages + diagnostics = parse_edit_diagnostics_result(result) + relative_path_result = diagnostics[relative_path] + diagnostic_messages = json.dumps(relative_path_result) + assert "missing_container" in diagnostic_messages + assert "create_service_container" in diagnostic_messages + finally: + replace_symbol_body_tool.ENABLE_DIAGNOSTICS = False @pytest.mark.parametrize( "serena_agent", From 5ab912ca0bd66a1edf7a5479fd7e6e584643981a Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Wed, 29 Apr 2026 23:29:14 +0200 Subject: [PATCH 09/12] CodeEditor: Remove unused management of edited files Management was unnecessarily introduced by AI in 133e1be2a780ee875a9b7d739e076b85d81b29fd --- src/serena/code_editor.py | 47 ---------------------------------- src/serena/tools/tools_base.py | 4 +-- 2 files changed, 2 insertions(+), 49 deletions(-) diff --git a/src/serena/code_editor.py b/src/serena/code_editor.py index ec834273..2392d2ba 100644 --- a/src/serena/code_editor.py +++ b/src/serena/code_editor.py @@ -13,7 +13,6 @@ from solidlsp.ls import LSPFileBuffer from solidlsp.ls_utils import PathUtils, TextUtils from .project import Project -from .util.ls_diagnostics import EditedFilePath log = logging.getLogger(__name__) TSymbol = TypeVar("TSymbol", bound=Symbol) @@ -24,7 +23,6 @@ class CodeEditor(Generic[TSymbol], ABC): self.project_root = project.project_root self.encoding = project.project_config.encoding self.newline = project.line_ending.newline_str - self._last_edited_file_paths: list[EditedFilePath] = [] class EditedFile(ABC): def __init__(self, relative_path: str) -> None: @@ -85,20 +83,6 @@ class CodeEditor(Generic[TSymbol], ABC): with open(abs_path, "w", encoding=self.encoding, newline=self.newline) as f: f.write(new_contents) - def _set_last_edited_file_paths(self, edited_file_paths: Iterable[EditedFilePath]) -> None: - unique_paths: list[EditedFilePath] = [] - seen_keys: set[tuple[str, str]] = set() - for edited_file_path in edited_file_paths: - key = (edited_file_path.before_relative_path, edited_file_path.after_relative_path) - if key in seen_keys: - continue - seen_keys.add(key) - unique_paths.append(edited_file_path) - self._last_edited_file_paths = unique_paths - - def get_last_edited_file_paths(self) -> list[EditedFilePath]: - return list(self._last_edited_file_paths) - @abstractmethod def _find_unique_symbol(self, name_path: str, relative_file_path: str) -> TSymbol: """ @@ -130,8 +114,6 @@ class CodeEditor(Generic[TSymbol], ABC): edited_file.delete_text_between_positions(start_pos, end_pos) edited_file.insert_text_at_position(start_pos, body) - self._set_last_edited_file_paths([EditedFilePath(relative_file_path, relative_file_path)]) - @staticmethod def _count_leading_newlines(text: Iterable) -> int: cnt = 0 @@ -189,8 +171,6 @@ class CodeEditor(Generic[TSymbol], ABC): with self.edited_file_context(relative_file_path) as edited_file: edited_file.insert_text_at_position(PositionInFile(line, col), body) - self._set_last_edited_file_paths([EditedFilePath(relative_file_path, relative_file_path)]) - def insert_before_symbol(self, name_path: str, relative_file_path: str, body: str) -> None: """ Inserts content before the symbol with the given name in the given file. @@ -219,8 +199,6 @@ class CodeEditor(Generic[TSymbol], ABC): with self.edited_file_context(relative_file_path) as edited_file: edited_file.insert_text_at_position(PositionInFile(line=line, col=col), body) - self._set_last_edited_file_paths([EditedFilePath(relative_file_path, relative_file_path)]) - def insert_at_line(self, relative_path: str, line: int, content: str) -> None: """ Inserts content at the given line in the given file. @@ -232,8 +210,6 @@ class CodeEditor(Generic[TSymbol], ABC): with self.edited_file_context(relative_path) as edited_file: edited_file.insert_text_at_position(PositionInFile(line, 0), content) - self._set_last_edited_file_paths([EditedFilePath(relative_path, relative_path)]) - def delete_lines(self, relative_path: str, start_line: int, end_line: int) -> None: """ Deletes lines in the given file. @@ -250,8 +226,6 @@ class CodeEditor(Generic[TSymbol], ABC): end_pos = PositionInFile(line=end_line_for_delete, col=end_col) edited_file.delete_text_between_positions(start_pos, end_pos) - self._set_last_edited_file_paths([EditedFilePath(relative_path, relative_path)]) - def delete_symbol(self, name_path: str, relative_file_path: str) -> None: """ Deletes the symbol with the given name in the given file. @@ -262,8 +236,6 @@ class CodeEditor(Generic[TSymbol], ABC): with self.edited_file_context(relative_file_path) as edited_file: edited_file.delete_text_between_positions(start_pos, end_pos) - self._set_last_edited_file_paths([EditedFilePath(relative_file_path, relative_file_path)]) - @abstractmethod def rename_symbol(self, name_path: str, relative_path: str, new_name: str) -> str: pass @@ -320,10 +292,6 @@ class LanguageServerCodeEditor(CodeEditor[LanguageServerSymbol]): def apply(self) -> None: pass - @abstractmethod - def get_edited_file_paths(self) -> list[EditedFilePath]: - pass - class EditOperationFileTextEdits(EditOperation): def __init__(self, code_editor: "LanguageServerCodeEditor", file_uri: str, text_edits: list[ls_types.TextEdit]): self._code_editor = code_editor @@ -335,9 +303,6 @@ class LanguageServerCodeEditor(CodeEditor[LanguageServerSymbol]): edited_file = cast(LanguageServerCodeEditor.EditedFile, edited_file) edited_file.apply_text_edits(self._text_edits) - def get_edited_file_paths(self) -> list[EditedFilePath]: - return [EditedFilePath(self._relative_path, self._relative_path)] - class EditOperationRenameFile(EditOperation): def __init__(self, code_editor: "LanguageServerCodeEditor", old_uri: str, new_uri: str): self._code_editor = code_editor @@ -349,9 +314,6 @@ class LanguageServerCodeEditor(CodeEditor[LanguageServerSymbol]): new_abs_path = os.path.join(self._code_editor.project_root, self._new_relative_path) os.rename(old_abs_path, new_abs_path) - def get_edited_file_paths(self) -> list[EditedFilePath]: - return [EditedFilePath(self._old_relative_path, self._new_relative_path)] - def _workspace_edit_to_edit_operations(self, workspace_edit: ls_types.WorkspaceEdit) -> list["LanguageServerCodeEditor.EditOperation"]: operations: list[LanguageServerCodeEditor.EditOperation] = [] @@ -381,15 +343,6 @@ class LanguageServerCodeEditor(CodeEditor[LanguageServerSymbol]): :return: number of edit operations applied """ operations = self._workspace_edit_to_edit_operations(workspace_edit) - - # recording the affected files - edited_file_paths: list[EditedFilePath] = [] - for operation in operations: - edited_file_paths.extend(operation.get_edited_file_paths()) - - self._set_last_edited_file_paths(edited_file_paths) - - # applying the edit operations for operation in operations: operation.apply() return len(operations) diff --git a/src/serena/tools/tools_base.py b/src/serena/tools/tools_base.py index 11d92dce..4348deb9 100644 --- a/src/serena/tools/tools_base.py +++ b/src/serena/tools/tools_base.py @@ -427,8 +427,8 @@ class EditingToolWithDiagnostics(Tool, ToolMarkerCanEdit): self._before_edit_diagnostics_snapshot: PublishedDiagnosticsSnapshot | None = None self._symbol_retriever: Optional["LanguageServerSymbolRetriever"] | None = None if self._is_diagnostics_enabled: - symbol_retriever = tool.create_language_server_symbol_retriever() - self._before_edit_diagnostics_snapshot = PublishedDiagnosticsSnapshot(self._edited_files, symbol_retriever) + self._symbol_retriever = tool.create_language_server_symbol_retriever() + self._before_edit_diagnostics_snapshot = PublishedDiagnosticsSnapshot(self._edited_files, self._symbol_retriever) def __enter__(self) -> Self: return self From 371d70f87ee0280d58ff7693ee4ad20c9b644293 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Thu, 30 Apr 2026 11:43:23 +0200 Subject: [PATCH 10/12] Refactoring: Introduce abstraction GroupedDiagnostics, removing duplication --- src/serena/tools/symbol_tools.py | 68 +++-------------- src/serena/tools/tools_base.py | 2 +- src/serena/util/ls_diagnostics.py | 121 +++++++++++++++--------------- 3 files changed, 71 insertions(+), 120 deletions(-) diff --git a/src/serena/tools/symbol_tools.py b/src/serena/tools/symbol_tools.py index 7f9c17f5..d2626ae0 100644 --- a/src/serena/tools/symbol_tools.py +++ b/src/serena/tools/symbol_tools.py @@ -18,44 +18,8 @@ from serena.tools import ( ToolMarkerSymbolicRead, ) from serena.tools.tools_base import ToolMarkerOptional -from solidlsp import ls_types +from serena.util.ls_diagnostics import GroupedDiagnostics from solidlsp.ls_types import SymbolKind -from solidlsp.lsp_protocol_handler.lsp_types import DiagnosticSeverity - -FILE_LEVEL_DIAGNOSTIC_BUCKET = "" - - -def _diagnostic_severity_name(severity: int | None) -> str: - if severity is None: - return "Unknown" - try: - return DiagnosticSeverity(severity).name - except ValueError: - return f"Severity_{severity}" - - -def _diagnostic_output_dict(diagnostic: ls_types.Diagnostic) -> dict[str, Any]: - result: dict[str, Any] = { - "message": diagnostic["message"], - "range": diagnostic["range"], - } - if "code" in diagnostic: - result["code"] = diagnostic["code"] - if "source" in diagnostic: - result["source"] = diagnostic["source"] - return result - - -def _add_grouped_diagnostic( - grouped_result: dict[str, dict[str, dict[str, list[dict[str, Any]]]]], - relative_path: str, - severity_name: str, - name_path: str, - diagnostic: ls_types.Diagnostic, -) -> None: - grouped_result.setdefault(relative_path, {}).setdefault(severity_name, {}).setdefault(name_path, []).append( - _diagnostic_output_dict(diagnostic) - ) def _offset_to_line_and_column(text: str, offset: int) -> tuple[int, int]: @@ -663,7 +627,7 @@ class GetDiagnosticsForFileTool(Tool, ToolMarkerSymbolicRead): Gets diagnostics for a file, optionally restricted to a line range, grouped by file, severity, and containing symbol. """ - _ENABLE_DIAGNOSTICS: bool = True + FILE_LEVEL_DIAGNOSTIC_BUCKET = "" def apply( self, @@ -693,10 +657,10 @@ class GetDiagnosticsForFileTool(Tool, ToolMarkerSymbolicRead): min_severity=min_severity, ) - grouped_result: dict[str, dict[str, dict[str, list[dict[str, Any]]]]] = {} + grouped_diagnostics = GroupedDiagnostics() for diagnostic in diagnostics: diag_range = diagnostic["range"]["start"] - name_path = FILE_LEVEL_DIAGNOSTIC_BUCKET + name_path = self.FILE_LEVEL_DIAGNOSTIC_BUCKET owner_symbol = symbol_retriever.find_diagnostic_owner_symbol( relative_file_path=relative_path, line=diag_range["line"], @@ -704,15 +668,9 @@ class GetDiagnosticsForFileTool(Tool, ToolMarkerSymbolicRead): ) if owner_symbol is not None: name_path = owner_symbol.get_name_path() - _add_grouped_diagnostic( - grouped_result, - relative_path=relative_path, - severity_name=_diagnostic_severity_name(diagnostic.get("severity")), - name_path=name_path, - diagnostic=diagnostic, - ) + grouped_diagnostics.add(relative_path, name_path, diagnostic) - result = self._to_json(grouped_result) + result = self._to_json(grouped_diagnostics.get_dict()) return self._limit_length(result, max_answer_chars) @@ -721,8 +679,6 @@ class GetDiagnosticsForSymbolTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOption Gets diagnostics for a symbol and, optionally, for symbols that reference it. """ - _ENABLE_DIAGNOSTICS: bool = True - def apply( self, name_path: str, @@ -752,22 +708,16 @@ class GetDiagnosticsForSymbolTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOption min_severity=min_severity, ) - grouped_result: dict[str, dict[str, dict[str, list[dict[str, Any]]]]] = {} + grouped_diagnostics = GroupedDiagnostics() for symbol, diagnostics in diagnostics_by_symbol.items(): relative_path = symbol.relative_path if relative_path is None: continue symbol_name_path = symbol.get_name_path() for diagnostic in diagnostics: - _add_grouped_diagnostic( - grouped_result, - relative_path=relative_path, - severity_name=_diagnostic_severity_name(diagnostic.get("severity")), - name_path=symbol_name_path, - diagnostic=diagnostic, - ) + grouped_diagnostics.add(relative_path, symbol_name_path, diagnostic) - result = self._to_json(grouped_result) + result = self._to_json(grouped_diagnostics.get_dict()) return self._limit_length(result, max_answer_chars) diff --git a/src/serena/tools/tools_base.py b/src/serena/tools/tools_base.py index 4348deb9..565a4055 100644 --- a/src/serena/tools/tools_base.py +++ b/src/serena/tools/tools_base.py @@ -448,7 +448,7 @@ class EditingToolWithDiagnostics(Tool, ToolMarkerCanEdit): assert self._symbol_retriever is not None diagnostics_diff = DiagnosticsDiff(self._before_edit_diagnostics_snapshot, self._edited_files, self._symbol_retriever) - grouped_diagnostics = diagnostics_diff.get_grouped_diagnostics() + grouped_diagnostics = diagnostics_diff.get_grouped_diagnostics().get_dict() if not grouped_diagnostics: return base_result diff --git a/src/serena/util/ls_diagnostics.py b/src/serena/util/ls_diagnostics.py index 6df5ce34..13b505ea 100644 --- a/src/serena/util/ls_diagnostics.py +++ b/src/serena/util/ls_diagnostics.py @@ -88,6 +88,62 @@ class PublishedDiagnosticsSnapshot: self.warning_identities_by_before_path = warning_identities_by_before_path +class GroupedDiagnostics: + def __init__(self) -> None: + self._grouped_diagnostics: dict[str, dict[str, dict[str, list[dict[str, Any]]]]] = {} + + def add(self, relative_path: str, name_path: str, diagnostic: ls_types.Diagnostic) -> None: + severity_name = self._diagnostic_severity_name(diagnostic.get("severity")) + self._grouped_diagnostics.setdefault(relative_path, {}).setdefault(severity_name, {}).setdefault(name_path, []).append( + self._diagnostic_output_dict(diagnostic) + ) + + def get_dict(self) -> dict[str, dict[str, dict[str, list[dict[str, Any]]]]]: + """ + Returns a nested dictionary of the form: + { + relative_file_path: { + severity_name: { + name_path: [ + diagnostic_dict, + ... + ], + ... + }, + ... + }, + ... + } + where: + - relative_file_path is the relative path of the file containing the diagnostic + - severity_name is the name of the diagnostic severity (e.g. "Warning", "Error") + - name_path is the name path of the symbol that owns the diagnostic (or "" if no owner symbol was found) + - diagnostic_dict is a dictionary containing the diagnostic's message, range, and optionally code and source + """ + return self._grouped_diagnostics + + @staticmethod + def _diagnostic_severity_name(severity: int | None) -> str: + if severity is None: + return "Unknown" + try: + return DiagnosticSeverity(severity).name + except ValueError: + return f"Severity_{severity}" + + @staticmethod + def _diagnostic_output_dict(diagnostic: ls_types.Diagnostic) -> dict[str, Any]: + result: dict[str, Any] = { + "message": diagnostic["message"], + "range": diagnostic["range"], + } + if "code" in diagnostic: + result["code"] = diagnostic["code"] + if "source" in diagnostic: + result["source"] = diagnostic["source"] + return result + + class DiagnosticsDiff: def __init__( self, @@ -95,7 +151,7 @@ class DiagnosticsDiff: edited_files: Iterable[EditedFilePath], symbol_retriever: "LanguageServerSymbolRetriever", ): - grouped_result: dict[str, dict[str, dict[str, list[dict[str, Any]]]]] = {} + grouped_diagnostics = GroupedDiagnostics() for edited_file_path in edited_files: try: @@ -139,64 +195,9 @@ class DiagnosticsDiff: column=diagnostic_start["character"], ) name_path = owner_symbol.get_name_path() if owner_symbol is not None else "" - self._add_grouped_diagnostic(grouped_result, edited_file_path.after_relative_path, name_path, diagnostic) + grouped_diagnostics.add(edited_file_path.after_relative_path, name_path, diagnostic) - self._grouped_result = grouped_result + self._grouped_diagnostics = grouped_diagnostics - def get_grouped_diagnostics(self) -> dict[str, dict[str, dict[str, list[dict[str, Any]]]]]: - """ - Returns a nested dictionary of the form: - { - relative_file_path: { - severity_name: { - name_path: [ - diagnostic_dict, - ... - ], - ... - }, - ... - }, - ... - } - where: - - relative_file_path is the relative path of the file containing the diagnostic - - severity_name is the name of the diagnostic severity (e.g. "Warning", "Error") - - name_path is the name path of the symbol that owns the diagnostic (or "" if no owner symbol was found) - - diagnostic_dict is a dictionary containing the diagnostic's message, range, and optionally code and source - """ - return self._grouped_result - - @classmethod - def _add_grouped_diagnostic( - cls, - grouped_result: dict[str, dict[str, dict[str, list[dict[str, Any]]]]], - relative_path: str, - name_path: str, - diagnostic: ls_types.Diagnostic, - ) -> None: - severity_name = cls._diagnostic_severity_name(diagnostic.get("severity")) - grouped_result.setdefault(relative_path, {}).setdefault(severity_name, {}).setdefault(name_path, []).append( - cls._diagnostic_output_dict(diagnostic) - ) - - @staticmethod - def _diagnostic_severity_name(severity: int | None) -> str: - if severity is None: - return "Unknown" - try: - return DiagnosticSeverity(severity).name - except ValueError: - return f"Severity_{severity}" - - @staticmethod - def _diagnostic_output_dict(diagnostic: ls_types.Diagnostic) -> dict[str, Any]: - result: dict[str, Any] = { - "message": diagnostic["message"], - "range": diagnostic["range"], - } - if "code" in diagnostic: - result["code"] = diagnostic["code"] - if "source" in diagnostic: - result["source"] = diagnostic["source"] - return result + def get_grouped_diagnostics(self) -> GroupedDiagnostics: + return self._grouped_diagnostics From 2e0167f8180c2725489cbfd407bbfc3d3b164447 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Thu, 30 Apr 2026 15:08:56 +0200 Subject: [PATCH 11/12] Refactor LSP tools for symbol declaration retrieval * Apply common functionality already used in JetBrainsFindDeclarationTool * Remove location-based tool (counteracts Serena's principle to eschew line numbers) * Apply renaming to use "find declaration" instead of "find defining symbol" --- scripts/demo_find_defining_symbol.py | 22 +-- src/serena/code_editor.py | 10 +- src/serena/symbol.py | 10 +- src/serena/tools/symbol_tools.py | 272 ++++++--------------------- src/solidlsp/ls_utils.py | 8 + test/serena/test_serena_agent.py | 43 +---- 6 files changed, 91 insertions(+), 274 deletions(-) diff --git a/scripts/demo_find_defining_symbol.py b/scripts/demo_find_defining_symbol.py index f65b820c..63bde56c 100644 --- a/scripts/demo_find_defining_symbol.py +++ b/scripts/demo_find_defining_symbol.py @@ -11,7 +11,7 @@ from serena.agent import SerenaAgent from serena.config.serena_config import LanguageBackend, ProjectConfig, RegisteredProject, SerenaConfig from serena.constants import REPO_ROOT from serena.project import Project -from serena.tools import FindDefiningSymbolAtLocationTool, FindDefiningSymbolTool +from serena.tools import FindDeclarationTool from solidlsp.ls_config import Language SEPARATOR = "=" * 80 @@ -73,24 +73,8 @@ if __name__ == "__main__": relative_path = SERVICES_FILE.as_posix() services_abs_path = PYTHON_TEST_REPO / SERVICES_FILE - # resolving via exact location - line, column = find_identifier_occurrence_position(services_abs_path, "User", occurrence_index=1) - find_by_location_tool = agent.get_tool(FindDefiningSymbolAtLocationTool) - location_result = agent.execute_task( - lambda: find_by_location_tool.apply( - relative_path=relative_path, - line=line, - column=column, - include_info=True, - ) - ) - - print_section("FindDefiningSymbolAtLocationTool") - location_symbol = json.loads(location_result) - pprint(location_symbol, width=200) - # resolving via regex over the full file - find_by_regex_tool = agent.get_tool(FindDefiningSymbolTool) + find_by_regex_tool = agent.get_tool(FindDeclarationTool) regex_result = agent.execute_task( lambda: find_by_regex_tool.apply( regex=r"from \.models import Item, (User)", @@ -118,7 +102,7 @@ if __name__ == "__main__": pprint(contained_regex_symbol, width=200) # validating the demonstrated result - for symbol in [location_symbol, regex_symbol, contained_regex_symbol]: + for symbol in [regex_symbol, contained_regex_symbol]: assert symbol is not None, "Expected a defining symbol result" assert symbol.get("relative_path") is not None assert "models.py" in symbol["relative_path"], symbol diff --git a/src/serena/code_editor.py b/src/serena/code_editor.py index 2392d2ba..fed0297f 100644 --- a/src/serena/code_editor.py +++ b/src/serena/code_editor.py @@ -57,15 +57,21 @@ class CodeEditor(Generic[TSymbol], ABC): """ raise NotImplementedError("This method must be overridden for each subclass") - def read_file(self, relative_path: str) -> str: + def read_file(self, relative_path: str, lines: tuple[int, int] | None = None) -> str: """ Reads the content of a file. :param relative_path: the relative path of the file to read + :param lines: tuple of (first_line, last_line) to read only a specific line range (0-based, inclusive) :return: the content of the file """ with self._open_file_context(relative_path) as file: - return file.get_contents() + contents = file.get_contents() + if lines is None: + return contents + else: + first_line, last_line = lines + return TextUtils.get_text_in_lines_range(contents, first_line, last_line) @contextmanager def edited_file_context(self, relative_path: str) -> Iterator["CodeEditor.EditedFile"]: diff --git a/src/serena/symbol.py b/src/serena/symbol.py index b44339e1..8bae7d14 100644 --- a/src/serena/symbol.py +++ b/src/serena/symbol.py @@ -310,6 +310,12 @@ class LanguageServerSymbol(Symbol, ToStringMixin): end_line = end_pos["line"] if end_pos else None return start_line, end_line + def get_body_line_numbers_or_raise(self) -> tuple[int, int]: + start_line, end_line = self.get_body_line_numbers() + if start_line is None or end_line is None: + raise ValueError(f"Body line numbers could not be determined for {self.get_name_path()}") + return start_line, end_line + @property def line(self) -> int | None: """ @@ -936,7 +942,7 @@ class LanguageServerSymbolRetriever: return [LanguageServerSymbol(s) for s in implementing_symbols] - def find_defining_symbol( + def find_declaration( self, relative_file_path: str, line: int, @@ -944,7 +950,7 @@ class LanguageServerSymbolRetriever: include_body: bool = False, ) -> LanguageServerSymbol | None: """ - Find the symbol that defines the symbol at the given file position. + Find the declaration/definition of the symbol at the given file position. :param relative_file_path: the relative path to the file in which the symbol usage occurs. :param line: the 0-based line number of the symbol usage. diff --git a/src/serena/tools/symbol_tools.py b/src/serena/tools/symbol_tools.py index d2626ae0..e146c5ea 100644 --- a/src/serena/tools/symbol_tools.py +++ b/src/serena/tools/symbol_tools.py @@ -4,7 +4,6 @@ Language server-related tools import copy import os -import re from collections import Counter, defaultdict from collections.abc import Sequence from typing import Any @@ -19,37 +18,10 @@ from serena.tools import ( ) from serena.tools.tools_base import ToolMarkerOptional from serena.util.ls_diagnostics import GroupedDiagnostics +from serena.util.text_utils import find_text_coordinates from solidlsp.ls_types import SymbolKind -def _offset_to_line_and_column(text: str, offset: int) -> tuple[int, int]: - if offset < 0 or offset > len(text): - raise ValueError(f"Offset out of range: {offset}") - prefix = text[:offset] - line = prefix.count("\n") - previous_newline_offset = prefix.rfind("\n") - column = offset if previous_newline_offset == -1 else offset - previous_newline_offset - 1 - return line, column - - -def _line_and_column_to_offset(text: str, line: int, column: int) -> int: - if line < 0 or column < 0: - raise ValueError(f"Line and column must be non-negative, got {line=}, {column=}") - - line_start_offsets = [0] - for match in re.finditer("\n", text): - line_start_offsets.append(match.end()) - - if line >= len(line_start_offsets): - raise ValueError(f"Line out of range: {line}") - - line_start_offset = line_start_offsets[line] - line_end_offset = line_start_offsets[line + 1] - 1 if line + 1 < len(line_start_offsets) else len(text) - if column > line_end_offset - line_start_offset: - raise ValueError(f"Column out of range for line {line}: {column}") - return line_start_offset + column - - class RestartLanguageServerTool(Tool, ToolMarkerOptional): """Restarts the language server, may be necessary when edits not through Serena happen.""" @@ -411,215 +383,85 @@ class FindImplementationsTool(Tool, ToolMarkerSymbolicRead): return self._limit_length(result, max_answer_chars) -class FindDefiningSymbolAtLocationTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional): +class FindDeclarationTool(Tool, ToolMarkerSymbolicRead): """ - Finds the symbol that defines the symbol at the given file position using the language server backend. + Finds the declaration/definition of a symbol """ - @staticmethod - def _defining_symbol_to_result_dict( - symbol_retriever: Any, - defining_symbol: LanguageServerSymbol | None, - include_body: bool, - include_info: bool, - ) -> dict[str, Any] | None: - if defining_symbol is None: - return None - - symbol_dict = dict(defining_symbol.to_dict(kind=True, relative_path=True, depth=0, body=include_body, body_location=True)) - if not include_body and include_info: - if symbol_info := symbol_retriever.request_info_for_symbol(defining_symbol): - symbol_dict["info"] = symbol_info - symbol_dict.pop("name", None) - return symbol_dict - - # noinspection PyDefaultArgument def apply( self, relative_path: str, - line: int, - column: int, + regex: str, + containing_symbol_name_path: str | None = None, include_body: bool = False, include_info: bool = False, - max_answer_chars: int = -1, - ) -> str: - """ - Finds the defining symbol for the symbol at the given position. - - :param relative_path: the relative path to the file containing the symbol usage. - :param line: the 0-based line number of the symbol usage. - :param column: the 0-based column number of the symbol usage. - :param include_body: whether to include the source code of the defining symbol. - :param include_info: whether to include additional info (hover-like, typically including docstring and signature) - about the defining symbol. Ignored if no defining symbol is found. - :param max_answer_chars: same as in the `find_symbol` tool. - :return: a JSON object representing the defining symbol, or `null` if no definition was found. - """ - symbol_retriever = self.create_language_server_symbol_retriever() - defining_symbol = symbol_retriever.find_defining_symbol( - relative_file_path=relative_path, - line=line, - column=column, - include_body=include_body, - ) - - if defining_symbol is None: - result = self._to_json(None) - return self._limit_length(result, max_answer_chars) - - symbol_dict = self._defining_symbol_to_result_dict(symbol_retriever, defining_symbol, include_body, include_info) - assert symbol_dict is not None - result = self._to_json(symbol_dict) - return self._limit_length(result, max_answer_chars) - - -class FindDefiningSymbolTool(Tool, ToolMarkerSymbolicRead): - """ - Finds the symbol that defines a uniquely captured regex match in a file or containing symbol body. - """ - - @staticmethod - def _describe_search_scope(relative_path: str, containing_symbol_name_path: str | None) -> str: - if containing_symbol_name_path: - return f"symbol body '{containing_symbol_name_path}' in file '{relative_path}'" - return f"file '{relative_path}'" - - @classmethod - def _format_match_preview(cls, match: re.Match[str]) -> str: - matched_text = match.group(0).replace("\n", "\\n") - return matched_text[:120] - - @classmethod - def _get_unique_captured_span(cls, match: re.Match[str], regex: str, search_scope_description: str) -> tuple[int, int] | str: - if match.re.groups == 0: - return ( - f"Error: Regex '{regex}' must contain exactly one capturing group that identifies the symbol usage in " - f"{search_scope_description}." - ) - - matched_capture_spans = [span for span in match.regs[1:] if span != (-1, -1)] - if len(matched_capture_spans) != 1: - return ( - f"Error: Regex '{regex}' must produce exactly one matched capture in {search_scope_description}, " - f"but produced {len(matched_capture_spans)} for match '{cls._format_match_preview(match)}'." - ) - - capture_start_offset, capture_end_offset = matched_capture_spans[0] - if capture_start_offset == capture_end_offset: - return ( - f"Error: Regex '{regex}' produced an empty capture in {search_scope_description}; " - "the capture must select the referenced symbol text." - ) - return capture_start_offset, capture_end_offset - - def _find_unique_captured_location( - self, - relative_path: str, - regex: str, - containing_symbol_name_path: str | None, - ) -> tuple[int, int] | str: - # retrieving the search region - file_content = self.project.read_file(relative_path) - symbol_retriever = self.create_language_server_symbol_retriever() - search_scope_description = self._describe_search_scope(relative_path, containing_symbol_name_path) - search_start_offset = 0 - search_text = file_content - - if containing_symbol_name_path: - try: - containing_symbol = symbol_retriever.find_unique(containing_symbol_name_path, within_relative_path=relative_path) - except Exception as e: - return f"Error: Could not resolve containing symbol '{containing_symbol_name_path}' in file '{relative_path}': {e}" - - body_start_position = containing_symbol.get_body_start_position_or_raise() - body_end_position = containing_symbol.get_body_end_position_or_raise() - search_start_offset = _line_and_column_to_offset(file_content, body_start_position.line, body_start_position.col) - search_end_offset = _line_and_column_to_offset(file_content, body_end_position.line, body_end_position.col) - search_text = file_content[search_start_offset:search_end_offset] - - # finding regex matches - try: - compiled_regex = re.compile(regex, re.MULTILINE) - except re.error as e: - return f"Error: Invalid regex '{regex}': {e}" - - if compiled_regex.groups == 0: - return ( - f"Error: Regex '{regex}' must contain exactly one capturing group that identifies the symbol usage in " - f"{search_scope_description}." - ) - - matches = list(compiled_regex.finditer(search_text)) - if len(matches) != 1: - match_previews = [self._format_match_preview(match) for match in matches[:3]] - preview_suffix = f" Matches: {match_previews}" if match_previews else "" - return ( - f"Error: Expected exactly one regex match for '{regex}' in {search_scope_description}, " - f"but found {len(matches)}.{preview_suffix}" - ) - - capture_span_or_error = self._get_unique_captured_span(matches[0], regex, search_scope_description) - if isinstance(capture_span_or_error, str): - return capture_span_or_error - - capture_start_offset, _ = capture_span_or_error - absolute_capture_start_offset = search_start_offset + capture_start_offset - return _offset_to_line_and_column(file_content, absolute_capture_start_offset) - - # noinspection PyDefaultArgument - def apply( - self, - regex: str, - relative_path: str, - containing_symbol_name_path: str = "", - include_body: bool = False, - include_info: bool = False, - max_answer_chars: int = -1, ) -> str: r""" - Finds the defining symbol for a uniquely captured regex match in a file. + Finds the declaration of a symbol. - The regex must contain exactly one capturing group, and exactly one overall match must be found. - The capture identifies the symbol usage whose definition should be resolved. - - The regex is compiled with ``re.MULTILINE`` enabled and ``re.DOTALL`` disabled. This keeps common - single-symbol matches predictable. If cross-line matching is needed, opt in explicitly with ``(?s)`` - or ``[\\s\\S]*?``. - - :param regex: a Python regular expression containing exactly one capturing group. - :param relative_path: the relative path to the file containing the symbol usage. + :param relative_path: the relative path to the source file containing the symbol for which to find the declaration. + :param regex: a regular expression with one group, where the group matches the symbol for which to perform the lookup. + For example, to find the declaration of the `process` method in a call like `obj.process()`, + pass an expression like "obj\.(process)\(process_input_arg=37\)". + Prefer regexes with sufficiently large context around the group to render the match unambiguous. + Uses Python syntax with MULTILINE and DOTALL flags enabled. :param containing_symbol_name_path: optional name path of a containing symbol whose body shall be searched instead of the full file. - :param include_body: whether to include the source code of the defining symbol. - :param include_info: whether to include additional info (hover-like, typically including docstring and signature) - about the defining symbol. Ignored if no defining symbol is found. - :param max_answer_chars: same as in the `find_symbol` tool. - :return: a JSON object representing the defining symbol, or ``null`` if no definition was found. - If the regex does not identify a unique captured match, an informative error string is returned. + :param include_body: whether to include the symbol's body in the result. Default False. + :param include_info: whether to include additional info (hover-like). Default False. """ - captured_location_or_error = self._find_unique_captured_location( - relative_path=relative_path, - regex=regex, - containing_symbol_name_path=containing_symbol_name_path or None, - ) - if isinstance(captured_location_or_error, str): - return captured_location_or_error - - line, column = captured_location_or_error symbol_retriever = self.create_language_server_symbol_retriever() - defining_symbol = symbol_retriever.find_defining_symbol( + relative_path = self._sanitize_input_param(relative_path) + regex = self._sanitize_input_param(regex) + + # find relevant location for lookup + editor = self.create_code_editor() + if not containing_symbol_name_path: + content = editor.read_file(relative_path) + coords = find_text_coordinates(content, regex, require_unique=True) + assert coords is not None + else: + symbol = symbol_retriever.find_unique(name_path_pattern=containing_symbol_name_path, within_relative_path=relative_path) + body_line_numers = symbol.get_body_line_numbers_or_raise() + content = editor.read_file(relative_path, lines=body_line_numers) + coords = find_text_coordinates(content, regex, require_unique=True) + assert coords is not None + coords.line += body_line_numers[0] + + # retrieve declaration + defining_symbol = symbol_retriever.find_declaration( relative_file_path=relative_path, - line=line, - column=column, + line=coords.line, + column=coords.col, include_body=include_body, ) - symbol_dict = FindDefiningSymbolAtLocationTool._defining_symbol_to_result_dict( + if defining_symbol is None: + raise ValueError( + f"No symbol declaration found at the location of the regex match. Location: {relative_path}:{coords.line}:{coords.col}." + ) + + # create output + symbol_dict = self._defining_symbol_to_result_dict( symbol_retriever, defining_symbol, include_body, include_info, ) result = self._to_json(symbol_dict) - return self._limit_length(result, max_answer_chars) + return result + + @staticmethod + def _defining_symbol_to_result_dict( + symbol_retriever: Any, + defining_symbol: LanguageServerSymbol, + include_body: bool, + include_info: bool, + ) -> dict[str, Any]: + symbol_dict = dict(defining_symbol.to_dict(kind=True, relative_path=True, depth=0, body=include_body, body_location=True)) + if not include_body and include_info: + if symbol_info := symbol_retriever.request_info_for_symbol(defining_symbol): + symbol_dict["info"] = symbol_info + symbol_dict.pop("name", None) + return symbol_dict class GetDiagnosticsForFileTool(Tool, ToolMarkerSymbolicRead): diff --git a/src/solidlsp/ls_utils.py b/src/solidlsp/ls_utils.py index b54654b8..73066e12 100644 --- a/src/solidlsp/ls_utils.py +++ b/src/solidlsp/ls_utils.py @@ -124,6 +124,14 @@ class TextUtils: end_idx = TextUtils.get_index_from_line_col(text, end_line, end_col) return text[start_idx:end_idx] + @staticmethod + def get_text_in_lines_range(text: str, start_line: int, end_line: int) -> str: + """ + Returns the text encompassed by the given start and end lines (inclusive). + """ + lines = text.splitlines(keepends=True) + return "".join(lines[start_line : end_line + 1]) + class PathUtils: """ diff --git a/test/serena/test_serena_agent.py b/test/serena/test_serena_agent.py index b3e2427b..4a30550a 100644 --- a/test/serena/test_serena_agent.py +++ b/test/serena/test_serena_agent.py @@ -19,8 +19,7 @@ from serena.tools import ( SUCCESS_RESULT, ActivateProjectTool, EditingToolWithDiagnostics, - FindDefiningSymbolAtLocationTool, - FindDefiningSymbolTool, + FindDeclarationTool, FindImplementationsTool, FindReferencingSymbolsTool, FindSymbolTool, @@ -958,38 +957,10 @@ class TestSerenaAgent: f"Expected to find reference to {case.symbol_name} in {case.reference_file}. refs={refs}" ) - @pytest.mark.parametrize("serena_agent,case", FIND_DEFINING_SYMBOL_CASES, indirect=["serena_agent"]) - def test_find_defining_symbol(self, serena_agent: SerenaAgent, case: FindDefiningSymbolCase) -> None: - project_root = get_repo_path(case.language) - position = find_identifier_pos( - project_root / case.relative_path, - case.identifier, - case.occurrence_index, - case.column_offset, - ) - assert position is not None, f"Could not find occurrence {case.occurrence_index} of {case.identifier!r} in {case.relative_path}" - find_defining_symbol_tool = serena_agent.get_tool(FindDefiningSymbolAtLocationTool) - result = find_defining_symbol_tool.apply( - relative_path=case.relative_path, - line=position[0], - column=position[1], - include_info=True, - ) - defining_symbol = json.loads(result) - assert defining_symbol is not None, f"Expected defining symbol for {case.identifier!r} in {case.relative_path}" - assert defining_symbol.get("relative_path") is not None - assert case.expected_definition_file in defining_symbol["relative_path"], ( - f"Expected defining symbol in {case.expected_definition_file!r}, got: {defining_symbol}" - ) - assert self._symbol_matches_expected_name(defining_symbol, case.expected_name), ( - f"Expected defining symbol name {case.expected_name!r}, got: {defining_symbol}" - ) - self._assert_symbol_info_present(serena_agent, defining_symbol) - @pytest.mark.parametrize("serena_agent,case", FIND_DEFINING_SYMBOL_REGEX_CASES, indirect=["serena_agent"]) - def test_find_defining_symbol_by_regex(self, serena_agent: SerenaAgent, case: RegexDefiningSymbolCase) -> None: - find_defining_symbol_tool = serena_agent.get_tool(FindDefiningSymbolTool) - result = find_defining_symbol_tool.apply( + def test_find_declaration(self, serena_agent: SerenaAgent, case: RegexDefiningSymbolCase) -> None: + tool = serena_agent.get_tool(FindDeclarationTool) + result = tool.apply( regex=case.regex, relative_path=case.relative_path, containing_symbol_name_path=case.containing_symbol_name_path, @@ -1007,13 +978,13 @@ class TestSerenaAgent: self._assert_symbol_info_present(serena_agent, defining_symbol) @pytest.mark.parametrize("serena_agent,case", FIND_DEFINING_SYMBOL_REGEX_ERROR_CASES, indirect=["serena_agent"]) - def test_find_defining_symbol_by_regex_error( + def test_find_declaration_error( self, serena_agent: SerenaAgent, case: RegexDefiningSymbolErrorCase, ) -> None: - find_defining_symbol_tool = serena_agent.get_tool(FindDefiningSymbolTool) - result = find_defining_symbol_tool.apply( + tool = serena_agent.get_tool(FindDeclarationTool) + result = tool.apply( regex=case.regex, relative_path=case.relative_path, containing_symbol_name_path=case.containing_symbol_name_path, From 4f0c3f83e3f6262a9b9828d6171e5e69b0bc3edd Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Thu, 30 Apr 2026 15:22:37 +0200 Subject: [PATCH 12/12] Update change log and README to consider new diagnostics/declaration/implementations tools --- CHANGELOG.md | 13 ++++++++++++- README.md | 5 +++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 96330eaa..e65d8ceb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,21 @@ Status of the `main` branch. Changes prior to the next official version change will appear here. +* JetBrains: + - Add new tools: + - `jet_brains_list_inspections`: Lists available IDE inspections (akin to diagnostics), optionally filtered by language or group + - `jet_brains_run_inspections`: Runs IDE inspections on a file and returns the results + +* LSP Tools: + - Add new tools: + - `find_declaration`: Finds the declaration/definition of a symbol + - `find_implementations`: Finds the implementations of an interface or abstract method + - `get_diagnostics_for_file`: Retrieves diagnostics for a specific file (errors, warnings, etc.) + - `get_diagnostics_for_symbol`: Retrieves diagnostics pertaining to a specific symbol + * Language Servers: - Java (`eclipse.jdt.ls`): Add upstream JDTLS mode for offline / restricted-network use. Setting both `jdtls_path` and `lombok_path` in `ls_specific_settings.java` makes Serena use an existing upstream JDTLS installation (e.g. `brew install jdtls`) and the system JDK 21+, skipping the ~500 MB vscode-java VSIX, Gradle, and IntelliCode downloads. New related setting `java_home` lets the user override the JDK used to launch JDTLS. Default behavior unchanged — the JDTLS workspace hash is preserved bit-for-bit for users on the default route, so existing project caches are reused without a one-time reindex; the launcher path is mixed into the hash only when `jdtls_path` is set, isolating upstream installations from the default workspace. #1415 - # v1.2.0 (2026-04-27) * General: diff --git a/README.md b/README.md index 0c819fb4..a89d9267 100644 --- a/README.md +++ b/README.md @@ -145,9 +145,10 @@ without reading entire files. | find referencing symbols | yes | yes | | search in project dependencies | -- | yes | | type hierarchy | -- | yes | -| find declaration | -- | yes | -| find implementations | -- | yes | +| find declaration | yes | yes | +| find implementations | yes | yes | | query external projects | yes | yes | +| diagnostics/inspections | yes | yes | ### Refactoring