i18n-context-generator 0.4.0 → 0.5.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 (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 +294 -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 +218 -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
@@ -2,6 +2,11 @@
2
2
 
3
3
  require_relative 'context_extractor/source_filters'
4
4
  require_relative 'context_extractor/run_logging'
5
+ require_relative 'context_extractor/source_entries'
6
+ require_relative 'context_extractor/translation_filters'
7
+ require_relative 'context_extractor/cache_identity'
8
+ require_relative 'context_extractor/extraction_result'
9
+ require_relative 'context_extractor/workflow'
5
10
 
6
11
  module I18nContextGenerator
7
12
  # Main orchestrator that parses translation files, searches source code for usages,
@@ -10,45 +15,36 @@ module I18nContextGenerator
10
15
  include Writers::Helpers
11
16
  include SourceFilters
12
17
  include RunLogging
18
+ include SourceEntries
19
+ include TranslationFilters
20
+ include CacheIdentity
21
+ include Workflow
13
22
 
14
- # Result for a single translation key
15
- ExtractionResult = Data.define(:key, :text, :description, :source_file, :ui_element, :tone,
16
- :max_length, :locations, :error) do
17
- def initialize(key:, text:, description:, source_file: nil, ui_element: nil, tone: nil,
18
- max_length: nil, locations: [], error: nil)
19
- super
20
- end
21
-
22
- def to_h
23
- {
24
- key: key,
25
- text: text,
26
- description: description,
27
- source_file: source_file,
28
- ui_element: ui_element,
29
- tone: tone,
30
- max_length: max_length,
31
- locations: locations,
32
- error: error
33
- }
34
- end
35
- end
36
-
37
- attr_reader :results, :errors
23
+ attr_reader :results, :errors, :metrics
38
24
 
39
- def initialize(config)
25
+ def initialize(config, log_output: nil, structured_output: nil, patch_output: nil,
26
+ quiet: false, progress: true)
40
27
  @config = config
28
+ @configured_log_output = log_output
29
+ @structured_output = structured_output
30
+ @patch_output = patch_output
31
+ @quiet = quiet
32
+ @progress_enabled = progress && !quiet
41
33
  @results = Concurrent::Array.new
42
34
  @errors = Concurrent::Array.new
35
+ @metrics = RunMetrics.from([], provider: @config.provider, model: resolved_model)
43
36
 
44
37
  # Defer initialization of expensive resources
45
38
  @searcher = nil
46
39
  @llm = nil
47
40
  @cache = nil
41
+ @supplemental_context = nil
42
+ @cache_context_sources = nil
48
43
  end
49
44
 
50
45
  def run
51
- PlatformValidator.new(@config).validate!
46
+ @config.validate!
47
+ @platform = PlatformValidator.new(@config).validate!
52
48
 
53
49
  entries = load_entries
54
50
  entries = filter_entries(entries) if @config.key_filter
@@ -62,25 +58,27 @@ module I18nContextGenerator
62
58
 
63
59
  log_loaded_entries(entries.size)
64
60
 
65
- if @config.dry_run
66
- puts "\nDry run - would process these keys:"
67
- entries.first(20).each { |e| puts " - #{e.key}: #{truncate(e.text, 50)}" }
68
- puts " ... and #{entries.size - 20} more" if entries.size > 20
69
- return
61
+ return if pre_extraction_stage_handled?(entries)
62
+
63
+ # Resolve shared prompt inputs and provider state on the caller thread so
64
+ # file reads, digest work, and configuration errors are not duplicated by workers.
65
+ context_sources = supplemental_context
66
+ cache_context_sources
67
+ llm_client = llm
68
+ unless context_sources.empty?
69
+ llm_client.validate_supplemental_context!(
70
+ supplemental_context: context_sources,
71
+ redact_prompts: @config.redact_prompts,
72
+ max_prompt_chars: @config.max_prompt_chars
73
+ )
70
74
  end
71
-
72
75
  process_entries(entries)
76
+ @metrics = RunMetrics.from(@results, provider: @config.provider, model: resolved_model)
73
77
 
74
- if @config.output_path
75
- write_output
76
- puts "\nWrote #{@results.size} results to #{@config.output_path}"
77
- end
78
-
79
- write_back_to_source if @config.write_back
80
-
81
- write_back_to_code if @config.write_back_to_code
78
+ deliver_results
82
79
 
83
- puts "Errors: #{@errors.size}" if @errors.any?
80
+ log "Errors: #{@errors.size}" if @errors.any?
81
+ log_metrics
84
82
  end
85
83
 
86
84
  private
@@ -89,95 +87,49 @@ module I18nContextGenerator
89
87
  @searcher ||= Searcher.new(
90
88
  source_paths: @config.source_paths,
91
89
  ignore_patterns: @config.ignore_patterns,
92
- context_lines: @config.context_lines
90
+ context_lines: @config.context_lines,
91
+ platform: @platform,
92
+ swift_functions: @config.swift_functions
93
93
  )
94
94
  end
95
95
 
96
96
  def llm
97
- @llm ||= LLM::Client.for(@config.provider)
97
+ @llm ||= LLM::Client.for(@config.provider, endpoint: @config.endpoint)
98
98
  end
99
99
 
100
100
  def cache
101
- @cache ||= Cache.new(enabled: !@config.no_cache)
101
+ @cache ||= Cache.new(enabled: !@config.no_cache, directory: @config.cache_dir)
102
+ end
103
+
104
+ def supplemental_context
105
+ @supplemental_context ||= SupplementalContext.load(
106
+ files: @config.context_files,
107
+ runtime: @config.supplemental_context
108
+ )
102
109
  end
103
110
 
104
111
  def load_translations
105
112
  return @load_translations if defined?(@load_translations)
106
113
 
107
- @load_translations = @config.translations.uniq.flat_map do |path|
108
- unless File.exist?(path)
109
- warn "Translation file not found: #{path}"
110
- next []
111
- end
112
-
113
- parser = Parsers::Base.for(path)
114
+ entries = @config.translations.flat_map do |path|
115
+ parser = Parsers::Base.for(path, locale: @config.translation_locales[path])
114
116
  parser.parse(path)
115
117
  end
116
- end
117
118
 
118
- def load_entries
119
- case normalized_discovery_mode
120
- when 'source'
121
- load_source_entries
122
- when 'translations'
123
- load_translations
124
- else
125
- auto_discovery_entries
126
- end
127
- end
128
-
129
- def auto_discovery_entries
130
- return load_source_entries if @config.translations.empty?
131
-
132
- load_translations
133
- end
134
-
135
- def load_source_entries
136
- translation_lookup = load_translation_lookup
137
- discovered_entries = filter_source_entries(searcher.discover_localization_entries)
138
-
139
- discovered_entries.map do |entry|
140
- hydrated_entry = translation_lookup[entry.key]
141
- translation_comment = hydrated_entry&.metadata&.dig(:comment)
142
- source_comment = entry.comment
143
- metadata = {}
144
- metadata[:comment] = translation_comment || source_comment if translation_comment || source_comment
145
- metadata[:source_location] = "#{entry.file}:#{entry.line}"
146
-
147
- Parsers::TranslationEntry.new(
148
- key: entry.key,
149
- text: hydrated_entry&.text || entry.text || entry.key,
150
- source_file: hydrated_entry&.source_file,
151
- metadata: metadata
152
- )
153
- end
154
- end
155
-
156
- def load_translation_lookup
157
- return @load_translation_lookup if defined?(@load_translation_lookup)
158
-
159
- @load_translation_lookup = load_translations.each_with_object({}) do |entry, lookup|
160
- lookup[entry.key] ||= entry
161
- end
162
- end
163
-
164
- def normalized_discovery_mode
165
- @config.discovery_mode.to_s.downcase
166
- end
167
-
168
- def translation_backed_discovery?
169
- return true if normalized_discovery_mode == 'translations'
170
- return false if normalized_discovery_mode == 'source'
171
-
172
- @config.translations.any?
173
- end
174
-
175
- def entry_label_for_logging
176
- translation_backed_discovery? ? 'translation keys' : 'source localization entries'
119
+ @load_translations = validate_translation_entries(entries)
120
+ rescue I18nContextGenerator::Error
121
+ raise
122
+ rescue StandardError => e
123
+ raise Error, "Failed to load translations: #{e.message}"
177
124
  end
178
125
 
179
126
  def filter_entries(entries)
180
- patterns = @config.key_filter.split(',').map do |pattern|
127
+ configured_patterns = if @config.key_filter.is_a?(Array)
128
+ @config.key_filter
129
+ else
130
+ @config.key_filter.split(',')
131
+ end
132
+ patterns = configured_patterns.map do |pattern|
181
133
  escaped = Regexp.escape(pattern.strip).gsub('\*', '.*')
182
134
  Regexp.new("^#{escaped}$")
183
135
  end
@@ -187,109 +139,93 @@ module I18nContextGenerator
187
139
  end
188
140
  end
189
141
 
190
- def filter_by_diff(entries)
191
- git_diff = GitDiff.new(base_ref: @config.diff_base)
192
- changed_keys = git_diff.changed_keys(@config.translations)
193
-
194
- if changed_keys.empty?
195
- puts "No changes detected in translation files since #{@config.diff_base}"
196
- return []
197
- end
198
-
199
- puts "Found #{changed_keys.size} changed keys in git diff"
200
-
201
- entries.select do |entry|
202
- changed_keys.include?(entry.key) || changed_keys.include?(android_base_key(entry.key))
203
- end
204
- end
205
-
206
- # Extract the base resource name from composite Android keys
207
- # e.g., "post_likes_count:one" -> "post_likes_count"
208
- # "days_of_week[0]" -> "days_of_week"
209
- def android_base_key(key)
210
- key.sub(/:[a-z]+$/, '').sub(/\[\d+\]$/, '')
211
- end
212
-
213
142
  def filter_by_range(entries)
214
143
  start_idx = 0
215
144
  end_idx = entries.size - 1
216
145
 
217
146
  if @config.start_key
218
147
  found_idx = entries.find_index { |e| e.key == @config.start_key }
219
- if found_idx
220
- start_idx = found_idx
221
- else
222
- puts "Warning: start_key '#{@config.start_key}' not found, starting from beginning"
223
- end
148
+ raise Error, "start_key not found: #{@config.start_key}" unless found_idx
149
+
150
+ start_idx = found_idx
224
151
  end
225
152
 
226
153
  if @config.end_key
227
- found_idx = entries.find_index { |e| e.key == @config.end_key }
228
- if found_idx
229
- end_idx = found_idx
230
- else
231
- puts "Warning: end_key '#{@config.end_key}' not found, processing to end"
232
- end
154
+ found_idx = entries.rindex { |e| e.key == @config.end_key }
155
+ raise Error, "end_key not found: #{@config.end_key}" unless found_idx
156
+
157
+ end_idx = found_idx
233
158
  end
234
159
 
160
+ raise Error, 'start_key must not come after end_key' if start_idx > end_idx
161
+
235
162
  range_info = []
236
163
  range_info << "from '#{@config.start_key}'" if @config.start_key
237
164
  range_info << "to '#{@config.end_key}'" if @config.end_key
238
- puts "Filtering #{range_info.join(' ')}: keys #{start_idx + 1} to #{end_idx + 1}"
165
+ log "Filtering #{range_info.join(' ')}: keys #{start_idx + 1} to #{end_idx + 1}"
239
166
 
240
167
  entries[start_idx..end_idx]
241
168
  end
242
169
 
243
170
  def process_entries(entries)
244
- # Ensure output is not buffered
245
- $stdout.sync = true
171
+ # Results expose changed source locations even during translation-backed
172
+ # discovery. Resolve the diff once on the caller thread before workers can
173
+ # race to initialize the lazy filter.
174
+ source_line_filter if @config.diff_base
246
175
 
247
- progress = TTY::ProgressBar.new(
248
- '[:bar] :current/:total :percent :eta :key',
249
- total: entries.size,
250
- width: 30,
251
- output: $stdout
252
- )
176
+ progress = build_progress(entries.size)
253
177
 
254
178
  # Use a thread pool for concurrent processing
255
179
  pool = Concurrent::FixedThreadPool.new(@config.concurrency)
256
- semaphore = Concurrent::Semaphore.new(@config.concurrency)
257
- current_key = Concurrent::AtomicReference.new('')
258
180
 
259
181
  entries.each do |entry|
260
182
  pool.post do
261
- semaphore.acquire
262
- begin
263
- current_key.set(truncate(entry.key, 40))
264
- result = process_entry(entry)
265
- @results << result
266
- @errors << result if result.error
267
- rescue StandardError => e
268
- # Capture errors as results so they're visible in output
269
- result = ExtractionResult.new(
270
- key: entry.key,
271
- text: entry.text,
272
- description: 'Processing failed',
273
- source_file: entry.source_file,
274
- error: e.message
275
- )
276
- @results << result
277
- @errors << result
278
- ensure
279
- semaphore.release
280
- progress.advance(key: current_key.get)
281
- end
183
+ result = process_entry(entry)
184
+ @results << result
185
+ @errors << result if result.error
186
+ rescue StandardError => e
187
+ # Capture errors as results so they're visible in output
188
+ result = ExtractionResult.new(
189
+ key: entry.key,
190
+ text: entry.text,
191
+ description: 'Processing failed',
192
+ source_file: entry.source_file,
193
+ translation_key: translation_key_for(entry),
194
+ changed_translation_locations: changed_translation_locations_for(entry),
195
+ status: :error,
196
+ error: e.message
197
+ )
198
+ @results << result
199
+ @errors << result
200
+ ensure
201
+ progress&.advance(key: truncate(entry.key, 40))
282
202
  end
283
203
  end
284
204
 
285
205
  pool.shutdown
286
206
  pool.wait_for_termination
287
- puts # New line after progress bar
207
+ log if progress # New line after progress bar
208
+ end
209
+
210
+ def build_progress(total)
211
+ return unless @progress_enabled
212
+
213
+ TTY::ProgressBar.new(
214
+ '[:bar] :current/:total :percent :eta :key',
215
+ total: total,
216
+ width: 30,
217
+ output: log_output
218
+ )
288
219
  end
289
220
 
290
221
  def process_entry(entry)
291
222
  # Search for key usage in code first — needed for both cache key and LLM prompt
292
- matches = searcher.search(entry.key)
223
+ resource_type = entry.metadata&.dig(:resource_type)
224
+ matches = if resource_type
225
+ searcher.search(entry.key, resource_type: resource_type)
226
+ else
227
+ searcher.search(entry.key)
228
+ end
293
229
  comment = @config.include_translation_comments ? entry.metadata&.dig(:comment) : nil
294
230
 
295
231
  if matches.empty?
@@ -298,28 +234,22 @@ module I18nContextGenerator
298
234
  text: entry.text,
299
235
  description: 'No usage found in source code',
300
236
  source_file: entry.source_file,
237
+ translation_key: translation_key_for(entry),
238
+ changed_translation_locations: changed_translation_locations_for(entry),
239
+ status: :no_usage,
301
240
  locations: []
302
241
  )
