i18n-context-generator 0.3.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 +165 -73
  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 +315 -105
  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 +65 -0
  17. data/lib/i18n_context_generator/context_extractor/source_entries.rb +103 -0
  18. data/lib/i18n_context_generator/context_extractor/source_filters.rb +104 -0
  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 +218 -164
  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 +280 -88
  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 +232 -0
  44. data/lib/i18n_context_generator/searcher.rb +69 -201
  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 +62 -12
@@ -1,86 +1,84 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'context_extractor/source_filters'
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'
10
+
3
11
  module I18nContextGenerator
4
12
  # Main orchestrator that parses translation files, searches source code for usages,
5
13
  # sends context to the LLM, and writes results via the configured writer.
6
14
  class ContextExtractor
7
15
  include Writers::Helpers
16
+ include SourceFilters
17
+ include RunLogging
18
+ include SourceEntries
19
+ include TranslationFilters
20
+ include CacheIdentity
21
+ include Workflow
8
22
 
9
- # Result for a single translation key
10
- ExtractionResult = Data.define(:key, :text, :description, :source_file, :ui_element, :tone,
11
- :max_length, :locations, :error) do
12
- def initialize(key:, text:, description:, source_file: nil, ui_element: nil, tone: nil,
13
- max_length: nil, locations: [], error: nil)
14
- super
15
- end
16
-
17
- def to_h
18
- {
19
- key: key,
20
- text: text,
21
- description: description,
22
- source_file: source_file,
23
- ui_element: ui_element,
24
- tone: tone,
25
- max_length: max_length,
26
- locations: locations,
27
- error: error
28
- }
29
- end
30
- end
23
+ attr_reader :results, :errors, :metrics
31
24
 
32
- attr_reader :results, :errors
33
-
34
- def initialize(config)
25
+ def initialize(config, log_output: nil, structured_output: nil, patch_output: nil,
26
+ quiet: false, progress: true)
35
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
36
33
  @results = Concurrent::Array.new
37
34
  @errors = Concurrent::Array.new
35
+ @metrics = RunMetrics.from([], provider: @config.provider, model: resolved_model)
38
36
 
39
37
  # Defer initialization of expensive resources
40
38
  @searcher = nil
41
39
  @llm = nil
42
40
  @cache = nil
41
+ @supplemental_context = nil
42
+ @cache_context_sources = nil
43
43
  end
44
44
 
45
45
  def run
46
- PlatformValidator.new(@config).validate!
46
+ @config.validate!
47
+ @platform = PlatformValidator.new(@config).validate!
47
48
 
48
- entries = load_translations
49
+ entries = load_entries
49
50
  entries = filter_entries(entries) if @config.key_filter
50
- entries = filter_by_diff(entries) if @config.diff_base
51
+ entries = filter_by_diff(entries) if @config.diff_base && translation_backed_discovery?
51
52
  entries = filter_by_range(entries) if @config.start_key || @config.end_key
52
53
 
53
54
  if entries.empty?
54
- if @config.diff_base
55
- puts "No changed translation keys found since #{@config.diff_base}."
56
- else
57
- puts 'No translation entries found.'
58
- end
55
+ log_empty_entries_message
59
56
  return
60
57
  end
61
58
 
62
- puts "Loaded #{entries.size} translation keys"
63
- puts "(filtered to changes since #{@config.diff_base})" if @config.diff_base
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
70
- end
61
+ return if pre_extraction_stage_handled?(entries)
71
62
 
72
- process_entries(entries)
73
-
74
- if @config.output_path
75
- write_output
76
- puts "\nWrote #{@results.size} results to #{@config.output_path}"
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
+ )
77
74
  end
75
+ process_entries(entries)
76
+ @metrics = RunMetrics.from(@results, provider: @config.provider, model: resolved_model)
78
77
 
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,32 +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
- @config.translations.uniq.flat_map do |path|
106
- unless File.exist?(path)
107
- warn "Translation file not found: #{path}"
108
- next []
109
- end
112
+ return @load_translations if defined?(@load_translations)
110
113
 
111
- parser = Parsers::Base.for(path)
114
+ entries = @config.translations.flat_map do |path|
115
+ parser = Parsers::Base.for(path, locale: @config.translation_locales[path])
112
116
  parser.parse(path)
113
117
  end
118
+
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}"
114
124
  end
115
125
 
116
126
  def filter_entries(entries)
117
- 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|
118
133
  escaped = Regexp.escape(pattern.strip).gsub('\*', '.*')
119
134
  Regexp.new("^#{escaped}$")
120
135
  end
@@ -124,109 +139,93 @@ module I18nContextGenerator
124
139
  end
125
140
  end
126
141
 
