i18n-context-generator 0.4.0 → 0.5.1

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 (61) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +163 -26
  3. data/lib/i18n_context_generator/android_resource.rb +209 -0
  4. data/lib/i18n_context_generator/apple_string_literal.rb +28 -0
  5. data/lib/i18n_context_generator/cache.rb +40 -8
  6. data/lib/i18n_context_generator/changed_location.rb +55 -0
  7. data/lib/i18n_context_generator/cli.rb +163 -76
  8. data/lib/i18n_context_generator/config/cli_values.rb +33 -0
  9. data/lib/i18n_context_generator/config/defaults.rb +35 -0
  10. data/lib/i18n_context_generator/config/schema.rb +225 -0
  11. data/lib/i18n_context_generator/config/serialization.rb +64 -0
  12. data/lib/i18n_context_generator/config/validation.rb +249 -0
  13. data/lib/i18n_context_generator/config.rb +293 -102
  14. data/lib/i18n_context_generator/context_extractor/cache_identity.rb +65 -0
  15. data/lib/i18n_context_generator/context_extractor/extraction_result.rb +46 -0
  16. data/lib/i18n_context_generator/context_extractor/run_logging.rb +45 -6
  17. data/lib/i18n_context_generator/context_extractor/source_entries.rb +103 -0
  18. data/lib/i18n_context_generator/context_extractor/source_filters.rb +51 -5
  19. data/lib/i18n_context_generator/context_extractor/translation_filters.rb +91 -0
  20. data/lib/i18n_context_generator/context_extractor/workflow.rb +118 -0
  21. data/lib/i18n_context_generator/context_extractor.rb +207 -216
  22. data/lib/i18n_context_generator/file_classifier.rb +72 -0
  23. data/lib/i18n_context_generator/generated_comment.rb +32 -0
  24. data/lib/i18n_context_generator/git_diff/xml_changes.rb +235 -0
  25. data/lib/i18n_context_generator/git_diff.rb +224 -95
  26. data/lib/i18n_context_generator/llm/anthropic.rb +65 -38
  27. data/lib/i18n_context_generator/llm/client.rb +304 -92
  28. data/lib/i18n_context_generator/llm/openai.rb +92 -51
  29. data/lib/i18n_context_generator/llm/openai_compatible.rb +20 -0
  30. data/lib/i18n_context_generator/llm/prompt_evidence.rb +47 -0
  31. data/lib/i18n_context_generator/llm/request_policy.rb +147 -0
  32. data/lib/i18n_context_generator/localization_syntax.rb +246 -0
  33. data/lib/i18n_context_generator/parsers/android_xml_parser.rb +47 -8
  34. data/lib/i18n_context_generator/parsers/base.rb +7 -4
  35. data/lib/i18n_context_generator/parsers/json_parser.rb +4 -0
  36. data/lib/i18n_context_generator/parsers/xcstrings_parser.rb +79 -0
  37. data/lib/i18n_context_generator/parsers/yaml_parser.rb +20 -7
  38. data/lib/i18n_context_generator/path_policy.rb +107 -0
  39. data/lib/i18n_context_generator/platform_validator.rb +24 -50
  40. data/lib/i18n_context_generator/run_metrics.rb +47 -0
  41. data/lib/i18n_context_generator/searcher/comment_masking.rb +115 -0
  42. data/lib/i18n_context_generator/searcher/match_filtering.rb +52 -0
  43. data/lib/i18n_context_generator/searcher/source_discovery.rb +109 -64
  44. data/lib/i18n_context_generator/searcher.rb +61 -205
  45. data/lib/i18n_context_generator/supplemental_context.rb +77 -0
  46. data/lib/i18n_context_generator/translation_comment_index.rb +121 -0
  47. data/lib/i18n_context_generator/version.rb +1 -1
  48. data/lib/i18n_context_generator/writers/android_xml_writer.rb +48 -18
  49. data/lib/i18n_context_generator/writers/atomic_file.rb +37 -0
  50. data/lib/i18n_context_generator/writers/csv_writer.rb +43 -18
  51. data/lib/i18n_context_generator/writers/helpers.rb +7 -29
  52. data/lib/i18n_context_generator/writers/json_writer.rb +31 -6
  53. data/lib/i18n_context_generator/writers/preview.rb +80 -0
  54. data/lib/i18n_context_generator/writers/result_serialization.rb +30 -0
  55. data/lib/i18n_context_generator/writers/strings_writer.rb +23 -12
  56. data/lib/i18n_context_generator/writers/swift_writer.rb +16 -54
  57. data/lib/i18n_context_generator/writers/xcstrings_writer.rb +57 -0
  58. data/lib/i18n_context_generator/xcstrings_document.rb +248 -0
  59. data/lib/i18n_context_generator/xml_scanner.rb +38 -0
  60. data/lib/i18n_context_generator.rb +20 -4
  61. metadata +59 -12