303
242
  end
304
243
 
305
244
  # Limit matches to avoid huge prompts
306
245
  matches = matches.first(@config.max_matches_per_key)
246
+ context_sources = supplemental_context
307
247
 
308
- # Build a cache context digest from all prompt-shaping inputs so the cache
309
- # invalidates when source code, comments, or model change
310
- cache_ctx = [
311
- matches.map { |m| "#{m.file}:#{m.line}:#{m.match_line}:#{m.enclosing_scope}:#{m.context}" }.sort.join("\0"),
312
- "comment:#{comment}",
313
- "provider:#{@config.provider}",
314
- "model:#{@config.model}",
315
- "include_file_paths:#{@config.include_file_paths}",
316
- "redact_prompts:#{@config.redact_prompts}"
317
- ].join("\n")
248
+ cache_ctx = cache_context(entry, matches, comment)
318
249
 
319
250
  # Check cache with match context included
320
- if (cached = cache.get(entry.key, entry.text, context: cache_ctx))
321
- return ExtractionResult.new(source_file: entry.source_file, **cached.transform_keys(&:to_sym))
322
- end
251
+ cached = cache.get(entry.key, entry.text, context: cache_ctx)
252
+ return cached_extraction_result(entry, cached, matches) if cached && !(cached[:error] || cached['error'])
323
253
 
