diff --git a/PARITY_CHECKLIST.md b/PARITY_CHECKLIST.md index 9fc5d8e..915cab8 100644 --- a/PARITY_CHECKLIST.md +++ b/PARITY_CHECKLIST.md @@ -720,7 +720,7 @@ Missing major utility categories: - [ ] Session management (`utils/sessionStorage.ts`, `utils/sessionState.ts`, `utils/sessionStart.ts`, `utils/sessionRestore.ts`) - [ ] Plugin/Skill utilities (`utils/plugins/`, `utils/skills/`) - [ ] Memory/Context (`utils/memory/`, `utils/claudemd.ts`, `utils/contextAnalysis.ts`) -- [ ] IDE integration (`utils/ide.ts`, `utils/jetbrains.ts`) +- [ ] IDE integration (`utils/ide.ts`, `utils/jetbrains.ts`) — partial: `utils/idePathConversion.ts` ported in `src/ide_path_conversion.py` (`WindowsToWSLConverter`, `checkWSLDistroMatch`) - [ ] Platform/OS (`utils/platform.ts`, `utils/terminal.ts`, `utils/systemDirectories.ts`) — partial: platform detection (`getPlatform`, `getWslVersion`, `getLinuxDistroInfo`, `detectVcs`) and `getSystemDirectories` ported in `src/platform_info.py` - [ ] Debugging (`utils/debug.ts`, `utils/diagLogs.ts`, `utils/log.ts`, `utils/profilerBase.ts`) - [ ] Telemetry (`utils/telemetry/`) diff --git a/src/ide_path_conversion.py b/src/ide_path_conversion.py new file mode 100644 index 0000000..c91af59 --- /dev/null +++ b/src/ide_path_conversion.py @@ -0,0 +1,91 @@ +"""IDE path conversion — Python port of ``utils/idePathConversion.ts``. + +Used when Claude runs under WSL but the IDE (VS Code, JetBrains) is on the +host Windows side. Outgoing paths need to be converted to ``\\\\wsl$\\...`` +form for the IDE; incoming paths from the IDE need to be converted back to +``/mnt/c/...`` form for Claude. +""" + +from __future__ import annotations + +import re +import subprocess +from typing import Protocol + + +_WSL_UNC_RE = re.compile(r'^\\\\wsl(?:\.localhost|\$)\\([^\\]+)(.*)$') +_DRIVE_RE = re.compile(r'^([A-Za-z]):') + + +class IDEPathConverter(Protocol): + """Bidirectional path mapping between IDE-side and Claude-side paths.""" + + def to_local_path(self, ide_path: str) -> str: ... + + def to_ide_path(self, local_path: str) -> str: ... + + +def _run_wslpath(flag: str, path: str) -> str: + """Invoke ``wslpath`` and return the stripped stdout. Raises on failure.""" + completed = subprocess.run( + ['wslpath', flag, path], + capture_output=True, + text=True, + check=True, + ) + return completed.stdout.strip() + + +def _manual_windows_to_wsl(windows_path: str) -> str: + """Fallback when ``wslpath`` is unavailable: ``C:\\foo`` → ``/mnt/c/foo``.""" + converted = windows_path.replace('\\', '/') + + def _replace_drive(match: re.Match[str]) -> str: + return f'/mnt/{match.group(1).lower()}' + + return _DRIVE_RE.sub(_replace_drive, converted) + + +class WindowsToWSLConverter: + """Converter for the Windows IDE + WSL Claude scenario.""" + + def __init__(self, wsl_distro_name: str | None) -> None: + self.wsl_distro_name = wsl_distro_name + + def to_local_path(self, windows_path: str) -> str: + if not windows_path: + return windows_path + + if self.wsl_distro_name: + unc = _WSL_UNC_RE.match(windows_path) + if unc and unc.group(1) != self.wsl_distro_name: + # Path belongs to a different distro — wslpath would fail. + return windows_path + + try: + return _run_wslpath('-u', windows_path) + except (subprocess.CalledProcessError, FileNotFoundError, OSError): + return _manual_windows_to_wsl(windows_path) + + def to_ide_path(self, wsl_path: str) -> str: + if not wsl_path: + return wsl_path + try: + return _run_wslpath('-w', wsl_path) + except (subprocess.CalledProcessError, FileNotFoundError, OSError): + return wsl_path + + +def check_wsl_distro_match(windows_path: str, wsl_distro_name: str) -> bool: + """True if ``windows_path`` isn't a WSL UNC path or names this distro.""" + unc = _WSL_UNC_RE.match(windows_path) + if unc: + return unc.group(1) == wsl_distro_name + return True + + +__all__ = [ + 'IDEPathConverter', + 'WindowsToWSLConverter', + 'check_wsl_distro_match', +] diff --git a/tests/test_ide_path_conversion.py b/tests/test_ide_path_conversion.py new file mode 100644 index 0000000..8996832 --- /dev/null +++ b/tests/test_ide_path_conversion.py @@ -0,0 +1,133 @@ +"""Tests for ``src/ide_path_conversion.py``.""" + +from __future__ import annotations + +import subprocess +import unittest +from unittest import mock + +from src.ide_path_conversion import ( + WindowsToWSLConverter, + check_wsl_distro_match, +) + + +class CheckWslDistroMatchTest(unittest.TestCase): + def test_matches_named_distro(self) -> None: + self.assertTrue( + check_wsl_distro_match(r'\\wsl$\Ubuntu\home\me', 'Ubuntu'), + ) + + def test_matches_localhost_form(self) -> None: + self.assertTrue( + check_wsl_distro_match( + r'\\wsl.localhost\Ubuntu\home\me', 'Ubuntu', + ), + ) + + def test_mismatch(self) -> None: + self.assertFalse( + check_wsl_distro_match(r'\\wsl$\Debian\home\me', 'Ubuntu'), + ) + + def test_non_unc_path_returns_true(self) -> None: + self.assertTrue(check_wsl_distro_match(r'C:\Users\me', 'Ubuntu')) + + +class WindowsToWSLConverterToLocalPathTest(unittest.TestCase): + def test_empty_path_passthrough(self) -> None: + conv = WindowsToWSLConverter('Ubuntu') + self.assertEqual(conv.to_local_path(''), '') + + def test_uses_wslpath_when_available(self) -> None: + conv = WindowsToWSLConverter(None) + with mock.patch( + 'src.ide_path_conversion.subprocess.run', + return_value=subprocess.CompletedProcess( + args=[], returncode=0, stdout='/mnt/c/Users/me\n', stderr='', + ), + ) as run: + self.assertEqual(conv.to_local_path(r'C:\Users\me'), '/mnt/c/Users/me') + run.assert_called_once() + args = run.call_args[0][0] + self.assertEqual(args, ['wslpath', '-u', r'C:\Users\me']) + + def test_falls_back_to_manual_when_wslpath_missing(self) -> None: + conv = WindowsToWSLConverter(None) + with mock.patch( + 'src.ide_path_conversion.subprocess.run', + side_effect=FileNotFoundError(), + ): + self.assertEqual( + conv.to_local_path(r'C:\Users\me'), '/mnt/c/Users/me', + ) + + def test_falls_back_to_manual_on_called_process_error(self) -> None: + conv = WindowsToWSLConverter(None) + with mock.patch( + 'src.ide_path_conversion.subprocess.run', + side_effect=subprocess.CalledProcessError(1, 'wslpath'), + ): + self.assertEqual( + conv.to_local_path(r'D:\path\to\file.txt'), + '/mnt/d/path/to/file.txt', + ) + + def test_different_distro_path_returned_as_is(self) -> None: + conv = WindowsToWSLConverter('Ubuntu') + with mock.patch( + 'src.ide_path_conversion.subprocess.run', + ) as run: + self.assertEqual( + conv.to_local_path(r'\\wsl$\Debian\home\me'), + r'\\wsl$\Debian\home\me', + ) + run.assert_not_called() + + def test_same_distro_unc_uses_wslpath(self) -> None: + conv = WindowsToWSLConverter('Ubuntu') + with mock.patch( + 'src.ide_path_conversion.subprocess.run', + return_value=subprocess.CompletedProcess( + args=[], returncode=0, stdout='/home/me\n', stderr='', + ), + ) as run: + self.assertEqual( + conv.to_local_path(r'\\wsl$\Ubuntu\home\me'), + '/home/me', + ) + run.assert_called_once() + + +class WindowsToWSLConverterToIdePathTest(unittest.TestCase): + def test_empty_passthrough(self) -> None: + conv = WindowsToWSLConverter(None) + self.assertEqual(conv.to_ide_path(''), '') + + def test_uses_wslpath(self) -> None: + conv = WindowsToWSLConverter(None) + with mock.patch( + 'src.ide_path_conversion.subprocess.run', + return_value=subprocess.CompletedProcess( + args=[], returncode=0, stdout=r'\\wsl$\Ubuntu\home\me' + '\n', + stderr='', + ), + ) as run: + self.assertEqual( + conv.to_ide_path('/home/me'), r'\\wsl$\Ubuntu\home\me', + ) + run.assert_called_once() + args = run.call_args[0][0] + self.assertEqual(args, ['wslpath', '-w', '/home/me']) + + def test_returns_original_on_failure(self) -> None: + conv = WindowsToWSLConverter(None) + with mock.patch( + 'src.ide_path_conversion.subprocess.run', + side_effect=FileNotFoundError(), + ): + self.assertEqual(conv.to_ide_path('/home/me'), '/home/me') + + +if __name__ == '__main__': + unittest.main()