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.
- checksums.yaml +7 -0
- data/LICENSE +373 -0
- data/README.md +282 -0
- data/exe/i18n-context-generator +7 -0
- data/lib/i18n_context_generator/cache.rb +49 -0
- data/lib/i18n_context_generator/cli.rb +223 -0
- data/lib/i18n_context_generator/config.rb +211 -0
- data/lib/i18n_context_generator/context_extractor.rb +381 -0
- data/lib/i18n_context_generator/git_diff.rb +159 -0
- data/lib/i18n_context_generator/llm/anthropic.rb +91 -0
- data/lib/i18n_context_generator/llm/client.rb +260 -0
- data/lib/i18n_context_generator/llm/openai.rb +112 -0
- data/lib/i18n_context_generator/parsers/android_xml_parser.rb +110 -0
- data/lib/i18n_context_generator/parsers/base.rb +60 -0
- data/lib/i18n_context_generator/parsers/json_parser.rb +21 -0
- data/lib/i18n_context_generator/parsers/strings_parser.rb +28 -0
- data/lib/i18n_context_generator/parsers/yaml_parser.rb +30 -0
- data/lib/i18n_context_generator/platform_validator.rb +92 -0
- data/lib/i18n_context_generator/searcher.rb +447 -0
- data/lib/i18n_context_generator/version.rb +5 -0
- data/lib/i18n_context_generator/writers/android_xml_writer.rb +117 -0
- data/lib/i18n_context_generator/writers/csv_writer.rb +39 -0
- data/lib/i18n_context_generator/writers/helpers.rb +58 -0
- data/lib/i18n_context_generator/writers/json_writer.rb +34 -0
- data/lib/i18n_context_generator/writers/strings_writer.rb +67 -0
- data/lib/i18n_context_generator/writers/swift_writer.rb +160 -0
- data/lib/i18n_context_generator.rb +36 -0
- metadata +196 -0
|
@@ -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,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
|