324
254
  # Get context from LLM
325
255
  llm_result = llm.generate_context(
@@ -329,9 +259,12 @@ module I18nContextGenerator
329
259
  model: @config.model,
330
260
  comment: comment,
331
261
  include_file_paths: @config.include_file_paths,
332
- redact_prompts: @config.redact_prompts
262
+ redact_prompts: @config.redact_prompts,
263
+ max_prompt_chars: @config.max_prompt_chars,
264
+ supplemental_context: context_sources
333
265
  )
334
266
 
267
+ result_locations = result_locations_for(entry, matches)
335
268
  result = ExtractionResult.new(
336
269
  key: entry.key,
337
270
  text: entry.text,
@@ -340,14 +273,52 @@ module I18nContextGenerator
340
273
  ui_element: llm_result.ui_element,
341
274
  tone: llm_result.tone,
342
275
  max_length: llm_result.max_length,
343
- locations: result_locations_for(entry, matches),
276
+ confidence: llm_result.confidence,
277
+ ambiguity_reason: llm_result.ambiguity_reason,
278
+ request_count: llm_result.request_count,
279
+ input_tokens: llm_result.input_tokens,
280
+ output_tokens: llm_result.output_tokens,
281
+ retries: llm_result.retries,
282
+ locations: result_locations,
283
+ **changed_location_attributes_for(entry, result_locations),
284
+ translation_key: translation_key_for(entry),
285
+ changed_translation_locations: changed_translation_locations_for(entry),
286
+ status: llm_result.error ? :error : :success,
344
287
  error: llm_result.error
345
288
  )
