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
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module I18nContextGenerator
4
+ class Searcher
5
+ # Removes duplicate, translation-definition, and non-localization matches.
6
+ module MatchFiltering
7
+ private
8
+
9
+ def filter_matches(matches, key)
10
+ seen = Set.new
11
+ explicit_patterns = explicit_localization_patterns(key)
12
+ matches.select do |match|
13
+ location = "#{match.file}:#{match.line}"
14
+ next false if seen.include?(location)
15
+ next false if false_positive?(match.match_line, key, explicit_patterns)
16
+ next false if translation_file?(match.file)
17
+
18
+ seen.add(location)
19
+ end
20
+ end
21
+
22
+ def false_positive?(line, key, explicit_patterns)
23
+ return false if line.nil? || line.empty?
24
+ return false if explicit_localization_usage?(line, explicit_patterns)
25
+
26
+ quoted_key = "[\"']#{Regexp.escape(key)}[\"']"
27
+ comparison_patterns = [
28
+ /==\s*#{quoted_key}/,
29
+ /#{quoted_key}\s*==/,
30
+ /!=\s*#{quoted_key}/,
31
+ /#{quoted_key}\s*!=/,
32
+ /\.equals\(\s*#{quoted_key}/,
33
+ /contentEquals\(\s*#{quoted_key}/
34
+ ]
35
+
36
+ comparison_patterns.any? { |pattern| pattern.match?(line) }
37
+ end
38
+
39
+ def explicit_localization_patterns(key)
40
+ (build_ios_patterns(key) + build_android_patterns(key)).map { |pattern| Regexp.new(pattern) }
41
+ end
42
+
43
+ def explicit_localization_usage?(line, patterns)
44
+ patterns.any? { |pattern| pattern.match?(line) }
45
+ end
46
+
47
+ def translation_file?(file)
48
+ !FileClassifier.translation_platform(file).nil?
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,232 @@
1
+ # frozen_string_literal: true
2
+
3
+ module I18nContextGenerator
4
+ class Searcher
5
+ # Source-discovery helpers used for source-first extraction runs.
6
+ module SourceDiscovery
7
+ IOS_COMMENT_ARGUMENT_PATTERN = /\bcomment:\s*["'](?<comment>(?:\\.|[^"'\\])*)["']/
8
+
9
+ def discover_localization_entries
10
+ entries = discover_files.flat_map do |file|
11
+ discover_entries_in_file(file)
12
+ end
13
+
14
+ deduplicate_discovered_entries(entries)
15
+ end
16
+
17
+ private
18
+
19
+ def discover_entries_in_file(file)
20
+ case platform_for_file(file)
21
+ when :ios
22
+ discover_ios_entries(file)
23
+ when :android
24
+ discover_android_entries(file)
25
+ else
26
+ []
27
+ end
28
+ rescue Errno::ENOENT, Errno::EACCES, Errno::EISDIR => e
29
+ warn "Warning: Could not read #{file}: #{e.message}" if $VERBOSE
30
+ []
31
+ rescue ArgumentError => e
32
+ return [] if e.message.include?('invalid byte sequence')
33
+
34
+ raise
35
+ end
36
+
37
+ def deduplicate_discovered_entries(entries)
38
+ entries.each_with_object({}) do |entry, deduplicated_entries|
39
+ identity = [entry.resource_type, entry.key]
40
+ existing_entry = deduplicated_entries[identity]
41
+ if existing_entry.nil?
42
+ deduplicated_entries[identity] = entry
43
+ next
44
+ end
45
+
46
+ preferred_entry = if existing_entry.comment.to_s.empty? && !entry.comment.to_s.empty?
47
+ entry
48
+ else
49
+ existing_entry
50
+ end
51
+ deduplicated_entries[identity] = DiscoveredLocalization.new(
52
+ key: preferred_entry.key,
53
+ file: preferred_entry.file,
54
+ line: preferred_entry.line,
55
+ text: preferred_entry.text,
56
+ comment: preferred_entry.comment,
57
+ resource_type: preferred_entry.resource_type,
58
+ locations: (existing_entry.locations + entry.locations).uniq,
59
+ location_groups: (existing_entry.location_groups + entry.location_groups).uniq
60
+ )
61
+ end.values
62
+ end
63
+
64
+ def platform_for_file(file)
65
+ FileClassifier.searchable_platform(file, platform_hint: @platform)
66
+ end
67
+
68
+ def discover_ios_entries(file)
69
+ lines = searchable_file_lines(file)
70
+ entries = []
71
+ index = 0
72
+
73
+ while index < lines.length
74
+ line = lines[index]
75
+ next_index, discovered_entry = extract_ios_entry(lines, file, index, line)
76
+ entries << discovered_entry if discovered_entry
77
+ index = next_index || (index + 1)
78
+ end
79
+
80
+ entries
81
+ end
82
+
83
+ def extract_ios_entry(lines, file, index, line)
84
+ if @localization_syntax.ios_call_start_patterns.any? { |pattern| pattern.match?(line) }
85
+ next_index, discovered_entry = extract_ios_multiline_entry(lines, file, index)
86
+ return [next_index, discovered_entry] if discovered_entry
87
+ end
88
+
89
+ if (entry = extract_ios_single_line_entry(file, index, line))
90
+ return [index + 1, entry]
91
+ end
92
+
93
+ [index + 1, nil]
94
+ end
95
+
96
+ def extract_ios_single_line_entry(file, index, line)
97
+ pattern = @localization_syntax.ios_single_line_discovery_patterns.find { |candidate| candidate.match?(line) }
98
+ return unless pattern
99
+
100
+ build_ios_discovered_entry(
101
+ file,
102
+ index,
103
+ pattern.match(line),
104
+ comment_match: IOS_COMMENT_ARGUMENT_PATTERN.match(line)
105
+ )
106
+ end
107
+
108
+ def extract_ios_multiline_entry(lines, file, start_index, lookahead: 8)
109
+ end_index = ios_call_end_index(lines, start_index, lookahead: lookahead)
110
+ snippet = lines[start_index..end_index].join("\n")
111
+ pattern = @localization_syntax.ios_multiline_discovery_patterns.find { |candidate| candidate.match?(snippet) }
112
+ return [start_index + 1, nil] unless pattern
113
+
114
+ match = pattern.match(snippet)
115
+ key_line_index = locate_key_line(lines, start_index, end_index, match[:key])
116
+ comment_match = IOS_COMMENT_ARGUMENT_PATTERN.match(snippet)
117
+ locations = (start_index..end_index).map { |index| "#{file}:#{index + 1}" }
118
+
119
+ [
120
+ end_index + 1,
121
+ build_ios_discovered_entry(
122
+ file,
123
+ key_line_index || start_index,
124
+ match,
125
+ comment_match: comment_match,
126
+ locations: locations
127
+ )
128
+ ]
129
+ end
130
+
131
+ def build_ios_discovered_entry(file, index, match, comment_match: nil, locations: nil)
132
+ key = unescape_source_string(match[:key])
133
+ return if key.nil? || key.empty?
134
+
135
+ text = match.names.include?('text') ? match[:text] : nil
136
+ comment = if comment_match
137
+ unescape_source_string(comment_match[:comment])
138
+ elsif match.names.include?('comment')
139
+ unescape_source_string(match[:comment])
140
+ end
141
+ text = unescape_source_string(text) if text
142
+
143
+ DiscoveredLocalization.new(
144
+ key: key,
145
+ file: file,
146
+ line: index + 1,
147
+ text: text,
148
+ comment: comment,
149
+ locations: locations
150
+ )
151
+ end
152
+
153
+ def ios_call_end_index(lines, start_index, lookahead:)
154
+ maximum_index = [lines.length - 1, start_index + lookahead].min
155
+ depth = 0
156
+ found_opening = false
157
+
158
+ (start_index..maximum_index).each do |index|
159
+ parenthesis_delta(lines[index]).each do |delta|
160
+ found_opening = true if delta.positive?
161
+ depth += delta
162
+ end
163
+ return index if found_opening && depth <= 0
164
+ end
165
+
166
+ maximum_index
167
+ end
168
+
169
+ def parenthesis_delta(line)
170
+ deltas = []
171
+ quote = nil
172
+ escaped = false
173
+
174
+ line.each_char do |character|
175
+ if quote
176
+ if escaped
177
+ escaped = false
178
+ elsif character == '\\'
179
+ escaped = true
180
+ elsif character == quote
181
+ quote = nil
182
+ end
183
+ elsif ["'", '"'].include?(character)
184
+ quote = character
185
+ elsif character == '('
186
+ deltas << 1
187
+ elsif character == ')'
188
+ deltas << -1
189
+ end
190
+ end
191
+ deltas
192
+ end
193
+
194
+ def locate_key_line(lines, start_index, end_index, key)
195
+ (start_index..end_index).find do |index|
196
+ lines[index].include?("\"#{key}\"") || lines[index].include?("@\"#{key}\"")
197
+ end
198
+ end
199
+
200
+ def discover_android_entries(file)
201
+ lines = searchable_file_lines(file)
202
+ entries = []
203
+
204
+ lines.each_with_index do |line, index|
205
+ entries.concat(extract_android_entries_from_line(file, index, line))
206
+ end
207
+
208
+ entries
209
+ end
210
+
211
+ def extract_android_entries_from_line(file, index, line)
212
+ @localization_syntax.android_discovery_patterns.flat_map do |resource_type, pattern|
213
+ line.scan(pattern).filter_map do |captures|
214
+ key = captures.compact.first
215
+ next if key.nil? || key.empty?
216
+
217
+ DiscoveredLocalization.new(
218
+ key: key,
219
+ file: file,
220
+ line: index + 1,
221
+ resource_type: resource_type
222
+ )
223
+ end
224
+ end
225
+ end
226
+
227
+ def unescape_source_string(text)
228
+ AppleStringLiteral.decode(text)
229
+ end
230
+ end
231
+ end
232
+ end
@@ -1,10 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'find'
3
+ require 'concurrent'
4
+ require_relative 'path_policy'
5
+ require_relative 'file_classifier'
6
+ require_relative 'localization_syntax'
7
+ require_relative 'searcher/comment_masking'
8
+ require_relative 'searcher/match_filtering'
9
+ require_relative 'searcher/source_discovery'
4
10
 
5
11
  module I18nContextGenerator
6
12
  # Finds where translation keys are used in iOS and Android source code.
7
13
  class Searcher
14
+ include CommentMasking
15
+ include MatchFiltering
16
+ include SourceDiscovery
17
+
8
18
  # Represents a code match with surrounding context
9
19
  Match = Data.define(:file, :line, :match_line, :context, :enclosing_scope) do
10
20
  def initialize(file:, line:, match_line: '', context: '', enclosing_scope: nil)
@@ -12,40 +22,36 @@ module I18nContextGenerator
12
22
  end
13
23
  end
14
24
 
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*\()/
25
+ # Represents a localization entry discovered directly from source code.
26
+ DiscoveredLocalization = Data.define(
27
+ :key, :file, :line, :text, :comment, :resource_type, :locations, :location_groups
28
+ ) do
29
+ def initialize(key:, file:, line:, text: nil, comment: nil, resource_type: :string, locations: nil,
30
+ location_groups: nil)
31
+ locations ||= ["#{file}:#{line}"]
32
+ location_groups ||= [locations]
33
+ super
34
+ end
35
+ end
36
+
34
37
  IOS_TYPE_DECLARATION_PATTERN = /\b(class|struct|enum|extension)\s+(\w+)/
35
38
 
36
- def initialize(source_paths:, ignore_patterns:, context_lines: 15, platform: nil)
39
+ def initialize(source_paths:, ignore_patterns:, context_lines: 15, platform: nil, swift_functions: nil)
37
40
  @source_paths = source_paths
38
- @ignore_patterns = compile_ignore_patterns(ignore_patterns)
41
+ @path_policy = PathPolicy.new(ignore_patterns: ignore_patterns, roots: source_paths)
39
42
  @context_lines = context_lines
40
43
  @platform = platform || detect_platform
44
+ @localization_syntax = LocalizationSyntax.new(swift_functions: swift_functions)
41
45
 
42
46
  # Cache discovered files for repeated searches
43
47
  @files_cache = nil
44
- @file_lines_cache = {}
48
+ @files_cache_mutex = Mutex.new
49
+ @file_lines_cache = Concurrent::Map.new
50
+ @searchable_file_lines_cache = Concurrent::Map.new
45
51
  end
46
52
 
47
- def search(key)
48
- patterns = build_search_patterns(key)
53
+ def search(key, resource_type: nil)
54
+ patterns = build_search_patterns(key, resource_type: resource_type)
49
55
  files = discover_files
50
56
  direct_matches = []
51
57
 
@@ -67,87 +73,47 @@ module I18nContextGenerator
67
73
 
68
74
  def detect_platform
69
75
  @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
76
+ @path_policy.each_file(path) do |file|
77
+ platform = FileClassifier.source_platform(file)
78
+ return platform if platform
89
79
  end
90
80
  end
91
81
  :unknown
92
82
  end
93
83
 
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
84
  def discover_files
110
85
  return @files_cache if @files_cache
111
86
 
112
- extensions = FILE_EXTENSIONS[@platform] || FILE_EXTENSIONS[:unknown]
113
- files = []
87
+ @files_cache_mutex.synchronize do
88
+ return @files_cache if @files_cache
114
89
 
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)))
90
+ @files_cache = @path_policy.files(@source_paths) do |file|
91
+ FileClassifier.searchable_source?(file, platform: @platform)
122
92
  end
