util 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,178 @@
1
+ module Util
2
+ # Help write formatted messages to the console, for friendlier
3
+ # command-line interface. Uses generally available ANSI codes, so
4
+ # it should work on all UNIXes and on recent Windows.
5
+ #
6
+ # @example
7
+ # cl = ConsoleLogger.new e: { :stderr => true }
8
+ # cl.warning 'Errors will be logged on STDERR.'
9
+ # # Message written in yellow
10
+ # begin
11
+ # text = File.read 'secret.msg'
12
+ # rescue Exception => e
13
+ # msg = 'Cannot go any further because of %E%, aborting.'
14
+ # cl.error msg, 'E': e.message
15
+ # # Message written in red
16
+ # end
17
+ class ConsoleLogger
18
+ require 'util/arg'
19
+
20
+ # ANSI code to reset all formatting
21
+ RESET = "\x1b[0m"
22
+ # @no_doc
23
+ BASE = "\x1b[%CODE%m"
24
+ # @no_doc
25
+ COLORS = { :black => 0, :red => 1, :green => 2, :yellow => 3,
26
+ :blue => 4, :magenta => 5, :cyan => 6, :white => 7 }
27
+ # @no_doc
28
+ COLOR_TYPES = { :fg => 30, :bg => 40, :bright => 60 }
29
+ # @no_doc
30
+ DECORS = { :bold => 1, :faint => 2, :italic => 3, :underline => 4,
31
+ :blink => 5, :reverse => 7, :conceal => 8, :crossed => 9,
32
+ :dbl_underline => 21, :overline => 53 }
33
+ # @no_doc
34
+ CL = ConsoleLogger
35
+
36
+ # Generate the ANSI code to obtain a given formatting.
37
+ # @param opts [Hash] the wanted formatting
38
+ # @option opts [:black, :blue, :cyan, :green, :magenta,
39
+ # :red, :white, :yellow] :color font color
40
+ # @option opts [idem] :bgcolor background color
41
+ # @option opts [Boolean] :bright use bright font color
42
+ # @option opts [Boolean] :bgbright use bright background color
43
+ # @option opts [:blink, :bold, :conceal, :crossed,
44
+ # :dbl_underline, :faint, :italic, :overline, :reverse,
45
+ # :underline, Array<idem>] :decor text decorations
46
+ def self.escape_code opts={}
47
+ opts = Arg.check opts, Hash, {}
48
+ return RESET if opts.empty?
49
+ code = ''
50
+
51
+ if opts.has_key? :color then
52
+ color = Arg.check opts[:color], Symbol, :white
53
+ color = :white unless COLORS.has_key? color
54
+ bright = Arg.check opts[:bright], 'Boolean', false
55
+
56
+ cur = COLOR_TYPES[:fg] + COLORS[color]
57
+ cur += COLOR_TYPES[:bright] if bright
58
+ code += cur.to_s
59
+ end
60
+
61
+ if opts.has_key? :bgcolor then
62
+ color = Arg.check opts[:bgcolor], Symbol, :black
63
+ color = :black unless COLORS.has_key? color
64
+ bright = Arg.check opts[:bgbright], 'Boolean', false
65
+
66
+ cur = COLOR_TYPES[:bg] + COLORS[color]
67
+ cur += COLOR_TYPES[:bright] if bright
68
+ code += ';' unless code.empty?
69
+ code += cur.to_s
70
+ end
71
+
72
+ if opts.has_key? :decor then
73
+ decors = Arg.check opts[:decor], Array, [opts[:decor]]
74
+ cur = ''
75
+ decors.each do |d|
76
+ cur += ';' + DECORS[d].to_s if DECORS.has_key? d
77
+ end
78
+ cur = cur.sub ';', '' if code.empty?
79
+ code += cur
80
+ end
81
+
82
+ code.empty? ? RESET : BASE.sub('%CODE%', code)
83
+ end
84
+
85
+ # Create a new ConsoleLogger.
86
+ # @param config [Hash] initial configuration: for each kind of
87
+ # message, whether to use STDERR or STDOUT, and which formatting
88
+ # @option config [Hash { :stderr => Boolean, :code => String }]
89
+ # e configuration for error (defaults to red text)
90
+ # @option config [Hash { :stderr => Boolean, :code => String }]
91
+ # i configuration for information (defaults to cyan text)
92
+ # @option config [Hash { :stderr => Boolean, :code => String }]
93
+ # n configuration for normal (defaults to no formatting)
94
+ # @option config [Hash { :stderr => Boolean, :code => String }]
95
+ # o configuration for ok (defaults to green text)
96
+ # @option config [Hash { :stderr => Boolean, :code => String }]
97
+ # w configuration for warning (defaults to yellow text)
98
+ def initialize config={}
99
+ config = Arg.check config, Hash, {}
100
+ @config = {
101
+ :e => { :io => $stdout, :code => CL.escape_code(color: :red) },
102
+ :i => { :io => $stdout, :code => CL.escape_code(color: :cyan) },
103
+ :n => { :io => $stdout, :code => '' },
104
+ :o => { :io => $stdout, :code => CL.escape_code(color: :green) },
105
+ :w => { :io => $stdout, :code => CL.escape_code(color: :yellow) },
106
+ }
107
+
108
+ config.each_pair do |k, v|
109
+ next unless @config.has_key? k
110
+ v = Arg.check v, Hash, {}
111
+ @config[k][:io] = (v[:stderr] == true) ? $stderr : $stdout
112
+ @config[k][:code] = CL.escape_code v
113
+ end
114
+ end
115
+
116
+ # Print an error to the console.
117
+ # @param msg [String] message to print
118
+ # @param payload [Hash<#to_s, #to_s>] parts to replace in the base
119
+ # message: '%KEY%' will be replaced by 'VALUE'.
120
+ # @example
121
+ # msg = 'The array contains only %I% objects of type %T%.'
122
+ # cl.error msg, 'T': Float, 'I': arr.how_many?(Float)
123
+ #
124
+ # # Results in `The array contains only 42 objects of type Float.`
125
+ # @return nil
126
+ def error msg, payload={}
127
+ self.printf :e, msg, payload
128
+ end
129
+
130
+ # Print an important message to the console.
131
+ # @param (see #error)
132
+ # @example (see #error)
133
+ # @return (see #error)
134
+ def important msg, payload={}
135
+ self.printf :i, msg, payload
136
+ end
137
+
138
+ # Print a normal message to the console.
139
+ # @param (see #error)
140
+ # @example (see #error)
141
+ # @return (see #error)
142
+ def normal msg, payload={}
143
+ self.printf :n, msg, payload
144
+ end
145
+
146
+ # Print an approval to the console.
147
+ # @param (see #error)
148
+ # @example (see #error)
149
+ # @return (see #error)
150
+ def ok msg, payload={}
151
+ self.printf :o, msg, payload
152
+ end
153
+
154
+ # Print a warning to the console.
155
+ # @param (see #error)
156
+ # @example (see #error)
157
+ # @return (see #error)
158
+ def warning msg, payload={}
159
+ self.printf :w, msg, payload
160
+ end
161
+
162
+ private
163
+
164
+ # Common parts to all messaging methods.
165
+ # @param type [:e, :i, :n, :o, :w] kind of message
166
+ # @param msg (see #error)
167
+ # @param payload (see #error)
168
+ # @return nil
169
+ def printf type, msg, payload
170
+ msg = Arg.check msg, String, ''
171
+ payload = Arg.check payload, Hash, {}
172
+ payload.each_pair do |k, v|
173
+ msg = msg.gsub "%#{k}%", v.to_s
174
+ end
175
+ @config[type][:io].puts @config[type][:code] + msg + RESET
176
+ end
177
+ end
178
+ end
data/lib/util/i18n.rb ADDED
@@ -0,0 +1,328 @@
1
+ module Util
2
+ # A class for simple internationalization.
3
+ # Do not expect anything extraordinary.
4
+ class I18n
5
+ # If the provided default language does not work, the default default
6
+ # language is French.
7
+ DEFAULT_LANG = :fra
8
+
9
+ private_class_method :new
10
+
11
+ # Initialize the internationalization manager. Automatically called when
12
+ # using another method of the class.
13
+ def self.init
14
+ @default_lang = DEFAULT_LANG
15
+ @errors = []
16
+ @messages = {}
17
+ end
18
+
19
+ # Simpler variant of register, where the module name is generated
20
+ # from its filename. No recursivity.
21
+ # @param [String] path path relative to which the +i18n+ folder is located
22
+ # @return [Boolean] true on success, false on error
23
+ def self.<< path
24
+ name = File.basename path.to_s, '.rb'
25
+ register name, path
26
+ end
27
+
28
+ # Get the current default language.
29
+ # @return [Symbol]
30
+ def self.default_lang
31
+ init unless initialized?
32
+ @default_lang
33
+ end
34
+
35
+ # Check whether the internationalization system is initialized.
36
+ # @return [Boolean]
37
+ def self.initialized?
38
+ not @default_lang.nil?
39
+ end
40
+
41
+ # Obtain the translated message associated to a given
42
+ # internationalization token. If no options are provided, the
43
+ # default language and last used module will be used.
44
+ # @param [String] id internationalization token
45
+ # @param [Hash] opts options
46
+ # @option opts [#to_sym] :lang wanted language
47
+ # @option opts [String] :mod in which module to find the token
48
+ # @return [String] translated message, or +''+ in case of error
49
+ def self.message id, opts={}
50
+ init unless initialized?
51
+ if @messages.empty? then
52
+ @errors << [:messages_empty, nil]
53
+ return ''
54
+ end
55
+
56
+ require 'util/arg'
57
+ id, opts = Arg.check_a [[id, String, ''], [opts, Hash, {}]]
58
+ o_lang, o_mod = Arg.check_h opts, [[:lang, Symbol, @default_lang],
59
+ [:mod, String, @last_used_mod]]
60
+
61
+ mod = o_mod.empty? ? @last_used_mod : o_mod
62
+ mod = @messages.keys[0] if mod.empty?
63
+ if @messages[mod].nil? or @messages[mod].empty? then
64
+ @errors << [:msg_no_module_content, mod]
65
+ return ''
66
+ end
67
+
68
+ lang = (@messages[mod][o_lang].nil? or @messages[mod][o_lang].empty?) \
69
+ ? @default_lang : o_lang
70
+ lang = (@messages[mod][lang].nil? or @messages[mod][lang].empty?) \
71
+ ? DEFAULT_LANG : lang
72
+ if @messages[mod][lang].nil? or @messages[mod][lang].empty? then
73
+ data = [o_lang, @default_lang, DEFAULT_LANG].to_s
74
+ @errors << [:msg_no_valid_lang, data]
75
+ return ''
76
+ end
77
+
78
+ @last_used_mod = mod
79
+ @messages[mod][lang][id].to_s
80
+ end
81
+
82
+ # Get the next error encountered while using I18n. Possible
83
+ # errors are the following.
84
+ # - +:messages_empty+ (data is +nil+): attempted to get a tranlated
85
+ # message before having registered any.
86
+ # - +:msg_no_module_content+ (data is the module name): attempted to get
87
+ # a translated message from a module that does not exist or
88
+ # contains no messages.
89
+ # - +:msg_no_valid_lang+ (data is an array of tried languages): attempted
90
+ # to get a translated message from a module where neither the asked
91
+ # language nor the default languages contain any messages.
92
+ # - +:reg_no_file+ (data is the provided path): the path provided to
93
+ # register messages contains no valid YAML file to use.
94
+ # - +:reg_no_name+ (data is the provided name): attempted to
95
+ # register messages with a module name that resolves to an empty string.
96
+ # - +:reg_path_not_dir+ (data is the provided path): the path provided to
97
+ # register messages is not a directory.
98
+ # - +:reg_path_not_exist+ (data is the provided path): the path provided
99
+ # to register messages does not exist.
100
+ # - +:reg_yaml_cant_open+ (data is the file path): the file cannot be
101
+ # opened or parsed by YAML.
102
+ # - +:set_def_unknown+ (data is the provided language code): attempted
103
+ # to set the default language to an invalid value.
104
+ # @return [Array<Symbol, Object>] error name and error data
105
+ def self.next_error
106
+ init unless initialized?
107
+ @errors.shift
108
+ end
109
+
110
+ # Register a set of internationalization tokens. Will search the
111
+ # same directory as the given path for YAML files whose file name
112
+ # is a valid language code, and extract the internationalized
113
+ # strings from them.
114
+ # @param [String] o_name name of the token-set
115
+ # @param [String] path file relative to which the YAML files
116
+ # will be searched (it is meant to be used with +__FILE__+).
117
+ # If nothing is provided, present working directory will be used.
118
+ # @param [Boolean] relative if true, will search +i18n+ folder
119
+ # next to the given path; if false, will search YAML files
120
+ # directly inside the given path
121
+ # @param [Boolean] recursive search subfolders too, and generate
122
+ # token-set names derived from the subfolders’ name
123
+ # @return [Boolean] true on success, false on error
124
+ def self.register o_name='', path='', relative=true, recursive=false
125
+ name, path, relative, recursive = \
126
+ register_check_args o_name, path, relative, recursive
127
+ return false if name == false
128
+
129
+ locations = register_get_locations name, path, recursive
130
+
131
+ messages = {}
132
+ locations.each do |loc|
133
+ register_get_messages loc, messages
134
+ end
135
+ return false if check_t messages.empty?, :reg_no_file, path
136
+
137
+ @messages.merge! messages do |k, ov, nv|
138
+ @messages[k] = ov.merge nv do |l, lov, lnv|
139
+ ov[l] = lov.merge lnv
140
+ end
141
+ end
142
+
143
+ @last_used_mod = name if @last_used_mod.nil?
144
+ true
145
+ end
146
+
147
+ # Set the default language to use from now on. Recommended is an
148
+ # ISO 639-3 language code, but any ISO 639 code, IETF BCP 47 language
149
+ # tag or ISO/IEC 15897 locale (a. k. a. POSIX locale) will work.
150
+ # @param [#to_sym] n_lang new default language
151
+ # @return [Boolean]
152
+ def self.set_default_lang n_lang
153
+ init unless initialized?
154
+ lang = n_lang.to_s.downcase
155
+ lang = $1 if lang.match /^(\w+)(?:_|-)/
156
+ lang = valid_lang? lang
157
+ return false if check_f lang, :set_def_unknown, n_lang
158
+
159
+ @default_lang = lang.to_sym
160
+ true
161
+ end
162
+
163
+ private
164
+
165
+ # Register an error if a given test yields a given result.
166
+ # @param [Boolean] trigger test result that shall trigger the error
167
+ # @param [Boolean] test test that might trigger the error
168
+ # @param [Symbol] err_name error name
169
+ # @param [Object] err_data data giving details on the error
170
+ # @return [Boolean] whether the error was triggered
171
+ def self.check trigger, test, err_name, err_data
172
+ @errors << [err_name, err_data] if test == trigger
173
+ test == trigger
174
+ end
175
+
176
+ # Register an error if the test is false
177
+ # @param [Boolean] test test that might trigger the error
178
+ # @param [Symbol] err_name error name
179
+ # @param [Object] err_data data giving details on the error
180
+ # @return [Boolean] whether the error was triggered
181
+ def self.check_f test, err_name, err_data=nil
182
+ check false, test, err_name, err_data
183
+ end
184
+
185
+ # Register an error if the test is true
186
+ # @param (see check_f)
187
+ # @return (see check_f)
188
+ def self.check_t test, err_name, err_data=nil
189
+ check true, test, err_name, err_data
190
+ end
191
+
192
+ # Verify the arguments given to I18n.register.
193
+ # @param (see register)
194
+ # @return [String, String, Boolean, Boolean] success
195
+ # @return [false] failure
196
+ def self.register_check_args o_name, path, relative, recursive
197
+ require 'util/arg'
198
+ init unless initialized?
199
+ name, path, relative, recursive = Arg.check_a [
200
+ [o_name, String, ''], [path, String, ''],
201
+ [relative, FalseClass, true], [recursive, TrueClass, false]
202
+ ]
203
+ return false if check_t name.empty?, :reg_no_name, o_name
204
+
205
+ path = relative ? './temp.rb' : '.' if path.empty?
206
+ if relative then
207
+ dir = File.dirname(File.expand_path path)
208
+ path = File.join dir, 'i18n'
209
+ end
210
+
211
+ return false if check_f File.exist?(path), :reg_path_not_exist, path
212
+ return false if check_f File.directory?(path), :reg_path_not_dir, path
213
+ [name, path, relative, recursive]
214
+ end
215
+
216
+ # Get the folder(s) in which to search for YAML files, and the
217
+ # associated module names.
218
+ # @param [String] name name of the root module
219
+ # @param [String] path path to the root folder
220
+ # @param [Boolean] recursive wether the search should be recursive
221
+ # @return [Array<Array<String, String>>]
222
+ def self.register_get_locations name, path, recursive
223
+ locations = []
224
+ dirs = [path]
225
+ begin
226
+ current = dirs.shift
227
+ glob = File.join current.gsub(/([?*{\[])/, "\\$1"), '*.yml'
228
+ # These characters have a meaning for +Dir.glob+ and might
229
+ # yield very strange results if they are not escaped.
230
+
231
+ slug = slugify(current.sub(path, '').sub(File::SEPARATOR, ''))
232
+ full_name = slug.empty? ? name : (name + '-' + slug)
233
+ locations << [glob, full_name]
234
+ Dir[File.join current, '*'].each do |f|
235
+ dirs << f if File.directory? f
236
+ end if recursive
237
+ end until dirs.empty?
238
+ locations
239
+ end
240
+
241
+ # Extract the messages from a given folder. Will use all files with
242
+ # the form +lang-code.yml+ that contain valid YAML for a hash, then
243
+ # convert the values to strings, and finally merge it in the
244
+ # existing list.
245
+ # @param [Array<String, String>] loc glob and module name to use
246
+ # @param [Hash] messages existing list
247
+ # @return [Hash] updated list
248
+ def self.register_get_messages loc, messages
249
+ require 'util/yaml'
250
+ glob, name = loc
251
+ langs = {}
252
+
253
+ Dir[glob].each do |f|
254
+ lang = File.basename(f, '.yml')
255
+ next unless lang = valid_lang?(lang)
256
+ lang = lang.to_sym
257
+
258
+ content = YAML.from_file f
259
+ check_f content, :reg_yaml_cant_open, f
260
+ next unless content.is_a? Hash
261
+
262
+ langs[lang] = {} unless content.empty?
263
+ content.each_pair do |k, v|
264
+ langs[lang][k.to_s] = v.to_s
265
+ end
266
+ end
267
+
268
+ return messages if langs.empty?
269
+
270
+ if messages.has_key? name then
271
+ messages[name].merge! langs do |k, ov, nv|
272
+ messages[name][k] = ov.merge nv
273
+ end
274
+ else
275
+ messages[name] = langs
276
+ end
277
+
278
+ messages
279
+ end
280
+
281
+ # Escape a path so that it can be used as a Hash key. Rules are as follows.
282
+ # - Any non-word character becomes a hyphen.
283
+ # - Multiple hyphens are reduced to just one.
284
+ # - Starting and trailing hyphens are removed.
285
+ # - Everything is downcased.
286
+ # - The {https://en.wikipedia.org/wiki/Base64 base64} representation
287
+ # of the path is used instead if the resulting slug is empty.
288
+ # - All directories’ slugs are joined by hyphens.
289
+ # @param [String] string path to transform in slug
290
+ # @return [String]
291
+ # @example
292
+ # I18n.slugify 'alias' # 'alias'
293
+ # I18n.slugify '今日は' # '今日は'
294
+ # I18n.slugify 'La vie de ma mère' # 'la-vie-de-ma-mère'
295
+ # I18n.slugify 'Ça va? Oui, et toi?' # 'ça-va-oui-et-toi'
296
+ # I18n.slugify '??!?' # 'Pz8hPw=='
297
+ # I18n.slugify 'hello/Darkneß/m41_0!d' # 'hello-darkneß-m41_0-d'
298
+ # I18n.slugify 'Estie/de/tabarnak/!!§!' # 'estie-de-tabarnak-ISHCpyE='
299
+ # @note {https://www.youtube.com/watch?v=DvR6-SQzqO8 For those who did
300
+ # not get the last example…}
301
+ def self.slugify string
302
+ require 'base64'
303
+ result = []
304
+ string.split(File::SEPARATOR).each do |part|
305
+ res = part.gsub(/[^[:word:]]/, '-').gsub(/-{2,}/, '-')
306
+ res = res.sub(/^-/, '').sub(/-$/, '').downcase
307
+ res = Base64.urlsafe_encode64(part) if res.empty?
308
+ result << res
309
+ end
310
+ result.join '-'
311
+ end
312
+
313
+ # From a given ISO 639 code, either get the valid corresponding
314
+ # ISO 639-3 code, or false.
315
+ # @param [#to_sym] lang language code to check
316
+ # @return [#to_sym] existing ISO 639-3 code
317
+ # @return [false] could not find a matching ISO 639-3 code
318
+ def self.valid_lang? lang
319
+ require 'util/lists/iso639'
320
+ iso = Util::Lists::ISO639
321
+ # Typecheck is already done by ISO639
322
+ return lang if iso::P3.exist?(lang)
323
+ if n_lang = iso::P3.from2(lang) then return n_lang; end
324
+ if n_lang = iso::P3.from1(lang) then return n_lang; end
325
+ false
326
+ end
327
+ end
328
+ end