toml-rb 4.2.1 → 6.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6d198202b34da88436ce3d0e3cb8841bc1a6ff433a0d9397303c0870c6fed1d5
4
- data.tar.gz: 916c15730aebc6974a1e7fd04e7c793a3b1321312657f370cae8c9c0a63e6f54
3
+ metadata.gz: d7514edd12c127a9f4ff3343e7590d4e526a15ebf3b13594d4d9683aea9fa1d0
4
+ data.tar.gz: a689fab092b24d2aadcecd046462af6d0cc741cdec8d4461372c65f6d249411b
5
5
  SHA512:
6
- metadata.gz: acf22dc79ffa1f4b4650e1979eaf7b3d3a04bc894c68e752008e87dbdf695d5ab4ad2c4e835b8d7a5b0aab151c802cd6cfc403ecd361a2cad125c32cf2cb42b3
7
- data.tar.gz: 1bdedac516138288dcb97ce4cf59153d03d5fdc8f48a93eca7afbd3ab2931f9ca1dd73eb104decca1e1a6bc7dbc3771d575cfe6a81429e3590e3f41d4048c959
6
+ metadata.gz: e58611d93fbb27112dd505c43edc135500661c088e33cae0b50d6419bf30f814c94d389cc3a71f85072996a61492e74b9b9e9fce00477df5c3cd8e14691980a4
7
+ data.tar.gz: 4c5dff3f4537f96ba315410482dee6100463538be2cefe8c19fd8a1fcbce0db614870c9b0e83d52bc269fce28ef9872e8d9b78317b25fcae1b92a12b28c20cca
data/README.md CHANGED
@@ -9,15 +9,19 @@ toml-rb
9
9
  [![Code Climate](https://codeclimate.com/github/emancu/toml-rb/badges/gpa.svg)](https://codeclimate.com/github/emancu/toml-rb)
10
10
  [![RubyGem](https://img.shields.io/gem/dt/toml-rb.svg)](https://rubygems.org/gems/toml-rb)
11
11
 
12
- A [TOML](https://github.com/toml-lang/toml) parser using [Citrus](http://mjackson.github.io/citrus) library.
12
+ A pure Ruby [TOML](https://github.com/toml-lang/toml) parser and dumper with no runtime dependencies.
13
13
 
14
- TOML specs supported: `1.0.0`
14
+ toml-rb implements [TOML 1.1.0](https://toml.io/en/v1.1.0) and passes all [toml-test](https://github.com/toml-lang/toml-test) v2.2.0 decoder tests for that version.
15
15
 
16
16
  Installation
17
17
  ------------
18
18
 
19
19
  $ gem install toml-rb
20
20
 
21
+ Or add this line to your Gemfile:
22
+
23
+ gem "toml-rb", "~> 6.0"
24
+
21
25
  Parser Usage
22
26
  ------------
23
27
 
@@ -42,6 +46,16 @@ TomlRB.parse(stream)
42
46
  # You want symbols as your keys? No problem!
43
47
  TomlRB.load_file(path, symbolize_keys: true)
44
48
  # Works the same for TomlRB.parse
49
+
50
+ # Local datetimes, dates and times are subclasses of Time. Offset datetimes are Time.
51
+ times = <<-EOS
52
+ odt = 1979-05-27T07:32:00Z
53
+ ldt = 1979-05-27T07:32:00
54
+ ld = 1979-05-27
55
+ lt = 07:32:00
56
+ EOS
57
+ TomlRB.parse(times).transform_values(&:class)
58
+ # => {"odt"=>Time, "ldt"=>TomlRB::LocalDateTime, "ld"=>TomlRB::LocalDate, "lt"=>TomlRB::LocalTime}
45
59
  ```
46
60
 
47
61
  Dumper Usage
@@ -78,11 +92,13 @@ TOML 1.0.0 and TOML 1.1.0. The harness lives in:
78
92
  - `tool/toml-test-decoder` — toml-test decoder shim.
79
93
  - `.github/workflows/conformance.yml` — CI workflow (per-version matrix).
80
94
  - `.github/toml-test-skip-1.0.txt` and `.github/toml-test-skip-1.1.txt` —
81
- skip-lists of currently-failing tests.
95
+ skip-lists of toml-test names for each spec version.
82
96
 
83
- The skip-lists are a living gap report: every entry is a known limitation in
84
- the current parser. The goal for v5.0.0 is **empty skip-lists** — full
85
- conformance against both spec versions.
97
+ The TOML 1.1.0 skip-list is empty. The TOML 1.0.0 skip-list has 9 tests with
98
+ documents that TOML 1.0.0 forbids and TOML 1.1.0 allows. `toml-rb` accepts
99
+ TOML 1.1.0 syntax and has no option to select the spec version, so these tests
100
+ fail. CI runs toml-test with `-skip-must-err`, so the run fails when a skipped
101
+ test passes.
86
102
 
87
103
  Contributing
88
104
  ------------
@@ -1,38 +1,54 @@
1
+ require "date"
2
+
1
3
  module TomlRB
2
- module OffsetDateTimeParser
3
- def value
4
- skeleton = captures[:datetime_skeleton].first
5
- year, mon, day, hour, min, sec, sec_frac = skeleton.value
6
- offset = captures[:date_offset].first || "+00:00"
7
- sec = "#{sec}.#{sec_frac}".to_f
8
-
9
- Time.new(year, mon, day, hour, min, sec, offset.to_s)
4
+ LocalDateTime = Class.new(Time)
5
+ LocalDate = Class.new(Time)
6
+ LocalTime = Class.new(Time)
7
+
8
+ module DatetimeParser
9
+ module_function
10
+
11
+ def offset_datetime(year, mon, day, hour, min, sec, sec_frac, offset)
12
+ validate_date(year, mon, day)
13
+ validate_time(hour, min, sec)
14
+ offset = "+00:00" if offset.casecmp?("Z")
15
+ unless offset[1, 2].to_i.between?(0, 23) && offset[4, 2].to_i.between?(0, 59)
16
+ raise ParseError, "Invalid UTC offset: #{offset}"
17
+ end
18
+
19
+ Time.new(year, mon, day, hour, min, seconds(sec, sec_frac), offset)
10
20
  end
11
- end
12
21
 
13
- module LocalDateTimeParser
14
- def value
15
- year, mon, day = captures[:date_skeleton].first.value
16
- hour, min, sec, sec_frac = captures[:time_skeleton].first.value
17
- usec = sec_frac.to_s.ljust(6, "0")
22
+ def local_datetime(year, mon, day, hour, min, sec, sec_frac)
23
+ validate_date(year, mon, day)
24
+ validate_time(hour, min, sec)
25
+ LocalDateTime.utc(year, mon, day, hour, min, seconds(sec, sec_frac))
26
+ end
18
27
 
19
- Time.local(year, mon, day, hour, min, sec, usec)
28
+ def local_date(year, mon, day)
29
+ validate_date(year, mon, day)
30
+ LocalDate.utc(year, mon, day)
20
31
  end
21
- end
22
32
 
23
- module LocalDateParser
24
- def value
25
- year, mon, day = captures[:date_skeleton].first.value
26
- Time.local(year, mon, day)
33
+ def local_time(hour, min, sec, sec_frac)
34
+ validate_time(hour, min, sec)
35
+ LocalTime.utc(1970, 1, 1, hour, min, seconds(sec, sec_frac))
27
36
  end
28
- end
29
37
 
30
- module LocalTimeParser
31
- def value
32
- hour, min, sec, sec_frac = captures[:time_skeleton].first.value
33
- usec = sec_frac.to_s.ljust(6, "0")
38
+ def validate_date(year, mon, day)
39
+ unless Date.valid_date?(year.to_i, mon.to_i, day.to_i, Date::GREGORIAN)
40
+ raise ParseError, "Invalid date: #{year}-#{mon}-#{day}"
41
+ end
42
+ end
43
+
44
+ def validate_time(hour, min, sec)
45
+ unless hour.to_i.between?(0, 23) && min.to_i.between?(0, 59) && sec.to_i.between?(0, 60)
46
+ raise ParseError, "Invalid time: #{hour}:#{min}:#{sec || 0}"
47
+ end
48
+ end
34
49
 
35
- Time.at(3600 * hour.to_i + 60 * min.to_i + sec.to_i, usec.to_i)
50
+ def seconds(sec, sec_frac)
51
+ "#{sec || 0}.#{sec_frac || 0}".to_r
36
52
  end
37
53
  end
38
54
  end
@@ -19,7 +19,7 @@ module TomlRB
19
19
  attr_reader :toml_str
20
20
 
21
21
  def initialize(hash)
22
- @toml_str = ""
22
+ @toml_str = +""
23
23
 
24
24
  visit(hash, [])
25
25
  end
@@ -41,13 +41,13 @@ module TomlRB
41
41
  simple_pairs = []
42
42
  table_array_pairs = []
43
43
 
44
- hash.keys.sort.each do |key|
44
+ sorted_keys(hash).each do |key|
45
45
  val = hash[key]
46
46
  element = [key, val]
47
47
 
48
48
  if val.is_a? Hash
49
49
  nested_pairs << element
50
- elsif val.is_a?(Array) && val.first.is_a?(Hash)
50
+ elsif val.is_a?(Array) && !val.empty? && val.all?(Hash)
51
51
  table_array_pairs << element
52
52
  else
53
53
  simple_pairs << element
@@ -57,6 +57,17 @@ module TomlRB
57
57
  [simple_pairs, nested_pairs, table_array_pairs]
58
58
  end
59
59
 
60
+ def sorted_keys(hash)
61
+ keys = hash.keys
62
+ raise Error, "Cannot dump keys with duplicate names: #{keys.inspect}" if keys.map(&:to_s).uniq!
63
+
64
+ begin
65
+ keys.sort
66
+ rescue ArgumentError
67
+ keys.sort_by(&:to_s)
68
+ end
69
+ end
70
+
60
71
  def dump_pairs(simple, nested, table_array, prefix = [])
61
72
  # First add simple pairs, under the prefix
62
73
  dump_simple_pairs simple
@@ -67,7 +78,7 @@ module TomlRB
67
78
  def dump_simple_pairs(simple_pairs)
68
79
  simple_pairs.each do |key, val|
69
80
  key = quote_key(key) unless bare_key? key
70
- @toml_str += "#{key} = #{to_toml(val)}\n"
81
+ @toml_str << "#{key} = #{to_toml(val)}\n"
71
82
  end
72
83
  end
73
84
 
@@ -97,20 +108,34 @@ module TomlRB
97
108
  new_prefix = prefix.join(".")
98
109
  new_prefix = "[" + new_prefix + "]" if extra_brackets
99
110
 
100
- @toml_str += "[" + new_prefix + "]\n"
111
+ @toml_str << "[" + new_prefix + "]\n"
101
112
  end
102
113
 
103
114
  def to_toml(obj)
104
- if obj.is_a?(Time) || obj.is_a?(DateTime)
105
- obj.strftime("%Y-%m-%dT%H:%M:%SZ")
115
+ if obj.is_a?(LocalDate)
116
+ obj.strftime("%Y-%m-%d")
117
+ elsif obj.is_a?(LocalTime)
118
+ obj.strftime("%H:%M:%S") + sec_fraction(obj)
119
+ elsif obj.is_a?(LocalDateTime)
120
+ obj.strftime("%Y-%m-%dT%H:%M:%S") + sec_fraction(obj)
121
+ elsif obj.is_a?(Time) || obj.is_a?(DateTime)
122
+ zone = obj.strftime("%:z").sub("+00:00", "Z")
123
+ obj.strftime("%Y-%m-%dT%H:%M:%S") + sec_fraction(obj) + zone
106
124
  elsif obj.is_a?(Date)
107
125
  obj.strftime("%Y-%m-%d")
108
126
  elsif obj.is_a?(Regexp)
109
- obj.inspect.inspect
110
- elsif obj.is_a?(String)
111
- escape_string(obj)
127
+ escape_string(obj.inspect)
128
+ elsif obj.is_a?(String) || obj.is_a?(Symbol)
129
+ escape_string(obj.to_s)
112
130
  elsif obj.is_a?(Array)
113
131
  "[" + obj.map(&method(:to_toml)).join(", ") + "]"
132
+ elsif obj.is_a?(Hash)
133
+ pairs = sorted_keys(obj).map do |key|
134
+ val = obj[key]
135
+ key = quote_key(key) unless bare_key? key
136
+ "#{key} = #{to_toml(val)}"
137
+ end
138
+ "{" + pairs.join(", ") + "}"
114
139
  elsif obj.is_a?(Float) && (obj.nan? || obj.infinite?)
115
140
  # Ruby renders these as Infinity/-Infinity/NaN, which are invalid TOML.
116
141
  if obj.nan?
@@ -120,8 +145,13 @@ module TomlRB
120
145
  else
121
146
  "inf"
122
147
  end
123
- else
148
+ elsif obj.is_a?(Integer) || obj.is_a?(Float) || obj == true || obj == false
149
+ obj.inspect
150
+ elsif defined?(BigDecimal) && obj.is_a?(BigDecimal) && obj.finite?
151
+ # BigDecimal#inspect is a valid TOML float, for example 0.15e1.
124
152
  obj.inspect
153
+ else
154
+ raise Error, "Cannot dump #{obj.class} to TOML"
125
155
  end
126
156
  end
127
157
 
@@ -135,7 +165,8 @@ module TomlRB
135
165
  end
136
166
 
137
167
  # Serialize a Ruby string as a TOML basic string. Ruby's String#inspect
138
- # emits \a \v \e for 0x07/0x0B/0x1B, which TOML (and this parser) reject.
168
+ # emits \a and \v, which are not TOML escapes, and \e, which is not a TOML 1.0
169
+ # escape. Write ESC as \u001B to keep the output valid TOML 1.0.
139
170
  def escape_string(str)
140
171
  escaped = str.gsub(/[\x00-\x1f\x7f"\\]/) do |char|
141
172
  BASIC_ESCAPES[char] || format('\\u%04X', char.ord)
@@ -143,5 +174,9 @@ module TomlRB
143
174
 
144
175
  "\"#{escaped}\""
145
176
  end
177
+
178
+ def sec_fraction(time)
179
+ time.strftime(".%9N").sub(/\.?0+\z/, "")
180
+ end
146
181
  end
147
182
  end
@@ -10,17 +10,9 @@ module TomlRB
10
10
 
11
11
  def value(symbolize_keys = false)
12
12
  result = {}
13
- @pairs.each do |kv|
14
- update = kv.assign({}, [], symbolize_keys)
15
- result.merge!(update) { |key, _, _| fail ValueOverwriteError.new(key) }
16
- end
13
+ tables = {}.compare_by_identity
14
+ @pairs.each { |kv| kv.assign(result, tables, symbolize_keys) }
17
15
  result
18
16
  end
19
17
  end
20
-
21
- module InlineTableParser
22
- def value
23
- TomlRB::InlineTable.new(captures[:keyvalue].map(&:value))
24
- end
25
- end
26
18
  end
@@ -10,36 +10,23 @@ module TomlRB
10
10
  @symbolize_keys = false
11
11
  end
12
12
 
13
- def assign(hash, fully_defined_paths, symbolize_keys = false)
13
+ def assign(hash, tables, symbolize_keys = false)
14
14
  @symbolize_keys = symbolize_keys
15
- keys = symbolize_keys ? @dotted_keys.map(&:to_sym) : @dotted_keys
16
- depth = @dotted_keys.size
17
- update = keys.reverse.inject(visit_value(@value)) { |k1, k2| {k2 => k1} }
15
+ *keys, last_key = symbolize_keys ? @dotted_keys.map(&:to_sym) : @dotted_keys
18
16
 
19
- parent_inline_table = fully_defined_paths.find { |k| k.size < depth && @dotted_keys.first(k.size) == k }
20
- fail ValueOverwriteError.new(@dotted_keys.first) if parent_inline_table
21
-
22
- if @value.is_a?(InlineTable)
23
- child_keys_exist = fully_defined_paths.find { |k| k.size > depth && k.first(depth) == @dotted_keys }
24
- fail ValueOverwriteError.new(@dotted_keys.first) if child_keys_exist
25
-
26
- existing_hash = hash.dig(*keys)
27
- fail ValueOverwriteError.new(@dotted_keys.first) if existing_hash.is_a?(Hash) && !existing_hash.empty?
17
+ keys.each do |key|
18
+ unless hash.key?(key)
19
+ hash[key] = {}
20
+ tables[hash[key]] = :dotted
21
+ end
22
+ hash = hash[key]
23
+ fail ValueOverwriteError.new(key) unless [:implicit, :dotted].include?(tables[hash])
28
24
 
29
- fully_defined_paths << @dotted_keys
25
+ tables[hash] = :dotted
30
26
  end
27
+ fail ValueOverwriteError.new(last_key) if hash.key?(last_key)
31
28
 
32
- dotted_key_merge(hash, update)
33
- end
34
-
35
- def dotted_key_merge(hash, update)
36
- hash.merge!(update) do |key, old, new|
37
- if old.is_a?(Hash) && new.is_a?(Hash)
38
- dotted_key_merge(old, new)
39
- else
40
- fail ValueOverwriteError.new(key)
41
- end
42
- end
29
+ hash[last_key] = visit_value(@value)
43
30
  end
44
31
 
45
32
  def accept_visitor(parser)
@@ -49,16 +36,10 @@ module TomlRB
49
36
  private
50
37
 
51
38
  def visit_value(a_value)
39
+ return a_value.map { |v| visit_value(v) } if a_value.is_a?(Array)
52
40
  return a_value unless a_value.respond_to? :accept_visitor
53
41
 
54
42
  a_value.accept_visitor self
55
43
  end
56
44
  end
57
-
58
- # Used in document.citrus
59
- module KeyvalueParser
60
- def value
61
- TomlRB::Keyvalue.new(capture(:stripped_key).value, capture(:v).value)
62
- end
63
- end
64
45
  end
@@ -1,19 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "strscan"
4
+
1
5
  module TomlRB
2
6
  class Parser
7
+ BASIC_STRING = /(?:[^"\\\x00-\x08\x0a-\x1f\x7f]|\\.)*/
8
+ LITERAL_STRING = /[^'\x00-\x08\x0a-\x1f\x7f]*/
9
+ MULTILINE_STRING = /(?:[^"\\\x00-\x08\x0b-\x1f\x7f]|\r\n|\\(?:[^\r]|\r\n)|"{1,2}(?!"))*(?:"{0,2}(?="""))?/
10
+ MULTILINE_LITERAL = /(?:[^'\x00-\x08\x0b-\x1f\x7f]|\r\n|'{1,2}(?!'))*(?:'{0,2}(?='''))?/
11
+ # A UTF-8 regexp raises Encoding::CompatibilityError on binary input with high bytes.
12
+ COMMENT = /[^\x00-\x08\x0a-\x1f\x7f]*/u
13
+ DATETIME = /(\d{4})-(\d\d)-(\d\d)(?:[Tt ]([0-2]\d):([0-6]\d)(?::([0-6]\d)(?:\.(\d{1,9})\d*)?)?([Zz]|[+-]\d\d:\d\d)?)?/
14
+ TIME = /([0-2]\d):([0-6]\d)(?::([0-6]\d)(?:\.(\d{1,9})\d*)?)?/
15
+ PREFIXED_INTEGER = /0x[0-9a-fA-F](?:_?[0-9a-fA-F])*|0o[0-7](?:_?[0-7])*|0b[01](?:_?[01])*/
16
+ DECIMAL = /[+-]?(?:0|[1-9](?:_?\d)*)(\.\d(?:_?\d)*(?:[eE][+-]?\d(?:_?\d)*)?|[eE][+-]?\d(?:_?\d)*)?/
17
+ MAX_NESTING = 100
18
+
3
19
  attr_reader :hash
4
20
 
5
21
  def initialize(content, symbolize_keys: false)
6
22
  @hash = {}
7
- @visited_keys = []
8
- @fully_defined_paths = []
23
+ # Tables and arrays of tables that headers or dotted keys made, by identity:
24
+ # :implicit (super-table of a header), :explicit (header) or :dotted (dotted key).
25
+ # Inline tables and static arrays are absent, so no header or dotted key can enter them.
26
+ @tables = {}.compare_by_identity
9
27
  @current = @hash
10
28
  @symbolize_keys = symbolize_keys
29
+ @depth = 0
11
30
 
12
31
  begin
13
- parsed = TomlRB::Document.parse(content)
14
- parsed.matches.map(&:value).compact.each { |m| m.accept_visitor(self) }
15
- rescue Citrus::ParseError => e
16
- raise TomlRB::ParseError.new(e.message)
32
+ @scanner = StringScanner.new(source_text(content))
33
+ # The visitor runs after the whole document is parsed, so a syntax
34
+ # error wins over a key or table that the visitor rejects.
35
+ document.each { |statement| statement.accept_visitor(self) }
17
36
  rescue Encoding::CompatibilityError => e
18
37
  raise TomlRB::ParseError.new("Encoding error: #{e.message}")
19
38
  rescue ArgumentError => e
@@ -28,20 +47,250 @@ module TomlRB
28
47
  # Read about the Visitor pattern
29
48
  # http://en.wikipedia.org/wiki/Visitor_pattern
30
49
  def visit_table_array(table_array)
31
- @fully_defined_paths = []
32
- table_array_key = table_array.full_key
33
- @visited_keys.reject! { |k| k.start_with? table_array_key }
34
-
35
- @current = table_array.navigate_keys @hash, @symbolize_keys
50
+ @current = table_array.navigate_keys @hash, @tables, @symbolize_keys
36
51
  end
37
52
 
38
53
  def visit_table(table)
39
- @fully_defined_paths = []
40
- @current = table.navigate_keys @hash, @visited_keys, @symbolize_keys
54
+ @current = table.navigate_keys @hash, @tables, @symbolize_keys
41
55
  end
42
56
 
43
57
  def visit_keyvalue(keyvalue)
44
- keyvalue.assign @current, @fully_defined_paths, @symbolize_keys
58
+ keyvalue.assign @current, @tables, @symbolize_keys
59
+ end
60
+
61
+ private
62
+
63
+ def source_text(source)
64
+ if source.respond_to?(:to_path)
65
+ File.read(source.to_path)
66
+ elsif source.respond_to?(:read)
67
+ source.read
68
+ elsif source.respond_to?(:to_str)
69
+ source.to_str
70
+ else
71
+ raise ArgumentError, "Unable to parse from #{source}"
72
+ end
73
+ end
74
+
75
+ def document
76
+ statements = []
77
+ loop do
78
+ @scanner.skip(/(?:[ \t]*\r?\n)*[ \t]*/)
79
+ return statements if @scanner.eos?
80
+
81
+ statements << statement unless @scanner.match?(/#/)
82
+ line_end
83
+ end
84
+ end
85
+
86
+ def statement
87
+ if @scanner.match?(/\[/)
88
+ @scanner.match?(/\[\[/) ? table_array : table
89
+ else
90
+ keyvalue
91
+ end
92
+ end
93
+
94
+ def table_array
95
+ @scanner.skip(/\[\[[ \t]*/)
96
+ keys = key
97
+ expect(/[ \t]*\]\]/, "']]'")
98
+ TableArray.new(keys)
99
+ end
100
+
101
+ def table
102
+ @scanner.skip(/\[[ \t]*/)
103
+ keys = key
104
+ expect(/[ \t]*\]/, "']'")
105
+ Table.new(keys)
106
+ end
107
+
108
+ def keyvalue
109
+ keys = key
110
+ expect(/[ \t]*=[ \t]*/, "'='")
111
+ Keyvalue.new(keys, value)
112
+ end
113
+
114
+ def key
115
+ keys = [simple_key]
116
+ keys << simple_key while @scanner.skip(/[ \t]*\.[ \t]*/)
117
+ keys
118
+ end
119
+
120
+ def simple_key
121
+ if (bare = @scanner.scan(/[A-Za-z0-9_-]+/))
122
+ bare
123
+ elsif @scanner.match?(/"/)
124
+ basic_string
125
+ elsif @scanner.match?(/'/)
126
+ literal_string
127
+ else
128
+ syntax_error("a key")
129
+ end
130
+ end
131
+
132
+ def value
133
+ case @scanner.peek(1)
134
+ when '"'
135
+ @scanner.match?(/"""/) ? multiline_string : basic_string
136
+ when "'"
137
+ @scanner.match?(/'''/) ? multiline_literal : literal_string
138
+ when "["
139
+ nested { array }
140
+ when "{"
141
+ nested { inline_table }
142
+ when "t"
143
+ expect(/true/, "a value")
144
+ true
145
+ when "f"
146
+ expect(/false/, "a value")
147
+ false
148
+ else
149
+ datetime || local_time || number
150
+ end
151
+ end
152
+
153
+ def basic_string
154
+ @scanner.skip(/"/)
155
+ text = @scanner.scan(BASIC_STRING)
156
+ expect(/"/, %('"'))
157
+ unescape(text)
158
+ end
159
+
160
+ def literal_string
161
+ @scanner.skip(/'/)
162
+ text = @scanner.scan(LITERAL_STRING)
163
+ expect(/'/, %("'"))
164
+ text
165
+ end
166
+
167
+ def multiline_string
168
+ start = @scanner.pos
169
+ @scanner.skip(/"""(?:\r?\n)?/)
170
+ text = @scanner.scan(MULTILINE_STRING)
171
+ @scanner.skip(/"""/) || syntax_error(%('"""' to close the string at #{location(start)}))
172
+ unescape(text)
173
+ end
174
+
175
+ def multiline_literal
176
+ start = @scanner.pos
177
+ @scanner.skip(/'''(?:\r?\n)?/)
178
+ text = @scanner.scan(MULTILINE_LITERAL)
179
+ @scanner.skip(/'''/) || syntax_error(%("'''" to close the string at #{location(start)}))
180
+ text
181
+ end
182
+
183
+ def unescape(text)
184
+ text.include?("\\") ? BasicString.transform_escaped_chars(text) : text
185
+ end
186
+
187
+ def datetime
188
+ return unless @scanner.skip(DATETIME)
189
+
190
+ year, mon, day, hour, min, sec, sec_frac, offset = @scanner.values_at(1, 2, 3, 4, 5, 6, 7, 8)
191
+ if hour.nil?
192
+ DatetimeParser.local_date(year, mon, day)
193
+ elsif offset
194
+ DatetimeParser.offset_datetime(year, mon, day, hour, min, sec, sec_frac, offset)
195
+ else
196
+ DatetimeParser.local_datetime(year, mon, day, hour, min, sec, sec_frac)
197
+ end
198
+ end
199
+
200
+ def local_time
201
+ DatetimeParser.local_time(*@scanner.values_at(1, 2, 3, 4)) if @scanner.skip(TIME)
202
+ end
203
+
204
+ def number
205
+ if (text = @scanner.scan(PREFIXED_INTEGER))
206
+ Integer(text)
207
+ elsif (text = @scanner.scan(DECIMAL))
208
+ @scanner[1] ? text.to_f : text.to_i
209
+ elsif (text = @scanner.scan(/[+-]?inf/))
210
+ text.start_with?("-") ? -Float::INFINITY : Float::INFINITY
211
+ elsif @scanner.skip(/[+-]?nan/)
212
+ Float::NAN
213
+ else
214
+ syntax_error("a value")
215
+ end
216
+ end
217
+
218
+ def array
219
+ @scanner.skip(/\[/)
220
+ values = []
221
+ loop do
222
+ skip_whitespace_and_comments
223
+ return values if @scanner.skip(/\]/)
224
+
225
+ values << value
226
+ skip_whitespace_and_comments
227
+ return values if @scanner.skip(/\]/)
228
+
229
+ expect(/,/, "',' or ']'")
230
+ end
231
+ end
232
+
233
+ def inline_table
234
+ @scanner.skip(/\{/)
235
+ pairs = []
236
+ loop do
237
+ skip_whitespace_and_comments
238
+ return InlineTable.new(pairs) if @scanner.skip(/\}/)
239
+
240
+ pairs << keyvalue
241
+ skip_whitespace_and_comments
242
+ return InlineTable.new(pairs) if @scanner.skip(/\}/)
243
+
244
+ expect(/,/, "',' or '}'")
245
+ end
246
+ end
247
+
248
+ # Each nested array or inline table recurses, so the limit stops a deep
249
+ # document before it exhausts the stack.
250
+ def nested
251
+ @depth += 1
252
+ syntax_error("at most #{MAX_NESTING} nested arrays and inline tables") if @depth > MAX_NESTING
253
+ yield
254
+ ensure
255
+ @depth -= 1
256
+ end
257
+
258
+ def line_end
259
+ return if @scanner.skip(/[ \t]*\r?\n/)
260
+
261
+ @scanner.skip(/[ \t]*/)
262
+ @scanner.skip(COMMENT) if @scanner.skip(/#/)
263
+ new_line
264
+ end
265
+
266
+ def skip_whitespace_and_comments
267
+ @scanner.skip(/[ \t\r\n]*/)
268
+ while @scanner.skip(/#/)
269
+ @scanner.skip(COMMENT)
270
+ new_line
271
+ @scanner.skip(/[ \t\r\n]*/)
272
+ end
273
+ end
274
+
275
+ def new_line
276
+ @scanner.skip(/\r?\n/) || @scanner.eos? || syntax_error("a new line")
277
+ end
278
+
279
+ def expect(pattern, expected)
280
+ @scanner.skip(pattern) || syntax_error(expected)
281
+ end
282
+
283
+ def syntax_error(expected)
284
+ @scanner.skip(/[ \t]*/)
285
+ found = @scanner.eos? ? "end of input" : @scanner.check(/./m).inspect
286
+ raise ParseError, "Unexpected #{found} at #{location(@scanner.pos)}: expected #{expected}"
287
+ end
288
+
289
+ def location(pos)
290
+ consumed = @scanner.string.byteslice(0, pos)
291
+ line = consumed.count("\n") + 1
292
+ column = consumed.length - (consumed.rindex("\n") || -1)
293
+ "line #{line}, column #{column}"
45
294
  end
46
295
  end
47
296
  end
@@ -1,10 +1,9 @@
1
1
  module TomlRB
2
- # Used in primitive.citrus
3
2
  module BasicString
4
3
  SPECIAL_CHARS = {
5
- "\\0" => "\0",
6
4
  "\\t" => "\t",
7
5
  "\\b" => "\b",
6
+ "\\e" => "\e",
8
7
  "\\f" => "\f",
9
8
  "\\n" => "\n",
10
9
  "\\r" => "\r",
@@ -12,21 +11,22 @@ module TomlRB
12
11
  "\\\\" => "\\"
13
12
  }.freeze
14
13
 
15
- def value
16
- aux = TomlRB::BasicString.transform_escaped_chars first.value
17
-
18
- aux[1...-1]
19
- end
20
-
21
14
  # Replace the unicode escaped characters with the corresponding character
22
15
  # e.g. \u03B4 => ?
23
16
  def self.decode_unicode(str)
24
- [str[2..-1].to_i(16)].pack("U")
17
+ code = str[2..-1].to_i(16)
18
+ if code.between?(0xD800, 0xDFFF) || code > 0x10FFFF
19
+ fail ParseError.new "Escape sequence #{str} is not a Unicode scalar value"
20
+ end
21
+
22
+ [code].pack("U")
25
23
  end
26
24
 
27
25
  def self.transform_escaped_chars(str)
28
- str.gsub(/\\(u[\da-fA-F]{4}|U[\da-fA-F]{8}|.)/) do |m|
29
- if m.size == 2
26
+ str.gsub(/\\(x[\da-fA-F]{2}|u[\da-fA-F]{4}|U[\da-fA-F]{8}|[ \t]*\r?\n[ \t\r\n]*|.)/) do |m|
27
+ if m.include?("\n")
28
+ ""
29
+ elsif m.size == 2
30
30
  SPECIAL_CHARS[m] || parse_error(m)
31
31
  else
32
32
  decode_unicode(m).force_encoding("UTF-8")
@@ -38,31 +38,4 @@ module TomlRB
38
38
  fail ParseError.new "Escape sequence #{m} is reserved"
39
39
  end
40
40
  end
41
-
42
- module LiteralString
43
- def value
44
- first.value[1...-1]
45
- end
46
- end
47
-
48
- module MultilineString
49
- def value
50
- return "" if captures[:text].empty?
51
- aux = captures[:text].first.value
52
-
53
- # Remove spaces on multilined Singleline strings
54
- aux.gsub!(/\\\r?\n[\n\t\r ]*/, "")
55
-
56
- TomlRB::BasicString.transform_escaped_chars aux
57
- end
58
- end
59
-
60
- module MultilineLiteral
61
- def value
62
- return "" if captures[:text].empty?
63
- aux = captures[:text].first.value
64
-
65
- aux.gsub(/\\\r?\n[\n\t\r ]*/, "")
66
- end
67
- end
68
41
  end
data/lib/toml-rb/table.rb CHANGED
@@ -4,24 +4,22 @@ module TomlRB
4
4
  @dotted_keys = dotted_keys
5
5
  end
6
6
 
7
- def navigate_keys(hash, visited_keys, symbolize_keys = false)
8
- ensure_key_not_defined(visited_keys)
7
+ def navigate_keys(hash, tables, symbolize_keys = false)
9
8
  current = hash
10
9
  keys = symbolize_keys ? @dotted_keys.map(&:to_sym) : @dotted_keys
11
- keys.each_with_index do |key, index|
12
- current[key] = {} unless current.key?(key)
13
- element = current[key]
14
-
15
- # If this is the final key and it's already an array (from [[key]]), that's invalid
16
- is_final_key = (index == keys.length - 1)
17
- if is_final_key && element.is_a?(Array)
18
- fail ValueOverwriteError.new(key)
10
+ keys.each do |key|
11
+ unless current.key?(key)
12
+ current[key] = {}
13
+ tables[current[key]] = :implicit
19
14
  end
15
+ element = current[key]
16
+ fail ValueOverwriteError.new(key) unless tables.key?(element)
20
17
 
21
18
  current = element.is_a?(Array) ? element.last : element
22
- # check that key has not been defined before as a scalar value
23
- fail ValueOverwriteError.new(key) unless current.is_a?(Hash)
24
19
  end
20
+ fail ValueOverwriteError.new(full_key) unless tables[current] == :implicit
21
+
22
+ tables[current] = :explicit
25
23
  current
26
24
  end
27
25
 
@@ -32,20 +30,5 @@ module TomlRB
32
30
  def full_key
33
31
  @dotted_keys.join(".")
34
32
  end
35
-
36
- private
37
-
38
- # Fail if the key was already defined with a ValueOverwriteError
39
- def ensure_key_not_defined(visited_keys)
40
- fail ValueOverwriteError.new(full_key) if visited_keys.include?(full_key)
41
- visited_keys << full_key
42
- end
43
- end
44
-
45
- # Used in document.citrus
46
- module TableParser
47
- def value
48
- TomlRB::Table.new(captures[:stripped_key].map(&:value).first)
49
- end
50
33
  end
51
34
  end
@@ -4,21 +4,20 @@ module TomlRB
4
4
  @dotted_keys = dotted_keys
5
5
  end
6
6
 
7
- def navigate_keys(hash, symbolize_keys = false)
7
+ def navigate_keys(hash, tables, symbolize_keys = false)
8
8
  current = hash
9
- keys = symbolize_keys ? @dotted_keys.map(&:to_sym) : @dotted_keys
10
- last_key = keys.pop
9
+ *keys, last_key = symbolize_keys ? @dotted_keys.map(&:to_sym) : @dotted_keys
11
10
 
12
11
  # Go over the parent keys
13
12
  keys.each do |key|
14
- current[key] = {} unless current[key]
15
-
16
- if current[key].is_a? Array
17
- current[key] << {} if current[key].empty?
18
- current = current[key].last
19
- else
20
- current = current[key]
13
+ unless current.key?(key)
14
+ current[key] = {}
15
+ tables[current[key]] = :implicit
21
16
  end
17
+ element = current[key]
18
+ fail ValueOverwriteError.new(key) unless tables.key?(element)
19
+
20
+ current = element.is_a?(Array) ? element.last : element
22
21
  end
23
22
 
24
23
  # Define Table Array
@@ -26,25 +25,19 @@ module TomlRB
26
25
  fail TomlRB::ParseError,
27
26
  "#{last_key} was defined as hash but is now redefined as a table!"
28
27
  end
29
- current[last_key] = [] unless current[last_key]
30
- current[last_key] << {}
28
+ unless current.key?(last_key)
29
+ current[last_key] = []
30
+ tables[current[last_key]] = :explicit
31
+ end
32
+ fail ValueOverwriteError.new(last_key) unless tables.key?(current[last_key])
31
33
 
34
+ current[last_key] << {}
35
+ tables[current[last_key].last] = :explicit
32
36
  current[last_key].last
33
37
  end
34
38
 
35
39
  def accept_visitor(parser)
36
40
  parser.visit_table_array self
37
41
  end
38
-
39
- def full_key
40
- @dotted_keys.join(".")
41
- end
42
- end
43
-
44
- # Used in document.citrus
45
- module TableArrayParser
46
- def value
47
- TomlRB::TableArray.new(captures[:stripped_key].map(&:value).first)
48
- end
49
42
  end
50
43
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TomlRB
4
- VERSION = "4.2.1"
4
+ VERSION = "6.0.0"
5
5
  end
data/lib/toml-rb.rb CHANGED
@@ -1,7 +1,4 @@
1
- require "citrus"
2
-
3
1
  require_relative "toml-rb/errors"
4
- require_relative "toml-rb/array"
5
2
  require_relative "toml-rb/string"
6
3
  require_relative "toml-rb/datetime"
7
4
  require_relative "toml-rb/table"
@@ -11,12 +8,6 @@ require_relative "toml-rb/keyvalue"
11
8
  require_relative "toml-rb/parser"
12
9
  require_relative "toml-rb/dumper"
13
10
 
14
- File.dirname(File.expand_path(__FILE__)).tap do |root|
15
- Citrus.load "#{root}/toml-rb/grammars/helper.citrus"
16
- Citrus.load "#{root}/toml-rb/grammars/primitive.citrus"
17
- Citrus.load "#{root}/toml-rb/grammars/document.citrus"
18
- end
19
-
20
11
  module TomlRB
21
12
  # Public: Returns a hash from *TomlRB* content.
22
13
  #
@@ -67,7 +58,7 @@ module TomlRB
67
58
  # Raises Errno::ENOENT if the file cannot be found.
68
59
  # Raises Errno::EACCES if the file cannot be accessed.
69
60
  def self.load_file(path, symbolize_keys: false)
70
- TomlRB.parse(File.read(path), symbolize_keys: symbolize_keys)
61
+ TomlRB.parse(File.read(path, encoding: "UTF-8"), symbolize_keys: symbolize_keys)
71
62
  end
72
63
 
73
64
  # Public: Returns a *TomlRB* string from a Ruby Hash.
metadata CHANGED
@@ -1,49 +1,15 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: toml-rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 4.2.1
4
+ version: 6.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Emiliano Mancuso
8
8
  - Lucas Tolchinsky
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-07-31 00:00:00.000000000 Z
11
+ date: 2026-09-28 00:00:00.000000000 Z
12
12
  dependencies:
13
- - !ruby/object:Gem::Dependency
14
- name: citrus
15
- requirement: !ruby/object:Gem::Requirement
16
- requirements:
17
- - - "~>"
18
- - !ruby/object:Gem::Version
19
- version: '3.0'
20
- - - ">"
21
- - !ruby/object:Gem::Version
22
- version: '3.0'
23
- type: :runtime
24
- prerelease: false
25
- version_requirements: !ruby/object:Gem::Requirement
26
- requirements:
27
- - - "~>"
28
- - !ruby/object:Gem::Version
29
- version: '3.0'
30
- - - ">"
31
- - !ruby/object:Gem::Version
32
- version: '3.0'
33
- - !ruby/object:Gem::Dependency
34
- name: racc
35
- requirement: !ruby/object:Gem::Requirement
36
- requirements:
37
- - - "~>"
38
- - !ruby/object:Gem::Version
39
- version: '1.7'
40
- type: :runtime
41
- prerelease: false
42
- version_requirements: !ruby/object:Gem::Requirement
43
- requirements:
44
- - - "~>"
45
- - !ruby/object:Gem::Version
46
- version: '1.7'
47
13
  - !ruby/object:Gem::Dependency
48
14
  name: minitest
49
15
  requirement: !ruby/object:Gem::Requirement
@@ -86,7 +52,7 @@ dependencies:
86
52
  - - ">="
87
53
  - !ruby/object:Gem::Version
88
54
  version: '0'
89
- description: 'A Toml parser using Citrus parsing library. '
55
+ description: A TOML 1.1.0 parser and dumper.
90
56
  email:
91
57
  - emiliano.mancuso@gmail.com
92
58
  - lucas.tolchinsky@gmail.com
@@ -97,13 +63,9 @@ files:
97
63
  - LICENSE
98
64
  - README.md
99
65
  - lib/toml-rb.rb
100
- - lib/toml-rb/array.rb
101
66
  - lib/toml-rb/datetime.rb
102
67
  - lib/toml-rb/dumper.rb
103
68
  - lib/toml-rb/errors.rb
104
- - lib/toml-rb/grammars/document.citrus
105
- - lib/toml-rb/grammars/helper.citrus
106
- - lib/toml-rb/grammars/primitive.citrus
107
69
  - lib/toml-rb/inline_table.rb
108
70
  - lib/toml-rb/keyvalue.rb
109
71
  - lib/toml-rb/parser.rb
data/lib/toml-rb/array.rb DELETED
@@ -1,8 +0,0 @@
1
- module TomlRB
2
- module ArrayParser
3
- def value
4
- elements = captures[:array_elements].first
5
- elements ? elements.value : []
6
- end
7
- end
8
- end
@@ -1,57 +0,0 @@
1
- grammar TomlRB::Document
2
- include TomlRB::Primitive
3
-
4
- rule document
5
- (comment | table_array | table | keyvalue | line_break)*
6
- end
7
-
8
- rule table_array
9
- (space? '[[' stripped_key ']]' comment?) <TomlRB::TableArrayParser>
10
- end
11
-
12
- rule table
13
- (space? '[' stripped_key ']' comment?) <TomlRB::TableParser>
14
- end
15
-
16
- rule keyvalue
17
- (stripped_key '=' space? v:(toml_values) comment? space) <TomlRB::KeyvalueParser>
18
- end
19
-
20
- ### Values
21
-
22
- rule inline_table
23
- (space? '{' array_comments (keyvalue (array_comments ',' array_comments keyvalue)* (array_comments ',')?)? array_comments '}' ) <TomlRB::InlineTableParser>
24
- end
25
-
26
- rule array
27
- ("[" array_comments (array_elements)? space ","? array_comments "]" indent?) <TomlRB::ArrayParser>
28
- end
29
-
30
- rule array_array
31
- (array (space "," array_comments array)*) {
32
- captures[:array].map(&:value)
33
- }
34
- end
35
-
36
- rule array_elements
37
- (toml_values (indent? "," array_comments toml_values)*) {
38
- captures[:toml_values].map(&:value).map do |v|
39
- v.respond_to?(:value) ? v.value : v
40
- end
41
- }
42
- end
43
-
44
- ### Helpers
45
-
46
- rule stripped_key
47
- (space? key space?) { captures[:key].first.value }
48
- end
49
-
50
- rule array_comments
51
- (indent? (comment indent?)*)
52
- end
53
-
54
- rule toml_values
55
- primitive | inline_table | array
56
- end
57
- end
@@ -1,17 +0,0 @@
1
- grammar TomlRB::Helper
2
- rule comment
3
- (space? "#" (~line_break)* line_break?) { nil }
4
- end
5
-
6
- rule space
7
- [ \t]*
8
- end
9
-
10
- rule indent
11
- [ \t\r\n]*
12
- end
13
-
14
- rule line_break
15
- (space "\n" space | space "\r\n" space) { nil }
16
- end
17
- end
@@ -1,176 +0,0 @@
1
- grammar TomlRB::Primitive
2
- include TomlRB::Helper
3
-
4
- rule primitive
5
- datetime | bool | number | string
6
- end
7
-
8
- ##
9
- # String rules
10
- ##
11
-
12
- rule string
13
- multiline_string | multiline_literal | basic_string | literal_string
14
- end
15
-
16
- rule basic_string
17
- (/(["])(?:\\?.)*?\1/ space) <TomlRB::BasicString>
18
- end
19
-
20
- rule literal_string
21
- (/(['])(?:\\?.)*?\1/ space) <TomlRB::LiteralString>
22
- end
23
-
24
- rule multiline_string
25
- ('"""' line_break* (text:~('"""' !'"')|'') '"""' space) <TomlRB::MultilineString>
26
- end
27
-
28
- rule multiline_literal
29
- ("'''" line_break* (text:~("'''" !"'")|'') "'''" space) <TomlRB::MultilineLiteral>
30
- end
31
-
32
- ##
33
- # Date time rules
34
- ##
35
-
36
- rule datetime
37
- offset_datetime | local_datetime | local_date | local_time
38
- end
39
-
40
- rule offset_datetime
41
- ( skeleton:datetime_skeleton ("Z" | date_offset) ) <TomlRB::OffsetDateTimeParser>
42
- end
43
-
44
- rule local_datetime
45
- datetime_skeleton <TomlRB::LocalDateTimeParser>
46
- end
47
-
48
- rule local_date
49
- (date_skeleton space) <TomlRB::LocalDateParser>
50
- end
51
-
52
- rule local_time
53
- (time_skeleton space) <TomlRB::LocalTimeParser>
54
- end
55
-
56
- rule datetime_skeleton
57
- (date_skeleton ("T"|" ") time_skeleton) {
58
- capture(:date_skeleton).value + capture(:time_skeleton).value
59
- }
60
- end
61
-
62
- rule date_skeleton
63
- (year:/\d\d\d\d/ "-" mon:/\d\d/ "-" day:/\d\d/) {
64
- [:year,:mon,:day].map{ |s| capture(s).value }
65
- }
66
- end
67
-
68
- rule time_skeleton
69
- ( hour:([0-2][0-9]) ":" mim:([0-6][0-9]) ":" sec:([0-6][0-9]) ([,\.] sec_frac:(/\d/1*6))? ) {
70
- [:hour,:mim,:sec].map{ |s| capture(s).value } + [capture(:sec_frac) || '0']
71
- }
72
- end
73
-
74
- rule date_offset
75
- offset:(sign /\d\d/ ":" /\d\d/)
76
- end
77
-
78
- ##
79
- # Number rules
80
- ##
81
-
82
- rule number
83
- float | integer
84
- end
85
-
86
- rule float
87
- inf | nan | exponential_float | fractional_float
88
- end
89
-
90
- rule inf
91
- (s:sign? 'inf') {
92
- sign = (capture(:s).value != '-') ? 1 : -1
93
- sign * Float::INFINITY
94
- }
95
- end
96
-
97
- rule nan
98
- (sign? 'nan') { Float::NAN }
99
- end
100
-
101
- rule exponential_float
102
- ((fractional_float | decimal_integer) [eE] decimal_integer) { to_str.to_f }
103
- end
104
-
105
- rule fractional_float
106
- (decimal_integer '.' [0-9] ([0-9] | '_' [0-9])*) {
107
- to_str.to_f
108
- }
109
- end
110
-
111
- rule integer
112
- hexadecimal_integer | octal_integer | binary_integer | decimal_integer
113
- end
114
-
115
- rule hexadecimal_integer
116
- ('0x' [a-fA-F0-9] ([a-fA-F0-9] | '_' [a-fA-F0-9])*) { to_str.to_i(16) }
117
- end
118
-
119
- rule octal_integer
120
- ('0o' [0-7] ([0-7] | '_' [0-7])*) { to_str.to_i(8) }
121
- end
122
-
123
- rule binary_integer
124
- ('0b' [01] ([01] | '_' [01])*) { to_str.to_i(2) }
125
- end
126
-
127
- rule decimal_integer
128
- (sign? [0-9] ([0-9] | '_' [0-9])*) { to_str.to_i }
129
- end
130
-
131
- rule sign
132
- '+' | '-'
133
- end
134
-
135
- ##
136
- # Boolean rules
137
- ##
138
-
139
- rule bool
140
- true | false
141
- end
142
-
143
- rule true
144
- 'true' { true }
145
- end
146
-
147
- rule false
148
- 'false' { false }
149
- end
150
-
151
- ##
152
- # Key rules
153
- ##
154
-
155
- rule key
156
- dotted_key
157
- end
158
-
159
- rule bare_key
160
- [a-zA-Z0-9_-]+
161
- end
162
-
163
- rule quoted_key
164
- basic_string | literal_string
165
- end
166
-
167
- rule single_key
168
- quoted_key | bare_key
169
- end
170
-
171
- rule dotted_key
172
- (space? single_key space? ("." space? single_key space?)* ) {
173
- captures[:single_key].map(&:value)
174
- }
175
- end
176
- end