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,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
- def initialize(base_ref: 'main')
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
- keys = Set.new
27
+ changed_key_locations(translation_paths).each_key.to_set(&:last)
28
+ end
17
29
 
18
- translation_paths.each do |path|
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 = git_diff_for_file(path)
38
+ diff_output = git_diff_for_path(path)
22
39
  next if diff_output.empty?
23
40
 
24
- keys.merge(extract_keys_from_diff(diff_output, path))
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
- keys
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 > /dev/null 2>&1')
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 git_diff_for_file(path)
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 current branch since it diverged from base
47
- stdout, _stderr, status = Open3.capture3('git', 'diff', "#{@base_ref}...HEAD", '--', pathspec, chdir: dir)
48
- status.success? ? stdout : ''
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 extract_keys_from_diff(diff_output, path)
52
- ext = File.extname(path).downcase
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
- case ext
55
- when '.strings'
56
- extract_strings_keys(diff_output)
57
- when '.xml'
58
- extract_xml_keys(diff_output, path)
59
- else
60
- Set.new
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
- # Extract keys from iOS .strings diff
65
- # Looks for added lines like: +"key" = "value";
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
- diff_output.each_line do |line|
70
- # Match added or modified lines (start with +, not ++)
71
- next unless line.start_with?('+') && !line.start_with?('++')
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
- # Extract key from: "key" = "value";
74
- keys << Regexp.last_match(1) if line =~ /^\+\s*"([^"]+)"\s*=/
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
- keys
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
- # Extract keys from Android strings.xml diff.
81
- # Tracks parent element context from diff lines and uses hunk headers to
82
- # map added lines to file positions. When an added <item> can't be attributed
83
- # to a parent from diff context alone (e.g. large plural/array blocks where the
84
- # opener isn't in the hunk), falls back to reading the actual file.
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.each_line do |line|
92
- # Parse hunk header to track position in new file
93
- if (hunk = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/))
94
- file_line = hunk[1].to_i
95
- next
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
- # Skip diff metadata lines
99
- next if line.start_with?('diff ', 'index ', '--- ', '+++ ')
223
+ prefer_right_locations(locations)
224
+ end
100
225
 
101
- is_removed = line.start_with?('-')
102
- is_added = line.start_with?('+')
103
- content = line.sub(/^[ +-]/, '')
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
- # Track parent element from any visible line (context, added, or removed)
106
- if content =~ /<(?:plurals|string-array)\s+name=["']([^"']+)["']/
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
- # Process added lines for key extraction
113
- if is_added
114
- keys << Regexp.last_match(1) if content =~ /<string\s+name=["']([^"']+)["']/
115
- keys << Regexp.last_match(1) if content =~ /<(?:plurals|string-array)\s+name=["']([^"']+)["']/
116
-
117
- if content =~ /^\s*<item[\s>]/
118
- if current_parent
119
- keys << current_parent
120
- elsif file_line
121
- orphaned_item_file_lines << file_line
122
- end
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
- # Context and added lines exist in new file; removed lines do not
127
- file_line += 1 if file_line && !is_removed
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
- # Resolve orphaned items by reading the actual file
131
- resolve_orphaned_items(keys, orphaned_item_file_lines, file_path)
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
- keys
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
- # Build a map of file line numbers to enclosing plural/array resource names,
137
- # then use it to attribute orphaned <item> additions to their parent.
138
- def resolve_orphaned_items(keys, orphaned_lines, file_path)
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
- current_parent = nil
142
- parent_at_line = {}
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
- File.readlines(file_path).each_with_index do |line, index|
145
- if line =~ /<(?:plurals|string-array)\s+name=["']([^"']+)["']/
146
- current_parent = Regexp.last_match(1)
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
- orphaned_lines.each do |line_num|
154
- parent = parent_at_line[line_num]
155
- keys << parent if parent
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