123
93
  end
124
-
125
- # Apply ignore patterns and cache
126
- @files_cache = files.reject { |f| ignored?(f) }
127
94
  end
128
95
 
129
- def ignored?(file)
130
- @ignore_patterns.any? { |pattern| pattern.match?(file) }
96
+ def ignored?(file, directory: false)
97
+ @path_policy.ignored?(file, directory: directory)
131
98
  end
132
99
 
133
100
  def search_file(file, patterns, key, enable_multiline: true)
134
101
  matches = []
135
- lines = []
102
+ lines = cached_file_lines(file)
103
+ searchable_lines = searchable_file_lines(file)
136
104
  match_indices = Set.new
137
105
 
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
-
106
+ # Find matching line indices using the comment-masked source while
107
+ # retaining the original lines for prompt context and output.
108
+ searchable_lines.each_with_index do |line, index|
143
109
  # Check if any pattern matches this line
144
110
  match_indices << index if patterns.any? { |pattern| pattern.match?(line) }
145
111
  end
146
112
 
147
113
  # For iOS files, also check for multi-line NSLocalizedString patterns
148
114
  # 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)
115
+ if enable_multiline && @platform == :ios && FileClassifier.searchable_platform(file) == :ios
116
+ multiline_matches = find_multiline_ios_matches(searchable_lines, patterns, key)
151
117
  match_indices.merge(multiline_matches)
