jtag 0.2.0 → 1.0.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.
data/lib/jtag/string.rb CHANGED
@@ -1,48 +1,246 @@
1
- # encoding: utf-8
2
- class String
3
- # convert "WikiLink" to "Wiki link"
4
- def break_camel
5
- return downcase if match(/\A[A-Z]+\z/)
6
- gsub(/([A-Z]+)([A-Z][a-z])/, '\1 \2').
7
- gsub(/([a-z])([A-Z])/, '\1 \2').
8
- downcase
9
- end
1
+ # frozen_string_literal: true
10
2
 
11
- def strip_markdown
12
- # strip all Markdown and Liquid tags
13
- gsub(/\{%.*?%\}/,'').
14
- gsub(/\[\^.+?\](\: .*?$)?/,'').
15
- gsub(/\s{0,2}\[.*?\]: .*?$/,'').
16
- gsub(/\!\[.*?\][\[\(].*?[\]\)]/,"").
17
- gsub(/\[(.*?)\][\[\(].*?[\]\)]/,"\\1").
18
- gsub(/^\s{1,2}\[(.*?)\]: (\S+)( ".*?")?\s*$/,'').
19
- gsub(/^\#{1,6}\s*/,'').
20
- gsub(/(\*{1,2})(\S.*?\S)\1/,"\\2").
21
- gsub(/(`{3,})(.*?)\1/m,"\\2").
22
- gsub(/^-{3,}\s*$/,"").
23
- gsub(/`(.+)`/,"\\1").
24
- gsub(/\n{2,}/,"\n\n")
25
- end
3
+ module JekyllTag
4
+ ## String helpers
5
+ class ::String
6
+ ##
7
+ ## normalize bool string to symbo
8
+ ##
9
+ ## @return [Symbol] :and :or :not
10
+ ##
11
+ def to_bool
12
+ case self.downcase
13
+ when /^a/i
14
+ :and
15
+ when /^o/i
16
+ :or
17
+ when /^n/i
18
+ :not
19
+ else
20
+ raise ArgumentError, "Invalid boolean string: #{self}"
21
+ end
22
+ end
26
23
 
27
- def strip_tags
28
- return CGI.unescapeHTML(
29
- gsub(/<(script|style|pre|code|figure).*?>.*?<\/\1>/im, '').
30
- gsub(/<!--.*?-->/m, '').
31
- gsub(/<(img|hr|br).*?>/i, " ").
32
- gsub(/<(dd|a|h\d|p|small|b|i|blockquote|li)( [^>]*?)?>(.*?)<\/\1>/i, " \\3 ").
33
- gsub(/<\/?(dt|a|ul|ol)( [^>]+)?>/i, " ").
34
- gsub(/<[^>]+?>/, '').
35
- gsub(/\[\d+\]/, '').
36
- gsub(/&#8217;/,"'").gsub(/&.*?;/,' ').gsub(/;/,' ')
37
- ).lstrip.gsub("\xE2\x80\x98","'").gsub("\xE2\x80\x99","'").gsub("\xCA\xBC","'").gsub("\xE2\x80\x9C",'"').gsub("\xE2\x80\x9D",'"').gsub("\xCB\xAE",'"').squeeze(" ")
38
- end
24
+ ##
25
+ ## Convert a string to a format symbol (:list, :csv, :complete, :json, :plist, :yaml)
26
+ ##
27
+ ## @return [Symbol] format symbol
28
+ ##
29
+ def to_format
30
+ case self.downcase
31
+ when /^j/i
32
+ :json
33
+ when /^p/i
34
+ :plist
35
+ when /^y/i
36
+ :yaml
37
+ when /^cs/i
38
+ :csv
39
+ when /^c/i
40
+ :complete
41
+ when /^l/i
42
+ :list
43
+ else
44
+ raise ArgumentError, "Invalid format string: #{self}"
45
+ end
46
+ end
39
47
 
40
- def strip_urls
41
- gsub(/(https?:\/\/)?([\da-z\.-]+)\.([a-z\.]{2,6})([\/\w \.-]*)*\/?/i,"")
42
- end
48
+ ## Check if a string is a list of files
49
+ ##
50
+ ## @param null [Boolean] check for null-delimited list
51
+ ##
52
+ ## @example Check if a string is a list of files
53
+ ## "file1\nfile2\nfile3".file_list?
54
+ ## # => true
55
+ ##
56
+ ## @return [Boolean]
57
+ def file_list?(null = false)
58
+ self.strip.split(null ? "\x00" : "\n").each do |line|
59
+ return false unless File.exist?(line)
60
+ end
61
+ true
62
+ end
43
63
 
44
- def strip_all
45
- strip_tags.strip_markdown.strip
46
- end
64
+ ##
65
+ ## Check if string is YAML
66
+ ##
67
+ ## @example Check if a string is YAML
68
+ ## "tags:\n - tag1\n - tag2".yaml?
69
+ ## # => true
70
+ ##
71
+ ## @example Check if a string is not YAML
72
+ ## "test string".yaml?
73
+ ## # => false
74
+ ##
75
+ ## @return [Boolean]
76
+ ##
77
+ def yaml?
78
+ begin
79
+ YAML.load(self)
80
+ rescue
81
+ return false
82
+ end
83
+ true
84
+ end
85
+
86
+ ##
87
+ ## Matches the string against a given keyword based on various options.
88
+ ##
89
+ ## @param keyword [String] the keyword to match against the string.
90
+ ## @param options [Hash] a hash of options to customize the matching behavior.
91
+ ## @option options [Boolean] :case_sensitive (false) whether the match should be case-sensitive.
92
+ ## @option options [Boolean] :starts_with (false) whether the match should check if the string starts with the keyword.
93
+ ## @option options [Boolean] :exact (false) whether the match should be exact.
94
+ ## @option options [Boolean] :fuzzy (true) whether the match should be fuzzy.
95
+ ## @option options [Integer] :distance (2) the maximum distance between characters for a fuzzy match.
96
+ ##
97
+ ## @return [Boolean] true if the string matches the keyword based on the given options, false otherwise.
98
+ ##
99
+ def match_keyword(keyword, options = {})
100
+ options = {
101
+ case_sensitive: false,
102
+ starts_with: false,
103
+ exact: false,
104
+ fuzzy: false,
105
+ contains: false,
106
+ distance: 2,
107
+ }.merge(options)
108
+
109
+ keyword = Regexp.escape(keyword)
110
+
111
+ if options[:exact]
112
+ re = "^#{keyword}$"
113
+ elsif options[:starts_with]
114
+ re = "^#{keyword}"
115
+ elsif options[:fuzzy]
116
+ re = ".*#{keyword.split(//).join(".{,#{options[:distance] - 1}}")}.*"
117
+ else
118
+ re = ".*#{keyword}.*"
119
+ end
120
+
121
+ if options[:case_sensitive]
122
+ self.match?(/#{re}/)
123
+ else
124
+ self.match?(/#{re}/i)
125
+ end
126
+ end
47
127
 
128
+ ##
129
+ ## Check if a string is JSON
130
+ ##
131
+ ## @example Check if a string is JSON
132
+ ## '{"tags": ["tag1", "tag2"]}'.json?
133
+ ## # => true
134
+ ##
135
+ ## @example Check if a string is not JSON
136
+ ## "test string".json?
137
+ ## # => false
138
+ ##
139
+ ## @return [Boolean]
140
+ ##
141
+ ## @note JSON must be a hash or array
142
+ ##
143
+ def json?
144
+ begin
145
+ JSON.parse(self).is_a?(Hash) || JSON.parse(self).is_a?(Array)
146
+ rescue
147
+ return false
148
+ end
149
+ true
150
+ end
151
+
152
+ ##
153
+ ## Convert parenthetical count to hash
154
+ ##
155
+ ## @example Convert a string to a hash
156
+ ## "tag (5)".to_count
157
+ ## # => { "name" => "tag", "count" => 5 }
158
+ ##
159
+ ## @example Convert a string to a hash with no count
160
+ ## "tag".to_count
161
+ ## # => { "name" => "tag", "count" => 0 }
162
+ ##
163
+ ## @return [Hash] hash with name and count
164
+ ##
165
+ def to_count
166
+ tag = dup.strip.sub(/^[[:punct:]] /, "")
167
+ if tag =~ /(.*) \((\d+)\)/
168
+ { "name" => $1, "count" => $2.to_i }
169
+ else
170
+ { "name" => tag, "count" => 0 }
171
+ end
172
+ end
173
+
174
+ ##
175
+ ## Check if a string is a list of tags
176
+ ##
177
+ ## @example Check if a string is a list of tags
178
+ ## "tag1\ntag2".tag_list?
179
+ ## # => true
180
+ ##
181
+ ## @return [Boolean]
182
+ ##
183
+ ## @note One tag per line
184
+ ## Tags are assumed to be alphanumeric and lowercase
185
+ ## (spaces, underscores, and dashes allowed) with no
186
+ ## leading or preceding or trailing punctuation
187
+ ##
188
+ def tag_list?
189
+ self.strip.split("\n").each do |tag|
190
+ return false unless tag.match?(/^(?![[:punct:]] )[a-z0-9 -_]+(?<![[:punct:]]) *$/)
191
+ end
192
+ true
193
+ end
194
+
195
+ def root_words
196
+ words = break_camel.split("[\s-]")
197
+ words.delete_if { |word| word =~ /^[^a-z]+$/i || word.length < 4 }
198
+ words.map { |word| word = Text::PorterStemming.stem(word).downcase }.join(" ")
199
+ end
200
+
201
+ # convert "WikiLink" to "Wiki link"
202
+ def break_camel
203
+ return downcase if match(/\A[A-Z]+\z/)
204
+ gsub(/([A-Z]+)([A-Z][a-z])/, '\1 \2').
205
+ gsub(/([a-z])([A-Z])/, '\1 \2').
206
+ downcase
207
+ end
208
+
209
+ def strip_markdown
210
+ # strip all Markdown and Liquid tags
211
+ gsub(/\{%.*?%\}/, "").
212
+ gsub(/\[\^.+?\](\: .*?$)?/, "").
213
+ gsub(/\s{0,2}\[.*?\]: .*?$/, "").
214
+ gsub(/\!\[.*?\][\[\(].*?[\]\)]/, "").
215
+ gsub(/\[(.*?)\][\[\(].*?[\]\)]/, "\\1").
216
+ gsub(/^\s{1,2}\[(.*?)\]: (\S+)( ".*?")?\s*$/, "").
217
+ gsub(/^\#{1,6}\s*/, "").
218
+ gsub(/(\*{1,2})(\S.*?\S)\1/, "\\2").
219
+ gsub(/(`{3,})(.*?)\1/m, "\\2").
220
+ gsub(/^-{3,}\s*$/, "").
221
+ gsub(/`(.+)`/, "\\1").
222
+ gsub(/\n{2,}/, "\n\n")
223
+ end
224
+
225
+ def strip_tags
226
+ return CGI.unescapeHTML(
227
+ gsub(/<(script|style|pre|code|figure).*?>.*?<\/\1>/im, "").
228
+ gsub(/<!--.*?-->/m, "").
229
+ gsub(/<(img|hr|br).*?>/i, " ").
230
+ gsub(/<(dd|a|h\d|p|small|b|i|blockquote|li)( [^>]*?)?>(.*?)<\/\1>/i, " \\3 ").
231
+ gsub(/<\/?(dt|a|ul|ol)( [^>]+)?>/i, " ").
232
+ gsub(/<[^>]+?>/, "").
233
+ gsub(/\[\d+\]/, "").
234
+ gsub(/&#8217;/, "'").gsub(/&.*?;/, " ").gsub(/;/, " ")
235
+ ).lstrip.gsub("\xE2\x80\x98", "'").gsub("\xE2\x80\x99", "'").gsub("\xCA\xBC", "'").gsub("\xE2\x80\x9C", '"').gsub("\xE2\x80\x9D", '"').gsub("\xCB\xAE", '"').squeeze(" ")
236
+ end
237
+
238
+ def strip_urls
239
+ gsub(/(https?:\/\/)?([\da-z\.-]+)\.([a-z\.]{2,6})([\/\w \.-]*)*\/?/i, "")
240
+ end
241
+
242
+ def strip_all
243
+ strip_tags.strip_markdown.strip
244
+ end
245
+ end
48
246
  end
@@ -0,0 +1,319 @@
1
+ # encoding: utf-8
2
+ #
3
+ ## Stupid small pure Ruby JSON parser & generator.
4
+ #
5
+ # Copyright © 2013 Mislav Marohnić
6
+ #
7
+ # Permission is hereby granted, free of charge, to any person obtaining a copy of this
8
+ # software and associated documentation files (the “Software”), to deal in the Software
9
+ # without restriction, including without limitation the rights to use, copy, modify,
10
+ # merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
11
+ # permit persons to whom the Software is furnished to do so, subject to the following
12
+ # conditions:
13
+ #
14
+ # The above copyright notice and this permission notice shall be included in all copies or
15
+ # substantial portions of the Software.
16
+ #
17
+ # THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
18
+ # INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
19
+ # PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT
21
+ # OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
22
+ # OTHER DEALINGS IN THE SOFTWARE.
23
+
24
+ require 'strscan'
25
+ require 'forwardable'
26
+
27
+ # Usage:
28
+ #
29
+ # JSON.parse(json_string) => Array/Hash
30
+ # JSON.generate(object) => json string
31
+ #
32
+ # Run tests by executing this file directly. Pipe standard input to the script to have it
33
+ # parsed as JSON and to display the result in Ruby.
34
+ #
35
+ class JSON
36
+ def self.parse(data) new(data).parse end
37
+
38
+ WSP = /\s+/
39
+ OBJ = /[{\[]/; HEN = /\}/; AEN = /\]/
40
+ COL = /\s*:\s*/; KEY = /\s*,\s*/
41
+ NUM = /-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/
42
+ BOL = /true|false/; NUL = /null/
43
+
44
+ extend Forwardable
45
+
46
+ attr_reader :scanner
47
+ alias_method :s, :scanner
48
+ def_delegators :scanner, :scan, :matched
49
+ private :s, :scan, :matched
50
+
51
+ def initialize data
52
+ @scanner = StringScanner.new data.to_s
53
+ end
54
+
55
+ def parse
56
+ space
57
+ object
58
+ end
59
+
60
+ private
61
+
62
+ def space() scan WSP end
63
+
64
+ def endkey() scan(KEY) or space end
65
+
66
+ def object
67
+ matched == '{' ? hash : array if scan(OBJ)
68
+ end
69
+
70
+ def value
71
+ object or string or
72
+ scan(NUL) ? nil :
73
+ scan(BOL) ? matched.size == 4:
74
+ scan(NUM) ? eval(matched) :
75
+ error
76
+ end
77
+
78
+ def hash
79
+ obj = {}
80
+ space
81
+ repeat_until(HEN) { k = string; scan(COL); obj[k] = value; endkey }
82
+ obj
83
+ end
84
+
85
+ def array
86
+ ary = []
87
+ space
88
+ repeat_until(AEN) { ary << value; endkey }
89
+ ary
90
+ end
91
+
92
+ SPEC = {'b' => "\b", 'f' => "\f", 'n' => "\n", 'r' => "\r", 't' => "\t"}
93
+ UNI = 'u'; CODE = /[a-fA-F0-9]{4}/
94
+ STR = /"/; STE = '"'
95
+ ESC = '\\'
96
+
97
+ def string
98
+ if scan(STR)
99
+ str, esc = '', false
100
+ while c = s.getch
101
+ if esc
102
+ str << (c == UNI ? (s.scan(CODE) || error).to_i(16).chr : SPEC[c] || c)
103
+ esc = false
104
+ else
105
+ case c
106
+ when ESC then esc = true
107
+ when STE then break
108
+ else str << c
109
+ end
110
+ end
111
+ end
112
+ str
113
+ end
114
+ end
115
+
116
+ def error
117
+ raise "parse error at: #{scan(/.{1,10}/m).inspect}"
118
+ end
119
+
120
+ def repeat_until reg
121
+ until scan(reg)
122
+ pos = s.pos
123
+ yield
124
+ error unless s.pos > pos
125
+ end
126
+ end
127
+
128
+ module Generator
129
+ def generate(obj)
130
+ raise ArgumentError unless obj.is_a? Array or obj.is_a? Hash
131
+ generate_type(obj)
132
+ end
133
+ alias dump generate
134
+
135
+ private
136
+
137
+ def generate_type(obj)
138
+ type = obj.is_a?(Numeric) ? :Numeric : obj.class.name
139
+ begin send(:"generate_#{type}", obj)
140
+ rescue NoMethodError; raise ArgumentError, "can't serialize #{type}"
141
+ end
142
+ end
143
+
144
+ ESC_MAP = Hash.new {|h,k| k }.update \
145
+ "\r" => 'r',
146
+ "\n" => 'n',
147
+ "\f" => 'f',
148
+ "\t" => 't',
149
+ "\b" => 'b'
150
+
151
+ def quote(str) %("#{str}") end
152
+
153
+ def generate_String(str)
154
+ quote str.gsub(/[\r\n\f\t\b"\\]/) { "\\#{ESC_MAP[$&]}"}
155
+ end
156
+
157
+ def generate_simple(obj) obj.inspect end
158
+ alias generate_Numeric generate_simple
159
+ alias generate_TrueClass generate_simple
160
+ alias generate_FalseClass generate_simple
161
+
162
+ def generate_Symbol(sym) generate_String(sym.to_s) end
163
+
164
+ def generate_Time(time)
165
+ quote time.strftime(time.utc? ? "%F %T UTC" : "%F %T %z")
166
+ end
167
+ def generate_Date(date) quote date.to_s end
168
+
169
+ def generate_NilClass(*) 'null' end
170
+
171
+ def generate_Array(ary) '[%s]' % ary.map {|o| generate_type(o) }.join(', ') end
172
+
173
+ def generate_Hash(hash)
174
+ '{%s}' % hash.map { |key, value|
175
+ "#{generate_String(key.to_s)}: #{generate_type(value)}"
176
+ }.join(', ')
177
+ end
178
+ end
179
+
180
+ extend Generator
181
+ end
182
+
183
+ if __FILE__ == $0
184
+ if !$stdin.tty?
185
+ data = JSON.parse $stdin.read
186
+ require 'pp'
187
+ pp data
188
+ else
189
+ require 'test/unit'
190
+ require 'date'
191
+ class ParserTest < Test::Unit::TestCase
192
+ PARSED = JSON.parse DATA.read
193
+ def parsed() PARSED end
194
+ def parse_string(str) JSON.parse(%(["#{str}"]).gsub('\\\\', '\\')).first end
195
+ def test_string
196
+ assert_equal "Pagination library for \"Rails 3\", Sinatra, Merb, DataMapper, and more",
197
+ parsed['head']['repository']['description']
198
+ end
199
+ def test_string_specials
200
+ assert_equal "\r\n\t\f\b", parse_string('\r\n\t\f\b')
201
+ assert_equal "aA", parse_string('\u0061\u0041')
202
+ assert_equal "\e", parse_string('\u001B')
203
+ assert_equal "xyz", parse_string('\x\y\z')
204
+ assert_equal '"\\/', parse_string('\"\\\\\\/')
205
+ assert_equal 'no #{interpolation}', parse_string('no #{interpolation}')
206
+ end
207
+ def test_hash
208
+ assert_equal %w[label ref repository sha user], parsed['head'].keys.sort
209
+ end
210
+ def test_number
211
+ assert_equal 124.3e2, parsed['head']['repository']['size']
212
+ end
213
+ def test_bool
214
+ assert_equal true, parsed['head']['repository']['fork']
215
+ assert_equal false, parsed['head']['repository']['private']
216
+ end
217
+ def test_nil
218
+ assert_nil parsed['head']['user']['company']
219
+ end
220
+ def test_array
221
+ assert_equal ["4438f", {"a" => "b"}], parsed['head']['sha']
222
+ end
223
+ def test_invalid
224
+ assert_raises(RuntimeError) { JSON.parse %({) }
225
+ assert_raises(RuntimeError) { JSON.parse %({ "foo": }) }
226
+ assert_raises(RuntimeError) { JSON.parse %([ "foo": "bar" ]) }
227
+ assert_raises(RuntimeError) { JSON.parse %([ ~"foo" ]) }
228
+ assert_raises(RuntimeError) { JSON.parse %([ "foo ]) }
229
+ assert_raises(RuntimeError) { JSON.parse %([ "foo\\" ]) }
230
+ assert_raises(RuntimeError) { JSON.parse %([ "foo\\uabGd" ]) }
231
+ end
232
+ end
233
+
234
+ class GeneratorTest < Test::Unit::TestCase
235
+ def generate(obj) JSON.generate(obj) end
236
+ def test_array
237
+ assert_equal %([1, 2, 3]), generate([1, 2, 3])
238
+ end
239
+ def test_bool
240
+ assert_equal %([true, false]), generate([true, false])
241
+ end
242
+ def test_null
243
+ assert_equal %([null]), generate([nil])
244
+ end
245
+ def test_string
246
+ assert_equal %(["abc\\n123"]), generate(["abc\n123"])
247
+ end
248
+ def test_string_unicode
249
+ assert_equal %(["ć\\"č\\nž\\tš\\\\đ"]), generate(["ć\"č\nž\tš\\đ"])
250
+ end
251
+ def test_time
252
+ time = Time.utc(2012, 04, 19, 1, 2, 3)
253
+ assert_equal %(["2012-04-19 01:02:03 UTC"]), generate([time])
254
+ end
255
+ def test_date
256
+ time = Date.new(2012, 04, 19)
257
+ assert_equal %(["2012-04-19"]), generate([time])
258
+ end
259
+ def test_symbol
260
+ assert_equal %(["abc"]), generate([:abc])
261
+ end
262
+ def test_hash
263
+ json = generate(:abc => 123, 123 => 'abc')
264
+ assert_match /^\{/, json
265
+ assert_match /\}$/, json
266
+ assert_equal [%("123": "abc"), %("abc": 123)], json[1...-1].split(', ').sort
267
+ end
268
+ def test_nested_structure
269
+ json = generate(:hash => {1=>2}, :array => [1,2])
270
+ assert json.include?(%("hash": {"1": 2}))
271
+ assert json.include?(%("array": [1, 2]))
272
+ end
273
+ def test_invalid_json
274
+ assert_raises(ArgumentError) { generate("abc") }
275
+ end
276
+ def test_invalid_object
277
+ err = assert_raises(ArgumentError) { generate("a" => Object.new) }
278
+ assert_equal "can't serialize Object", err.message
279
+ end
280
+ end
281
+ end
282
+ end
283
+
284
+ __END__
285
+ {
286
+ "head": {
287
+ "ref": "master",
288
+ "repository": {
289
+ "forks": 0,
290
+ "integrate_branch": "rails3",
291
+ "watchers": 1,
292
+ "language": "Ruby",
293
+ "description": "Pagination library for \"Rails 3\", Sinatra, Merb, DataMapper, and more",
294
+ "has_downloads": true,
295
+ "fork": true,
296
+ "created_at": "2011/10/24 03:20:48 -0700",
297
+ "homepage": "http://github.com/mislav/will_paginate/wikis",
298
+ "size": 124.3e2,
299
+ "private": false,
300
+ "has_wiki": true,
301
+ "name": "will_paginate",
302
+ "owner": "dbackeus",
303
+ "url": "https://github.com/dbackeus/will_paginate",
304
+ "has_issues": false,
305
+ "open_issues": 0,
306
+ "pushed_at": "2011/10/25 05:44:05 -0700"
307
+ },
308
+ "label": "dbackeus:master",
309
+ "sha": ["4438f", { "a" : "b" }],
310
+ "user": {
311
+ "name": "David Backeus",
312
+ "company": null,
313
+ "gravatar_id": "ebe96524f5db9e92188f0542dc9d1d1a",
314
+ "location": "Stockholm (Sweden)",
315
+ "type": "User",
316
+ "login": "dbackeus"
317
+ }
318
+ }
319
+ }