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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9a614b6dc35cf6bb101732526b9679f51fedd2f4b48a39737d7eca3161fcdbc9
4
- data.tar.gz: 055f80b9abb6f3e339bd5fb7949f53b0a83f7c542377b255d8a715205e0789c7
3
+ metadata.gz: 4e0eb6c563c42845bb7442c624883c75f2421a2298d4e721485433c2676a1abe
4
+ data.tar.gz: 46c35ab8d5de26c434723e04a6b8b6c22241b79346c0f2eef3e121b20dc2ac74
5
5
  SHA512:
6
- metadata.gz: fa6984ae1709b30cc8b0cfecaf09a52c03e493cc47f5f485f04ad6d084e1a3f452e3150ac09e3717eb111db3af397522560410bfacd4f8c11e22df69aee6b26b
7
- data.tar.gz: 524c84d838078662b7881f26f1ded5b5843a40c80a5fd5aea47d7d88c88638f1dbbdbee170f09470daa95c8fc6ba590ad2b837ab66ffc3918aed0ec975a350ed
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
- # Kochab
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
- JSONC parsing with byte ranges, error recovery, and edits that preserve the
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 are annotations written in a manuscript's margins. `gloss` and `glossa`
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
- ## Installation
45
+ ```ruby
46
+ gem "kochab"
47
+ ```
48
+
49
+ Then install:
50
+
51
+ ```sh
52
+ bundle install
53
+ ```
13
54
 
14
- From this checkout:
55
+ Or install it directly:
15
56
 
16
57
  ```sh
17
- gem build kochab.gemspec
18
- gem install ./kochab-0.1.0.gem
58
+ gem install kochab
19
59
  ```
20
60
 
21
- Or use `gem "kochab", path: "/path/to/checkout"` in your Gemfile.
61
+ Kochab requires Ruby 3.1 or later.
22
62
 
23
- ## Parse and update a setting
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
- ## Recovery and strict mode
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
- ## Minimal edits
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
- ## Validation and performance
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"
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Kochab
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
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.1.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: 1980-01-02 00:00:00.000000000 Z
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, and UTF-16 positions. No runtime gem dependencies.
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.0.19
60
+ rubygems_version: 3.4.19
61
+ signing_key:
52
62
  specification_version: 4
53
- summary: Recoverable JSONC parsing and edits that preserve comments and formatting
63
+ summary: Source-aware JSONC parsing, editing, formatting, and schema validation
54
64
  test_files: []