@@ -8,18 +8,57 @@ module I18nContextGenerator
8
8
 
9
9
  def log_empty_entries_message
10
10
  if @config.diff_base && translation_backed_discovery?
11
- puts "No changed translation keys found since #{@config.diff_base}."
11
+ log "No changed translation keys found in #{@config.diff_base}...#{@config.diff_head}."
12
12
  elsif @config.diff_base && source_discovery_filtered_by_diff?
13
- puts "No changed source localization entries found since #{@config.diff_base}."
13
+ log "No changed source localization entries found in #{@config.diff_base}...#{@config.diff_head}."
14
14
  else
15
- puts "No #{entry_label_for_logging} found."
15
+ log "No #{entry_label_for_logging} found."
16
16
  end
17
17
  end
18
18
 
19
19
  def log_loaded_entries(count)
20
- puts "Loaded #{count} #{entry_label_for_logging}"
21
- puts "(filtered to changes since #{@config.diff_base})" if @config.diff_base && translation_backed_discovery?
22
- puts "(filtered to source changes since #{@config.diff_base})" if source_discovery_filtered_by_diff?
20
+ log "Loaded #{count} #{entry_label_for_logging}"
21
+ range = "#{@config.diff_base}...#{@config.diff_head}"
22
+ log "(filtered to changes in #{range})" if @config.diff_base && translation_backed_discovery?
23
+ log "(filtered to source changes in #{range})" if source_discovery_filtered_by_diff?
24
+ end
25
+
26
+ def resolved_model
27
+ LLM::Client.default_model_for(@config.provider, configured_model: @config.model)
28
+ end
29
+
30
+ def log_metrics
31
+ summary = [
32
+ "Requests: #{@metrics.request_count}",
33
+ "cache hits: #{@metrics.cache_hits}",
34
+ "tokens: #{@metrics.input_tokens} in / #{@metrics.output_tokens} out",
35
+ "retries: #{@metrics.retry_count}"
36
+ ].join(', ')
37
+ if @metrics.estimated_cost_usd
38
+ summary += format(
39
+ ', estimated cost: $%<cost>.6f (standard list prices as of %<date>s)',
40
+ cost: @metrics.estimated_cost_usd,
41
+ date: @metrics.cost_pricing_as_of
42
+ )
43
+ end
44
+ log summary
45
+ end
46
+
47
+ def log(message = '')
48
+ return if @quiet
49
+
50
+ log_output.puts(message)
51
+ end
52
+
53
+ def log_output
54
+ @configured_log_output ||
55
+ (@config.output_stdout || workflow_stage == 'preview_diff' ? $stderr : $stdout)
56
+ end
57
+
58
+ def write_patch(patch)
59
+ output = @patch_output || $stdout
60
+ output.write(patch)
61
+ output.write("\n") unless patch.end_with?("\n")
23
62
  end
24
63
  end
25
64
  end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module I18nContextGenerator
