i18n-context-generator 0.3.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 +165 -73
- 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 +315 -105
- 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 +65 -0
- data/lib/i18n_context_generator/context_extractor/source_entries.rb +103 -0
- data/lib/i18n_context_generator/context_extractor/source_filters.rb +104 -0
- 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 +218 -164
- 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 +280 -88
- 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 +232 -0
- data/lib/i18n_context_generator/searcher.rb +69 -201
- 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 +62 -12
|
@@ -6,70 +6,56 @@ module I18nContextGenerator
|
|
|
6
6
|
class OpenAI < Client
|
|
7
7
|
API_URL = 'https://api.openai.com/v1/responses'
|
|
8
8
|
DEFAULT_MODEL = 'gpt-5-mini'
|
|
9
|
-
MAX_RETRIES = 2
|
|
10
|
-
RESPONSE_SCHEMA = {
|
|
11
|
-
type: 'object',
|
|
12
|
-
additionalProperties: false,
|
|
13
|
-
required: %w[description ui_element tone max_length],
|
|
14
|
-
properties: {
|
|
15
|
-
description: { type: 'string' },
|
|
16
|
-
ui_element: { type: %w[string null], enum: %w[button label title alert toast placeholder navigation menu tab error confirmation other] + [nil] },
|
|
17
|
-
tone: { type: %w[string null], enum: %w[formal casual urgent friendly technical neutral] + [nil] },
|
|
18
|
-
max_length: { type: %w[integer null] }
|
|
19
|
-
}
|
|
20
|
-
}.freeze
|
|
21
9
|
|
|
22
|
-
def initialize
|
|
23
|
-
super
|
|
24
|
-
@api_key =
|
|
25
|
-
raise Error, 'OPENAI_API_KEY environment variable is required'
|
|
10
|
+
def initialize(api_url: API_URL, api_key: ENV.fetch('OPENAI_API_KEY', nil), require_api_key: true)
|
|
11
|
+
super()
|
|
12
|
+
@api_key = api_key
|
|
13
|
+
raise Error, 'OPENAI_API_KEY environment variable is required' if require_api_key && !@api_key
|
|
26
14
|
|
|
27
|
-
@uri = URI(
|
|
15
|
+
@uri = URI(api_url)
|
|
28
16
|
end
|
|
29
17
|
|
|
30
18
|
def generate_context(key:, text:, matches:, model: nil, comment: nil,
|
|
31
|
-
include_file_paths: false, redact_prompts: true
|
|
32
|
-
|
|
19
|
+
include_file_paths: false, redact_prompts: true,
|
|
20
|
+
max_prompt_chars: nil, supplemental_context: [])
|
|
21
|
+
outcome = nil
|
|
22
|
+
model = resolved_model(model)
|
|
33
23
|
prompt = build_prompt(
|
|
34
24
|
key: key,
|
|
35
25
|
text: text,
|
|
36
26
|
matches: matches,
|
|
37
27
|
comment: comment,
|
|
38
28
|
include_file_paths: include_file_paths,
|
|
39
|
-
redact_prompts: redact_prompts
|
|
29
|
+
redact_prompts: redact_prompts,
|
|
30
|
+
max_prompt_chars: max_prompt_chars,
|
|
31
|
+
supplemental_context: supplemental_context
|
|
40
32
|
)
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
if response.code.to_i == 429 && retries < MAX_RETRIES
|
|
47
|
-
retries += 1
|
|
48
|
-
delay = (response['retry-after']&.to_i || 2) * retries
|
|
49
|
-
sleep(delay)
|
|
50
|
-
next
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
return handle_response(response)
|
|
54
|
-
end
|
|
33
|
+
outcome = request_with_retries(uri: @uri) { post_request(model: model, prompt: prompt) }
|
|
34
|
+
handle_response(outcome.response, retries: outcome.retries)
|
|
35
|
+
rescue PromptPreparationError => e
|
|
36
|
+
ContextResult.new(description: 'Prompt preparation failed', error: e.message)
|
|
55
37
|
rescue StandardError => e
|
|
56
|
-
ContextResult.new(
|
|
38
|
+
ContextResult.new(
|
|
39
|
+
description: 'API request failed',
|
|
40
|
+
error: e.message,
|
|
41
|
+
**failure_request_telemetry(e, outcome: outcome)
|
|
42
|
+
)
|
|
57
43
|
end
|
|
58
44
|
|
|
59
45
|
private
|
|
60
46
|
|
|
61
47
|
def post_request(model:, prompt:)
|
|
48
|
+
headers = {}
|
|
49
|
+
headers['Authorization'] = "Bearer #{@api_key}" if @api_key
|
|
62
50
|
post_json(
|
|
63
51
|
uri: @uri,
|
|
64
|
-
headers:
|
|
65
|
-
'Authorization' => "Bearer #{@api_key}"
|
|
66
|
-
},
|
|
52
|
+
headers: headers,
|
|
67
53
|
body: {
|
|
68
54
|
model: model,
|
|
69
55
|
store: false,
|
|
70
56
|
instructions: SYSTEM_PROMPT,
|
|
71
57
|
input: prompt,
|
|
72
|
-
max_output_tokens:
|
|
58
|
+
max_output_tokens: MAX_OUTPUT_TOKENS,
|
|
73
59
|
text: {
|
|
74
60
|
format: {
|
|
75
61
|
type: 'json_schema',
|
|
@@ -82,24 +68,70 @@ module I18nContextGenerator
|
|
|
82
68
|
)
|
|
83
69
|
end
|
|
84
70
|
|
|
85
|
-
def handle_response(response)
|
|
71
|
+
def handle_response(response, retries:)
|
|
86
72
|
case response.code.to_i
|
|
87
73
|
when 200
|
|
88
74
|
body = JSON.parse(response.body)
|
|
89
|
-
|
|
90
|
-
when 429
|
|
91
|
-
ContextResult.new(description: 'Rate limited', error: 'Rate limit exceeded - try reducing concurrency')
|
|
92
|
-
when 401
|
|
93
|
-
ContextResult.new(description: 'Authentication failed', error: 'Invalid API key')
|
|
75
|
+
handle_successful_response(body, retries: retries)
|
|
94
76
|
else
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
77
|
+
http_error_result(response, retries: retries)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def handle_successful_response(body, retries:)
|
|
82
|
+
telemetry = usage_telemetry(body, retries: retries)
|
|
83
|
+
status = body['status']
|
|
84
|
+
return incomplete_result(body, telemetry: telemetry) if status == 'incomplete'
|
|
85
|
+
return failed_result(body, telemetry: telemetry) if status == 'failed'
|
|
86
|
+
|
|
87
|
+
unless status == 'completed'
|
|
88
|
+
return ContextResult.new(
|
|
89
|
+
description: 'Incomplete response',
|
|
90
|
+
error: "Unexpected OpenAI response status: #{status || 'missing'}",
|
|
91
|
+
**telemetry
|
|
92
|
+
)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
refusal = extract_refusal(body)
|
|
96
|
+
if refusal
|
|
97
|
+
return ContextResult.new(
|
|
98
|
+
description: 'Provider refused request',
|
|
99
|
+
error: "OpenAI refusal: #{refusal[0, 300]}",
|
|
100
|
+
**telemetry
|
|
101
|
+
)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
parse_response(extract_output_text(body), telemetry: telemetry)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def incomplete_result(body, telemetry:)
|
|
108
|
+
reason = body.dig('incomplete_details', 'reason') || 'unknown reason'
|
|
109
|
+
ContextResult.new(
|
|
110
|
+
description: 'Incomplete response',
|
|
111
|
+
error: "OpenAI response incomplete: #{reason}",
|
|
112
|
+
**telemetry
|
|
113
|
+
)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def failed_result(body, telemetry:)
|
|
117
|
+
message = body.dig('error', 'message') || 'unknown provider error'
|
|
118
|
+
ContextResult.new(
|
|
119
|
+
description: 'API error',
|
|
120
|
+
error: "OpenAI response failed: #{message.to_s[0, 300]}",
|
|
121
|
+
**telemetry
|
|
122
|
+
)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def extract_refusal(body)
|
|
126
|
+
Array(body['output']).each do |output_item|
|
|
127
|
+
Array(output_item['content']).each do |content_item|
|
|
128
|
+
next unless content_item['type'] == 'refusal'
|
|
129
|
+
|
|
130
|
+
refusal = content_item['refusal'].to_s
|
|
131
|
+
return refusal unless refusal.empty?
|
|
99
132
|
end
|
|
100
|
-
error_msg = error_body.dig('error', 'message') || error_body['message'] || "HTTP #{response.code}"
|
|
101
|
-
ContextResult.new(description: 'API error', error: error_msg)
|
|
102
133
|
end
|
|
134
|
+
nil
|
|
103
135
|
end
|
|
104
136
|
|
|
105
137
|
def extract_output_text(body)
|
|
@@ -107,6 +139,15 @@ module I18nContextGenerator
|
|
|
107
139
|
content_item = Array(output_item&.[]('content')).find { |item| item['type'] == 'output_text' }
|
|
108
140
|
content_item&.dig('text')
|
|
109
141
|
end
|
|
142
|
+
|
|
143
|
+
def usage_telemetry(body, retries:)
|
|
144
|
+
{
|
|
145
|
+
input_tokens: body.dig('usage', 'input_tokens').to_i,
|
|
146
|
+
output_tokens: body.dig('usage', 'output_tokens').to_i,
|
|
147
|
+
retries: retries,
|
|
148
|
+
request_count: retries + 1
|
|
149
|
+
}
|
|
150
|
+
end
|
|
110
151
|
end
|
|
111
152
|
end
|
|
112
153
|
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module I18nContextGenerator
|
|
4
|
+
module LLM
|
|
5
|
+
# Explicit OpenAI Responses-compatible endpoint support. A separate
|
|
6
|
+
# credential variable prevents local or third-party endpoints from ever
|
|
7
|
+
# receiving the official OpenAI credential by accident.
|
|
8
|
+
class OpenAICompatible < OpenAI
|
|
9
|
+
DEFAULT_MODEL = nil
|
|
10
|
+
|
|
11
|
+
def initialize(endpoint:)
|
|
12
|
+
super(
|
|
13
|
+
api_url: endpoint,
|
|
14
|
+
api_key: ENV.fetch('OPENAI_COMPATIBLE_API_KEY', nil),
|
|
15
|
+
require_api_key: false
|
|
16
|
+
)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module I18nContextGenerator
|
|
4
|
+
module LLM
|
|
5
|
+
# Converts application and supplemental inputs into prompt-safe evidence records.
|
|
6
|
+
module PromptEvidence
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def detect_platform(matches)
|
|
10
|
+
return 'mobile' if matches.empty?
|
|
11
|
+
|
|
12
|
+
platforms = matches.filter_map { |match| FileClassifier.searchable_platform(match.file) }
|
|
13
|
+
|
|
14
|
+
if platforms.include?(:ios)
|
|
15
|
+
'iOS'
|
|
16
|
+
elsif platforms.include?(:android)
|
|
17
|
+
'Android'
|
|
18
|
+
else
|
|
19
|
+
'mobile'
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def prompt_matches(matches, include_file_paths:, redact_prompts:)
|
|
24
|
+
matches.map do |match|
|
|
25
|
+
location = include_file_paths ? match.file : File.basename(match.file)
|
|
26
|
+
{
|
|
27
|
+
location: sanitized_prompt_value(location, redact: redact_prompts),
|
|
28
|
+
line: match.line,
|
|
29
|
+
enclosing_scope: sanitized_prompt_value(match.enclosing_scope, redact: redact_prompts),
|
|
30
|
+
matched_line: sanitized_prompt_value(match.match_line, redact: redact_prompts),
|
|
31
|
+
context: sanitized_prompt_value(match.context, redact: redact_prompts)
|
|
32
|
+
}.compact
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def prompt_context_sources(sources, redact_prompts:)
|
|
37
|
+
sources.map do |source|
|
|
38
|
+
{
|
|
39
|
+
kind: sanitized_prompt_value(source.kind, redact: redact_prompts),
|
|
40
|
+
name: sanitized_prompt_value(source.name, redact: redact_prompts),
|
|
41
|
+
content: sanitized_prompt_value(source.content, redact: redact_prompts)
|
|
42
|
+
}
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module I18nContextGenerator
|
|
4
|
+
module LLM
|
|
5
|
+
# Shared retry and HTTP error handling for remote LLM providers.
|
|
6
|
+
module RequestPolicy
|
|
7
|
+
RequestOutcome = Data.define(:response, :retries) do
|
|
8
|
+
def request_count = retries + 1
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# Adds run-local attempt counters to a transport exception without
|
|
12
|
+
# changing its public exception class.
|
|
13
|
+
module FailureTelemetry
|
|
14
|
+
attr_reader :request_count, :retries
|
|
15
|
+
|
|
16
|
+
def record_request_failure(retries:)
|
|
17
|
+
@retries = retries
|
|
18
|
+
@request_count = retries + 1
|
|
19
|
+
self
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
private_constant :FailureTelemetry
|
|
23
|
+
|
|
24
|
+
MAX_RETRIES = 2
|
|
25
|
+
MAX_RETRY_DELAY = 30.0
|
|
26
|
+
RETRYABLE_STATUS_CODES = [408, 409, 425, 429, 500, 502, 503, 504, 529].freeze
|
|
27
|
+
TRANSIENT_NETWORK_ERRORS = [
|
|
28
|
+
Net::OpenTimeout,
|
|
29
|
+
Net::ReadTimeout,
|
|
30
|
+
Timeout::Error,
|
|
31
|
+
EOFError,
|
|
32
|
+
SocketError,
|
|
33
|
+
Errno::ECONNABORTED,
|
|
34
|
+
Errno::ECONNREFUSED,
|
|
35
|
+
Errno::ECONNRESET,
|
|
36
|
+
Errno::EHOSTUNREACH,
|
|
37
|
+
Errno::ENETUNREACH,
|
|
38
|
+
Errno::ETIMEDOUT,
|
|
39
|
+
OpenSSL::SSL::SSLError
|
|
40
|
+
].freeze
|
|
41
|
+
|
|
42
|
+
protected
|
|
43
|
+
|
|
44
|
+
def request_with_retries(uri:)
|
|
45
|
+
retries = 0
|
|
46
|
+
|
|
47
|
+
loop do
|
|
48
|
+
response = yield
|
|
49
|
+
return RequestOutcome.new(response: response, retries: retries) unless retryable_response?(response) && retries < MAX_RETRIES
|
|
50
|
+
|
|
51
|
+
retries += 1
|
|
52
|
+
delay = retry_delay(response, retries)
|
|
53
|
+
reset_http_session(uri)
|
|
54
|
+
sleep(delay)
|
|
55
|
+
rescue *TRANSIENT_NETWORK_ERRORS => e
|
|
56
|
+
if retries >= MAX_RETRIES
|
|
57
|
+
e.extend(FailureTelemetry).record_request_failure(retries: retries)
|
|
58
|
+
raise
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
retries += 1
|
|
62
|
+
reset_http_session(uri)
|
|
63
|
+
sleep(retry_delay(nil, retries))
|
|
64
|
+
retry
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def retryable_response?(response)
|
|
69
|
+
status = response.code.to_i
|
|
70
|
+
RETRYABLE_STATUS_CODES.include?(status)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def retry_delay(response, retry_number)
|
|
74
|
+
retry_after = parse_retry_after(response&.[]('retry-after'))
|
|
75
|
+
return [retry_after, MAX_RETRY_DELAY].min if retry_after
|
|
76
|
+
|
|
77
|
+
base = 0.5 * (2**(retry_number - 1))
|
|
78
|
+
[base + (rand * 0.25), MAX_RETRY_DELAY].min
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def parse_retry_after(value)
|
|
82
|
+
return nil if value.nil? || value.to_s.strip.empty?
|
|
83
|
+
|
|
84
|
+
numeric = Float(value, exception: false)
|
|
85
|
+
return [numeric, 0].max if numeric&.finite?
|
|
86
|
+
|
|
87
|
+
[Time.httpdate(value) - Time.now, 0].max
|
|
88
|
+
rescue ArgumentError
|
|
89
|
+
nil
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def http_error_result(response, retries: 0)
|
|
93
|
+
telemetry = request_telemetry(retries: retries)
|
|
94
|
+
case response.code.to_i
|
|
95
|
+
when 401
|
|
96
|
+
ContextResult.new(
|
|
97
|
+
description: 'Authentication failed',
|
|
98
|
+
error: 'Provider rejected the API credentials',
|
|
99
|
+
**telemetry
|
|
100
|
+
)
|
|
101
|
+
when 429
|
|
102
|
+
ContextResult.new(
|
|
103
|
+
description: 'Rate limited',
|
|
104
|
+
error: 'Rate limit exceeded - try reducing concurrency',
|
|
105
|
+
**telemetry
|
|
106
|
+
)
|
|
107
|
+
else
|
|
108
|
+
ContextResult.new(description: 'API error', error: provider_error_message(response), **telemetry)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def provider_error_message(response)
|
|
113
|
+
body = JSON.parse(response.body)
|
|
114
|
+
return "HTTP #{response.code}" unless body.is_a?(Hash)
|
|
115
|
+
|
|
116
|
+
message = body.dig('error', 'message') || body['message']
|
|
117
|
+
return "HTTP #{response.code}" unless message.is_a?(String) && !message.empty?
|
|
118
|
+
|
|
119
|
+
message.gsub(/[\u0000-\u001F\u007F]/, ' ')[0, 500]
|
|
120
|
+
rescue JSON::ParserError, TypeError
|
|
121
|
+
"HTTP #{response.code}"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def request_telemetry(retries:)
|
|
125
|
+
{ request_count: retries + 1, retries: retries }
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def failure_request_telemetry(error, outcome:)
|
|
129
|
+
return request_telemetry(retries: outcome.retries) if outcome
|
|
130
|
+
return { request_count: error.request_count, retries: error.retries } if
|
|
131
|
+
error.respond_to?(:request_count) && error.respond_to?(:retries)
|
|
132
|
+
|
|
133
|
+
{}
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def reset_http_session(uri)
|
|
137
|
+
sessions = Thread.current.thread_variable_get(http_sessions_key)
|
|
138
|
+
return unless sessions
|
|
139
|
+
|
|
140
|
+
http = sessions.delete([uri.scheme, uri.host, uri.port])
|
|
141
|
+
http.finish if http&.started?
|
|
142
|
+
rescue IOError, SystemCallError
|
|
143
|
+
nil
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'android_resource'
|
|
4
|
+
require_relative 'apple_string_literal'
|
|
5
|
+
|
|
6
|
+
module I18nContextGenerator
|
|
7
|
+
# Registry of localization call/resource syntaxes shared by source search,
|
|
8
|
+
# source-first discovery, and Swift comment write-back.
|
|
9
|
+
class LocalizationSyntax
|
|
10
|
+
DEFAULT_SWIFT_FUNCTIONS = %w[
|
|
11
|
+
NSLocalizedString
|
|
12
|
+
String(localized:
|
|
13
|
+
Text(
|
|
14
|
+
LocalizedStringResource(
|
|
15
|
+
].freeze
|
|
16
|
+
|
|
17
|
+
IOS_STATIC_SEARCH_BUILDERS = [
|
|
18
|
+
->(key) { "LocalizedStringKey\\s*\\(\\s*\"#{key}\"" },
|
|
19
|
+
->(key) { "LocalizedStringKey\\s*=\\s*\"#{key}\"" },
|
|
20
|
+
->(key) { ":\\s*LocalizedStringResource\\s*=\\s*\"#{key}\"" },
|
|
21
|
+
->(key) { "\"#{key}\"\\.localized" }
|
|
22
|
+
].freeze
|
|
23
|
+
SWIFT_STRING_BODY_PATTERN = AppleStringLiteral::BODY_PATTERN
|
|
24
|
+
private_constant :SWIFT_STRING_BODY_PATTERN
|
|
25
|
+
|
|
26
|
+
IOS_STATIC_SINGLE_LINE_DISCOVERY_PATTERNS = [
|
|
27
|
+
/LocalizedStringKey\s*\(\s*"(?<key>#{SWIFT_STRING_BODY_PATTERN})"\s*\)/,
|
|
28
|
+
/:\s*LocalizedStringKey\s*=\s*"(?<key>#{SWIFT_STRING_BODY_PATTERN})"/,
|
|
29
|
+
/:\s*LocalizedStringResource\s*=\s*"(?<key>#{SWIFT_STRING_BODY_PATTERN})"/,
|
|
30
|
+
/"(?<key>#{SWIFT_STRING_BODY_PATTERN})"\.localized\b/
|
|
31
|
+
].freeze
|
|
32
|
+
|
|
33
|
+
LOCALIZED_RESOURCE_SINGLE_LINE_DISCOVERY_PATTERNS = [
|
|
34
|
+
/LocalizedStringResource\s*\(\s*"(?<key>#{SWIFT_STRING_BODY_PATTERN})"[^\n]*?\bdefaultValue:\s*"(?<text>#{SWIFT_STRING_BODY_PATTERN})"/,
|
|
35
|
+
/LocalizedStringResource\s*\(\s*"(?<key>#{SWIFT_STRING_BODY_PATTERN})"/
|
|
36
|
+
].freeze
|
|
37
|
+
|
|
38
|
+
IOS_STATIC_MULTILINE_DISCOVERY_PATTERNS = [
|
|
39
|
+
/Text\s*\(\s*LocalizedStringKey\s*\(\s*"(?<key>#{SWIFT_STRING_BODY_PATTERN})"\s*\)[\s\S]*?\)/
|
|
40
|
+
].freeze
|
|
41
|
+
|
|
42
|
+
LOCALIZED_RESOURCE_MULTILINE_DISCOVERY_PATTERNS = [
|
|
43
|
+
/LocalizedStringResource\s*\(\s*"(?<key>#{SWIFT_STRING_BODY_PATTERN})"[\s\S]*?\bdefaultValue:\s*"(?<text>#{SWIFT_STRING_BODY_PATTERN})"/,
|
|
44
|
+
/LocalizedStringResource\s*\(\s*"(?<key>#{SWIFT_STRING_BODY_PATTERN})"/
|
|
45
|
+
].freeze
|
|
46
|
+
|
|
47
|
+
OPTIONAL_ARGUMENT_LABEL_PATTERN = '(?:[A-Za-z_]\w*\s*:\s*)?'
|
|
48
|
+
private_constant :OPTIONAL_ARGUMENT_LABEL_PATTERN
|
|
49
|
+
|
|
50
|
+
ANDROID_DISCOVERY_PATTERNS = {
|
|
51
|
+
string: %r{R\.string\.(\w+)\b|@string/([\w.]+)\b|[(\s,=]string\.(\w+)\b}x,
|
|
52
|
+
plural: %r{R\.plurals\.(\w+)\b|@plurals/([\w.]+)\b|[(\s,=]plurals\.(\w+)\b}x,
|
|
53
|
+
array: %r{R\.array\.(\w+)\b|@array/([\w.]+)\b|[(\s,=]array\.(\w+)\b}x
|
|
54
|
+
}.freeze
|
|
55
|
+
|
|
56
|
+
def self.functions_with_defaults(functions)
|
|
57
|
+
configured = Array(functions).map(&:strip).reject(&:empty?)
|
|
58
|
+
(DEFAULT_SWIFT_FUNCTIONS + configured).uniq { |function| function.sub(/\(\s*\z/, '') }.freeze
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def self.swift_string_content_pattern(value)
|
|
62
|
+
encoded = value.each_char.map do |character|
|
|
63
|
+
case character
|
|
64
|
+
when '\\' then '\\\\'
|
|
65
|
+
when '"' then '\\"'
|
|
66
|
+
when "\n" then '\n'
|
|
67
|
+
when "\r" then '\r'
|
|
68
|
+
when "\t" then '\t'
|
|
69
|
+
else character
|
|
70
|
+
end
|
|
71
|
+
end.join
|
|
72
|
+
Regexp.escape(encoded)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def initialize(swift_functions: nil)
|
|
76
|
+
@swift_functions = self.class.functions_with_defaults(swift_functions)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
attr_reader :swift_functions
|
|
80
|
+
|
|
81
|
+
def search_patterns(key, platform:, resource_type: nil)
|
|
82
|
+
strings = case platform.to_sym
|
|
83
|
+
when :ios then ios_search_patterns(key)
|
|
84
|
+
when :android then android_search_patterns(key, resource_type: resource_type)
|
|
85
|
+
else
|
|
86
|
+
ios_search_patterns(key) + android_search_patterns(key, resource_type: resource_type) +
|
|
87
|
+
[Regexp.escape(key)]
|
|
88
|
+
end
|
|
89
|
+
strings.map { |pattern| Regexp.new(pattern) }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def ios_search_patterns(key)
|
|
93
|
+
escaped_key = self.class.swift_string_content_pattern(key)
|
|
94
|
+
function_patterns = swift_function_key_patterns(escaped_key)
|
|
95
|
+
function_patterns + IOS_STATIC_SEARCH_BUILDERS.map { |builder| builder.call(escaped_key) }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def ios_multiline_search_patterns(key)
|
|
99
|
+
escaped_key = self.class.swift_string_content_pattern(key)
|
|
100
|
+
swift_function_key_patterns(escaped_key).map { |pattern| Regexp.new(pattern) }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def android_search_patterns(key, resource_type: nil)
|
|
104
|
+
base_key = AndroidResource.base_key(key)
|
|
105
|
+
escaped_base = Regexp.escape(base_key)
|
|
106
|
+
|
|
107
|
+
case AndroidResource.type_for(key, explicit: resource_type)
|
|
108
|
+
when :plural then android_plural_patterns(escaped_base)
|
|
109
|
+
when :array then android_array_patterns(escaped_base)
|
|
110
|
+
else android_string_patterns(Regexp.escape(key))
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def ios_single_line_discovery_patterns
|
|
115
|
+
@ios_single_line_discovery_patterns ||=
|
|
116
|
+
(LOCALIZED_RESOURCE_SINGLE_LINE_DISCOVERY_PATTERNS +
|
|
117
|
+
function_discovery_patterns(multiline: false) +
|
|
118
|
+
IOS_STATIC_SINGLE_LINE_DISCOVERY_PATTERNS).freeze
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def ios_multiline_discovery_patterns
|
|
122
|
+
@ios_multiline_discovery_patterns ||=
|
|
123
|
+
(LOCALIZED_RESOURCE_MULTILINE_DISCOVERY_PATTERNS +
|
|
124
|
+
function_discovery_patterns(multiline: true) +
|
|
125
|
+
IOS_STATIC_MULTILINE_DISCOVERY_PATTERNS).freeze
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def ios_call_start_patterns
|
|
129
|
+
@ios_call_start_patterns ||=
|
|
130
|
+
@swift_functions.map { |function| Regexp.new(swift_function_opener(function)) }.freeze
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def ios_wrapper_definition_pattern
|
|
134
|
+
@ios_wrapper_definition_pattern ||= begin
|
|
135
|
+
functions = (@swift_functions.map { |function| swift_call_prefix(function) } +
|
|
136
|
+
['LocalizedStringKey\\s*\\(']).join('|')
|
|
137
|
+
constructor = "\\s*=\\s*(?:#{functions})"
|
|
138
|
+
typed_resource = '\s*:\s*LocalizedStringResource\s*=\s*"'
|
|
139
|
+
/\bstatic\s+(?:let|var)\s+(?<member_name>\w+)(?:#{constructor}|#{typed_resource})/
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def android_discovery_patterns
|
|
144
|
+
ANDROID_DISCOVERY_PATTERNS
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def swift_writer_patterns(key)
|
|
148
|
+
escaped_key = self.class.swift_string_content_pattern(key)
|
|
149
|
+
comment = 'comment:\\s*"(?:\\\\.|[^"\\\\])*"'
|
|
150
|
+
|
|
151
|
+
patterns = @swift_functions.map do |function|
|
|
152
|
+
Regexp.new(
|
|
153
|
+
"#{swift_key_argument_prefix(function)}\"#{escaped_key}\"[^)]*#{comment}[^)]*\\)",
|
|
154
|
+
Regexp::MULTILINE
|
|
155
|
+
)
|
|
156
|
+
end
|
|
157
|
+
patterns << nested_text_writer_pattern(escaped_key, comment)
|
|
158
|
+
patterns
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
private
|
|
162
|
+
|
|
163
|
+
def function_discovery_patterns(multiline:)
|
|
164
|
+
tail = multiline ? '[\\s\\S]*?' : '[^\\n]*?'
|
|
165
|
+
@swift_functions.flat_map do |function|
|
|
166
|
+
prefix = swift_key_argument_prefix(function)
|
|
167
|
+
[
|
|
168
|
+
Regexp.new("#{prefix}\\s*@?\"(?<key>#{SWIFT_STRING_BODY_PATTERN})\"#{tail}comment:\\s*\"(?<comment>#{SWIFT_STRING_BODY_PATTERN})\""),
|
|
169
|
+
Regexp.new("#{prefix}\\s*@?\"(?<key>#{SWIFT_STRING_BODY_PATTERN})\"")
|
|
170
|
+
]
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def swift_function_key_patterns(escaped_key)
|
|
175
|
+
@swift_functions.map do |function|
|
|
176
|
+
"#{swift_key_argument_prefix(function)}@?\"#{escaped_key}\""
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def swift_key_argument_prefix(function)
|
|
181
|
+
prefix = swift_call_prefix(function)
|
|
182
|
+
return "#{prefix}\\s*" if explicit_argument_fragment?(function) || DEFAULT_SWIFT_FUNCTIONS.include?(function)
|
|
183
|
+
|
|
184
|
+
"#{prefix}\\s*#{OPTIONAL_ARGUMENT_LABEL_PATTERN}"
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def swift_call_prefix(function)
|
|
188
|
+
name, arguments = function.split('(', 2)
|
|
189
|
+
prefix = "#{Regexp.escape(name.strip)}\\s*\\("
|
|
190
|
+
return prefix unless arguments
|
|
191
|
+
|
|
192
|
+
arguments = arguments.strip
|
|
193
|
+
arguments.empty? ? prefix : "#{prefix}\\s*#{Regexp.escape(arguments)}"
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def swift_function_opener(function)
|
|
197
|
+
name = function.split('(', 2).first
|
|
198
|
+
"#{Regexp.escape(name.strip)}\\s*\\("
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def explicit_argument_fragment?(function)
|
|
202
|
+
_name, arguments = function.split('(', 2)
|
|
203
|
+
arguments && !arguments.strip.empty?
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def nested_text_writer_pattern(escaped_key, comment)
|
|
207
|
+
Regexp.new(
|
|
208
|
+
"Text\\s*\\(\\s*LocalizedStringKey\\s*\\(\\s*\"#{escaped_key}\"\\s*\\)" \
|
|
209
|
+
"[^)]*#{comment}[^)]*\\)",
|
|
210
|
+
Regexp::MULTILINE
|
|
211
|
+
)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def android_plural_patterns(key)
|
|
215
|
+
[
|
|
216
|
+
"R\\.plurals\\.#{key}\\b", "@plurals/#{key}\\b",
|
|
217
|
+
"getQuantityString\\s*\\(\\s*R\\.plurals\\.#{key}",
|
|
218
|
+
"\\.getQuantityString\\s*\\(\\s*R\\.plurals\\.#{key}",
|
|
219
|
+
"pluralStringResource\\s*\\(\\s*R\\.plurals\\.#{key}",
|
|
220
|
+
"[\\(\\s,=]plurals\\.#{key}\\b"
|
|
221
|
+
]
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def android_array_patterns(key)
|
|
225
|
+
[
|
|
226
|
+
"R\\.array\\.#{key}\\b", "@array/#{key}\\b",
|
|
227
|
+
"getStringArray\\s*\\(\\s*R\\.array\\.#{key}",
|
|
228
|
+
"\\.getStringArray\\s*\\(\\s*R\\.array\\.#{key}",
|
|
229
|
+
"resources\\.getStringArray\\s*\\(\\s*R\\.array\\.#{key}",
|
|
230
|
+
"[\\(\\s,=]array\\.#{key}\\b"
|
|
231
|
+
]
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def android_string_patterns(key)
|
|
235
|
+
[
|
|
236
|
+
"R\\.string\\.#{key}\\b", "@string/#{key}\\b",
|
|
237
|
+
"getString\\s*\\(\\s*R\\.string\\.#{key}",
|
|
238
|
+
"\\.getString\\s*\\(\\s*R\\.string\\.#{key}",
|
|
239
|
+
"stringResource\\s*\\(\\s*R\\.string\\.#{key}",
|
|
240
|
+
"[\\(\\s,=]string\\.#{key}\\b",
|
|
241
|
+
"getString\\s*\\(\\s*string\\.#{key}",
|
|
242
|
+
"stringResource\\s*\\(\\s*string\\.#{key}"
|
|
243
|
+
]
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|