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.
- checksums.yaml +4 -4
- data/README.md +163 -26
- data/lib/i18n_context_generator/android_resource.rb +209 -0
- data/lib/i18n_context_generator/apple_string_literal.rb +28 -0
- data/lib/i18n_context_generator/cache.rb +40 -8
- data/lib/i18n_context_generator/changed_location.rb +55 -0
- data/lib/i18n_context_generator/cli.rb +165 -73
- data/lib/i18n_context_generator/config/cli_values.rb +33 -0
- data/lib/i18n_context_generator/config/defaults.rb +35 -0
- data/lib/i18n_context_generator/config/schema.rb +225 -0
- data/lib/i18n_context_generator/config/serialization.rb +64 -0
- data/lib/i18n_context_generator/config/validation.rb +249 -0
- data/lib/i18n_context_generator/config.rb +315 -105
- data/lib/i18n_context_generator/context_extractor/cache_identity.rb +65 -0
- data/lib/i18n_context_generator/context_extractor/extraction_result.rb +46 -0
- data/lib/i18n_context_generator/context_extractor/run_logging.rb +65 -0
- data/lib/i18n_context_generator/context_extractor/source_entries.rb +103 -0
- data/lib/i18n_context_generator/context_extractor/source_filters.rb +104 -0
- data/lib/i18n_context_generator/context_extractor/translation_filters.rb +91 -0
- data/lib/i18n_context_generator/context_extractor/workflow.rb +118 -0
- data/lib/i18n_context_generator/context_extractor.rb +218 -164
- data/lib/i18n_context_generator/file_classifier.rb +72 -0
- data/lib/i18n_context_generator/generated_comment.rb +32 -0
- data/lib/i18n_context_generator/git_diff/xml_changes.rb +235 -0
- data/lib/i18n_context_generator/git_diff.rb +280 -88
- data/lib/i18n_context_generator/llm/anthropic.rb +65 -38
- data/lib/i18n_context_generator/llm/client.rb +294 -92
- data/lib/i18n_context_generator/llm/openai.rb +92 -51
- data/lib/i18n_context_generator/llm/openai_compatible.rb +20 -0
- data/lib/i18n_context_generator/llm/prompt_evidence.rb +47 -0
- data/lib/i18n_context_generator/llm/request_policy.rb +147 -0
- data/lib/i18n_context_generator/localization_syntax.rb +246 -0
- data/lib/i18n_context_generator/parsers/android_xml_parser.rb +47 -8
- data/lib/i18n_context_generator/parsers/base.rb +7 -4
- data/lib/i18n_context_generator/parsers/json_parser.rb +4 -0
- data/lib/i18n_context_generator/parsers/xcstrings_parser.rb +79 -0
- data/lib/i18n_context_generator/parsers/yaml_parser.rb +20 -7
- data/lib/i18n_context_generator/path_policy.rb +107 -0
- data/lib/i18n_context_generator/platform_validator.rb +24 -50
- data/lib/i18n_context_generator/run_metrics.rb +47 -0
- data/lib/i18n_context_generator/searcher/comment_masking.rb +115 -0
- data/lib/i18n_context_generator/searcher/match_filtering.rb +52 -0
- data/lib/i18n_context_generator/searcher/source_discovery.rb +232 -0
- data/lib/i18n_context_generator/searcher.rb +69 -201
- data/lib/i18n_context_generator/supplemental_context.rb +77 -0
- data/lib/i18n_context_generator/translation_comment_index.rb +121 -0
- data/lib/i18n_context_generator/version.rb +1 -1
- data/lib/i18n_context_generator/writers/android_xml_writer.rb +48 -18
- data/lib/i18n_context_generator/writers/atomic_file.rb +37 -0
- data/lib/i18n_context_generator/writers/csv_writer.rb +43 -18
- data/lib/i18n_context_generator/writers/helpers.rb +7 -29
- data/lib/i18n_context_generator/writers/json_writer.rb +31 -6
- data/lib/i18n_context_generator/writers/preview.rb +80 -0
- data/lib/i18n_context_generator/writers/result_serialization.rb +30 -0
- data/lib/i18n_context_generator/writers/strings_writer.rb +23 -12
- data/lib/i18n_context_generator/writers/swift_writer.rb +16 -54
- data/lib/i18n_context_generator/writers/xcstrings_writer.rb +57 -0
- data/lib/i18n_context_generator/xcstrings_document.rb +218 -0
- data/lib/i18n_context_generator/xml_scanner.rb +38 -0
- data/lib/i18n_context_generator.rb +20 -4
- 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 '
|
|
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
|
-
#
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
@
|
|
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
|
-
@
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
113
|
-
|
|
87
|
+
@files_cache_mutex.synchronize do
|
|
88
|
+
return @files_cache if @files_cache
|
|
114
89
|
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
@
|
|
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
|
-
#
|
|
139
|
-
|
|
140
|
-
|
|
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 &&
|
|
150
|
-
multiline_matches = find_multiline_ios_matches(
|
|
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 =
|
|
219
|
-
return unless definition_match
|
|
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[
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
264
|
+
index if preceded_by_localization_call?(lines, index, call_patterns)
|
|
292
265
|
end.to_set
|
|
293
266
|
end
|
|
294
267
|
|
|
295
|
-
def
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|