127
- def filter_by_diff(entries)
128
- git_diff = GitDiff.new(base_ref: @config.diff_base)
129
- changed_keys = git_diff.changed_keys(@config.translations)
130
-
131
- if changed_keys.empty?
132
- puts "No changes detected in translation files since #{@config.diff_base}"
133
- return []
134
- end
135
-
136
- puts "Found #{changed_keys.size} changed keys in git diff"
137
-
138
- entries.select do |entry|
139
- changed_keys.include?(entry.key) || changed_keys.include?(android_base_key(entry.key))
140
- end
141
- end
142
-
143
- # Extract the base resource name from composite Android keys
144
- # e.g., "post_likes_count:one" -> "post_likes_count"
145
- # "days_of_week[0]" -> "days_of_week"
146
- def android_base_key(key)
147
- key.sub(/:[a-z]+$/, '').sub(/\[\d+\]$/, '')
148
- end
149
-
150
142
  def filter_by_range(entries)
151
143
  start_idx = 0
152
144
  end_idx = entries.size - 1
153
145
 
154
146
  if @config.start_key
155
147
  found_idx = entries.find_index { |e| e.key == @config.start_key }
156
- if found_idx
157
- start_idx = found_idx
158
- else
159
- puts "Warning: start_key '#{@config.start_key}' not found, starting from beginning"
160
- end
148
+ raise Error, "start_key not found: #{@config.start_key}" unless found_idx
149
+
150
+ start_idx = found_idx
161
151
  end
162
152
 
163
153
  if @config.end_key
164
- found_idx = entries.find_index { |e| e.key == @config.end_key }
165
- if found_idx
166
- end_idx = found_idx
167
- else
168
- puts "Warning: end_key '#{@config.end_key}' not found, processing to end"
169
- 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
170
158
  end
171
159
 
160
+ raise Error, 'start_key must not come after end_key' if start_idx > end_idx
161
+
172
162
  range_info = []
173
163
  range_info << "from '#{@config.start_key}'" if @config.start_key
174
164
  range_info << "to '#{@config.end_key}'" if @config.end_key
175
- 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}"
176
166
 
177
167
  entries[start_idx..end_idx]
178
168
  end
179
169
 
180
170
  def process_entries(entries)
181
- # Ensure output is not buffered
182
- $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
183
175
 
184
- progress = TTY::ProgressBar.new(
185
- '[:bar] :current/:total :percent :eta :key',
186
- total: entries.size,
187
- width: 30,
188
- output: $stdout
189
- )
176
+ progress = build_progress(entries.size)
190
177
 
191
178
  # Use a thread pool for concurrent processing
192
179
  pool = Concurrent::FixedThreadPool.new(@config.concurrency)
193
- semaphore = Concurrent::Semaphore.new(@config.concurrency)
194
- current_key = Concurrent::AtomicReference.new('')
195
180
 
196
181
  entries.each do |entry|
197
182
  pool.post do
198
- semaphore.acquire
199
- begin
200
- current_key.set(truncate(entry.key, 40))
201
- result = process_entry(entry)
202
- @results << result
203
- @errors << result if result.error
204
- rescue StandardError => e
205
- # Capture errors as results so they're visible in output
206
- result = ExtractionResult.new(
207
- key: entry.key,
208
- text: entry.text,
209
- description: 'Processing failed',
210
- source_file: entry.source_file,
211
- error: e.message
212
- )
213
- @results << result
214
- @errors << result
215
- ensure
216
- semaphore.release
217
- progress.advance(key: current_key.get)
218
- 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))
219
202
  end
220
203
  end
221
204
 
222
205
  pool.shutdown
223
206
  pool.wait_for_termination
224
- 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
+ )
225
219
  end
226
220
 
227
221
  def process_entry(entry)
228
222
  # Search for key usage in code first — needed for both cache key and LLM prompt
229
- 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
230
229
  comment = @config.include_translation_comments ? entry.metadata&.dig(:comment) : nil
231
230
 
232
231
  if matches.empty?
@@ -235,28 +234,22 @@ module I18nContextGenerator
235
234
  text: entry.text,
236
235
  description: 'No usage found in source code',
237
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,
238
240
  locations: []
239
241
  )
240
242
  end
241
243
 
242
244
  # Limit matches to avoid huge prompts
243
245
  matches = matches.first(@config.max_matches_per_key)
246
+ context_sources = supplemental_context
244
247
 
245
- # Build a cache context digest from all prompt-shaping inputs so the cache
246
- # invalidates when source code, comments, or model change
247
- cache_ctx = [
248
- matches.map { |m| "#{m.file}:#{m.line}:#{m.match_line}:#{m.enclosing_scope}:#{m.context}" }.sort.join("\0"),
249
- "comment:#{comment}",
250
- "provider:#{@config.provider}",
251
- "model:#{@config.model}",
252
- "include_file_paths:#{@config.include_file_paths}",
253
- "redact_prompts:#{@config.redact_prompts}"
254
- ].join("\n")
248
+ cache_ctx = cache_context(entry, matches, comment)
255
249
 
256
250
  # Check cache with match context included
257
- if (cached = cache.get(entry.key, entry.text, context: cache_ctx))
258
- return ExtractionResult.new(source_file: entry.source_file, **cached.transform_keys(&:to_sym))
259
- 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'])
260
253
 
261
254
  # Get context from LLM