346
289
 
347
- cache.set(entry.key, entry.text, result.to_h.except(:source_file), context: cache_ctx)
290
+ unless result.error
291
+ cache.set(
292
+ entry.key,
293
+ entry.text,
294
+ result.to_h.except(
295
+ :source_file, :changed_locations, :changed_location_groups, :changed_translation_locations,
296
+ :cache_hit, :request_count, :input_tokens, :output_tokens, :retries
297
+ ),
298
+ context: cache_ctx
299
+ )
300
+ end
348
301
  result
349
302
  end
350
303
 
304
+ def cached_extraction_result(entry, cached, matches)
305
+ attributes = cached.transform_keys(&:to_sym).except(
306
+ :source_file, :locations, :changed_locations, :changed_location_groups,
307
+ :translation_key, :changed_translation_locations,
308
+ :cache_hit, :request_count, :input_tokens, :output_tokens, :retries
309
+ )
310
+ locations = result_locations_for(entry, matches)
311
+ ExtractionResult.new(
312
+ source_file: entry.source_file,
313
+ locations: locations,
314
+ **changed_location_attributes_for(entry, locations),
315
+ translation_key: translation_key_for(entry),
316
+ changed_translation_locations: changed_translation_locations_for(entry),
317
+ cache_hit: true,
318
+ **attributes
319
+ )
320
+ end
321
+
351
322
  def write_output