4
+ class ContextExtractor
5
+ # Loads source-discovered entries and hydrates them from translation files.
6
+ module SourceEntries
7
+ private
8
+
9
+ def load_entries
10
+ case normalized_discovery_mode
11
+ when 'source'
12
+ load_source_entries
13
+ when 'translations'
14
+ load_translations
15
+ else
16
+ auto_discovery_entries
17
+ end
18
+ end
19
+
20
+ def auto_discovery_entries
21
+ return load_source_entries if @config.translations.empty?
22
+
23
+ load_translations
24
+ end
25
+
26
+ def load_source_entries
27
+ discovered_entries = filter_source_entries(searcher.discover_localization_entries)
28
+
29
+ discovered_entries.flat_map do |entry|
30
+ hydrated_entries = translation_entries_for_discovered(entry)
31
+ hydrated_entries = [nil] if hydrated_entries.empty?
32
+
33
+ hydrated_entries.map { |hydrated_entry| build_source_entry(entry, hydrated_entry) }
34
+ end
35
+ end
36
+
37
+ def build_source_entry(entry, hydrated_entry)
38
+ translation_comment = hydrated_entry&.metadata&.dig(:comment)
39
+ source_comment = entry.comment
40
+ metadata = hydrated_entry&.metadata&.dup || {}
41
+ metadata[:comment] = translation_comment || source_comment if translation_comment || source_comment
42
+ metadata[:source_location] = "#{entry.file}:#{entry.line}"
43
+ metadata[:source_locations] = entry.locations
44
+ metadata[:source_location_groups] = entry.location_groups
45
+ metadata[:resource_type] = entry.resource_type unless entry.resource_type == :string
46
+
47
+ Parsers::TranslationEntry.new(
48
+ key: hydrated_entry&.key || entry.key,
49
+ text: hydrated_entry&.text || entry.text || entry.key,
50
+ source_file: hydrated_entry&.source_file,
51
+ metadata: metadata
52
+ )
53
+ end
54
+
55
+ def load_translation_lookup
56
+ return @load_translation_lookup if defined?(@load_translation_lookup)
57
+
58
+ empty_lookup = Hash.new { |hash, key| hash[key] = [] }
59
+ @load_translation_lookup = load_translations.each_with_object(empty_lookup) do |entry, lookup|
60
+ lookup[entry.key] << entry
61
+ end
62
+ end
63
+
64
+ def load_translation_resource_lookup
65
+ return @load_translation_resource_lookup if defined?(@load_translation_resource_lookup)
66
+
67
+ empty_lookup = Hash.new { |hash, key| hash[key] = [] }
68
+ @load_translation_resource_lookup = load_translations.each_with_object(empty_lookup) do |entry, lookup|
69
+ metadata = entry.metadata || {}
70
+ if metadata[:plural]
71
+ lookup[[:plural, metadata[:plural]]] << entry
72
+ elsif metadata[:array]
73
+ lookup[[:array, metadata[:array]]] << entry
74
+ end
75
+ end
76
+ end
77
+
78
+ def translation_entries_for_discovered(entry)
79
+ case entry.resource_type
80
+ when :plural, :array
81
+ load_translation_resource_lookup[[entry.resource_type, entry.key]]
82
+ else
83
+ load_translation_lookup[entry.key]
84
+ end
85
+ end
86
+
87
+ def normalized_discovery_mode
88
+ @config.discovery_mode.to_s.downcase
89
+ end
90
+
91
+ def translation_backed_discovery?
92
+ return true if normalized_discovery_mode == 'translations'
93
+ return false if normalized_discovery_mode == 'source'
94
+
95
+ @config.translations.any?
96
+ end
97
+
98
+ def entry_label_for_logging
99
+ translation_backed_discovery? ? 'translation keys' : 'source localization entries'
100
+ end
101
+ end
102
+ end
103
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'pathname'
4
+
3
5
  module I18nContextGenerator
4
6
  class ContextExtractor
5
7
  # Helpers for filtering source-discovered entries without narrowing the full
@@ -21,7 +23,9 @@ module I18nContextGenerator
21
23
  return [] if line_filter.empty?
22
24
 
23
25
  entries.select do |entry|
24
- line_filter.fetch(entry.file.to_s, Set.new).include?(entry.line)
26
+ entry.locations.any? do |location|
27
+ changed_location?(location, line_filter)
28
+ end
25
29
  end
26
30
  end
27
31
 
@@ -31,11 +35,41 @@ module I18nContextGenerator
31
35
  @source_line_filter =
32
36
  if @config.source_line_filter
33
37
  normalize_source_line_filter(@config.source_line_filter)
34
- elsif @config.diff_base && source_discovery?
35
- GitDiff.new(base_ref: @config.diff_base).changed_lines(@config.source_paths)
38
+ elsif @config.diff_base
39
+ git_diff.changed_lines(@config.source_paths)
36
40
  end
37
41
  end
38
42
 
43
+ def changed_result_locations_for(locations)
44
+ line_filter = source_line_filter
45
+ return [] unless line_filter
46
+
47
+ locations.select do |location|
48
+ changed_location?(location, line_filter)
49
+ end
50
+ end
51
+
52
+ def changed_result_location_groups_for(entry, changed_locations)
53
+ return [] if changed_locations.empty?
54
+
55
+ location_groups = entry.metadata&.dig(:source_location_groups)
56
+ return changed_locations.map { |location| [location] } unless location_groups&.any?
57
+
58
+ changed_location_set = changed_locations.to_set
59
+ location_groups.filter_map do |group|
60
+ changed_group = Array(group).select { |location| changed_location_set.include?(location) }
61
+ changed_group unless changed_group.empty?
62
+ end
63
+ end
64
+
65
+ def changed_location_attributes_for(entry, locations)
66
+ changed_locations = changed_result_locations_for(locations)
67
+ {
68
+ changed_locations: changed_locations,
69
+ changed_location_groups: changed_result_location_groups_for(entry, changed_locations)
70
+ }
71
+ end
72
+
39
73
  def normalize_source_line_filter(filter)
