From 4a0812972fa8b85e807d5aef32378cf853fac573 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sun, 15 Feb 2026 18:41:08 +0100 Subject: [PATCH] JetBrainsPluginClient: Generalise path matching * Account for additional prefixes * Account for WSL vs. Windows path mismatch Add a test for path matching --- .../jetbrains/jetbrains_plugin_client.py | 66 +++++++++++++------ test/serena/test_jetbrains_plugin_client.py | 20 ++++++ 2 files changed, 67 insertions(+), 19 deletions(-) create mode 100644 test/serena/test_jetbrains_plugin_client.py diff --git a/src/serena/jetbrains/jetbrains_plugin_client.py b/src/serena/jetbrains/jetbrains_plugin_client.py index 2607f547..1ae525fa 100644 --- a/src/serena/jetbrains/jetbrains_plugin_client.py +++ b/src/serena/jetbrains/jetbrains_plugin_client.py @@ -118,43 +118,71 @@ class JetBrainsPluginClient(ToStringMixin): if client.matches(resolved_path): return client + plugin_paths_found = [] for port in range(cls.BASE_PORT, cls.BASE_PORT + 20): client = JetBrainsPluginClient(port) if client.matches(resolved_path): log.info("Found matching %s", client) cls._last_port = port return client + elif client._project_root is not None: + plugin_paths_found.append(client._project_root) + log.warning( + "Searched for Serena JetBrains plugin service for project at %s but found no matching service. " + "Found plugin instances for the following project paths: %s", + resolved_path, + plugin_paths_found, + ) raise ServerNotFoundError("Found no Serena service in a JetBrains IDE instance for the project at " + str(resolved_path)) @staticmethod - def _normalize_wsl_path(path_str: str) -> Path: + def _paths_match(resolved_serena_path: str, plugin_path: str) -> bool: """ - Normalize WSL UNC paths to Linux paths for comparison. + Checks whether the resolved Serena path matches the plugin path, accounting for possible prefixes + in the plugin path, different file system perspectives, and case sensitivity. - When JetBrains IDE runs on Windows with a project opened from WSL, - it returns paths like `//wsl.localhost/Ubuntu-24.04/home/user/project` - or `//wsl$/Ubuntu/home/user/project`. This method converts such paths - to standard Linux format `/home/user/project` for proper matching. + Concrete aspects considered: + - The plugin path may contain prefixes: + - The plugin path may be a WSL UNC path, e.g. `//wsl.localhost/Ubuntu-24.04/home/user/project` + or `//wsl$/Ubuntu/home/user/project` while Serena will just have `/home/user/project` + - Other prefixes like `/workspaces/serena/C:/Users/user/projects/my-app` + - One path may use a different file system perspective (particularly WSL vs Windows-native) but still + point to the same location, e.g. `/mnt/c/` vs `C:/` + - Case sensitivity - :param path_str: Path string that may be a WSL UNC path - :return: Normalized Path object + :param resolved_serena_path: The resolved project root path from Serena's perspective + :param plugin_path: The project root path reported by the plugin (which may be a WSL UNC path) + :return: True if the paths match, False otherwise """ - path_str = str(path_str) - # Match //wsl.localhost//... or //wsl$//... - match = re.match(r"^//wsl(?:\.localhost|\$)/[^/]+(.*)$", path_str, re.IGNORECASE) - if match: - return Path(match.group(1)) - return Path(path_str) + # try to resolve the plugin path, checking for a direct match + # (this is robust against symlinks as long as there are no prefixes) + try: + resolved_plugin_path = str(Path(plugin_path).resolve()) + if resolved_plugin_path == resolved_serena_path: + return True + except: + pass + + def normalise_wsl_mnt(path_str: str) -> str: + # normalise WSL /mnt/c/ to c:/ for comparison + return re.sub(r"/mnt/([a-z])/", r"\1:/", path_str, flags=re.IGNORECASE) + + # standardise paths for comparison: normalise WSL /mnt/ to Windows paths and ignore case + std_serena_path = normalise_wsl_mnt(str(resolved_serena_path)).lower() + std_plugin_path = normalise_wsl_mnt(str(plugin_path)).lower() + + # At this point, the plugin path may still contain prefixes, so we check if the Serena path is a suffix of the plugin path + return std_plugin_path.endswith(std_serena_path) def matches(self, resolved_path: Path) -> bool: + """ + :param resolved_path: the resolved project root path from Serena's perspective + :return: whether this client instance matches the given project path + """ if self._project_root is None: return False - try: - plugin_root = self._normalize_wsl_path(self._project_root) - return plugin_root.resolve() == resolved_path - except ConnectionError: - return False + return self._paths_match(str(resolved_path), self._project_root) def is_version_at_least(self, *version_parts: int) -> bool: if self._plugin_version is None: diff --git a/test/serena/test_jetbrains_plugin_client.py b/test/serena/test_jetbrains_plugin_client.py new file mode 100644 index 00000000..d6590eaf --- /dev/null +++ b/test/serena/test_jetbrains_plugin_client.py @@ -0,0 +1,20 @@ +import pytest + +from serena.constants import REPO_ROOT +from serena.jetbrains.jetbrains_plugin_client import JetBrainsPluginClient + + +class TestSerenaJetBrainsPluginClient: + @pytest.mark.parametrize( + "serena_path, plugin_path", + [ + (REPO_ROOT, REPO_ROOT), + ("/home/user/project", "/home/user/project"), + ("/home/user/project", "//wsl.localhost/Ubuntu-24.04/home/user/project"), + ("/home/user/project", "//wsl$/Ubuntu/home/user/project"), + ("/home/user/project", "//wsl$/Ubuntu/home/user/project"), + ("/mnt/c/Users/user/projects/my-app", "/workspaces/serena/C:/Users/user/projects/my-app"), + ], + ) + def test_path_matching(self, serena_path, plugin_path) -> None: + assert JetBrainsPluginClient._paths_match(serena_path, plugin_path)