|
| 1 | +#!/usr/bin/env python3 |
| 2 | +""" |
| 3 | +IntelliJ IDEA formatter hook for Nextflow development. |
| 4 | +This hook applies IDEA code formatting to Groovy files after edits. |
| 5 | +""" |
| 6 | + |
| 7 | +import hashlib |
| 8 | +import json |
| 9 | +import os |
| 10 | +import subprocess |
| 11 | +import sys |
| 12 | +import time |
| 13 | +from pathlib import Path |
| 14 | + |
| 15 | + |
| 16 | +def is_groovy_file(file_path): |
| 17 | + """Check if the file is a Groovy source file""" |
| 18 | + if not file_path: |
| 19 | + return False |
| 20 | + |
| 21 | + path = Path(file_path) |
| 22 | + |
| 23 | + # Skip build directories, .git, etc. |
| 24 | + if any(part.startswith('.') or part == 'build' for part in path.parts): |
| 25 | + return False |
| 26 | + |
| 27 | + # Only process Groovy files |
| 28 | + return path.suffix.lower() == '.groovy' |
| 29 | + |
| 30 | + |
| 31 | +def get_file_hash(file_path): |
| 32 | + """Get SHA256 hash of file contents""" |
| 33 | + try: |
| 34 | + with open(file_path, 'rb') as f: |
| 35 | + return hashlib.sha256(f.read()).hexdigest() |
| 36 | + except Exception: |
| 37 | + return None |
| 38 | + |
| 39 | + |
| 40 | +def find_idea_sh(): |
| 41 | + """Locate idea.sh command""" |
| 42 | + # Check environment variable first |
| 43 | + idea_sh = os.environ.get('IDEA_SH') |
| 44 | + if idea_sh and os.path.exists(idea_sh): |
| 45 | + return idea_sh |
| 46 | + |
| 47 | + # Common installation paths for macOS |
| 48 | + common_paths = [ |
| 49 | + '/Applications/IntelliJ IDEA.app/Contents/MacOS/idea', |
| 50 | + '/Applications/IntelliJ IDEA CE.app/Contents/MacOS/idea', |
| 51 | + '/Applications/IntelliJ IDEA Ultimate.app/Contents/MacOS/idea', |
| 52 | + # Common paths for Linux |
| 53 | + '/usr/local/bin/idea.sh', |
| 54 | + '/opt/idea/bin/idea.sh', |
| 55 | + '/snap/intellij-idea-community/current/bin/idea.sh', |
| 56 | + '/snap/intellij-idea-ultimate/current/bin/idea.sh', |
| 57 | + # Check user's home for JetBrains Toolbox installations |
| 58 | + os.path.expanduser('~/.local/share/JetBrains/Toolbox/apps/IDEA-U/bin/idea.sh'), |
| 59 | + os.path.expanduser('~/.local/share/JetBrains/Toolbox/apps/IDEA-C/bin/idea.sh'), |
| 60 | + ] |
| 61 | + |
| 62 | + for path in common_paths: |
| 63 | + if os.path.exists(path): |
| 64 | + return path |
| 65 | + |
| 66 | + # Try to find in PATH |
| 67 | + try: |
| 68 | + result = subprocess.run(['which', 'idea'], capture_output=True, text=True) |
| 69 | + if result.returncode == 0: |
| 70 | + idea_path = result.stdout.strip() |
| 71 | + if os.path.exists(idea_path): |
| 72 | + return idea_path |
| 73 | + except Exception: |
| 74 | + pass |
| 75 | + |
| 76 | + return None |
| 77 | + |
| 78 | + |
| 79 | +def format_with_idea(file_path, idea_sh_path): |
| 80 | + """Apply IDEA formatting to a Groovy file""" |
| 81 | + start_time = time.time() |
| 82 | + |
| 83 | + try: |
| 84 | + # Get file hash before formatting |
| 85 | + before_hash = get_file_hash(file_path) |
| 86 | + |
| 87 | + # Run IDEA formatter |
| 88 | + # Using format.sh script which is typically alongside idea.sh |
| 89 | + idea_dir = os.path.dirname(idea_sh_path) |
| 90 | + format_script = None |
| 91 | + |
| 92 | + # Try to find format.sh in the same directory |
| 93 | + possible_format_scripts = [ |
| 94 | + os.path.join(idea_dir, 'format.sh'), |
| 95 | + os.path.join(os.path.dirname(idea_dir), 'bin', 'format.sh'), |
| 96 | + ] |
| 97 | + |
| 98 | + for script in possible_format_scripts: |
| 99 | + if os.path.exists(script): |
| 100 | + format_script = script |
| 101 | + break |
| 102 | + |
| 103 | + # If no format.sh found, use idea.sh with format command |
| 104 | + if format_script: |
| 105 | + command = [format_script, '-allowDefaults', file_path] |
| 106 | + else: |
| 107 | + command = [idea_sh_path, 'format', '-allowDefaults', file_path] |
| 108 | + |
| 109 | + format_result = subprocess.run( |
| 110 | + command, |
| 111 | + capture_output=True, |
| 112 | + text=True, |
| 113 | + timeout=30 |
| 114 | + ) |
| 115 | + |
| 116 | + # Get file hash after formatting |
| 117 | + after_hash = get_file_hash(file_path) |
| 118 | + execution_time = time.time() - start_time |
| 119 | + |
| 120 | + # Check if formatting succeeded |
| 121 | + # IDEA formatter typically returns 0 on success |
| 122 | + if format_result.returncode == 0: |
| 123 | + return { |
| 124 | + 'success': True, |
| 125 | + 'changes_made': before_hash != after_hash, |
| 126 | + 'execution_time': execution_time, |
| 127 | + 'file_changed': before_hash != after_hash, |
| 128 | + 'idea_path': idea_sh_path, |
| 129 | + 'command': ' '.join(command) |
| 130 | + } |
| 131 | + else: |
| 132 | + return { |
| 133 | + 'success': False, |
| 134 | + 'error': 'IDEA formatting failed', |
| 135 | + 'error_details': format_result.stderr or format_result.stdout, |
| 136 | + 'execution_time': execution_time, |
| 137 | + 'command': ' '.join(command), |
| 138 | + 'suggestions': [ |
| 139 | + 'Verify IDEA installation and configuration', |
| 140 | + 'Check file permissions', |
| 141 | + 'Ensure IDEA is not already running the formatter' |
| 142 | + ] |
| 143 | + } |
| 144 | + |
| 145 | + except subprocess.TimeoutExpired: |
| 146 | + execution_time = time.time() - start_time |
| 147 | + return { |
| 148 | + 'success': False, |
| 149 | + 'error': 'IDEA formatting timed out after 30 seconds', |
| 150 | + 'execution_time': execution_time, |
| 151 | + 'suggestions': [ |
| 152 | + 'Check if IDEA is responsive', |
| 153 | + 'Try closing and reopening IDEA', |
| 154 | + 'Check system resources' |
| 155 | + ] |
| 156 | + } |
| 157 | + except Exception as e: |
| 158 | + execution_time = time.time() - start_time |
| 159 | + return { |
| 160 | + 'success': False, |
| 161 | + 'error': f'Exception during formatting: {str(e)}', |
| 162 | + 'execution_time': execution_time, |
| 163 | + 'suggestions': [ |
| 164 | + 'Check file exists and is readable', |
| 165 | + 'Verify system permissions', |
| 166 | + 'Review hook configuration' |
| 167 | + ] |
| 168 | + } |
| 169 | + |
| 170 | + |
| 171 | +def main(): |
| 172 | + try: |
| 173 | + input_data = json.load(sys.stdin) |
| 174 | + except json.JSONDecodeError as e: |
| 175 | + error_output = { |
| 176 | + "decision": "block", |
| 177 | + "reason": f"IDEA formatter hook received invalid JSON input: {e}", |
| 178 | + "suggestions": ["Check Claude Code hook configuration"] |
| 179 | + } |
| 180 | + print(json.dumps(error_output)) |
| 181 | + sys.exit(0) |
| 182 | + |
| 183 | + hook_event = input_data.get("hook_event_name", "") |
| 184 | + tool_name = input_data.get("tool_name", "") |
| 185 | + tool_input = input_data.get("tool_input", {}) |
| 186 | + |
| 187 | + # Only process Edit, Write, MultiEdit tools |
| 188 | + if tool_name not in ["Edit", "Write", "MultiEdit"]: |
| 189 | + sys.exit(0) |
| 190 | + |
| 191 | + file_path = tool_input.get("file_path", "") |
| 192 | + if not file_path or not is_groovy_file(file_path): |
| 193 | + sys.exit(0) |
| 194 | + |
| 195 | + # Check if file exists after the edit |
| 196 | + if not os.path.exists(file_path): |
| 197 | + sys.exit(0) |
| 198 | + |
| 199 | + # Find IDEA installation |
| 200 | + idea_sh_path = find_idea_sh() |
| 201 | + if not idea_sh_path: |
| 202 | + # If IDEA is not found, silently skip (don't block the workflow) |
| 203 | + # This allows the hook to be optional |
| 204 | + output = { |
| 205 | + "suppressOutput": True, |
| 206 | + "systemMessage": "⚠ IDEA formatter: idea.sh not found (set IDEA_SH environment variable)", |
| 207 | + "formattingResults": { |
| 208 | + "skipped": True, |
| 209 | + "reason": "IDEA not found" |
| 210 | + } |
| 211 | + } |
| 212 | + print(json.dumps(output)) |
| 213 | + sys.exit(0) |
| 214 | + |
| 215 | + # Apply IDEA formatting |
| 216 | + result = format_with_idea(file_path, idea_sh_path) |
| 217 | + filename = os.path.basename(file_path) |
| 218 | + |
| 219 | + if result['success']: |
| 220 | + # Generate success message |
| 221 | + if result['changes_made']: |
| 222 | + time_text = f" ({result['execution_time']:.1f}s)" |
| 223 | + system_message = f"✓ IDEA formatter: applied to {filename}{time_text}" |
| 224 | + else: |
| 225 | + time_text = f" ({result['execution_time']:.1f}s)" |
| 226 | + system_message = f"✓ IDEA formatter: no changes needed for {filename}{time_text}" |
| 227 | + |
| 228 | + output = { |
| 229 | + "suppressOutput": True, |
| 230 | + "systemMessage": system_message, |
| 231 | + "formattingResults": { |
| 232 | + "changesMade": result['changes_made'], |
| 233 | + "executionTime": result['execution_time'], |
| 234 | + "ideaPath": result['idea_path'], |
| 235 | + "fileChanged": result['file_changed'] |
| 236 | + } |
| 237 | + } |
| 238 | + print(json.dumps(output)) |
| 239 | + sys.exit(0) |
| 240 | + else: |
| 241 | + # Enhanced error output |
| 242 | + error_message = f"IDEA formatting failed for {filename}: {result['error']}" |
| 243 | + |
| 244 | + output = { |
| 245 | + "decision": "block", |
| 246 | + "reason": error_message, |
| 247 | + "stopReason": f"Code formatting issues in {filename}", |
| 248 | + "formattingError": { |
| 249 | + "errorType": result['error'], |
| 250 | + "errorDetails": result.get('error_details', ''), |
| 251 | + "executionTime": result['execution_time'], |
| 252 | + "suggestions": result.get('suggestions', []), |
| 253 | + "filename": filename, |
| 254 | + "command": result.get('command', '') |
| 255 | + } |
| 256 | + } |
| 257 | + print(json.dumps(output)) |
| 258 | + sys.exit(0) |
| 259 | + |
| 260 | + |
| 261 | +if __name__ == "__main__": |
| 262 | + main() |
0 commit comments