352
323
  writer = case @config.output_format.to_s.downcase
353
324
  when 'json'
@@ -356,7 +327,12 @@ module I18nContextGenerator
356
327
  Writers::CsvWriter.new
357
328
  end
358
329
 
359
- writer.write(@results, @config.output_path)
330
+ writer.write(
331
+ @results,
332
+ @config.output_path,
333
+ metrics: @metrics,
334
+ output: @structured_output || $stdout
335
+ )
360
336
  end
361
337
 
362
338
  def write_back_to_source
@@ -369,33 +345,44 @@ module I18nContextGenerator
369
345
  relevant_results = @results.select { |result| result_matches_source_path?(result, path) }
370
346
  next if relevant_results.empty?
371
347
 
372
- writer.write(relevant_results, path)
373
- puts "Updated #{path} with context comments"
348
+ updated = writer.write(relevant_results, path)
349
+ log "Updated #{path} with context comments" if updated
374
350
  end
375
351
  end
376
352
 
377
353
  def write_back_to_code
378
- swift_writer = Writers::SwiftWriter.new(
379
- functions: @config.swift_functions,
380
- context_prefix: @config.context_prefix,
381
- context_mode: @config.context_mode
382
- )
383
-
384
354
  updated_count = 0
385
355
  results_by_key = build_results_by_key_for_code_write_back
