message_format 0.0.1
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.
- checksums.yaml +7 -0
- data/.gitignore +40 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +22 -0
- data/README.md +47 -0
- data/Rakefile +2 -0
- data/benchmark.rb +73 -0
- data/lib/message_format.rb +30 -0
- data/lib/message_format/interpreter.rb +171 -0
- data/lib/message_format/parser.rb +387 -0
- data/lib/message_format/version.rb +3 -0
- data/message_format.gemspec +24 -0
- data/spec/message_format_spec.rb +131 -0
- data/spec/spec_helper.rb +1 -0
- metadata +104 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: e901569363c40dffd88d1690540f7a2bddc5aa5c
|
4
|
+
data.tar.gz: 64bbb73e1cfc027ec033e59a068b532763ac4d3d
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: ec37a34fe69cdf69069d6a4ae36950611ed64827577230ed8c9be6c0267ad1dbb553a2cc295c91908cae9ebb384629363a7becd1304ca58dc74b8a63a05fdc40
|
7
|
+
data.tar.gz: c29c673fb869551da456265b6ba2562c9884f1f11fd65f7a4af934e3a02daf8c64d8255a77c2e4ec3cee204739b2c0ab814c9e3cae278597324fe413be604f59
|
data/.gitignore
ADDED
@@ -0,0 +1,40 @@
|
|
1
|
+
# ignore system files
|
2
|
+
|
3
|
+
# OS X
|
4
|
+
.DS_Store
|
5
|
+
.Spotlight-V100
|
6
|
+
.Trashes
|
7
|
+
._*
|
8
|
+
|
9
|
+
# Win
|
10
|
+
Thumbs.db
|
11
|
+
Desktop.ini
|
12
|
+
|
13
|
+
# vim
|
14
|
+
*~
|
15
|
+
.swp
|
16
|
+
.*.sw[a-z]
|
17
|
+
Session.vim
|
18
|
+
|
19
|
+
*.gem
|
20
|
+
*.rbc
|
21
|
+
.bundle
|
22
|
+
.config
|
23
|
+
.yardoc
|
24
|
+
Gemfile.lock
|
25
|
+
InstalledFiles
|
26
|
+
_yardoc
|
27
|
+
coverage
|
28
|
+
doc/
|
29
|
+
lib/bundler/man
|
30
|
+
pkg
|
31
|
+
rdoc
|
32
|
+
spec/reports
|
33
|
+
test/tmp
|
34
|
+
test/version_tmp
|
35
|
+
tmp
|
36
|
+
*.bundle
|
37
|
+
*.so
|
38
|
+
*.o
|
39
|
+
*.a
|
40
|
+
mkmf.log
|
data/Gemfile
ADDED
data/LICENSE.txt
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
Copyright (c) 2015 Andy VanWagoner
|
2
|
+
|
3
|
+
MIT License
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
6
|
+
a copy of this software and associated documentation files (the
|
7
|
+
"Software"), to deal in the Software without restriction, including
|
8
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
9
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
10
|
+
permit persons to whom the Software is furnished to do so, subject to
|
11
|
+
the following conditions:
|
12
|
+
|
13
|
+
The above copyright notice and this permission notice shall be
|
14
|
+
included in all copies or substantial portions of the Software.
|
15
|
+
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
17
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
18
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
19
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
20
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
21
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
22
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,47 @@
|
|
1
|
+
# MessageFormat
|
2
|
+
|
3
|
+
Parse and format i18n messages using ICU MessageFormat patterns
|
4
|
+
|
5
|
+
## Installation
|
6
|
+
|
7
|
+
Add this line to your application's Gemfile:
|
8
|
+
|
9
|
+
gem 'message_format'
|
10
|
+
|
11
|
+
And then execute:
|
12
|
+
|
13
|
+
$ bundle
|
14
|
+
|
15
|
+
Or install it yourself as:
|
16
|
+
|
17
|
+
$ gem install message_format
|
18
|
+
|
19
|
+
## Usage
|
20
|
+
|
21
|
+
```ruby
|
22
|
+
require 'message_format'
|
23
|
+
|
24
|
+
message = MessageFormat.new('Hello { place }!', 'en-US')
|
25
|
+
formatted = message.format({ :place => 'World' })
|
26
|
+
```
|
27
|
+
|
28
|
+
The [ICU Message Format][icu-message] is a great format for user-visible strings, and includes simple placeholders, number and date placeholders, and selecting among submessages for gender and plural arguments. The format is used in apis in [C++][icu-cpp], [PHP][icu-php], [Java][icu-java], and [JavaScript][icu-javascript].
|
29
|
+
|
30
|
+
## Contributing
|
31
|
+
|
32
|
+
1. Fork it ( https://github.com/thetalecrafter/message-format-rb/fork )
|
33
|
+
2. Create your feature branch (`git checkout -b my-new-feature`)
|
34
|
+
3. Commit your changes (`git commit -am 'Add some feature'`)
|
35
|
+
4. Push to the branch (`git push origin my-new-feature`)
|
36
|
+
5. Create a new Pull Request
|
37
|
+
|
38
|
+
## License
|
39
|
+
|
40
|
+
This software is free to use under the MIT license. See the [LICENSE.txt file][LICENSE] for license text and copyright information.
|
41
|
+
|
42
|
+
[icu-message]: http://userguide.icu-project.org/formatparse/messages
|
43
|
+
[icu-cpp]: http://icu-project.org/apiref/icu4c/classicu_1_1MessageFormat.html
|
44
|
+
[icu-php]: http://php.net/manual/en/class.messageformatter.php
|
45
|
+
[icu-java]: http://icu-project.org/apiref/icu4j/
|
46
|
+
[icu-javascript]: https://github.com/thetalecrafter/message-format
|
47
|
+
[LICENSE]: https://github.com/thetalecrafter/message-format-rb/blob/master/LICENSE.txt
|
data/Rakefile
ADDED
data/benchmark.rb
ADDED
@@ -0,0 +1,73 @@
|
|
1
|
+
require 'benchmark'
|
2
|
+
require_relative 'lib/message_format'
|
3
|
+
|
4
|
+
iterations = 100_000
|
5
|
+
|
6
|
+
Benchmark.bm do |bm|
|
7
|
+
bm.report('parse simple message') do
|
8
|
+
parser = MessageFormat::Parser.new()
|
9
|
+
iterations.times do
|
10
|
+
parser.parse("I'm a super simple message")
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
bm.report('format simple message') do
|
15
|
+
message = MessageFormat.new("I'm a super simple message")
|
16
|
+
iterations.times do
|
17
|
+
message.format()
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
bm.report('parse one arg message') do
|
22
|
+
parser = MessageFormat::Parser.new()
|
23
|
+
iterations.times do
|
24
|
+
parser.parse("I'm a { arg } message")
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
bm.report('format one arg message') do
|
29
|
+
message = MessageFormat.new("I'm a { arg } message")
|
30
|
+
iterations.times do
|
31
|
+
message.format({ :arg => 'awesome' })
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
bm.report('parse complex message') do
|
36
|
+
parser = MessageFormat::Parser.new()
|
37
|
+
iterations.times do
|
38
|
+
parser.parse('On {day, date, short} {
|
39
|
+
count, plural, offset:1
|
40
|
+
=0 {nobody carpooled.}
|
41
|
+
=1 {{driverName} drove {
|
42
|
+
driverGender, select,
|
43
|
+
male {himself}
|
44
|
+
female {herself}
|
45
|
+
other {themself}
|
46
|
+
}.}
|
47
|
+
other {{driverName} drove # people.}
|
48
|
+
}')
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
bm.report('format complex message') do
|
53
|
+
message = MessageFormat.new('On {day, date, short} {
|
54
|
+
count, plural, offset:1
|
55
|
+
=0 {nobody carpooled.}
|
56
|
+
=1 {{driverName} drove {
|
57
|
+
driverGender, select,
|
58
|
+
male {himself}
|
59
|
+
female {herself}
|
60
|
+
other {themself}
|
61
|
+
}.}
|
62
|
+
other {{driverName} drove # people.}
|
63
|
+
}')
|
64
|
+
iterations.times do
|
65
|
+
message.format({
|
66
|
+
:day => DateTime.now,
|
67
|
+
:count => 5,
|
68
|
+
:driverName => 'Jeremy',
|
69
|
+
:driverGender => 'male'
|
70
|
+
})
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
require 'twitter_cldr'
|
2
|
+
require_relative 'message_format/version'
|
3
|
+
require_relative 'message_format/parser'
|
4
|
+
require_relative 'message_format/interpreter'
|
5
|
+
|
6
|
+
module MessageFormat
|
7
|
+
class MessageFormat
|
8
|
+
|
9
|
+
def initialize ( pattern, locale=nil )
|
10
|
+
@locale = (locale || TwitterCldr.locale).to_sym
|
11
|
+
@format = Interpreter.interpret(
|
12
|
+
Parser.parse(pattern),
|
13
|
+
{ :locale => @locale }
|
14
|
+
)
|
15
|
+
end
|
16
|
+
|
17
|
+
def format ( args=nil )
|
18
|
+
return @format.call(args)
|
19
|
+
end
|
20
|
+
|
21
|
+
end
|
22
|
+
|
23
|
+
class << self
|
24
|
+
|
25
|
+
def new ( pattern, locale=nil )
|
26
|
+
return MessageFormat.new(pattern, locale)
|
27
|
+
end
|
28
|
+
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,171 @@
|
|
1
|
+
require 'twitter_cldr'
|
2
|
+
|
3
|
+
#
|
4
|
+
# Interpreter
|
5
|
+
#
|
6
|
+
# Turns this:
|
7
|
+
# [ "You have ", [ "numBananas", "plural", 0, {
|
8
|
+
# "=0": [ "no bananas" ],
|
9
|
+
# "one": [ "a banana" ],
|
10
|
+
# "other": [ [ '#' ], " bananas" ]
|
11
|
+
# } ], " for sale." ]
|
12
|
+
#
|
13
|
+
# into this:
|
14
|
+
# format({ numBananas:0 })
|
15
|
+
# "You have no bananas for sale."
|
16
|
+
#
|
17
|
+
module MessageFormat
|
18
|
+
class Interpreter
|
19
|
+
|
20
|
+
def initialize ( options=nil )
|
21
|
+
if options and options.has_key?(:locale)
|
22
|
+
@originalLocale = options[:locale]
|
23
|
+
else
|
24
|
+
@originalLocale = TwitterCldr.locale
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
def interpret ( elements )
|
29
|
+
return interpretSubs(elements)
|
30
|
+
end
|
31
|
+
|
32
|
+
def interpretSubs ( elements, parent=nil )
|
33
|
+
elements = elements.map do |element|
|
34
|
+
interpretElement(element, parent)
|
35
|
+
end
|
36
|
+
|
37
|
+
# optimize common case
|
38
|
+
if elements.length == 1
|
39
|
+
return elements[0]
|
40
|
+
end
|
41
|
+
|
42
|
+
return lambda do |args|
|
43
|
+
message = ''
|
44
|
+
elements.map do |element|
|
45
|
+
message += element.call(args)
|
46
|
+
end
|
47
|
+
return message
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
def interpretElement ( element, parent=nil )
|
52
|
+
if element.is_a?(String)
|
53
|
+
return lambda { |args=nil| return element }
|
54
|
+
end
|
55
|
+
|
56
|
+
id, type, style = element
|
57
|
+
offset = 0
|
58
|
+
|
59
|
+
if id == '#'
|
60
|
+
id = parent[0]
|
61
|
+
type = 'number'
|
62
|
+
offset = parent[2] || 0
|
63
|
+
style = nil
|
64
|
+
end
|
65
|
+
|
66
|
+
id = id.to_sym # actual arguments should always be keyed by symbols
|
67
|
+
|
68
|
+
case type
|
69
|
+
when 'number'
|
70
|
+
return interpretNumber(id, offset, style)
|
71
|
+
when 'date', 'time'
|
72
|
+
return interpretDateTime(id, type, style)
|
73
|
+
when 'plural', 'selectordinal'
|
74
|
+
offset = element[2]
|
75
|
+
options = element[3]
|
76
|
+
return interpretPlural(id, type, offset, options)
|
77
|
+
when 'select'
|
78
|
+
return interpretSelect(id, style)
|
79
|
+
when 'spellout', 'ordinal', 'duration'
|
80
|
+
return interpretNumber(id, offset, type)
|
81
|
+
else
|
82
|
+
return interpretSimple(id)
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
def interpretNumber ( id, offset, style )
|
87
|
+
locale = @originalLocale
|
88
|
+
return lambda do |args|
|
89
|
+
number = TwitterCldr::Localized::LocalizedNumber.new(args[id] - offset, locale)
|
90
|
+
if style == 'integer'
|
91
|
+
return number.to_decimal(:precision => 0).to_s
|
92
|
+
elsif style == 'percent'
|
93
|
+
return number.to_percent.to_s
|
94
|
+
elsif style == 'currency'
|
95
|
+
return number.to_currency.to_s
|
96
|
+
elsif style == 'spellout'
|
97
|
+
return number.spellout
|
98
|
+
elsif style == 'ordinal'
|
99
|
+
return number.to_rbnf_s('OrdinalRules', 'digits-ordinal')
|
100
|
+
else
|
101
|
+
return number.to_s
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
def interpretDateTime ( id, type, style='medium' )
|
107
|
+
locale = @originalLocale
|
108
|
+
return lambda do |args|
|
109
|
+
datetime = TwitterCldr::Localized::LocalizedDateTime.new(args[id], locale)
|
110
|
+
datetime = type == 'date' ? datetime.to_date : datetime.to_time
|
111
|
+
if style == 'medium'
|
112
|
+
return datetime.to_medium_s
|
113
|
+
elsif style == 'long'
|
114
|
+
return datetime.to_long_s
|
115
|
+
elsif style == 'short'
|
116
|
+
return datetime.to_short_s
|
117
|
+
elsif style == 'full'
|
118
|
+
return datetime.to_full_s
|
119
|
+
else
|
120
|
+
return datetime.to_additional_s(style)
|
121
|
+
end
|
122
|
+
end
|
123
|
+
end
|
124
|
+
|
125
|
+
def interpretPlural ( id, type, offset, children )
|
126
|
+
parent = [ id, type, offset ]
|
127
|
+
options = {}
|
128
|
+
children.each do |key, value|
|
129
|
+
options[key.to_sym] = interpretSubs(value, parent)
|
130
|
+
end
|
131
|
+
|
132
|
+
locale = @originalLocale
|
133
|
+
pluralType = type == 'selectordinal' ? :ordinal : :cardinal
|
134
|
+
return lambda do |args|
|
135
|
+
arg = args[id]
|
136
|
+
exactSelector = ('=' + arg.to_s).to_sym
|
137
|
+
keywordSelector = TwitterCldr::Formatters::Plurals::Rules.rule_for(arg - offset, locale, pluralType)
|
138
|
+
func =
|
139
|
+
options[exactSelector] ||
|
140
|
+
options[keywordSelector] ||
|
141
|
+
options[:other]
|
142
|
+
return func.call(args)
|
143
|
+
end
|
144
|
+
end
|
145
|
+
|
146
|
+
def interpretSelect ( id, children )
|
147
|
+
options = {}
|
148
|
+
children.each do |key, value|
|
149
|
+
options[key.to_sym] = interpretSubs(value, nil)
|
150
|
+
end
|
151
|
+
return lambda do |args|
|
152
|
+
selector = args[id].to_sym
|
153
|
+
func =
|
154
|
+
options[selector] ||
|
155
|
+
options[:other]
|
156
|
+
return func.call(args)
|
157
|
+
end
|
158
|
+
end
|
159
|
+
|
160
|
+
def interpretSimple ( id )
|
161
|
+
return lambda do |args|
|
162
|
+
return args[id].to_s
|
163
|
+
end
|
164
|
+
end
|
165
|
+
|
166
|
+
def self.interpret ( elements, options=nil )
|
167
|
+
return Interpreter.new(options).interpret(elements)
|
168
|
+
end
|
169
|
+
|
170
|
+
end
|
171
|
+
end
|
@@ -0,0 +1,387 @@
|
|
1
|
+
#
|
2
|
+
# Parser
|
3
|
+
#
|
4
|
+
# Turns this:
|
5
|
+
# `You have { numBananas, plural,
|
6
|
+
# =0 {no bananas}
|
7
|
+
# one {a banana}
|
8
|
+
# other {# bananas}
|
9
|
+
# } for sale`
|
10
|
+
#
|
11
|
+
# into this:
|
12
|
+
# [ "You have ", [ "numBananas", "plural", 0, {
|
13
|
+
# "=0": [ "no bananas" ],
|
14
|
+
# "one": [ "a banana" ],
|
15
|
+
# "other": [ [ '#' ], " bananas" ]
|
16
|
+
# } ], " for sale." ]
|
17
|
+
#
|
18
|
+
module MessageFormat
|
19
|
+
class Parser
|
20
|
+
|
21
|
+
def initialize ()
|
22
|
+
@pattern = nil
|
23
|
+
@length = 0
|
24
|
+
@index = 0
|
25
|
+
end
|
26
|
+
|
27
|
+
def parse ( pattern )
|
28
|
+
if !pattern.is_a?(String)
|
29
|
+
throwExpected('String pattern', pattern.class.to_s)
|
30
|
+
end
|
31
|
+
|
32
|
+
@pattern = pattern
|
33
|
+
@length = pattern.length
|
34
|
+
@index = 0
|
35
|
+
return parseMessage("message")
|
36
|
+
end
|
37
|
+
|
38
|
+
def isDigit ( char )
|
39
|
+
return (
|
40
|
+
char == '0' or
|
41
|
+
char == '1' or
|
42
|
+
char == '2' or
|
43
|
+
char == '3' or
|
44
|
+
char == '4' or
|
45
|
+
char == '5' or
|
46
|
+
char == '6' or
|
47
|
+
char == '7' or
|
48
|
+
char == '8' or
|
49
|
+
char == '9'
|
50
|
+
)
|
51
|
+
end
|
52
|
+
|
53
|
+
def isWhitespace ( char )
|
54
|
+
return (
|
55
|
+
char == "\s" or
|
56
|
+
char == "\t" or
|
57
|
+
char == "\n" or
|
58
|
+
char == "\r" or
|
59
|
+
char == "\f" or
|
60
|
+
char == "\v" or
|
61
|
+
char == "\u00A0" or
|
62
|
+
char == "\u2028" or
|
63
|
+
char == "\u2029"
|
64
|
+
)
|
65
|
+
end
|
66
|
+
|
67
|
+
def skipWhitespace ()
|
68
|
+
while @index < @length and isWhitespace(@pattern[@index])
|
69
|
+
@index += 1
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
def parseText ( parentType )
|
74
|
+
isHashSpecial = (parentType == 'plural' or parentType == 'selectordinal')
|
75
|
+
isArgStyle = (parentType == 'style')
|
76
|
+
text = ''
|
77
|
+
while @index < @length
|
78
|
+
char = @pattern[@index]
|
79
|
+
if (
|
80
|
+
char == '{' or
|
81
|
+
char == '}' or
|
82
|
+
(isHashSpecial and char == '#') or
|
83
|
+
(isArgStyle and isWhitespace(char))
|
84
|
+
)
|
85
|
+
break
|
86
|
+
elsif char == '\''
|
87
|
+
@index += 1
|
88
|
+
char = @pattern[@index]
|
89
|
+
if char == '\'' # double is always 1 '
|
90
|
+
text += char
|
91
|
+
@index += 1
|
92
|
+
elsif (
|
93
|
+
# only when necessary
|
94
|
+
char == '{' or
|
95
|
+
char == '}' or
|
96
|
+
(isHashSpecial and char == '#') or
|
97
|
+
(isArgStyle and isWhitespace(char))
|
98
|
+
)
|
99
|
+
text += char
|
100
|
+
while @index + 1 < @length
|
101
|
+
@index += 1
|
102
|
+
char = @pattern[@index]
|
103
|
+
if @pattern.slice(@index, 2) == '\'\'' # double is always 1 '
|
104
|
+
text += char
|
105
|
+
@index += 1
|
106
|
+
elsif char == '\'' # end of quoted
|
107
|
+
@index += 1
|
108
|
+
break
|
109
|
+
else
|
110
|
+
text += char
|
111
|
+
end
|
112
|
+
end
|
113
|
+
else # lone ' is just a '
|
114
|
+
text += '\''
|
115
|
+
# already incremented
|
116
|
+
end
|
117
|
+
else
|
118
|
+
text += char
|
119
|
+
@index += 1
|
120
|
+
end
|
121
|
+
end
|
122
|
+
|
123
|
+
return text
|
124
|
+
end
|
125
|
+
|
126
|
+
def parseArgument ()
|
127
|
+
if @pattern[@index] == '#'
|
128
|
+
@index += 1 # move passed #
|
129
|
+
return [ '#' ]
|
130
|
+
end
|
131
|
+
|
132
|
+
@index += 1 # move passed {
|
133
|
+
id = parseArgId()
|
134
|
+
char = @pattern[@index]
|
135
|
+
if char == '}' # end argument
|
136
|
+
@index += 1 # move passed }
|
137
|
+
return [ id ]
|
138
|
+
end
|
139
|
+
if char != ','
|
140
|
+
throwExpected(',')
|
141
|
+
end
|
142
|
+
@index += 1 # move passed ,
|
143
|
+
|
144
|
+
type = parseArgType()
|
145
|
+
char = @pattern[@index]
|
146
|
+
if char == '}' # end argument
|
147
|
+
if (
|
148
|
+
type == 'plural' or
|
149
|
+
type == 'selectordinal' or
|
150
|
+
type == 'select'
|
151
|
+
)
|
152
|
+
throwExpected(type + ' message options')
|
153
|
+
end
|
154
|
+
@index += 1 # move passed }
|
155
|
+
return [ id, type ]
|
156
|
+
end
|
157
|
+
if char != ','
|
158
|
+
throwExpected(',')
|
159
|
+
end
|
160
|
+
@index += 1 # move passed ,
|
161
|
+
|
162
|
+
format = nil
|
163
|
+
offset = nil
|
164
|
+
if type == 'plural' or type == 'selectordinal'
|
165
|
+
offset = parsePluralOffset()
|
166
|
+
format = parseSubMessages(type)
|
167
|
+
elsif type == 'select'
|
168
|
+
format = parseSubMessages(type)
|
169
|
+
else
|
170
|
+
format = parseSimpleFormat()
|
171
|
+
end
|
172
|
+
char = @pattern[@index]
|
173
|
+
if char != '}' # not ended argument
|
174
|
+
throwExpected('}')
|
175
|
+
end
|
176
|
+
@index += 1 # move passed
|
177
|
+
|
178
|
+
return (type == 'plural' or type == 'selectordinal') ?
|
179
|
+
[ id, type, offset, format ] :
|
180
|
+
[ id, type, format ]
|
181
|
+
end
|
182
|
+
|
183
|
+
def parseArgId ()
|
184
|
+
skipWhitespace()
|
185
|
+
id = ''
|
186
|
+
while @index < @length
|
187
|
+
char = @pattern[@index]
|
188
|
+
if char == '{' or char == '#'
|
189
|
+
throwExpected('argument id')
|
190
|
+
end
|
191
|
+
if char == '}' or char == ',' or isWhitespace(char)
|
192
|
+
break
|
193
|
+
end
|
194
|
+
id += char
|
195
|
+
@index += 1
|
196
|
+
end
|
197
|
+
if id.empty?
|
198
|
+
throwExpected('argument id')
|
199
|
+
end
|
200
|
+
skipWhitespace()
|
201
|
+
return id
|
202
|
+
end
|
203
|
+
|
204
|
+
def parseArgType ()
|
205
|
+
skipWhitespace()
|
206
|
+
argType = nil
|
207
|
+
types = [
|
208
|
+
'number', 'date', 'time', 'ordinal', 'duration', 'spellout', 'plural', 'selectordinal', 'select'
|
209
|
+
]
|
210
|
+
types.each do |type|
|
211
|
+
if @pattern.slice(@index, type.length) == type
|
212
|
+
argType = type
|
213
|
+
@index += type.length
|
214
|
+
break
|
215
|
+
end
|
216
|
+
end
|
217
|
+
if !argType
|
218
|
+
throwExpected(types.join(', '))
|
219
|
+
end
|
220
|
+
skipWhitespace()
|
221
|
+
return argType
|
222
|
+
end
|
223
|
+
|
224
|
+
def parseSimpleFormat ()
|
225
|
+
skipWhitespace()
|
226
|
+
style = parseText('style')
|
227
|
+
if style.empty?
|
228
|
+
throwExpected('argument style name')
|
229
|
+
end
|
230
|
+
skipWhitespace()
|
231
|
+
return style
|
232
|
+
end
|
233
|
+
|
234
|
+
def parsePluralOffset ()
|
235
|
+
skipWhitespace()
|
236
|
+
offset = 0
|
237
|
+
if @pattern.slice(@index, 7) == 'offset:'
|
238
|
+
@index += 7 # move passed offset:
|
239
|
+
skipWhitespace()
|
240
|
+
start = @index
|
241
|
+
while (
|
242
|
+
@index < @length and
|
243
|
+
isDigit(@pattern[@index])
|
244
|
+
)
|
245
|
+
@index += 1
|
246
|
+
end
|
247
|
+
if start == @index
|
248
|
+
throwExpected('offset number')
|
249
|
+
end
|
250
|
+
offset = @pattern[start..@index].to_i
|
251
|
+
skipWhitespace()
|
252
|
+
end
|
253
|
+
return offset
|
254
|
+
end
|
255
|
+
|
256
|
+
def parseSubMessages ( parentType )
|
257
|
+
skipWhitespace()
|
258
|
+
options = {}
|
259
|
+
hasSubs = false
|
260
|
+
while (
|
261
|
+
@index < @length and
|
262
|
+
@pattern[@index] != '}'
|
263
|
+
)
|
264
|
+
selector = parseSelector()
|
265
|
+
skipWhitespace()
|
266
|
+
options[selector] = parseSubMessage(parentType)
|
267
|
+
hasSubs = true
|
268
|
+
skipWhitespace()
|
269
|
+
end
|
270
|
+
if !hasSubs
|
271
|
+
throwExpected(parentType + ' message options')
|
272
|
+
end
|
273
|
+
if !options.has_key?('other') # does not have an other selector
|
274
|
+
throwExpected(nil, nil, '"other" option must be specified in ' + parentType)
|
275
|
+
end
|
276
|
+
return options
|
277
|
+
end
|
278
|
+
|
279
|
+
def parseSelector ()
|
280
|
+
selector = ''
|
281
|
+
while @index < @length
|
282
|
+
char = @pattern[@index]
|
283
|
+
if char == '}' or char == ','
|
284
|
+
throwExpected('{')
|
285
|
+
end
|
286
|
+
if char == '{' or isWhitespace(char)
|
287
|
+
break
|
288
|
+
end
|
289
|
+
selector += char
|
290
|
+
@index += 1
|
291
|
+
end
|
292
|
+
if selector.empty?
|
293
|
+
throwExpected('selector')
|
294
|
+
end
|
295
|
+
skipWhitespace()
|
296
|
+
return selector
|
297
|
+
end
|
298
|
+
|
299
|
+
def parseSubMessage ( parentType )
|
300
|
+
char = @pattern[@index]
|
301
|
+
if char != '{'
|
302
|
+
throwExpected('{')
|
303
|
+
end
|
304
|
+
@index += 1 # move passed {
|
305
|
+
message = parseMessage(parentType)
|
306
|
+
char = @pattern[@index]
|
307
|
+
if char != '}'
|
308
|
+
throwExpected('}')
|
309
|
+
end
|
310
|
+
@index += 1 # move passed }
|
311
|
+
return message
|
312
|
+
end
|
313
|
+
|
314
|
+
def parseMessage ( parentType )
|
315
|
+
elements = []
|
316
|
+
text = parseText(parentType)
|
317
|
+
if !text.empty?
|
318
|
+
elements.push(text)
|
319
|
+
end
|
320
|
+
while @index < @length
|
321
|
+
if @pattern[@index] == '}'
|
322
|
+
if parentType == 'message'
|
323
|
+
throwExpected()
|
324
|
+
end
|
325
|
+
break
|
326
|
+
end
|
327
|
+
elements.push(parseArgument())
|
328
|
+
text = parseText(parentType)
|
329
|
+
if !text.empty?
|
330
|
+
elements.push(text)
|
331
|
+
end
|
332
|
+
end
|
333
|
+
return elements
|
334
|
+
end
|
335
|
+
|
336
|
+
def throwExpected ( expected=nil, found=nil, message=nil )
|
337
|
+
lines = @pattern[0..@index].split(/\r?\n/)
|
338
|
+
line = lines.length
|
339
|
+
column = lines.last.length
|
340
|
+
if !found
|
341
|
+
found = @index < @length ? @pattern[@index] : 'end of input'
|
342
|
+
end
|
343
|
+
if !message
|
344
|
+
message = errorMessage(expected, found)
|
345
|
+
end
|
346
|
+
message += ' in "' + @pattern.gsub(/\r?\n/, "\n") + '"'
|
347
|
+
|
348
|
+
raise SyntaxError.new(message, expected, found, @index, line, column)
|
349
|
+
end
|
350
|
+
|
351
|
+
def errorMessage ( expected=nil, found )
|
352
|
+
if !expected
|
353
|
+
return "Unexpected \"#{ found }\" found"
|
354
|
+
end
|
355
|
+
return "Expected \"#{ expected }\" but found \"#{ found }\""
|
356
|
+
end
|
357
|
+
|
358
|
+
def self.parse ( pattern )
|
359
|
+
return Parser.new().parse(pattern)
|
360
|
+
end
|
361
|
+
|
362
|
+
#
|
363
|
+
# Syntax Error
|
364
|
+
# Holds information about bad syntax found in a message pattern
|
365
|
+
#
|
366
|
+
class SyntaxError < StandardError
|
367
|
+
|
368
|
+
attr_reader :message
|
369
|
+
attr_reader :expected
|
370
|
+
attr_reader :found
|
371
|
+
attr_reader :offset
|
372
|
+
attr_reader :line
|
373
|
+
attr_reader :column
|
374
|
+
|
375
|
+
def initialize (message, expected, found, offset, line, column)
|
376
|
+
@message = message
|
377
|
+
@expected = expected
|
378
|
+
@found = found
|
379
|
+
@offset = offset
|
380
|
+
@line = line
|
381
|
+
@column = column
|
382
|
+
end
|
383
|
+
|
384
|
+
end
|
385
|
+
|
386
|
+
end
|
387
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require 'message_format/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |spec|
|
7
|
+
spec.name = "message_format"
|
8
|
+
spec.version = MessageFormat::VERSION
|
9
|
+
spec.authors = ["Andy VanWagoner"]
|
10
|
+
spec.email = ["andy@instructure.com"]
|
11
|
+
spec.summary = %q{Parse and format i18n messages using ICU MessageFormat patterns}
|
12
|
+
spec.description = %q{Parse and format i18n messages using ICU MessageFormat patterns, including simple placeholders, number and date placeholders, and selecting among submessages for gender and plural arguments.}
|
13
|
+
spec.homepage = "https://github.com/thetalecrafter/message-format-rb"
|
14
|
+
spec.license = "MIT"
|
15
|
+
|
16
|
+
spec.files = `git ls-files -z`.split("\x0")
|
17
|
+
spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
|
18
|
+
spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
|
19
|
+
spec.require_paths = ["lib"]
|
20
|
+
|
21
|
+
spec.add_runtime_dependency "twitter_cldr", "~> 3.1"
|
22
|
+
spec.add_development_dependency "bundler", "~> 1.6"
|
23
|
+
spec.add_development_dependency "rake", "~> 10.0"
|
24
|
+
end
|
@@ -0,0 +1,131 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe MessageFormat do
|
4
|
+
describe '#new' do
|
5
|
+
it 'throws an error on bad syntax' do
|
6
|
+
expect { MessageFormat.new({}) }.to raise_error
|
7
|
+
expect { MessageFormat.new('no finish arg {') }.to raise_error
|
8
|
+
expect { MessageFormat.new('no start arg }') }.to raise_error
|
9
|
+
expect { MessageFormat.new('empty arg {}') }.to raise_error
|
10
|
+
expect { MessageFormat.new('unfinished select { a, select }') }.to raise_error
|
11
|
+
expect { MessageFormat.new('unfinished select { a, select, }') }.to raise_error
|
12
|
+
expect { MessageFormat.new('sub with no selector { a, select, {hi} }') }.to raise_error
|
13
|
+
expect { MessageFormat.new('sub with no other { a, select, foo {hi} }') }.to raise_error
|
14
|
+
expect { MessageFormat.new('wrong escape \\{') }.to raise_error
|
15
|
+
expect { MessageFormat.new('wrong escape \'{\'', 'en', { escape: '\\' }) }.to raise_error
|
16
|
+
expect { MessageFormat.new('bad arg type { a, bogus, nope }') }.to raise_error
|
17
|
+
expect { MessageFormat.new('bad arg separator { a bogus, nope }') }.to raise_error
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
describe '#format' do
|
22
|
+
it 'formats a simple message' do
|
23
|
+
pattern = 'Simple string with nothing special'
|
24
|
+
message = MessageFormat.new(pattern, 'en-US').format()
|
25
|
+
|
26
|
+
expect(message).to eql('Simple string with nothing special')
|
27
|
+
end
|
28
|
+
|
29
|
+
it 'handles pattern with escaped text' do
|
30
|
+
pattern = 'This isn\'\'t a \'{\'\'simple\'\'}\' \'string\''
|
31
|
+
message = MessageFormat.new(pattern, 'en-US').format()
|
32
|
+
|
33
|
+
expect(message).to eql('This isn\'t a {\'simple\'} \'string\'')
|
34
|
+
end
|
35
|
+
|
36
|
+
it 'accepts arguments' do
|
37
|
+
pattern = 'x{ arg }z'
|
38
|
+
message = MessageFormat.new(pattern, 'en-US').format({ :arg => 'y' })
|
39
|
+
|
40
|
+
expect(message).to eql('xyz')
|
41
|
+
end
|
42
|
+
|
43
|
+
it 'formats numbers, dates, and times' do
|
44
|
+
pattern = '{ n, number } : { d, date, short } { d, time, short }'
|
45
|
+
message = MessageFormat.new(pattern, 'en-US').format({ :n => 0, :d => DateTime.new(0) })
|
46
|
+
|
47
|
+
expect(message).to match(/^0 \: \d\d?\/\d\d?\/\d{2,4} \d\d?\:\d\d [AP]M$/)
|
48
|
+
end
|
49
|
+
|
50
|
+
it 'handles plurals' do
|
51
|
+
pattern =
|
52
|
+
'On {takenDate, date, short} {name} {numPeople, plural, offset:1
|
53
|
+
=0 {didn\'t carpool.}
|
54
|
+
=1 {drove himself.}
|
55
|
+
other {drove # people.}}'
|
56
|
+
message = MessageFormat.new(pattern, 'en-US')
|
57
|
+
.format({ :takenDate => DateTime.now, :name => 'Bob', :numPeople => 5 })
|
58
|
+
|
59
|
+
expect(message).to match(/^On \d\d?\/\d\d?\/\d{2,4} Bob drove 4 people.$/)
|
60
|
+
end
|
61
|
+
|
62
|
+
it 'handles plurals for other locales' do
|
63
|
+
pattern =
|
64
|
+
'{n, plural,
|
65
|
+
zero {zero}
|
66
|
+
one {one}
|
67
|
+
two {two}
|
68
|
+
few {few}
|
69
|
+
many {many}
|
70
|
+
other {other}}'
|
71
|
+
message = MessageFormat.new(pattern, 'ar')
|
72
|
+
|
73
|
+
expect(message.format({ n: 0 })).to eql('zero')
|
74
|
+
expect(message.format({ n: 1 })).to eql('one')
|
75
|
+
expect(message.format({ n: 2 })).to eql('two')
|
76
|
+
expect(message.format({ n: 3 })).to eql('few')
|
77
|
+
expect(message.format({ n: 11 })).to eql('many')
|
78
|
+
end
|
79
|
+
|
80
|
+
it 'handles selectordinals' do
|
81
|
+
pattern =
|
82
|
+
'{n, selectordinal,
|
83
|
+
one {#st}
|
84
|
+
two {#nd}
|
85
|
+
few {#rd}
|
86
|
+
other {#th}}'
|
87
|
+
message = MessageFormat.new(pattern, 'en')
|
88
|
+
|
89
|
+
expect(message.format({ n: 1 })).to eql('1st')
|
90
|
+
expect(message.format({ n: 22 })).to eql('22nd')
|
91
|
+
expect(message.format({ n: 103 })).to eql('103rd')
|
92
|
+
expect(message.format({ n: 4 })).to eql('4th')
|
93
|
+
end
|
94
|
+
|
95
|
+
it 'handles select' do
|
96
|
+
pattern =
|
97
|
+
'{ gender, select,
|
98
|
+
male {it\'s his turn}
|
99
|
+
female {it\'s her turn}
|
100
|
+
other {it\'s their turn}}'
|
101
|
+
message = MessageFormat.new(pattern, 'en-US')
|
102
|
+
.format({ gender: 'female' })
|
103
|
+
|
104
|
+
expect(message).to eql('it\'s her turn')
|
105
|
+
end
|
106
|
+
|
107
|
+
it 'should throw an error when args are expected and not passed' do
|
108
|
+
expect { MessageFormat.new('{a}').format() }.to raise_error
|
109
|
+
end
|
110
|
+
end
|
111
|
+
|
112
|
+
describe 'locales' do
|
113
|
+
it 'doesn\'t throw for any locale\'s plural function' do
|
114
|
+
pattern =
|
115
|
+
'{n, plural,
|
116
|
+
zero {zero}
|
117
|
+
one {one}
|
118
|
+
two {two}
|
119
|
+
few {few}
|
120
|
+
many {many}
|
121
|
+
other {other}}'
|
122
|
+
TwitterCldr.supported_locales.each do |locale|
|
123
|
+
message = MessageFormat.new(pattern, locale)
|
124
|
+
for n in 0..200 do
|
125
|
+
result = message.format({ :n => n })
|
126
|
+
expect(result).to match(/^(zero|one|two|few|many|other)$/)
|
127
|
+
end
|
128
|
+
end
|
129
|
+
end
|
130
|
+
end
|
131
|
+
end
|
data/spec/spec_helper.rb
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
require_relative '../lib/message_format'
|
metadata
ADDED
@@ -0,0 +1,104 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: message_format
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.1
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Andy VanWagoner
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2015-04-08 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: twitter_cldr
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '3.1'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '3.1'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: bundler
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '1.6'
|
34
|
+
type: :development
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '1.6'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: rake
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "~>"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '10.0'
|
48
|
+
type: :development
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '10.0'
|
55
|
+
description: Parse and format i18n messages using ICU MessageFormat patterns, including
|
56
|
+
simple placeholders, number and date placeholders, and selecting among submessages
|
57
|
+
for gender and plural arguments.
|
58
|
+
email:
|
59
|
+
- andy@instructure.com
|
60
|
+
executables: []
|
61
|
+
extensions: []
|
62
|
+
extra_rdoc_files: []
|
63
|
+
files:
|
64
|
+
- ".gitignore"
|
65
|
+
- Gemfile
|
66
|
+
- LICENSE.txt
|
67
|
+
- README.md
|
68
|
+
- Rakefile
|
69
|
+
- benchmark.rb
|
70
|
+
- lib/message_format.rb
|
71
|
+
- lib/message_format/interpreter.rb
|
72
|
+
- lib/message_format/parser.rb
|
73
|
+
- lib/message_format/version.rb
|
74
|
+
- message_format.gemspec
|
75
|
+
- spec/message_format_spec.rb
|
76
|
+
- spec/spec_helper.rb
|
77
|
+
homepage: https://github.com/thetalecrafter/message-format-rb
|
78
|
+
licenses:
|
79
|
+
- MIT
|
80
|
+
metadata: {}
|
81
|
+
post_install_message:
|
82
|
+
rdoc_options: []
|
83
|
+
require_paths:
|
84
|
+
- lib
|
85
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
86
|
+
requirements:
|
87
|
+
- - ">="
|
88
|
+
- !ruby/object:Gem::Version
|
89
|
+
version: '0'
|
90
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
91
|
+
requirements:
|
92
|
+
- - ">="
|
93
|
+
- !ruby/object:Gem::Version
|
94
|
+
version: '0'
|
95
|
+
requirements: []
|
96
|
+
rubyforge_project:
|
97
|
+
rubygems_version: 2.2.2
|
98
|
+
signing_key:
|
99
|
+
specification_version: 4
|
100
|
+
summary: Parse and format i18n messages using ICU MessageFormat patterns
|
101
|
+
test_files:
|
102
|
+
- spec/message_format_spec.rb
|
103
|
+
- spec/spec_helper.rb
|
104
|
+
has_rdoc:
|