i18n-processes 0.1.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.
Files changed (132) hide show
  1. checksums.yaml +7 -0
  2. data/Gemfile.lock +102 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +46 -0
  5. data/Rakefile +12 -0
  6. data/bin/i18n-processes +28 -0
  7. data/bin/i18n-processes.cmd +2 -0
  8. data/config/locales/en.yml +2 -0
  9. data/config/locales/zh-CN.yml +2 -0
  10. data/i18n-processes.gemspec +64 -0
  11. data/lib/i18n/processes/base_process.rb +47 -0
  12. data/lib/i18n/processes/cli.rb +208 -0
  13. data/lib/i18n/processes/command/collection.rb +21 -0
  14. data/lib/i18n/processes/command/commander.rb +43 -0
  15. data/lib/i18n/processes/command/commands/data.rb +107 -0
  16. data/lib/i18n/processes/command/commands/eq_base.rb +21 -0
  17. data/lib/i18n/processes/command/commands/health.rb +26 -0
  18. data/lib/i18n/processes/command/commands/meta.rb +38 -0
  19. data/lib/i18n/processes/command/commands/missing.rb +86 -0
  20. data/lib/i18n/processes/command/commands/preprocessing.rb +90 -0
  21. data/lib/i18n/processes/command/commands/tree.rb +119 -0
  22. data/lib/i18n/processes/command/commands/usages.rb +69 -0
  23. data/lib/i18n/processes/command/commands/xlsx.rb +29 -0
  24. data/lib/i18n/processes/command/dsl.rb +56 -0
  25. data/lib/i18n/processes/command/option_parsers/enum.rb +55 -0
  26. data/lib/i18n/processes/command/option_parsers/locale.rb +60 -0
  27. data/lib/i18n/processes/command/options/common.rb +41 -0
  28. data/lib/i18n/processes/command/options/data.rb +95 -0
  29. data/lib/i18n/processes/command/options/locales.rb +36 -0
  30. data/lib/i18n/processes/command_error.rb +13 -0
  31. data/lib/i18n/processes/commands.rb +31 -0
  32. data/lib/i18n/processes/configuration.rb +132 -0
  33. data/lib/i18n/processes/console_context.rb +76 -0
  34. data/lib/i18n/processes/data/adapter/json_adapter.rb +29 -0
  35. data/lib/i18n/processes/data/adapter/yaml_adapter.rb +27 -0
  36. data/lib/i18n/processes/data/file_formats.rb +111 -0
  37. data/lib/i18n/processes/data/file_system.rb +14 -0
  38. data/lib/i18n/processes/data/file_system_base.rb +205 -0
  39. data/lib/i18n/processes/data/router/conservative_router.rb +66 -0
  40. data/lib/i18n/processes/data/router/pattern_router.rb +60 -0
  41. data/lib/i18n/processes/data/tree/node.rb +204 -0
  42. data/lib/i18n/processes/data/tree/nodes.rb +97 -0
  43. data/lib/i18n/processes/data/tree/siblings.rb +333 -0
  44. data/lib/i18n/processes/data/tree/traversal.rb +190 -0
  45. data/lib/i18n/processes/data.rb +87 -0
  46. data/lib/i18n/processes/google_translation.rb +125 -0
  47. data/lib/i18n/processes/html_keys.rb +16 -0
  48. data/lib/i18n/processes/ignore_keys.rb +30 -0
  49. data/lib/i18n/processes/key_pattern_matching.rb +37 -0
  50. data/lib/i18n/processes/locale_list.rb +19 -0
  51. data/lib/i18n/processes/locale_pathname.rb +17 -0
  52. data/lib/i18n/processes/logging.rb +37 -0
  53. data/lib/i18n/processes/missing_keys.rb +122 -0
  54. data/lib/i18n/processes/path.rb +42 -0
  55. data/lib/i18n/processes/plural_keys.rb +41 -0
  56. data/lib/i18n/processes/rainbow_utils.rb +13 -0
  57. data/lib/i18n/processes/references.rb +101 -0
  58. data/lib/i18n/processes/reports/base.rb +71 -0
  59. data/lib/i18n/processes/reports/spreadsheet.rb +72 -0
  60. data/lib/i18n/processes/reports/terminal.rb +252 -0
  61. data/lib/i18n/processes/scanners/file_scanner.rb +65 -0
  62. data/lib/i18n/processes/scanners/files/caching_file_finder.rb +34 -0
  63. data/lib/i18n/processes/scanners/files/caching_file_finder_provider.rb +33 -0
  64. data/lib/i18n/processes/scanners/files/caching_file_reader.rb +28 -0
  65. data/lib/i18n/processes/scanners/files/file_finder.rb +60 -0
  66. data/lib/i18n/processes/scanners/files/file_reader.rb +19 -0
  67. data/lib/i18n/processes/scanners/occurrence_from_position.rb +27 -0
  68. data/lib/i18n/processes/scanners/pattern_mapper.rb +60 -0
  69. data/lib/i18n/processes/scanners/pattern_scanner.rb +103 -0
  70. data/lib/i18n/processes/scanners/pattern_with_scope_scanner.rb +98 -0
  71. data/lib/i18n/processes/scanners/relative_keys.rb +53 -0
  72. data/lib/i18n/processes/scanners/results/key_occurrences.rb +54 -0
  73. data/lib/i18n/processes/scanners/results/occurrence.rb +69 -0
  74. data/lib/i18n/processes/scanners/ruby_ast_call_finder.rb +62 -0
  75. data/lib/i18n/processes/scanners/ruby_ast_scanner.rb +206 -0
  76. data/lib/i18n/processes/scanners/ruby_key_literals.rb +30 -0
  77. data/lib/i18n/processes/scanners/scanner.rb +17 -0
  78. data/lib/i18n/processes/scanners/scanner_multiplexer.rb +41 -0
  79. data/lib/i18n/processes/split_key.rb +68 -0
  80. data/lib/i18n/processes/stats.rb +24 -0
  81. data/lib/i18n/processes/string_interpolation.rb +16 -0
  82. data/lib/i18n/processes/unused_keys.rb +23 -0
  83. data/lib/i18n/processes/used_keys.rb +177 -0
  84. data/lib/i18n/processes/version.rb +7 -0
  85. data/lib/i18n/processes.rb +69 -0
  86. data/source/p1/_messages/zh/article.properties +9 -0
  87. data/source/p1/_messages/zh/company.properties +62 -0
  88. data/source/p1/_messages/zh/devices.properties +40 -0
  89. data/source/p1/_messages/zh/meeting-rooms.properties +99 -0
  90. data/source/p1/_messages/zh/meetingBooking.properties +18 -0
  91. data/source/p1/_messages/zh/office-areas.properties +64 -0
  92. data/source/p1/_messages/zh/orders.properties +25 -0
  93. data/source/p1/_messages/zh/schedulings.properties +7 -0
  94. data/source/p1/_messages/zh/tag.properties +2 -0
  95. data/source/p1/_messages/zh/ticket.properties +9 -0
  96. data/source/p1/_messages/zh/visitor.properties +5 -0
  97. data/source/p1/messages +586 -0
  98. data/source/p2/orders.properties +25 -0
  99. data/source/p2/schedulings.properties +7 -0
  100. data/source/p2/tag.properties +2 -0
  101. data/source/p2/ticket.properties +9 -0
  102. data/source/p2/visitor.properties +5 -0
  103. data/source/zh.messages.ts +30 -0
  104. data/translated/en/p1/_messages/zh/article.properties +9 -0
  105. data/translated/en/p1/_messages/zh/company.properties +62 -0
  106. data/translated/en/p1/_messages/zh/devices.properties +40 -0
  107. data/translated/en/p1/_messages/zh/meeting-rooms.properties +99 -0
  108. data/translated/en/p1/_messages/zh/meetingBooking.properties +18 -0
  109. data/translated/en/p1/_messages/zh/office-areas.properties +64 -0
  110. data/translated/en/p1/_messages/zh/orders.properties +25 -0
  111. data/translated/en/p1/_messages/zh/schedulings.properties +7 -0
  112. data/translated/en/p1/_messages/zh/tag.properties +2 -0
  113. data/translated/en/p1/_messages/zh/ticket.properties +9 -0
  114. data/translated/en/p1/_messages/zh/visitor.properties +5 -0
  115. data/translated/en/p1/messages +586 -0
  116. data/translated/en/p2/orders.properties +25 -0
  117. data/translated/en/p2/schedulings.properties +7 -0
  118. data/translated/en/p2/tag.properties +2 -0
  119. data/translated/en/p2/ticket.properties +9 -0
  120. data/translated/en/p2/visitor.properties +5 -0
  121. data/translated/en/zh.messages.ts +30 -0
  122. data/translation/en/article.properties +9 -0
  123. data/translation/en/company.properties +56 -0
  124. data/translation/en/meeting-rooms.properties +87 -0
  125. data/translation/en/meetingBooking.properties +14 -0
  126. data/translation/en/messages.en +164 -0
  127. data/translation/en/office-areas.properties +51 -0
  128. data/translation/en/orders.properties +26 -0
  129. data/translation/en/tag.properties +2 -0
  130. data/translation/en/translated +1263 -0
  131. data/translation/en/visitor.properties +4 -0
  132. metadata +408 -0
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'i18n/processes/reports/base'
4
+ require 'fileutils'
5
+ require 'i18n/processes/path'
6
+
7
+ module I18n::Processes::Reports
8
+ class Spreadsheet < Base
9
+ include I18n::Processes::Path
10
+
11
+ def missing_report(locale)
12
+ path = 'tmp/missing_keys/'
13
+ FileUtils::mkdir_p path unless Dir.exist?path
14
+ file = "#{path}missing_keys_#{locale}"
15
+ report = File.new(file, 'w')
16
+ report.write("# 说明:以#开头的行,表示key对应的中文翻译\n# 下一行'='左边为key,'='右边需要填上对应的#{locale}翻译: \n")
17
+ report.write("\n\n# ======================= missing keys list =============================\n\n")
18
+ find_missing(locale).map do |k,v|
19
+ report.write("# #{v}")
20
+ report.write("#{k}=#{k}\n\n")
21
+ end
22
+ report.close
23
+ $stderr.puts Rainbow("missing report saved to #{file}\n").green
24
+ end
25
+
26
+ def translated_files(locale)
27
+ path = translated_path.first
28
+ dic = get_dic("./tmp/#{locale}")
29
+ FileUtils.rm_f Dir.glob("./#{path}**/**") unless Dir["./#{path}**/**"].size.zero?
30
+ origin_files = origin_files(base_locale).flatten
31
+ # $stderr.puts Rainbow origin_files
32
+ origin_files.each do |origin_file|
33
+ translated_file(origin_file,"#{path}#{locale}/", dic)
34
+ end
35
+ $stderr.puts Rainbow("translated files saved to #{path}\n").green
36
+ end
37
+
38
+ def find_missing(locale = nil)
39
+ path = './tmp/'
40
+ comp_dic = get_dic(path + locale)
41
+ base_dic = get_dic(path + base_locale)
42
+ base_dic.select { |k,v| (base_dic.keys - comp_dic.keys).include?(k)}
43
+ end
44
+
45
+ private
46
+
47
+ def translated_file(file, path, dic)
48
+ translated_file = new_file(file, path)
49
+ File.open(file).read.each_line do |line|
50
+ if line =~ /^#/ || line == "\n" || !line.include?('.')
51
+ translated_file.write line
52
+ elsif line.include?('\':')
53
+ line.gsub!(/'|,/, '')
54
+ key = line.split(': ').first.delete(' ')
55
+ translated_file.write(" '#{key}': '#{dic[key].chomp}',\n") if dic.key?(key)
56
+ else
57
+ key = line.split('=').first.delete(' ')
58
+ translated_file.write("#{key}=#{dic[key]}") if dic.key?(key)
59
+ end
60
+ end
61
+ translated_file.close
62
+ end
63
+
64
+ def new_file(file, path)
65
+ sourced = source_path.first
66
+ new_file = file.sub(sourced, path) if file.include?(sourced)
67
+ FileUtils::mkdir_p File.dirname(new_file) unless Dir.exist?File.dirname(new_file)
68
+ File.new(new_file, 'w')
69
+ end
70
+
71
+ end
72
+ end
@@ -0,0 +1,252 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'i18n/processes/reports/base'
4
+ require 'i18n/processes/rainbow_utils'
5
+ require 'terminal-table'
6
+ module I18n
7
+ module Processes
8
+ module Reports
9
+ class Terminal < Base # rubocop:disable Metrics/ClassLength
10
+ def missing_keys(forest = task.missing_keys)
11
+ forest = collapse_missing_tree! forest
12
+ if forest.present?
13
+ print_title missing_title(forest)
14
+ print_table headings: [Rainbow('Locale').cyan.bright,
15
+ Rainbow('Key').cyan.bright,
16
+ 'Value in other locales or source'] do |t|
17
+ t.rows = sort_by_attr!(forest_to_attr(forest)).map do |a|
18
+ [{ value: Rainbow(format_locale(a[:locale])).cyan, alignment: :center },
19
+ format_key(a[:key], a[:data]),
20
+ missing_key_info(a)]
21
+ end
22
+ end
23
+ else
24
+ print_success 'No translations are missing.'
25
+ end
26
+ end
27
+
28
+ def changed_keys(diff = nil)
29
+ if diff
30
+ print_title "#{diff.count} keys' value changed"
31
+ print_table headings: [Rainbow('key').cyan.bright,
32
+ Rainbow('Current').cyan.bright,
33
+ 'Previous'] do |t|
34
+ t.rows = diff.map do |key, value|
35
+ [key, value[:current], value[:previous]]
36
+ end
37
+ end
38
+ else
39
+ print_success 'No key have been changed.'
40
+ end
41
+ end
42
+
43
+ def icon(type)
44
+ glyph = missing_type_info(type)[:glyph]
45
+ { missing_used: Rainbow(glyph).red, missing_diff: Rainbow(glyph).yellow }[type]
46
+ end
47
+
48
+ def used_keys(used_tree = task.used_tree)
49
+ # For the used tree we may have usage nodes that are not leaves as references.
50
+ keys_nodes = used_tree.nodes.select { |node| node.data[:occurrences].present? }.map do |node|
51
+ [node.full_key(root: false), node]
52
+ end
53
+ print_title used_title(keys_nodes, used_tree.first.root.data[:key_filter])
54
+ # Group multiple nodes
55
+ if keys_nodes.present?
56
+ keys_nodes.sort! { |a, b| a[0] <=> b[0] }.each do |key, node|
57
+ print_occurrences node, key
58
+ end
59
+ else
60
+ print_error 'No key usages found.'
61
+ end
62
+ end
63
+
64
+ def unused_keys(tree = task.unused_keys)
65
+ keys = tree.root_key_value_data(true)
66
+ if keys.present?
67
+ print_title unused_title(keys)
68
+ print_locale_key_value_data_table keys
69
+ else
70
+ print_success 'Every translation is in use.'
71
+ end
72
+ end
73
+
74
+ def eq_base_keys(tree = task.eq_base_keys)
75
+ keys = tree.root_key_value_data(true)
76
+ if keys.present?
77
+ print_title eq_base_title(keys)
78
+ print_locale_key_value_data_table keys
79
+ else
80
+ print_info Rainbow('No translations are the same as base value').cyan
81
+ end
82
+ end
83
+
84
+ def show_tree(tree)
85
+ print_locale_key_value_data_table tree.root_key_value_data(true)
86
+ end
87
+
88
+ def forest_stats(forest, stats = task.forest_stats(forest))
89
+ text = if stats[:locale_count] == 1
90
+ "has #{stats[:key_count]} keys in total. On average, values are #{stats[:value_chars_avg]} characters long, keys have #{stats[:key_segments_avg]} segments."
91
+ else
92
+ "has #{stats[:key_count]} keys across #{stats[:locale_count]} locales. On average, values are #{stats[:value_chars_avg]} characters long, keys have #{stats[:key_segments_avg]} segments, a locale has #{stats[:per_locale_avg]} keys."
93
+ end
94
+ title = Rainbow("Forest (#{stats.slice(:locales)})").bright
95
+ print_info "#{Rainbow(title).cyan} #{Rainbow(text).cyan}"
96
+ end
97
+
98
+ def mv_results(results)
99
+ results.each do |(from, to)|
100
+ if to
101
+ print_info "#{Rainbow(from).cyan} #{Rainbow('⮕').yellow.bright} #{Rainbow(to).cyan}"
102
+ else
103
+ print_info "#{Rainbow(from).red}#{Rainbow(' 🗑').red.bright}"
104
+ end
105
+ end
106
+ end
107
+
108
+ def check_normalized_results(non_normalized)
109
+ if non_normalized.empty?
110
+ print_success 'All data is normalized'
111
+ return
112
+ end
113
+ log_stderr Rainbow('The following data requires normalization:').yellow
114
+ puts non_normalized
115
+ log_stderr Rainbow('Run `i18n-processes normalize` to fix').yellow
116
+ end
117
+
118
+ private
119
+
120
+ def missing_key_info(leaf)
121
+ if leaf[:type] == :missing_used
122
+ first_occurrence leaf
123
+ else
124
+ "#{Rainbow(leaf[:data][:missing_diff_locale]).cyan} "\
125
+ "#{format_value(leaf[:value].is_a?(String) ? leaf[:value].strip : leaf[:value])}"
126
+ end
127
+ end
128
+
129
+ def format_key(key, data)
130
+ if data[:ref_info]
131
+ from, to = data[:ref_info]
132
+ resolved = key[0...to.length]
133
+ after = key[to.length..-1]
134
+ " #{Rainbow(from).yellow}#{Rainbow(after).cyan}\n" \
135
+ "#{Rainbow('⮕').yellow.bright} #{Rainbow(resolved).yellow.bright}"
136
+ else
137
+ Rainbow(key).cyan
138
+ end
139
+ end
140
+
141
+ def format_value(val)
142
+ val.is_a?(Symbol) ? "#{Rainbow('⮕ ').yellow.bright}#{Rainbow(val).yellow}" : val.to_s.strip
143
+ end
144
+
145
+ def format_reference_desc(node_data)
146
+ return nil unless node_data
147
+ case node_data[:ref_type]
148
+ when :reference_usage
149
+ Rainbow('(ref)').yellow.bright
150
+ when :reference_usage_resolved
151
+ Rainbow('(resolved ref)').yellow.bright
152
+ when :reference_usage_key
153
+ Rainbow('(ref key)').yellow.bright
154
+ end
155
+ end
156
+
157
+ def print_occurrences(node, full_key = node.full_key)
158
+ occurrences = node.data[:occurrences]
159
+ puts [Rainbow(full_key).bright,
160
+ format_reference_desc(node.data),
161
+ (Rainbow(occurrences.size).green if occurrences.size > 1)].compact.join ' '
162
+ occurrences.each do |occurrence|
163
+ puts " #{key_occurrence full_key, occurrence}"
164
+ end
165
+ end
166
+
167
+ def print_locale_key_value_data_table(locale_key_value_datas)
168
+ if locale_key_value_datas.present?
169
+ print_table headings: [Rainbow("Locale").cyan.bright,
170
+ Rainbow("Key").cyan.bright,
171
+ "Value"] do |t|
172
+ t.rows = locale_key_value_datas.map { |(locale, k, v, data)|
173
+ [{ value: Rainbow(locale).cyan, alignment: :center }, format_key(k, data), format_value(v)]
174
+ }
175
+ end
176
+ else
177
+ puts 'ø'
178
+ end
179
+ end
180
+
181
+ def print_title(title)
182
+ log_stderr "#{Rainbow(title.strip).bright} #{I18n::Processes::RainbowUtils.faint_color('|')} " \
183
+ "#{"i18n-tasks v#{I18n::Processes::VERSION}"}"
184
+ end
185
+
186
+ def print_success(message)
187
+ log_stderr Rainbow("✓ #{["Good job!", "Well done!", "Perfect!"].sample} #{message}").green.bright
188
+ end
189
+
190
+ def print_error(message)
191
+ log_stderr(Rainbow(message).red.bright)
192
+ end
193
+
194
+ def print_info(message)
195
+ log_stderr message
196
+ end
197
+
198
+ def indent(txt, n = 2)
199
+ txt.gsub(/^/, ' ' * n)
200
+ end
201
+
202
+ def print_table(opts, &block)
203
+ puts ::Terminal::Table.new(opts, &block)
204
+ end
205
+
206
+ def key_occurrence(full_key, occurrence)
207
+ location = Rainbow("#{occurrence.path}:#{occurrence.line_num}").green
208
+ source = highlight_key(occurrence.raw_key || full_key, occurrence.line, occurrence.line_pos..-1).strip
209
+ "#{location} #{source}"
210
+ end
211
+
212
+ def first_occurrence(leaf)
213
+ # @type [I18n::Processes::Scanners::KeyOccurrences]
214
+ occurrences = leaf[:data][:occurrences]
215
+ # @type [I18n::Processes::Scanners::Occurrence]
216
+ first = occurrences.first
217
+ [
218
+ Rainbow("#{first.path}:#{first.line_num}").green,
219
+ ("(#{occurrences.length - 1} more)" if occurrences.length > 1)
220
+
221
+ ].compact.join(' ')
222
+ end
223
+
224
+ def highlight_key(full_key, line, range = (0..-1))
225
+ line.dup.tap do |s|
226
+ s[range] = s[range].sub(full_key) do |m|
227
+ highlight_string m
228
+ end
229
+ end
230
+ end
231
+
232
+ module HighlightUnderline
233
+ def highlight_string(s)
234
+ Rainbow(s).underline
235
+ end
236
+ end
237
+
238
+ module HighlightOther
239
+ def highlight_string(s)
240
+ Rainbow(s).yellow
241
+ end
242
+ end
243
+
244
+ if Gem.win_platform?
245
+ include HighlightOther
246
+ else
247
+ include HighlightUnderline
248
+ end
249
+ end
250
+ end
251
+ end
252
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'i18n/processes/scanners/scanner'
4
+
5
+ module I18n::Processes::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
+ @config = config
19
+ @file_reader = file_reader
20
+ @file_finder = file_finder_provider.get(**config.slice(:paths, :only, :exclude))
21
+ end
22
+
23
+ # @return (see Scanner#keys)
24
+ def keys
25
+ (traverse_files do |path|
26
+ scan_file(path)
27
+ end.reduce(:+) || []).group_by(&:first).map do |key, keys_occurrences|
28
+ Results::KeyOccurrences.new(key: key, occurrences: keys_occurrences.map(&:second))
29
+ end
30
+ end
31
+
32
+ protected
33
+
34
+ # Extract all occurrences of translate calls from the file at the given path.
35
+ #
36
+ # @return [Array<[key, Results::KeyOccurrence]>] each occurrence found in the file
37
+ def scan_file(_path)
38
+ fail 'Unimplemented'
39
+ end
40
+
41
+ # Read a file. Reads of the same path are cached.
42
+ #
43
+ # @param path [String]
44
+ # @return [String] file contents
45
+ def read_file(path)
46
+ @file_reader.read_file(path)
47
+ end
48
+
49
+ # Traverse the paths and yield the matching ones.
50
+ #
51
+ # @note This method is cached, it will only access the filesystem on the first invocation.
52
+ # @param (see FileFinder#traverse_files)
53
+ # @yieldparam (see FileFinder#traverse_files)
54
+ # @return (see FileFinder#traverse_files)
55
+ def traverse_files(&block)
56
+ @file_finder.traverse_files(&block)
57
+ end
58
+
59
+ # @note This method is cached, it will only access the filesystem on the first invocation.
60
+ # @return (see FileFinder#find_files)
61
+ def find_files
62
+ @file_finder.find_files
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'i18n/processes/scanners/files/file_finder'
4
+ module I18n::Processes::Scanners::Files
5
+ # Finds the files in the specified search paths with support for exclusion / inclusion patterns.
6
+ # Wraps a {FileFinder} and caches the results.
7
+ #
8
+ # @note This class is thread-safe. All methods are cached.
9
+ # @since 0.9.0
10
+ class CachingFileFinder < FileFinder
11
+ # @param (see FileFinder#initialize)
12
+ def initialize(**args)
13
+ super
14
+ @mutex = Mutex.new
15
+ @cached_paths = nil
16
+ end
17
+
18
+ # Traverse the paths and yield the matching ones.
19
+ #
20
+ # @note This method is cached, it will only access the filesystem on the first invocation.
21
+ # @param (see FileFinder#traverse_files)
22
+ # @yieldparam (see FileFinder#traverse_files)
23
+ # @return (see FileFinder#traverse_files)
24
+ def traverse_files
25
+ super
26
+ end
27
+
28
+ # @note This method is cached, it will only access the filesystem on the first invocation.
29
+ # @return (see FileFinder#find_files)
30
+ def find_files
31
+ @cached_paths || @mutex.synchronize { @cached_paths ||= super }
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'i18n/processes/scanners/files/caching_file_finder'
4
+
5
+ module I18n::Processes::Scanners::Files
6
+ # Finds the files and provides their contents.
7
+ #
8
+ # @note This class is thread-safe. All methods are cached.
9
+ # @since 0.9.0
10
+ class CachingFileFinderProvider
11
+ # @param exclude [Array<String>]
12
+ def initialize(exclude: [])
13
+ @cache = {}
14
+ @mutex = Mutex.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[file_finder_args] || @mutex.synchronize do
24
+ @cache[file_finder_args] ||= begin
25
+ args = file_finder_args.dup
26
+ args[:exclude] = @defaults[:exclude] + (args[:exclude] || [])
27
+ args[:exclude].uniq!
28
+ CachingFileFinder.new(**args)
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'i18n/processes/scanners/files/file_reader'
4
+ module I18n::Processes::Scanners::Files
5
+ # Reads the files in 'rb' mode and UTF-8 encoding.
6
+ # Wraps a {FileReader} and caches the results.
7
+ #
8
+ # @note This class is thread-safe. All methods are cached.
9
+ # @since 0.9.0
10
+ class CachingFileReader < FileReader
11
+ def initialize
12
+ super
13
+ @mutex = Mutex.new
14
+ @cache = {}
15
+ end
16
+
17
+ # Return the contents of the file at the given path.
18
+ # The file is read in the 'rb' mode and UTF-8 encoding.
19
+ #
20
+ # @param (see FileReader#read_file)
21
+ # @return (see FileReader#read_file)
22
+ # @note This method is cached, it will only access the filesystem on the first invocation.
23
+ def read_file(path)
24
+ absolute_path = File.expand_path(path)
25
+ @cache[absolute_path] || @mutex.synchronize { @cache[absolute_path] ||= super }
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module I18n::Processes::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::Processes::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
+ @paths = paths
19
+ @include = only
20
+ @exclude = exclude || []
21
+ end
22
+
23
+ # Traverse the paths and yield the matching ones.
24
+ #
25
+ # @yield [path]
26
+ # @yieldparam path [String] the path of the found file.
27
+ # @return [Array<of block results>]
28
+ def traverse_files
29
+ find_files.map { |path| yield path }
30
+ end
31
+
32
+ # @return [Array<String>] found files
33
+ def find_files # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
34
+ results = []
35
+ paths = @paths.select { |p| File.exist?(p) }
36
+ log_warn "None of the search.paths exist #{@paths.inspect}" if paths.empty?
37
+ Find.find(*paths) do |path|
38
+ is_dir = File.directory?(path)
39
+ hidden = File.basename(path).start_with?('.') && !%w[. ./].include?(path)
40
+ not_incl = @include && !path_fnmatch_any?(path, @include)
41
+ excl = path_fnmatch_any?(path, @exclude)
42
+ if is_dir || hidden || not_incl || excl
43
+ Find.prune if is_dir && (hidden || excl)
44
+ else
45
+ results << path
46
+ end
47
+ end
48
+ results
49
+ end
50
+
51
+ private
52
+
53
+ # @param path [String]
54
+ # @param globs [Array<String>]
55
+ # @return [Boolean]
56
+ def path_fnmatch_any?(path, globs)
57
+ globs.any? { |glob| File.fnmatch(glob, path) }
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module I18n::Processes::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,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module I18n::Processes
4
+ module Scanners
5
+ module OccurrenceFromPosition
6
+ # Given a path to a file, its contents and a position in the file,
7
+ # return a {Results::Occurrence} at the position until the end of the line.
8
+ #
9
+ # @param path [String]
10
+ # @param contents [String] contents of the file at the path.
11
+ # @param position [Integer] position just before the beginning of the match.
12
+ # @return [Results::Occurrence]
13
+ def occurrence_from_position(path, contents, position, raw_key: nil)
14
+ line_begin = contents.rindex(/^/, position - 1)
15
+ line_end = contents.index(/.(?=\r?\n|$)/, position)
16
+ Results::Occurrence.new(
17
+ path: path,
18
+ pos: position,
19
+ line_num: contents[0..position].count("\n") + 1,
20
+ line_pos: position - line_begin + 1,
21
+ line: contents[line_begin..line_end],
22
+ raw_key: raw_key
23
+ )
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'i18n/processes/scanners/file_scanner'
4
+ require 'i18n/processes/scanners/relative_keys'
5
+ require 'i18n/processes/scanners/occurrence_from_position'
6
+ require 'i18n/processes/scanners/ruby_key_literals'
7
+
8
+ module I18n::Processes::Scanners
9
+ # Maps the provided patterns to keys.
10
+ class PatternMapper < FileScanner
11
+ include I18n::Processes::Scanners::RelativeKeys
12
+ include I18n::Processes::Scanners::OccurrenceFromPosition
13
+ include I18n::Processes::Scanners::RubyKeyLiterals
14
+
15
+ # @param patterns [Array<[String, String]> the list of pattern-key pairs
16
+ # the patterns follow the regular expression syntax, with a syntax addition for matching
17
+ # string/symbol literals: you can include %{key} in the pattern, and it will be converted to
18
+ # a named capture group, capturing ruby strings and symbols, that can then be used in the key:
19
+ #
20
+ # patterns: [['Spree\.t[( ]\s*%{key}', 'spree.%{key}']]
21
+ #
22
+ # All of the named capture groups are interpolated into the key with %{group_name} interpolations.
23
+ #
24
+ def initialize(config:, **args)
25
+ super
26
+ @patterns = configure_patterns(config[:patterns] || [])
27
+ end
28
+
29
+ protected
30
+
31
+ # @return [Array<[absolute key, Results::Occurrence]>]
32
+ def scan_file(path)
33
+ text = read_file(path)
34
+ @patterns.flat_map do |pattern, key|
35
+ result = []
36
+ text.scan(pattern) do |_|
37
+ match = Regexp.last_match
38
+ matches = Hash[match.names.map(&:to_sym).zip(match.captures)]
39
+ if matches.key?(:key)
40
+ matches[:key] = strip_literal(matches[:key])
41
+ next unless valid_key?(matches[:key])
42
+ end
43
+ result << [absolute_key(format(key, matches), path),
44
+ occurrence_from_position(path, text, match.offset(0).first)]
45
+ end
46
+ result
47
+ end
48
+ end
49
+
50
+ private
51
+
52
+ KEY_GROUP = "(?<key>#{LITERAL_RE})"
53
+
54
+ def configure_patterns(patterns)
55
+ patterns.map do |(pattern, key)|
56
+ [pattern.is_a?(Regexp) ? pattern : Regexp.new(format(pattern, key: KEY_GROUP)), key]
57
+ end
58
+ end
59
+ end
60
+ end