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 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
+ [![Tests](https://github.com/philiprehberger/rb-toml-kit/actions/workflows/ci.yml/badge.svg)](https://github.com/philiprehberger/rb-toml-kit/actions/workflows/ci.yml)
4
+ [![Gem Version](https://badge.fury.io/rb/philiprehberger-toml_kit.svg)](https://rubygems.org/gems/philiprehberger-toml_kit)
5
+ [![License](https://img.shields.io/github/license/philiprehberger/rb-toml-kit)](LICENSE)
6
+ [![Sponsor](https://img.shields.io/badge/sponsor-GitHub%20Sponsors-ec6cb9)](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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Philiprehberger
4
+ module TomlKit
5
+ VERSION = '0.1.0'
6
+ end
7
+ 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: []