i18n-youdao-tasks 0.9.37

Sign up to get free protection for your applications and to get access to all the features.
Files changed (95) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +22 -0
  3. data/README.md +448 -0
  4. data/Rakefile +13 -0
  5. data/bin/i18n-tasks +15 -0
  6. data/bin/i18n-tasks.cmd +2 -0
  7. data/config/locales/en.yml +129 -0
  8. data/config/locales/ru.yml +131 -0
  9. data/i18n-tasks.gemspec +58 -0
  10. data/lib/i18n/tasks/base_task.rb +52 -0
  11. data/lib/i18n/tasks/cli.rb +214 -0
  12. data/lib/i18n/tasks/command/collection.rb +21 -0
  13. data/lib/i18n/tasks/command/commander.rb +38 -0
  14. data/lib/i18n/tasks/command/commands/data.rb +107 -0
  15. data/lib/i18n/tasks/command/commands/eq_base.rb +22 -0
  16. data/lib/i18n/tasks/command/commands/health.rb +30 -0
  17. data/lib/i18n/tasks/command/commands/interpolations.rb +22 -0
  18. data/lib/i18n/tasks/command/commands/meta.rb +37 -0
  19. data/lib/i18n/tasks/command/commands/missing.rb +73 -0
  20. data/lib/i18n/tasks/command/commands/tree.rb +102 -0
  21. data/lib/i18n/tasks/command/commands/usages.rb +81 -0
  22. data/lib/i18n/tasks/command/dsl.rb +56 -0
  23. data/lib/i18n/tasks/command/option_parsers/enum.rb +57 -0
  24. data/lib/i18n/tasks/command/option_parsers/locale.rb +60 -0
  25. data/lib/i18n/tasks/command/options/common.rb +47 -0
  26. data/lib/i18n/tasks/command/options/data.rb +97 -0
  27. data/lib/i18n/tasks/command/options/locales.rb +44 -0
  28. data/lib/i18n/tasks/command_error.rb +15 -0
  29. data/lib/i18n/tasks/commands.rb +29 -0
  30. data/lib/i18n/tasks/concurrent/cache.rb +22 -0
  31. data/lib/i18n/tasks/concurrent/cached_value.rb +61 -0
  32. data/lib/i18n/tasks/configuration.rb +136 -0
  33. data/lib/i18n/tasks/console_context.rb +76 -0
  34. data/lib/i18n/tasks/data/adapter/json_adapter.rb +29 -0
  35. data/lib/i18n/tasks/data/adapter/yaml_adapter.rb +27 -0
  36. data/lib/i18n/tasks/data/file_formats.rb +99 -0
  37. data/lib/i18n/tasks/data/file_system.rb +14 -0
  38. data/lib/i18n/tasks/data/file_system_base.rb +200 -0
  39. data/lib/i18n/tasks/data/router/conservative_router.rb +62 -0
  40. data/lib/i18n/tasks/data/router/pattern_router.rb +62 -0
  41. data/lib/i18n/tasks/data/tree/node.rb +206 -0
  42. data/lib/i18n/tasks/data/tree/nodes.rb +97 -0
  43. data/lib/i18n/tasks/data/tree/siblings.rb +333 -0
  44. data/lib/i18n/tasks/data/tree/traversal.rb +197 -0
  45. data/lib/i18n/tasks/data.rb +87 -0
  46. data/lib/i18n/tasks/html_keys.rb +14 -0
  47. data/lib/i18n/tasks/ignore_keys.rb +31 -0
  48. data/lib/i18n/tasks/interpolations.rb +30 -0
  49. data/lib/i18n/tasks/key_pattern_matching.rb +38 -0
  50. data/lib/i18n/tasks/locale_list.rb +19 -0
  51. data/lib/i18n/tasks/locale_pathname.rb +17 -0
  52. data/lib/i18n/tasks/logging.rb +35 -0
  53. data/lib/i18n/tasks/missing_keys.rb +185 -0
  54. data/lib/i18n/tasks/plural_keys.rb +67 -0
  55. data/lib/i18n/tasks/references.rb +103 -0
  56. data/lib/i18n/tasks/reports/base.rb +75 -0
  57. data/lib/i18n/tasks/reports/terminal.rb +243 -0
  58. data/lib/i18n/tasks/scanners/erb_ast_processor.rb +51 -0
  59. data/lib/i18n/tasks/scanners/erb_ast_scanner.rb +48 -0
  60. data/lib/i18n/tasks/scanners/file_scanner.rb +66 -0
  61. data/lib/i18n/tasks/scanners/files/caching_file_finder.rb +35 -0
  62. data/lib/i18n/tasks/scanners/files/caching_file_finder_provider.rb +31 -0
  63. data/lib/i18n/tasks/scanners/files/caching_file_reader.rb +28 -0
  64. data/lib/i18n/tasks/scanners/files/file_finder.rb +61 -0
  65. data/lib/i18n/tasks/scanners/files/file_reader.rb +19 -0
  66. data/lib/i18n/tasks/scanners/local_ruby_parser.rb +74 -0
  67. data/lib/i18n/tasks/scanners/occurrence_from_position.rb +29 -0
  68. data/lib/i18n/tasks/scanners/pattern_mapper.rb +60 -0
  69. data/lib/i18n/tasks/scanners/pattern_scanner.rb +108 -0
  70. data/lib/i18n/tasks/scanners/pattern_with_scope_scanner.rb +100 -0
  71. data/lib/i18n/tasks/scanners/relative_keys.rb +70 -0
  72. data/lib/i18n/tasks/scanners/results/key_occurrences.rb +54 -0
  73. data/lib/i18n/tasks/scanners/results/occurrence.rb +69 -0
  74. data/lib/i18n/tasks/scanners/ruby_ast_call_finder.rb +63 -0
  75. data/lib/i18n/tasks/scanners/ruby_ast_scanner.rb +234 -0
  76. data/lib/i18n/tasks/scanners/ruby_key_literals.rb +30 -0
  77. data/lib/i18n/tasks/scanners/scanner.rb +17 -0
  78. data/lib/i18n/tasks/scanners/scanner_multiplexer.rb +43 -0
  79. data/lib/i18n/tasks/split_key.rb +72 -0
  80. data/lib/i18n/tasks/stats.rb +24 -0
  81. data/lib/i18n/tasks/string_interpolation.rb +17 -0
  82. data/lib/i18n/tasks/translation.rb +29 -0
  83. data/lib/i18n/tasks/translators/base_translator.rb +156 -0
  84. data/lib/i18n/tasks/translators/deepl_translator.rb +81 -0
  85. data/lib/i18n/tasks/translators/google_translator.rb +69 -0
  86. data/lib/i18n/tasks/translators/yandex_translator.rb +63 -0
  87. data/lib/i18n/tasks/translators/youdao_translator.rb +69 -0
  88. data/lib/i18n/tasks/unused_keys.rb +25 -0
  89. data/lib/i18n/tasks/used_keys.rb +184 -0
  90. data/lib/i18n/tasks/version.rb +7 -0
  91. data/lib/i18n/tasks.rb +69 -0
  92. data/templates/config/i18n-tasks.yml +142 -0
  93. data/templates/minitest/i18n_test.rb +36 -0
  94. data/templates/rspec/i18n_spec.rb +34 -0
  95. 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