152
118
  end
153
119
 
@@ -215,29 +181,35 @@ module I18nContextGenerator
215
181
  return unless definition_index
216
182
 
217
183
  definition_line = lines[definition_index]
218
- definition_match = IOS_WRAPPER_DEFINITION_PATTERN.match(definition_line)
219
- return unless definition_match&.captures&.first
184
+ definition_match = @localization_syntax.ios_wrapper_definition_pattern.match(definition_line)
185
+ return unless definition_match
220
186
 
221
187
  type_path = find_ios_type_path(lines, definition_index)
222
188
  return if type_path.empty?
223
189
 
224
190
  {
225
191
  type_path: type_path,
226
- member_name: definition_match[2],
192
+ member_name: definition_match[:member_name],
227
193
  definition_file: match.file,
228
194
  definition_line: definition_index + 1
229
195
  }
230
196
  end
231
197
 
232
198
  def cached_file_lines(file)
233
- @file_lines_cache[file] ||= File.readlines(file, chomp: true)
199
+ @file_lines_cache.compute_if_absent(file) { File.readlines(file, chomp: true) }
200
+ end
201
+
202
+ def searchable_file_lines(file)
203
+ @searchable_file_lines_cache.compute_if_absent(file) do
204
+ mask_comments(cached_file_lines(file), file)
205
+ end
234
206
  end
