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.
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 +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 +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 +218 -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
@@ -4,16 +4,16 @@ module I18nContextGenerator
4
4
  module Parsers
5
5
  # Parses YAML translation files (including Rails i18n style) into TranslationEntry objects.
6
6
  class YamlParser < Base
7
+ def initialize(locale: nil)
8
+ super()
9
+ @locale = locale
10
+ end
11
+
7
12
  def parse(path)
8
13
  data = YAML.safe_load_file(path, permitted_classes: [])
14
+ raise Error, "Invalid YAML translation file #{path}: root must be a mapping" unless data.is_a?(Hash)
9
15
 
10
- # Skip top-level locale key if present (Rails i18n style)
11
- # e.g., { "en" => { "hello" => "Hello" } } -> { "hello" => "Hello" }
12
- if data.is_a?(Hash) && data.keys.size == 1 && data.values.first.is_a?(Hash)
13
- locale_key = data.keys.first
14
- # Only skip if it looks like a locale code (2-5 chars)
15
- data = data.values.first if locale_key.match?(/\A[a-z]{2}(-[A-Z]{2})?\z/i)
16
- end
16
+ data = locale_root(data, path) if @locale
17
17
 
18
18
  flatten_keys(data).filter_map do |key, text|
19
19
  next if text.nil? || text.to_s.strip.empty?
@@ -24,6 +24,19 @@ module I18nContextGenerator
24
24
  source_file: path
25
25
  )
26
26
  end
27
+ rescue Psych::SyntaxError => e
28
+ raise Error, "Failed to parse YAML translation file #{path}: #{e.problem} at line #{e.line}, column #{e.column}"
29
+ rescue Psych::Exception => e
30
+ raise Error, "Failed to parse YAML translation file #{path}: #{e.message}"
31
+ end
32
+
33
+ private
34
+
35
+ def locale_root(data, path)
36
+ root = data[@locale]
37
+ raise Error, "YAML translation file #{path} does not contain locale root #{@locale.inspect}" unless root.is_a?(Hash)
38
+
39
+ root
27
40
  end
28
41
  end
29
42
  end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'find'
4
+ require 'pathname'
5
+
6
+ module I18nContextGenerator
7
+ # Compiles client ignore globs once and applies them consistently while
8
+ # traversing configured source roots.
9
+ class PathPolicy
10
+ def initialize(ignore_patterns:, roots: [])
11
+ @ignore_patterns = self.class.compile_globs(ignore_patterns)
12
+ @roots = roots.map { |root| normalize(root) }.uniq
13
+ end
14
+
15
+ def ignored?(path, directory: false)
16
+ candidates = path_candidates(path, directory: directory)
17
+ @ignore_patterns.any? do |pattern|
18
+ candidates.any? { |candidate| pattern.match?(candidate) }
19
+ end
20
+ end
21
+
22
+ def each_file(path)
23
+ return enum_for(__method__, path) unless block_given?
24
+
25
+ if File.file?(path)
26
+ yield path unless ignored?(path)
27
+ return
28
+ end
29
+ return unless File.directory?(path)
30
+ return if ignored?(path, directory: true)
31
+
32
+ Find.find(path) do |candidate|
33
+ if File.directory?(candidate)
34
+ Find.prune if candidate != path && ignored?(candidate, directory: true)
35
+ next
36
+ end
37
+
38
+ yield candidate unless ignored?(candidate)
39
+ end
40
+ end
41
+
42
+ def files(paths, &predicate)
43
+ seen = {}
44
+ paths.each_with_object([]) do |path, files|
45
+ each_file(path) do |file|
46
+ next if predicate && !predicate.call(file)
47
+
48
+ identity = File.expand_path(file)
49
+ next if seen[identity]
50
+
51
+ seen[identity] = true
52
+ files << file
53
+ end
54
+ end
55
+ end
56
+
57
+ class << self
58
+ def compile_globs(patterns)
59
+ Array(patterns).map { |pattern| glob_to_regex(pattern) }.freeze
60
+ end
61
+
62
+ def glob_to_regex(glob_pattern)
63
+ regex = Regexp.escape(normalize(glob_pattern))
64
+ .gsub('\*\*/', '(.*/)?')
65
+ .gsub('\*\*', '.*')
66
+ .gsub('\*', '[^/]*')
67
+ .gsub('\?', '.')
68
+ Regexp.new("(?:^|/)#{regex}(?:$|/)")
69
+ end
70
+
71
+ private
72
+
73
+ def normalize(path)
74
+ path.to_s.tr('\\', '/')
75
+ end
76
+ end
77
+
78
+ private
79
+
80
+ def path_candidates(path, directory:)
81
+ normalized_path = normalize(path)
82
+ candidates = [normalized_path]
83
+
84
+ @roots.each do |root|
85
+ relative = relative_to_root(normalized_path, root)
86
+ candidates << relative if relative && !relative.empty?
87
+ end
88
+
89
+ return candidates.uniq unless directory
90
+
91
+ candidates.flat_map do |candidate|
92
+ [candidate, candidate.end_with?('/') ? candidate : "#{candidate}/"]
93
+ end.uniq
94
+ end
95
+
96
+ def relative_to_root(path, root)
97
+ return '' if path == root
98
+
99
+ prefix = root.end_with?('/') ? root : "#{root}/"
100
+ path.delete_prefix(prefix) if path.start_with?(prefix)
101
+ end
102
+
103
+ def normalize(path)
104
+ self.class.send(:normalize, Pathname.new(path.to_s).cleanpath.to_s)
105
+ end
106
+ end
107
+ end
@@ -1,55 +1,45 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'find'
3
+ require_relative 'path_policy'
4
+ require_relative 'file_classifier'
4
5
 
