mova 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.
@@ -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