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.
- 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 +304 -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 +248 -0
- data/lib/i18n_context_generator/xml_scanner.rb +38 -0
- data/lib/i18n_context_generator.rb +20 -4
- metadata +59 -12
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module I18nContextGenerator
|
|
4
|
+
module Parsers
|
|
5
|
+
# Parser for Apple string catalogs. Context generation is keyed by the
|
|
6
|
+
# catalog entry rather than by individual target-language localizations.
|
|
7
|
+
class XcstringsParser < Base
|
|
8
|
+
def parse(path)
|
|
9
|
+
catalog = load_catalog(path)
|
|
10
|
+
source_language = catalog['sourceLanguage']
|
|
11
|
+
|
|
12
|
+
catalog.fetch('strings').filter_map do |key, entry|
|
|
13
|
+
next if key.empty?
|
|
14
|
+
next if entry['shouldTranslate'] == false
|
|
15
|
+
|
|
16
|
+
TranslationEntry.new(
|
|
17
|
+
key: key,
|
|
18
|
+
text: source_text(key, entry, source_language),
|
|
19
|
+
source_file: path,
|
|
20
|
+
metadata: {
|
|
21
|
+
comment: entry['comment'],
|
|
22
|
+
source_language: source_language,
|
|
23
|
+
resource_type: :string
|
|
24
|
+
}.compact
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
rescue KeyError => e
|
|
28
|
+
raise Error, "Failed to parse Apple string catalog #{path}: missing #{e.key.inspect}"
|
|
29
|
+
rescue Oj::ParseError, TypeError => e
|
|
30
|
+
raise Error, "Failed to parse Apple string catalog #{path}: #{e.message}"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.validate_catalog!(catalog, path:)
|
|
34
|
+
raise TypeError, 'root must be a mapping' unless catalog.is_a?(Hash)
|
|
35
|
+
raise TypeError, 'sourceLanguage must be a non-empty string' unless nonempty_string?(catalog['sourceLanguage'])
|
|
36
|
+
raise TypeError, 'strings must be a mapping' unless catalog['strings'].is_a?(Hash)
|
|
37
|
+
|
|
38
|
+
valid_entries = catalog['strings'].all? { |key, entry| key.is_a?(String) && entry.is_a?(Hash) }
|
|
39
|
+
raise TypeError, 'strings must map string keys to entries' unless valid_entries
|
|
40
|
+
|
|
41
|
+
catalog
|
|
42
|
+
rescue TypeError => e
|
|
43
|
+
raise Error, "Failed to parse Apple string catalog #{path}: #{e.message}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.nonempty_string?(value)
|
|
47
|
+
value.is_a?(String) && !value.empty?
|
|
48
|
+
end
|
|
49
|
+
private_class_method :nonempty_string?
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def load_catalog(path)
|
|
54
|
+
catalog = Oj.load_file(path, mode: :strict)
|
|
55
|
+
self.class.validate_catalog!(catalog, path: path)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def source_text(key, entry, source_language)
|
|
59
|
+
localization = entry.dig('localizations', source_language)
|
|
60
|
+
values = string_unit_values(localization)
|
|
61
|
+
values.empty? ? key : values.join(' | ')
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def string_unit_values(value)
|
|
65
|
+
case value
|
|
66
|
+
when Hash
|
|
67
|
+
direct_value = value.dig('stringUnit', 'value')
|
|
68
|
+
return [direct_value] if direct_value.is_a?(String)
|
|
69
|
+
|
|
70
|
+
value.values.flat_map { |child| string_unit_values(child) }
|
|
71
|
+
when Array
|
|
72
|
+
value.flat_map { |child| string_unit_values(child) }
|
|
73
|
+
else
|
|
74
|
+
[]
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
@
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
26
|
-
|
|
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
|
-
|
|
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
|
|
64
|
-
|
|
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
|
-
|
|
73
|
-
@
|
|
74
|
-
|
|
75
|
-
|
|
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
|
|
84
|
-
|
|
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
|