5
6
  module I18nContextGenerator
6
7
  # Validates that a run targets a single mobile platform after applying ignore rules.
7
8
  class PlatformValidator
8
9
  def initialize(config)
9
10
  @config = config
10
- @ignore_patterns = @config.ignore_patterns.map { |pattern| glob_to_regex(pattern) }
11
+ @path_policy = PathPolicy.new(ignore_patterns: @config.ignore_patterns, roots: @config.source_paths)
11
12
  end
12
13
 
13
14
  def validate!
14
15
  platforms = (@config.translations.flat_map { |path| translation_platforms_for_path(path) } +
15
16
  @config.source_paths.flat_map { |path| source_platforms_for_path(path) }).uniq
16
17
 
17
- return if platforms.size <= 1
18
+ raise Error, 'Mixed iOS and Android runs are not supported. Split them into separate invocations or config files.' if platforms.size > 1
18
19
 
19
- raise Error, 'Mixed iOS and Android runs are not supported. Split them into separate invocations or config files.'
20
+ detected_platform = platforms.first
21
+ requested_platform = @config.platform&.to_sym
22
+ raise Error, "Configured platform #{requested_platform} conflicts with detected #{detected_platform} inputs" if requested_platform && detected_platform && requested_platform != detected_platform
23
+
24
+ resolved_platform = requested_platform || detected_platform || :unknown
25
+ validate_code_write_back!(resolved_platform)
26
+
27
+ resolved_platform
20
28
  end
21
29
 
22
30
  private
23
31
 
24
32
  def translation_platforms_for_path(path)
25
- basename = File.basename(path).downcase
26
- ext = File.extname(path).downcase
27
-
28
- case ext
29
- when '.strings'
30
- [:ios]
31
- when '.xml'
32
- basename == 'strings.xml' || path.include?('/res/values') ? [:android] : []
33
- else
34
- []
35
- end
33
+ platform = FileClassifier.translation_platform(path)
34
+ platform ? [platform] : []
36
35
  end
37
36
 
38
37
  def source_platforms_for_path(path)
39
38
  return [] unless File.exist?(path)
40
39
 
41
- if File.file?(path)
42
- platform = source_platform_for_file(path)
43
- return platform ? [platform] : []
44
- end
45
-
46
40
  platforms = []
47
-
48
- Find.find(path) do |file|
49
- next unless File.file?(file)
50
- next if ignored_source_file?(file)
51
-
52
- platform = source_platform_for_file(file)
41
+ @path_policy.each_file(path) do |file|
42
+ platform = FileClassifier.source_platform(file)
53
43
  next unless platform
54
44
  next if platforms.include?(platform)
55
45
 
@@ -60,33 +50,17 @@ module I18nContextGenerator
60
50
  platforms
61
51
  end
62
52
 
63
- def source_platform_for_file(path)
64
- case File.extname(path).downcase
65
- when '.swift', '.m', '.mm', '.h'
66
- :ios
67
- when '.kt', '.java'
68
- :android
69
- end
70
- end
53
+ def validate_code_write_back!(platform)
54
+ return unless @config.write_back_to_code
71
55
 
