i18n-youdao-tasks 0.9.37
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/LICENSE.txt +22 -0
- data/README.md +448 -0
- data/Rakefile +13 -0
- data/bin/i18n-tasks +15 -0
- data/bin/i18n-tasks.cmd +2 -0
- data/config/locales/en.yml +129 -0
- data/config/locales/ru.yml +131 -0
- data/i18n-tasks.gemspec +58 -0
- data/lib/i18n/tasks/base_task.rb +52 -0
- data/lib/i18n/tasks/cli.rb +214 -0
- data/lib/i18n/tasks/command/collection.rb +21 -0
- data/lib/i18n/tasks/command/commander.rb +38 -0
- data/lib/i18n/tasks/command/commands/data.rb +107 -0
- data/lib/i18n/tasks/command/commands/eq_base.rb +22 -0
- data/lib/i18n/tasks/command/commands/health.rb +30 -0
- data/lib/i18n/tasks/command/commands/interpolations.rb +22 -0
- data/lib/i18n/tasks/command/commands/meta.rb +37 -0
- data/lib/i18n/tasks/command/commands/missing.rb +73 -0
- data/lib/i18n/tasks/command/commands/tree.rb +102 -0
- data/lib/i18n/tasks/command/commands/usages.rb +81 -0
- data/lib/i18n/tasks/command/dsl.rb +56 -0
- data/lib/i18n/tasks/command/option_parsers/enum.rb +57 -0
- data/lib/i18n/tasks/command/option_parsers/locale.rb +60 -0
- data/lib/i18n/tasks/command/options/common.rb +47 -0
- data/lib/i18n/tasks/command/options/data.rb +97 -0
- data/lib/i18n/tasks/command/options/locales.rb +44 -0
- data/lib/i18n/tasks/command_error.rb +15 -0
- data/lib/i18n/tasks/commands.rb +29 -0
- data/lib/i18n/tasks/concurrent/cache.rb +22 -0
- data/lib/i18n/tasks/concurrent/cached_value.rb +61 -0
- data/lib/i18n/tasks/configuration.rb +136 -0
- data/lib/i18n/tasks/console_context.rb +76 -0
- data/lib/i18n/tasks/data/adapter/json_adapter.rb +29 -0
- data/lib/i18n/tasks/data/adapter/yaml_adapter.rb +27 -0
- data/lib/i18n/tasks/data/file_formats.rb +99 -0
- data/lib/i18n/tasks/data/file_system.rb +14 -0
- data/lib/i18n/tasks/data/file_system_base.rb +200 -0
- data/lib/i18n/tasks/data/router/conservative_router.rb +62 -0
- data/lib/i18n/tasks/data/router/pattern_router.rb +62 -0
- data/lib/i18n/tasks/data/tree/node.rb +206 -0
- data/lib/i18n/tasks/data/tree/nodes.rb +97 -0
- data/lib/i18n/tasks/data/tree/siblings.rb +333 -0
- data/lib/i18n/tasks/data/tree/traversal.rb +197 -0
- data/lib/i18n/tasks/data.rb +87 -0
- data/lib/i18n/tasks/html_keys.rb +14 -0
- data/lib/i18n/tasks/ignore_keys.rb +31 -0
- data/lib/i18n/tasks/interpolations.rb +30 -0
- data/lib/i18n/tasks/key_pattern_matching.rb +38 -0
- data/lib/i18n/tasks/locale_list.rb +19 -0
- data/lib/i18n/tasks/locale_pathname.rb +17 -0
- data/lib/i18n/tasks/logging.rb +35 -0
- data/lib/i18n/tasks/missing_keys.rb +185 -0
- data/lib/i18n/tasks/plural_keys.rb +67 -0
- data/lib/i18n/tasks/references.rb +103 -0
- data/lib/i18n/tasks/reports/base.rb +75 -0
- data/lib/i18n/tasks/reports/terminal.rb +243 -0
- data/lib/i18n/tasks/scanners/erb_ast_processor.rb +51 -0
- data/lib/i18n/tasks/scanners/erb_ast_scanner.rb +48 -0
- data/lib/i18n/tasks/scanners/file_scanner.rb +66 -0
- data/lib/i18n/tasks/scanners/files/caching_file_finder.rb +35 -0
- data/lib/i18n/tasks/scanners/files/caching_file_finder_provider.rb +31 -0
- data/lib/i18n/tasks/scanners/files/caching_file_reader.rb +28 -0
- data/lib/i18n/tasks/scanners/files/file_finder.rb +61 -0
- data/lib/i18n/tasks/scanners/files/file_reader.rb +19 -0
- data/lib/i18n/tasks/scanners/local_ruby_parser.rb +74 -0
- data/lib/i18n/tasks/scanners/occurrence_from_position.rb +29 -0
- data/lib/i18n/tasks/scanners/pattern_mapper.rb +60 -0
- data/lib/i18n/tasks/scanners/pattern_scanner.rb +108 -0
- data/lib/i18n/tasks/scanners/pattern_with_scope_scanner.rb +100 -0
- data/lib/i18n/tasks/scanners/relative_keys.rb +70 -0
- data/lib/i18n/tasks/scanners/results/key_occurrences.rb +54 -0
- data/lib/i18n/tasks/scanners/results/occurrence.rb +69 -0
- data/lib/i18n/tasks/scanners/ruby_ast_call_finder.rb +63 -0
- data/lib/i18n/tasks/scanners/ruby_ast_scanner.rb +234 -0
- data/lib/i18n/tasks/scanners/ruby_key_literals.rb +30 -0
- data/lib/i18n/tasks/scanners/scanner.rb +17 -0
- data/lib/i18n/tasks/scanners/scanner_multiplexer.rb +43 -0
- data/lib/i18n/tasks/split_key.rb +72 -0
- data/lib/i18n/tasks/stats.rb +24 -0
- data/lib/i18n/tasks/string_interpolation.rb +17 -0
- data/lib/i18n/tasks/translation.rb +29 -0
- data/lib/i18n/tasks/translators/base_translator.rb +156 -0
- data/lib/i18n/tasks/translators/deepl_translator.rb +81 -0
- data/lib/i18n/tasks/translators/google_translator.rb +69 -0
- data/lib/i18n/tasks/translators/yandex_translator.rb +63 -0
- data/lib/i18n/tasks/translators/youdao_translator.rb +69 -0
- data/lib/i18n/tasks/unused_keys.rb +25 -0
- data/lib/i18n/tasks/used_keys.rb +184 -0
- data/lib/i18n/tasks/version.rb +7 -0
- data/lib/i18n/tasks.rb +69 -0
- data/templates/config/i18n-tasks.yml +142 -0
- data/templates/minitest/i18n_test.rb +36 -0
- data/templates/rspec/i18n_spec.rb +34 -0
- metadata +441 -0
@@ -0,0 +1,243 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'i18n/tasks/reports/base'
|
4
|
+
require 'terminal-table'
|
5
|
+
module I18n
|
6
|
+
module Tasks
|
7
|
+
module Reports
|
8
|
+
class Terminal < Base # rubocop:disable Metrics/ClassLength
|
9
|
+
def missing_keys(forest = task.missing_keys)
|
10
|
+
forest = collapse_missing_tree! forest
|
11
|
+
if forest.present?
|
12
|
+
print_title missing_title(forest)
|
13
|
+
print_table headings: [Rainbow(I18n.t('i18n_tasks.common.locale')).cyan.bright,
|
14
|
+
Rainbow(I18n.t('i18n_tasks.common.key')).cyan.bright,
|
15
|
+
I18n.t('i18n_tasks.missing.details_title')] do |t|
|
16
|
+
t.rows = sort_by_attr!(forest_to_attr(forest)).map do |a|
|
17
|
+
[{ value: Rainbow(format_locale(a[:locale])).cyan, alignment: :center },
|
18
|
+
format_key(a[:key], a[:data]),
|
19
|
+
missing_key_info(a)]
|
20
|
+
end
|
21
|
+
end
|
22
|
+
else
|
23
|
+
print_success I18n.t('i18n_tasks.missing.none')
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
def inconsistent_interpolations(forest = task.inconsistent_interpolations)
|
28
|
+
if forest.present?
|
29
|
+
print_title inconsistent_interpolations_title(forest)
|
30
|
+
show_tree(forest)
|
31
|
+
else
|
32
|
+
print_success I18n.t('i18n_tasks.inconsistent_interpolations.none')
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
def used_keys(used_tree = task.used_tree)
|
37
|
+
# For the used tree we may have usage nodes that are not leaves as references.
|
38
|
+
keys_nodes = used_tree.nodes.select { |node| node.data[:occurrences].present? }.map do |node|
|
39
|
+
[node.full_key(root: false), node]
|
40
|
+
end
|
41
|
+
print_title used_title(keys_nodes, used_tree.first.root.data[:key_filter])
|
42
|
+
# Group multiple nodes
|
43
|
+
if keys_nodes.present?
|
44
|
+
keys_nodes.sort! { |a, b| a[0] <=> b[0] }.each do |key, node|
|
45
|
+
print_occurrences node, key
|
46
|
+
end
|
47
|
+
else
|
48
|
+
print_error I18n.t('i18n_tasks.usages.none')
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
def unused_keys(tree = task.unused_keys)
|
53
|
+
keys = tree.root_key_value_data(true)
|
54
|
+
if keys.present?
|
55
|
+
print_title unused_title(keys)
|
56
|
+
print_locale_key_value_data_table keys
|
57
|
+
else
|
58
|
+
print_success I18n.t('i18n_tasks.unused.none')
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
def eq_base_keys(tree = task.eq_base_keys)
|
63
|
+
keys = tree.root_key_value_data(true)
|
64
|
+
if keys.present?
|
65
|
+
print_title eq_base_title(keys)
|
66
|
+
print_locale_key_value_data_table keys
|
67
|
+
else
|
68
|
+
print_info Rainbow('No translations are the same as base value').cyan
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
def show_tree(tree)
|
73
|
+
print_locale_key_value_data_table tree.root_key_value_data(true)
|
74
|
+
end
|
75
|
+
|
76
|
+
def forest_stats(forest, stats = task.forest_stats(forest))
|
77
|
+
text = if stats[:locale_count] == 1
|
78
|
+
I18n.t('i18n_tasks.data_stats.text_single_locale', **stats)
|
79
|
+
else
|
80
|
+
I18n.t('i18n_tasks.data_stats.text', **stats)
|
81
|
+
end
|
82
|
+
title = Rainbow(I18n.t('i18n_tasks.data_stats.title', **stats.slice(:locales))).bright
|
83
|
+
print_info "#{Rainbow(title).cyan} #{Rainbow(text).cyan}"
|
84
|
+
end
|
85
|
+
|
86
|
+
def mv_results(results)
|
87
|
+
results.each do |(from, to)|
|
88
|
+
if to
|
89
|
+
print_info "#{Rainbow(from).cyan} #{Rainbow('⮕').yellow.bright} #{Rainbow(to).cyan}"
|
90
|
+
else
|
91
|
+
print_info "#{Rainbow(from).red}#{Rainbow(' 🗑').red.bright}"
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
def check_normalized_results(non_normalized)
|
97
|
+
if non_normalized.empty?
|
98
|
+
print_success 'All data is normalized'
|
99
|
+
return
|
100
|
+
end
|
101
|
+
log_stderr Rainbow('The following data requires normalization:').yellow
|
102
|
+
puts non_normalized
|
103
|
+
log_stderr Rainbow('Run `i18n-tasks normalize` to fix').yellow
|
104
|
+
end
|
105
|
+
|
106
|
+
private
|
107
|
+
|
108
|
+
def missing_key_info(leaf)
|
109
|
+
case leaf[:type]
|
110
|
+
when :missing_used
|
111
|
+
first_occurrence leaf
|
112
|
+
when :missing_plural
|
113
|
+
leaf[:data][:missing_keys].join(', ')
|
114
|
+
else
|
115
|
+
"#{Rainbow(leaf[:data][:missing_diff_locale]).cyan} "\
|
116
|
+
"#{format_value(leaf[:value].is_a?(String) ? leaf[:value].strip : leaf[:value])}"
|
117
|
+
end
|
118
|
+
end
|
119
|
+
|
120
|
+
def format_key(key, data)
|
121
|
+
if data[:ref_info]
|
122
|
+
from, to = data[:ref_info]
|
123
|
+
resolved = key[0...to.length]
|
124
|
+
after = key[to.length..-1]
|
125
|
+
" #{Rainbow(from).yellow}#{Rainbow(after).cyan}\n" \
|
126
|
+
"#{Rainbow('⮕').yellow.bright} #{Rainbow(resolved).yellow.bright}"
|
127
|
+
else
|
128
|
+
Rainbow(key).cyan
|
129
|
+
end
|
130
|
+
end
|
131
|
+
|
132
|
+
def format_value(val)
|
133
|
+
val.is_a?(Symbol) ? "#{Rainbow('⮕ ').yellow.bright}#{Rainbow(val).yellow}" : val.to_s.strip
|
134
|
+
end
|
135
|
+
|
136
|
+
def format_reference_desc(node_data)
|
137
|
+
return nil unless node_data
|
138
|
+
|
139
|
+
case node_data[:ref_type]
|
140
|
+
when :reference_usage
|
141
|
+
Rainbow('(ref)').yellow.bright
|
142
|
+
when :reference_usage_resolved
|
143
|
+
Rainbow('(resolved ref)').yellow.bright
|
144
|
+
when :reference_usage_key
|
145
|
+
Rainbow('(ref key)').yellow.bright
|
146
|
+
end
|
147
|
+
end
|
148
|
+
|
149
|
+
def print_occurrences(node, full_key = node.full_key)
|
150
|
+
occurrences = node.data[:occurrences]
|
151
|
+
puts [Rainbow(full_key).bright,
|
152
|
+
format_reference_desc(node.data),
|
153
|
+
(Rainbow(occurrences.size).green if occurrences.size > 1)].compact.join ' '
|
154
|
+
occurrences.each do |occurrence|
|
155
|
+
puts " #{key_occurrence full_key, occurrence}"
|
156
|
+
end
|
157
|
+
end
|
158
|
+
|
159
|
+
def print_locale_key_value_data_table(locale_key_value_datas)
|
160
|
+
if locale_key_value_datas.present?
|
161
|
+
print_table headings: [Rainbow(I18n.t('i18n_tasks.common.locale')).cyan.bright,
|
162
|
+
Rainbow(I18n.t('i18n_tasks.common.key')).cyan.bright,
|
163
|
+
I18n.t('i18n_tasks.common.value')] do |t|
|
164
|
+
t.rows = locale_key_value_datas.map do |(locale, k, v, data)|
|
165
|
+
[{ value: Rainbow(locale).cyan, alignment: :center }, format_key(k, data), format_value(v)]
|
166
|
+
end
|
167
|
+
end
|
168
|
+
else
|
169
|
+
puts 'ø'
|
170
|
+
end
|
171
|
+
end
|
172
|
+
|
173
|
+
def print_title(title)
|
174
|
+
log_stderr "#{Rainbow(title.strip).bright} #{Rainbow('|').faint} " \
|
175
|
+
"#{"i18n-tasks v#{I18n::Tasks::VERSION}"}"
|
176
|
+
end
|
177
|
+
|
178
|
+
def print_success(message)
|
179
|
+
log_stderr Rainbow("✓ #{I18n.t('i18n_tasks.cmd.encourage').sample} #{message}").green.bright
|
180
|
+
end
|
181
|
+
|
182
|
+
def print_error(message)
|
183
|
+
log_stderr(Rainbow(message).red.bright)
|
184
|
+
end
|
185
|
+
|
186
|
+
def print_info(message)
|
187
|
+
log_stderr message
|
188
|
+
end
|
189
|
+
|
190
|
+
def indent(txt, n = 2)
|
191
|
+
txt.gsub(/^/, ' ' * n)
|
192
|
+
end
|
193
|
+
|
194
|
+
def print_table(opts, &block)
|
195
|
+
puts ::Terminal::Table.new(opts, &block)
|
196
|
+
end
|
197
|
+
|
198
|
+
def key_occurrence(full_key, occurrence)
|
199
|
+
location = Rainbow("#{occurrence.path}:#{occurrence.line_num}").green
|
200
|
+
source = highlight_key(occurrence.raw_key || full_key, occurrence.line, occurrence.line_pos..-1).strip
|
201
|
+
"#{location} #{source}"
|
202
|
+
end
|
203
|
+
|
204
|
+
def first_occurrence(leaf)
|
205
|
+
# @type [I18n::Tasks::Scanners::KeyOccurrences]
|
206
|
+
occurrences = leaf[:data][:occurrences]
|
207
|
+
# @type [I18n::Tasks::Scanners::Occurrence]
|
208
|
+
first = occurrences.first
|
209
|
+
[
|
210
|
+
Rainbow("#{first.path}:#{first.line_num}").green,
|
211
|
+
("(#{I18n.t 'i18n_tasks.common.n_more', count: occurrences.length - 1})" if occurrences.length > 1)
|
212
|
+
].compact.join(' ')
|
213
|
+
end
|
214
|
+
|
215
|
+
def highlight_key(full_key, line, range = (0..-1))
|
216
|
+
line.dup.tap do |s|
|
217
|
+
s[range] = s[range].sub(full_key) do |m|
|
218
|
+
highlight_string m
|
219
|
+
end
|
220
|
+
end
|
221
|
+
end
|
222
|
+
|
223
|
+
module HighlightUnderline
|
224
|
+
def highlight_string(s)
|
225
|
+
Rainbow(s).underline
|
226
|
+
end
|
227
|
+
end
|
228
|
+
|
229
|
+
module HighlightOther
|
230
|
+
def highlight_string(s)
|
231
|
+
Rainbow(s).yellow
|
232
|
+
end
|
233
|
+
end
|
234
|
+
|
235
|
+
if Gem.win_platform?
|
236
|
+
include HighlightOther
|
237
|
+
else
|
238
|
+
include HighlightUnderline
|
239
|
+
end
|
240
|
+
end
|
241
|
+
end
|
242
|
+
end
|
243
|
+
end
|
@@ -0,0 +1,51 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'ast'
|
4
|
+
require 'set'
|
5
|
+
require 'i18n/tasks/scanners/local_ruby_parser'
|
6
|
+
|
7
|
+
module I18n::Tasks::Scanners
|
8
|
+
class ErbAstProcessor
|
9
|
+
include AST::Processor::Mixin
|
10
|
+
def initialize
|
11
|
+
super()
|
12
|
+
@ruby_parser = LocalRubyParser.new
|
13
|
+
@comments = []
|
14
|
+
end
|
15
|
+
|
16
|
+
def process_and_extract_comments(ast)
|
17
|
+
result = process(ast)
|
18
|
+
[result, @comments]
|
19
|
+
end
|
20
|
+
|
21
|
+
def on_code(node)
|
22
|
+
parsed, comments = @ruby_parser.parse(
|
23
|
+
node.children[0],
|
24
|
+
location: node.location
|
25
|
+
)
|
26
|
+
@comments.concat(comments)
|
27
|
+
|
28
|
+
unless parsed.nil?
|
29
|
+
parsed = parsed.updated(
|
30
|
+
nil,
|
31
|
+
parsed.children.map { |child| node?(child) ? process(child) : child }
|
32
|
+
)
|
33
|
+
node = node.updated(:send, parsed)
|
34
|
+
end
|
35
|
+
node
|
36
|
+
end
|
37
|
+
|
38
|
+
def handler_missing(node)
|
39
|
+
node.updated(
|
40
|
+
nil,
|
41
|
+
node.children.map { |child| node?(child) ? process(child) : child }
|
42
|
+
)
|
43
|
+
end
|
44
|
+
|
45
|
+
private
|
46
|
+
|
47
|
+
def node?(node)
|
48
|
+
node.is_a?(::Parser::AST::Node)
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
@@ -0,0 +1,48 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'i18n/tasks/scanners/ruby_ast_scanner'
|
4
|
+
require 'i18n/tasks/scanners/erb_ast_processor'
|
5
|
+
require 'better_html'
|
6
|
+
require 'better_html/parser'
|
7
|
+
|
8
|
+
module I18n::Tasks::Scanners
|
9
|
+
# Scan for I18n.translate calls in ERB-file better-html and ASTs
|
10
|
+
class ErbAstScanner < RubyAstScanner
|
11
|
+
def initialize(**args)
|
12
|
+
super(**args)
|
13
|
+
@erb_ast_processor = ErbAstProcessor.new
|
14
|
+
end
|
15
|
+
|
16
|
+
private
|
17
|
+
|
18
|
+
# Parse file on path and returns AST and comments.
|
19
|
+
#
|
20
|
+
# @param path Path to file to parse
|
21
|
+
# @return [{Parser::AST::Node}, [Parser::Source::Comment]]
|
22
|
+
def path_to_ast_and_comments(path)
|
23
|
+
parser = BetterHtml::Parser.new(make_buffer(path))
|
24
|
+
ast = convert_better_html(parser.ast)
|
25
|
+
@erb_ast_processor.process_and_extract_comments(ast)
|
26
|
+
end
|
27
|
+
|
28
|
+
# Convert BetterHtml nodes to Parser::AST::Node
|
29
|
+
#
|
30
|
+
# @param node BetterHtml::Parser::AST::Node
|
31
|
+
# @return Parser::AST::Node
|
32
|
+
def convert_better_html(node)
|
33
|
+
definition = Parser::Source::Map::Definition.new(
|
34
|
+
node.location.begin,
|
35
|
+
node.location.begin,
|
36
|
+
node.location.begin,
|
37
|
+
node.location.end
|
38
|
+
)
|
39
|
+
Parser::AST::Node.new(
|
40
|
+
node.type,
|
41
|
+
node.children.map { |child| child.is_a?(BetterHtml::AST::Node) ? convert_better_html(child) : child },
|
42
|
+
{
|
43
|
+
location: definition
|
44
|
+
}
|
45
|
+
)
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
@@ -0,0 +1,66 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'i18n/tasks/scanners/scanner'
|
4
|
+
|
5
|
+
module I18n::Tasks::Scanners
|
6
|
+
# A base class for a scanner that analyses files.
|
7
|
+
#
|
8
|
+
# @abstract The child must implement {#scan_file}.
|
9
|
+
# @since 0.9.0
|
10
|
+
class FileScanner < Scanner
|
11
|
+
attr_reader :config
|
12
|
+
|
13
|
+
def initialize(
|
14
|
+
config: {},
|
15
|
+
file_finder_provider: Files::CachingFileFinderProvider.new,
|
16
|
+
file_reader: Files::CachingFileReader.new
|
17
|
+
)
|
18
|
+
super()
|
19
|
+
@config = config
|
20
|
+
@file_reader = file_reader
|
21
|
+
@file_finder = file_finder_provider.get(**config.slice(:paths, :only, :exclude))
|
22
|
+
end
|
23
|
+
|
24
|
+
# @return (see Scanner#keys)
|
25
|
+
def keys
|
26
|
+
(traverse_files do |path|
|
27
|
+
scan_file(path)
|
28
|
+
end.reduce(:+) || []).group_by(&:first).map do |key, keys_occurrences|
|
29
|
+
Results::KeyOccurrences.new(key: key, occurrences: keys_occurrences.map(&:second))
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
protected
|
34
|
+
|
35
|
+
# Extract all occurrences of translate calls from the file at the given path.
|
36
|
+
#
|
37
|
+
# @return [Array<[key, Results::KeyOccurrence]>] each occurrence found in the file
|
38
|
+
def scan_file(_path)
|
39
|
+
fail 'Unimplemented'
|
40
|
+
end
|
41
|
+
|
42
|
+
# Read a file. Reads of the same path are cached.
|
43
|
+
#
|
44
|
+
# @param path [String]
|
45
|
+
# @return [String] file contents
|
46
|
+
def read_file(path)
|
47
|
+
@file_reader.read_file(path)
|
48
|
+
end
|
49
|
+
|
50
|
+
# Traverse the paths and yield the matching ones.
|
51
|
+
#
|
52
|
+
# @note This method is cached, it will only access the filesystem on the first invocation.
|
53
|
+
# @param (see FileFinder#traverse_files)
|
54
|
+
# @yieldparam (see FileFinder#traverse_files)
|
55
|
+
# @return (see FileFinder#traverse_files)
|
56
|
+
def traverse_files(&block)
|
57
|
+
@file_finder.traverse_files(&block)
|
58
|
+
end
|
59
|
+
|
60
|
+
# @note This method is cached, it will only access the filesystem on the first invocation.
|
61
|
+
# @return (see FileFinder#find_files)
|
62
|
+
def find_files
|
63
|
+
@file_finder.find_files
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'i18n/tasks/concurrent/cached_value'
|
4
|
+
require 'i18n/tasks/scanners/files/file_finder'
|
5
|
+
|
6
|
+
module I18n::Tasks::Scanners::Files
|
7
|
+
# Finds the files in the specified search paths with support for exclusion / inclusion patterns.
|
8
|
+
# Wraps a {FileFinder} and caches the results.
|
9
|
+
#
|
10
|
+
# @note This class is thread-safe. All methods are cached.
|
11
|
+
# @since 0.9.0
|
12
|
+
class CachingFileFinder < FileFinder
|
13
|
+
# @param (see FileFinder#initialize)
|
14
|
+
def initialize(**args)
|
15
|
+
super
|
16
|
+
@cached_value = ::I18n::Tasks::Concurrent::CachedValue.new { uncached_find_files }
|
17
|
+
end
|
18
|
+
|
19
|
+
# Traverse the paths and yield the matching ones.
|
20
|
+
#
|
21
|
+
# @note This method is cached, it will only access the filesystem on the first invocation.
|
22
|
+
# @param (see FileFinder#traverse_files)
|
23
|
+
# @yieldparam (see FileFinder#traverse_files)
|
24
|
+
# @return (see FileFinder#traverse_files)
|
25
|
+
|
26
|
+
alias uncached_find_files find_files
|
27
|
+
private :uncached_find_files
|
28
|
+
|
29
|
+
# @note This method is cached, it will only access the filesystem on the first invocation.
|
30
|
+
# @return (see FileFinder#find_files)
|
31
|
+
def find_files
|
32
|
+
@cached_value.get
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'i18n/tasks/concurrent/cache'
|
4
|
+
require 'i18n/tasks/scanners/files/caching_file_finder'
|
5
|
+
|
6
|
+
module I18n::Tasks::Scanners::Files
|
7
|
+
# Finds the files and provides their contents.
|
8
|
+
#
|
9
|
+
# @note This class is thread-safe. All methods are cached.
|
10
|
+
# @since 0.9.0
|
11
|
+
class CachingFileFinderProvider
|
12
|
+
# @param exclude [Array<String>]
|
13
|
+
def initialize(exclude: [])
|
14
|
+
@cache = ::I18n::Tasks::Concurrent::Cache.new
|
15
|
+
@defaults = { exclude: exclude }
|
16
|
+
end
|
17
|
+
|
18
|
+
# Initialize a {CachingFileFinder} or get one from cache based on the constructor arguments.
|
19
|
+
#
|
20
|
+
# @param (see FileFinder#initialize)
|
21
|
+
# @return [CachingFileFinder]
|
22
|
+
def get(**file_finder_args)
|
23
|
+
@cache.fetch(file_finder_args) do
|
24
|
+
args = file_finder_args.dup
|
25
|
+
args[:exclude] = @defaults[:exclude] + (args[:exclude] || [])
|
26
|
+
args[:exclude].uniq!
|
27
|
+
CachingFileFinder.new(**args)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'i18n/tasks/concurrent/cache'
|
4
|
+
require 'i18n/tasks/scanners/files/file_reader'
|
5
|
+
|
6
|
+
module I18n::Tasks::Scanners::Files
|
7
|
+
# Reads the files in 'rb' mode and UTF-8 encoding.
|
8
|
+
# Wraps a {FileReader} and caches the results.
|
9
|
+
#
|
10
|
+
# @note This class is thread-safe. All methods are cached.
|
11
|
+
# @since 0.9.0
|
12
|
+
class CachingFileReader < FileReader
|
13
|
+
def initialize
|
14
|
+
super
|
15
|
+
@cache = ::I18n::Tasks::Concurrent::Cache.new
|
16
|
+
end
|
17
|
+
|
18
|
+
# Return the contents of the file at the given path.
|
19
|
+
# The file is read in the 'rb' mode and UTF-8 encoding.
|
20
|
+
#
|
21
|
+
# @param (see FileReader#read_file)
|
22
|
+
# @return (see FileReader#read_file)
|
23
|
+
# @note This method is cached, it will only access the filesystem on the first invocation.
|
24
|
+
def read_file(path)
|
25
|
+
@cache.fetch(File.expand_path(path)) { super }
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,61 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module I18n::Tasks::Scanners::Files
|
4
|
+
# Finds the files in the specified search paths with support for exclusion / inclusion patterns.
|
5
|
+
#
|
6
|
+
# @since 0.9.0
|
7
|
+
class FileFinder
|
8
|
+
include I18n::Tasks::Logging
|
9
|
+
|
10
|
+
# @param paths [Array<String>] {Find.find}-compatible paths to traverse,
|
11
|
+
# absolute or relative to the working directory.
|
12
|
+
# @param only [Array<String>, nil] {File.fnmatch}-compatible patterns files to include.
|
13
|
+
# Files not matching any of the inclusion patterns will be excluded.
|
14
|
+
# @param exclude [Arry<String>] {File.fnmatch}-compatible patterns of files to exclude.
|
15
|
+
# Files matching any of the exclusion patterns will be excluded even if they match an inclusion pattern.
|
16
|
+
def initialize(paths: ['.'], only: nil, exclude: [])
|
17
|
+
fail 'paths argument is required' if paths.nil?
|
18
|
+
|
19
|
+
@paths = paths
|
20
|
+
@include = only
|
21
|
+
@exclude = exclude || []
|
22
|
+
end
|
23
|
+
|
24
|
+
# Traverse the paths and yield the matching ones.
|
25
|
+
#
|
26
|
+
# @yield [path]
|
27
|
+
# @yieldparam path [String] the path of the found file.
|
28
|
+
# @return [Array<of block results>]
|
29
|
+
def traverse_files(&block)
|
30
|
+
find_files.map(&block)
|
31
|
+
end
|
32
|
+
|
33
|
+
# @return [Array<String>] found files
|
34
|
+
def find_files # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
|
35
|
+
results = []
|
36
|
+
paths = @paths.select { |p| File.exist?(p) }
|
37
|
+
log_warn "None of the search.paths exist #{@paths.inspect}" if paths.empty?
|
38
|
+
Find.find(*paths) do |path|
|
39
|
+
is_dir = File.directory?(path)
|
40
|
+
hidden = File.basename(path).start_with?('.') && !%w[. ./].include?(path)
|
41
|
+
not_incl = @include && !path_fnmatch_any?(path, @include)
|
42
|
+
excl = path_fnmatch_any?(path, @exclude)
|
43
|
+
if is_dir || hidden || not_incl || excl
|
44
|
+
Find.prune if is_dir && (hidden || excl)
|
45
|
+
else
|
46
|
+
results << path
|
47
|
+
end
|
48
|
+
end
|
49
|
+
results
|
50
|
+
end
|
51
|
+
|
52
|
+
private
|
53
|
+
|
54
|
+
# @param path [String]
|
55
|
+
# @param globs [Array<String>]
|
56
|
+
# @return [Boolean]
|
57
|
+
def path_fnmatch_any?(path, globs)
|
58
|
+
globs.any? { |glob| File.fnmatch(glob, path) }
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module I18n::Tasks::Scanners::Files
|
4
|
+
# Reads the files in 'rb' mode and UTF-8 encoding.
|
5
|
+
#
|
6
|
+
# @since 0.9.0
|
7
|
+
class FileReader
|
8
|
+
# Return the contents of the file at the given path.
|
9
|
+
# The file is read in the 'rb' mode and UTF-8 encoding.
|
10
|
+
#
|
11
|
+
# @param path [String] Path to the file, absolute or relative to the working directory.
|
12
|
+
# @return [String] file contents
|
13
|
+
def read_file(path)
|
14
|
+
result = nil
|
15
|
+
File.open(path, 'rb', encoding: 'UTF-8') { |f| result = f.read }
|
16
|
+
result
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,74 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'parser/current'
|
4
|
+
|
5
|
+
module I18n::Tasks::Scanners
|
6
|
+
class LocalRubyParser
|
7
|
+
def initialize
|
8
|
+
@parser = ::Parser::CurrentRuby.new
|
9
|
+
end
|
10
|
+
|
11
|
+
# Parse string and normalize location
|
12
|
+
def parse(source, location: nil)
|
13
|
+
buffer = ::Parser::Source::Buffer.new('(string)')
|
14
|
+
buffer.source = source
|
15
|
+
|
16
|
+
@parser.reset
|
17
|
+
ast, comments = @parser.parse_with_comments(buffer)
|
18
|
+
ast = normalize_location(ast, location)
|
19
|
+
comments = comments.map { |comment| normalize_comment_location(comment, location) }
|
20
|
+
[ast, comments]
|
21
|
+
end
|
22
|
+
|
23
|
+
# Normalize location for all parsed nodes
|
24
|
+
|
25
|
+
# @param node {Parser::AST::Node} Node in parsed code
|
26
|
+
# @param location {Parser::Source::Map} Global location for the parsed string
|
27
|
+
# @return {Parser::AST::Node}
|
28
|
+
def normalize_location(node, location)
|
29
|
+
return node.map { |child| normalize_location(child, location) } if node.is_a?(Array)
|
30
|
+
|
31
|
+
return node unless node.is_a?(::Parser::AST::Node)
|
32
|
+
|
33
|
+
node.updated(
|
34
|
+
nil,
|
35
|
+
node.children.map { |child| normalize_location(child, location) },
|
36
|
+
{ location: updated_location(location, node.location) }
|
37
|
+
)
|
38
|
+
end
|
39
|
+
|
40
|
+
# Calculate location relative to a global location
|
41
|
+
#
|
42
|
+
# @param global_location {Parser::Source::Map} Global location where the code was parsed
|
43
|
+
# @param local_location {Parser::Source::Map} Local location in the parsed string
|
44
|
+
# @return {Parser::Source::Map}
|
45
|
+
def updated_location(global_location, local_location)
|
46
|
+
range = ::Parser::Source::Range.new(
|
47
|
+
global_location.expression.source_buffer,
|
48
|
+
global_location.expression.to_range.begin + local_location.expression.to_range.begin,
|
49
|
+
global_location.expression.to_range.begin + local_location.expression.to_range.end
|
50
|
+
)
|
51
|
+
|
52
|
+
::Parser::Source::Map::Definition.new(
|
53
|
+
range.begin,
|
54
|
+
range.begin,
|
55
|
+
range.begin,
|
56
|
+
range.end
|
57
|
+
)
|
58
|
+
end
|
59
|
+
|
60
|
+
# Normalize location for comment
|
61
|
+
#
|
62
|
+
# @param comment {Parser::Source::Comment} A comment with local location
|
63
|
+
# @param location {Parser::Source::Map} Global location for the parsed string
|
64
|
+
# @return {Parser::Source::Comment}
|
65
|
+
def normalize_comment_location(comment, location)
|
66
|
+
range = ::Parser::Source::Range.new(
|
67
|
+
location.expression.source_buffer,
|
68
|
+
location.expression.to_range.begin + comment.location.expression.to_range.begin,
|
69
|
+
location.expression.to_range.begin + comment.location.expression.to_range.end
|
70
|
+
)
|
71
|
+
::Parser::Source::Comment.new(range)
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module I18n
|
4
|
+
module Tasks
|
5
|
+
module Scanners
|
6
|
+
module OccurrenceFromPosition
|
7
|
+
# Given a path to a file, its contents and a position in the file,
|
8
|
+
# return a {Results::Occurrence} at the position until the end of the line.
|
9
|
+
#
|
10
|
+
# @param path [String]
|
11
|
+
# @param contents [String] contents of the file at the path.
|
12
|
+
# @param position [Integer] position just before the beginning of the match.
|
13
|
+
# @return [Results::Occurrence]
|
14
|
+
def occurrence_from_position(path, contents, position, raw_key: nil)
|
15
|
+
line_begin = contents.rindex(/^/, position - 1)
|
16
|
+
line_end = contents.index(/.(?=\r?\n|$)/, position)
|
17
|
+
Results::Occurrence.new(
|
18
|
+
path: path,
|
19
|
+
pos: position,
|
20
|
+
line_num: contents[0..position].count("\n") + 1,
|
21
|
+
line_pos: position - line_begin + 1,
|
22
|
+
line: contents[line_begin..line_end],
|
23
|
+
raw_key: raw_key
|
24
|
+
)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|