philiprehberger-toml_kit 0.1.1 → 0.2.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: d199d5d8f8d7b28944c260c28a87887b56fd3cdc3e98b5c759035fd1f80d6bce
4
- data.tar.gz: b23ad52472d7bfc481f0480fa5db4ec759d0883aefa5e8184ab702ae9d802b7e
3
+ metadata.gz: 74417b867e192747c92bc5db6a147f1d52333718a6461f3dce72ee5e15ca5b45
4
+ data.tar.gz: 2f26751f1f266724a4493ceb5ec76bfde3e3832b05e7b804b0db8c452b2da93e
5
5
  SHA512:
6
- metadata.gz: fa8701b149c8f928413485936ed95072d6cd4dd4fc363ec6314b26161cc1718092d2a7147ed6ab5f4ca728cf7d2731cfc1a3b91abb933c97f60ee202e67978b8
7
- data.tar.gz: 7b100b0df6a733b3b97a8239c93c90c646a158bcc828827f786b1102d7aa44eb6f0c9bef4c8bdf268d700d3b2b0939b0e0c5e725b28ac192b3958de5240b0df9
6
+ metadata.gz: 197a2730afb7c93d928ced99cb39d3d328970a611738051dfb23289ad426fbdd87e2d967cf55c5a65d0cd2f4d1e9abd44b00dbc56e3d5683c0a6abb126d56705
7
+ data.tar.gz: 7460f7dcb52085a7f29150239d27bb03a657e62c90d433a3de6c421dc0e97b9890188aa9840f126a36b2c08e08fcace823fcc976e88369b3eff98458b8b9632d
data/CHANGELOG.md CHANGED
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.0] - 2026-03-28
11
+
12
+ ### Added
13
+
14
+ - Comment preservation during round-trip parse/serialize via `TomlKit.parse_with_comments` and `CommentDocument`
15
+ - TOML schema validation with `Schema` class supporting required keys, type checks, nested properties, and array items
16
+ - TOML merging with conflict resolution strategies (`:override`, `:keep_existing`, `:error_on_conflict`) via `TomlKit.merge`
17
+ - Dot-path query support via `TomlKit.query` with array indexing, `Query.set`, `Query.exists?`, and `Query.delete`
18
+ - Type coercion hooks via `TypeCoercion` for custom serializer/deserializer registration with optional tagged round-trips
19
+ - TOML diff via `TomlKit.diff` to compare two documents reporting additions, removals, and changes
20
+
10
21
  ## [0.1.1] - 2026-03-26
11
22
 
12
23
  ### Added
data/README.md CHANGED
@@ -1,11 +1,8 @@
1
1
  # philiprehberger-toml_kit