235
207
 
236
208
  def find_ios_wrapper_definition_index(lines, match_index, lookback: 5)
237
209
  start_idx = [0, match_index - lookback].max
238
210
 
239
211
  match_index.downto(start_idx) do |index|
240
- return index if IOS_WRAPPER_DEFINITION_PATTERN.match?(lines[index])
212
+ return index if @localization_syntax.ios_wrapper_definition_pattern.match?(lines[index])
241
213
  end
242
214
 
243
215
  nil
@@ -282,22 +254,24 @@ module I18nContextGenerator
282
254
  # Find matches where localization calls span multiple lines
283
255
  # e.g., NSLocalizedString(\n "key",\n comment: "...")
284
256
  def find_multiline_ios_matches(lines, patterns, key)
285
- key_pattern = /["']#{Regexp.escape(key)}["']/
257
+ key_pattern = /@?"#{LocalizationSyntax.swift_string_content_pattern(key)}"/
258
+ call_patterns = @localization_syntax.ios_multiline_search_patterns(key)
286
259
 
287
260
  lines.each_with_index.filter_map do |line, index|
288
261
  next if patterns.any? { |p| p.match?(line) } # Already a single-line match
289
262
  next unless key_pattern.match?(line) # Doesn't contain the key
290
263
 
291
- index if preceded_by_localization_opener?(lines, index)
264
+ index if preceded_by_localization_call?(lines, index, call_patterns)
292
265
  end.to_set
293
266
  end
294
267
 
295
- def preceded_by_localization_opener?(lines, index, lookback: 5)
268
+ def preceded_by_localization_call?(lines, index, patterns, lookback: 5)
296
269
  start_idx = [0, index - lookback].max
297
270
 
298
271
  (start_idx...index).reverse_each do |i|
299
272
  line = lines[i]
300
- return true if IOS_FUNCTION_OPENERS.any? { |opener| opener.match?(line) }
273
+ snippet = lines[i..index].join("\n")
274
+ return true if patterns.any? { |pattern| pattern.match?(snippet) }
301
275
  return false if line =~ /;\s*$/ || line =~ /\)\s*$/ # Hit a statement boundary
302
276
  end
303
277
 
@@ -326,122 +300,16 @@ module I18nContextGenerator
326
300
  context_parts.join("\n")
327
301
  end
328
302
 
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+\]$/, '')
303
+ def build_search_patterns(key, resource_type: nil)
304
+ @localization_syntax.search_patterns(key, platform: @platform, resource_type: resource_type)
348
305
  end
349
306
 
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
307
  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
- ]
308
+ @localization_syntax.ios_search_patterns(key)
375
309
  end
376
310
 
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
311
+ def build_android_patterns(key, resource_type: nil)
312
+ @localization_syntax.android_search_patterns(key, resource_type: resource_type)
445
313
  end
446
314
  end
447
315
  end