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/.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 c9fd4467..aa5d12e3 100644 --- a/.serena/project.yml +++ b/.serena/project.yml @@ -31,6 +31,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/CHANGELOG.md b/CHANGELOG.md index 158a82b9..e5d8f79c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,10 +8,21 @@ Status of the `main` branch. Changes prior to the next official version change w See updated [documentation on modes](https://oraios.github.io/serena/02-usage/050_configuration.html#modes). - Serena's default configuration now uses `interactive` and `editing` as `base_modes` instead of as `default_modes`. +* 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 58c04aea..de04fa91 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 diff --git a/scripts/demo_diagnostics.py b/scripts/demo_diagnostics.py new file mode 100644 index 00000000..2e5cd30e --- /dev/null +++ b/scripts/demo_diagnostics.py @@ -0,0 +1,176 @@ +""" +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, + EditingToolWithDiagnostics, + GetDiagnosticsForFileTool, + GetDiagnosticsForSymbolTool, + ReplaceContentTool, +) +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__": + EditingToolWithDiagnostics.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: + shutil.rmtree(temp_dir, ignore_errors=True) + agent.shutdown() diff --git a/scripts/demo_find_defining_symbol.py b/scripts/demo_find_defining_symbol.py new file mode 100644 index 00000000..63bde56c --- /dev/null +++ b/scripts/demo_find_defining_symbol.py @@ -0,0 +1,112 @@ +""" +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 FindDeclarationTool +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 regex over the full file + 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)", + 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 [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/scripts/demo_run_tools.py b/scripts/demo_run_tools.py index b0ed063b..1c95365c 100644 --- a/scripts/demo_run_tools.py +++ b/scripts/demo_run_tools.py @@ -8,14 +8,16 @@ 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, + JetBrainsRunInspectionsTool, JetBrainsSafeDeleteTool, SearchForPatternTool, ) @@ -23,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) @@ -35,12 +38,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( + lambda: diagnostics_in_file_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/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/jetbrains/jetbrains_plugin_client.py b/src/serena/jetbrains/jetbrains_plugin_client.py index 263bc8a9..c7b440ce 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", "/runInspectionsOnFile", 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 47606f72..cbaab89d 100644 --- a/src/serena/resources/config/internal_modes/jetbrains.yml +++ b/src/serena/resources/config/internal_modes/jetbrains.yml @@ -26,3 +26,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/symbol.py b/src/serena/symbol.py index 4a1b14cc..8bae7d14 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 @@ -309,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: """ @@ -869,6 +876,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_declaration( + self, + relative_file_path: str, + line: int, + column: int, + include_body: bool = False, + ) -> LanguageServerSymbol | None: + """ + 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. + :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..4b4313eb 100644 --- a/src/serena/tools/file_tools.py +++ b/src/serena/tools/file_tools.py @@ -12,7 +12,7 @@ from fnmatch import fnmatch from pathlib import Path from typing import Literal -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 @@ -48,7 +48,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. """ @@ -61,23 +61,27 @@ class CreateTextFileTool(Tool, ToolMarkerCanEdit): :param content: the (appropriately encoded) content to write to the file :return: a message indicating success or failure """ - project_root = self.get_project_root() - abs_path = (Path(project_root) / relative_path).resolve() - will_overwrite_existing = abs_path.exists() + 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() - 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=}" + ) - 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 + # 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 diagnostics_context.format_result(answer) class ListDirTool(Tool): @@ -156,7 +160,7 @@ class FindFileTool(Tool): return result -class ReplaceContentTool(Tool, ToolMarkerCanEdit): +class ReplaceContentTool(EditingToolWithDiagnostics): """ Replaces content in a file (optionally using regular expressions). """ @@ -212,16 +216,17 @@ class ReplaceContentTool(Tool, ToolMarkerCanEdit): Performs the replacement, with additional options not exposed in the tool. This function can be used internally by other tools. """ - 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 + 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(Tool, ToolMarkerCanEdit, ToolMarkerOptional): +class DeleteLinesTool(EditingToolWithDiagnostics, ToolMarkerOptional): """ Deletes a range of lines within a file. """ @@ -241,12 +246,13 @@ 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 """ - code_editor = self.create_code_editor() - code_editor.delete_lines(relative_path, start_line, end_line) - return SUCCESS_RESULT + 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(Tool, ToolMarkerCanEdit, ToolMarkerOptional): +class ReplaceLinesTool(EditingToolWithDiagnostics, ToolMarkerOptional): """ Replaces a range of lines within a file with new content. """ @@ -268,16 +274,19 @@ 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 + + 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) + + return diagnostics_context.format_result(SUCCESS_RESULT) -class InsertAtLineTool(Tool, ToolMarkerCanEdit, ToolMarkerOptional): +class InsertAtLineTool(EditingToolWithDiagnostics, ToolMarkerOptional): """ Inserts content at a given line in a file. """ @@ -298,11 +307,15 @@ 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" - code_editor = self.create_code_editor() - code_editor.insert_at_line(relative_path, line, content) - return SUCCESS_RESULT + + with self.DiagnosticsContext(self, relative_path) as diagnostics_context: + code_editor = self.create_code_editor() + code_editor.insert_at_line(relative_path, line, content) + + return diagnostics_context.format_result(SUCCESS_RESULT) class SearchForPatternTool(Tool): 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) diff --git a/src/serena/tools/symbol_tools.py b/src/serena/tools/symbol_tools.py index 3881eb23..caa42b3a 100644 --- a/src/serena/tools/symbol_tools.py +++ b/src/serena/tools/symbol_tools.py @@ -6,15 +6,19 @@ import copy import os from collections import Counter, defaultdict from collections.abc import Sequence +from typing import Any from serena.symbol import LanguageServerSymbol, LanguageServerSymbolDictGrouper from serena.tools import ( SUCCESS_RESULT, + EditingToolWithDiagnostics, Tool, ToolMarkerSymbolicEdit, ToolMarkerSymbolicRead, ) 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 @@ -324,7 +328,242 @@ class FindReferencingSymbolsTool(Tool, ToolMarkerSymbolicRead): return self._limit_length(result_json, max_answer_chars, shortened_result_factories=shortened_results) -class ReplaceSymbolBodyTool(Tool, ToolMarkerSymbolicEdit): +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 FindDeclarationTool(Tool, ToolMarkerSymbolicRead): + """ + Finds the declaration/definition of a symbol + """ + + def apply( + self, + relative_path: str, + regex: str, + containing_symbol_name_path: str | None = None, + include_body: bool = False, + include_info: bool = False, + ) -> str: + r""" + Finds the declaration of a symbol. + + :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 symbol's body in the result. Default False. + :param include_info: whether to include additional info (hover-like). Default False. + """ + symbol_retriever = self.create_language_server_symbol_retriever() + 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=coords.line, + column=coords.col, + include_body=include_body, + ) + 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 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): + """ + Gets diagnostics for a file, optionally restricted to a line range, grouped by file, severity, and containing symbol. + """ + + FILE_LEVEL_DIAGNOSTIC_BUCKET = "" + + 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_diagnostics = GroupedDiagnostics() + for diagnostic in diagnostics: + diag_range = diagnostic["range"]["start"] + name_path = self.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() + grouped_diagnostics.add(relative_path, name_path, diagnostic) + + result = self._to_json(grouped_diagnostics.get_dict()) + 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. + """ + + 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_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: + grouped_diagnostics.add(relative_path, symbol_name_path, diagnostic) + + result = self._to_json(grouped_diagnostics.get_dict()) + return self._limit_length(result, max_answer_chars) + + +class ReplaceSymbolBodyTool(EditingToolWithDiagnostics): """ Replaces the full definition of a symbol using the language server backend. """ @@ -348,16 +587,17 @@ 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. """ - code_editor = self.create_code_editor() - code_editor.replace_body( - name_path, - relative_file_path=relative_path, - body=body, - ) - return SUCCESS_RESULT + 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(Tool, ToolMarkerSymbolicEdit): +class InsertAfterSymbolTool(EditingToolWithDiagnostics): """ Inserts content after the end of the definition of a given symbol. """ @@ -377,12 +617,13 @@ class InsertAfterSymbolTool(Tool, ToolMarkerSymbolicEdit): :param body: the body/content to be inserted. The inserted code shall begin with the next line after the symbol. """ - code_editor = self.create_code_editor() - code_editor.insert_after_symbol(name_path, relative_file_path=relative_path, body=body) - return SUCCESS_RESULT + 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(Tool, ToolMarkerSymbolicEdit): +class InsertBeforeSymbolTool(EditingToolWithDiagnostics): """ Inserts content before the beginning of the definition of a given symbol. """ @@ -402,9 +643,10 @@ 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 """ - code_editor = self.create_code_editor() - code_editor.insert_before_symbol(name_path, relative_file_path=relative_path, body=body) - return SUCCESS_RESULT + 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 c13e4cd4..565a4055 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,6 +18,7 @@ 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 serena.util.ls_diagnostics import DiagnosticsDiff, EditedFilePath, PublishedDiagnosticsSnapshot from solidlsp.ls_exceptions import SolidLSPException if TYPE_CHECKING: @@ -319,7 +320,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: @@ -403,6 +404,62 @@ class Tool(Component): 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. + """ + + DIAGNOSTICS_KEY = "diagnostics[warning-or-higher]" + + 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: + 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 + + def __exit__(self, exc_type, exc_val, exc_tb): # type: ignore + pass + + def format_result( + self, + base_result: str, + ) -> str: + if not self._is_diagnostics_enabled: + return base_result + + if self._before_edit_diagnostics_snapshot is None: + return base_result + + 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().get_dict() + + 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: """ Context manager for file editing. diff --git a/src/serena/util/ls_diagnostics.py b/src/serena/util/ls_diagnostics.py new file mode 100644 index 00000000..13b505ea --- /dev/null +++ b/src/serena/util/ls_diagnostics.py @@ -0,0 +1,203 @@ +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 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, + before_snapshot: PublishedDiagnosticsSnapshot, + edited_files: Iterable[EditedFilePath], + symbol_retriever: "LanguageServerSymbolRetriever", + ): + grouped_diagnostics = GroupedDiagnostics() + + 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 "" + grouped_diagnostics.add(edited_file_path.after_relative_path, name_path, diagnostic) + + self._grouped_diagnostics = grouped_diagnostics + + def get_grouped_diagnostics(self) -> GroupedDiagnostics: + return self._grouped_diagnostics 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/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/elm_language_server.py b/src/solidlsp/language_servers/elm_language_server.py index e1960c32..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 @@ -32,9 +33,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 +67,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 +84,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 +92,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 +108,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 +142,7 @@ class ElmLanguageServer(SolidLanguageServer): "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, "codeAction": {"dynamicRegistration": True}, "rename": {"dynamicRegistration": True}, + "publishDiagnostics": {"relatedInformation": True}, }, "workspace": { "workspaceFolders": True, @@ -203,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 2cd6b099..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 @@ -220,6 +220,7 @@ class FortranLanguageServer(SolidLanguageServer): "formatting": {"dynamicRegistration": True}, "rangeFormatting": {"dynamicRegistration": True}, "codeAction": {"dynamicRegistration": True}, + "publishDiagnostics": {"relatedInformation": True}, }, "workspace": { "workspaceFolders": True, @@ -284,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/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/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/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/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/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/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/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/solidity_language_server.py b/src/solidlsp/language_servers/solidity_language_server.py index 223a6683..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"] @@ -148,6 +192,7 @@ class SolidityLanguageServer(SolidLanguageServer): "dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"], # type: ignore[list-item] }, + "publishDiagnostics": {"relatedInformation": True}, }, "workspace": { "workspaceFolders": True, @@ -224,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/language_servers/typescript_language_server.py b/src/solidlsp/language_servers/typescript_language_server.py index ddf3f605..6f3a443b 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 @@ -85,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() @@ -248,9 +255,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}, }, @@ -298,6 +307,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 @@ -357,6 +370,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) @@ -402,6 +416,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 5f3d7d43..aa5e2b4b 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -13,8 +13,8 @@ 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 typing import Self, Union, cast +from time import monotonic, perf_counter, sleep +from typing import Any, Self, Union, cast import pathspec from sensai.util.pickle import getstate, load_pickle @@ -515,6 +515,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 @@ -554,6 +558,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 @@ -571,6 +576,388 @@ 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)) + + # 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[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(key, -1) + + def _wait_for_published_diagnostics( + self, + uri: str, + 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(key, -1) + if current_generation > after_generation: + return list(self._published_diagnostics.get(key, [])) + + 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: + key = self._canonicalize_published_diagnostics_uri(uri) + with self._published_diagnostics_condition: + diagnostics = self._published_diagnostics.get(key) + 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"] + + # 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: + 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) + 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_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 _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. + """ + 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, + 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) + 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 + + with self.open_file(relative_file_path): + 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 + + 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.get("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_relevant_published_diagnostics( + uri=published_uri, + after_generation=diagnostics_before_request, + timeout=self._get_published_diagnostics_wait_timeout(pull_diagnostics_failed), + ) + 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_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: 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/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/conftest.py b/test/conftest.py index 4d3f1154..7672bde6 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_pos( + 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/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/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/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/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/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/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/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/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/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/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..169754f7 --- /dev/null +++ b/test/resources/repos/lean4/test_repo/DiagnosticsSample.lean @@ -0,0 +1 @@ +def brokenFactory : Nat := 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/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/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/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/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/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 6c688345..ec23cf33 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..4a30550a 100644 --- a/test/serena/test_serena_agent.py +++ b/test/serena/test_serena_agent.py @@ -5,9 +5,12 @@ import re 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 _pytest.mark import Mark, MarkDecorator, ParameterSet from serena.agent import SerenaAgent from serena.config.serena_config import ProjectConfig, RegisteredProject, SerenaConfig @@ -15,8 +18,12 @@ from serena.project import Project from serena.tools import ( SUCCESS_RESULT, ActivateProjectTool, + EditingToolWithDiagnostics, + FindDeclarationTool, + FindImplementationsTool, FindReferencingSymbolsTool, FindSymbolTool, + GetDiagnosticsForFileTool, InitialInstructionsTool, ReplaceContentTool, ReplaceSymbolBodyTool, @@ -25,8 +32,728 @@ 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.solidlsp import clojure as clj +from test.conftest import ( + find_identifier_pos, + get_pytest_markers, + get_repo_path, + is_ci, + language_tests_enabled, +) + + +@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"), + ), +] + +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(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(), +] + +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 + ), +] + +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(), +] + +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(), +] @pytest.fixture @@ -37,6 +764,7 @@ def serena_config(): test_projects = [] for language in [ Language.PYTHON, + Language.PYTHON_TY, Language.GO, Language.JAVA, Language.KOTLIN, @@ -82,6 +810,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.""" + assert EditingToolWithDiagnostics.DIAGNOSTICS_KEY in result + d = json.loads(result) + return d[EditingToolWithDiagnostics.DIAGNOSTICS_KEY] + + @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.""" @@ -119,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 @@ -131,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) @@ -245,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: @@ -265,227 +953,128 @@ 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}" + ) - @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,case", FIND_DEFINING_SYMBOL_REGEX_CASES, indirect=["serena_agent"]) + 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, + 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,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,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( + @pytest.mark.parametrize("serena_agent,case", FIND_DEFINING_SYMBOL_REGEX_ERROR_CASES, indirect=["serena_agent"]) + def test_find_declaration_error( self, - serena_agent, - name_path: str, - substring_matching: bool, - expected_symbol_name: str, - expected_kind: str, - expected_file: str, - ): + serena_agent: SerenaAgent, + case: RegexDefiningSymbolErrorCase, + ) -> 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, + ) + 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=case.symbol_name, relative_path=case.definition_file) + symbols = json.loads(result) + 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( + 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 {case.symbol_name} in {case.implementation_file}. implementations={implementations}" + for implementation in implementations: + self._assert_symbol_info_present(serena_agent, implementation) + + @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) @@ -494,51 +1083,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"], ) @@ -551,7 +1127,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", ) @@ -560,14 +1136,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. @@ -591,10 +1164,71 @@ class TestSerenaAgent: @pytest.mark.parametrize( "serena_agent", [ - pytest.param( - Language.TYPESCRIPT, - marks=pytest.mark.typescript, - ), + 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"], + ) + 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) + 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", + ) + + 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", + [ + 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"], + ) + 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) + 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 + """, + ) + + 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", + [ + pytest.param(Language.TYPESCRIPT, marks=get_pytest_markers(Language.TYPESCRIPT), id="typescript_ambiguous_regex"), ], indirect=["serena_agent"], ) @@ -607,98 +1241,40 @@ class TestSerenaAgent: 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/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/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_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/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_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/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 d0f52443..f4f49ebc 100644 --- a/test/solidlsp/fsharp/test_fsharp_basic.py +++ b/test/solidlsp/fsharp/test_fsharp_basic.py @@ -7,8 +7,9 @@ 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 +from test.solidlsp.util.diagnostics import assert_file_diagnostics # Currently, most F# tests fail, there seems to be a regression or instability. @@ -39,6 +40,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.""" @@ -169,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_basic.py b/test/solidlsp/go/test_go_basic.py index 93fcd98e..347f6022 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") == "FormatGreeting" and "main.go" in symbol["location"].get("relativePath", "") + for symbol in 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: """Filter workspace symbols to exact name matches in the test repo.""" 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_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/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/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/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_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/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 814892d0..4c7ec2c0 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,12 +58,39 @@ 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) 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_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/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/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/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_basic.py b/test/solidlsp/typescript/test_typescript_basic.py index 2caf6b11..1c9ed1ae 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_tsx_symbol_range_not_truncated_by_jsx(self, language_server: SolidLanguageServer) -> None: # Regression: when the language id is sent as "typescript" instead of 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, + )