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,235 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../android_resource'
|
|
4
|
+
require_relative '../translation_comment_index'
|
|
5
|
+
require_relative '../xml_scanner'
|
|
6
|
+
|
|
7
|
+
module I18nContextGenerator
|
|
8
|
+
# Android XML diff parsing kept separate from Git command/path orchestration.
|
|
9
|
+
module GitDiffXmlChanges
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
def extract_xml_key_locations(diff_output, file_path, base_content: nil, head_content: nil)
|
|
13
|
+
extract_xml_changes(
|
|
14
|
+
diff_output,
|
|
15
|
+
file_path,
|
|
16
|
+
base_content: base_content,
|
|
17
|
+
head_content: head_content
|
|
18
|
+
)[:locations]
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def extract_xml_changes(diff_output, file_path, base_content: nil, head_content: nil)
|
|
22
|
+
state = initial_xml_diff_state(file_path)
|
|
23
|
+
parse_xml_diff!(state, diff_output)
|
|
24
|
+
resolve_orphaned_items(
|
|
25
|
+
state[:keys],
|
|
26
|
+
state[:orphaned_item_file_lines],
|
|
27
|
+
file_path,
|
|
28
|
+
locations: state[:locations],
|
|
29
|
+
content: head_content
|
|
30
|
+
)
|
|
31
|
+
add_comment_only_locations(
|
|
32
|
+
state[:locations],
|
|
33
|
+
state[:added_file_lines],
|
|
34
|
+
file_path,
|
|
35
|
+
format: :xml,
|
|
36
|
+
content: head_content
|
|
37
|
+
) { |line_number| line_number }
|
|
38
|
+
|
|
39
|
+
typed_locations = state[:locations].transform_values do |lines|
|
|
40
|
+
lines.map { |line| changed_location(file_path, line, side: :right) }
|
|
41
|
+
end
|
|
42
|
+
merge_removed_xml_locations!(
|
|
43
|
+
typed_locations,
|
|
44
|
+
diff_output,
|
|
45
|
+
file_path,
|
|
46
|
+
base_content: base_content,
|
|
47
|
+
head_content: head_content
|
|
48
|
+
)
|
|
49
|
+
typed_locations = prefer_right_locations(typed_locations)
|
|
50
|
+
state[:keys].merge(typed_locations.keys)
|
|
51
|
+
|
|
52
|
+
{ keys: state[:keys], locations: typed_locations }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def initial_xml_diff_state(file_path)
|
|
56
|
+
{
|
|
57
|
+
keys: Set.new,
|
|
58
|
+
locations: Hash.new { |hash, key| hash[key] = Set.new },
|
|
59
|
+
current_parent: nil,
|
|
60
|
+
current_string: nil,
|
|
61
|
+
file_line: nil,
|
|
62
|
+
orphaned_item_file_lines: [],
|
|
63
|
+
pending_tag: nil,
|
|
64
|
+
file_path: file_path,
|
|
65
|
+
added_file_lines: [],
|
|
66
|
+
xml_comment_state: {}
|
|
67
|
+
}
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def parse_xml_diff!(state, diff_output)
|
|
71
|
+
diff_output.each_line do |line|
|
|
72
|
+
next if update_xml_hunk_line?(state, line)
|
|
73
|
+
next if line.start_with?('diff ', 'index ', '--- ', '+++ ')
|
|
74
|
+
|
|
75
|
+
is_removed = line.start_with?('-')
|
|
76
|
+
is_added = line.start_with?('+')
|
|
77
|
+
content = line.sub(/^[ +-]/, '')
|
|
78
|
+
process_xml_diff_line(state, content, added: is_added) unless is_removed
|
|
79
|
+
state[:file_line] += 1 if state[:file_line] && !is_removed
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def process_xml_diff_line(state, content, added:)
|
|
84
|
+
state[:added_file_lines] << state[:file_line] if added && state[:file_line]
|
|
85
|
+
visible_content, contained_comment = XmlScanner.without_comments(
|
|
86
|
+
content,
|
|
87
|
+
state[:xml_comment_state]
|
|
88
|
+
)
|
|
89
|
+
process_xml_diff_content(
|
|
90
|
+
state,
|
|
91
|
+
visible_content,
|
|
92
|
+
added: added,
|
|
93
|
+
contained_comment: contained_comment
|
|
94
|
+
)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def update_xml_hunk_line?(state, line)
|
|
98
|
+
hunk = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/)
|
|
99
|
+
return false unless hunk
|
|
100
|
+
|
|
101
|
+
state[:file_line] = hunk[1].to_i
|
|
102
|
+
state[:current_parent] = nil
|
|
103
|
+
state[:current_string] = nil
|
|
104
|
+
state[:pending_tag] = nil
|
|
105
|
+
state[:xml_comment_state] = {}
|
|
106
|
+
true
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def process_xml_diff_content(state, content, added:, contained_comment: false)
|
|
110
|
+
meaningful_change = !content.strip.empty? || contained_comment
|
|
111
|
+
record_xml_change(state, state[:current_parent]) if added && meaningful_change && state[:current_parent]
|
|
112
|
+
record_xml_change(state, state[:current_string]) if added && meaningful_change && state[:current_string]
|
|
113
|
+
|
|
114
|
+
accumulate_xml_opening_tag(state, content, added: added)
|
|
115
|
+
complete_xml_opening_tag(state) if state.dig(:pending_tag, :content)&.include?('>')
|
|
116
|
+
track_xml_item_change(state) if added && content.match?(/<item\b/)
|
|
117
|
+
|
|
118
|
+
state[:current_string] = nil if content.match?(%r{</string>})
|
|
119
|
+
state[:current_parent] = nil if content.match?(%r{</(?:plurals|string-array)>})
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def accumulate_xml_opening_tag(state, content, added:)
|
|
123
|
+
if state[:pending_tag]
|
|
124
|
+
state[:pending_tag][:content] << content
|
|
125
|
+
state[:pending_tag][:added] ||= added
|
|
126
|
+
state[:pending_tag][:first_added_line] ||= state[:file_line] if added
|
|
127
|
+
elsif (tag_start = content.index(/<(?:string-array|plurals|string)\b/))
|
|
128
|
+
state[:pending_tag] = {
|
|
129
|
+
content: content[tag_start..],
|
|
130
|
+
added: added,
|
|
131
|
+
first_added_line: (state[:file_line] if added)
|
|
132
|
+
}
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def complete_xml_opening_tag(state)
|
|
137
|
+
tag = state[:pending_tag]
|
|
138
|
+
resource = resource_from_opening_tag(tag[:content])
|
|
139
|
+
if resource
|
|
140
|
+
record_xml_change(state, resource[:name], line: tag[:first_added_line]) if tag[:added]
|
|
141
|
+
track_open_xml_resource(state, resource, tag[:content])
|
|
142
|
+
end
|
|
143
|
+
state[:pending_tag] = nil
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def track_open_xml_resource(state, resource, content)
|
|
147
|
+
if resource[:type] == :string
|
|
148
|
+
state[:current_string] = resource[:name] unless content.include?('</string>')
|
|
149
|
+
elsif !content.include?("</#{AndroidResource.tag_for_type(resource[:type])}>")
|
|
150
|
+
state[:current_parent] = resource[:name]
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def track_xml_item_change(state)
|
|
155
|
+
if state[:current_parent]
|
|
156
|
+
record_xml_change(state, state[:current_parent])
|
|
157
|
+
elsif state[:file_line]
|
|
158
|
+
state[:orphaned_item_file_lines] << state[:file_line]
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def record_xml_change(state, key, line: state[:file_line])
|
|
163
|
+
return unless key
|
|
164
|
+
|
|
165
|
+
state[:keys] << key
|
|
166
|
+
state[:locations][key] << line if line
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def resource_from_opening_tag(tag)
|
|
170
|
+
type_match = tag.match(/<(string-array|plurals|string)\b/)
|
|
171
|
+
name_match = tag.match(/\bname\s*=\s*(["'])(.*?)\1/m)
|
|
172
|
+
return unless type_match && name_match
|
|
173
|
+
|
|
174
|
+
{ type: AndroidResource.type_for_tag(type_match[1]), name: name_match[2] }
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def resolve_orphaned_items(keys, orphaned_lines, file_path, locations: nil, content: nil)
|
|
178
|
+
return if orphaned_lines.empty?
|
|
179
|
+
return if content.nil? && !File.exist?(file_path)
|
|
180
|
+
|
|
181
|
+
resource_index = AndroidResource.index(content || File.read(file_path, encoding: 'UTF-8'))
|
|
182
|
+
|
|
183
|
+
orphaned_lines.each do |line_num|
|
|
184
|
+
parent = resource_index.base_key_at(line_num)
|
|
185
|
+
next unless parent
|
|
186
|
+
|
|
187
|
+
keys << parent
|
|
188
|
+
locations[parent] << line_num if locations
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def add_comment_only_locations(locations, added_lines, file_path, format:, content: nil)
|
|
193
|
+
return if content.nil? && !File.file?(file_path)
|
|
194
|
+
|
|
195
|
+
comment_index = TranslationCommentIndex.new(file_path, format: format, content: content)
|
|
196
|
+
|
|
197
|
+
added_lines.each do |line_number|
|
|
198
|
+
key = comment_index.key_at(line_number)
|
|
199
|
+
next unless key
|
|
200
|
+
next if locations[key].any?
|
|
201
|
+
|
|
202
|
+
locations[key] << yield(line_number)
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def merge_removed_xml_locations!(locations, diff_output, file_path, base_content:, head_content:)
|
|
207
|
+
return unless base_content && head_content
|
|
208
|
+
|
|
209
|
+
base_comments = TranslationCommentIndex.new(format: :xml, content: base_content)
|
|
210
|
+
head_comments = TranslationCommentIndex.new(format: :xml, content: head_content)
|
|
211
|
+
base_resources = AndroidResource.index(base_content)
|
|
212
|
+
head_resources = AndroidResource.index(head_content)
|
|
213
|
+
|
|
214
|
+
each_changed_diff_line(diff_output) do |content, old_line, _new_line, side|
|
|
215
|
+
next unless side == :left
|
|
216
|
+
next if content.strip.empty?
|
|
217
|
+
|
|
218
|
+
key = base_comments.key_at(old_line) || base_resources.base_key_at(old_line)
|
|
219
|
+
next unless key
|
|
220
|
+
|
|
221
|
+
fallback_line = head_comments.line_for_key(key) || resource_start_line(head_resources, key)
|
|
222
|
+
(locations[key] ||= []) << changed_location(
|
|
223
|
+
file_path,
|
|
224
|
+
old_line,
|
|
225
|
+
side: :left,
|
|
226
|
+
fallback_line: fallback_line
|
|
227
|
+
)
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def resource_start_line(index, key)
|
|
232
|
+
index.resource_spans.find { |span| span.base_key == key }&.start_line
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
@@ -1,35 +1,94 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'open3'
|
|
4
|
+
require 'pathname'
|
|
5
|
+
require_relative 'apple_string_literal'
|
|
6
|
+
require_relative 'changed_location'
|
|
7
|
+
require_relative 'translation_comment_index'
|
|
8
|
+
require_relative 'xml_scanner'
|
|
9
|
+
require_relative 'android_resource'
|
|
10
|
+
require_relative 'xcstrings_document'
|
|
11
|
+
require_relative 'git_diff/xml_changes'
|
|
4
12
|
|
|
5
13
|
module I18nContextGenerator
|
|
6
14
|
# Parses git diff to extract changed translation keys
|
|
7
15
|
class GitDiff
|
|
8
|
-
|
|
16
|
+
include GitDiffXmlChanges
|
|
17
|
+
|
|
18
|
+
def initialize(base_ref: 'main', head_ref: 'HEAD')
|
|
9
19
|
@base_ref = base_ref
|
|
20
|
+
@head_ref = head_ref
|
|
10
21
|
end
|
|
11
22
|
|
|
12
23
|
# Get keys that were added or modified since the base ref
|
|
13
24
|
# @param translation_paths [Array<String>] paths to translation files
|
|
14
25
|
# @return [Set<String>] set of changed keys
|
|
15
26
|
def changed_keys(translation_paths)
|
|
16
|
-
|
|
27
|
+
changed_key_locations(translation_paths).each_key.to_set(&:last)
|
|
28
|
+
end
|
|
17
29
|
|
|
18
|
-
|
|
30
|
+
# Get changed translation keys together with the exact changed lines that
|
|
31
|
+
# produced them. Keys are scoped by translation file so duplicate keys in
|
|
32
|
+
# different files remain distinct.
|
|
33
|
+
# @return [Hash{Array(String, String) => Array<ChangedLocation>}]
|
|
34
|
+
def changed_key_locations(translation_paths)
|
|
35
|
+
translation_paths.each_with_object({}) do |path, changes|
|
|
19
36
|
next unless File.exist?(path)
|
|
20
37
|
|
|
21
|
-
diff_output =
|
|
38
|
+
diff_output = git_diff_for_path(path)
|
|
22
39
|
next if diff_output.empty?
|
|
23
40
|
|
|
24
|
-
|
|
41
|
+
normalized_path = Pathname.new(path).cleanpath.to_s
|
|
42
|
+
base_content, head_content = revision_contents(path) if revision_contents_needed?(path, diff_output)
|
|
43
|
+
head_content ||= File.binread(path)
|
|
44
|
+
key_locations = case File.extname(path).downcase
|
|
45
|
+
when '.strings'
|
|
46
|
+
extract_strings_key_locations(
|
|
47
|
+
diff_output,
|
|
48
|
+
normalized_path,
|
|
49
|
+
base_content: base_content,
|
|
50
|
+
head_content: head_content
|
|
51
|
+
)
|
|
52
|
+
when '.xcstrings'
|
|
53
|
+
extract_xcstrings_key_locations(
|
|
54
|
+
diff_output,
|
|
55
|
+
normalized_path,
|
|
56
|
+
base_content: base_content,
|
|
57
|
+
head_content: head_content
|
|
58
|
+
)
|
|
59
|
+
when '.xml'
|
|
60
|
+
extract_xml_key_locations(
|
|
61
|
+
diff_output,
|
|
62
|
+
normalized_path,
|
|
63
|
+
base_content: base_content,
|
|
64
|
+
head_content: head_content
|
|
65
|
+
)
|
|
66
|
+
else
|
|
67
|
+
{}
|
|
68
|
+
end
|
|
69
|
+
key_locations.each do |key, locations|
|
|
70
|
+
changes[[normalized_path, key]] = locations
|
|
71
|
+
end
|
|
25
72
|
end
|
|
73
|
+
end
|
|
26
74
|
|
|
27
|
-
|
|
75
|
+
# Get changed line numbers in source files since the base ref.
|
|
76
|
+
# @param source_paths [Array<String>] paths to source files or directories
|
|
77
|
+
# @return [Hash{String => Set<Integer>}] changed line numbers keyed by file path
|
|
78
|
+
def changed_lines(source_paths)
|
|
79
|
+
source_paths.each_with_object(Hash.new { |h, k| h[k] = Set.new }) do |path, line_map|
|
|
80
|
+
next unless File.exist?(path)
|
|
81
|
+
|
|
82
|
+
diff_output = git_diff_for_path(path)
|
|
83
|
+
next if diff_output.empty?
|
|
84
|
+
|
|
85
|
+
merge_line_maps!(line_map, extract_changed_lines(diff_output, path))
|
|
86
|
+
end
|
|
28
87
|
end
|
|
29
88
|
|
|
30
89
|
# Check if we're in a git repository
|
|
31
90
|
def self.available?
|
|
32
|
-
system('git rev-parse --git-dir
|
|
91
|
+
system('git', 'rev-parse', '--git-dir', out: File::NULL, err: File::NULL)
|
|
33
92
|
end
|
|
34
93
|
|
|
35
94
|
# Check if the base ref exists
|
|
@@ -37,122 +96,255 @@ module I18nContextGenerator
|
|
|
37
96
|
system('git', 'rev-parse', '--verify', @base_ref, out: File::NULL, err: File::NULL)
|
|
38
97
|
end
|
|
39
98
|
|
|
99
|
+
def head_ref_exists?
|
|
100
|
+
system('git', 'rev-parse', '--verify', @head_ref, out: File::NULL, err: File::NULL)
|
|
101
|
+
end
|
|
102
|
+
|
|
40
103
|
private
|
|
41
104
|
|
|
42
|
-
def
|
|
105
|
+
def git_diff_for_path(path)
|
|
43
106
|
# Run git from the directory containing the file so the correct repo is used
|
|
44
107
|
dir = File.directory?(path) ? path : File.dirname(path)
|
|
45
108
|
pathspec = File.directory?(path) ? '.' : File.basename(path)
|
|
46
|
-
# Use triple-dot to get changes on
|
|
47
|
-
stdout,
|
|
48
|
-
|
|
109
|
+
# Use triple-dot to get changes on the configured head since it diverged from base.
|
|
110
|
+
stdout, stderr, status = Open3.capture3(
|
|
111
|
+
'git', 'diff', "#{@base_ref}...#{@head_ref}", '--', pathspec, chdir: dir
|
|
112
|
+
)
|
|
113
|
+
return stdout if status.success?
|
|
114
|
+
|
|
115
|
+
detail = stderr.strip
|
|
116
|
+
detail = 'git exited unsuccessfully without an error message' if detail.empty?
|
|
117
|
+
raise Error, "Git diff failed for #{@base_ref}...#{@head_ref} (#{path}): #{detail}"
|
|
118
|
+
rescue SystemCallError => e
|
|
119
|
+
raise Error, "Git diff failed for #{@base_ref}...#{@head_ref} (#{path}): #{e.message}"
|
|
49
120
|
end
|
|
50
121
|
|
|
51
|
-
def
|
|
52
|
-
|
|
122
|
+
def extract_changed_lines(diff_output, path)
|
|
123
|
+
changed_lines = Hash.new { |h, k| h[k] = Set.new }
|
|
124
|
+
current_file = File.file?(path) ? path : nil
|
|
125
|
+
file_line = nil
|
|
126
|
+
in_hunk = false
|
|
127
|
+
|
|
128
|
+
diff_output.each_line do |line|
|
|
129
|
+
if line.start_with?('diff ')
|
|
130
|
+
file_line = nil
|
|
131
|
+
in_hunk = false
|
|
132
|
+
next
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
if !in_hunk && (match = line.match(%r{^\+\+\+ b/(.+)$}))
|
|
136
|
+
current_file = resolve_diff_file_path(path, match[1])
|
|
137
|
+
next
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
if (hunk = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/))
|
|
141
|
+
file_line = hunk[1].to_i
|
|
142
|
+
in_hunk = true
|
|
143
|
+
next
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
next if !in_hunk && line.start_with?('index ', '--- ', '+++ ')
|
|
147
|
+
next if line.start_with?('\\')
|
|
148
|
+
next if !in_hunk || file_line.nil? || current_file.nil?
|
|
53
149
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
150
|
+
if line.start_with?('+')
|
|
151
|
+
changed_lines[current_file] << file_line
|
|
152
|
+
file_line += 1
|
|
153
|
+
elsif line.start_with?('-')
|
|
154
|
+
next
|
|
155
|
+
else
|
|
156
|
+
file_line += 1
|
|
157
|
+
end
|
|
61
158
|
end
|
|
159
|
+
|
|
160
|
+
changed_lines
|
|
62
161
|
end
|
|
63
162
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
def extract_strings_keys(diff_output)
|
|
67
|
-
keys = Set.new
|
|
163
|
+
def resolve_diff_file_path(path, diff_file_path)
|
|
164
|
+
return Pathname.new(path).cleanpath.to_s if File.file?(path)
|
|
68
165
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
166
|
+
normalized_path = Pathname.new(path).cleanpath.to_s
|
|
167
|
+
if Pathname.new(normalized_path).absolute?
|
|
168
|
+
prefix = repository_prefix_for(path)
|
|
169
|
+
relative_path = diff_file_path.delete_prefix(prefix)
|
|
170
|
+
return Pathname.new(File.join(normalized_path, relative_path)).cleanpath.to_s
|
|
171
|
+
end
|
|
172
|
+
return Pathname.new(diff_file_path).cleanpath.to_s if normalized_path.empty? || normalized_path == '.'
|
|
173
|
+
return Pathname.new(diff_file_path).cleanpath.to_s if diff_file_path == normalized_path || diff_file_path.start_with?("#{normalized_path}/")
|
|
174
|
+
|
|
175
|
+
Pathname.new(File.join(normalized_path, diff_file_path)).cleanpath.to_s
|
|
176
|
+
end
|
|
72
177
|
|
|
73
|
-
|
|
74
|
-
|
|
178
|
+
def repository_prefix_for(path)
|
|
179
|
+
@repository_prefixes ||= {}
|
|
180
|
+
directory = File.directory?(path) ? path : File.dirname(path)
|
|
181
|
+
@repository_prefixes[directory] ||= begin
|
|
182
|
+
stdout, stderr, status = Open3.capture3('git', 'rev-parse', '--show-prefix', chdir: directory)
|
|
183
|
+
if status.success?
|
|
184
|
+
stdout.strip.sub(%r{/\z}, '')
|
|
185
|
+
else
|
|
186
|
+
detail = stderr.strip
|
|
187
|
+
detail = 'unable to resolve repository path prefix' if detail.empty?
|
|
188
|
+
raise Error, "Git diff failed for #{@base_ref}...#{@head_ref} (#{path}): #{detail}"
|
|
189
|
+
end
|
|
75
190
|
end
|
|
191
|
+
end
|
|
76
192
|
|
|
77
|
-
|
|
193
|
+
def merge_line_maps!(target, source)
|
|
194
|
+
source.each do |file, lines|
|
|
195
|
+
target[file].merge(lines)
|
|
196
|
+
end
|
|
78
197
|
end
|
|
79
198
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
def extract_xml_keys(diff_output, file_path)
|
|
86
|
-
keys = Set.new
|
|
87
|
-
current_parent = nil
|
|
88
|
-
file_line = nil
|
|
89
|
-
orphaned_item_file_lines = []
|
|
199
|
+
def extract_strings_key_locations(diff_output, file_path, base_content: nil, head_content: nil)
|
|
200
|
+
locations = Hash.new { |hash, key| hash[key] = [] }
|
|
201
|
+
head_content ||= File.binread(file_path)
|
|
202
|
+
head_index = TranslationCommentIndex.new(format: :strings, content: head_content)
|
|
203
|
+
base_index = TranslationCommentIndex.new(format: :strings, content: base_content) if base_content
|
|
90
204
|
|
|
91
|
-
diff_output
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
205
|
+
each_changed_diff_line(diff_output) do |content, old_line, new_line, side|
|
|
206
|
+
if side == :right
|
|
207
|
+
key = AppleStringLiteral.assignment_key(content) || head_index.key_at(new_line)
|
|
208
|
+
locations[key] << changed_location(file_path, new_line, side: :right) if key
|
|
209
|
+
elsif base_index
|
|
210
|
+
key = AppleStringLiteral.assignment_key(content) || base_index.key_at(old_line)
|
|
211
|
+
fallback_line = head_index.line_for_key(key)
|
|
212
|
+
if key
|
|
213
|
+
locations[key] << changed_location(
|
|
214
|
+
file_path,
|
|
215
|
+
old_line,
|
|
216
|
+
side: :left,
|
|
217
|
+
fallback_line: fallback_line
|
|
218
|
+
)
|
|
219
|
+
end
|
|
96
220
|
end
|
|
221
|
+
end
|
|
97
222
|
|
|
98
|
-
|
|
99
|
-
|
|
223
|
+
prefer_right_locations(locations)
|
|
224
|
+
end
|
|
100
225
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
226
|
+
def extract_xcstrings_key_locations(diff_output, file_path, base_content: nil, head_content: nil)
|
|
227
|
+
current_content = head_content || File.binread(file_path)
|
|
228
|
+
head_index = xcstrings_line_index(head_content || current_content, file_path)
|
|
229
|
+
base_index = xcstrings_line_index(base_content || head_content || current_content, file_path)
|
|
230
|
+
locations = Hash.new { |hash, key| hash[key] = [] }
|
|
231
|
+
head_lines = first_lines_by_key(head_index)
|
|
104
232
|
|
|
105
|
-
|
|
106
|
-
if content
|
|
107
|
-
current_parent = Regexp.last_match(1)
|
|
108
|
-
elsif content =~ %r{</(?:plurals|string-array)>}
|
|
109
|
-
current_parent = nil
|
|
110
|
-
end
|
|
233
|
+
each_changed_diff_line(diff_output) do |content, old_line, new_line, side|
|
|
234
|
+
next if content.strip.empty?
|
|
111
235
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
236
|
+
if side == :right
|
|
237
|
+
key = head_index[new_line]
|
|
238
|
+
locations[key] << changed_location(file_path, new_line, side: :right) if key
|
|
239
|
+
else
|
|
240
|
+
key = base_index[old_line]
|
|
241
|
+
fallback_line = head_lines[key]
|
|
242
|
+
if key
|
|
243
|
+
locations[key] << changed_location(
|
|
244
|
+
file_path,
|
|
245
|
+
old_line,
|
|
246
|
+
side: :left,
|
|
247
|
+
fallback_line: fallback_line
|
|
248
|
+
)
|
|
123
249
|
end
|
|
124
250
|
end
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
prefer_right_locations(locations)
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def xcstrings_line_index(content, file_path)
|
|
257
|
+
return {} unless content
|
|
258
|
+
|
|
259
|
+
XcstringsDocument.new(content, path: file_path).line_index
|
|
260
|
+
end
|
|
125
261
|
|
|
126
|
-
|
|
127
|
-
|
|
262
|
+
def revision_contents_needed?(path, diff_output)
|
|
263
|
+
File.extname(path).downcase == '.xcstrings' ||
|
|
264
|
+
diff_output.each_line.any? { |line| line.start_with?('-') && !line.start_with?('---') }
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def revision_contents(path)
|
|
268
|
+
directory = File.dirname(File.expand_path(path))
|
|
269
|
+
root, stderr, status = Open3.capture3('git', 'rev-parse', '--show-toplevel', chdir: directory)
|
|
270
|
+
unless status.success?
|
|
271
|
+
detail = stderr.strip
|
|
272
|
+
detail = 'unable to resolve repository root' if detail.empty?
|
|
273
|
+
raise Error, "Git diff failed for #{@base_ref}...#{@head_ref} (#{path}): #{detail}"
|
|
128
274
|
end
|
|
129
275
|
|
|
130
|
-
|
|
131
|
-
|
|
276
|
+
repository_root = root.strip
|
|
277
|
+
relative_path = Pathname.new(File.expand_path(path))
|
|
278
|
+
.relative_path_from(Pathname.new(repository_root)).to_s
|
|
279
|
+
merge_base, _stderr, status = Open3.capture3(
|
|
280
|
+
'git', 'merge-base', @base_ref, @head_ref, chdir: repository_root
|
|
281
|
+
)
|
|
282
|
+
base_revision = status.success? && !merge_base.strip.empty? ? merge_base.strip : @base_ref
|
|
283
|
+
[
|
|
284
|
+
file_at_revision(repository_root, relative_path, base_revision),
|
|
285
|
+
file_at_revision(repository_root, relative_path, @head_ref)
|
|
286
|
+
]
|
|
287
|
+
end
|
|
132
288
|
|
|
133
|
-
|
|
289
|
+
def file_at_revision(repository_root, relative_path, revision)
|
|
290
|
+
stdout, _stderr, status = Open3.capture3(
|
|
291
|
+
'git', 'show', "#{revision}:#{relative_path}", chdir: repository_root
|
|
292
|
+
)
|
|
293
|
+
status.success? ? stdout : nil
|
|
134
294
|
end
|
|
135
295
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
return if orphaned_lines.empty? || !File.exist?(file_path)
|
|
296
|
+
def changed_location(file, line, side:, fallback_line: nil)
|
|
297
|
+
ChangedLocation.new(file: file, line: line, side: side, fallback_line: fallback_line)
|
|
298
|
+
end
|
|
140
299
|
|
|
141
|
-
|
|
142
|
-
|
|
300
|
+
def prefer_right_locations(locations)
|
|
301
|
+
preferred = locations.transform_values do |values|
|
|
302
|
+
unique = values.uniq
|
|
303
|
+
right = unique.select(&:right?)
|
|
304
|
+
right.empty? ? unique : right
|
|
305
|
+
end
|
|
306
|
+
preferred.reject { |_key, values| values.empty? }
|
|
307
|
+
end
|
|
143
308
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
elsif line =~ %r{</(?:plurals|string-array)>}
|
|
148
|
-
current_parent = nil
|
|
149
|
-
end
|
|
150
|
-
parent_at_line[index + 1] = current_parent
|
|
309
|
+
def first_lines_by_key(line_index)
|
|
310
|
+
line_index.each_with_object({}) do |(line, key), lines|
|
|
311
|
+
lines[key] ||= line
|
|
151
312
|
end
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def each_changed_diff_line(diff_output)
|
|
316
|
+
old_line_number = nil
|
|
317
|
+
new_line_number = nil
|
|
318
|
+
in_hunk = false
|
|
152
319
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
320
|
+
diff_output.each_line do |line|
|
|
321
|
+
if line.start_with?('diff ')
|
|
322
|
+
old_line_number = nil
|
|
323
|
+
new_line_number = nil
|
|
324
|
+
in_hunk = false
|
|
325
|
+
next
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
if (match = line.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/))
|
|
329
|
+
old_line_number = match[1].to_i
|
|
330
|
+
new_line_number = match[2].to_i
|
|
331
|
+
in_hunk = true
|
|
332
|
+
next
|
|
333
|
+
end
|
|
334
|
+
next if !in_hunk && line.start_with?('index ', '--- ', '+++ ')
|
|
335
|
+
next if line.start_with?('\\')
|
|
336
|
+
next if !in_hunk || old_line_number.nil? || new_line_number.nil?
|
|
337
|
+
|
|
338
|
+
if line.start_with?('+')
|
|
339
|
+
yield(line[1..], nil, new_line_number, :right)
|
|
340
|
+
new_line_number += 1
|
|
341
|
+
elsif line.start_with?('-')
|
|
342
|
+
yield(line[1..], old_line_number, nil, :left)
|
|
343
|
+
old_line_number += 1
|
|
344
|
+
else
|
|
345
|
+
old_line_number += 1
|
|
346
|
+
new_line_number += 1
|
|
347
|
+
end
|
|
156
348
|
end
|
|
157
349
|
end
|
|
158
350
|
end
|