better_translate 1.0.0 → 1.1.0

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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +28 -0
  3. data/.rubocop_todo.yml +291 -0
  4. data/CHANGELOG.md +88 -0
  5. data/README.md +262 -2
  6. data/RELEASE_NOTES_v1.0.0.md +240 -0
  7. data/Steepfile +2 -2
  8. data/docs/implementation/00-overview.md +1 -1
  9. data/docs/implementation/04-provider_architecture.md +5 -5
  10. data/lib/better_translate/analyzer/code_scanner.rb +151 -0
  11. data/lib/better_translate/analyzer/key_scanner.rb +109 -0
  12. data/lib/better_translate/analyzer/orphan_detector.rb +88 -0
  13. data/lib/better_translate/analyzer/reporter.rb +155 -0
  14. data/lib/better_translate/cache.rb +2 -1
  15. data/lib/better_translate/cli.rb +81 -2
  16. data/lib/better_translate/configuration.rb +48 -2
  17. data/lib/better_translate/errors.rb +9 -0
  18. data/lib/better_translate/json_handler.rb +227 -0
  19. data/lib/better_translate/providers/anthropic_provider.rb +4 -3
  20. data/lib/better_translate/providers/chatgpt_provider.rb +2 -1
  21. data/lib/better_translate/providers/gemini_provider.rb +5 -4
  22. data/lib/better_translate/railtie.rb +2 -1
  23. data/lib/better_translate/rate_limiter.rb +4 -1
  24. data/lib/better_translate/strategies/batch_strategy.rb +1 -1
  25. data/lib/better_translate/strategies/deep_strategy.rb +1 -1
  26. data/lib/better_translate/translator.rb +204 -19
  27. data/lib/better_translate/utils/hash_flattener.rb +2 -2
  28. data/lib/better_translate/variable_extractor.rb +7 -7
  29. data/lib/better_translate/version.rb +1 -1
  30. data/lib/better_translate/yaml_handler.rb +59 -0
  31. data/lib/better_translate.rb +5 -0
  32. data/lib/generators/better_translate/analyze/analyze_generator.rb +2 -1
  33. data/lib/generators/better_translate/install/install_generator.rb +4 -3
  34. data/lib/generators/better_translate/install/templates/initializer.rb.tt +39 -7
  35. data/lib/generators/better_translate/translate/translate_generator.rb +2 -1
  36. data/regenerate_vcr.rb +47 -0
  37. data/sig/better_translate/configuration.rbs +13 -2
  38. data/sig/better_translate/errors.rbs +4 -0
  39. data/sig/better_translate/providers/base_http_provider.rbs +1 -1
  40. data/sig/better_translate/translator.rbs +12 -1
  41. data/sig/better_translate/variable_extractor.rbs +1 -1
  42. data/sig/better_translate/yaml_handler.rbs +6 -0
  43. data/sig/better_translate.rbs +2 -1
  44. metadata +9 -1
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module BetterTranslate
6
+ module Analyzer
7
+ # Scans code files to find i18n key references
8
+ #
9
+ # Supports multiple patterns:
10
+ # - t('key') / t("key")
11
+ # - I18n.t(:key) / I18n.t('key')
12
+ # - <%= t('key') %> in ERB
13
+ #
14
+ # @example Basic usage
15
+ # scanner = CodeScanner.new("app/")
16
+ # keys = scanner.scan
17
+ # #=> #<Set: {"users.greeting", "products.list"}>
18
+ #
19
+ class CodeScanner
20
+ # I18n patterns to match
21
+ #
22
+ # Matches:
23
+ # - t('users.greeting')
24
+ # - t("users.greeting")
25
+ # - I18n.t(:users.greeting)
26
+ # - I18n.t('users.greeting')
27
+ # - I18n.translate('users.greeting')
28
+ I18N_PATTERNS = [
29
+ /\bt\(['"]([a-z0-9_.]+)['"]/i, # t('key') or t("key")
30
+ /\bI18n\.t\(:([a-z0-9_.]+)/i, # I18n.t(:key)
31
+ /\bI18n\.t\(['"]([a-z0-9_.]+)['"]/i, # I18n.t('key')
32
+ /\bI18n\.translate\(['"]([a-z0-9_.]+)['"]/i # I18n.translate('key')
33
+ ].freeze
34
+
35
+ # File extensions to scan
36
+ SCANNABLE_EXTENSIONS = %w[.rb .erb .html.erb .haml .slim].freeze
37
+
38
+ # @return [String] Path to scan (file or directory)
39
+ attr_reader :path
40
+
41
+ # @return [Set] Found i18n keys
42
+ attr_reader :keys
43
+
44
+ # @return [Array<String>] List of scanned files
45
+ attr_reader :files_scanned
46
+
47
+ # Initialize scanner with path
48
+ #
49
+ # @param path [String] File or directory path to scan
50
+ #
51
+ def initialize(path)
52
+ @path = path
53
+ @keys = Set.new
54
+ @files_scanned = []
55
+ end
56
+
57
+ # Scan path and extract i18n keys
58
+ #
59
+ # @return [Set] Set of found i18n keys
60
+ # @raise [FileError] if path does not exist
61
+ #
62
+ # @example
63
+ # scanner = CodeScanner.new("app/")
64
+ # keys = scanner.scan
65
+ # #=> #<Set: {"users.greeting"}>
66
+ #
67
+ def scan
68
+ validate_path!
69
+
70
+ files = collect_files
71
+ files.each do |file|
72
+ scan_file(file)
73
+ @files_scanned << file
74
+ end
75
+
76
+ @keys
77
+ end
78
+
79
+ # Get count of unique keys found
80
+ #
81
+ # @return [Integer] Number of unique keys
82
+ #
83
+ def key_count
84
+ @keys.size
85
+ end
86
+
87
+ private
88
+
89
+ # Validate that path exists
90
+ #
91
+ # @raise [FileError] if path does not exist
92
+ #
93
+ def validate_path!
94
+ return if File.exist?(path)
95
+
96
+ raise FileError.new(
97
+ "Path does not exist: #{path}",
98
+ context: { path: path }
99
+ )
100
+ end
101
+
102
+ # Collect all scannable files from path
103
+ #
104
+ # @return [Array<String>] List of file paths
105
+ #
106
+ def collect_files
107
+ if File.file?(path)
108
+ return [path] if scannable_file?(path)
109
+
110
+ return []
111
+ end
112
+
113
+ Dir.glob(File.join(path, "**", "*")).select do |file|
114
+ File.file?(file) && scannable_file?(file)
115
+ end
116
+ end
117
+
118
+ # Check if file should be scanned
119
+ #
120
+ # @param file [String] File path
121
+ # @return [Boolean]
122
+ #
123
+ def scannable_file?(file)
124
+ SCANNABLE_EXTENSIONS.any? { |ext| file.end_with?(ext) }
125
+ end
126
+
127
+ # Scan single file and extract keys
128
+ #
129
+ # @param file [String] File path
130
+ #
131
+ def scan_file(file)
132
+ content = File.read(file)
133
+
134
+ # Remove commented lines to avoid false positives
135
+ lines = content.split("\n")
136
+ active_lines = lines.reject { |line| line.strip.start_with?("#", "//", "<!--") }
137
+ active_content = active_lines.join("\n")
138
+
139
+ I18N_PATTERNS.each do |pattern|
140
+ active_content.scan(pattern) do |match|
141
+ key = match.is_a?(Array) ? match.first : match
142
+ @keys.add(key) if key
143
+ end
144
+ end
145
+ rescue StandardError => e
146
+ # Skip files that can't be read
147
+ warn "Warning: Could not scan #{file}: #{e.message}" if ENV["VERBOSE"]
148
+ end
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module BetterTranslate
6
+ module Analyzer
7
+ # Scans YAML translation files and extracts all keys in flatten format
8
+ #
9
+ # @example Basic usage
10
+ # scanner = KeyScanner.new("config/locales/en.yml")
11
+ # keys = scanner.scan
12
+ # #=> { "users.greeting" => "Hello", "users.welcome" => "Welcome %{name}" }
13
+ #
14
+ class KeyScanner
15
+ # @return [String] Path to the YAML file
16
+ attr_reader :file_path
17
+
18
+ # @return [Hash] Flatten keys extracted from YAML
19
+ attr_reader :keys
20
+
21
+ # Initialize scanner with YAML file path
22
+ #
23
+ # @param file_path [String] Path to YAML file
24
+ #
25
+ def initialize(file_path)
26
+ @file_path = file_path
27
+ @keys = {}
28
+ end
29
+
30
+ # Scan YAML file and extract all flatten keys
31
+ #
32
+ # @return [Hash] Flatten keys with their values
33
+ # @raise [FileError] if file does not exist
34
+ # @raise [YamlError] if YAML is invalid
35
+ #
36
+ # @example
37
+ # scanner = KeyScanner.new("en.yml")
38
+ # keys = scanner.scan
39
+ # #=> { "users.greeting" => "Hello" }
40
+ #
41
+ def scan
42
+ validate_file!
43
+
44
+ begin
45
+ content = YAML.load_file(file_path)
46
+
47
+ # Skip root language key (en, it, fr, etc.) and start from its content
48
+ if content.is_a?(Hash) && content.size == 1
49
+ root_key = content.keys.first.to_s
50
+ content = content[root_key] || {} if root_key.match?(/^[a-z]{2}(-[A-Z]{2})?$/)
51
+ end
52
+
53
+ flatten_keys(content)
54
+ rescue Psych::SyntaxError => e
55
+ raise YamlError.new(
56
+ "Invalid YAML syntax in #{file_path}",
57
+ context: { file: file_path, error: e.message }
58
+ )
59
+ end
60
+
61
+ @keys
62
+ end
63
+
64
+ # Get total count of keys
65
+ #
66
+ # @return [Integer] Number of keys
67
+ #
68
+ def key_count
69
+ @keys.size
70
+ end
71
+
72
+ private
73
+
74
+ # Validate that file exists
75
+ #
76
+ # @raise [FileError] if file does not exist
77
+ #
78
+ def validate_file!
79
+ return if File.exist?(file_path)
80
+
81
+ raise FileError.new(
82
+ "Translation file does not exist: #{file_path}",
83
+ context: { file: file_path }
84
+ )
85
+ end
86
+
87
+ # Flatten nested hash into dot-notation keys
88
+ #
89
+ # @param hash [Hash] Nested hash to flatten
90
+ # @param prefix [String] Prefix for current level
91
+ #
92
+ # @example
93
+ # flatten_keys({ "users" => { "greeting" => "Hello" } })
94
+ # #=> { "users.greeting" => "Hello" }
95
+ #
96
+ def flatten_keys(hash, prefix = nil)
97
+ hash.each do |key, value|
98
+ current_key = prefix ? "#{prefix}.#{key}" : key.to_s
99
+
100
+ if value.is_a?(Hash)
101
+ flatten_keys(value, current_key)
102
+ else
103
+ @keys[current_key] = value
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterTranslate
4
+ module Analyzer
5
+ # Detects orphan i18n keys (keys defined but never used in code)
6
+ #
7
+ # @example Basic usage
8
+ # all_keys = { "users.greeting" => "Hello", "orphan" => "Unused" }
9
+ # used_keys = Set.new(["users.greeting"])
10
+ #
11
+ # detector = OrphanDetector.new(all_keys, used_keys)
12
+ # orphans = detector.detect
13
+ # #=> ["orphan"]
14
+ #
15
+ class OrphanDetector
16
+ # @return [Hash] All translation keys with their values
17
+ attr_reader :all_keys
18
+
19
+ # @return [Set] Keys that are used in code
20
+ attr_reader :used_keys
21
+
22
+ # @return [Array<String>] Orphan keys (not used in code)
23
+ attr_reader :orphans
24
+
25
+ # Initialize detector
26
+ #
27
+ # @param all_keys [Hash] All translation keys from YAML files
28
+ # @param used_keys [Set] Keys found in code
29
+ #
30
+ def initialize(all_keys, used_keys)
31
+ @all_keys = all_keys
32
+ @used_keys = used_keys
33
+ @orphans = []
34
+ end
35
+
36
+ # Detect orphan keys
37
+ #
38
+ # Compares all keys with used keys and identifies those that are never referenced.
39
+ #
40
+ # @return [Array<String>] List of orphan key names
41
+ #
42
+ # @example
43
+ # detector.detect
44
+ # #=> ["orphan_key", "another.orphan"]
45
+ #
46
+ def detect
47
+ @orphans = all_keys.keys.reject { |key| used_keys.include?(key) }
48
+ end
49
+
50
+ # Get count of orphan keys
51
+ #
52
+ # @return [Integer] Number of orphan keys
53
+ #
54
+ def orphan_count
55
+ @orphans.size
56
+ end
57
+
58
+ # Get details of orphan keys with their values
59
+ #
60
+ # @return [Hash] Hash of orphan keys and their translation values
61
+ #
62
+ # @example
63
+ # detector.orphan_details
64
+ # #=> { "orphan_key" => "This is never used" }
65
+ #
66
+ def orphan_details
67
+ @orphans.each_with_object({}) do |key, details|
68
+ details[key] = all_keys[key]
69
+ end
70
+ end
71
+
72
+ # Calculate usage percentage
73
+ #
74
+ # @return [Float] Percentage of keys that are used (0.0 to 100.0)
75
+ #
76
+ # @example
77
+ # detector.usage_percentage
78
+ # #=> 75.0 # 6 out of 8 keys are used
79
+ #
80
+ def usage_percentage
81
+ return 0.0 if all_keys.empty?
82
+
83
+ used_count = all_keys.size - @orphans.size
84
+ (used_count.to_f / all_keys.size * 100).round(1)
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "csv"
5
+
6
+ module BetterTranslate
7
+ module Analyzer
8
+ # Generates reports for orphan key analysis
9
+ #
10
+ # Supports multiple output formats: text, JSON, CSV
11
+ #
12
+ # @example Basic usage
13
+ # reporter = Reporter.new(
14
+ # orphans: ["orphan_key"],
15
+ # orphan_details: { "orphan_key" => "Unused" },
16
+ # total_keys: 10,
17
+ # used_keys: 9,
18
+ # usage_percentage: 90.0,
19
+ # format: :text
20
+ # )
21
+ # puts reporter.generate
22
+ #
23
+ class Reporter
24
+ # @return [Array<String>] List of orphan keys
25
+ attr_reader :orphans
26
+
27
+ # @return [Hash] Orphan keys with their values
28
+ attr_reader :orphan_details
29
+
30
+ # @return [Integer] Total number of keys
31
+ attr_reader :total_keys
32
+
33
+ # @return [Integer] Number of used keys
34
+ attr_reader :used_keys
35
+
36
+ # @return [Float] Usage percentage
37
+ attr_reader :usage_percentage
38
+
39
+ # @return [Symbol] Output format (:text, :json, :csv)
40
+ attr_reader :format
41
+
42
+ # Initialize reporter
43
+ #
44
+ # @param orphans [Array<String>] List of orphan keys
45
+ # @param orphan_details [Hash] Orphan keys with values
46
+ # @param total_keys [Integer] Total number of keys
47
+ # @param used_keys [Integer] Number of used keys
48
+ # @param usage_percentage [Float] Usage percentage
49
+ # @param format [Symbol] Output format (:text, :json, :csv)
50
+ #
51
+ def initialize(orphans:, orphan_details:, total_keys:, used_keys:, usage_percentage:, format: :text)
52
+ @orphans = orphans
53
+ @orphan_details = orphan_details
54
+ @total_keys = total_keys
55
+ @used_keys = used_keys
56
+ @usage_percentage = usage_percentage
57
+ @format = format
58
+ end
59
+
60
+ # Generate report in specified format
61
+ #
62
+ # @return [String] Generated report
63
+ #
64
+ def generate
65
+ case format
66
+ when :json
67
+ generate_json
68
+ when :csv
69
+ generate_csv
70
+ else
71
+ generate_text
72
+ end
73
+ end
74
+
75
+ # Save report to file
76
+ #
77
+ # @param file_path [String] Output file path
78
+ #
79
+ def save_to_file(file_path)
80
+ File.write(file_path, generate)
81
+ end
82
+
83
+ private
84
+
85
+ # Generate text format report
86
+ #
87
+ # @return [String] Text report
88
+ #
89
+ def generate_text
90
+ lines = []
91
+ lines << "=" * 60
92
+ lines << "Orphan Keys Analysis Report"
93
+ lines << "=" * 60
94
+ lines << ""
95
+ lines << "Statistics:"
96
+ lines << " Total keys: #{total_keys}"
97
+ lines << " Used keys: #{used_keys}"
98
+ lines << " Orphan keys: #{orphans.size}"
99
+ lines << " Usage: #{usage_percentage}%"
100
+ lines << ""
101
+
102
+ if orphans.empty?
103
+ lines << "✓ No orphan keys found! All translation keys are being used."
104
+ else
105
+ lines << "Orphan Keys (#{orphans.size}):"
106
+ lines << "-" * 60
107
+
108
+ orphans.each do |key|
109
+ value = orphan_details[key]
110
+ lines << ""
111
+ lines << " Key: #{key}"
112
+ lines << " Value: #{value}" if value
113
+ end
114
+ end
115
+
116
+ lines << ""
117
+ lines << "=" * 60
118
+
119
+ lines.join("\n")
120
+ end
121
+
122
+ # Generate JSON format report
123
+ #
124
+ # @return [String] JSON report
125
+ #
126
+ def generate_json
127
+ data = {
128
+ orphans: orphans,
129
+ orphan_details: orphan_details,
130
+ orphan_count: orphans.size,
131
+ total_keys: total_keys,
132
+ used_keys: used_keys,
133
+ usage_percentage: usage_percentage
134
+ }
135
+
136
+ JSON.pretty_generate(data)
137
+ end
138
+
139
+ # Generate CSV format report
140
+ #
141
+ # @return [String] CSV report
142
+ #
143
+ def generate_csv
144
+ CSV.generate do |csv|
145
+ csv << %w[Key Value]
146
+
147
+ orphans.each do |key|
148
+ value = orphan_details[key]
149
+ csv << [key, value]
150
+ end
151
+ end
152
+ end
153
+ end
154
+ end
155
+ end
@@ -52,7 +52,8 @@ module BetterTranslate
52
52
  entry = @cache[key]
53
53
 
54
54
  # Check TTL
55
- if @ttl && entry && Time.now - entry[:timestamp] > @ttl
55
+ ttl_value = @ttl
56
+ if ttl_value && entry && Time.now - entry[:timestamp] > ttl_value
56
57
  @cache.delete(key)
57
58
  return nil
58
59
  end
@@ -53,6 +53,8 @@ module BetterTranslate
53
53
  run_generate
54
54
  when "direct"
55
55
  run_direct
56
+ when "analyze"
57
+ run_analyze
56
58
  when "--version", "-v"
57
59
  puts "BetterTranslate version #{VERSION}"
58
60
  when "--help", "-h", nil
@@ -79,6 +81,7 @@ module BetterTranslate
79
81
  translate Translate YAML files using config file
80
82
  generate OUTPUT_FILE Generate sample config file
81
83
  direct TEXT Translate text directly
84
+ analyze Analyze YAML files for orphan keys
82
85
 
83
86
  Options:
84
87
  --help, -h Show this help message
@@ -88,6 +91,7 @@ module BetterTranslate
88
91
  better_translate translate --config config.yml
89
92
  better_translate generate config.yml
90
93
  better_translate direct "Hello" --to it --provider chatgpt --api-key KEY
94
+ better_translate analyze --source config/locales/en.yml --scan-path app/
91
95
  HELP
92
96
  end
93
97
 
@@ -221,8 +225,8 @@ module BetterTranslate
221
225
  "dry_run" => false,
222
226
  "translation_mode" => "override",
223
227
  "preserve_variables" => true,
224
- "global_exclusions" => Array.new,
225
- "exclusions_per_language" => Hash.new,
228
+ "global_exclusions" => [],
229
+ "exclusions_per_language" => {},
226
230
  "model" => nil,
227
231
  "temperature" => 0.3,
228
232
  "max_tokens" => 2000,
@@ -300,5 +304,80 @@ module BetterTranslate
300
304
  rescue StandardError => e
301
305
  puts "Error: #{e.message}"
302
306
  end
307
+
308
+ # Run analyze command
309
+ #
310
+ # @return [void]
311
+ # @api private
312
+ #
313
+ def run_analyze
314
+ # @type var options: Hash[Symbol, String]
315
+ options = {}
316
+ OptionParser.new do |opts|
317
+ opts.on("--source FILE", "Source YAML file path") { |v| options[:source] = v }
318
+ opts.on("--scan-path PATH", "Path to scan for code files") { |v| options[:scan_path] = v }
319
+ opts.on("--format FORMAT", "Output format (text, json, csv)") { |v| options[:format] = v }
320
+ opts.on("--output FILE", "Output file path") { |v| options[:output] = v }
321
+ end.parse!(args[1..])
322
+
323
+ # Validate required options
324
+ unless options[:source]
325
+ puts "Error: --source is required"
326
+ return
327
+ end
328
+
329
+ unless options[:scan_path]
330
+ puts "Error: --scan-path is required"
331
+ return
332
+ end
333
+
334
+ # Validate paths exist
335
+ unless File.exist?(options[:source])
336
+ puts "Error: Source file not found: #{options[:source]}"
337
+ return
338
+ end
339
+
340
+ unless File.exist?(options[:scan_path])
341
+ puts "Error: Scan path not found: #{options[:scan_path]}"
342
+ return
343
+ end
344
+
345
+ # Default format
346
+ format = (options[:format] || "text").to_sym
347
+
348
+ # Scan keys from YAML
349
+ key_scanner = Analyzer::KeyScanner.new(options[:source])
350
+ all_keys = key_scanner.scan
351
+
352
+ # Scan code for used keys
353
+ code_scanner = Analyzer::CodeScanner.new(options[:scan_path])
354
+ used_keys = code_scanner.scan
355
+
356
+ # Detect orphans
357
+ detector = Analyzer::OrphanDetector.new(all_keys, used_keys)
358
+ orphans = detector.detect
359
+
360
+ # Generate report
361
+ reporter = Analyzer::Reporter.new(
362
+ orphans: orphans,
363
+ orphan_details: detector.orphan_details,
364
+ total_keys: all_keys.size,
365
+ used_keys: used_keys.size,
366
+ usage_percentage: detector.usage_percentage,
367
+ format: format
368
+ )
369
+
370
+ report = reporter.generate
371
+
372
+ # Output or save
373
+ if options[:output]
374
+ reporter.save_to_file(options[:output])
375
+ puts "Report saved to #{options[:output]}"
376
+ else
377
+ puts report
378
+ end
379
+ rescue StandardError => e
380
+ puts "Error: #{e.message}"
381
+ end
303
382
  end
304
383
  end