i18n-context-generator 0.4.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 +163 -76
- 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 +293 -102
- 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 +45 -6
- data/lib/i18n_context_generator/context_extractor/source_entries.rb +103 -0
- data/lib/i18n_context_generator/context_extractor/source_filters.rb +51 -5
- 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 +207 -216
- 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 +224 -95
- 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 +109 -64
- data/lib/i18n_context_generator/searcher.rb +61 -205
- 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 +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
|
-
|
|
47
|
-
|
|
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
|
-
|
|
56
|
-
|
|
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 :
|
|
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
|
-
@
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
72
|
+
update_match(match, description)
|
|
85
73
|
end
|
|
86
74
|
end
|
|
87
75
|
|
|
88
76
|
content
|
|
89
77
|
end
|
|
90
78
|
|
|
91
|
-
def
|
|
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
|
-
|
|
127
|
-
context_line
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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,218 @@
|
|
|
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
|
+
|
|
13
|
+
attr_reader :catalog
|
|
14
|
+
|
|
15
|
+
def initialize(content, path:)
|
|
16
|
+
@content = content
|
|
17
|
+
@path = path
|
|
18
|
+
@catalog = Oj.load(content, mode: :strict)
|
|
19
|
+
@line_starts = [0]
|
|
20
|
+
content.each_char.with_index { |character, index| @line_starts << (index + 1) if character == "\n" }
|
|
21
|
+
@root_members = object_members(skip_whitespace(0))
|
|
22
|
+
strings_member = @root_members.find { |member| member.key == 'strings' }
|
|
23
|
+
raise TypeError, 'strings must be a mapping' unless strings_member && @content[strings_member.value_start] == '{'
|
|
24
|
+
|
|
25
|
+
@strings_member = strings_member
|
|
26
|
+
@entry_members = object_members(strings_member.value_start)
|
|
27
|
+
@entries_by_key = @entry_members.to_h { |member| [member.key, member] }
|
|
28
|
+
rescue Oj::ParseError, TypeError => e
|
|
29
|
+
raise Error, "Failed to parse Apple string catalog #{path}: #{e.message}"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def line_index
|
|
33
|
+
@entry_members.each_with_object({}) do |member, index|
|
|
34
|
+
start_line = line_number(member.key_start)
|
|
35
|
+
end_line = line_number(member.value_end - 1)
|
|
36
|
+
(start_line..end_line).each { |line| index[line] = member.key }
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def with_comments(comments_by_key)
|
|
41
|
+
edits = comments_by_key.filter_map do |key, comment|
|
|
42
|
+
entry_member = @entries_by_key[key]
|
|
43
|
+
next unless entry_member && @content[entry_member.value_start] == '{'
|
|
44
|
+
next if @catalog.dig('strings', key, 'comment') == comment
|
|
45
|
+
|
|
46
|
+
comment_edit(entry_member, comment)
|
|
47
|
+
end
|
|
48
|
+
return @content if edits.empty?
|
|
49
|
+
|
|
50
|
+
rendered = @content.dup
|
|
51
|
+
edits.sort_by(&:first).reverse_each do |start_offset, end_offset, replacement|
|
|
52
|
+
rendered[start_offset...end_offset] = replacement
|
|
53
|
+
end
|
|
54
|
+
rendered
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def comment_edit(entry_member, comment)
|
|
60
|
+
children = object_members(entry_member.value_start)
|
|
61
|
+
existing = children.find { |member| member.key == 'comment' }
|
|
62
|
+
encoded_comment = JSON.generate(comment)
|
|
63
|
+
return [existing.value_start, existing.value_end, encoded_comment] if existing
|
|
64
|
+
|
|
65
|
+
insert_comment_edit(entry_member, children, encoded_comment)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def insert_comment_edit(entry_member, children, encoded_comment)
|
|
69
|
+
separator = children.first&.separator || entry_member.separator
|
|
70
|
+
if inline_member?(entry_member)
|
|
71
|
+
insertion_point = entry_member.value_start + 1
|
|
72
|
+
rendered_comment = "\"comment\"#{separator}#{encoded_comment}"
|
|
73
|
+
rendered_comment = "#{rendered_comment}," unless children.empty?
|
|
74
|
+
return [insertion_point, insertion_point, rendered_comment]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
entry_indent = line_indent(entry_member.key_start)
|
|
78
|
+
child_indent = children.empty? ? "#{entry_indent}#{indent_unit}" : line_indent(children.first.key_start)
|
|
79
|
+
child_indent = "#{entry_indent}#{indent_unit}" unless child_indent.match?(/\A\s*\z/)
|
|
80
|
+
rendered_comment = "#{child_indent}\"comment\"#{separator}#{encoded_comment}"
|
|
81
|
+
|
|
82
|
+
if children.empty?
|
|
83
|
+
interior_start = entry_member.value_start + 1
|
|
84
|
+
interior_end = entry_member.value_end - 1
|
|
85
|
+
replacement = "#{newline}#{rendered_comment}#{newline}#{entry_indent}"
|
|
86
|
+
[interior_start, interior_end, replacement]
|
|
87
|
+
else
|
|
88
|
+
insertion_point = entry_member.value_start + 1
|
|
89
|
+
[insertion_point, insertion_point, "#{newline}#{rendered_comment},"]
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def inline_member?(member)
|
|
94
|
+
!line_indent(member.key_start).match?(/\A[ \t]*\z/)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def indent_unit
|
|
98
|
+
@indent_unit ||= begin
|
|
99
|
+
strings_indent = line_indent(@strings_member.key_start)
|
|
100
|
+
block_entry = @entry_members.find { |member| !inline_member?(member) }
|
|
101
|
+
entry_indent = line_indent(block_entry&.key_start || @strings_member.key_start)
|
|
102
|
+
difference = entry_indent.delete_prefix(strings_indent)
|
|
103
|
+
difference.empty? || !difference.match?(/\A[ \t]*\z/) ? ' ' : difference
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def newline
|
|
108
|
+
@newline ||= @content.include?("\r\n") ? "\r\n" : "\n"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def line_indent(offset)
|
|
112
|
+
line_start = @content.rindex("\n", offset - 1)
|
|
113
|
+
@content[(line_start ? line_start + 1 : 0)...offset]
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def line_number(offset)
|
|
117
|
+
@line_starts.bsearch_index { |line_start| line_start > offset } || @line_starts.length
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def object_members(object_start)
|
|
121
|
+
raise TypeError, "expected object at byte #{object_start}" unless @content[object_start] == '{'
|
|
122
|
+
|
|
123
|
+
members = []
|
|
124
|
+
cursor = skip_whitespace(object_start + 1)
|
|
125
|
+
return members if @content[cursor] == '}'
|
|
126
|
+
|
|
127
|
+
loop do
|
|
128
|
+
key_start = cursor
|
|
129
|
+
key_end = string_end(key_start)
|
|
130
|
+
key = JSON.parse(@content[key_start...key_end])
|
|
131
|
+
cursor = skip_whitespace(key_end)
|
|
132
|
+
raise TypeError, "expected ':' at byte #{cursor}" unless @content[cursor] == ':'
|
|
133
|
+
|
|
134
|
+
cursor = skip_whitespace(cursor + 1)
|
|
135
|
+
value_start = cursor
|
|
136
|
+
value_end = value_end(value_start)
|
|
137
|
+
members << Member.new(
|
|
138
|
+
key: key,
|
|
139
|
+
key_start: key_start,
|
|
140
|
+
key_end: key_end,
|
|
141
|
+
value_start: value_start,
|
|
142
|
+
value_end: value_end,
|
|
143
|
+
separator: @content[key_end...value_start]
|
|
144
|
+
)
|
|
145
|
+
cursor = skip_whitespace(value_end)
|
|
146
|
+
break if @content[cursor] == '}'
|
|
147
|
+
|
|
148
|
+
raise TypeError, "expected ',' at byte #{cursor}" unless @content[cursor] == ','
|
|
149
|
+
|
|
150
|
+
cursor = skip_whitespace(cursor + 1)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
members
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def value_end(start_offset)
|
|
157
|
+
case @content[start_offset]
|
|
158
|
+
when '"'
|
|
159
|
+
string_end(start_offset)
|
|
160
|
+
when '{'
|
|
161
|
+
collection_end(start_offset, '{', '}')
|
|
162
|
+
when '['
|
|
163
|
+
collection_end(start_offset, '[', ']')
|
|
164
|
+
else
|
|
165
|
+
primitive_end(start_offset)
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def string_end(start_offset)
|
|
170
|
+
cursor = start_offset + 1
|
|
171
|
+
escaped = false
|
|
172
|
+
while cursor < @content.length
|
|
173
|
+
character = @content[cursor]
|
|
174
|
+
if escaped
|
|
175
|
+
escaped = false
|
|
176
|
+
elsif character == '\\'
|
|
177
|
+
escaped = true
|
|
178
|
+
elsif character == '"'
|
|
179
|
+
return cursor + 1
|
|
180
|
+
end
|
|
181
|
+
cursor += 1
|
|
182
|
+
end
|
|
183
|
+
raise TypeError, "unterminated string at byte #{start_offset}"
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def collection_end(start_offset, opening, closing)
|
|
187
|
+
depth = 0
|
|
188
|
+
cursor = start_offset
|
|
189
|
+
while cursor < @content.length
|
|
190
|
+
character = @content[cursor]
|
|
191
|
+
case character
|
|
192
|
+
when '"'
|
|
193
|
+
cursor = string_end(cursor)
|
|
194
|
+
next
|
|
195
|
+
when opening
|
|
196
|
+
depth += 1
|
|
197
|
+
when closing
|
|
198
|
+
depth -= 1
|
|
199
|
+
return cursor + 1 if depth.zero?
|
|
200
|
+
end
|
|
201
|
+
cursor += 1
|
|
202
|
+
end
|
|
203
|
+
raise TypeError, "unterminated collection at byte #{start_offset}"
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def primitive_end(start_offset)
|
|
207
|
+
cursor = start_offset
|
|
208
|
+
cursor += 1 while cursor < @content.length && !",}] \t\r\n".include?(@content[cursor])
|
|
209
|
+
cursor
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def skip_whitespace(offset)
|
|
213
|
+
cursor = offset
|
|
214
|
+
cursor += 1 while cursor < @content.length && @content[cursor].match?(/\s/)
|
|
215
|
+
cursor
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
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
|