i18n-context-generator 0.4.0 → 0.5.1

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 +163 -76
  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 +293 -102
  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 +45 -6
  17. data/lib/i18n_context_generator/context_extractor/source_entries.rb +103 -0
  18. data/lib/i18n_context_generator/context_extractor/source_filters.rb +51 -5
  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 +207 -216
  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 +224 -95
  26. data/lib/i18n_context_generator/llm/anthropic.rb +65 -38
  27. data/lib/i18n_context_generator/llm/client.rb +304 -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 +109 -64
  44. data/lib/i18n_context_generator/searcher.rb +61 -205
  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 +248 -0
  59. data/lib/i18n_context_generator/xml_scanner.rb +38 -0
  60. data/lib/i18n_context_generator.rb +20 -4
  61. metadata +59 -12
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative '../generated_comment'
4
+
3
5
  module I18nContextGenerator
4
6
  module Writers
5
7
  # Writer that updates iOS .strings files with context comments
@@ -15,6 +17,7 @@ module I18nContextGenerator
15
17
  def write(results, source_path)
16
18
  return unless File.exist?(source_path)
17
19
 
20
+ original_content = File.binread(source_path)
18
21
  # Parse the existing file
19
22
  original_file = DotStrings.parse_file(source_path, strict: false)
20
23
  results_by_key = results.each_with_object({}) do |result, lookup|
@@ -22,6 +25,7 @@ module I18nContextGenerator
22
25
 
23
26
  lookup[result.key] = result
24
27
  end
28
+ return false unless results_by_key.values.any? { |result| writable_result?(result) }
25
29
 
26
30
  # Build new file with updated comments (DotStrings::Item is immutable)
27
31
  new_file = DotStrings::File.new
@@ -43,24 +47,31 @@ module I18nContextGenerator
43
47
  new_file << new_item
44
48
  end
45
49
 
46
- # Write back to file
47
- File.write(source_path, new_file.to_s)
50
+ rendered = new_file.to_s
51
+ return false if rendered == original_content
52
+
53
+ AtomicFile.replace(source_path, rendered) do |candidate_path|
54
+ DotStrings.parse_file(candidate_path, strict: true)
55
+ end
56
+ true
48
57
  end
49
58
 
50
59
  private
51
60
 
52
61
  def build_comment(existing_comment, context_description)
53
- context_line = "#{@context_prefix}#{context_description}"
62
+ context_line = sanitize_comment("#{@context_prefix}#{context_description}")
63
+ context_prefix = sanitize_comment(@context_prefix)
64
+ GeneratedComment.merge(
65
+ existing: existing_comment,
66
+ generated: context_line,
67
+ prefix: context_prefix,
68
+ mode: @context_mode,
69
+ separator: "\n"
70
+ )
71
+ end
54
72
 
55
- if existing_comment.nil? || existing_comment.empty? || @context_mode == 'replace'
56
- context_line
57
- elsif !@context_prefix.empty? && existing_comment.include?(@context_prefix)
58
- # Replace existing context line (idempotent update)
59
- existing_comment.gsub(/#{Regexp.escape(@context_prefix)}[^\n]*/, context_line)
60
- else
61
- # Append context to existing comment
62
- "#{existing_comment}\n#{context_line}"
63
- end
73
+ def sanitize_comment(comment)
74
+ comment.gsub('*/', '* /')
64
75
  end
65
76
  end
66
77
  end
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative '../generated_comment'
4
+ require_relative '../localization_syntax'
5
+
3
6
  module I18nContextGenerator
4
7
  module Writers
5
8
  # Writer that updates comment: parameters in Swift localization calls
@@ -7,20 +10,11 @@ module I18nContextGenerator
7
10
  class SwiftWriter
8
11
  include Helpers
9
12
 
10
- SWIFT_STRING_PATTERN = '"(?:\\\\.|[^"\\\\])*"'
11
13
  COMMENT_ARGUMENT_PATTERN = /comment:\s*"((?:\\.|[^"\\])*)"/
12
- private_constant :SWIFT_STRING_PATTERN, :COMMENT_ARGUMENT_PATTERN
13
-
14
- # Default patterns for Swift localization functions
15
- # Each pattern should capture: (prefix)(key)(middle)(comment_value)(suffix)
16
- DEFAULT_FUNCTIONS = %w[
17
- NSLocalizedString
18
- String(localized:
19
- Text(
20
- ].freeze
14
+ private_constant :COMMENT_ARGUMENT_PATTERN
21
15
 
22
16
  def initialize(functions: nil, context_prefix: 'Context: ', context_mode: 'replace')
23
- @functions = functions || DEFAULT_FUNCTIONS
17
+ @localization_syntax = LocalizationSyntax.new(swift_functions: functions)
24
18
  @context_prefix = context_prefix
25
19
  @context_mode = context_mode
26
20
  end
@@ -61,7 +55,7 @@ module I18nContextGenerator
61
55
  end
62
56
 
63
57
  if updated && content != original_content
64
- File.write(path, content)
58
+ AtomicFile.replace(path, content)
65
59
  true
66
60
  else
67
61
  false
@@ -72,46 +66,17 @@ module I18nContextGenerator
72
66
 
73
67
  # Update comment for a specific key in the content
74
68
  def update_comment_for_key(content, key, description)
75
- escaped_key = Regexp.escape(key)
76
-
77
- @functions.each do |func|
78
- # Build pattern based on function type
79
- pattern = build_pattern_for_function(func, escaped_key)
80
- next unless pattern
81
-
69
+ @localization_syntax.swift_writer_patterns(key).each do |pattern|
82
70
  # Try to match and replace
83
71
  content = content.gsub(pattern) do |match|
84
- update_match(match, func, key, description)
72
+ update_match(match, description)
85
73
  end
86
74
  end
87
75
 
88
76
  content
89
77
  end
90
78
 
91
- def build_pattern_for_function(func, escaped_key)
92
- comment_pattern = "comment:\\s*#{SWIFT_STRING_PATTERN}"
93
-
94
- case func
95
- when 'NSLocalizedString'
96
- # NSLocalizedString("key", comment: "...")
97
- # NSLocalizedString("key", value: "...", comment: "...")
98
- # NSLocalizedString("key", tableName: "...", comment: "...")
99
- Regexp.new("NSLocalizedString\\(\\s*\"#{escaped_key}\"[^)]*#{comment_pattern}[^)]*\\)", Regexp::MULTILINE)
100
- when 'String(localized:'
101
- # String(localized: "key", comment: "...")
102
- Regexp.new("String\\(\\s*localized:\\s*\"#{escaped_key}\"[^)]*#{comment_pattern}[^)]*\\)", Regexp::MULTILINE)
103
- when 'Text('
104
- # Text("key", comment: "...")
105
- # Text(LocalizedStringKey("key"), comment: "...")
106
- Regexp.new("Text\\([^)]*\"#{escaped_key}\"[^)]*#{comment_pattern}[^)]*\\)", Regexp::MULTILINE)
107
- else
108
- # Custom function - assume pattern like: func("key", ..., comment: "...")
109
- escaped_func = Regexp.escape(func)
110
- Regexp.new("#{escaped_func}\\([^)]*\"#{escaped_key}\"[^)]*#{comment_pattern}[^)]*\\)", Regexp::MULTILINE)
111
- end
112
- end
113
-
114
- def update_match(match, _func, _key, new_comment)
79
+ def update_match(match, new_comment)
115
80
  # Replace the comment value while preserving the rest of the call
116
81
  match.gsub(COMMENT_ARGUMENT_PATTERN) do |_comment_match|
117
82
  existing_comment = unescape_swift_string(Regexp.last_match(1))
@@ -122,16 +87,13 @@ module I18nContextGenerator
122
87
 
123
88
  def build_final_comment(existing_comment, new_context)
124
89
  context_line = "#{@context_prefix}#{new_context}"
125
-
126
- if existing_comment.nil? || existing_comment.empty? || @context_mode == 'replace'
127
- context_line
128
- elsif !@context_prefix.empty? && existing_comment.include?(@context_prefix)
129
- # Update existing context line (idempotent)
130
- existing_comment.gsub(/#{Regexp.escape(@context_prefix)}[^\n]*/, context_line)
131
- else
132
- # Append context to existing comment
133
- "#{existing_comment} #{context_line}"
134
- end
90
+ GeneratedComment.merge(
91
+ existing: existing_comment,
92
+ generated: context_line,
93
+ prefix: @context_prefix,
94
+ mode: @context_mode,
95
+ separator: ' '
96
+ )
135
97
  end
136
98
 
137
99
  def escape_swift_string(str)
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../generated_comment'
4
+
5
+ module I18nContextGenerator
6
+ module Writers
7
+ # Updates developer comments in Apple string catalogs while leaving every
8
+ # localization and extraction attribute intact.
9
+ class XcstringsWriter
10
+ include Helpers
11
+
12
+ def initialize(context_prefix: 'Context: ', context_mode: 'replace')
13
+ @context_prefix = context_prefix
14
+ @context_mode = context_mode
15
+ end
16
+
17
+ def write(results, source_path)
18
+ return unless File.exist?(source_path)
19
+
20
+ original = File.binread(source_path)
21
+ document = XcstringsDocument.new(original, path: source_path)
22
+ catalog = document.catalog
23
+ Parsers::XcstringsParser.validate_catalog!(catalog, path: source_path)
24
+ results_by_key = results.each_with_object({}) do |result, lookup|
25
+ next unless result_matches_source_path?(result, source_path)
26
+ next unless writable_result?(result)
27
+
28
+ lookup[result.key] = result
29
+ end
30
+ return false if results_by_key.empty?
31
+
32
+ comments_by_key = catalog.fetch('strings').each_with_object({}) do |(key, entry), comments|
33
+ result = results_by_key[key]
34
+ next unless result
35
+
36
+ comments[key] = GeneratedComment.merge(
37
+ existing: entry['comment'],
38
+ generated: "#{@context_prefix}#{result.description}",
39
+ prefix: @context_prefix,
40
+ mode: @context_mode,
41
+ separator: "\n"
42
+ )
43
+ end
44
+ rendered = document.with_comments(comments_by_key)
45
+ return false if rendered == original
46
+
47
+ AtomicFile.replace(source_path, rendered) do |candidate_path|
48
+ candidate = Oj.load_file(candidate_path, mode: :strict)
49
+ Parsers::XcstringsParser.validate_catalog!(candidate, path: candidate_path)
50
+ end
51
+ true
52
+ rescue Oj::ParseError => e
53
+ raise Error, "Failed to parse Apple string catalog #{source_path}: #{e.message}"
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,248 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'oj'
5
+
6
+ module I18nContextGenerator
7
+ # Structural index for an Apple string catalog that retains byte offsets in
8
+ # the original JSON. It lets write-back edit only owned comment values and
9
+ # lets diff handling map revision-specific line numbers without reformatting.
10
+ class XcstringsDocument
11
+ Member = Data.define(:key, :key_start, :key_end, :value_start, :value_end, :separator)
12
+ BYTE = {
13
+ backslash: '\\'.ord,
14
+ close_array: ']'.ord,
15
+ close_object: '}'.ord,
16
+ colon: ':'.ord,
17
+ comma: ','.ord,
18
+ newline: "\n".ord,
19
+ open_array: '['.ord,
20
+ open_object: '{'.ord,
21
+ quote: '"'.ord
22
+ }.freeze
23
+ JSON_WHITESPACE_BYTES = ["\t".ord, "\n".ord, "\r".ord, ' '.ord].freeze
24
+ PRIMITIVE_TERMINATOR_BYTES = [
25
+ BYTE[:comma],
26
+ BYTE[:close_object],
27
+ BYTE[:close_array],
28
+ *JSON_WHITESPACE_BYTES
29
+ ].freeze
30
+ private_constant :BYTE, :JSON_WHITESPACE_BYTES, :PRIMITIVE_TERMINATOR_BYTES
31
+
32
+ attr_reader :catalog
33
+
34
+ def initialize(content, path:)
35
+ @content = content
36
+ @path = path
37
+ @catalog = Oj.load(content, mode: :strict)
38
+ @line_starts = [0]
39
+ content.each_byte.with_index { |byte, index| @line_starts << (index + 1) if byte == BYTE[:newline] }
40
+ @root_members = object_members(skip_whitespace(0))
41
+ strings_member = @root_members.find { |member| member.key == 'strings' }
42
+ strings_object = strings_member && byte_at(strings_member.value_start) == BYTE[:open_object]
43
+ raise TypeError, 'strings must be a mapping' unless strings_object
44
+
45
+ @strings_member = strings_member
46
+ @entry_members = object_members(strings_member.value_start)
47
+ @entries_by_key = @entry_members.to_h { |member| [member.key, member] }
48
+ rescue Oj::ParseError, TypeError => e
49
+ raise Error, "Failed to parse Apple string catalog #{path}: #{e.message}"
50
+ end
51
+
52
+ def line_index
53
+ @entry_members.each_with_object({}) do |member, index|
54
+ start_line = line_number(member.key_start)
55
+ end_line = line_number(member.value_end - 1)
56
+ (start_line..end_line).each { |line| index[line] = member.key }
57
+ end
58
+ end
59
+
60
+ def with_comments(comments_by_key)
61
+ edits = comments_by_key.filter_map do |key, comment|
62
+ entry_member = @entries_by_key[key]
63
+ next unless entry_member && byte_at(entry_member.value_start) == BYTE[:open_object]
64
+ next if @catalog.dig('strings', key, 'comment') == comment
65
+
66
+ comment_edit(entry_member, comment)
67
+ end
68
+ return @content if edits.empty?
69
+
70
+ original_encoding = @content.encoding
71
+ rendered = @content.b
72
+ edits.sort_by(&:first).reverse_each do |start_offset, end_offset, replacement|
73
+ rendered[start_offset...end_offset] = replacement.b
74
+ end
75
+ rendered.force_encoding(original_encoding)
76
+ end
77
+
78
+ private
79
+
80
+ def comment_edit(entry_member, comment)
81
+ children = object_members(entry_member.value_start)
82
+ existing = children.find { |member| member.key == 'comment' }
83
+ encoded_comment = JSON.generate(comment)
84
+ return [existing.value_start, existing.value_end, encoded_comment] if existing
85
+
86
+ insert_comment_edit(entry_member, children, encoded_comment)
87
+ end
88
+
89
+ def insert_comment_edit(entry_member, children, encoded_comment)
90
+ separator = children.first&.separator || entry_member.separator
91
+ if inline_member?(entry_member)
92
+ insertion_point = entry_member.value_start + 1
93
+ rendered_comment = "\"comment\"#{separator}#{encoded_comment}"
94
+ rendered_comment = "#{rendered_comment}," unless children.empty?
95
+ return [insertion_point, insertion_point, rendered_comment]
96
+ end
97
+
98
+ entry_indent = line_indent(entry_member.key_start)
99
+ child_indent = children.empty? ? "#{entry_indent}#{indent_unit}" : line_indent(children.first.key_start)
100
+ child_indent = "#{entry_indent}#{indent_unit}" unless child_indent.match?(/\A\s*\z/)
101
+ rendered_comment = "#{child_indent}\"comment\"#{separator}#{encoded_comment}"
102
+
103
+ if children.empty?
104
+ interior_start = entry_member.value_start + 1
105
+ interior_end = entry_member.value_end - 1
106
+ replacement = "#{newline}#{rendered_comment}#{newline}#{entry_indent}"
107
+ [interior_start, interior_end, replacement]
108
+ else
109
+ insertion_point = entry_member.value_start + 1
110
+ [insertion_point, insertion_point, "#{newline}#{rendered_comment},"]
111
+ end
112
+ end
113
+
114
+ def inline_member?(member)
115
+ !line_indent(member.key_start).match?(/\A[ \t]*\z/)
116
+ end
117
+
118
+ def indent_unit
119
+ @indent_unit ||= begin
120
+ strings_indent = line_indent(@strings_member.key_start)
121
+ block_entry = @entry_members.find { |member| !inline_member?(member) }
122
+ entry_indent = line_indent(block_entry&.key_start || @strings_member.key_start)
123
+ difference = entry_indent.delete_prefix(strings_indent)
124
+ difference.empty? || !difference.match?(/\A[ \t]*\z/) ? ' ' : difference
125
+ end
126
+ end
127
+
128
+ def newline
129
+ @newline ||= @content.include?("\r\n") ? "\r\n" : "\n"
130
+ end
131
+
132
+ def line_indent(offset)
133
+ following_line = @line_starts.bsearch_index { |line_start| line_start > offset }
134
+ line_start = following_line ? @line_starts.fetch(following_line - 1) : @line_starts.last
135
+ byte_slice(line_start, offset)
136
+ end
137
+
138
+ def line_number(offset)
139
+ @line_starts.bsearch_index { |line_start| line_start > offset } || @line_starts.length
140
+ end
141
+
142
+ def object_members(object_start)
143
+ raise TypeError, "expected object at byte #{object_start}" unless byte_at(object_start) == BYTE[:open_object]
144
+
145
+ members = []
146
+ cursor = skip_whitespace(object_start + 1)
147
+ return members if byte_at(cursor) == BYTE[:close_object]
148
+
149
+ loop do
150
+ key_start = cursor
151
+ key_end = string_end(key_start)
152
+ key = JSON.parse(byte_slice(key_start, key_end))
153
+ cursor = skip_whitespace(key_end)
154
+ raise TypeError, "expected ':' at byte #{cursor}" unless byte_at(cursor) == BYTE[:colon]
155
+
156
+ cursor = skip_whitespace(cursor + 1)
157
+ value_start = cursor
158
+ value_end = value_end(value_start)
159
+ members << Member.new(
160
+ key: key,
161
+ key_start: key_start,
162
+ key_end: key_end,
163
+ value_start: value_start,
164
+ value_end: value_end,
165
+ separator: byte_slice(key_end, value_start)
166
+ )
167
+ cursor = skip_whitespace(value_end)
168
+ break if byte_at(cursor) == BYTE[:close_object]
169
+
170
+ raise TypeError, "expected ',' at byte #{cursor}" unless byte_at(cursor) == BYTE[:comma]
171
+
172
+ cursor = skip_whitespace(cursor + 1)
173
+ end
174
+
175
+ members
176
+ end
177
+
178
+ def value_end(start_offset)
179
+ case byte_at(start_offset)
180
+ when BYTE[:quote]
181
+ string_end(start_offset)
182
+ when BYTE[:open_object]
183
+ collection_end(start_offset, BYTE[:open_object], BYTE[:close_object])
184
+ when BYTE[:open_array]
185
+ collection_end(start_offset, BYTE[:open_array], BYTE[:close_array])
186
+ else
187
+ primitive_end(start_offset)
188
+ end
189
+ end
190
+
191
+ def string_end(start_offset)
192
+ cursor = start_offset + 1
193
+ escaped = false
194
+ while cursor < @content.bytesize
195
+ byte = byte_at(cursor)
196
+ if escaped
197
+ escaped = false
198
+ elsif byte == BYTE[:backslash]
199
+ escaped = true
200
+ elsif byte == BYTE[:quote]
201
+ return cursor + 1
202
+ end
203
+ cursor += 1
204
+ end
205
+ raise TypeError, "unterminated string at byte #{start_offset}"
206
+ end
207
+
208
+ def collection_end(start_offset, opening, closing)
209
+ depth = 0
210
+ cursor = start_offset
211
+ while cursor < @content.bytesize
212
+ byte = byte_at(cursor)
213
+ case byte
214
+ when BYTE[:quote]
215
+ cursor = string_end(cursor)
216
+ next
217
+ when opening
218
+ depth += 1
219
+ when closing
220
+ depth -= 1
221
+ return cursor + 1 if depth.zero?
222
+ end
223
+ cursor += 1
224
+ end
225
+ raise TypeError, "unterminated collection at byte #{start_offset}"
226
+ end
227
+
228
+ def primitive_end(start_offset)
229
+ cursor = start_offset
230
+ cursor += 1 while cursor < @content.bytesize && !PRIMITIVE_TERMINATOR_BYTES.include?(byte_at(cursor))
231
+ cursor
232
+ end
233
+
234
+ def skip_whitespace(offset)
235
+ cursor = offset
236
+ cursor += 1 while cursor < @content.bytesize && JSON_WHITESPACE_BYTES.include?(byte_at(cursor))
237
+ cursor
238
+ end
239
+
240
+ def byte_at(offset)
241
+ @content.getbyte(offset)
242
+ end
243
+
244
+ def byte_slice(start_offset, end_offset)
245
+ @content.byteslice(start_offset...end_offset)
246
+ end
247
+ end
248
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module I18nContextGenerator
4
+ # Small stateful XML scanning helpers for line-oriented diff and location code.
5
+ # This is intentionally not an XML parser; it only removes comments while
6
+ # preserving the remaining text and line boundaries.
7
+ module XmlScanner
8
+ module_function
9
+
10
+ def without_comments(line, state)
11
+ remaining = line.to_s
12
+ visible = +''
13
+ contained_comment = false
14
+
15
+ loop do
16
+ if state[:inside_comment]
17
+ contained_comment = true
18
+ comment_end = remaining.index('-->')
19
+ return [visible, contained_comment] unless comment_end
20
+
21
+ state[:inside_comment] = false
22
+ remaining = remaining[(comment_end + 3)..].to_s
23
+ else
24
+ comment_start = remaining.index('<!--')
25
+ unless comment_start
26
+ visible << remaining
27
+ return [visible, contained_comment]
28
+ end
29
+
30
+ visible << remaining[0...comment_start]
31
+ contained_comment = true
32
+ state[:inside_comment] = true
33
+ remaining = remaining[(comment_start + 4)..].to_s
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -1,5 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ module I18nContextGenerator
4
+ class Error < StandardError; end
5
+ end
6
+
3
7
  require 'yaml'
4
8
  require 'csv'
5
9
  require 'digest'
@@ -10,27 +14,39 @@ require 'tty-progressbar'
10
14
  require 'dotstrings'
11
15
 
12
16
  require_relative 'i18n_context_generator/version'
17
+ require_relative 'i18n_context_generator/path_policy'
18
+ require_relative 'i18n_context_generator/file_classifier'
19
+ require_relative 'i18n_context_generator/supplemental_context'
20
+ require_relative 'i18n_context_generator/apple_string_literal'
21
+ require_relative 'i18n_context_generator/changed_location'
22
+ require_relative 'i18n_context_generator/localization_syntax'
23
+ require_relative 'i18n_context_generator/generated_comment'
24
+ require_relative 'i18n_context_generator/android_resource'
25
+ require_relative 'i18n_context_generator/xcstrings_document'
13
26
  require_relative 'i18n_context_generator/config'
14
27
  require_relative 'i18n_context_generator/parsers/base'
15
28
  require_relative 'i18n_context_generator/parsers/json_parser'
16
29
  require_relative 'i18n_context_generator/parsers/yaml_parser'
17
30
  require_relative 'i18n_context_generator/parsers/strings_parser'
31
+ require_relative 'i18n_context_generator/parsers/xcstrings_parser'
18
32
  require_relative 'i18n_context_generator/parsers/android_xml_parser'
19
33
  require_relative 'i18n_context_generator/searcher'
20
34
  require_relative 'i18n_context_generator/llm/client'
21
35
  require_relative 'i18n_context_generator/llm/anthropic'
22
36
  require_relative 'i18n_context_generator/llm/openai'
37
+ require_relative 'i18n_context_generator/llm/openai_compatible'
23
38
  require_relative 'i18n_context_generator/writers/helpers'
39
+ require_relative 'i18n_context_generator/writers/result_serialization'
40
+ require_relative 'i18n_context_generator/writers/atomic_file'
41
+ require_relative 'i18n_context_generator/writers/preview'
24
42
  require_relative 'i18n_context_generator/writers/csv_writer'
25
43
  require_relative 'i18n_context_generator/writers/json_writer'
26
44
  require_relative 'i18n_context_generator/writers/strings_writer'
45
+ require_relative 'i18n_context_generator/writers/xcstrings_writer'
27
46
  require_relative 'i18n_context_generator/writers/android_xml_writer'
28
47
  require_relative 'i18n_context_generator/writers/swift_writer'
29
48
  require_relative 'i18n_context_generator/cache'
30
49
  require_relative 'i18n_context_generator/git_diff'
31
50
  require_relative 'i18n_context_generator/platform_validator'
51
+ require_relative 'i18n_context_generator/run_metrics'
32
52
  require_relative 'i18n_context_generator/context_extractor'
33
-
34
- module I18nContextGenerator
35
- class Error < StandardError; end
36
- end