356
+ return if results_by_key.empty?
386
357
 
387
- @config.source_paths.each do |source_path|
388
- swift_files = find_swift_files(source_path, ignore_patterns: @config.ignore_patterns)
389
-
390
- swift_files.each do |swift_file|
391
- if swift_writer.update_file(swift_file, results_by_key)
392
- updated_count += 1
393
- puts "Updated #{swift_file} with context comments"
394
- end
358
+ swift_files_for_write_back.each do |swift_file|
359
+ if swift_writer.update_file(swift_file, results_by_key)
360
+ updated_count += 1
361
+ log "Updated #{swift_file} with context comments"
395
362
  end
396
363
  end
397
364
 
398
- puts "Updated #{updated_count} Swift files with context comments" if updated_count.positive?
365
+ log "Updated #{updated_count} Swift files with context comments" if updated_count.positive?
366
+ end
367
+
368
+ def validate_translation_entries(entries)
369
+ invalid_entry = entries.find do |entry|
370
+ !entry.key.is_a?(String) || entry.key.empty? ||
371
+ !entry.text.is_a?(String) ||
372
+ !entry.source_file.is_a?(String) || entry.source_file.empty?
373
+ end
374
+ if invalid_entry
375
+ source = invalid_entry.source_file || 'unknown source'
376
+ raise Error, "Invalid translation entry in #{source}: key, text, and source file must be strings"
377
+ end
378
+
379
+ grouped = entries.group_by { |entry| [File.expand_path(entry.source_file), entry.key] }
380
+ conflicts = grouped.filter_map do |(source_file, key), duplicates|
381
+ "#{source_file}:#{key}" if duplicates.map(&:text).uniq.size > 1
382
+ end
383
+ raise Error, "Conflicting duplicate translation keys: #{conflicts.join(', ')}" if conflicts.any?
384
+
385
+ entries.uniq { |entry| [File.expand_path(entry.source_file), entry.key, entry.text] }
399
386
  end
400
387
 
401
388
  def build_results_by_key_for_code_write_back
@@ -416,7 +403,6 @@ module I18nContextGenerator
416
403
  end
417
404
 
418
405
  def source_writer_for(path)
419
- basename = File.basename(path).downcase
420
406
  ext = File.extname(path).downcase
421
407
 
422
408
  case ext