2
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)
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) [![Gem Version](https://img.shields.io/gem/v/philiprehberger-toml_kit)](https://rubygems.org/gems/philiprehberger-toml_kit) [![GitHub release](https://img.shields.io/github/v/release/philiprehberger/rb-toml-kit)](https://github.com/philiprehberger/rb-toml-kit/releases) [![GitHub last commit](https://img.shields.io/github/last-commit/philiprehberger/rb-toml-kit)](https://github.com/philiprehberger/rb-toml-kit/commits/main) [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) [![Bug Reports](https://img.shields.io/badge/bug-reports-red.svg)](https://github.com/philiprehberger/rb-toml-kit/issues) [![Feature Requests](https://img.shields.io/badge/feature-requests-blue.svg)](https://github.com/philiprehberger/rb-toml-kit/issues) [![GitHub Sponsors](https://img.shields.io/badge/sponsor-philiprehberger-ea4aaa.svg?logo=github)](https://github.com/sponsors/philiprehberger)
7
4
 
8
- TOML v1.0 parser and serializer for Ruby
5
+ TOML v1.0 parser and serializer for Ruby with comment preservation, schema validation, merging, querying, type coercion, and diffing.
9
6
 
10
7
  ## Requirements
11
8
 
@@ -108,6 +105,147 @@ TOML
108
105
  data = Philiprehberger::TomlKit.parse(toml)
109
106
  ```
110
107
 
108
+ ### Comment Preservation
109
+
110
+ Parse a TOML document while preserving comments for round-trip editing:
111
+
112
+ ```ruby
113
+ toml = <<~TOML
114
+ # Application config
115
+ title = "My App"
116
+
117
+ # Database settings
118
+ [database]
119
+ host = "localhost" # primary host
120
+ TOML
121
+
122
+ doc = Philiprehberger::TomlKit.parse_with_comments(toml)
123
+ doc["title"] # => "My App"
124
+ doc["database"]["host"] # => "localhost" (via doc.data)
125
+ doc.header_comments # => ["# Application config"]
126
+ doc.comments["database.host"] # => {inline: "# primary host"}
127
+
128
+ # Modify and re-serialize with comments intact
129
+ doc["title"] = "New App"
130
+ output = doc.to_toml
131
+ # Comments are preserved in the output
132
+ ```
133
+
134
+ ### Schema Validation
135
+
136
+ Define expected structure and validate parsed TOML against it:
137
+
138
+ ```ruby
139
+ schema = Philiprehberger::TomlKit::Schema.new(
140
+ "name" => { type: String, required: true },
141
+ "port" => { type: Integer, required: true },
142
+ "database" => {
143
+ type: Hash,
144
+ required: true,
145
+ properties: {
146
+ "host" => { type: String, required: true },
147
+ "port" => { type: Integer }
148
+ }
149
+ },
150
+ "tags" => { type: Array, items: { type: String } }
151
+ )
152
+
153
+ data = Philiprehberger::TomlKit.parse(toml_string)
154
+ errors = schema.validate(data)
155
+ # => [] if valid, or ["Missing required key: name", ...]
156
+
157
+ schema.validate!(data) # raises SchemaError if invalid
158
+ ```
159
+
160
+ ### Merging
161
+
162
+ Deep merge two TOML hashes with conflict resolution:
163
+
164
+ ```ruby
165
+ base = Philiprehberger::TomlKit.parse(base_toml)
166
+ overrides = Philiprehberger::TomlKit.parse(override_toml)
167
+
168
+ # Right-side wins (default)
169
+ merged = Philiprehberger::TomlKit.merge(base, overrides)
170
+
171
+ # Left-side wins
172
+ merged = Philiprehberger::TomlKit.merge(base, overrides, strategy: :keep_existing)
173
+
174
+ # Raise on conflict
175
+ merged = Philiprehberger::TomlKit.merge(base, overrides, strategy: :error_on_conflict)
176
+ # raises MergeConflictError if keys conflict
177
+ ```
178
+
179
+ ### Query Support
180
+
181
+ Access nested values using dot-paths:
182
+
183
+ ```ruby
184
+ data = Philiprehberger::TomlKit.parse(toml_string)
185
+
186
+ Philiprehberger::TomlKit.query(data, "database.host")
187
+ # => "localhost"
188
+
189
+ Philiprehberger::TomlKit.query(data, "servers[0].name")
190
+ # => "alpha"
191
+
192
+ Philiprehberger::TomlKit.query(data, "missing.path", default: "N/A")
193
+ # => "N/A"
194
+
195
+ # Additional Query methods
196
+ Philiprehberger::TomlKit::Query.set(data, "database.timeout", 30)
197
+ Philiprehberger::TomlKit::Query.exists?(data, "database.host") # => true
198
+ Philiprehberger::TomlKit::Query.delete(data, "database.timeout") # => 30
199
+ ```
200
+
201
+ ### Type Coercion Hooks
202
+
203
+ Register custom serializers and deserializers for Ruby types:
204
+
205
+ ```ruby
206
+ coercion = Philiprehberger::TomlKit::TypeCoercion.new
207
+
208
+ coercion.register(
209
+ Symbol,
210
+ tag: "symbol",
211
+ serializer: ->(v) { v.to_s },
212
+ deserializer: ->(v) { v.to_sym }
213
+ )
214
+
215
+ # Serialize: converts symbols to tagged strings
216
+ data = { "key" => :hello }
217
+ serialized = coercion.coerce_for_serialize(data)
218
+ toml = Philiprehberger::TomlKit.dump(serialized)
219
+
220
+ # Deserialize: converts tagged strings back to symbols
221
+ parsed = Philiprehberger::TomlKit.parse(toml)
222
+ restored = coercion.coerce_for_deserialize(parsed)
223
+ restored["key"] # => :hello
224
+ ```
225
+
226
+ ### TOML Diff
227
+
228
+ Compare two TOML documents and report differences:
229
+
230
+ ```ruby
231
+ old_config = Philiprehberger::TomlKit.parse(old_toml)
232
+ new_config = Philiprehberger::TomlKit.parse(new_toml)
233
+
234
+ changes = Philiprehberger::TomlKit.diff(old_config, new_config)
235
+ changes.each do |change|
236
+ puts "#{change.type}: #{change.path}"
237
+ # => :added, :removed, or :changed
238
+ end
239
+
240
+ # Filter by type
241
+ Philiprehberger::TomlKit::Diff.additions(old_config, new_config)
242
+ Philiprehberger::TomlKit::Diff.removals(old_config, new_config)
243
+ Philiprehberger::TomlKit::Diff.changes(old_config, new_config)
244
+
245
+ # Check equality
246
+ Philiprehberger::TomlKit::Diff.identical?(old_config, new_config)
247
+ ```
248
+
111
249
  ## API
112
250
 
113
251
  | Method | Description |
@@ -116,6 +254,26 @@ data = Philiprehberger::TomlKit.parse(toml)
116
254
  | `TomlKit.load(path)` | Parse a TOML file into a Hash |
117
255
  | `TomlKit.dump(hash)` | Serialize a Hash to a TOML string |
118
256
  | `TomlKit.save(hash, path)` | Write a Hash as a TOML file |
257
+ | `TomlKit.parse_with_comments(string)` | Parse TOML preserving comments, returns `CommentDocument` |
258
+ | `TomlKit.query(data, path, default:)` | Dot-path access into nested hashes |
259
+ | `TomlKit.merge(left, right, strategy:)` | Deep merge two hashes with conflict resolution |
260
+ | `TomlKit.diff(left, right)` | Compare two hashes, returns array of `Diff::Change` |
261
+ | `Schema.new(properties)` | Create a schema for validation |
262
+ | `Schema#validate(data)` | Validate data, returns array of error strings |
263
+ | `Schema#validate!(data)` | Validate data, raises `SchemaError` on failure |
264
+ | `Query.get(data, path, default:)` | Retrieve nested value by dot-path |
265
+ | `Query.set(data, path, value)` | Set nested value by dot-path |
266
+ | `Query.exists?(data, path)` | Check if a dot-path exists |
267
+ | `Query.delete(data, path)` | Delete value at dot-path |
268
+ | `Diff.diff(left, right)` | Full diff between two hashes |
269
+ | `Diff.additions(left, right)` | Keys added in right |
270
+ | `Diff.removals(left, right)` | Keys removed from left |
271
+ | `Diff.changes(left, right)` | Keys with changed values |
272
+ | `Diff.identical?(left, right)` | Check if two hashes are equal |
273
+ | `TypeCoercion#register(type, ...)` | Register custom type handler |
274
+ | `TypeCoercion#coerce_for_serialize(value)` | Apply serialization coercions |
275
+ | `TypeCoercion#coerce_for_deserialize(value)` | Apply deserialization coercions |
276
+ | `Merger.merge(left, right, strategy:)` | Merge with strategy |
119
277
 
120
278
  ## Development
121
279
 
@@ -125,6 +283,10 @@ bundle exec rspec
125
283
  bundle exec rubocop
126
284
  ```
127
285
 
286
+ ## Support
287
+
288
+ [![LinkedIn](https://img.shields.io/badge/LinkedIn-Philip%20Rehberger-blue?logo=linkedin)](https://linkedin.com/in/philiprehberger) [![More Packages](https://img.shields.io/badge/more-packages-blue.svg)](https://github.com/philiprehberger?tab=repositories)
289
+
128
290
  ## License
129
291
 
130
292
  [MIT](LICENSE)
@@ -0,0 +1,396 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Philiprehberger
4
+ module TomlKit
5
+ # Represents a TOML document that preserves comments during round-trip
6
+ # parse/serialize operations.
7
+ #
8
+ # Comments are stored as metadata attached to keys or sections:
9
+ # - :header_comments -> comments at the top of the document
10
+ # - :comments -> hash mapping "path.to.key" to { before: [...], inline: "..." }
11
+ # - :table_comments -> hash mapping "[table]" to { before: [...], inline: "..." }
12
+ class CommentDocument
13
+ attr_reader :data, :comments, :table_comments, :header_comments
14
+
15
+ def initialize(data = {}, comments: {}, table_comments: {}, header_comments: [])
16
+ @data = data
17
+ @comments = comments
18
+ @table_comments = table_comments
19
+ @header_comments = header_comments
20
+ end
21
+
22
+ # Parse a TOML string preserving comments.
23
+ #
24
+ # @param input [String] TOML document
25
+ # @return [CommentDocument]
26
+ def self.parse(input)
27
+ CommentPreservingParser.new.parse(input)
28
+ end
29
+
30
+ # Serialize back to a TOML string with comments preserved.
31
+ #
32
+ # @return [String] TOML formatted string with comments
33
+ def to_toml
34
+ CommentPreservingSerializer.new.serialize(self)
35
+ end
36
+
37
+ # Access the underlying data hash.
38
+ #
39
+ # @param key [String] top-level key
40
+ # @return [Object] value
41
+ def [](key)
42
+ @data[key]
43
+ end
44
+
45
+ # Set a value in the underlying data hash.
46
+ #
47
+ # @param key [String] top-level key
48
+ # @param value [Object] value to set
49
+ def []=(key, value)
50
+ @data[key] = value
51
+ end
52
+
53
+ # Return the data hash for compatibility.
54
+ #
55
+ # @return [Hash]
56
+ def to_h
57
+ @data
58
+ end
59
+ end
60
+
61
+ # Parser that tracks comments alongside parsed data.
62
+ class CommentPreservingParser
63
+ # @param input [String] TOML document
64
+ # @return [CommentDocument]
65
+ def parse(input)
66
+ @lines = input.lines
67
+ @data = {}
68
+ @comments = {}
69
+ @table_comments = {}
70
+ @header_comments = []
71
+ @current_path = []
72
+ @pending_comments = []
73
+
74
+ parse_lines
75
+ CommentDocument.new(@data, comments: @comments, table_comments: @table_comments,
76
+ header_comments: @header_comments)
77
+ end
78
+
79
+ private
80
+
81
+ def parse_lines
82
+ in_header = true
83
+
84
+ @lines.each do |line|
85
+ stripped = line.strip
86
+
87
+ if stripped.empty?
88
+ @pending_comments << '' unless @pending_comments.empty? || !in_header
89
+ next
90
+ end
91
+
92
+ if stripped.start_with?('#')
93
+ @pending_comments << stripped
94
+ next
95
+ end
96
+
97
+ in_header = false
98
+
99
+ if stripped.start_with?('[[')
100
+ parse_array_table_line(stripped)
101
+ elsif stripped.start_with?('[')
102
+ parse_table_line(stripped)
103
+ else
104
+ parse_key_value_line(stripped)
105
+ end
106
+ end
107
+ end
108
+
109
+ def parse_table_line(line)
110
+ # Extract inline comment
111
+ inline_comment = nil
112
+ table_part = line
113
+
114
+ # Find the closing bracket, then check for comment
115
+ bracket_end = line.index(']')
116
+ if bracket_end
117
+ rest = line[(bracket_end + 1)..]
118
+ if rest && (comment_idx = rest.index('#'))
119
+ inline_comment = rest[comment_idx..].strip
120
+ table_part = line[0..bracket_end]
121
+ end
122
+ end
123
+
124
+ # Extract table name
125
+ table_name = table_part.strip.sub(/\A\[/, '').sub(/\]\s*\z/, '').strip
126
+ @current_path = table_name.split('.')
127
+
128
+ # Navigate/create nested structure
129
+ navigate_to_table(@data, @current_path)
130
+
131
+ table_key = "[#{table_name}]"
132
+ store_table_comments(table_key, inline_comment)
133
+ end
134
+
135
+ def parse_array_table_line(line)
136
+ inline_comment = nil
137
+ table_part = line
138
+
139
+ bracket_end = line.index(']]')
140
+ if bracket_end
141
+ rest = line[(bracket_end + 2)..]
142
+ if rest && (comment_idx = rest.index('#'))
143
+ inline_comment = rest[comment_idx..].strip
144
+ table_part = line[0..(bracket_end + 1)]
145
+ end
146
+ end
147
+
148
+ table_name = table_part.strip.sub(/\A\[\[/, '').sub(/\]\]\s*\z/, '').strip
149
+ @current_path = table_name.split('.')
150
+
151
+ # Navigate to parent
152
+ parent = navigate_to_table(@data, @current_path[0...-1])
153
+ last_key = @current_path.last
154
+ parent[last_key] = [] unless parent.key?(last_key)
155
+ parent[last_key] << {}
156
+
157
+ table_key = "[[#{table_name}]]"
158
+ store_table_comments(table_key, inline_comment)
159
+ end
160
+
161
+ def store_table_comments(table_key, inline_comment)
162
+ entry = {}
163
+ entry[:before] = @pending_comments.dup unless @pending_comments.empty?
164
+ entry[:inline] = inline_comment if inline_comment
165
+ @table_comments[table_key] = entry unless entry.empty?
166
+ @pending_comments.clear
167
+ end
168
+
169
+ def parse_key_value_line(line)
170
+ # Extract inline comment - be careful not to match # inside strings
171
+ inline_comment = nil
172
+ key_part = extract_key_value_without_comment(line)
173
+ remaining = line[key_part.length..]
174
+ if remaining && (comment_idx = remaining.index('#'))
175
+ inline_comment = remaining[comment_idx..].strip
176
+ end
177
+
178
+ # Parse the key from the line
179
+ eq_idx = find_equals_index(key_part)
180
+ return unless eq_idx
181
+
182
+ raw_key = key_part[0...eq_idx].strip
183
+
184
+ # Build full path for comment storage
185
+ full_path = (@current_path + [raw_key]).join('.')
186
+
187
+ # Store comments
188
+ entry = {}
189
+ entry[:before] = @pending_comments.dup unless @pending_comments.empty?
190
+ entry[:inline] = inline_comment if inline_comment
191
+ @comments[full_path] = entry unless entry.empty?
192
+ @pending_comments.clear
193
+
194
+ # Actually parse the value using the real parser
195
+ value_str = key_part[(eq_idx + 1)..].strip
196
+ table = navigate_to_table(@data, @current_path)
197
+ keys = raw_key.split('.')
198
+ set_nested(table, keys, parse_value_simple(value_str))
199
+ end
200
+
201
+ def extract_key_value_without_comment(line)
202
+ in_string = false
203
+ string_char = nil
204
+ i = 0
205
+
206
+ # Skip past the = sign first
207
+ past_equals = false
208
+
209
+ while i < line.length
210
+ ch = line[i]
211
+
212
+ if in_string
213
+ if ch == '\\' && string_char == '"'
214
+ i += 2
215
+ next
216
+ end
217
+ in_string = false if ch == string_char
218
+ else
219
+ if ch == '=' && !past_equals
220
+ past_equals = true
221
+ i += 1
222
+ next
223
+ end
224
+
225
+ if past_equals
226
+ if ['"', "'"].include?(ch)
227
+ in_string = true
228
+ string_char = ch
229
+ elsif ch == '#'
230
+ return line[0...i].rstrip
231
+ end
232
+ end
233
+ end
234
+
235
+ i += 1
236
+ end
237
+
238
+ line.rstrip
239
+ end
240
+
241
+ def find_equals_index(str)
242
+ in_string = false
243
+ string_char = nil
244
+
245
+ str.each_char.with_index do |ch, i|
246
+ if in_string
247
+ if ch == '\\' && string_char == '"'
248
+ next
249
+ elsif ch == string_char
250
+ in_string = false
251
+ end
252
+ elsif ['"', "'"].include?(ch)
253
+ in_string = true
254
+ string_char = ch
255
+ elsif ch == '='
256
+ return i
257
+ end
258
+ end
259
+ nil
260
+ end
261
+
262
+ def parse_value_simple(str)
263
+ # Delegate to the real parser for proper type handling
264
+ Parser.new.parse("__key__ = #{str}")['__key__']
265
+ end
266
+
267
+ def navigate_to_table(root, keys)
268
+ current = root
269
+ keys.each do |key|
270
+ if current.key?(key)
271
+ val = current[key]
272
+ if val.is_a?(Array)
273
+ current = val.last
274
+ elsif val.is_a?(Hash)
275
+ current = val
276
+ else
277
+ return current
278
+ end
279
+ else
280
+ new_table = {}
281
+ current[key] = new_table
282
+ current = new_table
283
+ end
284
+ end
285
+ current
286
+ end
287
+
288
+ def set_nested(table, keys, value)
289
+ current = table
290
+ keys[0...-1].each do |key|
291
+ current[key] = {} unless current.key?(key)
292
+ current = current[key]
293
+ end
294
+ current[keys.last] = value
295
+ end
296
+ end
297
+
298
+ # Serializer that outputs comments from a CommentDocument.
299
+ class CommentPreservingSerializer
300
+ # @param doc [CommentDocument]
301
+ # @return [String] TOML with comments
302
+ def serialize(doc)
303
+ # Header comments
304
+ lines = doc.header_comments.map { |c| c }
305
+ lines << '' unless doc.header_comments.empty?
306
+
307
+ serialize_table(doc.data, [], lines, doc)
308
+ lines.join("\n") << "\n"
309
+ end
310
+
311
+ private
312
+
313
+ def serialize_table(hash, path, lines, doc)
314
+ simple_keys = []
315
+ table_keys = []
316
+ array_table_keys = []
317
+
318
+ hash.each do |key, value|
319
+ if value.is_a?(Hash) && !local_time_hash?(value)
320
+ table_keys << key
321
+ elsif value.is_a?(Array) && value.all?(Hash)
322
+ array_table_keys << key
323
+ else
324
+ simple_keys << key
325
+ end
326
+ end
327
+
328
+ serializer = Serializer.new
329
+
330
+ simple_keys.each do |key|
331
+ full_path = (path + [key]).join('.')
332
+ comment_entry = doc.comments[full_path]
333
+
334
+ if comment_entry
335
+ comment_entry[:before]&.each { |c| lines << c }
336
+ end
337
+
338
+ line = "#{format_key(key)} = #{serializer.send(:format_value, hash[key])}"
339
+ line = "#{line} #{comment_entry[:inline]}" if comment_entry&.dig(:inline)
340
+ lines << line
341
+ end
342
+
343
+ table_keys.each do |key|
344
+ full_path = path + [key]
345
+ table_key = "[#{full_path.map { |k| format_key(k) }.join('.')}]"
346
+
347
+ comment_entry = doc.table_comments[table_key]
348
+
349
+ lines << '' unless lines.empty?
350
+ if comment_entry
351
+ comment_entry[:before]&.each { |c| lines << c }
352
+ end
353
+
354
+ table_line = table_key
355
+ table_line = "#{table_line} #{comment_entry[:inline]}" if comment_entry&.dig(:inline)
356
+ lines << table_line
357
+
358
+ serialize_table(hash[key], full_path, lines, doc)
359
+ end
360
+
361
+ array_table_keys.each do |key|
362
+ full_path = path + [key]
363
+ table_key = "[[#{full_path.map { |k| format_key(k) }.join('.')}]]"
364
+
365
+ hash[key].each_with_index do |element, idx|
366
+ comment_entry = doc.table_comments[table_key] if idx.zero?
367
+
368
+ lines << '' unless lines.empty?
369
+ if comment_entry
370
+ comment_entry[:before]&.each { |c| lines << c }
371
+ end
372
+
373
+ aot_line = table_key
374
+ aot_line = "#{aot_line} #{comment_entry[:inline]}" if idx.zero? && comment_entry&.dig(:inline)
375
+ lines << aot_line
376
+
377
+ serialize_table(element, full_path, lines, doc)
378
+ end
379
+ end
380
+ end
381
+
382
+ def format_key(key)
383
+ key = key.to_s
384
+ if key.match?(/\A[A-Za-z0-9_-]+\z/)
385
+ key
386
+ else
387
+ "\"#{key}\""
388
+ end
389
+ end
390
+
391
+ def local_time_hash?(hash)
392
+ hash.key?(:hour) && hash.key?(:minute) && hash.key?(:second)
393
+ end
394
+ end
395
+ end
396
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Philiprehberger
4
+ module TomlKit
5
+ # Compares two TOML documents (as hashes) and reports differences.
6
+ #
7
+ # Returns a structured diff with:
8
+ # - :added -> keys present only in the right document
9
+ # - :removed -> keys present only in the left document
10
+ # - :changed -> keys present in both but with different values
11
+ module Diff
12
+ # A single change entry.
13
+ Change = Struct.new(:path, :type, :left_value, :right_value, keyword_init: true)
14
+
15
+ # Compare two parsed TOML hashes.
16
+ #
17
+ # @param left [Hash] first document
18
+ # @param right [Hash] second document
19
+ # @return [Array<Change>] list of differences
20
+ def self.diff(left, right)
21
+ changes = []
22
+ compare(left, right, [], changes)
23
+ changes
24
+ end
25
+
26
+ # Return only additions (keys in right but not left).
27
+ #
28
+ # @param left [Hash]
29
+ # @param right [Hash]
30
+ # @return [Array<Change>]
31
+ def self.additions(left, right)
32
+ diff(left, right).select { |c| c.type == :added }
33
+ end
34
+
35
+ # Return only removals (keys in left but not right).
36
+ #
37
+ # @param left [Hash]
38
+ # @param right [Hash]
39
+ # @return [Array<Change>]
40
+ def self.removals(left, right)
41
+ diff(left, right).select { |c| c.type == :removed }
42
+ end
43
+
44
+ # Return only changes (keys in both with different values).
45
+ #
46
+ # @param left [Hash]
47
+ # @param right [Hash]
48
+ # @return [Array<Change>]
49
+ def self.changes(left, right)
50
+ diff(left, right).select { |c| c.type == :changed }
51
+ end
52
+
53
+ # Check if two documents are identical.
54
+ #
55
+ # @param left [Hash]
56
+ # @param right [Hash]
57
+ # @return [Boolean]
58
+ def self.identical?(left, right)
59
+ diff(left, right).empty?
60
+ end
61
+
62
+ # @api private
63
+ def self.compare(left, right, path, changes)
64
+ all_keys = ((left.is_a?(Hash) ? left.keys : []) | (right.is_a?(Hash) ? right.keys : [])).uniq
65
+
66
+ all_keys.each do |key|
67
+ current_path = (path + [key]).join('.')
68
+ left_has = left.is_a?(Hash) && left.key?(key)
69
+ right_has = right.is_a?(Hash) && right.key?(key)
70
+
71
+ if left_has && !right_has
72
+ changes << Change.new(path: current_path, type: :removed, left_value: left[key], right_value: nil)
73
+ elsif !left_has && right_has
74
+ changes << Change.new(path: current_path, type: :added, left_value: nil, right_value: right[key])
75
+ elsif left[key].is_a?(Hash) && right[key].is_a?(Hash)
76
+ compare(left[key], right[key], path + [key], changes)
77
+ elsif left[key] != right[key]
78
+ # Handle NaN comparison (NaN != NaN is always true)
79
+ next if left[key].is_a?(Float) && right[key].is_a?(Float) && left[key].nan? && right[key].nan?
80
+
81
+ changes << Change.new(path: current_path, type: :changed, left_value: left[key],
82
+ right_value: right[key])
83
+ end
84
+ end
85
+ end
86
+
87
+ private_class_method :compare
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Philiprehberger
4
+ module TomlKit
5
+ class MergeConflictError < Error; end
6
+
7
+ # Deep merge two TOML hashes with configurable conflict resolution.
8
+ #
9
+ # Strategies:
10
+ # - :override -> right-side wins (default)
11
+ # - :keep_existing -> left-side wins
12
+ # - :error_on_conflict -> raise MergeConflictError
13
+ class Merger
14
+ STRATEGIES = %i[override keep_existing error_on_conflict].freeze
15
+
16
+ # Merge two hashes.
17
+ #
18
+ # @param left [Hash] base hash
19
+ # @param right [Hash] hash to merge in
20
+ # @param strategy [Symbol] conflict resolution strategy
21
+ # @return [Hash] merged result
22
+ # @raise [MergeConflictError] when strategy is :error_on_conflict and a conflict exists
23
+ def self.merge(left, right, strategy: :override)
24
+ new(strategy).merge(left, right)
25
+ end
26
+
27
+ # @param strategy [Symbol]
28
+ def initialize(strategy = :override)
29
+ unless STRATEGIES.include?(strategy)
30
+ raise ArgumentError, "Unknown merge strategy: #{strategy}. Use one of: #{STRATEGIES.join(', ')}"
31
+ end
32
+
33
+ @strategy = strategy
34
+ end
35
+
36
+ # @param left [Hash]
37
+ # @param right [Hash]
38
+ # @param path [Array<String>] internal tracking for error messages
39
+ # @return [Hash]
40
+ def merge(left, right, path = [])
41
+ result = left.dup
42
+
43
+ right.each do |key, right_value|
44
+ current_path = path + [key]
45
+
46
+ if result.key?(key)
47
+ left_value = result[key]
48
+
49
+ result[key] = if left_value.is_a?(Hash) && right_value.is_a?(Hash)
50
+ merge(left_value, right_value, current_path)
51
+ else
52
+ resolve_conflict(left_value, right_value, current_path)
53
+ end
54
+ else
55
+ result[key] = deep_copy(right_value)
56
+ end
57
+ end
58
+
59
+ result
60
+ end
61
+
62
+ private
63
+
64
+ def resolve_conflict(left_value, right_value, path)
65
+ case @strategy
66
+ when :override
67
+ deep_copy(right_value)
68
+ when :keep_existing
69
+ left_value
70
+ when :error_on_conflict
71
+ path_str = path.join('.')
72
+ raise MergeConflictError, "Conflict at key: #{path_str}"
73
+ end
74
+ end
75
+
76
+ def deep_copy(value)
77
+ case value
78
+ when Hash
79
+ value.transform_values { |v| deep_copy(v) }
80
+ when Array
81
+ value.map { |v| deep_copy(v) }
82
+ else
83
+ value
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Philiprehberger
4
+ module TomlKit
5
+ # Dot-path query access for nested TOML hashes.
6
+ #
7
+ # Supports:
8
+ # - Simple dot paths: "database.host"
9
+ # - Array indexing: "servers[0].name"
10
+ # - Deep nesting: "a.b.c.d"
11
+ module Query
12
+ # Retrieve a value from a nested hash using a dot-path.
13
+ #
14
+ # @param data [Hash] parsed TOML hash
15
+ # @param path [String] dot-separated path (e.g., "database.host")
16
+ # @param default [Object] value to return if the path does not exist
17
+ # @return [Object] the value at the path, or default
18
+ def self.get(data, path, default: nil)
19
+ segments = parse_path(path)
20
+ current = data
21
+
22
+ segments.each do |segment|
23
+ case segment
24
+ when Integer
25
+ return default unless current.is_a?(Array) && segment < current.length
26
+
27
+ current = current[segment]
28
+ when String
29
+ return default unless current.is_a?(Hash) && current.key?(segment)
30
+
31
+ current = current[segment]
32
+ end
33
+ end
34
+
35
+ current
36
+ end
37
+
38
+ # Set a value in a nested hash using a dot-path.
39
+ # Creates intermediate hashes/arrays as needed.
40
+ #
41
+ # @param data [Hash] target hash
42
+ # @param path [String] dot-separated path
43
+ # @param value [Object] value to set
44
+ # @return [Object] the value that was set
45
+ def self.set(data, path, value)
46
+ segments = parse_path(path)
47
+ current = data
48
+
49
+ segments[0...-1].each_with_index do |segment, idx|
50
+ next_segment = segments[idx + 1]
51
+
52
+ case segment
53
+ when Integer
54
+ current[segment] = next_segment.is_a?(Integer) ? [] : {} unless current[segment]
55
+ current = current[segment]
56
+ when String
57
+ current[segment] = next_segment.is_a?(Integer) ? [] : {} unless current.key?(segment)
58
+ current = current[segment]
59
+ end
60
+ end
61
+
62
+ current[segments.last] = value
63
+ value
64
+ end
65
+
66
+ # Check whether a path exists in the data.
67
+ #
68
+ # @param data [Hash] parsed TOML hash
69
+ # @param path [String] dot-separated path
70
+ # @return [Boolean]
71
+ def self.exists?(data, path)
72
+ sentinel = Object.new
73
+ get(data, path, default: sentinel) != sentinel
74
+ end
75
+
76
+ # Delete a value at the given path.
77
+ #
78
+ # @param data [Hash] target hash
79
+ # @param path [String] dot-separated path
80
+ # @return [Object, nil] the removed value, or nil
81
+ def self.delete(data, path)
82
+ segments = parse_path(path)
83
+ return nil if segments.empty?
84
+
85
+ parent = segments.length > 1 ? get(data, build_path(segments[0...-1])) : data
86
+ return nil unless parent
87
+
88
+ last = segments.last
89
+ case last
90
+ when Integer
91
+ parent.is_a?(Array) ? parent.delete_at(last) : nil
92
+ when String
93
+ parent.is_a?(Hash) ? parent.delete(last) : nil
94
+ end
95
+ end
96
+
97
+ # Parse a dot-path into segments.
98
+ # Handles array indexing like "servers[0]".
99
+ #
100
+ # @param path [String]
101
+ # @return [Array<String, Integer>]
102
+ def self.parse_path(path)
103
+ segments = []
104
+ path.split('.').each do |part|
105
+ if part =~ /\A(.+?)\[(\d+)\]\z/
106
+ segments << ::Regexp.last_match(1)
107
+ segments << ::Regexp.last_match(2).to_i
108
+ else
109
+ segments << part
110
+ end
111
+ end
112
+ segments
113
+ end
114
+
115
+ # Build a dot-path from segments.
116
+ #
117
+ # @param segments [Array<String, Integer>]
118
+ # @return [String]
119
+ def self.build_path(segments)
120
+ result = +''
121
+ segments.each_with_index do |seg, i|
122
+ if seg.is_a?(Integer)
123
+ result << "[#{seg}]"
124
+ else
125
+ result << '.' if i.positive?
126
+ result << seg
127
+ end
128
+ end
129
+ result
130
+ end
131
+
132
+ private_class_method :parse_path, :build_path
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Philiprehberger
4
+ module TomlKit
5
+ class SchemaError < Error; end
6
+
7
+ # Validates a parsed TOML hash against a schema definition.
8
+ #
9
+ # Schema format:
10
+ # {
11
+ # "key" => { type: String, required: true },
12
+ # "port" => { type: Integer, required: false, default: 8080 },
13
+ # "database" => {
14
+ # type: Hash,
15
+ # properties: {
16
+ # "host" => { type: String, required: true },
17
+ # "port" => { type: Integer }
18
+ # }
19
+ # },
20
+ # "tags" => { type: Array, items: { type: String } }
21
+ # }
22
+ class Schema
23
+ attr_reader :properties
24
+
25
+ # Build a new schema.
26
+ #
27
+ # @param properties [Hash] schema definition
28
+ def initialize(properties)
29
+ @properties = properties
30
+ end
31
+
32
+ # Validate a hash against this schema.
33
+ #
34
+ # @param data [Hash] parsed TOML data
35
+ # @return [Array<String>] list of validation errors (empty if valid)
36
+ def validate(data)
37
+ errors = []
38
+ validate_properties(data, @properties, [], errors)
39
+ errors
40
+ end
41
+
42
+ # Validate and raise if invalid.
43
+ #
44
+ # @param data [Hash] parsed TOML data
45
+ # @raise [SchemaError] if validation fails
46
+ # @return [true]
47
+ def validate!(data)
48
+ errors = validate(data)
49
+ raise SchemaError, "Schema validation failed: #{errors.join('; ')}" unless errors.empty?
50
+
51
+ true
52
+ end
53
+
54
+ private
55
+
56
+ def validate_properties(data, properties, path, errors)
57
+ properties.each do |key, rules|
58
+ full_path = (path + [key]).join('.')
59
+ value = data.is_a?(Hash) ? data[key] : nil
60
+ has_key = data.is_a?(Hash) && data.key?(key)
61
+
62
+ if rules[:required] && !has_key
63
+ errors << "Missing required key: #{full_path}"
64
+ next
65
+ end
66
+
67
+ next unless has_key
68
+
69
+ validate_value(value, rules, full_path, errors)
70
+ end
71
+ end
72
+
73
+ def validate_value(value, rules, path, errors)
74
+ expected_type = rules[:type]
75
+ if expected_type && !type_matches?(value, expected_type)
76
+ errors << "Type mismatch at #{path}: expected #{expected_type}, got #{value.class}"
77
+ return
78
+ end
79
+
80
+ # Validate nested properties for Hash types
81
+ if value.is_a?(Hash) && rules[:properties]
82
+ validate_properties(value, rules[:properties], path.split('.'), errors)
83
+ end
84
+
85
+ # Validate array items
86
+ return unless value.is_a?(Array) && rules[:items]
87
+
88
+ value.each_with_index do |item, idx|
89
+ item_path = "#{path}[#{idx}]"
90
+ validate_value(item, rules[:items], item_path, errors)
91
+ end
92
+ end
93
+
94
+ def type_matches?(value, expected)
95
+ case expected
96
+ when :boolean
97
+ value.is_a?(TrueClass) || value.is_a?(FalseClass)
98
+ else
99
+ value.is_a?(expected)
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Philiprehberger
4
+ module TomlKit
5
+ # Registry for custom type serialization and deserialization hooks.
6
+ #
7
+ # Allows registering custom handlers for Ruby types that are not natively
8
+ # supported by TOML. Coercions are applied during dump (serialize) and
9
+ # can be applied after parse (deserialize).
10
+ class TypeCoercion
11
+ # A single coercion rule.
12
+ Rule = Struct.new(:type, :serializer, :deserializer, :tag, keyword_init: true)
13
+
14
+ def initialize
15
+ @rules = []
16
+ end
17
+
18
+ # Register a custom type coercion.
19
+ #
20
+ # @param type [Class] the Ruby class to handle
21
+ # @param tag [String, nil] optional tag prefix for round-trip identification
22
+ # @param serializer [Proc] converts an instance to a TOML-native value
23
+ # @param deserializer [Proc, nil] converts a TOML-native value back to the Ruby type
24
+ # @return [self]
25
+ def register(type, serializer:, tag: nil, deserializer: nil)
26
+ @rules << Rule.new(type: type, serializer: serializer, deserializer: deserializer, tag: tag)
27
+ self
28
+ end
29
+
30
+ # Apply serialization coercions to a value (for dump).
31
+ # Walks hashes and arrays recursively.
32
+ #
33
+ # @param value [Object]
34
+ # @return [Object] coerced value
35
+ def coerce_for_serialize(value)
36
+ rule = find_rule(value)
37
+ if rule
38
+ result = rule.serializer.call(value)
39
+ if rule.tag
40
+ "__coerced:#{rule.tag}:#{result}"
41
+ else
42
+ result
43
+ end
44
+ elsif value.is_a?(Hash)
45
+ value.transform_values { |v| coerce_for_serialize(v) }
46
+ elsif value.is_a?(Array)
47
+ value.map { |v| coerce_for_serialize(v) }
48
+ else
49
+ value
50
+ end
51
+ end
52
+
53
+ # Apply deserialization coercions to a value (after parse).
54
+ # Walks hashes and arrays recursively looking for tagged strings.
55
+ #
56
+ # @param value [Object]
57
+ # @return [Object] coerced value
58
+ def coerce_for_deserialize(value)
59
+ case value
60
+ when String
61
+ if value.start_with?('__coerced:')
62
+ parts = value.split(':', 3)
63
+ tag = parts[1]
64
+ raw = parts[2]
65
+ rule = @rules.find { |r| r.tag == tag }
66
+ if rule&.deserializer
67
+ rule.deserializer.call(raw)
68
+ else
69
+ value
70
+ end
71
+ else
72
+ value
73
+ end
74
+ when Hash
75
+ value.transform_values { |v| coerce_for_deserialize(v) }
76
+ when Array
77
+ value.map { |v| coerce_for_deserialize(v) }
78
+ else
79
+ value
80
+ end
81
+ end
82
+
83
+ # Check if there is a registered rule for the given value.
84
+ #
85
+ # @param value [Object]
86
+ # @return [Boolean]
87
+ def handles?(value)
88
+ !find_rule(value).nil?
89
+ end
90
+
91
+ # Return all registered rules.
92
+ #
93
+ # @return [Array<Rule>]
94
+ def rules
95
+ @rules.dup
96
+ end
97
+
98
+ private
99
+
100
+ def find_rule(value)
101
+ @rules.find { |r| value.is_a?(r.type) }
102
+ end
103
+ end
104
+ end
105
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Philiprehberger
4
4
  module TomlKit
5
- VERSION = '0.1.1'
5
+ VERSION = '0.2.0'
6
6
  end
7
7
  end
@@ -1,14 +1,25 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'toml_kit/version'
4
- require_relative 'toml_kit/parser'
5
- require_relative 'toml_kit/serializer'
6
4
 
7
5
  module Philiprehberger
8
6
  module TomlKit
9
7
  class Error < StandardError; end
10
8
  class ParseError < Error; end
9
+ end
10
+ end
11
11
 
12
+ require_relative 'toml_kit/parser'
13
+ require_relative 'toml_kit/serializer'
14
+ require_relative 'toml_kit/comment_document'
15
+ require_relative 'toml_kit/schema'
16
+ require_relative 'toml_kit/merger'
17
+ require_relative 'toml_kit/query'
18
+ require_relative 'toml_kit/type_coercion'
19
+ require_relative 'toml_kit/diff'
20
+
21
+ module Philiprehberger
22
+ module TomlKit
12
23
  # Parse a TOML string into a Ruby Hash.
13
24
  #
14
25
  # @param string [String] TOML document
@@ -44,5 +55,42 @@ module Philiprehberger
44
55
  def self.save(hash, path)
45
56
  File.write(path, dump(hash), encoding: 'utf-8')
46
57
  end
58
+
59
+ # Parse a TOML string preserving comments for round-trip editing.
60
+ #
61
+ # @param string [String] TOML document
62
+ # @return [CommentDocument] document with data and comments
63
+ def self.parse_with_comments(string)
64
+ CommentDocument.parse(string)
65
+ end
66
+
67
+ # Query a nested value using a dot-path.
68
+ #
69
+ # @param data [Hash] parsed TOML hash
70
+ # @param path [String] dot-separated path (e.g., "database.host")
71
+ # @param default [Object] fallback value
72
+ # @return [Object]
73
+ def self.query(data, path, default: nil)
74
+ Query.get(data, path, default: default)
75
+ end
76
+
77
+ # Deep merge two TOML hashes.
78
+ #
79
+ # @param left [Hash] base hash
80
+ # @param right [Hash] hash to merge in
81
+ # @param strategy [Symbol] :override, :keep_existing, or :error_on_conflict
82
+ # @return [Hash]
83
+ def self.merge(left, right, strategy: :override)
84
+ Merger.merge(left, right, strategy: strategy)
85
+ end
86
+
87
+ # Compare two TOML hashes and return differences.
88
+ #
89
+ # @param left [Hash] first document
90
+ # @param right [Hash] second document
91
+ # @return [Array<Diff::Change>]
92
+ def self.diff(left, right)
93
+ Diff.diff(left, right)
94
+ end
47
95
  end
48
96
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: philiprehberger-toml_kit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Philip Rehberger
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-03-27 00:00:00.000000000 Z
11
+ date: 2026-03-28 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: Parse and generate TOML v1.0 documents with full type support including
14
14
  datetimes, inline tables, and array of tables. Zero dependencies.
@@ -22,8 +22,14 @@ files:
22
22
  - LICENSE
23
23
  - README.md
24
24
  - lib/philiprehberger/toml_kit.rb
25
+ - lib/philiprehberger/toml_kit/comment_document.rb
26
+ - lib/philiprehberger/toml_kit/diff.rb
27
+ - lib/philiprehberger/toml_kit/merger.rb
25
28
  - lib/philiprehberger/toml_kit/parser.rb
29
+ - lib/philiprehberger/toml_kit/query.rb
30
+ - lib/philiprehberger/toml_kit/schema.rb
26
31
  - lib/philiprehberger/toml_kit/serializer.rb
32
+ - lib/philiprehberger/toml_kit/type_coercion.rb
27
33
  - lib/philiprehberger/toml_kit/version.rb
28
34
  homepage: https://github.com/philiprehberger/rb-toml-kit
29
35
  licenses: