util 0.2.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.
@@ -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