72
- def ignored_source_file?(path)
73
- @ignore_patterns.any? do |pattern|
74
- candidates = [path]
75
- @config.source_paths.each do |root|
76
- prefix = root.end_with?('/') ? root : "#{root}/"
77
- candidates << path.delete_prefix(prefix) if path.start_with?(prefix)
78
- end
79
- candidates.any? { |candidate| pattern.match?(candidate) }
80
- end
56
+ raise Error, 'write_back_to_code is supported only for iOS Swift sources' unless platform == :ios
57
+ return if @config.source_paths.any? { |path| contains_writable_swift_source?(path) }
58
+
59
+ raise Error, 'write_back_to_code requires at least one non-ignored Swift source file'
81
60
  end
82
61
 
83
- def glob_to_regex(glob_pattern)
84
- regex_str = Regexp.escape(glob_pattern)
85
- .gsub('\*\*/', '(.*/)?')
86
- .gsub('\*\*', '.*')
87
- .gsub('\*', '[^/]*')
88
- .gsub('\?', '.')
89
- Regexp.new("(?:^|/)#{regex_str}(?:$|/)")
62
+ def contains_writable_swift_source?(path)
63
+ @path_policy.each_file(path).any? { |file| FileClassifier.swift_source?(file) }
90
64
  end
91
65
  end
92
66
  end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module I18nContextGenerator
