Coverage for src/prosemark/adapters/editor_launcher_system.py: 100%
67 statements
« prev ^ index » next coverage.py v7.8.0, created at 2025-09-24 18:08 +0000
« prev ^ index » next coverage.py v7.8.0, created at 2025-09-24 18:08 +0000
1# Copyright (c) 2024 Prosemark Contributors
2# This software is licensed under the MIT License
4"""System editor launcher implementation using environment variables and OS defaults."""
6import os
7import subprocess # noqa: S404
8import sys
9from pathlib import Path
11from prosemark.exceptions import EditorLaunchError, EditorNotFoundError
12from prosemark.ports.editor_port import EditorPort
15class EditorLauncherSystem(EditorPort):
16 """System editor launcher using environment variables and OS detection.
18 This implementation provides cross-platform editor launching with:
19 - $EDITOR environment variable support (Unix/Linux tradition)
20 - $VISUAL environment variable fallback
21 - Platform-specific default editors
22 - Proper error handling for missing editors or launch failures
24 Editor precedence:
25 1. $EDITOR environment variable
26 2. $VISUAL environment variable
27 3. Platform-specific defaults (notepad on Windows, nano on Unix)
29 The adapter handles path validation, editor detection, and subprocess
30 management while maintaining security and proper error reporting.
31 """
33 def open(self, path: str, *, cursor_hint: str | None = None) -> None:
34 """Open a file in the external editor.
36 Args:
37 path: File path to open (will be converted to absolute path)
38 cursor_hint: Optional cursor positioning hint (implementation-specific)
40 Raises:
41 EditorNotFoundError: When no suitable editor can be found
42 EditorLaunchError: When editor fails to launch or start properly
44 """
45 # Convert to absolute path and validate
46 abs_path = Path(path).resolve()
48 # Ensure parent directory exists (editor might create the file)
49 abs_path.parent.mkdir(parents=True, exist_ok=True)
51 # Find suitable editor
52 editor_cmd = EditorLauncherSystem._find_editor()
54 # Build command with cursor hint if supported
55 cmd = EditorLauncherSystem._build_command(editor_cmd, str(abs_path), cursor_hint)
57 try:
58 # Launch editor as subprocess
59 subprocess.run(cmd, check=True, shell=False) # noqa: S603
61 except subprocess.CalledProcessError as exc:
62 msg = f'Editor process failed with exit code {exc.returncode}'
63 raise EditorLaunchError(msg) from exc
65 except FileNotFoundError as exc:
66 msg = f'Editor executable not found: {editor_cmd[0]}'
67 raise EditorNotFoundError(msg) from exc
69 except Exception as exc:
70 msg = f'Failed to launch editor: {exc}'
71 raise EditorLaunchError(msg) from exc
73 @staticmethod
74 def _find_editor() -> list[str]:
75 """Find suitable editor command.
77 Returns:
78 Command list for subprocess execution
80 Raises:
81 EditorNotFoundError: When no suitable editor is found
83 """
84 # Try environment variables first
85 for env_var in ['EDITOR', 'VISUAL']:
86 editor = os.environ.get(env_var)
87 if editor:
88 # Split command to handle editors with arguments
89 return editor.split()
91 # Platform-specific defaults
92 if sys.platform == 'win32':
93 return ['notepad.exe']
94 # Unix-like systems - try common editors
95 for editor in ['nano', 'vim', 'vi']:
96 if EditorLauncherSystem._command_exists(editor):
97 return [editor]
99 msg = 'No suitable editor found. Set $EDITOR environment variable.'
100 raise EditorNotFoundError(msg)
102 @staticmethod
103 def _command_exists(command: str) -> bool:
104 """Check if a command exists in PATH.
106 Args:
107 command: Command name to check
109 Returns:
110 True if command exists, False otherwise
112 """
113 try:
114 subprocess.run(['which', command], check=True, capture_output=True) # noqa: S603,S607
115 except (subprocess.CalledProcessError, FileNotFoundError):
116 return False
117 else:
118 return True
120 @staticmethod
121 def _build_command(editor_cmd: list[str], file_path: str, cursor_hint: str | None) -> list[str]:
122 """Build complete command with optional cursor positioning.
124 Args:
125 editor_cmd: Base editor command
126 file_path: Absolute file path
127 cursor_hint: Optional cursor positioning hint
129 Returns:
130 Complete command list for subprocess
132 """
133 cmd = editor_cmd.copy()
134 cmd.append(file_path)
136 # Add cursor hint if provided and editor supports it
137 if cursor_hint and EditorLauncherSystem._supports_cursor_hint(editor_cmd[0]):
138 cmd = EditorLauncherSystem._add_cursor_hint(cmd, cursor_hint)
140 return cmd
142 @staticmethod
143 def _supports_cursor_hint(editor: str) -> bool:
144 """Check if editor supports cursor positioning hints.
146 Args:
147 editor: Editor command name
149 Returns:
150 True if editor supports hints, False otherwise
152 """
153 # Common editors that support line number hints
154 editors_with_hints = {'vim', 'vi', 'nano', 'emacs', 'code'}
155 return any(known_editor in editor.lower() for known_editor in editors_with_hints)
157 @staticmethod
158 def _add_cursor_hint(cmd: list[str], cursor_hint: str) -> list[str]:
159 """Add cursor positioning hint to command.
161 Args:
162 cmd: Current command list
163 cursor_hint: Cursor positioning hint
165 Returns:
166 Modified command list with cursor hint
168 """
169 editor = cmd[0].lower()
171 # Handle different editor cursor hint formats
172 if 'vim' in editor or 'vi' in editor:
173 # Vim format: +line_number
174 if cursor_hint.isdigit():
175 cmd.insert(-1, f'+{cursor_hint}')
177 elif 'nano' in editor:
178 # Nano format: +line_number
179 if cursor_hint.isdigit():
180 cmd.insert(-1, f'+{cursor_hint}')
182 elif 'code' in editor and cursor_hint.isdigit():
183 # VS Code format: --goto line:column
184 cmd.insert(-1, '--goto')
185 cmd.insert(-1, f'{cursor_hint}:1')
187 # For other editors, cursor_hint is ignored
188 return cmd