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 +4 -4
- data/CHANGELOG.md +11 -0
- data/README.md +167 -5
- data/lib/philiprehberger/toml_kit/comment_document.rb +396 -0
- data/lib/philiprehberger/toml_kit/diff.rb +90 -0
- data/lib/philiprehberger/toml_kit/merger.rb +88 -0
- data/lib/philiprehberger/toml_kit/query.rb +135 -0
- data/lib/philiprehberger/toml_kit/schema.rb +104 -0
- data/lib/philiprehberger/toml_kit/type_coercion.rb +105 -0
- data/lib/philiprehberger/toml_kit/version.rb +1 -1
- data/lib/philiprehberger/toml_kit.rb +50 -2
- metadata +8 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 74417b867e192747c92bc5db6a147f1d52333718a6461f3dce72ee5e15ca5b45
|
|
4
|
+
data.tar.gz: 2f26751f1f266724a4493ceb5ec76bfde3e3832b05e7b804b0db8c452b2da93e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
[](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)
|
|
3
|
+
[](https://github.com/philiprehberger/rb-toml-kit/actions/workflows/ci.yml) [](https://rubygems.org/gems/philiprehberger-toml_kit) [](https://github.com/philiprehberger/rb-toml-kit/releases) [](https://github.com/philiprehberger/rb-toml-kit/commits/main) [](LICENSE) [](https://github.com/philiprehberger/rb-toml-kit/issues) [](https://github.com/philiprehberger/rb-toml-kit/issues) [](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
|
+
[](https://linkedin.com/in/philiprehberger) [](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
|
|
@@ -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.
|
|
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-
|
|
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:
|