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

1# Copyright (c) 2024 Prosemark Contributors 

2# This software is licensed under the MIT License 

3 

4"""System editor launcher implementation using environment variables and OS defaults.""" 

5 

6import os 

7import subprocess # noqa: S404 

8import sys 

9from pathlib import Path 

10 

11from prosemark.exceptions import EditorLaunchError, EditorNotFoundError 

12from prosemark.ports.editor_port import EditorPort 

13 

14 

15class EditorLauncherSystem(EditorPort): 

16 """System editor launcher using environment variables and OS detection. 

17 

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 

23 

24 Editor precedence: 

25 1. $EDITOR environment variable 

26 2. $VISUAL environment variable 

27 3. Platform-specific defaults (notepad on Windows, nano on Unix) 

28 

29 The adapter handles path validation, editor detection, and subprocess 

30 management while maintaining security and proper error reporting. 

31 """ 

32 

33 def open(self, path: str, *, cursor_hint: str | None = None) -> None: 

34 """Open a file in the external editor. 

35 

36 Args: 

37 path: File path to open (will be converted to absolute path) 

38 cursor_hint: Optional cursor positioning hint (implementation-specific) 

39 

40 Raises: 

41 EditorNotFoundError: When no suitable editor can be found 

42 EditorLaunchError: When editor fails to launch or start properly 

43 

44 """ 

45 # Convert to absolute path and validate 

46 abs_path = Path(path).resolve() 

47 

48 # Ensure parent directory exists (editor might create the file) 

49 abs_path.parent.mkdir(parents=True, exist_ok=True) 

50 

51 # Find suitable editor 

52 editor_cmd = EditorLauncherSystem._find_editor() 

53 

54 # Build command with cursor hint if supported 

55 cmd = EditorLauncherSystem._build_command(editor_cmd, str(abs_path), cursor_hint) 

56 

57 try: 

58 # Launch editor as subprocess 

59 subprocess.run(cmd, check=True, shell=False) # noqa: S603 

60 

61 except subprocess.CalledProcessError as exc: 

62 msg = f'Editor process failed with exit code {exc.returncode}' 

63 raise EditorLaunchError(msg) from exc 

64 

65 except FileNotFoundError as exc: 

66 msg = f'Editor executable not found: {editor_cmd[0]}' 

67 raise EditorNotFoundError(msg) from exc 

68 

69 except Exception as exc: 

70 msg = f'Failed to launch editor: {exc}' 

71 raise EditorLaunchError(msg) from exc 

72 

73 @staticmethod 

74 def _find_editor() -> list[str]: 

75 """Find suitable editor command. 

76 

77 Returns: 

78 Command list for subprocess execution 

79 

80 Raises: 

81 EditorNotFoundError: When no suitable editor is found 

82 

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() 

90 

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] 

98 

99 msg = 'No suitable editor found. Set $EDITOR environment variable.' 

100 raise EditorNotFoundError(msg) 

101 

102 @staticmethod 

103 def _command_exists(command: str) -> bool: 

104 """Check if a command exists in PATH. 

105 

106 Args: 

107 command: Command name to check 

108 

109 Returns: 

110 True if command exists, False otherwise 

111 

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 

119 

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. 

123 

124 Args: 

125 editor_cmd: Base editor command 

126 file_path: Absolute file path 

127 cursor_hint: Optional cursor positioning hint 

128 

129 Returns: 

130 Complete command list for subprocess 

131 

132 """ 

133 cmd = editor_cmd.copy() 

134 cmd.append(file_path) 

135 

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) 

139 

140 return cmd 

141 

142 @staticmethod 

143 def _supports_cursor_hint(editor: str) -> bool: 

144 """Check if editor supports cursor positioning hints. 

145 

146 Args: 

147 editor: Editor command name 

148 

149 Returns: 

150 True if editor supports hints, False otherwise 

151 

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) 

156 

157 @staticmethod 

158 def _add_cursor_hint(cmd: list[str], cursor_hint: str) -> list[str]: 

159 """Add cursor positioning hint to command. 

160 

161 Args: 

162 cmd: Current command list 

163 cursor_hint: Cursor positioning hint 

164 

165 Returns: 

166 Modified command list with cursor hint 

167 

168 """ 

169 editor = cmd[0].lower() 

170 

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}') 

176 

177 elif 'nano' in editor: 

178 # Nano format: +line_number 

179 if cursor_hint.isdigit(): 

180 cmd.insert(-1, f'+{cursor_hint}') 

181 

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') 

186 

187 # For other editors, cursor_hint is ignored 

188 return cmd