40
74
  filter.each_with_object(Hash.new { |h, k| h[k] = Set.new }) do |(file, lines), normalized_filter|
41
75
  next if file.nil?
@@ -43,13 +77,25 @@ module I18nContextGenerator
43
77
  normalized_lines = Array(lines).filter_map { |line| Integer(line, exception: false) }
44
78
  next if normalized_lines.empty?
45
79
 
46
- normalized_filter[file.to_s].merge(normalized_lines)
80
+ normalized_filter[normalize_source_file(file)].merge(normalized_lines)
47
81
  end
48
82
  end
49
83
 
84
+ def changed_location?(location, line_filter)
85
+ file, _, line = location.rpartition(':')
86
+ line_filter.fetch(normalize_source_file(file), Set.new).include?(line.to_i)
87
+ end
88
+
89
+ def normalize_source_file(file)
90
+ Pathname.new(file.to_s).cleanpath.to_s
91
+ end
92
+
50
93
  def result_locations_for(entry, matches)
94
+ source_locations = entry.metadata&.dig(:source_locations)
95
+ return source_locations if source_locations&.any?
96
+
51
97
  source_location = entry.metadata&.dig(:source_location)
52
- return [source_location] if source_location
98
+ return Array(source_location) if source_location
53
99
 
54
100
  matches.map { |m| "#{m.file}:#{m.line}" }
55
101
  end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pathname'