4
+ RUN_METRICS_PRICING_AS_OF = '2026-07-23'
5
+ RUN_METRICS_PRICES_PER_MILLION = {
6
+ ['anthropic', 'claude-sonnet-4-6'] => { input: 3.0, output: 15.0 },
7
+ ['openai', 'gpt-5-mini'] => { input: 0.25, output: 2.0 }
8
+ }.freeze
9
+
10
+ # Aggregates run-local provider and cache telemetry. Cost is an estimate based
11
+ # on documented standard list prices for the built-in default models.
12
+ RunMetrics = Data.define(
13
+ :request_count, :cache_hits, :input_tokens, :output_tokens,
14
+ :retry_count, :estimated_cost_usd, :cost_model, :cost_pricing_as_of
15
+ ) do
16
+ def self.from(results, provider:, model:)
17
+ input_tokens = results.sum { |result| result.input_tokens.to_i }
18
+ output_tokens = results.sum { |result| result.output_tokens.to_i }
19
+ price = RUN_METRICS_PRICES_PER_MILLION[[provider.to_s, model.to_s]]
20
+ estimated_cost = ((input_tokens * price[:input]) + (output_tokens * price[:output])) / 1_000_000.0 if price
21
+
22
+ new(
23
+ request_count: results.sum { |result| result.request_count.to_i },
24
+ cache_hits: results.count(&:cache_hit),
25
+ input_tokens: input_tokens,
26
+ output_tokens: output_tokens,
27
+ retry_count: results.sum { |result| result.retries.to_i },
28
+ estimated_cost_usd: estimated_cost&.round(8),
29
+ cost_model: price ? model.to_s : nil,
30
+ cost_pricing_as_of: price ? RUN_METRICS_PRICING_AS_OF : nil
31
+ )
32
+ end
33
+
34
+ def to_h
35
+ {
36
+ request_count: request_count,
37
+ cache_hits: cache_hits,
38
+ input_tokens: input_tokens,
39
+ output_tokens: output_tokens,
40
+ retry_count: retry_count,
41
+ estimated_cost_usd: estimated_cost_usd,
42
+ cost_model: cost_model,
43
+ cost_pricing_as_of: cost_pricing_as_of
44
+ }
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module I18nContextGenerator
4
+ class Searcher
5
+ # Masks comments while preserving line numbers and character positions.
6
+ module CommentMasking
7
+ private
8
+
9
+ def mask_comments(lines, file)
10
+ File.extname(file).downcase == '.xml' ? mask_xml_comments(lines) : mask_c_style_comments(lines)
11
+ end
12
+
13
+ def mask_c_style_comments(lines)
14
+ block_depth = 0
15
+
16
+ lines.map do |line|
17
+ masked = line.dup
18
+ quote = nil
19
+ escaped = false
20
+ index = 0
21
+
22
+ while index < line.length
23
+ pair = line[index, 2]
24
+
25
+ if block_depth.positive?
26
+ if pair == '/*'
27
+ mask_characters!(masked, index, 2)
28
+ block_depth += 1
29
+ index += 2
30
+ elsif pair == '*/'
31
+ mask_characters!(masked, index, 2)
32
+ block_depth -= 1
33
+ index += 2
34
+ else
35
+ mask_characters!(masked, index, 1)
36
+ index += 1
37
+ end
38
+ next
39
+ end
40
+
41
+ if quote
42
+ if escaped
43
+ escaped = false
44
+ elsif line[index] == '\\'
45
+ escaped = true
46
+ elsif line[index] == quote
47
+ quote = nil
48
+ end
49
+ index += 1
50
+ next
51
+ end
52
+
53
+ if pair == '//'
54
+ mask_characters!(masked, index, line.length - index)
55
+ break
56
+ elsif pair == '/*'
57
+ mask_characters!(masked, index, 2)
58
+ block_depth = 1
59
+ index += 2
60
+ elsif ['"', "'"].include?(line[index])
61
+ quote = line[index]
62
+ index += 1
63
+ else
64
+ index += 1
65
+ end
66
+ end
67
+
68
+ masked
69
+ end
70
+ end
71
+
72
+ def mask_xml_comments(lines)
73
+ in_comment = false
74
+
75
+ lines.map do |line|
76
+ masked = line.dup
77
+ index = 0
78
+
79
+ while index < line.length
80
+ if in_comment
81
+ closing_index = line.index('-->', index)
82
+ if closing_index
83
+ mask_characters!(masked, index, closing_index + 3 - index)
84
+ index = closing_index + 3
85
+ in_comment = false
86
+ else
87
+ mask_characters!(masked, index, line.length - index)
88
+ break
89
+ end
90
+ else
91
+ opening_index = line.index('<!--', index)
92
+ break unless opening_index
93
+
94
+ closing_index = line.index('-->', opening_index + 4)
95
+ if closing_index
96
+ mask_characters!(masked, opening_index, closing_index + 3 - opening_index)
97
+ index = closing_index + 3
98
+ else
99
+ mask_characters!(masked, opening_index, line.length - opening_index)
100
+ in_comment = true
101
+ break
102
+ end
103
+ end
104
+ end
105
+
106
+ masked
107
+ end
108
+ end
109
+
110
+ def mask_characters!(text, start, length)
111
+ text[start, length] = ' ' * length
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module I18nContextGenerator
4
+ class Searcher
5
+ # Removes duplicate, translation-definition, and non-localization matches.
6
+ module MatchFiltering
7
+ private
8
+
9
+ def filter_matches(matches, key)
10
+ seen = Set.new
11
+ explicit_patterns = explicit_localization_patterns(key)
12
+ matches.select do |match|
13
+ location = "#{match.file}:#{match.line}"
14
+ next false if seen.include?(location)
15
+ next false if false_positive?(match.match_line, key, explicit_patterns)
16
+ next false if translation_file?(match.file)
17
+
18
+ seen.add(location)
19
+ end
20
+ end
21
+
22
+ def false_positive?(line, key, explicit_patterns)
23
+ return false if line.nil? || line.empty?
24
+ return false if explicit_localization_usage?(line, explicit_patterns)
25
+
26
+ quoted_key = "[\"']#{Regexp.escape(key)}[\"']"
27
+ comparison_patterns = [
28
+ /==\s*#{quoted_key}/,
29
+ /#{quoted_key}\s*==/,
30
+ /!=\s*#{quoted_key}/,
31
+ /#{quoted_key}\s*!=/,
32
+ /\.equals\(\s*#{quoted_key}/,
33
+ /contentEquals\(\s*#{quoted_key}/
34
+ ]
35
+
36
+ comparison_patterns.any? { |pattern| pattern.match?(line) }
37
+ end
38
+
39
+ def explicit_localization_patterns(key)
40
+ (build_ios_patterns(key) + build_android_patterns(key)).map { |pattern| Regexp.new(pattern) }
41
+ end
42
+
43
+ def explicit_localization_usage?(line, patterns)
44
+ patterns.any? { |pattern| pattern.match?(line) }
45
+ end
46
+
47
+ def translation_file?(file)
48
+ !FileClassifier.translation_platform(file).nil?
49
+ end
50
+ end
51
+ end
52
+ end