262
255
  llm_result = llm.generate_context(
@@ -266,9 +259,12 @@ module I18nContextGenerator
266
259
  model: @config.model,
267
260
  comment: comment,
268
261
  include_file_paths: @config.include_file_paths,
269
- redact_prompts: @config.redact_prompts
262
+ redact_prompts: @config.redact_prompts,
263
+ max_prompt_chars: @config.max_prompt_chars,
264
+ supplemental_context: context_sources
270
265
  )
271
266
 
267
+ result_locations = result_locations_for(entry, matches)
272
268
  result = ExtractionResult.new(
273
269
  key: entry.key,
274
270
  text: entry.text,
@@ -277,14 +273,52 @@ module I18nContextGenerator
277
273
  ui_element: llm_result.ui_element,
278
274
  tone: llm_result.tone,
279
275
  max_length: llm_result.max_length,
280
- locations: matches.map { |m| "#{m.file}:#{m.line}" },
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,
281
287
  error: llm_result.error
282
288
  )
283
289
 
284
- 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
285
301
  result
286
302
  end
287
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
+
288
322
  def write_output
289
323
  writer = case @config.output_format.to_s.downcase
290
324
  when 'json'
@@ -293,7 +327,12 @@ module I18nContextGenerator
293
327
  Writers::CsvWriter.new
294
328
  end
295
329
 
296
- writer.write(@results, @config.output_path)
330
+ writer.write(
331
+ @results,
332
+ @config.output_path,
333
+ metrics: @metrics,
334
+ output: @structured_output || $stdout
335
+ )
297
336
  end
298
337
 
299
338
  def write_back_to_source
@@ -306,33 +345,44 @@ module I18nContextGenerator
306
345
  relevant_results = @results.select { |result| result_matches_source_path?(result, path) }
307
346
  next if relevant_results.empty?
308
347
 
309
- writer.write(relevant_results, path)
310
- puts "Updated #{path} with context comments"
348
+ updated = writer.write(relevant_results, path)
349
+ log "Updated #{path} with context comments" if updated
311
350
  end
312
351
  end
313
352
 
314
353
  def write_back_to_code
315
- swift_writer = Writers::SwiftWriter.new(
316
- functions: @config.swift_functions,
317
- context_prefix: @config.context_prefix,
318
- context_mode: @config.context_mode
319
- )
320
-
321
354
  updated_count = 0
322
355
  results_by_key = build_results_by_key_for_code_write_back
356
+ return if results_by_key.empty?
323
357
 
324
- @config.source_paths.each do |source_path|
325
- swift_files = find_swift_files(source_path, ignore_patterns: @config.ignore_patterns)
326
-
327
- swift_files.each do |swift_file|
328
- if swift_writer.update_file(swift_file, results_by_key)
329
- updated_count += 1
330
- puts "Updated #{swift_file} with context comments"
331
- 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"
332
362
  end
333
363
  end
334
364
 
335
- 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] }
336
386
  end
337
387
 
338
388
  def build_results_by_key_for_code_write_back
@@ -353,7 +403,6 @@ module I18nContextGenerator
353
403
  end
354
404
 
355
405
  def source_writer_for(path)
356
- basename = File.basename(path).downcase
357
406
  ext = File.extname(path).downcase
358
407
 
359
408
  case ext
@@ -362,8 +411,13 @@ module I18nContextGenerator
362
411
  context_prefix: @config.context_prefix,
363
412
  context_mode: @config.context_mode
364
413
  )
414
+ when '.xcstrings'
415
+ Writers::XcstringsWriter.new(
416
+ context_prefix: @config.context_prefix,
417
+ context_mode: @config.context_mode
418
+ )
365
419
  when '.xml'
366
- if basename == 'strings.xml' || path.include?('/res/values')
420
+ if FileClassifier.android_translation_file?(path)
367
421
  Writers::AndroidXmlWriter.new(
368
422
  context_prefix: @config.context_prefix,
369
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
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module I18nContextGenerator
4
+ # Shared ownership and merge rules for context generated into destination
5
+ # comments. Writers remain responsible for destination-specific escaping.
6
+ module GeneratedComment
7
+ module_function
8
+
9
+ def merge(existing:, generated:, prefix:, mode:, separator:)
10
+ return generated if existing.nil? || existing.empty? || mode.to_s == 'replace'
11
+
12
+ if prefix.empty?
13
+ return existing if existing.include?(generated)
14
+
15
+ return "#{existing}#{separator}#{generated}"
16
+ end
17
+
18
+ return replace_managed(existing, generated, prefix) if managed?(existing, prefix: prefix)
19
+
20
+ "#{existing}#{separator}#{generated}"
21
+ end
22
+
23
+ def managed?(comment, prefix:)
24
+ prefix.empty? || comment.to_s.include?(prefix)
25
+ end
26
+
27
+ def replace_managed(existing, generated, prefix)
28
+ existing.gsub(/#{Regexp.escape(prefix)}[^\n]*/) { generated }
29
+ end
30
+ private_class_method :replace_managed
31
+ end
32
+ end