From 3ad4044c69df188a3ff9a823668463bab05fbf4c Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Thu, 17 Sep 2026 22:43:08 +0200 Subject: [PATCH 1/2] Enable ty rule 'unresolved-attribute' for test/ --- pyproject.toml | 1 - test/serena/test_fs_api.py | 4 ++-- test/serena/test_ls_file_sync.py | 7 +++++-- test/serena/test_serena_agent.py | 3 ++- test/serena/test_symbol.py | 6 +++--- test/solidlsp/al/test_al_basic.py | 16 ++++++++-------- test/solidlsp/angular/test_angular_basic.py | 1 + .../solidlsp/angular/test_angular_error_cases.py | 2 ++ test/solidlsp/clojure/test_clojure_indexing.py | 6 ++++-- test/solidlsp/crystal/test_crystal_basic.py | 4 +++- test/solidlsp/dart/test_dart_basic.py | 1 + test/solidlsp/erlang/test_erlang_ignored_dirs.py | 4 ++++ test/solidlsp/julia/test_fatou.py | 2 +- test/solidlsp/pascal/test_pascal_basic.py | 2 +- test/solidlsp/python/test_symbol_retrieval.py | 1 + test/solidlsp/ruby/test_ruby_symbol_retrieval.py | 2 ++ test/solidlsp/rust/test_rust_basic.py | 4 ++-- test/solidlsp/scss/test_scss_basic.py | 2 ++ test/solidlsp/svelte/test_svelte_basic.py | 7 ++++--- test/solidlsp/svelte/test_svelte_references.py | 4 ++-- test/solidlsp/svelte/test_svelte_rename.py | 2 ++ test/solidlsp/test_ls_start_cleanup.py | 4 ++-- test/solidlsp/test_pdeathsig.py | 10 ++++++---- test/solidlsp/test_process_group_cleanup.py | 10 +++++++--- 24 files changed, 67 insertions(+), 38 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 05cfe4cf..1a4b21eb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -152,7 +152,6 @@ no-matching-overload = "ignore" not-subscriptable = "ignore" parameter-already-assigned = "ignore" too-many-positional-arguments = "ignore" -unresolved-attribute = "ignore" unsupported-operator = "ignore" [tool.poe.env] diff --git a/test/serena/test_fs_api.py b/test/serena/test_fs_api.py index 28d343de..65f29b3b 100644 --- a/test/serena/test_fs_api.py +++ b/test/serena/test_fs_api.py @@ -73,9 +73,9 @@ def test_list_dir_and_find_file(api: FsApi) -> None: def test_search_for_pattern(api: FsApi) -> None: matches = api.search_for_pattern("foo", relative_path="src") assert len(matches) == 3 - assert {m.source_file_path.replace("\\", "/") for m in matches.matches} == {"src/a.py", "src/b.txt"} + assert {m.source_file_path.replace("\\", "/") for m in matches.matches} == {"src/a.py", "src/b.txt"} # type: ignore # restricting to code files excludes the text file; the rendering maps files to matched lines code_matches = api.search_for_pattern("foo", restrict_search_to_code_files=True) - assert all(m.source_file_path.endswith("a.py") for m in code_matches.matches) + assert all(m.source_file_path.endswith("a.py") for m in code_matches.matches) # type: ignore assert "foo(1)" in code_matches.represent() diff --git a/test/serena/test_ls_file_sync.py b/test/serena/test_ls_file_sync.py index 618559b3..381e31de 100644 --- a/test/serena/test_ls_file_sync.py +++ b/test/serena/test_ls_file_sync.py @@ -62,7 +62,8 @@ class FileSystemSyncTestCase: symbol_names = [ref["name_path"].split("/")[-1] for ref in ref_symbols] return symbol_names else: - ls = next(iter(agent.get_active_project_or_raise().language_server_manager.iter_language_servers())) + ls_manager = agent.get_active_project_or_raise().get_language_server_manager_or_raise() + ls = next(iter(ls_manager.iter_language_servers())) document_symbols = ls.request_document_symbols(self._TARGET_FILE).get_all_symbols_and_roots() target = next((s for s in document_symbols[0] if s.get("name") == self._TARGET_SYMBOL), None) assert target is not None and "selectionRange" in target, f"{self._TARGET_SYMBOL} not found in {self._TARGET_FILE}" @@ -162,7 +163,9 @@ class SymbolPositionStaleAfterExternalEditTestCase: with agent_for_project_context(LanguageServerId.PYTHON, str(repo_root)) as agent: project = agent.get_active_project_or_raise() - ls = next(iter(project.language_server_manager.iter_language_servers())) + ls_manager = project.language_server_manager + assert ls_manager is not None + ls = next(iter(ls_manager.iter_language_servers())) tool = agent.get_tool(FindSymbolTool) # Hold the file's buffer open across the external edit, mirroring the state left diff --git a/test/serena/test_serena_agent.py b/test/serena/test_serena_agent.py index 625bd5a2..dd774b42 100644 --- a/test/serena/test_serena_agent.py +++ b/test/serena/test_serena_agent.py @@ -833,7 +833,7 @@ def parse_edit_diagnostics_result(result: str) -> dict: @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.""" - project = serena_agent.get_active_project() + project = serena_agent.get_active_project_or_raise() file_path = os.path.join(project.project_root, relative_path) # Read the original content @@ -1455,6 +1455,7 @@ class TestPromptProvision: # now activate another project which dynamically enables a new mode (no-onboarding) reg_project = serena_agent.serena_config.get_registered_project(project_name2) + assert reg_project is not None reg_project.project_config.default_modes = ["no-onboarding"] expected_new_mode_message = "The onboarding process is not applied." result2 = self._call_tool(serena_agent, ActivateProjectTool, project=project_name2, session_id=session1) diff --git a/test/serena/test_symbol.py b/test/serena/test_symbol.py index db315270..5dfc2029 100644 --- a/test/serena/test_symbol.py +++ b/test/serena/test_symbol.py @@ -251,10 +251,10 @@ class TestSymbolDictTypes: :param key_type: the corresponding key type (Literal[...]) that the dict should have for keys """ dict_type_keys = dict_type.__annotations__.keys() - assert len(dict_type_keys) == len(key_type.__args__), ( - f"Expected {len(key_type.__args__)} keys in {dict_type}, but got {len(dict_type_keys)}" + assert len(dict_type_keys) == len(key_type.__args__), ( # type: ignore + f"Expected {len(key_type.__args__)} keys in {dict_type}, but got {len(dict_type_keys)}" # type: ignore ) - for expected_key in key_type.__args__: + for expected_key in key_type.__args__: # type: ignore assert expected_key in dict_type_keys, f"Expected key '{expected_key}' not found in {dict_type}" def test_ls_symbol_dict_type(self): diff --git a/test/solidlsp/al/test_al_basic.py b/test/solidlsp/al/test_al_basic.py index 62e129e5..e6310c36 100644 --- a/test/solidlsp/al/test_al_basic.py +++ b/test/solidlsp/al/test_al_basic.py @@ -264,7 +264,7 @@ class TestALHoverInjection: char = start.get("character", 0) hover = language_server.request_hover(file_path, line, char) if hover and "contents" in hover: - return hover, hover["contents"].get("value", "") + return hover, hover["contents"].get("value", "") # type: ignore return hover, None return None, None @@ -286,7 +286,7 @@ class TestALHoverInjection: char = start.get("character", 0) hover = language_server.request_hover(file_path, line, char) if hover and "contents" in hover: - return hover, hover["contents"].get("value", "") + return hover, hover["contents"].get("value", "") # type: ignore return hover, None return None, None @@ -373,7 +373,7 @@ class TestALHoverInjection: hover = language_server.request_hover(file_path, line, char) assert hover is not None, "Hover should return a result for field" - value = hover.get("contents", {}).get("value", "") + value = hover.get("contents", {}).get("value", "") # type: ignore # Field hover should NOT start with ** (no injection) assert not value.startswith("**"), f"Field hover should not have injected name. Got: {value[:200]}" return @@ -445,7 +445,7 @@ class TestALPathNormalization: hover = language_server.request_hover(file_path, line, char) assert hover is not None, "Hover should return a result" - value = hover.get("contents", {}).get("value", "") + value = hover.get("contents", {}).get("value", "") # type: ignore assert '**Table 50000 "TEST Customer"**' in value, f"Hover should have injection. Got: {value[:200]}" return @@ -466,7 +466,7 @@ class TestALPathNormalization: hover = language_server.request_hover(file_path, line, char) assert hover is not None, "Hover should return a result" - value = hover.get("contents", {}).get("value", "") + value = hover.get("contents", {}).get("value", "") # type: ignore assert '**Table 50000 "TEST Customer"**' in value, f"Hover should have injection. Got: {value[:200]}" return @@ -491,7 +491,7 @@ class TestALPathNormalization: # Request hover with forward slash path (different format) hover = language_server.request_hover(file_path_forward, line, char) assert hover is not None, "Hover should return a result" - value = hover.get("contents", {}).get("value", "") + value = hover.get("contents", {}).get("value", "") # type: ignore assert '**Table 50000 "TEST Customer"**' in value, ( f"Hover injection should work with mixed path formats. Got: {value[:200]}" ) @@ -518,7 +518,7 @@ class TestALPathNormalization: # Request hover with backslash path (different format) hover = language_server.request_hover(file_path_backslash, line, char) assert hover is not None, "Hover should return a result" - value = hover.get("contents", {}).get("value", "") + value = hover.get("contents", {}).get("value", "") # type: ignore assert '**Table 50000 "TEST Customer"**' in value, ( f"Hover injection should work with mixed path formats. Got: {value[:200]}" ) @@ -553,7 +553,7 @@ class TestALPathNormalization: # Request hover with different path format hover = language_server.request_hover(hover_path, line, char) assert hover is not None, f"Hover should return a result for {symbol_name}" - value = hover.get("contents", {}).get("value", "") + value = hover.get("contents", {}).get("value", "") # type: ignore assert f"**{expected_injection}**" in value, ( f"Hover for {symbol_name} should have injection with mixed paths. Got: {value[:200]}" ) diff --git a/test/solidlsp/angular/test_angular_basic.py b/test/solidlsp/angular/test_angular_basic.py index 8b377d5c..c1d6bbd3 100644 --- a/test/solidlsp/angular/test_angular_basic.py +++ b/test/solidlsp/angular/test_angular_basic.py @@ -125,6 +125,7 @@ class TestAngularLanguageServerBasics: # +1 puts the cursor inside the identifier rather than on its leading boundary. refs = language_server.request_references(src_path, coords.line, coords.col + 1) ref_paths = {r.get("relativePath", "") for r in refs} + ref_paths = {p for p in ref_paths if p} # filter out any empty relativePath entries assert any(p.endswith("app.component.html") for p in ref_paths), ( f"Expected references for setName to include its template callsite in app.component.html, got: {ref_paths}" ) diff --git a/test/solidlsp/angular/test_angular_error_cases.py b/test/solidlsp/angular/test_angular_error_cases.py index 6ce05e8c..53a1a30b 100644 --- a/test/solidlsp/angular/test_angular_error_cases.py +++ b/test/solidlsp/angular/test_angular_error_cases.py @@ -21,6 +21,7 @@ import time import pytest from solidlsp import SolidLanguageServer +from solidlsp.language_servers.angular_language_server import AngularLanguageServer from solidlsp.ls_config import LanguageServerId from solidlsp.ls_exceptions import SolidLSPException from test.conftest import _create_ls @@ -344,6 +345,7 @@ class TestAngularStartupCleanup: with pytest.raises(RuntimeError, match="simulated ngserver init"): ls.start() + assert isinstance(ls, AngularLanguageServer) assert ls._ts_server is None, "TS companion was not cleared after startup failure" assert ls._html_server is None, "HTML companion was not cleared after startup failure" diff --git a/test/solidlsp/clojure/test_clojure_indexing.py b/test/solidlsp/clojure/test_clojure_indexing.py index da5121b0..e1048e79 100644 --- a/test/solidlsp/clojure/test_clojure_indexing.py +++ b/test/solidlsp/clojure/test_clojure_indexing.py @@ -54,7 +54,7 @@ class TestClojureProjectIndexing: # extra.clj contains two real call sites (in double-product and triple-product); # they must be returned regardless of whether the file was opened beforehand - extra_refs = [r for r in refs if r.get("relativePath", "").endswith("extra.clj")] + extra_refs = [r for r in refs if r.get("relativePath", "").endswith("extra.clj")] # type: ignore assert extra_refs, ( "Expected references to 'multiply' to include call sites from extra.clj, " f"but got files: {sorted(ref_paths)}. " @@ -82,7 +82,9 @@ class TestClojureProjectIndexing: ref_paths = {r.get("relativePath", "") for r in refs} consumer_refs = [ - r for r in refs if r.get("relativePath", "").replace("\\", "/").endswith("sub_module/src/sub_module_app/consumer.clj") + r + for r in refs + if r.get("relativePath", "").replace("\\", "/").endswith("sub_module/src/sub_module_app/consumer.clj") # type: ignore ] assert consumer_refs, ( "Expected references to 'multiply' to include call sites from the sibling module " diff --git a/test/solidlsp/crystal/test_crystal_basic.py b/test/solidlsp/crystal/test_crystal_basic.py index ae5d618d..b1a1fd83 100644 --- a/test/solidlsp/crystal/test_crystal_basic.py +++ b/test/solidlsp/crystal/test_crystal_basic.py @@ -14,6 +14,7 @@ import os import pytest from solidlsp import SolidLanguageServer +from solidlsp.language_servers.crystal_language_server import CrystalLanguageServer from solidlsp.ls_config import LanguageServerId from test.conftest import language_server_tests_enabled from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols @@ -94,7 +95,8 @@ class TestCrystalDefinition: file_path = os.path.join("src", "main.cr") # wait for Crystalline to compile the project - language_server.language_server._wait_for_compilation() + assert isinstance(language_server, CrystalLanguageServer) + language_server._wait_for_compilation() # Calculator.new on line 35 (0-indexed: 34), col 13 -> Calculator class on line 3 (0-indexed: 2) definitions = language_server.request_definition(file_path, 34, 13) diff --git a/test/solidlsp/dart/test_dart_basic.py b/test/solidlsp/dart/test_dart_basic.py index 96f93b7e..598fd879 100644 --- a/test/solidlsp/dart/test_dart_basic.py +++ b/test/solidlsp/dart/test_dart_basic.py @@ -249,6 +249,7 @@ class TestDartLanguageServer: # Find coordinates of 'final result = a + b;' - test position on 'result' with language_server.open_file(file_path, open_in_ls=False) as f: pos = find_text_coordinates(f.contents, r"final (result) = a \+ b;") + assert pos is not None defining_symbol = language_server.request_defining_symbol(file_path, pos.line, pos.col) diff --git a/test/solidlsp/erlang/test_erlang_ignored_dirs.py b/test/solidlsp/erlang/test_erlang_ignored_dirs.py index 38013a6d..5aaa6310 100644 --- a/test/solidlsp/erlang/test_erlang_ignored_dirs.py +++ b/test/solidlsp/erlang/test_erlang_ignored_dirs.py @@ -4,6 +4,7 @@ from pathlib import Path import pytest from solidlsp import SolidLanguageServer +from solidlsp.language_servers.erlang_language_server import ErlangLanguageServer from solidlsp.ls_config import LanguageServerId from test.conftest import language_server_tests_enabled, start_ls_context @@ -146,6 +147,8 @@ def test_symbol_tree_excludes_build_dirs(language_server: SolidLanguageServer): @pytest.mark.parametrize("language_server", [LanguageServerId.ERLANG], indirect=True) def test_ignore_compiled_files(language_server: SolidLanguageServer): """Test that compiled Erlang files are ignored.""" + assert isinstance(language_server, ErlangLanguageServer) + # Test that beam files are ignored assert language_server.is_ignored_filename("module.beam"), "BEAM files should be ignored" assert language_server.is_ignored_filename("app.beam"), "BEAM files should be ignored" @@ -164,6 +167,7 @@ def test_rebar_directories_ignored(language_server: SolidLanguageServer): assert language_server.is_ignored_dirname(".rebar3"), "rebar3 cache should be ignored" # Test that rebar.lock and rebar.config are not ignored (they are configuration files) + assert isinstance(language_server, ErlangLanguageServer) assert not language_server.is_ignored_filename("rebar.config"), "rebar.config should not be ignored" assert not language_server.is_ignored_filename("rebar.lock"), "rebar.lock should not be ignored" diff --git a/test/solidlsp/julia/test_fatou.py b/test/solidlsp/julia/test_fatou.py index d6f7a6d5..daa6bdcc 100644 --- a/test/solidlsp/julia/test_fatou.py +++ b/test/solidlsp/julia/test_fatou.py @@ -25,7 +25,7 @@ class TestFatouLanguageServer: def test_cross_file_references(self, language_server: SolidLanguageServer) -> None: references = language_server.request_references("src/fatou_a.jl", line=0, column=2) - locations = {(reference["relativePath"].replace("\\", "/"), reference["range"]["start"]["line"]) for reference in references} + locations = {(reference["relativePath"].replace("\\", "/"), reference["range"]["start"]["line"]) for reference in references} # type: ignore assert locations >= {("src/fatou_a.jl", 1), ("src/fatou_b.jl", 0)} def test_file_matching(self) -> None: diff --git a/test/solidlsp/pascal/test_pascal_basic.py b/test/solidlsp/pascal/test_pascal_basic.py index 74576459..bfa14728 100644 --- a/test/solidlsp/pascal/test_pascal_basic.py +++ b/test/solidlsp/pascal/test_pascal_basic.py @@ -192,7 +192,7 @@ class TestPascalLanguageServerBasics: contents = hover.get("contents", {}) value = contents.get("value", "") if isinstance(contents, dict) else str(contents) else: - value = hover.contents.value if hasattr(hover.contents, "value") else str(hover.contents) + value = hover.contents.value if hasattr(hover.contents, "value") else str(hover.contents) # type: ignore # Should contain the function signature assert "CalculateSum" in value, f"Hover should show function name. Got: {value[:500]}" diff --git a/test/solidlsp/python/test_symbol_retrieval.py b/test/solidlsp/python/test_symbol_retrieval.py index 38185f31..45460d73 100644 --- a/test/solidlsp/python/test_symbol_retrieval.py +++ b/test/solidlsp/python/test_symbol_retrieval.py @@ -46,6 +46,7 @@ class TestLanguageServerSymbols: with language_server.open_file(file_path, open_in_ls=False) as f: file_content = f.contents coords = find_text_coordinates(file_content, r"(status): str") + assert coords is not None ref_symbols = [ref.symbol for ref in language_server.request_referencing_symbols(file_path, coords.line, coords.col)] assert len(ref_symbols) > 0 diff --git a/test/solidlsp/ruby/test_ruby_symbol_retrieval.py b/test/solidlsp/ruby/test_ruby_symbol_retrieval.py index 67a29eb2..1f4e9aa0 100644 --- a/test/solidlsp/ruby/test_ruby_symbol_retrieval.py +++ b/test/solidlsp/ruby/test_ruby_symbol_retrieval.py @@ -590,7 +590,9 @@ class TestRubyLanguageServerSymbols: pos = find_text_coordinates(fb.contents, r"user = @service\.(create_user)") # Verify that we can find the method definition + assert pos is not None defining_symbol = language_server.request_defining_symbol(file_path, pos.line, pos.col) + assert defining_symbol is not None assert "name" in defining_symbol assert "kind" in defining_symbol assert defining_symbol.get("name") == "create_user" diff --git a/test/solidlsp/rust/test_rust_basic.py b/test/solidlsp/rust/test_rust_basic.py index 446cdfbe..ac7c8d06 100644 --- a/test/solidlsp/rust/test_rust_basic.py +++ b/test/solidlsp/rust/test_rust_basic.py @@ -68,7 +68,7 @@ class TestRustLanguageServer: 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), ( + assert any("src/lib.rs" in implementation.get("relativePath", "").replace("\\", "/") for implementation in implementations), ( # type: ignore f"Expected ConsoleGreeter.format_greeting in implementations, got: {implementations}" ) @@ -81,7 +81,7 @@ class TestRustLanguageServer: 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("\\", "/") + symbol.get("name") == "format_greeting" and "src/lib.rs" in symbol["location"].get("relativePath", "").replace("\\", "/") # type: ignore for symbol in implementing_symbols ), f"Expected ConsoleGreeter.format_greeting symbol, got: {implementing_symbols}" diff --git a/test/solidlsp/scss/test_scss_basic.py b/test/solidlsp/scss/test_scss_basic.py index 7266f85d..9ae51a34 100644 --- a/test/solidlsp/scss/test_scss_basic.py +++ b/test/solidlsp/scss/test_scss_basic.py @@ -130,6 +130,7 @@ class TestScssReferences: line, col = coords.line, coords.col refs = language_server.request_references(path, line, col + 2) ref_paths = {r.get("relativePath", "") for r in refs} + ref_paths = {r for r in ref_paths if r} # filter out empty strings assert any(p.endswith("buttons.scss") for p in ref_paths), ( f"Expected card-surface references to include buttons.scss, got: {ref_paths}" ) @@ -148,6 +149,7 @@ class TestScssReferences: line, col = coords.line, coords.col refs = language_server.request_references(path, line, col + 2) ref_paths = {r.get("relativePath", "") for r in refs} + ref_paths = {r for r in ref_paths if r} # filter out empty strings assert any(p.endswith("buttons.scss") for p in ref_paths), ( f"Expected $color-primary references to include buttons.scss, got: {ref_paths}" ) diff --git a/test/solidlsp/svelte/test_svelte_basic.py b/test/solidlsp/svelte/test_svelte_basic.py index 3757ff83..e799c139 100644 --- a/test/solidlsp/svelte/test_svelte_basic.py +++ b/test/solidlsp/svelte/test_svelte_basic.py @@ -20,7 +20,7 @@ class TestSvelteLanguageServer: def test_svelte_language_server_root_matches_repo_path(self, language_server: SolidLanguageServer, repo_path: Path) -> None: assert language_server.is_running() assert repo_path.resolve() == svelte_test_conftest.repo_path.resolve() - assert Path(language_server.language_server.repo_path).resolve() == repo_path.resolve() + assert Path(language_server.repository_root_path).resolve() == repo_path.resolve() @pytest.mark.parametrize("language_server", [LanguageServerId.SVELTE], indirect=True) def test_svelte_and_typescript_files_in_symbol_tree(self, language_server: SolidLanguageServer) -> None: @@ -74,12 +74,13 @@ class TestSvelteLanguageServer: def test_definition_from_component_import_to_svelte_file(self, language_server: SolidLanguageServer) -> None: file_path = os.path.join("src", "lib", "components", "Header.svelte") coords = find_text_coordinates(read_repo_file(language_server, file_path), r"(count)") + assert coords is not None definitions = language_server.request_definition(file_path, coords.line, coords.col) - definition_paths = sorted(definition["relativePath"].replace("\\", "/") for definition in definitions) + definition_paths = sorted(definition["relativePath"].replace("\\", "/") for definition in definitions) # type: ignore assert len(definitions) == 1, definition_paths - assert definitions[0]["relativePath"].replace("\\", "/") == "src/lib/components/Counter.svelte", definition_paths + assert definitions[0]["relativePath"].replace("\\", "/") == "src/lib/components/Counter.svelte", definition_paths # type: ignore @pytest.mark.parametrize("language_server", [LanguageServerId.SVELTE], indirect=True) def test_diagnostics_in_typescript_file(self, language_server: SolidLanguageServer) -> None: diff --git a/test/solidlsp/svelte/test_svelte_references.py b/test/solidlsp/svelte/test_svelte_references.py index 2fc7bdbf..c7777799 100644 --- a/test/solidlsp/svelte/test_svelte_references.py +++ b/test/solidlsp/svelte/test_svelte_references.py @@ -12,7 +12,7 @@ class TestSvelteReferences: @pytest.mark.parametrize("language_server", [LanguageServerId.SVELTE], indirect=True) def test_references_across_svelte_and_typescript(self, language_server: SolidLanguageServer) -> None: refs = language_server.request_references(os.path.join("src", "lib", "components", "Words.svelte"), 1, 17) - ref_paths = {ref["relativePath"].replace("\\", "/") for ref in refs} + ref_paths = {ref["relativePath"].replace("\\", "/") for ref in refs} # type: ignore assert "src/routes/(sverdle)/words.server.ts" in ref_paths, sorted(ref_paths) assert "src/lib/game.ts" in ref_paths, sorted(ref_paths) @@ -21,6 +21,6 @@ class TestSvelteReferences: @pytest.mark.parametrize("language_server", [LanguageServerId.SVELTE], indirect=True) def test_references_from_typescript_file(self, language_server: SolidLanguageServer) -> None: refs = language_server.request_references(os.path.join("src", "lib", "game.ts"), 3, 13) - ref_paths = {ref["relativePath"].replace("\\", "/") for ref in refs} + ref_paths = {ref["relativePath"].replace("\\", "/") for ref in refs} # type: ignore assert "src/routes/(sverdle)/+page.server.ts" in ref_paths, sorted(ref_paths) diff --git a/test/solidlsp/svelte/test_svelte_rename.py b/test/solidlsp/svelte/test_svelte_rename.py index ec550b82..c40e5d78 100644 --- a/test/solidlsp/svelte/test_svelte_rename.py +++ b/test/solidlsp/svelte/test_svelte_rename.py @@ -56,6 +56,7 @@ class TestSvelteRename: def test_rename_svelte_export_updates_svelte_importers(self, language_server: SolidLanguageServer) -> None: file_path = os.path.join("src", "lib", "components", "Counter.svelte") coords = find_text_coordinates(read_repo_file(language_server, file_path), r"(count)") + assert coords is not None workspace_edit = language_server.request_rename_symbol_edit(file_path, coords.line, coords.col, "score") @@ -69,6 +70,7 @@ class TestSvelteRename: def test_rename_svelte_export_updates_ts_and_svelte_files(self, language_server: SolidLanguageServer) -> None: file_path = os.path.join("src", "lib", "components", "Words.svelte") coords = find_text_coordinates(read_repo_file(language_server, file_path), r"(words)") + assert coords is not None workspace_edit = language_server.request_rename_symbol_edit(file_path, coords.line, coords.col, "vocabulary") diff --git a/test/solidlsp/test_ls_start_cleanup.py b/test/solidlsp/test_ls_start_cleanup.py index 7e574311..5246ff43 100644 --- a/test/solidlsp/test_ls_start_cleanup.py +++ b/test/solidlsp/test_ls_start_cleanup.py @@ -38,7 +38,7 @@ def test_start_stops_process_when_start_server_raises_after_spawning(): with pytest.raises(RuntimeError, match="capability assertion"): server.start() - server.server.stop.assert_called_once() + server.server.stop.assert_called_once() # type: ignore assert server.server_started is False @@ -51,5 +51,5 @@ def test_start_does_not_call_stop_when_start_server_raises_before_spawning(): with pytest.raises(RuntimeError, match="capability assertion"): server.start() - server.server.stop.assert_not_called() + server.server.stop.assert_not_called() # type: ignore assert server.server_started is False diff --git a/test/solidlsp/test_pdeathsig.py b/test/solidlsp/test_pdeathsig.py index 662143c9..9be4be2a 100644 --- a/test/solidlsp/test_pdeathsig.py +++ b/test/solidlsp/test_pdeathsig.py @@ -115,8 +115,8 @@ def test_language_server_process_survives_a_short_lived_calling_thread() -> None text=True, ) try: - ready_line = driver.stdout.readline() - assert ready_line.strip() == "READY", f"driver failed to start the language server: {driver.stderr.read()}" + ready_line = driver.stdout.readline() # type: ignore + assert ready_line.strip() == "READY", f"driver failed to start the language server: {driver.stderr.read()}" # type: ignore time.sleep(2) # The driver's own argv also contains `marker` (it's passed as sys.argv[1]), so exclude @@ -143,8 +143,10 @@ def test_language_server_process_dies_with_a_sigkilled_serena() -> None: text=True, ) try: - ready_line = driver.stdout.readline() - assert ready_line.strip() == "READY", f"driver failed to start the language server: {driver.stderr.read()}" + ready_line = driver.stdout.readline() # type: ignore + stderr = driver.stderr + assert stderr is not None, "stderr should be captured" + assert ready_line.strip() == "READY", f"driver failed to start the language server: {stderr.read()}" assert _find_marked_processes(marker), "language server process never started" driver.kill() # SIGKILL: simulates Serena being killed without a chance to clean up diff --git a/test/solidlsp/test_process_group_cleanup.py b/test/solidlsp/test_process_group_cleanup.py index 9bd4098c..473a803b 100644 --- a/test/solidlsp/test_process_group_cleanup.py +++ b/test/solidlsp/test_process_group_cleanup.py @@ -80,7 +80,9 @@ def _spawn_ready(src: str) -> subprocess.Popen: test_pdeathsig.py's driver pattern (deterministic sync instead of a blind sleep). """ proc = subprocess.Popen([sys.executable, "-c", src], start_new_session=True, stdout=subprocess.PIPE, text=True) - ready_line = proc.stdout.readline() + stdout = proc.stdout + assert stdout is not None + ready_line = stdout.readline() assert ready_line.strip() == "READY", f"helper process failed to start: {ready_line!r}" return proc @@ -341,8 +343,10 @@ class TestPsutilDenialConsequences: """ ) proc = subprocess.Popen([sys.executable, "-c", src], start_new_session=True, stdout=subprocess.PIPE, text=True) - child_pid = int(proc.stdout.readline().strip()) - ready_line = proc.stdout.readline() + stdout = proc.stdout + assert stdout is not None + child_pid = int(stdout.readline().strip()) + ready_line = stdout.readline() assert ready_line.strip() == "READY", f"helper process failed to start: {ready_line!r}" return proc, child_pid From 3b898b671cd612113895f0731a1d544f0f61668d Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Thu, 17 Sep 2026 15:40:34 +0200 Subject: [PATCH 2/2] Reify language backends, introducing LanguageBackendRegistry * Many case differentiations in the agent code were replaced by method calls in the newly introduced LanguageBackend abstraction * The registry allows new backends to be added dynamically (via Python packages that implement a specific entrypoint) --- docs/autogen_docs.py | 6 +- pyproject.toml | 2 +- scripts/demo_diagnostics.py | 5 +- scripts/demo_find_defining_symbol.py | 5 +- scripts/demo_find_implementing_symbol.py | 5 +- scripts/demo_progressive_tool_shortening.py | 11 +- scripts/demo_run_tools.py | 5 +- src/serena/agent.py | 51 +--- src/serena/cli.py | 14 +- src/serena/code_editor.py | 3 +- src/serena/config/serena_config.py | 93 ++----- src/serena/dashboard.py | 2 +- src/serena/jetbrains/jetbrains_backend.py | 105 ++++++++ src/serena/jetbrains/jetbrains_types.py | 8 - src/serena/language_backend.py | 251 ++++++++++++++++++ src/serena/lsp/lsp_backend.py | 80 ++++++ src/serena/lsp/lsp_diagnostics.py | 2 +- src/serena/mcp.py | 3 +- src/serena/project.py | 32 +-- src/serena/project_server.py | 5 +- src/serena/repl/facade.py | 10 +- src/serena/tools/query_project_tools.py | 18 +- src/serena/tools/tools_base.py | 20 +- src/serena/util/file_proxy.py | 35 +-- test/serena/config/test_serena_config.py | 46 ++-- test/serena/test_code_editor_atomic_writes.py | 8 + test/serena/test_repl_tool.py | 8 +- 27 files changed, 571 insertions(+), 262 deletions(-) create mode 100644 src/serena/jetbrains/jetbrains_backend.py create mode 100644 src/serena/language_backend.py create mode 100644 src/serena/lsp/lsp_backend.py diff --git a/docs/autogen_docs.py b/docs/autogen_docs.py index 20b03e39..3d613d4a 100644 --- a/docs/autogen_docs.py +++ b/docs/autogen_docs.py @@ -192,7 +192,7 @@ def autogen_tool_list(target_filename = "01-about/035_tools.md"): def facades_section(): from serena.repl.facade import ApiScope from serena.agent import SerenaAgent, SerenaConfig - from serena.config.serena_config import LanguageBackend + from serena.language_backend import BuiltinLanguageBackend f.write("\n\n## Serena's REPL (Code Execution-Based Interface)\n\n") f.write( @@ -236,8 +236,8 @@ def autogen_tool_list(target_filename = "01-about/035_tools.md"): api_scope = ApiScope() agent = SerenaAgent(serena_config=SerenaConfig().with_headless_mode_overrides()) facades = [] - for backend in LanguageBackend: - facades.extend(backend.create_facades(agent, api_scope)) + for backend in BuiltinLanguageBackend: + facades.extend(backend.get_instance().create_facades(agent, api_scope)) facades.extend(agent.create_default_facade_list(api_scope)) text = TextBuilder() diff --git a/pyproject.toml b/pyproject.toml index 1a4b21eb..b2f6d25d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -107,7 +107,7 @@ packages = ["src/serena", "src/interprompt", "src/solidlsp"] max-line-length = 1000 [tool.ty.environment] -python-version = "3.11" +python-version = "3.11" # We configure the oldest Python version supported by Serena # Analyze for all platforms rather than defaulting to the OS ty happens to run on. This keeps the # check deterministic across the CI matrix (Linux/Windows/macOS) and lets platform-conditional stdlib # members (e.g. subprocess.CREATE_NO_WINDOW, ctypes.windll, pwd) resolve without per-OS type-ignores. diff --git a/scripts/demo_diagnostics.py b/scripts/demo_diagnostics.py index 02d12929..2ead12a4 100644 --- a/scripts/demo_diagnostics.py +++ b/scripts/demo_diagnostics.py @@ -14,8 +14,9 @@ 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.config.serena_config import ProjectConfig, RegisteredProject, SerenaConfig from serena.constants import REPO_ROOT +from serena.language_backend import BuiltinLanguageBackend from serena.project import Project from serena.tools import ( CreateTextFileTool, @@ -35,7 +36,7 @@ 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 + serena_config.set_builtin_language_backend(BuiltinLanguageBackend.LSP) project = Project( project_root=str(REPO_PATH), diff --git a/scripts/demo_find_defining_symbol.py b/scripts/demo_find_defining_symbol.py index 001f001d..92107fe7 100644 --- a/scripts/demo_find_defining_symbol.py +++ b/scripts/demo_find_defining_symbol.py @@ -9,8 +9,9 @@ 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.config.serena_config import ProjectConfig, RegisteredProject, SerenaConfig from serena.constants import REPO_ROOT +from serena.language_backend import BuiltinLanguageBackend from serena.project import Project from serena.tools import FindDeclarationTool from solidlsp.ls_config import LanguageServerId @@ -24,7 +25,7 @@ def make_agent(project_root: Path, language: LanguageServerId, project_name: str """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 + serena_config.set_builtin_language_backend(BuiltinLanguageBackend.LSP) project = Project( project_root=str(project_root), diff --git a/scripts/demo_find_implementing_symbol.py b/scripts/demo_find_implementing_symbol.py index 30402f25..87ea6c62 100644 --- a/scripts/demo_find_implementing_symbol.py +++ b/scripts/demo_find_implementing_symbol.py @@ -8,8 +8,9 @@ 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.config.serena_config import ProjectConfig, RegisteredProject, SerenaConfig from serena.constants import REPO_ROOT +from serena.language_backend import BuiltinLanguageBackend from serena.project import Project from serena.tools import FindImplementationsTool from solidlsp.ls_config import LanguageServerId @@ -22,7 +23,7 @@ def make_agent(project_root: Path, language: LanguageServerId, project_name: str """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 + serena_config.set_builtin_language_backend(BuiltinLanguageBackend.LSP) project = Project( project_root=str(project_root), diff --git a/scripts/demo_progressive_tool_shortening.py b/scripts/demo_progressive_tool_shortening.py index 9af742ef..0e06f151 100644 --- a/scripts/demo_progressive_tool_shortening.py +++ b/scripts/demo_progressive_tool_shortening.py @@ -11,8 +11,9 @@ import json from pprint import pprint from serena.agent import SerenaAgent -from serena.config.serena_config import LanguageBackend, SerenaConfig +from serena.config.serena_config import SerenaConfig from serena.constants import REPO_ROOT +from serena.language_backend import BuiltinLanguageBackend from serena.tools import ( FindReferencingSymbolsTool, FindSymbolTool, @@ -165,16 +166,16 @@ def run_jb_tools(agent: SerenaAgent) -> None: ) -def make_agent(backend: LanguageBackend) -> SerenaAgent: +def make_agent(backend: BuiltinLanguageBackend) -> SerenaAgent: config = SerenaConfig.from_config_file() config.web_dashboard = False - config.language_backend = backend + config.set_builtin_language_backend(backend) return SerenaAgent(project=REPO_ROOT, serena_config=config) if __name__ == "__main__": # LSP backend - lsp_agent = make_agent(LanguageBackend.LSP) + lsp_agent = make_agent(BuiltinLanguageBackend.LSP) try: run_lsp_tools(lsp_agent) run_backend_independent_tools(lsp_agent) @@ -183,7 +184,7 @@ if __name__ == "__main__": # JetBrains backend (requires a running IDE) try: - jb_agent = make_agent(LanguageBackend.JETBRAINS) + jb_agent = make_agent(BuiltinLanguageBackend.JETBRAINS) try: run_jb_tools(jb_agent) finally: diff --git a/scripts/demo_run_tools.py b/scripts/demo_run_tools.py index d6c61f0e..2dc6efbe 100644 --- a/scripts/demo_run_tools.py +++ b/scripts/demo_run_tools.py @@ -9,8 +9,9 @@ from pathlib import Path from pprint import pprint from serena.agent import SerenaAgent -from serena.config.serena_config import LanguageBackend, SerenaConfig +from serena.config.serena_config import SerenaConfig from serena.constants import REPO_ROOT +from serena.language_backend import BuiltinLanguageBackend from serena.tools import ( FindFileTool, FindReferencingSymbolsTool, @@ -26,7 +27,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 + serena_config.set_builtin_language_backend(BuiltinLanguageBackend.LSP) # project = Path(REPO_ROOT).parent / "serena-jetbrains-plugin-copy" project = Path(REPO_ROOT) agent = SerenaAgent(project=str(project), serena_config=serena_config) diff --git a/src/serena/agent.py b/src/serena/agent.py index 943f4543..af29bd93 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -32,7 +32,6 @@ from serena.analytics import RegisteredTokenCountEstimator, ToolUsageStats from serena.config.context_mode import SerenaAgentContext, SerenaAgentMode from serena.config.serena_config import ( AgentInterface, - LanguageBackend, ModeSelectionDefinition, ModeSelectionDefinitionWithAddedModes, ModeSelectionDefinitionWithBaseModes, @@ -44,7 +43,7 @@ from serena.config.serena_config import ( ToolInclusionDefinition, ) from serena.dashboard import SerenaDashboardAPI, SerenaDashboardTrayManager, SerenaDashboardViewer, open_url_in_browser -from serena.jetbrains import launch_coordinator as jetbrains_launch_coordinator +from serena.language_backend import BuiltinLanguageBackend, LanguageBackend from serena.ls_manager import LanguageServerManager from serena.memories.memory_manager import MemoryManager from serena.project import Project @@ -756,7 +755,7 @@ class SerenaAgent: "os": platform.system(), "dashboard": int(self.serena_config.web_dashboard), "version": self.version, - "backend": self._language_backend.value, + "backend": self._language_backend.get_key(), "context": self._context.name, } try: @@ -1226,9 +1225,7 @@ class SerenaAgent: msg = f"Created and activated a new project with name '{proj.project_name}' at {proj.project_root}.\n" else: msg = f"The project with name '{proj.project_name}' at {proj.project_root} is activated.\n" - if self._language_backend == LanguageBackend.LSP: - language_servers_str = ", ".join([ls.get_key() for ls in proj.project_config.language_servers]) - msg += f"Active language servers: {language_servers_str}.\n" + msg += self._language_backend.get_project_activation_statement(proj) msg += f"File encoding: {proj.project_config.encoding}.\n" # add list of memories (if memories are enabled) @@ -1405,7 +1402,7 @@ class SerenaAgent: """ :return: whether this agent uses language server-based code analysis """ - return self._language_backend == LanguageBackend.LSP + return self._language_backend == BuiltinLanguageBackend.LSP def _activate_project(self, project: Project, update_active_modes: bool = True, update_active_tools: bool = True) -> bool: """ @@ -1428,14 +1425,12 @@ class SerenaAgent: if project_backend is not None and project_backend != self._language_backend: if self._agent_interface.is_tools(): raise ValueError( - f"Cannot activate project '{project.project_name}': it requires the {project_backend.value} backend, " - f"but this session was initialized with {self._language_backend.value}. " + f"Cannot activate project '{project.project_name}': it requires the {project_backend} backend, " + f"but this session was initialized with {self._language_backend}. " f"Workarounds: (1) Use project activation at startup via the --project flag, " f"(2) Configure one MCP server per backend in your client, (3) use the REPL interface." ) - log.info( - f"Switching language backend from {self._language_backend.value} to {project_backend.value} for project '{project.project_name}'" - ) + log.info(f"Switching language backend from {self._language_backend} to {project_backend} for project '{project.project_name}'") self._language_backend = project_backend # shut down the previously active project to release its language server processes @@ -1460,7 +1455,7 @@ class SerenaAgent: def init_project_services() -> None: self._run_project_activation_command(project) - self._init_active_project_language_backend() + self._language_backend.init_active_project(self) # initialise the project's language backend in the background self.issue_task(init_project_services) @@ -1516,28 +1511,6 @@ class SerenaAgent: except Exception: log.exception(f"Unexpected error running activation_command for project '{project.project_name}'") - def _init_active_project_language_backend(self) -> None: - """ - Initialises the active project's language backend - """ - project = self._active_project - assert project is not None - - # for LSP mode, start the language server manager - if self.get_language_backend().is_lsp(): - with LogTime("Language server initialization", logger=log): - self.reset_language_server_manager() - - # for JetBrains mode, search for plugin server and spawn IDE (if not found and launch command provided) - elif self.get_language_backend().is_jetbrains(): - client = jetbrains_launch_coordinator.find_plugin_server(project) - if client is not None: - log.info("Found Serena JetBrains Plugin server: %s", client) - else: - log.info("Serena JetBrains Plugin server not found for project %s", project.project_name) - if self.serena_config.jetbrains_launch_command: - jetbrains_launch_coordinator.launch_and_wait_for_plugin_server(project, self.serena_config.jetbrains_launch_command) - def activate_project_from_path_or_name( self, project_root_or_name: str, update_active_modes: bool = True, update_active_tools: bool = True ) -> bool: @@ -1604,12 +1577,12 @@ class SerenaAgent: else: result_str += "No active project\n" result_str += f"Agent interface: {self._agent_interface.value}\n" - result_str += f"Language backend: {self._language_backend.value}" + result_str += f"Language backend: {self._language_backend.get_key()}" if self._active_project and self._active_project.project_config.language_backend is not None: result_str += " (project override)" - result_str += f" (global default: {self.serena_config.language_backend.value})\n" - if self._language_backend.is_lsp() and self._active_project: - result_str += f"Language server status: {self._active_project.get_language_server_manager_status()}\n" + result_str += f" (global default: {self.serena_config.language_backend.get_key()})\n" + if self._active_project: + result_str += self._language_backend.get_config_overview_statement(self._active_project) result_str += "Available projects:\n" + "\n".join(list(self.serena_config.project_names)) + "\n" result_str += f"Active context: {self._context.name}\n" diff --git a/src/serena/cli.py b/src/serena/cli.py index e256adbe..29d87331 100644 --- a/src/serena/cli.py +++ b/src/serena/cli.py @@ -24,7 +24,6 @@ from serena.config.client_setup import client_setup_handlers from serena.config.context_mode import SerenaAgentContext, SerenaAgentMode from serena.config.serena_config import ( AgentInterface, - LanguageBackend, ModeSelectionDefinition, ModeSelectionDefinitionWithAddedModes, ProjectConfig, @@ -39,6 +38,7 @@ from serena.constants import ( SERENAS_OWN_CONTEXT_YAMLS_DIR, SERENAS_OWN_MODE_YAMLS_DIR, ) +from serena.language_backend import BuiltinLanguageBackend, LanguageBackendRegistry from serena.prompt_factory import SerenaPromptFactory from serena.tools import ActivateProjectTool from serena.util.cli_util import AutoRegisteringGroup @@ -181,14 +181,14 @@ class TopLevelCommands(AutoRegisteringGroup): @click.option( "--language-backend", "-b", - type=click.Choice([b.value for b in LanguageBackend]), - default=LanguageBackend.LSP.value, + type=click.Choice([b.value for b in BuiltinLanguageBackend]), + default=BuiltinLanguageBackend.LSP.value, show_default=True, help="Default code intelligence backend (can be overridden in the project config).", ) def init(language_backend: Literal["LSP", "JetBrains"] = "LSP") -> None: click.echo(f"\nSerena version: {serena_version()}\n") - serena_config = SerenaConfig.init(language_backend=LanguageBackend(language_backend)) + serena_config = SerenaConfig.init(builtin_language_backend=BuiltinLanguageBackend(language_backend)) click.echo(f"Configuration file: {serena_config.config_file_path}") click.echo(f"Language backend: {language_backend}") @@ -260,7 +260,7 @@ class TopLevelCommands(AutoRegisteringGroup): ) @click.option( "--language-backend", - type=click.Choice([lb.value for lb in LanguageBackend]), + type=click.Choice(LanguageBackendRegistry.get_instance().get_keys()), default=None, help="Override the configured language backend.", ) @@ -393,7 +393,7 @@ class TopLevelCommands(AutoRegisteringGroup): host=host, port=port, mode_selection_def=mode_selection_def, - language_backend=LanguageBackend.from_str(language_backend) if language_backend else None, + language_backend=LanguageBackendRegistry.get_instance().resolve(language_backend) if language_backend else None, agent_interface=AgentInterface.from_str(agent_interface) if agent_interface else None, enable_web_dashboard=enable_web_dashboard, open_web_dashboard=open_web_dashboard, @@ -968,7 +968,7 @@ class ProjectCommands(AutoRegisteringGroup): logging.configure(level=logging.INFO) project_path = os.path.abspath(project) serena_config = SerenaConfig.from_config_file().with_headless_mode_overrides() - serena_config.language_backend = LanguageBackend.LSP + serena_config.set_builtin_language_backend(BuiltinLanguageBackend.LSP) proj = Project.load(project_path, serena_config=serena_config) # Create log file with timestamp diff --git a/src/serena/code_editor.py b/src/serena/code_editor.py index 59b70b5b..85aa8b96 100644 --- a/src/serena/code_editor.py +++ b/src/serena/code_editor.py @@ -25,6 +25,7 @@ TSymbol = TypeVar("TSymbol", bound=Symbol) class CodeEditor(Generic[TSymbol], ABC): def __init__(self, project: Project) -> None: + self.project = project self.project_root = project.project_root self.encoding = project.project_config.encoding self.newline = project.line_ending.newline_str @@ -83,7 +84,7 @@ class CodeEditor(Generic[TSymbol], ABC): """ Context manager for editing a file. """ - if FileProxy.is_external_path(relative_path): + if FileProxy.is_external_path(relative_path, self.project): raise ValueError(f"Cannot edit external file: {relative_path}") with self._open_file_context(relative_path) as edited_file: yield edited_file diff --git a/src/serena/config/serena_config.py b/src/serena/config/serena_config.py index 86c8ffed..a73c3d49 100644 --- a/src/serena/config/serena_config.py +++ b/src/serena/config/serena_config.py @@ -41,15 +41,13 @@ from serena.util.yaml import YamlCommentNormalisation, load_yaml, normalise_yaml from solidlsp.ls_config import LanguageServerId, LanguageServerIdLike, LanguageServerRegistry from ..analytics import RegisteredTokenCountEstimator +from ..language_backend import BuiltinLanguageBackend, LanguageBackend, LanguageBackendRegistry from ..util.class_decorators import singleton from ..util.cli_util import ask_yes_no from ..util.dataclass import get_dataclass_default if TYPE_CHECKING: - from ..agent import SerenaAgent from ..project import Project - from ..repl.facade import ApiScope, Facade - from ..tools.tools_base import Tool log = logging.getLogger(__name__) T = TypeVar("T") @@ -251,67 +249,6 @@ class AgentInterface(Enum): return self == AgentInterface.REPL -class LanguageBackend(Enum): - LSP = "LSP" - """ - Use the language server protocol (LSP), spawning freely available language servers - via the SolidLSP library that is part of Serena - """ - JETBRAINS = "JetBrains" - """ - Use the Serena plugin in your JetBrains IDE. - (requires the plugin to be installed and the project being worked on to be open in your IDE) - """ - - @staticmethod - def from_str(backend_str: str) -> "LanguageBackend": - for backend in LanguageBackend: - if backend.value.lower() == backend_str.lower(): - return backend - raise ValueError(f"Unknown language backend '{backend_str}': valid values are {[b.value for b in LanguageBackend]}") - - def is_lsp(self) -> bool: - return self == LanguageBackend.LSP - - def is_jetbrains(self) -> bool: - return self == LanguageBackend.JETBRAINS - - def get_lsp_tool_class_replacements(self) -> "dict[type[Tool], type[Tool]]": - """ - :return: mapping from LSP tool classes to replacement tool classes (functional replacements) - """ - match self: - case LanguageBackend.LSP: - return {} - case LanguageBackend.JETBRAINS: - from ..tools import jetbrains_tools, symbol_tools - - return { - symbol_tools.FindSymbolTool: jetbrains_tools.JetBrainsFindSymbolTool, - symbol_tools.GetSymbolsOverviewTool: jetbrains_tools.JetBrainsGetSymbolsOverviewTool, - symbol_tools.FindReferencingSymbolsTool: jetbrains_tools.JetBrainsFindReferencingSymbolsTool, - symbol_tools.FindImplementationsTool: jetbrains_tools.JetBrainsFindImplementationsTool, - symbol_tools.FindDeclarationTool: jetbrains_tools.JetBrainsFindDeclarationTool, - symbol_tools.RenameSymbolTool: jetbrains_tools.JetBrainsRenameTool, - symbol_tools.SafeDeleteSymbol: jetbrains_tools.JetBrainsSafeDeleteTool, - } - case _: - raise NotImplementedError() - - def create_facades(self, agent: "SerenaAgent", api_scope: "ApiScope") -> list["Facade"]: - from ..repl.facade import Facade - - if self.is_lsp(): - from ..repl.api.lsp_api import LspApi - - return [Facade.from_api(LspApi(agent), api_scope)] - elif self.is_jetbrains(): - from ..repl.api.jb_api import JetBrainsApi - - return [Facade.from_api(JetBrainsApi(agent), api_scope)] - return [] - - class LineEnding(Enum): """Line ending convention for file writes.""" @@ -680,7 +617,7 @@ class ProjectConfig(SharedConfig, ModeSelectionDefinitionWithAddedModes): raise ValueError(f"symbol_info_budget cannot be negative, got: {symbol_info_budget}") language_backend_value = data.get("language_backend") - language_backend = LanguageBackend.from_str(language_backend_value) if language_backend_value else None + language_backend = LanguageBackendRegistry.get_instance().resolve(language_backend_value) if language_backend_value else None agent_interface_value = data.get("agent_interface") agent_interface = AgentInterface.from_str(agent_interface_value) if agent_interface_value else None @@ -742,7 +679,7 @@ class ProjectConfig(SharedConfig, ModeSelectionDefinitionWithAddedModes): # map fields using non-primitive types to a YAML-compatible representation d["language_servers"] = [lang.get_key() for lang in self.language_servers] - d["language_backend"] = self.language_backend.value if self.language_backend is not None else None + d["language_backend"] = self.language_backend.get_key() if self.language_backend is not None else None d["agent_interface"] = self.agent_interface.value if self.agent_interface is not None else None d["line_ending"] = self.line_ending.value if self.line_ending is not None else None @@ -1020,7 +957,7 @@ class SerenaConfig(SharedConfig, ModeSelectionDefinitionWithBaseModes): Defaults to TOOLS for backward compatibility (as users without this settings will get this default). The default for new users is defined in the template file. """ - language_backend: LanguageBackend = LanguageBackend.LSP + language_backend: LanguageBackend = field(default_factory=lambda: BuiltinLanguageBackend.LSP.get_instance()) """ the language backend to use for code understanding features """ @@ -1204,13 +1141,13 @@ class SerenaConfig(SharedConfig, ModeSelectionDefinitionWithBaseModes): language_backend = get_dataclass_default(SerenaConfig, "language_backend") if "language_backend" in loaded_commented_yaml: backend_str = loaded_commented_yaml["language_backend"] - language_backend = LanguageBackend.from_str(backend_str) + language_backend = LanguageBackendRegistry.get_instance().resolve(backend_str) else: # backward compatibility (migrate Boolean field "jetbrains") if "jetbrains" in loaded_commented_yaml: num_migrations += 1 if loaded_commented_yaml["jetbrains"]: - language_backend = LanguageBackend.JETBRAINS + language_backend = BuiltinLanguageBackend.JETBRAINS.get_instance() del loaded_commented_yaml["jetbrains"] instance.language_backend = language_backend @@ -1277,17 +1214,25 @@ class SerenaConfig(SharedConfig, ModeSelectionDefinitionWithBaseModes): log.error(f"Error migrating configuration file: {e}") return None + def set_builtin_language_backend(self, backend: BuiltinLanguageBackend) -> None: + """ + Sets the built-in language backend to use for code understanding features. + + :param backend: the language backend to set + """ + self.language_backend = backend.get_instance() + @classmethod - def init(cls, language_backend: LanguageBackend) -> "SerenaConfig": + def init(cls, builtin_language_backend: BuiltinLanguageBackend) -> "SerenaConfig": """ Supports the config initialisation CLI command, allowing the user to configure fundamental settings before the first launch. - :param language_backend: the language backend to use + :param builtin_language_backend: the language backend to use :return: the created SerenaConfig instance """ config = cls.from_config_file() - config.language_backend = language_backend + config.language_backend = builtin_language_backend.get_instance() config._save() return config @@ -1479,7 +1424,7 @@ class SerenaConfig(SharedConfig, ModeSelectionDefinitionWithBaseModes): commented_yaml["projects"] = sorted({str(project.project_root) for project in self.projects}) # convert language backend to string - commented_yaml["language_backend"] = self.language_backend.value + commented_yaml["language_backend"] = self.language_backend.get_key() # convert agent interface to string (None if not configured) commented_yaml["agent_interface"] = self.agent_interface.value if self.agent_interface is not None else None @@ -1609,7 +1554,7 @@ class SerenaConfig(SharedConfig, ModeSelectionDefinitionWithBaseModes): log.info(f"Using agent interface '{agent_interface.value}' ({source})") return agent_interface - def determine_language_backend(self, project_config: ProjectConfig | None = None, log_choice: bool = False): + def determine_language_backend(self, project_config: ProjectConfig | None = None, log_choice: bool = False) -> LanguageBackend: language_backend = self.language_backend if project_config and project_config.language_backend is not None: language_backend = project_config.language_backend diff --git a/src/serena/dashboard.py b/src/serena/dashboard.py index dcb3bb2c..117482b4 100644 --- a/src/serena/dashboard.py +++ b/src/serena/dashboard.py @@ -651,7 +651,7 @@ class SerenaDashboardAPI: modes=modes_info, active_tools=active_tools, agent_interface=self._agent.get_agent_interface().value, - language_backend=self._agent.get_language_backend().value, + language_backend=self._agent.get_language_backend().get_key(), facades=facades, tool_stats_summary=tool_stats_summary, registered_projects=registered_projects, diff --git a/src/serena/jetbrains/jetbrains_backend.py b/src/serena/jetbrains/jetbrains_backend.py new file mode 100644 index 00000000..6e62fce9 --- /dev/null +++ b/src/serena/jetbrains/jetbrains_backend.py @@ -0,0 +1,105 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +import logging +from typing import TYPE_CHECKING + +from overrides import override + +from serena.code_editor import JetBrainsCodeEditor +from serena.jetbrains import launch_coordinator +from serena.language_backend import BuiltinLanguageBackend, LanguageBackend + +from ..util.file_proxy import FileProxy, LocalProjectFileProxy +from . import jetbrains_types as jb + +if TYPE_CHECKING: + from serena.agent import SerenaAgent + from serena.code_editor import CodeEditor + from serena.project import Project + from serena.repl.facade import ApiScope, Facade + from serena.tools import Tool + +log = logging.getLogger(__name__) + + +class LanguageBackendJetBrains(LanguageBackend): + def __init__(self): + super().__init__(BuiltinLanguageBackend.JETBRAINS.value) + + @override + def get_lsp_tool_class_replacements(self) -> "dict[type[Tool], type[Tool]]": + from ..tools import jetbrains_tools, symbol_tools + + return { + symbol_tools.FindSymbolTool: jetbrains_tools.JetBrainsFindSymbolTool, + symbol_tools.GetSymbolsOverviewTool: jetbrains_tools.JetBrainsGetSymbolsOverviewTool, + symbol_tools.FindReferencingSymbolsTool: jetbrains_tools.JetBrainsFindReferencingSymbolsTool, + symbol_tools.FindImplementationsTool: jetbrains_tools.JetBrainsFindImplementationsTool, + symbol_tools.FindDeclarationTool: jetbrains_tools.JetBrainsFindDeclarationTool, + symbol_tools.RenameSymbolTool: jetbrains_tools.JetBrainsRenameTool, + symbol_tools.SafeDeleteSymbol: jetbrains_tools.JetBrainsSafeDeleteTool, + } + + @override + def create_facades(self, agent: "SerenaAgent", api_scope: "ApiScope") -> list["Facade"]: + from ..repl.api.jb_api import JetBrainsApi + from ..repl.facade import Facade + + return [Facade.from_api(JetBrainsApi(agent), api_scope)] + + @override + def init_active_project(self, agent: "SerenaAgent") -> None: + project = agent.get_active_project_or_raise() + client = launch_coordinator.find_plugin_server(project) + if client is not None: + log.info("Found Serena JetBrains Plugin server: %s", client) + else: + log.info("Serena JetBrains Plugin server not found for project %s", project.project_name) + launch_command = agent.serena_config.jetbrains_launch_command + if launch_command: + launch_coordinator.launch_and_wait_for_plugin_server(project, launch_command) + + @override + def shutdown_active_project(self, project: "Project", timeout: float) -> None: + pass + + @override + def create_code_editor(self, project: "Project") -> "CodeEditor": + return JetBrainsCodeEditor(project) + + @override + def is_source_file(self, abs_path: str, project: "Project") -> bool: + # no distinction is made; every file is potentially a source file + return True + + @override + def is_external_path(self, relative_path: str) -> bool: + return relative_path.startswith(jb.JB_EXTERNAL_FILE_PREFIX) + + @override + def create_file_proxy(self, relative_path: str, project: "Project") -> FileProxy: + if self.is_external_path(relative_path): + return JetBrainsFileProxy(relative_path, project) + return LocalProjectFileProxy(relative_path, project) + + +class JetBrainsFileProxy(FileProxy): + """ + Retrieves the contents of a file from the JetBrains plugin via the plugin client, given its relative path, + which may be an external path (e.g., "") + """ + + def __init__(self, relative_path: str, project: "Project"): + self._relative_path = relative_path + self._project = project + + def get_contents(self) -> str: + from serena.jetbrains.jetbrains_plugin_client import JetBrainsPluginClient + + client = JetBrainsPluginClient.from_project(self._project) + return client.read_file(self._relative_path) + + def get_relative_path(self) -> str: + return self._relative_path + + def is_glob_supported(self): + return False diff --git a/src/serena/jetbrains/jetbrains_types.py b/src/serena/jetbrains/jetbrains_types.py index 1739e607..5ccd561b 100644 --- a/src/serena/jetbrains/jetbrains_types.py +++ b/src/serena/jetbrains/jetbrains_types.py @@ -8,14 +8,6 @@ Prefix used for in relative paths of symbols that are from external libraries (i """ -def is_external_path(relative_path: str): - """ - :param relative_path: a relative path (e.g., from a symbol's `relative_path` field) - :return: whether the path is an external path (i.e., from a library, not the user's codebase) - """ - return relative_path.startswith(JB_EXTERNAL_FILE_PREFIX) - - class PluginStatusDTO(TypedDict): project_root: str plugin_version: str diff --git a/src/serena/language_backend.py b/src/serena/language_backend.py new file mode 100644 index 00000000..54b98525 --- /dev/null +++ b/src/serena/language_backend.py @@ -0,0 +1,251 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +import importlib +import logging +import threading +from abc import ABC, abstractmethod +from enum import Enum +from functools import cache +from typing import TYPE_CHECKING + +from serena.util.file_proxy import FileProxy + +log = logging.getLogger(__name__) + +if TYPE_CHECKING: + from serena.agent import SerenaAgent + from serena.code_editor import CodeEditor + from serena.project import Project + from serena.repl.facade import ApiScope, Facade + from serena.tools import Tool + + +class LanguageBackend(ABC): + def __init__(self, key: str): + """ + :param key: the key by which the backend is identified in the registry and in configuration + """ + self._key = key + + def __str__(self): + return self._key + + def get_key(self) -> str: + """ + :return: the key by which the backend is identified in the registry and in configuration + """ + return self._key + + def is_lsp(self): + return self.get_key() == BuiltinLanguageBackend.LSP.value + + def is_jetbrains(self): + return self.get_key() == BuiltinLanguageBackend.JETBRAINS.value + + @property + def name(self): + return self.get_key() + + @abstractmethod + def get_lsp_tool_class_replacements(self) -> "dict[type[Tool], type[Tool]]": + """ + :return: mapping from LSP tool classes to replacement tool classes (functional replacements) + """ + + @abstractmethod + def create_facades(self, agent: "SerenaAgent", api_scope: "ApiScope") -> list["Facade"]: + """ + Creates backend-specific facades for the given agent and API scope. + + :param agent: the agent + :param api_scope: the API scope defining active facade methods + :return: the list of facades to be used by the agent for this backend + """ + + @abstractmethod + def init_active_project(self, agent: "SerenaAgent") -> None: + """ + Initialises the backend for the given agent's newly activated project. + + :param agent: the agent, which has just set a new active project + """ + + @abstractmethod + def shutdown_active_project(self, project: "Project", timeout: float) -> None: + """ + Cleans up, freeing resources, after a project has been deactivated. + + :param project: the project + :param timeout: the timeout, in seconds, after which to give up on graceful shutdown + """ + + def get_project_activation_statement(self, project: "Project") -> str: + """ + :return: a statement to add to the project activation message + """ + return "" + + def get_config_overview_statement(self, project: "Project") -> str: + """ + :return: a statement to add to the project configuration overview + """ + return "" + + @abstractmethod + def create_code_editor(self, project: "Project") -> "CodeEditor": + pass + + @abstractmethod + def is_source_file(self, abs_path: str, project: "Project") -> bool: + """ + Determines whether the given absolute path corresponds to a source file that can (potentially) be processed/understood by the backend. + + :param abs_path: the absolute path to an existing file + :param project: the project in which the file is located + :return: True if the file is a source file for this backend (or the backend does not specifically make distinctions), + False otherwise + """ + + @abstractmethod + def is_external_path(self, relative_path: str) -> bool: + """ + Determines whether the given relative path corresponds to a file that is external to the project (e.g. a dependency file). + Virtually all of Serena's interfaces use `relative_path` (relative to the project root) to refer to files, but some backends + may need to support project-external files. In this case, the external path should be encoded in the `relative_path` parameter + (e.g. "") rather than this being an actual relative path that points outside the project root. + Therefore, information about the project in question is deliberately not provided to this method. + + :param relative_path: the relative path to a file within the project or an encoded external path. + The path can be assumed to have been provided by the backend itself. + :return: whether the file is considered external to the project by this backend + """ + + @abstractmethod + def create_file_proxy(self, relative_path: str, project: "Project") -> FileProxy: + """ + Creates a file proxy for the given relative path in the given project. + + :param relative_path: the relative path to a file within the project or an encoded external path. + :param project: the project + :return: a file proxy for the given file + """ + + +class BuiltinLanguageBackend(Enum): + LSP = "LSP" + """ + Use the language server protocol (LSP), spawning freely available language servers + via the SolidLSP library that is part of Serena + """ + JETBRAINS = "JetBrains" + """ + Use the Serena plugin in your JetBrains IDE. + (requires the plugin to be installed and the project being worked on to be open in your IDE) + """ + + @staticmethod + def from_str(backend_str: str) -> "BuiltinLanguageBackend": + for backend in BuiltinLanguageBackend: + if backend.value.lower() == backend_str.lower(): + return backend + raise ValueError(f"Unknown language backend '{backend_str}': valid values are {[b.value for b in BuiltinLanguageBackend]}") + + @cache + def get_instance(self) -> LanguageBackend: + if self == BuiltinLanguageBackend.LSP: + from .lsp.lsp_backend import LanguageBackendLSP + + return LanguageBackendLSP() + elif self == BuiltinLanguageBackend.JETBRAINS: + from .jetbrains.jetbrains_backend import LanguageBackendJetBrains + + return LanguageBackendJetBrains() + else: + raise NotImplementedError + + +class LanguageBackendRegistry: + """ + Registry of language backends + """ + + REGISTRATION_ENTRY_POINT_GROUP = "serena.language_backend_registration" + """ + entry point group for language backend registration functions; each function should call use + `LanguageBackendRegistry.get_instance().register(...)` to register a backend + """ + + _instance = None + _instance_lock = threading.Lock() + + @classmethod + def get_instance(cls): + if cls._instance is None: + with cls._instance_lock: + if cls._instance is None: + cls._instance = cls(True) + cls._discover_backends_from_entry_points() + return cls._instance + + def __init__(self, _singleton: bool): + if not _singleton: + raise RuntimeError("LanguageServerRegistry is a singleton. Use get_instance() to access it.") + self._registered_backends: dict[str, LanguageBackend] = {} + + # auto-register built-in language backends + for builtin_backend in BuiltinLanguageBackend: + self._registered_backends[builtin_backend.value] = builtin_backend.get_instance() + + @classmethod + def _discover_backends_from_entry_points(cls) -> None: + """ + Discover and execute language server adapter registration functions from entry points. + """ + log.debug("Discovering language backend registration entry points ...") + try: + entry_points = importlib.metadata.entry_points(group=cls.REGISTRATION_ENTRY_POINT_GROUP) + except Exception as error: + log.exception("Failed to discover language server registration entry points: %s", error) + return + + def get_distribution_name(ep: importlib.metadata.EntryPoint) -> str: + distribution = getattr(ep, "dist", None) + if distribution is None: + return "unknown distribution" + return distribution.name or "unknown distribution" + + log.debug("Found %d language server registration entry points", len(entry_points)) + for entry_point in entry_points: + try: + registration = entry_point.load() + if not callable(registration): + raise TypeError("Entry point must resolve to a callable registration function") + registration() + except Exception as error: + log.exception( + "Failed to load language backend entry point '%s' from %s: %s", + entry_point.name, + get_distribution_name(entry_point), + error, + ) + + def resolve(self, key: str) -> LanguageBackend: + if key in self._registered_backends: + return self._registered_backends[key] + raise ValueError(f"Unknown language backend key: '{key}'; Valid keys: {self.get_keys()}") + + def register(self, backend: LanguageBackend, allow_override: bool = False) -> None: + """ + :param backend: the backend to register + :param allow_override: whether to allow overriding an existing registration with the same key + """ + key = backend.get_key() + log.info("Registering language backend: %s (class=%s)", key, backend.__class__.__name__) + if backend.get_key() in self._registered_backends and not allow_override: + raise ValueError(f"Language backend already registered: {key}") + self._registered_backends[key] = backend + + def get_keys(self) -> list[str]: + """ + :return: the sorted list of all registered string keys + """ + return sorted(self._registered_backends.keys()) diff --git a/src/serena/lsp/lsp_backend.py b/src/serena/lsp/lsp_backend.py new file mode 100644 index 00000000..1d672195 --- /dev/null +++ b/src/serena/lsp/lsp_backend.py @@ -0,0 +1,80 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +import logging +from typing import TYPE_CHECKING + +from overrides import override +from sensai.util.logging import LogTime + +from serena.language_backend import BuiltinLanguageBackend, LanguageBackend +from serena.util.file_proxy import FileProxy, LocalProjectFileProxy + +if TYPE_CHECKING: + from serena.agent import SerenaAgent + from serena.code_editor import LanguageServerCodeEditor + from serena.project import Project + from serena.repl.facade import ApiScope, Facade + from serena.tools import Tool + +log = logging.getLogger(__name__) + + +class LanguageBackendLSP(LanguageBackend): + def __init__(self): + super().__init__(BuiltinLanguageBackend.LSP.value) + + @override + def get_lsp_tool_class_replacements(self) -> "dict[type[Tool], type[Tool]]": + return {} + + @override + def create_facades(self, agent: "SerenaAgent", api_scope: "ApiScope") -> list["Facade"]: + from ..repl.api.lsp_api import LspApi + from ..repl.facade import Facade + + return [Facade.from_api(LspApi(agent), api_scope)] + + @override + def init_active_project(self, agent: "SerenaAgent") -> None: + with LogTime("Language server initialization", logger=log): + agent.reset_language_server_manager() + + @override + def shutdown_active_project(self, project: "Project", timeout: float) -> None: + # nothing to do; the language server manager is already shut down by the project itself + pass + + @override + def get_project_activation_statement(self, project: "Project") -> str: + language_servers_str = ", ".join([ls.get_key() for ls in project.project_config.language_servers]) + return f"Active language servers: {language_servers_str}.\n" + + @override + def get_config_overview_statement(self, project: "Project") -> str: + return f"Language server status: {project.get_language_server_manager_status()}\n" + + @override + def create_code_editor(self, project: "Project") -> "LanguageServerCodeEditor": + from serena.code_editor import LanguageServerCodeEditor + from serena.symbol import LanguageServerSymbolRetriever + + symbol_retriever = LanguageServerSymbolRetriever(project) + return LanguageServerCodeEditor(symbol_retriever) + + @override + def is_source_file(self, abs_path: str, project: "Project") -> bool: + is_file_in_supported_languages = False + for language in project.project_config.language_servers: + fn_matcher = language.get_source_fn_matcher() + if fn_matcher.is_relevant_filename(abs_path): + is_file_in_supported_languages = True + break + return is_file_in_supported_languages + + @override + def is_external_path(self, relative_path: str) -> bool: + # LSP backend currently uses only true project-relative paths + return False + + @override + def create_file_proxy(self, relative_path: str, project: "Project") -> FileProxy: + return LocalProjectFileProxy(relative_path, project) diff --git a/src/serena/lsp/lsp_diagnostics.py b/src/serena/lsp/lsp_diagnostics.py index 661c3332..4d0a0844 100644 --- a/src/serena/lsp/lsp_diagnostics.py +++ b/src/serena/lsp/lsp_diagnostics.py @@ -219,7 +219,7 @@ class DiagnosticsContext: DIAGNOSTICS_KEY = "diagnostics[warning-or-higher]" def __init__(self, agent: "SerenaAgent", *edited_relative_paths: str, enable: bool = ENABLE_DIAGNOSTICS_DEFAULT) -> None: - self._is_diagnostics_enabled = enable and agent.is_using_language_server() + self._is_diagnostics_enabled = enable and agent.get_language_backend() 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 diff --git a/src/serena/mcp.py b/src/serena/mcp.py index cbe38d8b..a7bee105 100644 --- a/src/serena/mcp.py +++ b/src/serena/mcp.py @@ -26,8 +26,9 @@ from serena.agent import ( SerenaAgent, ) from serena.config.context_mode import SerenaAgentContext -from serena.config.serena_config import AgentInterface, LanguageBackend, ModeSelectionDefinition, SerenaConfig +from serena.config.serena_config import AgentInterface, ModeSelectionDefinition, SerenaConfig from serena.constants import DEFAULT_CONTEXT, SERENA_LOG_FORMAT +from serena.language_backend import LanguageBackend from serena.tools import Tool, ToolCallError from serena.util.exception import show_fatal_exception_safe from serena.util.logging import MemoryLogHandler diff --git a/src/serena/project.py b/src/serena/project.py index a6dd1f04..396a01cf 100644 --- a/src/serena/project.py +++ b/src/serena/project.py @@ -12,11 +12,11 @@ from sensai.util.logging import LogTime from sensai.util.string import TextBuilder, ToStringMixin from serena.config.serena_config import ( - LanguageBackend, ProjectConfig, ProjectConfigAutoGenerationMode, SerenaConfig, ) +from serena.language_backend import LanguageBackend from serena.ls_manager import LanguageServerFactory, LanguageServerManager from serena.memories.memory_manager import MemoryManager from serena.util.file_proxy import FileCollection, FileProxy @@ -128,7 +128,7 @@ class Project(ToStringMixin): @property def language_backend(self) -> LanguageBackend: # The backend configuration is fundamentally owned by the agent, so it takes - # precedence. (Note: The agent does not necessary honour the project's choice, + # precedence. (Note: The agent does not necessarily honour the project's choice, # as it may be invalid.) if self._agent is not None: return self._agent.get_language_backend() @@ -236,18 +236,10 @@ class Project(ToStringMixin): # check code file restriction (depending on backend) if ignore_non_source_files: - # apply restriction only for LSP backend, which enumerates known languages - # and therefore can determine whether a file is a source file or not - if self.language_backend.is_lsp(): - if os.path.isfile(abs_path): - is_file_in_supported_language = False - for language in self.project_config.language_servers: - fn_matcher = language.get_source_fn_matcher() - if fn_matcher.is_relevant_filename(abs_path): - is_file_in_supported_language = True - break - if not is_file_in_supported_language: - return True + if os.path.isfile(abs_path): + # non-source files are ignored + if not self.language_backend.is_source_file(abs_path, self): + return True # Create normalized path for consistent handling rel_path = Path(relative_path) @@ -336,7 +328,7 @@ class Project(ToStringMixin): :param relative_path: the path to validate, relative to the project root :param require_not_ignored: if True, the path must not be ignored according to the project's ignore settings """ - if FileProxy.is_external_path(relative_path): + if FileProxy.is_external_path(relative_path, self): return if not self.is_path_in_project(relative_path): @@ -392,7 +384,7 @@ class Project(ToStringMixin): :param skip_ignored_files: whether to skip ignored files; has no effect if `code_files_only` is True :return: """ - if FileProxy.is_external_path(relative_path): + if FileProxy.is_external_path(relative_path, self): # single external path: create appropriate proxy file_collection = FileCollection([FileProxy.from_project_relative_path(self, relative_path)]) else: @@ -621,6 +613,14 @@ class Project(ToStringMixin): return 0 def shutdown(self, timeout: float = 2.0) -> None: + """ + Shuts down the project, calling the language backend-specific shutdown of the active project. + + :param timeout: the timeout, in seconds + """ + # clean up internal resources if self.language_server_manager is not None: self.language_server_manager.stop_all(save_cache=True, timeout=timeout) self.language_server_manager = None + # trigger additional backend-specific shutdown + self.language_backend.shutdown_active_project(self, timeout=timeout) diff --git a/src/serena/project_server.py b/src/serena/project_server.py index e00100d3..abc2e3c0 100644 --- a/src/serena/project_server.py +++ b/src/serena/project_server.py @@ -12,8 +12,9 @@ from flask import Flask, Response, abort, request from pydantic import BaseModel from sensai.util.logging import LogTime -from serena.config.serena_config import LanguageBackend, SerenaConfig +from serena.config.serena_config import SerenaConfig from serena.constants import SerenaPorts +from serena.language_backend import BuiltinLanguageBackend if TYPE_CHECKING: from serena.project import Project @@ -73,7 +74,7 @@ class ProjectServer: port = self.PORT serena_config = SerenaConfig.from_config_file().with_headless_mode_overrides() - serena_config.language_backend = LanguageBackend.LSP + serena_config.set_builtin_language_backend(BuiltinLanguageBackend.LSP) self._agent = SerenaAgent(serena_config=serena_config) self._loaded_projects_by_root: dict[str, "Project"] = {} diff --git a/src/serena/repl/facade.py b/src/serena/repl/facade.py index cfe53a7c..96828682 100644 --- a/src/serena/repl/facade.py +++ b/src/serena/repl/facade.py @@ -362,17 +362,9 @@ class FacadeApi(ABC): """ :return: a code editor for the active project, using the active language backend """ - from serena.code_editor import JetBrainsCodeEditor, LanguageServerCodeEditor - from serena.symbol import LanguageServerSymbolRetriever - project = self._get_project() backend = self._agent.get_language_backend() - if backend.is_lsp(): - return LanguageServerCodeEditor(LanguageServerSymbolRetriever(project)) - elif backend.is_jetbrains(): - return JetBrainsCodeEditor(project) - else: - raise ValueError(f"Unsupported language backend: {backend}") + return backend.create_code_editor(project) class FacadeMethod: diff --git a/src/serena/tools/query_project_tools.py b/src/serena/tools/query_project_tools.py index 68e4eb3e..781ccc27 100644 --- a/src/serena/tools/query_project_tools.py +++ b/src/serena/tools/query_project_tools.py @@ -2,7 +2,6 @@ import json -from serena.config.serena_config import LanguageBackend from serena.jetbrains.jetbrains_plugin_client import JetBrainsPluginClientManager from serena.project_server import ProjectServerClient from serena.tools import Tool, ToolMarkerDoesNotRequireActiveProject, ToolMarkerOptional @@ -67,13 +66,10 @@ class QueryProjectTool(Tool, ToolMarkerOptional, ToolMarkerDoesNotRequireActiveP return tool.apply(**json.loads(tool_params_json)) def _is_project_server_required(self, tool: Tool) -> bool: - match self.agent.get_language_backend(): - case LanguageBackend.JETBRAINS: - return False - case LanguageBackend.LSP: - # Note: As long as only read-only tools are considered, only symbolic tools require the project server. - # But if we were to allow non-read-only tools, then tools using a CodeEditor also indirectly require language servers. - assert tool.is_readonly() - return tool.is_symbolic() - case _: - raise NotImplementedError + # The project server is relevant to the LSP backend only + if not self.agent.get_language_backend().is_lsp(): + return False + # Note: As long as only read-only tools are considered, only symbolic tools require the project server. + # But if we were to allow non-read-only tools, then tools using a CodeEditor also indirectly require language servers. + assert tool.is_readonly() + return tool.is_symbolic() diff --git a/src/serena/tools/tools_base.py b/src/serena/tools/tools_base.py index b80413fd..dce69857 100644 --- a/src/serena/tools/tools_base.py +++ b/src/serena/tools/tools_base.py @@ -16,7 +16,6 @@ from sensai.util.helper import mark_used from sensai.util.string import dict_string from serena.code_editor import EditedFileContext -from serena.config.serena_config import LanguageBackend from serena.lsp.lsp_diagnostics import DiagnosticsContext from serena.memories.memory_manager import MemoryManager from serena.project import Project @@ -29,7 +28,7 @@ from solidlsp.ls_exceptions import SolidLSPException if TYPE_CHECKING: from serena.agent import SerenaAgent - from serena.code_editor import CodeEditor, LanguageServerCodeEditor + from serena.code_editor import CodeEditor from serena.symbol import LanguageServerSymbolRetriever @@ -67,22 +66,7 @@ class Component(ABC): return self.agent.get_active_project_or_raise() def create_code_editor(self) -> "CodeEditor": - from ..code_editor import JetBrainsCodeEditor - - match self.agent.get_language_backend(): - case LanguageBackend.LSP: - return self.create_ls_code_editor() - case LanguageBackend.JETBRAINS: - return JetBrainsCodeEditor(project=self.project) - case _: - raise ValueError - - def create_ls_code_editor(self) -> "LanguageServerCodeEditor": - from ..code_editor import LanguageServerCodeEditor - - if not self.agent.is_using_language_server(): - raise Exception("Cannot create LanguageServerCodeEditor; agent is not in language server mode.") - return LanguageServerCodeEditor(self.create_language_server_symbol_retriever()) + return self.agent.get_language_backend().create_code_editor(self.project) class ToolMarker: diff --git a/src/serena/util/file_proxy.py b/src/serena/util/file_proxy.py index df6ab3a4..14889a1a 100644 --- a/src/serena/util/file_proxy.py +++ b/src/serena/util/file_proxy.py @@ -6,8 +6,6 @@ from abc import ABC, abstractmethod from collections.abc import Iterator from typing import TYPE_CHECKING, Self -from serena.jetbrains import jetbrains_types as jb - if TYPE_CHECKING: from serena.project import Project @@ -30,19 +28,15 @@ class FileProxy(ABC): """ @staticmethod - def is_external_path(relative_path: str) -> bool: + def is_external_path(relative_path: str, project: "Project") -> bool: """ :return: whether the given relative path is an encoded external path (not a local project file) """ - # This is intended to be extended once we also support external paths in other backends - return jb.is_external_path(relative_path) + return project.language_backend.is_external_path(relative_path) @classmethod def from_project_relative_path(cls, project: "Project", relative_path: str) -> "FileProxy": - if cls.is_external_path(relative_path): - if project.language_backend.is_jetbrains(): - return JetBrainsFileProxy(relative_path, project) - return LocalProjectFileProxy(relative_path, project) + return project.language_backend.create_file_proxy(relative_path, project) class LocalProjectFileProxy(FileProxy): @@ -62,29 +56,6 @@ class LocalProjectFileProxy(FileProxy): return True -class JetBrainsFileProxy(FileProxy): - """ - Retrieves the contents of a file from the JetBrains plugin via the plugin client, given its relative path, - which may be an external path (e.g., "") - """ - - def __init__(self, relative_path: str, project: "Project"): - self._relative_path = relative_path - self._project = project - - def get_contents(self) -> str: - from serena.jetbrains.jetbrains_plugin_client import JetBrainsPluginClient - - client = JetBrainsPluginClient.from_project(self._project) - return client.read_file(self._relative_path) - - def get_relative_path(self) -> str: - return self._relative_path - - def is_glob_supported(self): - return False - - class FileCollection: def __init__(self, file_proxies: list[FileProxy]): self._file_proxies = file_proxies diff --git a/test/serena/config/test_serena_config.py b/test/serena/config/test_serena_config.py index 9b91bbdb..c0f21d9e 100644 --- a/test/serena/config/test_serena_config.py +++ b/test/serena/config/test_serena_config.py @@ -12,13 +12,13 @@ from serena.agent import SerenaAgent from serena.config.serena_config import ( DEFAULT_PROJECT_SERENA_FOLDER_LOCATION, AgentInterface, - LanguageBackend, ProjectConfig, RegisteredProject, SerenaConfig, SerenaConfigError, ) from serena.constants import PROJECT_TEMPLATE_FILE, SERENA_MANAGED_DIR_NAME +from serena.language_backend import BuiltinLanguageBackend from serena.project import MemoryManager, Project from solidlsp.ls_config import LanguageServerId from test.conftest import create_default_serena_config @@ -178,15 +178,16 @@ class TestProjectConfigLanguageBackend: config = ProjectConfig( project_name="test", language_servers=[LanguageServerId.PYTHON], - language_backend=LanguageBackend.JETBRAINS, + language_backend=BuiltinLanguageBackend.JETBRAINS.get_instance(), ) - assert config.language_backend == LanguageBackend.JETBRAINS + assert config.language_backend is not None + assert config.language_backend.is_jetbrains() def test_language_backend_roundtrips_through_yaml(self): config = ProjectConfig( project_name="test", language_servers=[LanguageServerId.PYTHON], - language_backend=LanguageBackend.JETBRAINS, + language_backend=BuiltinLanguageBackend.JETBRAINS.get_instance(), ) d = config._to_yaml_dict() assert d["language_backend"] == "JetBrains" @@ -207,7 +208,8 @@ class TestProjectConfigLanguageBackend: data["languages"] = ["python"] data["language_backend"] = "JetBrains" config = ProjectConfig._from_dict(data, local_override_keys=[]) - assert config.language_backend == LanguageBackend.JETBRAINS + assert config.language_backend is not None + assert config.language_backend.is_jetbrains() def test_language_backend_none_when_missing_from_dict(self): """Test that _from_dict handles missing language_backend gracefully.""" @@ -284,20 +286,20 @@ class TestAgentInterface: def _make_config_with_project( project_name: str, - language_backend: LanguageBackend | None = None, - global_backend: LanguageBackend = LanguageBackend.LSP, + language_backend: BuiltinLanguageBackend | None = None, + global_backend: BuiltinLanguageBackend = BuiltinLanguageBackend.LSP, ) -> tuple[SerenaConfig, str]: """Create a SerenaConfig with a single registered project and return (config, project_name).""" config = SerenaConfig( log_level=logging.ERROR, - language_backend=global_backend, + language_backend=global_backend.get_instance(), ).with_headless_mode_overrides() project = Project( project_root=str(Path(__file__).parent.parent / "resources" / "repos" / "python" / "test_repo"), project_config=ProjectConfig( project_name=project_name, language_servers=[LanguageServerId.PYTHON], - language_backend=language_backend, + language_backend=language_backend.get_instance() if language_backend is not None else None, ), serena_config=config, ) @@ -310,7 +312,7 @@ class TestEffectiveLanguageBackend: def test_default_backend_is_global(self): """When no project override, effective backend matches global config.""" - config, name = _make_config_with_project("test_proj", language_backend=None, global_backend=LanguageBackend.LSP) + config, name = _make_config_with_project("test_proj", language_backend=None, global_backend=BuiltinLanguageBackend.LSP) agent = SerenaAgent(project=name, serena_config=config) try: assert agent.get_language_backend().is_lsp() @@ -320,7 +322,7 @@ class TestEffectiveLanguageBackend: def test_project_overrides_global_backend(self): """When startup project has language_backend set, it overrides the global.""" config, name = _make_config_with_project( - "test_jetbrains", language_backend=LanguageBackend.JETBRAINS, global_backend=LanguageBackend.LSP + "test_jetbrains", language_backend=BuiltinLanguageBackend.JETBRAINS, global_backend=BuiltinLanguageBackend.LSP ) agent = SerenaAgent(project=name, serena_config=config) try: @@ -332,18 +334,18 @@ class TestEffectiveLanguageBackend: """When no startup project is provided, effective backend is the global one.""" config = SerenaConfig( log_level=logging.ERROR, - language_backend=LanguageBackend.LSP, + language_backend=BuiltinLanguageBackend.LSP.get_instance(), ).with_headless_mode_overrides() agent = SerenaAgent(project=None, serena_config=config) try: - assert agent.get_language_backend() == LanguageBackend.LSP + assert agent.get_language_backend().is_lsp() finally: agent.on_shutdown(timeout=5) def test_activate_project_rejects_backend_mismatch(self): """Post-init activation of a project with mismatched backend raises ValueError.""" # Start with LSP backend - config, name = _make_config_with_project("lsp_proj", language_backend=None, global_backend=LanguageBackend.LSP) + config, name = _make_config_with_project("lsp_proj", language_backend=None, global_backend=BuiltinLanguageBackend.LSP) # Add a second project that requires JetBrains jb_project = Project( @@ -351,7 +353,7 @@ class TestEffectiveLanguageBackend: project_config=ProjectConfig( project_name="jb_proj", language_servers=[LanguageServerId.JAVA], - language_backend=LanguageBackend.JETBRAINS, + language_backend=BuiltinLanguageBackend.JETBRAINS.get_instance(), ), serena_config=config, ) @@ -366,14 +368,14 @@ class TestEffectiveLanguageBackend: def test_activate_project_switches_backend_with_repl_interface(self): """With the REPL interface, post-init activation of a project with a different backend switches the backend.""" - config, name = _make_config_with_project("lsp_proj", language_backend=None, global_backend=LanguageBackend.LSP) + config, name = _make_config_with_project("lsp_proj", language_backend=None, global_backend=BuiltinLanguageBackend.LSP) config.agent_interface = AgentInterface.REPL jb_project = Project( project_root=str(Path(__file__).parent.parent / "resources" / "repos" / "java" / "test_repo"), project_config=ProjectConfig( project_name="jb_proj", language_servers=[LanguageServerId.JAVA], - language_backend=LanguageBackend.JETBRAINS, + language_backend=BuiltinLanguageBackend.JETBRAINS.get_instance(), ), serena_config=config, ) @@ -381,12 +383,12 @@ class TestEffectiveLanguageBackend: agent = SerenaAgent(project=name, serena_config=config) try: - assert agent.get_language_backend() == LanguageBackend.LSP + assert agent.get_language_backend().is_lsp() assert "s.lsp" in agent.get_repl().entrypoint.overview() # the backend and everything depending on it follow the activated project agent.activate_project_from_path_or_name("jb_proj") - assert agent.get_language_backend() == LanguageBackend.JETBRAINS + assert agent.get_language_backend().is_jetbrains() overview = agent.get_repl().entrypoint.overview() assert "s.jb" in overview and "s.lsp" not in overview assert "jetbrains" in [m.name for m in agent.get_active_modes().get_modes(include_background_base_modes=True)] @@ -395,7 +397,7 @@ class TestEffectiveLanguageBackend: def test_activate_project_allows_matching_backend(self): """Post-init activation of a project with matching backend succeeds.""" - config, name = _make_config_with_project("lsp_proj", language_backend=None, global_backend=LanguageBackend.LSP) + config, name = _make_config_with_project("lsp_proj", language_backend=None, global_backend=BuiltinLanguageBackend.LSP) # Add a second project that also uses LSP lsp_project2 = Project( @@ -403,7 +405,7 @@ class TestEffectiveLanguageBackend: project_config=ProjectConfig( project_name="lsp_proj2", language_servers=[LanguageServerId.PYTHON], - language_backend=LanguageBackend.LSP, + language_backend=BuiltinLanguageBackend.LSP.get_instance(), ), serena_config=config, ) @@ -418,7 +420,7 @@ class TestEffectiveLanguageBackend: def test_activate_project_allows_none_backend(self): """Post-init activation of a project with no backend override succeeds.""" - config, name = _make_config_with_project("lsp_proj", language_backend=None, global_backend=LanguageBackend.LSP) + config, name = _make_config_with_project("lsp_proj", language_backend=None, global_backend=BuiltinLanguageBackend.LSP) # Add a second project with no backend override proj2 = Project( diff --git a/test/serena/test_code_editor_atomic_writes.py b/test/serena/test_code_editor_atomic_writes.py index e9264eef..77b55237 100644 --- a/test/serena/test_code_editor_atomic_writes.py +++ b/test/serena/test_code_editor_atomic_writes.py @@ -18,6 +18,7 @@ from typing import Any import pytest from serena.code_editor import CodeEditor +from serena.language_backend import BuiltinLanguageBackend from serena.util import file_system @@ -44,7 +45,14 @@ class _InMemoryEditedFile(CodeEditor.EditedFile): class _StubCodeEditor(CodeEditor[Any]): """A ``CodeEditor`` whose only inherited behaviour under test is the file-saving path.""" + class DummyProject: + """A dummy project object with only the attributes needed to construct a ``CodeEditor``.""" + + def __init__(self) -> None: + self.language_backend = BuiltinLanguageBackend.LSP.get_instance() + def __init__(self, project_root: str, encoding: str = "utf-8", newline: str | None = None) -> None: + self.project = self.DummyProject() self.project_root = project_root self.encoding = encoding self.newline = newline diff --git a/test/serena/test_repl_tool.py b/test/serena/test_repl_tool.py index f3f0a5f5..9cf5dd38 100644 --- a/test/serena/test_repl_tool.py +++ b/test/serena/test_repl_tool.py @@ -7,7 +7,8 @@ from unittest.mock import MagicMock import pytest -from serena.config.serena_config import ApiInclusionDefinition, LanguageBackend +from serena.config.serena_config import ApiInclusionDefinition +from serena.language_backend import BuiltinLanguageBackend from serena.repl.api.edit_api import EditApi from serena.repl.api.lsp_api import LspApi from serena.repl.external_project import ExternalProjectExecution @@ -83,10 +84,11 @@ class TestReplExecution: overview = rebuilt_repl.execute("facades()", session) assert "s.edit" in overview and "s.lsp" not in overview - @pytest.mark.parametrize("backend", [LanguageBackend.LSP, LanguageBackend.JETBRAINS]) + @pytest.mark.parametrize("builtin_backend", [BuiltinLanguageBackend.LSP, BuiltinLanguageBackend.JETBRAINS]) @pytest.mark.parametrize("read_only", [True, False]) - def test_external_project_dispatch(self, backend: LanguageBackend, read_only: bool) -> None: + def test_external_project_dispatch(self, builtin_backend: BuiltinLanguageBackend, read_only: bool) -> None: agent = MagicMock() + backend = builtin_backend.get_instance() agent.get_language_backend.return_value = backend class FakeExternalProject(ExternalProjectExecution):