@@ -425,8 +411,13 @@ module I18nContextGenerator
425
411
  context_prefix: @config.context_prefix,
426
412
  context_mode: @config.context_mode
427
413
  )
414
+ when '.xcstrings'
415
+ Writers::XcstringsWriter.new(
416
+ context_prefix: @config.context_prefix,
417
+ context_mode: @config.context_mode
418
+ )
428
419
  when '.xml'
429
- if basename == 'strings.xml' || path.include?('/res/values')
420
+ if FileClassifier.android_translation_file?(path)
430
421
  Writers::AndroidXmlWriter.new(
431
422
  context_prefix: @config.context_prefix,
432
423
  context_mode: @config.context_mode
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module I18nContextGenerator
4
+ # Owns source and translation file-type classification. Header files remain
5
+ # searchable for iOS runs but are deliberately not platform evidence because
6
+ # generic C headers also occur in Android projects.
7
+ module FileClassifier
8
+ IOS_SOURCE_EXTENSIONS = %w[.swift .m .mm].freeze
9
+ IOS_SEARCH_EXTENSIONS = (IOS_SOURCE_EXTENSIONS + ['.h']).freeze
10
+ ANDROID_SOURCE_EXTENSIONS = %w[.kt .java].freeze
11
+ SEARCH_EXTENSIONS = {
12
+ ios: IOS_SEARCH_EXTENSIONS,
13
+ android: (ANDROID_SOURCE_EXTENSIONS + ['.xml']).freeze,
14
+ unknown: (IOS_SEARCH_EXTENSIONS + ANDROID_SOURCE_EXTENSIONS + ['.xml']).freeze
15
+ }.freeze
16
+
17
+ module_function
18
+
19
+ def source_platform(path)
20
+ extension = File.extname(path).downcase
21
+ return :ios if IOS_SOURCE_EXTENSIONS.include?(extension)
22
+ return :android if ANDROID_SOURCE_EXTENSIONS.include?(extension)
23
+
24
+ :android if android_source_xml?(path)
25
+ end
26
+
27
+ def searchable_platform(path, platform_hint: nil)
28
+ return :ios if File.extname(path).downcase == '.h'
29
+ return :android if File.extname(path).downcase == '.xml' && platform_hint&.to_sym == :android
30
+
31
+ source_platform(path)
32
+ end
33
+
34
+ def searchable_source?(path, platform: :unknown)
35
+ extensions = SEARCH_EXTENSIONS.fetch(platform.to_sym, SEARCH_EXTENSIONS[:unknown])
36
+ return false unless extensions.include?(File.extname(path).downcase)
37
+ return platform.to_sym == :android || android_source_xml?(path) if File.extname(path).downcase == '.xml'
38
+
39
+ true
40
+ end
41
+
42
+ def translation_platform(path)
43
+ case File.extname(path).downcase
44
+ when '.strings', '.xcstrings' then :ios
45
+ when '.xml' then :android if android_translation_file?(path)
46
+ end
47
+ end
48
+
49
+ def android_resource_xml?(path)
50
+ File.extname(path).downcase == '.xml' && path_components(path).include?('res')
51
+ end
52
+
53
+ def android_source_xml?(path)
54
+ android_resource_xml?(path) || File.basename(path).casecmp('AndroidManifest.xml').zero?
55
+ end
56
+
57
+ def android_translation_file?(path)
58
+ return false unless File.extname(path).downcase == '.xml'
59
+ return true if File.basename(path).casecmp('strings.xml').zero?
60
+
61
+ path_components(path).each_cons(2).any? { |first, second| first == 'res' && second.start_with?('values') }
62
+ end
63
+
64
+ def swift_source?(path)
65
+ File.extname(path).downcase == '.swift'
66
+ end
67
+
68
+ def path_components(path)
69
+ path.to_s.tr('\\', '/').split('/').reject(&:empty?)
70
+ end
71
+ end
72
+ end