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
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module I18nContextGenerator
4
+ # Immutable, named evidence supplied in addition to translations and source usages.
5
+ ContextSource = Data.define(:kind, :name, :content) do
6
+ def initialize(kind:, name:, content:)
7
+ super(
8
+ kind: kind.to_sym,
9
+ name: name.dup.freeze,
10
+ content: content.dup.freeze
11
+ )
12
+ end
13
+ end
14
+
15
+ # Converts configured text files and caller-provided values into context records.
16
+ class SupplementalContext
17
+ class << self
18
+ def load(files:, runtime:)
19
+ validate_files!(files)
20
+ validate_runtime!(runtime)
21
+
22
+ file_sources = files.map { |path| load_file(path) }
23
+ runtime_sources = runtime.map do |name, content|
24
+ ContextSource.new(
25
+ kind: :runtime,
26
+ name: scrub_text(name),
27
+ content: scrub_text(content)
28
+ )
29
+ end
30
+
31
+ (file_sources + runtime_sources).freeze
32
+ end
33
+
34
+ private
35
+
36
+ def validate_files!(files)
37
+ return if files.is_a?(Array) && files.all? { |path| path.is_a?(String) && !path.strip.empty? }
38
+
39
+ raise Error, 'context_files must be an array of non-empty strings'
40
+ end
41
+
42
+ def validate_runtime!(runtime)
43
+ raise Error, 'supplemental_context must be a mapping' unless runtime.is_a?(Hash)
44
+
45
+ runtime.each do |name, content|
46
+ raise Error, 'supplemental context name must be a non-empty string' unless
47
+ name.is_a?(String) && !name.strip.empty?
48
+ next if content.is_a?(String) && !content.strip.empty?
49
+
50
+ raise Error, "supplemental context value for #{name} must be a non-empty string"
51
+ end
52
+ end
53
+
54
+ def load_file(path)
55
+ bytes = File.binread(path)
56
+ raise Error, "context file contains NUL bytes; convert it to UTF-8 text: #{path}" if bytes.include?("\0")
57
+
58
+ content = scrub_text(bytes)
59
+ raise Error, "context file is empty: #{path}" if content.strip.empty?
60
+
61
+ ContextSource.new(
62
+ kind: :file,
63
+ name: scrub_text(File.basename(path)),
64
+ content: content
65
+ )
66
+ rescue Error
67
+ raise
68
+ rescue SystemCallError => e
69
+ raise Error, "Unable to read context file #{path}: #{e.message}"
70
+ end
71
+
72
+ def scrub_text(value)
73
+ value.dup.force_encoding(Encoding::UTF_8).scrub
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'xml_scanner'
4
+ require_relative 'apple_string_literal'
5
+
6
+ module I18nContextGenerator
7
+ # Associates translator-comment lines with the translation entry that follows.
8
+ # Diff consumers use this to treat comment-only edits as entry changes while
9
+ # retaining the exact changed comment line for review placement.
10
+ class TranslationCommentIndex
11
+ def initialize(file_path = nil, format:, content: nil)
12
+ @file_path = file_path
13
+ @format = format
14
+ @content = content || File.binread(file_path)
15
+ @key_lines = {}
16
+ end
17
+
18
+ def key_at(line_number)
19
+ entries[line_number]
20
+ end
21
+
22
+ def line_for_key(key)
23
+ entries
24
+ @key_lines[key]
25
+ end
26
+
27
+ private
28
+
29
+ def entries
30
+ @entries ||= case @format
31
+ when :strings then strings_entries
32
+ when :xml then xml_entries
33
+ else {}
34
+ end
35
+ end
36
+
37
+ def strings_entries
38
+ entries = {}
39
+ pending_lines = []
40
+ inside_comment = false
41
+
42
+ @content.each_line.with_index(1) do |line, line_number|
43
+ content = line
44
+
45
+ if inside_comment
46
+ pending_lines << line_number
47
+ comment_end = content.index('*/')
48
+ next unless comment_end
49
+
50
+ inside_comment = false
51
+ content = content[(comment_end + 2)..].to_s
52
+ elsif (comment_start = content.index(%r{\A\s*/\*}))
53
+ pending_lines << line_number
54
+ comment_end = content.index('*/', comment_start + 2)
55
+ unless comment_end
56
+ inside_comment = true
57
+ next
58
+ end
59
+ content = content[(comment_end + 2)..].to_s
60
+ elsif content.lstrip.start_with?('//')
61
+ pending_lines << line_number
62
+ next
63
+ end
64
+
65
+ if (key = strings_key_from(content))
66
+ @key_lines[key] ||= line_number
67
+ pending_lines.each { |comment_line| entries[comment_line] = key }
68
+ pending_lines.clear
69
+ elsif !content.strip.empty?
70
+ pending_lines.clear
71
+ end
72
+ end
73
+
74
+ entries
75
+ end
76
+
77
+ def strings_key_from(content)
78
+ AppleStringLiteral.assignment_key(content)
79
+ end
80
+
81
+ def xml_entries
82
+ entries = {}
83
+ pending_lines = []
84
+ pending_tag = nil
85
+ pending_tag_line = nil
86
+ comment_state = {}
87
+
88
+ @content.each_line.with_index(1) do |line, line_number|
89
+ visible, contained_comment = XmlScanner.without_comments(line, comment_state)
90
+ pending_lines << line_number if contained_comment
91
+
92
+ if pending_tag
93
+ pending_tag << visible
94
+ elsif (tag_start = visible.index(/<(?:string-array|plurals|string)\b/))
95
+ pending_tag = visible[tag_start..]
96
+ pending_tag_line = line_number
97
+ elsif !visible.strip.empty?
98
+ pending_lines.clear
99
+ end
100
+
101
+ next unless pending_tag&.include?('>')
102
+
103
+ if (key = resource_name_from(pending_tag))
104
+ @key_lines[key] ||= pending_tag_line
105
+ pending_lines.each { |comment_line| entries[comment_line] = key }
106
+ end
107
+ pending_lines.clear
108
+ pending_tag = nil
109
+ pending_tag_line = nil
110
+ end
111
+
112
+ entries
113
+ end
114
+
115
+ def resource_name_from(tag)
116
+ return unless tag.match?(/<(?:string-array|plurals|string)\b/)
117
+
118
+ tag[/\bname\s*=\s*(["'])(.*?)\1/m, 2]
119
+ end
120
+ end
121
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module I18nContextGenerator
4
- VERSION = '0.4.0'
4
+ VERSION = '0.5.1'
5
5
  end
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative '../android_resource'
4
+ require_relative '../generated_comment'
5
+
3
6
  module I18nContextGenerator
4
7
  module Writers
5
8
  # Writer that updates Android strings.xml files with context comments
@@ -15,8 +18,10 @@ module I18nContextGenerator
15
18
  def write(results, source_path)
16
19
  return unless File.exist?(source_path)
17
20
 
21
+ original_content = File.binread(source_path)
18
22
  lines = File.readlines(source_path, encoding: 'UTF-8')
19
23
  results_by_key = build_results_lookup(results, source_path)
24
+ return false unless results_by_key.values.any? { |result| writable_result?(result) }
20
25
 
21
26
  output_lines = []
22
27
  i = 0
@@ -27,11 +32,11 @@ module I18nContextGenerator
27
32
  # Only write comments on <string> elements, not <plurals> or <string-array>.
28
33
  # Plural/array parent comments would use a single child's description which
29
34
  # is misleading for the resource as a whole.
30
- # Match the opening <string name="..."> tag works for both single-line and
31
- # multi-line string elements.
32
- if (match = line.match(/^(\s*)<string\s+name="([^"]+)"/))
33
- indent = match[1]
34
- key = match[2]
35
+ # Match the complete opening tag so attribute order, quote style, and
36
+ # multiline attributes do not affect write-back.
37
+ if (element = string_element_at(lines, i))
38
+ indent = element[:indent]
39
+ key = element[:key]
35
40
  result = results_by_key[key]
36
41
 
37
42
  insert_context_comment(output_lines, indent, result.description) if writable_result?(result)
@@ -41,11 +46,34 @@ module I18nContextGenerator
41
46
  i += 1
42
47
  end
43
48
 
44
- File.write(source_path, output_lines.join)
49
+ rendered = output_lines.join
50
+ return false if rendered == original_content
51
+
52
+ AtomicFile.replace(source_path, rendered) do |candidate_path|
53
+ REXML::Document.new(File.read(candidate_path, encoding: 'UTF-8'))
54
+ end
55
+ true
45
56
  end
46
57
 
47
58
  private
48
59
 
60
+ def string_element_at(lines, index)
61
+ start_match = lines[index].match(/^(\s*)<string(?=\s|>)/)
62
+ return unless start_match
63
+
64
+ opening_tag_lines = []
65
+ lines[index..].each do |line|
66
+ opening_tag_lines << line
67
+ break if line.include?('>')
68
+ end
69
+ opening_tag = opening_tag_lines.join
70
+ opening_tag = opening_tag.split('>', 2).first
71
+ name_match = opening_tag.match(/\bname\s*=\s*(["'])(.*?)\1/m)
72
+ return unless name_match
73
+
74
+ { indent: start_match[1], key: name_match[2] }
75
+ end
76
+
49
77
  # Build a lookup that maps base resource names to results.
50
78
  # For plural keys like "post_likes_count:one", maps "post_likes_count" to a result.
51
79
  # For array keys like "days_of_week[0]", maps "days_of_week" to a result.
@@ -57,7 +85,7 @@ module I18nContextGenerator
57
85
  results.sort_by(&:key).each do |r|
58
86
  next if source_path && !result_matches_source_path?(r, source_path)
59
87
 
60
- base = r.key.sub(/:[a-z]+$/, '').sub(/\[\d+\]$/, '')
88
+ base = AndroidResource.base_key(r.key)
61
89
  lookup[base] ||= r
62
90
  lookup[r.key] ||= r
63
91
  end
@@ -65,7 +93,7 @@ module I18nContextGenerator
65
93
  end
66
94
 
67
95
  def insert_context_comment(output_lines, indent, description)
68
- context_text = "#{@context_prefix}#{escape_comment(description)}"
96
+ context_text = escape_comment("#{@context_prefix}#{description}")
69
97
 
70
98
  if output_lines.any? && output_lines.last.match?(/^\s*<!--.*-->\s*$/)
71
99
  existing_match = output_lines.last.match(/^\s*<!--\s*(.*?)\s*-->\s*$/)
@@ -90,19 +118,17 @@ module I18nContextGenerator
90
118
  # so we always treat the preceding comment as replaceable — the user accepted
91
119
  # this trade-off by choosing an empty prefix.
92
120
  def managed_comment?(comment)
93
- @context_prefix.empty? || comment.include?(@context_prefix)
121
+ GeneratedComment.managed?(comment, prefix: escaped_context_prefix)
94
122
  end
95
123
 
96
124
  def build_comment(existing_comment, context_text)
97
- if existing_comment.nil? || existing_comment.empty? || @context_mode == 'replace'
98
- context_text
99
- elsif !@context_prefix.empty? && existing_comment.include?(@context_prefix)
100
- # Replace existing context line (idempotent update)
101
- existing_comment.gsub(/#{Regexp.escape(@context_prefix)}[^\n]*/, context_text)
102
- else
103
- # Append context to existing comment
104
- "#{existing_comment} #{context_text}"
105
- end
125
+ GeneratedComment.merge(
126
+ existing: existing_comment,
127
+ generated: context_text,
128
+ prefix: escaped_context_prefix,
129
+ mode: @context_mode,
130
+ separator: ' '
131
+ )
106
132
  end
107
133
 
108
134
  def escape_comment(text)
@@ -112,6 +138,10 @@ module I18nContextGenerator
112
138
  .gsub("\n", ' ')
113
139
  .strip
114
140
  end
141
+
142
+ def escaped_context_prefix
143
+ escape_comment(@context_prefix)
144
+ end
115
145
  end
116
146
  end
117
147
  end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tempfile'
4
+ require 'fileutils'
5
+
6
+ module I18nContextGenerator
7
+ module Writers
8
+ # Safely replaces an existing file after optionally validating the candidate.
9
+ class AtomicFile
10
+ class << self
11
+ def replace(path, content)
12
+ original_mode = File.stat(path).mode & 0o7777
13
+ directory = File.dirname(File.expand_path(path))
14
+ basename = File.basename(path)
15
+ temporary_file = Tempfile.new([".#{basename}.", '.tmp'], directory)
16
+ temporary_path = temporary_file.path
17
+
18
+ begin
19
+ temporary_file.binmode
20
+ temporary_file.write(content)
21
+ temporary_file.flush
22
+ temporary_file.fsync
23
+ temporary_file.chmod(original_mode)
24
+ temporary_file.close
25
+
26
+ yield temporary_path if block_given?
27
+
28
+ File.rename(temporary_path, path)
29
+ ensure
30
+ temporary_file.close unless temporary_file.closed?
31
+ FileUtils.rm_f(temporary_path)
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -4,30 +4,55 @@ module I18nContextGenerator
4
4
  module Writers
5
5
  # Writes extraction results to a CSV file.
6
6
  class CsvWriter
7
- HEADERS = %w[key text description ui_element tone max_length locations error].freeze
7
+ include ResultSerialization
8
+
9
+ HEADERS = %w[
10
+ schema_version key source_file translation_key text description ui_element
11
+ tone max_length confidence ambiguity_reason locations changed_locations
12
+ changed_location_groups changed_translation_locations status cache_hit
13
+ request_count input_tokens output_tokens retries error
14
+ ].freeze
8
15
  DANGEROUS_CSV_PREFIX = /\A[ \t\r\n]*[=+\-@]/
9
16
 
10
- def write(results, path)
11
- CSV.open(path, 'w') do |csv|
12
- csv << HEADERS
13
-
14
- results.sort_by(&:key).each do |result|
15
- csv << [
16
- sanitize_cell(result.key),
17
- sanitize_cell(result.text),
18
- sanitize_cell(result.description),
19
- sanitize_cell(result.ui_element),
20
- sanitize_cell(result.tone),
21
- result.max_length,
22
- sanitize_cell(result.locations.join(';')),
23
- sanitize_cell(result.error)
24
- ]
25
- end
26
- end
17
+ def write(results, path, output: $stdout, **_options)
18
+ return write_rows(CSV.new(output), results) if path == '-'
19
+
20
+ CSV.open(path, 'w') { |csv| write_rows(csv, results) }
27
21
  end
28
22
 
29
23
  private
30
24
 
25
+ def write_rows(csv, results)
26
+ csv << HEADERS
27
+
28
+ results.sort_by { |result| result_sort_key(result) }.each do |result|
29
+ csv << [
30
+ OUTPUT_SCHEMA_VERSION,
31
+ sanitize_cell(result.key),
32
+ sanitize_cell(result.source_file),
33
+ sanitize_cell(result.translation_key),
34
+ sanitize_cell(result.text),
35
+ sanitize_cell(result.description),
36
+ sanitize_cell(result.ui_element),
37
+ sanitize_cell(result.tone),
38
+ result.max_length,
39
+ sanitize_cell(result.confidence),
40
+ sanitize_cell(result.ambiguity_reason),
41
+ sanitize_cell(Oj.dump(serialize_locations(result.locations), mode: :compat)),
42
+ sanitize_cell(Oj.dump(serialize_locations(result.changed_locations), mode: :compat)),
43
+ sanitize_cell(Oj.dump(serialize_location_groups(result.changed_location_groups), mode: :compat)),
44
+ sanitize_cell(Oj.dump(serialize_locations(result.changed_translation_locations), mode: :compat)),
45
+ result.status,
46
+ result.cache_hit,
47
+ result.request_count,
48
+ result.input_tokens,
49
+ result.output_tokens,
50
+ result.retries,
51
+ sanitize_cell(result.error)
52
+ ]
53
+ end
54
+ end
55
+
31
56
  def sanitize_cell(value)
32
57
  return value unless value.is_a?(String)
33
58
  return value unless value.match?(DANGEROUS_CSV_PREFIX)
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative '../path_policy'
4
+ require_relative '../file_classifier'
5
+
3
6
  module I18nContextGenerator
4
7
  module Writers
5
8
  # Shared utilities for writer classes (description filtering, file discovery).
@@ -10,6 +13,7 @@ module I18nContextGenerator
10
13
 
11
14
  def writable_result?(result)
12
15
  return false unless result&.description
16
+ return false if result.respond_to?(:actionable?) && !result.actionable?
13
17
  return false unless result.error.nil?
14
18
  return false if result.description.strip.empty?
15
19
 
@@ -23,35 +27,9 @@ module I18nContextGenerator
23
27
  end
24
28
 
25
29
  def find_swift_files(path, ignore_patterns: [])
26
- files = if File.file?(path) && path.end_with?('.swift')
27
- [path]
28
- elsif File.directory?(path)
29
- Dir.glob(File.join(path, '**', '*.swift'))
30
- else
31
- []
32
- end
33
-
34
- filter_ignored_paths(files, ignore_patterns)
35
- end
36
-
37
- private
38
-
39
- def filter_ignored_paths(paths, ignore_patterns)
40
- compiled_patterns = ignore_patterns.map { |pattern| glob_to_regex(pattern) }
41
- paths.reject { |path| ignored_path?(path, compiled_patterns) }.sort
42
- end
43
-
44
- def ignored_path?(path, compiled_patterns)
45
- compiled_patterns.any? { |pattern| pattern.match?(path) }
46
- end
47
-
48
- def glob_to_regex(glob_pattern)
49
- regex_str = Regexp.escape(glob_pattern)
50
- .gsub('\*\*/', '(.*/)?')
51
- .gsub('\*\*', '.*')
52
- .gsub('\*', '[^/]*')
53
- .gsub('\?', '.')
54
- Regexp.new("(?:^|/)#{regex_str}(?:$|/)")
30
+ PathPolicy.new(ignore_patterns: ignore_patterns, roots: [path])
31
+ .files([path]) { |file| FileClassifier.swift_source?(file) }
32
+ .sort
55
33
  end
56
34
  end
57
35
  end
@@ -6,28 +6,53 @@ module I18nContextGenerator
6
6
  module Writers
7
7
  # Writes extraction results to a JSON file.
8
8
  class JsonWriter
9
- def write(results, path)
10
- output = {
9
+ include ResultSerialization
10
+
11
+ def write(results, path, metrics: nil, output: $stdout)
12
+ document = {
13
+ schema_version: OUTPUT_SCHEMA_VERSION,
11
14
  generated_at: Time.now.iso8601,
12
15
  version: I18nContextGenerator::VERSION,
13
16
  total: results.size,
14
- entries: results.sort_by(&:key).map do |result|
17
+ metrics: metrics&.to_h,
18
+ entries: results.sort_by { |result| result_sort_key(result) }.map do |result|
15
19
  {
16
20
  key: result.key,
21
+ source_file: result.source_file,
22
+ translation_key: result.translation_key,
17
23
  text: result.text,
18
24
  context: {
19
25
  description: result.description,
20
26
  ui_element: result.ui_element,
21
27
  tone: result.tone,
22
- max_length: result.max_length
28
+ max_length: result.max_length,
29
+ confidence: result.confidence,
30
+ ambiguity_reason: result.ambiguity_reason
31
+ },
32
+ locations: serialize_locations(result.locations),
33
+ changed_locations: serialize_locations(result.changed_locations),
34
+ changed_location_groups: serialize_location_groups(result.changed_location_groups),
35
+ changed_translation_locations: serialize_locations(result.changed_translation_locations),
36
+ status: result.status,
37
+ telemetry: {
38
+ cache_hit: result.cache_hit,
39
+ request_count: result.request_count,
40
+ input_tokens: result.input_tokens,
41
+ output_tokens: result.output_tokens,
42
+ retries: result.retries
23
43
  },
24
- locations: result.locations,
25
44
  error: result.error
26
45
  }
27
46
  end
28
47
  }
29
48
 
30
- File.write(path, Oj.dump(output, indent: 2, mode: :compat))
49
+ rendered = Oj.dump(document, indent: 2, mode: :compat)
50
+ if path == '-'
51
+ output.write(rendered)
52
+ output.write("\n") unless rendered.end_with?("\n")
53
+ else
54
+ File.write(path, rendered)
55
+ end
31
56
  end
32
57
  end
33
58
  end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+ require 'pathname'
5
+ require 'tempfile'
6
+
7
+ module I18nContextGenerator
8
+ module Writers
9
+ # Renders a writer against a temporary copy and returns a Git-style patch
10
+ # without mutating the original file.
11
+ class Preview
12
+ class << self
13
+ def render(path)
14
+ extension = File.extname(path)
15
+ candidate = Tempfile.new(['i18n-context-preview-', extension])
16
+ candidate.binmode
17
+ candidate.write(File.binread(path))
18
+ candidate.close
19
+
20
+ yield candidate.path
21
+ return nil if File.binread(candidate.path) == File.binread(path)
22
+
23
+ unified_diff(path, candidate.path)
24
+ ensure
25
+ candidate&.close!
26
+ end
27
+
28
+ private
29
+
30
+ def unified_diff(original_path, candidate_path)
31
+ stdout, stderr, status = Open3.capture3(
32
+ 'git', 'diff', '--no-index', '--no-prefix', '--', original_path, candidate_path
33
+ )
34
+ unless [0, 1].include?(status.exitstatus)
35
+ detail = stderr.strip
36
+ detail = 'git diff --no-index failed' if detail.empty?
37
+ raise Error, "Unable to render preview for #{original_path}: #{detail}"
38
+ end
39
+
40
+ sanitize_headers(stdout, original_path)
41
+ rescue SystemCallError => e
42
+ raise Error, "Unable to render preview for #{original_path}: #{e.message}"
43
+ end
44
+
45
+ def sanitize_headers(diff, original_path)
46
+ display_path = display_path_for(original_path)
47
+ in_hunk = false
48
+ diff.lines.map do |line|
49
+ in_hunk = true if line.start_with?('@@ ')
50
+ if !in_hunk && line.start_with?('diff --git ')
51
+ "diff --git a/#{display_path} b/#{display_path}\n"
52
+ elsif !in_hunk && line.start_with?('--- ')
53
+ "--- a/#{display_path}\n"
54
+ elsif !in_hunk && line.start_with?('+++ ')
55
+ "+++ b/#{display_path}\n"
56
+ else
57
+ line
58
+ end
59
+ end.join
60
+ end
61
+
62
+ def display_path_for(original_path)
63
+ clean_path = Pathname.new(original_path).cleanpath
64
+ return clean_path.to_s unless clean_path.absolute?
65
+
66
+ directory = File.dirname(clean_path.to_s)
67
+ stdout, _stderr, status = Open3.capture3('git', 'rev-parse', '--show-toplevel', chdir: directory)
68
+ if status.success?
69
+ relative = clean_path.relative_path_from(Pathname.new(stdout.strip)).cleanpath.to_s
70
+ return relative unless relative == '..' || relative.start_with?('../')
71
+ end
72
+
73
+ clean_path.basename.to_s
74
+ rescue ArgumentError, SystemCallError
75
+ clean_path.basename.to_s
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module I18nContextGenerator
4
+ module Writers
5
+ # Stable machine-output encoding shared by JSON and CSV writers.
6
+ module ResultSerialization
7
+ OUTPUT_SCHEMA_VERSION = 1
8
+
9
+ private
10
+
11
+ def result_sort_key(result)
12
+ [result.source_file.to_s, result.translation_key.to_s, result.key]
13
+ end
14
+
15
+ def serialize_locations(locations)
16
+ Array(locations).map { |location| serialize_location(location) }
17
+ end
18
+
19
+ def serialize_location_groups(groups)
20
+ Array(groups).map { |group| serialize_locations(group) }
21
+ end
22
+
23
+ def serialize_location(location)
24
+ return location unless location.is_a?(ChangedLocation)
25
+
26
+ location.to_h.transform_values { |value| value.is_a?(Symbol) ? value.to_s : value }
27
+ end
28
+ end
29
+ end
30
+ end