4
+ require_relative '../android_resource'
5
+
6
+ module I18nContextGenerator
7
+ class ContextExtractor
8
+ # Filters translation-backed entries and retains their changed inline locations.
9
+ module TranslationFilters
10
+ private
11
+
12
+ def filter_by_diff(entries)
13
+ @android_collection_members_by_location = {}
14
+ @android_resource_indexes_by_file = {}
15
+ @changed_translation_locations = git_diff.changed_key_locations(@config.translations)
16
+
17
+ return [] if @changed_translation_locations.empty?
18
+
19
+ log "Found #{@changed_translation_locations.size} changed translation entries in git diff"
20
+
21
+ entries.select { |entry| changed_translation_locations_for(entry).any? }
22
+ end
23
+
24
+ # Extract the base resource name from composite Android keys.
25
+ def android_base_key(key)
26
+ AndroidResource.base_key(key)
27
+ end
28
+
29
+ def translation_key_for(entry)
30
+ metadata = entry.metadata || {}
31
+ metadata[:plural] || metadata[:array] || android_base_key(entry.key)
32
+ end
33
+
34
+ def changed_translation_locations_for(entry)
35
+ return [] unless @changed_translation_locations
36
+ return [] unless entry.source_file
37
+
38
+ source_file = Pathname.new(entry.source_file).cleanpath.to_s
39
+ locations = @changed_translation_locations.fetch([source_file, translation_key_for(entry)], [])
40
+ narrow_android_collection_locations(entry, locations)
41
+ end
42
+
43
+ def narrow_android_collection_locations(entry, locations)
44
+ metadata = entry.metadata || {}
45
+ return locations unless metadata[:plural] || metadata[:array]
46
+
47
+ members_by_location = locations.to_h do |location|
48
+ changed_location = ChangedLocation.parse(location)
49
+ line_number = changed_location.right? ? changed_location.line : changed_location.fallback_line
50
+ member = if line_number && metadata[:line_span]&.cover?(line_number)
51
+ entry.key
52
+ else
53
+ android_collection_member_at(changed_location, translation_key_for(entry))
54
+ end
55
+ [location, member]
56
+ end
57
+ return locations if members_by_location.values.compact.empty?
58
+
59
+ locations.select { |location| members_by_location[location] == entry.key }
60
+ end
61
+
62
+ def android_collection_member_at(location, expected_parent)
63
+ cache_key = [expected_parent, location]
64
+ return @android_collection_members_by_location[cache_key] if @android_collection_members_by_location&.key?(cache_key)
65
+
66
+ changed_location = ChangedLocation.parse(location)
67
+ file = changed_location.file
68
+ target_line = changed_location.right? ? changed_location.line : changed_location.fallback_line
69
+ return unless target_line
70
+
71
+ member = scan_android_collection_members(file, target_line, expected_parent) if File.file?(file)
72
+ @android_collection_members_by_location ||= {}
73
+ @android_collection_members_by_location[cache_key] = member
74
+ end
75
+
76
+ def scan_android_collection_members(file, target_line, expected_parent)
77
+ android_resource_index(file).member_at(target_line, parent: expected_parent)
78
+ end
79
+
80
+ def android_resource_index(file)
81
+ @android_resource_indexes_by_file ||= {}
82
+ @android_resource_indexes_by_file[file] ||=
83
+ AndroidResource.index(File.read(file, encoding: 'UTF-8'))
84
+ end
85
+
86
+ def git_diff
87
+ @git_diff ||= GitDiff.new(base_ref: @config.diff_base, head_ref: @config.diff_head)
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module I18nContextGenerator
4
+ class ContextExtractor
5
+ # Explicit non-mutating and mutating workflow stages shared by the CLI and
6
+ # programmatic callers.
7
+ module Workflow
8
+ private
9
+
10
+ def workflow_stage
11
+ @config.dry_run ? 'plan' : @config.workflow_stage
12
+ end
13
+
14
+ def pre_extraction_stage_handled?(entries)
15
+ case workflow_stage
16
+ when 'plan'
17
+ log_plan(entries)
18
+ true
19
+ when 'check'
20
+ check_entries(entries)
21
+ true
22
+ else
23
+ false
24
+ end
25
+ end
26
+
27
+ def log_plan(entries)
28
+ label = @config.dry_run ? 'Dry run' : 'Plan'
29
+ log "\n#{label} - would process these keys:"
30
+ entries.first(20).each { |entry| log " - #{entry.key}: #{truncate(entry.text, 50)}" }
31
+ log " ... and #{entries.size - 20} more" if entries.size > 20
32
+ destinations = []
33
+ destinations << (@config.output_stdout ? 'structured stdout' : @config.output_path) if @config.output_path
34
+ destinations << 'translation write-back' if @config.write_back
35
+ destinations << 'Swift code write-back' if @config.write_back_to_code
36
+ log "Destinations: #{destinations.empty? ? 'none' : destinations.join(', ')}"
37
+ end
38
+
39
+ def check_entries(entries)
40
+ without_usage = entries.count do |entry|
41
+ resource_type = entry.metadata&.dig(:resource_type)
42
+ searcher.search(entry.key, resource_type: resource_type).empty?
43
+ end
44
+ log "Check passed: #{entries.size} entries parsed; #{without_usage} without source usage."
45
+ end
46
+
47
+ def deliver_results
48
+ if workflow_stage == 'preview_diff'
49
+ preview_changes
50
+ else
51
+ apply_results
52
+ end
53
+ end
54
+
55
+ def apply_results
56
+ write_configured_output
57
+ write_back_to_source if @config.write_back
58
+ write_back_to_code if @config.write_back_to_code
59
+ end
60
+
61
+ def write_configured_output
62
+ return unless @config.output_path
63
+
64
+ write_output
65
+ log "\nWrote #{@results.size} results to #{@config.output_stdout ? 'stdout' : @config.output_path}"
66
+ end
67
+
68
+ def preview_changes
69
+ preview_translation_changes if @config.write_back
70
+ preview_code_changes if @config.write_back_to_code
71
+ end
72
+
73
+ def preview_translation_changes
74
+ @config.translations.each do |path|
75
+ writer = source_writer_for(path)
76
+ next unless writer
77
+
78
+ relevant_results = @results.select { |result| result_matches_source_path?(result, path) }
79
+ next if relevant_results.empty?
80
+
81
+ patch = Writers::Preview.render(path) do |candidate_path|
82
+ candidate_results = relevant_results.map do |result|
83
+ ExtractionResult.new(**result.to_h.except(:source_file), source_file: candidate_path)
84
+ end
85
+ writer.write(candidate_results, candidate_path)
86
+ end
87
+ write_patch(patch) if patch
88
+ end
89
+ end
90
+
91
+ def preview_code_changes
92
+ results_by_key = build_results_by_key_for_code_write_back
93
+ return if results_by_key.empty?
94
+
95
+ swift_files_for_write_back.each do |swift_file|
96
+ patch = Writers::Preview.render(swift_file) do |candidate_path|
97
+ swift_writer.update_file(candidate_path, results_by_key)
98
+ end
99
+ write_patch(patch) if patch
100
+ end
101
+ end
102
+
103
+ def swift_writer
104
+ @swift_writer ||= Writers::SwiftWriter.new(
105
+ functions: @config.swift_functions,
106
+ context_prefix: @config.context_prefix,
107
+ context_mode: @config.context_mode
108
+ )
109
+ end
110
+
111
+ def swift_files_for_write_back
112
+ @config.source_paths.flat_map do |source_path|
113
+ find_swift_files(source_path, ignore_patterns: @config.ignore_patterns)
114
+ end.uniq
115
+ end
116
+ end
117
+ end
118
+ end