mova 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,183 @@
1
+ module Mova
2
+ # Wrapper around a storage that provides key management and fallbacks.
3
+ #
4
+ # Translator knows that keys by definition are dot-separated and each key should
5
+ # include a locale. It also flattens any given hash, because ordinary key-value
6
+ # storage can handle only flat data.
7
+ #
8
+ # Translator is in charge of returning non-empty data for given set of locales and keys,
9
+ # because it is likely that some translations are missing.
10
+ #
11
+ # @since 0.1.0
12
+ class Translator
13
+ include ReadStrategy::Lazy
14
+
15
+ # @!attribute [r] storage
16
+ # Key-value storage for translations.
17
+ # @return [#read, #read_multi, #write, #exist?, #clear]
18
+ attr_reader :storage
19
+
20
+ module Overridable
21
+ # @param opts [Hash]
22
+ # @option opts [see #storage] :storage default: {Storage::Memory} instance
23
+ def initialize(opts = {})
24
+ @storage = opts.fetch(:storage) do
25
+ require "mova/storage/memory"
26
+ Storage::Memory.new
27
+ end
28
+ end
29
+
30
+ # @return [Array<String, Symbol>] locales that should be tried until non-empty
31
+ # translation would be found.
32
+ # @param current_locale [String, Symbol]
33
+ #
34
+ # @example Override locale fallbacks
35
+ # translator = Mova::Translator.new.tap do |t|
36
+ # def t.locales_to_try(locale)
37
+ # [locale, :en]
38
+ # end
39
+ # end
40
+ # translator.put(en: {hello: "world"})
41
+ # translator.get(:hello, :de) #=> "world"; tried "de.hello", then "en.hello"
42
+ def locales_to_try(current_locale)
43
+ [current_locale]
44
+ end
45
+
46
+ # @return [Array<String, Symbol>] keys that should be tried until non-empty
47
+ # translation would be found.
48
+ # @param key [String, Symbol]
49
+ #
50
+ # @example Override key fallbacks
51
+ # translator = Mova::Translator.new.tap do |t|
52
+ # def t.keys_to_try(key)
53
+ # [key, "errors.#{key}"]
54
+ # end
55
+ # end
56
+ # translator.put(en: {errors: {fail: "Fail"}})
57
+ # translator.get(:fail, :en) #=> "Fail"; tried "en.fail", then "en.errors.fail"
58
+ def keys_to_try(key)
59
+ [key]
60
+ end
61
+
62
+ # @return [String] default value if no translation was found.
63
+ # @param locales [Array<String>] that were used to find a translation.
64
+ # @param keys [Array<String>] that were used to find a translation.
65
+ # @param get_options [Hash{Symbol => Object}] that were passed to {#get}
66
+ #
67
+ # @example Override default value handling
68
+ # translator = Mova::Translator.new.tap do |t|
69
+ # def t.default(locales, keys, get_options)
70
+ # "translation is missing"
71
+ # end
72
+ # end
73
+ # translator.get("hello", :de) #=> "translation is missing"
74
+ def default(locales, keys, get_options)
75
+ EMPTY_TRANSLATION
76
+ end
77
+ end
78
+ include Overridable
79
+
80
+ # Retrieves translation from the storage or return default value.
81
+ #
82
+ # @return [String] translation or default value if nothing found
83
+ #
84
+ # @example
85
+ # translator.put(en: {hello: "world"})
86
+ # translator.get("hello", :en) #=> "world"
87
+ # translator.get("bye", :en) #=> ""
88
+ #
89
+ # @example Providing the default if nothing found
90
+ # translator.get("hello", :de, default: "nothing") #=> "nothing"
91
+ #
92
+ # @overload get(key, locale, opts = {})
93
+ # @param key [String, Symbol]
94
+ # @param locale [String, Symbol]
95
+ #
96
+ # @overload get(keys, locale, opts = {})
97
+ # @param keys [Array<String, Symbol>] use this to redefine an array returned
98
+ # by {#keys_to_try}.
99
+ # @param locale [String, Symbol]
100
+ #
101
+ # @example
102
+ # translator.put(en: {fail: "Fail"})
103
+ # translator.get(["big.fail", "mine.fail"], :en) #=> ""; tried "en.big.fail", then "en.mine.fail"
104
+ #
105
+ # @overload get(key, locales, opts = {})
106
+ # @param key [String, Symbol]
107
+ # @param locales [Array<String, Symbol>] use this to redefine an array returned
108
+ # by {#locales_to_try}.
109
+ #
110
+ # @example
111
+ # translator.put(en: {hello: "world"})
112
+ # translator.get(:hello, :de) #=> ""; tried only "de.hello"
113
+ # translator.get(:hello, [:de, :en]) #=> "world"; tried "de.hello", then "en.hello"
114
+ #
115
+ # @example Disable locale fallbacks locally
116
+ # translator.put(en: {hello: "world"}) # suppose this instance has fallback to :en locale
117
+ # translator.get(:hello, :de) #=> "world"; tried "de.hello", then "en.hello"
118
+ # translator.get(:hello, [:de]) #=> ""; tried only "de.hello"
119
+ #
120
+ # @overload get(keys, locales, opts = {})
121
+ # @param keys [Array<String, Symbol>]
122
+ # @param locales [Array<String, Symbol>]
123
+ #
124
+ # @note Keys fallback has a higher priority than locales one, that is, Mova
125
+ # tries to find a translation for any given key and only then it fallbacks
126
+ # to another locale.
127
+ #
128
+ # @param opts [Hash]
129
+ # @option opts [String] :default use this to redefine default value returned
130
+ # by {#default}.
131
+ #
132
+ # @see #locales_to_try
133
+ # @see #keys_to_try
134
+ # @see #default
135
+ #
136
+ def get(key, locale, opts = {})
137
+ keys = resolve_scopes(key)
138
+ locales = resolve_locales(locale)
139
+ read_first(locales, keys) || opts[:default] || default(locales, keys, opts)
140
+ end
141
+
142
+ # Writes translations to the storage.
143
+ #
144
+ # @return [void]
145
+ # @param translations [Hash{String, Symbol => String, Hash}] where
146
+ # root key/keys must be a locale
147
+ #
148
+ # @example
149
+ # translator.put(en: {world: "world"}, uk: {world: "світ"})
150
+ # translator.get("world", :uk) #=> "світ"
151
+ def put(translations)
152
+ Scope.flatten(translations).each do |key, value|
153
+ storage.write(key, value) unless storage.exist?(key)
154
+ end
155
+ end
156
+
157
+ # @see #put
158
+ #
159
+ # @return [void]
160
+ #
161
+ # @note This method overwrites existing translations.
162
+ def put!(translations)
163
+ Scope.flatten(translations).each do |key, value|
164
+ storage.write(key, value)
165
+ end
166
+ end
167
+
168
+ # @private
169
+ def inspect
170
+ "<##{self.class.name} storage=#{storage.inspect}>"
171
+ end
172
+
173
+ private
174
+
175
+ def resolve_locales(locale)
176
+ (locale if locale.is_a? Array) || locales_to_try(locale)
177
+ end
178
+
179
+ def resolve_scopes(key)
180
+ (key if key.is_a? Array) || keys_to_try(key)
181
+ end
182
+ end
183
+ end
@@ -0,0 +1,18 @@
1
+ Gem::Specification.new do |spec|
2
+ spec.name = "mova"
3
+ spec.version = "0.1.0"
4
+ spec.authors = ["Andrii Malyshko"]
5
+ spec.email = ["mail@nashbridges.me"]
6
+ spec.summary = "Translation and localization library"
7
+ spec.description = spec.summary
8
+ spec.homepage = "https://github.com/mova-rb/mova"
9
+ spec.license = "MIT"
10
+
11
+ spec.files = `git ls-files -z`.split("\x0")
12
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
13
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
14
+ spec.require_paths = ["lib"]
15
+
16
+ spec.add_development_dependency "bundler", "~> 1.6"
17
+ spec.add_development_dependency "rake", "~> 10.0"
18
+ end
@@ -0,0 +1,22 @@
1
+ require "test_helper"
2
+
3
+ module Mova
4
+ class AcceptanceTest < Minitest::Test
5
+ def test_get
6
+ translator = Translator.new.tap do |t|
7
+ def t.locales_to_try(current_locale)
8
+ [current_locale, :en]
9
+ end
10
+ end
11
+
12
+ translator.put(en: {global: {hello: "world"}}, de: {hi: "Hallo"})
13
+
14
+ assert_equal "Hallo", translator.get(:hi, :de)
15
+ assert_equal "world", translator.get("global.hello", :de)
16
+ assert_equal "", translator.get("global.hello", [:de])
17
+ assert_equal "world", translator.get(["hello", "global.hello"], :en)
18
+ assert_equal "", translator.get(:nothing, :en)
19
+ assert_equal "nothing", translator.get(:nothing, :en, default: "nothing")
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,60 @@
1
+ require "test_helper"
2
+ require "mova/interpolation/sprintf"
3
+
4
+ module Mova
5
+ class SprintfTest < Minitest::Test
6
+ def interpolator
7
+ @interpolator ||= Interpolation::Sprintf.new
8
+ end
9
+
10
+ def test_no_placeholders_and_no_values
11
+ assert_equal "hi there!", interpolator.call("hi there!", {})
12
+ end
13
+
14
+ def test_no_placeholders_and_values
15
+ assert_equal "hi there!", interpolator.call("hi there!", subject: "people")
16
+ end
17
+
18
+ def test_replaced_placeholders
19
+ values = {subject: "people", type: "smart"}
20
+ assert_equal "hi there, smart people!", interpolator.call("hi there, %{type} %{subject}!", values)
21
+ end
22
+
23
+ def test_placeholder_and_extra_value
24
+ assert_equal "hi there, people!", interpolator.call("hi there, %{subject}!", subject: "people", type: "smart")
25
+ end
26
+
27
+ def test_placeholder_and_missing_value
28
+ values = {type: "smart"}
29
+ expect(interpolator).to receive(:missing_placeholder).with(:subject, values, "hi there, %{subject}!").and_return("<missing value>")
30
+ assert_equal "hi there, <missing value>!", interpolator.call("hi there, %{subject}!", values)
31
+ end
32
+
33
+ def test_escaped_placeholder
34
+ assert_equal "hi there, %{subject}!", interpolator.call("hi there, %%{subject}!", subject: "people")
35
+ end
36
+
37
+ def test_activesupport_safebuffer_like_strings
38
+ substring_class = Class.new(String) do
39
+ def gsub(*args, &blk)
40
+ to_str.gsub(*args, &blk)
41
+ end
42
+ end
43
+
44
+ string = substring_class.new("hi there, %{subject}!")
45
+ assert_equal "hi there, people!", interpolator.call(string, subject: "people")
46
+ end
47
+
48
+ def test_ruby_sprintf
49
+ assert_equal "1", interpolator.call("%<num>d", num: 1)
50
+ assert_equal "0b1", interpolator.call("%<num>#b", num: 1)
51
+ assert_equal "foo", interpolator.call("%<msg>s", msg: "foo")
52
+ assert_equal "1.000000", interpolator.call("%<num>f", num: 1.0)
53
+ assert_equal " 1", interpolator.call("%<num>3.0f", num: 1.0)
54
+ assert_equal "100.00", interpolator.call("%<num>2.2f", num: 100.0)
55
+ assert_equal "0x64", interpolator.call("%<num>#x", num: 100.0)
56
+ assert_raises(ArgumentError) { interpolator.call("%<num>,d", num: 100) }
57
+ assert_raises(ArgumentError) { interpolator.call("%<num>/d", num: 100) }
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,21 @@
1
+ require "test_helper"
2
+
3
+ module Mova
4
+ class MovaTest < Minitest::Test
5
+ def test_presence_nil
6
+ assert_nil Mova.presence(nil)
7
+ end
8
+
9
+ def test_presence_empty
10
+ assert_nil Mova.presence("")
11
+ end
12
+
13
+ def test_presence_non_empty
14
+ assert_equal "hello", Mova.presence("hello")
15
+ end
16
+
17
+ def test_presence_spaces_only
18
+ assert_equal " ", Mova.presence(" ")
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,47 @@
1
+ require "test_helper"
2
+ require "mova/read_strategy/eager"
3
+
4
+ module Mova
5
+ class EagerReadStrategyTest < Minitest::Test
6
+ include Test::Doubles
7
+
8
+ def translator_class
9
+ Struct.new(:storage) do
10
+ include ReadStrategy::Eager
11
+ end
12
+ end
13
+
14
+ def translator
15
+ @translator ||= translator_class.new(storage)
16
+ end
17
+
18
+ def test_read_first
19
+ expect(storage).to receive(:read_multi).with("de.hello").and_return("de.hello" => "Hallo")
20
+ assert_equal "Hallo", translator.read_first([:de], ["hello"])
21
+ end
22
+
23
+ def test_read_first_returns_nil_when_nothing_found
24
+ expect(storage).to receive(:read_multi).with("de.hello").and_return({})
25
+ assert_nil translator.read_first([:de], ["hello"])
26
+ end
27
+
28
+ def test_read_first_fallbacks_to_next_scope
29
+ expect(storage).to receive(:read_multi).with("de.hello", "de.hi").and_return("de.hi" => "Hallo")
30
+ assert_equal "Hallo", translator.read_first([:de], ["hello", "hi"])
31
+ end
32
+
33
+ def test_read_first_fallbacks_when_empty_result_in_storage
34
+ expect(storage).to receive(:read_multi).with("de.hello", "de.hi").and_return(
35
+ "de.hello" => "", "de.hi" => "Hallo"
36
+ )
37
+ assert_equal "Hallo", translator.read_first([:de], ["hello", "hi"])
38
+ end
39
+
40
+ def test_read_first_fallbacks_to_next_locale
41
+ expect(storage).to receive(:read_multi).with("de.hello", "de.hi", "en.hello", "en.hi").and_return(
42
+ "en.hello" => "Hello", "en.hi" => "hi"
43
+ )
44
+ assert_equal "Hello", translator.read_first([:de, :en], ["hello", "hi"])
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,46 @@
1
+ require "test_helper"
2
+
3
+ module Mova
4
+ class LazyReadStrategyTest < Minitest::Test
5
+ include Test::Doubles
6
+
7
+ def translator_class
8
+ Struct.new(:storage) do
9
+ include ReadStrategy::Lazy
10
+ end
11
+ end
12
+
13
+ def translator
14
+ @translator ||= translator_class.new(storage)
15
+ end
16
+
17
+ def test_read_first
18
+ expect(storage).to receive(:read).with("de.hello") { "Hallo" }
19
+ assert_equal "Hallo", translator.read_first([:de], ["hello"])
20
+ end
21
+
22
+ def test_read_first_returns_nil_when_nothing_found
23
+ expect(storage).to receive(:read).with("de.hello") { nil }
24
+ assert_nil translator.read_first([:de], ["hello"])
25
+ end
26
+
27
+ def test_read_first_fallbacks_to_next_scope
28
+ expect(storage).to receive(:read).ordered.with("de.hello") { nil }
29
+ expect(storage).to receive(:read).ordered.with("de.hi") { "Hallo" }
30
+ assert_equal "Hallo", translator.read_first([:de], ["hello", "hi"])
31
+ end
32
+
33
+ def test_read_first_fallbacks_when_empty_result_in_storage
34
+ expect(storage).to receive(:read).ordered.with("de.hello") { "" }
35
+ expect(storage).to receive(:read).ordered.with("de.hi") { "Hallo" }
36
+ assert_equal "Hallo", translator.read_first([:de], ["hello", "hi"])
37
+ end
38
+
39
+ def test_read_first_fallbacks_to_next_locale
40
+ expect(storage).to receive(:read).ordered.with("de.hello") { nil }
41
+ expect(storage).to receive(:read).ordered.with("de.hi") { nil }
42
+ expect(storage).to receive(:read).ordered.with("en.hello") { "Hello" }
43
+ assert_equal "Hello", translator.read_first([:de, :en], ["hello", "hi"])
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,41 @@
1
+ require "test_helper"
2
+
3
+ module Mova
4
+ class ScopeTest < Minitest::Test
5
+ def test_join
6
+ assert_equal "hello.world", Scope.join("hello", "world")
7
+ end
8
+
9
+ def test_join_symbols
10
+ assert_equal "hello.world", Scope.join(:hello, :world)
11
+ end
12
+
13
+ def test_join_array
14
+ assert_equal "hello.world", Scope.join(["hello", "world"])
15
+ end
16
+
17
+ def test_split
18
+ assert_equal ["hello", "world"], Scope.split("hello.world")
19
+ end
20
+
21
+ def test_flatten_simple
22
+ expected = {"hello" => "world"}
23
+ assert_equal expected, Scope.flatten(hello: "world")
24
+ end
25
+
26
+ def test_flatten_with_one_root
27
+ expected = {"en.foo" => "bar", "en.inner.foo" => "bar"}
28
+ assert_equal expected, Scope.flatten(en: {foo: "bar", inner: {foo: "bar"}})
29
+ end
30
+
31
+ def test_flatten_with_multiple_roots
32
+ expected = {"en.foo" => "bar", "ru.foo" => "bar"}
33
+ assert_equal expected, Scope.flatten(en: {foo: "bar"}, ru: {foo: "bar"})
34
+ end
35
+
36
+ def test_cross_join
37
+ expected = ["de.hello", "de.hi", "en.hello", "en.hi"]
38
+ assert_equal expected, Scope.cross_join([:de, :en], ["hello", "hi"])
39
+ end
40
+ end
41
+ end