rfmt 1.3.0-x86_64-linux-musl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/lib/rfmt/cache.rb ADDED
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'fileutils'
5
+
6
+ module Rfmt
7
+ # Cache system for formatted files
8
+ # Uses mtime (modification time) to determine if formatting is needed
9
+ class Cache
10
+ class CacheError < StandardError; end
11
+
12
+ DEFAULT_CACHE_DIR = File.expand_path('~/.cache/rfmt').freeze
13
+ CACHE_VERSION = '1'
14
+
15
+ attr_reader :cache_dir
16
+
17
+ def initialize(cache_dir: DEFAULT_CACHE_DIR)
18
+ @cache_dir = cache_dir
19
+ @cache_data = {}
20
+ ensure_cache_dir
21
+ load_cache
22
+ end
23
+
24
+ # Check if file needs formatting
25
+ # Returns true if file mtime has changed or not in cache
26
+ def needs_formatting?(file_path)
27
+ return true unless File.exist?(file_path)
28
+
29
+ current_mtime = File.mtime(file_path).to_i
30
+ cached_mtime = @cache_data.dig(file_path, 'mtime')
31
+
32
+ current_mtime != cached_mtime
33
+ end
34
+
35
+ # Mark file as formatted with current mtime
36
+ def mark_formatted(file_path)
37
+ return unless File.exist?(file_path)
38
+
39
+ @cache_data[file_path] = {
40
+ 'mtime' => File.mtime(file_path).to_i,
41
+ 'formatted_at' => Time.now.to_i,
42
+ 'version' => CACHE_VERSION
43
+ }
44
+ end
45
+
46
+ # Save cache to disk
47
+ def save
48
+ cache_file = File.join(@cache_dir, 'cache.json')
49
+ File.write(cache_file, JSON.pretty_generate(@cache_data))
50
+ end
51
+
52
+ # Clear all cache data
53
+ def clear
54
+ @cache_data = {}
55
+ save
56
+ end
57
+
58
+ # Remove cache for specific file
59
+ def invalidate(file_path)
60
+ @cache_data.delete(file_path)
61
+ end
62
+
63
+ # Get cache statistics
64
+ def stats
65
+ {
66
+ total_files: @cache_data.size,
67
+ cache_dir: @cache_dir,
68
+ cache_size_bytes: cache_size
69
+ }
70
+ end
71
+
72
+ # Prune old cache entries (files that no longer exist)
73
+ def prune
74
+ before_count = @cache_data.size
75
+ @cache_data.delete_if { |file_path, _| !File.exist?(file_path) }
76
+ after_count = @cache_data.size
77
+ pruned = before_count - after_count
78
+
79
+ save if pruned.positive?
80
+ pruned
81
+ end
82
+
83
+ private
84
+
85
+ def ensure_cache_dir
86
+ FileUtils.mkdir_p(@cache_dir)
87
+ rescue StandardError => e
88
+ raise CacheError, "Failed to create cache directory: #{e.message}"
89
+ end
90
+
91
+ def load_cache
92
+ cache_file = File.join(@cache_dir, 'cache.json')
93
+ return unless File.exist?(cache_file)
94
+
95
+ content = File.read(cache_file)
96
+ @cache_data = JSON.parse(content)
97
+ rescue JSON::ParserError => e
98
+ warn "Warning: Failed to parse cache file, starting with empty cache: #{e.message}"
99
+ @cache_data = {}
100
+ rescue StandardError => e
101
+ warn "Warning: Failed to load cache, starting with empty cache: #{e.message}"
102
+ @cache_data = {}
103
+ end
104
+
105
+ def cache_size
106
+ cache_file = File.join(@cache_dir, 'cache.json')
107
+ return 0 unless File.exist?(cache_file)
108
+
109
+ File.size(cache_file)
110
+ end
111
+ end
112
+ end
data/lib/rfmt/cli.rb ADDED
@@ -0,0 +1,308 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'thor'
4
+
5
+ # Check for verbose flag before loading rfmt to set debug mode early
6
+ ENV['RFMT_DEBUG'] = '1' if ARGV.include?('-v') || ARGV.include?('--verbose')
7
+
8
+ require 'rfmt'
9
+ require 'rfmt/configuration'
10
+ require 'rfmt/cache'
11
+
12
+ module Rfmt
13
+ # Cache management commands
14
+ class CacheCommands < Thor
15
+ desc 'clear', 'Clear all cache data'
16
+ option :cache_dir, type: :string, desc: 'Cache directory (default: ~/.cache/rfmt)'
17
+ def clear
18
+ cache_opts = options[:cache_dir] ? { cache_dir: options[:cache_dir] } : {}
19
+ cache = Cache.new(**cache_opts)
20
+ cache.clear
21
+ say 'Cache cleared', :green
22
+ end
23
+
24
+ desc 'stats', 'Show cache statistics'
25
+ option :cache_dir, type: :string, desc: 'Cache directory (default: ~/.cache/rfmt)'
26
+ def stats
27
+ cache_opts = options[:cache_dir] ? { cache_dir: options[:cache_dir] } : {}
28
+ cache = Cache.new(**cache_opts)
29
+ stats = cache.stats
30
+ say "Cache directory: #{stats[:cache_dir]}", :blue
31
+ say "Total files in cache: #{stats[:total_files]}", :blue
32
+ say "Cache size: #{(stats[:cache_size_bytes] / 1024.0).round(2)} KB", :blue
33
+ end
34
+
35
+ desc 'prune', 'Remove cache entries for files that no longer exist'
36
+ option :cache_dir, type: :string, desc: 'Cache directory (default: ~/.cache/rfmt)'
37
+ def prune
38
+ cache_opts = options[:cache_dir] ? { cache_dir: options[:cache_dir] } : {}
39
+ cache = Cache.new(**cache_opts)
40
+ pruned = cache.prune
41
+ say "Pruned #{pruned} stale cache entries", :green
42
+ end
43
+ end
44
+
45
+ # Command Line Interface for rfmt
46
+ class CLI < Thor
47
+ class_option :config, type: :string, desc: 'Path to configuration file'
48
+ class_option :verbose, type: :boolean, aliases: '-v', desc: 'Verbose output'
49
+
50
+ default_command :format
51
+
52
+ desc 'format [FILES]', 'Format Ruby files (default command)'
53
+ option :write, type: :boolean, default: true, desc: 'Write formatted output'
54
+ option :check, type: :boolean, desc: "Check if files are formatted (don't write)"
55
+ option :diff, type: :boolean, desc: 'Show diff of changes'
56
+ option :diff_format, type: :string, default: 'unified', desc: 'Diff format: unified, side_by_side, or color'
57
+ option :parallel, type: :boolean, default: true, desc: 'Process files in parallel'
58
+ option :jobs, type: :numeric, desc: 'Number of parallel jobs (default: CPU count)'
59
+ option :cache, type: :boolean, default: true, desc: 'Use cache to skip unchanged files'
60
+ option :cache_dir, type: :string, desc: 'Cache directory (default: ~/.cache/rfmt)'
61
+ def format(*files)
62
+ config = load_config
63
+ files = files.empty? ? config.files_to_format : files.flatten
64
+
65
+ if files.empty?
66
+ say 'No files to format', :yellow
67
+ return
68
+ end
69
+
70
+ # Initialize cache
71
+ cache = if options[:cache]
72
+ cache_opts = options[:cache_dir] ? { cache_dir: options[:cache_dir] } : {}
73
+ Cache.new(**cache_opts)
74
+ end
75
+
76
+ # Filter files using cache
77
+ if cache
78
+ original_count = files.size
79
+ files = files.select { |file| cache.needs_formatting?(file) }
80
+ skipped = original_count - files.size
81
+ say "ℹ Skipped #{skipped} unchanged file(s) (cached)", :cyan if skipped.positive? && options[:verbose]
82
+ end
83
+
84
+ if files.empty?
85
+ say '✓ All files are already formatted (cached)', :green
86
+ return
87
+ end
88
+
89
+ # Show progress message
90
+ if files.size == 1
91
+ say "Processing #{files.first}...", :blue
92
+ else
93
+ say "Processing #{files.size} file(s)...", :blue
94
+ end
95
+
96
+ results = if options[:parallel] && files.size > 1
97
+ format_files_parallel(files)
98
+ else
99
+ format_files_sequential(files)
100
+ end
101
+ handle_results(results, cache)
102
+ end
103
+
104
+ desc 'check [FILES]', 'Check if files need formatting'
105
+ def check(*files)
106
+ invoke :format, files, check: true, write: false
107
+ end
108
+
109
+ desc 'version', 'Show version'
110
+ def version
111
+ say "rfmt #{Rfmt::VERSION}"
112
+ say "Rust extension: #{Rfmt.rust_version}"
113
+ end
114
+
115
+ desc 'config', 'Show current configuration'
116
+ def config_cmd
117
+ config = load_config
118
+ require 'json'
119
+ say JSON.pretty_generate(config.config)
120
+ end
121
+
122
+ desc 'cache SUBCOMMAND', 'Manage cache'
123
+ subcommand 'cache', CacheCommands
124
+
125
+ desc 'init', 'Initialize rfmt configuration'
126
+ option :force, type: :boolean, desc: 'Overwrite existing configuration'
127
+ option :path, type: :string, default: '.rfmt.yml', desc: 'Configuration file path'
128
+ def init
129
+ config_file = options[:path] || '.rfmt.yml'
130
+
131
+ # Use Rfmt::Config module for consistent behavior
132
+ result = Rfmt::Config.init(config_file, force: options[:force] || false)
133
+
134
+ if result
135
+ say "Created #{config_file}", :green
136
+ else
137
+ say "Configuration file already exists at #{config_file}. Use --force to overwrite.", :yellow
138
+ end
139
+ end
140
+
141
+ private
142
+
143
+ def load_config
144
+ if options[:config]
145
+ Configuration.new(file: options[:config])
146
+ else
147
+ Configuration.discover
148
+ end
149
+ end
150
+
151
+ def format_files_sequential(files)
152
+ files.map do |file|
153
+ format_single_file(file)
154
+ end
155
+ end
156
+
157
+ def format_files_parallel(files)
158
+ require 'parallel'
159
+
160
+ # Determine number of processes to use
161
+ process_count = options[:jobs] || Parallel.processor_count
162
+
163
+ say "Processing #{files.size} files with #{process_count} parallel jobs...", :blue if options[:verbose]
164
+
165
+ Parallel.map(files, in_processes: process_count) do |file|
166
+ format_single_file(file)
167
+ end
168
+ end
169
+
170
+ def format_single_file(file)
171
+ start_time = Time.now
172
+ source = File.read(file)
173
+
174
+ formatted = Rfmt.format(source)
175
+ changed = source != formatted
176
+
177
+ {
178
+ file: file,
179
+ changed: changed,
180
+ original: source,
181
+ formatted: formatted,
182
+ duration: Time.now - start_time,
183
+ error: nil
184
+ }
185
+ rescue StandardError => e
186
+ {
187
+ file: file,
188
+ error: e.message,
189
+ duration: Time.now - start_time
190
+ }
191
+ end
192
+
193
+ def handle_results(results, cache = nil)
194
+ failed_count = 0
195
+ changed_count = 0
196
+ error_count = 0
197
+
198
+ results.each do |result|
199
+ if result[:error]
200
+ say "Error in #{result[:file]}: #{result[:error]}", :red
201
+ error_count += 1
202
+ next
203
+ end
204
+
205
+ if result[:changed]
206
+ changed_count += 1
207
+
208
+ if options[:check]
209
+ say "#{result[:file]} needs formatting", :yellow
210
+ failed_count += 1
211
+ show_diff(result[:file], result[:original], result[:formatted]) if options[:diff]
212
+ elsif options[:diff]
213
+ show_diff(result[:file], result[:original], result[:formatted])
214
+ elsif options[:write]
215
+ File.write(result[:file], result[:formatted])
216
+ # Always show formatted files (not just in verbose mode)
217
+ say "✓ Formatted #{result[:file]}", :green
218
+
219
+ # Update cache after successful write
220
+ cache&.mark_formatted(result[:file])
221
+ else
222
+ puts result[:formatted]
223
+ end
224
+ else
225
+ # Show already formatted files in non-check mode
226
+ say "✓ #{result[:file]} already formatted", :cyan unless options[:check]
227
+
228
+ # Update cache even if no changes (file was checked)
229
+ cache&.mark_formatted(result[:file])
230
+ end
231
+ end
232
+
233
+ # Save cache to disk
234
+ cache&.save
235
+
236
+ # Summary - always show a summary message
237
+ if error_count.positive?
238
+ say "\n✗ Failed: #{error_count} error(s) occurred", :red
239
+ elsif options[:check] && failed_count.positive?
240
+ say "\n✗ Check failed: #{failed_count} file(s) need formatting", :yellow
241
+ elsif changed_count.positive?
242
+ # Success message with appropriate details
243
+ say "\n✓ Success! Formatted #{changed_count} file(s)", :green
244
+ elsif results.size == 1
245
+ say "\n✓ Success! File is already formatted", :green
246
+ else
247
+ say "\n✓ Success! All #{results.size} files are already formatted", :green
248
+ end
249
+
250
+ # Detailed summary in verbose mode
251
+ if options[:verbose]
252
+ say "Total: #{results.size} file(s) processed", :blue
253
+ say "Changed: #{changed_count} file(s)", :yellow if changed_count.positive?
254
+ end
255
+
256
+ exit(1) if (options[:check] && failed_count.positive?) || error_count.positive?
257
+ end
258
+
259
+ def show_diff(file, original, formatted)
260
+ require 'diffy'
261
+
262
+ say "\n#{'=' * 80}", :blue
263
+ say "Diff for #{file}:", :yellow
264
+ say '=' * 80, :blue
265
+
266
+ case options[:diff_format]
267
+ when 'unified'
268
+ diff = Diffy::Diff.new(original, formatted, context: 3)
269
+ puts diff.to_s(:color)
270
+ when 'side_by_side'
271
+ diff = Diffy::Diff.new(original, formatted, context: 3)
272
+ # Side-by-side is not well supported in terminal, use unified with more context
273
+ puts diff.to_s(:color)
274
+ when 'color'
275
+ show_colored_line_diff(original, formatted)
276
+ else
277
+ diff = Diffy::Diff.new(original, formatted, context: 3)
278
+ puts diff.to_s(:color)
279
+ end
280
+
281
+ say "#{'=' * 80}\n", :blue
282
+ end
283
+
284
+ def show_colored_line_diff(original, formatted)
285
+ require 'diff/lcs'
286
+
287
+ original_lines = original.split("\n")
288
+ formatted_lines = formatted.split("\n")
289
+
290
+ diffs = Diff::LCS.sdiff(original_lines, formatted_lines)
291
+
292
+ diffs.each_with_index do |diff, idx|
293
+ line_num = idx + 1
294
+ case diff.action
295
+ when '-'
296
+ say "#{line_num}: - #{diff.old_element}", :red
297
+ when '+'
298
+ say "#{line_num}: + #{diff.new_element}", :green
299
+ when '='
300
+ say "#{line_num}: #{diff.old_element}", :white
301
+ when '!'
302
+ say "#{line_num}: - #{diff.old_element}", :red
303
+ say "#{line_num}: + #{diff.new_element}", :green
304
+ end
305
+ end
306
+ end
307
+ end
308
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+
5
+ module Rfmt
6
+ # Configuration management for rfmt
7
+ class Configuration
8
+ class ConfigError < StandardError; end
9
+
10
+ DEFAULT_CONFIG = {
11
+ 'version' => '1.0',
12
+ 'formatting' => {
13
+ 'line_length' => 100,
14
+ 'indent_width' => 2,
15
+ 'indent_style' => 'spaces'
16
+ },
17
+ 'include' => ['**/*.rb'],
18
+ 'exclude' => ['vendor/**/*', 'tmp/**/*', 'node_modules/**/*']
19
+ }.freeze
20
+
21
+ CONFIG_FILES = ['rfmt.yml', 'rfmt.yaml', '.rfmt.yml', '.rfmt.yaml'].freeze
22
+
23
+ attr_reader :config
24
+
25
+ def initialize(options = {})
26
+ @config = load_configuration(options)
27
+ end
28
+
29
+ # Discover configuration file in current directory
30
+ def self.discover
31
+ config_file = CONFIG_FILES.find { |file| File.exist?(file) }
32
+ config_file ? new(file: config_file) : new
33
+ end
34
+
35
+ # Get list of files to format based on include/exclude patterns
36
+ def files_to_format(base_path: '.')
37
+ include_patterns = @config['include']
38
+ exclude_patterns = @config['exclude']
39
+
40
+ included_files = include_patterns.flat_map { |pattern| Dir.glob(File.join(base_path, pattern)) }
41
+ excluded_files = exclude_patterns.flat_map { |pattern| Dir.glob(File.join(base_path, pattern)) }
42
+
43
+ (included_files - excluded_files).select { |f| File.file?(f) }
44
+ end
45
+
46
+ # Get formatting configuration
47
+ def formatting_config
48
+ @config['formatting']
49
+ end
50
+
51
+ private
52
+
53
+ def load_configuration(options)
54
+ config = deep_dup(DEFAULT_CONFIG)
55
+
56
+ # Load from file if specified
57
+ if (file = options[:file] || options['file'])
58
+ file_config = YAML.load_file(file)
59
+ config = deep_merge(config, file_config)
60
+ end
61
+
62
+ # Override with options
63
+ options.delete(:file)
64
+ options.delete('file')
65
+ config = deep_merge(config, options) unless options.empty?
66
+
67
+ validate_config!(config)
68
+ config
69
+ end
70
+
71
+ def validate_config!(config)
72
+ line_length = config.dig('formatting', 'line_length')
73
+ raise ConfigError, 'line_length must be positive' if line_length && line_length <= 0
74
+
75
+ indent_width = config.dig('formatting', 'indent_width')
76
+ raise ConfigError, 'indent_width must be positive' if indent_width && indent_width <= 0
77
+ end
78
+
79
+ def deep_merge(hash1, hash2)
80
+ hash1.merge(hash2) do |_key, old_val, new_val|
81
+ if old_val.is_a?(Hash) && new_val.is_a?(Hash)
82
+ deep_merge(old_val, new_val)
83
+ else
84
+ new_val
85
+ end
86
+ end
87
+ end
88
+
89
+ def deep_dup(hash)
90
+ hash.transform_values do |value|
91
+ value.is_a?(Hash) ? deep_dup(value) : value.dup
92
+ end
93
+ end
94
+ end
95
+ end