philiprehberger-toml_kit 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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +26 -0
- data/LICENSE +21 -0
- data/README.md +130 -0
- data/lib/philiprehberger/toml_kit/parser.rb +544 -0
- data/lib/philiprehberger/toml_kit/serializer.rb +151 -0
- data/lib/philiprehberger/toml_kit/version.rb +7 -0
- data/lib/philiprehberger/toml_kit.rb +48 -0
- metadata +56 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: b5a839127b00750fc45d36aad221cad5df5320e80077950ce024ecf665d60471
|
|
4
|
+
data.tar.gz: e39bfae29986a5aa2d06e124d3944390b47cd27db75803971852da6236f58f84
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: be8644f17293ba8b240b020b0ec900ea674df859e473f29ae563c500ff96d3aed3c2be697d972e4c5bba6875493f5446fa7cd4977082e98b37761be171ef2827
|
|
7
|
+
data.tar.gz: f3d17fe3cb22e6328d79d0d52671529a460a623c6832c60432e456b3ae883d2e38da8e763bdff61d024a6afe8089432a0790a6a737432e135432bc10bd323454
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this gem will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.1.0] - 2026-03-26
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- Initial release
|
|
14
|
+
- TOML v1.0 parser with full type support
|
|
15
|
+
- Key types: bare keys, quoted keys, dotted keys
|
|
16
|
+
- Value types: strings, integers, floats, booleans, datetimes, arrays, inline tables
|
|
17
|
+
- Integer formats: decimal, hexadecimal (0x), octal (0o), binary (0b)
|
|
18
|
+
- Special float values: inf, -inf, nan
|
|
19
|
+
- Date/time types: offset datetime, local datetime, local date, local time
|
|
20
|
+
- Standard tables and array of tables
|
|
21
|
+
- Multiline basic and literal strings
|
|
22
|
+
- Hash to TOML serializer
|
|
23
|
+
- `TomlKit.parse` for parsing TOML strings
|
|
24
|
+
- `TomlKit.load` for parsing TOML files
|
|
25
|
+
- `TomlKit.dump` for serializing hashes to TOML strings
|
|
26
|
+
- `TomlKit.save` for writing hashes to TOML files
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 philiprehberger
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# philiprehberger-toml_kit
|
|
2
|
+
|
|
3
|
+
[](https://github.com/philiprehberger/rb-toml-kit/actions/workflows/ci.yml)
|
|
4
|
+
[](https://rubygems.org/gems/philiprehberger-toml_kit)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
[](https://github.com/sponsors/philiprehberger)
|
|
7
|
+
|
|
8
|
+
TOML v1.0 parser and serializer for Ruby
|
|
9
|
+
|
|
10
|
+
## Requirements
|
|
11
|
+
|
|
12
|
+
- Ruby >= 3.1
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
Add to your Gemfile:
|
|
17
|
+
|
|
18
|
+
```ruby
|
|
19
|
+
gem "philiprehberger-toml_kit"
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Or install directly:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
gem install philiprehberger-toml_kit
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
29
|
+
|
|
30
|
+
```ruby
|
|
31
|
+
require "philiprehberger/toml_kit"
|
|
32
|
+
|
|
33
|
+
data = Philiprehberger::TomlKit.parse('title = "TOML Example"')
|
|
34
|
+
# => {"title" => "TOML Example"}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### Parsing Strings
|
|
38
|
+
|
|
39
|
+
```ruby
|
|
40
|
+
toml = <<~TOML
|
|
41
|
+
[database]
|
|
42
|
+
host = "localhost"
|
|
43
|
+
port = 5432
|
|
44
|
+
enabled = true
|
|
45
|
+
|
|
46
|
+
[[servers]]
|
|
47
|
+
name = "alpha"
|
|
48
|
+
port = 8001
|
|
49
|
+
|
|
50
|
+
[[servers]]
|
|
51
|
+
name = "beta"
|
|
52
|
+
port = 8002
|
|
53
|
+
TOML
|
|
54
|
+
|
|
55
|
+
config = Philiprehberger::TomlKit.parse(toml)
|
|
56
|
+
config["database"]["host"] # => "localhost"
|
|
57
|
+
config["servers"][0]["name"] # => "alpha"
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Loading Files
|
|
61
|
+
|
|
62
|
+
```ruby
|
|
63
|
+
config = Philiprehberger::TomlKit.load("config.toml")
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Serializing to TOML
|
|
67
|
+
|
|
68
|
+
```ruby
|
|
69
|
+
hash = {
|
|
70
|
+
"title" => "My App",
|
|
71
|
+
"database" => { "host" => "localhost", "port" => 5432 },
|
|
72
|
+
"servers" => [
|
|
73
|
+
{ "name" => "alpha", "port" => 8001 },
|
|
74
|
+
{ "name" => "beta", "port" => 8002 }
|
|
75
|
+
]
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
toml_string = Philiprehberger::TomlKit.dump(hash)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Saving to Files
|
|
82
|
+
|
|
83
|
+
```ruby
|
|
84
|
+
Philiprehberger::TomlKit.save(hash, "output.toml")
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### Supported Types
|
|
88
|
+
|
|
89
|
+
All TOML v1.0 types are supported:
|
|
90
|
+
|
|
91
|
+
```ruby
|
|
92
|
+
toml = <<~TOML
|
|
93
|
+
str = "hello"
|
|
94
|
+
int = 42
|
|
95
|
+
hex = 0xDEADBEEF
|
|
96
|
+
oct = 0o755
|
|
97
|
+
bin = 0b11010110
|
|
98
|
+
flt = 3.14
|
|
99
|
+
inf_val = inf
|
|
100
|
+
bool = true
|
|
101
|
+
dt = 1979-05-27T07:32:00Z
|
|
102
|
+
date = 1979-05-27
|
|
103
|
+
time = 07:32:00
|
|
104
|
+
arr = [1, 2, 3]
|
|
105
|
+
inline = {x = 1, y = 2}
|
|
106
|
+
TOML
|
|
107
|
+
|
|
108
|
+
data = Philiprehberger::TomlKit.parse(toml)
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## API
|
|
112
|
+
|
|
113
|
+
| Method | Description |
|
|
114
|
+
|--------|-------------|
|
|
115
|
+
| `TomlKit.parse(string)` | Parse a TOML string into a Hash |
|
|
116
|
+
| `TomlKit.load(path)` | Parse a TOML file into a Hash |
|
|
117
|
+
| `TomlKit.dump(hash)` | Serialize a Hash to a TOML string |
|
|
118
|
+
| `TomlKit.save(hash, path)` | Write a Hash as a TOML file |
|
|
119
|
+
|
|
120
|
+
## Development
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
bundle install
|
|
124
|
+
bundle exec rspec
|
|
125
|
+
bundle exec rubocop
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## License
|
|
129
|
+
|
|
130
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'date'
|
|
4
|
+
require 'time'
|
|
5
|
+
require 'strscan'
|
|
6
|
+
|
|
7
|
+
module Philiprehberger
|
|
8
|
+
module TomlKit
|
|
9
|
+
# TOML v1.0 parser.
|
|
10
|
+
#
|
|
11
|
+
# Parses a TOML string into a Ruby Hash with proper type mapping:
|
|
12
|
+
# - Strings -> String
|
|
13
|
+
# - Integers -> Integer
|
|
14
|
+
# - Floats -> Float
|
|
15
|
+
# - Booleans -> true/false
|
|
16
|
+
# - Offset Date-Time -> Time
|
|
17
|
+
# - Local Date-Time -> Time (local)
|
|
18
|
+
# - Local Date -> Date
|
|
19
|
+
# - Local Time -> Hash with :hour, :minute, :second keys
|
|
20
|
+
# - Arrays -> Array
|
|
21
|
+
# - Inline Tables -> Hash
|
|
22
|
+
# - Tables -> Hash (nested)
|
|
23
|
+
# - Array of Tables -> Array of Hashes
|
|
24
|
+
class Parser
|
|
25
|
+
# @param input [String] TOML document
|
|
26
|
+
# @return [Hash] parsed result
|
|
27
|
+
def parse(input)
|
|
28
|
+
@scanner = StringScanner.new(input)
|
|
29
|
+
@result = {}
|
|
30
|
+
@current_table = @result
|
|
31
|
+
@current_path = []
|
|
32
|
+
@implicit_tables = {}
|
|
33
|
+
@defined_tables = {}
|
|
34
|
+
@defined_array_tables = {}
|
|
35
|
+
|
|
36
|
+
parse_document
|
|
37
|
+
@result
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def parse_document
|
|
43
|
+
skip_whitespace_and_newlines
|
|
44
|
+
until @scanner.eos?
|
|
45
|
+
skip_whitespace
|
|
46
|
+
case @scanner.peek(1)
|
|
47
|
+
when '#'
|
|
48
|
+
skip_comment
|
|
49
|
+
when '['
|
|
50
|
+
parse_table_header
|
|
51
|
+
when "\n", "\r"
|
|
52
|
+
skip_newline
|
|
53
|
+
when ''
|
|
54
|
+
break
|
|
55
|
+
else
|
|
56
|
+
key, value = parse_key_value
|
|
57
|
+
set_value(@current_table, key, value)
|
|
58
|
+
end
|
|
59
|
+
skip_whitespace_and_newlines
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def skip_whitespace
|
|
64
|
+
@scanner.scan(/[ \t]*/)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def skip_newline
|
|
68
|
+
@scanner.scan(/\r?\n/)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def skip_whitespace_and_newlines
|
|
72
|
+
@scanner.scan(/\s*/)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def skip_comment
|
|
76
|
+
@scanner.scan(/#[^\n]*/)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def skip_whitespace_and_comments
|
|
80
|
+
loop do
|
|
81
|
+
skip_whitespace
|
|
82
|
+
break unless @scanner.peek(1) == '#'
|
|
83
|
+
|
|
84
|
+
skip_comment
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def parse_table_header
|
|
89
|
+
if @scanner.peek(2) == '[['
|
|
90
|
+
parse_array_table
|
|
91
|
+
else
|
|
92
|
+
parse_standard_table
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def parse_standard_table
|
|
97
|
+
@scanner.scan('[')
|
|
98
|
+
skip_whitespace
|
|
99
|
+
keys = parse_key
|
|
100
|
+
skip_whitespace
|
|
101
|
+
expect(']')
|
|
102
|
+
|
|
103
|
+
skip_whitespace_and_comments
|
|
104
|
+
consume_newline_or_eof
|
|
105
|
+
|
|
106
|
+
path_str = keys.join('.')
|
|
107
|
+
raise ParseError, "Table [#{path_str}] already defined" if @defined_tables[path_str]
|
|
108
|
+
|
|
109
|
+
@defined_tables[path_str] = true
|
|
110
|
+
@current_path = keys
|
|
111
|
+
@current_table = navigate_to_table(@result, keys, define: true)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def parse_array_table
|
|
115
|
+
@scanner.scan('[[')
|
|
116
|
+
skip_whitespace
|
|
117
|
+
keys = parse_key
|
|
118
|
+
skip_whitespace
|
|
119
|
+
expect(']]')
|
|
120
|
+
|
|
121
|
+
skip_whitespace_and_comments
|
|
122
|
+
consume_newline_or_eof
|
|
123
|
+
|
|
124
|
+
path_str = keys.join('.')
|
|
125
|
+
@defined_array_tables[path_str] = true
|
|
126
|
+
@current_path = keys
|
|
127
|
+
|
|
128
|
+
parent = navigate_to_table(@result, keys[0...-1], define: false)
|
|
129
|
+
last_key = keys.last
|
|
130
|
+
|
|
131
|
+
parent[last_key] = [] unless parent.key?(last_key)
|
|
132
|
+
arr = parent[last_key]
|
|
133
|
+
raise ParseError, "Key #{last_key} is not an array of tables" unless arr.is_a?(Array)
|
|
134
|
+
|
|
135
|
+
new_table = {}
|
|
136
|
+
arr << new_table
|
|
137
|
+
@current_table = new_table
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def navigate_to_table(root, keys, define:)
|
|
141
|
+
current = root
|
|
142
|
+
keys.each_with_index do |key, idx|
|
|
143
|
+
partial_path = keys[0..idx].join('.')
|
|
144
|
+
if current.key?(key)
|
|
145
|
+
val = current[key]
|
|
146
|
+
if val.is_a?(Array)
|
|
147
|
+
current = val.last
|
|
148
|
+
elsif val.is_a?(Hash)
|
|
149
|
+
current = val
|
|
150
|
+
else
|
|
151
|
+
raise ParseError, "Key #{key} already exists as a non-table value"
|
|
152
|
+
end
|
|
153
|
+
else
|
|
154
|
+
new_table = {}
|
|
155
|
+
current[key] = new_table
|
|
156
|
+
@implicit_tables[partial_path] = true if !define || idx < keys.length - 1
|
|
157
|
+
current = new_table
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
current
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def parse_key_value
|
|
164
|
+
keys = parse_key
|
|
165
|
+
skip_whitespace
|
|
166
|
+
expect('=')
|
|
167
|
+
skip_whitespace
|
|
168
|
+
value = parse_value
|
|
169
|
+
skip_whitespace_and_comments
|
|
170
|
+
consume_newline_or_eof
|
|
171
|
+
[keys, value]
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def set_value(table, keys, value)
|
|
175
|
+
current = table
|
|
176
|
+
keys[0...-1].each do |key|
|
|
177
|
+
if current.key?(key)
|
|
178
|
+
existing = current[key]
|
|
179
|
+
if existing.is_a?(Array)
|
|
180
|
+
current = existing.last
|
|
181
|
+
elsif existing.is_a?(Hash)
|
|
182
|
+
current = existing
|
|
183
|
+
else
|
|
184
|
+
raise ParseError, "Key #{key} already exists as a non-table value"
|
|
185
|
+
end
|
|
186
|
+
else
|
|
187
|
+
new_table = {}
|
|
188
|
+
current[key] = new_table
|
|
189
|
+
current = new_table
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
last_key = keys.last
|
|
193
|
+
raise ParseError, "Duplicate key: #{last_key}" if current.key?(last_key)
|
|
194
|
+
|
|
195
|
+
current[last_key] = value
|
|
196
|
+
current
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def parse_key
|
|
200
|
+
keys = [parse_simple_key]
|
|
201
|
+
keys << parse_simple_key while @scanner.scan(/[ \t]*\.[ \t]*/)
|
|
202
|
+
keys
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def parse_simple_key
|
|
206
|
+
if @scanner.peek(1) == '"'
|
|
207
|
+
parse_basic_string
|
|
208
|
+
elsif @scanner.peek(1) == "'"
|
|
209
|
+
parse_literal_string
|
|
210
|
+
else
|
|
211
|
+
parse_bare_key
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def parse_bare_key
|
|
216
|
+
key = @scanner.scan(/[A-Za-z0-9_-]+/)
|
|
217
|
+
raise ParseError, "Expected bare key at position #{@scanner.pos}" unless key
|
|
218
|
+
|
|
219
|
+
key
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def parse_value
|
|
223
|
+
case @scanner.peek(1)
|
|
224
|
+
when '"'
|
|
225
|
+
if @scanner.peek(3) == '"""'
|
|
226
|
+
parse_multiline_basic_string
|
|
227
|
+
else
|
|
228
|
+
parse_basic_string
|
|
229
|
+
end
|
|
230
|
+
when "'"
|
|
231
|
+
if @scanner.peek(3) == "'''"
|
|
232
|
+
parse_multiline_literal_string
|
|
233
|
+
else
|
|
234
|
+
parse_literal_string
|
|
235
|
+
end
|
|
236
|
+
when 't'
|
|
237
|
+
parse_true
|
|
238
|
+
when 'f'
|
|
239
|
+
parse_false
|
|
240
|
+
when '['
|
|
241
|
+
parse_array
|
|
242
|
+
when '{'
|
|
243
|
+
parse_inline_table
|
|
244
|
+
when 'i', 'n'
|
|
245
|
+
parse_special_float
|
|
246
|
+
when '+', '-'
|
|
247
|
+
if @scanner.rest.match?(/\A[+-](inf|nan)/)
|
|
248
|
+
parse_special_float
|
|
249
|
+
else
|
|
250
|
+
parse_number_or_date
|
|
251
|
+
end
|
|
252
|
+
else
|
|
253
|
+
parse_number_or_date
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def parse_basic_string
|
|
258
|
+
expect('"')
|
|
259
|
+
result = +''
|
|
260
|
+
until @scanner.eos?
|
|
261
|
+
ch = @scanner.scan(/[^"\\]+/)
|
|
262
|
+
result << ch if ch
|
|
263
|
+
if @scanner.peek(1) == '\\'
|
|
264
|
+
result << parse_escape_sequence
|
|
265
|
+
elsif @scanner.peek(1) == '"'
|
|
266
|
+
@scanner.scan('"')
|
|
267
|
+
return result
|
|
268
|
+
else
|
|
269
|
+
raise ParseError, 'Unterminated basic string'
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
raise ParseError, 'Unterminated basic string'
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def parse_escape_sequence
|
|
276
|
+
@scanner.scan('\\')
|
|
277
|
+
ch = @scanner.getch
|
|
278
|
+
case ch
|
|
279
|
+
when 'b' then "\b"
|
|
280
|
+
when 't' then "\t"
|
|
281
|
+
when 'n' then "\n"
|
|
282
|
+
when 'f' then "\f"
|
|
283
|
+
when 'r' then "\r"
|
|
284
|
+
when '"' then '"'
|
|
285
|
+
when '\\' then '\\'
|
|
286
|
+
when 'u'
|
|
287
|
+
hex = @scanner.scan(/[0-9A-Fa-f]{4}/)
|
|
288
|
+
raise ParseError, 'Invalid unicode escape' unless hex
|
|
289
|
+
|
|
290
|
+
hex.to_i(16).chr(Encoding::UTF_8)
|
|
291
|
+
when 'U'
|
|
292
|
+
hex = @scanner.scan(/[0-9A-Fa-f]{8}/)
|
|
293
|
+
raise ParseError, 'Invalid unicode escape' unless hex
|
|
294
|
+
|
|
295
|
+
hex.to_i(16).chr(Encoding::UTF_8)
|
|
296
|
+
else
|
|
297
|
+
raise ParseError, "Invalid escape sequence: \\#{ch}"
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def parse_multiline_basic_string
|
|
302
|
+
@scanner.scan('"""')
|
|
303
|
+
# skip first newline if immediately after opening
|
|
304
|
+
@scanner.scan(/\r?\n/)
|
|
305
|
+
result = +''
|
|
306
|
+
until @scanner.eos?
|
|
307
|
+
if @scanner.peek(3) == '"""'
|
|
308
|
+
@scanner.scan('"""')
|
|
309
|
+
return result
|
|
310
|
+
elsif @scanner.peek(1) == '\\'
|
|
311
|
+
if @scanner.rest.match?(/\\\s*\n/)
|
|
312
|
+
# line-ending backslash: trim whitespace
|
|
313
|
+
@scanner.scan(/\\[ \t]*\r?\n\s*/)
|
|
314
|
+
else
|
|
315
|
+
result << parse_escape_sequence
|
|
316
|
+
end
|
|
317
|
+
else
|
|
318
|
+
ch = @scanner.getch
|
|
319
|
+
raise ParseError, 'Unterminated multiline basic string' unless ch
|
|
320
|
+
|
|
321
|
+
result << ch
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
raise ParseError, 'Unterminated multiline basic string'
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def parse_literal_string
|
|
328
|
+
expect("'")
|
|
329
|
+
content = @scanner.scan(/[^']*/)
|
|
330
|
+
expect("'")
|
|
331
|
+
content || ''
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def parse_multiline_literal_string
|
|
335
|
+
@scanner.scan('\'\'\'')
|
|
336
|
+
@scanner.scan(/\r?\n/)
|
|
337
|
+
result = +''
|
|
338
|
+
until @scanner.eos?
|
|
339
|
+
if @scanner.peek(3) == "'''"
|
|
340
|
+
@scanner.scan('\'\'\'')
|
|
341
|
+
return result
|
|
342
|
+
else
|
|
343
|
+
ch = @scanner.getch
|
|
344
|
+
raise ParseError, 'Unterminated multiline literal string' unless ch
|
|
345
|
+
|
|
346
|
+
result << ch
|
|
347
|
+
end
|
|
348
|
+
end
|
|
349
|
+
raise ParseError, 'Unterminated multiline literal string'
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def parse_true
|
|
353
|
+
@scanner.scan('true') or raise ParseError, "Expected 'true'"
|
|
354
|
+
true
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
def parse_false
|
|
358
|
+
@scanner.scan('false') or raise ParseError, "Expected 'false'"
|
|
359
|
+
false
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def parse_special_float
|
|
363
|
+
str = @scanner.scan(/[+-]?(inf|nan)/)
|
|
364
|
+
raise ParseError, 'Expected inf or nan' unless str
|
|
365
|
+
|
|
366
|
+
case str
|
|
367
|
+
when 'inf', '+inf' then Float::INFINITY
|
|
368
|
+
when '-inf' then -Float::INFINITY
|
|
369
|
+
when 'nan', '+nan', '-nan' then Float::NAN
|
|
370
|
+
end
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
def parse_number_or_date
|
|
374
|
+
# Peek ahead to determine type
|
|
375
|
+
rest = @scanner.rest
|
|
376
|
+
|
|
377
|
+
# Offset date-time: 1979-05-27T07:32:00Z or 1979-05-27T07:32:00+00:00
|
|
378
|
+
case rest
|
|
379
|
+
when /\A\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})/
|
|
380
|
+
parse_offset_datetime
|
|
381
|
+
# Local date-time: 1979-05-27T07:32:00
|
|
382
|
+
when /\A\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}/
|
|
383
|
+
parse_local_datetime
|
|
384
|
+
# Local date: 1979-05-27
|
|
385
|
+
when /\A\d{4}-\d{2}-\d{2}(?![T \d])/
|
|
386
|
+
parse_local_date
|
|
387
|
+
# Local time: 07:32:00
|
|
388
|
+
when /\A\d{2}:\d{2}:\d{2}/
|
|
389
|
+
parse_local_time
|
|
390
|
+
# Hex integer: 0x...
|
|
391
|
+
when /\A[+-]?0x/
|
|
392
|
+
parse_hex_integer
|
|
393
|
+
# Octal integer: 0o...
|
|
394
|
+
when /\A[+-]?0o/
|
|
395
|
+
parse_octal_integer
|
|
396
|
+
# Binary integer: 0b...
|
|
397
|
+
when /\A[+-]?0b/
|
|
398
|
+
parse_binary_integer
|
|
399
|
+
# Float (has dot or exponent)
|
|
400
|
+
when /\A[+-]?\d[\d_]*(\.\d[\d_]*)?[eE]/, /\A[+-]?\d[\d_]*\.\d/
|
|
401
|
+
parse_float
|
|
402
|
+
else
|
|
403
|
+
parse_integer
|
|
404
|
+
end
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
def parse_offset_datetime
|
|
408
|
+
str = @scanner.scan(/\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})/)
|
|
409
|
+
raise ParseError, 'Invalid offset datetime' unless str
|
|
410
|
+
|
|
411
|
+
Time.parse(str)
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
def parse_local_datetime
|
|
415
|
+
str = @scanner.scan(/\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(\.\d+)?/)
|
|
416
|
+
raise ParseError, 'Invalid local datetime' unless str
|
|
417
|
+
|
|
418
|
+
Time.parse(str)
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
def parse_local_date
|
|
422
|
+
str = @scanner.scan(/\d{4}-\d{2}-\d{2}/)
|
|
423
|
+
raise ParseError, 'Invalid local date' unless str
|
|
424
|
+
|
|
425
|
+
Date.parse(str)
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
def parse_local_time
|
|
429
|
+
str = @scanner.scan(/\d{2}:\d{2}:\d{2}(\.\d+)?/)
|
|
430
|
+
raise ParseError, 'Invalid local time' unless str
|
|
431
|
+
|
|
432
|
+
parts = str.split(':')
|
|
433
|
+
hour = parts[0].to_i
|
|
434
|
+
minute = parts[1].to_i
|
|
435
|
+
sec_parts = parts[2].split('.')
|
|
436
|
+
second = sec_parts[0].to_i
|
|
437
|
+
nanosecond = sec_parts[1] ? sec_parts[1].ljust(9, '0')[0, 9].to_i : 0
|
|
438
|
+
|
|
439
|
+
{ hour: hour, minute: minute, second: second, nanosecond: nanosecond }
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
def parse_integer
|
|
443
|
+
str = @scanner.scan(/[+-]?\d[\d_]*/)
|
|
444
|
+
raise ParseError, "Invalid integer at position #{@scanner.pos}" unless str
|
|
445
|
+
|
|
446
|
+
str.delete('_').to_i
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
def parse_hex_integer
|
|
450
|
+
str = @scanner.scan(/[+-]?0x[0-9A-Fa-f_]+/)
|
|
451
|
+
raise ParseError, 'Invalid hex integer' unless str
|
|
452
|
+
|
|
453
|
+
str.delete('_').to_i(16)
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
def parse_octal_integer
|
|
457
|
+
str = @scanner.scan(/[+-]?0o[0-7_]+/)
|
|
458
|
+
raise ParseError, 'Invalid octal integer' unless str
|
|
459
|
+
|
|
460
|
+
str.delete('_').to_i(8)
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
def parse_binary_integer
|
|
464
|
+
str = @scanner.scan(/[+-]?0b[01_]+/)
|
|
465
|
+
raise ParseError, 'Invalid binary integer' unless str
|
|
466
|
+
|
|
467
|
+
str.delete('_').to_i(2)
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
def parse_float
|
|
471
|
+
str = @scanner.scan(/[+-]?\d[\d_]*(\.\d[\d_]*)?([eE][+-]?\d[\d_]*)?/)
|
|
472
|
+
raise ParseError, 'Invalid float' unless str
|
|
473
|
+
|
|
474
|
+
str.delete('_').to_f
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
def parse_array
|
|
478
|
+
@scanner.scan('[')
|
|
479
|
+
arr = []
|
|
480
|
+
skip_whitespace_and_newlines
|
|
481
|
+
skip_comments_in_collection
|
|
482
|
+
|
|
483
|
+
until @scanner.peek(1) == ']'
|
|
484
|
+
arr << parse_value
|
|
485
|
+
skip_whitespace_and_newlines
|
|
486
|
+
skip_comments_in_collection
|
|
487
|
+
@scanner.scan(',')
|
|
488
|
+
skip_whitespace_and_newlines
|
|
489
|
+
skip_comments_in_collection
|
|
490
|
+
end
|
|
491
|
+
expect(']')
|
|
492
|
+
arr
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
def parse_inline_table
|
|
496
|
+
@scanner.scan('{')
|
|
497
|
+
table = {}
|
|
498
|
+
skip_whitespace
|
|
499
|
+
unless @scanner.peek(1) == '}'
|
|
500
|
+
loop do
|
|
501
|
+
keys = parse_key
|
|
502
|
+
skip_whitespace
|
|
503
|
+
expect('=')
|
|
504
|
+
skip_whitespace
|
|
505
|
+
value = parse_value
|
|
506
|
+
set_value(table, keys, value)
|
|
507
|
+
skip_whitespace
|
|
508
|
+
break unless @scanner.scan(',')
|
|
509
|
+
|
|
510
|
+
skip_whitespace
|
|
511
|
+
end
|
|
512
|
+
end
|
|
513
|
+
expect('}')
|
|
514
|
+
table
|
|
515
|
+
end
|
|
516
|
+
|
|
517
|
+
def skip_comments_in_collection
|
|
518
|
+
loop do
|
|
519
|
+
skip_whitespace_and_newlines
|
|
520
|
+
break unless @scanner.peek(1) == '#'
|
|
521
|
+
|
|
522
|
+
skip_comment
|
|
523
|
+
end
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
def expect(str)
|
|
527
|
+
return if @scanner.scan(Regexp.new(Regexp.escape(str)))
|
|
528
|
+
|
|
529
|
+
raise ParseError, "Expected '#{str}' at position #{@scanner.pos}"
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
def consume_newline_or_eof
|
|
533
|
+
return if @scanner.eos?
|
|
534
|
+
|
|
535
|
+
skip_whitespace
|
|
536
|
+
return if @scanner.eos?
|
|
537
|
+
return if @scanner.scan(/\r?\n/)
|
|
538
|
+
return if @scanner.peek(1) == '#'
|
|
539
|
+
|
|
540
|
+
raise ParseError, "Expected newline or EOF at position #{@scanner.pos}, got '#{@scanner.peek(10)}'"
|
|
541
|
+
end
|
|
542
|
+
end
|
|
543
|
+
end
|
|
544
|
+
end
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'date'
|
|
4
|
+
require 'time'
|
|
5
|
+
|
|
6
|
+
module Philiprehberger
|
|
7
|
+
module TomlKit
|
|
8
|
+
# Converts a Ruby Hash into a TOML v1.0 formatted string.
|
|
9
|
+
#
|
|
10
|
+
# Type mapping:
|
|
11
|
+
# - String -> TOML basic string (with escapes)
|
|
12
|
+
# - Integer -> TOML integer
|
|
13
|
+
# - Float -> TOML float (handles inf, nan)
|
|
14
|
+
# - true/false -> TOML boolean
|
|
15
|
+
# - Time -> TOML offset or local date-time
|
|
16
|
+
# - Date -> TOML local date
|
|
17
|
+
# - Hash with :hour/:minute/:second -> TOML local time
|
|
18
|
+
# - Array -> TOML array (or array of tables if all elements are Hashes)
|
|
19
|
+
# - Hash -> TOML table
|
|
20
|
+
class Serializer
|
|
21
|
+
# @param hash [Hash] Ruby hash to serialize
|
|
22
|
+
# @return [String] TOML formatted string
|
|
23
|
+
def serialize(hash)
|
|
24
|
+
lines = []
|
|
25
|
+
serialize_table(hash, [], lines)
|
|
26
|
+
lines.join("\n") << "\n"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def serialize_table(hash, path, lines)
|
|
32
|
+
# First pass: emit simple key/value pairs and inline structures
|
|
33
|
+
simple_keys = []
|
|
34
|
+
table_keys = []
|
|
35
|
+
array_table_keys = []
|
|
36
|
+
|
|
37
|
+
hash.each do |key, value|
|
|
38
|
+
if value.is_a?(Hash) && !local_time_hash?(value)
|
|
39
|
+
table_keys << key
|
|
40
|
+
elsif value.is_a?(Array) && value.all?(Hash)
|
|
41
|
+
array_table_keys << key
|
|
42
|
+
else
|
|
43
|
+
simple_keys << key
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
simple_keys.each do |key|
|
|
48
|
+
lines << "#{format_key(key)} = #{format_value(hash[key])}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
table_keys.each do |key|
|
|
52
|
+
full_path = path + [key]
|
|
53
|
+
lines << '' unless lines.empty?
|
|
54
|
+
lines << "[#{full_path.map { |k| format_key(k) }.join('.')}]"
|
|
55
|
+
serialize_table(hash[key], full_path, lines)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
array_table_keys.each do |key|
|
|
59
|
+
full_path = path + [key]
|
|
60
|
+
hash[key].each do |element|
|
|
61
|
+
lines << '' unless lines.empty?
|
|
62
|
+
lines << "[[#{full_path.map { |k| format_key(k) }.join('.')}]]"
|
|
63
|
+
serialize_table(element, full_path, lines)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def format_key(key)
|
|
69
|
+
key = key.to_s
|
|
70
|
+
if key.match?(/\A[A-Za-z0-9_-]+\z/)
|
|
71
|
+
key
|
|
72
|
+
else
|
|
73
|
+
format_basic_string(key)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def format_value(value)
|
|
78
|
+
case value
|
|
79
|
+
when String then format_basic_string(value)
|
|
80
|
+
when Integer then value.to_s
|
|
81
|
+
when Float then format_float(value)
|
|
82
|
+
when true then 'true'
|
|
83
|
+
when false then 'false'
|
|
84
|
+
when Time then value.strftime('%Y-%m-%dT%H:%M:%S%:z')
|
|
85
|
+
when Date then value.strftime('%Y-%m-%d')
|
|
86
|
+
when Array then format_array(value)
|
|
87
|
+
when Hash
|
|
88
|
+
if local_time_hash?(value)
|
|
89
|
+
format_local_time(value)
|
|
90
|
+
else
|
|
91
|
+
format_inline_table(value)
|
|
92
|
+
end
|
|
93
|
+
else
|
|
94
|
+
format_basic_string(value.to_s)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def format_basic_string(str)
|
|
99
|
+
escaped = str.gsub('\\', '\\\\\\\\')
|
|
100
|
+
.gsub('"', '\\"')
|
|
101
|
+
.gsub("\b", '\\b')
|
|
102
|
+
.gsub("\t", '\\t')
|
|
103
|
+
.gsub("\n", '\\n')
|
|
104
|
+
.gsub("\f", '\\f')
|
|
105
|
+
.gsub("\r", '\\r')
|
|
106
|
+
"\"#{escaped}\""
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def format_float(value)
|
|
110
|
+
if value.infinite? == 1
|
|
111
|
+
'inf'
|
|
112
|
+
elsif value.infinite? == -1
|
|
113
|
+
'-inf'
|
|
114
|
+
elsif value.nan?
|
|
115
|
+
'nan'
|
|
116
|
+
else
|
|
117
|
+
# Ensure float always has a decimal point
|
|
118
|
+
str = value.to_s
|
|
119
|
+
str.include?('.') || str.include?('e') ? str : "#{str}.0"
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def format_array(arr)
|
|
124
|
+
elements = arr.map { |v| format_value(v) }
|
|
125
|
+
"[#{elements.join(', ')}]"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def format_inline_table(hash)
|
|
129
|
+
pairs = hash.map { |k, v| "#{format_key(k)} = #{format_value(v)}" }
|
|
130
|
+
"{#{pairs.join(', ')}}"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def format_local_time(hash)
|
|
134
|
+
hour = hash[:hour].to_s.rjust(2, '0')
|
|
135
|
+
minute = hash[:minute].to_s.rjust(2, '0')
|
|
136
|
+
second = hash[:second].to_s.rjust(2, '0')
|
|
137
|
+
nano = hash[:nanosecond] || 0
|
|
138
|
+
if nano.positive?
|
|
139
|
+
frac = nano.to_s.rjust(9, '0').sub(/0+\z/, '')
|
|
140
|
+
"#{hour}:#{minute}:#{second}.#{frac}"
|
|
141
|
+
else
|
|
142
|
+
"#{hour}:#{minute}:#{second}"
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def local_time_hash?(hash)
|
|
147
|
+
hash.key?(:hour) && hash.key?(:minute) && hash.key?(:second)
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'toml_kit/version'
|
|
4
|
+
require_relative 'toml_kit/parser'
|
|
5
|
+
require_relative 'toml_kit/serializer'
|
|
6
|
+
|
|
7
|
+
module Philiprehberger
|
|
8
|
+
module TomlKit
|
|
9
|
+
class Error < StandardError; end
|
|
10
|
+
class ParseError < Error; end
|
|
11
|
+
|
|
12
|
+
# Parse a TOML string into a Ruby Hash.
|
|
13
|
+
#
|
|
14
|
+
# @param string [String] TOML document
|
|
15
|
+
# @return [Hash] parsed result
|
|
16
|
+
# @raise [ParseError] if the input is not valid TOML
|
|
17
|
+
def self.parse(string)
|
|
18
|
+
Parser.new.parse(string)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Parse a TOML file into a Ruby Hash.
|
|
22
|
+
#
|
|
23
|
+
# @param path [String] path to a TOML file
|
|
24
|
+
# @return [Hash] parsed result
|
|
25
|
+
# @raise [ParseError] if the file contents are not valid TOML
|
|
26
|
+
# @raise [Errno::ENOENT] if the file does not exist
|
|
27
|
+
def self.load(path)
|
|
28
|
+
parse(File.read(path, encoding: 'utf-8'))
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Serialize a Ruby Hash into a TOML string.
|
|
32
|
+
#
|
|
33
|
+
# @param hash [Hash] data to serialize
|
|
34
|
+
# @return [String] TOML formatted string
|
|
35
|
+
def self.dump(hash)
|
|
36
|
+
Serializer.new.serialize(hash)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Write a Ruby Hash to a TOML file.
|
|
40
|
+
#
|
|
41
|
+
# @param hash [Hash] data to serialize
|
|
42
|
+
# @param path [String] output file path
|
|
43
|
+
# @return [void]
|
|
44
|
+
def self.save(hash, path)
|
|
45
|
+
File.write(path, dump(hash), encoding: 'utf-8')
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: philiprehberger-toml_kit
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Philip Rehberger
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-03-27 00:00:00.000000000 Z
|
|
12
|
+
dependencies: []
|
|
13
|
+
description: Parse and generate TOML v1.0 documents with full type support including
|
|
14
|
+
datetimes, inline tables, and array of tables. Zero dependencies.
|
|
15
|
+
email:
|
|
16
|
+
- me@philiprehberger.com
|
|
17
|
+
executables: []
|
|
18
|
+
extensions: []
|
|
19
|
+
extra_rdoc_files: []
|
|
20
|
+
files:
|
|
21
|
+
- CHANGELOG.md
|
|
22
|
+
- LICENSE
|
|
23
|
+
- README.md
|
|
24
|
+
- lib/philiprehberger/toml_kit.rb
|
|
25
|
+
- lib/philiprehberger/toml_kit/parser.rb
|
|
26
|
+
- lib/philiprehberger/toml_kit/serializer.rb
|
|
27
|
+
- lib/philiprehberger/toml_kit/version.rb
|
|
28
|
+
homepage: https://github.com/philiprehberger/rb-toml-kit
|
|
29
|
+
licenses:
|
|
30
|
+
- MIT
|
|
31
|
+
metadata:
|
|
32
|
+
homepage_uri: https://github.com/philiprehberger/rb-toml-kit
|
|
33
|
+
source_code_uri: https://github.com/philiprehberger/rb-toml-kit
|
|
34
|
+
changelog_uri: https://github.com/philiprehberger/rb-toml-kit/blob/main/CHANGELOG.md
|
|
35
|
+
bug_tracker_uri: https://github.com/philiprehberger/rb-toml-kit/issues
|
|
36
|
+
rubygems_mfa_required: 'true'
|
|
37
|
+
post_install_message:
|
|
38
|
+
rdoc_options: []
|
|
39
|
+
require_paths:
|
|
40
|
+
- lib
|
|
41
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
42
|
+
requirements:
|
|
43
|
+
- - ">="
|
|
44
|
+
- !ruby/object:Gem::Version
|
|
45
|
+
version: 3.1.0
|
|
46
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
47
|
+
requirements:
|
|
48
|
+
- - ">="
|
|
49
|
+
- !ruby/object:Gem::Version
|
|
50
|
+
version: '0'
|
|
51
|
+
requirements: []
|
|
52
|
+
rubygems_version: 3.5.22
|
|
53
|
+
signing_key:
|
|
54
|
+
specification_version: 4
|
|
55
|
+
summary: TOML v1.0 parser and serializer for Ruby
|
|
56
|
+
test_files: []
|