i18n-context-generator 0.3.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.
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'find'
4
+
5
+ module I18nContextGenerator
6
+ # Validates that a run targets a single mobile platform after applying ignore rules.
7
+ class PlatformValidator
8
+ def initialize(config)
9
+ @config = config
10
+ @ignore_patterns = @config.ignore_patterns.map { |pattern| glob_to_regex(pattern) }
11
+ end
12
+
13
+ def validate!
14
+ platforms = (@config.translations.flat_map { |path| translation_platforms_for_path(path) } +
15
+ @config.source_paths.flat_map { |path| source_platforms_for_path(path) }).uniq
16
+
17
+ return if platforms.size <= 1
18
+
19
+ raise Error, 'Mixed iOS and Android runs are not supported. Split them into separate invocations or config files.'
20
+ end
21
+
22
+ private
23
+
24
+ def translation_platforms_for_path(path)
25
+ basename = File.basename(path).downcase
26
+ ext = File.extname(path).downcase
27
+
28
+ case ext
29
+ when '.strings'
30
+ [:ios]
31
+ when '.xml'
32
+ basename == 'strings.xml' || path.include?('/res/values') ? [:android] : []
33
+ else
34
+ []
35
+ end
36
+ end
37
+
38
+ def source_platforms_for_path(path)
39
+ return [] unless File.exist?(path)
40
+
41
+ if File.file?(path)
42
+ platform = source_platform_for_file(path)
43
+ return platform ? [platform] : []
44
+ end
45
+
46
+ platforms = []
47
+
48
+ Find.find(path) do |file|
49
+ next unless File.file?(file)
50
+ next if ignored_source_file?(file)
51
+
52
+ platform = source_platform_for_file(file)
53
+ next unless platform
54
+ next if platforms.include?(platform)
55
+
56
+ platforms << platform
57
+ break if platforms.size == 2
58
+ end
59
+
60
+ platforms
61
+ end
62
+
63
+ def source_platform_for_file(path)
64
+ case File.extname(path).downcase
65
+ when '.swift', '.m', '.mm', '.h'
66
+ :ios
67
+ when '.kt', '.java'
68
+ :android
69
+ end
70
+ end
71
+
72
+ def ignored_source_file?(path)
73
+ @ignore_patterns.any? do |pattern|
74
+ candidates = [path]
75
+ @config.source_paths.each do |root|
76
+ prefix = root.end_with?('/') ? root : "#{root}/"
77
+ candidates << path.delete_prefix(prefix) if path.start_with?(prefix)
78
+ end
79
+ candidates.any? { |candidate| pattern.match?(candidate) }
80
+ end
81
+ end
82
+
83
+ def glob_to_regex(glob_pattern)
84
+ regex_str = Regexp.escape(glob_pattern)
85
+ .gsub('\*\*/', '(.*/)?')
86
+ .gsub('\*\*', '.*')
87
+ .gsub('\*', '[^/]*')
88
+ .gsub('\?', '.')
89
+ Regexp.new("(?:^|/)#{regex_str}(?:$|/)")
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,447 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'find'
4
+
5
+ module I18nContextGenerator
6
+ # Finds where translation keys are used in iOS and Android source code.
7
+ class Searcher
8
+ # Represents a code match with surrounding context
9
+ Match = Data.define(:file, :line, :match_line, :context, :enclosing_scope) do
10
+ def initialize(file:, line:, match_line: '', context: '', enclosing_scope: nil)
11
+ super
12
+ end
13
+ end
14
+
15
+ # Patterns that indicate false positive matches (not actual localization usage)
16
+ FALSE_POSITIVE_PATTERNS = [
17
+ /==\s*["']/, # String comparisons like == "yes"
18
+ /["']\s*==/, # String comparisons like "yes" ==
19
+ /!=\s*["']/, # String comparisons like != "no"
20
+ /["']\s*!=/, # String comparisons like "no" !=
21
+ /\.equals\(["']/, # Java .equals("string")
22
+ /contentEquals\(["']/ # Kotlin contentEquals
23
+ ].freeze
24
+
25
+ # File extensions to search by platform
26
+ FILE_EXTENSIONS = {
27
+ ios: %w[.swift .m .mm .h].freeze,
28
+ android: %w[.kt .java .xml].freeze,
29
+ unknown: %w[.swift .m .mm .h .kt .java .xml].freeze
30
+ }.freeze
31
+
32
+ IOS_WRAPPER_DEFINITION_PATTERN =
33
+ /\b(static\s+)?(?:let|var)\s+(\w+)\s*=\s*(?:NSLocalizedString|String\s*\(\s*localized:|LocalizedStringKey\s*\(|Text\s*\()/
34
+ IOS_TYPE_DECLARATION_PATTERN = /\b(class|struct|enum|extension)\s+(\w+)/
35
+
36
+ def initialize(source_paths:, ignore_patterns:, context_lines: 15, platform: nil)
37
+ @source_paths = source_paths
38
+ @ignore_patterns = compile_ignore_patterns(ignore_patterns)
39
+ @context_lines = context_lines
40
+ @platform = platform || detect_platform
41
+
42
+ # Cache discovered files for repeated searches
43
+ @files_cache = nil
44
+ @file_lines_cache = {}
45
+ end
46
+
47
+ def search(key)
48
+ patterns = build_search_patterns(key)
49
+ files = discover_files
50
+ direct_matches = []
51
+
52
+ files.each do |file|
53
+ matches = search_file(file, patterns, key)
54
+ direct_matches.concat(matches)
55
+ end
56
+
57
+ all_matches = if @platform == :ios
58
+ search_ios_wrapper_usages(direct_matches, files) + direct_matches
59
+ else
60
+ direct_matches
61
+ end
62
+
63
+ filter_matches(all_matches, key)
64
+ end
65
+
66
+ private
67
+
68
+ def detect_platform
69
+ @source_paths.each do |path|
70
+ next unless File.exist?(path)
71
+
72
+ if File.directory?(path)
73
+ Find.find(path) do |f|
74
+ if File.directory?(f) && ignored?(f)
75
+ Find.prune
76
+ next
77
+ end
78
+
79
+ next unless File.file?(f)
80
+ next if ignored?(f)
81
+
82
+ return :ios if f.end_with?('.swift', '.m', '.mm', '.h')
83
+ return :android if f.end_with?('.kt', '.java')
84
+ end
85
+ elsif !ignored?(path) && path.end_with?('.swift', '.m', '.mm', '.h')
86
+ return :ios
87
+ elsif !ignored?(path) && path.end_with?('.kt', '.java')
88
+ return :android
89
+ end
90
+ end
91
+ :unknown
92
+ end
93
+
94
+ def compile_ignore_patterns(patterns)
95
+ patterns.map { |p| glob_to_regex(p) }
96
+ end
97
+
98
+ def glob_to_regex(glob_pattern)
99
+ # Convert glob pattern to regex
100
+ # Handle common glob patterns: *, **, ?
101
+ regex_str = Regexp.escape(glob_pattern)
102
+ .gsub('\*\*/', '(.*/)?') # **/ matches any path (including empty)
103
+ .gsub('\*\*', '.*') # ** matches anything
104
+ .gsub('\*', '[^/]*') # * matches within path segment
105
+ .gsub('\?', '.') # ? matches single char
106
+ Regexp.new("(?:^|/)#{regex_str}(?:$|/)")
107
+ end
108
+
109
+ def discover_files
110
+ return @files_cache if @files_cache
111
+
112
+ extensions = FILE_EXTENSIONS[@platform] || FILE_EXTENSIONS[:unknown]
113
+ files = []
114
+
115
+ @source_paths.each do |path|
116
+ if File.file?(path)
117
+ files << path if extensions.any? { |ext| path.end_with?(ext) }
118
+ elsif File.directory?(path)
119
+ # Build a single glob pattern for all extensions
120
+ ext_pattern = extensions.size == 1 ? "*#{extensions.first}" : "*{#{extensions.join(',')}}"
121
+ files.concat(Dir.glob(File.join(path, '**', ext_pattern)))
122
+ end
123
+ end
124
+
125
+ # Apply ignore patterns and cache
126
+ @files_cache = files.reject { |f| ignored?(f) }
127
+ end
128
+
129
+ def ignored?(file)
130
+ @ignore_patterns.any? { |pattern| pattern.match?(file) }
131
+ end
132
+
133
+ def search_file(file, patterns, key, enable_multiline: true)
134
+ matches = []
135
+ lines = []
136
+ match_indices = Set.new
137
+
138
+ # Read file and find all matching line indices in a single pass
139
+ File.foreach(file).with_index do |line, index|
140
+ line = line.chomp
141
+ lines << line
142
+
143
+ # Check if any pattern matches this line
144
+ match_indices << index if patterns.any? { |pattern| pattern.match?(line) }
145
+ end
146
+
147
+ # For iOS files, also check for multi-line NSLocalizedString patterns
148
+ # where the function call and key are on different lines
149
+ if enable_multiline && @platform == :ios && file.end_with?('.swift', '.m', '.mm', '.h')
150
+ multiline_matches = find_multiline_ios_matches(lines, patterns, key)
151
+ match_indices.merge(multiline_matches)
152
+ end
153
+
154
+ # Build Match objects for each match with context
155
+ match_indices.each do |match_index|
156
+ context = extract_context(lines, match_index)
157
+ scope = extract_enclosing_scope(lines, match_index)
158
+ matches << Match.new(
159
+ file: file,
160
+ line: match_index + 1, # 1-indexed line numbers
161
+ match_line: lines[match_index],
162
+ context: context,
163
+ enclosing_scope: scope
164
+ )
165
+ end
166
+
167
+ matches
168
+ rescue Errno::ENOENT, Errno::EACCES, Errno::EISDIR => e
169
+ # Skip files that can't be read
170
+ warn "Warning: Could not read #{file}: #{e.message}" if $VERBOSE
171
+ []
172
+ rescue ArgumentError => e
173
+ # Skip files with encoding issues (binary files, etc.)
174
+ return [] if e.message.include?('invalid byte sequence')
175
+
176
+ raise
177
+ end
178
+
179
+ def search_ios_wrapper_usages(direct_matches, files)
180
+ references = direct_matches.filter_map do |match|
181
+ ios_wrapper_reference(match)
182
+ end.uniq
183
+
184
+ references.flat_map do |reference|
185
+ matches = []
186
+ local_pattern = ios_wrapper_local_pattern(reference)
187
+ qualified_pattern = ios_wrapper_qualified_pattern(reference)
188
+
189
+ matches.concat(
190
+ search_file(reference[:definition_file], [local_pattern], reference[:member_name], enable_multiline: false)
191
+ )
192
+
193
+ cross_file_pattern = qualified_pattern || local_pattern
194
+ cross_file_candidates = if qualified_pattern || reference[:type_path].size == 1
195
+ files.reject { |file| file == reference[:definition_file] }
196
+ else
197
+ []
198
+ end
199
+
200
+ cross_file_candidates.each do |file|
201
+ matches.concat(search_file(file, [cross_file_pattern], reference[:member_name], enable_multiline: false))
202
+ end
203
+
204
+ matches.reject do |match|
205
+ match.file == reference[:definition_file] && match.line == reference[:definition_line]
206
+ end
207
+ end
208
+ end
209
+
210
+ def ios_wrapper_reference(match)
211
+ return unless File.extname(match.file).downcase == '.swift'
212
+
213
+ lines = cached_file_lines(match.file)
214
+ definition_index = find_ios_wrapper_definition_index(lines, match.line - 1)
215
+ return unless definition_index
216
+
217
+ definition_line = lines[definition_index]
218
+ definition_match = IOS_WRAPPER_DEFINITION_PATTERN.match(definition_line)
219
+ return unless definition_match&.captures&.first
220
+
221
+ type_path = find_ios_type_path(lines, definition_index)
222
+ return if type_path.empty?
223
+
224
+ {
225
+ type_path: type_path,
226
+ member_name: definition_match[2],
227
+ definition_file: match.file,
228
+ definition_line: definition_index + 1
229
+ }
230
+ end
231
+
232
+ def cached_file_lines(file)
233
+ @file_lines_cache[file] ||= File.readlines(file, chomp: true)
234
+ end
235
+
236
+ def find_ios_wrapper_definition_index(lines, match_index, lookback: 5)
237
+ start_idx = [0, match_index - lookback].max
238
+
239
+ match_index.downto(start_idx) do |index|
240
+ return index if IOS_WRAPPER_DEFINITION_PATTERN.match?(lines[index])
241
+ end
242
+
243
+ nil
244
+ end
245
+
246
+ def find_ios_type_path(lines, index)
247
+ scope_stack = []
248
+ brace_depth = 0
249
+ pending_type = nil
250
+
251
+ lines[0..index].each do |line|
252
+ if (type_match = IOS_TYPE_DECLARATION_PATTERN.match(line))
253
+ if line.include?('{')
254
+ scope_stack << { name: type_match[2], depth: brace_depth + 1 }
255
+ else
256
+ pending_type = { name: type_match[2], depth: brace_depth + 1 }
257
+ end
258
+ elsif pending_type && line.include?('{')
259
+ scope_stack << pending_type
260
+ pending_type = nil
261
+ end
262
+
263
+ brace_depth += line.count('{') - line.count('}')
264
+ scope_stack.pop while scope_stack.any? && scope_stack.last[:depth] > brace_depth
265
+ end
266
+
267
+ scope_stack.map { |scope| scope[:name] }
268
+ end
269
+
270
+ def ios_wrapper_local_pattern(reference)
271
+ local_type_name = reference[:type_path].last
272
+ Regexp.new("\\b#{Regexp.escape(local_type_name)}\\.#{Regexp.escape(reference[:member_name])}\\b")
273
+ end
274
+
275
+ def ios_wrapper_qualified_pattern(reference)
276
+ return nil unless reference[:type_path].size > 1
277
+
278
+ qualified_type_path = reference[:type_path].join('.')
279
+ Regexp.new("\\b#{Regexp.escape(qualified_type_path)}\\.#{Regexp.escape(reference[:member_name])}\\b")
280
+ end
281
+
282
+ # Find matches where localization calls span multiple lines
283
+ # e.g., NSLocalizedString(\n "key",\n comment: "...")
284
+ def find_multiline_ios_matches(lines, patterns, key)
285
+ key_pattern = /["']#{Regexp.escape(key)}["']/
286
+
287
+ lines.each_with_index.filter_map do |line, index|
288
+ next if patterns.any? { |p| p.match?(line) } # Already a single-line match
289
+ next unless key_pattern.match?(line) # Doesn't contain the key
290
+
291
+ index if preceded_by_localization_opener?(lines, index)
292
+ end.to_set
293
+ end
294
+
295
+ def preceded_by_localization_opener?(lines, index, lookback: 5)
296
+ start_idx = [0, index - lookback].max
297
+
298
+ (start_idx...index).reverse_each do |i|
299
+ line = lines[i]
300
+ return true if IOS_FUNCTION_OPENERS.any? { |opener| opener.match?(line) }
301
+ return false if line =~ /;\s*$/ || line =~ /\)\s*$/ # Hit a statement boundary
302
+ end
303
+
304
+ false
305
+ end
306
+
307
+ # Scan backwards from the match to find the nearest enclosing function/class/struct
308
+ def extract_enclosing_scope(lines, match_index)
309
+ match_index.downto(0) do |i|
310
+ line = lines[i]
311
+ return "#{::Regexp.last_match(1)} #{::Regexp.last_match(2)}" if line =~ /\b(func|class|struct|enum|protocol)\s+(\w+)/
312
+ # Android/Kotlin patterns
313
+ return "#{::Regexp.last_match(1)} #{::Regexp.last_match(2)}" if line =~ /\b(fun|class|object)\s+(\w+)/
314
+ end
315
+ nil
316
+ end
317
+
318
+ def extract_context(lines, match_index)
319
+ start_idx = [0, match_index - @context_lines].max
320
+ end_idx = [lines.length - 1, match_index + @context_lines].min
321
+
322
+ context_parts = (start_idx..end_idx).map do |i|
323
+ i == match_index ? ">>> #{lines[i]}" : lines[i]
324
+ end
325
+
326
+ context_parts.join("\n")
327
+ end
328
+
329
+ def build_search_patterns(key)
330
+ pattern_strings = case @platform
331
+ when :ios
332
+ build_ios_patterns(key)
333
+ when :android
334
+ build_android_patterns(key)
335
+ else
336
+ build_ios_patterns(key) + build_android_patterns(key) + [Regexp.escape(key)]
337
+ end
338
+
339
+ # Pre-compile all patterns for this search
340
+ pattern_strings.map { |p| Regexp.new(p) }
341
+ end
342
+
343
+ # Extract the base resource name from composite Android keys
344
+ # e.g., "post_likes_count:one" -> "post_likes_count"
345
+ # "days_of_week[0]" -> "days_of_week"
346
+ def android_base_key(key)
347
+ key.sub(/:[a-z]+$/, '').sub(/\[\d+\]$/, '')
348
+ end
349
+
350
+ # Patterns that indicate the start of a localization function call
351
+ # Used for multi-line matching when the key is on a different line
352
+ IOS_FUNCTION_OPENERS = [
353
+ /NSLocalizedString\s*\(\s*$/,
354
+ /String\s*\(\s*localized:\s*$/,
355
+ /LocalizedStringKey\s*\(\s*$/,
356
+ /Text\s*\(\s*$/
357
+ ].freeze
358
+ private_constant :IOS_FUNCTION_OPENERS
359
+
360
+ def build_ios_patterns(key)
361
+ escaped = Regexp.escape(key)
362
+ [
363
+ # NSLocalizedString("key", ...) - most common (Swift and Obj-C)
364
+ # Note: @? handles optional @ prefix for Objective-C @"string" syntax
365
+ "NSLocalizedString\\s*\\(\\s*@?[\"']#{escaped}[\"']",
366
+ # String(localized: "key", ...) - modern Swift
367
+ "String\\s*\\(\\s*localized:\\s*[\"']#{escaped}[\"']",
368
+ # LocalizedStringKey("key") - SwiftUI
369
+ "LocalizedStringKey\\s*\\(\\s*[\"']#{escaped}[\"']",
370
+ # Text("key") - SwiftUI (when using localized strings)
371
+ "Text\\s*\\(\\s*[\"']#{escaped}[\"']",
372
+ # .localized extension pattern
373
+ "[\"']#{escaped}[\"']\\.localized"
374
+ ]
375
+ end
376
+
377
+ def build_android_patterns(key)
378
+ base = android_base_key(key)
379
+ escaped_base = Regexp.escape(base)
380
+
381
+ if key =~ /:[a-z]+$/
382
+ # Plural key (e.g., "post_likes_count:one") — search by base name in plural resources
383
+ [
384
+ "R\\.plurals\\.#{escaped_base}\\b",
385
+ "@plurals/#{escaped_base}\\b",
386
+ "getQuantityString\\s*\\(\\s*R\\.plurals\\.#{escaped_base}",
387
+ "\\.getQuantityString\\s*\\(\\s*R\\.plurals\\.#{escaped_base}",
388
+ "pluralStringResource\\s*\\(\\s*R\\.plurals\\.#{escaped_base}",
389
+ "[\\(\\s,=]plurals\\.#{escaped_base}\\b"
390
+ ]
391
+ elsif key =~ /\[\d+\]$/
392
+ # Array key (e.g., "days_of_week[0]") — search by base name in array resources
393
+ [
394
+ "R\\.array\\.#{escaped_base}\\b",
395
+ "@array/#{escaped_base}\\b",
396
+ "getStringArray\\s*\\(\\s*R\\.array\\.#{escaped_base}",
397
+ "\\.getStringArray\\s*\\(\\s*R\\.array\\.#{escaped_base}",
398
+ "resources\\.getStringArray\\s*\\(\\s*R\\.array\\.#{escaped_base}",
399
+ "[\\(\\s,=]array\\.#{escaped_base}\\b"
400
+ ]
401
+ else
402
+ # Standard string key
403
+ escaped = Regexp.escape(key)
404
+ [
405
+ "R\\.string\\.#{escaped}\\b",
406
+ "@string/#{escaped}\\b",
407
+ "getString\\s*\\(\\s*R\\.string\\.#{escaped}",
408
+ "\\.getString\\s*\\(\\s*R\\.string\\.#{escaped}",
409
+ "stringResource\\s*\\(\\s*R\\.string\\.#{escaped}",
410
+ "[\\(\\s,=]string\\.#{escaped}\\b",
411
+ "getString\\s*\\(\\s*string\\.#{escaped}",
412
+ "stringResource\\s*\\(\\s*string\\.#{escaped}"
413
+ ]
414
+ end
415
+ end
416
+
417
+ def filter_matches(matches, key)
418
+ seen = Set.new
419
+ matches.select do |match|
420
+ location = "#{match.file}:#{match.line}"
421
+ next false if seen.include?(location)
422
+ next false if false_positive?(match.match_line, key)
423
+ next false if translation_file?(match.file)
424
+
425
+ seen.add(location)
426
+ end
427
+ end
428
+
429
+ def false_positive?(line, _key)
430
+ return false if line.nil? || line.empty?
431
+
432
+ FALSE_POSITIVE_PATTERNS.any? { |pattern| pattern.match?(line) }
433
+ end
434
+
435
+ def translation_file?(file)
436
+ basename = File.basename(file).downcase
437
+ ext = File.extname(file).downcase
438
+
439
+ # Skip translation files - we want code usage, not definitions
440
+ return true if ext == '.strings'
441
+ return true if basename == 'strings.xml'
442
+ return true if file.include?('/res/values') && ext == '.xml'
443
+
444
+ false
445
+ end
446
+ end
447
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module I18nContextGenerator
4
+ VERSION = '0.3.0'
5
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module I18nContextGenerator
4
+ module Writers
5
+ # Writer that updates Android strings.xml files with context comments
6
+ # Uses line-by-line approach to preserve original formatting
7
+ class AndroidXmlWriter
8
+ include Helpers
9
+
10
+ def initialize(context_prefix: 'Context: ', context_mode: 'replace')
11
+ @context_prefix = context_prefix
12
+ @context_mode = context_mode
13
+ end
14
+
15
+ def write(results, source_path)
16
+ return unless File.exist?(source_path)
17
+
18
+ lines = File.readlines(source_path, encoding: 'UTF-8')
19
+ results_by_key = build_results_lookup(results, source_path)
20
+
21
+ output_lines = []
22
+ i = 0
23
+
24
+ while i < lines.length
25
+ line = lines[i]
26
+
27
+ # Only write comments on <string> elements, not <plurals> or <string-array>.
28
+ # Plural/array parent comments would use a single child's description which
29
+ # is misleading for the resource as a whole.
30
+ # Match the opening <string name="..."> tag — works for both single-line and
31
+ # multi-line string elements.
32
+ if (match = line.match(/^(\s*)<string\s+name="([^"]+)"/))
33
+ indent = match[1]
34
+ key = match[2]
35
+ result = results_by_key[key]
36
+
37
+ insert_context_comment(output_lines, indent, result.description) if writable_result?(result)
38
+ end
39
+
40
+ output_lines << line
41
+ i += 1
42
+ end
43
+
44
+ File.write(source_path, output_lines.join)
45
+ end
46
+
47
+ private
48
+
49
+ # Build a lookup that maps base resource names to results.
50
+ # For plural keys like "post_likes_count:one", maps "post_likes_count" to a result.
51
+ # For array keys like "days_of_week[0]", maps "days_of_week" to a result.
52
+ # Standard string keys map directly.
53
+ # Results are sorted by key first so the lookup is deterministic regardless
54
+ # of concurrent execution order.
55
+ def build_results_lookup(results, source_path = nil)
56
+ lookup = {}
57
+ results.sort_by(&:key).each do |r|
58
+ next if source_path && !result_matches_source_path?(r, source_path)
59
+
60
+ base = r.key.sub(/:[a-z]+$/, '').sub(/\[\d+\]$/, '')
61
+ lookup[base] ||= r
62
+ lookup[r.key] ||= r
63
+ end
64
+ lookup
65
+ end
66
+
67
+ def insert_context_comment(output_lines, indent, description)
68
+ context_text = "#{@context_prefix}#{escape_comment(description)}"
69
+
70
+ if output_lines.any? && output_lines.last.match?(/^\s*<!--.*-->\s*$/)
71
+ existing_match = output_lines.last.match(/^\s*<!--\s*(.*?)\s*-->\s*$/)
72
+ existing_comment = existing_match ? existing_match[1] : ''
73
+
74
+ if managed_comment?(existing_comment)
75
+ # Update existing context comment
76
+ output_lines.pop
77
+ new_comment = build_comment(existing_comment, context_text)
78
+ output_lines << "#{indent}<!-- #{new_comment} -->\n"
79
+ else
80
+ # Preceding comment is not ours (e.g. a section header) — leave it, insert new
81
+ output_lines << "#{indent}<!-- #{context_text} -->\n"
82
+ end
83
+ else
84
+ output_lines << "#{indent}<!-- #{context_text} -->\n"
85
+ end
86
+ end
87
+
88
+ # A comment is managed by us if it contains the configured context prefix.
89
+ # When prefix is empty, we cannot distinguish managed from unmanaged comments,
90
+ # so we always treat the preceding comment as replaceable — the user accepted
91
+ # this trade-off by choosing an empty prefix.
92
+ def managed_comment?(comment)
93
+ @context_prefix.empty? || comment.include?(@context_prefix)
94
+ end
95
+
96
+ def build_comment(existing_comment, context_text)
97
+ if existing_comment.nil? || existing_comment.empty? || @context_mode == 'replace'
98
+ context_text
99
+ elsif !@context_prefix.empty? && existing_comment.include?(@context_prefix)
100
+ # Replace existing context line (idempotent update)
101
+ existing_comment.gsub(/#{Regexp.escape(@context_prefix)}[^\n]*/, context_text)
102
+ else
103
+ # Append context to existing comment
104
+ "#{existing_comment} #{context_text}"
105
+ end
106
+ end
107
+
108
+ def escape_comment(text)
109
+ # Remove any existing comment markers and newlines
110
+ text
111
+ .gsub('--', '- -') # Double dash not allowed in XML comments
112
+ .gsub("\n", ' ')
113
+ .strip
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module I18nContextGenerator
4
+ module Writers
5
+ # Writes extraction results to a CSV file.
6
+ class CsvWriter
7
+ HEADERS = %w[key text description ui_element tone max_length locations error].freeze
8
+ DANGEROUS_CSV_PREFIX = /\A[ \t\r\n]*[=+\-@]/
9
+
10
+ def write(results, path)
11
+ CSV.open(path, 'w') do |csv|
12
+ csv << HEADERS
13
+
14
+ results.sort_by(&:key).each do |result|
15
+ csv << [
16
+ sanitize_cell(result.key),
17
+ sanitize_cell(result.text),
18
+ sanitize_cell(result.description),
19
+ sanitize_cell(result.ui_element),
20
+ sanitize_cell(result.tone),
21
+ result.max_length,
22
+ sanitize_cell(result.locations.join(';')),
23
+ sanitize_cell(result.error)
24
+ ]
25
+ end
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ def sanitize_cell(value)
32
+ return value unless value.is_a?(String)
33
+ return value unless value.match?(DANGEROUS_CSV_PREFIX)
34
+
35
+ "'#{value}"
36
+ end
37
+ end
38
+ end
39
+ end