kochab 0.1.0 → 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 +6 -0
- data/README.md +128 -17
- data/lib/kochab/schema/builder.rb +92 -0
- data/lib/kochab/schema/json_compiler.rb +140 -0
- data/lib/kochab/schema/merge.rb +90 -0
- data/lib/kochab/schema/validation.rb +94 -0
- data/lib/kochab/schema.rb +177 -0
- data/lib/kochab/version.rb +1 -1
- data/lib/kochab.rb +4 -0
- data/sig/kochab.rbs +42 -0
- metadata +15 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4e0eb6c563c42845bb7442c624883c75f2421a2298d4e721485433c2676a1abe
|
|
4
|
+
data.tar.gz: 46c35ab8d5de26c434723e04a6b8b6c22241b79346c0f2eef3e121b20dc2ac74
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2d665d220c79847fc8771f9433196a673be028bb5f171e9e51bc78755d4a0a9e08e5649da38211ecef279dbec50992f10dbf7e8d7f6c6c54ecbfe1857cfdcb17
|
|
7
|
+
data.tar.gz: 93970f7f6ccf7cff4f8165f9e9fe42ffcd9bef186efed304eb1a1dce40598165c0bf377d740ef14fed456cf7d43f621303b1b93f4c70fbedba1c0ee334a50754
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.0 — 2026-09-15
|
|
4
|
+
|
|
5
|
+
- Add schema DSL, defaults, source-aware validation, and field metadata.
|
|
6
|
+
- Add safe layered merging that retains earlier values when later values are invalid.
|
|
7
|
+
- Add a JSON Schema subset importer.
|
|
8
|
+
|
|
3
9
|
## 0.1.0 — 2026-09-11
|
|
4
10
|
|
|
5
11
|
- Initial release.
|
data/README.md
CHANGED
|
@@ -1,26 +1,66 @@
|
|
|
1
|
-
|
|
1
|
+
<h1 align="center">Kochab</h1>
|
|
2
|
+
|
|
3
|
+
<p align="center">
|
|
4
|
+
<strong>JSONC parsing with byte ranges, error recovery, and formatting-preserving edits</strong>
|
|
5
|
+
</p>
|
|
6
|
+
|
|
7
|
+
<p align="center">
|
|
8
|
+
<a href="https://rubygems.org/gems/kochab"><img src="https://img.shields.io/gem/v/kochab.svg?colorB=319e8c" alt="Gem Version"></a>
|
|
9
|
+
<a href="https://rubygems.org/gems/kochab"><img src="https://img.shields.io/gem/dt/kochab.svg" alt="Downloads"></a>
|
|
10
|
+
<a href="https://github.com/noxdea/kochab/actions/workflows/main.yml"><img src="https://github.com/noxdea/kochab/actions/workflows/main.yml/badge.svg" alt="CI"></a>
|
|
11
|
+
<img src="https://img.shields.io/badge/ruby-%3E%3D%203.1-CC342D.svg" alt="Ruby 3.1+">
|
|
12
|
+
<a href="LICENSE.txt"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="MIT License"></a>
|
|
13
|
+
</p>
|
|
14
|
+
|
|
15
|
+
<p align="center">
|
|
16
|
+
<a href="#features">Features</a> ·
|
|
17
|
+
<a href="#installation">Installation</a> ·
|
|
18
|
+
<a href="#quick-start">Quick Start</a> ·
|
|
19
|
+
<a href="#source-queries">Source Queries</a> ·
|
|
20
|
+
<a href="#schemas">Schemas</a> ·
|
|
21
|
+
<a href="#editing">Editing</a> ·
|
|
22
|
+
<a href="#development">Development</a>
|
|
23
|
+
</p>
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
Kochab is a pure Ruby JSONC parser that retains exact source locations. It
|
|
28
|
+
recovers from syntax errors and produces minimal text edits that preserve
|
|
29
|
+
surrounding comments and formatting.
|
|
30
|
+
|
|
31
|
+
## Features
|
|
32
|
+
|
|
33
|
+
- JSONC parsing with comments and trailing commas
|
|
34
|
+
- UTF-8 byte ranges, syntax tree queries, and UTF-16 position conversion
|
|
35
|
+
- Error recovery with structured diagnostics and a strict JSON mode
|
|
36
|
+
- Minimal insert, replace, and remove edits that preserve unrelated source text
|
|
37
|
+
- Comment-preserving formatting
|
|
38
|
+
- Typed settings schemas with source-aware diagnostics and safe layer merging
|
|
39
|
+
- RBS signatures with no runtime gem dependencies
|
|
2
40
|
|
|
3
|
-
|
|
4
|
-
surrounding comments and formatting. Ruby 3.1+, no runtime gem dependencies,
|
|
5
|
-
no custom native extension. Ruby's standard `json` library handles string
|
|
6
|
-
escapes and value serialization; scanning, recovery, and edits are Ruby code.
|
|
41
|
+
## Installation
|
|
7
42
|
|
|
8
|
-
Kochab
|
|
9
|
-
were registered on RubyGems; `kochab` was available when checked on
|
|
10
|
-
2026-09-09. Availability is not a reservation. This checkout is not published.
|
|
43
|
+
Add Kochab to your Gemfile:
|
|
11
44
|
|
|
12
|
-
|
|
45
|
+
```ruby
|
|
46
|
+
gem "kochab"
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Then install:
|
|
50
|
+
|
|
51
|
+
```sh
|
|
52
|
+
bundle install
|
|
53
|
+
```
|
|
13
54
|
|
|
14
|
-
|
|
55
|
+
Or install it directly:
|
|
15
56
|
|
|
16
57
|
```sh
|
|
17
|
-
gem
|
|
18
|
-
gem install ./kochab-0.1.0.gem
|
|
58
|
+
gem install kochab
|
|
19
59
|
```
|
|
20
60
|
|
|
21
|
-
|
|
61
|
+
Kochab requires Ruby 3.1 or later.
|
|
22
62
|
|
|
23
|
-
##
|
|
63
|
+
## Quick Start
|
|
24
64
|
|
|
25
65
|
```ruby
|
|
26
66
|
require "kochab"
|
|
@@ -73,7 +113,67 @@ Property nodes have one value child; a missing value is a zero-length `:null`
|
|
|
73
113
|
node. Containers' `value` fields contain their Ruby Hash or Array values.
|
|
74
114
|
Treat the tree and its values as read-only snapshots. `doc.text` is frozen.
|
|
75
115
|
|
|
76
|
-
##
|
|
116
|
+
## Schemas
|
|
117
|
+
|
|
118
|
+
Define settings metadata once and use it for defaults, validation, layer
|
|
119
|
+
merging, and settings UI generation:
|
|
120
|
+
|
|
121
|
+
```ruby
|
|
122
|
+
schema = Kochab::Schema.define do
|
|
123
|
+
boolean "format_on_save", default: false,
|
|
124
|
+
description: "Run the formatter when saving"
|
|
125
|
+
integer "font_size", default: 14, minimum: 6, maximum: 96
|
|
126
|
+
enum "theme", values: %w[auto light dark], default: "auto"
|
|
127
|
+
object "minimap" do
|
|
128
|
+
boolean "enabled", default: false
|
|
129
|
+
integer "width", default: 100, minimum: 20, maximum: 400
|
|
130
|
+
end
|
|
131
|
+
array "code_actions_on_save", items: :string, default: []
|
|
132
|
+
map "languages", value: ->(language) {
|
|
133
|
+
language.integer "tab_size", default: 2, minimum: 1
|
|
134
|
+
}
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
schema.defaults
|
|
138
|
+
# {"format_on_save"=>false, "font_size"=>14, "theme"=>"auto",
|
|
139
|
+
# "minimap"=>{"enabled"=>false, "width"=>100},
|
|
140
|
+
# "code_actions_on_save"=>[]}
|
|
141
|
+
|
|
142
|
+
document = Kochab.parse('{"font_size": 4, "theme": "blue"}')
|
|
143
|
+
diagnostics = schema.validate(document)
|
|
144
|
+
diagnostics.first.path # ["font_size"]
|
|
145
|
+
document.text.byteslice(diagnostics.first.range) # "4"
|
|
146
|
+
|
|
147
|
+
field = schema.describe(["minimap", "width"])
|
|
148
|
+
field.type # :integer
|
|
149
|
+
schema.fields # all metadata in definition order
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Schema paths are arrays, like `Document` paths. Map metadata uses `"*"` in
|
|
153
|
+
`Field#path`; `describe` accepts a concrete map key. Diagnostics have `path`,
|
|
154
|
+
byte `range`, `severity` (`:error`), and `message`. Syntax diagnostics remain
|
|
155
|
+
in `Document#errors`.
|
|
156
|
+
|
|
157
|
+
`merge` starts with schema defaults and applies documents or Hash layers from
|
|
158
|
+
left to right. Objects merge recursively and arrays replace earlier arrays.
|
|
159
|
+
An invalid value is skipped at its own key, leaving the earlier valid value
|
|
160
|
+
active; unrelated valid values in the same layer still apply. Fields not
|
|
161
|
+
declared by the DSL are retained, which permits extension-owned settings.
|
|
162
|
+
|
|
163
|
+
```ruby
|
|
164
|
+
user = Kochab.parse('{"font_size": 18, "minimap": {"width": 120}}')
|
|
165
|
+
project = Kochab.parse('{"font_size": "large", "minimap": {"enabled": true}}')
|
|
166
|
+
values = schema.merge(user, project)
|
|
167
|
+
values["font_size"] # 18; invalid project value was skipped
|
|
168
|
+
values["minimap"] # {"enabled"=>true, "width"=>120}
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
`Schema.from_json_schema(hash)` imports the settings-oriented JSON Schema
|
|
172
|
+
subset: local references, object properties, required names, array items,
|
|
173
|
+
additional properties, primitive and nullable types, defaults, descriptions,
|
|
174
|
+
deprecation, enums, numeric bounds, and collection/string size bounds.
|
|
175
|
+
|
|
176
|
+
## Parsing and Recovery
|
|
77
177
|
|
|
78
178
|
The default parser consumes the complete input and reports syntax problems in
|
|
79
179
|
`doc.errors`, including invalid UTF-8. It returns the best available value:
|
|
@@ -115,7 +215,7 @@ value's `trailing_comment`. Comments separated by a blank line, and additional
|
|
|
115
215
|
comments that cannot occupy the single trailing slot, are `floating_comments`.
|
|
116
216
|
Comment text always retains its original delimiters and bytes.
|
|
117
217
|
|
|
118
|
-
##
|
|
218
|
+
## Editing
|
|
119
219
|
|
|
120
220
|
```ruby
|
|
121
221
|
doc.set(["editor", "font_size"], 14) # replace one value
|
|
@@ -157,7 +257,7 @@ the first newline style present in the input, and appends one final newline.
|
|
|
157
257
|
rejects invalid documents to avoid discarding incomplete input. Use minimal
|
|
158
258
|
edits when existing whitespace must be preserved exactly.
|
|
159
259
|
|
|
160
|
-
##
|
|
260
|
+
## Development
|
|
161
261
|
|
|
162
262
|
```sh
|
|
163
263
|
bundle install
|
|
@@ -177,6 +277,8 @@ outcome for `i_` cases. Valid inputs are also checked against `JSON.parse`.
|
|
|
177
277
|
Recovery/fuzz tests check termination, source preservation, and bounded ranges.
|
|
178
278
|
Edit tests check exact replacement bytes and preservation of unrelated comments.
|
|
179
279
|
|
|
280
|
+
## Performance
|
|
281
|
+
|
|
180
282
|
Measured on macOS arm64, Ruby 4.0.0 with YJIT (2026-09-09), seven-sample medians:
|
|
181
283
|
|
|
182
284
|
| Operation | Observed | Budget |
|
|
@@ -185,6 +287,8 @@ Measured on macOS arm64, Ruby 4.0.0 with YJIT (2026-09-09), seven-sample medians
|
|
|
185
287
|
| Parse 1,048,594-byte JSONC | 157.5 ms | 200 ms |
|
|
186
288
|
| Query `node_at` | < 1 µs | 10 µs |
|
|
187
289
|
| Generate a `set` edit | 2 µs | 1 ms |
|
|
290
|
+
| Validate a small schema document | 1 µs | 1 ms |
|
|
291
|
+
| Merge a small schema document | 2 µs | 1 ms |
|
|
188
292
|
|
|
189
293
|
The corpus contains nested settings, UTF-8 strings, and one comment per setting.
|
|
190
294
|
Run `bench/benchmark.rb` on your deployment machine; these are measurements,
|
|
@@ -197,6 +301,13 @@ CI tests Ruby 3.1, 3.2, 3.3, 3.4, and 4.0 on Linux, macOS, and Windows. Performa
|
|
|
197
301
|
budgets run separately on Linux with Ruby 4.0 and YJIT. Built-gem installation
|
|
198
302
|
and the example are smoke-tested in CI.
|
|
199
303
|
|
|
304
|
+
## Contributing
|
|
305
|
+
|
|
306
|
+
Bug reports and pull requests are welcome on
|
|
307
|
+
[GitHub](https://github.com/noxdea/kochab).
|
|
308
|
+
|
|
309
|
+
See [CHANGELOG.md](CHANGELOG.md) for release history.
|
|
310
|
+
|
|
200
311
|
## License
|
|
201
312
|
|
|
202
313
|
MIT; see [LICENSE.txt](LICENSE.txt). Vendored test data retains its original
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kochab
|
|
4
|
+
class Schema
|
|
5
|
+
# Definition context for Schema.define.
|
|
6
|
+
class Builder
|
|
7
|
+
def initialize(path = [])
|
|
8
|
+
@path = path
|
|
9
|
+
@rules = []
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
attr_reader :rules
|
|
13
|
+
|
|
14
|
+
def boolean(name, **options) = scalar(name, :boolean, **options)
|
|
15
|
+
def integer(name, **options) = scalar(name, :integer, **options)
|
|
16
|
+
def number(name, **options) = scalar(name, :number, **options)
|
|
17
|
+
def string(name, **options) = scalar(name, :string, **options)
|
|
18
|
+
|
|
19
|
+
def enum(name, values:, **options)
|
|
20
|
+
raise ArgumentError, "values must be a nonempty Array" unless values.is_a?(Array) && !values.empty?
|
|
21
|
+
|
|
22
|
+
types = values.map { |value| Schema.type_of(value) }.uniq
|
|
23
|
+
scalar(name, types.one? ? types.first : :any, enum: values, **options)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def object(name, **options, &block)
|
|
27
|
+
nested = self.class.new(field_path(name))
|
|
28
|
+
evaluate(nested, block)
|
|
29
|
+
add(name, :object, items: nested.rules.map(&:field).freeze, children: nested.rules, **options)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def array(name, items:, **options)
|
|
33
|
+
item = Schema.rule_for_type(items, field_path(name) + [0])
|
|
34
|
+
add(name, :array, items: Schema.public_item(item), item: item, **options)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def map(name, value:, **options)
|
|
38
|
+
path = field_path(name) + ["*"]
|
|
39
|
+
item = if value.respond_to?(:call)
|
|
40
|
+
nested = self.class.new(path)
|
|
41
|
+
value.call(nested)
|
|
42
|
+
Schema.rule(nil, :object, children: nested.rules)
|
|
43
|
+
else
|
|
44
|
+
Schema.rule_for_type(value, path)
|
|
45
|
+
end
|
|
46
|
+
public_items = item.field || item.children.map(&:field).freeze
|
|
47
|
+
add(name, :map, items: public_items, item: item, **options)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def scalar(name, type, **options)
|
|
53
|
+
add(name, type, **options)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def add(name, type, default: UNSET, description: nil, enum: nil, minimum: nil,
|
|
57
|
+
maximum: nil, items: nil, deprecated: false, children: [], item: nil)
|
|
58
|
+
path = field_path(name)
|
|
59
|
+
raise ArgumentError, "Duplicate field #{path.join(".")}" if @rules.any? { |rule| rule.field.path == path }
|
|
60
|
+
validate_metadata(description, deprecated, minimum, maximum)
|
|
61
|
+
|
|
62
|
+
field = Field.new(path: Schema.frozen_copy(path), type: type, default: default.equal?(UNSET) ? nil : Schema.frozen_copy(default),
|
|
63
|
+
description: description && Schema.frozen_copy(description), enum: enum && Schema.frozen_copy(enum), minimum: minimum, maximum: maximum,
|
|
64
|
+
items: items, deprecated: deprecated).freeze
|
|
65
|
+
@rules << Schema.rule(field, type, children: children, item: item,
|
|
66
|
+
constraints: {minimum: minimum, maximum: maximum, enum: field.enum}, default_set: !default.equal?(UNSET))
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def field_path(name)
|
|
70
|
+
raise TypeError, "field names must be nonempty Strings" unless name.is_a?(String) && !name.empty?
|
|
71
|
+
|
|
72
|
+
@path + [name]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def evaluate(builder, block)
|
|
76
|
+
raise ArgumentError, "a schema block is required" unless block
|
|
77
|
+
|
|
78
|
+
block.arity.zero? ? builder.instance_eval(&block) : block.call(builder)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def validate_metadata(description, deprecated, minimum, maximum)
|
|
82
|
+
raise TypeError, "description must be a String" unless description.nil? || description.is_a?(String)
|
|
83
|
+
raise TypeError, "deprecated must be true or false" unless [true, false].include?(deprecated)
|
|
84
|
+
{minimum: minimum, maximum: maximum}.each do |name, value|
|
|
85
|
+
next if value.nil? || value.is_a?(Numeric) && (!value.respond_to?(:finite?) || value.finite?)
|
|
86
|
+
|
|
87
|
+
raise TypeError, "#{name} must be a finite Numeric"
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kochab
|
|
4
|
+
class Schema
|
|
5
|
+
# Compiles the documented JSON Schema subset into internal rules.
|
|
6
|
+
class JsonCompiler
|
|
7
|
+
def initialize(root)
|
|
8
|
+
@root = root
|
|
9
|
+
@active = {}
|
|
10
|
+
@resolving = {}
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def compile_root
|
|
14
|
+
schema = @root
|
|
15
|
+
seen = {}
|
|
16
|
+
while schema.is_a?(Hash) && schema.key?("$ref")
|
|
17
|
+
raise ArgumentError, "Circular JSON Schema reference" if seen[schema.object_id]
|
|
18
|
+
|
|
19
|
+
seen[schema.object_id] = true
|
|
20
|
+
schema = reference(schema.fetch("$ref"))
|
|
21
|
+
end
|
|
22
|
+
raise TypeError, "JSON Schema entries must be Hash values" unless schema.is_a?(Hash)
|
|
23
|
+
raise ArgumentError, "JSON Schema root must be an object" unless infer_type(schema) == :object
|
|
24
|
+
|
|
25
|
+
compile(@root, [])
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def compile(schema, path)
|
|
29
|
+
raise TypeError, "JSON Schema entries must be Hash values" unless schema.is_a?(Hash)
|
|
30
|
+
if schema.key?("$ref")
|
|
31
|
+
target = reference(schema.fetch("$ref"))
|
|
32
|
+
return @active[target.object_id] if @active.key?(target.object_id)
|
|
33
|
+
raise ArgumentError, "Circular JSON Schema reference" if @resolving[target.object_id]
|
|
34
|
+
|
|
35
|
+
@resolving[target.object_id] = true
|
|
36
|
+
begin
|
|
37
|
+
return compile(target, path)
|
|
38
|
+
ensure
|
|
39
|
+
@resolving.delete(target.object_id)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
return @active[schema.object_id] if @active.key?(schema.object_id)
|
|
43
|
+
|
|
44
|
+
properties = schema.fetch("properties", {})
|
|
45
|
+
required = schema.fetch("required", [])
|
|
46
|
+
raise TypeError, "JSON Schema properties must be a Hash" unless properties.is_a?(Hash)
|
|
47
|
+
raise TypeError, "JSON Schema required must be an Array" unless required.is_a?(Array)
|
|
48
|
+
unless properties.keys.all? { |name| name.is_a?(String) } && required.all? { |name| name.is_a?(String) }
|
|
49
|
+
raise TypeError, "JSON Schema property names must be Strings"
|
|
50
|
+
end
|
|
51
|
+
validate_keywords(schema)
|
|
52
|
+
|
|
53
|
+
type = infer_type(schema)
|
|
54
|
+
field = path.empty? ? nil : public_field(schema, path, type)
|
|
55
|
+
rule = Rule.new(field: field, children: [], item: nil, additional: true, required: [], constraints: {},
|
|
56
|
+
default_set: schema.key?("default"),
|
|
57
|
+
default: schema.key?("default") ? Schema.frozen_copy(schema["default"]) : nil)
|
|
58
|
+
@active[schema.object_id] = rule
|
|
59
|
+
rule.children = properties.map { |name, child| compile(child, path + [name]) }.freeze
|
|
60
|
+
rule.item = compile(schema["items"], path + [0]) if schema["items"].is_a?(Hash)
|
|
61
|
+
rule.additional = additional(schema, path)
|
|
62
|
+
rule.required = required.dup.freeze
|
|
63
|
+
rule.constraints = constraints(schema).freeze
|
|
64
|
+
@active.delete(schema.object_id)
|
|
65
|
+
rule
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def infer_type(schema)
|
|
71
|
+
type = schema["type"]
|
|
72
|
+
type ||= "object" if schema.key?("properties") || schema.key?("additionalProperties")
|
|
73
|
+
Schema.normalize_type(type || :any)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def public_field(schema, path, type)
|
|
77
|
+
items = schema["items"] && Schema.normalize_type(schema["items"]["type"] || :any)
|
|
78
|
+
Field.new(path: Schema.frozen_copy(path), type: type, default: schema.key?("default") ? Schema.frozen_copy(schema["default"]) : nil,
|
|
79
|
+
description: schema["description"] && Schema.frozen_copy(schema["description"]),
|
|
80
|
+
enum: schema["enum"] && Schema.frozen_copy(schema["enum"]),
|
|
81
|
+
minimum: schema["minimum"], maximum: schema["maximum"], items: items,
|
|
82
|
+
deprecated: schema.fetch("deprecated", false)).freeze
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def additional(schema, path)
|
|
86
|
+
value = schema.fetch("additionalProperties", true)
|
|
87
|
+
unless value.equal?(true) || value.equal?(false) || value.is_a?(Hash)
|
|
88
|
+
raise TypeError, "JSON Schema additionalProperties must be true, false, or a Hash"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
value.is_a?(Hash) ? compile(value, path + ["*"]) : value
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def constraints(schema)
|
|
95
|
+
{enum: schema["enum"] && Schema.frozen_copy(schema["enum"]), minimum: schema["minimum"], maximum: schema["maximum"],
|
|
96
|
+
exclusive_minimum: schema["exclusiveMinimum"], exclusive_maximum: schema["exclusiveMaximum"],
|
|
97
|
+
min_items: schema["minItems"], max_items: schema["maxItems"],
|
|
98
|
+
min_length: schema["minLength"], max_length: schema["maxLength"],
|
|
99
|
+
min_properties: schema["minProperties"], max_properties: schema["maxProperties"]}
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def validate_keywords(schema)
|
|
103
|
+
raise TypeError, "JSON Schema items must be a Hash" if schema.key?("items") && !schema["items"].is_a?(Hash)
|
|
104
|
+
unless !schema.key?("enum") || schema["enum"].is_a?(Array) && !schema["enum"].empty?
|
|
105
|
+
raise TypeError, "JSON Schema enum must be a nonempty Array"
|
|
106
|
+
end
|
|
107
|
+
if schema.key?("description") && !schema["description"].is_a?(String)
|
|
108
|
+
raise TypeError, "JSON Schema description must be a String"
|
|
109
|
+
end
|
|
110
|
+
unless [true, false].include?(schema.fetch("deprecated", false))
|
|
111
|
+
raise TypeError, "JSON Schema deprecated must be true or false"
|
|
112
|
+
end
|
|
113
|
+
%w[minimum maximum exclusiveMinimum exclusiveMaximum].each do |name|
|
|
114
|
+
next unless schema.key?(name)
|
|
115
|
+
|
|
116
|
+
value = schema[name]
|
|
117
|
+
unless value.is_a?(Numeric) && (!value.respond_to?(:finite?) || value.finite?)
|
|
118
|
+
raise TypeError, "JSON Schema #{name} must be a finite Numeric"
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
%w[minItems maxItems minLength maxLength minProperties maxProperties].each do |name|
|
|
122
|
+
next unless schema.key?(name)
|
|
123
|
+
|
|
124
|
+
raise TypeError, "JSON Schema #{name} must be a nonnegative Integer" unless schema[name].is_a?(Integer) && schema[name] >= 0
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def reference(pointer)
|
|
129
|
+
raise ArgumentError, "Only local JSON Schema references are supported" unless pointer.is_a?(String) && pointer.start_with?("#")
|
|
130
|
+
|
|
131
|
+
pointer.delete_prefix("#").split("/").reject(&:empty?).reduce(@root) do |value, token|
|
|
132
|
+
value.fetch(token.gsub("~1", "/").gsub("~0", "~"))
|
|
133
|
+
end
|
|
134
|
+
rescue KeyError
|
|
135
|
+
raise ArgumentError, "Unknown JSON Schema reference #{pointer.inspect}"
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
private_constant :JsonCompiler
|
|
139
|
+
end
|
|
140
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kochab
|
|
4
|
+
class Schema
|
|
5
|
+
def merge(*documents)
|
|
6
|
+
documents.reduce(defaults) do |values, document|
|
|
7
|
+
if document.is_a?(Document)
|
|
8
|
+
layer = document.value
|
|
9
|
+
next values unless layer.is_a?(Hash)
|
|
10
|
+
else
|
|
11
|
+
layer = document
|
|
12
|
+
raise TypeError, "layers must be Kochab::Document or Hash values" unless layer.is_a?(Hash)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
merge_object(values, layer, @root)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def merge_object(previous, layer, rule)
|
|
22
|
+
merge_members(previous, layer, rule).first
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def merge_members(previous, layer, rule)
|
|
26
|
+
result = copy(previous)
|
|
27
|
+
changed = layer.empty?
|
|
28
|
+
layer.each do |name, value|
|
|
29
|
+
child = rule.children.find { |candidate| candidate.field.path.last == name }
|
|
30
|
+
child ||= rule.additional if rule.additional.is_a?(Rule)
|
|
31
|
+
if child
|
|
32
|
+
merged, valid = merge_value(result.fetch(name, UNSET), value, child)
|
|
33
|
+
result[name] = merged if valid
|
|
34
|
+
changed ||= valid
|
|
35
|
+
elsif rule.additional
|
|
36
|
+
result[name] = merge_untyped(result.fetch(name, UNSET), value)
|
|
37
|
+
changed = true
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
[result, changed]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def merge_value(previous, value, rule)
|
|
44
|
+
return [previous, false] unless type_match?(rule.field&.type || :object, value)
|
|
45
|
+
type = matching_type(rule.field&.type || :object, value)
|
|
46
|
+
if type == :object
|
|
47
|
+
base = previous.is_a?(Hash) ? previous : {}
|
|
48
|
+
candidate, changed = merge_members(base, value, rule)
|
|
49
|
+
return [candidate, changed && valid_value?(rule, candidate)] if previous.equal?(UNSET)
|
|
50
|
+
|
|
51
|
+
return [candidate, valid_value?(rule, candidate)]
|
|
52
|
+
end
|
|
53
|
+
if type == :map
|
|
54
|
+
result = previous.is_a?(Hash) ? copy(previous) : {}
|
|
55
|
+
changed = value.empty?
|
|
56
|
+
value.each do |name, entry|
|
|
57
|
+
next unless name.is_a?(String)
|
|
58
|
+
|
|
59
|
+
previous_item = result.fetch(name, UNSET)
|
|
60
|
+
if previous_item.equal?(UNSET)
|
|
61
|
+
item_default = default_for(rule.item)
|
|
62
|
+
previous_item = item_default if rule.item.default_set || !item_default.nil?
|
|
63
|
+
end
|
|
64
|
+
merged, valid = merge_value(previous_item, entry, rule.item)
|
|
65
|
+
result[name] = merged if valid
|
|
66
|
+
changed ||= valid
|
|
67
|
+
end
|
|
68
|
+
return [result, changed && valid_value?(rule, result)] if previous.equal?(UNSET)
|
|
69
|
+
|
|
70
|
+
return [result, valid_value?(rule, result)]
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
[copy(value), valid_value?(rule, value)]
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def valid_value?(rule, value)
|
|
77
|
+
diagnostics = []
|
|
78
|
+
validate_rule(rule, value, [], nil, diagnostics)
|
|
79
|
+
diagnostics.empty?
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def merge_untyped(previous, value)
|
|
83
|
+
return copy(value) unless previous.is_a?(Hash) && value.is_a?(Hash)
|
|
84
|
+
|
|
85
|
+
value.each_with_object(copy(previous)) do |(name, entry), result|
|
|
86
|
+
result[name] = merge_untyped(result.fetch(name, UNSET), entry)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kochab
|
|
4
|
+
class Schema
|
|
5
|
+
def validate(document)
|
|
6
|
+
raise TypeError, "document must be a Kochab::Document" unless document.is_a?(Document)
|
|
7
|
+
|
|
8
|
+
diagnostics = []
|
|
9
|
+
validate_rule(@root, document.value, [], document, diagnostics)
|
|
10
|
+
diagnostics
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
def validate_rule(rule, value, path, document, diagnostics, check_required: true)
|
|
16
|
+
type = rule.field&.type || :object
|
|
17
|
+
unless type_match?(type, value)
|
|
18
|
+
return add_diagnostic(diagnostics, document, path, "Expected #{Array(type).join(" or ")}")
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
constraints = rule.constraints
|
|
22
|
+
add_diagnostic(diagnostics, document, path, "Must be one of #{constraints[:enum].inspect}") if constraints[:enum] && !constraints[:enum].include?(value)
|
|
23
|
+
add_diagnostic(diagnostics, document, path, "Must be at least #{constraints[:minimum]}") if constraints[:minimum] && value.is_a?(Numeric) && value < constraints[:minimum]
|
|
24
|
+
add_diagnostic(diagnostics, document, path, "Must be at most #{constraints[:maximum]}") if constraints[:maximum] && value.is_a?(Numeric) && value > constraints[:maximum]
|
|
25
|
+
add_diagnostic(diagnostics, document, path, "Must be greater than #{constraints[:exclusive_minimum]}") if constraints[:exclusive_minimum] && value.is_a?(Numeric) && value <= constraints[:exclusive_minimum]
|
|
26
|
+
add_diagnostic(diagnostics, document, path, "Must be less than #{constraints[:exclusive_maximum]}") if constraints[:exclusive_maximum] && value.is_a?(Numeric) && value >= constraints[:exclusive_maximum]
|
|
27
|
+
add_size_diagnostics(diagnostics, document, path, value, constraints)
|
|
28
|
+
validate_container(rule, value, path, document, diagnostics, check_required: check_required)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def validate_container(rule, value, path, document, diagnostics, check_required: true)
|
|
32
|
+
type = matching_type(rule.field&.type || :object, value)
|
|
33
|
+
if type == :array
|
|
34
|
+
value.each_with_index { |entry, index| validate_rule(rule.item, entry, path + [index], document, diagnostics, check_required: check_required) } if rule.item
|
|
35
|
+
elsif type == :map
|
|
36
|
+
value.each do |key, entry|
|
|
37
|
+
if key.is_a?(String)
|
|
38
|
+
validate_rule(rule.item, entry, path + [key], document, diagnostics, check_required: check_required) if rule.item
|
|
39
|
+
else
|
|
40
|
+
add_diagnostic(diagnostics, document, path + [key], "Map keys must be Strings")
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
elsif type == :object
|
|
44
|
+
validate_object(rule, value, path, document, diagnostics, check_required: check_required)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def add_size_diagnostics(diagnostics, document, path, value, constraints)
|
|
49
|
+
return unless value.respond_to?(:length)
|
|
50
|
+
|
|
51
|
+
minimum = value.is_a?(String) ? constraints[:min_length] : constraints[value.is_a?(Array) ? :min_items : :min_properties]
|
|
52
|
+
maximum = value.is_a?(String) ? constraints[:max_length] : constraints[value.is_a?(Array) ? :max_items : :max_properties]
|
|
53
|
+
add_diagnostic(diagnostics, document, path, "Must contain at least #{minimum} entries") if minimum && value.length < minimum
|
|
54
|
+
add_diagnostic(diagnostics, document, path, "Must contain at most #{maximum} entries") if maximum && value.length > maximum
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def validate_object(rule, value, path, document, diagnostics, check_required: true)
|
|
58
|
+
rule.required.each do |name|
|
|
59
|
+
add_diagnostic(diagnostics, document, path + [name], "Required value is missing", path) unless value.key?(name)
|
|
60
|
+
end if check_required
|
|
61
|
+
value.each do |name, entry|
|
|
62
|
+
child = rule.children.find { |candidate| candidate.field.path.last == name }
|
|
63
|
+
child ||= rule.additional if rule.additional.is_a?(Rule)
|
|
64
|
+
add_diagnostic(diagnostics, document, path + [name], "Unknown field") if !child && !rule.additional
|
|
65
|
+
validate_rule(child, entry, path + [name], document, diagnostics, check_required: check_required) if child
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def add_diagnostic(diagnostics, document, path, message, range_path = path)
|
|
70
|
+
range = document && (document.range_of(range_path) || document.range_of(range_path[0...-1]))
|
|
71
|
+
diagnostics << Diagnostic.new(path: path.freeze, range: range, severity: :error, message: message)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def type_match?(type, value)
|
|
75
|
+
return type.any? { |entry| type_match?(entry, value) } if type.is_a?(Array)
|
|
76
|
+
|
|
77
|
+
case type
|
|
78
|
+
when :any then true
|
|
79
|
+
when :null then value.nil?
|
|
80
|
+
when :boolean then value.equal?(true) || value.equal?(false)
|
|
81
|
+
when :integer then value.is_a?(Integer)
|
|
82
|
+
when :number then value.is_a?(Numeric) && (!value.respond_to?(:finite?) || value.finite?)
|
|
83
|
+
when :string then value.is_a?(String)
|
|
84
|
+
when :array then value.is_a?(Array)
|
|
85
|
+
when :object, :map then value.is_a?(Hash)
|
|
86
|
+
else false
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def matching_type(type, value)
|
|
91
|
+
type.is_a?(Array) ? type.find { |entry| type_match?(entry, value) } : type
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kochab
|
|
4
|
+
# Typed settings metadata, source-aware validation, and safe layer merging.
|
|
5
|
+
class Schema
|
|
6
|
+
UNSET = Object.new.freeze
|
|
7
|
+
TYPES = %i[any array boolean integer map null number object string].freeze
|
|
8
|
+
Rule = Struct.new(:field, :children, :item, :additional, :required, :constraints,
|
|
9
|
+
:default_set, :default, keyword_init: true)
|
|
10
|
+
private_constant :UNSET, :TYPES, :Rule
|
|
11
|
+
|
|
12
|
+
def self.define(&block)
|
|
13
|
+
raise ArgumentError, "a schema block is required" unless block
|
|
14
|
+
|
|
15
|
+
builder = Builder.new
|
|
16
|
+
block.arity.zero? ? builder.instance_eval(&block) : block.call(builder)
|
|
17
|
+
new(rule(nil, :object, children: builder.rules))
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.from_json_schema(hash)
|
|
21
|
+
raise TypeError, "JSON Schema must be a Hash" unless hash.is_a?(Hash)
|
|
22
|
+
|
|
23
|
+
compiler = JsonCompiler.new(hash)
|
|
24
|
+
root = compiler.compile_root
|
|
25
|
+
new(root)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def initialize(root)
|
|
29
|
+
@root = root
|
|
30
|
+
validate_defaults!
|
|
31
|
+
@fields = collect_fields(root).freeze
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def defaults
|
|
35
|
+
copy(default_for(@root) || {})
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def describe(path)
|
|
39
|
+
rule = find_rule(path)
|
|
40
|
+
rule&.field
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def fields
|
|
44
|
+
@fields.dup
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
class << self
|
|
48
|
+
def rule(field, type, children: [], item: nil, additional: true, required: [], constraints: {}, default_set: false)
|
|
49
|
+
Rule.new(field: field, children: children.freeze, item: item, additional: additional,
|
|
50
|
+
required: required.freeze, constraints: constraints.freeze, default_set: default_set,
|
|
51
|
+
default: default_set ? field&.default : nil)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def rule_for_type(type, path)
|
|
55
|
+
type = normalize_type(type)
|
|
56
|
+
field = Field.new(path: frozen_copy(path), type: type, default: nil, items: nil, deprecated: false).freeze
|
|
57
|
+
rule(field, type)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def normalize_type(type)
|
|
61
|
+
unless type.is_a?(Array) || type.respond_to?(:to_sym)
|
|
62
|
+
raise ArgumentError, "Unsupported schema type #{type.inspect}"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
normalized = type.is_a?(Array) ? type.map { |entry| normalize_type(entry) }.freeze : type.to_sym
|
|
66
|
+
raise ArgumentError, "Unsupported schema type #{type.inspect}" if normalized.is_a?(Array) && normalized.empty?
|
|
67
|
+
|
|
68
|
+
valid = normalized.is_a?(Array) ? normalized.all? { |entry| TYPES.include?(entry) } : TYPES.include?(normalized)
|
|
69
|
+
raise ArgumentError, "Unsupported schema type #{type.inspect}" unless valid
|
|
70
|
+
|
|
71
|
+
normalized
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def type_of(value)
|
|
75
|
+
case value
|
|
76
|
+
when nil then :null
|
|
77
|
+
when true, false then :boolean
|
|
78
|
+
when Integer then :integer
|
|
79
|
+
when Numeric then :number
|
|
80
|
+
when String then :string
|
|
81
|
+
when Array then :array
|
|
82
|
+
when Hash then :object
|
|
83
|
+
else :any
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def public_item(rule)
|
|
88
|
+
rule.field&.type
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def copy(value)
|
|
92
|
+
case value
|
|
93
|
+
when Hash then value.to_h { |key, entry| [copy(key), copy(entry)] }
|
|
94
|
+
when Array then value.map { |entry| copy(entry) }
|
|
95
|
+
else value.dup
|
|
96
|
+
end
|
|
97
|
+
rescue TypeError
|
|
98
|
+
value
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def frozen_copy(value)
|
|
102
|
+
freeze_value(copy(value))
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def freeze_value(value)
|
|
106
|
+
case value
|
|
107
|
+
when Hash then value.each { |key, entry| freeze_value(key); freeze_value(entry) }
|
|
108
|
+
when Array then value.each { |entry| freeze_value(entry) }
|
|
109
|
+
end
|
|
110
|
+
value.freeze
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
private
|
|
115
|
+
|
|
116
|
+
def validate_defaults!
|
|
117
|
+
diagnostics = []
|
|
118
|
+
defaults = default_for(@root) || {}
|
|
119
|
+
validate_rule(@root, defaults, [], nil, diagnostics, check_required: false)
|
|
120
|
+
return if diagnostics.empty?
|
|
121
|
+
|
|
122
|
+
raise ArgumentError, "Invalid schema default at #{diagnostics.first.path.join(".")}: #{diagnostics.first.message}"
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def collect_fields(rule, seen = {})
|
|
126
|
+
return [] if seen[rule.object_id]
|
|
127
|
+
|
|
128
|
+
seen[rule.object_id] = true
|
|
129
|
+
own = rule.field ? [rule.field] : []
|
|
130
|
+
own + rule.children.flat_map { |child| collect_fields(child, seen) } +
|
|
131
|
+
(rule.item.is_a?(Rule) ? collect_fields(rule.item, seen) : []) +
|
|
132
|
+
(rule.additional.is_a?(Rule) ? collect_fields(rule.additional, seen) : [])
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def default_for(rule, ancestors = {})
|
|
136
|
+
return copy(rule.default) if rule.default_set
|
|
137
|
+
|
|
138
|
+
return unless [:object, :map].include?(rule.field&.type || :object)
|
|
139
|
+
return if ancestors[rule.object_id]
|
|
140
|
+
|
|
141
|
+
ancestors[rule.object_id] = true
|
|
142
|
+
values = rule.children.each_with_object({}) do |child, result|
|
|
143
|
+
value = default_for(child, ancestors)
|
|
144
|
+
result[child.field.path.last] = value unless value.nil? && !child.default_set
|
|
145
|
+
end
|
|
146
|
+
ancestors.delete(rule.object_id)
|
|
147
|
+
values.empty? ? nil : values
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def find_rule(path)
|
|
151
|
+
raise TypeError, "path must be an Array" unless path.is_a?(Array)
|
|
152
|
+
|
|
153
|
+
path.reduce(@root) do |rule, part|
|
|
154
|
+
return nil unless rule
|
|
155
|
+
type = rule.field&.type || :object
|
|
156
|
+
case type
|
|
157
|
+
when :object
|
|
158
|
+
rule.children.find { |child| child.field.path.last == part } || (rule.additional if rule.additional.is_a?(Rule))
|
|
159
|
+
when :map
|
|
160
|
+
return nil unless part.is_a?(String)
|
|
161
|
+
rule.item
|
|
162
|
+
when :array
|
|
163
|
+
return nil unless part.is_a?(Integer) && part >= 0
|
|
164
|
+
rule.item
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def copy(value) = self.class.copy(value)
|
|
170
|
+
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
require_relative "schema/builder"
|
|
175
|
+
require_relative "schema/json_compiler"
|
|
176
|
+
require_relative "schema/validation"
|
|
177
|
+
require_relative "schema/merge"
|
data/lib/kochab/version.rb
CHANGED
data/lib/kochab.rb
CHANGED
|
@@ -11,6 +11,9 @@ module Kochab
|
|
|
11
11
|
TextEdit = Struct.new(:offset, :length, :text, keyword_init: true)
|
|
12
12
|
Node = Struct.new(:kind, :range, :key_range, :value, :children,
|
|
13
13
|
:leading_comments, :trailing_comment, :key, :parent, keyword_init: true)
|
|
14
|
+
Field = Struct.new(:path, :type, :default, :description, :enum, :minimum, :maximum,
|
|
15
|
+
:items, :deprecated, keyword_init: true)
|
|
16
|
+
Diagnostic = Struct.new(:path, :range, :severity, :message, keyword_init: true)
|
|
14
17
|
|
|
15
18
|
class ParseError < StandardError
|
|
16
19
|
attr_reader :errors
|
|
@@ -79,3 +82,4 @@ end
|
|
|
79
82
|
require_relative "kochab/parser"
|
|
80
83
|
require_relative "kochab/document"
|
|
81
84
|
require_relative "kochab/editing"
|
|
85
|
+
require_relative "kochab/schema"
|
data/sig/kochab.rbs
CHANGED
|
@@ -42,6 +42,27 @@ module Kochab
|
|
|
42
42
|
def initialize: (?kind: Symbol, ?range: Range[Integer], ?key_range: Range[Integer]?, ?value: value, ?children: Array[Node], ?leading_comments: Array[Comment], ?trailing_comment: Comment?, ?key: (String | Integer)?, ?parent: Node?) -> void
|
|
43
43
|
end
|
|
44
44
|
|
|
45
|
+
class Field < Struct[untyped]
|
|
46
|
+
attr_accessor path: path
|
|
47
|
+
attr_accessor type: (Symbol | Array[Symbol])
|
|
48
|
+
attr_accessor default: value
|
|
49
|
+
attr_accessor description: String?
|
|
50
|
+
attr_accessor enum: Array[value]?
|
|
51
|
+
attr_accessor minimum: Numeric?
|
|
52
|
+
attr_accessor maximum: Numeric?
|
|
53
|
+
attr_accessor items: untyped
|
|
54
|
+
attr_accessor deprecated: bool
|
|
55
|
+
def initialize: (?path: path, ?type: (Symbol | Array[Symbol]), ?default: value, ?description: String?, ?enum: Array[value]?, ?minimum: Numeric?, ?maximum: Numeric?, ?items: untyped, ?deprecated: bool) -> void
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
class Diagnostic < Struct[untyped]
|
|
59
|
+
attr_accessor path: path
|
|
60
|
+
attr_accessor range: Range[Integer]?
|
|
61
|
+
attr_accessor severity: Symbol
|
|
62
|
+
attr_accessor message: String
|
|
63
|
+
def initialize: (?path: path, ?range: Range[Integer]?, ?severity: Symbol, ?message: String) -> void
|
|
64
|
+
end
|
|
65
|
+
|
|
45
66
|
class ParseError < StandardError
|
|
46
67
|
attr_reader errors: Array[Error]
|
|
47
68
|
def initialize: (Array[Error] errors) -> void
|
|
@@ -66,4 +87,25 @@ module Kochab
|
|
|
66
87
|
def insert: (path path, value value, ?after: String?) -> Array[TextEdit]
|
|
67
88
|
def format: (?indent: Integer, ?keep_blank_lines: Integer) -> String
|
|
68
89
|
end
|
|
90
|
+
|
|
91
|
+
class Schema
|
|
92
|
+
def self.define: () { (Builder) -> void } -> Schema
|
|
93
|
+
def self.from_json_schema: (Hash[String, value] hash) -> Schema
|
|
94
|
+
def validate: (Document document) -> Array[Diagnostic]
|
|
95
|
+
def defaults: () -> Hash[String, value]
|
|
96
|
+
def merge: (*(Document | Hash[String, value]) documents) -> Hash[String, value]
|
|
97
|
+
def describe: (path path) -> Field?
|
|
98
|
+
def fields: () -> Array[Field]
|
|
99
|
+
|
|
100
|
+
class Builder
|
|
101
|
+
def boolean: (String name, ?default: value, ?description: String?, ?deprecated: bool) -> untyped
|
|
102
|
+
def integer: (String name, ?default: value, ?description: String?, ?minimum: Numeric?, ?maximum: Numeric?, ?deprecated: bool) -> untyped
|
|
103
|
+
def number: (String name, ?default: value, ?description: String?, ?minimum: Numeric?, ?maximum: Numeric?, ?deprecated: bool) -> untyped
|
|
104
|
+
def string: (String name, ?default: value, ?description: String?, ?deprecated: bool) -> untyped
|
|
105
|
+
def enum: (String name, values: Array[value], ?default: value, ?description: String?, ?deprecated: bool) -> untyped
|
|
106
|
+
def object: (String name, ?default: value, ?description: String?, ?deprecated: bool) { (Builder) -> void } -> untyped
|
|
107
|
+
def array: (String name, items: (Symbol | Array[Symbol]), ?default: value, ?description: String?, ?deprecated: bool) -> untyped
|
|
108
|
+
def map: (String name, value: (Symbol | ^(Builder) -> void), ?default: value, ?description: String?, ?deprecated: bool) -> untyped
|
|
109
|
+
end
|
|
110
|
+
end
|
|
69
111
|
end
|
metadata
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: kochab
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Yudai Takada
|
|
8
|
+
autorequire:
|
|
8
9
|
bindir: bin
|
|
9
10
|
cert_chain: []
|
|
10
|
-
date:
|
|
11
|
+
date: 2026-09-15 00:00:00.000000000 Z
|
|
11
12
|
dependencies: []
|
|
12
13
|
description: A Ruby JSONC parser with byte ranges, syntax recovery, source queries,
|
|
13
|
-
minimal text edits, formatting,
|
|
14
|
+
minimal text edits, formatting, UTF-16 positions, and typed settings schemas. No
|
|
15
|
+
runtime gem dependencies.
|
|
14
16
|
email:
|
|
15
17
|
- t.yudai92@gmail.com
|
|
16
18
|
executables: []
|
|
@@ -25,6 +27,11 @@ files:
|
|
|
25
27
|
- lib/kochab/document.rb
|
|
26
28
|
- lib/kochab/editing.rb
|
|
27
29
|
- lib/kochab/parser.rb
|
|
30
|
+
- lib/kochab/schema.rb
|
|
31
|
+
- lib/kochab/schema/builder.rb
|
|
32
|
+
- lib/kochab/schema/json_compiler.rb
|
|
33
|
+
- lib/kochab/schema/merge.rb
|
|
34
|
+
- lib/kochab/schema/validation.rb
|
|
28
35
|
- lib/kochab/version.rb
|
|
29
36
|
- sig/kochab.rbs
|
|
30
37
|
homepage: https://github.com/noxdea/kochab
|
|
@@ -33,7 +40,9 @@ licenses:
|
|
|
33
40
|
metadata:
|
|
34
41
|
source_code_uri: https://github.com/noxdea/kochab
|
|
35
42
|
changelog_uri: https://github.com/noxdea/kochab/blob/main/CHANGELOG.md
|
|
43
|
+
allowed_push_host: https://rubygems.org
|
|
36
44
|
rubygems_mfa_required: 'true'
|
|
45
|
+
post_install_message:
|
|
37
46
|
rdoc_options: []
|
|
38
47
|
require_paths:
|
|
39
48
|
- lib
|
|
@@ -48,7 +57,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
48
57
|
- !ruby/object:Gem::Version
|
|
49
58
|
version: '0'
|
|
50
59
|
requirements: []
|
|
51
|
-
rubygems_version: 4.
|
|
60
|
+
rubygems_version: 3.4.19
|
|
61
|
+
signing_key:
|
|
52
62
|
specification_version: 4
|
|
53
|
-
summary:
|
|
63
|
+
summary: Source-aware JSONC parsing, editing, formatting, and schema validation
|
|
54
64
|
test_files: []
|