Files
gh-kylesnowschwartz-simplec…/hooks/handlers/auto_format_handler.rb
2025-11-30 08:36:27 +08:00

245 lines
6.9 KiB
Ruby
Executable File

#!/usr/bin/env ruby
# frozen_string_literal: true
require 'claude_hooks'
require 'open3'
require 'shellwords'
# Auto Format Handler
#
# PURPOSE: Automatically format/lint files after Write, Edit, or MultiEdit operations
# DESIGN: Lightweight, obvious, maintainable using claude_hooks DSL patterns
# ALIGNMENT: Mirrors pre-commit configuration for consistency
#
# SUPPORTED FORMATTERS:
# - Ruby: RuboCop with auto-correct (-A flag)
# - Markdown: markdownlint with --fix (matching pre-commit rules)
# - Shell: shfmt with 2-space indentation (matching pre-commit args)
# - Lua: stylua with custom configuration (160 column width, Unix line endings, etc.)
# - Rust: rustfmt with default configuration
# - Python: ruff format (automatically uses existing Black/isort/flake8 configs)
# - YAML: yamlfmt with default configuration, prettier as fallback
# - JavaScript/TypeScript: eslint with --fix, prettier as fallback
class AutoFormatHandler < ClaudeHooks::PostToolUse
DEFAULT_SKIP_PATTERNS = %w[
node_modules/
dist/
build/
.git/
*.min.js
*.min.css
vendor/
tmp/
.bundle/
].freeze
def call
log "Auto-format handler triggered for #{tool_name}"
return output_data unless should_process_tool?
return output_data unless file_path_available?
return output_data if should_skip_file?
perform_formatting
output_data
end
private
def should_process_tool?
file_modification_tools.include?(tool_name) && tool_successful?
end
def file_modification_tools
%w[Write Edit MultiEdit]
end
def tool_successful?
return false if tool_response&.dig('error')
return false if tool_response&.dig('isError')
return false if tool_response&.dig('success') == false
return false if tool_response&.dig('exitCode')&.nonzero?
true
end
def file_path_available?
return true if current_file_path && File.exist?(current_file_path)
log "File path not available or doesn't exist: #{current_file_path}", level: :warn
false
end
def current_file_path
@current_file_path ||= tool_input&.dig('file_path')
end
def should_skip_file?
return false unless current_file_path
relative_path = relative_file_path
# Check against simple, obvious skip patterns
if skip_patterns.any? { |pattern| matches_skip_pattern?(relative_path, pattern) }
log "Skipping #{relative_path} - matches ignore pattern"
return true
end
false
end
def relative_file_path
# Use claude_hooks cwd helper instead of Dir.pwd
File.expand_path(current_file_path).sub("#{cwd}/", '')
end
def skip_patterns
DEFAULT_SKIP_PATTERNS
end
def matches_skip_pattern?(file_path, pattern)
if pattern.end_with?('/')
# Directory pattern
file_path.start_with?(pattern[0..-2])
elsif pattern.include?('*')
# Simple glob pattern using Ruby's built-in File.fnmatch
File.fnmatch(pattern, file_path)
else
# Exact match or basename match
file_path == pattern || File.basename(file_path) == pattern
end
end
def perform_formatting
formatter = detect_formatter
unless formatter
log "No formatter available for #{current_file_path}"
return
end
log "Formatting #{current_file_path} with #{formatter[:name]}"
result = run_formatter(formatter)
if result[:success]
log "Successfully formatted #{current_file_path}"
add_success_feedback(formatter[:name])
else
log "Formatting failed: #{result[:error]}", level: :error
add_error_feedback(formatter[:name], result[:error])
end
end
def detect_formatter
extension = File.extname(current_file_path).downcase
case extension
when '.rb'
command_available?('rubocop') ? { name: 'RuboCop', command: 'rubocop', args: ['-A'] } : nil
when '.md'
if command_available?('markdownlint')
{
name: 'markdownlint',
command: 'markdownlint',
args: [
'--fix',
'--disable',
'MD013,MD041,MD026,MD012,MD024'
]
}
end
when '.sh', '.bash'
if command_available?('shfmt')
{
name: 'shfmt',
command: 'shfmt',
args: ['-w', '-i', '2'] # 2-space indentation
}
end
when '.lua'
if command_available?('stylua')
{
name: 'stylua',
command: 'stylua',
args: [
'--column-width', '160',
'--line-endings', 'Unix',
'--indent-type', 'Spaces',
'--indent-width', '2',
'--quote-style', 'AutoPreferSingle',
'--call-parentheses', 'None'
]
}
end
when '.rs'
command_available?('rustfmt') ? { name: 'rustfmt', command: 'rustfmt', args: [] } : nil
when '.py'
command_available?('ruff') ? { name: 'ruff', command: 'ruff', args: ['format'] } : nil
when '.yml', '.yaml'
if command_available?('yamlfmt')
{ name: 'yamlfmt', command: 'yamlfmt', args: ['-w'] }
elsif command_available?('prettier')
{ name: 'prettier', command: 'prettier', args: ['--write', '--parser', 'yaml'] }
end
when '.js', '.jsx', '.ts', '.tsx', '.json'
if command_available?('eslint')
{ name: 'eslint', command: 'eslint', args: ['--fix'] }
elsif command_available?('prettier')
{ name: 'prettier', command: 'prettier', args: ['--write'] }
end
end
end
def command_available?(command)
# Cache command availability to avoid repeated system calls
@command_cache ||= {}
return @command_cache[command] if @command_cache.key?(command)
@command_cache[command] = system("which #{command} > /dev/null 2>&1")
end
def run_formatter(formatter)
command_parts = [formatter[:command]] + formatter[:args] + [current_file_path]
command = command_parts.map { |part| Shellwords.escape(part) }.join(' ')
log "Running: #{command}"
stdout_err, status = Open3.capture2e(command)
if status.success?
{ success: true, output: stdout_err }
else
{ success: false, error: stdout_err.strip }
end
rescue StandardError => e
{ success: false, error: e.message }
end
def add_success_feedback(formatter_name)
# Use output_data hash directly - obvious and DSL-idiomatic
output_data['feedback'] ||= []
output_data['feedback'] << "✓ Auto-formatted with #{formatter_name}"
end
def add_error_feedback(formatter_name, error)
output_data['feedback'] ||= []
output_data['feedback'] << "⚠ Auto-formatting failed (#{formatter_name}): #{error}"
end
end
# Testing support - claude_hooks DSL pattern
if __FILE__ == $PROGRAM_NAME
ClaudeHooks::CLI.test_runner(AutoFormatHandler) do |input_data|
input_data['tool_name'] = 'Write'
input_data['tool_input'] = { 'file_path' => '/tmp/test.rb' }
input_data['tool_response'] = { 'success' => true }
input_data['session_id'] = 'test-session-01'
# Create a test file for demonstration
File.write('/tmp/test.rb', "def hello\nputs 'world'\nend")
end
end