44This hook applies editorconfig formatting rules after file edits.
55"""
66
7+ import hashlib
78import json
89import os
910import subprocess
1011import sys
12+ import time
1113from pathlib import Path
1214
1315
@@ -27,37 +29,152 @@ def is_source_file(file_path):
2729 return path .suffix .lower () in extensions
2830
2931
32+ def get_file_hash (file_path ):
33+ """Get SHA256 hash of file contents"""
34+ try :
35+ with open (file_path , 'rb' ) as f :
36+ return hashlib .sha256 (f .read ()).hexdigest ()
37+ except Exception :
38+ return None
39+
40+
41+ def detect_formatting_changes (file_path , before_hash , after_hash ):
42+ """Detect what types of formatting changes were made"""
43+ if before_hash == after_hash :
44+ return []
45+
46+ changes = []
47+
48+ try :
49+ # Read the file to analyze changes
50+ with open (file_path , 'r' , encoding = 'utf-8' , errors = 'ignore' ) as f :
51+ content = f .read ()
52+
53+ # Detect common formatting changes
54+ if content .endswith ('\n ' ) and not content .endswith ('\n \n ' ):
55+ changes .append ('final_newline' )
56+
57+ if '\t ' in content :
58+ changes .append ('indentation' )
59+
60+ if content != content .rstrip ():
61+ changes .append ('trailing_whitespace' )
62+
63+ if '\r \n ' in content :
64+ changes .append ('line_endings' )
65+
66+ # If we can't detect specific changes, just note something changed
67+ if not changes :
68+ changes .append ('formatting' )
69+
70+ except Exception :
71+ changes = ['formatting' ]
72+
73+ return changes
74+
75+
76+ def get_eclint_version ():
77+ """Get eclint version if available"""
78+ try :
79+ result = subprocess .run (['eclint' , '--version' ], capture_output = True , text = True )
80+ if result .returncode == 0 :
81+ return result .stdout .strip ()
82+ except Exception :
83+ pass
84+ return None
85+
86+
3087def format_with_editorconfig (file_path ):
3188 """Apply editorconfig formatting to a file"""
89+ start_time = time .time ()
90+ eclint_installed = False
91+
3292 try :
93+ # Get file hash before formatting
94+ before_hash = get_file_hash (file_path )
95+
3396 # Check if eclint is available
3497 result = subprocess .run (['which' , 'eclint' ], capture_output = True , text = True )
3598 if result .returncode != 0 :
3699 print ("eclint not found. Installing via npm..." , file = sys .stderr )
100+ install_start = time .time ()
37101 install_result = subprocess .run (['npm' , 'install' , '-g' , 'eclint' ],
38102 capture_output = True , text = True )
39103 if install_result .returncode != 0 :
40- return False , "Failed to install eclint"
104+ execution_time = time .time () - start_time
105+ return {
106+ 'success' : False ,
107+ 'error' : 'Failed to install eclint' ,
108+ 'error_details' : install_result .stderr ,
109+ 'execution_time' : execution_time ,
110+ 'suggestions' : [
111+ 'Install Node.js and npm if not available' ,
112+ 'Check npm global install permissions' ,
113+ 'Try: sudo npm install -g eclint'
114+ ]
115+ }
116+ eclint_installed = True
117+ print (f"eclint installed successfully in { time .time () - install_start :.1f} s" , file = sys .stderr )
41118
42119 # Apply editorconfig formatting
43120 format_result = subprocess .run (['eclint' , 'fix' , file_path ],
44121 capture_output = True , text = True )
45122
123+ # Get file hash after formatting
124+ after_hash = get_file_hash (file_path )
125+ execution_time = time .time () - start_time
126+
46127 if format_result .returncode == 0 :
47- return True , f"Applied editorconfig formatting to { file_path } "
128+ changes = detect_formatting_changes (file_path , before_hash , after_hash )
129+ eclint_version = get_eclint_version ()
130+
131+ return {
132+ 'success' : True ,
133+ 'changes_applied' : changes ,
134+ 'changes_made' : len (changes ) > 0 ,
135+ 'execution_time' : execution_time ,
136+ 'eclint_installed' : eclint_installed ,
137+ 'eclint_version' : eclint_version ,
138+ 'file_changed' : before_hash != after_hash
139+ }
48140 else :
49- return False , f"eclint failed: { format_result .stderr } "
141+ return {
142+ 'success' : False ,
143+ 'error' : 'eclint formatting failed' ,
144+ 'error_details' : format_result .stderr ,
145+ 'execution_time' : execution_time ,
146+ 'suggestions' : [
147+ 'Check .editorconfig file syntax' ,
148+ 'Verify file permissions' ,
149+ 'Review eclint documentation'
150+ ]
151+ }
50152
51153 except Exception as e :
52- return False , f"Error formatting file: { str (e )} "
154+ execution_time = time .time () - start_time
155+ return {
156+ 'success' : False ,
157+ 'error' : f'Exception during formatting: { str (e )} ' ,
158+ 'execution_time' : execution_time ,
159+ 'suggestions' : [
160+ 'Check file exists and is readable' ,
161+ 'Verify system permissions' ,
162+ 'Review hook configuration'
163+ ]
164+ }
53165
54166
55167def main ():
56168 try :
57169 input_data = json .load (sys .stdin )
58170 except json .JSONDecodeError as e :
59- print (f"Error: Invalid JSON input: { e } " , file = sys .stderr )
60- sys .exit (1 )
171+ error_output = {
172+ "decision" : "block" ,
173+ "reason" : f"EditorConfig hook received invalid JSON input: { e } " ,
174+ "suggestions" : ["Check Claude Code hook configuration" ]
175+ }
176+ print (json .dumps (error_output ))
177+ sys .exit (0 )
61178
62179 hook_event = input_data .get ("hook_event_name" , "" )
63180 tool_name = input_data .get ("tool_name" , "" )
@@ -75,20 +192,56 @@ def main():
75192 if not os .path .exists (file_path ):
76193 sys .exit (0 )
77194
78- success , message = format_with_editorconfig (file_path )
195+ # Apply editorconfig formatting with enhanced reporting
196+ result = format_with_editorconfig (file_path )
197+ filename = os .path .basename (file_path )
198+
199+ if result ['success' ]:
200+ # Generate rich success message
201+ if result ['changes_made' ]:
202+ changes_text = ', ' .join (result ['changes_applied' ])
203+ time_text = f" ({ result ['execution_time' ]:.1f} s)"
204+ system_message = f"✓ EditorConfig: { changes_text } applied to { filename } { time_text } "
205+ else :
206+ time_text = f" ({ result ['execution_time' ]:.1f} s)"
207+ system_message = f"✓ EditorConfig: no changes needed for { filename } { time_text } "
208+
209+ # Add installation note if eclint was installed
210+ if result ['eclint_installed' ]:
211+ system_message += " (eclint auto-installed)"
79212
80- if success :
81- # Use JSON output to suppress the normal stdout display
82213 output = {
83214 "suppressOutput" : True ,
84- "systemMessage" : f"✓ EditorConfig formatting applied to { os .path .basename (file_path )} "
215+ "systemMessage" : system_message ,
216+ "formattingResults" : {
217+ "changesApplied" : result ['changes_applied' ],
218+ "changesMade" : result ['changes_made' ],
219+ "executionTime" : result ['execution_time' ],
220+ "eclintInstalled" : result ['eclint_installed' ],
221+ "eclintVersion" : result .get ('eclint_version' ),
222+ "fileChanged" : result ['file_changed' ]
223+ }
85224 }
86225 print (json .dumps (output ))
87226 sys .exit (0 )
88227 else :
89- # Non-blocking error - show message but don't fail
90- print (f"Warning: { message } " , file = sys .stderr )
91- sys .exit (1 )
228+ # Enhanced error output with structured information
229+ error_message = f"EditorConfig formatting failed for { filename } : { result ['error' ]} "
230+
231+ output = {
232+ "decision" : "block" ,
233+ "reason" : error_message ,
234+ "stopReason" : f"Code formatting issues in { filename } " ,
235+ "formattingError" : {
236+ "errorType" : result ['error' ],
237+ "errorDetails" : result .get ('error_details' , '' ),
238+ "executionTime" : result ['execution_time' ],
239+ "suggestions" : result .get ('suggestions' , []),
240+ "filename" : filename
241+ }
242+ }
243+ print (json .dumps (output ))
244+ sys .exit (0 )
92245
93246
94247if __name__ == "__main__" :
0 commit comments